CC++ & Algorithm

list双向链表详解

极难8
语言版本:通用
概述:像一列火车,每节车厢独立连接,list可以在任意位置快速插入和删除,但不支持随机访问。

手拉手排队:C++ list 双向链表详解

想象一下,你们班同学手拉手围成一圈做游戏,每个人左边拉着一个人的手,右边拉着另一个人的手。这个结构就像一个双向链表——每个同学是一个“节点”,前后相连。如果新同学想加入,他只需要找到两个相邻的同学,让他们松开手,然后分别拉住新同学的手,整个过程非常快,其他同学完全不用移动位置。如果要退出,他两边的同学直接手拉手绕过他就行,也很简单。这种灵活的结构在计算机里就是双向链表

C++标准库中的 list 就是一个双向链表(double-linked list)。每个元素(叫“节点”)除了保存自己的数据,还保存指向前一个节点和后一个节点的“指针”(就像记住了前面和后面同学的名字)。因此,在链表的任意位置插入或删除一个节点都非常快(只需要改几个指针,O(1)),前提是你已经找到了那个位置。坏处是:你不能直接说“我要第5个同学”,必须从头一个个数过去(O(n))。也就是说,list 特别适合频繁在任意位置插入和删除,但不适合“按序号查找”。

1. list 是什么?——双向链表的原理

list 包含在头文件 <list> 中。它的核心特点:

  • 双向:每个节点有前驱指针和后继指针,可以从头到尾遍历,也可以从尾到头。
  • 链式存储:元素在内存中不连续,靠指针连接。
  • 插入/删除快:只要有了指向那个位置的“迭代器”(相当于一个标记),插入或删除只需要改几个指针,其他元素不受影响。
  • 不支持随机访问:不能写 list[3],只能用迭代器一个一个走。

生活中的类比:火车车厢

一列火车有很多节车厢,每节车厢之间用挂钩连接。你要在中间加一节车厢,只需要把挂钩解开,把新车厢挂上去,再连好——不需要移动其他车厢。这就是链表的插入操作。同样,要卸掉一节车厢,也只需要拆下挂钩,其他车厢不动。

迭代器是什么?

迭代器就像一个“指向某节车厢的指针”。有了迭代器,你可以知道它是第几节吗?不能直接知道,你只能通过它访问当前车厢的数据,然后切换到下一节或上一节。迭代器是操作链表的关键。

2. 常用操作详解(配例子)

list 提供了很多针对链表特性的操作,有些是 vectordeque 没有的。下面我们用一个“班级排队”的例子来讲解。

2.1 添加元素:push_back 和 push_front

  • push_back(值):在队伍末尾加一个人。
  • push_front(值):在队伍最前面加一个人(当领队)。
list<string> team;                // 定义一个空的队伍名单
team.push_back("小明");          // 小明站到队尾
team.push_back("小红");          // 小红站到小明后面
team.push_front("李老师");      // 李老师插到队头

2.2 删除元素:pop_back 和 pop_front

  • pop_back():删除队尾的人(不能是空队伍)。
  • pop_front():删除队头的人。

2.3 在任意位置插入:insert

insert(迭代器, 值) 在迭代器指向的位置之前插入一个新元素。前提是你要先找到那个位置(用迭代器表示)。

// 假设队伍是:李老师 小明 小红
auto it = find(team.begin(), team.end(), "小红"); // 找到“小红”的位置
if (it != team.end()) {
    team.insert(it, "小强"); // 在小红前面插入小强
}
// 结果:李老师 小明 小强 小红

2.4 删除指定位置:erase

erase(迭代器) 删除迭代器指向的元素。

auto it = find(team.begin(), team.end(), "小刚");
if (it != team.end()) {
    team.erase(it); // 小刚离开了
}

2.5 拼接:splice

splicelist 的独门功夫:可以把另一个 list 中的元素“搬”到本 list 的指定位置。原 list 中这些元素会被移除(剪切)。

list<string> otherTeam = {"小华", "小美"};
team.splice(team.end(), otherTeam); // 把otherTeam的所有元素剪切到team尾部
// 现在team包含了小华和小美,otherTeam变为空

2.6 排序:sort

list 自己有 sort() 方法,不需要用 <algorithm> 里的 std::sort,因为那个要求随机访问。list::sort 用的是归并排序,稳定,不需要额外空间。

team.sort(); // 按字典序(比如姓名拼音)排序

2.7 去重:unique

移除连续重复的元素。通常先排序再 unique,确保所有相同元素都相邻。

team.sort();          // 先排序
team.unique();        // 去掉连续重复,每个值只剩一个

2.8 反转:reverse

team.reverse(); 把队伍顺序颠倒过来。

2.9 大小和判空

  • team.size():返回元素个数。注意:在某些旧版本C++中,size() 可能是O(n)(需要遍历计算),但C++11后标准要求是O(1)。为了保险,判断是否为空请用 empty(),它总是O(1)。
  • team.empty():返回 true 如果队伍为空。

