ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

std::deque深度解析:双端队列的底层原理与应用实战

std::deque深度解析:双端队列的底层原理与应用实战 做了这么多年C我越来越有个体会STL容器大部分人只熟vector和list中间的deque总是被一带而过好像它只是“两者的过渡品”。可实际上std::deque双端队列发音接近“deck”是四个序列容器里设计最巧妙、最容易被人用错也最值得深挖的一个。它同时支持头部和尾部的O(1)插入删除又支持下标随机访问内存上还不需要像vector那样一次性组织一整块连续空间。很多看着麻烦的场景比如生产者消费者队列、滑动窗口、临时任务缓冲用deque就是最自然的选择。这篇我把deque从头到尾拆一遍从底层结构到接口行为从迭代器失效到内存释放最后给两个可以直接抄的实战示例希望能让更多人用顺这个“低调但能打”的容器。1. 为什么需要dequevector和list的中间态1.1 vector的头部尴尬每次插入都是一次全员搬家先聊聊vector的问题。vector的优点是随机访问快到几乎没有代价缺点是“只在尾部舒服”。你调用push_back均摊一下确实O(1)但如果调用insert(vec.begin(), x)或者频繁从头部删除那代价就完全不一样了。因为vector必须保证所有元素存储在一块连续地址上头部插入一个元素后面所有元素都得往后挪一格。100万个int头插一次就是100万次拷贝你要是每次只加一个总成本基本就退化成平方级了。我见过不少同事为了绕开这个问题硬着头皮用std::vectorint v; reverse; v.push_back; reverse;这种土办法做头插虽然能用但代码可读性很差而且在元素是自定义结构体、拷贝代价不低的时候两次reverse带来的额外搬运会让你很心疼。pop_front也一样。v.erase(v.begin())在向量里意味着前面空了一个位置所有后续元素都要前移。如果你把vector当队列用尾部进、头部出数据量一大基本就是在做全量搬家性能会明显崩。vector的另外一个大坑是扩容当size capacity时它要重新申请一块更大的连续内存把原来的所有元素搬过去再释放旧内存。均摊后是O(1)但单次峰值延迟很吓人某些实时性要求高的场景根本没法接受这种“突然卡一下”。说到底vector的设计目标就是“随机访问优先尾部操作优先”它没有义务为头部操作和频繁扩容做优化。1.2 deque的分段缓冲区设计用一块“中控器”串起多段内存deque的底层想法非常直接我不搞一整块连续内存而是切成很多固定大小的缓冲区block/buffer每个缓冲区内部是连续的缓冲区之间不一定连续。然后再用一个“中控器”标准库里习惯叫map把这些缓冲区的指针存起来。用的时候根据元素的全局下标先算出它在第几块缓冲区里再算出它在缓冲区内部第几个位置然后做两次指针跳转就能取到数据。这个设计的好处很多。第一头部插入不再需要把整片数据往后挪前面的缓冲区满了就在中控器前面再登记一个新缓冲区指针新元素放进去就行。第二不会因为单个缓冲区满了就整体扩容真正的元素数据各管各的从头到尾不需要搬动任何已有元素。第三每个缓冲区就是一小段连续内存一个窗口内的元素依然能享受CPU缓存的局部性不像list那样每个节点单独分配到处跳。那中控器本身会不会扩容会。如果缓冲区数量太多存缓冲区块指针的数组放不下了它会重新分配一个更大的指针数组把旧指针搬过去。但注意搬的是“指针”不是“元素”。假设你有一万个缓冲区那也就搬一万个指针按8字节算才80KB远比搬几百万个元素快。这个差别就是deque和vector在扩容本质上最大的区别。也正因如此deque在“需要动态增长、但不想在扩容时搬运已有元素”的场合下是一个比vector更稳的选择。你甚至可以把它理解成“一张桌子配很多抽屉”vector是必须换一张更大的桌子然后把所有东西重摆一遍deque是抽屉不够了就在桌子边再挂一个新抽屉原有的抽屉完全不动。2. deque核心操作实战解析2.1 双端添加与删除push_front、push_back、emplace系列deque最招牌的能力就是两端操作同样是O(1)。先看一段最基础的代码#include deque #include iostream int main() { std::dequeint dq; dq.push_back(3); // 尾部插入 3 dq.push_front(1); // 头部插入 1 dq.emplace_back(4); dq.emplace_front(0); // 此时容器里0 1 3 4 for (int x : dq) { std::cout x ; } std::cout \n; std::cout dq.front() \n; // 0 std::cout dq.back() \n; // 4 dq.pop_front(); dq.pop_back(); // 此时容器里1 3 }如果你的元素是自定义结构体建议尽量用emplace系列而不是push_back加临时对象。emplace会把构造参数直接转发给构造函数在容器底层的缓冲区里原地构造省一次临时对象的拷贝或移动。举个例子struct Order { int id; double amount; Order(int i, double a) : id(i), amount(a) {} }; std::dequeOrder orders; orders.emplace_back(1, 99.5); orders.emplace_front(2, 88.3);这段代码如果写成orders.push_back(Order(1, 99.5))要先在栈上构造一个临时Order再移动或拷贝进deque内部缓冲区用了emplace_back以后这些临时对象就免了。在对象拷贝成本不低、或者容器频繁插入的长生命周期场景里这个区别会实实在在反映到性能上。front()和back()返回的是引用既能读也能改dq.front() 100; dq.back() 10;不过这里有个老生常谈但总是有人踩的坑对一个空deque调用front()、back()、pop_front()、pop_back()全是未定义行为。很多人的代码在Debug版里可能还没事Release版就莫名其妙崩这类问题占了很大一部分。安全写法永远是先判空再操作。2.2 中间位置插入与删除insert和erase的真实开销很多人以为deque头尾操作快中间操作会像list一样很灵活这个理解不准确。deque本质还是“分段连续”的中间插入一个元素依然要移动大量已有元素来腾位置。具体怎么移动标准库实现里有很聪明的优化它会比较插入点离头部更近还是离尾部更近然后选择搬动元素较少的那一端。比如往begin() 2的位置插入离头部更近那就从头部方向把前面几个元素往前挪出一个空位如果插入位置靠近尾部就从尾部方向往后挪。这个策略保证了中间插入/删除的代价大约是O(min(pos, size - pos))比vector那种无论在哪都必须“整体从头搬”要灵活但依然是线性代价不可能做到list那样只改几个指针。std::dequeint dq {1, 2, 3, 4, 5}; auto it dq.begin() 2; dq.insert(it, 99); // 结果1 2 99 3 4 5如果你在循环里反复往中间插数据deque的性能会肉眼可见地下降。它不是std::list的替代品中间操作频繁的场景list或者std::map/std::unordered_map可能更合适。从这里也能引出一个重要结论迭代器、引用在中间插入操作后几乎都会失效因为元素发生了搬运。这个我在后面第3部分会专门展开。2.3 随机访问operator[]和at()的区别以及隐藏的寻址成本deque对下标访问的支持是“有但不是白给的”。operator[]不做边界检查at()会检查边界并抛std::out_of_range异常std::dequeint dq {10, 20, 30}; std::cout dq[0] \n; // 10不检查越界 std::cout dq.at(5) \n; // 抛异常因为越界从性能角度看deque的operator[]比vector慢一点。vector的地址计算是_M_start index这种连续指针运算几乎没有额外的间接跳转。deque呢它要先定位缓冲区再定位缓冲区内偏移等于多了层间接寻址。这个差距在元素数量少的时候几乎可以忽略但在一个超大规模循环里反复遍历时会有一定影响。你要是在写性能敏感代码建议用迭代器遍历或者把deque当作容器时尽量让访存模式集中在同一批缓冲区里能省不少事。还有一个很多人会误解的地方dq[0]不能当作数组首地址用。vector保证v[0]就是整个连续数组的起点你甚至可以拿它跟C接口交互deque没有这个保证它没有data()方法也不保证dq[0]到dq[size()-1]是连续的。谁要是把dq[0]传给一个期待连续内存的C函数在大部分时候能跑通纯属运气一旦缓冲区切换就会读到错误数据。这种事情我亲眼见过不止一次。3. 迭代器结构与失效规则一定要重视的坑3.1 deque的“胖迭代器”四指针结构带来的性能细节deque的迭代器不像vector迭代器那样就是一个裸指针它需要同时知道当前缓冲区的位置和边界。在常见的libstdc实现里std::deque::iterator内部至少有四个指针层级的信息当前元素指针、当前缓冲区的首元素指针、当前缓冲区的尾部指针、以及当前缓冲区在中控器里的节点指针。这个设计直接用到的场景是迭代器自增。当迭代器走到当前缓冲区末尾时它必须跳到中控器里的下一个节点重新加载新缓冲区的首尾指针再继续前进。也就是说每次it都可能存在一次分支判断。相比之下vector迭代器的自增就是一个普通指针自增非常纯粹的流水线友好操作。所以用deque做循环遍历时迭代器自增的开销会比vector高一点。我个人的习惯是能写范围for循环就写范围for循环for (auto x : dq)既简洁又能让编译器优化得更彻底。如果你非要手写迭代器循环尽量少在循环体里拷贝迭代器因为那个结构不是几个字节能放下的拷贝也比裸指针贵。3.2 失效规则速查与经验总结deque的迭代器失效规则我见过很多资料写得含糊但实际使用里记住下面这张表就够了操作迭代器影响引用/指针影响两端插入push_back/push_front/emplace所有迭代器可能失效已有元素的引用通常保持有效中间插入insert/emplace所有迭代器失效所有引用和指针失效两端删除pop_back/pop_front被删除元素的迭代器失效被删除元素的引用失效中间删除erase所有迭代器失效所有引用和指针失效clear全部失效全部失效这里最反直觉的一点是两头插入会连带已有元素的迭代器一起失效但不影响引用。原因很简单插入要动的是“缓冲区指针的布局”可能导致中控器重新分配所以迭代器里保存的节点信息就过期了而已有元素本身没有搬动所以只要持有引用int x dq.front()就能继续安全使用。不同STL实现的细节会有出入所以我们写代码的时候最安全的姿势就是做了一次结构变动之后不再依赖任何之前保存的迭代器。不要赌它没有失效赌输了就是未定义行为在线上表现可能就是Access Violation。我见过最典型的错误是下面这种循环std::dequeint dq {1, 2, 3}; for (auto it dq.begin(); it ! dq.end(); it) { if (*it % 2 0) { dq.push_back(*it * 10); // 操作后 it、end 都可能失效 } }第一次push_back之后缓存的end()可能已经失效循环继续用it ! dq.end()比较执行到后面就是死循环或者直接崩溃。如果真有向deque尾部逐步追加的需求建议先记下初始大小用下标遍历size_t n dq.size(); for (size_t i 0; i n; i) { // 处理 dq[i] }这招也不是万能但至少避免迭代器失效问题。更稳的办法是把追加的元素先放到另一个vector或deque等遍历完再一次性合并。4. 内存管理与性能评估4.1 deque没有reserve和capacity提前布点怎么处理很多从vector转过来的人第一反应是dq.reserve(10000)然后编译报错接着一头雾水。deque确实没有reserve()和capacity()这是它的设计选择分段缓冲区意味着它没必要一次性准备好一整块地址连续的大内存也不需要向用户暴露“容量”这个语义。那提前知道元素规模怎么办直接用构造参数或者resize()std::dequeint dq(100000); // 直接创建10万个默认元素 std::dequeint dq2; dq2.resize(5000); // 调整大小如果元素类型默认构造代价低这样能省掉大量后续扩缓冲区的开销。但要注意这种预分配方式会真的把元素构造出来不像vector::reserve那样只备空间。如果元素构造很昂贵还是老老实实按需增长吧deque的均摊扩展成本本身也不高。另一个常见的痛点就是内存释放。很多人以为clear()之后deque占的内存会自动还给系统实测下来经常不是这样。deque在clear()时会把所有元素析构但不一定释放所有缓冲区至少不会保证把容量归零。标准里根本没有capacity的概念所以“收缩”也不是标准义务。如果你真想彻底释放deque占用的内存最有效的传统写法还是那位老朋友std::dequeint().swap(dq);用一个空deque和现在的dq交换内部资源临时对象析构时把旧内存带走释放。操作之后dq变成空容器原内存归还给分配器。这个方法在vector上同样适用属于“老中医”级别的通用技巧。4.2 缓冲区大小策略512字节窗口与缓存局部性deque每个缓冲区到底多大标准没有硬性规定。GCC libstdc的做法是设定目标块大小为512字节然后按512 / sizeof(T)计算出每块能放多少个元素如果算出来是0即元素本身超过512字节那就一个元素占一块。MSVC的标准库实现也采用类似的“按对象大小折算块内元素个数”的策略只是具体常数不同。这个设计带来几个实际影响。第一小元素类型的缓冲区能存很多个元素比如int一块就是128个遍历时大概率命中同一段连续内存缓存局部性比list好不少。第二当元素是很大的结构体时每块只放一个元素这时deque的存储模式就退化得像“连续版list”遍历时每个块之间跳跃缓存表现会明显下降。第三缓冲区大小是固定的所以内存碎片压力比list这种“一对一分配”的方式要小。在实际性能表现上deque通常介于vector和list之间随机访问略慢于vector但远快于list顺序遍历快于list但可能略逊于vector两端操作远优于vector和list持平。4.3 三容器横评到底什么时候该选deque维度vectordequelist随机访问O(1)常数极小O(1)常数较大O(n)头部插入/删除O(n)O(1)O(1)尾部插入/删除O(1)均摊O(1)O(1)中间插入/删除O(n)O(min(pos,n-pos))已知迭代器时O(1)内存连续性整块连续分段连续完全不连续扩容时元素搬运需要整体搬迁不搬元素只搬中控指针无此概念capacity/reserve有没有没有迭代器结构裸指针级别胖迭代器节点指针典型场景随机访问、尾部操作双端队列、滑块窗口中间频繁增删我的选型经验很简单只用尾部操作、需要最大随机访问性能选vector需要两端操作不比中间操作多、又不想list那种节点开销选deque需要任意位置高频插入且无需随机访问选list。实际工程里很多“队列”需求只要不用线程安全约束deque几乎总是比list更像正确的选择——它能随机访问内存又连续得更好还不怕扩容搬家。5. 实战deque做任务队列与滑动窗口5.1 生产者消费者队列back生产front消费std::queue默认的底层容器就是deque原因很简单队列需要从尾部进、从头部出这两个操作恰好都是deque的看家本领。如果自己做个简单多线程任务队列deque同样是好选择。下面是个最小可用的示例核心结构就是mutex condition_variable deque#include deque #include mutex #include condition_variable template typename T class BlockingQueue { public: void push(const T value) { { std::unique_lock lock(mtx_); dq_.push_back(value); } cv_.notify_one(); } T pop() { std::unique_lock lock(mtx_); cv_.wait(lock, [this] { return !dq_.empty(); }); T value std::move(dq_.front()); dq_.pop_front(); return value; } bool empty() const { std::unique_lock lock(mtx_); return dq_.empty(); } private: mutable std::mutex mtx_; std::condition_variable cv_; std::dequeT dq_; };几个细节值得说说。notify_one()放在锁外能减少线程切换时的锁竞争。pop()里用cv_.wait的第二个参数也就是带谓词的重载能正确处理“伪唤醒”——线程没被真正唤醒成功时继续等。返回元素时用std::move(dq_.front())再pop_front()对可移动对象可以避免一次拷贝。如果你改造成“双端任务队列”——一端派发新任务、另一端回收完成结果deque的两端O(1)特性也能完美覆盖。这类结构在异步框架里比我原来想象得还要常见早期我用list实现过后来换成deque内存开销和访问速度都明显改善。5.2 滑动窗口最大值最能体现deque价值的经典场景LeetCode的“滑动窗口最大值”问题标准最优解就是用一个deque存候选下标窗口滑动时两端都要淘汰元素这几乎是为deque量身定做的题目。核心思路是维护一个“从左到右递减”的下标队列新元素入队前先把队尾所有比它小的下标全部弹掉因为它们不可能成为后续窗口的最大值窗口滑动时把已经离开窗口的过期下标从队头弹掉。这样队头永远保存当前窗口最大值的下标。#include deque #include vector std::vectorint maxSlidingWindow(const std::vectorint nums, int k) { std::vectorint ans; std::dequeint dq; // 存下标内部值单调递减 for (int i 0; i static_castint(nums.size()); i) { // 1. 清除窗口外的过期下标 while (!dq.empty() dq.front() i - k) { dq.pop_front(); } // 2. 从尾部淘汰所有不大于当前值的下标 while (!dq.empty() nums[dq.back()] nums[i]) { dq.pop_back(); } // 3. 当前下标入队 dq.push_back(i); // 4. 窗口形成后队头就是当前窗口最大值下标 if (i k - 1) { ans.push_back(nums[dq.front()]); } } return ans; }为什么其他容器不行vector从头部淘汰元素是O(n)每次窗口滑动一次都搬数据整体退化成O(nk)list虽然头部删除快但随机访问弱维护单调结构需要来回扫描也麻烦deque是唯一一个“头尾都能快速增删还能随机访问候选下标”的容器全套操作都落在它最擅长的点上。这个例子我建议所有想理解deque的人都手写一遍写完之后你对“双端”两个字的体会绝对不一样。6. 常见坑与排查技巧实录6.1 空deque上调用front/back最容易被忽视的UB我遇到过好几次线上崩溃最终定位到的问题都是类似这样的代码std::dequeint dq; // 中间某条逻辑出了岔子没往里push数据 if (dq.front() 42) { // dq是空的这里就是UB // ... }front()、back()、pop_front()、pop_back()这些操作不会告诉你队列是不是空的它们要求调用前容器非空。空容器上调用它们标准是说未定义行为所以不同编译器不同优化级别下表现可能完全不一样。有的会直接崩出段错误有的可能读到一段被污染的内存有的表面没事但数据已经错了。排查这种问题时我第一反应是检查empty()判断是否覆盖了所有出口。不要写if (!dq.empty() dq.front() 42)之外的任何形式就这么平铺直叙别炫技。多线程场景下还要小心即使你判断了非空下一步pop_front()之前容器可能已经被其他线程掏空了这也是为什么我上面任务队列示例里全部用锁保护绝不在锁外碰容器的状态。6.2 迭代器失效还没被编译器拦住关于排查的心里话前面已经说过deque两端的插入也会使已有迭代器失效。这个问题编译器不会主动提醒你它只会让你的程序在某个性能好得多的Release版本里突然崩掉。我记得有一次排查一个偶发崩溃整套逻辑明明在前面测试环境怎么跑都没事到线上压测一会儿就挂。后来用AddressSanitizer重编马上定位到迭代器失效的非法访问。遇到类似的疑难杂症我建议三板斧优先开AddressSanitizer/UndefinedBehaviorSanitizer重编一遍这类工具会把越界和UB点名报出来然后把所有“缓存迭代器”的代码全部拿掉凡是要在插入后继续遍历的地方全部改用下标或者重新取begin()最后在关键容器操作前后加日志/断言确认size()变化是否符合预期。平时写的时候留个心眼一旦do了insert、erase、push_back、push_front这类操作之前保存的任何迭代器都不要再碰。这条规则比背十遍失效表都有用。6.3 clear后内存依然被占用别等GC帮你收C没有GCclear()只析构元素不保证释放容器的底层缓冲区。deque尤其如此因为它内部有一堆缓冲区不同STL实现甚至在clear后还会保留一部分空缓冲区供后续复用。如果你把deque当成一个会不断“填满-清空”的缓冲池程序的长驻内存会比预期高不少。要确认内存是不是真被占了可以在构造一个大deque后观察内存再clear并观察。如果发现内存没降别慌这不是内存泄漏只是容器还在为后续插入保留底层存储。这时候std::dequeT().swap(dq)就能强制释放。我有一个经验任何生命周期长、又经历过大量push/pop循环的deque都要定期评估它的峰值内存。比如做一个高频交易行情缓存每一秒都在push_back新数据同时从pop_front清理旧数据如果不关注缓冲区数量容器可能一直维持在一个很大的规模上内存曲线下不去。遇到这种情况可以考虑定时收缩或者在确认没有并发访问的时间点做一次swap释放。当然性能敏感场景下频繁释放不一定是好事这个尺度只能根据你自己的内存水位和性能测试来定。踩过几次坑之后我现在的建议是只要在项目里出现了“长期驻留的deque”就先问一句——谁还持有指向它的旧迭代器和旧引用有没有人一边push一边遍历有没有人拿dq[0]当连续数组用这三个问题问完大部分deque相关的隐形问题就都浮出水面了。
返回列表