UIUC-CS473-算法笔记-全-

UIUC CS473 算法笔记(全)

001:课程介绍与递归入门

在本节课中,我们将学习课程的基本信息,并深入探讨算法设计中最重要的工具之一:递归。我们将通过经典的汉诺塔问题和整数乘法问题来理解递归的思想和分析方法。

课程介绍

这门课程是伊利诺伊大学的高级算法课程,主要面向已完成CS374(本科理论课程)的本科生,或计算机科学及相关领域的研究生。课程将深入探讨算法设计与分析。

如果你对算法、大O表示法、二叉搜索树或递归不熟悉,这门课程可能不适合你。课程需要一定的编程经验和离散数学证明基础。

本学期课程注册人数远超以往,导致助教资源相对紧张。我们将尽力确保教学质量,如果你遇到任何问题,请及时与我们沟通。

课程所有资料都将在网上发布。我们将使用GradeScope提交作业,使用Ed平台进行在线讨论。鼓励大家在Ed上匿名提问和讨论。

课程政策

关于小组作业,你可以与任何人合作,使用任何资源。但在提交作业时,必须用自己的语言撰写解决方案,并注明所使用的资源(包括合作者)。从作业一开始,可以以最多三人的小组形式提交作业。

我们遵循尊重、包容、责任和诚信的价值观。任何形式的偏见、骚扰或暴力行为都是不可接受的。如果你遇到相关问题,请及时与课程工作人员沟通。

如果你感觉不适,请不要来上课。所有课程内容都有在线资源。如果你需要残疾相关的便利措施,请提前联系相关办公室并获取证明信。

递归:核心设计工具

上一节我们介绍了课程的基本信息,本节中我们来看看递归这一核心的算法设计工具。

递归的基本思想是:要解决一个给定问题实例,不是直接解决它,而是先取得一点进展,直到得到一个或多个更小的相同问题实例,然后将这些更小的实例委托给“递归精灵”去解决。

你可以将其视为“归约为更小的实例,然后委托”。如果实例已经足够小,你可以直接暴力解决。任何在常数大小输入上运行的算法都只需要常数时间,这就是每个递归算法的基本情况。

经典示例:汉诺塔问题

汉诺塔问题是一个经典的递归问题。它包含三根柱子和一堆大小不同的圆盘,目标是将所有圆盘从一根柱子移动到另一根柱子,规则是每次只能移动一个圆盘,且不能将较大的圆盘放在较小的圆盘上。

以下是解决该问题的递归思路:

  1. 为了移动最底下的最大圆盘,必须先将上面的 n-1 个圆盘移开。
  2. n-1 个圆盘移动到另一根柱子(这是一个更小的相同问题,交给递归精灵)。
  3. 移动最大的圆盘到目标柱子。
  4. n-1 个圆盘从临时柱子移动到目标柱子(这又是一个更小的相同问题,交给递归精灵)。

算法可以简洁地描述为:

函数 Hanoi(n, source, dest, temp):
    如果 n > 0:
        Hanoi(n-1, source, temp, dest)   // 步骤1
        移动圆盘 n 从 source 到 dest      // 步骤2
        Hanoi(n-1, temp, dest, source)   // 步骤3

n = 0 时,无事可做,算法自然结束。

核心建议:相信递归精灵。不要试图打开黑盒去理解递归调用的每一步,就像调用一个已实现的库函数一样使用它。

递归算法分析

我们需要分析汉诺塔算法的运行时间(这里指移动次数)。定义 T(n) 为移动 n 个圆盘所需的次数。

根据算法结构,我们可以得到递推关系:

T(0) = 0
T(n) = 2 * T(n-1) + 1, 当 n > 0

通过观察小数值或数学归纳法,可以证明其闭合形式解为:

T(n) = 2^n - 1

证明(归纳法)

  • 归纳假设:假设对于所有 k < n,有 T(k) = 2^k - 1
  • 基础情况n=0 时,T(0)=0 = 2^0 -1,成立。
  • 归纳步骤:当 n>0 时,
    T(n) = 2 * T(n-1) + 1          // 根据递推式
          = 2 * (2^(n-1) - 1) + 1  // 应用归纳假设于 n-1
          = 2^n - 2 + 1
          = 2^n - 1
    
    因此,对于任意非负整数 nT(n) = 2^n - 1 成立。

更复杂的递归:整数乘法

现在,让我们看一个更复杂的递归应用:整数乘法。

传统的竖式乘法(或格子乘法)将两个 n 位数相乘,需要大约 n^2 次单数字乘法操作。历史上,人们曾猜想任何整数乘法算法都必须具有平方级复杂度。

朴素分治算法

我们可以将两个 n 位数 xy 分别拆分为两部分:

x = a * 10^(n/2) + b
y = c * 10^(n/2) + d

那么它们的乘积为:

x * y = (a*c) * 10^n + (a*d + b*c) * 10^(n/2) + (b*d)

这需要计算四个 n/2 位数的乘积:a*c, a*d, b*c, b*d。由此得到递推式:

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

使用递归树法分析,总工作量是各级别工作量之和。最终分析表明,该算法的时间复杂度仍然是 O(n^2),并未改进。

Karatsuba 算法

Karatsuba 发现了一个关键技巧:我们只需要进行三次 n/2 位数的乘法,而不是四次。

注意到中间项 (a*d + b*c) 可以通过以下方式计算:

a*d + b*c = (a+b)*(c+d) - a*c - b*d

由于我们已经计算了 a*cb*d,要得到中间项,只需再计算一次 (a+b)*(c+d) 即可。因此,新的递推式为:

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

再次使用递归树法分析。此时,每层的工作量增长因子变为 3/2。总时间复杂度为:

O(n ^ (log_2 3)) ≈ O(n^1.585)

这确实是一个优于平方复杂度的算法。在实践中,当数字位数超过约50位时,Karatsuba 算法就比朴素算法更快。

总结

本节课中我们一起学习了课程的基本框架和递归这一强大的算法设计范式。

我们首先介绍了课程的目标、资源和政策,强调了合作学习与学术诚信的重要性。

接着,我们深入探讨了递归思想,通过汉诺塔问题展示了如何将问题分解并委托给递归调用,并分析了其时间复杂度。

最后,我们探索了递归在解决复杂问题上的威力,以整数乘法为例,从朴素的 O(n^2) 算法出发,逐步推导出 Karatsuba 的 O(n^1.585) 分治算法,并简要介绍了更快的算法(如基于FFT的算法)的存在。

递归的核心在于相信“递归精灵”能解决更小的子问题,而你将专注于如何组合这些结果。这种“分而治之”的策略是算法设计的基石,我们将在后续课程中反复运用。

002:P2 快速傅里叶变换

在本节课中,我们将学习一种强大的算法工具——快速傅里叶变换。我们将探讨如何利用它来高效地处理多项式,特别是实现比传统方法快得多的多项式乘法。

课程概述

上一节我们介绍了分治算法。本节中,我们将深入探讨一个具体的分治应用:快速傅里叶变换。这是一种将多项式从一种表示形式快速转换为另一种表示形式的算法,是实现快速多项式乘法的关键。

多项式及其表示

多项式是形如 P(x) = Σ_{i=0}^{n} a_i * x^i 的函数。在计算机科学中,我们通常用数组 a 来存储系数 a_i,这称为系数表示法

以下是多项式的基本操作:

  • 求值:给定 x,计算 P(x)。使用霍纳法则可以在 O(n) 时间内完成。
  • 加法:给定两个多项式 PQ 的系数数组,结果多项式 R 的第 j 个系数是 R[j] = P[j] + Q[j]。这可以在 O(n) 时间内完成。
  • 乘法:给定两个多项式 PQ 的系数数组,结果多项式 R 的第 k 个系数是 R[k] = Σ_{i+j=k} P[i] * Q[j]。使用嵌套循环的朴素算法需要 O(n^2) 时间。

系数表示法求值快,但乘法慢。是否存在其他表示法能加速乘法呢?

其他多项式表示法

除了系数表示法,还有两种重要的表示方式。

点值表示法:通过记录多项式在一组特定点 {x_0, x_1, ..., x_n} 上的值 {y_0, y_1, ..., y_n} 来表示多项式。对于 n 次多项式,需要 n+1 个点。

以下是点值表示法的操作效率:

  • 加法:如果两个多项式在相同的点集上采样,直接将对应点的值相加即可,时间复杂度为 O(n)
  • 乘法:同样,将对应点的值相乘即可。但需要注意,结果多项式的次数是两多项式次数之和,因此初始采样点必须足够多(至少为 m+n+1 个),时间复杂度为 O(n)
  • 求值:为了在任意新点 x 求值,需要使用拉格朗日插值公式,其时间复杂度为 O(n^2)

点值表示法乘法快,但求值慢。

根表示法:通过记录多项式的所有根(零点) {r_1, r_2, ..., r_n} 和一个缩放因子 c 来表示多项式:P(x) = c * Π_{i=1}^{n} (x - r_i)

以下是根表示法的操作效率:

  • 求值:直接将 x 代入乘积公式,时间复杂度为 O(n)
  • 乘法:将两个多项式的根列表合并,并乘以缩放因子,时间复杂度为 O(n)
  • 加法:没有直接的方法。通常需要转换回系数表示法进行加法,再重新求根,这非常困难且低效。

根表示法求值和乘法快,但加法几乎无法进行。

表示法转换与范德蒙德矩阵

我们面临一个权衡:系数表示法求值快、乘法慢;点值表示法则相反。一个理想的想法是:能否在两种表示法之间快速转换?这样,要进行乘法时,可以先将两个多项式从系数表示转换为点值表示,在点值表示下进行 O(n) 的乘法,然后再转换回系数表示。

从系数 a 转换到点值 y 是一个线性变换,可以用矩阵乘法描述:
y = V * a
其中矩阵 V 是一个范德蒙德矩阵,其第 i 行第 j 列元素为 x_i^{j-1}

朴素地计算这个矩阵乘法需要 O(n^2) 时间。问题的关键在于:我们能否通过精心选择采样点 {x_i},使得矩阵 V 具有特殊结构,从而加速这个计算过程?

快速傅里叶变换的核心思想

答案是肯定的。如果我们选择一组特殊的采样点——单位复根,就可以应用分治策略。

n 次单位复根是方程 ω^n = 1 在复数域上的 n 个解。它们均匀分布在复平面的单位圆上。主 n 次单位根定义为 ω_n = e^{2πi/n} = cos(2π/n) + i*sin(2π/n)。所有单位根可以表示为 ω_n^0, ω_n^1, ..., ω_n^{n-1}

这组点具有一个关键性质:可折叠性。当我们计算 (ω_n^k)^2 时,由于 (ω_n^k)^2 = ω_{n/2}^k,平方后的点集恰好是 n/2 次单位根的集合,规模减半。

利用这个性质,我们可以设计一个分治算法来求值。对于一个 n 次多项式 P(x)(假设 n 是 2 的幂),我们可以将其按奇偶次项拆分:
P(x) = P_even(x^2) + x * P_odd(x^2)
其中 P_even 包含所有偶次项系数,P_odd 包含所有奇次项系数。

为了计算 P(x) 在所有 n 次单位根上的值,我们需要计算 P_evenP_odd 在所有 (n/2) 次单位根上的值。这恰好是两个规模减半的相同子问题。

快速傅里叶变换算法

基于上述思想,我们得到快速傅里叶变换 算法,用于计算离散傅里叶变换。

以下是递归形式的 FFT 伪代码:

function FFT(a):
    // a 是长度为 n (n是2的幂) 的系数数组
    if n == 1:
        return a  // 只有一个系数,其DFT就是自身
    omega_n = e^(2πi/n)
    omega = 1
    // 拆分奇偶系数
    a_even = [a[0], a[2], ..., a[n-2]]
    a_odd = [a[1], a[3], ..., a[n-1]]
    // 递归计算
    y_even = FFT(a_even)
    y_odd = FFT(a_odd)
    // 合并结果
    for k from 0 to n/2 - 1:
        y[k] = y_even[k] + omega * y_odd[k]
        y[k + n/2] = y_even[k] - omega * y_odd[k]
        omega = omega * omega_n
    return y // y 是点值数组,即 DFT 结果

该算法的时间复杂度递归式为 T(n) = 2T(n/2) + O(n),解得 T(n) = O(n log n)

逆快速傅里叶变换

为了从点值表示(DFT结果)y 恢复系数 a,我们需要计算逆 DFT。幸运的是,DFT 对应的范德蒙德矩阵的逆矩阵几乎与其自身相同,只是需要将单位根 ω_n 替换为其共轭 ω_n^{-1},并对结果除以 n

因此,逆FFT 算法与 FFT 算法几乎完全相同:

  • 将 FFT 算法中所有的 ω_n 替换为 ω_n^{-1}(或 e^{-2πi/n})。
  • 在算法最后,将得到的每个结果除以 n

逆 FFT 的时间复杂度同样为 O(n log n)

快速多项式乘法

现在,我们可以组合 FFT 和逆 FFT 来实现快速多项式乘法。

以下是快速多项式乘法的步骤:

  1. 补零:给定两个多项式 P(次数 m)和 Q(次数 n)的系数数组。将它们的长度都补零到 L,其中 L 是大于 m+n 的最小的 2 的幂。
  2. 正向变换:分别计算 PQ 的长度为 L 的 DFT,得到它们的点值表示 Y_PY_Q。使用 FFT,时间复杂度 O(L log L)
  3. 点乘:逐点相乘得到结果多项式的点值表示:Y_R[k] = Y_P[k] * Y_Q[k]。时间复杂度 O(L)
  4. 逆向变换:对 Y_R 进行逆 DFT(使用逆 FFT),得到结果多项式 R 的系数数组。时间复杂度 O(L log L)

总时间复杂度为 O(L log L),远优于朴素的 O(n^2) 算法。

卷积运算

多项式乘法在信号处理等领域有一个更通用的名字:卷积。给定两个序列 a (长度 m) 和 b (长度 n),它们的卷积 c 是一个长度为 m+n-1 的序列,其中:
c[k] = Σ_{i+j=k} a[i] * b[j]
这正是多项式乘法系数的计算方式。因此,FFT 也可以用来在 O(N log N) 时间内计算两个序列的卷积,其中 N 与序列总长度相关。

历史注记与总结

快速傅里叶变换的思想最早可追溯到高斯,他用于计算小行星轨道,但因数据噪声问题转向了最小二乘法。现代形式的 FFT 由库利和图基在 20 世纪 60 年代重新发现并推广,主要用于信号处理。

本节课中我们一起学习了快速傅里叶变换的原理和算法。我们了解了多项式不同表示法的优劣,发现了通过选择单位复根作为采样点,可以利用分治策略在 O(n log n) 时间内完成系数表示与点值表示的相互转换。基于此,我们实现了 O(n log n) 时间的快速多项式乘法算法,并了解了其与卷积运算的紧密联系。FFT 是算法设计中一个经典而强大的工具。

003:回溯与动态规划

在本节课中,我们将学习如何将看似复杂的字符串分割和序列查找问题,通过递归思想分解,并利用动态规划技术高效解决。我们将从最直观的递归解法开始,逐步优化,最终得到高效的迭代算法。


课程概述

我们首先将探讨一个经典问题:如何判断一个字符串能否被分割成字典中存在的单词序列。接着,我们将研究另一个问题:如何在一个整数序列中找到最长的递增子序列。通过这两个例子,我们将清晰地展示从递归回溯到带记忆化的递归,再到自底向上的动态规划的完整思维过程。


单词分割问题

问题定义

给定一个字符串(例如拉丁语,单词间无空格)和一个能判断任意字符串是否为单词的函数 isWord,我们需要判断该字符串是否能被分割成一个由有效单词组成的序列。

递归思路

我们尝试构建的“单词序列”是一个递归结构:

  • 要么是空序列(对应空字符串)。
  • 要么是一个单词,后面跟着另一个单词序列。

基于此,我们可以设计一个递归算法:

  1. 如果输入字符串为空,则它可以被分割为空序列,返回 true
  2. 否则,我们尝试所有可能的前缀(从第一个字符开始,到任意位置结束)。
  3. 对于每个前缀,检查它是否是一个单词(调用 isWord)。
  4. 如果该前缀是单词,则我们将剩余的后缀字符串递归地交给“递归精灵”,询问它能否被分割。
  5. 只要存在一个前缀满足条件(是单词且剩余部分可分割),整个字符串就是可分割的。

以下是该思路的伪代码描述:

function splittable(string s):
    if s is empty:
        return True
    for i from 1 to length(s):
        prefix = s[0:i] # 尝试所有可能的前缀
        if isWord(prefix) and splittable(s[i:]):
            return True
    return False

从递归到记忆化

上述递归算法存在大量重复计算。例如,在判断不同前缀后,我们可能会多次询问同一个后缀字符串是否可分割。

为了避免重复计算,我们引入记忆化技术:使用一个数组 memo 来记录已经计算过的子问题的结果(例如,memo[i] 记录从第 i 个字符开始的后缀是否可分割)。在每次进行递归调用前,先检查 memo 中是否有答案;如果有,则直接使用;如果没有,再计算并存储结果。

动态规划解法

记忆化是“自顶向下”的。我们可以更进一步,采用“自底向上”的动态规划方法,显式地按正确顺序填充这个记忆数组。

我们定义 dp[i] 表示:从字符串第 i 个字符开始的后缀是否可分割。

  1. 基础情况dp[n] = True(空字符串可分割)。
  2. 状态转移:对于位置 i,我们尝试所有可能的分割点 ji <= j < n)。如果子串 s[i:j] 是单词,并且 dp[j+1]True,那么 dp[i] 也为 True
  3. 计算顺序:由于 dp[i] 依赖于后面位置(j+1)的结果,我们需要从字符串末尾向前计算。

动态规划算法的伪代码如下,其时间复杂度为 O(n²):

function splittable_DP(string s):
    n = length(s)
    dp = array of size n+1, initialized to False
    dp[n] = True # 空字符串可分割
    for i from n-1 down to 0:
        for j from i to n-1:
            if isWord(s[i:j+1]) and dp[j+1]:
                dp[i] = True
                break
    return dp[0]

最长递增子序列问题

上一节我们介绍了如何用递归和动态规划解决字符串分割问题。本节中,我们来看看另一个经典问题:最长递增子序列。

问题定义

给定一个整数数组,我们需要找到其中最长的递增子序列的长度。子序列不要求连续,但必须保持原数组中的相对顺序,并且每个元素都严格大于前一个元素。

递归思路与状态定义

我们尝试构建一个递增子序列。一个关键的洞察是:当我们决定是否将当前元素纳入子序列时,不仅需要考虑后面的元素,还需要记住上一个被选中元素的值,以确保序列是递增的。

因此,我们定义一个递归函数 LIS(i, prev_idx),其含义是:在数组 A 中,从下标 i 开始的部分,并且要求所有选中元素都大于 A[prev_idx] 的情况下,能找到的最长递增子序列的长度。

递归关系如下:

  1. 基础情况:如果 i 超出数组范围,长度为 0。
  2. 选择忽略:如果当前元素 A[i] 不大于上一个选中的元素(即 A[i] <= A[prev_idx]),那么我们只能忽略它,结果等于 LIS(i+1, prev_idx)
  3. 选择或忽略:如果 A[i] 大于上一个选中的元素,我们有两种选择:
    • 忽略它:结果等于 LIS(i+1, prev_idx)
    • 选择它:结果等于 1 + LIS(i+1, i)(长度加1,并且新的“上一个元素”变为当前元素 A[i])。
      最终结果是这两种选择中的最大值。

动态规划解法

递归函数 LIS(i, prev_idx) 有两个参数,因此我们需要一个二维数组 dp 来进行记忆化。dp[i][j] 可以表示从位置 i 开始,且前一个选中元素在位置 j(或使用一个特殊值表示尚未选择任何元素)时的最长递增子序列长度。

更常见的优化是定义 dp[i] 为:以第 i 个元素作为结尾的最长递增子序列的长度。

  1. 基础情况:对于每个位置 i,至少可以以自身结尾,所以 dp[i] = 1
  2. 状态转移:对于每个位置 i,我们检查它之前的所有位置 j0 <= j < i)。如果 A[j] < A[i],那么我们可以将 A[i] 接在以 A[j] 结尾的子序列后面,形成更长的子序列。因此,dp[i] = max(dp[i], dp[j] + 1)
  3. 计算顺序:从左到右依次计算每个 dp[i]
  4. 最终答案dp 数组中的最大值。

该算法的时间复杂度为 O(n²),伪代码如下:

function LIS_DP(array A):
    n = length(A)
    dp = array of size n, initialized to 1
    for i from 0 to n-1:
        for j from 0 to i-1:
            if A[j] < A[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

总结

本节课中我们一起学习了两个重要问题的动态规划解法。

  1. 对于单词分割问题,我们通过定义子问题(后缀是否可分割),从递归回溯出发,引入记忆化消除重叠子问题,最终得到自底向上的动态规划算法。
  2. 对于最长递增子序列问题,我们展示了如何定义关键的状态(以 i 结尾的LIS长度),并建立状态之间的转移关系。

这两个例子清晰地阐述了动态规划的核心思想:将原问题分解为重叠子问题,并存储子问题的解以避免重复计算。解决问题的通用步骤是:首先思考递归结构和定义,然后尝试记忆化递归,最后明确填充顺序转化为迭代的动态规划算法。

004:更多动态规划

在本节课中,我们将继续学习动态规划。我们将首先回顾动态规划的核心思想,然后探讨如何为动态规划问题提供完整的解答。接着,我们将通过两个具体例子来加深理解:一个是求解最长递增子序列的另一种方法,另一个是更复杂的“伐木工”问题。

动态规划回顾

上一节我们介绍了动态规划。动态规划本质上是一种高效、迭代地评估递归函数的方法。

我们之前讨论了两个例子。第一个是文本分割问题。给定一个长字符序列,我们需要将其分割成单词。算法的核心思想是确定第一个单词的结束位置。如果我们能知道第一个单词在哪里结束,就可以递归地处理剩余的字符串。由于不知道确切位置,我们通过一个循环尝试所有可能性。为了避免指数级的时间复杂度,我们将子问题的结果存储在一个表中,并从右向左填充,最终在 O(n²) 时间内完成。

第二个例子是最长递增子序列问题。给定一个数字序列,我们需要找出一个按递增顺序排列的子序列。我们通过从左到右构建子序列来思考这个问题。当我们考虑是否将一个数字作为子序列的下一个元素时,决策会受到之前选择的影响。因此,我们需要记住最后一个被选入子序列的数字,以确保未来的选择是递增的。这导致我们定义了一个带有两个参数的函数 LIS(i, j),表示从位置 j 开始、所有元素都大于 a[i] 的最长递增子序列的长度。通过记忆化,我们可以将时间复杂度优化到 O(n²)。

如何完整解答动态规划问题

为了在作业或考试中获得满分,你需要清晰地展示动态规划的各个组成部分。以下是所需内容:

  1. 问题的英文描述:为你的动态规划函数起一个有意义的名字(不要叫 dp),并用英文明确描述该函数返回值的含义。描述应使用函数参数,但不涉及算法内部状态。
  2. 递推关系:用数学公式或伪代码形式写出如何计算函数值。这解释了如何解决你描述的问题。
  3. 迭代实现细节:说明如何高效地评估这个递推关系,避免重复计算。这包括三部分:
    • 数据结构:说明用于存储结果的数据结构(例如,一维数组、二维数组)。请标注索引轴。
    • 填充顺序:说明遍历数据结构以填充值的顺序。通常用箭头表示循环方向。
    • 时间复杂度:给出算法的时间复杂度分析。

你可以选择提供递推关系和迭代细节,或者直接提供完整的迭代伪代码。但无论如何,清晰的问题描述是必不可少的。如果你无法用英文解释数组条目的含义,说明你并未真正理解算法。

最长递增子序列(方法二)

现在,我们来看看求解最长递增子序列的另一种方法。之前的方法是逐个询问“这个数字是下一个吗?”。这次,我们换个角度思考。

我们不知道子序列的第一个元素是什么,所以尝试所有可能性。假设一个小鸟告诉我们第一个元素是 a[i]。那么,接下来的问题就变成了:在 a[i] 之后,最长递增子序列的下一个元素是什么?但这里的关键是,未来决策只依赖于最后一个被选中的元素的值,而不是它在序列中的位置。

因此,我们定义一个新函数 LIS'(i),它表示a[i] 开始的最长递增子序列的长度。注意,这个子序列必须包含 a[i]

那么,如何求解原问题呢?一个常见的技巧是添加一个哨兵元素。我们设置 a[0] = -∞,然后最终答案就是 LIS'(0) - 1(因为哨兵不计入实际长度)。另一种方法是尝试所有可能的起始位置 i 并取最大值。

接下来,我们建立递推关系。对于 LIS'(i),子序列至少包含 a[i],所以长度至少为 1。然后,我们需要在所有可能的后续元素 a[j](其中 j > ia[j] > a[i])中,选择能构成最长子序列的那个。因此,递推式为:

LIS'(i) = 1 + max{ LIS'(j) | j > i 且 a[j] > a[i] }

这里,max 作用于一个集合。如果集合为空(即 a[i] 是后缀中最大的),则 max 值定义为 0,此时 LIS'(i) = 1。这同时也是我们的基本情况。

现在,我们考虑如何记忆化。LIS'(i) 只有一个参数 i,取值范围是 1 到 n,因此我们可以用一个一维数组存储。观察递推式,计算 LIS'(i) 时需要所有 j > iLIS'(j) 值。因此,我们应该从右向左填充这个数组。这样,当计算 LIS'(i) 时,所需的值都已经计算好了。

尽管函数只有一个参数,但实现时我们需要两层循环:外层循环 i 从 n 递减到 1,内层循环 ji+1 到 n,以找到最大值。因此,时间复杂度仍然是 O(n²)。

值得注意的是,这种递推形式允许我们利用更高级的数据结构(如平衡二叉搜索树)来优化。我们可以在树中按 a[j] 的值存储 LIS'(j),从而在对数时间内查询满足 a[j] > a[i] 的最大 LIS'(j) 值。这可以将总时间复杂度降低到 O(n log n)。不过,这属于更高级的优化技巧。

伐木工问题

下面,我们来看一个更复杂的问题:伐木工问题。

假设我们有一块长木板,上面标记了需要切割的位置。我们需要将木板完全切割成这些指定的小块。每次切割时,锯木厂收取的费用与当前正在切割的整块木板的长度成正比。问题是,找到总费用最低的切割方案。

例如,第一次切割可以在任意标记处进行,费用是整块木板的长度。切割后,我们得到左右两块较短的木板,然后递归地对它们进行切割。不同的首次切割位置会导致不同的后续成本。

为了形式化问题,假设我们有 n 块最终需要的小木板,长度分别为 b[1], b[2], ..., b[n]。我们也可以计算出每个切割点的位置 x[0], x[1], ..., x[n],其中 x[0]=0x[i] 是前 i 块小木板的总长度。木板总长度是 x[n]

