AIGC标识 数据结构(编写中~)

1. 存储结构

所有数据结构在物理存储层面,只分为连续存储不连续存储两种形态。

1.1 连续存储

数据元素被分配在地址连续的内存空间中。访问任意元素只需通过「基地址 + 偏移量」即可定位,因此支持 O(1) 的随机读写。

array[offset]

picture-01-01-array

1.2 不连续存储

数据元素分散在内存的不同位置。每个元素除了存储自身数据外,还持有指向下一个元素的指针,通过指针链将离散的地址空间串联起来。访问某一元素需要从头部开始沿指针逐跳遍历。

head.val + head.next

picture-01-02-linked-list


2. 逻辑结构

逻辑结构建立在存储结构之上,描述的是数据元素之间的组织关系及其允许的操作集合。它与存储结构之间没有强绑定关系——同一种逻辑结构可以用不同的存储方式实现。

数组和链表是最基础的两种逻辑结构:数组天然适配连续存储,通过下标实现快速随机访问;链表则将不连续的地址空间串接起来,通过指针遍历访问。栈(LIFO)和队列(FIFO)这样的约束结构,既可以用数组实现,也可以用链表实现,这说明:

  • 存储结构与逻辑结构之间没有强相关关系;
  • 复杂的逻辑结构由简单结构不断变换和组合而成。

在实际工程中,出于性能考虑,通常会优先选择数组(连续存储)来实现栈和队列。同理,更复杂的逻辑结构在实现时也会优先考虑性能。

下面展开梳理常见的逻辑结构。

2.1.1 单向链

单链表是一个个离散节点通过指针引用形成的、只能沿一个方向遍历的数据结构。每个节点的结构定义如下:

type ListNode struct {
    val  int
    next *ListNode
}

通常我们拿到一条单向链表时,拿到的就是一个 ListNode 节点(称为 Head 节点),通过它可以访问该节点及其 next 链上所有后续节点。

2.1.2 双向链

picture-01-03-doubly-linked-list

双链表是在单链表的基础上增加反向遍历能力的数据结构。每个节点同时持有前驱和后继指针:

type DoubleListNode struct {
    val  int
    prev *DoubleListNode
    next *DoubleListNode
}

在单向链表中,拿到任意一个节点时,该节点只能作为遍历起点(因为它无法反向索引上游节点)。在双向链表中,每个节点可以通过检查 prev 是否为空来判断自身是否为头节点(head)。双向链表有两个端点:通常约定一端为 head,另一端为 tail。

2.1.3 循环链

前面讨论的链表默认不成环。当链表的首尾连接在一起时,就称为循环链表。

双向循环链表的节点同时持有 prevnext,成环后形成严格的对称环:

picture-01-04-circular-doubly

单向循环链表只有 next 指针,因此允许一条线性链的尾部接入环中,形成 ρ 形(6 字形),入口节点会有两条入边:

picture-01-05-circular-singly

2.2 STACK 栈

栈可以理解为在数组基础上增加读写约束的逻辑结构。它的约束是:只有一个方向的边界可读写,数据操作必须满足后进先出(LIFO, Last In First Out)。

picture-02-01-stack

栈结构被设计用于数据保留、回溯、现场恢复等业务场景。

2.3 QUEUE 队列

队列与栈结构相似,但操作约束不同——它遵循先进先出(FIFO, First In First Out)。与栈只有一个方向可读写不同,队列两端开口但各司其职:front 端只出不进(dequeue),rear 端只进不出(enqueue)。

picture-02-02-queue

队列结构被设计用于数据缓冲、顺序处理等业务场景。

2.4 HASH 散列表

散列表以期望达到 O(1) 平均随机读写时间为目标而设计,相较于前面的结构,其复杂度明显提升。

2.4.1 基本原理

在我们已知的数据结构中,只有通过下标访问数组元素能够满足 O(1) 随机读写的要求。实际上,散列表也是一种基于数组构建的逻辑结构,以图继承数组的 O(1) 随机访问性能。

