在 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 是两者的折中,既有随机访问又能两端高效插删,但那是另一个故事了。)

对比维度vectorlist
底层结构动态顺序表,一段连续空间带头双向循环链表
随机访问支持,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)、头尾插删都无所谓、迭代器像不死金刚一样稳(只要你别删它指的那个节点)。也别忘了它的软肋:缓存不友好、随机访问必须爬、每个节点多两个指针。

带着这套直觉去写代码,你就能在"读得快"和"改得快"之间做出真正适合当前场景的选择。以后遇到"既要又要"的需求,多想想那三件会改变你判断的事:我是读多还是改多?我要头插还是尾插?我这个迭代器需要活得久吗? 三个问题一问,答案自己就浮上来了。