C++ STL deque 双端队列:原理、实现与实战应用
2026/7/22 5:03:52 网站建设 项目流程

1. deque是什么?为什么你需要了解它?

如果你写过C++,用过vector,也用过list,那你可能会在某个时刻感到一丝纠结:我需要一个能快速随机访问的容器,但又在头部频繁插入删除,vector的头部操作是O(n)的,太慢了;list倒是能在两端O(1)操作,但随机访问又是O(n)的,没法用下标直接定位。这个时候,deque就该登场了。

deque,全称double-ended queue,双端队列。这个名字很直白地告诉你它的核心能力:两端进出。但它的内涵远不止于此。在C++ STL中,deque被设计成一个“序列式容器”,它承诺在两端进行插入和删除操作都拥有分摊常数时间复杂度,同时支持随机访问(通过operator[]at())。这听起来有点像结合了vectorlist的优点,事实也的确如此,虽然它在某些方面做了权衡。

我第一次深入接触deque是在实现一个高性能的网络数据包缓冲区时。数据包从网络一端流入(尾部插入),从另一端被处理模块取出(头部弹出),同时我还需要能快速随机抽查某个位置的数据包进行校验。用vector,头部弹出会导致大量数据移动;用list,随机抽查需要遍历。deque完美地解决了这个场景。理解deque,不仅仅是多学一个容器,更是理解STL设计者如何在连续存储与节点存储之间寻找精妙平衡点的绝佳案例。它适合所有已经熟悉vectorlist,并希望构建更复杂、性能要求更高的数据结构的C++开发者。

2. deque的整体设计与核心思路拆解

deque的魔法在于其内部结构,它并不是一块单纯的连续内存,也不是简单的链表。标准并未规定其具体实现,但主流实现(如GCC的libstdc++和Clang的libc++)都采用了一种类似“动态数组的数组”或“分段连续”的结构。

2.1 核心数据结构:中控器与缓冲区

你可以把deque想象成一本活页夹。这本活页夹有一个目录(中控器,通常是一个指针数组),目录的每一项记录着一个活页夹(缓冲区,一段固定大小的连续内存)的起始地址。数据就存储在这些活页夹里。

  1. 中控器 (Map): 这是一个指针数组,每个指针指向一块缓冲区。中控器本身是动态分配的,当活页夹数量增多,目录不够用时,可以重新分配一个更大的目录,并将旧目录的条目复制过去(这个过程开销相对较小,因为只复制指针)。
  2. 缓冲区 (Buffer): 这是实际存储元素的内存块,大小固定。在主流实现中,缓冲区大小通常是这样计算的:如果元素类型大小大于512字节,则一个缓冲区只放一个元素;否则,缓冲区大小 = 512 / 元素大小。例如,对于int(4字节),一个缓冲区可以存放512 / 4 = 128个int

这种设计带来了几个关键优势:

  • 两端高效操作: 在头部插入时,如果第一个缓冲区还有空间,就直接在前面放;如果满了,就在中控器头部新增一个缓冲区。尾部插入同理。这避免了vector那样整体搬移的巨大开销。
  • 随机访问: 虽然数据在物理上是不完全连续的,但通过计算可以快速定位。给定一个索引i,我们可以通过i / 每个缓冲区的容量找到对应的中控器索引(第几个活页夹),再通过i % 每个缓冲区的容量找到在该缓冲区内的偏移量。这是一个O(1)的操作。
  • 迭代器设计复杂deque的迭代器比vector的普通指针复杂得多,它需要记录:当前元素指针、当前缓冲区起始指针、当前缓冲区末尾指针、以及指向中控器中当前位置的指针。这样在迭代器++--时,才能判断是否需要跳到上一个或下一个缓冲区。

2.2 与vector和list的权衡

理解deque,一定要放在与vectorlist的对比中看:

特性std::vectorstd::dequestd::list
内部结构单段连续内存分段连续内存(中控器+多缓冲区)双向链表
随机访问O(1), 极快O(1), 较快O(n), 慢
头部插入/删除O(n), 慢(除首元素外需移动)分摊O(1), 快O(1), 快
尾部插入/删除分摊O(1), 快分摊O(1), 快O(1), 快
中间插入/删除O(n), 慢O(n), 慢O(1), 快(已知位置)
迭代器类型随机访问迭代器随机访问迭代器双向迭代器
内存局部性极好, 数据完全连续较好, 缓冲区内连续,缓冲区间不连续差, 节点分散
内存开销低(仅容量可能略大于大小)中(有中控器和可能未满的缓冲区开销)高(每个元素都有前后指针开销)

