高级数据结构课程讲义 4

5 40~52 分钟

高级数据结构课程讲义 4 *

第一部分:动态顺序维护 (Dynamic Order Maintenance)

同学们好,欢迎来到高级数据结构课程。今天我们将深入探讨一个非常重要且有趣的问题:动态顺序维护。顾名思义,动态顺序维护就是在一个集合中,不仅要存储元素,还要维护这些元素之间的相对顺序,并且允许我们动态地插入和删除元素。这在许多实际应用中都至关重要,例如操作系统中的进程调度、数据库中的索引管理,甚至是文本编辑器中的光标移动和文本插入等。

1.1 任务与基本操作

首先,我们来明确一下动态顺序维护的核心任务和它所需要支持的基本操作。我们的目标是存储一个元素的集合,并始终保持它们在一个给定的顺序中。为了实现这一目标,我们需要支持以下几种操作:

* insert(x, y)​*: 这个操作的含义是将元素 y​ 插入到元素 x​ 的紧后方。如果 x​ 是一个特殊值(例如,空值或列表的起始标记),那么 y​ 将被插入到列表的最前端。例如,如果当前列表是 a, b, c​,执行 insert(b, d)​ 后,列表将变为 a, b, d, c​。

* delete(x)​*: 这个操作相对简单,就是从集合中删除元素 x​。

* order(x, y)​*: 这是一个查询操作,它返回一个布尔值。如果元素 x​ 在当前顺序中位于元素 y​ 之前,则返回 True​;否则,返回 False​。

让我们通过一个简单的例子来理解这些操作:

假设我们从一个空集合开始:

  1. ​insert(ε, a)​:将 a​ 插入到最前面。集合a​

  2. ​insert(a, b)​:将 b​ 插入到 a​ 后面。集合a, b​

  3. ​insert(b, c)​:将 c​ 插入到 b​ 后面。集合a, b, c​

  4. ​insert(a, d)​:将 d​ 插入到 a​ 后面。集合a, d, b, c​

现在我们进行查询操作:

* order(a, c)a​ 在 c​ 之前,返回 True​。

* order(b, d)b​ 不在 d​ 之前d​ 在 b​ 之前),返回 False​。

1.2 朴素的实现思路及其局限性

在深入探讨更高级的解决方案之前,我们先来看看一些直观的实现思路,并分析它们的优缺点。

1.2.1 思想一:链表 (Linked List)

最直接的想法就是使用链表来存储这些元素。每个节点存储一个元素,并指向其在顺序中的下一个元素。例如a → d → b → c​。

* insert(x, y)​*: 如果我们知道 x​ 的位置,插入 y​ 只需要修改少数几个指针,理论上是 O(1) 操作。但如果我们需要先找到 x​,那么在最坏情况下,我们需要遍历整个链表,这将是 O(N) 的时间复杂度,其中 N 是链表中元素的数量。

* delete(x)​*: 同样,如果已知 x​ 的位置,删除是 O(1)。但查找 x​ 仍然是 O(N)。

* order(x, y)​*: 要判断 x​ 是否在 y​ 之前,我们需要从 x​ 开始遍历,看能否在遇到 y​ 之前到达 y​。在最坏情况下,这可能需要遍历整个链表,时间复杂度为 O(N)。

局限性:链表在查找和顺序查询方面的效率较低,都是 O(N)。当元素数量 N 很大时,这会成为性能瓶颈。

1.2.2 思想二:平衡二叉搜索树 (Balanced Binary Search Tree, BST)

为了提高查找效率,我们可能会想到使用平衡二叉搜索树。平衡BST(如AVL树、红黑树)能够保证插入、删除和查找操作的对数时间复杂度 O(log N)。

* insert(x, y)​*: 插入操作通常是 O(log N)。但这里的 insert(x, y)​ 是“在 x​ 之后插入 y​”,这需要我们能够找到 x​ 在树中的位置,并在其“逻辑”后方插入 y​。这可能需要对树进行一些增强,例如,通过在节点中存储子树大小或排名信息,我们可以实现 select​ 或 rank​ 操作,从而找到第 k 个元素或某个元素的排名。如果能够通过这些增强操作找到 x​ 的后继节点,并在其后插入 y​,那么插入操作的复杂度可以达到 O(log N)。

