
第十章:list
一、list 基础认知与底层核心结构
1.1 底层结构本质
list的底层是带头双向循环链表 ,这是链表结构中的最优实现,也是我们数据结构课程中链表实现的参考原型。它属于STL的顺序容器,核心优势是任意位置的插入和删除操作都能达到常数时间O(1),无需像顺序表一样搬移元素。
1.2 与vector的核心差异
- 不支持下标随机访问 :STL没有为
list实现operator[]下标访问。因为链表节点的物理地址不连续,若要实现下标访问,必须遍历链表,时间复杂度为O(N),嵌套使用会导致O(N²)的极低效率,因此STL直接不提供该接口。 - 主力遍历方式为迭代器 :因为不支持随机访问,
list的遍历完全依赖迭代器(正向/反向、const/非const),同时兼容C++11的范围for(底层基于迭代器实现)。 - 全位置插入删除高效 :原生支持
push_front/pop_front头插头删、push_back/pop_back尾插尾删,以及任意位置的insert/erase,所有操作均为O(1)时间复杂度;而vector的头插、中间插入删除需要搬移元素,效率极低。
1.3 常规基础接口
list的基础接口设计与string、vector高度相似,老师快速带过核心部分,结合讲义整理如下:
1.3.1 构造函数
| 构造函数 | 接口说明 |
|---|---|
list<T> lt() |
无参构造,创建空的list |
list<T> lt(n, val) |
填充构造,创建包含n个值为val的list |
list<T> lt(first, last) |
迭代器区间构造,用[first, last)区间的元素创建list |
list<T> lt(const list& lt2) |
拷贝构造,复制已有list的全部元素 |
list<T> lt = {v1, v2, v3...} |
列表初始化(C++11),便捷创建list |
1.3.2 容量相关接口
老师重点强调:list没有reserve和capacity相关接口 。
原因:vector底层是连续数组,需要预分配空间避免频繁扩容;而list是动态节点开辟,用一个开一个、删一个释放一个,不存在预分配空间的需求,因此无需reserve。
仅保留的核心容量接口:
empty():判断list是否为空size():返回list中有效节点的个数resize(n, val):调整list大小,大于当前size则尾插val,小于则删除尾部元素,日常使用频率较低
1.3.3 基础修改与访问接口
- 元素访问:
front()获取首元素的引用、back()获取尾元素的引用,不支持随机位置访问 - 基础修改:
push_front/pop_front、push_back/pop_back、insert/erase、swap、clear,接口逻辑与vector完全一致,且基于链表结构效率更高。
二、list 专属链表特性接口详解
list额外提供了一批针对链表结构的专属接口,这是与vector的核心区别,也是本节课的重点讲解内容。
2.1 专属接口总览
| 接口名称 | 核心功能 | 关键特性 |
|---|---|---|
reverse() |
逆置 | 原地反转链表中所有元素的顺序,不创建新节点 |
sort() |
排序 | 对链表元素进行排序,默认升序,支持自定义比较规则 |
merge() |
有序归并 | 将两个有序链表合并为一个有序链表,原链表清空 |
unique() |
去重 | 删除链表中连续重复的元素,仅保留第一个,需先排序才能全去重 |
remove(val) |
值删除 | 删除链表中所有等于指定值的元素 |
remove_if(条件) |
条件删除 | 删除链表中满足指定条件的所有元素 |
splice() |
节点转移 | 将另一个链表的节点直接转移到当前链表,不拷贝、不销毁节点,效率极高 |
老师重点讲解了unique、remove、sort、splice四个核心接口。 |
2.2 unique 去重接口(先排序,在去重)
- 功能:去除list中的重复元素
- 底层原理:采用双指针算法,前后两个指针同步遍历,仅当元素连续相等时,删除后一个重复节点,直到两指针元素不相等再同时后移。
- 关键注意事项:必须先对list排序,让相同元素连续,才能实现完全去重;若list无序,非连续的重复元素无法被删除。
- 代码示例与详细解释:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<list>
#include<algorithm>
using namespace std;
int main()
{
// 初始化list,包含非连续的重复元素
list<int> lt1 = {1,2,2,2,3,3,4,2,3,5,6};
cout << "原list:";
for (auto e : lt1) // 范围for遍历,底层基于迭代器实现
{
cout << e << " ";
}
cout << endl;
// 错误用法:直接去重,非连续的2、3无法被删除
lt1.unique();
cout << "直接unique后:";
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
// 正确用法:先排序,让相同元素连续,再去重
list<int> lt2 = {1,2,2,2,3,3,4,2,3,5,6};
lt2.sort(); // 先排序,使相同元素连续
lt2.unique(); // 再去重,实现完全去重
cout << "先sort再unique后:";
for (auto e : lt2)
{
cout << e << " ";
}
cout << endl;
return 0;
}
-
输出结果说明:
原list:1 2 2 2 3 3 4 2 3 5 6
直接unique后:1 2 3 4 2 3 5 6
先sort再unique后:1 2 3 4 5 6
2.3 remove 值删除接口
- 功能:删除list中所有等于指定值 的元素,与
erase有本质区别 - 与erase的核心差异:
erase:参数是迭代器,仅删除迭代器指向的单个节点,需要先通过查找获取目标元素的迭代器位置remove:参数是目标值,底层自动遍历list,删除所有匹配该值的节点,无需提前查找- 代码示例与详细解释:
cpp
int main()
{
list<int> lt1 = {1,2,2,2,3,3,4,2,3,5,6};
cout << "原list:";
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
lt1.remove(2); // 删除list中所有值为2的元素
cout << "remove(2)后:";
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
return 0;
}
-
输出结果说明:
原list:1 2 2 2 3 3 4 2 3 5 6
remove(2)后:1 3 3 4 3 5 6
2.4 其他辅助专属接口详解
1. reverse:链表逆置
- 功能:直接将整个 list 中的元素顺序反转,无需手动实现复杂的链表指针逆置逻辑。
- 特点:操作是"原地"进行的,不会创建新的节点,仅修改原有节点的指针指向,效率很高。
- 代码示例:
cpp
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> lt = {1, 2, 3, 4, 5};
cout << "逆置前:";
for (auto e : lt) cout << e << " ";
cout << endl;
// 直接调用 reverse 接口
lt.reverse();
cout << "逆置后:";
for (auto e : lt) cout << e << " ";
cout << endl;
return 0;
}
-
输出结果 :
逆置前:1 2 3 4 5 逆置后:5 4 3 2 1
2. sort:链表排序(本节课深度讲解重点)
虽然在"2.4"中我们先提它,但它的性能对比和底层逻辑 是在后面单独的大章节(第三章、第四章)详细讲的。这里先补充它的基本用法:
- 功能 :对 list 中的元素进行排序,底层采用归并排序实现。
- 默认规则 :默认按升序(从小到大)排序。
- 降序用法 :如果需要降序(从大到小),需要传入比较仿函数
greater<T>()。 - 代码示例:
cpp
#include <iostream>
#include <list>
#include <functional> // 包含 greater 仿函数的头文件
using namespace std;
int main() {
list<int> lt = {5, 2, 8, 1, 3};
// 1. 默认升序排序
lt.sort();
cout << "默认升序:";
for (auto e : lt) cout << e << " ";
cout << endl;
// 2. 传入 greater<int>() 实现降序
lt.sort(greater<int>());
cout << "降序排序:";
for (auto e : lt) cout << e << " ";
cout << endl;
return 0;
}
-
输出结果 :
默认升序:1 2 3 5 8 降序排序:8 5 3 2 1 -
注意 :关于
list::sort和std::sort的区别、以及性能对比,是本节课的核心难点,在笔记的第三章、第四章有极其详细的代码测试和原理解释。
3. merge:有序链表归并
- 功能 :将两个已经有序(不要求严格有序,允许链表中存在重复的相等元素)的 list 归并成一个新的有序 list。
- 核心前提 :参与归并的两个 list 必须本身就是有序的,否则归并结果会出错。
- 行为特点 :归并后,源 list(被合并的那个)会被清空,所有节点都被转移到目标 list 中。
- 代码示例:
cpp
#include <iostream>
#include <list>
using namespace std;
int main() {
// 注意:两个 list 都必须是有序的!
list<int> lt1 = {1, 3, 5};
list<int> lt2 = {2, 4, 6};
cout << "归并前 lt1 的大小:" << lt1.size() << endl;
cout << "归并前 lt2 的大小:" << lt2.size() << endl;
// 把 lt2 归并到 lt1 中
lt1.merge(lt2);
cout << "归并后 lt1:";
for (auto e : lt1) cout << e << " ";
cout << endl;
cout << "归并后 lt1 的大小:" << lt1.size() << endl;
cout << "归并后 lt2 的大小:" << lt2.size() << " (lt2 已被清空)";
return 0;
}
-
输出结果 :
归并前 lt1 的大小:3 归并前 lt2 的大小:3 归并后 lt1:1 2 3 4 5 6 归并后 lt1 的大小:6 归并后 lt2 的大小:0 (lt2 已被清空) -
老师提示:这个接口在实际业务中使用频率较低,因为通常不会用 list 来做大规模的归并逻辑。
4. remove_if:按条件删除
- 功能 :删除 list 中满足特定条件的所有元素。
- 参数要求 :需要传入一个"判断条件",这个条件可以是函数指针 、仿函数 或 Lambda 表达式(C++11)。
- 老师说明:由于当时还没深入讲解"仿函数"和"Lambda",所以课上没展开细讲,但这里可以给你一个最易懂的 Lambda 示例:
- 代码示例:删除 list 中所有的偶数
cpp
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> lt = {1, 2, 3, 4, 5, 6};
cout << "删除前:";
for (auto e : lt) cout << e << " ";
cout << endl;
// 使用 Lambda 表达式作为条件:如果元素是偶数,返回 true,就会被删除
lt.remove_if([](int val) {
return val % 2 == 0;
});
cout << "删除偶数后:";
for (auto e : lt) cout << e << " ";
cout << endl;
return 0;
}
-
输出结果 :
删除前:1 2 3 4 5 6 删除偶数后:1 3 5
三、迭代器分类与核心规则
这是本节课的核心理论知识点,老师通过该部分解答了"为什么list不能用std::sort"的核心问题,也是理解STL算法与容器适配的关键。
3.1 迭代器的核心分类
迭代器的类型由容器的底层结构决定,从功能和移动方式上,分为3个有具体实现的核心实体类型,同时包含2个顶层的抽象概念基类,是STL算法与容器适配的核心依据,也是本节课的核心理论知识点。
3.1.1 三大核心实体迭代器类型
这三类是日常开发中直接接触的迭代器,核心差异是移动能力的强弱,老师课上核心强调:迭代器的分类本质是移动能力的差异,随机迭代器能力最强,单向迭代器能力最弱。
| 迭代器类型 | 支持的核心操作 | 对应典型容器 |
|---|---|---|
| 单向迭代器(Forward) | 仅支持++单向向前移动,支持解引用*(读+写)、->、==、!=,支持对同一位置多次重复遍历 |
forward_list(单链表)、unordered_map/unordered_set |
| 双向迭代器(Bidirectional) | 支持++向前、--向后双向移动,支持解引用*(读+写)、->、==、!= |
list、map/set |
| 随机迭代器(Random Access) | 支持++、--双向移动,额外支持+、-、[]随机地址跳转,支持解引用*(读+写)、->、==、!= |
string、vector、deque |
- list迭代器的类型判定:list底层是双向循环链表,节点物理地址不连续,仅能通过指针向前/向后逐个移动,无法通过
+、-直接跳转到指定节点,因此是双向迭代器,不支持随机访问。
3.1.2 两个顶层抽象迭代器基类