注意: 这里deque的“分摊O(1)”需要理解。单次扩容(新增缓冲区)可能涉及中控器的重分配,但平均到多次操作上,时间复杂度是常数。它的性能表现非常稳定,不像vector在扩容时会有一次明显的性能抖动。

选择容器的简单指南

  • 需要极致随机访问速度,且主要在尾部操作 -> 选vector
  • 需要频繁在任意位置插入删除,且不需要随机访问 -> 选listforward_list
  • 需要频繁在头部和尾部插入删除,同时还需要不错的随机访问能力 -> 这就是deque的主场。

3. deque的核心接口解析与使用要点

知道deque的原理后,我们来看看怎么用它。它的接口和vector非常相似,这降低了学习成本。

3.1 基础构造与赋值

#include <deque> #include <iostream> int main() { // 1. 默认构造 std::deque<int> dq1; // 2. 使用n个val初始化 std::deque<int> dq2(5, 100); // 包含5个100 // 3. 使用迭代器范围初始化 std::vector<int> vec = {1, 2, 3, 4, 5}; std::deque<int> dq3(vec.begin(), vec.end()); // 拷贝vec的内容 // 4. 拷贝构造 std::deque<int> dq4(dq3); // 5. 初始化列表 (C++11) std::deque<int> dq5 = {10, 20, 30, 40}; // 赋值操作 dq1 = dq5; // 拷贝赋值 dq2.assign(3, 50); // 重新赋值为3个50 dq3.assign(vec.begin(), vec.end()); // 用迭代器范围赋值 dq4.assign({1, 2, 3}); // 用初始化列表赋值 return 0; }

3.2 关键特性操作:两端操作

这是deque的看家本领,接口清晰明了:

std::deque<int> dq; // 尾部操作 dq.push_back(1); // 尾部插入1 dq.emplace_back(2); // C++11, 尾部原位构造, 效率可能比push_back高(避免拷贝) int back_val = dq.back(); // 获取尾部元素引用 dq.pop_back(); // 删除尾部元素, 但不返回它 // 头部操作 (vector没有这些!) dq.push_front(0); // 头部插入0 dq.emplace_front(-1); // 头部原位构造 int front_val = dq.front(); // 获取头部元素引用 dq.pop_front(); // 删除头部元素 std::cout << "Front: " << dq.front() << ", Back: " << dq.back() << std::endl;

实操心得emplace_backemplace_front是 C++11 引入的“原位构造”方法。对于非平凡类型(如自定义类),push_back需要先构造一个临时对象,再移动或拷贝到容器中;而emplace_back直接传递构造参数给容器,让容器在内存中直接构造对象,省去了临时对象的创建和转移。在性能敏感的场景下,应优先使用emplace_*系列函数。

3.3 随机访问与容量查询

deque支持像数组一样的随机访问,这是它区别于list的关键。

std::deque<int> dq = {10, 20, 30, 40, 50}; // 1. 使用 operator[], 不进行边界检查, 访问更快 std::cout << dq[2] << std::endl; // 输出 30 dq[2] = 300; // 可以修改 // 2. 使用 at(), 进行边界检查, 越界会抛出 std::out_of_range 异常 try { std::cout << dq.at(10) << std::endl; // 会抛出异常 } catch (const std::out_of_range& e) { std::cerr << "Out of range error: " << e.what() << std::endl; } // 3. 容量相关 (注意:deque没有capacity()概念, 因为它不是单段连续内存) std::cout << "Size: " << dq.size() << std::endl; // 元素个数 std::cout << "Empty? " << dq.empty() << std::endl; // 是否为空 // dq.max_size(); // 理论可容纳的最大元素数, 通常很大, 实际意义不大 // 4. 调整大小 dq.resize(8); // 将大小调整为8, 新增的元素默认初始化(int为0) dq.resize(10, 999); // 将大小调整为10, 新增的元素初始化为999 dq.resize(3); // 将大小调整为3, 会丢弃尾部多余的元素

重要区别vectorcapacity()reserve()来管理底层内存,避免频繁扩容。但deque没有capacity()的概念,因为它是由多个固定大小的缓冲区组成的。你无法为整个deque“预留”空间,它的扩容是以缓冲区为单位的。这是使用deque时需要接受的一个设计差异。

