C++ STL 之 list 与 forward_list 详解:用法→原理→面试

C++ STL 之 list 与 forward_list 详解:用法→原理→面试

三段式:先讲调用,再讲原理,最后给口语化面试答案。list 和 forward_list 是 STL 中唯二的链表容器,前者是双向链表,后者是单向链表,核心优势是任意位置 O(1) 插入/删除。


一、用法速查

1.1 初始化

#include <list>
#include <forward_list>
#include <iostream>
using namespace std;

int main() {
    // --- list ---
    list<int> l1;                         // 空链表
    list<int> l2(5);                      // 5个元素,默认0
    list<int> l3(5, 1);                   // 5个1
    list<int> l4{1, 2, 3, 4, 5};          // 初始化列表
    list<int> l5(l4);                     // 拷贝构造

    // --- forward_list ---
    forward_list<int> fl1;                // 空单向链表
    forward_list<int> fl2(5);             // 5个元素,默认0
    forward_list<int> fl3(5, 1);          // 5个1
    forward_list<int> fl4{1, 2, 3};       // 初始化列表

    for (int x : l4) cout << x << " ";    // 1 2 3 4 5
    cout << "\n";
}

1.2 元素访问

#include <list>
#include <forward_list>
#include <iostream>
using namespace std;

int main() {
    list<int> l{10, 20, 30, 40};
    cout << l.front() << "\n";            // 10
    cout << l.back() << "\n";             // 40

    auto it = l.begin();
    advance(it, 2);                       // O(N) 只能步步走
    cout << *it << "\n";                  // 30

    forward_list<int> fl{10, 20, 30};
    cout << fl.front() << "\n";           // 10
    // fl.back() 不存在!forward_list 只有单向头访问
}

list 没有 operator[]at(),不支持随机访问。要走到第 N 个元素必须用 advancenext,时间复杂度 O(N)。

1.3 头尾增删

#include <list>
#include <forward_list>
#include <iostream>
using namespace std;

void print(const list<int>& l) {
    for (int x : l) cout << x << " ";
    cout << "\n";
}

void print_fl(const forward_list<int>& fl) {
    for (int x : fl) cout << x << " ";
    cout << "\n";
}

int main() {
    // --- list: 双端操作 ---
    list<int> l;
    l.push_back(1);                       // 1
    l.push_front(0);                      // 0 1
    l.push_back(2);                       // 0 1 2
    print(l);                             // 0 1 2

    l.pop_back();                         // 0 1
    l.pop_front();                        // 1
    print(l);                             // 1

    // --- forward_list: 只有前端操作 ---
    forward_list<int> fl;
    fl.push_front(2);                     // 2
    fl.push_front(1);                     // 1 2
    fl.push_front(0);                     // 0 1 2
    print_fl(fl);                         // 0 1 2

    fl.pop_front();                       // 1 2
    print_fl(fl);                         // 1 2

    // fl.push_back(3);  // 编译错误!无 push_back
    // fl.pop_back();    // 编译错误!无 pop_back
}

1.4 insert / erase

#include <list>
#include <iostream>
using namespace std;

void print(const list<int>& l) {
    for (int x : l) cout << x << " ";
    cout << "\n";
}

int main() {
    list<int> l{1, 2, 3, 4, 5};

    // insert:在迭代器之前插入
    auto it = l.begin();
    ++it; ++it;                            // 指向3
    l.insert(it, 99);                      // 在3之前插入99
    print(l);                              // 1 2 99 3 4 5

    // erase:删除迭代器指向的元素
    it = l.begin();
    ++it;                                  // 指向2
    it = l.erase(it);                      // 删除2,返回下一个(99)
    print(l);                              // 1 99 3 4 5
    cout << *it << "\n";                   // 99

    // erase 区间
    auto first = l.begin();
    auto last = l.begin();
    advance(first, 1);                     // 指向99
    advance(last, 3);                      // 指向4
    l.erase(first, last);                  // 删除99, 3
    print(l);                              // 1 4 5
}

1.5 splice — list 的核心操作(重点)

splice 把节点从一个 list 转移到另一个 list,不拷贝元素,只调整指针,时间复杂度 O(1)。

原始链表 L2

摘除节点 B

将 B 插入 L1

完成 O(1) 转移

#include <list>
#include <iostream>
using namespace std;

void print(const list<int>& l) {
    for (int x : l) cout << x << " ";
    cout << "\n";
}

