第十三讲 Union Find

并查集(Union-Find)


  • 合并:指定两个元素,将这两个元素所在的集合合并
  • 查找:查找指定元素所处集合的代表元

利用链表完成并查集

  • 用链表联系起一个集合中的所有元素
  • 头指针中的元素是此集合的代表
  • 每一个元素都储存有一个指针(Leader pointer)指向代表

  • 查找操作时间复杂度为\(O(1)\)

  • 合并操作时间复杂度为\(O(n)\),可优化为仅改变较小长度链表,时间复杂度为\(\min\{L(x),L(y)\}\)

一个元素在整个过程中,其 Leader 指针最多会被修改多少次?

一个元素的集合大小从 1 开始,每次修改指针后所处集合大小至少翻两倍,次数 \(m\) 满足 \(2^m \le n\),因此 \(m \le \log_2 n\)。即任意一个元素的指针在整个生命周期内,最多只会被修改 \(O(\log n)\) 次。\(n\) 个元素,每个元素最多贡献 \(\log n\) 次修改,所有合并操作的总复杂度是 \(O(n \log n)\)均摊运行时间\(O(\log n)\)

势能分析法

每个元素 \(x\) 的势函数:\(\phi(x) = \lg n - \lg |L(x)|\)\(n\) 是总元素个数。\(|L(x)|\) 是元素 \(x\) 当前所属集合(链表)的大小。

  • 直观理解: 当元素处于小集合时,势能很高;集合变大时,其势能会降低。

总势能 \(\Phi\) 是所有元素势能的总和:\(\Phi = \sum \phi(x)\)

初始状态:\(\phi(x) = \lg n\)。初始总势能:\(\Phi_{initial} = n \lg n\)

执行合并操作,假设 \(L(x)\) 是较短的链表,实际代价正比于短链表长度 \(O(|L(x)|)\)。当 \(L(x)\) 合并到 \(L(y)\) 时,新集合的大小变为 \(|L(x)| + |L(y)|\)

  • 对于原 \(L(y)\) 中的元素,它们的集合大小变大了,势能下降,我们忽略这一部分。
  • 对于 \(L(x)\) 中的任意元素\(z\),其势能变化量为: $\(\Delta \phi(z) =\displaystyle \lg \frac{|L(x)|}{|L(x)| + |L(y)|}\)$,又 \(|L(x)| + |L(y)| \ge 2|L(x)|\)
  • \(\displaystyle \Delta \phi(z) \le \lg(\frac{1}{2}) = -1\)。每一个受影响的元素,势能至少降低了 1
  • 对于 \(L(x)\) 中所有 \(|L(x)|\) 个元素,总势能变化量 \(\Delta \Phi \le -|L(x)|\)

计算摊还代价的公式为: $\(\text{摊还代价} = \text{实际代价} + \text{势能变化量} (\Delta \Phi)\)$ 根据上述推导,实际代价 \(\approx |L(x)|\),势能变化量 \(\Delta \Phi \le -|L(x)|\)\(\text{摊还代价} \le = 0\)


利用有根树完成并查集

在链表背景下,进行合并操作时我们修改较短链表上所有元素的leader pointer,现在我们只修改其代表元素的指针,使其指向另一集合的代表元素。

此时合并与查找的操作取决于待合并元素到根路径长度之和。

类比加短并长思想,合并时将树高较小的合并到树高较大的树中。只有当一个节点所在的子树与另一个等大或更大的子树合并时,该节点到根的距离才会增加。这意味着子树规模至少翻倍。树的高度(即路径长度)被限制在 \(O(\log n)\)

优化:在执行查找的过程中,既然已经走了一遍从\(z\)到根节点的长路径,可顺手把这条路径上所有的节点都直接指向根节点。一旦执行过一次深层的查找,该路径上的所有元素下次执行查找时都将是 \(O(1)\)

为保证树不退化成链表,需要一套规则来决定合并时的“方向”。

  • 秩(Rank): 每个节点的属性,在没有路径压缩的情况下,Rank是树高
  • 合并规则:
    1. 秩小并入秩大: Rank较小的树根指向Rank较大的树根。整棵树的秩不增
    2. 等秩合并: 当两树Rank相等时,合并后新根节点的Rank加 1。
  • 定义:\(x\)\(y\) 的父节点时,通常有 \(\text{rank}(x) > \text{rank}(y)\)

  • 单独使用按秩合并: 保证了树的高度永远不会超过 \(\lfloor \lg n \rfloor\)。因此 FindUnion 的最坏情况复杂度都是 \(O(\log n)\)

