第十章:list


第十章:list

一、list 基础认知与底层核心结构

1.1 底层结构本质

list的底层是带头双向循环链表 ,这是链表结构中的最优实现,也是我们数据结构课程中链表实现的参考原型。它属于STL的顺序容器,核心优势是任意位置的插入和删除操作都能达到常数时间O(1),无需像顺序表一样搬移元素。

1.2 与vector的核心差异
  1. 不支持下标随机访问 :STL没有为list实现operator[]下标访问。因为链表节点的物理地址不连续,若要实现下标访问,必须遍历链表,时间复杂度为O(N),嵌套使用会导致O(N²)的极低效率,因此STL直接不提供该接口。
  2. 主力遍历方式为迭代器 :因为不支持随机访问,list的遍历完全依赖迭代器(正向/反向、const/非const),同时兼容C++11的范围for(底层基于迭代器实现)。
  3. 全位置插入删除高效 :原生支持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_frontpush_back/pop_backinsert/eraseswapclear,接口逻辑与vector完全一致,且基于链表结构效率更高。

二、list 专属链表特性接口详解

list额外提供了一批针对链表结构的专属接口,这是与vector的核心区别,也是本节课的重点讲解内容。

2.1 专属接口总览
接口名称 核心功能 关键特性
reverse() 逆置 原地反转链表中所有元素的顺序,不创建新节点
sort() 排序 对链表元素进行排序,默认升序,支持自定义比较规则
merge() 有序归并 两个有序链表合并为一个有序链表,原链表清空
unique() 去重 删除链表中连续重复的元素,仅保留第一个,需先排序才能全去重
remove(val) 值删除 删除链表中所有等于指定值的元素
remove_if(条件) 条件删除 删除链表中满足指定条件的所有元素
splice() 节点转移 将另一个链表的节点直接转移到当前链表,不拷贝、不销毁节点,效率极高
老师重点讲解了uniqueremovesortsplice四个核心接口。
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::sortstd::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迭代器为并列的独立顶层基类,二者无继承关系。

  • 标准继承关系与兼容规则:三大核心实体迭代器形成了严格的单链继承体系,可理解为子类是特殊的父类,能力完全覆盖父类,因此能力强的迭代器可以兼容所有要求父类迭代器的算法:

    1. 单向迭代器继承自input迭代器,完全满足input迭代器的所有能力要求;
    2. 双向迭代器继承自单向迭代器,在单向能力基础上扩展了反向移动能力;
    3. 随机迭代器继承自双向迭代器,在双向能力基础上扩展了随机跳转能力。
  • 老师课上核心补充:算法的模板参数名称,会直接暗示其要求的迭代器类型,比如要求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点:

  1. 语法层面:std::sort底层需要对两个迭代器执行相减操作,来计算元素距离、选取排序基准值,而list的双向迭代器不支持+-操作,传入后会直接编译报错。
  2. 算法层面: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;
}
  • 代码核心逻辑解释:
  1. srandtime设置随机数种子,保证每次测试的随机数据完全一致;
  2. 定义100万的测试数据量,生成相同的随机数同时插入list和vector,排除数据差异对测试结果的影响;
  3. clock()函数获取程序运行的时钟周期,精准计算排序操作的耗时,单位为毫秒;
  4. 分别执行vector的std::sort和list的自带sort,输出耗时对比。
  • 老师重点强调的测试注意事项:
  1. 禁止用Debug版本做性能结论:Debug版本会添加大量调试信息,归并排序底层的递归调用会被严重削弱性能,无法体现真实的算法效率;
  2. 必须用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;
}
  • 代码核心逻辑解释:
  1. 两个list存入完全相同的100万条随机数,排除数据差异对测试的影响;
  2. 方案1分三步:通过list迭代器构造vector→vector执行排序→通过assign把排序后的数据拷贝回list,计算三步操作的总耗时;
  3. 方案2直接调用list::sort,计算纯排序的耗时;
  4. 输出两个方案的耗时对比。
  • 测试结论:
  1. 100万数据量下,即使加上两次拷贝的耗时,方案1的总耗时依然远低于list直接sort的耗时;
  2. 数据量在1万及以下时,两者耗时差距极小(小于1毫秒,用户无感知);
  3. 数据量超过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容器的两个通用核心操作,这里拆解每一步的用法和设计原因:

  1. list数据拷贝到vector:迭代器区间构造

    代码中 vector<int> v(lt2.begin(), lt2.end()); 这行,是STL所有容器都支持的迭代器区间构造函数 。它的作用是通过list的 [begin(), end()) 迭代器区间,把list里的所有元素按顺序完整拷贝到新创建的vector中。

    这个写法是STL容器间数据互转的标准写法,比手动循环调用 push_back 逐个插入更高效,编译器会做内部的内存预分配等优化,同时代码更简洁,只要是符合要求的输入迭代器,都可以用这种方式构造容器,兼容性极强。

  2. 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;
}
  • 代码运行结果说明:
  1. 跨链表全量转移部分:

    转移前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,原链表无节点剩余)

  2. 同链表节点调整部分:
    输入4,输出:节点调整后lt3:4 1 2 3 5
    输入3,输出:节点调整后lt3:3 1 2 4 5