int main() {
    list<int> l1{1, 2, 3, 4, 5};
    list<int> l2{10, 20, 30};

    // splice 整个 l2 到 l1 的 begin() 位置
    l1.splice(l1.begin(), l2);
    print(l1);                             // 10 20 30 1 2 3 4 5
    cout << "l2.size: " << l2.size() << "\n"; // 0,l2 被掏空

    // splice 单个节点
    list<int> l3{100, 200};
    auto it = l1.begin();
    advance(it, 2);                        // 指向30
    l1.splice(it, l3, l3.begin());
    print(l1);                             // 10 20 100 30 1 2 3 4 5

    // splice 区间
    list<int> l4{1, 2, 3};
    list<int> l5{10, 20, 30, 40, 50};
    auto first = l5.begin();
    auto last = l5.begin();
    advance(first, 1);                     // 指向20
    advance(last, 4);                      // 指向50
    l4.splice(l4.end(), l5, first, last);  // 把20,30,40接到l4末尾
    print(l4);                             // 1 2 3 20 30 40
    print(l5);                             // 10 50
}

1.6 remove / remove_if / unique

#include <list>
#include <iostream>
using namespace std;

void print(const list<int>& l) {
    for (int x : l) cout << x << " ";
    cout << "\n";
}

int main() {
    list<int> l{1, 3, 3, 2, 5, 3, 4, 5, 5};

    l.remove(3);                           // 删除所有值为3的元素
    print(l);                              // 1 2 5 4 5 5

    l.remove_if([](int x) { return x > 3; });
    print(l);                              // 1 2

    // unique:删除相邻重复(只保留一个)
    list<int> l2{1, 1, 2, 3, 3, 3, 4, 4};
    l2.unique();
    print(l2);                             // 1 2 3 4

    // unique 备注:标准要求二元谓词必须是等价关系
    // a + 1 == b 不满足自反性和对称性,因此是未定义行为
    // 生产代码中务必使用符合等价关系的谓词
}

1.7 merge / sort / reverse

#include <list>
#include <iostream>
using namespace std;

void print(const list<int>& l) {
    for (int x : l) cout << x << " ";
    cout << "\n";
}

int main() {
    // merge:合并两个已排序的 list
    list<int> l1{1, 3, 5, 7};
    list<int> l2{2, 4, 6, 8};
    l1.merge(l2);                          // l1 归并 l2,l2 变空
    print(l1);                             // 1 2 3 4 5 6 7 8

    // sort:list 有自己的 sort(归并排序)
    list<int> l3{5, 1, 4, 2, 3};
    l3.sort();
    print(l3);                             // 1 2 3 4 5

    l3.sort(greater<int>());               // 降序
    print(l3);                             // 5 4 3 2 1

    // reverse
    list<int> l4{1, 2, 3, 4, 5};
    l4.reverse();
    print(l4);                             // 5 4 3 2 1
}

1.8 forward_list 的 insert_after / erase_after

forward_list 是单向链表,只能在当前节点之后插入或删除,没有 inserterase,只有 insert_aftererase_after。它还有一个特殊的 before_begin 迭代器,指向首元素之前。

#include <forward_list>
#include <iostream>
using namespace std;

void print(const forward_list<int>& fl) {
    for (int x : fl) cout << x << " ";
    cout << "\n";
}

int main() {
    forward_list<int> fl{1, 2, 3, 4, 5};

    // insert_after:在指定位置之后插入
    auto it = fl.begin();                  // 指向1
    fl.insert_after(it, 99);               // 在1之后插入99
    print(fl);                             // 1 99 2 3 4 5

    // 在头部插入:用 before_begin
    fl.insert_after(fl.before_begin(), 0);
    print(fl);                             // 0 1 99 2 3 4 5

    // erase_after:删除指定位置之后的元素
    it = fl.begin();                       // 指向0
    fl.erase_after(it);                    // 删除0后面的1
    print(fl);                             // 0 99 2 3 4 5

    // erase_after 区间
    auto first = fl.before_begin();
    auto last = fl.begin();
    advance(last, 3);                      // 指向3
    fl.erase_after(first, last);           // 删除 first 之后到 last 之前的元素
    print(fl);                             // 3 4 5
}

1.9 常用函数速查

方法listforward_list含义复杂度
push_back尾部插入O(1)
pop_back尾部删除O(1)
push_front头部插入O(1)
pop_front头部删除O(1)
insert / erase任意位置插/删O(1)
insert_after / erase_after指定位置之后插/删O(1)
splice节点转移O(1)
remove / remove_if按值删除O(N)
unique去重相邻重复O(N)
merge归并已排序链表O(N)
sort排序O(NlogN)
reverse反转O(N)
size✓ (O(1) C++11起)元素个数O(1) / ✗
back尾部元素O(1)

二、底层原理

2.1 双向链表节点结构

std::list 底层是双向循环链表(gcc 实现带哨兵节点)。每个节点包含三个字段:

