CC++ & Algorithm

自定义比较器与堆操作详解——打造专属排序规则

极难2
语言版本:通用
概述:在C++ STL中,priority_queue、sort等算法默认使用less比较器,但我们可以通过自定义比较函数或函数对象来改变排序规则。同时,STL还提供了make_heap、push_heap、pop_heap、sort_heap等底层堆操作函数,让你能够更灵活地使用堆。Python中的heapq也支持自定义比较(通过优先级元组或重写__lt__)。本文将深入讲解自定义比较器的原理及堆操作函数的用法。

自定义比较器与堆操作:让排序规则由你做主

从生活中的例子说起

班级里要排座位,老师本来想按身高从矮到高排,但同学们觉得应该按学号从大到小排。你看,同样的数据,不同的排序规则会得到完全不同的顺序。在编程中,我们也常常需要这样的“规则定制”:比如按学生成绩从高到低排序、按任务紧急程度优先处理、按字符串长度分组等。

比较器(Comparator) 就是这样一个“裁判”,它告诉程序:当两个元素站在一起时,谁应该排在前面。而 堆(Heap) 是一种特殊的完全二叉树,它天然要求“父节点比子节点大(最大堆)”或“父节点比子节点小(最小堆)”。STL 不仅提供了封装好的 priority_queue,还提供了 make_heappush_heappop_heapsort_heap 四个底层函数,让你能像搭积木一样灵活地操作堆。掌握这些,你就能在算法竞赛或日常编程中随心所欲地控制数据的“秩序”。


一、C++ 中的自定义比较器

1. 比较器到底是什么?

比较器是一个 可调用对象(函数指针、函数对象、lambda 表达式),它接收两个参数,返回 truefalse

  • 对于 sortmake_heap 等算法:如果比较器返回 true,表示第一个参数应该排在第二个参数之前(即更小)。
  • 对于 priority_queue:默认是最大堆,比较器返回 true 表示第一个参数的优先级更低(也就是排在后面)。

初学者最容易搞混的正是这个语义:同样的比较器在 sortpriority_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> 头文件中提供了四个操作堆的函数,它们可以直接用于 vectordeque 等随机访问容器,比 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_backpop_heap 只是把当前堆顶放到末尾,并调整剩余部分为堆,但并没有删除这个元素。如果不调用 v.pop_back(),容器长度不变,且末尾那个“已弹出”的元素还留在那里,后续操作(比如 sort_heap)会出错。
  • sort_heap 要求原容器已经是堆:如果容器不是堆,sort_heap 的行为是未定义的(可能崩溃或得到错误结果)。
  • 底层容器必须支持随机访问list 不能用堆函数,只能用 vectordeque 或数组。

三、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 就是让优先级大的对象“觉得自己更小”,从而先被弹出。


四、总结与几点提醒

  1. 比较器语义要分清

    • sort 中:比较器返回 true 表示第一个参数更小(应排在前面)。
    • priority_queue 中:比较器返回 true 表示第一个参数优先级更低(应排在后面)。默认 less 得到最大堆。
  2. priority_queue 比底层堆函数方便:如果只需要“取最大/最小”的操作,用 priority_queue 即可;如果需要遍历堆中所有元素或做排序,用 make_heap 等函数更灵活。

  3. pop_heap 后一定要 pop_back,否则堆中仍残留“已弹出”的元素,可能导致后续结果错误。

  4. sort_heap 只对堆有效,且排序后会破坏堆性质。

  5. Python 中重写 __lt__ 要小心:理解 heapq 的最小堆语义,必要时用元组加取反的方法更直观。


相关知识点指引

  • STL 容器 priority_queue:封装好的优先队列,适合快速插入和弹出最值。
  • STL 算法 sortnth_element:排序和部分排序,同样支持自定义比较器。
  • 关联容器 setmap:通过第二个模板参数自定义比较器,实现自定义排序。
  • C++ 函数对象和 lambda 表达式:编写比较器的两种主要方式。
  • 图算法(Dijkstra 最短路径):常用优先队列 + 自定义比较器(按距离小优先)。
  • Python 的 heapq.nlargest / heapq.nsmallest:直接支持 key 参数,无需重写 __lt__

掌握了这些,你就拥有了定制编程世界“秩序”的核心能力,无论是排序还是优先级队列,都能应对自如。

例题精讲

1单选题

在C++中,使用priority_queue创建一个存储int类型元素的小顶堆(元素优先队列,值越小优先级越高),以下哪个声明是正确的?

Apriority_queue<int, vector<int>, greater<int>> pq;
Bpriority_queue<int, vector<int>, less<int>> pq;
Cpriority_queue<int, vector<int>, greater<int>()> pq;
Dpriority_queue<int, vector<int>, greater> pq;
2单选题

关于C++ STL中的堆操作函数make_heap和pop_heap,下列说法正确的是?

Apop_heap执行后,堆的最大元素被移除,容器size减1。
Bpop_heap将堆顶元素移到序列末尾,然后对前n-1个元素重新调整成堆,但元素仍在容器中,需要手动调用pop_back删除。
Cmake_heap必须配合less比较器使用,否则无法正确建堆。
Dsort_heap可以对任意区间进行排序,不需要区间事先是堆。
3判断题

在Python中,使用heapq模块实现自定义优先级队列时,可以通过将元素包装成元组 (priority, item) 来间接实现自定义比较,也可以通过定义类并重写 __lt__ 方法使heapq按照自定义规则比较。

4填空题
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);
5填空题
给定一个vector<int> nums = {3,1,4,1,5,9,2,6},希望利用STL堆操作将其原地转换为一个小顶堆(最小堆),然后输出堆顶元素。请补全代码:

make_heap(nums.begin(), nums.end(), ___);
cout << nums[0]; // 输出堆顶元素