3.4 迭代器与遍历

deque提供随机访问迭代器,意味着它支持所有vector支持的迭代器操作,包括迭代器加减数字。

std::deque<std::string> dq = {"Apple", "Banana", "Cherry", "Date"}; // 1. 常规迭代器遍历 std::cout << "Using iterators:\n"; for (auto it = dq.begin(); it != dq.end(); ++it) { std::cout << *it << " "; } std::cout << std::endl; // 2. 基于范围的for循环 (C++11) std::cout << "Range-based for loop:\n"; for (const auto& fruit : dq) { std::cout << fruit << " "; } std::cout << std::endl; // 3. 随机访问迭代器的威力:可以直接加减 auto it = dq.begin(); std::cout << "Third element: " << *(it + 2) << std::endl; // 输出 Cherry // 4. 反向迭代器 std::cout << "Reverse order:\n"; for (auto rit = dq.rbegin(); rit != dq.rend(); ++rit) { std::cout << *rit << " "; } std::cout << std::endl;

3.5 插入与删除

除了两端,deque也支持在任意位置插入删除,但效率是O(n),因为可能需要移动多个缓冲区的元素。

std::deque<int> dq = {1, 5, 9}; // 在指定位置前插入元素 auto it = dq.begin() + 1; // 指向5 dq.insert(it, 3); // deque变为 {1, 3, 5, 9} dq.insert(it, 2, 4); // 在it(现在指向5)前插入2个4, {1, 3, 4, 4, 5, 9} std::vector<int> vec = {7, 8}; it = dq.end(); // 指向末尾 dq.insert(it, vec.begin(), vec.end()); // 在末尾插入vector范围, {1, 3, 4, 4, 5, 9, 7, 8} // C++11 初始化列表插入 dq.insert(dq.begin(), {0, 0}); // 头部插入两个0 // 删除元素 dq.erase(dq.begin()); // 删除第一个元素 dq.erase(dq.begin() + 2, dq.begin() + 4); // 删除区间 [第三元素, 第五元素) // 清空所有元素 dq.clear();

注意事项inserterase操作会使指向deque所有迭代器、指针和引用失效。这是因为插入可能导致中控器重新分配(虽然概率比vector小),或者元素移动。这是一个常见的坑点。相比之下,push_backpush_front通常只会使部分迭代器失效,但最安全的做法是,在插入/删除操作后,不要继续使用旧的迭代器。

4. 动手模拟实现一个简易deque

理解了原理和接口,我们尝试自己实现一个极度简化的MyDeque。这将彻底巩固你对deque内部机制的理解。我们的目标是实现一个能push_backpush_front、随机访问、并且能通过迭代器遍历的deque

4.1 定义缓冲区大小与核心数据结构

首先,我们定义一些常量和核心结构。我们的设计将采用经典的“中控器映射缓冲区”模型。

