前言
数组、链表、哈希表是三种最基础的容器结构,几乎所有数据结构课程都从它们讲起,但工程实践中用错的情况依然很多。原因往往不是不懂原理,而是把「渐近复杂度」当成了唯一标准:看到链表插入是 O(1)、数组插入是 O(n),就认为频繁插入的场景必须用链表;看到哈希表查找是 O(1),就认为它永远比std::map快。这些结论都忽略了一个关键变量——内存布局。
现代 CPU 访问连续内存时有预取和缓存行(通常 64 字节)加成,而链表节点分散在堆上,每次next都可能是一次缓存未命中。于是实践中「O(n) 的连续遍历」经常优于「O(1) 的链表插入」,因为前者很快,后者要付出分配和指针跳转的代价。这不是说复杂度分析没用,而是说常数因子和访问模式必须一起考虑。
本文按「数组 → 链表 → 哈希表」的顺序讲清各自的存储模型、标准库对应容器、迭代器失效规则和选型依据,并给出可以直接使用的示例。所有复杂度都是标准规定或平均意义上的,不给出任何未测量的性能数字。
一、三种结构的存储模型
| 结构 | 内存布局 | 随机访问 | 按值查找 | 插入/删除 | 额外开销 |
|---|
| 数组 | 连续 | O(1) | O(n) | 尾部 O(1) 摊还,中间 O(n) | 无(可能预留容量) |
| 链表 | 节点分散,指针相连 | O(n) | O(n) | 已知位置 O(1) | 每节点 1~2 个指针 |
| 哈希表 | 桶数组 + 节点/内联存储 | 无(按 key 访问) | 平均 O(1),最坏 O(n) | 平均 O(1),最坏 O(n) | 桶数组、负载因子冗余 |
「已知位置」是链表 O(1) 插入的前提。如果你只有值、还要先遍历找到位置,那整体仍是 O(n)。这一点是链表被误用的最大来源:在std::list里做find再insert,成本并不比std::vector低,反而多了指针跳转和内存分配。
二、数组:从 C 数组到std::vector
C 风格数组int a[10]的大小是类型的一部分,不能拷贝、不能按值传递(传参会退化为指针),数组名在大多数语境下转换成首元素指针。它不携带长度,sizeof(a) / sizeof(a[0])只在数组还「是数组」的作用域里有效。
std::array<T, N>(C++11)是 C 数组的零开销封装:大小固定、可拷贝、可比较、有begin()/end()/size(),还能和标准算法配合。它不进行堆分配,对象本身就在栈上或作为成员内联存放。适合「编译期已知大小且不需要动态扩容」的场景。
std::vector<T>是动态数组:内部一块连续缓冲区,size()是元素个数,capacity()是已分配空间能容纳的个数。push_back在容量足够时是 O(1);容量不足时重新分配更大的缓冲区、把元素移动(或拷贝)过去,这一操作是 O(n),但按摊还分析,连续 n 次push_back的总代价是 O(n)。标准不规定扩容因子,常见实现取 1.5 或 2 倍,不能依赖具体值。
// 适用于 C++17
#include <array>
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::array<int, 4> fixed{1, 2, 3, 4};
int sum = 0;
for (int v : fixed) sum += v;
std::cout << fixed.size() << ' ' << sum << '\n';
std::vector<int> dyn;
dyn.reserve(100); // 一次分配,避免多次扩容
for (int i = 0; i < 100; ++i) {
dyn.push_back(i);
}
// 下标不检查边界,越界是未定义行为
dyn[0] = 42;
// at() 检查边界,越界抛 std::out_of_range
try {
dyn.at(1000) = 0;
} catch (const std::out_of_range& e) {
std::cout << "out of range: " << e.what() << '\n';
}
dyn.resize(10); // 缩小 size,capacity 不变
dyn.shrink_to_fit(); // 请求释放多余容量(非强制)
return 0;
}reserve只改变容量,不改变元素个数;resize改变元素个数,可能构造或销毁元素。混淆这两个函数是常见错误:reserve(10)之后v[0]访问是越界(size()仍为 0),属于未定义行为。
std::vector<bool>是标准里的特化,按位压缩存储,operator[]返回代理对象而不是bool&,因此不能取地址、行为与其它vector不一致。需要真正的布尔数组时用std::vector<char>或std::bitset。
三、链表:std::list与std::forward_list
std::list<T>是双向链表,每个节点存前驱和后继指针。它支持 O(1) 的splice(把另一个链表的一段直接接过来,不搬元素)、O(1) 的erase(已知迭代器)、稳定的迭代器——插入不会让其它元素的迭代器失效。
std::forward_list<T>(C++11)是单向链表,节点只存后继指针,内存开销更小,但只能向前遍历,接口是insert_after/erase_after/before_begin这一套,且没有size()(计算长度是 O(n))。
// 适用于 C++17
#include <forward_list>
#include <iostream>
#include <list>
int main() {
std::list<int> a{1, 2, 3};
std::list<int> b{10, 20};
auto pos = a.begin();
++pos; // 指向 2
a.splice(pos, b); // 把 b 的全部节点接到 2 之前,O(1)
// 此时 a = {1, 10, 20, 2, 3},b 为空
for (int v : a) std::cout << v << ' ';
std::cout << '\n';
std::forward_list<int> f{1, 2, 3};
f.insert_after(f.before_begin(), 0); // 头部插入
f.erase_after(f.begin()); // 删除第二个元素
for (int v : f) std::cout << v << ' ';
std::cout << '\n';
return 0;
}链表的真实优势场景是:已经持有迭代器、需要在中间频繁插入删除,且不需要随机访问;或者需要在 O(1) 时间内把整段序列转移(splice)。典型的例子是 LRU 缓存:用std::list维护访问顺序,配合unordered_map存迭代器,每次访问把节点splice到头部。如果只是「边遍历边删除」,std::vector配合erase-remove惯用法通常更划算。
还要注意:std::list::sort是成员函数,因为链表不能随机访问,std::sort要求随机访问迭代器,传std::list的迭代器编译不过。std::list::sort是稳定排序。
四、哈希表:std::unordered_map的内部结构
std::unordered_map<Key, T, Hash, KeyEqual, Allocator>是链地址法实现的哈希表(标准只规定接口和复杂度,不规定具体实现,主流实现是桶数组 + 链表或节点)。它维护一个桶数组,每个桶挂一条链(或一棵小树)。插入时计算hash(key) % bucket_count定位桶。
几个必须掌握的概念。
- 负载因子(load factor)=
size() / bucket_count()。超过max_load_factor()(默认通常是 1.0,由实现决定)时触发 rehash:桶数组扩大,所有元素重新分配到新桶。 - rehash 的失效规则:rehash 会让所有迭代器失效,但不会让指向元素的指针和引用失效——因为节点本身没有搬动,只是桶数组重建。这与
std::vector扩容完全不同。 - 平均 O(1),最坏 O(n):如果所有 key 落进同一个桶(哈希函数差或遭恶意构造),查找退化为线性。标准只承诺平均情况。
operator[]会插入:m[key]在 key 不存在时默认构造一个 value 并插入,返回引用。只想查询时用find()或 C++17 的contains()。
// 适用于 C++17
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> wordCount;
for (const std::string& w : {"a", "b", "a", "c", "a"}) {
++wordCount[w]; // 不存在则插入 0 再自增
}
if (auto it = wordCount.find("b"); it != wordCount.end()) {
std::cout << it->first << " = " << it->second << '\n';
}
if (wordCount.contains("z")) { // C++20 起可用
std::cout << "has z\n";
}
wordCount.reserve(1000); // 预留桶,减少 rehash
std::cout << wordCount.size() << ' ' << wordCount.bucket_count() << '\n';
return 0;
}自定义类型作为 key 时,需要提供哈希函数和相等比较。可以特化std::hash,也可以把哈希器作为模板参数传入。
// 适用于 C++17
#include <cstddef>
#include <functional>
#include <unordered_map>
struct Point {
int x;
int y;
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
};
struct PointHash {
std::size_t operator()(const Point& p) const noexcept {
std::size_t h1 = std::hash<int>{}(p.x);
std::size_t h2 = std::hash<int>{}(p.y);
// 组合两个哈希值,减少规律性碰撞
return h1 ^ (h2 + 0x9e3779b97f4a7c15ULL + (h1 << 6) + (h1 >> 2));
}
};
std::unordered_map<Point, int, PointHash> grid;哈希函数的要求是:相等的 key 必须产生相同的哈希值;不相等的 key 尽量产生不同的值。返回std::size_t,且应当是确定性的。不要把可变成员纳入哈希(元素入表后再改 key 会让它「找不到」)。
五、怎么选
| 场景 | 首选 | 理由 |
|---|
| 元素个数固定、编译期已知 | std::array | 无堆分配,零开销 |
| 需要随机访问、尾部增删 | std::vector | 连续内存,缓存友好 |
| 已知位置频繁中间插删 | std::list | O(1) 插入,迭代器稳定 |
| 只需头部插入、省内存 | std::forward_list | 单指针节点 |
| 按 key 查找、不要求顺序 | std::unordered_map | 平均 O(1) |
| 按 key 查找、要求有序遍历 | std::map | 红黑树,O(log n),稳定有序 |
一个实用的经验:默认用std::vector。它连续、可预测、分配次数少。只有当剖析数据显示「中间插入/删除」或「迭代器稳定性」确实是瓶颈时,才考虑链表。哈希表则要注意 key 的哈希质量,以及在需要范围查询或有序输出时改用std::map。
常见坑点
- ❌
std::vector扩容后继续使用旧的迭代器或指针。
✅ 扩容会让所有迭代器、指针、引用失效;扩容后重新获取。
- ❌
v.reserve(n)之后直接v[i] = x,以为元素已存在。
✅reserve只改容量;要访问元素得先resize或push_back。
- ❌ 在
std::list上调用std::sort,编译报错。
✅ 用成员list.sort();std::sort要求随机访问迭代器。
- ❌ 用
m[key]做查询,结果悄悄插入了新元素。
✅ 查询用find()或contains(),确认存在后再访问。
- ❌ 认为
unordered_maprehash 后指针和引用也失效。
✅ rehash 只失效迭代器,元素本身的指针和引用仍有效。
- ❌ 自定义 key 只提供
operator==,不提供哈希器就塞进unordered_map。
✅ 同时提供std::hash特化或自定义哈希器。
- ❌ 把
std::vector<bool>当普通bool数组,对元素取地址。
✅ 它返回代理对象;需要地址语义时改用std::vector<char>或std::bitset。
- ❌ 用链表实现「遍历查找再插入」,还期待 O(1)。
✅ 查找本身是 O(n);要 O(1) 必须事先持有迭代器,或改用哈希表。
总结
| 容器 | 访问 | 查找 | 插入删除 | 迭代器失效 |
|---|
std::array | O(1) | O(n) | 不可变 | 不适用 |
std::vector | O(1) | O(n) | 尾部摊还 O(1),中间 O(n) | 扩容时全部失效 |
std::list | O(n) | O(n) | 已知位置 O(1) | 仅被删元素失效 |
std::forward_list | O(n) | O(n) | 已知位置 O(1) | 仅被删元素失效 |
std::unordered_map | 按 key 平均 O(1) | 平均 O(1) | 平均 O(1) | rehash 时迭代器失效 |
数组、链表、哈希表并不是「谁更高级」的关系,而是三种不同的取舍:连续换随机访问,指针换插入灵活性,哈希换查找速度。选型时先问「访问模式是什么」,再看复杂度,最后用测量验证——顺序反过来,就容易写出复杂度漂亮但实际很慢的代码。