C++ list 深度解析:从使用原理到模拟实现

引言

在 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 非常高效的操作,因为它仅仅改变了指针指向,不涉及数据的拷贝搬移。

  • sortlist 自带成员函数 sort()(底层通常是归并排序),用于排序链表中元素。

1.5 性能与算法约束

test_op1test_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;
    }
};
  • RefPtr 分别决定解引用返回 T& 还是 const T&。对于 list<int>::iteratorRefint&;对于 const_iteratorRefconst int&。这完美解决了 const 对象的不可修改问题。

  • operator-> 的细节

    当存储自定义类型 Pos 时,it->_row 实际上被编译器自动转换为 it.operator->()->_row,其中 operator->() 返回 _node->_data 的地址(即 Pos*),再通过 -> 访问成员。

  • typename 关键字的陷阱

    Test.cppprint 函数中:

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 关于 splicemerge 的深度理解

  • splice :这是 list 的"杀手锏",它可以常数时间 O(1) 将一个链表中的一段节点挂接到另一个链表中。它不需要拷贝数据,也不释放节点,只是把节点从原链表剥离并接入目标链表,因此效率极高。

  • merge:合并两个有序链表,也是只做指针操作,不创建新节点。


总结

C++ 中的 list 是一个功能强大但容易在使用中踩坑的容器。通过深入分析其底层带头双向循环链表的结构、巧妙设计的 Ref/Ptr 迭代器模板、以及 typename 等语法陷阱,我们能够更自如地驾驭它。虽然它的随机访问极慢,但在需要频繁进行头尾或中间插入删除的场景,以及底层逻辑需要"零拷贝"移动节点(如 splice)时,list 依然是无可替代的绝佳选择。结合代码,将原理与实现对应起来学习,是掌握 STL 源码精髓的必经之路。

相关推荐
Chester_199921 分钟前
CSP202303C.LDAP
开发语言·c++·蓝桥杯·stl
奋斗吧程序媛30 分钟前
CSV文件导入功能
开发语言·javascript·ecmascript
evans在进步42 分钟前
Java 常用设计模式(二):装饰器、适配器、责任链、模板方法、策略与观察者
java·开发语言·设计模式
小雨笙笙1 小时前
C语言:指针
c语言·开发语言
j7~1 小时前
【C++】《unordered系列关联式容器以及哈希底层、哈希冲突、闭散列与开散列完整解析》
c++·哈希算法·开散列·闭散列·unordered_map·unordered_set·哈希底层结构
旧梦95272 小时前
Java EnumMap 详解:原理、用法与实战
java·开发语言
2402_882893862 小时前
封装红黑树实现map和set —— 从源码到模拟实现
c++·set·map
snow@li3 小时前
Java:微服务项目与单体项目区别、市场占有率全景深度分析
java·开发语言·微服务
吴声子夜歌3 小时前
Java——性能和效率
java·开发语言