同样一批数据,为什么数组按下标取值很快,链表插入之前却常常需要先找位置?为什么堆能快速取出最大值,却不能像有序数组一样做二分查找?这篇沿着“数据放在哪里 → 一次操作改变什么 → 哪个约束始终成立”的顺序,把结构和操作连起来。
1. 先提出操作,再选择结构
设想正在处理机器人记录:传感器样本要顺序保存,等待处理的任务要排队,紧急任务要优先执行,设备名称要映射到状态,设备之间又有连接关系。这些数据都能保存到 vector,但查询与修改的代价会不同。
| 最常发生的操作 | 从这个结构开始考虑 | 接受的代价 |
|---|---|---|
| 固定数量、按下标访问 | std::array<T, N> |
长度是类型的一部分 |
| 顺序收集、随机访问、批量处理 | std::vector<T> |
中间插入要搬移元素 |
| 两端增删且需要下标 | std::deque<T> |
不保证整段连续内存 |
| 持有节点位置后频繁移接 | std::list<T> |
寻找位置及缓存访问可能较慢 |
| 只处理最新/最早加入的任务 | stack / queue |
接口限制了访问方式 |
| 反复取当前极值 | priority_queue |
其余元素没有全局排序 |
| 按键排序、范围查询 | map / set |
常见查找为 O(log n) |
| 按完整键查找或计数 | unordered_map / unordered_set |
平均快,最坏可能退化 |
| 按字符串前缀查询 | Trie | 用空间换前缀路径共享 |
| 邻居、路径、连接关系 | 图的邻接表 | 需要明确方向和边权 |
| 只查询集合是否连通 | 并查集 | 不保存具体路径,不直接支持拆边 |
这里的 n 表示元素数,V、E 表示顶点数与边数。复杂度描述规模增长时的操作量,不是一次操作的耗时。O(1) 也要执行哈希、访问内存或分配节点;实际速度应在真实数据上测量。
2. 连续存储:数组怎样换来随机访问
假设每个整数占 4 字节,数组首地址为 1000,第 i 个元素的地址就是 1000 + 4 × i。只计算偏移,不需要从头走过前面的元素。
下标 0 1 2 |
std::array<int, 3> 把这 3 个元素放在对象内部,元素连续。它不保证对象一定在栈上:局部对象、静态对象、另一个对象的成员或动态创建的对象,可以有不同的存储期。标准草案:array
std::array<int, 3> samples{10, 20, 30}; |
vector 的 size 和 capacity 各表示什么
size 是已经存在的元素数,capacity 是当前分配的空间能容纳多少元素。reserve(8) 只是预留空间,不能让空数组立刻合法地访问 v[0];resize(8) 才会把元素数改为 8。
先看在 [10,20,30] 的下标 1 插入 15:必须给新元素腾出一格。图用整数赋值展示移动关系;真正的泛型 vector 还要处理对象的构造、移动和析构。
点击下一步,观察被搬移的后缀。这里始终维持的约束是:插入位置之前的元素保持顺序,已经搬好的后缀不会被尚未移动的数据覆盖。结束后得到 [10,15,20,30]。
std::vector<int> values{10, 20, 30}; |
容量不足和中间搬移是两件事。 即使提前 reserve,中间插入仍要移动后缀;如果还发生重新分配,则全部元素要进入新的存储区域。重新分配后,旧指针、引用和迭代器全部失效;未重新分配的插入也会使插入点及其后的迭代器、指针和引用失效,包含旧的 end()。标准草案:vector 修改规则
尾插多数时候只增加一个元素,少数时候搬迁整个数组,所以一系列尾插平均分摊为 O(1),但单次可能 O(n)。标准不规定每次容量必须恰好翻倍。尾删为 O(1),通常保留容量;shrink_to_fit() 是非强制的缩容请求。
即使先 reserve(100),在已有 50 个元素的最前面插入仍需移动后缀。预留容量减少重新分配,不能消除元素移动。
3. 节点存储:链表的 O(1) 有什么前提
head → [10 | next] → [20 | next] → [30 | null] |
节点不要求连续。已有前驱节点时,单链表插入只改变少量链接;但要从头寻找“第 100 个节点”,仍要顺着链接走。双链表还保存前驱链接,能向两个方向移动。
std::list<int> values{10, 20, 30}; |
std::forward_list 是单链表,对应 insert_after、erase_after;std::list 是双链表。二者都没有下标访问。链表中的稳定节点身份适合某些移接操作,但“经常增删就用链表”仍不够:如果每次都要先搜索,搜索可能占主要成本。
list::splice 可以转移节点而不复制元素,但不同重载复杂度不同:转移整张表或单个节点是常数时间;跨容器转移一个范围通常还需统计范围长度,标准规定线性复杂度;同一容器内合法的范围移接为常数时间。来源与目标的分配器还必须相等。标准草案:list 操作
具体插入、删除、反转的逐步指针图见数据结构 02:链表。图中直接标出了修改前后的链接关系。
4. 栈、队列、deque:限制入口让意图明确
同样加入 10,20,30,不同规则决定下一个处理谁:
栈: 入 10 → 入 20 → 入 30 → 取出 30 后进先出 |
stack 与 queue 是容器适配器,默认使用 deque;它们强调允许哪些操作,不暴露用于任意遍历的迭代器。top() / front() 观察元素,pop() 删除元素但不返回值;调用前要先检查非空。
std::queue<int> jobs; |
deque 支持 O(1) 随机访问和两端单元素增删,但不保证元素处于同一连续内存区。因此不能把它当作 vector::data() 那样的连续数组传给 C 接口。中间插入仍是线性操作。标准草案:deque 修改
用操作理解场景: 括号匹配需要最近一个未配对左括号,所以用栈;BFS 必须先处理早发现的节点,所以用队列。结构选择来自访问规则。
5. 搜索树、堆与哈希表解决三个不同的问题
有序树:查询值和区间
二叉搜索树要求左子树键小于当前键、右子树键大于当前键。未经平衡的树可能退化成链,所以不能一概声称树查找是 O(log n)。标准库 map、set 提供相应的对数复杂度保证,常见实现是平衡搜索树,标准未指定必须使用红黑树。
map 保存键值对;set 只保存键;multimap、multiset 允许等价键重复。键是否等价由比较器判断:!comp(a,b) && !comp(b,a),不一定由 operator== 决定。
std::map<int, std::string> devices{ |
定位起点 O(log n),输出 k 项再花 O(k)。不要把整个范围查询只记成 O(log n)。标准草案:有序关联容器
堆:只维持最值所需的秩序
最小堆要求父节点不大于孩子,不要求左右子树互相有序。用数组保存完全二叉树时,节点 i 的孩子下标是 2*i+1 与 2*i+2;只有 i>0 时才有父节点 (i-1)/2。
例如最小堆数组 [2,5,3,9,7] 并没有完全排序,但根已经是最小值。加入 1 时先放末尾,再与父节点交换,直到父子约束恢复;弹出根时用末尾补位,再向下调整。
std::priority_queue<int, std::vector<int>, std::greater<int>> ready; |
priority_queue 默认是最大堆;上面用 greater<int> 得到最小堆。查看顶端 O(1),调整堆所需比较次数 O(log n)。默认底层 vector 插入偶尔还会重新分配,因此 push 的总成本有摊还因素。维护大小为 k 的最小堆,可以在扫描 n 项时保留最大的 k 项,典型代价 O(n log k)。标准草案:priority_queue
哈希表:完整键定位,不维护大小关系
哈希把键映射到桶,桶冲突后仍必须比较真正的键。平均查找为 O(1),最坏可能 O(n);若键是长字符串,计算哈希和比较字符串本身也有成本。
std::unordered_map<std::string, int> counts; |
不能依赖哈希表的遍历顺序。rehash 会使迭代器失效,但不会使仍存活元素的指针和引用失效;删除元素才会使指向该元素的引用失效。reserve 也可能触发 rehash。标准草案:无序关联容器
自定义键的相等判断与哈希必须一致:相等的两个键必须产生相同哈希值;哈希值相同则不代表键相等。C++17 写法示例:
struct Point { |
6. Trie:把“公共前缀”变成公共路径
root |
插入 cat 后再插入 car,只需在 ca 后新建 r 节点。查询 ca 时路径存在,但终点未被标为单词,因此 starts_with("ca") 为真,contains("ca") 为假。
固定 26 字母孩子数组让每一步索引为 O(1),长度为 L 的插入和查询为 O(L)。代价是每个节点都预留 26 个孩子位置。字符集大且分支稀疏时,可改用映射存孩子,复杂度和空间也会随之变化。
static void validate_word(std::string_view word) { |
完整 Trie 实现和边界用例 使用 unique_ptr 表达子节点所有权,接受空字符串作为可插入的单词,非法字符抛出异常。这个教学版本针对小规模单词,不把 Unicode 分词、超长输入的资源限制或产品级搜索功能混在一起。
7. 图与 BFS:让队列对应“距离一层层增长”
图的顶点表示对象,边表示关系。先画一个有向无权图:A 指向 B、C;B 指向 D;C 指向 D、E。代码用 0..4 对应 A..E。
using Graph = std::vector<std::vector<int>>; |
从 A 出发的过程如下。队列左侧是下一次要出队的元素;发现节点时就标记,而不是等到出队时才标记。
| 刚处理的节点 | 处理之后的队列 | 新发现的距离 |
|---|---|---|
| 尚未处理 | [A] |
A 的距离为 0 |
| A | [B,C] |
B、C 的距离为 1 |
| B | [C,D] |
D 的距离为 2 |
| C | [D,E] |
E 的距离为 2;D 已发现,不重复入队 |
| D | [E] |
无新节点 |
| E | [] |
遍历结束 |
BFS 的关键约束是:队列按距离非递减的顺序处理顶点。若每条边代价相同,第一次发现一个点时就得到最短步数。若边有不同权重,这个结论不再成立,需要根据权重条件选择 Dijkstra 等算法。
std::vector<int> bfs_distance(const Graph& graph, int start) { |
完整源码将空图和非法起点视为无效调用并抛出异常;即使某个坏的边端点位于不可达分量,也会先验证出来,避免因输入形状不同而漏检。未访问顶点的距离保持 -1。
| 表示方法 | 空间 | 检查 u→v 是否存在 |
枚举 u 的邻居 |
|---|---|---|---|
| 普通邻接表 | O(V+E) | O(deg(u)) | O(deg(u)) |
| 邻接矩阵 | O(V²) | O(1) | O(V) |
这里是未额外建立索引的普通邻接表。无向边通常存两次,不改变渐进复杂度。BFS/DFS 遍历邻接表的代价为 O(V+E);DFS 用递归或显式栈,可用于连通分量、环检测等,但普通 DFS 不保证无权最短路。
8. 并查集:只关心哪些点属于同一集合
假设依次连接 (0,1)、(2,3)、(1,2):
开始: {0} {1} {2} {3} {4} |
并查集保存“父节点”,沿父节点最终找到代表元。集合代表元是谁不重要,同一集合中的元素是否找到同一个根才重要。合并时把小集合的根挂到大集合的根上;查询时把沿途节点尽量指向根,称为路径压缩。
DisjointSet groups(5); // 完整定义见 structures.cpp |
初始化为 O(n)。按大小合并与路径压缩一起使用时,后续操作的摊还复杂度为 O(α(n));α 是增长极慢的反阿克曼函数,不能据此宣称每一次调用严格为 O(1)。它适合增量连通性与 Kruskal;不保存具体连通路径,也不能直接支持任意删除边。
示例构造函数先拒绝负数规模,再分配数组;零规模允许构造,但没有合法元素下标。find、unite、connected 接受的编号范围是 [0,n),完整代码检查这个前提,不把负数隐式转成超大无符号下标。
9. 复杂度要写清楚是哪一个操作
| 结构 | 访问/查找 | 插入 | 删除 |
|---|---|---|---|
vector |
下标 O(1);无序值查找 O(n) | 尾插摊还 O(1)、单次最坏 O(n);中间 O(n) | 尾删 O(1);中间 O(n) |
deque |
下标 O(1);值查找 O(n) | 两端单元素 O(1);中间 O(n) | 两端单元素 O(1);中间 O(n) |
list |
走到第 i 项 O(i);查值 O(n) | 已知位置单元素 O(1) | 已知位置单元素 O(1) |
map / set |
按键 O(log n) | 普通单元素 O(log n) | erase(iterator) 摊还 O(1);erase(key) 为 O(log n + count(key)) |
unordered_* |
按键平均 O(1)、最坏 O(n) | 单元素平均 O(1)、最坏 O(n) | 单元素平均 O(1)、最坏 O(n) |
表中的 unordered_* 单元素删除指持有一个元素迭代器的情形;允许重复键的 erase(key) 还要计入删掉多少项。有序容器按迭代器删除与按键删除也不是同一复杂度,详细要求见标准草案对应表。
10. 把图变成可运行的实验
下载 structures.cpp,其中包括安全的 Trie、BFS、并查集,以及数组、堆和自定义哈希键示例。仓库中的统一实验命令见专题学习路线。所有示例使用 C++17。
接下来到 STL 数据流水线:将这里的结构选择,落实成查找、过滤、计数、排序和去重代码。