5.3 splice 其他重载用法

老师补充了splice的另外两种常用重载形式:

  1. 转移单个节点:lt1.splice(pos, lt2, it),把lt2中it迭代器指向的单个节点,转移到lt1的pos位置之前
  2. 转移区间节点:lt1.splice(pos, lt2, first, last),把lt2中[first, last)区间的所有节点,转移到lt1的pos位置之前
  3. 同链表区间调整:支持在同一个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个原因:

  1. 面试必考点:校招、社招C++岗位,list的底层实现、迭代器设计、和vector的区别,是面试官的高频提问点

  2. 避免使用踩坑:只有懂了底层,才知道为什么list不能随机访问、为什么迭代器失效规则和vector不一样、为什么尾插尾删效率这么高,不会写出崩溃代码

  3. 理解STL设计思想:list的迭代器设计是STL「封装、泛型」思想的极致体现,吃透它,再看其他容器的源码会一通百通

  4. list源码查找方式

  • list容器的核心实现包含在<list>标准头文件中,底层逻辑集中在list.h头文件内
  • 老师推荐使用every search工具快速索引系统中的头文件,该工具会索引全量文件,大幅提升源码查找效率
  1. 源码阅读核心技巧
  • 先抓核心架构,再抠细节实现,不要逐行无差别阅读代码
  • 面对多类的项目,先捋清类与类之间的关联关系,再针对性查看具体功能的函数实现
  • 工作中面对大型项目时,该阅读习惯能快速帮助理解项目整体框架,避免陷入细节泥潭
  1. 课程内容说明
  • 本节课重点讲解list的底层结构、迭代器设计、核心接口模拟实现,不会过多讲解list的基础使用习题
  • 原因:list的基础使用在掌握string/vector后极易上手,且OJ算法题中极少以list作为默认容器,核心难点在底层实现
本节核心总结
  • 本节课核心:带头双向循环链表的实现 + 迭代器的封装设计
  • 源码阅读原则:先框架,后细节;先关联,后实现

二、STL list底层核心结构确认

本节核心目标

彻底搞懂list的底层物理结构,明白「带头双向循环链表」到底是什么,以及它的核心优势。

2.1 底层结构最终判定

通过分析list构造函数的初始化逻辑与节点定义,最终确认STL list的底层是带头(哨兵位)双向循环链表,这是本节课所有代码实现的基础。

【什么是带头双向循环链表?】

给大家用最直白的方式拆解这个名字,每个词都对应一个核心特性:

  1. 链表:每个数据都存在一个独立的「节点盒子」里,盒子之间靠指针链接,不是连续的内存
  2. 双向 :每个盒子里有两个指针,一个指向前一个盒子(前驱指针_prev),一个指向后一个盒子(后继指针_next),既能往前走,也能往后走
  3. 循环:最后一个盒子的后继指针,指向第一个盒子;第一个盒子的前驱指针,指向最后一个盒子,首尾相连成一个环
  4. 带头(哨兵位):额外加了一个「空盒子」,这个盒子不存任何有效数据,只用来标记链表的起点和终点,我们叫它「哨兵位头节点」
