C++ list 原理与实践:双向链表、迭代器与简化实现
std::list 是 C++ 标准库中的双向链表容器。它不要求所有元素挤在一段连续内存里,每个元素位于独立节点中,节点之间通过前驱和后继指针连接。
这套结构带来了鲜明的取舍:已经找到操作位置时,插入和删除只需调整少量指针;想访问第 n 个元素时,却只能沿节点逐个移动。使用 list 的难点因此不在接口数量,而在能否把"节点、位置、迭代器"三者对应起来。
一、list 的底层结构
双向链表中的节点通常包含三部分:前驱指针、当前数据和后继指针。
text
+------+-------+------+
| prev | value | next |
+------+-------+------+
一种常见实现会增加哨兵节点,并把首尾相连,形成双向循环链表:
text
next
+--------------------------+
| v
+----------+ +-----+ +-----+ +-----+
| sentinel |<-->| 10 |<-->| 20 |<-->| 30 |
+----------+ +-----+ +-----+ +-----+
^ |
+--------------------------+
prev
哨兵节点不保存业务数据。它的 next 指向首节点,prev 指向尾节点;空链表中,这两个指针都指回哨兵自身。
这种设计把边界处理统一了:
begin()可以表示为哨兵的下一个节点。end()可以直接表示哨兵节点。- 在首部插入,本质上是在
begin()之前插入。 - 在尾部插入,本质上是在
end()之前插入。
C++ 标准规定的是
std::list的可观察行为,并没有要求所有实现必须采用同一种节点布局。双向循环链表加哨兵节点是一种便于理解和实现的模型。
list 的性能特征
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 访问首尾元素 | O(1) |
通过首尾链接直接定位 |
| 已知位置的插入、删除 | O(1) |
只调整相邻节点的链接 |
按位置访问第 n 个元素 |
O(n) |
需要沿链表移动 |
| 按值查找 | O(n) |
最坏需要检查所有节点 |
| 首部、尾部插入删除 | O(1) |
无需移动其他元素 |
"任意位置插入删除是 O(1)"有一个经常被省略的前提:操作位置已经由有效迭代器给出 。如果为了找到该位置先遍历了半条链表,总成本仍然是 O(n)。
二、构造与遍历
使用 std::list 需要包含头文件:
cpp
#include <list>
2.1 常见构造方式
cpp
#include <iostream>
#include <iterator>
#include <list>
int main()
{
std::list<int> empty; // 空链表
std::list<int> fiveZeros(5); // 5 个 0
std::list<int> fourSevens(4, 7); // 4 个 7
std::list<int> source{1, 2, 3, 4};
std::list<int> copy(source); // 拷贝构造
std::list<int> part(std::next(source.begin()), source.end());
for (int value : part)
std::cout << value << ' ';
std::cout << '\n';
}
输出:
text
2 3 4
范围构造使用左闭右开区间 [first, last)。这里可以对 source.begin() 递增,但不能写 source.begin() + 1,因为 list 迭代器不支持随机跳跃。
需要移动多个位置时,可以使用 std::next 和 std::advance:
cpp
#include <iterator>
auto third = std::next(source.begin(), 2); // 指向值 3
auto it = source.begin();
std::advance(it, 2); // it 也指向值 3
对 list 而言,这两个操作仍然需要逐个经过中间节点,移动 k 步的成本是 O(k)。
2.2 正向与反向遍历
cpp
std::list<int> values{10, 20, 30};
for (auto it = values.begin(); it != values.end(); ++it)
std::cout << *it << ' ';
for (auto it = values.rbegin(); it != values.rend(); ++it)
std::cout << *it << ' ';
正向迭代器执行 ++ 时沿后继方向移动,反向迭代器执行 ++ 时沿前驱方向移动。两段输出分别是:
text
10 20 30
30 20 10
end() 和 rend() 都是边界位置,不能被解引用。
三、常用接口
list 没有 operator[],也没有 at()。它的接口围绕首尾访问、节点插入和节点删除展开。
3.1 容量与首尾元素
| 接口 | 作用 |
|---|---|
empty() |
判断容器是否为空 |
size() |
返回元素个数 |
front() |
返回首元素的引用 |
back() |
返回尾元素的引用 |
现代 C++ 中,std::list::size() 是常数复杂度。front() 和 back() 只能在非空容器上调用。
3.2 插入与删除
| 接口 | 作用 |
|---|---|
push_front(value) |
在首部插入元素 |
push_back(value) |
在尾部插入元素 |
pop_front() |
删除首元素 |
pop_back() |
删除尾元素 |
insert(pos, value) |
在 pos 之前插入元素 |
erase(pos) |
删除 pos 指向的元素 |
clear() |
删除全部元素 |
swap(other) |
交换两个容器的内容 |
下面把常见操作放进一个完整程序:
cpp
#include <algorithm>
#include <iostream>
#include <list>
int main()
{
std::list<int> values{20, 30, 40};
values.push_front(10); // 10 20 30 40
values.push_back(50); // 10 20 30 40 50
auto position = std::find(values.begin(), values.end(), 30);
if (position != values.end())
values.insert(position, 25); // 10 20 25 30 40 50
position = std::find(values.begin(), values.end(), 40);
if (position != values.end())
values.erase(position); // 10 20 25 30 50
values.pop_front(); // 20 25 30 50
values.pop_back(); // 20 25 30
std::cout << "front=" << values.front()
<< ", back=" << values.back() << '\n';
for (int value : values)
std::cout << value << ' ';
std::cout << '\n';
}
编译运行:
bash
g++ list_basic.cpp -std=c++11 -Wall -Wextra -pedantic -o list_basic
./list_basic
预期输出:
text
front=20, back=30
20 25 30
这里的 std::find 来自 <algorithm>,不是 list 的成员函数。它通过迭代器逐个比较元素,时间复杂度为 O(n)。
四、迭代器失效
list 的节点各自独立,插入新节点不会搬动已有节点。因此,普通插入操作不会让指向已有元素的迭代器、指针和引用失效。
删除则不同。节点一旦释放,指向该节点的迭代器立即失效;指向其他节点的迭代器仍然有效。
4.1 错误的删除循环
cpp
std::list<int> values{1, 2, 3, 4};
for (auto it = values.begin(); it != values.end(); ++it)
{
if (*it % 2 == 0)
values.erase(it); // it 指向的节点已被删除
}
循环末尾还会执行 ++it,但 it 已经失效,程序进入未定义行为。
4.2 接住 erase 的返回值
erase 返回被删除节点之后的迭代器,适合直接交给下一轮循环:
cpp
std::list<int> values{1, 2, 3, 4, 5, 6};
for (auto it = values.begin(); it != values.end(); )
{
if (*it % 2 == 0)
it = values.erase(it);
else
++it;
}
执行后 values 中剩下 1 3 5。
也能看到 erase(it++) 这样的写法。后置递增会先保存旧位置,再把 it 移到下一节点,随后旧位置交给 erase 删除。这个写法合法,但 it = erase(it) 更直观,连续删除和条件删除都容易读懂。
不要调用
erase(end()),也不要对空容器调用pop_front()或pop_back()。这些操作都没有有效元素可处理。
五、简化实现:哨兵节点如何串起整个 list
下面写一个简化版双向链表,包含迭代器、深拷贝、插入、删除和首尾操作。它用于理解节点链接,没有覆盖自定义分配器、完整的 const_iterator、移动接口和标准容器的全部异常约定。
5.1 为什么把节点拆成两层
如果哨兵节点也使用 Node<T>,构造空链表时就必须额外构造一个 T。这会错误地要求元素类型支持默认构造。
更合适的做法是拆成节点基类和数据节点:
cpp
#include <cstddef>
#include <iterator>
struct NodeBase
{
NodeBase* prev;
NodeBase* next;
};
template<class T>
struct ListNode : NodeBase
{
explicit ListNode(const T& value) : data(value) {}
T data;
};
哨兵只使用 NodeBase,普通节点使用 ListNode<T>。这样,只有真正插入元素时才构造 T。
5.2 迭代器封装的是节点位置
cpp
template<class T>
class ListIterator
{
public:
using Self = ListIterator<T>;
using Node = ListNode<T>;
using value_type = T;
using pointer = T*;
using reference = T&;
using difference_type = std::ptrdiff_t;
using iterator_category = std::bidirectional_iterator_tag;
explicit ListIterator(NodeBase* node = nullptr) : node_(node) {}
T& operator*() const
{
return static_cast<Node*>(node_)->data;
}
T* operator->() const
{
return &operator*();
}
Self& operator++()
{
node_ = node_->next;
return *this;
}
Self operator++(int)
{
Self old(*this);
++(*this);
return old;
}
Self& operator--()
{
node_ = node_->prev;
return *this;
}
bool operator==(const Self& other) const
{
return node_ == other.node_;
}
bool operator!=(const Self& other) const
{
return !(*this == other);
}
private:
template<class U>
friend class MiniList;
NodeBase* node_;
};
迭代器对外表现得像指针,内部保存的却是节点地址。operator++ 不能做地址加一,它必须读取当前节点的 next;operator-- 则沿 prev 移动。
5.3 完整的 MiniList
下面的代码与前面的节点、迭代器定义放在同一个文件中:
cpp
#include <cstddef>
#include <utility>
template<class T>
class MiniList
{
public:
using iterator = ListIterator<T>;
MiniList() : size_(0)
{
sentinel_.next = &sentinel_;
sentinel_.prev = &sentinel_;
}
MiniList(const MiniList& other) : MiniList()
{
for (NodeBase* current = other.sentinel_.next;
current != &other.sentinel_;
current = current->next)
{
push_back(static_cast<Node*>(current)->data);
}
}
MiniList& operator=(MiniList other)
{
swap(other);
return *this;
}
~MiniList()
{
clear();
}
iterator begin() { return iterator(sentinel_.next); }
iterator end() { return iterator(&sentinel_); }
bool empty() const { return size_ == 0; }
std::size_t size() const { return size_; }
T& front() { return *begin(); }
T& back()
{
iterator last = end();
--last;
return *last;
}
iterator insert(iterator position, const T& value)
{
Node* node = new Node(value);
NodeBase* next = position.node_;
NodeBase* prev = next->prev;
node->prev = prev;
node->next = next;
prev->next = node;
next->prev = node;
++size_;
return iterator(node);
}
iterator erase(iterator position)
{
NodeBase* current = position.node_;
NodeBase* next = current->next;
NodeBase* prev = current->prev;
prev->next = next;
next->prev = prev;
delete static_cast<Node*>(current);
--size_;
return iterator(next);
}
void push_front(const T& value) { insert(begin(), value); }
void push_back(const T& value) { insert(end(), value); }
void pop_front() { erase(begin()); }
void pop_back()
{
iterator last = end();
erase(--last);
}
void clear()
{
iterator current = begin();
while (current != end())
current = erase(current);
}
void swap(MiniList& other)
{
if (this == &other)
return;
std::swap(sentinel_.next, other.sentinel_.next);
std::swap(sentinel_.prev, other.sentinel_.prev);
std::swap(size_, other.size_);
repairSentinel();
other.repairSentinel();
}
private:
using Node = ListNode<T>;
void repairSentinel()
{
if (size_ == 0)
{
sentinel_.next = &sentinel_;
sentinel_.prev = &sentinel_;
}
else
{
sentinel_.next->prev = &sentinel_;
sentinel_.prev->next = &sentinel_;
}
}
NodeBase sentinel_;
std::size_t size_;
};
插入时的四次链接调整可以记成一句话:新节点夹在 prev 和 next 中间。
text
插入前:prev <----------> next
插入后:prev <--> new <--> next
删除则相反,让前驱和后继越过当前节点重新相连,再释放当前节点。
text
删除前:prev <--> current <--> next
删除后:prev <------------> next
拷贝构造逐个创建新节点,所以新旧链表各自管理一套节点。赋值运算符按值接收参数,再通过 swap 接管副本中的节点,能自然处理自赋值。
5.4 测试简化实现
cpp
#include <iostream>
#include <string>
int main()
{
MiniList<std::string> words;
words.push_back("C++");
words.push_back("list");
words.push_front("learn");
auto it = words.begin();
++it;
words.insert(it, "modern");
for (const std::string& word : words)
std::cout << word << ' ';
std::cout << '\n';
MiniList<std::string> copy(words);
copy.pop_front();
for (const std::string& word : copy)
std::cout << word << ' ';
std::cout << '\n';
}
预期输出:
text
learn modern C++ list
modern C++ list
六、反向迭代器为什么能复用正向迭代器
反向迭代器可以保存一个正向迭代器 current。它所表示的元素位于 current 的前一个位置,因此解引用时先复制,再执行一次 --:
cpp
template<class Iterator>
class ReverseIterator
{
public:
using Traits = std::iterator_traits<Iterator>;
using Reference = typename Traits::reference;
using Pointer = typename Traits::pointer;
explicit ReverseIterator(Iterator current) : current_(current) {}
Reference operator*() const
{
Iterator temp = current_;
--temp;
return *temp;
}
Pointer operator->() const
{
return &operator*();
}
ReverseIterator& operator++()
{
--current_;
return *this;
}
ReverseIterator& operator--()
{
++current_;
return *this;
}
bool operator==(const ReverseIterator& other) const
{
return current_ == other.current_;
}
bool operator!=(const ReverseIterator& other) const
{
return !(*this == other);
}
private:
Iterator current_;
};
这里的 typename 告诉编译器,Traits::reference 和 Traits::pointer 是依赖于模板参数的类型名。少了它,编译器无法在模板定义阶段确认这个名字表示类型。实际使用时还需要包含 <iterator>。
反向迭代器执行 ++,内部正向迭代器反而执行 --;反向迭代器执行 --,内部则执行 ++。这就是反向遍历能够建立在正向迭代器之上的原因。
七、list 与 vector 怎么选
两个容器都能保存线性序列,但内存组织方式完全不同。
| 对比项 | vector |
list |
|---|---|---|
| 底层模型 | 连续动态数组 | 双向链表 |
| 随机访问 | 支持,O(1) |
不支持,定位元素为 O(n) |
| 已知位置插入删除 | 通常需要移动元素 | 调整链接即可,O(1) |
| 尾部追加 | 均摊 O(1) |
O(1) |
| 额外空间 | 容量可能有预留 | 每个节点保存前后指针及分配开销 |
| 缓存局部性 | 通常较好 | 节点分散,通常较差 |
| 插入后的迭代器 | 扩容时全部失效 | 指向已有元素的迭代器保持有效 |
| 删除后的迭代器 | 删除位置及其后可能失效 | 只有指向被删节点的迭代器失效 |
单看复杂度表,很容易得出"频繁插入就选 list"的结论,但实际还要看位置怎么得到、元素大小和访问模式。
如果每次都要先线性查找插入点,list 并没有消除查找成本。它的节点分散也会增加内存占用,并削弱缓存局部性。对于以遍历、下标访问和尾部追加为主的代码,vector 往往仍是更自然的默认选择。
list 更适合这样的情况:程序长期保存着若干有效迭代器,需要在这些已知位置附近频繁插入或删除,并且不需要随机访问。
八、常见问题与易错点
1. 为什么 list 不提供下标访问
链表节点不连续,无法通过起始地址加偏移量直接定位。即使人为实现 operator[],每次访问仍要遍历,接口外观会掩盖 O(n) 成本。
2. insert 返回什么
insert(position, value) 在 position 之前插入,并返回指向新元素的迭代器。插入不会让已有元素的迭代器失效。
3. erase 返回什么
erase(position) 删除当前位置,返回下一个元素的迭代器。删除尾元素时返回 end(),正好可以继续用于循环判断。
4. clear 之后迭代器还能用吗
指向原有元素的迭代器全部失效,因为对应节点都已销毁。容器对象仍然有效,可以继续插入新元素。
5. 为什么不建议对 list 使用 std::sort
std::sort 要求随机访问迭代器,而 list 只提供双向迭代器,因此代码无法通过编译。需要排序时使用 list 自己的 sort() 成员函数。
6. 删除当前元素后能否直接 ++it
不能。当前节点已经释放,继续递增该迭代器属于未定义行为。应写成 it = values.erase(it)。
九、总结
list 的行为都可以从节点模型推出来:节点彼此独立,所以已知位置的插入和删除只需改链接;节点不连续,所以不能随机访问,遍历的缓存表现也不如连续容器稳定。
掌握它时,最值得反复确认的是三个细节:insert 在迭代器之前插入,erase 会使当前迭代器失效并返回下一位置,插入删除的 O(1) 需要以已经找到位置为前提。理解哨兵节点和迭代器封装后,这些规则就不再需要死记。