在切割过程中,我们面对的子问题是一段连续的木板,它从某个切割点 x[i-1] 之后开始,到切割点 x[j] 结束,包含了第 i 到第 j 块小木板。我们将其记为子问题 (i, j),其中 1 <= i <= j <= n

定义函数 Woodcut(i, j) 表示将包含第 i 到第 j 块小木板的木板,完全切割成单个小块的最小成本。

现在,我们建立递推关系。考虑一般情况 i < j。我们需要进行第一次切割。假设我们在第 k 块小木板之后切割(i <= k < j)。这意味着我们将木板分成了左半部分 (i, k) 和右半部分 (k+1, j)

  • 第一次切割的成本是当前木板的长度:length(i, j) = x[j] - x[i-1]
  • 然后,递归地,左半部分最优切割成本为 Woodcut(i, k)
  • 右半部分最优切割成本为 Woodcut(k+1, j)
  • 因此,总成本为 length(i, j) + Woodcut(i, k) + Woodcut(k+1, j)

由于我们不知道最佳的 k 是哪个,需要尝试所有可能性并取最小值。所以递推式为:
Woodcut(i, j) = min{ length(i, j) + Woodcut(i, k) + Woodcut(k+1, j) | i <= k < j }

对于基本情况 i == j,这意味着木板本身就是一块需要的小木板,无需切割,成本为 0。所以 Woodcut(i, i) = 0

现在,我们设计迭代算法。函数 Woodcut(i, j) 有两个参数 i 和 j,因此我们需要一个二维数组来存储结果,大小为 O(n²)。

分析依赖关系:要计算 Woodcut(i, j),我们需要所有 Woodcut(i, k)(其中 k < j,即同一行左边的值)和所有 Woodcut(k+1, j)(其中 k >= i,即同一列下边的值)。因此,填充顺序需要确保在计算 (i, j) 时,这些依赖值都已就绪。

一种可行的填充顺序是:按子问题规模(即 j-i 的值)从小到大进行。先填充所有 i=j 的对角线(成本为0),然后填充 j-i=1 的对角线,接着 j-i=2,以此类推,直到填充 (1, n)。这可以通过两层循环实现:外层循环 len 从 1 到 n-1,内层循环 i 从 1 到 n-len,并设置 j = i + len

在计算每个 (i, j) 时,我们需要内嵌第三个循环来遍历所有可能的切割点 k(从 i 到 j-1)。因此,总共有三层循环,时间复杂度为 O(n³)。

总结

本节课我们一起深入学习了动态规划。我们首先明确了完整解答动态规划问题的关键要素:清晰的问题描述、正确的递推关系以及迭代实现的细节。接着,我们探索了最长递增子序列问题的另一种解法,展示了动态规划思路的灵活性。最后,我们分析了一个更复杂的伐木工问题,学习了如何为涉及区间划分和成本叠加的问题建立动态规划模型,其时间复杂度为 O(n³)。动态规划的核心在于将复杂问题分解为重叠子问题,并通过记忆化避免重复计算,从而高效地找到最优解。

005:更多动态规划 🧩

在本节课中,我们将学习动态规划(DP)的更多概念和技巧,并通过几个具体例子来加深理解。我们将从建立DP直觉开始,然后探讨如何将问题分解为子问题,并讨论记忆化结构与评估顺序。最后,我们会分析一个在树结构上应用DP的经典问题。


建立动态规划直觉 🧠

上一节我们介绍了动态规划的基本思想。本节中,我们来看看如何通过简单的草图来建立解决DP问题的直觉,而无需立即写出完整的递推式或英文描述。

核心思想是思考决策序列。对于文本分割问题,决策是“在哪里将长字符串分割成单词”。决策顺序是从左到右。

  • 子问题来源:处理完前几个单词后,剩下的未处理部分(一个后缀)就是子问题。
  • 子问题表示:可以用后缀的起始索引这一个整数来表示。
  • 记忆化结构:因此,记忆化结构将是一个一维数组。
  • 评估顺序:原始问题是整个字符串(索引1),基础情况是空后缀(索引n+1)。因此,评估顺序是从右向左(从n+1到1)。

也可以从“最后一个单词是什么”开始决策,这样决策顺序就从右向左,子问题变为前缀,评估顺序则从左向右。这里存在一个反转:递归调用的顺序(我们思考决策的顺序)与获取答案的顺序(填充记忆化数组的顺序)是相反的。

对于最长递增子序列问题,决策(选择是否将元素纳入子序列)也是从左到右。但为了确保子序列递增,子问题不仅需要知道剩余的后缀,还需要记住最后选择的元素(哨兵)。因此,子问题由两个数(哨兵索引 i 和后缀起始索引 j)指定,记忆化结构是一个二维数组。

对于木材切割问题,决策更像是分治:决定第一刀切在哪里,然后递归处理左右两段。子问题是一段区间,由起始和结束索引 (i, j) 指定,记忆化结构也是一个二维数组。原始问题是整个区间 (1, n),因此答案在表格的右上角,评估顺序需要确保子区间先于大区间被计算。

总结:这种直觉构建的第一步是思考决策序列。决策可能从左到右、从右到左,或是树形分治。子问题可能需要携带少量额外信息(如最后一个选择)。这直接影响了记忆化结构的维度(一维数组、二维数组等)和评估顺序。


树上的动态规划:最大独立集 🌳

之前的问题输入都是数组,记忆化结构也是数组。但并非总是如此。当子问题不能仅用少量整数元组表示时,记忆化结构可能不同。我们以最大独立集(MIS)问题为例。

最大独立集:给定一个无向图,找到一个最大的顶点子集,使得其中任意两个顶点之间没有边相连。

这个问题在一般图上是NP难的。但我们限制输入为,从而可以在多项式时间内解决。树是连通的无环无向图。

为了利用递归,我们为树选择一个根节点,并将所有边指向远离根的方向,从而得到一个有根树。有根树可以递归定义:一个节点加上一组它的子树(可能是空集)。

现在,我们围绕根节点做决策:根节点要么在最大独立集中,要么不在。然后递归地在子树中处理。因此,一个子问题可以用一个节点 v 来指定,表示“在以 v 为根的子树中寻找最大独立集”。

然而,为了计算子树 v 的MIS,我们需要知道关于 v 的决策是否会影响到其子节点的选择。更清晰的思路是定义两个函数:

  • MISyes(v):在以 v 为根的子树中,包含 v 的最大独立集的大小。
  • MISno(v):在以 v 为根的子树中,不包含 v 的最大独立集的大小。

那么,整个子树的MIS大小就是 max(MISyes(v), MISno(v))

接下来是推导递推关系:

  1. 计算 MISno(v):如果 v 不在独立集中,那么它的每个子节点 w 可以自由地选择是否加入。因此,我们对每个子节点取两种可能性的最大值,并求和。
    MISno(v) = sum_over_children_w_of_v( max(MISyes(w), MISno(w)) )
    
  2. 计算 MISyes(v):如果 v 在独立集中,那么它的所有子节点 w 都不能在独立集中。因此,我们取每个子节点不加入时的值,并求和,最后加上 v 本身。
    MISyes(v) = 1 + sum_over_children_w_of_v( MISno(w) )
    

基础情况:当 v 是叶子节点时,它没有子节点。MISyes(v) = 1(只包含自己),MISno(v) = 0(空集)。求和项为空,值为0,符合公式。

记忆化结构:我们不需要一个单独的数组。可以直接在树的节点数据结构中添加两个字段 mis_yesmis_no 来存储计算结果。

评估顺序:计算节点 v 的值需要先知道其所有子节点的值。因此,评估顺序必须是自底向上的,即孩子先于父母。这可以通过对树进行后序遍历来实现。

时间复杂度:每个节点访问一次,每条边被用来查找子节点值一次。对于有 n 个节点的树,有 n-1 条边,因此总时间为 O(n)

思考:这个算法是记忆化递归还是动态规划?本质上,后序遍历隐式地给出了子问题(节点)的评估顺序(拓扑序)。如果我们显式地按此顺序循环,就是自底向上的DP;如果递归实现并存储结果,就是记忆化递归。两者等价,区别在于思考角度。


依赖图与动态规划 🔄

深度优先搜索(DFS)的递归模式与记忆化递归的通用结构非常相似:

  • DFS:访问节点,标记,预处理,递归访问邻居,后处理。
  • 记忆化递归:对于子问题 x,若未计算,则初始化,递归求解所有依赖的子问题 y,用其结果更新 x,最后进行最终计算并存储。

动态规划中的递推关系定义了一个依赖图:节点是子问题,边表示递归调用。关键的是,这个依赖图必须是一个有向无环图(DAG)。如果存在环,递归将无法终止。

  • 文本分割的依赖图:节点是后缀,边指向更短的后缀,是一个DAG。
  • 树上MIS的依赖图:节点是子树根,边从父节点指向子节点,是一个有根树(特殊的DAG)。

对于DAG,我们可以对其进行拓扑排序。动态规划中自底向上的评估顺序,实质上就是按照逆拓扑序(或后序)来遍历这个依赖图。而记忆化递归,则相当于在这个DAG上运行DFS。

区别:在大多数DP问题中,依赖图是隐式的,我们不会显式构建它。但在某些问题中,图是直接给定的输入。例如,在给定DAG中寻找最长路径问题中,图本身就是DAG。我们可以定义子问题 LLP(v) 为从某源点到节点 v 的最长路径长度。其递推关系依赖于指向 v 的入边,评估顺序按照拓扑序进行。这留作一个思考练习。


总结 📝

本节课我们一起学习了:

  1. 如何通过思考决策序列来建立动态规划的直觉,这有助于确定子问题形式、记忆化结构和评估顺序。
  2. 如何将动态规划应用于树形结构,以解决树上的最大独立集问题。我们定义了包含/不包含当前节点的状态,推导了递推式,并利用后序遍历实现自底向上的计算。
  3. 动态规划、记忆化递归和深度优先搜索在依赖图(DAG)视角下的统一性。理解子问题间的依赖关系构成DAG是设计正确DP算法的关键。

通过结合直观的草图和对问题结构的深入分析,我们可以更系统地将复杂问题分解为可管理的子问题,并设计出高效的动态规划算法。

006:动态规划的进阶技巧

在本节课中,我们将学习动态规划的两个进阶主题。首先,我们将探讨如何在有向无环图上进行动态规划,这是一种将许多问题转化为图论问题的强大技巧。接着,我们将深入研究编辑距离问题,并了解如何通过巧妙的递归分治策略,在保持线性空间复杂度的同时,计算出最优解及其具体结构。

在有向无环图上的动态规划

上一节我们介绍了动态规划的基本思想。本节中,我们来看看如何将动态规划应用于有向无环图。

任何动态规划递推式都定义了一个依赖图。图中的节点代表子问题,边代表递归调用。为了使递推式有效,这个依赖图必须是无环的。对于许多问题,我们可以直接处理给定的图,或者从给定图衍生出依赖图。

有向无环图,顾名思义,是一个没有环的有向图。例如,考虑寻找DAG中最长路径的问题。我们首先关注计算最优路径的长度,稍后再讨论如何提取路径本身。

一个自然的想法是定义子问题 L(u, v) 为从节点 u 到节点 v 的最长路径长度。递推关系可以考虑路径的第一条边。如果从 u 出发有一条边到 x,那么 L(u, v) 可以是这条边的权重加上 L(x, v) 的最大值。

以下是递推式的公式化描述:

  • 基础情况 1:如果 u == v,则 L(u, v) = 0
  • 基础情况 2:如果 u != v 且没有从 u 出发的边,则 L(u, v) = -∞
  • 递归情况L(u, v) = max_{(u, x) ∈ E} ( weight(u, x) + L(x, v) )

在这个递推式中,目标节点 v 是固定的。因此,对于每个固定的 v,这实际上是一个单参数递推。我们可以通过深度优先搜索的后序顺序来高效计算所有 uL(u, v) 值,时间复杂度为 O(V + E)

为了找到整个图中的最长路径(而不指定终点),一个技巧是添加一个“超级汇点” t,并从所有其他节点向 t 添加一条边。这样,原图中到任意节点的最长路径,在新图中就变成了到 t 的路径(长度减一)。我们只需以 t 为终点运行一次上述动态规划算法即可。

这种将问题转化为DAG上最长/最短路径的技巧非常通用。例如,文本分割问题可以转化为在特定DAG上寻找路径,最长递增子序列问题也可以建模为DAG上的最长路径。

编辑距离问题

现在,我们转向一个经典的动态规划问题:编辑距离。编辑距离的目标是计算将一个字符串转换为另一个字符串所需的最少编辑操作次数,允许的操作包括插入一个字符、删除一个字符或替换一个字符。

编辑距离可以通过一个二维动态规划表来计算。定义 edit(i, j) 为将第一个字符串的前 i 个字符转换为第二个字符串的前 j 个字符所需的最小编辑次数。

递推关系如下:

  • 基础情况:如果 i == 0,则 edit(0, j) = j(全部插入)。如果 j == 0,则 edit(i, 0) = i(全部删除)。
  • 递归情况
    edit(i, j) = min( edit(i-1, j) + 1, // 删除第一个字符串的第i个字符
    edit(i, j-1) + 1, // 在第一个字符串插入第二个字符串的第j个字符
    edit(i-1, j-1) + [A[i] != B[j]] // 替换或不操作
    )
    其中 [A[i] != B[j]] 是指示函数,相等时为0,不等时为1。

通过按行主序填充一个 m x n 的二维表,我们可以在 O(mn) 时间内计算出编辑距离。计算完成后,我们可以通过回溯表(从 (m, n) 开始,根据递推关系反向追踪到 (0, 0))来重建具体的编辑操作序列。

然而,当字符串非常长时(例如百万级字符),存储整个 m x n 的表在空间上是不可行的。标准的空间优化技巧是“滑动窗口”,即只保留当前计算所需的前一行(或前一列)数据,将空间复杂度降至 O(min(m, n))。但这种方法丢失了完整表格,使我们无法直接回溯出编辑序列。

线性空间下的分治策略

为了同时实现 O(mn) 时间和 O(m+n) 空间,并能重建编辑序列,我们可以采用一种巧妙的分治策略。该策略的核心思想是递归地将动态规划表分成四象限进行计算。

假设两个字符串长度均为 n。算法步骤如下:

  1. 递归计算左上象限。这为我们提供了中间行和中间列在分割线上的值。
  2. 利用这些边界值,递归计算右上和左下象限。
  3. 最后,递归计算右下象限。最终,右下角的单元格值即为编辑距离。

这个递归过程的顺序(左上 -> 右上 -> 左下 -> 右下)虽然看起来非常规,但它具有极佳的缓存局部性,在实践中效率很高。空间复杂度分析表明,在任何递归层,我们只需要存储常数数量的行和列,递归深度为 O(log n),因此总空间为 O(n)

但上述过程只计算了距离值。为了重建编辑序列,我们需要进行第二次递归分治搜索:

  1. 首先运行上述算法找到编辑距离。
  2. 然后,我们定位最优编辑路径穿过表中线的位置。这可以通过比较边界值来确定。
  3. 路径会将右下象限问题分解为至多三个更小的子问题(路径可能穿过右上、左下和左上象限)。我们递归地在这些子象限中寻找路径片段。
  4. 最终,将所有路径片段拼接起来,就得到了完整的编辑操作序列。

这个算法的总时间复杂度仍然是 O(n²),空间复杂度为 O(n),并且成功构造出了最优编辑序列。

总结

本节课中我们一起学习了动态规划的两种进阶应用。首先,我们看到了如何将有向无环图上的路径问题转化为动态规划,这是一种强大的建模工具。然后,我们深入探讨了编辑距离问题,并学习了一种复杂但高效的分治算法,该算法能够在平方级时间复杂度和线性空间复杂度下,不仅计算出编辑距离,还能重构出具体的编辑操作序列。这些技巧展示了动态规划思想的深度和灵活性。

007:加速动态规划

在本节课中,我们将学习一种称为 SMAWK 的算法,它可以显著加速一大类动态规划算法的运行时间。我们将从理解问题本身开始,逐步介绍算法的核心思想,并最终看到如何将其应用于实际问题。

概述

动态规划是解决许多优化问题的强大工具,但其时间复杂度有时可能较高。本节课将介绍一种技术,能够将某些动态规划算法的时间复杂度降低大约一个线性因子。我们将通过一个具体的例子——木材切割问题——来展示这种技术的威力,并深入探讨其背后的算法原理:在满足特定结构(单调性)的矩阵中高效地寻找每行的最小值。


问题引入:木材切割问题

首先,我们回顾一下木材切割问题。假设我们有一块长木板,上面标记了多个需要切割的位置。每次切割的成本等于被切割木板的长度。我们的目标是找到一种切割顺序,使得总成本最小。

这个问题可以形式化为一个动态规划问题。设 cost(i, k) 为切割从第 i 个标记点到第 k 个标记点之间木板的最小成本。其递推关系如下:

cost(i, k) = min_{i < j < k} [ (x_k - x_i) + cost(i, j) + cost(j, k) ]

其中,x_ix_k 是标记点的位置。这个递推式自然地导出一个时间复杂度为 O(n³) 的算法(三层嵌套循环)。

然而,通过观察这个动态规划中内层循环所执行的操作,我们可以将其重新表述为另一个问题:在一个特定的二维数组(或矩阵)中,为每一行找到最小值。如果我们能更快地解决这个“寻找行最小值”的问题,就能加速整个动态规划过程。


核心问题:在矩阵中寻找行最小值

现在,我们聚焦于这个核心子问题:给定一个 mn 列的矩阵 A,找出每一行中最小元素所在的列索引。

如果没有关于矩阵 A 的任何额外信息,我们只能使用暴力扫描,时间复杂度为 O(m * n),即检查每一个元素。可以证明,在没有额外信息的情况下,这是最优的。

但是,许多从动态规划中产生的矩阵具有特殊的结构。接下来,我们将定义这种结构,并展示如何利用它来设计更高效的算法。


矩阵的单调性

算法的关键在于矩阵所具有的 单调性

单调矩阵

一个矩阵是 单调 的,如果其每行最小值的列索引随着行号的增加而单调不减(即向左或保持不变)。

示例
假设一个5x5矩阵中,第1行最小值在第2列,第2行最小值在第2列,第3行最小值在第3列,第4行最小值在第4列,第5行最小值在第5列。那么这些最小值的列索引序列 [2, 2, 3, 4, 5] 是非递减的,这个矩阵就是单调的。

仅仅利用单调性,我们就可以设计出一个比暴力法更快的算法。


完全单调矩阵

然而,为了应用更强大的SMAWK算法,我们需要一个更强的条件:完全单调性

一个矩阵是 完全单调 的,如果它的每一个 2x2子矩阵 都是单调的。这意味着,对于任意两行 i < j 和任意两列 p < q,都不可能出现以下“坏”模式:

  • i 行中,A[i][p] > A[i][q](即第 i 行的最小值在右侧)
  • j 行中,A[j][p] < A[j][q](即第 j 行的最小值在左侧)

换句话说,在任意2x2子矩阵中,最小值的分布只能是“左上-左上”、“右下-右下”或“左上-右下”的模式,绝不能是“右上-左下”。

令人惊讶的是,木材切割问题中产生的矩阵满足完全单调性。这使得我们可以应用接下来的高效算法。


算法一:FILTER算法(针对单调矩阵)

首先,我们介绍一个针对单调矩阵的行最小值查找算法,称为 FILTER算法。它有两种等价的视角:自顶向下(递归)和自底向上(迭代)。

自顶向下视角(递归)

  1. 找到中间行的最小值:在具有 m 行的矩阵中,找到第 ⌊m/2⌋ 行的最小值,设其位于列 h
  2. 递归求解子问题
    • 由于单调性,第 1⌊m/2⌋-1 行的最小值一定在列 1h 之间。递归求解这个左上子矩阵(⌊m/2⌋-1 行, h 列)。
    • 同理,第 ⌊m/2⌋+1m 行的最小值一定在列 hn 之间。递归求解这个右下子矩阵(约 m/2 行, n-h 列)。

时间复杂度分析
每次递归调用,我们都需要 O(n) 时间来扫描中间行。递归树的深度是 O(log m),因为每次行数减半。每一层递归的所有节点所需的扫描时间总和也是 O(n)。因此,总时间复杂度为 O(n log m + m)。当 m 远小于 n 时,这比 O(mn) 要好。

自底向上视角(迭代)

这种视角更清晰地展示了算法如何工作:

  1. 递归求解偶数行:首先,递归地找出所有偶数行(第2, 4, 6, ...行)的最小值位置。
  2. 推断奇数行范围:利用单调性,对于任意奇数行 r,它的最小值一定被“夹在”其上方和下方偶数行最小值的列索引之间。
  3. 扫描奇数行:在每个奇数行限定的这个列范围内进行线性扫描,找到最小值。

关键观察:所有需要扫描的“范围”总长度之和是 O(m + n)。因此,步骤2和3可以在线性时间内完成。递归部分则处理规模减半(行数减半)的子问题。

无论哪种视角,FILTER算法都利用了单调性来避免检查整个矩阵。


算法二:REDUCE算法(针对完全单调矩阵)

FILTER算法在矩阵“高而瘦”(m > n)时表现良好。当矩阵“宽而扁”(m < n)时,我们需要另一个工具:REDUCE算法。它能在 O(n) 时间内,将一个 m x n 的完全单调矩阵“缩减”为一个 m x m 的矩阵,并且保证原矩阵每行的最小值一定出现在缩减后的列中。

REDUCE算法的核心思想是维护一个,用来存储“候选”的列索引。算法通过比较栈顶列与当前列中特定元素的大小,利用完全单调性的性质,决定是丢弃当前列(弹出栈顶)还是将当前列加入候选(压入栈)。

算法伪代码概要

函数 REDUCE(A, m, n):
    初始化空栈 S
    对于 k 从 1 到 n:
        当 栈非空 且 A[栈顶索引, 栈的大小] > A[k, 栈的大小]:
            弹出栈顶
        如果 栈的大小 < m:
            将 k 压入栈
    返回栈 S (即保留的 m 个列索引)

理解其工作方式
每次比较都发生在当前列 k 和栈顶列 s 的某个特定行(由栈的大小决定)。根据完全单调性:

  • 如果 A[t, s] > A[t, k],那么栈顶列 s 中,从第 t 行往下的所有元素都不可能是该行的最小值(因为 k 列对应位置更小,且单调性会保持)。因此可以安全地丢弃列 s(弹出栈)。
  • 否则,列 k 中,第 t 行以上的元素都不可能是最小值,列 k 成为一个新的候选列(压入栈)。

由于每列最多被压入和弹出栈各一次,总比较次数为 O(n)。最终栈中恰好保留了 m 列,它们构成了我们需要的 m x m 子矩阵。


完整的SMAWK算法

现在,我们将FILTER和REDUCE组合起来,形成完整的 SMAWK算法,用于在完全单调矩阵中寻找行最小值。算法采用分治策略,根据矩阵的形状选择不同的操作:

算法步骤

  1. 基础情况:如果行数 m 很小(例如 m=1),直接暴力扫描该行,时间复杂度 O(n)
  2. 宽矩阵情况 (m < n):调用 REDUCE 算法,在 O(n) 时间内将矩阵缩减为 m x m 的方阵。然后递归地在缩减后的方阵上求解。
  3. 高矩阵情况 (m >= n):调用 FILTER 算法(自底向上版本)。递归地求解所有偶数行构成的大小为 (m/2) x n 的子问题。得到偶数行的答案后,在 O(m+n) 时间内推导出所有奇数行的答案。

时间复杂度分析
算法在“宽”和“高”两种情况下交替进行。REDUCE步骤将宽度降至与高度相等,FILTER步骤将高度减半。整个过程的总工作量是一个收敛的几何级数,最终时间复杂度为惊人的 O(m + n)。这比最初的 O(mn) 有了巨大的提升。


在动态规划中的应用

回到最初的木材切割问题。其动态规划的内层循环本质上是在一个隐含的、满足完全单调性的矩阵中寻找每行的最小值。我们并不需要显式地构建这个矩阵,只需要能够根据下标 (i, j) 在常数时间内计算出矩阵元素 A[i][j] 的值(即递推式的一部分)。

因此,我们可以将SMAWK算法“嵌入”到动态规划中,替代原来的内层循环扫描。这样,整个动态规划的时间复杂度就从 O(n³) 降低到了 O(n²),节省了一个线性因子。

这种方法可以应用于许多具有类似结构的动态规划问题,例如最优二叉搜索树、某些字符串对齐问题等。


总结

本节课我们一起学习了一种强大的算法技术,用于加速动态规划。

  1. 我们首先 通过木材切割问题,引出了在动态规划中寻找行最小值的子问题。
  2. 接着,我们定义了单调矩阵和更强的完全单调矩阵,这是算法能够高效运行的关键前提。
  3. 然后,我们介绍了 FILTER算法,它利用单调性,通过分治在 O(n log m) 时间内解决问题。
  4. 进一步,我们介绍了 REDUCE算法,它利用完全单调性,能在 O(n) 时间内将宽矩阵缩减为方阵。
  5. 最后,我们将两者结合,得到了 SMAWK算法,它能在 O(m + n) 的线性时间内,解决完全单调矩阵的行最小值查找问题,从而将一类动态规划算法的时间复杂度降低一个线性因子。

这种将问题转化为具有特殊结构的矩阵查询,并利用该结构设计亚线性查询算法的思想,是算法设计中一个非常深刻和优美的范例。

008:离散概率回顾 🎲

在本节课中,我们将回顾离散概率论的基础知识,为后续学习随机化算法打下坚实的理论基础。我们将从基本概念开始,逐步深入到期望值、条件概率以及线性期望等核心工具。

概述 📋

概率论是分析随机化算法的关键。本节将介绍样本空间、事件、随机变量、期望值等基本概念,并通过经典示例(如模拟公平硬币和收集优惠券问题)来展示如何应用这些概念分析算法。

样本空间与事件 📊

上一节我们介绍了课程背景,本节中我们来看看概率论的基础构件。

一个样本空间是一个有限或可数的集合,它代表了所有可能的结果。例如,一副标准的54张扑克牌(含两张王牌)就构成了一个有54个元素的样本空间。

一个概率质量函数为样本空间中的每个元素分配一个实数,满足两个条件:

  1. 每个元素的概率是非负的。
  2. 所有元素的概率之和为1。

在标准扑克牌的例子中,每张牌被抽中的概率是均匀的,即 P(某张牌) = 1/54。但“随机”并不总意味着“均匀”。例如,在“石头剪刀布”游戏中,可以定义一个概率空间,其中“石头”的概率为1,“剪刀”和“布”的概率为0。

一个事件是样本空间的任意子集。我们可以将事件视为一个命题或条件。例如,“抽到红牌”是一个事件。我们可以用逻辑运算(如与、或、非)来组合事件。

