手写一个 C++ List:从零拆解双向循环链表的底层
一、这张"网"长什么样:双向循环链表 + 哨兵头节点
这个 list 不是普通的单向链表,而是双向循环链表 ,外面还套了一个哨兵头节点
先画个图,直观感受一下:
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│_head│<─>│ 1 │<─>│ 2 │<─>│ 3 │
└─────┘ └─────┘ └─────┘ └─────┘
↑ │
└────────────────────────────┘
几个关键点:
_head是哨兵节点,它自己不存正经数据,纯粹的"路标"_head->_next指向第一个真实数据节点,_head->_prev指向最后一个- 最后一个节点的
_next又指回_head,头尾相连,形成一个环
这个设计的妙处在哪?空链表不是 NULL,而是 _head 自己指回自己:
cpp
// 空链表的初始化
void empty_init()
{
_head = new Node();
_head->_next = _head;
_head->_prev = _head;
_size = 0;
}
正因为有了哨兵节点,end() 就可以直接返回 _head------它既是"最后一个元素的下一个位置",也是"第一个元素的前一个位置"。所有边界条件被统一处理了,不用到处写 if (p != NULL) 的判断
二、最底层的那块砖:节点结构
cpp
template<class T>
struct list_node
{
T _data;
list_node<T>* _next;
list_node<T>* _prev;
list_node(const T& data = T())
: _data(data), _next(NULL), _prev(NULL)
{}
};
三个成员,简单直白:数据 _data、前驱指针 _prev、后继指针 _next。
构造函数给 data 设了默认值 T(),这样哨兵节点用 new Node() 就能正常创建------_data 按默认值填上,反正哨兵不靠它干活。
三、迭代器:整个设计里最聪明的一步
迭代器是手写 list 的第一个坎。标准库的做法是写两套几乎一样的代码:iterator 和 const_iterator,一个返回 T&,一个返回 const T&,其他逻辑一个字不差地重复。
这个实现高明就高明在------它用模板参数来控制返回值类型,一套代码顶两套:
cpp
template<class T, class Ref, class Ptr>
struct list_iterator
{
typedef list_node<T> Node;
typedef list_iterator<T, Ref, Ptr> Self;
Node* _node;
list_iterator(Node* node) : _node(node) {}
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
// 前置 ++
Self& operator++()
{
_node = _node->_next;
return *this;
}
// 后置 ++
Self operator++(int)
{
Self tmp(*this);
_node = _node->_next;
return tmp;
}
// 前置 --
Self& operator--()
{
_node = _node->_prev;
return *this;
}
// 后置 --
Self operator--(int)
{
Self tmp(*this);
_node = _node->_prev;
return tmp;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
};
然后在 list 类里这样 typedef:
cpp
typedef list_iterator<T, T&, T*> iterator;
typedef list_iterator<T, const T&, const T*> const_iterator;
就这一手:Ref=T& 时 operator* 返回普通引用(可读可写),Ref=const T& 时返回常量引用(只读)。一套代码,两套行为,零冗余。
operator-> 的小花招
operator-> 返回的是 &_node->_data,即数据成员的指针。当你遍历一个存了自定义类型的链表时:
cpp
struct AA
{
int _a1;
int _a2;
};
list<AA> lt;
lt.push_back(AA());
list<AA>::iterator ita = lt.begin();
cout << ita->_a1 << ":" << ita->_a2 << endl;
ita->_a1 按语法应该是 ita.operator->()->_a1,两个箭头。但 C++ 编译器做了特殊处理,自动省略了一个,可读性就上来了
迭代器的自增自减
前置 ++ 直接改 _node 并返回引用,后置 ++ 先保存一份拷贝再改,返回旧值。-- 同理,只是方向换成 _prev。这些都是 C++98 就有的基本功。
四、insert 和 erase:所有增删的老祖宗
这个实现遵循了一条很好的原则:所有的增删操作,最后都落到 insert 和 erase 头上。
insert:在指定位置前插入
cpp
iterator insert(iterator it, const T& x)
{
Node* cur = it._node; // 当前位置
Node* prev = cur->_prev; // 前一个位置
Node* newnode = new Node(x); // 新节点
newnode->_next = cur;
cur->_prev = newnode;
newnode->_prev = prev;
prev->_next = newnode;
++_size;
return newnode; // 返回新节点的迭代器
}
四步指针操作,把新节点挂到 prev 和 cur 之间:
cpp
插入前: prev <──> cur
插入后: prev <──> newnode <──> cur
有了 insert,push_back 就一行:
cpp
void push_back(const T& x)
{
insert(end(), x);
}
end() 返回 _head,在 _head 前面插入,就等于在最后一个元素后面插入------板上钉钉的尾插。
erase:删除指定位置
cpp
iterator erase(iterator pos)
{
assert(pos != end()); // 不能删哨兵节点
Node* prev = pos._node->_prev;
Node* next = pos._node->_next;
prev->_next = next;
next->_prev = prev;
delete pos._node;
--_size;
return next; // 返回被删节点的下一个
}
两步指针操作把 pos 节点从链上摘下来,然后 delete 释放内存。返回 next 是关键------这样遍历删除时才能安全地继续走:
cpp
list<int> lt;
// ... 往里塞了一堆数据
list<int>::iterator it = lt.begin();
while (it != lt.end())
{
if (*it % 2 == 0)
{
it = lt.erase(it); // 返回值接住,不会野指针
}
else
{
++it;
}
}
你要是写成 lt.erase(it); ++it;,程序直接炸------it 已经指向一块被释放的内存了,再对它 ++ 就是未定义行为
有了 erase,pop_back 和 pop_front 也各是一行:
cpp
void pop_back() { erase(--end()); }
void pop_front() { erase(begin()); }
这种"复杂操作复用简单操作"的写法,让代码又短又不容易出 bug------改一处,所有增删路径一起受益
五、构造、拷贝与析构:三大件怎么处理
默认构造
cpp
list()
{
empty_init();
}
调 empty_init() 建好哨兵节点,_size 归零。
拷贝构造
cpp
list(const list<T>& lt)
{
empty_init();
for (list<T>::const_iterator it = lt.begin(); it != lt.end(); ++it)
{
push_back(*it);
}
}
先把自己的哨兵建好,再遍历源链表逐个拷进来。深拷贝,两个链表各自独立。
赋值运算符:copy-and-swap 经典写法
cpp
list<T>& operator=(list<T> lt)
{
swap(lt);
return *this;
}
注意参数是传值 ,不是传引用。调用 operator= 时,lt 已经是源对象的一个拷贝了(拷贝构造在这期间完成)。然后把自己的 _head 和 _size 跟 lt 交换,函数结束时 lt(拿着原来的旧数据)被析构,旧资源就自动释放了。
析构函数
cpp
~list()
{
clear();
delete _head;
_head = NULL;
}
clear() 把所有数据节点逐个删掉,最后把哨兵 _head 也 delete 掉,指针置空。
clear() 的实现复用了 erase:
cpp
void clear()
{
list<T>::iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
六、size 和 empty:两个小但重要的接口
cpp
size_t size() const { return _size; }
bool empty() const { return _size == 0; }
这个实现单独维护了一个 _size 成员变量来缓存链表长度。有些教科书里的实现在意内存、不存 _size,每次 size() 时遍历一遍------O(n) 的 size 在 C++98 时代就已经被吐槽了,标准委员会后来明确要求 list::size() 必须是 O(1)。这个实现老老实实维护 _size,所有增删操作同步更新。
七、const 迭代器的实际用法
源码里的测试函数 test1 顺带演示了 const 迭代器的场景:
cpp
template<class Container>
void print_Container(const Container& con)
{
typename Container::const_iterator it = con.begin();
while (it != con.end())
{
// *it += 10; // 编译报错!const 对象只能用 const 迭代器,只读
cout << *it << " ";
++it;
}
cout << endl;
}
当你把容器以 const& 传入函数时,con.begin() 调的是 const 版本的 begin(),返回 const_iterator。这时 *it 返回的是 const T&,写操作在编译期就被拦住了。
对比普通迭代器的用法:
cpp
list<int> lt;
lt.push_back(1); lt.push_back(2); lt.push_back(3); lt.push_back(4);
list<int>::iterator it = lt.begin();
while (it != lt.end())
{
*it += 10; // 普通对象可读可写
cout << *it << " ";
++it;
}
// 输出: 11 12 13 14
八、从头到尾看一遍:完整类结构
整理下来,这个 list 实现的骨架如下:
| 组件 | 作用 | 关键设计 |
|---|---|---|
list_node<T> |
节点 | _data + _prev + _next |
list_iterator<T, Ref, Ptr> |
迭代器 | 用模板参数统一 iterator / const_iterator |
empty_init() |
初始化 | 建哨兵头节点,自己指自己 |
insert(pos, x) |
插入核心 | 四步指针,返回新节点迭代器 |
erase(pos) |
删除核心 | 两步指针 + delete,返回下一个迭代器 |
push_back / pop_back / pop_front |
便捷增删 | 全部复用 insert / erase |
| 拷贝构造 | 深拷贝 | 遍历源链表逐个 push_back |
operator= |
赋值 | copy-and-swap,异常安全 |
~list() |
析构 | clear 所有数据节点 + delete 哨兵 |
size() / empty() |
查询 | 缓存 _size,O(1) |
369 行代码,实现了 STL list 的核心功能子集。麻雀虽小,五脏俱全。
九、面试会问到什么
如果你在面试里被要求"手写一个 list",说清楚下面几点基本就能过关:
-
为什么用双向循环链表而不是单向? 双向才能 O(1) 拿到
_prev,--end()直接到最后一个元素;循环的好处是哨兵节点既是头也是尾,边界统一。 -
哨兵节点解决了什么问题? 不用到处判断
NULL,end()始终有效,插入删除逻辑统一。 -
迭代器怎么同时支持 iterator 和 const_iterator? 模板参数控制返回值类型,一套代码两套行为。
-
insert 和 erase 为什么是核心? 所有增删都复用它们,改一处生效全局。
-
赋值运算符怎么保证异常安全? copy-and-swap:先拷贝再交换,异常只发生在拷贝阶段,不影响原对象。
-
list 的迭代器属于哪种类型? 双向迭代器(bidirectional iterator),支持
++和--,不支持+n和[]。
十、补充说明:initializer_list 构造
源码里还有一个 initializer_list 的构造函数:
cpp
list(std::initializer_list<T> il)
{
empty_init();
for (std::initializer_list<T>::iterator it = il.begin(); it != il.end(); ++it)
{
push_back(*it);
}
}
这个其实已经是 C++11 的东西了------C++98 里没有 std::initializer_list。让你能写 list<int> lt = {1, 2, 3, 4, 5} 这种初始化。如果你在用老编译器编译这份代码,可以把 initializer_list 去掉,用普通构造函数替代
