【C++ 进阶】list 核心机制解析及 vector 巅峰对决


✨ 把代码写进星轨,
用逻辑丈量宇宙。

导航 链接
个人主页 🏠 星轨初途
基础语言专栏 💻 C语言📚 数据结构
C++ 进阶专栏 🏆 C++学习(竞赛类)⚙️ C++专栏(开发类)
刷题实战专栏 🚀 算法及编程题分享

文章目录

前言

在前几篇文章中,我们深入解析并手撕了 vector 的底层实现,深刻体会到了连续内存带来的极速随机访问体验(时间复杂度 O ( 1 ) O(1) O(1))。但是,vector 也有一个致命的软肋:如果在头部或中间进行插入和删除操作,需要挪动大量数据,效率极低(时间复杂度 O ( N ) O(N) O(N));同时,空间不足时的扩容也会带来较大的性能开销。

为了解决这种"频繁在任意位置增删数据"的痛点,C++ 标准模板库(STL)为我们准备了另一把神兵利器------list(链表)。今天,就让我们一起来揭开它的神秘面纱。

一、list 是什么?

list 的本质是一个带头双向循环链表

如果你在数据结构阶段学过链表,那你一定知道这是链表结构中最复杂、但也最完美、最实用的一种形态。我们来拆解一下这几个关键词:

  • 带头(哨兵位):链表内部维护了一个不存储有效数据的"哨兵位节点(头节点)"。它的存在极大地简化了代码逻辑,让你在进行头插、尾插、头删、尾删时,完全不需要去判断链表是否为空,避免了繁琐的空指针检查。
  • 双向 :每个节点除了存储数据外,还包含两个指针:一个指向前一个节点(prev),一个指向后一个节点(next)。这使得链表不仅能从前往后遍历,还能从后往前遍历。
  • 循环 :链表尾节点的 next 指向哨兵位节点,而哨兵位节点的 prev 指向尾节点。它们首尾相连,形成了一个闭环。

底层结构示意图:

text 复制代码
       +---------------------------------------------------------+
       |                                                         |
       v                                                         |
+------------+        +------------+        +------------+       |
|            |  next  |            |  next  |            |       |
|   哨兵位    | -----> |   节点 1   | -----> |   节点 2   | ----+  |
| (不存数据)  | <----- | (有效数据)  | <----- | (有效数据)  | <---+
|            |  prev  |            |  prev  |            |
+------------+        +------------+        +------------+ 

🎯 链表(list)的核心特性与优缺点

vector 相比,list 具有截然不同的特性,它们俩可以说是"相爱相杀"的互补关系:

✨ 优点:

  1. 极致的插入/删除效率 :只要拿到了目标位置的迭代器,在任意位置进行插入和删除操作的时间复杂度都是真正的 O ( 1 ) O(1) O(1),不需要挪动任何其余元素。
  2. 按需分配,拒绝浪费 :每次插入一个新元素,就单独向系统申请一块该节点大小的内存;删除元素时立刻释放。不存在 vector 那样需要提前预留空间或扩容带来的内存浪费与拷贝开销。
  3. 迭代器极少失效 :在 list 中进行插入操作绝对不会导致原有迭代器失效;进行删除操作时,也只有被删除的那个节点的迭代器会失效,其余节点的迭代器依然坚挺(而 vector 一旦扩容,所有迭代器全部报废)。

🧨 缺点:

  1. 不支持随机访问 :这也是它最大的硬伤。由于内存物理空间不连续,list 无法像数组那样使用 operator[](即 list[i])来直接访问第 i 个元素。要想找某个元素,只能老老实实从头(或尾)开始遍历,时间复杂度为 O ( N ) O(N) O(N)。
  2. CPU 高速缓存命中率极低 :由于节点是随用随申请的,它们在内存中就像是漫天散落的星星,物理地址相差甚远,这导致 CPU 缓存预取数据的机制彻底失效,遍历速度在底层硬件级别上远不如 vector
  3. 空间开销较大:每个节点除了存放数据,还需要额外存放至少两个指针(在 64 位系统下就是 16 字节的额外开销),存储密度低。

🔗 官方参考文档: cplusplus - list 详解

