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

相关推荐
wdfk_prog19 小时前
嵌入式面试真题第 10 题:高优化等级下共享状态可见性、内存模型与系统级同步设计
java·linux·开发语言·面试·职场和发展·架构·c
谭光志20 小时前
Vibe Coding 时代,程序员该何去何从
前端·后端·ai编程
私人珍藏库21 小时前
[Android] 会计快题库 -财会职称考试刷题学习
android·人工智能·学习·app·软件·多功能
GoGeekBaird21 小时前
我用 BeeWeave 跑完了一次完整写作闭环
后端·github
闻道且行之21 小时前
TurboOCR:基于PP-OCRv6的极速Windows离线OCR工具,深度解析3.4GB依赖背后的技术架构
c++·人工智能·python·qt·机器学习·ocr
绝世番茄1 天前
鸿蒙HarmonyOS ArkTS原生拖拽排序深度解析
深度学习·华为·list·harmonyos·鸿蒙
仿生狮子1 天前
别再说“全栈”了,AI 时代团队只认这 5 种人
前端·人工智能·后端
许彰午1 天前
95_Python内存管理与垃圾回收
开发语言·python
多加点辣也没关系1 天前
JavaScript|第13章:原始类型的方法
开发语言·javascript·ecmascript