MIT-6-851-高级数据结构笔记-全-
MIT 6.851 高级数据结构笔记(全)
001:持久化数据结构

概述
在本节课中,我们将学习持久化数据结构。这是一种能够“记住”所有历史版本的数据结构,允许我们查询或修改过去的版本,而不会丢失任何信息。我们将从模型定义开始,逐步探讨不同级别的持久化技术及其实现。
模型定义:指针机
在深入持久化之前,我们需要定义计算模型。本课程中一个重要的主题是计算模型至关重要。我们将使用一种称为指针机的模型。
指针机模型对应于面向对象或结构体编程的思想。它包含一系列节点,每个节点有恒定数量的字段。这些字段可以是指向其他节点的指针,也可以是存储的数据(例如整数)。计算操作包括创建节点、查看或设置字段值等。所有操作都通过一个称为“根”的节点开始进行。
指针机模型示例代码结构:
struct Node {
int data;
Node* field1;
Node* field2;
// ... 其他字段
};
Node* root; // 根节点
持久化简介
持久化数据结构的核心思想是记住一切。具体来说,我们希望保留数据结构在每次更新操作后的所有版本。每次更新操作都会基于一个指定版本创建一个新版本,而不会破坏旧版本。然后,所有数据结构的操作(查询或更新)都是相对于某个指定版本进行的。
持久化有四个级别:
- 部分持久化:只能更新最新的版本,版本呈线性顺序。
- 完全持久化:可以更新任何版本,版本形成一个树形结构。
- 汇合持久化:可以合并两个版本来创建新版本,版本形成一个有向无环图。
- 函数式持久化:在纯函数式世界中,不允许修改任何节点,只能创建新节点。
上一节我们介绍了计算模型,本节中我们来看看持久化的具体目标和分类。
部分持久化
部分持久化是最容易实现的级别。在此模型中,版本是线性排列的,我们只能更新最新的版本,但可以查询任何旧版本。
定理与实现思路
对于任何满足“任何节点的入度有常数上界”的指针机数据结构,都可以被转化为部分持久化版本,且仅需付出常数倍的摊销时间开销和每次修改的常数附加空间。
实现基于两个核心思想:
- 存储反向指针:每个节点记录所有指向它的指针,由于入度有界,这只需要常数空间。
- 存储修改记录:每个节点附带一个“修改列表”,用于记录对该节点字段的更改,而不是直接覆盖原值。每个修改记录包含版本号、被修改的字段以及新值。每个节点只存储常数数量的修改记录。
操作细节
- 读取字段:给定版本V,查看节点本身的字段值,然后按版本顺序检查修改列表,应用所有版本号≤V的修改,最后生效的修改即决定了该字段在版本V的值。由于修改列表大小为常数,此操作耗时O(1)。
- 修改字段:
- 简单情况:如果节点的修改列表未满,则直接添加一条新的修改记录。
- 复杂情况:如果节点的修改列表已满,则创建一个新节点。将原节点修改列表中的所有修改应用到新节点的字段上,并清空新节点的修改列表。然后,需要将所有指向原节点的指针(在最新版本中)更新为指向新节点。这通过反向指针找到这些指针的位置,并递归地更新它们(这本身也是一个字段修改操作)。
摊销分析
使用势能法进行分析。定义势能为所有“存活”(属于最新版本)节点中已占用修改槽位的数量乘以某个常数C。
- 添加修改记录时,势能增加C。
- 当节点已满需要创建新节点时,原节点的修改列表被清空(势能大幅下降),这为递归更新指针的操作提供了“信用”。
通过精心设置常数,可以证明每次修改的摊销时间复杂度为O(1)。
完全持久化
完全持久化允许更新任何版本,从而形成一个版本树。这带来了两个新挑战:
- 反向指针需要在所有版本中维护。
- 版本不再是线性序,而是树形结构。
处理版本树:线性化
为了高效处理版本间的祖先关系查询,我们将版本树进行线性化。对版本树进行深度优先遍历,为每个版本V记录其开始访问时间B(V)和结束访问时间E(V)。这样,版本U是版本V的祖先,当且仅当区间[B(U), E(U)]包含区间[B(V), E(V)]。
我们需要动态维护这个线性顺序。这可以通过一个顺序维护数据结构来实现,该数据结构支持:
insert(x, y): 在项y之前或之后插入新项x。order(x, y): 判断项x和y在顺序中的前后关系。
这两个操作都可以在O(1)时间内完成。利用这个数据结构,我们可以动态维护所有B(V)和E(V)的顺序,从而在O(1)时间内判断任意两个版本的祖先关系。
读取与修改的调整
- 读取字段:给定版本V,我们需要找到所有适用于V的修改(即修改发生的版本是V的祖先)。利用线性化后的顺序,我们可以通过常数次祖先关系检查来找到决定字段值的最近一次修改。
- 修改字段:基本策略与部分持久化类似,但当节点修改列表已满时,策略有所不同。我们不能简单地创建新节点并丢弃旧节点,因为旧节点可能在未来被再次修改。相反,我们需要将节点的修改列表(对应一个版本子树)分割成大致平衡的两部分,并创建一个新节点来承担其中一部分。这涉及到更复杂的树分割操作和指针更新,但通过类似的势能分析(势能定义为所有节点中空闲修改槽位总数的负值),仍然可以证明每次修改的摊销时间复杂度为O(1)。
汇合持久化与函数式持久化
汇合持久化允许合并两个版本,这使得数据结构的规模可能指数级增长(例如,反复将数据结构与自身连接)。因此,实现高效的汇合持久化更加困难。
已知结果
- 通用转换:Fiat和Kaplan在2003年提出了一种通用转换方法,其时间与空间开销是
O(log U + 有效深度)的乘积,其中U是操作次数,有效深度是版本DAG的某种度量。在最坏情况下,这可能达到线性开销O(U)。 - 受限情况:在“不相交合并”的假设下(即被合并的两个版本不共享节点),可以获得更好的
O(log U)开销。 - 下界:存在需要Ω(有效深度)倍空间开销的例子,表明在某些情况下线性开销可能是不可避免的(如果只考虑更新操作的成本)。
函数式持久化
在函数式持久化中,任何节点都不能被修改,只能创建新节点。这自然实现了完全持久化甚至汇合持久化。一些数据结构可以高效地以函数式方式实现:
- 平衡二叉搜索树:通过“路径复制”技术,在修改时复制从根到目标节点路径上的所有节点,耗时O(log n)。
- 双端队列:支持在两端进行插入删除,以及常数时间的连接操作。
- 具有固定拓扑结构的树:例如版本控制系统中的目录树,可以支持高效的子树复制和合并。
函数式实现有时会比允许修改的实现慢一个对数因子,这是目前已知的最坏情况分离。

总结
本节课我们一起学习了持久化数据结构。我们从指针机模型出发,详细探讨了部分持久化和完全持久化的通用转换技术,它们都能在常数摊销开销内实现。我们还简要介绍了更具挑战性的汇合持久化和函数式持久化的现状与结果。持久化数据结构是时间旅行概念在计算机科学中的精彩体现,它为需要访问历史状态的应用提供了强大的基础。
002:回溯数据结构 🕰️

在本节课中,我们将学习一种名为“回溯数据结构”的概念,它允许我们“穿越时间”,在过去的时间点插入或删除操作,并观察这些更改如何影响数据结构的当前状态。
概述
回溯数据结构让我们能够修改一个操作序列的历史。想象一条时间线,上面记录了所有对数据结构进行的更新操作(如插入、删除)。通常,我们只能在时间线的末尾(即“现在”)追加新操作。而回溯性允许我们回到过去的任意时间点,插入或删除一个操作,然后“快进”到现在,查看所有后续操作因这个改变而产生的连锁反应结果。这类似于电影《回到未来》中的时间旅行。
部分回溯性与简单情况
首先,我们定义“部分回溯性”。在这种模型中,我们可以在时间线的任意位置插入或删除更新操作,但查询操作只能在时间线的末尾(即“现在”)进行。
如果数据结构的更新操作满足以下两个性质,那么实现部分回溯性将变得非常简单:
- 交换性:操作顺序不影响最终结果。即
操作X后跟操作Y的结果,与操作Y后跟操作X的结果相同。 - 可逆性:每个操作
X都有一个对应的逆操作X⁻¹,使得执行X后再执行X⁻¹等同于什么都没做。
公式:若更新可交换且可逆,则:
- 在时间
T回溯插入一个操作X,等价于在现在(时间线末尾)执行X。 - 在时间
T回溯删除一个操作X,等价于在现在执行X⁻¹。
例子:
- 哈希表:如果键是唯一的,那么插入和删除操作是可交换且互为逆操作。
- 数组(仅加法):如果操作是“给数组元素加 Δ”,那么这些加法操作是可交换的,减法则是其逆操作。
上一节我们介绍了在简单条件下实现部分回溯性的方法。接下来,我们看看一种更强大但也更复杂的回溯性。
完全回溯性与可分解搜索问题
“完全回溯性”比部分回溯性更强:它允许在任意时间点进行查询,而不仅仅是现在。实现完全回溯性通常更困难。
然而,对于一类称为“可分解搜索问题”的数据结构,我们可以高效地实现完全回溯性,仅需付出对数级的时间开销。
一个可分解搜索问题需要维护一个对象集合 S,支持插入、删除和某种查询。关键特性在于其查询函数 Q 必须是“可分解”的:
公式:对于任意将集合 S 划分成的两个子集 A 和 B,存在一个可在常数时间内计算的组合函数 F,使得:
Q(S) = F( Q(A), Q(B) )
例子:
- 最近邻搜索:查询距离某个点最近的点。集合
S的最近邻,等于子集A的最近邻和子集B的最近邻中更近的那个。 - 后继查询:在一维线上查找某个值的下一个元素。
- 点定位:后续课程会涉及。
对于可分解搜索问题,我们可以使用一种名为线段树的数据结构来实现完全回溯性。
线段树方法
我们构建一棵基于时间的平衡二叉搜索树。树的每个叶子代表一个时间点,每个内部节点代表一个时间区间。
核心思想:每个数据元素(例如,被插入后又删除的对象)存在于一个连续的时间区间内。我们将这个元素存储在线段树中恰好能覆盖其存在区间的、数量为 O(log n) 的节点里。
操作:
- 回溯更新:当插入或删除一个元素时,我们在线段树中对应的 O(log n) 个节点上,对非回溯版本的底层数据结构执行插入或删除。
- 时间点查询:要查询在某个时间
t的状态,我们从代表时间t的叶子节点向上走到根,并查询路径上所有节点的底层数据结构。利用查询的可分解性,我们可以用函数F将这些部分结果合并,得到时间t的完整查询结果。
这种方法在时间和空间上都引入了 O(log n) 的乘性开销。
我们看到了对于可分解搜索问题,完全回溯性是可行的。那么,对于更一般的数据结构,情况如何呢?
通用方法的下界
不幸的是,对于任意的数据结构,高效的回溯性通常是不可行的。最直观的通用方法是“回滚法”:要修改过去,就先“回滚”到那个时间点,执行更改,然后重新执行(“重放”)之后的所有操作。这种方法需要 O(r) 的时间,其中 r 是从修改点到现在的操作数量。
研究表明,对于某些问题,这种线性的开销在理论上是最优的,无法被显著改进。这类似于一个哲学结论:像《回到未来》那样随意修改历史而不付出重历时间的代价,在计算上是困难的。
一个具体的下界例子是模拟一个简单的两寄存器计算机(寄存器 X 和 Y),支持设置 X、给 Y 加值、计算 X*Y 存入 Y 等操作。通过一系列操作可以计算多项式。如果回溯性地改变早期对 X 的赋值,那么在最坏情况下,重新计算当前 Y 的值需要的时间与多项式次数成正比,即 Ω(n),即使每个原始操作本身是常数时间。
尽管存在这些下界,我们仍然可以在一些重要的、非平凡的数据结构上实现高效的回溯性。
案例研究:优先队列(部分回溯)
优先队列是一个经典例子,其操作(插入 insert(k) 和删除最小元 delete-min)不是可交换的。在时间线中插入一个过去的 insert 可能引发连锁反应,改变后续所有 delete-min 的结果。
然而,我们仍然可以实现部分回溯性(仅在现在查询),且每个回溯操作仅需 O(log n) 时间。
关键思想与数据结构
我们不显式维护所有元素随时间的完整状态变化(那会导致线性连锁更新),而是维护一些辅助信息来快速计算回溯操作对“现在”队列的影响。
定义:时间 T 是一个“桥”,如果在此时间点,队列中所有元素都将一直存在到时间线结束(即之后不会被删除)。桥是时间线上的“平静点”。
引理:要计算在时间 T 插入一个键 k 后,最终队列(“现在”)中会增加哪个元素,可以找到时间 T 之前的第一个桥 T‘。那么,新插入的元素将是:所有在 T‘ 之后被插入、且最终不在队列中的键里面的最大值。
基于这个引理,我们维护以下数据结构:
- 最终队列
Q_now:用一个平衡二叉搜索树维护当前时间点的所有元素。 - 按时间排序的插入操作树:叶子是所有
insert操作,按时间排序。每个节点维护一个值:该子树中所有最终不在Q_now中的键的最大值。 - 按时间排序的所有操作树:叶子是所有操作(
insert和delete-min)。为每个叶子赋予一个权值:若该insert的键最终在Q_now中,权值为 0;若不在,权值为 +1;delete-min权值为 -1。每个节点维护子树权值和。
操作:
- 查找前驱桥:利用操作树的子树权值和,可以在 O(log n) 时间内找到任意时间点
T的前一个桥T‘(即前缀权值和为 0 的点)。 - 执行回溯插入:
- 找到插入时间
T的前驱桥T‘。 - 在插入操作树中,找到
T‘之后所有插入操作对应的键的最大值(利用节点维护的 max 值)。 - 结合新插入的键
k,确定最终哪个键会进入Q_now。 - 更新
Q_now以及两棵辅助树中的相关信息。
- 找到插入时间
所有步骤都能在 O(log n) 时间内完成。
其他数据结构的回溯性
以下是其他一些数据结构已知的回溯性结果:
- 队列(FIFO)/栈(LIFO):部分回溯可达 O(1) 时间;完全回溯可达 O(log M) 时间。
- 双端队列:完全回溯可达 O(log M) 时间。
- 并查集:完全回溯的最佳已知结果是 O(log M) 时间。
- 优先队列(完全回溯):这是一个开放问题,最佳已知结果是通过通用转换得到的 O(√M * log M) 时间。
- 后继问题:这是回溯性研究中的一个核心问题。作为可分解搜索问题,完全回溯可达 O(log² M) 时间。已有更复杂的结果将其优化至 O(log M) 时间。
非 oblivious 回溯性
前述的回溯性模型假设查询是“被动的”,即查询结果不会影响后续的更新操作。这被称为 oblivious 回溯性。
非 oblivious 回溯性 考虑了更现实的场景:算法根据查询结果来决定后续进行什么更新操作。在这种情况下,回溯性地修改一个过去的操作,不仅可能改变后续查询的结果,还可能改变后续的更新操作本身。
目标:数据结构需要能够报告,在进行了回溯修改后,最早在哪个时间点出现了“错误”——即某个查询或更新操作基于旧状态执行,而现在状态已改变。
假设:算法会以单调从左到右的方式修复错误:它找到第一个错误,在那里进行必要的回溯更改,然后继续寻找并修复下一个错误,直到所有操作与新的时间线一致。
例子:优先队列的非 oblivious 回溯
在这种情况下,我们需要支持回溯插入/删除三种操作:insert, delete-min, 以及 query-min。问题可以转化为在二维平面(时间 vs. 键值)上维护动态线段(元素的存在区间)和点(查询),并支持:
- 向上射线查询:在某个时间点进行
query-min,相当于从该时间点向上发射射线,找到碰到的第一条线段(最小键值)。这本质上是动态的后继查询。 - 向右射线查询:当删除一个删除操作时,需要知道一个水平线段向右延伸会碰到什么。这本质上是另一种方向的后继查询。
通过利用高效的动态射线射击/后继查询数据结构(如改进的完全回溯后继数据结构),可以实现所有非 oblivious 回溯操作在 O(log M) 时间内完成。
总结
本节课我们一起学习了回溯数据结构这一强大的概念:
- 部分回溯性允许修改过去的更新,但只在现在查询。对于更新操作可交换且可逆的数据结构,这很容易实现。
- 完全回溯性还允许在过去查询。对于可分解搜索问题,我们可以使用线段树技术,以 O(log n) 的开销实现它。
- 对于通用数据结构,高效的完全回溯性通常不可能,最坏情况下需要线性时间来回滚和重放操作。
- 我们深入探讨了优先队列的部分回溯性实现,它通过巧妙的辅助数据结构和“桥”的概念,在 O(log n) 时间内处理了潜在的线性连锁反应。
- 最后,我们了解了非 oblivious 回溯性,它考虑了查询结果对后续操作流的影响,并通过将其转化为动态几何查询问题(如射线射击)来解决。

回溯性为我们提供了“调试”或“修正”算法历史的能力,是数据处理中一个非常深刻和有用的工具。
003:几何结构 I 🧭

在本节课中,我们将学习两种几何数据结构问题:平面点定位和正交范围搜索。我们将探讨两种核心技术:将静态数据结构动态化的权重平衡技术,以及听起来很酷但原理其实很简单的分数级联技术。我们还会看到它们与持久化和可回溯性之间的联系。
平面点定位 🗺️
平面点定位问题是指,给定一个平面地图(由互不相交的直线段构成的平面图),我们需要快速确定一个查询点位于地图中的哪个面内。这在地理信息系统(如GPS定位)和图形用户界面(如鼠标点击检测)中都有应用。
静态版本与扫描线技术
在静态版本中,地图是预先给定的。我们可以使用扫描线技术来解决一个相关的问题:垂直射线射击。想象一条垂直线从左向右扫描整个平面。在扫描过程中,我们用一个平衡二叉搜索树来维护当前与扫描线相交的所有线段,并按它们的Y坐标排序。
当我们遇到一条线段的左端点时,就将其插入树中;遇到右端点时,则将其删除。这样,对于任意X坐标(即扫描线的某个“时刻”),我们都能知道该位置垂直线上的线段顺序。
引入持久化
如果我们对这个二叉搜索树应用部分持久化,会发生什么?持久化允许我们查询数据结构在过去任意时刻的状态。这意味着,对于一个给定的查询点 (x, y),我们可以“回到”X坐标为 x 的那个时刻,然后在对应的持久化二叉搜索树中,查询Y坐标 y 的前驱和后继线段。这正好回答了“从点 (x, y) 向上发射的垂直射线首先击中哪条线段?”这个问题,即垂直射线射击查询。
通过这种方式,我们仅用 O(log n) 的查询时间就解决了静态平面点定位问题。持久化巧妙地为我们增加了一个“时间”维度。
引入可回溯性
如果我们想让地图动态变化(即支持线段和顶点的插入与删除),该怎么办?这时可以使用部分可回溯性。对于由水平和垂直线段组成的正交地图,我们可以将线段的插入和删除视为对可回溯数据结构(如前驱查询结构)的“过去”进行修改。这样,我们就能在 O(log n) 时间内支持动态更新和查询。
然而,对于由任意方向线段组成的一般性地图,动态问题更加困难,目前最好的算法在更新和查询时间之间需要权衡。
正交范围搜索 📦
正交范围搜索是另一个经典问题:给定平面上的一组点,我们需要快速回答诸如“在这个矩形查询窗口内有多少个点?”或“列出矩形内的所有点”这样的查询。
一维范围树
我们先从一维情况开始。给定一组点(在一条线上)和一个区间查询 [a, b],我们可以使用二叉搜索树,并将所有数据存储在叶子节点中。搜索 a 和 b 的过程会在树中定位到两个叶子节点,而答案(位于 [a, b] 区间内的所有点)就由这两个搜索路径之间的 O(log n) 棵子树隐含表示。我们可以通过存储子树大小来快速计算点的数量,或者按顺序遍历来列出前K个点。
多维范围树
对于二维情况,查询是一个矩形 [x1, x2] × [y1, y2]。一个直观的想法是构建一棵主X范围树,它按X坐标组织所有点。对于X树中的每个节点(代表一个X坐标区间),我们为其对应的点集再构建一棵辅助的Y范围树,按Y坐标组织。
查询时,我们首先在主X树中找到覆盖 [x1, x2] 区间的 O(log n) 棵子树。对于每一棵这样的子树,我们进入其对应的Y范围树,执行一次一维Y区间查询 [y1, y2]。这总共需要 O(log² n) 时间。空间上,每个点会出现在 O(log n) 棵Y树中,因此总空间为 O(n log n)。
这个思想可以推广到d维,得到 O(log^d n) 的查询时间和 O(n log^{d-1} n) 的空间。
分层范围树与分数级联
我们可以通过分数级联技术将二维查询时间优化到 O(log n)。其核心思想是重用搜索。
我们不再为每个X子树节点存储一棵完整的Y范围树,而是存储一个按Y排序的数组。关键技巧在于,我们从全局的Y排序数组(根节点)开始,进行一次二分搜索,找到 y1 和 y2 的位置。然后,当我们沿着X树向下遍历时,通过预存储的指针,可以在常数时间内确定在子节点的Y数组中 y1 和 y2 的新位置,而无需重新进行二分搜索。
这本质上是将全局的Y搜索信息“级联”到了局部。通过精心设计指针(例如,从“被提升”的元素指向其在相邻列表中对应的位置),我们可以在 O(log n)(用于最初的全局搜索)加上 O(k)(用于输出结果)的时间内完成查询。对于d维情况,此技术可以将最后一维的 log n 因子优化掉。
动态化:权重平衡树 ⚖️
上述的分层范围树结构是静态的。为了支持点的插入和删除,我们需要一种方法将其动态化。这里我们使用权重平衡树(如BB[α]树)。
定义与性质
在权重平衡树中,对于每个节点,我们要求其左子树和右子树的大小(节点数)至少是节点本身大小的一个常数比例 α(α < 1/2)。这比高度平衡条件更强,意味着树高是 O(log n)。
更新策略
插入或删除一个点时,我们像普通二叉搜索树一样在叶子层面进行操作。这可能会破坏从叶子到根路径上一些节点的权重平衡条件。
当检测到一个节点不再平衡时,我们采用的策略非常直接:重建以该节点为根的整个子树,将其重构成一棵完全平衡的树。重建的成本与子树大小成线性关系。
摊还分析
为什么这样做是高效的?关键点在于,如果一个子树被重建为完全平衡,那么需要 Ω(|子树大小|) 次更新才能再次使其变得不平衡。虽然一次更新可能影响 O(log n) 个祖先节点,但通过摊还分析,我们可以将重建成本分摊到导致不平衡的那些更新上。最终,每个更新操作的摊还时间复杂度为 O(log n)。
对于带有复杂augmentation(如我们分层范围树中的数组和指针)的结构,重建整个子树比尝试动态维护所有指针要简单得多。只要静态构建时间是 O(size * polylog(size)),动态更新就能达到 O(polylog(n)) 的摊还时间。
将权重平衡树应用于分层范围树,我们可以在二维情况下实现 O(log n) 的查询时间和 O(log² n) 的摊还更新时间。
分数级联通论 🔗
最后,我们更一般地探讨分数级联技术。它解决的是这样一个问题:在多个有序列表 L1, L2, ..., Lk 中搜索同一个元素 x(即找到 x 在每个列表中的前驱和后继)。
朴素方法与改进目标
朴素的方法是进行k次独立的二分搜索,耗时 O(k log n)。分数级联的目标是将其优化到 O(k + log n),这几乎是最优的。
核心思想:提升与级联
- 提升:从最底层的列表
Lk开始,我们取其中一部分元素(例如,每隔一个元素),并将它们“提升”到上一层列表L_{k-1}中,形成一个新的列表L'_{k-1}。这个过程递归进行,每一层都从下一层提升一个常数比例(如1/2)的元素到本层。 - 指针:在每个提升后的列表
L'_i中,我们在“被提升的元素”(来自L_{i+1})和“原生元素”(原本在L_i中)之间建立双向指针。同时,每个元素也存储指向其在相邻列表(L'_{i-1}和L'_{i+1})中对应位置的指针。
查询过程
查询时,我们只在最顶层的列表 L'_1 中进行一次二分搜索,找到 x 的位置。然后,利用存储的指针,我们可以在常数时间内确定 x 在下一层列表 L'_2 中的可能位置范围(因为该范围大小是常数)。通过几次局部比较,就能精确定位。以此类推,我们可以一路向下,在 O(k + log n) 的总时间内找到 x 在所有原始列表 L_i 中的位置。
推广到图结构
分数级联可以进一步推广到图结构。图中每个节点都有一个元素集合。边上有标签(值域区间),只有查询值 x 落在该区间内时,才能沿这条边移动。搜索的目标是从某个起始节点开始,沿着符合条件的边访问k个特定节点,并找到 x 在每个节点集合中的位置。只要每个节点的“入边”在任意 x 处的交集大小有常数上界(局部有界入度),分数级联技术就能同样实现 O(k + log n) 的搜索效率。这个强大的工具被用来解决许多几何查询问题,并获得了最优的时间复杂度。

