C++ 竞赛入门
list 链表
节点串成的序列:已知位置的插删是 O(1),但不支持下标。
list 链表
读完能做什么
- 能创建
list,用迭代器从头到尾遍历它,并说出为什么l[0]、l.begin() + 1都用不了。 - 能先把游标移动到某个位置,再用
insert、erase在那个位置插入、删除元素,并说出每一步之后容器里剩哪些元素。 - 能用成员函数
l.sort()给list排序,并解释为什么std::sort对list不能用。 - 能分清“已知位置的插入删除是 O(1)”和“找到那个位置仍要 O(n)”这两件事。
为什么需要它
vector 的元素挤在一整块连续内存里。在中间插入一个元素,后面的元素都要整体后移一格:
| 时刻 | 下标 0 | 下标 1 | 下标 2 | 下标 3 |
|---|---|---|---|---|
插入前([10,30,40]) |
10 | 30 | 40 | — |
| 在下标 1 前插入 20 之后 | 10 | 20 | 30 | 40 |
元素一多,这种“整体搬家”就很贵。可有些任务偏偏是“经常在中间插人、删人”:名单里插入一个新同学,或者把已经离开的人从中间删掉。
list 换了一种组织方式:每个元素单独放在一个节点里,节点之间用链接连起来。插入和删除只要改几根链接,不用搬动元素。
它是什么
一句话:list 是双向链表,元素分散在各自的节点里,节点之间靠链接串成一条序列。
直观模型是每个节点装一个元素,并且记住“前一个节点”和“后一个节点”在哪里。下图用箭头表示节点之间的链接:
graph LR
a["节点 10"] --> b["节点 30"] --> c["节点 40"]
b --> a
c --> b
节点与链接。 节点像链表的“车厢”,链接是车厢之间的挂钩。每个节点都记着前后邻居在哪里,所以从任意一个节点出发都能往两个方向走。
迭代器与位置。 list 没有连续下标,位置只能用迭代器表示。迭代器是“指向某个位置的游标”:l.begin() 停在第一个节点上,l.end() 停在最后一个节点后面的位置(尾后位置,那里没有元素)。*it 取出游标当前指向的元素。
| 位置 | l.begin() |
++it 一次 |
++it 两次 |
l.end() |
|---|---|---|---|---|
| 指向 | 10 | 30 | 40 | 尾后(没有元素) |
双向移动,不能跳格。 list 的迭代器支持 ++it(往后一格)、--it(往前一格),但不支持 it + n、it2 - it1 和 it < it2。原因就是节点不连续:从 10 出发没有“直接跳到第 3 个节点”的办法,只能沿着链接一格一格走。这层区别在容器、迭代器与区间里讲过:vector、deque、string 的迭代器是随机访问迭代器,list、set、map 的迭代器是双向迭代器。
不支持下标。 因为节点不连续,l[0] 这种写法不存在,l.begin() + 1 也编译不过。想知道“第几个元素”,只能从头数着走。
模板尖括号。 list<int> 里的 <int> 表示每个节点里放什么类型的数据,list<string> 就是存字符串的链表。
使用时要 #include <list>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件。
最小可运行示例
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 30, 40};
cout << "size = " << l.size() << '\n';
cout << "front = " << l.front() << ", back = " << l.back() << '\n';
cout << "从头到尾:";
for (list<int>::iterator it = l.begin(); it != l.end(); ++it) {
cout << *it << ' ';
}
cout << '\n';
return 0;
}
输出:
size = 3
front = 10, back = 40
从头到尾:10 30 40
逐行看:
list<int> l = {10, 30, 40};创建一条链表,依次接上三个节点。l.size()返回节点个数;l.front()、l.back()分别读第一个和最后一个节点里的值。for (list<int>::iterator it = l.begin(); it != l.end(); ++it)是遍历list的标准写法:从第一个位置出发,每轮++it往后挪一格,走到尾后位置l.end()就停。这里写++it而不是it++,两者结果一样,++it更常用。*it取出当前节点里的值,所以输出是10 30 40。
一步步看变化
沿数据 [10, 30, 40] 走三步:把游标移到 30 的位置,在它前面插入 20,再删掉 30。
| 步骤 | 操作 | 操作前 | 操作后 | size() |
|---|---|---|---|---|
| 0 | 初始 | — | [10,30,40] |
3 |
| 1 | ++it 从 10 移到 30 |
游标在 10 | 游标在 30 | 3 |
| 2 | l.insert(it, 20) |
[10,30,40] |
[10,20,30,40] |
4 |
| 3 | it = l.erase(it) 删掉 30 |
[10,20,30,40] |
[10,20,40] |
3 |
第 1、2 步:移动到 30,在它前面插入 20
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 30, 40};
list<int>::iterator it = l.begin(); // 游标先停在 10 上
++it; // 后移一格,指向 30
cout << "游标停在 " << *it << " 上\n";
l.insert(it, 20); // 在 30 之前插入 20
cout << "插入 20 后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
cout << "游标还停在 " << *it << " 上\n";
return 0;
}
输出:
游标停在 30 上
插入 20 后:10 20 30 40 size = 4
游标还停在 30 上
insert(it, 20) 的含义是“把 20 插到游标 it 指向的节点之前”。新的 20 节点被接到 10 和 30 之间,原来两个节点之间的链接断掉、换成两根新链接:
插入前:
it
↓
[10] <-> [30] <-> [40]
l.insert(it, 20) ← 在 it 指向的节点之前接上一个新节点
插入后:
it
↓
[10] <-> [20] <-> [30] <-> [40]
注意最后一行的输出:插入之后 it 仍然停在 30 上。list 的插入不搬动任何已有节点,所以指向其他节点的迭代器不会失效——这一点和 vector 不同(vector 中间插入会让后面的位置全部错位,旧迭代器不能再盲用)。
第 3 步:删除 30,并用返回的位置继续
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 30, 40};
list<int>::iterator it = l.begin();
++it; // 指向 30
l.insert(it, 20); // 在 30 之前插入 20,it 仍然指向 30
cout << "删除前:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
it = l.erase(it); // 删除 30,返回它后面一个位置
cout << "删除 30 后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
cout << "erase 返回的位置上是 " << *it << '\n';
return 0;
}
输出:
删除前:10 20 30 40 size = 4
删除 30 后:10 20 40 size = 3
erase 返回的位置上是 40
erase(it) 删掉 it 指向的那个节点,把它的前后两个节点接起来,并返回被删节点后面一个位置。所以把返回值存回 it(写成 it = l.erase(it);)之后,游标正好落在 40 上,可以接着往下走。被删节点的迭代器会失效,但其他迭代器仍然有效。
逐项扩展核心操作
两端增删:push_front / push_back / pop_front / pop_back
链表的两端也是节点,接上或摘掉一个节点都只改链接,所以四个操作都是 O(1):
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 30, 40};
l.push_front(5);
l.push_back(50);
cout << "两端加入后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
l.pop_front();
l.pop_back();
cout << "两端弹出后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
return 0;
}
输出:
两端加入后:5 10 30 40 50 size = 5
两端弹出后:10 30 40 size = 3
push_front / push_back 在头部 / 尾部加入,pop_front / pop_back 删除头部 / 尾部。pop_* 只负责删除,不返回被删掉的值;想用那个值,先 front() / back() 读出来再删。
用成员 sort() 排序
list 自己带一个排序成员函数,用法是 l.sort():
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {30, 10, 40, 20};
cout << "排序前:";
for (int x : l) cout << x << ' ';
cout << '\n';
l.sort(); // 成员函数:重接节点,元素本身不搬家
cout << "l.sort() 后:";
for (int x : l) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
排序前:30 10 40 20
l.sort() 后:10 20 30 40
l.sort() 默认按 < 从小到大排。它和 std::sort 的工作方式不一样:std::sort 靠搬动元素来重排(像数组里那样交换位置),l.sort() 靠重接链接来重排——节点本身一直待在原地,只是挂钩换了个接法。所以它不需要随机访问迭代器,也能用在 list 上。
l.sort() 还是稳定排序:值相等的元素会保持它们原来的相对顺序。稳定排序指“排序前后,相等元素的先后关系不变”,标准规定 list::sort() 满足这一点,而 std::sort() 不保证。例如三条记录 (90,A)、(80,B)、(90,C) 按成绩排序,稳定排序一定得到 (80,B)、(90,A)、(90,C):两个 90 分的人不会互换。要按多个关键字排序时,这个性质很有用。
为什么 std::sort 不能用在 list 上
std::sort 要求随机访问迭代器:它要在区间里随意跳来跳去、用两个迭代器相减算出元素个数,然后做快速排序式的划分。list 的迭代器只能 ++ / --,两个迭代器还不能相减,所以 std::sort 用不了:
// 错误示例:std::sort 需要随机访问迭代器,list 的迭代器只能一步步走,下面这段不能通过编译
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {30, 10, 40, 20};
sort(l.begin(), l.end()); // 编译错误:list 的迭代器不支持相减,sort 无法计算元素个数
return 0;
}
这段编译不过,报错信息里会直接指出 no match for 'operator-'(list 的迭代器之间没有定义减法)。正确做法就是上一节的 l.sort()。
list 的迭代器还有别的限制,下面这段同样编译不过:
// 错误示例:list 不支持下标和 it + n,下面两行都不能通过编译
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 30, 40};
cout << l[0] << '\n'; // list 没有 operator[],不能按下标取元素
cout << *(l.begin() + 1) << '\n'; // 双向迭代器不支持 + n
return 0;
}
所以 list 里想访问“第 3 个元素”,只能 ++it 两次;想找某个值,得从头扫一遍。
删除所有等于某个值的节点:remove
按值删除用成员函数 remove,它会删掉所有值等于给定值的节点:
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 20, 30, 20};
l.remove(20); // 删除所有值为 20 的节点
cout << "删除 20 后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
return 0;
}
输出:
删除 20 后:10 30 size = 2
remove 收的是值,erase 收的是位置(迭代器),别混。remove 要从头走一遍才能找到所有匹配的节点,是 O(n)。
删除后继续遍历:用 erase 的返回值
“一边遍历一边删除”是链表的常见任务。因为 erase 会返回下一个有效位置,遍历时就不要在 for 里写 ++it,而是把返回值存回游标:
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {1, 2, 3, 4, 5};
for (list<int>::iterator it = l.begin(); it != l.end(); ) {
if (*it % 2 == 0) {
it = l.erase(it); // 删掉偶数,erase 返回下一个位置
} else {
++it;
}
}
cout << "删掉所有偶数后:";
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
return 0;
}
输出:
删掉所有偶数后:1 3 5 size = 3
注意 for 的第三段是空的:往不往后走、怎么走,都由循环体自己决定。删掉节点时用 erase 返回的新位置接着走;没删的时候才手动 ++it。
什么时候选它、哪里容易错
不支持下标,也不能跳格。 l[0]、l.begin() + 1、it < it2 全都编译不过。所有定位都要靠 ++it / --it 一格一格走,所以写循环时统一用 it != l.end() 而不是 it < l.end()。
“已知位置快”不等于“找位置快”。 已经拿到指向某个节点的迭代器时,在它前面插入、把它删掉都只改几根链接,是 O(1);但找到那个位置(比如“值为 20 的节点在哪”)要从头扫,是 O(n)。l.remove(x) 这类按值的操作整体就是 O(n)。
std::sort 用不了,改用成员 sort()。 忘了这一点会直接编译失败。要按自己的规则排序,用 l.sort(cmp)(选读,比较规则见比较规则与函数对象)。
空链表的边界。 front()、back()、pop_front()、pop_back() 都要求容器非空,空链表上调用属于未定义行为,不能靠“它应该会给我 0”来猜。删之前先判 !l.empty()。
复杂度对照:
| 操作 | 复杂度 | 说明 |
|---|---|---|
push_front / push_back / pop_front / pop_back |
O(1) | 只改两端链接 |
已知位置的 insert / erase |
O(1) | 只改链接,不搬元素 |
从头找位置(含 remove) |
O(n) | 沿链接走一遍 |
l.sort() |
O(n log n) | 重接链接,不搬元素 |
选读。 每个节点除了元素还要存两根链接,空间开销比 vector 大;节点分散在内存各处,从头到尾走一遍时 CPU 缓存命中率低,所以“同样遍历一遍”,list 通常比 vector 慢。list 的优势在“已知位置频繁插入删除”这类场景,不在于遍历。
操作速查表
| 写法 | 作用 | 备注 |
|---|---|---|
list<int> l; |
创建空链表 | 尖括号里填元素类型 |
list<int> l = {10,30,40}; |
用一组初值创建 | 顺序就是链接顺序 |
l.size() / l.empty() |
节点个数 / 是否为空 | 个数是无符号整数;空为真时打印 1 |
l.front() / l.back() |
首节点 / 末节点的值 | 空链表不能调用 |
l.push_front(x) / l.push_back(x) |
头部 / 尾部加入 x |
O(1) |
l.pop_front() / l.pop_back() |
删除首 / 末节点 | 不返回被删的值 |
l.insert(it, x) |
在 it 指向的节点前插入 x |
返回新节点的位置,O(1) |
l.erase(it) |
删除 it 指向的节点 |
返回下一个位置,O(1) |
it = l.erase(it) |
删除并接着遍历 | 一边走一边删的标准写法 |
l.remove(x) |
删除所有值为 x 的节点 |
按值删,O(n) |
l.clear() |
删除所有节点 | 之后 size() 变 0 |
l.sort() |
成员排序,默认升序 | 稳定排序;std::sort 不可用 |
l.reverse() |
反转顺序 | 直接改动链表本身 |
++it / --it |
迭代器前后移动一格 | 不能 it + n,也不能相减 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
list<int> l = {1, 2, 3};
list<int>::iterator it = l.begin();
++it; // 指向 2
l.insert(it, 9); // 在 2 之前插入 9
cout << l.front() << ' ' << *it << ' ' << l.size() << '\n';
第 2 题:修改代码
下面这段片段把 [10,20,30,40] 里的 30 删掉。请把它改成删掉 40,让输出变成 10 20 30、size() 变成 3:
list<int> l = {10, 20, 30, 40};
list<int>::iterator it = l.begin();
++it; // 指向 20
++it; // 指向 30
it = l.erase(it); // 删掉 30
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
验证条件:先说出改完之后游标要移动几次、erase 返回的位置是什么,再运行核对。
参考结果与解释
第 1 题:输出是 1 2 4。++it 让游标停在 2 上,insert 把 9 接到 2 的前面,链表变成 [1,9,2,3],size() 是 4。it 仍然指向 2——插入不搬动已有节点,所以 *it 还是 2,而不是新插入的 9。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {1, 2, 3};
list<int>::iterator it = l.begin();
++it; // 指向 2
l.insert(it, 9); // 在 2 之前插入 9
cout << l.front() << ' ' << *it << ' ' << l.size() << '\n';
return 0;
}
输出:
1 2 4
第 2 题:游标要多移动一次,总共 ++it 三次才停在 40 上;erase(it) 删掉最后一个节点,返回的位置就是尾后位置 l.end()。删完链表是 [10,20,30],长度 3。注意返回的 l.end() 不能解引用。
#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> l = {10, 20, 30, 40};
list<int>::iterator it = l.begin();
++it; // 指向 20
++it; // 指向 30
++it; // 指向 40
it = l.erase(it); // 删除 40,返回尾后位置
for (int x : l) cout << x << ' ';
cout << " size = " << l.size() << '\n';
return 0;
}
输出:
10 20 30 size = 3
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及/提高- | 洛谷 P1160:队列安排 | 题解 | 一开始就有一个同学在队伍里,之后不断有人被安排到某个同学的左边或右边。用 list 保存队伍、用迭代器记住每个同学所在的位置,练习“已知位置的插入是 O(1)”:如果换成数组,每次插入都要把后面的人整体后移。 |
这道题只需要数组、循环和本页讲过的 list 操作(insert、erase、迭代器移动)。做完后请说明:为什么这里用 list 比用数组或 vector 更合适?在 list 里“找到第 k 个人”要花多少时间?