利用数学归纳法:转证:当高度为\(h\)时,树结点个数大于\(2^h\),使用数学归纳法,只有当树高均为\(h-1\)时进行合并操作才会增加树高为\(h\)

  1. 单独使用路径压缩:\(m\) 次操作中,最坏情况复杂度是 \(O(n + m \log_{1+m/n} n)\)
  2. 按秩合并 + 路径压缩: 这是算法史上最著名的结论之一。经过证明,同时使用这两种优化时,并查集每步操作的均摊时间复杂度为: $\(O(\alpha(n))\)$,这里的 \(\alpha(n)\)反阿克曼函数

标准并查集操作(Tarjan,1975)

数据结构

  • p(x) (Parent Pointer): 每个节点 x 存储一个指针指向其父节点。

  • rank(x) (Rank):该节点的秩。

查找操作

  • 检查 x 是否为根节点。是则直接返回 x。
  • p(x) = Find(p(x))。这一递归调用会一路向上查找根节点。在返回的过程中,它会将路径上遇到的所有节点,直接连接到根节点下(路径压缩)。

合并操作

  • 找到 x 和 y 各自的根节点 s 和 t。

  • 如果 s 的秩大于 t,直接把 t 接到 s 下面。

  • 如果秩相等,所以任选一个作为新根(s),将 s 秩加 1, t 接到 s 下面。

并查集算法的时间复杂度分析

  • 核心变量 \(\Delta_1(x)\):定义为父节点与当前节点“秩”的差。 $\(\Delta_1(x) = \text{rank}(p(x)) - \text{rank}(x)\)$
  • 势函数 \(\phi(x)\) 的设计
    • 对于非根节点,\(\phi(x) = \lg n - \lfloor \lg \Delta_1(x) \rfloor\)。当路径被压缩时,某个节点 \(x\) 会被直接挂到很远处的根节点下。这意味着它的父节点的秩 \(\text{rank}(p(x))\) 会剧增,导致 \(\Delta_1(x)\) 变大。
    • 此时\(\phi(x)\)减小。这种释放出来的势能用来支付路径压缩时遍历节点的实际耗时。
    • 对于根节点,\(\phi(x)=\lg n\cdot \rank(x)\)

在长为 \(L\) 的查找路径上,我们可以把节点按照 \(\lfloor \lg \Delta_1(x) \rfloor\) 的值(记为 \(k\))进行分类。由于 \(\text{rank}\) 的取值范围在 \([0, \lg n]\) 之间,所以 \(k\) 的可能取值非常少(只有 \(\lg(\lg n)\) 级别)。直观上认为,一条长路径上的大部分节点 \(x\) 都会和它的某个祖先 \(y\) 拥有相同的 \(k\) 值。

如果存在元素\(x\)及其祖先\(y\)满足\(\lfloor \lg\Delta_1(y)\rfloor=\lfloor \lg\Delta_1(x)\rfloor=k\)

\(\rank(root)-\rank(x)\ge \Delta_1(y)+\Delta_1(x)\ge 2^{k+1}\)

当路径压缩完成后。这导致 \(x\)\(k\) 值至少增加 1,对应势能 \(\phi(x)\) 至少下降 1。

Claim:对一条长度为\(L\)的路径压缩,这一原则对\(L-\log \log n\)个元素适用。故总势能减小\(\ge L-\log\log n\)


  • 优化变量 \(\Delta_2(x)\):改为使用比率 \(\frac{\text{rank}(p(x))}{\text{rank}(x)}\),而不再是差值。

如果存在元素\(x\)及其祖先\(y\)满足\(\lfloor \lg\Delta_2(y)\rfloor=\lfloor \lg\Delta_2(x)\rfloor=k\)

\(\displaystyle \frac{rank(root)}{\rank(x)}\ge \Delta_2(y)\Delta_2(x)\ge 2^{2^{k+1}}\)

当路径压缩完成后。这导致 \(x\)\(k\) 值至少增加 1,对应势能 \(\phi(x)\) 至少下降 1。

Claim:对一条长度为\(L\)的路径压缩,这一原则对\(L-\log \log\log n\)个元素适用。故总势能减小\(\ge L-\log \log \log n\)

