C++ list 原理与实践:双向链表、迭代器与简化实现

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::nextstd::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++ 不能做地址加一,它必须读取当前节点的 nextoperator-- 则沿 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_;
};

插入时的四次链接调整可以记成一句话:新节点夹在 prevnext 中间。

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::referenceTraits::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) 需要以已经找到位置为前提。理解哨兵节点和迭代器封装后,这些规则就不再需要死记。

相关推荐
小月土星21 小时前
LeetCode 142. 环形链表 II 超详细题解(快慢指针原理+逐行代码拆解)
算法
香菜TTT21 小时前
LangChain框架_学习笔记
笔记·学习·langchain
郡杰21 小时前
yudao-cloud及测试
后端
春水碧于天,画船听雨眠21 小时前
LangChain学习笔记(二)
笔记·学习·langchain
dok1221 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》 (11)kmalloc 动态内存分配和struct file中的私有数据
linux·驱动开发·算法
hansang_IR21 小时前
【题解】[COCI 2024/2025 #2] 流明 / Blistavost
c++·算法·动态规划·dp
卷无止境21 小时前
Python 装饰器:给函数穿件"外套",到底难在哪?
后端·python
深爱水瓶 血影S狂风21 小时前
Asp.Net无刷新上传并裁剪头像
后端·asp.net
不正经学生21 小时前
C语言指针进阶:const 和野指针——给指针加锁,向野指针宣战
c语言·开发语言·c++·算法·c#
程序喵大人21 小时前
【C++进阶】STL算法与函数对象 - 01 让算法去处理一段迭代器范围
开发语言·c++·算法·函数对象