二、list常见构造方式

构造函数 ( (constructor)) 接口说明
list (size_type n, const value_type& val = value_type()) 构造的 list 中包含 n 个值为 val 的元素
list() 构造空的 list
list (const list& x) 拷贝构造函数
list (InputIterator first, InputIterator last) 用 [first, last) 区间中的元素构造 list
cpp 复制代码
#include <iostream>
#include <list>
#include <vector>

using namespace std;

int main() {
    // 1. 无参构造:list()
    // 结果: 创建一个空的链表
    list<int> l1; 

    // 2. 填充构造:list(size_type n, const value_type& val)
    // 结果: l2 包含 5 个值为 10 的元素 {10, 10, 10, 10, 10}
    list<int> l2(5, 10); 

    // 3. 拷贝构造:list(const list& x)
    // 结果: l3 深拷贝 l2 的数据,包含 {10, 10, 10, 10, 10}
    list<int> l3(l2); 

    // 4. 迭代器区间构造:list(InputIterator first, InputIterator last)
    // 结果: 使用 vector 的迭代器区间初始化,l4 包含 {1, 2, 3}
    vector<int> v = {1, 2, 3};
    list<int> l4(v.begin(), v.end()); 

    return 0;
}

三、list迭代器的使用

由于 list 的底层物理空间不连续,我们不能 使用 [] 来遍历它。迭代器是 list 最核心的遍历方式。

函数声明 接口说明
begin + end 返回第一个元素的迭代器 + 返回最后一个元素下一个位置的迭代器
rbegin + rend 返回第一个元素的 reverse_iterator,即 end 位置;返回最后一个元素下一个位置的 reverse_iterator,即 begin 位置。

以下是正向和反向遍历的简洁代码示例:

C++

cpp 复制代码
#include <iostream>
#include <list>
using namespace std;
int main() 
{
    list<int> lt = {1, 2, 3, 4, 5};

    // ==========================================
    // 1. 正向遍历 (begin + end)
    // ==========================================
    cout << "正向遍历: ";
    // begin() 指向 1,end() 指向 5 的下一个位置(越界标志)
    list<int>::iterator it = lt.begin();
    while (it != lt.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl; // 输出: 1 2 3 4 5

    // ==========================================
    // 2. 反向遍历 (rbegin + rend)
    // ==========================================
    cout << "反向遍历: ";
    // rbegin() 指向最后一个元素 5,rend() 指向第一个元素 1 的前一个位置
    list<int>::reverse_iterator rit = lt.rbegin();
    while (rit != lt.rend()) 
    {
        cout << *rit << " ";
        ++rit; // 注意:反向迭代器执行 ++ 操作,实际上是向链表头部移动
    }
    cout << endl; // 输出: 5 4 3 2 1

    return 0;
}

补充小贴士: 在现代 C++ 中,如果你只需要顺序读取数据,强烈建议直接使用范围 for 循环for (auto e : lt)),它的底层其实就是用 beginend 迭代器实现的,代码会更加清爽。

四、list容量和元素访问

函数声明 接口说明
empty 检测 list 是否为空,是返回 true,否则返回 false
size 返回 list 中有效节点的个数
front 返回 list 的第一个节点中值的引用
back 返回 list 的最后一个节点中值的引用
  • 💡 代码实战演示
cpp 复制代码
#include <iostream>
#include <list>
using namespace std;
int main()
{
    list<int> lt = {10, 20, 30, 40};

    // ==========================================
    // 1. 容量接口测试 (Capacity)
    // ==========================================
    cout << "list 是否为空: " << (lt.empty() ? "是" : "否") << endl; // 输出: 否
    cout << "list 的有效节点数: " << lt.size() << endl;              // 输出: 4

    // ==========================================
    // 2. 元素访问接口测试 (Element Access)
    // ==========================================
    cout << "第一个元素 (front): " << lt.front() << endl;            // 输出: 10
    cout << "最后一个元素 (back): " << lt.back() << endl;            // 输出: 40

    // 【重点避坑/进阶】:因为返回的是引用,可以直接修改头尾的值
    lt.front() = 100; 
    lt.back()  = 400; 
    
    cout << "修改后第一个元素: " << lt.front() << endl;              // 输出: 100
    cout << "修改后最后一个元素: " << lt.back() << endl;             // 输出: 400

    return 0;
}