* delete(x)​*: 删除操作同样是 O(log N)。

* order(x, y)​*: 要判断 x​ 是否在 y​ 之前,我们可以比较它们在树中的“排名”或通过遍历树来判断。如果树支持 rank​ 操作,那么 order(x, y)​ 就可以通过比较 rank(x)​ 和 rank(y)​ 来实现,时间复杂度为 O(log N)。

局限性:虽然平衡BST将操作复杂度降低到了 O(log N),但对于某些应用场景,我们可能需要更快的 order​ 查询,理想情况下是 O(1)。此外,平衡BST的实现相对复杂,且每次操作都可能涉及树的旋转和平衡调整,常数因子较大。

1.3 基于标签的动态顺序维护:核心思想与挑战

为了克服上述方法的局限性,特别是实现 O(1) 的 order​ 查询,我们引入了**基于标签 (Label-based) 的动态顺序维护**思想。其核心理念是为每个元素分配一个唯一的数值标签 (label),这些标签的数值大小直接反映了元素的相对顺序。也就是说,如果 label(x) < label(y)​,那么 x​ 就在 y​ 之前。

例如,我们有元素 a, b, c​,可以给它们分配标签a:1, b:2, c:3​。那么 order(a, c)​ 只需要比较 label(a)​ (1) 和 label(c)​ (3),因为 1 < 3​,所以 a​ 在 c​ 之前,返回 True​。这显然是一个 O(1) 的操作!

1.3.1 标签分配与重标记问题

然而,这种方法也带来了新的挑战,主要体现在动态操作上:

* 插入 (Insert):假设我们要在 a​ (标签1) 和 b​ (标签2) 之间插入 d​。我们需要为 d​ 分配一个标签,使其介于1和2之间,例如 1.5。这看起来很简单a:1, d:1.5, b:2, c:3​。但如果我们要插入 e​ 在 a​ (标签1) 和 d​ (标签1.5) 之间,我们可以给 e​ 分配 1.25。但是,如果连续插入导致标签之间没有足够的“空隙”来分配新的标签怎么办?例如a:1, b:2​,如果我们要插入 d​ 在 a​ 和 b​ 之间,我们分配 d:1.5​。现在 a:1, d:1.5, b:2​。如果再要在 a​ 和 d​ 之间插入 e​,我们可以分配 e:1.25​。但如果不断地在某个狭窄的标签范围内插入,最终我们会耗尽所有可用的浮点数精度,或者标签值变得非常密集,导致无法直接插入新的标签。

* 重标记 (Relabeling):当标签空间变得过于拥挤时,我们就需要进行“重标记”操作。重标记意味着重新为一部分或所有元素分配标签,以在它们之间创建新的空隙。例如,将 a:1, d:1.5, b:2, c:3​ 重新标记为 a:10, d:20, b:30, c:40​,这样在 a​ 和 d​ 之间就有了很大的空隙(10到20),可以插入新的元素。重标记操作的代价可能很高,因为它涉及到修改多个元素的标签。

问题:重标记操作的频率和成本是关键。我们希望 order(x, y)​ 仍然是 O(1),而更新操作insert​ 和 delete​)的均摊时间复杂度能够达到 O(log N)。

1.3.2 应用场景

