前言
std::list是标准库里的双向链表。它常被贴上两个标签:插入删除快、随机访问慢。标签本身没错,但据此做决定往往会翻车——很多人听说链表插入是 O(1),就把所有容器换成std::list,结果遍历变慢、内存占用变大、代码还更啰嗦;也有人因为「链表慢」而从不使用它,错过了splice这种其它容器没有的能力。
要讲清楚std::list,得先讲清楚它的节点语义:每个元素单独分配在一个节点里,节点之间有前驱和后继指针;容器本身只持有头尾与长度。插入与删除改的是指针,不搬元素;但访问第 n 个元素必须从头或尾一个个走。splice、merge、sort这些成员函数之所以存在,正是因为链表可以只改指针就完成「搬运」和「重排」,而通用算法做不到。
本文先讲结构与迭代器语义,再给出一张接口与复杂度对照表,然后重点解释splice、merge、sort的节点操作与前提条件,最后用一个任务列表的例子把排序、转移、删除串起来。示例按 C++17 编写。关于「链表一定比 vector 快」的说法,本文不会给出任何未经验证的倍数,只讲复杂度与缓存局部性的机制。
一、结构:节点、迭代器与失效规则
std::list的每个元素都存放在独立分配的节点中。节点至少包含:前驱指针、后继指针、元素本身。容器对象保存一个头节点(或头尾指针)和元素个数。由此可以推出几条直接结论:
- 内存不连续。遍历时每次跳到另一个地址,无法像数组那样预取整块缓存。
- 插入不搬移。在已知位置插入,只需新建节点并改相邻节点的指针,与元素类型的大小、拷贝代价无关。
- 迭代器是双向的。支持
++与--,不支持+ n、- n、<比较。std::sort、std::binary_search这类要求随机访问迭代器的算法不能直接用。 - 迭代器稳定性好。插入不会使任何迭代器失效;删除只使指向被删元素的迭代器失效;
splice不失效任何迭代器,只是被转移元素的迭代器改为指向新容器中的位置。
std::list不提供operator[]与at。要访问第 n 个元素,只能std::advance或循环,代价 O(n)。C++11 起size()是常数时间;C++11 之前标准允许它是线性的,老代码里l.size() == 0的写法曾经有性能隐患,现在应直接写l.empty(),语义也更清楚。
头尾操作是链表的强项:push_front、push_back、pop_front、pop_back、front、back都是 O(1)。emplace_front、emplace_back把参数直接传给元素构造函数,避免先构造临时对象再拷贝,C++11 起可用。
二、接口地图与复杂度
std::list的成员函数可以按「是否改结构」「是否搬元素」分类:
| 操作 | 典型接口 | 复杂度 | 是否使迭代器失效 |
|---|
| 头尾插入 | push_front/push_back/emplace_* | O(1) | 否 |
| 任意位置插入 | insert/emplace | O(1)(已知位置) | 否 |
| 删除单个 | erase(it) | O(1) | 仅被删元素 |
| 按值删除 | remove/remove_if | O(n) | 被删元素 |
| 去重 | unique | O(n) | 被删元素 |
| 转移节点 | splice | O(1) 或 O(k) | 否 |
| 合并有序表 | merge | O(n + m) | 否 |
| 排序 | sort | O(n log n) | 否(元素地址不变) |
| 反转 | reverse | O(n) | 否 |
| 查找 | std::find | O(n) | 否 |
几个接口容易混淆:
remove(value)删除所有等于 value 的元素;erase(it)只删迭代器指向的那一个;remove_if(pred)按谓词删除。三者都返回 void(C++20 起remove/remove_if返回删除个数,写代码时以所用标准为准)。unique()只删除相邻的重复元素,要删除全局重复,必须先sort()再unique()。它也可以接受一个二元谓词。resize(n)会多退少补,补的元素用默认构造或给定值。链表扩缩不搬移已有元素。assign(n, value)或assign(first, last)清空后重新填充,比循环push_back更直接。
与std::vector的取舍不能只看复杂度。链表的每个节点都有指针开销,遍历时缓存命中率低;即使中间插入是 O(1),若插入位置需要 O(n) 查找,总代价仍是 O(n)。只有在「已经持有迭代器、需要频繁在中间插入删除、且元素较大或不适合搬移」时,链表的结构优势才明显。判断依据应是具体的访问模式,而不是一句口诀。
三、splice、merge 与 sort:只改指针的重排
std::list::splice是把节点从一个链表转移到另一个链表(或同一链表的另一个位置),不复制、不移动元素本身。它有三个重载:
splice(pos, other) 把 other 的全部元素转移到 pos 之前
splice(pos, other, it) 把 other 中 it 指向的单个元素转移
splice(pos, other, first, last) 把 other 中 [first, last) 转移转移后,原容器不再拥有这些节点,目标容器拥有;指向被转移元素的迭代器仍然有效,只是现在属于新容器。这是std::list最适合做 LRU 缓存、任务队列「提升优先级」等操作的原因:把一个元素挪到头部,不需要拷贝字符串、不需要重新分配。
merge(other)假设两个链表都已按同一规则有序,把 other 的节点按序并入当前链表,过程稳定:相等元素保持原有先后。它同样只改指针。若两个链表未排序,结果不保证有序——merge不会替你排序。
sort()是成员函数而非std::sort。原因在于std::sort要求随机访问迭代器,而链表只有双向迭代器。成员sort通常用归并排序实现,只重连节点,不交换元素值,因此指向元素的迭代器在排序后仍然有效(但位置变了)。它的比较器必须满足严格弱序:用<或>,不要用<=或>=,否则是未定义行为。
splice有一个容易忽视的边界:单元素重载中,如果pos等于it或等于it的下一个位置,标准规定该操作没有效果(具体措辞以所用标准版本为准)。跨容器使用时,other不能与*this是同一个对象(那属于同容器重载的语义)。这些细节不影响常见用法,但在写通用工具函数时要查标准而不是凭印象。
四、完整示例
下面的例子维护一个任务列表:先按优先级排序,再把某个任务用splice提到最前,删除已完成项,最后把队首任务转移到「紧急」链表。整个过程没有拷贝任何std::string。
// 适用于 C++17
#include <algorithm>
#include <iostream>
#include <list>
#include <string>
struct Task {
int id = 0;
int priority = 0; // 数值越小越优先
std::string name;
bool done = false;
};
void print(const std::list<Task>& tasks) {
for (const Task& t : tasks) {
std::cout << t.id << " [p" << t.priority << "] " << t.name;
if (t.done) std::cout << " (done)";
std::cout << '\n';
}
}
int main() {
std::list<Task> tasks = {
{1, 3, "write report", false},
{2, 1, "fix build", false},
{3, 2, "review pr", false},
{4, 2, "update docs", true},
};
// 按优先级升序;优先级相同按 id 升序,保证严格弱序
tasks.sort([](const Task& a, const Task& b) {
if (a.priority != b.priority) return a.priority < b.priority;
return a.id < b.id;
});
// 找到 id == 3 的任务,用 splice 提到最前,不发生拷贝
auto it = std::find_if(tasks.begin(), tasks.end(),
[](const Task& t) { return t.id == 3; });
if (it != tasks.end()) {
tasks.splice(tasks.begin(), tasks, it);
}
// 删除所有已完成任务
tasks.remove_if([](const Task& t) { return t.done; });
std::cout << "after sort / splice / remove_if:\n";
print(tasks);
// 把队首任务转移到紧急链表
std::list<Task> urgent;
if (!tasks.empty()) {
urgent.splice(urgent.end(), tasks, tasks.begin());
}
std::cout << "urgent: " << urgent.front().name << '\n';
std::cout << "remaining: " << tasks.size() << '\n';
return 0;
}逐点核对:
std::list<Task>用初始化列表构造,每个Task是聚合类型,花括号按成员顺序初始化。sort的比较器先比priority,再比id。相等时返回 false,满足严格弱序;若写成<=,两个相等元素互相比较都返回 true,行为未定义。std::find_if是通用算法,只需要输入迭代器,链表可以用。找到后用splice调整位置,it仍然有效。remove_if遍历并删除满足条件的节点,复杂度 O(n),不会影响其它元素的迭代器。- 跨链表
splice之后,urgent.front()有效,因为刚转移了一个元素且urgent非空;先判空再取,避免对空链表调用front(未定义行为)。 - 整个过程中
Task::name没有被复制或移动,转移的只是节点指针。
如果这个列表需要按 id 快速查找,单靠链表做不到 O(1)。常见的组合是std::unordered_map<int, std::list<Task>::iterator>加链表:哈希表负责定位节点,链表负责 O(1) 转移顺序。这也是 LRU 缓存的标准实现骨架。注意:删除元素时必须同步从哈希表里移除对应条目,否则留下悬垂迭代器。
常见坑点
❌ 用tasks[i]或tasks.at(i)访问元素。
✅std::list没有下标访问;用迭代器配合std::advance,或改用std::vector。
❌ 对std::list调用std::sort(l.begin(), l.end())。
✅ 用成员l.sort(comp);通用std::sort要求随机访问迭代器,编译期就会失败。
❌ 认为l.remove(x)只删除第一个等于 x 的元素。
✅remove删除所有相等的元素;只删一个用erase(it)。
❌ 直接unique()去重,发现没删干净。
✅unique只处理相邻重复;先sort()再unique()。
❌ 在循环里for (auto it = l.begin(); it != l.end(); ++it) { if (bad(*it)) l.erase(it); }。
✅ 用it = l.erase(it)接收返回的下一位置;被删迭代器不能再自增。
❌ 以为splice会复制元素,担心字符串拷贝开销。
✅splice只转移节点,不复制也不移动元素;被转移元素的迭代器仍然有效。
❌ 比较器写成a.priority <= b.priority。
✅ 严格弱序用<;<=会让相等元素互相「小于」,sort的行为未定义。
总结
| 维度 | std::list | std::vector |
|---|
| 内存布局 | 节点分散 | 连续 |
| 随机访问 | O(n) | O(1) |
| 已知位置插入/删除 | O(1) | O(n) |
| 迭代器类别 | 双向 | 随机访问 |
| 插入后迭代器 | 不失效 | 可能全部失效 |
| 节点转移 | splice常数时间 | 需搬移或交换 |
| 缓存友好度 | 低 | 高 |
std::list的价值不在「插入快」这句口号,而在节点语义带来的三个能力:稳定的迭代器、常数时间的节点转移、只改指针的重排。需要频繁在中间增删、需要把元素在容器间搬运、需要排序但不希望元素搬家时,它是合适的选择;只是顺序遍历、按下标访问、追求缓存友好时,std::vector通常更简单也更快。选容器先看访问模式,再看复杂度表,最后才谈常数因子。