第9.7章: list

English

概述

::fast_io::list 提供若干直接操作链表结构、不复制元素的操作。它们比 vector 上的等价操作更高效,因为只需调整指针。

1. 特有操作表

操作 说明
splice(pos, it) 把 it 所指元素移动到 pos 之前
splice(pos, first, last) 把范围 [first, last) 移动到 pos 之前
splice(pos, other) 把 other 中的所有元素移动到 pos 之前
sort() / sort(cmp) 就地排序整个链表
sort(first, last) 就地排序一个子范围
merge(other) / merge(other, cmp) 把已排序的 other 合并进这个已排序的链表
reverse() 反转所有元素的顺序
is_single() 恰有一个元素时返回 true
has_multiple() 有两个或更多元素时返回 true

2. 完整示例


#include <fast_io_dsal/list.h>
#include <fast_io.h>

int main() {
    ::fast_io::list<::std::size_t> a{10zu, 30zu, 50zu};
    ::fast_io::list<::std::size_t> b{20zu, 40zu};

    // Splice: move element from b to a
    auto bit = b.begin(); // points to 20
    auto ait = a.begin() + 1; // points to 30
    a.splice(ait, bit);
    // a is now {10zu, 20zu, 30zu, 50zu}
    // b is now {40zu}

    // Sort
    a.sort(); // a is now {10zu, 20zu, 30zu, 50zu} (already sorted)

    // Merge two sorted lists
    b.sort(); // b = {40}
    a.merge(::std::move(b));
    // a is now {10zu, 20zu, 30zu, 40zu, 50zu}
    // b is now empty

    // Reverse
    a.reverse();
    // a is now {50zu, 40zu, 30zu, 20zu, 10zu}
}

3. 为什么 list 往往比 vector 和 deque 慢得多

尽管在理论上插入与删除是 Θ(1),list 在真实工作负载中往往比 vector 或 deque 慢 10× 到 100×。原因在于数据局部性——容器的内存布局与 CPU 缓存体系结构的契合程度。

缓存行利用率

现代 CPU 按缓存行取内存,通常一行 64 字节。只要访问到某行中的任意一个字节,整行 64 字节都会被载入 L1 缓存。问题在于:这一行里有多少是真正有用的数据?

容器 布局 每 64 字节缓存行中的有用字节数(以 size_t 为例) 效率
vector 连续:[e0][e1][e2]...[e7] 64 / 64 = 64 字节(8 个元素) 100%
deque 按块分块:每个块内部连续 64 / 64 = 64 字节(每块 8 个元素) 块内约 100%
list 结点散布:每个结点都是一次独立的堆分配 8 / 64 = 8 字节(每缓存行 1 个元素) ~12%

一个 list<::std::size_t> 结点除了 8 字节的值,还有两个 8 字节指针(prev/next),加上对齐填充总共约 24–32 字节。但每个结点位于不同的堆地址,所以每次访问都可能触发缓存未命中。CPU 必须等待主存(约 100 ns),而不是 L1 缓存(约 1 ns)。

指针追逐使预取器失效

现代 CPU 使用硬件预取器,它能识别顺序访问模式,并在需要之前把将要使用的缓存行载入。这对 vector(连续、可预测)效果完美,对 deque(顺序的块)效果尚可。但 list 需要指针追逐:每个结点的地址只有在读完前一个结点的 next 指针之后才知道。预取器无法预测这些地址,因此每次访问都可能缓存未命中。

常数因子压倒了渐近优势

链表提供 Θ(1) 的插入/删除,而 vector 是 O(n)(针对序列中间的操作)。但两者的常数因子差异巨大:

在实践中,除了需要稳定的迭代器(不会因为别处的插入/删除而失效的迭代器)之外,几乎任何工作负载下 vector 都会胜出。

基准测试:list 对 vector 对 deque

压入 1000 万个 ::std::size_t 元素,然后遍历求和:

容器 push_back 遍历(求和) 总计
vector (no reserve) 0.162 s 0.014 s 0.185 s
vector + reserve() 0.093 s 0.012 s 0.114 s
deque (plain) 0.096 s 0.026 s 0.138 s
deque + resize_for_overwrite() 0.078 s 0.012 s 0.105 s
list 0.768 s 0.121 s 1.686 s

list 比不带 reserve 的 vector 慢 9×(1.686 s 对 0.185 s),比带 reserve 的 vector 慢 15×(1.686 s 对 0.114 s)。甚至单纯遍历也比 vector 慢 9×(0.121 s 对 0.014 s),因为每访问一个结点都会缓存未命中。

数据规模越大,差距越悬殊。当数据集超出 RAM 时,操作系统会把页面换到磁盘。每次访问 list 结点都可能变成缺页,需要磁盘 I/O — 每次访问约 10 ms,比 L1 缓存命中(约 100 ns)慢 100,000×。相比之下,vector 能受益于操作系统的预读:当一个页面缺页时,操作系统会批量预取相邻页面,把磁盘延迟分摊到数百个元素上。对 list 来说,每个随机地址都不太可能落在已预取的页面里,于是几乎每次访问都要付出完整的磁盘代价。在网络存储的延迟下(每次访问数毫秒),list 完全不可用。

什么时候真的该用 list

除此之外几乎所有场合 — 尤其是遍历密集的工作负载 — 请优先使用 vector 或 deque。

要点