自定义比较器与堆操作详解——打造专属排序规则
极难2自定义比较器与堆操作:让排序规则由你做主
从生活中的例子说起
班级里要排座位,老师本来想按身高从矮到高排,但同学们觉得应该按学号从大到小排。你看,同样的数据,不同的排序规则会得到完全不同的顺序。在编程中,我们也常常需要这样的“规则定制”:比如按学生成绩从高到低排序、按任务紧急程度优先处理、按字符串长度分组等。
比较器(Comparator) 就是这样一个“裁判”,它告诉程序:当两个元素站在一起时,谁应该排在前面。而 堆(Heap) 是一种特殊的完全二叉树,它天然要求“父节点比子节点大(最大堆)”或“父节点比子节点小(最小堆)”。STL 不仅提供了封装好的 priority_queue,还提供了 make_heap、push_heap、pop_heap、sort_heap 四个底层函数,让你能像搭积木一样灵活地操作堆。掌握这些,你就能在算法竞赛或日常编程中随心所欲地控制数据的“秩序”。
一、C++ 中的自定义比较器
1. 比较器到底是什么?
比较器是一个 可调用对象(函数指针、函数对象、lambda 表达式),它接收两个参数,返回 true 或 false。
- 对于
sort、make_heap等算法:如果比较器返回true,表示第一个参数应该排在第二个参数之前(即更小)。 - 对于
priority_queue:默认是最大堆,比较器返回true表示第一个参数的优先级更低(也就是排在后面)。
初学者最容易搞混的正是这个语义:同样的比较器在 sort 和 priority_queue 中“效果相反”。比如 greater<int>() 在 sort 中表示升序(小在前),但在 priority_queue 中表示最小堆(堆顶最小)。下面我们会用例子说清楚。
2. 三种使用自定义比较器的方式
假设我们有一个学生结构体,包含姓名和年龄,希望按年龄从大到小排序(即年龄大的同学优先级高,先被处理)。
方法一:重载 operator<
在结构体内部直接定义 < 运算符,这样默认的 less 比较器就会使用它。
#include <iostream>
#include <queue>
#include <vector>
#include <string>
using namespace std;
struct Student {
string name; // 姓名
int age; // 年龄
// 重载小于号:让年龄大的同学被视作“小于”年龄小的
// 注意:priority_queue 默认是最大堆,它会认为“大”的优先级高
// 所以这里我们让 age 大的返回 true(即“我比你小”,从而被堆顶优先弹出)
bool operator<(const Student& other) const {
return age < other.age; // 年龄大 > 年龄小,所以年龄大的在堆顶
}
};
int main() {
priority_queue<Student> pq; // 默认使用 less<Student>,调用 operator<
pq.push({"Alice", 12}); // 姓名 Alice,年龄 12
pq.push({"Bob", 10}); // 姓名 Bob,年龄 10
pq.push({"Charlie", 15}); // 姓名 Charlie,年龄 15
while (!pq.empty()) {
Student top = pq.top();
cout << top.name << " " << top.age << endl;
pq.pop();
}
return 0;
}
// 输出(年龄从大到小):
// Charlie 15
// Alice 12
// Bob 10
新手常见错误:把 operator< 写反了。比如写成 return age > other.age;,那么年龄大的反而会被认为“更大”,在最大堆中会被放到底部,结果反而变成年龄从小到大。
方法二:自定义函数对象(仿函数)
如果不想修改结构体本身,可以定义一个独立的 “函数对象”(一个重载了 operator() 的类)。
#include <iostream>
#include <queue>
#include <vector>
#include <string>
using namespace std;
struct Student {
string name;
int age;
};
// 定义一个函数对象:按年龄从小到大(即最小堆)
struct CompareByAgeAsc {
// 括号运算符:判断 a 的优先级是否低于 b
// 如果返回 true,表示 a 比 b 优先级低(a 应该排在 b 后面)
bool operator()(const Student& a, const Student& b) const {
return a.age > b.age; // 年龄大的优先级低 → 年龄小的优先级高 → 最小堆
}
};
int main() {
// 模板参数:元素类型、底层容器、比较器类型
priority_queue<Student, vector<Student>, CompareByAgeAsc> pq;
pq.push({"Alice", 12});
pq.push({"Bob", 10});
pq.push({"Charlie", 15});
while (!pq.empty()) {
auto s = pq.top();
cout << s.name << " " << s.age << endl;
pq.pop();
}
return 0;
}
// 输出(年龄从小到大):
// Bob 10
// Alice 12
// Charlie 15
小提示:函数对象里
a.age > b.age为什么代表最小堆?因为priority_queue的规则是:如果比较器返回 true,则 a 的优先级比 b 低。我们希望年龄小的优先级高,所以当 a 年龄大于 b 时,a 优先级低,返回 true。因此堆顶总是年龄最小的。
方法三:使用 lambda 表达式
如果比较逻辑只在一个函数内使用,用 lambda 最简洁。
#include <iostream>
#include <queue>
#include <vector>
#include <string>
using namespace std;
struct Student {
string name;
int age;
};
int main() {
// auto 推导 lambda 类型
auto cmp = [](const Student& a, const Student& b) {
return a.age > b.age; // 最小堆
};
// 注意:模板参数用 decltype(cmp),并传入 lambda 对象
priority_queue<Student, vector<Student>, decltype(cmp)> pq(cmp);
pq.push({"Alice", 12});
pq.push({"Bob", 10});
pq.push({"Charlie", 15});
while (!pq.empty()) {
auto s = pq.top();
cout << s.name << " " << s.age << endl;
pq.pop();
}
return 0;
}
// 同样输出最小堆
注意事项:lambda 表达式没有默认构造函数,所以必须在 priority_queue 的构造函数中传入 lambda 对象。如果漏了 pq(cmp),会编译错误。
二、C++ 底层堆操作函数(make_heap, push_heap, pop_heap, sort_heap)
STL 在 <algorithm> 头文件中提供了四个操作堆的函数,它们可以直接用于 vector、deque 等随机访问容器,比 priority_queue 更灵活——因为你可以随时查看或修改堆中的任意元素(只要保持堆性质)。
1. 函数一览
| 函数 | 作用 | 时间复杂度 |
|---|---|---|
make_heap(begin, end) | 将区间 [begin, end) 变成堆(默认最大堆) | O(n) |
push_heap(begin, end) | 将最后一个元素插入到堆中(要求前 n-1 个已经是堆) | O(log n) |
pop_heap(begin, end) | 将堆顶(最大)元素移到最后一个位置,并调整堆(但不删除) | O(log n) |
sort_heap(begin, end) | 对堆进行排序(会破坏堆性质),得到升序序列 | O(n log n) |
所有这些函数都可以接受一个比较器作为第三个参数,实现最小堆或自定义规则。
2. 完整示例(默认最大堆)
#include <iostream>
#include <algorithm> // for heap functions
#include <vector>
using namespace std;
int main() {
vector<int> v = {30, 10, 50, 20, 40};
// 1. 建堆(最大堆)
make_heap(v.begin(), v.end());
cout << "初始堆: ";
for (int x : v) cout << x << " "; // 堆顶是最大值 50
cout << endl;
// 2. 插入新元素:先添加到最后,再 push_heap
v.push_back(45);
push_heap(v.begin(), v.end());
cout << "插入45后: ";
for (int x : v) cout << x << " "; // 堆顶现在是 50
cout << endl;
// 3. 弹出堆顶元素(移到末尾,然后手动删除)
pop_heap(v.begin(), v.end()); // 50 被移到 v.back()
int max_val = v.back(); // 取出最大值
v.pop_back(); // 真正删除
cout << "弹出最大元素 " << max_val << " 后: ";
for (int x : v) cout << x << " ";
cout << endl;
// 4. 堆排序(升序)
sort_heap(v.begin(), v.end()); // 变成升序(注意:会破坏堆性质)
cout << "堆排序后: ";
for (int x : v) cout << x << " "; // 输出: 20 30 40 45 (假设之前元素)
cout << endl;
return 0;
}
3. 自定义比较器实现最小堆
只需要在所有函数后添加 greater<int>() 第三个参数即可:
vector<int> v = {30, 10, 50, 20, 40};
make_heap(v.begin(), v.end(), greater<int>());
// 现在 v 是最小堆,堆顶是 10
// 后续的 push_heap、pop_heap 也都要传 greater<int>()
4. 新手常见错误
- pop_heap 后忘记 pop_back:
pop_heap只是把当前堆顶放到末尾,并调整剩余部分为堆,但并没有删除这个元素。如果不调用v.pop_back(),容器长度不变,且末尾那个“已弹出”的元素还留在那里,后续操作(比如sort_heap)会出错。 - sort_heap 要求原容器已经是堆:如果容器不是堆,
sort_heap的行为是未定义的(可能崩溃或得到错误结果)。 - 底层容器必须支持随机访问:
list不能用堆函数,只能用vector、deque或数组。
三、Python 中如何自定义堆比较
Python 的 heapq 模块只支持最小堆,且不能像 C++ 那样传入比较器函数。常用的技巧有:
1. 使用元组(优先级, 任务)
将排序键放在元组第一位,堆会自动按元组比较(先比较第一个元素,再比较第二个)。
import heapq
# 存储 (优先级, 任务)
heap = []
heapq.heappush(heap, (3, "写作业"))
heapq.heappush(heap, (1, "紧急任务"))
heapq.heappush(heap, (2, "看书"))
while heap:
print(heapq.heappop(heap))
# 输出:(1, '紧急任务'), (2, '看书'), (3, '写作业')
如果想实现最大堆,可以将优先级取反:
heapq.heappush(heap, (-10, "紧急"))
2. 重写对象的 __lt__ 方法
heapq 在堆化元素时会用 < 运算符比较对象,因此可以通过定义 __lt__ 来控制排序。
import heapq
class Task:
def __init__(self, name, priority):
self.name = name # 任务名
self.priority = priority # 优先级(数值越大越紧急)
def __lt__(self, other):
# 我们想让优先级大的任务先弹出(即认为“大”的反而“小”)
# 因为 heapq 是最小堆,__lt__ 返回 True 表示 self 比 other “小”,先弹出
return self.priority > other.priority
def __repr__(self):
return f"{self.name}({self.priority})"
tasks = [Task("吃饭", 5), Task("睡觉", 2), Task("紧急", 10)]
heapq.heapify(tasks)
while tasks:
print(heapq.heappop(tasks))
# 输出(按优先级从大到小):
# 紧急(10)
# 吃饭(5)
# 睡觉(2)
注意:重写 __lt__ 时要非常小心:heapq 的“最小堆”总是弹出最小的元素,而“最小”由 < 定义。如果你想让优先级大的先出来,必须让优先级大的对象被 < 判断为 更小。上面的例子中 self.priority > other.priority 就是让优先级大的对象“觉得自己更小”,从而先被弹出。
四、总结与几点提醒
-
比较器语义要分清:
- 在
sort中:比较器返回true表示第一个参数更小(应排在前面)。 - 在
priority_queue中:比较器返回true表示第一个参数优先级更低(应排在后面)。默认less得到最大堆。
- 在
-
priority_queue比底层堆函数方便:如果只需要“取最大/最小”的操作,用priority_queue即可;如果需要遍历堆中所有元素或做排序,用make_heap等函数更灵活。 -
pop_heap后一定要pop_back,否则堆中仍残留“已弹出”的元素,可能导致后续结果错误。 -
sort_heap只对堆有效,且排序后会破坏堆性质。 -
Python 中重写
__lt__要小心:理解heapq的最小堆语义,必要时用元组加取反的方法更直观。
相关知识点指引
- STL 容器
priority_queue:封装好的优先队列,适合快速插入和弹出最值。 - STL 算法
sort、nth_element:排序和部分排序,同样支持自定义比较器。 - 关联容器
set、map:通过第二个模板参数自定义比较器,实现自定义排序。 - C++ 函数对象和 lambda 表达式:编写比较器的两种主要方式。
- 图算法(Dijkstra 最短路径):常用优先队列 + 自定义比较器(按距离小优先)。
- Python 的
heapq.nlargest/heapq.nsmallest:直接支持 key 参数,无需重写__lt__。
掌握了这些,你就拥有了定制编程世界“秩序”的核心能力,无论是排序还是优先级队列,都能应对自如。
例题精讲
在C++中,使用priority_queue创建一个存储int类型元素的小顶堆(元素优先队列,值越小优先级越高),以下哪个声明是正确的?
关于C++ STL中的堆操作函数make_heap和pop_heap,下列说法正确的是?
在Python中,使用heapq模块实现自定义优先级队列时,可以通过将元素包装成元组 (priority, item) 来间接实现自定义比较,也可以通过定义类并重写 __lt__ 方法使heapq按照自定义规则比较。
C++中,已有lambda表达式作为比较器,用于priority_queue小顶堆(按Point的x坐标降序,即x大的优先级高)。请补全声明:
struct Point { int x, y; };
auto cmp = [](const Point& a, const Point& b) { return a.x < b.x; }; // 注意:返回true表示a优先级低于b,因此x大的优先级高
priority_queue<Point, vector<Point>, ___> pq(cmp);给定一个vector<int> nums = {3,1,4,1,5,9,2,6},希望利用STL堆操作将其原地转换为一个小顶堆(最小堆),然后输出堆顶元素。请补全代码:
make_heap(nums.begin(), nums.end(), ___);
cout << nums[0]; // 输出堆顶元素