引言
在 C++ STL 中,list 是一个非常重要的序列式容器。与 vector 的连续线性空间不同,list 的底层结构是带头结点的双向循环链表。这种结构使得它的插入删除极其高效(O(1)),但无法进行随机访问(O(N))。本文从使用细节、底层模拟实现、迭代器原理和性能对比等多维度进行深度剖析。
第一部分:list 的基本使用与底层结构分析
1.1 底层结构
它是一个环状双向链表。
-
头结点 (
_head) :不存储有效数据,仅作为起始哨兵位,用于简化边界处理。end()迭代器指向的就是这个头结点。 -
begin():指向头结点的下一个节点(即第一个有效数据节点)。 -
end():指向头结点。遍历时it != end()即意味着遍历完整个链表。 -
正向遍历通过
_next指针,反向遍历通过_prev指针。 -
rbegin()返回end()位置的逆向迭代器,rend()返回begin()位置的逆向迭代器。
1.2 构造与初始化
具有多种构造方式:
-
list<int> lt1;构造空链表。 -
list<int> lt2(10, 1);构造 10 个值为 1 的元素。 -
list<int> lt3(v1.begin(), v1.end());用迭代器区间构造。 -
list<int> lt4 = { 1,2,3,4,5,6,1,1,1,1 };使用initializer_list构造。 -
注意 :
list不支持连续内存的随机访问,所以不能像数组一样直接排序,list不能使用std::sort,因为它要求的是随机访问迭代器。
1.3 迭代器的使用
-
迭代器本质上是模拟指针,屏蔽了底层链表的差异,让用户可以用统一的方式遍历容器。
-
list<int>::iterator it4 = lt4.begin();的遍历方式,以及 C++11 的for(auto e : lt4)。 -
使用
reverse可以反向遍历。
cpp
list<int>::iterator it4 = lt4.begin();
while (it4 != lt4.end())
{
cout << *it4 << " ";
++it4;
}
cout << endl;
for (auto e : lt4)
{
cout << e << " ";
}
cout << endl;
1.4 操作接口详解
-
push_back/push_front/pop_back/pop_front:首尾插删。 -
resize:调整容器大小。如果新大小大于当前,则默认补 0 或指定的val(如lt2.resize(20, 2);)。如果新大小小于当前,则截断多余元素。 -
merge:合并两个已排序的 list,合并后依然保持有序。 -
remove:删除所有等于特定值的元素。 -
splice:转移节点。lt2.splice(lt2.begin(), lt2, it);将迭代器it指向的节点转移到lt2.begin()位置。这是 list 非常高效的操作,因为它仅仅改变了指针指向,不涉及数据的拷贝搬移。 -
sort:list自带成员函数sort()(底层通常是归并排序),用于排序链表中元素。
1.5 性能与算法约束
test_op1 和 test_op2 进行了经典的性能测试:
cpp
void test_op1()
{
srand(time(0));
const int N = 100000;
list<int> lt1;
vector<int> v;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
lt1.push_back(e);
v.push_back(e);
}
int begin1 = clock();
// 排序
sort(v.begin(), v.end());
int end1 = clock();
int begin2 = clock();
lt1.sort();
int end2 = clock();
printf("vector sort:%d\n", end1 - begin1);
printf("list sort:%d\n", end2 - begin2);
}
void test_op2()
{
srand(time(0));
const int N = 1000000;
list<int> lt1;
list<int> lt2;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
lt1.push_back(e);
lt2.push_back(e);
}
int begin1 = clock();
// 拷贝vector
vector<int> v(lt2.begin(), lt2.end());
// 排序
sort(v.begin(), v.end());
// 拷贝回lt2
lt2.assign(v.begin(), v.end());
int end1 = clock();
int begin2 = clock();
lt1.sort();
int end2 = clock();
printf("list copy vector sort copy list sort:%d\n", end1 - begin1);
printf("list sort:%d\n", end2 - begin2);
}
-
Vector排序 vs List排序 :
sort(v.begin(), v.end())的速度远快于lt1.sort()。原因在于vector内存连续,CPU 缓存命中率高(Cache 友好),而list的节点零散分布(内存碎片多),缓存命中率低。 -
性能优化技巧 :
test_op2展示了一种"曲线救国 "的优化方案:将 list 元素拷贝到vector,在vector中排序,再通过assign拷回list。这种方式往往比直接对 list 排序更快。
第二部分:list 的模拟实现(list.h 全解析)
2.1 节点结构设计
cpp
template<class T>
struct list_node
{
T _data;
list_node<T>* _next;
list_node<T>* _prev;
// 构造函数
list_node(const T& x = T()) : _data(x), _next(nullptr), _prev(nullptr) {}
};
节点 list_node 存放数据和前后指针。
2.2 迭代器的巧妙设计(核心考点)
为了区分普通迭代器和 const 迭代器,同时又避免重复写两套代码,标准库常采用模板参数化设计:
cpp
template<class T, class Ref, class Ptr>
struct __list_iterator
{
typedef list_node<T> Node;
typedef __list_iterator<T, Ref, Ptr> Self;
Node* _node;
// 核心运算符重载
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
Self& operator++() { _node = _node->_next; return *this; }
Self operator++(int) { Self tmp(*this); _node = _node->_next; return tmp; }
// 同理实现 operator-- 和 operator!=, operator==
Self& operator--()
{
_node = _node->_prev;
return *this;
}
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
bool operator!=(const Self& it) const
{
return _node != it._node;
}
bool operator==(const Self& it) const
{
return _node == it._node;
}
};
-
Ref 和 Ptr 分别决定解引用返回
T&还是const T&。对于list<int>::iterator,Ref是int&;对于const_iterator,Ref是const int&。这完美解决了 const 对象的不可修改问题。 -
operator->的细节 :当存储自定义类型
Pos时,it->_row实际上被编译器自动转换为it.operator->()->_row,其中operator->()返回_node->_data的地址(即Pos*),再通过->访问成员。 -
typename关键字的陷阱 :在
Test.cpp的print函数中:
cpp
template<class T>
void print(const list<T>& lt) {
// typename list<T>::const_iterator it = lt.begin();
auto it = lt.begin(); // C++11 推荐
}
- 因为编译器在解析
list<T>时,并不知道const_iterator是一个类型 还是list<T>的静态成员变量 (静态成员也可以::访问),因此必须加typename明确告诉编译器它是类型。
2.3 list 类的实现细节
- 头结点初始化 (
empty_init):
cpp
void empty_init() {
_head = new Node;
_head->_next = _head;
_head->_prev = _head; // 让自己指向自己,形成环
_size = 0;
}
-
头结点的作用极大,它让
end()永远指向_head,保证了空链表时begin() == end(),也简化了插入删除时对边界(首尾)的判断,无需特判空表。 -
插入 (
insert) 与删除 (erase) :insert先记录前后节点,改变指针指向,并增加_size。erase先记录前后节点,断开连接,删除当前节点,减小_size。重点:
erase必须返回下一个节点的迭代器,否则当前迭代器就失效了。 -
拷贝构造与赋值重载 :
现代写法(传值传参 + 交换):
cpp
list<T>& operator=(list<T> lt) { swap(lt); return *this; }
-
这种写法巧妙利用了拷贝构造产生临时对象
lt,然后交换_head和_size,临时对象析构时自动释放旧空间,异常安全且代码简洁。 -
迭代器失效问题:
-
插入 (insert) :链表节点是动态分配的,插入新节点不会导致原有节点的内存地址改变,因此插入时迭代器不会失效。
-
删除 (erase) :删除的节点空间被释放(
delete),指向该节点的迭代器自然失效。其它节点的迭代器不受影响。 -
在
erase中,代码里用了it = erase(it);来更新迭代器,这是正确的标准写法。
-
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 iterator(next);
return next;
}
void TestListIterator()
{
int array[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
list<int> l(array, array+sizeof(array)/sizeof(array[0]));
auto it = l.begin();
while (it != l.end())
{
l.erase(it++); // it = l.erase(it);
}
}
第三部分:补充知识点
3.1 反向迭代器的实现原理
反向迭代器 ReverseListIterator。反向迭代器并非重写逻辑,而是对正向迭代器进行封装:
-
operator++底层调用正向迭代器的operator--。 -
operator--底层调用正向迭代器的operator++。 -
解引用时:因为
rbegin()指向end()(头结点),为了取到最后一个元素,反向迭代器解引用时,会先让正向迭代器--一步,再解引用。 -
这种设计实现了代码的极致复用,无需单独为反向迭代器写一套节点遍历逻辑。
3.2 异常安全(关键进阶)
在模拟实现的 push_back 等插入操作中,如果 new Node 抛异常(比如内存不足),链表状态应该保持不变。标准库通常采用"先构造节点,再连接指针"的策略。代码中 insert 的逻辑就是先 new 完成构造,再修改指针,这保证了基本异常安全。
3.3 List 与 Vector 的终极对比
| 对比维度 | Vector | List |
|---|---|---|
| 底层结构 | 动态顺序表,一段连续空间 | 带头结点的双向循环链表 |
| 随机访问 | 支持,O(1) | 不支持,O(N) |
| 插入删除 | 任意位置 O(N),可能需要扩容(搬移数据) | 任意位置 O(1),仅需修改指针 |
| 空间利用率 | 连续内存,空间利用率高,缓存友好 | 节点零散分布,容易产生内存碎片,缓存不友好 |
| 迭代器 | 原生指针 | 对节点指针进行封装 |
| 迭代器失效 | 插入可能导致扩容,所有迭代器失效;删除时当前迭代器失效 | 插入时不影响;删除时仅当前迭代器失效 |
| 使用场景 | 高效存储、随机访问、不关心插入删除效率 | 大量插入删除、不关心随机访问 |
3.4 关于 splice 和 merge 的深度理解
-
splice:这是 list 的"杀手锏",它可以常数时间 O(1) 将一个链表中的一段节点挂接到另一个链表中。它不需要拷贝数据,也不释放节点,只是把节点从原链表剥离并接入目标链表,因此效率极高。 -
merge:合并两个有序链表,也是只做指针操作,不创建新节点。
总结
C++ 中的 list 是一个功能强大但容易在使用中踩坑的容器。通过深入分析其底层带头双向循环链表的结构、巧妙设计的 Ref/Ptr 迭代器模板、以及 typename 等语法陷阱,我们能够更自如地驾驭它。虽然它的随机访问极慢,但在需要频繁进行头尾或中间插入删除的场景,以及底层逻辑需要"零拷贝"移动节点(如 splice)时,list 依然是无可替代的绝佳选择。结合代码,将原理与实现对应起来学习,是掌握 STL 源码精髓的必经之路。