2.10 访问首尾元素

  • team.front():返回第一个元素(队头)。
  • team.back():返回最后一个元素(队尾)。

注意:如果队伍为空,调用这两个函数会导致程序崩溃,一定要先检查 !team.empty()

3. 新手容易犯的错误

错误1:试图用下标访问

list<int> nums = {1,2,3};
cout << nums[0]; // 编译错误!list不支持operator[]

正确做法:用迭代器或 front()/back()

错误2:在遍历时删除元素导致迭代器失效

虽然 list 的插入/删除不会使其他迭代器失效(除了被删除的那个),但如果你在循环中删除了当前迭代器指向的元素,然后继续使用这个迭代器,就会出问题。

// 错误示范:删除所有偶数
for (auto it = nums.begin(); it != nums.end(); ++it) {
    if (*it % 2 == 0) {
        nums.erase(it); // 删除后it失效,再++it就危险了
    }
}

正确姿势:用 erase 返回的迭代器。

auto it = nums.begin();
while (it != nums.end()) {
    if (*it % 2 == 0) {
        it = nums.erase(it); // erase返回下一个元素的迭代器
    } else {
        ++it;
    }
}

错误3:用 std::sort 排序 list

#include <algorithm>
sort(team.begin(), team.end()); // 编译错误!因为list的迭代器不是随机访问迭代器

正确做法:调用 team.sort()

错误4:忽略 size() 的性能陷阱

如果你在循环里频繁调用 size(),比如 for (int i=0; i<team.size(); ++i),在一些实现中 size() 可能每次都要遍历整个链表,导致性能极差。建议改用 empty() 判断,或先用变量保存 size()

错误5:忘记包含头文件 <list>

#include <list> // 别忘了!

4. 完整代码示例:班级排队系统

下面的程序模拟一个“手拉手排队”的场景,使用 list 管理学生的顺序。每行变量定义都加了中文注释。

#include <iostream>
#include <list>
#include <algorithm>  // 用于find
using namespace std;

int main() {
    // 初始队伍:小明、小红、小刚
    list<string> team = {"小明", "小红", "小刚"};

    // 遍历(从头到尾),使用范围for循环
    cout << "初始队伍: ";
    for (const string& name : team) {
        cout << name << " ";
    }
    cout << endl;

    // 尾部加入新人
    team.push_back("小丽");
    cout << "小丽加入队尾: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 头部插入(当领队)
    team.push_front("李老师");
    cout << "李老师插到队头: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 在指定位置插入:在小红前面插入小强
    // 先找到小红的迭代器
    auto it = find(team.begin(), team.end(), "小红");
    if (it != team.end()) {
        team.insert(it, "小强");  // 在小红之前插入
    }
    cout << "小强插在小红前面: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 删除某人:小刚离开
    it = find(team.begin(), team.end(), "小刚");
    if (it != team.end()) {
        team.erase(it);
    }
    cout << "小刚离开后: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 使用splice将另一个队里的元素转移过来
    list<string> otherTeam = {"小华", "小美"};
    // 将otherTeam的所有元素拼接到当前队伍尾部
    team.splice(team.end(), otherTeam);  // otherTeam将变为空
    cout << "合并另一队后: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;
    cout << "另一队现在大小: " << otherTeam.size() << endl; // 0

    // 排序(按字母顺序)
    team.sort();
    cout << "排序后: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 去重:先手动加一个重复,再排序去重
    team.push_back("小华");  // 已经有了小华,再加一个
    team.sort(); // 确保有序
    team.unique();
    cout << "去重后: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 反转
    team.reverse();
    cout << "反转后: ";
    for (const string& name : team) cout << name << " ";
    cout << endl;

    // 清空
    team.clear();
    cout << "清空后大小: " << team.size() << endl;

    return 0;
}

输出结果(示例)

初始队伍: 小明 小红 小刚 
小丽加入队尾: 小明 小红 小刚 小丽 
李老师插到队头: 李老师 小明 小红 小刚 小丽 
小强插在小红前面: 李老师 小明 小强 小红 小刚 小丽 
小刚离开后: 李老师 小明 小强 小红 小丽 
合并另一队后: 李老师 小明 小强 小红 小丽 小华 小美 
另一队现在大小: 0
排序后: 小丽 小强 小红 小华 小美 小明 李老师 
去重后: 小丽 小强 小红 小华 小美 小明 李老师 
反转后: 李老师 小明 小美 小华 小红 小强 小丽 
清空后大小: 0

5. Python 中的替代方案

Python 标准库没有内置的双向链表。常用的 collections.deque 是双端队列,底层是用数组实现的(有点像“环状缓冲区”),它只支持头尾 O(1) 插入删除,中间插入是 O(n)(因为需要移动元素)。所以:

  • 如果你只需要在头尾操作:用 deque 很棒。
  • 如果你必须频繁在中间插入删除,但数据量不大:可以用 Python 的 list 配合 insertpop,虽然慢但代码简单。
  • 如果你需要真正的高效链表:可以自己实现节点类(如现有内容中的简单示例),但容易出错,而且日常编程中很少用到。