文字版结构示意图
  • 空链表(只有哨兵位):[哨兵位头节点] <-> 自己(_next和_prev都指向自己)
  • 有2个有效数据的链表:[哨兵位] <-> 节点1(存数据1) <-> 节点2(存数据2) <-> [哨兵位]
【新增通俗类比:哨兵位的作用】

哨兵位就像超市门口的迎宾员:

  • 它不买东西(不存有效数据),但永远站在门口,不会消失
  • 你想找第一个商品(第一个有效节点),直接找迎宾员身后的人就行
  • 你想找最后一个商品(最后一个有效节点),直接找迎宾员身前的人就行
  • 哪怕超市里没有商品(空链表),迎宾员也在,你永远不会走到空指针的死胡同里
  1. 结构判定关键依据:list构造时会创建一个哨兵位头节点,让该节点的nextprev指针都指向自身,只有带头双向循环链表会采用这种初始化方式。
  2. 带头双向循环链表的核心优势
  • 头插、尾插、任意位置插入/删除的逻辑完全统一,无需单独处理空链表的特殊情况
  • 尾节点可直接通过头节点的prev指针获取,无需遍历整个链表找尾,时间复杂度O(1)
  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 节点结构的代码演进
  1. 基础结构定义
cpp 复制代码
template<class T>
struct list_node
{
list_node<T>* _next; // 后继节点指针,指向下一个节点
list_node<T>* _prev; // 前驱节点指针,指向前一个节点
T _data; // 数据域,存用户的有效数据
};
【新增新手避坑指南】

类模板中,list_node自身必须携带模板参数<T>,不能只写list_node*,否则编译器无法识别完整的节点类型,会直接编译报错。

  1. 构造函数完善(解决初始化问题)
  • 问题:哨兵位头节点无需存储有效数据,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存什么类型,这个构造函数都能正确初始化,不会出错。
  1. 易错点提醒 :类模板中,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迭代器的核心原因:

  1. vector/string的底层是连续的数组,原生指针天然满足迭代器的核心行为:
  • 解引用*p:直接获取当前位置的数据
  • 自增++p:直接移动到下一个元素的内存地址
  • 可通过==/!=判断是否指向同一位置
  1. list的底层是不连续的独立节点,原生指针无法满足迭代器需求:
  • 节点指针解引用*p,得到的是整个节点对象,而非节点内的_data数据,不符合迭代器的访问需求
  • 节点指针自增++p,仅会让指针地址数值+1,无法跳转到下一个节点(节点物理地址不连续,只有next指针存储了下一个节点的地址)
  • 节点地址无大小关系,无法通过<判断遍历终止,只能通过==/!=判断
【原生指针为什么在list里没用?】
  • vector的内存,是一排连在一起的房子,门牌号是连续的:1号、2号、3号...你拿着1号房的地址,+1就能直接走到2号房,原生指针完全够用
  • list的内存,是散落在全国各地的房子,每个房子里只写了「下一个房子的地址」和「上一个房子的地址」。你拿着1号房的地址,直接+1只会走到隔壁的荒地,根本找不到2号房!
    所以我们必须把「节点指针」封装成一个类,通过运算符重载,告诉编译器:++it不是地址+1,而是去当前节点_next指针指向的下一个节点。
  1. 最终结论:必须通过自定义类(struct)对节点指针进行封装,通过运算符重载,让该类的对象具备迭代器的核心行为。
4.2 普通迭代器的基础封装(单模板参数)

老师先实现了最基础的普通迭代器,仅支持可读可写的遍历访问,采用单模板参数设计,核心是重载迭代器的关键运算符。

【新增迭代器的核心行为清单】

我们要让迭代器模拟指针的行为,必须实现这几个核心操作,一个都不能少:

  1. 能解引用:*it,拿到节点里的数据,支持修改

  2. 能自增:++it/it++,移动到下一个节点

  3. 能自减:--it/it--,移动到上一个节点(双向链表)

  4. 能比较:it1!=it2/it1==it2,判断两个迭代器是否指向同一个节点

  5. 迭代器类的基础结构

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. 核心运算符重载1:operator 解引用*
  • 需求:迭代器解引用需返回节点中_data的引用,支持对数据的读写操作
  • 关键点:返回引用而非值拷贝,既可以修改节点数据,也能避免自定义类型的拷贝开销
