☰
C++中的数组、链表与哈希表
2026/10/20 15:15:27 网站建设 项目流程

前言


数组、链表、哈希表是三种最基础的容器结构,几乎所有数据结构课程都从它们讲起,但工程实践中用错的情况依然很多。原因往往不是不懂原理,而是把「渐近复杂度」当成了唯一标准:看到链表插入是 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::listO(1) 插入,迭代器稳定

只需头部插入、省内存std::forward_list单指针节点

按 key 查找、不要求顺序std::unordered_map平均 O(1)

按 key 查找、要求有序遍历std::map红黑树,O(log n),稳定有序



一个实用的经验:默认用std::vector。它连续、可预测、分配次数少。只有当剖析数据显示「中间插入/删除」或「迭代器稳定性」确实是瓶颈时,才考虑链表。哈希表则要注意 key 的哈希质量,以及在需要范围查询或有序输出时改用std::map。


常见坑点



  1. ❌std::vector扩容后继续使用旧的迭代器或指针。


✅ 扩容会让所有迭代器、指针、引用失效;扩容后重新获取。



  1. ❌v.reserve(n)之后直接v[i] = x,以为元素已存在。


✅reserve只改容量;要访问元素得先resize或push_back。



  1. ❌ 在std::list上调用std::sort,编译报错。


✅ 用成员list.sort();std::sort要求随机访问迭代器。



  1. ❌ 用m[key]做查询,结果悄悄插入了新元素。


✅ 查询用find()或contains(),确认存在后再访问。



  1. ❌ 认为unordered_maprehash 后指针和引用也失效。


✅ rehash 只失效迭代器,元素本身的指针和引用仍有效。



  1. ❌ 自定义 key 只提供operator==,不提供哈希器就塞进unordered_map。


✅ 同时提供std::hash特化或自定义哈希器。



  1. ❌ 把std::vector<bool>当普通bool数组,对元素取地址。


✅ 它返回代理对象;需要地址语义时改用std::vector<char>或std::bitset。



  1. ❌ 用链表实现「遍历查找再插入」,还期待 O(1)。


✅ 查找本身是 O(n);要 O(1) 必须事先持有迭代器,或改用哈希表。


总结




容器访问查找插入删除迭代器失效



std::arrayO(1)O(n)不可变不适用

std::vectorO(1)O(n)尾部摊还 O(1),中间 O(n)扩容时全部失效

std::listO(n)O(n)已知位置 O(1)仅被删元素失效

std::forward_listO(n)O(n)已知位置 O(1)仅被删元素失效

std::unordered_map按 key 平均 O(1)平均 O(1)平均 O(1)rehash 时迭代器失效



数组、链表、哈希表并不是「谁更高级」的关系,而是三种不同的取舍:连续换随机访问,指针换插入灵活性,哈希换查找速度。选型时先问「访问模式是什么」,再看复杂度,最后用测量验证——顺序反过来,就容易写出复杂度漂亮但实际很慢的代码。




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

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

立即咨询