以下是关于事件的两个重要概念:

  • 互斥事件:如果两个事件没有交集,即 P(A ∩ B) = 0,则称它们互斥。例如,“抽到红牌”和“抽到黑牌”是互斥的。
  • 独立事件:如果两个事件满足 P(A ∩ B) = P(A) * P(B),则称它们独立。例如,在一副标准扑克牌中,“抽到红心”和“抽到5”是独立的。但“抽到红牌”和“抽到红心”则不独立。

条件概率与随机变量 🔄

理解了基本事件后,我们来看看当已知部分信息时,概率如何变化。

对于两个事件A和B,在事件B发生的条件下,事件A发生的条件概率定义为:
P(A | B) = P(A ∩ B) / P(B)
条件概率仅在 P(B) > 0 时有定义。

一个随机变量是一个从样本空间到某个值集(通常是实数)的函数。尽管名字叫“变量”,但它本质上是一个函数。例如,从扑克牌中抽牌,“牌的点数”就是一个随机变量。

随机变量的期望值是其可能取值的加权平均,权重为取该值的概率。对于一个随机变量X,其期望值 E[X] 定义为:
E[X] = Σ (值 x * P(X = x))
例如,一个公平的六面骰子的期望点数是 (1+2+3+4+5+6)/6 = 3.5。期望值不一定等于某个可能的结果。

类似地,我们可以定义条件期望 E[X | A],即在事件A发生的条件下,随机变量X的期望值。

期望的线性性与经典问题 🧮

掌握了随机变量的期望后,本节我们将学习一个极其强大的分析工具:期望的线性性。

期望的线性性指出,对于任意两个随机变量X和Y,有:
E[X + Y] = E[X] + E[Y]
这个性质也适用于多个随机变量的和,以及随机变量与常数的乘法。但请注意,它不适用于乘法或除法运算。

以下是两个利用这些概念分析的经典问题:

1. 用有偏硬币模拟公平硬币
问题:假设有一枚硬币,正面朝上的概率为p(未知),反面朝上概率为q=1-p。如何用这枚硬币模拟出公平的(即正反面概率各为1/2)的抛掷?
冯·诺依曼的解决方案是:连续抛掷这枚硬币两次。

  • 如果结果是(正面,反面),则输出“正面”。
  • 如果结果是(反面,正面),则输出“反面”。
  • 如果结果是(正面,正面)或(反面,反面),则忽略这次实验,重新开始。
    因为 P(正,反) = p*qP(反,正) = q*p,所以当算法输出结果时,输出“正面”和“反面”的概率是相等的。这个算法成功的概率是 2pq,因此期望的抛掷次数为 1/(2pq)

2. 收集优惠券问题
问题:有n种不同的宝可梦卡片,每次购买随机获得一种(每种概率为1/n)。期望需要购买多少次才能集齐所有n种卡片?
令随机变量X为集齐所有卡片所需的总购买次数。我们将X分解为多个阶段:设 Y_i 为在已经拥有 i-1 种不同卡片后,直到获得第 i 种新卡片所需的购买次数。

  • Y_1 = 1(第一次购买总能获得新卡片)。
  • 当已拥有 i-1 种卡片时,一次购买获得新卡片的概率是 (n - (i-1)) / n。因此,Y_i 的期望值 E[Y_i] = n / (n - i + 1)
    根据期望的线性性,总次数的期望为:
    E[X] = E[Y_1 + Y_2 + ... + Y_n] = Σ_{i=1}^{n} E[Y_i] = Σ_{i=1}^{n} n/(n-i+1) = n * Σ_{j=1}^{n} (1/j)
    这个和 H_n = Σ_{j=1}^{n} (1/j) 被称为第n个调和数,其值约为 ln n。因此,E[X] ≈ n ln n

总结 🎯

本节课中我们一起学习了离散概率论的核心概念,为理解随机化算法做好了准备。我们回顾了样本空间、事件、概率、条件概率以及随机变量。我们重点掌握了期望值的计算及其关键的线性性质。最后,我们通过“模拟公平硬币”和“收集优惠券”两个经典问题,演示了如何运用这些工具分析算法的期望运行时间。在接下来的课程中,我们将把这些概率工具直接应用到随机化算法的设计与分析中。

009:匹配螺母与螺栓 🧩

在本节课中,我们将学习一个经典的算法问题:匹配螺母与螺栓。我们将探讨问题的定义、一个简单的暴力解法,并重点分析一个高效的随机化快速排序解决方案及其期望运行时间。


课程概述

首先,我们处理一些课程管理事务。期中考试将于下周一晚上7点至9点举行。考试地点在指定的报告厅。本周四的课程将改为复习课,我会讲解一份由历年考题组成的样卷。考试内容涵盖课程材料以及作业0、1、2和3,包括分治法、快速傅里叶变换和动态规划等基础知识,但不涉及最近两节课中复杂的动态规划内容。

考试形式为闭卷,但允许携带一张手写的“小抄”纸。其目的是帮助大家整理思路,而非鼓励死记硬背。对于因时间冲突或特殊情况无法参加考试的同学,请填写网页上的冲突考试登记表。

关于作业,特别是较难的问题,请大家不要过分焦虑。作业的目的是练习和巩固知识,为考试做准备。即使没有完全解决所有问题,思考的过程也极具价值。


问题引入:螺母与螺栓匹配

现在,我们进入今天的核心内容。上次课我们开始讨论随机化算法。今天,我们将探讨一个由Gregory Rollins在90年代初提出的有趣问题:匹配螺母与螺栓

假设你有一个袋子,里面有 n个螺母n个螺栓。每个螺栓都恰好有一个与之匹配的螺母,反之亦然。螺母和螺栓的尺寸各不相同。但是,环境很暗,你无法通过观察比较两个螺母或两个螺栓的大小。

你唯一允许的操作是:拿起一个螺母一个螺栓,尝试将它们拧在一起。结果有三种可能:

  1. 螺栓太小(螺母松动)。
  2. 螺栓太大(穿不过螺母)。
  3. 完美匹配。

你的任务是:使用尽可能少的“测试”操作,将每个螺母与其对应的螺栓匹配起来。


暴力解法与思路转化

一个最直接的暴力算法是:拿起一个螺栓,逐个尝试所有螺母,直到找到匹配的那个。然后拿起下一个螺栓,在剩余的螺母中寻找匹配项。在最坏情况下,这大约需要 n²/2 次测试,即 O(n²) 的时间复杂度。

我们能否做得更好?如果我们能预先知道螺栓(或螺母)从小到大的顺序,那么对于任意一个螺母,我们可以用二分查找O(log n) 次测试内找到其匹配的螺栓,总时间就是 O(n log n)

实际上,排序匹配在这个问题是等价的。因为一旦所有螺母和螺栓完成匹配,我们自然就得到了它们的排序顺序。反之,如果我们能对它们进行排序,匹配问题也就迎刃而解。标准的比较排序算法(如归并排序、堆排序)时间复杂度是 O(n log n)

然而,问题在于我们无法直接比较两个螺母或两个螺栓。我们唯一的操作是:

def compare(nut, bolt):
    # 返回: ‘nut_larger‘, ‘bolt_larger‘, 或 ‘match‘

这导致像归并排序这样的算法无法直接应用,因为递归的第一步——将数组均匀分成两半——要求我们知道哪些螺母和螺栓应该被分到同一侧,而这在初始时是无法做到的。


随机化快速排序解法

虽然归并排序行不通,但快速排序的思路可以很好地适应这个问题。算法的核心是“分区”操作。

以下是随机化快速排序解决螺母螺栓匹配问题的步骤:

  1. 随机选择一个枢轴螺栓:从袋子中随机取出一个螺栓 pivot_bolt
  2. 用枢轴螺栓分区螺母:将 pivot_bolt 与每一个螺母进行比较。根据比较结果,将所有螺母分成三堆:
    • pivot_bolt 的螺母。
    • pivot_bolt 匹配的螺母。
    • pivot_bolt 的螺母。
  3. 找到匹配的枢轴螺母:在上一步中,我们找到了与 pivot_bolt 匹配的那个螺母,记为 pivot_nut
  4. 用枢轴螺母分区螺栓:将 pivot_nut 与剩下的每一个螺栓(除了已作为枢轴的 pivot_bolt)进行比较。同样地,将所有螺栓分成三堆:
    • pivot_nut 的螺栓(即比 pivot_bolt 小)。
    • pivot_nut 匹配的螺栓(即 pivot_bolt,已匹配)。
    • pivot_nut 的螺栓(即比 pivot_bolt 大)。
  5. 递归:现在,我们得到了两个独立的子问题:
    • 所有“小”螺母和“小”螺栓的集合。
    • 所有“大”螺母和“大”螺栓的集合。
      对这两个子问题递归地执行上述步骤。

一次分区操作的成本:步骤2需要 n 次测试,步骤4需要 n-1 次测试(因为 pivot_bolt 已匹配,无需再比)。所以,一次分区总共需要 2n - 1 次测试。


算法运行时间分析

T(n) 为匹配 n 对螺母螺栓所需的期望测试次数。在随机选择枢轴的情况下,枢轴是第 k 小(1 ≤ k ≤ n)螺栓的概率是 1/n

分区后,我们会得到规模为 (k-1)(n-k) 的两个子问题。因此,期望运行时间满足以下递归式:

E[T(n)] = (2n - 1) + (1/n) * Σ_{k=1}^{n} ( E[T(k-1)] + E[T(n-k)] )

我们可以通过一些技巧(例如使用递归树或指示器随机变量)来求解这个递归式。分析结果表明:

E[T(n)] = O(n log n)

更精确地说,期望测试次数约为 4 n H_n,其中 H_n 是第 n 个调和数,近似于 ln n。因此,期望时间复杂度为 Θ(n log n)


指示器随机变量分析法

另一种更简洁的分析方法是使用指示器随机变量期望的线性性质

定义指示器变量 X_ij

  • X_ij = 1,如果算法在运行过程中比较了第 i 小的螺栓和第 j 小的螺母。
  • X_ij = 0,否则。

那么,总测试次数 T(n) 就是所有 X_ij 的和:

T(n) = Σ_{i=1}^{n} Σ_{j=1}^{n} X_ij

根据期望的线性性质,总期望测试次数为:

E[T(n)] = Σ_{i=1}^{n} Σ_{j=1}^{n} E[X_ij] = Σ_{i=1}^{n} Σ_{j=1}^{n} Pr(X_ij = 1)

现在,问题转化为计算任意一对 (i, j) 被比较的概率。

关键结论:在随机化快速排序算法中,第 i 小的螺栓和第 j 小的螺母被比较,当且仅当在算法执行过程中,第一个被选为枢轴、并且其排名在区间 [i, j] 内的元素,恰好是排名为 i 或排名为 j 的那个。

因为在区间 [i, j] 内总共有 |j - i| + 1 个可能的枢轴候选,而只有两个(i 和 j)会导致比较发生。因此:

Pr(X_ij = 1) = 2 / (|j - i| + 1)   (当 i ≠ j)
Pr(X_ii = 1) = 1                   (自身必然匹配比较)

将这个概率代入双重求和公式,经过推导,即可得到 E[T(n)] = O(n log n) 的结论。这种分析方法清晰地揭示了概率如何依赖于两个元素在排序序列中的“距离”。


总结

本节课我们一起学习了“螺母与螺栓匹配”这个经典问题。

  • 我们首先明确了问题的约束:只能通过螺母和螺栓的配对测试来获取信息。
  • 我们分析了一个简单的 O(n²) 暴力解法。
  • 我们认识到,通过随机化快速排序的思路可以高效解决该问题,其核心是利用随机选择的枢轴进行分区,并递归处理。
  • 我们详细分析了该随机算法的期望运行时间,通过递归式求解和指示器随机变量两种方法,都证明了其期望复杂度为 O(n log n)

这个例子展示了随机化算法如何巧妙地绕过确定性算法难以处理的问题(如无法直接比较同类物品),以简单的逻辑和高效的期望性能解决实际问题。

010:期中复习课

在本节课中,我们将一起回顾几个典型的算法问题,这些问题来自以往的考试或具有相似的难度级别。我们将学习如何分析问题、选择合适的算法策略(如动态规划、分治、归纳法等),并清晰地表述解决方案。课程将涵盖斐波那契字符串、树节点标记、有向无环图中的路径、集合的Minkowski和、寻找两个有序数组的中位数以及一个卡牌游戏策略问题。


算法课程:P10.1:斐波那契字符串 🧮

上一节我们介绍了课程概述,本节中我们来看看第一个关于斐波那契字符串的证明问题。

斐波那契字符串定义如下:

  • F(0) = “0”
  • F(1) = “1”
  • 对于 n ≥ 2F(n) = F(n-1) · F(n-2),其中 “·” 表示字符串连接。

例如,F(2) = “10”, F(3) = “101”。

问题

  1. 计算 F(8)。
  2. 证明关于斐波那契字符串的某些性质(例如,其长度、特定模式的出现等)。

核心思路
此类证明问题通常采用数学归纳法。你需要根据递推定义,建立归纳假设,并完成归纳步骤的推导。


算法课程:P10.2:树节点标记问题 🌳

上一节我们讨论了基于归纳的证明,本节中我们来看一个需要在树上进行动态规划的问题。

问题描述
给定一棵有根树 T。你需要为每个节点标记数字 1、2 或 3,且要求每个节点的标签必须与其父节点的标签不同。一种标记方案的成本定义为:标签值小于其父节点标签值的节点数量。目标是设计一个算法,计算所有可能标记方案中的最小成本。

示例
在下图的标记中,红色节点(标签小于父节点)有9个,因此成本为9,但这并非最优解。

核心思路
这是一个典型的树形动态规划问题。子问题的状态需要包含当前节点及其父节点的标签信息。

动态规划定义
定义 dp(v, c) 为:在以节点 v 为根的子树中,当节点 v 的父节点标签为 c 时,该子树能达到的最小成本。

状态转移
对于节点 v,其父节点标签为 parent_labelv 可以选择的标签是 {1,2,3} 中不等于 parent_label 的值。对于 v 的每一个子节点 child,我们需要递归求解 dp(child, label_of_v)v 节点的成本贡献取决于其标签是否小于 parent_label

伪代码框架

def dfs(v, parent_label):
    for label in {1,2,3} - {parent_label}:
        cost = 0
        if label < parent_label:
            cost += 1
        for child in v.children:
            cost += dfs(child, label)
        min_cost = min(min_cost, cost)
    return min_cost

最终答案为 min(dfs(root, 1), dfs(root, 2), dfs(root, 3)),其中 root 的父节点标签可以虚拟为不影响结果的任意值(如0)。

算法复杂度
使用后序遍历(自底向上)计算,每个状态的计算时间与其子节点数成正比。总时间复杂度为 O(n),其中 n 为树中节点数。


算法课程:P10.3:有向无环图中的最小跨度路径 🗺️

上一节我们解决了树上的动态规划问题,本节中我们来看看在有向无环图中的路径问题。

问题描述
给定一个有向无环图 G,每个顶点 v 有一个关联的高度 h(v)。对于图中的每条边 (u->v),都满足 h(u) > h(v)。一条路径的跨度定义为路径起点高度与终点高度之差。给定一个整数 K,要求找到一条至少包含 K 条边的路径,使其跨度最小。需要描述并分析算法。

示例
对于下图,给定 K=3,算法应报告数字4(对应路径 8->7->6->4,跨度 8-4=4)。

核心思路
由于高度沿边严格递减,图是无环的。问题要求最小化 h(start) - h(end),且边数至少为 K。我们可以定义动态规划状态,同时追踪当前所在节点和已使用的边数(或剩余所需边数)。

动态规划定义
定义 dp(v, L) 为:从节点 v 出发,至少使用 L 条边的路径所能达到的最小跨度。

状态转移
考虑从 v 出发的第一条边 (v -> w)。选择这条边后,路径跨度包含 h(v) - h(w) 这部分,剩余部分由从 w 出发至少使用 L-1 条边的路径贡献。因此,我们取所有出边中的最小值:
dp(v, L) = min_{(v->w) in E} [ (h(v) - h(w)) + dp(w, L-1) ]

边界条件

  • dp(v, 0) = 0 (不使用边,路径就是节点自身,跨度为0)。
  • 如果节点 v 没有出边,但对于 L > 0dp(v, L) = ∞(表示不存在这样的路径)。
  • 观察:由于所有高度不同,增加边数只会增加(或至少不减少)跨度,因此“至少 K 条边”的最优解必然由“恰好 K 条边”的路径取得。上述递推式基于此观察。

算法实现与复杂度

  1. 对 DAG 进行拓扑排序。
  2. 按照逆拓扑序(从汇点到源点)遍历节点。
  3. 对于每个节点 v,计算 dp(v, L),其中 L 从 0 到 K。
  4. 最终答案是所有节点 dp(v, K) 的最小值。

时间复杂度为 O((V+E) * K),因为每个状态 (v, L) 需要检查 v 的所有出边。


算法课程:P10.4:计算Minkowski和的元素数量 ⚡

上一节我们处理了图上的动态规划,本节中我们来看一个可以利用快速傅里叶变换巧妙解决的问题。

问题描述
给定两个整数集合 X 和 Y。它们的 Minkowski 和 X+Y 定义为所有可能的两数之和的集合:{x+y | x∈X, y∈Y}

  1. 描述一个算法,在 O(n² log n) 时间内计算 |X+Y|(集合的大小),其中 n = |X| + |Y|
  2. 描述一个算法,在 O(M log M) 时间内计算 |X+Y|,其中 M 是集合中元素绝对值的最大值。注意,此处未给出集合大小 n。

第一部分解法(朴素)
以下是朴素的 O(n² log n) 算法步骤:

  1. 生成所有 个可能的和 x+y
  2. 将这个列表排序,耗时 O(n² log n)
  3. 在排序后的列表中遍历并去除重复项,然后计数。

第二部分解法(FFT)
核心思路是将集合表示为多项式(或向量),利用卷积运算(通过FFT加速)来计算每个可能的和出现了多少次。

算法步骤

  1. 构造多项式:对于集合 X,构造多项式 P(z) = Σ_{x∈X} z^x。对于集合 Y,构造多项式 Q(z) = Σ_{y∈Y} z^y。这里 z^x 的指数 x 可能为负,可以通过给所有指数加上 M 来调整为非负,这不影响结果。
  2. 计算卷积:计算多项式乘积 R(z) = P(z) * Q(z)。使用 FFT 可以在 O(M log M) 时间内完成,因为多项式系数的非零索引范围约为 [-M, M],长度是 O(M)
  3. 解释结果:乘积 R(z) 中,z^i 的系数 c_i 等于满足 x+y = i(x, y) 对的数量。
  4. 得到答案|X+Y| 就是 R(z)系数 c_i > 0 的项 i 的数量。

直观理解
多项式乘法本质上是系数的卷积:c_i = Σ_{k} a_k * b_{i-k}。这里 a_k=1 表示 k∈Xb_{i-k}=1 表示 i-k∈Y,因此 c_i 统计了所有和为 i 的配对。


算法课程:P10.5:寻找两个有序数组的中位数 🔍

上一节我们利用了FFT解决集合问题,本节中我们来看一个经典的分治算法问题。

问题描述
给定两个已排序的数组 A 和 B,各包含 n 个互不相同的整数。设计一个 O(log n) 时间的算法,找到这两个数组合并后的第 n 小元素(即中位数)。

示例
A = [1, 3, 5, 7, 9], B = [2, 4, 6, 8, 10]。合并后为 [1,2,3,4,5,6,7,8,9,10],第5小元素是5,第6小元素是6。这里 n=5,目标是找到第5小元素,即5。

核心思路(分治)
比较两个数组的中位数 A[mid]B[mid]

  • A[mid] < B[mid]
    • 则合并数组的中位数不可能在 A 的前半部分(A[0..mid-1]),因为这部分元素太小。
    • 同样,中位数也不可能在 B 的后半部分(B[mid+1..]),因为这部分元素太大。
    • 因此,可以安全地丢弃 A 的前半部分和 B 的后半部分。
  • A[mid] > B[mid],则进行对称操作:丢弃 A 的后半部分和 B 的前半部分。
  • A[mid] == B[mid],则该值即为中位数。

递归过程
丢弃部分元素后,问题规模减半。但需要注意,我们丢弃的元素数量可能不完全相等,因此需要调整在新子数组中寻找的“第 k 小”的 k 值。同时,需要小心处理数组长度为奇数/偶数时的索引以及递归基(当数组长度很小时,直接合并查找)。

算法复杂度
每次递归都将问题规模(n)大致减半,因此时间复杂度为 O(log n)


算法课程:P10.6:击败贪心对手的卡牌游戏 🃏

上一节我们学习了分治查找中位数,本节最后我们分析一个游戏策略问题,它引导我们使用动态规划。

问题描述
你和Elmo玩一个卡牌游戏。一排卡牌,每张有一个点数。游戏开始时,你们能看到所有点数。轮流进行,当前玩家总是选择拿走最左边最右边的卡牌,并获得其点数。游戏结束后点数高者胜。Elmo采用贪心策略:总是选择当前左右两端点数较大的那张牌。

  1. 证明如果你也使用贪心策略,在某些牌局下会输给Elmo。
  2. 描述一个算法,为你找到最优策略,以最大化最终得分(即尽可能大地击败Elmo)。

第一部分(反例)
需要构造一个具体的牌序列,使得当双方都采用贪心策略时,后手的Elmo能赢。例如,序列 [2, 10, 5]。你先手,贪心选右边(5),Elmo选左边(10),你最后拿(2),你总分7,Elmo10,Elmo胜。

第二部分(动态规划)
这是一个零和博弈问题,可以用动态规划求解最优策略。

定义状态
定义 dp(i, j) 为:当牌堆剩余区间为 [i, j] 时,当前玩家(不一定是“你”)能获得的最大净胜分(当前玩家得分减去对手得分)。

状态转移
当前玩家有两种选择:

  1. 拿左边的牌 points[i]:那么对手将在剩余区间 [i+1, j] 上以最优策略行动。因此,当前玩家的净胜分为 points[i] - dp(i+1, j)
  2. 拿右边的牌 points[j]:同理,净胜分为 points[j] - dp(i, j-1)
    当前玩家会选择两者中较大的一个:
    dp(i, j) = max(points[i] - dp(i+1, j), points[j] - dp(i, j-1))

边界条件
i > j 时,区间为空,dp(i, j) = 0

计算与答案
使用记忆化搜索或自底向上(按区间长度递增)的方式填充 dp 表。最终,dp(0, n-1) 的值代表先手玩家(也就是“你”)的最优净胜分。如果这个值大于0,你就有一个必胜策略。

算法复杂度
状态数为 O(n²),每个状态转移是 O(1),总时间复杂度为 O(n²)


总结 📚

本节课中我们一起学习了期中考试可能涉及的多种算法问题类型:

  1. 数学归纳法:用于证明序列或结构的性质(如斐波那契字符串)。
  2. 树形动态规划:通过定义与父节点相关的状态,递归求解树上的优化问题(如节点标记)。
  3. 图上动态规划:在DAG等特殊图上,结合拓扑序定义状态,解决路径相关问题。
  4. 快速傅里叶变换的应用:将集合求和问题转化为多项式乘法,利用FFT高效求解。
  5. 分治算法:通过比较中位数,每次递归丢弃一半数据,在对数时间内解决有序数组的中位数查找问题。
  6. 博弈动态规划:在零和游戏中,通过定义净胜分状态,找到最优策略。

掌握这些问题的识别方法和解决框架,对于应对算法考试至关重要。记得在考试中合理分配时间,先解决最有把握的问题。

011:P11 Treaps

在本节课中,我们将学习一种名为Treap的简单而强大的数据结构。Treap结合了二叉搜索树和堆的特性,能够以期望的对数时间复杂度支持动态集合的插入、删除、查找、分裂与合并等操作。我们将从基本概念开始,逐步理解其工作原理、操作实现以及背后的概率分析。


概述

Treap是一种随机化的二叉搜索树。它的每个节点包含一个搜索键和一个随机分配的优先级。从搜索键的角度看,它是一棵二叉搜索树;从优先级的角度看,它又是一个堆(通常是最大堆或最小堆)。这种双重属性使得Treap在期望上是平衡的,从而保证了高效的操作性能。


Treap的定义与性质

上一节我们介绍了Treap的基本思想,本节中我们来看看它的精确定义和关键性质。

一个Treap是一棵二叉树,每个节点存储一个键值对 (key, priority),并满足以下两个性质:

  1. 二叉搜索树性质:对于任意节点,其左子树中所有节点的键值小于该节点的键值,其右子树中所有节点的键值大于该节点的键值。
    • 公式:node.left.key < node.key < node.right.key
  2. 堆性质:我们通常采用最小堆,即每个节点的优先级不大于其子节点的优先级。
    • 公式:node.priority <= node.left.prioritynode.priority <= node.right.priority

一个重要结论:给定一组互不相同的键和互不相同的优先级,存在唯一的Treap结构。根节点是优先级最小的节点,然后根据其键值将剩余节点划分到左子树(键值较小)和右子树(键值较大),并递归构建。


Treap的核心操作

理解了Treap的结构后,我们来看看如何对其进行动态操作。所有操作都依赖于一个基础工具:旋转

旋转操作

旋转是用于在保持二叉搜索树性质的前提下,调整节点父子关系的局部操作。以下是右旋(将左子节点提升为父节点)的示意图和代码逻辑。

设节点 uv 的左孩子,ABC 是它们的子树。旋转操作保持 A < u < B < v < C 的顺序不变。

def rotate_right(v):
    u = v.left
    v.left = u.right
    u.right = v
    return u  # 新的子树根节点

对称地,也存在左旋操作。旋转可以在常数时间内完成。


插入操作

插入操作分为两步,模拟了“先按二叉搜索树插入,再按堆调整”的过程。

  1. 二叉搜索树插入:像在普通二叉搜索树中一样,根据键值寻找到插入位置,创建一个新节点作为叶子节点插入。同时,为该节点随机生成一个优先级。
  2. 堆化上浮:如果新节点的优先级破坏了堆性质(即比其父节点的优先级小),则通过旋转操作将其“上浮”。不断将其与父节点进行旋转,直到堆性质恢复为止。

由于每次旋转都将新节点向根节点移动一步,这个过程必然会在有限步内终止。


删除操作

删除操作是插入操作的逆过程,目标是将待删除节点移至叶子位置后轻松移除。

  1. 堆化下沉:将待删除节点的优先级设置为 +∞(对于最小堆)。此时,该节点优先级大于其子节点,破坏了堆性质。为了修复,我们需要将其“下沉”:比较其左右子节点的优先级,将优先级较小的子节点通过旋转提升上来。重复此过程,直到该节点成为一个叶子节点。
  2. 剪除叶子:直接移除已成为叶子节点的待删除节点。

分裂与合并操作

利用插入和删除的思想,我们可以实现强大的分裂与合并操作。

  • 分裂(Split):给定一个值 x,将Treap分裂成两个Treap,一个包含所有键值小于 x 的节点,另一个包含所有键值大于 x 的节点。
    • 方法:插入一个键值为 x、优先级为 -∞ 的临时节点。根据堆性质,这个节点会通过旋转成为整棵树的根。此时,它的左子树和右子树就是所需的两部分。最后删除这个临时根节点。
  • 合并(Join):给定两个Treap LR,且 L 中所有键值小于 R 中所有键值,将它们合并成一棵Treap。
    • 方法:创建一个优先级为 -∞、键值介于两树之间的临时根节点,其左孩子指向 L,右孩子指向 R。然后删除这个临时根节点(即执行一次删除操作),其左右子树会在调整过程中自然合并。

