UIUC-CS598-高级数据结构笔记-全-

UIUC CS598 高级数据结构笔记(全)

001:课程概述与区间最小值查询

在本节课中,我们将学习高级数据结构课程的整体框架,并深入探讨一个具体的数据结构问题:区间最小值查询。我们将从课程的基本要求开始,逐步分析如何高效地解决这个查询问题。

课程概述

这门课程是为研究生设计的,重点在于数据结构的理论、设计与分析。课程不涉及代码实现,而是专注于算法复杂度分析、证明和数学建模。课程的核心评估方式是基于项目的最终报告和演示。

课程涵盖的主题广泛,包括但不限于:

  • 前驱查询与平衡二叉搜索树
  • 优先队列与堆
  • 映射/字典与哈希表
  • 动态图算法
  • 考虑内存层次结构的数据结构
  • 利用字长并行性的数据结构
  • 数据结构的下界证明

区间最小值查询问题

现在,让我们聚焦于一个具体的数据结构问题:区间最小值查询。

问题定义

给定一个长度为 n 的静态数组 A,我们需要预处理这个数组,以便能够快速回答以下形式的查询:
RMQ(i, j):返回子数组 A[i..j] 中最小元素的值(或索引)。

基础解决方案

最直接的解决方案是预先计算所有可能的 (i, j) 组合的答案。

以下是构建查询表的方法:

# 假设 n 为数组长度
lookup_table = [[0] * n for _ in range(n)]
for i in range(n):
    current_min = A[i]
    for j in range(i, n):
        current_min = min(current_min, A[j])
        lookup_table[i][j] = current_min

这个方案能在 O(1) 时间内回答查询,但需要 O(n²) 的存储空间,对于大型数组来说不可行。

使用锦标赛树(区间树)

为了在减少空间的同时保持可接受的查询时间,我们可以使用基于二叉树的结构。

上一节我们介绍了基础解决方案的局限性,本节中我们来看看如何利用二叉树来优化。

其核心思想是将数组递归地分成两半,并在每个树节点存储其对应区间的最小值。查询时,我们将查询区间分解为树中若干个“规范区间”的并集。

以下是查询算法的伪代码描述:

function RMQ(node, i, j):
    if node.interval 与 [i, j] 不相交:
        return INFINITY
    if [i, j] 完全包含 node.interval:
        return node.min_value
    else:
        return min( RMQ(node.left, i, j), RMQ(node.right, i, j) )

这种方法使用 O(n) 空间构建树,每次查询最多访问 O(log n) 个节点,因此查询时间为 O(log n)

稀疏表法

我们可以进一步优化,目标是让查询时间变为真正的常数,同时空间小于 O(n²)

上一节我们利用二叉树将查询时间降至对数级,本节中我们来看看如何通过存储特定长度的区间答案来达到常数查询时间。

稀疏表法的核心是:我们只预计算所有长度为 2^k(即1, 2, 4, 8...)的区间的最小值。对于任意查询 [i, j],我们可以用两个长度为 2^k 的区间(可能重叠)来覆盖它。

以下是构建稀疏表的方法:

import math
k_max = math.floor(math.log2(n)) + 1
sparse_table = [[0] * n for _ in range(k_max)]

# 初始化长度为 1 的区间
for i in range(n):
    sparse_table[0][i] = A[i]