注意:在算法竞赛中(如USACO),如果问题要求 O(1) 中间插入,Python 选手通常会用数组+下标来模拟静态链表,或者直接用 C++ 的 list 更省心。

下面是一个简单的自定义双向链表实现(仅用于理解原理,不推荐在正式项目中使用):

# 自定义节点类
class Node:
    def __init__(self, data):
        self.data = data     # 节点数据
        self.prev = None     # 前一个节点
        self.next = None     # 后一个节点

# 简单的双向链表
class LinkedList:
    def __init__(self):
        self.head = None     # 头节点
        self.tail = None     # 尾节点
        self._size = 0       # 元素个数

    def push_back(self, data):
        """尾部添加"""
        new_node = Node(data)
        if not self.head:
            self.head = self.tail = new_node
        else:
            new_node.prev = self.tail
            self.tail.next = new_node
            self.tail = new_node
        self._size += 1

    def push_front(self, data):
        """头部添加"""
        new_node = Node(data)
        if not self.head:
            self.head = self.tail = new_node
        else:
            new_node.next = self.head
            self.head.prev = new_node
            self.head = new_node
        self._size += 1

    def insert_after(self, node, data):
        """在给定节点后插入新节点"""
        new_node = Node(data)
        new_node.prev = node
        new_node.next = node.next
        if node.next:
            node.next.prev = new_node
        else:  # 尾部
            self.tail = new_node
        node.next = new_node
        self._size += 1

    def delete_node(self, node):
        """删除指定节点"""
        if node.prev:
            node.prev.next = node.next
        else:
            self.head = node.next
        if node.next:
            node.next.prev = node.prev
        else:
            self.tail = node.prev
        self._size -= 1

    def size(self):
        return self._size

    def __iter__(self):
        cur = self.head
        while cur:
            yield cur.data
            cur = cur.next

# 使用示例
ll = LinkedList()
ll.push_back("小明")
ll.push_back("小红")
ll.push_back("小刚")
ll.push_front("李老师")
print("队伍:", list(ll))

# 在小红后面插入小强
node = ll.head
while node and node.data != "小红":
    node = node.next
if node:
    ll.insert_after(node, "小强")
print("插入后:", list(ll))

# 删除小刚
node = ll.head
while node and node.data != "小刚":
    node = node.next
if node:
    ll.delete_node(node)
print("删除后:", list(ll))

6. 总结与相关指引

什么时候用 list

  • 频繁在任意位置插入和删除,且不依赖随机访问。
  • 需要稳定的迭代器:插入/删除不会使其他迭代器失效(除了被删的那个)。
  • 需要 splice 这类链表专属操作(比如把一个链表的一部分搬到另一个链表)。

什么时候不用 list

  • 需要随机访问:用 vectordeque
  • 元素很小,数量很多list 每个节点额外占用两个指针内存(16字节左右),内存开销大。
  • 主要操作是遍历和排序vector 的排序通常更快(虽然 list 的归并排序稳定且不需要额外空间,但常数大)。

与其他容器的比较

特性vectordequelist
随机访问O(1)O(1)不支持
头部插入/删除O(n)O(1)O(1)
尾部插入/删除O(1)O(1)O(1)
中间插入/删除O(n)O(n)O(1)(有迭代器时)
迭代器稳定性插入/删除会使所有迭代器失效插入/删除会使部分迭代器失效除了被删的,其他全有效
内存连续,紧凑分段连续节点分散,内存大

相关知识点指引

  • 迭代器:理解迭代器是操作 list 的基础。建议学习迭代器分类(输入、输出、前向、双向、随机访问)。
  • vectordeque:对比学习,知道它们的优势和劣势。
  • std::find 算法:如何在线性容器中查找元素。
  • splice 的常用场景:比如将两个链表合并,或者将一个链表的一部分移到另一个链表。

现在你已经理解了双向链表这种“手拉手”的结构,知道什么时候该用它,什么时候不该用了。下次遇到需要频繁在中间增删的问题,记得考虑 list

例题精讲

1单选题

关于C++ STL list容器,以下哪个说法是错误的?

Alist底层实现是双向链表
Blist支持随机访问迭代器
Clist插入元素不会引起其他元素的内存重新分配
Dlist的size()操作是常数时间复杂度
2单选题

在list中进行insert操作后,关于原有迭代器的有效性,下列说法正确的是?

A所有原有迭代器都失效
B只有指向被插入位置的迭代器失效
C所有原有迭代器依然有效(除非指向被删除的元素)
D插入操作会使所有迭代器失效,需要重新获取
3判断题

可以对list容器使用标准库中的std::sort算法进行排序。

4判断题

对于两个已排序的list,使用merge()合并后,得到的新list仍然保持有序。

5填空题
给定一个list<int> lst = {1,2,3,4,5},要删除所有值为偶数的元素,请补全以下代码:for (auto it = lst.begin(); it != lst.end(); ) { if (*it % 2 == 0) { ___; } else { ++it; } }