第9.9章: deque

English

概述

::fast_io::deque<T>(读作“deck”)能在两端高效地插入与删除,同时仍支持用 operator[] 进行随机访问。

1. 内部结构

vector 把所有元素存放在一个连续的块里,deque 则不同,它采用块映射设计:


// Conceptual layout of a deque:
//
// Controller: [ptr_to_block0] [ptr_to_block1] [ptr_to_block2] ...
//                    |               |               |
//               Block 0:        Block 1:        Block 2:
//             [e0][e1]..[eN]  [e0][e1]..[eN]  [e0][e1]..[eN]

当 deque 需要在前端增长时,它分配一个新块并把指针加到控制器前端,不需要搬移任何已有元素,后端同理。这正是它相对 vector 的关键优势——vector 在前端增长时必须复制所有元素。

2. deque 特有的容量

由于 deque 在两端增长,它的每一端都有独立的容量:


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

int main() {
    using namespace ::fast_io::iomnp;

    ::fast_io::deque<::std::size_t> dq;

    // Reserve at each end independently
    dq.reserve_back(1000zu);
    dq.reserve_front(500zu);

    println("Front capacity: ", dq.front_capacity());
    println("Back capacity: ", dq.back_capacity());

    // Segment introspection
    println("Block size: ", decltype(dq)::block_size);
    println("Segments: ", dq.segments_count());
}

3. 什么时候 deque 优于 vector?

场景 选择 理由
最一般的使用 vector 缓存局部性更好、更简单、遍历更快
需要在前端增长 deque vector 不能 push_front;deque 可以摊还 Θ(1) 地做到
需要栈或队列 deque stack 与 queue 的默认底层容器
非常大的数据集并不断 push_back deque vector 重新分配时要复制所有元素;deque 只需分配一个新块
迭代性能要求很高 vector 连续内存对预取友好

为什么 deque 的分配更高效

vector 的增长方式是把整块缓冲区翻倍:容量耗尽时,它分配一块 2× 当前容量的新缓冲区,并复制每一个已有元素。对 1000 万个元素来说,最后一次重新分配要搬移约 500 万个元素,前一次约 250 万个,依此类推。所有重新分配中元素搬移的总次数大致等于最终的大小。

deque 的增长方式是分配一个新块(通常相当于 4 KB 的元素),并往控制器里加一个指针。永远不会搬移任何已有元素。每个块都是一次独立的小分配。这意味着:

这种效果在基准测试中看得见。压入 1000 万个 ::std::size_t 元素:

容器 push_back 耗时 总耗时
vector (no reserve) 0.187 s 0.233 s
vector + reserve() 0.095 s 0.126 s
deque (plain) 0.095 s 0.146 s
deque + resize_for_overwrite() 0.080 s 0.120 s

普通 deque(0.095 s)已经比不带 reserve 的 vector(0.187 s)快 2×,因为它没有复制代价。而预分配的 deque(0.080 s)甚至比带 reserve 的 vector(0.095 s)更快,因为 deque 避免了 reserve 所要求的那一次大分配。

deque 的缺点

尽管在分配上有优势,deque 有两个显著缺点,使得 vector 在很多场合仍是更好的选择:

1. 不连续

deque 的元素散布在多个堆分配的块上,没有单一连续的缓冲区可以交给 C API、memcpy、SIMD intrinsic,或者任何期望“T* 加元素个数”的库。正因如此,deque 上没有 data() 方法。

如果你需要把元素交给要求连续内存的 API,就必须把它们复制到临时的 vector 里,或者使用 segments API 一块一块地处理。

2. 最小占用很大

每个 deque 至少分配一个完整的块,即使它只有一个元素。块大小通常是 4 KB(4096 字节,选择这个值是为了对齐页或缓存行边界)。所以一个只装有一个 ::std::size_t(8 字节)的 deque,仍然要耗用约 4 KB 内存外加控制器开销。

这使得 deque 成为嵌套容器中内层维度的糟糕选择:


// BAD: 1000 inner deques, each allocating ~4 KB even if nearly empty
::fast_io::vector<::fast_io::deque<::std::size_t>> matrix(1000zu);
// Minimum memory: 1000 * 4 KB = 4 MB, even if each deque has only 1 element

// GOOD: 1000 inner vectors, each allocating only what they need
::fast_io::vector<::fast_io::vector<::std::size_t>> matrix(1000zu);
// Minimum memory: 1000 * ~24 bytes (vector overhead) = ~24 KB

作为经验法则:

4. 用 segments API 快速遍历

常规的 deque 迭代器在每次元素自增时都要检查块边界:


