
说到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::vectorT, class Compare std::lesstypename Container::value_type class priority_queue;第二个参数Container就是底层的那个真实容器默认vector。第三个参数是仿函数决定你是大顶堆还是小顶堆。**为什么默认选vector而不是deque**很多人的直觉是既然priority_queue叫“队列”那底层应该用deque吧但实际默认是vector。原因有两个。第一堆调整sift up / sift down需要频繁的随机访问中间位置的元素。堆用数组方式存储时父子节点的关系是纯数学计算父节点下标i左孩子是2i1右孩子是2i2。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新的值。C17之后有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_queueint, vectorint, 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_queueTask, std::vectorTask, 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和lastcur指向新的last或first取决于前进方向。这个过程看起来复杂实际执行就几行代码但是每一次跨块都会多一次内存访问这也是deque的迭代器累加比vector慢的原因之一。所以遍历deque优先使用范围for或者迭代器而不是下标虽然连续内存遍历时两者差不多但在deque上迭代器累加会自动处理跨块逻辑下标访问每次都要计算块号。还有一个很实用的问题deque的迭代器是随机访问迭代器所以它能用std::sort、std::lower_bound这些需要随机访问的算法。但每次都多几步跳转性能比vector差。这直接决定了“deque不是用来排序的”这个经验法则。4. 实操过程从零手写一个简易版priority_queue4.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::vectorT, typename Compare std::lesstypename 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::forwardArgs(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 做底层 MyPriorityQueueint 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 做底层小顶堆 MyPriorityQueueint, std::dequeint, std::greaterint 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::vectorint slidingWindowMax(const std::vectorint nums, int k) { // 队列里存的是下标不是值方便判断窗口是否过期 std::dequeint dq; std::vectorint 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::vectorint nums {1, 3, -1, -3, 5, 3, 6, 7}; int k 3; std::vectorint 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)且常数极小头尾都会频繁插入删除dequepush_front/pop_front真正O(1)优先队列默认底层vector堆算法需要随机访问vector最优优先队列元素拷贝昂贵可尝试deque减少扩容搬运但需要benchmark验证需要data()传给C接口vectordeque没有连续内存保证需要迭代器长时间持有看操作位置两端操作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::vectorint 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)的场景应该去看看配对堆或斐波那契堆了那是另一个更复杂的故事。