cpp 复制代码
T& operator*()
{
return _node->_data;
}
【operator*的作用】

*it就相当于「你拿着迭代器这个钥匙,打开节点这个盒子,拿出里面的_data数据」。

返回T&引用的原因:

  • 如果返回值,只是返回了数据的拷贝,你修改拷贝不会影响节点里的原数据,不符合我们的使用需求
  • 返回引用,就是直接返回节点里的原数据,你修改*it,就是直接修改节点里的数据,同时避免了自定义类型的拷贝开销
  1. 核心运算符重载2:operator++ 前置自增
  • 需求:++it让迭代器移动到下一个节点,返回移动后的迭代器本身
  • 关键点:返回引用,支持连续自增操作(如++++it
cpp 复制代码
Self& operator++()
{
_node = _node->_next; // 核心:让节点指针指向下一个节点
return *this; // 返回移动后的迭代器本身
}
  1. 核心运算符重载3:operator++ 后置自增
  • 需求:it++让迭代器移动到下一个节点,返回移动前的迭代器副本
  • 关键点:通过int占位符区分前置与后置自增;返回值而非引用(临时对象出作用域会销毁,无法返回引用)
cpp 复制代码
Self operator++(int)
{
Self tmp(*this); // 拷贝当前迭代器的状态(记录移动前的地址)
_node = _node->_next; // 移动到下一个节点
return tmp; // 返回移动前的副本
}
【#前置++和后置++的区别】

这是面试高频考点,也是新手最容易搞混的点:

  • 前置++:++it,先移动,再返回,返回的是移动后的自己,所以返回引用
  • 后置++:it++,先返回原来的自己,再移动,所以必须先拷贝一份原来的状态,再移动,最后返回拷贝的临时对象,只能返回值,不能返回引用
  • 效率区别:前置++不需要拷贝临时对象,效率比后置++更高,所以遍历容器时,优先用++it,而不是it++
  1. 核心运算符重载4:operator-- 前置/后置自减
  • 双向链表支持迭代器向前移动,因此需要重载自减运算符,逻辑与自增完全对称
cpp 复制代码
// 前置--:先往前移动,再返回自己
Self& operator--()
{
_node = _node->_prev;
return *this;
}

// 后置--:先返回原来的自己,再往前移动
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
  1. 核心运算符重载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迭代器的需求,并逐步优化实现,最终通过第二个模板参数解决了代码冗余问题,这是本节课的核心递进逻辑。

  1. const迭代器的核心需求
  • 需求场景:当通过const引用传递list对象时(如打印函数void print(const list<int>& lt)),需要遍历const list对象,普通迭代器允许修改数据,会打破const的只读限制,因此需要专门的const迭代器。
  • 核心要求:const迭代器本身可以修改(支持++/--遍历),但迭代器指向的内容不可修改 ,对应指针中的const T* p(指向内容不可改,指针本身可改),而非T* const p(指针本身不可改)。
【对比:const迭代器 vs 迭代器const】

90%的新手都会搞混这两个概念,用一个类比彻底搞懂:

  1. const迭代器(我们需要的):就像博物馆的参观权限
  • 你可以在博物馆里随便走(迭代器本身能++/--,可以修改)
  • 你只能看展品,不能碰、不能改(指向的内容是const的,不能修改)
  • 对应指针:const T* p,指针本身能改,指向的内容不能改
  1. 迭代器const(错误的):就像你被锁在一个房间里
  • 你不能走动(迭代器本身是const的,不能++/--,无法遍历)
  • 你可以随便修改房间里的东西(指向的内容能修改)
  • 对应指针:T* const p,指针本身不能改,指向的内容能改
  1. 错误的实现方式
  • 误区1:在普通迭代器前加const,错误写法const iterator it = lt.begin();
    错误原因:const修饰的是迭代器本身,迭代器无法执行++/--操作,无法完成遍历,不符合const迭代器的需求
  • 误区2:给普通迭代器的成员函数加const
    错误原因:仅能保证迭代器本身的成员不被修改,依然可以通过*it修改节点的数据,无法实现只读限制
  1. 第一种正确实现:单独编写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*的返回值不同,代码冗余度高,维护成本大,修改一处逻辑需要同步修改两个类。
  1. 优化实现:双模板参数消除代码冗余
  • 设计思路:给迭代器类模板增加第二个模板参数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源码级别的迭代器最终设计。

  1. 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;
}
  1. operator->的基础实现与编译器特殊处理
