尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++ STL deque双端队列:核心原理、性能对比与滑动窗口实战

C++ STL deque双端队列:核心原理、性能对比与滑动窗口实战 1. 双端队列deque到底是什么为什么C程序员都绕不开它如果你写过C尤其是涉及到需要频繁在序列两端进行操作的场景比如实现一个滑动窗口算法、一个任务队列或者一个撤销/重做功能的历史记录栈那你大概率已经用过或者听说过std::deque。它的全称是“double-ended queue”中文叫双端队列。这个名字很直白就是允许你在队列的头部front和尾部back都能高效地进行插入push和删除pop操作的数据结构。听起来是不是有点像std::vector和std::list的结合体确实很多人刚开始学的时候会把它理解成一个“超级向量”或者“更快的链表”。但它的内部实现和性能特性恰恰是它最精妙也最容易让人产生误解的地方。vector在尾部操作是O(1)但在头部插入删除是O(n)因为需要移动后面所有元素。list在任何位置插入删除都是O(1)但访问任意元素是O(n)而且内存开销大、缓存不友好。而deque的设计目标就是在保证两端操作都是O(1)的前提下让随机访问通过下标[]或at()的性能也尽可能接近vector。我最初接触deque是在实现一个实时数据流处理模块时。数据包不断从网络一端涌入push_back同时处理线程从另一端取出数据进行分析pop_front。用vector的话每次从头部弹出都要整体搬移数据量大时根本受不了。用list虽然操作快但后续需要随机访问中间某些数据包进行校验时性能又成了瓶颈。deque完美地平衡了这两方面的需求。它底层通常不是一个连续的巨型数组而是由多个固定大小的连续内存块常被称为“缓冲区”或“块”通过一个中央映射表通常是数组索引起来。这个中央数组存储着指向各个内存块的指针。当你push_back时它会在最后一个内存块的剩余空间添加如果最后一个块满了就分配一个新块并在中央数组记录新块的指针。push_front也是同理向第一个内存块的前面添加如果第一个块前面没空间了就在中央数组的头部新增一个块指针。这种结构使得在两端增长时绝大多数情况下都只是在一个已有的连续内存块上操作效率极高只有在块边界时才需要一次内存分配。所以deque可以被看作一个“分段连续的数组”。它牺牲了vector那种绝对的连续内存带来的极致缓存友好性换来了两端操作的高效和更大的理论容量因为不需要像vector扩容时那样找一块巨大的连续空间。对于需要“队列”和“随机访问”双重特性的场景它是无可替代的选择。接下来我们就深入它的内部看看怎么用好它。2. deque的核心特性与内部实现窥探理解一个工具不能只看接口还得大概知道它肚子里是怎么转的。虽然C标准并没有规定deque的具体实现方式只规定了它的复杂度要求两端插入删除为分摊常数时间随机访问为常数时间但主流标准库如GCC的libstdc、Clang的libc的实现思路大同小异就是我们上面提到的“分块数组”模型。2.1 与vector和list的深度对比在决定使用哪个容器前一张清晰的对比表能避免很多后期的性能陷阱。下面这个表格是我根据多年使用经验总结的特性std::vectorstd::dequestd::list内存结构单块连续内存多块连续内存分块数组非连续内存双向链表节点随机访问O(1)极致高效缓存友好O(1)但比vector慢需两次跳转O(n)需要遍历头部插入/删除O(n)需要移动后续所有元素分摊O(1)O(1)尾部插入/删除分摊O(1)分摊O(1)O(1)中间插入/删除O(n)需要移动元素O(n)移动元素可能跨块O(1)已知迭代器位置迭代器类型随机访问迭代器随机访问迭代器双向迭代器迭代器失效插入/删除可能导致所有迭代器失效在中间插入/删除会导致所有迭代器失效在头尾插入可能导致迭代器失效具体看实现只有被删除元素的迭代器失效内存开销很小仅容量可能略大于大小中等有中央索引表和多个块的管理开销很大每个元素都有前后指针缓存友好性极好数据连续较好块内连续差数据分散关键解读与避坑指南“分摊O(1)”的含义对于vector的push_back和deque的两端操作之所以是“分摊”常数时间是因为它们涉及到动态扩容。vector扩容时比如2倍扩容需要复制所有元素到新内存这是一次O(n)操作但平摊到n次插入操作上每次还是O(1)。deque在需要分配新内存块时也有类似开销。迭代器失效是巨坑这是C容器使用中最容易出错的地方之一。vector任何可能引起内存重新分配的操作如push_back导致扩容insert导致容量不足而扩容都会使所有指向该vector的迭代器、引用和指针失效。即使insert在中间没有触发扩容插入点之后的所有迭代器也会失效。deque情况更复杂一些。在头尾插入push_front/back通常不会使迭代器失效除非导致分配了新块且具体实现导致中央数组重组但这种情况较少。但在中间插入/删除insert/erase会导致所有迭代器失效这是因为中间插入可能引起大量元素的移动破坏了原有的位置关系。所以如果你在遍历deque的过程中进行了中间修改程序很可能崩溃。list最安全只有指向被删除元素的迭代器会失效。缓存友好性决定实际速度O(1)的复杂度不代表实际运行快。deque的随机访问是O(1)因为它通过中央索引表算出了元素在哪个块的哪个位置。但这个计算过程除法和取模比vector的直接指针加法要慢更重要的是数据不在连续内存上CPU预取器可能失效导致缓存命中率下降。在需要高频、顺序访问所有元素的场景下vector的性能通常碾压deque。2.2 deque的典型应用场景知道了特性就能把它用在刀刃上实现队列Queue和栈Stack虽然标准库有std::queue和std::stack但它们默认的底层容器就是deque。因为deque完美支持了队列FIFO和栈LIFO所需的操作。滑动窗口算法这是算法面试和实际开发中的常客。例如求一个数组所有长度为k的连续子数组的最大值。你需要维护一个当前窗口内元素的索引队列队头是最大值的索引新元素从队尾加入同时要从队尾弹出比它小的元素以保持单调性还要从队头弹出已经滑出窗口的旧索引。deque的两端操作特性在这里大放异彩。撤销/重做Undo/Redo历史记录很多编辑器或图形软件的历史记录功能有容量限制。当历史记录达到上限时加入新的操作需要从历史记录的头部最老的操作移除一项。这又是一个典型的push_back新增操作和pop_front移除最老操作的组合deque非常适合。任务调度器一个简单的多线程任务池主线程向任务队列尾部提交任务push_back工作线程从队列头部获取任务执行pop_front。deque可以很好地胜任。不过在多线程环境下需要额外的锁或使用无锁队列这是另一个话题了。作为vector的替代当无法预知大小且担心头部插入时如果你需要一个容器但完全无法预估最终会有多少元素又担心偶尔需要在头部插入数据那么deque是比vector更安全的选择。因为vector在头部插入是灾难而deque能从容应对。3. 从零开始deque的完整操作指南与实战代码理论说再多不如一行代码。我们抛开枯燥的文档直接看如何在实战中使用deque。我会假设你已经有基本的C和STL容器知识。3.1 基础操作创建、增删、访问首先包含头文件和基本的创建#include iostream #include deque #include algorithm // 用于std::find等算法 int main() { // 1. 创建空的deque std::dequeint dq1; // 2. 创建并初始化支持列表初始化C11 std::dequeint dq2 {1, 2, 3, 4, 5}; std::dequeint dq3{10, 20, 30}; // 3. 创建指定大小的deque元素默认初始化int为0 std::dequeint dq4(10); // 10个0 std::dequeint dq5(5, 99); // 5个99 // 4. 通过迭代器范围创建例如从数组或另一个容器 int arr[] {6, 7, 8, 9}; std::dequeint dq6(std::begin(arr), std::end(arr)); // 5. 拷贝构造函数 std::dequeint dq7(dq2); }核心操作两端增删这是deque的看家本领务必熟练掌握。std::dequestd::string taskQueue; // 尾部添加任务 taskQueue.push_back(Download file A); taskQueue.push_back(Process image B); // 现在队列: [Download file A, Process image B] // 头部添加一个高优先级任务紧急插队 taskQueue.push_front(Urgent: System update); // 现在队列: [Urgent: System update, Download file A, Process image B] // 查看但不移除 std::cout Next task: taskQueue.front() std::endl; // 输出: Urgent: System update std::cout Last task: taskQueue.back() std::endl; // 输出: Process image B // 移除并处理任务 std::string currentTask taskQueue.front(); taskQueue.pop_front(); // 移除头部任务 // 处理 currentTask (Urgent: System update)... // 现在队列: [Download file A, Process image B] // 尾部移除比如取消最后一个任务 taskQueue.pop_back(); // 现在队列: [Download file A]随机访问和迭代deque支持像数组一样的下标访问也支持迭代器。std::dequedouble prices {95.5, 96.0, 95.8, 97.2, 96.5}; // 1. 下标访问不检查边界速度快 double thirdPrice prices[2]; // 95.8 prices[4] 99.9; // 修改最后一个元素 // 2. at()成员函数访问检查边界越界抛出std::out_of_range异常 try { double price prices.at(10); // 会抛出异常 } catch (const std::out_of_range e) { std::cerr Access out of range: e.what() std::endl; } // 3. 使用迭代器遍历C11起推荐使用范围for循环 std::cout All prices: ; for (const auto price : prices) { std::cout price ; } std::cout std::endl; // 4. 使用传统迭代器当需要位置信息或反向遍历时 std::cout Prices in reverse: ; for (auto it prices.rbegin(); it ! prices.rend(); it) { std::cout *it ; } std::cout std::endl;3.2 进阶操作插入、删除与容量管理除了头尾我们也可以在中间操作但要牢记迭代器失效的坑。std::dequechar letters {a, b, d, e}; // 1. 在指定位置前插入元素返回指向新元素的迭代器 auto it letters.begin() 2; // 指向 d letters.insert(it, c); // 在d之前插入c // 现在 letters: [a, b, c, d, e] // 注意此操作后所有迭代器都可能失效it不能再使用。 // 2. 插入多个相同元素 letters.insert(letters.begin(), 3, z); // 在开头插入3个z // 现在: [z,z,z,a,b,c,d,e] // 3. 通过迭代器范围插入 std::vectorchar vec {x, y}; letters.insert(letters.end() - 1, vec.begin(), vec.end()); // 在最后一个元素e之前插入x,y // 现在: [z,z,z,a,b,c,d,x,y,e] // 4. 删除指定位置的元素返回被删元素之后位置的迭代器 it letters.begin() 3; // 指向第一个a it letters.erase(it); // 删除ait现在指向b // 现在: [z,z,z,b,c,d,x,y,e] // 同样删除操作后所有迭代器都可能失效。 // 5. 删除一个范围内的元素 auto first letters.begin() 1; auto last letters.begin() 4; letters.erase(first, last); // 删除 [first, last) 区间即第2到第4个元素下标1,2,3 // 删除的是 z(第二个), z(第三个), b // 现在: [z,c,d,x,y,e] // 6. 清空容器 letters.clear(); // size()变为0但capacity底层内存块不一定释放 // 7. 调整大小 letters.resize(5, o); // 将大小调整为5新增的元素用o填充 // 现在: [o,o,o,o,o] letters.resize(3); // 将大小调整为3丢弃末尾多余的元素 // 现在: [o,o,o]容量相关操作deque没有capacity()和reserve()成员函数这是它与vector的一个重要区别。因为deque的底层内存是分块管理的你无法也不需要像vector那样预留一整块连续空间。你只能查询它当前的大小(size())和是否为空(empty())。3.3 实战案例使用deque实现滑动窗口最大值这是LeetCode上的一道经典题目239. Sliding Window Maximum也是deque的绝佳应用场景。我们将维护一个存储索引的deque使其对应元素的值从队头到队尾是单调递减的。#include vector #include deque #include iostream std::vectorint maxSlidingWindow(const std::vectorint nums, int k) { std::vectorint result; if (nums.empty() || k 0) return result; if (k 1) return nums; // 窗口大小为1最大值就是自己 std::dequeint indexDeque; // 存储的是下标不是值 for (int i 0; i nums.size(); i) { // 步骤1维护单调性。如果队尾对应的值小于等于当前值则弹出队尾 // 因为只要当前值更大那么窗口内比它小的旧值就不可能再成为最大值了 while (!indexDeque.empty() nums[indexDeque.back()] nums[i]) { indexDeque.pop_back(); } // 步骤2将当前索引入队 indexDeque.push_back(i); // 步骤3检查队头是否已经滑出窗口。窗口范围是 [i-k1, i] // 如果队头索引小于窗口左边界则弹出 if (indexDeque.front() i - k 1) { indexDeque.pop_front(); } // 步骤4当窗口形成后i k-1记录当前窗口最大值队头对应的值 if (i k - 1) { result.push_back(nums[indexDeque.front()]); } } return result; } int main() { std::vectorint nums {1, 3, -1, -3, 5, 3, 6, 7}; int k 3; std::vectorint maxs maxSlidingWindow(nums, k); // 预期输出: [3, 3, 5, 5, 6, 7] for (int val : maxs) { std::cout val ; } std::cout std::endl; return 0; }这个算法的精妙之处在于每个元素最多入队一次、出队一次因此总时间复杂度是O(n)。deque的两端操作pop_back,push_back,pop_front都是O(1)完美匹配了算法需求。存储索引而不是值可以方便地判断元素是否还在窗口内。4. 性能陷阱、迭代器失效与最佳实践用错了容器或者用对了容器但用错了方法都可能带来性能灾难或诡异的bug。下面是我在多年开发中总结的关于deque的“血泪教训”。4.1 性能陷阱何时该用何时不该用绝对不要用deque替代需要高频、顺序遍历的vector。 这是最常见的误用。比如你要存储一百万个点然后对它们进行一系列数学运算如求均值、方差运算过程需要反复遍历整个容器。用deque会比vector慢很多因为CPU缓存失效。vector的数据是连续的一次预取可以加载一大片数据到缓存而deque的数据是分块的遍历时可能在多个内存块间跳跃造成缓存颠簸。实测对比#include chrono #include vector #include deque #include numeric #include iostream int main() { const int N 10000000; std::vectorint vec(N, 1); std::dequeint deq(N, 1); auto start std::chrono::high_resolution_clock::now(); long long sum_vec std::accumulate(vec.begin(), vec.end(), 0LL); auto end std::chrono::high_resolution_clock::now(); auto duration_vec std::chrono::duration_caststd::chrono::milliseconds(end - start); start std::chrono::high_resolution_clock::now(); long long sum_deq std::accumulate(deq.begin(), deq.end(), 0LL); end std::chrono::high_resolution_clock::now(); auto duration_deq std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout Vector sum: sum_vec , time: duration_vec.count() ms\n; std::cout Deque sum: sum_deq , time: duration_deq.count() ms\n; // 在我的测试机上vector通常比deque快2-5倍 return 0; }谨慎使用中间插入和删除。 虽然deque提供了insert和erase但它们的复杂度是O(n)。如果你需要频繁在中间位置操作std::list如果不需要随机访问或者std::vector如果插入点靠近尾部可能是更好的选择。对于deque中间操作不仅慢还会导致所有迭代器失效这是极其危险的。4.2 迭代器失效你必须牢记的规则这是C STL容器使用中的“雷区”deque的规则尤其需要小心。黄金法则在修改deque后之前获取的所有迭代器、指针和引用都应视为失效除非你能明确知道它仍然有效。具体来说push_back()和push_front()通常不会使迭代器失效。但是如果操作导致分配了新的内存块即当前块已满那么根据标准库的实现所有迭代器都可能失效。不过主流实现中在两端添加元素通常不会使迭代器失效引用和指针指向的元素本身地址不变但迭代器内部的状态可能需要更新安全起见视为失效。最安全的做法是假设它们会失效。pop_back()和pop_front()指向被删除元素的迭代器、引用和指针肯定失效。其他迭代器通常保持有效。insert()和erase()在任何位置导致所有迭代器、引用和指针失效。因为这两个操作可能引起元素的移动从而打乱整个内部结构。clear()和resize()缩小所有迭代器、引用和指针失效。swap()交换两个deque后迭代器、引用和指针会指向交换后的容器中的元素。错误示例std::dequeint dq {1, 2, 3, 4, 5}; auto it dq.begin() 2; // it 指向 3 std::cout *it std::endl; // 输出 3 // 在中间插入一个元素 dq.insert(dq.begin() 1, 99); // 在2前面插入99 // 此时所有迭代器失效包括 it // 错误访问失效的迭代器是未定义行为可能导致崩溃或输出错误值 // std::cout *it std::endl; // 绝对不要这么做 // 正确做法要么在修改后重新获取迭代器要么避免在修改后使用旧的迭代器。 it dq.begin() 3; // 重新计算现在指向原来的3位置后移了一位 std::cout *it std::endl; // 安全输出 3安全编程建议尽量在修改容器后重新获取迭代器。如果需要在循环中修改deque要特别注意迭代器的更新。例如用erase删除满足条件的元素时erase会返回下一个有效迭代器。std::dequeint dq {1, 2, 3, 4, 5, 6}; for (auto it dq.begin(); it ! dq.end(); /* 这里不递增 */) { if (*it % 2 0) { // 删除偶数 it dq.erase(it); // erase返回被删元素的下一个位置 } else { it; } } // dq 变为 [1, 3, 5]4.3 最佳实践与经验技巧默认使用vector有明确需求时才考虑deque。vector在大多数情况下都是最优选择因为它最简单、最快缓存友好。只有当你确实需要高效的头部插入/删除并且也需要随机访问时才选择deque。如果只需要头部操作不需要随机访问std::queue底层默认是deque或std::list可能更语义化。使用emplace系列函数替代push。 C11引入了emplace_front,emplace_back,emplace。它们直接在容器内构造对象避免了先创建临时对象再拷贝或移动的开销对于非平凡类型如自定义类、std::string等性能更好。std::dequestd::pairint, std::string dq; // 传统push_back需要构造临时pair dq.push_back(std::pairint, std::string(1, hello)); // 或者 dq.push_back({1, hello}); // 使用emplace_back直接传递构造参数效率更高 dq.emplace_back(1, hello); // 直接在deque内存中构造pair注意dequebool的特殊性。 和vectorbool一样dequebool可能是一个特化版本它为了节省空间每个bool值可能只占一个比特。但这会导致一些问题你无法获取到一个bool元素的引用operator[]返回的可能是一个代理对象。如果需要存储布尔值并正常使用引用可以考虑用std::dequechar或std::dequeint或者使用std::vectorchar。与算法库协同工作。deque提供随机访问迭代器因此它可以和绝大多数STL算法完美配合如std::sort,std::find,std::copy等。但要注意std::sort要求随机访问迭代器deque可以但list就不行。不过对deque排序可能比vector慢因为元素移动可能涉及跨块。内存碎片问题。 由于deque由多个内存块组成长期频繁的插入删除可能导致内存碎片。虽然现代内存分配器对此有优化但在极端高性能或内存受限的嵌入式场景下这一点仍需考虑。对于生命周期长、大小稳定的队列可以考虑使用定长的环形缓冲区Circular Buffer来实现以获得更确定性的性能。
返回列表