// my_deque.h #pragma once #include <cstddef> #include <iterator> #include <memory> namespace my { // 假设缓冲区大小为 4, 方便调试和观察。实际库实现会根据元素类型动态计算。 const size_t BUFFER_SIZE = 4; template <typename T> class deque { private: // 中控器:一个指针数组,每个指针指向一个缓冲区(T数组) T** map; // 中控器的容量(能容纳多少个缓冲区指针) size_t map_capacity; // 中控器的中心位置,或起始使用位置(简化处理,我们固定从中间开始使用) // 实际STL实现会更复杂,有start和finish迭代器来记录首尾位置。 size_t map_center; // 迭代器类:它是理解deque实现的关键 struct iterator { // 当前元素指针 T* cur; // 当前缓冲区起始指针 T* first; // 当前缓冲区结束指针(最后一个元素的下一个位置) T* last; // 指向中控器中,当前缓冲区指针的指针 T** node; // 迭代器分类 using iterator_category = std::random_access_iterator_tag; using value_type = T; using difference_type = std::ptrdiff_t; using pointer = T*; using reference = T&; // 构造函数 iterator(T* cur_, T** node_) : cur(cur_), node(node_) { first = *node; last = first + BUFFER_SIZE; } // 解引用 reference operator*() const { return *cur; } pointer operator->() const { return cur; } // 前置++ iterator& operator++() { ++cur; if (cur == last) { // 如果到达当前缓冲区末尾 set_node(node + 1); // 跳到中控器的下一个节点 cur = first; // 当前指针指向新缓冲区的开始 } return *this; } // 后置++ iterator operator++(int) { iterator tmp = *this; ++(*this); return tmp; } // 前置-- iterator& operator--() { if (cur == first) { // 如果到达当前缓冲区开头 set_node(node - 1); // 跳到中控器的上一个节点 cur = last; // 当前指针指向新缓冲区的末尾 } --cur; return *this; } // 后置-- iterator operator--(int) { iterator tmp = *this; --(*this); return tmp; } // 随机访问迭代器必须支持加减运算 iterator& operator+=(difference_type n) { difference_type offset = n + (cur - first); if (offset >= 0 && offset < difference_type(BUFFER_SIZE)) { // 目标位置仍在当前缓冲区内 cur += n; } else { // 需要跨缓冲区 difference_type node_offset = offset > 0 ? offset / difference_type(BUFFER_SIZE) : -((-offset - 1) / BUFFER_SIZE) - 1; set_node(node + node_offset); cur = first + (offset - node_offset * BUFFER_SIZE); } return *this; } iterator operator+(difference_type n) const { iterator tmp = *this; return tmp += n; } // 同理可以实现 -= 和 - // 比较运算符 bool operator==(const iterator& other) const { return cur == other.cur; } bool operator!=(const iterator& other) const { return !(*this == other); } private: void set_node(T** new_node) { node = new_node; first = *new_node; last = first + BUFFER_SIZE; } }; // deque类的私有成员 iterator start; // 指向第一个元素 iterator finish; // 指向最后一个元素的下一个位置 // ... 其他辅助函数,如 allocate_map, allocate_buffer 等 }; }

这段代码定义了迭代器的核心。iterator内部保存了cur(当前元素)、firstlast(当前缓冲区边界)以及node(指向中控器对应槽位的指针)。set_node函数用于在迭代器跨越缓冲区时更新这些指针。operator++operator--需要检查边界并可能跳转缓冲区,这是deque迭代器与vector指针最根本的不同。

4.2 实现内存管理与构造/析构

接下来,我们实现deque本身的内存管理、构造函数和析构函数。

// 接在 my_deque.h 的 deque 类私有部分 private: // 分配指定数量的缓冲区指针空间(中控器) void allocate_map(size_t n) { map_capacity = std::max(n, size_t(8)); // 至少分配8个槽位 map = new T*[map_capacity]; map_center = map_capacity / 2; // 从中间开始使用,方便两端扩展 } // 分配一个缓冲区 T* allocate_buffer() { return new T[BUFFER_SIZE]; } // 释放一个缓冲区 void deallocate_buffer(T* buffer) { delete[] buffer; } // 初始化中控器和迭代器 void initialize() { allocate_map(8); // 初始分配8个中控器槽位 // 在中间位置分配第一个缓冲区 map[map_center] = allocate_buffer(); // 初始化start和finish迭代器,都指向第一个缓冲区的起始位置(此时容器为空) start = iterator(map[map_center], &map[map_center]); finish = start; } // 销毁所有元素并释放内存 void destroy_all() { // 1. 调用所有已存在元素的析构函数 (简化版, 假设是平凡类型, 实际需用allocator) for (auto it = start; it != finish; ++it) { it->~T(); // 显式调用析构函数 } // 2. 释放所有缓冲区 for (size_t i = 0; i < map_capacity; ++i) { if (map[i] != nullptr) { deallocate_buffer(map[i]); } } // 3. 释放中控器 delete[] map; } public: // 默认构造函数 deque() { initialize(); } // 析构函数 ~deque() { destroy_all(); } // 获取迭代器 iterator begin() { return start; } iterator end() { return finish; } // 基础功能 bool empty() const { return start == finish; } size_t size() const { // 简化计算:迭代器相减需要实现 operator- // 这里先返回一个粗略估计,完整实现需要迭代器支持减法 // 临时方案:遍历计数 size_t count = 0; for (auto it = start; it != finish; ++it) ++count; return count; }

这里我们实现了最简单的内存管理。allocate_map负责分配中控器数组,allocate_buffer/deallocate_buffer负责单个缓冲区的生灭。initialize在构造函数中被调用,它建立了一个初始状态:一个中控器,中间位置有一个空缓冲区,startfinish都指向这个缓冲区的起始位置。destroy_all在析构时清理所有资源。

4.3 实现push_back与push_front

现在实现最核心的两端操作。这是deque性能优势的体现。

