PavelMavrin-数据结构算法笔记-全-

PavelMavrin 数据结构算法笔记(全)

001:算法、时间复杂度和归并排序 🚀

在本节课中,我们将学习算法的基础概念,了解如何衡量算法的效率(时间复杂度),并通过一个经典算法——归并排序,来实践这些理论。

什么是算法?🤔

算法是解决特定问题的一系列形式化步骤。它将问题的解决方案分解为一系列基本操作。在本课程中,我们主要讨论数据处理算法。这类算法通常的工作流程是:接收输入数据,经过一系列处理,最终产生输出结果。

例如,计算一个整数数组中所有元素的和。我们可以用伪代码描述这个算法:

S = 0
for i from 0 to n-1:
    S = S + A[i]
output S

如何衡量算法效率?⏱️

衡量算法效率的一个核心指标是时间复杂度。它衡量的是算法解决问题所需的时间。然而,我们通常不用“秒”来直接衡量,因为不同计算机的处理速度不同。因此,我们转而计算算法执行的基本操作数量

为了统一计算操作数,我们需要一个计算模型。最常用的模型是RAM模型。在这个模型中:

  • 内存被看作一个大数组,可以在常数时间内访问或修改任意位置的元素。
  • 算法可以执行常规的算术运算、循环、条件判断等操作。

大O符号:描述增长趋势 📈

回到计算数组和的例子。如果我们仔细计算,其操作数可能是一个类似 2 + 5n 的函数。当 n 很大时,常数项 2 和系数 5 变得不那么重要,真正决定算法快慢的是 n 本身。我们用大O符号来描述这种渐近增长的上界。

定义:如果存在常数 Cn0,使得对于所有 n ≥ n0,都有 f(n) ≤ C * g(n),则称 f(n)O(g(n))

例如,我们可以证明 2 + 5nO(n)。只需取 C = 6, n0 = 2,即可满足条件。

大O符号表示的是时间复杂度的上界。与之对应的还有表示下界的大Ω符号和表示紧确界的大Θ符号。在本课程中,为简化起见,我们主要使用大O符号来证明算法的“足够快”。

计算时间复杂度:几种常见情况 🧮

以下是计算时间复杂度时常见的几种模式:

  • 简单循环:如果有一个从 0n-1 的循环,其时间复杂度通常是 O(n)
  • 嵌套循环:如果有一个双重嵌套循环,每层都运行 n 次,那么时间复杂度是 O(n²)
  • 循环变量倍增:在 while i < n: i = i * 2 这样的循环中,循环次数约为 log₂ n,因此时间复杂度是 O(log n)。我们通常省略对数的底数,因为不同底数之间只差一个常数倍。
  • 递归算法:计算递归算法的时间复杂度,需要分析递归调用次数和每次调用的工作量。

排序算法初探:插入排序 🔢

排序算法是算法的经典案例。插入排序的工作原理是:维护一个已排序的前缀,依次将后续元素插入到前缀的正确位置。

其伪代码如下:

for i from 0 to n-1:
    j = i
    while j > 0 and A[j] < A[j-1]:
        swap A[j] and A[j-1]
        j = j - 1

插入排序的时间复杂度取决于输入

  • 最好情况(数组已排序):O(n)
  • 最坏情况(数组逆序):O(n²)
    在算法分析中,若无特别说明,我们讨论的通常是最坏情况时间复杂度。因此,插入排序的时间复杂度是 O(n²)

更高效的排序:归并排序 🧬

有没有比 O(n²) 更快的排序算法?有,归并排序就是一个 O(n log n) 的算法。它基于分治思想。

归并排序的核心操作是合并:将两个已经排序的数组合并成一个大的有序数组。合并过程如下:

  1. 比较两个数组当前的最小元素。
  2. 将较小的元素放入结果数组。
  3. 重复步骤1和2,直到所有元素都被取出。

合并两个长度分别为 nm 的数组,时间复杂度为 O(n + m)

归并排序的分治过程 ⚙️

完整的归并排序算法是递归的:

  1. :将数组分成左右两半。
  2. :递归地对左右两半分别进行归并排序。
  3. :将两个已排序的半边数组合并起来。

其递归结构可以表示为:

T(n) = 2 * T(n/2) + O(n)

其中 T(n) 是排序长度为 n 的数组所需的时间,O(n) 是合并操作的时间。

分析归并排序的时间复杂度 📊

我们可以通过递归树来分析这个复杂度:

  • 递归树共有 log₂ n 层。
  • 每一层所有子问题需要处理的元素总数加起来都是 n
  • 因此,总时间复杂度 = 层数 × 每层工作量 = O(n log n)

这符合主定理的结论。主定理是解决形如 T(n) = a * T(n/b) + f(n) 的递归式时间复杂度的通用方法。在归并排序中,a = 2, b = 2, f(n) = O(n),属于主定理的第三种情况,结果正是 O(n log n)

总结 🎯

本节课我们一起学习了:

  1. 算法是解决问题的形式化步骤序列。
  2. 使用时间复杂度(通常用大O符号表示)来衡量算法效率,它关注的是操作数量随输入规模的增长趋势。
  3. 分析了插入排序,其最坏情况时间复杂度为 O(n²)
  4. 深入探讨了基于分治思想的归并排序,通过递归和合并操作,实现了 O(n log n) 的更优时间复杂度。
  5. 简要介绍了用于分析递归算法复杂度的主定理

理解这些基础概念是学习更复杂算法与数据结构的基石。下节课我们将继续探索更多精彩内容。

002:数据结构、二叉堆与堆排序 🧠

在本节课中,我们将要学习数据结构的基本概念,并深入探讨一种名为“二叉堆”的具体数据结构。我们将了解它的工作原理、如何实现插入和删除最小元素的操作,以及如何利用它来实现一个高效的排序算法——堆排序。


什么是数据结构?🏗️

数据结构,顾名思义,是一种用于存储数据的结构。它不仅仅是简单地将数据堆放在一起,而是以一种特定的方式组织数据,以便高效地执行某些操作。

为什么需要数据结构?为什么不直接把所有数据放进一个大数组里?原因在于,我们通常希望对数据进行访问和操作。例如,在数据库中查找特定记录,或计算某项统计数据。这些操作定义了我们需要什么样的数据结构。因此,在选择数据结构之前,首先要明确需要支持哪些操作。

每个数据结构都有一组特定的操作,并且我们需要分析每个操作的时间复杂度。例如,一个简单的数组支持两种基本操作:

  • 获取元素a[i]
  • 设置元素a[i] = x

这两种操作的时间复杂度都是常数时间 O(1)。

数据结构和算法紧密相连。设计算法时,合适的数据结构可以使其更高效;而实现数据结构本身,也需要用到算法。


二叉堆(优先队列)介绍 🌳

上一节我们介绍了数据结构的基本概念,本节中我们来看看一种名为“二叉堆”(也称为优先队列)的具体数据结构。

二叉堆属于“优先队列”这一类数据结构。它支持以下两个核心操作:

  1. 插入:向堆中添加一个新元素。
  2. 删除最小元素:从堆中移除并返回当前最小的元素(假设元素可以相互比较)。

在深入二叉堆之前,我们先尝试用简单数组来实现这两个操作,以理解其挑战所在。

简单数组的两种实现尝试

第一种尝试:无序数组

  • 插入:直接将新元素放在数组末尾,时间复杂度为 O(1)。
  • 删除最小元素:需要遍历整个数组以找到最小元素,然后将其与最后一个元素交换并移除,时间复杂度为 O(n)。

第二种尝试:有序数组

  • 删除最小元素:由于数组已排序,最小元素在末尾,直接移除即可,时间复杂度为 O(1)。
  • 插入:为了保持数组有序,需要为新元素找到正确位置并移动其他元素,时间复杂度为 O(n)。

可以看到,两种简单实现都无法同时让两个操作都高效。如果算法中需要频繁进行插入和删除,总时间复杂度会很高(O(n²))。而二叉堆的目标是让两个操作的时间复杂度都达到 O(log n)


二叉堆的结构与性质 🌲

现在,我们来学习真正的二叉堆。二叉堆在逻辑上是一棵“完全二叉树”(或近似完全二叉树)。这意味着除了最后一层,其他层都是满的,并且最后一层的节点尽可能靠左排列。

这棵树有一个关键性质:堆性质。对于最大堆,每个节点的值都大于或等于其子节点的值;对于最小堆(我们讨论的),每个节点的值都小于或等于其子节点的值。因此,最小堆的根节点总是整个堆中的最小元素。

我们如何存储这棵树?由于它的结构是固定的(由元素个数决定),我们可以使用一个简单的数组来存储,无需复杂的指针。

  • 将树的节点按层、从左到右依次编号(从0开始)。
  • 数组的第 i 个位置就存储编号为 i 的节点上的元素。

通过简单的公式,我们可以在数组中找到任意节点的父节点或子节点:

  • 节点 i左子节点索引:2*i + 1
  • 节点 i右子节点索引:2*i + 2
  • 节点 i父节点索引:(i - 1) // 2 (整数除法)

二叉堆的操作:上浮与下沉 ⬆️⬇️

理解了堆的存储方式后,我们来看看如何实现插入和删除操作。这两个操作的核心是维护堆性质的两种辅助操作:“上浮”和“下沉”。

插入与上浮操作

当插入一个新元素 x 时:

  1. x 放在数组的末尾(即树的最后一个位置)。
  2. 此时,堆性质可能被破坏(新元素可能比它的父节点小)。
  3. 进行“上浮”操作:不断将新元素与其父节点比较。如果它比父节点小,则交换它们的位置。重复此过程,直到它不再小于父节点,或者到达根节点。

这个过程最多需要从树的最底层移动到根节点,层数为树的高度,即 O(log n)。

以下是上浮操作的伪代码描述:

function insert(x):
    heap.append(x)          // 将x放入数组末尾
    i = heap.size() - 1     // i是x的当前位置
    while i > 0 and heap[i] < heap[(i-1)//2]:
        swap(heap[i], heap[(i-1)//2]) // 与父节点交换
        i = (i-1)//2                  // 更新当前位置为父节点位置

删除最小元素与下沉操作

要删除最小元素(即根节点元素):

  1. 根节点就是最小元素。但我们不能直接删除根节点,否则树会分裂。
  2. 将数组的最后一个元素移动到根节点的位置。
  3. 此时,根节点的堆性质几乎肯定被破坏(新上来的元素可能比子节点大)。
  4. 进行“下沉”操作:从根节点开始,将其与它的两个子节点中较小的那个比较。如果它比这个较小的子节点大,则交换它们的位置。重复此过程,直到它不大于任何子节点,或者到达叶子节点。

这个过程同样最多需要从根节点移动到叶子节点,时间复杂度为 O(log n)。

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

function extract_min():
    min_value = heap[0]          // 保存根节点(最小值)
    heap[0] = heap.last()        // 将最后一个元素移到根节点
    heap.remove_last()           // 删除最后一个元素
    i = 0
    while (2*i + 1) < heap.size(): // 当左子节点存在时
        j = 2*i + 1               // j先设为左子节点
        // 如果右子节点存在且更小,则j更新为右子节点
        if (2*i + 2) < heap.size() and heap[2*i + 2] < heap[j]:
            j = 2*i + 2
        // 如果当前节点已经小于等于最小子节点,则停止
        if heap[i] <= heap[j]:
            break
        swap(heap[i], heap[j])   // 否则,与较小子节点交换
        i = j                    // 继续向下检查
    return min_value

堆排序算法 🚀

掌握了二叉堆后,我们可以用它来实现一个非常优雅的排序算法——堆排序。其思路非常简单直观:

  1. 建堆:将待排序的数组构建成一个最小堆。
  2. 依次提取:重复从堆中提取最小元素。由于每次提取的都是当前剩余元素中的最小值,按提取顺序排列就得到了有序序列。

一个朴素的实现需要额外一个数组来存放堆。但我们可以优化,实现“原地”堆排序,只使用原始数组的空间:

  1. 原地建堆:从数组的中间位置开始,向前遍历每个元素,并对每个元素执行“下沉”操作。这样可以线性时间复杂度(O(n))地将整个数组调整成堆结构。
  2. 排序:此时数组第一个元素(a[0])是最小值。我们将它与数组的最后一个元素交换,这样最小值就放在了最终位置。然后,将堆的大小减1(忽略最后一个位置),并对新的根节点(刚才交换上来的元素)执行“下沉”操作以恢复堆性质。重复此过程,直到堆中只剩一个元素。

最终,数组就按升序排列好了。堆排序的时间复杂度是 O(n log n),并且是原地排序,空间复杂度为 O(1)。


总结 📚

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

  1. 数据结构的意义:为了高效操作数据而组织数据的方式。
  2. 二叉堆(最小堆):一种基于完全二叉树、能同时支持 O(log n) 时间插入和删除最小元素的数据结构。
  3. 堆的核心操作:上浮(用于插入后维护堆性质)和下沉(用于删除根节点后维护堆性质)。
  4. 堆排序:利用二叉堆实现的、时间复杂度为 O(n log n) 的高效原地排序算法。

二叉堆是优先队列的一种简单高效实现,在调度算法、图算法(如Dijkstra最短路径)等领域有广泛应用。理解它有助于你掌握更多高级数据结构和算法。

003:快速排序与顺序统计量

在本节课中,我们将要学习一种新的排序算法——快速排序。它与我们之前学过的算法有所不同,因为它是一种随机化算法。首先,我们将探讨什么是随机化算法以及如何衡量其性能。然后,我们会详细学习快速排序的原理和实现。最后,我们还会了解一个与排序紧密相关的问题:如何在未排序的数组中快速找到第K小的元素(顺序统计量问题)。

什么是随机化算法?

在上一节我们介绍了算法的一般概念,本节中我们来看看随机化算法有何特殊之处。

通常,一个算法有输入数据和输出数据。例如,排序算法的输入是一个数组,输出是排序后的同一个数组。

随机化算法则多了一个输入源:随机数生成器。

这个额外的随机输入有时能帮助我们更快地解决问题。我们将要学习的第一个随机化算法就是快速排序。

快速排序算法原理

快速排序算法的核心思想是“分而治之”。给定一个数组,我们想将其按升序排列。

以下是算法的基本步骤:

  1. 随机选取一个基准元素:从数组中随机选择一个元素,记作 x
  2. 分割数组:将数组重新排列,使得所有小于 x 的元素都在其左侧,所有大于等于 x 的元素都在其右侧。这个过程可以在线性时间内完成。
  3. 递归排序:对左侧子数组和右侧子数组分别递归地调用快速排序。
  4. 基准情况:当子数组的大小为0或1时,它自然就是有序的,递归终止。

代码实现

让我们用代码来描述这个过程。我们定义一个递归函数 sort(l, r),用于排序数组 A 中索引从 lr-1 的部分。

def quick_sort(A, l, r):
    # 如果区间长度 <= 1,则已经有序
    if r - l <= 1:
        return
    # 1. 随机选取基准值 x
    pivot_index = random.randint(l, r-1)
    x = A[pivot_index]
    # 2. 分割数组
    m = l  # m 指向“小于x”区域的末尾
    for i in range(l, r):
        if A[i] < x:
            # 将小于x的元素交换到“小于x”区域
            A[i], A[m] = A[m], A[i]
            m += 1
    # 循环结束后,A[l:m] 是小于x的元素,A[m:r] 是大于等于x的元素
    # 3. 递归排序两个子数组
    quick_sort(A, l, m)
    quick_sort(A, m, r)

算法的问题与改进

上述实现存在一个问题:当数组中存在大量与基准值 x 相等的元素时,分割会非常不均衡,甚至可能导致无限递归(例如,如果总是选到最小元素,右侧子数组大小几乎不变)。

为了解决这个问题,一个更好的方法是将数组分割成三部分

  • 小于 x 的元素
  • 等于 x 的元素
  • 大于 x 的元素

然后,我们只需要递归排序小于和大于 x 的两个部分,等于 x 的部分已经在其正确位置。这被称为“三路划分”的快速排序。

快速排序的时间复杂度分析

对于确定性算法,我们通常分析其最坏情况时间复杂度。快速排序的最坏情况发生在每次选取的基准值都是当前子数组的最小或最大值时,导致分割极度不均衡,时间复杂度为 O(n²)

然而,由于我们随机选取基准值,这种最坏情况发生的概率极低。对于随机化算法,我们通常分析其期望时间复杂度

可以证明,快速排序的期望时间复杂度为 O(n log n)。直观理解是,每次随机选取基准值,有很大概率(例如约1/3)能将数组分割得比较均衡(每个子数组大小不超过原数组的2/3)。虽然有时分割会很差,但差分割的概率小,在数学期望上,递归的层数仍然是对数级别,每层的工作量是 O(n),因此总期望时间是 O(n log n)。

在实践中,为了减小常数因子,可以采用一些策略,例如随机选取三个元素,并用它们的中位数作为基准值,这样选到“好”基准值的概率会更高。

顺序统计量问题

现在,我们转向一个与排序相关但可以更快解决的问题:顺序统计量。即,给定一个数组和一个整数 k,找出数组中第 k 小的元素(假设数组索引从0开始)。

最直接的方法是先排序整个数组,然后取第 k 个元素,时间复杂度为 O(n log n)。但我们可以做得更好。

基于快速选择的随机化算法

我们可以借鉴快速排序的分割思想,但只关注包含目标元素的那一部分。这个算法称为 快速选择

以下是算法的步骤:

  1. 随机选取一个基准元素 x
  2. 将数组分割为小于 x 和大于等于 x 的两部分。
  3. 判断第 k 小的元素在哪一部分:
    • 如果 k 小于左侧子数组的长度,则目标在左侧,我们只在左侧递归查找。
    • 否则,目标在右侧,我们更新 k 的值(减去左侧子数组的长度),然后在右侧递归查找。
  4. 当子数组只剩一个元素时,该元素即为所求。

代码实现

def quick_select(A, l, r, k):
    # 在 A[l:r] 中寻找第k小的元素 (0-indexed)
    if r - l == 1:
        return A[l]
    # 随机选取基准值并分割
    pivot_index = random.randint(l, r-1)
    x = A[pivot_index]
    m = l
    for i in range(l, r):
        if A[i] < x:
            A[i], A[m] = A[m], A[i]
            m += 1
    # 判断k所在区间
    if k < m - l:
        # 目标在左侧部分
        return quick_select(A, l, m, k)
    else:
        # 目标在右侧部分,更新k
        return quick_select(A, m, r, k - (m - l))

算法复杂度分析

快速选择与快速排序的关键区别在于,它每次递归只进入一个分支,而不是两个。

  • 每次分割的成本是 O(n)。
  • 每次递归,数组的期望大小会以常数因子(例如 2/3)减少。
  • 因此,总的时间成本是一个等比数列的和:n + (2/3)n + (4/9)n + ... = O(n)。

所以,快速选择的期望时间复杂度是 O(n),优于先排序的 O(n log n)。

确定性的线性时间选择算法

虽然随机化算法在实践中很好,但理论上存在完全确定性的算法也能在 O(n) 时间内解决顺序统计量问题。这就是著名的 BFPRT 算法(以五位发明者 Blum, Floyd, Pratt, Rivest, Tarjan 的名字命名),也称为“中位数的中位数”算法。

其核心思想是:不再随机选择基准值,而是用一种确定性的方法选择一个“足够好”的基准值,以保证分割的均衡性。

算法步骤如下:

  1. 将数组划分为每组5个元素的小组(最后一组可能不足5个)。
  2. 找出每个小组的中位数(对5个元素排序后取中间值)。
  3. 递归地调用选择算法,找出这些中位数组成的数组的中位数 x。这个 x 就是选定的基准值。
  4. 用这个基准值 x 对原数组进行分割。
  5. 像快速选择一样,判断目标元素在哪一侧,并进入该侧递归查找。

可以证明,通过这种方法选出的基准值 x,能保证分割后的任意一侧子数组的大小不超过原数组的 7/10。通过递归式分析,可以得出该算法的总时间复杂度为 O(n)

总结

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

  1. 随机化算法的概念,它通过引入随机性来获得更好的平均性能。
  2. 快速排序算法,一种高效且广泛使用的随机化排序算法,其期望时间复杂度为 O(n log n)
  3. 快速排序的潜在问题及改进方法(如三路划分)。
  4. 顺序统计量问题,即寻找第K小的元素。
  5. 快速选择算法,一个基于快速排序思想的随机化算法,能在 O(n) 的期望时间内解决问题。
  6. 确定性的 BFPRT 算法,同样能在 O(n) 的最坏情况下解决顺序统计量问题,展示了如何用精妙的确定性策略替代随机性。

通过对比随机化和确定性算法,我们看到了算法设计中权衡与创新的魅力。快速排序及其变体是理解分治法和随机化算法思想的经典范例。

004:排序下界、基数排序与排序网络

在本节课中,我们将要学习排序算法的理论下界,以及如何通过基数排序在线性时间内对特定类型的数据进行排序。最后,我们还将探讨一种特殊的并行计算模型——排序网络。

排序算法的下界

上一节我们介绍了归并排序、堆排序和快速排序。虽然这些算法使用了不同的思想和技术,但它们的时间复杂度都是 O(n log n)

一个自然的问题是:是否存在比 O(n log n) 更快的排序算法?一个有趣的事实是,对于基于比较的排序算法,这是不可能的。让我们来探讨其原因。

为了证明这一点,我们需要限定算法能对数组元素进行的操作。在创建这些排序算法时,我们唯一需要的操作是比较两个元素。例如,在归并排序中,我们比较两个数组的首元素以获取最小值;在堆排序中,我们比较元素与其父节点来决定是否交换。

因此,如果我们规定算法只能通过比较两个元素来获取信息,那么任何此类排序算法在最坏情况下都需要至少 Ω(n log n) 次比较。

决策树模型证明

我们可以通过决策树模型来理解这个下界。假设我们有三个元素 X, Y, Z。排序算法通过一系列比较来决定最终的排列顺序。这个过程可以表示为一棵二叉树:

  • 每个内部节点代表一次比较(例如,比较 X 和 Y)。
  • 每个分支代表比较的结果(例如,X < Y 或 X > Y)。
  • 每个叶子节点代表一种可能的排序结果(即元素的一种排列)。

对于 n 个元素,共有 n! 种可能的排列,因此决策树至少有 n! 个叶子节点。一棵高度为 h 的二叉树最多有 2^h 个叶子节点。因此,要容纳 n! 个叶子,树的高度 h 必须满足:
2^h ≥ n!
对两边取对数,得到:
h ≥ log₂(n!)

根据斯特林公式近似,log₂(n!) 的增长速度是 Ω(n log n)。因此,任何基于比较的排序算法在最坏情况下都需要至少 Ω(n log n) 次比较。决策树的高度对应了算法所需的时间复杂度,这就证明了基于比较的排序算法的时间下界是 Ω(n log n)

计数排序:突破下界的特例

上述下界证明基于“只能比较元素”的假设。如果我们能对元素进行更丰富的操作,有时就能实现更快的排序。计数排序就是这样一个特例,它适用于元素是较小整数的情况。

假设我们有一个数组 a,其中的元素都是范围在 0m-1 之间的整数,且 m 是一个较小的数。

以下是计数排序的步骤:

  1. 统计频次:创建一个大小为 m 的计数数组 c,用于统计每个值出现的次数。我们只需遍历原数组一次,对每个元素 a[i],执行 c[a[i]]++
  2. 生成排序数组:根据计数数组 c,我们可以直接构造出排序后的数组。例如,如果 c[0]=3,就在结果数组的前三个位置放入 0;接着如果 c[1]=4,就在接下来的四个位置放入 1,依此类推。

计数排序的时间复杂度是 O(n + m)。当 mn 处于同一数量级时,复杂度就是线性的 O(n),这比基于比较的排序更快。

处理带附属数据的对象

在实际应用中,我们通常排序的是对象,而整数只是对象的键(key)。我们需要保持对象本身的完整。以下是改进的计数排序步骤:

  1. 统计每个键值的出现次数,得到计数数组 c
  2. 将计数数组 c 转换为前缀和数组,这样 c[k] 就表示键值小于 k 的元素个数,也即键值为 k 的元素在结果数组中的起始位置。
  3. 再次遍历原数组,根据每个对象的键值 k 和当前 c[k] 指向的位置,将对象放入结果数组的对应位置,然后 c[k]++

这种方法同样是 O(n + m) 的时间复杂度,并且是稳定排序(即键值相同的对象,其相对顺序保持不变)。

基数排序:扩展计数排序的适用范围

计数排序要求整数范围 m 不能太大。如果我们要排序的整数范围很大(例如 0m² - 1),该怎么办?基数排序通过将整数视为多个“位”的组合来解决这个问题。

核心思想是:将一个整数 x 表示为 x = y * M + z,其中 yz 都在 0M-1 范围内。这相当于在 M 进制下,将 x 拆分成高位 y 和低位 z 两个数字。

排序时,我们不再直接比较整个数字,而是先根据低位 z 进行排序,再根据高位 y 进行排序。关键是,第二次排序(按高位)必须使用稳定排序算法(如稳定的计数排序)。这样,在整体排序后,对于高位相同的元素,它们会按照低位的顺序排列,从而得到完全有序的序列。

对于范围更大的整数,我们可以将其拆分成 k 个数字(例如,按十进制或二进制位拆分)。然后,我们从最低位到最高位,依次进行稳定的计数排序。这个过程称为基数排序

基数排序的时间复杂度是 O(k * (n + M)),其中 k 是数字的位数,M 是每一位的取值范围(例如十进制就是10)。当 k 为常数且 Mn 相当时,复杂度是线性的 O(n)

排序网络:一种并行计算模型

最后,我们介绍一种不同的计算模型——排序网络。在这个模型中,我们只能进行一种操作:比较-交换。即,给定两个索引 ij,如果 a[i] > a[j],则交换它们。

排序网络的目标是设计一个固定的、由比较器组成的网络结构,无论输入序列是什么,都能在输出端得到有序序列。我们关心两个指标:

  1. 比较器的总数:代表算法的总工作量。
  2. 网络的深度:代表在允许并行比较(互不依赖的比较可同时进行)的情况下,所需的“时间”步数。

双调排序网络

一种著名的排序网络是双调排序网络。它的核心是能够高效地排序一种特殊的序列——双调序列

一个序列是双调的,如果它先单调递增(或不变)再单调递减(或不变),或者能通过循环移位变成这种形式。例如序列 [3, 5, 7, 8, 6, 4, 2, 1]

双调排序网络利用了一个关键引理:如果一个排序网络能正确排序所有由0和1组成的任意序列,那么它就能排序任意序列。证明思路是,对于任意输入序列中的任意元素,可以构造一个0/1序列来“追踪”该元素在网络中的路径,从而证明其最终位置正确。

基于此,双调排序网络的设计专注于排序0/1双调序列。其算法采用分治策略:

  1. 对于一个长度为 n 的双调序列,将其对半分成两个子序列 AB
  2. A 中的每个元素 A[i]B 中对应位置的元素 B[i] 进行比较-交换,使得 A 中的所有元素都不大于 B 中的对应元素。
  3. 可以证明,经过这一步后,AB 都变成了(更小的)双调序列,并且 A 中的所有元素都不大于 B 中的任何元素。
  4. 递归地对 AB 进行双调排序。

对于一个长度为 n 的序列,双调排序网络总共需要 O(n log² n) 个比较器,其深度为 O(log² n)。虽然这不是理论上最优的(最优深度为 O(log n)),但其结构规整,易于理解和并行实现。

总结

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

  1. 排序的理论下界:基于比较的排序算法时间复杂度下界为 Ω(n log n),这通过决策树模型得到证明。
  2. 计数排序:对于元素为小整数的特殊情况,通过统计频次可以在 O(n + m) 时间内完成排序,突破了比较排序的下界。
  3. 基数排序:通过从低位到高位进行多次稳定的计数排序,可以对更大范围的整数进行线性时间排序。
  4. 排序网络:介绍了一种并行计算模型,以及双调排序网络的基本原理,它通过递归处理双调序列来构建排序网络。

这些知识揭示了算法效率的理论极限,也展示了在特定条件下突破极限的可能性,并为我们理解并行算法提供了基础。

005:二分查找 🔍

在本节课中,我们将要学习一种基础且强大的算法——二分查找。我们将从最简单的在有序数组中查找元素开始,逐步深入到更复杂和通用的应用场景,例如查找边界值、解决“好/坏”判定问题,以及处理浮点数搜索。最后,我们还会简要介绍三分查找及其优化技巧。

什么是二分查找?🤔

二分查找是一种用于在有序列表中查找目标元素的基础技术。它的核心思想是通过不断将搜索范围对半分割,从而快速缩小查找空间。

在有序数组中查找元素 📊

我们首先解决一个最常见的问题:在一个已排序的整数数组中,查找一个特定的值 x

假设我们有一个排序数组 a,例如 [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25]。我们的任务是找到数组中等于 x 的元素。

基本思路

基本思路是维护一个可能包含目标元素 x 的数组段。我们使用两个指针 L(左)和 R(右)来标记这个段的边界,并始终保持一个性质:元素 x 一定在 LR 之间的段内。

初始时,我们可以设置 L = 0R = n - 1n 是数组长度)。然后,我们通过以下步骤不断缩小这个段的范围:

  1. 计算中间位置 m = (L + R) / 2
  2. 比较中间元素 a[m] 与目标值 x
    • 如果 a[m] < x,由于数组有序,x 不可能在 m 及其左侧。因此,我们将 L 更新为 m + 1
    • 如果 a[m] > x,由于数组有序,x 不可能在 m 及其右侧。因此,我们将 R 更新为 m - 1
    • 如果 a[m] == x,那么我们找到了目标,直接返回 m

我们重复这个过程,直到 L > R(段为空)或找到目标。

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

L = 0
R = n - 1
while L <= R:
    m = (L + R) // 2
    if a[m] < x:
        L = m + 1
    elif a[m] > x:
        R = m - 1
    else:
        return m
return -1  // 未找到

时间复杂度分析

每次迭代,搜索范围的大小至少减半。因此,对于一个长度为 n 的数组,最多需要 O(log n) 次比较。这使得二分查找比线性查找(O(n))高效得多。

然而,上述实现方式在处理一些边界情况(如查找第一个/最后一个等于 x 的元素)时可能不够清晰。接下来,我们将学习一种更通用、更清晰的实现方法。

更通用的二分查找实现 🛠️

上一节我们介绍了基础的二分查找。本节中,我们来看看如何用一种更清晰、更通用的方式来实现它,以解决更复杂的问题,例如“查找第一个大于等于 x 的元素”。

问题定义

给定一个有序数组 a 和一个值 x,我们希望找到数组中第一个(最左边的)大于或等于 x 的元素。如果所有元素都小于 x,则返回一个特殊值(例如数组长度 n)。

新的思路:维护不变性

我们依然使用两个指针 LR,但这次我们维护一个不同的不变性(Invariant):

  • a[L] < xL 指向的元素严格小于 x
  • a[R] >= xR 指向的元素大于或等于 x

我们的目标是让 LR 最终成为相邻的位置(R = L + 1)。此时,根据不变性,a[L] < xa[R] >= x,那么 R 就是我们要找的第一个大于等于 x 的元素的位置。

初始化与虚拟元素

为了确保初始时不变性成立,我们需要小心地选择 LR 的初始值。一个巧妙的技巧是引入两个虚拟元素

  • 在数组“前面”添加一个值为 -∞ 的元素,其索引为 -1
  • 在数组“后面”添加一个值为 +∞ 的元素,其索引为 n

这样,我们可以安全地初始化:

  • L = -1 (指向虚拟的 -∞,保证 a[L] < x
  • R = n (指向虚拟的 +∞,保证 a[R] >= x

在实际代码中,我们并不真的创建这些元素,只是想象它们存在。

算法步骤

算法循环的条件是 RL 不相邻(即 R > L + 1)。在每次循环中:

  1. 计算中间位置 m = (L + R) // 2。注意,由于 LR 是虚拟索引,m 始终是有效的数组索引(0 <= m < n)。
  2. 检查 a[m]
    • 如果 a[m] >= x,那么 m 位置满足 R 指针的性质。我们将 R 移动到 m
    • 如果 a[m] < x,那么 m 位置满足 L 指针的性质。我们将 L 移动到 m

循环结束后,R 就是答案。如果 R == n,说明所有元素都小于 x

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

L = -1
R = n
while R > L + 1:
    m = (L + R) // 2
    if a[m] >= x:
        R = m
    else:
        L = m
return R  // R 是第一个 >= x 的元素索引,若 R==n 则表示未找到

变体:查找最后一个小于等于 x 的元素

只需稍作修改,我们就可以解决对称的问题:查找最后一个(最右边的)小于或等于 x 的元素。

此时,我们维护的不变性变为:

  • a[L] <= xL 指向的元素小于或等于 x
  • a[R] > xR 指向的元素严格大于 x

初始化同样为 L = -1, R = n。循环中的判断逻辑改为:

  • 如果 a[m] > x,则 R = m
  • 否则(a[m] <= x),则 L = m

循环结束后,L 就是答案。如果 L == -1,说明所有元素都大于 x

这种“维护不变性”的二分查找框架非常清晰,只需根据具体问题定义好 LR 指针所代表的性质,以及中间点 m 的判断逻辑即可。

二分查找的典型应用:判定问题 ✅❌

上一节我们学习了通用的二分查找框架。本节中,我们来看看二分查找最经典的一类应用场景:解决具有单调性的判定问题。

问题模型

我们面对的问题通常具有以下形式:
存在一个定义在整数(或实数)上的判定函数 good(x),它对于输入值 x 返回 true(好)或 false(坏)。
并且,这个函数具有单调性:如果 x 是好的,那么所有比 x 大的值也是好的(或者反过来,如果 x 是坏的,那么所有比 x 小的值也是坏的)。

我们的任务是找到满足 good(x)true最小(或最大)的 x

图形化理解

我们可以将数轴上的点分为“好”和“坏”两部分。由于单调性,数轴上存在一个分界点:该点左侧全是坏点,右侧全是好点(或者相反)。我们的目标就是找到这个分界点。

坏点 | 坏点 | ... | 坏点 | 分界点 | 好点 | 好点 | ... | 好点

这正好与我们之前“查找第一个好点”的二分查找框架吻合!

实例:最小正方形覆盖

问题:我们有 n 个尺寸为 w_i × h_i 的矩形。我们需要找到一个最小的正方形(边长为整数 x),使得所有矩形都能不重叠地放入这个正方形中(假设可以任意旋转或排列,但这里简化为例题)。

分析

  1. 定义判定函数good(x) = 边长为 x 的正方形能否放下所有 n 个矩形。
  2. 验证单调性:如果一个边长为 x 的正方形能放下所有矩形(good(x) = true),那么边长为 x+1, x+2, ... 的更大正方形也一定能放下。因此,函数 good(x) 是单调的(从某个点开始由 false 变为 true)。
  3. 应用二分查找:我们需要找到最小的 x 使得 good(x)true。这正是“查找第一个好点”的问题。
    • 初始化:L 指向一个已知的坏点(例如 L=0,边长为0的正方形肯定放不下任何东西)。R 指向一个已知的好点。如何找到一个初始的好点?可以采用“指数搜索”:从 R=1 开始,只要 good(R)false,就将 R 乘以2(例如 R=1,2,4,8,...),直到找到一个好点。这样可以避免初始 R 过大导致计算溢出。
    • 然后使用标准的二分查找框架缩小 [L, R] 范围,直到找到分界点 R

实现 good(x) 函数:这是问题的核心。一个简单的策略是计算在 x×x 的正方形中,按网格排列最多能放下多少个给定矩形。如果数量 >= n,则 good(x)=true。更精确的判定可能需要复杂的几何包装算法,但思路不变。

实例:人员集合的最短时间

问题n 个人站在一条直线上,第 i 个人在位置 p_i,最大移动速度为 v_i。他们想移动到同一点集合。求所有人集合所需的最短时间 T

分析

  1. 定义判定函数good(t) = 是否能在 t 秒内让所有人移动到同一点。
  2. 验证单调性:如果时间 t 足够(good(t)=true),那么给更多时间 t’ > t,他们肯定也能集合(可以先花 t 秒移动到目标点,然后原地等待)。函数单调。
  3. 应用二分查找:找到最小的 t 使得 good(t)=true
  4. 实现 good(t) 函数:对于给定的时间 t,第 i 个人可以移动到的位置范围是 [p_i - v_i*t, p_i + v_i*t]。所有人要集合到同一点,意味着存在一个点同时位于所有人的可达区间内。这等价于所有区间的交集非空。计算所有区间左端点的最大值 L_max 和右端点的最小值 R_min。如果 L_max <= R_min,则交集非空,good(t)=true;否则为 false

通过这两个例子可以看出,二分查找将优化问题(求最小/最大值)转化为了判定问题(判断某个值是否可行)。我们只需要专注于实现高效的 good(x) 函数,剩下的搜索工作就交给二分查找的通用框架。

浮点数二分与三分查找 📐

上一节我们讨论了基于判定问题的整数二分查找。本节中,我们来看看当搜索空间是连续值(浮点数)时该如何处理,并介绍一种相关的算法——三分查找。

浮点数二分查找

当答案 x 是实数时,二分查找的基本逻辑不变。我们仍然维护一个包含答案的区间 [L, R],并通过判断中间点 m 的性质来缩小区间。

关键变化与挑战

  1. 循环终止条件:我们不能再用 L <= RR > L + 1 作为整数比较。因为浮点数有精度限制,可能永远无法完全相等。
  2. 常用方法
    • 固定迭代次数:进行固定次数的二分迭代(例如100次)。每次迭代将区间长度减半,100次后区间长度变为初始的 1/2^100,这通常能达到极高的精度,且能避免无限循环和精度判断的麻烦。这是最安全、最常用的方法。
    • 设定精度阈值:当区间长度 R - L 小于某个预设的精度 epsilon(例如 1e-9)时退出循环。但需要注意,当答案本身很大时,相对精度可能仍然不足。

浮点数二分查找框架(固定迭代法)

L = 初始左边界
R = 初始右边界
for i in range(100):  # 例如迭代100次
    m = (L + R) / 2.0
    if good(m):  # 根据问题定义判断 m 是否“偏大”或“偏小”
        R = m    # 如果 m 是“好”的(或满足某种条件),答案在左半部分
    else:
        L = m    # 否则答案在右半部分
# 循环结束后,答案在 [L, R] 内,通常取 (L+R)/2 或 R 作为近似解

三分查找

三分查找用于在单峰函数(Unimodal Function)上寻找极值(最大值或最小值)。单峰函数指的是先严格单调递增(或递减),到达一个峰值(或谷值)后,再严格单调递减(或递增)的函数。

问题模型:有一个函数 f(x),在定义域 [L, R] 上是单峰的。我们想找到其极大值点(对于先增后减的函数)或极小值点(对于先减后增的函数)。我们只能查询函数在某点的值 f(x),而无法直接求导。

算法思路(求极大值)

  1. 在区间 [L, R] 内取两个点 m1m2,且 L < m1 < m2 < R
  2. 比较 f(m1)f(m2)
    • 如果 f(m1) < f(m2),那么极大值点不可能在 m1 左侧。因为如果极大值点在 m1 左边,由于函数先增后减,m1m2 应该是递减的,与 f(m1) < f(m2) 矛盾。因此,我们可以将搜索范围缩小到 [m1, R]
    • 如果 f(m1) > f(m2),同理,极大值点不可能在 m2 右侧。搜索范围缩小到 [L, m2]
    • 如果 f(m1) == f(m2),那么极大值点一定在 [m1, m2] 之间。搜索范围缩小到 [m1, m2]
  3. 重复上述步骤,直到区间足够小。

点的选取:为了每次迭代能均等地缩小区间,通常取 m1m2 为区间的三等分点:m1 = L + (R-L)/3, m2 = R - (R-L)/3

时间复杂度:每次迭代去掉原区间长度的 1/3,复杂度也是 O(log n),但常数比二分查找大。

优化技巧(黄金分割法):在三分查找中,每次需要计算两个点的函数值。通过精心选择 m1m2 的比例(例如使用黄金分割比 ≈ 0.618),可以使得在缩小区间后,其中一个点恰好是新区间的三等分点之一,从而在下次迭代中复用这个点的函数值,节省一次计算。这可以将效率提升近一倍。

总结 📝

本节课中我们一起学习了二分查找算法及其广泛应用。

  1. 核心思想:通过不断将有序搜索空间对半分割,以对数时间复杂度快速定位目标。
  2. 基础应用:在有序数组中查找特定元素。
  3. 通用框架:通过维护指针 LR 的某种“不变性”,可以清晰、统一地实现查找边界值(如第一个大于等于 x 的元素)的算法。
  4. 核心应用模式:将求最优解(最小化/最大化)问题转化为对候选解 x单调判定问题 good(x)。二分查找用于快速找到满足 good(x) 为真的边界点。
  5. 浮点数处理:采用固定迭代次数的二分法,安全且高效。
  6. 扩展算法:三分查找用于求解单峰函数的极值问题。

掌握二分查找的关键在于准确识别问题的单调性,并正确实现判定函数。这种“转化”思维是解决许多算法问题的有力工具。

006:栈、队列与摊还分析

在本节课中,我们将学习两种非常简单但应用广泛的数据结构:栈和队列。我们还将探讨如何分析这些数据结构中某些操作的平均时间复杂度,即摊还分析。

栈:什么是栈?📚

栈是一种非常简单的数据结构。你可以把它想象成一摞盘子。你只能从最上面放入或拿走盘子。

栈有两个基本操作:

  • 入栈:将一个新元素放到栈顶。
  • 出栈:从栈顶取出一个元素。

例如,我们依次入栈元素 A、B、C。此时栈顶是 C。当我们执行出栈操作时,会取出并移除元素 C。

栈的实现:使用数组 🛠️

如何用数组实现栈?假设我们有一个足够大的数组 a。我们用一个变量 n 来记录栈中当前有多少个元素。

以下是两个核心操作的伪代码:

入栈操作 push(x)

a[n] = x  # 将新元素 x 放入数组的 n 位置
n = n + 1 # 栈的大小增加 1

出栈操作 pop()

n = n - 1     # 栈的大小减少 1
return a[n]   # 返回原栈顶元素

栈的应用非常广泛。例如,计算机在执行递归函数时,就是使用栈来保存每一层函数的局部变量和返回地址。

队列:什么是队列?🚶‍♂️🚶‍♀️

队列是另一种简单的数据结构,就像现实生活中的排队。新来的人排在队尾,服务从队头开始。

队列也有两个基本操作:

  • 入队:在队列的尾部添加一个新元素。
  • 出队:从队列的头部移除一个元素。

例如,依次入队 A、B、C。当我们执行出队操作时,会移除并返回第一个元素 A。

队列的实现:使用数组 🛠️

我们同样使用一个数组 a 来实现队列。我们需要两个指针(或索引):

  • head:指向队列的第一个元素。
  • tail:指向队列尾部下一个空闲位置。

以下是操作的伪代码:

入队操作 enqueue(x)

a[tail] = x   # 将新元素 x 放入 tail 位置
tail = tail + 1 # 尾指针后移

出队操作 dequeue()

x = a[head]   # 获取队头元素
head = head + 1 # 头指针后移
return x

随着入队和出队操作的进行,队列会在数组中向右“移动”。当 tail 到达数组末尾时,我们可以让它回到数组开头,形成一个循环数组,以重复利用空间。

动态数组与摊还分析 ⚡

上一节我们假设数组是无限大的。现实中,数组大小是固定的。对于栈,如果我们不知道最大需要多少空间,就需要一个能动态增长的数组。

一个简单的策略是:当数组已满时,创建一个更大的新数组(例如,原大小的两倍),然后将所有旧元素复制过去,再添加新元素。

如果每次数组满时只增加一个空间,那么每次 push 都可能需要复制所有元素,导致单次操作的时间复杂度为 O(n),这很糟糕。

通过将数组大小翻倍,我们可以证明,虽然某些 push 操作很慢(需要复制),但平均下来,每个 push 操作的摊还时间复杂度是常数 O(1)。

什么是摊还分析?

摊还分析用于分析一系列操作的平均性能,即使其中某些单次操作代价很高。它保证了在任意长的操作序列中,总时间开销与操作次数成线性关系。

以下是三种常见的摊还分析方法:

  1. 聚合分析:直接计算 n 次操作的总时间 T(n),然后证明平均每次操作的时间 T(n)/n 是一个常数。

    • 对于翻倍策略的动态数组,n 次 push 的总复制次数小于 2n,加上 n 次简单插入,总时间小于 3n。因此摊还成本为常数 3。
  2. 核算法(会计方法):为每个操作分配一个“虚拟”的摊还成本。低成本操作的“余额”被储存起来,用于支付未来高成本操作的开销。

    • 对于动态数组的 push,我们为每次 push 分配 3 单位的摊还成本。其中 1 单位用于支付本次的简单插入,另外 2 单位作为“存款”留在刚插入的元素上。当需要扩展数组时,所有在旧数组中“存款”的元素(即后半部分元素)正好可以提供足够的“存款”来支付复制它们自己的成本。
  3. 势能法:为数据结构的整个状态定义一个“势能”函数 Φ。操作的摊还成本定义为:实际成本 + ΔΦ(操作后势能的变化)

    • 对于动态数组,可以定义势能 Φ = 2 * (数组容量 - 栈顶指针)。当数组未满时,势能较高;当数组满需要扩容时,势能会大幅下降,这个下降值正好抵消了复制的实际成本,使得摊还成本保持为常数。

双端队列与栈的扩展 🎪

双端队列是一种更通用的结构,允许在头部和尾部进行添加和删除操作。它结合了栈和队列的功能。

一个有趣的挑战是:如何用两个栈实现一个队列?

思路如下:

  • 我们有两个栈:stack1stack2
  • 入队:直接将新元素压入 stack2
  • 出队
    1. 如果 stack1 不为空,直接从 stack1 弹出元素。
    2. 如果 stack1 为空,则将 stack2 中的所有元素依次弹出并压入 stack1。这样,最早进入 stack2 的元素就到了 stack1 的栈顶。然后从 stack1 弹出元素。

虽然将元素从 stack2 转移到 stack1 的操作是 O(n) 的,但每个元素只会被转移一次。使用摊还分析(例如核算法:每次入队时在元素上存一个“硬币”,用于支付未来它被转移和出队的成本),可以证明入队和出队的摊还时间复杂度都是 O(1)

总结 📝

本节课我们一起学习了两种基础数据结构——栈和队列,了解了它们的定义、数组实现以及循环数组的技巧。更重要的是,我们引入了摊还分析的概念,学习了聚合分析、核算法和势能法这三种分析方法,并用它们证明了动态数组扩容和“双栈实现队列”等操作的平均高效性。掌握这些分析工具对于理解和设计高效的数据结构至关重要。

007:链表与指针机模型 🧠

在本节课中,我们将学习一种新的计算模型——指针机模型,并在此模型下探讨链表这一基础数据结构。我们将了解指针机模型与RAM模型的区别,学习如何实现单链表和双链表,并探索如何实现栈、队列以及持久化数据结构。


概述:指针机模型 🧩

在之前的课程中,我们几乎都使用RAM模型。在RAM模型中,数据存储在一个大数组中,我们可以通过索引访问任何元素。

本节我们将切换到另一种计算模型,称为指针机模型

指针机模型是一个非常简单的计算模型。在指针机模型中,所有数据都存储在一些节点上。每个节点拥有恒定数量的字段,每个字段可以存储数据,也可以是指向另一个节点的指针

这类似于大多数编程语言中的对象或类。例如,在Java中,一个对象可以有多个字段,其中一些字段存储数据(如整数、字符串),另一些字段则是指向其他对象的引用。

在指针机模型中,你可以执行以下基本操作:

  • 创建新节点。
  • 删除节点。
  • 更改节点字段的值(数据或指针)。

指针机模型与RAM模型的主要区别在于:指针机模型中没有数组。你无法通过索引直接访问任意元素,只能通过节点间的指针来访问数据。

你可能会问,既然RAM模型更强大,为什么还要使用指针机模型?原因在于,更简单的模型有时更容易分析和保证某些性质。例如,在指针机模型中,要修改一个节点,你必须持有指向该节点的指针。这使得追踪数据访问和修改路径变得更容易,这对于实现垃圾回收并发数据结构非常重要。我们稍后会再讨论这一点。


链表:指针机模型下的列表 📝

既然指针机模型中没有数组,我们如何维护一个元素列表呢?答案就是链表

链表由一系列节点组成,每个节点包含:

  1. 存储的数据
  2. 一个指向列表中下一个节点的指针。

此外,我们还需要一个指向列表第一个节点的指针(通常称为 headfirst)。

遍历链表

在数组中,我们使用索引循环来遍历所有元素:

for i in range(0, n):
    print(array[i])

在链表中,我们没有索引。遍历的方法是:从第一个节点开始,沿着 next 指针依次访问每个节点,直到 next 指针为空(null)。

current = first
while current is not None:
    print(current.data)
    current = current.next

链表的优势与劣势

与数组相比,链表有其优缺点:

  • 劣势:无法通过索引在常数时间内访问任意元素。要访问第 i 个元素,必须从头部开始,沿着指针移动 i 次,最坏情况需要 O(n) 时间。
  • 优势:可以在常数时间内插入新元素到已知位置(前提是持有指向该位置节点的指针)。

例如,要在节点 X 之后插入新节点 Z

  1. 记录 X 原来的下一个节点 Y = X.next
  2. X.next 指向新节点 Z
  3. Z.next 指向 Y
    这个过程只涉及修改几个指针,是常数时间操作。

删除元素与双链表

删除链表中的节点 X 则稍微复杂一些。你需要将 X 的前一个节点的 next 指针,指向 X 的下一个节点。问题在于,在单链表中,要找到 X 的前一个节点,可能需要从头遍历。

为了解决这个问题,我们引入双链表。双链表的每个节点包含两个指针:一个指向下一个节点 (next),一个指向前一个节点 (prev)。

在双链表中,删除一个已知节点 X 就变得简单了(假设 X 不是头尾的特殊节点):

  1. Y = X.prev, Z = X.next
  2. Y.next 指向 Z
  3. Z.prev 指向 Y
    这样,我们就不再需要从头遍历来寻找前驱节点了。

简化边界条件:哨兵节点

在实现插入和删除时,需要处理许多边界情况(例如,在头部插入、删除最后一个元素等),导致代码中出现大量 if 语句。一个常见的技巧是使用哨兵节点

我们可以在链表的两端各添加一个额外的、不存储实际数据的节点。这样,所有实际数据节点都位于这两个哨兵节点之间。在操作时,我们永远不需要删除哨兵节点,因此很多边界检查(如指针是否为 null)就自然消失了,代码会简洁很多。

另一种更极致的简化是使用循环链表,即只用一个额外的节点,让它的 next 指向第一个数据节点,prev 指向最后一个数据节点,而最后一个数据节点的 next 又指回这个哨兵节点,形成一个环。这样,整个链表就没有真正的“端点”概念了。


栈与队列的实现 🥞🚶

上一节我们介绍了链表的基本操作。本节我们来看看如何用链表实现栈和队列这两种重要的数据结构。

栈的实现

栈是后进先出(LIFO)的数据结构,主要操作是 push(入栈)和 pop(出栈)。

用链表实现栈非常简单。我们只需要维护一个指向栈顶节点的指针(top)。栈顶节点就是链表的第一个节点。

  • push(x):创建一个新节点 x,将其 next 指针指向当前的 top 节点,然后将 top 指针更新为 x
    x.next = top
    top = x
    
  • pop():记录当前 top 节点存储的值,然后将 top 指针移动到 top.next,并返回记录的值。
    value = top.data
    top = top.next
    return value
    

可以看到,栈的实现只需要单链表,且指针方向是从栈顶指向栈底(即从新元素指向旧元素)。

队列的实现

队列是先进先出(FIFO)的数据结构,主要操作是 enqueue(入队)和 dequeue(出队)。

用链表实现队列需要维护两个指针:head(指向队首)和 tail(指向队尾)。链表指针的方向是从队首指向队尾。

  • enqueue(x):创建一个新节点 x
    • 如果队列为空,则 headtail 都指向 x
    • 否则,将当前 tail 节点的 next 指向 x,然后将 tail 指针更新为 x
  • dequeue():从 head 指针获取队首节点的值。
    • 如果队列只有一个元素(即 head == tail),出队后队列为空,将 headtail 都设为 null
    • 否则,将 head 指针移动到 head.next
    • 返回获取的值。

持久化数据结构 💾

持久化数据结构是指能够保留其所有历史版本的数据结构。你可以访问和查询过去的任何一个版本,而不仅仅是当前版本。这在某些算法(如版本控制系统、可持久化线段树)中非常有用。

一个有趣的理论结果是:几乎所有在指针机模型下实现的数据结构,都可以被改造成持久化版本,且不增加渐进时间复杂度

持久化栈

让我们以栈为例。假设我们有一个初始栈(版本1)。当我们执行 push 操作时,我们创建新节点,并移动 top 指针,得到版本2。

为了实现持久化,我们不覆盖旧的 top 指针,而是为每个版本保留其自己的 top 指针。这样,版本1的 top 指向旧的栈顶,版本2的 top 指向新的栈顶。通过跟随不同版本的 top 指针,我们就可以访问不同版本的栈。

如果从版本2再执行 pop 操作,我们就创建版本3,其 top 指针指向版本2栈顶的下一个元素(即版本1的栈顶)。如此反复,所有版本通过指针连接成一个“版本树”。

持久化的挑战与部分持久化

然而,对于像队列这样更复杂的数据结构,实现完全持久化(允许在任何历史版本上进行修改)会面临挑战。主要问题在于,一个节点可能被多个后续版本引用,当需要在某个旧版本的分支上修改该节点时,我们不能直接修改它(否则会影响其他分支),而必须创建该节点的一个新副本。这可能导致连锁的复制操作。

一种常见的简化是部分持久化数据结构。它要求版本是线性的(即只能从最新版本创建新版本,不能回溯到旧版本并分叉)。在这种限制下,我们可以使用一些摊销时间复杂度为常数的数据结构(如用两个栈实现的队列),因为线性版本顺序保证了摊销分析中的“债务”不会在旧版本上被重复索取。

部分持久化链表的实现思路

对于部分持久化链表,一个经典的实现方法是允许每个节点最多存储两个版本的数据(新旧各一)。每个节点记录一个版本号 v,表示从该版本开始使用新数据。

当需要在最新版本中插入一个节点时,我们可能会遇到目标位置两侧的节点都已满(即已存储两个版本)的情况。此时,我们需要向后(或向前)寻找第一个未满的节点,并复制沿途的所有节点到新版本。这个过程在最坏情况下是 O(n) 的。

但是,通过巧妙的势能分析法,可以证明其摊销时间复杂度是常数。我们定义势能为“已满节点”的数量。一次耗时的复制操作会减少大量“已满节点”,从而为未来的操作“预付”了成本。由于版本是线性的,这种摊销分析是有效的。

为什么限制每个节点只有两个版本?这是为了在遍历时,能在常数时间内决定使用哪个版本的数据。如果允许更多版本,就需要更复杂的数据结构(如平衡二叉搜索树)来查询对应版本,这会引入对数因子,增加时间复杂度。


总结 🎯

本节课我们一起学习了以下内容:

  1. 指针机模型:一种基于节点和指针的计算模型,与RAM模型的关键区别在于没有数组索引访问。
  2. 链表:指针机模型下实现动态列表的基础数据结构,包括单链表和双链表,支持高效的插入/删除,但随机访问效率低。
  3. 栈和队列的实现:利用链表可以简洁地实现栈(后进先出)和队列(先进先出)的核心操作。
  4. 持久化数据结构:能够保留所有历史版本的数据结构。我们探讨了持久化栈的实现,并了解了实现完全持久化的挑战以及部分持久化的解决方案和其摊销分析思想。

指针机模型和链表是理解更高级数据结构(如树、图)的基础,而持久化的概念则在函数式编程和特定算法领域具有重要意义。

008:并查集 📚

在本节课中,我们将要学习一种非常重要的数据结构——并查集。它有时也被称为“Union-Find”结构。虽然它的概念和实现看起来很简单,但在许多算法中,尤其是在后续的图算法中,它将扮演至关重要的角色。

什么是并查集?🤔

并查集是一种用于维护一系列互不相交的集合的数据结构。简单来说,我们有一组对象,这些对象被划分成若干个集合,每个对象恰好属于一个集合

例如,我们有7个对象:1, 2, 3, 4, 5, 6, 7。它们可能被划分为三个集合:{1, 2, 5}, {3, 6}, {4, 7}。

并查集主要支持两种操作:

  1. union(x, y):将包含元素 x 的集合和包含元素 y 的集合合并成一个新的集合。
  2. find(x):返回包含元素 x 的集合的“代表元素”。

为了简化操作,我们通常不会返回整个集合,而是为每个集合指定一个代表元素find(x) 操作返回的就是元素 x 所在集合的代表元素。如果对同一集合中的不同元素调用 find,它们将返回相同的代表元素。因此,我们可以通过检查 find(a) == find(b) 来判断两个元素是否属于同一个集合。

简单实现与优化思路 ⚙️

上一节我们介绍了并查集的基本概念。本节中我们来看看如何实现它,并逐步优化其性能。

初始简单实现

一个最直接的实现方式是使用一个数组 p,其中 p[x] 存储元素 x 所在集合的代表元素。

  • find(x):直接返回 p[x],时间复杂度为 O(1)
  • union(x, y):首先找到 xy 的代表 rxry。然后遍历整个数组,将所有代表为 rx 的元素改为 ry。这个操作的时间复杂度是 O(n)

这种实现方式中,find 很快,但 union 很慢。在最坏情况下,进行 n-1 次 union 操作的总时间复杂度会达到 O(n²)

优化一:按大小合并

我们可以通过一个简单的技巧来改善 union 操作:总是将较小的集合合并到较大的集合中。

具体做法是:为每个代表元素维护一个列表,记录该集合中的所有元素。在合并时,我们比较两个集合的大小,将较小集合中的所有元素“移动”到较大集合中,并更新它们的代表元素。

这个优化的关键在于:每个元素从一个小集合被移动到一个大集合中。可以证明,在整个数据结构的生命周期中,每个元素最多被移动 O(log n) 次。因此,所有 union 操作的总时间复杂度从 O(n²) 降到了 O(n log n)。这是一种“启发式策略”,在构建可合并数据结构时非常常用。

树形实现与路径压缩 🌲

上一节我们通过按大小合并优化了总时间复杂度。本节中我们将引入更高效的树形表示法和核心优化——路径压缩

树形表示法

我们可以用一棵树来表示一个集合。树的根节点就是这个集合的代表元素。每个节点都有一个指向其父节点的指针。我们依然用一个数组 p 来实现,p[x] 表示元素 x 的父节点。根节点的父节点指向它自己。

在这种表示下:

  • find(x):通过不断查找父节点 (x = p[x]),直到找到根节点。
  • union(x, y):找到 xy 的根节点 rxry,然后将其中一个根节点的父指针指向另一个根节点(例如 p[rx] = ry)。

如果不加优化,union 操作很快(主要是两次 find),但 find 操作在最坏情况下(树退化成一条链)可能需要 O(n) 时间。

优化二:按秩合并

为了防止树变得过高,我们可以借鉴按大小合并的思想,使用按秩合并。这里的“秩”可以近似理解为树的高度。

我们为每个节点维护一个 rank 值。在合并两棵树时,我们总是将秩较小的树的根,连接到秩较大的树的根上。如果两棵树秩相等,则连接后新的根节点的秩需要加一。

可以证明,使用按秩合并后,树的高度将始终保持在 O(log n) 以内。因此,单次 find 操作的时间复杂度优化为 O(log n)。

优化三:路径压缩

这是并查集算法的“灵魂”优化。在每次执行 find(x) 操作时,我们不仅在寻找根节点,还会顺便将x 到根节点路径上的所有节点的父指针,直接指向根节点。

这样,后续再对这条路径上的任何节点执行 find 操作时,都将在常数时间内完成。路径压缩极大地“压扁”了树的结构,使得后续操作变得极快。

路径压缩的代码实现非常简洁,通常用递归可以轻松写出:

def find(x):
    if p[x] != x:
        p[x] = find(p[x]) # 递归查找并压缩路径
    return p[x]

时间复杂度分析与总结 🎯

上一节我们介绍了并查集的两种关键优化:按秩合并和路径压缩。本节中我们来分析它们共同作用下的惊人效率。

同时使用按秩合并和路径压缩时,并查集的操作效率非常高。经过复杂分析(这里不展开证明),可以得到其摊还时间复杂度接近于常数。

具体来说,进行 m 次 findunion 操作(在 n 个元素上)的总时间复杂度是 O(m α(n)),其中 α(n) 是增长极其缓慢的反阿克曼函数。对于任何在现实世界中有意义的数据规模 n,α(n) 的值都不会超过 5。因此,在实践中,我们可以认为每次操作的平均时间是常数级别的。

这种近乎常数的操作速度,加上其简洁的实现,使得并查集成为算法竞赛和实际工程中不可或缺的高效工具。


本节课中我们一起学习了并查集数据结构。我们从最基础的概念和简单实现出发,逐步探讨了如何通过按大小/按秩合并路径压缩这两种优化策略,将其操作效率提升到近乎常数的惊人水平。并查集虽然原理简单,但却是理解算法优化思想和摊还分析的一个绝佳范例,并将在后续的图算法(如最小生成树、连通分量)中发挥核心作用。

009:斐波那契堆 🧮

在本节课中,我们将要学习一种名为“斐波那契堆”的高级数据结构。它是一种支持多种高效操作的堆结构,特别是“合并”和“降低键值”操作。我们将从基础的堆操作开始,逐步理解斐波那契堆的设计原理和其摊销时间复杂度。


什么是斐波那契堆? 🤔

斐波那契堆是一种堆数据结构。它支持像添加元素和移除最小元素这样的基本操作,就像我们在第二讲中讨论过的普通二叉堆一样。

这些操作是所有堆结构的通用操作。斐波那契堆还支持另外两个重要的操作。

第一个重要操作是合并两个堆。就像在并查集中一样,你有两个独立的堆,然后将所有元素的集合合并起来,得到一个包含两个堆所有元素的大堆。

第二个重要操作是降低键值。这个操作会获取堆中已经存在的某个元素,并将其键值更改为另一个更小的值。例如,我们取一个元素 X,将其键值改为 Y,前提是 Y 小于 X。


与二叉堆的比较 ⚖️

上一节我们介绍了斐波那契堆支持的操作。本节中,我们来看看它与我们已知的二叉堆有何不同。

我们已经知道如何使用二叉堆。使用二叉堆时,你可以在 O(log n) 时间内添加元素,也可以在 O(log n) 时间内移除最小元素。你同样可以在 O(log n) 时间内降低键值(降低键值后通过“上浮”操作调整堆)。然而,你不能轻易地合并两个二叉堆。虽然你可以通过将较小堆的所有元素插入较大堆来实现合并,但这需要大约 O(log² n) 的时间,效率不高。

最常见的二叉堆支持这三种操作,它们都在 O(log n) 时间内完成。


迈向斐波那契堆:二项堆 🌳

在深入理解斐波那契堆之前,我们先来看一个更简单的中间结构:二项堆。理解二项堆有助于我们掌握斐波那契堆的工作原理。

二项堆是构建在“二项树”之上的堆。我们在第二讲中讨论的简单堆是基于二叉树的,每个节点恰好有两个子节点。而二项树则有所不同。

以下是不同阶数的二项树:

  • 0阶二项树:只是一个单独的节点。
  • 1阶二项树:一个根节点,连接着一个0阶二项树(作为子节点)。
  • 2阶二项树:一个根节点,连接着一个1阶二项树和一个0阶二项树(作为子节点)。
  • k阶二项树:一个根节点,连接着 k 个子节点,这些子节点分别是阶数为 k-1, k-2, ..., 0 的二项树。

另一种理解方式是:k阶二项树可以通过将两个 (k-1) 阶二项树连接起来构成,将其中一个的根作为另一个根的子节点。

我们可以在这样的树上构建堆:在每个节点中放入元素,并维护堆性质——每个节点的值都小于或等于其子节点的值。


二项堆的结构与操作 🛠️

上一节我们介绍了二项树,本节中我们来看看如何用它们构建二项堆,并实现其操作。

k阶二项树恰好包含 2^k 个节点。如果我们想将 n 个元素放入二项堆中,而 n 不是2的幂次,我们可以将 n 分解为多个2的幂次之和(例如 11 = 8 + 2 + 1),并用对应阶数的二项树来存放这些元素。这样,二项堆就是一组不同阶数的二项树的集合。

为了最小化树的数量,我们应确保所有二项树的阶数都不同。因为 k阶树有 2^k 个节点,所以树的阶数不会超过 log₂ n。因此,一个二项堆中树的数量最多为 O(log n)。

以下是二项堆的核心操作:

添加元素

  1. 将新元素视为一个0阶二项树。
  2. 如果堆中已存在0阶树,则将这两棵0阶树合并成一棵1阶树。
  3. 如果合并后,堆中又存在另一棵1阶树,则继续合并成2阶树。
  4. 重复此过程,直到没有相同阶数的树可合并为止。
    由于树的阶数不超过 O(log n),此过程最多进行 O(log n) 次合并,每次合并是常数时间操作。因此,添加元素的时间复杂度为 O(log n)

合并两个堆

合并两个二项堆类似于合并两个有序链表(按树的阶数排序)。我们同时遍历两个堆的树列表:

  • 如果当前阶数唯一,则将该树加入结果堆。
  • 如果遇到相同阶数的树,则将它们合并成一棵更高阶的树,并像处理进位一样继续与结果堆中可能存在的同阶树合并。
    由于两个堆总共有 O(log n) 棵树,合并操作的时间复杂度为 O(log n)

移除最小元素

  1. 找到所有树根中的最小元素(O(log n) 时间)。
  2. 移除该最小元素所在的根节点。
  3. 将该根节点的所有子节点(它们本身是阶数各不相同的二项树)视为一个新的二项堆 H2。
  4. 将原堆(移除了最小树后剩下的部分)与堆 H2 合并。
    合并操作是 O(log n),因此移除最小元素的总时间复杂度也是 O(log n)

降低键值

在二项堆中,降低键值可以像在二叉堆中一样处理:降低键值后,通过不断与父节点交换(“上浮”)来恢复堆性质。由于树高为 O(log n),此操作的时间复杂度为 O(log n)


斐波那契堆的设计目标 🎯

上一节我们完整介绍了二项堆,本节中我们来看看斐波那契堆如何对其进行优化,以实现更高效的操作。

我们的目标是让某些操作比 O(log n) 更快,即达到摊销常数时间复杂度

  1. 添加元素:二项堆的添加操作已经是摊销常数时间(虽然我们之前分析为 O(log n),但通过精细的摊销分析可以证明)。
  2. 合并堆:我们希望在常数时间内合并两个堆。
  3. 降低键值:我们希望在常数时间内降低一个元素的键值。这在某些图算法(如Dijkstra或Prim算法)中非常重要。

为了实现这些目标,我们需要一个更灵活的结构。关键思路是:推迟工作,仅在必要时进行整理


斐波那契堆的简化核心 💎

斐波那契堆放松了二项堆中“所有树阶数必须不同”的严格限制。它允许堆中存在多棵阶数相同的树。

基于此,操作变得非常简单:

  • 添加元素:直接将新元素作为一个单节点树(0阶树)添加到堆的根链表中。时间复杂度:O(1)
  • 合并堆:直接将两个堆的根链表拼接在一起。时间复杂度:O(1)

然而,这种简单性是有代价的。如果我们一直添加元素而不整理,堆可能会退化成大量单节点树。这会导致查找或移除最小元素变得低效,因为我们需要遍历所有树根来找到最小值。

因此,我们将整理工作推迟到移除最小元素时进行。


斐波那契堆的整理与移除最小元素 🔧

当需要移除最小元素时,我们不得不进行整理以保持效率。

移除最小元素步骤

  1. 找到并移除最小根节点(我们始终维护一个指向最小根的指针)。
  2. 将该最小根的所有子节点提升为新的根节点,加入到根链表中。
  3. 此时,根链表中可能有很多树,且可能存在相同阶数的树。
  4. 执行整理(Consolidate):遍历根链表,使用一个辅助数组记录已看到的树的阶数。
    • 如果遇到某个阶数 k 尚未记录,则记录这棵树。
    • 如果阶数 k 已记录,则将这两棵同阶树合并(将根值较大的树作为根值较小的树的子节点),得到一棵阶数为 k+1 的树。然后继续尝试将新树放入数组,可能引发连锁合并。
  5. 整理完成后,所有树的阶数都将不同。因此,树的数量最多为 O(log n)。
  6. 在整理过程中或之后,遍历新的根链表,找到并更新最小根指针。

时间复杂度分析

  • 步骤2、3是常数时间。
  • 步骤4的整理过程需要遍历所有根树并进行合并。设整理前有 m 棵树。每次合并减少一棵树。整理后剩下 O(log n) 棵树。因此,合并操作次数为 m - O(log n)
  • 如果我们定义势能函数 Φ = 堆中树的数量,那么:
    • 添加/合并操作会增加 Φ(O(1) 操作,势能增加 O(1))。
    • 移除最小元素时,实际耗时 O(m),但势能变化 ΔΦ = O(log n) - m(从 m 棵树减少到 O(log n) 棵)。
    • 根据摊销分析,摊销时间复杂度 = 实际耗时 + ΔΦ = O(m) + (O(log n) - m) = O(log n)

因此,移除最小元素的摊销时间复杂度为 O(log n)


斐波那契堆的降低键值操作 ⚡

这是斐波那契堆最精巧的部分,目的是实现 O(1) 的摊销降低键值操作。

在二项树中,降低键值后可能需要 O(log n) 次“上浮”操作。为了允许常数时间操作,斐波那契堆使用了更灵活的树结构,并引入了一个新规则:每个节点最多允许“丢失”一个子节点(被从子节点列表中移除)。如果一个节点丢失了一个子节点,我们将其标记为“已标记”。

降低键值步骤

  1. 降低某个节点 X 的键值。
  2. 如果破坏了对父节点的堆性质(即 X 变得比父节点小),则将 X 从其父节点处“切断”(Cut)。
  3. 将 X 提升为根节点,加入到根链表中,并清除其标记(因为根节点没有父节点,无需标记)。
  4. 现在检查 X 的原父节点 P:
    • 如果 P 未被标记,则将其标记(表示它刚丢失了一个子节点)。操作结束。
    • 如果 P 已被标记,则意味着这是它丢失的第二个子节点(违反了“最多丢失一个”的规则)。那么,我们同样将 P 从其父节点处“切断”,提升为根节点,并清除其标记。
    • 然后,继续递归地检查 P 的原父节点,直到遇到一个未被标记的节点或根节点为止。这个过程称为级联切断(Cascading Cut)

为什么这是常数摊销时间?

  • 一次降低键值可能引发一连串的切断操作(级联切断)。
  • 我们修改势能函数为:Φ = 树的数量 + 2 × 标记节点的数量
  • 添加/合并:增加树的数量,势能增加 O(1)。
  • 降低键值:假设我们进行了 k 次切断(包括最初的切断和级联切断)。
    • 实际耗时:O(k)。
    • 势能变化 ΔΦ:
      • 我们新增了 k 棵独立的树(来自被切断的节点):+k
      • 我们清除了这些节点原有的标记(它们不再是子节点):-2k
      • 我们可能标记了最后那个未被切断的父节点:+2
      • 总计:ΔΦ ≈ -k + 2
    • 摊销时间复杂度 = O(k) + (-k + O(1)) = O(1)

通过精巧的势能函数设计(为每个标记节点存储“信用”),降低键值操作的摊销时间复杂度被证明是 O(1)


总结 📚

本节课中我们一起学习了斐波那契堆这一复杂但强大的数据结构。让我们回顾一下其各项操作的摊销时间复杂度:

操作 描述 斐波那契堆摊销复杂度 二叉堆复杂度
insert(e) 插入元素 O(1) O(log n)
merge(H1, H2) 合并两个堆 O(1) O(n)
find-min() 查找最小元素 O(1) O(1)
extract-min() 移除最小元素 O(log n) O(log n)
decrease-key(x, k) 降低元素键值 O(1) O(log n)
delete(x) 删除任意元素 O(log n) O(log n)

斐波那契堆的核心思想是惰性操作摊销分析。它通过允许结构暂时的不完美(允许存在多棵同阶树,允许节点丢失子节点),将昂贵的工作推迟到必要时(如extract-min时)进行,从而在摊还意义上获得了极其高效的操作性能,尤其是在需要频繁进行decrease-key操作的图算法中优势明显。虽然其实践中的常数因子较大,但它的理论价值深远。

010:动态规划基础

在本节课中,我们将要学习动态规划这一非常重要的算法技术。动态规划不是一种具体的算法或数据结构,而是一种通用的解题思路,广泛应用于解决各类问题。我们将从非常基础的概念和简单问题入手,逐步理解其核心思想。

什么是动态规划?🤔

动态规划是算法理论中的一项基本技术。它不是一个具体的算法,也不是一个数据结构,而是一种通用的解题技巧。这种技巧被用于各种不同的算法中,以解决各种各样的问题。

今天我们将学习这项技术的基础知识,从一些非常简单的案例开始。在后续的几节课中,我们会讨论更复杂的内容。

从斐波那契数列开始🔢

让我们从一个非常简单的计算斐波那契数列的问题开始。斐波那契数列的定义如下:第一项 F(1) 和第二项 F(2) 都等于 1。之后的每一项都等于前两项之和,即 F(n) = F(n-1) + F(n-2)

简单的递归解法及其问题

如果我们尝试使用简单的递归算法来计算,代码可能如下:

def fib(n):
    if n <= 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)

这个程序在功能上是正确的,但它的时间复杂度太高了。让我们分析一下原因。

当我们计算 fib(n) 时,它会递归调用 fib(n-1)fib(n-2)。这会导致大量的重复计算。例如,计算 fib(10) 时,fib(8) 会被计算多次。实际上,这个递归算法的时间复杂度是指数级的,这是我们不希望看到的。

使用记忆化优化

为了修复这个问题,我们可以使用记忆化技术。基本思路是:保存每个 n 对应的斐波那契数计算结果。如果某个值之前已经计算过,就直接返回保存的结果,避免重复计算。

以下是改进后的代码:

memo = [0] * (n+1)  # 假设 n 已知

def fib_memo(n):
    if n <= 2:
        return 1
    if memo[n] != 0:  # 如果已经计算过
        return memo[n]
    # 否则进行计算并保存结果
    memo[n] = fib_memo(n-1) + fib_memo(n-2)
    return memo[n]

在这个改进中,我们使用了一个额外的数组 memo 来存储已计算的值。每个 fib(n) 的值只会被计算一次,因此总的时间复杂度降低到了线性级 O(n)

迭代解法(自底向上)

我们甚至可以完全不用递归,使用简单的循环来实现,这种方法通常称为“自底向上”的动态规划。

def fib_iterative(n):
    if n <= 2:
        return 1
    dp = [0] * (n+1)
    dp[1] = dp[2] = 1
    for i in range(3, n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

这段代码创建了一个数组 dp,然后按顺序从 3n 计算每个斐波那契数。它的时间复杂度同样是线性的 O(n)

青蛙跳格子问题🐸

上一节我们介绍了如何使用动态规划高效计算斐波那契数。本节中我们来看看一个经典问题:青蛙跳格子。

假设有一条由 n 个格子组成的路径,编号从 0n-1。一只青蛙从最左边的格子(0 号)出发,想要跳到最右边的格子(n-1 号)。它每次可以跳 1 格或 2 格。问题是:计算从起点到终点有多少种不同的跳跃路径。

问题分析

让我们思考如何到达最后一个格子 n-1。在到达它之前,青蛙的最后一跳只能来自两个格子:

  1. 从格子 n-21 格过来。
  2. 从格子 n-32 格过来。

如果我们用 D(n) 表示到达第 n 个格子的路径总数,那么:

  • 通过第一种方式到达的路径数,等于到达格子 n-2 的路径数,即 D(n-2)
  • 通过第二种方式到达的路径数,等于到达格子 n-3 的路径数,即 D(n-3)

因此,总路径数就是这两部分之和:D(n) = D(n-2) + D(n-3)。注意,这里的初始条件需要根据实际情况设定,例如 D(0)=1(起点只有一种方式),D(1)=1(从起点跳1格)。

动态规划解法

我们可以用和计算斐波那契数列类似的方法来解决这个问题。

def count_paths(n):
    if n < 2:
        return 1  # 简单处理边界
    dp = [0] * (n+1)
    dp[0] = dp[1] = 1  # 初始化
    for i in range(2, n+1):
        # 注意边界检查,确保索引有效
        ways = dp[i-1]  # 从 i-1 跳 1 格
        if i-2 >= 0:
            ways += dp[i-2]  # 从 i-2 跳 2 格
        dp[i] = ways
    return dp[n]

扩展到更一般的情况🔄

上一节我们解决了青蛙每次跳1或2格的问题。现在,让我们考虑更一般的情况:如果青蛙一次可以跳 1 格、2 格,...,直到 k 格,那么到达第 n 个格子的路径数是多少?

问题建模

此时,到达格子 i 的最后一跳,可以来自格子 i-1, i-2, ..., i-k(前提是这些索引大于等于0)。因此,状态转移方程变为:
D(i) = D(i-1) + D(i-2) + ... + D(i-k),其中对于 j < 0 的情况,D(j) = 0

动态规划实现

以下是解决这个问题的代码框架:

def count_paths_general(n, k):
    dp = [0] * (n+1)
    dp[0] = 1  # 起点有一种方式
    for i in range(1, n+1):
        for j in range(1, k+1):
            if i - j >= 0:
                dp[i] += dp[i-j]
    return dp[n]

这个算法的时间复杂度是 O(n * k)。我们可以通过维护一个前缀和来优化到 O(n),因为 D(i) 本质上就是前 kD 值的和。

带权值的最优路径问题💰

前面的问题都是计算路径的数量。动态规划更常见的应用是解决优化问题,例如寻找最小代价或最大收益的路径。

现在,我们为每个格子赋予一个“代价” C[i]。青蛙每跳到一个格子,就需要支付该格子的代价。目标是从起点 0 跳到终点 n-1,使得总代价最小

问题分析

我们用 DP(i) 表示跳到格子 i 所需的最小总代价。
要到达格子 i,青蛙只能从 i-1i-2 跳过来(假设还是只能跳1或2格)。

  • 如果从 i-1 跳过来,总代价是 DP(i-1) + C[i]
  • 如果从 i-2 跳过来,总代价是 DP(i-2) + C[i]

由于我们想要最小代价,所以 DP(i) 应该是这两种可能中的较小值:
DP(i) = min(DP(i-1), DP(i-2)) + C[i]

动态规划解法

以下是计算最小代价的代码:

def min_cost(cost):
    n = len(cost)
    dp = [0] * n
    dp[0] = cost[0]  # 跳到起点的代价就是起点本身的代价
    if n > 1:
        dp[1] = cost[1] # 可以直接从起点跳到1
    for i in range(2, n):
        dp[i] = min(dp[i-1], dp[i-2]) + cost[i]
    return dp[n-1]

如何还原最优路径?

上面的代码只计算了最小代价的值。通常,我们还需要知道具体是哪条路径达到了这个最小代价。

我们可以在计算 dp 数组的同时,用另一个数组 prev 记录到达每个格子的“前驱格子”。在决定 dp[i] 是来自 i-1 还是 i-2 时,就把 prev[i] 设为对应的前驱索引。

以下是同时计算最小代价和还原路径的代码:

def min_cost_with_path(cost):
    n = len(cost)
    dp = [0] * n
    prev = [-1] * n  # 用于记录路径

    dp[0] = cost[0]
    prev[0] = -1  # 起点没有前驱

    if n > 1:
        dp[1] = cost[1]
        prev[1] = 0  # 从起点跳到1

    for i in range(2, n):
        if dp[i-1] < dp[i-2]:
            dp[i] = dp[i-1] + cost[i]
            prev[i] = i-1
        else:
            dp[i] = dp[i-2] + cost[i]
            prev[i] = i-2

    # 还原路径:从终点倒推
    path = []
    current = n-1
    while current != -1:
        path.append(current)
        current = prev[current]
    path.reverse()  # 反转得到从起点到终点的路径

    return dp[n-1], path

二维网格上的动态规划🌐

动态规划的思想可以很容易地扩展到二维甚至更高维的问题。考虑一个 m x n 的网格,青蛙从左上角 (0,0) 出发,每次只能向右或向下移动一格,最终要到达右下角 (m-1, n-1)。每个格子 (i, j) 有一个价值 G[i][j]。目标是找到一条路径,使得经过的格子价值总和最大。

问题分析

我们用 DP(i, j) 表示到达格子 (i, j) 能获得的最大价值。
由于只能从上方 (i-1, j) 或左方 (i, j-1) 过来,所以状态转移方程为:
DP(i, j) = max(DP(i-1, j), DP(i, j-1)) + G[i][j]

动态规划解法

以下是解决这个问题的代码:

def max_path_sum(grid):
    m, n = len(grid), len(grid[0])
    dp = [[0] * n for _ in range(m)]

    dp[0][0] = grid[0][0]
    # 初始化第一行和第一列
    for j in range(1, n):
        dp[0][j] = dp[0][j-1] + grid[0][j]
    for i in range(1, m):
        dp[i][0] = dp[i-1][0] + grid[i][0]

    # 填充其余部分
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i][j]

    return dp[m-1][n-1]

状态更复杂的问题:带跳跃限制🚦

现在让我们增加问题的难度。假设青蛙的跳跃有一个限制:每一次跳跃的长度不能小于上一次跳跃的长度(即跳跃具有“惯性”或“非递减”属性)。我们依然想计算从起点到终点的路径数或最小代价。

问题分析

此时,描述青蛙的“状态”需要更多的信息。不仅要知道它当前在哪个格子 i,还需要知道它上一次跳跃的长度 k,因为这会限制下一次跳跃的最小长度。

因此,我们定义状态 DP(i, k):表示以上一次跳跃长度为 k 的方式,到达格子 i 的路径数(或最小代价)。

状态转移

从状态 (i, k) 出发,青蛙下一次可以跳到 i + next_k,其中 next_k >= k。所以状态转移需要遍历所有合法的 next_k

以计算路径数为例,伪代码如下:

对于每个格子 i
  对于每个可能的上一跳长度 k
    对于每个可能的下一跳长度 next_k (next_k >= k,且 i+next_k 不越界)
      DP(i+next_k, next_k) += DP(i, k)

动态规划解法思路

这形成了一个二维动态规划。我们需要初始化 DP(0, *)(起点的状态,可以认为上一跳长度为0)。然后按照格子编号 i 从小到大的顺序进行递推。

def count_paths_with_limit(n, max_jump):
    # dp[i][k] 表示以长度k跳到i的路径数
    dp = [[0]*(max_jump+1) for _ in range(n+1)]
    # 初始化:在起点0,假设上一跳长度为0(代表起始状态)
    dp[0][0] = 1

    for i in range(n):
        for k in range(max_jump+1):
            if dp[i][k] > 0: # 如果当前状态可达
                for next_k in range(k, max_jump+1): # 下一跳必须不小于当前跳
                    next_pos = i + next_k
                    if next_pos <= n:
                        dp[next_pos][next_k] += dp[i][k]
    # 终点n可以由任何上一跳长度到达
    return sum(dp[n])

这个算法的时间复杂度较高,为 O(n * max_jump^2)。在实际应用中,通常需要根据具体问题优化状态定义和转移方程。

总结📚

本节课中我们一起学习了动态规划的基础知识:

  1. 核心思想:动态规划通过将复杂问题分解为重叠子问题,并存储子问题的解(记忆化),来避免重复计算,从而高效解决问题。
  2. 实现方式:主要有两种——“自顶向下”的带记忆化的递归,以及“自底向上”的迭代填表。
  3. 关键步骤
    • 定义状态:用一组参数清晰地描述一个子问题(例如 dp[i] 表示到达第 i 格的最优解)。
    • 确定状态转移方程:找出状态之间的关系(例如 dp[i] = min(dp[i-1], dp[i-2]) + cost[i])。
    • 设置初始条件(边界)
    • 确定计算顺序
    • (可选)还原具体方案
  4. 应用范围:从一维的斐波那契数列、跳跃问题,到二维的网格路径问题,再到状态更复杂的带限制问题,动态规划提供了一套强大的问题建模和解决框架。

动态规划是算法学习中至关重要的一环。掌握其基础后,你将能够挑战更多有趣且实际的问题。在接下来的课程中,我们将探讨更复杂的动态规划应用。

011:动态规划(第二部分)

在本节课中,我们将继续探讨动态规划的应用,并学习如何用它来解决几个更实际的问题。我们将重点分析两个经典案例:计算两个文本文件(或字符串)之间的差异(编辑距离),以及如何对文本进行最优排版(文本对齐问题)。最后,我们会简要介绍一个字符串最优编码的问题。


计算编辑距离 🧮

在上一节中,我们学习了动态规划的基本思想。本节中,我们来看看如何用它来解决一个非常实际的问题:计算两个字符串之间的最小编辑距离,也就是将一个字符串转换成另一个字符串所需的最少操作次数。这个算法是许多工具(如版本控制系统中的 diff 工具)的核心。

问题定义

假设我们有两个字符串 AB。我们允许三种操作:

  1. 修改一个字符。
  2. 删除一个字符。
  3. 插入一个字符。

我们的目标是找到将字符串 A 转换为字符串 B 所需的最少操作次数。这个最少操作数被称为 莱文斯坦距离编辑距离

动态规划思路

我们可以将大问题分解为更小的子问题。考虑两个字符串的最后一个字符:

  • 如果它们相同,我们可以直接保留这个字符,问题就转化为将 A 的前 i-1 个字符转换为 B 的前 j-1 个字符。
  • 如果它们不同,我们有三种选择:
    1. 修改 A 的最后一个字符为 B 的最后一个字符,然后解决剩余子问题。
    2. 删除 A 的最后一个字符,然后解决剩余子问题。
    3. A 的末尾插入 B 的最后一个字符,然后解决剩余子问题。

我们总是选择这三种操作中成本最小的那个。

状态定义与转移方程

我们定义 dp[i][j] 为将字符串 A 的前 i 个字符转换为字符串 B 的前 j 个字符所需的最小编辑距离。

以下是状态转移方程:

  • 基础情况:如果其中一个字符串为空,则编辑距离就是另一个字符串的长度。
    dp[0][j] = j
    dp[i][0] = i
    
  • 状态转移
    如果 A[i-1] == B[j-1]:
        dp[i][j] = dp[i-1][j-1]
    否则:
        dp[i][j] = 1 + min(dp[i-1][j-1],  // 修改操作
                           dp[i-1][j],    // 删除操作
                           dp[i][j-1])    // 插入操作
    

代码实现

以下是该算法的 Python 实现:

def levenshtein_distance(A, B):
    m, n = len(A), len(B)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # 初始化基础情况
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j

    # 填充 dp 表
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if A[i - 1] == B[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(dp[i - 1][j - 1],  # 修改
                                   dp[i - 1][j],      # 删除
                                   dp[i][j - 1])      # 插入
    return dp[m][n]

# 示例
A = "apple"
B = "aple"
print(levenshtein_distance(A, B))  # 输出:1 (删除一个 'p')

回溯以获取具体操作序列

如果我们不仅需要距离,还需要知道具体的操作序列,我们可以从 dp[m][n] 开始回溯。根据 dp[i][j] 的值是由哪个相邻状态转移而来,我们可以推断出在位置 (i, j) 执行了何种操作(修改、删除或插入)。


文本对齐问题 📝

了解了如何计算编辑距离后,我们来看另一个实际问题:文本对齐。想象你正在设计一个文本处理器,需要将一段文字美观地排列在固定宽度的页面上。如果单词排列不当,行尾可能会出现难看的巨大空白。我们的目标是找到一种分割文本为多行的方式,使得所有行尾的“不美观度”总和最小。

问题建模

首先,我们需要量化“不美观度”。一个常见的方法是定义一个“坏处”函数。例如,如果某行末尾的空白宽度为 x,我们可以定义其坏处为 。这样,大的空白会被严重惩罚。

假设我们有一个单词长度数组 words 和页面宽度 L。我们需要将单词序列分割成若干行。对于从第 l 到第 r-1 个单词组成的行,其坏处可以计算为:

badness(l, r) = (L - sum(words[l:r]) - (r - l - 1))³

这里 (r - l - 1) 是单词间的空格数(假设每个空格宽度为1)。如果 sum(words[l:r]) + (r - l - 1) > L,说明这行放不下,坏处设为无穷大。

动态规划思路

我们可以这样思考:对于前 i 个单词,考虑最后一行由哪些单词组成。假设最后一行包含从第 k 到第 i-1 个单词,那么总坏处就是这最后一行的坏处,加上前 k 个单词排列成行的最小总坏处。

状态定义与转移方程

定义 dp[i] 为排列前 i 个单词的最小总坏处。

状态转移方程为:

dp[i] = min( dp[k] + badness(k, i) ), 其中 0 <= k < i

dp[0] = 0 表示没有单词时坏处为0。

代码实现

以下是该算法的简化 Python 实现:

def text_justification(words, L):
    n = len(words)
    dp = [float('inf')] * (n + 1)
    dp[0] = 0

    # 预先计算前缀和以便快速计算单词总长度
    prefix_sum = [0] * (n + 1)
    for i in range(1, n + 1):
        prefix_sum[i] = prefix_sum[i - 1] + words[i - 1]

    for i in range(1, n + 1):
        for k in range(i):
            # 计算从 words[k] 到 words[i-1] 的单词总长度和所需空格
            total_chars = prefix_sum[i] - prefix_sum[k]
            total_spaces = i - k - 1
            line_width = total_chars + total_spaces

            if line_width <= L:
                badness_val = (L - line_width) ** 3
                dp[i] = min(dp[i], dp[k] + badness_val)

    return dp[n]

# 示例
words = [3, 2, 2, 5]  # 单词长度
L = 10
print(text_justification(words, L))

在实际应用中,内层循环 k 的范围通常受行宽 L 限制,因此算法效率在实践中是可以接受的。

回溯以获取分行方案

同样,我们可以通过维护一个 parent 数组来记录每个 dp[i] 是由哪个 k 转移而来的。最后从 dp[n] 回溯,就能得到最优的分行方案。


字符串最优编码简介 🔤

最后,我们简要提一个更复杂的动态规划问题:字符串的最优编码。假设我们允许使用两种方式编码一个字符串 S

  1. 直接输出字符,如 "a"
  2. 输出重复模式,如 "3[ab]" 表示 "ababab"

目标是找到编码后长度最短的表示形式。

解决思路

这个问题也可以用动态规划解决。我们定义 dp[l][r] 为子串 S[l:r] 的最短编码长度。对于每个子串,我们有两种选择:

  • 选项1:将第一个字符单独编码,然后加上剩余部分的最优编码。即 1 + dp[l+1][r]
  • 选项2:尝试找到一个重复模式。即寻找一个长度 x 和重复次数 k,使得 S[l:l+x] 重复 k 次能构成 S[l:l+k*x]。那么编码长度就是 数字k的位数 + 2(方括号) + dp[l][l+x](编码重复单元) + dp[l+k*x][r](编码剩余部分)

我们需要遍历所有可能的 xk,并检查子串是否匹配(这可以用字符串哈希或 Z 算法等高效完成),然后取最小值。

这个问题的特点是,计算一个状态 dp[l][r] 时,可能会依赖两个更小的子问题状态(dp[l][l+x]dp[l+k*x][r]),而不仅仅是之前常见的一个。在回溯构建最优编码字符串时,也需要递归地构建两个部分。


总结 📚

本节课我们一起学习了动态规划的更多实际应用:

  1. 编辑距离:我们学习了如何用动态规划计算两个字符串的最小编辑距离,这是 diff 等工具的基础。其核心是定义 dp[i][j] 状态,并基于最后一个字符是否相同进行状态转移。
  2. 文本对齐:我们探讨了如何将动态规划用于文本排版,通过定义“坏处”函数和状态 dp[i],找到最小化行尾空白总坏处的分行方案。
  3. 字符串编码:我们简要介绍了一个更复杂的动态规划问题,其中状态转移可能依赖于多个子问题,拓宽了我们对动态规划模型的理解。

动态规划的魅力在于,它能将许多看似复杂的最优化问题,分解为一系列重叠子问题,并通过递推高效求解。掌握识别问题状态和推导转移方程的能力是关键。

012:背包问题 🎒

在本节课中,我们将要学习一个在算法理论中非常重要的问题——背包问题。这个问题看似简单,但会出现在各种不同的实际场景中。学习它,能帮助你在实际项目中识别并优化类似的问题。

什么是经典背包问题?

经典背包问题描述如下:你有一系列物品,每个物品都有其重量(weight)和成本(cost)。同时,你有一个容量为 S 的背包。我们的目标是选择一些物品放入背包,使得在总重量不超过 S 的前提下,总成本达到最大。

用公式描述,即:从物品集合中选取一个子集,使得 ∑weight ≤ S,并且 ∑cost 的值最大。

一个重要的概念是,背包问题是一个 NP完全 问题。这意味着我们目前还不知道是否存在一个高效的通用解法。对于今天的课程,你只需要知道这类问题通常很难解决。

当重量为整数且较小时的解法

然而,在某些特定条件下,背包问题是可以高效解决的。其中一个重要情况是:所有物品的重量都是整数,并且背包容量 S 相对较小(例如,S 大约在百万级别,这在计算机内存中是可以处理的)。

在这种情况下,我们可以使用动态规划来解决。首先,让我们从一个更简单的问题开始理解。

简化问题:仅最大化总重量

假设我们只关心总重量,即每个物品的成本等于其重量。我们的目标仍然是总重量不超过 S 且最大化。

我们可以定义一个二维布尔数组 dp[i][j]

  • i 表示我们只考虑前 i 个物品(索引从0到 i-1)。
  • j 表示一个目标总重量。
  • dp[i][j] 的值表示:是否可能 从前 i 个物品中选出一个子集,使其总重量恰好等于 j

以下是计算这个数组的方法:

我们考虑最后一个物品(第 i-1 个)。对于状态 dp[i][j],有两种可能:

  1. 不选i-1 个物品:那么问题就变成了从前 i-1 个物品中选出总重量为 j 的子集,即 dp[i-1][j]
  2. 选择i-1 个物品:那么剩下的 i-1 个物品需要凑出总重量 j - weight[i-1],即 dp[i-1][j - weight[i-1]]

只要以上两种情况有一种为真,dp[i][j] 就为真。因此,状态转移方程为:
dp[i][j] = dp[i-1][j] OR dp[i-1][j - weight[i-1]]

初始条件:dp[0][0] = true(空集的总重量为0),dp[0][j] = false(j > 0)。

填完整个表格后,要找到最大总重量(不超过 S),我们只需在最后一行(i = n)找到最大的 j,使得 dp[n][j]true

回到原问题:最大化总成本

现在,我们把成本加回来。我们需要修改动态规划的状态定义。

我们定义 dp[i][j] 为一个数值(而不是布尔值):

  • dp[i][j] 表示:从前 i 个物品中选出一个总重量恰好等于 j 的子集,所能获得的最大总成本

状态转移的思路类似,但我们需要取最大值:

  1. 不选第 i-1 个物品:最大成本为 dp[i-1][j]
  2. 选择第 i-1 个物品:最大成本为 dp[i-1][j - weight[i-1]] + cost[i-1]

因此,状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i-1]] + cost[i-1])

对于不可能达到的状态(例如 j < 0 或初始时 j > 0i=0),我们可以将其成本设为 -∞(在代码中可以用一个非常小的负数代替),因为 -∞max 操作中的中性元素。

初始条件:dp[0][0] = 0dp[0][j] = -∞(j > 0)。

填完表格后,最终答案不是 dp[n][S],而是 max(dp[n][j]),其中 j 从 0 到 S。因为最优解的总重量可能小于 S

这个算法的时间复杂度是 O(n × S),当 S 不大时,这是可行的。

当物品数量 n 很小时的解法

上一节我们介绍了在背包容量 S 较小时的高效解法。本节中我们来看看另一种情况:当物品数量 n 非常小的时候(例如 n ≤ 2540),即使 S 很大,我们也有办法。

最直接的方法是枚举所有可能的物品子集。总共有 2^n 个子集。对于每个子集,我们计算其总重量和总成本,检查重量是否不超过 S,并更新最大成本。

如何枚举所有子集?

一个巧妙的方法是利用整数的二进制表示。对于一个有 n 个物品的集合,我们可以用一个 n 位的二进制数来表示一个子集:如果第 i 位是1,表示选择第 i 个物品;是0则表示不选。

例如,对于3个物品,子集 {物品1, 物品3} 对应的二进制数是 101(二进制),即十进制数 5

我们可以简单地循环 x0(2^n - 1),每个 x 就对应一个子集。要检查物品 i 是否在子集 x 中,可以使用位运算:(x >> i) & 1

这种暴力枚举算法的时间复杂度是 O(2^n × n)。虽然是指数级,但在 n 很小时是可行的。

优化:折半搜索(Meet in the Middle)

n 大到约 40 时,2^40 的枚举可能就太慢了。我们可以使用一种称为 折半搜索 的技巧进行优化。

思路如下:

  1. n 个物品平分成两组,每组大约 n/2 个。
  2. 分别枚举第一组和第二组的所有子集,得到两个列表 List1List2。每个列表中的元素是一个 (重量 w, 成本 c) 对。
  3. List2 按重量 w 排序,并为每个前缀预先计算该前缀中的最大成本 max_c
  4. 遍历 List1 中的每个子集 (w1, c1)。我们需要在 List2 中找到一个子集 (w2, c2),使得 w1 + w2 ≤ S,并且 c1 + c2 最大。
    • 由于 List2 已按重量排序,我们可以用二分查找快速找到所有满足 w2 ≤ S - w1 的子集(即一个前缀)。
    • 这个前缀中的最大成本 max_c 我们已经预先计算好了。
    • 因此,对于每个 (w1, c1),我们可以在 O(log(2^(n/2))) = O(n) 时间内找到最优的 (w2, c2)

总时间复杂度约为:O(2^(n/2) * n)(生成和排序列表) + O(2^(n/2) * n)(遍历和二分查找)。这比直接的 O(2^n * n) 有了巨大改进,使得解决 n ≈ 40 的问题成为可能。

扩展:多背包问题(装箱问题)

最后,我们简要看一个相关但更复杂的问题的变体:多背包问题(或称为装箱问题)。

问题描述:你有 n 个物品(只有重量,没有成本)和无限多个容量均为 S 的背包。目标是用最少的背包装下所有物品。

即使物品重量是小的整数,这个问题也通常很难,因为状态需要记录多个背包的当前容量。但当 n 很小时,我们依然可以用基于子集枚举的动态规划。

基于子集的动态规划

定义 dp[X] 为:装完物品集合 X(用二进制数表示)所需的最少背包数量。
状态转移:要计算 dp[X],我们枚举 X 的一个子集 Y,这个子集 Y 必须能被单独装进一个背包(即 sum(weight of Y) ≤ S)。那么,剩下的物品 X \ Y 需要 dp[X \ Y] 个背包。所以:
dp[X] = min(1 + dp[X \ Y]),对于所有满足条件的子集 Y

初始状态:dp[空集] = 0

这个算法需要枚举所有子集 X,以及每个 X 的所有子集 Y。优化前的时间复杂度很高(约 O(3^n))。但我们可以优化枚举子集 Y 的过程,只枚举 X 的子集,并使用位运算技巧高效生成下一个子集。

更优的解法:基于“最后一个背包”的状态设计

我们可以设计一个更聪明的状态,每次只添加一个物品,从而减少状态转移的数量。

定义状态 dp[X] 不是一个数字,而是一个数对 (a, b)

  • a:装完集合 X 已使用的完整背包数量。
  • b:当前最后一个(未装满的)背包中已装入物品的总重量。

我们定义 (a1, b1)(a2, b2) 更优,当且仅当 a1 < a2,或者 a1 == a2 且 b1 ≤ b2。我们的动态规划就是寻找每个状态 X 下的最优 (a, b) 对。

状态转移(添加一个物品 i 到集合 X):

  1. 如果当前最后一个背包还能放下物品 i (b + weight[i] ≤ S),则放入最后一个背包:新状态为 (a, b + weight[i])
  2. 如果放不下,则必须新开一个背包来装物品 i:新状态为 (a + 1, weight[i])

我们从空状态 (0, 0) 开始,逐步添加物品,并始终为每个物品集合 X 维护最优的 (a, b) 对。最终,装完所有物品(即全集)对应的 a 值就是最少需要的背包数。

这种方法的状态数仍是 2^n,但每个状态的转移只有 O(n) 种(尝试添加每个尚未加入的物品),总复杂度约为 O(2^n * n),比之前的 O(3^n) 更优。

总结

本节课中我们一起学习了背包问题及其变体:

  1. 经典背包问题:在总重量限制下最大化总成本,是一个NP完全问题。
  2. 动态规划解法:当所有物品重量为整数且背包容量 S 较小时,可以使用 O(n × S) 的动态规划高效解决。
  3. 枚举子集解法:当物品数量 n 很小时,可以直接枚举所有 2^n 个子集来求解。
  4. 折半搜索优化:当 n 较大(如~40)时,可以将集合分成两半,分别枚举后合并,将复杂度从 O(2^n) 优化到约 O(2^(n/2))
  5. 多背包问题:介绍了当 n 较小时,如何使用基于子集枚举的动态规划,以及通过优化状态设计(记录最后一个背包的容量)来更高效地求解。

理解这些不同场景下的解法,能帮助你在遇到类似优化问题时,快速识别并应用合适的策略。

013:子集动态规划与轮廓动态规划

在本节课中,我们将学习动态规划的两种高级应用:基于子集的动态规划和基于轮廓的动态规划。这两种方法在处理组合优化问题时非常强大,尤其当问题规模较小时,它们能提供高效的解决方案。

子集动态规划

上一节我们介绍了动态规划的基本概念,本节中我们来看看如何将子集作为动态规划的状态参数。这意味着动态规划的状态之一是一个集合,而不仅仅是一个数字。例如,状态可以表示为 dp[x],其中 x 是一个子集。

如果集合中元素的数量 n 较小(例如大约为20),那么子集的总数 2^n(大约一百万)也是可以接受的。我们可以创建一个大小为百万的数组来处理。

旅行商问题

以下是子集动态规划的一个经典应用:寻找最小权重的哈密顿回路,即旅行商问题。

问题描述:给定一个带权图(顶点代表城市,边代表道路及其距离),需要从起点城市 s 出发,访问所有其他城市恰好一次,并最终返回起点(或结束于任意点),目标是使总旅行距离最小。这是一个NP完全问题,意味着没有已知的多项式时间解法。然而,当顶点数 n 较小时,我们可以使用时间复杂度为 O(2^n * n^2) 的动态规划算法,这比暴力枚举所有 n! 条路径要高效得多。

动态规划思路:我们按顺序构建路径。动态规划的中间状态是:我们已经访问了某个顶点子集 X,并且当前正站在该子集中的最后一个顶点 v 上。状态 dp[X][v] 表示访问子集 X 并以顶点 v 结束的最小成本。

状态转移:从当前状态 (X, v),我们可以通过一条边 (v, u) 移动到未访问的顶点 u。新的状态将是 (X ∪ {u}, u),成本增加边 (v, u) 的权重。

算法实现

  1. 初始化:dp[{s}][s] = 0
  2. 遍历所有子集状态 (X, v)
  3. 对于每个状态,遍历所有从 v 出发的边 (v, u),如果 u 不在 X 中,则尝试更新状态 (X ∪ {u}, u) 的最小成本。
  4. 最终答案是在访问了所有顶点(即 X 为全集)的各个状态 dp[全集][v] 中取最小值。

核心代码框架

# 假设 n 为顶点数,graph[v] 是 v 的邻接表(包含边权)
dp = [[INF] * n for _ in range(1 << n)]
dp[1 << s][s] = 0  # 初始状态,只包含起点 s

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pavel-mavrin-dsal/img/f40506d187648ea1c9e8ced70ef087b8_90.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pavel-mavrin-dsal/img/f40506d187648ea1c9e8ced70ef087b8_92.png)

for mask in range(1 << n): # 遍历所有子集
    for v in range(n):
        if not (mask & (1 << v)): # 如果 v 不在当前子集中,跳过
            continue
        for u, weight in graph[v]:
            if mask & (1 << u): # 如果 u 已在子集中,跳过
                continue
            new_mask = mask | (1 << u)
            dp[new_mask][u] = min(dp[new_mask][u], dp[mask][v] + weight)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pavel-mavrin-dsal/img/f40506d187648ea1c9e8ced70ef087b8_94.png)

# 寻找访问所有顶点后的最小成本
full_mask = (1 << n) - 1
answer = min(dp[full_mask][v] for v in range(n))

关键点:当问题的状态可以仅由已处理元素的子集来定义,而无需关心其内部顺序时,就可以考虑使用子集动态规划。

轮廓动态规划

上一节我们介绍了基于子集的动态规划,本节中我们来看看一种更具体的、常用于二维网格问题的技术——轮廓动态规划。

轮廓动态规划常用于按层(例如按列)构建解决方案的问题。在构建下一层时,我们只需要知道上一层边界的特定状态(即“轮廓”),而不需要知道整个已构建部分的内部细节。

铺砖问题

一个经典例子是铺砖问题:计算用 1x2 大小的砖块铺满 n x m 矩形网格的方法数。

动态规划思路:我们一列一列地填充网格。状态定义为:我们已经填充了前 i 列,并且第 i 列的“轮廓”由一个位掩码 profile 表示。profile 的第 j 位为1表示第 j 行有一个砖块从第 i 列“伸出”到第 i+1 列(即该砖块横跨了两列)。

状态转移:从状态 (i, profile),我们需要填充第 i+1 列中所有因轮廓而空出的格子。我们需要枚举所有可能的方式用砖块填满这些空位,从而得到一个新的轮廓 new_profile,并转移到状态 (i+1, new_profile)

检查兼容性:判断能否从轮廓 p 转移到轮廓 q,需要检查两者定义的中间空隙是否能被 1x2 砖块恰好填满。这可以通过逐行检查或巧妙的位运算来完成。

算法优化(破碎轮廓法):上述方法的状态转移数可能较多(最多 4^n)。我们可以采用“破碎轮廓”法来简化:不再一次填充整列,而是一个格子一个格子地填充。状态变为 (i, j, profile),表示正在填充第 i 列的第 j 行,profile 描述了当前列及下一列相关格子的占用情况。这样,每次转移只涉及放置一个砖块(水平或垂直),代码更简单,且状态转移是线性的。

核心思想:轮廓动态规划适用于问题结构是分层的,在构建下一层时,只需要知道上一层边界(轮廓)的状态信息。

另一个例子:网格染色问题

考虑另一个问题:将 n x m 网格的每个格子染成黑或白,要求不存在任何 2x2 子网格颜色全相同。求染色方案数。

动态规划思路:同样按列处理。状态 (i, profile) 表示前 i 列已染色,profile 记录了第 i 列每行的颜色。在填充第 i+1 列时,我们需要确保不会与第 i 列形成单色的 2x2 方块。这同样可以通过轮廓动态规划或破碎轮廓法来解决,状态中需要包含当前列和下一列部分格子的颜色信息。

总结

本节课我们一起学习了两种高级动态规划技术。

  1. 子集动态规划:当问题状态可以由已处理元素的子集唯一确定时使用。我们以旅行商问题为例,展示了如何用 O(2^n * n^2) 的时间解决小规模NP难问题。
  2. 轮廓动态规划:常用于二维网格的铺砌、染色等问题。其核心是按层构建,状态记录层间的边界轮廓。我们介绍了标准的轮廓法和更优的破碎轮廓法,后者通过逐格转移简化了逻辑和复杂度。

这两种方法都利用了位掩码来高效表示和转移状态,是解决特定类型组合优化问题的有力工具。要掌握它们,关键在于识别问题是否具有“仅依赖前一层轮廓”的结构,并通过练习熟悉状态设计与转移的实现。

014:哈希表

在本节课中,我们将要学习一种非常强大且广泛应用的数据结构——哈希表。我们将探讨它的基本概念、工作原理、实现方式以及在实际应用中需要注意的问题。

哈希表是一种随机化的数据结构,虽然在某些特殊情况下可能引发问题,但其应用极为广泛。我们将讨论基于哈希表的两大主要数据结构:集合(Set)和映射(Map),并学习如何实现它们。


什么是哈希表?

哈希表是一种用于存储键值对的数据结构。它的核心思想是使用一个哈希函数,将键(Key)映射到一个固定大小的数组(通常称为哈希桶)的索引上。这样,我们可以通过计算键的哈希值来快速定位其对应的值(Value)。

核心公式index = hash(key) % array_size


基于哈希表的数据结构

上一节我们介绍了哈希表的基本概念,本节中我们来看看基于哈希表构建的两种核心数据结构。

集合(Set)

集合是一种存储唯一元素的数据结构。它支持以下基本操作:

  • 添加(Add):将一个对象加入集合。
  • 删除(Remove):从集合中移除一个对象。
  • 查询(Contains):检查一个对象是否存在于集合中。

集合在许多场景下非常有用,例如,计算一个列表中不同对象的数量:只需将所有对象放入一个集合,然后检查集合的大小即可。

映射(Map)

映射(或称字典)是一种将键对象映射到值对象的数据结构。你可以把它想象成一个数组,但索引(键)可以是任意对象,而不仅仅是整数。

映射支持以下基本操作:

  • 插入(Put):将一个键值对存入映射。
  • 获取(Get):根据键获取对应的值。

这两种数据结构在各种算法和实际项目中都非常重要且被广泛使用。


哈希表的简单实现

了解了集合和映射的概念后,我们来看看如何用哈希表来实现它们。让我们从一个简单的情况开始。

当键为小整数时

最简单的场景是当键是范围较小的整数时(例如从0到M-1)。在这种情况下,实现一个映射非常简单。

实现思路:直接使用一个大小为M的数组。将键k作为数组的索引,值v存储在array[k]的位置。

  • put(k, v) 操作:array[k] = v
  • get(k) 操作:return array[k]

代码示例

array = [None] * M  # 创建一个大小为M的数组

def put(k, v):
    array[k] = v

def get(k):
    return array[k]

处理更大的键空间

上一节我们处理了键是小整数的简单情况。但当键的范围很大(例如一个很大的整数)时,我们无法直接创建一个巨大的数组。本节中我们来看看如何解决这个问题。

核心思路是引入一个哈希函数(Hash Function)。这个函数接收一个大范围的键,并返回一个小范围的整数(例如0到M-1),然后我们用这个结果作为数组的索引。

核心公式h(k) = k % M (一个简单的哈希函数例子)

实现思路

  1. 创建一个大小为M的数组。
  2. 插入时,计算 index = h(key),然后将值存入 array[index]
  3. 查询时,同样计算 index = h(key),然后从 array[index] 读取值。

示例:设数组大小为5,哈希函数为 h(k) = k % 5

  • 执行 put(37, 3)h(37) = 2,所以将值3存入 array[2]
  • 执行 get(37)h(37) = 2,从 array[2] 读取到值3。

哈希冲突及其解决

上一节我们使用哈希函数将大键映射到小数组。但这会带来一个关键问题:哈希冲突(Collision)。本节中我们来看看什么是冲突以及如何解决它。

什么是哈希冲突?

当两个不同的键 xy 经过哈希函数计算后得到了相同的索引,即 h(x) == h(y),就发生了哈希冲突。

示例:继续使用 h(k) = k % 5

  • h(37) = 2
  • h(52) = 2
    键37和52发生了冲突,它们都会被映射到数组索引2。

解决方法一:链地址法

最直接的解决方法是链地址法(Chaining)。我们不再在数组的每个位置存储单个值,而是存储一个链表(或其他容器),用于存放所有哈希到该索引的键值对。

调整后的实现思路

  1. 数组的每个元素是一个链表,初始为空。
  2. 插入 put(k, v)
    • 计算 index = h(k)
    • 遍历 array[index] 处的链表,检查是否已存在键 k
    • 如果存在,更新其值;如果不存在,将新的键值对 (k, v) 添加到链表末尾。
  3. 查询 get(k)
    • 计算 index = h(k)
    • 遍历 array[index] 处的链表,查找键为 k 的节点。
    • 找到则返回对应的值,否则返回 None(表示键不存在)。

代码示例(简化版,未处理更新逻辑)

class Node:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None

array = [None] * M  # 每个位置初始为None,代表空链表

def put(k, v):
    index = h(k)
    # 简化为直接添加到链表头部,实际需先检查是否存在
    new_node = Node(k, v)
    new_node.next = array[index]
    array[index] = new_node

def get(k):
    index = h(k)
    current = array[index]
    while current is not None:
        if current.key == k:
            return current.value
        current = current.next
    return None

时间复杂度与随机哈希函数

解决了冲突后,我们需要分析链地址法的时间复杂度。最坏情况下,所有键都冲突到同一个桶,查询时间会退化为O(n)。本节中我们探讨如何通过选择好的哈希函数来获得平均常数时间复杂度。

理想情况:完全随机哈希函数

假设我们有一个完全随机的哈希函数 h,它将键均匀、独立地映射到 M 个桶中。对于任意两个不同的键 xy,它们哈希值相等的概率是 1/M

在这种情况下,对于一个包含 n 个元素的哈希表,每个桶中链表长度的期望值n / M。如果我们保持 Mn 成正比(例如 M ≈ n),那么期望长度就是一个常数。因此,getput 操作的平均时间复杂度是 O(1)

现实挑战与通用哈希函数族

然而,我们无法在程序中存储一个完全随机的函数(因为需要太多空间)。实践中,我们从一个精心设计的、较小的哈希函数族中随机选择一个函数来使用。

一个经典且简单的通用哈希函数族是:
公式h_{a, p}(k) = ((a * k) % p) % M
其中 p 是一个大质数,a 是从 1p-1 中随机选择的整数。

可以证明,从这个函数族中随机选择的函数,能以很高的概率满足“对于任意不同键 x, y,冲突概率约为 1/M”的性质,从而保证平均常数时间复杂度。

关键点:哈希函数需要是“随机”的,或者来自一个具有良好随机性质的函数族,以防止恶意数据导致性能退化。


开放寻址法

除了链地址法,还有另一种处理冲突的策略,称为开放寻址法(Open Addressing)。本节中我们来看看它的工作原理。

核心思想

在开放寻址法中,哈希表数组的每个位置只存储一个键值对。当发生冲突时(即目标位置已被占用),它会按照一个预定的探测序列依次检查后续位置,直到找到一个空位(用于插入)或找到目标键(用于查询)。

最简单的探测序列是线性探测:如果位置 i 被占,则尝试 i+1, i+2, ... 直到数组末尾,然后可能绕回开头。

插入 put(k, v) 操作(简化描述)

  1. 计算起始索引 i = h(k)
  2. i 开始,向后查找第一个空位置。
  3. 将键值对 (k, v) 放入该空位。

查询 get(k) 操作

  1. 计算起始索引 i = h(k)
  2. i 开始,向后查找。
    • 如果遇到键为 k 的位置,返回对应的值。
    • 如果遇到一个空位置,说明键 k 不存在于表中(因为插入时,键 k 一定会被放在探测序列中第一个可用的空位)。

负载因子与性能

为了使开放寻址法高效工作,哈希表必须保持足够多的空位。我们定义负载因子 α = n / M,即元素数量与数组大小的比值。

通常需要保持 α < 0.7 ~ 0.8。如果表太满(α 接近1),查找空位的探测序列会变得非常长,性能急剧下降。当元素数量增多时,需要动态扩容(创建一个更大的数组,并重新插入所有元素),类似于动态数组(如Python list)的扩容机制。

开放寻址法的优缺点

优点

  • 内存局部性好:所有数据都存储在一个连续的数组中,遍历探测序列时缓存命中率高,访问速度快。
  • 无需额外指针:节省了链地址法中链表节点所需的额外内存开销。

缺点

  • 容易产生聚集:线性探测容易导致连续的被占位置形成“聚集”,这会加长探测路径。可以使用二次探测或双重哈希等改进的探测序列来缓解。
  • 删除操作复杂:删除一个元素不能简单地将位置置空,否则会中断后续元素的探测序列。通常采用“惰性删除”(标记为已删除)的策略。

哈希表的动态管理与总结

最后,我们讨论哈希表在实际使用中的一些高级话题和总结。

应对哈希洪水攻击

如果攻击者知道你的哈希函数,他们可以精心构造大量具有相同哈希值的键,使你的哈希表退化为链表,导致服务拒绝。防御策略包括:

  1. 使用随机密钥:像之前介绍的,从通用哈希函数族中随机选择函数,攻击者无法预知。
  2. 动态重新哈希:监控桶的深度。如果某个桶变得异常大,说明可能正在被攻击。此时,可以随机选择一个新的哈希函数,并重新将所有元素插入到新的哈希表中(即重新哈希)。由于新函数是随机且独立的,攻击者之前构造的数据在新函数下很难再次全部冲突。

哈希表与二叉搜索树的比较

哈希表虽然高效,但并非万能。另一种重要的数据结构——平衡二叉搜索树(如红黑树),提供了不同的权衡:

  • 哈希表优势:平均情况下,插入、删除、查找的时间复杂度为 O(1),通常更快。
  • 二叉搜索树优势
    • 有序性:树中元素是按序存储的,可以高效地进行范围查询、查找前驱/后继、最小/最大元素等操作。
    • 最坏情况保证:平衡树能保证最坏情况下操作时间复杂度为 O(log n),而哈希表最坏情况是 O(n)。
    • 稳定性:不依赖于哈希函数的随机性。

总结

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

  1. 哈希表的基本概念:通过哈希函数将键映射到数组索引,实现快速访问。
  2. 基于哈希表的两种数据结构:集合(Set)映射(Map)
  3. 哈希冲突的必然性及两种主要解决方法:
    • 链地址法:每个桶使用一个链表存储冲突元素。
    • 开放寻址法:在数组内按照探测序列寻找空位。
  4. 保证平均 O(1) 时间复杂度的关键:使用随机或来自通用函数族的哈希函数
  5. 哈希表的动态扩容机制以及应对恶意数据的重新哈希策略。
  6. 哈希表与二叉搜索树的简要比较。

哈希表是算法工具箱中不可或缺的利器,理解其原理和实现细节,对于编写高效程序至关重要。

015:完美哈希、布谷鸟哈希与布隆过滤器

在本节课中,我们将继续探讨哈希表,学习几种高级技术:完美哈希、布谷鸟哈希以及布隆过滤器。这些技术旨在优化哈希表的性能,特别是在最坏情况下的查询时间,或者以极小的内存开销实现集合成员查询,同时允许一定的错误率。

回顾:标准哈希表

上一节我们介绍了两种基于哈希表的数据结构:集合(Set)和映射(Map)。集合支持插入和查询操作,映射则支持通过键来存储和获取值。

我们学习了如何构建平均时间复杂度为 O(1) 的哈希表。其关键在于选择一个良好的哈希函数。如果一个哈希函数满足:对于任意两个不同的键 xy,其碰撞概率约为 1/L(其中 L 是桶数组的大小),那么哈希表的操作平均复杂度就是常数时间。

然而,这种平均复杂度并不能保证最坏情况下的性能。在某些场景下,我们可能希望查询操作(如 getcontains)在最坏情况下也能保证 O(1) 的时间复杂度,即使这可能需要更长的构建时间。接下来,我们将探讨如何实现这一目标。

完美哈希

完美哈希的目标是构建一个静态哈希表,使得查询操作在最坏情况下也能在常数时间内完成。其核心思想是使用两级哈希结构来彻底避免查询时的碰撞。

基本结构

回忆标准哈希表的链地址法:我们有一个大小为 L 的主数组,每个桶(bucket)是一个链表,存放所有哈希到该位置的键值对。查询时,需要遍历链表,平均链表长度是常数,但最坏情况下可能很长。

完美哈希的改进方法是:将每个桶内的链表替换为另一个小型哈希表。

  1. 第一级哈希:使用一个哈希函数 h 将键映射到主数组的某个桶 i
  2. 第二级哈希:对于第 i 个桶,我们为其分配一个专属的哈希函数 g_i 和一个独立的小型数组。这个小型哈希表被设计为无碰撞的。

如何实现无碰撞的小哈希表?

要使一个存放 n_i 个元素的小哈希表无碰撞,一个经典方法是让该哈希表的大小 m_i 约等于 n_i 的平方(即 m_i ≈ n_i²)。如果从一个“通用哈希函数族”中随机选择哈希函数 g_i,那么构建出无碰撞哈希表的概率将超过 1/2。因此,我们可以多次尝试选择不同的 g_i,直到找到一个能产生无碰撞映射的函数。由于成功概率高,这个过程通常很快。

内存占用分析

你可能会问:为什么不直接构建一个巨大的无碰撞哈希表?因为那需要 O(n²) 的内存,代价太高。

在完美哈希中,我们只对小哈希表使用平方级空间。总内存消耗是各个小哈希表大小之和:S = Σ (n_i²)。我们可以证明,如果主哈希函数 h 是良好的,那么 S 的期望值是 O(n)。通过重新选择主哈希函数 h,我们可以以高概率保证总内存消耗保持在 O(n) 线性范围内。

操作流程

  1. 构建阶段
    • 选择一个主哈希函数 h
    • 将所有元素分配到主数组的各个桶中。
    • 检查总内存消耗 S。如果过大,则重新选择 h
    • 对每个非空桶,尝试不同的哈希函数 g_i,直到构建出无碰撞的小哈希表。
  2. 查询阶段
    • 计算 i = h(key)
    • 在桶 i 对应的小哈希表中,计算 j = g_i(key)
    • 直接访问小哈希表数组的第 j 个位置获取值。由于无碰撞,此操作是严格的 O(1)

总结:完美哈希通过“以空间换时间”和“两级哈希”的策略,将最坏情况下的查询时间复杂度优化到了 O(1),同时保持了总体的线性空间复杂度。它非常适合“一次构建、多次查询”的静态场景。

布谷鸟哈希

布谷鸟哈希是另一种保证最坏情况下 O(1) 查询时间的技术,它在实践中通常比完美哈希更节省空间且常数因子更小。

核心思想

布谷鸟哈希维护两个数组(Array1Array2)和两个哈希函数(h1h2)。每个键 x 在哈希表中有两个候选位置Array1[h1(x)]Array2[h2(x)]。哈希表始终维持一个不变式:任何已存储的键 x 必定存在于它的两个候选位置之一。

查询与插入

  • 查询 get(x):只需检查两个候选位置。如果找到 x,则返回;否则,x 肯定不在表中。这是一个严格的 O(1) 操作。
  • 插入 put(x)
    1. 检查 x 的两个候选位置。如果任一为空,则将 x 放入。
    2. 如果两个位置都被占用,则选择其中一个(例如 Array1[h1(x)]),将原元素 y “踢出”,并将 x 放入该位置。
    3. 现在,被踢出的 y 需要被重新安置到它的另一个候选位置。如果那个位置也被占用,就重复这个“踢出”过程,直到找到一个空位,或者达到一定的循环次数。

处理循环与重建

插入过程中可能会遇到循环,导致无法安置所有元素。这表明当前的两个哈希函数对于当前的元素集合可能导致冲突无法解决。

此时,解决方案是:记录本次插入失败,选择两个新的哈希函数(h1', h2'),然后重建整个哈希表(即取出所有元素,用新哈希函数重新插入)。研究表明,如果哈希函数来自一个“log n 通用哈希函数族”,那么插入失败(需要重建)的概率很低。

特点与权衡

  • 优点:查询速度极快,内存利用率较高(数组负载因子可达约50%)。
  • 缺点:插入操作在最坏情况下可能触发全表重建,虽然平均概率很低。它更适用于查询为主、插入不频繁或可以批量预处理再查询的场景。

总结:布谷鸟哈希通过赋予每个元素两个“巢穴”并允许“踢出”机制,以简洁的方式实现了最坏情况 O(1) 的查询。其性能依赖于哈希函数的性质,在实践中往往表现优异。

布隆过滤器

布隆过滤器是一个完全不同的数据结构,用于表示一个集合。它的特点是占用内存极小,但代价是允许一定的误判率(即可能错误地认为某个元素在集合中)。

为什么需要它?

有时,我们只需要判断一个元素是否在一个超大集合中(例如,检查一个URL是否在黑名单中),并且可以容忍极低概率的错误。使用标准哈希表需要存储所有元素本身,内存开销大。布隆过滤器则允许我们使用远小于元素总大小的内存来完成这个任务。

工作原理

布隆过滤器包含一个长度为 m 的比特数组 B(初始全为0)和 k 个独立的哈希函数 h1, h2, ..., hk

  • 插入 add(x):对于元素 x,计算 k 个哈希值 h1(x) ... hk(x),将比特数组 B 中对应位置全部设为1。
    for i in range(k):
        index = h_i(x) % m
        B[index] = 1
    
  • 查询 contains(x):计算 xk 个哈希值,检查比特数组 B 中所有对应位置是否都为1。
    • 如果所有位都是1,则返回“可能在集合中”。
    • 如果有任何一位是0,则返回“肯定不在集合中”。

误差分析与参数设置

  • 为什么会有误判? 不同的元素可能通过哈希函数将某些相同的比特位设为1。查询一个不存在的元素时,如果它的 k 个哈希位置恰好都被其他元素设为1了,就会发生误判。
  • 如何控制误差? 误判概率 ε 与参数 m(数组大小)、k(哈希函数个数)、n(已插入元素数量)有关。经过优化,可以得到近似关系:
    • 给定 n 和期望的误判率 ε,最优的哈希函数数量 k ≈ ln(2) * (m/n),但更直接的是,所需数组大小 m ≈ -n * ln(ε) / (ln2)²
    • 例如,要存储100万个元素,并希望误判率低于百万分之一(ε=1e-6),大约需要 m ≈ 2.5MB 的比特数组和 k ≈ 10 个哈希函数。

特点与应用

  • 优点:空间效率极高,插入和查询时间都是 O(k),且 k 是常数。
  • 缺点:不支持删除操作(除非使用变体如计数布隆过滤器);存在误判;只能回答“可能在”或“肯定不在”。
  • 应用:网络爬虫去重、缓存穿透防护、数据库查询前置过滤等。

总结:布隆过滤器以允许可控的误判率为代价,实现了极高的空间效率,是处理海量数据成员查询问题的利器。

高级变体:布谷鸟过滤器

最后,我们简要提及结合了布谷鸟哈希和布隆过滤器思想的数据结构——布谷鸟过滤器。它同样用于成员查询,并允许误判。

  • 它存储的是元素的“指纹”(一个短哈希值),而非元素本身。
  • 像布谷鸟哈希一样,每个指纹有两个候选桶位置。
  • 查询时,检查两个位置中是否存在该指纹。
  • 它的空间效率通常比标准布隆过滤器更高,并且支持删除操作(标准布隆过滤器不支持)。

本节课中我们一起学习了三种高级哈希相关技术:完美哈希通过两级哈希保证最坏情况查询速度;布谷鸟哈希通过巧妙的“踢出”机制实现高效查询;布隆过滤器则以微小误判率为代价,实现了极致的空间节省。这些工具丰富了我们在不同场景(追求绝对性能、追求空间效率、允许近似结果)下处理数据集合问题的工具箱。

016:并查集的时间复杂度(反阿克曼函数)

在本节课中,我们将学习并查集数据结构的时间复杂度分析。我们将探讨仅使用路径压缩,以及同时使用按秩合并与路径压缩两种启发式方法时,操作的时间复杂度。课程的核心是理解一个非常高效但分析复杂的结论:一系列操作的摊还时间复杂度是反阿克曼函数级别的。

课程背景与目标

在开始之前,我们先说明开设这些讲座的原因。算法与数据结构领域有许多主题在常规课程中并未涵盖,也很难找到相关的视频讲座。虽然一些论文有所涉及,但阅读论文的人并不多。因此,我们希望将这些经典的算法工作转移到YouTube等社交媒体平台,让任何对算法和数据结构感兴趣的人都能通过视频学习到有趣的知识。

今天,我们将讨论Robert Tarjan关于并查集数据结构的一篇经典论文。论文链接已发布在Codeforces博客中。这篇论文非常出色,但某些部分可能略显复杂。在本教程中,我们将尝试简化证明过程,略去一些边界情况和不重要的常数,使其更易于理解。不过,我们仍然强烈推荐阅读原文。

并查集数据结构简介

并查集,有时也称为不相交集合联合数据结构。它支持两种操作:

  • 查找find(x) 返回包含元素 x 的集合的代表元。
  • 合并union(x, y) 将包含元素 xy 的两个集合合并为一个。

数据结构的当前状态由若干集合构成。find 操作返回元素 x 所在集合的代表元。union 操作则将两个集合合并为一个大集合。

并查集的实现

正如我们在之前的讲座中讨论过的,并查集的实现非常简单。我们将每个集合维护为一棵树。树的根节点就是该集合的代表元。

例如,我们可能有两个集合:一个包含五个元素,另一个包含三个元素。每个集合都是一棵树,其根节点代表该集合。

查找操作的实现是:从元素 x 开始,沿着指向父节点的边向上遍历,直到到达树的根节点。这个根节点就是包含 x 的集合的代表元。

合并操作的实现是:首先,分别对元素 xy 执行 find 操作,找到它们所在树的根节点。然后,将其中一个根节点链接到另一个根节点上。这样,两棵树就合并成了一棵更大的树。

以上就是并查集数据结构的基本实现,它是我们在第一学期学习的最简单的算法之一。

优化启发式方法

为了使操作更高效,我们应用两种启发式方法。

第一种启发式方法是按秩合并。其工作原理是尝试保持树的平衡。当我们合并两个集合时,需要将一棵树的根链接到另一棵树的根。我们总是将较小的树链接到较大的树上。为此,我们为每个节点维护一个称为“秩”的值,可以粗略地理解为树的高度。在合并时,我们比较两个根节点的秩,将秩较小的根链接到秩较大的根上。这个启发式方法有助于保持所有树的平衡,使得树的高度保持在 O(log n) 级别。

第二种启发式方法是路径压缩。其工作原理是:当调用 find(x) 时,我们从 x 开始向上遍历到根节点。同时,我们将路径上所有节点的父指针直接修改为指向根节点。也就是说,对于路径上的每个节点(除了根节点),我们移除它指向其父节点的边,并添加一条直接指向根节点的边。这被称为路径压缩。

关于秩的说明:节点的秩是它所在子树的高度。当我们进行路径压缩时,树的实际高度可能会改变,但我们不会更新节点的秩值,以简化分析。秩仅在合并操作时可能改变。你也可以使用树的大小(节点数量)作为启发式标准,将较小的树链接到较大的树上,这也能达到相同的时间复杂度。

本节课的分析计划

本节课,我们将证明以下内容的时间复杂度:

  1. 如果仅使用路径压缩,时间复杂度是“良好”的。我们稍后会解释“良好”的含义。
  2. 如果同时使用按秩合并和路径压缩,我们将得到一个精确的公式,虽然不简单,但非常高效。

在标准课程中,通常只证明使用路径压缩时复杂度为 O(log n),或者同时使用两种启发式时复杂度为 O(alpha(n))(反阿克曼函数)。今天,我们将证明比这更精确的复杂度。

准备工作与分析设定

首先,为了简化分析,我们只计算所有 find 操作的总时间。因为 union 操作很简单,最多只有 n-1 次,每次 union 需要执行两次 find 和一次链接,所以如果我们优化了 find,也就优化了 union。我们计算总时间是为了得到摊还时间复杂度。

我们假设有 n 个元素和 mfind 操作。为简化证明,我们假设 m 至少为 n,且至多为 n^2。这是为了避免处理边界情况,使证明更清晰。实际上,数据结构在 m 更小或更大时仍然有效。

计算所有 find 操作总时间的最简单方法是整体考虑。首先,执行所有 union 操作(仅用于分析,并非实际算法步骤)。假设我们从单节点开始,按顺序执行所有 union 操作,最终得到一棵包含所有元素的大树。我们在这棵最终的树上为所有节点分配秩。

现在考虑执行 find 操作时会发生什么。当我们从某个节点 x 调用 find 时,我们沿着路径向上走到当前树的根节点(注意,不是最终树的根,而是调用 find 时数据结构的当前根),并应用路径压缩。

为了计算总时间,我们需要计算在所有 find 操作中遍历的所有路径的总长度。每次 find 操作的时间复杂度就是所遍历路径的长度。

我们可以这样计算总和:对于每条边 (x, parent(x)),计算在所有 find 操作中,我们经过这条边的总次数。那么总时间就是所有边的这个次数之和。

仅使用路径压缩的时间复杂度分析

我们首先分析仅使用路径压缩(不使用按秩合并)时的时间复杂度。

我们引入两个“魔法”值:

  • t = m / n
  • z = log(n^2 / m) / log(m / n)

接下来,我们定义一系列序列。序列 jj 从 0 到 z)定义为:0, t^j, 2t^j, 3t^j, ...

现在,我们为每条边分配一个“类”。考虑一条边 (x, y),其中 yx 的父节点,rank(x)rank(y) 是它们的秩。我们定义这条边的类 k 为最小的 j,使得 rank(x)rank(y) 可以位于序列 j 的两个连续值之间。即存在整数 p,使得 p * t^j <= rank(x) < rank(y) <= (p+1) * t^j。如果对于所有 j <= z 都无法找到这样的区间,则将该边的类定义为 z+1

直观理解:我们根据两个端点的秩的差异,将边分到不同的“桶”或类中。差异越小,类编号越小。

当我们执行 find(x) 操作时,会从 x 向上遍历一条路径。我们观察路径上各条边的类。

我们将总时间 T 分为三部分:

  1. T1:对于每个类,考虑路径上属于该类的最后一条边。由于最多有 z+1 个类,所以每次 find 操作最多贡献 z+1 条这样的边。因此,T1 = O(m * (z+1))
  2. T2:对于类 kk <= z)中不是该路径上最后一条的边。分析表明,对于每个节点,其连向父节点的边在类 k 中时,最多经历 t 次路径压缩后,该边的类就会增加(因为秩差变大,无法再容纳于当前类的区间)。而一条边的类最多增加 z 次(从 0 到 z)。因此,每个节点在各类中经历的非最后边遍历总次数为 O(n * t * z)
  3. T3:对于类为 z+1 的边(即秩差非常大的边)。我们需要计算在最终序列(序列 z)中,值不超过 n 的元素个数。通过推导,这个数量级为 O(n^2 / m)。对于每个节点,当其边在类 z+1 时,最多经历这么多次路径压缩后,该边就会因为秩差区间内元素过多而“升级”或不再被频繁遍历。这部分贡献为 O(n * (n^2 / m)),但经过化简,其主要部分可被吸收。

综合以上三部分,并代入 t = m/nz 的定义,经过化简(具体代数步骤略),我们得到总时间复杂度为:
T = O(m * z) = O(m * log(n^2/m) / log(m/n))

这个公式是 nm 的函数。让我们看几个特例:

  • 如果 m ≈ n,那么 z ≈ log n,时间复杂度为 O(m log n),即每次 find 摊还 O(log n)
  • 如果 m ≈ n^2,那么 z ≈ 常数,时间复杂度为 O(m),即每次 find 摊还 O(1)

这表明,即使只使用路径压缩,当 find 操作数量非常多时(相对于 n),每个操作的平均时间会变得非常小,甚至为常数。数据结构随着操作增多而变快。

同时使用按秩合并与路径压缩的时间复杂度分析

现在,我们分析同时使用按秩合并和路径压缩这种更常见、更高效的情况。分析框架类似,但我们将使用不同的序列定义,这些序列与阿克曼函数相关。

阿克曼函数增长极快,其反函数增长极慢。我们定义一系列序列:

  • 序列 0:0, 2, 4, 6, 8, ...(偶数序列)
  • 序列 1:0, 2, 4, 8, 16, 32, ...(2的幂次序列)
  • 序列 2:0, 2, 4, 16, 2^16, 2^(2^16), ...(塔状幂次)
  • 序列 i (i>=1) 的递推定义:A_i(0)=0, A_i(1)=2, A_i(j) = A_{i-1}( A_i(j-1) ) for j>1.

这些序列增长极其迅速。序列 i 是序列 i-1 的子序列。

我们选择 z,使得 A_z(1) > log n。也就是说,在序列 z 中,第一个大于 1 的元素就已经超过了 log n

像之前一样,我们为每条边分配一个类 k:如果边 (x,y)rank(x)rank(y) 能落在序列 k 的两个连续值之间,则其类为 k。否则,类为 z+1

按秩合并的关键性质是:秩为 k 的节点,其子树大小至少为 2^k。这意味着秩不超过 k 的节点总数最多为 n / 2^k

分析过程与仅路径压缩的情况类似,但更复杂:

  1. T1:路径上各类的最后一条边。最多有 z+1 个类,所以 T1 = O(m * (z+1))
  2. T2:各类中的非最后边。通过利用“秩为 k 的节点数最多为 n/2^k”这一性质,我们可以证明,对所有类和所有节点,这部分非最后边被遍历的总次数为 O(n * z)。由于 z 是反阿克曼函数级别,这是一个非常小的倍数。
  3. T3:类为 z+1 的边。由于我们选择的 z 使得序列 z 中大于 log n 的元素我们不再关心,而秩的范围是 0log n,因此这类边的处理次数也可以被很好地控制。

综合起来,总时间复杂度为:
T = O(m * z + n * z) = O((m+n) * alpha(n))
其中 alpha(n) 是反阿克曼函数,增长比对数函数慢得多,对于任何实际应用规模的 nalpha(n) 通常小于 5。

因此,每个操作的摊还时间复杂度是 O(alpha(n)),这几乎是常数时间。

总结

在本节课中,我们一起深入学习了并查集数据结构的时间复杂度分析。

我们首先回顾了并查集的基本实现和两种关键的启发式优化:按秩合并与路径压缩。接着,我们设定了分析目标:计算一系列 find 操作的总时间。

在分析仅使用路径压缩的情况时,我们通过引入参数序列、为边分配类,并将总时间分解为最后边、非最后边和特殊类边三部分,最终推导出时间复杂度为 O(m * log(n^2/m) / log(m/n))。这个公式显示了操作次数 m 对摊还时间的影响。

最后,我们分析了同时使用两种启发式方法的情况。通过引入与阿克曼函数相关的更复杂的序列,并利用按秩合并带来的树大小下界性质,我们证明了其摊还时间复杂度为 O(alpha(n)),其中 alpha(n) 是增长极其缓慢的反阿克曼函数。这使得并查集成为实践中效率极高的数据结构。

尽管证明过程有些复杂,但希望本教程能让你对并查集卓越的效率背后的原因有更深刻的理解。我们再次推荐阅读 Robert Tarjan 的原始论文以获取更严谨的细节。

017:Eval-Link-Update 数据结构

在本节课中,我们将学习一种名为 Eval-Link-Update 的数据结构。它类似于我们之前讨论过的并查集(Union-Find),但功能更强大,支持在动态树结构上高效地计算路径上的聚合值。我们将了解其核心操作、工作原理以及如何通过路径压缩和平衡树技术来优化其性能。

数据结构概述

Eval-Link-Update 数据结构维护一个由多棵树组成的森林。每棵树都有一个根节点,每个节点恰好属于一棵树。每个节点 v 都存储一个值,我们称之为标签 label(v)

该数据结构支持以下三种操作:

  • link(v, w):将节点 w 所在的树连接到节点 v 所在的树下,使 v 成为 w 的父节点。这会将两棵树合并为一棵。
  • eval(v):计算从节点 v 到其所在树根节点的路径上所有节点标签的聚合值。这个聚合操作(记为 )必须是可结合的,即 (a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)
  • update(r, x):更新节点 r 的标签。在本文讨论的版本中,r 必须是其所在树的根节点

我们的目标是高效地支持这些操作,理想情况下能达到接近常数时间的摊还复杂度。

基础实现与路径压缩

上一节我们介绍了数据结构的基本形态和操作。本节中,我们来看看如何利用一个简单的技巧——路径压缩——来实现这些操作。

路径压缩的思想与我们之前在并查集中使用的类似。当我们执行一次 eval(v) 操作时,我们会沿着从 v 到根节点 r 的路径遍历,并计算聚合结果。之后,我们可以将这条路径上的所有节点(除了根节点)直接连接到根节点 r 上,从而“压缩”这条路径。

以下是执行 eval(v) 并应用路径压缩的步骤:

  1. 从节点 v 开始,向上遍历到根节点 r,记录路径上所有节点的标签:a0, a1, ..., ak,其中 a0 = label(v)ak = label(r)
  2. 计算聚合结果 result = a0 ⊕ a1 ⊕ ... ⊕ ak
  3. 为了在压缩后保持 eval 结果不变,我们需要更新路径上(除根节点外)每个节点的标签。新的标签应使得从该节点到根的新路径的聚合结果等于旧路径的结果。
    • 我们可以从路径的末端(靠近根)向前计算新的标签值。
    • 例如,对于路径上的倒数第二个节点,其新标签应设为 a_{k-1} ⊕ ak,这样从它到根(现在直接相连)的结果 (a_{k-1} ⊕ ak) ⊕ (根标签) 在结合律下等于旧结果。
  4. 最后,将路径上所有节点(除根节点外)的父指针直接指向根节点 r

link(v, w) 操作很简单,只需将 w 的父指针设为 v 即可。
update(r, x) 操作也很直接,因为 r 是根节点,我们只需修改 label(r)

如果树是平衡的,这种朴素的路径压缩方法能带来很好的摊还时间复杂度(逆阿克曼函数级别)。但如果树不平衡(例如退化成链),单次 eval 操作在最坏情况下可能是 O(n)

实现平衡:具有逆元的操作

上一节我们看到,简单的路径压缩在树不平衡时效率不高。本节中,我们来看看一种特殊情况:当聚合操作 对每个元素都存在逆元时,如何构建一棵始终平衡的“虚拟树”来保证高效性。

我们要求对于任何标签值 x,都存在一个逆元 x^{-1},使得 x ⊕ x^{-1} = e,其中 e 是该操作的单位元(例如,加法中的 0,乘法中的 1)。一个常见的例子是整数加法,其逆元就是相反数(-x)。

核心思路是,我们并不直接维护原始的“真实树”,而是维护一棵与之等价的、平衡的虚拟树。这棵虚拟树包含所有相同的节点,但结构经过调整以保证平衡。我们确保在这棵虚拟树上计算 eval 的结果与在真实树上计算的结果完全相同。

以下是构建和维护平衡虚拟树的关键技巧:在 link(v, w) 时,我们模仿并查集的“按大小合并”启发式策略。

  • 我们跟踪每棵虚拟树的大小(节点总数)。
  • 当需要连接 vw 时,我们比较 size(v)size(w)
  • 如果 size(w) <= size(v),我们按原计划连接(w 成为 v 的子节点)。
  • 如果 size(w) > size(v),我们则反转连接方向,让 v 成为 w 的子节点。为了保持 eval 结果不变,我们必须相应地调整 vw 的标签:
    • label(w) = label(w) ⊕ label(v)
    • label(v) = label(v)^{-1} ⊕ label(w)^{-1} (或根据操作顺序调整)
  • 通过总是将较小的树连接到较大的树下,我们可以保证虚拟树的高度是 O(log n) 的。

由于虚拟树是平衡的,在其上应用路径压缩就能获得优异的摊还复杂度(O(α(n)),其中 α 是逆阿克曼函数)。

处理更一般的操作(如最大值)

上一节的方法依赖于聚合操作存在逆元。但对于像最大值max)这样的操作,逆元并不存在。本节中,我们探讨如何处理这类更一般的操作。

对于 max 操作,我们采用一种不同的策略。我们不再将整棵树维护为一棵平衡树,而是将其维护为一系列平衡链的序列。

我们维护以下结构:

  • 将原始树中的每个连通分量(树)表示为一系列平衡的“小树”。
  • 这些小树通过它们的根节点连接成一个序列。
  • 关键性质:这个序列中,根节点的标签值是单调非递减的(从左到右递增或相等)。

eval(v) 操作现在只需在 v 所在的小树内部进行路径压缩,计算到其小树根节点的聚合值。由于序列中右边小树的根节点标签值更大,从 v 到全局根节点的实际最大值其实就是 v 所在小树根节点的值(或者序列中更右侧的某个根节点值,取决于实现)。这个性质保证了正确性。

update(r, x) 操作(r 是全局根)需要更新 label(r)。如果新值 x 大于旧值,我们可能需要:

  1. 找到序列中所有根节点标签值 <= x 的小树。
  2. 将这些小树的根节点标签也更新为 x(因为 x 现在成为了路径上的新最大值)。
  3. 将这些小树合并成一棵新的平衡小树。合并时使用“小树连大树”的启发式方法以保持平衡。

link(v, w) 操作更为复杂,但核心思想是考虑两个节点所属的整个集合(所有小树)的总大小。总是将总大小较小的集合合并到较大的集合中,并调整小树序列的顺序以维持根节点标签的单调性。在合并过程中,可能会触发类似 update 中的子树合并操作。

通过精细的势能分析,可以证明即使对于 max 操作,每个操作的摊还时间复杂度也能达到接近常数(O(α(n)))。

复杂度分析与总结

本节课中,我们一起学习了 Eval-Link-Update 数据结构。

我们首先定义了它的三个核心操作:link(合并树)、eval(计算路径聚合值)和 update(更新根标签)。然后,我们看到了如何通过路径压缩这一基础技术来实现它们。

接着,我们探讨了两种优化策略以获得优异的摊还复杂度:

  1. 对于存在逆元的操作(如加法):通过维护一棵平衡的虚拟树,并在 link 时使用“按大小合并”策略,可以保证 O(α(n)) 的时间复杂度。
  2. 对于一般结合操作(如最大值):通过将树组织成一系列根节点标签有序的平衡链,并在 linkupdate 时谨慎地合并链以保持平衡和单调性,同样可以达到 O(α(n)) 的摊还复杂度。

这种数据结构虽然比简单的并查集复杂,但它提供了在动态树上进行路径查询和更新的强大能力,在某些特定算法(如动态最小生成树)中非常有用。其核心思想——结合路径压缩、平衡启发式合并以及势能分析——是高级数据结构设计中的经典范例。

018:线段树

在本节课中,我们将学习一种名为“线段树”的强大数据结构。线段树主要用于处理数组上的区间查询和单点更新操作,并且能够在对数时间内完成这些操作。我们将从线段树的基本概念开始,逐步学习其构建、更新和查询方法,并探讨其可以支持的各种操作。

线段树是什么?

线段树是一种二叉树数据结构,用于高效处理数组的区间查询和单点更新问题。它之所以得名,是因为树中的每个节点都对应原始数组中的一个连续区间(或“线段”)。

线段树可以解决以下核心问题:你有一个包含 n 个元素的数组 a,需要支持两种操作:

  1. set(i, v):将位置 i 的元素值修改为 v
  2. sum(l, r):计算并返回数组中从索引 l(包含)到 r(不包含)这个区间内所有元素的和。

我们的目标是让这两种操作都比线性时间 O(n) 更快。线段树可以在 O(log n) 时间内完成这两种操作。

构建线段树

线段树是一棵完全二叉树。我们假设数组的长度 n 是 2 的幂次(如果不是,可以通过添加不影响结果的“中性元素”来扩展到最近的 2 的幂次,这不会改变算法的渐近复杂度)。

构建过程如下:

  • 树的叶子节点对应原始数组的每个单个元素。
  • 每个内部节点存储其两个子节点所代表区间的值的“和”(或我们定义的其他操作结果)。
  • 根节点代表整个数组区间 [0, n)

例如,对于数组 a = [5, 1, 3, 2, 6, 12, 4, 5],其用于求和的线段树结构如下图所示(数值为对应区间的和):

                  [0,8):38
                 /        \
          [0,4):11       [4,8):27
          /    \          /    \
    [0,2):6 [2,4):5 [4,6):18 [6,8):9
     / \     / \     / \     / \
   5   1   3   2   6  12   4   5

这样,我们就将整个数组的信息以分层的方式组织了起来。

实现更新操作 (set)

当我们想要更新数组中的一个元素(例如 set(i, v))时,我们需要更新所有包含该元素的线段树节点。

更新算法步骤如下:

  1. 从代表该单个元素的叶子节点开始。
  2. 更新该叶子节点的值。
  3. 沿着路径向上回溯到根节点。
  4. 对于路径上的每个父节点,根据其更新后的子节点值重新计算其存储的值(例如,重新计算和)。

由于树的高度是 O(log n),因此我们只需要更新 O(log n) 个节点,这使得更新操作非常高效。

伪代码描述(递归版本):

def set(node, node_l, node_r, i, v):
    # node: 当前节点索引
    # node_l, node_r: 当前节点代表的区间 [node_l, node_r)
    # i: 要更新的数组位置
    # v: 新值
    if node_l == i and node_r == node_l + 1: # 到达叶子节点
        tree[node] = v
        return
    mid = (node_l + node_r) // 2
    if i < mid: # 目标在左子树
        set(node*2 + 1, node_l, mid, i, v)
    else:       # 目标在右子树
        set(node*2 + 2, mid, node_r, i, v)
    # 更新当前节点的值(例如,求和)
    tree[node] = tree[node*2 + 1] + tree[node*2 + 2]

实现区间查询操作 (sum)

查询区间 [l, r) 的和时,我们不需要遍历区间内的每个元素。相反,我们可以利用线段树中预计算好的区间和来组合出答案。

查询算法核心思想:
我们从根节点开始进行递归遍历,并应用以下两个优化规则:

  1. 完全无关:如果当前节点代表的区间 [node_l, node_r) 与查询区间 [l, r) 完全没有交集,则直接返回,不继续向下递归。
  2. 完全包含:如果当前节点代表的区间完全包含在查询区间内(即 l <= node_lnode_r <= r),则直接将该节点存储的预计算值(区间和)加入最终结果,并返回,不再向下递归。
  3. 部分重叠:如果上述两种情况都不满足,说明当前区间与查询区间部分重叠。此时,我们递归地查询其左子节点和右子节点,然后将两者的结果合并。

为什么这样高效?

  • “完全无关”和“完全包含”的节点会立即终止递归,避免了遍历整个树。
  • 在每一层树中,只有最多 O(log n) 个节点会进入“部分重叠”的情况(这些节点是查询区间边界 lr 所在的节点)。因此,总共访问的节点数约为 O(log n)

伪代码描述(递归版本):

def sum(node, node_l, node_r, l, r):
    # 查询区间 [l, r) 在节点 node 所代表区间 [node_l, node_r) 内的和
    if node_l >= r or node_r <= l: # 情况1:完全无关
        return 0 # 对于求和操作,空区间的中性元素是0
    if l <= node_l and node_r <= r: # 情况2:完全包含
        return tree[node]
    # 情况3:部分重叠
    mid = (node_l + node_r) // 2
    left_sum = sum(node*2 + 1, node_l, mid, l, r)
    right_sum = sum(node*2 + 2, mid, node_r, l, r)
    return left_sum + right_sum

线段树支持的其他操作

线段树的强大之处在于,它不仅仅能用于求和。只要一个二元操作 op 满足结合律(即 (a op b) op c = a op (b op c)),我们就可以用线段树来维护它。

常见的满足结合律的操作包括:

  • 求和a + b
  • 乘积a * b
  • 最大值max(a, b) (中性元素为 -∞
  • 最小值min(a, b) (中性元素为 +∞
  • 最大公约数 (GCD)gcd(a, b)
  • 按位与/或/异或a & b, a | b, a ^ b

要将线段树从求和改为其他操作,只需修改代码中的三处:

  1. 构建和更新时,合并子节点值的操作(如将 + 改为 max)。
  2. 查询时,合并左右子树结果的操作。
  3. 查询中“完全无关”情况下返回的中性元素(对于 max-∞,对于 min+∞,对于求和是 0)。

可持久化线段树简介

上一节我们介绍了标准线段树的基本操作,本节中我们来看看一个高级概念:可持久化

可持久化数据结构能够保留其所有历史版本。当你对其进行修改时,它会创建一个新的版本,同时旧的版本仍然可以被访问和查询。

如何实现可持久化线段树?
核心思想是路径复制。当更新一个节点时,我们并不直接修改它,而是创建该节点的一个新副本,在新副本上修改值。然后,为了保持树的连接,我们需要创建其父节点的新副本,并让其指向新的子节点,如此递归向上直到根节点。这样,旧版本的根节点仍然指向旧的节点结构,而新版本的根节点指向新的节点结构。

优点与代价:

  • 优点:可以访问任意历史版本的状态,这在某些问题中非常有用(例如,查询某个历史时刻的区间信息)。
  • 代价:每次更新会创建 O(log n) 个新节点,增加了空间消耗。实现上需要使用指针或引用来连接节点,而不是简单的数组索引。

总结

本节课中我们一起学习了线段树这一重要的数据结构。

  • 我们首先了解了线段树解决的核心问题:数组的单点更新区间查询
  • 然后,我们学习了如何构建线段树,以及如何在对数时间内实现 setsum 操作,关键在于利用树的层次结构和递归中的剪枝优化。
  • 接着,我们认识到线段树的通用性,它能够支持任何满足结合律的二元操作,如最大值、最小值、GCD等。
  • 最后,我们简要介绍了可持久化线段树的概念,它通过路径复制技术来保存数据结构的所有历史版本。

线段树是许多更复杂数据结构(如树状数组、树链剖分等)的基础,理解其原理对后续学习至关重要。

019:线段树与延迟传播

在本节课中,我们将继续学习线段树。如果你看过Codeforces上关于线段树的课程,那么本节课的第一部分内容会与第二课完全相同。我们将回顾线段树的基本概念,然后深入探讨一种更强大的技术——延迟传播,它允许我们高效地对整个区间进行修改操作。

线段树回顾

上一节我们讨论了以下问题:我们有一个数组,需要处理两种类型的查询。第一种查询是修改单个元素的值,例如 set(i, v)。第二种查询是计算某个区间上的聚合值,例如区间和、区间最小值,或任何其他满足结合律的函数。

我们讨论了如何构建线段树,使得这两种操作都能在对数时间内完成。

引入新的操作类型

今天,我们将再次使用线段树,但尝试处理不同类型的操作。我们将处理两种操作:

  1. 修改操作:对整个区间 [L, R] 内的所有元素加上一个值 v。即,对于所有 i 属于 [L, R],执行 a[i] += v
  2. 查询操作:获取某个位置 i 的当前元素值。

如果使用简单的数组,查询操作可以在常数时间内完成,但修改操作需要遍历区间内的所有元素,时间复杂度是线性的。我们希望让修改操作更快,同时也能在良好时间内获取元素值。我们的目标是让这两种操作都在对数时间内完成。

基础思路:存储“待加”值

我们将像上一讲那样构建线段树。例如,对于一个有8个元素的数组,我们构建一个满二叉树,叶子节点对应数组的每个元素。初始时,树中所有节点的值都为0。

每个节点对应数组的一个区间。我们将存储在节点中的数字解释为“需要加到该节点对应区间所有元素上的值”。

例如,如果我们在某个节点存储了数字5,就意味着我们需要将5加到该节点对应区间的所有元素上。

区间修改操作

现在,我们来看如何执行区间修改操作。假设我们想对某个区间 [L, R] 的所有元素加上值 v(例如5)。

操作步骤如下:

  1. 我们将目标区间 [L, R] 分割成若干个部分,使得每个部分都完全包含于线段树的某个节点所代表的区间内。这与上一讲中计算区间和时的分割方法完全相同。
  2. 对于每一个被完全包含的节点,我们直接将值 v 加到该节点存储的“待加”值上。

这样,我们就完成了对整个区间的修改,而无需实际遍历区间内的每一个元素。寻找这些节点的递归过程与上一讲完全一致,因此时间复杂度也是 O(log n)

以下是递归函数的伪代码:

def add(node, node_l, node_r, query_l, query_r, v):
    if node_r <= query_l or query_r <= node_l: # 区间无交集
        return
    if query_l <= node_l and node_r <= query_r: # 节点区间完全被包含
        tree[node] += v
        return
    mid = (node_l + node_r) // 2
    add(node*2, node_l, mid, query_l, query_r, v)
    add(node*2+1, mid, node_r, query_l, query_r, v)

单点查询操作

接下来,我们如何获取某个位置 i 的当前值呢?

思路很简单:一个元素的最终值,等于初始值加上所有影响到它的修改操作的值。在线段树中,所有会影响元素 i 的修改操作,都存储在该元素对应叶子节点到根节点的路径上的节点中。

因此,要查询元素 i 的值,我们只需从根节点出发,沿着通向叶子 i 的路径向下走,将路径上所有节点存储的“待加”值累加起来,再加上元素的初始值,就得到了当前值。这个操作同样只需要访问 O(log n) 个节点。

从具体操作到抽象操作

上一节我们介绍了“区间加值”和“单点查询”。但线段树的能力远不止于此。我们可以将思路抽象化,以支持更多种类的操作。

假设我们有一个抽象的操作 modify(L, R, param),它会对区间 [L, R] 内的所有元素施加某种变换,参数是 param。同时,我们还有一个查询函数 get(L, R),用于计算区间 [L, R] 上的某个聚合值(如和、最小值等)。

为了我们的算法能正常工作,这些操作需要满足一些性质。

首先,修改操作本身需要是可结合的。这意味着如果我们先施加操作 X,再施加操作 Y,其结果应该等同于施加一个组合后的操作 ZZ = combine(X, Y))。加法操作显然是可结合的,因为 (a + 5) + 2 = a + (5 + 2)

其次,在计算最终元素值时,我们隐含地假设了操作的可交换性。在之前的例子中,我们查询元素值时,只是简单地将路径上的所有“加值”求和,这相当于假设了加法操作的顺序不影响结果(5 + 2 = 2 + 5)。然而,并非所有可结合的操作都是可交换的。

一个常见的、可结合但不可交换的操作是赋值操作set value)。如果你先赋值 x,再赋值 y,最终结果是 y。但如果顺序反过来,先赋值 y 再赋值 x,结果就是 x。顺序至关重要。

那么,如何处理这种不可交换的操作呢?答案是:我们需要在线段树中维护操作的顺序

延迟传播:维护操作顺序

延迟传播是一种强大的技术,它允许我们处理不可交换的操作,同时保持高效性。

核心思想是:我们保证对于树中的任何节点,所有需要应用到该节点对应区间的操作,都按照从底向上(从旧到新)的顺序存储。也就是说,在从叶子到根的路径上,越靠近叶子的操作越旧,越靠近根的操作越新。

当我们计算一个元素的值时,我们从叶子开始,自底向上地按顺序应用路径上的所有操作,这样就能得到正确的结果。

但是,当我们进行新的修改时,可能会破坏这个顺序。例如,一个旧操作 X 存储在一个父节点,而一个新的操作 Y 需要应用到它的某个子节点所代表的区间。如果我们简单地把 Y 加到子节点,那么在计算时,我们会先应用 Y(在子节点),再应用 X(在父节点),这顺序就反了。

延迟传播通过“将操作向下推”来解决这个问题。其原则是:只有当我们需要访问一个节点的子节点时,才将该节点存储的操作应用到它的子节点上

具体过程如下:

  1. 假设节点 p 存储了一个操作 X
  2. 当我们需要递归进入 p 的左孩子或右孩子时,我们首先进行“传播”:
    • 将操作 X 与左孩子当前存储的操作 Y_left 组合,得到新操作 Z_left = combine(X, Y_left),并将其存储到左孩子。
    • 同样,将操作 X 与右孩子当前存储的操作 Y_right 组合,得到 Z_right = combine(X, Y_right),存储到右孩子。
    • 清空节点 p 存储的操作(或标记为已传播)。
  3. 这样,操作 X 就被“推”到了更深的层,并且与子节点原有的操作以正确的顺序(先旧后新)组合了起来。

通过这种方式,我们始终能保持操作在树中的正确顺序。每次传播只涉及常数时间的操作(组合两个操作)。

结合区间查询:区间加值与区间最小值

现在,让我们把延迟传播技术和上一讲的区间查询功能结合起来。我们希望数据结构支持两种操作:

  1. add(L, R, v): 给区间 [L, R] 的所有元素加上 v
  2. query_min(L, R): 查询区间 [L, R] 的最小值。

我们在线段树的每个节点维护两个值:

  • min_val[node]: 该节点对应区间的最小值(不考虑存储在该节点及其祖先节点上的“待加”操作)。
  • add_val[node]: 需要加到该区间所有元素上的值(即延迟标记)。

区间加值操作 add:

  1. 与之前类似,递归地找到完全包含于 [L, R] 的节点。
  2. 对于这些节点,我们执行:
    • add_val[node] += v
    • min_val[node] += v (因为给区间内每个元素加 v,其最小值也增加 v
  3. 在递归返回时,像普通线段树一样,根据左右孩子的最小值更新 min_val[node]

区间最小值查询 query_min:

  1. 在递归过程中,每当进入一个节点,首先执行延迟传播,将该节点的 add_val 推到它的左右孩子,并更新左右孩子的 min_val
  2. 然后,像普通线段树查询一样,根据查询区间与当前节点区间的关系,递归查询左右子树,并返回结果。

延迟传播函数 propagate(node):

def propagate(node):
    if node 不是叶子节点:
        left_child = node * 2
        right_child = node * 2 + 1
        # 将当前节点的延迟标记推到左右孩子
        add_val[left_child] += add_val[node]
        min_val[left_child] += add_val[node]
        add_val[right_child] += add_val[node]
        min_val[right_child] += add_val[node]
        # 清空当前节点的延迟标记
        add_val[node] = 0

通过在每个递归操作的开始调用 propagate,我们确保了查询和修改过程中信息的正确性。

抽象操作所需性质总结

如果我们希望支持一个抽象的修改操作 modify_op 和一个抽象的查询操作 query_op(例如区间赋值和区间和),那么它们需要满足以下性质才能使延迟传播线段树正常工作:

  1. 结合律: modify_op 必须是可结合的。即 combine(combine(a, X), Y) = combine(a, combine(X, Y)),这允许我们将多个操作合并。
  2. 分配律: modify_op 必须能分配到 query_op 上。这是最关键的性质。它意味着:先对区间内每个元素应用修改操作 X,再进行区间查询 query_op,得到的结果,应该等于先进行区间查询 query_op,再对查询结果应用同一个修改操作 X(以某种方式定义在聚合值上)。
    • 例如,对于“区间加值”和“区间求和”:sum(a[i] + v) = sum(a[i]) + length * v。这里,对每个元素加 v 再求和,等于先求和再加 (区间长度 * v)v 被“分配”到了求和操作上。
    • 对于“区间赋值”和“区间求和”:这个性质不成立,因为赋值操作会覆盖原有值,不能简单地分配到求和上。对于“区间赋值”和“区间最大值/最小值”,性质是成立的。

如果一对操作不满足分配律,通常不能直接用标准的延迟传播线段树处理,可能需要更复杂的技巧或不同的数据结构。

应用示例:离线查询区间不同元素个数

让我们看一个利用线段树(结合持久化技术)解决的有趣问题:离线处理多个查询,每个查询问一个区间内有多少个不同的数字

离线算法思路(莫队算法思想变种):

  1. 我们维护一个数组 B,初始全为0。
  2. 考虑所有左端点相同的查询。对于左端点 L,我们将 B 中每个数字在区间 [L, n) 内第一次出现的位置标记为1,其余为0。
    • 例如,对于数组 [5, 1, 3, 5, 1, 4, 3, 4],当 L=0 时,数字5第一次出现在下标0,数字1在1,数字3在2,数字4在5。所以 B = [1, 1, 1, 0, 0, 1, 0, 0]
  3. 对于任何一个左端点为 L,右端点为 R 的查询,答案就是 B[L]B[R] 的和!因为 B 中1的个数正好对应了从 L 开始,每个数字第一次出现的位置。
  4. 现在,我们将左端点 L 向右移动一位到 L+1。我们需要更新数组 B
    • B[L] 设为0(因为左端点移动,原 L 位置不再考虑)。
    • 找到原 a[L] 这个数字在 L 之后下一次出现的位置 next[L],将 B[next[L]] 设为1(因为它现在成了该数字在新区间内的第一次出现)。

算法流程:

  1. 预处理每个位置 i 的下一个相同值的位置 next[i](可用哈希表从右向左扫描完成)。
  2. 将所有查询按左端点 L 分组。
  3. 初始化一个支持单点赋值区间求和的线段树(线段树维护数组 B)。
  4. 从左到右遍历每个左端点 L
    a. 回答所有左端点为 L 的查询(在线段树上查询区间和)。
    b. 将线段树在位置 L 的值设为0。
    c. 将线段树在位置 next[L] 的值设为1(如果 next[L] 存在)。
  5. 时间复杂度:预处理 O(n),每个查询 O(log n),每次左端点移动进行两次线段树更新 O(log n),总复杂度 O((n + m) log n),其中 m 是查询数。

在线查询与持久化线段树

上述算法是离线的,因为它需要预先知道所有查询并按左端点排序。如果问题要求在线查询(即每次给出 L, R 要立即回答),我们可以借助持久化线段树

思路:

  1. 在上述离线算法中,我们实际上为每个可能的左端点 L 构建了一个对应的线段树状态(即当时的数组 B 的状态)。
  2. 如果我们能把这些所有 L 对应的线段树状态都保存下来,那么对于任何一个在线查询 (L, R),我们只需要:
    a. 取出左端点 L 对应的那个版本的线段树。
    b. 在这个版本的线段树上查询区间 [L, R] 的和,即为答案。
  3. 问题在于,直接保存 n 棵完整的线段树需要 O(n^2) 空间,不可行。

解决方案:
使用持久化线段树。在从左向右移动 L 的过程中,从 L 的状态到 L+1 的状态,我们只对线段树进行了两次单点修改操作。持久化线段树在每次修改时,只创建新路径上的 O(log n) 个新节点,并与旧节点共享未修改的部分。

因此,我们可以顺序构建出 n+1 个版本(从 L=0L=n)的持久化线段树,总空间复杂度仅为 O(n log n)。对于每个在线查询,我们只需在对应版本的树上进行区间求和查询,时间复杂度 O(log n)

这展示了持久化数据结构的一个经典应用场景:需要高效访问某个数据结构在历史时刻的状态。

总结

本节课我们一起深入学习了线段树及其高级技巧。

  • 我们首先回顾了线段树处理单点修改和区间查询的基础。
  • 然后,我们学习了如何用线段树处理区间修改单点查询,核心是在节点上存储“待加”的延迟标记。
  • 接着,我们引入了延迟传播这一关键技术,它通过惰性地将标记向下推送,解决了操作顺序和不可交换操作的问题。
  • 我们将延迟传播与区间查询功能结合,实现了能同时高效处理区间修改区间查询的强大数据结构,并以“区间加值/区间最小值”为例说明了实现方法。
  • 我们讨论了抽象操作需要满足的结合律分配律,这是设计自定义延迟传播操作的关键。
  • 最后,我们通过“区间不同元素个数”问题,展示了线段树在离线算法中的应用,并引申出使用持久化线段树来应对在线查询的场景。

线段树是算法竞赛和软件开发中极其重要的数据结构,理解和掌握其基础与变种,对于解决复杂的区间维护问题至关重要。

020:Fenwick Tree 与 Sparse Table

在本节课中,我们将学习两种与线段树相关但略有不同的数据结构:Fenwick Tree(树状数组)和 Sparse Table(稀疏表)。这两种结构都能高效地处理数组上的区间查询问题,但各有其适用场景和优势。

Fenwick Tree(树状数组) 🧮

上一节我们介绍了线段树,本节中我们来看看Fenwick Tree。Fenwick Tree 是一种用于高效计算数组前缀和的数据结构,它支持两种操作:单点更新和前缀和查询,进而可以计算任意区间和。

核心问题与优势

Fenwick Tree 旨在解决以下问题:对于一个初始数组,我们需要支持两种操作:

  1. 单点更新:将数组中第 i 个元素的值增加 v
  2. 区间查询:查询数组中从索引 LRL <= R)的所有元素之和。

其时间复杂度与线段树相同,均为 O(log n)。但Fenwick Tree具有两个显著优势:

  • 更小的常数因子:其操作更简单,在实际运行中通常比线段树更快。
  • 更低的内存消耗:它只需要一个与原数组大小相同的额外数组,而线段树在最坏情况下需要约 4n 的空间。

核心思想

Fenwick Tree 的核心是维护一个大小为 n 的数组 F。数组 F 中的每个元素 F[i] 并不直接存储原数组 A[i] 的值,而是存储原数组 A某个特定区间的和。

这个“特定区间”由一个巧妙的函数 P(i) 定义。F[i] 存储的是原数组 A 中从索引 P(i)i(包含两端)这个区间的和。

关键在于,我们通过精心设计 P(i) 函数,使得无论是进行前缀和查询还是单点更新,都只需要访问大约 log nF 数组中的元素。

神奇的 P(i) 函数

P(i) 函数的定义基于数字 i 的二进制表示。具体规则如下:

  1. 找到数字 i 的二进制表示中最右侧的 0
  2. 将这个 0 及其右侧所有的 1 都变为 0。
  3. 得到的数字就是 P(i)

例如,若 i = 13,其二进制为 1101

  • 最右侧的 0 是第二位(从右向左,0-based索引)。
  • 将该位及其右侧(即最低位)的 1 变为 0,得到 1100,即十进制的 12
  • 因此,P(13) = 12F[13] 存储的就是 A[12] + A[13] 的和。

这个计算可以通过位运算高效完成:
P(i) = i & (i + 1)

操作实现

以下是两种核心操作的实现逻辑。

前缀和查询

要计算原数组前 x 个元素的和(即前缀和 sum(0, x-1)),我们遵循以下步骤:

  1. 初始化结果 result = 0
  2. 令当前索引 idx = x
  3. 只要 idx >= 0,就执行循环:
    • F[idx] 的值加到 result
    • idx 更新为 P(idx) - 1,即 idx = (idx & (idx + 1)) - 1
  4. 循环结束,result 即为所求前缀和。

这个过程可以理解为不断“跳跃”到前一个未覆盖的区间块,并将这些块的和累加起来。

单点更新

当我们要将原数组第 i 个元素的值增加 v 时,需要更新所有包含了 A[i] 的区间块。这些区间块对应的 F 数组索引可以通过以下方式找到:

  1. 令当前索引 j = i
  2. 只要 j < n,就执行循环:
    • v 加到 F[j] 上。
    • j 更新为 j | (j + 1)
  3. 循环结束,所有相关的区间和都已更新。

j | (j + 1) 这个操作的效果是,将 j 的二进制表示中最右侧的 0 变为 1,并将其右侧所有位变为 0。这正是寻找下一个包含 i 的更大区间块的方法。

时间复杂度分析

无论是查询还是更新,while 循环的迭代次数都等于数字 i 的二进制表示中 1 的个数。在最坏情况下,这个数量是 O(log n)。因此,两种操作的时间复杂度都是 O(log n)

局限性

Fenwick Tree 功能相对专一,通常适用于可逆的结合性操作(如加法、乘法、异或)。对于更复杂的操作(如区间赋值、求最值且需要懒更新),线段树是更灵活的选择。


Sparse Table(稀疏表) 📊

上一节我们介绍了支持动态更新的Fenwick Tree,本节中我们来看看处理静态区间查询的利器——Sparse Table。Sparse Table 主要用于解决静态数组上的区间查询问题,即数组元素在预处理后不再改变。

核心问题与对比

Sparse Table 解决的核心问题是:给定一个固定数组,需要高效回答大量的区间查询(例如求区间最小值、最大值、区间和等)。

与线段树对比:

  • 线段树:构建时间 O(n),查询时间 O(log n)
  • Sparse Table:构建时间 O(n log n),查询时间 O(1)

因此,选择哪种结构取决于查询次数 m

  • 如果查询次数 m 很大,Sparse Table 的 O(1) 查询优势明显。
  • 如果查询次数 m 较小,线段树的 O(n + m log n) 总时间可能更优。

核心思想:预计算幂长度区间

Sparse Table 的核心思想是预计算。它预先计算并存储原数组中所有长度为 2 的幂次(1, 2, 4, 8...)的区间的查询结果。

我们用一个二维数组 st 来存储这些结果:

  • st[i][j] 表示原数组中,从索引 i 开始,长度为 2^j 的区间的查询结果(例如最小值)。

构建 Sparse Table

构建过程采用动态规划的思想,通过较小区间的结果组合出较大区间的结果。

以下是构建步骤:

  1. 初始化:对于每个 ist[i][0] = A[i](长度为 2^0 = 1 的区间就是元素本身)。
  2. 递推计算:对于 j1log2(n),对于 i0n - 2^j(确保区间不越界):
    • st[i][j] = combine(st[i][j-1], st[i + 2^(j-1)][j-1])
    • 这里 combine 是查询操作(如 min, max, sum)。它将区间 [i, i+2^j) 分成了两个长度为 2^(j-1) 的子区间 [i, i+2^(j-1))[i+2^(j-1), i+2^j),然后合并它们的结果。

构建过程的总时间复杂度为 O(n log n),因为共有 n log n 个状态,每个状态的计算是 O(1)

区间查询

对于一个查询区间 [L, R],其长度为 len = R - L + 1。Sparse Table 的巧妙之处在于,我们可以用两个预计算的、长度为 2^k 的区间来覆盖它,其中 k = floor(log2(len)),即不大于 len 的最大2的幂次。

这两个区间是:

  1. L 开始,长度为 2^k 的区间:[L, L + 2^k)
  2. R - 2^k + 1 开始,长度为 2^k 的区间:[R - 2^k + 1, R + 1)

可以证明,这两个区间一定覆盖了整个 [L, R] 区间,并且可能有重叠。对于幂等性操作(如 min, max, gcd),重叠不影响结果。因此,查询结果就是:
result = combine(st[L][k], st[R - 2^k + 1][k])

由于 k 可以通过预处理或位运算在 O(1) 时间内得到,因此整个查询操作是 O(1) 的。

适用性与扩展

Sparse Table 完美适用于幂等可结合的运算,例如:

  • 最小值 (min)
  • 最大值 (max)
  • 最大公约数 (gcd)
  • 按位与 (&)、按位或 (|)

对于非幂等但可结合的操作(如区间和),标准的 Sparse Table 无法直接应用,因为重叠区间的值会被重复计算。但可以通过一些扩展技巧(例如结合前缀和,或使用更复杂的分层结构)来实现 O(1) 查询,这通常被称为 “±1 RMQ” 或通过 “分块+预处理” 的思想来实现 O(n) 预处理、O(1) 查询的静态区间和,但这已超出基础 Sparse Table 的范畴。


总结 🎯

本节课中我们一起学习了两种高效的数据结构:

  1. Fenwick Tree (树状数组):一种内存效率极高、代码简洁的数据结构,专为处理单点更新前缀和查询(可推导出区间和)而设计,两种操作均为 O(log n) 时间复杂度。它比线段树更轻量,但功能相对单一。
  2. Sparse Table (稀疏表):一种用于静态数组区间查询的强大工具,支持 O(n log n) 预处理和 O(1) 查询。它特别适合回答大量区间最值类查询,但对于需要更新的场景则不适用。

理解这两种结构的设计思想、优势及局限性,能帮助你在解决实际问题时,根据数据是否静态、操作类型、查询数量等因素,选择最合适的工具。

021:二维线段树问题

在本节课中,我们将要学习如何处理二维问题。我们将介绍两种主要技术:第一种是利用一维数据结构(如线段树)通过扫描线法解决二维查询;第二种是直接构建二维数据结构(如二维线段树)来处理二维操作。内容将尽可能简单直白,让初学者能够看懂。


一维与二维问题

上一节我们讨论了一维线段树。在一维情况下,我们有一个一维数组,并在一维区间上进行查询和计算。

然而,现实问题并不总是一维的。有时我们需要处理二维甚至更高维度的操作。例如,我们可能需要回答基于二维平面的查询。

本节中,我们将学习两种技术:

  1. 如何使用一维数据结构来回答二维查询(扫描线法)。
  2. 如何以构建一维数据结构类似的方式,构建二维数据结构。

技术一:扫描线法

问题引入:矩形覆盖计数

让我们从一个简单的二维问题开始:矩形覆盖计数。
假设平面上有一些矩形。

同时,平面上有一些点。

对于每个点,我们需要计算覆盖该点的矩形数量。

例如,上图中某些点被0个、1个或2个矩形覆盖。

扫描线思想

扫描线技术的核心思想是将二维问题降维为一维问题。
我们创建一条垂直的扫描线。

让这条线从最左侧开始,逐步向右移动。

在移动过程中,我们只维护当前扫描线上的状态,而不是整个平面的状态。这样,我们就把平面分割成了一系列垂直线,并为每条线回答查询。

降维为一维问题

当扫描线移动到某个位置时,例如经过一个查询点。

此时,扫描线内部的问题就变成了一维的:我们有一些线段(矩形在扫描线上的投影),需要查询给定点被多少条线段覆盖。

这是一个经典的一维问题,可以使用线段树解决。

使用线段树维护

我们需要一个支持两种操作的数据结构:

  1. 区间修改:对某个区间内的所有值加1或减1(对应矩形的左边界和右边界)。
  2. 单点查询:查询某个点的当前值。

公式描述操作

  • add(l, r, delta): 对区间 [l, r) 内所有值增加 delta (delta 为 +1 或 -1)。
  • query(pos): 返回位置 pos 的值。

我们可以使用支持懒传播的线段树来实现 O(log n) 的区间加法和单点查询。

算法步骤

以下是算法的主要步骤:

  1. 预处理Y坐标:收集所有矩形的上下边界Y坐标,排序去重。将Y轴划分成若干个小区间(段)。
  2. 构建线段树:基于Y坐标段构建一维线段树,初始所有值为0。
  3. 事件排序:将每个矩形的左边界(x=x1, 操作+1)和右边界(x=x2, 操作-1)以及所有查询点,按X坐标排序。
  4. 扫描处理:按X坐标从小到大处理所有事件。
    • 如果是矩形边界事件,在线段树上对对应的Y区间执行加1或减1操作。
    • 如果是查询点事件,在线段树上查询该点Y坐标对应的值,即为答案。

时间复杂度

  • 排序:O(n log n)
  • 线段树每次操作:O(log n)
  • 总共有 O(n) 个事件
  • 总时间复杂度:O(n log n)

扫描线法的应用:矩形面积并

上一节我们介绍了用扫描线法解决矩形覆盖计数问题,本节中我们来看看一个更复杂的问题:计算多个矩形的总面积并

问题描述

给定多个矩形,计算它们覆盖区域的总面积(重叠部分只算一次)。

从一维问题思考

首先,考虑一维情况:给定若干线段,求它们覆盖的总长度。
我们可以使用线段树,但需要维护一个不同的信息:当前区间内,被覆盖的长度(即值大于0的区间总长度)。

我们需要支持的操作是:

  1. 区间加1或减1(线段出现或消失)。
  2. 查询整个区间内被覆盖的总长度。

线段树节点设计

为了高效计算被覆盖的长度,每个线段树节点需要维护:

  • min_val: 该区间内所有点的最小值(覆盖次数)。
  • cnt_min: 达到最小值的点的总长度(或数量)。

核心思想:整个区间内,值大于0的长度 = 区间总长度 - 值为0的长度。
而值为0的长度,就是 min_val == 0 时的 cnt_min

当对区间执行整体加/减操作时,只需修改 min_valcnt_min 不变。
当合并两个子节点时:

  • 如果 min_left < min_right,则当前节点 min_val = min_left, cnt_min = cnt_left
  • 如果 min_left > min_right,则当前节点 min_val = min_right, cnt_min = cnt_right
  • 如果 min_left == min_right,则当前节点 min_val = min_left, cnt_min = cnt_left + cnt_right

扩展到二维面积并

现在,我们将此一维线段树作为扫描线在Y轴上的数据结构。
算法流程与覆盖计数类似:

  1. 将Y坐标离散化,构建上述支持“区间加减”和“查询非零长度”的线段树。
  2. 将所有矩形的左右边界作为事件(x=x1, +1; x=x2, -1),按X坐标排序。
  3. 从左到右扫描。当扫描线从 x_i 移动到 x_{i+1} 时:
    • delta_x = x_{i+1} - x_i
    • 当前线段树查询到的“被覆盖总长度”为 covered_length
    • 则这部分对总面积的贡献为 delta_x * covered_length
    • 处理在 x_{i+1} 处的事件,更新线段树。

总结

通过扫描线法,我们将二维面积并问题转化为一维区间覆盖长度问题,并使用增强的线段树进行维护。总时间复杂度为 O(n log n)


技术二:二维数据结构

上一节我们学习了如何用一维数据结构处理二维问题,本节中我们来看看如何直接构建二维线段树

二维线段树概述

二维线段树是处理二维数组(矩阵)上区间操作的数据结构。
它支持两种基本操作:

  1. 点更新:将位置 (i, j) 的值增加 v
    increment(i, j, v)
  2. 矩形查询:查询矩形区域 [x1, x2) × [y1, y2) 内所有元素的和。
    sum(x1, y1, x2, y2)

结构构建

二维线段树可以看作“线段树的线段树”。

  1. 首先,我们以矩阵的为元素,构建一棵一维线段树(外层树)。
  2. 外层线段树的每个节点,不再存储一个单一值,而是存储对应行区间的一棵内层线段树
  3. 内层线段树则以为元素,负责管理列区间上的信息。

例如,一个4x4的矩阵:

  • 外层树根节点代表所有行 [0,4),其内层树管理所有列。
  • 外层树左子节点代表行 [0,2),其内层树管理这些行对应的列数据。

每个内层树节点存储的是其对应子矩阵中所有元素的和。

操作实现

点更新 increment(i, j, v)

  1. 在外层树中,从根节点出发,找到所有包含行 i 的节点(路径上的节点)。共有 O(log n) 个。
  2. 对于找到的每一个外层节点,在其对应的内层树中,执行同样的过程:找到所有包含列 j 的节点。每个内层树也有 O(log n) 个节点。
  3. 对所有找到的最终节点(对应某个子矩阵),将其存储的值加上 v

时间复杂度O(log n * log n) = O(log² n)

矩形查询 sum(x1, y1, x2, y2)

  1. 在外层树中,递归查找完全落在行区间 [x1, x2) 内的节点集合。最多有 O(log n) 个不相交的节点。
  2. 对于找到的每一个外层节点,在其内层树中,递归查找完全落在列区间 [y1, y2) 内的节点集合。每个内层树最多返回 O(log n) 个节点。
  3. 将这些内层树节点存储的值(即对应子矩阵的和)相加,得到最终结果。

时间复杂度O(log² n)

扩展到更高维度

这种“分层”思想可以扩展到三维甚至更高维度。

  • 对于三维,可以构建“线段树的线段树的线段树”。
  • 每次操作的时间复杂度为 O(log^d n),其中 d 是维度。

其他数据结构的二维化

同样的分层思想可以应用于其他一维数据结构:

二维树状数组

  • 维护一个二维数组 bit[][]
  • update(i, j, delta):通过两层循环,分别处理 ijlowbit 祖先。
    def update(i, j, delta):
        x = i
        while x < n:
            y = j
            while y < m:
                bit[x][y] += delta
                y += y & -y
            x += x & -x
    
  • query(x, y):查询矩阵 [0, x) × [0, y) 的和,同样使用两层循环。
    def query(x, y):
        res = 0
        i = x
        while i > 0:
            j = y
            while j > 0:
                res += bit[i][j]
                j -= j & -j
            i -= i & -i
        return res
    
  • 矩形和通过二维前缀和容斥计算:sum(x1,y1,x2,y2) = query(x2,y2) - query(x1,y2) - query(x2,y1) + query(x1,y1)

二维稀疏表

  • 预处理一个四维数组 st[kx][ky][i][j],表示从点 (i, j) 开始,宽度为 2^kx,高度为 2^ky 的矩形中的最值(如最小值)。
  • 预处理时间复杂度:O(n² log² n)
  • 空间复杂度:O(n² log² n)
  • 查询矩形最值:将矩形在行和列方向分别用两个2的幂次区间覆盖,共4个子矩形,取最值即可。查询时间 O(1)

二维范围查询:归并树与分数级联

最后,我们介绍另一种解决二维静态范围查询(如矩形内点查询)的数据结构:归并树

问题描述

给定平面上 n 个静态点,需要多次查询:给定一个矩形,输出矩形内所有的点(或计数)。

归并树构建

  1. 将所有点按 x 坐标排序,以此构建一棵外层线段树。
  2. 外层线段树每个节点对应一个 x 坐标区间。节点内存储的是落在这个 x 区间内的所有点,但这些点按 y 坐标排序好。
  3. 构建过程类似归并排序:叶子节点是单个点。父节点的有序列表通过合并两个子节点的有序列表得到。总构建时间为 O(n log n)

查询操作

查询矩形 [x1, x2) × [y1, y2)

  1. 在外层树中,找到 O(log n) 个完全覆盖 [x1, x2) 的节点。
  2. 对于每个找到的节点,其内部存储了一个按 y 排序的数组。我们需要在这个数组中找出所有 y 坐标在 [y1, y2) 内的点。
  3. 这可以通过在有序数组上二分查找 y1y2 的位置来实现。在每个节点上二分查找需要 O(log n) 时间。

总时间复杂度O(log² n + k),其中 k 是输出点数。

优化:分数级联

为了将查询时间优化到 O(log n + k),可以使用分数级联技术。

  • 核心思想:避免在每个节点都进行二分查找。
  • 实现:在外层树的根节点对 y1y2 进行一次二分查找,得到位置 p1p2
  • 当递归进入子节点时,我们不需要重新二分。因为子节点的有序列表是父节点列表的子集,我们可以通过预计算好的“指针”或“索引”,在 O(1) 时间内从父节点的位置推导出在子节点列表中的对应位置。
  • 这样,整个查询过程只需要在根节点进行一次 O(log n) 的二分,后续在 O(log n) 个节点上都能以 O(1) 时间确定范围。

动态性说明

归并树通常用于处理静态点集。如果点集需要动态增删,结构会变得复杂,可能需要用平衡二叉搜索树(如Treap、Splay)替代每个节点中的有序数组,但实现难度显著增加。


总结

本节课中我们一起学习了处理二维问题的关键技术:

  1. 扫描线法:将二维问题通过扫描降维,利用一维线段树解决。适用于矩形覆盖计数、面积并等问题。
  2. 二维线段树:通过“线段树套线段树”的分层结构直接处理二维更新与查询。操作时间复杂度为 O(log² n)
  3. 其他二维化数据结构:树状数组和稀疏表也可以通过类似思想扩展到二维。
  4. 归并树与分数级联:用于高效处理二维静态范围查询,通过预排序和索引优化查询速度。

掌握这些技术,你就能应对许多常见的二维平面上的算法问题。

022:二叉搜索树与AVL树

在本节课中,我们将要学习一种非常重要的数据结构——二叉搜索树。我们将了解它的基本概念、核心操作,以及如何通过AVL树这种平衡策略来保证其操作效率。课程内容从基础定义开始,逐步深入到插入、删除、查找等操作的实现,最后介绍保持树平衡的AVL树旋转机制。

什么是二叉搜索树?🌳

二叉搜索树是计算机科学中一个非常重要的数据结构,它是许多其他数据结构和算法的基础。

二叉搜索树最常见的应用是实现映射和集合这两种数据结构。集合包含一组元素,你可以向其中添加元素、移除元素,以及检查某个元素是否存在于集合中。映射则是从一个对象到另一个对象的映射关系,例如在Java中,它类似于键值对。

我们已经学习过可以使用哈希表来实现集合和映射。那么,为什么还要使用二叉搜索树呢?在某些方面,二叉搜索树比哈希表更强大。如果只需要基本的添加、删除和查找操作,哈希表通常更快且更简单。但是,如果你需要一些我们稍后会讨论的额外操作,就可能需要使用二叉搜索树。

二叉搜索树的定义与性质 📝

要实现二叉搜索树,你需要能够比较两个不同的键。存储在树中的所有键必须是可相互比较的。你需要一种方法来检查一个键是否小于另一个键。对于数字,直接比较即可;对于对象,则需要特定的比较方式,例如字符串可以按字典序比较。本质上,你需要一个接收两个参数并判断第一个参数是否小于第二个参数的函数。

二叉搜索树是一种二叉树。二叉树意味着树中的每个节点最多有两个子节点:一个左孩子和一个右孩子。有些节点可能只有左孩子或只有右孩子,也可能没有孩子。

二叉搜索树有一个简单的性质:对于树中的任意节点X,其左子树中的所有元素都小于X,而其右子树中的所有元素都都大于X。这个性质有助于我们高效地查找元素。

基本操作:查找 🔍

查找是二叉搜索树的基本操作。我们来看看如何检查一个给定元素是否存在于树中。

假设我们要在树中查找键 13。我们从树的根节点开始。根节点的键是 10,而我们要找的 13 大于 10,因此目标元素(如果存在)必然在右子树中。于是我们进入右子树。在右子树的节点上,我们看到键 25。因为 13 小于 25,所以目标元素必然在当前节点的左子树中。我们进入左子树,在这里找到了键为 13 的节点,查找成功。

这是一个递归过程:从根节点开始,每次比较当前节点的键与目标键。如果目标键小于当前节点的键,则进入左子树;如果大于,则进入右子树;如果相等,则找到目标。如果最终到达了一个空子树(没有孩子),则说明目标元素不存在于树中。

查找操作的时间复杂度取决于树的高度。树的高度是从根节点到最远叶子节点的最长路径上的节点数。在最坏情况下,如果树退化成一条链(每个节点只有一个孩子),高度就是 O(n),查找操作变为线性时间。在最好情况下,如果树是完全平衡的,高度就是 O(log n),查找操作是对数时间。

基本操作:插入 ➕

接下来我们看看如何向二叉搜索树中添加新元素。

假设我们要添加元素 18。我们首先需要为这个新元素找到合适的位置。查找位置的过程与查找操作类似:从根节点开始,比较键值。18 大于根节点的 10,所以进入右子树。在右子树的节点 25 上,18 小于 25,所以进入其左子树。在这个左子树的节点 13 上,18 大于 13,应该进入其右子树。但此时节点 13 的右孩子为空,这正是新元素 18 应该插入的位置。于是,我们在此处创建一个新的右孩子节点,并将 18 放入其中。

插入操作也是一个递归过程,其时间复杂度同样取决于树的高度。

基本操作:删除 ➖

从二叉搜索树中删除元素稍微复杂一些。

假设我们要删除节点 X。问题在于,如果 X 有两个孩子,删除后需要处理这两个子树。最简单的处理方式如下:

  1. 找到节点 X 的后继节点 Y,即右子树中最小的元素。要找到最小元素,只需从右子树的根开始,一直向左走,直到没有左孩子为止。
  2. 将节点 Y 的键值与节点 X 交换。
  3. 现在,原来的节点 Y 位置(现在是原来的键值)需要被删除。由于 Y 是右子树中的最小节点,它最多只有一个右孩子(不可能有左孩子,否则那就不是最小节点了)。因此,我们可以简单地用 Y 的右子树(如果存在)替换 Y 原来的位置。

删除操作的时间复杂度也取决于树的高度。

平衡的重要性 ⚖️

如前所述,所有基本操作的时间复杂度都依赖于树的高度。如果树退化成链状,高度为 O(n),操作就是线性时间,这和使用简单数组进行线性搜索的效率一样,并不理想。我们期望树的高度是 O(log n),这样所有操作都能在对数时间内完成。

完全平衡的二叉树高度为 O(log n),但我们无法在频繁的插入和删除操作中始终保持树的完全平衡。因此,我们的目标是让树“近似平衡”,使得高度始终保持在 O(log n) 的范围内。这就需要引入“平衡二叉搜索树”的概念。

AVL树:一种平衡策略 🔄

AVL树是实现平衡二叉搜索树的一种简单方法。其平衡基于“旋转”操作。

旋转是一种能改变树的结构,但同时保持二叉搜索树性质(元素顺序不变)的简单操作。考虑两个节点 XYYX 的右孩子),以及它们的子树 ABC。右旋操作将 Y 变为新的根节点,X 变为 Y 的左孩子,同时调整子树 B 的位置,使其成为 X 的右孩子。可以验证,旋转前后树中元素的中序遍历顺序保持不变。

AVL树通过维护一个“平衡因子”来保持平衡。对于树中的每个节点,我们定义其左子树的高度和右子树的高度。AVL树要求对于每个节点,其左右子树的高度差(平衡因子)绝对值不超过1。这个性质保证了树的高度是 O(log n)

AVL树的平衡维护 🛠️

当我们插入或删除一个节点后,可能会破坏AVL树的平衡条件(某个节点的平衡因子变为2或-2)。由于每次只修改一个节点,高度差的变化最多为1,所以不平衡时,高度差恰好为2。

当我们发现某个节点 X 不平衡时(假设右子树比左子树高2),我们需要通过旋转来修复。修复分为四种情况,取决于 X 的右孩子 Y 的平衡情况。以下是两种主要情况的旋转策略:

  1. 右右情况X 的右子树比左子树高2,并且 X 的右孩子 Y 的右子树比左子树高(或等高)。这种情况下,对 X 进行一次左旋即可恢复平衡。
  2. 右左情况X 的右子树比左子树高2,但是 X 的右孩子 Y 的左子树比右子树高。这种情况下,需要先对 Y 进行一次右旋,将其转换为右右情况,然后再对 X 进行一次左旋。

左子树过高的情况与此对称。

在实现插入或删除操作时,我们在递归返回(从修改位置回溯到根节点)的过程中,沿途检查每个节点的平衡因子。一旦发现不平衡的节点,就根据上述情况应用相应的旋转操作来恢复平衡。修复后,该节点及其子树恢复平衡,然后继续向上检查,直到根节点。

二叉搜索树的扩展操作 📈

除了实现集合和映射,二叉搜索树(尤其是平衡二叉搜索树)还支持一些哈希表难以实现的有用操作。

一个重要的操作是查找最接近的元素。例如,在C++的 std::set 中,有 lower_bound 函数,用于查找大于等于给定值 x 的最小元素。在二叉搜索树中实现此功能很简单:从根节点开始,根据比较结果决定向左或向右子树搜索,并沿途记录可能的最佳候选节点。

此外,我们还可以利用二叉搜索树来实现类似于线段树的功能。我们可以为每个节点维护其子树中所有元素的某种聚合信息(如总和、最小值等)。当需要查询某个键值区间 [L, R] 的聚合值时,可以从根节点开始进行递归查询:如果当前节点代表的区间完全在 [L, R] 内,则直接返回该节点的聚合值;如果完全不相交,则返回空值;如果部分相交,则递归查询左右子树并合并结果。由于树的平衡性,这种查询可以在 O(log n) 时间内完成。更重要的是,我们还可以在任意位置插入或删除元素,这是静态线段树无法做到的。通过引入类似线段树的“懒惰传播”机制,我们甚至能在平衡二叉搜索树上实现区间修改操作。

总结 🎯

本节课我们一起学习了二叉搜索树这一基础数据结构。我们从其定义和核心性质出发,详细讲解了查找、插入和删除三大基本操作的原理与实现,并分析了其时间复杂度与树高度的关系。为了确保高效性,我们引入了AVL树这种平衡二叉搜索树,学习了通过旋转操作维护树平衡的机制。最后,我们还探讨了二叉搜索树在范围查找和模拟线段树功能等领域的扩展应用。理解二叉搜索树是掌握更高级树形结构(如红黑树、B树等)的关键一步。

023:Treap与隐式键

在本节课中,我们将学习一种特殊的二叉搜索树——Treap。我们将了解它的结构、如何通过随机化来保持平衡,以及如何实现分裂与合并这两个强大的操作。最后,我们将探讨如何利用Treap实现一个支持按索引操作的动态列表。


Treap的结构与原理

Treap是一种二叉搜索树。为了使其高度保持在对数级别,我们需要一种平衡机制,而Treap就是实现这种平衡的方法之一。

Treap的每个节点包含两个键值:XY。我们维护以下性质:

  • 在左子树中,所有节点的 X 值小于当前节点的 X 值,且所有节点的 Y 值小于当前节点的 Y 值。
  • 在右子树中,所有节点的 X 值大于当前节点的 X 值,且所有节点的 Y 值小于当前节点的 Y 值。

用公式描述,对于一个节点 node

  • node.left.x < node.xnode.left.y < node.y
  • node.right.x > node.xnode.right.y < node.y

如果我们把 XY 看作平面上的坐标,那么所有左子树的节点都在当前节点的左下方,所有右子树的节点都在当前节点的右下方。

Treap 这个名字来源于 Tree(树)和 Heap(堆)。如果我们只看 X 键,它是一棵标准的二叉搜索树;如果只看 Y 键,它满足堆的性质(通常是最大堆,即父节点的 Y 值大于子节点)。

保持平衡的秘诀在于 Y 键。我们为每个节点随机分配一个 Y 值。可以证明,当 Y 值是随机的时候,Treap 的期望高度是 O(log n)。这与快速排序的递归深度是对数级别的原理类似。这是一种随机化的数据结构,其代码实现通常比确定性的平衡树(如AVL树、红黑树)更简单。


核心操作:分裂与合并

除了标准的二叉搜索树操作(插入、删除、查找等),Treap 还支持两个强大的操作:split(分裂)和 merge(合并)。

分裂操作

分裂操作接收一个值 x,将 Treap 分成两棵子树:

  • 第一棵树包含所有 X 键值小于 x 的节点。
  • 第二棵树包含所有 X 键值大于等于 x 的节点。

以下是 split 操作的递归实现思路:

def split(node, x):
    if node is None:
        return (None, None) # 空树分裂为两个空树

    if node.x < x:
        # 当前节点属于左部分
        # 递归分裂右子树
        left_part, right_part = split(node.right, x)
        node.right = left_part # 将分裂出的左部分作为当前节点的右孩子
        return (node, right_part)
    else:
        # 当前节点属于右部分
        # 递归分裂左子树
        left_part, right_part = split(node.left, x)
        node.left = right_part # 将分裂出的右部分作为当前节点的左孩子
        return (left_part, node)

该函数返回两个新树的根节点。由于每次递归调用都沿着树向下进行,且树高为 O(log n),因此时间复杂度为 O(log n)。

合并操作

合并操作接收两棵 Treap AB,其中 A 中所有节点的 X 键值都小于 B 中所有节点的 X 键值。它将这两棵树合并成一棵大的 Treap。

合并时,我们比较两棵树根节点的 Y 值。Y 值较大的节点将成为新树的根。

以下是 merge 操作的实现思路:

def merge(a, b):
    if a is None:
        return b
    if b is None:
        return a

    if a.y > b.y:
        # a 作为根
        a.right = merge(a.right, b) # 合并 a 的右子树和 b
        return a
    else:
        # b 作为根
        b.left = merge(a, b.left) # 合并 a 和 b 的左子树
        return b

同样,合并操作的时间复杂度也是 O(log n)。


基于Treap实现动态列表

上一节我们介绍了Treap的基本操作,本节我们来看看如何用它实现一个功能强大的动态列表。这个列表支持按索引访问、在任意位置分裂以及合并两个列表。

其核心思想是:我们不再使用节点的 X 键来存储具体值,而是用节点在树中的“中序遍历顺序”来隐式地表示其索引(位置)

隐式键

在标准的Treap中,我们通过比较 X 键来决定节点在树中的位置。在隐式键Treap中,节点的位置由其左子树的大小决定。一个节点的“索引”等于其左子树中的节点数加一。

因此,我们需要在每个节点中维护一个 size 字段,表示以该节点为根的子树中的节点总数。这个值可以在分裂和合并操作中动态更新。

列表操作

基于分裂和合并,我们可以实现动态列表的各种操作:

  • 按索引 k 获取元素:从根开始,比较 k 与左子树大小,决定向左还是向右递归查找。
  • 在位置 k 分裂列表:调用 split(root, k)。注意,这里的 split 函数比较的不再是 X 键值,而是左子树的大小与 k 的关系。
  • 合并两个列表:直接调用 merge(a, b)。这要求列表 a 的所有元素在逻辑上排在列表 b 之前。
  • 在位置 k 插入元素
    1. 将原列表在位置 k 分裂成 LR
    2. 创建包含新元素的单节点树 M
    3. 合并 LM 得到 L’
    4. 合并 L’R 得到新列表。
  • 删除位置 k 的元素
    1. 将原列表在位置 k 分裂成 LR
    2. R 在位置 1(即第一个元素)分裂成 M(要删除的元素)和 R’
    3. 合并 LR’ 得到新列表。

通过这些基本操作的组合,我们可以实现更复杂的操作,例如将列表的一段区间剪切并粘贴到另一个位置,所有操作的时间复杂度都是 O(log n)。


总结

本节课我们一起学习了 Treap 这种随机化的平衡二叉搜索树。

  • 我们了解了它的核心结构:同时维护二叉搜索树(基于 X 键)和堆(基于随机 Y 键)的性质,从而获得期望的对数高度。
  • 我们重点掌握了两个关键操作:split(分裂)和 merge(合并),并分析了它们 O(log n) 的时间复杂度。
  • 最后,我们探讨了“隐式键”的概念,展示了如何利用 Treap 来实现一个支持按索引分裂、合并的动态列表数据结构,这为处理复杂的序列操作提供了强大的工具。

Treap 的代码实现相对简洁,是理解平衡树和序列操作思想的优秀范例。

024:伸展树

在本节课中,我们将学习一种特殊的二叉搜索树——伸展树。我们将了解它的核心操作“伸展”,并证明其操作的平摊时间复杂度为 O(log n)。伸展树的神奇之处在于,它无需存储任何额外的平衡信息,仅通过访问节点后的“伸展”操作就能自我优化,使频繁访问的节点靠近树根。

什么是伸展树?

上一节我们讨论了AVL树和Treap等平衡二叉搜索树。本节中我们来看看伸展树。

伸展树是一种二叉搜索树。它非常酷。我们不需要维护任何平衡属性,也不需要在节点中保存任何额外数据(如高度、权重或随机键)。我们只是构建一棵普通的二叉搜索树,然后通过一种内部机制让树自我平衡。

这完全像魔法一样。任何二叉搜索树都是一棵有效的伸展树。这意味着,在某个时刻,你的伸展树可能看起来像一条长链。这是一棵有效的伸展树。每棵树都是有效的伸展树。如果你的树长这样,意味着你的伸展树认为,对于当前情况,这就是最好的树。在每个时间点,伸展树都维持着一种结构,这种结构在某种程度上优化了你当前的操作序列。一些内部魔法使得这棵树为你的当前情况保持平衡。

“Splay”这个词有点奇怪。我不太确定它具体是什么意思。我相信除了关于伸展树的书籍外,我从未在其他书中见过这个词。

核心操作:伸展

伸展树的主要操作是“伸展”,这也是它名字的由来。

伸展操作非常简单。它接收树中的某个节点 x。通过一系列旋转,它将树变换为另一棵包含相同元素的树,但节点 x 成为了新的树根。

你可以通过旋转从 x 到树根路径上的边来实现这一点。任何元素都可以成为树的根。例如,如果你选择节点12并对其调用伸展操作,12将成为树根,所有其他元素将分布在它的子树中。

现在,所有其他操作是如何工作的呢?所有操作都非常简单。

查找操作

以下是查找操作的过程:

  1. 从树根开始,沿着二叉搜索树的规则向下查找目标节点 x
  2. 找到节点 x 后,对其调用伸展操作。

这就是整个计划。所有操作都是如此:如果你需要访问某个节点,你沿着路径找到它,然后对该节点调用伸展操作。伸展操作会将这个节点沿着路径向上移动,最终使其成为树根。

现在,我将证明所有操作的平摊时间复杂度是 O(log n)。更准确地说,我将证明查找操作的平摊时间复杂度是 O(log n)。

让我们回忆一下什么是平摊时间复杂度。平摊时间复杂度意味着,如果你有一个长度为 k 的操作序列,那么执行这 k 个操作所花费的总时间不超过 k * log n。这就是平摊复杂度的含义。

单个操作可能耗时很长。例如,如果你的树是一条长链,而你试图访问底部的节点,那么这次操作的时间复杂度将是线性的(O(n))。但这种情况不会一直发生。有些操作可能耗时很长,但总的时间复杂度不会超过 k * log n

如何证明呢?我会简单地证明。观察查找操作是如何工作的:你需要向下遍历整条路径找到 x,然后调用伸展操作,伸展操作又会沿着同一条路径向上移动 x。因此,查找操作的时间复杂度大约是伸展操作的两倍。

如果我证明了伸展操作的平摊时间复杂度是 O(log n),那么查找操作以及其他所有操作的平摊时间复杂度也都是 O(log n)。这就是计划。

伸展操作的实现

现在,如何实现伸展操作?我们分三步进行,每次我们查看节点 x、它的父节点 p 和它的祖父节点 g

让我们考虑三种可能的情况。

情况一:节点 x 没有祖父节点(即 p 是树根)
在这种情况下,我们只需要旋转连接 xp 的边。这个操作称为 zig

    p          x
   / \   ->   / \
  x   C      A   p
 / \            / \
A   B          B   C

旋转后,x 成为子树(此时也是整棵树)的根。

情况二:节点 x 有祖父节点,且 xppg 的关系方向不同(例如,xp 的右孩子,而 pg 的左孩子)
这个操作称为 zig-zag。我们可以通过两次旋转来实现:先旋转 x-p 边,再旋转 x-g 边。最终 x 成为子树的根。

     g            g            x
    / \          / \         /   \
   p   D        x   D       p     g
  / \     ->   / \     ->  / \   / \
 A   x        p   C       A   B C   D
    / \      / \
   B   C    A   B

情况三:节点 x 有祖父节点,且 xppg 的关系方向相同(例如,xp 的左孩子,且 pg 的左孩子)
这个操作称为 zig-zig。同样通过两次旋转实现:先旋转 p-g 边,再旋转 x-p 边。最终 x 成为子树的根。

      g            p            x
     / \          / \         /   \
    p   D        x   g       A     p
   / \     ->   / \ / \   ->      / \
  x   C        A   B C D         B   g
 / \                                  \
A   B                                  D
                                       \
                                        C

(注:上图第三棵树的右子树结构在典型描述中应为 (B, g(D, C)),此处为视频中图示的直观转译,原理一致:x 上提为根,原结构重组为其子树。)

实现起来非常简单。你编写一个处理单次旋转的函数。然后,只要 x 不是树根,就检查它属于哪种情况,并调用相应的旋转组合。每次操作都会将 x 向上移动两层。

时间复杂度证明(平摊分析)

现在到了关键部分:证明平摊时间复杂度是 O(log n)。我们将使用势能法进行证明。

我们为伸展树的当前状态定义一个势能函数 Φ。每次操作的实际耗时加上势能的变化量,就是该操作的平摊耗时。

目标是定义一个势能函数 Φ,使得即使单次伸展操作的实际耗时可能很长(O(n)),但势能会大幅下降,从而导致平摊耗时仅为 O(log n)。

势能函数的定义

  1. 为每个节点 x 分配一个权重 w(x)。为了简单证明 O(log n) 的复杂度,我们设所有节点的权重 w(x) = 1
  2. 定义节点 x规模 s(x) 为以 x 为根的子树中所有节点的权重之和。
  3. 定义节点 x r(x)log₂(s(x))
  4. 定义整个树的势能 Φ 为所有节点秩的总和:Φ = ∑ r(x)

关键引理

伸展操作中,将节点 x 伸展到根的平摊耗时不超过 1 + 3*(r'(x) - r(x)),其中 r(x)x 伸展前的秩,r'(x)x 伸展后(成为树根)的秩。

由于伸展后 x 是树根,s'(x) = n(总节点数),所以 r'(x) = log₂ n。而 r(x) ≥ 0,因此平摊耗时 ≤ 1 + 3*log₂ n = O(log n)

证明思路(以 zig-zig 为例)

对于 zig-zig 操作,我们需要证明其平摊耗时 ≤ 3*(r'(x) - r(x))(没有常数项 +1,这与 zig 情况不同,原因在总和中会抵消)。

平摊耗时 = 实际耗时 (2次旋转) + ΔΦ (势能变化)
ΔΦ 主要涉及 x, p, g 三个节点秩的变化:(r'(x) - r(x)) + (r'(p) - r(p)) + (r'(g) - r(g))

通过一系列代换和不等式放缩(利用 r(p) ≥ r(x),以及 r'(p) ≤ r'(x) 等性质),最终可以将证明转化为验证一个不等式:
log₂(s'(p)/s'(x)) + log₂(s'(g)/s'(x)) ≤ -2
这等价于证明 (s'(p)/s'(x)) * (s'(g)/s'(x)) ≤ 1/4

观察子树规模:s'(x) = s'(p) + s'(g) + 1。因此,两个分数 s'(p)/s'(x)s'(g)/s'(x) 的和小于等于1。根据基本不等式,当两个非负数的和为定值(≤1)时,其乘积在两者相等时取最大值,最大值为 (1/2)*(1/2)=1/4。因此不等式成立。

zig 和 zig-zag 情况的证明思路类似。将所有步骤(一系列 zig-zig, zig-zag 和一个可能的 zig)的平摊耗时相加,中间项的秩会相互抵消,最终总平摊耗时约为 1 + 3*(r'(根) - r(初始)) = O(log n)

伸展树的优势与特性

伸展树具有一些有趣的实践特性:

  • 局部性:如果一个节点被频繁访问,它会被多次伸展到树根附近,因此后续访问速度会更快。这类似于缓存算法。
  • 序列访问优化:如果连续多次访问同一个节点,第一次耗时 O(log n),之后几次访问由于该节点已在根节点,耗时仅为 O(1)。相比之下,AVL树或Treap每次访问都需要 O(log n)。
  • 静态最优性猜想:一个著名的开放问题是:对于任何固定的访问序列,伸展树的总耗时是否在常数因子内逼近最优的静态二叉搜索树(即事先知道所有访问序列而专门构建的最优树)?在许多特定情况下(如顺序访问、频率不均匀的访问),伸展树都被证明是(近似)最优的。虽然尚未被普遍证明,但这显示了伸展树强大的自适应能力。

总结

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

  1. 伸展树是一种自调整的二叉搜索树,无需存储平衡信息。
  2. 其核心操作是伸展,即在访问任何节点后,通过一系列旋转将其移动到树根。
  3. 我们详细分析了 zig, zig-zig, zig-zag 三种旋转情况。
  4. 使用势能法,我们证明了伸展操作的平摊时间复杂度为 O(log n),从而所有基本操作(查找、插入、删除)的平摊时间复杂度也是 O(log n)。
  5. 伸展树具有利用访问局部性的优点,对于某些访问模式效率很高。

伸展树是一个优雅且理论上深刻的数据结构。在接下来的课程中,我们还会看到如何利用伸展树来优化其他数据结构(如链接-切割树)。

025:替罪羊树与列表顺序维护

概述

在本节课中,我们将学习一种特殊的二叉搜索树——替罪羊树。这种树不使用旋转操作来维持平衡,因此适用于一些需要维护额外信息(如排序列表)的特殊场景。我们还将探讨如何利用替罪羊树来实现高效的列表顺序维护数据结构,该结构支持在常数时间内比较元素顺序和插入新元素。


替罪羊树简介

上一节我们介绍了多种平衡二叉搜索树。本节中我们来看看最后一种二叉搜索树——替罪羊树。这种树之所以特殊,是因为它允许我们解决一些特殊问题。

替罪羊树不使用旋转操作来维持平衡。这一点很重要,因为在某些数据结构中,旋转操作会破坏节点中维护的额外信息(例如排序列表),导致更新成本过高。

问题背景:动态二维点集查询

回忆之前关于二维线段树的讨论。我们有一个点集,需要支持两种操作:查询矩形区域内的所有点,以及动态更新点的坐标(例如移动一个点)。

构建线段树时,每个节点维护该节点对应X坐标区间内所有点的列表,并按Y坐标排序。查询时,我们需要在多个节点的列表中,通过二分查找找到Y坐标在指定区间内的点。

当需要更新一个点的Y坐标时,我们只需在该点所在的所有节点列表中更新该点的值。由于每个节点列表都是排序的,我们可以用二叉搜索树(如C++的set)来维护,从而实现 O(log² n) 的更新复杂度。

然而,当需要更新点的X坐标或插入新点时,问题就变得复杂了。这需要改变点在X轴上的顺序,从而可能破坏整个线段树的结构。简单的解决方案是允许树结构变得“不完美”,例如在插入新点时,将叶节点分裂,并将新点添加到路径上所有节点的列表中。但这会导致树的高度增加,不再保持 O(log n) 的高度。

传统平衡技术的局限性

我们之前讨论的平衡技术(如AVL树、红黑树、伸展树)都依赖于旋转操作。但在当前场景下,每个节点维护着一个包含其子树所有元素的排序列表。进行旋转时,我们需要为旋转后的节点重建这个排序列表,而合并两个已排序列表需要线性时间,无法在 O(log n) 时间内完成。因此,我们不能使用基于旋转的平衡技术。

替罪羊树的平衡策略

替罪羊树使用一种不同的平衡策略。它依赖于一个简单的属性:对于任意节点 x 及其父节点 px 的子树大小最多是 p 的子树大小的 α 倍。其中 α 是一个常数,通常取值在 0.51 之间,例如 0.7

公式size(x) <= α * size(p)

这个属性保证了树的高度为 O(log n)。因为从根到叶子的路径上,子树大小至少以因子 α 递减。

当插入或删除节点导致这个属性被破坏时,我们找到从插入点到根路径上第一个不满足该属性的节点。这个节点被称为“替罪羊”。我们不是通过旋转来修复它,而是将这个“替罪羊”节点的整个子树完全重建为一棵完美平衡的二叉搜索树。

重建操作可以在线性时间内完成。虽然单次重建代价较高,但分摊分析表明,每次插入操作的分摊时间复杂度仍然是 O(log n)

替罪羊树的操作与复杂度

以下是替罪羊树的核心操作步骤:

  1. 插入:像普通二叉搜索树一样插入新节点,并更新路径上所有节点的子树大小。
  2. 检查平衡:从新节点向根回溯,检查每个节点是否满足 size(child) <= α * size(parent)
  3. 重建:如果发现不满足条件的节点(替罪羊),则将其整个子树重建为完美平衡的树。

虽然单次重建需要 O(k) 时间(k 为子树大小),但分摊分析表明,在两次重建之间至少需要 Ω(k) 次插入操作才能使该子树再次变得不平衡。因此,每个插入操作的分摊成本是常数,总的分摊插入时间复杂度为 O(log n)

列表顺序维护问题

现在,我们利用替罪羊树来解决另一个问题:列表顺序维护。我们需要维护一个元素列表,并支持两种操作:

  1. insertAfter(x, y):在元素 x 之后插入新元素 y
  2. isBefore(x, y):比较元素 xy 在列表中的顺序。

使用普通的二叉搜索树,这两种操作都可以在 O(log n) 时间内完成。但我们的目标是让它们都在常数分摊时间内完成。

常数时间比较的初步想法

一个直观的想法是为每个列表元素分配一个整数标签,并保持这些标签按列表顺序递增。比较两个元素时,只需比较它们的标签。插入新元素时,将其标签设为前后两个元素标签的平均值。

代码示例(概念)

# 初始:元素A标签0,元素B标签MAX
# 在A和B之间插入C
label_C = (label_A + label_B) // 2

问题在于,如果反复在相同位置插入,标签的精度会耗尽(例如,整数会溢出或无法再取平均值)。如果标签范围是 0M-1,那么在最坏情况下,只能在固定位置插入约 log M 次。

结合替罪羊树的解决方案

为了支持任意多次插入,我们将列表元素分组为,每个块大小约为 O(log n)。我们维护一个替罪羊树,其中每个树节点对应一个块。

比较操作 isBefore(x, y)

  • 如果 xy 在同一个块内,我们使用块内分配的标签(范围足够小,可以用取平均的方法)进行常数时间比较。
  • 如果 xy 在不同块内,我们通过比较它们在替罪羊树中对应节点的标签(由树结构决定,后文详述)来确定顺序,这也是常数时间。

插入操作 insertAfter(x, y)

  1. x 所在的块内,将 y 插入到 x 之后,并为 y 分配其前后元素标签的平均值。这是常数时间。
  2. 如果插入后块的大小超过了上限(例如 2 * log n),我们将这个块分裂成两个大小约为 log n 的块。
  3. 块分裂意味着我们需要在替罪羊树中为新的块创建一个新节点并插入。这个插入操作的成本是 O(log n)

分摊复杂度分析

虽然块分裂会导致一次 O(log n) 的替罪羊树插入操作,但分裂后,两个新块的大小都只有原来的一半左右。因此,至少需要再经过 Ω(log n) 次插入操作,才会导致其中一个块再次需要分裂。

所以,每次列表插入操作的分摊时间复杂度是:O(1)(块内插入) + O(log n) / Ω(log n)(分裂成本分摊) = O(1)

替罪羊树中标签的分配

我们如何为替罪羊树中的每个节点(代表一个块)分配用于比较的标签呢?我们利用树的结构:

  • 从根节点到目标节点的路径中,每次向左走,我们在二进制标签前添加“00”;每次向右走,则添加“11”。
  • 到达目标节点后,我们在末尾添加“01”作为终止标记,并用0填充至固定长度。

这样生成的二进制数,保证了树的中序遍历顺序(即块的列表顺序)与这些数的数值顺序一致。由于树高为 O(log n),标签的长度也是 O(log n) 位,这在现代计算机模型中是常数时间可比的。

替罪羊树的关键优势在于,它通过重建而非旋转来保持平衡。在重建子树时,我们可以同时为子树中的所有节点重新计算这些标签,而不会影响树的其他部分。

总结

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

  1. 替罪羊树:一种通过重建子树而非旋转来维持平衡的二叉搜索树。它维护 size(child) <= α * size(parent) 的不变性,破坏时则重建。其插入操作的分摊时间复杂度为 O(log n)
  2. 列表顺序维护:一个支持常数时间比较和常数分摊时间插入的数据结构。其核心思想是将列表分块,块内使用局部标签,块间使用替罪羊树维护全局顺序。通过巧妙的分摊分析,insertAfterisBefore 操作都能高效完成。

替罪羊树展示了平衡二叉搜索树设计的多样性,以及在特定约束下(如禁止旋转)如何设计高效的数据结构。列表顺序维护结构则是一个经典案例,展示了如何组合简单想法(标签、分块)与复杂数据结构(替罪羊树)来解决难题。

026:Farach-Colton和Bender算法

在本节课中,我们将学习一种新的数据结构主题。我们已经完成了对二叉搜索树的讨论,现在开始学习略有不同的树结构。本周,或许接下来的几周,我们将讨论通用树。

什么是树?

树本质上是一个无环图。如果你有一个无向图,并且它没有环,那么它就是一个树。例如,下图就是一个树。

有时你会遇到另一种类型的树,称为有根树。上面的树是无根的,所有顶点都是平等的。而有根树则有一个根节点,它类似于我们讨论二叉搜索树时的结构。这棵树有一个根,所有连接到根的节点都是其子节点。每个节点可以有任意数量的子节点,这与二叉树的子节点数量限制不同。

例如,上图中一个节点有三个子节点,子节点数量可以是任意值。

树的重要性

树是一种非常重要的数据结构,广泛应用于各种不同的问题中。最常见的是,有根树通常用于描述层次模型。例如,文件系统中的目录树、版本控制系统中的版本树,或者公司组织结构图。有根树因其具有根、叶和方向的结构,处理起来更加方便。

无根树(即无环图)在算法中也有应用,尤其是在图算法中。有时,在图算法中,你只需要处理图内部的某个树结构。通常,处理无根树的第一步是将其转化为有根树。

如何为树指定根节点

为树指定根节点非常简单。你只需选择树中的任意一个顶点,将其声明为树的根,然后将所有边从这个根节点向外定向。例如,假设我们有以下树节点:1, 2, 3, 4, 5, 6, 7, 8, 9。如果我们选择节点1作为根,那么它的邻居节点2、6、9就成为其子节点。然后递归地对每个子节点进行相同操作:对于节点6,其子节点是7和4;对于节点9,其子节点是8和5,依此类推。这是一个简单的转换过程。

有根树上的关键操作:最低公共祖先

在有根树中,最有用的操作之一是找到两个节点的最低公共祖先

最低公共祖先的定义是:给定树中的两个节点U和V,考虑它们所有的祖先节点。公共祖先是指同时是U和V祖先的节点。在所有公共祖先中,深度最大的那个(即离根节点最远的那个)就是最低公共祖先。

为什么这个操作如此重要?因为它与树中的路径结构密切相关。在树中,从节点U到节点V的路径总是这样的:先从U向上走到达最低公共祖先W,然后再从W向下走到达V。因此,任何从U到V的路径都可以拆分为两部分:从U到W的上行路径和从W到V的下行路径。最低公共祖先W就是这个路径的“转折点”。

LCA的应用举例:计算节点间距离

一个直接的应用是计算树上两个节点之间的距离。假设我们知道节点U和V的最低公共祖先W。那么,从U到V的距离就等于从U到W的距离加上从V到W的距离。

如何计算从节点到其祖先的距离?这可以通过节点的深度来实现。节点的深度定义为从根节点到该节点的边数。那么:

  • 从U到W的距离 = depth(U) - depth(W)
  • 从V到W的距离 = depth(V) - depth(W)

因此,总距离为:
distance(U, V) = (depth(U) - depth(W)) + (depth(V) - depth(W))

所以,一旦我们能高效地找到任意两个节点的LCA,就能轻松计算出它们之间的距离。这只是LCA众多应用中的一个,许多涉及树上路径的问题都可以通过将路径拆分为两条到LCA的路径来简化。

计算LCA的技术概览

我们将讨论两种计算LCA的重要技术。它们本身是解决LCA问题的方法,但其思想可以应用于更多复杂问题。

第一种技术称为二叉提升
第二种技术利用了树的欧拉序和稀疏表线段树,并最终通过分块优化得到一个线性预处理、常数查询的算法,即Farach-Colton和Bender算法

技术一:二叉提升

上一节我们介绍了LCA的概念及其应用,本节我们来看看第一种计算LCA的技术:二叉提升。

核心思想

二叉提升的核心思想是:能够从任意节点快速跳转到其祖先节点,而不是一步一步地向上移动。我们通过预计算一些“跳跃指针”来实现这一点。

对于每个节点 v,我们预计算一个数组 jump[v][k]。这个指针指向从节点 v 向上走 2^k 步后到达的祖先节点。例如:

  • jump[v][0] 指向 v 的父节点(向上1步)。
  • jump[v][1] 指向 v 的祖父节点(向上2步)。
  • jump[v][2] 指向向上4步到达的节点,依此类推。

如果向上 2^k 步超出了根节点,我们可以将指针设为 null-1,或者简单地让它指向根节点(实现细节)。

预计算跳跃指针

我们如何计算所有这些指针呢?可以借鉴稀疏表的思路,按 k 从小到大的顺序进行计算。

  1. 初始化 (k=0): 对于每个节点 vjump[v][0] 就是它的父节点。这可以在一次树遍历中完成。
  2. 递推计算 (k>0): 对于 k 从 1 到 log(n),对于每个节点 v
    jump[v][k] = jump[ jump[v][k-1] ][k-1]
    这个公式的含义是:要向上跳 2^k 步,可以先向上跳 2^(k-1) 步到达一个中间节点 mid,然后再从 mid 向上跳 2^(k-1) 步。由于 jump[v][k-1]jump[mid][k-1] 都已经计算好了,所以可以直接得到。

以下是预计算的伪代码框架:

# 假设 parent[v] 存储了节点v的父节点,根节点的父节点为-1或自身。
logN = ceil(log2(n)) + 1
jump = [[-1]*logN for _ in range(n)]

# 初始化 k=0
for v in range(n):
    jump[v][0] = parent[v]

# 递推计算 k=1...logN-1
for k in range(1, logN):
    for v in range(n):
        mid = jump[v][k-1]
        if mid != -1:
            jump[v][k] = jump[mid][k-1]
        # 如果mid是-1,则jump[v][k]保持为-1

时间复杂度:我们需要为 n 个节点每个计算 log(n) 个指针,每次计算是常数时间,所以总预处理时间复杂度为 O(n log n)

使用二叉提升计算LCA

现在,我们利用预计算好的 jump 数组来求两个节点 uv 的LCA。过程分为两步:

第一步:将两个节点提升到同一深度
假设 depth[u] < depth[v](否则交换)。我们计算深度差 d = depth[v] - depth[u]。我们需要将较深的节点 v 向上移动 d 步,使其与 u 处于同一深度。
我们可以将 d 表示为二进制形式。例如,d = 5 (二进制101),意味着我们需要向上移动 2^2 + 2^0 步。利用 jump 数组,我们可以从最大的 2^k 开始尝试:如果 d >= 2^k,则进行 jump[v][k] 跳跃,并减少 d。这样可以在 O(log n) 步内完成。

更优雅的实现是,不显式计算 d,而是从高到低遍历 k,如果 jump[v][k] 的深度仍然大于等于 depth[u],就进行跳跃。

def lift_to_same_depth(u, v):
    if depth[u] > depth[v]:
        u, v = v, u # 确保v更深
    # 将v提升到与u同深
    diff = depth[v] - depth[u]
    k = 0
    while diff > 0:
        if diff & 1: # 如果二进制当前位为1
            v = jump[v][k]
        k += 1
        diff >>= 1
    return u, v
# 或者使用从高到低遍历k的版本

第二步:二分搜索寻找LCA
uv 处于同一深度后,如果此时 u == v,那么 u(或 v)就是LCA。
否则,我们进行一种“二分搜索”。我们维持一个不变性:uv 的当前祖先不同,但它们的某个祖先(最终是LCA)是相同的。我们从最大的步长 2^k 开始尝试:

  • 如果 jump[u][k] != jump[v][k],说明 uv 向上跳 2^k 步后到达的节点还不是公共祖先(或者还不是最低的那个)。那么我们可以安全地将 uv 同时向上跳 2^k 步,在新的、更低的起点上继续搜索。
  • 如果 jump[u][k] == jump[v][k],说明这个跳跃跳得太远了,直接跳到了一个公共祖先(可能不是最低的)。那么我们就不进行这次跳跃,而是尝试更小的步长 k-1

最终,当 k 减少到0时,uv 将停留在LCA的两个直接子节点上。此时,uv 的父节点(即 jump[u][0])就是LCA。

以下是计算LCA的完整函数伪代码:

def lca(u, v):
    # 1. 提升到同一深度
    if depth[u] > depth[v]:
        u, v = v, u
    # 将v提升
    diff = depth[v] - depth[u]
    for k in range(logN-1, -1, -1):
        if diff & (1 << k):
            v = jump[v][k]
    if u == v:
        return u
    # 2. 二分搜索LCA
    for k in range(logN-1, -1, -1):
        if jump[u][k] != jump[v][k]:
            u = jump[u][k]
            v = jump[v][k]
    # 此时u和v是LCA的子节点
    return jump[u][0]

查询时间复杂度:每一步循环最多 log(n) 次,因此每次查询LCA的时间复杂度为 O(log n)

总结:二叉提升技术预处理时间复杂度为 O(n log n),单次查询时间复杂度为 O(log n)。它是一种非常强大且灵活的技术,不仅可以用于求LCA,还可以通过在跳跃时维护额外信息(如路径上的最大值、和等)来解决更多树上路径查询问题。

技术二:基于欧拉序与RMQ

上一节我们学习了基于二叉提升的LCA算法。本节我们来看一种完全不同的思路:将LCA问题转化为区间最小值查询问题。

核心思想与欧拉序

这个技术的核心思想是将树形结构转化为线性结构。我们通过树的深度优先搜索来得到一个节点访问序列,称为欧拉序

生成欧拉序的递归过程如下:

  1. 从根节点开始DFS。
  2. 每次进入一个节点时,将其记录到序列中。
  3. 递归访问该节点的所有子节点。
  4. 每次离开一个节点(即回溯)时,再次将其记录到序列中。

这样,每条边会被遍历两次(一次向下,一次向上),每个节点会根据其度数被记录多次。最终我们得到一个长度为 2*n - 1 左右的序列 euler_tour

同时,我们记录:

  • depth[node]:每个节点在树中的深度。
  • first_occurrence[node]:每个节点在欧拉序中第一次出现的位置索引。

LCA转化为RMQ

关键观察是:对于任意两个节点 uv,考虑它们在欧拉序中第一次出现的位置 first[u]first[v]。假设 first[u] < first[v]
现在,查看欧拉序中从 first[u]first[v] 的这个区间。这个区间对应了从 u 出发,遍历部分子树,最后走到 v 的一条路径。这条路径上一定会经过 uv 的最低公共祖先 LCA(u, v)

更重要的性质是:在这个区间内,所有节点中深度最小的那个节点,就是 uv 的最低公共祖先。

为什么?
因为DFS遍历的特性,当你从 u 走到 LCA(u,v) 再走到 v 的过程中,LCA是深度最小的点(它是 uv 的公共祖先,且在路径上离根最近)。在欧拉序的这段区间里,深度更小的祖先节点(LCA的祖先)不会出现,因为DFS在离开LCA向上回溯时,就已经离开了那个子树分支。

因此,问题转化为:
在数组 depth[euler_tour[i]] 上,查询区间 [first[u], first[v]] 内的最小深度值对应的节点。

这是一个经典的区间最小值查询问题。

使用线段树或稀疏表求解RMQ

现在,我们有了一个深度数组 depth_array,其长度为 M (约 2n)。我们需要快速回答这个数组上的区间最小值查询。

有两种主要数据结构:

  1. 线段树:可以在 O(M) 时间内构建,每次查询需要 O(log M) 时间。由于 M 是 O(n),所以预处理 O(n),查询 O(log n)。
  2. 稀疏表:可以在 O(M log M) 时间内构建,但查询只需要 O(1) 时间。同样 M 是 O(n),所以预处理 O(n log n),查询 O(1)。

使用稀疏表,我们可以实现更快的查询。以下是步骤:

  • 预处理
    1. 进行DFS,生成欧拉序 euler_tour,深度数组 depth_array,以及首次出现数组 first_occ
    2. depth_array 上构建稀疏表 st,用于快速查询任意区间的最小值索引
  • 查询 LCA(u, v)
    1. l = first_occ[u], r = first_occ[v]。如果 l > r,交换它们。
    2. 使用稀疏表查询 depth_array 在区间 [l, r] 内最小深度值的索引 idx
    3. LCA(u, v) = euler_tour[idx]

总结当前方案

  • 方法2A(线段树):预处理 O(n),查询 O(log n)。
  • 方法2B(稀疏表):预处理 O(n log n),查询 O(1)。

我们的目标是获得一个预处理 O(n)查询 O(1) 的算法。接下来介绍的Farach-Colton和Bender算法就实现了这一点。

Farach-Colton和Bender算法

上一节我们将LCA转化为RMQ,并看到了稀疏表查询虽快但预处理较慢。本节我们介绍Farach-Colton和Bender算法,它通过巧妙的分块技术,在线性预处理时间内实现常数查询

算法框架回顾

我们有一个深度数组 D(来自欧拉序),长度为 M。我们需要支持 D 上的区间最小值查询。稀疏表需要 O(M log M) 的预处理时间,我们希望减少到 O(M)。

观察:我们的深度数组 D 有一个特殊性质——相邻元素的差绝对值为1(因为DFS时深度每次变化±1)。这个性质是优化的关键。

分块思想

  1. 将数组分块:将长度为 M 的数组 D 分成大小为 B 的块(最后一块可能较小)。设块数为 K = ceil(M / B)
  2. 块内最小值:对于每个块 i,预计算其内部的最小值 block_min[i]。这需要 O(M) 时间。
  3. 块间查询:对于查询区间 [l, r],它可能覆盖若干个完整的块以及左右两端不完整的块。
    • 对于完整的块,我们只需要查询这些块的 block_min 值中的最小值。
    • 对于左右不完整的块,我们需要在块内进行查询。

如果我们能:

  • 快速回答完整块区间的最小值查询(即对 block_min 数组进行RMQ)。
  • 快速回答任意块内的任意区间查询

并且总时间都是常数,那么整体查询就是常数时间。

实现常数查询

步骤一:处理完整块区间查询
我们对 block_min 数组构建一个稀疏表。由于块数 K ≈ M/B,构建稀疏表的时间为 O(K log K)。如果我们选择 B = log(M),那么 K ≈ M / log Mlog K ≈ log M,所以预处理时间为 O( (M/log M) * log M ) = O(M),是线性的!查询完整块区间最小值时,稀疏表可以在 O(1) 时间内完成。

步骤二:处理块内查询
块的大小是 B = log(M)。我们需要能够快速回答任意一个块内,任意区间 [l, r]` 的最小值查询。一个朴素的想法是为每个块预计算所有可能的区间查询结果,但这样会有 O(B^2) 个区间,总预计算量是 O(K * B^2) = O(M * B),当 B=log M 时是 O(M log M),不是线性。

这里利用了深度数组 D 的相邻差为±1的特殊性质。这个性质意味着,每个块的类型可以由一个长度为 B-1 的“符号序列”唯一确定,其中每个符号表示相邻深度的差是 +1 (上升) 还是 -1 (下降)。

例如,深度序列 [3, 4, 5, 4] 对应的符号序列是 [+1, +1, -1]
不同的符号序列有多少种?最多有 2^(B-1) 种。

如果我们选择 B = (log M) / 2,那么 2^(B-1) ≈ 2^((log M)/2) = sqrt(M)。这个数量级是关于 M 的亚线性(根号级别)。

关键操作

  • 我们预先枚举所有可能的 sqrt(M) 种块类型。
  • 对于每种块类型,我们预先计算其内部所有可能区间 [l, r] (0 <= l <= r < B) 的最小值查询结果,存储在一个表 precomp[type][l][r] 中。
  • 这个三维表的大小约为:(块类型数) * B * B ≈ sqrt(M) * (log M)^2。当 M 较大时,这仍然可以小于 M,因此构建这个表的总时间是 O(M) 级别的(具体是 O(sqrt(M) * (log M)^2),这被吸收进 O(M) 中)。

在实际查询时:

  1. 确定查询区间 [l, r] 所在的块。
  2. 对于两端的非完整块,通过查找该块的类型,并在预计算的表 precomp 中直接 O(1) 得到块内区间最小值。
  3. 对于中间的完整块,通过 block_min 数组上的稀疏表 O(1) 得到最小值。
  4. 取这三部分最小值中的最小值,即为最终结果。

算法总结

Farach-Colton和Bender算法的主要步骤:

  1. 预处理
    a. DFS生成欧拉序、深度数组 D、首次出现数组 first_occ
    b. 设置块大小 B = (log M) / 2(或类似常数),将 D 分块。
    c. 计算每个块的最小值数组 block_min
    d. 在 block_min 数组上构建稀疏表(O(M)时间)。
    e. 为每个块计算其“类型”(基于相邻深度差的符号序列)。
    f. 预计算所有可能块类型的所有可能区间查询结果表 precomp(O(M)时间)。
  2. 查询 LCA(u, v)
    a. l = first_occ[u], r = first_occ[v](确保 l <= r)。
    b. 找到 lr 所在的块 blbr
    c. 如果 bl == br,直接在块 bl 内通过查表 precomp 得到最小值对应节点。
    d. 否则:
    i. 查询块 bl[l, 块尾] 的最小值(查表 precomp)。
    ii. 查询块 br[块头, r] 的最小值(查表 precomp)。
    iii. 如果 bl+1 <= br-1,查询 block_min 数组在区间 [bl+1, br-1] 的最小值(用稀疏表)。
    iv. 从上述三个值中取最小值对应的节点,即为LCA。

最终复杂度

  • 预处理时间:O(n) (线性)
  • 查询时间:O(1) (常数)

扩展:将RMQ问题转化为LCA问题

有趣的是,LCA和RMQ问题可以相互转化。我们刚刚展示了如何利用RMQ(在特殊深度数组上)来解决LCA问题。反过来,任何一般的静态数组区间最小值查询问题,都可以转化为LCA问题来解决

转化方法如下:

  1. 给定数组 A,构建其笛卡尔树
    • 笛卡尔树的根是整个数组的最小值所在位置。
    • 根的左子树由最小值左侧子数组递归构建,右子树由右侧子数组递归构建。
    • 这样的树可以在 O(n) 时间内构建。
  2. 观察:在数组 A 的区间 [l, r] 中,最小值的索引正是笛卡尔树中节点 l 和节点 r最低公共祖先(这里的节点对应数组下标)。
  3. 因此,要查询 A[l...r] 的最小值,只需在笛卡尔树上计算 LCA(l, r),该节点对应的下标就是最小值位置。

既然我们已经有了线性预处理、常数查询的LCA算法(Farach-Colton和Bender),那么我们就得到了一个对于任意静态数组,也能实现线性预处理、常数查询的RMQ算法。这展示了LCA与RMQ这两个问题的深刻联系。

课程总结

本节课我们一起学习了树结构中的核心操作——最低公共祖先的多种计算方法。

  1. 二叉提升法:通过预计算每个节点向上 2^k 步的祖先,在 O(n log n) 预处理后,以 O(log n) 时间查询LCA。该方法思想通用,可用于解决多种树上路径问题。
  2. 基于欧拉序与RMQ的方法:将LCA问题转化为深度数组上的区间最小值查询。
    • 使用线段树处理RMQ,实现 O(n) 预处理,O(log n) 查询。
    • 使用稀疏表处理RMQ,实现 O(n log n) 预处理,O(1) 查询。
  3. Farach-Colton和Bender算法:一种优化的RMQ算法,利用深度数组相邻差为±1的特殊性质和分块技巧,实现了 O(n) 预处理O(1) 查询的LCA计算。这是理论上的最优结果之一。
  4. 我们还了解了LCA与一般RMQ问题的等价性,可以通过构建笛卡尔树将任意RMQ问题转化为LCA问题。

这些算法不仅在竞赛编程中非常重要,也是许多高级图算法和数据结构的基础。理解它们的思想,有助于你解决更复杂的树上查询与路径问题。

027:树链剖分 (Heavy-Light Decomposition)

在本节课中,我们将要学习一种处理树上路径查询与修改问题的强大技术——树链剖分。我们将学习如何将一棵树分解为若干条链,并利用线段树等数据结构高效地处理路径上的信息。

上一节我们讨论了如何利用倍增法或欧拉序结合RMQ来高效求解树上两点的最近公共祖先。本节中,我们来看看一个更一般的问题:如何在支持节点值修改的树上,快速查询任意两点路径上某种结合函数(如求和、求最小值)的结果。

问题定义

我们有一棵包含 N 个节点的树。每个节点 v 都有一个权值 val[v]。我们需要处理两种类型的请求:

  1. 修改请求:将节点 v 的权值修改为 x
    set(v, x): val[v] = x
    
  2. 查询请求:给定两个节点 uv,计算从 uv 的路径上所有节点权值的某个结合函数(例如求和、求最小值)的结果。
    query(u, v): f(val[a], val[b], val[c], ...) 其中 a, b, c, ... 是 u->v 路径上的节点
    

如果只有查询请求,我们可以使用倍增法在 O(log N) 时间内完成。但引入修改请求后,我们需要一种能动态维护路径信息的数据结构。

从特殊情况入手:链

在解决一般树上的问题前,让我们先考虑一个最简单的特殊情况:如果这棵树是一条链(即“竹子”形状的树)。

在这种情况下,树退化为一个数组。我们的问题就变成了:

  • 修改数组某个位置的值。
  • 查询数组某个区间的结合函数值。

这恰好是线段树可以完美解决的问题,时间复杂度为 O(log N)

这个简单的案例告诉我们,对于一般树,我们至少需要像线段树一样高效的数据结构。树链剖分的核心思想,就是将一棵树“拍平”成若干条链,然后在每条链上使用线段树。

树链剖分核心思想

树链剖分的目标是将树分解为若干条不相交的“重链”,使得从树根到任意节点的路径上,经过的重链数量不超过 O(log N) 条。

以下是实现树链剖分的步骤。

第一步:定义重儿子与重边

首先,我们需要为每个非叶子节点选择一个“重儿子”。为此,我们计算以每个节点为根的子树大小 size[x]

对于一个节点 x,我们查看它的所有子节点 ysize[y] 最大的那个子节点被称为 x重儿子。连接 x 与其重儿子的边被称为重边。连接到其他子节点的边则称为轻边

以下是计算子树大小和重儿子的递归伪代码:

def dfs_size(x, parent):
    size[x] = 1
    max_size = 0
    for y in children of x:
        if y == parent: continue
        dfs_size(y, x)
        size[x] += size[y]
        if size[y] > max_size:
            max_size = size[y]
            heavy[x] = y  # 记录重儿子

这个过程可以在 O(N) 时间内完成。

第二步:形成重链

所有重边连接起来,形成了若干条从上到下的路径,我们称之为重链。每个节点都属于且仅属于一条重链。轻边则是连接不同重链的“桥梁”。

下图展示了一棵树被剖分成重链(加粗边连接)后的样子:

第三步:关键性质——轻边数量级

树链剖分高效的关键在于以下性质:从树根到任意节点 u 的路径上,最多只有 O(log N) 条轻边

证明思路:考虑从节点 u 出发,沿着父节点不断走向根。每当我们经过一条轻边 (p[u], u),就意味着 u 不是 p[u] 的重儿子。根据定义,p[u] 的重儿子所在的子树大小至少和以 u 为根的子树大小一样大。因此,当我们从 u 跳到 p[u] 时,所在的子树大小至少翻倍。由于子树大小最大为 N,所以翻倍的次数不会超过 log₂ N 次,即轻边数量为 O(log N)

由于一条重链的端点由轻边决定,因此从根到 u 的路径最多被轻边分割成 O(log N) 条重链片段。

利用树链剖分处理查询

现在,我们来看如何利用剖分的结果回答 query(u, v)

  1. lcauv 的最近公共祖先。路径 u -> v 可以拆分为 u -> lcalca -> v 两段。
  2. 考虑 u -> lca 这一段。我们不断地将 u 向上跳转到所在重链的顶端:
    • 如果 u 所在重链的顶端节点 top[u]lca 的下方(即深度大于 lca),那么整段 utop[u] 的路径都在查询路径上。我们可以用线段树快速查询这条重链片段上节点权值的函数结果,然后将 u 设置为 top[u] 的父节点。
    • 如果 top[u] 就是 lca 或其祖先,那么只需要查询 ulca 在这条重链上的片段即可。
  3. v -> lca 这一段进行完全对称的操作。
  4. 最后,将两段查询的结果用结合函数合并(注意顺序,如果函数不满足交换律,需要小心处理)。

由于每条重链上的节点编号是连续的(我们接下来会实现),所以查询重链片段就是在线段树上查询一个区间。每次跳跃都会跳到一条新的重链,而跳跃次数是 O(log N) 的,每次跳跃需要进行一次 O(log N) 的线段树查询。因此,一次 query 操作的总时间复杂度为 O(log² N)

实现细节:简易编码法

理论可能略显复杂,但实现可以非常简洁。以下是一种高效的实现方法,它只使用一个线段树,而非每条重链一个。

1. 进行DFS并分配编号

我们进行两次DFS。

  • 第一次DFS:计算每个节点的子树大小 size、深度 depth、父节点 parent 和重儿子 heavy
  • 第二次DFS:优先遍历重儿子,并为每个节点分配一个连续的编号 pos[x]。这个DFS的顺序保证了每条重链上的节点编号是连续的

伪代码如下:

def dfs1(x, p, d):
    parent[x] = p
    depth[x] = d
    size[x] = 1
    max_size = 0
    for y in graph[x]:
        if y == p: continue
        dfs1(y, x, d+1)
        size[x] += size[y]
        if size[y] > max_size:
            max_size = size[y]
            heavy[x] = y

def dfs2(x, top_node):
    top[x] = top_node        # 当前重链的顶端
    pos[x] = current_pos     # 分配编号
    current_pos += 1
    # 优先处理重儿子,保证重链编号连续
    if heavy[x] != -1:
        dfs2(heavy[x], top_node)
    # 处理其他轻儿子,每个轻儿子都是一条新重链的起点
    for y in graph[x]:
        if y == parent[x] or y == heavy[x]: continue
        dfs2(y, y)

2. 构建线段树

我们用 pos[x] 作为下标,将节点的权值 val[x] 填入数组 arr 中,即 arr[pos[x]] = val[x]。然后在这个数组 arr 上构建线段树,支持单点修改和区间查询。

3. 路径查询函数

以下是 query(u, v) 的核心代码,它同时完成了求LCA和路径信息聚合:

def query_path(u, v):
    result = NEUTRAL  # 结合函数的单位元,如求和时为0,求最小值时为INF
    # 当u和v不在同一条重链上时
    while top[u] != top[v]:
        if depth[top[u]] < depth[top[v]]:
            u, v = v, u  # 保证u所在链顶端更深
        # 此时,top[u]一定比top[v]深。查询u到top[u]这段链
        result = combine(result, segtree_query(pos[top[u]], pos[u]))
        u = parent[top[u]]  # 跳到上一条链
    # 现在u和v在同一条重链上
    if depth[u] > depth[v]:
        u, v = v, u
    # 查询它们之间的部分
    result = combine(result, segtree_query(pos[u], pos[v]))
    return result

set(v, x) 操作非常简单,只需在线段树上更新 pos[v] 位置的值即可:segtree_update(pos[v], x)

时间复杂度分析

  • 预处理:两次DFS,O(N)
  • 修改操作:一次线段树单点更新,O(log N)
  • 查询操作while 循环每次跳跃到一条新重链,跳跃次数为 O(log N)。每次跳跃需要进行一次 O(log N) 的线段树查询。总复杂度 O(log² N)

总结

本节课中我们一起学习了树链剖分这一强大技术。我们首先定义了重儿子、重边和重链,并理解了其 O(log N) 跳跃次数的关键性质。然后,我们学习了如何通过两次DFS为节点分配连续编号,从而将树上路径查询转化为 O(log N) 个线段树区间查询,最终在 O(log² N) 的时间内处理带修改的树上路径查询问题。这种将树“拍平”并用线段树维护的思想,是解决许多复杂树上动态问题的基础。在接下来的课程中,我们将见识到更灵活、支持树形态修改的数据结构——Link-Cut Tree。

028:Link-Cut Tree

在本节课中,我们将学习一种强大的数据结构——Link-Cut Tree。它允许我们在一个动态变化的森林(一组树)上高效地执行三种核心操作:连接两棵树、切断一条边,以及在树中任意节点到根的路径上计算某个函数。我们将看到,这个结构虽然听起来复杂,但其核心思想非常巧妙,并且代码实现可以相当简洁。

上一节我们讨论了静态树上的路径查询。本节中,我们将升级这个结构,使其能够处理树结构的动态变化。

核心概念与问题定义

我们有一个由多棵树组成的森林。每棵树的每个节点上都有一个值。我们需要支持以下三种操作:

  1. link(u, v): 将节点 v 所在树的根,连接到另一棵树的节点 u 上,成为 u 的一个子节点。前提是 v 必须是其所在树的根,且 uv 原本不在同一棵树中。
    • 公式描述: parent[v] = u (将森林中两棵独立的树合并为一棵)。
  2. cut(v): 将节点 v 与其父节点之间的边切断,使 v 及其子树成为一棵新的独立树木。
    • 公式描述: parent[v] = null (将一棵树分割成两棵)。
  3. query(v): 计算从节点 v 到其所在树的根节点 r 的路径上所有节点值的某个结合函数(例如求和、求最小值、按位与等)。
    • 公式描述: 计算 f(value[v], value[parent[v]], ..., value[r]),其中 f 是结合函数。

我们的目标是让所有这些操作的时间复杂度都达到 O(log n),其中 n 是节点总数。

Link-Cut Tree 的核心是将每棵树分解成若干条路径。这不是像“树链剖分”那样固定的、基于子树大小的“重路径”分解,而是一种可以动态调整的分解。

以下是分解规则:

  • 对于树中的每个节点,我们最多可以标记 (mark) 它到其某个子节点的一条边。
  • 只考虑被标记的边,整个树就会被分割成若干条链(路径),以及一些孤立的节点(可视为长度为1的路径)。

示例:
假设我们有一棵树,并标记了某些边(图中加粗边)。那么,所有加粗边会形成几条路径,未标记的边则连接着这些路径。

        1
       / \
      2   3
     / \   \
    4   5   6
       / \
      7   8

假设标记的边是 1-2, 2-5, 5-7。那么形成的路径有:

  • 路径 A: 1 -> 2 -> 5 -> 7
  • 路径 B: 3 -> 6 (假设边3-6被标记)
  • 孤立节点: 4, 8

关键点:每条路径我们都用一个 Splay Tree(伸展树)来维护。Splay Tree 是一种自调整的二叉搜索树,它能高效地支持合并拆分操作,这正是我们动态调整路径分解所需要的。

在 Link-Cut Tree 的内部表示中:

  • 每个 Splay Tree 对应一条路径。
  • Splay Tree 中的中序遍历顺序,对应着路径从上(靠近根)到下的节点顺序。
  • 对于非根的路径,其对应 Splay Tree 的根节点有一个额外的指针,指向该路径在整棵树中的父节点(即连接这条路径和上方路径的那个节点)。

关键操作:expose(v)

Link-Cut Tree 最核心的操作是 expose(v)。它的作用是:将从节点 v 到树根的路径上的所有边都标记出来

为什么需要 expose
在执行 query(v) 时,我们需要计算 v 到根的路径。如果这条路径上的所有边都被标记了,那么根据我们的分解规则,v 和根节点就一定在同一条路径上,也就是在同一个 Splay Tree 中。这样,我们只需要在这个 Splay Tree 的根节点上维护的聚合信息,就能立刻得到查询结果。

expose(v) 如何工作?
算法从节点 v 开始,自底向上地处理,确保 v 到根的路径成为一条连续的、被标记的路径。

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

def expose(v):
    splay(v)                 # 将v旋转到其所在Splay Tree的根
    v.right = None           # 断开v的右子树(右子树对应路径上v下方的部分,我们不需要)
    while v.parent is not None: # 当v还有路径上方的父节点时
        u = v.parent         # u是v当前路径连接的上方节点
        splay(u)             # 将u旋转到其所在Splay Tree的根
        # 此时u的右子树是其原路径中在u下方的部分,我们需要先断开它(相当于取消标记一条边)
        u.right = None
        # 然后将v所在的整条路径(现在是一个Splay Tree)作为u的右子树连接上(相当于标记u-v边)
        u.right = v
        splay(v)             # 再次将v旋转到根,为下一次循环做准备

过程图解:

  1. 初始状态,路径是分散的。
  2. splay(v),使 v 成为其所在路径 Splay 的根。
  3. 找到 v 所在路径连接的父节点 u
  4. splay(u),使 u 成为其所在路径 Splay 的根。
  5. 断开 u 的右子树(取消 u 原来可能标记的到另一个子节点的边)。
  6. v 所在的 Splay Tree 作为 u 的右子树连接(标记 u-v 边)。现在 uv 就在同一个 Splay Tree 中了。
  7. 重复这个过程,直到 v 到达整棵树的根。

执行完 expose(v) 后,v 到根的整条路径就在一个 Splay Tree 中,且 v 是这个 Splay Tree 的根(通过最后的 splay(v))。

三大操作的实现

基于 expose 操作,我们可以非常简洁地实现三个核心操作。

1. 查询 query(v)

  1. 调用 expose(v)
  2. 此时,v 是包含其到根整条路径的 Splay Tree 的根。
  3. 直接返回该 Splay Tree 根节点上维护的聚合值(例如子树和、最小值等)。

2. 连接 link(u, v)

前提:v 是某棵树的根,且 uv 不在同一棵树中。

  1. 调用 expose(v)。确保 v 是其所在 Splay Tree 的根,且没有右子树(即 v 下方没有其他节点)。
  2. 调用 expose(u)。确保 u 是其所在 Splay Tree 的根。
  3. v 的父亲指针设置为 u。这相当于在 uv 之间添加了一条未标记的边。
    • 代码描述: v.parent = u

3. 切断 cut(v)

  1. 调用 expose(v)。确保 v 是其所在 Splay Tree 的根。
  2. 此时,v 的左子树(如果存在)就是原树中 v 到根路径上 v 以上的部分。v 的右子树是 v 的子孙(但根据 expose 性质,此时 v.right 应为 None)。
  3. 断开 v 与其左子树的连接。这相当于取消了 v 与其父节点之间边的标记,并完成了切割。
    • 代码描述: v.left.parent = None; v.left = None

时间复杂度分析(摊销 O(log n))

expose(v) 操作的时间主要花费在循环中的 splay 操作上。每个 splay 操作的摊销时间复杂度是 O(log n)

关键在于,一次 expose 中可能调用多次 splay。我们需要证明所有 splay 操作的总摊销时间仍然是 O(log n),而不是 O(k log n)(k 是路径上的边数)。

直观理解(非严格证明):
我们可以借助“树链剖分”中“轻边/重边”的概念来辅助分析。为森林定义一个固定的“重链剖分”。在 expose 过程中,每次循环我们标记一条边 (u-v),同时可能取消标记 u 原来连接的另一条边。

  • 如果被标记的边是重边,那么这次操作减少了“未标记的重边”数量。
  • 如果被标记的边是轻边,那么这次操作可能增加“未标记的重边”数量(因为取消了 u 原来可能连接的一条重边)。

我们定义一个势能函数Φ = (未标记的重边数量) * log n

  • 标记一条重边:势能减少 log n
  • 标记一条轻边:势能最多增加 log n

一次 expose 的实际操作代价约为 k * log n(k次splay)。其摊销代价 = 实际代价 + 势能变化。

  • 假设标记了 x 条重边和 y 条轻边 (k = x + y)。
  • 势能变化约为 -x*log n + y*log n
  • 摊销代价 ≈ (x+y)*log n + (-x+y)*log n = 2y*log n

根据重链剖分的性质,任意路径上的轻边数量 y 不超过 log n。因此,摊销代价为 O(log² n)。通过更精细地结合 Splay Tree 本身的摊销分析(势能定义为节点的“秩”之和),可以进一步将摊销代价优化到 O(log n)。详细证明较为复杂,但结论是:link, cut, query 操作的摊销时间复杂度均为 O(log n)

总结

本节课我们一起学习了 Link-Cut Tree 这一用于维护动态森林的强大数据结构。

  • 核心思想:通过动态的路径分解,将树分解为用 Splay Tree 维护的路径集合。
  • 关键操作expose(v) 是基石,它通过一系列 Splay 操作,将指定节点到根的路径整合到同一个 Splay Tree 中。
  • 操作实现
    • query(v): expose(v) 后从 Splay 根节点获取信息。
    • link(u, v): 对 uv 分别 expose,然后设置父指针。
    • cut(v): expose(v) 后断开其与左子树的连接。
  • 时间复杂度:所有核心操作的摊销时间复杂度均为 O(log n)

Link-Cut Tree 巧妙结合了路径分解、Splay Tree 的自调整特性以及摊销分析,是算法竞赛和高级数据结构课程中的经典内容。虽然初次接触可能觉得复杂,但理解了其核心思想后,你会发现它的设计非常精妙。

029:欧拉回路树与Tarjan算法

在本节课中,我们将学习两种处理树结构的重要技术。首先,我们将探讨欧拉回路树,这是一种支持动态连接和切割边,并能高效计算树上聚合函数的数据结构。随后,我们将学习Tarjan的离线LCA算法,这是一种在实践中非常高效且易于实现的技术,用于快速求解多组节点的最近公共祖先问题。


欧拉回路树:概念与表示

上一节我们介绍了处理动态树问题的需求。本节中,我们来看看如何利用欧拉回路来表示一棵树。

欧拉回路是对树的一种遍历方式,它从某个节点出发,沿着每条边恰好走两次(一去一回),最终回到起点,形成一个边的序列。这个序列包含了树的所有边信息。

对于一个无根树,我们可以从任意节点开始构建其欧拉回路。例如,对于下图中的树,从节点1开始的一个可能的欧拉回路边序列是:(1,3), (3,5), (5,3), (3,2), (2,3), (3,1)

这个边序列就是树的欧拉回路表示。我们将利用这个序列作为树的核心表示,并在此基础上支持动态操作。


支持的操作:连接、切割与查询

欧拉回路树需要支持三种核心操作,这与动态树问题(如Link-Cut Tree)类似,但实现更简单。

以下是三种操作的定义:

  1. 连接:给定两个不同树中的节点 uv,在它们之间添加一条边,将两棵树合并为一棵。
  2. 切割:给定一棵树中的一条边 (u, v),移除这条边,将树分割成两棵独立的树。
  3. 查询:给定一个节点 v,计算其所在整棵树上所有节点(或边)的某个聚合函数值(如求和、求最小值)。

数据结构实现:使用伸展树

为了高效地分割和合并欧拉回路序列,我们使用伸展树来维护这个序列。序列中的每个元素(边)对应伸展树中的一个节点。

因此,一棵树的欧拉回路就对应一棵伸展树。对树的操作将转化为对伸展树的分裂与合并操作。


切割操作详解

假设我们要切割边 (3,6)。在欧拉回路序列中,这条边会出现两次:一次是 (3,6),一次是 (6,3)

切割操作的步骤如下:

  1. 在伸展树中找到代表边 (3,6) 的节点,并通过伸展操作将其变为根节点。
  2. 将根节点的左子树和右子树分裂开。左子树对应原序列中 (3,6) 之前的部分,右子树对应之后的部分。
  3. 类似地,在伸展树中找到代表边 (6,3) 的节点,将其变为根并分裂其左右子树。
  4. 此时,我们得到了三个子树片段:A(3,6)之前)、B(3,6)(6,3)之间)、C(6,3)之后)。
  5. 片段 B 就是被切割下来的小树的欧拉回路。
  6. 将片段 A 和片段 C 合并,就得到了剩余大树的欧拉回路。

通过几次伸展树的分裂与合并,我们就在 O(log n) 时间内完成了切割操作。


连接操作详解

连接操作是切割的逆过程。假设我们要连接两棵独立的树,分别在节点 3 和节点 6 处添加边 (3,6)

设第一棵树的欧拉回路序列为 T1,第二棵为 T2。连接操作的步骤如下:

  1. T1 对应的伸展树中,找到代表节点 3 的任意位置(例如边 (1,3) 的节点),并将其分裂为左右两部分 L1R1
  2. T2 对应的伸展树中,找到代表节点 6 的任意位置,并将其分裂为左右两部分 L2R2
  3. 新的大树的欧拉回路可以通过按顺序合并以下片段构成:L1 -> 新边 (3,6) -> L2 -> R2 -> 新边 (6,3) -> R1
  4. 通过伸展树的合并操作,我们可以高效地构建出这个新序列。

为了快速定位节点在序列中的位置,一个巧妙的实现技巧是:在欧拉回路序列中不仅存储边,也存储节点。这样,每个节点在伸展树中都有一个明确的代表节点,查找和分裂操作就变得非常直接。


查询操作实现

查询操作是欧拉回路树简单性的体现。由于整棵树的欧拉回路完全由一棵伸展树维护,而伸展树天然支持区间查询。

实现方法如下:

  • 如果值存储在树的节点上,则在构建欧拉回路序列时,为每个节点创建一个对应的伸展树节点,并赋予其节点值;为每条边创建的节点则赋予中性值(如0)。
  • 如果值存储在树的上,则反之。
  • 当需要查询节点 v 所在整棵树的聚合值时,我们只需查询代表整棵树的伸展树的根节点存储的聚合值即可。因为伸展树每个节点都维护了其子树的聚合值,根节点就维护了整个序列(即整棵树)的聚合值。

因此,查询操作可以在 O(1)O(log n) 时间内完成(取决于伸展树是否需要在查询后调整)。


Tarjan离线LCA算法

现在,我们转换话题,来看一个解决最近公共祖先问题的经典离线算法——Tarjan算法。它基于深度优先搜索和并查集,实现非常简洁。


算法核心思想

Tarjan算法要求所有查询 (u, v) 预先已知。算法对树进行一次深度优先遍历,并在回溯的过程中利用并查集来回答查询。

核心思想是:当遍历到节点 v 时,对于所有形如 (u, v) 的查询,如果 u 已经被访问过,那么 uv 的 LCA 就是 u 当前所在集合的代表元(即 u 向上走最先遇到的未完全回溯的祖先)。


算法步骤

以下是算法的具体步骤:

  1. 预处理:为每个节点 v 建立一个列表,存放所有与 v 相关的查询 (u, v)
  2. 深度优先遍历:从根节点开始进行DFS。
  3. 访问节点:当首次访问节点 v 时,将其标记为“正在访问”。
  4. 处理子节点:递归地遍历 v 的所有子节点。
  5. 回答查询:在处理完 v 的所有子节点后,遍历 v 的查询列表。对于每个查询 (u, v)
    • 如果 u 已经被访问过且已经完成了回溯(即不在当前路径上),则此时 u 所在集合的代表元就是 uv 的 LCA。记录答案。
  6. 回溯与合并:在节点 v 的所有处理完成后,将 v 与其父节点在并查集中合并。这表示 v 及其子树的所有节点,它们的“向上最先遇到的未回溯祖先”变成了 v 的父节点。
  7. 完成:当遍历完整棵树后,所有查询的答案都已得出。

时间复杂度和优势

Tarjan算法的时间复杂度为 O(n + m * α(n)),其中 n 是节点数,m 是查询数,α 是反阿克曼函数,增长极其缓慢,在实际应用中可视为常数。

虽然理论上存在 O(n + m) 的在线算法,但Tarjan算法的常数因子非常小,且代码简洁,因此在实践中往往效率更高。它也是许多需要离线处理树上路径问题的基础。


总结

本节课中我们一起学习了两种强大的树算法。

  1. 我们首先学习了欧拉回路树,它通过将树的欧拉回路序列用伸展树维护,优雅地支持了动态树的连接、切割和子树聚合查询操作,代码实现相对直观。
  2. 接着,我们学习了Tarjan离线LCA算法,它利用深度优先遍历和并查集,以近乎线性的时间复杂度高效地回答了大量预知的最近公共祖先查询,是实践中不可或缺的利器。

掌握这两种算法,将极大地增强你处理复杂树形数据结构问题的能力。

030:重心分解 🌳

在本节课中,我们将要学习一种用于处理树形结构的高级技巧——重心分解。重心分解是一种“分而治之”的策略,它允许我们高效地解决两类常见问题:一类是计算树中所有路径的某种属性,另一类是在树的某个区域内回答在线查询。我们将通过简单的例子来理解其核心思想,并学习如何实现它。

什么是重心分解?🤔

重心分解是一种将树递归地分解为更小子结构的方法。其核心是找到一个称为“重心”的特殊节点。

定义:对于一棵有 n 个节点的树,一个节点 c 被称为重心,当且仅当移除该节点后,产生的每个连通分量的大小都不超过 n/2

一个重要的性质是:任何树都至少有一个重心。我们可以通过一个简单的算法在线性时间内找到它。

如何找到重心?🔍

上一节我们介绍了重心的定义,本节中我们来看看如何高效地找到它。算法步骤如下:

  1. 从任意节点(例如节点 v)开始,进行深度优先搜索(DFS),计算以每个节点为根的子树大小。
  2. 检查当前节点 v。如果对于 v 的每一个子节点 u,其子树大小 size[u] <= n/2,并且 n - size[v] <= n/2(即 v “上方”的部分也不超过 n/2),那么 v 就是重心。
  3. 如果 v 不是重心,那么必然存在一个子节点 u,其子树大小 size[u] > n/2。此时,重心必然在子树 u 中。我们令 v = u,并重复步骤 2。

以下是查找重心的伪代码:

def find_centroid(v, parent, total_size):
    size[v] = 1
    is_centroid = True
    for u in neighbors[v]:
        if u != parent and not removed[u]:
            find_centroid(u, v, total_size)
            size[v] += size[u]
            if size[u] > total_size // 2:
                is_centroid = False
    if total_size - size[v] > total_size // 2:
        is_centroid = False
    if is_centroid:
        centroid = v

重心分解的构建 🏗️

找到重心后,我们就可以递归地构建整个分解结构。过程如下:

  1. 找到当前树(或子树)的重心 c
  2. 将节点 c “移除”(标记为已处理),原树被分割成若干个互不相连的连通分量。
  3. 对每一个连通分量,递归地执行步骤 1 和 2。

这个过程会形成一棵以重心为节点的“分解树”。每个原始节点都会出现在从根到叶子的某条路径上,且这条路径的长度不超过 O(log n)

应用一:处理所有路径问题 🛤️

上一节我们构建了分解结构,本节中我们来看看如何利用它解决第一类问题:计算树中所有路径的某种属性。我们以计算“距离不超过 K 的节点对数量”为例。

思路:利用分治思想。对于当前子树的重心 c

  • 所有不经过 c 的路径,必然完全包含在某个子分量中,可以通过递归调用解决。
  • 所有经过 c 的路径 (u, v),可以拆分为 u->cc->v 两段。问题转化为:在 c 的不同子分量中,寻找满足 dist(c, u) + dist(c, v) <= K 的节点对 (u, v)

以下是计算经过重心 c 的合法路径数量的方法:

  1. 预处理从重心 c 到其所在子树中所有节点的距离。
  2. 收集所有子分量中的节点距离,形成一个数组,并排序。
  3. 对于每个子分量:
    • 先计算在整个数组中,有多少节点 v 满足 dist(c, v) <= K - dist(c, u)(使用二分查找)。
    • 再减去在本子分量内部满足该条件的节点数(避免重复计算同一分量内的点对,因为它们在递归中已处理)。
  4. 将结果累加。

这样,每一层递归的总时间复杂度为 O(n log n),递归深度为 O(log n),总时间复杂度约为 O(n log² n)。若能在线性时间内完成合并,则可优化至 O(n log n)

应用二:回答区域查询问题 📍

第二类问题是:在线回答形如“在节点 v 距离 D 范围内,所有节点的某种属性(如最小值、和)”的查询。

思路:利用分解的层次结构进行预处理。对于每个原始节点 v,我们预处理出它在分解树中所有祖先重心(即包含 v 的各级子分量的重心)列表,以及 v 到这些重心的距离。

当查询 (v, D) 到来时:

  1. 我们遍历 v 对应的所有重心祖先 c_i
  2. 对于每个重心 c_i,合法的节点 u 需满足:dist(v, c_i) + dist(c_i, u) <= D,即 dist(c_i, u) <= D - dist(v, c_i)
  3. 对于每个重心 c_i,我们已经预处理了其所在分量中所有节点到它的距离,并排序存储。我们只需在该数组中二分查找满足上式的节点数量或计算其属性(如最小值、前缀和等)。

由于每个节点所属的重心祖先不超过 O(log n) 个,因此每次查询的时间复杂度为 O(log² n)(若使用 O(1) 查询的数据结构维护前缀信息,可降至 O(log n))。

注意:若要计算“和”等可减属性,在计算重心 c_i 的贡献时,需减去与 v 处于 c_i 同一子分量的那些节点的贡献,以避免重复计算。这可以通过为每个子分量单独维护一个有序数组来实现。

总结 📚

本节课中我们一起学习了树的重心分解技术。

  • 我们首先学习了重心的定义和高效的查找算法。
  • 接着,我们了解了如何递归地构建重心分解,形成一棵深度为 O(log n) 的分解树。
  • 然后,我们探讨了重心分解的两大主要应用:
    1. 处理所有路径问题:通过“分治-合并”的策略,将路径按是否经过重心分类处理,典型时间复杂度为 O(n log² n)
    2. 回答区域查询问题:通过预处理每个节点到其所有重心祖先的距离,将树上区域查询转化为多个关于重心距离的二分查找,典型单次查询时间复杂度为 O(log² n)

重心分解是一个强大的工具,它将树的结构以平衡的方式层层分解,使得许多复杂问题得以高效解决。掌握其核心思想后,你可以尝试用它来解决更多有趣的树上问题。

031:外部存储算法 🗃️

在本节课中,我们将要学习外部存储算法。我们将了解为什么需要这种特殊的算法模型,它与我们熟悉的RAM模型有何不同,并学习如何设计适用于处理海量数据的算法。

概述:为什么需要外部存储算法?

通常,当我们讨论算法时,我们工作在RAM模型中。在这个模型中,我们假设所有数据都存储在一个大的内存数组中,并且可以在常数时间内访问任何数据。这个模型对于标准计算机程序来说非常合适。

但是,有时数据量太大,无法全部放入内存。这时,数据必须存储在外部存储设备上,例如硬盘。硬盘的物理结构与内存不同,访问数据的方式也大相径庭。

硬盘的工作原理与访问延迟

硬盘由一个高速旋转的盘片和一个读写磁头组成。数据分布在盘片表面。磁头只能读取其当前位置下方的数据。

当你需要访问硬盘上的某个特定数据时,磁头必须等待盘片旋转,直到目标数据移动到磁头下方。这个等待时间,称为寻道时间,大约为10毫秒。

10毫秒看似很短,但现代CPU每秒可以执行数十亿次操作。相比之下,如果每次只读取一个字节,那么读取速度将非常慢,大约只有100字节/秒。

然而,硬盘可以连续读取大块数据。一旦磁头定位到数据块的起始位置,它就可以高速连续读取整个块。典型的连续读取速度可以达到约100 MB/秒。

因此,连续读取的速度比随机访问单个字节快大约一百万倍。这个巨大的差异意味着RAM模型在处理外部存储数据时不再适用。

外部存储模型

为了简化问题并设计高效的算法,我们引入外部存储模型。这个模型抽象了硬盘的关键特性:

  1. 数据存储在外部存储(如硬盘)中。
  2. 我们有一个有限的内部内存(大小记为 M),可以快速访问。
  3. 数据在内部内存和外部存储之间以固定大小的块(大小记为 B)进行传输。
  4. 我们只关心输入/输出(I/O)操作的次数,即读取或写入一个块的次数。在内部内存中进行的计算被认为是免费的(零时间)。

该模型有两个关键参数:内存大小 M 和块大小 B。算法的时间复杂度将表示为包含 N(数据总量)、MB 的公式。

上一节我们介绍了外部存储模型的基本概念,本节中我们来看看如何在这个模型下进行简单的计算。

扫描:计算数组元素之和

假设我们有一个大小为 N 的数组存储在外部硬盘上,我们想计算所有元素的总和。

在RAM模型中,我们只需遍历数组,将每个元素加到累加器中。时间复杂度是 O(N)

在外部存储模型中,我们不能逐个元素访问,而必须按块读取。我们将数组分成若干个大小为 B 的块。

以下是计算步骤:

  1. 从硬盘读取第一个块到内部内存。
  2. 在内存中计算这个块内所有元素的和。
  3. 重复步骤1和2,直到处理完所有块。
  4. 将所有块的和相加得到最终结果。

这个算法需要读取 N/B 个块(假设N是B的倍数)。因此,I/O复杂度是 O(N/B)。与RAM模型的O(N)相比,这实际上快了 B 倍,因为每次I/O操作都带来了B个数据项。

排序:外部归并排序

排序是数据处理的核心操作。我们如何在外部存储模型中高效地排序呢?

我们之前学过的归并排序非常适合这个模型,因为它主要涉及数据的顺序访问。

两路归并

首先,回顾如何合并两个已排序的数组。我们使用两个指针,分别指向两个数组的当前元素,比较它们,将较小的元素放入输出数组,并移动相应的指针。

在外部存储模型中,我们这样做:

  1. 为两个输入数组和输出数组各在内部内存中维护一个块缓冲区
  2. 从硬盘读取每个输入数组的第一个块到其缓冲区。
  3. 比较两个缓冲区当前的最小元素,将较小的输出到输出缓冲区。
  4. 当一个输入缓冲区被耗尽时,从硬盘读取该输入数组的下一个块。
  5. 当输出缓冲区被填满时,将其作为一个块写入硬盘,并清空以接收更多数据。

合并两个总大小为 N 的数组,I/O复杂度为 O(N/B)

多路归并与排序算法

标准的归并排序是递归地将数组分成两半,分别排序后再合并。在外部存储模型中,我们可以做得更好。

我们的内部内存可以容纳不止两个块。假设内存可以容纳约 M/B 个块。那么,我们可以一次性合并 M/B 个已排序的子数组,而不是仅仅两个。

这改变了递归树:

  • 我们不是将问题分成2份,而是分成 K ≈ M/B 份。
  • 递归深度从 log₂ N 减少到 logₖ N,其中 K = M/B
  • 在每一递归层,我们仍然需要线性扫描所有数据以进行合并,I/O复杂度为 O(N/B)

因此,外部归并排序的总I/O复杂度为:
O( (N/B) * log_{M/B} (N/B) )

可以证明,对于基于比较的排序,这是外部存储模型下的最优时间复杂度

数据结构:栈、队列与B树

我们也可以在外部存储模型中设计数据结构。

实现一个外部存储栈:

  1. 在内部内存中维护栈的顶部一个或两个块作为缓冲区。
  2. 压入操作:将元素放入顶部缓冲区。如果缓冲区满,则将其作为块写入硬盘,并启用新的空缓冲区。
  3. 弹出操作:从顶部缓冲区取出元素。如果缓冲区空,则从硬盘读入前一个块到缓冲区。

通过维护两个块的缓冲区,可以平摊开销,使得每个栈操作的均摊I/O复杂度为 O(1/B)。这意味着每B次操作平均只需要一次I/O。

B树

对于需要随机访问的字典结构(如映射、集合),B树是外部存储的标准索引结构。

B树的特点:

  • 每个节点包含多达 B 个键值,而不是像二叉搜索树那样只有一个。
  • 一个包含 k 个键的节点有 k+1 个子节点指针。
  • 树的高度大约是 log_{B} N

搜索过程:从根节点开始,读取一个节点(一个块)到内存,在节点的键中找到合适的区间,然后进入相应的子节点。因此,一次搜索需要 O(log_{B} N) 次I/O操作。

虽然这比内存中的二叉搜索树慢,但对于必须支持任意随机访问的外部存储数据结构来说,这是高效的。像哈希表这样的结构在外部存储中也可能面临类似挑战,因为随机访问模式会导致大量I/O。

算法技巧:排序与连接

许多外部存储算法的一个常见模式是:先排序,后处理

示例1:构建排列的逆

问题:给定一个排列 P(即一个包含1到N每个数字恰好一次的数组),存储在外部。想构建其逆排列 R,使得 R[P[i]] = i

在RAM中,可以直接赋值:for i: R[P[i]] = i。但这在外部存储中是随机的写操作,效率低下。

外部存储解法:

  1. 生成一个操作列表,每个操作是 (目标索引, 值),即 (P[i], i)
  2. 将这个列表按照目标索引排序
  3. 现在,操作列表已按顺序(目标索引)排列。顺序地读取每个操作,并将值写入输出数组的相应位置。此时的写操作是连续的。

主要开销在于排序,因此I/O复杂度为 O((N/B) log_{M/B} (N/B))

示例2:数组复合

问题:给定数组 AB,构建数组 C,使得 C[i] = B[A[i]]。这类似于根据A中的值查找B。

我们可以将其视为两个表的连接操作:

  1. 构建表1:(i, A[i])
  2. 构建表2:(j, B[j]),其中j本身就是索引,所以实际上是 (j, B[j]),但j从1到N。
  3. 我们需要根据表1的第二个字段(A[i])和表2的第一个字段(j)进行连接。
  4. 将两个表分别按照连接键排序:表1按 A[i] 排序,表2按 j 排序。
  5. 使用类似归并排序的双指针方法扫描两个已排序的表,当键匹配时(A[i] == j),我们就知道 C[i] = B[j]

同样,主要开销是排序。

总结

本节课中我们一起学习了外部存储算法的核心内容。

我们首先了解了传统RAM模型的局限性,以及当数据量超出内存时,必须考虑数据存储的物理特性(如硬盘的连续访问远快于随机访问)。由此,我们引入了外部存储模型,其核心是使用块I/O来传输数据,并关注I/O次数而非CPU计算次数。

接着,我们探讨了该模型下的基本操作:扫描(求和)和排序。外部归并排序通过利用内存进行多路归并,达到了近乎最优的I/O复杂度 O((N/B) log_{M/B} (N/B))

然后,我们研究了如何设计外部存储数据结构,例如高效的和适用于索引的B树

最后,我们学习了外部存储算法的一个关键策略:“先排序,后处理”。通过将随机访问模式转化为顺序访问模式(如构建排列逆和数组复合的例子),我们可以极大地提升处理海量数据的效率。

掌握这些思想,对于处理数据库、大数据系统以及任何需要高效利用存储层次结构(如CPU缓存 vs. 内存)的场合都至关重要。

032:复杂度类

在本节课中,我们将学习复杂度理论的基础知识。我们将探讨哪些问题是可解的,哪些是不可解的,以及如何根据解决问题的计算难度对问题进行分类。课程将涵盖P类、NP类、可判定性以及NP完全性等核心概念。


什么是复杂度类?

上一节我们介绍了算法的时间复杂度。本节中,我们来看看如何根据计算难度对问题进行分类。

我们讨论过的大多数问题都有特定的时间复杂度,例如 O(n log n)O(n³)。在复杂度理论中,所有能在多项式时间内解决的问题被归入同一个复杂度类,称为 P类

P类 是所有能在多项式时间内解决的问题的集合。如果一个问题的求解时间复杂度是 nᵏ(其中k是常数),那么该问题就属于P类。

我们之所以不区分不同的多项式时间复杂度(如n²和n¹⁰),是因为“能在多项式时间内解决”这一性质与所使用的计算模型无关。无论是在RAM模型还是图灵机模型中,只要一个问题在某个模型中存在多项式时间解法,它在其他标准计算模型中同样存在多项式时间解法。


图灵机模型

为了理论上的简洁性,我们通常使用一个非常简单的计算模型:图灵机

图灵机包含以下部分:

  • 一个无限长的纸带,被划分为格子。
  • 一个读写头,每次只能访问纸带上的一个格子。
  • 一个有限状态控制器(程序)。

程序是一个简单的自动机。在每个步骤中,它根据当前状态和读写头下的符号,决定:

  1. 将当前格子写入一个新符号。
  2. 将读写头向左或向右移动一格(或不动)。
  3. 转移到下一个状态。

尽管图灵机模型非常简单,但它足以模拟任何现代计算机(RAM模型)的计算过程。在RAM模型中需要常数时间的操作(如数组索引),在图灵机中可能需要多项式时间(如O(n²))来模拟,但这仍然属于多项式时间。因此,P类 的定义在图灵机模型下依然成立。


超出P类的问题

那么,是否存在不属于P类的问题?答案是肯定的。

考虑一个 n × n 的国际象棋棋盘,上面有若干棋子。问题是:给定一个棋局,判断执白方是否必胜。

可以证明,这个问题无法在多项式时间内解决。虽然棋盘状态是有限的(总状态数是有限的),我们可以通过穷举所有可能状态来求解,但这种方法所需的时间是指数级的,远超任何多项式时间。

这类可以在指数时间内解决的问题,属于 EXP类。指数时间意味着时间复杂度形如 2^(nᵏ)n! 等。几乎所有实际可解但不属于P类的问题,都属于EXP类。


可判定性问题与不可解问题

接下来,我们探索比EXP类更难的问题:那些不可解的问题。

所有能被图灵机在有限步内解决的问题,构成 R类(递归可判定问题)。那么,是否存在不属于R类的问题?即,是否存在任何图灵机都无法解决的问题?

艾伦·图灵发现,这样的问题是存在的。最经典的例子是 停机问题

停机问题描述如下:给定一个程序(描述)和它的输入,判断这个程序在给定输入上是否会停止(在有限步内结束运行),还是会永远运行下去。

我们可以用反证法证明停机问题是不可解的:

  1. 假设存在一个算法 HALT(P, X),它能判断程序 P 在输入 X 上是否会停机。
  2. 现在我们构造一个新程序 F(P)
    • 它调用 HALT(P, P),即判断“程序P以自身代码作为输入时是否会停机”。
    • 如果 HALT(P, P) 返回“是”(会停机),那么 F 就进入无限循环(不停机)。
    • 如果 HALT(P, P) 返回“否”(不会停机),那么 F 就立刻停止。
  3. 现在,考虑当 F 以自身代码 F 作为输入时会发生什么 (F(F)):
    • 如果 F(F) 会停机,那么根据 F 的逻辑,HALT(F, F) 应返回“否”,这意味着 F(F) 不会停机,矛盾。
    • 如果 F(F) 不会停机,那么根据 F 的逻辑,HALT(F, F) 应返回“是”,这意味着 F(F) 会停机,同样矛盾。
  4. 因此,我们的假设不成立,这样的 HALT 算法不存在。停机问题是不可解的

这个结论意义深远。它意味着,分析程序的非平凡属性(如是否会出错、是否有某个功能)本质上是不可解的问题。我们日常使用的代码分析工具(如IDE的静态检查)只能基于启发式方法发现常见模式中的错误,而无法对任意复杂程序做出完全正确的判定。


图灵完备系统

不可解性不仅限于程序分析。许多看似简单的系统,只要它们足够复杂(被称为图灵完备),其行为分析就同样是不可解的。

图灵完备 意味着该系统可以模拟一台通用图灵机的计算。如果一个系统是图灵完备的,那么关于该系统行为的许多判定问题(例如“给定初始状态,系统最终会稳定吗?”)就都是不可解的,因为你可以将停机问题归约到该系统的判定问题上。

以下是几个图灵完备系统的例子:

  1. 康威的生命游戏:一个基于简单规则的二维细胞自动机。可以证明它能模拟图灵机。
  2. 王浩铺砖问题:给定一组带有颜色的瓦片,判断能否用它们铺满整个平面而不违反颜色匹配规则。这个问题是不可解的。
  3. 某些一维细胞自动机:例如规则110(Rule 110),它只有极其简单的几条规则,但被证明是图灵完备的。

这些例子表明,当一个系统拥有无限内存并展现出复杂行为时,它往往就是图灵完备的,从而导致关于其行为的判定问题不可解


NP类与NP完全问题

现在,让我们回到可解问题的范畴,看看P类和EXP类之间的区域。这里存在一个非常重要的复杂度类:NP类

NP类 不是“非多项式时间”,而是“非确定性多项式时间”。一个问题属于NP类,如果它的解可以在多项式时间内被验证。更形式化地说,存在一个非确定性图灵机,它可以在多项式时间内“猜”出一个解(证书),并验证其正确性。

NP类 的一个等价定义是:如果存在一个多项式时间算法 V(x, c),使得对于问题实例 x,当且仅当存在一个证书 c 满足 V(x, c) 返回真时,x 的答案为“是”。

显然,P ⊆ NP。因为如果一个问题能在多项式时间内解决,我们自然能在多项式时间内验证其解(直接解决它并比较结果即可)。

NP类中最困难的问题被称为 NP完全问题。一个问题Q是NP完全的,需要满足两个条件:

  1. Q ∈ NP
  2. NP中的任何问题都可以在多项式时间内归约到Q。这意味着,如果你能在多项式时间内解决Q,那么你就能在多项式时间内解决NP类中的所有问题。

“归约”是指:假设我们知道如何解决问题A。要解决问题B,我们可以将B的实例通过一个多项式时间变换,转换成A的实例,然后用解A的方法来得到B的答案。

NP难问题 则只需满足上述第二个条件(任何NP问题可归约到它),但不一定属于NP类。NP完全问题是NP难问题中属于NP的那一部分。


证明NP完全性:以背包问题为例

如何证明一个新问题是NP完全的?标准方法是:

  1. 证明该问题属于NP类(即它的解能在多项式时间内被验证)。
  2. 选择一个已知的NP完全问题(如三元布尔可满足性问题,3-SAT)。
  3. 将已知的NP完全问题多项式归约到你的新问题。

让我们以子集和问题(背包问题的一个简化版本)为例进行证明。

子集和问题:给定一个整数集合 {w₁, w₂, ..., wₙ} 和一个目标整数 S,判断是否存在一个子集,其元素之和恰好等于 S

证明步骤

  1. 子集和问题 ∈ NP:给定一个候选子集(证书),我们可以在多项式时间内求和并检查是否等于S。
  2. 从3-SAT归约:给定一个3-SAT公式,我们构造一个子集和问题的实例。
    • 对于公式中的每个布尔变量 xᵢ,我们创建两个“物品”,分别代表 xᵢ = truexᵢ = false
    • 每个“物品”用一个多位数表示。每一位对应一个子句。
    • 如果该变量(或其否定形式)出现在某个子句中,则对应数位为1,否则为0。
    • 此外,我们添加额外的“控制位”来确保对于每个变量,最终解中只能选择两个物品中的一个(truefalse)。
    • 最后,我们添加一些辅助物品,使得每个子句对应的数位之和必须达到特定值(例如3),以确保每个子句中至少有一个文字为真。
    • 目标总和 S 就是由这些特定值(每个子句位为3,每个控制位为1)构成的多位数。
  3. 归约正确性:可以证明,原3-SAT公式是可满足的,当且仅当构造出的子集和问题实例有解。因为满足赋值会对应一个物品子集,其和恰好为S;反之,一个和为S的物品子集也对应了一个变量的赋值,该赋值满足所有子句。
  4. 归约是多项式时间的:构造过程仅涉及简单的计数和赋值,可以在多项式时间内完成。

由于3-SAT是NP完全的,并且我们成功地将它多项式归约到了子集和问题,因此子集和问题也是NP完全的

第一个被证明是NP完全的问题是电路可满足性问题。而证明一个问题是NP完全的“起点”,通常是证明非确定性图灵机的有界接受问题是NP完全的。这个问题是“通用的”,因为它直接模拟了NP问题的定义:给定一个非确定性图灵机、一个输入和一个步数界限,判断机器是否能在该步数内接受输入。从这个通用问题出发,可以归约到3-SAT,再归约到其他问题,从而建立起NP完全问题的庞大类别。


P vs NP 问题

NP完全问题的核心重要性引出了理论计算机科学中最重要的开放问题:P 是否等于 NP?

  • 如果 P = NP,意味着所有能在多项式时间内验证解的问题,也都能在多项式时间内找到解。这将彻底改变密码学、优化和人工智能等领域。
  • 如果 P ≠ NP(这是学界普遍相信的情况),则意味着NP完全问题本质上就比P类问题困难,不存在通用的多项式时间解法。

尽管经过数十年的研究,这个问题仍未解决。它可能本身就是一个在现有数学体系内不可判定的命题。


总结

本节课中我们一起学习了复杂度理论的核心概念:

  1. P类:多项式时间内可解的问题。
  2. EXP类:指数时间内可解的问题。
  3. R类:所有可判定(可解)的问题。
  4. 不可解问题:如图灵机也无法解决的停机问题。
  5. 图灵完备系统:许多复杂系统(如生命游戏)是图灵完备的,导致其行为分析不可解。
  6. NP类:解能在多项式时间内被验证的问题。
  7. NP完全与NP难问题:NP类中最难的问题;证明NP完全性的方法是多项式时间归约。
  8. P vs NP 问题:关于计算本质的核心未解之谜。

理解这些概念有助于我们认识到实际工程问题的内在计算难度,从而合理选择近似算法、启发式方法或调整问题定义来寻找可行的解决方案。

033:图论。深度优先搜索。拓扑排序 🎼

在本节课中,我们将要学习图论的基础知识,包括图的基本概念、两种常见的存储方式,以及一个核心算法——深度优先搜索。我们还将探讨深度优先搜索的一个重要应用:在有向无环图中进行拓扑排序。

什么是图?📊

图本质上是一组“圆圈”和连接这些“圆圈”的“线段”。

这些“圆圈”被称为顶点,这些“线段”被称为

图主要分为两种类型:有向图无向图。在无向图中,边没有方向。在有向图中,每条边都有一个方向。

这两种图都非常重要。有向图通常出现在描述系统状态转换的问题中,例如从一个状态转移到另一个状态。无向图则通常用于表示对象之间的对称关系。

图的基本参数与存储 📦

图有两个重要的参数:顶点数量 n 和边的数量 m。在讨论算法的时间复杂度时,我们会使用这两个变量。通常我们假设 m 至少为 n-1(连通图),且不超过

接下来,我们看看如何在程序中存储图。有两种主要方式。

第一种是邻接矩阵。创建一个 n x n 的矩阵,对于每条边 (u, v),在矩阵的对应位置 [u][v] 标记为 1。这种方式需要 O(n²) 的内存空间。

第二种是邻接表。为每个顶点维护一个列表,存储所有与该顶点直接相连的邻居顶点。所有列表的总长度等于边的数量 m(对于无向图是 2m)。当图比较稀疏(即 m 远小于 )时,邻接表是更高效的选择。

深度优先搜索算法 🔍

深度优先搜索是一个基础且重要的图遍历算法。我们将用它来解决第一个问题:在无向图中找出所有的连通分量。连通分量是指图中最大的连通子图,在同一个分量里的任意两个顶点之间都存在路径。

以下是使用递归实现的深度优先搜索核心代码,用于标记一个连通分量中的所有顶点:

visited = [False] * n  # 标记数组

def dfs(v):
    visited[v] = True   # 标记当前顶点
    for u in adj_list[v]:  # 遍历所有邻居
        if not visited[u]:
            dfs(u)         # 递归访问未标记的邻居

算法从某个起始顶点 v 开始,标记它,然后递归地访问所有未被标记的邻居顶点。这个过程会遍历完 v 所在的整个连通分量。

为了找出图中的所有连通分量,我们需要在外层循环中遍历所有顶点,对每个未被访问的顶点调用 dfs 函数。

该算法的时间复杂度是 O(n + m)。因为每个顶点只被访问一次,每条边(在邻接表中)也只被检查一次。

深度优先搜索的应用:拓扑排序 ⚙️

现在,我们将深度优先搜索应用到有向图上,解决一个经典问题:拓扑排序

拓扑排序是针对有向无环图的顶点进行线性排序,使得对于图中的每一条有向边 (u, v)u 在排序中都出现在 v 之前。这就像为一系列有依赖关系的任务(例如编译依赖、课程选修)安排一个可行的执行顺序。

基于DFS的拓扑排序算法

算法基于深度优先搜索,并添加了一个简单的后序操作:

order = []  # 用于存储拓扑排序的结果
visited = [False] * n

def dfs(v):
    visited[v] = True
    for u in adj_list[v]:
        if not visited[u]:
            dfs(u)
    order.append(v)  # 在递归返回前将顶点加入列表

# 主程序:遍历所有顶点
for v in range(n):
    if not visited[v]:
        dfs(v)

# 最终得到的 order 是逆序的拓扑排序,需要反转
topological_order = order[::-1]

算法正确性证明:考虑任意一条边 (v -> u)。在DFS过程中,只有当 u 的所有后继都被访问完毕后,u 才会被加入 order 列表,然后递归函数返回到 v,最后 v 才被加入列表。因此,u 一定出现在 v 之前。将最终列表反转后,就保证了 vu 之前,满足拓扑排序的定义。如果图中存在环,则无法得到有效的拓扑排序。

基于入度的Kahn算法

另一种拓扑排序算法是Kahn算法,思路更加直观:

  1. 计算每个顶点的入度(有多少条边指向它)。
  2. 将所有入度为0的顶点加入一个队列。
  3. 当队列不为空时:
    • 取出队首顶点 v,将其加入拓扑序。
    • 遍历 v 的所有出边 (v -> u),将顶点 u 的入度减1。如果 u 的入度变为0,则将 u 加入队列。
  4. 如果最终拓扑序中的顶点数量等于 n,则排序成功;否则,说明图中存在环。

Kahn算法同样具有 O(n + m) 的时间复杂度。

边的分类与环检测 🔄

在深度优先搜索生成的有向图DFS树(或森林)中,我们可以将边分为三类:

  1. 树边:DFS遍历时使用的边,构成了搜索树。
  2. 后向边:从某个顶点指向其DFS树中祖先的边。后向边的存在意味着图中存在环
  3. 横叉边:连接DFS树中不同子树,或者从后访问的顶点指向先访问的顶点的边(在无向图中不会出现横叉边)。

利用DFS过程中的进入时间离开时间戳,我们可以判断一条边的类型。这对于分析图的结构和检测环非常有用。

环检测的简单方法:在基于DFS的拓扑排序中,如果最终得到的顶点序列长度小于 n,或者发现了一条“后向边”(即访问到一个已访问但未完成递归调用的顶点),则说明图中存在环。

总结 📝

本节课我们一起学习了图论的基础知识。

  • 我们了解了图的基本概念:顶点、边、有向图与无向图。
  • 学习了两种存储图的数据结构:邻接矩阵和邻接表。
  • 深入探讨了深度优先搜索算法的递归实现、时间复杂度和其用于寻找连通分量的应用。
  • 我们掌握了拓扑排序的概念和两种算法:基于DFS后序的算法和基于入度的Kahn算法,并理解了它们如何工作以及如何检测图中的环。
  • 最后,我们了解了在DFS过程中对边进行分类的方法,这为后续学习更复杂的图算法奠定了基础。

深度优先搜索是图算法中一个非常强大的工具,在接下来的课程中,我们还会看到它的更多应用。

034:强连通分量与2-SAT问题

在本节课中,我们将继续讨论图论。我们将继续探讨深度优先搜索算法,并讨论该算法的另一个应用,这个应用比上一讲的内容更有趣。

今天我们要讨论的是有向图中的连通性。如果你还记得上一讲,我们讨论了无向图中的连通性。在无向图中,连通性非常简单:你有一些连通分量,如果两个顶点属于同一个连通分量,那么它们就是连通的。如果你从两个不同的连通分量中取顶点,它们就不连通。

然而,在有向图中,情况稍微复杂一些,因为这种关系不是对称的。你可能有一个节点 V 和一个节点 U,U 可以从 V 到达,但 V 不一定能从 U 到达。所以它不是对称的。

今天我们要讨论的是,如果你从宏观角度看有向图中的连通性是如何运作的。

强连通分量

让我们定义以下关系:我们说两个节点是强连通的,如果存在从 V 到 U 的路径,也存在从 U 到 V 的路径。

这个关系实际上是对称的,并且也是传递的。因此,它会形成一些等价类。所有节点将被分割成一些集合,在每个集合中,所有节点都彼此强连通。这些分量被称为强连通分量

例如,考虑一个图。在这个图中,我们有一些节点集合,使得在每个集合中,所有节点都彼此强连通。例如,从这四个节点中的任何一个到其他节点都有路径。所以这四个节点构成一个强连通分量。很容易看出,所有其他节点与这些节点没有连接。所以这四个节点属于同一个强连通分量,而其他节点属于一些不同的强连通分量。

在这个图的另一部分,你有另外两个节点,它们之间可以双向到达,所以这两个节点是另一个强连通分量。你还有一个单独的节点,这个单独的节点是一个只有一个节点的强连通分量,这是可能的。

所以这个图被分割成三个强连通分量。

图的凝聚

你可以枚举这些强连通分量。例如,将它们标记为 A、B、C。现在,你可以构建另一个图,它由这些强连通分量组成。对于每个强连通分量,我们构建一个顶点。然后,如果这些连通分量之间有边,我们就构建边。

例如,如果有一条从属于分量 A 的节点 3 到属于分量 B 的节点 1 的边,那么我们在凝聚图中添加一条从 A 到 B 的边。

这个图被称为原图的凝聚

关于这个图有趣的一点是,在任何图的凝聚中,它总是一个有向无环图。为什么?因为如果你在凝聚图中有一个环,那么这个环中的所有节点实际上应该属于同一个强连通分量。但在凝聚图中,每个强连通分量被压缩成一个节点,所以凝聚图中不能有任何环。

有向无环图对于许多算法来说更容易处理,因为你可以对其进行拓扑排序,然后运行一些算法。因此,在许多算法中,第一步就是找出图的强连通分量,构建凝聚图,然后在这个凝聚图上运行为有向无环图设计的特定算法。

寻找强连通分量的算法

现在我们需要构建一个算法,来找出图的所有强连通分量。找到所有强连通分量后,就很容易构建凝聚图。你只需要知道每个节点属于哪个分量,然后遍历所有边,如果边的两个节点属于不同的分量,就在凝聚图中添加一条边。

首先,让我们构建一个简单的算法。如果你不关心时间,只想找到所有强连通分量,你可以使用深度优先搜索。例如,取节点 1,运行深度优先搜索,找到所有从节点 1 可达的节点。然后,在反向边上运行另一个深度优先搜索,找到所有可以到达节点 1 的节点。然后,取这两个集合的交集。这个交集就是与节点 1 属于同一个强连通分量的节点。

然后,你取下一个尚未标记的节点,重复这个过程。

然而,这个算法的时间复杂度可能很高。在最坏情况下,你可能需要为每个节点运行两次深度优先搜索,导致时间复杂度为 O(N * M),这对于大图来说太高了。

幸运的是,有更快的算法。主要有两种算法:Tarjan 算法和 Kosaraju 算法。今天我们将讨论 Kosaraju 算法。这是一个非常简单的算法,代码简洁,时间复杂度是线性的。

Kosaraju 算法

以下是 Kosaraju 算法的步骤:

  1. 对原图运行第一次深度优先搜索,并按退出顺序将节点加入列表。这与我们在构建拓扑排序时所做的类似。
  2. 将得到的节点列表反转。
  3. 反向图上运行第二次深度优先搜索,但按照第一步得到的列表顺序(从最左边的节点开始)访问节点。
  4. 第二次深度优先搜索每次启动所访问到的所有节点,就构成一个强连通分量。

让我们通过一个例子来说明。假设我们有一个图。首先,我们运行第一次深度优先搜索,并按退出顺序记录节点。然后反转这个列表。

现在,我们按照这个列表的顺序,在反向图上运行第二次深度优先搜索。我们从列表中最左边的节点开始。这次深度优先搜索将标记出该节点所属的整个强连通分量。然后,我们取下一个尚未标记的节点,再次在反向图上运行深度优先搜索。这次搜索将标记出另一个强连通分量。重复此过程,直到所有节点都被标记。

这个算法的时间复杂度是线性的,即 O(N + M)。

算法正确性证明

算法的证明基于对深度优先搜索顺序的一些观察。

观察 1:对于任何强连通分量,考虑在第一次深度优先搜索中最先进入该分量的节点 V。当退出节点 V 的递归时,该强连通分量中的所有节点都已被标记。因为从 V 可以到达分量中的所有节点。

观察 2:考虑两个不同的强连通分量 A 和 B,且存在从 A 到 B 的边。设 V 是分量 A 中第一个被访问的节点,U 是分量 B 中第一个被访问的节点。那么,在第一次深度优先搜索得到的顺序列表中,V 一定在 U 的左边。

基于这些观察,我们可以证明算法的正确性。在第二次深度优先搜索中,我们从列表最左边的节点开始。这个节点是其所在强连通分量中第一个被访问的节点,并且该分量在凝聚图中没有入边(否则会有一个节点在它左边)。因此,从该节点开始在反向图上进行深度优先搜索,只会访问该分量内部的节点,而不会跑到其他分量去。这就找到了一个完整的强连通分量。移除这些节点后,下一个最左边的未访问节点也具有类似的性质,以此类推。

2-SAT 问题

强连通分量的一个经典应用是解决 2-SAT 问题。

2-SAT 问题来自布尔逻辑。问题描述如下:你有 n 个布尔变量,和一个由多个子句组成的布尔公式。每个子句恰好有两个文字,每个文字是一个变量或其否定形式。例如:(X ∨ Y) ∧ (¬Y ∨ Z) ∧ (¬Z ∨ ¬X)。

问题是:是否存在对变量的赋值(真或假),使得整个公式为真?

有趣的是,许多问题都可以规约到 2-SAT 问题。另外,如果允许每个子句有三个文字(3-SAT),那么问题是 NP 完全的。但对于每个子句只有两个文字的 2-SAT,存在多项式时间算法,甚至是线性时间算法。

2-SAT 的图论建模

我们可以将 2-SAT 问题转化为图论问题。为每个变量创建两个节点:一个代表 x(真),一个代表 ¬x(假)。

对于每个子句 (a ∨ b),可以推导出两个逻辑蕴含关系:如果 ¬a 为真,则 b 必须为真;如果 ¬b 为真,则 a 必须为真。我们在图中添加两条有向边:¬a -> b¬b -> a

例如,子句 (X ∨ Y) 会添加边 ¬X -> Y¬Y -> X

可满足性判定

在这个图中,如果某个变量 x 和它的否定 ¬x 属于同一个强连通分量,那么公式是不可满足的。因为这意味着 x 为真会推出 x 为假,反之亦然,产生矛盾。

因此,算法步骤如下:

  1. 根据 2-SAT 公式构建蕴含图。
  2. 使用 Kosaraju 算法(或 Tarjan 算法)找出图的所有强连通分量。
  3. 检查每个变量 x¬x 是否在同一个强连通分量中。如果是,则公式不可满足
  4. 否则,公式可满足,并且我们可以构造出一个解。

构造解

如果公式可满足,我们需要构造出一个具体的赋值。

  1. 构建原图的凝聚图(有向无环图)。
  2. 对凝聚图进行拓扑排序。实际上,Kosaraju 算法输出的强连通分量顺序已经是逆拓扑序。
  3. 按照拓扑顺序处理分量。对于每个分量(代表一组赋值),如果它尚未被赋值,则将其赋值为 ,并将其对称分量(所有文字取反的分量)赋值为

可以证明,这样得到的赋值满足所有子句。

整个算法的时间复杂度是线性的 O(N + M),其中 N 是变量数(节点数的两倍),M 是子句数(边数的两倍)。

总结

本节课我们一起学习了:

  1. 强连通分量在有向图中的定义及其性质。
  2. 使用 Kosaraju 算法在线性时间内寻找有向图的所有强连通分量。该算法包括两次深度优先搜索:第一次在原图上得到特定的节点顺序,第二次在反向图上按此顺序搜索以找出分量。
  3. 强连通分量的一个重要应用:解决 2-SAT 问题
  4. 将 2-SAT 公式转化为蕴含图,并通过判断变量及其否定是否在同一个强连通分量中来判定公式的可满足性。
  5. 在公式可满足时,通过对凝聚图进行拓扑排序,为分量赋值以构造出一个解。

理解强连通分量及其高效算法,是处理复杂有向图问题的基础,而 2-SAT 模型则展示了图论在逻辑和约束求解中的强大应用。

035:桥、割点与欧拉回路

在本节课中,我们将学习无向图中的双连通性概念,包括边双连通分量和点双连通分量,以及如何高效地找到图中的桥(割边)和割点(关节点)。最后,我们还将探讨欧拉回路的存在性及其寻找算法。


桥与边双连通分量

上一节我们讨论了有向图的连通性。本节中,我们回到无向图,探讨更复杂的连通性概念——双连通性。

边双连通性的定义

两个节点是边双连通的,当且仅当它们之间存在两条边不相交的路径。这意味着这两条路径可以共享节点,但不能共享任何边。

这个关系是一个等价关系(自反、对称、传递)。因此,它可以将图的所有节点划分为若干个等价类,每个等价类称为一个边双连通分量

桥的定义与性质

连接两个不同边双连通分量的边被称为(或割边)。桥有一个关键性质:移除图中的任何一条桥,都会增加图的连通分量数量

证明如下:假设边 (u, v) 连接了两个不同的边双连通分量。如果移除这条边后 uv 仍然连通,那么在移除前就存在另一条 uv 的路径。这条路径与边 (u, v) 一起构成了两条边不相交的路径,这意味着 uv 本应属于同一个边双连通分量,与假设矛盾。因此,移除桥 (u, v) 后,uv 不再连通。

寻找桥的算法

我们将使用深度优先搜索(DFS)来寻找图中的所有桥。算法的核心思想是:图中的所有桥都必然包含在任意一棵DFS生成树中

对于DFS树中的一条边 (v, parent[v]),我们如何判断它是否是桥呢?

  • 如果移除边 (v, parent[v]),以 v 为根的子树将与图的其余部分分离。
  • 如果这条边不是桥,那么在这棵子树中,至少存在一条“后向边”,连接子树中的某个节点到 parent[v] 或其祖先节点。
  • 反之,如果不存在这样的后向边,那么 (v, parent[v]) 就是一座桥。

为了高效地检查这一点,我们为每个节点 v 计算一个值 up[v]

up[v] 的定义:在节点 v 的子树中,所有“后向边”所能到达的、在DFS树中深度最小(或进入时间最早)的祖先节点的进入时间。

以下是计算 up[v] 和判断桥的伪代码:

time = 0
entry = [0] * n  # 节点进入时间
up = [0] * n     # up值
bridges = []

def dfs(v, parent):
    global time
    time += 1
    entry[v] = time
    up[v] = entry[v]  # 初始化为自身进入时间

    for u in graph[v]:
        if u == parent:
            continue  # 忽略指向父节点的树边
        if entry[u] == 0:  # u 是子节点,树边
            dfs(u, v)
            up[v] = min(up[v], up[u])  # 用子节点的 up 值更新
            # 判断桥:如果子树中没有后向边能到达 v 或 v 的祖先
            if up[u] > entry[v]:
                bridges.append((v, u))
        else:  # u 已被访问,这是一条后向边
            up[v] = min(up[v], entry[u])  # 用后向边更新 up 值

算法解释

  1. 对每个节点 v,记录其进入DFS的时间 entry[v]
  2. 初始化 up[v] = entry[v]
  3. 遍历 v 的所有邻居 u
    • 如果 u 是父节点,跳过。
    • 如果 u 未被访问,递归处理子树 u,然后用 up[u] 更新 up[v]。递归返回后,检查条件 up[u] > entry[v]。如果成立,说明从 u 的子树出发,没有任何后向边能到达 vv 的祖先,因此边 (v, u) 是桥。
    • 如果 u 已被访问(且不是父节点),说明 (v, u) 是一条后向边。用 entry[u] 更新 up[v],尝试到达更早的祖先。
  4. 对根节点(没有父节点)需要特殊处理吗?不需要。根节点没有连向父节点的边,因此算法中不会将根节点与不存在的父节点之间的边判为桥。

寻找边双连通分量

找到所有桥之后,我们可以通过移除这些桥来找到所有边双连通分量。具体方法是:从图中删除所有桥,然后在剩余的图中寻找连通分量,每个连通分量就是一个边双连通分量。

我们也可以在同一个DFS中完成分量划分,使用一个栈:

stack = []
components = []  # 存储每个分量中的节点

def dfs(v, parent):
    ...
    stack.append(v)
    for u in graph[v]:
        if u == parent:
            continue
        if entry[u] == 0:
            dfs(u, v)
            up[v] = min(up[v], up[u])
            if up[u] > entry[v]:  # (v, u) 是桥
                # 弹出栈中直到 v 的所有节点,它们构成一个边双连通分量
                comp = []
                while True:
                    node = stack.pop()
                    comp.append(node)
                    if node == v:
                        break
                components.append(comp)
        else:
            up[v] = min(up[v], entry[u])
    # DFS结束时,如果栈中还有节点(例如根节点所在的整个分量),也需要弹出

割点与点双连通分量

上一节我们讨论了边双连通性。本节中,我们来看一个更严格的概念——点双连通性。

点双连通性的定义

两个节点是点双连通的,当且仅当它们之间存在两条点不相交的路径(除了起点和终点)。这意味着这两条路径不能共享任何节点。

与边双连通性不同,点双连通性在节点之间不构成等价关系(不满足传递性)。因此,我们转而定义之间的点双连通性。

定义:两条边是点双连通的,如果存在两条点不相交的路径,连接第一条边的两个端点和第二条边的两个端点。

边之间的点双连通关系是一个等价关系。因此,所有边可以被划分为若干个等价类,每个等价类称为一个点双连通分量

割点的定义与性质

如果一个顶点属于多个不同的点双连通分量,那么这个顶点被称为割点(或关节点)。割点的关键性质是:移除图中的任何一个割点,都会增加图的连通分量数量

寻找割点的算法

寻找割点的算法与寻找桥的算法非常相似,同样基于DFS树。对于树边 (v, parent[v]),我们检查节点 v 是否为割点。

判断逻辑

  • 如果 v 不是根节点:对于 v 的每个子节点 u,如果从 u 的子树出发,没有任何后向边能到达 v 的祖先(即 up[u] >= entry[v]),那么移除 v 后,子树 u 将与图的其余部分分离。因此,只要存在一个这样的子节点 uv 就是割点。
  • 如果 v 是根节点:那么当且仅当它在DFS树中拥有两个或更多个子节点时,它才是割点。

以下是修改后的DFS代码,用于寻找割点:

time = 0
entry = [0] * n
up = [0] * n
is_articulation = [False] * n

def dfs(v, parent):
    global time
    time += 1
    entry[v] = time
    up[v] = entry[v]
    children = 0  # 记录子节点数量,用于根节点判断

    for u in graph[v]:
        if u == parent:
            continue
        if entry[u] == 0:
            children += 1
            dfs(u, v)
            up[v] = min(up[v], up[u])
            # 非根节点,且存在子节点 u 使得 up[u] >= entry[v]
            if parent != -1 and up[u] >= entry[v]:
                is_articulation[v] = True
        else:
            up[v] = min(up[v], entry[u])

    # 根节点,且拥有多于一个子节点
    if parent == -1 and children > 1:
        is_articulation[v] = True

寻找点双连通分量

我们可以在寻找割点的同一个DFS中,使用栈来记录边,从而划分点双连通分量。

stack = []  # 存储边 (v, u)
components = []  # 存储每个点双连通分量中的边

def dfs(v, parent):
    ...
    for u in graph[v]:
        if u == parent:
            continue
        if entry[u] == 0:
            stack.append((v, u))
            dfs(u, v)
            up[v] = min(up[v], up[u])
            if up[u] >= entry[v]:  # v 是割点,或根节点
                # 弹出栈中直到边 (v, u) 的所有边,它们构成一个点双连通分量
                comp = []
                while True:
                    edge = stack.pop()
                    comp.append(edge)
                    if edge == (v, u):
                        break
                components.append(comp)
        elif entry[u] < entry[v]:  # 后向边,且 u 是祖先
            stack.append((v, u))
            up[v] = min(up[v], entry[u])

算法解释

  1. 在DFS过程中,将遍历到的每条边(树边或后向边)压入栈。
  2. 当检测到节点 v 是割点(或根节点满足条件)时,意味着以 v 的子节点 u 为根的子树构成了一个独立的点双连通分量。此时,不断从栈中弹出边,直到弹出边 (v, u) 为止。这些弹出的边就构成了一个点双连通分量。
  3. 注意,割点 v 本身会属于多个点双连通分量。

欧拉回路

最后,我们简要介绍欧拉回路及其寻找算法。

欧拉回路的定义与存在条件

欧拉回路是指一条经过图中每条边恰好一次,并最终回到起点的回路。

存在性条件(对于无向连通图)

  • 图中的所有节点都必须具有偶数度

存在性条件(对于有向连通图)

  • 图中每个节点的入度必须等于出度

寻找欧拉回路的算法:Hierholzer算法

算法思想是进行DFS,但标记的是边而非节点。当在一个节点无法继续前进(所有出边都已访问)时,回溯并将边加入回路中。

以下是算法的核心伪代码:

circuit = []  # 存储欧拉回路中的边(或节点)

def dfs(v):
    while unmarked_edges_from[v] 非空:
        取一条未标记的边 (v, u)
        标记边 (v, u)
        dfs(u)
        circuit.append((v, u))  # 回溯时加入回路

算法步骤

  1. 从任意节点开始DFS。
  2. 每次选择一条未访问的边走到下一个节点,并标记该边。
  3. 当在一个节点无路可走时,回溯到上一个节点,并将刚才走过的边加入回路列表。
  4. 最终,circuit 列表中逆序存储的边序列(或节点序列)就是一条欧拉回路。

实现细节:需要高效地管理每个节点的未访问边集。可以使用邻接表,并在访问边时将其从列表末尾移除(或标记为已访问)。对于无向图,需要同时处理边的两个端点。

时间复杂度:该算法的时间复杂度是 O(|E|),即线性于边的数量。


总结

本节课中我们一起学习了无向图中的双连通性:

  1. 边双连通分量与桥:我们学习了如何利用DFS计算 up 值,从而在线性时间内找出图中所有的桥,并基于桥划分边双连通分量。
  2. 点双连通分量与割点:我们了解了点双连通性的定义,学习了类似的DFS算法来寻找割点,并使用栈在算法过程中划分点双连通分量。
  3. 欧拉回路:我们回顾了欧拉回路的存在条件,并学习了经典的Hierholzer算法来寻找欧拉回路。

理解这些概念和算法,对于分析网络可靠性、设计冗余路径以及解决许多图论问题至关重要。

036:支配树

概述

在本节课中,我们将继续讨论图论,并深入探讨深度优先搜索算法的应用。我们将学习一种称为“支配树”的特殊结构,它描述了有向图中节点之间的支配关系。我们将从基本概念开始,逐步构建算法,最终学习如何为任意有向图构建支配树。


支配关系的基本概念

上一节我们讨论了无向图中的桥和关节点,它们是图中的“瓶颈”。在有向图中,连通关系不再对称,因此瓶颈结构更为复杂。本节中,我们来看看有向图中的支配关系。

支配节点 是一个特殊的节点,它位于从源点到目标节点的每一条路径上。形式化定义如下:

给定一个有向图和一个特殊的源点 S。节点 U 是节点 V支配节点,当且仅当 U 出现在从 SV 的每一条路径上。

这意味着,如果你无法在不经过节点 U 的情况下从 S 到达 V,那么 U 就支配了 V。源点 S 支配图中的所有节点,因为任何从 S 出发的路径都包含 S 本身。


支配关系的性质与支配树

支配关系具有一些重要的性质,这些性质帮助我们构建一个清晰的结构来描述它。

以下是支配关系的两个关键性质:

  1. 传递性:如果 U 支配 V,且 V 支配 W,那么 U 也支配 W
  2. 有序性:对于任意节点 V,它的所有支配节点在从 SV 的路径上会按特定顺序出现。这意味着支配节点之间也相互支配,形成一个链式结构。

在所有支配节点中,最靠近 V 的那个被称为直接支配节点。如果我们知道每个节点的直接支配节点,就可以通过不断回溯直接支配节点来找到它的所有支配节点。这种关系可以表示为一棵树,即支配树

在支配树中,每个节点的父节点就是它的直接支配节点。这棵树清晰地展示了图中的支配层级和瓶颈结构。例如,如果一个节点支配了一大片区域,那么移除该节点将使该区域与源点断开连接。


构建支配树的算法框架

我们的目标是构建一个数组 idom[],其中 idom[v] 表示节点 V 的直接支配节点。整个算法分为两个主要阶段。

第一阶段:计算半支配节点

为了找到直接支配节点,我们首先引入一个中间概念:半支配节点

节点 U 是节点 V半支配节点,如果存在一条从 UV 的路径,并且该路径上除了 UV 之外的所有中间节点,其编号(按DFS进入时间排序)都大于 V 的编号。

半支配节点的意义在于,它提供了一种“绕过”某些潜在支配节点的方法。对于一个节点 V,我们关心的是所有半支配节点中编号最小的那个,记为 sdom[v]

计算 sdom[v] 的公式考虑了所有可能的情况:

sdom[v] = min({
    u | 存在边 (u, v)
    sdom[x] | 存在边 (w, v),且 w > v,x 位于 w 到 LCA(w, v) 的路径上
})

其中 LCA 指在DFS树中的最近公共祖先。

为了高效计算这个最小值,我们需要一个数据结构来支持在DFS树的某条路径上查询最小 sdom 值。这可以使用带路径压缩的并查集(如Link-Cut Tree的思想)或精心设计的二分查找(Binary Lifting)来实现。

第二阶段:从半支配节点推导直接支配节点

在得到半支配节点后,我们可以推导出直接支配节点。规则如下:

对于节点 V,考虑在DFS树中从 sdom[v]V 的路径(不包括 sdom[v])。在这条路径上,找到 sdom 值最小的节点 U

  • 如果 sdom[u] >= sdom[v],那么 idom[v] = sdom[v]
  • 否则,idom[v] = idom[u]

这个推导过程需要按照DFS编号从大到小(即从树的底部到顶部)的顺序进行,以确保计算 idom[v] 时所需的 idom[u] 已经计算完毕。

同样,这一步也需要在树路径上查询最小值的操作,可以使用与第一阶段类似的数据结构。


算法实现与复杂度

整个算法的核心在于高效处理树路径上的最小值查询。

以下是算法步骤的简要总结:

  1. 从源点 S 开始进行深度优先搜索,为每个节点标记DFS进入序号。
  2. 第一阶段:按DFS序号从大到小遍历节点,利用并查集等数据结构,根据前述公式计算每个节点的半支配节点 sdom[v]
  3. 第二阶段:同样按DFS序号从大到小遍历节点,利用半支配节点和路径最小值查询,计算每个节点的直接支配节点 idom[v]
  4. 根据 idom[] 数组构建出支配树。

如果使用朴素的二分查找实现路径查询,算法的时间复杂度为 O((N+M) log N)。如果使用更高级的、近似线性的数据结构(如基于并查集优化的Link-Cut思想),时间复杂度可以接近 O((N+M) α(N)),其中 α 是反阿克曼函数。理论上也存在纯粹的 O(N+M) 线性算法,但实现更为复杂。


总结

本节课我们一起学习了有向图中的支配树。

  • 我们首先定义了支配节点和直接支配节点的概念。
  • 然后,我们探讨了支配关系的性质,并引出了用树形结构(支配树)来表示这种关系的思想。
  • 接着,我们详细介绍了构建支配树的两阶段算法框架:先计算半支配节点,再从中推导出直接支配节点。
  • 最后,我们讨论了算法实现中的关键——高效处理树路径查询,并简要分析了算法复杂度。

支配树是一个强大的工具,它能帮助我们分析有向图中节点的控制流和瓶颈,在编译器优化、程序分析等领域有重要应用。虽然算法细节较为复杂,但理解其核心思想和框架是掌握这一概念的关键。

037:最小生成树与最小树形图

在本节课中,我们将要学习图论中的两个核心问题:最小生成树最小树形图。我们将从基本概念入手,逐步介绍解决这些问题的经典算法,并分析其原理与复杂度。


最小生成树问题

首先,我们来看最小生成树问题。给定一个连通无向图,其中每条边都有一个权重(或成本)。我们的目标是找到一个生成树,即一个包含图中所有节点的树形子图,并且使得该树中所有边的权重之和最小。

什么是生成树?

一个图的生成树是一个包含该图所有节点的树。如果图是连通的,那么它总是存在生成树。例如,你可以通过深度优先搜索得到一个深度优先搜索树,这就是一个生成树。

问题定义

假设我们有一个带权无向图。生成树的权重就是树中所有边权重的总和。最小生成树问题就是要找到一个权重总和最小的生成树。

这个问题的应用很广泛。例如,如果你想用最低的成本连接所有节点(如铺设电缆或道路网络),那么最小生成树就是最优的解决方案。因为如果所有权重非负,那么连接所有节点的最小成本结构必然是一棵树(如果存在环,你可以移除环中的一条边来降低成本)。

一个关键引理

在介绍具体算法之前,我们先证明一个关键引理,它是许多最小生成树算法的基础。

引理:将图的所有节点任意分割成两个集合 A 和 B。考虑所有连接集合 A 和 B 的边(称为一个“割”)。设 (u, v) 是这些边中权重最小的那条边(u 在 A 中,v 在 B 中)。那么,这条边 (u, v) 必然属于某个最小生成树。

证明

  1. 假设存在一个最小生成树 T,它不包含边 (u, v)
  2. 在树 T 中,节点 uv 之间必然存在一条路径 P(因为生成树是连通的)。
  3. 由于 u 在 A 中,v 在 B 中,路径 P 上必然存在一条边 (x, y),使得 x 在 A 中,y 在 B 中(即路径穿过了割)。
  4. 现在,我们构造一棵新的树 T‘:从 T 中移除边 (x, y),并加入边 (u, v)
  5. 因为 (u, v) 是割中权重最小的边,所以 weight(u, v) <= weight(x, y)。因此,新树 T‘ 的权重不会大于 T 的权重。
  6. 由于 T 是最小生成树,所以 T‘ 的权重也不可能更小,因此 T‘ 也是一个最小生成树,并且它包含了边 (u, v)

这个引理告诉我们,在任何割中,权重最小的那条边对于构建最小生成树是“安全”的,我们可以放心地把它加入解中。


克鲁斯卡尔算法

基于上述引理,我们可以设计第一个算法:克鲁斯卡尔算法

算法思路

算法的核心思想非常简单:

  1. 将图中所有边按照权重从小到大排序。
  2. 按顺序遍历每条边。
  3. 对于当前边 (u, v),如果加入这条边不会在已选择的边集中形成环(即 uv 目前尚未连通),那么就将这条边加入最小生成树中。否则,跳过这条边。

为什么这样做是正确的?当我们考虑边 (u, v) 时:

  • 如果 uv 尚未连通,那么当前已选边构成的森林中,uv 属于不同的连通分量。
  • 我们可以将 u 所在的连通分量视为集合 A,其他所有节点视为集合 B,这就构成了一个割。
  • 由于我们按权重升序处理边,当前边 (u, v) 就是这个割中权重最小的边(因为所有比它权重小的边都已经被处理过了,并且它们连接的是各自连通分量内部的节点,不会连接 A 和 B)。
  • 根据引理,这条边应该被加入最小生成树。

算法实现与复杂度

检查两个节点是否连通以及连通两个连通分量,最有效的数据结构是并查集

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

# 假设 edges 是列表,每个元素是 (weight, u, v)
edges.sort() # 按权重排序
uf = UnionFind(n) # 初始化并查集,n为节点数
mst_edges = [] # 存储最小生成树的边
total_weight = 0

for weight, u, v in edges:
    if uf.find(u) != uf.find(v): # 如果u和v不在同一个集合
        uf.union(u, v) # 合并集合
        mst_edges.append((u, v))
        total_weight += weight

# 最终 mst_edges 包含最小生成树的所有边,total_weight 是其总权重

时间复杂度分析

  • 排序所有边:O(m log m),其中 m 是边数。
  • 并查集操作:进行 mfind 和最多 n-1union。使用路径压缩和按秩合并的并查集,每次操作的平均时间复杂度接近常数 O(α(n)),其中 α 是增长极慢的反阿克曼函数。
  • 因此,总时间复杂度为 O(m log m),主要由排序步骤决定。由于 m 最多为 O(n^2),所以也可以表示为 O(m log n)

克鲁斯卡尔算法在边数不多(稀疏图)时非常高效。


普里姆算法

接下来我们学习第二个经典算法:普里姆算法。它采用了另一种贪心策略。

算法思路

普里姆算法从一个节点开始,逐步“生长”出一棵最小生成树。

  1. 初始时,集合 A 只包含一个任意起始节点(例如节点0),最小生成树边集为空。
  2. 重复以下步骤,直到集合 A 包含所有节点:
    • 考虑所有连接集合 A 内部节点和外部节点 BB = V \ A)的边,这构成了一个割。
    • 找到这个割中权重最小的边 (u, v),其中 uA 中,vB 中。
    • 将边 (u, v) 加入最小生成树,并把节点 v 从集合 B 移到集合 A 中。

每一步都直接应用了之前的引理,因此算法正确性得以保证。

算法实现与优化

我们需要一个高效的数据结构来维护割中的最小边。有两种主要思路:

思路一:维护所有候选边

  • 使用一个最小堆(优先队列),存储所有从 A 集合连接到 B 集合的边。
  • 每次从堆中取出权重最小的边 (u, v)。如果 v 还在 B 中,则将边加入 MST,并将 v 加入 A
  • v 加入 A 后,需要遍历 v 的所有邻边 (v, w)。如果 wB 中,则将边 (v, w) 加入堆中。
  • 复杂度:每条边最多进入和离开堆一次,每次堆操作 O(log m),总复杂度 O(m log m)

思路二:维护每个B集合节点的最小入边

  • 我们不需要维护所有边,对于 B 集合中的每个节点 v,我们只关心连接 Av 的所有边中,权重最小的那条。记这个最小权重为 dist[v]
  • 使用一个最小堆,存储 B 集合中所有节点,其键值为 dist[v]
  • 每次从堆中取出 dist 值最小的节点 v(这对应了割中的最小边)。将 v 和其对应的边 (parent[v], v) 加入 MST,并将 v 移入 A
  • v 移入 A 后,需要更新 v 的所有邻居 w(若 wB 中)的 dist 值:dist[w] = min(dist[w], weight(v, w))。这需要在堆中更新 w 的键值(降低键值)。
  • 复杂度:使用二叉堆时,降低键值操作需要 O(log n)。总操作次数为 O(m) 次降低键值和 O(n) 次取出最小元,总复杂度 O(m log n)

不同数据结构的比较

  • 二叉堆:实现简单,复杂度 O(m log n),适用于一般情况。
  • 斐波那契堆:降低键值操作摊还时间复杂度为 O(1),取出最小元为 O(log n)。使用斐波那契堆的普里姆算法复杂度可达 O(m + n log n),在稠密图上理论更优,但常数较大。
  • 简单数组:如果不使用堆,而用数组存储 dist,每次查找最小值需要 O(n),但更新 dist 只需 O(1)。总复杂度为 O(n^2 + m)。这在稠密图m 接近 n^2)时比 O(m log n) 更优,因为 O(n^2) 小于 O(n^2 log n)

因此,普里姆算法的具体实现可以根据图的稠密程度选择合适的数据结构。


博鲁夫卡算法

最后,我们介绍一个并行的、非常古老的算法——博鲁夫卡算法。它同样基于那个关键引理,但以并行的方式应用。

算法思路

算法分为多轮,每轮并行地为每个连通分量添加一条边。

  1. 初始时,每个节点自身是一个连通分量。
  2. 重复以下步骤,直到只剩一个连通分量(即得到生成树):
    a. 对于当前图中的每个连通分量:考虑所有连接该分量与外部节点的边,找到其中权重最小的那条边(根据引理,这条边属于某个 MST)。
    b. 将所有找到的这些最小边加入 MST 边集中(注意,同一条边可能被两个分量同时选中,只需加入一次)。
    c. 根据新加入的边,合并连通分量。即,将上一步中通过边连接起来的各个连通分量,合并成新的、更大的连通分量。
  3. 展开所有被压缩的连通分量,得到最终的最小生成树。

复杂度分析

  • 每一轮中,每个连通分量都会找到一条最小边并合并。最坏情况下,每轮每个连通分量至少与另一个分量合并,因此连通分量的数量至少减半。
  • 所以,最多进行 O(log n) 轮。
  • 在每一轮中,我们需要为每个连通分量找到其连出去的最小边。这可以通过遍历所有边,为每条边的两个端点所属的连通分量更新候选最小边来实现。每轮工作量为 O(m)
  • 因此,总时间复杂度为 O(m log n)

博鲁夫卡算法的优势在于其并行性,并且在实际应用中,由于连通分量快速变大,轮数往往远小于 log n,因此效率很高。


最小树形图问题

现在,我们转向一个类似但更复杂的问题:最小树形图,也称为最小有向生成树朱-刘算法问题

问题定义

给定一个有向带权图和一个指定的根节点 r。目标是找到一个以 r 为根的有向生成树,使得从 r 出发可以到达所有其他节点,并且树中所有有向边的权重之和最小。

这与最小生成树的区别在于边是有向的,并且要求所有边都从父节点指向子节点(即根节点没有入边)。

朱-刘算法

解决此问题的经典算法是朱-刘算法。其核心思想是通过“缩点”操作,逐步将图简化。

算法步骤概述

  1. 预处理:删除根节点 r 的所有入边。对于除根节点外的每个节点 v,选择所有指向 v 的入边中权重最小的那条,记为 min_in[v]。将所有这些最小入边的权重和记为 total。如果对于某个非根节点,没有入边,则问题无解。
    • 注意:这样选择的边集可能形成环,而不是树。
  2. 检查环:如果当前选择的最小入边集合不构成环(即形成一棵以 r 为根的有向树),那么算法结束,这就是最小树形图。
  3. 缩点:如果存在环,则进行缩点操作。
    • 将每个环收缩成一个“超级节点”。
    • 修改图中所有边的权重:
      • 对于环外的边 (u, v),如果 v 在环内,则新权重为 原权重 - min_in[v]。这是因为我们已经为环内的节点 v 支付了 min_in[v] 的成本来选择其入边,如果最终我们选择边 (u, v) 进入环,那么它替代的是环内 v 原来的最小入边,所以实际增加的成本是差值。
      • 对于环内指向环外的边,权重不变。
      • 环内部的边被丢弃。
  4. 递归求解:在收缩后的新图上,递归调用算法,找到其最小树形图。
  5. 展开环:将收缩的环展开。递归求解得到的结果中,进入超级节点的边对应原图中进入环的某条边 (u, v)。在环中,断开原来指向 v 的那条环内边,并将边 (u, v) 加入最终解。这样,环就被打开成了一棵树的一部分。

算法正确性直觉

算法的关键在于权重的调整。为每个非根节点选择最小入边,并调整环外入边权重的操作,相当于执行了如下等价变换:

  • 对于任意节点 v,给所有指向 v 的边的权重同时加上或减去一个常数,不会改变最小树形树的结构(因为最终树中 v 只有一条入边,总成本变化是固定的)。
  • 我们通过减去 min_in[v],使得每个节点都至少有一条零权入边。如果这些零权边恰好构成一棵树,那显然是最优解。如果构成环,则说明我们需要在环上做出选择,缩点并递归求解等价于在更高的层次上做决策。

复杂度

朴素的实现每轮需要 O(m) 时间找最小入边和缩点,最多进行 O(n) 轮(每轮至少减少一个节点),总复杂度 O(mn)
使用更高效的数据结构(如可并堆)来维护每个节点的最小入边,可以将复杂度优化到 O(m log n)


总结

本节课我们一起学习了图论中两个重要的最优化问题:

  1. 最小生成树:在无向带权连通图中寻找权重最小的生成树。

    • 关键引理:任意割的最小边必属于某个最小生成树。
    • 克鲁斯卡尔算法:按边权排序,用并查集避免环。复杂度 O(m log m),适合稀疏图。
    • 普里姆算法:从单点生长,用堆维护割的最小边。复杂度可优化至 O(m + n log n),适合稠密图。
    • 博鲁夫卡算法:并行合并连通分量。复杂度 O(m log n),具有并行性。
  2. 最小树形图:在有向带权图中,寻找以指定根节点出发能到达所有点的最小有向生成树。

    • 问题比无向图情况更复杂。
    • 朱-刘算法:通过不断为节点选择最小入边、收缩环并调整权重来求解。朴素实现 O(mn),可用数据结构优化。

理解这些算法背后的贪心思想和图论引理,是掌握其本质的关键。最小生成树问题已有接近线性的随机算法,但确定性的最优复杂度仍未可知,这体现了算法研究中理论与实践的深度。

038:广度优先搜索与迪杰斯特拉算法 🚀

在本节课中,我们将要学习图论中的两种核心最短路径算法:广度优先搜索迪杰斯特拉算法。我们将从最简单的等权图开始,逐步深入到带权图,并探讨算法的优化与变种。课程内容力求简单直白,确保初学者能够理解。


最短路径问题概述 🗺️

最短路径是图论中一个非常常见的问题。通常,你有一个图,图中有两个特定的节点:一个起点 S 和一个终点 T。你的目标是找到从 ST最短路径

这个问题不仅限于道路网络。有时,一个系统有多个状态,你需要找到从初始状态到目标状态所需的最少操作次数。在这些情况下,图就代表了状态之间的转移关系。

根据图的性质,问题有不同的变体。例如,图可以是有向的或无向的。对于我们将要讨论的大多数算法,图是否有向影响不大。如果是无向图,你可以简单地用两条方向相反的有向边替换每条无向边,这样最短路径问题就转化为了有向图问题。


等权图:广度优先搜索 🧭

首先,我们讨论最简单的情况:图中所有边的权重都相同。为了简化,我们假设每条边的权重都为 1

在这种情况下,最短路径就是包含最少边数的路径。解决这个问题的算法称为广度优先搜索

BFS 的核心思想

BFS 的核心思想非常简单:我们按照距离起点 S 的递增顺序来探索图中的所有节点。我们先探索距离为 0 的节点(即 S 本身),然后是距离为 1 的节点(S 的所有邻居),接着是距离为 2 的节点,依此类推,直到我们找到终点 T。

BFS 的逐步演示

让我们通过一个例子来演示这个过程。假设我们有如下图的节点:

  1. 距离 0:只有起点 S。我们标记 dist[S] = 0
  2. 距离 1:找到 S 的所有邻居(节点 2, 12, 9)。我们标记 dist[2] = dist[12] = dist[9] = 1
  3. 距离 2:对于所有距离为 1 的节点,找到它们尚未被访问的邻居。
    • 从节点 2 找到邻居 3,标记 dist[3] = 2
    • 从节点 12 找到邻居 4, 5,标记 dist[4] = dist[5] = 2
    • 从节点 9 找到邻居 7,标记 dist[7] = 2
  4. 重复这个过程,每次处理完距离为 k 的所有节点后,就去探索距离为 k+1 的节点,直到访问到终点 T 或所有可达节点。

BFS 的算法实现

以下是 BFS 的一个经典实现,它使用一个队列来管理待探索的节点。

from collections import deque

def bfs_shortest_path(graph, S, T):
    n = len(graph)  # 节点数量
    dist = [-1] * n  # 初始化所有距离为 -1(未访问)
    parent = [-1] * n  # 用于重建路径,记录每个节点的前驱节点

    dist[S] = 0
    queue = deque([S])  # 初始化队列,加入起点

    while queue:
        v = queue.popleft()  # 从队列左侧取出一个节点
        if v == T:
            break  # 如果找到终点,可以提前结束

        for u in graph[v]:  # 遍历节点 v 的所有邻居
            if dist[u] == -1:  # 如果邻居 u 未被访问过
                dist[u] = dist[v] + 1
                parent[u] = v  # 记录是从 v 到达 u 的
                queue.append(u)  # 将 u 加入队列末尾

    # 重建从 S 到 T 的路径
    if dist[T] == -1:
        return None, -1  # T 不可达

    path = []
    cur = T
    while cur != -1:
        path.append(cur)
        cur = parent[cur]
    path.reverse()  # 反转得到从 S 到 T 的路径
    return path, dist[T]

算法解释

  • 我们使用一个数组 dist 来记录每个节点到起点 S 的距离,初始化为 -1 表示“无穷远”或“未访问”。
  • 我们使用一个队列 queue。首先将起点 S 入队。
  • 只要队列不为空,我们就取出队首的节点 v,并检查它的所有邻居 u
  • 如果邻居 u 尚未被访问过(即 dist[u] == -1),那么我们就找到了从 S 到 u 的一条最短路径,其长度为 dist[v] + 1。我们更新 dist[u],将 u 的前驱节点记录为 v,并将 u 入队。
  • 当我们从队列中取出终点 T 时,循环可以提前终止,因为我们已经找到了最短距离。
  • 最后,通过 parent 数组从终点 T 回溯到起点 S,即可重建出完整的最短路径。

时间复杂度:每个节点和每条边都只被访问一次,因此时间复杂度为 O(V + E),其中 V 是顶点数,E 是边数。


BFS 的优化:双向搜索 🔄

上一节我们介绍了标准的 BFS,它从起点单向扩展。本节中我们来看看一种优化策略:双向广度优先搜索

当图的规模非常大时(例如解魔方等状态空间巨大的问题),标准 BFS 需要探索的节点数量可能呈指数级增长。双向搜索可以显著减少需要探索的节点数。

双向搜索的思想

我们同时从起点 S 和终点 T 开始进行 BFS。

  • 从 S 出发的搜索向前推进。
  • 从 T 出发的搜索向后推进(在无向图中,向前和向后是等价的)。
  • 当两个搜索的“前沿”相遇时,我们就找到了一条连接 S 和 T 的路径。

为什么更高效?
假设从起点和终点出发,探索到距离为 d 的节点数都约为 α^d(指数增长)。

  • 标准 BFS 需要探索到总距离 D,节点数约为 α^D
  • 双向搜索每边只需探索大约 D/2 的距离,每边节点数约为 α^(D/2)。总探索节点数约为 2 * α^(D/2),这远小于 α^D

算法要点

  1. 维护两个队列和两个距离数组,分别对应从 S 和从 T 开始的搜索。
  2. 在每一步中,选择两个队列中较小的那个进行扩展(以平衡两边的搜索进度)。
  3. 当一个节点被两个方向的搜索都访问过时,搜索结束。假设该节点为 v,从 S 到 v 的距离为 d1,从 T 到 v 的距离为 d2,则最短路径长度可能是 d1 + d2
  4. 注意:对于等权图,相遇节点处的 d1 + d2 就是最短路径长度。对于带权图(后续介绍),情况会更复杂一些。

带权图:迪杰斯特拉算法 ⚖️

现在,我们考虑更一般的情况:图中每条边都有不同的权重(非负)。我们的目标是找到从 S 到 T 的总权重和最小的路径。

解决这个问题的经典算法是迪杰斯特拉算法。它的核心思想与 BFS 类似,也是按照距离起点的递增顺序来确定每个节点的最短距离,但这里“距离”指的是权重和。

迪杰斯特拉算法的核心思想

  1. 我们将所有节点分为两个集合:
    • 集合 A:最短距离已确定的节点。
    • 集合 B:最短距离尚未确定的节点。
  2. 初始时,集合 A 只包含起点 S,dist[S] = 0。其他节点的距离初始化为无穷大。
  3. 重复以下步骤,直到所有节点都进入集合 A:
    a. 从集合 B 中选出当前 dist 值最小的节点 v。可以证明,此时 dist[v] 就是它的最终最短距离。
    b. 将节点 v 加入集合 A。
    c. 对于 v 的每个邻居 u,尝试进行松弛操作:如果 dist[v] + weight(v, u) < dist[u],则更新 dist[u] = dist[v] + weight(v, u)。这意味着我们找到了一条经由 v 到达 u 的更短路径。

为什么选择 dist 最小的节点是正确的?
因为所有边权非负,所以当前 dist 值最小的节点 v,不可能通过其他尚未确定的节点获得更短的路径。任何其他从 S 到 v 的路径,在离开集合 A 的第一步,其距离就已经不小于 dist[v] 了。

迪杰斯特拉算法的实现与数据结构

算法的效率取决于如何高效地从集合 B 中取出 dist 最小的节点。以下是几种常见的选择:

  1. 使用数组:每次遍历数组寻找最小值。时间复杂度为 O(V²),适合稠密图。
  2. 使用二叉堆(优先队列):每次提取最小值和更新键值(松弛操作)的时间复杂度为 O(log V)。总时间复杂度为 O((V+E) log V),适合稀疏图。
  3. 使用斐波那契堆:提取最小值时间复杂度为 O(log V),但降低键值(松弛)的时间复杂度为 O(1)(平摊)。总时间复杂度为 O(E + V log V),理论效率更高。

以下是使用优先队列(最小堆)的典型实现:

import heapq

def dijkstra(graph, S, T):
    n = len(graph)  # graph[u] 应返回列表 [(v, weight), ...]
    dist = [float('inf')] * n
    parent = [-1] * n
    dist[S] = 0

    # 优先队列,元素为 (当前距离, 节点)
    pq = [(0, S)]

    while pq:
        current_dist, v = heapq.heappop(pq)
        # 如果当前取出的距离大于记录的距离,说明是旧数据,跳过
        if current_dist > dist[v]:
            continue

        if v == T:
            break  # 找到终点,可提前结束

        for u, w in graph[v]:
            new_dist = current_dist + w
            if new_dist < dist[u]:
                dist[u] = new_dist
                parent[u] = v
                heapq.heappush(pq, (new_dist, u))

    # 重建路径
    if dist[T] == float('inf'):
        return None, -1

    path = []
    cur = T
    while cur != -1:
        path.append(cur)
        cur = parent[cur]
    path.reverse()
    return path, dist[T]

算法解释

  • 我们使用一个最小堆 pq 来存储 (距离, 节点) 对。
  • 初始将起点 (0, S) 入堆。
  • 每次从堆中弹出距离最小的节点 v。由于堆中可能存在同一节点的多个不同距离(在它被更新后),我们通过 if current_dist > dist[v]: continue 来跳过过时的条目。
  • v 的每个邻居 u 进行松弛操作。如果找到更短路径,就更新 dist[u]parent[u],并将新距离 (new_dist, u) 入堆。

迪杰斯特拉算法的变种与优化 🧠

双向迪杰斯特拉搜索

与 BFS 类似,迪杰斯特拉算法也可以从起点和终点同时进行。当两个搜索的已确定集合出现交集时,搜索可以终止。但是,与等权图不同,带权图中相遇点并不一定在最终的最短路径上。

正确做法
当两个搜索相遇(即某个节点被两个方向的搜索都确定为最短路径节点)后,需要检查所有连接“起点搜索树”和“终点搜索树”的边 (x, y),其中 x 在起点树中,y 在终点树中。最短路径长度为:
min( dist_s[x] + weight(x, y) + dist_t[y] )
其中 dist_sdist_t 分别是从起点和终点出发计算的距离。

A* 搜索算法

A* 算法是迪杰斯特拉算法的一个智能变种。它通过一个启发式函数 h(v) 来指导搜索方向。h(v) 是对从节点 v 到终点 T剩余距离的估计

算法修改
在迪杰斯特拉算法使用 dist[v] 作为优先队列的键值时,A* 算法使用 f(v) = dist[v] + h(v) 作为键值。其中 dist[v] 是从起点 S 到 v 的当前最短距离。

要求
启发式函数 h(v) 必须是可采纳的,即对于所有节点 v,有 h(v) ≤ 实际从 v 到 T 的最短距离。同时,通常还要求 h(v)一致的(或单调的),即对于任意边 (u, v),有 h(u) ≤ weight(u, v) + h(v)。这保证了 A* 算法的正确性。

效果

  • 如果 h(v) = 0,A* 退化为迪杰斯特拉算法。
  • 如果 h(v) 是实际剩余距离的完美估计,A* 将直接沿着最短路径搜索,效率最高。
  • 一个好的启发式函数能显著减少需要探索的节点数量,将搜索方向“引导”向终点。

为什么有效?
可以证明,A* 算法等价于在一个修改了边权的新图上运行迪杰斯特拉算法。新图的边权为:new_weight(u, v) = old_weight(u, v) + h(v) - h(u)。在一致性启发函数保证下,新边权非负,迪杰斯特拉算法适用,且在新图上找到的路径对应原图的最短路径。


总结 📚

本节课中我们一起学习了图的最短路径算法。

  1. 广度优先搜索:适用于所有边权相等的图。它按照距离起点的层数逐层扩展,使用队列实现,时间复杂度为 O(V+E)。我们还探讨了其优化版本——双向 BFS。
  2. 迪杰斯特拉算法:适用于边权非负的带权图。它按照到起点的最短距离递增的顺序确定节点,使用优先队列实现,典型时间复杂度为 O((V+E) log V)。它的核心操作是“松弛”。
  3. 算法变种
    • 双向迪杰斯特拉:从起点和终点同时搜索,可能减少探索范围。
    • A 搜索算法*:在迪杰斯特拉算法中引入启发式函数,引导搜索方向,在已知问题结构信息时能大幅提升效率。

理解这些算法及其背后的思想,是解决许多实际问题的关键,从地图导航到游戏 AI,再到状态空间搜索,它们都有着广泛的应用。

039:贝尔曼-福特与弗洛伊德-沃舍尔算法

在本节课中,我们将要学习两种用于处理带负权边图的单源最短路径算法:贝尔曼-福特算法和弗洛伊德-沃舍尔算法。我们将从负权边带来的问题开始,逐步推导出算法的原理、实现和优化。

负权边与负权环

上一节我们介绍了迪杰斯特拉算法,它要求图中所有边的权重均为非负值。本节中我们来看看当图中存在负权边时会发生什么。

首先,负权边可能导致一个严重问题:负权环。负权环是指一个环,其所有边的权重之和为负数。

公式:对于一个环 C = (v1, v2, ..., vk, v1),如果 sum(weight(vi, vi+1)) + weight(vk, v1) < 0,则称 C 为负权环。

为什么负权环是问题?因为如果存在一条从起点 s 到终点 t 的路径包含一个负权环,你可以无限次地绕这个环行走,使得路径的总成本无限降低。因此,不存在“最短”路径,问题变得无定义。

如果我们限制路径必须是简单路径(不包含重复顶点),问题是否可解?答案是否定的。寻找最短简单路径是一个 NP 完全问题,因为我们可以通过将所有边权设为 -1,将寻找最长简单路径(即哈密顿路径)的问题归约到它。

因此,为了使最短路径问题有明确定义,我们禁止图中存在任何负权环。在此前提下,任何最短路径都必然是简单路径(因为可以移除非负权环而不增加总成本),且边数不超过 n-1

贝尔曼-福特算法:动态规划思路

现在,我们来看如何在没有负权环的带负权图中寻找单源最短路径。贝尔曼-福特算法基于一个简单的动态规划思想。

我们定义状态 d[v][k]:从源点 s 到顶点 v最多使用 k 条边的最短路径长度。

初始化

  • k = 0 时,只有从 s 到其自身的空路径长度为 0。
  • 因此:d[s][0] = 0,对于所有 v != sd[v][0] = +∞

状态转移
考虑如何得到一条从 sv 且最多使用 k 条边的路径。

  1. 这条路径可能实际上使用了少于 k 条边。那么它的长度就是 d[v][k-1]
  2. 这条路径恰好使用了 k 条边。那么它的最后一条边一定来自某个顶点 u,即形式为 s -> ... -> u -> v。其中 s -> ... -> u 的部分最多使用 k-1 条边。
    因此,我们需要检查所有指向 v 的边 (u, v),并取最小值。

转移方程
d[v][k] = min( d[v][k-1], min_{(u,v)∈E}( d[u][k-1] + weight(u, v) ) )

由于最短路径最多包含 n-1 条边,我们只需要计算 k1n-1 的情况。最终,d[v][n-1] 即为从 sv 的最短路径长度。

以下是该动态规划思想的伪代码描述:

// 初始化
for each vertex v in V:
    if v == s:
        dist[v] = 0
    else:
        dist[v] = INFINITY

// 动态规划核心,进行 n-1 轮松弛
for i from 1 to |V|-1:
    for each edge (u, v) in E:
        if dist[u] + weight(u, v) < dist[v]:
            dist[v] = dist[u] + weight(u, v)

算法优化:空间与提前终止

上述动态规划需要一个二维数组。我们可以进行优化。

空间优化
注意到计算 d[v][k] 时,只依赖于 d[·][k-1]。因此,我们可以只使用两个一维数组(代表当前轮和上一轮),甚至只使用一个一维数组 dist[v]。常见的贝尔曼-福特算法实现正是采用单数组形式,通过多轮“松弛”操作来更新距离。

代码:单数组实现的松弛操作。

if dist[u] + weight(u, v) < dist[v]:
    dist[v] = dist[u] + weight(u, v)

提前终止优化
如果在某一轮松弛中,没有任何 dist[v] 被更新,说明所有最短路径已被找到,算法可以提前终止。

负权环检测
如果图中存在从源点可达的负权环,那么进行第 n 轮松弛时,某些 dist[v] 仍然会被更新。因此,在完成 n-1 轮松弛后,再执行一轮松弛。如果仍有更新发生,则说明图中存在从源点可达的负权环。

时间复杂度
最坏情况下需要 O(V * E) 次操作。对于稀疏图,这比迪杰斯特拉算法慢,但它能处理负权边。

弗洛伊德-沃舍尔算法:所有顶点对的最短路径

上一节我们介绍了解决单源问题的贝尔曼-福特算法。本节中我们来看看如何高效地求解所有顶点对之间的最短路径

一个朴素的想法是对每个顶点运行一次贝尔曼-福特算法,时间复杂度为 O(V^2 * E)。弗洛伊德-沃舍尔算法提供了一个更优的解决方案,尤其适用于稠密图。

该算法也采用动态规划,但状态定义不同:
定义 dist[k][i][j]:从顶点 i 到顶点 j,且中间顶点编号不超过 k 的最短路径长度。

初始化 (k = 0):
此时不允许任何中间顶点。因此,dist[0][i][j] 就是边 (i, j) 的权重(如果存在),否则为 +∞。此外,dist[0][i][i] = 0

状态转移
考虑从 ij 且中间顶点编号不超过 k 的路径。

  1. 该路径不经过顶点 k。那么它的长度就是 dist[k-1][i][j]
  2. 该路径经过顶点 k。那么它可以分解为从 ik(中间顶点编号不超过 k-1)和从 kj(中间顶点编号不超过 k-1)两段。

转移方程
dist[k][i][j] = min( dist[k-1][i][j], dist[k-1][i][k] + dist[k-1][k][j] )

同样,我们可以将三维数组压缩成一个二维数组 dist[i][j],通过循环 k1n 来进行原地更新。

以下是算法的核心伪代码:

// 初始化 dist 矩阵为邻接矩阵
let dist be a |V| × |V| matrix of minimum distances initialized to INFINITY
for each vertex v:
    dist[v][v] = 0
for each edge (u, v):
    dist[u][v] = weight(u, v)

// 弗洛伊德-沃舍尔算法核心
for k from 1 to |V|:
    for i from 1 to |V|:
        for j from 1 to |V|:
            if dist[i][k] + dist[k][j] < dist[i][j]:
                dist[i][j] = dist[i][k] + dist[k][j]

负权环检测
在算法结束后,检查 dist[i][i](对角线)。如果存在某个 i 使得 dist[i][i] < 0,则说明图中存在经过顶点 i 的负权环。

时间复杂度
三重循环导致时间复杂度为 O(V^3)。对于稠密图(E ≈ V^2),这比运行 V 次贝尔曼-福特算法 (O(V^2 * E) ≈ O(V^4)) 要快得多。

约翰逊算法:重赋权与结合运用

最后,我们介绍一个结合了贝尔曼-福特和迪杰斯特拉算法思想的技巧——约翰逊算法。它适用于需要多次从不同源点计算最短路径的场景。

核心思想:通过重赋权,将原图 G 转换为所有边权为非负的新图 G’,且在新图中的最短路径与原图对应。

重赋权方法
为每个顶点 v 分配一个势能 h[v]。对于每条边 (u, v),定义其新权重为:
weight'(u, v) = weight(u, v) + h[u] - h[v]

性质
对于任意路径 p = (v0, v1, ..., vk),新路径长度满足:
weight'(p) = weight(p) + h[v0] - h[vk]
由于 h[v0] - h[vk] 对于所有从 v0vk 的路径是常数,因此路径的相对长度顺序不变,最短路径保持不变。

如何选择势能 h[v]
我们可以通过运行一次贝尔曼-福特算法来得到合适的势能。具体做法是:

  1. 向原图添加一个虚拟源点 s',并添加从 s' 到所有原图中顶点的边,权重为 0
  2. s' 为源点运行贝尔曼-福特算法,得到从 s' 到每个顶点 v 的最短距离 dist[s'][v]
  3. h[v] = dist[s'][v]

可以证明,这样定义的 h[v] 能保证 weight'(u, v) = weight(u, v) + h[u] - h[v] >= 0(这本质上是三角不等式)。

算法步骤

  1. 添加虚拟源点,运行一次贝尔曼-福特算法,得到势能 h[v],并检测原图是否有负权环。
  2. 根据 h[v] 重赋权,得到所有边权非负的新图 G’
  3. G’ 上,对每个需要计算最短路径的源点,运行更快的迪杰斯特拉算法

优势
如果需要计算所有顶点对的最短路径,总时间复杂度为:一次贝尔曼-福特 O(V*E),加上 V 次迪杰斯特拉。若使用二叉堆实现迪杰斯特拉,总时间为 O(V*E + V*E log V)。对于稀疏图,这比弗洛伊德-沃舍尔算法的 O(V^3) 更优。

总结

本节课中我们一起学习了处理带负权边图的最短路径算法。

  • 我们首先明确了负权环会使最短路径问题无定义,因此算法通常假设图中不含负权环。
  • 贝尔曼-福特算法基于动态规划,通过 V-1 轮松弛操作求解单源最短路径,时间复杂度为 O(V*E),并能检测负权环。
  • 弗洛伊德-沃舍尔算法通过动态规划求解所有顶点对的最短路径,使用三重循环,时间复杂度为 O(V^3),适合稠密图。
  • 约翰逊算法巧妙地结合了前两者,通过重赋权消除负权边,从而可以运用更快的迪杰斯特拉算法,在需要多次计算最短路径时(尤其是稀疏图)效率更高。

理解这些算法的核心思想、状态定义以及它们之间的关联,是掌握图论最短路径问题的关键。

040:图上的游戏

在本节课中,我们将学习图论博弈的基本概念。图论博弈是在图上进行的游戏。我们将讨论几种经典的图论博弈,学习如何分析它们,并介绍解决这些问题的通用方法。

什么是图论博弈?

图论博弈本质上是在图上进行的游戏。我们有一个图,两个玩家在这个图上进行某种游戏。我们将讨论一些经典的图论博弈。

最经典的图论博弈是这样的:你有一个有向图。游戏开始时,一个“棋子”被放置在图的某个顶点上。两名玩家轮流行动,轮到某位玩家时,他必须将棋子沿着某条边移动到相邻的顶点。例如,第一位玩家将棋子移动到这里,然后第二位玩家将棋子移动到这里,依此类推。

当你无法移动棋子时(即从当前顶点没有出边),你就输了。游戏没有平局,总会有一方获胜。问题的核心是:如果双方都采取最优策略,当棋子从某个特定顶点开始时,谁会获胜?

分析无环图上的游戏

首先,我们来分析无环图上的游戏。对于无环图,大多数游戏的分析都非常简单。这是因为你可以对图进行拓扑排序,然后按照这个顺序(例如从右到左)来分析游戏。

假设我们有一个有向无环图。我们对其进行拓扑排序,得到顶点顺序 1, 2, 3, 4。然后我们从右向左分析从每个顶点开始的游戏。

  • 如果从顶点 4 开始,由于没有出边,玩家会立即输掉游戏。我们称顶点 4 为“必败点”。
  • 现在分析顶点 3。从顶点 3,玩家可以移动到顶点 4。因为顶点 4 是必败点,所以如果玩家从顶点 3 移动到顶点 4,他的对手将面临必败局面。因此,顶点 3 是一个“必胜点”。
  • 接下来分析顶点 2。从顶点 2,玩家可以移动到顶点 3 或顶点 4。如果他移动到顶点 3(一个必胜点),对手将获胜。如果他移动到顶点 4(一个必败点),对手将失败。因此,作为先手玩家,他会选择移动到必败点以赢得游戏。所以顶点 2 是必胜点。
  • 最后分析顶点 1。从顶点 1,玩家可以移动到顶点 2 或顶点 3,两者都是必胜点。无论他移动到哪个点,对手都将面临必胜局面。因此,顶点 1 是必败点。

这就是分析无环图游戏的方法。我们从右向左遍历。对于每个顶点,我们查看其所有出边指向的顶点状态:

  • 如果至少有一条出边指向一个必败点,那么当前顶点是必胜点(因为你可以通过移动迫使对手面对必败局面)。
  • 如果所有出边都指向必胜点,那么当前顶点是必败点(因为无论你怎么走,对手都将面对必胜局面)。

带权图上的游戏

现在,我们来看一个带权图的变体。假设每条边都有一个整数权重。当玩家沿着一条边移动棋子时,他需要向对手支付这条边的费用。游戏的目标是最大化自己的收益(或最小化损失)。

分析这类问题的方法与之前类似。我们进行拓扑排序,然后从右向左进行动态规划。对于每个顶点 v,我们计算一个值 dp[v],表示从该顶点开始,先手玩家能获得的最大收益。

计算 dp[v] 的规则如下:假设从顶点 v 出发有若干条边,指向顶点 u1, u2, ...,边权分别为 w1, w2, ...。如果你选择移动到 ui,你需要支付 wi,然后游戏在顶点 ui 继续,但此时对手变成了先手。因此,你的总收益将是 -wi + (-dp[ui]),即 -wi - dp[ui]。作为先手玩家,你会选择能最大化这个值的移动。所以公式为:

dp[v] = max_{ (v->u, w) } ( -w - dp[u] )

非对称游戏

之前的游戏是对称的,即双方玩家的移动规则相同。现在我们考虑非对称游戏,即不同玩家有不同的移动规则。例如,用红色和蓝色标记两种边,红方玩家只能沿红边移动,蓝方玩家只能沿蓝边移动。

分析这种游戏,我们需要为每个顶点记录两个值:一个表示如果当前是红方回合,该位置是必胜还是必败;另一个表示如果当前是蓝方回合,该位置是必胜还是必败。

我们依然可以从右向左分析。对于没有出边的终点,无论轮到谁,都是必败点。然后,对于其他顶点:

  • 如果当前是红方回合,只要存在一条红边指向一个对蓝方(即下一个回合的玩家)来说是必败的位置,那么红方就能获胜。
  • 如果当前是蓝方回合,判断逻辑类似。

另一种更通用的方法是“状态复制”。我们将原图复制两份,一份代表“红方回合”的状态,另一份代表“蓝方回合”的状态。然后根据移动规则,在复制后的图上连边,构建一个新的对称游戏图。最后,在这个新图上用标准方法分析即可。这种方法通常更好,因为它将新问题归约到了我们已经知道如何解决的问题上。

改变获胜条件

我们回到最初的经典游戏,但改变获胜条件:如果玩家无法移动,则他获胜(而不是失败)。

如何分析?我们只需要修改终点状态的初始标记。在经典规则下,没有出边的顶点是必败点。在新规则下,这些顶点变成了必胜点。然后,分析其他顶点的规则保持不变:如果一个顶点有出边指向必败点,则它是必胜点;如果所有出边都指向必胜点,则它是必败点。

同样,我们可以通过修改图来将这个问题归约到经典问题。具体做法是:为每个原图的终点添加一条指向一个新的“特殊终点”的边。在经典规则下,这个特殊终点是必败点。这样一来,所有原终点都有一条出边指向一个必败点,因此它们都变成了必胜点,从而模拟了新规则的获胜条件。

分析带环图上的游戏

当图中存在环时,情况变得复杂。游戏可能无限进行下去,导致平局。但并非所有环都会导致平局,有些环中的位置仍然可能是必胜或必败的。

我们需要一个算法来确定每个顶点的状态(必胜、必败或平局)。思路如下:

  1. 首先,将所有没有出边的顶点标记为“必败”(在经典规则下)。
  2. 然后,进行迭代处理:
    • 如果一个顶点有一条出边指向一个“必败”点,那么该顶点可以标记为“必胜”。
    • 如果一个顶点的所有出边都指向“必胜”点,那么该顶点可以标记为“必败”。
  3. 重复步骤2,直到没有新的顶点可以被标记为止。
  4. 最后,所有未被标记的顶点都是“平局”点。这是因为从这些顶点出发,玩家总可以选择不走向已知的必败点,从而将游戏引向一个环,导致无限循环。

这个算法可以在线性时间内实现。我们需要维护每个顶点的“未标记出边计数器”。当我们将一个顶点标记为必胜时,我们检查所有指向它的顶点,并减少其计数器的值。如果某个顶点的计数器减到0,意味着它的所有出边都指向了必胜点,那么它就应该被标记为必败,并加入处理队列。

游戏的组合与SG函数

有时,一个复杂的游戏可以分解为几个独立的子游戏之和。在组合游戏中,玩家每回合选择其中一个子游戏进行一步操作。当所有子游戏都无法操作时,当前玩家输。

如果我们直接构建组合游戏的状态图(即所有子游戏状态的笛卡尔积),状态数会随着子游戏数量指数级增长,这是不可接受的。因此,我们需要一种方法来通过分析单个子游戏来推断组合游戏的结果。这就是Sprague-Grundy定理(SG定理)的用武之地。

SG函数 定义如下:对于一个游戏状态 v,其SG值 g(v) 是集合 { g(u) | 存在从 v 到 u 的合法移动 }最小非负整数补集(mex)。也就是说,g(v) 是不在该集合中的最小的非负整数。

对于无环图,我们可以从终点(SG值为0)开始,自底向上计算每个顶点的SG值。

SG函数与游戏状态的关系很简单:

  • 一个状态是必败点,当且仅当其SG值为 0
  • 一个状态是必胜点,当且仅当其SG值大于0。不同的SG值可以看作是不同类型的必胜局面。

SG定理的强大之处在于它处理游戏组合的方式:组合游戏的SG值,等于其各子游戏SG值的异或和

也就是说,如果我们有子游戏 AB,其当前状态的SG值分别为 g(A)g(B),那么组合游戏 A+B 的SG值就是:

g(A+B) = g(A) XOR g(B)

其中 XOR 表示按位异或操作。

因此,要分析组合游戏,我们只需:

  1. 独立计算每个子游戏的SG值。
  2. 计算这些SG值的异或和。
  3. 如果异或和为0,则整个组合游戏对先手玩家是必败的;否则是必胜的。

这避免了构建庞大的组合状态图,是分析复杂组合游戏的利器。


本节课中,我们一起学习了图论博弈的基本概念。我们从最简单的无环图对称游戏开始,学习了通过拓扑排序和动态规划进行分析的方法。接着,我们探讨了带权图、非对称游戏以及改变获胜条件等变体,并学习了通过修改图或复制状态来将它们归约为已知问题。

然后,我们研究了带环图上的游戏,介绍了通过迭代标记和计数器在线性时间内判断必胜、必败和平局状态的算法。

最后,我们介绍了强大的SG函数和SG定理,它们为分析复杂的组合游戏提供了高效的工具,使我们能够通过分析独立的子游戏并计算其SG值的异或和来确定整个游戏的胜负。

掌握这些核心思想和方法,你将能够分析和解决许多类型的图论博弈问题。

041:哈希、KMP与Z算法

在本节课中,我们将要学习处理字符串的基础算法。我们将从最基础的子串查找问题开始,逐步介绍三种核心的字符串处理技术:基于哈希的快速查找、经典的KMP算法以及高效的Z算法。这些算法是解决许多复杂字符串问题的基础。


字符串基础

字符串本质上是一个字符数组。

例如,一个字符串 S 可以是 A, B, A, B, B。每个字符串只是一个字母序列。

对字符串的基本操作包括在常数时间内获取任意位置的字符,就像操作数组一样。例如,给定索引 0, 1, 2, 5, 6,你可以直接通过索引访问字符。

字符串的某些部分有特定的名称:

  • 前缀:取字符串开头的任意多个字符。例如,取前 i 个字符 S[0..i-1],就得到了一个长度为 i 的前缀。
  • 后缀:取字符串末尾的任意多个字符。
  • 子串:取字符串中从位置 LR-1 的任意连续字符序列 S[L..R-1]

关于这些部分有一些有趣的性质:

  • 任何前缀的前缀,仍然是前缀。
  • 任何后缀的后缀,仍然是后缀。
  • 任何前缀的后缀,是一个子串。换句话说,任何子串都是某个前缀的后缀
  • 同样,任何后缀的前缀,也是一个子串。所以,任何子串也是某个后缀的前缀

这些是字符串部分的基本定义,也是我们本节讨论字符串算法所需的基础知识。


子串查找问题

本节我们将讨论的核心问题是子串查找

子串查找问题描述如下:你有一段很长的文本 T(一个大字符串),以及一个较短的字符串 S(模式串)。你需要在文本 T 中找到一个子串,这个子串与 S 完全相同。

形式化地说,给定文本 T(长度为 N)和模式串 S(长度为 M),我们需要找到一个位置 i,使得子串 T[i..i+M-1] 等于字符串 S。这就是整个问题。


朴素算法

让我们从一个非常简单的算法开始:暴力匹配。

算法思路是尝试所有可能的位置 i,对于每个位置,取出对应的子串并与 S 进行比较。

以下是该算法的伪代码:

for i from 0 to N - M:
    if T[i..i+M-1] == S:
        return i
return -1

让我们讨论这个算法的时间复杂度。外层循环最多进行 N 次迭代。但是,字符串比较操作 T[i..i+M-1] == S 并不是常数时间的,它需要逐个字符比较,因此需要 O(M) 的时间。所以,总的时间复杂度是 O(N * M)

这个算法虽然正确,但速度较慢。今天我们的目标就是寻找更聪明、更快速的方法来解决这个问题。


基于哈希的算法

第一种改进时间复杂度的方法是使用哈希函数

核心思想是:大多数情况下,字符串比较的结果是“不相等”。如果我们能在常数时间内判断两个字符串“很可能不相等”,就能节省大量时间。

我们可以比较两个字符串的哈希值,而不是直接比较字符串本身。如果两个字符串的哈希值不相等,那么这两个字符串一定不相等。如果哈希值相等,我们才需要进行一次精确的字符串比较来确认(因为存在哈希碰撞的可能)。

以下是改进后的算法框架:

hash_S = hash(S)
for i from 0 to N - M:
    hash_sub = hash(T[i..i+M-1])
    if hash_sub != hash_S:
        continue # 哈希值不同,子串一定不同,跳过
    if T[i..i+M-1] == S: # 哈希值相同,进行精确比较
        return i
return -1

这带来了两个需要解决的问题:

  1. 碰撞问题:我们需要一个好的哈希函数,使得不同字符串哈希值相同的概率尽可能小。
  2. 计算效率:我们需要能够快速计算文本中每个子串的哈希值。如果每次计算都需要 O(M) 时间,那么我们没有获得任何改进。

接下来,我们将解决这两个问题。


多项式哈希函数

我们将使用一种称为多项式哈希的函数。对于一个字符串 S(字符序列 S0, S1, ..., S_{n-1}),我们将其每个字符视为一个数字(例如ASCII码或字母表索引),然后构造一个多项式:

H(S) = (S0 * x^{n-1} + S1 * x^{n-2} + ... + S_{n-1} * x^0) mod M

其中 xM 是我们选择的参数。M 通常是一个大质数,x 是一个随机数。

为什么这个哈希函数好?
考虑两个不同的字符串。它们的哈希值相同,意味着对应的两个多项式在模 M 下相等,即 x 是它们差多项式的根。一个 n 次多项式最多有 n 个根。如果我们随机选择 x,那么碰撞的概率大约为 n / M。通过选择足够大的 M(例如接近 2^64),我们可以使碰撞概率极低。


滚动哈希

现在解决第二个问题:如何高效计算文本中连续子串的哈希值?

假设我们已经计算了子串 T[i..i+M-1] 的哈希值 H。当窗口向右移动一位,我们需要计算 T[i+1..i+M] 的哈希值 H‘

设多项式为:

  • H = T[i]*x^{M-1} + T[i+1]*x^{M-2} + ... + T[i+M-1]*x^0
  • H' = T[i+1]*x^{M-1} + T[i+2]*x^{M-2} + ... + T[i+M]*x^0

观察可得,H' 可以通过 H 快速计算:
H' = (H * x - T[i] * x^M + T[i+M]) mod M

这样,我们就能在常数时间内从一个子串的哈希值推导出下一个子串的哈希值。


完整算法与复杂度

结合滚动哈希,完整的算法如下:

  1. 预计算模式串 S 的哈希值 hash_S
  2. 预计算文本 T 第一个长度为 M 的子串的哈希值 hash_T
  3. 遍历所有起始位置 i
    • 比较 hash_Thash_S
    • 如果不同,则子串一定不同,继续循环。
    • 如果相同,则进行精确字符串比较。若相同则返回 i
    • 使用滚动哈希公式更新 hash_T 为下一个子串的哈希值。

时间复杂度:外层循环 O(N),每次循环中的哈希比较和更新是 O(1)。只有当哈希值相等时(概率极低)才进行 O(M) 的精确比较。因此,期望时间复杂度接近 O(N)

关于确定性与随机性:基于哈希的算法是概率算法。在非关键系统中(如推荐系统),因其高效性而被广泛使用。在要求绝对正确的关键系统中,则需要使用接下来介绍的确性算法。


哈希的扩展应用

多项式哈希的强大之处不止于此。通过预处理字符串所有前缀的哈希值,我们可以在常数时间内计算任意子串的哈希值。

  1. 预处理前缀哈希数组 pref_hash,其中 pref_hash[i] 是字符串 S[0..i-1] 的哈希值。这可以在 O(N) 时间内完成。
  2. 要计算子串 S[L..R-1] 的哈希值,使用公式:
    hash(S[L..R-1]) = (pref_hash[R] - pref_hash[L] * x^{R-L}) mod M

这个性质非常有用,例如:

  • 快速比较任意两个子串是否相等:比较它们的哈希值即可。
  • 统计一组字符串中不同字符串的个数:计算每个字符串的哈希值放入集合,集合的大小就是近似答案。需要注意,当字符串数量 k 很大时,至少发生一次碰撞的概率大约为 k^2 * PP 为单次碰撞概率),因此仍需谨慎选择参数。

KMP 算法

现在,我们转向一种完全确定性的、无需随机化的经典算法——KMP算法(Knuth-Morris-Pratt算法)。

KMP算法的核心思想是利用一个称为前缀函数的预计算信息来避免在匹配失败时回退文本指针,从而实现线性时间匹配。


前缀函数

前缀函数 π(i) 定义为:对于字符串 S 的长度为 i+1 的前缀 S[0..i],其最长的、相等的真前缀和真后缀的长度。

  • “真”意味着不能是字符串本身。
  • 例如,字符串 "ABABA"
    • 前缀 "A":没有相等的真前缀和真后缀,π(0) = 0
    • 前缀 "AB":没有相等的真前缀和真后缀,π(1) = 0
    • 前缀 "ABA":最长的相等真前缀和真后缀是 "A",长度为1,π(2) = 1
    • 前缀 "ABAB":最长的相等真前缀和真后缀是 "AB",长度为2,π(3) = 2
    • 前缀 "ABABA":最长的相等真前缀和真后缀是 "ABA",长度为3,π(4) = 3

如何找到所有相等的前缀和后缀? 从最大的匹配(即 π(i))开始,其下一个更短的匹配就是 π(π(i)-1),以此类推,直到长度为0。这形成了一个链式关系。


计算前缀函数数组

KMP算法的第一步是为模式串 S 计算其所有前缀对应的前缀函数值,得到一个数组 p[],其中 p[i] = π(i-1)(即前缀 S[0..i-1] 的前缀函数值)。我们规定 p[0] = -1 以方便计算。

计算过程是递推的,从 i=1M-1

  • k = p[i-1],即前一个前缀的最长相等前后缀长度。
  • 我们检查字符 S[i] 是否等于 S[k]
    • 如果相等,那么 p[i] = k + 1
    • 如果不相等,则我们需要找一个更短的、也是前后缀的字符串,即令 k = p[k],然后继续比较 S[i]S[k],直到 k 变为 -1
  • 如果 k 变为 -1,意味着没有匹配的前后缀,则 p[i] = 0

以下是该算法的核心代码:

p[0] = -1
for i in range(1, M):
    k = p[i-1]
    while k >= 0 and S[i] != S[k]:
        k = p[k]
    p[i] = k + 1

时间复杂度证明:关键在于 while 循环。变量 k 在循环中严格递减,而在每次外层循环中,k 最多增加1(p[i] = k+1)。因此,k 减少的总次数不会超过 k 增加的总次数 O(M),所以整个算法是 O(M) 的。


使用KMP进行子串查找

有了模式串 S 的前缀函数数组 p,我们可以在文本 T 中进行查找。

算法使用两个指针:i 遍历文本 Tj 表示当前已匹配的模式串字符数。

  1. 初始化 i = 0, j = 0
  2. i < N 时循环:
    • 如果 T[i] == S[j],则 i++, j++
    • 如果 j == M,则匹配成功,返回 i - M
    • 如果 T[i] != S[j]
      • 如果 j > 0,根据前缀函数,将 j 回退到 p[j-1](即已匹配部分的最长相等前后缀长度),然后继续比较 T[i]S[j]
      • 如果 j == 0,则直接 i++

这个算法保证了文本指针 i 永不回退,因此总的时间复杂度是 O(N + M)

一个更简单的实现技巧是:构造新字符串 S + ‘#’ + T,其中 ‘#’ 是一个不出现在 ST 中的分隔符。然后对这个新字符串计算前缀函数数组。在数组的后半部分(对应原文本 T 的位置),如果某个 p[i] 的值等于 M,就意味着在位置 i - M - 1(考虑分隔符的偏移)处找到了一个匹配。


Z 算法

最后,我们介绍另一种线性时间算法——Z算法。它与KMP算法思想类似,但是从另一个角度计算信息。


Z 数组

对于字符串 S,Z数组 z[i] 定义为:从位置 i 开始的子串 S[i..] 与整个字符串 S最长公共前缀的长度。

  • 通常我们定义 z[0] = 0 或不使用它。
  • 例如,字符串 "ABACABAB"
    • z[1]"BACABAB""ABACABAB" 的公共前缀长度为0。
    • z[2]"ACABAB""ABACABAB" 的公共前缀长度为0。
    • z[3]"CABAB""ABACABAB" 的公共前缀长度为0。
    • z[4]"ABAB""ABACABAB" 的公共前缀 "AB",长度为2。


计算 Z 数组

计算Z数组也有一个高效的线性算法。它维护一个“匹配段” [L, R],表示当前已知的、与前缀匹配的最右子串区间。

算法从左到右计算 z[i]

  1. 如果 i > R,则无法利用已知信息,朴素地逐个字符比较计算 z[i]
  2. 如果 i <= R,则可以利用 z[i-L] 的值。
    • k = i - L
    • 如果 z[k] < R - i + 1,那么 z[i] = z[k]
    • 否则,我们知道 S[0..R-i]S[i..R] 匹配,但 R 之后的情况未知。因此,从 R+1 开始继续朴素比较,并更新 L = iR 为新的匹配右边界。

以下是算法框架:

L = R = 0
z[0] = 0 # 或不使用
for i in range(1, N):
    if i > R:
        # 朴素扩展
        L, R = i, i
        while R < N and S[R] == S[R - i]:
            R += 1
        z[i] = R - L
        R -= 1
    else:
        k = i - L
        if z[k] < R - i + 1:
            z[i] = z[k]
        else:
            L = i
            while R < N and S[R] == S[R - i]:
                R += 1
            z[i] = R - L
            R -= 1

时间复杂度证明:算法中的 while 循环每执行一次,匹配段的右边界 R 都会向右移动。而 R 最多移动 N 次,因此总循环次数为 O(N)


使用Z算法进行子串查找

与KMP类似,我们可以构造新字符串 S + ‘#’ + T。对这个新字符串计算Z数组。在数组的后半部分(对应原文本 T 的位置),如果某个 z[i] 的值等于 M(模式串长度),就意味着在位置 i - M - 1 处找到了一个匹配。


总结

本节课我们一起学习了三种重要的字符串子串查找算法:

  1. 基于哈希的算法:利用多项式哈希和滚动哈希,在期望 O(N) 时间内完成查找。它实现简单、效率高,但属于概率算法。
  2. KMP算法:通过预计算模式串的前缀函数,在匹配失败时智能移动模式串指针,实现了确定性的 O(N+M) 时间查找。它是经典的字符串匹配算法。
  3. Z算法:通过计算字符串的Z数组,同样实现了确定性的 O(N+M) 时间查找。它从“每个后缀与整个字符串的匹配长度”这一角度提供信息。

KMP的“前缀函数”和Z算法的“Z函数”在本质上是相通的,可以相互转换。在不同的具体问题中,选择哪一种视角更为方便。掌握这些基础算法,是解决更复杂字符串问题(如后缀数组、自动机等)的关键第一步。

042:有限状态自动机 🎯

在本节课中,我们将学习有限状态自动机(Finite State Automata)的基本概念、如何构建它,以及如何将其应用于算法问题中。我们将从简单的定义开始,逐步深入到构建特定自动机的算法,并学习如何最小化自动机。


什么是有限状态自动机? 🤔

有限状态自动机本质上是一个包含若干状态的图。每个状态是图中的一个顶点。其中一个状态被标记为起始状态,一些状态被标记为终止状态。状态之间存在转移,每条转移都标记了字母表中的一个字母。

例如,假设我们有一个字母表,只包含字母 AB。一个简单的自动机可能如下图所示:

起始状态 (S) --A--> 状态1 --B--> 终止状态 (T)

在这个自动机中,从起始状态 S 出发,读取字母 A 会转移到状态1,再从状态1读取字母 B 会转移到终止状态 T


如何使用有限状态自动机? 🛠️

给定一个字符串,自动机的工作方式非常简单。它从起始状态开始,然后依次读取字符串中的每个字母,并根据当前字母沿着对应的转移边移动到下一个状态。

例如,对于字符串 "AB",自动机从起始状态 S 开始:

  1. 读取 A,移动到状态1。
  2. 读取 B,移动到终止状态 T

如果处理完整个字符串后,自动机停留在某个终止状态,那么我们就说这个自动机接受这个字符串。


确定性与非确定性自动机 ⚙️

我们主要讨论确定性有限状态自动机。在这种自动机中,对于每个状态和字母表中的每个字母,最多只有一条对应的转移边。这意味着在任何状态下,给定一个输入字母,下一步的去向是唯一确定的。

也存在非确定性有限状态自动机,它允许从一个状态对同一个字母有多个转移。虽然功能更强大,但在算法中通常不那么实用,因为我们需要在多个可能的选择中做出决定,这通常很复杂。在本讲座中,我们主要关注确定性自动机。


自动机的作用:连接字符串与图问题 🌉

自动机就像一个桥梁,它将字符串问题转化为图问题。你有一个关于字符串的问题,可以构建一个对应的自动机。现在,你面对的就是一个关于图的问题,而我们已经学习了许多优秀的图算法。

例如,假设一个自动机定义了一个语言(即一组字符串的集合)。如果你想计算该语言中长度为 n 的字符串有多少个,你可以这样做:

每个被自动机接受的字符串,都对应着图中一条从起始状态到某个终止状态的路径。因此,计算长度为 n 的字符串数量,就等价于计算图中从起始状态到任意终止状态的、长度为 n 的路径数量。这可以通过动态规划轻松解决。

动态规划公式示例:
dp[v][k] 为从起始状态到顶点 v、长度为 k 的路径数量。
dp[v][k] = sum(dp[u][k-1]),其中 u 是所有能通过一条边到达 v 的状态。


构建接受特定子串的自动机 🧱

现在,让我们看一个具体的例子:如何构建一个自动机,它接受所有包含给定字符串 S 作为子串的文本。

假设 S = "ABBA"。我们想要构建的自动机 A,满足:对于任何文本 T,当且仅当 ST 的子串时,A 接受 T

构建思路

  1. 构建主干路径:首先,自动机必须能接受字符串 S 本身。因此,我们创建一条路径,其转移边依次对应 S 的每个字符,并从起始状态通向一个终止状态。这条路径上的每个状态,都对应着 S 的一个前缀。

    • 状态0:对应空前缀 ""(起始状态)
    • 状态1:对应前缀 "A"
    • 状态2:对应前缀 "AB"
    • 状态3:对应前缀 "ABB"
    • 状态4:对应前缀 "ABBA"(终止状态)
  2. 为其他状态添加转移:关键点在于,每个状态 i 表示“当前已读文本的最长后缀,且该后缀是 S 的一个前缀”。当我们处于状态 i(对应前缀 P_i)并读入字符 c 时,我们需要找到新的最长后缀,它必须是 P_i + c 的后缀,同时是 S 的前缀。

    • 如果 c 正好等于 S[i](即 S 的下一个字符),那么我们直接转移到状态 i+1
    • 否则,我们需要回退。我们寻找更短的、P_i 的后缀,看看加上 c 后是否能成为 S 的前缀。这实际上就是计算 KMP 算法中的前缀函数
  3. 处理终止状态:一旦到达终止状态(状态4),意味着已经找到了子串 S。我们希望此后永远停留在这个接受状态。因此,在终止状态,我们为所有字母添加指向自身的循环转移。

高效构建算法

我们可以利用类似 KMP 算法中计算前缀函数的思想,在线性时间内构建这个自动机。我们维护一个 next 状态转移表。

算法核心(伪代码):

# 假设字母表为小写字母 a-z
# S 是模式串,长度为 m
# go[i][c] 表示在状态 i 读入字符 c 后转移到的状态
# 状态编号 0...m,其中 m 是终止状态

# 初始化所有转移为 0
go = [[0]*26 for _ in range(m+1)]

# 构建路径:对于 S 本身的字符
for i in range(m):
    c = S[i]
    go[i][ord(c)-ord('a')] = i+1

# 利用前缀函数快速构建其他转移
link = [0]*(m+1) # 前缀函数,link[0] = -1
j = 0
for i in range(1, m):
    # 计算前缀函数
    while j > 0 and S[i] != S[j]:
        j = link[j]
    if S[i] == S[j]:
        j += 1
    link[i+1] = j
    # 为状态 i+1 构建失败转移
    for c in range(26):
        if go[i][c] != 0:
            go[i+1][c] = go[i][c]
        else:
            go[i+1][c] = go[link[i+1]][c]

这个算法的时间复杂度是 O(m * |字母表|)。如果字母表大小是常数,那么就是 O(m) 的线性时间。


存储字符串集合:Trie 树 📚

另一个重要的自动机是 Trie 树(前缀树),它是一种特殊的确定性有限状态自动机,用于高效存储字符串集合。

构建方法:

  1. 从单一的根节点(起始状态)开始。
  2. 对于集合中的每个字符串,从根节点开始,依次添加字符对应的边和节点。
  3. 每个字符串的最后一个字符对应的节点标记为终止状态。

优点:

  • 线性时间构建:总构建时间为所有字符串长度之和 O(总字符数)
  • 快速查询:检查一个字符串是否在集合中只需 O(字符串长度) 时间。
  • 支持动态操作:可以较容易地添加或删除字符串。
  • 表示所有前缀:Trie 树不仅存储了字符串集合,还隐式存储了所有字符串的所有前缀,便于进行各种前缀相关的查询和计算。

自动机的最小化与等价性检查 ⚖️

对于一个给定的语言,可能存在许多接受它的不同自动机。自动机最小化的目标是找到状态数最少的那个等价的确定性有限状态自动机。

最小化算法原理

算法的核心思想是识别并合并等价状态。两个状态 uv 是等价的,当且仅当:从它们出发,所有可能输入的字符串要么都导致接受,要么都导致拒绝。换句话说,它们无法被任何字符串区分。

算法步骤(划分细化法):

  1. 初始划分:将所有状态划分为两个组——终止状态组和非终止状态组。显然,这两组状态不等价。
  2. 迭代细化:不断检查每个分组 A。对于每个输入字符 c,查看组内状态通过 c 转移到的目标状态组。
    • 如果组内所有状态通过 c 都转移到同一个目标组,则保持该组不变。
    • 否则,将组 A 分裂为更小的子组,使得每个子组内的状态通过 c 都转移到同一个目标组。
  3. 重复步骤2,直到没有任何分组可以再被分裂。此时,每个分组内的状态都是等价的。
  4. 合并:将每个等价状态组合并成一个单一状态,更新转移关系,得到的就是最小自动机。

Hopcroft 算法 是执行上述划分过程的一个高效实现,时间复杂度可达 O(n log n),其中 n 是状态数。

检查两个自动机是否等价

有了最小化算法,检查两个自动机 AB 是否等价就很简单了:

  1. 分别最小化 AB
  2. 检查得到的最小自动机是否同构(即结构完全相同)。由于最小自动机在忽略状态命名的情况下是唯一的,所以如果它们同构,则原自动机等价;否则不等价。

更直接的方法是:将两个自动机视为一个更大的自动机,然后运行上述最小化算法。如果算法结束时,A 的起始状态和 B 的起始状态被划分在同一个等价组中,那么这两个自动机就是等价的。


总结 📝

本节课我们一起学习了有限状态自动机的核心知识:

  1. 基本概念:自动机是一个带有标记转移边的图,包含起始状态和终止状态,用于识别字符串。
  2. 核心应用:它将字符串问题转化为图问题,使我们能够利用动态规划等图算法来解决复杂的字符串计数、匹配问题。
  3. 特定自动机构建:我们学习了如何为“包含某子串”这一语言构建自动机,并利用类似 KMP 前缀函数的思想实现线性时间构建。
  4. Trie 树:作为一种特殊的自动机,用于高效存储和查询字符串集合。
  5. 最小化与等价性:我们了解了自动机最小化的原理,以及如何通过最小化来检查两个自动机是否描述同一种语言。

掌握有限状态自动机,为你解决各类字符串处理问题提供了又一个强大的工具。

043:Aho-Corasick 算法 🎯

在本节课中,我们将学习 Aho-Corasick 算法。这是一个用于在文本中同时查找多个模式串的高效算法。我们将从问题定义开始,逐步构建算法的核心概念,并学习如何用它解决不同类型的字符串匹配问题。

问题定义

上一节我们讨论了单模式串的搜索算法。本节中,我们来看看一个更复杂的问题:多模式串匹配

我们有一个很长的文本字符串 T,以及一组较短的字符串集合 {S1, S2, ..., Sk}。我们的目标是找出文本 T 中所有出现这些模式串的位置。

一个简单的解法是对每个模式串 Si 都运行一次 KMP 等线性搜索算法。但这样做的总时间复杂度是 O(|T| * k),当文本很长或模式串很多时,效率很低。我们希望只对文本 T 进行一次扫描,就能找出所有模式串的出现。

构建 Trie 树 🌲

Aho-Corasick 算法的第一步是构建一个 Trie 树(前缀树),将所有模式串插入其中。

以下是构建 Trie 树的过程:

  1. 从根节点(代表空字符串)开始。
  2. 对于每个模式串 Si,从根节点出发,按字符依次向下走。
  3. 如果当前字符对应的边不存在,则创建一个新的节点和边。
  4. 当处理完一个模式串的所有字符后,标记最后一个节点为“终止节点”。

构建 Trie 树的时间复杂度是 O(Σ|Si|),即所有模式串的总长度。

上一节我们介绍了 Trie 树,本节中我们来看看 Aho-Corasick 算法的核心:后缀链接

后缀链接类似于 KMP 算法中的 前缀函数 (π)。对于 Trie 树中的每个节点 v(代表一个字符串 str(v)),我们定义它的后缀链接 link[v] 指向另一个节点 u,使得 str(u)str(v)最长真后缀,并且 u 也存在于 Trie 树中。如果不存在这样的后缀,则 link[v] 指向根节点。

公式定义
对于节点 vlink[v] = u,其中 str(u)str(v) 的最长真后缀,且 u 在 Trie 中。

以下是计算后缀链接的规则:

  • 根节点:没有后缀链接(或指向一个虚拟的空节点)。
  • 深度为 1 的节点(即代表单个字符的节点):其后缀链接指向根节点。
  • 其他节点 v:设其父节点为 p,从父节点到 v 的边上的字符为 c。要计算 link[v],我们首先看 link[p] 指向的节点 k。然后,我们检查节点 k 是否有通过字符 c 的转移。如果有,则 link[v] 就指向那个转移的目标节点。如果没有,我们就继续查看 link[k],重复此过程,直到找到这样的转移或到达根节点。

这个过程可以通过广度优先搜索 (BFS) 按节点深度递增的顺序高效完成。

构建转移函数 (Transitions) ➡️

有了后缀链接,我们就可以定义完整的转移函数 go(v, c)。它表示当我们在状态(节点)v 时,读入下一个字符 c 后,应该转移到哪个状态。

转移函数的计算规则如下:

  1. 如果节点 v 本身有一条标记为字符 c 的边指向子节点 u,那么 go(v, c) = u
  2. 否则,go(v, c) = go(link[v], c)。这里我们递归地利用后缀链接指向的更短后缀的状态来计算转移。这个递归过程最终会停止在根节点(根节点对所有字符都有定义,通常是转移到自身或一个初始状态)。

在实际实现中,我们可以用动态规划的方式,在计算后缀链接的同时或之后,一次性计算出所有 (v, c) 的转移,并存储起来。这样,在扫描文本时,每次转移都是 O(1) 时间。

代码描述(伪代码)

# 假设 nodes 是 Trie 节点列表,root = 0
# link[v] 是节点 v 的后缀链接
# next[v][c] 是节点 v 在字符 c 下的转移

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pavel-mavrin-dsal/img/41fb4f6b1cef2af9be617e35e749b848_23.png)

def build_automaton():
    queue = [root]
    while queue:
        v = queue.pop(0)
        for c in alphabet:
            if next[v][c] exists: # 情况1:有直接子节点
                u = next[v][c]
                link[u] = next[link[v]][c] if v != root else root
                queue.append(u)
            else: # 情况2:无直接子节点,利用后缀链接
                next[v][c] = next[link[v]][c] if v != root else root

利用自动机进行匹配 🧲

现在,我们已经构建了一个完整的自动机。用它来扫描文本 T 并解决问题就非常直观了。

以下是匹配过程:

  1. 初始化当前状态 state = root(根节点)。
  2. 从左到右遍历文本 T 的每个字符 c
    • state = go(state, c)。根据转移函数移动到下一个状态。
    • 检查新状态 state 及其通过后缀链接可达的所有状态,看它们是否是“终止节点”,以报告匹配到的模式串。

关键在于,当处于某个状态 state 时,不仅 state 本身代表的字符串(某个模式串的前缀)是当前文本后缀,所有通过后缀链接链从 state 能回溯到的状态所代表的字符串,也都是当前文本的后缀。因此,我们需要检查这条链上的所有节点。

解决不同类型的问题 ✅

上一节我们介绍了匹配的基本过程,本节中我们来看看如何用这个框架解决课程开头提出的几类具体问题。

以下是三类典型问题的解法:

  1. 问题一:判断文本是否包含任意一个模式串

    • 解法:在扫描文本时,只要当前状态 state 或其通过后缀链接可达的任意状态是终止节点,就立即返回 True。扫描完若未发现,则返回 False
  2. 问题二:判断每个模式串是否在文本中出现

    • 解法:在扫描文本时,记录所有访问过的状态(节点)。然后,通过后缀链接树(所有后缀链接反向构成的树),将这些“访问标记”传播给它们的祖先节点。如果一个状态被标记,意味着以它为后缀的某个前缀曾在文本中出现。最后,对于每个模式串对应的终止节点,检查它是否被标记,即可知该模式串是否出现。
  3. 问题三:计算每个模式串在文本中出现的次数

    • 解法:在扫描文本时,对每个访问到的状态 state,将其计数器 cnt[state] 加 1。扫描结束后,在后缀链接树上进行一次后缀和累加:对于每个节点 v,将 cnt[v] 的值加到 cnt[link[v]] 上。这个过程完成后,对于每个模式串对应的终止节点 vcnt[v] 的值就是该模式串在文本中出现的总次数。

算法复杂度分析 📊

让我们总结一下 Aho-Corasick 算法的时间和空间复杂度。

  • 构建 Trie 树O(Σ|Si|)
  • 构建后缀链接和转移函数O(Σ|Si| * |A|),其中 |A| 是字母表大小。如果字母表很大,我们可以选择不预计算所有转移,而是在匹配时动态计算,这样构建复杂度可降至 O(Σ|Si|),但每次转移可能需要 O(log |A|) 或均摊 O(1) 时间。
  • 扫描文本O(|T| + z),其中 z 是匹配到的模式串总数。因为每次状态转移是 O(1),报告匹配的额外开销与匹配数成正比。

因此,对于字母表不大的情况,Aho-Corasick 算法是一种非常高效的多模式串匹配算法。

总结

本节课中我们一起学习了 Aho-Corasick 算法。我们从多模式串匹配的问题定义出发,首先构建了包含所有模式串的 Trie 树。然后,通过引入类似 KMP 前缀函数的 后缀链接,并在此基础上定义 转移函数,我们构建了一个强大的自动机。最后,我们学习了如何利用这个自动机一次性扫描文本,高效地解决“是否存在任意匹配”、“哪些模式串出现”以及“计算各模式串出现次数”等三类经典问题。该算法是处理字典匹配和关键词过滤等任务的基石。

044:后缀数组

在本节课中,我们将要学习一种名为“后缀数组”的字符串数据结构。后缀数组是一种非常简洁且强大的工具,它能够帮助我们高效地解决许多与字符串子串相关的问题,例如在文本中快速查找子串。我们将学习后缀数组是什么、为什么需要它、如何构建它,以及如何利用它来回答查询。

什么是后缀数组?

后缀数组是一种简单的数据结构。给定一个字符串,后缀数组就是将该字符串的所有后缀按字典序排序后,记录每个后缀起始索引的数组。

例如,对于字符串 ABBABA,我们列出它的所有后缀:

  • 起始于索引 0:ABBABA
  • 起始于索引 1:BBABA
  • 起始于索引 2:BABA
  • 起始于索引 3:ABA
  • 起始于索引 4:BA
  • 起始于索引 5:A
  • 起始于索引 6(空后缀):

将这些后缀按字典序排序后得到:

  1. (空后缀)
  2. A
  3. ABA
  4. ABBABA
  5. BA
  6. BABA
  7. BBABA

后缀数组 P 就是记录这些排序后后缀的起始索引:[6, 5, 3, 0, 4, 2, 1]

我们并不直接存储后缀字符串本身,因为那样总长度会是 O(n^2),内存消耗过大。我们只存储起始索引,当需要获取某个后缀的第 j 个字符时,可以通过 S[P[i] + j] 在常数时间内获得。

为什么需要后缀数组?

后缀数组可以高效解决许多字符串问题。一个最常见的应用场景是:在一个给定的长文本中,回答多个关于子串是否存在的查询。

假设我们有一个文本 S,以及一系列查询字符串 Q1, Q2, ...。对于每个查询 Q,我们需要判断它是否在文本 S 中出现。

利用后缀数组,我们可以将这个问题转化为:在排序好的后缀数组中,二分查找第一个以 Q 为前缀的后缀。因为任何子串都是某个后缀的前缀。如果找到了这样的后缀,并且它的前 |Q| 个字符与 Q 匹配,那么就说明 Q 存在于文本中。二分查找的时间复杂度为 O(log n * |Q|)

后缀数组的优势在于其内存效率高,只需要额外 O(n) 的空间(一个整数数组),就能支持快速查询。

如何构建后缀数组?

构建后缀数组最直接的方法是将所有后缀的索引放入数组,然后使用自定义比较器进行排序。比较两个后缀需要 O(n) 时间,因此总复杂度为 O(n^2 log n)。对于随机字符串,比较通常很快,但在最坏情况下(如所有字符相同)效率很低。

我们可以利用字符串哈希和二分查找来优化比较过程。比较两个后缀时,先用二分查找找到它们的最长公共前缀长度,再比较下一个字符。这样每次比较的复杂度降为 O(log n),总构建复杂度为 O(n log^2 n)。但哈希可能存在碰撞风险。

接下来,我们将介绍一种更稳定、更高效的 O(n log n) 构建算法。

倍增算法

倍增算法的核心思想是:分阶段对后缀进行排序。在第 k 阶段,我们并非直接比较整个后缀,而是比较每个后缀长度为 2^k 的前缀。

为了使算法更清晰,我们首先对原字符串进行一些技术性处理:

  1. 在字符串末尾添加一个特殊字符(如 $),其值小于任何字母,以保证后缀顺序不变。
  2. 将字符串视为循环字符串,并扩展其长度至 2^m,使得每个“后缀”实际上是一个循环移位,且长度统一为 2^m。这简化了边界处理。

算法步骤:

  1. 初始化 (k=0):对字符串的所有单个字符进行排序。我们可以得到每个长度为1的子串的排名(rank,或称 class)。
  2. 从 k 阶段过渡到 k+1 阶段
    • 在第 k 阶段,我们已经得到了所有长度为 2^k 的子串的排序顺序及其排名。
    • 要比较两个长度为 2^{k+1} 的子串 AB,我们可以将它们各分为两半:A = A1 + A2B = B1 + B2,每半长度均为 2^k
    • 比较 AB 等价于比较二元组 (rank(A1), rank(A2))(rank(B1), rank(B2))。因为 rank 值可以直接从上一阶段获得,所以这个比较可以在常数时间内完成。
  3. 排序与更新:利用这个常数时间的比较器,我们对所有长度为 2^{k+1} 的子串(即后缀的前缀)进行排序(例如使用快速排序)。然后根据新的排序结果,为每个子串计算新的 rank 值。
  4. 迭代:重复步骤2-3,直到 k 使得 2^k >= n,或者直到所有子串的 rank 值都不同(意味着已完全排序)。

由于每次排序的对象是 n 个元素,比较成本为 O(1),若使用 O(n log n) 的排序算法,总复杂度为 O(n log n)。实际上,我们可以利用计数排序对整数二元组进行线性时间排序,从而将总复杂度优化到 O(n log n),且常数较小,在实践中非常高效。

示例:
以字符串 ABBABA$ 为例,演示倍增算法的前几步(为简洁,略过循环扩展):

  • 阶段0:排序单个字符,得到初始排名。
  • 阶段1:排序所有长度为2的子串,通过组合阶段0的两个排名作为二元组来比较。
  • 阶段2:排序所有长度为4的子串,通过组合阶段1的两个排名作为二元组来比较。
    ……
    最终得到后缀数组 P

最长公共前缀与查询优化

仅有后缀数组,对于“求任意两个后缀的最长公共前缀”这类查询,我们还需要额外的数据结构。

我们定义 LCP(i, j) 为后缀数组中第 i 个和第 j 个后缀的最长公共前缀长度。

关键观察: 后缀数组中两个后缀的 LCP,等于它们之间所有相邻后缀的 LCP 的最小值。即:
LCP(i, j) = min(LCP(i, i+1), LCP(i+1, i+2), ..., LCP(j-1, j))

因此,如果我们能快速计算任意区间的最小值,就能快速回答 LCP 查询。这可以通过预处理一个 RMQ 数据结构(如稀疏表、线段树)在 O(1)O(log n) 时间内完成。

所以,问题的核心转化为:如何高效计算相邻后缀LCP 数组 L,其中 L[i] = LCP(P[i], P[i+1])

Kasai 算法

Kasai 算法可以在 O(n) 时间内计算出 L 数组。其思想是巧妙地利用已经计算出的 LCP 值。

算法按后缀在原字符串中的起始位置0n-1 的顺序进行计算(即从最长的后缀开始)。设当前计算的是以 i 开头的后缀与其在后缀数组中的前一个后缀的 LCP,长度为 k。那么,当我们接下来计算以 i+1 开头的后缀时,我们知道它至少与 i 的后缀的前一个后缀的对应后缀有 k-1 的共同前缀。因此,我们可以从第 k 个字符开始比较,而不是从头开始。

算法流程:

  1. 计算一个辅助数组 pos,其中 pos[P[i]] = i,表示后缀起始位置在后缀数组中的排名。
  2. 初始化 k = 0
  3. 遍历原字符串下标 i0n-1
    • 如果 pos[i] 是后缀数组的最后一个位置,设 k=0 并继续。
    • 否则,找到后缀数组中 pos[i] 的下一个后缀,其起始位置为 j = P[pos[i] + 1]
    • 比较后缀 i 和后缀 j,从第 k 个字符开始(初始 k=0 即从头开始),直到字符不同,得到新的 k
    • L[pos[i]] = k
    • 如果 k > 0,则 k = k - 1(为下一个 i+1 的计算做准备)。

时间复杂度分析: k 在整个过程中最多减少 n 次(每次循环最多减1),而 k 的增加(在字符比较时)总共也不会超过 n 次(因为每次比较成功都会使 k 增加,且 k 不会超过 n)。因此总复杂度是线性的 O(n)

有了 L 数组和 RMQ 数据结构,我们就能在常数或对数时间内回答任意两个后缀的 LCP 查询,从而极大地扩展了后缀数组的应用能力。

总结

本节课我们一起学习了后缀数组这一重要的字符串数据结构。

  • 我们首先了解了后缀数组的定义和作用,它是对字符串所有后缀排序后得到的索引数组,是处理子串问题的有力工具。
  • 接着,我们探讨了构建后缀数组的朴素方法和基于哈希的优化方法。
  • 然后,我们重点讲解了高效的倍增算法,它通过分阶段比较固定长度的前缀,以 O(n log n) 的时间复杂度构建后缀数组。
  • 最后,为了支持更复杂的查询(如求任意后缀的最长公共前缀),我们引入了 LCP 数组的概念,并介绍了能在 O(n) 时间内计算该数组的 Kasai 算法,结合区间最小值查询,可以快速回答 LCP 查询。

后缀数组及其相关扩展是解决大量字符串匹配、重复子串查找、不同子串计数等问题的基石,理解和掌握它对于算法学习至关重要。

045:后缀树与Ukkonen算法 🎼

在本节课中,我们将学习一种非常强大的字符串数据结构——后缀树。后缀树包含了给定字符串的所有子串信息,可用于解决多种字符串问题,例如子串搜索、计算不同子串数量以及寻找最长公共子串等。我们将重点介绍如何在线性时间内构建后缀树,即Ukkonen算法。

什么是后缀树? 🌳

后缀树本质上是一棵包含给定字符串所有后缀的字典树。

我们以一个字符串为例,例如 ABBAABBA。首先,我们构建一棵包含该字符串所有后缀的字典树。每个后缀都作为一条从根节点到叶子节点的路径被插入树中。

例如,字符串 ABBAABBA 的后缀包括:

  • ABBAABBA
  • BBAABBA
  • BAABBA
  • AABBA
  • ABBA
  • BBA
  • BA
  • A
  • (以及空后缀 ε

将所有这些后缀插入一棵字典树,就得到了初始的后缀树。

压缩后缀树以节省空间 📦

上一节我们介绍了后缀树的基本概念。然而,直接存储这样一棵包含所有后缀的字典树需要 O(n²) 的内存,因为总共有 n 个后缀,每个后缀的长度最多为 n。本节中,我们将通过压缩来使树的大小变为线性。

我们通过压缩那些只有一个子节点的路径来实现。具体规则如下:

  • 如果一个节点有至少两个子节点,我们保留它。
  • 如果一个节点只有一个子节点,我们将其压缩。这意味着我们将该节点与其子节点之间的路径(可能包含多个字符)合并为一条边,边上标记着合并后的子串。

此外,为了简化处理,我们会在原字符串的末尾添加一个特殊的终止字符(例如 $),这个字符不出现在原字符串中。这样做可以确保每个后缀都对应一个叶子节点,使得树的结构更加规整。

压缩后,树中只包含两种节点:

  1. 内部节点:拥有至少两个子节点的节点。
  2. 叶子节点:代表某个后缀结束的节点。

由于叶子节点只有 n 个(每个后缀一个),而每个内部节点都会产生分支,所以内部节点的数量也是 O(n) 的。因此,整棵树的节点总数是线性的。

接下来,我们还需要压缩边上存储的字符串。每条边原本可能标记着一个长字符串。由于这些字符串都是原字符串的子串,我们可以用两个索引 [l, r] 来表示它,指向原字符串中对应的子串 S[l:r]。这样,存储每条边只需要常数空间。

经过以上压缩,我们得到了一棵节点数和边数均为 O(n) 的后缀树,这为我们在线性时间内构建它提供了可能。

后缀链接 🔗

在开始构建算法之前,我们需要引入一个关键概念:后缀链接。后缀链接是后缀树高效构建的核心。

对于后缀树中的每个内部节点 v(代表某个子串 s),我们定义它的后缀链接指向另一个节点。该节点代表的子串是 s最长后缀,并且这个后缀也出现在树中(即也是原字符串的一个子串)。

在后缀树中有一个重要性质:由于树包含了所有子串,节点 v(代表子串 s)的后缀链接指向的节点,代表的子串恰好是 s 去掉第一个字符后得到的子串。例如,如果节点 v 代表 "ABBA",那么它的后缀链接将指向代表 "BBA" 的节点。

另一个关键性质是:如果一个节点是内部节点(有至少两个子节点),那么它的后缀链接所指向的节点也一定是内部节点。这个性质保证了后缀链接总是在内部节点之间跳跃,这对于算法分析至关重要。

Ukkonen算法概述 🚀

前面我们准备好了后缀树的结构和后缀链接的概念。现在,我们将看到Ukkonen算法如何利用这些概念,以在线性时间内逐步构建后缀树。

算法的核心思想是在线构建。我们从一棵只包含根节点的空树开始,然后从左到右依次将原字符串 S 的每个字符添加到树中。假设我们已经构建好了字符串 S[0..i-1] 的后缀树,现在要添加字符 S[i] = c

我们需要将所有后缀 S[j..i-1] (j = 0..i-1) 都扩展一个字符 c。Ukkonen算法的高明之处在于,它并不显式地处理每一个后缀,而是通过维护一个“当前扩展点”来高效地完成所有扩展。

算法将扩展过程分为三种情况。假设我们正在处理后缀 S[j..i-1]

  1. 情况1(叶节点扩展):如果后缀 S[j..i-1] 结束于一个叶子节点,那么我们只需要将该叶子节点对应的边延长一个字符 c 即可。实际上,我们可以一次性将这条边延长到字符串末尾,从而在后续步骤中跳过所有叶节点的扩展。
  2. 情况2(创建新分支):如果后缀 S[j..i-1] 结束于某个节点或边的中间,并且从该点出发没有以字符 c 开头的边,那么我们需要创建一条新的边(和一个新的叶子节点)来代表字符 c
  3. 情况3(已存在路径):如果后缀 S[j..i-1] 结束于某个点,并且从该点出发存在以字符 c 开头的边,那么我们不需要做任何操作,只需将“当前扩展点”移动到这条边上下一个位置即可。此时,对于所有更短的后缀,情况3也会成立,因此我们可以停止本轮(第 i 个字符)的扩展。

算法维护一个“当前扩展点”,它代表最长的、不是结束于叶子节点的后缀的位置。在添加字符 c 时,我们从“当前扩展点”开始,执行情况2或3的操作,然后通过后缀链接跳转到下一个需要检查的后缀位置,重复此过程,直到遇到情况3或回到根节点。

算法核心:后缀链接的使用与均摊分析 ⚖️

上一节概述了算法的流程,其中最关键且复杂的部分是如何使用后缀链接进行跳转,以及为什么整个算法是线性的。本节我们来详细分析这一点。

当我们处于“当前扩展点”(可能是一个内部节点,也可能是一条边的中间位置),并需要沿着后缀链接跳转时,如果扩展点在一个节点上,跳转很简单。但如果扩展点在一条边的中间(我们称这个点代表子串 β,其父节点是 u),情况就复杂了,因为我们没有为边中间的点定义后缀链接。

此时,跳转规则如下:

  1. 首先,移动到父节点 u
  2. 然后,使用节点 u 的后缀链接,跳转到另一个节点 vv 代表的子串是 u 去掉第一个字符)。
  3. 最后,从节点 v 出发,沿着原本边上的子串 β 走下去,到达新的位置。这个新位置就是原扩展点 β 的后缀链接应该指向的位置。

这个过程可能涉及沿着多条边向下走,因为 β 可能跨越多个节点。这个跳转操作本身可能不是常数时间的

那么,如何保证整个算法的线性复杂度呢?答案是均摊分析

我们定义一个势能函数:Φ = -当前扩展点的深度。这里深度指的是从根节点到当前扩展点所经过的边数。

  • 当沿着边向下移动(扩展)时:每次操作是常数时间,但深度增加,所以势能 Φ 减少(或不变)。均摊代价是常数。
  • 当沿着后缀链接跳转时:设我们进行了 1 + k 步操作(1步到父节点,k步向下走)。关键点在于,跳转后新位置的深度最多比原位置的深度减少1。这是因为后缀链接跳转的本质是去掉字符串的第一个字符,而在后缀树中,这种操作不会导致深度大幅减少。
    • 因此,势能 Φ 最多增加 1(因为深度减少了)。
    • 而向下走的 k 步会使势能减少 k
    • 所以,整个跳转操作的均摊代价约为 (1+k) + (1 - k) = 2,是常数。

由于每个字符被添加时,我们只进行常数次(均摊意义上)的跳转和扩展操作,因此构建整棵后缀树的总时间复杂度是 O(n)

算法步骤与演示 📝

现在,让我们将理论付诸实践,一步步地演示Ukkonen算法。我们以字符串 "ABBA"(加上终止符 $,即 "ABBA$")为例。

我们从仅包含根节点的树开始。算法维护一个当前扩展点,初始在根节点。变量当前后缀长度等概念在完整实现中需要,但为了简化演示,我们关注核心步骤。

添加字符 ‘A’ (i=1):

  • 从根节点(当前扩展点)开始。没有以 ‘A’ 开头的边(情况2)。
  • 创建一条从根节点出发的新边,标记为 A...$(我们一次性将叶子边延伸到字符串末尾)。这创建了一个叶子节点,代表后缀 "A$"
  • 当前扩展点仍在根节点。由于在根节点没有其他以 ‘A’ 开头的边需要处理,并且我们处于情况2后创建了叶子,按照算法,我们通过后缀链接跳转?对于根节点,后缀链接是它自身。但此时规则是:如果从根节点扩展后,下一个要检查的后缀是空后缀,它也在根节点结束,并且从根节点出发有 ‘A’ 边(情况3)。因此,第一轮扩展结束。当前扩展点 保持在根节点。

添加字符 ‘B’ (i=2):

  • 现在字符串是 "AB"。我们需要扩展后缀 "A"""
  • 从根节点(当前扩展点)开始。检查代表后缀 "A" 的路径。"A" 目前结束于上一步创建的叶子节点。根据情况1(叶节点),我们不需要做任何操作,因为叶节点边会自动延伸到末尾(它已经是 A...$)。
  • 通过后缀链接处理下一个后缀 ""(空后缀,即根节点)。从根节点检查字符 ‘B’。没有以 ‘B’ 开头的边(情况2)。
  • 创建一条从根节点出发的新边,标记为 B...$,代表后缀 "B$"。这创建了第二个叶子节点。
  • 再次从根节点检查,所有后缀处理完毕。当前扩展点 保持在根节点。

添加字符 ‘B’ (i=3):

  • 字符串现在是 "ABB"。需要处理后缀 "BB", "B", ""
  • 从根节点开始,找后缀 "BB"。从根节点走 ‘B’ 边,到达代表 "B" 的叶子节点?这里需要注意,上一步 "B" 是一个叶子边 B...$"BB" 需要从 "B" 后面再走 ‘B’。但叶子边 B...$ 的下一个字符是 $(根据字符串,"B$"),不是 ‘B’。所以,对于后缀 "BB",我们实际上是从根节点走 ‘B’ 边,但发现边上期望的字符是 $,而我们需要的是 ‘B’,因此是情况2。
  • 我们需要在 "B...$" 这条边上进行分裂。在 ‘B’ 字符之后的位置创建一个新的内部节点(假设叫 u)。原边 B...$ 被分裂成:根节点到 u 的边标记为 B,以及 u 到原叶子的边标记为剩余部分 ...$
  • 现在,我们从新节点 u 出发,创建一条以 ‘B’ 开头的新边,指向一个新的叶子节点,代表后缀 "BB$"
  • 关键步骤:现在我们需要为新建的内部节点 u 设置后缀链接。根据规则,我们需要找到 u 的后缀链接指向哪里。u 代表子串 "B"。它的后缀链接应指向代表 ""(空串)的节点,即根节点。所以设置 u.suffix_link = root
  • 接下来,通过后缀链接处理下一个后缀 "B"(即 u 的后缀链接指向的根节点)。从根节点检查字符 ‘B’。现在根节点有以 ‘B’ 开头的边(指向节点 u)。这是情况3。我们只需将当前扩展点移动到这条边上(即节点 u 的位置)。由于遇到情况3,后续更短的后缀("")也必然满足情况3,所以本轮扩展结束。当前扩展点 更新为节点 u

添加字符 ‘A’ (i=4):

  • 字符串是 "ABBA"。需要处理后缀 "BBA", "BA", "A", ""
  • 从当前扩展点 u(代表 "B")开始。检查字符 ‘A’。从 u 出发没有以 ‘A’ 开头的边(情况2)。
  • 从节点 u 创建一条以 ‘A’ 开头的新边,指向一个新的叶子节点,代表后缀 "BA$"
  • 通过后缀链接处理下一个后缀 "BA"(去掉 u 的第一个字符?等等,u 代表 "B",它的后缀 "" 在根节点。我们需要处理的后缀是 "BA",它等于 u 的后缀 "" 加上边上的 "A"?这里更准确的是:我们刚刚在 u 扩展了 ‘A’,接下来应该看 u 的后缀链接指向的节点(根节点),然后从根节点尝试走 "A" 这条路径(即代表 "A" 的边)。让我们遵循算法通用步骤:
    1. 记录当前边上的剩余字符串(这里是从 u 扩展时,我们位于节点上,没有边上剩余字符串)。
    2. 移动到父节点(u 的父节点是根节点)。
    3. 通过后缀链接跳转(根节点的后缀链接是自身)。
    4. 然后,我们需要从根节点向下走,匹配之前记录的路径(这里就是 "A")。
  • 从根节点走 ‘A’ 边,到达代表 "A" 的叶子节点。检查下一个字符(我们需要添加 ‘A’),但叶子边 A...$ 的下一个字符是 $,不是 ‘A’。所以是情况2。
  • 分裂 "A...$" 边,创建一个新的内部节点 v(代表 "A")。设置 v 的后缀链接。v 代表 "A",它的后缀链接应指向根节点(代表 "")。所以 v.suffix_link = root
  • 从节点 v 创建一条以 ‘A’ 开头的新边,指向一个新的叶子节点,代表后缀 "A$"(注意,这是另一个 "A$",是后缀 "BA" 扩展后的 "BAA$" 吗?不对,字符串是 "ABBA$",后缀 "A""A$")。这里有点混乱,实际上我们正在处理的是后缀 "BA" 扩展 ‘A’ 得到 "BAA",但 "BAA" 不是 "ABBA$" 的子串。让我们重新审视。
    • 更严谨地,在 i=4 时,字符是 ‘A’。我们从代表 "B" 的节点 u 开始。
    • u 创建了 "A$" 边,代表后缀 "BA$"
    • 然后,我们该处理下一个后缀。u 的后缀链接指向根节点。我们需要从根节点匹配的路径是 ""(因为 u 是单个字符节点,从父节点根节点通过后缀链接跳转后,要走的路径是 u 节点之后的路径,即我们刚添加的 "A"?不完全是)。算法中,当我们从节点 u 完成扩展后,下一个要检查的点是 u.suffix_link(根节点)。然后,我们需要从根节点走到一个位置,使得该位置代表的子串是 u 去掉第一个字符后剩下的部分再加上刚才扩展的字符?这比较复杂。
    • 简化理解:实际上,在Ukkonen算法完整的实现中,我们会维护一个剩余后缀数活动点,通过后缀链接快速定位下一个需要扩展的位置。演示的细节非常繁琐。

这个逐步演示展示了分裂节点、创建新边、设置后缀链接的核心操作。尽管手动跟踪所有细节很复杂,但希望你能体会到算法是如何通过后缀链接在树中“爬行”,并逐步构建出完整后缀树的。

后缀树的应用 💡

学习构建后缀树之后,你可能会问:它有什么用?本节我们将探讨后缀树的一些典型应用场景。

后缀树之所以强大,是因为它将字符串的所有子串信息高效地组织在了一棵树结构中。因此,许多关于子串的问题都可以通过在后缀树上进行遍历或计算来解决。

以下是一些常见的应用方向:

  • 精确子串匹配:给定一个文本 T,构建其后缀树。之后,对于任何查询模式串 P,要判断 P 是否在 T 中出现,只需从根节点开始,沿着 P 的字符在后缀树中向下走。如果能走完整个 P,则 PT 的子串。这个过程的时间复杂度是 O(|P|),与文本长度无关。
  • 统计不同子串:计算一个字符串中不同子串的数量。在后缀树中,每个子串都对应从根节点开始的唯一一条路径。因此,不同子串的总数等于所有边的长度之和。因为边是用索引表示的,所以可以在 O(n) 时间内计算出来。
  • 寻找最长重复子串:找到在字符串中出现至少两次的最长子串。这对应后缀树中最深的内部节点(非叶子节点),因为内部节点代表被至少两个不同后缀共享的前缀。
  • 最长公共子串:给定两个字符串 ST,找到最长的同时出现在两者中的子串。一个经典方法是:
    1. 构造字符串 S#T$ 的后缀树(#$ 是特殊的终止符)。
    2. 标记每个叶子节点,记录它属于 S 的后缀还是 T 的后缀。
    3. 在后缀树上做一次DFS,对于每个内部节点,检查其子树中是否同时包含来自 ST 的叶子。
    4. 满足上述条件的内部节点中,深度最大的节点所代表的子串就是最长公共子串。
  • 多模式匹配:类似于AC自动机,后缀树也可以用于多模式匹配,尤其当模式集固定而文本经常变化时,有独特优势。

这些只是后缀树应用的一部分。由于其结构的丰富性,它可以解决大量复杂的字符串问题。

总结 📚

本节课中,我们一起学习了字符串处理中的终极数据结构之一——后缀树。

  • 我们首先了解了后缀树的基本定义:一棵包含字符串所有后缀的压缩字典树,它隐式地包含了所有子串信息。
  • 为了将空间复杂度降至线性,我们引入了路径压缩和边标记索引化。
  • 我们深入探讨了后缀链接这一关键概念,它是Ukkonen算法高效运行的引擎。
  • 我们详细阐述了Ukkonen算法的在线构建思想,它将构建过程分解为字符的逐步添加,并通过维护活动点、利用后缀链接和三种扩展情况,在 O(n) 的均摊时间内完成构建。
  • 最后,我们列举了后缀树在子串搜索、统计、公共子串查找等多个方面的强大应用。

后缀树的构建算法(Ukkonen算法)理解起来有一定挑战,但一旦掌握,你就会拥有一件处理字符串问题的利器。虽然在实际编程竞赛中,由于实现复杂度,后缀数组可能更常被使用,但后缀树提供的直观树形视角和理论价值是不可替代的。鼓励你尝试实现它,这将极大地加深你对字符串算法的理解。

046:Y-Fast Trie 🚀

在本节课中,我们将要学习一种专门为整数设计的数据结构——Y-Fast Trie。我们将从基础的计算模型开始,逐步构建X-Fast Trie,并最终通过分组技巧将其优化为Y-Fast Trie,实现高效的查找、插入和删除操作。请注意,本教程将删除所有语气词,并严格遵循原文每一句话的含义。

概述

通常,我们使用二叉搜索树、线段树等数据结构来处理抽象对象,其时间复杂度通常为 O(log n)。然而,当处理的对象是整数时,我们可以利用整数的特性(如算术运算、位运算)和特定的计算模型,设计出理论上更快的数据结构。本节课将介绍X-Fast Trie和Y-Fast Trie,它们能在特定的计算模型下,将某些操作的时间复杂度降低到 O(log w),其中 w 是整数的位数(字长)。

计算模型

在深入数据结构之前,我们需要明确所采用的计算模型。我们工作在RAM(随机存取存储器)模型下,并假设以下操作都能在常数时间内完成:

  • 算术运算:加法、减法、乘法、整数除法。
  • 位运算:按位与 (&)、按位或 (|)、按位异或 (^) 等。
  • 位移:左移 (<<)、右移 (>>)。
  • 数组索引:通过下标访问数组元素。

我们引入一个关键参数 w,它代表模型中整数的位数(字长)。算法的时间复杂度将同时是输入规模 n 和字长 w 的函数。

核心概念:我们的计算模型允许在常数时间内对 w 位的整数执行算术和位运算。

动机与目标结构

对于抽象的二叉搜索树,比较两个对象需要 O(log n) 次操作。但对于整数,我们可以做得更好。

我们将讨论两种数据结构:

  1. X-Fast Trie:为后续结构做铺垫。
  2. Y-Fast Trie:本节课的重点,能实现所有操作(插入、删除、查找、前驱/后继)的 摊销 O(log w) 时间复杂度。

有趣的是,还有一种称为 Fusion Tree 的数据结构,其时间复杂度为 O(log_w n)。当字长 w 较大时,Fusion Tree 更优;当 w 较小时,Y-Fast Trie 更优。在实际应用中,可以根据 w 和 n 的关系选择更合适的数据结构。

X-Fast Trie 的核心思想

X-Fast Trie 的本质是一棵 字典树 (Trie),它将每个整数视为一个长度为 w 的二进制字符串(从最高位到最低位)。

结构构建

假设 w=4,我们有一组整数:0010 (2), 0110 (6), 1001 (9), 1011 (11), 1100 (12)。为它们构建的 Trie 如下所示(- 表示空节点,实际不存储):

        根
       /  \
      0    1
     /    / \
    0    0   1
   /    /   / \
  1    0   0   1
 /    /   /   / \
0-   1-  1-  0-  1-
(2) (6) (9) (11)(12)

每个叶子节点对应一个存在的整数。每个内部节点代表一个二进制前缀。

支持的操作与初步方案

我们需要支持的操作包括:插入 (insert)、删除 (delete)、查找 (find)、以及寻找下界 (lower_bound,即不小于给定值的最小元素)。

首先,我们尝试实现 lower_bound。给定一个查询值 x,我们从根开始,沿着 x 的二进制位向下走。当走到某个节点,需要转向的子树(根据 x 的下一位是0或1)不存在时,分两种情况:

  1. 需要走向左子树 (0) 但不存在:那么 x 的后继元素就在当前节点的右子树中,并且是右子树中的最小元素
  2. 需要走向右子树 (1) 但不存在:那么 x 的后继元素是当前节点的左子树中的最大元素下一个叶子节点

为了快速找到子树中的最值,我们为每个节点预计算并存储该子树中的最小叶子最大叶子的指针。为了能在情况2中快速找到“下一个叶子节点”,我们将所有叶子节点用一个双向链表连接起来。

以下是实现这些功能所需的步骤:

  • 构建整数二进制串的 Trie。
  • 为每个节点存储其子树的最小和最大叶子指针。
  • 将所有叶子节点用双向链表串联。

初步方案的分析

基于上述结构,操作的时间复杂度如下:

  • lower_bound:需要从根走到叶子,路径长度为 w,故时间复杂度为 O(w)
  • insert / delete:同样需要遍历/修改一条长度为 w 的路径,并更新相关节点的最值信息,时间复杂度为 O(w)
  • 空间复杂度:最多有 O(nw) 个节点(最坏情况每个整数的每一位都创建新节点),故为 O(nw)

这并不比 O(log n) 的二叉搜索树好,尤其是当 w 很大时。我们需要优化。

关键优化:快速定位“最深存在前缀”

lower_bound 操作最耗时的部分是沿着路径向下走。观察发现,我们最终需要的节点是 x 的二进制串的最长前缀,且该前缀对应的节点存在于 Trie 中。我们称这个节点为 node

我们可以通过二分搜索来快速找到这个前缀的长度,而不是逐位遍历。

算法步骤

  1. 在范围 [0, w] 内二分搜索一个长度 len
  2. 对于中间值 mid,取出 x 的前 mid 位(可通过位运算实现,例如 x >> (w - mid))。
  3. 检查这个前缀对应的节点是否存在
  4. 根据存在与否调整二分搜索的边界。

问题转化为:如何常数时间内检查一个前缀节点是否存在?答案是使用哈希表

  • 我们将每个存在于 Trie 中的节点(用其对应的整数值代表,即它的前缀)存储在一个哈希表中。
  • 检查时,只需计算前缀值并在哈希表中查找。

核心概念:通过 二分搜索 结合 哈希表 查询,我们能在 O(log w) 时间内找到最深的存在前缀节点 node

找到 node 后,lower_bound 的剩余步骤(判断走向、获取子树最值、通过链表找到下一个叶子)都只需常数时间。因此,优化后的 lower_bound 时间复杂度为 O(log w)

这个优化后的结构就是 X-Fast Trie。它能在 O(log w) 时间内回答 lower_bound 查询。然而,插入和删除操作仍然需要 O(w) 时间,因为可能需要在 Trie 中创建或删除一整条路径的节点。空间复杂度仍是 O(nw)。

从 X-Fast Trie 到 Y-Fast Trie

为了将插入和删除操作也优化到 O(log w)(摊销复杂度),并降低空间复杂度,我们引入分组思想。这个技巧类似于 B 树和之前课程中讨论的“顺序维护数据结构”。

分组结构

  1. 将元素分组:将所有 n 个整数分成若干组(块)。每个块的大小控制在 [w/4, w] 范围内。每个块内的元素在值域上是连续的一段。
  2. 代表元:对于每个块,我们取出一个代表元(例如块内的最大值,或任意一个元素)。将所有块的代表元放入一个 X-Fast Trie 中。这个 X-Fast Trie 被称为摘要结构
  3. 块内结构:每个块内部使用一个平衡二叉搜索树(如红黑树、AVL树)来维护其元素。

操作实现

  • lower_bound(x):

    1. 在摘要 X-Fast Trie 中查找 xlower_bound,找到对应的代表元,从而定位到 x 可能所在的块。
    2. 在该块的二叉搜索树中查找 xlower_bound
    3. 如果在该块中未找到(即 x 大于该块所有元素),则通过块之间的链表,找到下一个块,并取其最小元素。
      时间复杂度:摘要查找 O(log w) + 块内查找 O(log w) = O(log w)
  • insert(x):

    1. 使用 lower_bound 在摘要中定位 x 应属的块。
    2. 将 x 插入该块的二叉搜索树中,耗时 O(log w)。
    3. 维护块大小:插入后,如果块大小超过 w,则需要进行分裂
      • 将当前块近似平均地分裂成两个块。
      • 更新两个新块的二叉搜索树。
      • 从摘要中删除旧块的代表元,并添加两个新块的代表元。
      • 更新块之间的链表。
        分裂操作本身需要 O(w) 时间(因为需要处理块内所有元素)。但是,一个块只有在插入至少 Ω(w) 个元素后才会再次需要分裂。因此,分裂的摊销时间复杂度是 O(1)。
        综合来看,insert摊销时间复杂度为 O(log w)。
  • delete(x):

    1. 定位 x 所在的块。
    2. 从该块的二叉搜索树中删除 x,耗时 O(log w)。
    3. 维护块大小:删除后,如果块大小低于 w/4,则需要进行合并(或从邻居块调整元素)。
      • 如果相邻块足够大,可以从邻居块借调元素。
      • 否则,将当前块与一个相邻块合并。
      • 更新摘要和链表。
        与插入类似,合并/调整操作的摊销时间复杂度也是 O(1)。
        因此,delete摊销时间复杂度也为 O(log w)。

Y-Fast Trie 分析

  • 时间复杂度:所有核心操作 (insert, delete, find, lower_bound) 的摊销时间复杂度均为 O(log w)
  • 空间复杂度
    • 摘要 X-Fast Trie:存储 O(n/w) 个代表元,每个代表元在 Trie 中产生 O(w) 个节点?不对,优化后的 X-Fast Trie 主要存储哈希表,节点数与元素数成线性。摘要中有 O(n/w) 个代表元,因此摘要空间为 O(n/w)
    • 块内二叉搜索树:总共有 n 个元素,每个元素只在一个二叉搜索树中,总空间为 O(n)
    • 总空间复杂度为 O(n)

理论意义与实际应用

Y-Fast Trie 展示了当数据为整数时,利用位运算和特定计算模型可以突破基于比较的二叉搜索树 O(log n) 的下界。其性能取决于字长 w。当 w 很小(例如 w = O(log n))时,O(log w) = O(log log n),这比 O(log n) 有显著提升。

然而,该结构在实践中的应用有限,原因如下:

  1. 常数因子较大(哈希表、多级结构)。
  2. 依赖于“所有整数运算在常数时间内完成”的假设,这对于乘法等操作在现代 CPU 上并非严格成立。
  3. 通常实际问题中 n >> w,简单的 O(log n) 结构可能更快。

尽管如此,Y-Fast Trie 及其相关思想(如 Fusion Tree)是理论计算机科学中关于字级并行整数数据结构的优美范例,揭示了算法设计与计算模型之间的深刻联系。

总结

本节课我们一起学习了 Y-Fast Trie 的设计与实现。我们从计算模型和 X-Fast Trie 出发,通过“二分搜索+哈希表”将查询优化到 O(log w)。为了进一步优化更新操作和空间,我们引入了分组思想,将元素分成大小为 Θ(w) 的块,用 X-Fast Trie 管理块的代表元,用平衡二叉搜索树管理块内元素,并通过类似 B 树的分裂合并策略来维护块大小,最终得到了所有操作摊销 O(log w) 时间复杂度和线性空间复杂度的 Y-Fast Trie。这个结构是理论上的一个亮点,体现了利用整数特性设计高效数据结构的可能性。

047:Fusion Tree

概述

在本节课中,我们将学习一种名为 Fusion Tree 的整数数据结构。它能够执行与二叉搜索树相同的操作,但其时间复杂度与整数位宽 W 相关,并且随着 W 的增大,其性能反而可能提升。我们将探讨其核心思想、工作原理,并分析其时间复杂度。


回顾与引入

上一节我们讨论了 Van Emde Boas 树X-Fast Trie,它们处理整数集合操作的时间复杂度为 O(log W),其中 W 是整数的位宽。这意味着当整数位宽 W 较小时,这些结构比普通的 O(log N) 二叉搜索树更高效。

本节中,我们将介绍 Fusion Tree。它同样支持插入、删除和查找(如下界查询 lower_bound)等操作,但其时间复杂度接近 O(log_W N)。有趣的是,当整数位宽 W 增大时,Fusion Tree 的性能反而可能变得更好,这与之前的结构相反。


Fusion Tree 的核心思想

Fusion Tree 的基本想法是构建一棵 多叉搜索树,而不是二叉搜索树。树中的每个节点拥有大约 B 个子节点,B 是一个我们稍后会确定的分支因子

树的高度与分支因子

如果每个节点有 B 个子节点,那么树的高度大约是 O(log_B N)。我们希望这个高度能与 O(log_W N) 同阶。因此,我们需要选择一个合适的 B 值。

通过分析可知,如果我们选择 B 约为 W^(1/5),那么树的高度 log_B N 将近似为 (log N) / (log W^(1/5)) = 5 * (log N) / (log W),这确实是 O(log_W N)

核心公式:选择分支因子 B ≈ W^(1/5),则树高 h = O(log_B N) = O(log_W N)

节点内的操作

在一个拥有 B 个子节点的节点中,我们存储了 B-1关键值keys),用于将值域划分为 B 个区间。给定一个查询值 x,我们需要快速确定 x 属于哪个子区间,即需要在这个节点内的 B-1 个关键值中计算 x下界lower_bound)。

如果能在常数时间内完成节点内的下界查询,那么从根到叶子的整条路径查询时间就是树高,即 O(log_W N)

因此,Fusion Tree 的核心问题简化为:

如何在常数时间内,在一个大小为 B 的整数集合中,对给定的整数 x 执行下界查询?


解决核心问题:常数时间节点查询

我们有一个节点,其中包含 B 个整数(每个整数有 W 位)。我们需要对输入的 x 进行下界查询。

步骤1:提取“重要位”并构建 Sketch

首先,我们为节点内的所有整数构建一个压缩的二叉 Trie。在这个 Trie 中,只有那些在分叉点(即节点有两个子节点)上被检查的位才是重要的。这样的“重要位”最多有 B 个。

对于节点内的每个整数 a_i,我们创建一个 Sketch a_i',它只保留这些“重要位”,而忽略其他所有位。由于重要位是区分这些整数的关键,因此这些 Sketch 保持了原整数的顺序。

核心操作:对于每个整数,提取其重要位,生成一个短得多的 Sketch。Sketch 的长度约为 B 位。

步骤2:对 Sketch 进行查询

同样,我们对查询值 x 也提取其重要位,生成 Sketch x'

现在,问题转化为:在 B 个短 Sketch(每个长度约 B 位)中,查找 x' 的下界。由于 B 很小(B ≈ W^(1/5)),我们可以将所有 B 个 Sketch 紧密地打包进一个机器字word)中。

一旦所有 Sketch 都在一个字里,我们就可以利用上一讲学过的技巧,通过一系列常数时间的算术和位操作(如乘法、减法、掩码),在这个“打包”的数组中进行下界查询。这能在常数时间内完成。

步骤3:从 Sketch 结果还原到原整数

在 Sketch 的集合中找到 x' 的下界后,我们得到了对应的原始整数 a_k。接着,我们计算原始整数 xa_k最长公共前缀

计算两个整数的最长公共前缀可以通过计算它们的异或(xor),然后找到异或结果中最高位的 1 的位置来实现。我们之前也学过如何在常数时间内找到字中的最高位。

这个最长公共前缀的长度,正好对应了在 Trie 中从根开始,x 与集合中某个元素路径一致的最远位置。知道了这个位置,我们就能确定下一步应该走向哪个子节点,从而完成节点内的导航。

核心算法

  1. 为节点内所有整数和查询值 x 计算 Sketch(仅含重要位)。
  2. 将所有 Sketch 打包,在常数时间内找到 x' 的 Sketch 在其中的下界。
  3. 根据结果找到对应的原始整数 a_k,计算 xa_k 的最长公共前缀。
  4. 利用最长公共前缀的信息,决定进入哪个子节点。

关键技术:如何构建 Sketch

剩下的一个关键问题是:如何高效地从原始 W 位整数中提取分散的 B 个重要位,并将它们压缩到一个短的 Sketch 中?

使用乘法进行位收集

假设重要位的位置是 p1, p2, ..., pB。我们希望生成一个新的整数,使得这 B 个位连续地出现在一个短窗口内(例如从位置 LL+B^4)。这可以通过与一个特殊的掩码 M 相乘来实现。

掩码 M 在位置 m1, m2, ..., mB 上设置了 1。当我们将整数 aM 相乘时,a 的每一位 a[pi] 会被复制并加到结果的位置 pi + mj 上。通过精心选择 m1...mB 的值,我们可以确保:

  1. 每个重要位 a[pi] 的各个副本最终位于不同的、预先设计好的输出位置。
  2. 所有这些输出位置都落在我们期望的短窗口 [L, L+B^4] 内。

选择这样的 m1...mB 是可能的,因为可用的位置数量(B^4)远大于可能产生冲突的位置数量(约 B^3)。我们可以通过一个预处理步骤(即使较慢)为每个节点计算出合适的掩码 M

核心技巧:通过一次与特定掩码的乘法,将分散的重要位收集并对齐到一个连续的短区域内,然后通过掩码取出该区域,即得到 Sketch。


处理动态操作与最终复杂度

插入与删除

上述过程描述了在静态节点中的查询。为了支持动态插入和删除,我们需要将节点组织成类似 B树 的结构,保持每个节点的子节点数量在 [B/4, B] 之间。当节点过大时分裂,过小时合并。

在动态更新时,重建节点的 Sketch 和掩码可能需要 O(B^4) 时间。为了避免这成为瓶颈,我们可以将每个节点内的元素进一步分块(例如块大小为 B^4)。在块内使用简单的二叉搜索树。这样,更新操作的时间复杂度就变为 O(log B)

融合两种结构的优势

现在,我们有两种数据结构:

  1. X-Fast Trie / vEB Tree:时间复杂度 O(log W)
  2. Fusion Tree:时间复杂度 O(log_B N + log B),其中 B ≈ W^(1/5),因此约为 O(log_W N + log W)

对于给定的数据规模 N 和位宽 W,我们可以选择更优的那个:

  • log W < sqrt(log N) 时,选择 X-Fast Trie,复杂度为 O(log W) < O(sqrt(log N))
  • log W > sqrt(log N) 时,选择 Fusion Tree。此时我们选择 B = 2^(sqrt(log N)/5),可以验证 B < W^(1/5) 且复杂度为 O(log_B N + log B) = O(sqrt(log N))

因此,无论 W 多大,我们都能构建一个整数搜索结构,其操作时间复杂度为 O(sqrt(log N))


总结

本节课我们一起学习了 Fusion Tree 数据结构。我们从构建多叉搜索树以降低树高出发,将问题核心归结为在节点内进行常数时间的下界查询。通过提取“重要位”生成 Sketch、利用机器字并行操作进行快速查询,以及使用巧妙的乘法技巧构建 Sketch,我们实现了这一目标。通过结合 X-Fast Trie 和 Fusion Tree,并根据 WN 的关系选择最优结构,我们最终得到了一个时间复杂度为 O(sqrt(log N)) 的整数集合查询算法,这打破了基于比较的 Ω(log N) 下界,展示了在允许整数单位常数时间运算的模型下所能实现的强大能力。

048:二分图最大匹配 🧩

在本节课中,我们将要学习二分图的最大匹配问题。我们将从匹配的基本定义开始,逐步介绍寻找最大匹配的算法,并探讨其理论基础和实际应用。


什么是匹配?🤔

匹配是图论中的一个基本概念。给定一个无向图,一个匹配是该图中一组边的集合,并且这组边中的任意两条边都不能共享同一个顶点。

换句话说,我们选择一些边,这些边的所有端点都必须是不同的。

例如,在下图中,我们可以选择边 (A, B) 和 (C, D)。这两条边没有共享的端点,因此它们构成了一个匹配。

我们在这门课程中要解决的问题是寻找最大匹配,即包含边数最多的匹配。我们希望选择尽可能多的边,同时确保没有两条边共享同一个端点。

例如,在上图中,我们可以选择三条边(例如边 (A, B), (C, D), (E, F)),从而形成一个大小为3的最大匹配。

如果一个匹配包含了图中的所有顶点(即匹配的大小为 n/2,其中 n 是顶点数),则称其为完美匹配


二分图与非二分图 📊

匹配问题有两种主要类型:一种针对二分图,另一种针对非二分图。

  • 二分图:图的顶点可以被划分为两个不相交的集合(左部和右部),并且所有的边都连接着左部的一个顶点和右部的一个顶点。
  • 非二分图:图的结构没有上述限制。

二分图和非二分图在匹配问题上存在根本性的差异,尤其是在对应的线性规划表述上。本节课我们只讨论二分图的最大匹配问题


增广路径算法核心思想 💡

所有我们将讨论的算法都遵循一个基本框架:从空匹配开始,然后通过寻找一种称为“增广路径”的结构,逐步增加匹配的大小。

什么是增广路径?

增广路径是一条路径,它满足以下条件:

  1. 路径的起点和终点都是当前匹配中的“自由顶点”(即未被任何匹配边覆盖的顶点)。
  2. 路径上的边在“非匹配边”和“匹配边”之间交替出现。

例如,在下图中,假设当前匹配包含两条边(红色)。路径 A -> 1 -> B -> 2 就是一条增广路径:A 和 2 是自由顶点,边 (A,1) 是非匹配边,(1,B) 是匹配边,(B,2) 是非匹配边。

如何利用增广路径?

当我们找到一条增广路径后,我们可以通过“翻转”路径上所有边的状态来增加匹配的大小。具体来说,将路径上所有的非匹配边变为匹配边,同时将原有的匹配边变为非匹配边。

在上面的例子中,翻转后,匹配边变为 (A,1) 和 (B,2),而 (1,B) 变为非匹配边。匹配的大小从 2 增加到了 3。

算法的整体流程如下:

  1. 从空匹配开始。
  2. 尝试寻找增广路径。
  3. 如果找到,则翻转路径上的边,增加匹配大小,然后回到步骤2。
  4. 如果找不到增广路径,则当前匹配就是最大匹配。

关键定理:增广路径定理 📜

这个算法的正确性基于一个重要的定理:

一个匹配是最大匹配,当且仅当图中不存在关于该匹配的增广路径。

证明思路:

  • 必要性(=>):如果匹配是最大的,那么显然无法通过增加边来扩大它,因此不存在增广路径(因为增广路径能扩大匹配)。
  • 充分性(<=):如果不存在增广路径,但当前匹配 M 不是最大的,那么存在一个更大的匹配 M_max。考虑两个匹配的边集对称差(即属于其中一个但不属于另一个的边)。这个新图的每个顶点度数最多为2(因为每个顶点最多属于 M 和 M_max 各一条边)。这样的图由环和链构成。由于 |M_max| > |M|,必然存在一条链,其两端的边都属于 M_max。这条链恰好就是关于匹配 M 的一条增广路径,与假设矛盾。因此,匹配 M 必须是最大的。

这个证明对于二分图和非二分图都成立。算法框架也适用于非二分图,区别仅在于寻找增广路径的难度。在二分图中,寻找增广路径要简单得多。


在二分图中寻找增广路径 🚀

在二分图中,增广路径具有固定的模式:它总是从一个左部的自由顶点开始,交替经过非匹配边(到右部)和匹配边(回到左部),最终结束于一个右部的自由顶点。

我们可以利用这个特性来简化搜索。具体方法是构建一个有向图:

  1. 对于所有非匹配边,将其方向定为从左部指向右部。
  2. 对于所有匹配边,将其方向定为从右部指向左部。

在这个有向图中,任何一条从一个左部自由顶点到一个右部自由顶点的路径,都对应原图中的一条增广路径。这样,我们就把寻找交替路径的问题,简化成了在有向图中寻找普通路径的问题。

为了处理多个自由顶点,我们可以引入一个虚拟源点 S 和一个虚拟汇点 T

  • S 连接到所有左部的自由顶点。
  • 将所有右部的自由顶点连接到 T

然后,我们只需要在有向图中寻找一条从 ST 的路径。如果存在这样的路径,那么去掉 ST 后,中间的部分就是一条增广路径。

寻找路径可以使用深度优先搜索(DFS)或广度优先搜索(BFS)。每次搜索的时间复杂度为 O(E),其中 E 是边数。由于最多需要寻找 O(V) 次增广路径(每次增加一条匹配边),所以朴素的匈牙利算法时间复杂度为 O(V * E)


算法实现与优化 🛠️

在实际编码中,我们可以实现一个更简洁的 DFS 函数,它从一个左部顶点 v 出发,尝试寻找增广路径。如果找到,则在递归返回的过程中直接更新匹配关系。

以下是算法核心的伪代码描述:

# 假设图是二分图,左部顶点编号为 0..nL-1,右部顶点编号为 0..nR-1
# matchR[r] 表示右部顶点 r 当前匹配的左部顶点,-1 表示自由顶点
# visited 用于DFS中标记访问状态,防止重复访问

def dfs(v):
    for u in graph[v]: # u 是右部顶点
        if not visited[u]:
            visited[u] = True
            # 如果 u 是自由顶点,或者从 u 的当前匹配点出发能找到增广路
            if matchR[u] == -1 or dfs(matchR[u]):
                matchR[u] = v # 更新匹配关系
                return True
    return False

# 主算法
max_matching = 0
matchR = [-1] * nR
for v in range(nL):
    visited = [False] * nR # 每次尝试前重置访问标记
    if dfs(v):
        max_matching += 1

一个重要优化:在上述循环中,如果一个左部顶点 v 的 DFS 失败了,那么在后续的循环中,我们不需要再从这个顶点出发进行搜索。因为它无法到达任何右部的自由顶点,这个状态在算法运行期间不会改变。这个优化被包含在上述伪代码的 visited 数组重置逻辑中,但更高效的实现可能需要持久化某些访问状态。


霍尔定理(Hall‘s Theorem)🎯

霍尔定理提供了一个判断二分图是否存在完美匹配的简洁条件。

定理内容:设二分图左部顶点集合为 L,右部为 R。对于 L 的任意一个子集 A,令 N(A) 为 A 中所有顶点的邻居集合(位于 R 中)。则该图存在一个覆盖所有 L 顶点的匹配(即 |匹配| = |L|),当且仅当对于 L 的每一个子集 A,都有 |N(A)| >= |A|。

简单理解:左部任意一组顶点,它们所连接的右部顶点数量必须不少于这组顶点本身的数量。否则,这组顶点中必然有人找不到匹配对象。

证明思路

  • 必要性:如果存在完美匹配,那么左部子集 A 中的每个顶点都匹配到右部不同的顶点,这些右部顶点都在 N(A) 中,所以 |N(A)| >= |A|。
  • 充分性:如果霍尔条件满足,那么在我们之前提到的匈牙利算法中,每次从自由左部顶点出发的 DFS 都一定能找到增广路径(否则会导出一个违反霍尔条件的左部顶点集合),因此最终能构造出完美匹配。

霍尔定理通常用于理论证明,或者在处理具有特殊结构的隐式二分图时,作为判断完美匹配存在性的工具。


最小顶点覆盖问题 🛡️

顶点覆盖是另一个经典问题:在一个无向图中,选择一个顶点集合,使得图中的每一条边都至少有一个端点在这个集合中。目标是使这个集合尽可能小。

在一般图中,寻找最小顶点覆盖是 NP 完全问题。然而,在二分图中,它可以高效解决。

柯尼希定理(König‘s Theorem)

柯尼希定理揭示了二分图中最大匹配和最小顶点覆盖之间的深刻联系:

在二分图中,最大匹配的大小等于最小顶点覆盖的大小。

这属于对偶优化问题。对于任何匹配 M 和任何顶点覆盖 C,显然有 |M| <= |C|(因为覆盖 M 中的所有边需要至少 |M| 个顶点)。柯尼希定理指出,在二分图中,这个上界是可以达到的。

如何找到最小顶点覆盖?

算法步骤如下:

  1. 使用匈牙利算法找到二分图的一个最大匹配
  2. 从所有左部的自由顶点出发,在上一节构建的“匹配边从右到左、非匹配边从左到右”的有向图中进行 DFS,标记所有能访问到的顶点。
  3. 令访问到的左部顶点集合为 L+,未访问到的为 L-
    令访问到的右部顶点集合为 R+,未访问到的为 R-
  4. 最小顶点覆盖L-R+ 中的顶点构成。

为什么这是最小覆盖?

  • 它是顶点覆盖:根据 DFS 后图的特殊结构,所有边要么连接 L+R+,要么连接 L-R-,要么连接 L-R+L-R+ 的并集覆盖了所有这些边。
  • 它是最小的:这个覆盖的大小恰好等于最大匹配的大小。因为在这个覆盖中,我们只从最大匹配的边中选取顶点(每条匹配边恰好选一个端点:要么是 L- 中的左端点,要么是 R+ 中的右端点)。由于 |最大匹配| <= |最小顶点覆盖|,而此覆盖大小等于 |最大匹配|,所以它一定是最小的。

总结 📝

本节课我们一起学习了二分图匹配的核心知识:

  1. 基本概念:我们定义了匹配、最大匹配和完美匹配。
  2. 核心算法:介绍了通过寻找增广路径来逐步扩大匹配的匈牙利算法框架,并证明了其正确性基于“不存在增广路径等价于匹配最大”这一定理。
  3. 具体实现:讲解了如何在二分图中高效寻找增广路径(通过构建特定有向图并利用 DFS),给出了算法的伪代码和复杂度分析(O(V*E))。
  4. 霍尔定理:学习了判断二分图是否存在完美匹配的充分必要条件。
  5. 对偶问题:探讨了二分图中最大匹配最小顶点覆盖之间的对偶关系(柯尼希定理),并给出了通过最大匹配构造最小顶点覆盖的具体方法。

这些内容是图论和组合优化中的重要基础,广泛应用于任务分配、资源调度、网络流等众多领域。在接下来的课程中,我们将继续探讨更高效的算法(如基于网络流的算法)以及非二分图上的匹配问题。

049:非二分图中的最大匹配 🎯

在本节课中,我们将学习如何在非二分图中寻找最大匹配。我们将从二分图匹配的简单算法出发,探讨在非二分图中遇到的挑战,并介绍解决这些挑战的核心概念——花(Blossom) 及其收缩算法。


回顾:二分图匹配算法

上一节我们介绍了二分图的最大匹配算法。该算法非常简单。

我们从空匹配开始,然后通过寻找增广路径(Augmenting Path) 来逐步增加匹配的大小。

每次我们运行一个简单的DFS算法来寻找增广路径。找到增广路径后,我们反转路径上所有边的状态,从而将匹配的大小增加一。重复此过程,直到找不到更多的增广路径,此时匹配即为最大匹配。


非二分图匹配的挑战

本节中我们来看看非二分图的情况。算法的基本思想是相同的:我们从空匹配开始,每次通过寻找增广路径来增加匹配的大小。

增广路径的定义与之前相同:路径的起点和终点必须是自由节点(Free Vertices)(即不在当前匹配中的节点),并且路径上的边在匹配边和非匹配边之间交替出现。

如果我们找到一条增广路径,我们可以通过反转路径上所有边的状态来增加匹配的大小。

核心区别在于,在非二分图中,寻找增广路径的算法要复杂得多。因为在二分图中,我们可以通过给边定向(例如,从左到右定向非匹配边,从右到左定向匹配边)来简化问题,从而将增广路径的寻找转化为寻找有向图中的路径。但在非二分图中,没有“左”和“右”之分,因此无法用同样的方式简化。

主要定理仍然成立:一个匹配是最大的,当且仅当图中不存在增广路径。


简单的DFS修改尝试及其问题

让我们尝试修改DFS算法,使其只寻找交替路径(即增广路径的候选)。

在修改的DFS中,我们维护当前节点的状态,该状态表示到达该节点的上一条边是否在匹配中。

以下是算法的伪代码框架:

def dfs(v, state):
    # state = 0: 上一条边不在匹配中
    # state = 1: 上一条边在匹配中
    mark v as visited with the current state
    if state == 0:
        # 需要走一条非匹配边
        for each edge (v, u) not in matching:
            if u is not visited:
                dfs(u, 1)
    else: # state == 1
        # 需要走一条匹配边
        # 从v出发的匹配边最多只有一条
        let (v, u) be the matching edge from v
        if u is not visited:
            dfs(u, 0)
    # 检查是否找到了增广路径(终点为自由节点)

这个算法试图保证找到的路径是交替路径。然而,它存在一个问题。

问题在于:一个节点可能会被以两种不同的状态访问到。考虑一个奇环(Odd Cycle)的情况。DFS可能会从不同方向访问到环上的同一个节点,并且赋予它不同的状态(一次是state=0,另一次是state=1)。这会导致算法可能错过实际存在的增广路径,因为它错误地认为该节点“已访问”而不再探索。

在二分图中,所有环的长度都是偶数,因此不会出现这个问题。所以,这个问题是非二分图特有的。


核心结构:花(Blossom)🌸

当我们发现一个节点被以两种不同状态访问时,就意味着我们找到了一个被称为花(Blossom) 的结构。

花本质上是一个奇环,并且环上的边是交替的(匹配边和非匹配边交替出现)。花的“基部”是一个节点,从该节点有两条路径到达环上的同一个节点,一条路径长度为偶数,另一条为奇数。

以下是处理花的步骤:

  1. 识别花:当DFS发现一条边连接了两个状态相同的已访问节点时(例如,从state=0的节点v到另一个state=0的节点u),我们就找到了一个花。这个花由DFS栈中从uv的节点构成。
  2. 收缩花:我们将花中的所有节点收缩(Contract) 成一个新的超级节点。所有原来与花中节点相连的边,现在都与这个超级节点相连。
  3. 在收缩后的图中继续搜索:我们在新的图(记为G')中继续运行DFS算法,寻找增广路径。
  4. 路径还原:如果在G'中找到了增广路径,我们需要将其扩展(Expand) 回原图G
    • 如果增广路径不经过被收缩的超级节点,那么它直接对应原图中的一条路径。
    • 如果增广路径经过超级节点,我们需要用花内部的某条交替路径来替换它。由于花是奇环,我们总是能在花内部找到一条具有正确奇偶性的交替路径来连接进入点和离开点。

完整算法流程与复杂度

现在,让我们总结完整的带花树算法(Blossom Algorithm) 流程:

  1. 从空匹配开始。
  2. 对每个自由节点,运行修改后的DFS以寻找增广路径。
  3. 在DFS过程中:
    • 情况A:找到增广路径。反转路径上的边,匹配大小加一。回到步骤2,尝试寻找下一个增广路径。
    • 情况B:发现一个花。收缩该花,得到新图G'。在G'中递归地继续运行DFS。
      • 如果在G'中找到增广路径,将其扩展回原图G,然后应用情况A。
      • 如果在G'中未找到增广路径且未发现新花,则原图中从该起始点出发不存在增广路径。
    • 情况C:未找到增广路径且未发现花。说明从该起始点出发不存在增广路径。
  4. 当对所有自由节点都找不到增广路径时,算法结束,当前匹配即为最大匹配。

关于正确性的关键证明:需要证明,在原图G中存在增广路径,当且仅当在收缩花得到的新图G'中也存在增广路径。这个证明通过构造中间图并利用匹配最大性的等价条件(存在增广路径)来完成。

时间复杂度:每次找到增广路径,匹配大小增加1,最多发生O(n)次。每次DFS(包括收缩和扩展操作)可以在接近O(m)的时间内完成(使用并查集处理节点集合)。因此,总时间复杂度约为 O(n * m * α(n)),其中α(n)是阿克曼函数的反函数,增长极慢。


总结

本节课中我们一起学习了在非二分图中寻找最大匹配的带花树算法

  • 我们回顾了二分图匹配的简单增广算法。
  • 我们发现了将该算法直接应用于非二分图时遇到的问题,即节点可能被以不同状态重复访问。
  • 我们引入了花(Blossom) 这一核心概念来处理奇环结构。
  • 我们掌握了算法的关键操作:识别花收缩花、在收缩后的图中递归搜索,以及最后将路径扩展还原
  • 我们概述了算法的整体流程、正确性逻辑和大致复杂度。

虽然这个算法比二分图匹配复杂,但它优美地解决了非二分图最大匹配这一经典问题,是图论算法中的一个重要里程碑。

050:网络流、割与Ford-Fulkerson算法

在本节课中,我们将要学习网络流问题的基本概念,包括流、割的定义,以及求解最大流问题的经典算法——Ford-Fulkerson算法。我们会从直观理解开始,逐步深入到算法的实现细节和复杂度分析。

网络流问题简介

网络流问题描述如下:我们有一个有向图。图中有一个特定的起点,称为源点,以及一个特定的终点,称为汇点。我们可以想象有一种液体从源点流向汇点。

图中的每条边都有一个容量,表示单位时间内可以通过该边的最大流量。我们需要为每条边分配一个实际的流量,它不能超过该边的容量。同时,除了源点和汇点,流入每个节点的总流量必须等于流出该节点的总流量,这称为流量守恒

我们的目标是,在满足上述所有约束的前提下,找到从源点到汇点的最大可能流量

流的数学模型

为了精确描述问题,我们引入以下符号:

  • 设图中有向边 (u, v) 的容量为 c(u, v)
  • 设实际流过边 (u, v) 的流量为 f(u, v)

那么,流必须满足以下约束:

  1. 容量约束:对于每条边 (u, v),有 0 <= f(u, v) <= c(u, v)
  2. 流量守恒:对于除源点 s 和汇点 t 外的每个节点 v,流入该节点的总流量等于流出该节点的总流量。用公式表示为:
    sum_{u} f(u, v) = sum_{w} f(v, w)

流的值 |f| 定义为从源点流出的总流量,也等于流入汇点的总流量。我们的目标就是最大化 |f|

对称模型与反向边

上一节我们介绍了网络流的基本模型。本节中我们来看看一个对算法实现更友好的模型——对称模型。

原始模型在计算流量守恒时,需要分别处理入边和出边,这会使算法变得复杂。为了简化,我们为图中的每条有向边 (u, v) 都添加一条反向边 (v, u),并规定反向边的流量是原边流量的相反数,即 f(v, u) = -f(u, v)。同时,反向边的容量设为 0

在这个对称模型中,流量守恒约束可以统一表述为:对于所有节点 v(包括源点和汇点),所有与之相连的边(包括正向和反向边)的流量之和为 0。即:
sum_{u} f(u, v) = 0

这个模型的美妙之处在于,它允许我们在寻找增广路径时,不仅可以通过未满的边(正向)前进,还可以通过减少已有流量的边(反向)来“退回”流量,从而找到更优的流分配。这为后续的算法奠定了基础。

流的分解定理

一个重要的理论结果是,任何可行的流都可以分解为若干条从源点到汇点的路径流和一些环流的和。

以下是分解的构造方法:

  1. 从当前流中,找到任意一条从源点 s 到汇点 t、且路径上每条边的流量都为正的路径。
  2. 记该路径上所有边的最小流量为 delta
  3. 从当前流中减去一个值为 delta 的、仅在此路径上存在的“路径流”。
  4. 重复步骤1-3,直到找不到这样的路径。此时,剩下的流中如果还有正流量边,它们必然构成环(根据流量守恒),可以类似地分解为“环流”。

这个分解过程是有限的,因为每次操作至少会使一条边的流量变为 0。这个定理在理论上说明了流的构成,虽然算法本身不直接使用这种分解。

剩余网络与增广路径

上一节我们了解了流的构成,本节中我们来看看算法是如何逐步改进流的。核心概念是剩余网络

给定一个流 f,我们定义剩余容量 c_f(u, v) = c(u, v) - f(u, v)。它表示边 (u, v) 上还能增加多少流量。
剩余网络 G_f 由原图的所有节点,以及所有剩余容量 c_f(u, v) > 0 的边(包括正向和反向边)构成。

在剩余网络 G_f 中,任何一条从源点 s 到汇点 t 的路径,都对应原网络中的一条增广路径。沿着这条路径,我们可以将总流量增加一个值 deltadelta 等于该路径上所有边剩余容量的最小值。

增广操作是:对于路径上的每条边 (u, v)

  • 如果它是原图中的正向边,则增加其流量:f(u, v) += delta
  • 如果它是原图中的反向边(即对应正向边 (v, u)),则减少正向边的流量:f(v, u) -= delta。这相当于“退回”一部分流量,为新的流量让路。

Ford-Fulkerson 算法

基于增广路径的思想,我们可以得到最基础的 Ford-Fulkerson 算法

算法步骤如下:

  1. 初始化:将所有边的流量 f(u, v) 设为 0
  2. 当在剩余网络 G_f 中存在从 st 的路径时:
    a. 找到任意一条这样的路径 P
    b. 计算路径 P 的瓶颈容量:delta = min_{(u, v) in P} c_f(u, v)
    c. 沿着路径 P 进行增广,将总流量增加 delta
  3. 当剩余网络中不存在 st 的路径时,算法结束,当前的流即为最大流。

以下是该算法的一个简单DFS实现代码框架:

def ford_fulkerson_dfs(u, flow):
    if u == t:
        return flow
    visited[u] = True
    for v in graph[u]:
        if not visited[v] and c[u][v] - f[u][v] > 0: # 剩余容量为正
            pushed_flow = ford_fulkerson_dfs(v, min(flow, c[u][v] - f[u][v]))
            if pushed_flow > 0:
                f[u][v] += pushed_flow
                f[v][u] -= pushed_flow # 更新反向边
                return pushed_flow
    return 0

max_flow = 0
while True:
    visited = [False] * n
    additional_flow = ford_fulkerson_dfs(s, INF)
    if additional_flow == 0:
        break
    max_flow += additional_flow

最小割问题

在深入分析算法复杂度之前,我们先看一个与最大流紧密相关的问题:最小割

给定网络,一个 (s-t) 是将节点分成两个集合 ST,其中 sS 中,tT 中。割的容量定义为所有从集合 S 指向集合 T 的边的容量之和。最小割问题就是寻找容量最小的 (s-t) 割。

最大流与最小割之间存在着深刻的对偶关系,这由最大流最小割定理揭示:

一个网络中从 st 的最大流的值,等于分离 st 的最小割的容量。

证明思路

  1. 任何流的流量都不会超过任何割的容量(因为所有流量必须穿过割集中的边)。
  2. 当 Ford-Fulkerson 算法终止时,剩余网络中 s 可达的节点构成集合 S,不可达的节点构成集合 T。此时,所有从 ST 的边在原网络中都已饱和(流量=容量),所有从 TS 的边流量为 0
  3. 因此,当前流的流量正好等于割 (S, T) 的容量。根据1,它既是最大流,其值也等于最小割的容量。

这个定理不仅优美,也为我们提供了找到最小割的方法:在算法结束后,在剩余网络中从源点 s 进行搜索(如DFS/BFS),所有能被访问到的节点就属于最小割的 S 集合。

算法复杂度分析与改进

基础的 Ford-Fulkerson 算法(使用DFS找任意增广路)在最坏情况下的时间复杂度是 O(|f*| * E),其中 |f*| 是最大流的值。这是因为每次增广可能只增加 1 的流量。当容量很大时,这不是一个关于输入规模的多项式时间算法。

为了提高效率,我们有以下主要改进方向:

1. Edmonds-Karp 算法
将寻找增广路径的方法从DFS改为BFS,即每次都寻找最短的增广路径(边数最少)。可以证明,这样改进后,算法的时间复杂度变为 O(V * E^2),这是一个强多项式时间的算法。

证明关键:定义 d(v) 为剩余网络中从 sv 的最短距离(边数)。可以证明,在每次增广后,对于任何节点 vd(v) 不会减少。并且,每条边成为增广路径上瓶颈边的次数是 O(V) 的。因此总增广次数为 O(V * E),每次BFS耗时 O(E),总复杂度为 O(V * E^2)

2. 容量缩放算法
另一种思路是优先推送大的流量。我们引入一个参数 Delta,初始时设为大于等于最大容量的 2 的幂。在每一轮中,我们只在剩余容量 >= Delta 的边中寻找增广路。当找不到时,将 Delta 减半,重复此过程,直到 Delta0

可以证明,缩放轮数为 O(log C),其中 C 是最大边容量。每轮中的增广次数为 O(E)。因此总时间复杂度为 O(E^2 log C)。这是一个弱多项式时间算法,在实践中通常很快。

总结

本节课中我们一起学习了网络流的核心内容:

  1. 我们定义了网络流问题、流与割的概念,并建立了其数学模型。
  2. 我们引入了带有反向边的对称模型,这大大简化了算法的设计与分析。
  3. 我们学习了基础的 Ford-Fulkerson 算法框架,其核心是不断在剩余网络中寻找增广路径。
  4. 我们揭示了最大流与最小割之间的对偶定理,这是网络流理论的基石之一。
  5. 我们分析了基础算法的复杂度缺陷,并介绍了两种重要的改进策略:Edmonds-Karp 算法(BFS找最短路)和容量缩放算法,它们分别将复杂度提升到了强多项式和弱多项式级别。

理解这些基础概念和算法,是学习更高效、更复杂的网络流算法(如Dinic算法、Push-Relabel算法等)的必要前提。

051:最大流与Dinic算法

在本节课中,我们将继续学习网络流算法,并讨论一些更复杂、更高级的算法来寻找网络中的最大流。

首先,让我们回顾一下上一讲的内容。在上一讲中,我们学习了最大流问题。我们有一个图,每条边都有一个容量。图中有两个固定的节点:源点 S 和汇点 T。我们需要从 ST 推送流量。对于每条边,其容量 c(u, v) 是流经该边的最大速率。我们的目标是找到每条边上的流量值 f(u, v),它不能超过该边的容量,并且我们希望最大化从 S 流向 T 的总流量。

上一讲中,我们学习了最简单的最大流算法——Ford-Fulkerson算法。其工作原理是:每次尝试通过寻找一条增广路径来增加当前流量。增广路径是从 ST 的一条路径,路径上每条边的剩余容量(即最大容量减去当前流量)为正。找到路径后,我们计算路径上所有边的最小剩余容量 δ,然后将路径上每条边的流量增加 δ。如果找不到增广路径,则当前流即为最大流。

我们还学习了Edmonds-Karp算法,它是Ford-Fulkerson算法的一个简单改进。该算法规定,每次寻找边数最少的增广路径(即最短路径)。如果每次都用广度优先搜索(BFS)寻找最短增广路径,那么增广路径的总数最多为 O(nm) 条,每次BFS的时间复杂度为 O(m),因此总时间复杂度为 O(nm²)


从Edmonds-Karp到Dinic算法

上一节我们回顾了Edmonds-Karp算法。本节中,我们将探讨如何改进其时间复杂度,介绍一种名为Dinic的算法。

Edmonds-Karp算法每次运行BFS只找到一条增广路径,这并不高效,因为BFS扫描了整个图却只利用了一条路径的信息。Dinic算法的核心思想是:运行一次BFS,构建一个“分层网络”,然后在这个分层网络中一次性找到多条增广路径。

以下是具体步骤:

  1. 运行BFS构建分层网络:从源点 S 开始运行BFS,计算每个节点 vS 的距离 dist(v)。然后,我们只保留那些从距离为 d 的节点指向距离为 d+1 的节点的边。这样得到的图称为分层网络,它包含了所有从 ST 的最短路径。
  2. 在分层网络中寻找增广路径:在分层网络中,任何从 ST 的路径都是最短路径。我们可以通过简单的贪心法(从 S 开始,每次任意选择一条出边)快速找到一条路径。
  3. 推送流量并更新网络:找到路径后,计算路径上的最小剩余容量 δ,将路径上所有边的流量增加 δ。这会导致至少一条边达到饱和(剩余容量为0),我们将这条边从分层网络中移除。
  4. 处理“死胡同”节点:当一条边被移除后,可能导致某些节点没有出边(成为“死胡同”节点)。这些节点无法再用于任何最短路径,因此我们也将其及其所有入边从分层网络中移除。这个过程可能会级联发生。
  5. 重复寻找路径:在同一个分层网络中,重复步骤2-4,直到无法再找到从 ST 的路径为止。这意味着我们已经找完了所有当前距离下的最短增广路径。
  6. 重建分层网络:当在当前分层网络中找不到路径时,说明从 ST 的最短距离增加了。我们返回步骤1,基于更新后的残量网络重新运行BFS,构建新的分层网络。

算法复杂度分析

现在我们来分析Dinic算法的时间复杂度。

  • 外层循环(重建分层网络):每次重建分层网络后,ST 的距离至少增加1。这个距离最多为 n-1,因此外层循环最多执行 O(n) 次。
  • 内层循环(在同一分层网络中找路径):在内层循环中,每次找到一条增广路径并推送流量后,至少会从分层网络中移除一条边。因此,内层循环最多执行 O(m) 次。
  • 内层操作:在内层循环中,寻找一条路径、计算最小容量、更新流量等操作可以在 O(n) 时间内完成。级联移除“死胡同”节点的总开销是 O(m),因为每条边和每个节点最多被移除一次。

综合来看,Dinic算法的总时间复杂度为 O(n²m)。这比Edmonds-Karp算法的 O(nm²) 有所改进,尤其是在边数 m 远大于节点数 n 的稠密图中。

实现细节与优化

实际实现Dinic算法时,有一些技巧可以简化代码:

  • 隐式构建分层网络:我们不需要显式地创建并维护一个独立的分层网络图数据结构。在BFS计算出距离后,当我们在残量网络中寻找路径时,只需考虑那些满足 dist(v) == dist(u) + 1 的边 (u, v) 即可。
  • “懒惰”删除死胡同节点:我们不需要在移除饱和边后立即主动检查并级联删除死胡同节点。可以在后续寻找路径的DFS过程中“懒惰”处理:当从一个节点 u 尝试所有出边都失败(或出边容量均为0)时,就将 u 标记为无效,并在DFS回溯时不再考虑指向 u 的边。这通常通过维护每个节点的出边列表,并从列表末尾移除无效边来实现。

更高级的改进:使用数据结构

Dinic算法的瓶颈在于内层循环中需要多次在线性时间内寻找增广路径和更新网络。一个更高级的改进思路是使用动态树(Link-Cut Tree) 数据结构来加速这些操作。

核心思想是记住之前找到的增广路径的部分结构(构成一棵或多棵有向树,称为“当前森林”)。当寻找下一条增广路径时,我们不是从头开始,而是从源点 S 出发,快速跳转到当前森林中某个树的根节点,然后尝试扩展这条路径。这涉及到动态树的几种操作:

  • find_root(v):找到节点 v 所在树的根。
  • link(u, v):将节点 u(某树的根)链接到节点 v,添加一条边。
  • cut(u, v):将边 (u, v) 从树中切断。
  • path_min(u, v)path_add(u, v, delta):对树中从 uv 的路径查询最小值或统一增加一个值。

通过巧妙地将分层网络中的搜索过程转化为一系列动态树操作,可以将内层处理每条增广路径的时间从 O(n) 降低到 O(log n)。结合之前的分析,使用动态树的Dinic算法时间复杂度可以优化到 O(nm log n),这已经非常接近理论上已知的最好算法之一(O(nm))。


本节课中,我们一起学习了Dinic最大流算法。我们从回顾基础的增广路径法开始,指出了Edmonds-Karp算法的低效之处。然后,我们详细介绍了Dinic算法如何通过构建分层网络来一次性找到多条最短增广路径,从而将时间复杂度优化到 O(n²m)。我们还讨论了算法实现中的一些实用技巧,并简要介绍了如何利用高级数据结构(如动态树)进行进一步优化,达到接近理论最优的 O(nm log n) 复杂度。理解Dinic算法是掌握高效网络流算法的重要一步。

052:Hopcroft-Karp算法与Push-Relabel算法

在本节课中,我们将继续探讨网络流与二分图匹配问题。我们的目标是学习一个更高效的算法来寻找二分图最大匹配,即Hopcroft-Karp算法。我们将从网络流算法开始,分析其在边容量为1的特殊情况下的性能,然后将其应用于二分图匹配问题。最后,我们将简要介绍另一种解决最大流问题的不同思路——Push-Relabel算法。

回顾最大流算法

在之前的课程中,我们讨论了几种不同的算法来解决最大流问题。现在,让我们重新审视它们,并思考一个特殊情况:当图中所有边的容量都为1时,这些算法的时间复杂度会如何变化。

Ford-Fulkerson算法

简单的Ford-Fulkerson算法时间复杂度为 O(F * M),其中F是最大流的值。每次我们至少将流量增加1,通过寻找一条从源点S到汇点T的增广路径并推送流量来实现。

如果所有边的容量都为1,那么最大流F的上界是多少?一个简单的上界是M(边的总数),因为你无法从S向T推送超过M单位的流量。因此,时间复杂度上界为 O(M²)

然而,如果我们禁止平行边,那么从源点S出发的边最多有N条(N为顶点数),因此最大流F的上界是N。此时时间复杂度上界为 O(N * M)

Edmonds-Karp算法

Edmonds-Karp算法总是寻找最短的增广路径(使用BFS),其时间复杂度为 O(N * M²)

在边容量为1的情况下,其时间复杂度不会比Ford-Fulkerson算法更差,因为每次仍然只推送1单位的流量。因此,其上界同样是 O(N * M)O(M²)

Dinic算法

现在让我们看看Dinic算法。回顾一下,Dinic算法在每一阶段(phase)构建分层图(layer network),然后在该分层图中寻找阻塞流(blocking flow)。我们曾证明,每次推送流量后,至少会移除一条边。因此,每个阶段的时间复杂度为 O(N * M),而阶段数最多为N,总时间复杂度为 O(N² * M)

然而,当所有边容量为1时,情况发生了变化。当我们沿着一条路径推送1单位流量时,该路径上的所有边都会从残差网络中移除(因为容量耗尽)。这意味着,在每个阶段中,寻找所有增广路径的总时间与边的总数M成线性关系。

因此,每个阶段的时间复杂度变为 O(M)。那么,阶段数是多少呢?我们接下来将证明,在边容量为1的情况下,Dinic算法的阶段数有一个更好的上界。

边容量为1时Dinic算法的改进分析

我们首先证明一个有趣的结论:如果所有边容量为1,那么Dinic算法的阶段数不超过 O(√M)

证明思路

  1. 假设我们运行Dinic算法,并完成前 √M 个阶段。在这些阶段中,我们找到了所有长度不超过 √M 的增广路径。
  2. 在此之后,残差网络中剩余的增广路径长度都至少为 √M
  3. 考虑当前流与最大流之间的差值。这个差值可以分解为一组边不相交的增广路径(因为每条边容量为1,一条边只能属于一条路径)。
  4. 每条路径的长度至少为 √M,而总边数为M。因此,这样的路径数量最多为 M / √M = √M 条。
  5. 在Dinic算法的每个剩余阶段,我们至少能找到一条增广路径。因此,剩余的阶段数不超过 √M
  6. 总阶段数 = 前 √M 个阶段 + 剩余阶段 ≤ √M + √M = O(√M)

因此,在这种情况下,Dinic算法的总时间复杂度为:O(阶段数 * 每阶段时间) = O(√M * M) = O(M√M)

无平行边时的更紧上界

如果我们进一步假设图中没有平行边,那么阶段数还有一个更紧的上界:O(N^(2/3))。证明思路类似,通过分析分层图中顶点在各层的分布,并利用最小割的性质来限制最大流的值(从而限制剩余增广路径的数量)。最终的时间复杂度为 O(min(√M, N^(2/3)) * M)

这个因子 min(√M, N^(2/3)) 是许多高级网络流算法时间复杂度中常见的一个项,其根源就在于这些对边容量和结构的假设。

回到二分图匹配

现在,让我们回到本学期的第一个主题:二分图匹配。我们有一个二分图,希望找到最大的匹配(即最多的边,使得任意两条边不共享顶点)。

我们可以将此问题转化为最大流问题来解决:

  1. 引入一个源点S和一个汇点T。
  2. 从S向所有左侧顶点连边,容量为1。
  3. 从所有右侧顶点向T连边,容量为1。
  4. 原二分图中的边方向设为从左到右,容量为1。
  5. 在这个新网络上求解最大流。饱和的中间边(即流量为1的边)就构成了一个最大匹配。

我们在第一节课讨论的Kuhn算法,实际上等价于在这个转化后的网络上运行简单的Ford-Fulkerson算法。

现在,我们可以尝试应用更高效的Dinic算法。在这个转化后的网络中,所有边容量为1,并且没有平行边(如果我们从二分图开始就没有平行边)。因此,时间复杂度为 O(√N * M),这比Kuhn算法的 O(NM) 要好。

为什么对于二分图匹配更好?

我们可以证明,在这个特定的流网络上运行Dinic算法,阶段数不超过 O(√N)。证明的关键在于,增广路径不仅是边不相交的,而且是顶点不相交的(除了源点和汇点)。这是因为:

  • 每个左侧顶点只有一条来自S的入边(容量为1)。在残差网络中,一个左侧顶点最多只能有一条入边(要么是原边,要么是反向边)。
  • 每个右侧顶点只有一条指向T的出边(容量为1)。同理,在残差网络中,一个右侧顶点最多只能有一条出边。
    因此,任何两条不同的增广路径不能共享任何中间顶点。所有增广路径的总顶点数不超过N,而每条路径长度至少为√N,所以路径数不超过 N / √N = √N

因此,Hopcroft-Karp算法(本质上就是在这个二分图对应的流网络上运行Dinic算法)的时间复杂度为 O(√N * M)。这几乎是目前已知的解决二分图最大匹配问题的最优算法之一。

Push-Relabel算法简介

最后,我们简要介绍另一种解决最大流问题的思路——Push-Relabel(推送-重标记)算法。它与我们之前讨论的所有基于增广路径的算法有根本性的不同。

核心思想

之前的算法(如Ford-Fulkerson, Edmonds-Karp, Dinic)都是“拉”的思路:从源点S出发,寻找一条到汇点T的路径,然后沿着这条路径“拉”送流量。

Push-Relabel算法则是“推”的思路:

  1. 首先,从源点S“推”出尽可能多的流量到其相邻节点(可能超过实际能到达T的量),造成这些节点有“超额流量”(excess)。
  2. 然后,尝试将这些超额流量在图中向下“推送”或“重新分配”。
  3. 如果某个节点的流量无法推送出去,就“重标记”其高度,使其能够继续推送。
  4. 最终,所有超额流量要么被推送到汇点T,要么被推回源点S,从而得到一个可行的最大流。

算法要素

  1. 预流(Preflow):允许节点有超额流量(即流入量 > 流出量)。记节点v的超额流量为 excess(v)。当所有节点的 excess(v) = 0 时,预流就变成了一个合法的流。
  2. 高度/标签(Height/Label):为每个节点v分配一个高度 h(v)
    • 源点S的高度固定为 h(s) = N(顶点数)。
    • 汇点T的高度固定为 h(t) = 0
    • 其他节点高度初始为0,并在算法中变化。
  3. 关键不变性(Invariant):对于残差网络中的任意边 (u, v),如果 h(u) > h(v) + 1,那么这条边必须是饱和的(即流量=容量)。这保证了流量主要从高处向低处流动。

基本操作

算法不断选择有超额流量(excess(v) > 0)的非源非汇节点v,并对其执行以下两种操作之一:

  1. 推送(Push):如果存在一条从v出发的残差边 (v, w),且 h(v) = h(w) + 1,并且该边有剩余容量,则可以将流量从v推送到w。
    • 推送量 = min(excess(v), residual_capacity(v, w))
    • 如果推送后边 (v, w) 饱和,称为饱和推送;否则称为非饱和推送
  2. 重标记(Relabel):如果当前节点v有超额流量,但无法对其任何出边进行推送(即所有满足 h(v) > h(w) 的出边都已饱和),则提高节点v的高度。
    • 新的高度 h(v) = 1 + min{ h(w) | (v, w) 是残差边 }。这使得至少有一条出边可以用于后续推送。

算法过程与复杂度

算法不断执行推送或重标记操作,直到除源点和汇点外,所有节点的超额流量均为0。此时我们就得到了一个最大流。

时间复杂度分析

  • 重标记操作:每个节点的高度最多被提升至 2N(因为从S到该点的路径长度最多为N-1,加上S的高度N)。每次重标记需要检查节点的所有出边。总的重标记操作次数为 O(N²),总时间为 O(NM)
  • 饱和推送:每条边 (u, v) 在两次饱和推送之间,节点u或v的高度必须增加至少2。因此每条边的饱和推送次数为 O(N),总饱和推送次数为 O(NM)
  • 非饱和推送:非饱和推送会减少一个势函数(例如,所有有超额流量的节点的高度之和)。该势函数的总增加量受限于重标记和饱和推送的次数(O(N²M))。由于非饱和推送每次减少势函数至少1,因此非饱和推送的次数也为 O(N²M)

因此,朴素的Push-Relabel算法时间复杂度为 O(N²M)

通过更智能地选择要操作的节点(例如,总是选择高度最高的有超额流量的节点),可以将时间复杂度优化到 O(N³) 甚至更好(如使用“间隙启发式”等技术可达到 O(N²√M))。Push-Relabel算法在实践中通常非常高效,尤其是对于稠密图。

总结

本节课我们一起学习了以下内容:

  1. 回顾了最大流算法(Ford-Fulkerson, Edmonds-Karp, Dinic)在边容量为1时的性能变化。
  2. 重点分析了Dinic算法在边容量为1时的优越性,证明了其阶段数上界为 O(√M),从而总时间复杂度为 O(M√M)
  3. 将二分图最大匹配问题转化为特殊的网络流问题,并应用Dinic算法,得到了著名的Hopcroft-Karp算法,其时间复杂度为 O(√N * M)
  4. 简要介绍了另一种最大流算法——Push-Relabel算法的核心思想、基本操作和复杂度。它采用“推送”而非“增广”的思路,为最大流问题提供了不同的视角和高效的实现可能。

通过本节学习,我们看到了如何通过对问题特殊性的深入分析来优化经典算法,并了解了解决同一问题的不同算法范式。

053:分配问题与匈牙利算法

在本节课中,我们将要学习分配问题以及解决它的经典算法——匈牙利算法。分配问题是一种寻找最小(或最大)权重匹配的问题,在任务调度、资源分配等实际场景中有着广泛应用。

什么是分配问题?

上一节我们讨论了匹配问题,但没有考虑边的权重。本节中我们来看看带权重的匹配问题。

分配问题本质上是一个寻找最小或最大权重匹配的问题。在之前的问题中,我们的图没有权重。在分配问题中,图的每条边都有一个权重,我们的目标不再是最大化匹配的边数,而是最大化或最小化所有被选边权重的总和

通常,分配问题的传统描述是最小化总成本。最大化问题可以通过对所有权重取负值转化为最小化问题。

问题的传统表述

在传统的分配问题中,通常有数量相等的“工人”和“工作”。例如,有 n 个工人和 n 个工作。

我们有一个二分图,其中:

  • 左侧节点代表工人。
  • 右侧节点代表工作。
  • 每条边连接一个工人和一个工作,其权重代表该工人完成该工作的成本。

我们的目标是:为每个工人分配恰好一个工作,同时每个工作也由恰好一个工人完成(即找到一个完美匹配),并且使得所有被选边(即分配方案)的总成本最小

矩阵表示法

这个问题通常用一个成本矩阵 C 来表示,其中 C[i][j] 表示第 i 个工人完成第 j 个工作的成本。

我们的任务是从矩阵中选择 n 个单元格,满足每行和每列都恰好选中一个,使得这些单元格数值之和最小。这等价于在二分图中寻找一个总权重最小的完美匹配。

如果工人不能完成某项工作,可以将对应成本设为无穷大(INF)。

一个朴素的解决方法是检查所有 n! 种排列,但显然效率极低。接下来,我们将介绍更高效的匈牙利算法

匈牙利算法的核心思想

匈牙利算法的基本思路是:通过对成本矩阵进行一系列“安全”的变换,在不改变最优解本质的前提下,逐步构造出一个零成本边构成的完美匹配。

安全操作

我们有两种不会改变问题最优匹配(尽管会改变总成本值)的操作:

  1. 行操作:将矩阵某一行的所有元素都加上(或减去)同一个常数 delta
  2. 列操作:将矩阵某一列的所有元素都加上(或减去)同一个常数 delta

为什么这些操作是安全的?
因为任何完美匹配都必然包含每行的一个元素和每列的一个元素。对一行整体加减 delta,会使所有包含该行元素的匹配方案总成本都变化 delta,因此最优匹配方案本身不会改变。列操作同理。

算法步骤概述

匈牙利算法的主要步骤如下:

  1. 初始化:对矩阵的每一行,减去该行的最小值,确保每行至少有一个 0。同样地,对每一列减去该列的最小值,确保每列至少有一个 0。此时所有元素非负。
  2. 尝试匹配:尝试在由 0 元素构成的二分图中,寻找一个完美匹配(即覆盖所有行和列的匹配)。这可以使用之前学过的寻找最大匹配的算法(如DFS增广路算法)。
  3. 检查结果
    • 如果找到了完美匹配,那么这些 0 边对应的分配就是原问题的最小成本解(因为总成本为 0,且所有成本非负)。
    • 如果找不到完美匹配,则进入下一步。
  4. 调整矩阵(关键步骤)
    • 设当前通过DFS访问到的左侧节点集合为 L+,未访问的左侧节点为 L-
    • 设当前通过DFS访问到的右侧节点集合为 R+,未访问的右侧节点为 R-
    • 由于未找到增广路,当前不存在从 L+R-0 边。
    • 找到所有从 L+R- 的边中的最小成本值 delta
    • L+ 中的所有行,执行 -delta 操作。
    • R+ 中的所有列,执行 +delta 操作。
    • 此操作旨在创造新的 0 边(从 L+R-),同时保证所有元素保持非负,且不破坏已有的重要 0 边结构。
  5. 重复:返回步骤2,继续尝试寻找完美匹配。每次调整都会扩大DFS可访问的节点集,最终一定能找到完美匹配。

算法演示与细节

让我们通过一个具体例子来理解算法的执行过程。

假设我们有一个成本矩阵,经过初始化(每行每列减最小值)后得到:

[3, 1, 4, 0]
[0, 0, 1, 0]
[2, 0, 3, 0]
[7, 0, 6, 2]

我们标记出所有的 0

第一步:在零图中寻找匹配

我们构建一个只包含 0 元素的二分图,并尝试寻找完美匹配。

  • 从左侧节点1开始,可以匹配右侧节点4。
  • 从左侧节点2开始,可以匹配右侧节点1或2或4。假设匹配右侧节点1。
  • 从左侧节点3开始,可以匹配右侧节点2。
  • 此时,左侧节点4无法找到未匹配的右侧节点(右侧节点3未被匹配,但边(4,3)的成本是6,不是0)。当前匹配大小为3,不是完美匹配。

第二步:执行DFS并调整矩阵

从未匹配的左侧节点4开始执行DFS寻找增广路:

  • 访问节点4 (L+={4})。
  • 从节点4出发,没有成本为 0 的边通往未访问的右侧节点 (R-)。DFS失败。

确定集合:

  • L+ = {4}
  • R+ = {} (因为从节点4没有走通任何 0 边)
  • L- = {1,2,3}
  • R- = {1,2,3,4}

计算 delta:找到从 L+={4}R-={1,2,3,4} 所有边的最小成本。查看矩阵第4行:[7, 0, 6, 2],在 R- 列中的最小值为 min(7,0,6,2) = 0。所以 delta = 0。当 delta 为0时,调整无效,说明我们的初始零图不够好。

我们需要从所有未匹配的左侧节点开始DFS,而不仅仅是最后一个。更标准的做法是:维护一个“交错树”,记录所有从起点通过交替路径能访问到的节点。
假设更彻底的DFS访问后,我们得到:

  • L+ = {1, 3, 4} (例如,从节点4开始,通过匹配边和未匹配边交替访问)
  • R+ = {2, 4}
  • L- = {2}
  • R- = {1, 3}

计算 delta:查找所有从 L+ 的行(第1,3,4行)到 R- 的列(第1,3列)的交叉点元素,找出最小值。

  • 行1,列1: 3
  • 行1,列3: 4
  • 行3,列1: 2
  • 行3,列3: 3
  • 行4,列1: 7
  • 行4,列3: 6
    最小值为 delta = min(3,4,2,3,7,6) = 2

执行调整:

  • L+ 中的行(1,3,4)全部 -2
  • R+ 中的列(2,4)全部 +2

矩阵变为:

[1, 3, 2, 0]
[0, 2, 1, 2]
[0, 2, 1, 0]
[5, 2, 4, 2]

注意,我们在位置(3,1)创造了一个新的 0(原来是2,减去2后变为0)。

第三步:继续寻找匹配

在新的零图中,左侧节点3现在可以与右侧节点1通过 0 边连接。这为增广路提供了可能。重新运行匹配算法,可以找到一条增广路,并将匹配大小增加到4,即找到一个完美匹配。

第四步:回溯得到原问题解

在最终调整后的矩阵中找到的零成本完美匹配,直接对应了原成本矩阵的最优分配方案。只需记录匹配了哪些边即可。

时间复杂度与优化

基础的匈牙利算法实现复杂度为 O(n⁴),因为每一步调整需要 O(n²) 来寻找 delta,最多需要 O(n) 步调整。

我们可以通过维护辅助变量将其优化到 O(n³)

  1. 维护行、列势能:不直接修改矩阵 C,而是维护两个数组 row_potential[]col_potential[]。任何元素 C[i][j] 的“有效成本”计算为 C[i][j] - row_potential[i] - col_potential[j]。行加减 delta 的操作变为修改 row_potential[i],列操作变为修改 col_potential[j],均可在 O(1)O(n) 完成。
  2. 快速查找 delta:维护一个数组 min_for_col[j],记录对于每个右侧列 j,当前 L+ 集合中的行所能提供的最小“有效成本”。那么 delta 就是所有 j 属于 R-min_for_col[j] 的最小值。这可以在 O(n) 内完成。
  3. 持续DFS:在调整后,不重置DFS状态,而是在原有“交错树”的基础上继续扩展,直到找到增广路。这确保了每个节点最多被加入 L+ 一次。

通过这些优化,匈牙利算法的复杂度可以降至 O(n³)

对于稀疏图(边数 m 远小于 ),可以使用堆(支持全局增量操作)来维护从 L+ 到每个右侧节点的最小边权,从而获得接近 O(n * m log n) 的复杂度。

总结

本节课中我们一起学习了分配问题及其经典解法匈牙利算法

  • 分配问题是在二分图中寻找最小(或最大)权重完美匹配的问题。
  • 匈牙利算法的核心是通过行变换列变换(安全操作)逐步修改成本矩阵,使其出现一个由零成本边构成的完美匹配,此即原问题的最优解。
  • 算法流程包括:初始化创造零元素、在零图中寻找匹配、若失败则通过计算最小 delta 调整矩阵以创造新的零边,并重复此过程。
  • 通过维护行/列势能每列最小成本等技巧,可以将算法优化到 O(n³) 的时间复杂度。

匈牙利算法是组合优化中的一个重要算法,其思想也与线性规划中的原始-对偶方法密切相关。

054:最小费用流 📊

在本节课中,我们将学习网络流问题的一个变种——最小费用流问题。我们将从基本概念入手,逐步探讨其定义、求解思路以及核心算法,并最终了解如何通过一些技巧来优化算法性能。


最小费用流问题定义

上一节我们介绍了最大流问题,本节中我们来看看它的加权版本。

在最大流问题中,我们只需要最大化从源点 S 到汇点 T 的流量,所有边都是平等的,每条边只有容量限制 C

在最小费用流问题中,每条边有两个参数:

  • 容量 C(u, v):表示该边能承载的最大流量。
  • 费用 W(u, v):表示通过该边输送一个单位流量所需支付的成本。

核心公式:对于一条边 (u, v),其参数为 (C(u, v), W(u, v))

f总费用 cost(f) 定义为流经所有边的流量与其费用的乘积之和:
公式cost(f) = Σ_{(u, v)} f(u, v) * W(u, v)

我们的目标是:在所有可能的最大流中,找到一个总费用最小的流。这就是最小费用最大流问题。

该问题还有其他变体,例如寻找一个指定流量大小 X 的最小费用流。为简化起见,本节主要讨论最小费用最大流。


基础求解思路:连续最短路算法

我们先从一个简单情况开始:图中所有边的费用均为非负。

从零流开始构建

流量为 0 的最小费用流显然是空流,费用为 0

如何找到流量为 1 的最小费用流?任何流量为 1 的流都可以分解为一条从 ST 的路径和一些环。由于所有边费用非负,加入任何环都不会降低总费用。因此,最小费用流必然是一条从 ST最短路径(按费用计算)。

核心步骤F1 = 在原始网络中,从 ST 的最短路径(使用 Dijkstra 算法)。

逐步增加流量

假设我们已经找到了流量为 k 的最小费用流 Fk。如何得到流量为 k+1 的最小费用流 F(k+1)

我们需要在 Fk残量网络中,找到一条从 ST最短增广路,然后沿该路径推送一个单位的流量。

算法伪代码

1. 初始化流 f = 0
2. while (在残量网络中能找到从 S 到 T 的路径):
      a. 在残量网络中找到从 S 到 T 的最短路径 P(按费用计算)
      b. 沿路径 P 推送尽可能多的流量(此处为1单位)
      c. 更新流 f 和残量网络
3. 输出流 f

问题:在残量网络中,反向边的费用是原边费用的负值。因此,即使原图无边权非负,残量网络中也可能出现负权边,导致无法直接使用 Dijkstra 算法求最短路。

一个直接的解决方案是使用能处理负权边的 Bellman-Ford 算法来寻找最短增广路。

初始时间复杂度O(F * (V * E)),其中 F 是最大流值。当 F 很大时,效率较低。


关键优化:势函数与 Johnson 算法

我们希望能在残量网络中使用更快的 Dijkstra 算法。核心思路是引入势函数 φ(v) 来调整边权,消除负权边。

势函数原理

为每个顶点 v 分配一个势 φ(v)。定义调整后的边权 w'(u, v)
公式w'(u, v) = w(u, v) + φ(u) - φ(v)

关键性质:调整边权后,图中任意两点间的最短路径保持不变(仅路径长度整体偏移了一个常数)。因此,在新图上求出的最短路径,在原图上也是最短路径。

如何选择势函数

如果我们能选择一组势 φ(v),使得所有调整后的边权 w'(u, v) 都非负,就可以使用 Dijkstra 算法。

一个有效的选择是:令 φ(v) 等于从源点 S 到顶点 v最短距离(按原边权 w 计算)。根据三角不等式,可以证明此时 w'(u, v) ≥ 0

新的挑战:为了计算这个势(即最短距离),我们似乎又需要运行 Bellman-Ford 算法,这回到了原点。

巧妙的维护方法

我们利用算法是增量式构建流这一特点。算法流程如下:

  1. 初始化流 f = 0,初始化势 φ(v) = 0(此时残量网络即原图,边权非负)。
  2. 在当前的残量网络(使用调整边权 w')中,运行 Dijkstra 算法找到从 ST 的最短路径 P
  3. 沿路径 P 推送流量。
  4. 更新势函数φ(v) = φ(v) + dist(v),其中 dist(v) 是步骤 2 中 Dijkstra 算法计算出的从 Sv 的最短距离(按调整边权 w' 计算)。
  5. 重复步骤 2-4,直到无法增广。

为何有效

  • 初始时,w' = w ≥ 0,可使用 Dijkstra。
  • 找到最短路径 P 后,对于 P 上的边,有 w'(u, v) = 0
  • 推送流量后,在残量网络中新增的反向边 (v, u),其调整边权 w'(v, u) = -w'(u, v) = 0
  • 因此,每次迭代后,残量网络中所有边的调整边权 w' 始终保持非负,使得下一次迭代能继续使用 Dijkstra 算法。

这个算法被称为 Successive Shortest Path (SSP) 算法Primal-Dual 算法

优化后时间复杂度O(F * (E log V)),使用堆优化的 Dijkstra。虽然仍有因子 F,但实践中对于许多问题足够高效。


处理负权边与负环

原图存在负权边但无负环

如果原图边权可能为负,但不含负环,上述 SSP 算法稍作修改仍可使用。

修改:在算法开始前,先运行一次 Bellman-Ford 算法,计算出初始的势函数 φ(v)(即从 S 到各点的最短距离)。此后的步骤与之前完全相同。

因为初始势的设定保证了调整边权非负,并且算法迭代过程能维持这一性质。

原图存在负环

如果原图存在负费用环,最小费用流问题仍然有解(因为容量有限,总费用不会无限低),但 SSP 算法不能直接应用。

思路是:先消除所有负环

  1. 在残量网络中寻找负环。
  2. 若找到负环,则沿该环推送尽可能多的流量。这会降低总费用,并减少环上的残余容量。
  3. 重复步骤 1-2,直到残量网络中不存在负环。
  4. 此时,我们得到了一个“最小费用循环流”,在此基础上再运行 SSP 算法来增加从 ST 的流量。

寻找负环可以使用 Bellman-Ford 算法的扩展版。


进一步优化:容量缩放技术

SSP 算法的时间复杂度中有一个因子 F(最大流值),当容量很大时效率不高。容量缩放是一种用于消除对 F 直接依赖的经典技术。

核心思想

模仿二进制表示,逐步“构建”出最终的网络和流。

  • 操作A:将图中所有边的容量翻倍。
  • 操作B:将某一条边的容量增加 1。

我们可以从所有边容量为 0 的图开始,通过一系列“翻倍”和“加一”操作,得到目标图。操作次数约为 O(M log C),其中 C 是最大容量。

算法框架

  1. 从零容量图开始,其最小费用流为 0
  2. 处理“翻倍”操作:若将当前图所有容量翻倍,则最优流也只需简单翻倍即可得到新图的最优流。
  3. 处理“加一”操作:当将边 (u, v) 的容量增加 1 时,相当于在残量网络中添加一条单位容量的边 (u, v)
    • 检查加入此边是否会形成负环(通过判断 w(u, v) + dist(v, u) < 0 是否成立,其中 dist 是最短距离)。
    • 若不会形成负环,则直接添加该边。
    • 若会形成负环,则找到这个包含 (u, v) 的负环,并沿其推送 1 单位流量。推送后,新增的边 (u, v) 会被饱和并移除,负环消失。
  4. 按二进制位从低到高的顺序,依次执行“加一”和“翻倍”操作,最终得到原图的最小费用最大流。

通过这种方式,我们将算法的主要开销从 O(F * ...) 转移到了 O(log C * ...),对于大容量场景更为友好。


总结 🎯

本节课中我们一起学习了最小费用流问题。

  • 问题定义:在满足容量限制的前提下,寻找总输送成本最小的最大流。
  • 核心算法:连续最短路算法。通过引入势函数动态调整边权,使得在残量网络中能持续使用高效的 Dijkstra 算法寻找增广路。
  • 处理负权:对于含负权但无负环的图,需先用 Bellman-Ford 初始化势函数;对于含负环的图,需先消除负环。
  • 优化技术:容量缩放技术通过模拟二进制构建过程,将时间复杂度中的流值因子 F 替换为关于容量的对数因子 log C,提升了算法对于大容量输入的处理能力。

最小费用流是网络流中一个非常强大的模型,广泛应用于运输、调度、资源分配等实际问题中。

055:全局最小割

在本节课中,我们将学习一个与网络流理论相似但略有不同的问题:全局最小割问题。我们将探讨其定义、与标准最小割问题的区别,并介绍几种求解算法,包括确定性算法和高效的随机化算法。

全局最小割问题定义

首先,我们来看看什么是全局最小割问题。

你有一个图,图中的每条边都有一个权重(或称成本)。我们的目标是找到一个边集,如果从图中移除这个边集,图将不再连通。我们希望找到总权重最小的这样一个边集。

例如,在下图中,移除总权重为9的两条边(3和6),图就会被分割成两个连通分量。这个总权重为9的割就是一个全局割,我们的目标是找到总权重最小的那个。

与标准最小割问题的区别

你可能会想到我们之前讨论过的标准最小割问题。它们之间有什么区别呢?

在标准最小割问题中,我们有两个给定的节点S和T。目标是移除一些边,使得S和T不再连通。这相当于在图中找到一个将S和T分开的割。

而全局最小割问题没有指定的S和T。我们只想移除一些边,让整个图变得不连通即可。它不关心具体是哪两个点被分开。

基础算法:枚举所有点对

一个直观的想法是,既然全局割会将图分成至少两个部分,那么我们可以枚举所有可能的点对(S, T),计算它们之间的最小割,然后取其中的最小值。

以下是该算法的步骤:

  1. 初始化结果 result 为正无穷。
  2. 对于图中每一对不同的节点 ST
    • 使用最大流算法(如Dinic、Edmonds-Karp)计算 ST 之间的最小割 cut_value
    • 如果 cut_value < result,则更新 result = cut_value
  3. 返回 result 作为全局最小割的值。

时间复杂度分析:需要计算 O(n²) 次最大流。如果最大流算法的时间复杂度是 O(F),那么总时间复杂度为 O(n² * F)。对于稠密图,这可能会非常慢。

改进算法:固定源点

我们可以改进上述算法,将点对枚举次数从 O(n²) 减少到 O(n)。

思路如下:假设全局最小割将图分成两个连通分量 A 和 B。我们固定一个节点(比如节点1),它必然属于A或B。那么,我们只需要计算节点1与所有其他节点之间的最小割即可。因为至少有一个其他节点在另一个分量中,这个割就是全局最小割。

以下是改进后的算法步骤:

  1. 固定源点 S = 节点1
  2. 初始化结果 result 为正无穷。
  3. 对于图中每一个不同于 S 的节点 T
    • 计算 ST 之间的最小割 cut_value
    • 如果 cut_value < result,则更新 result = cut_value
  4. 返回 result

时间复杂度分析:需要计算 O(n) 次最大流,总时间复杂度为 O(n * F)。这比 O(n² * F) 快了不少。

Stoer-Wagner 算法

接下来,我们介绍一个更高效且巧妙的确定性算法:Stoer-Wagner 算法。它能在 O(nm + n² log n) 的时间内解决问题,且无需多次调用复杂的最大流算法。

该算法的核心是重复进行“合并”操作,并利用一个特殊的最大邻接和序来快速找到任意两点间的最小割。

算法框架

算法的主循环如下:

  1. min_cut = 正无穷
  2. while 图中节点数 > 1:
    • 在当前的图 G 中,找到任意一对节点 (s, t) 以及它们之间的最小割 cut_st
    • min_cut = min(min_cut, cut_st)
    • 将节点 st 合并为一个新节点(合并它们的边,处理平行边)。
  3. 返回 min_cut

关键在于如何快速找到任意一对节点 (s, t) 及其最小割 cut_st。Stoer-Wagner 使用“最大邻接和序”来解决这个问题。

最大邻接和序与最小割

我们通过以下步骤为当前图建立一个节点序:

  1. 创建一个空集合 A
  2. 随机选择一个节点加入 A
  3. while A 不包含所有节点:
    • 对于每个不在 A 中的节点 v,计算其到集合 A 中所有节点的边权之和:w(v, A) = sum( weight(v, u) for u in A )
    • 选择 w(v, A) 最大的节点 v_max,将其加入 A
  4. 最后加入 A 的两个节点记为 st

神奇的性质:按照上述方法找到的 st,它们之间的最小割值就等于 t 加入 A 时计算的 w(t, A),即 t 到之前所有节点的边权总和。并且,这个割就是简单地将 t 与其他所有节点分开的割。

算法步骤详解

结合以上两部分,完整的 Stoer-Wagner 算法步骤如下:

  1. min_cut = INF
  2. whileG 的节点数 |V| > 1:
    • A = [] (空列表)
    • 随机选一个节点 startA.append(start)
    • while len(A) < |V|
      • 对于每个节点 v 不在 A 中,计算 w(v, A)
      • 找到使 w(v, A) 最大的节点 next_node
      • A.append(next_node)
    • // 此时 A 的最后两个节点是 st
    • cut_weight = w(t, A) (即 t 加入前计算的权重和)
    • min_cut = min(min_cut, cut_weight)
    • 合并节点 st 为新的节点 st
      • 对于所有边,如果一端是 st,则改为连接 st
      • 处理可能产生的平行边(将平行边的权重相加)。
  3. 返回 min_cut

时间复杂度

  • 外层循环执行 O(n) 次。
  • 内层寻找最大邻接和序的过程,若使用斐波那契堆维护 w(v, A) 的最大值,可以在 O(m + n log n) 内完成。
  • 因此总时间复杂度为 O(nm + n² log n)

Karger 随机化算法

最后,我们介绍一个非常简单但充满智慧的随机化算法:Karger 算法。虽然它有时会出错,但通过多次运行,我们可以以很高的概率得到正确答案。

基本思想:随机边收缩

算法出人意料地简单:

  1. while 图中节点数 > 2:
    • 随机选择一条边 e
    • 将这条边连接的两个节点 uv 合并(收缩)为一个新节点。
    • 移除自环,合并平行边(权重相加)。
  2. 图中只剩下两个节点 AB。连接 AB 的边的总权重就是本次运行得到的“割”的值。

这个算法的核心在于:如果随机选择的边从未属于真正的全局最小割,那么最后得到的割就是全局最小割。因为收缩操作相当于承诺这条边的两端在最终割的同一侧。

成功概率分析

设全局最小割的大小为 c,图的总边数为 m

  • 在第一次收缩时,随机选到最小割中边的概率 ≤ c / m
  • 可以证明,c ≤ 2m / n(最小割不超过节点的最小度,最小度不超过平均度 2m/n)。
  • 因此,单次收缩不选到最小割边的概率 ≥ 1 - 2/n
  • 算法需要进行 n-2 次收缩。可以推导出,单次 Karger 算法运行成功(找到全局最小割)的概率至少是 1 / C(n, 2) ≈ 2 / n²

这个概率看起来很低。但我们可以通过多次独立运行来提高成功率。

多次运行与时间复杂度

如果我们独立运行 Karger 算法 T 次,并取所有结果中的最小值,那么失败(所有 T 次都没找到真正的最小割)的概率至多是:
(1 - 2/n²)^T ≈ e^(-2T/n²)

如果我们希望失败概率小于 ε,则需要运行 T = O(n² log(1/ε)) 次。
每次运行的时间复杂度为 O(n²)(使用邻接矩阵)或 O(m)(使用适当的数据结构)。
因此,总时间复杂度为 O(T * n²)O(T * m)。当 T = O(n² log n) 时,我们有很高的成功概率,总复杂度约为 O(n⁴ log n)。这比 Stoer-Wagner 算法慢,但思路极其简洁。

Karger-Stein 算法:优化的随机算法

Karger-Stein 算法是对基础 Karger 算法的重大改进,它将成功率从 O(1/n²) 提升到了 O(1/log n),从而将所需运行次数大幅减少。

核心思想:递归收缩

算法采用分治策略:

  1. 如果图 G 的节点数 n 非常小(比如 ≤ 6),则直接使用枚举等暴力方法计算最小割。
  2. 否则:
    • 将图 G 收缩到大约 n / √2 个节点,得到图 G1注意:这里不是一次收缩到 n/√2,而是进行多次随机收缩直到节点数达标。
    • G1 递归调用本算法两次,得到两个结果 cut1cut2
    • 返回 min(cut1, cut2)

成功概率与时间复杂度分析

  • 成功概率:可以证明,通过这种递归方式,算法找到全局最小割的概率约为 1 / log n。这比基础的 1/n² 高得多。
  • 时间复杂度:使用主定理分析,每次递归调用需要 O(n²) 时间进行收缩,递归树深度为 O(log n),每层总工作量也是 O(n²)。因此,单次 Karger-Stein 算法运行的时间复杂度为 O(n² log n)

为了达到更高的总体成功率(如失败概率 < ε),我们需要运行 O(log² n * log(1/ε)) 次 Karger-Stein 算法。
因此,总时间复杂度为 O(n² log³ n * log(1/ε))。这在实际应用中通常比 O(n⁴) 的朴素 Karger 算法快得多。

扩展到带权图

Karger 和 Karger-Stein 算法可以很容易地扩展到边带权重的图。关键在于“随机选择一条边”这一步:我们需要根据边的权重进行加权随机选择。权重越大的边,被选中的概率也成比例地增大。修改后,算法的所有理论保证(如成功概率下界)依然成立。

总结

本节课我们一起学习了全局最小割问题。

  • 我们首先明确了其定义:移除后使图不连通的最小权重边集。
  • 我们分析了它与标准 s-t 最小割问题的区别。
  • 我们介绍了几种算法:
    • 枚举点对法:思路直接,但效率低下(O(n² * F))。
    • 固定源点法:改进枚举,效率有所提升(O(n * F))。
    • Stoer-Wagner 算法:高效的确定性算法,利用最大邻接和序,时间复杂度为 O(nm + n² log n)。
    • Karger 算法:简洁的随机化算法,单次运行成功概率低(O(1/n²)),但思路巧妙。
    • Karger-Stein 算法:Karger 算法的递归改进版,大幅提升了单次成功概率(O(1/log n)),时间复杂度为 O(n² log³ n)。

在实际应用中,Stoer-Wagner 算法是一个可靠且高效的选择。而 Karger-Stein 算法则展示了随机化算法在解决组合优化问题上的独特魅力和强大潜力。

056:线性规划 📊

在本节课中,我们将学习线性规划。线性规划是一类优化问题,对于这类问题,存在一些通用的求解和分析方法。正如你将在本讲中看到的,我们本学期讨论过的一些问题实际上就属于线性规划问题。

什么是线性规划? 🤔

在线性规划中,你求解的是一个优化问题,其中所有的约束条件和目标函数都是线性函数

例如,假设你有 N 个变量:x1, x2, ..., xn。你对这些变量有一些线性约束。例如,像 2*x1 + 3*x2 + 7*x5 <= 10 这样的不等式。你有一系列这样的不等式,它们限制了变量的可能取值集合。然后,你想最大化或最小化某个函数,这个函数也必须是线性的。例如,最大化 x1 + x3 + 4*x5

这里重要的是:你有这些变量,有一些线性不等式,还有一个你想最大化或最小化的线性函数。

课程中的例子 🔍

上一节我们介绍了线性规划的基本形式,本节中我们来看看本课程中哪些问题可以建模成这种形式。

以下是两个例子:

  • 最大流问题:每条边有一个变量 f_uv 表示流量。约束包括 0 <= f_uv <= c_uv(容量约束)和对于每个非源/汇节点,流入等于流出(流量守恒)。这些都是线性(不)等式。目标函数是流出源点的总流量,也是一个线性函数。
  • 最大匹配问题:每条边有一个变量 x_uv,取值为 0 或 1(表示是否在匹配中)。对于每个节点,约束为其相连边的变量之和 <= 1(即每个节点最多匹配一条边)。目标函数是最大化所有 x_uv 之和,即匹配的边数。

这两个问题有一个重要区别:在最大流问题中,我们通常不要求变量必须是整数(如果容量是整数,最优解自然会是整数)。而在最大匹配问题中,我们明确要求变量必须是整数(0 或 1),因为不能取“半条边”。

整数线性规划 vs. 实数线性规划 ⚖️

这引出了两类不同的问题:

  • 线性规划:允许变量取任意实数。这类问题是多项式时间可解的。
  • 整数线性规划:要求变量取整数值。这类问题是NP完全的。

对于某些整数线性规划问题,如果去掉整数限制(即求解对应的实数线性规划),得到的最优解碰巧就是整数解。例如:

  • 最大流问题(容量为整数时):其最优值等于最小割的值,而最小割的值是整数,因此最优流也是整数。
  • 二分图最大匹配问题:可以转化为一个最大流问题,因此最优解也是整数。

但对于非二分图的最大匹配问题,情况则不同。考虑一个三角形图,其最优实数解可能是给每条边赋值 0.5,总值为 1.5,而实际的最大匹配大小是 1。因此,对于这类问题,实数线性规划的解只是原整数问题的一个近似。

线性规划的几何意义 📐

让我们从几何角度理解线性规划。假设我们有两个变量 x1x2,以及一些线性不等式约束。每个不等式在平面上定义了一个半平面。所有这些半平面的交集构成了一个凸多边形

我们的目标函数(例如 c1*x1 + c2*x2)可以看作一个向量 (c1, c2)。我们想在这个多边形内找到一个点,使得该点与目标向量的点积(即投影)最大。

一个关键性质是:最优解总是出现在这个凸多边形的某个顶点上。因为如果你在非顶点处,总可以沿着目标函数值不减少的方向移动,直到碰到边界,然后沿着边界移动,最终到达一个顶点。

在二维情况下,我们可以枚举多边形的所有顶点来找到最优解。但在高维空间(n 维)中,约束定义的区域是一个凸多面体,其顶点数量可能随维度指数增长,因此枚举所有顶点不可行。

标准形式与转换 🔧

为了方便使用通用算法求解,我们通常将线性规划问题转化为标准形式。一种常见的标准形式是:

  • 所有变量非负
  • 所有约束都是等式
  • 目标是最大化一个线性函数。

任何线性规划问题都可以转化为这种形式。以下是转换方法:

  1. 处理无约束变量:如果变量 x 可正可负,用两个非负变量 x+x- 代替,令 x = x+ - x-
  2. 处理不等式约束:对于 <= 约束,添加一个松弛变量 s >= 0,将不等式 a^T x <= b 变为等式 a^T x + s = b。对于 >= 约束,可以乘以 -1 转化为 <=
  3. 处理等式约束:等式约束 a^T x = b 可以直接保留。
  4. 处理最小化目标:将最小化 c^T x 转化为最大化 -c^T x

经过这些转换,我们总能得到标准形式的问题。

对偶问题 🔄

每一个线性规划问题(称为原问题)都有一个对应的对偶问题。它们从不同角度描述了同一个问题。

假设原问题是:

  • 变量 x >= 0
  • 约束 A x <= b
  • 目标:最大化 c^T x

那么它的对偶问题是:

  • 变量 y >= 0
  • 约束 A^T y >= c
  • 目标:最小化 b^T y

弱对偶定理指出,对于任何原问题的可行解 x 和对偶问题的可行解 y,都有 c^T x <= b^T y。即对偶问题的目标值给出了原问题目标值的上界。

强对偶定理指出,如果原问题和对偶问题都有可行解,那么它们的最优值相等。这是一个非常强大的结论。

对偶的例子

  • 最大匹配(原问题)最小顶点覆盖(对偶问题):在二分图中,这两个问题的优化值相等。
  • 最大流(原问题)最小割(对偶问题):根据最大流最小割定理,它们的优化值相等。
  • 指派问题(原问题)势函数调整(对偶问题):匈牙利算法本质上就是在维护原问题(匹配)和对偶问题(节点势能)的可行解,并利用它们之间的关系寻找最优解。

互补松弛条件 ✅

如何判断我们找到的原问题解 x* 和对偶问题解 y* 是否是最优的?一个关键条件是互补松弛条件

x*y* 分别为原问题和对偶问题的最优解时,对于每一个约束,以下两者至少有一个成立(即“松弛”):

  1. 原问题的第 i 个约束取等号(即紧约束)。
  2. 对应对偶变量 y_i 为 0。

用公式表示,对于原问题约束 (A x)_i <= b_i 和对偶变量 y_i,有:
y_i * (b_i - (A x)_i) = 0

这个条件非常实用,因为它将“全局最优”这个大条件,分解成了许多容易验证的“局部”条件。

处理非二分图匹配 🌀

回到非二分图最大匹配问题。我们之前看到,简单的线性规划模型(边变量 + 每个节点的度约束)会产生非整数最优解(如三角形的例子)。

解决方法是为所有奇数大小的顶点子集 U 添加额外的约束:
sum_{u,v in U} x_uv <= (|U| - 1) / 2

这个约束称为奇圈约束。添加了所有这样的约束后,多面体的所有顶点都变成了整数点,从而实数线性规划的解就是整数解。

问题是,这样的约束有指数多个。我们无法显式地写出整个线性规划。

解决思路是利用对偶。在对偶问题中,每个奇圈约束对应一个对偶变量。在算法(如开花算法)运行过程中,只有少数对偶变量(对应当前找到的“花”)是非零的。算法只需动态维护这些非零的对偶变量,而无需处理整个指数级的系统,从而实现了多项式时间的求解。

总结 📝

本节课我们一起学习了线性规划的核心概念:

  1. 定义:目标函数和约束均为线性的优化问题。
  2. 两类问题:实数域上的线性规划(P)和整数线性规划(NP完全)。
  3. 几何直观:可行域是凸多面体,最优解在顶点取得。
  4. 标准形式:为应用通用算法,可将问题转化为变量非负、约束为等式的形式。
  5. 对偶理论:每个原问题都有一个对偶问题,它们的最优值相等(强对偶)。这提供了证明最优性和设计算法(如匈牙利算法、开花算法)的强大工具。
  6. 互补松弛条件:判断最优解的实用准则。
  7. 应用:我们看到了如何将最大流、匹配等问题建模为线性规划,并利用对偶理论理解其内在联系(如最大流-最小割定理)。

线性规划是优化理论的基础,其思想和方法广泛应用于算法设计、经济学、工程管理等众多领域。

057:数论算法入门 🧮

在本节课中,我们将学习数论算法的基础知识。数论是算法理论中一个广阔的领域,虽然我们无法深入探讨所有细节,但本次课程将提供一个简明的入门介绍,涵盖最大公约数、扩展欧几里得算法、中国剩余定理、模运算、素数测试以及因数分解等核心概念。我们将使用简单的语言和示例,确保初学者能够理解。


最大公约数与欧几里得算法 📐

上一节我们介绍了课程概述,本节中我们来看看如何计算两个整数的最大公约数。

最大公约数是指能同时整除两个给定整数的最大正整数。例如,20和64的最大公约数是4。

计算最大公约数有一个古老而简单的算法,称为欧几里得算法。其核心思想基于一个简单的观察:如果数字A和B都能被某个数D整除,那么它们的差 A - B 也能被D整除。这意味着,数对 (A, B)(A, A-B) 拥有完全相同的公约数集合,因此它们的最大公约数也相同。

以下是该算法的递归实现:

def gcd(a, b):
    if b == 0:
        return a
    else:
        return gcd(b, a % b)

算法开始时,我们确保 a >= b。如果 b 为0,则 a 就是最大公约数。否则,我们递归计算 ba 除以 b 的余数的最大公约数。每次递归调用,其中一个数至少减少一半,因此算法的时间复杂度是 O(log(min(a, b))),这是一个多项式时间算法。


扩展欧几里得算法与丢番图方程 🔍

上一节我们学习了如何计算最大公约数,本节中我们来看看如何利用它来求解丢番图方程。

丢番图方程形如 A*x + B*y = C,其中A、B、C是整数系数,我们需要找到整数解x和y。首先,设 d = gcd(A, B)。如果C不能被d整除,则该方程无整数解。如果C能被d整除,我们可以先求解简化方程 A*x + B*y = d

扩展欧几里得算法不仅能计算出最大公约数d,还能找到满足 A*x + B*y = d 的系数x和y。以下是其实现:

def extended_gcd(a, b):
    if b == 0:
        return (a, 1, 0)
    else:
        d, x1, y1 = extended_gcd(b, a % b)
        x = y1
        y = x1 - (a // b) * y1
        return (d, x, y)

得到 (d, x, y) 后,原方程 A*x + B*y = C 的一个特解可以通过将x和y乘以 C/d 得到。该方程存在无穷多组解。


中国剩余定理 🧩

上一节我们解决了线性丢番图方程,本节中我们来看一个处理同余方程组的有力工具——中国剩余定理。

中国剩余定理指出:给定两个互质的正整数n和m(即 gcd(n, m) = 1),那么对于任意整数a(0 <= a < n*m),由它产生的两个余数 a1 = a mod na2 = a mod m 的组合是唯一的。反之,给定任意一对余数 (a1, a2),都存在唯一的一个a(0 <= a < n*m)满足这两个同余式。

如何从 (a1, a2) 反推回a呢?我们可以建立方程:
a = a1 + x*n
a = a2 + y*m

将两式联立,得到 a1 + x*n = a2 + y*m,整理后得到关于x和y的丢番图方程:x*n - y*m = a2 - a1。由于n和m互质,该方程一定有解。我们可以用扩展欧几里得算法求解出x和y,然后代入任一方程即可求出a。

如果n和m不互质,方程组有解的条件是 a2 - a1 能被 gcd(n, m) 整除,并且解在0到 lcm(n, m) - 1(最小公倍数减一)的范围内是唯一的。


模运算的世界 🔢

上一节我们处理了同余方程组,本节中我们系统地看看在模运算下如何进行算术。

模运算将整数限制在一个有限的集合 {0, 1, 2, ..., M-1} 内。我们定义模M下的加法、减法和乘法如下:
(a + b) mod M
(a - b) mod M
(a * b) mod M

这些运算保留了普通整数运算的许多性质,如结合律、交换律和分配律。关键在于,每次运算后我们都取模M,结果始终落在0到M-1之间。

那么除法呢?在模M下,“除以B”意味着寻找一个数 B^{-1}(称为B的模逆元),使得 B * B^{-1} ≡ 1 (mod M)。这等价于求解丢番图方程 B*x + M*y = 1。根据之前的讨论,该方程有解当且仅当 gcd(B, M) = 1,即B与M互质。我们可以再次使用扩展欧几里得算法来求解逆元。

一个重要的特例是当模数M为素数时。因为素数与所有小于它的正整数都互质(除了0),所以在模素数下,我们可以对任何非零元素进行除法运算,这构成了许多密码学算法的基础。


素数测试:从费马到米勒-拉宾 ⚗️

上一节我们进入了模运算的世界,本节中我们探讨一个关键问题:如何判断一个大数是否是素数?

一个简单的方法是试除法:检查从2到 sqrt(N) 的所有整数是否能整除N。如果没有,则N是素数。但这个方法对于非常大的数(如几百位)来说太慢了,因为 sqrt(N) 相对于N的位数(log N)是指数级增长的。

我们需要更快的概率性算法。费马小定理指出:如果p是素数,且a是与p互质的任意整数,那么 a^{p-1} ≡ 1 (mod p)。费马素性测试就是基于此:随机选取一个a,检查 a^{N-1} mod N 是否等于1。如果不等于1,则N一定是合数;如果等于1,则N可能是素数。

但存在一些合数(称为卡迈克尔数),对于大多数与其互质的a,费马测试也会错误地返回“可能是素数”。米勒-拉宾素性测试对此进行了改进。它将 N-1 分解为 2^s * d(其中d是奇数),然后计算序列 a^d, a^{2d}, a^{4d}, ..., a^{2^{s-1}*d} (mod N)。如果N是素数,那么这个序列要么第一个数就是1,要么在出现1之前会出现-1(即 N-1)。如果在出现1之前出现的数不是±1,那么N一定是合数。

理论上,对于合数N,至少75%的a会是“证人”(证明N是合数)。因此,通过随机选择多个a进行测试,我们可以以极高的概率确定N是否为素数。


因数分解与波拉德ρ算法 🎣

上一节我们学习了如何测试素数,本节中我们看看与之相关的难题:如何将一个合数分解为质因数的乘积。

因数分解是许多密码学系统安全性的基石,目前没有已知的多项式时间经典算法。最直接的方法仍是试除到 sqrt(N)。但有一些更快的亚指数级算法,例如波拉德ρ算法。

波拉德ρ算法的核心思想是“生日悖论”:在一个有N个可能值的系统中,随机选取大约 sqrt(N) 个样本后,有很大概率出现重复(碰撞)。算法步骤如下:

  1. 选择一个简单的伪随机函数,例如 f(x) = (x^2 + 1) mod N
  2. 从某个初始值 x0 开始,迭代计算序列 x_{i+1} = f(x_i)
  3. 使用弗洛伊德判圈算法(快慢指针)在这个序列中寻找循环。因为函数值模N是有限的,序列必然会出现循环。
  4. 关键点在于:如果N有一个真因子p,那么序列模p也会形成循环,且这个循环的长度大约为 sqrt(p),它可能远小于序列模N的循环长度。
  5. 当快慢指针在序列中相遇时(即 x_i ≡ x_{2i} (mod p)x_i ≠ x_{2i} (mod N)),它们的差 |x_i - x_{2i}| 将是p的倍数,但不是N的倍数。
  6. 计算 gcd(|x_i - x_{2i}|, N),结果就是N的一个非平凡因子。

该算法的时间复杂度约为 O(N^{1/4}),虽然仍不是多项式时间,但比试除法快得多。


原根及其寻找方法 🔑

上一节我们了解了因数分解的挑战,本节最后我们简要讨论一下原根的概念。

对于一个素数p,原根g是一个整数,使得 g^1, g^2, ..., g^{p-1} (mod p) 这个序列恰好遍历了 1p-1 的所有整数。也就是说,g的幂次在模p下能生成整个乘法群。

原根在密码学中很有用。好消息是,每个素数都有很多原根。要验证一个数g是否是模p的原根,我们不需要检查所有 p-1 个幂次。根据数论知识,如果g不是原根,那么它的阶(最小的正整数k使得 g^k ≡ 1 (mod p))必然是 p-1 的一个真因子。因此,我们只需要检查对于 p-1 的每个质因数q,是否有 g^{(p-1)/q} ≡ 1 (mod p)。如果对于所有质因数q,该等式都不成立,那么g就是原根。

这里的一个实际困难是,我们需要对 p-1 进行质因数分解。对于精心选择的素数(例如 p-1 只有小质因子),这个过程会很快。


本节课中我们一起学习了数论算法的基础。我们从计算最大公约数的欧几里得算法开始,扩展到求解丢番图方程。然后探讨了中国剩余定理和模运算,包括求逆元。接着,我们介绍了费马测试和更强大的米勒-拉宾概率素数测试。最后,我们触及了因数分解的难题,简介了波拉德ρ算法,并了解了原根的概念。这些知识是理解现代密码学和许多高级算法的基础。

058:基础密码学算法

在本节课中,我们将学习密码学的基础概念,并了解如何利用之前学过的数论算法来构建现代密码系统。我们将探讨对称加密、非对称加密(公钥加密)以及数字签名等核心概念,并通过RSA和Diffie-Hellman两个具体协议来理解其背后的数学原理。

什么是密码学?

上一节我们介绍了数论算法,本节中我们来看看如何将它们应用于密码学。密码学旨在解决一个核心问题:如何在不安全的信道上(如互联网)安全地交换信息。

想象有两个人,通常称为Alice和Bob,他们想通过网络交换信息。网络中存在一个攻击者Mallory,他可以监听所有经过网络节点的数据。如果不采取任何措施,Mallory就能读取所有消息。

对称加密:一次性密码本

为了解决这个问题,我们需要对消息进行加密。最简单的方法是对称加密,它要求通信双方共享一个相同的密钥。

以下是其工作原理:

  1. Alice和Bob事先共享一个与消息等长的随机密钥K。
  2. Alice将原始消息M与密钥K进行按位异或(XOR)操作,得到加密消息 M' = M ⊕ K。
  3. Alice将M'发送给Bob。
  4. Bob收到M'后,用相同的密钥K进行解密:M = M' ⊕ K。

对于攻击者Mallory而言,如果他不知道密钥K,那么M'看起来就是一个完全随机的比特序列,他无法从中获取任何关于M的信息。

然而,这种方法存在两个主要问题:

  1. 密钥分发问题:Alice和Bob如何在不安全的信道上安全地共享这个密钥?
  2. 密钥重用问题:同一个密钥只能使用一次。如果重复使用,攻击者通过分析多个加密消息的关联性,可能推断出部分信息。

公钥加密(非对称加密)

对称加密的核心难题是密钥分发。公钥加密通过使用一对密钥而非单个密钥,巧妙地解决了这个问题。

公钥加密的工作原理如下:

  • 每个人生成一对密钥:一个公钥(可以公开给任何人)和一个私钥(必须严格保密)。
  • 用公钥加密的消息,只能用对应的私钥解密。
  • 用私钥加密(签名)的消息,可以用对应的公钥验证。

RSA加密算法

RSA是一种基于大数分解困难性的公钥加密算法。下面我们来看看如何构建RSA系统。

密钥生成:

  1. 选择两个大质数 pq
  2. 计算它们的乘积 n = p * q。n 的长度就是密钥长度。
  3. 计算欧拉函数 φ(n) = (p-1) * (q-1)
  4. 选择一个整数 e,使得 1 < e < φ(n),且 e 与 φ(n) 互质(通常取 65537)。
  5. 计算 d,使得 d * e ≡ 1 (mod φ(n))。即 d 是 e 模 φ(n) 的乘法逆元,可以使用扩展欧几里得算法求得。
  6. 公钥是 (n, e),私钥是 (n, d)

加密过程:
如果Bob想给Alice发送消息M(M是一个小于n且与n互质的整数),他使用Alice的公钥 (n, e) 计算:
密文 C = M^e mod n

解密过程:
Alice收到密文C后,使用自己的私钥 (n, d) 计算:
明文 M = C^d mod n

安全性:
攻击者知道公钥 (n, e) 和密文 C。为了解密,他需要私钥 d。而计算 d 需要知道 φ(n),计算 φ(n) 又需要对 n 进行质因数分解(找出 p 和 q)。对于足够大的 n,质因数分解在计算上是不可行的,这就保证了RSA的安全性。

Diffie-Hellman密钥交换

Diffie-Hellman协议不是直接用于加密消息,而是让双方在不安全的信道上安全地协商出一个共享的对称密钥,解决了对称加密的密钥分发问题。

协议过程:

  1. Alice和Bob公开约定一个大质数 p 和它的一个原根 g
  2. Alice选择一个私密的随机数 a,计算 A = g^a mod p,并将A发送给Bob。
  3. Bob选择一个私密的随机数 b,计算 B = g^b mod p,并将B发送给Alice。
  4. Alice收到B后,计算共享密钥 s = B^a mod p = (gb)a mod p = g^(ab) mod p
  5. Bob收到A后,计算共享密钥 s = A^b mod p = (ga)b mod p = g^(ab) mod p

现在,Alice和Bob拥有了相同的共享密钥s,而窃听者Mallory只能看到p, g, A, B。他想计算出s,就需要从A或B中反推出a或b,这相当于求解离散对数问题,在计算上是困难的。

数字签名

公钥加密还可以用于实现数字签名,其目的是验证消息的来源和完整性,类似于手写签名。

签名过程(以RSA为例):
假设Alice想对消息M进行签名。

  1. Alice计算消息的哈希值 H = Hash(M)
  2. Alice使用自己的私钥 (n, d) 对哈希值进行“加密”:签名 S = H^d mod n
  3. Alice将消息M和签名S一起发送给Bob。

验证过程:
Bob收到消息M和签名S后:

  1. Bob使用Alice的公钥 (n, e) 对签名进行“解密”:H' = S^e mod n
  2. Bob自己计算收到消息M的哈希值:H = Hash(M)
  3. 如果 H' == H,则证明签名有效,消息确实来自Alice且未被篡改。

中间人攻击与证书

公钥加密虽然解决了密钥分发,但引入了新的问题:身份认证。攻击者Mallory可以进行中间人攻击:

  1. Alice想与Bob通信,向Bob索要公钥。
  2. Mallory截获请求,将自己的公钥发送给Alice,同时冒充Alice向Bob索要公钥。
  3. Bob将自己的公钥发送给“Alice”(实际上是Mallory)。
  4. 现在,Alice以为Mallory的公钥是Bob的,Bob以为Mallory的公钥是Alice的。Mallory可以解密双方的消息,阅读后再用正确的公钥加密转发,而通信双方毫无察觉。

为了解决这个问题,需要引入可信的第三方——证书颁发机构

  • CA用自己的私钥为网站的公钥签名,生成数字证书
  • 用户的设备(如浏览器)内置了受信任的CA的公钥。
  • 当用户访问网站时,网站会发送其证书。用户设备用内置的CA公钥验证证书签名,从而确信该公钥确实属于所要访问的网站。

实践中的注意事项

理论上的安全算法在实现时可能因为细节问题变得不安全。

侧信道攻击:
攻击者不直接攻击算法本身,而是通过测量执行时间、功耗、电磁辐射等“侧信道”信息来推断密钥。例如,在RSA解密运算 C^d mod n 中,如果实现代码在遇到私钥d的某一位为1时才执行一次乘法,那么通过精确测量解密时间,攻击者就可能逐步推算出私钥d的每一位。

低加密指数攻击:
在RSA中,为了提升加密速度,公钥e常取一个小值(如3)。如果同一个消息M用不同的模数n1, n2, n3但相同的e=3加密,即:
C1 = M^3 mod n1
C2 = M^3 mod n2
C3 = M^3 mod n3
根据中国剩余定理,攻击者可以计算出 M^3 mod (n1*n2*n3)。由于M小于每个n,M^3 也小于 n1*n2*n3,因此攻击者可以直接对计算结果开三次方根得到M。解决方案是在加密前对消息进行填充,加入随机数据,确保每次加密的“消息”都不同。


本节课中我们一起学习了密码学的基础。我们从简单的对称加密及其局限性出发,引出了公钥加密的概念。我们深入探讨了基于大数分解的RSA算法和基于离散对数的Diffie-Hellman密钥交换协议,并了解了如何利用它们进行加密和数字签名。最后,我们讨论了中间人攻击的威胁及其通过CA证书的解决方案,并指出了理论算法在实践应用中需要注意的侧信道攻击等问题。密码学是构建安全数字世界的基石,理解这些基本原理至关重要。

059:快速傅里叶变换

在本节课中,我们将要学习快速傅里叶变换。这是一种用于快速计算多项式乘法的算法,其时间复杂度远低于传统的平方级算法。我们将从整数乘法的问题引入,探讨如何通过多项式乘法来加速计算,并详细讲解快速傅里叶变换的原理和实现步骤。

问题引入:整数乘法

假设你有两个大整数 AB,每个整数的长度都是 n 位。你想要计算它们的乘积 C = A * B

如果使用传统的竖式乘法,你需要将 B 的每一位数字与整个整数 A 相乘,然后将所有中间结果相加。对于长度为 n 的整数,这需要 O(n^2) 的时间复杂度。

一个有趣的问题是:能否以低于 O(n^2) 的时间复杂度来计算两个大整数的乘积?答案是肯定的。

从整数到多项式

首先,让我们将问题从整数乘法转换为多项式乘法,因为后者在概念上更容易处理。

假设有两个多项式 A(x)B(x),它们的次数都是 n-1
A(x) = a0 + a1*x + a2*x^2 + ... + a_{n-1}*x^{n-1}
B(x) = b0 + b1*x + b2*x^2 + ... + b_{n-1}*x^{n-1}

我们想要计算它们的乘积多项式 C(x) = A(x) * B(x)。乘积多项式 C(x) 的次数将是 2n-2,其形式为:
C(x) = c0 + c1*x + c2*x^2 + ... + c_{2n-2}*x^{2n-2}

如何找到系数 c_i 呢?每个系数 c_i 是所有满足 j + k = ia_j * b_k 之和:
c_i = Σ_{j=0}^{i} a_j * b_{i-j}

如果直接计算这个求和,对于每个 c_i 需要 O(n) 次操作,总共有 O(n) 个系数,因此总时间复杂度仍然是 O(n^2)

我们的目标是找到一种方法,能够比 O(n^2) 更快地计算这些系数和。

分治算法:Karatsuba算法

在深入快速傅里叶变换之前,我们先看一个更简单的分治算法:Karatsuba算法。它展示了如何通过减少递归调用的次数来改进多项式乘法。

其核心思想是将每个多项式分成两半。设:
A(x) = A1(x) + x^{n/2} * A2(x)
B(x) = B1(x) + x^{n/2} * B2(x)

那么乘积 C(x) 可以表示为:
C(x) = A1*B1 + x^{n/2}*(A1*B2 + A2*B1) + x^n*(A2*B2)

如果直接递归计算这四个乘积 A1*B1, A1*B2, A2*B1, A2*B2,我们会有四个规模为 n/2 的子问题。递归深度为 log_2(n),总调用次数为 4^{log_2(n)} = n^2,时间复杂度仍是 O(n^2)

Karatsuba算法的巧妙之处在于,我们并不需要分别计算 A1*B2A2*B1,只需要它们的和。我们可以通过计算 (A1 + A2) * (B1 + B2) 来得到这个和:
(A1 + A2)*(B1 + B2) = A1*B1 + A1*B2 + A2*B1 + A2*B2

然后,从这个结果中减去我们已经计算好的 A1*B1A2*B2,就得到了 A1*B2 + A2*B1

因此,我们只需要进行三次递归调用:计算 A1*B1A2*B2(A1+A2)*(B1+B2)。时间复杂度变为 O(n^{log_2(3)}) ≈ O(n^{1.585}),比 O(n^2) 更快。

快速傅里叶变换的核心思想

快速傅里叶变换提供了另一种更快的多项式乘法方法,其目标是在 O(n log n) 时间内完成计算。

其基本思路分为三步:

  1. 求值:选取一组特殊的点 x_0, x_1, ..., x_{m-1},分别计算多项式 A(x)B(x) 在这些点上的值。
  2. 点乘:计算乘积多项式 C(x) 在这些点上的值。由于 C(x_i) = A(x_i) * B(x_i),这一步只需将第一步得到的对应值相乘即可,时间复杂度为 O(n)
  3. 插值:根据 C(x)m 个点上的值,重构出 C(x) 的系数。

这三步中,第二步是最简单的。难点在于第一步(求值)和第三步(插值)如何高效完成。如果直接计算每个点的值,每个点需要 O(n) 时间,m 个点就是 O(n^2),没有改进。

快速傅里叶变换的关键在于精心选择这组点,并利用这些点的特殊性质,使得求值和插值都能在 O(n log n) 时间内完成。

单位根与点的选择

为了使算法高效,我们需要两个条件:

  1. 多项式系数的数量 n 是 2 的幂次。如果不是,可以通过在末尾补零系数来扩展到最近的 2 的幂次。
  2. 选取的点是单位根的幂次。

什么是单位根?它是一个复数 ω,满足 ω^n = 1,并且它的幂次 ω^0, ω^1, ..., ω^{n-1} 互不相同。在复平面上,n 次单位根均匀分布在单位圆上。通常我们取:
ω = e^{2πi / n} = cos(2π/n) + i * sin(2π/n)

我们选取的点就是这些单位根:x_k = ω^k (k = 0, 1, ..., n-1)。

这些点的特殊性质在于:

  • ω^{k + n} = ω^k (周期性)
  • (ω^k)^2 = ω^{2k} = ω^{(2k) mod n} (平方后仍在单位根集合中)
  • n 为偶数时,ω^{k + n/2} = -ω^k

这些性质是快速傅里叶变换能够进行分治的基础。

离散傅里叶变换

第一步的求值过程,即计算多项式 A(x) 在所有 n 次单位根上的值,被称为离散傅里叶变换

设多项式 A(x) = a0 + a1*x + ... + a_{n-1}*x^{n-1}。DFT 将系数向量 (a0, a1, ..., a_{n-1}) 变换为值向量 (A(ω^0), A(ω^1), ..., A(ω^{n-1}))

我们可以用分治法高效计算 DFT。将多项式 A(x) 按奇偶次项拆分为两个小多项式:
A_even(x) = a0 + a2*x + a4*x^2 + ... (包含所有偶数下标系数)
A_odd(x) = a1 + a3*x + a5*x^2 + ... (包含所有奇数下标系数)

那么原多项式可以表示为:
A(x) = A_even(x^2) + x * A_odd(x^2)

现在,我们要计算 A(x) 在点 ω^0, ω^1, ..., ω^{n-1} 上的值。注意到:
A(ω^k) = A_even((ω^k)^2) + ω^k * A_odd((ω^k)^2)
A(ω^{k + n/2}) = A_even((ω^{k + n/2})^2) + ω^{k + n/2} * A_odd((ω^{k + n/2})^2)

由于 (ω^{k + n/2})^2 = ω^{2k + n} = ω^{2k} = (ω^k)^2,并且 ω^{k + n/2} = -ω^k,我们有:
A(ω^{k + n/2}) = A_even((ω^k)^2) - ω^k * A_odd((ω^k)^2)

观察这两个公式:
A(ω^k) = A_even((ω^k)^2) + ω^k * A_odd((ω^k)^2)
A(ω^{k + n/2}) = A_even((ω^k)^2) - ω^k * A_odd((ω^k)^2)

关键在于,我们只需要计算 A_evenA_odd(ω^0)^2, (ω^1)^2, ..., (ω^{n/2 -1})^2n/2 个点上的值。而这 n/2 个点恰好就是 n/2 次单位根的集合!

因此,计算规模为 n 的 DFT 问题,被归约为两个规模为 n/2 的 DFT 问题(分别计算 A_evenA_odd 的 DFT),再加上 O(n) 的合并操作。这给出了递归式 T(n) = 2T(n/2) + O(n),根据主定理,其解为 T(n) = O(n log n)

以下是 DFT 的递归伪代码描述:

def FFT(a, n, ω):
    # a: 系数数组 [a0, a1, ..., a_{n-1}]
    # n: 数组长度,必须是2的幂
    # ω: n次主单位根
    if n == 1:
        return a  # 对于常数多项式,其值就是系数本身
    # 按奇偶拆分系数
    a_even = [a[0], a[2], ..., a[n-2]]
    a_odd = [a[1], a[3], ..., a[n-1]]
    # 递归计算
    y_even = FFT(a_even, n/2, ω^2)
    y_odd = FFT(a_odd, n/2, ω^2)
    # 合并结果
    y = array of size n
    for k in range(n/2):
        t = ω^k * y_odd[k]
        y[k] = y_even[k] + t
        y[k + n/2] = y_even[k] - t
    return y # 返回A(x)在ω^0, ω^1, ..., ω^{n-1}处的值

逆离散傅里叶变换

第三步的插值过程,即从多项式 C(x) 在单位根上的值恢复其系数,被称为逆离散傅里叶变换

令人惊奇的是,逆 DFT 与 DFT 的计算过程几乎完全相同。如果 DFT 是将系数转换为点值:
y_k = A(ω^k) = Σ_{j=0}^{n-1} a_j * (ω^k)^j

那么逆 DFT 的公式为:
a_j = (1/n) * Σ_{k=0}^{n-1} y_k * (ω^{-k})^j

比较两个公式,逆 DFT 相当于:

  1. 将 DFT 中的单位根 ω 替换为其倒数 ω^{-1}
  2. 最后将结果除以 n

因此,我们可以复用 FFT 的代码来计算逆 FFT,只需将单位根参数改为 ω^{-1},并在最后将每个结果除以 n

算法总结与步骤

现在,我们可以总结使用 FFT 进行多项式乘法的完整步骤:

  1. 扩充:给定两个多项式 A(x)B(x),其次数分别为 n-1m-1。设 N 为大于等于 (n+m-1) 的最小的 2 的幂。将 AB 的系数用零填充到长度 N
  2. 求值:计算 AB 的 DFT。
    • 选择 N 次主单位根 ω = e^{2πi / N}
    • 调用 FFT 算法,计算 DFT(A)DFT(B),得到两个长度为 N 的数组 A_valsB_vals
  3. 点乘:逐点相乘得到 C 的点值。
    • C_vals[i] = A_vals[i] * B_vals[i],对于 i = 0, 1, ..., N-1
  4. 插值:从点值恢复系数。
    • 计算 C_vals 的逆 DFT。
    • 选择 ω^{-1} 作为单位根调用 FFT 算法(或使用专门的逆 FFT 函数)。
    • 将得到的结果数组的每个元素除以 N
    • 得到的数组就是乘积多项式 C(x) 的系数。由于我们进行了扩充,可能需要截断掉最高次项之后多余的零系数(但保留 n+m-1 个系数)。

整个算法的时间复杂度为 O(N log N),其中 N = O(n+m)

模数下的FFT

在实际编程竞赛中,我们通常在模素数 P 下进行计算,以避免浮点数的精度问题。此时,我们需要在模 P 的意义下找到“单位根”。

这要求我们找到一个数 g(模 P 的原根),使得 g^{P-1} ≡ 1 (mod P),并且 g 的幂次能生成模 P 的所有非零剩余。然后,如果我们需要长度为 N 的变换,且 N 是 2 的幂,我们必须要求 (P-1) 能被 N 整除。这样,我们令 ω = g^{(P-1)/N},则 ω 在模 P 下就具有了 N 次单位根的性质(ω^N ≡ 1,且其幂次互不相同)。

因此,常用的模数如 998244353 = 119 * 2^23 + 11004535809 = 479 * 2^21 + 1,它们的 P-1 都包含大的 2 的幂因子,非常适合做 FFT(也称为 NTT,数论变换)。

蝴蝶操作与迭代实现

上面展示的是递归形式的 FFT,易于理解但效率较低。在实际实现中,通常使用迭代版本。迭代版本基于位逆序置换蝴蝶操作

  • 位逆序置换:递归 FFT 的第一层将偶数下标和奇数下标的元素分开,这等价于根据二进制表示的最低比特进行分组。递归下去,最终输入数组的顺序恰好是其下标二进制表示的逆序。迭代实现首先将数组按位逆序排列。
  • 蝴蝶操作:这是合并两个子问题结果的核心操作,对应于递归代码中的 y[k] = y_even[k] + ω^k * y_odd[k]y[k + n/2] = y_even[k] - ω^k * y_odd[k]。迭代实现通过多层循环来模拟递归的合并过程。

迭代实现避免了递归调用栈的开销,并且访问内存更连续,效率更高。

总结

本节课中,我们一起学习了快速傅里叶变换。

  • 我们从大整数乘法的问题出发,将其转化为多项式乘法问题。
  • 我们了解了 Karatsuba 分治算法如何通过减少递归次数来优化乘法。
  • 我们深入探讨了快速傅里叶变换的核心思想:通过精心选择求值点(单位根),将系数表示与点值表示相互转换,并利用点值相乘的简便性,以及转换过程 O(n log n) 的高效性,来实现 O(n log n) 的多项式乘法。
  • 我们详细推导了离散傅里叶变换及其逆变换的分治算法原理。
  • 我们简要介绍了在模素数下使用原根来模拟单位根的数论变换。
  • 最后,我们提到了高效迭代实现的思路。

快速傅里叶变换是算法领域中一个非常强大且优美的工具,它不仅用于多项式乘法和大数乘法,还在信号处理、数据压缩等众多领域有广泛应用。

060:近似算法

在本节课中,我们将要学习近似算法。我们将探讨什么是近似算法,为什么需要它们,并通过几个经典问题(如顶点覆盖、旅行商问题和背包问题)来了解如何构建近似算法。

什么是近似算法

有些问题我们不知道如何精确求解,例如 NP 难问题。对于这些问题,我们不知道是否存在多项式时间的精确解法。科学家们在无法直接解决某个问题时,会转而寻找并解决另一个相关的问题。

对于 NP 难问题,我们通常希望最小化某个目标函数,例如寻找最短路径或最小包装。我们无法在多项式时间内找到最优解,但可以尝试找到一个“足够好”的解。

近似算法的定义

假设存在一个需要最小化目标函数的问题。设最优解的目标函数值为 OPT。如果我们找到一个解,其目标函数值 ALG 满足 ALG ≤ α · OPT(其中 α ≥ 1),那么我们称这个算法为 α-近似算法

  • α = 1.01:意味着我们找到的解最多比最优解差 1%。
  • α = 2:意味着我们找到的解最多是最优解的两倍。
  • α 可能是函数:有时 α 不是常数,而是问题规模 n 的函数,例如 log(n)。这意味着问题规模越大,近似解的质量可能越差。

近似算法的质量因问题而异,没有统一的构建方法。接下来,我们将通过具体问题来学习如何构建近似算法。

顶点覆盖问题

我们首先从一个简单问题开始:最小顶点覆盖问题

给定一个图,我们需要找到一个最小的顶点集合,使得图中的每一条边都至少有一个端点在这个集合中。这是一个 NP 难问题(对于非二分图)。我们将为其构建一个 2-近似算法。

以下是构建近似算法的步骤:

  1. 初始化一个空的顶点覆盖集合 C
  2. 当图中还存在未被 C 覆盖的边时:
    • 任意选择一条未被覆盖的边 (u, v)
    • 将它的两个端点 uv 都加入集合 C
  3. 算法结束,C 即为所求的顶点覆盖。

算法分析
假设算法在循环中选择了 k 条边。那么最终集合 C 的大小为 2k
对于这 k 条被选中的边,在任意一个顶点覆盖(包括最优覆盖)中,每条边至少需要一个端点。因此,最优解 OPT 的大小至少为 k
由此可得:ALG = 2k ≤ 2 · OPT。所以这是一个 2-近似算法

上一节我们介绍了一个简单的贪心策略,得到了顶点覆盖问题的 2-近似解。本节中我们来看看如何应对更复杂的带权顶点覆盖问题。

带权顶点覆盖问题

在带权顶点覆盖问题中,每个顶点都有一个权重 w(v)。目标不再是最小化顶点数量,而是最小化所选顶点集合的总权重。

我们可以使用线性规划舍入法来构建一个 2-近似算法。

1. 建立整数线性规划模型
为每个顶点 v 引入一个 0-1 变量 x_vx_v = 1 表示顶点 v 被选中。
目标:最小化 ∑ w(v) * x_v
约束:对于每条边 (u, v),要求 x_u + x_v ≥ 1

2. 松弛为线性规划
x_v ∈ {0, 1} 松弛为 0 ≤ x_v ≤ 1。这是一个普通的线性规划问题,可以在多项式时间内求解。设其最优解为 x_v*

3. 舍入获得整数解
对松弛后的解进行舍入:x‘_v = 1 如果 x_v* ≥ 0.5,否则 x‘_v = 0

算法分析

  • 可行性:对于任意边 (u, v),由于 x_u* + x_v* ≥ 1,则 x_u*x_v* 中至少有一个 ≥ 0.5。舍入后,该边对应的 x‘_ux‘_v 中至少有一个为 1。因此舍入后的解是可行的顶点覆盖。
  • 近似比:对于每个顶点,由于只有当 x_v* ≥ 0.5 时才将其设为 1,因此有 x‘_v ≤ 2 * x_v*。将所有顶点的权重相加,得到总权重 ALG ≤ 2 · (LP最优值)。而线性规划松弛后的最优值 LP 是原整数规划最优值 OPT 的下界(因为放松了约束)。所以 ALG ≤ 2 · LP ≤ 2 · OPT

因此,这是一个 2-近似算法。这种方法(线性规划+舍入)是构建近似算法的通用且强大的技巧。

旅行商问题

旅行商问题要求访问图中所有城市恰好一次并回到起点,使得总路程最短。这是一个经典的 NP 难问题。更糟的是,对于一般的旅行商问题,不存在任何常数倍的近似算法(除非 P=NP)。

证明概要:如果存在一个 α-近似算法,我们可以用它来解决哈密顿路径问题(另一个 NP 难问题)。构造一个完全图,原图中存在的边权重设为 0,不存在的边权重设为 1。如果原图存在哈密顿路径,则 TSP 最优解为 0,近似算法也必须返回 0;否则最优解至少为 1。这样我们就用 TSP 的近似算法精确判断了哈密顿路径是否存在,这与哈密顿路径是 NP 难的事实矛盾。

然而,如果问题满足三角不等式(即对于任意三点 u, v, w,有 c(u, v) ≤ c(u, w) + c(w, v)),情况就不同了。满足三角不等式的 TSP 称为度量 TSP。

度量 TSP 的 2-近似算法

我们可以构建一个基于最小生成树的 2-近似算法。

算法步骤

  1. 在图 G 上构造一棵最小生成树
  2. 对 MST 进行深度优先遍历,记录下访问节点的顺序(允许重复访问)。这个遍历路径的长度恰好是 MST 边权总和的两倍
  3. 根据 DFS 遍历的顺序,跳过已经访问过的城市,直接前往序列中下一个未访问的城市,从而形成一个哈密顿回路。由于三角不等式,这种“抄近路”不会增加总长度。

算法分析

  • 最优 TSP 回路去掉一条边后,是一条访问所有城市的路径,它构成了图的一棵生成树。因此,MST 的总权值 ≤ OPT
  • 我们的算法得到的回路长度 ALG ≤ 2 · MST ≤ 2 · OPT

因此,这是一个 2-近似算法

度量 TSP 的 1.5-近似算法

我们可以做得更好,获得一个 1.5-近似算法(Christofides 算法)。

算法步骤

  1. 构造图 G最小生成树
  2. O 为 MST 中所有度数为奇数的顶点的集合。可以证明 |O| 是偶数。
  3. 在由集合 O 诱导的子图上,构造一个最小权完美匹配
  4. 将 MST 和这个完美匹配的边合并,得到一个所有顶点度数均为偶数的图(欧拉图)。
  5. 在这个欧拉图中找到一个欧拉回路。
  6. 与之前一样,在欧拉回路中“抄近路”跳过已访问节点,得到哈密顿回路。

算法分析

  • 设 MST 权值为 W_mst,匹配权值为 W_match。最终回路长度 ALG ≤ W_mst + W_match
  • 我们已经知道 W_mst ≤ OPT
  • 考虑最优 TSP 回路。将 O 中的奇度顶点按回路顺序排列,可以形成两个互不相交的完美匹配。其中权值较小的那个匹配的权值 ≤ OPT / 2。而我们找到的是最小权完美匹配,因此 W_match ≤ OPT / 2
  • 综合可得:ALG ≤ OPT + OPT/2 = 1.5 · OPT

因此,这是一个 1.5-近似算法。在很长一段时间里,这都是度量 TSP 最好的近似比。

背包问题

背包问题:有 n 件物品,每件物品有重量 w_i 和价值 v_i,背包容量为 S。目标是在不超过背包容量的前提下,最大化所选物品的总价值。这也是一个 NP 难问题。

一个简单的 2-近似算法

首先尝试一个贪心算法:按价值重量比 v_i / w_i 降序排列物品,然后依次尝试放入背包。但这个算法本身可以任意差。

改进的 2-近似算法

  1. 运行上述贪心算法,得到解 Greedy
  2. 找出价值最高的单件物品,其价值为 V_max
  3. 输出 max(Greedy, V_max)

算法分析(思路)
考虑物品可分割的背包问题(分数背包),此时贪心算法是最优的。设原问题最优解为 OPT,分数背包最优解为 OPT_frac。显然 OPT ≤ OPT_frac
在分数背包的贪心解中,最后一件(可能只取了一部分)物品之前的所有物品构成了原问题贪心解 Greedy。最后那件(部分)物品的价值 ≤ V_max。因此有 OPT_frac ≤ Greedy + V_max
结合 OPT ≤ OPT_frac 和输出是 GreedyV_max 的较大者,可以证明 Output ≥ OPT / 2。因此这是一个 2-近似算法

多项式时间近似方案

对于背包问题,我们有更强的结果:对于任意小的 ε > 0,都存在一个算法,可以在多项式时间内找到一个解,其价值至少为 (1 - ε) * OPT。这称为多项式时间近似方案

思路一(理论意义大于实用)

  1. 猜测一个接近最优解的值 A(例如,先用 2-近似算法得到一个解)。
  2. 将物品分为“大件”(价值 > εA)和“小件”。
  3. 大件物品在最优解中最多有 1/ε 个。枚举所有可能的大件物品组合(最多 n^(1/ε) 种)。
  4. 对于每种大件组合,用贪心法尽量加入小件。
  5. 输出所有枚举结果中的最优解。
    这个方法的时间复杂度是 O(n^(1/ε)),当 ε 很小时非常慢。

思路二(更高效的方法)
利用动态规划,但针对修改后的价值进行。

  1. V_max 为最大物品价值。
  2. 对每件物品,定义其“缩放价值” v‘_i = floor( v_i / (ε * V_max / n) )。这样所有 v‘_i 都是不超过 n/ε 的整数。
  3. 对缩放后的价值 v‘_i 和原重量 w_i,运行标准的基于价值的动态规划算法(状态 dp[x] 表示达到总缩放价值 x 所需的最小重量)。
  4. 动态规划找到的最优缩放价值对应的原物品集合,即为我们的近似解。

算法分析

  • 动态规划的时间复杂度为 O(n * ∑ v‘_i) = O(n * (n * (n/ε))) = O(n^3 / ε),是 1/ε 的多项式,而不是指数。
  • 设最优解集合为 O*,我们的解集合为 A。由于缩放是向下取整,对于 O* 中的物品,有 ∑ v_i ≤ ∑ (v‘_i * (ε V_max / n)) + n * (ε V_max / n)。第二项是舍入误差,总和不超过 ε * V_max
  • V_max ≤ OPT。我们的算法找到了缩放价值下的最优解,因此 ∑ v_i(A) ≥ ∑ v_i(O*) - ε * OPT
  • 所以,ALG ≥ OPT - ε * OPT = (1 - ε) * OPT

这是一个真正高效的多项式时间近似方案。

总结

本节课中我们一起学习了近似算法。

  • 我们了解了近似算法的定义和动机,用于处理难以精确求解的优化问题。
  • 我们学习了三种构建技巧:
    1. 简单贪心与构造:如顶点覆盖问题的 2-近似算法。
    2. 线性规划舍入:如带权顶点覆盖问题的 2-近似算法,这是一个通用性强的方法。
    3. 利用问题结构寻找下界:如度量 TSP 中利用最小生成树和最小权匹配来逼近最优解。
  • 我们看到了问题性质对近似性的巨大影响:一般 TSP 无法近似,而满足三角不等式的 TSP 可以有 1.5 近似算法。
  • 我们探讨了近似算法的强度差异,从常数倍近似(如 2 倍、1.5 倍)到可以无限接近最优解的多项式时间近似方案(如背包问题)。
    近似算法是理论计算机科学和实际应用中处理难解问题的重要工具,针对不同问题需要设计不同的巧妙策略。

061:并行算法基础与经典操作

在本节课中,我们将学习并行算法的基本概念、模型以及一些经典并行操作的实现方法。我们将从现代计算机为何需要并行计算开始,逐步深入到并行计算模型和具体算法设计。

并行计算概述

现代计算机都是并行计算机。即使是在你的笔记本电脑中,处理器也不是单核的,而是多核的。处理器包含多个核心,它们可以同时进行计算。

这主要是因为我们已经接近了处理器速度的物理极限。处理器速度通常以每秒执行的指令数来衡量,例如从100兆赫兹发展到现在的数吉赫兹。我们无法拥有远高于3吉赫兹的处理器,是因为受到了物理定律的限制。

在物理学中,信号传递速度不能超过光速。对于一个3吉赫兹的处理器,每秒执行30亿次操作。光速约为每秒3亿米。因此,处理器执行一次操作的时间内,光只能传播约10厘米。而处理器的尺寸只有几厘米,所以无法让处理器速度比现在快太多。

因此,现代处理器不再主要提升每秒操作次数,而是增加核心数量。现代处理器通常有4核、8核等。GPU则包含更多小型处理器。我们通过增加可以同时工作的核心数量,而不是提升单核速度,来提高计算速度。

学习如何让算法在并行处理器上工作是非常有趣的,这就是我们今天要讨论的内容。

并行计算模型:PRAM

首先,我们来讨论并行计算模型。我们将使用PRAM模型。

PRAM代表并行随机存取存储器。在这个模型中,我们有一个共享内存和一定数量的处理器。这些处理器可以同时访问这个内存。

算法看起来是这样的:所有处理器同时执行一些操作。每个处理器执行一系列指令。我们关心的是所有处理器完成操作所需的最长时间,记为 T(P),即P个处理器解决问题所需的时间。

关于内存访问,情况比单处理器时更复杂。当多个处理器试图访问内存的同一单元时会发生什么,这取决于具体的模型规则。

在一些模型中,我们只允许每个内存单元在同一时刻只能被一个处理器访问。这被称为独占读/独占写模型。这意味着每个内存单元在同一时刻只能被一个处理器读取或写入。

在其他模型中,我们允许同一内存单元被多个处理器同时读取,这被称为并发读。读取内存通常不是大问题,因为多个处理器读取同一位置会得到相同的结果。

更有趣的是当多个处理器试图同时写入同一内存单元时的情况,这被称为并发写。此时会发生什么取决于模型。在某些模型中,最终结果可能是任意一个处理器试图写入的值。在另一些模型中,只有ID最小的处理器能成功写入。

不同模型之间的差异最多是一个对数因子。如果一个算法能在一种模型中实现,通常也能在另一种模型中实现,只是时间复杂度会乘以一个对数因子。虽然对数因子有时很重要,但对于我们今天讨论的算法,这个差异不大。

PRAM模型虽然清晰,但在实际中实现算法可能比较复杂,因为需要手动将不同操作分配给不同处理器并跟踪其状态。

工作-深度模型

因此,我们有时会使用一个稍有不同的模型:工作-深度模型。

想象你需要执行的所有操作构成一个有向无环图。图中的每个节点代表一个可以在常数时间内完成的基本操作。箭头表示操作之间的依赖关系,即一个操作必须在其前驱操作完成后才能开始。

如果我们只有一个处理器,那么执行所有操作所需的总时间就是图中节点的总数,我们称之为工作总量,记为 W

如果我们有无限多的处理器,那么执行所有操作所需的最短时间取决于图中最长的依赖路径。这条路径的长度被称为深度,记为 D。深度代表了算法内在的串行部分,无法通过增加处理器来加速。

工作 W 和深度 D 这两个值足以估算任何数量处理器下的时间复杂度。假设我们有P个处理器和一个能最优分配任务给处理器的自动调度器。

执行时间 T(P) 至少受两个因素限制:

  1. 它不能小于深度 D
  2. 它不能小于总工作量 W 除以处理器数量 P

因此,T(P) ≥ max(D, W/P)。实际上,这个下界是可以接近达到的。我们可以通过按层执行操作来设计算法,使得 T(P) = O(D + W/P)

在实际应用中,处理器数量P通常远小于输入规模n。因此,我们通常首先优化工作量 W,使其与单处理器算法的工作量相同(即保持算法的高效性),然后尽可能减小深度 D,理想情况下达到 O(log n)O(log² n)

基础并行操作

接下来,我们看看如何对一些基础操作进行并行化。

1. Map 操作

Map操作对一个数组的所有元素应用同一个函数。例如,给定数组A,生成数组B,其中 B[i] = f(A[i])

由于每个元素的计算是独立的,我们可以用多个处理器同时计算所有 f(A[i])。如果处理器数量足够,深度可以是 O(1)。在实际的 fork-join 模型中,可能需要 O(log n) 的深度来创建足够的并行任务。工作量显然是 O(n)

2. Reduce 操作

Reduce操作计算一个数组在某个可结合运算符下的累积结果,例如求和、求最小值等。

串行算法需要 O(n) 时间。并行算法可以采用类似构建线段树的方法:

  1. 将相邻元素两两相加,得到 n/2 个部分和。
  2. 再将这 n/2 个部分和两两相加,得到 n/4 个部分和。
  3. 重复此过程,直到得到最终结果。

每一层的操作都可以并行执行。总共有 O(log n) 层,总工作量(节点数)为 n + n/2 + n/4 + ... = O(n)。因此,这是一个工作量为 O(n),深度为 O(log n) 的优秀并行算法。

3. Scan (前缀和) 操作

Scan操作计算数组的所有前缀和。例如,输入 [3, 5, 2, 6],输出 [3, 8, 10, 16]

串行算法是顺序的,深度为 O(n)。并行算法再次借助“线段树”:

  1. 上行阶段:像Reduce操作一样,自底向上构建线段树,计算每个区间的和。
  2. 下行阶段:从根节点开始,向下传递“左侧前缀和”信息。
    • 对于左子节点,继承父节点传来的前缀和。
    • 对于右子节点,前缀和 = 父节点传来的前缀和 + 左兄弟节点的区间和。

最终,每个叶子节点得到的就是其对应的前缀和。所有同层操作可并行。总工作量 O(n),深度 O(log n)

4. Filter 操作

Filter操作根据条件筛选数组元素。例如,筛选出所有偶数。

并行算法步骤如下:

  1. Map:对每个元素应用条件函数,得到一个布尔数组(1表示保留,0表示丢弃)。工作量 O(n),深度 O(1)
  2. Scan:计算该布尔数组的前缀和。工作量 O(n),深度 O(log n)。前缀和数组的值减1(或第一个元素特殊处理)就代表了每个被保留元素在结果数组中的目标位置。
  3. Scatter:并行地将每个被保留的元素写入结果数组的对应位置。工作量 O(n),深度 O(1)

总工作量为 O(n),深度为 O(log n)

并行归并排序

最后,我们探讨如何并行化归并排序。归并排序的主要步骤是合并两个已排序的数组。

简单的想法是为结果数组的每个元素,并行地在另一个数组中二分查找其应插入的位置。这样,合并操作的工作量是 O(n log n)(n个元素,每个二分查找 O(log n)),深度是 O(log n)(所有二分查找并行)。在整个归并排序中,有 O(log n) 层合并,总工作量变为 O(n log² n),深度为 O(log² n)。这比串行算法 O(n log n) 更差。

我们需要一个工作量为 O(n),深度为 O(log n) 的合并算法。这里介绍一种基于分块的优化方法:

  1. 将两个待合并数组分别划分为大小为 O(log n) 的块。
  2. 对于每个数组的块边界元素,在另一个数组中并行地执行二分查找,确定其应插入的位置。这需要 O(n / log n) 次二分查找,每次 O(log n),总工作量 O(n),深度 O(log n)
  3. 经过步骤2,我们得到了两组块之间的对应关系。每一对对应的块(可能大小不均)合并后,可以保证每个块的大小不超过 O(log n)
  4. 现在,我们可以将每一对小块分配给一个单独的处理器进行串行合并。因为每个小块大小为 O(log n),串行合并只需 O(log n) 时间,并且所有处理器可以并行工作。

最终,合并操作的总工作量为 O(n),深度为 O(log n)。将其应用于归并排序,总工作量恢复为 O(n log n),深度为 O(log² n)。要获得 O(log n) 的深度,需要更复杂的排序网络(如双调排序),但实现起来也复杂得多。

总结

本节课我们一起学习了并行算法的基础。我们了解了现代计算机转向多核架构的物理原因,认识了PRAM和工作-深度两种并行计算模型。我们掌握了几个关键的并行原语:Map、Reduce、Scan和Filter,它们都能以 O(n) 的工作量和 O(log n) 的深度高效实现。最后,我们探讨了并行归并排序的挑战和一种优化合并步骤的方法。并行算法的核心思想是在保持总工作量(效率)与串行算法相近的前提下,通过挖掘独立子任务,尽可能降低算法的深度,从而利用多个处理器加速计算。

posted @ 2026-03-29 09:24  布客飞龙II  阅读(4)  评论(0)    收藏  举报