cpp 复制代码
// 在双模板参数的迭代器类中,增加箭头运算符重载
T* operator->()
{
return &_node->_data; // 返回节点中数据的地址
}
【编译器对operator->的特殊处理】

这是整个C++语法里最特殊的一个运算符重载,90%的新手都会在这里懵,我们一步一步拆解it->_name到底发生了什么:

  1. 编译器看到it->,首先会去找it的类里有没有重载operator->(),找到后调用它
  2. 调用operator->()后,得到返回值&_node->_data,也就是Person*类型的指针
  3. 编译器拿到这个Person*指针后,自动给你补一个-> ,把代码转换成(返回值)->_name
  4. 所以最终it->_name,等价于(&_node->_data)->_name,也等价于(*it)._name,结果完全一样
【operator->的作用】

operator->就像一个快递中转站:

  • 你写it->_name,相当于告诉中转站「我要拿Person对象里的_name成员」
  • 中转站先帮你拿到Person对象的地址(调用operator->()返回指针)
  • 再帮你把快递送到你要的地方(自动补一个->访问成员)
  • 你只需要写一次->,剩下的脏活累活编译器都帮你做了,就是为了让代码和原生指针的写法保持一致。
  1. 新问题:const迭代器的箭头运算符只读限制
    普通迭代器的operator->返回T*,能修改结构体成员;但const迭代器要求指向的内容不能修改,它的operator->必须返回const T*,否则会打破const的只读限制。

举个例子:如果const迭代器的operator->返回T*,那你就能写const_iterator it = lt.begin(); it->_age = 30;,直接修改了const对象里的数据,const的只读限制完全被打破了,这是绝对不允许的。

  1. 最终优化:三模板参数迭代器(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++标准规定了唯一的执行逻辑:

  1. 如果x是原生指针:直接等价于(*x).m,用指针访问成员;
  2. 如果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;

编译器会把它拆成完全独立的两步

  1. 第一步:处理箭头左边的it->,调用我们重载的operator->

    这一步,只有it是操作数,编译器会调用it.operator->(),拿到返回值Person*类型的指针。

    这一步,和右边的_name没有任何关系,哪怕你只写it->(语法上不允许,但编译器的处理逻辑是独立的),也会执行这个函数调用。

  2. 第二步:编译器自动补全箭头,处理右边的_name

    拿到第一步返回的Person*指针后,编译器会自动给你补一个->,执行原生指针的成员访问:指针->_name

    这一步,是C++原生指针的语法,和我们重载的operator->没有任何关系了,_name是原生->的操作数,不是我们重载函数的。

为什么重载operator->不能加参数?

C++标准明确规定:operator->必须作为类的成员函数重载,且不能有任何参数

因为它是一元运算符,只有一个操作数(就是调用它的对象本身,也就是this指针指向的it),右边的成员名,根本不是传给重载函数的参数,编译器不会把a1当成参数传进来。

对比一下你熟悉的运算符,就更清楚了
  1. 二元运算符(两个操作数) :比如operator+a + b,两个操作数ab,所以重载的时候要带一个参数:
cpp 复制代码
// 成员函数重载,this是a,参数b是第二个操作数
T operator+(const T& b) const;
  1. 一元运算符(一个操作数) :比如operator*(解引用),*it,只有一个操作数it,所以重载的时候无参数:
cpp 复制代码
// 成员函数重载,this是it,没有第二个参数
T& operator*();
  1. operator->operator*完全一样,都是一元运算符 ,只有箭头左边的it是它的操作数,右边的内容和它无关。
最终一句话总结
  1. operator->必须返回指针,是C++语法强制要求,只有这样编译器才能完成后续的自动补全操作;
  2. 它和operator[]语义完全不同,没有可比性,所有容器的迭代器operator->都必须这么实现;
  3. 它是一元运算符,只有箭头左边的迭代器是它的操作数,右边的成员名是编译器后续处理的,和重载函数无关。

【三个模板参数的分工】

现在我们的迭代器模具,有了三个开关,每个开关各司其职,分工明确:

  1. 第一个模板参数T:指定list里存的数据类型,告诉迭代器节点里的_data是什么类型
  2. 第二个模板参数Ref:控制operator*的返回值类型,决定了解引用后能不能修改数据
  3. 第三个模板参数Ptr:控制operator->的返回值类型,决定了箭头访问后能不能修改数据

普通迭代器:两个开关都开,给RefT&,给PtrT*,可读可写

const迭代器:两个开关都关,给Refconst T&,给Ptrconst 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;
};
  • 模板参数演进总结(完全贴合老师讲课顺序):
  1. 单模板参数T:实现基础的普通迭代器,支持可读可写的遍历
  2. 双模板参数T, Ref:解决const迭代器的只读需求,消除代码冗余
  3. 三模板参数T, Ref, Ptr:解决自定义类型的箭头访问需求,同时兼容const迭代器的只读限制,完全贴合STL源码设计