┌──────────────────────────────┐
│         list_node            │
│  ┌─────────┬─────────┬─────┐ │
│  │  prev*  │  next*  │  T  │ │
│  │ (前驱)   │ (后继)   │(数据)│ │
│  └─────────┴─────────┴─────┘ │
└──────────────────────────────┘
  • prev:指向前一个节点,首节点的 prev 指向哨兵或尾部
  • next:指向后一个节点,尾节点的 next 指向哨兵或首部
  • T:存储的元素数据

在 x64 架构下,每个 list 节点额外开销 = 2 个指针 = 16 字节(不含堆分配器自身的 cookie)。forward_list 只有 1 个 next 指针,每节点开销 8 字节。

对比 vector 的内存开销:

容器每个元素额外开销原因
vector0 字节连续存储,只有数据本身
list16 字节每个节点 2 个指针
forward_list8 字节每个节点 1 个 next 指针

list<int> 为例:int 占 4 字节,每个节点额外 16 字节指针开销——有效载荷只占 20%

2.2 节点级分配 vs 连续分配

最根本的区别在于内存布局和分配方式:

  • vector:一次分配一块连续大内存,元素紧密排列。插入尾部 O(1) 均摊,中间插入 O(N)——需要挪动后续所有元素。
  • list:每个节点单独分配在堆上,节点间通过指针连接。任意位置插入/删除 O(1)——只需修改相邻节点指针,不涉及元素拷贝。
  • forward_list:同 list,每个节点单独分配,但指针少一半。

vector 内存

连续地址
[a][b][c][d]

list 内存

指针连接
a<->b<->c<->d
节点分散各处

vector 插入中间元素

需挪动后续所有元素
O(N)

list 插入中间元素

只改相邻节点指针
O(1)

2.3 splice 的 O(1) 原理

splice 是 list 独有的高效操作。它把源链表的节点整个摘下,挂到目标链表上,只调整 prevnext 指针——不拷贝、不移动、不分配元素。

// splice 一个节点 L1.splice(pos, L2, it) 等价于(伪代码):
auto node = it;              // 记下要转移的节点
it->prev->next = it->next;   // 从 L2 中摘除
it->next->prev = it->prev;
node->prev = pos->prev;      // 挂到 L1 的 pos 之前
node->next = pos;
pos->prev->next = node;
pos->prev = node;

整个过程仅 6 条指针操作,与链表长度无关,严格 O(1)。

原始链表 L2

摘除节点 B

将 B 插入 L1

完成 O(1) 转移

2.4 为什么 forward_list 没有 size()

std::forward_list 不提供 size() 方法。原因在于它的设计哲学是最小开销

维护 size 计数器?

splice_after 转移区间时
需 O(N) 遍历计数才能更新
破坏 splice O(1) 承诺

用户调用 distance()
自行 O(N) 遍历计算
forward_list 保持最小开销

  • 如果维护 size 计数器,insert_after / erase_after / splice_after 时都需要同步更新
  • splice_after 可能把任意长度的区间从一个 forward_list 移到另一个——要知道区间长度,要么每次 O(N) 数一遍,要么破坏 splice O(1) 的承诺
  • forward_list 的设计哲学就是最小开销——单向链表本身是为了节省那一个 prev 指针,再加一个计数器就违背了初衷

需要大小时只能自己用 std::distance(fl.begin(), fl.end()) 遍历计数,O(N)。

2.5 C++11 引入 forward_list 的动机

C++11 之前 STL 只有 list(双向链表)。引入 forward_list 的原因:

  1. 内存减半:每个节点少一个 prev 指针(8 字节),大量元素时差别显著
  2. 空间局部性略优:节点更小,缓存压力更小
  3. 与 C 风格单向链表对应:很多底层数据结构(如哈希表的链地址法、邻接表)天然是单向链表
  4. 语义更轻量:明确告诉读者"只需单向遍历",降低心智负担

典型使用场景:哈希表的桶内链、图的邻接表、内存池的空闲链表。

2.6 遍历性能对比

vector 遍历: 地址连续
CPU 预取命中率高

一次 cache line
加载多个相邻元素

list 遍历: 节点地址分散
频繁 cache miss

每一步都可能
触发 L3 miss → ~100ns 延迟

list 遍历慢的根源在于内存碎片化——每个节点是单独 new 出来的,分布在堆的不同区域。即使逻辑上相邻,它们在物理内存上也往往不相邻。CPU 的预取器对 vector 的连续内存高效工作,但对 list 的随机跳转无能为力。


三、面试题 + 口语化答案

Q1:splice 为什么是 O(1)?

“splice 不拷贝元素,只改指针。把源链表的节点从前后节点中断开(摘链),再挂到目标链表的指定位置(接链),全程大概 6 条指针赋值,和链表长度没关系。”

