list双向链表详解
极难8手拉手排队:C++ list 双向链表详解
想象一下,你们班同学手拉手围成一圈做游戏,每个人左边拉着一个人的手,右边拉着另一个人的手。这个结构就像一个双向链表——每个同学是一个“节点”,前后相连。如果新同学想加入,他只需要找到两个相邻的同学,让他们松开手,然后分别拉住新同学的手,整个过程非常快,其他同学完全不用移动位置。如果要退出,他两边的同学直接手拉手绕过他就行,也很简单。这种灵活的结构在计算机里就是双向链表。
C++标准库中的 list 就是一个双向链表(double-linked list)。每个元素(叫“节点”)除了保存自己的数据,还保存指向前一个节点和后一个节点的“指针”(就像记住了前面和后面同学的名字)。因此,在链表的任意位置插入或删除一个节点都非常快(只需要改几个指针,O(1)),前提是你已经找到了那个位置。坏处是:你不能直接说“我要第5个同学”,必须从头一个个数过去(O(n))。也就是说,list 特别适合频繁在任意位置插入和删除,但不适合“按序号查找”。
1. list 是什么?——双向链表的原理
list 包含在头文件 <list> 中。它的核心特点:
- 双向:每个节点有前驱指针和后继指针,可以从头到尾遍历,也可以从尾到头。
- 链式存储:元素在内存中不连续,靠指针连接。
- 插入/删除快:只要有了指向那个位置的“迭代器”(相当于一个标记),插入或删除只需要改几个指针,其他元素不受影响。
- 不支持随机访问:不能写
list[3],只能用迭代器一个一个走。
生活中的类比:火车车厢
一列火车有很多节车厢,每节车厢之间用挂钩连接。你要在中间加一节车厢,只需要把挂钩解开,把新车厢挂上去,再连好——不需要移动其他车厢。这就是链表的插入操作。同样,要卸掉一节车厢,也只需要拆下挂钩,其他车厢不动。
迭代器是什么?
迭代器就像一个“指向某节车厢的指针”。有了迭代器,你可以知道它是第几节吗?不能直接知道,你只能通过它访问当前车厢的数据,然后切换到下一节或上一节。迭代器是操作链表的关键。
2. 常用操作详解(配例子)
list 提供了很多针对链表特性的操作,有些是 vector 和 deque 没有的。下面我们用一个“班级排队”的例子来讲解。
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
splice 是 list 的独门功夫:可以把另一个 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配合insert和pop,虽然慢但代码简单。 - 如果你需要真正的高效链表:可以自己实现节点类(如现有内容中的简单示例),但容易出错,而且日常编程中很少用到。
注意:在算法竞赛中(如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?
- 需要随机访问:用
vector或deque。 - 元素很小,数量很多:
list每个节点额外占用两个指针内存(16字节左右),内存开销大。 - 主要操作是遍历和排序:
vector的排序通常更快(虽然list的归并排序稳定且不需要额外空间,但常数大)。
与其他容器的比较
| 特性 | vector | deque | list |
|---|---|---|---|
| 随机访问 | O(1) | O(1) | 不支持 |
| 头部插入/删除 | O(n) | O(1) | O(1) |
| 尾部插入/删除 | O(1) | O(1) | O(1) |
| 中间插入/删除 | O(n) | O(n) | O(1)(有迭代器时) |
| 迭代器稳定性 | 插入/删除会使所有迭代器失效 | 插入/删除会使部分迭代器失效 | 除了被删的,其他全有效 |
| 内存 | 连续,紧凑 | 分段连续 | 节点分散,内存大 |
相关知识点指引
- 迭代器:理解迭代器是操作
list的基础。建议学习迭代器分类(输入、输出、前向、双向、随机访问)。 vector和deque:对比学习,知道它们的优势和劣势。std::find算法:如何在线性容器中查找元素。splice的常用场景:比如将两个链表合并,或者将一个链表的一部分移到另一个链表。
现在你已经理解了双向链表这种“手拉手”的结构,知道什么时候该用它,什么时候不该用了。下次遇到需要频繁在中间增删的问题,记得考虑 list!
例题精讲
关于C++ STL list容器,以下哪个说法是错误的?
在list中进行insert操作后,关于原有迭代器的有效性,下列说法正确的是?
可以对list容器使用标准库中的std::sort算法进行排序。
对于两个已排序的list,使用merge()合并后,得到的新list仍然保持有序。
给定一个list<int> lst = {1,2,3,4,5},要删除所有值为偶数的元素,请补全以下代码:for (auto it = lst.begin(); it != lst.end(); ) { if (*it % 2 == 0) { ___; } else { ++it; } }