这种动态顺序维护技术在许多领域都有实际应用:

  1. 内存中的项目存储:在内存管理中,我们可能需要维护一个有序的内存块列表。当需要分配新的内存块时,我们需要将其插入到正确的位置,并保持顺序。例如a1, x, a2, a3...​,其中 x​ 是新插入的内存块。

  2. 门牌号 (House Numbers):想象一下一条街道上的门牌号。最初可能是 1, 2, 3, 4, 5​。如果要在2号和3号之间新建一栋房子,我们可能需要给它一个 2.5​ 的门牌号。如果房子越来越多,门牌号可能会变得非常密集,甚至需要重新规划整个街道的门牌号,以保持合理的间隔。

  3. 基本程序 (Basic Program):在一些早期编程语言中,代码行号就是一种顺序维护。例如:

    ​10 PRINT "HELLO"​

    ​20 PRINT "HOW ARE YOU?"​

    ​30 GOTO 10​

    如果要在10和20之间插入一行代码,我们可能会使用 `15 PRINT

1.4 Dietz, Sleator 1987 方法:全局重标记策略

为了解决标签空间拥挤的问题,Dietz 和 Sleator 在 1987 年提出了一种全局重标记策略 [1]。他们的核心思想是:

* 预留充足空间:在任何时候,我们都维护一个比当前实际存储元素数量 N​ 大得多的标签空间 U​。具体来说,他们建议分配 U = N^c​ 个槽位(slots),其中 c​ 是一个常数。这样做的目的是尽可能均匀地分散元素,为未来的插入操作预留足够的“空隙”。

* 加倍/减半策略 (Doubling/Halving Strategy):

* 插入时:当元素数量 N​ 增加到 2N​ 时(例如,通过一系列插入操作),我们就启动一个新的“阶段 (phase)”。在这个新阶段的开始,我们会对所有元素进行重标记,将它们均匀地重新分布到新的、更大的标签空间中。这类似于动态数组的扩容。

* 删除时:当元素数量 N​ 减少到 N/2​ 时,我们也启动一个新的阶段,并对所有元素进行重标记,将它们均匀地重新分布到新的、更小的标签空间中。这类似于动态数组的缩容。

* 均摊成本分析:

* 重标记的实际成本:每次全局重标记操作的成本是 O(N),因为我们需要遍历所有 N​ 个元素并重新分配它们的标签。然而,这种重标记操作是“昂贵”的,但它不是每次插入或删除都发生。它只在元素数量翻倍或减半时才发生。

* 均摊分析:通过均摊分析,我们可以证明,在至少 N/2​ 次操作(插入或删除)之后,才会触发一次全局重标记。因此,将 O(N) 的重标记成本分摊到这 N/2​ 次操作上,每次操作的均摊成本就是 O(N) / (N/2) = O(1)。

总结:Dietz, Sleator 1987 方法通过全局重标记和加倍/减半策略,实现了 order(x, y)​ 操作的 O(1) 时间复杂度,以及 insert​ 和 delete​ 操作的 O(1) 均摊时间复杂度。这意味着从“全局”来看,我们总是有足够的空间来插入新元素。

局限性:尽管全局均摊成本很低,但这种方法在“局部”仍然可能遇到问题。例如,在两个相邻元素之间连续插入多个新元素时,即使全局空间充足,这两个元素之间的局部标签空间也可能迅速耗尽,导致需要进行局部重标记,而这在原始的 Dietz, Sleator 1987 方法中并未详细讨论。这引出了后续更精细的解决方案。

1.5 Bender, Cole, Demaine, Farach-Colton, Zito 2002 方法:分层标签分配

为了解决局部空间不足的问题,Bender 等人在 2002 年提出了一种更复杂的动态顺序维护方法 [2]。他们的方法基于一个分层的标签分配策略,可以看作是将 U = N^c​ 个槽位作为一棵完全二叉搜索树的叶子节点(概念上)。

1.5.1 概念模型:完全二叉搜索树的叶子

想象一个高度为 log U​ 的完全二叉搜索树,其叶子节点总数为 U​。我们将这些叶子节点视为可用的标签槽位。每个内部节点 t​ 代表其子树所覆盖的叶子节点范围。例如,根节点代表所有 U​ 个叶子节点,第一层(level 1)的节点代表 U/2​ 个叶子节点,第二层(level 2)的节点代表 U/4​ 个叶子节点,以此类推。一个在 level i​ 的内部节点 t​,其子树包含 2^i​ 个叶子节点。

1.5.2 密度与溢出密度 (Density and Overflow Density)

为了判断一个节点下的标签空间是否“拥挤”,我们引入了“密度”的概念:

​节点 t 的密度 = (t 子树中存储的元素数量) / (t 子树包含的叶子节点数量)​

对于一个在 level i​ 的节点 t​,其密度就是 (t 子树中存储的元素数量) / 2^i​。

我们还定义了一个“溢出密度阈值” T_i​。如果一个节点 t​ 的密度超过了 T_i​,我们就认为这个节点下的标签空间“过密”或“溢出”了,需要进行重标记。这个阈值 T_i​ 是根据节点所在的层级 i​ 来确定的。通常T_i = T^(-i)​,其中 T​ 是一个常数,且 T ∈ (1, 2)​(例如T = 1.5​)。这意味着层级越高的节点(越靠近根节点),其允许的密度越低,因为它们覆盖的范围越大,需要更宽松的标签分布。

1.5.3 操作与重标记机制

order(x, y)​ 操作*:

* 这仍然是 O(1) 操作。我们只需要比较 x​ 和 y​ 的标签 l(x)​ 和 l(y)​。如果 l(x) < l(y)​,则 x​ 在 y​ 之前。

delete(x)​ 操作*:

* 首先,从列表中移除 x​。这通常是 O(1) 操作。

* 如果删除操作导致某个节点下的元素数量变得“过少”(例如,低于某个阈值),那么可能会触发重标记。这类似于 Dietz, Sleator 方法中的减半策略,但这里是针对局部子树的。如果整个结构变得“太小”,则启动一个新的全局阶段,重新标记所有元素。

insert(x, y)​ 操作*:

插入 y​ 到 x​ 之后是这个方法最复杂的部分,因为它可能触发重标记。基本思路是:

  1. 尝试直接分配标签:如果 x​ 的标签 l(x)​ 和 x​ 的后继 z​ 的标签 l(z)​ 之间有足够的空隙(即 l(z) > l(x) + 1​),那么我们可以直接为 y​ 分配一个介于 l(x)​ 和 l(z)​ 之间的任意标签,例如 (l(x) + l(z)) / 2​。这通常是 O(1) 操作。

  2. 触发重标记:如果 l(z) = l(x) + 1​,这意味着 x​ 和 z​ 的标签是相邻的,没有空隙来插入 y​。此时,我们需要进行重标记。重标记的策略是:

    * 向上遍历:从 x​ 所在的叶子节点开始,向上遍历其祖先节点,直到找到第一个密度低于其溢出密度阈值 T_i​ 的祖先节点 t​。这个节点 t​ 就是我们需要进行重标记的子树的根节点。如果一直遍历到根节点都没有找到,那么整个结构可能已经过密,需要进行全局重标记。

    * 重新分配标签:一旦找到节点 t​,我们就会重新标记 t​ 子树中的所有元素,将它们均匀地分布到 t​ 子树所覆盖的标签槽位中。这样,在 x​ 和 z​ 之间就会产生新的空隙,从而可以为 y​ 分配一个标签。重标记 t​ 子树中的所有元素,并设置 l(y)​ 介于 l(x)​ 和 l(z)​ 之间。

1.5.4 均摊分析

Bender 等人的方法通过巧妙地选择溢出密度阈值 T_i​ 和重标记策略,实现了 insert​ 和 delete​ 操作的 O(log N) 均摊时间复杂度。其核心思想是:

* 重标记成本分摊:一个节点 t​(在 level i​)的重标记成本是与其子树中元素的数量成正比的。这个成本被分摊到该子树中发生的插入操作上。由于我们只在密度超过阈值时才重标记,并且每次重标记后会创建足够的空隙,因此在下一次重标记之前,会有足够多的插入操作来分摊这个成本。

* 根节点不溢出:通过适当选择 T​,可以确保根节点level 0​)永远不会溢出,即 T_0 = T^0 = 1​。这意味着根节点下的元素数量永远不会超过其叶子节点数量,从而保证了全局的标签空间是充足的。

* 均摊分析结果:最终的分析表明insert(x, y)​ 操作的均摊时间复杂度为 O(log N)。这是因为每次插入操作,可能需要向上遍历 log N​ 层,并在某个层级 i​ 的节点 t​ 处触发重标记。重标记的成本被均摊到 (2/T)^i​ 次操作上,最终使得每次插入的均摊成本为 O(log N)。

总结:Bender 等人的方法通过分层标签分配和局部重标记策略,有效地解决了动态顺序维护中的局部拥挤问题,并在理论上提供了 O(1) 的 order​ 查询和 O(log N) 的均摊 insertdelete​ 操作。这使得动态顺序维护在许多需要高效顺序操作的场景中成为可能。


第二部分:并查集 (Union-Find)

接下来,我们将转向另一个在算法和数据结构中都非常重要的概念:并查集 (Union-Find) 数据结构。并查集主要用于处理一系列不相交集合的合并与查询问题,它在图算法(如 Kruskal 最小生成树算法)、网络连通性问题、以及一些几何问题中都有广泛应用。

2.1 概述与应用:Kruskal 算法

并查集数据结构的核心任务是维护一组不相交的集合,并支持两种主要操作:

* Union(x, y)​*:合并包含元素 x​ 和 y​ 的两个集合。

* Find(x)​*:查找元素 x​ 所属集合的代表元素(通常是该集合的根节点)。通过比较 Find(x)​ 和 Find(y)​ 的结果,我们可以判断 x​ 和 y​ 是否属于同一个集合。

一个典型的应用场景是 Kruskal 最小生成树算法。Kruskal 算法的目标是找到一个连通图的最小生成树,其步骤如下:

  1. 初始化:将图中的每个顶点视为一个独立的集合。

  2. 按权重排序:将图中的所有边按权重从小到大排序。

  3. 遍历边:依次遍历排序后的边 (u, v)​:

    * 如果 u​ 和 v​ 不在同一个集合中(通过 Find(u) != Find(v)​ 判断),则将这条边加入最小生成树,并合并 u​ 和 v​ 所在的集合(通过 Union(u, v)​ 操作)。

    * 如果 u​ 和 v​ 已经在同一个集合中,则这条边会形成环,跳过。

通过并查集,Kruskal 算法能够高效地判断两个顶点是否连通,并在不形成环的情况下合并连通分量。这大大提高了算法的效率。

2.2 基本操作

并查集主要支持以下三个基本操作:

* MakeSet(x)​*:创建一个新的集合,其中只包含元素 x​。此时x​ 是它自己集合的代表元素。

* Find(x)​*:返回元素 x​ 所属集合的代表元素。这个代表元素是唯一的,可以用来判断两个元素是否属于同一个集合。

* Union(x, y)​*:合并包含元素 x​ 和 y​ 的两个集合。合并后,这两个集合将成为一个大集合,并拥有一个新的代表元素。

让我们通过一个例子来理解这些操作:

假设我们有元素 1, 2, 3, 4。

  1. ​MakeSet(1), MakeSet(2), MakeSet(3), MakeSet(4)​:初始状态,每个元素都在自己的集合中。

    ​{1}, {2}, {3}, {4}​

  2. ​Union(1, 3)​:合并包含 1 和 3 的集合。

    ​{1, 3}, {2}, {4}​ (假设 3 成为代表元素)

  3. ​Find(1)​:返回 3。

  4. ​Find(3)​:返回 3。

  5. ​Find(2)​:返回 2。

  6. ​Find(4)​:返回 4。

要检查 x​ 和 y​ 是否在同一个集合中,我们只需要比较 Find(x) == Find(y)​。

2.3 基于树的实现

并查集通常使用树形结构来实现。每个集合都表示为一棵树,树的根节点就是该集合的代表元素。每个节点存储一个指向其父节点的指针。对于根节点,其父指针可以指向自身或设置为特殊值(如 null​ 或 -1​)。

2.3.1 Find(x)​ 操作:路径追踪

​Find(x)​ 操作的实现非常直观:从元素 x​ 开始,沿着父指针一直向上遍历,直到找到根节点。这个根节点就是 x​ 所属集合的代表元素。

例如,如果 x​ 的父节点是 p(x)p(x)​ 的父节点是 p(p(x))​,以此类推,直到 root​。那么 Find(x)​ 就是 root​。

成本:在最坏情况下,如果树是一个“扁平”的链状结构Find(x)​ 操作的成本可能高达 O(N),其中 N 是集合中的元素数量(即树的高度)。

2.3.2 Union(x, y)​ 操作:连接策略

​Union(x, y)​ 操作的实现步骤是:

  1. 找到 x​ 所属集合的代表元素 root_x = Find(x)​。

  2. 找到 y​ 所属集合的代表元素 root_y = Find(y)​。

  3. 如果 root_x != root_y​,说明 x​ 和 y​ 不在同一个集合中,需要合并。合并的方法是:将其中一个根节点作为另一个根节点的子节点。例如,将 root_y​ 的父指针指向 root_x​,这样 root_x​ 就成为了新合并集合的代表元素。

成本Union​ 操作的成本主要取决于两次 Find​ 操作的成本,加上 O(1) 的指针修改。因此,在最坏情况下,仍然是 O(N)。

局限性:朴素的基于树的实现可能导致树的高度过大,从而使得 Find​ 操作的效率低下。为了解决这个问题,我们需要引入优化策略。

[1] Dietz, P. F., & Sleator, D. D. (1987). Two algorithms for maintaining order in a list. In Proceedings of the nineteenth annual ACM symposium on Theory of computing (pp. 365-372). ACM.

[2] Bender, M. A., Cole, R., Demaine, E. D., Farach-Colton, M., & Zito, J. (2002). Dynamic order maintenance in logarithmic amortized time. Journal of Algorithms, 43(1), 31-46.

2.4 优化策略

为了提高并查集的效率,我们通常会结合两种重要的优化策略:**按深度合并 (Union by Depth)** 和 路径压缩 (Path Compression)。

2.4.1 按深度合并 (Union by Depth) / 按秩合并 (Union by Rank)

当合并两棵树时,我们总是将较小的树连接到较大的树的根上。这里的“大小”可以有两种衡量标准:

  1. 按深度合并 (Union by Depth):将深度较小的树的根作为深度较大的树的根的子节点。如果两棵树的深度相同,则任意选择一个作为另一个的子节点,并将新树的深度加一。

    * 思想:这种策略旨在尽可能地保持树的扁平,避免出现过深的树。通过将较浅的树连接到较深的树上,可以最小化合并后树的深度增长。

    * 实现:每个根节点需要额外存储一个 depth​ 值,表示以该节点为根的树的深度。在 Union​ 操作时,比较两个根节点的 depth​ 值,将 depth​ 值较小的根节点连接到 depth​ 值较大的根节点上。如果 depth​ 值相等,则新根节点的 depth​ 值加一。

  2. 按秩合并 (Union by Rank):与按深度合并类似,但这里“秩 (rank)”的概念略有不同。秩通常表示树的近似高度或节点数量的对数。将秩较小的树的根作为秩较大的树的根的子节点。如果两棵树的秩相同,则任意选择一个作为另一个的子节点,并将新树的秩加一。

    * 思想:秩可以更准确地反映树的“大小”,尤其是在路径压缩优化下,实际深度可能不再准确。按秩合并同样是为了保持树的扁平化。

    * 实现:每个根节点需要额外存储一个 rank​ 值。在 Union​ 操作时,比较两个根节点的 rank​ 值,将 rank​ 值较小的根节点连接到 rank​ 值较大的根节点上。如果 rank​ 值相等,则新根节点的 rank​ 值加一。

效果:无论是按深度合并还是按秩合并,都能有效地控制树的高度。经过 N​ 次 Union​ 操作后,树的最大高度可以被限制在 O(log N)。这意味着 Find​ 操作在没有其他优化的情况下,其成本可以降低到 O(log N)。

2.4.2 路径压缩 (Path Compression)

路径压缩是一种在 Find​ 操作中进行的优化。当执行 Find(x)​ 操作时,我们不仅找到 x​ 的根节点,还会将 x​ 到根节点路径上的所有节点直接连接到根节点。这样,下次再查找这些节点时,就可以直接找到根节点,大大减少了查找时间。

* 思想:将路径上的所有节点“扁平化”,使其直接指向根节点。这有点像 Splay Tree 的操作,将访问过的节点移动到根部附近,但这里是直接连接到根。

* 实现:在 Find(x)​ 函数中,递归地查找 x​ 的父节点的根。当递归返回时,将 x​ 的父指针直接指向找到的根节点。

效果:路径压缩极大地提高了 Find​ 操作的效率。虽然单次 Find​ 操作的成本可能仍然是 O(log N)(在没有按秩合并的情况下),但它会显著改善后续 Find​ 操作的性能。路径压缩会改变树的结构,使得树变得更扁平。

2.5 均摊分析:Log*n 和反阿克曼函数

当并查集同时使用按秩合并和路径压缩这两种优化策略时,其性能会达到惊人的高效。其均摊时间复杂度非常接近常数时间。

2.5.1 迭代对数函数 (Iterated Logarithm Function) log*n

在分析并查集的性能时,我们经常会遇到一个增长极其缓慢的函数:迭代对数函数 log*n​ (读作 log star n)。

​log*n​ 的定义是:对 n​ 连续取对数,直到结果小于等于 1 所需要的次数。

例如:

log2 = 1​ (因为 log2(2) = 1​)

log4 = 2​ (因为 log2(4) = 2​, log2(2) = 1​)

log16 = 3​ (因为 log2(16) = 4​, log2(4) = 2​, log2(2) = 1​)

log65536 = 4​ (因为 log2(65536) = 16​, log2(16) = 4​, log2(4) = 2​, log2(2) = 1​)

log2^65536 = 5​ (这是一个非常大的数字,远超宇宙中原子的数量)

可以看出log*n​ 函数的增长速度非常非常慢。对于任何实际应用中的 n​ 值log*n​ 的值都不会超过 5 或 6。因此,在实际应用中,我们可以近似地认为 log*n​ 是一个常数。

2.5.2 反阿克曼函数 (Inverse Ackermann Function) α(n)

并查集在同时使用按秩合并和路径压缩时的均摊时间复杂度,可以用反阿克曼函数 α(n)​ 来表示。阿克曼函数 A(m, n)​ 是一个增长非常快的函数,而其反函数 α(n)​ 则是增长非常非常慢的函数。

​α(n)​ 的精确定义比较复杂,但其核心思想是它与 log*n​ 类似,增长速度极慢。对于所有实际可能遇到的 n​ 值(即使是宇宙中所有原子的数量)α(n)​ 的值都不会超过 4 或 5。因此,在所有实际应用中α(n)​ 也可以被视为一个常数。

2.5.3 带有路径压缩和按秩合并的性能分析

结论:当并查集数据结构同时采用按秩合并(或按深度合并)和路径压缩两种优化策略时Find​ 和 Union​ 操作的均摊时间复杂度为 O(α(N)),其中 N 是元素的总数。

* Find​ 操作*:均摊 O(α(N))。

* Union​ 操作*:均摊 O(α(N))(因为 Union​ 操作主要由两次 Find​ 操作和 O(1) 的指针修改组成)。

这意味着,对于任何实际规模的问题,并查集的 Find​ 和 Union​ 操作几乎可以视为常数时间操作。这是非常强大的性能,使得并查集成为处理不相交集合问题的首选数据结构。

总结:

* 维护不相交集合:并查集通过树形结构来表示集合,根节点是集合的代表。

* Find​ 操作*:沿着父指针向上找到根节点,并进行路径压缩,将路径上的所有节点直接连接到根节点。

* Union​ 操作*:找到两个元素的根节点,然后将秩(或深度)较小的树的根连接到秩(或深度)较大的树的根上。如果秩相等,则新根的秩加一。

* 均摊性能:结合路径压缩和按秩合并Find​ 和 Union​ 操作的均摊时间复杂度为 O(α(N)),在实际应用中可视为常数时间。


参考文献

[1] Dietz, P. F., & Sleator, D. D. (1987). Two algorithms for maintaining order in a list. In Proceedings of the nineteenth annual ACM symposium on Theory of computing (pp. 365-372). ACM. https://dl.acm.org/doi/10.1145/28395.28420

[2] Bender, M. A., Cole, R., Demaine, E. D., Farach-Colton, M., & Zito, J. (2002). Dynamic order maintenance in logarithmic amortized time. Journal of Algorithms, 43(1), 31-46. https://www.sciencedirect.com/science/article/pii/S019667740200002X


推荐文章

数据结构2