Treap的性能分析

我们已经了解了所有操作的步骤,现在来分析它们为什么高效。所有操作的时间都主要花费在从根节点到某个节点的路径遍历上,因此节点的深度是关键。

期望深度分析

我们分析在优先级完全随机的情况下,任意节点 k 的期望深度。深度定义为从根节点到该节点的路径上的边数(即祖先节点个数)。

关键引理:对于两个键值 i < k,节点 i 是节点 k 的祖先的充要条件是:在所有键值介于 ik 之间(含)的节点中,i 的优先级是最小的。

直观理解:在Treap的递归构建过程中,一个节点会成为某个子树的根,当且仅当它在当前集合中优先级最小。如果 iik 这个区间内优先级最小的,那么它将成为包含 k 的这个子树的根,从而成为 k 的祖先。

基于这个引理,ik 祖先的概率就是 1 / (k - i + 1)(即 i[i, k] 区间内优先级最小的概率)。

因此,节点 k 的期望深度为:
E[depth(k)] = Σ_{i<k} 1/(k-i+1) + Σ_{i>k} 1/(i-k+1)

这个和式被两个调和数所界定,其结果是 O(log n)。具体地,它小于 2 * ln(n)

操作时间复杂度

由于插入、删除、查找等操作的时间都与相关节点的深度成正比,因此它们的期望时间复杂度都是 O(log n)。更严格的分析可以证明,这个对数深度是高概率成立的,即性能非常稳定。


Treap的多种视角

Treap的魅力还在于它连接了几个不同的概念:

  1. 随机优先级二叉搜索树:这是最直接的定义。
  2. 按随机顺序插入的二叉搜索树:如果按照优先级从小到大的顺序将节点插入一棵普通的、不进行平衡操作的二叉搜索树,得到的结果就是Treap。这解释了为什么随机插入能产生平衡的树。
  3. 随机化快速排序的递归调用树:Treap的结构与随机选择主元的快速排序的递归过程完全一致。树中节点 i 是节点 k 的祖先,等价于在快速排序中,i 曾作为主元与 k 进行比较。对Treap深度的分析本质上就是分析快速排序的比较次数。

这种深刻的联系意味着,对Treap的操作(如插入一个遗漏元素)类似于“回到”快速排序的递归历史中去修复它。这种“持久化”或“回溯”思想在诸如版本控制系统(如Git)等高级应用中有重要体现。


总结

本节课中我们一起学习了Treap数据结构。我们首先了解了它结合二叉搜索树和堆的双重特性,然后学习了基于旋转的核心操作:插入、删除、分裂与合并。通过分析节点期望深度为 O(log n),我们证明了所有操作都具有高效的对数期望时间复杂度。最后,我们探讨了Treap与随机插入二叉搜索树、随机化快速排序之间的深刻联系,揭示了其简洁设计背后的强大与优美。与复杂的AVL树或红黑树相比,Treap以其易于实现、理解和证明的优点,成为实现动态有序字典的一个出色选择。

012:尾不等式与算法分析

在本节课中,我们将学习如何分析随机算法的运行时间,特别是如何证明算法“以高概率”运行得很快。我们将从基本的概率不等式开始,逐步深入到更强大的分析工具,并最终应用这些工具来分析Treap和快速排序等算法。

概述

随机算法的期望运行时间分析(如快速排序的O(n log n))是一个良好的开端。然而,我们通常希望更强的保证:算法运行时间远差于期望值的概率非常低。这种“尾概率”的分析需要更精细的工具,即尾不等式。本节课我们将学习马尔可夫不等式、切比雪夫不等式以及指数矩不等式,并了解如何利用随机变量之间的独立性来获得越来越强的概率上界。

马尔可夫不等式

马尔可夫不等式是最基本、最通用的尾不等式。它不对随机变量的分布做任何特殊假设。

定理(马尔可夫不等式):设Z是任意非负随机变量,其期望值为μ = E[Z]。那么对于任意实数z > 0,有:

P[Z ≥ z] ≤ μ / z

直观理解与推导
我们可以将期望值E[Z]视为概率分布曲线下的面积。考虑事件Z ≥ z对应的概率P[Z ≥ z]。我们可以通过比较面积来证明这个不等式。

定义指示函数:对于每个可能的整数值i,考虑事件Z ≥ i。期望值E[Z]可以写成:

E[Z] = Σ_{i≥1} P[Z ≥ i]

现在,考虑我们关心的阈值z。我们有:

E[Z] = Σ_{i≥1} P[Z ≥ i] ≥ Σ_{i=1}^{z} P[Z ≥ i] ≥ Σ_{i=1}^{z} P[Z ≥ z] = z * P[Z ≥ z]

整理后即得P[Z ≥ z] ≤ E[Z] / z

应用与局限性
马尔可夫不等式非常通用,但因此得出的上界通常很弱。例如,若已知快速排序的期望运行时间为4n log n,该不等式仅能告诉我们,运行时间超过8n log n的概率不超过1/2。为了得到更紧的界(如概率随n增大而衰减),我们需要对随机变量的特性做出更强假设。

从独立性到更强的尾界

为了获得更紧的概率上界,我们需要利用随机变量之间的关系。核心思想是:随机变量之间越独立,其和偏离期望值的概率衰减得越快。

首先,我们回顾独立性的定义:

  • 独立:两个随机变量X和Y独立,当且仅当对于任意值x, y,有P[X=x ∧ Y=y] = P[X=x] * P[Y=y]。这意味着知道一个变量的值不会提供关于另一个变量的任何信息。
  • 完全独立:一组随机变量X1, X2, ..., Xn是完全独立的,如果其中任意子集联合取值的概率等于各自概率的乘积。
  • k阶独立:一组随机变量是k阶独立的,如果其中任意大小不超过k的子集是完全独立的。例如,“两两独立”意味着任意两个变量是独立的,但三个变量之间可能不独立。

上一节我们介绍了最通用的马尔可夫不等式。本节中,我们将看到,如果随机变量具备某种独立性,我们可以得到强得多的结论。

切比雪夫不等式

如果我们关心的随机变量X是多个两两独立的指示变量(取值为0或1)之和,那么我们可以使用切比雪夫不等式来获得更好的尾概率上界。

设定:设X = Σ_{i=1}^{n} X_i,其中每个X_i是指示变量,P[X_i = 1] = p_i。令μ = E[X] = Σ p_i

定理(切比雪夫不等式):如果X_i是两两独立的,那么对于任意z > 0,有:

P[ |X - μ| ≥ z ] ≤ μ / z^2

更常用的形式是,对于任意δ > 0

P[ X ≥ (1+δ)μ ] ≤ 1 / (δ^2 μ)
P[ X ≤ (1-δ)μ ] ≤ 1 / (δ^2 μ)

思路证明

  1. 我们关心|X - μ|,即偏离均值的距离。为了应用马尔可夫不等式(要求非负),我们考虑平方偏差Y = (X - μ)^2
  2. 计算E[Y] = E[(X-μ)^2],这被称为方差。利用两两独立性,可以证明E[Y] ≤ μ
  3. 注意到事件|X-μ| ≥ z等价于事件Y ≥ z^2
  4. 对随机变量Y应用马尔可夫不等式:P[Y ≥ z^2] ≤ E[Y] / z^2 ≤ μ / z^2

意义
与马尔可夫不等式给出的1/δ上界相比,切比雪夫不等式给出了1/(δ^2 μ)的上界。由于μ通常随问题规模n增长(例如在算法分析中),这个上界可以随着n增大而衰减(例如1/n量级),这是一个显著的改进。

指数矩不等式与完全独立

如果我们拥有的随机变量是完全独立的,我们可以得到最强的结果——尾概率呈指数衰减。

定理(指数矩不等式,切尔诺夫界的一种形式):设X = Σ_{i=1}^{n} X_i,其中X_i是独立的伯努利变量(不一定同分布),μ = E[X]。那么对于任意δ > 0,存在一个上界:

P[ X ≥ (1+δ)μ ] ≤ exp( -δ^2 μ / 3 )   (当 0 < δ ≤ 1 时较紧)
P[ X ≤ (1-δ)μ ] ≤ exp( -δ^2 μ / 2 )

更一般的形式是,对于任意t > 0,有P[X ≥ μ + t] ≤ exp( -t^2/(2(μ+t/3)) )

直观理解
这个不等式的证明核心是计算E[exp(λX)](即指数矩),并利用完全独立性将其分解为各变量指数矩的乘积。通过优化参数λ,得到最紧的指数形式上界。

意义
指数衰减(如e^{-c n log n})比多项式衰减(如1/n^2)要快得多。这意味着当变量完全独立时,偏离期望值很远的事件发生的概率是极其微小的。

应用:以高概率分析Treap深度

现在,让我们应用这些工具来分析一个熟悉的随机数据结构:Treap。

回顾Treap深度的分析:一个特定节点k的深度D(k)等于所有可能成为其祖先的节点数量之和。即:

D(k) = Σ_{i≠k} X_i,其中 X_i = 1 当且仅当节点i是节点k的祖先。

我们知道E[X_i] = 1 / |i-k|+1,且E[D(k)] = H_{k-1} + H_{n-k} < 2 ln n

关键观察:虽然所有的X_i并不完全独立,但我们可以将它们分成两组:i < k的节点和i > k的节点。可以证明,每组内部的指示变量是相互独立的。例如,对于所有i < k,事件“i是k的祖先”是独立的。

以下是分析步骤:

  1. 分组应用指数矩不等式:将深度分解为D(k) = D_left(k) + D_right(k),分别对应左侧祖先和右侧祖先。由于每组内部变量独立,我们可以对D_left(k)D_right(k)分别应用指数矩不等式。
  2. 控制单个节点的深度:对于任意常数c > 2,我们可以证明:
    P[ D(k) ≥ c ln n ] ≤ n^{-(c/2 - 1)}   (大致形式,具体常数取决于c)
    
    这意味着单个节点深度过大的概率随n增大而多项式衰减,且指数部分随c增大而增大。
  3. 控制整棵树的最大深度:利用布尔不等式(并集界),整棵树的最大深度超过c ln n的概率,不超过所有节点深度超过c ln n的概率之和:
    P[ max_depth ≥ c ln n ] ≤ Σ_{k=1}^{n} P[ D(k) ≥ c ln n ] ≤ n * n^{-(c/2 - 1)} = n^{-(c/2 - 2)}
    
  4. 结论:通过选取足够大的常数c(例如c=8),我们可以使n^{-(c/2 - 2)}成为一个衰减极快的函数(如1/n^2)。这意味着Treap的最大深度以高概率为O(log n)。更进一步,这个结果暗示了Treap的期望高度本身就是O(log n),因为尾部贡献极小。

类似的分析完全可以应用于随机化快速排序,证明其运行时间以高概率集中在O(n log n)附近,远差于期望值的可能性微乎其微。

总结

在本节课中,我们一起学习了用于分析随机算法尾概率的关键工具——尾不等式。

  • 我们从最通用但最弱的马尔可夫不等式开始,它仅要求随机变量非负。
  • 为了获得更紧的界,我们引入了随机变量间的独立性概念。利用两两独立性,我们得到了切比雪夫不等式,它提供了多项式衰减的概率上界。
  • 最后,对于完全独立的随机变量,我们介绍了强大的指数矩不等式(切尔诺夫界),它能给出指数衰减的上界,这是分析算法“以高概率”行为的最有力工具。

我们将这些不等式应用于Treap(以及间接应用于快速排序)的深度分析,证明了其最大深度不仅期望值是O(log n),而且实际深度远超此值的概率是超多项式小的。这为随机算法的可靠性和效率提供了坚实的理论保证。


课程内容整理自伊利诺伊大学CS473算法课程(2022年秋季)第12讲。核心不等式将在考试公式表中提供。

013:哈希表

在本节课中,我们将要学习哈希表,这是随机化算法中最常见、最强大的应用之一。我们将探讨哈希表的基本概念、如何设计好的哈希函数、如何处理碰撞,并最终介绍一种能实现常数时间查找的完美哈希方案。

哈希表的基本概念

哈希表的目标是存储一个来自某个大集合(称为全集 U)的数据子集 S,并尽可能实现直接访问。我们使用一个大小为 M 的数组(哈希表)来存储这些数据。

为了将全集 U 中的元素映射到数组的索引上,我们需要一个哈希函数 h。理想情况下,我们希望对于数据集 S 中的所有不同元素 xy,都有 h(x) != h(y)。这样,每个元素都能被无冲突地放入表中。

然而,由于全集 U 通常远大于表的大小 M,冲突(即 h(x) == h(y))是不可避免的。因此,我们需要策略来处理这些冲突。

哈希函数的性质

上一节我们介绍了哈希表的基本目标,本节中我们来看看对哈希函数有哪些要求。一个常见的误解是追求均匀性,即对于任意元素 x 和任意索引 i,有 P(h(x) = i) = 1/M。但一个均匀的哈希函数族可能是无用的,例如,一个只将所有元素映射到固定索引 i 的函数 Hi 组成的族也是均匀的。

我们真正需要的是全域性。对于一个从函数族 H 中随机选择的哈希函数 h,我们希望对于任意两个不同的元素 xy,发生碰撞的概率很小:

P(h(x) = h(y)) <= 1/M

这个性质保证了在平均情况下,冲突是可控的。

处理碰撞:链地址法

当冲突发生时,我们需要一种方法来解决它。以下是两种常见的方法,我们先介绍链地址法。

在链地址法中,哈希表的每个槽位不再存储单个元素,而是存储一个链表(或其他数据结构)。所有哈希到同一索引的元素都被放入这个链表中。

查找一个元素 x 的期望时间是 O(1 + E[链表长度])。如果使用一个全域哈希函数族,并且表的负载因子 α = n/M 是一个常数,那么期望查找时间就是常数。

哈希函数的构建方法

我们已经了解了哈希函数需要具备全域性。那么,如何构建这样的函数族呢?以下是几种经典的方法:

  1. 乘法哈希:选择一个大于全集大小的素数 p,随机选择参数 a ∈ {1, ..., p-1}。哈希函数定义为:

    h(x) = ((a * x) mod p) mod M
    

    这个族是“接近全域”的。

  2. 带偏移的乘法哈希:在乘法哈希基础上增加一个随机偏移 b

    h(x) = ((a * x + b) mod p) mod M
    

    这个族是全域且均匀的。

  3. 二进制乘法哈希:假设键是 w 位二进制数,表大小 M = 2^l。随机选择一个 w 位数 a,哈希函数为:

    h(x) = (a * x) >> (w - l)
    

    即取乘法结果的高 l 位。这也是接近全域的。

  4. 查表哈希:将 w 位的键分割成多个小块(如两个 w/2 位的块)。预先准备多个随机表 T1, T2, ...,每个表存储随机值。哈希值为各表查得值的异或和。

    h(x) = T1[x_高半部分] XOR T2[x_低半部分]
    

    这种方法实现简单,且具有更强的统计保证(如2-全域性)。

  5. 矩阵哈希:构建一个随机的 l × w 的二进制矩阵 A。将键 x 视为一个 w 维二进制向量,哈希值是通过矩阵乘法(在模2下,即异或)得到的 l 维向量:

    h(x) = A * x (mod 2)
    

    这也是接近全域的。

从期望性能到最坏情况性能

虽然链地址法在期望情况下能提供常数时间的查找,但最坏情况下(即最长链的长度)可能达到 Θ(log n / log log n)。这对于需要强性能保证的场景(如网络路由器)是不够的。

如果我们简单地通过增大表尺寸(例如使 M ≈ n^2)来避免冲突,虽然可以高概率获得无冲突的完美哈希,但空间开销过大。

完美哈希

本节中我们来看看如何实现一个空间线性、查找时间严格为常数的数据结构,即完美哈希

其核心思想是使用两级哈希:

  1. 第一级:使用一个全域哈希函数 h1n 个元素散列到一个大小为 n 的主表 T 中。设散列到槽位 i 的元素个数为 ni
  2. 第二级:对于主表的每个槽位 i,如果 ni > 1,则为其构建一个次级哈希表 TiTi 的大小为 mi = ni^2(或略大的素数)。并为 Ti 独立地随机选择一个次级哈希函数 h2_i,直到 h2_ini 个元素上无冲突为止。由于表大小为 ni^2,根据之前分析,随机哈希函数无冲突的概率大于 1/2,因此期望只需尝试常数次即可找到无冲突的 h2_i

查找元素 x 时,先计算 i = h1(x),再计算 j = h2_i(x),最后访问 T[i][j] 即可。由于次级表内无冲突,查找是确定性的常数时间。

现在分析总空间复杂度。总空间为主表大小 n 加上所有次级表大小之和 Σ mi。我们需要计算 E[Σ ni^2]

E[Σ ni^2] = Σ E[ni^2] = Σ (E[ni] + Σ_{x≠y} P(h1(x)=h1(y)=i))

由于 h1 是全域的,P(h1(x)=h1(y)=i) <= 1/n^2。经过推导可得 E[Σ ni^2] < 2n。因此,总期望空间为 O(n)

总结

本节课中我们一起学习了哈希表的核心原理。我们从哈希表的基本概念和理想目标出发,认识到冲突的必然性,从而引入了哈希函数族和全域性的概念。我们介绍了使用链地址法处理冲突,并分析了其在期望下的常数时间性能。接着,我们探讨了几种构建全域哈希函数族的具体方法。最后,为了克服期望性能的局限性,我们学习了一种两级哈希的完美哈希方案,它能够在线性空间内实现确定性的常数查找时间,这是通过巧妙的概率分析和二级结构设计达成的。哈希表是算法设计中平衡效率与随机化力量的杰出范例。

014:高级哈希技术

在本节课中,我们将继续探讨哈希技术,特别是开放寻址哈希。我们将了解其工作原理、性能分析,以及如何通过精心设计的哈希函数来保证其高效性。本节课内容比之前讨论的哈希技术更为深入,但核心算法本身并不复杂。我们的目标是,即使不完全理解分析过程,你也能实现一个性能可证明良好的开放寻址哈希表。


回顾:链式哈希

上一节我们介绍了链式哈希表。其核心思想是:哈希表的每个槽位不存储单个元素,而是存储一个链表(或其他数据结构),用于存放所有哈希到该槽位的元素。

如果选择正确的哈希函数(例如全域哈希函数),并合理设置次级哈希表的大小,我们就能保证:

  • 常数时间的查找
  • 常数期望时间的插入和删除(不考虑重建操作)。

简而言之,只要选择合适的哈希函数,链式哈希就能高效工作。


引入:开放寻址哈希

本节中,我们来看看开放寻址哈希。在这种方法中,哈希表本身就是一个连续的数组(内存块),不包含任何额外的数据结构。我们仍然需要解决冲突。

其高级思想是:如果第一个哈希值对应的槽位已被占用,则尝试第二个哈希函数,如果还不行,就尝试第三个,依此类推。最终,我们得到一系列哈希函数 H0(x), H1(x), H2(x), ...

我们假设这个序列 H0(x), H1(x), ..., H_{m-1}(x) 构成了索引 {0, 1, ..., m-1} 的一个排列。这意味着哈希函数不是返回一个索引,而是返回表中所有索引的一个探测顺序。我们按此顺序探测,直到找到一个空槽插入元素,或者(在查找时)找到目标元素或遇到空槽。

线性探测与二进制探测

在实际中,常见的实现是线性探测
H_i(x) = (H_0(x) + i) mod m
探测序列从初始哈希值开始,按顺序遍历后续索引。

然而,线性探测的分析较为复杂。我们将介绍一种实践中表现更好、也更容易分析的方法:二进制探测。我们假设表大小 m 是 2 的幂(便于动态调整),并使用按位异或操作来生成探测序列:
H_i(x) = H_0(x) XOR i
这里 i 以二进制形式参与运算。

以下是二进制探测的工作原理示例:
假设 H_0(x) = 0101(二进制,即十进制5)。

  • H_1(x) = 0101 XOR 0001 = 0100(4)
  • H_2(x) = 0101 XOR 0010 = 0111(7)
  • H_3(x) = 0101 XOR 0011 = 0110(6)
  • 以此类推。

二进制探测的优势在于其缓存友好性。在探测过程中,它会完整地探索一个大小为 2^k 的地址块,然后再移动到其他块,这符合现代CPU缓存行的边界(大小通常是2的幂),从而减少缓存失效。


性能分析:直觉与设定

为了分析性能,我们首先做一个很强的假设:强均匀哈希假设。即每个探测序列都是所有可能排列中的一个均匀随机排列。在这个假设下,我们可以推导出插入第 n 个元素到大小为 m 的表中所需的期望探测次数 T(n, m) 满足一个递归关系,最终解为 m / (m - n)

通常我们关注负载因子 α = n / m。如果我们设定负载因子为 1/2(即表半满),那么期望探测次数约为 2 次。更一般地,期望探测次数约为 1 / (1 - α)

然而,“强均匀哈希假设”在实践中难以实现。我们需要更实际、可证明的保证。


基于块的分析框架

我们转向分析二进制探测。分析的核心是研究包含初始哈希值 H_0(x) 的、不同大小的块是否被填满。

我们定义 B_k(x) 为包含 H_0(x) 的大小为 2^k 的地址块。算法的高层描述如下:

for k from 0 to log m:
    if block B_k(x) is not full:
        put x into B_k(x) and return

由于表只是半满,最终总会找到一个未满的块(例如整个表)。

算法的运行时间与找到的第一个未满块的大小成正比(实际上最多是其常倍数)。因此,我们需要分析包含 H_0(x) 的最大满块的期望大小。

从“满”到“流行”

判断一个块是否“满”很复杂,因为它不仅取决于哈希到该块的元素,还取决于因冲突从其他块“溢出”到该块的元素。

为了简化分析,我们引入“流行”的概念:一个块是流行的,如果至少有 2^k 个元素的初始哈希值 H_0(y) 落在这个大小为 2^k 的块内。也就是说,在忽略溢出逻辑的链式哈希中,这个块也会被填满。

关键关系在于:如果一个块是满的,那么要么它自己是流行的,要么它的某个“叔父块”(即更大尺寸的祖先块)是流行的。因此,块“满”的概率 F(k) 可以以其自身及更大块“流行”的概率 P(j) 之和为上界。

分析“流行”的概率

Y 为初始哈希值落在块 B_k(x) 内的元素数量。在哈希值均匀随机的假设下,Y 的期望值 E[Y] = n * (2^k / m) = 2^{k-1}(因为 n = m/2)。

我们关心 P(Y >= 2^k),即 Y 至少是其期望值两倍的概率。这正是尾概率不等式的用武之地。

  1. 假设两两独立:如果哈希函数 H_0 是两两独立的,那么 Y 是两两独立随机变量之和。应用切比雪夫不等式可得:
    P(Y >= 2^k) = P(Y >= 2 * E[Y]) <= 1 / E[Y] ≈ 2^{-(k-1)}
    由此可推出 F(k) = O(2^{-k})。然而,将其代入运行时间期望公式 ∑ [2^k * F(k)] 时,我们得到 ∑ O(1) = O(log m)。这仅能证明性能不差于二叉搜索树,但并非我们想要的常数时间。

  2. 假设四路独立:为了获得常数时间,我们需要更强的独立性。假设 H_0 是四路独立的,我们可以使用高阶矩不等式(类似切比雪夫不等式的推广)得到更强的尾界:
    P(Y >= 2^k) = P(Y >= 2 * E[Y]) <= O(1 / (E[Y])^2) ≈ O(4^{-k})
    此时,F(k) = O(4^{-k})。代入运行时间期望公式:∑ [2^k * F(k)] = ∑ [2^k * O(4^{-k})] = ∑ O(2^{-k}) = O(1)
    因此,在四路独立哈希函数的假设下,开放寻址哈希(二进制探测)的每次操作期望时间是常数。

技术细节:为了严格分析插入一个新元素 x 的过程,我们需要考虑 x 与表中已有元素的联合分布。因此,实际上需要的是五路独立的哈希函数,以确保包含 x 在内的任意五个元素的哈希值都是独立均匀的。


实现:如何获得高阶独立哈希函数?

理论分析要求高阶独立性,我们如何实现这样的哈希函数?

  1. 多项式哈希:卡特和韦格曼提出的经典方法。对于五路独立,我们可以使用一个四次多项式:
    H(x) = (a + b*x + c*x^2 + d*x^3 + e*x^4) mod p mod m
    其中 p 是一个大素数,系数 a, b, c, d, e 随机选取。这种方法简单但计算成本较高。

  2. 简单列表哈希:将输入的关键字分成 c 个部分(例如8个)。预先准备 c 个随机数组。哈希值时,用每个部分作为索引查找对应数组中的随机值,然后将所有查找到的值进行异或操作。这种方法在实践中非常快。

    • 虽然理论分析表明它仅能提供有限的独立性(如三路独立),但帕特劳斯库和索普的深入研究证明,对于像开放寻址哈希这样的应用,它的表现如同完全随机的哈希函数,能够提供常数时间的高概率性能保证。其分析更为复杂,但结论坚实可用。

总结

本节课中,我们一起学习了开放寻址哈希,特别是二进制探测法。

  • 我们首先对比了链式哈希和开放寻址哈希的基本思想。
  • 我们介绍了线性探测和更优的二进制探测,并解释了后者的缓存友好特性。
  • 为了分析性能,我们建立了一个基于“块”的分析框架,并将复杂的“满块”问题转化为更易分析的“流行块”问题。
  • 通过应用概率论中的尾不等式,我们发现:两两独立的哈希函数只能保证 O(log n) 的期望探测次数,而四路(或五路)独立的哈希函数则可以保证 O(1) 的期望探测次数
  • 最后,我们探讨了实现高阶独立哈希函数的实用方法,包括多项式哈希和高效且理论保证坚实的简单列表哈希。

因此,在实践中,通过选择像简单列表哈希这样高效且经过充分分析的哈希函数,你可以实现一个性能可证明良好的开放寻址哈希表,并放心地在作业和考试中假设其基本操作具有常数期望时间。

015:字符串匹配与滚动哈希

在本节课中,我们将要学习如何使用滚动哈希技术来解决字符串匹配问题。这是一种高效的方法,能够在期望的线性时间内完成匹配,尤其适用于处理长文本和模式的情况。


概述

字符串匹配是一个经典的计算问题:给定一个长度为 M 的模式字符串 P 和一个长度为 N 的文本字符串 T,我们需要判断 P 是否是 T 的一个子串。本节课将介绍一种基于哈希的算法,它通过巧妙的数值计算和滑动窗口技术,避免了逐字符比较的高昂成本。


问题定义

基本字符串匹配问题定义如下:我们被给予两个字符串,一个称为模式,其长度为 M;另一个称为文本,其长度为 N。我们想要问的问题是:P 是否是 T 的一个子串?