4.5 迭代器的设计细节与注意事项
  1. 迭代器的拷贝构造、赋值与析构
  • 核心结论:迭代器无需手动实现拷贝构造、赋值运算符重载,也无需编写析构函数
  • 原因说明:
  1. 迭代器仅负责访问/修改链表节点,不管理节点的生命周期,节点的创建与释放由list容器本身负责
  2. 若迭代器析构时释放节点,会直接破坏链表结构,产生野指针
  3. 编译器默认生成的浅拷贝(值拷贝)完全符合需求:拷贝迭代器本质是让两个迭代器的节点指针指向同一个节点
  4. 不存在析构两次的问题,因为迭代器根本不会释放节点空间
【迭代器为什么不用写析构?】

迭代器就是一个「地址本」,里面只记了房子的地址,不是房子本身:

  • 你抄了一个地址本(拷贝迭代器),原来的地址本和新的都记着同一个房子的地址,房子不会因为你抄了地址本就多出来一个
  • 你把地址本扔了(迭代器析构),房子不会被拆,因为房子是list这个开发商管的,地址本只管记地址,不管拆房子
    所以迭代器根本不需要写析构函数,编译器默认的浅拷贝、默认的析构,完全符合我们的需求。
  1. 为什么不重载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()指向的节点,是最后一个有效节点的下一个位置,不能解引用
    好处:
  1. 遍历的终止条件非常统一:while(it != end())
  2. 空容器的判断非常简单:begin() == end()
  3. 所有容器的遍历逻辑完全一致,用户不用记不同的规则
5.2 空初始化与默认构造函数
  1. 空初始化公共函数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
}
  1. 默认构造函数
cpp 复制代码
template<class T>
list<T>::list()
{
empty_init();
}
【为什么要抽离empty_init?】

我们的默认构造、拷贝构造、initializer_list构造,都需要先创建哨兵位头节点,初始化空链表。如果不抽离,每个构造函数里都要写一遍相同的代码,非常冗余。抽离成公共函数后,只需要写一遍,所有构造函数都能调用,符合代码复用的原则,也方便后续修改。

5.3 插入接口实现:insert、push_back、push_front

老师先实现了最核心的insert接口,再让头尾插接口复用insert,与STL源码实现逻辑保持一致,同时演示了代码的演进过程。

  1. 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

  1. 第一步:prev->_next = newnode; 让prev的后继指向新节点
  2. 第二步:newnode->_prev = prev; 让新节点的前驱指向prev
  3. 第三步:newnode->_next = cur; 让新节点的后继指向cur
  4. 第四步: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之间,就是插入第一个节点,逻辑完全不变
    这就是带头双向循环链表的核心魅力,所有插入操作逻辑完全统一,不会出现空指针的边界问题。
  1. 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);
}
  1. 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
  1. 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; // 不删除的时候,才往前移动
}
}
  1. pop_back尾删接口
  • 复用erase,删除end()的前一个节点(--end())
cpp 复制代码
template<class T>
void list<T>::pop_back()
{
erase(--end());
}
  1. pop_front头删接口
  • 复用erase,删除begin()位置的第一个有效节点
cpp 复制代码
template<class T>
void list<T>::pop_front()
{
erase(begin());
}
  1. 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接口
  • 两种实现方案对比:
  1. 遍历计数:每次调用size()都遍历链表统计个数,时间复杂度O(N),效率极低,不推荐
  2. 维护_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迭代器失效的核心规则,这是面试高频考点。

