在 STL(Standard Template Library,标准模板库)的众多容器里,vector 大概是出场率最高的那个——它快、连续、支持随机访问。但如果你写的过程里碰上"我要在头部插入、我要在中间频繁删除"这类需求,用 vector 就会越写越别扭,因为每插入或删除一个元素,它都要把后面的元素整体往前搬或往后挪,代价很高。
这时候就需要另一个容器出场了:std::list。它是 STL 里的双向链表容器,在任意位置插入和删除都是 O(1) 的,哪怕是在最头部、最尾部,都一样的快。这篇文章我们就把它掰开揉碎讲清楚:它的底层长什么样、接口怎么用、迭代器有什么脾气、以及它和 vector 到底谁在什么场景下更强。
在看 list 之前,有两块前置知识我们需要先打好底:双向链表 和 迭代器。别担心,我会就地讲透,不需要你回头翻别的资料。
顺带交代一件事:C++11 之后标准库又加了一个
std::forward_list(单向链表),它只要一个_next指针,省内存但只能从头往后走。而我们这篇的主角list严格来说叫双向链表。你看官方文档时,凡写list都指双向,凡写forward_list才指单向。
为什么需要 list:先认识"内存不连续"的代价与优势
先想一个问题:vector 的内存是一段连续的空间,就像一栋楼的房间号一栋挨着一栋。当你想在楼层中间塞一个新房间,或者拆掉一个房间时麻烦就来了——后面的所有房间都得跟着移动,否则地址就乱了。这就是 vector 在中间插入、删除是 O(N) 的原因。
链表则完全不同。它的每个"房间"(节点,英文叫 node)独立地散落在内存的各个角落,各自带一个地址(指针)告诉别人"我旁边是谁"。想塞一个节点进去,我只需要改两条指针:让新节点的 next 指向前一个节点的下一个、让前一个节点的 next 指向新节点。房间里的人一个都不用动。
你可以理解为:vector 是"公寓楼",所有住户挤在一栋楼里,搬家(搬移元素)很痛苦;list 是"平房村",每户一个独立小院,想加农户只需要在门口挂个路标(改指针),邻居们完全不受影响。
记住这个核心区别,后面所有的特性几乎都从"连续" vs "不连续"这两个词派生出来。
那"连续"和"不连续"到底各自买到了什么、付出了什么?我们把账算清楚,后面才不会选错:
vector(连续)买到的是:随机访问 O(1)(v[5]一步到位)、缓存友好(数据挨着放,CPU 高速缓存一次能取一片)、额外内存开销小。付出的是:中间插入/删除要搬元素 O(N),头部插入尤其伤——每个元素都要挪一遍,还可能触发"增容"(重新开一块更大的空间→把老元素整体拷进去→释放旧空间),那一趟下来是 O(N) 甚至更惨。list(不连续)买到的是:任何已知位置插入/删除都 O(1)、头尾插删都 O(1)、增删节点不搬动任何已有元素。付出的是:随机访问 O(N)(只能一步一步爬)、缓存不友好(节点散落,跳来跳去)、每个节点额外带两三个指针的开销。
一句话:没有免费的午餐。 你是把"钱的权重"押在"读得快"还是"改得快"上,就决定了该选谁。
真实场景里,list 的身影经常出现在这些地方:LRU 缓存(最近最少使用,头尾来回搬)、任务/事件队列(元素要插到中间或从中间删)、贪吃蛇和斗地主出牌这类不断增删的集合、以及"公平调度"里把队伍里某个结点挪到队尾的轮转。这些共性都是"位置是主角、下标无所谓"。
list 的底层结构:带头双向循环链表
std::list 的底层实现是带头节点的双向循环链表。我们把这句话拆成三个词慢慢看。
"双向":每个节点除了存数据 _data,还有两个指针——_prev 指向前一个节点,_next 指向后一个节点。有了 _prev,链表就能从尾巴往头走,这就是后面"反向迭代器"能工作的基础。对比单向链表:它只有一个 _next,想从尾巴往头走根本走不了,所以单向链表天生没有反向遍历,也没有 rbegin()/rend()。
"循环":最后一个节点的 _next 不是 nullptr,而是指回头部的开头;第一个节点的 _prev 也不是 nullptr,而是指向链表的尾部。整个结构首尾相连,转成一个环。为什么要首尾相连成环而不是断成两条死路?两个立刻可见的好处:其一,尾插 push_back 只要知道哨兵(下面讲)就能一步定位到尾部,O(1);其二,从任意起点往后走,能保证"走到 end 之前"的遍历必然覆盖全部节点,不会撞上 nullptr 空指针。
"带头节点"(哨兵节点):链表头不是第一个数据节点,而是有一个额外的、不存放有效数据的"头节点"(也叫哨兵节点、哑节点)。它只是作为一个"地标"存在,专门用来标记链表的起点和终点。
为什么要这个哨兵?这是 STL 里一个精妙的设计。begin() 返回指向第一个真正的数据节点的迭代器,而 end() 返回的正是这个哨兵节点——它是不存数据的。这样一来,遍历到哨兵就说明走到头了,而且即使链表是空的,begin() == end() 也仍然成立,迭代器永远指向一个合法存在的节点,从根上避免了"空指针解引用"的种种麻烦。
空链表时,哨兵节点的 _prev 和 _next 都指向它自己。这一点不要求你背下来,但建立"end() 是一个哨兵节点"的直觉,会对理解后面 end() 为什么不能解引用很有帮助。
下面这段代码用最原始的方式把这个"环 + 哨兵"画给你看。它不依赖 std::list,纯粹模拟它的内部结构,你可直接编译运行:
#include <iostream>
using namespace std;
struct ListNode
{
int _data; // 数据
ListNode* _prev; // 指向前一个节点
ListNode* _next; // 指向后一个节点
ListNode(int v) : _data(v), _prev(nullptr), _next(nullptr) {}
};
int main()
{
// 造一个空的"带头循环"骨架:哨兵自身首尾相连 = 空表
ListNode sentinel(0); // 哨兵头,数据字段无效
sentinel._prev = &sentinel;
sentinel._next = &sentinel; // 空表特征:_prev == _next == &sentinel
// 往尾部依次插入两个数据节点
ListNode n1(10); // 第 1 个数据节点
n1._prev = sentinel._prev; // 前驱指向哨兵
n1._next = &sentinel; // 后继指向哨兵
sentinel._prev = &n1; // 哨兵的前驱 = n1
sentinel._next = &n1; // 哨兵的后继 = n1(它成为 begin())
ListNode n2(20); // 第 2 个数据节点
n2._prev = sentinel._prev; // 前驱指向 n1
n2._next = &sentinel; // 后继指向哨兵
n1._next = &n2; // n1 后继 = n2
sentinel._prev = &n2; // 哨兵前驱 = n2(它是最后一个)
// 此刻:begin() == sentinel._next(即 n1),end() == &sentinel(哨兵)
cout << "首节点: " << sentinel._next->_data << '\n'; // 10
cout << "尾节点: " << sentinel._prev->_data << '\n'; // 20
cout << "尾节点 _next 又指回哨兵: "
<< (sentinel._prev->_next == &sentinel ? "是(成环)" : "否") << '\n'; // 是(成环)
cout << "若这是空表 sentinel._prev==sentinel._next==&sentinel: "
<< "从上面注释看即可" << '\n';
return 0;
}看到没?哨兵扮演了"循环的铰链"——它既是 end() 的站位,又让空表也有一个合法节点可指。begin() 恒等于 sentinel._next,end() 恒等于 &sentinel,这条公式请刻进脑子里,后面手写 list 时你还会用到。
构造 list:多种初始化方式
list 的构造函数很丰富,基本覆盖了日常所有需求。直接看代码,每一条我都写了注释:
#include <list>
#include <vector>
#include <utility>
#include <string>
#include <iostream>
using namespace std;
void show(const string& tip, const list<int>& l)
{
cout << tip;
for (auto& x : l) cout << ' ' << x;
cout << '\n';
}
int main()
{
// 1. 默认构造:一个空的 list,里面一个元素都没有
list<int> l1;
// 2. 指定元素个数和初值:5 个都等于 3 的元素
list<int> l2(5, 3);
// 3. 只给个数、不给定值:n 个默认值(对 int 就是 0)
// 注意 empty 括号里那个值在 C++11 之前必须显式写,
// C++11 起才能省略,省略时用 T() 填充
list<int> l3(4); // 4 个 0
// 4. 拷贝构造:用 l2 的内容造一个新的 list(深拷贝,互不影响)
list<int> l4(l2);
// 5. 用一段连续的迭代器区间 [first, last) 构造
vector<int> v{ 7, 8, 9 };
list<int> l5(v.begin(), v.end()); // 跨容器初始化,这是允许的
// 6. 直接用一堆值初始化(C++11 新增的初始化列表语法)
list<int> l6{ 10, 20, 30, 40 };
// 7. 移动构造(C++11 新增):把 l6 的资源"抢"过来,l6 被掏空
list<int> l7(move(l6));
// 8. assign:给一个已经存在的 list 重新装满内容(会清空旧的)
list<int> l8;
l8.assign(3, 9); // 装 3 个 9
l8.assign(l5.begin(), l5.end()); // 用 l5 的区间重装
show("l2:", l2); // 3 3 3 3 3
show("l3:", l3); // 0 0 0 0
show("l5:", l5); // 7 8 9
show("l6:", l6); // (空,因为被移动走了)
show("l7:", l7); // 10 20 30 40
show("l8:", l8); // 7 8 9
return 0;
}这里有两个必须讲透的点:
第一,第 5 种区间构造 list(InputIterator first, InputIterator last) 接受的是迭代器区间,左闭右开——last 指向的位置不算在内。这里的迭代器不一定是 list 自己的,你甚至可以拿 vector 的迭代器、数组的裸指针来初始化一个 list<int>,只要迭代器解引用得到的类型能构造出 int 就行。这就是迭代器抽象的强大之处:它把"从哪里来"和"存到哪里去"解耦了。所以课件里 int array[] = {...}; list<int> l(array, array+sizeof(array)/sizeof(array[0])); 那种写法才成立——裸指针 array、array+n 在区间构造眼里就是一对迭代器。
第二,拷贝构造和移动构造的区别(这是 C++11 之后所有容器共通的认知):list<int> l4(l2) 是深拷贝,l2 和 l4 各有一份独立的节点,改一个不影响另一个;而 list<int> l7(move(l6)) 是移动,它只是把 l6 头部的指针"交接"给 l7,一个字节的数据拷贝都没有,所以 O(1)、飞快,代价是 l6 变空。什么时候会自动触发移动?返回临时 list 时、std::move 显式转义时。这是现代 C++ 性能的核心之一。
list 的容量与元素访问:empty/size/front/back
链表没有下标,所以没有 operator[] 也没有 at()。要看首尾元素得用 front() 和 back(),它们分别返回第一个和最后一个节点的值的引用——注意是引用,你可以通过它直接修改那个元素。容量方面是 empty() 判断是否为空、size() 返回节点个数,clear() 清空所有数据节点(哨兵保留,链表还能继续用)。
#include <list>
#include <iostream>
using namespace std;
int main()
{
list<int> l{ 1, 2, 3, 4 };
cout << "是否为空: " << l.empty() << endl; // 0 表示非空
cout << "元素个数: " << l.size() << endl; // 4
cout << "最大容量: " << l.max_size() << endl; // 实现相关,一般是个超大数
cout << "头元素: " << l.front() << endl; // 1
cout << "尾元素: " << l.back() << endl; // 4
// front()/back() 返回的是引用,可以直接改
l.front() = 100; // 把头元素改成 100
l.back() = 400; // 把尾元素改成 400
cout << "改后头元素: " << l.front() << endl; // 100
cout << "改后尾元素: " << l.back() << endl; // 400
cout << "清空前 size: " << l.size() << endl; // 4
l.clear(); // 清空所有数据节点,但哨兵保留
cout << "clear 后 size: " << l.size()
<< " empty: " << l.empty() << endl; // 0 1
// clear 之后链表还能继续用,哨兵还在
l.push_back(7);
cout << "clear 后还能插入: " << l.front() << endl; // 7
return 0;
}几个容易忽视的细节:
size()到底是不是 O(1)? 现代 C++(C++11 起)标准明确要求 list 的size()是常数时间——实现通过在对象内部维护一个_size计数器,增删时同步增减,查询直接返回该值。C++98 时代标准只允许size()是 O(N),但主流编译器其实早就用计数器实现了。所以今天你放心l.size()不会拖慢你。front()/back()(以及pop_front()/pop_back())在空 list 上调用是未定义行为——通常是崩溃或垃圾值。所以不确定是否非空时,先empty()判断一下再动。clear()之后,内部所有数据节点都被删除并释放,但哨兵节点不会消失,所以l这个对象还能继续push、继续用。这正是"哨兵是对象的固定地标"这一设计的红利。
list 的遍历与双向迭代器
要遍历 list,得先理解迭代器。前面在构造里我们已经悄悄见过它了。你现在可以暂时把迭代器理解成"一个指向某个节点的指针",它支持像指针一样的 * 解引用、-> 访问成员、++ 移动到下一个节点。
list 提供四个相关的迭代器。正向迭代器 begin() 指向第一个数据节点,end() 指向哨兵节点(即最后一个元素的下一个位置);遍历时从 begin() 一路 ++ 到 end() 停。注意 end() 指向哨兵,它不解引用——解引用 end() 是未定义行为,因为那里没有有效数据。
#include <list>
#include <iterator>
#include <iostream>
using namespace std;
int main()
{
list<int> l{ 10, 20, 30, 40 };
// 正向遍历:从第一个元素走到 end()(哨兵)之前
for (auto it = l.begin(); it != l.end(); ++it)
{
cout << *it << " "; // 解引用得到当前节点的数据
}
cout << endl; // 输出:10 20 30 40
// 反向遍历:rbegin() 从最后一个元素开始,++ 朝头方向走
for (auto it = l.rbegin(); it != l.rend(); ++it)
{
cout << *it << " ";
}
cout << endl; // 输出:40 30 20 10
// 算法库 <iterator> 里两个对双向迭代器友好的工具
// advance:把迭代器"挪动 n 步"。list 没有 +n,只能线性走
auto pos = l.begin();
advance(pos, 2); // 走到第 3 个(0 基),即 30
cout << "advance 后指向: " << *pos << endl; // 30
// distance:两个迭代器的距离。对 list 同样要线性算
cout << "begin到end距离: "
<< distance(l.begin(), l.end()) << endl; // 4
// 范围 for(C++11 起)。本质就是 begin()/!=/++(*)/end 的语法糖
for (auto& x : l) x *= 10; // 通过引用原地放大 10 倍
for (auto& x : l) cout << x << " ";
cout << endl; // 100 200 300 400
return 0;
}list 的迭代器还有一个关键的底层细节:它和 vector 那种"原生指针"式的迭代器不一样,它是一个类,内部封装了一个指向节点的指针(Node* _cur),并通过重载 operator*、operator++ 等来表现得像个指针。这就是后面"迭代器为什么不会因插入而失效"的根源——因为 list 的节点地址在插入时从不改变。
把迭代器这个对象掰开看,它的"芯"就是一行:一个指向节点的裸指针 _cur。具体行为是:
*it等价于it._cur->_data(取当前节点数据);++it等价于it._cur = it._cur->_next(指针沿_next走到下一个节点);--it等价于it._cur = it._cur->_prev(沿_prev往回走);it == end()等价于it._cur == head_(哨兵地址)。
你抓住"迭代器就是握着 _cur 的指针替身"这条,后面所有与 list 相关的迭代器行为都不再用背,直接推理即可。
反向迭代器 rbegin() 和 rend() 让你从尾巴往头遍历。这里有个很容易绕晕的点:反向迭代器的 ++ 其实是"向前走",也就是朝 begin() 方向移动。课件原话是:rbegin 对应 end 位置,rend 对应 begin 位置。所以我们说反向遍历是从"尾的下一位"反着走回"头",而 rbegin() 解引用恰好取到最后一个元素——这个"恰好"到底怎么来的,放到文章最后讲反向迭代器实现时,你会豁然开朗。
list 元素的插入与删除(头尾 + 中间)
这是 list 的主场。头插 push_front、尾插 push_back、头删 pop_front、尾删 pop_back,全部 O(1);任意位置的 insert(插入)和 erase(删除)也是 O(1)——因为它只改指针,不搬移任何元素。
#include <list>
#include <utility>
#include <iostream>
using namespace std;
void print(const list<int>& l)
{
// 注意:这里是 for 范围循环,std::list 只支持顺序访问,不能随机下标
for (auto& x : l)
cout << x << " ";
cout << endl;
}
int main()
{
list<int> l;
l.push_back(3); // 尾部插入 3
l.push_back(4); // 尾部插入 4
l.push_front(2); // 头部插入 2
l.push_front(1); // 头部插入 1
print(l); // 输出:1 2 3 4
// insert:在指定位置之前插入元素
auto it = l.begin(); // 指向第一个元素 1
++it; // 指向第二个元素 2
l.insert(it, 99); // 在 2 前面插入 99 -> 1 99 2 3 4
print(l); // 输出:1 99 2 3 4
// erase:删除指定位置(迭代器指向的位置)的元素
auto del = l.begin(); // 指向 1
l.erase(del); // 删掉 1
print(l); // 输出:99 2 3 4
// C++11 起 erase 返回被删元素的下一个元素的迭代器
auto nextIt = ++l.begin(); // 指向 2
nextIt = l.erase(nextIt); // 删除 2,返回指向 3 的迭代器
print(l); // 输出:99 3 4
cout << "erase 返回值指向: " << *nextIt << endl; // 3
// swap:交换两个 list 的内部指针,O(1),不拷贝任何元素
list<int> m{ 100, 200 };
l.swap(m);
print(l); // 输出:100 200
print(m); // 输出:99 3 4
// emplace_back(C++11):原地构造,省一次临时对象拷贝/移动
list<pair<int, int>> pl;
pl.emplace_back(1, 2); // 直接在尾节点上构造 pair
cout << "emplace: " << pl.front().first << ',' << pl.front().second << endl; // 1,2
l.pop_front(); // 头删,删 100 -> 剩 200
l.pop_back(); // 尾删,删 200 -> 空
print(l); // 输出:(空)
return 0;
}insert 的语义是"插入在 position 指向的元素之前",它返回指向新插入元素的迭代器。erase 删除 position 指向的元素。这里有个经常被忽略、也极其重要的点:insert 之后,原来所有的迭代器依然有效(包括指向被插入位置前后的那些),因为新节点是动态申请的一大块独立内存,插进去只是改了前后两个指针,没有任何已存在节点的地址发生改变。这跟 vector 一插入就可能让所有迭代器失效,形成了鲜明对比。
而你既然已经手握 _cur 这把钥匙,就更能体会这段 O(1) 是怎么来的:insert 干的事无非是"new 一个节点 → 改它的 _prev/_next → 再改它前后那两个邻居的指针",四行代码,跟链表有多长一毛钱关系都没有,所以是 O(1)。但请务必注意:这个 O(1) 说的是"位置已经由迭代器给定"时的插入成本。如果你手头根本没有迭代器、只有一个下标概念,那你得先从 begin() 一路爬过去找到位置——那一趟是 O(N)。所以更准确的说法是:"已知位置的插入删除 O(1),寻找位置本身 O(N)"。
swap 顺带提一句:它交换两个 list 也就是换换各自的哨兵指针和 _size 计数器,O(1) 且不使任何迭代器失效。对比 vector::swap 要搬整个缓冲区的指针,list::swap 尤其便宜。
list 的迭代器失效规律:插入不失效,删除则小心
list 有个铁律:插入不会让任何迭代器失效;删除时,只有指向被删除节点的那个迭代器会失效,其他迭代器毫发无损。
这个"失效"具体指什么?失效的迭代器还"存在",但它所指向的节点已经被销毁了,再去解引用它就是访问已释放的内存,结果是未定义行为(可能崩溃,可能读到垃圾数据,还可能在调试模式下断言报错)。所以规则很简单:被 erase 删掉的那个迭代器,用完赶紧扔掉,别再用。
为什么偏偏是这条规律?回到 _cur:插入新节点不改变任何已存在节点的地址,你手里的 _cur 还指向老地方,自然有效;删除则让那个节点的内存被 delete 掉,握着这个已释放地址的 _cur 就成了"悬空指针",一解引用就是访问已释放内存。
最经典的翻车演示是这样的——想用 erase 清空整个 list:
#include <list>
#include <iostream>
using namespace std;
int main()
{
list<int> l{ 1, 2, 3, 4, 5 };
auto it = l.begin();
while (it != l.end())
{
l.erase(it); // 错误:erase 后 it 指向的节点已被删除
++it; // 错误:it 已经失效,对失效迭代器 ++ 是未定义行为
}
return 0;
}这段代码在大多数环境下运行,程序会直接崩溃,或者行为诡异。原因就是把失效的 it 又拿去做 ++。正确的姿势有两种,我推荐第一种,它最稳妥、跨版本最安全:
#include <list>
#include <iostream>
using namespace std;
int main()
{
list<int> l{ 1, 2, 3, 4, 5 };
// 方式一:erase(it++),前缀森林之王
auto it = l.begin();
while (it != l.end())
{
// 先让 it 自增到下一个节点,再把自增前的老值传给 erase
// 这样哪怕 erase 把老节点删了,it 已经指向安全的下一个节点
l.erase(it++);
}
cout << "清空后 size = " << l.size() << endl; // 0
// 方式二(C++11 起):利用 erase 的返回值
list<int> m{ 6, 7, 8 };
auto p = m.begin();
while (p != m.end())
p = m.erase(p); // 让 p 等于被删元素的下一个位置
cout << "m 也空了 size = " << m.size() << endl; // 0
return 0;
}erase(it++) 之所以安全,是因为 it++(后置自增)的语义是:先返回自增前的旧值给 erase 去删,同时 it 自身已经前进到了下一个节点。这样删的是"上一任",而 it 已经提前站在了安全区。这段逻辑一定要想明白,它是所有"遍历中删除"场景的基础,后面我们讲 remove、讲复杂遍历删除时还会用到。
再补充一个陷阱场景:在范围 for 里删除。范围 for 内部等价于提前拍好 begin()/end() 的迭代器快照来遍历,如果你在循环体里 erase 了当前 x 背后的节点,循环内部的那个"隐藏迭代器"就失效了,再 ++ 就是未定义行为,容易出现"跳过元素"或崩溃。所以"边遍历边删"务必用上面两种显式迭代器写法,不要在范围 for 里直接删。
逻辑上讲,在 C++11 之后 erase 还会返回被删元素之后那个位置的迭代器,所以也可以写成 it = l.erase(it); 这样一行到底。两种都对,erase(it++) 的可移植性更好,因为它完全不依赖 erase 的返回值——这在你不得不兼容比较老的代码风格时尤其省心。
list 为什么不支持随机访问
你可能已经注意到,list 没有 operator[],也不能 it + 5 这样跳着访问。这是因为 list 的节点在内存中不连续,代码根本没法通过"地址加偏移"一步算出第五个节点在哪里——第 5 个节点和第 3 个节点之间没有固定的内存距离,它们的地址毫无规律,只能靠 _next 指针一个接一个地走过去。
所以,要访问 list 的第 N 个元素,你必须从头(或从尾)开始,一个节点一个节点地"爬"过去,时间复杂度是 O(N)。你只能通过 ++/-- 做一步一格地移动,这类迭代器叫双向迭代器(只能前进和后退一步,就已经是我能申请到的全部能力了),而不是 vector 那种支持 + n、[n] 的随机访问迭代器。
生活化地类比:数组像一本"有目录的藏书",我想读第 200 页直接翻过去就行(随机访问);链表像一串"手翻便签",每一张都只在页脚写着"下一张在哪",想找第 200 张只能从头一张张捻过去。
换句话说:list 用"无法随机访问"这个代价,换来了"任意位置 O(1) 插入删除"这个收益。 没有免费的午餐——如果你既想随机访问又想 O(1) 插入,那在单容器里是做不到的。你要么在"读得快"和"改得快"之间选一个。
这里有个实际影响:像 std::sort(标准库的排序函数)、二分查找这类依赖随机访问的算法,不能直接用在 list 上。这也是 list 为什么要自己提供 sort、merge 等一批成员函数的原因之一,我们稍后细说。
list 与 vector 的对比
前面零散讲了不少,这里用一个表把它们的关键差异一次性对齐,也方便你日后查阅。(C++ STL 里 deque 是两者的折中,既有随机访问又能两端高效插删,但那是另一个故事了。)
| 对比维度 | vector | list |
|---|---|---|
| 底层结构 | 动态顺序表,一段连续空间 | 带头双向循环链表 |
| 随机访问 | 支持,l[i] 为 O(1) | 不支持,访问第 N 个元素为 O(N) |
| 任意位置插入/删除 | 慢,需搬移元素 O(N),插入可能诱发增容(开新空间+拷元素+释放旧空间,更慢) | 快,只改指针,O(1) |
| 头插/front | 很慢,O(N),且可能搬动所有元素 | O(1) |
| 空间利用率 | 底层连续,不易碎片,缓存命中率高 | 小节点频繁 new,易碎片,缓存命中率低 |
| 迭代器实现 | 原生指针 | 对节点指针的类封装 |
| 迭代器失效 | 插入可能因扩容导致全部失效;删除中间元素会让其后的迭代器/引用失效 | 插入任何都不失效;删除只会让指向被删节点的迭代器失效 |
| 面向应用 | 高效存储+随机访问,插入删除少 | 大量插入删除,不关心随机访问 |
(已确认"使用场景"行其实是容器选型,我把同义信息并入了正文判断口诀,表格聚焦结构差异。)
这张表的每行都能解释出一串工程取舍。你看最后几行特别有意思:vector 的迭代器"稳不住",一个 push_back 触发扩容,之前存的迭代器/引用全作废;而 list 的迭代器特别"皮实",只要你别去删它指的那个节点,它就能一直用下去。另外要更正一个常见误解:不是"随便插删一定选 list"——如果你插删的位置总是在尾部,vector 的尾插摊销也是 O(1),且缓存友好,反而常更快。list 真正的主场是头插和任意位置对着迭代器插删。
应该选谁?
给一个实用的判断口诀:
- 你要频繁随机访问、关心读的速度和缓存友好,插入删除较少 → 选
vector。 - 你要做大量插入删除、位置在前端或中间占大头,不太依赖随机访问 → 选
list。 - 大量"往两端塞 + 也要一点随机访问" → 多数情况
deque更合适,你可以在它和 list 之间再掂量。
另外记住:永远不要"为了找第 N 个元素"去遍历 list。那种"我有个 list,现在要取第三个元素,就 it++; it++;"的写法,量大之后性能会很糟。要是"按下标取"是你操作的主流,一开始就该用 vector。
一层更深的提醒放这里:list 的缓存不友好在工程上往往比"O(N) 随机访问"更致命。因为每个节点都是一次独立 new,地址散落在堆里,遍历时 CPU 缓存基本命不中,逐节点访存的速度可能比 vector 慢一个数量级。这也是为什么现代高性能代码里,list 的用武之地其实在明显小于它的理论地位——除非你确实需要"O(1) 的中间增删"或"迭代器长期稳定",否则 vector 通常更快。
list 独有的成员函数:sort / splice / remove / unique / merge
list 除了普通容器都有的增删查改,还自带几个"亲儿子"成员函数。为什么是成员函数而不是像 std::sort 那样放在算法库里?根本原因就是前面说的:list 的迭代器不是随机访问迭代器,算法库那一套依赖随机访问的算法它一个都用不了。所以标准库直接把"链式版本"的算法塞进了 list 自己,让它调用起来又顺手又高效。
splice:list 的杀手锏
merge 我们等会说,先讲 splice——它可能是 list 最令人惊艳的功能。splice 的意思是把"另一个 list 的节点"直接转移到本 list 里,转移的是节点本身,而不是复制一份数据。
这意味着它把整条链表从一个容器"剪"下来,"贴"到另一个容器上。因为只改指针、完全不申请新节点、不拷贝任何元素,所以它的时间复杂度是 O(1)(整表转移、单元素转移)。这是 list 的杀手锏:任何别的容器(vector、deque)想合并两个容器都得复制元素,唯独 list 能做到"搬家不搬货"。
"splice 为什么要 O(1) 搬结点",我们把算账算到底:对 vector 而言"把 b 并到 a"意味着要为 b 的每个元素申请空间并把值拷贝/移动进 a——你这是复制货物;而 splice 只是把 b 一串节点的指针端点解开、重新接到 a 上——货物还是那些货物,只是换了仓库、搬的是指针。所以 splice 完成后,被搬元素的迭代器和引用一个都不会失效,只是它们现在表现为"属于 a"而不是"属于 b"了。
#include <list>
#include <iostream>
using namespace std;
void print(const char* tag, const list<int>& l)
{
cout << tag << ":";
for (auto& x : l) cout << ' ' << x;
cout << endl;
}
int main()
{
// 用法 1:整表转移,O(1)。dst 被 src 全部节点塞满,src 变空
list<int> src{ 100, 200, 300 };
list<int> dst{ 1, 2, 3 };
dst.splice(dst.begin(), src); // 把 src 整体插到 dst.begin() 之前
print("dst", dst); // 100 200 300 1 2 3
print("src", src); // (空)
// 用法 2:单元素转移,O(1)。还能把"自己"的节点挪个位置!
list<int> a{ 1, 2, 3, 4 };
auto pos = a.begin(); ++pos; // 指向 2
a.splice(pos, a, --a.end()); // 把 a 的末尾元素 4 挪到 2 之前
print("a", a); // 1 4 2 3
// 用法 3:转移一段区间 [first,last)。跨容器时线性 O(N)
list<int> e{ 1, 2, 3, 4, 5 };
list<int> f{ 90 };
auto first = e.begin(); ++first; // 指向 2
auto last = e.begin();
for (int i = 0; i < 4; ++i) ++last; // 指向 5
f.splice(f.end(), e, first, last); // 把 e 的 [2,3,4) 移到 f
print("e", e); // 1 5
print("f", f); // 90 2 3 4
return 0;
}splice 最常见的形态就是上面这三个:整表转移(a.splice(pos,b))、转移单个元素(a.splice(pos,b,it))、转移一段区间(a.splice(pos,b,first,last))。它的坑点要一字不落记住:
- 整表转移要求
b不能就是a自己(即&b != this),否则行为未定义。而单元素、区间转移允许b == a(用于在自家内部重排,如上面用法 2),但要求pos不能落在被搬移的区间[first,last)内,单元素版还要求pos不等于it或++it。 - 两个 list 的分配器必须一致(
get_allocator() != b.get_allocator()时行为未定义)。绝大多数场景用默认分配器,不冲突,但这是标准写死的硬前提。 - 区间转移在跨容器时是 O(N) 而不是 O(1)——因为要把区间尾的哨兵/末节点接回 b 的剩余链表,得先顺着
_next走一遍确认last的位置。同容器(b == a)才是 O(1)。
因为 splice 快,它常常被用来做"两个数据流按顺序合并""把待处理队列切给另一线程处理"这类操作,效率极高。
sort:list 自己的排序,是归并
list 给出了自己的 sort() 成员函数。你可能马上会问:为什么算法库那个现成的 std::sort 不能直接用?原因还是迭代器——std::sort 要求随机访问迭代器,而 list 的迭代器是双向迭代器,连"跳着访问"都做不到,自然没法跑快排那套需要反复跳跃的策略。所以 list 内部用自己的 sort 成员函数,它采用的是归并排序。
为什么链表单配归并?因为归并的核心操作是"把两条有序链表按序合并",这恰恰只需要顺序遍历 + 不停地改指针 _next 就能完成,完全不需要随机访问。而且归并排序是稳定的排序(值相等的元素相对顺序不变),这在有些场景挺重要。相比之下,std::sort 一般是基于快排/内省排序(introsort)的,快速排序不稳定,而且强烈依赖随机访问来选中枢和划分。
标准对 list::sort 的保证是"约 N log N 次比较、迭代器与引用不受影响";稳定性方面标准未像 stable_sort 那样强制,但主流实现(GCC 的 libstdc++、Clang 的 libc++、MSVC)都用自底向上的归并排序(bottom-up merge sort),避开递归、用迭代式逐倍合并有序段,因此实际结果稳定、原地搬链表、不申请额外数据存储。教材里有时画成递归的归并是"教学版",工业实现几乎都是迭代版,性能更好。
list 内建的排序性能与稳定性都很有保障:它只是重排 _next/_prev 指针的连接顺序,完全不搬动节点里的数据,所以迭代器和引用在排序前后都依然有效——这点比 std::sort(元素被换位置、但迭代器是指针仍"指向同一个位置")更贴心,因为它保证"我取过的迭代器指向的节点没搬家"。
#include <list>
#include <functional>
#include <iostream>
using namespace std;
void print(const list<int>& l, const char* name)
{
cout << name << ": ";
for (auto& x : l)
cout << x << " ";
cout << endl;
}
int main()
{
list<int> l{ 5, 2, 8, 1, 9, 3, 7 };
// list 自己的排序(归并),默认升序
l.sort();
print(l, "升序"); // 升序: 1 2 3 5 7 8 9
// 传一个比较规则(greater<int> 表示降序)
l.sort(greater<int>());
print(l, "降序"); // 降序: 9 8 7 5 3 2 1
// 自定义类型的排序:给类型重载 <,或传 lambda/仿函数
l.sort([](int a, int b) { return a % 10 < b % 10; });
// 注意:std::sort(l.begin(), l.end()) 会编译失败,
// 因为 list 迭代器不满足随机访问要求
return 0;
}顺序上要提醒一句:sort 需要"< 式"的严格弱序(strict weak ordering),默认用 < 升序。你要自定义类型排序的话,要么给类型重载 <,要么传个比较仿函数/lambda 进去。lambda 做自定义比较是 C++11 起的现代写法,比老的仿函数类更简洁。
remove:真正地删除,和 std::remove 不一样
l.remove(val) 会把链表中所有等于 val 的元素真正删掉,并把 size() 减下来。这里有一个 C++ 里特别著名的"同名陷阱"必须讲——算法库里也有个 std::remove,但它俩语义完全不同:
std::remove(first, last, val)只是把不等于 val 的元素"搬"到前面,并没有真正删除,容器的size()不变,尾部还留着"脏数据",想要真删还得配合erase(就是经典的 remove-erase 惯用法)。list::remove(val)直接就删了,返回后 size 就已经变小,而且它在内部就是遍历+erase,帮你把你我前面折腾的"遍历中安全删除"打包成了现成接口。
普通 list 用成员函数 remove 就行,别去用 std::remove,免得把自己绕晕。
#include <list>
#include <iostream>
using namespace std;
void print(const list<int>& l, const char* name)
{
cout << name << ": ";
for (auto& x : l) cout << x << " ";
cout << endl;
}
int main()
{
list<int> l{ 1, 2, 2, 3, 2, 4 };
l.remove(2); // 删除所有等于 2 的元素
print(l, "remove"); // 输出:1 3 4
// remove_if:按谓词删除,留一个条件来自定义
list<int> m{ 1, 2, 3, 4, 5, 6 };
m.remove_if([](int x) { return x % 2 == 0; }); // 删除所有偶数
print(m, "remove_if"); // 输出:1 3 5
return 0;
}这两个都好记,remove_if 的谓词传 lambda 是现在的推荐写法(把 "要不要删" 的决定权交给你)。版本提示:C++20 之前 remove/remove_if 返回 void,C++20 起为了便于统计删了多少,改成了返回 size_type(被删除的元素个数);同样的改动也发生在 unique 上,见下。
unique:去重相邻重复元素
l.unique() 会删除相邻的重复元素(重合为一个),让链表里没有两个紧挨着的相等元素。注意关键词"相邻"——{1, 1, 2, 1} 里的两个 1 不相邻,unique 不去管它们。所以想让整个链表"全局唯一",标准做法是先 sort 再 unique,把相同元素先聚到一起,再去重。
#include <list>
#include <iostream>
using namespace std;
void print(const list<int>& l, const char* name)
{
cout << name << ": ";
for (auto& x : l) cout << x << " ";
cout << endl;
}
int main()
{
// 相邻重复都被合并,只剩每组第一个
list<int> a{ 1, 1, 2, 3, 3, 3, 4 };
a.unique();
print(a, "a"); // 输出:1 2 3 4
// 注意"相邻"陷阱:这两个 2 中间隔着 3,unique 不去动它们
list<int> b{ 1, 1, 2, 3, 3, 3, 2, 5, 5 };
b.unique();
print(b, "b"); // 输出:1 2 3 2 5
// "先 sort 再 unique" 才是全局去重的正道
list<int> c{ 3, 1, 2, 3, 1, 3 };
c.sort(); // 1 1 2 3 3 3
c.unique(); // 1 2 3
print(c, "c"); // 输出:1 2 3
// unique 带谓词版本:自定义"视为重复"的规则
list<int> d{ 1, 2, 12, 23, 3, 2, 51, 1, 2, 2 };
d.unique([](int x, int y) { return (x % 10) == (y % 10); });
print(d, "d"); // 相邻且个位相同才去重
return 0;
}unique() 还存在一个重载 unique(binary_pred),你可以传"自定义的相等判断",比如"个位相同就视为重复"。它的实现就是遍历时两两比较相邻(比较次数恰好是 size-1),当 pred(*i, *(i-1)) 为真就把 *i 删掉。同样提醒:unique 在 C++20 起的返回类型也从 void 改成了 size_type(被删除个数)。
merge:合并两个有序链表
l1.merge(l2) 会把两个都已经有序的链表按序合并成一个有序链表,结果放在 l1 里,合并之后 l2 会变空。它同样以"搬节点"的方式实现(复用 splice 那种指针操作思路),时间复杂度 O(N)。前提是两边都已经排好序,否则结果是 "未定义行为",所以习惯上先各自 sort 再 merge。
#include <list>
#include <iostream>
using namespace std;
void print(const list<int>& l, const char* name)
{
cout << name << ": ";
for (auto& x : l)
cout << x << " ";
cout << endl;
}
int main()
{
list<int> a{ 1, 3, 5 };
list<int> b{ 2, 4, 6 };
// 两个 list 都必须是有序的
a.merge(b); // 有序合并到 a,b 被掏空
print(a, "a"); // a: 1 2 3 4 5 6
print(b, "b"); // b: (空)
return 0;
}merge 也是拿着两个头指针一路比、一路把小的那个节点用 splice 的方式接到结果链上,所以它不拷贝元素、只搬指针,结束时长串仍是有序的,且两个源的相对顺序当值相等时会尽量保持(属于稳定合并)。它同样有一个带比较器的重载 merge(comp) 用于自定义顺序。一个工程细节:如果 a 的排序规则和 b 的排序规则不同(比如一个升序一个降序),直接 merge 结果是未定义的,务必保证两边的"序"一致。
版本与包含的小结:上述 merge/sort/unique/remove/splice 都是 std::list 的成员函数,不用额外 #include 什么,<list> 就够。它们的签名在 C++11 后有细微演进(比如 splice 增加了右值引用重载、remove/unique 的返回类型 C++20 变化都由特性宏 __cpp_lib_list_remove_return_type 标注),但日常按本文用法写即可跨版本兼容到今天的 C++11/17/20/23。
全局 std::sort 为什么不支持 list:再挖一层
前面反复提到的"std::sort 用不了 list",值得再深挖一层,因为这背后是对迭代器分类的理解。
STL 把迭代器按能力分成几档,能力逐级增强:输入迭代器(只读单向)、输出迭代器(只写)、前向迭代器(可读写、单向)、双向迭代器(可前进可后退)、随机访问迭代器(能一次跳任意偏移)。vector 的迭代器是随机访问迭代器,list 的迭代器是双向迭代器,比随机访问低一档。
std::sort 在源码里明确要求 RandomAccessIterator(随机访问迭代器)。为什么?快速排序在划分时,要在一块区间里来回跳动地选 pivot、往中间扫描;堆排序要能找到任意位置的父节点和子节点。这些都必须"按下标 O(1) 定位"。list 的迭代器只会 ++/--,你让它"跳到中间"它就只能一步一步蠕动,复杂度爆炸,所以标准库干脆在编译期就把它卡死——你硬传 list 的迭代器给 std::sort,直接编译不过。
而这个"编译期卡死"的机制本身也值得一提:它靠的是"迭代器类别标签"。每个容器的迭代器都暴露一个 iterator_category(一个 tag 类型),std::sort 通过把标签分派(tag dispatch)到不同实现来选择是否能跑。list 的迭代器身上挂着 bidirectional_iterator_tag,而 std::sort 只接受 random_access_iterator_tag 那一档,双方在函数模板重载决议时就不匹配,于是报错。你可以用一个小的类型探测来亲眼看看差一档得到的是什么:
#include <list>
#include <vector>
#include <iterator>
#include <type_traits>
#include <iostream>
using namespace std;
int main()
{
typedef list<int>::iterator Lit;
typedef vector<int>::iterator Vit;
// iterator_traits 里藏着每个迭代器的"能力标签"
typedef iterator_traits<Lit>::iterator_category LitTag; // bidirectional_iterator_tag
typedef iterator_traits<Vit>::iterator_category VitTag; // random_access_iterator_tag
// 编译期判定,是类型就返回 1,否则 0
cout << "list 迭代器是 双向迭代器: "
<< is_same<LitTag, bidirectional_iterator_tag>::value << endl; // 1
cout << "vector 迭代器是 随机访问: "
<< is_same<VitTag, random_access_iterator_tag>::value << endl; // 1
cout << "list 迭代器是 随机访问: "
<< is_same<LitTag, random_access_iterator_tag>::value << endl; // 0
return 0;
}看到那个关键的 0 了吗?这就是 std::sort 拒绝 list 的"物理证据"。而这个"缺口",就由 list 自家的成员 sort 用归并排序来填补了。归并排序按顺序合并,正好是链表最擅长的节奏,这也回应了前文"为什么偏偏是归并"。
list 的底层实现思想:模拟 list 与反向迭代器
最后我们剥开 list 的外衣,看看它"脑内"是怎么组织的。理解实现思想不是为了让你手搓一个生产级的 list,而是为了看清前面那些特性到底是什么来的——尤其是反向迭代器那个绕人的设计,以及"为什么这么写才 O(1)、才不失效"。
一份能跑的迷你 list:节点、哨兵、迭代器、增删
下面这份精简版 MyList 把前面讲的所有核心理念浓缩成了代码:节点带 _prev/_next、哨兵头自指成环、迭代器持有一个 Node* _cur、begin()=哨兵的 _next、end()=哨兵、insert/erase 各自只做四行指针操作。它能直接编译运行,请你把它和前面"哨兵公式"对照着看。
#include <cstddef>
#include <iostream>
using namespace std;
// ---------- 节点 ----------
template <class T>
struct Node
{
T _data; // 数据
Node* _prev; // 前驱
Node* _next; // 后继
Node(const T& v = T()) : _data(v), _prev(nullptr), _next(nullptr) {}
};
// ---------- 迭代器:核心就是握着 Node* _cur ----------
template <class T>
struct ListIterator
{
typedef ListIterator<T> Self;
Node<T>* _cur; // 一个指向节点的指针
ListIterator(Node<T>* cur) : _cur(cur) {}
T& operator*() { return _cur->_data; } // 解引用 = 取当前节点数据
T* operator->() { return &_cur->_data; } // -> 访问成员
Self& operator++() { _cur = _cur->_next; return *this; } // ++ 就是沿 next 走
Self operator++(int) { Self t(*this); _cur = _cur->_next; return t; }
Self& operator--() { _cur = _cur->_prev; return *this; } // -- 沿 prev 走
bool operator==(const Self& o) const { return _cur == o._cur; }
bool operator!=(const Self& o) const { return _cur != o._cur; }
};
// ---------- 极简 list ----------
template <class T>
class MyList
{
typedef Node<T> NodeT;
typedef ListIterator<T> iterator;
NodeT* _head; // 哨兵头,不存有效数据
std::size_t _size;
public:
MyList()
{
_head = new NodeT(); // 开辟哨兵
_head->_prev = _head->_next = _head; // 空表:哨兵自指成环
_size = 0;
}
~MyList() { clear(); delete _head; }
iterator begin() { return iterator(_head->_next); } // 第一个数据节点
iterator end() { return iterator(_head); } // 哨兵
std::size_t size() const { return _size; }
bool empty() const { return _size == 0; }
void clear()
{
NodeT* cur = _head->_next;
while (cur != _head) { NodeT* nx = cur->_next; delete cur; cur = nx; }
_head->_prev = _head->_next = _head;
_size = 0;
}
void push_front(const T& val) { insert(begin(), val); }
void push_back(const T& val)
{
NodeT* n = new NodeT(val);
n->_prev = _head->_prev; // 新节点前驱 = 原尾
n->_next = _head; // 新节点后继 = 哨兵
_head->_prev->_next = n; // 原尾的后继 = 新节点
_head->_prev = n; // 哨兵前驱 = 新节点
++_size;
}
void pop_front() { erase(begin()); }
// 在 pos 之前插入,返回指向新节点的迭代器 —— 只改四行指针,O(1)
iterator insert(iterator pos, const T& val)
{
NodeT* n = new NodeT(val);
NodeT* p = pos._cur->_prev; // 定位 pos 的前驱
n->_prev = p;
n->_next = pos._cur;
p->_next = n;
pos._cur->_prev = n;
++_size;
return iterator(n);
}
// 删除 pos 指向的节点,返回它的下一个 —— 也是四行指针,O(1)
iterator erase(iterator pos)
{
NodeT* cur = pos._cur;
NodeT* ret = cur->_next;
cur->_prev->_next = cur->_next;
cur->_next->_prev = cur->_prev;
delete cur;
--_size;
return iterator(ret);
}
};
int main()
{
MyList<int> l;
l.push_back(30);
l.push_back(40);
l.push_front(10);
l.push_front(5); // 5 10 30 40
for (auto it = l.begin(); it != l.end(); ++it) cout << *it << ' ';
cout << endl; // 5 10 30 40
auto pos = l.begin(); ++pos; // 指向 10
l.insert(pos, 99); // 5 99 10 30 40
l.erase(l.erase(l.begin())); // 先删 5,再删 99(返回值即下一个)→ 10 30 40
for (auto it = l.begin(); it != l.end(); ++it) cout << *it << ' ';
cout << "size=" << l.size() << endl; // 10 30 40 size=3
return 0;
}逐条对照一下这份代码,你会发现之前所有概念都"落地可就地取材":
- 哨兵
_head只在构造时new一次,永远存在,空表时自指成环;begin()拿_head->_next、end()拿_head,所以空表begin()==end(),永远不会空指针解引用。 - 迭代器就是一个
Node* _cur的薄封装:*返回_cur->_data,++就让_cur = _cur->_next,==比较的就是两个_cur是否同址。 insert/erase正好四行指针操作,与链表长度无关 → O(1);因为不改动任何既有节点的地址,插入后所有老迭代器自然还指向原节点,毫发无损。erase返回下一节点的迭代器,这就是 C++11 起it = l.erase(it)能用的底层来源。
反向迭代器为什么"++ 是往头走"?
回想前面用 rbegin()/rend() 反向遍历时,*it 拿到的是"最后一个元素"。原理其实很朴素:反向迭代器内部封装的其实是一个正向迭代器,它只是把操作全部反转了。
课件给出的实现思路值得逐行看,尤其是那个"operator* 先退一步"的细节:
#include <iostream>
using namespace std;
// 极其简化的反向迭代器思路(示意,只看包装关系)
template<class Iterator>
class ReverseListIterator
{
public:
typedef ReverseListIterator<Iterator> Self;
ReverseListIterator(Iterator it) : _it(it) {}
// 关键:解引用时"后退一步"再取,所以 *rbegin() 能拿到最后一个元素
// *反向迭代器 = *(正向迭代器往前退一格)
auto operator*() const
{
Iterator tmp(_it); // 拿一个正向迭代器的副本
--tmp; // 退一步
return *tmp; // 再解引用
}
// 反向迭代器的 ++ = 正向迭代器的 --(往前走)
Self& operator++() { --_it; return *this; }
// 反向迭代器的 -- = 正向迭代器的 ++(往回走)
Self& operator--() { ++_it; return *this; }
bool operator!=(const Self& o) const { return _it != o._it; }
private:
Iterator _it; // 内部装的其实是"正向迭代器"
};
int main()
{
cout << "反向迭代器 = 正向迭代器 + 操作符号反转" << endl;
return 0;
}看到没:operator++ 内部写的是 --_it,operator-- 内部写的是 ++_it,解引用则先往回退一步再取值。所以从 rbegin()(对应正向 end() 哨兵)出发,你 ++ 一下其实是在正向链表里往"前一个"走,同时 * 又帮你多退一步取到数据——于是你看到的遍历顺序就是反的:40 → 30 → 20 → 10。
现在可以回答"为什么 rbegin() 解引用得到最后一个元素"了:rbegin() 内部存放的正向迭代器其实是 end()(哨兵),它 operator* 先 -- 退一格就到了最后那个真节点,于是取到最后一个元素;而每次 ++,内部的正向迭代器再往前 -- 一步,你就不断往前取,直到碰到 rend()(内部是 begin() 所在位置)为止。
这一幕也顺带解释了课件里强调的那个细节:反向迭代器的 ++ 是正向 --,反向 -- 是正向 ++。 有了"包装 + 反转符号"这把钥匙,反向迭代器就不再神秘了。其实标准库里 std::reverse_iterator 正是这么实现的——为一个双向迭代器"套一层反向壳",就得到反向能力。
你想要的那把刀,从来只看你怎么用
从"为什么会有 list"出发,我们认识了链表内存不连续的底牌;看懂了它是无惧碎片、以"任意位置 O(1) 插入删除"见长的带头双向循环链表;亲手用了构造、容量、访问、遍历这些接口;弄懂了双向迭代器的脾气和"删除即失效"那条铁律;搞清了它为什么不支持随机访问、又为何拿着 splice/sort/remove/unique/merge 这一手独家绝活;最后甚至扒开了底层,用哨兵、_cur 迭代器和反向迭代器的"包装 + 反转"把它的"脑内结构"看清了。
现在你应该能回答最核心的那个问题:当我要频繁在中间插删、而又不依赖下标取元素时,list 就是比 vector 更合适的那把刀。 记住它给你的三个关键印象——任意位置插入删除 O(1)、头尾插删都无所谓、迭代器像不死金刚一样稳(只要你别删它指的那个节点)。也别忘了它的软肋:缓存不友好、随机访问必须爬、每个节点多两个指针。
带着这套直觉去写代码,你就能在"读得快"和"改得快"之间做出真正适合当前场景的选择。以后遇到"既要又要"的需求,多想想那三件会改变你判断的事:我是读多还是改多?我要头插还是尾插?我这个迭代器需要活得久吗? 三个问题一问,答案自己就浮上来了。
还没有评论 — 第一条由你来留。