
最近在做一个日志分析工具要从大约五千万条访问记录里捞出 Top 20 的热门路径。第一反应是排序后来算了下账全量排序是 O(N log N)堆只需要 O(N log K)K 才 20差距不是一星半点。于是顺手用 C 的priority_queue把它收了。说真的priority_queue是 STL 里那种“看着平平无奇实际能顶半边天”的容器。但很多人学它的时候会被一个词卡住——堆。堆到底是内存里那个 new 出来的堆还是数据结构里的那个完全二叉树为什么明明叫priority_queue内部却偏偏用less默认搞出一个大根堆自定义比较器应该怎么写才不会反这篇就把priority_queue从概念到源码、从 API 到实战全捋一遍顺带把我踩过的一些坑也交代清楚。适合刚入门 C、准备笔试面试、或者已经在写服务端代码但想搞懂 STL 内部机制的朋友。看完整理好自己的笔记以后再遇到“动态取极值”“Top K”“合并有序序列”这类需求你第一反应应该就是它。1. 先把“堆”这个被用烂的词掰扯清楚很多初学者学priority_queue之前早已在报错信息里见过“堆”这个字。什么“编译器的堆空间不足”“进程堆大小调整为 8000还是报错 OutOfMemoryError”之类。这些说法里的“堆”指的是进程内存空间里动态分配内存的那个区域和今天要讲的数据结构堆是两码事。1.1 内存堆和数据堆只是名字撞车内存里的堆heap memory是进程运行时用来动态分配内存的地方。你在 C 里写new、malloc其实就是从这个区域划一块出来而栈stack则用来存放函数调用帧、局部变量等。两者的生命周期和管理方式完全不同栈由编译器自动分配回收堆上的对象得程序员自己管要么delete要么交给智能指针。热搜词里频繁出现的“堆外内存”“堆空间不足”之类的问题基本都是内存堆在闹脾气和priority_queue没有半点关系。数据结构里的堆binary heap是一种满足特殊顺序的完全二叉树通常用数组存储。它之所以也叫“堆”纯粹是历史遗留的习惯叫法。它能做到在 O(log N) 时间内插入元素、取出当前最大或最小的元素而查看当前极值只需要 O(1)。这就是优先级队列的基石。这两个概念撞车得厉害我第一次学的时候也晕。记住一个简单的分辨办法凡是说“堆内存”“堆溢出”“堆空间”的去查内存管理凡是说“大根堆”“小根堆”“堆排序”的去查数据结构。自测一下——priority_queue用的是哪个答案是数据结构堆。1.2 二叉堆的两个关键性质数据结构堆之所以高效靠两条硬规矩完全二叉树除了最后一层其它层节点都是满的最后一层的节点从左往右连续排列。这个性质保证了数组存储时不会出现空洞也保证了树的高度恒为 O(log N)。堆序每个父节点的值都不小于最大堆或不大于最小堆它的子节点。用数组存完全二叉树时省去了指针直接按下标访问。如果当前节点下标是 i那么左孩子是 2i1右孩子是 2i2父节点是 (i-1)/2。这组映射关系是理解后面所有操作的基础建议直接背下来。我之前看到有人问“为什么堆和栈要分开存”其实内存堆和栈的区别以及数据结构堆和栈的区别是 C 基础里最容易混的一对。不过今天聚焦优先队列内存那部分点到即止数据结构这边的堆才是主角。2. 三分钟上手priority_queue 的默认用法和小根堆priority_queue在queue头文件里是一个容器适配器。它不自己去管理存储而是包装了一个底层容器默认底层容器是vector。2.1 模板定义三个参数一个都不能少标准定义长这样templateclass T, class Container std::vectorT, class Compare std::lesstypename Container::value_type class priority_queue;三个模板参数分别是元素类型T、底层容器Container、比较器Compare。默认Compare是std::lessT看起来像是“从小到大”但实际构造出来的是大根堆——堆顶是最大的元素。这地方是初学者最容易懵的点节后细说。先看最常用的用法#include iostream #include queue #include vector int main() { std::priority_queueint pq; pq.push(3); pq.push(1); pq.push(4); pq.push(1); pq.push(5); while (!pq.empty()) { std::cout pq.top() ; pq.pop(); } return 0; }输出是5 4 3 1 1。默认情况下每次取出的都是当前最大的元素这就是大根堆。如果想取最小的语法上要把后两个模板参数都显式写出来#include functional std::priority_queueint, std::vectorint, std::greaterint minHeap; minHeap.push(3); minHeap.push(1); minHeap.push(4); // minHeap.top() 1注意std::greaterint需要包含functional头文件。漏掉这个头文件是新手最常见的问题之一编译时报greater is not a member of std加一行#include functional就好。2.2 API 一览与复杂度priority_queue的接口非常精简总共就那么几个方法操作方法时间复杂度取堆顶top()O(1)入队push(x)O(log N)出队pop()O(log N)原地构造入队emplace(args...)O(log N)判空empty()O(1)大小size()O(1)交换swap(other)O(1)emplace和push的区别主要体现在自定义类型上emplace直接把构造参数传进去在容器内部原地构造对象省掉一次临时对象的拷贝或移动。在存string、pair、自定义结构体的时候多用emplace是实打实的优化。2.3 一个最容易写反的直觉问题为什么std::less默认情况下却是大根堆这里给一个实操层面的记忆法把Compare当成排序用的比较器。std::sort用less排序结果是升序最大的元素排在最末尾。而priority_queue的语义是“每次从排序结果的末尾取元素当堆顶”所以less配出来就是大根堆。换句话说在priority_queue里Compare(a, b)返回true相当于告诉堆“a 应该排在 b 的后面优先级更低”。默认less大的元素被视为“排在后面”于是反复top()取到的就是最大的。用greater时反过来小元素“排在后面”堆顶就是最小的。这个记忆法不仅能解释默认行为自定义比较器的时候也能少走弯路想按某个字段优先级从高到低出队字段大的先出就写a.field b.field想字段小的先出就写a.field b.field。3. 内部到底怎么转的从 push_heap 到 pop_heappriority_queue本身不是一个全新的数据结构它只是把一个随机访问容器包装起来内部调用的是 STL 的另一组算法make_heap、push_heap、pop_heap。理解这些函数就等于看穿了它的底细。3.1 数组怎么表示一棵完全二叉树之前提到下标映射i的左孩子是2i1右孩子是2i2父节点是(i-1)/2。这就是整个堆的全部“结构信息”。假设数组是[9, 7, 8, 3, 1]那么下标 0 是根下标 1 和 2 是它的孩子下标 3 和 4 是下标 1 的孩子。画成树就是一棵完全二叉树且每个父节点都比子节点大。堆的调整操作无非是两种上浮sift up新元素插入到数组末尾不断和父节点比较。如果比父节点“优先级更高”就交换一路向上直到满足堆序。下沉sift down堆顶被取走后把末尾元素搬到堆顶然后不断和它“优先级更高”的那个孩子比较把它换下去直到子树重新满足堆序。3.2 push、pop 的源码级行为priority_queue::push(x)做了两件事在底层容器末尾push_back(x)调用std::push_heap(begin, end, comp)对[begin, end)区间做一次上浮调整。priority_queue::pop()的流程更讲究调用std::pop_heap(begin, end, comp)这个函数先把堆顶元素和区间末尾元素交换再对[begin, end-1)做下沉调整。执行完后原来的堆顶元素被“挪”到了容器末尾调用container.pop_back()把末尾那个元素真正删掉。所以pop()之后top()回到的是新的堆顶也就是当前“优先级最高”的元素。这两个操作的调整路径都是从根到叶子或从叶子到根完全二叉树高度是 O(log N)所以每次入队出队都是 O(log N)。空间上只多存了一个临时变量属于原地调整。3.3 make_heap为什么构造一个堆是 O(N)从vector直接构造priority_queue时内部调用了std::make_heap。如果对每个元素进行插入总复杂度是 O(N log N)。但make_heap不是从空堆开始逐个插入而是从最后一个非叶子节点开始从下往上对每个子树做下沉调整。这个调整总代价是 O(N)。给一个直觉解释假设堆高度为 h底层大约有 N/2 个节点它们作为“子树根”时高度是 1下沉最多走 1 步上一层的 N/4 个节点下沉最多走 2 步……总步数大约是 N/21 N/42 N/8*3 ...这个级数收敛到 O(N)。所以用priority_queue的时候如果一开始手里就有一整批数据直接用范围构造函数std::vectorint data {...}; std::priority_queueint, std::vectorint, std::greaterint pq(data.begin(), data.end());相比一个个push能省下一倍多的时间。别小看这个细节在数据量过百万的时候就非常明显了。3.4 为什么不直接用 vector 堆算法std::priority_queue能做的理论上std::vectormake_heap/push_heap/pop_heap都能做。但适配器把这个逻辑收拢在几个简洁的接口后面避免你在业务代码里反复写“先 push_back 再 push_heap”这种容易出错的组合。代价是灵活性受限制priority_queue不提供迭代器无法遍历、无法随机访问、无法直接修改内部元素。这是设计上的取舍不是缺陷。如果确实需要遍历或修改常见方案有两种直接用std::vector加堆算法手动管理用priority_queue的受保护成员c底层容器做个小继承扩展。第二种做法虽然能在调试时看到内部数据但只建议在调试或测试代码里用业务代码里这么干反而破坏了封装。4. 四个高频实战场景priority_queue 用的就是舒服接口再简单不会用等于零。这里整理了四个工作中、笔试里经常碰到的场景每一个都能直接用。4.1 场景一海量数据找 TopK这是priority_queue最经典的应用。五千万条日志找 Top 20如果先排序再取前 20内存和时间都很浪费。正确姿势是用一个大小为 K 的小根堆#include queue #include vector #include functional std::vectorint topK(const std::vectorint nums, int k) { if (k 0) return {}; std::priority_queueint, std::vectorint, std::greaterint pq; for (int x : nums) { if (static_castint(pq.size()) k) { pq.push(x); } else if (x pq.top()) { pq.pop(); pq.push(x); } } std::vectorint ans; while (!pq.empty()) { ans.push_back(pq.top()); pq.pop(); } return ans; }这个算法的核心逻辑小根堆里始终保存着当前最大的 K 个元素堆顶是这 K 个里最小的那个。新元素如果比堆顶大说明它有机会进入前 K于是弹出堆顶、加入新元素。最终堆里留下的就是前 K 大。注意这里要用小根堆而不是大根堆。有人第一次写会下意识用大根堆结果堆顶一直是全局最大根本没法判断新元素要不要替换。记住口诀找最大的 K 个用小根堆找最小的 K 个用大根堆。4.2 场景二合并 K 个有序序列多路归并也是priority_queue的主场。比如 K 个有序的数组合并成一个有序数组暴力做法是每个数组维护一个指针每次扫一遍找最小复杂度 O(K*N)。用小根堆可以把“找最小”这一步降到 O(log K)#include queue #include vector #include functional struct Node { int val; int row; int col; }; struct NodeCompare { bool operator()(const Node a, const Node b) const { return a.val b.val; // 注意小根堆val 小的先出 } }; std::vectorint mergeK(const std::vectorstd::vectorint arrs) { std::priority_queueNode, std::vectorNode, NodeCompare pq; for (int i 0; i static_castint(arrs.size()); i) { if (!arrs[i].empty()) { pq.push({arrs[i][0], i, 0}); } } std::vectorint ans; while (!pq.empty()) { Node cur pq.top(); pq.pop(); ans.push_back(cur.val); if (cur.col 1 static_castint(arrs[cur.row].size())) { pq.push({arrs[cur.row][cur.col 1], cur.row, cur.col 1}); } } return ans; }这个思路同样适用于合并 K 个有序链表、多个日志文件按时间戳合并等场景。核心是每次从堆顶拿到全局最小然后把该元素所在序列的下一个元素补进来。总复杂度 O(N log K)N 是全部元素个数。4.3 场景三数据流里的第 K 大 / 中位数很多系统处理的是流式数据数据源源不断进来要求实时回答“当前所有元素里第 K 大的数是多少”。每次都对全量数据排序显然不现实。这时候可以只维护一个大小为 K 的小根堆和 TopK 思路一致class KthLargest { public: KthLargest(int k) : k_(k) {} void add(int x) { if (static_castint(pq_.size()) k_) { pq_.push(x); } else if (x pq_.top()) { pq_.pop(); pq_.push(x); } } int value() const { return pq_.top(); } private: int k_; std::priority_queueint, std::vectorint, std::greaterint pq_; };求中位数则是双堆经典套路一个大根堆存较小的一半元素一个小根堆存较大的一半元素时刻保证两个堆的大小之差不超过 1。中位数要么是大根堆堆顶要么是两个堆顶的平均值。这个结构在实时监控、滑动窗口统计里很实用。4.4 场景四定时器与任务调度做服务端开发时经常要有“到点执行某件事”的需求。比如前阵子调 TDengine C 绑定的写入程序任务队列里就放了一把priority_queue按数据分片的优先级排队。如果用普通容器每次都要扫描一遍找最近到期的任务复杂度 O(N)数据一大就卡。用priority_queue按到期时间排序#include queue #include vector struct Task { int expireTime; int taskId; }; struct TaskCompare { bool operator()(const Task a, const Task b) const { return a.expireTime b.expireTime; // expireTime 小的先出 } }; class Timer { public: void addTask(const Task task) { queue_.push(task); } void run() { while (!queue_.empty()) { Task cur queue_.top(); if (cur.expireTime now()) { break; } queue_.pop(); execute(cur); } } private: int now() const; void execute(const Task task); std::priority_queueTask, std::vectorTask, TaskCompare queue_; };这种“每次取最近一个到期任务”的模型复杂度只有 O(log N)哪怕是几万个定时任务挂着也没压力。游戏服务端的技能冷却、消息队列的延迟消息底层基本都是这个思路。C 写的各种小游戏、事件模拟用priority_queue当事件队列也非常顺手。5. 自定义比较器真正进阶的分水岭默认的int排序大家都懂但现实中的元素往往是一个结构体。这时候自定义比较器就是绕不开的坎。5.1 写一个结构体比较器假设要管理一组带优先级的任务优先级高的先执行#include queue #include vector struct Task { int priority; int taskId; }; struct TaskCompare { bool operator()(const Task a, const Task b) const { return a.priority b.priority; // 注意这里 } }; std::priority_queueTask, std::vectorTask, TaskCompare taskQueue;return a.priority b.priority对应的是大根堆即 priority 大的先出队。如果想让 priority 小的先出队改成return a.priority b.priority。这里有一个非常好用的自检方法先想清楚“我需要堆顶是什么样的”再写比较器。比如我想让 priority 最大的排最上面那就想象有两个任务 a 和 ba.priority 比 b.priority 大那么 a 应该“排在优先位置”。比较器里写a.priority b.priority返回 true 表示 a 排在 b 后面所以 b 先出b 的 priority 更大正好对上。刚接触的时候最稳妥的方式是先背熟默认行为lessT是大根堆greaterT是小根堆。自定义比较器时结构体里仿函数operator()的写法与std::less/std::greater的语义保持一致——返回a b等价于less返回a b等价于greater。这样想最不容易出错。5.2 lambda 写法与 decltype 的坑不想额外定义结构体的话可以用 lambda#include queue #include vector auto comp [](const Task a, const Task b) { return a.priority b.priority; // 大根堆 }; std::priority_queueTask, std::vectorTask, decltype(comp) pq(comp);这里有几个关键点decltype(comp)取 lambda 的类型作为第三模板参数priority_queue的构造函数需要接收一个比较器实例所以末尾的(comp)不能省。因为 lambda 没有默认构造函数所以这种写法必须显式传入 comp否则编译直接报错。报错信息还特别隐晦经常指向queue头文件内部第一次遇到会一脸蒙。5.3 元素是指针时最容易踩的坑如果priority_queue存的是指针std::priority_queueTask* pq;默认比较的是指针本身的地址值不是Task的字段。排序结果完全随机毫无逻辑。要按真实内容排序得在比较器里解引用struct TaskPtrCompare { bool operator()(const Task* a, const Task* b) const { return a-priority b-priority; } };另外用裸指针时要注意所有权问题。priority_queue析构不会替你释放指针指向的对象该用unique_ptr或shared_ptr的地方别吝啬。智能指针的默认比较也支持按指针指向对象的顺序比较但如果你有自定义比较器同样需要自己写。6. 我踩过的坑priority_queue 问题速查用这么久priority_queue积累了一堆血泪教训。整理出来可能正好命中你正在遇到的问题。6.1 比较器方向写反这是最高频的坑没有之一。写自定义比较器时脑子里想着“priority 大的先出”手却写成了return a.priority b.priority结果变成小根堆。调试半天发现输出的顺序完全反了。我的习惯是写完比较器之后立刻往堆里塞三个不同优先级的元素打一遍top验证语义。这一步十秒都不要能省下几小时的排查时间。6.2 无法遍历和修改堆内元素priority_queue不提供迭代器想遍历、想修改里面的某个元素都做不到。之前调试时特别想看一眼堆里还剩什么最后用了继承访问底层容器的办法#include queue #include vector #include iostream template typename T struct DebugHeap : std::priority_queueT { using std::priority_queueT::c; }; int main() { DebugHeapint pq; pq.push(3); pq.push(1); pq.push(4); for (int x : pq.c) { std::cout x ; } return 0; }c是priority_queue的受保护成员指向底层容器。通过继承把它暴露出来就能在调试时打印了。但业务代码里不建议这么干它破坏了适配器的封装语义。更通用的做法是如果要频繁遍历或修改元素直接用std::vectormake_heap这一套效率和灵活性都更好。6.3 堆顶引用失效问题top()返回的是底层容器第一个元素的引用。这个引用在push或pop之后很可能失效因为底层vector可能重新分配内存或者堆调整改变了元素的物理位置。错误写法const int ref pq.top(); pq.pop(); // 用 ref —— 此时 ref 已经失效行为未定义正确做法是把值拷出来再操作int v pq.top(); pq.pop(); // 使用 v存自定义类型时同理优先auto v pq.top()拷贝一份而不是一直攥着引用不放。6.4 “修改”堆内元素的惰性删除方案有时候的需求是某个元素正在堆里排队但它的关键字段变了比如优先级提高了。priority_queue不支持随机访问和修改常规解法是引出“惰性删除”给元素加一个版本号或有效标记需要“修改”时不直接改堆内元素而是往堆里塞一个新元素把旧元素标记为失效出队时发现堆顶元素已失效直接丢弃继续取下一个。struct Item { int id; int priority; bool valid true; }; struct ItemCompare { bool operator()(const Item a, const Item b) const { return a.priority b.priority; } }; std::priority_queueItem, std::vectorItem, ItemCompare pq; // 想提高 id5 的优先级时直接把旧数据标记失效再塞入新的 pq.push({5, 100, true});堆里可能残留一些失效的旧元素但弹出的总代价仍然是摊销的 O(log N)。这是工程上很常用的技巧比强行实现堆内随机修改要简单得多。6.5 大对象拷贝导致的性能悬崖push的时候元素要拷贝进底层容器pop的时候又拷出来。如果元素是很大的结构体反复拷贝会产生不小的开销。两个手段可解能移动就移动pq.push(std::move(item))干脆存指针或智能指针只拷贝指针本身。另外底层vector扩容时会整体搬移所有元素如果大概知道数据量可以提前给底层容器reserve。但priority_queue构造时不好直接指定reserve更彻底的做法是先用普通vector装满并reserve再用它的迭代器构造priority_queue底层的vector被移动过来之前的容量也就保留下来了。6.6 头文件缺失和编译环境问题std::greater在functional里自定义比较器虽然不用额外头文件但如果你想用绑定表达式或函数指针也建议带上functional。漏头文件的报错容易误导人我第一次遇到greater is not a member of std时还以为是 STL 版本太老。顺带提一句环境问题如果你用的是 VS Code 配 C/C 环境经常出现“找不到标准库头文件”或“cannot open source file queue”多半是编译器的 include 路径没有配置好要么装了 Microsoft Visual C 运行库但是编译器路径没指对要么没装完整的 Build Tools。这些和priority_queue本身无关但会卡在环境上让人误以为是代码问题。建议先确认 GCC/MSVC 能独立编译一个最简单的hello.cpp再回来调业务代码。7. 一道真题把 priority_queue 彻底焊进脑子里讲完理论来道经典题收尾。很多 C 竞赛、认证考试都喜欢出这类优先队列入门题比如“有一堆小木块每次可以合并任意两堆消耗的体力等于两堆数量之和求最小总消耗”。这种题的暴力做法每次找最小的两堆但数据一大就超时标准解法就是小根堆。题目简化版有 n 堆果子每次挑两堆合并消耗的体力等于两堆数量之和问把所有果子合并成一堆的最小总消耗。思路非常直接把所有堆的大小放进小根堆每次弹出两个最小的堆合并消耗累加到答案把合并后的新堆重新入堆重复直到只剩一堆。代码长这样#include iostream #include queue #include vector #include functional int main() { int n; std::cin n; std::priority_queueint, std::vectorint, std::greaterint pq; for (int i 0; i n; i) { int x; std::cin x; pq.push(x); } long long total 0; while (pq.size() 1) { int a pq.top(); pq.pop(); int b pq.top(); pq.pop(); total a b; pq.push(a b); } std::cout total \n; return 0; }这个例子可以说是priority_queue的“体检套餐”小根堆的构建、取堆顶、出队、入队全部覆盖而且每一步操作的理由都一目了然——为什么要取最小的两堆因为贪心策略下小的果子越早被合并参与合并的次数就越多但整体代价反而最小。这个贪心的正确性在算法课本里叫 Huffman 编码的贪心性质和霍夫曼树的构造过程本质上是一回事。刷题的时候看到“1000ms 16MB”这样的限制第一件事就是估算复杂度。如果 n 到 10^5 量级O(N^2) 的暴力必然超时而小根堆方案是 O(N log N)内存只有 O(N)随便过。这也是一个判断经验凡是“每次需要取当前最小/最大”的动态过程先怀疑能用priority_queue。我个人在实际开发里的习惯是凡是遇到 TopK、任务调度、流式极值这类需求第一选择不是排序而是priority_queue。排序适合一次性把数据全部排好而优先队列适合数据动态变化、每次只取一个极值的场景。五千万条日志取 Top 20 的时候这俩的差距不是几毫秒而是几十倍的耗时差异。最后再分享一个调优小技巧priority_queue构造的时候如果你已经有一整个vector的数据千万别一个个push用迭代器范围构造直接一次性make_heap能把构造时间从 O(N log N) 降到 O(N)。这个细节我见过不少人不知道知道之后都感叹白推了那么多次。整体上priority_queue是那种看似简单、实则每个细节都能抠出知识点来的 STL 组件。把它吃透笔试面试能用工程实践更能用这波不亏。