Q2:list 的 sort 和 vector 的 sort 有什么区别?

“vector 的 sort 用的是快速排序(或 introsort),依赖于随机访问迭代器——要能 O(1) 跳到中间做 partition。list 的迭代器是双向的,不支持随机访问,所以不能用快排。list 自己的 sort 用的是归并排序——自底向上归并,不依赖随机访问,正好适合链表结构。时间复杂度都是 O(NlogN),但 list sort 的常数更大。”

Q3:为什么 list 的插入不会导致已有迭代器失效?

“因为 list 每个节点是独立分配的堆内存,插入新节点只是在前后节点之间接上新节点,已有节点的地址不变,指向它们的迭代器自然有效。这和 vector 完全不同——vector 插入可能触发扩容整块内存搬迁,所有迭代器全废了。list 只有被 erase 的那个节点的迭代器会失效,其他全部有效。”

Q4:forward_list 和 list 怎么选?

“主要看两个维度。第一,是否需要双向遍历——不需要就选 forward_list,节省一半指针内存。第二,是否需要 size()——需要快速获取元素个数时只能选 list(C++11 起保证 O(1))。另外,如果需要 splice 把区间从一个链表转移到另一个,list 的 splice 是 O(1),forward_list 虽然也有 splice_after,但不会自动更新计数器,反而容易出 bug。”

Q5:erase 的返回值在 C++11 前后有什么变化?

“C++11 之前,list 的 erase(iterator) 返回 void(gcc 实现)或者根本不定义返回值(标准未要求)。C++11 起,标准统一要求所有容器的 erase 返回被删除元素的下一个元素的迭代器。所以现在你能写 it = l.erase(it) 安全地一边删除一边遍历。《Effective STL》里提到的那种删除循环的写法,C++11 之后已经不需要了——直接返回下一个有效的迭代器。”

Q6:list 遍历为什么比 vector 慢?

“根本原因在于 cache locality。vector 的元素在内存里连续排列,CPU 加载一个元素时会把相邻几十字节一并装入缓存,遍历下一个元素时几乎 100% 缓存命中。list 的每个节点是独立 new 出来的,逻辑相邻的元素在物理内存上可能隔得很远——很可能每个节点访问都触发一次 cache miss。现代 CPU 的内存访问延迟大约 100ns(L3 miss 时),list 遍历的瓶颈不在 CPU 算力,在内存带宽和缓存命中率。”

Q7:list 的 size() 在 C++11 前后有什么变化?

“C++11 之前,list 的 size() 可以是 O(N) 的——gcc 的实现确实会遍历计数。C++11 起,标准强制要求 size() 必须是 O(1),所以所有主流实现都在 list 内部维护了一个计数器,插入加 1,删除减 1。但 forward_list 仍然没有 size()——因为它不是容器通用的接口,标准不要求。”

Q8:什么场景下应该用 list 代替 vector?

“频繁在中间或头部插入/删除、且不需要随机访问的场景。比如维护一个 LRU 链表、做任务调度队列、或者当一个大缓冲的中间部分需要不断调整。另外如果对迭代器稳定性有要求——不能因为插入而让已有指针或引用失效——也只能用 list。不过大多数时候,vector 的性能表现比大多数人直觉中好很多——因为 cache 比 O 记号重要,实测 vector 的批量尾部插入远快于 list。”

Q9:forward_list 的 before_begin() 有什么意义?

“因为 forward_list 是单向的,只有 next 指针,没办法 O(1) 在首元素之前插入或删除。before_begin() 返回一个特殊的哨兵迭代器——它指向一个’虚拟的头节点之前的位置’,配合 insert_aftererase_after 就能在链表头部做操作了。这是 forward_list 设计的巧思:用一个虚拟哨兵简化了’第一个位置’的特殊性。”

Q10:为什么 list 的 remove 是 O(N),splice 是 O(1)?

remove 需要遍历整个链表找到值为目标的所有节点,然后逐个摘除并释放内存。splice 不需要查找——它直接操作迭代器指向的具体节点,只需要改指针。换句话说,O(N) 来自查找,O(1) 来自纯指针操作。如果你知道要转移的节点的迭代器,就是 O(1);你不知道就需要先 find(O(N))再 splice(O(1))。”


一句话总结:list 和 forward_list 通过节点级分配和指针连接实现了任意位置 O(1) 插入/删除,代价是每个节点的额外指针开销(16 字节 / 8 字节)、不支持随机访问、以及遍历时因 cache miss 导致的性能劣化——splice 是它们最精华的操作,理解它也就理解了链表指针操作的精髓。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ricky_Theseus

感谢大家,祝您生活愉快

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值