前言
「数据结构」这四个字常被简化成一张张示意图:数组是一排格子,链表是箭头串起来的节点,树是倒过来的分叉。图看得懂,但一到写代码就出问题------不知道为什么 std::vector 中间插入慢,不知道为什么 std::list 明明插入是常数时间却常常更慢,不知道为什么哈希表「平均 O(1)」却可能退化成 O(n)。
原因在于,示意图只画了逻辑结构,没有画物理存储。数据结构的性能几乎全部由「数据在内存里怎么放、访问时要跳几次、扩容时要搬多少」决定。逻辑结构回答「元素之间是什么关系」,物理结构回答「这些关系用什么表示」,复杂度回答「操作代价随规模怎么长」,三者缺一不可。
本文按这条线索展开:先区分连续与链式两种基本映射,再从访问、插入、删除、查找四类操作推导复杂度,然后补上缓存局部性、摊还分析与哈希冲突这三个常被忽略的因素,最后手写一个动态数组,把扩容、拷贝、异常安全这些细节落到代码上。文中不给出任何未经验证的性能倍数,只讲可推导的复杂度与可观察的机制。
一、逻辑结构与物理存储
逻辑结构描述元素之间的关系,常见的有线性表、树、图、集合;物理结构描述这种关系在内存中的实现,基本只有两种:连续存储 与链式存储。
连续存储把所有元素放在一段连续地址里,第 i 个元素的地址等于首地址加 i 乘元素大小。这个公式带来两个直接后果:随机访问是常数时间;但在中间插入或删除时,必须搬动后面所有元素,代价与元素数量成正比。
链式存储把每个元素包进一个节点,节点里存数据和指向下一个节点的指针。节点之间不要求相邻,插入只需改几个指针,代价是常数;但要访问第 i 个元素,必须从头一个个跳,代价与 i 成正比。此外每个节点多出一个指针的开销,频繁分配小节点还会给分配器带来压力。
| 逻辑结构 | 常见物理实现 | 随机访问 | 中间插入/删除 | 额外空间 |
|---|
|-----|------|------|------|------|
| 线性表 | 动态数组 | O(1) | O(n) | 容量冗余 |
|-----|-------|------|------------|--------------|
| 线性表 | 单/双链表 | O(n) | 已知位置时 O(1) | 每节点 1~2 个指针 |
|--------|-------|-----|---------|-----|
| 栈 / 队列 | 数组或链表 | 仅端点 | 端点 O(1) | 视实现 |
|-----|---------|----------|----------|---------|
| 有序表 | 平衡二叉搜索树 | O(log n) | O(log n) | 每节点多个指针 |
|-----|-----|---------|---------|----------|
| 查找表 | 哈希表 | 平均 O(1) | 平均 O(1) | 桶数组 + 节点 |
这里有一个容易被忽视的事实:抽象数据类型不规定物理实现 。栈既可以用数组也可以用链表,队列既可以用循环数组也可以用链表,std::stack 和 std::queue 默认基于 std::deque,而标准只规定行为与复杂度,不规定底层一定是什么。因此「栈是数组」这种说法只在特定实现下成立。
二、四类操作的复杂度从哪里来
分析复杂度时,不要背结论,而要看「这个操作必须触碰多少内存」。
访问。连续存储可以直接算出地址,O(1);链式存储必须沿指针走,O(n)。这就是「链表不适合按下标读」的根本原因,与编译器优化无关。
插入与删除。若已经持有指向位置的迭代器或指针,链表改指针即可,O(1);数组要搬移后继元素,O(n)。但注意「已经持有位置」这个前提:如果还要先查找位置,总代价要把查找算进去。很多人比较链表与数组时只算插入不算定位,得出错误结论。
查找。无序结构只能线性扫描,O(n);有序数组可以二分,O(log n);平衡二叉搜索树维持有序性,O(log n);哈希表用哈希函数把键映射到桶,平均 O(1)。哈希表的「平均」有前提:哈希函数把键均匀分散,且负载因子受控。若所有键落到同一个桶,查找退化为 O(n),这就是哈希碰撞攻击的原理,也是标准库不保证最坏复杂度的原因。
有序操作 。需要按序遍历、找前驱后继、范围查询时,哈希表无能为力,必须用有序结构。std::map 与 std::set 通常用红黑树实现,标准只要求查找、插入、删除为对数复杂度,并不强制某种树。
一个操作往往由多个步骤组成,复杂度要按最坏或摊还口径说清楚。例如「在数组中插入」包含搬移与写入,搬移是 O(n);「在哈希表中插入」包含计算哈希、定位桶、可能的重哈希,重哈希单次是 O(n),但摊还到每次插入是 O(1)。
三、局部性、摊还与哈希冲突
缓存局部性 。现代 CPU 访问内存时会按缓存行批量加载,连续存储的顺序遍历能命中缓存,链式存储的每次跳转都可能是一次缓存缺失。这解释了一个反直觉现象:在元素数量不大、元素本身较小时,std::vector 的中间插入有时并不比 std::list 慢太多,因为链表的指针追逐把省下的搬移又赔了回去。这不是语言保证,而是硬件行为的后果;要下结论必须针对具体平台测量,不能凭复杂度表拍脑袋。
摊还分析 。动态数组扩容时要把所有元素搬到新缓冲区,单次代价 O(n)。若每次只增加固定容量,n 次插入的总代价是 O(n²),摊还每次 O(n);若容量按倍数增长(常见是翻倍),总代价是几何级数,摊还每次 O(1)。这就是「摊还 O(1)」的来源:它不否认某一次插入很慢,只说明长期平均下来是常数。标准库不规定 std::vector 的扩容因子,只要求 push_back 摊还常数,因此不要依赖「一定是两倍」。
哈希冲突 。主流方案有两类:链地址法把同桶元素串成链表或小树,开放寻址法在冲突时按探测序列找下一个空位。链地址法实现简单、删除方便;开放寻址法缓存更友好,但删除需要标记墓碑,负载因子过高时性能急剧下降。标准库的 std::unordered_map 通常用链地址法,但这是实现细节,标准只规定平均复杂度与迭代器失效规则。
迭代器失效 是数据结构原理的直接推论:数组扩容后旧指针指向被释放的缓冲区;链表删除节点后指向该节点的迭代器失效;哈希表重哈希后所有迭代器失效,但指向元素的引用与指针在标准容器中通常仍然有效(std::unordered_map 的重哈希不使引用失效,这一点常被记反)。
四、完整示例:手写一个动态数组
下面的 DynArray 把连续存储的关键机制显式写出来:容量与长度分离、按倍数扩容、拷贝交换实现赋值、移动构造接管缓冲区。它不追求替代 std::vector,而是让每个决策都可见。
cpp
// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <utility>
template <typename T>
class DynArray {
public:
DynArray() = default;
explicit DynArray(std::size_t n)
: data_(n ? new T[n] : nullptr), size_(n), cap_(n) {}
~DynArray() { delete[] data_; }
DynArray(const DynArray& other)
: data_(other.size_ ? new T[other.size_] : nullptr),
size_(other.size_),
cap_(other.size_) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
DynArray& operator=(const DynArray& other) {
if (this != &other) {
DynArray tmp(other); // 先完成可能抛异常的拷贝
swap(tmp); // 再无异常地交换
}
return *this;
}
DynArray(DynArray&& other) noexcept
: data_(other.data_), size_(other.size_), cap_(other.cap_) {
other.data_ = nullptr;
other.size_ = 0;
other.cap_ = 0;
}
DynArray& operator=(DynArray&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
cap_ = other.cap_;
other.data_ = nullptr;
other.size_ = 0;
other.cap_ = 0;
}
return *this;
}
void swap(DynArray& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
std::swap(cap_, other.cap_);
}
void reserve(std::size_t new_cap) {
if (new_cap <= cap_) return;
T* fresh = new T[new_cap]; // 先分配,失败则原对象不变
for (std::size_t i = 0; i < size_; ++i) {
fresh[i] = data_[i]; // 逐元素复制
}
delete[] data_;
data_ = fresh;
cap_ = new_cap;
}
void push_back(const T& value) {
if (size_ == cap_) {
reserve(cap_ ? cap_ * 2 : 1); // 按倍数增长,摊还 O(1)
}
data_[size_] = value; // 先写成功再增加长度
++size_;
}
T& operator[](std::size_t i) { return data_[i]; }
const T& operator[](std::size_t i) const { return data_[i]; }
T& at(std::size_t i) {
if (i >= size_) throw std::out_of_range("DynArray::at");
return data_[i];
}
std::size_t size() const noexcept { return size_; }
std::size_t capacity() const noexcept { return cap_; }
private:
T* data_ = nullptr;
std::size_t size_ = 0;
std::size_t cap_ = 0;
};
int main() {
DynArray<int> a;
for (int i = 0; i < 10; ++i) {
a.push_back(i);
std::cout << "size=" << a.size()
<< " capacity=" << a.capacity() << '\n';
}
std::cout << "a[3]=" << a[3] << '\n';
return 0;
}
这段代码里有几处刻意为之的设计:
reserve先分配新缓冲区再复制,若分配失败抛出std::bad_alloc,原对象保持完整;若复制过程中抛出,新缓冲区会泄漏,因为它是裸指针------这正是std::vector要用分配器与 RAII 的原因。示例为了短小没有处理这条路径,生产代码应把缓冲区包进std::unique_ptr<T[]>。push_back先赋值再自增长度。如果赋值抛异常,长度不变,容器仍处于有效状态;写成data_[size_++] = value则可能留下一个长度已增、元素未构造的洞。- 拷贝赋值用「拷贝并交换」,天然处理自赋值并提供强异常保证。
- 移动构造与移动赋值都标
noexcept,这是容器在扩容时愿意用移动而非拷贝的前提;若移动可能抛异常,标准容器通常退回拷贝以维持强保证。 operator[]不检查边界,越界是未定义行为;at检查并抛出std::out_of_range。这个分工与标准库一致。- 扩容因子取 2,只是示例选择;标准不规定
std::vector的因子,实际实现可能取 1.5 或 2。
常见坑点
❌ 认为链表插入一定是 O(1),忽略定位成本。
✅ 只有已持有插入位置的迭代器时才是 O(1);按值查找再插入的总体代价仍是 O(n)。
❌ 认为 std::vector 的 push_back 每次都是 O(1)。
✅ 它是摊还 O(1):个别插入触发扩容时为 O(n),长期平均为常数。
❌ 用下标遍历 std::list,写成 for (int i = 0; i < l.size(); ++i) use(l[i]);。
✅ std::list 没有下标操作;用迭代器或范围 for,否则根本无法通过编译。
❌ 扩容后继续使用指向旧缓冲区的指针或迭代器。
✅ 扩容会使所有指针、引用、迭代器失效;扩容后重新获取。
❌ 认为 std::unordered_map 重哈希后指向元素的引用也失效。
✅ 重哈希使迭代器失效,但指向元素的引用和指针保持有效;删除元素才会让被删元素的引用失效。
❌ 用 data_[size_++] = value 实现 push_back。
✅ 先赋值再自增,异常时容器状态仍然一致。
❌ 自己实现容器时把析构、拷贝、移动只写一半。
✅ 遵循规则五,或直接用 std::vector 等现成容器,把精力放在业务上。
总结
| 维度 | 连续存储 | 链式存储 |
|---|
|------|------|------|
| 随机访问 | O(1) | O(n) |
|-----------|------|------|
| 已知位置插入/删除 | O(n) | O(1) |
|------|------|-------|
| 额外空间 | 容量冗余 | 每节点指针 |
|-------|---|---|
| 缓存友好度 | 高 | 低 |
|-------|---------|---------|
| 迭代器失效 | 扩容时全部失效 | 仅被删节点失效 |
数据结构的原理可以压缩成一句话:逻辑关系决定能做什么,物理布局决定要付出什么。复杂度给出趋势,缓存与常数因子决定实际感受,摊还分析解释「偶尔很慢但长期很稳」。写业务代码时优先用标准容器,让实现去处理扩容与异常安全;需要自己实现时,先把长度与容量、拷贝与移动、检查与不检查这三组边界想清楚,再动手写循环。