【迭代器失效到底是什么?】

迭代器失效,本质就是「迭代器里封装的节点指针,变成了野指针」,或者「指针指向的内容已经不是你预期的内容了」。

就像你地址本里记的房子,已经被拆了,变成了荒地,你再拿着这个地址去找,就会出问题(程序崩溃)。

  1. insert接口的迭代器失效情况
  • vector:insert会触发扩容,原空间被释放,所有迭代器全部失效;即使不扩容,插入位置后的元素被移动,对应迭代器也会失效
  • list:insert不会导致任何迭代器失效。因为list节点是独立的,insert仅修改节点的指针指向,不会改变任何已有节点的内存地址,所有迭代器均保持有效
  1. erase接口的迭代器失效情况
  • vector:erase会导致删除位置后的所有迭代器失效,因为元素会整体向前移动
  • list:erase仅会导致被删除的pos迭代器失效,其他所有迭代器均保持有效,只有pos指向的节点被释放,其余节点地址无变化
【list和vector的迭代器失效区别总结表】
操作 vector迭代器失效情况 list迭代器失效情况
insert插入 1. 扩容:所有迭代器全部失效 2. 不扩容:插入位置及之后的迭代器全部失效 不会导致任何迭代器失效
erase删除 删除位置及之后的所有迭代器全部失效 仅被删除位置的迭代器失效,其余全部有效
  1. 正确的遍历删除写法
cpp 复制代码
// 删除list中所有偶数
list<int>::iterator it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
it = lt.erase(it); // 用erase返回值更新迭代器,避免失效
}
else
{
++it;
}
}
  1. 易错点提醒:erase后直接对pos迭代器执行++操作,会对已失效的野指针进行操作,导致程序崩溃。

七、list的拷贝构造与赋值运算符重载(深拷贝)

本节核心目标

搞懂浅拷贝的坑,写出正确的深拷贝,掌握现代写法的赋值运算符重载。

7.1 浅拷贝的核心问题

若不手动实现拷贝构造与赋值运算符重载,编译器默认生成的是浅拷贝:两个list对象的_head指针会指向同一个哨兵位头节点,共享同一份链表结构,会引发两个严重问题:

  1. 一个对象修改链表数据,另一个对象会同步受影响,不符合拷贝的预期
  2. 两个对象析构时,会对同一块内存空间执行两次释放,导致程序崩溃
  • 结论:必须手动实现深拷贝,让两个对象拥有独立的链表结构,互不影响。
【浅拷贝 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 赋值运算符重载
  1. 传统写法
  • 实现逻辑:先清空自身原有数据,再遍历原链表逐个尾插元素,需先判断是否为自己给自己赋值,避免清空自身数据后无法拷贝
cpp 复制代码
template<class T>
list<T>& list<T>::operator=(const list<T>& lt)
{
if (this != &lt) // 防止自己给自己赋值
{
clear(); // 清空自身原有数据
for (auto& e : lt)
{
push_back(e); // 逐个拷贝元素
}
}
return *this; // 支持连续赋值
}
  1. 现代写法(推荐)
  • 实现逻辑:利用传值传参自动调用拷贝构造生成临时对象,再将自身与临时对象交换,临时对象出作用域会自动析构,释放原有的旧数据
  • 优势:代码更简洁、天然防止自赋值、异常安全(拷贝构造失败不会修改原对象)
  • 第一步:实现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发生了什么:

  1. 传值传参list<T> lt:编译器会自动调用拷贝构造函数,用lt2生成一个临时对象lt,这个lt是lt2的深拷贝,和lt2完全独立
  2. swap(lt):把我们自己的lt1,和临时对象lt做交换,lt1现在拿到了lt2的深拷贝数据,临时对象lt拿到了lt1原来的旧数据
  3. 函数结束,临时对象lt出作用域,自动调用析构函数,把lt1原来的旧数据释放掉
  4. 返回*this,支持连续赋值