本节课中,我们一起学习了平面点定位和正交范围搜索这两个几何数据结构问题。我们看到了如何利用持久化和可回溯性来解决点定位问题,并深入探讨了范围树及其通过分数级联和权重平衡树实现的优化与动态化。分数级联作为一种通用的搜索加速技术,其思想深刻而优雅,在众多领域都有广泛应用。
004:几何结构 II 🧮


在本节课中,我们将学习如何利用分数级联技术改进三维正交范围搜索,并介绍一种处理动态移动数据的新数据结构——动力学数据结构。
分数级联在三维正交范围搜索中的应用 🔍
上一节我们介绍了分数级联的基本概念,本节中我们来看看如何利用它来优化三维正交范围搜索。
分数级联允许我们在 K 个有序列表中搜索同一个元素 X,并在 O(log n + K) 时间内找到其在每个列表中的前驱和后继,而不是简单的 O(K log n)。当我们在一个有界度的图中导航,且每个节点都有一个这样的列表时,只要花费 O(log n) 时间启动,就能在常数时间内知道 X 在每个列表中的位置。
从二维受限查询开始
我们从一个二维受限的正交范围查询开始。具体来说,我们考虑一个在 Y 和 Z 坐标上的查询,其左端点不存在,即查询区域是一个延伸到 B2 和 B3 的二维象限。我们希望找到所有被点 (B2, B3) 支配的点(即 Y 和 Z 坐标都小于等于该点的点)。
我们可以用 O(log n + K) 时间解决这个问题,但更精确地说,其成本是:在 Z 坐标列表中搜索 B3 的时间,加上 O(K)。我们这样表述是为了后续应用分数级联。
转化为射线穿刺问题
接下来,我们将此问题转化为一种射线穿刺问题。假设我们有一些点,给定一个查询点,我们想找到所有在该查询点左下象限内的点。我们可以从查询点向左画一条水平射线,并从每个点向上画一条垂直射线。射线的交点就对应着在查询象限内的点。
我们希望预处理这些垂直射线,使得我们可以用一条水平射线进行穿刺。理想情况下,我们希望在 O(log n) 时间内启动,然后以常数时间处理每个交点。这可以通过对平面进行分解来实现:从每个点向右和向左延伸水平线段,直到碰到其他线段或无限远。这样就将平面分解成了许多“板条”或“砖块”。
应用分数级联思想
在这种分解中,一个面(face)的右侧可能有许多出边。为了在常数时间内确定走哪条边,我们可以应用分数级联的思想:将一半的边“提升”到左侧的面上。这样,每个面的出边度数就变成了常数,从而我们可以在常数时间内遍历。这个过程只增加线性的额外边,因此空间复杂度是线性的。
这样,我们就得到了一个解决二维象限查询的高效数据结构,为后续扩展到三维奠定了基础。
扩展到三维正交范围搜索 📦
上一节我们构建了高效的二维象限查询工具,本节中我们来看看如何将其扩展到三维。
处理带 X 区间约束的查询
假设我们现在有一个三维查询,其中两个区间(比如 Y 和 Z)的左端点是负无穷,但 X 坐标是一个常规区间 [A1, B1]。我们希望用 O(log n) 次搜索加上 O(K) 的时间来解决。
这可以通过一维范围树轻松实现:
- 在 X 坐标上建立范围树。
- 每个节点存储一个针对其子树中点的二维数据结构(即上一步构建的,用于处理
(-∞, B2], (-∞, B3]查询)。 - 查询时,我们找到代表 X 区间 [A1, B1] 的 O(log n) 个节点,对每个节点存储的二维数据结构进行查询。
- 每个二维查询花费一次搜索(在 Z 列表中找 B3)加上 O(K_i) 时间,其中 K_i 是该节点输出的点数。总的 K_i 之和为 O(K)。
因此,总时间是 O(log n) 次搜索 + O(K)。关键点在于,这 O(log n) 次搜索都是在寻找同一个值 B3,这为应用分数级联创造了条件。
将单边区间转换为双边区间
目前我们的三维查询还有两个维度(Y 和 Z)是单边区间(-∞ 作为左端点)。接下来,我们展示一个巧妙的转换,可以将 Y 维度的单边区间 (-∞, B2] 转换为双边区间 [A2, B2],且只增加空间开销,不增加查询时间。
我们在 Y 坐标上建立另一棵范围树:
- 每个节点 V 存储两个数据结构:
- 一个针对其右子树中点的二维数据结构(处理
(-∞, B2], (-∞, B3])。 - 一个针对其左子树中点的、Y 轴反转的二维数据结构(处理
[A2, +∞), (-∞, B3])。
- 一个针对其右子树中点的二维数据结构(处理
- 查询时,我们从根节点开始,根据查询区间
[A2, B2]与节点键值的关系向下遍历:- 如果节点键值在区间左侧,则向右子树走。
- 如果节点键值在区间右侧,则向左子树走。
- 如果节点键值被区间包含(即区间跨越该键值),那么我们只需要查询该节点存储的两个数据结构(一个针对右子树,一个针对左子树),然后停止递归。这只需要常数次(2次)对二维数据结构的调用。
这个转换的精妙之处在于,我们只进行了一次 O(log n) 的树遍历,并在遍历路径的“分叉点”进行了常数次工作,从而将 Y 维度的双边查询,转化为了常数个 Y 维度的单边查询。我们为此付出了 O(log n) 倍的空间开销。
完成三维双边查询
现在,我们对 Z 维度重复完全相同的转换过程:
- 在 Z 坐标上建立范围树。
- 每个节点存储两个三维数据结构(一个常规,一个 Z 轴反转),用于处理
[A1, B1], [A2, B2], (-∞, B3]和[A1, B1], [A2, B2], [A3, +∞)的查询。 - 通过一次 O(log n) 的遍历和常数次对三维数据结构的调用,最终将 Z 维度也转换为双边查询
[A3, B3]。
至此,我们实现了完整的三维正交范围查询 [A1, B1] x [A2, B2] x [A3, B3]。
复杂度分析与分数级联的作用
整个查询过程可以看作在一个有常数度的图中导航:
- 在 Z 范围树中启动,花费 O(log n)。
- 触发常数次三维查询。
- 每个三维查询在 Y 范围树中启动,花费 O(log n),并触发常数次二维查询。
- 每个二维查询最终触发 O(log n) 次对一维 Z 列表的搜索。
如果不加优化,总时间将是 O(log² n + K)。然而,注意到在整个调用链中,我们反复搜索的是同一个值(B3 或 A3)。虽然我们有常规和反转两种版本,但这只是常数因子。整个导航图具有常数度,因此分数级联技术可以应用。
应用分数级联后,所有搜索共享信息,使得总时间从 O(log² n + K) 降低到 O(log n + K)。空间复杂度为 O(n log² n)(由于两层范围树)。
这个结果非常出色,它将 D 维正交范围报告查询的时间从上一讲的 O(logᴰ⁻¹ n + K) 改进到了 O(logᴰ⁻² n + K)。对于 D=3,我们实现了 O(log n + K) 的查询时间。
动力学数据结构简介 ⏱️
上一节我们深入探讨了静态几何查询的优化,本节中我们来看看一种处理动态移动数据的新范式——动力学数据结构。
在动力学数据结构中,数据点不是静止的,而是随着时间运动,每个点有一个已知的运动轨迹(例如,位置是时间的函数)。我们需要支持三种操作:
advance(t):将当前时间推进到 t。change(p, new_trajectory):改变点 p 的运动轨迹。query(...):在当前时间进行查询(例如,范围查询、最近邻等)。
核心思想:证书(Certificates)
几乎所有动力学数据结构都遵循一个统一的框架:
- 存储当前数据结构:维护一个在当前时刻正确的数据结构,使得查询可以快速进行。
- 存储证书:同时存储一组称为“证书”的布尔条件。这些条件在当前时刻为真,并且只要它们保持为真,数据结构就保持正确。
- 例如,在一个维护最小值的堆中,证书就是每个节点小于其子节点的关系。
- 预测失效时间:对于每个证书,根据点的运动轨迹,计算出它将来会变为假的最早时间(失效时间)。
- 事件队列:将所有证书的失效时间放入一个优先队列中。
算法流程
- 查询:直接在当前数据结构上进行,非常快。
- 推进时间
advance(t):while t >= priority_queue.min().failure_time: event_time = priority_queue.pop_min() # 处理事件:修复失效的证书和数据结构 handle_event(event_time) current_time = t - 处理事件:当一个证书失效时(例如,两个点的大小关系发生交换),我们需要:
- 更新数据结构以反映新的顺序(例如,在堆中交换两个节点)。
- 删除与已交换点相关的旧证书。
- 添加与新结构相关的新证书。
- 为这些新证书计算失效时间,并插入优先队列。
分析维度
评价一个动力学数据结构的优劣主要从以下几个维度考虑:
- 响应性:处理一个事件所需的时间。通常希望是 O(log n) 或更好。
- 局部性:每个数据点参与其中的证书数量。局部性好(如常数)通常意味着响应性好。
- 紧凑性:证书的总数量。通常希望是 O(n) 或 O(n log n)。
- 效率:这是最核心也最复杂的指标。它衡量在没有任何
change操作、单纯从初始时间推进到无穷远的过程中,数据结构处理的事件总数,与问题本身固有的“外部事件”数量(如最小值改变的次数)之间的关系。理想情况下,我们希望效率高,即事件总数接近下界。
示例:动力学堆(Kinetic Heap)
让我们以维护最小值的动力学堆为例:
- 数据结构:一个标准的二叉最小堆。
- 证书:对于每个节点,其值小于等于其两个子节点的值。共有 O(n) 个证书,局部性为常数(每个点涉及最多3个证书:父节点和两个子节点)。
- 事件处理:当证书
父节点 < 子节点即将失效时,交换这两个节点,并更新相关的证书(常数个)。 - 效率分析:可以证明,对于伪代数运动轨迹,事件总数是 O(n log n)。而最小值本身最多改变 O(n) 次。因此,这个动力学堆的效率因子是 O(log n),被认为是高效的。
相比之下,如果我们用一个始终保持全局排序的动力学二叉搜索树来维护最小值,虽然也能工作,但其事件总数可能高达 Ω(n²),效率很低。动力学堆通过维护局部堆性质,巧妙地减少了不必要的事件处理。
其他动力学问题
动力学数据结构的研究涵盖了许多几何问题:
- 二维凸包:事件数可达 O(n²+ε),接近下界 Ω(n²)。
- 最小包围圆:目前最好的上界是 O(n³+ε),下界是 Ω(n²),仍有差距。
- Delaunay 三角剖分:维护移动点集的 Delaunay 三角剖分是一个著名开放问题,上界 O(n³),下界 Ω(n²)。
- 碰撞检测:是动力学数据结构的经典应用场景。
- 最小生成树:简单的动力学方法需要 Ω(n²) 事件,是否存在更优方法仍是未知数。
总结 🎯
本节课中我们一起学习了两个高级主题。
首先,我们深入探讨了如何利用分数级联技术,通过巧妙的层次化转换和射线穿刺模型,将三维正交范围报告查询的时间优化到了 O(log n + K),展示了算法设计中“化繁为简”和“重用信息”的强大力量。
其次,我们介绍了动力学数据结构这一处理移动数据的框架。其核心在于通过维护证书和事件队列,在数据持续运动的情况下仍能快速回答当前时刻的查询。我们以动力学堆为例,分析了其设计方法和效率,并概述了该领域的一系列挑战与开放问题。
这两部分内容分别代表了静态数据查询的极致优化和动态数据维护的前沿思路,是高级数据结构中几何计算方向的精髓。
005:动态最优性 I 🧠

在本节课中,我们将探讨一个数据结构领域的核心开放性问题:动态最优性。我们将学习是否存在一种“最优”的二叉搜索树,能够适应任何访问序列,并理解衡量其性能的各种标准。课程将从基本概念入手,逐步深入到几何视角,最终介绍一个被认为可能达到动态最优的算法。
概述
动态最优性问题的核心是:是否存在一种二叉搜索树,对于任何给定的访问序列,其性能都能在常数因子内匹敌最优的离线算法?这是一个自上世纪80年代起就备受关注的问题,至今仍未完全解决。本节课我们将首先明确二叉搜索树的计算模型,然后介绍一系列衡量性能的“分析性界”,如工作集性质和动态手指性质。接着,我们将引入一个强大的几何视角,将二叉搜索树的执行过程映射为二维平面上的点集,并探讨其满足的“树状”性质。最后,我们将介绍“贪心算法”,它被认为是实现动态最优性的有力候选者。
二叉搜索树模型
为了形式化地讨论问题,我们首先需要定义二叉搜索树的计算模型。这个模型比一般的指针机模型更为受限。
- 数据必须存储在一个二叉搜索树中,树中的节点存储着键值。
- 允许的基本操作包括:
- 跟随指针:可以沿左孩子、右孩子或父节点指针移动,每次操作花费常数时间。
- 旋转:可以旋转一个节点与其父节点之间的边。如果节点
x是其父节点y的右孩子,旋转x会使其成为y的父节点,同时保持二叉搜索树的性质。反向旋转y可以恢复原状。
在这个模型中,搜索一个键值 x 的过程是:从根节点开始,通过上述操作(移动和旋转),最终必须访问到键值等于 x 的节点。我们通常假设搜索总是成功的,并且暂时不考虑插入和删除操作,以简化问题。
对于包含 n 个节点的树,最坏情况下(例如,对手总是选择树中最深的节点进行访问),每次搜索的成本至少是 O(log n)。然而,动态最优性的目标不是针对最坏情况,而是针对每一个特定的访问序列,都尽可能达到最优。
超越 O(log n):分析性界
对于某些访问序列,我们可以做得比 O(log n) 好得多。以下是几个重要的性能衡量标准(“界”),它们描述了在特定类型的访问序列下,我们期望达到的摊销成本。
顺序访问性质
如果访问序列是按键值顺序进行的(例如,连续访问 1, 2, 3, ..., n),那么理想情况下,每次访问的摊销成本应为 O(1)。这可以通过将树结构调整为类似链表的形式来实现。
动态手指性质
对于访问序列 x1, x2, ..., xm,如果当前访问的键 xi 与前一个访问的键 xi-1 在键值空间中的距离为 k(即 |xi - xi-1| = k),那么理想情况下,本次访问的摊销成本应为 O(log k)。当访问在空间上连续时,这个性质能带来很好的性能。
工作集性质
对于每次访问键 x,设 t 为自上次访问 x 以来,所访问过的不同键的数量。那么,访问 x 的理想摊销成本应为 O(log t)。这意味着,如果你最近频繁访问一小部分键,那么访问它们的成本就会很低。
统一界
统一界试图结合动态手指性质和工作集性质。其思想是:如果你访问的键 x 在空间上接近一个最近访问过的键 y,那么访问 x 的成本应该较低。形式化地说,对于在时间 j 访问键 xj,定义其成本为 O(log(min_{i<j} (|xi - xj| + (在时间 i 和 j 之间访问的不同键数)) + 2)。目前尚不清楚是否存在二叉搜索树能达到统一界,但已知存在指针机数据结构可以实现它。
动态最优性:终极目标
上述性质都是针对特定类型访问序列的。动态最优性提出了一个更宏大、更根本的目标:
是否存在一个(在线的)二叉搜索树算法,使得对于任何访问序列
X,其总成本都在一个常数因子内,最优的(离线的)二叉搜索树算法对于同一序列X的总成本?
这里,“最优的离线算法”是指预先知道整个访问序列 X,并可以为其量身定制最优的二叉搜索树策略。而“在线算法”则不知道未来的访问。这个问题目前仍然是开放的。
尽管无法证明常数竞争性,但我们已知一些接近的结果。例如,存在 O(log log n) 竞争的在线二叉搜索树算法,这比简单的平衡二叉搜索树(O(log n) 竞争)要指数级地好。
候选的最优算法:伸展树与几何视角
有两个二叉搜索树被猜想是动态最优的,但均未被证明。第一个是经典的伸展树。伸展树在每次搜索后,会将访问的节点通过一系列旋转移动到根节点。已知伸展树具有工作集性质(从而也具有熵界)和动态手指性质,但它是否满足统一界或达到动态最优性,仍是悬而未决的问题。
接下来,我们将介绍一个更具启发性的几何视角,它能将二叉搜索树的执行过程转化为一个二维点集,并引出一个被称为“贪心算法”的候选最优算法。
几何表示法
- 坐标轴:设横轴为键值空间(1 到 n),纵轴为时间。
- 访问点:对于在时间
t访问键值k,我们在坐标(k, t)处画一个点。 - 触摸点:在二叉搜索树模型中,为了访问目标节点,算法在搜索路径上会“触摸”一系列节点(包括通过指针移动或旋转访问的节点)。我们将所有在时间
t被触摸的节点k,都表示为点(k, t)。
这样,一个二叉搜索树算法对某个访问序列的执行过程,就对应了二维平面上的一个点集,其中包含访问点(必须有的)和额外的触摸点。
树状满足性质
一个关键定理指出:一个点集 P 对应某个二叉搜索树算法的执行过程,当且仅当它满足“树状满足性质”。
树状满足性质:对于点集 P 中任意两个不共水平线也不共垂直线的点 (x1, y1) 和 (x2, y2),以它们为对角顶点形成的矩形内,必须包含 P 中的另一个点(可以在边界上)。
直观上,这个性质意味着点集是“树状”的,不能存在空洞。如果存在一个不满足该性质的“空矩形”,则无法用二叉搜索树的操作来解释对应的触摸模式。
动态最优性的几何重构
根据这个定理,动态最优性问题有了一个清晰的几何表述:
给定一个访问序列对应的点集(只有访问点,没有触摸点),我们需要添加最少数量的额外点(触摸点),使得整个点集满足树状满足性质。这个最小添加点数(乘以一个常数因子)就等于最优离线二叉搜索树算法的成本。
因此,寻找动态最优的二叉搜索树,等价于寻找一个在线算法,能根据已出现的访问点,决定添加哪些触摸点,并使总添加点数接近上述离线的最优解。
贪心算法
基于几何视角,一个非常自然的算法浮出水面,我们称之为贪心算法。
算法过程:我们按时间顺序处理访问点。在时间 t,添加当前访问点 (x, t)。然后,检查所有新形成的、不满足树状性质的“空矩形”。对于每个这样的矩形,我们在其顶部边(即当前时间 t 的水平线上)添加一个点来“填补”它(具体位置在矩形的左边界和右边界对应的键值之间)。重复此过程,直到当前时间线对应的点集满足树状性质,然后处理下一个时间点。
这个算法在几何上看是在线的,因为它只依赖过去的信息。可以证明,通过一种称为“分裂树”的数据结构进行模拟,这个几何贪心算法可以被转化为一个真正的在线二叉搜索树算法。
贪心算法极其简洁和直观,被认为很有可能是动态最优的。然而,证明其常数竞争性仍然是一个巨大的挑战,也是当前研究的前沿。
总结

本节课我们一起深入探讨了动态最优性这个迷人而困难的问题。我们首先定义了二叉搜索树的计算模型,并回顾了诸如工作集、动态手指等分析性性能界。然后,我们引入了强大的几何视角,将树的操作转化为二维点集的满足性问题,从而对最优成本有了新的理解。最后,我们介绍了基于该视角的贪心算法,它是在线且被推测为最优的有力候选者。尽管尚未得到证明,但这些概念和工具为我们理解和逼近二叉搜索树的终极性能极限提供了清晰的框架。下节课,我们将探讨与此相关的下界理论。
006:动态最优性 II

在本节课中,我们将继续探讨动态最优性,这是关于动态最优二叉搜索树的第二讲。我们将主要关注下界理论,介绍几种不同的下界方法,并了解如何利用这些下界来分析和设计高效的二叉搜索树算法,例如 Tango 树。
概述
上一讲我们介绍了二叉搜索树执行的几何视角,将访问序列表示为时间-键值空间中的点集。一个点集对应一个有效的 BST 执行,当且仅当该点集是“arborally satisfied”的,即任意两点构成的矩形(非水平或垂直线段)内必须包含第三个点。
在上界方面,我们看到了一个贪心算法,它可以在线地(在常数因子内)构造出满足条件的点集,并被认为是常数竞争性的(尽管尚未证明)。本节课,我们将从下界角度出发,探讨如何证明某个算法接近最优。我们将介绍三种基于“独立矩形”的下界方法:独立矩形通用下界、Wilber 1 和 Wilber 2。特别地,我们将看到如何利用 Wilber 1 下界来分析 Tango 树,该树实现了 O(log log n) 的竞争比。最后,我们将介绍“有符号贪心”算法,它提供了已知最好的下界,并且与上界的贪心算法在形式上非常接近,这引出了动态最优性猜想的核心问题。
独立矩形下界
我们首先介绍一个通用的下界框架:独立矩形下界。其核心思想是,对于给定的访问序列(白点),我们可以从中找出一组特定的矩形,这组矩形的数量与任何 BST 执行该序列所需的最小代价(添加的红点数)相关。
独立矩形的定义
考虑由点集中任意两点(非同行同列)构成的矩形。我们称两个矩形是“独立”的,如果满足以下条件:
- 任何一个矩形的四个角都不严格位于另一个矩形的内部。
- 两个矩形的边界上也不包含对方的点(除了可能共享端点)。
直观上,独立矩形是指彼此不“嵌套”也不以特定方式“相交”的矩形。
下界定理
定理:对于任何访问序列,最优离线二叉搜索树的代价(即需要添加的最少点数)至少为:
|输入点集| + (1/2) * (最大独立矩形集的大小)
换句话说,除了必须包含原始访问点外,为了满足条件,你至少需要为最大独立集中的每个矩形添加一个点(平均而言)。证明这个定理需要一些技巧,其核心思路是将问题分解为只考虑“正斜率”矩形(左下-右上)和“负斜率”矩形(左上-右下)两种情况,并分别论证。
上一节我们介绍了独立矩形下界的通用形式。本节中,我们来看看如何具体构造这样的独立矩形集,从而得到可计算的下界。我们将介绍两种由 Wilber 提出的经典方法。
Wilber 2 下界
Wilber 2 下界是一种基于每个访问点的“交错”模式的计算方法。
下界定义
对于点集中的每个点 p(对应一次访问):
- 考虑所有在
p之前被访问、并且在键值上位于p左侧或右侧的点。 - 在这些点中,找出那些与
p能构成“空矩形”(即矩形内无其他点)的点,并按时间顺序排列。 - 观察这个序列在
p的垂直分界线左右两侧的交错次数。
Wilber 2 下界 定义为所有点 p 的交错次数之和。
几何解释与性质
从几何上看,每次交错对应一个以 p 的垂直线为一边的独立矩形。可以证明,这样为所有点 p 构造出的矩形集合是彼此独立的。因此,根据独立矩形下界定理,Wilber 2 的值是 BST 执行代价的一个下界(需乘以 1/2)。
Wilber 2 下界本身难以分析,但它有一个著名的应用场景:
定理(键值无关最优性):如果访问序列的键值是随机分配的(即键值本身不携带信息),那么在期望意义上,动态最优代价与“工作集界”同阶。这意味着 Splay 树在此随机模型下是常数竞争的。该定理的证明正是通过分析随机键值下 Wilber 2 的期望值来完成的。
Wilber 1 下界
Wilber 1 下界依赖于一个固定的、静态的“下界树” P(通常取为完美平衡树)。
下界定义
对于下界树 P 中的每个内部节点 y:
- 考虑访问序列中所有键值位于
y的左子树或右子树内的访问(忽略对y本身及子树外键值的访问)。 - 计算这些访问在左子树和右子树之间交替的次数。
Wilber 1 下界 定义为对所有节点 y 的交替次数求和。
几何解释与性质
同样,每次交替对应一个独立的矩形。Wilber 1 的值也是 BST 执行代价的一个下界。它的特点是依赖于预先选定的树 P。选择不同的 P 会得到不同的下界值,其中最大值可能更接近真实代价,但这很难计算。
Wilber 1 下界虽然可能不是最紧的,但它有一个非常优雅的重新表述,这直接引出了 Tango 树的设计。
我们介绍了 Wilber 1 下界,它基于一个固定树结构计算交替次数。本节中,我们将看到如何利用这个下界来设计一个高效的在线二叉搜索树——Tango 树。
Tango 树:基于 Wilber 1 的算法
Tango 树的核心思想是模拟 Wilber 1 下界,并确保每次访问的代价与下界中计数的“交替”次数成正比,再乘以一个 O(log log n) 的因子。
核心概念:偏好孩子与偏好路径
给定固定的完美平衡下界树 P:
- 对于
P中的每个节点,定义其偏好孩子:如果最近一次访问的键值位于其左子树,则偏好孩子为左孩子;若在右子树,则为右孩子;若都未访问过,则无偏好孩子。 - 沿着偏好孩子指针向下走,会将树
P分解成若干条不相交的偏好路径。
关键观察:Wilber 1 下界所计数的交替次数,正好等于一次访问过程中需要走过的非偏好边的数量。也就是说,如果你访问一个节点,需要离开当前的偏好路径而转向另一条路径,那么你就触发了一次 Wilber 1 的计数。
Tango 树的结构
Tango 树将每条偏好路径存储在一个辅助的平衡二叉搜索树中(例如红黑树)。这个辅助树按照节点的原始键值排序。
- 每条偏好路径上的节点被组织成一个高度为 O(log L) 的平衡 BST,其中 L 是路径的长度。由于在完美平衡树
P中,路径长度 L = O(log n),所以辅助树的高度为 O(log log n)。 - 不同偏好路径之间通过指针连接:辅助树中叶节点的“空子树”指针被替换为指向其他辅助树(即子偏好路径)的根节点的指针。
访问操作与复杂度分析
- 搜索:为了访问一个键值,我们从根所在的辅助树开始搜索。由于辅助树是平衡的,我们可以在 O(log log n) 时间内确定目标键值是否在当前偏好路径上。
- 路径切换:如果目标不在当前路径上,我们通过指针切换到下一个辅助树,这对应走过一条“非偏好边”。每次切换成本为 O(log log n)。
- 更新偏好路径:访问完成后,被访问节点的祖先节点的“偏好孩子”可能会改变。这导致偏好路径的分解需要调整:一些路径需要被“切割”和“连接”。幸运的是,利用平衡 BST 的 Split(分裂)和 Concatenate(合并)操作,可以在 O(log log n) 时间内完成对受影响路径的更新。
总代价:一次访问的代价是 O((1 + 非偏好边数量) * log log n)。而 Wilber 1 下界正好是非偏好边的数量。因此,Tango 树是 O(log log n)-竞争的。
为什么 Wilber 1 不是紧下界
考虑一个场景:在一条长度为 O(log n) 的偏好路径上反复随机访问节点。Tango 树每次访问需要 O(log log n) 时间,但 Wilber 1 下界(对于固定的树 P)可能保持不变(因为偏好孩子没有改变,没有非偏好边)。这说明 Wilber 1 下界本身可能比真实最优解小 O(log log n) 倍,因此 Tango 树达到的 O(log log n) 竞争比可能是基于该下界所能期望的最好结果。
我们看到了如何用 Wilber 1 下界构造 Tango 树。现在,让我们回到下界本身,探讨已知最强的下界构造方法——有符号贪心法,并理解它与动态最优性猜想的紧密联系。
有符号贪心:最强的已知下界
有符号贪心法提供了构造独立矩形集的一种系统化方法,并且可以证明它是最优的(在常数因子内)。
算法描述
有符号贪心分为两部分:
- 正贪心:只考虑“正斜率”矩形(左下-右上)。像上界的贪心算法一样,按时间顺序扫描访问点。每当遇到一个未被满足的正矩形,就在其右下角添加一个点(这是满足它所需的最“迟”的点)。重复此过程直到所有正矩形被满足。
- 负贪心:类似地,只考虑“负斜率”矩形(左上-右下),并添加点以满足它们。
有符号贪心下界 取 max(正贪心添加的点数, 负贪心添加的点数)。
为什么这是下界且是强的
- 作为下界:正贪心过程所“修复”的每个矩形,其两个原始白点和一个添加的红点可以构成一个独立矩形(经过适当调整)。因此,添加的点数对应于一组独立矩形的大小,从而是原问题代价的一个下界。
- 接近最优:可以证明,有符号贪心给出的下界,与所有可能的独立矩形选择方法中能得到的最好下界(即最大独立矩形集大小)之间,最多相差一个常数因子(例如 4)。也就是说:
max(正贪心, 负贪心) = Θ(最大独立矩形集大小)
与上界贪心算法的对比
回顾上界的贪心算法:它同时处理正矩形和负矩形,添加一个点可能同时满足多个不同类型的矩形,最终得到一个完全“arborally satisfied”的点集,对应一个有效的 BST 执行。
动态最优性猜想 本质上在问:上界的贪心算法(或其在线版本)的代价,与下界的有符号贪心法的代价,是否只相差一个常数因子?即:
贪心算法代价 = O(有符号贪心下界)
两者形式极其相似,区别在于贪心算法需处理正负矩形间的相互作用(修复一个矩形可能产生新的另一类矩形),而有符号贪心则分开处理。猜想认为这种相互作用不会导致代价发生本质差异。然而,这仍然是未解决的问题。
总结
本节课中,我们一起学习了动态最优性理论中的下界方法。
- 我们首先介绍了独立矩形下界的通用框架,它将问题转化为寻找点集中的最大独立矩形集。
- 接着,我们探讨了两种具体的下界构造:Wilber 2 和 Wilber 1。Wilber 2 下界用于证明了键值随机化模型下的最优性;而 Wilber 1 下界则因其“偏好边”的表述,直接启发了 Tango 树 的设计,该树实现了 O(log log n) 的竞争比。
- 最后,我们介绍了已知最强的 有符号贪心 下界,它能在常数因子内逼近最好的独立矩形下界。它与作为上界的贪心算法之间的相似性,构成了动态最优性猜想的核心。

尽管我们尚未证明存在常数竞争的二叉搜索树,但这些下界技术和基于它们设计的算法(如 Tango 树),已经极大地深化了我们对这个问题的理解,并提供了当前最好的理论保证。
007:内存层次模型 🧠

在本节课中,我们将学习内存层次模型,这是设计高效数据结构时必须考虑的关键概念。我们将介绍两种主要模型:外部内存模型和缓存无关模型,并探讨它们如何影响数据结构的性能。
外部内存模型 📀
上一节我们介绍了内存层次的基本概念。本节中,我们来看看外部内存模型,它也被称为I/O模型或磁盘访问模型。
传统数据结构通常将内存视为一个平坦的、可以随机访问的数组。然而,现实中的计算机系统具有多级缓存和内存层次。外部内存模型简化了这个问题,只关注两个层级:快速但容量有限的缓存(Cache)和慢速但容量巨大的磁盘(Disk)。
在这个模型中,我们假设:
- 计算和缓存访问是免费的。
- 我们只计算在缓存和磁盘之间发生的内存传输次数。
- 数据以块为单位进行传输,块大小为 B。
- 缓存的总容量为 M,可以容纳 M/B 个块。
我们的目标是设计算法,以最小化内存传输的次数。一个简单的下界是,任何需要读取 N 个字的算法,至少需要 N/B 次块传输。
以下是外部内存模型中的一些基本结果:
- 扫描:顺序读取一个大小为 N 的数组,成本为 ⌈N/B⌉ 次内存传输。
- 搜索树:B树是此模型的理想选择,其分支因子为 Θ(B)。一次搜索需要 O(log_{B+1} N) 次内存传输,并且这个下界在比较模型下是紧的。
- 排序:最优的外部内存排序算法复杂度为 (N/B) log_{M/B} (N/B),这比RAM模型下的 N log N 快得多。
- 置换:按照给定排列重排数据,最优成本是直接移动(成本 N)和先排序再重排(成本同排序)这两种方法中的较小值。
- 缓冲区树:这是一种支持延迟查询和批量更新的动态数据结构,可以实现类似排序的平摊复杂度,常用于实现优先级队列。
缓存无关模型 🧩
上一节我们了解了需要知晓参数 B 和 M 的外部内存模型。本节中我们来看看缓存无关模型,它不要求算法知晓这些硬件参数。
缓存无关模型由MIT的研究者在1999年提出。它与外部内存模型非常相似,但有一个关键区别:算法不知道块大小 B 和缓存大小 M。算法看起来就像一个普通的RAM算法,只进行字级别的读写操作。缓存的管理(如块替换)由系统自动完成,我们通常假设其使用类似LRU(最近最少使用)的优化策略。
这种模型的优势在于:
- 可移植性:一个好的缓存无关算法能自动适应所有可能的 B 和 M 值。
- 层次优化:它能在多级内存层次中同时优化各级之间的传输。
- 简化设计:程序员可以专注于算法逻辑,而无需针对特定硬件进行调优。
以下是缓存无关模型中的一些结果:
- 扫描:顺序访问依然高效,成本为 O(⌈N/B⌉)。
- 搜索:可以实现 O(log_{B+1} N) 次内存传输的搜索,但最优常数因子略高于B树。
- 排序:可以实现与外部内存模型相同的最优界限 O((N/B) log_{M/B} (N/B)),通常需要假设 M = Ω(B^{1+ε})(高缓存假设)。
- 置换:存在负面结果,无法像外部内存模型那样同时达到直接移动和排序两种策略的最优值。
- 优先级队列:在“高缓存假设”下,可以实现每次操作 O((1/B) log_{M/B} (N/B)) 的平摊复杂度。
缓存无关的静态搜索树 🌳
现在,让我们深入探讨如何在缓存无关模型中实现高效的搜索树。我们从静态情况开始。
我们不能直接使用B树,因为我们不知道 B。解决方案是使用平衡二叉搜索树,但以一种特殊的方式将其节点布局在内存中。这种布局称为 van Emde Boas 布局。
其思想是递归的:
- 将树从高度中间处“切开”,得到顶部的一个子树(大小约 √N)和底部多个子树(每个大小也约 √N)。
- 递归地对顶部子树和所有底部子树应用此布局。
- 将这些递归布局的结果在内存中串联起来。
分析:考虑任意一条从根到叶子的路径。我们观察递归中那些大小小于 B 但父节点大小大于等于 B 的子树(“小三角”)。每个这样的小三角最多跨越2个内存块。从根到叶子需要访问 O(log N / log B) = O(log_B N) 个小三角。因此,总的内存传输次数为 O(log_B N)。
迈向动态缓存无关B树 ⚙️
上一节我们构建了静态的缓存无关搜索树。本节中我们来看看如何使其动态化,支持插入和删除操作。
我们通过几个步骤来构建动态的缓存无关B树:
- 黑盒:顺序文件维护:这是一个支持在数组中维护有序元素序列的数据结构。它允许在指定位置插入或删除元素,并通过移动元素来保持数组稀疏(有常数大小的空隙)。每次更新操作的平摊成本为 O(log² N) 次元素移动,在内存层次模型中相当于 O(log² N / B) 次内存传输。
- 组合结构:我们将静态的van Emde Boas布局树(作为索引)搭建在顺序文件维护结构(存储实际数据)之上。树中的每个叶子节点指向文件中的一个元素,内部节点存储其子树中元素的最大值。
- 更新操作:插入时,首先在树中搜索到正确位置(成本 O(log_B N)),然后在底层的顺序文件中执行插入(成本 O(log² N / B))。接着,我们需要自底向上更新树中受影响节点的最大值。通过按后序遍历的顺序进行更新,并利用缓存能够同时容纳几个“小三角”的特性,可以证明更新树结构的额外成本也是 O(log² N / B)。
- 间接寻址:目前的更新成本是 O(log_B N + log² N / B),对于较小的 B(例如 B ≈ log N),第二项可能占主导。为了优化,我们引入一层间接性:树中只存储 N / log N 个“代表元素”,每个代表元素对应一个大小为 Θ(log N) 的数据块。所有更新首先发生在数据块内(成本 O(log N / B))。只有当数据块因插入太满而分裂或因删除太空而合并时,才需要更新顶层的树。由于每个块可以吸收 Ω(log N) 次更新才触发一次顶层更新,顶层更新的平摊成本就降低到了 O((log_B N + log² N / B) / log N) ≈ O(log N / B)。
最终,我们得到了一个完全动态的缓存无关B树,支持搜索、插入和删除操作,其复杂度分别为:
- 搜索:O(log_B N) 次内存传输。
- 插入/删除:O(log_B N) 次内存传输(搜索成本)加上 O(log N / B) 次内存传输(更新数据块成本),在大多数情况下主导项是 O(log_B N)。

本节课中我们一起学习了内存层次结构对算法设计的影响,重点介绍了外部内存模型和缓存无关模型。我们看到了如何通过巧妙的数据布局(van Emde Boas布局)和结构设计(结合顺序文件维护与间接寻址),在无需知晓硬件参数的情况下,构建出性能接近理论最优的动态搜索树。这些思想是设计现代高效数据处理系统的基石。
008:缓存无关结构 I


在本节课中,我们将学习两种重要的缓存无关数据结构。首先,我们将深入探讨有序文件维护问题,这是实现缓存无关B树的关键组件。接着,我们将了解一个密切相关的列表标记问题,它在完全持久化数据结构中扮演重要角色。最后,我们将介绍一种完全不同的缓存无关优先队列,它能同时适应块大小B和缓存大小M。
有序文件维护
上一节我们介绍了缓存无关B树,它能在不知道B的情况下实现所有操作(插入、删除、搜索)的O(log_B N)复杂度。其核心是一个存储在特定顺序(van Emde Boas顺序)下的二叉搜索树,底部是一个有序文件,我们当时将其视为黑盒。本节中,我们来看看如何实际实现这个有序文件,使其在每次插入和删除时仅需O(log² N)次数据移动。
问题定义
我们需要在一个数组中存储N个数据项,数组大小为Θ(N)。数组中的项按指定顺序排列,项与项之间留有常数大小的空隙。我们需要支持在该顺序中插入和删除项。
目标:每次更新(插入或删除)时,我们只在一个小区间内重新排列项,该区间大小为O(log² N)(均摊)。通过常数次交错扫描完成重排,从而在缓存无关模型中实现O(log² N / B)次内存传输(均摊)。这是实现缓存无关B树所需的关键步骤。
算法概览
基本思想很简单:当插入一个项时,我们找到一个包含该点的、密度既不太高也不太低的大小合适的区间,然后在该区间内均匀地重新分配所有项。
为了以一种可控的方式定义区间,我们构建一个概念上的二叉树。数组的底部被划分为大小为Θ(log N)的块。每个树节点代表其所有后代叶子节点对应的区间。
算法步骤
以下是更新(插入或删除)的算法:
- 更新叶子块:首先,在包含目标项的叶子块(大小为Θ(log N))内进行更新。我们可以直接重写整个块,因为O(log N)的代价在我们的目标O(log² N)范围内是可接受的。
- 向上遍历:从该叶子节点开始,沿树向上遍历,直到找到一个“在阈值内”的节点(即其对应的区间)。
- 均匀重平衡:一旦找到这样的节点,我们将其后代区间内的所有元素均匀地重新分配。
密度阈值定义
算法的关键在于“在阈值内”的定义,这由密度阈值决定。密度是指区间内实际元素数量与区间总容量(数组槽位数)的比值。
阈值不是固定的,而是取决于节点在树中的深度。设树的高度为H,节点深度为d(根节点深度为0,叶子节点深度为H)。我们要求节点的密度ρ满足:
- 下界:
ρ ≥ 1/2 - (1/4) * (d/H) - 上界:
ρ ≤ 3/4 + (1/4) * (d/H)
这意味着:
- 在根节点(d=0),密度必须在
[1/2, 3/4]之间,范围较窄。 - 在叶子节点(d=H),密度必须在
[1/4, 1]之间,范围较宽。 - 随着深度增加(向根部移动),密度范围线性收紧。
这种线性插值的设置是为了在分析中实现所需的性能保证。
算法分析
当我们重平衡一个节点X时,我们将其区间内的元素均匀分布。这使得X的子节点不仅“在阈值内”,而且“远在阈值内”——它们的密度与阈值的绝对差距至少增加了1/(4H)。
由于H = Θ(log N),且子区间大小为Θ(区间大小),这意味着要使一个子节点再次“超出阈值”,需要至少Ω(区间大小 / log N)次更新操作。
因此,我们可以将重平衡的成本(Θ(区间大小))分摊到这Ω(区间大小 / log N)次更新上,得到每个更新分摊O(log N)的成本。
然而,每次更新会影响从叶子到根路径上的所有log N个祖先区间。因此,每个更新最多被分摊O(log N)次,最终得到均摊O(log² N) 的区间大小(即操作成本)。
通过常数次交错扫描执行重排,在缓存无关模型中,这对应于均摊O(log² N / B) 次内存传输。
总结与延伸
这样,我们就完成了有序文件维护。这个结果可以追溯到1981年,并在2000年被引入缓存无关领域。一个开放问题是,在必须保持常数大小间隙和线性数组大小的限制下,O(log² N)是否是最优的。已知存在最坏情况复杂度版本,但更复杂。
接下来,我们通过放宽限制来探索一个密切相关的问题。
列表标记
有序文件维护要求数组大小严格线性,且物理移动项。如果我们放松对空间的要求,并允许只修改标签(相当于逻辑上移动项),就得到了列表标记问题。
问题定义
我们需要维护一个动态链表。每个节点存储一个整数标签。链表中的标签必须始终保持严格单调递增(沿着链表方向)。我们需要支持插入、删除节点,并且可以随时动态修改节点的标签以保持顺序。
这本质上与有序文件维护相同,只是“移动项”对应于修改一个整数标签,并且我们允许标签空间(即数组索引范围)是超线性的。
已知结果
列表标记的性能取决于允许的标签空间大小:
- 线性空间 (或 1+ε 空间):最佳已知更新时间为 O(log² N)(均摊或最坏情况)。这与有序文件维护结果一致。
- 多项式空间 (如 N²):最佳已知更新时间为 O(log N)。存在特定模型下的下界,表明这类数据结构至少需要Ω(log N)时间。
- 指数空间 (如 2^N):可以轻松实现 O(1) 更新时间(通过不断对分区间),但标签会变得极大。
关键思路:当拥有多项式空间时,密度阈值可以设置为指数级变化(例如1/α^d,其中α>1),而不是线性插值。这消除了一个log N因子,因为现在密度有更大的变化范围,节点从“超出阈值”到“远在阈值内”所需的更新次数更多。
列表顺序维护
列表标记的一个变体是列表顺序维护问题,这正是我们在第一讲完全持久化中需要的黑盒。
问题定义:维护一个动态链表,支持插入、删除,以及顺序查询:给定两个节点X和Y,判断X是否在Y之前。
目标:所有操作(插入、删除、查询)达到常数均摊时间。
解决方案:使用分层结构。
- 顶层:将大约 N/log N 个“组”的元数据,使用标签空间为N²的列表标记数据结构维护,更新时间O(log N)。
- 底层:每个组实际包含Θ(log N)个节点。每个组内部使用标签空间为
2^(log N) = N的列表标记(指数空间方案),实现常数时间操作。 - 复合标签:一个节点的完整标签是
(顶层组标签, 组内标签)。比较时,先比较顶层标签,若相同再比较组内标签。 - 更新策略:插入时,放入对应组。如果组过大则分裂,过小则合并。组的分裂/合并会触发顶层数据结构的O(log N)时间更新,但每次分裂/合并由Θ(log N)次底层更新引起,因此成本可以分摊,最终实现常数均摊时间。
这与简单使用N³标签空间的列表标记不同,因为这里通过修改一个顶层标签,同时隐式地修改了其下所有log N个节点的部分标签,从而实现了高效性。
现在,让我们转向一个完全不同的数据结构。
缓存无关优先队列
之前我们介绍了缓存无关B树。如果不需搜索,只需删除最小元素,优先队列可以更快。本节介绍一种缓存无关优先队列,它能同时适应B和M。
性能目标
已知在缓存无关模型中,排序N个元素需要 (N/B) * log_{M/B} (N/B) 次内存传输。对于优先队列,我们希望每次插入和删除最小值的均摊成本为 (1/B) * log_{M/B} (N/B)。当B > log N时,这通常小于1,意味着我们平均每B次操作才需支付一次内存传输成本。
数据结构概览
该结构由一系列按大小递增的“层”组成,大小呈双重指数增长(例如,大小序列为 ..., x, x^(3/2), x^(9/4), ...)。最小元素在底部(大小恒定),较大元素在上层。
每一层(例如大小为L = x^(3/2)的层)包含:
- 一个上缓冲区:大小为
L。存放正在“上浮”的元素。 - 多个下缓冲区:大约
L^(1/2)个,每个大小为Θ(x)。存放已基本到达正确位置的元素,它们之间是排序的(即一个下缓冲区的所有元素小于下一个下缓冲区的所有元素)。
关键不变量:
- 同一层内,所有上缓冲区中的元素都大于所有下缓冲区中的元素。
- 不同层的下缓冲区之间,下层元素小于上层元素(对于已就位元素)。
- 上缓冲区中的元素仍在寻找其最终位置,可能继续上浮。
数据在内存中按层顺序存储。
插入操作
- 插入底部:新元素插入最底层的上缓冲区。
- 局部调整:在底层内部,可能需要与下缓冲区交换元素以保持顺序(因为底层很小,可常数时间完成)。这可能导致底层上缓冲区大小增加。
- 上推:如果某层(大小为x)的上缓冲区溢出(达到Θ(x)),则执行
Push(x)操作:- 排序:对该上缓冲区的x个元素进行排序。成本为
(x/B) * log_{M/B} (x/B)。 - 分发:将排序后的元素序列,按顺序扫描下一层(大小为
x^(3/2))的下缓冲区。将元素放入合适的下缓冲区,保持顺序。如果下缓冲区溢出,则将其分裂。如果下缓冲区数量过多,则将最后一个下缓冲区移入本层的上缓冲区。 - 递归:如果本层的上缓冲区在分发后溢出,则递归地对上一层执行
Push操作。
- 排序:对该上缓冲区的x个元素进行排序。成本为
成本分析
Push(x)操作的主要成本在于排序。我们需要证明分发步骤的成本不超过排序。
分析:
- 假设“高缓存”条件:
M ≥ B²(或更一般地M = B^(1+ε))。 - 对于非常大的x(
x ≥ B²),分发成本约为x/B,主导项是排序成本中的(x/B)*log(...),因此分发相对免费。 - 对于非常小的x(
x < M),整个层可放入缓存,成本为0。 - 关键是在过渡区(
x ≈ B^(4/3)到B²)。此时,x^(1/2)(下缓冲区数量)≤ B。由于M/B ≥ B,我们可以将每个下缓冲区的末尾块保留在缓存中,使得访问这些块的成本消失,分发成本再次约为x/B。
因此,每次Push(x)的成本主要由排序步骤决定,即O((x/B) * log_{M/B} (x/B))。
分摊成本
一个元素可能被多次上推。但Push(x)操作处理x个元素,因此每个元素分摊到的成本是排序成本除以x,即O((1/B) * log_{M/B} (x/B))。
由于层大小双重指数增长,对不同层的x求和形成一个几何级数,最终每个插入操作的总分摊成本由最大层(大小≈N)决定,即 O((1/B) * log_{M/B} (N/B))。
删除最小元素操作类似,涉及“下拉”过程,分析也类似。
总结
本节课我们一起学习了三种重要的缓存无关技术:
- 有序文件维护:通过维护密度阈值在概念二叉树区间内重平衡,实现了O(log² N / B)的更新效率,是缓存无关B树的基础。
- 列表标记与列表顺序维护:通过放宽空间限制和改进阈值策略,获得了更好的更新复杂度;并利用分层结构,以常数均摊时间解决了完全持久化中的顺序查询问题。
- 缓存无关优先队列:利用双重指数增长的层结构、上下缓冲区以及排序-分发策略,实现了适应B和M的高效插入和删除最小元素操作,均摊成本达到理论最优的
O((1/B) * log_{M/B} (N/B))。

这些数据结构展示了在内存层次结构中,通过精心设计布局和操作顺序,即使在没有明确参数知识的情况下,也能实现高效数据访问的巧妙思想。
009:Cache-Oblivious Structures II


在本节课中,我们将学习如何将缓存无关技术应用于二维几何数据结构问题。我们将从批处理正交范围搜索开始,介绍一种称为分布扫描的新技术,然后深入探讨在线正交范围搜索的缓存无关解决方案,特别是令人惊叹的“两向”查询结构。
缓存无关最优排序
上一节我们介绍了缓存无关数据结构的基本概念,本节我们首先来看一个核心算法:缓存无关最优排序。
排序是许多算法的基础。在缓存无关模型中,最优的排序复杂度是 O((N/B) log_{M/B} (N/B))。我们将使用一种称为惰性漏斗排序的算法来实现这个界限。
漏斗排序本质上是一种归并排序。其核心组件是一个 K-漏斗。一个 K-漏斗可以合并 K 个已排序的列表,这些列表的总大小为 K³。合并这些列表所需的内存传输次数为 O(K³/B * log_{M/B} (K³/B) + K)。
一个 K-漏斗的构造是递归的。它本质上是一个二叉树,但在树的每一层之间加入了缓冲区。根漏斗有 K 个输入。我们将其递归地构建为 √K 个 √K-漏斗。每个子漏斗之间以及子漏斗与父漏斗之间都有缓冲区。关键之处在于缓冲区的大小被设置为 K^(3/2)。这样,所有缓冲区的总大小约为 K²,与输出大小 K³ 相比是线性的。
合并算法是惰性的。要填充一个输出缓冲区,我们对其下方的两个子缓冲区进行标准的二路归并。如果其中一个子缓冲区变空,我们就递归地“填充”那个子缓冲区。这个简单的惰性策略,结合递归的漏斗布局,就能实现最优的缓存无关排序界限。
批处理正交范围搜索
现在,我们利用排序技术来解决几何问题。我们首先处理批处理版本的正交二维范围搜索:给定 N 个点和 N 个矩形,找出所有落在每个矩形内的点。
我们希望达到排序的界限:O((N/B) log_{M/B} (N/B) + Z/B),其中 Z 是输出的总点数。
为此,我们引入一种称为分布扫描的技术。它是扫掠线算法和缓存无关排序的结合。基本思想是在对一个坐标(例如 x)进行分治归并排序(使用漏斗排序)的过程中,在归并步骤(即“合并”阶段)维护关于另一个坐标(y)的辅助信息。
以下是解决范围计数问题(计算每个点被多少个矩形包含)的核心步骤:
- 首先,使用惰性漏斗排序,将所有点和矩形的角点按 x 坐标排序。
- 然后,我们按 y 坐标进行分布扫描(即归并排序)。在归并两个已按 y 排序的列表时(它们对应相邻的 x 区间),我们维护两个计数器:
CL:活跃的、左角在当前左区间且完全跨越右区间的矩形数量。CR:活跃的、右角在当前右区间且完全跨越左区间的矩形数量。
- 当我们在归并过程中遇到一个点时(按 y 顺序),我们就知道它被
CL + CR个额外的矩形所包含(这些矩形在之前的递归层次中未被发现),并将这个数量加到该点的计数上。
通过这种方式,我们可以在排序的过程中完成计数。对于完整的范围报告问题,还需要更复杂的步骤来管理输出缓冲区的大小,但核心思想是利用分布扫描来组织计算。
在线正交二维范围搜索
接下来,我们转向更困难的在线静态问题:预先存储 N 个点,然后能够回答矩形查询。目标是达到 O(log_B N + Z/B) 的查询时间,其中 Z 是输出点数。
我们根据矩形的边数来分类问题:
- 两向查询:形如
x ≤ x_q 且 y ≤ y_q的象限查询。 - 三向查询:形如
x1 ≤ x ≤ x2 且 y ≤ y_q的查询。 - 四向查询:完整的矩形查询
x1 ≤ x ≤ x2 且 y1 ≤ y ≤ y2。
我们将重点介绍最令人惊奇的两向查询缓存无关解决方案,然后扩展到更多边的查询。
两向查询的魔法结构
这个结构由 Arge 和 Zeh 在 2006 年提出,其思想非常巧妙。
数据结构:
- 我们有一棵基于 y 坐标的 van Emde Boas 布局的二叉树,存储所有点。
- 树中每个叶子之间的位置(对应一个 y 区间)包含一个指针,指向一个数组
A。
查询算法 (x_q, y_q):
- 在 vEB 树中搜索
y_q。这需要 O(log_B N) 次内存传输。 - 跟随指针找到数组
A中的起始位置。 - 从该位置开始,线性扫描数组
A,报告所有满足x ≤ x_q的点,直到遇到第一个x > x_q的点为止。
这看起来好得令人难以置信——仅在一次搜索之后,我们就进行线性扫描。关键在于,我们必须构建数组 A,使得对于任何查询,扫描过的点数 O(Z),其中 Z 是实际在查询范围内的点数,并且整个数组 A 的总大小为 O(N)。
数组 A 的构造:
构造过程是递归的,核心思想是区分“稠密”和“稀疏”查询。
- 对于一个查询
(x, y),如果满足x' ≤ x的点总数 ≤ α * (实际在(x, y)象限内的点数),则称该查询在某个点集上是稠密的(α > 1 是常数)。 - 否则,查询是稀疏的。
我们递归地构建一系列点集 S_0, S_1, ... 和对应的数组 P_0, P_1, ...:
S_0是所有点,按 x 排序。- 定义
y_i为在S_{i-1}上存在稀疏查询的最大 y 坐标。 - 定义
x_i为使得查询(x_i, y_i)在S_{i-1}上稀疏的最大 x 坐标。 - 定义
P_{i-1}为S_{i-1}中所有x ≤ x_i的点。 - 然后,定义
S_i为S_{i-1}中满足(y ≤ y_i) 或 (x ≥ x_i)的点。换句话说,S_i去掉了P_{i-1}中位于“稀疏点”(x_i, y_i)左上方的点(这些点会导致后续查询稀疏)。 - 数组
A就是按顺序存储P_0, P_1, ...以及最后一个很小的S_k。
为何有效:
- 线性大小:由于
(x_i, y_i)在S_{i-1}上是稀疏的,这意味着P_{i-1}中只有一小部分点实际位于查询象限内。我们可以将存储P_{i-1}的成本“分摊”到那些被丢弃的点上,每个点只被分摊常数次,因此总大小为 O(N)。 - 查询正确性:对于一个查询
(x_q, y_q),我们通过 vEB 树找到第一个y_i ≤ y_q的层。从这个层的P_i开始扫描。由于构造方式,当我们扫描完P_i时,可能还没有达到x_q,但我们会继续扫描P_{i+1}, P_{i+2}, ...。关键点是,每个后续的P_j都从 x 的起点重新开始,并且包含的点集是前一个点集的子集。因此,我们最终会扫描到包含所有满足x ≤ x_q且y ≤ y_q的点的那个P_j,并且扫描的总点数是 O(Z)。 - 查询时间:搜索 vEB 树花费 O(log_B N)。扫描过程,根据上述分摊分析,总共访问 O(Z) 个点,因此传输次数为 O(Z/B)。
从两向扩展到三向和四向
有了两向查询结构,构建更多边的查询结构就相对直接了。
三向查询:
- 我们在一棵按 x 坐标组织的 vEB 布局二叉树上构建。
- 每个树节点存储两个两向查询结构:一个处理
(x ≥ x_node, y ≤ y_q),另一个处理镜像查询(x ≤ x_node, y ≤ y_q)。 - 要回答查询
(x1 ≤ x ≤ x2, y ≤ y_q),我们搜索 x1 和 x2,找到它们的最近公共祖先。在左子路径的节点上,使用第一种两向结构(因为 x ≥ x1 已知)。在右子路径的节点上,使用第二种两向结构(因为 x ≤ x2 已知)。合并结果即可。 - 每个点出现在 O(log N) 个节点中,因此空间为 O(N log N)。查询时间约为 O(log_B N + Z/B)。
四向查询:
为了进一步优化,我们可以使用一种技巧来减少空间因子。
- 不使用二叉树,而是使用一棵 √(log N)-叉树。
- 和之前一样,每个节点存储左、右两向结构(现在处理三向查询)。
- 关键新增:对于任何连续的子节点区间,我们存储该区间内所有点的一个简单的一维(按 y 排序)范围树。
- 查询时,在找到 LCA 后,左右两侧的不完全子树用三向结构处理,而中间完整的子节点区间则可以通过查询对应的一维范围树来高效解决(因为 x 范围已经固定)。
- 由于树高降低为 O(log N / log log N),且每个点只出现在这么多层的三向结构中,而一维结构的空间是线性的,因此总空间可以优化到 O(N log² N / log log N),查询时间仍为 O(log_B N + Z/B)。
总结
本节课我们一起学习了缓存无关算法在几何数据结构中的高级应用。
我们首先介绍了惰性漏斗排序,这是实现缓存无关最优排序的基础算法。接着,我们学习了分布扫描技术,它将排序与扫掠线算法结合,能够高效解决批处理几何问题,例如正交范围计数。
最后,我们深入探讨了在线正交范围搜索的缓存无关解决方案。其中,用于两向查询的线性扫描结构尤为精妙,它通过巧妙的递归构造和稀疏性分析,将二维搜索问题转化为一次树搜索加一次线性扫描。基于此,我们可以通过构建分层结构并将其与多叉树结合,扩展到三向和四向查询,在接近最优的查询时间内解决问题。

这些技术展示了缓存无关模型在处理复杂几何问题时的强大能力,以及通过精心设计数据布局和算法来隐藏内存层次结构的潜力。
010:字典

在本节课中,我们将要学习哈希技术。哈希是计算机科学中最常见的数据结构之一,几乎出现在每一门算法课程中。我们将快速回顾一些你可能已经了解的知识,然后深入探讨一些你可能不知道的内容。首先,我们会介绍不同类型的哈希函数,包括K-独立性和一种近年来被广泛分析的新技术——简单列表哈希。接着,我们将探讨如何利用这些哈希函数构建数据结构,包括链式哈希、完美哈希、线性探测以及布谷鸟哈希。
哈希基础概念
哈希的基本思想是将一个巨大的键值全域映射到一个相对较小的表中。我们称哈希函数为 h,全域为从0到 u-1 的整数,记作 U。我们有一个表用于存储数据,其索引从0到 m-1,m 是表的大小。通常我们希望 m 大约等于 n,即实际存储在表中的键的数量。哈希函数将整数(或可映射为整数的对象)映射到表的槽位中。
理想情况下,我们希望使用完全随机的哈希函数。这意味着对于任何键 x,它映射到任何特定槽位 t 的概率是 1/m,并且所有键的映射都是独立的。这提供了理想的哈希性能,但问题在于,要存储这样一个完全随机的函数需要 u * log m 比特的信息,这通常过于庞大,无法实际存储。
通用哈希
为了克服完全随机哈希的存储问题,我们使用通用哈希。我们从一个较小的哈希函数族中均匀随机地选择一个哈希函数。这个族的大小远小于所有可能的函数,因此可以用更少的比特来编码。我们希望这个族满足以下性质:对于任意两个不同的键 x 和 y,它们发生碰撞(即 h(x) = h(y))的概率大约为 1/m。这被称为通用哈希。
以下是两个通用哈希函数的例子:
- 乘法取模法:选择一个随机整数 a,计算
h(x) = (a * x) mod p mod m,其中 p 是一个大于 u 的质数。 - 乘法移位法:当 u 和 m 都是2的幂时,计算
h(x) = (a * x) >> (log u - log m),这相当于取乘法结果的高位比特。
K-独立性哈希
K-独立性是比通用性更强的性质。一个哈希函数族是 K-独立 的,如果对于任意 K 个不同的键 x1, x2, ..., xK 和任意 K 个槽位 t1, t2, ..., tK,有:
P[h(x1)=t1, h(x2)=t2, ..., h(xK)=tK] = O(1 / m^K)
这表示任意 K 个键的哈希值是(近似)独立的。K-独立性蕴含了通用性。
一个经典的K-独立哈希函数例子是使用随机系数的多项式:
h(x) = (a_{k-1} * x^{k-1} + ... + a_1 * x + a_0) mod p mod m
其中系数 a_i 是随机选取的。这种方法的计算成本是 O(k)。近年来,研究者们提出了更高效的K-独立哈希方案,例如:
- Thorup 和 Zhang (2004):实现了常数查询时间的对数阶独立性,但需要较大的存储空间。
- Siegel (2004):实现了常数查询时间的对数阶独立性,同样需要较大的空间。
简单列表哈希
简单列表哈希是一种既简单又强大的哈希技术。其思想是将一个整数键 x 分割成 c 个字符(每个字符是键的一部分)。然后,为每个字符位置维护一个完全随机的小型查找表 T_i。哈希值通过将这些表的输出进行异或运算得到:
h(x) = T_1(x_1) XOR T_2(x_2) XOR ... XOR T_c(x_c)
这种方法计算简单(O(c) 时间),并且已知它是3-独立的。然而,最近的分析表明,在许多哈希方案中,简单列表哈希的性能几乎与对数阶独立哈希一样好。
链式哈希
上一节我们介绍了哈希函数,本节我们来看看如何用它们构建第一个数据结构:链式哈希。这是最常见的哈希表实现方式。我们有一个哈希函数 h 将键映射到表的槽位。如果多个键映射到同一个槽位,我们将其存储在一个链表中(例如,链表)。
对于一个特定的槽位 t,其链表的期望长度是 n/m(负载因子)。如果我们通过表扩张保持 m = Θ(n),那么期望链长就是常数。然而,我们更关心高概率下的性能。对于完全随机的哈希函数,最长链的长度高概率为 O(log n / log log n)。这个分析使用了切尔诺夫界。
一个有趣的现象是,如果我们考虑一个包含 Θ(log n) 次操作的批次,并假设有一个缓存能记住最近访问过的 log n 个项,那么链式哈希可以实现常数分摊时间(高概率)。这是因为在这个批次中,总共需要探查的链中元素数量高概率为 O(log n),分摊下来每次操作就是常数时间。
对于通用哈希函数,期望链长仍然是常数。但要达到 O(log n / log log n) 的高概率链长,则需要 Ω(log n / log log n)-独立的哈希函数。简单列表哈希也能提供良好的链长性能。
完美哈希
为了消除链式哈希中的探查,我们可以使用完美哈希(也称为FKS哈希)。其思想是使用两级哈希。第一级使用一个哈希函数 h 将键分配到 m 个桶中。与链式哈希不同,每个桶 i 不再用链表存储,而是用一个次级哈希表 T_i 来存储所有落入该桶的键,并且保证这个次级哈希表内无碰撞。
次级哈希表的大小取为桶中键数量的平方,即 Θ(c_i^2),其中 c_i 是桶 i 中的键数。根据生日悖论,如果哈希函数是通用的,那么在一个大小为 c_i^2 的表中不发生碰撞的概率至少是 1/2。如果发生碰撞,只需选择另一个哈希函数重试,期望常数次尝试后即可成功。
所有次级哈希表的总空间期望是线性的,因为键对碰撞的总数期望是 O(n^2/m),当 m = Θ(n) 时是 O(n)。构建过程在期望线性时间内完成。查询时,只需计算两级哈希函数并访问两个位置,因此是确定性常数时间。通过巧妙的重建策略,插入和删除也可以实现分摊常数时间(期望或高概率)。
线性探测
线性探测可能是最简单的开放寻址哈希方案。要插入一个键 x,我们计算 h(x)。如果该槽位为空,则插入。如果被占用,则顺序检查下一个槽位(即 h(x)+1, h(x)+2, ...),直到找到空槽为止。
传统观点认为线性探测性能很差,因为“富者愈富”(长的连续占用区域更容易增长)。然而,理论分析表明,如果哈希函数足够好,线性探测的性能非常出色。对于完全随机的哈希函数,每次操作的期望时间是 O(1/ε^2),其中 ε 是表空闲部分的比例(即 m = (1+ε)n)。当 ε 为常数时,期望时间就是常数。
关键在于所需的哈希函数独立性:
- 5-独立性 足以保证常数期望时间。
- 4-独立性 则不足,可能导致 Ω(log n) 的查询时间。
- 简单列表哈希 也能为线性探测提供常数期望时间。
线性探测在实践中性能极佳,部分原因是它具有良好的缓存局部性:一旦访问到某个内存区域,后续的连续探查很可能在同一个缓存块中。
我们可以通过一个基于二叉树概念的简洁证明来理解线性探测的性能。将哈希表数组想象成一棵隐式的二叉树。定义一个节点是“危险”的,如果映射到该节点对应区间的键的数量超过了区间长度的 2/3。对于完全随机哈希,一个高度为 h 的节点是危险的概率非常小(双指数衰减)。然后可以证明,任何长的连续占用区域(“运行”)必然覆盖至少一个危险节点。因此,长运行的概率也很小,从而期望运行长度是常数。
布谷鸟哈希
布谷鸟哈希是一种使用两个哈希表和两个哈希函数的方案。设两个哈希函数为 g 和 h,两个表为 A 和 B。一个键 x 可以存放在两个位置:A[g(x)] 或 B[h(x)]。
查询时,只需检查这两个位置,因此是确定性常数时间(两次探测)。插入时,首先尝试放入 A[g(x)]。如果为空,则插入。如果被占用,则“踢出”原有的键 y,将 x 放入 A[g(x)]。然后,尝试将被踢出的键 y 放入它的另一个位置 B[h(y)]。如果 B[h(y)] 也被占用,则重复这个过程,踢出另一个键,形成一连串的置换。如果这个过程持续进行了很多步(或者形成循环),则插入失败,需要选择新的哈希函数重建整个表。
对于完全随机或对数阶独立的哈希函数,插入的期望时间是常数。重建的概率很小(大约每插入 Ω(n^2) 个键才需要一次重建),因此分摊成本是常数。有趣的是,6-独立性并不足以保证常数期望时间,而简单列表哈希则能提供接近最优的失败概率(约 1/n^{1/3})。
关于布谷鸟哈希插入路径长度的一个巧妙证明使用了信息论论证。如果存在一条很长的插入路径,那么我们可以用比标准编码更少的比特来编码哈希函数 g 和 h。但由于哈希函数是完全随机的,这种“节省”只可能以很小的概率发生,从而证明了长路径的概率是指数级小的。
总结

本节课我们一起学习了哈希技术的核心概念和多种数据结构。我们从哈希函数开始,介绍了通用哈希、K-独立性哈希和简单列表哈希。接着,我们探讨了如何利用这些函数构建高效的字典结构:链式哈希提供了基础的常数期望时间操作;完美哈希通过两级结构实现了确定性常数查询时间;线性探测在拥有良好哈希函数时,不仅理论性能优秀,而且具有极佳的缓存效率;最后,布谷鸟哈希以其独特的双表设计和确定性查询提供了另一种高效选择。每种方案都在理论保证、实现复杂度和实际性能之间有不同的权衡,理解这些将帮助我们为特定应用选择合适的哈希策略。
011:整数模型与前置查询问题


在本节课中,我们将学习整数数据结构的基础模型,并深入探讨一个经典问题:前置查询问题。我们将了解不同的计算模型,并介绍解决该问题的核心数据结构——van Emde Boas树。

计算模型概述
在深入数据结构之前,我们需要明确计算模型。对于整数数据结构,我们通常在一个统一的“字”概念下工作。
字 是一个W位的整数。它定义了数据的大小范围,即宇宙的大小:
[
U = 2^W
]
1. 跨二分随机存取机
这是一种基础模型。内存是一个由“字”组成的数组,每个操作可以读写常数个字。关键特性是,字本身可以作为指针来索引内存。这要求字的大小W至少能索引整个内存空间:
[
W \ge \log S
]
其中S是空间界限。由于我们至少需要存储N个元素,因此通常有 ( W \ge \log N )。
2. 字RAM模型
这是跨二分RAM的一个具体化版本,它限制了允许的操作,使其更贴近真实计算机。允许的常数时间操作包括:
- 算术运算:加、减、乘、除、取模。
- 位运算:按位与、或、异或、非、左移、右移。
- 随机存取:数组解引用。
字RAM模型因其自然性和实用性,已成为整数数据结构的标准模型。
3. 单元探测模型
这是一个用于理论下界分析的简化模型。在此模型中,我们只计算读取或写入内存单元(字)的次数,而忽略所有计算成本。虽然不现实,但它为证明任何算法必须访问的最小数据量提供了有力的工具。
模型层次关系
从最强到最弱,我们接触过的模型大致如下:
- 单元探测模型:只计数内存访问。
- 跨二分RAM:允许任何常数个字操作。
- 字RAM:限制为标准计算机指令。
- 指针机:通过指针链接数据。
- 二叉搜索树(比较模型):仅允许元素比较。
前置查询问题定义
上一节我们介绍了计算模型,本节中我们来看看在这些模型下要解决的核心问题。
我们维护一个动态集合 ( S ),其中的元素来自一个大小为 ( U = 2^W ) 的宇宙。我们希望支持以下操作:
insert(x): 将元素 ( x ) 插入集合 ( S )。delete(x): 从集合 ( S ) 中删除元素 ( x )。predecessor(x): 返回集合 ( S ) 中小于或等于 ( x ) 的最大元素。successor(x): 返回集合 ( S ) 中大于或等于 ( x ) 的最小元素。
在比较模型(如二叉搜索树)中,这些操作需要 ( O(\log N) ) 时间。我们的目标是在字RAM模型上做得更好。
已知结果与下界
以下是前置查询问题的一些重要结果,它们展示了利用字RAM的位并行能力所能达到的优异性能:
- van Emde Boas 树:实现 ( O(\log \log U) ) 时间操作,但需要 ( O(U) ) 空间。
- 改进的 van Emde Boas 树(结合散列):实现 ( O(\log W) ) 时间(高概率),仅需 ( O(N) ) 空间。
- Y-Fast 树:同样实现 ( O(\log W) ) 时间(高概率)和 ( O(N) ) 空间,结构更简单。
- 融合树:针对字长 ( W ) 非常大的情况,实现 ( O(\log_W N) ) 时间(高概率)和 ( O(N) ) 空间。
这些方法的性能可以概括为取以下两者的最小值:
[
O(\min(\log W, \log_W N))
]
当 ( W ) 约为 ( 2^{\sqrt{\log N}} ) 时,两者平衡,得到 ( O(\sqrt{\log N}) ) 的查询时间,这比二叉搜索树的 ( O(\log N) ) 有显著提升。
匹配的下界(在单元探测模型中)表明,对于静态前置查询,若使用 ( N \cdot \text{polylog}(N) ) 空间,则查询时间至少为:
[
\Omega\left( \min\left( \frac{\log W}{\log \log N}, \log_W N \right) \right)
]
这意味着上述数据结构在大多数实际情况下(( W = \text{polylog}(N) ) 时)几乎是最优的。
van Emde Boas 树核心思想
van Emde Boas 树的核心在于一个巧妙的分治递归,其时间复杂度满足以下递推式:
[
T(U) = T(\sqrt{U}) + O(1)
]
解这个递推式,我们得到 ( T(U) = O(\log \log U) )。
分层坐标系统
为了实现这个递归,我们将宇宙 ( [0, U-1] ) 划分为 ( \sqrt{U} ) 个簇,每个簇的大小为 ( \sqrt{U} )。对于一个元素 ( x ),我们可以用两个坐标 ( (c, i) ) 表示:
- ( c = \lfloor x / \sqrt{U} \rfloor ):簇编号。
- ( i = x \bmod \sqrt{U} ):簇内索引。
在二进制视角下,这相当于将 ( x ) 的 ( W ) 位分成高 ( W/2 ) 位(簇号 ( c ))和低 ( W/2 ) 位(索引 ( i ))。这些转换在字RAM上可以通过位移和掩码操作在常数时间内完成。
递归结构
一个 van Emde Boas 结构(vEB)包含以下部分:
- 簇数组:( \sqrt{U} ) 个更小的 vEB 结构,每个负责管理一个簇内的元素(递归地,宇宙大小变为 ( \sqrt{U} ))。
- 摘要结构:一个 vEB 结构,用于记录哪些簇是非空的(即存储了簇编号 ( c ))。
- 最小值:存储整个集合中的最小元素。关键点:这个最小值不递归存储在下层簇中。
- 最大值:存储整个集合中的最大元素(可以递归存储)。
这种“最小值不递归存储”的设计,是保证更新操作只触发一次递归调用的关键。
操作算法详解
上一节我们介绍了van Emde Boas树的递归结构,本节中我们来看看如何在这个结构上实现高效的操作。
查询后继
以下是 successor(x) 操作的伪代码。查询前驱 predecessor(x) 与之对称。
def vEB_successor(V, x):
if x < V.min:
return V.min # 特殊情况:x小于全局最小
# 计算x所在的簇c和簇内索引i
c = high(x)
i = low(x)
if i < V.cluster[c].max:
# 情况1:后继在同一簇内
j = vEB_successor(V.cluster[c], i)
return index(c, j) # 合并簇号和索引
else:
# 情况2:后继在后续的簇中
c_succ = vEB_successor(V.summary, c)
j = V.cluster[c_succ].min
return index(c_succ, j)
算法逻辑:
- 首先处理
x小于当前结构最小值的特殊情况。 - 计算
x的簇c和簇内索引i。 - 如果
i小于簇c的最大值,说明后继就在这个簇内,我们递归地在簇c中寻找i的后继j,然后组合(c, j)返回。 - 否则,后继在下一个非空簇中。我们递归地在摘要结构中寻找簇
c的后继c_succ,然后返回该簇的最小值。
这个算法只进行了一次递归调用(要么进入簇,要么进入摘要),因此满足了 ( T(U) = T(\sqrt{U}) + O(1) ) 的递推关系。
插入元素
insert(x) 操作需要小心维护最小值和最大值,并处理空簇的创建。
def vEB_insert(V, x):
if V.min is None:
# 情况1:结构为空
V.min = V.max = x
return
if x < V.min:
# 情况2:x成为新的最小值,交换
swap(x, V.min)
# 现在将x插入到它应该去的簇中
c = high(x)
i = low(x)
if V.cluster[c].min is None:
# 情况3:目标簇为空,需要先在摘要中标记
vEB_insert(V.summary, c)
vEB_insert(V.cluster[c], i)
# 更新最大值
if x > V.max:
V.max = x
算法逻辑与关键点:
- 如果结构为空,直接将
x设为最小值和最大值。 - 如果
x小于当前最小值,则交换x与当前最小值。这样,原最小值成为了需要被插入到下层结构中的元素,而x则保存在本层的min字段中。这是避免双重递归的关键。 - 计算
x应去的簇c。 - 如果簇
c为空,我们需要先在摘要结构中插入簇号c。这可能会触发一次递归。 - 然后,在簇
c中插入索引i。这也会触发一次递归。 - 更新最大值。
为什么不会导致双重深度递归?
关键在于第2步和第4步的交互。如果我们因为目标簇为空而递归插入了摘要(第4步),那么当执行第5步递归插入簇时,这个簇恰好是空的。对于空结构的插入,会落入第1种情况(if V.min is None),这只需要常数时间。因此,在任何插入路径上,我们最多只执行一次“深度”递归(要么是插入簇,要么是插入摘要,但不会两者都是深度递归)。
删除操作 delete(x) 遵循类似的逻辑:在簇中删除元素,如果簇变空则从摘要中删除其编号。同样,由于删除最后一个元素(即最小值)是常数时间操作,因此也能保证单次深度递归。
其他视角与优化
van Emde Boas 树有多种理解和实现方式,它们最终都通向相同的高效结果。
简单树视图与X-Fast树
我们可以将宇宙 ( U ) 视为一个完整的二叉树的叶子节点(共 ( U ) 个)。每个内部节点存储一个比特,表示其子树中是否存在元素(即子节点比特的“或”运算)。
在这个树中,从任意叶子节点到根的路径上的比特是单调的:从0开始,在某个祖先处变为1,之后保持为1。寻找前置或后继就转化为在这条路径上二分查找这个0到1的跳变点。找到后,通过查看兄弟子树的最小值或最大值,即可在常数时间内得到答案。
X-Fast树 直接实现了这个思想,但只存储比特为1的节点(通过哈希表),从而避免了 ( O(U) ) 的空间。然而,更新操作(需要修改整条路径)仍然需要 ( O(W) ) 时间。
通过间接层优化到线性空间
无论是原始的van Emde Boas结构还是X-Fast树,直接实现都可能面临空间过大(( O(U) ) 或 ( O(NW) ))或更新过慢的问题。
以下是两个关键的优化技巧,可以组合使用以达到 ( O(N) ) 空间和 ( O(\log W) ) 时间:
- 不存储空簇/使用哈希表:在van Emde Boas结构中,不使用数组存储所有簇,而是用一个动态完美哈希表只存储非空簇。每个非空簇的存在性由其“最小值”元素“支付”存储成本,从而将总空间降至 ( O(N) )。
- 间接:将元素分成大小为 ( \Theta(W) ) 的块。每个块用一个简单的数据结构(如二叉搜索树)管理,其操作成本为 ( O(\log W) )。再用一个“顶层”的van Emde Boas或X-Fast树来管理这些块。只有当块因插入过多而分裂或因删除过多而合并时,才需要更新顶层结构。由于 ( W ) 次底层更新才会引发一次顶层更新,通过摊还分析,可将顶层更新的成本分摊掉,从而使所有操作的整体时间维持在 ( O(\log W) )。
Y-Fast树 正是“X-Fast树 + 间接”的产物,它最终实现了 ( O(\log W) ) 查询(高概率)和 ( O(N) ) 空间的目标。
总结
本节课中我们一起学习了整数数据结构的计算模型,并深入探讨了前置查询问题。
- 我们明确了字RAM是研究整数问题的标准模型,它允许我们在常数时间内操作W位数据。
- 我们看到了如何通过分层递归(van Emde Boas树)或树上的二分查找(X-Fast树)将查询时间从比较模型的 ( O(\log N) ) 降低到 ( O(\log \log U) ) 或 ( O(\log W) )。
- 我们理解了通过不存储空数据和引入间接层,可以将空间优化到线性的 ( O(N) ),同时保持高效的操作时间。
- 这些结果几乎匹配了理论下界,展示了在字RAM模型上解决整数问题的强大能力。
下一讲,我们将学习另一种强大的数据结构——融合树,它在前置查询问题中,当字长 ( W ) 非常大时能提供更优的性能。
012:Fusion Trees


在本节课中,我们将学习一种名为Fusion Trees的整数数据结构。它利用字级并行和一系列巧妙的位操作技巧,实现了在静态、线性空间下的高效前驱/后继查询,目标时间复杂度为 O(log_W N)。我们将重点介绍其核心思想:草图计算、并行比较以及最高有效位的快速查找。
概述
Fusion Trees由Fredman和Willard于1990年提出,其核心目标是在字RAM模型上,实现对一组静态整数的高效前驱查询。其核心思想是构建一个分支因子约为 W^(1/5) 的B树,并利用“草图”技术压缩关键字的位数,从而能在常数时间内处理一个节点内的所有比较操作。
草图计算
上一节我们介绍了Fusion Trees的整体目标。本节中,我们来看看其核心操作之一:草图计算。
给定一个W位的整数x和一组“重要”的位索引 B0, B1, ..., B_{r-1}(r < K,K是节点中的关键字数量),完美草图是提取x在这些重要位上的值,并将其压缩成一个r位的字符串。这能保留关键字之间的顺序。
然而,完美草图计算在标准字RAM上并不直接高效。因此,我们使用近似草图。近似草图将重要位提取出来,并通过乘法操作将它们“扩散”到一个长度约为 r^4 的连续区间内,同时保持顺序。由于 r = O(W^(1/5)),所以 r^4 = O(W^(4/5))。对于一个节点内的 K = O(W^(1/5)) 个关键字,它们所有近似草图的总位数就是 O(W^(4/5) * W^(1/5)) = O(W),恰好能放入一个字中。
以下是近似草图的计算步骤:
- 掩码提取重要位:
x' = x & mask,其中mask在重要位 Bi 处为1。 - 乘以一个预计算的常数M:
y = x' * M。通过精心构造M,可以保证重要位 Bi 经过乘法偏移后(即 Bi + Mi)互不冲突,并且能按顺序落入一个长度为 O(r^4) 的连续区间。 - 二次掩码与移位:用一个掩码提取出包含这些重要位的区间,然后右移,使得草图位位于字的低端。
公式:sketch(x) ≈ ((x & mask) * M) & mask2) >> shift
并行比较
现在我们已经能够为查询Q和所有关键字Xi快速计算草图。接下来,我们需要在常数时间内找出sketch(Q)在所有sketch(Xi)中的位置。
思路是将所有sketch(Xi)拼接成一个长字,同时将sketch(Q)复制多份也拼接成一个结构相同的长字,然后通过一次减法操作一次性完成所有比较。
具体步骤如下:
- 构造节点草图字:
NodeSketch = 1 || sketch(x0) || 1 || sketch(x1) || ... || 1 || sketch(x_{K-1}),其中||表示拼接,1作为分隔符。 - 构造查询草图字:
QuerySketch = 0 || sketch(Q) || 0 || sketch(Q) || ... || 0 || sketch(Q),即用0作为分隔符重复sketch(Q)。 - 执行并行减法:
diff = NodeSketch - QuerySketch。 - 提取比较结果:对
diff应用一个掩码,提取出每个分隔符位置(原NodeSketch中1的位置)的位。如果sketch(Q) <= sketch(Xi),则该位为1;否则为0。由于sketch(Xi)是递增的,结果将是一个形如...000111...的位串。 - 定位分界点:通过另一个巧妙的乘法操作,可以计算出结果中
1的个数,这个计数就对应了sketch(Q)的排名i(即sketch(Q)位于sketch(Xi)和sketch(Xi+1)之间)。
代码思路:
// 假设 sketches 是所有 sketch(Xi) 的数组
uint64_t node_sketch = construct_node_sketch(sketches, K);
uint64_t query_sketch = construct_query_sketch(sketch_q, K);
uint64_t diff = node_sketch - query_sketch;
uint64_t cmp_bits = extract_comparison_bits(diff);
int rank = count_ones_special(cmp_bits); // 使用特定乘法计数
处理草图误差与最高有效位查找
通过并行比较,我们得到了草图空间中的排名i。然而,由于查询Q未参与草图函数的构建,sketch(Q)的排名可能并不对应Q在原始关键字Xi中的真实排名。
解决方案是找到Q与Xi或X_{i+1}的最长公共前缀(即它们在二叉Trie中分叉的位置)。这需要计算两个W位整数的异或,然后找到结果中最高位的1(Most Significant Set Bit, MSSB)。
因此,我们需要一个在常数时间内计算W位整数最高有效位1索引的操作。这本身是一个经典问题,Fusion Trees的方案同样巧妙地运用了草图与并行比较。
算法概要:
- 分块:将W位的字划分为√W个块,每块√W位。
- 生成摘要向量:判断每个块是否非空(即是否有任何位为1)。这可以通过对每个块的首位进行检查,以及对清除首位后的剩余部分进行“与零比较”来实现。
- 压缩摘要:将摘要向量(√W位)通过一个完美草图操作压缩到一个更短的、连续的位串中。由于此时位索引是规则分布的(间隔√W),可以构造出完美的乘法器M来实现无损压缩。
- 查找第一个非空块:对压缩后的摘要向量使用并行比较,将其与一系列2的幂次(
1, 2, 4, ...)进行比较,从而定位到最高位的1,即第一个非空块的索引c。 - 查找块内最高位:提取出第
c个块,同样使用并行比较在这个√W位的块内查找最高位的1,得到块内偏移d。 - 组合结果:最终最高有效位的索引为
c * √W + d。
公式:MSSB(x) = find_first_nonempty_cluster(x) * sqrt(W) + find_msb_in_cluster(extract_cluster(x, c))
融合节点查询流程
综合以上所有技术,一个Fusion Tree节点的查询流程如下:
- 计算查询Q的(近似)草图
sketch(Q)。 - 使用并行比较,找出
sketch(Q)在节点内所有关键字草图sketch(Xi)中的排名i。 - 计算Q与
Xi和X_{i+1}的异或,利用最高有效位查找找到它们首次分叉的位置(节点Y)。 - 根据分叉方向,构造一个修正值
E。如果Q在分叉处走了错误的方向(例如应为左却走了右),则将Y位之后的所有位“拉”到该子树的最右侧(全设为1),得到E。 - 再次计算
sketch(E),并使用并行比较找出其在sketch(Xi)中的排名j。这个j就是Q在原始关键字Xi中的真实前驱/后继排名。
总结
本节课我们一起学习了Fusion Trees这一强大的静态整数数据结构。其核心贡献在于:
- 引入了草图技术,利用乘法将关键信息压缩到可管理的位数。
- 通过并行比较,在常数时间内处理一个节点内的多次比较。
- 给出了一个基于草图与并行比较的、在标准字RAM上常数时间计算最高有效位的方法。
- 将这些技术融合到B树节点中,实现了 O(log_W N) 的查询复杂度。

Fusion Trees展示了如何通过深入理解机器字操作和算法设计,突破基于比较的模型下界,是理论与实践结合的典范。尽管实现复杂,但其思想影响深远。
013:整数下界


在本节课中,我们将学习前驱问题(Predecessor Problem)的下界。我们将证明,之前介绍的 van Emde Boas 树和融合树(Fusion Tree)所达到的性能,在多项式空间下,几乎是理论最优的。我们将通过通信复杂性和轮次消除(Round Elimination)这一简洁而强大的技术来证明这一点。
历史背景与问题概述
上一节我们介绍了融合树,它提供了前驱问题的一个高效上界。本节中,我们将证明一个几乎匹配的下界。
已知的前驱问题上界是 min(log W, log_W n)。我们将证明,对于任何静态的(即使没有更新操作)、使用多项式空间的数据结构,其查询时间下界为 Ω(min(log W / log log W, log_W n))。这个下界与上界仅相差一个 log log W 因子。
为了简化证明,我们将考虑一个更容易的问题:着色前驱问题。在这个问题中,集合中的每个元素被标记为红色或蓝色。查询时,给定一个键值,我们只需报告其前驱元素的颜色(红或蓝),而无需报告键值本身。显然,这比标准前驱问题更容易。我们将证明,即使对于这个更简单的问题,也存在上述下界,并且该下界也适用于能以大于1/2的概率给出正确答案的随机化数据结构。
通信复杂度视角
为了证明下界,我们引入通信复杂度模型。这个模型将数据结构的查询过程转化为两个参与者——Alice(查询算法)和Bob(内存/数据结构)——之间的通信问题。
- Alice 的输入是查询键值
x,这是一个W位的字。 - Bob 的输入是整个静态数据结构的内容
y。 - 他们的共同目标是计算函数
F(x, y),即着色前驱问题的答案(一个比特)。 - 通信以多轮方式进行,每轮对应一次内存读取:
- Alice 向 Bob 发送一个地址(
log S位,其中S是空间大小)。 - Bob 用该地址对应的字(
W位)回复。
- Alice 向 Bob 发送一个地址(
- 因此,一轮通信对应一次字读取。如果我们能证明任何通信协议都需要至少
T轮才能计算F(x, y),那么就证明了任何数据结构都需要至少T次探查(probe)。
在这个框架中,我们设:
a = log S(Alice 每轮发送的比特数,即地址长度)b = W(Bob 每轮回复的比特数,即字长)
我们的目标是证明查询轮数 T 的下界为 Ω(min(log_a W, log_b n))。
轮次消除引理
证明的核心工具是轮次消除引理。它适用于一般的通信问题。我们首先定义一个衍生问题 F^K。
问题 F^K 定义:
- Alice 有
K个输入:x_1, x_2, ..., x_K。 - Bob 有输入
y和一个整数i(1 ≤ i ≤ K)。此外,Bob 已经知道x_1, ..., x_{i-1}。 - 目标是计算
F(x_i, y)。
关键点在于,Alice 不知道 i 的值,而 Bob 不知道 x_i(但知道其他 x_j)。
轮次消除引理:
假设存在一个协议 P 来解决 F^K 问题,其中 Alice 先发言(发送第一条消息)。该协议使用 M 条消息,错误概率为 δ。
那么,存在另一个协议 P' 来解决原问题 F,其中 Bob 先发言,使用 M-1 条消息,且错误概率至多为 δ + O(√(a / K))。
直观理解:
在 F^K 协议中,Alice 的第一条消息(a 比特)必须涵盖所有 K 个 x_j 的信息。由于 i 是均匀随机的,这条消息中关于特定 x_i 的信息量期望只有 a/K 比特。在新协议 P' 中,我们让 Bob 先发言。由于 Bob 知道 i,他可以“猜测”Alice 原本会发送的、关于 x_i 的那部分信息(通过随机抛硬币)。猜错的概率大约为 O(√(a / K))。猜对后,问题就简化为计算 F(x_i, y),且 Alice 知道了 i,可以后续直接针对 x_i 进行通信。这样,我们就“消除”了 Alice 的第一条消息。
通过反复应用这个引理,我们可以消除所有消息。如果最终得到一个零消息协议,那么最好的策略就是随机猜测,错误概率至少为 1/2。如果我们初始协议的错误概率足够低,并且每次消除增加的错误概率足够小,那么经过 T 次消除后,总错误概率仍小于 1/2,这就与零消息协议的下限矛盾,从而证明原始协议必须使用至少 T 条消息。
应用于着色前驱问题
现在我们将轮次消除引理应用于着色前驱问题。证明过程是交替进行的:一次消除 Alice 到 Bob 的消息(对应探查内存),下一次消除 Bob 到 Alice 的消息(对应接收内存回复)。每次消除都会减小问题的规模。
以下是关键步骤:
1. 消除 Alice→Bob 的消息(类似 van Emde Boas 思想):
- 我们将 Alice 的查询字
x(W位)分割成K = Θ(a T^2)个块:x_1, x_2, ..., x_K。 - 我们考虑这样一类输入实例:所有数据元素共享
x_1, ..., x_{i-1}作为前缀,在x_i块上产生分叉,而后缀任意。 - 在这种情况下,找到全局前驱等价于在由
x_i块定义的“节点”内找到前驱。这正好构成了一个F^K问题,其中F是节点内的着色前驱问题。 - 应用引理,我们消除了 Alice 的第一条消息。同时,问题的字长 W 减小了约
K倍(即W' → W' / (a T^2))。
2. 消除 Bob→Alice 的消息(类似融合树思想):
- 现在考虑 Bob 的数据,它包含
n个W位的键。 - 我们根据键的最高若干位,将它们分割到
K = Θ(b T^2)个桶(子树)中。每个桶包含约n / (b T^2)个元素。 - 我们考虑这样一类输入实例:查询键的最高位决定了它属于哪个桶。
- 在这种情况下,Bob 需要先知道查询键属于哪个桶,才能提供有用信息。这构成了一个角色反转的
F^K问题(Bob 有多个输入y_1...y_K,Alice 知道索引i)。 - 应用引理,我们消除了 Bob 的第一条消息。同时,问题的元素数量 n 减小了约
K倍(即n' → n' / (b T^2)),字长W也略微减少(减去分割所用的位数)。
3. 交替进行与终止条件:
我们交替执行上述两种消除。每一轮“Alice→Bob + Bob→Alice”的消除构成完整的一轮,使 W 和 n 同时显著减小。
这个过程会持续直到:
- 情况A:字长
W减少到常数(即W被耗尽)。 - 情况B:元素数量
n减少到常数(即n被耗尽)。
在 T 轮消除后,如果问题规模还未降到常数,意味着我们成功消除了所有 2T 条消息,得到了一个零消息协议,这将导致矛盾(因为零消息协议错误率 ≥ 1/2,而我们通过精心设置参数可使总错误率 < 1/2)。因此,T 必须足够小,使得在 T 轮内问题规模就会降到常数。
4. 推导下界:
从规模缩减速率可以推导出:
- 要使
W在T轮内降至常数,需要T = Ω(log_{a T^2} W)。 - 要使
n在T轮内降至常数,需要T = Ω(log_{b T^2} n)。
由于已知存在 O(log W)(van Emde Boas)和 O(log n)(二叉搜索树)的上界,可以推断 T 最多为 O(min(log W, log n))。代入并简化上述表达式,最终得到下界:
T = Ω(min( log_a W, log_b n )),其中 a = log S(空间相关),b = W。
对于多项式空间 S = poly(n),有 a = Θ(log n),b = W,则下界变为:
T = Ω(min( log_W n, log W / log log n ))。
这与 van Emde Boas 树和融合树的上界 O(min( log W, log_W n )) 仅相差一个 log log n 因子。
总结
本节课中,我们一起学习了如何利用通信复杂性和轮次消除引理来证明前驱问题的几乎紧的下界。
- 我们将数据结构查询建模为 Alice(查询)和 Bob(内存)之间的通信游戏。
- 我们引入了轮次消除引理,它允许我们在付出少量额外错误概率的代价下,消除通信协议中的第一条消息。
- 通过精心构造输入分布,我们将着色前驱问题转化为适合应用该引理的形式。交替消除 Alice 和 Bob 的消息,分别对应了类似 van Emde Boas 树(在字长上二分)和融合树(在元素数量上分治)的思想。
- 最终,我们证明了对于多项式空间的静态数据结构,查询时间下界为 Ω(min( log_W n, log W / log log n ))。这表明我们之前所学的 van Emde Boas 树和融合树在理论上几乎是最优的。

这个证明的美妙之处在于,它脱离了具体的位运算技巧,从信息论的角度揭示了前驱问题的本质复杂度。
014:线性时间排序


在本节课中,我们将学习如何在线性时间内对整数进行排序,特别是当机器字长(W)较大时。我们将探讨签名排序(Signature Sort)这一核心算法,它结合了哈希、压缩字典树和打包排序等多种技术,最终实现在特定条件下的线性时间排序。
概述
排序是计算机科学中的基础问题。对于字长为 W 的机器上的 N 个 W 位整数,我们能否实现 O(N) 的排序时间?这是一个重要的开放性问题。本节课将介绍签名排序算法,它在字长 W 至少为 log²⁺ᵉ N 时,可以实现线性时间排序。该算法巧妙地利用了大字长的优势,通过哈希压缩、构建压缩字典树和递归排序等步骤解决问题。
算法核心思想
签名排序的核心思想是将大整数分解为多个“块”(chunk),然后利用哈希函数将这些块映射到更小的空间,从而减少后续排序的复杂度。接着,算法构建这些哈希值的压缩字典树,并通过递归排序来修正字典树中节点的顺序,最终得到完全排序的序列。
详细步骤
步骤一:分割与哈希
首先,我们将每个 W 位整数分割成 L = logᵉ N 个块。每个块的大小为 W / L 位。由于可能的块值数量(2^(W/L))远大于实际出现的块值数量(最多 N * L),我们可以使用哈希函数将每个块映射到 O(log N) 位的空间。这样,我们就能将每个块的大小从 W/L 位减少到 O(log N) 位。
公式:设块大小为 B = W / L,哈希后块大小为 B' = O(log N)。
步骤二:打包排序
哈希之后,我们得到了 N * L 个较小的块(每个块 O(log N) 位)。由于 W 足够大,我们可以将这些小块打包到机器字中,并使用打包排序算法在 O(N) 时间内对它们进行排序。打包排序要求 W ≥ B * log N * log log N,而我们的设置满足这个条件。
代码示意:将多个小块打包到一个机器字中。
word = (chunk1 << offset1) | (chunk2 << offset2) | ... | (chunkk << offsetk)
步骤三:构建压缩字典树
接下来,我们利用排序后的哈希块序列构建一个压缩字典树。压缩字典树通过合并非分支节点来减少树的大小,使得节点数量与叶子数量(即整数数量 N)成线性关系。构建压缩字典树可以在 O(N) 时间内完成。
过渡:上一节我们介绍了如何通过哈希和打包排序获得有序的哈希块序列。本节中,我们将利用这个序列构建压缩字典树,为后续的排序修正做准备。
步骤四:递归排序修正
我们构建的压缩字典树基于哈希值,因此节点的子节点顺序是混乱的。为了得到正确的排序顺序,我们需要对每个节点的子节点按照原始块值(而非哈希值)进行排序。我们通过递归排序来实现这一点:为每个节点生成一个包含节点ID、原始块值和边索引的三元组,然后对这些三元组进行排序。排序后,我们就能得到每个节点子节点的正确排列顺序。
列表:以下是递归排序修正的关键步骤:
- 为字典树的每条边生成一个三元组 (node_id, original_chunk_value, edge_index)。
- 对这些三元组进行排序,主要依据是 node_id 和 original_chunk_value。
- 根据排序结果,对每个节点的子节点进行重排。
步骤五:中序遍历输出
在修正了压缩字典树中所有节点的子节点顺序后,树的结构就完全正确了。此时,我们只需对树进行一次中序遍历,并输出叶子节点对应的原始整数,即可得到完全排序的序列。
过渡:通过递归排序,我们修正了字典树的结构。最后,我们通过简单的中序遍历来输出最终结果。
算法复杂度分析
签名排序算法的主要步骤(哈希、打包排序、构建字典树、递归排序、中序遍历)都可以在 O(N) 时间内完成。递归的深度是常数级别的(约为 1/ε),因此总时间复杂度保持为 O(N)。该算法需要字长 W 至少为 log^(2+ε) N * log log N 的条件。
公式:总时间复杂度 T(N) = O(N)。
总结
本节课我们一起学习了签名排序算法。该算法通过将整数分块、哈希压缩、构建压缩字典树和递归排序等步骤,在机器字长较大的条件下,实现了对 N 个整数的线性时间排序。签名排序展示了如何利用大字长的并行处理能力和哈希技术来解决复杂的排序问题,是高级数据结构中一个非常精妙的算法。
尽管对于所有字长 W 的线性时间排序仍然是一个开放性问题,但签名排序及其相关技术为我们处理大整数排序提供了强大的工具,并深化了我们对整数数据结构与算法设计的理解。
015:静态树 🌳


在本节课中,我们将学习如何为静态树设计高效的数据结构,以支持三种不同的查询:范围最小值查询(RMQ)、最近公共祖先(LCA)和层级祖先(LA)。我们的目标是实现常数查询时间、线性空间复杂度,并理解这些看似不同的问题之间的深刻联系。
概述 📋
我们将依次解决以下三个问题:
- 范围最小值查询(RMQ):给定一个静态数组,支持查询任意区间
[i, j]内的最小值及其索引。 - 最近公共祖先(LCA):给定一棵静态有根树,支持查询任意两个节点的最近公共祖先。
- 层级祖先(LA):给定一棵静态有根树、一个节点
x和一个整数k,支持查询节点x的第k级祖先。
我们将看到,RMQ 和 LCA 问题本质上是等价的,而 LA 问题则需要一套不同的、更精巧的技术。课程的核心技术包括表查找和间接寻址。
第一部分:RMQ 与 LCA 的等价性 🔄
从 RMQ 到 LCA:笛卡尔树
我们可以将 RMQ 问题转化为 LCA 问题。给定一个数组 A,我们构建其对应的笛卡尔树 T:
- 将数组最小值
A[i]作为树的根节点。 - 递归地,根的左子树由
A[0..i-1]构建,右子树由A[i+1..n-1]构建。
构建出的笛卡尔树是一个最小堆(父节点的值小于子节点)。关键性质是:对于数组中的任意两个位置 i 和 j,它们在笛卡尔树中对应节点的 LCA 的索引,恰好就是数组区间 [i, j] 中最小值的索引。
构建算法:可以通过一次线性扫描,使用栈来维护当前右链(从根一直向右走的路径),以 O(n) 时间构建笛卡尔树。
从 LCA 到 RMQ:欧拉序与深度序列
我们也可以将 LCA 问题转化为 RMQ 问题。给定一棵树 T:
- 对树进行欧拉环游(深度优先遍历,记录每次访问的节点),得到一个长度为
2n-1的节点序列E。 - 记录每个节点在
E中第一次出现的位置first[u]。 - 同时,生成一个与
E对应的深度序列D,其中D[i]是节点E[i]的深度。
关键性质是:对于树上任意两个节点 u 和 v,设 i = first[u], j = first[v] 且 i ≤ j。那么 u 和 v 的 LCA 就是序列 E 在区间 [i, j] 中深度最小的节点。因此,LCA 问题转化为了在深度序列 D 上的 RMQ 问题。
重要观察:在深度序列 D 中,相邻元素的差值总是 +1 或 -1。这被称为 ±1 RMQ 问题,是一个比一般 RMQ 限制更强的版本,将有助于我们设计更高效的算法。
第二部分:解决 RMQ / LCA 问题 ⚙️
我们通过一系列逐步优化的数据结构来解决 RMQ 问题,最终达到常数时间、线性空间的目标。
步骤1:稀疏表(Sparse Table)— O(n log n) 空间,O(1) 时间
这是一种简单直接的方法。
- 预处理:对于数组的每个起始位置
i和每个 2 的幂次长度2^j,预先计算区间[i, i+2^j-1]的最小值。总共需要存储O(n log n)个区间。 - 查询:对于任意区间
[l, r],其长度len = r-l+1。令k = floor(log2(len))。我们可以用两个长度为2^k的预计算区间[l, l+2^k-1]和[r-2^k+1, r]覆盖原区间。这两个区间的最小值中的较小者就是整个区间的最小值。查询只需两次查表操作。
步骤2:间接寻址(Indirection)— 将问题分解
为了将空间从 O(n log n) 降至 O(n),我们使用间接寻址。
- 分组:将原数组分成大小为
(log n)/2的组。 - 顶层结构:从每组中选出最小值,形成一个大小为
O(n / log n)的“概要”数组。对这个概要数组使用步骤1的稀疏表,空间为O((n/log n) * log n) = O(n)。 - 底层结构:现在,一个查询区间可能覆盖若干完整的组和两个不完整的组(头尾)。完整组的最小值通过顶层稀疏表查询。剩下的问题是如何在
O(1)时间内处理这些大小仅为O(log n)的底层组。
步骤3:底层处理 — 查表法
这是算法的关键。我们需要在 O(1) 时间内回答任何底层组内的 RMQ 查询。
- 观察:每个底层组的大小
n' = (log n)/2很小。更重要的是,由于我们处理的是从 LCA 转化来的 ±1 RMQ 问题,每个底层组对应的深度序列是一个相邻元素差为 ±1 的序列。 - 标准化:对于一个组,将其所有元素减去第一个元素的值。这样序列以 0 开头,且后续序列完全由长度为
n'-1的 ±1 字符串决定。 - 类型数量有限:不同的 ±1 字符串最多有
2^{n'-1} = 2^{(log n)/2 - 1} = O(√n)种。也就是说,只有O(√n)种不同的底层组“类型”。 - 预计算答案:对于每一种组类型(即每一种 ±1 模式),我们预先计算出该组内所有可能的
O((n')^2) = O(log^2 n)个查询的答案,存储在一个查找表中。 - 存储与查询:每个底层组只需存储一个指向其对应类型查找表的指针。查询时,根据组类型和查询边界,直接查表即可得到答案。
空间分析:
- 查找表总大小:
O(√n * log^2 n * log log n) = o(n),可忽略。 - 顶层稀疏表:
O(n)。 - 组指针:
O(n)。
因此总空间为O(n),查询时间为O(1)。
通过以上步骤,我们成功解决了 ±1 RMQ,进而解决了 LCA,再通过笛卡尔树转换,也解决了一般 RMQ。
第三部分:解决层级祖先(LA)问题 🧗
LA 问题需要不同的思路。我们同样通过组合多个简单结构来达到常数查询时间。
步骤1:跳转指针(Jump Pointers)— O(n log n) 空间,O(log n) 时间
每个节点存储指向其第 1, 2, 4, 8, ... 级祖先的指针(直到根节点)。要查询节点 x 的第 k 级祖先:
- 找到最大的
2^i ≤ k。 - 跳转到
x的第2^i级祖先,设为y,并更新k = k - 2^i。 - 重复上述过程直到
k=0。
由于每次k至少减半,最多O(log n)步即可完成。
步骤2:长路径分解(Long Path Decomposition)— O(n) 空间,O(√n) 时间
这是一种较慢但空间更优的方法。
- 分解:反复找到从根到叶子的最长路径,将其从树中移除,并对剩余的子树递归地进行相同操作。最终将树分解为若干不相交的路径。
- 存储:将每条路径按深度顺序存储在一个数组中。
- 查询:对于节点
x,设其在所在路径数组中的索引为i。- 如果
k ≤ i,则答案就在同一路径上,直接返回数组第i-k项。 - 否则,跳到该路径的顶端(数组第0项),再走到其父节点(进入上一条路径),并令
k = k - i - 1,然后继续查询。
最坏情况下(如一条长链旁挂许多短链),查询时间可能达到O(√n)。
- 如果
步骤3:梯子分解(Ladder Decomposition)— O(n) 空间,O(log n) 时间
这是对长路径分解的改进。
- 构建:先进行长路径分解。然后,将每条路径向上扩展一倍长度(即包含路径上节点的祖先)。这些扩展后的路径称为“梯子”。梯子可能会重叠。
- 查询:算法与长路径分解类似,但关键性质是:一个高度为
h的节点,其所在的梯子长度至少为2h。因此,在查询算法中,每次“跳到路径顶端再上移一步”的操作,至少能使当前节点的高度翻倍。从而在O(log n)步内完成查询。
步骤4:组合跳转指针与梯子 — O(n log n) 空间,O(1) 时间
这是实现常数查询的核心洞见。我们同时维护跳转指针和梯子分解两种结构。
- 查询算法:要查询节点
x的第k级祖先:- 第一步(跳转指针):使用跳转指针,从
x向上跳2^{floor(log2(k))}步,到达节点y。这一步至少完成了k/2的高度上升。 - 第二步(梯子):现在,
y的高度至少是x的高度加上k/2。由于梯子长度至少是节点高度的两倍,y所在的梯子必然包含了从y向上再延伸至少(x的高度 + k/2)的范围。这足以覆盖到x的第k级祖先。因此,我们只需在y所在的梯子数组中查找即可。
- 第一步(跳转指针):使用跳转指针,从
- 通过这样“一跳一查”,我们仅用两步就完成了查询。
步骤5:优化空间 — 树修剪与间接寻址
目前结构空间为 O(n log n),主要来自跳转指针。我们需要将其优化到 O(n)。
- 仅对叶子存储跳转指针:首先,我们只对树的叶子节点存储完整的跳转指针。对于任意节点
x的查询,我们先向下走到x的任意一个叶子后代l,然后在l上使用跳转指针和梯子进行查询。由于向下走了d步,我们需要查询的是l的第(k+d)级祖先。 - 减少叶子数量:为了控制空间(叶子数
L * log n),我们对树进行“修剪”。- 自底向上标记所有满足“子树大小 ≥ (log n)/4”的节点中,最深的那些。切断它们与父节点的连接。这些被标记的节点成为新树的叶子。
- 可以证明,这样得到的新树,其叶子数
L = O(n / log n)。 - 原树被分解为一个“顶层大树”(叶子数少)和许多个“底层小树”(每个大小 < (log n)/4)。
- 分层处理:
- 顶层大树:叶子数
O(n/log n),对其叶子使用跳转指针(结合梯子),总空间为O((n/log n) * log n) = O(n)。 - 底层小树:每个大小很小。如果查询的答案位于底层小树内部,我们使用查表法。因为不同结构的小树(无标号树)数量最多为
4^{log n} = O(√n)种,我们可以为每种类型的小树预计算所有查询答案。查询时,根据小树类型和节点位置直接查表。 - 如果查询的答案需要进入顶层,则通过每个底层小树存储的指向顶层对应节点的指针,转换到顶层大树中进行查询。
- 顶层大树:叶子数
通过这种复杂的间接寻址和查表组合,我们最终实现了常数查询时间和线性空间的层级祖先数据结构。
总结 🎉
本节课我们一起学习了针对静态树的三种经典查询问题的高效解法:
- RMQ 与 LCA 通过笛卡尔树和欧拉序深度序列相互转化,本质上是同一个问题。我们利用稀疏表、间接寻址和对 ±1 RMQ 特性的查表法,给出了线性空间、常数时间的解决方案。
- 层级祖先(LA) 是一个不同的问题。我们通过组合跳转指针(快速到达高位)和梯子分解(快速处理高位查询)实现了常数查询的框架。再通过巧妙的树修剪和间接寻址,将空间从
O(n log n)优化到O(n),其中再次利用了查表法来处理规模小的子问题。

这些解决方案展示了算法设计中强大的范式:通过问题转化揭示本质联系,通过组合简单结构实现复杂功能,以及通过间接寻址和查表法来优化复杂度。它们都是静态数据预处理领域的经典之作。
016:字符串


在本节课中,我们将要学习字符串匹配问题,并深入探讨一种强大的数据结构——后缀树。我们将了解其定义、应用,并学习如何在线性时间内构建它。课程将从更简单的前驱问题热身开始,逐步引入字典树、压缩字典树,最终构建出功能强大的后缀树。
概述:字符串匹配与数据结构
字符串匹配是一个经典问题。给定一个文本 T 和一个模式 P,我们的目标是找出 P 在 T 中出现的所有位置。这既是一个算法问题,也是一个数据结构问题。在数据结构的视角下,我们希望预先处理文本 T,构建一个静态数据结构,使得后续对于任意模式 P 的查询都能在 O(|P|) 时间内完成,同时数据结构的空间复杂度为 O(|T|)。本节课将探索如何实现这一目标。
热身:字符串的前驱问题
在深入后缀树之前,我们先解决一个更简单但相关的问题:字符串的前驱问题。假设我们有 K 个字符串 T₁, T₂, ..., Tₖ,查询时给定一个模式 P,我们需要找出 P 在这些字符串按字典序排列时所处的位置(即其前驱和后继)。直接使用比较字符串的二叉搜索树效率不高,因为字符串比较可能很耗时。为此,我们引入字典树。
字典树简介
字典树是一种根树,其分支(边)由字母表 Σ 中的字符标记。我们将每个字符串(末尾添加一个特殊字符 $)表示为从根到叶节点的一条路径。这样,每个叶节点对应一个字符串。
示例:将字符串 “A”, “AN”, “ANA”, “ANE” 插入字典树。
- 根节点有子节点 ‘A’。
- 从 ‘A’ 节点,有子节点 ‘N’ 和 ‘$’(对应字符串 “A”)。
- 从 ‘A’ -> ‘N’ 节点,有子节点 ‘A’, ‘E’, ‘$’(分别对应 “ANA”, “ANE”, “AN”)。
字典树节点的表示与查询
如何在字典树节点中高效地找到下一个字符对应的子节点,并支持前驱/后继查询?以下是几种方法:
- 数组:每个节点维护一个大小为 |Σ| 的数组,直接索引。查询时间为 O(|P|),但空间为 O(|T| * |Σ|),可能很大。
- 二叉搜索树:将子节点指针组织成BST。查询时间为 O(|P| log |Σ|),空间为 O(|T|)。
- 哈希表:使用哈希表存储存在的子节点。查询时间为 O(|P|),空间为 O(|T|),但哈希表破坏了顺序,无法直接支持前驱查询。
- 带权平衡的二叉搜索树:根据以每个子节点为根的子树中的叶节点数(权重)来构建BST,使重子树靠近根。这能实现 O(|P| + log K) 的查询时间(K为字符串数量)和线性空间。
- 间接法(叶节点修剪):结合方法1和方法4。将字典树分为“顶层”(节点后代叶节点数 ≥ |Σ|)和“底层”。顶层节点数少,可用数组表示;底层子树小,可用带权平衡BST。最终实现 O(|P| + log |Σ|) 查询和线性空间,这匹配了已知的“Tray”数据结构的最佳边界。
压缩字典树与后缀树
上一节我们介绍了基础字典树及其高效表示。本节中,我们来看看如何通过压缩来优化字典树,并最终引出核心数据结构——后缀树。
压缩字典树
压缩字典树通过合并所有非分支路径(即出度为1的节点)来减少节点数量。原来字典树中,边仅标记单个字符;在压缩字典树中,边可以标记一个字符串片段。压缩后,内部节点数少于叶节点数,总节点数为 O(K),其中 K 是字符串数量。
后缀树:定义与应用
后缀树是压缩字典树的一个特例:它为单个文本字符串 T(末尾添加特殊字符 $)的所有后缀构建压缩字典树。
示例:字符串 “BANANA$” 的后缀树。
- 它包含了 “BANANA\(”, “ANANA\)”, “NANA\(”, “ANA\)”, “NA\(”, “A\)”, “$” 所有后缀。
- 从根到叶节点的路径标记了对应后缀。
- 从根到内部节点的路径标记了文本中的一个公共子串。
后缀树支持高效的子串搜索:
- 查询:从根开始,沿着模式 P 的字符走,如果能走完,则 P 对应的节点(可能在边上)的子树中的所有叶节点,就是 P 在 T 中所有出现的起始位置。
- 性能:如果使用哈希表表示节点子节点,查询时间为 O(|P|);如果使用支持顺序的结构(如Tray),查询时间为 O(|P| + log |Σ|)。空间复杂度为 O(|T|)。
除了子串搜索,后缀树还能高效解决许多其他字符串问题:
- 统计出现次数:在节点存储子树中叶节点数量即可。
- 查找最长重复子串:即查找具有至少两个叶节点后代的最深节点。
- 计算任意两个后缀的最长公共前缀:这等价于在树中计算这两个后缀对应叶节点的最近公共祖先的“字符串深度”。
- 在多个文档中检索:将多个文档用不同分隔符连接后构建后缀树,并利用RMQ数据结构,可以在 O(|P| + #匹配文档数) 时间内列出所有包含模式 P 的文档,而非所有出现位置。
后缀数组与线性时间构建
上一节我们看到了后缀树的强大功能。本节中,我们来看看如何在线性时间内构建后缀树。关键是通过构建后缀数组和LCP数组来实现。
后缀数组与LCP数组
后缀数组 SA 是一个数组,它按字典序存储了文本 T 所有后缀的起始索引。LCP数组 LCP 存储了后缀数组中相邻后缀的最长公共前缀长度。
示例:对于 “BANANA\(”,后缀数组 SA 可能是 [6, 5, 3, 1, 0, 4, 2](对应后缀 “\)”, “A\(”, “ANA\)”, “ANANA\(”, “BANANA\)”, “NA\(”, “NANA\)”)。对应的 LCP 数组是 [0, 1, 3, 0, 0, 2]。
给定 SA 和 LCP 数组,可以用 笛卡尔树 在线性时间内构建出后缀树的结构:将 LCP 数组构建笛卡尔树,其中最小值(0)为根,叶节点按中序遍历顺序即为 SA 中的后缀索引,内部节点的值(LCP)对应其在后缀树中的字符串深度。
线性时间构建算法
构建 SA 和 LCP 的经典线性时间算法是 DC3算法(Difference Cover modulo 3)。其核心思想是递归与合并:
- 字符映射:首先对字母表排序,并将文本中每个字符替换为其排名(整数)。
- 三分法:将下标按模3余数分为三组:
T0 = [0, 3, 6, ...],T1 = [1, 4, 7, ...],T2 = [2, 5, 8, ...]。将每组中连续三个字符组成一个“元字符”。 - 递归排序:递归地对
T1和T2拼接后的新字符串(长度约为 2n/3)构建后缀数组。这给出了所有起始位置模3余1或2的后缀的顺序。 - 基数排序 T0:利用已知的
T1后缀顺序,通过基数排序(比较第一个字符和剩余部分)得到T0后缀的顺序。 - 合并:最后,合并
T0、T1、T2这三组已排序的后缀。比较任意两个后缀时(例如一个来自T0,一个来自T2),可以通过剥离一个或两个字符,将其转化为已排序组内后缀的比较,从而在常数时间内完成。 - 获取LCP:在合并过程中可以同时计算LCP信息。
由于递归问题规模以 2/3 比例缩小,总时间复杂度为线性 O(|T|)。字母表排序仅在第一次递归时需要,后续递归中“元字符”范围有限,可用基数排序高效处理。
总结

本节课中我们一起学习了字符串处理的核心数据结构。我们从字符串前驱问题入手,探讨了字典树及其多种高效表示方法。接着,我们引入了压缩字典树,并定义了功能强大的后缀树,它能够支持 O(|P|) 时间的子串搜索以及许多其他复杂的字符串查询。最后,我们探讨了后缀数组和LCP数组的概念,并概述了如何在 O(|T|) 线性时间内通过DC3算法构建它们,从而间接实现后缀树的线性时间构建。后缀树及其相关结构是处理字符串匹配、检索和分析问题的基石。
017:简洁数据结构 I


在本节课中,我们将学习简洁数据结构。我们的目标是使用极小的空间来存储数据结构。我们将重点关注静态数据结构,并定义“小空间”的三个层次:隐式、简洁和紧凑。今天,我们将主要探讨如何为二叉字典树和位向量实现简洁表示,并学习实现快速查询的关键操作:rank 和 select。
空间效率的三个层次
在简洁数据结构领域,我们追求三种不同层次的空间效率。
- 隐式:使用最优比特数加上一个常数。例如,二叉堆和有序数组。
- 简洁:使用最优比特数加上
o(opt)比特。这是我们的主要目标。 - 紧凑:使用
O(opt)比特。这通常意味着比传统的线性空间数据结构节省了因子W(字长)。
简洁二叉字典树
上一节我们定义了空间效率的目标,本节我们来看一个具体的数据结构:二叉字典树。我们的目标是使用接近最优的 2n + o(n) 比特来表示一个有 n 个节点的二叉字典树,并支持常数时间的遍历操作(左孩子、右孩子、父节点)。
层序遍历表示法
一种简单的表示方法是按层序遍历节点,并为每个节点记录其左孩子和右孩子是否存在。
以下是具体步骤:
- 从根节点开始,按层序(广度优先)访问每个节点。
- 对于每个访问到的节点,输出两个比特:第一个比特表示左孩子是否存在(1存在,0不存在),第二个比特表示右孩子是否存在。
例如,对于一个二叉字典树,我们可能得到比特串 11 01 11 01 01 00 00。对于 n 个节点,我们恰好产生 2n 个比特。
在层序表示中导航
为了在常数时间内找到左孩子、右孩子和父节点,我们需要一个关键的引理。
引理:在层序比特串中,第 i 个内部节点(即第 i 个“1”比特,代表一个真实节点)的左孩子位于整体数组的位置 2i,右孩子位于位置 2i+1。
证明思路:可以通过对 i 的归纳来证明。由于是层序遍历,节点 i 的孩子会紧接在节点 i-1 的孩子之后出现。
根据这个引理:
- 左孩子:位置 =
2 * rank(i) - 右孩子:位置 =
2 * rank(i) + 1 - 父节点:位置 =
select(floor(i/2))
这里,rank(i) 返回位置 i 之前(含)1 比特的数量,select(j) 返回第 j 个 1 比特的位置。因此,实现常数时间遍历的关键在于实现常数时间的 rank 和 select 操作。
位向量的 Rank 操作
上一节我们看到了 rank 和 select 操作对于导航层序二叉字典树的重要性。本节中,我们来看看如何为位向量实现常数时间的 rank 查询,同时仅使用 o(n) 的额外空间。
我们的目标是:给定一个长度为 n 的比特串 B 和一个索引 i,快速计算 B[1..i] 中 1 的个数。
间接寻址与查找表
我们使用两级间接寻址将问题规模减小到可以使用查找表解决。
第一级:大块
- 将比特串划分为大小为
log² n的块。 - 在每个块的边界存储累积的
rank值(即到该块之前为止1的总数)。存储每个值需要log n比特。 - 总空间开销:块数
(n / log² n)× 每个值大小(log n)=n / log n比特,属于o(n)。
第二级:小块
- 在每个
log² n的大块内部,进一步划分为大小为(1/2) log n的小块。 - 在每个小块的边界,存储其在大块内部的累积
rank值。由于值不超过log² n,存储每个值只需log log n比特。 - 总空间开销:小块边界总数
(n / log n)× 每个值大小(log log n)=(n log log n) / log n比特,仍属于o(n)。
查找表
- 预处理所有长度为
(1/2) log n的可能比特串。 - 对于每个这样的短串,预先计算每个位置的
rank答案并存储。 - 表大小:
2^{(1/2) log n} = √n个条目 × 每个条目O(log log n)比特 ≈O(√n log log n),属于o(n)。
查询过程
要计算 rank(i):
- 找到
i所在的大块,读取该块起始处的累积rank1。 - 找到
i所在大块内的小块,读取该小块起始处在大块内的累积rank2。 - 定位
i在小块内的具体位置,使用查找表获得在小块内的rank3。 - 最终结果:
rank(i) = rank1 + rank2 + rank3。
所有步骤均为常数时间,总额外空间为 o(n)。
位向量的 Select 操作
我们已经实现了 rank,现在来处理它的逆操作 select。给定一个索引 j,select(j) 返回第 j 个 1 比特的位置。这个操作稍微复杂一些。
处理稀疏和密集情况
我们根据 1 比特的分布情况采取不同策略。
第一级:按1的数目分组
- 将比特串划分为每组包含
log n log log n个1比特的组。每组大小可能不同。 - 存储一个数组,记录每组起始位置。给定
j,通过除法可定位到对应组。
处理组内查询
对于每个组,设其长度为 r 比特(包含 log n log log n 个 1)。
- 情况A:稀疏组 (
r ≥ (log n log log n)²)- 组内
0很多。我们可以直接存储组内所有1的位置(相对偏移)。 - 空间分析:每组存储一个大小为
(log n log log n)的数组,每个条目需log n比特。这样的组最多有n / (log n log log n)²个。总空间约为n / log log n比特,属o(n)。
- 组内
- 情况B:密集组 (
r < (log n log log n)²)- 组相对较小,我们将其标记为“待简化”位向量。
第二级:简化处理
对所有“待简化”的组(每个长度 < (log n log log n)²)重复类似 rank 中的两级间接寻址过程:
- 在组内,每
(log log n)²个1比特设一个子组边界,存储相对偏移(只需log log n比特)。 - 对于每个子组,再次判断稀疏(长度 ≥
(log log n)⁴)或密集。- 若稀疏,存储子组内所有
1的位置。 - 若密集,此时子组长度已降至
(log log n)⁴,可直接使用查找表解决。
- 若稀疏,存储子组内所有
查询过程
要计算 select(j):
j1 = floor(j / (log n log log n))找到所属大组,跳转到其起始位置。- 检查该组是稀疏还是密集。
- 若稀疏,使用组内存储的查找表直接得到答案。
- 若密集,在组内进行第二级查询:计算
j2 = j % (log n log log n),找到对应的子组,同样判断稀疏/密集并查表或继续查找。
- 所有操作均在常数时间内完成,总额外空间为
o(n)。
平衡括号表示法
层序表示法无法高效支持子树大小查询。本节我们介绍一种更强大的表示法:平衡括号表示法。它能支持所有导航操作以及子树大小查询。
从二叉字典树到有序根树
首先,我们建立二叉字典树与有序根树之间的双射关系。
- 转换:将二叉字典树中每个节点的右脊柱(从该节点开始,不断访问右孩子形成的链)旋转45度,作为其父节点的一串有序孩子。
- 结果:每个二叉字典树唯一对应一棵有序根树,反之亦然。因此,它们都有
Catalan(n)棵。
从有序根树到平衡括号序列
接着,将有序根树转换为平衡括号序列。
- 方法:对树进行深度优先遍历(前序)。访问节点时输出“(”,离开节点时输出“)”。
- 表示:一个有
n个节点的树产生长度为2n的平衡括号序列。我们可以用0表示“(”,1表示“)”。
在平衡括号表示中导航
现在,我们需要在平衡括号序列上实现二叉字典树的操作。这需要一些基本原语操作,如找到匹配的括号、找到某个括号的“封闭括号对”等。这些操作都可以通过扩展 rank/select 的技术在常数时间和 o(n) 额外空间内完成。
基于这些原语:
- 左孩子 ↔ 第一个孩子:对应当前位置的下一个字符(如果是“(”)。
- 右孩子 ↔ 下一个兄弟:对应匹配右括号的下一个字符。
- 父节点:对应前一个字符。如果是“)”,则找其匹配的“(”(得到前一个兄弟);如果是“(”,则它就是父节点。
支持子树大小查询
平衡括号表示法的最大优势是能高效计算子树大小。
- 在二叉字典树/有序根树中,一个节点的子树包含它和它的所有右兄弟。
- 在平衡括号序列中,这对应于从一个左括号到其“封闭括号对”的右括号之间的所有括号。
- 子树节点数 = (封闭右括号位置 - 左括号位置) / 2。
- 子树叶子数:可以通过计算该区间内匹配的括号对数量(即一种扩展的
rank查询)得到。
所有这些查询都可以在常数时间内完成。
总结
本节课中,我们一起学习了简洁数据结构的基本概念。我们首先定义了隐式、简洁和紧凑三种空间效率目标。然后,我们深入探讨了如何为二叉字典树构建简洁表示。
- 我们学习了层序遍历表示法,它将导航操作归结为位向量上的
rank和select操作。 - 我们详细设计了常数时间、
o(n)额外空间的rank和select数据结构,关键思想是多级间接寻址和查找表。 - 最后,我们介绍了更强大的平衡括号表示法,它不仅能实现常数时间的导航,还能支持子树大小查询,为下节课实现简洁后缀树奠定了基础。

通过使用这些技术,我们可以用近乎最优的空间存储大量数据,同时保持高效的查询性能。
018:简洁结构 II


概述
在本节课中,我们将学习如何将之前了解的字典树知识应用于一个核心应用:后缀树。我们将展示后缀树和后缀数组在空间视角下也是等价的。具体来说,如果你能简洁地表示一个后缀数组,那么只需增加少量额外空间,你就能构建一个后缀树,并以我们熟悉的时间复杂度(即 P 加上输出大小)进行搜索。虽然我们会损失一些对数因子,但这主要是由于转换过程中增加的 log log n 项。我们将从改进空间复杂度开始,最终目标是达到线性空间。
研究现状概览
在深入具体数据结构之前,我们先简要了解一下该领域已知的研究成果。
以下是关于紧凑和简洁后缀数组及后缀树的一些关键成果:
- Grossi-Vitter (2000): 这是第一个实现紧凑后缀数组的成果。其空间复杂度为
O((1/ε) * n log σ)比特,查询时间复杂度为O((P + occ) * log^ε n)。它允许通过调整 ε 在空间和时间之间进行权衡。 - FM-Index (2000): 该索引实现了
O(5 * H_k(T) * T + o(T log σ))比特的空间复杂度,其中H_k(T)是文本的 k 阶经验熵。这使得它在文本可压缩时能获得更好的空间效率。查询时间复杂度为O((P + occ) * log^ε n)。 - Sadakane (2003): 该结构空间复杂度为
O((1+ε) * H_0(T) * T + o(T log σ)),对大字母表处理得更好,但查询时间增加了一个对数因子。 - Grossi-Gupta-Vitter (2003): 这是第一个实现简洁后缀树的成果,空间复杂度为
O(H_k(T) * T + o(T log σ)),查询时间复杂度为O((P log σ + occ log^2 n / log log n) * log σ)。 - Ferragina-Manzini-Mäkinen-Navarro (2007): 该成果也达到了
O(H_k(T) * T + o(T))比特的空间复杂度,查询时间复杂度为O(P + occ * log^(1+ε) n),并且能在O(P)时间内完成计数查询。
这些结构大多是静态的,但也有关于动态构建和文档检索等扩展研究。本课程将重点讲解最简单易懂的 Grossi-Vitter 方法。
压缩后缀数组问题
我们的目标是能够回答形式为 SA[k] 的查询,即在所有后缀按字典序排序后,第 k 个后缀在原始文本中的起始位置。利用这个功能,我们可以进行搜索。
核心思想是采用递归分治的策略来表示后缀数组。
递归结构定义
我们定义递归的文本和后缀数组:
- 设
T_0为原始文本,长度为n_0 = n,其对应的后缀数组为SA_0。 - 在递归的第
k层,我们通过合并相邻字符来构建新文本T_{k+1}:T_{k+1}[i] = (T_k[2i], T_k[2i+1])。因此,T_{k+1}的长度为n_{k+1} = n / 2^{k+1}。 - 后缀数组
SA_{k+1}可以通过提取SA_k中对应偶数起始位置的后缀索引,并除以 2 得到。
我们的表示策略是自底向上的:假设我们已经简洁地表示了 SA_{k+1},然后在此基础上表示 SA_k。
关键辅助函数
为了建立 SA_k 和 SA_{k+1} 之间的联系,我们需要定义两个辅助函数:
- 偶后继 (Even Successor): 对于后缀数组
SA_k中的第i个后缀(起始位置为SA_k[i]):- 如果
SA_k[i]是偶数,则even_successor(i) = i。 - 如果
SA_k[i]是奇数,则even_successor(i)等于那个起始位置为SA_k[i]+1(即下一个偶数位置)的后缀在SA_k中的排名j。
- 如果
- 偶排名 (Even Rank):
even_rank(i)表示在SA_k的前i个后缀中,起始位置为偶数的后缀数量。
核心递推公式
利用上述定义,我们可以用 SA_{k+1} 来表示 SA_k[i]:
SA_k[i] = 2 * SA_{k+1}[ even_rank( even_successor(i) ) - 1 ] - (1 - is_even_suffix(i))
其中 is_even_suffix(i) 是一个指示函数,当 SA_k[i] 为偶数时值为 1,否则为 0。
公式解释:
even_successor(i): 将当前后缀(可能为奇数起始)规整到其后的偶数起始后缀。even_rank(...): 获取该偶数后缀在SA_{k+1}中对应的新索引。SA_{k+1}[...]: 在下一层递归中查找该后缀的起始位置(相对于T_{k+1})。2 * ...: 将T_{k+1}中的位置转换回T_k中的位置(因为每个T_{k+1}字符对应T_k的两个字符)。- (1 - is_even_suffix(i)): 如果原始后缀是奇数起始的(is_even_suffix(i)=0),我们需要减去 1 以补偿even_successor所做的 +1 偏移。
递归深度与查询
我们不需要递归到常数大小。只需进行 L = log log n 层递归。在底层(SA_L),文本长度 n_L = n / log n,我们可以直接使用普通的 O(n_L log n_L) 比特的空间存储显式的后缀数组,这部分空间是 o(n) 的。
要回答一个 SA_0[i] 的查询,我们只需自顶向下应用上述公式 L 次,最终在底层的显式数组中查找,然后逐层返回结果。如果所有辅助函数都能在常数时间内计算,那么总查询时间为 O(log log n)。
实现 O(n log log n) 比特的后缀数组
现在,关键问题是如何高效地存储辅助函数 is_even_suffix、even_rank 和 even_successor。
简单部分的存储
is_even_suffix: 直接存储一个长度为n_k的比特数组,1 表示对应后缀起始位置为偶数。总空间为O(n)比特(几何级数求和)。even_rank: 这正是我们在上一讲中学到的针对比特向量的rank1查询结构。我们可以使用O(n_k / log n_k)或更优的额外空间来实现常数时间查询。总空间也是o(n)。
高效存储 even_successor
这是最具技巧性的部分。我们只关心 SA_k[i] 为奇数的情况下的 even_successor(i) 值。对于这些“奇数后缀”,我们需要存储其对应的 even_successor 值(一个索引 j)。
关键的观察是:如果我们按照 i(即后缀的字典序)来列出这些(奇数后缀,even_successor 值)对,那么这些对的排序顺序实际上等同于按照(该奇数后缀的第一个字符,其 even_successor 值)这个二元组进行排序。
利用这个性质,我们可以用一种差分编码来存储这些 even_successor 值。我们将每个 even_successor 值看作一个比特串,分为高 log n_k 位和剩余的 2^k 位(因为 T_k 中一个字符对应原始文本的 2^k 位)。
- 高位的存储: 对于排序后的
even_successor值序列,我们存储其高位部分的差分值的一元编码(unary differential encoding)。具体来说,我们写入v1个 0,然后一个 1,再写入(v2-v1)个 0,然后一个 1,依此类推。由于高位最多变化n_k次,且共有n_k/2个值,所以这个比特串的总长度约为(3/2) * n_k。 - 低位的存储: 我们将每个值的低位部分(
2^k位)显式地存储在一个数组中。这部分的总空间是(n_k/2) * 2^k = n/2比特(对所有层求和后为O(n log log n))。
要查询第 i 个奇数后缀对应的 even_successor 值的高位,我们首先计算 odd_rank = i - even_rank(i)。然后,在存储高位的一元编码比特串中,找到第 odd_rank 个 1 的位置(使用 select1),并计算从开头到该位置的 0 的数量(使用 rank0),这个数量就是所需的高位值。低位值可以直接从显式数组中读取。合并高低位即得到完整的 even_successor 值。
空间与时间分析
将所有层的空间成本相加:
is_even_suffix:O(n)比特even_rank:o(n)比特even_successor的高位存储:O(n)比特even_successor的低位存储:O(n log log n)比特- 底层显式后缀数组:
o(n)比特
因此,总空间复杂度为 O(n log log n) 比特。查询时间复杂度为 O(log log n)。
改进到紧凑后缀数组 (O(n) 比特)
O(n log log n) 的空间瓶颈在于 even_successor 的低位存储。为了达到线性空间,我们不能存储所有递归层。
我们只存储稀疏的层:第 0 层,第 εL 层,第 2εL 层,...,直到第 L 层(L = log log n)。总共存储 O(1/ε) 层。
在查询时,要从 SA_{k} 跳转到 SA_{k+εL},我们不能像之前那样一步到位。我们需要定义一个更一般的 后继 (Successor) 函数:从当前后缀开始,不断移动到下一个后缀(即 SA 中的下一个索引),直到遇到一个起始位置能被 2^{εL} 整除的后缀。这个过程最多需要 2^{εL} = log^ε n 步。
然后,我们递归到下一层 SA_{k+εL} 进行查询,得到结果后,再乘以 2^{εL} 并减去之前走过的步数,以补偿偏移。
这个思路与之前完全相同,只是将“偶数”的概念推广到了“能被 2^{εL} 整除”,并且将单步跳转替换为了最多 log^ε n 步的迭代。successor 函数的存储可以采用与 even_successor 类似但更通用的编码技巧。
空间与时间分析
由于我们只存储了 O(1/ε) 层,每层的空间成本为 O(n),因此总空间复杂度为 O((1/ε) * n) 比特。查询时,在每一层我们可能需要进行最多 O(log^ε n) 次迭代来找到“可整除”的后缀,因此总查询时间复杂度为 O((1/ε) * log^ε n) = O(log^ε n)(假设 ε 为常数)。
通过一些优化(例如,第 0 层的 successor 结构可以简化;使用更高效的稀疏比特向量表示 is_even_suffix),可以将主导常数优化到接近 1,最终空间复杂度为 O((1/ε) * n + o(n)) 比特。
从后缀数组到后缀树
最后,我们简要介绍如何将紧凑的后缀数组转换为紧凑的后缀树。这里我们概述 Grossi-Vitter 方法的一个简化版本。
紧凑版本
- 存储树形结构: 对于二进制字母表,后缀树可以看作一棵二叉树(有
O(n)个节点)。我们使用上一讲中提到的平衡括号表示法来存储这棵树的拓扑结构,这需要O(n)比特。 - 省略边长信息: 我们不在树中显式存储边的长度(即跳过的字符数)。
- 查询时计算边长: 在搜索过程中,当我们需要知道从当前节点到子节点的边的长度时,我们这样做:
- 找到该子树中最左边和最右边的叶子节点(在平衡括号表示中可通过
rank/select操作高效实现)。 - 这两个叶子节点对应后缀数组中的两个索引
i和j。 - 通过查询后缀数组
SA获得这两个后缀在文本中的起始位置。 - 计算这两个后缀从当前深度开始的最长公共前缀 (LCP) 的长度,这个长度就是所需边的长度。
- 找到该子树中最左边和最右边的叶子节点(在平衡括号表示中可通过
- 搜索成本: 在最坏情况下,我们可能需要在每个搜索步骤中都计算一次 LCP,每次计算需要常数次后缀数组查询。因此,总搜索时间为
O(P * (后缀数组查询时间)),即O(P * log^ε n)。
简洁版本
为了进一步减少树结构本身的空间(达到 o(n) 比特),我们可以使用“采样”思想:
- 我们只保留每隔
B个叶子节点(B是一个缓慢增长的函数,如log log log n)的叶子,以及它们的最小公共祖先构成的树。这棵树的节点数是O(n/B)。 - 存储这棵采样树只需要
O(n/B)比特。 - 在搜索时,我们可能无法直接定位到精确的叶子,但可以定位到距离目标叶子不超过
B的范围内。 - 然后,我们可以利用一个大小为
O(2^B)的预计算查找表,在这个小范围内并行模拟对所有可能后缀的搜索,从而在O(P + B)时间内找到正确结果(再乘以后缀数组查询时间)。
通过将 B 设置为一个很小的值(如 log^ε n),我们可以使树结构的空间成为 o(n),同时查询时间增加一个可接受的附加项。
总结

本节课我们一起学习了简洁数据结构在后缀树和后缀数组中的应用。我们从问题定义出发,了解了该领域的主要研究成果。然后,我们深入探讨了 Grossi-Vitter 方法的核心思想:通过递归分治和巧妙的差分编码,用 SA_{k+1} 来表示 SA_k。我们首先实现了 O(n log log n) 比特的后缀数组,然后通过稀疏化递归层将其改进为 O(n) 比特的紧凑后缀数组,查询时间为 O(log^ε n)。最后,我们概述了如何利用这种紧凑的后缀数组,结合平衡括号表示和采样技术,构建出紧凑乃至简洁的后缀树。这些技术展示了如何用近乎最优的空间来存储和查询复杂的字符串索引结构。
019:动态图 I


在本节课中,我们将要学习一种名为 Link-Cut Tree 的数据结构。这是一种用于维护动态树的巧妙方法,也开启了我们对动态图的研究。在动态图中,我们通常有一个图(通常是无向图),并希望支持边的插入和删除操作。今天,我们将在所有时刻图都是树的情况下实现这一目标。具体来说,我们将维护一个森林(即多棵树的集合),并支持一系列操作,所有操作的目标是实现 O(log N) 的时间复杂度。
数据结构概述
Link-Cut Tree 用于维护一个动态的、有根的、无序(任意度数)的森林。它支持以下核心操作:
maketree():创建一个新的、仅包含单个节点的新树,并返回该节点。link(v, w):在节点v和w之间添加一条边。前提是v和w位于不同的树中,且v必须是其所在树的根节点。此操作将v作为w的一个子节点,从而将两棵树合并为一棵。cut(v):删除节点v与其父节点之间的边。此操作将一棵树分裂成两棵。findroot(v):返回节点v所在树的根节点。pathaggregate(v):对从根节点到节点v的路径上的所有边(或节点)的权重进行聚合查询(如求和、求最小值、求最大值等)。
我们的目标是,即使被表示的树(Represented Tree)本身可能非常不平衡(例如是一条深度为 N 的路径),也能在 O(log N) 的均摊时间内完成所有这些操作。
核心思想:路径分解与辅助树
Link-Cut Tree 的核心思想是将不平衡的“被表示树”存储在一组相对平衡的“辅助树”中。这通过两种路径分解技术实现:
- 首选路径分解:我们根据节点的访问历史动态定义“首选子节点”,从而将树分解为一系列“首选路径”。
- 轻重路径分解:我们根据子树大小静态定义“重子节点”(子树大小超过父节点一半的子节点),从而将树分解为一系列“重路径”。这个分解主要用于分析时间复杂度。
在 Link-Cut Tree 的实现中,我们使用首选路径分解。每条首选路径用一个伸展树(Splay Tree)来存储,我们称之为辅助树。辅助树中的节点按键值深度排序。
树中树结构
所有辅助树通过一种特殊的指针连接起来,形成一个“辅助树之树”的结构:
- 在每个辅助树内部,节点通过标准的伸展树指针连接。
- 每个辅助树的根节点存储一个
pathparent指针,指向该条首选路径顶端节点在“被表示树”中的父节点。
这样,整个动态森林就由许多通过 pathparent 指针连接起来的伸展树(辅助树)来表示。
关键操作:access(v)
几乎所有 Link-Cut Tree 操作都依赖于一个核心辅助函数:access(v)。这个函数完成两件事:
- 它使从根节点到
v的路径成为新的首选路径。 - 它通过一系列伸展操作,将节点
v变为其所在辅助树的根节点,并最终成为整个“辅助树之树”的根节点。
以下是 access(v) 的算法步骤:
splay(v):在v所在的辅助树中,将v伸展至根。此时,v的左子树包含路径上深度小于v的节点,右子树包含深度大于v的节点。- 断开右子树:由于访问
v后,v不应再有首选子节点,因此需要断开v与其右子树的连接。将v.right设为null,并将原右子树的根节点的pathparent指针指向v。 - 循环向上合并:
- 只要
v的pathparent指针 (w) 不为null,就执行以下操作:splay(w):在w所在的辅助树中,将w伸展至根。- 断开
w的右子树:类似步骤2,断开w与其右子树的连接,并将原右子树的根节点的pathparent指向w。 - 连接
v的树到w:将v所在的树作为w的右子树 (w.right = v),并设置v.parent = w,同时将v.pathparent设为null。 splay(v):再次将v在其新的辅助树中伸展至根,为下一次循环做准备。
- 当
v.pathparent为null时,v已成为整个“辅助树之树”的根,循环结束。
- 只要
执行完 access(v) 后,从根到 v 的整条路径都位于同一个辅助树中,并且 v 是该辅助树的根节点,没有右子树。
基于 access 实现其他操作
一旦有了 access 操作,其他操作就变得非常简单:
-
findroot(v):- 执行
access(v)。 - 在
v所在的辅助树中,一直向左走找到最小键值(即深度最小)的节点,那就是根节点r。 - 执行
splay(r)以保持均摊复杂度。 - 返回
r。
- 执行
-
pathaggregate(v):- 执行
access(v)。 - 此时,
v所在的辅助树正好代表了从根到v的路径。我们只需在伸展树节点上维护子树聚合信息(如和、最小值等),然后查询v节点的子树聚合值(或根据具体定义查询其左子树的聚合值)即可。
- 执行
-
cut(v):- 执行
access(v)。 - 此时,
v的左子树对应了从根到v的父节点的路径。断开v与其左子树的连接 (v.left.parent = null; v.left = null)。这样,v就成为了一个独立的辅助树(即一棵新树的根)。
- 执行
-
link(v, w):- 执行
access(v)和access(w)。此时v是孤立树的根,w是其所在树的辅助树根。 - 由于
v将成为w的子节点(深度更大),我们可以将v作为w的右子树连接:v.left = w; w.parent = v。(也可以将v作为w的右子节点,两种方式在伸展树中都是有序的)。
- 执行
时间复杂度分析
我们首先分析一个较松的界 O(log² N),然后引入伸展树的访问引理将其改进到 O(log N)。
O(log² N) 分析
设 M 为操作总数。每次操作(findroot, link, cut, pathaggregate)都包含常数次 access 调用。access 的成本主要来自其中的 splay 操作。
- 如果使用平衡二叉搜索树(如红黑树)作为辅助树,每次
splay(即调整到根)的成本为 O(log N)。 - 在
access(v)中,splay的次数等于从v到根路径上首选子节点发生改变的次数。
因此,总时间复杂度为:
O( (M + #PreferredChildChanges) * log N )
接下来,我们利用轻重路径分解来界定首选子节点改变的总次数。
轻重路径分解:在“被表示树”中,对于节点 v 到其父节点 p 的边,如果 size(v) > size(p)/2,则称该边为重边,否则为轻边。从根到任意节点 v 的路径上,轻边的数量不超过 log₂ N。
我们将边的状态分为:(是否首选) × (是轻是重)。分析不同操作如何影响这些状态:
-
access(v):- 它创建一条新的从根到
v的首选路径。 - 新创建的首选边:这些边都在一条路径上,其中轻边最多 O(log N) 条。
- 被破坏的首选边:这些边是那些不再首选的原首选边。如果一个被破坏的边是重边,那么它一定是从旧路径上“岔开”的边。由于从根出发,轻边只有 O(log N) 条,所以这样的重边岔路也最多只有 O(log N) 个。
- 因此,一次
access造成的首选子节点改变次数为 O(log N)。
- 它创建一条新的从根到
-
link(v, w):- 在调用
access之后,link只添加一条边。它可能改变一些节点的子树大小,从而可能使一些边由重变轻。但这些边原本就不是首选边(因为刚执行过access),所以不贡献首选子节点改变次数。
- 在调用
-
cut(v):- 在调用
access之后,cut断开一条边。这会使从根到v父节点路径上的一些节点子树大小减小,可能使一些边由重变轻,并成为新的首选边(因为路径现在是首选路径)。同样,这些新创建的首选轻边都在一条路径上,数量为 O(log N)。
- 在调用
综上所述,M 次操作引起的首选子节点改变总次数为 O(M log N)。代入公式,得到总时间复杂度 O(M log² N)。
O(log N) 分析:使用伸展树与访问引理
当我们使用伸展树作为辅助树时,可以利用其强大的访问引理来获得更好的均摊分析。
为每个节点 v 定义一个势能权重 W(v),它等于在“辅助树之树”中,以 v 为根的子树(包括通过 pathparent 指针挂接的所有子树)中的节点总数。
定义势函数 Φ = Σ log W(v),对所有节点 v 求和。
访问引理指出:在势函数 Φ 下,将节点 v 在其辅助树中伸展到根的均摊代价最多为:
3 * [log W(root of v‘s aux tree) - log W(v)] + 1
现在分析 access(v):
- 它包含一系列
splay操作(对v,以及对路径上的多个w)。 - 根据访问引理,这些
splay的均摊代价会形成一个望远镜求和。最终,所有splay的总均摊代价可以表示为:
O(log W(全局根) - log W(v) + #PreferredChildChanges)
由于W(全局根) ≤ N,所以log W(全局根) = O(log N)。 - 我们已经知道
#PreferredChildChanges的均摊次数是 O(log N) 每次access。
因此,一次 access 的均摊时间复杂度为 O(log N)。所有其他操作都主要调用常数次 access,因此它们的均摊时间复杂度也是 O(log N)。
总结

本节课我们一起学习了 Link-Cut Tree 这一强大的动态树数据结构。我们了解了其核心思想:通过首选路径分解将动态森林表示为由伸展树构成的“树中树”结构。关键的 access(v) 操作能够高效地将根到 v 的路径暴露在一个平衡的辅助树中,从而使查询(如找根、路径聚合)和更新(连接、切断)操作都能在 O(log N) 的均摊时间内完成。我们还利用轻重路径分解和伸展树的访问引理完成了复杂度的分析。Link-Cut Tree 是处理动态树问题的基础工具,在诸如网络流等算法中有着重要应用。
020:动态图 II


以下内容基于知识共享许可协议提供。您的支持将帮助麻省理工学院开放式课件继续免费提供高质量的教育资源。要捐款或查看来自数百门麻省理工学院课程的更多材料,请访问 MIT OpenCourseware 网站。
在本节课中,我们将继续探讨动态图这一主题。这是三讲中的第二讲,也是关于一般图上界的主要一讲。上一讲我们介绍了 Link-Cut 树,并基本解决了动态连通性问题。该问题允许插入和删除边,并需要知道哪些节点是连通的。对于树,我们已经解决了这个问题。今天,我们将再次以更简单的方式解决树上的问题,因为我们需要一种稍简单、能以不同方式增强的数据结构,称为欧拉序树。然后,我们将在只能删除边的设置下再次解决树的问题,从而将时间复杂度从 O(log n) 降低到 O(1)。我们仍将花相当多时间讨论树,但最终会转向一般图,并给出一个 O(log² n) 的解决方案。这比树的情况只多了一个对数因子。最后,我将概述动态图领域研究的其他问题。
问题定义
今天我们将主要关注动态连通性问题。目标是维护一个无向图,支持边的插入和删除操作,也可以插入和删除度为 0 的顶点。查询操作是连通性查询:给定两个顶点 V 和 W,询问是否存在从 V 到 W 的路径,即它们是否在同一个连通分量中。
在动态数据结构中,完全动态数据结构支持边的插入和删除。部分动态数据结构则只支持插入或只支持删除。只支持插入的称为增量动态,只支持删除的称为减量动态。对于减量动态,我们将在树上实现常数时间。对于完全动态,我们将在一般图上实现 O(log² n) 时间。
动态连通性研究现状
在深入算法之前,我们先了解动态连通性结果在文献中的位置。
对于树,已知的最佳结果是 O(log n) 时间,而减量动态可以达到 O(1) 时间。我们实际上已经知道如何使用 Link-Cut 树来处理树,但使用欧拉序树会更简单。
对于一般图,核心开放问题是能否实现 O(log n) 的更新时间。目前已知的最佳结果是 O(log n * (log log n)³) 的更新时间和 O(log n / log log log n) 的查询时间。另一个结果是 O(log² n) 的更新时间和 O(log n / log log n) 的查询时间。
对于增量动态(仅插入),这本质上是并查集问题,可以达到接近 α(n)(反阿克曼函数)的摊还时间,并且是最优的。对于减量动态(仅删除),在稠密图上可以达到 O(log n) 的摊还时间。
关于下界,已经证明对于动态连通性,更新时间和查询时间中至少有一个必须是 Ω(log n)。具体来说,存在更新时间和查询时间之间的权衡关系。这些下界即使对于路径图也成立,这意味着我们之前实现的 O(log n) 动态树操作是最优的。
本节课我们将专注于上界,即实现 O(log n) 的树操作和 O(log² n) 的一般图操作。
欧拉序树
首先,我们介绍一种更简单的动态树结构——欧拉序树,由 Henzinger 和 King 于 1995 年提出。与 Link-Cut 树的关键区别在于,欧拉序树允许我们处理子树,而不仅仅是路径。
数据结构构建
欧拉序树的思想是:对树进行欧拉环游(深度优先遍历,记录每次访问节点),将这个线性访问序列存储在一个平衡二叉搜索树中。每个节点存储指向其第一次和最后一次访问的指针。
数据结构表示:
- 将欧拉环游的节点访问序列按顺序存储在一个平衡二叉搜索树中。
- 每个树节点存储两个指针,分别指向该节点在 BST 中的第一次和最后一次访问位置。
操作实现
查询根节点:给定节点 V,取其任一访问位置(如第一次访问),在 BST 中向上走到根,再向左走到最左节点,该节点即为原树根节点的第一次访问,从而找到根。时间复杂度为 O(log n)。
删除边:要删除连接节点 V 与其父节点 W 的边。在欧拉序中,V 的子树对应一个连续的区间。我们在 BST 中 V 的第一次和最后一次访问位置进行分割操作,将子树对应的区间分离出来。然后,将剩余的两部分重新连接。同时,需要删除一个多余的 W 访问记录。这涉及常数次分割和连接操作,每次 O(log n),总时间 O(log n)。
添加边:假设要将根节点为 V 的树作为 W 的新子节点。我们找到 W 在 BST 中的最后一次访问位置,在此处分割。然后,按顺序连接以下部分:W 最后一次访问之前的部分、一个新的 W 访问记录、V 的整个欧拉序 BST、以及 W 最后一次访问及之后的部分。这同样涉及常数次 O(log n) 的操作。如果 V 不是其所在树的根,可以先通过一次循环移位操作将其变为根,然后再进行连接。
子树聚合:由于子树对应欧拉序 BST 中的一个连续区间,我们可以通过在 BST 上维护区间聚合信息(如最小值、最大值、和),在 O(log n) 时间内回答子树查询。
欧拉序树提供了一种在 O(log n) 时间内支持动态树基本操作(增删边、查询连通性、子树查询)的简洁方法。
树的减量连通性
接下来,我们探讨一个更弱的问题:仅支持删除操作的树连通性。目标是实现常数摊还时间。
基本思想是使用间接寻址和查找表。我们通过修剪叶子节点,将树分解为顶层结构和多个底层结构。
分解过程:
- 识别所有后代数量大于
log n的节点,从这些节点下方切断。这样得到的顶层树最多有n / log n个“叶子”(即连接底层结构的节点)。 - 每个底层结构的大小最多为
log n。 - 顶层树中的长路径(不分支的部分)也被单独处理。
查询处理:
对于查询 V 和 W:
- 如果它们在同一底层结构中,则使用该底层结构查询。
- 否则,查询可能涉及在底层结构内部、顶层结构内部以及它们之间的连接。这可以转化为常数次对底层结构、路径结构和顶层压缩树的查询。
底层结构:
由于底层树大小最多为 log n,我们可以用位向量表示边是否被删除。每个节点预计算一个位向量,表示到根路径上的边。查询时,通过异或和掩码操作,可以在常数时间内判断路径上是否有边被删除。
路径结构:
对于长路径,我们将其分块,每块大小 log n。每块用一个位向量表示。同时维护一个“摘要”位向量,表示每块中是否有边被删除。摘要向量大小为 n / log n,我们可以对其使用 O(log n) 的动态树结构。查询时,结合块内查询和摘要向量查询,总时间为常数。
通过这种多层间接寻址,我们实现了在只允许删除边的树中,连通性查询的常数摊还时间。
一般图的完全动态连通性
最后,我们解决一般无向图的动态连通性问题,目标是 O(log² n) 的更新时间和 O(log n / log log n) 的查询时间。
高层思路
核心思想是维护一个最小生成森林,并对其进行层次分解。
- 为每条边分配一个级别,初始为
log n。G_i表示级别 ≤i的边构成的子图。 - 为每个
G_i维护一个生成森林F_i,使用欧拉序树存储。 - 关键不变性:
G_i的每个连通分量大小不超过2^i。- 森林是嵌套的:
F_i是F_{log n}在G_i上的限制。这要求F_{log n}是关于边级别的最小生成森林。
操作实现
插入边:
- 将边添加到端点关联的边列表中。
- 设置其级别为
log n。 - 如果边的端点在不同连通分量中,则在
F_{log n}中连接它们(欧拉序树插入)。
查询连通性:
在 F_{log n} 中对两个顶点查询根节点是否相同。为了加速查询,可以将顶层森林的欧拉序树实现为分支因子 Θ(log n) 的 B 树,这样查询根操作只需 O(log n / log log n) 时间。
删除边:
这是最复杂的操作。
- 从边列表中移除边。
- 如果边不在
F_{log n}中,则结束。 - 否则,从其级别开始向上,在所有包含它的森林
F_i中删除它(欧拉序树删除)。 - 然后,尝试寻找替代边。从被删除边的级别
i开始循环:- 设
T_V和T_W为删除边后F_i中包含两个端点的树,且|T_V| ≤ |T_W|。根据不变性1,|T_V| ≤ 2^{i-1}。 - 搜索
T_V中所有级别为i的边。对于每条这样的边e':- 如果
e'连接到T_W,则找到替代边。将其插入F_i,结束。 - 如果
e'的另一端仍在T_V内,则将其级别降为i-1(这为搜索操作提供了“代价”)。
- 如果
- 如果本层未找到替代边,则
i增加,继续到上一层搜索。
- 设
- 搜索过程中,我们需要快速找到
T_V中具有级别i的边的节点。这可以通过在欧拉序树节点上增强信息来实现,记录子树中是否存在级别i的边。
时间复杂度分析
- 插入:可能引发
O(log n)次边级别下降,每次下降关联O(log n)的森林更新,摊还后为O(log² n)。 - 删除:在最多
O(log n)层中进行删除和搜索,每层成本O(log n),加上因边级别下降带来的摊还成本,总摊还时间为O(log² n)。 - 查询:使用 B 树增强的欧拉序树,查询时间为
O(log n / log log n)。
动态图的其他问题
动态图领域还研究了许多其他问题:
- k-连通性:询问两点间是否存在
k条边不相交或点不相交的路径。对于k=2已有多项式对数时间算法,k≥3仍是开放问题。 - 最小生成森林:维护带权图的最小生成森林,可通过额外对数因子归约到动态连通性。
- 平面性测试:动态判断图是否保持平面性,已知算法复杂度较高。
- 有向图:
- 动态传递闭包:询问是否存在有向路径。更新时间和查询时间存在权衡,通常乘积与
m * n相关。 - 动态全源最短路径:维护所有点对之间的最短距离,比传递闭包更难。
- 动态传递闭包:询问是否存在有向路径。更新时间和查询时间存在权衡,通常乘积与
总结

本节课我们一起学习了动态图的上界算法。我们首先介绍了欧拉序树,一种支持子树查询的动态树结构。然后,我们看到了如何利用间接寻址和位操作,在只允许删除的树上实现常数时间的连通性查询。最后,我们深入探讨了一般图完全动态连通性的 O(log² n) 算法,其核心是通过层次化分解最小生成森林,并巧妙地将搜索替代边的成本摊还给边的级别下降。这些算法展示了在处理动态变化图结构时,结合多种数据结构思想和摊还分析所能达到的优美结果。下节课我们将探讨动态图问题的下界。
021:动态连通性下界证明 🧠


在本节课中,我们将学习如何证明动态连通性问题的一个下界。具体来说,我们将证明,即使在图的每个连通分量都是简单路径的情况下,任何支持边插入、删除和连通性查询的动态图算法,其每次操作(更新或查询)的摊销时间下界为 Ω(log n)。这个证明结合了信息论、平衡二叉树和精心设计的访问序列。
概述与问题设定
我们考虑一个动态图问题,其中需要支持三种操作:
- 插入边 (u, v)
- 删除边 (u, v)
- 连通性查询 (u, v):查询顶点 u 和 v 之间是否存在路径。
我们的目标是证明,即使限制图的每个连通分量都是路径,任何正确的算法(即使是随机化的、摊销的)也必须满足:
max(更新时间, 查询时间) = Ω(log n)
这个下界在 Cell Probe 模型 中成立,这是一个非常强的计算模型,仅统计算法访问的内存单元(cell)数量。因此,该下界也适用于 RAM 模型和指针机模型。
证明思路总览
证明的核心思想是构造一个困难的实例,并将其规约到一个称为“部分和”的问题上。主要步骤如下:
- 构造特定图结构:我们构造一个由 √n 条平行路径组成的图,每条路径连接两列各有 √n 个顶点的集合。连接两列顶点的完美匹配定义了一个排列。
- 定义批量操作:我们将对整条路径(即整个排列)进行批量更新和验证查询,而不是单条边操作。
- 设计困难访问序列:我们使用 位反转序列 作为访问这些批量操作的顺序。
- 在时间上构建平衡二叉树:在位反转序列上构建一棵平衡二叉树,并分析其性质。
- 信息论论证:通过分析算法在二叉树左右子树间必须传递的信息量,来导出时间下界。
接下来,我们详细展开每个部分。
构造图与批量操作
我们构造的图 G 的顶点集是一个 √n × √n 的网格。每一列有 √n 个顶点。在每两列之间,我们建立一个 完美匹配,这个匹配定义了一个从左侧顶点到右侧顶点的排列 π。
这样,从最左侧一列的任意顶点出发,沿着匹配边行走,都会形成一条唯一到达最右侧的路径。因此,整个图由 √n 条不相交的路径组成。
基于此结构,我们定义两种 批量操作:
- 批量更新 Update(i, π):将第
i个匹配(即连接第i列和第i+1列的边集)设置为新的排列π。这需要通过 √n 次边的删除和插入来完成。 - 验证查询 Verify-Sum(i, π):验证从第 1 个到第
i个排列的复合结果是否等于给定的排列π。这可以通过 √n 次连通性查询来实现(检查每条路径的端点是否映射正确)。
如果我们可以证明这些批量操作需要很高的复杂度,那么除以 √n 后,就得到了原始单条边操作的 Ω(log n) 下界。
困难的访问序列:位反转序列
我们选择 位反转序列 作为执行批量操作的顺序。对于一个长度为 √n(假设是 2 的幂)的序列,位反转序列能产生高度交错访问模式。
具体的坏访问序列如下:对于序列中的每个 i(按位反转顺序),我们依次执行:
Verify-Sum(i, π_correct):验证当前的前缀复合排列是否正确。Update(i, π_random):将第i个排列更新为一个全新的随机排列。
由于验证总是基于当前正确的状态,所以查询答案总是“是”。算法的挑战在于需要快速验证这个“是”,同时还要处理紧随其后的随机更新。
在时间上构建平衡二叉树
我们在位反转序列(视为时间顺序)上构建一棵平衡二叉树。这个树不是数据结构,而是我们用于分析的工具。
对于树中的任何一个节点 v,考虑其左子树和右子树对应的时间区间。位反转序列的关键性质在于:左子树中更新的索引集合,与右子树中查询的索引集合,是完美交错的。
这意味着,右子树中的查询必须依赖于左子树中刚刚更新过的排列信息。因此,算法在执行右子树的查询时,必须去读取在左子树期间写入的内存单元。
核心下界论证
我们关注二叉树中任意一个节点 v。设其子树有 L 个叶子(即对应 L 个操作)。
核心断言:在节点 v 的右子树时间区间内,算法必须执行至少 Ω(L√n) 次 Cell Probe,并且这些探测读取的内存单元,其最后一次写入操作发生在左子树的时间区间内。
如果这个断言成立,我们就可以对树上所有节点求和。由于每个叶子出现在 log n 层中,总的此类“跨子树读取”次数至少为 (√n) * log n。考虑到每次批量操作对应 √n 次原始操作,我们就得到了每个原始操作 Ω(log n) 的下界。
因此,证明的核心就转化为证明上述断言。
信息论编码论证
我们使用反证法来证明断言。假设断言不成立,即这种“跨子树读取”的次数很少。那么我们将展示,可以利用这一点来 编码 左子树中发生的所有随机排列,而编码长度会小于这些排列本身的信息熵,从而产生矛盾。
编码对象:左子树中所有 L/2 个更新操作所使用的随机排列。这些排列的信息熵为 Ω(L√n log n) 比特(因为每个随机排列需要约 √n log √n 比特来描述)。
编码构造:
- 我们 显式存储 所有“跨子树读取”涉及的内存单元地址及其内容。假设此类单元数为
X,存储成本为O(X log n)比特。 - 为了在解码时能正确模拟右子树的查询,我们还需要一个 分离器。分离器是一个内存地址的集合
S,它满足:- 所有在右子树被读取但未在左子树写入的地址(即“已知旧数据”)都在
S中。 - 所有在左子树被写入但未在右子树读取的地址(即“无关新数据”)都不在
S中。
我们可以使用 完美哈希函数族 来构造一个小的分离器族,然后只需存储使用了哪个分离器,成本为O(|R| + |W| + log log U)比特,其中R和W分别是左右子树读写涉及的地址集合大小,U是地址空间大小。
- 所有在右子树被读取但未在左子树写入的地址(即“已知旧数据”)都在
解码过程(模拟查询):
为了解码出左子树的排列,我们尝试模拟右子树的所有 Verify-Sum 查询。对于每个可能的查询参数 π,我们运行查询算法。当需要读取一个内存单元时:
- 如果该单元在“显式存储”的列表中,我们使用存储的值。
- 如果该单元在分离器
S中,我们将其视为“已知旧数据”(可以从初始状态推导)。 - 如果该单元不在分离器
S中,那么根据分离器的性质,它一定是“无关新数据”。这意味着当前模拟的查询参数π肯定是错的(因为正确的查询不会读这个单元),我们立即中止当前模拟,尝试下一个π。
最终,只有一个 π 能使模拟运行完毕并返回“是”,这个 π 就是正确的查询答案。知道了右子树所有查询的正确答案后,我们就可以逆向推导出左子树所有的更新排列。
矛盾产生:
如果断言不成立(X 很小),并且左右子树的读写总量 |R|+|W| 也不大,那么我们的总编码长度将小于 Ω(L√n log n) 比特。然而,我们却用这个短编码恢复出了信息熵很高的 L/2 个随机排列,这违反了信息论原理。因此,断言必须成立:要么 X 很大(即跨子树读取多),要么 |R|+|W| 很大(即子树内操作本身很多)。无论哪种情况,都贡献了我们需要的时间下界。
总结
本节课我们一起学习了动态连通性下界的证明。我们通过将动态图问题规约为排列的批量验证问题,并利用位反转序列在时间上构建平衡二叉树,最后通过精巧的信息论编码论证,证明了即使在连通分量仅为路径的简单情况下,任何动态连通性算法也必须花费 Ω(log n) 的摊销时间 per operation。

这个结果意味着像 Link-Cut Trees 和 Euler Tour Trees 这样的数据结构在指针机模型下是 最优 的。证明过程中融合了困难实例构造、二叉树分析、信息论和哈希函数等多种技巧,是高级数据结构下界证明的一个经典范例。
022:内存模型的历史 📚



在本节课中,我们将学习计算机内存层次结构建模的发展历程。我们将从早期的简单模型开始,逐步探讨如何更精确地模拟现代计算机中不同速度和容量的存储设备,并最终了解当前主流的I/O模型和缓存无关模型。
理想化的两级存储模型 (1972)
上一节我们介绍了课程背景,本节中我们来看看最早的内存层次结构模型之一。
1972年,Bob Floyd在一篇论文中提出了一个理想化的两级存储模型。该模型旨在模拟当时计算机(如PDP-11)中快速但容量小的核心内存与慢速但容量大的磁盘之间的差异。
该模型的核心概念如下:
- CPU:可以进行本地计算。
- 内存:被划分为大小为 B 的块。每个块最多可容纳 B 个数据项。
- 块操作:在一步操作中,可以读取两个块中的所有数据项,选取其中的一部分(最多B个),然后将它们写入(覆盖)另一个目标块。模型假设数据项在块内的顺序可以自由重排。
该模型的主要定理是:若要对 N 个数据项进行随机排列(每个目标块都是满的),则平均至少需要 N/B * log B 次块操作。证明使用了基于信息熵的势能论证。
然而,这个上界并非最优。通过基数排序,可以实现 N/B * log (N/B) 次操作。当 B 很小时(例如 B=1),正确答案是 N 而非 N log N。这个问题在多年后才得到完全解决。
红蓝卵石游戏模型 (1981)
上一节我们了解了考虑分块的模型,本节中我们来看看一个考虑缓存容量但忽略分块的模型。
1981年,Hong和Kung提出了“红蓝卵石游戏”模型,用于分析计算过程中的I/O复杂度。该模型基于计算的有向无环图(DAG)。
模型规则如下:
- 红卵石:代表在高速缓存(Cache)中的数据。
- 蓝卵石:代表在磁盘(Disk)中的数据。
- 初始时,所有输入节点为蓝色。
- 可以放置红卵石在一个节点上,前提是其所有前驱节点已有红卵石(表示计算依赖)。
- 可以免费移除任何卵石(表示遗忘数据)。
- 关键操作:可以将一个红卵石变为蓝卵石(写回磁盘),或将一个蓝卵石变为红卵石(从磁盘读入),每次变色计为一次I/O操作。
- 目标:最终所有输出节点为蓝色,且过程中任何时候红卵石的数量不超过缓存大小 M。
该模型的目标是在给定缓存大小 M 的前提下,最小化I/O操作(即颜色切换)的次数。他们分析了许多经典算法(如矩阵乘法、FFT)在此模型下的I/O复杂度。
外部内存模型 (I/O模型) (1987)
上一节我们分别看到了考虑分块和考虑缓存的模型,本节中我们来看看将两者结合的主流模型。
1987年,Aggarwal和Vitter提出了外部内存模型(常称为I/O模型或磁盘访问模型)。它融合了前两个模型的核心思想,并成为该领域研究的基础。
模型定义如下:
- 内存层次:包含一个无限大的磁盘和一个大小为 M 的缓存。
- 分块:磁盘和缓存都被划分为大小为 B 的块。缓存可容纳 M/B 个块。
- 操作:
- 从磁盘读一个块到缓存(替换一个现有块)。
- 在缓存内进行免费计算。
- 将缓存中的一个块写回磁盘(替换磁盘上的一个块)。
- 目标:最小化内存传输(I/O)的次数。
以下是该模型中的一些基本结果和算法技术:
扫描 (Scan)
顺序读取或处理 N 个数据项,成本为 O(N/B) 次I/O,这是最优的。
搜索 (Search)
在有序数据中搜索,最优策略是使用 B树,成本为 O(log_B N) 次I/O。下界来自信息论:每次读入一个块,最多获得 log(B+1) 比特的信息,而确定元素位置需要 log(N+1) 比特信息。
排序 (Sort)
排序的紧确界是 Θ((N/B) * log_{M/B} (N/B)) 次I/O。
- 下界:通过信息论论证,每次I/O最多能获得 B * log(M/B) 比特的排序信息。
- 上界:可通过多路归并排序实现。算法将数据递归分成 M/B 个子序列排序,然后一次性合并它们。
置换 (Permutation)
与排序问题类似,但有时直接随机访问放置每个元素(成本 O(N))可能比排序界更优。
该模型还衍生出考虑顺序访问与随机访问成本差异的变体,其中顺序读写连续多个块的成本低于随机访问单个块。
层次化内存模型 (HMM) 及其他多层模型
上一节我们深入探讨了成功的外部内存模型,本节中我们简要回顾一些尝试建模多层内存层次的方法。
早期模型主要关注两层,但实际计算机有更多层次(L1/L2/L3缓存、主存、磁盘等)。一些模型试图捕捉这一点。
层次化内存模型 (HMM, 1987)
这是一个简洁的RAM模型变体。访问内存位置 x 的成本是一个函数 f(x),例如 f(x) = log x。这模拟了数据越“远”(地址越大),访问成本越高的层次结构。他们提出了均匀最优算法的概念:一个算法在不知道具体层次参数的情况下,对所有可能的 f(x) 都能达到近似最优。
BT模型 (1990)
在HMM基础上增加了块传输操作,允许以一次“寻址”成本移动连续的一段数据,更贴近实际硬件行为,但分析变得复杂。
UMH模型 (1993)
试图描述一个通用的多级内存层次,每级有自己的缓存大小 M_i、块大小 B_i 和传输延迟 t_i。为了简化,通常假设这些参数按指数规律增长。尽管更真实,但模型复杂,得出的界限难以直观理解。
缓存无关模型 (1999)
上一节我们看到多层模型往往变得复杂,本节中我们来看一个优雅的解决方案:缓存无关模型。
1999年,Frigo等人提出了缓存无关模型。它建立在外部内存模型的基础上,但做了一个关键改变:算法设计者不知道参数 B(块大小)和 M(缓存大小)。算法在运行时由系统自动管理数据在缓存和磁盘间的移动(通常采用LRU等策略)。
核心思想与优势
- 均匀性:一个缓存无关算法自动适用于所有可能的 B 和 M。
- 多层最优性:理论证明,如果一个缓存无关算法在两层模型中是渐近最优的,那么它在任意多层内存层次中也是渐近最优的。
- 鲁棒性:适用于块大小变化、缓存被其他进程共享等真实场景。
缓存无关算法技术
许多外部内存算法可以转化为缓存无关算法。
扫描
顺序扫描自然就是缓存无关的,成本仍为 O(N/B)。
搜索
无法直接使用需要知道 B 的B树。替代方案是使用递归布局的van Emde Boas树结构。它将搜索树递归地分割,使得任何根到叶子的路径最多访问 O(log_B N) 个不同的内存块,从而实现 O(log_B N) 次I/O。
排序
可以实现与外部内存模型相同的排序界 Θ((N/B) * log_{M/B} (N/B)),但需要满足一个高缓存假设:M = Ω(B^{1+ε}),即缓存足够“高”而不是“宽”。这是必要的,否则无法达到该界限。
总结与未来方向 🚀
本节课中我们一起回顾了内存模型丰富的发展历史。
我们从Floyd的简单两级分块模型和Hong & Kung的红蓝卵石游戏模型出发,看到了它们如何融合成强大而简洁的外部内存模型(I/O模型)。为了处理多层内存,出现了HMM等模型,但往往牺牲了简洁性。最终,缓存无关模型通过要求算法不依赖具体参数,优雅地解决了多层和动态环境下的问题,并成为另一个极具影响力的模型。
目前,外部内存模型和缓存无关模型是理论研究和算法设计中最常用和成功的两个模型。未来的研究方向包括:
- 在这两个模型下解决更多图算法和几何计算问题。
- 并行缓存无关计算:如何将缓存无关思想有效地扩展到多核、众核环境,是当前非常活跃的研究领域。
注:本教程内容整理自MIT《高级数据结构》(6.851)课程第22讲“内存模型的历史”。

浙公网安备 33010602011771号