# 动态规划构建更长的区间
for k in range(1, k_max):
    length = 1 << k # 2^k
    for i in range(n - length + 1):
        # 区间 [i, i+2^k-1] 的最小值是两个半区间最小值的较小者
        sparse_table[k][i] = min(sparse_table[k-1][i], sparse_table[k-1][i + (length // 2)])

对于查询 RMQ(i, j)

  1. 计算区间长度 L = j - i + 1
  2. 找到最大的 k,使得 2^k <= L
  3. 查询两个长度为 2^k 的区间:[i, i+2^k-1][j-2^k+1, j]
  4. 返回这两个区间最小值中的较小者。

这个方法需要 O(n log n) 的存储空间,但能在 O(1) 时间内回答查询。

间接寻址法优化空间

稀疏表的空间是 O(n log n),我们希望能接近 O(n)

上一节我们通过稀疏表实现了常数查询时间,但空间开销仍然较大。本节中我们通过将问题分块,并结合稀疏表来进一步压缩空间。

我们将原数组分成大小为 B 的块。我们处理两个层次:

  1. 块内:对每个大小为 B 的块,构建一个稀疏表来快速回答块内的区间查询。这需要 O(B log B) 空间每块,总共 O(n log B) 空间。
  2. 块间:创建一个“概要数组”,其中每个元素是对应块的最小值。对这个概要数组构建一个稀疏表。这需要 O((n/B) log(n/B)) 空间。

通过巧妙选择块大小 B = log n,可以使总空间复杂度降至 O(n log log n),同时保持 O(1) 的查询时间(需要常数次稀疏表查询)。

递归应用与展望

我们可以递归地应用上述分块思想,将空间复杂度降至 O(n log* n) 甚至更低,每次递归增加一个常数倍的查询时间。

在后续课程中,我们将看到一种完全不同的方法,能够实现 O(n) 空间和真正的 O(1) 查询时间,彻底解决这个问题。

总结

本节课中我们一起学习了高级数据结构课程的框架和第一个深入案例——区间最小值查询。我们从最朴素的 O(n²) 空间解法出发,逐步优化:

  1. 利用锦标赛树达到 O(n) 空间和 O(log n) 查询时间。
  2. 利用稀疏表达到 O(n log n) 空间和 O(1) 查询时间。
  3. 通过引入分块和间接寻址,权衡空间与时间,达到 O(n log log n) 空间和 O(1) 查询时间。
    这个过程展示了数据结构设计中空间与时间权衡的核心思想,以及通过分层、分治来解决问题的通用技巧。

002:区间最小值查询(续)🔍

在本节课中,我们将继续深入探讨区间最小值查询问题,目标是构建一个使用线性空间和常数查询时间的数据结构。我们将通过引入最低公共祖先问题,并利用笛卡尔树和欧拉游历等概念,最终实现这一目标。


课程管理事项更新 📢

首先,有几项课程管理事项需要更新。学期初,我已经更正了Ed讨论区的链接,现在链接指向的是注册页面。如果仍然无法进入,请通过邮件告知我。

本学期,我将在课程网页上更新课堂板书(即所谓的“板书工作”)的副本以及讲座视频的链接。我也会尽可能提供更详细的讲义。

此外,我将在本周末之前在Gradescope上设置作业提交链接。最后,我的每周办公时间定于周五下午两点,地点在Sebel办公室外。如果这个时间对很多人不合适,我可能会进行调整,请通过邮件或Ed讨论区告知。


回顾区间最小值查询问题 🔄

上一节我们介绍了区间最小值查询问题。输入是一个来自全序集合的数组,我们可以比较元素。目标是对数组进行预处理,以便后续能快速回答形如 (i, j) 的查询,即返回子数组 A[i:j] 中最小值的索引

我们讨论了两种基本方法:

  1. 构建所有答案的查找表:使用 O(n²) 空间,实现 O(1) 查询。
  2. 使用线段树(或类似二叉树):每个节点存储其子树中叶子节点的最小值。这需要 O(n) 空间,但查询时间为 O(log n)

我们通过“间接”技巧,在空间和查询时间之间取得了更精细的权衡,达到了 O(n log log n) 空间和 O(1) 查询。但我们的终极目标是 O(n) 空间和 O(1) 查询


通向目标的桥梁:最低公共祖先问题 🌉

实现线性空间常数时间查询的关键,是引入一个看似无关的问题:最低公共祖先查询

LCA问题的输入是一棵有根树 T。查询是给定两个节点 uv,找到它们深度最深的公共祖先节点。

表面上看,LCA与RMQ毫无关系。但我们将展示,RMQ问题可以规约到LCA问题,同时LCA问题也可以规约到RMQ问题。这种等价性为我们提供了强大的工具。

从RMQ规约到LCA:笛卡尔树 🗺️

这个规约使用了一种称为笛卡尔树的数据结构。

给定数组 A,其笛卡尔树 C 定义如下:

  • 树的中序遍历恰好得到原始数组 A
  • 树是一个最小堆:节点的值小于其子节点的值。

构建过程:根节点是数组中最小值的索引。其左子树递归地由该最小值左侧的元素构成,右子树递归地由该最小值右侧的元素构成。

关键性质:对于RMQ查询 (i, j),数组 A[i:j] 中的最小值索引,恰好是笛卡尔树中代表索引 i 的节点和代表索引 j 的节点的最低公共祖先

因此,如果我们能高效处理笛卡尔树上的LCA查询,就能高效回答原数组的RMQ查询。笛卡尔树本身只有 O(n) 个节点。

从LCA规约到RMQ:欧拉游历与深度序列 🧭

这个规约使用了一种称为欧拉游历的技术。

对树 T 进行欧拉游历,意味着沿着树的边“行走”,并在每次到达一个节点(无论是第一次还是返回时)都记录下它。对于一个有 n 个节点的树,欧拉游历序列的长度为 2n-1

我们不仅记录节点,更关键的是记录每次访问节点时的深度。这样就得到了一个深度序列 E

关键性质:对于LCA查询 (u, v),在深度序列 E 中,任选 uv 各一次出现的位置 p_up_v(假设 p_u < p_v),那么 uv 的LCA的深度,就是子数组 E[p_u : p_v] 中的最小值

因此,LCA查询被转化为了对深度序列 E 的RMQ查询。更重要的是,由于欧拉游历的特性,深度序列中相邻元素的差值总是 +1-1。这被称为 ±1 RMQ 问题,它具有特殊的结构,可以被利用。


实现线性空间与常数查询:四毛子算法 🧠

现在,我们结合上述规约和“间接”分块思想,来构建最终的 O(n) 空间、O(1) 查询的静态RMQ数据结构。这种方法常被称为“四毛子算法”。

以下是具体步骤:

  1. 从一般RMQ到±1 RMQ

    • 为原数组 A 构建笛卡尔树 C
    • C 进行欧拉游历,得到长度为 2n-1 的深度序列 EE 是一个±1序列。
    • 现在,原数组的RMQ查询 (i, j) 可以转化为深度序列 E 上某个区间的RMQ查询。
  2. 对±1 RMQ序列进行分块

    • 将序列 E 分成大小为 b = (1/2) log n 的块(这里对数以2为底)。
    • 我们有一个“概要”数组,存储每个块的最小值。这个数组长度为 n/b
  3. 预处理概要数组和块内部

    • 概要数组:使用稀疏表(Sparse Table)进行预处理,实现 O(1) 查询。这需要 O((n/b) log(n/b)) 空间,当 b = (1/2) log n 时,空间为 O(n)
    • 块内部:关键洞察在于,由于是±1序列,每个块的模式(上升/下降序列)最多只有 2^(b-1) 种可能。我们预先计算所有可能模式对应的RMQ查询表。虽然模式总数最多 2^b ≈ √n 种,对每种模式构建一个 b x b 的答案表,总预处理空间和时间仅为 O(√n * b²) = O(n)
  4. 回答查询
    对于一个查询区间 [i, j]

    • 它可能跨越多个完整的块。
    • 对于两端的非完整块,使用预先计算的、与该块模式对应的内部查询表,在 O(1) 时间内得到块内最小值。
    • 对于中间完整的块,使用概要数组的稀疏表查询,在 O(1) 时间内得到这些块最小值中的最小值。
    • 将上述三部分结果取最小值,即为最终答案。整个过程只涉及常数次数组查找。

通过这种方法,我们最终实现了 O(n) 空间和 O(1) 查询时间的静态RMQ数据结构。


支持动态更新的RMQ结构 ⚡

上述数据结构是静态的。如果我们希望支持更新操作(如修改数组中某个元素的值),该怎么办?

一个简单的动态结构是线段树,它支持 O(log n) 的查询和更新。但存在一个理论下界:在比较模型下,任何RMQ数据结构,如果查询时间是 O(1),则更新时间至少为 O(log n);反之,如果更新时间是 O(1),则查询时间至少为 O(log n)。这是通过将RMQ用于模拟排序算法证明的。

目前已知最好的动态RMQ数据结构之一(De Roche算法)在保证 O(1) 查询的同时,实现了 O(√n) 的更新时间。其核心思想是构建一个深度为 O(log log n) 的树状层次结构:

  • 层次化分块:将数组分成大小为 √n 的块,每个块再分成大小为 n^(1/4) 的子块,如此递归直到单个元素。
  • 每层预处理:在每个块内,预处理所有前缀最小值和后缀最小值(通过简单循环)。同时,计算每个子块的摘要(最小值)上传给父块,父块用稀疏表处理这些摘要。
  • 常数查询:给定查询 [i, j],可以快速定位到某个层次,使得块大小与查询区间长度相当。答案由至多一个前缀查询、一个后缀查询和一个上层摘要查询组成,均为 O(1)
  • 压缩存储:通过将较小层次的索引(所需比特数少)打包存入机器字,可以将总空间控制在 O(n) 个机器字。
  • O(√n) 更新:更新一个元素需要重建包含它的所有层次的前缀/后缀/摘要结构。最耗时的部分在顶层,需要重建一个大小为 √n 的块及其父块摘要,因此更新时间为 O(√n)

如何突破 O(√n) 的更新时间,同时保持 O(1) 查询,仍然是一个开放问题。


总结 📝

本节课我们一起深入学习了区间最小值查询问题的高效解决方案。

  1. 我们首先回顾了空间与查询时间之间的基本权衡。
  2. 然后,我们引入了最低公共祖先问题,并展示了它与RMQ问题的双向规约
    • 通过笛卡尔树将RMQ规约到LCA。
    • 通过欧拉游历深度序列将LCA规约到特殊的 ±1 RMQ
  3. 利用这种等价性和四毛子算法(预计算所有小块模式),我们构建了静态RMQ数据结构,达到了理想的 O(n) 空间和 O(1) 查询时间。
  4. 最后,我们探讨了动态RMQ问题,了解了线段树的 O(log n) 查询/更新权衡,以及更复杂的De Roche结构如何实现 O(1) 查询和 O(√n) 更新,并认识了该问题的理论下界和开放挑战。

这些技巧(规约、分层、预计算、位压缩)是设计高效算法和数据结构的核心工具,其应用远不止于RMQ问题。

003:静态到动态的转换 🛠️

在本节课中,我们将学习如何将一个静态的、不支持更新的数据结构,转换为一个动态的、能够支持插入和删除操作的数据结构。我们将探讨两种核心方法:对数法(Logarithmic Method)和惰性重建法(Lazy Rebuilding),并了解它们适用的场景和限制。


可分解查询

上一节我们介绍了数据结构的通用问题模型。本节中,我们来看看一个关键概念:可分解查询。这是后续所有动态化技术的基础。

一个查询被称为可分解的,如果我们可以将数据集 X 任意划分为若干不相交的子集,然后通过以下方式回答关于 X 的查询 q

  1. 在每个子集上独立地执行查询。
  2. 使用一个高效的运算符(记为 )合并这些子查询的结果。

这个过程可以用以下公式表示:

Query(q, X) = Query(q, A) ◊ Query(q, B) ◊ ... ◊ Query(q, Z)

其中,A, B, ..., ZX 的一个划分,且运算符 可以在常数时间内完成。

以下是几个常见查询类型的例子:

  • 计数查询:运算符 是加法 +。例如,Count(box) = Count_red(box) + Count_blue(box)
  • 存在性查询:运算符 是逻辑或 OR。例如,Exists(box) = Exists_red(box) OR Exists_blue(box)
  • 最小值查询:运算符 是取最小值 min。例如,Min(box) = min(Min_red(box), Min_blue(box))

相反,多数投票查询(例如,查询矩形内哪种颜色占多数)通常是不可分解的,因为你无法仅从子区域的多数结果推断出整个区域的多数结果。


对数法:支持插入操作

上一节我们定义了可分解查询。本节中,我们来看看如何利用这个特性来支持插入操作。这种方法被称为对数法

其核心思想是:我们不维护一个单一的大型静态数据结构,而是维护一系列大小呈指数增长的小型静态数据结构。

数据结构布局

我们将数据集划分为最多 log₂ N 个“层级”。每个层级 L_i 要么是空的,要么包含一个大小为 2^i 的静态数据结构。

  • 层级 i 的状态(空或满)恰好反映了当前数据总数 N 的二进制表示中第 i 位的值(1 或 0)。
  • 例如,当 N = 13(二进制 1101)时,我们将有:
    • 一个大小为 2^0 = 1 的数据结构(对应最低位 1)
    • 一个大小为 2^2 = 4 的数据结构(对应第三位 1)
    • 一个大小为 2^3 = 8 的数据结构(对应最高位 1)

查询操作

要回答一个查询,我们只需在所有非空的层级上分别执行查询,然后用运算符 合并结果。

总查询时间 ≤ Σ Q(2^i) ≤ Q(N) * log N

其中 Q(n) 是静态数据结构在大小为 n 时的查询时间。如果 Q(n) 是多项式级的(例如 √n),则求和会形成一个几何级数,log N 因子会被吸收进大 O 记号。

插入操作

插入操作模拟了二进制加一的过程:

  1. 将新元素视为一个大小为 2^0 = 1 的新数据结构。
  2. 检查层级 0。如果为空,则放入。如果已满(即已有一个大小为 1 的结构),则将这两个大小为 1 的结构合并,构建成一个新的、大小为 2^1 = 2 的静态数据结构。
  3. 将新的大小为 2 的结构放入层级 1。如果层级 1 已满,则继续合并并向上“进位”,直到找到一个空层级为止。

分摊分析

虽然单次插入在最坏情况下可能需要重建一个很大的数据结构(耗时 P(2^i)P(n) 是预处理时间),但大型重建发生的频率很低。

  • 大小为 2^i 的数据结构每 2^i 次插入才会被重建一次。
  • 因此,维护层级 i分摊时间成本是 P(2^i) / 2^i

对所有层级求和,得到分摊插入时间为:

O( Σ [P(2^i) / 2^i] ) ≤ O( (P(N) / N) * log N )

同样,如果 P(n)O(n log n) 或更高阶多项式,log N 因子通常可以忽略。


惰性重建:获得最坏情况保证

上一节介绍的对数法提供了优秀的分摊时间复杂度。但在某些实时系统(如机器人控制、高频交易)中,我们需要最坏情况时间保证。本节介绍的惰性重建技术可以实现这一点。

核心思想是:将大型重建工作“均摊”到多次插入操作中去逐步完成,而不是一次性完成。

数据结构布局

每个层级 L 不再只有一个结构,而是维护最多四个结构:oldest_L, older_L, old_L, new_L

  • oldest, older, old 是已完全构建好的、用于查询的静态数据结构,每个大小约为 2^L。每个数据项在整个系统中只存在于某一个层级的某一个 old* 结构中。
  • new_L 是一个正在构建中的结构,大小最多为 2^L,它包含了来自更低层级的数据副本。

操作过程

  1. 插入:每次插入时,不仅将新元素加入 new_0,还会为每个层级 L 花费 P(2^L) / 2^L 的单位时间,用于将 older_{L-1}oldest_{L-1} 的数据合并到 new_L 中。
  2. 渐进构建:当 new_L 构建完成时,它会被“提升”为 old_L(或 older_L 等),同时清空其来源的低层级结构。
  3. 查询:查询时,只查询所有 old* 结构,忽略 new 结构。由于每个层级最多有三个 old* 结构,查询时间仍为 O(log N * Q(N))
  4. 正确性关键:这种结构的状态对应于一种特殊的二进制表示(每位可取 1, 2, 3)。插入操作引起的状态变化与这种特殊计数的“加一”操作同步,从而保证了在需要提升 new 结构时,总有空的 old* 槽位可用。

通过这种方式,每次插入的操作时间都严格控制在 O((P(N)/N) * log N) 以内,实现了最坏情况下的时间保证。


支持删除操作

上一节我们实现了插入。本节中,我们来看看如何进一步支持删除操作。这需要更强的假设。

情形一:查询可逆(拥有“反操作”)

如果查询的合并运算符 存在逆运算(例如加法对应减法,集合并对应差集),我们可以使用一种“反数据结构”策略。

  • 数据结构:我们维护三个部分:
    • M:主数据结构,包含“存活”的数据。
    • I:一个仅支持插入的结构,记录自上次重建以来新增的数据。
    • D:一个仅支持插入的结构,记录自上次重建以来被删除的数据(作为“墓碑”)。
  • 查询Query(q) = Query(q, M) ◊ Query(q, I) ◊ Inverse(Query(q, D))。通过从主结果中加入新增项、减去删除项,得到正确结果。
  • 空间优化与全局重建:当“墓碑” D 的大小超过主结构 M 的一个常数比例(如 1/8)时,说明空间浪费严重。此时触发一次全局重建:将 MID 合并,构建一个全新的、只包含当前有效数据的 M‘,然后清空 ID
  • 分摊分析:重建成本 P(N) 可以分摊到触发重建前的那 Ω(N) 次删除操作上。因此,删除的分摊时间复杂度O(插入成本 + P(N)/N)

情形二:弱删除(标记删除)

如果查询不可逆(如最小值查询),我们可以采用弱删除标记删除

  • 删除一个元素时,并不立即将其从数据结构中物理移除,只是将其标记为“无效”(放置墓碑)。
  • 查询时,数据结构需要能够忽略这些无效元素并给出正确结果。
  • 同样,当无效元素积累到一定比例时,触发全局重建以清理空间并恢复性能。

这种方法被应用于一些平衡二叉搜索树(如替罪羊树 Scapegoat Tree)中,结合惰性重建思想,也可以实现最坏情况或分摊情况下的动态更新。


总结与展望

本节课中我们一起学习了将静态数据结构动态化的核心技巧:

  1. 可分解查询是动态化的基础前提。
  2. 对数法利用二进制思想,通过维护指数大小的结构块,以 O((P(N)/N) log N) 的分摊时间支持插入。
  3. 惰性重建通过对数法的去分摊化,实现了同样的时间上限,但是是最坏情况保证。
  4. 支持删除需要额外条件:要么查询可逆,配合“反数据结构”和全局重建;要么支持标记删除,并定期重建。

这些转换技术是通用的“工具箱”,允许我们在许多场景下为高效的静态数据结构添加动态更新能力。然而,它们也带来了额外的复杂度或常数因子。在后续课程中,我们将看到许多从一开始就为动态操作而设计的、更优雅高效的数据结构。

004:替罪羊树

在本节课中,我们将学习一种名为“替罪羊树”的平衡二叉搜索树。这种数据结构通过一种巧妙的“重建”策略来维持平衡,其核心思想是:当树变得不平衡时,不是通过复杂的旋转操作来调整,而是直接重建整个不平衡的子树,使其恢复完美平衡。我们将从最简单的场景开始,逐步引入替罪羊树的核心概念,并分析其性能。

从完美平衡树开始

上一节我们介绍了平衡二叉搜索树的基本概念。本节中,我们从一个非常基础的场景开始:一棵包含 N 个节点的完美平衡二叉搜索树,并且我们只关心搜索删除操作。

由于树是完美平衡的,其深度为 O(log N)。因此,搜索操作的最坏情况时间复杂度为 O(log N)

对于删除操作,标准的教科书算法如下:

以下是删除节点 V 的几种情况:

  1. 无子节点:直接删除 V,将其父节点的对应子指针设为 nil
  2. 有一个子节点:将指向 V 的父节点指针,重定向指向 V 的唯一子节点 W。
  3. 有两个子节点:找到 V 的后继节点 W(即其右子树中的最左节点)。将 V 和 W 的键值交换。此时,V 位于新的位置且没有右子节点,问题便转化为情况 1 或 2。

该算法的时间复杂度与树的深度成正比,因此也是 O(log N)

然而,这里存在一个问题。随着我们不断执行删除操作,树中剩余的节点数(记为 n)会减少。虽然树的深度仍然是 O(log N),但这里的 N 是初始的节点总数。当 n 远小于 N 时,O(log N) 可能不再是 O(log n),甚至可能退化为线性时间。我们真正希望的是,每个操作的时间复杂度是当前树中实际节点数 n 的对数函数,即 O(log n)

全局重建策略

为了解决上述问题,我们引入一个简单的全局重建规则:在删除 n/2 个节点后,重建整棵树

这里的“重建”是指,收集所有仍然存活的节点(排除已删除的节点或“墓碑”节点),构建一棵新的、完美平衡的二叉搜索树。

设最近一次重建后树的大小为 N。在触发下一次重建之前,我们最多执行了 N/2 次删除,因此树中剩余的节点数 n 满足 n ≥ N/2,即 N ≤ 2n

由此,我们可以得出以下性能保证:

  • 搜索:最坏情况时间为 O(log N) = O(log (2n)) = O(log n)
  • 删除(非重建时):最坏情况时间为 O(log n)
  • 删除(触发重建时):重建整棵树需要 O(N) = O(n) 时间。

虽然单次重建的代价很高,但我们可以使用摊还分析。从上次重建到触发本次重建,我们至少执行了 n 次删除(因为 n ≤ N/2)。我们可以将重建的 O(n) 成本分摊到这 n 次删除操作上,即每次删除操作预先支付一个常数时间的“重建税”。

因此,摊还后的删除时间复杂度为:O(log n)(查找节点) + O(1)(实际删除和分摊的重建成本) = O(log n)

插入操作与局部重建

上一节我们通过全局重建解决了删除导致的平衡问题。本节中我们来看看插入操作。插入比删除更容易破坏树的平衡性(例如,连续插入递增序列会形成一条链)。我们无法承受每次不平衡都进行全局重建的代价。

核心思想是进行局部重建当一个子树变得严重不平衡时,重建该子树

较小的子树更容易变得不平衡,但也更便宜、更频繁地被重建;较大的子树重建成本高,但重建频率低。这本质上是一个调度问题。

直觉上,每次插入操作时,我们沿着插入路径为路径上的每个节点 z 支付一个常数时间的“税”,用于支付未来重建以 z 为根的子树的费用。由于树始终保持近似平衡,深度为 O(log n),因此支付的总“税额”也是 O(log n),这保证了摊还插入时间为 O(log n)

现在的问题是:如何定义“严重不平衡”?以及如何确保税收策略能覆盖重建成本?

权重平衡与高度平衡

有两种常见的方式来定义平衡:

  1. 权重平衡:节点 vα 平衡 的,当且仅当其左子树和右子树的大小都至少是 α * size(v),其中 α 是一个小于 1 的常数(如 1/3 或 1/4)。如果树中所有节点都是 α 平衡的,那么树的深度为 O(log n)
  2. 高度平衡:节点 v 是高度平衡的,如果其子树的高度 h(v) 不超过 c * log(size(v)),其中 c 是一个常数。AVL 树就属于此类。

一个简单的重建触发策略是:插入新节点 v 后,从 v 向根回溯。如果遇到一个不平衡的节点 u,就重建以 u 为根的子树。

可以证明,如果一个节点因插入而变得 α 不平衡,那么自该节点上次被重建以来,至少有 Ω(size(u)) 次插入进入了该子树。这意味着,重建以 u 为根的子树所需的 O(size(u)) 成本,可以被分摊到导致其不平衡的那些插入操作上。

替罪羊树策略

替罪羊树结合了高度和权重的思想,并进行了优化:

  1. 触发条件(基于高度):维护一个参数 α > 1。当整棵树的高度超过 α * log n 时,认为树不平衡,需要干预。
  2. 寻找替罪羊(基于权重):从新插入的节点开始,向根回溯,找到第一个满足 size(left) > α * size(v)size(right) > α * size(v) 的节点 v。这个节点被称为“替罪羊”。
  3. 惩罚替罪羊:重建以替罪羊节点 v 为根的子树。

为什么只重建一个节点就够了? 在插入前,所有节点都是(接近)平衡的。插入一个节点可能使从插入点到根的路径上多个节点变得轻微不平衡。但是,重建最深的一个不平衡子树(替罪羊)会降低其高度,从而自动修复其所有祖先节点的不平衡状态。这就像把所有的“罪责”都归咎于替罪羊,惩罚它(重建)就能净化整个社区(树)。

如何找到替罪羊而不存储子树大小? 我们可以在需要时动态计算。从新节点开始向上遍历,对于每个祖先节点,我们遍历其整个子树来计算大小。虽然单次计算是 O(size(subtree)) 的,但因为我们即将花费 O(size(subtree)) 的时间来重建这个子树,所以寻找替罪羊的成本可以被吸收到重建成本中,是“免费”的。

最终方案与性能

替罪羊树的最终方案非常简洁:

  • 数据结构:一个普通的二叉搜索树,外加两个整数:树的总大小 n 和删除计数器。
  • 插入:按标准方式插入。如果插入后树高 > α * log n,则找到替罪羊节点并重建其子树。摊还时间复杂度为 O(log n)
  • 删除:使用“墓碑”法或惰性删除。维护一个删除计数器,当删除次数达到 n/2 时,触发一次全局重建。摊还时间复杂度为 O(log n)
  • 搜索:由于树始终保持 O(log n) 的深度,最坏情况时间复杂度为 O(log n)

扩展应用:打包内存数组

局部/全局重建的思想不仅适用于二叉搜索树。这里简要介绍一个经典问题:有序文件维护打包内存数组

问题:维护一个有序序列(如代码行),支持在指定元素后插入、删除元素,以及向前/向后扫描 k 个元素。

目标:使用一个大小 O(n) 的连续数组存储 n 个元素,并保证任何连续的 k 个逻辑元素存储在 O(k) 大小的连续内存中。

解决方案思想:在数组上建立一个隐式的完全二叉树结构。每个树节点对应数组的一个区间。我们为每个区间设定密度上下限(如上界 2/3,下界 1/3)。当插入导致某个区间密度过高,或删除导致密度过低时,就重建(即均匀重排)该区间内的所有元素。通过类似替罪羊树的摊还分析,可以证明插入和删除的摊还时间复杂度为 O(log² n),而扫描操作是 O(k) 最优的。

值得注意的是,对于确定性的数据结构,Ω(log² n) 是解决此问题的一个下界。而随机化算法可以做得更好,2024年的最新研究达到了接近 O(log n) 的期望摊还时间。

总结

本节课中我们一起学习了替罪羊树。它是一种通过局部重建来维持平衡的二叉搜索树。其核心在于:当插入操作导致树高超过阈值时,沿着路径向上找到一个“替罪羊”节点(其左右子树权重失衡),然后重建该子树以恢复平衡。通过巧妙的摊还分析,替罪羊树实现了 O(log n) 的最坏情况搜索时间,以及 O(log n) 的摊还插入和删除时间,同时几乎不需要在节点中存储额外的平衡信息。最后,我们还看到了局部重建思想在“打包内存数组”这一不同问题上的成功应用。

005:紧凑内存数组与伸展树

在本节课中,我们将学习两种重要的数据结构:紧凑内存数组和伸展树。紧凑内存数组是一种在连续内存块中高效维护序列的数据结构,支持插入、删除和扫描操作。伸展树是一种自调整的二叉搜索树,虽然没有最坏情况的时间保证,但在平摊分析下表现出色,并且具有一些近乎神奇的最优性特性。

紧凑内存数组

上一节我们介绍了课程的一些管理事项。本节中,我们来看看紧凑内存数组的具体细节。

紧凑内存数组的目标是存储一个包含 n 个项目的序列,并将其保存在一个大小为 O(N) 的连续内存块(即数组)中。它需要支持以下操作:

  • insert_after(x, y):将 y 紧接在 x 之后插入。
  • delete(x):从序列中移除 x
  • scan(x, k):如果 k 为正数,则扫描 x 之后的 k 个项目;如果 k 为负数,则扫描 x 之前的 k 个项目。

我们假设在给出序列中的某个项目时,我们实际上获得了指向该项目在数组中记录的指针。scan 操作的目标是达到每个报告元素的基本常数时间。

有人可能会问,为什么不能简单地使用双向链表来实现所有操作的常数时间?一个更清晰的答案是,我们希望支持“前序查询”。通过比较两个项目在数组中的地址,我们可以快速判断 x 是否在 y 之前。这在动态分配内存的链表中很难高效实现。

核心策略

基本思想是在数组元素之间保持常数大小的间隙。间隙不能太大,否则扫描数组获取数据的时间会变长;间隙也不能太小,否则插入新元素时,在狭小空间内挤压会创建更小的间隙。因此,元素大致均匀地分布在数组中。

当执行插入操作时,可能会导致局部区域过于密集;当执行删除操作时,可能会导致局部区域过于稀疏。在这两种情况下,我们都会执行局部重建。这里的“重建”比替罪羊树中的重建更简单:我们取数组的一部分,并将其中的元素均匀地重新分布,以消除过大的间隙或过密的区域。

密度阈值与层次结构

为了实现高效的平摊分析,我们定义一些密度阈值。在顶层,定义 low_0high_00 < low_0 < high_0 <= 1),它们代表整个数组允许被占用的比例。如果数组中的元素数量低于 low_0 * size,我们将重建一个大小为原来一半的新数据结构。如果元素数量超过 high_0 * size,我们将数组大小加倍并重新分布所有元素。

为了更精细地控制重建,我们递归地将数组分解为块。为简化分析,我们假设数组长度是2的幂。我们将数组划分为大小为 log n 的叶块,而不是一直递归到大小为1的块。这样,树的深度为 log(n/log n)

对于每一层 k,我们定义更严格的密度阈值 low_khigh_k。这些阈值构成一个等差数列:low_klow_0low_L 逐渐收紧(允许的密度范围变小),high_khigh_0high_L 也逐渐收紧。

插入算法

以下是插入算法 insert_after(x, y) 的步骤:

  1. 找到包含新元素 y 的叶块 B_Ly 紧接在 x 之后,可能位于 x 所在叶块或下一个叶块)。
  2. y 插入到该叶块中。
  3. 从层级 L(叶块)向上遍历到层级 0(根块):
    • 如果当前块 B_k 的密度超过 high_k,则重建(均匀分布)块 B_k 并返回。
    • 否则,继续检查父块。
  4. 如果一直检查到根块都 overcrowded,则重建整个数据结构(并可能扩大数组)。

重建一个大小为 2^r 的块的成本与其大小成正比,即 O(2^r)

平摊分析

分析的关键在于,一个块需要重建之前,必须有一定数量的插入操作发生在这个块内。具体来说,如果一个层级为 k 的块因为其子块过密而需要重建,那么自上次重建以来,插入到该块中的项目数至少为 (high_{k+1} - high_k) * (块大小)

由于密度阈值是等差数列,相邻层级的阈值差为 (high_L - high_0) / L,这是一个 O(1/L) 的量级。因此,每次插入操作需要为该块平摊支付 O(L) 的成本,即 O(log n)

然而,每次插入操作会影响从叶块到根块的整条路径上的所有 O(log n) 个块。因此,每次插入的总平摊成本为 O(log^2 n)。删除操作有对称的分析。

这个 O(log^2 n) 的平摊成本实际上是最优的,即使只支持地址比较的“前序查询”,在仅使用线性空间且希望插入和删除达到常数时间的情况下,也无法超越这个界限。

权衡与变体

通过允许使用比线性更多的空间,可以在插入/删除时间和扫描时间之间进行权衡。例如,如果只关心“前序查询”的标签维护,使用替罪羊树并利用搜索路径作为比特串标签可能是更合适的选择。

伸展树

接下来,我们讨论另一种平衡二叉搜索树数据结构——伸展树。它没有良好的最坏情况时间保证,但在平摊时间上具有非常好的保证。

伸展树由 Daniel Sleator 和 Robert Tarjan 在1983年左右提出。其核心思想是:每次访问(查找、插入、删除)一个节点后,通过一系列旋转将该节点移动到树的根节点,从而在局部调整树的结构,使其在未来对相同或附近节点的访问更快。

从“移至前端”启发法到伸展

最初,他们尝试了类似链表“移至前端”启发法的策略:每次查找一个节点后,通过单次旋转将其移动到根节点。然而,这在最坏情况下会导致 Θ(n) 的平摊搜索时间(例如,在一条链上反复查找最深节点)。

改进的策略是进行双旋转。具体分为两种情况:

  1. 之字形:当节点 x、其父节点 p 和祖父节点 g 不处于同一条直线上时(例如,xp 的右子节点,而 pg 的左子节点)。首先旋转 xp,然后再旋转 x 和新的父节点。
  2. 一字形:当节点 x、其父节点 p 和祖父节点 g 处于同一条直线上时(例如,三者都是左子节点)。首先旋转 pg,然后再旋转 xp

如果节点离根只有一步,则执行一次单旋转。通过这一系列操作将节点 x 移动到根的过程称为“伸展”。

伸展操作的直观效果

伸展操作的效果是压缩搜索路径。经过伸展后,原搜索路径上的节点深度大致减半(加减一个常数)。虽然某些不在路径上的节点深度可能略有增加,但总体而言,树的结构变得更加平衡。这种“昂贵的操作能显著改善数据结构状态”的特性是良好平摊性能的关键。

基本操作实现

  • 查找:找到节点 x 后,立即对其进行伸展。如果查找失败,则对查找过程中访问的最后一个节点(即前驱或后继)进行伸展。
  • 插入:首先像在普通BST中一样插入新节点 x,然后对 x 进行伸展。
  • 删除
    1. 对要删除的节点 x 进行伸展,使其成为根。
    2. 删除根节点 x,得到左子树 T_L 和右子树 T_R
    3. T_L 中找到最大节点(即 x 的前驱)w,对 w 进行伸展,使其成为 T_L 的根(此时 w 无右子树)。
    4. T_R 作为 w 的右子树连接起来。

所有操作的成本都与从根到目标节点的路径长度加上伸展该节点的成本成正比,因此分析的关键在于理解单次伸展的平摊成本。

平摊分析:势能法

我们使用势能法进行分析。定义:

  • 节点 v大小 size(v):以 v 为根的子树中的节点总数。
  • 节点 v rank(v)log₂(size(v))。可以直观理解为该子树理想平衡时的深度。
  • T势能 Φ(T):所有节点 vrank(v) 之和。

势能总是在 Θ(n)(完美平衡树)和 Θ(n log n)(一条链)之间。

Sleator 和 Tarjan 证明的访问引理指出:

  • 在节点 x 处的一次双旋转的平摊成本最多为 1 + 3 * rank'(x) - 3 * rank(x),其中 rank'(x)rank(x) 分别是旋转后和旋转前 x 的秩。
  • 在节点 x 处的一次单旋转的平摊成本最多为 3 * rank'(x) - 3 * rank(x)

由于在一次伸展中,除了最后一步可能是单旋转外,其余都是双旋转,且中间步骤的秩变化会抵消,因此将节点 x 伸展到根的总平摊成本最多为 1 + 3 * rank(root) - 3 * rank(x)。伸展后 x 成为根,rank(root) = log₂(n),所以平摊成本为 O(log n)

伸展树的魔力:静态最优性与其他性质

访问引理的证明可以推广到节点带权的情况。我们为每个节点 x 分配一个正权重 w(x),并重新定义 size(v) 为子树中节点的权重和,rank(v) = log₂(size(v))。访问引理依然成立。

通过巧妙设置权重,我们可以证明伸展树自动具备一些最优性:

  • 静态最优性:假设每个节点 x 被访问的频率为 t(x)。令所有权重 w(x) = t(x)。那么访问节点 x 的平摊成本为 O(log(W / w(x))),其中 W 是所有权重之和。这正是以频率 t(x) 构建的最优静态二叉搜索树所期望的搜索成本(信息论下界)。伸展树在不预先知道频率分布的情况下,自动趋近于此最优结构。
  • 静态手指性质:如果始终从某个固定“手指”节点开始搜索,则搜索到节点 x 的平摊成本为 O(log(distance(finger, x))),其中距离是按关键字排序的秩差。

这些性质表明,伸展树不仅能自动平衡,还能自动适应不同的访问模式,达到近乎最优的性能。

动态最优性猜想

一个尚未解决的重要开放问题是动态最优性猜想:伸展树在平摊意义上是否在常数因子内逼近最优的离线二叉搜索树算法?即,即使对手知道所有未来的操作序列并可以离线最优地调整树,伸展树的总成本是否也在最优成本的常数倍之内?这是数据结构领域一个著名的难题。

总结

本节课中我们一起学习了两种重要的数据结构。紧凑内存数组展示了如何通过局部重建和层次化密度阈值,在连续数组中高效维护动态序列,达到 O(log² n) 的平摊操作时间,并且这个界是最优的。伸展树则展示了一种巧妙的自调整二叉搜索树,通过将访问节点移动到根部的伸展操作,在平摊分析下获得了 O(log n) 的性能,并且自动具备静态最优性等令人惊叹的特性。最后,我们提到了动态最优性猜想这一未解决的挑战。

006:动态最优性与Tango/多伸展树

在本节课中,我们将学习动态最优性的概念,并探讨Tango树和多伸展树这两种数据结构。它们的目标是接近动态最优二叉搜索树的性能,即其操作总时间与一个能预知未来查询序列的最优二叉搜索树相比,只差一个对数对数的因子。


课程管理与回顾

首先,感谢大家的耐心等待。

关于课程管理,有几点需要宣布。作业零的解答已经发布。是否会布置作业一,取决于我批改作业零的速度。目前来看,布置作业一的概率大约是50%。我们拭目以待。

此外,学期末的安排有所调整。原本安排在期末考试周的项目展示,由于我将出国,现改为在最后三节常规课上进行。因此,项目提案的截止日期也相应推迟了一周。论文检索任务的截止日期没有改变,相关说明和论文列表将在本周末发布,大家仍有大约三周时间完成。

上次课程我们开始讨论伸展树。这是一种自调整二叉搜索树的例子。它不像其他树那样在插入和删除时通过显式规则来保持平衡,而是在执行搜索时动态调整树的形状。

伸展树具有许多优良特性。我们已看到,任何操作都能达到对数级的摊还时间。它还满足静态最优性,这意味着其运行时间总是类似于 log(总搜索次数 / 对特定目标X的搜索次数),这在信息论上是最优的。

此外,还有静态手指界:假设搜索键是1到n的整数,并设定一个静态手指F,那么搜索目标X的运行时间总是 log(|X - F|)。这是空间局部性的体现。

以及工作集界:运行时间与自上次搜索X以来,所搜索的不同键的数量对数成正比。这体现了时间局部性,即搜索越频繁的项目,其搜索速度越快。

甚至有一个动态手指猜想:第i次搜索的成本,与它在排序空间中与前一次搜索目标的距离成正比。

然后,我们遇到了动态最优性猜想:它声称伸展树(或任何常数竞争的动态二叉搜索树)的性能,与一个能预知整个查询序列的最优二叉搜索树相比,只差一个常数因子。目前,无论是伸展树还是其他任何结构是否满足常数竞争,都是未解决的问题。


动态二叉搜索树模型

为了分析动态最优性,我们需要明确比较的规则和成本模型。Wilber在这一领域做出了奠基性工作。

我们首先定义什么是动态二叉搜索树。模型一:数据结构必须是一棵二叉搜索树,允许在搜索过程中进行任意旋转。时间成本定义为搜索路径长度加上旋转次数。在这个模型中,我们假设除旋转外的一切操作都是免费的,因此这个时间实际上是运行时间的一个下界。

模型二:数据结构是一棵二叉搜索树,并有一个指针(手指)。允许的操作包括:将指针移动到左子节点、右子节点、父节点,或者在指针所在节点进行旋转(使其与父节点交换位置)。搜索结束时,指针必须触及目标X。时间成本等于这些操作的总数。这个模型可以描述红黑树、AVL树、树堆、替罪羊树和伸展树等。

模型三:在搜索过程中,选择一个包含根节点和搜索目标X的连通子树S,然后可以任意重构S(即用同一组键的任何其他二叉搜索树替换它),并将S外的部分正确连接。时间成本与S中的节点数成正比。

Wilber证明了这三个模型在渐进意义下是等价的,即它们定义的时间成本彼此只差一个常数因子。这为我们形式化动态最优性猜想提供了基础:伸展树在模型二(或等价模型)下的运行时间,与能预知未来的最优二叉搜索树相比,是常数倍的。


一个下界论证:从排列到树变换

在深入讨论Tango树之前,我们先简要回顾作业零中的一个下界论证,以理解为何某些问题需要 Ω(n log n) 的时间。

考虑一个更一般的动态树模型,允许操作包括:左移、右移、上移、向上旋转、交换子节点、报告根。目标是处理一个排列X,使得在处理完第i个元素后,通过一系列操作让该元素位于根节点。

这个过程的“轨迹”是一个由这六个操作符号组成的字符串。给定初始树,执行这个轨迹就能还原出排列X。因此,轨迹编码了排列。由于存在 n! 个不同的排列,而长度为L的字符串最多只能编码 6^L 个排列,所以对于大多数排列X,轨迹长度L必须满足 6^L ≥ n!,即 L = Ω(n log n)

另一方面,对于任何排列X,都存在一棵特定的树(例如一个右链),使得访问该序列只需 O(n) 次操作。这意味着,从任意二叉搜索树变换到这棵特定树,在最坏情况下需要 Ω(n log n) 次操作。这个论证说明了在允许交换子节点的模型中,达到某些排列的“距离”是很大的。


Wilber的互穿下界

回到动态二叉搜索树的最优性分析,Wilber提出了一个用于下界分析的关键概念——互穿界。

我们固定一棵任意的参考二叉搜索树P。对于树P中的每个节点y,定义其左区域 L(y)(包含y及其在P中左子树的所有节点)和右区域 R(y)(包含y及其在P中右子树的所有节点)。

给定一个搜索序列X,我们提取出所有属于 L(y) ∪ R(y) 的搜索项,构成子序列 X^y。节点y的互穿值 IB_y(X) 定义为在 X^y 中,搜索项在左区域和右区域之间交替的次数。

整个序列X的互穿界 IB(X) 是所有节点y的互穿值之和。Wilber证明,任何动态二叉搜索树执行搜索序列X所需的时间,至少是 Ω(IB(X))。直观上,每当参考树中某个节点的偏好区域发生切换时,在实际的搜索树中都必须触及某个特定的节点,而这些被触及的节点对于不同的y是不同的。


Tango树与多伸展树的设计

现在,我们来看如何设计一个数据结构,使其运行时间与互穿界 IB(X) 成正比,从而达到 O(log log n * OPT) 的竞争比。这就是Tango树和多伸展树的核心思想。

首先,我们固定一棵完全平衡的二叉搜索树作为参考树P。P中的每个节点维护一个“偏好孩子”指针,总是指向最近一次搜索所在子树的方向。这些偏好指针将P分解成若干条“偏好路径”。

Tango树的关键想法是:用一棵平衡二叉搜索树来表示每一条偏好路径。这样,整个Tango树就是由许多棵代表不同偏好路径的小二叉搜索树,通过指针连接而成的一棵大的二叉搜索树。

当执行一次搜索时,我们从根开始,沿着实际树(也是参考树P)的搜索路径下行。这条路径可能会穿越多条偏好路径。每次我们从一个偏好路径“离开”,通过一条非偏好边进入另一个偏好路径时,就对应参考树P中一个偏好孩子指针的改变(即一次“交替”)。

在Tango树中,每次进入一条新的偏好路径,我们需要在代表该路径的平衡BST中进行一次搜索(耗时 O(log |路径长度|),而路径长度不超过树高 O(log n),所以是 O(log log n))。同时,搜索结束后,我们需要更新偏好指针,这可能涉及将一条偏好路径分裂成两条,或将两条路径合并。平衡BST(如红黑树或伸展树)可以高效地支持分裂与合并操作。

因此,一次搜索的总时间是 O((交替次数) * log log n)。而“交替次数”正好对应于Wilber互穿界中,本次搜索所贡献的部分。所以,Tango树的总运行时间是 O(IB(X) * log log n),即 O(log log n * OPT)

多伸展树与Tango树在精神上完全相同,唯一的区别在于它使用伸展树作为每条偏好路径的内部平衡BST。由于伸展树本身具有良好的摊还性能,多伸展树在某些方面分析起来更优,并能处理插入和删除操作。


总结

本节课我们一起探讨了动态最优性的概念。我们首先了解了动态二叉搜索树的几种等价模型,为分析奠定了基础。接着,我们通过一个排列编码的论证,理解了某些树变换问题需要 Ω(n log n) 时间。然后,我们介绍了Wilber的互穿下界,它为最优动态BST的性能提供了一个有力的下界工具。

最后,我们学习了Tango树和多伸展树的设计精髓:通过固定一棵参考树来跟踪“偏好路径”,并用平衡BST组织这些路径,从而将每次搜索的成本与参考树中偏好改变的次数(即互穿界)绑定,实现了与最优解相比仅差 O(log log n) 因子的竞争性能。这为我们设计近乎动态最优的数据结构提供了清晰的蓝图。

007:Tango树、多展树与二叉搜索树的几何视角

在本节课中,我们将学习两种重要的动态二叉搜索树结构:Tango树及其变体多展树,并探讨二叉搜索树性能分析的一个强大几何视角。我们将了解它们如何实现接近最优的搜索性能,以及如何通过几何模型来理解和分析二叉搜索树算法。

课程概述

首先,我们有一些课程管理事项需要宣布。纸面追踪作业已经发布。这项作业的目的是让大家熟悉阅读数据结构领域的研究文献,追踪参考文献以帮助理解,并能够向非专业受众清晰地阐述论文内容。

作业的基本结构是:首先找到一篇论文(称为论文一),撰写一篇简短的评述,描述论文的主要内容、主要贡献、使用的核心技术工具以及你在阅读中感到困惑的地方。然后,选择第二篇论文来帮助你解决对论文一的困惑,并解释它与论文一的关系以及它如何澄清了你的疑问。如果仍有困惑,可能需要选择第三篇论文。整个文档应控制在五页左右。

作业中提供了一个包含42篇2023年及之后发表的论文建议列表作为起点,但这并非强制要求。你也可以从其他来源选择论文。

Tango树与多展树

上一节我们介绍了课程作业。本节中,我们来看看一种称为Tango树的数据结构及其变体。这些结构的目标是构建一个具有竞争性的动态二叉搜索树。

Tango树及其变体(如多展树、链展树、Skip-Splay树)都能达到 O(log log n) 的竞争比。这意味着,对于存储在树中的节点访问序列,Tango树所花费的时间最多是最优自调整二叉搜索树(即使它能预知未来)所花费时间的 log log n 倍。

Tango树的定义使用了两种树:

  1. 参考树 P:这是一棵完美平衡的静态二叉搜索树,包含所有键值(例如1到n)。它用于定义“偏好”关系。
  2. 实际的Tango树:这是动态维护的数据结构。

参考树P中的每个节点都有一个偏好子节点指针。其语义是:如果最近一次访问节点v的子树(或v本身)位于其左子树,则其偏好子节点为左孩子;否则为右孩子。这些偏好指针将P的顶点分解成若干条偏好路径,每条路径从一个节点开始,一直延伸到叶子节点。

每当访问一个节点x时,从P的根节点到x的路径就变为新的偏好路径。这可能会改变路径上一些节点的偏好子节点,从而改变偏好路径的分解方式。

Tango树的核心思想是:将每一条偏好路径用一个平衡的二叉搜索树(如红黑树)来存储。因此,Tango树本身是由多个代表偏好路径的平衡二叉搜索树通过指针连接而成的全局二叉搜索树。

当执行搜索时,我们沿着Tango树向下查找。每次从一个偏好路径树“跳转”到另一个偏好路径树时(这对应于在参考树P中遇到一个非偏好边),我们需要对当前的偏好路径树进行分割连接操作,以反映偏好路径的变化。

由于每条偏好路径的长度最多为 O(log n)(因为P是完美平衡的),所以代表它的平衡树大小也是 O(log n)。在这些平衡树上进行搜索、分割、连接操作的时间复杂度为 O(log (log n))

如果一次搜索在参考树P中经过了 k 条不同的偏好路径(即遇到了 k-1 次非偏好边),那么总时间就是 O(k log log n)。可以证明,这个 k 的总和正好等于Wilber的交错界,而交错界是任何二叉搜索树算法时间代价的一个下界。因此,Tango树的总时间是 O(opt * log log n),其中 opt 是最优算法的代价。

原始的Tango树使用红黑树,每次搜索可能需要对 O(log n) 个路径树进行操作,因此单次搜索的最坏情况时间是 O(log n log log n)。而多展树使用伸展树作为组件,利用伸展树的摊还分析特性,可以证明其单次搜索的摊还时间仅为 O(log n),从而实现了 O(log log n) 的竞争比。这是目前已知最接近常数竞争比的动态二叉搜索树结构。

二叉搜索树的几何视角

上一节我们介绍了Tango树如何实现对数对数级别的竞争性。本节中,我们转向一个分析二叉搜索树性能的强大工具:几何视角。

这个视角由Demaine、Harmon、Iacono、Pătraşcu等人提出。给定一个包含键值1到n的二叉搜索树和一个访问序列 X = (x1, x2, ..., xm),我们可以将每次访问定义为一个二维平面上的点:横坐标为被访问的键值,纵坐标为访问发生的时间。

现在考虑任何自调整二叉搜索树算法。当它在时刻 i 访问键值 xi 时,它会“触及”从树根到 xi 的搜索路径上的所有节点。我们可以为这些被触及的节点也在平面上创建点(横坐标为节点键值,纵坐标为时间 i)。这样就得到了一个比原始访问序列点集更大的点集 T(X)

研究者发现,点集 T(X) 满足一个称为树状满足的性质。其定义如下:
对于点集中任意两个横纵坐标均不同的点,它们确定了一个矩形。这个矩形内部或边界上必须包含该点集中的另一个点。

反之亦然:任何包含原始访问点集X的树状满足点集,都对应着某个自调整二叉搜索树算法对于序列X的触及序列

因此,我们得到了一个关键定理:

对于给定访问序列X,最优动态二叉搜索树的代价(总触及节点数),等于包含X的最小树状满足超集的大小

这为分析二叉搜索树的最优性能提供了一个清晰的几何刻画。

基于这个模型,一个自然的想法是使用贪心算法在线构建树状满足超集:按时间顺序处理访问,每次在必要时添加最少的点(通常是在新访问点与旧点构成的“违规”矩形的某个角落添加点)来保证当前所有点的树状满足性。

这个几何贪心算法可以(在付出常数倍时间开销的前提下)转化为一个在线的二叉搜索树算法,称为 Greedy Future。这个算法在道德上类似于根据节点下一次被访问的时间作为优先级,将搜索路径重构成一个Treap(树堆),以最小化未来访问的成本。

曾经有猜想认为Greedy Future算法是最优的,其代价最多比最优解多一个线性项(即 OPT + O(n))。然而,近年来的研究(如Paper Chase列表中的论文13)证明这个猜想是错误的。他们表明Greedy Future的代价至少是 OPT + Ω(n log log n),并且其竞争比至少为2。更奇怪的是,他们发现存在一些序列,对其运行Greedy Future的代价,比对它的逆序序列运行Greedy Future的代价高两倍;有时执行更多的搜索反而让Greedy Future更快。这些行为表明Greedy Future并非我们寻找的“终极”最优或近乎最优算法。

尽管如此,这个几何框架仍然是理解和设计二叉搜索树算法的强大工具,而最初的伸展树猜想(即伸展树是常数竞争的,代价为 OPT + O(n) )仍然悬而未决。

总结

本节课我们一起学习了:

  1. Tango树和多展树:通过将静态完美平衡树中的偏好路径用平衡BST表示,并在线更新这些路径,实现了 O(log log n) 竞争比的动态二叉搜索树。
  2. 二叉搜索树的几何视角:将访问序列和树的操作映射为二维点集,并引入了树状满足性这一关键性质。该视角证明了最优BST的代价等价于寻找包含访问点的最小树状满足超集,并由此引出了Greedy Future等在线算法。虽然Greedy Future被证明不是最优的,但这个几何模型为分析和设计BST算法提供了深刻的见解。

这些内容展示了现代数据结构研究如何将组合、几何和摊还分析相结合,以追求算法的理论极限。

008:欧拉回路树与ST树

在本节课中,我们将要学习如何利用平衡二叉搜索树来维护一个动态变化的森林(即树的集合)。我们将重点介绍两种核心数据结构:用于处理子树操作的欧拉回路树,以及用于处理路径操作的ST树。这两种结构都能在对数级的摊销时间内支持对树的动态修改和查询。


欧拉回路树

上一节我们介绍了动态森林的基本操作需求,本节中我们来看看如何利用欧拉回路的概念来支持子树操作。

核心思想:将树转化为序列

欧拉回路树的核心思想是,将待表示的树 T(我们称之为表示树)通过一次欧拉遍历转化为一个序列。想象你从树中任意一点出发,左手始终触摸着树,沿着边走,每经过一个节点就记录下来。这样你会得到一个访问序列,其中每个节点会出现多次(次数等于其度数)。为了便于处理,我们将这个环形序列在某处断开,形成一个线性序列。

这个线性化的欧拉序列有一个关键性质:表示树中的任意子树,对应欧拉序列中的一个(或至多两个)连续区间。例如,当你进入一个子树时,你会遍历完整个子树再离开,因此在序列中,该子树的所有节点访问记录是连续的。

数据结构:平衡二叉搜索树

我们并不直接存储表示树 T 的图结构。相反,我们构建一棵平衡二叉搜索树(例如伸展树或红黑树),其中序遍历顺序恰好就是这个欧拉序列。树中节点的键值是其在序列中的位置。

以下是构建欧拉回路树的关键步骤:

  1. 对表示树 T 进行欧拉遍历,得到序列 S
  2. S 中元素的顺序作为键,构建一棵平衡二叉搜索树 ET(T)。对 ET(T) 进行中序遍历,即可还原序列 S

支持操作:子树查询与更新

通过上述表示,子树操作被转化为对序列 ET(T)区间操作

子树查询

假设我们需要查询以边 (U, V) 指向的、包含 V 的子树中节点的权重和(或最大值等)。这对应于在欧拉序列中找到代表该子树的区间 [l, r]

实现方式

  • 如果我们使用静态的、完全平衡的二叉搜索树,可以将查询区间分解为 O(log n)规范区间(即树中某些子树对应的区间)。我们只需合并这些子树根节点上预计算好的聚合信息(如和、最大值)。
  • 如果我们使用伸展树,过程更直接:我们将区间端点 lr 对应的节点伸展到根附近。操作完成后,与这两个节点相关的子树结构会包含整个查询区间,我们可以直接从相关节点获取聚合信息。

为了支持查询,每个树节点需要维护其子树(在二叉搜索树中的子树)的聚合信息,例如:

  • sum: 子树中所有权重之和。
  • min: 子树中所有权重的最小值。
  • size: 子树中节点个数(用于支持区间加法等更新)。

这些信息可以在树旋转时用常数时间更新。

子树更新

当我们需要对子树中所有节点的权重进行批量更新时(例如全部加 7),显式地修改每个节点需要 O(n) 时间,不可接受。

解决方案是惰性更新

  • 每个树节点额外维护一个 delta 值,表示“应已加到其所有后代节点但尚未实际执行的增量”。
  • 当需要对一个区间执行“全部加 7”操作时,我们只需在代表该区间的 O(log n) 个规范区间的根节点上,将其 delta 值增加 7
  • 在后续任何需要访问某个节点 v 的操作(如查询、伸展)之前,我们必须先将 v 节点上累积的 delta 下推给它的两个子节点,并更新 v 自身的聚合信息。这保证了任何时候我们读取到的信息都是正确的。

通过这种方式,区间更新区间查询的时间复杂度相同,均为 O(log n)(摊销时间,若使用伸展树)。

结构操作:连接与断开

Link(连接)和 Cut(断开)操作可以通过对欧拉回路序列进行拆分和拼接来实现。

  • 断开 Cut(U, V):在欧拉序列中,边 (U, V) 对应两段相邻的访问记录 ..., U, V, ......, V, U, ...。断开操作相当于将环形序列在 U, VV, U 之间切开,并重新连接形成两个独立的序列。这可以通过几次二叉搜索树的拆分拼接操作完成。
  • 连接 Link(U, V):这是断开操作的逆过程。假设 UV 属于不同的树,连接操作将两个欧拉序列合并为一个。这同样可以通过几次二叉搜索树的拆分和拼接操作完成。

由于伸展树能高效支持拆分和拼接(每次操作摊销 O(log n) 时间),因此 LinkCut 操作也能在 O(log n) 摊销时间内完成。


ST树(用于路径操作)

上一节我们学习了处理子树操作的欧拉回路树,本节中我们来看看处理路径操作的ST树。路径操作关心的是树上两个节点之间唯一路径上的信息。

核心思想:偏好路径分解

ST树的核心思想是将表示树 T(现在假设已指定一个根节点)动态地分解为若干条偏好路径

  • 每个非叶子节点可以指定其一个子节点为偏好孩子。连接节点与其偏好孩子的边称为偏好边
  • 连续的偏好边形成一条偏好路径。这样,整棵树被划分成若干条从某个节点向下延伸的路径。
  • “偏好”的定义是动态的:最近被访问过的节点所在的子节点,成为其父节点的偏好孩子。一个特殊的操作 Access(v) 会将从根到节点 v 的整条路径变为一条偏好路径。

数据结构:路径的集合

ST树并不显式存储整个树形结构,而是:

  • 每一条偏好路径维护一棵平衡二叉搜索树(通常也用伸展树),树中节点按路径从上到下的顺序存储。
  • 每条偏好路径对应的二叉搜索树的根节点,存储一个路径父指针,指向该路径最顶端节点在表示树 T 中的父节点。通过这个指针,所有路径树被连接成一个整体。

关键操作:Access

几乎所有其他操作(路径查询、路径更新、换根等)都建立在 Access(v) 操作之上。Access(v) 的目标是:将从根到 v 的路径变为一条连续的偏好路径

实现过程简述

  1. 从节点 v 开始,沿着路径父指针向上走,直到根路径。
  2. 在向上走的过程中,我们需要不断改变边的偏好状态:
    • 取消偏好:当需要将当前路径与上方路径合并时,需要先断开当前路径顶端的偏好边。这对应在伸展树中进行一次拆分操作。
    • 建立偏好:然后将当前路径连接到上方路径。这对应在伸展树中进行一次拼接操作。
  3. 这些拆分和拼接操作都是在伸展树上进行的,每次耗时摊销 O(log n)

支持操作:路径查询与更新

执行 Access(v) 后,从根到 v 的路径已经成为一条偏好路径,并存储在一棵伸展树中。此时:

  • 路径查询(如求路径上节点的权重和):我们可以像在欧拉回路树中处理区间查询一样,在这棵代表路径的伸展树上进行查询。
  • 路径更新(如给路径上所有节点加一个值):同样,我们可以使用惰性更新技术在这棵伸展树上进行区间更新。

为了支持这些操作,伸展树的每个节点也需要维护聚合信息(如 sum, min, size)以及惰性标记(如 delta)。

时间复杂度分析

一次 Access(v) 操作可能会改变 O(k) 条边的偏好状态,其中 k 是路径上原本非偏好的边数。每次改变需要 O(log n) 的伸展树操作时间。通过精妙的摊还分析(例如使用势能法),可以证明任意连续 m 次操作的总时间是 O((m + n) log n),因此单次操作的摊销时间复杂度是 O(log n)


总结

本节课中我们一起学习了两种强大的动态树数据结构:

  1. 欧拉回路树:通过将树的欧拉遍历序列存储在平衡二叉搜索树中,高效支持子树的查询、更新以及树的连接断开操作。核心技巧是利用序列的区间特性以及惰性更新。
  2. ST树:通过动态维护偏好路径,并将每条路径存储在平衡二叉搜索树中,高效支持路径的查询、更新以及核心的 Access 操作。其效率依赖于伸展树的灵活性和精妙的摊还分析。

最终,存在一种称为自调整拓扑树的统一数据结构,它融合了二者的思想,能够同时支持所有类型的操作(子树、路径、结构修改),且每个操作均在 O(log n) 摊销时间内完成。欧拉回路树和ST树是理解这个统一结构的重要基础。

009:ST-trees

在本节课中,我们将学习一种名为ST-trees(或称Link-Cut Trees)的动态森林数据结构。这种数据结构能够高效地维护一个由多棵不相交的树组成的森林,并支持对树的结构(如添加或删除边)以及对树上的路径进行查询和更新操作。

回顾与概述

上一节我们介绍了动态森林的基本概念和欧拉环游树(Euler Tour Trees),它能够以对数时间处理子树操作和结构操作。本节中,我们来看看另一种强大的数据结构——ST-trees,它专门用于处理路径操作

ST-trees同样能在对数分摊时间内完成结构操作(如cutlink)和路径操作(如查询路径最小值、对路径上所有节点增加值等)。其核心思想与欧拉环游树不同,它并不依赖于全局的节点顺序,而是通过维护一种称为“偏好路径”的分解结构来实现。

ST-trees 的核心机制

偏好路径与路径树

首先,想象我们为森林中的每棵树指定一个根节点(稍后会讨论如何改变根)。每个节点都有一个偏好子节点指针,它可以指向其某个子节点,也可以为空。这个指针指向该节点子树中最近被访问过的节点所在的子节点方向。

access(v)是一个核心抽象操作,其高级别的作用是改变哪些子节点是“偏好”的。具体来说,在访问节点v之后,从根节点到v的路径上的所有边都会变为“偏好”边,而离开这条路径的所有边(即连接到路径上节点的其他子树的边)都会变为“非偏好”边。

在数据结构的内部表示中,每一条偏好路径都被存储在一个路径树中。这是一个二叉搜索树(通常使用伸展树splay tree以简化分析),其中的节点按键的顺序存储,这个“键”就是节点在路径中的深度。因此,对路径树进行中序遍历,就能按深度递增的顺序得到路径上的所有节点。

数据结构的连接

不同的偏好路径通过路径父指针连接起来。对于一条偏好路径,其最顶端的节点(深度最浅)的父节点并不直接存储在该节点的子指针中,而是通过一个路径父指针,从存储该顶端节点的路径树的根节点指向代表其父节点的节点。

因此,整个ST-tree可以看作是由许多伸展树(每个代表一条偏好路径)通过虚线箭头般的路径父指针连接而成的集合。

access 操作的过程

当我们执行access(v)时,算法大致流程如下:

  1. 从代表节点v的伸展树节点开始。
  2. 将该节点伸展到其所在路径树的根。
  3. 找到从该路径树根出发的路径父指针(指向其父节点所在的另一条偏好路径)。
  4. 我们需要将这条“虚线”连接变为“实线”连接,即进行树的连接(join)操作。这通常涉及对父节点所在路径树进行分裂(split),然后将两条路径树合并。
  5. 合并后,继续向上重复这个过程(伸展、连接),直到抵达整棵ST-tree的根。

粗略分析,access(v)的时间复杂度与从根到v的路径上初始非偏好边的数量乘以O(log n)成正比,因为每处理一条这样的边(即连接两条偏好路径),可能需要进行一次伸展树的分裂与合并操作。

分摊时间复杂度分析

然而,通过巧妙的分摊分析,我们可以证明access操作的分摊时间复杂度实际上是O(log n)。这依赖于两个技巧。

技巧一:利用伸展树引理

首先,我们利用伸展树的摊还引理。该引理指出,伸展一个节点的摊还代价最多为 1 + 3*(r'(x) - r(x)),其中r(x) = log(weight(x))weight(x)是我们为分析而定义的节点权重。

我们如下定义节点x的权重weight(x):它是所有通过路径父指针最终连接到x的路径树中的节点总数(包括x自身)。这意味着,一个节点的权重包含了它自身以及所有“挂”在它下面的整条偏好路径的大小。

access(v)过程中,节点v被反复伸展和连接。由于权重的定义方式,在连接操作前后,相关节点的秩(rank) 变化会相互抵消,最终摊还代价主要取决于v最终秩的增加。而v最终成为ST-tree的根,其权重为整棵树的大小n,秩为log n。因此,来自伸展操作的摊还代价总计为O(log n)

技巧二:轻重路径分解

第二个技巧用于证明在摊还意义上,每次access操作需要改变偏好状态的边数仅为常数(实际上是O(log n),但结合之前的O(log n)因子,总时间仍为O(log n))。

我们引入一个独立于“偏好路径”的概念:轻重路径分解。在原始的被表示的树中,对于从父节点u到子节点v的边,如果以v为根的子树大小严格大于u为根的子树大小的一半,则称这条边为重边,否则为轻边。每个节点最多有一个重子节点。

一个关键性质是:在任何从根到叶子的路径上,最多有 log n 条轻边。因为每经过一条轻边,其子树大小至少减半。

现在,我们将边的状态分为两类:重/轻(由树结构决定,除非进行link/cut,否则不变)和 偏好/非偏好(由access操作动态改变)。我们关心的是重偏好边的数量变化。

分析表明:

  • 一次access操作,最多使O(log n)条边从非偏好变为偏好(因为路径上最多有log n条轻边,而重边变为偏好的次数可以分摊到后续它们变为非偏好的操作上)。
  • 一次cut操作,在摊还意义上,最多改变O(log n)条边的重/轻状态。
  • 一次link操作,在摊还意义上,不增加额外的重偏好边变化代价。

因此,综合来看,每种操作(access, cut, link)的摊还时间复杂度都是 O(log n)

处理无根树与路径查询

ST-trees内部使用有根树,但我们可以处理无根树的路径查询。基本流程如下,假设要查询节点uv之间的路径:

  1. access(u):这将使从原根到u的路径变为偏好路径。
  2. make_root(u):将u设为新的根。这通过反转从原根到u的偏好路径来实现。在路径树中,这可以通过设置一个“反转”懒惰标记高效完成,实际反转子树的操作在需要时才下推。
  3. access(v):现在,从新根uv的路径将成为唯一的偏好路径,并且通过最后的伸展操作,v会位于其路径树的根附近。此时,从uv的整条路径就集中在了一个伸展树结构中。

完成上述步骤后,路径查询(如求和、求最小值)就变成了在这个路径树根节点上查询其维护的汇总信息。路径更新(如所有节点加一个值)也可以通过懒惰标记在根节点上记录,并在后续操作中下推。

其他操作:最近公共祖先

ST-trees也可以用来计算最近公共祖先。对于有根树,给定节点uv,可以通过以下步骤找到其LCA:

  1. access(u)
  2. make_root(u) (如果需要,取决于初始根)
  3. access(v)
    在第二次access(v)之后,LCA就是v所在路径树中,深度最小的那个节点(即第一次access后发生偏好子节点变更的那个节点的父节点)。这需要对数据结构内部有稍深入的了解,但实现起来是直接的。

总结

本节课我们一起学习了ST-trees(Link-Cut Trees)这一强大的动态森林数据结构。我们了解了它如何通过维护“偏好路径”并使用伸展树作为基础,来高效支持:

  • 结构更新link(连接两棵树)、cut(分割树)。
  • 路径查询与更新:查询路径上节点的聚合信息(如和、最小值),或对路径上所有节点进行批量更新。
  • 辅助操作find_root(查找根)、make_root(换根),以及计算最近公共祖先。

所有操作都能在 O(log n) 的分摊时间复杂度 内完成。下一节,我们将探讨ST-trees在一些经典问题中的应用,例如平面图中的最短路径问题。

010:多源最短路径

在本节课中,我们将学习如何利用动态森林数据结构,高效地解决平面图上的多源最短路径问题。我们将从回顾动态森林和平面图的基本概念开始,逐步构建出解决该问题的完整算法框架。

动态森林数据结构回顾

上一节我们介绍了用于维护动态森林的几种数据结构。本节中,我们来看看这些结构如何应用于具体问题。

动态森林数据结构的核心目标是维护一个由多棵树组成的集合,并支持以下操作:

  • 连接两棵树(通过添加边)。
  • 将一棵树分割成更小的树(通过删除边)。
  • 查询和更新子树或路径上的信息。

我们讨论过两种主要的数据结构:

  1. 基于欧拉环游的数据结构,擅长处理子树操作。
  2. 基于路径分解(使用平衡二叉搜索树,如伸展树)的数据结构,擅长处理路径操作。

目前,该领域最先进的结构是自调整Top树。它能够同时高效地处理子树和路径的查询与更新操作。

自调整Top树的核心思想是通过两种操作反复简化树的结构:

  • Rake(耙)操作:合并一个连接到度为1的顶点的边。
  • Compress(压缩)操作:消去一个度为2的顶点。

通过记录这些简化操作的历史,形成一个层次化的树状数据结构(即Top树)。本质上,它内部将用于处理子树操作的“Rake树”和处理路径操作的“Compress树”交织在一起。所有操作均能在 O(log n) 的摊还时间内完成。

需要注意的是,这些数据结构较为复杂,在实践中,只有当树的规模达到数万顶点时,其对数级优势才能抵消较大的常数开销。但从理论角度看,这是理想的结果。

平面图基础与多源最短路径问题

现在,我们将理论应用于一个具体问题:平面图上的多源最短路径问题。这是由Philip Klein在2005年提出的。

平面图与对偶图

首先,我们需要了解平面图的一些基本性质。

一个平面图是指可以画在平面上,使得其边仅在端点处相交的图。这样的画法将平面分割成若干区域,称为,其中有一个无界的外面

对于任一平面嵌入,可以定义其对偶图 G*

  • 顶点:原图G的每个面对应G*中的一个顶点。
  • :若原图G中两个面共享一条边,则在G*中对应的两个顶点之间连一条边。

对偶图本身也是平面图,并且对偶的对偶拓扑等价于原图。

在数据结构中,平面图通常用旋转系统表示。它不仅记录每个顶点的邻接表,还记录邻接边围绕该顶点的循环顺序。通过结合“下一个边”和“反向边”两个置换,我们可以从原图的表示中高效地推导出对偶图的结构,而无需额外存储。

树-余树分解

平面图一个非常有用且关键的性质是树-余树分解

设T是平面图G的一棵生成树。考虑所有不在T中的边,这些边在对偶图G*中对应的边集C*,恰好构成G*的一棵生成树

这个分解是对称的:你也可以从对偶图的一棵生成树开始,得到原图的一棵生成树。这个性质源于拓扑学中的乔丹曲线定理。

在本算法中,原图的生成树T将是我们的最短路径树。而对偶图的生成树C*将帮助我们高效地定位需要更新的边。

最短路径算法回顾

回顾最短路径算法的通用框架(如Ford提出的松弛操作):

  • 每个顶点v维护一个距离估计值 d[v],初始时源点s为0,其他为无穷大。
  • 一条边 (u, v)紧绷的,如果 d[u] + w(u, v) < d[v]
  • 松弛一条紧绷边 (u, v):设置 d[v] = d[u] + w(u, v),并更新v的前驱指针为u。
  • 算法不断松弛紧绷边,直到没有紧绷边为止。此时,d[v] 即为从s到v的真正最短距离,所有前驱指针构成一棵以s为根的最短路径树

Dijkstra和Bellman-Ford算法都是这一框架的具体调度策略。

多源最短路径问题定义

问题:给定一个平面图G,预处理该图,以支持高效的距离查询:查询从外面上任意顶点u到图中任意顶点v的最短距离。

一种朴素的方法是对外面上的每个顶点运行一次Dijkstra算法。如果外面有O(n)个顶点,总时间将是 O(n² log n)

Klein算法的目标是在O(n log n)时间内完成预处理,并支持O(log n)时间的查询。这看似不可思议,因为可能存在O(n²)对不同的距离。关键在于,算法并不显式存储所有距离,而是通过维护结构变化的历史来支持快速查询。

算法核心思想:连续移动源点

算法的核心洞察是:当源点沿着外面连续移动时,最短路径树的变化是稀疏且结构化的

具体来说,考虑外面上的两个相邻顶点U和V。我们关注将源点从U连续移动到V的过程。在此过程中,我们维护以当前源点S(位于边(U, V)上)为根的最短路径树。

这棵树会自然地被分成两部分:

  • 红色子树:通过U到达的子树。随着S远离U,这部分中所有顶点的距离都在增加
  • 蓝色子树:通过V到达的子树。随着S靠近V,这部分中所有顶点的距离都在减少

所有连接蓝色顶点到红色顶点的边(称为活动边)的松弛量(slack)都在以恒定速率(2倍速)减少。松弛量的定义是:slack(x, y) = d[y] - d[x] - w(x, y),其中x是蓝点,y是红点。

当一条活动边的松弛量减少到0时,它就变得紧绷,需要进行枢轴旋转:将这条边 (x, y) 加入最短路径树,替换掉原来进入y的树边。同时,以y为根的整个子树将从红色变为蓝色

一个关键定理(利用平面图和乔丹曲线定理证明)指出:在将源点沿整个外面移动一圈的过程中,枢轴旋转的总次数是O(n)。这意味着树的变化是稀疏的。

算法流程与数据结构应用

因此,算法流程如下:

  1. 初始化:计算以外面某个顶点为源的最短路径树T及其对偶生成树C*。
  2. 依次处理外面的每条边 (U, V),模拟将源点从U移动到V:
    a. 找到下一个枢轴:在所有活动边(即从蓝点到红点的边)中,找到松弛量最小的那条边 (x, y)。其松弛量决定了源点需要移动的“距离” Δ = slack(x, y) / 2
    b. 更新距离和松弛量
    * 所有红点的距离 (子树更新,在T上操作)。
    * 所有蓝点的距离 (子树更新,在T上操作)。
    * 所有活动边的松弛量 -2Δ(路径更新,在C*上操作)。
    c. 执行枢轴旋转
    * 在T中,连接 (x, y),断开原先进入y的边(先Cut后Link)。
    * 在C*中,进行互补的操作:断开 (x, y) 的对偶边,连接刚被T踢出的边的对偶边。
    d. 重复步骤a-c,直到源点到达V,此时最短路径树已更新为以V为根的树。
  3. 为支持查询,我们需要记录整个过程中每一刻的最短路径树状态。这可以通过持久化数据结构来实现,使得我们能在O(log n)时间内查询历史上任何源点对应的距离。

如何高效找到松弛量最小的活动边?
这正是对偶生成树C*发挥作用的地方。可以证明,所有活动边的对偶边,恰好位于C*中连接两个特定面(即边(U,V)两侧的面)的路径上。因此,“查找最小松弛量活动边”等价于在C*的某条路径上进行最小值查询。这正是动态森林数据结构(如Top树)所擅长的路径查询操作。

总结

本节课中我们一起学习了:

  1. 回顾了动态森林数据结构,特别是能同时处理子树和路径操作的自调整Top树。
  2. 介绍了平面图的基本概念,包括对偶图和关键的树-余树分解性质。
  3. 回顾了最短路径算法的通用松弛框架。
  4. 定义了平面图多源最短路径问题,并阐述了Klein算法的核心思想:通过连续移动源点,并利用最短路径树变化的稀疏性。
  5. 详细说明了算法流程,展示了如何将子树更新(在最短路径树T上)和路径查询/更新(在对偶生成树C*上)完美地结合,通过动态森林数据结构在O(log n)时间内完成每次枢轴旋转。
  6. 由于总旋转次数为O(n),因此总预处理时间为O(n log n),并能支持O(log n)的查询时间。

该算法是动态图算法与计算几何图算法结合的一个优美范例,充分展示了数据结构在优化经典问题中的强大威力。

011:动态连通性算法详解 🧩

在本节课中,我们将学习如何维护一个动态变化的无向图,并高效地回答图中任意两个顶点是否连通的问题。我们将重点介绍一种基于层次化森林和欧拉回路树(Euler Tour Tree)的经典算法,它能以对数平方的均摊时间处理边的插入和删除,并以对数时间回答连通性查询。

算法核心思想与数据结构

上一节我们介绍了动态连通性问题的定义,本节中我们来看看解决该问题的核心数据结构设计。

算法的基本思想是维护一个生成森林 F,它是当前图 G 的一个极大无圈子图。连通性查询可以通过检查两个顶点是否在森林 F 的同一棵树中来快速回答。

为了高效处理森林的更新(尤其是边的删除),算法引入了一个层次结构。我们为每条边分配一个层级(level),它是一个介于 0log n 之间的整数。所有边初始层级为 log n。边的层级只会随时间降低,不会升高。

定义 G_i 为所有层级 ≤ i 的边构成的子图。算法维护一个关键不变式G_i 中每个连通分量包含的顶点数 ≤ 2^i

同时,我们为每个层级 i 维护一个生成森林 F_i,它是 G_i 的一个生成森林(实际上是最小生成森林,以层级作为权重)。显然,F_{log n} 就是全局生成森林 F,而 F_0 则是没有边的森林。

数据结构的具体实现

上一节我们介绍了算法的层次化思想,本节中我们来看看这些森林和层级信息是如何具体维护的。

算法使用欧拉回路树来维护每个层级 i 上,森林 F_i 中的每个连通分量。欧拉回路树支持以下三种操作:

  • link(u, v): 合并 uv 所在的两棵树。
  • cut(u, v): 将边 (u, v) 从所在的树中删除,从而分裂成两棵树。
  • connected(u, v): 查询 uv 是否在同一棵树中。

此外,每个顶点 v 在层级 i 上维护一个信息:关联的、层级恰好为 i 的非树边数量。这个信息会被聚合到欧拉回路树的节点中,使得我们可以快速(在对数时间内)枚举出一个连通分量中所有层级为 i 的非树边。

以下是核心操作的伪代码描述:

# 查询操作
def connected(u, v):
    return F.log_n.connected(u, v)  # 在顶层森林中查询

# 插入边 (u, v)
def insert_edge(u, v):
    if not connected(u, v):
        # 连接两个不同分量,需要在顶层森林中添加边
        for i in range(level(u, v), log_n + 1):
            F_i.link(u, v)
        # 预支付未来可能降低层级的开销
        prepay_for_potential_level_decrease(u, v)

边的删除与替换边查找

上一节我们介绍了相对简单的查询和插入操作,本节中我们来看看最复杂的操作——边的删除,特别是当删除的边位于生成森林中时。

删除边 (u, v) 的算法是算法的核心。如果 (u, v) 不是树边,操作很简单。如果是树边,则可能将一个连通分量分裂为两个,我们需要在图中寻找一条替换边来重新连接它们。

由于 F_i 是关于层级的最小生成森林,任何连接 F_i 中两个不同分量的边,其层级必须 > i。因此,当我们删除一条层级为 l 的树边后,我们从层级 l 开始向上寻找替换边。

以下是删除树边 (u, v) 的核心流程伪代码:

def delete_tree_edge(u, v):
    l = level(u, v)
    for i in range(l, log_n + 1):
        # 在第 i 层森林中切断边 (u, v)
        F_i.cut(u, v)
        # 设 T_u, T_v 为删除边后包含 u 和 v 的树,且 |T_u| <= |T_v|
        T_u, T_v = get_smaller_and_larger_component(u, v, i)

        # 扫描 T_u 中所有层级为 i 的非树边 (x, y)
        for (x, y) in scan_edges_at_level(T_u, i):
            if y in T_v:  # 找到连接两部分的替换边!
                # 从层级 i 到 log_n,重新连接边 (x, y)
                for j in range(i, log_n + 1):
                    F_j.link(x, y)
                return  # 成功替换,结束
            else:
                # 此边未连接两部分,将其层级降为 i-1
                set_level(x, y, i-1)

        # 未找到替换边?继续尝试更高层级

关键点分析

  1. 扫描效率scan_edges_at_level 利用欧拉回路树中维护的“层级 i 非树边计数”信息,可以跳过不含此类边的子树,从而保证枚举每条相关边的时间成本为 O(log n)
  2. 层级降低与均摊分析:当一条边被扫描到却未能作为替换边时,我们将其层级降低。降低层级的开销已在插入该边时预先支付(均摊)。因此,在删除算法中,降低层级是“免费”的。
  3. 不变式的维护:我们总是扫描较小的连通分量 T_u。由于不变式保证 |T_u| ≤ 2^{i-1},因此其中边的数量有限,并且降低这些边的层级后,新形成的 F_{i-1} 中的分量大小仍能满足 ≤ 2^{i-1} 的不变式。
  4. 时间复杂度:最坏情况下,我们可能需要在 O(log n) 个层级上尝试连接,每次连接成本为 O(log n)。因此,删除(以及插入)的均摊时间复杂度为 O(log² n)。连通性查询直接在顶层森林进行,时间复杂度为 O(log n)

更优算法与开放问题

上一节我们详细分析了 Holm–de Lichtenberg–Thorup (2001) 算法,本节中我们简要了解该领域更前沿的进展和未解决的挑战。

对于最坏情况时间复杂度,目前已知的最好确定性算法是 Chuzhoy 等人 (2020) 提出的,其更新时间为亚多项式(即比任何 n^ε 增长都慢,但比多对数慢),这离理想的多对数时间仍有距离。

如果允许随机化,并且只关心期望运行时间,Hong 等人 (2023) 的算法可以达到 O((log n / log log n)²) 的期望均摊时间。

然而,一个重大的开放问题至今仍未解决:是否存在一个确定性、最坏情况、多对数更新时间的动态连通性算法?这个问题自 Frederickson 在 1985 年提出平方根时间算法以来,已开放了近四十年。

总结

本节课中我们一起学习了动态连通性问题的经典解法。我们看到了如何通过维护一个层次化的生成森林,并结合欧拉回路树,来高效处理边的插入和删除。算法的关键在于利用层级和不变式来组织搜索,并通过精妙的均摊分析将时间复杂度控制在 O(log² n) 更新和 O(log n) 查询。尽管存在更快的随机化算法和一些亚多项式的最坏情况算法,但实现确定性的多对数最坏情况更新时间仍然是一个悬而未决的难题。

012:动态连通性的下界

在本节课中,我们将探讨动态连通性问题的计算下界。我们将学习一种称为“单元探测模型”的强大模型,并理解如何利用信息论论证来证明,即使对于非常简单的图(如不相交路径的集合),任何动态连通性数据结构也必须花费至少对数时间来处理每个操作。


单元探测模型

上一节我们介绍了动态连通性问题的背景和已有的数据结构。本节中,我们来看看一个用于证明下界的通用计算模型。

在单元探测模型中,我们只关心对内存的读写次数,而将所有其他计算(如算术、比较)视为免费。这使其成为证明下界的理想模型,因为它低估了算法的实际工作量。

该模型的核心假设如下:

  • 内存由 2^w 个单元组成,每个单元存储一个 w 位的字。
  • 我们通常假设 w = Θ(log n),这样内存地址和索引就能存储在一个字中。
  • 时间成本仅由读写内存的次数决定。

这个模型非常强大,因为它不限制算法内部的计算方式。因此,在此模型下证明的下界适用于任何符合该范式的实际算法。


下界证明的核心思路

为了证明下界,我们将构造一个困难的操作序列。核心思想是分析信息如何必须从一个操作“流”到后续的操作。

我们将操作序列组织成一棵平衡二叉树。对于树中的任意节点 v,我们关注其左子树中的写操作和右子树中的读操作之间的关系。具体来说,我们关心那些在左子树中最后被写入、然后在右子树中被读取的内存地址。

这些“跨越”节点 v 的读写对,代表了信息从左向右流动的必要通道。为了正确执行右子树中的查询,数据结构必须通过这些内存访问来“了解”左子树中发生的变化。


困难的操作序列构造

以下是构造困难操作序列的步骤:

  1. 设置图结构:考虑一个 √n × √n 的网格图。初始时,每一列内部由一条特定的路径(即一个排列)连接,而列与列之间在对应行上有边相连。整个图编码了从第一列到最后一列的排列复合。

  2. 定义宏操作

    • 更新(X, π):将第 X 列的排列更改为新的随机排列 π。这可以通过 √n 次动态图的“加边”和“删边”操作实现。
    • 验证(X):检查从第一列到第 X 列的排列复合结果是否等于某个指定的排列。这可以通过 √n 次“连通性查询”操作实现。
  3. 生成操作序列:我们按照“位反转”顺序遍历各列。对于每个列索引 i(按位反转顺序):

    • 执行 更新(bitrev(i), 随机排列)
    • 执行 验证(bitrev(i)),要求验证的排列正是之前所有更新操作复合后的正确结果。

这个序列的关键在于,右子树中的验证操作的正确性,完全依赖于左子树中更新操作所使用的随机排列。因此,关于这些排列的信息必须通过内存访问从“过去”(左子树)传递到“未来”(右子树)。


信息论论证

现在,我们进行信息论分析:

  1. 信息需求:对于时间树中的节点 v,设其左子树有 L 个叶子(即 L 次更新操作)。右子树中的验证操作需要知道这 L 个随机排列的信息。每个排列有 √n! 种可能,因此编码所有排列至少需要 L * Ω(√n log n) 比特的信息。

  2. 信息载体:这些比特信息只能通过“在左子树写入,在右子树读取”的内存单元来传递。每个这样的单元传递 Θ(log n) 比特(因为字长是 Θ(log n))。因此,至少需要 Ω(L * √n) 次这样的跨越 v 的内存访问。

  3. 求和得到下界:对树中同一层的所有节点 v 求和,L 的总和是 Θ(√n)。因此,每一层需要 Ω(n) 次内存访问。树有 Θ(log n) 层,故总的内存访问次数为 Ω(n log n)

  4. 转换到原问题:我们的操作序列包含 √n 个宏操作(更新或验证),每个宏操作对应 √n 次基本的动态连通性操作。因此,总的操作次数是 n 次。既然总时间下界是 Ω(n log n),那么至少有一次基本操作需要 Ω(log n) 时间。

这就证明了,在单元探测模型下,动态连通性问题的每次操作都需要 Ω(log n) 时间,即使允许摊销分析和随机化算法,且图只是不相交路径的集合。


从“查询”下界到“验证”下界

上述论证基于一个更强的操作 “查询(X)”(直接返回排列复合结果),它比 “验证(X)” 返回更多信息。为了将下界应用到实际的“验证”操作上,需要更复杂的技术。

核心思路是用多次验证来模拟一次查询。为了知道复合排列是什么,我们可以遍历所有可能的排列,并逐个进行验证,直到某个验证返回“真”。这样,我们就用 √n! 次验证模拟了一次查询。

在模拟过程中,我们需要处理“模拟器访问了真实算法不会访问的内存地址”这一问题。这通过引入分离器族 的概念来解决。分离器可以帮助我们区分哪些内存访问是“好的”(真实算法会执行的),哪些是“坏的”。我们只需额外编码使用了哪个分离器,而这个开销很小,不会影响整体的下界结论。

最终,我们能够将针对“查询”操作的下界,转移到针对“验证”操作的下界,从而完成对原始动态连通性问题的下界证明。


总结

本节课中,我们一起学习了动态连通性问题的对数时间下界证明。我们首先介绍了用于下界分析的单元探测模型,该模型只计算内存访问次数。然后,我们通过构造一个基于网格图和位反转顺序的困难操作序列,将问题转化为信息在时间线上流动的问题。通过信息论论证,我们证明了必须有足够多的内存访问来传递左子树更新操作的信息,以满足右子树验证操作的需求,从而导出了 Ω(log n) 的每操作时间下界。这个结果非常强大,它表明我们之前学习的 O(log² n) 摊销时间数据结构在理论上已经接近最优。

013:持久化数据结构

在本节课中,我们将学习持久化数据结构的概念、不同类型以及实现它们的基本方法。持久化数据结构允许我们访问和操作其历史版本,这在许多应用中至关重要。

课程概述

持久化数据结构,也称为多版本数据结构,允许用户不仅对当前版本进行查询和更新,还能访问其过去版本。我们将探讨三种主要类型:部分持久化、完全持久化和汇合持久化,并介绍两种核心实现技术:路径复制和胖节点法。


项目与课程安排说明

上一节我们介绍了课程背景,本节中我们来看看本学期剩余时间的安排。

论文研读环节已基本结束,我将在春假期间进行评分。
下一个主要环节是项目提案,学期末则是项目展示和最终报告。

关于最终项目,特别是项目提案,我需要明确我的期望。理想的目标是项目最终可能导向可发表的成果,但考虑到项目周期仅为一个学期,期望达到那样的深度是不现实的。

因此,我对最终项目报告和展示的期望更接近于一份进展报告。报告应包含:我们尝试了哪些方法、我们的目标是什么、我们设定的子目标、我们取得的小成果、我们仍不确定是否能达成最终结果、我们考虑的特殊情况或变体,以及如果我们继续研究下一步将做什么。没有回答原始问题,但展示了进展和未来方向,这对我来说就是理想的期末项目报告。

这也应指导你构思项目提案。提案应提出一个有望取得一些进展的问题,并提供背景信息、相关成果以及一些初步想法。

项目也可以是其他形式,例如:为某个非标准应用开发更高效的数据结构、对同一问题的多种数据结构进行实验比较、或撰写关于某一类数据结构的详细综述。

你可以利用你之前的专业知识、研究经验或实习经历来匹配你想要做的项目类型。最终目标是取得进展。

最终项目预计需要投入20到40小时的工作量,分散在学期剩余几周。项目默认以三人小组形式进行,但如果有充分理由,也可以考虑更大规模的小组。评估更侧重于你在研究过程中取得的进展,而非抽象的最终目标。

关于成绩,除非完全不提交任何内容,否则我预计本课程的最低成绩为B或B+。评分更多基于整体表现而非具体分数。

项目提案在四月的第一个星期一截止,这是一个硬性截止日期。


持久化数据结构简介

上一节我们讨论了课程安排,本节中我们来看看持久化数据结构的基本概念。

持久化数据结构,或称多版本数据结构,允许我对数据结构进行更新和查询,同时还能以某种方式访问其过去版本。

最简单的例子是大多数文件系统使用的日志功能。当你更改文件时,系统会存储这些更改,使你可以在一定时间窗口内回溯并检索旧版本文件。

更复杂的版本如Git,我不仅可以查询过去,还可以创建分支(本质上修改过去,创建替代时间线),甚至可以将多个过去版本合并为新版本。

持久化通常用以下形容词来区分:

  • 部分持久化:我只能更新最新版本,但可以查询任何过去版本。
  • 完全持久化:我可以查询或更新任何版本。
  • 汇合持久化:除了完全持久化的功能,我还可以通过合并旧版本来创建新版本。

这三种类型的区别在于版本历史的结构:

  • 对于部分持久化数据结构,历史是一个序列(列表)。
  • 对于完全持久化数据结构,历史是一棵树。
  • 对于汇合持久化数据结构,历史是一个有向无环图。

Git最接近汇合持久化数据结构。

今天我将主要讨论部分持久化,周四会更详细地讨论完全持久化。

需要立即指出的一点是,汇合持久化数据结构的一个同义词是函数式数据结构。如果你在函数式编程语言中实现数据结构,由于没有状态和变量赋值,只有代表数学结构的新对象被创建,那么只要你能实现合并或连接操作,它就自动成为汇合持久化的。你始终持有版本7的句柄,三天后它看起来仍然和版本7完全一样,因为它从未改变。

有一本很棒的书叫《Purely Functional Data Structures》,作者是Chris Okasaki,我强烈推荐。


效率考量与摊还分析的挑战

上一节我们介绍了持久化的类型,本节中我们来看看实现持久化时的效率目标以及一个关键挑战。

理想的效率目标如下:

  1. 查询时间:尽可能接近在特定版本的非持久化数据结构中的查询时间。
  2. 更新时间:尽可能接近非持久化的更新时间,加上少量开销。
  3. 空间占用:理想情况下应与整个历史中写入内存的总次数成正比,即数据结构需要改变的次数总和。

然而,当我们使用具有摊还时间保证的数据结构时,会出现一个严重问题。以伸展树为例,伸展树保证每次访问的摊还时间为O(log n),但最坏情况下的查询时间可能是线性的。

假设对手创建了一个深度为线性的伸展树,并称其为版本7。然后,持久化数据结构的用户可以反复回到版本7,查询那个很深的节点。即使用户在摊还意义上对持久化结构进行了多次操作,每次查询那个旧版本中的坏节点都会触发最坏情况。因此,持久化数据结构中的整体查询时间(即使是摊还时间)会变得等于非持久化结构中的最坏情况查询时间。

核心问题:持久化数据结构的用户可以重复查询同一非持久化版本中的坏查询

因此,为了使持久化数据结构高效,底层必须使用具有最坏情况对数时间保证的数据结构,例如红黑树、AVL树或B树的加权版本,而不能使用伸展树。

第二个问题是,伸展树的架构基于每次查询同时也是更新。这对于完全持久化没问题,因为每次对过去的查询都会分支出一个新版本。但对于部分持久化,我不被允许改变过去,因此无法使用伸展树。

简而言之,伸展树虽然在其他方面很优秀,但在持久化方面表现不佳,我们必须使用其他数据结构。


一个简单例子:持久化栈

上一节我们讨论了持久化的挑战,本节中我们通过一个简单的例子来理解持久化如何工作。

考虑一个持久化栈。栈维护一个序列,支持在序列开头添加元素、移除开头元素、查看开头元素以及检查栈是否为空。

有一种非常简单的方法可以实现持久化栈,这本质上也是Lisp的核心理念。Lisp围绕操作列表设计,列表在内部表示为二叉树。

假设我们有一个指针直接指向要查询的版本的头部。持久化栈可以这样实现:

  • new(): 返回空列表(nil指针)。
  • push(S, x): 使用cons操作,构造一个新节点,其左子节点指向新元素x,右子节点指向旧栈S。返回指向新节点的指针。
  • pop(S): 返回S的右子节点指针。
  • top(S): 返回S的左子节点值。
  • is_empty(S): 检查S是否为nil。

用非Lisp代码表示:

  • push(S, x): 创建新节点nodenode.left = xnode.right = S,返回node
  • pop(S): 返回S.right
  • top(S): 返回S.left
  • is_empty(S): 返回(S == null)

这样,每次push都会创建一个新节点,该节点通过指针指向旧的栈结构。你得到的是一个指向新栈头部的指针。你可以保存这个指针,以后通过它来访问那个版本的栈并执行操作。这是一种函数式的方法:你创建新对象,但从不改变旧对象,只是让新对象通过指针指向旧对象。


实现方法一:路径复制

上一节我们看了一个简单的持久化栈,本节中我们探讨第一种通用的持久化实现方法:路径复制

此方法需要一个限制性假设:数据结构由有根森林组成,是指针式的结构,具有常数个根指针,并且每个节点都有从某个根到该节点的唯一访问路径。

核心思想:当你想修改树中的某个节点时,复制从根到该节点的整条路径上的所有节点。对于路径上指向路径外节点的指针,保持其值与原始结构相同。

例如,考虑学期初用于解决区间最小值查询的锦标赛树。如果更改一个叶子节点的值,在非持久化结构中,变化可能不会一直传播到根。但在持久化结构中,你必须将复制操作一直传播到根节点,即使只有一个节点的数据发生变化。

操作分析

  • 查询:要查询版本v,只需将指向版本v根节点的指针视为根,然后像在非持久化结构中一样正常操作。
  • 更新:更新一个节点所需的时间和额外空间,与该节点的路径长度成正比。对于平衡二叉搜索树,路径长度为O(log n)。
  • 空间:总空间与所有更新中路径长度的总和成正比,即与非持久化更新时间的总和相关。

局限性:此方法难以直接处理具有父指针的数据结构,因为那会引入循环,违反唯一访问路径的假设。一种解决方案是使用Zipper数据结构。

Zipper简介:Zipper表示一棵树加上一个指向任意节点的指针(称为finger)。它通过“反转”从根到finger的路径来实现。在内部,它表示为一个特殊的树,其中节点可以有左孩子、右孩子或父孩子(并附带一个比特位指示是左孩子还是右孩子)。移动finger或进行局部修改(如旋转)只需要交换少量节点的顺序,更新常数个指针,创建常数空间。这使得在函数式环境中实现红黑树、AVL树等成为可能,从而获得完全持久化(甚至汇合持久化)的平衡二叉搜索树。


实现方法二:胖节点法

上一节我们介绍了路径复制,本节中我们看看另一种基础方法:胖节点法

此方法假设数据结构由常数大小的单元(称为“节点”)组成。节点不一定是指针式的,也可以是数组中的单元格等。

核心思想:非常简单,每个节点存储其完整的本地历史。每次向节点写入新信息时,就在该节点的历史记录中创建一个新条目。从实践角度看,你不需要复制整个节点,只需记录哪个字段在何时从何值变为何值。但由于节点大小恒定,在大O表示法中,可以视为复制了整个节点。

优势

  • 空间:每次写入只使用常数额外空间来存储本地历史。因此,整个持久化数据结构的总空间等于创建所有版本所进行的写入总次数,这是可能的最佳情况。

劣势

  • 时间:查询过去版本时,在每个被访问的节点上,都需要找到该时间戳对应的正确版本。即,给定时间戳t,需要找到该节点历史中不晚于t的最新修改记录。

查询开销

  • 如果使用按时间排序的动态数组或平衡二叉搜索树来存储节点的历史,则每次查找前驱需要O(log N)时间,其中N是总更新次数。
  • 由于时间戳是1到N之间的整数,我们可以使用van Emde Boas树等数据结构,将每次查找前驱的时间降低到O(log log N)。

关键问题:这个开销是乘性的。执行一次查询的总时间,是非持久化查询时间乘以这个O(log log N)的开销。虽然log log N在实践中很小(约等于5),但从理论角度看,我们希望在周四介绍一种方法,能将这个乘性开销降低为加性开销。这需要在不同节点的前驱搜索之间共享信息,并且需要更多工作来使其适用于完全持久化。


课程总结

本节课我们一起学习了持久化数据结构。我们了解了部分、完全和汇合持久化的区别,讨论了使用摊还分析数据结构实现持久化时面临的挑战。我们通过持久化栈的例子理解了函数式实现的思路,并深入探讨了两种通用的实现技术:路径复制和胖节点法,分析了它们各自的优势和代价。这些基础为我们后续学习更高效的持久化方案打下了基础。

014:更持久的数据结构

在本节课中,我们将深入探讨持久化数据结构,特别是部分持久化和完全持久化的高级方法。我们将学习如何高效地查询历史版本,同时保持空间和时间的最优性。


回顾:部分持久化

上一节我们介绍了持久化数据结构的基本概念。其核心思想是,我们不仅能在数据结构的最新版本上进行查询和更新,还能查询其历史版本。最简单的形式是部分持久化:我们可以查询任何历史版本,但只能更新最新版本。

为了实现这一点,我们讨论了两种基本方法:

路径复制:适用于有根树结构。更新时,复制从根节点到目标节点的整条路径。查询历史版本时,只需指向对应版本的根节点即可。

  • 查询时间:与原始(短暂)数据结构的查询时间相同,即 O(ephemeral_query_time)
  • 空间开销:可能较高,在最坏情况下,每次更新会复制 O(log n) 个节点(对于平衡二叉搜索树)。

胖节点:每个节点存储其所有历史版本的完整记录(例如,使用数组)。

  • 查询时间:访问节点时,需要在节点的版本历史中进行二分查找,以找到目标时间点前的最近版本,这引入了 O(log(updates)) 的额外开销。
  • 空间开销:与对数据结构进行的修改(写操作)次数成正比,这是最优的。

这两种方法各有优劣:路径复制查询快但空间可能浪费;胖节点空间最优但查询有额外开销。


节点分裂法:实现最优平衡

本节中,我们来看看一种更复杂的方法——节点分裂法。它旨在同时达到最优的查询时间和空间开销。

其核心思想是:每个持久化节点并不存储短暂节点的所有版本,而是只存储常数个版本(例如,2个或3个)。当一个持久化节点被“填满”(即达到了其版本容量)时,我们将其分裂

具体操作如下:

  1. 每个持久化节点维护一个固定大小的数组,用于存储短暂节点在不同时间点的状态(值、指针等)。
  2. 当需要对一个节点进行更新时,我们检查其对应的持久化节点是否有空位。
    • 如果有空位,直接将新版本写入空位。
    • 如果没有空位(节点已满),则创建一个新的持久化节点。这个新节点只包含当前最新的版本信息。而旧的、已满的节点将被“冻结”,不再接受修改。
  3. 关键步骤:由于我们创建了一个新节点,所有指向旧节点的指针(来自其父节点或其他节点)都需要更新,以指向这个新节点。这可能会触发对其父节点的递归更新。

为何有效?摊还分析

更新操作可能引发一连串的节点分裂(级联更新)。我们使用势能法来分析其摊还代价。

  • 定义势能:当前最新版本中,所有“已满”的持久化节点的数量。
  • 一次更新可能引发 k 次节点复制(分裂)和1次最终写入。
  • 每次节点复制会减少势能(一个满节点被冻结并替换)。
  • 最终写入最多将势能增加1(可能填满一个新节点)。
  • 摊还代价 = 实际操作数 + 势能变化 ≤ (k+1) + (1 - k) = O(1)

因此,每次更新在摊还意义下只消耗常数时间和空间。

查询操作

当查询一个历史版本时,我们沿着数据结构的指针访问节点。在每个持久化节点处,我们需要从它存储的常数个版本中,找出在查询时间点之前的最新版本。由于版本数量是常数,这可以在 O(1) 时间内完成(例如,顺序比较时间戳)。

因此,持久化查询的总时间 = 短暂查询访问的节点数 × O(1) = O(ephemeral_query_time)

结论:节点分裂法在保持空间与修改次数成正比(最优)的同时,实现了查询时间的常数倍开销(最优)。


应用:平面点定位

持久化数据结构有一个经典应用,来自计算几何领域:平面点定位问题

问题描述:给定一个由互不相交(除端点外)的线段构成的平面细分(平面图),我们需要快速回答:对于一个查询点 Q,它位于哪个面(或哪条线段的上方/下方)?

扫描线算法与持久化

  1. 想象一条垂直线从左向右扫描整个平面。
  2. 在扫描过程中,我们维护一个数据结构,用于回答“如果查询点正好在这条扫描线上,它位于哪条线段下方?”这个问题。这可以转化为在扫描线与线段交点的Y坐标集合中,进行前驱查找。
  3. 这个数据结构(通常是一个平衡二叉搜索树)只在扫描线经过图的顶点时才会发生结构变化(插入或删除若干条线段)。
  4. 如果我们将这个过程持久化,那么对于任意给定的X坐标(查询点的X坐标),我们可以找到扫描线处于该位置时的“历史版本”数据结构,并在其中进行查询(基于查询点的Y坐标)。

效果

  • 查询时间:首先,在版本历史中定位正确的扫描线位置(O(log n)),然后在对应的二叉搜索树中查询(O(log n)),总计 O(log n)
  • 空间:如果使用节点分裂法等高效持久化技术,并且底层BST的每次插入/删除只涉及常数次节点修改(如弱AVL树),那么总空间与线段总数 O(n) 成正比。

这种方法提供了一种简洁、高效且易于理解的解决方案,将二维点定位问题转化为一维持久化搜索问题。


迈向完全持久化

前面的讨论主要围绕部分持久化。本节中我们来看看完全持久化的挑战与核心思路。在完全持久化中,我们可以在任何历史版本上进行更新,从而形成一个版本树,而不仅仅是版本链。

主要挑战在于:给定一个查询版本 V 和一个持久化节点,如何快速确定该节点在版本 V 时应取哪个存储的版本值?

版本树的线性化

为了高效回答上述问题,我们需要将树状的版本历史转化为线性序来处理前驱查询。常用方法是使用欧拉环游序来表示版本树:

  • 每个版本对应一对括号 (open, close)
  • 版本 A 是版本 B 的祖先,当且仅当 (A_open, A_close) 括号对包含了 (B_open, B_close)
  • 在括号序列中,“在版本 V 之前的最新更新”就转化为寻找包含 V_open 的最内层括号对。

我们需要动态维护这个括号序列(支持在任意版本后插入新版本对应的括号)。这可以通过一个支持顺序查询和插入的数据结构(如平衡二叉搜索树绳索)来实现,同时结合顺序维护数据结构来快速比较两个版本的先后顺序。

完全持久化的实现策略

胖节点的扩展:每个节点存储其版本历史。查询时,需要在节点的历史记录中,查找查询版本在版本树中的最近祖先版本。这可以通过在节点的(线性化)版本列表上建立辅助数据结构(如前驱查询结构)来实现,可能会引入对数因子开销。

节点分裂法的扩展:思路类似,但分裂规则更复杂。当一个节点的修改记录(包括不同版本的不同出边和入边)超过容量时,将其分裂成两个节点,并递归更新所有相关节点(父节点和子节点)的指针。为了保证摊还分析成立,需要确保每个短暂节点的入度和出度之和是常数。分析表明,每次更新仍然只有常数摊还开销。


总结

本节课我们一起深入学习了更高级的持久化数据结构技术。

  • 我们首先回顾了部分持久化的两种基础方法:路径复制和胖节点。
  • 接着,我们深入探讨了节点分裂法,它通过在每个节点只维护常数个版本,并在节点满时进行分裂和递归更新,巧妙地实现了查询时间和空间开销的双重最优
  • 然后,我们考察了持久化数据结构的一个经典应用:平面点定位问题,展示了如何通过持久化扫描线算法优雅地解决该问题。
  • 最后,我们探讨了完全持久化所面临的挑战,即将版本树线性化以及高效定位节点版本,并简要介绍了胖节点和节点分裂法在完全持久化模型下的扩展思路。

持久化是一种强大的技术,它允许我们高效地“记录”数据结构的全部历史,或模拟连续变化的过程,在算法设计(尤其是计算几何和动态算法)中有着广泛的应用。

015:布谷鸟哈希

在本节课中,我们将要学习一种名为“布谷鸟哈希”的高效哈希表技术。布谷鸟哈希以其独特的冲突解决策略而闻名,它使用两个哈希函数,并允许在插入时“踢出”已有元素,从而在常数期望时间内完成查找、插入和删除操作。我们将探讨其基本原理、算法流程、失败模式分析,以及一些实用的变体。

项目提案截止日期说明

上一节我们介绍了课程安排,本节中我们来看看一个重要的截止日期更新。

网站上的项目提案截止日期信息存在不一致。我将截止日期从4月7日(下下周的周一)提前到了3月31日(本周一),但未能更新所有相关页面。因此,部分页面仍显示4月7日。

以下是关于截止日期的具体说明:

  • 请尽量在本周一(3月31日)前提交项目提案。
  • 如果您原计划依赖4月7日的截止日期而无法在周一完成,请尽力而为并给我发送邮件说明情况。
  • 根据收到的提案数量,我将在周二或周四向大家展示提案。最迟会在下周二展示。
  • 我将项目展示安排在学期最后的一周到两周内,而非期末考试周,以便大家有更多时间根据反馈组建小组并推进项目。
  • 最终的项目报告提交截止日期是期末考试周的最后一天(5月14日)。

对于网页信息不一致带来的不便,我表示歉意。

布谷鸟哈希的基本思想

上一节我们提到了哈希,本节中我们来看看布谷鸟哈希这一具体技术。

布谷鸟哈希的核心思想借鉴了布谷鸟的习性:布谷鸟会将蛋产在其他鸟类的巢中,孵化后的雏鸟会将宿主的蛋推出巢外。在哈希表中,我们使用两个哈希函数,每个元素都有两个可能的“巢”(存储位置)。

基本设定

  • 我们有两个哈希函数:H1(x)H2(x),它们将元素 x 映射到大小为 M 的表中。
  • 不变式:表中存储的任何元素 x 必须位于 H1(x)H2(x) 这两个位置之一。

查找和删除操作非常简单:只需检查这两个位置即可。插入操作是算法的关键。

插入算法

  1. 计算新元素 w 的两个候选位置 H1(w)H2(w)
  2. 如果其中一个位置为空,则将 w 放入该位置。
  3. 如果两个位置都被占用,则选择其中一个(例如 H1(w)),将 w 放入,并“踢出”该位置原有的元素 x
  4. 被踢出的元素 x 会尝试放入它的另一个候选位置(即 H2(x))。
  5. 如果 H2(x) 也被占用,则重复此“踢出”过程,直到找到一个空位,或达到最大尝试次数。

这个“踢出”链可能会很长,甚至形成循环。算法会设置一个最大尝试次数(例如 O(log n)),如果超过此限制仍未找到空位,则认为插入失败,需要重建哈希表。

相关背景:二选一的力量与随机图

为了理解布谷鸟哈希为何有效,我们需要了解两个相关的概率论概念。

二选一的力量
考虑一个实验:将 n 个球随机、独立、均匀地扔进 n 个箱子。

  • 在这种情况下,负载最重的箱子中球的数量期望是 Θ(log n / log log n)
  • 现在改变规则:每个球随机选择两个箱子,并放入当前球数较少的那个箱子。
  • 在这种情况下,负载最重的箱子中球的数量期望会急剧下降到 Θ(log log n)

这个结果直观地说明了,即使只是提供两个选择并择优而入,也能极大地平滑分布,减少“热点”。布谷鸟哈希利用了两个哈希函数,在某种程度上体现了这种思想。

随机图模型
布谷鸟哈希的分析与随机图理论密切相关。我们可以定义一个布谷鸟图

  • 顶点:哈希表中的每个槽位。
  • :对于每个元素 x,从 H1(x)H2(x) 画一条有向边(假设 x 当前存储在 H1(x))。

在这个图中,插入元素时的“踢出”过程对应于沿着边移动并可能反转边的方向。插入失败的模式(长链或循环)对应于图中存在长路径或特定结构的环。研究表明,当哈希表的负载因子(元素数/槽位数)低于 0.5 时,这些不良结构出现的概率很低;一旦超过 0.5,失败概率会急剧上升。

布谷鸟哈希的算法与失败模式分析

上一节我们了解了布谷鸟哈希的直观思想和相关背景,本节中我们来看看更形式化的算法描述和失败模式分析。

算法设定
假设我们有两个表 T1T2,每个大小为 M,且 M = (1 + ε)n,其中 n 是元素数量,ε 是一个正常数。我们暂时假设哈希函数 H1H2 是理想的完全随机函数。

插入算法伪代码

function Insert(x):
    for k = 1 to MaxLoop:
        if T1[H1(x)] is empty:
            T1[H1(x)] = x
            return Success
        swap(x, T1[H1(x)]) // 踢出原有元素,x 持有被踢出者
        if T2[H2(x)] is empty:
            T2[H2(x)] = x
            return Success
        swap(x, T2[H2(x)]) // 踢出原有元素,x 持有被踢出者
    // 超过最大循环次数
    return Failure (需要重建哈希表)

其中 MaxLoop 通常设置为 O(log n)

失败模式
插入失败主要有两种模式:

  1. 模式一:进入无限循环。踢出过程形成了一个闭环,元素在其中循环而永远找不到空位。
  2. 模式二:踢出链过长。虽然没有循环,但踢出的路径长度超过了 MaxLoop

概率分析概要
通过对布谷鸟图中特定结构(长路径或特定环)的计数和概率计算,可以证明:

  • 当负载因子保持在 1/(2+2ε) 以下(即低于 1/2)时,一次插入操作进入无限循环的概率至多为 O(1/n^2)
  • 同样条件下,一次插入操作因路径过长而失败的概率至多为 O(1/n^c)(c 为某个常数)。
  • 因此,单次插入的期望时间是常数,并且以高概率在 O(log n) 时间内完成

布谷鸟哈希的变体与实现考量

上一节我们分析了基础布谷鸟哈希的性能,本节中我们来看看一些改进的变体和实际实现中的考量。

基础的布谷鸟哈希对空间要求较高(负载因子低于50%)。为了提高空间利用率,研究者提出了几种有效的变体:

以下是三种主要的扩展方向:

  • 多路哈希:使用 dd > 2)个哈希函数,而不仅仅是两个。这显著提高了负载因子的阈值(例如,d=3 时可达约91%,d=4 时可达约97%)。
  • 桶式布谷鸟哈希:每个槽位不再只存储一个元素,而是可以存储一个小桶(bucket)的多个元素。只有当桶满时才触发“踢出”操作。
  • 备用存储区:维护一个额外的、较小的“备用”表(stash)。当主表的插入过程失败时,不是立即重建,而是将冲突元素放入备用区。查询时也需要检查备用区。研究表明,即使一个很小(如常数大小)的备用区,也能将失败概率从 O(1/n^2) 降低到 O(1/n^{s+1})(s 为备用区大小),从而允许主表达到极高的负载因子(如99%)。

在实际实现中,通常会结合使用多种变体(例如,3路哈希 + 小桶 + 备用区)以达到最佳的空间和时间效率。

关于哈希函数
之前的分析假设了理想的完全随机哈希函数。实际上,我们可以用更弱的、高效的可计算哈希函数族来替代。

  • 简单制表哈希:将键分割成小块,每块作为索引查一个预填充的随机值表,然后将所有查到的值进行异或。这种方法即使不具备强独立性,在实践中配合布谷鸟哈希也能提供不错的性能(失败概率约为 O(1/n^{1/3}))。
  • 理论保证:通过精心组合成对独立哈希制表哈希,可以在理论上实现与完全随机哈希类似的 O(1/n^2) 失败概率边界,同时保持哈希函数评估的高效性。

总结

本节课中我们一起学习了布谷鸟哈希。我们从其仿生学灵感出发,理解了它使用两个哈希函数和“踢出”机制来解决冲突的核心思想。我们探讨了其高效的查找、删除和期望常数时间的插入操作,并分析了其两种失败模式(无限循环和长链)以及相关的概率边界。我们还了解了提高其空间利用率的多种实用变体,如多路哈希、桶式哈希和备用存储区。最后,我们讨论了在实际实现中,可以使用高效且具备理论保证的哈希函数(如结合制表哈希的成对独立哈希)来替代理想的完全随机函数。布谷鸟哈希是一种在理论和实践中都极具影响力的数据结构,它展示了随机化和简单规则如何产生强大的性能。

016:多选择的力量与“总是向左”策略

在本节课中,我们将学习“多选择”负载均衡模型,并深入探讨一个有趣的现象:当我们将选择范围限制在特定的“块”中,并采用“总是向左”的平局决胜规则时,最大负载会如何变化。我们将从经典的“球与箱子”模型开始,逐步引入更复杂的策略,并通过概率分析和“见证树”的概念来证明这些策略的性能上界。


经典“球与箱子”模型:热身

首先,我们回顾经典的“球与箱子”实验。假设我们有 n 个球和 n 个箱子。每个球被独立且均匀地随机投入一个箱子。

核心问题:所有箱子中,球最多的那个箱子(即最大负载)大约是多少?

通过概率分析可以证明,以高概率,最大负载约为:

log n / log log n

其中 log 表示自然对数。这个结果与使用链地址法的哈希表性能直接相关:最长的链长度大约就是这个值。

为了直观理解这个上界,我们可以进行一个简单的计数论证。

上界证明思路

考虑任意一个箱子(例如箱子1)拥有至少 M 个球的概率。通过组合计数和布尔不等式(Union Bound),我们可以得到:

Pr(箱子1有 ≥ M 个球) ≤ (n choose M) * (1/n)^M ≤ (e/M)^M

然后,我们考虑所有 n 个箱子。再次使用布尔不等式:

Pr(存在箱子有 ≥ M 个球) ≤ n * (e/M)^M

现在,我们设 M = 4 * (log n / log log n)。通过代入和代数运算(这里略去细节),可以证明当 n 很大时,这个概率小于 1/n^2。这意味着,以很高的概率,没有箱子会超过这个负载。虽然常数 4 不是紧的,但核心的增长阶 log n / log log n 是正确的。


多选择的力量

上一节我们看到了随机分配的负载情况。现在,我们引入一个更聪明的策略,它能显著降低最大负载。

模型描述

  1. 对于每个球,我们不再只随机选一个箱子,而是独立、均匀地随机选择 d 个箱子
  2. 然后,我们观察这 d 个箱子当前的负载(即已有球数)。
  3. 最后,将球放入这 d 个箱子中负载最轻的那个。

核心结论:采用此策略后,以高概率,最大负载从 Θ(log n / log log n) 急剧下降至:

log log n / log d + O(1)

这是一个巨大的改进。即使 d=2(两个选择),最大负载也变成了双对数级别。

与哈希的联系:这直接对应到一种哈希表方案。我们有 d 个哈希函数,每个键计算 d 个位置,然后插入到其中负载最轻的桶中(例如使用链地址法处理冲突)。


非均匀选择与“总是向左”策略

现在,我们考虑一个更结构化的选择方式,并引入一个关键的平局决胜规则。

模型修改

  1. 分块:首先,将 n 个箱子均匀分成 d 个块,每个块包含 n/d 个箱子。
  2. 非均匀选择:对于每个球,它在每个块中独立、均匀地随机选择恰好一个箱子。这样,它总共还是选择了 d 个箱子,但保证了每个块都有一个代表。
  3. 负载比较与插入:球仍然放入这 d 个箱子中负载最轻的那个。
  4. 平局决胜规则:当多个箱子负载相同时,我们总是选择编号最小的块中的那个箱子(即“总是向左”)。

这个模型是“布谷鸟哈希”的自然推广:我们有 d 个哈希表(对应 d 个块),每个键通过 d 个哈希函数分别映射到每个表中的一个位置。

惊人的结论

  • 如果平局是随机打破的,最大负载仍然是 Θ(log log n / log d)
  • 但如果采用“总是向左”的规则,最大负载可以进一步降低到 Θ(log log n / d)!当 d 增大时,这个改进非常显著。
  • 理论证明,在给定的“选择 d 个箱子”的框架下,这种“分块+总是向左”的策略在所有可能的非均匀选择分布和平局决胜规则中,达到了最优的最大负载下界。

见证树分析:理解上界证明

为了证明多选择模型的上界,我们引入一个强大的组合工具——见证树。它能将“某个箱子负载过高”这个复杂事件,分解为一组更简单、概率更低的事件的组合。

均匀选择下的见证树

我们目标是证明:最大负载超过 L + O(1) 的概率非常小。

见证树的定义

  • 它是一棵满的 d 叉树,高度为 L(根在 level L,叶子在 level 0)。
  • 树中每个节点关联一个箱子,以及最后放入该箱子的那个球
  • 根节点表示的事件是:某个球 B 被放入了一个已经装有至少 L+3 个球的箱子 X(使其成为第 L+4 个球)。
  • 子节点的含义:既然球 B 选择了箱子 X,意味着在它做决定的时刻,它随机选择的 d 个箱子(记为 H1(B), ..., Hd(B))负载都至少L+3。每个子节点就对应这些箱子之一,以及更早放入该箱子的最后一个球。
  • 以此类推递归构建。叶子节点对应的事件是:某个球被放入了一个已经至少有 3 个球的箱子(即成为第 4 个球)。

事件概率分析

  1. 叶子事件:一个球被放入已有至少3个球的箱子。由于总共只有 n 个球,根据鸽巢原理,这种“富箱”最多有 n/3 个。因此,对于任何特定的箱子,一个随机球选择它作为 d 个选择之一的概率 ≤ d/3。通过更精细的分析,可以论证单个叶子事件发生的概率 ≤ 1/3
  2. 边事件:连接父节点(球 P)和子节点(箱子 V)的边,表示球 Pd 个随机选择中,包含了箱子 V。这个概率 ≤ d/n
  3. 树的结构数量:一棵有 m 个节点的见证树,每个节点可以标记为 n 个箱子之一,所以最多有 n^m 种不同的标记方式。

综合计算
一棵特定的、有 q 个叶子和 m 个节点的见证树,其所有事件(所有叶子事件和所有边事件)同时发生的概率上界大约是:

(n^m) * (d/n)^{m-1} * (1/3)^{q}

经过化简和近似(利用 q ≈ d^L 以及 m ≈ q),这个上界可以化为类似于 n / 2^{d^L} 的形式。

得出结论
如果我们设 L = log_d(log n) + O(1),那么上述概率就变得非常小(例如小于 1/n^2)。这意味着,以高概率,不存在高度为 L 的活跃见证树,从而没有箱子的负载会超过 L + O(1)。这就证明了上界 log log n / log d

“总是向左”策略下的见证树变化

在“分块+总是向左”的策略下,见证树的结构发生了关键变化,这导致了不同的负载上界。

树结构的变化

  • 每个节点现在除了负载高度 h,还关联一个块编号 i (1 ≤ i ≤ d)。
  • 一个关联于块 i、高度 h 的节点,其子节点结构如下:
    • 对于块编号 j < i(左边块):子节点高度为 h
    • 对于块编号 j = i(自身块):子节点高度为 h-1
    • 对于块编号 j > i(右边块):子节点高度为 h-1
  • 原因:“总是向左”规则意味着,当球放入块 i 的一个箱子时,所有左边块(j < i)中被选中的箱子负载必须不低于当前箱子,而右边块(j > i)中被选中的箱子负载可以少一个(因为如果少得更多,球就会去右边了)。

对树大小的影响
这种结构下,树的生长速度不再像均匀情况那样是 d^L,而是遵循一个类似广义斐波那契数列的递归关系。叶子数量 F(L) 满足:

F(L) = F(L-1) + F(L-1) + ... + F(L-d) (具体形式与d有关)

分析表明,F(L) 的增长速度大约是 (φ_d)^L,其中 φ_d 是一个常数(φ_2 ≈ 1.618φ_3 ≈ 1.839,随着 d 增大而接近2)。关键点是,φ_d^L 的增长速度比均匀情况下的 d^L

对负载上界的影响
在概率计算中,叶子数量 q 的增长速度变慢,意味着为了使得“所有事件发生”的总概率足够小,我们所允许的树高度 L 可以更大。但请注意,树的高度 L 对应的是负载的超出量。允许的 L 更大,实际意味着负载的实际上界更小

将新的叶子数量增长阶 (φ_d)^L 代入之前的概率分析框架,最终可以得到最大负载的上界为 Θ(log log n / (d * log φ_d))。当 d 较大时,log φ_d ≈ log 2,因此上界简化为 Θ(log log n / d)。这比均匀选择下的 Θ(log log n / log d) 要好得多。


总结

本节课我们一起学习了负载均衡中的多选择策略及其分析。

  1. 我们从经典的随机分配模型开始,其最大负载为 Θ(log n / log log n)
  2. 然后引入了多选择(Power of d Choices)策略,通过让每个球在 d 个随机选项中挑选负载最轻的,将最大负载显著降低到 Θ(log log n / log d)
  3. 进一步,我们探讨了非均匀选择模型(将箱子分块,每块选一个)和特定的平局决胜规则(“总是向左”)。令人惊讶的是,这种策略能达到理论最优的下界 Θ(log log n / d)
  4. 为了证明这些上界,我们学习了见证树这一强大的分析工具。它将高负载事件映射为一种树形结构,并通过计算特定树结构出现的概率来得到负载上界。均匀和非均匀策略下见证树结构的不同,直接导致了最终性能的差异。

这些结果不仅具有理论美感,也为设计高效的哈希表(如布谷鸟哈希的推广)和分布式负载均衡算法提供了坚实的理论基础。

017:基于部分信息的排序

在本节课中,我们将学习一个非常新的排序问题:基于部分信息的排序,有时也称为DAG排序。我们将探讨两篇发表于2025年的论文,它们以不同的方式解决了同一个问题,并最终实现了最优的时间复杂度。这个结果的一个直接应用是,它首次在50年内改进了经典的“排序X+Y”问题的算法。


问题定义

我们被给定一组 N 个待排序的项目。同时,我们还预先知道了其中 M 对项目的比较结果(例如,A < B)。这些已知的比较结果定义了一个有向无环图,其中每个顶点代表一个项目,每条有向边 A -> B 表示已知 A < B

我们的目标是:确定这N个项目的完整、正确的全序排列

这个DAG定义了一个偏序。我们最终要找出的全序,必须是这个偏序的一个线性扩展。设 T 为与该DAG一致的所有可能全序(即线性扩展)的数量。

我们的目标是设计一个算法,其运行时间为 O(N + M + log T)。这个界是最优的,因为:

  • N + M 是读取输入所必需的。
  • log T 是信息论下界:我们需要通过二元比较,从T种可能性中确定唯一正确的排序。

应用:排序 X+Y 问题

这个问题的一个经典应用是“排序X+Y”问题。给定两个已排序的数组 XY,每个数组包含N个数字。我们希望生成所有 个和 X[i] + Y[j] 的排序列表。

  • 朴素算法:计算所有和,然后排序,时间复杂度为 O(N² log N)
  • 新视角:我们可以将这个问题建模为DAG排序。每个和 (i, j) 是一个顶点。已知的比较信息是:
    • 对于固定的 i,有 (i, j) < (i, j+1)(因为 Y 已排序)。
    • 对于固定的 j,有 (i, j) < (i+1, j)(因为 X 已排序)。
      这形成了一个 N x N 的网格状DAG。
  • 关键:这个特定DAG的线性扩展数量 T 远小于 N²!。事实上,可以证明 log T = O(N log N)
  • 结论:应用我们即将讨论的算法,可以在 O(N² + log T) = O(N²) 时间内解决排序X+Y问题,这打破了50年来 O(N² log N) 的最佳记录。这个改进还能推广到2k-SUM等问题。

方法一:拓扑堆排序

上一节我们定义了问题,本节我们来看看第一种解决方案:拓扑堆排序。这个算法结合了拓扑排序和堆排序的思想。

算法维护一个存放当前“源点”(入度为0的顶点)的堆 H,以及一个输出队列 Q

以下是算法步骤:

  1. 初始化一个空队列 Q
  2. 将当前DAG中所有的源点插入堆 H 中。
  3. 当图不为空时,重复以下步骤:
    a. 从堆 H取出值最小的源点 v(此操作涉及比较)。
    b. 对于图中每一条从 v 出发的边 (v -> w)
    * 如果删除 vw 的入度变为0(即成为新的源点),则将 w 插入堆 H
    c. 将 v 追加到输出队列 Q 的末尾。
    d. 将 v 及其相连的边从图中删除。
  4. 最终,Q 中存储的就是排序好的序列。

算法理解

  • 如果初始DAG无边,则所有顶点都是源点,算法退化为标准的堆排序。
  • 如果初始DAG是一条链,则算法只是按顺序取出源点,退化为简单的遍历。

算法的效率核心在于所使用的堆数据结构。如果使用普通的二叉堆,每次插入和删除最小元素需要 O(log N) 时间,总时间为 O(N log N)。但我们需要更精细的结构来达到 O(N + M + log T) 的目标。

所需堆的性质

为了实现目标时间复杂度,我们需要一个支持以下操作的优先队列(堆):

  • Insert(x): 插入元素 x
  • ExtractMin(): 删除并返回最小元素。

摊还时间复杂度需满足:

  • Insert: O(1) 时间。
  • ExtractMin 对于元素 x 的耗时,与 x工作集大小有关。

工作集 W(x) 定义为:在 x 被插入之后、被删除之前,所插入的所有其他元素的集合。其大小记为 |W(x)|

我们需要 ExtractMin 操作对元素 x 的摊还代价为 O(log |W(x)|)

这意味着,如果一个元素在堆中停留时间很短(工作集小),那么取出它的代价就低;反之则高。这种性质完美契合了我们的算法需求。

如何实现:论文中提到了配对堆(Pairing Heap)的一种变体(在满足特定条件下)可以满足这些性质。其分析比标准配对堆更简单。


方法二:拓扑插入排序

上一节我们介绍了基于堆的方法,本节我们来看看第二种思路:拓扑插入排序。这个算法模拟了插入排序的过程,但利用了已知的偏序信息来加速定位。

算法维护一个已排序的列表 π(初始时是DAG中的最长路径),以及剩余的子图 H

以下是算法步骤:

  1. 找到初始DAG G 中的一条最长路径,作为初始排序列表 π
  2. H = G - {π中的顶点及其出边}
  3. H 不为空时,重复:
    a. 从 H 中任取一个源点 x_i
    b. 在原始图 G 中,找到 x_i 的所有前驱里,在列表 π位置最靠后的那个顶点 p_i(这可以通过比较得到)。
    c. 将 x_i 插入到列表 πp_i 的后面。
    d. 将 x_iH 中删除。
  4. 最终,π 就是排序好的序列。

算法理解

  • 如果初始DAG就是一条路径,那么算法第一步就完成了。
  • 如果初始DAG是两条链(即合并两个已排序列表),那么算法本质上就是归并过程,可以高效完成。

算法的效率核心在于第3.c步的插入操作。我们不能简单地用 O(log N) 的二叉搜索树插入,因为已知的偏序 (p_i < x_i) 给了我们一个“提示”:x_i 应该插入在 p_i 附近。

所需数据结构:指状搜索树

我们需要一个支持指状搜索的数据结构来维护列表 π

  • 操作FingerInsert(x, finger)。给定一个指向元素 finger(即 p_i)的指针,将新元素 x(即 x_i)插入到正确位置。
  • 目标时间复杂度O(log d),其中 dx 的最终排名与 finger 的排名之差的绝对值。

如何实现

  1. 随机化方法:使用树堆。从 finger 开始,期望的搜索路径长度为 O(log d)
  2. 确定性方法:使用B树(如2-3-4树)。B树通过节点分裂(而非旋转)来保持平衡,这使得维护“同层指针”变得容易,从而可以实现确定性的 O(log d) 指状搜索。

核心分析工具:区间引理

无论是拓扑堆排序还是拓扑插入排序,其最终的时间复杂度分析都归结于一个关键的区间引理

我们定义一系列区间 [a_i, b_i](1 ≤ i ≤ N):

  • 对于堆排序a_i 是项目 i 被插入堆的时间,b_i 是它被提取出的时间。区间长度 (b_i - a_i) 正比于其工作集大小。
  • 对于插入排序a_i 是前驱 p_i 的最终排名,b_i 是项目 x_i 的最终排名。区间长度 (b_i - a_i) 反映了插入时的搜索距离。

基于这些区间,我们构造一个新的DAG I:如果区间 [a_i, b_i] 完全在区间 [a_j, b_j] 的左边(即 b_i ≤ a_j),则有一条边从 i 指向 j

T(I) 是DAG I 的线性扩展数量。可以证明,原始DAG G 的线性扩展数量 T ≥ T(I)

区间引理指出:

∑_{i=1}^{N} ln(b_i - a_i + 1) ≤ ln T(I) + O(N) ≤ ln T + O(N)

其中 ln 是自然对数。

证明概要

  1. 想象一个随机实验:独立、均匀地生成 N 个随机数 r_1, ..., r_N ∈ [1, N]。
  2. 考虑事件 E:对于每个 i,随机数 r_i 都落在其对应的区间 [a_i, b_i] 内。
  3. 事件 E 发生的概率是 Π_{i=1}^{N} ( (b_i - a_i + 1) / N )
  4. 如果事件 E 发生,那么将随机数 {r_i} 按大小排序后得到的排列,必然是 DAG I 的一个线性扩展。
  5. 所有 N! 种排列是等可能的,因此 DAG I 的线性扩展数量 T(I) 至少为 N! × P(E)
  6. 对不等式 T(I) ≥ N! × Π ( (b_i - a_i + 1) / N ) 两边取自然对数,并利用斯特林近似 ln(N!) ≈ N ln N - N,即可推导出引理。

这个引理将算法中各个操作的代价(ln(区间长度))之和,与问题固有的信息论下界 ln T 联系了起来,从而完成了算法的整体分析。


总结

本节课我们一起学习了基于部分信息的排序问题。

  • 问题:在已知部分比较结果(构成一个DAG)的前提下,完成排序。
  • 目标:达到 O(N + M + log T) 的最优时间复杂度,其中 T 是符合已知偏序的全序数量。
  • 两种算法
    1. 拓扑堆排序:按拓扑序从堆中提取最小源点。关键在于使用具有工作集敏感提取操作的堆。
    2. 拓扑插入排序:维护一个已排序列表,并按拓扑序插入剩余源点。关键在于使用支持指状搜索的树结构。
  • 核心分析:通过巧妙的区间引理,将数据结构的操作代价总和与信息论下界 log T 关联起来。
  • 重大应用:该成果直接带来了排序X+Y问题的 O(N²) 时间算法,打破了50年来的记录,并可能影响一系列相关问题(如2k-SUM)的算法上界。

这个工作展示了如何通过结合经典的算法思想(拓扑排序、堆排序、插入排序)并精心设计底层数据结构,来解决一个看似简单但长期未决的理论问题。

018:van Emde Boas树与x-fast tries

在本节课中,我们将学习两种用于整数集合的高效有序字典数据结构:van Emde Boas树和x-fast tries。这两种结构都通过利用整数的位表示,将查询时间从传统的O(log n)降低到O(log log U),其中U是整数的全域大小。我们将从基础概念开始,逐步构建出这些复杂但高效的结构。

课程概述

我们将首先回顾一些课程管理事项,然后深入探讨整数数据结构。核心思想是打破“键值是不可分割原子”的抽象,直接操作其二进制位。我们将看到两种主要技巧:位级并行(在单个字操作中比较多个键)和位级二分查找(在键的位表示上进行二分查找,而非在键值本身上)。本节课重点介绍后者,并详细讲解van Emde Boas树和x-fast trie的原理与操作。

管理事项

以下是关于课程项目的一些安排。

  • 请于今天课后,在课程网页上填写表格,登记项目小组信息。这有助于我了解在学期末需要安排多少场报告。
  • 表格中需要填写小组成员姓名,以及一个暂定的、约六个词的标题和一两句项目描述。项目内容后续可以更新。
  • 项目报告旨在展示进展,而非最终成果。报告预计于两周后开始,请大家在表格中注明时间偏好,并保持灵活。

关于上节课提到的“排序X+Y”问题,我需要做一个更正。该问题与“部分信息排序”问题看似相关,但“X+Y”矩阵具有额外的结构限制(相邻项差值相同),而部分信息排序的DAG模型仅记录大小关系,不包含此信息。因此,利用部分信息排序的结果并不能解决这个已有50年历史的问题。目前,“排序X+Y”问题的最优算法仍是已知的平凡算法。对此前的过度解读表示歉意。

整数数据结构简介

现在,我们开始讨论今天的主题:整数数据结构。

我们将暂时抛开本学期大部分时间使用的抽象——即被比较的对象是原子,只能进行常数时间的比较操作,而不能查看其内部结构。我们将深入探讨,如果打破这种抽象,我们能获得多少性能提升。

具体来说,我们将关注有序字典,用于维护一个属于{1, 2, ..., U}集合的子集S。我们将尝试利用“它们是整数”这一事实,特别是每个元素S[i]都可以用一个log U位的字来表示。

我们假设可以对字进行常数时间操作,例如加减、比较、取模、位运算(如异或)等。

核心技巧:位级并行

一种利用整数特性的简单方法是位级并行

假设我的字长是log U位,但我通过某种方式将键的大小减少到(1/4) log U位。那么,在一个字里,我可以存储四个小整数A, B, C, D。我可以通过插入分隔位(例如,在上层字中插入1,在下层字中插入0)将它们打包进一个字。

当我执行两个字相减时,只需关注结果中分隔位之间的部分。如果某位置结果为1,意味着对应位置的上层整数大于下层整数。通过一次比较操作,我实际上同时执行了四次比较。

这个技巧的变体可以加速基于比较的算法,前提是你能将键空间减小到足以将多个键打包进一个字。这样,排序的Ω(n log n)比较下界在此设置下不再适用,因为我不再需要为每次比较花费常数时间。

核心技巧:位级二分查找

今天要讨论的是另一种略有不同的方法,用于有序字典(支持查找前驱、后继以及动态插入删除)。

核心思想是,对于log U位的键,查找其后继时有两种可能:

  1. 后继的高位部分与查询键相同。此时,我需要在低位部分递归地查找后继。
  2. 后继的高位部分不同。此时,我需要在高位部分递归地查找后继,然后找到具有该新高位的最小键(即对应簇中的最小值)。

通过这种方式,我将对n个键的二分查找,转化为了对log U个位的二分查找。这构成了van Emde Boas树和x-fast trie等数据结构的基础。

分层位向量 (Tiered Bit Vector)

我们从最简单有序字典结构开始:一个长度为U的位向量

定义:B[x] = 1 当且仅当 x ∈ S

插入、删除和成员查询只需常数时间(直接访问位)。然而,查找前驱或后继在最坏情况下需要O(U)时间(可能需要扫描整个数组)。

为了改进,我们引入分层位向量

结构

将位向量分成大小为√U的块。同时,维护一个长度为√U摘要向量。摘要向量的第i位为1,当且仅当第i个块非空。

将整数xlog U位)拆分为高位部分x_H(1/2)log U位)和低位部分x_L(1/2)log U位)。因此,x = x_H * √U + x_L