// 在 my_deque.h 的 deque 类 public 部分继续添加 public: void push_back(const T& value) { // 如果finish迭代器的cur不是当前缓冲区的最后一个位置(last-1) if (finish.cur != finish.last - 1) { // 当前缓冲区还有空间 *finish.cur = value; // 在当前位置构造(简化,应使用placement new) ++finish.cur; // finish迭代器向前移动一位 } else { // 当前缓冲区已满,需要分配新缓冲区 push_back_aux(value); } } void push_front(const T& value) { // 如果start迭代器的cur不是当前缓冲区的第一个位置(first) if (start.cur != start.first) { --start.cur; // start迭代器向后移动一位 *start.cur = value; // 在新位置构造 } else { // 当前缓冲区已满(在头部),需要分配新缓冲区 push_front_aux(value); } } T& front() { if (empty()) { // 应该抛出异常,这里简单处理 static T dummy; return dummy; } return *start; } T& back() { if (empty()) { static T dummy; return dummy; } auto tmp = finish; --tmp; return *tmp; } private: // 辅助函数:当尾部缓冲区用完时调用 void push_back_aux(const T& value) { // 检查中控器尾部是否还有空间存放新缓冲区的指针 // 这里简化处理,假设总是有空间。实际需要判断并可能重分配中控器(map)。 size_t next_node_index = (finish.node - map) + 1; if (next_node_index >= map_capacity) { // 中控器需要扩容!这是一个复杂操作,此处省略。 // 实际应重新分配更大的map,拷贝指针,并更新start和finish的node指针。 throw std::bad_alloc(); // 简单抛出异常 } // 分配新缓冲区 map[next_node_index] = allocate_buffer(); // 在新缓冲区的第一个位置构造元素 *(map[next_node_index]) = value; // 简化构造 // 重新设置finish迭代器,使其指向新缓冲区的第二个位置(因为第一个位置已用) finish.set_node(&map[next_node_index]); finish.cur = finish.first + 1; } void push_front_aux(const T& value) { // 检查中控器头部是否还有空间 size_t prev_node_index = (start.node - map) - 1; // 假设prev_node_index是有效的(否则需要处理中控器前端扩容) // 分配新缓冲区 map[prev_node_index] = allocate_buffer(); // 重新设置start迭代器,使其指向新缓冲区的最后一个位置 start.set_node(&map[prev_node_index]); start.cur = start.last - 1; // 在当前位置构造元素 *start.cur = value; }

push_back的逻辑是:如果尾部缓冲区还有空间,就直接在finish.cur位置构造元素并移动finish.cur;如果满了,就调用push_back_aux分配新的缓冲区,更新finish迭代器使其指向新缓冲区。push_front是镜像操作。这里省略了中控器(map)扩容这一复杂但关键的步骤,在实际库实现中,这会涉及重新分配指针数组、拷贝原有指针并更新所有迭代器内部的node指针,是一个需要仔细处理的地方。

4.4 实现随机访问operator[]

最后,我们实现随机访问,这是体现deque“分段连续”计算逻辑的地方。

