17.C++入门:list 容器与迭代器适配
最近在带几个朋友入门 C++,发现不少人一接触 STL 就从 vector 开始,用到 list 的时候总是有一种“我知道它能用,但不知道为什么这么设计”的别扭感。尤其是反向迭代器,很多人直接用 rbegin() / rend() 遍历一遍就完了,完全没想过它底层是怎么做到“反向”的——也就更谈不上理解正向迭代器如何适配成反向迭代器。这篇东西,我把 list 从容器结构到迭代器适配再到和 vector 的对比,按自己梳理过的思路给你完整过一遍,适合刚把语法基础过完、正在啃 STL 容器的同学。
先说结论:list 是一个双向链表容器,插入删除快、随机访问慢;反向迭代器不是一个新的迭代器类型,而是对正向迭代器做了一层适配包装。这两点你真正理解了,后面看任何容器的迭代器设计都会顺畅很多。
1. list 容器的基础:先搞懂底层结构再谈用
1.1 双向链表到底长什么样
list 在标准里是双向链表(doubly linked list),每个节点除了存数据,还存两个指针——一个指向前一个节点(prev),一个指向后一个节点(next)。你从任意一个节点出发,都能往前往后走。这一点和 vector 那种连续内存的数组结构完全不同。
我经常跟刚学的朋友打个比方:vector 像是一排连在一起的储物柜,你知道第一个柜子在哪儿,后面的柜子跟着就能找到,因为你只需要按顺序加偏移量;list 则像一列手拉手的人,每个人只知道前面是谁、后面是谁,你要是想找第 10 个人,就只能从第 1 个人开始一个一个数过去。
这个比喻不是让你只是想象一下,它背后直接决定了接口的性能特性。list 没有 operator[],你不能像 vector 那样通过下标访问第 n 个元素——因为下标 n 对应的节点在内存里并没有固定的偏移关系,你只能从头节点开始遍历。而 vector 的 operator[] 是 O(1) 的,因为它的底层就是一段连续内存,cpu 计算一下地址偏移就能直接命中。
在实际工程里,这个差别会带来非常明显的性能差异。我自己以前写过一个小型日志系统,一开始用 vector 存日志条目,后面需要频繁在头部插入新日志,结果发现头部插入是 O(n) 的——每次都要把后面所有元素往右挪。后来改成 list,头部插入变成 O(1),代码几乎没动,性能瓶颈直接消失。
1.2 list 的核心操作与接口速览
list 的接口和 vector 有很多相似之处,比如 push_back、pop_back、size、empty、clear 这些,但真正体现双向链表特性的是下面这些操作:
- push_front / pop_front:在头部插入或删除,O(1) 时间,vector 没有这两个操作(或者说有但不建议用,因为效率低)
- insert(pos, val):在指定位置插入,O(1) 时间——但前提是你已经有那个位置的迭代器了,找这个迭代器本身可能是 O(n)
- erase(pos):删除指定位置的节点,O(1) 时间,这一点非常关键
- remove(val):删除所有等于 val 的节点
- unique():删除相邻重复的元素
- sort():list 自己的排序,不能用 std::sort,因为 std::sort 需要随机访问迭代器
这里有个新手容易混淆的点:list 的 insert 和 erase 接收的是迭代器,不是下标。比如lst.insert(lst.begin() + 3, 100)这种写法在 vector 里可以,但 list 里不行——因为 list 的迭代器不支持 + 运算,它只支持 ++ 和 --。这也是后面讲迭代器适配时绕不开的一个点。
1.3 为什么 list 的 insert/erase 是 O(1)
这点值得展开说,因为它是理解 list 和 vector 差异的钥匙。
list 的 insert 只需要做指针的断开和重接。比如要在节点 B 和 C 之间插入一个新节点 X,你只需要四步操作:
- X 的 prev 指向 B
- X 的 next 指向 C
- B 的 next 指向 X
- C 的 prev 指向 X
这就完成了,不需要移动任何其他节点,不需要重新分配内存。删除也是类似的,把前后节点的指针绕过目标节点就可以了。
vector 的 insert 为什么是 O(n)?因为它必须保证元素在内存里是连续的。你往中间插一个元素,从插入位置到末尾的所有元素都得往后挪一个位置。如果容量不够,还要整块内存搬到更大的地方,这个搬家的开销更是重量级。
所以如果你在做一个频繁中间插入删除的程序,list 会比 vector 合适得多。反过来,如果你只是不断往末尾 push_back,然后随机访问下标读取,vector 的连续内存优势就是碾压级的。
2. 反向迭代器:rbegin/rend 背后的完整原理
2.1 反向迭代器是什么
先明确一个概念:反向迭代器不是 list 独有的,vector 也有 rbegin() 和 rend(),所有标准容器都有。它的作用是让你从容器尾部往头部遍历,但不需要你手动去写it = lst.end(); it--;这种别扭的循环。
典型的用法:
std::list<int> lst = {1, 2, 3, 4, 5}; for (auto rit = lst.rbegin(); rit != lst.rend(); ++rit) { std::cout << *rit << " "; } // 输出:5 4 3 2 1很多初学者看到这段代码会有两个疑问:
第一,rbegin() 指向的到底是哪个元素?
第二,rit++ 明明看起来是“往后走”,为什么实际是从尾部往头部走?
第一个问题的答案是:rbegin() 返回的迭代器指向最后一个元素(即 end() 的前一个)。第二个问题才是关键——关键在于反向迭代器的 ++ 操作符重载成了“调用正向迭代器的 --”。
也就是说,反向迭代器本身不是一个全新的迭代器类型,它内部包装了一个正向迭代器,然后把你习惯的操作反转了过来。这里就是标题里说的“通过正向迭代器适配”的雏形。
2.2 反向迭代器的“反向”逻辑
要彻底搞懂反向迭代器,得理解它内部存的是哪个位置的正向迭代器。
标准库的实现里,反向迭代器内部保存的 base() 指向的是当前正向位置的“下一个”位置。说人话就是:假设容器里有 5 个元素,位置分别是 [0, 1, 2, 3, 4](这只是逻辑下标,list 里没有这个概念,但便于说明)。rbegin() 内部保存的 base 其实指向的是 end()——也就是最后一个元素的下一个位置。当你对反向迭代器执行 ++ 操作时,它做的事情是让内部的正向迭代器往后退一个位置,即从 end() 变成指向最后一个元素的位置。
所以反向迭代器每次解引用,做的事其实是:先让内部的正向迭代器减一,再解引用。
用代码看更清楚:
// 简化版:想象一下标准库的实现逻辑 template <class Iterator> class reverse_iterator { Iterator current; // 内部的正向迭代器 public: // 解引用时,先往前退一步,再取那个位置的值 typename Iterator::reference operator*() const { Iterator tmp = current; --tmp; return *tmp; } // 反向迭代器的++,实际上是内部正向迭代器的-- reverse_iterator& operator++() { --current; return *this; } reverse_iterator& operator--() { ++current; return *this; } };为什么设计成这样,而不是让 rbegin() 内部直接存最后一个元素?因为标准库的迭代器有一个约定:reverse_iterator和base()之间要满足一个关系——正向范围 [first, last) 对应反向范围 [rbegin, rend),并且 size 一致。如果 rbegin() 直接存最后一个元素的迭代器,那么 rend() 就没办法表示“第一个元素之前的位置”——而单向链表根本没有“前一个位置”的合法迭代器。
所以标准库选择了一个巧妙的偏移:反向迭代器存的是“逻辑位置的下一个”,通过解引用时减一来校正。这个设计是理解后面所有适配代码的基石。
2.3 一个被忽略的细节:base() 的偏移
正因为上面说的这张机制,rit.base()返回的正向迭代器并不是rit当前指向的那个元素,而是它的后一个位置。
std::list<int> lst = {10, 20, 30, 40}; auto rit = lst.rbegin(); // rit 逻辑上指向 40 auto base_it = rit.base(); // base_it 实际上指向 lst.end()这个偏移在很多场景下会坑人,尤其是当你需要把一个反向迭代器转换成正向迭代器去删除元素的时候。
一个典型需求:删除容器中倒数第 3 个元素。你先用反向迭代器找到它,然后想通过 erase(rit.base()) 来删,你会惊讶地发现删的是倒数第 2 个。原因就是 base() 偏了一位。正确的做法是erase(std::prev(rit.base()))。
这种细节如果不亲自踩一遍,光看文档很难记住。后面我在常见问题部分还会再提到。
3. 从正向迭代器到反向迭代器:适配器模式的完整实现
3.1 为什么标准库不单独实现一个反向迭代器
现在可以进入标题的第三个关键词:通过正向迭代器适配。
你可能会想,反向迭代器走的方向和正向相反,直接写一个从尾到头遍历的迭代器不就行了,为什么要费劲包一层正向迭代器?
核心原因在于:正向迭代器已经拥有了遍历容器的一切能力——移动、解引用、比较、访问成员。反向遍历无非是“移动方向取反”。如果单独实现一个反向迭代器,你就得为每一种容器写一套反向逻辑,而且这套逻辑和正向逻辑几乎完全对称。适配器模式在这里的价值是:一份通用的适配逻辑,套在任何支持双向遍历的正向迭代器上,就能得到对应的反向迭代器。
标准库的实现就是std::reverse_iterator,它是一个类模板,参数就是正向迭代器类型:
std::reverse_iterator<std::list<int>::iterator>你甚至可以直接用正向迭代器来构造一个反向迭代器,这就是“适配”这个词的含义。
3.2 手写一个最简单的 reverse_iterator
不要觉得标准库的实现很高深,剥去模板的壳,核心逻辑就一个 Iterator 成员 + 反向操作成员函数。下面我写一个精简但能用的版本,帮你理解本质:
#include <iterator> template <class Iterator> class MyReverseIterator { private: Iterator current; // 内部保存正向迭代器 public: using value_type = typename Iterator::value_type; using reference = typename Iterator::reference; using pointer = typename Iterator::pointer; using difference_type = typename Iterator::difference_type; using iterator_category = std::bidirectional_iterator_tag; MyReverseIterator() : current() {} explicit MyReverseIterator(Iterator it) : current(it) {} // 核心:解引用时前移一格 reference operator*() const { Iterator tmp = current; --tmp; return *tmp; } pointer operator->() const { return &(operator*()); } // 反向操作:++ 走的是 --,-- 走的是 ++ MyReverseIterator& operator++() { --current; return *this; } MyReverseIterator operator++(int) { MyReverseIterator tmp = *this; --current; return tmp; } MyReverseIterator& operator--() { ++current; return *this; } MyReverseIterator operator--(int) { MyReverseIterator tmp = *this; ++current; return tmp; } // 比较:直接比较内部的正向迭代器 bool operator==(const MyReverseIterator& other) const { return current == other.current; } bool operator!=(const MyReverseIterator& other) const { return !(*this == other); } // 返回内部正向迭代器(注意偏移问题) Iterator base() const { return current; } };然后我们可以把它用在 list 上:
#include <list> #include <iostream> int main() { std::list<int> lst = {1, 2, 3, 4, 5}; MyReverseIterator<std::list<int>::iterator> rbegin(lst.end()); MyReverseIterator<std::list<int>::iterator> rend(lst.begin()); for (auto rit = rbegin; rit != rend; ++rit) { std::cout << *rit << " "; } // 输出:5 4 3 2 1 }这个手写版本完全能跑,而且逻辑上跟标准库是一致的。看到没,从头到尾我没写过“怎么从尾往头遍历容器”的任何容器特定代码,只是把正迭代器的 ++ 换成 --,解引用时做一个偏移。这就是适配器的精髓——不改动原容器任何代码,通过包装一层新接口来改变行为。
3.3 适配器真正难的三个地方
手写一个能跑的反向迭代器不难,但真正要做出标准库的水准,有几个细节得留意。
第一个是 type alias(类型别名)。为了让算法库能识别你的迭代器类型,你必须定义iterator_category、value_type、reference、pointer、difference_type这五个类型。algorithm 库里的很多算法,比如std::count_if、std::copy,都会通过iterator_traits来读取这些类型信息。如果野外自定义迭代器少了这些 alias,编译期就会报一堆莫名其妙的模板错误。
第二个是 operator-> 的正确实现。看起来很简单,return &(operator*())就行?对常见场景是的。但严谨的做法要考虑当前指针是可重载的代理类型等情况,这里不展开,但你要知道 operator-> 返回的是 pointer,而 pointer 可能不是一个裸指针。
第三个是 STL 的迭代器体系分类。上面的代码我写了iterator_category = std::bidirectional_iterator_tag,因为 list 的迭代器是双向迭代器,它的移动只能 ++/--。如果你拿这个反向迭代器去喂给std::sort,编译会报错——因为std::sort需要随机访问迭代器(random_access_iterator_tag)。这就是为什么 list 自己提供了一个成员函数sort(),而不是用std::sort对 list 排序。
这一点超级重要,值得重点说:迭代器分类是 C++ 泛型编程的隐形协议。你写模板函数的时候,可以通过std::enable_if或 C++20 的 concept 对不同类型的迭代器走不同的实现。理解了迭代器分类,你才真正理解为什么 list 不能配合某些算法。
3.4 list 自带的 reverse 接口
既然说到反向迭代器,顺便提一下 list 的一个成员函数reverse()。它是把整个链表原地反转——双向链表的每个节点的 prev 和 next 指针互换,然后 head 和 tail 互换。复杂度和遍历一样是 O(n)。
注意区分:reverse()是 “把容器内容倒序”,rbegin()/rend()是 “用反向顺序访问容器但不改动容器内容”。两者用途完全不同。
std::list<int> lst = {1, 2, 3, 4, 5}; // 不改容器内容,只是反向遍历 for (auto rit = lst.rbegin(); rit != lst.rend(); ++rit) std::cout << *rit; // 54321 // 原地翻转容器内容 lst.reverse(); for (int x : lst) std::cout << x; // 54321,现在容器本身变成这样了我在实际开发里见过有人用reverse()来做反向遍历的需求,以为“反正结果一样”。性能上如果只是遍历一次倒无所谓,但如果后面还有别的操作,修改了容器内容可能带来意外影响。能用 rbegin 的时候不要轻易 reverse。
4. list 和 vector 正面交锋:从底层到场景的全面对比
4.1 底层结构对接口能力的根本制约
表格是最好的呈现方式,我先放一个大致的对比框架,再逐项解释:
| 对比项 | list(双向链表) | vector(动态数组) |
|---|---|---|
| 内存分布 | 节点分散,每个节点独立分配 | 连续内存,一次性分配一段 |
| 随机访问 | 不支持,无 operator[] | O(1),支持 operator[] |
| 头部插入/删除 | O(1),push_front/pop_front | O(n),需要整体移位 |
| 尾部插入/删除 | O(1) | 均摊 O(1),容量满时扩容 O(n) |
| 中间插入/删除 | O(1)(已知位置迭代器) | O(n),元素移位 |
| 迭代器类型 | 双向迭代器 | 随机访问迭代器 |
| 内存利用 | 每节点额外两指针开销 | 紧凑,但可能有空余容量 |
| 排序 | list::sort(),O(n log n) | std::sort(),O(n log n) |
| 缓存友好性 | 差,节点跳跃访问 | 好,顺序访问命中率高 |
| 迭代器失效规则 | 删除节点才会使对应迭代器失效 | 扩容或插入/删除可能导致全部失效 |
这表格不是让你背下来的,关键是要能把每一行的差异关联到底层结构。
内存分布这一行影响最大。vector 的连续内存决定了它天然支持随机访问,也决定了插入中间位置要挪元素。list 的节点分散决定了它插入删除只要改指针,但访问第 n 个元素必须从头走。这是你选择容器时第一个要看的需求维度。
4.2 最容易被忽略的性能维度:缓存命中率
很多初学者对比 list 和 vector,只看时间复杂度的表格,然后得出结论:如果你的操作是“中间插入删除居多”,就无脑选 list。这个结论在算法题里是对的,但在真实工程里,还有一匹黑马你可能完全没想到——缓存局部性。
现代 CPU 从内存读数据的速度远慢于从 CPU 缓存读。vector 的元素在内存里是连续的,你遍历 vector 时,CPU 会把相邻的元素一起加载到缓存行里——你访问第 1 个元素时,第 2、3、4 个大概率已经进缓存了。list 的节点分散在内存各处,你访问完第 1 个节点再访问第 2 个,很可能要重新从主存读取,缓存命中率极低。
我举一个自己实测过的例子。有一段时间我写一个需要频繁“在头部插入且经常遍历”的工具,从复杂度看 list 应该是完胜——头部插入 O(1) 对 vector 的 O(n)。但实际跑起来,在数据量到 100 万级别时,遍历 100 万次 list 节点所花的时间,远高于我用一个 vector 每次头部插入 O(n) 的实现。原因就是 List 的缓存跳变太致命。
所以结论是:如果你的操作以小规模数据为主,或者遍历频率远高于修改频率,vector 往往仍是更好的选择,哪怕你在头部做插入。如果数据量巨大且修改频繁,才考虑 list。工程上没有银弹,复杂度分析只是起点,真实数据量、访问模式、操作频率都要一起看。
4.3 迭代器失效规则:list 完胜 vector 的领域
迭代器失效是 STL 使用中最隐蔽的坑之一,也是 list 相比 vector 最优势的地方。
vector 的失效规则很严格:
- push_back 导致容量重新分配时,所有迭代器和引用都失效。
- insert 使插入点之后的所有迭代器失效。
- erase 使删除点之后的所有迭代器失效。
list 则温和得多:
- insert 不会使任何迭代器失效。
- erase 只会使被删除元素对应的迭代器失效,其他节点迭代器不受影响。
这意味着什么?意味着你在遍历 list 的过程中可以安全地删除 node 之外的其他节点,迭代器不受干扰;而在遍历 vector 时,任何修改都可能让已有的迭代器直接变成一个“悬挂”的东西,一用就未定义行为。
我后面第五节的常见问题部分,会专门展示一个基于这两条规则的实际场景。
4.4 list::sort 和 std::sort 为什么不能混用
一个总被忽略的细节是 list 的排序。很多初学者直接写std::sort(lst.begin(), lst.end()),编译直接报错,不知道问题在哪。
原因很简单:std::sort要求随机访问迭代器,list 提供的是双向迭代器。随机访问意味着迭代器能一次性跳转到任意位置(O(1) 的 advance),双向迭代器只能一步一挪。快排类的算法大量依赖 O(1) 的枢轴选取和元素交换,list 满足不了。
list 自己实现了sort()成员函数,用的是归并排序的思路。为什么归并排序适合链表?因为归并的核心操作是“比较两个有序子序列的头节点并拼接”,这恰好是链表最擅长的——节点拆分和指针重接都是 O(1) 操作,不需要像数组那样开辟额外空间搬运元素。
这里有个冷知识,用 list::sort 排序后,list 中任何节点的 prev 和 next 关系都会被打乱重接,因此所有迭代器会失效(但元素值不变)。这点和 erase 的温和失效规则不一样。
5. 常见问题与实操避坑清单
5.1 erase 和迭代器配合的正确姿势
最经典的一个坑:遍历 list 并删除满足条件的元素。
错误写法:
std::list<int> lst = {1, 2, 3, 4, 5, 6}; for (auto it = lst.begin(); it != lst.end(); ++it) { if (*it % 2 == 0) { lst.erase(it); // 错误!erase 后 it 变成无效迭代器,++it 未定义行为 } }为什么这样是错的?因为 erase(it) 把 it 指向的节点释放了,然后循环里的 ++it 还在用这个已经释放的迭代器往后跳。虽然很多时候没有立刻崩溃,但这是未定义行为,换个编译环境可能就崩了。
正确写法有两种。
第一种是使用 erase 的返回值——C++11 之后 std::list::erase 返回被删元素的下一个有效迭代器:
std::list<int> lst = {1, 2, 3, 4, 5, 6}; auto it = lst.begin(); while (it != lst.end()) { if (*it % 2 == 0) { it = lst.erase(it); // erase 返回下一个节点,it 继续前进 } else { ++it; } }另一种是借助 remove_if,这是 list 针对“按条件删除”给出的接口,简洁且高效:
lst.remove_if([](int x) { return x % 2 == 0; });在我的经验里,能使用 remove_if 的场景尽量用 remove_if。它语义清晰,还免去了手动维护迭代器的麻烦,性能也不会差。
5.2 反向迭代器删除元素:base() 偏一格的坑
回到前面提过的反向迭代器和 base() 的偏移问题,这里给个完整的例子。假设容器是 {a, b, c, d},你想删除 c(倒数第二个),代码可能写成:
std::list<char> lst = {'a', 'b', 'c', 'd'}; auto rit = lst.rbegin(); // 指向 d ++rit; // 指向 c lst.erase(rit.base()); // 错误!rit.base() 指向的是 d 的位置,而不是 c刚才我们讲过,反向迭代器内部保存的正向迭代器比逻辑位置靠后一格。rbegin() 逻辑指向 d,但内部正向迭代器指向 end();++rit 后逻辑指向 c,但内部正向迭代器指向 d。所以rit.base()指向 d,你删除的其实是 d,容器变成 {a, b, c} ——和预期完全相反。
正确做法:
auto rit = lst.rbegin(); ++rit; // 逻辑指向 c lst.erase(std::prev(rit.base()));用std::prev把偏的那一格补回来。优先建议:如果只是要删除指定元素,不去“绕”正反迭代器的转换,直接拿正向迭代器做删除更稳妥。
5.3 不要把 list 当作“万能插入删除容器”
我在第 4.2 提到过,list 的缓存命中率低带来性能隐患。这里再延伸一个常见误区:有人以为“list 插入 O(1) 所以任何时候插入都最快”。但是 O(1) 在真实的工程里只说明常数规模下的渐进复杂度,并不代表绝对时间短。你需要先定位到插入位置——list 找插入位置是 O(n),vector 虽然插入本身是 O(n),但随机访问定位是 O(1)。如果你的应用模式是“频繁随机访问 + 偶尔插入”,vector 综合起来完胜。
所以我的建议是:数据规模较小(几千以内)时,优先 vector,除非你有极强的理由。大规模且插入删除频率确实高、遍历频率低,再换 list。不要拿着时间复杂度表闭眼选型。
5.4 list 的 size() 是 O(1) 还是 O(n)?
这是个面试常问的细节。C++11 标准要求 std::list::size() 是常数时间 O(1),但老一点的标准(C++03)没有这个强制要求。在 C++11 的编译环境里,你可以放心地用 size() 做循环条件判断,不会有性能灾难。这一点和某些老资料的描述可能不一致,需要注意。
另外提一个容易搞混的:splice 函数是 list 独有的大杀器,它可以把一个 list 的节点直接转移给另一个 list,O(1) 时间,不复制元素只改指针。这在某些需要分桶、重组数据的场景里极其好用。vector 完全做不到这一点。
std::list<int> listA = {1, 2, 3}; std::list<int> listB = {4, 5, 6}; // 把 listB 的所有元素移动拼接到 listA 的尾部,listB 变空 listA.splice(listA.end(), listB);6. 实操总结与排除思路
从基础到适配到对比,这一趟走下来,核心就一件事:STL 容器的设计不是靠背接口,而是靠理解底层结构对接口的约束。list 的双向链表结构决定了它是双向迭代器,所以它没有 operator[]、不能用 std::sort;而反向迭代器的巧妙之处就在于它通过适配器模式,让“反向遍历”这一需求不需要依赖容器特定实现,直接复用正向迭代器能力。
我的个人经验是,动手实现一个精简版的 reverse_iterator 非常值得。哪怕你十行代码就能写完,但做一遍之后,你对“迭代器是容器和算法之间的粘合剂”这句话的理解会完全不一样。以后看 std::reverse_iterator 的源码文档,你会一眼看穿它只是包装了 base() 加偏移那一套逻辑而已。
真正上手的时候,我可以给你一条排错路径:遇到 STL 相关编译错误,先看报错里出现的迭代器类型名字。如果出现 error: no match for 'operator-',多数是你的某种迭代器根本不支持减法——换个容器或改用 std::advance 按步数推进就好。如果出现 static_assert failed due to requirement 'std::random_access_iterator...',通常是迭代器类别不满足算法要求,把 std::sort 换成 list::sort 或者改用 std::vector 存储。
当然我知道,学迭代器和链表容器的过程确实比其他章节要绕。但一旦过了这个坎,接下来学 map、set、unordered_map,你会觉得越来越顺手——因为迭代器这套抽象在 STL 里无处不在,而你已经在“最难适配”的双向迭代器上把原理摸透了。这个基础打得越扎实,后面越省力。