我们有一个二维数组B[x_H][x_L]表示原始位向量,以及一个一维摘要数组summary[x_H]

后继查询算法

查找x的后继succ(x)

  1. 首先,在x所在的块(即B[x_H])中查找最大值M
  2. 如果块非空且M > x_L,则后继存在于当前块中。返回 (x_H, succ(x_L)在块B[x_H]中)
  3. 否则,后继存在于后续的块中。在摘要数组summary中查找succ(x_H),得到下一个非空块的高位h'。然后返回 (h', min(B[h'])),即下一个非空块中的最小元素。

性能分析与问题

为了完成算法,我们需要支持在块内和摘要中快速查找最大值max和最小值min。这些操作本身也是递归的。

T(u)为在大小为u的全域上执行后继查询的时间。根据算法:
T(u) = T(√u) + 2 * M(√u) + O(1)
其中M(√u)是在大小为√u的全域上查找最大值的时间。

而查找最大值的递归式为:
M(u) = 2 * M(√u) + O(1)

解这个递归式,M(u) = O(log u)。代入后继查询的递归式,会导致总时间为O(log U)。这并不比普通的二叉搜索树(O(log U))更好,而且最大值查询成为瓶颈。

改进的关键在于:能否让minmax查询更快?

van Emde Boas 树

van Emde Boas树通过显式存储最小值和最大值来解决上述瓶颈。

结构

一个vEB树节点包含以下字段:

  • min: 存储集合中的最小值(不递归存储)。
  • max: 存储集合中的最大值(不递归存储)。
  • summary: 一个指向更小vEB树的指针,用于记录哪些簇是非空的。其全域大小为√U
  • cluster[]: 一个大小为√U的数组,每个元素指向一个更小的vEB树。每个簇管理低位部分,全域大小也为√U

重要minmax元素不递归存储在summarycluster中。如果一个vEB树只包含一个或两个元素,它们仅存储在minmax字段中,递归结构为空。这使得在空树中插入第一个元素或删除最后一个元素仅需常数时间。

后继查询

由于minmax是常数时间可访问的,后继查询算法与分层位向量类似,但省去了耗时的递归min/max查询。递归式简化为:
T(u) = T(√u) + O(1)
其解为 T(u) = O(log log U)

插入操作

插入元素x到vEB树V的过程如下:

  1. 如果 V.min 为空(树空),则设置 V.min = V.max = x,返回。
  2. 如果 x < V.min,交换 xV.min 的值(保证新插入的值大于当前最小值)。
  3. 如果 x > V.max,交换 xV.max 的值。
  4. 如果 V.cluster[x_H] 为空(即其min为空):
    • 递归地将 x_H 插入到 V.summary 中。
  5. 递归地将 x_L 插入到 V.cluster[x_H] 中。

在最坏情况下,插入操作会进行一次非平凡的递归调用(要么插入summary,要么插入cluster,但不会同时两者都非平凡)。因此,插入时间也是O(log log U)。删除操作是对称的,同样需要O(log log U)时间。

小结

van Emde Boas树支持所有查询(成员、前驱、后继)和更新(插入、删除)操作,时间复杂度为 O(log log U)
然而,空间复杂度为 O(U),这当U远大于集合大小n时是不可接受的。

X-fast Trie

X-fast Trie由Willard于1982年提出,它提供了与vEB树相似的查询性能,但结构不同。

结构:二叉Trie

基础结构是一棵深度为log U的完全二叉Trie树。每个叶子节点对应一个log U位的二进制串(即一个可能的键)。我们仅保留集合S中元素的祖先节点。

增强指针

为了高效导航,我们添加以下指针:

  1. 层级最大/最小指针:对于任何只有左孩子的节点,其缺失的右孩子指针指向左子树中的最大叶子。对称地,对于只有右孩子的节点,其缺失的左孩子指针指向右子树中的最小叶子。
  2. 叶子链表:所有叶子节点按顺序链接成一个双向链表,每个叶子存储其前驱和后继指针。
  3. 层级哈希表:对Trie的每一层,维护一个哈希表,存储该层所有存在的节点。节点的“地址”即从根到该节点的路径形成的比特串(长度不超过log U,可存入一个字)。哈希表支持常数时间的成员查询。

后继查询算法

查找x的后继:

  1. 找到最低共同祖先(LCA):找到x与集合中任意元素共享的最长前缀对应的节点P。这等价于在Trie中查找x的最低祖先节点。我们通过对x的前缀进行二分查找来实现:
    • 检查x的前log U/2位对应的节点是否存在(查询中间层的哈希表)。
    • 如果存在,则在后半部分位中递归查找更长的前缀;如果不存在,则在前半部分位中递归查找更短的前缀。
    • 二分查找共需O(log log U)步,每步一次常数时间的哈希表查询。
  2. 确定后继:找到节点P后:
    • 如果P没有左孩子(意味着x如果存在,应在左子树,但左子树为空),则P的“最大左叶子”指针实际上指向右子树的最小叶子,这就是x的后继。
    • 如果P没有右孩子(意味着x如果存在,应在右子树,但右子树为空),则P的“最小右叶子”指针指向左子树的最大叶子,即x的前驱。通过叶子链表,前驱节点的next指针即给出后继。

因此,后继查询时间为 O(log log U)。成员查询可通过查询叶子层哈希表在O(1)时间内完成。

不足

X-fast Trie存在两个主要缺点:

  1. 空间复杂度:仍然为O(n log U),因为每个元素最多贡献log U个祖先节点。
  2. 更新操作:插入和删除需要更新从叶子到根路径上的所有节点,以及相关的指针和哈希表,耗时 O(log U)

总结

本节课我们一起学习了两种用于整数集合的先进数据结构:

  1. van Emde Boas树:通过递归分割全域和显式存储最小/最大值,实现了 O(log log U) 时间的所有操作,但需要 O(U) 空间。
  2. X-fast Trie:基于二叉Trie并辅以层级哈希表和增强指针,实现了 O(log log U) 时间的后继查询和O(1)时间的成员查询,但需要 O(n log U) 空间,且更新操作较慢,为O(log U)。

这两种结构都展示了通过操作整数的位表示,可以突破基于比较的模型下界,获得指数级加速。然而,它们的空间复杂度仍然与全域大小U相关。在下节课中,我们将介绍间接技术,以将这些结构的空间降低到O(n),同时保持高效的查询时间。

019:Y-Fast Tries 与 Fusion Trees

在本节课中,我们将学习两种用于整数集合上高效前驱/后继查询的高级数据结构:Y-Fast Tries 和 Fusion Trees。我们将了解它们如何利用整数的位表示来超越传统对数时间的界限。

课程管理公告

课程网页上链接了一个表格,用于注册项目小组。请在周一前填写此表格。

我计划在下周二发布初步的演示日程安排,因为第一次演示将在一周半后开始。我希望至少能提前一周通知大家进行演示。

关于演示的预期:演示时长约为15分钟,大约对应10张幻灯片。演示内容应是项目进展报告,而非最终完成的描述。在演示时,你应该已经明确了目标问题,并能简要介绍该问题的现有技术、相关解决方案,以及你的初步计划。如果已有初步观察结果或已排除某些思路,也可以进行分享。

准备演示时,请制作幻灯片。建议提前练习并计时,以确保内容精炼。在正式演示时,请放慢语速。

演示日程计划:如果每次演示能控制在15分钟内,我们可以在三个讲座日内完成所有演示。如果时间紧张,可能会使用阅读日作为备用。调查表会询问团队在阅读日是否绝对无法进行演示。

回顾:X-Fast Tries

上一节我们介绍了X-Fast Tries。它是一种用于有序字典的数据结构,利用了整数在底层由比特表示的特性。

X-Fast Tries可以在 O(log log U) 时间内完成前驱/后继查询,在 O(log U) 时间内完成插入和删除操作,使用 O(n log U) 空间。其中,U是全集的大小,n是集合中元素的数量。

其核心思想是构建一个覆盖全集的二叉Trie树,但只保留包含集合中元素的节点。每一层使用一个哈希表来记录该层存在的节点。O(log log U) 的查询时间来源于在Trie树的 O(log U) 个层级上进行二分搜索,以找到查询值Q与集合中某个元素X共享的最长前缀。

引入:Y-Fast Tries

X-Fast Tries在空间效率上存在不足。本节中,我们来看看Y-Fast Tries,它通过间接(Indirection)技术改善了空间和更新时间的效率。

Y-Fast Tries可以在 O(log log U) 时间内完成查询和摊还的更新操作,同时仅使用 O(n) 空间。其核心思想是将数据集分块。

数据结构构建

以下是构建Y-Fast Trie的步骤:

  1. 分块:将有序集合S分成大约 n / log U 个块。每个块的大小在 (1/4) log U4 log U 之间。这种范围是为了给插入删除操作留出缓冲空间,便于后续的摊还分析。
  2. 选择代表元:从每个块S_i中选择一个代表元y_i。
  3. 构建上层结构:为所有代表元 {y_1, y_2, ..., y_k} 构建一个X-Fast Trie。
  4. 构建下层结构:为每个块S_i构建一个平衡二叉搜索树(如AVL树、红黑树)。

查询操作

要查询Q的前驱:

  1. 在X-Fast Trie中查询Q的前驱和后继代表元。这需要 O(log log U) 时间。
  2. 这确定了Q可能位于的两个块(前驱代表元和后继代表元所在的块,它们可能相同)。
  3. 在这两个块的二叉搜索树中进行搜索。由于每个树大小约为 O(log U),此步骤也需要 O(log log U) 时间。

因此,总查询时间为 O(log log U)

插入操作

以下是插入一个新元素Q的步骤:

  1. 查询Q应属的块S_i。需要 O(log log U) 时间。
  2. 将Q插入块S_i的二叉搜索树中。需要 O(log log U) 时间。
  3. 如果插入后块S_i的大小超过 4 log U,则将其分裂为两个大小各约 2 log U 的块。
  4. 需要更新X-Fast Trie:删除旧代表元y_i,并插入两个新块的代表元。此操作需要 O(log U) 时间。

摊还分析:分裂操作代价较高,但分裂后,每个新块需要至少再进行 Ω(log U) 次插入才会再次触发分裂。因此,可以将分裂的代价摊还到之前的多次插入操作中,使得每次插入的摊还时间仍为 O(log log U)

删除与空间分析

删除操作类似,涉及合并过小的块。空间方面,X-Fast Trie存储 n / log U 个元素,占用 O(n) 空间。所有二叉搜索树总共存储n个元素,也占用 O(n) 空间。因此总空间为 O(n)

深入:Fusion Trees

Y-Fast Tries 需要预知全集大小U并使用随机化哈希。Fusion Trees 则提供了一种确定性的解决方案,在特定模型下实现了更快的查询。

Fusion Trees 支持在 O(log n / log w) 时间内进行前驱/后继查询,使用 O(n) 空间,且是确定性的。这里,w是机器字长(每个元素最多w比特),n是元素个数。它工作在字RAM模型上,支持算术运算、位运算和移位操作。

核心思想:高位压缩与字级并行

Fusion Tree 是一棵B树,其分支因子B约为 w^(1/5)。每个节点存储大约B个键(w比特整数)。关键创新在于“Sketch”(概要)技术:

  1. 构建Sketch:考虑存储在该节点中的所有键构成的二叉Trie。只保留那些在Trie中实际产生分支的比特位。由于只有B个键,这样的分支位最多有B-1个。
  2. 压缩存储:每个键的Sketch仅由这些关键比特位组成,因此长度远小于w。例如,B = w^(1/5),则每个Sketch长度约为 w^(4/5)。可以将B个这样的Sketch打包进一个w比特的字中。
  3. 并行比较:查询时,计算查询值Q的Sketch。通过巧妙的字级并行操作(如一次减法配合位掩码),可以在常数时间内确定Q的Sketch位于节点中哪两个Sketch之间,从而决定进入哪个子树。

查询过程与挑战

直接比较Sketch的顺序并不能完全等价于比较原始键的顺序。因此,Fusion Tree引入了一个“去Sketch化”的步骤:

  1. 根据Q与相邻键在Trie中的最长公共前缀,构造一个辅助查询值 Q_twiddle
  2. Q_twiddle 的性质保证了:基于Sketch比较为 Q_twiddle 找到的前驱/后继,就是原始Q的正确前驱/后继。
  3. 在节点内,通过将 Q_twiddle 的Sketch与节点中打包的Sketch字进行特殊的并行比较,可以在常数时间内确定下一步要进入的子节点。

技术细节与权衡

  • 为什么B是w^(1/5)?这是为了确保Sketch的长度、打包后的字长以及后续并行比较中所需的各类常数时间位操作(如计算前导1的个数)都能在w比特的字内完成。这是一个平衡计算复杂度和存储压缩的技术选择。
  • 字级并行:通过乘法等操作,可以模拟出一些在标准C指令集中不直接支持,但在硬件中易于实现的位操作(如find most significant set bit)。这使得算法在理论上仅用字RAM的标准操作就能实现常数时间的节点内搜索。

总结

本节课我们一起学习了两种突破比较模型下Ω(log n)下限的整数集合查询数据结构。

  • Y-Fast Tries 通过结合X-Fast Tries和分块思想,以随机化为代价,实现了 O(log log U) 的查询和摊还更新时间,以及线性的空间复杂度。
  • Fusion Trees 则利用位压缩和字级并行,以确定性的方式实现了 O(log n / log w) 的查询时间。它代表了在字RAM模型下对前驱查询问题的深刻理解,尽管其实现细节较为复杂。

这两种结构都展示了如何通过深入利用数据的底层表示(比特串)和机器的计算模型(字操作)来设计出异常高效的数据结构。

020:Fusion Tree 详解 🧠

在本节课中,我们将深入探讨 Fredman 和 Willard 提出的 Fusion Tree 数据结构。这是一种理论上的数据结构,旨在证明对于存储在单个机器字内的整数,可以在不使用随机化的情况下,实现比二叉搜索树更快的操作。具体来说,我们将学习如何实现 O(log n / log W) 时间复杂度的前驱/后继查询、插入和删除操作,其中 W 是机器字的位数。


核心概念与高级概述

Fusion Tree 本质上是一个 B 树,其分支因子 B 大约为 W^(1/5)。每个节点存储 k 个枢轴值(pivot values)以及这些值的 草图(sketch),该草图可以压缩存储在一个机器字内。

上一节我们介绍了 Fusion Tree 的基本目标,本节中我们来看看其核心工作原理。

草图(Sketch)的定义与作用

草图是通过构建一个 二进制字典树(Binary Trie) 来定义的。这棵树的深度为 W,代表了所有可能的字。但我们只保留那些对当前存储的子集有意义的节点(即包含至少一个枢轴值后代的节点)。我们将树中发生分支(即一个节点有多个子节点)的层级称为 分裂层级(splitting level)

由于只有 k 个标记的叶子节点,因此最多只有 k-1 个分裂节点。这意味着我们只需要关注这些分裂层级上的比特位。由此构造出的草图具有一个关键性质:草图的排序顺序与原始枢轴值的排序顺序一致

在节点的压缩表示中,我们交替存储单个的“1”比特位和各个枢轴值的草图。这样,对于一个查询值 Q,我们可以计算其草图,并通过一次并行比较,在常数时间内确定其在草图排序中的位置,从而决定在 B 树中应走向哪个子节点。

面临的挑战

然而,要实现上述过程,我们需要解决几个关键问题:

  1. 草图计算:如何在常数时间内计算查询值 Q 的草图?难点在于将分散在字中的相关比特位“融合”到一个连续的块中。
  2. 顺序一致性:根据草图比较找到的前驱和后继,并不直接对应原始值的前驱和后继,需要额外的处理。
  3. 位操作支持:我们需要在常数时间内计算一个二进制串中“1”的个数或找到最高有效位(Most Significant Bit, MSB)。虽然现代处理器可能直接支持,但 Fusion Tree 的原始论文需要在标准操作集内实现它。

解决草图顺序问题 🔄

上一节我们提到了草图比较结果可能不直接对应原始值的问题,本节中我们来看看如何修正这一点。

核心思想是处理查询路径与枢轴值路径发生“隐形”分叉的情况。这种分叉在草图中不可见,因为草图只记录由枢轴值确定的分裂层级。

修正算法如下:

  1. 设查询值为 Q,通过草图比较找到的枢轴值为 x_i
  2. 计算 Qx_i最长公共前缀(Longest Common Prefix, LCP),记为 P
  3. 由于路径在 P 之后分叉(假设 Q 走向左侧),我们可以构造一个新的查询值 P、紧接着的一个“1”比特以及一串“0”组成。这样, 就落在了右子树的最左侧叶节点上。
  4. 由于左子树中不包含任何枢轴值,因此 在枢轴值中的后继,就是原始查询值 Q 的后继。此时,草图的比较行为将恢复正常。

计算最长公共前缀等价于找到 Q ⊕ x_i(异或)的 最高有效位(MSB)。这再次凸显了 MSB 操作的重要性。


近似草图计算与乘法技巧 ✖️

上一节我们了解了草图的理论定义,本节中我们来看看如何在标准操作下实际计算一个可用的近似草图。

理想的草图是将 k 个相关比特位 b_0, b_1, ..., b_{k-1} 压缩到一个连续的 k 比特块中。公式表示为:
sketch_ideal(x) = Σ_{i=0}^{k-1} (2^i * x_{b_i})
其中 x_{b_i} 表示 x 在第 b_i 位上的比特值。

由于无法完美实现,Fredman 和 Willard 采用了乘法技巧来构造一个更大的近似草图。

核心引理:我们可以选择一个乘数 M,使得对于所有相关比特位 b_iM 中的比特位 m_j,满足以下性质:

  1. 所有和 b_i + m_j 互不相同(无碰撞)。
  2. b_i + m_i 按索引 i 有序排列。
  3. 最大和与最小和之差为 O(k⁴)

这意味着,通过计算 x * M,我们可以将 k 个相关比特位“扩散”到一个长度约为 k⁴ 比特的窗口内,并且它们保持正确的相对顺序。然后,我们可以通过掩码和移位操作,将这个窗口提取出来作为近似草图。

由于每个草图现在需要 O(k⁴) 比特,而一个节点需要存储 k 个这样的草图,因此总共需要 O(k⁵) 比特。为了能将这些草图打包进一个 W 比特的字中,我们令 k⁵ ≈ W,即 k ≈ W^(1/5)。这就是 B 树分支因子取 W^(1/5) 次根的由来。

一旦在预处理阶段计算出乘数 M 和相应的掩码,对于任何查询值 x,计算其近似草图只需一次乘法、一次按位与和一次移位,是常数时间操作。


常数时间最高有效位(MSB)计算 🎯

无论是修正查询值(需要 LCP/MSB),还是解释并行比较的结果(需要找到一串“1”的开始位置),我们都需要在常数时间内找到最高有效位。本节介绍 Fredman-Willard 的 MSB 算法。

该算法分两步走:

  1. 找到包含最高有效位的块:将 W 比特的字 X 划分为 √W 个块,每块 √W 比特。目标是找到第一个非零块。
  2. 在块内找到最高有效位:对找到的 √W 比特块,使用类似的方法或并行比较快速定位 MSB。

步骤一详解(找非零块):
以下是关键操作序列,通过乘法和位操作技巧实现:

  • 定义掩码 F,在每个块的最高位设置为1。
  • 计算 Y = X & F,获取每个块的最高位。
  • 计算 Z = F - (X ^ Y),并通过与 F 的掩码和异或操作,得到一个指示哪些块非零的位图(非零块对应1,零块对应0)。
  • 由于这些“1”比特均匀间隔 √W 位,我们可以使用之前介绍的乘法压缩技巧,将这个位图压缩成一个 √W 比特的整数。再通过乘法计数技巧,即可得到最高非零块的索引 C

步骤二详解(块内找MSB):

  • 根据索引 C,可以生成一个掩码,从原始字 X 中提取出目标块。
  • 对于这个 √W 比特的块,我们可以将其比特位均匀扩散,然后使用乘法计数技巧;或者,更直接地,利用 √W 的大小,将其与所有 2^0, 2^1, ..., 2^{√W-1} 进行并行比较(原理与 B 树节点内的比较相同)。比较结果会产生一串由“0”变“1”的位模式,对此模式使用计数技巧即可得到块内的位索引 D

最终,最高有效位的全局索引为:C * √W + D

整个算法由一系列固定的乘法、移位、加减和位运算组成,因此是常数时间复杂度。尽管常数因子很大,但在理论上是成立的。


总结与展望 📚

本节课中我们一起学习了 Fusion Tree 的核心细节:

  1. 高级结构:Fusion Tree 是一个分支因子 B = Θ(W^(1/5)) 的 B 树,通过在节点内存储枢轴值的“草图”来实现快速导航。
  2. 草图技术:使用基于分裂层级的草图来压缩信息,并通过乘法技巧在常数时间内计算近似草图,解决了比特位压缩问题。
  3. 查询修正:通过构造 并利用最长公共前缀(LCP) 来解决草图排序与真实排序可能不一致的问题,这依赖于 MSB 计算。
  4. 位操作基石:详细剖析了如何在仅使用加法、乘法、移位和位运算的“AC⁰”操作集内,实现常数时间的最高有效位(MSB) 查找,这是整个结构能高效运行的关键。

将这些组件组合起来,我们就能在 Fusion Tree 的每个 B 树节点上以常数时间完成比较和导航,从而使所有字典操作的时间复杂度达到 O(log n / log W)

尽管 Fusion Tree 的常数因子很大,更多是理论上的突破,但它启发了后续许多更高效的整数排序算法(如 O(n log log n) 的排序算法)和数据结构。它展示了巧妙运用机器字级并行和位操作所能带来的巨大潜力。

021:快速整数排序 — 范围缩减与签名排序

在本节课中,我们将要学习两种针对整数集合的快速排序算法:Kirkpatrick-Reisch 的范围缩减排序和 Albers-Hagerup 的签名排序。我们将探讨如何利用整数的位表示和机器的字长特性,实现比传统基于比较的排序更快的算法。

范围缩减排序 (Kirkpatrick-Reisch)

上一节我们介绍了整数排序的基本背景,本节中我们来看看 Kirkpatrick-Reisch 算法。该算法的核心思想是通过递归地将长整数键拆分为更短的片段,从而减少排序问题的规模。

算法概述

该算法旨在排序 N 个 B 位的整数键。其运行时间由递归式 T(N, B) = T(N, B/2) + O(N) 描述,基础情况是当 B = O(log N) 时,我们使用基数排序。

算法步骤

以下是该算法的具体步骤:

  1. 拆分键值:将每个 B 位的键拆分为两个 B/2 位的块(高位块和低位块)。
  2. 构建字典树:基于这些块构建一个深度为 2 的字典树。根节点的子节点对应不同的高位块,每个子节点下又有子节点对应不同的低位块。为了在线性时间和空间内构建此树,需要使用哈希表或位图技巧来高效检测重复的块。
  3. 提取最小子节点:对于树中的每个内部节点,找到其最小的子节点(根据块值)。将所有“非最小子节点”的节点收集起来。可以证明,这样的节点恰好有 N 个。
  4. 递归排序:递归地对这 N 个“非最小子节点”进行排序。此时,每个节点由其 B/2 位的块值标识,同时需要附带其父节点的信息,以便后续重组。
  5. 重组与遍历:根据递归排序的结果,对每个内部节点的子节点列表进行重排,使其符合排序顺序。最后,对树进行中序遍历,即可得到完全排序的原始键序列。

性能分析

通过递归,每次都将键的位数减半。经过 O(log (W/log N)) 层递归后,键的长度将降至 O(log N),此时使用基数排序可在 O(N) 时间内完成。因此,总时间复杂度为 O(N log (W/log N))。当字长 W = O(log N) 时,该算法为线性时间;当 W 较大时,性能优于传统的 O(N log N) 比较排序。


签名排序 (Signature Sort)

上一节我们学习了通过递归减半位数进行排序的方法,本节中我们来看看一种更高效的算法——签名排序。它通过哈希将键大幅压缩,并利用打包排序技术,在字长足够大时实现线性时间排序。

算法前提

签名排序在以下条件下运行:字长 W 显著大于键长 B,具体来说,要求 W = Ω(B log² N)。其核心是利用多余的字长空间并行处理多个键。

算法核心:打包排序

首先,我们介绍一个关键子过程:打包排序。假设每个键的位数 B 很小,满足 B ≤ W / (log N log log N)

以下是打包排序的步骤:

  1. 打包:将 K = Θ(log N / log log N) 个键打包进一个字中。
  2. 归并排序:对打包后的字数组进行归并排序。但这里的基础情况是当子数组能放入一个字时(即包含 K 个键)。
  3. 高效合并:合并两个已排序的打包字时,使用 Batcher 的双调合并网络。这是一个深度为 O(log K) 的比较交换网络,由于所有比较交换操作都在单个字内,可以通过位并行操作在常数时间内模拟一步网络深度。因此,合并两个打包字仅需 O(log K) 时间。

通过用 O(log K) 时间的合并操作替换标准归并排序中 O(K) 时间的合并,整体运行时间减少为 O(N log N * (log K / K))。代入 K 的值,可得时间复杂度为 O(N)

完整签名排序算法

现在,我们将打包排序与范围缩减结合:

  1. 哈希压缩:将每个 B 位的键分割成 L = O(log^ε N) 个块。对每个块应用一个随机哈希函数(如通过乘法取模),将其映射为一个 O(log N) 位的“签名”。将所有这些签名拼接起来,形成一个长度约为 O(L log N) = O(log^{1+ε} N) 位的压缩键。通过精心设计的哈希,可以在常数时间内并行计算所有块的签名。
  2. 排序签名:现在我们需要排序的是这些更短的压缩键。由于其长度已降至 O(log^{1+ε} N) 位,并且我们有 W = Ω(B log² N) 的假设,可以满足打包排序的条件。因此,使用打包排序在 O(N) 时间内对这些签名进行排序。
  3. 构建压缩字典树:根据排序后的签名序列,构建一个深度为 L 的压缩字典树。压缩意味着省略只有一个子节点的内部节点,确保树中节点数为 O(N)。可以按顺序插入签名在线性时间内构建此树。
  4. 递归排序边:字典树中的每条边对应原始键的一个块。我们现在需要根据这些块的原始值(而非其签名),对每个节点的子节点(即出边)进行排序。为此,为每条边创建一个元组(父节点ID,原始块值,原始子节点索引)。大约有 N 个这样的元组。
  5. 递归调用:递归地对这些元组进行排序。此时,每个元组的位数是原始块长度 B/L 加上 O(log N)。通过设置参数,经过常数级(例如 1/ε)的递归后,键长将缩减到足以直接应用打包排序。
  6. 重组与输出:根据递归排序的结果,对每个节点的子节点列表进行重排。最后,对最终的字典树进行中序遍历,输出原始键的排序结果。

性能总结

签名排序通过哈希大幅缩减键的表示长度,并利用打包排序处理短键,在 W = Ω(B log² N) 的条件下,实现了 O(N) 的线性时间复杂度。这是目前已知限制最少的线性时间整数排序算法。


算法对比与开放问题

本节课中我们一起学习了两种高效的整数排序算法。

  • Kirkpatrick-Reisch 范围缩减排序:通过递归减半键的位数工作,时间复杂度为 O(N log (W/log N))。它在字长 W 接近 log N 时是线性的,但在 W 较大时性能会下降。
  • Albers-Hagerup 签名排序:通过哈希和打包排序工作,在字长 W 足够大(W = Ω(B log² N))时,实现了 O(N) 的线性时间复杂度。

目前存在一个有趣的开放问题:对于字长 W 满足 ω(log N) < W < o(log² N) 的情况,是否存在线性时间的整数排序算法?这仍然是理论计算机科学中一个未解决的问题。

总而言之,利用整数键的位级特性和机器的字操作能力,我们可以设计出远超传统比较排序算法性能的专用排序算法。

022:超宽字RAM模型与动态有序字典

在本节课中,我们将学习一种扩展的字RAM模型——超宽字RAM模型,并探讨如何在该模型上实现支持常数时间查询的动态有序字典(前驱/后继查询、插入和删除)。我们将从模型定义开始,逐步构建一个支持并行成员查询的哈希表,并最终将其与压缩字典树结合,实现高效的前驱搜索。

模型定义 🧠

上一节我们介绍了课程背景,本节中我们来看看超宽字RAM模型的具体定义。

超宽字RAM模型是标准字RAM模型的扩展。在标准模型中,内存由一系列W位的字组成,支持常数时间的常规操作(如加法、减法、乘法、位移、布尔运算等)。超宽字RAM在此基础上引入了称为 U字 的寄存器。

  • U字:每个U字包含 位。你可以将其视为W个标准字拼接在一起。
  • 数量:内存中主要是标准字,但允许使用少量(常数个)U字寄存器。
  • 核心操作:除了标准运算外,模型支持关键的新操作——分散读写

分散读写操作

在标准字RAM中,给定一个W位的地址,可以加载或存储该地址处的W位字。U字不能直接用作地址(地址空间仍为2^W)。分散读写操作允许你同时处理多个地址。

  • 分散读:给定一个U字,其中每个分量(W位)都是一个地址,该操作能并行地读取所有这些地址的内容,并将结果(W个W位字)打包成一个新的U字返回。
    • 形式化描述:给定U字 A,其中 A[i] 是地址,操作返回U字 R,其中 R[i] = memory[A[i]]
  • 分散写:给定两个U字,一个包含地址,一个包含要写入的数据,该操作能并行地将数据写入对应的地址。
    • 形式化描述:给定地址U字 A 和数据U字 D,操作执行 memory[A[i]] = D[i] 对于所有i。

这些操作在常数时间内完成。该模型旨在模拟具有大规模向量处理能力的硬件(例如,某些架构支持1024到4096位宽的向量寄存器)。

其他支持的运算

基于模型的基本运算(特别是乘法),我们可以构建一些有用的并行操作:

  1. 压缩:给定U字 X,提取每个分量字的最高位(符号位),组成一个W位的标准字 x
    • 公式:x[i] = leftmost_bit(X[i])
    • 这可以通过U字乘法实现,类似于融合树中的技巧。
  2. 分量算术与比较
    • 分量加法Z[i] = (X[i] + Y[i]) mod 2^W
    • 分量比较:生成一个U字 C,其中 C[i] 是一个W位字,如果 X[i] < Y[i] 则其最低位为1,否则为0。也可以压缩成一个表示比较结果的位向量。
  3. 分量乘法:处理稍复杂,因为两个W位数的乘积是2W位。该操作通常假设输入U字的奇数分量为0,然后计算偶数分量的乘积,并将结果的低W位和高W位分别存入输出U字的连续分量中。
    • 形式化描述:设 X[2i]Y[2i] 为操作数,Z[2i] 存储乘积模 2^W(低W位),Z[2i+1] 存储乘积除以 2^W(高W位)。

有了这个强大的计算模型,我们就能探索比标准字RAM更高效的算法。

并行成员查询哈希表 🗂️

在标准字RAM上,动态前驱搜索的最佳时间复杂度约为 O(log log N)。超宽字RAM模型的目标是实现常数时间的操作。我们首先忽略“顺序”,解决一个更基础的问题:如何构建一个支持并行成员查询的字典。

我们希望维护一个包含n个W位字的集合S,除了支持插入和删除,还能处理以下查询:

  • 并行成员查询:输入一个U字 X(包含W个待查关键字),返回一个U字 R,其中 R[i] 指示 X[i] 是否在集合S中。

我们的目标是实现 O(1) 的查询时间,以及摊销期望为 O(1) 的更新时间,空间复杂度为 O(n + W)。这比传统哈希表(需要O(W)时间进行W次独立查询)快了一个W因子。

数据结构基础:两级完美哈希

实现并行查询的核心是一个经典的两级哈希表结构(Fredman, Komlós, Szemerédi, 1984)。

以下是该结构的关键组件:

  1. 一级哈希表:大小为 n。使用哈希函数 h₁ 将关键字映射到桶中。
  2. 二级哈希表:每个一级桶 i 关联一个独立的二级哈希表,其大小约为 nᵢ²,其中 nᵢ 是散列到桶i的关键字数量。二级表使用哈希函数 h₂ᵢ
  3. 无冲突保证:通过精心选择哈希函数族(如全域哈希),可以高概率保证每个二级哈希表内部没有冲突。这使得查询只需计算两次哈希值即可定位元素,实现常数查找时间。
  4. 空间:二级表大小平方和的总期望是线性的,即 O(n)

为了支持动态操作,我们采用一种特定的、结构良好的哈希函数族:乘数移位哈希

  • 对于一级哈希:h₁(x) = ((a * x) mod 2^W) >> (W - log n),其中a是随机奇数。
  • 对于二级哈希(每个桶i):h₂ᵢ(x) = ((aᵢ * x) mod 2^W) >> (W - log nᵢ),其中aᵢ是随机奇数。

并行查询过程

现在,我们利用超宽字RAM的并行能力,一次性计算W个关键字的哈希值并进行查询。

以下是并行查询的步骤:

  1. 计算一级哈希值:给定输入U字 X,我们需要计算包含所有 h₁(X[k]) 的U字 H。这涉及 A * X 的分量乘法(A是每个分量都是a的U字),然后进行掩码和移位操作。通过处理奇偶索引、使用乘法模拟移位等技巧,可以在常数时间内完成。
  2. 获取二级哈希函数盐值:根据一级哈希值 H,我们需要为每个关键字获取对应的二级哈希盐值 aᵢ。这些盐值存储在一个大小为n的数组(一级表)中。我们使用分散读操作,以 H 作为地址向量,一次性读取所有对应的 aᵢ,得到U字 A'
  3. 计算二级哈希值:计算 A' * X 的分量乘法以获得二级哈希值。这里面临的挑战是每个分量需要移位的量(W - log nᵢ)可能不同。解决方案是将其转化为先乘以不同的2的幂(右移不同量),再进行一次统一的左移。
  4. 执行并行查找:现在,对于每个关键字,我们有了其所在的一级桶索引和二级桶内的位置。通过另一个分散读操作,我们可以从所有对应的二级哈希表中并行读取内容。
  5. 生成结果:将读取到的内容与原始关键字 X 进行分量比较。如果相等,则说明关键字存在。如果需要返回值,可以再进行一次分散读来获取存储的数据。

通过这种方式,我们实现了常数时间的并行成员查询。插入和删除操作需要更细致的处理(如重建哈希表、处理散列冲突),但核心思想是利用相同的并行原语和哈希函数特性,在摊销期望常数时间内完成。

构建动态有序字典:X-Fast Trie 变种 🔍

仅有并行哈希表无法支持前驱查询。为了引入顺序信息,我们转向一种类似 Willard 的 X-Fast Trie 的数据结构,论文中称之为 X-Fast Trie(拼写略有不同)。

我们假设关键字是 W-1 位(可通过简单变换处理W位情况)。数据结构的高层设计是一个压缩字典树

压缩字典树

压缩字典树是标准二叉字典树的压缩版本:

  • 只存储具有多个子节点的内部节点叶节点根节点
  • 树边不再标记为单个比特,而是标记为一个比特串(即从父节点到子节点路径上的比特序列)。
  • 每个节点 v 关联两个值:min(v)max(v),分别表示以该节点为根的子树中的最小和最大叶节点关键字。

数据结构由两部分组成:

  1. 双向链表:将所有叶节点(即集合S中的所有关键字)按排序顺序链接起来。这便于在找到近似位置后快速找到前驱或后继。
  2. 并行哈希表:存储所有压缩树中所有节点的路径标签(填充至W位)。对于每个节点键,哈希表中存储的数据是该节点的 min(v)max(v)

前驱查询算法

查询关键字 x 的前驱过程如下:

  1. 生成所有前缀:构造一个U字,其中包含 x 的所有可能前缀(从长度为0到W-1),并将每个前缀填充或处理为W位的形式。这可以通过复制、掩码等U字操作在常数时间内完成。
  2. 并行前缀查询:使用上节构建的并行成员查询哈希表,一次性查询所有这些前缀是否存在于压缩树的节点哈希表中。返回的结果是一个位向量,指示哪些前缀是树中节点的路径。
  3. 定位最长匹配前缀:从位向量中找出最高位的“1”,它对应于 x 在压缩树中的最长匹配前缀节点,记作节点 p。这本质上是找到了 x 与树中关键字的最长公共前缀所在的节点。
  4. 确定前驱:根据 x 在匹配前缀之后的下一个比特,以及节点 p 的子节点信息,可以确定前驱。
    • 情况分析:前驱要么是 p 的左子树中的最大关键字(p.left.max),要么是 p 的右子树中的最小关键字的前一个节点(在双向链表中)。通过访问节点 p 存储的 min/max 信息以及双向链表,可以在常数时间内完成这一步。

整个查询过程的关键在于第2、3步:利用超宽字RAM的并行能力,一次性检查所有前缀,从而在常数时间内找到最长匹配前缀。一旦找到该节点,剩下的工作只是简单的指针访问和比较。

总结与延伸 🎯

本节课我们一起学习了超宽字RAM模型及其上一个强大的应用:常数时间的动态有序字典。

  • 模型:我们介绍了超宽字RAM,它通过引入U字(W²位)和分散读写操作,扩展了标准字RAM,能够高效模拟向量化并行计算。
  • 基础构件:我们构建了一个支持并行成员查询的哈希表,利用两级完美哈希结构和乘数移位哈希函数,在常数时间内回答W个并行查询,比传统方法快W倍。
  • 有序字典:我们将并行哈希表与压缩字典树的思想结合,设计了X-Fast Trie的变种。通过并行查询关键字的所有前缀,我们能在常数时间内定位到最长匹配前缀节点,进而利用节点信息和双向链表确定前驱。

这个结果突破了标准字RAM上前驱搜索的 Ω(log log N) 下界,展示了更强大的计算模型如何催生更优的算法。

论文还提到了在此模型上的其他结果,例如动态区间最小值查询可以达到 O(log log log N) 的时间复杂度。超宽字RAM模型仍然是一个富有潜力的研究领域,为许多经典问题提供了新的优化视角。

posted @ 2026-03-29 09:31  布客飞龙II  阅读(17)  评论(0)    收藏  举报