现代视角下的线性表全解

从某种角度而言 线性表几乎是计算机科学内的基石 一切数据结构都可以被表述为线性表 也必须在某一刻内被表述为线性表

这其实是由于目前计算机的存储器所决定的 可以说 任何计算机的存储器设备 包括但不限于寄存器 高速缓存 内存 硬盘 具体到操作系统的宏观而言 它们本质上都是一个线性表 准确来说是可寻址的连续线性表 也就是说 宏观上 计算机信息都最终存储在线性表内

尽管在物理层面 它们可能同常规理解的可寻址的数组不太一样 但是对于暴露给操作系统的接口而言 比如逻辑块地址 (Logical Block Addressing, LBA) 目前的存储器确实都是可寻址的连续线性表 即广义数组

因此 线性表是一个最基础 甚至是最重要的数据结构

定义

那么 线性表的意义是什么呢 本质上 我们认为线性表 (Linear List) 是一个逻辑概念 它定义了一种数据元素之间的关系 它的核心特征是

  • 数据元素之间是一对一的线性关系
  • 除了第一个元素 每个元素都有且只有一个直接前驱
  • 除了最后一个元素 每个元素都有且只有一个直接后继

直观上 我们可以认为线性表是只有一行的字符串

从这个定义 我们可以推出更进一步的性质 这些性质

  • 有序性:线性表具有有序性 每一个元素都有唯一的索引表示自己的位置
  • 可遍历性:线性表可遍历 由于线性表的元素之间一定存在路径 对于线性表 这个路径还是唯一的 所以一定可以设计一个朴素遍历路线遍历所有元素
  • 线性/确定性:线性表内的元素的直接前驱(如果有)和直接后驱(如果有)是确认且唯一的

对于这里 线性/确定性是最基本的性质 有序性让我们得以谈论第i个元素 而可遍历性则作为迭代(广义遍历)的基础 毕竟迭代器能访问到一切元素的要求当且仅当可以遍历 即可以找到一条遍历路径访问所有元素

ADT

大多数数据结构ADT的构建在现代百花齐放 我们希望讨论一个粒度合适 且十分贴合实现的ADT

讨论一个数据结构的特征 最好分离它的实现来讨论 即只暴露接口 只讨论ADT 算法应该在ADT的基础上实现

对于线性表而言 它的元素与存储可以被数学表为如下性质:

数据应当有顺序:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> D = { a i ∣ a i ∈ ElemSet , i = 1 , 2 , ... , n , n ≥ 0 } D = \{a_i \mid a_i \in \text{ElemSet}, i=1,2,\dots,n, n \ge 0\} </math>D={ai∣ai∈ElemSet,i=1,2,...,n,n≥0}

而且应当有确认的直接前驱(如果有)和直接后驱(如果有):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> R = { < a i − 1 , a i > ∣ a i − 1 , a i ∈ ElemSet , i = 2 , ... , n } R = \{<a_{i-1}, a_i> \mid a_{i-1}, a_i \in \text{ElemSet}, i=2,\dots,n\} </math>R={<ai−1,ai>∣ai−1,ai∈ElemSet,i=2,...,n}

当然 上文其实有些许不贴合实践 所以我们看看它的ADT的接口设计

最基本ADT接口

对于线性表 有一套最基本的 传统的ADT 基本操作 (Basic Operations):

cpp 复制代码
template<typename T>  
concept ILinearListStatic = requires(T& repo, const T& const_repo, size_t index, const typename T::value_type& l_value)  
{  
	// 使用类型自省 我们要求容器类型内有类型名称(using)
    typename T::value_type;  
  
    // 作为元素良好类型的要求  
    requires std::movable<typename T::value_type>;  
  
    // 默认构造一个线性表  
    requires std::default_initializable<T>;  
  
    // 返回线性表的长度  
    { repo.size() } noexcept -> std::convertible_to<size_t>;  
  
    // 返回线性表的第index个元素  
    { repo[index] } -> std::same_as<typename T::value_type&>;  
    { const_repo[index] } -> std::same_as<const typename T::value_type&>;  
    { repo.at(index) } -> std::same_as<typename T::value_type&>;  
    { const_repo.at(index) } -> std::same_as<const typename T::value_type&>;  
  
    // 在线性表index位置上添加一个元素  
    { repo.insert(index, l_value) } -> std::same_as<void>;  
    { repo.insert(index, std::declval<typename T::value_type>()) } -> std::same_as<void>;  
    // 删除线性表的第index个元素  
    { repo.erase(index) } -> std::same_as<void>;  
};

如果觉得这样很晦涩 使用多态的实现如下

cpp 复制代码
template <typename T>  
class ILinearListDynamic  
{  
public:  
    /*  
     * 值语义 与ADT关系不大  
     * 为了避免对象切片 我们全部删除 并依赖于具体的实现来暴露实现相关语义的接口  
     */
    ILinearListDynamic(const ILinearListDynamic&) = delete;  
    ILinearListDynamic& operator=(const ILinearListDynamic&) = delete;  
    ILinearListDynamic(ILinearListDynamic&&) = delete;  
    ILinearListDynamic& operator=(ILinearListDynamic&&) = delete;  
  
    // 构造一个线性表  
    ILinearListDynamic() = default;  
    
    // 安全 合适的销毁线性表  
    virtual ~ILinearListDynamic() = default;  
    
    // 返回线性表的长度  
    virtual size_t size() const noexcept = 0;  
    
    // 返回线性表的第index个元素  
    virtual const T& operator[](size_t index) const = 0;  
    virtual const T& at(size_t index) const = 0;  
    virtual T& operator[](size_t index) = 0;  
    virtual T& at(size_t index) = 0;  
    
    // 在线性表index位置上添加一个元素  
    virtual void insert(size_t index, const T& value) = 0;  
    virtual void insert(size_t index, T&& value) = 0;  
    
    // 删除线性表的第index个元素  
    virtual void erase(size_t index) = 0;  
};

我们还可以使用Java的interface更简单得描述它

java 复制代码
public interface ILinearList<T>  
{  
    /**  
     * 获取线性表的长度  
     * @return 线性表的长度  
     */  
    long size();  
    /**  
     * 获取线性表的元素  
     * @param index 索引  
     * @return 线性表的元素  
     */  
    T at(long index);  
    /**  
     * 设置线性表的元素  
     * @param index 索引  
     * @param value 元素值  
     */  
    void set(long index, T value);  
    /**  
     * 在指定位置插入元素  
     * @param index 索引  
     * @param value 元素值  
     */  
    void insert(long index, T value);  
    /**  
     * 在指定位置插入元素  
     * @param index 索引  
     * @param value 元素值  
     */  
    void remove(long index);  
}

显然 我们可以使用以上的ADT接口处理关于暴露线性表的一切 这也是大多数传统教科书的ADT设计

局限性

上述设计非常优美 简单 但是以上设计其实有很大问题 虽然上述ADT可以很好的实现线性表的操作 但是在性能上并不优良 或者说 上述线性表的接口的粒度过大 缺乏一些细粒度的操作

接口粒度是很重要的东西 接口粒度过大会导致我们在进行某些细粒度的操作的时候缺乏优化空间

过小会使得ADT过于暴露实现

其中最主要的缺陷是缺少了迭代器的支持 没有运用线性表的可迭代性 迭代器可以提供面向迭代过程(类似于在线性表上行走)的粒度操作 所以在大部分编程语言内 我们所看到的内置的线性表接口是另一个版本的 它在传统的线性表ADT的基础上添加了迭代器

最明显的点莫过于链表(线性表的一种) 链表具有不良寻址性 即它进行随机访问(定位到元素的方法 可以体现在上述的operator[]()/at()/insert()/delete())的效率是很低的(尽管它定位到元素之后 进行CRUD操作的速度很快) 它需要一个指针慢慢沿着整个表慢慢走到O(n)的位置

而如果我们有一个细粒度场合 它要求遍历一遍表 或者删除某一块的元素 如果我们使用以上的ADT接口 调用将是类似于对某个操作按1 2 3 4 5的调用 此时由于每次调用 我们没有状态 都需要重新遍历定位元素 所以时间复杂度是1+2+3+4+5的 显然等差数列的和函数是O(n^2)

但是如果我们有迭代器可以记录元素 这一切都会被简化为先遍历到第i个元素 之后是每次遍历只需要移动一位 所以时间复杂度是O(i+n)的 即O(n)

可以发现 这一切都是O(n)而这个优化极度依赖于ADT接口提供细粒度的接口

带迭代器的ADT接口

迭代器提供了面向迭代过程(类似于在线性表上行走)的粒度操作 而这种细粒度的接口可以提供更多的优化空间

当然 它一定是毫无缺点吗 其实也并不一定 它的重要缺点体现在容器和迭代器的互操作下 迭代器内数据的安全性和可靠性问题 特别是并发场合 但是通过合理的设计和契约可以避免这个问题 但是我们确实引入了额外的数据和契约

但是这些额外的数据和契约都发生在实现层面 而不是现在的ADT应该讨论的事情 让我们

迭代器 (Iterator) 的抽象定义

如果说线性表定义了数据元素之间静态的一对一逻辑关系 那么迭代器则描述了在这些元素之上进行移动和访问的动态行为 它是一个独立于容器 但又与容器紧密协作的对象

我们可以认为 迭代器是数据访问的一种泛化指针

核心概念与性质

一个最基本的迭代器(前向迭代器)必须具备以下三种核心能力

  • 引用 (Dereferencing):迭代器必须能够返回它当前所指向的元素 这是迭代器最根本的价值所在 它连接了位置与值
  • 遍历 (Traversal):迭代器必须能够从当前元素移动到序列中的下一个元素 这是实现高效线性遍历的基础
  • 状态与比较 (State and Comparison):迭代器自身是有状态的(记录当前位置) 并且两个迭代器之间必须可以进行比较(通常是相等性比较) 以判断遍历是否到达了终点 或者两个迭代器是否指向同一位置
数学表达

我们可以将迭代器I抽象地看作一个包含容器和位置的二元组:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> I = ( C , p ) I=(C,p) </math>I=(C,p)

其中 C 是其所归属的容器 (Container) p 是其在容器内的位置 (position) 其核心操作可以被数学化地描述为:

  • 引用操作 value(I): 返回位置 p 上的元素值。

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> value ( ( C , p ) ) → C [ p ] \text{value}((C, p)) \rightarrow C[p] </math>value((C,p))→C[p]

  • 遍历操作 next(I): 这是一个操作族 不同的迭代器类别会支持不同的操作子集
  • 比较操作 equal(I₁, I₂): 判断两个迭代器是否指向同一容器的同一位置

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> equal ( ( C 1 , p 1 ) , ( C 2 , p 2 ) ) → ( true if C 1 = C 2 and p 1 = p 2 else false ) \text{equal}((C_1, p_1), (C_2, p_2)) \rightarrow (\text{true if } C_1 = C_2 \text{ and } p_1 = p_2 \text{ else false}) </math>equal((C1,p1),(C2,p2))→(true if C1=C2 and p1=p2 else false)