我们更进一步,使用迭代对数级差 并修改势能函数\(\Delta_3(x)=\max\{k|\lg^{(k)}[(\rank(p(x))]\ge \rank(x)\}\)

\(\phi(x)=\begin{cases}\lg \lg ^*n\cdot \rank(x)& root\\ \lg \lg ^* n-\lfloor\lg \Delta_3(x)\rfloor & else\end{cases}\)

迭代对数 \(\lg^* n\)\(\lg^{(k)} n\) 表示对 \(n\) 连续取 \(k\) 次对数。

  • 如果 \(n = 2^{65536}\)\(\lg^* n\) 也仅为 \(5\)。在现实世界的计算中,可以粗略地认为 \(\lg^* n\) 是一个常数
  • \(\Delta_3(x)\)衡量的是父节点的秩相对于当前节点的秩,在“对数嵌套深度”上领先了多少。

Claim:\(0\le \Delta_3(x)\le \lg^* n=\min\{k|\lg ^{(k)}n<0\}\)

如果存在元素\(x\)及其祖先\(y\)满足\(\lfloor \lg\Delta_3(y)\rfloor=\lfloor \lg\Delta_3(x)\rfloor=k\)

\(\displaystyle \lg^{2^{k+1}}(\rank(r))\ge \lg^{2^k}(\rank(y))\ge \rank(x)\)

因此\(\phi(x)\)将减少1

Claim:对一条长度为\(L\)的路径压缩,这一原则对\(L-\log \log\log n\)个元素适用。故总势能减小\(\ge L-\log \lg^*n\)


阿克曼函数 (Ackermann Function) (期中不考)

\(A_k(j)=\begin{cases}j+1 & k=0\\ A_{k-1}^{(j+1)}(j) & k>0\end{cases}\)

  • \(A_0(j)\):简单的加法(线性)。
  • \(A_1(j)\):迭代加法 \(\rightarrow\) 变成乘法 (\(2j + 1\))。
  • \(A_2(j)\):迭代乘法 \(\rightarrow\) 变成幂运算(指数级,\(2^{j+1}(j+1)-1\))。
  • \(A_3(j)\):迭代幂运算 \(\rightarrow\) 变成叠幂运算,即 \(2^{2^{2^{\dots^{2}}}}\),高度为 \(j\)
  • \(A_4(j)\):增长速度已经超越了人类感官的理解,在 \(j\) 很小时结果就超过了宇宙中原子的总数。

并查集的均摊时间复杂度 \(O(\alpha(n))\),即阿克曼函数的反函数。

通过 \(\Delta_1, \Delta_2, \Delta_3\) 不断重定义势函数。每一层定义实际上都在利用更深层次的对数嵌套,本质是在寻找:究竟需要迭代多少层对数,才能让路径上几乎所有节点的势能下降都能覆盖其代价?这个嵌套的层数正是阿克曼函数的反函数 \(\alpha(n)\)

  • 阿克曼函数 (\(A_k\)) 的引入:为了量化极其剧烈的增长,引入了阿克曼函数。即使 \(k=4\)\(A_4(n)\) 的值也是一个“天文数字”。
  • 直观理解:在路径压缩中,父节点会不断向上跳跃,导致 \(\text{rank}(p(x))\) 远大于 \(\text{rank}(x)\)。我们通过寻找一个 \(k\) 值,使得 \(\text{rank}(p(x)) \approx A_k(\text{rank}(x))\),以此来反映这种“跨度”。

  • 反阿克曼函数 \(\alpha(n)\):定义为使得 \(A_k(1) > n\) 的最小 \(k\) 值。由于阿克曼函数增长极快,\(\alpha(n)\) 在宇宙可观测范围内通常不大于 5。

  • 层级 \(\text{level}(x)\):衡量父节点秩领先节点秩的“数量级”。它是最大的 \(k\),满足 \(\text{rank}(p(x)) \ge A_k(\text{rank}(x))\)
  • 迭代次数 \(\text{iter}(x)\):在当前的层级 \(k\) 下,衡量还差多少次迭代能进入下一个层级。
  • 取值范围:证明了 \(1 \le \text{iter}(x) \le \text{rank}(x)\),以及 \(\text{level}(x) < \alpha(n)\)

使用势能分析法,每个节点被赋予一个势能值 \(\phi(x)\): * 根节点:势能较高,为 \(\alpha(n) \cdot \text{rank}(x)\)。 * 非根节点\(\phi(x) = (\alpha(n) - \text{level}(x)) \cdot \text{rank}(x) - \text{iter}(x)\)。 * 直观意义: * 当 \(\text{level}(x)\) 增加时(即父节点秩跨度变大),势能 \(\phi(x)\) 会显著下降。 * 当 \(\text{iter}(x)\) 增加时,势能也会微弱下降。 * 路径压缩的操作会消耗这些势能,从而抵消实际的操作耗时。

单次查找操作中,路径上大部分节点的势能都会至少减少 1。如果路径上存在一个祖先 \(y\),使得 \(y\)\(x\) 处于相同的层级(\(\text{level}(x) = \text{level}(y)\))。除了路径上靠近根部的少数几个节点(约 \(\alpha(n)\) 个)外,其余每个节点在路径压缩后势能都会减 1。