五、list的增删查改

函数声明 接口说明
push_front 在 list 首元素前插入值为 val 的元素
pop_front 删除 list 中第一个元素
push_back 在 list 尾部插入值为 val 的元素
pop_back 删除 list 中最后一个元素
insert 在 list position 位置中插入值为 val 的元素
erase 删除 list position 位置的元素
swap 交换两个 list 中的元素
clear 清空 list 中的有效元素
  • 💡 代码实战演示

由于 list 是双向链表,它的头插、头删效率和尾插、尾删一样,都是 O ( 1 ) O(1) O(1),这点远胜于 vector

cpp 复制代码
#include <iostream>
#include <list>
#include <algorithm> // 为了使用 find
using namespace std;
// 辅助打印函数
void print_list(const list<int>& lt, const string& name) 
{
    cout << name << ": ";
    for (auto e : lt) 
    {
        cout << e << " ";
    }
    cout << endl;
}

int main()
{
    list<int> lt;

    // ==========================================
    // 1. 头尾插入与删除 (push / pop)
    // ==========================================
    lt.push_back(10);   // 尾插 10
    lt.push_back(20);   // 尾插 20
    lt.push_front(5);   // 头插 5
    lt.push_front(1);   // 头插 1
    print_list(lt, "四次插入后"); // 输出: 1 5 10 20

    lt.pop_front();     // 头删 (删掉 1)
    lt.pop_back();      // 尾删 (删掉 20)
    print_list(lt, "头尾各删一次后"); // 输出: 5 10

    // ==========================================
    // 2. 指定位置插入与删除 (insert / erase)
    // ==========================================
    // 注意:list 没有 find 成员函数,需要使用 <algorithm> 里的全局 find
    auto pos = find(lt.begin(), lt.end(), 10); 
    
    if (pos != lt.end()) 
    {
        // 在 10 的前面插入 8
        lt.insert(pos, 8); 
    }
    print_list(lt, "在 10 前面插入 8"); // 输出: 5 8 10

    // 再找一次 8,把它删掉
    pos = find(lt.begin(), lt.end(), 8);
    if (pos != lt.end()) 
    {
        lt.erase(pos);
    }
    print_list(lt, "删除 8 之后"); // 输出: 5 10

    // ==========================================
    // 3. 交换与清空 (swap / clear)
    // ==========================================
    list<int> lt2 = {100, 200, 300};
    
    // 交换 lt 和 lt2 的内容 (极速交换,只换指针不拷数据)
    lt.swap(lt2); 
    print_list(lt, "swap 后的 lt");  // 输出: 100 200 300
    print_list(lt2, "swap 后的 lt2"); // 输出: 5 10

    // 清空 lt (保留哨兵位,只是干掉有效节点)
    lt.clear(); 
    cout << "clear 后 lt 的大小: " << lt.size() << endl; // 输出: 0

    return 0;
}

六、常见算法操作

在 C++ 中,很多算法都定义在 <algorithm> 头文件中。但是,由于 list 的底层是物理空间不连续的链表,它不支持随机访问迭代器 。因此,对于需要大量跳跃访问的算法(如排序),list 无法使用全局版本的算法,而是在类内部自己实现了这些专用的成员函数

  • list 常见算法接口
函数声明 / 算法 接口说明
std::find (全局) 查找值为 val 的元素,返回该位置的迭代器。若未找到,返回 end()
reverse (成员) list 中的元素逆序排列。
sort (成员) list 中的元素进行升序排序。
unique (成员) 去除 list连续的 重复元素(通常配合 sort 一起使用)。

⚠️ 【重要避坑】:为什么 list 必须用自己的 sort()

<algorithm> 库中的 std::sort 底层使用的是快速排序等算法,要求容器必须支持随机访问迭代器 (能够直接使用 it + nit - n 的操作)。而 list 的迭代器是双向迭代器 ,不支持加减运算,因此如果强行对 list 使用 std::sort 会导致编译报错。所以,list 专门提供了属于自己的成员函数 lt.sort()(底层基于归并排序实现)。

  • 💡 代码实战演示