对于遍历操作 大致可以分为如下 它们都是状态转移函数

  • 前向遍历 next(I)

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> next ( ( C , p ) ) → ( C , p + 1 ) \text{next}((C, p)) \rightarrow (C, p+1) </math>next((C,p))→(C,p+1)

  • 双向遍历 prev(I)

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> prev ( ( C , p ) ) → ( C , p − 1 ) \text{prev}((C, p)) \rightarrow (C, p-1) </math>prev((C,p))→(C,p−1)

  • 随机访问 advance(I, n)

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> advance ( ( C , p ) , n ) → ( C , p + n ) , n ∈ Z \text{advance}((C, p), n) \rightarrow (C, p+n), \quad n \in \mathbb{Z} </math>advance((C,p),n)→(C,p+n),n∈Z

这个抽象定义几乎涵盖了所有编程语言中迭代器设计的理论基础

接口定义

带迭代器的ADT实际上只是比原先基础ADT多了个返回迭代器的接口 以及一些容器的面相迭代器的操作接口 其他并无差别 而我们在这里忽略迭代器自己的ADT 不过应当知道 迭代器也是一种数据结构 也需要容器编写具体实现

cpp 复制代码
#include <iterator>

template<typename T>  
concept ILinearListStatic = requires(T& repo, const T& const_repo, size_t index, const typename T::value_type& l_value)  
{  
    typename T::value_type;  
    
	// 作为元素良好类型的要求  
	requires std::movable<typename T::value_type>;  
	requires std::default_initializable<typename T::value_type>;
	
    // 默认构造一个线性表  
    requires std::default_initializable<T>;  
    
    // 返回线性表的长度  
    { repo.size() } noexcept -> std::convertible_to<size_t>;  
	
    // 返回线性表的第index个元素  
    { repo[index] } -> std::same_as<typename T::value_type&>;  
    { const_repo[index] } -> std::same_as<const typename T::value_type&>;  
    { repo.at(index) } -> std::same_as<typename T::value_type&>;  
    { const_repo.at(index) } -> std::same_as<const typename T::value_type&>;  
	
    // 在线性表index位置上添加一个元素  
    { repo.insert(index, l_value) } -> std::same_as<void>;  
    { repo.insert(index, std::declval<typename T::value_type>()) } -> std::same_as<void>;  
    // 删除线性表的第index个元素  
    { repo.erase(index) } -> std::same_as<void>;  
};

template<typename T>
concept ILinearListStaticWithIterator = requires(T& repo, const T& const_repo, typename T::iterator& l_it, typename T::iterator& cl_it, const typename T::value_type& l_value)
{
	// 应当有线性表ADT的基本接口
	requires ILinearListStatic<T>;
	
	// 使用类型自省 我们要求容器类型内有类型名称(using)
    typename T::iterator;
    typename T::const_iterator;
	
	// 迭代器应当至少是个前向迭代器
    requires std::forward_iterator<typename T::iterator>;
    requires std::forward_iterator<typename T::const_iterator>;

    // 容器必须提供 begin() 和 end() 方法 并返回正确的迭代器类型
    // 返回容器起始的迭代器
    { repo.begin() } -> std::same_as<typename T::iterator>;
    { const_repo.begin() } -> std::same_as<typename T::const_iterator>;
    // 返回容器末尾的迭代器
    { repo.end() } -> std::same_as<typename T::iterator>;
    { const_repo.end() } -> std::same_as<typename T::const_iterator>;
    
    // 删除对应元素
    { repo.erase(l_it) } -> std::same_as<void>;
    { repo.erase(cl_it) } -> std::same_as<void>;
    // 在迭代器前插入元素
    { repo.insert(l_it, l_value) } -> std::same_as<void>;
    { repo.insert(cl_it, l_value) } -> std::same_as<void>;
    { repo.insert(l_it, std::declval<typename T::value_type>()) } -> std::same_as<void>;
    { repo.insert(cl_it, std::declval<typename T::value_type>()) } -> std::same_as<void>;
};

这里的迭代器接口风格遵循标准库风格

我们还可以使用Java的interface更简单得描述它

java 复制代码
import java.util.Iterator;

public interface ILinearList<T>  
{  
    /**  
     * 获取线性表的长度  
     * @return 线性表的长度  
     */  
    long size();  
    /**  
     * 获取线性表的元素  
     * @param index 索引  
     * @return 线性表的元素  
     */  
    T at(long index);  
    /**  
     * 设置线性表的元素  
     * @param index 索引  
     * @param value 元素值  
     */  
    void set(long index, T value);  
    /**  
     * 在指定位置插入元素  
     * @param index 索引  
     * @param value 元素值  
     */  
    void insert(long index, T value);  
    /**  
     * 删除索引位置元素  
     * @param index 索引  
     */  
    void remove(long index);  
}

public interface ILinearListWithIterator<T> extends ILinearList<T>, Iterable<T>
{
    /**  
     * 删除迭代器对应位置的元素
     * @param it 指向待删除的迭代器
     */  
    void remove(Iterator<T> it);
    /**  
     * 在迭代器对应位置之前插入一个元素
     * @param it 指向一个元素的迭代器
     * @param value 插入的值
     */  
    void insert(Iterator<T> it, T value);
}

注意:这里的接口定义其实异于Java的标准做法 是C++风格的 Java通常在迭代器上提供删除方法 在容器上提供插入方法

由于返回类型协变和概念约束的存在 我们很自然还允许让容器使用这个接口实现更高级的迭代器 事实上 这里我们只限制了容器实现最低级的前向迭代器

实现

对于线性表的实现 大抵可以分为两种类型 顺序和链式 以及它们的混合

链与顺序

从某种角度说 这两个类型的区别是前继元素和后继元素 元素之间的链接方式差异 从另一种角度 也可以视为元素在内存空间的分布差异

  • 链式实现的元素之间使用指向对方的指针进行链接 元素在内存的分布是分块的 细碎的 一块一块的
  • 顺序存储的元素的之间使用在内存上的前继与后继进行链接 元素在内存的分布是连续的 一大块的

这两种实现的巨大差异可以在ADT的效率上产生很强的偏置 虽然它们具有相同的接口

传统复杂度分析

传统复杂度分析朴素的分析每个ADT操作具有的时间复杂度

  • size() 这取决于容器是否记录自己的大小 还是依赖于末尾和开头的边界标志 对于前者 时间复杂度是O(1)的 对于后者 由于要遍历到末尾 所以是O(n) 大部分实现都是前者
  • operator[]()/at() 对于顺序存储 如果我们有每个元素的字节 所以只是对指针的偏移 是O(1)的 对于链式 需要遍历到对应位置 时间复杂度是O(n)的(走到对应位置)
  • insert()/erase() 对于顺序存储 定位元素是O(1)的 但是插入 删除时要进行数据搬移(数据搬移到前面) 时间复杂度是O(n)的 对于链式 定位的时间复杂度是O(n)的 但是插入的时间复杂度是O(1)
  • remove/insertFront/insertBack(Iterator<T> it) 对于顺序操作 虽然不用定位元素 由于插入/删除的时间复杂度仍然是O(n) 所以操作仍然是O(n) 对于链式操作 由于不用定位元素 而插入/删除的时间复杂度是O(1) 所以整体为O(1)
  • 迭代器的一切操作 插入与删除均为O(1) 但是链式的最大支持是双向迭代 而顺序存储一般都支持随机访问

这里其实存在普遍的误解 即链表(链式的著名实现)的插入效率远远高于数组(顺序的著名实现)

如果我们不依赖于迭代器 或是迭代器无法给予一个操作更小的粒度 则对于删除和插入操作 线性和链式的时间复杂度都是O(n)

效率分析

传统的时间复杂度分析没有考虑进一步的效率因素 包括但不限于与计算机底层相关的一些因子 比如内存利用率与CPU缓存 在现代 还有SIMD操作的适配度

在传统的复杂度分析内 我们可以说十分青睐链式存储去实现数据结构 因素大抵可以概括如下

  • 链式存储的自由度非常高
  • 许多数据结构的插入/删除操作都可以使用迭代器的粒度优化 此时链表比数组效率显著较高
  • 链式存储对内存的利用率高 它可以完美高效的利用离散的堆内存
  • 链式存储存在一定的 存储前后继元素的指针的内存浪费

但是在现代 我们的青睐度正在转向顺序存储 这是因为顺序存储更贴合现代的计算机发展

  • 顺序存储兼容SIMD指令 这使得一部分对数组的操作跨时代的高效
  • 顺序存储可以有效运用CPU缓存 有很高的缓存命中率 可以降低对内存的IO 提升效率
  • 顺序存储可以对现代CPU的预取器非常友好 容易被预测优化
  • 现代内存大小较大 堆内存连续块的大小基本不可能成为瓶颈问题 不太需要链式存储的有效利用
  • 顺序存储的经典动态数组实现为了保证插入高效性的预留空间存在一定内存浪费

为了兼容链表的自由度和数组的高效性 链式和顺序混合的数据结构在现代也陆续产生

顺序表

顺序表描述了一个纯粹顺序存储的线性表 这常作为轻量级数据结构的基础与嵌入在重量级数据结构内 在现代 顺序表也正在替代许多链式表作为主流的重量数据结构的实现

线性数组

数组是一块只用来存储相同类型的连续空间 它的每个元素的大小可以自然由类型大小计算 内存偏移量自然很好被类型大小*元素偏移量计算

静态数组

静态数组一种的长度不变的数组 是数组的最基本的 简单的实现 同理 由于简单性 它通常是性能最高的数组

线性数组的底层都是一块内存空间 只是取决于怎么去使用那块内存空间 大多数语言都有按数组使用那块内存空间的语法糖

对于C++ 创建一块内存空间有两种方式 一种是在栈内创建 一种是在堆里创建 它们对应不同的语法糖 前一种只能接受编译时确认的数组大小参数(constexpr) 这是由它的编译后的产物决定的

堆与栈不是C++标准内容 C++标准内容只有生命周期的不同 因此 按标准而言 实际上只是生命周期的不同 不过 主流实现通常是堆与栈

cpp 复制代码
constexpr int CONSTEXPR_SIZE = 8;
int size = 8;

TypeName array1[CONSTEXPR_SIZE];
TypeName* array2 = new TypeName[size];

第二种创建方法通常有一种比较现代的替代方法 它更安全 即STL的array容器

cpp 复制代码
std::array<int, CONSTEXPR_SIZE> array2;

这种实现方法产生的接口和直接使用new基本没区别 不过它只接受常量 会做一些溢出检测 拥有STL通用接口 以及 使用更安全现代的通用迭代器代替了指针

如果是Java 则只能显式默认在堆内创建 当然JVM可能会对对应的变量做逃逸分析 然后给它丢到栈上作为优化 这个过程是不确定的 所以不应该对它做任何承诺

java 复制代码
int[] array = new int[8];

由于静态数组是很基础的数据结构 语言一般内置支持 这里简单讨论一下它们的ADT实现

  • 显然 我们可以使用array[index]去访问对应数组下的对应元素 注意 在C++内 只有std::array使用.at()才会检测索引是否越界 即超出原来的大小 而Java一定检测
  • 我们几乎无法让容器天然支持删除与插入元素 需要额外记录元素长度或是缺失的元素 对于有这个接口需求的场合来说 建议使用动态数组
  • 对于迭代器 可以使用指针或索引(两者效率一致)模拟迭代器 迭代器开头是这个数组的索引0 末尾是size - 1 但是要注意处理可能的越界问题 而对于C++的array 天然内置迭代器且处理越界问题