public: T& operator[](size_t n) { // 计算相对于起始位置的偏移 // 首先,计算start迭代器到第一个缓冲区开头的偏移 size_t offset_from_start = n + (start.cur - start.first); // 计算目标缓冲区在中控器中的索引 size_t buffer_index = offset_from_start / BUFFER_SIZE; // 计算在目标缓冲区内的偏移 size_t offset_in_buffer = offset_from_start % BUFFER_SIZE; // 获取目标缓冲区的指针 T* target_buffer = map[(start.node - map) + buffer_index]; // 返回对应元素的引用 return target_buffer[offset_in_buffer]; } const T& operator[](size_t n) const { // const版本,同上 size_t offset_from_start = n + (start.cur - start.first); size_t buffer_index = offset_from_start / BUFFER_SIZE; size_t offset_in_buffer = offset_from_start % BUFFER_SIZE; T* target_buffer = map[(start.node - map) + buffer_index]; return target_buffer[offset_in_buffer]; }

这个operator[]的实现清晰地展示了随机访问的O(1)过程:通过简单的除法和取模运算,定位到中控器的哪个缓冲区,以及缓冲区内的哪个位置。注意计算起始偏移时(start.cur - start.first),这是因为start迭代器不一定指向缓冲区的起始位置(first),我们需要算出从逻辑容器起点到物理缓冲区起点的偏移量。

4.5 一个简单的测试用例

让我们写个简单的程序测试一下我们的MyDeque

// test_my_deque.cpp #include <iostream> #include "my_deque.h" int main() { my::deque<int> dq; std::cout << "Testing push_back and push_front...\n"; for (int i = 0; i < 5; ++i) { dq.push_back(i); // 尾部插入 0, 1, 2, 3, 4 } for (int i = 5; i < 10; ++i) { dq.push_front(i); // 头部插入 9, 8, 7, 6, 5 } // 此时deque内容(从头到尾)应为: 9 8 7 6 5 0 1 2 3 4 std::cout << "Deque size: " << dq.size() << std::endl; std::cout << "Front: " << dq.front() << ", Back: " << dq.back() << std::endl; std::cout << "Contents using iterator: "; for (auto it = dq.begin(); it != dq.end(); ++it) { std::cout << *it << " "; } std::cout << std::endl; std::cout << "\nTesting random access with operator[]:\n"; for (size_t i = 0; i < dq.size(); ++i) { std::cout << "dq[" << i << "] = " << dq[i] << std::endl; } // 修改元素 dq[2] = 100; dq[dq.size() - 1] = 200; std::cout << "\nAfter modification: "; for (auto val : dq) { // 使用基于范围的for循环需要实现begin/end std::cout << val << " "; } std::cout << std::endl; return 0; }

这个测试展示了基本的插入、遍历和随机访问功能。通过调试,你可以观察startfinish迭代器内部curnode的变化,以及map指针数组的分配情况,这对理解deque的运行机制非常有帮助。

5. 使用deque的常见问题与实战技巧

在实际项目中,仅仅知道接口是不够的,一些细节和陷阱决定了代码的健壮性和性能。

5.1 迭代器失效问题详解

这是使用STL容器最需要注意的问题之一,deque也不例外。

  • 会使所有迭代器失效的操作
    • insert在除首尾位置插入(因为可能导致元素大范围移动或中控器重分配)。
    • erase在除首尾位置删除。
    • 任何可能导致中控器(map)重新分配的操作(例如,当两端同时快速增长,中控器空间不足时)。虽然我们的简易实现没做,但标准库实现会做。
  • 会使部分迭代器失效的操作
    • push_backemplace_back通常不会使已有迭代器失效,除非操作导致中控器重分配。
    • push_frontemplace_front同上。
    • pop_backpop_front只会使指向被删除元素的迭代器失效,其他迭代器通常安全。
  • 安全的做法在进行了任何插入或删除操作后,如果后续逻辑还需要使用迭代器,最安全的做法是重新获取迭代器(例如it = dq.begin()),而不是继续使用旧的迭代器变量。

实战示例

std::deque<int> dq = {1, 2, 3, 4}; auto it = dq.begin() + 2; // it 指向 3 dq.push_front(0); // 在头部插入,it **可能**仍然有效,但不保证 // 保险起见,如果需要使用指向元素3的迭代器,应该重新计算 // it = dq.begin() + 3; // 重新获取 dq.insert(dq.begin() + 1, 99); // 在中间插入,it **肯定**失效!不能再使用。

5.2 性能考量与适用场景

  • 内存开销deque的内存开销比vector大,因为它有中控器的开销和可能的缓冲区空间浪费(缓冲区未满)。如果元素数量非常少,deque的相对开销会显得很大。
  • 遍历性能: 由于数据不是完全连续存储,遍历deque的CPU缓存友好性不如vector。在需要高强度顺序访问的场景下,vector可能仍有优势。
  • 首选场景
    1. 队列(FIFO)或双端队列(Deque)数据结构:这是deque的经典用途,例如任务队列、消息队列、滑动窗口等。
    2. 需要频繁在序列两端进行插入删除,同时偶尔需要随机访问。例如,维护一个排序的列表,需要在两端添加新元素,又需要快速访问中间某个元素。
    3. 作为stackqueue的默认底层容器。C++ STL中std::stackstd::queue的默认容器就是deque,因为它提供了两端高效的操作。

5.3 与vector和list的再对比与选择

让我们通过一个具体例子来感受选择容器的差异。假设你要处理一个实时数据流,需要保留最近N个数据样本。

  • 使用vector

    std::vector<int> samples; // 当数据到来 samples.push_back(new_data); if (samples.size() > N) { // 移除最旧的数据,但vector头部删除是O(n) samples.erase(samples.begin()); // 性能瓶颈! } // 随机访问很快 int median = samples[N/2];

    问题: 当N很大时,频繁的erase(samples.begin())会导致巨大的性能开销。

  • 使用list

    std::list<int> samples; // 当数据到来 samples.push_back(new_data); if (samples.size() > N) { samples.pop_front(); // O(1), 高效 } // 但如果你想计算中位数,需要随机访问... auto it = samples.begin(); std::advance(it, N/2); // O(n), 非常慢! int median = *it;

    问题: 随机访问是O(n),无法接受。

  • 使用deque(最佳选择)

    std::deque<int> samples; // 当数据到来 samples.push_back(new_data); if (samples.size() > N) { samples.pop_front(); // O(1), 高效 } // 随机访问也是O(1) int median = samples[N/2]; // 高效

    deque在这里完美兼顾了两端的操作效率和随机访问需求。

5.4 一个综合案例:使用deque实现简单的LRU Cache

LRU(最近最少使用)缓存是一种常见的缓存淘汰算法。我们可以用deque来记录访问顺序,用unordered_map来快速查找。

#include <deque> #include <unordered_map> #include <iostream> template<typename K, typename V> class SimpleLRUCache { private: size_t capacity_; // 存储键值对,并保持访问顺序(最近访问的放在尾部) std::deque<std::pair<K, V>> cache_list_; // 快速通过key找到在deque中的迭代器 std::unordered_map<K, typename std::deque<std::pair<K, V>>::iterator> cache_map_; public: SimpleLRUCache(size_t capacity) : capacity_(capacity) {} V* get(const K& key) { auto it = cache_map_.find(key); if (it == cache_map_.end()) { return nullptr; // 未找到 } // 找到,将其移动到尾部(标记为最近使用) auto list_it = it->second; std::pair<K, V> kv = *list_it; cache_list_.erase(list_it); // 从原位置删除 cache_list_.push_back(kv); // 插入到尾部 // 更新map中的迭代器指向新的位置 cache_map_[key] = --cache_list_.end(); return &(cache_list_.back().second); } void put(const K& key, const V& value) { auto it = cache_map_.find(key); if (it != cache_map_.end()) { // 键已存在,更新值并移动到尾部 auto list_it = it->second; cache_list_.erase(list_it); cache_list_.push_back({key, value}); cache_map_[key] = --cache_list_.end(); } else { // 键不存在 if (cache_list_.size() >= capacity_) { // 缓存已满,删除最久未使用的(头部) auto lru_key = cache_list_.front().first; cache_map_.erase(lru_key); cache_list_.pop_front(); } // 插入新键值对到尾部 cache_list_.push_back({key, value}); cache_map_[key] = --cache_list_.end(); } } void print() const { std::cout << "LRU Cache (most recent -> least recent): "; for (auto rit = cache_list_.rbegin(); rit != cache_list_.rend(); ++rit) { std::cout << "[" << rit->first << ":" << rit->second << "] "; } std::cout << std::endl; } }; int main() { SimpleLRUCache<int, std::string> cache(3); cache.put(1, "Data1"); cache.put(2, "Data2"); cache.put(3, "Data3"); cache.print(); // 输出: [3:Data3] [2:Data2] [1:Data1] auto val = cache.get(2); // 访问key=2 if (val) std::cout << "Got: " << *val << std::endl; cache.print(); // 输出: [2:Data2] [3:Data3] [1:Data1] (2被移到最前) cache.put(4, "Data4"); // 插入新数据,容量已满,淘汰最旧的1 cache.print(); // 输出: [4:Data4] [2:Data2] [3:Data3] return 0; }

在这个案例中,dequepush_back(标记最近使用)、pop_front(淘汰最久未使用)和erase(将元素从中间移到尾部)操作都被用到了,并且都是相对高效的操作。虽然erase是O(n),但在这个小规模缓存中是可以接受的。如果缓存规模极大,可能需要更复杂的数据结构(如链表+哈希表),但deque提供了一个简单清晰的实现起点。

通过这个从原理到接口,从模拟实现到实战案例的完整过程,我希望你不仅学会了如何使用deque,更理解了其内部设计精妙之处,以及如何在合适的场景下选择它。记住,没有最好的容器,只有最合适的容器。deque正是在vector的连续性和list的节点灵活性之间,找到了一个独特的、实用的平衡点。下次当你面临需要在序列两端频繁操作,又不想放弃随机访问能力时,别忘了你工具箱里的这个利器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询