妙处:

  • 天然防止自赋值:如果是lt1 = lt1,传值的时候会拷贝一份自己,交换之后也不会有问题
  • 异常安全:如果拷贝构造失败,临时对象生成不出来,我们自己的lt1的数据也不会被修改
  • 代码更简洁,不用自己写循环,不用手动clear,复用了拷贝构造和析构的代码

八、课程核心重点与高频易错点总结

8.1 课程核心重点(面试必背)
  1. list底层结构 :STL list的底层是带头双向循环链表,这是所有接口实现的基础,理解该结构后,所有插入、删除逻辑都能迎刃而解。
  2. 迭代器设计思想:迭代器是对容器底层访问方式的封装,屏蔽了不同容器的底层结构差异,让所有容器拥有统一的遍历方式。
  • vector/string:底层内存连续,原生指针即可满足迭代器行为
  • list:底层内存不连续,必须通过类封装节点指针,结合运算符重载实现迭代器行为
  1. 迭代器模板参数的演进逻辑
  • 单模板参数T:实现基础的普通迭代器,支持可读可写的遍历
  • 双模板参数T, Ref:解决const迭代器的只读需求,消除代码冗余
  • 三模板参数T, Ref, Ptr:解决自定义类型的箭头访问需求,同时兼容const迭代器的只读限制,完全贴合STL源码设计
  1. const迭代器设计核心 :迭代器本身可修改,指向的内容不可修改;通过RefPtr两个模板参数,可消除普通与const迭代器的代码冗余。
  2. 运算符重载规则
  • 前置++/--返回引用,支持连续操作;后置++/--返回值,通过int占位符区分
  • operator*返回引用,支持修改节点数据
  • operator->返回指针,编译器会做特殊的语法处理,省略一个箭头
  1. 深拷贝实现:list的拷贝构造与赋值必须实现深拷贝,避免浅拷贝导致的二次释放与数据互相影响;现代写法通过拷贝构造+swap更简洁安全。
  2. 迭代器失效规则
  • list的insert不会导致任何迭代器失效
  • list的erase仅会导致被删除位置的迭代器失效,其余迭代器不受影响
  • 遍历删除时,必须用erase的返回值更新迭代器
8.2 高频易错点(新手必看,避免踩坑)
  1. 节点构造函数必须提供T()匿名对象作为缺省值,否则模板类型为自定义类型时会编译报错。
  2. list迭代器遍历只能用!=判断终止,不可用<,因为节点物理地址不连续,无大小关系。
  3. erase接口必须添加assert(pos != end())断言,禁止删除哨兵位头节点,否则会破坏链表结构。
  4. const迭代器不能通过在迭代器前加const实现,必须通过operator*返回const引用来实现只读限制。
  5. 迭代器不负责节点的生命周期,无需编写析构函数,编译器默认的浅拷贝正是所需的行为。
  6. operator->需理解编译器的特殊语法处理,记住其本质是返回数据的指针,编译器自动补全后续的成员访问。
  7. list的size接口必须维护_size成员变量,不可每次遍历计数,否则会导致效率极低。
  8. 编译器默认生成的拷贝构造与赋值是浅拷贝,必须手动实现深拷贝,否则析构时会出现二次释放的问题。
  9. 拷贝构造函数的参数必须传const引用,不能传值,否则会导致无限递归栈溢出。
  10. erase之后不能再使用原来的pos迭代器,必须用返回值更新,否则就是野指针操作。
相关推荐
游乐码2 小时前
C#List
开发语言·c#·list
‎ദ്ദിᵔ.˛.ᵔ₎2 小时前
仿函数使用
c++
Z1Jxxx2 小时前
C++ P1150 Peter 的烟
数据结构·c++·算法
是娇娇公主~2 小时前
线程池:工作窃取线程池WorkingStealingPool
c++·线程池
xyq20242 小时前
jQuery Tooltip:深入解析与最佳实践
开发语言
CheerWWW2 小时前
C++学习笔记——函数指针、Lambda表达式、谨慎使用using namespace std、命名空间
c++·笔记·学习
夜猫子ing2 小时前
如何编写一个CMakelists文件
开发语言·c++
踮起脚看烟花2 小时前
chapter10_泛型算法
c++·算法
笨笨饿2 小时前
# 52_浅谈为什么工程基本进入复数域?
linux·服务器·c语言·数据结构·人工智能·算法·学习方法