14. std::priority_queue 详解:优先队列的实现(堆)、自定义比较器、应用场景(Top-K 问题)

说到优先队列,我脑子里第一个蹦出来的场景就是:几年前做实时行情系统时,需要从每秒涌入的几万笔交易中,快速找出价格最高的前10笔。用普通数组排序?那性能直接崩了。用 std::priority_queue?嗯,几行代码搞定。

说白了,priority_queue 就是一个「谁优先级高谁先出」的队列。它底层用的是堆(heap),默认是大顶堆——也就是最大的元素永远在队首。

14.1 优先队列的本质:堆

你可能会问:为什么优先队列要用堆?我直接排序不行吗?

想想看,排序的时间复杂度是 O(n log n),而堆的插入和删除都是 O(log n)。如果你需要频繁地插入和取出最大/最小元素,堆的优势就非常明显了。我在项目中处理流式数据时,几乎只用堆,不用排序。

std::priority_queue 默认使用 std::vector 作为底层容器,配合 std::make_heapstd::push_heapstd::pop_heap 这些算法。你不需要手动调用这些函数,优先队列已经帮你封装好了。

核心要点:优先队列 = 容器适配器 + 堆算法。它不自己管理内存,而是包装了一个容器(默认 vector),然后在其上执行堆操作。

14.2 基本用法:从入门到上手

先看一个最简单的例子:

#include <iostream>
#include <queue>
#include <vector>

int main() {
    // 默认是大顶堆
    std::priority_queue<int> pq;

    pq.push(10);
    pq.push(30);
    pq.push(20);
    pq.push(5);

    std::cout << "队首元素: " << pq.top() << std::endl; // 输出 30

    pq.pop(); // 移除 30
    std::cout << "新的队首: " << pq.top() << std::endl; // 输出 20

    return 0;
}

输出结果:

队首元素: 30
新的队首: 20

嗯,这里要注意:priority_queue 没有 begin()end(),你不能遍历它。只能通过 top() 看队首,通过 push()pop() 操作。这是适配器的典型特征——只暴露最核心的接口。

14.3 自定义比较器:小顶堆与复杂类型

默认是大顶堆,那怎么变成小顶堆?很简单,用 std::greater<T>

#include <iostream>
#include <queue>
#include <vector>
#include <functional> // for std::greater

int main() {
    // 小顶堆:最小的元素在队首
    std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq;

    min_pq.push(10);
    min_pq.push(30);
    min_pq.push(20);
    min_pq.push(5);

    std::cout << "队首元素: " << min_pq.top() << std::endl; // 输出 5

    return 0;
}

模板参数有三个:元素类型、底层容器类型、比较器类型。很多人只写前两个,忘了第三个,结果编译报错。我刚开始学的时候也踩过这个坑。

如果是自定义类型呢?比如一个 Task 结构体,按优先级排序:

#include <iostream>
#include <queue>
#include <vector>

struct Task {
    int id;
    int priority;
    std::string description;
};

// 自定义比较器:优先级高的先出
struct CompareTask {
    bool operator()(const Task& a, const Task& b) {
        return a.priority < b.priority; // 大顶堆:priority 大的在队首
    }
};

int main() {
    std::priority_queue<Task, std::vector<Task>, CompareTask> task_queue;

    task_queue.push({1, 5, "低优先级任务"});
    task_queue.push({2, 10, "高优先级任务"});
    task_queue.push({3, 7, "中优先级任务"});

    std::cout << "队首任务ID: " << task_queue.top().id << std::endl; // 输出 2

    return 0;
}
个人习惯:我一般把比较器写成仿函数(functor),而不是 lambda。因为仿函数可以复用,而且代码更清晰。当然,C++20 之后你也可以用 lambda 做模板参数,但可读性嘛……见仁见智。

14.4 避坑指南:我踩过的三个坑

坑一:比较器方向搞反

我曾经在写 Top-K 问题时,想找最小的 K 个数,结果用了默认的大顶堆,取出来的全是最大的。记住:std::greater<T> 才是小顶堆。

坑二:忘记包含头文件

std::greater<functional> 里,不是 <queue>。我有一回在 Linux 下编译,报了一堆模板错误,找了半天才发现是头文件没包含。

坑三:自定义类型没有定义 operator<

如果你不想写仿函数,也可以直接在结构体里重载 operator<。但注意:默认是大顶堆,所以 operator< 返回 true 表示当前对象优先级更低。这个逻辑有点绕,我建议还是用仿函数,更直观。

14.5 应用场景:Top-K 问题

Top-K 是面试高频题,也是实际项目中的常客。比如:从海量日志中找出出现次数最多的 K 个 IP,或者从商品列表中找出价格最高的 K 个商品。

思路很简单:维护一个大小为 K 的小顶堆。遍历数据时,如果堆未满,直接插入;如果堆已满,且当前元素比堆顶大,则弹出堆顶,插入当前元素。这样遍历结束后,堆里就是最大的 K 个元素。

#include <iostream>
#include <queue>
#include <vector>
#include <functional>

std::vector<int> findTopK(const std::vector<int>& data, int k) {
    if (k <= 0) return {};

    // 小顶堆:堆顶是最小的,方便淘汰
    std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;

    for (int num : data) {
        if (min_heap.size() < k) {
            min_heap.push(num);
        } else if (num > min_heap.top()) {
            min_heap.pop();
            min_heap.push(num);
        }
    }

    // 把堆里的元素取出来
    std::vector<int> result;
    while (!min_heap.empty()) {
        result.push_back(min_heap.top());
        min_heap.pop();
    }
    // 因为是小顶堆,取出来是升序,可以反转一下
    std::reverse(result.begin(), result.end());
    return result;
}

int main() {
    std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
    int k = 3;
    auto top_k = findTopK(data, k);

    std::cout << "最大的 " << k << " 个数: ";
    for (int x : top_k) {
        std::cout << x << " ";
    }
    std::cout << std::endl; // 输出 9 6 5

    return 0;
}

时间复杂度是 O(n log k),空间复杂度是 O(k)。如果 k 远小于 n,这个方案非常高效。

注意:如果数据量极大(比如上亿条),连 vector 都装不下,那就得用外部排序 + 堆的混合方案了。不过那是另一个话题,这里不展开。

14.6 知识体系图

下面这张图帮你理清优先队列的核心脉络:

std::priority_queue 底层:堆(heap) 容器:vector(默认) 比较器:less(默认) push() → O(log n) pop() → O(log n) top() → O(1) size() → O(1) Top-K 问题 任务调度(优先级) Dijkstra 算法 自定义比较器:仿函数 / lambda / operator<

14.7 小结

优先队列是个小而美的工具。它不复杂,但用好了能解决很多实际问题。我个人觉得,掌握它最好的方式就是动手写一个 Top-K 的 demo,然后试着改一改比较器,看看行为怎么变。

记住三个关键点:底层是堆、默认是大顶堆、自定义比较器用仿函数。下次面试或者写代码时,你就能自信地说:「这个用优先队列,几行搞定。」


公众号:蓝海资料掘金营,微信deep3321