C++ priority_queue 与 pair 自定义排序:三种写法及方向详解
2026/9/13 14:24:06 网站建设 项目流程

先说我自己的感受:priority_queuepair这件事,几乎是每个写 C++ 图论和算法题的人都会撞上的墙。默认规则能跑,一旦要按second排、按权值排、或者做小顶堆,立刻一堆编译错误和诡异行为。这篇文章把我这几年在项目里和刷题时踩过的排序相关的坑、用过的三种写法、以及大小顶堆方向混淆的底层逻辑,一次性理清楚。

1. 问题场景:为什么 pair 会频繁出现在 priority_queue 里

1.1 默认排序的“意外”与局限

std::pair是 C++ 里最常见的一对一组合容器,priority_queue又是自带堆结构的优先级容器,两者结合最常见的场景就是——把“节点编号 + 权值”或者“起始点 + 终点”打包放进堆里。默认情况下,priority_queue直接用std::less来比较元素,而std::pair的比较规则是字典序:先比first,再比second

先看一段最简单的代码:

#include <iostream> #include <queue> #include <utility> int main() { std::priority_queue<std::pair<int, int>> pq; pq.push({1, 5}); pq.push({2, 4}); pq.push({1, 3}); pq.push({3, 1}); while (!pq.empty()) { auto [a, b] = pq.top(); std::cout << a << " " << b << "\n"; pq.pop(); } return 0; }

这段代码输出的顺序是什么?是3 12 41 51 3。为什么同样的first = 15排在3前面?因为pair先比firstfirst相等就比second。也就是说,默认规则下,大顶堆由first决定,first相同再接second

问题就来了:在很多实际场景里,我只想按second排序,或者想实现一个小顶堆。比如 Dijkstra 算法中,pair<int, int>的两个元素分别是“路径长度”和“节点编号”,如果first是路径长度,默认大顶堆就完全不符合需求,因为 Dijkstra 每次要取的是“当前距离最短”的节点,需要的是小顶堆,而且要按first排序。这时候你就必须自定义排序算法。

1.2 自定义排序的核心难点在哪

priority_queue的模板签名长这样:

template<class T, class Container = std::vector<T>, class Compare = std::less<typename Container::value_type>> class priority_queue;

三个模板参数分别是元素类型、底层容器、比较器。Compare是一个可调用对象,默认用std::less。很多人卡住的第一个点:Compare的参数写反了,或者 lambda 的写法不知道要传入构造函数。

第二个难点就是方向问题。这个我在第 3 节专门讲,这里先记住一个结论:priority_queue的比较器返回true时,表示第一个参数的优先级低于第二个参数,也就是它会待在堆的更下方。这个规则和std::sort完全相反,超多人在这里翻车。

第三个难点是pair本身不是自定义类型,你不能给它写成员形式的operator<,只能通过第三方的比较方式来影响堆的排序。所以接下来要讲的三种方式,本质上都是提供一个“外部比较器”。

2. 自定义排序的三把刀:仿函数、lambda、重载运算符

2.1 方法一:函数对象(仿函数)写法

函数对象是最经典、兼容性最好的写法。定义一个结构体,重载operator(),然后把结构体类型作为priority_queue的第三个模板参数。

比如我要实现一个“按second从小到大排”的堆,也就是second越小越靠顶:

#include <iostream> #include <queue> #include <utility> #include <vector> struct CmpBySecond { bool operator()(const std::pair<int, int>& a, const std::pair<int, int>& b) const { return a.second > b.second; // 注意:这是小顶堆逻辑 } }; int main() { std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, CmpBySecond> pq; pq.push({1, 5}); pq.push({2, 4}); pq.push({1, 3}); pq.push({3, 1}); while (!pq.empty()) { auto [a, b] = pq.top(); std::cout << a << " " << b << "\n"; pq.pop(); } return 0; }

输出是3 11 32 41 5。如果你想要大顶堆(second越大越靠顶),就把operator()里的>改成<

