ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

deque与priority_queue深度拆解:底层原理、适配器本质与C++面试实战

deque与priority_queue深度拆解:底层原理、适配器本质与C++面试实战 你有没有发现C面试八股里有个特别有意思的组合——deque和priority_queue。这俩名字都带“queue”但一个属于容器一个属于容器适配器底层逻辑、适用场景几乎完全不一样。把这两个东西放在一起聊不是因为它们长得像而是因为很多人对它们的理解停留在“用过某个接口”的层面一旦面试官问“底层怎么实现”“为什么用这个不用那个”就露馅了。这篇东西我打算做一次实操向的深度拆解先讲清 deque 的分段连续存储到底是什么再掰开 priority_queue 的适配器本质最后结合刷题和项目里最常见的场景给出可以直接套用的经验和踩坑记录。适合刚上手 C 的初学者也适合准备面试想把自己的“八股”补扎实的中级开发者。读完你至少能回答三个问题deque 凭什么头尾插入都是 O(1)priority_queue 为什么默认是大顶堆自定义比较器到底怎么写才不晕1. 先搞清楚 deque 的真面目1.1 deque 不是“双向 vector”这么简单很多资料里把 deque 说成“双端都能插入删除的 vector”这话只对了一半。vector 的内存是连续的一段deque 不是。deque 的真实结构是“分段连续”底层由一段一段定长的连续缓冲区组成每段缓冲区里存元素这些缓冲区本身再由一个“中控器”串起来。中控器本质上是一个指针数组每个元素指向一块缓冲区。听起来有点绕我习惯打一个比方vector 是一套打通的大三居家具可以连续摆deque 是几个小房间串联的 Loft每个房间里家具摆得整整齐齐但房间和房间之间不挨着需要通过走廊中控器找到下一个房间。这个设计的收益是什么头尾插入删除都只需要在对应缓冲区的头尾操作不需要搬运其他元素因此push_front和push_back都是常数时间。中控器本身会动态扩容但它只存指针搬运代价远小于搬元素。1.2 中控器与缓冲区协作机制每个缓冲区固定大小libstdc 里通常一个缓冲区能存 512 字节换算成元素个数是512 / sizeof(T)所以不同元素类型每段缓冲区能存的数量不一样。当你push_back导致当前缓冲区尾部满了deque 会向中控器再申请一块缓冲区接在后面push_front同理往头部方向申请新块。注意到一个关键点deque 的随机访问是 O(1)但不是像 vector 那样的直接指针偏移。它要先通过中控器定位到落在哪个缓冲区再在缓冲区内部做偏移。源码头文件里的operator[]大概就是这个逻辑reference operator[](size_type __n) { return *(this-_M_impl._M_start __n); }_M_start是一个迭代器它内部记录了当前缓冲区指针_M_cur、缓冲区首地址_M_first、缓冲区末尾_M_last、以及中控器位置_M_node。迭代器往前或往后移动跨缓冲区时要先判断是否越过当前缓冲区的边界越了就要从中控器取出相邻缓冲区指针。这套机制意味着deque的随机访问比 vector 多一层间接常数更大实际跑起来不可能达到纯数组的速度。1.3 迭代器失效规则反而比 vector 简单操作 vector 时一旦insert或push_back触发扩容所有迭代器全部失效你得小心翼翼保存索引。deque 则有自己的规则在中间插入或删除会让所有迭代器失效但在两端插入删除时只有被操作端的迭代器失效指向另一端元素的迭代器仍然有效指向元素本身的引用和指针也仍然有效。这个特性让 deque 在某些双端操作场景下比 vector 好写得多。比如你在写一个双端缓冲队列一端持续接收数据另一端持续消费数据用 deque 的话两个线程各自持有指向首尾元素的引用只要不往对方那一端做操作引用生命周期是安全的。实操时还要注意一个细节deque 的迭代器是随机访问迭代器支持it n、it - n但迭代器的跨缓冲区移动是 O(1) 常数操作不是 O(1) 的指针减法。每次、--内部都有一个边界判断所以代码里大量的迭代器增减操作会有额外开销。2. deque 的核心操作与实战要点2.1 基本功两端操作与中间操作deque 提供的接口里日常最高频的就是下面这一组#include deque #include iostream int main() { std::dequeint dq; dq.push_back(3); dq.push_front(1); dq.push_back(4); dq.push_front(0); // 现在内容0 1 3 4 std::cout dq.front() \n; // 0 std::cout dq.back() \n; // 4 dq.pop_front(); // 移除0剩下 1 3 4 dq.pop_back(); // 移除4剩下 1 3 }注意push_front和pop_front是 vector 没有的这也是 deque 最重要的存在价值。C11 之后还有emplace_front和emplace_back用于直接在缓冲区上构造对象避免临时对象的拷贝或移动。如果元素类型是std::string这种构造成本不低的类型emplace_back(hello)和push_back(hello)的差异在循环里会被放大。中间插入insert和删除erase虽然能用但常数成本较高。原因很简单不管在哪个位置插入deque 都得做两件事——搬移元素以及维护中控器里缓冲区的可能分裂。尤其当插入位置恰好在某个缓冲区中间时deque 的实现会尽量让头尾两端“借地”来减少搬移但归根结底不比 vector 的连续内存搬运更划算。实战里如果频繁做中间位置的随机插入删除优先想想std::list或者更换数据结构别硬用 deque。2.2 场景一滑动窗口最大值面试和竞赛题里deque 最常见的出镜方式就是维护一个“单调队列”。以 LeetCode 239 为例给一个数组和窗口大小 k求每个窗口的最大值。暴力法是每滑动一次就遍历窗口里的 k 个元素复杂度 O(n*k)数据规模一大就超时。单调队列做法是让 deque 从头到尾单调递减滑动过程中每次加入新元素前把队尾所有小于等于它的元素弹出去它们不可能再成为最大值把队头不在当前窗口范围内的元素弹出去队头就是当前窗口最大值#include deque #include vector std::vectorint maxSlidingWindow(std::vectorint nums, int k) { std::dequeint dq; // 存下标不是存值 std::vectorint res; for (int i 0; i 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) { res.push_back(nums[dq.front()]); } } return res; }为什么这里用 deque 而不是 list 或 vector因为双端两头都要弹出窗口滑动时要从头部弹出过期的加入新元素时要从尾部弹出不配当前的。同时还要能从尾部加入。这三个操作组合起来正好踩在 deque 的优势区。换成 vector头部弹出是 O(n) 的换成 list随机访问队头没问题但缓存局部性差实际跑起来比 deque 慢不少。2.3 场景二双端任务缓冲项目里我还遇到过这种场景一个数据采集线程不断把数据塞到队列尾部处理线程从头部取数据但是有一些优先级更高的“紧急任务”需要插队到头部。相当于一个既能push_back又能push_front的队列。用std::queue要自己拿两个队列拼用 vector 头插是灾难deque 天然支持。这类场景下我建议配合std::mutexstd::condition_variable使用注意 deque 本身的线程安全性标准库容器都不是线程安全的双端同时操作必须由外部锁保护。当初我在一个采集程序里直接push_back和pop_front不加锁运行半天后偶发崩溃查了半天才定位到是队列并发访问问题。2.4 性能对照deque vs vector vs list用数据说话。n10万分别测试尾部 push、头部 push、随机访问的耗时相对值具体和机器有关操作vectordequelist尾部 push快略慢于 vector慢头部 pushO(n) 极慢快快随机访问at(i)最快比 vector 慢约 10%~30%不支持中间插入视扩容情况较差较好内存占用低中多一层中控器高每个节点要存指针没有银弹。deque 是一个“均衡型选手”它在多数操作上不是最快但差距都不大。正因为如此很多标准库实现里std::queue的默认底层容器就是 deque——队列的所有操作都只是 deque 的子集。3. priority_queue 不是容器是适配器3.1 先从“适配器”三个字开始理解priority_queue本身内部并没有真正实现堆结构它是在某个底层容器之上封装了std::make_heap、std::push_heap、std::pop_heap这套算法对外只暴露符合堆语义的接口。这种设计模式就叫“容器适配器”——它不拥有存储只限定操作。看一下标准库的实现思路template typename _Tp, typename _Sequence std::vector_Tp, typename _Compare std::lesstypename _Sequence::value_type class priority_queue { protected: _Sequence c; // 底层容器 _Compare comp; // 比较器 public: void push(const value_type __x) { c.push_back(__x); std::push_heap(c.begin(), c.end(), comp); } void pop() { std::pop_heap(c.begin(), c.end(), comp); c.pop_back(); } const_reference top() const { return c.front(); } };你没看错默认底层容器是std::vector不是 deque。模板声明里三个参数——元素类型、底层容器、比较器——第二个和第三个都有默认值。这就引出了两个容易忽略的点底层容器不是固定的你可以换成 deque只要它支持front()、push_back()、pop_back()和随机访问迭代器。top()返回的是容器首元素因为堆算法的特性保证最大元素在堆顶。这里我插一句priority_queue 不是堆本身它是“拿容器包了一层堆语义的门面”。理解这一点很多怪癖就说得通了为什么它不支持遍历为什么拿到了迭代器也排不了序因为它只允许你从顶部进、从顶部出这是刻意设计的。3.2 默认大顶堆背后的比较器玄机std::less是函数对象做的是a b。而 priority_queue 默认用std::less却实现了“大顶堆”——最大的元素在 top。这和直觉相反不少人第一次写都蒙了。原因在堆算法里std::push_heap和std::pop_heap用的是“比较器判断父子是否交换”的规则。std::less配合这些算法最终让根节点是最大的元素。如果你希望top()返回最小的元素也就是小顶堆反而需要用std::greater。这个“反直觉”恰好和std::sort的默认行为相反——sort 用less是升序而 priority_queue 用less是“大根堆”。我的记忆办法很简单priority_queue 的 top 总是 comp 比较规则下的“最后一个”元素。less 排序时最后一个最大greater 排序时最后一个最小。这样想就不会混了。3.3 插入和删除的时间复杂度到底怎么回事堆的插入是 O(log n)因为元素加到尾部后需要不断和父节点比较上浮最多上浮到根高度就是 log n。删除同理先把堆顶与堆尾交换然后删除现在的尾部元素也就是原来的堆顶再把新堆顶下沉到合适位置。上浮和下沉都是树高量级。底层容器的push_back和pop_back都是均摊 O(1)所以 priority_queue 的push和pop总体是 O(log n)top()是 O(1)。看一个完整的 priority_queue 使用流程#include queue #include vector #include iostream int main() { std::priority_queueint pq; // 默认大顶堆 pq.push(10); pq.push(5); pq.push(20); pq.push(15); std::cout pq.top() \n; // 20 pq.pop(); // 弹出20 std::cout pq.top() \n; // 15 }如果要小顶堆std::priority_queueint, std::vectorint, std::greaterint min_pq;greater需要包含头文件functional不过我实测发现在queue里也能编译过因为标准库实现互相包含但规范上std::greater在functional中定义显式包含更稳妥。4. 优先队列自定义排序谁在用怎么用4.1 自定义比较器的三层结构这里必须先说清楚priority_queue 的模板第三参数是一个“函数对象类型”不是你随便写一个 lambda 表达式就能直接用decltype解决的问题。最常见的是下面三种写法。第一种函数对象结构体struct Cmp { bool operator()(int a, int b) const { return a b; // 小顶堆 } }; std::priority_queueint, std::vectorint, Cmp pq;第二种lambda 表达式配合decltypeauto cmp [](int a, int b) { return a b; }; std::priority_queueint, std::vectorint, decltype(cmp) pq(cmp);注意这里pq(cmp)不能省因为decltype(cmp)只能给出 lambda 类型构造函数需要传入一个该类型的实例也就是那个 lambda 对象本身。C20 之前 lambda 没有默认构造函数不传会编译报错。第三种函数指针bool greater_cmp(int a, int b) { return a b; } std::priority_queueint, std::vectorint, bool(*)(int, int) pq(greater_cmp);实战中我推荐第一种函数对象结构体语义清晰复用方便还能内联性能最好。Lambda 写法写单个场景很清爽但一旦和其他容器共用同一个比较规则代码复用比较费劲。4.2 自定义比较器里最容易犯的“方向错误”很多人在自定义结构体排序时会把比较器写成“我想要谁在上面谁就 return true”。这在std::sort里是成立的但在 priority_queue 里不成立。还记得前面说的吗priority_queue 的top()返回的是比较规则下的“最后一个”。你写return a bless 的意义被反转顶上是小的你写return a.score b.score顶上是 score 最小的。所以想实现“score 最大者优先”时比较器要写return a.score b.score也就是直接用。这个方向感问题我见过太多人翻车。最好的自测办法是写完比较器以后push 三个乱序数据打印 top确认是不是你想要的。十几秒的事避免上线后才发现优先级反了。4.3 自定义结构体放进优先队列复杂对象的排序往往要落到某个成员上。假设有一个任务体需要按截止时间越早越优先处理struct Task { int deadline; int cost; }; struct TaskCompare { bool operator()(const Task a, const Task b) const { return a.deadline b.deadline; // 注意这里用了 意味着 deadline 小越紧急的 top 优先级越高 } }; std::priority_queueTask, std::vectorTask, TaskCompare task_queue;如果同时希望 deadline 相同的情况下耗时长的优先先做最费时的就把比较器写成if (a.deadline ! b.deadline) return a.deadline b.deadline; return a.cost b.cost;这种多关键字比较规则在调度类场景特别常见。核心逻辑就是第二个条件是相等时的“平局决胜”。注意这里第二个条件用让 cost 大的优先因为 cost 大意味着“更重”先处理更重的任务是常见的贪心策略。5. 优先队列的实战应用与性能分析5.1 Top K 问题一个模板通吃面试几乎必考的 Top K 问题用 priority_queue 有大顶堆和小顶堆两种完全不同的思路得想明白再用。找数组里最大的 K 个数正确的做法是维护一个大小为 K 的小顶堆。堆顶是当前 K 个候选里最小的每遇到一个新元素如果它比堆顶大就弹出堆顶、把新元素放进去。这样遍历结束后堆里就是最大的 K 个数。复杂度 O(n log K)。#include queue #include vector std::vectorint topKFrequent_Largest(std::vectorint nums, int k) { std::priority_queueint, std::vectorint, std::greaterint min_heap; for (int x : nums) { if (min_heap.size() k) { min_heap.push(x); } else if (x min_heap.top()) { min_heap.pop(); min_heap.push(x); } } std::vectorint res; while (!min_heap.empty()) { res.push_back(min_heap.top()); min_heap.pop(); } return res; }反直觉点在于求“最大 K 个”用的是“小顶堆”。因为小顶堆能保证 O(1) 找出当前候选里最弱的一个方便淘汰。如果真用大顶堆每次 pop 出来的是最大值堆里的元素不断变化最后留下的并不是 top K——大顶堆的 top 每次都走了留下的反而是“最小的一批”。这个“反直觉”坑即使是有几年经验的开发者也很容易踩属于细节决定成败的典型。5.2 合并 K 个有序链表LeetCode 23 是另一个高频题。K 个有序链表把它们合并成一个有序链表。每次从 K 个链表的当前头节点里选最小的最直观的暴力法是每次比较 K 个节点总复杂度 O(n*K)。用 priority_queue 可以把选最小这个操作优化到 O(log K)。#include queue #include vector struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; struct CmpNode { bool operator()(ListNode* a, ListNode* b) const { return a-val b-val; // 小顶堆值小的在 top } }; ListNode* mergeKLists(std::vectorListNode* lists) { std::priority_queueListNode*, std::vectorListNode*, CmpNode pq; for (auto* node : lists) { if (node) pq.push(node); } ListNode dummy(0); ListNode* tail dummy; while (!pq.empty()) { ListNode* cur pq.top(); pq.pop(); tail-next cur; tail cur; if (cur-next) pq.push(cur-next); } return dummy.next; }这里存的是指针要小心节点生命周期ListNode 的生命周期由原始链表持有priority_queue 只借指针排序不会 delete。如果你擅自new了一堆节点放进 pq 又不管释放就会内存泄漏。5.3 模拟 Dijkstra 算法的朴素实现Dijkstra 最短路算法在稀疏图上的标准实现就是用 priority_queue 维护“当前距离最近且未确定最短路的节点”。核心循环是这个样子using PII std::pairint, int; // {dist, node} std::priority_queuePII, std::vectorPII, std::greaterPII pq; dist[start] 0; pq.push({0, start}); while (!pq.empty()) { auto [d, u] pq.top(); pq.pop(); if (d dist[u]) continue; // 过期数据跳过 for (auto [v, w] : graph[u]) { if (dist[u] w dist[v]) { dist[v] dist[u] w; pq.push({dist[v], v}); } } }std::greaterPII天然把 pair 按 first 升序排列正好符合“每次取距离最小节点”的需求。这种“惰性删除”过期节点不主动删弹出时拿当前距离比对是 priority_queue 实践中最常用的技巧比维护一个“是否已确定”标记还省事。这里有个性能细节priority_queue 里可能同时存在同一个节点的多份旧距离数据。最坏情况下队列最大长度会膨胀到 O(E)但每个边最多让一个节点入队一次总体复杂度 O(E log E)在稀疏图中依然可用。如果对性能极端敏感可以考虑改用二叉堆手写或者 Fibonacci 堆但工程上大多数场景 priority_queue 已经绰绰有余。6. 常见问题与排查技巧实录6.1 priority_queue 为什么不能遍历有人希望“既能拿到最大值又能遍历所有元素”很自然地会用类似容器的遍历方式操作 priority_queue直接编译报错。因为priority_queue根本不提供迭代器也不提供begin()end()。这是适配器的强制约束堆只暴露 top 入口避免破坏堆结构。真需要“需要遍历 取极值”时就别硬用 priority_queue 了。可以直接维护一个std::vector需要时用std::make_heap调整或者直接用std::set/std::multiset。前者牺牲了部分封装性后者牺牲了常数性能但都保留遍历能力。工程选型就是这样没有全能的容器看你要的核心操作是什么。6.2 比较器方向写反的经典症状症状描述通常是“我 push 了 1、2、3然后 top 一直返回 1符合我对小顶堆的预期但自定义结构体又不对劲”。这类问题九成出在比较器定义本身。对比一下三种写法的差异容器想让 top 最小想让 top 最大priority_queue 默认std::greaterTstd::lessT默认自定义结构体 Cmpreturn a b;return a b;std::sort 升序return a b;不写 comparator写自定义 Cmp 时只要记住一件事return 的是“a 应该排在 b 后面”的条件不是“a 优先于 b”的条件。这个角度理解最简单——a b表示 a 比 b 大在队列里应该排在更后面于是小的在前面top 是小根堆。6.3 修改堆内元素以后数据乱了priority_queue 的元素是限定的你不能pq[2] xxx因为 priority_queue 不提供下标访问。但持有top()返回的引用并修改它是一个隐蔽的坑。int x pq.top(); x -100; // 危险堆结构可能已经被破坏修改 top 元素后堆的性质不再成立之后push、pop的行为未定义。标准库里top()返回的是const_reference理论上你拿不到可变引用但如果你自定义比较器并让返回类型推断出非常量引用或者在某些实现上耍小聪明仍然可能出事。想修改堆顶元素正确姿势是auto tmp pq.top(); pq.pop(); tmp new_value; pq.push(tmp);三步走确保堆结构一致。6.4 deque 的内存释放问题deque 在内存清理上有个和 vector 类似的坑clear()只析构元素并重置 size缓冲区内存并不一定还给操作系统。如果你构造了一个超大的 deque用完想立刻把内存交回去正确的做法是std::dequeint dq; // ... 用了一大堆 dq.clear(); // 析构元素但内存可能仍挂在 malloc 缓存里 dq.shrink_to_fit(); // C11 起请求归还多余内存但shrink_to_fit是个非强制的请求标准库实现可以忽略。稳妥的做法是用空 deque 交换std::dequeint().swap(dq);这条语句创建临时空对象和 dq 交换内部缓冲区指针临时对象析构时带着原来的所有内存一起释放。用 unordered_map、vector 遇到同样问题时这套也是通用的。6.5 一个容易忽略的性能问题deque 的缓存局部性连续循环访问std::deque的所有元素时如果你按迭代器从begin()走到end()实际是在多个缓冲区之间跳转。跳转频率取决于缓冲区大小和元素类型。当元素类型很小比如int每个缓冲区能存 128 个int那么每访问 128 个元素就要跳一次指针这个跳转在 CPU 流水线上会产生约 10~20 个周期的代价。数据规模小的时候无所谓一旦循环达到百万级、千万级这个惩罚会非常明显。实测里如果你要频繁遍历整个双端队列两个选择要么考虑用 vector 做环形缓冲head/tail 记录下标能获得最好的顺序访问性能要么就把 deque 当成“小规模双端口”场景专用别把海量数据的遍历压给它。最后说点个人经验这两个容器我在实际项目和面试题里接触得非常多。追根究底我认为它们的价值不在于背接口而在于理解“数据结构选型”的思维方式deque 让你知道“头尾操作频繁”时可以不用 vectorpriority_queue 让你明白“只需要取极值插入”时不必自己写堆排序。如果在面试里被问到我最推荐的答法是把底层结构简单画出deque 分段连续 中控器priority_queue 的 vector 底层 heap 算法。能画出来就说明你是真懂不是背的。画不出来就回去对着源码再读一遍把deque和queue的头部注释看明白比刷十道题都管用。希望这篇东西对你有帮助。写代码这件事多看底层、多动手实测慢慢就有手感了。
返回列表