下面这段代码演示了这四个最常用算法的具体用法。特别要注意 unique() 去重的前提条件:

cpp 复制代码
#include <iostream>
#include <list>
#include <algorithm> // 为了使用全局的 std::find
using namespace std;

// 辅助打印函数
void print_list(const list<int>& lt, const string& name) 
{
    cout << name << ": ";
    for (auto e : lt) 
    {
        cout << e << " ";
    }
    cout << endl;
}

int main()
{
    list<int> lt = {5, 2, 8, 5, 1, 9, 2, 2, 8};
    print_list(lt, "初始 list"); 
    // 输出: 5 2 8 5 1 9 2 2 8

    // ==========================================
    // 1. 查找 (std::find) -> 属于全局算法
    // ==========================================
    auto pos = find(lt.begin(), lt.end(), 9);
    if (pos != lt.end()) 
    {
        cout << "成功找到数字: " << *pos << endl; 
    }

    // ==========================================
    // 2. 逆置 (reverse) -> 属于成员函数
    // ==========================================
    lt.reverse();
    print_list(lt, "逆置后"); 
    // 输出: 8 2 2 9 1 5 8 2 5

    // ==========================================
    // 3. 排序 (sort) -> 属于成员函数
    // ==========================================
    // 注意:绝对不能写成 std::sort(lt.begin(), lt.end()); 会编译报错!
    lt.sort(); 
    print_list(lt, "排序后"); 
    // 输出: 1 2 2 2 5 5 8 8 9

    // ==========================================
    // 4. 去重 (unique) -> 属于成员函数
    // ==========================================
    // 注意:unique 只能去除【连续的】重复元素。
    // 因此,在去重之前,通常必须要先调用 sort() 将相同的元素排列在一起!
    lt.unique();
    print_list(lt, "去重后"); 
    // 输出: 1 2 5 8 9

    return 0;
}

在你的 vector 博客之后补充 list 的迭代器失效,是一个非常专业的举动。因为 vectorlist 在内存结构上的巨大差异,导致了它们在"迭代器失效"表现上的截然不同。

以下是为你润色和补充的**"七、避坑指南:list 迭代器失效深度解析"**专题内容:


七、避坑指南:list 迭代器失效深度解析

在上一篇 vector 的底层实现中,我们提到了 vector 扩容会导致"全军覆没"(所有迭代器失效)。而 list 由于其非连续存储的特性,在迭代器失效的表现上要"温柔"得多。

1. 插入操作:永远不会失效

vector 中,push_backinsert 可能会触发扩容导致内存搬迁,从而使所有迭代器失效。

但在 list 中,插入一个新节点只需:

  1. 申请一个新节点的内存。
  2. 改变前后节点的 nextprev 指针。

结论 :在 list 中进行 insertpush 操作,原有的任何迭代器都不会失效。它们依然忠实地指向原来那个节点,因为那个节点的物理地址从始至终都没有改变。

2. 删除操作:局部失效

list 的迭代器失效只发生在一种情况下:执行 erasepop

由于 list 的每个节点是独立申请的,当你删除某个节点时,只有指向当前被删除节点的那个迭代器会失效(因为它指向的内存被释放了),而指向其他节点的迭代器依然是安全有效的。

💣 错误示范:

cpp 复制代码
auto it = lt.begin();
while (it != lt.end()) 
{
    if (*it % 2 == 0) 
    {
        lt.erase(it); // ❌ 此时 it 已经指向了一块被销毁的内存,变成了野指针
    }
    ++it; // ❌ 对失效的 it 进行自增,程序会崩溃
}

3. 正确的解决办法

vector 相同,list::erase 会返回被删除节点下一个位置的有效迭代器。我们必须利用这个返回值来更新我们的迭代器。

✅ 正确示范(删除所有偶数):

C++

cpp 复制代码
auto it = lt.begin();
while (it != lt.end()) 
{
    if (*it % 2 == 0) 
    {
        // erase 返回下一个有效位置,完美衔接
        it = lt.erase(it); 
    } 
    else
    {
        // 只有没删除时才手动自增
        ++it; 
    }
}

