std::deque 的中控板与缓冲区结构:它真的是连续内存吗?
各位编程爱好者、系统架构师们,大家好。今天我们将深入探讨 C++ 标准库中一个强大而常被误解的容器——std::deque。它在性能和灵活性之间取得了巧妙的平衡,但其内部实现远比我们想象的要复杂。我们将聚焦于 std::deque 的核心结构:它的“中控板”(map)和“缓冲区”(buffers),并最终解答那个萦绕心头的疑问:std::deque 真的像 std::vector 那样使用连续内存吗?
一、引言:std::deque 的魅力与误解
在 C++ 的容器家族中,std::vector 以其连续内存存储、高效随机访问和缓存友好性而闻名,是默认的首选动态数组。然而,std::vector 在头部插入和删除时效率低下,需要将所有后续元素移动,操作复杂度为 O(N)。相对地,std::list 提供了 O(1) 的任意位置插入和删除,但牺牲了内存连续性和随机访问能力。
std::deque(双端队列)的目标是融合两者的优点:它支持两端的 O(1) 插入和删除(push_front/pop_front 和 push_back/pop_back),同时提供 O(1) 的随机访问能力(operator[])。这种特性使其在需要频繁在序列两端进行操作,同时又需要快速访问任意元素的应用场景中表现出色。
然而,正是这种看似“万能”的特性,引发了一个核心问题:std::deque 是如何做到的?它如何既能支持两端快速增删,又能提供随机访问,同时还避免 std::vector 那样的元素大范围移动?这必然涉及到其独特的内存管理策略。许多初学者甚至经验丰富的开发者,可能会误以为 std::deque 提供了某种形式的“连续”内存,或者至少在逻辑上是如此。今天,我们将彻底揭开这个谜团。
二、std::deque 的设计哲学:兼顾两端高效操作
在深入其内部结构之前,我们先来回顾一下 std::deque 为什么需要这种复杂的设计。
-
std::vector的局限性:std::vector内部使用一个单一的、动态分配的连续内存块来存储元素。当这个内存块满时,它会重新分配一个更大的内存块,并将所有旧元素复制到新内存块中。这使得push_back在均摊情况下为 O(1)。但是,在vector的头部或中间插入/删除元素,需要移动插入点之后的所有元素,导致 O(N) 的时间复杂度。对于push_front而言,这几乎总是最坏的情况。std::vector<int> v = {1, 2, 3}; // push_back: O(1) amortized v.push_back(4); // {1, 2, 3, 4} // push_front: O(N) v.insert(v.begin(), 0); // {0, 1, 2, 3, 4} - requires shifting all existing elements -
std::list的局限性:std::list是一种双向链表,每个元素都是一个独立的节点,包含数据和指向前后节点的指针。这使得在任何位置插入和删除都是 O(1) 操作,因为只需修改少数几个指针。然而,std::list的主要缺点在于:- 内存不连续: 元素分散在内存中,导致缓存局部性差,遍历性能可能不如
std::vector。 - 额外的内存开销: 每个节点都需要存储两个指针,导致比
std::vector更多的内存消耗。 - 不支持随机访问: 无法通过索引直接访问元素,
operator[]操作复杂度为 O(N)。std::list<int> l = {1, 2, 3}; // push_back: O(1) l.push_back(4); // {1, 2, 3, 4}
// push_front: O(1)
l.push_front(0); // {0, 1, 2, 3, 4}// Random access: O(N)
// int val = l[2]; // Error, list doesn’t have operator[]
auto it = l.begin();
std::advance(it, 2); // O(N) to reach the 3rd element
int val = *it; - 内存不连续: 元素分散在内存中,导致缓存局部性差,遍历性能可能不如
std::deque 的设计目标就是在不引入额外指针开销的同时,尽可能地提供 vector 的随机访问能力和 list 的两端操作效率。它采用了一种折衷方案:分块存储。
三、std::deque 核心结构:分块存储与中控板
std::deque 的核心思想是将其逻辑上连续的元素序列,在物理内存上分散存储到一系列固定大小的内存块(Buffers/Blocks)中。这些内存块本身是连续的,但它们之间不一定连续。为了管理这些分散的内存块,std::deque 引入了一个中控板(Map/Control Board)。
A. 内存块(Buffers/Blocks):基本存储单元
每个内存块都是一段预先分配好的、连续的内存区域,用于存储 std::deque 的一部分元素。
- 连续性: 每个块内部的元素是连续存储的,这保证了在块内进行迭代和随机访问时,能够享受到与
std::vector类似的缓存局部性优势。 - 块大小: 标准库并未强制规定块的具体大小,这是一个实现细节。通常,块的大小会根据元素类型
T的大小进行调整,以优化性能。例如,对于小型元素(如int),块可能包含数百个元素;对于大型元素,块可能只包含少量元素。一个常见的策略是使块的总字节数固定(例如 512 字节或 1KB),这样可以避免内存碎片化,并提供相对稳定的性能。 - 元素存储: 元素在块中从头到尾依次存储。当一个块被填满后,
std::deque会分配一个新的块来存储后续的元素。
考虑一个 std::deque<int>,其块大小为 4。它的内存布局可能如下:
块 A: [ 0 | 1 | 2 | 3 ] <-- 内存地址 X
块 B: [ 4 | 5 | 6 | 7 ] <-- 内存地址 Y (不一定紧邻 X)
块 C: [ 8 | 9 | 10 | 11 ] <-- 内存地址 Z (不一定紧邻 Y)
B. 中控板(Map/Control Board):块的管理器
中控板是 std::deque 的核心管理结构,它是一个指针数组,每个指针都指向一个内存块。这个指针数组本身通常是连续存储的(例如,可以由 std::vector<T*> 或一个原生数组实现)。
- 结构: 中控板存储的是
T*类型的指针,其中T是std::deque中存储的元素类型。 - 作用: 它提供了一个逻辑上的“连续”视图,将分散的内存块串联起来。通过中控板,
std::deque能够快速定位到包含某个元素的内存块。 - 动态扩展: 和
std::vector类似,当std::deque的元素数量增长,需要更多的内存块时,如果中控板已满,它也会重新分配一个更大的中控板,并将旧的块指针复制到新的中控板中。这个操作的成本是 O(M),其中 M 是中控板中的块指针数量,通常远小于 N(元素总数)。 - 逻辑起始与结束:
std::deque维护了两个索引(或指针)来指示中控板中哪些条目对应于实际存储数据的内存块。例如,start_map_idx指向第一个有效块的指针,end_map_idx指向最后一个有效块的下一个位置。同时,还需要在第一个和最后一个有效块内部维护偏移量,以指示实际元素的起始和结束位置,因为这些块不一定被完全填满。
示意图:
中控板 (Map) - 一个连续的指针数组:
[ 指向块A的指针 | 指向块B的指针 | 指向块C的指针 | ... | nullptr | nullptr ]
^ ^
| |
start_map_idx end_map_idx (exclusive)
实际内存块 (Buffers) - 分散在堆上:
块A: [ ... elements ... ]
块B: [ ... elements ... ]
块C: [ ... elements ... ]
C. 逻辑视图与物理视图
通过中控板和内存块的组合,std::deque 为用户呈现了一个逻辑上连续的元素序列,就像一个数组。用户可以通过索引 operator[] 访问任何元素,并通过迭代器遍历整个序列。然而,在物理内存层面,std::deque 并不是一个单一的、巨大的连续内存块。它的元素分散在多个独立的、固定大小的内存块中,这些块通过中控板的指针连接起来。
这种设计是 std::deque 性能特点的根本来源:
- 随机访问: O(1) 的随机访问是通过两次解引用实现的:首先通过索引在中控板中找到对应的内存块指针,然后通过偏移量在内存块中找到具体元素。
- 两端 O(1) 增删: 当在
deque的两端添加或删除元素时,通常只需要在当前的首尾内存块中移动内部指针,或者在必要时分配/释放一个新块,并更新中控板的start_map_idx或end_map_idx。这避免了像std::vector那样的大规模元素移动。
四、深入剖析内存布局:std::deque 真的不连续吗?
现在,我们可以明确地回答这个问题:std::deque 整体上不是连续内存。
A. 宏观不连续,微观连续
- 宏观不连续:
std::deque的各个内存块在堆上是独立分配的,它们在物理内存中的位置可能相距遥远,甚至不是相邻的。这意味着,如果你获取一个元素的地址,然后尝试像对待数组那样直接通过指针算术访问下一个元素的内存地址,你可能会遇到问题,因为下一个元素可能位于不同的内存块中。 - 微观连续: 尽管整体不连续,但每个内存块内部的元素是连续存储的。这是
std::deque能够提供 O(1) 随机访问和较好缓存局部性的关键。当访问同一内存块内的元素时,CPU 缓存能够有效地预取数据,从而提高访问速度。
这种“分段连续”的内存模型,使得 std::deque 在许多方面优于 std::list,特别是在随机访问和某些遍历场景下,但它也无法完全达到 std::vector 在纯粹连续内存方面的优势。
B. 迭代器与指针算术
由于 std::deque 的内存不连续特性,其迭代器不能简单地是一个 T* 指针。std::deque 的迭代器需要更复杂的数据结构来管理其在逻辑序列中的位置。
一个典型的 std::deque 迭代器至少需要包含以下信息:
- 当前内存块的指针:
T* current_block_ptr;指向当前迭代器所在的内存块。 - 当前元素在块内的指针:
T* current_element_ptr;指向当前迭代器所指的元素。 - 指向中控板中当前块指针的指针:
T** map_pointer;或者一个索引,用于在中控板中定位当前块。这在迭代器跨越块边界时非常关键。 - 块大小信息:
size_t block_size;用于计算块内偏移和判断是否需要跨越块边界。
迭代器操作示例:
当我们对 std::deque 的迭代器执行 operator++ 操作时,其逻辑大致如下:
template <typename T>
struct DequeIterator {
T* current_element_ptr; // 指向当前元素
T** map_pointer; // 指向中控板中当前块的指针
size_t block_capacity; // 每个块的容量
DequeIterator& operator++() {
current_element_ptr++; // 尝试移动到当前块的下一个元素
// 如果移动后超出了当前块的末尾
if (current_element_ptr == *map_pointer + block_capacity) {
map_pointer++; // 移动到中控板中的下一个块指针
current_element_ptr = *map_pointer; // 指向新块的第一个元素
}
return *this;
}
// operator--() 逻辑类似,需要判断是否到达块的起始,然后移动到前一个块的末尾
// operator+() 和 operator-() 需要更复杂的逻辑来处理跨越多个块的情况
};
这种迭代器设计使得 std::deque 的迭代器能够有效地遍历元素,并支持随机访问迭代器所需的所有操作(operator+, operator-, operator[] 等),尽管其实现比 std::vector 的简单指针迭代器要复杂得多。
C. 地址稳定性
std::deque 在地址稳定性方面,介于 std::vector 和 std::list 之间。
std::list: 插入和删除元素不会使任何其他元素的地址失效。std::vector: 任何可能导致重新分配的操作(如push_back导致容量不足,insert到中间),都会使所有元素的地址失效。std::deque:- 插入/删除元素:
- 在两端(
push_front,push_back,pop_front,pop_back)操作时,如果操作没有导致中控板的扩展/收缩,并且没有引起元素在块内部移动(例如,在未满的块内操作),那么除了被插入/删除的元素及其邻近元素外,其他元素的地址通常是稳定的。 - 如果操作导致新的内存块分配或旧内存块释放,那么受影响的块内的元素地址会改变(因为块本身被替换了),或者整个中控板可能被重新分配,导致所有块的指针地址改变。
- 在中间(
insert,erase)操作时,为了维持逻辑上的连续性,std::deque可能需要移动插入点之后(或之前)的半数元素。这可能导致大量元素在块内部移动,甚至跨越多个块进行移动,从而使这些移动元素的地址失效。
- 在两端(
- 插入/删除元素:
因此,std::deque 的地址稳定性不如 std::list,但优于 std::vector 在头部或中间插入/删除时的表现。具体来说,在 deque 的中间插入或删除元素,会使插入点之后或之前的半数元素地址失效,而 vector 的中间插入或删除,会使所有后续元素地址失效。
表格:容器地址稳定性对比
| 容器类型 | push_back |
push_front |
insert(pos) |
erase(pos) |
|---|---|---|---|---|
std::vector |
容量不足时所有元素地址失效 | 所有元素地址失效 | pos 之后所有元素地址失效 |
pos 之后所有元素地址失效 |
std::list |
无元素地址失效 | 无元素地址失效 | 无元素地址失效 | 无元素地址失效 |
std::deque |
一般稳定,但可能导致中控板扩展或末尾块分配,使相关块内元素地址失效 | 一般稳定,但可能导致中控板扩展或开头块分配,使相关块内元素地址失效 | pos 之后或之前半数元素地址失效 |
pos 之后或之前半数元素地址失效 |
五、核心操作的实现机制
理解了 std::deque 的基本结构后,我们来看看其核心操作是如何实现的。
A. push_front() 和 push_back():高效的 O(1) 操作
这是 std::deque 的标志性优势。
-
push_back(value)逻辑:- 检查当前尾部块是否有空间:
std::deque会维护一个指向当前最后一个元素之后位置的指针(last_element_ptr)。如果last_element_ptr还没有达到当前最后一个内存块的末尾,说明该块还有空间。 - 直接插入: 将
value拷贝到last_element_ptr指向的位置,然后递增last_element_ptr。 - 分配新块: 如果
last_element_ptr已经到达当前最后一个内存块的末尾,说明该块已满。此时:- 中控板扩展: 检查中控板是否有空间存储新的块指针。如果中控板已满,则需要重新分配一个更大的中控板,并复制所有旧的块指针。
- 分配新内存块: 分配一个新的内存块。
- 更新中控板和指针: 将新块的指针添加到中控板的末尾,并更新
end_map_idx。将last_element_ptr指向新块的起始位置。 - 插入元素: 将
value拷贝到last_element_ptr指向的位置,然后递增last_element_ptr。
- 检查当前尾部块是否有空间:
-
push_front(value)逻辑:- 检查当前头部块是否有空间:
std::deque会维护一个指向当前第一个元素位置的指针(first_element_ptr)。如果first_element_ptr还没有到达当前第一个内存块的起始,说明该块还有空间。 - 直接插入: 递减
first_element_ptr,然后将value拷贝到first_element_ptr指向的位置。 - 分配新块: 如果
first_element_ptr已经到达当前第一个内存块的起始,说明该块已满。此时:- 中控板扩展: 检查中控板是否有空间存储新的块指针。如果中控板已满,则需要重新分配一个更大的中控板,并复制所有旧的块指针。
- 分配新内存块: 分配一个新的内存块。
- 更新中控板和指针: 递减
start_map_idx,将新块的指针添加到中控板的start_map_idx位置。将first_element_ptr指向新块的末尾-1位置(因为是从前向后填充)。 - 插入元素: 将
value拷贝到first_element_ptr指向的位置。
- 检查当前头部块是否有空间:
这些操作在大多数情况下都只需要常量时间,因为它们通常只涉及指针的移动和少量元素的拷贝。只有在需要重新分配中控板时,才会出现 O(M) 的开销,但这是均摊 O(1) 的。
B. pop_front() 和 pop_back():同样高效的 O(1) 操作
-
pop_back()逻辑:- 检查是否为空: 如果
deque为空,则抛出异常。 - 递减
last_element_ptr: 逻辑上移除最后一个元素。 - 检查是否清空了当前尾部块: 如果
last_element_ptr递减后,等于当前最后一个内存块的起始位置(且该块不是唯一的块),说明这个块已经完全空闲。 - 释放块: 释放该内存块,并更新
end_map_idx。将last_element_ptr指向新的最后一个内存块的末尾-1位置。
- 检查是否为空: 如果
-
pop_front()逻辑:- 检查是否为空: 如果
deque为空,则抛出异常。 - 递增
first_element_ptr: 逻辑上移除第一个元素。 - 检查是否清空了当前头部块: 如果
first_element_ptr递增后,等于当前第一个内存块的末尾位置(且该块不是唯一的块),说明这个块已经完全空闲。 - 释放块: 释放该内存块,并更新
start_map_idx。将first_element_ptr指向新的第一个内存块的起始位置。
- 检查是否为空: 如果
这些操作也都是常量时间复杂度 O(1),因为它们只涉及指针的移动和可能的内存块释放。
C. 随机访问 operator[]:O(1) 但略慢于 vector
std::deque 的随机访问操作 operator[] 也是 O(1),但其实现比 std::vector 稍微复杂,因此可能会有略高的常数因子。
实现步骤:
- 计算逻辑偏移: 根据
index和first_element_ptr在其块中的偏移量,计算出目标元素相对于整个逻辑序列的起始位置的绝对偏移。 - 定位目标块: 根据绝对偏移量,以及每个块的固定大小
block_size,计算出目标元素所在的内存块在中控板中的索引。- 例如,如果
first_element_ptr在其块内有offset_in_first_block个元素之前有空位,那么逻辑索引为index的元素,首先要填充(block_size - offset_in_first_block)个元素在第一个块中,然后才开始填充后续的完整块。 - 计算目标块的索引:
map_index = start_map_idx + (index + offset_in_first_block) / block_size;
- 例如,如果
- 定位块内元素: 根据绝对偏移量,计算出目标元素在目标内存块内部的偏移量。
- 计算块内偏移:
element_in_block_offset = (index + offset_in_first_block) % block_size;
- 计算块内偏移:
- 两次解引用: 通过
map[map_index][element_in_block_offset]访问到目标元素。
template <typename T>
T& SimpleDeque<T>::operator[](size_type index) {
// 假设 deque 内部有 start_map_idx, first_element_ptr, block_size
// (这是简化后的逻辑,实际实现更复杂,需要考虑环绕和边界条件)
// 计算第一个逻辑元素在第一个物理块中的偏移
size_type offset_in_first_block = first_element_ptr - map[start_map_idx];
// 如果目标元素还在第一个物理块中 (即在 first_element_ptr 之后)
if (index < (block_size - offset_in_first_block)) {
return first_element_ptr[index];
} else {
// 目标元素在后续块中
// 调整索引,使其从第一个完整块的起始开始计数
index -= (block_size - offset_in_first_block);
// 计算目标元素所在的块在中控板中的相对索引
size_type block_offset_in_map = index / block_size;
// 计算目标元素在所在块中的偏移
size_type element_offset_in_block = index % block_size;
// 通过中控板找到对应块,再找到块内元素
return map[start_map_idx + 1 + block_offset_in_map][element_offset_in_block];
}
}
相较于 std::vector 的 vector_ptr[index] 一次解引用,std::deque 需要两次解引用:一次是中控板到块的指针,另一次是块指针到元素。这可能导致轻微的性能损失,但在 O(1) 复杂度下,这种差异通常在可接受范围内。
D. insert() 和 erase():O(N) 操作
std::deque 的中间插入和删除操作与 std::vector 类似,都是 O(N) 复杂度,因为它们可能需要移动大量的元素来维持逻辑上的连续性。
-
insert(pos, value)逻辑:- 判断插入点靠近哪一端:
std::deque会比较插入点pos到begin()的距离和pos到end()的距离。 - 移动较少的一半元素: 为了保持 O(N) 的最佳性能,
std::deque会选择移动元素较少的那一侧。- 如果
pos靠近begin(),则将pos之前的元素向deque的头部移动,并在pos处腾出空间。 - 如果
pos靠近end(),则将pos之后的元素向deque的尾部移动,并在pos处腾出空间。
- 如果
- 插入元素: 将
value拷贝到腾出的位置。 - 处理块边界和中控板: 移动操作可能涉及到跨越块边界,甚至需要分配新的内存块和扩展中控板。
- 判断插入点靠近哪一端:
-
erase(pos)逻辑:- 判断删除点靠近哪一端: 同样,
std::deque会比较删除点pos到begin()的距离和pos到end()的距离。 - 移动较少的一半元素:
- 如果
pos靠近begin(),则将pos之前的元素向deque的尾部移动,覆盖被删除的元素。 - 如果
pos靠近end(),则将pos之后的元素向deque的头部移动,覆盖被删除的元素。
- 如果
- 处理块边界和中控板: 移动操作可能涉及到跨越块边界,甚至可能导致空闲块的释放。
- 判断删除点靠近哪一端: 同样,
尽管这些操作是 O(N),但由于 std::deque 能够选择移动较少的一半元素,它的常数因子通常比 std::vector 在最坏情况下(如 vector.insert(vector.begin(), val))要好。
六、内存管理与性能考量
std::deque 的独特内存模型带来了特定的性能特征和内存管理考量。
A. 块大小的选择
块大小是 std::deque 实现中的一个关键参数。标准库没有规定具体的块大小,因此它由不同的编译器和库实现者自行决定。
- 小块:
- 优点: 减少内部碎片(当
deque不完全填充其最后一个块时),可能更有效地利用内存。 - 缺点: 需要更多的内存块,导致中控板更大,更多的指针存储开销。更频繁的内存分配和释放(当
deque增长和收缩时)。跨块访问更频繁,可能增加缓存未命中的几率。
- 优点: 减少内部碎片(当
- 大块:
- 优点: 减少中控板的条目数量,降低指针存储开销。减少内存分配和释放的频率。在块内拥有更好的缓存局部性。
- 缺点: 增加内部碎片(如果
deque的总大小不是块大小的整数倍,或最后一个块未完全填充)。当元素类型很小而块很大时,一个未完全填充的块可能会浪费大量内存。
表格:不同块大小的优劣
| 特性 | 小块(例如 64 字节) | 大块(例如 4KB) |
|---|---|---|
| 内存碎片 | 较少 | 较多(特别是在 deque 大小非块大小整数倍时) |
| 中控板大小 | 较大(更多指针) | 较小(更少指针) |
| 内存分配/释放 | 较频繁 | 较不频繁 |
| 缓存局部性 | 跨块访问可能更多,缓存效率可能略低 | 块内缓存效率高,但跨块时同样失效 |
| 启动/销毁开销 | 中控板操作开销相对高 | 中控板操作开销相对低 |
| 适用场景 | 元素类型大,或 deque 大小变化剧烈 |
元素类型小,或 deque 大小相对稳定 |
实际的库实现通常会选择一个折衷值,或者根据 sizeof(T) 动态调整块大小,以平衡这些因素。例如,对于 sizeof(T) <= 8 的类型,块可能包含 4096 / sizeof(T) 个元素;对于 sizeof(T) > 8 的类型,块可能只包含 16 个元素。
B. 缓存局部性
std::vector 因为其完美的内存连续性,在遍历和顺序访问时具有极佳的缓存局部性,CPU 能够高效地预取数据。
std::deque 在这方面表现略逊一筹。虽然每个内存块内部是连续的,具有良好的缓存局部性,但当迭代器从一个内存块跳到另一个内存块时,就可能发生缓存未命中。CPU 需要从主内存加载新的数据块,这会引入延迟。
尽管如此,对于大部分操作,特别是随机访问,std::deque 仍然比 std::list 具有更好的缓存性能,因为 std::list 的每个节点都可能是完全独立的内存位置,导致频繁的缓存未命中。
C. 预留空间 (reserve / shrink_to_fit)
reserve():std::deque没有reserve()方法。这是因为std::deque的内存模型决定了它无法像std::vector那样预留一个单一的、连续的内存块。deque只能通过分配更多的块来增加容量,或者通过扩展中控板来增加块指针的容量。std::deque的增长策略是按需分配新的内存块,并且在必要时扩展中控板。shrink_to_fit():std::deque提供了shrink_to_fit()方法,但其行为可能不如std::vector那么直接。它会尝试释放任何未使用的内存块,并可能收缩中控板的容量。然而,由于其分块特性,不能保证总内存占用会大幅减少,特别是如果deque的元素分布在多个块中,并且每个块都被部分占用。
D. 异常安全性
std::deque 提供了强大的异常安全性保证:
- 强异常安全性: 对于
push_front和push_back等操作,如果发生异常(例如内存分配失败),std::deque会回滚到之前的状态,不泄漏任何资源,并且容器保持有效。 - 基本异常安全性: 对于
insert和erase等可能涉及移动大量元素的操作,如果发生异常,容器可能会处于一个有效但未定义的状态,但不会泄漏资源。
七、一个简化的 Deque 骨架
为了更好地理解 std::deque 的内部机制,下面我们将展示一个高度简化的 SimpleDeque 骨架代码。这个骨架省略了完整的异常处理、迭代器所有操作、分配器支持、拷贝/移动语义以及其他生产级容器所需的所有复杂性,但它足以说明中控板和缓冲区的工作原理。
#include <iostream>
#include <vector>
#include <stdexcept>
#include <algorithm> // for std::copy
// --- 简化版 Deque 迭代器 ---
template <typename T>
class SimpleDequeIterator {
public:
using iterator_category = std::random_access_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = T*;
using reference = T&;
private:
T** map_ptr; // 指向中控板中当前块指针的指针
T* block_ptr; // 指向当前元素在块内的位置
size_t block_size; // 每个内存块的大小
public:
SimpleDequeIterator(T** map_p = nullptr, T* block_p = nullptr, size_t bs = 0)
: map_ptr(map_p), block_ptr(block_p), block_size(bs) {}
reference operator*() const { return *block_ptr; }
pointer operator->() const { return block_ptr; }
SimpleDequeIterator& operator++() { // 前缀递增
block_ptr++;
if (block_ptr == *map_ptr + block_size) { // 到达当前块末尾,需要跨块
map_ptr++; // 移动到中控板的下一个块
block_ptr = *map_ptr; // 指向新块的第一个元素
}
return *this;
}
SimpleDequeIterator operator++(int) { // 后缀递增
SimpleDequeIterator temp = *this;
++(*this);
return temp;
}
// (此处省略 operator--, operator+, operator-, 比较运算符等,它们逻辑更复杂)
bool operator==(const SimpleDequeIterator& other) const {
return map_ptr == other.map_ptr && block_ptr == other.block_ptr;
}
bool operator!=(const SimpleDequeIterator& other) const {
return !(*this == other);
}
};
// --- 简化版 Deque 容器骨架 ---
template <typename T>
class SimpleDeque {
public:
using value_type = T;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
using iterator = SimpleDequeIterator<T>;
private:
static constexpr size_type DEFAULT_BLOCK_SIZE = 4; // 为了演示方便,块大小设为很小
std::vector<T*> map; // 中控板:存储指向内存块的指针
size_type map_capacity; // 中控板当前容量
size_type start_map_idx; // 中控板中第一个有效块的索引
size_type end_map_idx; // 中控板中最后一个有效块的下一个索引 (exclusive)
T* first_element_ptr; // 指向第一个逻辑元素的物理位置
T* last_element_ptr; // 指向最后一个逻辑元素“之后”的物理位置
size_type current_size; // 实际元素数量
// 辅助函数:分配新内存块
T* allocate_block() {
T* block = new T[DEFAULT_BLOCK_SIZE];
std::cout << " [DEBUG] Allocated block at: " << static_cast<void*>(block) << std::endl;
return block;
}
// 辅助函数:释放内存块
void deallocate_block(T* block) {
if (block) {
std::cout << " [DEBUG] Deallocated block at: " << static_cast<void*>(block) << std::endl;
delete[] block;
}
}
// 辅助函数:扩展中控板 (如果 map_capacity 不足)
void expand_map_if_needed() {
if (start_map_idx == 0 || end_map_idx == map_capacity) {
size_type old_map_size = end_map_idx - start_map_idx;
size_type new_map_capacity = map_capacity * 2;
if (new_map_capacity == 0) new_map_capacity = 8; // 初始容量
std::vector<T*> new_map(new_map_capacity, nullptr);
size_type new_start_idx = (new_map_capacity - old_map_size) / 2; // 新的起始索引,居中
// 复制旧的中控板内容到新中控板
for (size_type i = 0; i < old_map_size; ++i) {
new_map[new_start_idx + i] = map[start_map_idx + i];
}
map = std::move(new_map);
map_capacity = new_map_capacity;
start_map_idx = new_start_idx;
end_map_idx = new_start_idx + old_map_size;
std::cout << " [DEBUG] Map expanded to " << map_capacity << " entries. New start_map_idx: " << start_map_idx << std::endl;
}
}
public:
SimpleDeque()
: map_capacity(0), start_map_idx(0), end_map_idx(0),
first_element_ptr(nullptr), last_element_ptr(nullptr), current_size(0) {
// 初始状态:分配一个足够大的中控板,并在其中间分配一个块
map_capacity = 8; // 初始中控板容量
map.resize(map_capacity, nullptr);
start_map_idx = map_capacity / 2;
end_map_idx = start_map_idx + 1; // 仅一个块
map[start_map_idx] = allocate_block();
// 初始时,让 first_element_ptr 和 last_element_ptr 指向块的中间,方便两端扩展
first_element_ptr = map[start_map_idx] + DEFAULT_BLOCK_SIZE / 2;
last_element_ptr = first_element_ptr;
}
~SimpleDeque() {
for (size_type i = start_map_idx; i < end_map_idx; ++i) {
deallocate_block(map[i]);
}
}
void push_back(const T& value) {
if (current_size == 0) { // 第一个元素特殊处理
*first_element_ptr = value;
last_element_ptr++;
} else if (last_element_ptr != map[end_map_idx - 1] + DEFAULT_BLOCK_SIZE) {
// 当前尾部块还有空间
*last_element_ptr = value;
last_element_ptr++;
} else {
// 当前尾部块已满,需要新块
expand_map_if_needed(); // 确保中控板有空间
map[end_map_idx] = allocate_block();
last_element_ptr = map[end_map_idx]; // 指向新块的起始
end_map_idx++;
*last_element_ptr = value;
last_element_ptr++;
}
current_size++;
}
void push_front(const T& value) {
if (current_size == 0) { // 第一个元素特殊处理
first_element_ptr--;
*first_element_ptr = value;
} else if (first_element_ptr != map[start_map_idx]) {
// 当前头部块还有空间
first_element_ptr--;
*first_element_ptr = value;
} else {
// 当前头部块已满,需要新块
expand_map_if_needed(); // 确保中控板有空间
start_map_idx--;
map[start_map_idx] = allocate_block();
first_element_ptr = map[start_map_idx] + DEFAULT_BLOCK_SIZE - 1; // 指向新块的末尾
*first_element_ptr = value;
}
current_size++;
}
void pop_back() {
if (empty()) throw std::out_of_range("Deque is empty");
last_element_ptr--; // 逻辑上移除最后一个元素
// 真实 deque 会调用元素的析构函数
if (last_element_ptr == map[end_map_idx - 1] && (end_map_idx - start_map_idx > 1 || current_size == 0)) {
// 如果最后一个块现在是空的,并且不是唯一的块,或者 deque 变空了
if (current_size > 0) { // 避免当只剩一个元素且它在唯一的块中时删除这个唯一的块
deallocate_block(map[end_map_idx - 1]);
map[end_map_idx - 1] = nullptr;
end_map_idx--;
last_element_ptr = map[end_map_idx - 1] + DEFAULT_BLOCK_SIZE; // 指向新最后一个块的末尾
}
}
if (current_size > 0) current_size--;
else { // last element removed, reset pointers for empty deque
first_element_ptr = map[start_map_idx] + DEFAULT_BLOCK_SIZE / 2;
last_element_ptr = first_element_ptr;
current_size = 0;
}
}
void pop_front() {
if (empty()) throw std::out_of_range("Deque is empty");
// 真实 deque 会调用元素的析构函数
first_element_ptr++; // 逻辑上移除第一个元素
if (first_element_ptr == map[start_map_idx] + DEFAULT_BLOCK_SIZE && (end_map_idx - start_map_idx > 1 || current_size == 0)) {
// 如果第一个块现在是空的,并且不是唯一的块,或者 deque 变空了
if (current_size > 0) {
deallocate_block(map[start_map_idx]);
map[start_map_idx] = nullptr;
start_map_idx++;
first_element_ptr = map[start_map_idx]; // 指向新第一个块的起始
}
}
if (current_size > 0) current_size--;
else { // last element removed, reset pointers for empty deque
first_element_ptr = map[start_map_idx] + DEFAULT_BLOCK_SIZE / 2;
last_element_ptr = first_element_ptr;
current_size = 0;
}
}
T& operator[](size_type index) {
if (index >= current_size) throw std::out_of_range("Index out of bounds");
// 计算第一个逻辑元素在第一个物理块中的偏移
size_type offset_in_first_block = first_element_ptr - map[start_map_idx];
// 如果目标元素还在第一个物理块中
if (index < (DEFAULT_BLOCK_SIZE - offset_in_first_block)) {
return first_element_ptr[index];
} else {
// 目标元素在后续块中
index -= (DEFAULT_BLOCK_SIZE - offset_in_first_block); // 调整索引
size_type block_offset_in_map = index / DEFAULT_BLOCK_SIZE;
size_type element_offset_in_block = index % DEFAULT_BLOCK_SIZE;
return map[start_map_idx + 1 + block_offset_in_map][element_offset_in_block];
}
}
const T& operator[](size_type index) const {
return const_cast<SimpleDeque*>(this)->operator[](index);
}
iterator begin() {
return iterator(&map[start_map_idx], first_element_ptr, DEFAULT_BLOCK_SIZE);
}
iterator end() {
// end() 迭代器指向最后一个元素“之后”的位置
// 它可能在最后一个块的末尾,也可能指向中控板的下一个空块
return iterator(&map[end_map_idx - 1], last_element_ptr, DEFAULT_BLOCK_SIZE);
}
size_type size() const { return current_size; }
bool empty() const { return current_size == 0; }
};
void run_simple_deque_demo() {
std::cout << "--- SimpleDeque Demo Start ---" << std::endl;
SimpleDeque<int> myDeque;
std::cout << "Initial size: " << myDeque.size() << std::endl;
myDeque.push_back(10);
myDeque.push_back(20);
myDeque.push_front(5);
myDeque.push_back(30);
myDeque.push_front(1);
std::cout << "nPushing 40 (should trigger back block alloc):" << std::endl;
myDeque.push_back(40); // 触发尾部新块分配
std::cout << "nPushing 0 (should trigger front block alloc):" << std::endl;
myDeque.push_front(0); // 触发头部新块分配
myDeque.push_back(50);
myDeque.push_front(-1);
std::cout << "nDeque elements after several pushes (" << myDeque.size() << " elements): ";
for (int i = 0; i < myDeque.size(); ++i) {
std::cout << myDeque[i] << " ";
}
std::cout << std::endl;
std::cout << "Using iterators: ";
for (auto it = myDeque.begin(); it != myDeque.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
std::cout << "Element at index 3: " << myDeque[3] << std::endl; // 应该为 5
std::cout << "nPopping front and back..." << std::endl;
myDeque.pop_front();
myDeque.pop_back();
myDeque.pop_front();
myDeque.pop_back();
std::cout << "nDeque elements after several pops (" << myDeque.size() << " elements): ";
for (auto it = myDeque.begin(); it != myDeque.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
std::cout << "--- SimpleDeque Demo End ---" << std::endl;
}
int main() {
run_simple_deque_demo();
return 0;
}
运行上述代码,你将看到内存块的分配和释放信息,以及中控板扩展的调试信息,这有助于你直观地理解 std::deque 的动态内存管理过程。
总结
std::deque 的内部结构是分块存储和中控板管理的巧妙结合。它在宏观上并非连续内存,而是由多个独立的、连续的内存块组成,这些块的地址由中控板这个指针数组统一管理。这种设计使得 std::deque 能够在两端提供 O(1) 的高效操作,同时保持 O(1) 的随机访问能力,成为 std::vector 和 std::list 之间的一个有力补充。理解其分段连续的内存模型,对于正确选择和高效使用 C++ 标准库容器至关重要。