运用场合

静态数组作为最接近计算机底层的数据结构 被许多语言原生支持 且经常作为其他顺序存储的数据结构的基石

对于主动使用静态数组的场合 通常是我们追求极致的性能 而且发现某个数据的大小固定 且可以在栈分配(不逃逸)

动态数组

在线性数组内进行插入和删除元素的场合并不少见 所以诞生了动态数组 动态数组是对静态数组及其行为的封装 以及引入了一些额外的逻辑(如静态数组重分配)来实现的

主流的动态数组的实现逻辑大抵是通过引入额外的size变量表示自己的逻辑大小 而还有一个capacity变量表示自己的实际内存大小

  • 大小size size是我们可以真正使用的数组内的内存空间 即可以通过通过ADT接口访问
  • 已分配的内存空间capacity 已分配的内存空间包含了size和预留内存空间
  • 预留内存空间capacity() - size() 被用于数组插入时扩展的预留 避免每次扩展数组都要重新申请内存空间 不可以直接访问

之后我们简单讨论一下ADT实现 对于动态数组 sizecapacity是核心 它的一切行为都围绕这两个状态变量展开

  • 访问与修改 at()/operator[] set() 这组操作是最简单的 动态数组的访问与静态数组并无本质不同 都是通过基地址加偏移量的方式直接定位内存 唯一的区别在于 它的有效性检查是基于逻辑大小size 而不是物理大小capacity 所以 它的时间复杂度是纯粹的 O(1) 这也是顺序存储最核心的优势 随机访问的高效性
  • 插入操作 insert(index, value) 插入操作是动态数组复杂性的主要体现 它必须处理两种情况
    • 情况一 size < capacity 容量充足 这是一种幸运的情况 我们不需要与内存分配器打交道 为了在index位置插入新元素 我们需要将从indexsize - 1的所有元素向右移动一位 这为新元素腾出了空间 之后将新值放入index位置 更新size即可 这个过程涉及数据的搬移 时间开销取决于需要移动的元素数量 即size - index 因此 时间复杂度为O(n) 最坏情况是在表头插入O(n) 最好情况是在表尾追加O(1)
    • 情况二 size == capacity 容量耗尽 这是动态数组的关键所在 我们必须进行扩容 (Reallocation) 扩容是一个成本高昂但不可避免的操作 它的基本流程如下
      • 申请新空间 首先需要重置动态数组底层的 无法变动大小的静态数组 向内存分配器申请一块更大的内存空间 新的capacity通常是旧capacity的一个倍数 比如2倍或1.5倍 这很重要 这保证了在摊还分析下 动态数组在插入下的时间复杂度
      • 搬移旧数据 将旧内存空间中的所有size个元素 拷贝或移动到新的内存空间中
      • 释放旧空间 释放掉原有的那块较小的内存空间
      • 执行插入 在这个新的 拥有更大容量的内存空间上 重复情况一的插入逻辑
      • 更新状态 更新capacitysize以及指向内存的内部指针
  • 删除操作 erase(index) 删除操作相对简单 它通常不涉及容量的变化 只需要将index之后的所有元素向左移动一位 覆盖掉被删除的元素 然后更新size即可 它的时间复杂度同样取决于需要移动的元素数量 size - index - 1 所以是O(n)

从传统时间复杂度分析上看起来 似乎数组在插入和删除上十分不友好 十分缓慢 甚至有点完全不如链表的意思 但实际上不然 这体现在两个方面

  • 对重分配运用乘性函数策略 可以使得插入可能发生的重分配的时间复杂度完全被均摊
  • 现代CPU的缓存 预取 以及SIMD 对数据搬移做出了十分好的优化 这使得数据搬移的效率比朴素的计算快很多
  • 对于拥有值语义的C++ 移动语义的支持使得数据搬移中的元素赋值可以只需要更轻量级的移动来优化
重分配的时间复杂度分析

显然 扩容操作本身需要遍历整个数组 时间复杂度是O(n)的 这引出了一个重要问题 如果每次在末尾添加元素都可能触发O(n)的扩容 push_back操作的效率如何保证呢

这里就需要引入摊还分析(Amortized Analysis) 关键在于扩容的增长因子(Growth Factor) 必须是乘性的(multiplicative) 而不是加性的(additive)

如果我们每次只增加一个固定的量 比如capacity + 10 那么每10次操作就会有一次O(n)的扩容 平均下来性能很差 但如果我们每次将容量翻倍 capacity * 2 那么一次O(n)的昂贵操作后 会跟随着nO(1)的廉价操作 相当于把O(n)的成本分摊到了每一次操作上 平均下来 在表尾追加元素的时间复杂度就是摊还O(1) 这是一个极其重要的性质 它让动态数组在频繁追加元素的场景下依然能保持极高的效率

数据搬移的效率分析

现代CPU的SIMD允许我们通过一个指令达成执行同时对多个部分执行朴素指令操作的效果 通常效率显著高于对那些部分分别执行指令 前提是执行操作的部分被向量化
数组的连续性 使得在数据搬移的过程中 执行操作的部分及其容易被向量化 这使得我们及其容易通过SIMD加速数据搬移过程

C函数memovememcpy都集成了智能的SIMD优化 在不支持SIMD的CPU上会退化

并且 就算我们不使用SIMD 现代CPU的预取可以很好识别到在顺序的数组上操作元素的步骤 从而进行预处理优化 而CPU缓存也能很好存储进行数据搬移的连续的内存块
从元素赋值的效率而言 对于Java 动态数组一般都存储引用 而引用(本质上是一个整型)的赋值是极度快的对于C++ 如果也一样存储指针(包括智能指针) 那指针的复制同样很快 如果存储值的话 移动语义的支持也可以让元素赋值可以只需要更轻量级的移动来优化

内置实现

上述的理论与实现思路 构成了现代编程语言中一些最核心容器的基石

C++ std::vector

在C++标准库中 std::vector 就是动态数组最经典 最强大的实现 它完美封装了动态数组的一切 包括自动内存管理 扩容缩容逻辑 std::vector提供了随机访问迭代器 保证了operator[]O(1)访问和在尾部push_back的摊还O(1)性能 同时 它也对异常安全做出了强有力的保证 由于其内存连续性带来的缓存友好性 std::vector通常是C++程序员在需要序列容器时的默认首选

Java ArrayList

在Java集合框架(JCF)中 ArrayList 扮演了同样的角色 它的底层就是一个Object[]数组 同样通过sizecapacity的逻辑实现动态增长 当容量不足时 ArrayList也会进行扩容 Java中经典的增长因子是1.5倍 即 oldCapacity + (oldCapacity >> 1) 它提供了所有List接口的操作 在随机访问get(index)时具有O(1)的性能 而插入删除add(index, E) remove(index)则为O(n)

可以说 std::vectorArrayList等内置实现 将动态数组的复杂性隐藏在优雅的接口之下 让开发者可以专注于业务逻辑 同时享受到顺序存储在现代计算机硬件上的巨大性能优势 这也是我们之前在现代效率分析中讨论的 顺序存储更贴合现代计算机发展这一结论的实践体现

运用场合

伟大无需多言 作为顺序存储中 实现了线性表ADT的 且最接近计算机底层的数据结构 被大多数依赖顺序存储的数据结构广泛运用

就算作为直接使用的场合 它也相当有竞争力 它描述了顺序存储的一个优良且强大的模式 作为很多相同类型数据存储的默认选择

循环数组

动态数组解决了静态数组长度固定的问题 但它依然继承了线性数组的一个固有缺陷 即在数组头部进行插入和删除操作的低效性 这类操作需要移动后续所有元素 时间复杂度为O(n) 这是一个巨大的开销

为了克服这一缺陷 循环数组 (Circular Array)或称环形缓冲区(Ring Buffer)应运而生 它通过一种巧妙的逻辑设计 将线性数组的首尾相连 从而将头部的插入与删除操作的复杂度优化到与尾部操作相同的摊还O(1) 这种数据结构天然地实现了双端队列(Deque, Double-ended Queue)的核心语义

核心实现逻辑

循环数组的底层依然是一个线性数组 由于其性质 我们并不好使用动态数组来作为它的基底实现 它不再假设数据总是从索引0开始 而是在逻辑上引入了两个逻辑指针 headtail 来标记逻辑上的首尾

  • 物理存储 底层是一个容量为 capacity 的线性数组

而对于这两种逻辑指针 则有很多种存储方法 毕竟只要能描述开头与结尾即可

容易想到最简单的实现方法 即拥有首尾两个逻辑指针 而size计算得出

  • 头部指针 head 它指向队列中的第一个有效元素
  • 尾部指针 tail 它指向队列中最后一个有效元素的下一个位置(内存上也可以认为是最后一个元素的开头位置)

但是这种方法有一些不足 最严重的不足是对于满和空的处理 tail == head时 我们无法确认此时Deque是满还是空 这意味着我们几乎一定要引入新变量来标志Deque是否为满

可能会想 空和满是对称的 那怎么区分有一点元素的Deque和快满的Deque呢 其实这是由headtail指针的位置决定的head在前还是tail在前 tail == head可能对应tail在后追上head的全满边界情况 也可能对应两者都没运动的边界情况

更好的解决方法是使用head标识头指针 size记录大小 而尾指针计算得出

  • 头部指针 head 它指向队列中的第一个有效元素
  • Deque大小 size 它标识容器的大小

其中 数组的内部元素构成大抵可以视为和动态数组一样慢慢线性填充元素 当头和尾部运动的时候 让逻辑指针向前运动 或向后运动 就像拥有两端的动态数组一样 不过这里特殊的是 当逻辑指针向前运动 或向后运动到达底层的数组的边界 而且大小未满的时候 此时会发生回环 即跳跃到底层数组的另一端 以充分运用内存

此时可以把底层的数组的开头和结尾逻辑上视为连接起来的 即为一个环 所以回环的操作是很自然的 这也是环形缓冲区名称的来源

循环数组的索引到实际位置的映射完全由一个简单的一元数值函数决定 这使得其效率相比动态数组只低一点(来源于回环产生的缓存局部性破坏)

因此 讨论循环数组的时候 主要讨论的是从索引到实际位置的映射函数 以及获取逻辑size的映射函数 其他和动态数组并无区别 环形缓冲区在自己满的场合也支持扩容

映射函数

我们之所以能用简单的线性数组模拟出逻辑上的环形结构 其核心依赖于模运算(Modular Arithmetic)提供的周期性映射能力 模运算可以构建一个具有有限性的环

而模运算产生的代数系统可以将一个可以无限延伸逻辑坐标系 (Logical Coordinate System)上的点 唯一且正确地映射到我们有限的物理坐标系 (Physical Coordinate System)即数组索引0capacity - 1
这使得我们不需要关系索引的合法性 只需要运用这样的环形映射 无论索引为多少 超出物理边界还是为负数 通过这种映射都可以使其合法化 这使得前插和前删产生的负数索引以及后插和后删产生的超出物理边界的索引都映射为另一端的合法索引