bool operator()(const std::pair<int, int>& a, const std::pair<int, int>& b) const { return a.second < b.second; // 大顶堆逻辑 }

这里有一个非常关键的点:如果你直接写return a.second > b.second,直觉上可能会觉得这是“升序”,但在priority_queue里,这个比较器会让second小的靠顶,也就是小顶堆效果。很多初学者在这里纠结半天,我建议直接做试验:写两个小例子,一个>一个<,记住结果即可。实践经验比死记规则更靠谱。

函数对象的优点是可以额外携带状态,比如根据某个全局标志位决定比较规则。缺点就是代码量稍微多几行,但可读性其实是最好的,团队协作时一眼就能看懂意图。

2.2 方法二:lambda 表达式写法

lambda 是写快速原型和刷题时最常用的方式。但必须注意,因为 lambda 的闭包类型是匿名的、不可默认构造的,所以你不能只把 lambda 类型传给模板参数,还必须把 lambda 对象传入构造函数

正确写法:

#include <iostream> #include <queue> #include <utility> #include <vector> int main() { auto cmp = [](const std::pair<int, int>& a, const std::pair<int, int>& b) { return a.second > b.second; // 小顶堆:second 小的靠顶 }; std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, decltype(cmp)> pq(cmp); // 必须把 cmp 传进去 pq.push({1, 5}); pq.push({2, 4}); pq.push({1, 3}); pq.push({3, 1}); while (!pq.empty()) { auto [a, b] = pq.top(); std::cout << a << " " << b << "\n"; pq.pop(); } return 0; }

错误写法:只写decltype(cmp)却不传cmp给构造函数,这会导致编译错误。因为 lambda 类型没有默认构造函数。这里很多人栽过,我当年也栽过,编译器的报错信息还特别长,容易让人懵。

如果 lambda 是空捕获列表[],也就是不捕获任何变量,那么实际上它在 C++20 之后是可以默认构造的,但为了兼容老标准,最好的习惯仍然是“无论是否捕获,都显式把 lambda 对象传给构造函数”。这样代码在所有启用 C++11 以上的环境都能稳定编译。

我个人的建议是:如果比较逻辑简单,lambda 可以;如果比较逻辑超过三行,或者要在多个地方复用,还是用函数对象吧,省得每次都要复制粘贴 lambda。

2.3 方法三:重载 operator<,让 pair 变成“可排序的节点”

第三种方式思路不同:不直接给pair写比较器,而是把pair包装成一个自定义结构体,并重载它的operator<。这样一来,堆本身根本不需要第三个模板参数,用默认的std::less就会自动调用结构体的<运算符。

#include <iostream> #include <queue> #include <vector> struct Node { int id; int dist; // 注意这个符号:我们希望 dist 越小越靠顶 bool operator<(const Node& other) const { return dist > other.dist; // 小顶堆效果 } }; int main() { std::priority_queue<Node> pq; // 不需要任何额外模板参数 pq.push({1, 5}); pq.push({2, 4}); pq.push({3, 1}); pq.push({4, 3}); while (!pq.empty()) { auto node = pq.top(); std::cout << node.id << " " << node.dist << "\n"; pq.pop(); } return 0; }

输出是3 14 32 41 5

这种方式写 Dijkstra 的堆优化时特别顺手,因为结构体里可以塞更多信息,比如除了iddist还想塞一个path标志位。直接用pair就太局促了。缺点也很现实:你必须修改节点的类型定义,有些场景下pair是既有的,不方便再包一层结构体,这时候就得回到前两种方式。

另外需要注意,operator<重载的语义必须是“严格弱序”,不能出现同时a < bb < a都为真的情况。用dist作为比较字段时,如果两个节点dist相等,比较器应该返回false,让堆认为它们“等价”,否则可能产生未定义行为。

我把三种方式的适用场景整理成了一张表,方便你根据实际情况快速决策:

方式核心写法适用场景是否需要额外模板参数可维护性
函数对象struct 重载 operator()多处复用、比较逻辑复杂、需携带状态
lambda声明 lambda + decltype刷题、局部使用、逻辑简单
重载 operator<自定义结构体Dijkstra、节点带额外属性、不想写第三个模板参数

3. 排序方向的底层逻辑:priority_queue 为什么和 sort“反着来”

3.1 堆的核心规则:比较函数决定的是“父节点优先权”

这一节是最容易让人混淆的地方。很多人在sort里写return a < b,知道这是升序,也就是小的在前。但到了priority_queue,天然以为return a < b是让“小的优先”,结果每次top()弹出的是最大的,直接懵。

要搞清楚这件事,得先理解堆的内部结构。priority_queue默认是最大堆,底层是一棵完全二叉树,存储在vector里。堆的性质是:父节点的优先级高于它的两个孩子节点。而这里的“高于”不是天然的大小关系,而是由你传入的Compare决定的。

Compare的定义规则是:Compare(a, b)返回true,表示a的优先级低于b,也就是说a应该排在b的下面。把这个规则放到堆排序里,每次堆顶元素是优先级最高的那个。

举个例子,默认的Comparestd::less<pair<int,int>>,它等价于a.first < b.first || (a.first == b.first && a.second < b.second)。这个表达式返回true时,表示ab“小”。但是因为返回true代表a优先级更低,所以堆会把“更大”的元素推到顶部。这就是为什么默认情况下3 1这样的 pair 会跑到堆顶。

如果你用std::greater<pair<int,int>>作为Compare,那么ab“大”时返回true,也就是a优先级更低,最终堆顶反而是“最小”的元素,形成小顶堆。这个逻辑你品一下,是不是和sort的方向完全反过来?

3.2 用模板参数推导记忆:less 是大顶堆,greater 是小顶堆

我总结了一套非常容易记忆的口诀:priority_queueCompare参数,写成less就是大顶堆,写成greater就是小顶堆。不管元素是int还是pair,这条都成立。

  • priority_queue<int>默认less<int>,堆顶是最大值。
  • priority_queue<int, vector<int>, greater<int>>,堆顶是最小值。
  • 对于pair<int, int>priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>>就是一个按字典序的小顶堆。first最小的靠顶,first相同则second最小的靠顶。

那么自定义排序时怎么反向设计?我来列一个思维框架:

  1. 先明确你想要什么样的堆:小顶堆还是大顶堆?
  2. 确定比较的字段:是pair.first还是pair.second,或者是它们的组合?
  3. 以你想放到堆顶的实体作为“高优先级”,反过来写比较器:如果想让a优先于b,则比较器要求Compare(a, b)返回falseCompare(b, a)返回true

举一个直观例子:我想让pair.second最小者置顶,也就是“second 越小,优先级越高”。按照上面第三条,asecondbsecond小,a应该优先,那么Compare(b, a)要返回true,即b.second > a.second为真。为了通用,我直接写return a.second > b.second;,含义是“如果 a 的 second 大于 b 的 second,说明 a 优先级更低”。这就是正确的写法,也印证了第 2 节里>对应小顶堆的说法。

我自己在写的时候还有一个习惯:注释里一定写清楚“小顶堆”“大顶堆”“按哪个字段排”,而不是只写代码。因为一周后回看代码,人很容易忘记当时设计的方向。

注意:sort的比较器与堆完全相反,二者千万不要互相套用。如果你在sort里写的升序比较器直接复制到priority_queue,得到的不是期望的降序,而是一个行为完全相反的堆。

4. 实战应用:Dijkstra 堆优化和 Top K 问题

4.1 Dijkstra:距离优先的 pair 排序

Dijkstra 是最经典的堆优化场景。算法要求每次选出“当前未访问节点中距离源点最近”的节点。这里你需要一个小顶堆,节点按照“当前距离”排序。

很多人的初始写法是这样的:

using PII = std::pair<int, int>; // {distance, node} std::priority_queue<PII, std::vector<PII>, std::greater<PII>> pq; pq.push({0, src});

这个写法本身没问题,因为greater<PII>会形成按first(距离)从小到大排列的堆,first相同再按node排。但如果你不对node的顺序有要求,这已经是最简洁的写法。问题出在下面场景:如果距离不是放在first,而是放在second,比如你想让节点编号占first,那么必须自定义比较器。

#include <iostream> #include <queue> #include <vector> #include <utility> #include <climits> using PII = std::pair<int, int>; struct Cmp { bool operator()(const PII& a, const PII& b) const { return a.second > b.second; // 按 second(距离)小顶堆 } }; void dijkstra(const std::vector<std::vector<PII>>& graph, int src) { int n = (int)graph.size(); std::vector<int> dist(n, INT_MAX); dist[src] = 0; std::priority_queue<PII, std::vector<PII>, Cmp> pq; // 或者 lambda 写法: // auto cmp = [](const PII& a, const PII& b) { return a.second > b.second; }; // std::priority_queue<PII, std::vector<PII>, decltype(cmp)> pq(cmp); pq.push({src, 0}); while (!pq.empty()) { auto [node, d] = pq.top(); pq.pop(); if (d != dist[node]) continue; // 剪枝,跳过过期元素 for (auto& [neighbor, weight] : graph[node]) { if (dist[node] + weight < dist[neighbor]) { dist[neighbor] = dist[node] + weight; pq.push({neighbor, dist[neighbor]}); } } } }

这里有几个细节值得关注:

  • if (d != dist[node]) continue;是一个“惰性删除”技巧。堆里可能残留过期的元素,它们的距离不是最新的,弹出时直接跳过。这个技巧避免了你手动维护一个“已删除”集合,代码更简洁。
  • pair 的顺序我故意设计成{node, dist},然后让比较器按second排序。这样在访问top()时,你可以直接用结构化绑定auto [node, d]取出节点和距离,语义比{dist, node}更符合直觉。
  • 如果你按{dist, node}存储再配合greater<PII>,虽然代码更少,但当有多个节点距离相同时,它们会按node再排一次,这通常是没必要的,反而会影响性能(虽然影响很小)。自定义比较器就可以完全按距离排,不关心第二个字段。

我实测过,在大规模图上(比如 10 万节点、20 万条边),这两种写法运行时间几乎无差别。但对于代码可读性来说,{node, dist}+ 自定义比较器更友好,因为top()取出来的第一个元素永远是节点编号,不需要再去记忆“第一个是距离还是节点”。这一点在写长算法时非常有帮助。

4.2 Top K:first 和 second 的优先级取舍

Top K 问题也是 priority_queue 的拿手好戏。给你一堆pair<int, int>,每个 pair 表示某个元素出现的次数(second)和它的编号(first),你需要找出出现次数最多的 K 个,怎么办?

直观思路是用一个小顶堆,堆内维护当前出现次数“最少”的元素,当堆大小超过 K 时弹出堆顶。这样就保证堆里始终是目前出现次数最大的 K 个。比较器按second排序,也就是出现次数。

#include <iostream> #include <queue> #include <vector> #include <utility> using PII = std::pair<int, int>; void topKFrequent(const std::vector<PII>& data, int k) { auto cmp = [](const PII& a, const PII& b) { return a.second > b.second; // 小顶堆:second 小的靠顶 }; std::priority_queue<PII, std::vector<PII>, decltype(cmp)> pq(cmp); for (auto& item : data) { pq.push(item); if ((int)pq.size() > k) { pq.pop(); // 弹出当前出现次数最少的 } } while (!pq.empty()) { auto [id, count] = pq.top(); std::cout << id << " " << count << "\n"; pq.pop(); } }

你可能会问:为什么不用大顶堆直接来top()?因为大顶堆只能帮你取到最大值,取不到第 K 大的。小顶堆维护窗口的代价更低,每次堆的大小不超过 K,插入和弹出的复杂度都是O(log K),整体是O(N log K)

这里真正考察你对Compare方向理解的地方是:cmp里写a.second > b.second,在小顶堆中,second最小的元素在堆顶,因而当堆满 K 个元素时,堆顶代表的是“K 个最大元素中最小的那一个”。这个过程既利用了priority_queue的自动调整,又通过自定义比较器把关注点集中在second上,first只是作为一个附带信息存着,不参与比较。

Top K 场景和 Dijkstra 场景有一点不同:Dijkstra 中你可能只关心firstsecond单一字段,Top K 则经常需要你“同时记录编号和次数”,这时候pair是天然的数据结构。自定义排序的价值在这里体现得非常直接:你完全掌控比较规则,不用为了迁就默认排序而强行把“次数”塞进first

5. 常见坑点与排查技巧实录

5.1 lambda 写法编译报错?decltype 类型不匹配

有一个非常典型的问题,很多初学者这样写:

auto cmp = [](const std::pair<int, int>& a, const std::pair<int, int>& b) { return a.second > b.second; }; // 错误:没有把 cmp 传给构造函数 std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, decltype(cmp)> pq;

在 C++20 之前,这会直接编译失败,报错信息大概类似use of deleted functionno matching constructor。原因就是 lambda 闭包类型通常不是默认构造的,priority_queue默认构造时无法构建比较器。

正确写法是:

std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, decltype(cmp)> pq(cmp);

还有一种情况是 lambda 捕获了外部变量:

int offset = 10; auto cmp = [&offset](const std::pair<int, int>& a, const std::pair<int, int>& b) { return a.second + offset > b.second + offset; };

如果不在构造函数里传入cmp,那问题更大,因为捕获了状态的 lambda 连类型都不一样了。凡是遇到 lambda 作为比较器,务必养成“初始化时把 lambda 对象传给构造函数”的肌肉记忆。

5.2 greater<pair<int, int>> 与自定义结构体的区别

std::greater<std::pair<int, int>>可以直接用,它的比较逻辑就是字典序的反转:先比first,再比secondfirst较小者靠顶。如果你对两个字段都无所谓排序顺序,直接用greater<pair<int,int>>最省事。

但如果你只想按second排序,greater<pair<int,int>>就做不到。原因是它无法只关注一个字段。很多人用greater试完之后发现排序结果不对,才回来学自定义比较器。

另外,std::pair自己有operator<operator==,但不建议你去重载std::pair的运算符,因为pairstd命名空间里,随意特化标准库的运算符可能导致未定义行为或代码污染。如果你想用重载operator<的方式,正确做法是像第 2.3 节那样,自己定义一个struct Node

5.3 比较器必须满足严格弱序

这是很多人忽略的坑。Compare必须满足“严格弱序”的要求,简单来说:

  • Compare(a, a)必须为false
  • 如果Compare(a, b)true,则Compare(b, a)必须为false
  • 传递性:如果a < bb < c,那么a < c应该成立。

举个反例:假如我写了一个比较器,当两个second相等时返回true,这就是严格弱序的叛徒,可能导致堆的关键操作出现未定义行为,轻则排序结果不符合预期,重则直接运行崩溃。

// 错误示例 struct BadCmp { bool operator()(const std::pair<int, int>& a, const std::pair<int, int>& b) const { return a.second >= b.second; // 相等时也返回 true } };

正确写法是return a.second > b.second;return a.second < b.second;,相等时返回false。所以写比较器的一个通用准则是:只在明确的前后关系下返回true,相等时一律返回false

如果你需要“先按第二字段降序,第二字段相同再按第一字段排”,比较器可以这样写:

struct Cmp { bool operator()(const std::pair<int, int>& a, const std::pair<int, int>& b) const { if (a.second != b.second) return a.second < b.second; // 大顶堆按 second return a.first < b.first; // second 相同时按 first 排序 } };

这种多重条件的比较器在真实项目里非常常见,比如任务调度,可能先按优先级,再按创建时间,再按任务 ID。pair只能放两个字段,真正复杂情况我更建议用struct来代替。

5.4 排查技巧:用打印法快速验证排序方向

当你不确定自己的排序方向对不对时,最快的排查方式不是看文档,而是写一个十行的小程序,往堆里塞三四个有对比性的元素,直接top()pop()打印出来。

我推荐的验证模板是这样的:

#include <iostream> #include <queue> #include <vector> #include <utility> void testPriorityQueue() { auto cmp = [](const std::pair<int, int>& a, const std::pair<int, int>& b) { return a.second > b.second; }; std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, decltype(cmp)> pq(cmp); pq.push({1, 10}); pq.push({2, 5}); pq.push({3, 8}); pq.push({4, 1}); while (!pq.empty()) { auto [id, val] = pq.top(); std::cout << id << "," << val << " "; pq.pop(); } std::cout << "\n"; }

如果输出是4,1 2,5 3,8 1,10,说明是小顶堆效果,符合a.second > b.second。如果输出是1,10 3,8 2,5 4,1,说明是大顶堆效果。

这个调试方法我强烈建议收藏。每次换比较器时你都跑一遍,五秒钟就能确认方向,不用靠猜。

5.5 老生常谈但很多人不知:比较器不能访问私有成员

如果你把pair换成自定的结构体,而结构体内有私有成员,比较器又定义在结构体外部,就可能导致访问权限错误。解决办法是:要么把成员设置为public,要么在结构体内部声明友元函数。实际中我一般让 Node 就是一个纯数据聚合体,所有字段public,根本不用搞什么封装。所谓“简单结构体”就是为了方便访问,没必要把简单问题复杂化。

6. 个人经验总结

如果你现在要我把这一整篇文章浓缩成几条可执行的建议,我会这么说:

  • 能用greater<pair<int,int>>解决就不自定义,比如只要按first小顶堆,直接一行搞定。priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>>是我刷算法题时最常用的模板之一。
  • 需要按second或组合排序时,优先用 lambda,代码短,意图直接;如果比较规则要在多个函数间复用,就改成函数对象。
  • Dijkstra 这种场景强烈建议自定义struct Node,重载operator<,把距离和节点 ID 做成字段,再顺手加上int id, dist;这样的语义化命名,阅读起来比pair<int,int>舒服得多。
  • 排序方向一定要靠验证来记,别靠背。写一个能打印的小堆,三分钟跑完就清楚了。之后这个方向感就会内化,再也不用每次踩坑。

最后再分享一个小技巧:priority_queue弹出的“最大值”或“最小值”其实取决于你传入的比较器,但pushpop的时间复杂度始终是O(log N)。也就是说,无论你怎么折腾比较器,堆的性能特性都不变,设计排序算法的自由度比你想象中要大得多。当你发现某些场景下比较器很别扭时,不要硬凑,回头想想是不是数据结构选型有问题,也许你需要的是set或者sortvector,不一定非得是堆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询