STL迭代器的概念继承体系中,定义了两个无具体容器实体实现的顶层抽象基类,是所有迭代器的能力定义基础,老师课上补充:这两个抽象基类在实际开发中没有直接对应的容器迭代器,仅作为能力规范存在。
-
input(输入迭代器):只读迭代器,抽象基类,核心能力是单向读取元素,仅支持++单向移动、*(只读)、->、==、!=,仅支持单次遍历;const只读迭代器可看作其最贴合的具体实现。 -
output(输出迭代器):只写迭代器,抽象基类,核心能力是单向写入元素,仅支持++单向移动、*(只写,仅用于赋值),仅支持单次遍历,与input迭代器为并列的独立顶层基类,二者无继承关系。 -
标准继承关系与兼容规则:三大核心实体迭代器形成了严格的单链继承体系,可理解为子类是特殊的父类,能力完全覆盖父类,因此能力强的迭代器可以兼容所有要求父类迭代器的算法:
- 单向迭代器继承自input迭代器,完全满足input迭代器的所有能力要求;
- 双向迭代器继承自单向迭代器,在单向能力基础上扩展了反向移动能力;
- 随机迭代器继承自双向迭代器,在双向能力基础上扩展了随机跳转能力。
-
老师课上核心补充:算法的模板参数名称,会直接暗示其要求的迭代器类型,比如要求input迭代器的算法,所有实体迭代器都可兼容;要求双向迭代器的算法,仅双向、随机迭代器可兼容;要求随机迭代器的算法,仅随机迭代器可兼容。
示例:
std::find要求input迭代器,所有容器的迭代器均可使用std::reverse要求双向迭代器,list、vector均可使用std::sort要求随机迭代器,仅string、vector、deque可使用,list无法使用
3.3 算法模板参数的迭代器暗示
STL算法的模板参数名称,直接明确了该算法对迭代器类型的要求,这是STL的设计规范:
std::sort的模板参数是RandomAccessIterator,明确要求随机迭代器std::reverse的模板参数是BidirectionalIterator,明确要求双向迭代器std::find的模板参数是InputIterator,明确要求input迭代器(兼容所有迭代器)
3.4 为什么list不能使用std::sort?
老师核心解答了该问题,核心原因有2点:
- 语法层面:
std::sort底层需要对两个迭代器执行相减操作,来计算元素距离、选取排序基准值,而list的双向迭代器不支持+、-操作,传入后会直接编译报错。 - 算法层面:
std::sort底层是快速排序,快排的核心逻辑依赖随机访问选取基准值,链表不支持随机访问,无法适配快排的算法逻辑,因此list必须自己实现专属的sort接口。
四、list::sort 深度解析与性能对比测试
老师通过两段实测代码,深度解析了list自带sort接口的性能特点,并给出了明确的使用建议。
4.1 list::sort 底层实现
list自带的sort接口,底层采用归并排序实现,而非快排。因为归并排序不需要随机访问,完美适配链表的结构特性,是链表排序的最优算法。
4.2 性能测试1:vector::sort 与 list::sort 直接对比
老师通过test_op1函数,对比了相同数据量下,vector用std::sort和list用自带sort的性能差异。
- 完整测试代码:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<list>
#include<vector>
#include<algorithm>
#include<ctime> // 用于clock计时、rand随机数生成
using namespace std;
void test_op1()
{
srand(time(0)); // 设置随机数种子,保证每次运行数据一致
const int N = 1000000; // 测试数据量:100万条
list<int> lt1;
vector<int> v;
// 生成相同的随机数,同时插入list和vector,保证测试公平
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
lt1.push_back(e);
v.push_back(e);
}
// 测试vector排序耗时
int begin1 = clock();
sort(v.begin(), v.end()); // std::sort,快排实现
int end1 = clock();
// 测试list排序耗时
int begin2 = clock();
lt1.sort(); // list自带sort,归并排序实现
int end2 = clock();
// 输出耗时,单位:毫秒
printf("vector sort 耗时:%d 毫秒\n", end1 - begin1);
printf("list sort 耗时:%d 毫秒\n", end2 - begin2);
}
int main()
{
test_op1();
return 0;
}
- 代码核心逻辑解释:
- 用
srand和time设置随机数种子,保证每次测试的随机数据完全一致; - 定义100万的测试数据量,生成相同的随机数同时插入list和vector,排除数据差异对测试结果的影响;
- 用
clock()函数获取程序运行的时钟周期,精准计算排序操作的耗时,单位为毫秒; - 分别执行vector的
std::sort和list的自带sort,输出耗时对比。
- 老师重点强调的测试注意事项:
- 禁止用Debug版本做性能结论:Debug版本会添加大量调试信息,归并排序底层的递归调用会被严重削弱性能,无法体现真实的算法效率;
- 必须用Release版本测试:Release版本会开启编译器的极致优化,才能体现两个排序算法的真实性能差异。
- 测试结论:
Release版本下,vector的std::sort耗时比list::sort快3倍以上,数据量越大,两者的性能差距越明显。
4.3 性能测试2:拷贝到vector排序再拷贝回,与直接list排序对比
老师通过test_op2函数,验证了一个极端场景:即使把list的数据拷贝到vector,排序后再拷贝回list,总耗时也比直接用list::sort更短。
- 完整测试代码:
cpp
void test_op2()
{
srand(time(0));
const int N = 1000000; // 100万条测试数据
list<int> lt1;
list<int> lt2;
// 生成相同的随机数,分别插入两个list,保证测试公平
for (int i = 0; i < N; ++i)
{
auto e = rand() + i;
lt1.push_back(e);
lt2.push_back(e);
}
// 测试方案1:list拷贝到vector,排序后再拷贝回list
int begin1 = clock();
vector<int> v(lt2.begin(), lt2.end()); // 1. list数据拷贝到vector
sort(v.begin(), v.end()); // 2. vector执行std::sort排序
lt2.assign(v.begin(), v.end()); // 3. 排序后的数据拷贝回list
int end1 = clock();
// 测试方案2:直接用list自带sort排序
int begin2 = clock();
lt1.sort();
int end2 = clock();
// 输出耗时,单位:毫秒
printf("list→vector排序→list 总耗时:%d 毫秒\n", end1 - begin1);
printf("list 直接sort 耗时:%d 毫秒\n", end2 - begin2);
}
int main()
{
test_op2();
return 0;
}
- 代码核心逻辑解释:
- 两个list存入完全相同的100万条随机数,排除数据差异对测试的影响;
- 方案1分三步:通过list迭代器构造vector→vector执行排序→通过assign把排序后的数据拷贝回list,计算三步操作的总耗时;
- 方案2直接调用
list::sort,计算纯排序的耗时; - 输出两个方案的耗时对比。
- 测试结论:
- 100万数据量下,即使加上两次拷贝的耗时,方案1的总耗时依然远低于list直接sort的耗时;
- 数据量在1万及以下时,两者耗时差距极小(小于1毫秒,用户无感知);
- 数据量超过10万时,方案1的性能优势就会明显体现。
4.4 list::sort 最终使用建议
老师给出的明确使用结论:
- 数据量小(万级及以下):可以直接使用
list::sort,使用便捷,性能差异无感知; - 数据量大(十万级及以上):不建议使用list::sort,优先把数据拷贝到vector排序后再拷贝回list,性能更优;
- 业务中如果需要频繁排序,优先选择vector容器,而非list。
4.5 性能测试代码核心基础知识点补充
为了彻底理解测试代码的每一行逻辑,这里针对代码中涉及的3个核心C++基础知识点,结合老师的代码设计做详细拆解讲解。
4.5.1 C++伪随机数生成逻辑(srand、rand、time(0)与+i的作用)
本知识点涉及的头文件:
<cstdlib>:包含srand()和rand()随机数生成函数<ctime>:包含time()时间获取函数和clock()计时函数(计时函数在下一个知识点详解)
代码中用于生成测试数据的随机数逻辑,是C++最经典的伪随机数生成方案,每一行都有明确的设计目的:
srand(time(0))的作用是设置随机数种子。这里要先明确一个核心概念:rand()生成的不是真正的随机数,而是通过固定数学公式计算出的伪随机数序列 。如果种子不变,程序每次运行生成的随机数序列会完全一致,无法满足测试的随机性要求。而time(0)会获取当前系统的秒级时间戳,时间每秒都在变化,因此每次运行程序的种子都不同,能保证生成的随机数序列每次都不一样。rand()的作用是生成一个范围在[0, RAND_MAX]之间的整数,其中RAND_MAX是C++标准库定义的宏,绝大多数编译环境下它的值为32767(即2的15次方减1)。每次调用rand(),都会基于当前设置的种子,生成序列里的下一个随机数。- 老师代码中
rand() + i的设计,是专门针对100万大数据量的测试优化。因为rand()的最大值只有3万多,如果只用rand()生成100万条数据,会出现大量完全重复的数值,不符合真实业务的数据特征。加上从0到999999递增的循环变量i,能让生成的数值整体呈现递增趋势,几乎不会出现完全重复的数值,让排序测试的结果更严谨、更贴合真实业务场景。如果只是简单生成随机数,不加i也完全可以,这里是为了测试的严谨性做的专属优化。
4.5.2 程序耗时计算:clock()函数的使用
本知识点涉及的头文件:
<ctime>:包含clock()程序计时函数
代码里用 clock() 来精准统计排序算法的耗时,这是C++标准库中专门用于测试程序段CPU耗时的工具,比普通的系统时间获取方式更适合算法性能测试。
clock()的核心作用,是获取程序从启动到当前时刻,CPU执行的总时钟周期数,返回值是clock_t类型(本质是长整型)。它统计的是CPU真正消耗在当前程序上的时间,不会被系统后台其他程序的运行干扰,因此能精准反映算法本身的性能。- 代码中的计时逻辑非常清晰:在排序操作执行前,用
begin1 = clock()记录当前的时钟周期数;排序操作执行完成后,立刻用end1 = clock()记录结束时的时钟周期数。end1 - begin1的差值,就是排序操作消耗的总时钟周期数。 - 关于毫秒的转换:C++标准库定义了宏
CLOCKS_PER_SEC,代表每秒对应的时钟周期数,在绝大多数编译环境中这个值固定为1000,因此end - begin的差值可以直接当做毫秒数输出。如果需要更严谨的跨平台写法,可以写成(end - begin) * 1000 / CLOCKS_PER_SEC,最终结果完全一致。 - 这里也要呼应老师课上强调的重点:Debug版本会关闭编译器优化、添加大量调试信息,归并排序的递归调用开销会被严重放大,计时结果完全无法反映算法的真实性能,因此必须用Release版本开启编译器优化后,才能得到可信的性能测试结论。
4.5.3 list与vector的互转逻辑:迭代器构造与assign函数
本知识点涉及的头文件:
<list>:包含std::list容器<vector>:包含std::vector容器
代码里实现了list数据拷贝到vector、排序后再拷贝回list的完整流程,用到了STL容器的两个通用核心操作,这里拆解每一步的用法和设计原因:
-
list数据拷贝到vector:迭代器区间构造
代码中
vector<int> v(lt2.begin(), lt2.end());这行,是STL所有容器都支持的迭代器区间构造函数 。它的作用是通过list的[begin(), end())迭代器区间,把list里的所有元素按顺序完整拷贝到新创建的vector中。这个写法是STL容器间数据互转的标准写法,比手动循环调用
push_back逐个插入更高效,编译器会做内部的内存预分配等优化,同时代码更简洁,只要是符合要求的输入迭代器,都可以用这种方式构造容器,兼容性极强。 -
vector数据拷贝回list:assign函数
代码中
lt2.assign(v.begin(), v.end());这行,是把排序后的vector数据批量赋值回list,也是该场景下的最优解。
assign的核心作用是:先清空list中原有的所有元素,再把传入的迭代器区间[v.begin(), v.end())里的所有元素,按顺序插入到list中,完成全量数据的覆盖赋值。它是STL所有序列容器(string、vector、list、deque)都支持的通用函数,除了迭代器区间的重载,还有另一个常用重载assign(n, val),可以把容器重置为n个值为val的元素。关于你疑问的"能不能用其他方式替代assign",这里分情况明确说明:
- 手动
clear()+ 循环push_back:可以实现,但需要手动写循环,代码更繁琐,而且频繁调用push_back的效率比assign的批量插入更低,不推荐使用; - 直接用赋值运算符
=:不可以,因为list<int>和vector<int>是完全不同的容器类型,C++不支持不同类型的序列容器直接互相赋值,编译会直接报错; - 用
swap交换:不可以,swap函数只能用于两个完全相同类型的容器之间交换数据,list和vector类型不同,无法使用。
综上,
assign是把vector数据批量赋值回list的最简洁、最高效、最符合STL规范的写法。 - 手动
五、splice 接口详解与使用场景
splice是list最具特色的接口,也是老师本节课讲解的最后一个核心知识点,中文常译为"接合/剪切粘贴"。
5.1 splice 核心本质
splice的核心是链表节点的转移(剪切粘贴),而非拷贝:直接把一个链表的节点从原链表中剥离,修改指针链接到目标链表的指定位置,全程不会创建新节点、也不会拷贝数据,单个节点转移的时间复杂度为O(1),效率极高。
- 核心要求:只能用于同类型的list之间,不能用于不同类型的容器;
- 核心规则:将节点转移到目标迭代器
position的之前的位置。
5.2 splice 核心用法与代码示例
老师通过test_list1函数,演示了splice的两种核心用法:跨链表节点全量转移、同链表内节点顺序调整。
- 完整代码与详细解释:
cpp
void test_list1()
{
// 用法1:一个链表的全部节点转移到另一个链表
std::list<int> lt1 = { 1, 2, 3, 4, 5 };
std::list<int> lt2 = { 10, 20, 30, 40 };
// 打印转移前的两个list
cout << "转移前lt1:";
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
cout << "转移前lt2:";
for (auto e : lt2)
{
cout << e << " ";
}
cout << endl;
// 核心操作:把lt2的全部节点,转移到lt1的begin()位置之前
lt1.splice(lt1.begin(), lt2);
// 打印转移后的两个list
cout << "splice后lt1:";
for (auto e : lt1)
{
cout << e << " ";
}
cout << endl;
cout << "splice后lt2:";
for (auto e : lt2)
{
cout << e << " ";
}
cout << endl;
// 用法2:同一个链表内,调整节点的顺序
std::list<int> lt3 = { 1, 2, 3, 4, 5 };
int x;
cout << "请输入要移动到头部的数字:";
cin >> x;
// 查找目标值对应的迭代器位置
auto it = find(lt3.begin(), lt3.end(), x);
if (it != lt3.end()) // 找到目标元素,执行节点转移
{
// 核心操作:把it指向的节点,从lt3中转移到lt3的begin()之前
// 实现效果:把目标节点移动到链表头部
lt3.splice(lt3.begin(), lt3, it);
}
// 打印调整后的lt3
cout << "节点调整后lt3:";
for (auto e : lt3)
{
cout << e << " ";
}
cout << endl;
}
int main()
{
test_list1();
return 0;
}
- 代码运行结果说明:
-
跨链表全量转移部分:
转移前lt1:1 2 3 4 5
转移前lt2:10 20 30 40
splice后lt1:10 20 30 40 1 2 3 4 5
splice后lt2:(空,所有节点都被转移到lt1,原链表无节点剩余) -
同链表节点调整部分:
输入4,输出:节点调整后lt3:4 1 2 3 5
输入3,输出:节点调整后lt3:3 1 2 4 5
5.3 splice 其他重载用法
老师补充了splice的另外两种常用重载形式:
- 转移单个节点:
lt1.splice(pos, lt2, it),把lt2中it迭代器指向的单个节点,转移到lt1的pos位置之前 - 转移区间节点:
lt1.splice(pos, lt2, first, last),把lt2中[first, last)区间的所有节点,转移到lt1的pos位置之前 - 同链表区间调整:支持在同一个list内,转移区间节点,灵活调整链表内元素的顺序。
5.4 splice 核心应用场景
老师重点提到了splice的经典工业级应用场景:LRU缓存(最近最少使用缓存)。
- 场景需求:LRU缓存需要把最近访问的元素移动到链表头部,淘汰尾部最少使用的元素;
- 核心优势:用
splice移动节点,仅需要修改链表指针,无需拷贝数据、删除重建节点,时间复杂度O(1),效率极高,是LRU缓存实现的核心操作。
六、list 与 vector 核心特性全对比
老师结合底层结构,总结了list和vector的核心差异,也是业务中容器选型的核心依据,结合课程讲义整理如下:
| 对比维度 | vector | list |
|---|---|---|
| 底层结构 | 动态连续的顺序表(数组) | 带头结点的双向循环链表 |
| 随机访问 | 支持[]下标随机访问,效率O(1) | 不支持随机访问,访问元素效率O(N) |
| 插入删除 | 任意位置插入删除效率低,需要搬移元素,时间复杂度O(N);仅尾插尾删为O(1) | 任意位置插入删除效率高,无需搬移元素,仅需修改指针,时间复杂度O(1) |
| 空间利用率 | 连续空间,不易产生内存碎片,空间利用率高,缓存利用率高 | 节点动态开辟,小节点易产生内存碎片,空间利用率低,缓存利用率低 |
| 迭代器类型 | 随机迭代器,能力最强 | 双向迭代器,能力中等 |
| 迭代器失效 | 插入元素可能触发扩容,导致所有迭代器失效;删除元素时,当前迭代器失效,需重新赋值 | 插入元素不会导致任何迭代器失效;删除元素时,仅被删除节点的迭代器失效,其他迭代器不受影响 |
| 核心使用场景 | 需要高效随机访问、频繁尾插尾删,不关心中间插入删除效率的场景 | 大量任意位置的插入删除操作,不关心随机访问效率的场景 |
【专题】list类模拟实现
一、课程前置说明与源码查看方法
本节核心目标
搞懂「为什么要学list底层实现」,掌握STL源码的正确阅读方法,提前明确本节课的学习重点。
本节课核心讲解C++ STL中list容器的底层结构与模拟实现,是对C++类、模板、运算符重载、双向链表数据结构知识的综合应用,相比之前的vector/string内容,结构嵌套更多,有一定的学习难度。老师在课程开篇明确了核心学习方法与源码查看技巧。
【为什么一定要学list底层实现?】
很多同学会问:STL里的list直接用就行,为什么要学底层?核心有3个原因:
-
面试必考点:校招、社招C++岗位,list的底层实现、迭代器设计、和vector的区别,是面试官的高频提问点
-
避免使用踩坑:只有懂了底层,才知道为什么list不能随机访问、为什么迭代器失效规则和vector不一样、为什么尾插尾删效率这么高,不会写出崩溃代码
-
理解STL设计思想:list的迭代器设计是STL「封装、泛型」思想的极致体现,吃透它,再看其他容器的源码会一通百通
-
list源码查找方式
- list容器的核心实现包含在
<list>标准头文件中,底层逻辑集中在list.h头文件内 - 老师推荐使用
every search工具快速索引系统中的头文件,该工具会索引全量文件,大幅提升源码查找效率
- 源码阅读核心技巧
- 先抓核心架构,再抠细节实现,不要逐行无差别阅读代码
- 面对多类的项目,先捋清类与类之间的关联关系,再针对性查看具体功能的函数实现
- 工作中面对大型项目时,该阅读习惯能快速帮助理解项目整体框架,避免陷入细节泥潭
- 课程内容说明
- 本节课重点讲解list的底层结构、迭代器设计、核心接口模拟实现,不会过多讲解list的基础使用习题
- 原因:list的基础使用在掌握string/vector后极易上手,且OJ算法题中极少以list作为默认容器,核心难点在底层实现
本节核心总结
- 本节课核心:带头双向循环链表的实现 + 迭代器的封装设计
- 源码阅读原则:先框架,后细节;先关联,后实现
二、STL list底层核心结构确认
本节核心目标
彻底搞懂list的底层物理结构,明白「带头双向循环链表」到底是什么,以及它的核心优势。
2.1 底层结构最终判定
通过分析list构造函数的初始化逻辑与节点定义,最终确认STL list的底层是带头(哨兵位)双向循环链表,这是本节课所有代码实现的基础。
【什么是带头双向循环链表?】
给大家用最直白的方式拆解这个名字,每个词都对应一个核心特性:
- 链表:每个数据都存在一个独立的「节点盒子」里,盒子之间靠指针链接,不是连续的内存
- 双向 :每个盒子里有两个指针,一个指向前一个盒子(前驱指针
_prev),一个指向后一个盒子(后继指针_next),既能往前走,也能往后走 - 循环:最后一个盒子的后继指针,指向第一个盒子;第一个盒子的前驱指针,指向最后一个盒子,首尾相连成一个环
- 带头(哨兵位):额外加了一个「空盒子」,这个盒子不存任何有效数据,只用来标记链表的起点和终点,我们叫它「哨兵位头节点」
文字版结构示意图
- 空链表(只有哨兵位):
[哨兵位头节点] <-> 自己(_next和_prev都指向自己) - 有2个有效数据的链表:
[哨兵位] <-> 节点1(存数据1) <-> 节点2(存数据2) <-> [哨兵位]
【新增通俗类比:哨兵位的作用】
哨兵位就像超市门口的迎宾员:
- 它不买东西(不存有效数据),但永远站在门口,不会消失
- 你想找第一个商品(第一个有效节点),直接找迎宾员身后的人就行
- 你想找最后一个商品(最后一个有效节点),直接找迎宾员身前的人就行
- 哪怕超市里没有商品(空链表),迎宾员也在,你永远不会走到空指针的死胡同里
- 结构判定关键依据:list构造时会创建一个哨兵位头节点,让该节点的
next和prev指针都指向自身,只有带头双向循环链表会采用这种初始化方式。 - 带头双向循环链表的核心优势
- 头插、尾插、任意位置插入/删除的逻辑完全统一,无需单独处理空链表的特殊情况
- 尾节点可直接通过头节点的
prev指针获取,无需遍历整个链表找尾,时间复杂度O(1)
- 源码节点细节说明
- 源码中节点的前后指针定义为
void*类型,老师明确说明该写法无必要,后续需要强制类型转换,反而增加代码复杂度,模拟实现时直接使用节点类型指针更清晰 - 源码中节点空间通过内存池分配,而非直接
malloc/new,模拟实现为了简化逻辑,直接使用new/delete管理节点空间,内存池非本节课重点 - 节点的创建与销毁:
create_node等价于new,会通过定位new完成数据初始化;destroy_node会先调用数据的析构函数,再释放节点空间,避免自定义类型出现内存泄漏
本节核心总结
- list底层 = 带头双向循环链表,哨兵位是核心设计
- 所有插入、删除操作,都不会改变哨兵位的地址,只会修改节点的指针指向
- 面试考点:list和vector的底层结构区别,直接决定了两者的使用场景和效率差异
三、list节点结构的模拟实现
本节核心目标
写出符合STL规范的节点结构,解决模板类型的初始化问题,搞懂为什么用struct而不是class。
3.1 节点类型的选型:struct与class
源码中节点使用struct而非class定义,老师讲解了核心原因:
- 节点的成员变量需要在list类中频繁访问,若使用
class,需要将成员设为public或使用友元,反而增加代码冗余 struct的成员默认访问权限是public,更适合这种无需对外暴露、仅内部频繁访问的类型- 隐性封装:用户使用list时只会接触到迭代器,完全不会接触到节点类型,且不同编译器平台的节点实现细节不同,用户也不会尝试访问节点成员,因此不会破坏封装性
【新增补充:struct和class的唯一区别】
C++中struct和class只有一个区别:默认访问权限不同
- struct的成员和继承,默认是public
- class的成员和继承,默认是private
除此之外,两者没有任何区别,struct也能写构造函数、析构函数、运算符重载,和class完全一致。
3.2 节点结构的代码演进
- 基础结构定义
cpp
template<class T>
struct list_node
{
list_node<T>* _next; // 后继节点指针,指向下一个节点
list_node<T>* _prev; // 前驱节点指针,指向前一个节点
T _data; // 数据域,存用户的有效数据
};
【新增新手避坑指南】
类模板中,list_node自身必须携带模板参数<T>,不能只写list_node*,否则编译器无法识别完整的节点类型,会直接编译报错。
- 构造函数完善(解决初始化问题)
- 问题:哨兵位头节点无需存储有效数据,T是模板参数,类型不确定,无法给固定初始值
- 解决方案:给构造函数提供全缺省参数,以
T()匿名对象作为默认值,无论T是内置类型还是自定义类型,都能完成正确初始化
cpp
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是内置类型,也能写int()吗?
答案是:完全可以!C++对内置类型也支持「值初始化」:
- 内置类型:
int()= 0,double()= 0.0,char()= '\0' - 自定义类型:
T()会调用该类型的默认构造函数,生成一个匿名对象
所以不管用户的list存什么类型,这个构造函数都能正确初始化,不会出错。
- 易错点提醒 :类模板中,
list_node自身需要携带模板参数<T>,否则编译器无法识别完整类型。
本节核心总结
- 节点结构 = 两个节点指针 + 一个数据域 + 全缺省构造函数
- 用struct定义节点,是为了方便list类访问其成员,不会破坏封装
- 全缺省构造函数是模板类的关键,解决了未知类型的初始化问题
四、list迭代器的设计与实现(课程核心重点)
本节核心目标
彻底搞懂迭代器的设计思想,从0到1写出符合STL规范的迭代器,理解3个模板参数的由来,吃透const迭代器的实现原理。
迭代器是STL容器的核心设计,它屏蔽了不同容器的底层结构差异,为所有容器提供了统一的遍历和访问方式,本节课的迭代器实现严格按照需求驱动、循序渐进的方式完成,完全贴合老师的讲课逻辑。
【迭代器到底是什么?】
迭代器的本质,就是「对容器底层访问方式的封装」,给所有容器提供一套完全统一的遍历接口。
给大家举个最直白的类比:
- 你去不同的城市旅游,不管是坐高铁、飞机、轮船,旅游平台都给你提供一套统一的操作:选目的地→买票→出发→到达
- 迭代器就是这个旅游平台:不管容器底层是连续的数组(vector),还是分散的链表(list),都给你提供统一的操作:
*it拿数据、++it到下一个位置、it!=end()判断到没到终点
用户只需要学会一套迭代器的用法,就能遍历所有STL容器,不用关心底层到底是什么结构。
4.1 为什么原生指针不能作为list的迭代器?
对比之前学习的vector/string,老师明确了原生指针无法适配list迭代器的核心原因:
- vector/string的底层是连续的数组,原生指针天然满足迭代器的核心行为:
- 解引用
*p:直接获取当前位置的数据 - 自增
++p:直接移动到下一个元素的内存地址 - 可通过
==/!=判断是否指向同一位置
- list的底层是不连续的独立节点,原生指针无法满足迭代器需求:
- 节点指针解引用
*p,得到的是整个节点对象,而非节点内的_data数据,不符合迭代器的访问需求 - 节点指针自增
++p,仅会让指针地址数值+1,无法跳转到下一个节点(节点物理地址不连续,只有next指针存储了下一个节点的地址) - 节点地址无大小关系,无法通过
<判断遍历终止,只能通过==/!=判断
【原生指针为什么在list里没用?】
- vector的内存,是一排连在一起的房子,门牌号是连续的:1号、2号、3号...你拿着1号房的地址,
+1就能直接走到2号房,原生指针完全够用 - list的内存,是散落在全国各地的房子,每个房子里只写了「下一个房子的地址」和「上一个房子的地址」。你拿着1号房的地址,直接
+1只会走到隔壁的荒地,根本找不到2号房!
所以我们必须把「节点指针」封装成一个类,通过运算符重载,告诉编译器:++it不是地址+1,而是去当前节点_next指针指向的下一个节点。
- 最终结论:必须通过自定义类(struct)对节点指针进行封装,通过运算符重载,让该类的对象具备迭代器的核心行为。
4.2 普通迭代器的基础封装(单模板参数)
老师先实现了最基础的普通迭代器,仅支持可读可写的遍历访问,采用单模板参数设计,核心是重载迭代器的关键运算符。
【新增迭代器的核心行为清单】
我们要让迭代器模拟指针的行为,必须实现这几个核心操作,一个都不能少:
-
能解引用:
*it,拿到节点里的数据,支持修改 -
能自增:
++it/it++,移动到下一个节点 -
能自减:
--it/it--,移动到上一个节点(双向链表) -
能比较:
it1!=it2/it1==it2,判断两个迭代器是否指向同一个节点 -
迭代器类的基础结构
cpp
template<class T>
struct __list_iterator
{
typedef list_node<T> Node; // 重命名节点类型,简化代码
typedef __list_iterator<T> Self; // 重命名当前迭代器类型,简化后续代码
Node* _node; // 核心成员:指向链表节点的指针(迭代器的本质,就是封装了节点指针)
// 构造函数:通过节点指针构造迭代器
__list_iterator(Node* node)
:_node(node)
{}
};
【两个typedef的作用】
typedef list_node<T> Node;:每次写list_node<T>太长了,用Node代替,代码更简洁,不容易写错typedef __list_iterator<T> Self;:把当前迭代器的完整类型名,用Self代替,后面写运算符重载的返回值时,不用再写一长串类型名,比如Self&比__list_iterator<T>&方便太多
- 核心运算符重载1:operator 解引用*
- 需求:迭代器解引用需返回节点中
_data的引用,支持对数据的读写操作 - 关键点:返回引用而非值拷贝,既可以修改节点数据,也能避免自定义类型的拷贝开销
cpp
T& operator*()
{
return _node->_data;
}
【operator*的作用】
*it就相当于「你拿着迭代器这个钥匙,打开节点这个盒子,拿出里面的_data数据」。
返回T&引用的原因:
- 如果返回值,只是返回了数据的拷贝,你修改拷贝不会影响节点里的原数据,不符合我们的使用需求
- 返回引用,就是直接返回节点里的原数据,你修改
*it,就是直接修改节点里的数据,同时避免了自定义类型的拷贝开销
- 核心运算符重载2:operator++ 前置自增
- 需求:
++it让迭代器移动到下一个节点,返回移动后的迭代器本身 - 关键点:返回引用,支持连续自增操作(如
++++it)
cpp
Self& operator++()
{
_node = _node->_next; // 核心:让节点指针指向下一个节点
return *this; // 返回移动后的迭代器本身
}
- 核心运算符重载3:operator++ 后置自增
- 需求:
it++让迭代器移动到下一个节点,返回移动前的迭代器副本 - 关键点:通过
int占位符区分前置与后置自增;返回值而非引用(临时对象出作用域会销毁,无法返回引用)
cpp
Self operator++(int)
{
Self tmp(*this); // 拷贝当前迭代器的状态(记录移动前的地址)
_node = _node->_next; // 移动到下一个节点
return tmp; // 返回移动前的副本
}
【#前置++和后置++的区别】
这是面试高频考点,也是新手最容易搞混的点:
- 前置++:
++it,先移动,再返回,返回的是移动后的自己,所以返回引用 - 后置++:
it++,先返回原来的自己,再移动,所以必须先拷贝一份原来的状态,再移动,最后返回拷贝的临时对象,只能返回值,不能返回引用 - 效率区别:前置++不需要拷贝临时对象,效率比后置++更高,所以遍历容器时,优先用
++it,而不是it++
- 核心运算符重载4:operator-- 前置/后置自减
- 双向链表支持迭代器向前移动,因此需要重载自减运算符,逻辑与自增完全对称
cpp
// 前置--:先往前移动,再返回自己
Self& operator--()
{
_node = _node->_prev;
return *this;
}
// 后置--:先返回原来的自己,再往前移动
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
- 核心运算符重载5:operator!= 与 operator==
- 需求:判断两个迭代器是否指向同一个节点,作为遍历的终止条件
- 关键点:函数添加
const保证const对象也可调用;参数添加const避免修改入参;list迭代器只能用==/!=判断,不可用<(节点地址无大小关系)
cpp
bool operator!=(const Self& s) const
{
return _node != s._node; // 两个迭代器的节点指针不一样,就不相等
}
bool operator==(const Self& s) const
{
return _node == s._node; // 两个迭代器的节点指针一样,就相等
}
【绝对不能用< >比较list迭代器】
很多新手学完vector,会习惯性写for(auto it=lt.begin(); it<lt.end(); ++it),这在list里是100%错误的!
原因:vector的内存是连续的,地址有大小顺序;但list的节点是分散在内存里的,节点1的地址可能是0x100,节点2的地址可能是0x20,你用it<end()判断,循环永远不会结束,甚至直接崩溃。
list迭代器只能用!=和==判断是否到达终点。
4.3 const迭代器的需求与双模板参数引入
完成普通迭代器后,老师讲解了const迭代器的需求,并逐步优化实现,最终通过第二个模板参数解决了代码冗余问题,这是本节课的核心递进逻辑。
- const迭代器的核心需求
- 需求场景:当通过const引用传递list对象时(如打印函数
void print(const list<int>& lt)),需要遍历const list对象,普通迭代器允许修改数据,会打破const的只读限制,因此需要专门的const迭代器。 - 核心要求:const迭代器本身可以修改(支持++/--遍历),但迭代器指向的内容不可修改 ,对应指针中的
const T* p(指向内容不可改,指针本身可改),而非T* const p(指针本身不可改)。
【对比:const迭代器 vs 迭代器const】
90%的新手都会搞混这两个概念,用一个类比彻底搞懂:
- const迭代器(我们需要的):就像博物馆的参观权限
- 你可以在博物馆里随便走(迭代器本身能++/--,可以修改)
- 你只能看展品,不能碰、不能改(指向的内容是const的,不能修改)
- 对应指针:
const T* p,指针本身能改,指向的内容不能改
- 迭代器const(错误的):就像你被锁在一个房间里
- 你不能走动(迭代器本身是const的,不能++/--,无法遍历)
- 你可以随便修改房间里的东西(指向的内容能修改)
- 对应指针:
T* const p,指针本身不能改,指向的内容能改
- 错误的实现方式
- 误区1:在普通迭代器前加const,错误写法
const iterator it = lt.begin();
错误原因:const修饰的是迭代器本身,迭代器无法执行++/--操作,无法完成遍历,不符合const迭代器的需求 - 误区2:给普通迭代器的成员函数加const
错误原因:仅能保证迭代器本身的成员不被修改,依然可以通过*it修改节点的数据,无法实现只读限制
- 第一种正确实现:单独编写const迭代器类
- 设计思路:const迭代器与普通迭代器的唯一区别,是
operator*的返回值为const T&(只读),其余运算符重载逻辑完全一致
cpp
template<class T>
struct __list_const_iterator
{
typedef list_node<T> Node;
typedef __list_const_iterator<T> Self;
Node* _node;
__list_const_iterator(Node* node)
:_node(node)
{}
// 唯一区别:返回const引用,只读不可修改
const T& 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;
}
};
- 在list类中添加const版本的begin/end,返回const迭代器:
cpp
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 普通迭代器
typedef __list_iterator<T> iterator;
// const迭代器
typedef __list_const_iterator<T> const_iterator;
// 普通版本begin/end
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
// const版本begin/end,const对象调用时自动匹配
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end() const
{
return const_iterator(_head);
}
// 其他成员函数省略...
private:
Node* _head;
size_t _size;
};
- 存在的问题:普通迭代器与const迭代器类的代码高度重复,仅
operator*的返回值不同,代码冗余度高,维护成本大,修改一处逻辑需要同步修改两个类。
- 优化实现:双模板参数消除代码冗余
- 设计思路:给迭代器类模板增加第二个模板参数
Ref(Reference引用的缩写) ,专门用来接收operator*的返回值类型,通过传入不同的模板参数,让编译器自动实例化出普通迭代器和const迭代器两个类,无需手写两遍重复代码。
【模板的作用】
模板就像一个万能模具,你给它不同的材料(模板参数),它就给你做出不同的零件。
- 原来我们要做两个模具:一个做普通迭代器,一个做const迭代器,两个模具99%的结构都一样,只有一个地方不一样,非常浪费
- 现在我们只做一个模具,加一个「开关」(第二个模板参数Ref):
- 给开关传
T&,模具就做出普通迭代器,*it返回可读可写的引用 - 给开关传
const T&,模具就做出const迭代器,*it返回只读的const引用
完美解决了代码冗余的问题,不用再写两遍重复的代码了。
cpp
// 双模板参数迭代器,解决const迭代器代码冗余问题
template<class T, class Ref>
struct __list_iterator
{
typedef list_node<T> Node;
typedef __list_iterator<T, Ref> Self; // 自身类型同步更新
Node* _node;
__list_iterator(Node* node)
:_node(node)
{}
// 解引用运算符,返回值由第二个模板参数Ref控制
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;
}
};
- 在list类中,通过模板参数区分普通迭代器和const迭代器:
cpp
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 普通迭代器:Ref传T&,解引用返回可读可写的引用
typedef __list_iterator<T, T&> iterator;
// const迭代器:Ref传const T&,解引用返回只读的const引用
typedef __list_iterator<T, const T&> const_iterator;
// begin/end实现完全不变,无需任何修改
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);
}
// 其他成员函数省略...
private:
Node* _head;
size_t _size;
};
- 核心优势:仅通过一个模板参数,就完美解决了普通迭代器和const迭代器的代码复用问题,没有任何冗余代码,const迭代器的核心需求也完全满足。
4.4 operator->运算符重载与第三个模板参数Ptr引入
解决const迭代器的问题后,老师讲解了迭代器的另一个核心需求:箭头运算符重载,并由此引出了第三个模板参数,完成了STL源码级别的迭代器最终设计。
- operator->的需求场景
当list中存储自定义结构体/类类型时,通过迭代器访问成员变量,只能写(*it)._a1(先解引用得到对象,再.访问成员),写法繁琐,我们希望能像原生指针一样通过it->_a1的方式访问成员,因此需要重载operator->。
cpp
// 示例自定义类型
struct Person
{
string _name;
int _age;
Person(string name, int age)
:_name(name), _age(age)
{}
};
int main()
{
list<Person> lt;
lt.push_back(Person("张三", 20));
lt.push_back(Person("李四", 22));
// 遍历访问结构体成员
list<Person>::iterator it = lt.begin();
while (it != lt.end())
{
// 方式1:先解引用再.访问,可行但写法繁琐
cout << (*it)._name << ":" << (*it)._age << endl;
// 方式2:用->箭头访问,更符合原生指针的使用习惯,需要重载运算符
// cout << it->_name << ":" << it->_age << endl;
++it;
}
return 0;
}
- operator->的基础实现与编译器特殊处理
cpp
// 在双模板参数的迭代器类中,增加箭头运算符重载
T* operator->()
{
return &_node->_data; // 返回节点中数据的地址
}
【编译器对operator->的特殊处理】
这是整个C++语法里最特殊的一个运算符重载,90%的新手都会在这里懵,我们一步一步拆解it->_name到底发生了什么:
- 编译器看到
it->,首先会去找it的类里有没有重载operator->(),找到后调用它 - 调用
operator->()后,得到返回值&_node->_data,也就是Person*类型的指针 - 编译器拿到这个
Person*指针后,自动给你补一个-> ,把代码转换成(返回值)->_name - 所以最终
it->_name,等价于(&_node->_data)->_name,也等价于(*it)._name,结果完全一样
【operator->的作用】
operator->就像一个快递中转站:
- 你写
it->_name,相当于告诉中转站「我要拿Person对象里的_name成员」 - 中转站先帮你拿到Person对象的地址(调用operator->()返回指针)
- 再帮你把快递送到你要的地方(自动补一个->访问成员)
- 你只需要写一次->,剩下的脏活累活编译器都帮你做了,就是为了让代码和原生指针的写法保持一致。
- 新问题:const迭代器的箭头运算符只读限制
普通迭代器的operator->返回T*,能修改结构体成员;但const迭代器要求指向的内容不能修改,它的operator->必须返回const T*,否则会打破const的只读限制。
举个例子:如果const迭代器的operator->返回T*,那你就能写const_iterator it = lt.begin(); it->_age = 30;,直接修改了const对象里的数据,const的只读限制完全被打破了,这是绝对不允许的。
- 最终优化:三模板参数迭代器(STL源码最终方案)
- 设计思路:给迭代器类模板增加第三个模板参数
Ptr(Pointer指针的缩写) ,专门用来控制operator->的返回值类型,完美兼容普通迭代器和const迭代器的箭头访问需求。
【易错强调】
我们重载的operator->,返回的不是节点指针list_node<T>*,而是节点里数据的指针T* ,也就是&_node->_data。
我们的目标是访问「数据的成员」(比如Person的_name),不是访问「节点的成员」(比如_next/_prev),这是所有设计的前提。
问题1:为什么operator->必须返回指针,不能直接返回node/数据值?
核心结论:这不是我们设计的,是C++语法标准对operator->重载的硬性强制规定 ,没有第二种写法。
先给你讲死C++对operator->的语法规则
对于表达式 x->m,C++标准规定了唯一的执行逻辑:
- 如果
x是原生指针:直接等价于(*x).m,用指针访问成员; - 如果
x是自定义类类型(比如我们的迭代器):
- 第一步:编译器会去这个类里找
operator->的重载函数,调用它,拿到返回值ret; - 第二步:编译器会自动把代码转换成
ret->m,重复上面的判断,直到ret是一个原生指针,最终执行原生指针的->成员访问。
简单说:你重载的operator->,必须返回一个「能继续执行->操作的类型」 ------要么是原生指针,要么是另一个重载了operator->的类类型。
我们逐个看你说的几种写法为什么不行
1. 能不能直接返回node(节点对象)?
绝对不行,完全不符合我们的需求,也过不了编译:
- 如果
operator->返回list_node<T>节点对象,那编译器拿到返回值后,会执行节点对象->m; - 节点对象是类类型,不是指针,编译器会继续找
list_node里的operator->,但我们没给节点类重载这个函数,直接编译报错; - 就算你给节点重载了,最终访问的也是节点的
_next/_prev/_data成员,不是我们要的「数据_data里的成员」,完全偏离了迭代器的设计目标。
2. 能不能直接返回T(数据值本身)?
也不行,语法不允许:
- 如果
operator->返回T类型(比如Person),编译器拿到返回值后,会执行Person对象->m; - 自定义类型的对象,只能用
.访问成员,不能用->,->只能给指针用,这里直接语法错误,编译不通过。
3. 能不能像operator*一样返回T&(数据的引用)?
还是不行,语法不支持:
- 引用的本质是「变量的别名」,它和对象本身的用法完全一致,只能用
.访问成员,不能用->; - 你返回
T&,编译器会执行Person&->m,和返回对象一样,直接语法报错。
唯一符合语法规则的写法:返回T*(数据的原生指针)
只有返回原生指针,编译器才能在拿到返回值后,执行原生指针的->成员访问,完美匹配我们的需求:
cpp
// 我们的重载函数
Ptr operator->() // Ptr就是T* / const T*
{
return &_node->_data; // 返回数据的指针
}
你写it->_name,编译器会自动转换成:
cpp
(it.operator->())->_name;
// 等价于 (&_node->_data)->_name;
// 最终等价于 (*it)._name;
完全符合我们模拟原生指针行为的目标。
问题2:和vector的operator[]对比,为什么返回值不一样?
核心结论:这两个运算符的语义、定位、语法规则完全不同,没有任何可比性,不能类比。
我们把两个运算符的本质拆开看:
| 运算符 | 核心语义 | 语法规则 | 设计目标 |
|---|---|---|---|
operator[](下标运算符) |
「获取容器中第n个位置的元素」 | 二元运算符,需要两个操作数(容器对象+下标),返回值无强制要求,按需求返回元素的引用/值 | 让容器能像数组一样,通过下标访问元素 |
operator->(箭头运算符) |
「模拟指针的成员访问行为」 | 一元后缀运算符,语法强制要求返回值必须支持->操作 |
让自定义类型能像原生指针一样,用->访问指向对象的成员 |
补充:vector的迭代器也有operator->,和list的写法完全一致
你觉得vector的[]返回值,其实是vector容器的下标运算符,不是迭代器的箭头运算符。
vector的迭代器(原生指针),天然支持->,比如:
cpp
vector<Person> vec = {Person("张三", 20)};
vector<Person>::iterator it = vec.begin();
// 原生指针天然支持->,等价于 (&(*it))->_name
cout << it->_name << endl;
哪怕是vector的迭代器,如果是自定义封装的(比如部分编译器的实现),它的operator->也必须返回T*,和list的迭代器写法完全一样,没有任何区别。
问题3:it->a1看起来有两个操作数,为什么重载的时候只有it一个?
核心结论:C++中,operator->是一元后缀运算符,它只有1个操作数,就是箭头左边的it;箭头右边的a1,根本不是这个重载函数的操作数/参数。
我们彻底拆解it->a1的执行过程,你就完全懂了
你写的代码:
cpp
it->_name;
编译器会把它拆成完全独立的两步:
-
第一步:处理箭头左边的
it->,调用我们重载的operator->这一步,只有
it是操作数,编译器会调用it.operator->(),拿到返回值Person*类型的指针。这一步,和右边的
_name没有任何关系,哪怕你只写it->(语法上不允许,但编译器的处理逻辑是独立的),也会执行这个函数调用。 -
第二步:编译器自动补全箭头,处理右边的
_name拿到第一步返回的
Person*指针后,编译器会自动给你补一个->,执行原生指针的成员访问:指针->_name。这一步,是C++原生指针的语法,和我们重载的
operator->没有任何关系了,_name是原生->的操作数,不是我们重载函数的。
为什么重载operator->不能加参数?
C++标准明确规定:operator->必须作为类的成员函数重载,且不能有任何参数 。
因为它是一元运算符,只有一个操作数(就是调用它的对象本身,也就是this指针指向的it),右边的成员名,根本不是传给重载函数的参数,编译器不会把a1当成参数传进来。
对比一下你熟悉的运算符,就更清楚了
- 二元运算符(两个操作数) :比如
operator+,a + b,两个操作数a和b,所以重载的时候要带一个参数:
cpp
// 成员函数重载,this是a,参数b是第二个操作数
T operator+(const T& b) const;
- 一元运算符(一个操作数) :比如
operator*(解引用),*it,只有一个操作数it,所以重载的时候无参数:
cpp
// 成员函数重载,this是it,没有第二个参数
T& operator*();
operator->和operator*完全一样,都是一元运算符 ,只有箭头左边的it是它的操作数,右边的内容和它无关。
最终一句话总结
operator->必须返回指针,是C++语法强制要求,只有这样编译器才能完成后续的自动补全操作;- 它和
operator[]语义完全不同,没有可比性,所有容器的迭代器operator->都必须这么实现; - 它是一元运算符,只有箭头左边的迭代器是它的操作数,右边的成员名是编译器后续处理的,和重载函数无关。
【三个模板参数的分工】
现在我们的迭代器模具,有了三个开关,每个开关各司其职,分工明确:
- 第一个模板参数
T:指定list里存的数据类型,告诉迭代器节点里的_data是什么类型 - 第二个模板参数
Ref:控制operator*的返回值类型,决定了解引用后能不能修改数据 - 第三个模板参数
Ptr:控制operator->的返回值类型,决定了箭头访问后能不能修改数据
普通迭代器:两个开关都开,给Ref传T&,给Ptr传T*,可读可写
const迭代器:两个开关都关,给Ref传const T&,给Ptr传const T*,只读不可写
cpp
// 最终的三模板参数迭代器,STL源码标准实现
template<class T, class Ref, class Ptr>
struct __list_iterator
{
typedef list_node<T> Node;
typedef __list_iterator<T, Ref, Ptr> Self; // 自身类型同步更新
Node* _node;
__list_iterator(Node* node)
:_node(node)
{}
// 解引用:由第二个模板参数Ref控制返回值
Ref operator*()
{
return _node->_data;
}
// 箭头访问:由第三个模板参数Ptr控制返回值
Ptr 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;
}
};
- 在list类中,最终的迭代器重定义:
cpp
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 普通迭代器:可读可写,Ref传T&,Ptr传T*
typedef __list_iterator<T, T&, T*> iterator;
// const迭代器:只读不可写,Ref传const T&,Ptr传const T*
typedef __list_iterator<T, const T&, const T*> const_iterator;
// begin/end实现完全不变,无需任何修改
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);
}
// 其他成员函数省略...
private:
Node* _head;
size_t _size;
};
- 模板参数演进总结(完全贴合老师讲课顺序):
- 单模板参数
T:实现基础的普通迭代器,支持可读可写的遍历 - 双模板参数
T, Ref:解决const迭代器的只读需求,消除代码冗余 - 三模板参数
T, Ref, Ptr:解决自定义类型的箭头访问需求,同时兼容const迭代器的只读限制,完全贴合STL源码设计
4.5 迭代器的设计细节与注意事项
- 迭代器的拷贝构造、赋值与析构
- 核心结论:迭代器无需手动实现拷贝构造、赋值运算符重载,也无需编写析构函数
- 原因说明:
- 迭代器仅负责访问/修改链表节点,不管理节点的生命周期,节点的创建与释放由list容器本身负责
- 若迭代器析构时释放节点,会直接破坏链表结构,产生野指针
- 编译器默认生成的浅拷贝(值拷贝)完全符合需求:拷贝迭代器本质是让两个迭代器的节点指针指向同一个节点
- 不存在析构两次的问题,因为迭代器根本不会释放节点空间
【迭代器为什么不用写析构?】
迭代器就是一个「地址本」,里面只记了房子的地址,不是房子本身:
- 你抄了一个地址本(拷贝迭代器),原来的地址本和新的都记着同一个房子的地址,房子不会因为你抄了地址本就多出来一个
- 你把地址本扔了(迭代器析构),房子不会被拆,因为房子是list这个开发商管的,地址本只管记地址,不管拆房子
所以迭代器根本不需要写析构函数,编译器默认的浅拷贝、默认的析构,完全符合我们的需求。
- 为什么不重载operator+与operator-
- 语法上可实现,但STL list并未提供,老师也不建议实现
- 核心原因:list节点物理地址不连续,移动N个位置必须循环N次执行
++/--,时间复杂度O(N),效率极低 - 设计原则:迭代器仅提供高效的操作,避免用户误用低效接口导致性能问题
本节核心总结(面试必背)
- 迭代器的本质:封装节点指针,通过运算符重载模拟原生指针的行为,屏蔽底层结构差异
- 3个模板参数的分工:
T指定数据类型,Ref控制*的返回值,Ptr控制->的返回值 - const迭代器的核心:迭代器本身可修改,指向的内容不可修改,通过const的Ref和Ptr实现
- operator->的特殊处理:编译器会自动补一个->,最终等价于(*it).成员
- 迭代器不需要写析构、拷贝构造,编译器默认生成的完全够用
五、list容器类的框架与核心接口实现
本节核心目标
写出完整的list容器类,实现所有STL标准接口,搞懂插入、删除、深拷贝的核心逻辑,避免踩坑。
在迭代器的基础上,老师按照先搭框架、再完善细节的顺序,实现了list容器的全量核心接口,所有接口均与STL标准规范保持一致,同时演示了代码的演进过程。
5.1 list容器类的完整基础框架
cpp
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 最终的三模板参数迭代器类型重定义
typedef __list_iterator<T, T&, T*> iterator;
typedef __list_iterator<T, const T&, const T*> const_iterator;
// 迭代器核心接口:begin与end,遵循左闭右开区间[begin(), end())
iterator begin()
{
// begin指向第一个有效数据节点(头节点的下一个节点)
return iterator(_head->_next);
}
iterator end()
{
// end指向哨兵位头节点,是最后一个有效节点的下一个位置
return iterator(_head);
}
// const版本的begin/end,供const对象调用
const_iterator begin() const
{
return const_iterator(_head->_next);
}
const_iterator end() const
{
return const_iterator(_head);
}
// 空初始化公共函数
void empty_init();
// 默认构造函数
list();
// initializer_list初始化构造
list(initializer_list<T> il);
// 拷贝构造函数
list(const list<T>& lt);
// 交换接口
void swap(list<T>& lt);
// 赋值运算符重载
list<T>& operator=(list<T> lt);
// 析构函数
~list();
// 清空所有有效数据节点
void clear();
// 获取有效元素个数
size_t size() const;
// 尾插
void push_back(const T& x);
// 头插
void push_front(const T& x);
// 尾删
void pop_back();
// 头删
void pop_front();
// 任意位置插入
void insert(iterator pos, const T& x);
// 任意位置删除
iterator erase(iterator pos);
private:
Node* _head; // 哨兵位头节点指针
size_t _size; // 记录链表有效元素个数
};
- 设计逻辑 :遵循STL容器迭代器的左闭右开区间规则,保证所有容器的遍历逻辑完全统一,同时原生支持C++范围for(范围for底层会被替换为begin/end的迭代器遍历)。
【左闭右开区间】
STL所有容器的迭代器都遵循[begin(), end())左闭右开规则:
- 闭区间
[begin():begin()指向的节点,是有效数据的第一个节点,能解引用 - 开区间
end()):end()指向的节点,是最后一个有效节点的下一个位置,不能解引用
好处:
- 遍历的终止条件非常统一:
while(it != end()) - 空容器的判断非常简单:
begin() == end() - 所有容器的遍历逻辑完全一致,用户不用记不同的规则
5.2 空初始化与默认构造函数
- 空初始化公共函数empty_init
- 作用:抽离公共的初始化逻辑,为链表创建哨兵位头节点,构建空的带头双向循环链表,供多个构造函数复用
cpp
template<class T>
void list<T>::empty_init()
{
_head = new Node; // 创建哨兵位头节点
_head->_next = _head; // 头节点next指向自身
_head->_prev = _head; // 头节点prev指向自身
_size = 0; // 有效元素个数初始化为0
}
- 默认构造函数
cpp
template<class T>
list<T>::list()
{
empty_init();
}
【为什么要抽离empty_init?】
我们的默认构造、拷贝构造、initializer_list构造,都需要先创建哨兵位头节点,初始化空链表。如果不抽离,每个构造函数里都要写一遍相同的代码,非常冗余。抽离成公共函数后,只需要写一遍,所有构造函数都能调用,符合代码复用的原则,也方便后续修改。
5.3 插入接口实现:insert、push_back、push_front
老师先实现了最核心的insert接口,再让头尾插接口复用insert,与STL源码实现逻辑保持一致,同时演示了代码的演进过程。
- insert接口:在指定迭代器位置的前面插入数据
- STL通用规范:insert在pos迭代器指向的节点前面插入新节点
- 核心逻辑:获取pos位置的节点cur、cur的前驱节点prev,将新节点插入到prev与cur之间
- 指针修改注意事项:需先建立新节点与前后节点的链接,最后修改cur的prev指针,避免丢失前驱节点地址
cpp
template<class T>
void list<T>::insert(iterator pos, const T& x)
{
Node* cur = pos._node; // 获取pos位置的节点
Node* prev = cur->_prev; // 获取pos节点的前驱节点
Node* newnode = new Node(x); // 创建新节点
// 建立双向链接关系(顺序不能乱!)
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size; // 有效元素个数+1
}
【拆解:insert的4步指针操作,为什么顺序不能乱?】
很多新手会把指针赋值的顺序写反,导致野指针,我们一步一步讲清楚:
我们要把newnode插入到prev和cur之间,原来的关系是:prev <-> cur
- 第一步:
prev->_next = newnode;让prev的后继指向新节点 - 第二步:
newnode->_prev = prev;让新节点的前驱指向prev - 第三步:
newnode->_next = cur;让新节点的后继指向cur - 第四步:
cur->_prev = newnode;让cur的前驱指向新节点
❌ 为什么不能先写第四步cur->_prev = newnode;?
如果你先把cur的前驱改成newnode,那原来的cur->_prev(也就是prev节点的地址)就丢了!你再也找不到prev节点了,后面的指针操作就全错了,就像你换手机号之前,没把旧手机号里的联系人存下来,就再也联系不上老朋友了。
【带头循环链表的insert有多香?】
不管你是头插(pos=begin())、尾插(pos=end())、中间任意位置插,这个insert逻辑完全通用,不需要单独处理空链表的情况!
- 尾插:pos=end(),cur就是哨兵位,prev就是尾节点,插入到prev和cur之间,就是尾插
- 头插:pos=begin(),cur就是第一个有效节点,prev就是哨兵位,插入到prev和cur之间,就是头插
- 空链表:begin()和end()都是哨兵位,插入到prev和cur之间,就是插入第一个节点,逻辑完全不变
这就是带头双向循环链表的核心魅力,所有插入操作逻辑完全统一,不会出现空指针的边界问题。
- push_back尾插接口
- 初始实现:单独编写尾插逻辑,老师先演示了该版本,后续优化为复用insert
cpp
// 初始版本:单独实现尾插
template<class T>
void list<T>::push_back(const T& x)
{
Node* tail = _head->_prev; // 尾节点是头节点的prev
Node* newnode = new Node(x);
// 建立双向链接
tail->_next = newnode;
newnode->_prev = tail;
newnode->_next = _head;
_head->_prev = newnode;
++_size;
}
- 优化版本:复用insert,end()的前面插入就是尾插,代码更简洁,避免重复逻辑出错
cpp
template<class T>
void list<T>::push_back(const T& x)
{
insert(end(), x);
}
- push_front头插接口
- 复用insert,begin()的前面插入就是头插
cpp
template<class T>
void list<T>::push_front(const T& x)
{
insert(begin(), x);
}
5.4 删除接口实现:erase、pop_back、pop_front、clear
- erase接口:删除指定迭代器位置的节点
- 核心逻辑:获取pos位置的节点cur,找到其前驱prev和后继next,让prev与next直接建立链接,释放cur节点
- 强制断言:必须禁止删除end()位置的哨兵位头节点,否则会破坏链表结构
- 迭代器失效处理:erase会导致pos迭代器失效(节点被释放),因此返回删除位置的下一个节点的迭代器,解决遍历删除的失效问题
cpp
template<class T>
typename list<T>::iterator list<T>::erase(iterator pos)
{
assert(pos != end()); // 断言:禁止删除哨兵位头节点
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* next = cur->_next;
// 重新建立前后节点的链接
prev->_next = next;
next->_prev = prev;
delete cur; // 释放被删除的节点
--_size; // 有效元素个数-1
return iterator(next); // 返回下一个位置的迭代器
}
【为什么erase要返回迭代器?】
因为erase会把pos指向的节点释放掉,pos这个迭代器就失效了(变成野指针了),你不能再对这个失效的迭代器做++、解引用等操作。
所以erase会返回删除位置的下一个有效节点的迭代器,你用这个返回值更新你的迭代器,就能继续安全遍历了。
【正确的遍历删除写法 vs 错误写法】
❌ 错误写法(90%的新手都会踩坑)
cpp
auto it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
lt.erase(it); // 这里it已经失效了,下面的++it是对野指针操作
}
++it; // 不管删不删除都++,删除后it已经失效,程序直接崩溃
}
✅ 正确写法(STL标准规范)
cpp
auto it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
it = lt.erase(it); // 用erase返回的下一个有效迭代器更新it
}
else
{
++it; // 不删除的时候,才往前移动
}
}
- pop_back尾删接口
- 复用erase,删除end()的前一个节点(--end())
cpp
template<class T>
void list<T>::pop_back()
{
erase(--end());
}
- pop_front头删接口
- 复用erase,删除begin()位置的第一个有效节点
cpp
template<class T>
void list<T>::pop_front()
{
erase(begin());
}
- clear接口:清空所有有效数据节点
- 作用:清空链表中所有有效节点,保留哨兵位头节点,支持后续继续插入数据
- 实现逻辑:遍历链表,通过erase逐个删除节点,利用erase的返回值更新迭代器,避免失效
cpp
template<class T>
void list<T>::clear()
{
auto it = begin();
while (it != end())
{
it = erase(it); // 用返回值更新迭代器,避免失效
}
}
【clear和析构的区别】
- clear:只删除所有有效数据节点,保留哨兵位头节点,链表还能用,还能继续插入数据
- 析构函数:先调用clear删除所有有效节点,再删除哨兵位头节点,整个链表彻底销毁,不能再用了
5.5 析构函数
- 作用:释放链表所有节点(包括哨兵位头节点),避免内存泄漏
- 实现逻辑:先调用clear清空所有有效数据节点,再释放哨兵位头节点,最后置空头指针避免野指针
cpp
template<class T>
list<T>::~list()
{
clear(); // 清空所有有效数据节点
delete _head; // 释放哨兵位头节点
_head = nullptr; // 置空指针,避免野指针
}
5.6 size接口
- 两种实现方案对比:
- 遍历计数:每次调用size()都遍历链表统计个数,时间复杂度O(N),效率极低,不推荐
- 维护_size成员变量:插入时++_size,删除时--_size,调用size()直接返回,时间复杂度O(1),STL标准实现方案
cpp
template<class T>
size_t list<T>::size() const
{
return _size;
}
5.7 initializer_list初始化构造
- 作用:支持花括号初始化语法,如
list<int> lt = {1,2,3,4}; - 实现逻辑:先初始化哨兵位头节点,再遍历initializer_list,逐个尾插元素
cpp
template<class T>
list<T>::list(initializer_list<T> il)
{
empty_init();
for (auto& e : il)
{
push_back(e);
}
}
六、迭代器失效问题深度分析
本节核心目标
彻底搞懂list的迭代器失效规则,和vector做对比,面试必背。
老师专门对比了list与vector的迭代器失效差异,明确了list迭代器失效的核心规则,这是面试高频考点。
【迭代器失效到底是什么?】
迭代器失效,本质就是「迭代器里封装的节点指针,变成了野指针」,或者「指针指向的内容已经不是你预期的内容了」。
就像你地址本里记的房子,已经被拆了,变成了荒地,你再拿着这个地址去找,就会出问题(程序崩溃)。
- insert接口的迭代器失效情况
- vector:insert会触发扩容,原空间被释放,所有迭代器全部失效;即使不扩容,插入位置后的元素被移动,对应迭代器也会失效
- list:insert不会导致任何迭代器失效。因为list节点是独立的,insert仅修改节点的指针指向,不会改变任何已有节点的内存地址,所有迭代器均保持有效
- erase接口的迭代器失效情况
- vector:erase会导致删除位置后的所有迭代器失效,因为元素会整体向前移动
- list:erase仅会导致被删除的pos迭代器失效,其他所有迭代器均保持有效,只有pos指向的节点被释放,其余节点地址无变化
【list和vector的迭代器失效区别总结表】
| 操作 | vector迭代器失效情况 | list迭代器失效情况 |
|---|---|---|
| insert插入 | 1. 扩容:所有迭代器全部失效 2. 不扩容:插入位置及之后的迭代器全部失效 | 不会导致任何迭代器失效 |
| erase删除 | 删除位置及之后的所有迭代器全部失效 | 仅被删除位置的迭代器失效,其余全部有效 |
- 正确的遍历删除写法
cpp
// 删除list中所有偶数
list<int>::iterator it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
it = lt.erase(it); // 用erase返回值更新迭代器,避免失效
}
else
{
++it;
}
}
- 易错点提醒:erase后直接对pos迭代器执行++操作,会对已失效的野指针进行操作,导致程序崩溃。
七、list的拷贝构造与赋值运算符重载(深拷贝)
本节核心目标
搞懂浅拷贝的坑,写出正确的深拷贝,掌握现代写法的赋值运算符重载。
7.1 浅拷贝的核心问题
若不手动实现拷贝构造与赋值运算符重载,编译器默认生成的是浅拷贝:两个list对象的_head指针会指向同一个哨兵位头节点,共享同一份链表结构,会引发两个严重问题:
- 一个对象修改链表数据,另一个对象会同步受影响,不符合拷贝的预期
- 两个对象析构时,会对同一块内存空间执行两次释放,导致程序崩溃
- 结论:必须手动实现深拷贝,让两个对象拥有独立的链表结构,互不影响。
【浅拷贝 vs 深拷贝】
- 浅拷贝:两个人共用同一个笔记本,一个人改了内容,另一个人看到的也变了;最后两个人都用完了,都把笔记本扔了(析构),就扔了两次,直接出问题
- 深拷贝:给你一个一模一样的新笔记本,里面的内容和原来的完全一样,但是是独立的;你改你的,我改我的,互不影响;用完各自扔自己的,不会出问题
7.2 拷贝构造函数的实现
- 实现逻辑:先调用empty_init初始化自身的哨兵位头节点,再遍历原链表的所有元素,逐个尾插到当前链表中,完成深拷贝
- 关键点:参数必须加
const和引用,避免拷贝传参的开销,同时保证原链表不被修改
cpp
template<class T>
list<T>::list(const list<T>& lt)
{
empty_init(); // 先初始化自身的哨兵位头节点
for (auto& e : lt) // 遍历原链表
{
push_back(e); // 逐个尾插,完成深拷贝
}
}
【拷贝构造的参数必须传引用!】
拷贝构造的参数必须是const list<T>& lt,绝对不能传值list<T> lt!
原因:传值的时候,需要调用拷贝构造函数来生成临时对象,而拷贝构造函数又要传值,又要调用拷贝构造,无限递归下去,程序直接栈溢出崩溃。
这是C++的语法规定,也是面试高频考点,必须记住。
7.3 赋值运算符重载
- 传统写法
- 实现逻辑:先清空自身原有数据,再遍历原链表逐个尾插元素,需先判断是否为自己给自己赋值,避免清空自身数据后无法拷贝
cpp
template<class T>
list<T>& list<T>::operator=(const list<T>& lt)
{
if (this != <) // 防止自己给自己赋值
{
clear(); // 清空自身原有数据
for (auto& e : lt)
{
push_back(e); // 逐个拷贝元素
}
}
return *this; // 支持连续赋值
}
- 现代写法(推荐)
- 实现逻辑:利用传值传参自动调用拷贝构造生成临时对象,再将自身与临时对象交换,临时对象出作用域会自动析构,释放原有的旧数据
- 优势:代码更简洁、天然防止自赋值、异常安全(拷贝构造失败不会修改原对象)
- 第一步:实现swap交换接口,仅交换核心成员,时间复杂度O(1)
cpp
template<class T>
void list<T>::swap(list<T>& lt)
{
std::swap(_head, lt._head); // 交换两个链表的哨兵位头节点
std::swap(_size, lt._size); // 交换两个链表的元素个数
}
- 第二步:赋值运算符重载现代写法
cpp
template<class T>
list<T>& list<T>::operator=(list<T> lt) // 传值传参,自动调用拷贝构造
{
swap(lt); // 交换自身与临时对象
return *this;
}
【现代写法的赋值重载到底妙在哪里?】
我们一步一步看lt1 = lt2发生了什么:
- 传值传参
list<T> lt:编译器会自动调用拷贝构造函数,用lt2生成一个临时对象lt,这个lt是lt2的深拷贝,和lt2完全独立 swap(lt):把我们自己的lt1,和临时对象lt做交换,lt1现在拿到了lt2的深拷贝数据,临时对象lt拿到了lt1原来的旧数据- 函数结束,临时对象lt出作用域,自动调用析构函数,把lt1原来的旧数据释放掉
- 返回*this,支持连续赋值
妙处:
- 天然防止自赋值:如果是
lt1 = lt1,传值的时候会拷贝一份自己,交换之后也不会有问题 - 异常安全:如果拷贝构造失败,临时对象生成不出来,我们自己的lt1的数据也不会被修改
- 代码更简洁,不用自己写循环,不用手动clear,复用了拷贝构造和析构的代码
八、课程核心重点与高频易错点总结
8.1 课程核心重点(面试必背)
- list底层结构 :STL list的底层是带头双向循环链表,这是所有接口实现的基础,理解该结构后,所有插入、删除逻辑都能迎刃而解。
- 迭代器设计思想:迭代器是对容器底层访问方式的封装,屏蔽了不同容器的底层结构差异,让所有容器拥有统一的遍历方式。
- vector/string:底层内存连续,原生指针即可满足迭代器行为
- list:底层内存不连续,必须通过类封装节点指针,结合运算符重载实现迭代器行为
- 迭代器模板参数的演进逻辑:
- 单模板参数
T:实现基础的普通迭代器,支持可读可写的遍历 - 双模板参数
T, Ref:解决const迭代器的只读需求,消除代码冗余 - 三模板参数
T, Ref, Ptr:解决自定义类型的箭头访问需求,同时兼容const迭代器的只读限制,完全贴合STL源码设计
- const迭代器设计核心 :迭代器本身可修改,指向的内容不可修改;通过
Ref和Ptr两个模板参数,可消除普通与const迭代器的代码冗余。 - 运算符重载规则:
- 前置++/--返回引用,支持连续操作;后置++/--返回值,通过int占位符区分
operator*返回引用,支持修改节点数据operator->返回指针,编译器会做特殊的语法处理,省略一个箭头
- 深拷贝实现:list的拷贝构造与赋值必须实现深拷贝,避免浅拷贝导致的二次释放与数据互相影响;现代写法通过拷贝构造+swap更简洁安全。
- 迭代器失效规则:
- list的insert不会导致任何迭代器失效
- list的erase仅会导致被删除位置的迭代器失效,其余迭代器不受影响
- 遍历删除时,必须用erase的返回值更新迭代器
8.2 高频易错点(新手必看,避免踩坑)
- 节点构造函数必须提供
T()匿名对象作为缺省值,否则模板类型为自定义类型时会编译报错。 - list迭代器遍历只能用
!=判断终止,不可用<,因为节点物理地址不连续,无大小关系。 - erase接口必须添加
assert(pos != end())断言,禁止删除哨兵位头节点,否则会破坏链表结构。 - const迭代器不能通过在迭代器前加const实现,必须通过
operator*返回const引用来实现只读限制。 - 迭代器不负责节点的生命周期,无需编写析构函数,编译器默认的浅拷贝正是所需的行为。
operator->需理解编译器的特殊语法处理,记住其本质是返回数据的指针,编译器自动补全后续的成员访问。- list的size接口必须维护
_size成员变量,不可每次遍历计数,否则会导致效率极低。 - 编译器默认生成的拷贝构造与赋值是浅拷贝,必须手动实现深拷贝,否则析构时会出现二次释放的问题。
- 拷贝构造函数的参数必须传const引用,不能传值,否则会导致无限递归栈溢出。
- erase之后不能再使用原来的pos迭代器,必须用返回值更新,否则就是野指针操作。