这个时候 又有一些很重要的问题了 通过这样的映射 是否可以保证映射过去的索引是不被使用的 以及如何保证

  • Deque内 我们向外暴露的实际上是一个线性数组 所以外界完全不能控制环形缓冲区的内部空间 而我们维护的数据范围是逻辑连续的 这意味着不在逻辑的数据范围外的范围一定是不被使用的
  • 我们记录size是否等于capacity 就可以确定性的判定是否需要扩容
实例与陷阱

我们希望把一个无限延伸的逻辑坐标系 映射到有限的 长度为capacity的坐标系内 很容易根据模运算构建出这个系统来
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> p h y s i c a l _ i n d e x = ( h e a d _ i n d e x + l o g i c a l _ i n d e x ) % c a p a c i t y physical\_index = (head\_index + logical\_index)\%capacity </math>physical_index=(head_index+logical_index)%capacity

但是这样其实存在潜在的问题 即 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e a d _ i n d e x + l o g i c a l _ i n d e x head\_index + logical\_index </math>head_index+logical_index可能会产生错误 如我们希望在 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 -1 </math>−1处 即head前一位插入元素 如果此时 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e a d _ i n d e x head\_index </math>head_index为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 0 </math>0 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e a d _ i n d e x + l o g i c a l _ i n d e x head\_index + logical\_index </math>head_index+logical_index就为负数 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 -1 </math>−1

从数学角度而言 这似乎无伤大雅 但是在计算机科学中 这取决于不同编程语言对负数模运算的处理

  • C, C++, Java :在这些语言中 %运算符的结果的符号与被除数(dividend)的符号保持一致 因此(-1) % capacity的结果是-1 这显然是一个非法的数组索引 会导致程序崩溃
  • Python%运算符的结果的符号与除数(divisor)的符号保持一致 因此 (-1) % capacity的结果是capacity - 1 这是我们期望的环绕行为 也是数学上更常见的定义

当然 我们不能让这个映射函数如此不跨平台 但是这个时候 容易考虑到 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e a d _ i n d e x + l o g i c a l _ i n d e x head\_index + logical\_index </math>head_index+logical_index为负数的时候当且仅当我们在head为0时候的前面插入元素的时候 而这个方法是暴露的 也就是说 <math xmlns="http://www.w3.org/1998/Math/MathML"> h e a d _ i n d e x + l o g i c a l _ i n d e x head\_index + logical\_index </math>head_index+logical_index大于 <math xmlns="http://www.w3.org/1998/Math/MathML"> − c a p a c i t y -capacity </math>−capacity 而应对这种在模范围内的负数 如果想把它映射为正数 最快最高效的办法是 再加一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> c a p a c i t y capacity </math>capacity

即我们可以把公式演化为
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> p h y s i c a l _ i n d e x = ( h e a d _ i n d e x + l o g i c a l _ i n d e x + c a p a c i t y ) % c a p a c i t y , ( h e a d _ i n d e x + l o g i c a l _ i n d e x ) > − c a p a c i t y physical\_index = (head\_index + logical\_index + capacity)\%capacity, \quad (head\_index + logical\_index) > -capacity </math>physical_index=(head_index+logical_index+capacity)%capacity,(head_index+logical_index)>−capacity

后面的约束条件很容易满足 因为一个正常的Deque ADT设计 能出现的负数只是 <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 -1 </math>−1

但是如果我们要把约束条件去掉 讨论绝对通用 函数可以写成如下
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> p h y s i c a l _ i n d e x = ( ( h e a d _ i n d e x + l o g i c a l _ i n d e x ) % c a p a c i t y + c a p a c i t y ) % c a p a c i t y physical\_index=((head\_index+logical\_index) \% capacity+capacity) \% capacity </math>physical_index=((head_index+logical_index)%capacity+capacity)%capacity

这个映射函数可以适用于所有关于模正负的场合 代价是性能 这里有两次模运算 显然非常慢

模的位优化

先给出一个定理
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> a % n = a & ( n − 1 )    ⟺    ∃ k ∈ N 0 , s.t. n = 2 k a \ \% \ n = a \ \& \ (n-1) \iff \exists k \in \mathbb{N}_0 \text{, s.t. } n = 2^k </math>a % n=a & (n−1)⟺∃k∈N0, s.t. n=2k

当n等于2的幂 且为正整数时 模运算可以用一个位运算来描述 而显然后者比前者快很多

这个定理的来源是位掩码的性质 对于满足上述性质的 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 其 <math xmlns="http://www.w3.org/1998/Math/MathML"> n − 1 n-1 </math>n−1的二进制表示一定为000...111...1 而一个数对它做位与AND相当于切掉了其大于n的组成部分 只留下小于n的组成部分 即模

扩容

在环形缓冲区满的时候 容易想到 如果我们将它扩容为原来的两倍 并且这个时候我们按逻辑长度给它展平的话 像动态数组一样把它填到开头 相当于我们将环形数组转换为了它的一刻状态(完全没有进行过前插入与前删除) 而此时环形缓冲区是非满的

听起来很简单 那么实现呢

首先 透过映射函数的视角 我们显然可以直接根据逻辑索引去遍历 搬移数据 这也是最简单的实现方法

但是这样真的好吗 容易想到 遍历的效率肯定低于块操作 因为后者可以使用SIMD优化 虽然两者都可以被CPU缓存很好的支持 被CPU很好的预测 因为底层都是连续的存储单元

那么 如果我们操作块的话 环形缓冲区的快就有些复杂了

  • 如果tailhead后面 此时可以保证其内维护的区间是完全有序(逻辑索引顺序)的 且退化为动态数组 直接整体复制
  • 如果tailhead前面 此时发生了回环 我们先复制head到容器末尾的元素到新缓冲区开头 之后复制容器开头到tail的元素 紧跟着之前复制的head到容器末尾的元素

这个时候 就有 在size+head下 我们如何判断tail呢 其实给head + size过一遍映射函数就得到了tail索引 然后是朴素大小判断

对于插入/删除后的数据搬移 情况也是类似的 不过不应当使用Deque作为常插入/删除容器(容易发现 Deque可能要操作两次块才能完成数据搬移) 如果没有前插和前删的需要 则考虑使用动态数组或链表

朴素的实现想法

很容易想到 就算我们不使用复杂的映射 只是通过逻辑上的头指针和尾指针 也可以实现Deque 我们可以设计一个类似于逻辑内部空间在实际内部空间中间的数据结构 它在两端的行为都类似动态数组 只要一端满后 就发生重分配 将该端的capacity扩展为两倍

这样相当于镜像的动态数组 我们甚至可以使用两个动态数组头头相连模拟

但是这种更简单的数据结构并没有被广泛运用 或是几乎没有运用 因为它在某种程度上并不能很好的利用空间 性能抖动较为严重

  • 两端的扩容分离 可能会产生两端同时需要扩容的性能抖动
  • 发生重分配的时期当且仅当一段到达边界 但此时另一端可能完全是空的

还有一个极度重要的原因是 环形缓冲区的在解决这些问题的情况下 性能还能几乎保持和这种带问题的暴力实现一致(特别是使用位运算优化求模之后)

ADT实现分析

循环数组的ADT实现完全围绕其核心的 head 指针 size 状态 以及逻辑到物理的映射函数展开 它的行为在两端和中间呈现出截然不同的性能特征

  • 访问与修改 at()/operator[] set() 这组操作是循环数组逻辑能力的直接体现 它们不关心元素的物理位置 只关心逻辑索引 当需要访问逻辑上的第 i 个元素时 实现会通过映射函数 physical_index = (head + i) % capacity (或其位优化版本) 瞬间计算出其在底层物理数组中的实际位置 时间复杂度为纯粹的 O(1) 不过 相比于动态数组的 O(1) 它的 O(1) 常数项会略高一点 因为它包含了一次加法和一次模(或位与)运算 而非动态数组那样几乎只是纯粹的地址偏移
  • 头尾添加 push_front()/push_back() 这是循环数组的标志性功能 也是其设计的首要目的
    • push_back() 逻辑上类似于动态数组的尾部追加 计算新的尾部位置 (head + size) % capacity 放入新元素 然后 size 加一
    • push_front() 这是它的独特优势 需要在 head 前面插入元素 这意味着 head 指针需要逻辑上向"前"移动一位 新的 head 位置变为 (head - 1 + capacity) % capacity (注意处理负数) 然后在新 head 位置放入元素 size 加一 在这两种操作之前 都必须检查 size == capacity 如果容量已满 则必须先进行扩容 操作 之后再执行添加 如果引入摊还分析 这两次操作的时间复杂度均为 摊还O(1)
  • 头尾删除 pop_front()/pop_back() 删除操作甚至更简单
    • pop_front() 只需将 head 指针向后移动一位 head = (head + 1) % capacity 然后 size 减一
    • pop_back() 只需将 size 减一 逻辑上的尾部就自动收缩了 这些操作完全不涉及数据搬移 只需要修改指针或状态变量 因而具有纯粹的 O(1) 时间复杂度(在C++等语言中 需要手动调用被删除元素的析构函数)
  • 中间插入/删除 insert(index, value) erase(index) 这是循环数组的软肋 当操作点位于序列中间时 它就丧失了头尾操作的优势 为了在 index 处插入或删除元素 循环数组必须移动一部分元素 为此 它有一个动态数组不具备的优化空间 它可以比较 indexsize - index 的大小 选择移动较少的一端
    • 如果 index 离头部更近 则将 [0, index-1] 的元素向头部方向移动
    • 如果 index 离尾部更近 则将 [index, size-1] 的元素向尾部方向移动 尽管有此优化 但其时间复杂度仍取决于需要移动的元素数量 即 O(min(index, size - index)) 在最坏的情况下 (在正中间操作) 复杂度为 O(n) 并且由于可能跨越物理数组的边界 数据搬移的逻辑比动态数组更为复杂
效率分析

循环数组在现代硬件上的效率表现呈现出一种有趣的二元性 它既有顺序存储的优点 也暴露了其逻辑抽象带来的代价

  • 缓存性能 (Cache Performance) 循环数组的缓存行为是其性能的关键
    • 友好的一面 在没有发生回环的情况下 即所有数据在物理上仍然是连续的 它的缓存表现与动态数组一样优秀 对CPU的预取器极其友好
    • 断裂的一面 一旦发生回环 当迭代或遍历操作从物理数组的末尾 capacity - 1 跳到开头 0 时 就会产生一次缓存断崖 (Cache Cliff) 这会破坏访问的局部性 导致一次代价高昂的 Cache Miss 并可能让CPU预取器预测失败 这是它相比于 std::vector 这种永不断裂的结构所付出的核心代价
    • 中间操作的代价 对于中间的 insert/erase 如果移动的元素区间跨越了回环的断裂点 这意味着CPU需要同时处理两个完全不相邻的内存块 这会进一步恶化缓存性能
  • 内存分配与利用 在这方面它和动态数组基本一致 它将所有元素存储于一整块连续的内存中 避免了链式结构中每次操作都可能需要与内存分配器打交道的问题 也避免了链表的指针开销 它的扩容也是一次性的大块分配 均摊成本较低
内置实现

循环数组的设计思想是双端队列(Deque)的标准实现方案之一 但有趣的是 C++ 和 Java 的标准库对此做出了截然不同的选择