要想使用数组的随机访问能力,首先需要定义「下标」。在数组中,「下标」仅代表数据存储位置,与元素值没有直接联系——虽然数组可以通过下标快速访问元素,但无法直接用元素值定位其位置:判断一个元素是否存在于数组中的时间复杂度是 O(n)。

picture-03-01-array-search

散列表的设计目标是让「下标」与元素值建立一一对应的映射关系。理想情况下,可以为每个元素计算一个数字编码,将其映射到以数组地址为基址的存储空间上,访问时通过编码实现平均 O(1) 的读写。地址空间中每一个可被读写的元素位称为「桶」(bucket),因此散列表底层常被称为「桶数组」。

那么编码是如何进行的呢?

首先,编码最终被用于寻址,因此结果必然与存储地址相关。假设使用长度为 256 的数组来存储元素,则编码最多能表示 256 个号码,即一个 8 位二进制数。能想到的最简单的编码方式是计数编码,但这种方式无法满足 O(1) 快速转换的需求——在不借助其他存储结构的情况下,仍无法解决「元素 → Code」的映射问题。

工程上用于满足上述需求的计算方法有很多,它们被统称为单向散列算法(也称为 Hash 算法)。Hash 算法的本质是将一个大集合中离散的数据压缩到一个紧凑的数值范围内,这类方法天生自带冲突风险——集合中的 A 和 B 经 hash 计算后可能得到相同的 hash 值。不考虑冲突的理想情况下,可以通过 Hash 算法将数据确定性地转化为固定长度的结果,以满足快速编码转换需求:操作数据结构时,用元素内容进行 hash 计算,将计算结果作为编码地址进行快速寻址。

要存储数据就需要分配存储空间,根据前面的原理,存储空间大小与 hash 结果的最大值(即值长度)相关。Hash 算法有很多种,结果长度覆盖 8~256 bit。选择短 hash 时,容量有限,可能无法满足存储需求;选择长 hash 时,预留的存储空间很大,如果实际占用较少,就会造成严重的存储空间浪费。因此,工程实现上通常会选择一个稍长 hash 的算法,然后只取低 n 位作为编码地址。当空间不够用时,多选一个高位加入编码(n+1 位)以达到扩容目的——扩容后元素的存储位置可能发生变化,需要重新分配桶数组。

picture-03-02-hash-ideal

2.4.2 哈希冲突

前面提到,Hash 算法具有冲突风险。如果冲突发生,集合中的 A、B 在散列表中就不再是一一对应的关系了,可能出现用 A 取到 B 或用 B 取到 A 的情况。实现上,Hash 算法分为密码学范畴和非密码学范畴两类,前者对 hash 冲突进行了针对性的预防设计,冲突风险大大降低。

尽管如此,在做了「只取低 n 位」的变换后,即使是密码学范畴的 Hash 算法,冲突概率也会被放大,其预防效果大打折扣。

picture-03-03-hash-collision

工程上解决 hash 冲突主要有两种方案:拉链法和开放寻址法。

拉链法

拉链法的思路是:当 hash 冲突发生时,在桶位上开链,使用链表向后扩展以存储后入的冲突元素。访问时对比真实元素值进行最终定位。这种方式在最坏情况下会退化为 O(n)。

为了降低这种影响,通常会在每个桶中预置多个槽位,避免频繁开链。具体实现细节可参考 Go 语言中 map 的底层设计。

picture-03-04-chaining

开放寻址法

与拉链法不同,开放寻址法的所有元素直接存储在桶数组内部,不存在链表等外部存储结构。当一个新元素要插入,但其 hash 计算出的桶位已被占用时,该元素不能覆盖已有数据,而必须按照预设的探测序列寻找下一个空桶存入。

picture-03-05-open-addressing

以线性探测为例,探测序列为 (hash + i) mod M(i = 0, 1, 2, ...)。假设 M = 5:

初始:hash(A)=0 → slot[0]=A
      hash(B)=0 → slot[0] 已占 → slot[1]=B

下标:  0      1      2      3      4
       [A]   [B]    空     空     空

此时插入 C,hash(C)=1。C 走到 slot[1],发现被 B 占据——虽然 B 的原始 hash 是 0,B 是漂移过来的,但规则只关心「槽位当前是否被占用」,不关心占用者的原始 hash 是什么。C 继续探测到 slot[2]:

下标:  0      1      2      3      4
       [A]   [B]   [C]    空     空

B 的原始 hash 是 0,物理住在 slot[1];C 的原始 hash 是 1,物理住在 slot[2]。二者都发生了漂移,但插入过程中没有发生任何覆盖。

删除与墓碑标记:删除元素时不能直接将对应槽位置空。如果直接把 slot[1] 的 B 清空:

下标:  0      1      2
       [A]    空    [C]

此时查询 C:hash(C)=1 → slot[1] 为空,查找终止,C 永远无法被找到。

工程上的解法是使用墓碑标记(Tombstone):删除时不在槽内写入空值,而是打上一个特殊标记。插入时,墓碑位置可以被复用,写入新数据;查找时,遇到墓碑不停止,继续向后探测;只有遇到真正未被使用过的空槽,才终止查找。

主聚集(Primary Clustering):由于开放寻址的插入规则是「被占即后移」,冲突会逐槽向后传染。以 M = 10 为例:

插入 A(hash=2) → slot[2]=A
插入 B(hash=2) → slot[2] 被占 → slot[3]=B  (B 漂移)
插入 C(hash=3) → slot[3] 被 B 占 → slot[4]=C (C 本身无冲突,但被 B 占坑,被迫漂移)

C 的原始 hash=3,本可以一次命中 slot[3],但 B 漂移过来占了位置,C 被迫多走一步。B 和 C 的原始 hash 值不同,冲突是传染过来的,而非 hash 碰撞直接导致。

这种「前置漂移元素占用槽位 → 后续 hash 不同的 key 被迫卷入探测链 → 聚集链持续变长」的现象,称为主聚集,是线性探测特有的缺陷。拉链法不存在此问题——链表的每个节点只影响同一桶内的元素,冲突不会扩散到其他桶。

性能分析:Knuth 给出了线性探测的理论公式(假设哈希函数均匀分布),其中 α = 已存储元素数 / 表大小(负载因子):

  • 成功查找平均探测次数:(\frac{1}{2}\left(1 + \frac{1}{1-\alpha}\right))
  • 插入 / 失败查找平均探测次数:(\frac{1}{2}\left(1 + \frac{1}{(1-\alpha)^2}\right))

代入几组 α 值:

α 成功查找平均探测 插入/失败平均探测
0.5 1.50 次 2.50 次
0.7 2.17 次 5.05 次
0.8 3.00 次 8.50 次
0.9 5.50 次 25.5 次
0.95 10.5 次 200.5 次

插入和失败查找的代价随 α → 1 呈平方级增长。α = 0.95 时,平均需要扫描 200 多个槽位才能完成一次插入或失败查找。

但线性探测有一个重要的补偿效应:探测序列沿连续内存地址推进,CPU 缓存预取和命中率很高。在 α = 0.7 时,虽然平均需要 5 次探测,但 5 次连续内存访问的实际耗时远低于拉链法遍历 5 个分散在堆上的链表节点。然而当 α 继续升高,探测次数的爆炸终将压倒缓存优势——工程实践通常将扩容阈值设在 α = 0.6~0.7,在性能断崖出现之前触发 rehash,将聚集链打散。

缓解策略:除扩容外,还可以通过调整探测策略缓解聚集:

  • 二次探测:探测步长随 i 递增(如 (h + i²) mod M),可以缓解主聚集,但 hash 相同的 key 探测序列仍然相同,会产生次聚集(Secondary Clustering);
  • 双重哈希:使用第二个哈希函数决定探测步长((h + i · h₂(key)) mod M),不同 key 的探测步长不同,对聚集的缓解效果最好,但多一次 hash 计算的额外开销;
  • 优化哈希函数:让原始 hash 分布更均匀,减少原始冲突的源头,但无法彻底消灭主聚集——主聚集的根源不是哈希冲突本身,而是「漂移元素传染无辜 key」。
posted @ 2026-08-07 20:54  DevinChameleon  阅读(3)  评论(0)    收藏  举报