🔥 本文定位 :本文从
std::list的节点式存储模型出发,系统讲解构造、遍历、容量与元素访问、增删改查、链表专属算法、迭代器失效、反向迭代器、底层哨兵结构,并通过一个教学版MiniList串起节点、链接、迭代器和对象生命周期。💡 学习目标 :不仅要会调用
push_back、insert、erase、splice和sort,还要能够解释 为什么list不支持随机访问、已知位置插删为何是O(1)、删除时究竟哪些迭代器失效、反向迭代器为什么要"先减一再解引用",以及vector与list应如何按访问模式选择。📌 阅读说明:标准规定的是接口、语义、复杂度与失效规则,并不要求实现必须使用某一套字段名称。本文用"带哨兵节点的双向循环链表"建立心智模型,这是主流实现方式,也非常适合教学;模拟代码刻意省略分配器定制、完整异常安全传播和全部重载,不应直接替代标准库实现。

文章目录
- [一、先建立全局视角:list 到底是什么](#一、先建立全局视角:list 到底是什么)
- [二、list 的定义、构造与初始化](#二、list 的定义、构造与初始化)
- 三、迭代器与遍历方式
- 四、容量查询与元素访问
- 五、插入、删除与链表专属操作
- 六、迭代器失效:稳定地址不等于永不失效
- 七、底层结构:带哨兵节点的双向循环链表
- [八、教学版 MiniList 的核心模拟实现](#八、教学版 MiniList 的核心模拟实现)
- [九、list 迭代器是怎样封装节点指针的](#九、list 迭代器是怎样封装节点指针的)
- 十、反向迭代器为什么是一个适配器
- [十一、vector 与 list 应该怎么选](#十一、vector 与 list 应该怎么选)
- 十二、常见误区、高频面试题与练习
- 总结
一、先建立全局视角:list 到底是什么
1.1 一句话认识 list
std::list 是 C++ 标准库中的双向链式序列容器。它把每个元素放在独立节点中,节点之间通过前驱和后继链接维持顺序,因此能够从任意已知位置向前或向后移动。
可以先记住下面这句话:
list的强项不是"更快地找到某个位置",而是"已经找到位置后,以稳定链接完成插入、删除或转移"。
最小示例:
cpp
#include <iostream>
#include <list>
int main() {
std::list<int> numbers{10, 20, 30};
numbers.push_front(5);
numbers.push_back(40);
for (int value : numbers) {
std::cout << value << ' ';
}
}
输出为:
text
5 10 20 30 40
与 vector 不同,list 的元素通常不在一段连续地址中。容器负责逐个管理节点、维护链接并在销毁时释放节点,调用者通过迭代器而不是下标访问位置。

1.2 list 的核心特征
list 最值得掌握的特征有以下几条:
- 元素按逻辑顺序排列,但物理地址通常不连续;
- 提供双向迭代器,可以
++it,也可以--it; - 不提供
operator[],不能常数时间随机访问第i个元素; - 已知迭代器位置时,单元素插入与删除通常是
O(1); - 插入不会使已有元素的迭代器、指针和引用失效;
- 删除只会使指向被删除元素的迭代器、指针和引用失效;
- 提供
splice、merge、sort、unique、remove_if等适合链式结构的成员函数。
"已知位置"四个字非常重要。下面两段代码的整体复杂度不同:
cpp
// 已经持有目标位置:插入本身通常是 O(1)
auto pos = items.begin();
++pos;
items.insert(pos, 42);
cpp
// 先从头找第 n 个位置:查找 O(n),插入 O(1),整体仍是 O(n)
auto pos = items.begin();
std::advance(pos, n);
items.insert(pos, 42);
1.3 list 的优势来自节点式存储
假设当前链表逻辑上保存:
text
10 <-> 20 <-> 30 <-> 40
若要在 20 与 30 之间插入 25,链表不需要搬移 30 和 40,只需:
- 为
25创建一个新节点; - 让新节点连接
20与30; - 让
20的后继和30的前驱改为新节点。
删除节点也是类似的链接重接。正因为其他节点对象没有被搬到新地址,指向其他元素的引用和迭代器能够保持有效。
1.4 list 不是"插删一定比 vector 快"
只看大 O 容易得出错误结论。list 的节点式存储也有成本:
- 每个节点通常还要保存两个链接指针;
- 节点可能分散在堆上,顺序遍历的缓存局部性较弱;
- 每次插入新元素可能伴随一次独立内存分配;
- 若必须先线性查找位置,
O(1)插入并不能消除查找成本; - 小规模数据中,
vector搬移一段连续内存可能比追逐链表指针更快。
因此,容器选择应从访问模式、位置来源、地址稳定性和内存布局出发,而不是机械背诵复杂度表。
二、list 的定义、构造与初始化
2.1 基本定义
使用 std::list 需要包含头文件:
cpp
#include <list>
常见定义方式:
cpp
std::list<int> scores;
std::list<double> prices;
std::list<std::string> names;
list 是类模板,尖括号中的类型决定节点里保存的元素类型。std::list<int> 与 std::list<std::string> 是不同的具体类型。
2.2 常见构造方式
cpp
#include <list>
#include <string>
int main() {
std::list<int> a; // 空 list
std::list<int> b(5); // 5 个值初始化的 int
std::list<int> c(5, 7); // 5 个 7
std::list<int> d{1, 2, 3, 4}; // 初始化列表
std::list<int> e(d); // 拷贝构造
auto first = d.begin();
auto last = d.end();
++first;
std::list<int> f(first, last); // 2 3 4
std::list<std::string> words{
"C++", "STL", "list"
};
}
同样要区分圆括号和花括号:
cpp
std::list<int> x(5, 1); // 5 个 1
std::list<int> y{5, 1}; // 两个元素:5 和 1
2.3 区间构造仍然采用左闭右开范围
cpp
std::list<int> source{10, 20, 30, 40, 50};
auto first = source.begin();
std::advance(first, 1);
auto last = source.begin();
std::advance(last, 4);
std::list<int> part(first, last); // 20 30 40
范围是 [first, last):包含 first 指向的元素,不包含 last 指向的元素。由于 list 的迭代器不是随机访问迭代器,不能写 source.begin() + 4,应使用递增或 std::advance。
需要注意,std::advance(it, n) 在 list 上通常要逐节点移动,复杂度与移动距离成正比。
2.4 拷贝、移动与赋值
cpp
std::list<std::string> a{"red", "green", "blue"};
std::list<std::string> b = a; // 拷贝元素
std::list<std::string> c = std::move(a); // 移动构造
b = {"C++", "STL", "list"};
c.assign(3, "node");
拷贝后的两个容器拥有独立节点。修改 b 不会修改原容器。移动通常可以接管节点链,但标准语义还会受到分配器等条件影响;被移动对象仍然有效,可以析构或重新赋值,不应凭空假设它的精确内容。
assign 会替换整个元素序列:
cpp
std::list<int> values{1, 2, 3};
values.assign(4, 9); // 9 9 9 9
2.5 类型不完整与节点容器
现代标准允许在满足分配器要求时,用不完整类型声明某些节点容器,这是节点式结构的一项高级能力。例如类内部可以先声明一个类型,再在适当位置完成定义。不过这属于工程级主题,初学阶段更应先掌握公共接口、复杂度和对象生命周期,不要把实现技巧当成日常用法。
三、迭代器与遍历方式
3.1 begin、end、rbegin 与 rend
list 提供双向迭代器:
| 接口 | 含义 |
|---|---|
begin() |
指向第一个元素 |
end() |
指向最后一个元素之后的尾后位置 |
rbegin() |
指向反向遍历看到的第一个元素,也就是原序列最后一个元素 |
rend() |
指向反向遍历的尾后位置 |
cbegin() / cend() |
返回只读迭代器 |
crbegin() / crend() |
返回只读反向迭代器 |
end()和rend()都是边界哨兵,不代表有效元素,不能解引用。

3.2 使用迭代器正向遍历
cpp
std::list<int> values{10, 20, 30, 40};
for (auto it = values.begin(); it != values.end(); ++it) {
std::cout << *it << ' ';
}
++it 的含义不是把地址简单加上 sizeof(int),而是沿节点中的后继链接走到下一个节点。
3.3 使用范围 for
cpp
for (int value : values) {
std::cout << value << ' ';
}
若要原地修改元素,应使用引用:
cpp
for (int& value : values) {
value *= 2;
}
只读遍历大型对象时使用常量引用:
cpp
for (const std::string& word : words) {
std::cout << word << '\n';
}
3.4 反向遍历
cpp
for (auto it = values.rbegin(); it != values.rend(); ++it) {
std::cout << *it << ' ';
}
反向迭代器的 ++ 会让逻辑位置向原序列前方移动。使用者看到的是统一的"继续前进",适配器内部负责把方向反转。
3.5 list 不支持下标访问
下面的代码不能编译:
cpp
std::list<int> values{10, 20, 30};
// std::cout << values[1]; // 错误:list 没有 operator[]
如果一定要访问第 n 个元素,可以移动迭代器:
cpp
auto it = values.begin();
std::advance(it, 1);
std::cout << *it;
但这不是随机访问,移动 n 步通常是 O(n)。如果业务频繁按下标读取,vector 或 deque 通常更合适。
3.6 双向迭代器能做什么,不能做什么
list 迭代器通常支持:
*it和it->member;++it、it++;--it、it--;==和!=。
它不支持随机访问迭代器的典型运算:
cpp
// it += 5; // 不支持
// it = it + 5; // 不支持
// it[3]; // 不支持
// it1 < it2; // 通常没有位置大小关系
因此 std::sort(values.begin(), values.end()) 也不能用于 list,因为 std::sort 要求随机访问迭代器。list 提供了自己的 sort() 成员函数。
四、容量查询与元素访问
4.1 empty 与 size
cpp
std::list<int> values{1, 2, 3};
std::cout << std::boolalpha << values.empty() << '\n';
std::cout << values.size() << '\n';
常见接口:
| 接口 | 含义 | 典型复杂度 |
|---|---|---|
empty() |
是否没有元素 | O(1) |
size() |
当前元素个数 | C++11 起要求常数复杂度 |
max_size() |
理论最大元素个数 | O(1) |
判断空容器时优先写:
cpp
if (values.empty()) {
// ...
}
它比 values.size() == 0 更直接地表达意图。
4.2 front 与 back
cpp
std::list<std::string> words{"first", "middle", "last"};
std::cout << words.front() << '\n'; // first
std::cout << words.back() << '\n'; // last
words.front() = "begin";
words.back() = "end";
front() 与 back() 返回引用,因此可以读取或修改元素。对空 list 调用它们不满足前置条件,必须先判断:
cpp
if (!words.empty()) {
std::cout << words.front();
}
4.3 list 没有 capacity、reserve 与 data
vector 管理一整块可扩展连续空间,所以有 capacity()、reserve() 和 data()。list 按节点管理元素,没有"当前连续缓冲区容量"的概念,也不能提供指向全部元素连续区域的 data()。
cpp
std::list<int> values;
// values.capacity(); // 不存在
// values.reserve(100); // 不存在
// values.data(); // 不存在
这并不代表节点没有内存成本,而是它的分配模型与 vector 的容量模型不同。
五、插入、删除与链表专属操作
5.1 push_front、push_back 与 emplace
cpp
std::list<int> values{20, 30};
values.push_front(10); // 10 20 30
values.push_back(40); // 10 20 30 40
对象类型还可以使用 emplace_front、emplace_back 或 emplace:
cpp
struct Record {
int id;
std::string name;
Record(int i, std::string n)
: id(i), name(std::move(n)) {}
};
std::list<Record> records;
records.emplace_back(1, "Alice");
records.emplace_front(0, "Root");
emplace 把构造参数交给容器,在节点中直接构造元素。它可能减少临时对象,但不能笼统地说永远比 push_back 快;是否产生额外移动或拷贝取决于传参方式、类型和优化。
5.2 pop_front 与 pop_back
cpp
values.pop_front();
values.pop_back();
它们删除首元素或尾元素,但不返回被删除的值。调用前必须确保容器非空:
cpp
if (!values.empty()) {
values.pop_front();
}
5.3 insert:在 position 之前插入
cpp
std::list<int> values{10, 30, 40};
auto pos = values.begin();
++pos; // 指向 30
auto inserted = values.insert(pos, 20);
std::cout << *inserted; // 20
插入后序列为 10 20 30 40。insert(pos, value) 的语义是把新元素放在 pos 之前,并返回指向新元素的迭代器。
还可以一次插入多个元素或一个区间:
cpp
values.insert(pos, 3, 7); // 在 pos 前插入三个 7
std::vector<int> extra{100, 200};
values.insert(values.end(), extra.begin(), extra.end());
5.4 erase:删除位置并返回后继
cpp
std::list<int> values{10, 20, 30, 40};
auto it = values.begin();
++it; // 指向 20
it = values.erase(it); // 删除 20,it 现在指向 30
erase 返回被删元素之后的位置,这个设计让循环删除非常自然:
cpp
for (auto it = values.begin(); it != values.end(); ) {
if (*it % 2 == 0) {
it = values.erase(it);
} else {
++it;
}
}

5.5 clear 与 swap
cpp
std::list<int> a{1, 2, 3};
std::list<int> b{8, 9};
a.swap(b);
a.clear();
clear() 销毁所有元素,使容器变空;它需要逐个销毁节点,因此复杂度通常是 O(n)。swap 交换两个容器的内容,具体的异常保证和迭代器行为还与分配器规则有关。
5.6 splice:转移节点,而不是复制元素
splice 是 list 最有代表性的成员函数之一。它能把另一个 list 的节点整体、单个或按区间转移到当前链表的指定位置。
cpp
std::list<int> left{1, 2, 3};
std::list<int> right{10, 20, 30};
auto pos = left.begin();
++pos; // 指向 2
left.splice(pos, right);
结果:
text
left = 1 10 20 30 2 3
right = 空
关键点在于:
- 元素通常没有被逐个拷贝或移动;
- 实现主要重新连接节点链;
- 指向转移元素的迭代器和引用通常保持有效,只是这些元素现在属于目标容器;
- 调用仍必须满足标准规定的分配器与区间前置条件。
转移单个元素:
cpp
auto moved = right.begin();
left.splice(left.end(), right, moved);
转移区间:
cpp
left.splice(left.end(), right, first, last); // [first, last)
5.7 remove 与 remove_if
cpp
std::list<int> values{1, 2, 2, 3, 4, 2};
values.remove(2); // 删除所有等于 2 的元素
values.remove_if([](int x) {
return x % 2 == 0;
});
与 std::remove 算法不同,list::remove 和 list::remove_if 会真正删除节点。对于其他顺序容器,常见的是 erase-remove 惯用法;对 list,优先使用容器自己的节点删除接口更直接。
在 C++20 中,std::erase 和 std::erase_if 也为标准容器提供了统一入口。
5.8 unique 只删除相邻重复元素
cpp
std::list<int> values{1, 1, 2, 3, 3, 2};
values.unique();
结果是:
text
1 2 3 2
末尾那个 2 没被删除,因为它与前一个 3 不相等。若目标是删除所有重复值,通常先排序再 unique:
cpp
values.sort();
values.unique();
5.9 sort:链表使用自己的排序
cpp
std::list<int> values{4, 1, 3, 2};
values.sort();
自定义比较:
cpp
values.sort(std::greater<int>{});
不能写:
cpp
// std::sort(values.begin(), values.end());
原因不是标准库"忘了支持",而是 std::sort 依赖随机访问能力,而 list 只提供双向迭代器。list::sort 可以利用节点重链接完成适合链表的稳定排序。
5.10 merge 与 reverse
两个链表已经按相同规则排序时,可以合并:
cpp
std::list<int> a{1, 3, 5};
std::list<int> b{2, 4, 6};
a.merge(b);
结果为:
text
a = 1 2 3 4 5 6
b = 空
reverse() 会反转节点顺序:
cpp
a.reverse(); // 6 5 4 3 2 1
merge 的前提是两个范围都已按相同规则排序。若前提不成立,不能期待得到正确有序结果。
六、迭代器失效:稳定地址不等于永不失效
6.1 什么叫迭代器失效
迭代器内部保存了定位元素所需的状态。若对应元素已经销毁、节点已经释放,或者容器操作改变了这个状态的语义,继续解引用或递增旧迭代器就不再满足前置条件。
list 的节点通常独立分配,因此插入其他节点时不必搬移已有元素。它的迭代器稳定性比 vector 强,但不是无限期有效。

6.2 插入为什么通常不会让已有迭代器失效
cpp
std::list<int> values{10, 20, 30};
auto it = values.begin();
++it; // 指向 20
values.push_front(5);
values.push_back(40);
values.insert(it, 15);
std::cout << *it; // 仍然是 20
新节点被接入链表,原来的 20 节点没有被搬家,所以 it 仍能定位该节点。
6.3 删除只让被删节点失效
cpp
std::list<int> values{10, 20, 30};
auto first = values.begin(); // 10
auto middle = std::next(first); // 20
auto last = std::next(middle); // 30
values.erase(middle);
std::cout << *first << ' '; // 有效
std::cout << *last << '\n'; // 有效
// std::cout << *middle; // 错误:middle 指向的节点已被删除
节点 20 的存储已经释放,middle 成为悬空迭代器。是否"看起来还能打印旧值"完全不能证明它有效。
6.4 错误的循环删除
源材料中的典型错误可以简化为:
cpp
auto it = values.begin();
while (it != values.end()) {
values.erase(it);
++it; // 错误:it 已经失效
}
erase(it) 之后,it 指向的节点已经销毁,再对它执行 ++ 是未定义行为。
正确写法一:接收返回值。
cpp
auto it = values.begin();
while (it != values.end()) {
it = values.erase(it);
}
正确写法二:先递增旧迭代器,再删除它原先指向的节点。
cpp
auto it = values.begin();
while (it != values.end()) {
values.erase(it++);
}
第二种写法利用后置递增"返回旧值、迭代器自身先移动"的语义,但第一种更直观,也更容易扩展到条件删除。
6.5 常见操作的失效规则
| 操作 | 对已有迭代器、引用和指针的典型影响 |
|---|---|
push_front / push_back |
已有元素通常保持有效 |
insert / emplace |
已有元素通常保持有效 |
erase(pos) |
仅被删除元素失效 |
pop_front / pop_back |
指向被删首/尾元素的位置失效 |
clear |
所有元素位置失效 |
remove / remove_if |
指向被删除元素的位置失效 |
unique |
指向被删除重复节点的位置失效 |
sort / reverse |
元素仍存在,迭代器和引用通常保持有效 |
splice |
转移元素的位置通常保持有效,但归属目标容器改变 |
6.6 end 迭代器也不要脱离操作规则猜测
end() 通常对应哨兵节点,但标准使用者应依赖接口语义,而不是把某个实现的哨兵地址当成永远不变。进行交换、移动赋值或涉及分配器的容器级操作时,应查看相应接口的精确失效保证,不要用"反正是链表"代替规范。
七、底层结构:带哨兵节点的双向循环链表
7.1 为什么常见实现需要哨兵节点
若没有哨兵节点,空链表、只有一个节点、删除首节点、删除尾节点都可能需要不同分支。引入一个不保存用户值的哨兵节点后,可以让链表形成闭环:
text
head <-> node1 <-> node2 <-> node3 <-> head
空链表则是:
text
head.next == &head
head.prev == &head
这样 begin() 可以返回 head.next,end() 可以表示 &head。插入和删除都能统一为"修改相邻四条链接"。

7.2 为什么哨兵节点不应该强迫 T 默认构造
如果哨兵也使用完整的 Node<T>,它可能不得不构造一个没有业务意义的 T。这会错误地要求元素类型必须默认可构造,并浪费一次对象构造。
更合理的教学结构是把链接与数据分开:
cpp
struct NodeBase {
NodeBase* prev;
NodeBase* next;
};
template<class T>
struct Node : NodeBase {
T value;
};
哨兵只使用 NodeBase,普通元素节点使用 Node<T>。迭代器在解引用时,只有确认当前不是哨兵,才把基础节点指针转换为元素节点指针并访问 value。
7.3 插入前的四步链接更新
要把 new_node 插入 pos 之前,可设:
cpp
NodeBase* before = pos->prev;
然后:
cpp
new_node->prev = before;
new_node->next = pos;
before->next = new_node;
pos->prev = new_node;
链接更新顺序必须保证所需的旧邻居地址没有过早丢失。工程实现还必须考虑构造元素时抛异常:应先成功申请并构造新节点,再把它接入现有链表。
7.4 删除节点的链接更新
假设删除 current:
cpp
NodeBase* before = current->prev;
NodeBase* after = current->next;
before->next = after;
after->prev = before;
完成脱链后,再销毁 current 中的元素并释放节点。调用者需要的返回值通常就是 after 对应的迭代器。
7.5 为什么保存 size 是一种空间换时间
若容器不保存元素个数,size() 需要从头遍历到尾;若保存 _size,每次插入和删除都同步增减计数,就能常数时间返回大小。
代价是所有改变节点数量的路径都必须正确维护 _size。splice 尤其容易暴露这个权衡:整链转移容易更新计数,跨容器转移任意区间时,如果没有现成的区间长度,可能需要计算转移节点数。
八、教学版 MiniList 的核心模拟实现
8.1 实现目标与边界
下面实现一个用于理解原理的 MiniList<T>,包含:
- 独立的基础节点与数据节点;
- 带哨兵节点的双向循环链表;
- 正向迭代器与常量迭代器;
begin、end、front、back;emplace、insert、erase;push_front、push_back、pop_front、pop_back;- 拷贝构造、移动构造、移动赋值、
clear; - 基于
std::reverse_iterator的反向迭代。
它没有完整实现标准 std::list 的分配器传播、所有构造重载、跨常量迭代器比较、splice、merge、调试检查等能力。
8.2 完整教学代码
cpp
#include <cstddef>
#include <initializer_list>
#include <iterator>
#include <memory>
#include <stdexcept>
#include <utility>
namespace demo {
struct ListNodeBase {
ListNodeBase* prev = nullptr;
ListNodeBase* next = nullptr;
};
template<class T>
struct ListNode : ListNodeBase {
template<class... Args>
explicit ListNode(Args&&... args)
: value(std::forward<Args>(args)...) {}
T value;
};
template<class T, class Ref, class Ptr>
class ListIterator {
using Base = ListNodeBase;
using Node = ListNode<T>;
public:
using iterator_category = std::bidirectional_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = Ptr;
using reference = Ref;
ListIterator() = default;
reference operator*() const {
return static_cast<Node*>(_node)->value;
}
pointer operator->() const {
return std::addressof(operator*());
}
ListIterator& operator++() {
_node = _node->next;
return *this;
}
ListIterator operator++(int) {
ListIterator old(*this);
++(*this);
return old;
}
ListIterator& operator--() {
_node = _node->prev;
return *this;
}
ListIterator operator--(int) {
ListIterator old(*this);
--(*this);
return old;
}
friend bool operator==(const ListIterator& lhs,
const ListIterator& rhs) {
return lhs._node == rhs._node;
}
friend bool operator!=(const ListIterator& lhs,
const ListIterator& rhs) {
return !(lhs == rhs);
}
private:
explicit ListIterator(Base* node) : _node(node) {}
Base* _node = nullptr;
template<class>
friend class MiniList;
};
template<class T>
class MiniList {
using Base = ListNodeBase;
using Node = ListNode<T>;
public:
using value_type = T;
using size_type = std::size_t;
using iterator = ListIterator<T, T&, T*>;
using const_iterator = ListIterator<T, const T&, const T*>;
using reverse_iterator = std::reverse_iterator<iterator>;
using const_reverse_iterator = std::reverse_iterator<const_iterator>;
MiniList() noexcept {
reset_empty();
}
MiniList(std::initializer_list<T> init) : MiniList() {
try {
for (const T& value : init) {
push_back(value);
}
} catch (...) {
clear();
throw;
}
}
MiniList(const MiniList& other) : MiniList() {
try {
for (const T& value : other) {
push_back(value);
}
} catch (...) {
clear();
throw;
}
}
MiniList(MiniList&& other) noexcept : MiniList() {
move_from(other);
}
MiniList& operator=(MiniList&& other) noexcept {
if (this != &other) {
clear();
move_from(other);
}
return *this;
}
MiniList& operator=(const MiniList& other) {
if (this != &other) {
MiniList temp(other);
clear();
move_from(temp);
}
return *this;
}
~MiniList() {
clear();
}
iterator begin() noexcept {
return iterator(_head.next);
}
const_iterator begin() const noexcept {
return const_iterator(_head.next);
}
const_iterator cbegin() const noexcept {
return begin();
}
iterator end() noexcept {
return iterator(&_head);
}
const_iterator end() const noexcept {
return const_iterator(const_cast<Base*>(&_head));
}
const_iterator cend() const noexcept {
return end();
}
reverse_iterator rbegin() noexcept {
return reverse_iterator(end());
}
reverse_iterator rend() noexcept {
return reverse_iterator(begin());
}
bool empty() const noexcept {
return _size == 0;
}
size_type size() const noexcept {
return _size;
}
T& front() {
require_not_empty();
return *begin();
}
const T& front() const {
require_not_empty();
return *begin();
}
T& back() {
require_not_empty();
auto it = end();
--it;
return *it;
}
const T& back() const {
require_not_empty();
auto it = end();
--it;
return *it;
}
template<class... Args>
iterator emplace(iterator pos, Args&&... args) {
Node* node = new Node(std::forward<Args>(args)...);
link_before(pos._node, node);
++_size;
return iterator(node);
}
iterator insert(iterator pos, const T& value) {
return emplace(pos, value);
}
iterator insert(iterator pos, T&& value) {
return emplace(pos, std::move(value));
}
void push_front(const T& value) {
insert(begin(), value);
}
void push_front(T&& value) {
insert(begin(), std::move(value));
}
void push_back(const T& value) {
insert(end(), value);
}
void push_back(T&& value) {
insert(end(), std::move(value));
}
iterator erase(iterator pos) {
if (pos == end()) {
throw std::out_of_range("cannot erase end()");
}
Base* current = pos._node;
Base* after = current->next;
unlink(current);
delete static_cast<Node*>(current);
--_size;
return iterator(after);
}
void pop_front() {
require_not_empty();
erase(begin());
}
void pop_back() {
require_not_empty();
auto it = end();
erase(--it);
}
void clear() noexcept {
Base* current = _head.next;
while (current != &_head) {
Base* after = current->next;
delete static_cast<Node*>(current);
current = after;
}
reset_empty();
}
private:
static void link_before(Base* pos, Base* node) noexcept {
Base* before = pos->prev;
node->prev = before;
node->next = pos;
before->next = node;
pos->prev = node;
}
static void unlink(Base* node) noexcept {
node->prev->next = node->next;
node->next->prev = node->prev;
}
void reset_empty() noexcept {
_head.prev = &_head;
_head.next = &_head;
_size = 0;
}
void move_from(MiniList& other) noexcept {
if (other.empty()) {
return;
}
_head.next = other._head.next;
_head.prev = other._head.prev;
_head.next->prev = &_head;
_head.prev->next = &_head;
_size = other._size;
other.reset_empty();
}
void require_not_empty() const {
if (empty()) {
throw std::out_of_range("MiniList is empty");
}
}
private:
Base _head;
size_type _size = 0;
};
} // namespace demo
8.3 为什么先构造节点,再修改链接
cpp
Node* node = new Node(std::forward<Args>(args)...);
link_before(pos._node, node);
new Node(...) 可能因为内存不足或 T 的构造函数抛出异常而失败。在它成功之前,原链表没有被修改;一旦成功,link_before 只执行不抛异常的指针赋值。
这让单节点插入容易获得强异常保证:失败时容器保持原状。
8.4 为什么 erase 要先保存后继
cpp
Base* after = current->next;
unlink(current);
delete static_cast<Node*>(current);
return iterator(after);
一旦 current 被删除,就不能再访问 current->next。因此必须在释放节点前保存后继地址。这也是 erase 能返回下一个有效位置的实现基础。
8.5 为什么 clear 不直接复用 erase
教学实现可以循环调用 erase(begin()),但 clear 内部已经掌握所有节点,直接保存 next、删除当前节点通常更简单,也避免重复构造迭代器和执行边界检查。
cpp
while (current != &_head) {
Base* after = current->next;
delete static_cast<Node*>(current);
current = after;
}
最后恢复空环,让对象仍处于可继续使用的有效状态。
8.6 拷贝赋值为什么先构造临时对象
cpp
MiniList temp(other);
clear();
move_from(temp);
如果拷贝 other 期间抛异常,当前对象尚未改变。临时对象构造成功后,再清理旧内容并接管临时节点。完整工业实现还会精细处理分配器传播、swap 条件和 noexcept,教学版只展示"先准备新资源,再提交修改"的基本思路。
8.7 这个教学版还缺少什么
标准库级实现还需要处理:
allocator_type与allocator_traits;const_iterator从iterator的安全转换及混合比较;- 完整的范围构造、赋值和插入重载;
splice、merge、sort、unique、remove_if;- C++20 ranges、比较运算与条件
noexcept; - 调试迭代器、边界诊断和 ABI 约束;
- 异常时的精确资源回滚;
- 对不完整类型和 fancy pointer 的支持。
模拟实现的目标不是"重写标准库",而是让接口语义能够落到节点与链接操作上。
九、list 迭代器是怎样封装节点指针的
9.1 为什么不能直接用 T*
vector 的元素连续排列,主流实现的普通迭代器可以接近原生指针语义。list 的两个元素节点可能相距很远,对 T* 加一无法找到下一个节点。
因此 list 迭代器通常要保存节点位置:
cpp
ListNodeBase* _node;
然后把运算翻译为节点操作:
cpp
operator* -> 访问当前节点的 value
operator++ -> _node = _node->next
operator-- -> _node = _node->prev
operator== -> 比较节点地址
9.2 operator* 与 operator->
cpp
reference operator*() const {
return static_cast<Node*>(_node)->value;
}
pointer operator->() const {
return std::addressof(operator*());
}
operator* 返回引用,避免复制元素,并允许非常量迭代器修改元素。operator-> 返回元素地址,使 it->member 能够工作。
如果 _node 指向哨兵,也就是 end(),把它当作 Node<T> 解引用是不合法的。因此 *end() 绝不能使用。
9.3 前置与后置递增
cpp
ListIterator& operator++() {
_node = _node->next;
return *this;
}
ListIterator operator++(int) {
ListIterator old(*this);
++(*this);
return old;
}
前置递增返回更新后的自身引用;后置递增要保留旧值,因此通常需要一个迭代器副本。
在不需要旧值时优先写:
cpp
++it;
对轻量级迭代器,编译器通常能优化很多差异,但前置形式更直接地表达"不需要旧值"。
9.4 const_iterator 的 const 指向元素,而不是迭代器变量
下面两种 const 不同:
cpp
std::list<int>::iterator it;
const std::list<int>::iterator fixed = it;
fixed 自身不能再递增,但仍是可写元素迭代器。真正用于只读元素的是:
cpp
std::list<int>::const_iterator read_only;
这和指针中的 T* const 与 const T* 区别类似。
9.5 typename 为什么经常出现在迭代器模板中
若模板代码访问依赖模板参数的嵌套类型,编译器在第一阶段解析时不能确定它是类型还是静态成员,需要 typename 明确说明:
cpp
template<class Iterator>
struct TraitsUser {
using reference = typename Iterator::reference;
using pointer = typename Iterator::pointer;
};
源材料中的反向迭代器实现使用了相同规则。现代泛型代码也常借助 std::iterator_traits<Iterator> 统一提取迭代器关联类型。
十、反向迭代器为什么是一个适配器
10.1 反向迭代器内部保存正向迭代器
标准库的 std::reverse_iterator<Iterator> 是通用适配器,它内部保存一个基础迭代器,通常通过 base() 取出。
cpp
std::list<int> values{10, 20, 30};
auto rit = values.rbegin();
std::cout << *rit; // 30
auto base = rit.base();
// base 通常等于 values.end()
10.2 为什么解引用要先减一
rbegin() 的基础迭代器通常是 end()。但 end() 是尾后位置,不能解引用。反向迭代器的 operator* 会复制基础迭代器、先 --,再解引用:
cpp
reference operator*() const {
Iterator temp = current;
--temp;
return *temp;
}
因此,反向迭代器指向的元素与其 base() 之间总有一个"错位":
text
正向边界: [ 10 ][ 20 ][ 30 ] end
反向 rbegin.base() -----------> end
反向 *rbegin() ----------------> 30
这个设计让反向范围仍然满足统一的左闭右开语义。
10.3 反向 ++ 为什么调用基础 --
cpp
ReverseIterator& operator++() {
--current;
return *this;
}
从反向视角看,++rit 表示"前进到下一个反向元素";映射回原序列,就是让基础迭代器向前移动。
同理:
text
反向 ++ -> 基础 --
反向 -- -> 基础 ++
10.4 不要手写一个只服务 list 的反向迭代器
只要正向迭代器满足双向迭代要求,std::reverse_iterator 就可以复用。教学实现应优先展示通用适配思想:
cpp
using reverse_iterator = std::reverse_iterator<iterator>;
这比为每个容器复制一套反向逻辑更符合 STL 的泛型设计。
十一、vector 与 list 应该怎么选
11.1 底层模型决定能力边界

| 对比项 | vector |
list |
|---|---|---|
| 典型底层模型 | 动态连续数组 | 双向节点链 |
| 随机访问 | 支持,O(1) |
不支持,定位第 n 个通常 O(n) |
| 尾部追加 | 摊还 O(1) |
O(1) |
| 已知位置单元素插删 | 通常需要搬移,O(n) |
重接链接,O(1) |
| 查找插入位置 | 视算法而定 | 通常需要线性遍历 |
| 缓存局部性 | 通常较好 | 通常较弱 |
| 每元素额外开销 | 通常较低 | 通常至少需要节点链接与分配元数据 |
| 地址稳定性 | 扩容或搬移可能失效 | 除被删除元素外通常稳定 |
| 迭代器类别 | 随机访问/连续迭代能力 | 双向迭代能力 |
| 排序 | std::sort |
list::sort |
11.2 什么时候优先选择 vector
以下情况通常先考虑 vector:
- 高频顺序遍历;
- 需要下标或随机访问;
- 数据量大且关注缓存效率;
- 主要在尾部追加;
- 需要与连续内存接口协作;
- 元素较小,搬移成本可以接受。
即使中间偶尔插删,vector 也可能仍然更快,因为连续内存复制、预取和更少的分配常数具有现实优势。
11.3 什么时候考虑 list
以下情况可以认真评估 list:
- 已长期保存目标元素的迭代器或引用,并要求其他位置插入后仍稳定;
- 已知节点位置,需要频繁插入、删除;
- 需要用
splice在链表之间转移节点; - 元素搬移代价很高,而节点转移能避免搬移;
- 算法天然按双向链顺序运行,不依赖随机访问;
- 内存开销和缓存局部性不是主要瓶颈。
11.4 "list 插入 O(1)"为什么常被误用
考虑"在第 100000 个元素前插入":
cpp
auto pos = items.begin();
std::advance(pos, 100000); // O(n)
items.insert(pos, value); // O(1)
整体仍是 O(n)。只有当 pos 已经由业务状态、索引结构或前一步遍历保存下来,O(1) 插入的优势才真正成立。
11.5 deque、forward_list 也可能更合适
容器选择不只有二选一:
- 需要首尾高效插删,同时还想保留随机访问:考虑
deque; - 只需要单向遍历并极度关注节点链接开销:考虑
forward_list; - 需要按键查找:考虑
map、unordered_map等关联容器; - 需要稳定地址但也想改善局部性:可以评估专用池、间接索引或稳定容器设计。
先描述操作模式,再选数据结构,比背诵"某容器更高级"可靠得多。
十二、常见误区、高频面试题与练习
12.1 常见误区
误区一:list 支持 operator[]
错误。节点不连续,list 只提供双向迭代,不提供常数时间下标访问。
误区二:list 任意位置插入的整个过程都是 O(1)
只有已知位置后的单元素链接操作是常数复杂度。若先查找位置,总成本通常仍为 O(n)。
误区三:list 插入删除永远比 vector 快
错误。分配成本、指针追逐、缓存未命中、元素大小和查找位置成本都会影响实测结果。
误区四:list 迭代器永远不会失效
错误。删除节点、clear 或销毁容器都会使相应位置失效。
误区五:erase 后可以继续 ++ 原迭代器
错误。应接收 erase 返回值,或在删除前按正确顺序保存后继。
误区六:unique 会删除所有重复值
错误。unique 只处理相邻等价元素。需要全局去重时通常先排序。
误区七:可以对 list 使用 std::sort
错误。std::sort 要求随机访问迭代器,应使用 list::sort()。
误区八:splice 会逐个复制元素
它的核心价值正是节点转移。满足前置条件时,通常通过重接链接完成,而不是逐个复制元素。
误区九:end() 指向最后一个元素
错误。end() 是尾后位置,最后一个元素需要通过 --end() 获得,前提是容器非空。
误区十:双向链表一定不需要哨兵
实现上可以不用,但哨兵能统一空表与边界操作,降低分支复杂度。它是常见实现策略,不是用户接口的一部分。
误区十一:节点容器没有额外内存成本
错误。节点通常包含前后链接、对齐填充以及分配器元数据,内存密度往往低于连续容器。
误区十二:反向迭代器的 base() 就指向当前元素
通常不是。反向迭代器解引用的元素位于 base() 的前一个位置,这是保持左闭右开边界的关键。
12.2 高频面试题
问题 1:list 的底层结构是什么?
主流实现通常使用带哨兵节点的双向循环链表。标准重点保证双向迭代、插删复杂度和失效语义,不要求成员字段必须长成某种固定形式。
问题 2:为什么 list 不支持随机访问?
节点在内存中不连续,找到第 n 个元素需要沿链接逐步移动,无法像连续数组那样通过首地址加偏移直接定位。
问题 3:list 插入为什么是 O(1)?
给定目标迭代器后,单节点插入只需创建节点并修改固定数量的前驱、后继链接;但创建节点和定位目标位置的成本需要单独考虑。
问题 4:list 的迭代器属于什么类别?
双向迭代器,支持前进、后退、解引用和相等比较,但不支持随机跳转与大小关系比较。
问题 5:list 插入和删除如何影响迭代器?
插入通常不影响已有元素;删除只使指向被删元素的位置失效,其他元素通常保持有效。
问题 6:为什么 erase 返回迭代器?
被删位置已经失效,返回后继位置能让调用者安全地继续遍历,尤其适合循环条件删除。
问题 7:为什么 list 要有自己的 sort?
通用 std::sort 依赖随机访问,而 list 只有双向迭代器。成员 sort 可以利用节点链接实现稳定排序。
问题 8:splice 的价值是什么?
它能在链表之间转移节点,通常避免逐个复制或移动元素,同时保持转移元素的引用和迭代器有效。
问题 9:哨兵节点解决了什么问题?
它把空表、首部、尾部统一进同一个循环结构,使 begin、end、插入和删除可以用更少的特殊分支实现。
问题 10:为什么哨兵只保存链接,不保存 T?
这样不必为无业务意义的哨兵默认构造一个 T,也不会额外要求 T 必须默认可构造。
问题 11:vector 和 list 如何选择?
高频遍历、随机访问和紧凑存储优先考虑 vector;已知位置频繁插删、地址稳定或节点转移需求再评估 list,最终用基准测试验证。
问题 12:反向迭代器为什么保存尾后位置?
为了让反向范围仍采用统一边界。rbegin().base() 通常是 end(),解引用时先把基础迭代器减一。
12.3 排查清单
写 list 代码时,可以依次检查:
- 是否错误使用了下标、
+ n或大小比较; - 插入前是否已经持有目标位置,还是还需要线性查找;
- 删除后是否继续使用了旧迭代器;
- 循环删除是否接收
erase返回值; - 是否对空容器调用
front、back、pop_front或pop_back; - 是否把
unique误当成全局去重; - 是否把
std::sort用在双向迭代器上; merge前两个链表是否按同一规则有序;splice的源、目标和区间是否满足前置条件;- 是否真的需要
list的地址稳定或节点转移优势; - 是否测量过
vector在真实负载下的表现; - 是否依赖了某个实现的节点字段或哨兵地址。
12.4 建议练习
- 使用
list<int>完成正向、反向、范围 for 三种遍历。 - 删除链表中所有偶数,分别使用
erase循环与remove_if。 - 演示插入后旧迭代器仍有效、删除后被删迭代器失效。
- 用
splice把一个链表中的所有负数转移到另一个链表。 - 先
sort再unique,完成全局去重。 - 为
MiniList增加emplace_front与emplace_back。 - 为
MiniList实现范围erase(first, last)。 - 为迭代器增加从
iterator到const_iterator的转换。 - 实现只转移单节点的
splice,并画出链接变化。 - 对相同数据规模的
vector与list做顺序遍历基准测试。 - 对比"先查找再插入"和"持有迭代器后插入"的总耗时。
- 使用 AddressSanitizer 检查删除后继续使用迭代器的问题。
12.5 参考资料
- 本文内容结构来源:
10.list.pdf; - C++ 标准库实现随编译器与标准库版本而异,应以所用实现文档为准;
- 接口细节、复杂度和失效规则应结合当前 C++ 标准资料与标准库文档核对;
- 性能结论应在真实元素类型、数据规模、分配器与访问模式下测量。
总结
学习 std::list,可以沿四条主线串起来:
第一条:接口主线
text
构造
-> begin / end / rbegin / rend
-> empty / size
-> front / back
-> insert / erase / push / pop
-> splice / sort / merge / unique / remove
第二条:结构主线
text
哨兵节点
-> 双向循环链接
-> 节点独立存储
-> 已知位置常数时间重接链接
-> 其他节点地址保持稳定
第三条:迭代器主线
text
节点指针封装
-> ++ 沿 next
-> -- 沿 prev
-> * 访问 value
-> reverse_iterator 反转移动方向
第四条:工程判断主线
text
先问访问模式
-> 是否需要随机访问
-> 是否已经持有插删位置
-> 是否要求地址稳定
-> 是否需要节点转移
-> 再用基准测试验证容器选择
list 真正重要的并不是"链表插入是 O(1)"这一句结论,而是理解这句话的完整前提:目标位置已经获得、节点分配成本可以接受、业务不依赖随机访问,并且稳定迭代器或节点转移确实能带来价值。
如果本文对你有帮助,欢迎点赞、收藏。掌握
list的标志,不是能背出多少个成员函数,而是在看到一次插入、删除或转移时,能够准确回答:当前位置从哪里来、哪些链接会改变、哪些对象仍在原地址、哪些迭代器已经失效,以及为什么这个访问模式值得选择节点容器。