💡 深度对比:vector vs list 迭代器失效

操作 vector 迭代器失效情况 list 迭代器失效情况
插入 (insert/push) 可能全部失效 (触发扩容时) 永不失效
删除 (erase/pop) 被删位置及之后全部失效 仅被删节点失效
失效根源 内存挪动/空间释放 仅当前节点空间释放

八、对比vector和list

特性 vector list
底层结构 动态顺序表,一段连续空间 带头结点的双向循环链表
随机访问 支持随机访问,访问某个元素效率O(1) 不支持随机访问,访问某个元素效率O(N)
插入和删除 任意位置插入和删除效率低,需要搬移元素,时间复杂度为O(N),插入时有可能需要增容,增容:开辟新空间,拷贝元素,释放旧空间,导致效率更低 任意位置插入和删除效率高,不需要搬移元素,时间复杂度为O(1)
空间利用率 底层为连续空间,不容易造成内存碎片,空间利用率高,缓存利用率高 底层节点动态开辟,小节点容易造成内存碎片,空间利用率低,缓存利用率低
迭代器 原生态指针 对原生态指针(节点指针)进行封装
迭代器失效 在插入元素时,要给所有的迭代器重新赋值,因为插入元素有可能会导致重新扩容,致使原来迭代器失效,删除时,当前迭代器需要重新赋值否则会失效 插入元素不会导致迭代器失效,删除元素时,只会导致当前迭代器失效,其他迭代器不受影响
使用场景 需要高效存储,支持随机访问,不关心插入删除效率 大量插入和删除操作,不关心随机访问

💡 核心建议:日常开发 90% 的场景请无脑选择 vector

现代 CPU 高速缓存(Cache)对连续内存的预取优化,让 vector 的遍历和操作速度极快。

只有当你确实需要高频在任意位置增删数据,或者单个元素体积巨大拷贝成本过高时,才考虑使用 list

结束语

本文我们从底层结构出发,全面梳理了 C++ list 的核心机制与实战用法。最后,我们提炼出以下三条核心开发准则:

  1. 精准定位容器特性list 作为带头双向循环链表,其最大的价值在于 O ( 1 ) O(1) O(1) 的任意位置极速增删 ,完美弥补了 vector 在数据搬移和扩容时的性能短板。
  2. 警惕专属算法与迭代器陷阱 :由于物理空间不连续,list 无法使用 std::sort 等需要随机访问的全局算法,必须依赖类内专属成员函数;此外,在执行 erase 删除操作时,务必利用其返回值来安全更新迭代器,避免坠入野指针陷阱。
  3. 业务导向的工程选型 :由于 CPU 缓存等硬件特性的限制,在绝大多数常规场景中,vector 依然是首选;只有当业务明确面临海量、高频的中间位置增删时 ,才应祭出 list 这把利器。

理解并掌握 listvector 的物理结构差异与互补关系,能够帮助我们在面对复杂的业务数据流时,做出最合理的底层选型。下一篇我们将讲解list的底层实现,让我们更加了解它吧!

相关推荐
光泽雨13 小时前
C# 扩展方法(Extension Method)在语法上的核心灵魂。
开发语言·c#
代码小书生13 小时前
shutil,一个文件操作的 Python 库!
开发语言·python·策略模式
啄缘之间13 小时前
10.【学习】SPI & UART 验证环境与测试用例
开发语言·经验分享·学习·fpga开发·测试用例·verilog
yu859395813 小时前
基于MATLAB的层合板等效模量及极限强度计算实现
开发语言·matlab
咸甜适中13 小时前
rust语言学习笔记Trait(十三)Borrow、BorrowMut(借用)
笔记·学习·rust
wh_xia_jun13 小时前
Apifox 测试项目实操1
开发语言·lua
影寂ldy13 小时前
C#Lambda表达式
开发语言·c#
小侯不躺平.14 小时前
C++ Boost库【6】时间戳整体综合
开发语言·c++·算法
鹏北海-RemHusband14 小时前
Go 包管理笔记 — 面向 JS/TS 前端开发者
笔记·golang