list:从会用到能自己写一个
节点之间没有地址关系,只有两个指针把彼此串起来。今天聊 list。
list 的接口和 vector 高度一致:构造、size、push_back、insert、erase、迭代器遍历,换个容器名就能跑。真正不同的地方在两处:一是底层结构从连续数组变成了分散节点,二是迭代器不能再拿原生指针糊弄过去。前半段讲接口和性质,后半段拆开节点、哨兵和迭代器封装。
为什么需要 list
vector 的代价集中在"连续"两个字上。中间插入或删除,要把后面的元素整体搬移,复杂度 O(n);容量不够时要开新空间、搬元素、释放旧空间,原有的迭代器、指针、引用全部失效。
list 把每个元素放进独立节点,节点之间用指针相连。已知位置的插入和删除只改几个指针,O(1),不搬移任何元素,也不会让其他节点的地址改变。
代价同样明确:
| 维度 | vector | list |
|---|---|---|
| 底层结构 | 动态数组,一段连续空间 | 带头结点的双向循环链表 |
| 随机访问 | O(1) | 不支持,访问第 n 个是 O(n) |
| 中间插入删除 | O(n),要搬元素 | O(1),只改指针(已知位置) |
| 查找 | 连续内存,缓存友好 | 逐节点跳转,缓存不友好 |
| 每个元素的额外开销 | 无 | 两个指针 |
| 插入是否失效迭代器 | 可能全部失效 | 不失效 |
关键限定词是"已知位置"。insert / erase 本身是 O(1),但用 find 找到那个位置是 O(n)。标准文档写"常数时间插入删除",说的是前者,不是"查找加删除"。
💡 背景补充:C 语言的单链表只有
next,删除节点要先找前驱;双向链表补上prev,再加一个哨兵头结点,空表和非空表的边界就消失了。
📖 参考:《数据结构知识库》第一节(线性表:顺序表与链表的对比)
底层结构:带头双向循环链表