例如,如果我想在美国宪法中搜索单词 “militia”,答案是肯定的,它出现在第二修正案中。如果我想搜索单词 “internet”,答案则是否定的。


暴力解法及其局限性

最直接的暴力解法是扫描文本的所有可能起始位置,并与模式进行逐字符比较。

以下是暴力解法的伪代码描述:

for s in range(0, N - M + 1):
    equal = True
    i = 0
    while i < M and equal:
        if T[s + i] != P[i]:
            equal = False
        i += 1
    if equal:
        return s  # 找到匹配
return None  # 未找到匹配

该算法在最坏情况下的时间复杂度是 O(M * N)。例如,当模式是许多个 ‘A’ 后跟一个 ‘B’,而文本全是 ‘A’ 时,算法会在每个位置都几乎比较完整个模式后才失败,导致性能低下。

在实践中,对于像英文这样的文本,由于不匹配通常发生得很早,这个算法可能很快。但在某些领域(如基因序列分析,其中可能存在很长的重复字符段),这种最坏情况会频繁发生,使得暴力解法不可行。


数值化直觉与滑动窗口

为了改进算法,我们首先建立一个直觉:将字符串视为数字。

我们可以将模式 P 解释为一个十进制数字:
P = Σ (10^(M-i) * P[i]),其中 i 从 1 到 M

同样,对于文本中从位置 s 开始的长度为 M 的子串,我们也可以计算其数值 T_s
T_s = Σ (10^(M-i) * T[s + i - 1]),其中 i 从 1 到 M

如果能在常数时间内进行任意精度整数的比较和算术运算,我们就可以设计一个线性时间的算法:

  1. 预先计算模式 P 的数值。
  2. 计算文本第一个子串 T_0 的数值。
  3. s=0 开始,比较 T_sP
  4. 使用滑动窗口公式更新数值:T_{s+1} = 10 * T_s - 10^(M-1) * T[s] + T[s + M]
  5. 重复步骤3和4,直到找到匹配或遍历完文本。

这个算法的核心思想是维护一个在文本上滑动的“窗口”,并利用前一个窗口的数值快速计算下一个窗口的数值,而不是每次都从头计算。

然而,问题在于任意精度算术本身需要 O(M) 时间,因此我们并没有节省时间。


引入模运算与哈希

为了真正实现常数时间的窗口更新和比较,我们转向模运算。我们选择一个素数 Q,并对所有数值计算取模 Q

现在,我们计算的是哈希值:

  • P_mod = P mod Q
  • T_s_mod = T_s mod Q

关键的滑动窗口更新公式变为:
T_{s+1}_mod = (B * T_s_mod - B^M * T[s] + T[s + M]) mod Q
其中 B 是我们的基数(之前是10)。为了确保结果在 [0, Q-1] 范围内,在编程实现取模运算时需要特别注意处理负数。

通过模运算,我们确实可以在常数时间内完成更新和比较。但引入了一个新问题:哈希碰撞。即,两个不同的字符串可能具有相同的模 Q 哈希值。


处理哈希碰撞:过滤与验证

为了解决哈希碰撞导致的误报,我们将哈希值用作一个快速过滤器:

  1. 如果 P_mod != T_s_mod,那么子串肯定不匹配,我们可以安全地跳过。
  2. 如果 P_mod == T_s_mod,则可能是一个真匹配,也可能是一个巧合(碰撞)。此时,我们需要进行一次暴力验证,逐字符比较 PT[s:s+M]

算法的总运行时间变为:O(N + F * M),其中 F 是误报(碰撞)的次数。
我们的目标是控制 F,使得 F * MN 同阶,从而整体保持 O(N) 的期望时间复杂度。


随机化以确保低碰撞率

为了从理论上保证误报的期望次数足够低,我们需要引入随机性。如果固定模数 Q,对手可以精心构造产生大量碰撞的输入。

拉宾-卡普算法采用了以下随机化策略:

  1. 固定一个足够大的素数 Q(例如大于 M^2)。
  2. 随机选择一个基数 B,范围在 [2, Q-1] 之间。

为什么这样有效?
Q 为素数时,将字符串视为以 B 为基数的多项式:H(P) = Σ (B^i * P[i]) mod Q
两个不同字符串的哈希值相等,意味着 B 是某个非零多项式(次数最多为 M-1)的根。
根据有限域上的代数基本定理,一个 M-1 次多项式最多有 M-1 个根。
因此,在 [2, Q-1] 范围内随机选择 B,发生碰撞的概率至多为 (M-1) / (Q-2)
通过选择 Q > M^2,我们可以将这个概率控制在 O(1/M) 以内。
这样,误报的期望次数 E[F] 就是 O(N/M),代入总时间公式得到期望时间复杂度为 O(N)


算法总结

本节课我们一起学习了基于滚动哈希的字符串匹配算法(拉宾-卡普算法)。其核心步骤总结如下:

  1. 预处理:选择一个素数 Q > M^2,并随机选择基数 B ∈ [2, Q-1]
  2. 计算模式哈希:计算模式 P 的哈希值 hash_P
  3. 计算初始文本窗口哈希:计算文本前 M 个字符的哈希值 hash_T
  4. 滑动窗口匹配
    • 如果 hash_P == hash_T,进行暴力验证。如果匹配成功则返回位置。
    • 使用公式 hash_T = (B * hash_T - B^M * T[s] + T[s + M]) mod Q 更新下一个窗口的哈希值。注意处理负数的模运算。
    • 滑动窗口,重复此过程直到文本末尾。

该算法在期望情况下具有 O(N) 的时间复杂度,并且易于实现。它巧妙地结合了数值哈希的快速性和随机化带来的理论保证,是解决字符串匹配问题的有力工具之一。在下节课中,我们将看到另一种不依赖于随机化的经典字符串匹配算法。

016:通过精心设计的失败函数进行字符串匹配

在本节课中,我们将学习一种确定性的字符串匹配算法——KMP算法。我们将探讨如何避免暴力匹配中的冗余比较,并介绍一个关键的“失败函数”来指导匹配过程,从而实现线性时间复杂度的搜索。


上一节我们介绍了使用滚动哈希的随机化字符串匹配方法。本节中,我们来看看如何确定性地解决这个问题,并深入理解KMP算法的核心思想。

算法动机:避免冗余比较

考虑在文本中搜索模式“abracadabra”。暴力算法会尝试每一个可能的起始位置,并进行逐字符比较,直到发现不匹配。然而,这种方法存在浪费。

例如,当模式的前几个字符与文本匹配,但在某个位置(比如第五个字符)出现不匹配时,暴力算法会将模式向右滑动一位,并重新从模式的开头进行比较。但我们已经知道文本中那个位置的字符(比如是‘B’)与模式第一个字符(‘A’)不同,因此这次比较是多余的。

核心思想是:利用已经匹配成功的部分信息,智能地跳过那些必然会导致不匹配的起始位置

KMP算法核心:失败函数

KMP算法通过一个预计算的“失败函数” fail[j] 来实现智能滑动。这个函数定义了当在模式的第 j 个字符处匹配失败时,下一步应该尝试与模式的第 fail[j] 个字符进行比较。

失败函数的定义基于“边框”的概念。一个字符串的边框是指一个既是其真前缀又是其真后缀的子串。对于模式 Pfail[j] 的值是:子串 P[1..j-1] 的最长边框的长度加1

用公式表示,即寻找最大的 k < j,使得:
P[1..k-1]P[1..j-1] 的一个后缀。
那么 fail[j] = k

算法流程

以下是KMP搜索算法的主循环伪代码。假设我们已计算出失败函数 fail[1..m],其中 m 是模式长度。

function KMP_Search(text T[1..n], pattern P[1..m]):
    j = 1 // 指向模式的指针
    for i = 1 to n do // 指向文本的指针
        while j > 0 and T[i] != P[j] do
            j = fail[j] // 匹配失败,根据失败函数回退j
        end while
        if j == m then
            return i - m + 1 // 找到匹配,返回起始位置
        else
            j = j + 1 // 字符匹配成功,两个指针都前进(i在for循环中前进)
        end if
    end for
    return "Pattern not found"

算法工作原理

  • 指针 i 在文本上单向向前移动。
  • 指针 j 在模式上移动。当字符匹配时,j 前进;当不匹配时,j 根据 fail[j] 回退。
  • j 回退到0时,意味着当前文本字符 T[i] 与模式的任何前缀起始都不匹配,i 前进,j 重置为1。
  • j 等于 m 时,意味着找到了完整匹配。

计算失败函数

失败函数本身也可以用类似KMP的方式在线性时间内计算出来,这体现了算法的自引用之美。

以下是计算失败函数的伪代码:

function Compute_Failure(pattern P[1..m]):
    fail[1] = 0
    j = 0
    for i = 2 to m do
        while j > 0 and P[i] != P[j+1] do
            j = fail[j]
        end while
        if P[i] == P[j+1] then
            j = j + 1
        end if
        fail[i] = j
    end for
    return fail

直观理解:这个过程相当于将模式串本身既当作文本又当作模式,进行自我匹配,从而找出每个位置前缀的最长边框。

时间复杂度分析

KMP算法的时间复杂度是线性的。

  • 搜索阶段:在 KMP_Search 函数中,i 从1增加到 n。虽然内部有 while 循环,但注意 j 的值变化。每次成功匹配(T[i] == P[j])会使 j 增加1,这最多发生 n 次。每次失败回退(j = fail[j])会使 j 减少。由于 j 每次增加1,它减少的总次数不可能超过增加的总次数。因此,总比较次数为 O(n)
  • 预处理阶段Compute_Failure 函数使用相同的分析,其时间复杂度为 O(m)

因此,KMP算法的总时间复杂度为 O(m + n)

总结

本节课我们一起学习了KMP字符串匹配算法。我们首先指出了暴力匹配的冗余性,然后引入了“失败函数”的概念来指导模式滑动,从而跳过不必要的比较。我们详细讲解了算法的流程、失败函数的定义及其线性时间的计算方法,并分析了算法整体的线性时间复杂度。KMP算法是一个经典且高效的确定性字符串匹配算法,它巧妙地利用模式本身的信息来加速搜索过程。

017:最大流与最小割

在本节课中,我们将学习网络流理论中的两个核心问题:最大流与最小割。我们将了解它们的定义、它们之间深刻的等价关系(最大流最小割定理),并初步探索如何寻找最大流。

概述

我们从一个历史实例开始。下图展示了一张1950年代东欧的铁路网络图,用于研究从莫斯科(源点)向西柏林(汇点)运输物资的能力。每条边上的数字代表该铁路线的每日运输容量(即最大可通过的列车数量)。我们的目标是找出每天能从莫斯科运抵西柏林的最大物资量。这就是最大流问题

同时,作为敌方,我们希望用最少的炸弹破坏铁路线,以切断从莫斯科到西柏林的所有运输路径。每条铁路线需要破坏的炸弹数等于其容量。这就是最小割问题

令人惊讶的是,这两个看似对立的问题,其答案在数值上是完全相等的。这就是著名的最大流最小割定理

最大流问题定义

首先,我们形式化地定义最大流问题。

输入

  • 一个有向图 G = (V, E)
  • 两个特殊的顶点:源点 s汇点 t
  • 一个容量函数 c: E -> R⁺,为每条边 (u, v) 分配一个非负的容量 c(u, v)。可以将其想象为水管的最大流量或铁路的最大运力。

输出

  • 一个流函数 f: E -> R⁺,为每条边分配一个非负的流量值 f(u, v)。该函数必须满足以下两个约束:
    1. 容量约束:对于所有边 (u, v),有 0 ≤ f(u, v) ≤ c(u, v)。流经边的流量不能超过其容量。
    2. 流量守恒:对于所有顶点 v ∈ V \ {s, t}(即除源点和汇点外的所有顶点),流入 v 的总流量必须等于流出 v 的总流量。即:
      ∑_{(u, v) ∈ E} f(u, v) = ∑_{(v, w) ∈ E} f(v, w)

目标

  • 最大化从源点 s 净流出的流量,称为流的,记作 |f|。其定义为:
    |f| = ∑_{(s, v) ∈ E} f(s, v) - ∑_{(u, s) ∈ E} f(u, s)
    由于流量守恒,这个值也等于净流入汇点 t 的流量。

最小割问题定义

接下来,我们定义最小割问题。

输入:与最大流问题相同(一个有向图 G,容量函数 c,源点 s,汇点 t)。

输出

  • 一个 (S, T),它是顶点集 V 的一个划分,满足 s ∈ St ∈ T,且 S ∪ T = VS ∩ T = ∅。可以理解为将图切成两块,一块包含源点,另一块包含汇点。

目标

  • 最小化这个割的容量。割 (S, T) 的容量定义为所有从 S 指向 T 的边的容量之和:
    c(S, T) = ∑_{u ∈ S, v ∈ T, (u, v) ∈ E} c(u, v)
    注意,只计算从 S 侧流向 T 侧的边。从 T 流回 S 的边不计入容量。割的容量可以理解为“切断ST之间所有联系所需的最小代价”。

最大流最小割定理

现在,我们陈述并初步证明这个核心定理。

定理(最大流最小割):在任何流网络中,最大流的值等于最小割的容量。即:
max_{可行流 f} |f| = min_{割 (S, T)} c(S, T)

证明(弱对偶部分)
我们首先证明一个较容易的方向:对于任意可行流 f任意(S, T),都有 |f| ≤ c(S, T)。这意味着最大流的值不可能超过最小割的容量。

以下是证明步骤:

  1. 从流的值定义开始:|f| = ∑_{v ∈ V} f(s, v) - ∑_{u ∈ V} f(u, s)
  2. 利用流量守恒性质,我们可以将求和范围从源点 s 扩展到整个 S 集合。因为对于 S 中除 s 外的其他顶点,流入等于流出,其净贡献为零。因此:
    |f| = ∑_{v ∈ S} (∑_{w ∈ V} f(v, w) - ∑_{u ∈ V} f(u, v))
  3. 重新排列这个双重求和,我们发现,只有那些连接 ST 的边才会对总和产生净贡献。具体来说:
    |f| = ∑_{v ∈ S, w ∈ T} f(v, w) - ∑_{u ∈ T, v ∈ S} f(u, v)
    第一项是S 流向 T 的流量,第二项是T 流回 S 的流量
  4. 应用流的可行性约束:
    • 所有流量 f(u, v) ≥ 0。因此,第二项(被减数)是一个非负数。去掉它会使等式左边变大(或不变),所以我们得到第一个不等式:
      |f| ≤ ∑_{v ∈ S, w ∈ T} f(v, w)
    • 所有流量 f(v, w) ≤ c(v, w)。因此,我们可以用边的容量替换流量值,得到第二个不等式:
      |f| ≤ ∑_{v ∈ S, w ∈ T} c(v, w)
  5. 不等式的右边正是割 (S, T) 的容量定义 c(S, T)。因此,我们证明了 |f| ≤ c(S, T)

这个证明表明,任何流的价值都无法超过任何割的容量。这为寻找最大流设定了一个上界。

推论:如果我们能找到一个特定的流 f* 和一个特定的割 (S*, T*),使得 |f*| = c(S*, T*),那么根据上述定理,我们可以立即得出结论:

  • f* 一定是一个最大流(因为不存在比 c(S*, T*) 价值更大的流)。
  • (S*, T*) 一定是一个最小割(因为不存在比 |f*| 容量更小的割)。

此外,要使等号成立,上述证明中的两个不等式必须同时取等号。这意味着在最大流和最小割中:

  • 所有从 S* 指向 T* 的边都是饱和的f(u, v) = c(u, v)
  • 所有从 T* 指向 S* 的边都是空的f(u, v) = 0

这个性质为我们提供了验证流是否最大、割是否最小的一个有力条件。

总结

本节课中,我们一起学习了网络流理论的基础:

  1. 我们定义了最大流问题,即在满足容量约束和流量守恒的条件下,最大化从源点到汇点的流量。
  2. 我们定义了最小割问题,即找到一种分离源点和汇点的方式,使得割集的容量最小。
  3. 我们介绍并部分证明了最大流最小割定理,该定理揭示了对偶问题的等价性:最大流的值等于最小割的容量。我们证明了定理的“弱对偶”部分,即任何流的值 ≤ 任何割的容量。

这为我们后续课程中学习实际计算最大流和最小割的算法(例如Ford-Fulkerson方法)奠定了坚实的理论基础。算法的核心思想正是不断地寻找流和割之间的这种“间隙”并消除它,直到达到等号成立的状态。

018:最大流结构

在本节课中,我们将学习最大流问题的核心概念、Ford-Fulkerson算法的行为分析,以及流的结构分解。我们将探讨算法可能遇到的陷阱,并介绍改进算法性能的策略。

概述

最大流问题旨在从源点 s 向汇点 t 输送尽可能多的“流”,同时不违反每条边的容量限制和除源汇点外的所有顶点上的流量守恒。与之对偶的是最小割问题。著名的最大流最小割定理指出,最大流的值等于最小割的容量。我们将从定义开始,深入理解这些概念。

网络与流定义

首先,我们明确问题的输入和输出。

输入 是一个流网络 G,它包含:

  • 一个有向图 G
  • 两个特殊顶点:源点 s 和汇点 t
  • 为每条边 (u, v) 赋予一个正的容量 c(u, v)

最大流问题 要求计算一个函数 f: E -> R,它满足以下三个条件:

  1. 容量约束:对于每条边 (u, v),有 0 <= f(u, v) <= c(u, v)
  2. 流量守恒:对于除 st 外的每个顶点 v,流入 v 的总流量等于流出 v 的总流量。即 ∑_{(u,v)∈E} f(u, v) = ∑_{(v,w)∈E} f(v, w)
  3. 目标:最大化从源点 s 流出的净流量,即流的值 |f| = ∑_{(s,w)∈E} f(s, w) - ∑_{(u,s)∈E} f(u, s)

最小割问题 的输入相同,但目标是找到一个顶点划分 (S, T),使得 s ∈ St ∈ T,并最小化从 S 指向 T 的所有边的容量之和,即割的容量 c(S, T) = ∑_{u∈S, v∈T} c(u, v)

最大流最小割定理指出:最大流的值等于最小割的容量

Ford-Fulkerson 算法及其行为分析

上一节我们定义了问题,本节我们来看看求解最大流的基础算法——Ford-Fulkerson方法,并分析其行为。

该算法基于残差图的概念。对于当前流 f,残差图 G_f 包含与 G 相同的顶点。对于原图中的每条边 (u, v)

  • 如果 f(u, v) < c(u, v),则在 G_f 中添加一条从 uv 的边,其残差容量为 c(u, v) - f(u, v)
  • 如果 f(u, v) > 0,则在 G_f 中添加一条从 vu 的边,其残差容量为 f(u, v)

算法的核心思想是:只要在残差图 G_f 中存在一条从 st 的路径(称为增广路),就可以沿该路径推送额外的流量,从而增加总流值。

以下是Ford-Fulkerson算法的伪代码框架:

初始化流 f 为 0
while (在残差图 G_f 中存在一条从 s 到 t 的路径 p) {
    令 delta 为路径 p 上所有边的最小残差容量
    沿路径 p 推送 delta 单位的流量(更新 f)
    更新残差图 G_f
}
返回流 f

如果算法终止,它返回的流 f 就是一个最大流。此时,在残差图 G_f 中从 s 可达的顶点集合 S,与其余顶点 T 就构成了一个最小割。

算法的运行时间与潜在问题

算法的每次迭代(寻找路径、增广、更新残差图)可以在 O(E) 时间内完成。关键问题是:while 循环需要迭代多少次?

以下是算法可能表现不佳的情况:

  1. 整数容量下的最坏情况:即使所有容量都是整数,算法也可能需要非常多的迭代。考虑一个简单的网络,其中一条关键边的容量 X 很大。如果每次都“不幸地”选择交替的两条路径进行增广,每次只能推送1单位流量,那么算法将需要 2X 次迭代,运行时间为 O(E * |f*|),其中 |f*| 是最大流值。当 X 很大时(例如 2^100),这相当于指数时间。

  2. 非整数(无理数)容量下的无限循环:如果允许容量为无理数,存在精心构造的网络(如Zwick网络),使得Ford-Fulkerson算法沿着某个特定的增广路序列会无限循环下去,并且收敛到的流值甚至不是最大流值。

尽管在实际计算机中我们通常处理有理数(浮点数),但浮点运算的舍入误差可能导致类似无限循环或不一致状态的问题。因此,从理论和实践上,基础的Ford-Fulkerson算法都不够可靠。

流的结构分解

为了设计更好的算法并理解流的本质,我们需要深入流的结构。上一节我们看到算法可能表现不佳,本节我们引入一个强大的工具——流分解定理,它能将任何流表示为更简单“流单元”的和。

关键思想是:任何流都可以分解为若干从 st 的路径流和若干环流的加权和,且这些路径和环都只使用原流中正向(流量>0)的边。

考虑一个特殊的零值流(称为环流),它在所有顶点满足流量守恒。以下是将一个环流分解为环流的算法思路:

当流 f 不是全零时:
    任意选择一个仍有正流量流出的顶点 v
    从 v 开始,沿着流量为正的边随意行走
    由于流量守恒,我们最终一定会回到一个已经访问过的顶点,从而找到一个环 C
    令 delta 为环 C 上所有边流量的最小值
    从当前流 f 中减去 delta 倍的环 C 对应的单位环流
    更新 f(至少有一条边的流量变为0)

每次迭代至少使一条边的流量归零,因此最多进行 O(E) 次迭代,每次耗时 O(V),总时间为 O(VE)

对于一般的流(值不为零),我们可以先找到一条从 st 的路径,提取其流量,使其值减少,最终转化为环流再进行分解。最终,任何流 f 都可以表示为 O(E) 条路径和环的和,每条路径/环的长度为 O(V)

这个分解定理意义重大:

  • 它揭示了流的组合结构。
  • 它表明,如果一个最大流中包含环,我们可以通过删除这些环得到一个无环的最大流
  • 它给出了一个下界:任何“一次增广一条路径”的算法(如Ford-Fulkerson)在最坏情况下可能需要 Ω(VE) 次操作,因为某些流的分解就需要这么多路径。

改进的增广路径选择策略

既然基础的路径选择可能导致很差的性能,我们自然要问:如何选择增广路径能保证算法快速终止?本节我们探讨两种有效的启发式策略。

以下是两种主要的改进策略:

  1. 最大容量增广(Fattest Path):在残差图中选择一条从 st 的路径,使得路径上最小的残差容量尽可能大。这类似于“加宽瓶颈”。可以使用修改的“最佳优先搜索”算法在 O(E log V) 时间内找到这样的路径。理论分析表明,采用此策略,当容量为整数时,算法迭代次数为 O(E log |f*|)

  2. 最短路径增广(Shortest Path):在残差图中选择一条从 st 的、边数最少的路径。这可以通过广度优先搜索(BFS)在 O(E) 时间内完成。这是一个关键突破:采用此策略(即Edmonds-Karp算法),迭代次数被限制在 O(VE) 以内,与容量值大小无关!因此,其总运行时间为 O(VE^2),这是一个真正的多项式时间算法,即使面对无理数容量也保证终止。

算法研究现状总结

在本节课中,我们一起学习了最大流问题的核心定义、基础的Ford-Fulkerson算法、其潜在缺陷、揭示流内在结构的分解定理,以及通过明智选择增广路径(如最短路径法)来获得多项式时间保证的改进算法。

当前最大流算法的研究前沿已远超这些基础算法。例如,存在运行时间为 O(VE) 的算法(Orlin‘s Algorithm),以及针对整数容量的、运行时间接近 O(E) 的算法。然而,这些先进算法通常非常复杂。在实践中,基于最短路径增广的算法及其优化版本(如Dinic算法)因其良好的实际性能和相对简单的实现,仍然是常用的选择。

通过本节课的学习,你应该理解最大流最小割定理的基本原理,掌握分析简单流算法行为的方法,并认识到精心设计的策略对于保证算法效率至关重要。

019:最大流应用

在本节课中,我们将学习如何将最大流算法作为“黑盒”工具,来解决其他看似不相关的问题。我们将重点探讨两个经典应用:边不相交/顶点不相交路径问题,以及二分图最大匹配问题。


课程概述与期中考试安排

下一周一是期中考试二。考试将涵盖自期中考试一以来课堂上学到的所有内容,具体包括作业四、五、六、七。尽管我们今天要讲的内容尚未出现在作业中,但由于这部分内容易于出题,因此也会被纳入考试范围。

考试结构与期中考试一相同:共有四道题目,允许携带一张单页(可双面手写)的“小抄”,考试时长为两小时。考试地点与期中考试一相同。考试时,我会在答题册背面提供公式表。

关于考试安排的重要信息:

  • 考试时间为下周一。
  • 周四没有常规课程,将进行考前复习,讲解模拟试题。
  • 如需参加冲突考试,请在周二参加。请务必在周五前通过课程网页上的表格登记。
  • 如需任何考试便利安排,请确保我已收到相关证明文件。

应用一:边不相交路径问题

上一节我们介绍了最大流问题的基本框架,本节中我们来看看如何用它来解决一个具体问题:在有向图中寻找从源点 s 到汇点 t边不相交路径的最大数量。所谓边不相交,是指任意两条路径不共享同一条边。

问题建模与算法

解决此问题的核心思想是将原图转化为一个流网络。

以下是具体步骤:

  1. 构建流网络:给定有向图 G、源点 s 和汇点 t。将 G 中的每条边的容量设置为 1
  2. 计算最大流:在这个新构建的流网络上,计算从 st 的最大流的值 f
  3. 提取路径:最大流的值 f 就等于边不相交路径的最大数量。要获得这些路径本身,可以对计算出的整数流进行流分解

算法原理与复杂度分析

为什么这个方法是正确的?

  • 由于所有边容量均为整数(1),因此存在整数最大流。
  • 在整数最大流中,每条边上的流量值只能是 0 或 1。
  • 流量值为 1 的边构成了从 st 的路径。因为每条边容量为 1,所以这些路径必然是边不相交的。

关于算法运行时间:

  • 使用 Ford-Fulkerson 算法时,每次增广需要 O(E) 时间。
  • 最大流的值 f 最多为 min(deg_out(s), deg_in(t))O(V)
  • 因此,总运行时间为 O(V * E)
  • 流分解同样需要 O(V * E) 时间。

所以,整体算法的时间复杂度为 O(V * E)

扩展到无向图与顶点不相交路径

对于无向图:只需将每条无向边替换为两条方向相反的有向边,并为每条有向边设置容量 1,然后应用上述算法即可。如果算法产生的流中包含了方向相反的一对边(形成一个 2-环),可以安全地移除这个环,这不影响流的值和最终路径的数量。

对于顶点不相交路径问题:我们希望路径除了端点 st 外,不共享任何顶点。这可以通过引入顶点容量的概念来解决。

我们修改流网络,为每个顶点 v(除 st 外)设置容量 c(v),表示流经该顶点的流量上限。为了在现有算法框架内处理顶点容量,我们使用一个经典的图变换技巧:

顶点拆分:将每个顶点 v 拆分为两个顶点 v_inv_out,并添加一条从 v_in 指向 v_out 的有向边,其容量即为该顶点的容量 c(v)。所有原图中指向 v 的边,现在指向 v_in;所有从 v 指出的边,现在从 v_out 指出。

通过这个变换,顶点容量约束就转化为了这条内部边的边容量约束。之后,我们就可以在变换后的图上运行标准的最大流算法。

对于顶点不相交路径问题,我们只需为所有中间顶点设置容量 1,然后进行顶点拆分并计算最大流即可。

更一般的情况:如果我们要求每条边最多被 a 条路径使用,每个顶点最多被 b 条路径使用,只需在构建流网络时,设置所有边容量为 a,所有顶点容量为 b,然后进行顶点拆分并计算最大流。


应用二:二分图最大匹配

本节我们来看最大流的另一个重要应用:在二分图中寻找最大匹配。二分图是指顶点集可以划分为左右两个部分(LR),所有边都连接一个 L 中的顶点和一个 R 中的顶点。匹配是边的一个子集,其中任意两条边没有公共顶点。最大匹配是包含边数最多的匹配。

通过最大流求解匹配

我们可以将二分图最大匹配问题归约为顶点不相交路径问题。

以下是构建流网络的步骤:

  1. 添加超级源点和汇点:添加源点 s 和汇点 t
  2. 连接源点和汇点:从 sL 中的每个顶点添加一条有向边。从 R 中的每个顶点向 t 添加一条有向边。
  3. 处理原图边:将原二分图中所有无向边,定向为从 L 指向 R
  4. 设置容量:将所有边的容量设置为 1

在这个新流网络上计算从 st 的最大流。最大流的值就等于原二分图中最大匹配的边数。匹配中的边对应于那些从 L 指向 R、且流量为 1 的边。

交替路径视角

如果我们观察在此特定流网络上运行 Ford-Fulkerson 算法的过程,可以得到一个直接在原二分图上操作的等价算法,即交替路径增广法

定义:对于当前匹配 M,一条交替路径是一条路径,其边在属于 M 和不属于 M 之间交替,并且路径的起点和终点都是未匹配的顶点。

算法流程如下:

While 在二分图 G 中存在关于当前匹配 M 的交替路径 P:
    M = M ⊕ P  (对称差操作:将P中的边加入M,同时移除P中已在M里的边)

每次找到一条交替路径并执行对称差操作,都会将匹配的大小增加 1。当不存在交替路径时,当前的匹配就是最大匹配。

这个算法的运行时间是 O(V * E),因为最多进行 O(V) 次增广(每次增加一条匹配边),每次寻找交替路径需要 O(E) 时间。

不同的视角与历史注记

最大匹配问题可以通过多种视角理解:流网络、交替路径、甚至是更古老的组合数学方法。例如,早在1836年,数学家 Carl Gustav Jacobi 就在研究微分方程组时,描述了一个本质上等同于交替路径法的算法来解决一个相关问题。

这些不同的视角揭示了算法背后统一的思想,也为我们提供了灵活解决问题的工具。选择哪种视角取决于个人的直觉和问题的具体情境。


课程总结

本节课中我们一起学习了最大流算法的两个经典应用:

  1. 边/顶点不相交路径问题:通过设置边容量为 1(或进行顶点拆分处理顶点容量),将问题转化为标准最大流问题。
  2. 二分图最大匹配问题:通过添加超级源汇点、设置所有边容量为 1,将问题转化为最大流问题。我们还探讨了与之等价的交替路径增广算法。

关键在于,许多看似不同的问题可以通过巧妙的图变换,规约到我们已经掌握的最大流/最小割模型上,从而利用现成的算法黑盒高效求解。这种建模能力是算法设计与分析中的重要技巧。

020:期中考试复习课

在本节课中,我们将一起复习期中考试二可能涉及的核心主题。我们将通过分析一份模拟试卷,来了解问题的类型、难度以及解题思路。课程涵盖哈希、图论、随机二叉搜索树和字符串算法。

哈希问题分析

上一节我们介绍了课程概述,本节中我们来看看第一个问题:关于哈希的分析。

我们有一个大小为 M = 2n 的哈希表,使用一个通用哈希函数族将 n 个项插入其中。我们假设 sqrt(n) 是整数。

第一部分:证明期望碰撞次数

以下是证明期望碰撞次数至多为 n/4 的步骤:

  1. 定义碰撞:对于两个不同的项 xy,如果 h(x) = h(y),则它们发生碰撞。
  2. X 为总碰撞次数的随机变量。我们可以将其表示为指示变量之和:X = Σ_{i<j} I_{ij},其中 I_{ij} 在项 i 和项 j 碰撞时为1,否则为0。
  3. 根据期望的线性性质,E[X] = Σ_{i<j} E[I_{ij}] = Σ_{i<j} Pr(h(i) = h(j))
  4. 由于哈希函数族是通用的,对于任意 i ≠ j,有 Pr(h(i) = h(j)) ≤ 1/M = 1/(2n)
  5. 无序对 (i, j) 的总数为 C(n, 2) = n(n-1)/2
  6. 因此,E[X] ≤ [n(n-1)/2] * [1/(2n)] = (n-1)/4 ≤ n/4

第二部分:证明至少 n/2 次碰撞的概率

现在,我们利用第一部分的结论,分析碰撞次数显著超过其期望值的概率。

  1. 设随机变量 X 为碰撞次数,我们已证明 E[X] ≤ n/4
  2. 问题要求 Pr(X ≥ n/2)。注意 n/2 ≥ 2 * E[X]
  3. 根据马尔可夫不等式:对于非负随机变量 X 和任意 a > 0,有 Pr(X ≥ a) ≤ E[X] / a
  4. a = n/2,则 Pr(X ≥ n/2) ≤ (n/4) / (n/2) = 1/2

第三部分:证明大簇的概率

本节我们考虑一个不同的事件:是否有超过 sqrt(n) 个项被哈希到同一个地址。

  1. 定义事件 E:存在某个哈希桶,包含超过 sqrt(n) 个项。
  2. 如果事件 E 发生,假设某个桶有 k > sqrt(n) 个项,那么这些项之间两两都会发生碰撞。
  3. 因此,当 E 发生时,总碰撞次数 X 至少为 C(k, 2) > C(sqrt(n), 2) ≈ n/2
  4. 更精确地说,k ≥ sqrt(n) + 1,所以 X ≥ C(sqrt(n)+1, 2) = (sqrt(n)(sqrt(n)+1))/2 > n/2
  5. 所以,Pr(E) ≤ Pr(X > n/2) ≤ 1/2(由第二部分结论)。

第四部分:强独立性下的分析

上一节我们在通用哈希假设下得到了一个上界,本节中我们来看看如果哈希函数族是4-均匀的,结论会如何加强。

  1. 4-均匀性意味着任意四个不同项的哈希值是完全独立的。
  2. 考虑特定桶(例如桶0)。设 Y 为哈希到桶0的项的数量。对于每个项,Pr(h(item)=0) = 1/(2n),故 E[Y] = n * (1/(2n)) = 1/2
  3. 我们关心 Pr(Y > sqrt(n))。由于 sqrt(n) 远大于 E[Y],我们需要比马尔可夫更强的不等式。
  4. 哈希值的4-均匀性意味着这些指示变量是4-独立的。对于4-独立的随机变量,我们可以使用切比雪夫不等式的高阶形式(或直接计算四阶矩)。
  5. 通过计算,可以得到 Pr(Y > sqrt(n)) 的上界为 O(1/n),远小于第三部分的 1/2。这表明强独立性可以极大降低坏事件(出现过大簇)的概率。

图论问题:车站关闭

在分析了哈希问题后,我们转向一个图论建模问题:通过关闭最少的车站来阻断铁路交通。

我们有一个无向图 G=(V, E),表示铁路网络,其中顶点是车站,边是铁路连接。有两个特殊顶点 F(起点)和 T(终点)。目标是找到除 FT 外,需要关闭的最少顶点集合,使得在 GFT 不再连通。

问题分析与转化

以下是解决此问题的关键步骤:

  1. 问题识别:这是一个最小顶点割问题(Minimum Vertex Cut),但源点和汇点本身不能被移除。
  2. 经典转化:将“顶点容量”问题转化为“边容量”问题。对图中每个普通顶点 v(非 FT),将其拆分为两个顶点 v_inv_out,并添加一条有向边 (v_in -> v_out),容量为1(表示关闭该点的代价)。
  3. 处理原边:对于原图中的每条无向边 (u, v),在转化后的图中添加两条有向边:(u_out -> v_in)(v_out -> u_in),容量设为无穷大(或一个足够大的数,如 n),表示切断铁路连接本身是不可行的,我们只能通过关闭车站来间接阻断。
  4. 应用最大流最小割定理:在转化后的有向图中,以 F_out(或直接 F,若 F 未拆分)为源点,以 T_in 为汇点,计算最小割。根据最大流最小割定理,最小割的容量等于最大流的值,并且这个容量正好对应了需要关闭的最少车站数。
  5. 算法与复杂度:可以使用 Ford-Fulkerson 或 Edmonds-Karp 等算法求解最大流。转化后的图有 O(V) 个顶点和 O(E) 条边,因此算法可以在 O(V*E) 时间内完成。

随机二叉搜索树节点分析

接下来,我们分析一个关于随机二叉搜索树(Treap)的概率问题。Treap 是给每个节点随机分配优先级后,同时满足二叉搜索树性质和堆性质的数据结构。

对于一个有 n 个节点的 Treap,我们需要计算:

  1. 叶子节点(无子节点)的期望数量。
  2. 有两个子节点的节点的期望数量。
  3. 有一个子节点的节点的期望数量。

关键思路与计算

以下是利用概率和期望线性性质进行分析的方法:

  1. 固定节点顺序:假设节点的键值为 1, 2, ..., n。我们关注特定键值 k 的节点。
  2. 叶子节点条件:节点 k 是叶子,当且仅当它的优先级大于其前驱(k-1)和后继(k+1)的优先级。对于 1 < k < n,三个优先级随机,k 最大的概率为 1/3。对于边界节点 k=1k=n,只需大于其唯一邻居,概率为 1/2
  3. 期望叶子数:令指示变量 L_k 表示节点 k 是否为叶子。则期望叶子数 E[L] = Σ E[L_k] = 2*(1/2) + (n-2)*(1/3) = (n+1)/3(当 n>1)。
  4. 有两个子节点的条件:节点 k 有两个子节点,当且仅当其优先级小于前驱和后继的优先级。对于 1 < k < n,概率为 1/3。边界节点不可能有两个子节点。
  5. 期望双孩子节点数E[D] = (n-2)*(1/3) = (n-2)/3
  6. 有一个子节点的期望数:利用线性性质。树中节点总数 n 等于叶子数、单孩子节点数、双孩子节点数之和。因此,期望单孩子节点数 E[S] = n - E[L] - E[D] = n - (n+1)/3 - (n-2)/3 = (n-2)/3

字符串循环移位问题

最后,我们探讨两个关于字符串循环移位的问题。

问题一:判断循环移位

给定两个字符串 AB,判断 A 是否是 B 的一个循环移位。

  • 思路:如果 AB 的循环移位,那么 A 的长度必须等于 B 的长度,设为 n。并且 A 一定是 B+B 的一个长度为 n 的子串。例如,B="abcd",则 B+B="abcdabcd",其中包含了所有循环移位 "abcd", "bcda", "cdab", "dabc"
  • 算法:检查 len(A) == len(B),如果是,则使用 KMP 或类似算法检查 A 是否为 B+B 的子串。时间复杂度为 O(n)

问题二:判断是否为循环移位的子串

给定两个字符串 AB,判断 A 是否是 B 的某个循环移位的子串。

  • 思路:这个问题比第一个更简单。AB 的某个循环移位的子串,当且仅当 AB+B 的子串。因为 B+B 包含了 B 的所有循环移位。
  • 算法:直接使用 KMP 算法检查 A 是否为 B+B 的子串。时间复杂度为 O(|A| + |B|)

总结

本节课中我们一起学习了期中考试可能涉及的四种核心问题类型。

  1. 哈希分析:我们复习了如何利用指示变量、期望线性性质以及马尔可夫不等式来分析碰撞的期望和概率,并探讨了哈希函数独立性强弱对结果的影响。
  2. 图论建模:我们学习了如何将最小顶点割问题通过顶点拆分技巧转化为标准的最小割/最大流问题,从而利用已知算法求解。
  3. 随机数据结构:我们分析了 Treap 中各类节点数量的期望,关键在于理解 Treap 的随机结构如何转化为特定节点与其邻居优先级的比较概率。
  4. 字符串匹配:我们掌握了利用字符串拼接(B+B)来涵盖所有循环移位技巧,从而将循环移位问题转化为标准的子串匹配问题,并用 KMP 算法高效解决。

希望这次复习能帮助大家梳理知识,在考试中取得好成绩!

021:更多最大流应用

在本节课中,我们将学习如何将各种实际问题转化为最大流问题,并利用最大流算法来解决它们。我们将通过几个具体的例子,如考试调度、有向无环图的路径覆盖和图的环覆盖,来深入理解这种“归约”的思路和步骤。


概述:归约策略

上一讲我们介绍了最大流的一些应用,如边不相交路径、顶点不相交路径和二分图匹配。本节中,我们将看到更多应用,并总结一个通用的解决模式。

解决这类问题的通用策略是“归约”。其流程如下:

  1. 输入转换:将原始问题的输入(如考试、信封、图的边)转化为一个流网络。
  2. 黑盒计算:使用最大流算法(如Ford-Fulkerson算法)在这个网络上计算最大流。
  3. 输出转换:将得到的最大流(或其路径分解)解释回原始问题的解。

整个过程可以看作一个“算法管道”。我们需要描述输入/输出转换的算法,分析其运行时间(用原始问题的参数表示),并理解两个问题解之间的对应关系,这本质上就是证明归约的正确性。


应用一:考试调度问题

现在,我们来看一个具体的例子:期末考试调度。

问题描述

我们需要为一系列课程安排期末考试。每场考试需要分配一个教室、一个时间槽和一名监考员。存在以下约束:

  • 每门课程只能安排一场考试。
  • 教室的容量必须大于或等于课程的学生人数。
  • 每个教室在每个时间槽最多只能安排一场考试。
  • 每位监考员最多只能监考5场考试。
  • 监考员只在特定时间槽有空。

我们需要判断是否能安排所有考试,并给出具体的安排方案。

解决方案:构建流网络

这是一个典型的元组选择问题。我们需要从资源集合(课程、教室、时间、监考员)的笛卡尔积中,选出一个满足所有约束的元组集合。关键在于,约束必须只出现在相邻的资源集合之间。

以下是构建流网络的步骤:

  1. 确定资源顺序:根据约束,我们需要将资源按“课程 -> 教室 -> 时间 -> 监考员”的顺序排列成层。
  2. 创建顶点:为每一类资源(课程、教室、时间、监考员)的每个实例创建一个顶点。此外,添加源点 s 和汇点 t
  3. 添加边并设置容量
    • s 到所有“课程”顶点添加边,容量为1(每门课一场考试)。
    • 在相邻层之间添加所有可能的边(例如,从每个课程顶点到每个教室顶点)。
    • 根据约束设置边容量:
      • 课程-教室边:如果课程人数 <= 教室容量,容量为1(允许安排),否则为0(不允许)。
      • 教室-时间边:容量为1(每个教室每个时间最多一场考试)。
      • 时间-监考员边:如果监考员在该时间有空,容量为1,否则为0。
    • 从所有“监考员”顶点到 t 添加边,容量为5(每位监考员最多监考5场)。
    • 未明确指定容量的顶点和边,容量视为无穷大。

算法与对应关系

构建好网络 G 后,我们执行以下算法:

  1. G 上计算最大流 f*
  2. 如果 f* 的值小于课程总数 n,则无法安排所有考试,返回 false
  3. 否则,将流 f* 分解为 n 条值为1的 s-t 路径。
  4. 每条路径 (s -> 课程C -> 教室R -> 时间T -> 监考员P -> t) 对应一场考试安排 (C, R, T, P)

对应关系定理:存在一个可行的考试安排方案,当且仅当在构建的流网络中存在一个值为 n 的可行流。每条 s-t 流路径唯一对应一场考试安排。

运行时间分析

设课程数为 C,教室数为 R,时间槽数为 T,监考员数为 P。流网络的顶点数 V = O(C + R + T + P),边数 E = O(C*R + R*T + T*P)。使用 O(VE) 的最大流算法,总运行时间为输入规模的多项式时间。


应用二:有向无环图的顶点不相交路径覆盖

接下来,我们考虑一个覆盖问题:给定一个有向无环图,希望用尽可能少的顶点不相交路径覆盖图中所有顶点。

问题描述与转化

一个经典实例是“信封嵌套”问题:有 n 个信封,每个有宽和高。信封 i 可以套入信封 j 当且仅当 i 的宽和高都小于 j。目标是用最少的“套娃”堆来装下所有信封。

将每个信封视为图 G 的一个顶点。如果信封 i 能套入 j,则添加有向边 i -> j。这样,一个嵌套堆对应图上的一条路径。问题转化为:用最少的顶点不相交路径覆盖 G 的所有顶点。

关键洞察:最小化路径数等价于最大化每个顶点的后继分配数(因为每条路径的最后一个顶点没有后继)。这提示我们使用匹配。

解决方案:归约为二分图匹配

我们通过构建一个二分图 G‘ 来将路径覆盖问题归约为最大匹配问题。

以下是构建二分图的步骤:

  1. 创建顶点:对于原图 G 的每个顶点 v,在二分图 G‘ 的左部 L 和右部 R 各创建一个副本,分别记为 v_Lv_R
  2. 添加边:对于原图 G 中的每条边 (u -> v),在 G‘ 中添加一条从左部 u_L 到右部 v_R 的边。

算法与对应关系

构建好二分图 G‘ 后,我们执行以下算法:

  1. G‘ 上计算最大匹配 M
  2. 在匹配 M 中,每条边 (u_L, v_R) 对应原图中将 u 的后继分配为 v
  3. 这些后继关系在原图 G 中形成一组顶点不相交的路径(可能包含单个顶点的路径)。路径的数量等于 |V| - |M|

对应关系定理:原图 G 中顶点不相交路径覆盖的最小路径数等于 |V| - (G‘ 的最大匹配数)

运行时间分析

设原图 Gn 个顶点,m 条边。二分图 G‘2n 个顶点,m 条边。使用 O(VE) 的二分图匹配算法,运行时间为 O(n * m)。对于信封问题,m 最多为 O(n^2),故总时间为 O(n^3)


应用三:有向图的边不相交环覆盖

最后,我们看一个边覆盖问题:给定一个有向图,希望将所有的边划分成若干个边不相交的有向环。

问题描述与转化

我们希望将图的边集划分成若干个环,环之间可以共享顶点,但不能共享边。这可以理解为:为每条边指定它在同一个环中的“下一条边”。

关键动词:为每条边选择其后继边。这又是一个匹配/分配问题。

解决方案:归约为二分图匹配

我们构建一个二分图 H,其顶点代表原图的边。

以下是构建二分图的步骤:

  1. 创建顶点:对于原图 G 的每条边 e,在二分图 H 的左部 L 和右部 R 各创建一个副本。
  2. 添加边:对于原图 G 中的两条边 (u->v)(v->w)(即第一条边的终点是第二条边的起点),在 H 中添加一条从左部 (u->v) 的副本到右部 (v->w) 的副本的边。这条边表示 (v->w) 可以作为 (u->v) 在环中的后继。

算法与对应关系

构建好二分图 H 后,我们执行以下算法:

  1. H 上计算一个完美匹配(即覆盖所有顶点的匹配)M
  2. 在匹配 M 中,每条边 ( (u->v)_L, (v->w)_R ) 对应原图中边 (u->v) 的后继是 (v->w)
  3. 这些后继关系将原图 G 的边集划分成若干个边不相交的有向环。

对应关系定理:原图 G 存在边不相交的环覆盖,当且仅当在构建的二分图 H 中存在一个完美匹配。

运行时间分析

设原图 Gm 条边,n 个顶点。二分图 H2m 个顶点。H 中的边数 E‘:对于原图的每条边 (u->v),其可能的后续边数不超过顶点 v 的出度。因此,E‘ ≤ m * max_out_degree ≤ m * n。使用 O(VE) 的匹配算法,运行时间为 O(m * (m*n)) = O(m^2 * n)


总结

本节课我们一起学习了如何利用最大流和匹配算法解决复杂的规划与覆盖问题。核心在于掌握“归约”的思维模式:

  1. 识别问题中的“选择”、“匹配”、“分配”等关键词。
  2. 将问题抽象为元组选择问题,并确保约束是“局部”(相邻资源间)的,以便构建分层流网络。
  3. 或者,将问题转化为二分图匹配问题,通过为对象(顶点或边)分配后继来构建解。
  4. 明确构建的图模型与原问题解之间的对应关系定理,这是算法正确性的核心。
  5. 用原始问题的参数分析最终算法的运行时间。

通过考试调度、路径覆盖和环覆盖这三个例子,我们看到了这种技巧的强大与灵活。在作业和考试中,理解并应用这一流程是解决相关问题的关键。

022:最小割的应用与推广

在本节课中,我们将学习最小割问题的几个重要应用,包括二分图的最小权顶点覆盖和项目选择问题。我们还将探讨如何将更一般的网络流问题(如带供需约束或边流量上下界约束的问题)转化为标准的最大流/最小割问题来解决。


二分图的最小权顶点覆盖

上一节我们介绍了最大流与最小割定理。本节中,我们来看看如何利用最小割来解决二分图上的最小权顶点覆盖问题。

顶点覆盖 是指图的一个顶点子集,使得图中的每一条边都至少有一个端点在这个子集中。在带权图中,我们希望找到总权重最小的顶点覆盖。对于一般图,这是一个NP难问题。但对于二分图,我们可以通过构建一个流网络,并计算其最小割,在多项式时间内解决。

问题定义

给定一个二分图 G = (L ∪ R, E),其中每个顶点 v 有一个非负权重 c(v)。目标是找到一个顶点子集 C ⊆ L ∪ R,使得:

  1. 对于每条边 (u, v) ∈ E,满足 u ∈ Cv ∈ C
  2. 最小化总权重 cost(C) = Σ_{v∈C} c(v)

构建流网络

以下是构建对应流网络 G’ 的步骤:

  1. 创建源点 s 和汇点 t
  2. 从源点 s左部 L 的每个顶点 v 添加一条有向边,容量为 c(v)
  3. 将原二分图中所有无向边 (u, v)(其中 u ∈ L, v ∈ R)改为从 u 指向 v 的有向边,容量设为无穷大 ()。
  4. 右部 R 的每个顶点 v 向汇点 t 添加一条有向边,容量为 c(v)

核心构建代码描述

# 假设 graph 是二分图,left_nodes 和 right_nodes 是左右顶点集合
def build_flow_network(graph, left_nodes, right_nodes, weight):
    G_prime = 新建空流网络
    添加源点 s 和汇点 t
    for v in left_nodes:
        添加边 (s -> v),容量 = weight[v]
    for (u, v) in graph.edges: # u 在左部,v 在右部
        添加边 (u -> v),容量 = INFINITY
    for v in right_nodes:
        添加边 (v -> t),容量 = weight[v]
    return G_prime

最小割与顶点覆盖的对应关系

关键定理:在构建的流网络 G’ 中,任何有限容量的 s-t(S, T) 都对应原图 G 中的一个顶点覆盖 C,且 割的容量 = 顶点覆盖 C 的总权重

构造对应关系

  • 从割到覆盖:给定一个有限容量的最小割 (S, T),对应的顶点覆盖 C 包含:
    • 所有在 T 中的左部顶点。
    • 所有在 S 中的右部顶点。
  • 从覆盖到割:给定一个顶点覆盖 C,可以构造一个割 (S, T)
    • S = {s} ∪ (L \ C) ∪ (R ∩ C)
    • T 包含其余所有顶点。

证明思路:由于割的容量有限,所有容量为无穷大的边(即原图的边)都不能从 S 跨越到 T。这保证了对于任何原图中的边 (u, v),其端点 uv 不能同时分别位于 ST 中。通过分析 uvS/T 中所有可能的分布情况,可以验证上述构造的 C 确实覆盖了每一条边。同时,割的容量恰好等于从 sT 中左部顶点的边容量之和,加上从 S 中右部顶点到 t 的边容量之和,这正是覆盖 C 的总权重。

算法步骤与总结

  1. 根据二分图及其顶点权重,构建上述流网络 G‘
  2. G’ 上使用最大流算法(如Edmonds-Karp算法)计算最大流,并得到对应的一个最小割 (S, T)
  3. 根据上述规则,从最小割 (S, T) 构造出顶点覆盖 C
  4. C 即为原二分图的最小权顶点覆盖。

总结:我们通过巧妙的网络构建,将二分图上的最小权顶点覆盖问题转化为标准的最小割问题。利用最大流最小割定理,我们可以在 O(VE^2)(使用Edmonds-Karp算法)时间内高效求解。值得注意的是,在这个网络中,最大流对应的是原图的一个“广义最大匹配”问题。


项目选择问题

接下来,我们探讨另一个经典应用:项目选择问题(又称开放矿坑问题)。这展示了最小割如何帮助我们在具有依赖关系的任务中做出最优决策。

问题定义

我们有一个有向无环图 DAG。每个顶点代表一个项目,有一个价值 p(v)(可正可负,正值代表收益,负值代表成本)。图中的边 (u -> v) 表示依赖关系:要选择项目 v必须先完成项目 u。目标是选择一个项目子集 S,在满足所有依赖关系的前提下,最大化总价值 Σ_{v∈S} p(v)

构建流网络

以下是构建流网络 G’ 的方法:

  1. 创建源点 s 和汇点 t
  2. 对于每个价值为正的项目 v (p(v) > 0),添加边 (s -> v),容量为 p(v)
  3. 对于每个价值为负的项目 v (p(v) < 0),添加边 (v -> t),容量为 -p(v)(即成本的绝对值)。
  4. 对于原DAG中的每条依赖边 (u -> v),添加边 (u -> v),容量设为无穷大 ()。

最小割与最优选择的对应关系

关键定理:在构建的流网络 G’ 中,任何有限容量的 s-t(S, T) 都对应原问题的一个合法项目选择(即 S \ {s} 中的项目),且项目选择的总利润等于所有正价值之和减去该割的容量

直觉解释

  • 无穷大边的作用:容量为 的边确保了在任何一个有限容量的割中,不能有依赖边从割的 S 侧跨越到 T 侧。这意味着,如果你选择了一个项目(在 S 侧),那么所有它依赖的前置项目(通过有向边指向它)也必须在 S 侧,从而满足了依赖约束。
  • 割容量的含义:割的容量由两部分组成:
    1. sT 中正价值项目的边(即放弃的正收益)。
    2. S 中负价值项目到 t 的边(即需要承担的成本)。
      因此,最小割就是在满足依赖的前提下,最小化“放弃的收益”与“承担的成本”之和。