// What deque_iterator::operator++ does internally:
if (++curr_ptr == block_end) [[unlikely]]
    wrap_to_next_block(); // branch on every element

这个分支通常能被正确预测(对 8 字节类型,每块约 500 个元素),但它妨碍编译器自动向量化循环,并带来延迟。segments API 让你按块遍历,从而彻底消除逐元素检查。

segment 方法

方法 返回 说明
segments_count() size_type 当前正在使用的块数。
nth_segment(pos) span<T> 覆盖第 pos 个块中有效元素的可变 span。
const_nth_segment(pos) span<T const> 覆盖第 pos 个块中有效元素的 const span。
size_per_segment() size_type(静态) 每块最大元素个数(= block_size)。
size_bytes_per_segment() size_type(静态) 一个完整块的字节容量。

返回的 span 带有 .data() 和 .size(),因此内层循环会编译成一次普通的指针遍历 — 可自动向量化、可用 memcpy、热路径上无分支。只有第一个和最后一个 segment 可能是不满的,中间的 segment 总是恰好包含 block_size 个元素。

只读遍历


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

int main() {
    ::fast_io::deque<::std::size_t> dq;
    for (::std::size_t i{}; i < 10000zu; ++i)
        dq.push_back(i);

    ::std::size_t sum{};

    // Segment iteration: no per-element bounds check
    for (::std::size_t i{}, segs{dq.segments_count()}; i != segs; ++i)
    {
        for (auto const e : dq.const_nth_segment(i))
        {
            sum += e;
        }
    }

    ::fast_io::io::println("sum = ", sum);
}

可变遍历


// Write through segments
for (::std::size_t i{}, segs{dq.segments_count()}; i != segs; ++i)
{
    for (auto& e : dq.nth_segment(i))
    {
        e *= 2zu;
    }
}

为 memcpy / SIMD 直接取指针


// Get raw pointer + count from each segment
for (::std::size_t i{}, segs{dq.segments_count()}; i != segs; ++i)
{
    auto seg = dq.const_nth_segment(i);
    ::std::size_t const* data = seg.data();
    ::std::size_t count = seg.size();
    // pass (data, count) to memcpy, SIMD intrinsics, etc.
}

什么时候用 segments 而不是常规迭代

写法 用法
Simple range-for, algorithms, std::ranges 常规迭代器:for (auto const& e : dq)
Inner loop performance matters (sum, transform, SIMD) segments API:按块遍历
Need raw pointers for memcpy or C APIs segments API:seg.data() / seg.size()

5. 迭代器失效与指针/引用稳定性的区别

deque 的块映射设计带来了一个与 vector 不同的独特失效性质:

操作 迭代器 指向元素的指针 / 引用
push_back() / push_front() 全部失效。控制器可能被重新分配,而迭代器里编码了块的位置。 仍然有效(除非该元素被删除)。在两端增长时元素从不搬移 — 只是分配新块。
emplace_back() / emplace_front() 全部失效。 仍然有效 — 原因同 push_*。
pop_back() / pop_front() 全部失效。 指向被删除元素的指针/引用失效,其余仍然有效。
insert_index() / erase_index() (middle) 全部失效。 指向被删除元素的指针/引用失效。其余元素是否仍然有效,取决于元素是否在块之间被搬移。
clear() / clear_destroy() 全部失效。 全部失效。

为什么迭代器总会失效:deque 迭代器内部持有指向控制器(块指针数组)的指针。当控制器增长时 — 任何前端或后端插入都可能引发 — 旧的控制器被释放、新的被分配。所有指向旧控制器的迭代器因此悬空,尽管元素本身并没有移动。

为什么指针与引用在尾部插入后仍然有效:当 deque 在某一端增长时,它要么填满现有端块中的剩余容量,要么分配一个全新的块。不会复制或搬移任何已有元素。因此插入之前取得的指针或引用仍然指向同一处内存。


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

int main() {
    ::fast_io::deque<::std::size_t> dq{10zu, 20zu, 30zu};

    ::std::size_t& ref = dq[1zu]; // reference to element 20

    // Iterator is invalidated, but the reference survives:
    dq.push_back(40zu);
    // ref is still valid and refers to 20

    dq.push_front(5zu);
    // ref is STILL valid — elements are never moved on end insertions

    ::fast_io::io::println("ref = ", ref); // 20
}

与 vector 的对比:vector 把所有元素存放在一个连续数组里。当它增长超过容量时,会分配一个新数组并搬移每一个元素,这会使所有迭代器、指针和引用失效 — 不存在部分稳定。

要点