说到C++标准库里的这两个容器,很多人的第一反应是“priority_queue不就是堆吗,deque就是双端队列”,然后用的时候才发现一堆坑。我这几年代码写下来,见过太多人在priority_queue的自定义排序上栽跟头,也见过不少把deque当成vector使导致性能崩掉的案例。今天就把这两个容器的使用细节和底层实现一次讲透,特别是priority_queue默认底层容器是vector而不是deque这个容易混淆的点,以及deque那套分段内存结构到底是怎么回事,都会拆开揉碎了聊。
这篇文章适合两类人:一类是刚学STL没多久、想搞懂容器适配器到底是个什么玩意的初学者;另一类是工作中要用priority_queue做任务调度、用deque做滑动窗口或双端缓存,想避开性能陷阱的开发者。看完之后你不仅能正确使用这两个容器,还能理解它们背后的设计逻辑,遇到奇奇怪怪的问题时知道往哪个方向排查。
1. 整体设计与思路拆解:为什么这俩容器总是被放在一起聊
1.1 先搞清楚“容器适配器”和“真正的容器”的区别
priority_queue在我们嘴上经常被叫成“容器”,但严格来说它是个容器适配器。这个“适配器”三个字非常关键。适配器的意思是:它自己不实际存储数据,而是坐在另一个真实容器的头上,把那个容器的接口改造成一副全新的面孔。
deque则是一个真正的底层容器,它自己管内存、存元素,算法层可以直接操作它。priority_queue默认坐在vector上,把vector包装成了“只能从队头取最大/最小元素、只能从队尾插入元素”的受限接口。所以priority_queue的三个模板参数长这样:
template< class T, class Container = std::vector<T>, class Compare = std::less<typename Container::value_type> > class priority_queue;第二个参数Container就是底层的那个真实容器,默认vector。第三个参数是仿函数,决定你是大顶堆还是小顶堆。
**为什么默认选vector而不是deque?**很多人的直觉是:既然priority_queue叫“队列”,那底层应该用deque吧?但实际默认是vector。原因有两个。
第一,堆调整(sift up / sift down)需要频繁的随机访问中间位置的元素。堆用数组方式存储时,父子节点的关系是纯数学计算:父节点下标i,左孩子是2i+1,右孩子是2i+2。vector的随机访问是O(1),而deque虽然是分段连续内存,也能做到O(1)随机访问,但每次访问都比vector多一级间接跳转(要先把下标换算成块内偏移),常数开销更大。
第二,vector的迭代器失效规则简单,堆算法里大量使用迭代器移动,vector上更安全。deque在中间插入元素会导致迭代器失效,而且失效规则比vector复杂,用它做堆的载体,等于给自己挖坑。
也就是说,STL选择vector作为priority_queue的默认底层,不是随手选的,是综合考虑了随机访问性能、缓存友好性和迭代器安全性之后的结果。这一点在面试里经常被问到,也是很多人理解偏差最大的地方。
1.2 这两个容器的适用场景有本质上的区别
我给很多新人讲过一句话:priority_queue解决的是“每次只关心最大/最小的那一个”的问题,deque解决的是“两端都要频繁进出”的问题。
priority_queue典型场景:
- 任务调度器,每次取优先级最高的任务执行
- Top K问题,维护一个K大小的堆
- 合并K个有序链表,每次取最小的那一个
- 中位数维护,左大半用大顶堆,右半用小顶堆
deque典型场景:
- 滑动窗口最大值/最小值
- 双端缓存,比如浏览器历史记录(前进后退)
- 工作队列,任务可以从头部或尾部插入
- 在序列头部也有频繁插入需求时,替代vector
两者经常被放在一起讨论,还有一个重要原因:priority_queue的模板参数允许你显式传入deque作为底层容器,这也是官方文档明确支持的一种用法。当你的元素不只是简单的int,而是一个体积较大的结构体,并且入队出队操作很频繁,用deque做底层可以减少vector扩容时的元素搬运开销。不过这个优化需要根据实际数据量来判断,后面我会专门讲。
2. 核心接口与基础使用:别在简单的API上栽跟头
2.1 priority_queue的标准接口和三个最容易用错的点
priority_queue的接口非常少:push、pop、top、empty、size。就这么几个,但越简单的接口越容易用出问题。
**易错点一:top()返回的是const T&。**你可能想通过top()修改堆顶元素的值,比如把优先级改高了再让它自动调整。对不起,做不到。priority_queue不提供任何修改元素的入口,因为一旦修改了元素的值,它可能不再满足堆的性质,而priority_queue又不会自动重新调整。这就像你在一摞按大小排好的扑克牌最上面换了一张牌,整摞牌就乱了一样。如果你确实需要修改堆中元素优先级,标准的做法是:先pop再push新的值。C++17之后有emplace,可以原地构造,减少一次拷贝。
**易错点二:pop()不返回被删除的元素。**很多从Java转过来的朋友习惯int x = pq.pop();,C++里这么写直接编译错误。pop()的返回值是void,你想拿堆顶元素必须先调用top()拿引用,再调用pop()删除。为什么这么设计?因为它要保证异常安全。如果pop()返回被删除元素的值,返回时必然涉及一次拷贝构造,这一步抛异常的话,元素已经被删掉了,就丢失了。所以STL选择先让你top()拷贝出来,再pop()删除,这样即使拷贝异常,堆里的数据还在。
**易错点三:只传比较器不够,还得看比较器的签名怎么写。**这是重灾区。看代码:
struct cmp { bool operator()(const int& a, const int& b) const { return a > b; } }; priority_queue<int, vector<int>, cmp> minHeap;这个cmp返回a > b时,最终得到的priority_queue是小顶堆,堆顶是最小值。很多人不理解:我写的是“大于”,为什么反而变成小顶堆了?因为priority_queue的定义是:**第一个参数T的优先级低于第二个参数U时,operator()(T, U)返回true。**也就是说,Compare被定义为“优先级低的先返回true”。默认的less,表示第一参数如果“小于”第二个参数,优先级更低,你想想堆顶是谁。
我给所有新讲过的人一个口诀:“less是大堆,greater是小堆”。反直觉但就是事实——因为less时,priority_queue认为“小的那个优先级低”,堆顶自然要放大的,所以是大顶堆。默认情况下less都没传,系统默认给你less,所以默认是大顶堆。
2.2 deque的核心接口和它特有的操作
deque的接口和vector非常像:push_back、pop_back、push_front、pop_front、insert、erase、operator[]、at、begin、end。真正让deque和vector拉开差异的是push_front和pop_front,这两个操作在vector上是O(n),在deque上是O(1)。
我实测了很多次,在头部插入元素时,vector要整体后移,如果元素是结构体,还有析构和拷贝的开销;deque只需要在头部缓冲区分配一个新位置就行,快一个数量级。
deque还有一个和vector不一样的地方:没有data()方法。很多人在写代码时想当然地认为deque也连续内存,拿&dq[0]当数组首地址去传C接口。这个操作在语法层面编译不过去(deque没有data()),就算你硬通过&dq[0]取地址,得到的也只是第一块缓冲区里的地址,并不能覆盖整个deque。原因下面讲底层结构的时候会展开。
另外,deque支持在头部使用insert,效率高,但如果在中间insert,它比vector还慢。因为deque要在中间位置插入元素时,可能涉及多个缓冲区之间的元素挪动,比连续内存的vector挪起来还要麻烦。记住:deque适合两端操作,不适合中间操作。
2.3 自定义类型使用priority_queue的完整姿势
工作中经常要对结构体排序,这里用一个完整的例子演示:
#include <queue> #include <vector> #include <iostream> #include <string> struct Task { int priority; int id; std::string name; Task(int p, int i, std::string n) : priority(p), id(i), name(std::move(n)) {} }; // 方法一:在结构体内部定义 operator<,然后直接用默认比较器 struct TaskLess { bool operator()(const Task& a, const Task& b) const { return a.priority < b.priority; // 注意:priority高的优先级高 } }; // 方法二(推荐):外部仿函数,不改结构体,也更灵活 struct TaskCmp { bool operator()(const Task& a, const Task& b) const { if (a.priority != b.priority) return a.priority < b.priority; return a.id > b.id; // 同优先级时 id 小的先出 } }; int main() { // 大顶堆,按照 TaskCmp 的规则排序 std::priority_queue<Task, std::vector<Task>, TaskCmp> pq; pq.emplace(3, 1, "low"); pq.emplace(8, 2, "high"); pq.emplace(5, 3, "mid"); while (!pq.empty()) { std::cout << pq.top().name << " (priority=" << pq.top().priority << ", id=" << pq.top().id << ")\n"; pq.pop(); } return 0; }**为什么说仿函数比operator<更好用?**因为operator<只能定一个排序规则,而仿函数可以定义很多个——按priority排、按id排、联合排序,互不干扰。性能上两者没有本质区别,都是内联调用。我个人的习惯是:只在元素本身有天然序关系(比如自定义的一个数值类型)时重载operator<,其他排序需求一律写仿函数。
这里必须提醒一个const的问题:仿函数的operator()务必加const。STL内部调用Compare时会通过const引用调用,如果你没加const,某些版本的编译器可能会报错或者静默产生性能开销。std::less、std::greater这些标准比较器都是const成员函数,所以用自定义类型时要模仿这个习惯。
3. 深入底层:堆算法与deque的内存结构
3.1 push_heap和pop_heap到底做了什么
priority_queue的所有功能,本质上是把vector上的操作转换成四组堆算法:make_heap、push_heap、pop_heap、sort_heap。
以push为例,priority_queue内部是这么干的:
- 把新元素push_back到vector末尾,此时它暂时在“堆”之外。
- 调用push_heap,从最后一个位置开始向上“上浮”。
上浮的逻辑很简单:新元素跟它的父节点比较(i-1)/2,如果它比父节点优先级高,就交换位置;然后继续往上,直到父节点比它优先级高或者到达根节点。这个过程的时间复杂度是O(log n)。
pop时:
- 调用pop_heap,把堆顶元素(下标0)和最后一个元素交换。
- 从根节点开始“下沉”:比较当前节点和两个子节点,把最大(或最小,取决于比较器)的那个交换到父位置,一直下探到叶子。
- 此时堆顶是正确元素,但最大/最小元素已经被换到了末尾。
- 调用pop_back把末尾元素弹出。
所以pop_heap本身不删除元素,它只是把要弹出的元素挪到容器末尾。pop_heap配合pop_back才完成了priority_queue的pop操作。理解这个机制很重要,因为如果你自己操作底层容器,想实现“把堆顶拿走”的效果,就要这么两步走。
**一个很多人不知道的细节:**std::sort_heap可以直接对vector里的堆进行原地排序。排序完之后,这个vector就是完全有序的了,此时priority_queue就失去了“堆”的性质——但没人规定你不能这么干。当你有一个原始堆数据,需求是从大到小输出排序结果时,可以先make_heap再sort_heap,比逐个pop更高效,因为sort_heap全程原地操作,省掉pop_back的反复缩容。
3.2 deque的中控器与缓冲区
deque的底层设计,是STL里最精巧的部分之一。直接用一句话概括:它用一段连续的内存数组(叫map中控器)存放各个缓冲区(叫buffer或者block)的指针,每个缓冲区内部是连续内存,但缓冲区之间不连续。
map本身是一个T**指针数组。每个元素指向一个缓冲区,缓冲区大小由实现决定,一般是512字节或者相对元素大小的倍数。在libstdc++里,每个缓冲区是512字节(如果元素大小超过512就一个元素占一块),而MSVC的实现则使用fixed的block。
让我用一个生活类比来解释这个过程:vector像一个望不到头的地板,元素一个挨一个排在上面;deque像一串装修好的房间,每个房间地板上也排着元素,房间之间由一条走廊(map)连起来。你走进任何一个房间,房间内部的地板是连续的;但你不能从房间A直接走到房间B的任意位置,得先回到走廊,再进另一个房间。
这个结构带来的直接结果:
operator[]是O(1)但常数大。它要先pos = itr + n,再取node指针,再计算块内偏移。多两级间接寻址。- 在两端插入是O(1),因为只需要在该端缓冲区有空间时直接用头尾指针,空间不够时要么加一块新缓冲区,要么扩展map。
- 在中间插入是O(n),可能打散原有缓冲区的元素分布。
deque的push_front过程是这样的:先看第一块缓冲区前面还有没有空闲位置,有就直接在first - 1处构造元素;没有就新分配一块缓冲区,更新map指针,把新块设为第一块。这个流程里不涉及任何已有元素的移动,这就是为什么O(1)。
3.3 deque迭代器如何在块间“跳转”
deque的迭代器是四个指针的结构体,这一点在面试中被问到的概率极高:
struct deque_iterator { T* cur; // 当前指向的元素 T* first; // 当前缓冲区起始位置 T* last; // 当前缓冲区结束位置(空位) map_pointer node; // 指向中控器中“当前缓冲区指针”的指针 };cur指向当前位置,first和last是当前缓冲区的边界,node指向map里记录当前缓冲区地址的那个位置。当迭代器越过last时,它需要做一件事:node++移动到下一个缓冲区的指针位置,然后解引用node得到新缓冲区的首地址,更新first和last,cur指向新的last(或first,取决于前进方向)。
这个过程看起来复杂,实际执行就几行代码,但是每一次跨块都会多一次内存访问,这也是deque的迭代器累加比vector慢的原因之一。所以,遍历deque优先使用范围for或者迭代器而不是下标,虽然连续内存遍历时两者差不多,但在deque上迭代器累加会自动处理跨块逻辑,下标访问每次都要计算块号。
还有一个很实用的问题:deque的迭代器是随机访问迭代器,所以它能用std::sort、std::lower_bound这些需要随机访问的算法。但每次都多几步跳转,性能比vector差。这直接决定了“deque不是用来排序的”这个经验法则。
4. 实操过程:从零手写一个简易版priority_queue
4.1 准备工作与整体设计
理论讲得再多,不动手写一遍总觉得隔层纱。这一章带着你手写一个精简版priority_queue。我们不实现全部功能,但保证核心逻辑和STL一致,并且支持vector和deque两种底层容器。写完你就能彻底理解适配器的含义。
代码结构规划如下:
- 一个模板类MyPriorityQueue,模板参数:T(元素类型)、Container(底层容器,默认vector)、Compare(比较器,默认less)。
- 内部拥有container和cmp两个成员。
- 组合已有容器的push_back、pop_back来构建堆。
- 调用std::push_heap和std::pop_heap完成堆的维护。
**为什么直接使用std::push_heap而不是自己写堆算法?**因为我们要学习的是priority_queue的容器适配器逻辑,堆算法本身就是另一个独立主题。STL里堆算法是通用算法,适配器只是它的调用者。自己重写堆算法又是另一篇文章的量,这里直接用标准库的堆算法,能更清晰地看到适配层做了什么。
4.2 核心代码实现与分步解释
先看完整代码:
#include <iostream> #include <vector> #include <deque> #include <algorithm> #include <functional> template <typename T, typename Container = std::vector<T>, typename Compare = std::less<typename Container::value_type>> class MyPriorityQueue { public: using value_type = typename Container::value_type; using size_type = typename Container::size_type; using const_reference = typename Container::const_reference; MyPriorityQueue() = default; explicit MyPriorityQueue(const Compare& c) : cmp(c) {} bool empty() const { return container.empty(); } size_type size() const { return container.size(); } const_reference top() const { return container.front(); } void push(const value_type& value) { container.push_back(value); // 1. 先放入末尾 std::push_heap(container.begin(), container.end(), cmp); // 2. 上浮调整 } void push(value_type&& value) { container.push_back(std::move(value)); std::push_heap(container.begin(), container.end(), cmp); } // emplace:变长模板,直接构造,少一次拷贝 template <typename... Args> void emplace(Args&&... args) { container.emplace_back(std::forward<Args>(args)...); std::push_heap(container.begin(), container.end(), cmp); } void pop() { std::pop_heap(container.begin(), container.end(), cmp); // 1. 堆顶换到末尾 container.pop_back(); // 2. 删除末尾元素 } private: Container container; Compare cmp; };逐段解释一下:
top()返回container.front()。为什么堆顶就是第一个元素?因为std::make_heap、push_heap、pop_heap操作之后,堆顶一定在begin()位置。这个结论是堆算法的性质保证的,不用自己维护额外的变量。
push()的两步特别关键:先把元素push_back到vector尾部,然后调用push_heap。这里必须注意顺序:必须先push_back,再push_heap。如果你先push_heap,末尾还没有新元素,堆不变,再push_back元素,堆又退了。STL内部也是这个顺序。
pop()的逻辑看起来有点别扭:先pop_heap再pop_back,方向似乎是反的。回顾前面讲的堆算法,pop_heap会把堆顶交换到末尾,然后对前半部分做下沉调整,此时“被弹出的元素”躺在末尾,再pop_back才能真正删掉它。两步缺一不可。
4.3 用deque做底层容器验证适配逻辑
重点是验证我们这套模板能不能配上deque。为了测试,我在main里分别用vector和deque作为底层容器各生成一个堆:
int main() { // 用 vector 做底层 MyPriorityQueue<int> pq_vector; pq_vector.push(3); pq_vector.push(1); pq_vector.push(4); pq_vector.push(1); pq_vector.push(5); std::cout << "vector底层,默认大顶堆: "; while (!pq_vector.empty()) { std::cout << pq_vector.top() << " "; pq_vector.pop(); } std::cout << "\n"; // 用 deque 做底层,小顶堆 MyPriorityQueue<int, std::deque<int>, std::greater<int>> pq_deque; pq_deque.push(3); pq_deque.push(1); pq_deque.push(4); pq_deque.push(1); pq_deque.push(5); std::cout << "deque底层,greater小顶堆: "; while (!pq_deque.empty()) { std::cout << pq_deque.top() << " "; pq_deque.pop(); } std::cout << "\n"; return 0; }运行结果是:
vector底层,默认大顶堆: 5 4 3 1 1 deque底层,greater小顶堆: 1 1 3 4 5看到没有,同一个适配器代码,换一个底层容器、换一个比较器,行为就完全不同。这就是适配器模式的意义所在——它不关心底层是谁,只要底层容器满足随机访问迭代器、push_back、pop_back这几个要求,就能被包装成优先队列。
从我个人的实测经验来看,在这个简易版中,数据量小时deque底层的性能与vector几乎没差别,但当你push几百万个元素时,vector会明显更快。原因还是第一章节讲的,堆操作频繁随机访问,vector缓存更友好。
4.4 写一个deque双向操作的小案例
既然文章标题里deque占了半个版面,咱们也得让deque露一手。这里给一个用deque实现滑动窗口最大值的代码,这是deque最经典的应用之一,面试中也经常考:
#include <deque> #include <vector> #include <iostream> std::vector<int> slidingWindowMax(const std::vector<int>& nums, int k) { // 队列里存的是下标,不是值,方便判断窗口是否过期 std::deque<int> dq; std::vector<int> result; result.reserve(nums.size() - k + 1); for (int i = 0; i < (int)nums.size(); ++i) { // 如果队头元素对应的下标已经在窗口外,弹出 if (!dq.empty() && dq.front() <= i - k) dq.pop_front(); // 维护单调递减的性质:新元素更大时,队尾小元素全部淘汰 while (!dq.empty() && nums[dq.back()] <= nums[i]) dq.pop_back(); dq.push_back(i); // 窗口满才输出 if (i >= k - 1) result.push_back(nums[dq.front()]); } return result; } int main() { std::vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7}; int k = 3; std::vector<int> r = slidingWindowMax(nums, k); for (int v : r) std::cout << v << " "; std::cout << "\n"; // 输出: 3 3 5 5 6 7 return 0; }这段代码看起来简单,但两个关键操作都是O(1):pop_front删除过期下标,pop_back淘汰不可能成为最大值的元素。每个元素最多入队一次、出队一次,所以整体O(n)。用vector做这个会退化成O(n*k)或者需要额外维护索引,用deque才是正解。
5. 常见问题与排查技巧实录
5.1 什么时候选deque,什么时候选vector,我的判断标准
根据我自己的编码经验,整理了一个选择标准,直接照着选就行:
| 使用场景 | 容器选择 | 理由 |
|---|---|---|
| 只在尾部插入、频繁随机访问 | vector | 缓存最友好,随机访问O(1)且常数极小 |
| 头尾都会频繁插入删除 | deque | push_front/pop_front真正O(1) |
| 优先队列默认底层 | vector | 堆算法需要随机访问,vector最优 |
| 优先队列元素拷贝昂贵 | 可尝试deque | 减少扩容搬运,但需要benchmark验证 |
| 需要data()传给C接口 | vector | deque没有连续内存保证 |
| 需要迭代器长时间持有 | 看操作位置 | 两端操作deque迭代器安全,中间deque会失效 |
注意最后一行的问题,deque的迭代器失效规则:在两端push/pop不影响其他迭代器,但在中间insert/erase会使所有迭代器失效,这点和vector不同。vector的中间插入会把后面的迭代器推挤,deque中间插入则可能重新分配缓冲区指针数组map,导致全部失效。
5.2 自定义类型排序报错,三个高频原因
排查这类问题我一般按下面三个步骤来:
第一步,看比较器是不是严格弱序。Compare必须满足strict weak ordering,如果你写了个返回true的比较器(比如return a <= b;),堆算法会陷入死循环或者输出错误结果。这是新手最容易犯的错误。记住规则:永远不要用<=或>=,只能用<或>,并且等值情况一定要返回false。
第二步,检查自定义类型是否有operator<。如果你直接使用默认比较器(不传第三个参数),编译器会尝试用less调用operator<,找不到就报编译错误。此时要么给结构体重载operator<,要么提供一个仿函数。
第三步,检查仿函数是否const可调用。前面说过,STL内部通常以const引用调用比较器,如果你的operator()没加const,某些实现下会报错。我习惯是所有比较器一律写成bool operator()(const T&, const T&) const,从根上杜绝这个问题。
5.3 怎么安全地遍历priority_queue里的所有元素
priority_queue不提供begin/end,你想遍历整个堆,有两条路:
方法一:拷贝底层容器。这是最推荐的做法:
auto temp = pq; // 拷贝整个priority_queue while (!temp.empty()) { std::cout << temp.top() << " "; temp.pop(); }拷贝后不影响原队列,代价是O(n)的时间和一个完整的元素拷贝。如果元素很大且数量很多,可以用方法二。
方法二:直接拿底层容器的引用,用构造性语法遍历。通过pq.*(&priority_queue_t::c)这样的技巧,可以拿到container的引用。但需要把容器类型声明为public,所以通常不这么做,除非你自己封装一个带公开底层的priority_queue。正规一点的写法是自己在类里提供member函数返回container的引用。
我实测过的技巧是方法一的各种变体都不如直接拷贝简单。拷贝priority_queue本身是O(n)深拷贝,虽然慢一点,但代码可读性高、不易出错。如果连这次O(n)拷贝都无法忍受,就更应该考虑是否真的需要“遍历堆里所有元素”——如果确实要频繁遍历全部元素且还要有序,priority_queue可能不是正确的数据结构。
5.4 deque踩坑实录:随机访问慢、迭代器失效、误用data
我见过最离谱的一个bug,是有人用deque当缓冲区,然后从中间频繁插入删除,结果性能比vector还差十倍。因为他完全忽略了deque的优势场景。deque适合“两端”,不适合“中间”。中间操作deque要搬运多个缓冲区的元素,比vector还麻烦。
另一个常见的坑是对deque使用data()。deque没有data(),这个接口在vector上有。如果你需要把连续内存传给C接口(比如fwrite写文件、或者传给OpenGL的顶点数据),不要用deque,直接用vector。没有data()接口是一个设计上的明示:它根本不是一个连续内存容器。
还有关于迭代器失效的实战经验:如果你持有deque的迭代器,同时又在中间做了insert/erase,原有迭代器会全部失效;但在两端push/pop,迭代器不受影响。所以写代码时,如果要在循环里同时push和erase,务必小心迭代器失效。一个常见的安全做法是用下标计算代替迭代器,或者用容器的std::erase配合remove_if。
5.5 emplace与push的性能差异能有多大
经常有人问emplace比push快多少,我做过一次实测定论:小元素差距微乎其微,大对象(比如字符串、含vector成员的结构体)差距明显。
以代码为例:
struct Employee { std::string name; std::vector<int> scores; int level; Employee(std::string n, int lvl) : name(std::move(n)), level(lvl) {} }; // push 方式:先构造临时对象,再拷贝进容器(可能一次move) pq.push(Employee("张三", 3)); // emplace 方式:参数直接传进去,容器内部就地构造 pq.emplace("张三", 3);第一种方式会经历“构造函数创建临时对象”和“拷贝/移动进入容器”两个阶段。类含vector成员时,移动构造可能会使vector的堆内存被转移,虽然不算灾难但时间开销存在。第二种方式直接调用构造函数,少一次移动。在大对象频繁push时,emplace的性能优势可以到10%到30%,积少成多。
我个人的经验是:凡是priority_queue里存大对象,一律用emplace,别给每个对象先造临时变量。但如果存的是int、指针这类平凡类型,push和emplace没区别,纯粹看你喜欢哪种写法。
6. 最后再聊几句实在的
写到这里,把priority_queue和deque从接口用法、底层结构、手写实现到常见坑都过了一遍。我个人的体会是,STL学习最重要的是“看穿包装”:看到priority_queue,要想到堆算法;看到deque,要想到中控器和缓冲区;看到适配器这个单词,要意识到背后一定还有一个真实容器在默默干活。带着这层视角去看任何STL组件,你都比别人多一层理解深度。
最后再分享一个小技巧:当你需要priority_queue支持“任意位置删除”时,别硬造轮子。常规方案是懒删除——维护一个独立的“已删除集合”或者“有效性标记”,pop的时候循环跳过被标记的元素。这个模式在处理网络事件调度、缓存淘汰时非常实用,我在生产环境就是这么干的。真到了需要任意删除和修改优先级都O(log n)的场景,应该去看看配对堆或斐波那契堆了,那是另一个更复杂的故事。