C++ std::deque(并不是循环数组)

一个广为流传的误解是 std::deque 是一个环形缓冲区 实际上 大多数标准库的 std::deque 实现并非如此 std::deque 的经典实现是一种分块的数组 或称为中控映射 (Map of Pointers)的结构 它的底层是一个动态数组 这个数组里存的不是数据 而是指向其他内存块(chunk)的指针 每个内存块都是一个固定大小的数组 用来实际存储数据

  • 结构分析 这种结构像一个"数组的数组" 可以在头部和尾部高效地添加或删除整个内存块
  • 性能特征
    • 头尾添加/删除是摊还 O(1)(通常是分配一个新块 然后在中控数组里添加一个指针)
    • 随机访问 operator[] 仍然是 O(1) 但它需要两次指针解引用 (先在中控图找到块指针 再在块内找到元素) 因此常数时间比 std::vector 大得多
    • 中间插入/删除是 O(n) 但可能比 std::vector 快 因为它可能只需要在块内移动数据 而无需移动所有元素
  • 优点 它的迭代器失效规则比 std::vector 宽松 (中间插入不会让指向两端的迭代器失效) 并且不会像 std::vector 一样在扩容时需要一次性复制所有数据
Java ArrayDeque

与C++不同 Java的 java.util.ArrayDeque 正是我们上文所讨论的经典循环数组实现 它完美地体现了环形缓冲区的思想

  • 结构分析 其内部只有一个简单的 Object[] 数组 以及 headtail 两个整型变量作为逻辑指针
  • 性能特征
    • addFirst/addLast/removeFirst/removeLast 等操作拥有摊还 O(1) 的性能
    • 它没有实现 List 接口 因为它的设计者明确指出 它不适合用作List get(index) 这种随机访问虽然是O(1) 但不是它的优势场景
    • 它的迭代器会在遍历时正确处理头尾指针和回环逻辑
  • 运用 在Java中 当需要一个高效的队列(Queue)或栈(Stack)实现时 ArrayDeque通常是比LinkedList更优的选择 因为它的缓存局部性更好 内存开销也更小
运用场合

循环数组及其所实现的双端队列在算法和系统设计中极为有用

  • 队列与栈的实现 它是实现队列(FIFO)和栈(LIFO)的高效数据结构
  • 滑动窗口算法 在处理数据流问题时 经常需要维护一个固定大小的窗口 循环数组可以在O(1)时间内在窗口一端加入新元素 并从另一端移除旧元素
  • 工作窃取调度 在并发编程中 线程可以拥有自己的工作队列(一个Deque) 它们主要在自己队列的一端进行推入和弹出(像栈一样 高效且无竞争) 当一个线程空闲时 它可以从另一个线程队列的另一端"窃取"任务 这就是一种典型的Deque应用

间隙缓冲区

间隙缓冲区是特化的 常见于文本编辑器的数据结构 它只是把动态数组的空闲空间(capacity - size)放在数组内部 而不是数组尾

不妨思考一下普通动态数组的性质 在末尾插入是均摊O(1)的 那么间隙缓冲区把空闲空间换到了数组内 所以其性质很容易想到是在间隙内插入元素是均摊O(1)

这个时候 也能想到 对于动态数组 我们能通过sizecapacity直接知道大小和位置的原因是 数组的空闲空间一定在末尾 但是对于间隙缓冲区 空闲空间不一定在末尾 而是在任意地方 所以我们需要额外的状态存储

核心实现逻辑

这个设计的底层依然是一个连续的线性数组 但它的逻辑视图被动态地划分为三个部分

  • 数据头 从数组开始到间隙开始前的部分 存储着光标前的文本
  • 间隙 (Gap) 一段连续的未使用空间 它的位置代表了逻辑上的光标
  • 数据尾 从间隙结束后到数组有效内容末尾的部分 存储着光标后的文本

容器只需要维护几个指针或索引即可精确描述整个结构 通常是gap_start指向间隙的起始gap_end指向间隙的结束 以及buffer_end指向数据尾的末端 当用户在编辑器中移动光标时 数据结构内部就在移动这个间隙

当然 显然我们可以把gap_startgap_end换为gapgap_size 这在支持高效随机访问的数组内没有一点区别

ADT实现分析

