【C++ list 深度解析】从双向循环链表、常用接口与迭代器失效到核心模拟实现

🔥 本文定位 :本文从 std::list 的节点式存储模型出发,系统讲解构造、遍历、容量与元素访问、增删改查、链表专属算法、迭代器失效、反向迭代器、底层哨兵结构,并通过一个教学版 MiniList 串起节点、链接、迭代器和对象生命周期。

💡 学习目标 :不仅要会调用 push_backinserterasesplicesort,还要能够解释 为什么 list 不支持随机访问、已知位置插删为何是 O(1)、删除时究竟哪些迭代器失效、反向迭代器为什么要"先减一再解引用",以及 vectorlist 应如何按访问模式选择

📌 阅读说明:标准规定的是接口、语义、复杂度与失效规则,并不要求实现必须使用某一套字段名称。本文用"带哨兵节点的双向循环链表"建立心智模型,这是主流实现方式,也非常适合教学;模拟代码刻意省略分配器定制、完整异常安全传播和全部重载,不应直接替代标准库实现。


文章目录


一、先建立全局视角: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)
  • 插入不会使已有元素的迭代器、指针和引用失效;
  • 删除只会使指向被删除元素的迭代器、指针和引用失效;
  • 提供 splicemergesortuniqueremove_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

若要在 2030 之间插入 25,链表不需要搬移 3040,只需:

  1. 25 创建一个新节点;
  2. 让新节点连接 2030
  3. 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)。如果业务频繁按下标读取,vectordeque 通常更合适。

3.6 双向迭代器能做什么,不能做什么

list 迭代器通常支持:

  • *itit->member
  • ++itit++
  • --itit--
  • ==!=

它不支持随机访问迭代器的典型运算:

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_frontemplace_backemplace

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 40insert(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:转移节点,而不是复制元素

splicelist 最有代表性的成员函数之一。它能把另一个 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::removelist::remove_if 会真正删除节点。对于其他顺序容器,常见的是 erase-remove 惯用法;对 list,优先使用容器自己的节点删除接口更直接。

在 C++20 中,std::erasestd::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.nextend() 可以表示 &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,每次插入和删除都同步增减计数,就能常数时间返回大小。

代价是所有改变节点数量的路径都必须正确维护 _sizesplice 尤其容易暴露这个权衡:整链转移容易更新计数,跨容器转移任意区间时,如果没有现成的区间长度,可能需要计算转移节点数。


八、教学版 MiniList 的核心模拟实现

8.1 实现目标与边界

下面实现一个用于理解原理的 MiniList<T>,包含:

  • 独立的基础节点与数据节点;
  • 带哨兵节点的双向循环链表;
  • 正向迭代器与常量迭代器;
  • beginendfrontback
  • emplaceinserterase
  • push_frontpush_backpop_frontpop_back
  • 拷贝构造、移动构造、移动赋值、clear
  • 基于 std::reverse_iterator 的反向迭代。

它没有完整实现标准 std::list 的分配器传播、所有构造重载、跨常量迭代器比较、splicemerge、调试检查等能力。

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_typeallocator_traits
  • const_iteratoriterator 的安全转换及混合比较;
  • 完整的范围构造、赋值和插入重载;
  • splicemergesortuniqueremove_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* constconst 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
  • 需要按键查找:考虑 mapunordered_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:哨兵节点解决了什么问题?

它把空表、首部、尾部统一进同一个循环结构,使 beginend、插入和删除可以用更少的特殊分支实现。

问题 10:为什么哨兵只保存链接,不保存 T?

这样不必为无业务意义的哨兵默认构造一个 T,也不会额外要求 T 必须默认可构造。

问题 11:vector 和 list 如何选择?

高频遍历、随机访问和紧凑存储优先考虑 vector;已知位置频繁插删、地址稳定或节点转移需求再评估 list,最终用基准测试验证。

问题 12:反向迭代器为什么保存尾后位置?

为了让反向范围仍采用统一边界。rbegin().base() 通常是 end(),解引用时先把基础迭代器减一。

12.3 排查清单

list 代码时,可以依次检查:

  • 是否错误使用了下标、+ n 或大小比较;
  • 插入前是否已经持有目标位置,还是还需要线性查找;
  • 删除后是否继续使用了旧迭代器;
  • 循环删除是否接收 erase 返回值;
  • 是否对空容器调用 frontbackpop_frontpop_back
  • 是否把 unique 误当成全局去重;
  • 是否把 std::sort 用在双向迭代器上;
  • merge 前两个链表是否按同一规则有序;
  • splice 的源、目标和区间是否满足前置条件;
  • 是否真的需要 list 的地址稳定或节点转移优势;
  • 是否测量过 vector 在真实负载下的表现;
  • 是否依赖了某个实现的节点字段或哨兵地址。

12.4 建议练习

  1. 使用 list<int> 完成正向、反向、范围 for 三种遍历。
  2. 删除链表中所有偶数,分别使用 erase 循环与 remove_if
  3. 演示插入后旧迭代器仍有效、删除后被删迭代器失效。
  4. splice 把一个链表中的所有负数转移到另一个链表。
  5. sortunique,完成全局去重。
  6. MiniList 增加 emplace_frontemplace_back
  7. MiniList 实现范围 erase(first, last)
  8. 为迭代器增加从 iteratorconst_iterator 的转换。
  9. 实现只转移单节点的 splice,并画出链接变化。
  10. 对相同数据规模的 vectorlist 做顺序遍历基准测试。
  11. 对比"先查找再插入"和"持有迭代器后插入"的总耗时。
  12. 使用 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 的标志,不是能背出多少个成员函数,而是在看到一次插入、删除或转移时,能够准确回答:当前位置从哪里来、哪些链接会改变、哪些对象仍在原地址、哪些迭代器已经失效,以及为什么这个访问模式值得选择节点容器。

相关推荐
法哥20122 小时前
用 C++ 控制 CMW100,到底在干什么?
开发语言·c++
_Narcissus_3 小时前
枚举和模拟算法笔记
c语言·数据结构·c++·笔记·算法·模拟·枚举
冻柠檬飞冰走茶3 小时前
《数据结构实验指导-C++语言版》 在顺序表 list 中查找元素 x
开发语言·数据结构·c++·算法·list
Brilliantwxx3 小时前
【C++】 高阶数据结构图(1)并查集
开发语言·数据结构·c++
小鱼仙官3 小时前
Ubuntu C++ NTP校时程序
linux·c++·ubuntu
冻柠檬飞冰走茶4 小时前
《数据结构实验指导-C++语言版》 返回单链表 list 中第 i 个元素值
开发语言·数据结构·c++·算法·list
老当益壮梁奶奶4 小时前
Linux软件编程学习笔记(二)Linux文件IO编程入门:从fopen到fgetc
linux·c语言·c++·笔记·学习
雪的季节4 小时前
C++ 高级(泛型编程与模板)(有空再研究吧)
c++
三十岁老牛再出发4 小时前
8月21日总结
c++·python