公式化表述
P = Σ_{v: p(v)>0} p(v) 为所有正价值的总和(理想最大收益)。
(S, T) 为一个有限容量的割,对应的项目集合为 X = S \ {s}
则项目选择 X 的利润为:Profit(X) = P - capacity(S, T)

因此,寻找最大利润的项目选择 X,等价于在流网络 G’ 中寻找容量最小的 s-t 割。

算法步骤与总结

  1. 根据项目DAG和价值,构建上述流网络 G‘
  2. G’ 上计算最小割 (S, T)
  3. 最优项目集合即为 S \ {s}(去掉源点)。
  4. 最大利润为 P 减去最小割的容量。

总结:项目选择问题通过将依赖关系编码为无穷大容量的边,并将收益和成本分别连接到源点和汇点,成功转化为了最小割问题。这再次体现了最小割模型在解决带约束的优化问题上的强大能力。


网络流问题的推广

最后,我们简要了解如何将更复杂的网络流问题规约到标准的最大流问题。核心思想是分阶段处理不同的约束。

带供需约束的流

在标准流中,每个中间节点满足流量守恒(流入=流出)。现在,我们允许节点有供需值 b(v)

  • b(v) > 0:表示该节点是需求点,需要净流入 b(v) 单位流量。
  • b(v) < 0:表示该节点是供应点,可以提供净流出 -b(v) 单位流量。
  • b(v) = 0:标准守恒点。

问题可能有两种形式:

  1. 可行性问题:是否存在一个流满足边的容量约束和节点的供需约束?
  2. 最大流问题:在满足供需约束的前提下,从指定的源点 s 到汇点 t 能传输的最大流量是多少?

解决方法(以最大流问题为例)
采用两阶段法

  • 阶段一:求可行流
    • 添加一个超级源点 s’ 和超级汇点 t’
    • 对于每个供应点 v (b(v) < 0),添加边 (s’ -> v),容量为 -b(v)
    • 对于每个需求点 v (b(v) > 0),添加边 (v -> t’),容量为 b(v)
    • 在原图的源点 s 和汇点 t 之间添加一条容量为 的返回边 (t -> s),以确保在新网络中总供应等于总需求。
    • 在新网络上计算从 s’t’ 的最大流。如果最大流能饱和所有从 s’ 出发和进入 t’ 的边,则说明存在可行流。将该流映射回原图(忽略 s’, t’ 和返回边 (t->s) 上的流量),即得到一个满足供需约束的可行流 f
  • 阶段二:在可行流基础上增广
    • 在得到可行流 f 后,构造原图(带有 st)关于流 f残差网络。注意,此时残差网络中所有节点的净需求均为0。
    • 在残差网络上,从 st 运行标准的增广路算法(如Ford-Fulkerson),寻找并增加流量,直到得到最大流 f’
    • 最终的最大流为 f + f’

带上下界约束的流

更进一步,我们不仅要求边 (u, v) 上的流量 f(u,v) 有上界 c(u,v),还有下界 l(u,v),即 l(u,v) ≤ f(u,v) ≤ c(u,v)

解决方法:可以采用三阶段法

  1. 满足下界:首先,强制让每条边流过等于其下界 l(u,v) 的流量。这会导致每个节点的流量平衡被破坏,产生净流出或净流入。
  2. 恢复平衡(求可行流):此时,问题转化为上一个问题——在已经有一部分流量的基础上,寻找一个流来满足各节点新产生的供需约束,使整体达到平衡。这可以通过上述的“带供需约束的可行性问题”方法解决。
  3. 最大化流量:在得到满足上下界和平衡约束的可行流后,再在残差网络上运行最大流算法,从真正的源点向汇点推送更多流量。

课程总结

本节课中我们一起学习了最小割理论的多方面应用与推广:

  1. 二分图最小权顶点覆盖:通过构建特定流网络,将该NP难问题在二分图上的特例转化为最小割问题。
  2. 项目选择问题:利用最小割处理具有前驱依赖关系的任务调度与选择,以最大化净收益。
  3. 网络流模型的推广:我们探讨了如何通过添加超级源汇、分阶段计算(先满足可行性,再优化目标)等策略,将带有节点供需平衡约束或边流量上下界约束的复杂流问题,规约到标准的最大流/最小割问题上来解决。

这些内容展示了最大流最小割定理作为核心工具,其应用范围远不止于简单的网络传输问题,而是能够解决一大类具有约束和优化目标的组合决策问题。

023:最小成本流

在本节课中,我们将学习网络流问题的一个重要扩展:最小成本流。我们将从基本概念入手,逐步介绍两种核心算法:环消除法连续最短路法,并探讨如何将更一般化的问题(包含供需、下界等)归约到标准的最小成本循环问题。


概述 📋

我们之前学习的最大流问题,目标是最大化从源点 s 到汇点 t 的流量。现在,我们为网络中的每条边引入一个新的参数:成本。我们的目标不再是最大化流量,而是在满足流量平衡和容量限制的前提下,找到总成本最小的流。这被称为最小成本流问题。


最小成本循环问题

首先,我们来看一个更基础的问题:最小成本循环。在这个问题中,没有特定的源点和汇点,流可以在网络中循环。目标是找到一个满足所有顶点流量平衡的流,并且总成本最小。

输入

  • 一个有向图 G = (V, E)
  • 每条边 e 有一个容量 c(e) >= 0
  • 每条边 e 有一个成本 cost(e)(可正、可负、可为零)。

输出

  • 一个流函数 f: E -> R,满足:
    1. 可行性:对于每条边 e,有 0 <= f(e) <= c(e)
    2. 平衡性:对于每个顶点 v,流入量等于流出量。
    3. 成本最小:总成本 sum_{e in E} f(e) * cost(e) 最小。

核心公式
总成本 = sum_{e in E} f(e) * cost(e)

如果所有边的成本都是非负的,那么零流(所有边流量为0)就是最优解。因此,问题只在某些边成本为负时才真正有趣。


环消除法

上一节我们定义了最小成本循环问题,本节中我们来看看第一个求解算法:环消除法。它的思想与Ford-Fulkerson算法类似:从一个可行流(如零流)开始,不断在残差图中寻找可以改进当前流的“增广环”。

残差图与负成本环

在残差图中,我们不仅需要记录每条边的残差容量,还需要定义其残差成本

  • 对于原图中的正向边,其残差成本等于原成本。
  • 对于新增的反向边(对应减少正向边的流量),其残差成本等于原成本的相反数-cost(e))。

关键观察:如果在当前流的残差图中存在一个总成本为负的环(负成本环),那么沿着这个环推送流量(在容量限制内)可以降低总成本,同时不破坏平衡性。

以下是环消除法的步骤:

  1. 初始化:从任意可行流开始(例如零流 f = 0)。
  2. 寻找负环:在残差图 G_f 中,寻找一个负成本环 C
  3. 增广:如果找到负环 C,则沿该环推送尽可能多的流量。推送量 delta 是环上所有边残差容量的最小值:delta = min_{e in C} r_c(e)。更新流:f = f + delta * flow_along_C
  4. 循环:重复步骤2和3,直到残差图中不再存在负成本环。

算法终止与最优性:当算法终止(残差图中无负成本环)时,当前的流 f 就是一个最小成本循环。

算法效率与改进

一个简单的实现是使用Bellman-Ford算法来检测负环,每次迭代时间复杂度为 O(VE)。如果所有容量和成本都是整数,每次增广至少使总成本减少1,因此迭代次数以初始流成本为界。但这在最坏情况下可能是指数级的。

为了提高效率,我们需要精心选择增广的环。以下是两种策略:

  • 最小平均成本环:选择平均成本(总成本除以边数)最小的负环进行增广。这可以在 O(VE) 时间内找到,并能将总迭代次数降至 O(V log V),从而实现 O(V^2 E log V) 的总时间复杂度。
  • 最负环:选择总成本最负的环。然而,寻找这样的环本身是NP难问题。

推广:带有供需和下界的最小成本流

上一节我们介绍了基础的环消除法,本节中我们来看看一个更一般化的问题模型。实际问题中,顶点可能有供应(产生流量)或需求(消耗流量),边可能有流量下界(必须输送的最小流量)。

推广问题的输入

  • 有向图 G = (V, E)
  • 每条边 e 有容量上界 c(e) 和下界 l(e)0 <= l(e) <= c(e))。
  • 每条边 e 有成本 cost(e)
  • 每个顶点 v供需值 b(v)b(v) > 0 表示需求,b(v) < 0 表示供应,且所有顶点的供需值之和必须为零。

输出

  • 一个流函数 f,满足:
    1. 可行性l(e) <= f(e) <= c(e)
    2. 平衡性:对于每个顶点 v(流入 v 的流量) - (流出 v 的流量) = b(v)
    3. 成本最小

我们可以通过一系列归约,将这个推广问题转化为我们已经知道如何求解的最小成本循环问题。归约遵循一个清晰的优先级顺序:可行性 -> 平衡性 -> 成本最优性

以下是归约步骤:

  1. 满足下界(实现可行性)

    • 对于每条边 e,直接令初始流 f(e) = l(e)。这自动满足了所有下界约束。
    • 副作用:这会改变顶点的净流量(即供需值)。我们需要更新每个顶点 v残差供需值 b'(v) = b(v) - (从 f 中计算出的 v 的净流出量)
  2. 满足供需平衡(实现平衡性)

    • 现在,我们有一个满足下界(即可行)但可能不满足供需平衡的流。
    • 我们在当前的残差图中操作。将所有 b'(v) < 0 的顶点(供应点)连接到一个虚拟源点,将所有 b'(v) > 0 的顶点(需求点)连接到一个虚拟汇点。
    • 在这个新网络上运行最大流算法(如Edmonds-Karp),将供应点的多余流量输送到需求点。这得到一个既可行又平衡的流 f'
  3. 最小化成本(实现成本最优性)

    • 此时,流 f' 在原始问题的残差图中,对应一个可行且平衡的流。更重要的是,在这个残差图中,所有边的下界都变成了0。
    • 我们现在面对的正是一个标准的最小成本循环问题(顶点供需均为0,无边下界)。对此,我们可以直接应用环消除法
    • 运行环消除法,得到最终的最小成本流 f*

连续最短路法

上一节我们通过“可行性->平衡性->最优性”的优先级来解决问题。本节中我们介绍另一种重要算法:连续最短路法。它采用了不同的优先级顺序:可行性 -> 局部最优性 -> 平衡性

算法阶段

  1. 满足下界与处理负成本边(实现可行性并趋向局部最优)

    • 处理下界:与之前相同,令 f(e) = l(e),更新残差供需。
    • 处理负成本边:对于所有成本为负的边,我们饱和它们(推送流量至容量上限)。因为使用这些边能“赚钱”(减少总成本)。这会在残差图中产生成本为正的反向边。
    • 此阶段结束后,我们得到一个可行流,并且残差图中所有边的成本均为非负。这意味着残差图中没有负环,即当前流对于其残差图是局部最优的。
  2. 满足供需平衡(在保持局部最优的前提下)

    • 此时我们有一个可行且局部最优,但可能不平衡的流。所有残差边成本非负。
    • 核心操作:只要存在供应点(b'(v) < 0)和需求点(b'(v) > 0):
      • 残差图中,以边成本作为长度,计算从某个供应点 s 到某个需求点 t最短路径
      • 沿这条最短路径 sigma 推送尽可能多的流量。推送量 delta 是路径上残差容量的最小值与 |b'(s)|b'(t) 的最小值。
      • 更新流和顶点的残差供需。

正确性关键:保持无负环

为什么在推送最短路径流量后,残差图依然没有负环?以下是证明思路(反证法):

  • 假设:推送流量后,新的残差图中出现了一个负环 C
  • 推理:这个负环 C 必须包含至少一条新出现的边,即我们刚才推送流量的最短路径 sigma 上某条边的反向边。
  • 构造矛盾:我们可以将负环 C 和最短路径 sigma “组合”起来,通过抵消公共边(方向相反),得到另一条从 st 的路径 sigma'
  • 计算 sigma' 的成本:cost(sigma') <= cost(sigma) + cost(C)。因为 cost(C) < 0,所以 cost(sigma') < cost(sigma)
  • 这与 sigma最短路径的假设矛盾。因此,假设不成立,推送后仍无负环。

算法效率:寻找最短路径可以使用Dijkstra算法(因为残差成本非负),但每次增广后需要更新潜在距离函数以维持非负性,或者使用Bellman-Ford算法(O(VE))。迭代次数与总供需量有关。通过精心选择供应点和需求点对,可以得到更高效(如 O(V^2 log^2 V))的实现。


总结 🎯

本节课我们一起学习了网络流的高级主题——最小成本流。

  • 我们从最小成本循环这个基础问题出发,理解了成本、容量、平衡性等核心概念。
  • 我们学习了环消除法,它通过不断消除残差图中的负成本环来逐步优化流,其思想是“在保持平衡的前提下优化成本”。
  • 我们探讨了更一般的、带有供需流量下界的最小成本流问题,并学会了通过分步归约(先满足下界,再平衡供需,最后优化成本)将其转化为标准问题。
  • 最后,我们介绍了连续最短路法,它采用了不同的策略(先处理负成本边达到局部最优,再通过连续推送最短路流量来满足平衡),并理解了其保持“无负环”这一关键性质的原因。

这两种算法是解决最小成本流问题的基石,理解它们有助于你应对各种复杂的网络流优化场景。

024:线性规划入门

在本节课中,我们将学习线性规划的基本概念。线性规划是一类强大的优化问题,它要求我们在满足一系列线性约束的条件下,最大化或最小化一个线性目标函数。我们将从几何角度理解它,学习其标准形式,并探索其对偶性这一核心概念。

线性规划是什么?🤔

线性规划问题可以抽象地描述为:给定一系列系数,我们试图找到一组变量(实数)的值,使得它们满足一组线性不等式或等式约束,同时最大化(或最小化)一个线性目标函数。

其数学形式可以概括为:

  • 目标:最大化 c₁x₁ + c₂x₂ + ... + c_d x_d
  • 约束:满足 aᵢ₁x₁ + aᵢ₂x₂ + ... + aᵢ_d x_d ≤ bᵢ (对于某些 i)
  • 变量x₁, x₂, ..., x_d 是实数。

这里的“线性”指的是所有表达式都是变量的线性组合(常数乘以变量后相加),不会出现变量相乘、平方或其它非线性函数。“规划”一词与动态规划中的含义类似,指的是通过系统化的方法(如构建表格)来求解。

上一节我们介绍了线性规划的抽象定义,本节中我们来看看如何从几何角度直观地理解它。

几何视角 📐

考虑一个只有两个变量 x₁x₂ 的线性规划。每个线性约束 a₁x₁ + a₂x₂ ≤ b 在平面上定义了一个半空间(一条直线及其一侧的所有点)。所有约束半空间的交集构成了一个区域,称为可行域可行多面体

在更高维度(例如三维)中,每个约束是一个平面及其一侧的空间,可行域则是这些半空间的交集,形成一个多面体。

目标函数 c₁x₁ + c₂x₂ 可以看作一个方向向量。我们可以旋转坐标系,使得这个方向指向“下方”。于是,线性规划问题就转化为:在可行域这个多面体内,找到“最低”的那个点(即沿目标函数方向投影最小的点)。

这种几何理解比单纯看数字表格更直观。最著名的线性规划算法——单纯形法,其核心思想就像在这个多面体内丢下一颗弹珠,让它滚到最低点。

了解了几何意义后,我们来看看线性规划可能出现的几种特殊情况。

可能遇到的问题 ⚠️

在寻找“最低点”的过程中,可能会遇到两种“病理”情况:

  1. 无界:可行域存在,但目标函数值可以无限降低(在最大化问题中则是无限升高),没有最优解。例如,在最短路径问题中存在负权环时。
  2. 不可行:约束条件互相冲突,导致可行域是空集,没有任何点能满足所有约束。例如,在流网络中,供需不平衡或容量不足导致不存在可行流。

值得注意的是,问题是否有界取决于约束矩阵(决定了多面体的形状)和目标函数方向,而是否可行则取决于约束矩阵和偏移向量(决定了半空间的位置)。

接下来,我们将通过一个经典例子,看看如何将实际问题建模为线性规划。

示例:最短路径作为线性规划 🛣️

最短路径问题可以表述为一个线性规划。以下是其中一种建模方式(最大化形式):

  • 变量:对每个顶点 v,有一个变量 dist(v),表示从源点 sv 的距离。
  • 目标:最大化 dist(t) (即我们希望 t 点的距离尽可能大,但会受到约束限制)。
  • 约束
    1. dist(s) = 0
    2. 对每条边 (u, v),有 dist(u) + l(u,v) ≤ dist(v)。这保证了最终的距离满足三角不等式,即边是“松弛”的。
  • 解释:算法(如 Dijkstra, Bellman-Ford)初始化距离为无穷大,然后不断降低距离使其满足约束。这个线性规划则是在所有满足约束的解中,寻找使 dist(t) 最大的那个,这恰好就是最短路径长度。

另一种建模方式(最小化形式)则涉及为每条边分配一个表示最短路径使用次数的变量,并将其转化为一个最小成本流问题。有趣的是,这两种形式是互相对偶的。

为了更系统地讨论和求解,我们需要将线性规划转化为一种标准形式。

标准不等式形式 📝

为了避免处理各种形式的约束(≤, ≥, =),我们通常将线性规划转化为如下标准不等式形式

  • 目标:最大化 cᵀ xc 是目标系数向量,x 是变量向量)。
  • 约束
    1. A x ≤ bA 是约束矩阵,b 是偏移向量)。
    2. x ≥ 0 (所有变量非负)。

任何线性规划都可以通过引入新变量、等式拆分、乘以 -1 等方式转化为这种形式。在这种形式下,输入就是矩阵 A、向量 b 和向量 c

线性规划一个极其优美且强大的特性是对偶性,这类似于最大流与最小割的关系。

对偶性:邪恶的双胞胎 😈

每个线性规划(称为原问题)都有一个对应的对偶问题。对于标准不等式形式的原问题(最大化),其对偶问题是一个最小化问题:

  • 原问题 (P):最大化 cᵀ x,满足 A x ≤ bx ≥ 0
  • 对偶问题 (D):最小化 bᵀ y,满足 Aᵀ y ≥ cy ≥ 0

其中 y 是对偶变量向量。可以看到,原问题的系数与对偶问题的偏移量互换了,约束矩阵转置了,不等号方向改变了,优化方向也反了过来。

原问题与对偶问题通过弱对偶定理强对偶定理紧密相连:

  • 弱对偶:任何原问题的可行解 x 的目标值,不超过任何对偶问题的可行解 y 的目标值。即 cᵀ x ≤ bᵀ y
  • 强对偶:如果原问题有最优解 x,那么对偶问题也有最优解 y,并且最优值相等:cᵀ x* = bᵀ y*。

这就像最大流的值等于最小割的容量。如果找到一个原问题可行解和一个对偶问题可行解,使得它们的目标值相等,那么这两个解都是各自问题的最优解。

最后,让我们实践一下,如何为之前的最短路径线性规划构造对偶问题。

构造对偶:以最短路径为例 🔄

回顾最短路径线性规划(原问题,P):

  • 变量:dist(v) 对于所有顶点 v
  • 目标:最大化 dist(t)
  • 约束:对于每条边 (u,v),有 dist(v) - dist(u) ≤ l(u,v);且 dist(s) = 0

按照对偶规则进行转换:

  1. 原问题是最大化,所以对偶是最小化。
  2. 原问题每个约束(每条边)对应一个对偶变量。设对偶变量为 x(u,v)(对于每条边)。
  3. 原问题的目标系数(除了 dist(t) 系数为1,其余为0)成为对偶约束的右侧。
  4. 原问题的变量 dist(v)(除了 dist(s) 固定为0)对应对偶的约束。dist(s) 固定,故在对偶中无对应约束;其他 dist(v) 无符号限制,故在对偶中产生等式约束。

经过推导,得到对偶问题 (D):

  • 变量:x(u,v) ≥ 0 对于每条边 (u,v)
  • 目标:最小化 ∑ l(u,v) * x(u,v)
  • 约束:对于每个顶点 v ∉ {s, t},流入的 x 之和等于流出的 x 之和;对于顶点 t,流入的 x 之和等于 1。

这正是一个从 st 发送 1 单位流量的最小成本流问题,其最优值就是最短路径长度。这完美体现了对偶性。


本节课中我们一起学习了线性规划的基础:从其定义和几何解释,到可能遇到的无界与不可行情况。我们通过最短路径问题实例了解了如何建模,并介绍了标准形式。最后,我们探讨了线性规划的核心——对偶性,揭示了原问题与对偶问题之间深刻而对称的联系,并通过实例演示了如何构造对偶问题。理解这些概念是运用线性规划这一强大工具解决实际优化问题的关键。

025:线性规划对偶与单纯形法

在本节课中,我们将学习线性规划的核心概念之一——对偶性,并介绍求解线性规划最常用的算法——单纯形法。我们将从几何和代数的角度理解这些概念,并探讨算法的基本工作原理。

概述

线性规划问题通常涉及在一组线性不等式约束下,最大化或最小化一个线性目标函数。对偶性揭示了每个线性规划问题都存在一个与之紧密相关的“对偶”问题。单纯形法则是一种通过迭代改进“基”来寻找最优解的经典算法。

对偶性:原问题与对偶问题

上一节我们介绍了线性规划的基本形式。本节中,我们来看看它的“孪生兄弟”——对偶问题。

给定一个标准形式的线性规划(原问题):

  • 最大化 cᵀx
  • 约束条件:Ax ≤ b,且 x ≥ 0

其对应的对偶问题为:

  • 最小化 bᵀy
  • 约束条件:Aᵀy ≥ c,且 y ≥ 0

这里,原问题有 n 个变量和 m 个约束,而对偶问题则有 m 个变量和 n 个约束,两者互换了角色。

弱对偶定理

这是理解对偶关系的第一步。它指出,任何原问题的可行解的目标值,都不会超过任何对偶问题的可行解的目标值。

公式:若 x 是原问题的可行解,y 是对偶问题的可行解,则 cᵀx ≤ bᵀy

证明思路

  1. x 可行,知 Ax ≤ b
  2. y 可行,知 y ≥ 0Aᵀy ≥ c
  3. 将不等式 Ax ≤ b 两边同时左乘非负向量 yᵀ,得到 yᵀAx ≤ yᵀb
  4. Aᵀy ≥ cx ≥ 0,可得 cᵀx ≤ (Aᵀy)ᵀx = yᵀAx
  5. 结合第3和第4步,即得 cᵀx ≤ yᵀb

强对偶定理

这是线性规划对偶理论的核心。它指出,如果原问题和对偶问题都有可行解,那么它们的最优目标值相等

几何解释:想象原问题是在一个凸多面体(可行域)中寻找最“高”的点。对偶问题则提供了另一个视角。弱对偶定理说明原问题的所有可能值构成一个区间,对偶问题的所有可能值构成另一个区间,且前者总在后者“左边”。强对偶定理则告诉我们,这两个区间恰好在一个点相接,没有间隙。这个点就是共同的最优值。

物理直觉:考虑一个在多面体角落静止的弹珠。重力(目标函数)向下拉。弹珠静止意味着有多面体的面(紧约束)提供向上的支持力。这些支持力的强度(大小和方向)恰好抵消了重力。这些“力”的系数,在某种意义上,就对应于对偶问题中非零的变量。只有那些“起作用”(紧)的约束,其对应的对偶变量才非零。

单纯形法:基本思想

理解了问题结构后,我们来看看如何求解。单纯形法是解决线性规划最著名的算法。

基与顶点

单纯形法的操作对象是“基”。

  • :从所有约束中选出 d 个(d 是变量个数),使得它们线性独立。在二维中,基就是两条相交的直线。
  • 基解:令这 d 个约束取等号(即作为等式),解这个 d×d 的线性方程组得到的点。在二维中,就是两条直线的交点。
  • 可行基:如果该基解同时满足所有其他约束(即落在可行域内),则该基是可行的。可行基对应于可行凸多面体的顶点
  • 局部最优基:如果在该基定义的 d 个约束下(忽略其他约束),该基解已经是最优的,则称该基是局部最优的。在对偶问题中,局部最优基对应于对偶问题的可行基。

一个关键结论是:如果线性规划有最优解,那么必然存在一个可行且局部最优的基(即顶点)达到该最优解。

算法框架

单纯形法本质上是在可行域的顶点(即可行基)之间“行走”,每一步都沿着边移动到相邻的、目标值更优的顶点,直到无法改进为止。

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

单纯形算法(原问题视角):
1.  找到一个初始的可行基(顶点)x。
2.  while x 不是局部最优的:
        a. 检查所有与 x 相邻的可行基(即仅交换一个约束得到的基)。
        b. 如果所有相邻基的目标值都比 x 差,则问题无界(可无限优化)。
        c. 否则,选择一个目标值更好的相邻基作为新的 x(进行一次“旋转”)。
3.  返回 x 作为最优解。

几何行走:在二维多边形上,就是从某个顶点开始,沿着边走到下一个更优的顶点,直到到达最低点。在高维中,则是沿着多面体的棱移动。

对偶单纯形法

有趣的是,我们也可以从对偶问题的视角运行单纯形法,这被称为对偶单纯形法

单纯形算法(对偶问题视角):
1.  找到一个初始的局部最优基(不一定可行)y。
2.  while y 不是可行的:
        a. 检查所有与 y 相邻的局部最优基。
        b. 如果所有相邻基在对偶意义下都更差(即更不可行),则原问题不可行。
        c. 否则,选择一个“更可行”的相邻基作为新的 y(进行一次“旋转”)。
3.  返回 y 作为最优解。

物理类比:原单纯形法像让一个弹珠在可行域内滚到最低点。对偶单纯形法则像在多面体外部吹一个气泡,让它不断膨胀,直到刚好触碰到可行域的一个顶点。

启动单纯形法:两阶段法

单纯形法需要一个起点:要么是一个可行基(原问题),要么是一个局部最优基(对偶问题)。如何找到这个起点呢?常用方法是两阶段法

核心思想:通过临时修改问题,制造一个显而易见的起点。

方法一(通过修改目标函数启动对偶单纯形)

  1. 任意选择一个基(不要求可行或局部最优)。
  2. 旋转世界:临时改变目标函数的方向,使得当前这个基变成局部最优的。
  3. 运行对偶单纯形法,利用这个局部最优基,找到原问题的一个可行基
  4. 恢复原始的目标函数。现在我们已经有了一个可行基。
  5. 运行标准的原单纯形法,求得最终最优解。

方法二(通过修改约束启动原单纯形)

  1. 任意选择一个基。
  2. 平移约束:临时将所有约束平移,使得当前这个基变成可行的。
  3. 运行原单纯形法,在这个修改后的问题中找到局部最优基
  4. 撤销约束的平移。此时得到的基对于原始问题仍是局部最优的。
  5. 运行对偶单纯形法,将这个局部最优基“提升”为原始问题的可行(即最优)解。

