手撕 C++ List:把双向循环链表扒光看

手写一个 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 的第一个坎。标准库的做法是写两套几乎一样的代码:iteratorconst_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:所有增删的老祖宗

这个实现遵循了一条很好的原则:所有的增删操作,最后都落到 inserterase 头上

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;              // 返回新节点的迭代器
}

四步指针操作,把新节点挂到 prevcur 之间:

cpp 复制代码
插入前:  prev <──> cur
插入后:  prev <──> newnode <──> cur

有了 insertpush_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 已经指向一块被释放的内存了,再对它 ++ 就是未定义行为

有了 erasepop_backpop_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_sizelt 交换,函数结束时 lt(拿着原来的旧数据)被析构,旧资源就自动释放了。

析构函数

cpp 复制代码
~list()
{
    clear();
    delete _head;
    _head = NULL;
}

clear() 把所有数据节点逐个删掉,最后把哨兵 _headdelete 掉,指针置空。

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",说清楚下面几点基本就能过关:

  1. 为什么用双向循环链表而不是单向? 双向才能 O(1) 拿到 _prev--end() 直接到最后一个元素;循环的好处是哨兵节点既是头也是尾,边界统一。

  2. 哨兵节点解决了什么问题? 不用到处判断 NULLend() 始终有效,插入删除逻辑统一。

  3. 迭代器怎么同时支持 iterator 和 const_iterator? 模板参数控制返回值类型,一套代码两套行为。

  4. insert 和 erase 为什么是核心? 所有增删都复用它们,改一处生效全局。

  5. 赋值运算符怎么保证异常安全? copy-and-swap:先拷贝再交换,异常只发生在拷贝阶段,不影响原对象。

  6. 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 去掉,用普通构造函数替代