间隙缓冲区的性能表现完全取决于操作是否发生在间隙处 也就是光标的当前位置

  • 在光标处插入 这是一个O(1)操作 只需在gap_start位置放入新字符 然后gap_start指针加一 间隙因被填充而缩小一格
  • 在光标处删除(backspace 同样是O(1)操作 只需gap_start指针减一 逻辑上删除了一个字符 间隙则因回收空间而扩大一格
  • 在光标后删除(delete O(1)操作 gap_end指针加一 间隙同样扩大一格
  • 移动光标 这是间隙缓冲区的核心代价所在 移动光标就意味着移动间隙 例如 当光标向左移动k个位置时 我们需要将gap_start左边的k个字符通过memmove等操作拷贝到原间隙的末尾 然后整体更新gap_startgap_end指针 这个操作的时间复杂度为O(k) k为移动的距离
  • 扩容 当间隙被完全填满 (gap_start == gap_end) 时 如果再有插入操作 就会触发扩容 这个过程和动态数组完全一致 申请一块更大的内存 将数据头和数据尾依次拷贝到新空间 在中间留出一个更大的新间隙 通过倍率增长策略 插入操作的复杂度依然是摊还O(1)
效率分析
  • 缓存性能(Cache Performance) 极其出色 这是它相比于链表 绳索(Rope 更贴近一种树)等结构的巨大优势 除了间隙本身 文本内容被存储在两个大的连续块中 当用户顺序阅读或小范围移动光标时 CPU可以有效地预取和缓存数据 提供了极佳的内存局部性
  • 操作的局部性优势 文本编辑的实践证明 大部分操作(连续输入 删除一个单词)都发生在光标的当前位置 间隙缓冲区的设计完美地利用了这一行为模式 将最高频的操作优化到了极致的O(1) 而将相对低频的光标大幅度跳转作为需要付出代价的操作
  • 内存开销 非常低 几乎没有额外的元数据开销 与动态数组在同一水平
运用场合

间隙缓冲区是文本编辑器 的经典实现方案 著名的Emacs编辑器就使用了这种数据结构

链状表

链条状的存储是非常自由的 它只是若干个内存块互相连接构成 而内存块内拥有也必须拥有状态 因为没有任何隐式的 潜在的信息和承诺 (顺序表的承诺基于内存上的相邻和元素类型大小)知道元素的位置

但是这个时候 我们知道 相邻只可以描述前后两种逻辑 而对于链状表的内存块 可以描述的关系不受限制 因此 对于链状表 我们本质上可以描述一些非一对一 非逻辑上的前继和后继的数据结构 比如树

但是我们都知道 具体到操作系统层面 我们能控制的存储都是线性表 所以这个时候 我们要澄清逻辑存储形式和物理存储形式的区别

  • 逻辑数据形式是一个数据结构的表观体现 是ADT的体现 描述了这个数据结构看起来长什么样 有什么用 以及有什么接口
  • 物理存储形式是一个数据结构在存储器内的实际存储方式 在可寻址的顺序线性表的底层基础下 几乎所有的数据结构都有相同的底层形式 顺序 链式与混合

树显然可以存储于线性表内 树显然是一个连通图 因此能找到任意的一条路径 链接若干个节点 而节点可能还包含一个值和链表(分支点) 也可能是一个值(叶)

对分支点递归进行操作 可知道树在线性表的存储形式

可以换一种角度而言 树存储于一个线性表内 但是逻辑上不是线性表

这里主要讨论链式线性表 也可以认为 通过链存储在顺序线性表内的逻辑线性表

Allocation

在谈论链表之前 有一个重要的问题需要知道 就是链表空间的分配问题 虽然对于链表的每一个节点 其空间都是顺序的 但是元素之间却不是顺序的

我们都知道 计算机的存储器在操作系统层面上是一个顺序的块 那么怎么在这个内存块上分配关于空间是一个自然的问题 这个问题在顺序表中得到了大量简化 甚至几乎无法关注到

但是对于链式表 这个问题就变得很暴露了 链式存储的元素之间并不是空间上的前继后继关系 那它们是什么关系

我们都知道 对于大多数程序 大多数运行时 大多数语言 将空间分为两个部分 堆与栈 介于链表内节点 容器大多数时候都存储在堆内 所以本质上是在问堆的内存分配规则 或许有个更加抽象 更加准确的说法 给定一个顺序表 你怎么在里面优良的装入一个链表 并且维护节点的删除与添加

这一问题的答案通向了计算机科学的一个核心领域 内存分配器(Memory Allocator) 无论是C语言的 malloc/free C++的 new/delete 还是Java虚拟机(JVM)的垃圾回收机制 其底层都必须回答这个最基本的问题

内存分配器管理着一大块连续的内存空间 通常我们称之为堆(Heap) 它的核心职责可以被简化为两个操作

  • 分配(Allocate) 当程序(比如我们的链表实现)请求一块特定大小的内存来创建一个新节点时 分配器需要在堆中找到一块足够大的空闲空间 将其标记为已使用 并返回指向这块空间的指针
  • 释放(Deallocate) 当程序显式或隐式地(通过垃圾回收GC)表示一个节点不再需要时 分配器需要将这块空间重新标记为空闲 以便未来的分配请求可以再次使用它

这个过程的核心挑战在于 碎片(Fragmentation) 随着程序的运行 堆内存会被反复地分配和释放 这会导致原本连续的空闲空间被分割成许多不连续的小块

  • 外部碎片(External Fragmentation) 指的是堆中存在足够多的总空闲空间 来满足一个分配请求 但是没有任何一个单独的连续空闲块足够大 这种情况对于需要连续内存的顺序表是致命的 但对于链表 节点是离散分配的 所以看似影响不大 但它依然会影响分配器能否找到一个哪怕很小的空间给新节点
  • 内部碎片(Internal Fragmentation) 指的是分配器为了管理的方便(例如为了对齐或满足最小块大小)分配了比请求稍大的内存块 请求大小与实际分配大小之间的差值就是内部碎片 这部分空间对于程序是不可见的 浪费的

为了管理这些空闲空间并与之对抗 分配器演化出了多种策略 其中最基础也最直观的一种是 空闲链表(Free List) 这是一种非常有趣的设计 它使用链表这种数据结构来管理用于分配给链表节点的内存 即分配器将所有空闲的内存块通过指针链接成一个或多个链表

当一个分配请求到来时 分配器会遍历这个空闲链表 寻找一个合适的块 这又引申出不同的搜索策略

  • 首次适应(First-Fit) 从链表头部开始 找到第一个足够大的空闲块就立即使用 这种策略简单快速 但可能导致链表前端留下许多小的无法利用的碎片
  • 最佳适应(Best-Fit) 遍历整个空闲链表 找出与请求大小最接近的那个空闲块 这种策略能最大限度减少内部碎片 但搜索开销更大 且容易产生大量极小的 几乎无法再被利用的外部碎片
  • 下次适应(Next-Fit) 类似于首次适应 但每次都从上次分配操作结束的位置开始搜索 试图将内存消耗更均匀地分布在整个堆上 避免对堆的前半部分过度消耗

对于链表这种频繁申请和释放大量同样大小 (一个节点的大小)内存的场景 一种更高效的策略是 内存池(Memory Pool) 或称为对象池(Object Pool) 它的思想是 首先向通用分配器申请一大块连续内存 然后将其预先分割成无数个大小固定的节点槽位

  • 当需要新节点时 直接从池中取出一个预先准备好的槽位 时间复杂度是纯粹的 O(1)
  • 当节点被删除时 不将其真正"释放"给操作系统 而是将其"归还"到池中 以便下次复用

这种方法完全绕过了通用分配器的复杂查找和分割逻辑 极大地提升了分配和释放的效率 并且从根本上消除了外部碎片和内部碎片 (因为块大小是为节点量身定做的) 这也是为什么在高性能的网络服务器 游戏引擎等场景中 针对特定对象的内存池技术被广泛应用

因此 链表的所谓自由是建立在底下复杂的内存分配机制之上的 它的每一次newmalloc操作的性能 都直接取决于其所依赖的内存分配器的策略与效率 这也是分析数据结构时不能脱离其底层系统环境的重要原因

头尾节点与值语义

对于链表及其实现方法 一个很常见的问题是 需不需要有头尾节点 它们没有任何意义 只是标识一个开头的节点和结尾的节点 即哨兵节点(Sentinel Node)
这样的好处是不需要讨论没有前后节点的情况 即插入第一个元素或者删除最后一个元素 可以统一操作 降低出错率 但是代价呢 这取决于链表内存储的数据
在静态类型语言内 头尾节点最好和内容节点保持一致的类型 否则我们还需要讨论前后节点的类型问题 这个工作量甚至比讨论有没有前后节点更大 毫无意义

那么 也就是头尾节点也是存储值的 对于没有值语义的语言 如Java/C# 容器本质上在存储引用 而引用是可以默认构造的 并且占用极小 所以问题不大

那么 对于有值语义的语言 比如C++呢 这里有很多衍生问题

  • 对于存储的值 可能无法默认构造 所以根本无法构造出一个供头尾指针使用的无意义的默认值
  • 对于存储的值 可能非常大 这个时候可能空间浪费是很显著的

当然对于这些语言 链表内部也可以存储指针 只是多一次解引用而已 由于链表已经没有缓存优良性了 所以这一次解引用产生的性能损失不大

对于哨兵节点 有一个有意思的地方是 如果我们记录大小size 事实上 大多数实现都记录 我们就可以把头尾指针连起来 统一为一个指针 此时链表为环形 因为头尾指针本质上没有任何区别 所以这个操作是合理的

侵入式和非侵入式

很多实现默认的链表都是非侵入式链表 所有接口只面向值 由数据结构自己进行值的在节点内的包装 然后插入容器

但是 链表还有一张很常见的实现 即侵入式 链表 它要求用户的数据结构T内部自身包含 next/prev指针 这意味着链表可以将任何已存在的 符合要求的对象链接起来 而无需任何额外的内存分配 这在性能极致的C/C++场景(如游戏引擎 操作系统)中非常流行

比起包含**next/prev指针 更务实的说法是 包含获取与设置(可以被统一为获取引用)nextprev指针的接口

可以使用ADT描述它

cpp 复制代码
template<typename T, typename E>  
concept NodeConcept = requires(T& repo)  
{  
    // 默认构造 用于哨兵节点  
    requires std::default_initializable<T>;  
    
    // 节点操作  
    { repo.get() } -> std::same_as<typename E&>;   
    { repo.next() } -> std::same_as<typename T*&>;
	{ repo.prev() } -> std::same_as<typename T*&>;
};

或者Java Interface

java 复制代码
/**
 * 定义了一个侵入式节点的契约。
 * @param <T> 实现了此接口的节点自身的类型
 */
public interface IntrusiveNode<T extends IntrusiveNode<T>, E>
{
    /**
     * 获取当前节点的元素值。
     * @return 后继节点,如果不存在则为 null。
     */
	E get();
	
    /**
     * 获取当前节点的后继节点。
     * @return 后继节点,如果不存在则为 null。
     */
    T getNext();

    /**
     * 设置当前节点的后继节点。
     * @param next 要设置的后继节点。
     */
    void setNext(T next);

    /**
     * 获取当前节点的前驱节点。
     * @return 前驱节点,如果不存在则为 null。
     */
    T getPrev();

    /**
     * 设置当前节点的前驱节点。
     * @param prev 要设置的前驱节点。
     */
    void setPrev(T prev);
}

之后 一切关于插入和删除的操作都接受这样的节点或迭代器

基础链表

基础链表是链表的基础实现 也是最贴近内存的实现 可以视为存放在逻辑内存上的链表

单向链表 (Singly-Linked List)

单向链表是链式存储最质朴 最基础的形态 它的每一个节点内只包含一个指向其直接后继的指针 这种极简的结构构成了一条单向的 无法回头的通路 一切遍历都只能从头到尾进行

节点结构与核心逻辑

其节点定义异常简单 只包含数据域和后继指针

cpp 复制代码
template<typename E>
struct SinglyLinkedListNode
{
    E value; // 数据域
    SinglyLinkedListNode<E>* next = nullptr; // 指针域
};

这种结构引出了一套独特的ADT操作性能特征 它的所有高效操作都强依赖于一个"已知前驱"的假设

ADT实现分析

链表的实现是很显然的 重在幻想链表的结构

对于一个给定的节点迭代器(指针)it 它指向节点p

  • 访问 value(it) 这显然是O(1)的 操作 只是一个简单的指针解引用
  • 在节点后插入 insert_after(it, value) 这是一个O(1)操作 只需要生成一个新节点 new_node 然后执行两步指针操作
    1. new_node->next = p->next
    2. p->next = new_node 这个过程与链表总长度n无关 效率极高
  • 删除节点后的节点 erase_after(it) 这同样是O(1)操作 只需要将被删除的节点node_to_delete = p->next保存下来 然后让p->next = node_to_delete->next 即可将node_to_delete从链中断开 之后释放其内存
  • 在节点前插入 insert_before(it, value) 与 删除当前节点 erase(it) 这是单向链表的核心困境 所在 由于我们只有p的地址 我们无法在O(1)时间内获取p的直接前驱prev 也就无法修改prev->next指针 这是完成这两个操作的关键步骤 唯一的选择就是从链表的头节点head开始重新遍历 直到找到那个满足node->next == p的节点prev 这个过程的时间复杂度显然是O(n) 这使得单向链表在处理需要修改前驱的场景时 性能表现非常糟糕
效率分析

单向链表的效率分析非常纯粹 因为它几乎没有任何优化的空间

  • 缓存性能(Cache Performance) 这是所有链式结构的致命弱点 链表节点通过newmalloc在堆上分配 它们的内存地址通常是随机且离散 的 当CPU遍历链表时 每访问一个新节点 几乎都会导致一次缓存未命中(Cache Miss) CPU必须暂停执行 从慢速的主内存中加载下一个节点的数据 这与顺序表访问时 缓存可以一次性载入一大块连续内存并被高效利用的情况形成了鲜明对比 这种糟糕的缓存局部性导致了链表在现代硬件上的实际遍历速度远逊于数组 即使理论复杂度相同
  • 内存开销(Memory Overhead) 每个节点都需要额外存储一个指针 其大小在64位系统上通常是8字节 如果存储的数据类型本身很小 (比如charint) 那么指针带来的额外内存开销会非常显著
  • 分配器开销(Allocator Overhead) 链表的每个节点的创建和销毁都是一次独立的内存分配和释放操作 这会频繁地与底层内存分配器交互 带来额外的性能开销 同时也加剧了内存碎片的风险
运用场合

鉴于其显著的缺点 单向链表在通用序列容器中并不常见 但它的简单性和低内存开销(仅一个指针)使其在特定场景下依然有用

  • 实现栈(Stack) 在链表头部进行插入和删除都是O(1)操作 可以高效实现一个栈
  • 实现队列(Queue) 如果我们同时持有headtail两个指针 就可以在尾部插入 在头部删除 两者都是O(1)操作 从而高效实现队列
  • 哈希表中的冲突链(Separate Chaining in Hash Tables) 当哈希冲突发生时 可以用单向链表将冲突的元素链接起来 这里的操作主要是insert_after和遍历
  • 对内存极度敏感的嵌入式系统
双向链表(Doubly-Linked List)

双向链表的诞生 其唯一目的就是为了解决单向链表无法高效访问前驱节点的根本性问题 它在每个节点中额外增加了一个指向其直接前驱的指针

节点结构与核心逻辑

其节点结构只是在单向链表的基础上增加了一个prev指针

cpp 复制代码
template<typename E>
struct DoublyLinkedListNode
{
    E value; // 数据域
    DoublyLinkedListNode<E>* next = nullptr; // 后继指针
    DoublyLinkedListNode<E>* prev = nullptr; // 前驱指针
};

这个prev指针的引入 使得链表的逻辑通路从单向变为双向 赋予了它在任意位置进行高效操作的能力

ADT实现分析

链表的实现是很显然的 重在幻想链表的结构

对于一个给定的节点迭代器(指针)it 它指向节点p

  • 在节点前插入 insert_before(it, value) 由于p->prev的存在 我们可以在O(1)时间内获取其前驱节点prev 之后的操作变为四步指针交换
    1. new_node->next = p
    2. new_node->prev = prev
    3. prev->next = new_node
    4. p->prev = new_node 这个操作完全是局部的 时间复杂度为纯粹的 O(1)
  • 删除当前节点 erase(it) 同样 我们可以通过p->prevp->nextO(1)时间内获取其前后节点prevnext 然后执行一步跨越操作
    1. prev->next = next
    2. next->prev = prev 之后释放p的内存即可 时间复杂度同样为纯粹的 O(1)

正是这种在任意位置O(1)的插入与删除能力 使得双向链表成为了通用链式序列容器的标准实现 它可以完美支持双向迭代器 (Bidirectional Iterator)

效率分析

双向链表在解决了单向链表操作局限性的同时 也放大了一些固有的成本

  • 缓存性能 (Cache Performance) 与单向链表一样糟糕 甚至可能更差 因为遍历时虽然通常只用到next指针 但节点的更大尺寸(因为多了prev指针)意味着在同一大小的缓存行中能容纳的节点信息更少
  • 内存开销(Memory Overhead) 这是它最主要的代价 每个节点需要存储两个指针 在64位系统上是16字节的额外开销 这使得它成为所有线性表实现中单位元素内存占用最高的结构 当存储小类型数据时 这种浪费是惊人的
  • 迭代器稳定性(Iterator Stability) 这是双向链表相对于顺序存储(如std::vector)的一个核心优势 只要一个节点没有被删除 指向它的迭代器就永远不会失效 std::vector在扩容时会导致所有迭代器失效 这在某些复杂的算法逻辑中是不可接受的 而链表的这一特性提供了强大的保证
内置实现
  • C++ std::list 正是双向链表的完美封装 它提供了双向迭代器和O(1)insert/erase操作 但由于其糟糕的缓存性能 在现代C++编程实践中 除非必须用到其迭代器稳定性的保证 否则std::vector通常是性能更优的默认选择
  • Java LinkedList 同样是双向链表的实现 它同时实现了ListDeque接口 但在作为栈或队列使用时 其性能通常不如基于循环数组的ArrayDeque 后者没有节点分配的开销和指针追逐的缓存问题
运用场合

双向链表适用于那些对序列中间位置的插入/删除操作频率远高于遍历和随机访问频率的场景

  • 需要稳定迭代器的算法 例如 在一个容器上进行迭代的同时 还需要在迭代点附近安全地添加或删除元素
  • 实现LRU缓存淘汰算法 LRU缓存需要能快速将一个被访问的元素移动到链表头部 并且能快速删除链表尾部的元素 双向链表可以O(1)地完成这些操作
  • 某些操作系统的任务调度队列 需要在队列的任意位置插入和移除任务
静态链表

从某种角度而言 静态链表可以被视为是 分配部分使用的分配器是基于数组的对象池来实现的链表

但是如果是数组的话 这个时候有一个奇妙的性质 就是数组内可以使用索引唯一标识一个对象

所以 如果有指向数组头的状态 我们也可以使用索引来替代指针来标识前驱和后驱元素

由于静态链表和其他两个基础链表的逻辑是共通的 只是底层实现不同 所以略过

循环链表

我们之前讨论的链表都有明确的起点(头节点)和终点(尾节点) 尾节点的next指针指向nullptr 这在逻辑上构成了一条线段

而循环链表 (Circular Linked List) 则是将这条线段的首尾相连 构成一个环

​从结构上说 它只是一个微小的改动 将尾节点的next指针指向头节点 (对于双向循环链表 还需将头节点的prev指针指向尾节点) 但这个改动带来了一些有趣的性质

核心实现逻辑

循环链表最大的特点是逻辑上没有了端点 任意一个节点都可以被看作是起点 从任何一个节点出发 沿着nextprev)指针最终都能回到它自身

这个特性使得我们不再需要同时维护headtail两个指针 通常 只需要一个指针(例如 cursortail)指向环中的任意一个节点即可 就可以在O(1)时间内 访问到逻辑上的头与尾

例如 如果我们的指针tail指向逻辑上的尾节点 那么tail->next就是逻辑上的头节点 ​之前在链表的实现中提到了 一个带哨兵节点的双向循环链表 可以使用循环链表来简化状态 它的空状态是哨兵节点的nextprev都指向自己 所有插入和删除操作都相对于哨兵节点进行 这就完全消除了对空链表的特殊判断 让所有操作的逻辑统一 但是这个做法本质上还是在实现一个线段的表 和原始实现的逻辑结构没有区别

因此 循环链表的更重要的特性体现在循环的逻辑特性上 即环 而不是实现上

ADT实现分析

循环特性主要改变了遍历和边界判断的逻辑 ​遍历 遍历的终止条件不再是检查指针是否为nullptr 而是检查指针是否回到了起始节点 这在使用for循环时需要格外小心 很容易写成死循环 使用do-while循环通常是更自然和安全的选择 因为它能保证循环体至少执行一次 恰好处理了起始节点

  • 插入与删除 操作本身的核心指针交换逻辑与线性链表完全相同 但其优势在于无需处理头尾节点的边界情况 例如在一个双向循环链表中 在任意节点p之前插入new_node的指针操作都是完全一致的 我们无需再编写if (p == head)之类的分支代码
  • 移动与旋转 循环链表天然支持旋转操作 将head指针向前移动一位 逻辑上就相当于将整个列表向左旋转了一格 而这只是一个O(1)的指针修改操作
循环检测与判环算法

到目前为止 我们讨论的循环链表是已知和有意为之的环 但在更广泛的场景中 我们可能会拿到一个未知的链表 并需要判断它内部是否存在环 一个错误的指针操作就可能无意中将一个线性链表的中间指向其前驱 从而形成一个环

一个朴素的想法是遍历链表 并用一个哈希表记录所有访问过的节点 如果遇到一个已在哈希表中的节点 就说明存在环 这种方法的时间复杂度是O(n) 空间复杂度也是O(n)

然而 存在一个极为巧妙且优雅的算法 它能在O(1)的额外空间内解决这个问题 这就是著名的快慢指针(Fast and Slow Pointers)算法 也常被称为Floyd判环算法 它的逻辑如下

创建两个指针slowfast 初始时都指向链表的头节点

  • 在一个循环中 slow指针每次向前移动一步(slow = slow->next
  • 而fast指针每次向前移动两步(fast = fast->next->next

现在 考虑两种情况

  • 如果链表是线性的(无环) fast指针永远在slow指针的前面 它会比slow更早到达链表末尾 当fastfast->next变为nullptr时 循环终止 我们可以断定链表无环
  • 如果链表存在环 fast指针会首先进入环 然后slow指针也会进入环 此时由于fast的速度是slow的两倍 fast指针必定会在未来的某个时刻从后面追上slow指针(每移动一下 两个指针的间隙增大一格) 也就是slow == fast(当且仅当两个指针间隙为环元素个数)的情况一定会发生

一旦检测到两指针相遇 即可百分之百断定链表中存在环 这个算法的时间复杂度是O(n) 而空间复杂度是惊人的O(1) 它是链表算法中的一个经典范例

效率分析

缓存性能(Cache Performance)与标准链表一样糟糕 节点的内存地址是离散的 遍历时会频繁导致缓存未命中 循环的特性并不能改善这一点

运用场合

​循环链表的应用场景都利用了其无尽循环(环)的特性

  • 轮询调度(Round-Robin Scheduling) 这是循环链表最经典的应用 操作系统可以用一个循环链表来管理处于就绪状态的进程 每次时间片结束 调度器只需将指针移动到下一个节点 current = current->next 即可公平地选择下一个要运行的进程
  • 数据缓冲区生产者-消费者模型中 循环链表可以作为一个无限循环的缓冲区
  • 环模拟 可以模拟需要循环轮换的情况

混合数据结构

在线性表的实现光谱上 纯粹的顺序表与纯粹的链式表构成了两个极端 它们各自拥有鲜明的优点 也伴随着无法忽视的缺点

  • 顺序表std::vector 为代表 拥有极致的缓存性能与 O(1) 随机访问 但其软肋在于中间插入和删除时 O(n) 的昂贵数据搬移
  • 链式表std::list 为代表 拥有 O(1) 的任意位置插入和删除能力(在给定迭代器时) 但为此付出了糟糕的缓存性能 额外的指针内存开销 以及高昂的逐元素内存分配成本

混合数据结构便是在这两个极端之间寻求最佳平衡点的工程智慧结晶 它的核心思想只有一个词 分块 (Blocking / Chunking) 即不再将数据视为单个元素的序列 而是将其组织成一个 块的序列 这种化整为零 再聚沙成塔的设计哲学 旨在同时吸收两种基础结构的优点

父结构与子结构

为了系统地理解各种混合数据结构 我们可以引入一个统一的分析框架 将其解构为两个层面

  • 父结构(Parent Structure) 也可称为中控(Controller) 它负责组织和链接各个子结构 决定了整个数据结构的宏观布局和行为 比如 整个结构看起来更像一个数组 还是更像一个链表
  • 子结构(Child Structure) 也可称为数据块(Block) 它是实际存储元素的单元 通常是一个固定大小的数组 以便在微观层面利用其出色的缓存局部性和低内存开销

这个框架的核心论点在于 混合数据结构的宏观性能特征很大程度上继承自其父结构 对数据块的分裂或合并操作 本质上可以映射为在父结构上插入或删除一个元素 因此 父结构的性能特征决定了混合数据结构的基调

分块数组

当父结构被设计为一个数组时 我们便得到了分块数组模型 这是C++标准库中 std::deque 的经典实现范式 事实上 分块数组可以视为双端序列的特化数据结构

核心实现逻辑

可以将分块数组想象成一列火车

  • 父结构/中控 是管理所有车厢的调度中心 它本身是一个动态数组 但它不存放乘客(数据) 而是存放指向每一节车厢的指针 更精确地说 为了支持高效的双端操作 这个中控结构自身通常被实现为一个存储指针的循环缓冲区 这使得在中控层面添加或移除指向头部/尾部块的指针是一个摊还O(1)的操作
  • 子结构/数据块 是每一节车厢 车厢内部是固定大小的数组 座位连续 乘客(数据)紧密排列

这种"指针的循环缓冲区"管理"数据的数组"的结构 精巧地平衡了访问与修改的效率

结构继承分析

分块数组的整体性质 直接反映了其数组父结构的特点

  • 随机访问 父结构的O(1)随机访问能力被完美继承 要访问逻辑上的第i个元素 只需通过一次整数除法i / BLOCK_SIZE在中控数组中找到块指针 再通过一次取模i % BLOCK_SIZE在块内部找到元素 虽然是两步操作 但复杂度依然是纯粹的 O(1)
  • 头尾操作 由于父结构本身就是指针的循环缓冲区 它天然支持高效的双端操作 在混合结构的尾部添加元素 如果当前块未满 则是块内操作 如果块满了 则相当于在中控(父结构)的尾部追加一个指向新数据块的指针 这同样是摊还O(1)
  • 中间插入/删除 父数组在中间位置插入/删除元素的 O(n) 弱点同样被继承 如果要在两个数据块之间插入一个全新的块 就必须在中控数组的中间插入一个指针 这需要移动后续所有的指针 成本极高 而且对于分块数组 不支持中间的随机插入能力 所以自然无法通过分裂和合并来降低内部的碎片化

所以对于分块数组 我们几乎不考虑它的中间插入和删除的能力 因为它的效率实在是过于低了

  • 如果我们考虑通过合并和分裂来降低碎片的话 则就不得不面对父结构O(n)插入/删除产生的效率衰减
  • 如果我们不考虑降低碎片的话 则数据结构经过多次使用之后 其内部将会千疮百孔 空间利用率极低

但是对于容器 对于线性表 我们不得不面对中间操作的需求 在这点上std::deque 的实现选择了后者 即接受了复杂碎片的产生 默认中间操作少 局部且可接受 而不是引入更复杂的碎片管理

效率与应用

分块数组的缓存在块内表现极好 在块间则会发生跳跃产生Cache Miss 整体优于链表但逊于纯粹的数组 它通过牺牲一部分缓存连续性与随机访问的常数时间 换来了极其高效的双端操作能力 因此 它的最主要应用场景就是实现双端队列(Deque) C++的std::deque正是为此而生 分块数组和我们之前讨论的朴素循环缓冲区(如 Java 的ArrayDeque)都是实现双端队列的优秀方案 但它们的设计哲学和性能取向截然不同

  • 内存连续性与缓存性能
    • 朴素循环缓冲区 它的数据存储在单一的 巨大的连续内存块中 最多只在头尾相接处有一个断点 因此它的缓存局部性极好 遍历性能非常接近纯粹的数组
    • 分块数组 它的内存在宏观上是高度碎片化 的 每一次跨越数据块的访问都会导致一次Cache Miss 其缓存性能显著劣于朴素循环缓冲区
  • 内存分配与增长模式
    • 朴素循环缓冲区 当容量不足时 它需要进行一次全局性的 昂贵的重分配 即申请一块更大的内存 并将所有旧元素拷贝到新内存中 这会带来明显的性能抖动(Latency Spike)
    • 分块数组 它的增长是零碎的 平滑的 每次只在需要时分配一个固定大小的小数据块 无需移动任何现有元素 内存分配的成本被平摊到了每一次块的扩充中 没有全局性的性能抖动
  • 迭代器与引用的稳定性
    • 朴素循环缓冲区 每一次扩容都会导致指向其内部元素的所有指针 引用 迭代器全部失效
    • 分块数组 在两端添加元素 永远不会导致指向已有元素的指针和引用失效(因为旧的数据块没有移动) 这在 C++ 中是一个极为重要的优势
  • 对大型对象的影响
    • 朴素循环缓冲区 如果存储的元素类型很大(sizeof(T)很大) 那么全局拷贝的成本会变得极其高昂
    • 分块数组 由于从不进行全局拷贝 它在存储大型对象时表现得更为从容和高效

总结来说 朴素循环缓冲区追求极致的缓存性能 适合存储小型对象且能容忍偶尔性能抖动的场景 而分块数组通过牺牲缓存连续性 换取了更平滑的内存增长模式更强的引用稳定性 这使其在存储大型对象或需要稳定引用的复杂场景中更具优势

分块链表

当父结构被设计为一个链表时 我们便得到了分块链表模型

核心实现逻辑

可以将分块链表想象成一串

  • 父结构/中控 是连接所有块的 它是一个经典的双向链表
  • 子结构/数据块 是链上的每一个

为了实现精细的控制 每个作为父结构节点的块内部 除了包含一个固定大小的数组T data[BLOCK_SIZE]next/prev指针外 还必须包含一个状态变量 例如int size 用来追踪当前块内已存储的元素数量 这个size是后续分裂与合并机制的基础

这种链表的节点内含数组的结构 将链表的灵活性与数组的局部性结合了起来

结构继承分析

分块链表的性质 同样是其链表父结构的直接映射

  • 随机访问 父链表的O(n)顺序访问特性被完全继承 要找到逻辑上的第 i 个元素 别无选择 必须从父链表的头节点开始 沿着指针逐个节点地向后遍历 跨越一个又一个数据块 随机访问能力在此结构中被牺牲
  • 任意位置的块操作 父链表在任意位置 O(1) 插入/删除节点的核心优势被完美继承 一个子结构(数据块)的分裂操作 本质上是在父链表上 O(1) 地插入一个新节点 一个子结构的合并操作 本质上是在父链表上 O(1) 地删除一个节点 这种宏观操作的 O(1) 复杂度正是分块链表设计的基石 当然 在块内部移动元素仍有 O(BLOCK_SIZE) 的微观成本
分裂与合并

分块链表的动态行为是其设计的精髓 其核心在于通过分裂与合并来高效地处理块的满与空的状态 这也是它与分块数组最根本的区别

熟悉B-树的可能很熟悉这个形式

插入与分裂

当需要在某个位置插入一个元素时 如果目标块未满 操作仅限于块内O(BLOCK_SIZE)的数据搬移 但当目标块已满(size == BLOCK_SIZE)时 必须执行分裂 (Split) 操作

  • 创建一个新的空块节点
  • 将当前已满的块中的后半部分 元素 (通常是 BLOCK_SIZE / 2 个) 搬移到这个新块中
  • 在父结构的链表层面 将这个新块节点插入到当前块的后面 这是一个纯粹的 O(1) 指针操作
  • 此时 原来的插入点无论落在前半部分还是后半部分 对应的块都已腾出足够空间 可以继续执行块内插入

分裂的成本主要在于数据搬移 是O(BLOCK_SIZE)但关键在于 它将一个可能导致全局数据移动的操作 限制在了一个局部的 固定成本的范围内

删除与合并

当删除一个元素导致块内元素过于稀疏(例如size < BLOCK_SIZE / 2)时 为了维持较高的空间利用率和缓存性能 结构会触发合并 (Merge)

  • 检查相邻的块 是否也处于稀疏状态
  • 如果满足条件(例如 两个块的元素总数可以被一个块容纳) 就将其中一个块的所有元素全部搬移到另一个块中
  • 在父结构的链表层面 释放掉那个变空的块节点 这是一个 O(1) 的指针操作

合并的成本同样是 O(BLOCK_SIZE) 它的目的是通过主动的结构调整 来对抗内部碎片的累积

效率与应用

分块链表通过让每个节点承载更多数据 极大地降低了传统链表的两大痛点

  • 减少了指针的相对内存开销
  • 显著改善了缓存性能 在遍历一个块时 CPU可以一次性载入多个元素 此外 内存分配的次数也大幅减少 相比于每个元素都要new一次 分块链表只需在创建新块时才与分配器交互
  • 不得不承认 分块链表的缓存的利用很局限在某一块内 对于零散的访问 其缓存性能会退化为链表

这种结构非常适合需要在序列中间 频繁进行批量 插入和删除的场景 它的思想在数据库的B-树/B+树索引结构中得到了充分体现 树的每一个节点都可以看作一个数据块 节点之间通过指针链接

与分块数组的对比

分块链表的设计哲学与分块数组截然相反 这种差异完全源于它们父结构的不同

  • 核心优势与应用场景
    • 分块数组 它的核心优势在于高效的双端操作 其整个结构都是为了在序列的头部和尾部实现摊还O(1)的增删而优化的 因此它最适合的场景是实现一个健壮的双端队列
    • 分块链表 它的核心优势在于高效的任意位置批量操作 通过分裂与合并机制 它将中间插入/删除的成本控制在一个局部的 固定的大小内 因此它适合需要在一个长序列的中间频繁进行批量数据修改的场景
  • 随机访问能力
    • 分块数组 继承了其数组父结构的特性 提供了纯粹的 O(1) 随机访问能力
    • 分块链表 继承了其链表父结构的特性 只能进行 O(n) 的顺序访问 牺牲了随机访问能力
  • 中间操作的实现
    • 分块数组 面对中间插入和删除时 其父结构(数组)无法高效地增删节点(指针) 因此它只能退而求其次 进行 O(n) 的全局性元素移动 其成本随容器总大小线性增长
    • 分块链表 其父结构(链表)支持高效的中间节点增删 因此它可以采用 O(BLOCK_SIZE) 的局部性分裂/合并来应对 这是一个成本固定的操作 不会随容器总大小变化
  • 内存开销
    • 分块数组 内存开销较小 只有中控数组需要存储指针 数据块本身没有额外的指针开销
    • 分块链表 内存开销较大 每个块节点 都需要额外存储next/prev指针 导致了更高的空间占用
  • 碎片管理
    • 分块数组 被动地接受内部碎片 它的设计没有考虑回收块内的零散空间 因为合并的代价(父结构O(n)操作)过高 这可能导致其空间利用率随使用而下降
    • 分块链表 主动地管理内部碎片 其合并机制的根本目的就是为了对抗空间碎片化 通过回收稀疏的块来维持较高的空间利用率

效率分析

对于现代CPU 缓存 CPU预取器 和 SIMD 指令集是决定现代程序实际性能的关键变量 在这个层面 混合数据结构的混合特性体现得淋漓尽致

  • 缓存性能 (Cache Performance)
    • 块内(微观)层面 这是混合结构优势的来源 在单个数据块(子结构)内部 元素是连续存储的 这意味着当 CPU 访问块内第一个元素时 会将包含该元素及其后续邻居的整个缓存行(Cache Line)加载到高速缓存中 接下来的块内遍历将是极速的缓存命中(Cache Hit) 这种行为与纯粹的数组无异
    • 块间(宏观)层面 这是混合结构代价的体现 当遍历从一个块的末尾跳到下一个块的开头时 由于两个块在内存中的物理位置几乎肯定是不连续的 这几乎必然导致一次缓存未命中(Cache Miss) CPU 不得不暂停流水线 从慢速的主内存中重新加载数据
    • 对比 分块数组分块链表 在这方面的宏观行为类似 它们的遍历性能都是一系列高速的块内命中和可预测的块间未命的的交替 整体缓存效率远高于每个节点都可能触发缓存未命中的传统链表 但永远无法企及数据从头到尾几乎完全缓存命中的纯粹数组 块的大小BLOCK_SIZE直接决定了缓存命中与未命中比例 是一个核心的性能调优参数
  • CPU 预取与分支预测(CPU Prefetching)
    • 现代 CPU 的预取器(Prefetcher)会猜测程序即将访问的内存地址 并提前将其加载到缓存中 这种猜测依赖于清晰的 线性的内存访问模式 在混合数据结构的块内遍历时 这种线性的访问模式清晰可辨 预取器可以完美地工作
    • 块间 跳转时 预取器则会完全失效 下一个块的地址是一个存储在指针或索引中的数据 CPU 必须先完成当前块的读取 才能解引用指针得知下一个目标地址 这种指针追逐 (Pointer Chasing) 的行为打断了内存访问的线性流 使预取器无能为力
    • 在这一点上 分块数组分块链表没有本质区别 它们都因块间的指针跳转而无法被 CPU 预取器进行宏观优化
  • SIMD (单指令多数据流) 的适用性
    • SIMD 指令集通过一次操作处理一组连续的数据(向量)来获得巨大的性能提升 它对数据的连续性有硬性要求
    • 这意味着 我们无法对整个混合数据结构进行一次全局的 SIMD 操作 因为数据在块间是断裂的
    • 但是 我们可以将 SIMD 应用在块内操作上 每个数据块都是一个标准的连续数组 我们可以对遍历过程进行优化 在每个块上调用一个 SIMD 优化的函数来处理块内数据
    • 适用性分析 无论是分块数组 还是分块链表 它们对 SIMD 的支持程度是完全相同的 因为这取决于它们的子结构(数组)而非父结构 这种块级SIMD优化的有效性同样取决于BLOCK_SIZE 如果块太小 遍历块的开销可能会抵消SIMD带来的加速效果 如果块太大 则块内的数据搬移成本又会增加

混合数据结构在硬件层面是一种局部优化的典范 它们通过分块创造出许多缓存友好的局部地带 使得大部分操作都能在这些高效的局部环境中完成 从而远胜于传统链表的零碎 但这些块之间由性能低下的指针跳转连接 使得其永远无法达到纯粹数组那种从头到尾的高速公路般的全局性能

相关推荐
胡萝卜3.039 分钟前
数据结构初阶:详解单链表(一)
数据结构·笔记·学习·单链表
闪电麦坤952 小时前
数据结构:红黑树(Red-Black Tree)
数据结构··红黑树
墨染点香2 小时前
LeetCode 刷题【53. 最大子数组和】
数据结构·算法·leetcode
工藤新一¹3 小时前
C/C++ 数据结构 —— 树(2)
c语言·数据结构·c++·二叉树··c/c++
七十二小時4 小时前
力扣热题——前K个高频元素
数据结构·算法·leetcode
空白到白5 小时前
算法练习-合并两个有序数组
数据结构·python·算法
花开富贵ii8 小时前
代码随想录算法训练营四十九天|图论part07
java·数据结构·算法·图论·prim·kruscal
张同学的IT技术日记8 小时前
数据结构初学者必用:手把手教你写可复用代码模板(附完整示例)
数据结构
Forest239 小时前
浅谈ArrayList的扩容机制
java·数据结构