这两种方法是等价的,只是看待问题的视角不同。在实践中,通常通过引入人工变量等技巧来构造一个明显的初始可行基。

算法复杂度与现状

单纯形法在实际中通常非常高效,但其理论最坏情况复杂度是指数级的。

  • 最坏情况:存在像“Klee-Minty立方体”这样的病理例子,使得某些简单的旋转规则(如选择目标值提升最大的边)需要遍历几乎所有顶点,导致步数呈指数级增长。
  • 平均情况与光滑分析:在实际中,单纯形法往往很快。研究表明,如果给线性规划的系数加入微小的随机扰动(这很符合现实世界数据带噪声的特性),那么单纯形法的期望运行时间是多项式级别的。这解释了为何它在工程应用中如此成功。
  • 多项式时间算法:存在像椭球法内点法这样的算法,能在多项式时间内求解线性规划,无需遍历顶点。内点法尤其在现代优化软件中广泛应用。
  • 未解之谜:是否存在一个确定性的旋转规则,能保证单纯形法在多项式步数内结束?这是一个悬而未决的重大理论问题。

总结

本节课中我们一起学习了线性规划的两个核心主题。

  1. 对偶性:我们了解了每个线性规划问题都有一个对应的对偶问题,它们通过弱对偶和强对偶定理紧密相连。对偶变量具有直观的物理意义(如约束的“影子价格”或“支持力”)。
  2. 单纯形法:我们探讨了这种经典算法的基本思想,它通过迭代地在可行域的顶点之间移动来寻找最优解。我们分别从原问题和对偶问题的角度审视了算法,并介绍了如何通过两阶段法来启动算法。最后,我们讨论了算法的效率,认识到其在实际中的高效性与理论最坏情况的复杂性,以及相关的开放性问题。

掌握对偶性和单纯形法的基本原理,是理解更复杂优化算法和应用线性规划解决实际问题的坚实基础。

026:在线算法简介与竞争分析

在本节课中,我们将要学习一种特殊的算法——在线算法。我们将了解其核心概念,并通过几个经典例子(如寻牛问题、租或买问题)来学习如何分析在线算法的性能,即竞争比分析。

在线算法与离线算法

上一节我们介绍了在线算法的基本概念,本节中我们来看看它与传统算法的区别。

在线算法需要处理一个操作序列,并且必须立即响应每一个操作请求,无法预知未来的请求。其目标是最小化处理整个序列的总成本

与之对比的是离线算法,它能够预先获得整个操作序列,从而可以做出全局最优的决策。在线算法的性能,通常通过竞争比来衡量,即将其总成本与一个全知全能的离线最优算法的总成本进行比较。

一个数据结构的操作过程,可以看作是在线算法的一个例子。你向这个“盒子”发送指令(如存储、查询),它立即响应并返回结果。

缓存管理:一个经典例子

为了理解竞争比,我们先看一个熟悉的例子:缓存管理。

假设有一个容量为 K 的缓存。操作是请求一个内存地址 x

  • 如果 x 在缓存中,成本为 0
  • 如果 x 不在缓存中,成本为 1。此时需要将 x 载入缓存,并驱逐一个已有的项。

以下是几种常见的缓存置换策略:

  • FIFO:缓存是一个先进先出队列。新项加入队头,驱逐队尾项。
  • LRU:缓存是一个优先队列,驱逐最近最久未使用的项。

离线最优算法(OPT)的策略是:驱逐那个在未来最晚会被再次访问的项。

分析表明,FIFO和LRU都是 K-竞争 的。这意味着对于任何访问序列,它们产生的总成本至多是OPT成本的 K 倍。即竞争比为 K

然而,在实际应用中,由于程序访问具有局部性,LRU通常表现远优于FIFO。这是因为理论分析针对的是最坏情况,由一个全知且恶意的“对手”设计访问序列。

为了对抗这种最坏情况,可以使用随机化。例如,随机标记算法期望竞争比可以降至 O(log K),这比确定性的 K 有指数级改进。

寻牛问题:确定性策略

现在,我们来看一个更简单的在线问题:寻牛问题。

一头牛在一条无限长的路上醒来,它不知道自己的位置。路上某处有一个谷仓,牛必须走到谷仓所在点才能发现它。牛的目标是设计一个行走策略,最小化其行走总距离谷仓实际距离的比值(竞争比)。

假设牛醒来的位置为0点,谷仓在位置 T(T > 0)。一个自然的策略是交替向左右方向进行指数级增长的探索。

一个确定的策略序列可以是:向右走 1 单位,返回0点;向左走 2 单位,返回0点;向右走 4 单位,返回0点;向左走 8 单位…… 即步长按 2的幂次 增长。

当谷仓位于 2^(2i) < T < 2^(2i+2) 时,牛走过的总距离 D 满足 D < 9T - 2。因此,该确定性算法的竞争比至多为9。可以证明,9是确定性算法竞争比的下界。

寻牛问题:随机化策略

上一节的确定性策略有一个任意选择:第一步的方向。随机化可以改善这一点。

一个简单的随机化策略是:以 1/2 的概率第一步向右走,以 1/2 的概率第一步向左走,后续步长仍按2的幂次增长。

分析表明,这个简单随机策略的期望竞争比可以降至 6.28...。通过进一步优化(例如随机化增长基数 B,或增加一个随机偏移量 δ),可以得到期望竞争比约为 4.61 的最优随机算法,并且这被证明是理论下界。

租或买问题:确定性策略

另一个经典在线问题是租或买问题。

你每天都需要使用一件物品(如滑雪板)。每天你可以选择:

  • :花费 1 单位成本。
  • :花费 B 单位成本,此后可以永久免费使用。

但你不知道世界(或滑雪季)会在第 T 天结束。你的目标是使总花费尽可能小。

离线最优策略很简单:如果 T < B,则始终租用;如果 T ≥ B,则在第一天购买。

在线算法只能决定一个购买时间 i。算法策略为:先租 i 天,然后在第 i+1 天购买。

可以分析得出,最优的确定性选择是 i = B - 1。此时,竞争比为 2 - 1/B。当 B 很大时,竞争比趋近于 2。这也是确定性算法的下界。

租或买问题:随机化与线性规划

对于租或买问题,我们可以设计随机算法来获得更好的竞争比。

设算法以概率 p_i 选择“租 i 天后购买”。我们的目标是选择一组概率 {p_i},使得对于所有可能的结束时间 T,算法的期望成本与最优离线成本 min(T, B) 的比值(即期望竞争比)C 尽可能小。

这可以形式化为一个线性规划问题:

  • 变量:概率 p_0, p_1, ... 和竞争比 C
  • 约束:对于每个 T,算法的期望成本 ≤ C * min(T, B)
  • 目标:最小化 C

通过观察(对手最优策略是令 T < BT → ∞)和推理(算法无需租超过 B-1 天),可以将这个无限维线性规划简化为有限维问题。

求解该线性规划,得到最优的随机策略概率分布和最优竞争比 C = e/(e-1) ≈ 1.58。这显著优于确定性算法的竞争比2。这个解同时由对偶性证明了其最优性:不存在期望竞争比低于 e/(e-1) 的随机算法。

总结

本节课中我们一起学习了在线算法的基本概念和竞争分析方法。

  • 在线算法必须即时处理未知未来的请求序列,其性能通过与离线最优算法的竞争比来衡量。
  • 我们通过缓存管理例子了解了竞争比分析。
  • 寻牛问题展示了如何通过确定性和随机化策略设计在线算法,并分析了其竞争比。
  • 租或买问题揭示了如何将随机算法设计转化为线性规划问题,并求得了理论最优解。

在线算法和竞争分析为我们在信息不完全的情况下进行决策提供了重要的理论工具和设计思路。

027:P27 Splay Trees

在本节课中,我们将学习一种名为伸展树(Splay Tree)的动态二叉搜索树数据结构。我们将了解其基本操作、工作原理,并通过势能分析法理解其平摊时间复杂度。最后,我们将探讨关于伸展树动态最优性的一个著名猜想及其相关的几何视角。


概述

伸展树是一种自调整的二叉搜索树。它不保证每次操作后树都是平衡的,但能保证一系列操作的总时间开销是高效的。其核心思想是:每次访问一个节点后,通过一系列称为“伸展”(splay)的旋转操作,将该节点移动到树的根部。这种策略利用了“局部性”原理,即最近被访问的节点很可能再次被访问,从而使得频繁访问的节点靠近根部,提高后续访问速度。


二叉搜索树与旋转操作

在深入伸展树之前,我们先回顾标准二叉搜索树的更新操作。最核心的操作是旋转,它能在保持二叉搜索树性质的前提下,局部改变树的结构。

旋转操作图示如下,它通过改变节点 x 和其父节点 y 的关系来调整树的高度:

    y                         x
   / \                       / \
  x   C   --右旋(y)-->      A   y
 / \                           / \
A   B                         B   C

代码描述
旋转操作只涉及常数次指针修改。例如,右旋节点 y 的伪代码如下:

function rightRotate(y):
    x = y.left
    y.left = x.right
    if x.right != null:
        x.right.parent = y
    x.parent = y.parent
    // 更新 y 父节点的子指针
    if y.parent == null:
        root = x
    else if y == y.parent.left:
        y.parent.left = x
    else:
        y.parent.right = x
    x.right = y
    y.parent = x

上一节我们回顾了基础操作,本节中我们来看看伸展树如何利用旋转进行自调整。


伸展操作

伸展树的核心是 伸展(splay) 操作。每当访问(查找、插入、删除)一个节点 x 后,都会执行 splay(x),通过一系列旋转将 x 移动到根节点。但简单地重复单旋转将节点上移可能导致树退化成链。因此,伸展操作使用两种双旋转模式,确保树的深度能有效减少。

伸展操作根据节点 x 与其父节点 p、祖父节点 g 的位置关系,分为以下三种情况:

  1. Zig(单旋转):如果 x 的父节点是根节点,则只需对 x 进行一次单旋转。
  2. Zig-Zig(同侧双旋转):如果 x 和其父节点 p 都是各自父节点的左孩子或都是右孩子(即同侧),则先旋转 p,再旋转 x
  3. Zig-Zag(异侧双旋转):如果 x 和其父节点 p 是异侧的(例如 xp 的右孩子,而 pg 的左孩子),则先旋转 x,再旋转 x(此时 x 已处于新位置)。

伸展操作伪代码

function splay(x):
    while x.parent != null:
        p = x.parent
        g = p.parent
        if g == null:          // Zig 情况
            if x == p.left:
                rightRotate(p)
            else:
                leftRotate(p)
        else if (x == p.left) == (p == g.left): // Zig-Zig 情况
            // 先旋转父节点
            if p == g.left:
                rightRotate(g)
                rightRotate(p)
            else:
                leftRotate(g)
                leftRotate(p)
        else:                                   // Zig-Zag 情况
            // 先旋转 x
            if x == p.left:
                rightRotate(p)
                leftRotate(g)
            else:
                leftRotate(p)
                rightRotate(g)

伸展操作的效果是:不仅将 x 移动到根,而且使从根到 x 原路径上大部分节点的深度大约减半,同时其他节点的深度最多增加 1 或 2。


伸展树的基本操作

基于伸展操作,我们可以定义伸展树的基本操作:

以下是插入、查找和删除操作的简要描述:

  • 插入:使用标准二叉搜索树插入法找到新节点的位置并插入,然后对新插入的节点执行 splay 操作。
  • 查找:使用标准二叉搜索树查找法。无论是否找到目标键值,都对查找路径上最后到达的节点执行 splay 操作(例如,如果未找到,则对其前驱或后继节点执行 splay)。
  • 删除
    1. 对要删除的节点 x 执行 splay(x),使其成为根。
    2. 删除根节点 x,此时树被分裂为左子树 L 和右子树 R
    3. 在左子树 L 中找到最大值节点 w(即 x 的前驱),对 w 执行 splay(w),使其成为 L 的新根。由于 wL 中的最大值,其右子树必为空。
    4. 将右子树 R 作为 w 的右子树连接起来。

每个操作的时间复杂度都取决于一次 splay 操作。


平摊分析:势能法

伸展树的性能保证是平摊的。单个操作可能耗时 O(n),但任意 m 次连续操作的总时间复杂度为 O(m log n),因此单次操作的平摊成本为 O(log n)。我们使用势能法进行分析。

首先定义几个概念:

  • 节点大小 size(v):以节点 v 为根的子树中的节点总数。
  • 节点秩 rank(v)rank(v) = log₂(size(v))。可以理解为该子树若为完全平衡树时应有的高度。
  • 树的势能 Φ:当前树中所有节点秩的总和,即 Φ = Σ rank(v)

平摊时间定义
对于一次操作,其平摊时间 a 定义为:
a = t + Φ_new - Φ_old
其中 t 是实际耗时,Φ_newΦ_old 分别是操作后和操作前树的势能。

如果对一系列 m 次操作求和,总实际时间 T_real = Σ t = Σ a + Φ_initial - Φ_final
如果我们从空树开始,初始势能为 0,且势能始终非负,则 T_real ≤ Σ a。因此,只要我们能证明每次操作的平摊时间 a = O(log n),就能证明总时间 T_real = O(m log n)

分析的关键是证明以下访问引理

对节点 x 执行一次伸展操作的平摊时间最多为 3 * (rank(root) - rank(x)) + 1 = O(log n)

由于伸展后 x 成为根,rank(root_new) = log₂(n),而 rank(x_old) ≥ 0,因此平摊时间为 O(log n)。该引理的证明需要细致地分析 Zig、Zig-Zig、Zig-Zag 各情况下的势能变化,此处略过。

这个结果意味着,即使树暂时不平衡,伸展操作也能以平摊 O(log n) 的成本自我修复。


静态最优性与动态最优性猜想

伸展树不仅平摊效率高,还具有更强的性质:

  • 静态最优性:如果每个节点 x 被访问的频率为 f(x),那么伸展树执行一系列访问的总平摊时间在常数因子内逼近于最优静态二叉搜索树(即事先知道访问频率并构建的哈夫曼树)的成本。这表明伸展树能自动适应不同的访问模式。

更引人入胜且未解决的是 动态最优性猜想

伸展树在常数因子内是动态最优的。即,对于任何访问序列,伸展树的总操作成本在常数因子内不超过最优离线动态二叉搜索树的成本。这个最优算法可以预先知道整个访问序列,并可以随时任意重组树(但重组本身有成本)。

这个猜想由 Sleator 和 Tarjan 在 1980 年代提出,至今仍未解决。它是数据结构领域最重要的开放问题之一。


几何视角:将BST问题转化为点集覆盖

为了研究动态最优性,研究者引入了二叉搜索树的几何模型。它将访问序列和执行过程转化为平面上的点集问题:

  1. 输入点集:对于一个访问序列 (t, key),我们在平面上点 (key, t) 处放置一个点。
  2. 执行点集:当算法在时间 t 访问键值 key 时,它必须触及搜索路径上的所有节点。我们将这些被触及的节点 (touched_key, t) 也标记为点。

这个模型有一个关键性质:执行点集是 Arborally Satisfied 的。这意味着,对于该点集中任意两个不在同一行或同一列的点所确定的矩形,该矩形的边界上至少存在该点集中的另一个点。

反之,任何包含输入点集并满足上述性质的超集,都对应一个有效的动态二叉搜索树执行过程。因此,寻找最优动态BST等价于寻找包含输入点集的最小Arborally Satisfied超集。


贪心算法与开放问题

基于几何模型,研究者提出了离线贪心算法(Greedy Future)和在线贪心算法(Greedy Past)。例如,Greedy Past 算法在访问一个节点时,根据节点最近被访问的时间来重构搜索路径。这非常直观:最近刚被访问的节点应该放在更靠近根的位置。

动态最优性猜想 等价于猜想这些贪心算法(以及伸展树)是常数竞争的。有大量实验证据支持这一猜想,且已知这些贪算法的成本与理论下界(如独立矩形集大小)非常接近,差距通常只是个位数。然而,严格的数学证明仍然缺失。


总结

本节课我们一起学习了伸展树这一重要的自调整二叉搜索树数据结构。我们了解了其基于旋转的伸展操作,并使用势能法分析了其 O(log n) 的平摊时间复杂度。我们还探讨了伸展树更优的静态最优性质,并深入了解了计算机科学中一个悬而未决的难题——动态最优性猜想,以及如何通过几何模型来逼近这个问题。尽管尚未得到证明,但伸展树及其相关研究展示了在线算法与最优离线算法之间可能存在的紧密联系。

028:期末考试样题讲解

在本节课中,我们将一起复习期末考试可能涉及的几种典型算法问题。我们将逐一分析样题,理解其核心概念,并学习如何设计高效的解决方案。


帕累托最优点问题

上一节我们介绍了课程安排,本节中我们来看看第一个算法问题:计算平面点集中的帕累托最优点。

问题定义与算法

给定平面上一个点集 S,其中所有点的 X 坐标和 Y 坐标均互不相同。一个点被称为帕累托最优的,当不存在另一个点同时具有比它更大的 X 坐标和更大的 Y 坐标。我们的目标是计算 S 中帕累托最优点的数量。

以下是计算帕累托最优点数量的一个高效算法:

  1. 将点集 S 按照 X 坐标从大到小排序。
  2. 初始化一个变量 y_max 为负无穷大,计数器 count 为 0。
  3. 按排序后的顺序遍历每个点 (x_i, y_i)
    • 如果 y_i > y_max,则 count 加 1,并将 y_max 更新为 y_i
  4. 遍历结束后,count 的值即为帕累托最优点的数量。

该算法的运行时间为 O(n log n),主要由排序步骤决定。

随机点集的期望数量

现在考虑一个变化:假设每个点都是从单位正方形中独立且均匀随机选取的。那么帕累托最优点的期望数量是多少?

提示:考虑按 X 坐标排序后,第 k 个点(从左到右)是帕累托最优点的概率。

分析
对于按 X 坐标升序排列的点,第 k 个点是帕累托最优的,当且仅当它是前 k 个点中 Y 坐标最大的点。由于 Y 坐标是独立均匀随机生成的,每个点成为前 k 个点中最大值的概率是 1/k

因此,帕累托最优点的期望数量是所有点概率之和:

E[数量] = Σ (k=1 到 n) (1/k) = H_n

其中 H_n 是第 n 个调和数,其值近似为 ln(n)


线性规划与点集分离问题

上一节我们讨论了组合计数问题,本节中我们来看看一个可以建模为线性规划的问题。

问题建模

我们被给定三个点集:R(红色)、G(绿色)、B(蓝色),每个集合包含 n 个点。目标是判断是否存在一对平行线,能将红色点全部置于下方,绿色点置于中间,蓝色点置于上方。

我们需要为此问题写出一个线性规划。

变量定义
设平行线的方程为 y = a*x + b(下线)和 y = a*x + b'(上线),其中 b' > b。变量为 a, b, b'

约束条件
以下是确保点集被正确分离所需的线性约束:

  • 对于每个蓝色点 (x_i, y_i)y_i ≥ a*x_i + by_i ≥ a*x_i + b' (蓝色点在上线及以上)
  • 对于每个红色点 (x_i, y_i)y_i ≤ a*x_i + by_i ≤ a*x_i + b' (红色点在下线及以下)
  • 对于每个绿色点 (x_i, y_i)a*x_i + b ≤ y_i ≤ a*x_i + b' (绿色点位于两线之间)
  • 明确顺序:b' ≥ b

目标函数(第一部分)
第一部分只需判断可行性,因此目标函数可以任意设置,例如 Maximize 0

目标函数(第二部分)
若要求最小化两条平行线之间的垂直距离,则目标函数为 Minimize (b' - b)

该线性规划具有 O(n) 个变量和 O(n) 个约束。


树形结构匹配问题

上一节我们使用了线性规划,本节中我们来看看一个关于树形模式匹配的问题。

问题与暴力解法

给定两棵二叉树:一棵较小的模式树 P 和一棵较大的文本树 T。我们需要判断 T 中是否存在一个根子树(即一个节点及其所有后代)与 P 完全相同(仅结构匹配,节点无数据)。

一个直接的暴力解法是:
对于 T 中的每个节点 v,检查以 v 为根的子树是否与 P 相同。每次检查需要 O(m) 时间(m 为 P 的大小),总时间为 O(m*n)

优化思路:字符串匹配

我们可以将树结构编码成字符串,从而将树匹配问题转化为字符串匹配问题。

编码方法
一种可靠的方法是使用带空节点标记的先序遍历序列。例如,用特定符号(如 #)表示空指针。这样,每棵树都能被唯一地编码为一个字符串。

算法步骤

  1. 使用 DFS 将模式树 P 编码为字符串 str(P)
  2. 使用 DFS 将文本树 T 编码为字符串 str(T)
  3. str(T) 中寻找子串 str(P)。这可以使用 KMP 或滚动哈希等算法在 O(m + n) 时间内完成。

此方法将总运行时间降低到了 O(m + n)


箱子嵌套问题

上一节我们处理了树匹配,本节中我们来看一个关于空间嵌套的优化问题。

问题建模为图论

给定 N 个长方体盒子,每个盒子有长、宽、高(均在 10 到 20 厘米之间,且所有尺寸互异)。一个盒子可以旋转后放入另一个更大的盒子中。目标是尽可能嵌套盒子,使得最后可见(未被嵌套)的盒子数量最少。

我们可以将此问题转化为一个有向无环图(DAG)上的路径覆盖问题。

构建图 G

  • 每个盒子是一个节点。
  • 如果盒子 U 经过旋转后能放入盒子 V 中,则添加一条有向边 U -> V

问题转化
在最终的嵌套方案中,每个嵌套序列形成图 G 中的一条路径。我们希望找到一组顶点不相交的路径,覆盖尽可能多的节点。未被路径覆盖的节点就是可见的盒子。因此,最小可见盒子数 = 总盒子数 - 最大路径覆盖的节点数

通过二分图匹配求解

在 DAG 中,最小路径覆盖可以通过二分图最大匹配来求解。

构建二分图 H

  • 创建两个副本:左集 L 和右集 R,各包含所有盒子节点。
  • 如果盒子 U_i(在左集)能放入盒子 V_j(在右集),则在 L_iR_j 之间添加一条边。

求解
在二分图 H 上计算最大匹配。最大匹配的大小就等于可以被嵌套(即位于某条路径中间)的最大盒子数。
因此,最小可见盒子数 = N - (最大匹配的大小)

可以使用 Hopcroft-Karp 算法在 O(E√V) ≈ O(n^2.5) 时间内求解最大匹配,或者通过最大流算法求解。


网格逃逸问题

上一节我们使用了匹配算法,本节中我们来看一个网络流建模问题。

问题与流网络建模

给定一个 n x n 的网格,其中有 m 个标记为终端的顶点。逃逸问题要求判断是否存在 m 条顶点不相交的路径,将这些终端分别连接到 m 个互不相同的边界顶点上。

这是一个典型的顶点不相交路径问题,可以通过最大流算法解决。

构建流网络

  1. 将网格中的每条无向边替换为两条方向相反的有向边。
  2. 为处理“顶点不相交”的条件,需要对每个顶点施加容量为 1 的限制。标准技巧是:将每个原始顶点 v 拆分为入点 v_in 和出点 v_out,并添加一条容量为 1 的边 v_in -> v_out。所有指向 v 的原始边改为指向 v_in,所有从 v 出发的原始边改为从 v_out 出发。
  3. 添加超级源点 s,并添加从 s 到每个终端顶点入点 v_in 的边,容量为 1。
  4. 添加超级汇点 t,并添加从每个边界顶点出点 w_out 到 t 的边,容量为 1。
  5. 网络中所有其他边的容量设为 1(或无穷大,只要不小于1即可)。

求解与判断
计算从 s 到 t 的最大流。如果最大流的值等于终端数量 m,则说明存在满足要求的 m 条顶点不相交路径,答案为“是”;否则为“否”。

该流网络的顶点和边数量均为 O(n^2),使用 Dinic 等算法可以在 O(V^2.5) 时间内求解。


卡牌游戏期望得分问题

最后,我们来看一个涉及概率的动态规划问题。

问题与状态定义

两个玩家 Elmo 和 Daisy 按顺序从一排卡牌的两端取牌。Elmo 总是取两端中数值较大的牌,Daisy 则随机选择左端或右端牌(各 50% 概率)。给定初始牌序列,假设 Elmo 先手,计算 Elmo 的期望得分。

这是一个可以使用动态规划解决的期望值计算问题。

定义状态
dp[i][j] 表示当牌堆只剩下第 i 张到第 j 张牌(闭区间),且当前轮到 Elmo 取牌时,Elmo 能获得的期望得分。

基础情况

  • 如果 i > jdp[i][j] = 0(无牌)。
  • 如果 i == jdp[i][j] = value[i](只有一张牌,Elmo 取走)。

状态转移
i < j 时,Elmo 会观察两端牌 value[i]value[j]

  • 情况 A:value[i] > value[j]。Elmo 取走左端牌 value[i]
    • 然后 Daisy 取牌:
      • 以 0.5 概率取左端(即 value[i+1]),之后状态变为 dp[i+2][j]
      • 以 0.5 概率取右端(即 value[j]),之后状态变为 dp[i+1][j-1]
    • 因此,此情况下的期望得分为:value[i] + 0.5 * dp[i+2][j] + 0.5 * dp[i+1][j-1]
  • 情况 B:value[i] < value[j]。Elmo 取走右端牌 value[j]
    • 然后 Daisy 取牌:
      • 以 0.5 概率取左端(即 value[i]),之后状态变为 dp[i+1][j-1]
      • 以 0.5 概率取右端(即 value[j-1]),之后状态变为 dp[i][j-2]
    • 因此,此情况下的期望得分为:value[j] + 0.5 * dp[i+1][j-1] + 0.5 * dp[i][j-2]

最终 dp[i][j] 取上述两种情况的对应值。

计算顺序与答案
需要按区间长度从小到大的顺序计算 dp 表。最终答案为 dp[1][n]。算法共有 O(n^2) 个状态,每个状态转移为 O(1),故总时间复杂度为 O(n^2)


总结

本节课中我们一起学习了期末考试的六类样题及其解法:

  1. 帕累托最优点:通过排序和扫描解决计数问题,并分析了随机情况下的期望值。
  2. 线性规划:将几何点集分离问题建模为线性规划,定义了变量、约束和目标函数。
  3. 树形匹配:通过将树编码为字符串,利用字符串匹配算法优化子树查找问题。
  4. 箱子嵌套:将三维嵌套问题转化为 DAG 上的最小路径覆盖问题,并通过二分图最大匹配求解。
  5. 网格逃逸:将顶点不相交路径问题转化为带有顶点容量的网络流问题。
  6. 期望得分 DP:使用动态规划计算在随机对手策略下的期望收益。

希望这些分析和思路能帮助你为期末考试做好充分准备。祝你好运!

posted @ 2026-03-29 09:31  布客飞龙II  阅读(7)  评论(0)    收藏  举报