图中红色方框是哨兵结点(_head),蓝色方框是第一个数据结点。四个数据结点 0、2、3、4 首尾相接,4 的 _next 指回哨兵,哨兵的 _prev 指向 4。begin() 返回哨兵的下一个结点,end() 返回哨兵本身。
这个结构把链表最常见的边界情况全部消掉了:
- 空表时,哨兵的
_next和_prev都指向自己,仍然是一个合法的环。 - 尾结点永远是
_head->_prev,取尾 O(1),不需要遍历。 - 头插、尾插、头删、尾删都能统一成"在某结点之前插入"或"删除某结点",不用为空表、单结点、尾结点分别写分支。
插入一个新结点,只需要改四条链接:prev->_next = newnode、newnode->_prev = prev、newnode->_next = cur、cur->_prev = newnode。删除则反向操作两条,再 delete 当前结点。
哨兵结点不存有效数据,它的 _data 是 T(),只是为了占住环的入口。所有"最后一个元素的下一个位置"都指向它,这正是 end() 的语义。
构造、容量与元素访问
常用构造如下:
| 构造 | 作用 | 备注 |
|---|---|---|
list() |
空链表 | 只有一个哨兵结点 |
list(size_t n, T val = T()) |
n 个 val | 标准库有约束;自写实现易被区间构造抢走 |
list(InputIterator first, InputIterator last) |
迭代器区间 | 左闭右开 |
list(const list& lt) |
拷贝构造 | 深拷贝 |
list(initializer_list<T> il) |
花括号初始化 | C++11 |
cpp
#include <list>
#include <iostream>
int main()
{
std::list<int> l1; // 空
std::list<int> l2(5, 1); // 5 个 1
int a[] = {1, 2, 3, 4};
std::list<int> l3(a, a + 4); // 区间构造
std::list<int> l4(l3); // 拷贝构造
std::list<int> l5{1, 2, 3}; // C++11
for (int e : l5)
std::cout << e << ' ';
std::cout << l5.size() << '\n';
}
list 没有 capacity、reserve、shrink_to_fit,也没有 operator[]。容量概念只属于连续空间;链表按需申请一个节点就是一个节点,没有"预留槽位"这回事。
下标访问被去掉的原因很直接:vector 的 operator[] 是"起始地址 + i",O(1);链表要拿到第 i 个,只能从头逐个 ++,O(n)。语法上可以实现,语义上不该伪装成常数时间。
元素访问只保留 front() 和 back(),都返回引用,可以读取也可以修改:
cpp
std::list<int> lt = {1, 2, 3};
lt.front() = 100; // 第一个结点
lt.back() = 200; // 最后一个结点
迭代器的三个性质
从"能做什么"划分,迭代器有正向、反向、const 三类;从"性质"划分,有单向、双向、随机三类。前者是用法,后者是能力。
| 性质 | 支持的操作 | 典型容器 |
|---|---|---|
| 单向(forward) | ++ |
forward_list、unordered_map、unordered_set |
| 双向(bidirectional) | ++、-- |
list、map、set |
| 随机访问(random access) | ++、--、+、-、[] |
vector、string |
性质由底层结构决定,不是想实现什么就实现什么:
vector/string是连续空间,指针加 i 就跳到第 i 个,天然支持+、-。list的结点里有两个指针,能顺着_next往后、顺着_prev往前,但没有"第 i 个"的地址公式,所以只支持++、--。forward_list的结点只有一个next,只能往后,所以只支持++。
这也是 lt.begin() + 3 编译不过的原因。要在第三个位置插入,只能走:
cpp
std::list<int> lt = {1, 2, 3, 4};
auto it = lt.begin();
int k = 3;
while (k--)
++it;
lt.insert(it, 99); // 在下标 3 之前插入
std::advance(it, 3) 做的是同一件事,内部按迭代器性质选择实现:随机迭代器直接 it += n,双向迭代器循环 ++ 或 --。
算法会挑迭代器
标准算法的形参名会标明它要求的最低性质,名字本身就是约束:
| 算法 | 要求 | 原因 |
|---|---|---|
sort |
随机访问 | 快排、堆排要算中点、要 +、-、[] |
reverse |
双向 | 从两端向中间交换,要 -- |
find |
输入迭代器 | 只做 ++,单向双向随机都能用 |
所以 std::sort 不能排链表:
cpp
#include <algorithm>
#include <list>
std::list<int> lt = {3, 1, 2};
std::sort(lt.begin(), lt.end()); // 编译报错:ListIterator 没有 operator-
lt.sort(); // 成员函数,归并排序
list 自己实现了一个 sort,因为它用不了算法库那个。标准库的容器里,只有需要特殊实现的容器才会带成员 sort,链表是典型。
reverse 则可以用算法库的版本,因为链表迭代器支持 --:
cpp
std::reverse(lt.begin(), lt.end()); // 可以
lt.reverse(); // 也可以,等价
迭代器的性质在标准库里用继承关系建模:随机访问是双向的特例,双向是单向的特例。算法只声明它需要的最弱性质,更强的迭代器自然能传进去。find 的形参写成输入迭代器,意思是"只要求 ++",所有容器都满足。
只属于 list 的接口:splice、merge、unique、remove
这几个接口是链表结构带来的,vector 没有对应物。
splice 是"剪切 + 粘贴":把源链表的节点直接摘下来,接到目标位置之前,不拷贝、不重新分配。
cpp
std::list<int> lt1 = {1, 2, 3, 4};
std::list<int> lt2 = {10, 20, 30};
auto pos = std::find(lt1.begin(), lt1.end(), 2);
lt1.splice(pos, lt2); // lt2 的所有节点接到 2 之前,lt2 变为空
它还有两个重载:转移单个节点、转移一段区间。也可以对自己用,用来调整顺序:
cpp
std::list<int> lt = {1, 2, 3, 4, 5};
auto it = std::find(lt.begin(), lt.end(), 5);
lt.splice(lt.begin(), lt, it); // 把 5 移到头部
merge 合并两个有序链表,取较小者尾插,节点整体转移:
cpp
std::list<int> a = {1, 3, 5};
std::list<int> b = {2, 4, 6};
a.merge(b); // a: 1 2 3 4 5 6,b 为空
unique 删除相邻的重复值,前提同样是有序:
cpp
std::list<int> c = {1, 1, 2, 2, 2, 3};
c.unique(); // 1 2 3
无序时它只比较相邻元素,{2, 1, 2} 去重后仍是 {2, 1, 2}。想真正去重,先 sort 再 unique。
remove 按值删除所有匹配节点:
cpp
c.remove(2); // 删除所有值为 2 的节点
remove 内部逐个比较,O(n);它不要求有序,也不会因为找不到而报错。
排序:把链表拷进 vector
list::sort 是归并排序,时间复杂度 O(n log n),但它每次比较都要跳到下一个节点,缓存命中率很低。数据量大时,把节点拷进 vector、排序、再拷回来,往往更快。
cpp
#include <list>
#include <vector>
#include <algorithm>
#include <ctime>
#include <cstdio>
void test_list_sort()
{
srand((unsigned)time(nullptr));
const int N = 1000000;
std::list<int> lt1, lt2;
for (int i = 0; i < N; ++i)
{
int e = rand() + i; // 减少重复值
lt1.push_back(e);
lt2.push_back(e);
}
int b1 = clock();
std::vector<int> v(lt2.begin(), lt2.end());
std::sort(v.begin(), v.end());
lt2.assign(v.begin(), v.end());
int e1 = clock();
int b2 = clock();
lt1.sort();
int e2 = clock();
printf("copy to vector: %d ms\n", e1 - b1);
printf("list::sort: %d ms\n", e2 - b2);
}
Release 构建下,第二条路通常快 2 到 4 倍,而且这个时间把两次拷贝都算在内。拷贝是顺序写入连续内存,代价远小于链表排序时逐个节点的指针跳转。
Debug 构建的结论不能信。递归实现的快排在 Debug 下每层都要建栈帧、写调试信息,开销被放大;链表归并排序的递归深度浅,反而显得不慢。性能对比只在 Release 下有意义。
数据量小的时候,list::sort 完全够用;数据量大且只排一次,先拷进 vector 是更划算的选择。
迭代器失效:插入不失效,删除才失效
list 的插入不改变已有节点的地址,所以插入不会让任何迭代器失效 。这一点和 vector 相反:vector 一旦扩容,所有迭代器、指针、引用全部作废。
删除会让被删节点的迭代器失效。只失效那一个,其他节点不受影响。
cpp
void TestListIterator1()
{
int array[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
std::list<int> l(array, array + sizeof(array) / sizeof(array[0]));
auto it = l.begin();
while (it != l.end())
{
l.erase(it); // it 指向的节点已被 delete
++it; // 未定义行为
}
}
erase(it) 之后,it 里的 _node 指向一块已释放的内存,再 ++ 就是访问野指针。正确写法是用返回值更新:
cpp
auto it = l.begin();
while (it != l.end())
{
it = l.erase(it);
}
erase 返回被删节点的下一个位置,标准库和这份模拟实现都遵循这个约定。另一种写法是 l.erase(it++):先把旧值传给 erase,再把 it 后移,两个动作的顺序由后缀 ++ 保证。
删除所有偶数这类操作,两种写法都对:
cpp
for (auto it = l.begin(); it != l.end(); )
{
if (*it % 2 == 0)
it = l.erase(it);
else
++it;
}
手写一个 list:节点与骨架
先定义节点。每个节点存数据加前后两个指针:
cpp
namespace bit
{
template<class T>
struct list_node
{
list_node<T>* _next;
list_node<T>* _prev;
T _data;
// 哨兵节点也走这个构造,所以必须有默认参数
list_node(const T& x = T())
: _next(nullptr), _prev(nullptr), _data(x)
{}
};
}
T() 对内置类型是零值初始化:int 得 0,double 得 0.0;对自定义类型调用默认构造。哨兵节点不存数据,但必须能构造出来,这个默认参数就是为它准备的。
链表本体只保存两个成员:
cpp
template<class T>
class list
{
using Node = list_node<T>;
public:
using iterator = list_iterator<T, T&>;
using const_iterator = list_iterator<T, const T&>;
// ...
private:
Node* _head; // 哨兵
size_t _size = 0; // 结点个数
};
_head 永远指向哨兵,不指向第一个数据节点。begin() 是 _head->_next,end() 是 _head。空表时两者相等,循环一次都不进,不需要特判。
_size 不是链表结构必需的,但没有它,size() 要遍历整条链,O(n)。加一个计数器,插入 ++、删除 --,size() 就是 O(1)。
节点指针为什么不能直接当迭代器
vector 的迭代器就是原生指针,因为它的元素连续:++p 的地址偏移正好是下一个元素。
链表不行。一个 list_node<T>*:
cpp
using Node = bit::list_node<int>;
Node* p = nullptr; // 假设指向某个节点
// *p 的类型是 list_node<int>,不是 int
// ++p 的地址加 sizeof(list_node<int>),不是下一个节点
解引用拿到的是整个节点,不是里面的 _data;自增是按地址偏移走,而节点是逐个 new 出来的,地址之间没有规律。
解决办法是把节点指针包进一个类,用运算符重载改掉它的行为:
cpp
template<class T, class Ref>//这里的Ref写法直接可以允许少写一个const版本
struct list_iterator
{
using Self = list_iterator<T, Ref>;
using Node = list_node<T>;
Node* _node;
list_iterator(Node* node)
: _node(node)
{}
Ref operator*()
{
return _node->_data; // 解引用给出数据,不是节点
}
Self& operator++()
{
_node = _node->_next; // 自增走链表,不是走地址
return *this;
}
Self operator++(int)
{
Self tmp(*this);
_node = _node->_next;
return tmp;
}
Self& operator--()
{
_node = _node->_prev;
return *this;
}
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
bool operator!=(const Self& s) const { return _node != s._node; }
bool operator==(const Self& s) const { return _node == s._node; }
};
几处细节值得停一下:
Self是list_iterator<T, Ref>的别名。类内直接写list_iterator也行(注入类名),但形参和返回值都要用它,起个短名字省事。- 前置
++返回Self&:改的是自己,返回引用不产生拷贝,++++it可以连着写。后置++必须返回Self:它要交出自增前 的值,只能先拷一份tmp存着。 - 后置版本形参里的
int没有任何用处。它只是占位标记,用来和前置版本构成重载,调用时编译器自动补0。 operator*返回Ref而不是写死的T&,这是后面const_iterator能复用同一个类模板的关键。operator!=的形参是const Self&,按引用传,不拷贝;函数末尾那个const是另一件事,它修饰this,表示函数不改迭代器自身,因此const的迭代器对象也能调用。形参const和函数const管的是两个不同的对象。
迭代器对象里只有一个 Node*,物理大小和裸指针完全相同:32 位下 4 字节,64 位下 8 字节。差别在类型上。裸指针的 ++ 走内置的地址运算,ListIterator 的 ++ 走重载函数,跳到 _node->_next。同样的字节,不同的规则,这是类型系统在做区分。
有了这个类,begin() 返回 iterator(_head->_next),end() 返回 iterator(_head)。范围 for 只依赖 begin、end、!=、++、*,全部满足,所以自然支持。
operator-> 与两个箭头
迭代器还要模拟指针的 ->。存 int 时用不到,存结构体时绕不开:
cpp
struct A
{
int a1;
int a2;
};
bit::list<A> lt;
lt.push_back({1, 2});
auto it = lt.begin();
it->a1;
it 是类对象,-> 必须在类里重载:
cpp
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
operator-> 返回的是数据成员的地址,也就是 T*。写 it->a1 时,编译器实际展开成:
cpp
it.operator->()->a1;
如果 operator-> 返回的仍然是一个类对象,编译器会继续对它调用 operator->,直到拿到指针。这条规则允许链式代理。所以严格来说,it->a1 等价于 it.operator->()->a1,中间那个箭头是原生指针自带的。
链表的迭代器必须暴露 pointer 和 reference 两个类型别名,反向迭代器才能从它身上取返回值类型:
cpp
template<class T, class Ref, class Ptr>
struct list_iterator
{
using Self = list_iterator<T, Ref, Ptr>;
using Node = list_node<T>;
using reference = Ref;
using pointer = Ptr;
Node* _node;
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
// ++、--、==、!= 同上
};
// 两个参数不同的实例化
using iterator = list_iterator<T, T&, T*>;
using const_iterator = list_iterator<T, const T&, const T*>;
标准库的链表迭代器就是三个模板参数:T、Ref、Ptr。多出来的两个参数只控制 * 和 -> 的返回类型,其他实现完全共用。
const 迭代器:为什么不是 const iterator
指针有两种 const,位置不同,含义不同:
cpp
T* const p; // 指针本身不能改,指向的数据可以改
const T* p; // 指针本身可以改,指向的数据不能改
const iterator 对应第一种:迭代器对象不能改,所以 ++it 编译不过;但它指向的数据没被保护。const_iterator 对应第二种:可以 ++ 遍历,但不能通过它修改元素。
所以 const_iterator 不能靠给普通迭代器加 const 得到。它需要的是一个"自增正常、解引用返回 const T&"的新类型。
最直觉的做法是另写一个类,节点指针和成员函数照抄一遍,只改 operator* 的返回类型:
cpp
template<class T>
struct list_const_iterator
{
list_node<T>* _node;
const T& operator*() { return _node->_data; }
// ++、--、==、!= 全部抄一遍
};
能跑,但迭代器的逻辑(跳 _next、跳 _prev、比较 _node)和 const 毫无关系,只有 * 的返回类型不同。整份代码复制两遍,以后改一处就要同步两处。
把这点差异抽成模板参数,一个类就够:
cpp
using iterator = list_iterator<T, T&>;
using const_iterator = list_iterator<T, const T&>;
同一个类模板,Ref 传 T& 时 operator* 返回可写引用;传 const T& 时返回只读引用。其他成员一字不改。
cpp
bit::list<int> lt = {1, 2, 3};
const bit::list<int>& clt = lt;
bit::list<int>::const_iterator cit = clt.begin(); // const 对象调用 const 版本 begin
std::cout << *cit << '\n'; // 可以读
*cit = 10; // 编译报错:const T& 不能赋值
++cit; // 可以
模板的成员函数体按需实例化:没被调用的函数,编译器只做语法层面的浅扫描,不会检查里面的细节错误。*cit = 10 一旦写出来,operator* 被实例化,错误立刻暴露;不写,错误就一直藏着。
begin/end 的 const 重载
const 对象只能调用 const 成员函数。所以链表的 begin / end 各有两个版本:
cpp
iterator begin() { return iterator(_head->_next); }
iterator end() { return iterator(_head); }
const_iterator begin() const { return const_iterator(_head->_next); }
const_iterator end() const { return const_iterator(_head); }
非 const 对象两个版本都能调,重载决议会选最匹配的非 const 版本,拿到可写迭代器;const 对象只能调 const 版本,拿到只读迭代器。
cpp
void Print(const bit::list<int>& lt)
{
bit::list<int>::const_iterator it = lt.begin();
while (it != lt.end())
{
std::cout << *it << ' ';
++it;
}
}
如果只保留非 const 版本,const list<int>& 参数就无法遍历。如果只保留 const 版本,普通对象也能用,但永远拿到只读迭代器,失去了修改能力。两个版本都写,才能按对象本身的 const 属性分流。
复用:insert 与 erase
有了 insert 和 erase,头尾操作全部是复用:
cpp
void push_back(const T& x) { insert(end(), x); }
void push_front(const T& x) { insert(begin(), x); }
void pop_back() { erase(--end()); }
void pop_front() { erase(begin()); }
push_back 最初是直接 new 一个节点、改四条链接。改成调用 insert(end(), x) 之后,写指针的地方全篇只剩 insert 和 erase 两处,出了问题也只能从这两处出。
end() 指向哨兵,在它之前插入就是尾插;--end() 得到尾节点,删掉就是尾删;begin() 是第一个数据节点,删掉就是头删。
insert 的完整实现:
cpp
void insert(iterator pos, const T& x)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(x);
// prev <-> newnode <-> cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size;
}
erase 返回下一个位置,顺便解决了遍历删除的问题:
cpp
iterator erase(iterator pos)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* next = cur->_next;
prev->_next = next;
next->_prev = prev;
delete cur;
--_size;
return next; // Node* 隐式转换成 iterator
}
return next; 能编过,是因为 list_iterator 的构造函数只有一个 Node* 参数且没有 explicit,Node* 可以隐式转换成迭代器。标准规定 insert 也返回指向新节点的迭代器;当前实现返回 void,补上 return iterator(newnode); 即可。
两个操作的代价都是 O(1),不需要查找、不需要搬移。
析构、clear、拷贝、赋值
clear 删除所有数据节点,但保留哨兵:
cpp
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
析构在 clear 之后再把哨兵也释放:
cpp
~list()
{
clear();
delete _head;
_head = nullptr;
}
拷贝构造必须先把哨兵建出来,再逐个 push_back:
cpp
list(const list<T>& lt)
{
empty_init(); // 没有这一行,push_back 会解引用空指针
for (auto& e : lt)
{
push_back(e);
}
}
empty_init 负责建立哨兵并让 _next、_prev 指向自己:
cpp
void empty_init()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
new Node 不带实参,走的是 list_node 的默认实参 x = T()。这带来一个隐含约束:list<T> 要求 T 可默认构造。
cpp
struct NoDef
{
explicit NoDef(int x) {}
};
bit::list<NoDef> lt; // 编译报错:no matching function for call to 'NoDef::NoDef()'
报错落在 empty_init 里,离声明只有两行。标准库的链表把哨兵结点的链接部分和数据部分拆开,哨兵只存前后指针,所以没有这个要求。简化版牺牲一点通用性,换来结构上的直白。
传统赋值写法是先判自赋值,再清空、再拷贝:
cpp
list<T>& operator=(const list<T>& lt)
{
if (this != <)
{
clear();
for (auto& e : lt)
{
push_back(e);
}
}
return *this;
}
更现代的写法是 copy-and-swap:形参按值传,自动得到一份拷贝,再和自身交换,形参析构时带走旧数据。
cpp
list<T>& operator=(list<T> lt) // 传值,已经拷贝一份
{
swap(lt);
return *this;
}
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
std::swap(_size, lt._size);
}
交换只动两个成员,O(1);旧数据随形参一起析构,不需要手写释放逻辑;自赋值也安全,因为交换的是两份独立的数据。
异常安全是这套写法真正的理由。传统版本先 clear(),旧数据已经删干净;如果后面的 push_back 在中途抛异常(比如 new 失败),对象停在半成品状态:旧数据没了,新数据不全。这只能算基本保证------不泄漏、能析构,但状态不可预期。
copy-and-swap 先让形参把拷贝做完。拷贝阶段抛异常,*this 一个字节都没动,异常安全等级是强保证:要么成功,要么原样。交换只改两个指针和一个计数,本身不会失败。
swap 不能直接调 std::swap(*this, lt),那会走拷贝构造和赋值,反而变成深拷贝。必须交换成员。
📖 参考:《高质量C++/C编程指南》第 9 章(Big-Three、位拷贝、赋值函数四步)
区间构造与整数构造的重载冲突
把两个构造函数并排放:
cpp
template<class InputIterator>
list(InputIterator first, InputIterator last)
{
empty_init();
while (first != last)
{
push_back(*first);
++first;
}
}
list(size_t n, T val = T())
{
empty_init();
for (size_t i = 0; i < n; ++i)
push_back(val);
}
bit::list<int> lt(5, 10); 在这一版里编译不过。两个候选都活着:
| 候选 | 实参 5 的转换 |
匹配质量 |
|---|---|---|
list(size_t, T) |
int → size_t,整型转换 |
需要转换 |
list(InputIterator, InputIterator),InputIterator = int |
5 本身就是 int |
精确匹配 |
精确匹配优先于转换,模板被选中,实例化成 list(int, int)。函数体里要执行 *first,而 first 是 int:
text
error: invalid type argument of unary '*' (have 'int')
list(InputIterator first, InputIterator last) { ... push_back(*first); ... }
模板参数叫 InputIterator,但编译器不检查任何概念。函数体只用到三样东西:!= 比较、* 解引用、前置 ++。传进来的类型只要凑齐这三个操作,模板就能实例化成功------鸭子类型,看行为不看身份。int 有 != 和 ++,唯独没有 *,错误就出在这里。
参数匹配发生在函数体检查之前。参数对上了,错误要等实例化才爆出来,报错位置还落在模板内部,离调用点很远。
补一个 list(int n, T val = T()) 能救这一句:
cpp
// 想拦住负数,却把重载冲突挪到了别处
list(int n, T val = T())
{
empty_init();
for (int i = 0; i < n; ++i)
push_back(val);
}
(5, 10) 恢复了,(arr, arr + 3) 也没受影响。但只要实参不是 int,两个整数版本又打起来。bit::list<int> lt(5u, 10);:
text
error: call of overloaded 'list(unsigned int, int)' is ambiguous
unsigned int 转 int 和转 size_t 都是一次整型转换,谁也不比谁更优。long long 实参同样二义。补重载只是把冲突从一个位置挪到另一个位置。
正解是给模板加约束:实参是整数时,让它干脆不参与重载。
cpp
#include <type_traits>
template<class InputIterator,
class = typename std::enable_if<
!std::is_integral<InputIterator>::value>::type>
list(InputIterator first, InputIterator last)
{
empty_init();
while (first != last)
{
push_back(*first);
++first;
}
}
std::is_integral<int>::value 为真,enable_if<false> 里没有 type 成员,模板推导失败,候选被静默移除,这就是 SFINAE。整数实参面前只剩 list(size_t, T);指针、数组迭代器面前只剩模板。
C++20 可以写得更直白:
cpp
#include <iterator>
template<std::input_iterator InputIterator>
list(InputIterator first, InputIterator last);
标准库的 std::list 用的就是这类约束,所以它既能 std::list<int> l(5, 10),也能 std::list<int> l(a, a + 4)。
约束之后,负数也没了拦截。list<int> lt(-5, 10) 里的 -5 会转换成 size_t,得到一个接近 2^64 的数:
cpp
std::size_t n = -5; // 18446744073709551611
循环会在耗尽内存时抛 bad_alloc。想拦负数,在构造函数体里判断,而不是靠增加一个 int 重载------整型转换是标准允许的,编译器没有理由拦。
💡 让重载决议少一个候选,比让两个候选争胜负更稳。约束模板是前者,补
int重载是后者。
initializer_list:花括号怎么变成 list
list<int> lt = {1, 2, 3}; 能编过,靠的是 C++11 的 std::initializer_list<T>。
它是个轻量视图:两个指针分别指向一段 const T 数组的开头和结尾,size() 就是 last - first。数组由编译器在栈上生成,initializer_list 本身不拥有数据,也不允许修改元素。拷贝一个 initializer_list 只复制这两个指针,代价等同于拷贝两个指针。
构造函数接收它,遍历一遍插进链表:
cpp
list(initializer_list<T> il)
{
empty_init(); // 先把哨兵建出来,push_back 才有地方挂节点
for (auto& e : il) // 逐个取出元素
{
push_back(e); // 每个元素拷贝一份,放进新节点
}
}
三行代码里各有一个要点:
empty_init() 必须在循环之前。 push_back 走 insert(end(), x),要读 _head->_next。_head 还是野指针时,第一条语句就是访问非法地址。
auto& e 推导出来是 const T&。 initializer_list<T>::begin() 返回 const T*,解引用得到 const T&。写 auto e 会多一次拷贝,写 auto& e 不会。
每个元素必须拷贝进节点。 背后那段 const T[N] 数组只活到当前完整表达式结束,initializer_list 手里只有指针。构造函数返回时数组已经销毁,节点里必须是独立的一份数据。
范围 for 展开成普通的迭代器循环,和 il.begin()、il.end()、!=、++、* 一一对应:
cpp
for (auto it = il.begin(); it != il.end(); ++it)
{
push_back(*it);
}
花括号还能隐式转换成 list:
cpp
void f(bit::list<int> lt);
f({1, 2, 3}); // 花括号 → initializer_list → 隐式构造 list
initializer_list 是单参数,构造函数没有加 explicit,因此支持隐式类型转换。vector、string 也有同样的构造函数,这是所有标准容器在 C++11 之后的统一约定。
代价是花括号的语义被这个构造函数接管了。列表初始化优先匹配 initializer_list:
cpp
bit::list<int> a(5, 10); // 5 个 10
bit::list<int> b{5, 10}; // 2 个元素:5 和 10
圆括号和花括号在这里不是风格差异,是两个不同的构造函数。想构造 n 个相同值,必须用圆括号。
反向迭代器:包装一层正向迭代器

