「中间插入删除用链表,随机访问用数组」这条规则背了很多年,但它只说对了一半。真把十万个 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 的差别不是「谁更快」,而是内存布局------布局决定缓存行为,缓存行为才决定快慢。
2. 节点布局:指针比数据还大
链表的每个元素都是一个独立分配的节点(node),节点里除了数据,还有指向邻居的指针。
text
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() 都没有:
cpp
// 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;
}
text
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 的另一个模板实例化,如果计数器定义在模板里就会各自记一份,放基类才能全算进同一本账。
cpp
// 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;
}
text
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),链表常数因子大得多」的物理原因。
cpp
// 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;
}
text
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) 慢遍历」,全面落后。
5. 第一个真优势:迭代器失效规则完全不同
这是链表最值钱的差别,也是最容易踩坑的地方。vector 扩容时必须整块搬家 (把元素逐个搬到新内存、释放旧内存),所有迭代器、指针、引用一次性全部失效;即使在中间 insert 没有触发扩容,插入点之后的元素也要整体后移,从插入点起的迭代器同样失效。list 的 insert / erase 只是改几个指针,不移动任何已有节点,所以其他迭代器保持有效。
| 操作 | vector |
deque |
list / forward_list |
|---|---|---|---|
insert(中间,未扩容) |
插入点及其后全部失效 | 全部失效 | 全部有效 |
insert(触发扩容) |
全部失效 | 全部失效 | 全部有效 |
erase |
删除点及其后全部失效 | 全部失效 | 仅被删元素失效 |
push_back / pop_back |
扩容时全部失效 | 全部失效 | 全部有效 |
push_front / pop_front |
不支持 | 全部失效 | 全部有效 |
| 整体搬迁 | 扩容时发生 | 通常不发生 | 永不发生 |
cpp
// 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;
}
text
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 的价值非常实际:元素对象的内存地址全程不变。
cpp
// 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;
}
text
a = aa cc (size=2)
b = dd ee bb (size=3)
element lived in place? 1
total elements kept = 5
element 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,但指向它的迭代器依然有效,只是所属容器换了。
7. 完整示例:用迭代器稳定性写一个 O(1) 的 LRU 缓存
LRU 缓存是「链表不可替代」的经典场景,也正好把前面两个优势串起来:需要中间删除、把元素提到头部、并且长期持有指向元素的迭代器 。如果用 vector 实现,每次「提到最前」都要搬动后面一整段元素,缓存的命中路径会退化到 O(n)。
cpp
// 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;
}
text
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/erase O(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 的接口更好用。
9. 延伸阅读
- std::list --- cppreference:完整接口,重点看
splice的三个重载和复杂度标注。 - std::forward_list --- cppreference:注意它没有
size()、没有push_back,接口一律带_after。 - std::list::splice --- cppreference:明确写了「不拷贝也不移动元素,只调整节点内部的指针」。
- 容器库 · 迭代器失效总表 --- cppreference:哪条规则会失效、哪条不会,以这张表为准,不要凭印象。
- C++ Core Guidelines --- isocpp.github.io:容器与资源管理的基调来源。
10. 一句话总结
list / forward_list 用「每个元素一个节点」换来 O(1) 的已知位置插入删除,代价是固定 16~24 字节的节点开销(存 int 时膨胀 4~6 倍)和指针追逐导致的缓存不友好------所以顺序遍历反而比 vector 慢十几倍;它们的真正价值只有两个:insert/erase 不使其他迭代器失效,以及 splice 能零拷贝搬移元素。默认选 vector,把链表留给这两条确实成立的场景。