「中间插入删除用链表,随机访问用数组」这条规则背了很多年,但它只说对了一半。真把十万个int分别塞进std::list和std::vector,链表的内存占用是后者的6 倍,顺序遍历还要慢一个数量级。原因不在复杂度(两者遍历都是 O(n)),而在每个节点的额外指针和缓存局部性(cache locality)。这篇用实测把链表的开销算清楚,也把链表真正不可替代的两个场景(迭代器失效规则与splice零拷贝)讲透,最后给一条能直接用的选型结论。
1. 引子:一个反直觉的实测数字
先把结论摆上桌:同样遍历两万个int,vector和list的复杂度都是 O(n),但在 gcc-13.2.0(-O2)下一次实测,list慢了十几倍。这不是编译器没优化好:list每走一步,都要从当前节点读出下一个节点的地址,再跳到那个地址去读数据,两次访问落在两个互不相干的内存位置;vector走一步只是把地址加 4 字节,下一个元素大概率已经在同一条缓存行(cache line)里了。
std::list是双向链表(doubly linked list),std::forward_list是 C++11 引入的单向链表(singly linked list)。它们与vector的差别不是「谁更快」,而是内存布局:布局决定缓存行为,缓存行为才决定快慢。
官方文档:std::list — cppreference 官方文档:std::forward_list — cppreference
2. 节点布局:指针比数据还大
链表的每个元素都是一个独立分配的节点(node),节点里除了数据,还有指向邻居的指针。
vector<int>(一次分配、连续内存,每元素 4 字节) [0][1][2][3][4][5][6][7][8][9][10][11][12][13][14][15] ... 一条 64B 缓存行装 16 个 int ^ data():地址连续,++it 就是 +4 字节 list<int>(每个元素一个节点,每节点 24 字节) head | v ┌───────────┬───────────┬──────┬─────────┐ ┌───────────┬───────────┬──────┬─────────┐ │ prev(NUL) │ next ──── │ 20 │ padding │ │ prev ──── │ next ──── │ 21 │ padding │ └───────────┴───────────┴──────┴─────────┘ └───────────┴───────────┴──────┴─────────┘ 8B 8B 4B 4B 8B 8B 4B 4B | ^ +-------------------------------------+ 节点散落在堆上不同位置:访问 21 要先读 20 的 next 字段 forward_list<int>(每个元素一个节点,每节点 16 字节) ┌───────────┬──────┬─────────┐ │ next ──── │ 20 │ padding │ └───────────┴──────┴─────────┘ 8B 4B 4B 单向链表省掉的正是 prev 指针list节点 = 两个指针 + 一个int,按 8 字节对齐后是 24 字节;forward_list节点 = 一个指针 + 一个int,是 16 字节。容器对象本身的大小也不一样,而且forward_list连size()都没有:
// fwd_list.cpp — 编译: g++ -std=c++17 -Wall -O2 fwd_list.cpp -o fwd_list #include <forward_list> #include <iostream> #include <iterator> #include <list> int main() { std::forward_list<int> f{2, 3, 4}; f.push_front(1); // 单向链表没有 push_back f.insert_after(f.before_begin(), 0); // 只能「在某节点之后」插入 std::cout << "f = "; for (int v : f) std::cout << v << ' '; std::cout << '\n'; std::cout << "sizeof(forward_list<int>) = " << sizeof(std::forward_list<int>) << '\n'; std::cout << "sizeof(list<int>) = " << sizeof(std::list<int>) << '\n'; std::cout << "distance(f) = " << std::distance(f.begin(), f.end()) << " (no size(), O(n))\n"; return 0; }f = 0 1 2 3 4 sizeof(forward_list<int>) = 8 sizeof(list<int>) = 24 distance(f) = 5 (no size(), O(n))forward_list只有 8 字节(一个 head 指针),list是 24 字节:两个哨兵指针再加一个元素计数器。forward_list没有size()不是标准委员会的疏忽,是一个明确的设计取舍:要支持 O(1) 的size(),就得在容器里长期多存一个计数器,并在每次insert_after/erase_after/splice_after里维护它;单向链表的定位是「尽可能小的节点 + 尽可能少的簿记」,所以把计数这件事推给想用的人自己数:std::distance(f.begin(), f.end())是 O(n)。
官方文档:std::distance — cppreference 顺带记住单向链表的接口形态:没有
insert,只有insert_after;没有erase,只有erase_after;before_begin()是「第一个元素之前」的虚拟位置,专给头部插入用。
3. 内存膨胀:实测每个元素占多少堆
sizeof(std::list<int>)只是容器对象,真正吃内存的是节点。要量出「每个元素实际占了多少堆」,最直接的办法是塞一个计数分配器(counting allocator)进去,把每次allocate的字节数加起来。链表的节点同样是通过分配器分配的,所以这本账是准的。关键技巧是把计数器放在非模板基类里:list内部的节点分配器是CountingAllocator的另一个模板实例化,如果计数器定义在模板里就会各自记一份,放基类才能全算进同一本账。
// list_mem.cpp — 编译: g++ -std=c++17 -Wall -O2 list_mem.cpp -o list_mem #include <cstddef> #include <forward_list> #include <iostream> #include <list> #include <memory> #include <vector> struct ByteCounter { inline static std::size_t bytes = 0; // C++17 内联静态数据成员,模板实例化之间共享 }; template <typename T> struct CountingAllocator : ByteCounter { using value_type = T; CountingAllocator() = default; template <typename U> CountingAllocator(const CountingAllocator<U>&) noexcept {} // 跨类型拷贝,账本同一本 T* allocate(std::size_t n) { bytes += n * sizeof(T); // 只记字节,不碰内存内容 return std::allocator<T>{}.allocate(n); } void deallocate(T* p, std::size_t n) noexcept { bytes -= n * sizeof(T); std::allocator<T>{}.deallocate(p, n); } template <typename U> bool operator==(const CountingAllocator<U>&) const noexcept { return true; } template <typename U> bool operator!=(const CountingAllocator<U>&) const noexcept { return false; } }; struct ListNode { ListNode* prev; ListNode* next; int value; }; struct FwdNode { FwdNode* next; int value; }; constexpr int N = 100'000; // 不写魔法数字,量级放在一处 int main() { std::cout << "sizeof(int) = " << sizeof(int) << '\n'; std::cout << "sizeof(ListNode) = " << sizeof(ListNode) << " (prev + next + int)\n"; std::cout << "sizeof(FwdNode) = " << sizeof(FwdNode) << " (next + int)\n"; auto report = [](const char* name, std::size_t bytes) { std::cout << name << " : " << bytes << " bytes for " << N << " ints = " << bytes / N << " B/elem\n"; }; { ByteCounter::bytes = 0; std::list<int, CountingAllocator<int>> lst; for (int i = 0; i < N; ++i) lst.push_back(i); report("list ", ByteCounter::bytes); } { ByteCounter::bytes = 0; std::forward_list<int, CountingAllocator<int>> flst; for (int i = 0; i < N; ++i) flst.push_front(i); report("forward_list", ByteCounter::bytes); } { ByteCounter::bytes = 0; std::vector<int, CountingAllocator<int>> vec; vec.reserve(N); // 用 reserve 把 vector 的一次分配定死,好比较 for (int i = 0; i < N; ++i) vec.push_back(i); report("vector ", ByteCounter::bytes); } return 0; }sizeof(int) = 4 sizeof(ListNode) = 24 (prev + next + int) sizeof(FwdNode) = 16 (next + int) list : 2400000 bytes for 100000 ints = 24 B/elem forward_list : 1600000 bytes for 100000 ints = 16 B/elem vector : 400000 bytes for 100000 ints = 4 B/elem实测的每元素字节与手写节点的sizeof完全吻合(24 / 16),说明「节点里两个指针 + 对齐填充」这个模型是对的。换算成膨胀倍数:
存储 10 万个int | 每元素栈上/堆上字节 | 总堆字节 | 相对vector膨胀 | 多出来的是什么 |
|---|---|---|---|---|
std::vector<int> | 4 | 400 000 | 1.0× | —— |
std::forward_list<int> | 16 | 1 600 000 | 4.0× | 1 个next指针 + 4 字节对齐填充 |
std::list<int> | 24 | 2 400 000 | 6.0× | prev+next两个指针 + 填充 |
std::list<LargeStruct> | 24 + sizeof(LargeStruct) | 随元素增大 | 逐渐趋近 1× | 元素越大,指针开销被摊薄 |
最后一行的意思是:指针开销是固定的,元素本身越大,链表的相对浪费越小;存小对象(int、float、指针)时,链表的 6 倍膨胀最刺眼。
4. 缓存局部性:遍历为什么反而不如 vector
CPU 不是一个个字节从内存取数据,而是以64 字节的缓存行(cache line)为单位。vector<int>的 16 个元素挤在一条缓存行里,顺序遍历时每 16 个元素才可能缺一次行;而且硬件预取器(prefetcher)能识别「地址连续递增」这种模式,提前把后面的行拉进缓存。
list是典型的指针追逐(pointer chasing):要访问下一个元素,必须先把当前节点的next字段读进寄存器。这是一个内存访问,它的结果决定了下一次访问的地址。CPU 无法提前知道地址,预取器完全失效,只能串行等待;每个节点都几乎必然触发一次 cache miss,而一次 miss 是几十到几百个时钟周期。这就是「同样 O(n),链表常数因子大得多」的物理原因。
// list_cache.cpp — 编译: g++ -std=c++17 -Wall -O2 list_cache.cpp -o list_cache #include <chrono> #include <cstddef> #include <cstdint> #include <iostream> #include <iterator> #include <list> #include <memory> #include <vector> constexpr int N = 20'000; int main() { std::vector<int> vec; vec.reserve(N); std::list<int> lst; std::vector<std::unique_ptr<char[]>> noise; // 模拟真实程序里穿插的其它分配 for (int i = 0; i < N; ++i) { vec.push_back(i); lst.push_back(i); noise.push_back(std::make_unique<char[]>(64)); } long long sum_v = 0; const auto t0 = std::chrono::steady_clock::now(); for (int v : vec) sum_v += v; // 连续内存 + 预取友好 const auto t1 = std::chrono::steady_clock::now(); long long sum_l = 0; const auto t2 = std::chrono::steady_clock::now(); for (int v : lst) sum_l += v; // 指针追逐,每次都等内存 const auto t3 = std::chrono::steady_clock::now(); const auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); }; std::cout << "vector: sum=" << sum_v << " time=" << ms(t0, t1) << " ms\n"; std::cout << "list : sum=" << sum_l << " time=" << ms(t2, t3) << " ms\n"; auto addr_of = [](const int& r) { return reinterpret_cast<std::uintptr_t>(std::addressof(r)); }; std::cout << "vector: neighbour gap = " << (addr_of(vec[1]) - addr_of(vec[0])) << " bytes\n"; // list 只能前向遍历单向链表,这里数前 1000 个节点的平均地址间距 std::uintptr_t prev = addr_of(lst.front()); std::size_t total = 0; std::size_t steps = 0; for (auto it = std::next(lst.begin()); it != lst.end() && steps < 1000; ++it, ++steps) { const std::uintptr_t cur = addr_of(*it); total += static_cast<std::size_t>(cur > prev ? cur - prev : prev - cur); prev = cur; } std::cout << "list : avg neighbour gap = " << total / steps << " bytes\n"; std::cout << "noise alive = " << noise.size() << '\n'; return 0; }vector: sum=199990000 time=~~ ms list : sum=199990000 time=~~ ms vector: neighbour gap = 4 bytes list : avg neighbour gap = ~~ bytes noise alive = 20000耗时每次运行都不同,但量级稳定:list比vector慢十几倍。更值得记住的是最后那个地址间距:vector的相邻元素永远只隔 4 字节(16 个元素共用一条缓存行),而list的相邻节点平均相隔两百多字节,也就是说每访问一个链表元素就要拉一条新的缓存行,而这行里另外 60 字节全是浪费。
| 维度 | std::vector | std::list | 谁赢 |
|---|---|---|---|
| 顺序遍历复杂度 | O(n) | O(n) | 一样 |
| 顺序遍历实际代价 | 每 16 个元素一次 cache miss | 每个元素一次 cache miss | vector完胜 |
| 硬件预取器 | 能识别连续模式,有效 | 地址依赖上一次读取,失效 | vector完胜 |
| 中间插入 | O(n)(尾部元素整体后移) | O(1)(改指针) | list赢,但前提是你已有迭代器 |
| 按位置随机访问 | O(1) | O(n) | vector完胜 |
| 每元素额外内存 | 0 | 16 B | vector完胜 |
| 迭代器失效 | 扩容/插入点之后全失效 | 仅被删元素失效 | list赢 |
「中间插入 O(1)」这一条也常被误用:list的insert是 O(1),但找到插入位置这一步通常要走 O(n)。只有当你已经持有那个位置的迭代器(比如在遍历中途、或迭代器存在别的索引结构里),O(1) 才是真收益。如果每次都要std::find找位置,那list就变成「O(n) 查找 + O(n) 慢遍历」,全面落后。
官方文档:容器库 · 迭代器失效总表 — cppreference
5. 第一个真优势:迭代器失效规则完全不同
这是链表最值钱的差别,也是最容易踩坑的地方。vector扩容时必须整块搬家(把元素逐个搬到新内存、释放旧内存),所有迭代器、指针、引用一次性全部失效;即使在中间insert没有触发扩容,插入点之后的元素也要整体后移,从插入点起的迭代器同样失效。list的insert/erase只是改几个指针,不移动任何已有节点,所以其他迭代器保持有效。
| 操作 | vector | deque | list/forward_list |
|---|---|---|---|
insert(中间,未扩容) | 插入点及其后全部失效 | 全部失效 | 全部有效 |
insert(触发扩容) | 全部失效 | 全部失效 | 全部有效 |
erase | 删除点及其后全部失效 | 全部失效 | 仅被删元素失效 |
push_back/pop_back | 扩容时全部失效 | 全部失效 | 全部有效 |
push_front/pop_front | 不支持 | 全部失效 | 全部有效 |
| 整体搬迁 | 扩容时发生 | 通常不发生 | 永不发生 |
// list_iterator.cpp — 编译: g++ -std=c++17 -Wall -O2 list_iterator.cpp -o list_iterator #include <iostream> #include <iterator> #include <list> #include <vector> int main() { std::list<int> lst{10, 20, 30}; auto it20 = std::next(lst.begin()); auto it30 = std::next(lst.begin(), 2); lst.insert(it30, 25); // 在 30 之前插入,已有迭代器全部保持有效 std::cout << "list: size=" << lst.size() << " *it20=" << *it20 << " *it30=" << *it30 << '\n'; lst.erase(it20); // 只有 it20 自己失效,it30 照常可用 std::cout << "list: size=" << lst.size() << " *it30=" << *it30 << " front=" << lst.front() << '\n'; std::vector<int> vec{10, 20, 30}; std::cout << "vector: capacity before insert = " << vec.capacity() << '\n'; vec.insert(vec.begin() + 2, 25); // size == capacity → 触发重新分配 std::cout << "vector: capacity after insert = " << vec.capacity() << '\n'; std::cout << "vector: insert/erase invalidates iterators at and after the point\n"; return 0; }list: size=4 *it20=20 *it30=30 list: size=3 *it30=30 front=10 vector: capacity before insert = 3 vector: capacity after insert = 6 vector: insert/erase invalidates iterators at and after the point注意上面刻意没有使用失效后的vector迭代器:那是未定义行为(undefined behavior),演示代码不该示范它。这里只用一个有明确定义的证据:容量从 3 变成 6,说明确实发生了重新分配。libstdc++ 的vector扩容按 2 倍增长(标准不规定倍数,只保证均摊 O(1)),任何教材上写死的「1.5 倍」都只是某个实现的选择。
6. 第二个真优势:splice 零拷贝搬移
list独有的splice(拼接)可以把一个节点(或一整段、整个链表)从一处搬到另一处,只改指针,不拷贝、不移动元素。用vector做同样的事只能insert一个拷贝、再erase原来的,元素搬两次。当元素很大(比如是个含std::string的结构体,甚至已经拿到地址被外部引用)时,splice的价值非常实际:元素对象的内存地址全程不变。
// list_splice.cpp — 编译: g++ -std=c++17 -Wall -O2 list_splice.cpp -o list_splice #include <iostream> #include <iterator> #include <list> #include <memory> #include <string> int main() { std::list<std::string> a{"aa", "bb", "cc"}; std::list<std::string> b{"dd", "ee"}; auto it = std::next(a.begin()); // 指向 "bb" const std::string* addr_before = std::addressof(*it); b.splice(b.end(), a, it); // 整节点搬移:不拷贝、不移动元素 std::cout << "a = "; for (const auto& s : a) std::cout << s << ' '; std::cout << "(size=" << a.size() << ")\n"; std::cout << "b = "; for (const auto& s : b) std::cout << s << ' '; std::cout << "(size=" << b.size() << ")\n"; std::cout << "element lived in place? " << (addr_before == std::addressof(b.back())) << '\n'; std::cout << "total elements kept = " << a.size() + b.size() << '\n'; return 0; }a = aa cc (size=2) b = dd ee bb (size=3) element lived in place? 1 total elements kept = 5element lived in place? 1是硬证据:"bb"这个对象在搬移前后地址一模一样,说明既没拷贝构造也没移动构造,只是把节点从a的链上摘下来挂到b上。另外注意splice不涉及任何元素构造/析构,元素总数守恒(a.size() + b.size()仍是 5)。
splice有三种重载:整条链表(splice(pos, other))、单个元素(splice(pos, other, it))、一个区间(splice(pos, other, first, last))。所有重载都保持迭代器有效性,除了被搬走的那个元素,它现在属于other,但指向它的迭代器依然有效,只是所属容器换了。
官方文档:std::list::splice — cppreference
7. 完整示例:用迭代器稳定性写一个 O(1) 的 LRU 缓存
LRU 缓存是「链表不可替代」的经典场景,也正好把前面两个优势串起来:需要中间删除、把元素提到头部、并且长期持有指向元素的迭代器。如果用vector实现,每次「提到最前」都要搬动后面一整段元素,缓存的命中路径会退化到 O(n)。
// lru_list.cpp — 编译: g++ -std=c++17 -Wall -O2 lru_list.cpp -o lru_list #include <iostream> #include <list> #include <string> #include <unordered_map> class LruCache { public: explicit LruCache(std::size_t capacity) : capacity_(capacity) {} bool get(const std::string& key, int& out) { const auto found = index_.find(key); // find:未命中不会插入任何东西 if (found == index_.end()) return false; order_.splice(order_.begin(), order_, found->second); // 提到最前:零拷贝 out = found->second->second; return true; } void put(const std::string& key, int value) { const auto found = index_.find(key); if (found != index_.end()) { // 已存在:改值 + 提到最前 found->second->second = value; order_.splice(order_.begin(), order_, found->second); return; } if (order_.size() == capacity_) { // 淘汰最久未用(链表尾部) index_.erase(order_.back().first); order_.pop_back(); } order_.emplace_front(key, value); index_.emplace(key, order_.begin()); // 迭代器稳定,可以长期存着 } void dump(const char* tag) const { std::cout << tag << ": "; for (const auto& item : order_) std::cout << item.first << '=' << item.second << ' '; std::cout << '\n'; } private: using Item = std::pair<std::string, int>; // key, value std::list<Item> order_; // front = 最近使用 std::unordered_map<std::string, std::list<Item>::iterator> index_; std::size_t capacity_; }; int main() { LruCache cache(3); cache.put("a", 1); cache.put("b", 2); cache.put("c", 3); cache.dump("after put a,b,c"); int value = 0; std::cout << "get(a) hit=" << cache.get("a", value) << " value=" << value << '\n'; cache.dump("after get a"); cache.put("d", 4); // 容量满,淘汰最久未用的 b cache.dump("after put d"); std::cout << "get(b) hit=" << cache.get("b", value) << '\n'; return 0; }after put a,b,c: c=3 b=2 a=1 get(a) hit=1 value=1 after get a: a=1 c=3 b=2 after put d: d=4 a=1 c=3 get(b) hit=0这段代码的每一处都吃到了链表的特性:index_里存的是std::list<Item>::iterator,如果换成vector,任何一次插入都可能让这些迭代器失效,整个设计直接崩塌;get命中时用splice把节点摘到头部,是纯指针操作,不管缓存里有一千条还是一百万条,都是常数时间;淘汰时从尾部pop_back,也是 O(1)。换成vector的话这三个操作里有两个会退化成 O(n),这就是链表真正不可替代的地方。
8. 选型表:默认 vector,链表是特例工具
把整篇的结论压成一张表:
| 你的需求 | 选谁 | 理由 |
|---|---|---|
| 不确定 / 没有特殊理由 | std::vector | 缓存友好、内存最省、遍历最快,绝大多数场景的最优解 |
| 尾部增删为主 | std::vector | push_back均摊 O(1),连续内存 |
| 需要按下标随机访问 | std::vector | O(1);list是 O(n) |
| 两头都要增删 | std::deque | 分段连续内存,两端都 O(1)(见《deque 与容器适配器全解》) |
| 中间频繁增删,且已持有迭代器 | std::list | insert/eraseO(1),不使其他迭代器失效 |
| 需要在遍历中途删元素 | std::list | 迭代器稳定;vector会被 O(n) 的元素搬移拖垮 |
| 元素很大、需要零拷贝搬移 | std::list+splice | 只改指针,元素地址不变,省掉拷贝/移动构造 |
| 元素很小、节点开销难以接受 | 别用list | 存int时膨胀 6 倍,纯亏 |
| 只需要单向遍历、想省内存 | std::forward_list | 节点 16 B 而非 24 B,容器对象 8 B 而非 24 B |
| 要求有序、按 key 查找 | std::map/std::set | 链表的查找是 O(n)(见《map / set 完全指南:红黑树与有序容器》) |
一句话:默认选vector;只有当「已经持有迭代器 + 中间增删」或「splice零拷贝搬移」这两条真正成立时,链表才是划算的。至于forward_list,它是「省一个指针和 4 字节对齐填充」的极致优化,代价是只能单向遍历、只能insert_after/erase_after、没有size();除非你在做内存极度受限的嵌入式场景,否则list的接口更好用。
官方文档:C++ Core Guidelines「SL: Containers」——容器选型的一手依据 官方文档:std::vector — cppreference
9. 延伸阅读
- std::list — cppreference:完整接口,重点看
splice的三个重载和复杂度标注。 - std::forward_list — cppreference:注意它没有
size()、没有push_back,接口一律带_after。 - std::list::splice — cppreference:明确写了「不拷贝也不移动元素,只调整节点内部的指针」。
- 容器库 · 迭代器失效总表 — cppreference:哪条规则会失效、哪条不会,以这张表为准,不要凭印象。
- C++ Core Guidelines — isocpp.github.io:容器与资源管理的基调来源。
本知识库内的相关篇目:
- 《vector 扩容策略与迭代器失效全解》 —— vector 的 size 与 capacity 为什么分开、push_back 触发的扩容到底做了什么
- 《deque 与容器适配器全解:stack、queue、priority_queue 到底套了什么》 —— 讲透 std::deque 的分段连续内存结构——固定大小的缓冲块加一个中控数组
- 《C++ map 与 unordered_map 怎么选:底层结构、复杂度与决策流程》 —— std::map 和 std::unordered_map 接口几乎一样,底层却完全不同。
10. 一句话总结
list/forward_list用「每个元素一个节点」换来 O(1) 的已知位置插入删除,代价是固定 16~24 字节的节点开销(存int时膨胀 4~6 倍)和指针追逐导致的缓存不友好,所以顺序遍历反而比vector慢十几倍;它们的真正价值只有两个:insert/erase不使其他迭代器失效,以及splice能零拷贝搬移元素。默认选vector,把链表留给这两条确实成立的场景。