反向迭代器的方向是翻转的:++ 对应正向的 --,-- 对应正向的 ++。rbegin() 返回 end() 位置的包装,rend() 返回 begin() 位置的包装。
图中的 rbegin 指向哨兵(即 end),rend 指向第一个数据节点(即 begin)。解引用 rbegin 时不能直接取哨兵的数据,要先把内部的迭代器往前挪一格:
cpp
template<class Iterator>
class ReverseListIterator
{
using Ref = typename Iterator::reference;
using Ptr = typename Iterator::pointer;
public:
using Self = ReverseListIterator<Iterator>;
ReverseListIterator(Iterator it)
: _it(it)
{}
Ref operator*()
{
Iterator tmp(_it);
--tmp; // rbegin 存的是 end,先退回最后一个元素
return *tmp;
}
Ptr operator->() { return &(operator*()); }
Self& operator++()
{
--_it; // 反向的 ++ 是正向的 --
return *this;
}
Self operator++(int)
{
Self tmp(*this);
--_it;
return tmp;
}
Self& operator--()
{
++_it;
return *this;
}
Self operator--(int)
{
Self tmp(*this);
++_it;
return tmp;
}
bool operator!=(const Self& r) const { return _it != r._it; }
bool operator==(const Self& r) const { return _it == r._it; }
private:
Iterator _it;
};
Iterator::reference 是依赖类型,前面必须加 typename,否则编译器分不清它是类型还是静态成员。
链表里补上两个接口:
cpp
using reverse_iterator = ReverseListIterator<iterator>;
reverse_iterator rbegin() { return reverse_iterator(end()); }
reverse_iterator rend() { return reverse_iterator(begin()); }
rbegin 不能返回 end() - 1,因为链表迭代器没有 -;减一只能靠 -- 完成,所以包装类的构造函数直接收下 end(),在解引用时再退一步。
list 与 vector 的对比
| 维度 | vector | list |
|---|---|---|
| 底层结构 | 动态顺序表,一段连续空间 | 带头结点的双向循环链表 |
| 随机访问 | 支持,O(1) | 不支持,O(n) |
| 插入删除 | 任意位置 O(n),可能扩容 | 已知位置 O(1) |
| 空间利用率 | 连续空间,碎片少,缓存友好 | 节点分散,碎片多,缓存不友好 |
| 迭代器 | 原生指针 | 对节点指针的封装 |
| 迭代器失效 | 扩容后全部失效 | 插入不失效,删除只失效被删节点 |
| 使用场景 | 高效存储、随机访问 | 大量插入删除、不关心随机访问 |
表格最后一行是选型依据:需要按下标频繁访问、数据量稳定,用 vector;需要频繁在中间插入删除、且已经持有位置,用 list。绝大多数场景 vector 更快,因为缓存和连续内存的优势通常盖过搬移成本。
常见误区
1. 对 list 迭代器做 +、-。 lt.begin() + 3 编译不过。用循环 ++ 或 std::advance。
2. 用 std::sort 排 list。 它要求随机访问迭代器。用成员 lt.sort()。
3. erase 之后继续 ++it。 节点已释放,行为未定义。用 it = lt.erase(it)。
4. 以为 insert 会让迭代器失效。 链表插入不改变已有节点地址,所有迭代器仍然有效。
5. 对无序链表用 unique。 它只删除相邻重复值,无序时几乎不起作用。
6. 对无序链表用 merge。 前提是两个链表都有序,否则结果没有意义。
7. 以为 merge 是拷贝。 节点被整体转移,源链表会变空。
8. 以为 splice 是插入副本。 它是转移,源链表的那一段会消失。
9. 把 const iterator 当 const_iterator。 前者的自增被禁止,后者禁止的是通过迭代器改数据。
10. 以为 clear 会释放哨兵。 clear 只删数据节点;哨兵由析构函数负责。
11. 拷贝构造里忘记 empty_init。 哨兵没建,push_back 解引用空指针。
12. 以为 erase / insert 的 O(1) 包含查找。 查找是 O(n),O(1) 只针对已经拿到位置的插入删除。
13. 自己实现时只写 list(size_t, T) 和区间构造。 list<int> lt(5, 10) 会被区间模板抢走,报 invalid type argument of unary '*'。给模板加 is_integral 约束,而不是再补一个 int 重载。
14. 用花括号构造 n 个相同值。 list<int> lt{5, 10} 是两个元素,不是五个 10。initializer_list 优先于其它构造函数。
15. 以为哨兵不需要 T 可默认构造。 这份实现的哨兵要执行 T(),存没有默认构造函数的类型会编译失败。
本节要点
list的底层是带头结点的双向循环链表;哨兵让空表、头尾操作都不用特判。- 没有
operator[]、没有capacity;随机访问 O(n),已知位置插入删除 O(1)。 - 迭代器性质由底层结构决定:链表是双向,
vector/string是随机,forward_list是单向。 std::sort要随机迭代器,std::reverse要双向,std::find只要++。- 链表的迭代器必须封装节点指针,重载
*、->、++、--、!=、==。 operator->返回数据地址,it->a实际展开成it.operator->()->a。const_iterator是独立类型,靠Ref模板参数让operator*返回const T&,不是给迭代器对象加const。- 插入不失效迭代器;删除只失效被删节点的迭代器,
erase返回下一个位置。 push_back/push_front/pop_back/pop_front全部复用insert/erase。- 拷贝构造先
empty_init;赋值可用传统写法或 copy-and-swap,swap只交换_head和_size。 initializer_list是两个指针的轻量视图,元素在构造时逐个拷贝进节点;单参数构造因此支持花括号隐式转换。- 花括号优先匹配
initializer_list:lt(5, 10)是五个 10,lt{5, 10}是两个元素。 - 区间构造模板会抢走整数实参;用
is_integral把模板约束掉,比补一个int重载更可靠。 - 前置
++返回引用、后置返回旧值的拷贝;成员函数尾部的const修饰this,和形参上的const不是一回事。 - 大数据量排序,拷进
vector排完再拷回来通常比list::sort更快;性能对比只看 Release。
📖 参考:《数据结构知识库》第一节(线性表:顺序表与链表的对比)
📖 参考:《高质量C++/C编程指南》第 7 章(
new/delete配对)、第 9 章(构造、析构、赋值与 Big-Three)📖 参考:《Effective C++》条款 11(自我赋值)、条款 12(复制每一个成分)
KIRACRIMSON