MIT-6-046J-数据结构与算法设计笔记-全-
MIT 6.046J 数据结构与算法设计笔记(全)
L1:课程介绍与间隔调度





在本节课中,我们将学习麻省理工学院6.046J课程的第一讲内容。我们将从课程的整体介绍开始,然后深入探讨一个具体的算法问题——间隔调度。我们将学习如何设计一个高效的贪婪算法来解决基础版本,分析其正确性,并探讨当问题条件变化时,如何转向动态规划等更复杂的范式,甚至触及NP完全问题的概念。
课程概述与安排 🎯
本课程是麻省理工学院6.046J《数据结构与算法设计》的完整版。课程内容基于知识共享许可提供。
课程由Srinivas Devadas教授、共同讲师Erik Demaine和Nancy Lynch,以及多位助教共同讲授。课程假设学生已掌握6.006课程(算法导论)的基础知识,包括数据结构、动态规划和最短路径算法等。
课程将在Stellar网站运行,提供讲义、习题集等所有资料。课程视频也将为开放课件项目录制并稍后上线。
学生需在Stellar网站注册复习课,并仔细阅读课程信息文件,了解评分政策(习题集占30%,但需达到一定正确率)和协作策略。
课程内容模块 📦
上一节我们介绍了课程的基本信息,本节中我们来看看课程涵盖的主要技术模块。
课程内容分为多个模块:
- 分而治之:深化在6.006中学到的范式,探讨如快速傅立叶变换、求凸包等高级算法。
- 贪婪算法:研究如Dijkstra最短路径算法等贪婪策略,并将其应用于多种问题。
- 网络流:研究如何最大化流经网络的商品数量,有广泛的应用背景。
- 难处理性与近似算法:探讨不存在多项式时间精确解的问题(NP难问题),并学习如何在多项式时间内找到近似最优解。
- 高级主题:包括分布式算法和密码学等。
算法设计的迷人之处在于,问题定义的微小变化可能导致算法复杂性发生巨大改变,从易处理(P)变为难处理(NP完全)。本节课将通过一个具体例子来阐明这一点。
算法复杂性基础回顾 ⚙️
在深入具体问题前,我们先简要回顾与算法复杂性和难易度相关的术语。
- P类问题:指存在多项式时间算法可以解决的问题。例如,最短路径问题可以在O(V²)时间内解决(V为顶点数)。
- NP类问题:指其解可以在多项式时间内被验证的问题。例如,哈密顿回路问题(判断图中是否存在经过每个顶点恰好一次的回路)是NP问题。
- NP完全问题:是NP中最难的一类问题。如果任何一个NP完全问题存在多项式时间算法,那么所有NP问题都存在多项式时间算法。哈密顿回路问题和布尔可满足性问题都是NP完全的。
接下来,我们将通过间隔调度问题,展示问题约束的细微变化如何影响其算法复杂性。
间隔调度问题与贪婪算法 ⏰
我们现在开始研究间隔调度问题。在这个问题中,我们有一个资源和多个请求。每个请求i对应一个时间间隔 [s_i, f_i),其中 s_i 为开始时间,f_i 为结束时间,且 s_i < f_i。两个请求i和j是兼容的,当且仅当它们的时间间隔不重叠,即 f_i <= s_j 或 f_j <= s_i。
我们的目标是选择一个最大的兼容请求子集,即最大化可以满足的请求数量。
贪婪算法模板
贪婪算法通常高效且近视,它每次只处理输入的一小部分,做出当前最优选择,然后简化剩余问题。
以下是解决间隔调度问题的贪婪算法通用模板:
- 使用一个简单规则选择一个请求。
- 拒绝所有与该请求不兼容的请求。
- 在剩余的请求集合上重复上述步骤,直到所有请求被处理。
这个模板尚未具体化,关键在于第1步的选择规则。
选择规则探讨
对于选择规则,有多种直观的启发式策略,但并非所有都正确。
以下是几种可能的选择规则及其有效性:
- 最早开始时间:可能失败。例如,一个最早开始但很长的请求会阻止许多其他短请求。
- 最短间隔:可能失败。例如,一个短间隔可能位于两个长间隔之间,选择它会阻止选择两个兼容的长间隔。
- 最小冲突数(选择与其他请求重叠最少的请求):可能失败。存在反例表明其无法始终得到最优解。
- 最早结束时间:这是正确的规则。选择具有最小
f_i的请求,可以证明总能得到最大规模的兼容请求子集。
正确性证明(最早结束时间规则)
我们使用数学归纳法证明贪婪算法(采用最早结束时间规则)的正确性。
命题:对于任意间隔调度问题实例,最早结束时间贪婪算法总能找到一个最优解(即最大规模的兼容请求集)。
证明:
- 归纳基础:当最优解只包含一个间隔时,选择最早结束的间隔显然构成一个有效且最优的调度。
- 归纳步骤:
- 设最优解为
S* = { [s_{j1}, f_{j1}), ..., [s_{jk*}, f_{jk*}) },共k*个间隔。 - 设贪婪算法选择的第一个间隔为
[s_{i1}, f_{i1}),根据最早结束时间规则,有f_{i1} <= f_{j1}。 - 构造一个新的解
S**:用[s_{i1}, f_{i1})替换S*中的第一个间隔[s_{j1}, f_{j1})。由于f_{i1} <= f_{j1},[s_{i1}, f_{i1})与S*中后续所有间隔仍然兼容(因为后续间隔的开始时间>= f_{j1} >= f_{i1})。因此S**也是一个包含k*个间隔的最优解。 - 考虑在选择了
[s_{i1}, f_{i1})后剩余的请求集L‘,即所有开始时间>= f_{i1}的请求。S**中从第二个开始的间隔构成了L‘的一个解,且其大小为k* - 1。 - 根据归纳假设,对于问题集
L‘,贪婪算法能找到其最优解。而贪婪算法在L‘上运行得到的结果正是{ [s_{i2}, f_{i2}), ..., [s_{ik}, f_{ik}) },设其大小为k-1。 - 因此,
k* - 1 = k - 1,即k* = k。这意味着贪婪算法找到的解{ [s_{i1}, f_{i1}), ..., [s_{ik}, f_{ik}) }也包含了k*个间隔,是最优的。
- 设最优解为
证毕。
问题变体与算法演进 🔄
上一节我们证明了基础间隔调度问题存在高效的贪婪算法。本节中我们来看看当问题条件发生变化时,解决方案如何演变。
加权间隔调度
现在为每个请求 i 引入一个权重 w_i。目标不再是最大化请求数量,而是最大化所选请求的总权重。
最早结束时间贪婪算法不再适用。反例:一个权重很小但结束很早的请求,可能会阻止选择后续两个权重很大但稍晚的兼容请求。
此时,动态规划成为合适的工具。关键在于定义子问题。一种定义方式是:令 R_x 为所有开始时间 >= x 的请求集合。但更精妙的定义是:对于每个请求 i,定义子问题为:在 f_i 时刻之后开始的请求集合上的最优调度。这样我们就有 n 个子问题(n 为请求总数)。
动态规划递归式:
设 OPT(R) 为请求集 R 上的最大权重。则
OPT(R) = max_{i in R} ( w_i + OPT( R_{f_i} ) )
其中 R_{f_i} 表示 R 中所有开始时间 >= f_i 的请求集合(即与选择 i 兼容的后续请求)。
通过记忆化或自底向上填表,该动态规划算法的时间复杂度为 O(n²)。实际上,通过更巧妙的预处理和设计,可以优化到 O(n log n)。
多资源间隔调度与NP完全性
进一步推广问题:假设有多种不同的资源(机器),每个请求 i 只能在一组特定的机器子集 Q_i 上运行。目标同样是最大化总权重或请求数量。
这个推广后的问题被证明是NP完全的。这意味着,除非P=NP,否则不存在解决所有实例的多项式时间精确算法。
面对NP完全问题,通常有两种应对策略:
- 近似算法:设计在多项式时间内运行的算法,其解的质量(如总权重)保证在最优解的一定比例范围内。
- 处理难解实例:虽然最坏情况下是指数时间,但针对许多实际实例,优化后的搜索算法(如回溯、分支定界)可能在可接受时间内找到最优解。
总结 📝
本节课中我们一起学习了以下内容:
- 了解了6.046J课程的整体结构和安排。
- 回顾了P、NP和NP完全等算法复杂性基本概念。
- 深入研究了间隔调度问题:
- 对于基础的最大数量问题,我们设计了基于最早结束时间规则的贪婪算法,并严格证明了其正确性。
- 对于加权版本,贪婪算法失效,我们转向了动态规划解决方案,定义了子问题并给出了递归式。
- 当问题推广到多台不同机器时,问题变为NP完全的,我们讨论了应对NP难问题的基本思路(近似算法、启发式算法)。
- 通过这个例子,我们直观感受到了问题定义的微小变化可能导致算法设计范式和计算复杂性的显著不同,这是算法设计领域的一个核心特点。
L2:分治:中位数查找 🧠





在本节课中,我们将要学习分治算法的核心思想,并通过两个经典问题——凸包和中位数查找——来深入理解如何应用这一范式。我们将看到,分治算法的关键在于如何巧妙地划分问题,以及如何高效地合并子问题的解。
分治算法范式概述
分治是一种强大的算法设计思想。其基本模式是:将一个规模为 n 的问题分解为 a 个规模为 n/b 的子问题。当子问题规模足够小时,可以直接求解。最后,将子问题的解合并,得到原问题的解。
其运行时间通常可以用递归式描述:
T(n) = a * T(n/b) + [合并步骤的代价]
其中 a ≥ 1, b > 1。通过主定理等工具可以求解此类递归式。
上一节我们介绍了分治算法的通用框架,本节中我们来看看如何将其应用于具体的几何和选择问题。
凸包问题 ⛵
凸包问题是指:给定二维平面上的 n 个点,找出能包含所有点的最小凸多边形。这个多边形称为这些点的凸包。
分治算法思路
我们将使用分治法来解决凸包问题。算法的核心步骤如下:
- 划分:将所有点按
x坐标排序,然后将其分为左右两个大小大致相等的点集。 - 征服:递归地计算左、右两个点集的凸包。
- 合并:将两个子凸包合并为整个点集的凸包。这是算法中最关键也最有趣的部分。
合并步骤:双指针算法
合并两个凸包,本质上是找到连接它们的两条“切线”:一条上切线和一条下切线。以下是寻找上切线的“双指针”算法步骤:
- 初始化两个指针。左指针指向左凸包上
x坐标最大的点,右指针指向右凸包上x坐标最小的点。 - 计算当前两点连线的
y轴截距。 - 进入循环,交替移动指针以寻找最大截距:
- 固定左指针,顺时针移动右指针。如果连线截距增加,则继续移动;如果减少,则退回一步。
- 固定右指针,逆时针移动左指针。如果连线截距增加,则继续移动;如果减少,则退回一步。
- 当两个指针都无法再移动以增加截距时,当前两点连线即为上切线。
寻找下切线的过程类似,目标是寻找最小截距。
算法复杂度分析
- 合并复杂度:双指针算法中,每个指针最多遍历其凸包上的所有点一次。因此,合并两个总点数为
n的凸包,时间复杂度为O(n)。 - 总体复杂度:算法满足递归式
T(n) = 2T(n/2) + O(n)。这与归并排序的递归式相同,因此总时间复杂度为O(n log n)。
上一节我们详细探讨了凸包问题的分治解法,特别是其巧妙的合并步骤。本节中我们来看看另一个经典问题——中位数查找,它的挑战性主要体现在划分步骤上。
中位数查找问题 🔍
中位数查找问题是指:在一个包含 n 个不同数字的未排序集合 S 中,找到第 k 小(或特定秩,如中位数)的元素。
一个简单的方法是先排序再选取,时间复杂度为 O(n log n)。我们的目标是利用分治思想,设计一个最坏情况下也能在线性时间 O(n) 内完成的确定性算法。
简单分治框架及其缺陷
一个直观的分治框架如下:
- 选择:从集合
S中选取一个元素x作为枢轴。 - 划分:将
S分为三部分:B(所有小于x的元素)、{x}、C(所有大于x的元素)。设k为x在S中的秩。 - 递归:
- 若
k == i(i是目标秩),则返回x。 - 若
k > i,则在B中递归寻找第i小的元素。 - 若
k < i,则在C中递归寻找第i - k小的元素。
- 若
这个框架的问题在于:如果每次选择的 x 都是极端值(例如最大值或最小值),那么划分会极度不平衡,导致递归树深度为 O(n),而每层划分需要 O(n) 时间,总复杂度退化为 O(n^2)。
关键改进:中位数的中位数
为了保证划分的平衡性,我们需要一个确定性的、聪明的方法来选择枢轴 x。这就是“中位数的中位数”方法。
以下是选择枢轴 x 的步骤:
- 分组:将
n个元素分成⌈n/5⌉组,每组最多 5 个元素。 - 组内排序:对每组内的 5 个元素进行排序(因为 5 是常数,所以每组排序是
O(1),总O(n))。 - 提取中位数:从每组中取出中位数,形成一个“中位数集合”。
- 递归寻找:在这个“中位数集合”上递归调用本算法,找出其中位数。这个中位数就是我们最终选定的枢轴
x。
算法复杂度分析
为什么这个方法能保证平衡划分?通过几何分析可以证明,通过这种方式选出的枢轴 x,能保证至少有约 3n/10 的元素小于它,也至少有约 3n/10 的元素大于它。这意味着,每次递归调用时,我们至少能丢弃 3n/10 个元素,最多只需要在 7n/10 个元素的子集中继续查找。
由此,我们可以得到递归式:
T(n) ≤ T(⌈n/5⌉) + T(⌊7n/10 + 6⌋) + O(n)
其中:
T(⌈n/5⌉)对应递归寻找“中位数的中位数”的代价。T(⌊7n/10 + 6⌋)对应在较大子集中递归查找的代价。O(n)对应分组、排序和划分的线性时间。
可以证明,此递归式的解为 T(n) = O(n)。关键在于 n/5 + 7n/10 = 9n/10 < n,确保了问题规模以几何级数递减。
总结 📚
本节课中我们一起学习了分治算法在两个经典问题上的精妙应用。
- 对于凸包问题,我们看到了分治法的典型结构:简单的划分(按
x坐标),递归求解,以及一个需要智慧的合并步骤(双指针算法寻找切线)。其时间复杂度为O(n log n)。 - 对于中位数查找问题,挑战从合并转移到了划分。我们学习了“中位数的中位数”这一确定性枢轴选择法,它保证了每次划分的平衡性,从而实现了最坏情况下
O(n)的线性时间复杂度。这展示了分治思想中,一个精心设计的划分策略如何能极大地提升算法效率。
通过这两个例子,我们深刻体会到,分治算法的威力不仅在于“分”和“治”,更在于如何“分”得均衡,以及如何“合”得高效。
R1:矩阵乘法与主定理 🧮





在本节课中,我们将学习加权区间调度问题的优化解法、矩阵乘法的斯特拉森算法,以及用于分析递归算法复杂度的强大工具——主定理。
加权区间调度优化 🗓️
上一节我们介绍了加权区间调度的基本递归解法。本节中,我们来看看如何优化它。
基本递归算法将原问题分解为多个子问题,导致大量重复计算。为了提升效率,我们采用动态规划思想:将已解决的子问题结果存储起来,避免重复计算。
优化算法的核心思路是:从最早开始的请求入手。对于最早开始的请求,它只有两种可能:要么被选中(作为解中的第一个请求),要么不被选中。这避免了考虑所有请求作为第一个候选的冗余。
以下是算法的递归关系:
- 如果不选择最早开始的请求
i,则子问题是请求i+1到n。 - 如果选择请求
i,则获得其权重w_i,子问题是所有在i结束后才开始的请求集合R_i。
通过这种方式,递归树的分支因子从 n 降为 2。结合记忆化存储,算法的复杂度可以从 O(n²) 优化至 O(n log n)(排序开销)。
斯特拉森矩阵乘法算法 ✖️
接下来,我们探讨一个经典的分治算法:斯特拉森矩阵乘法。
标准的矩阵乘法(将矩阵分块计算)复杂度为 O(n³)。斯特拉森算法通过巧妙的数学变换,将 8 次子矩阵乘法减少为 7 次,从而降低了复杂度。
算法将 n×n 矩阵 A 和 B 划分为四个 n/2 × n/2 的子块。通过定义 7 个特定的矩阵乘积 M1 到 M7,最终结果矩阵 C 的四个子块可以由这 7 个矩阵的加减运算组合得到。
以下是 C 的一个子块计算示例:
C21 = M2 + M4
其中 M2 和 M4 是预先计算好的中间矩阵。
算法的递归式可以表示为:
T(n) = 7 * T(n/2) + Θ(n²)
这里,7 是子问题数量,n/2 是子问题规模,Θ(n²) 是合并结果(矩阵加减)的代价。
主定理 📐
为了分析像斯特拉森算法这样的递归算法的复杂度,我们引入主定理。
主定理提供了一种直接求解形如 T(n) = a * T(n/b) + f(n) 的递归式复杂度的方法,其中 a ≥ 1, b > 1。
以下是主定理的三种主要情况:
- 情况一:如果
f(n) = O(n^c),且c < log_b(a),则T(n) = Θ(n^{log_b(a)})。 - 情况二:如果
f(n) = Θ(n^{log_b(a)} * log^k n),则T(n) = Θ(n^{log_b(a)} * log^{k+1} n)。 - 情况三:如果
f(n) = Ω(n^c),且c > log_b(a),同时满足正则条件a * f(n/b) ≤ k * f(n)(对于某个k<1),则T(n) = Θ(f(n))。
直观理解:比较递归树中叶子节点总工作量 n^{log_b(a)} 与根节点合并工作量 f(n) 的阶数。谁占主导,总复杂度就趋向于谁。
应用主定理 🔍
现在,我们应用主定理分析之前的算法。
-
标准分块矩阵乘法:递归式为
T(n) = 8 * T(n/2) + Θ(n²)。a=8,b=2,log_b(a)=log_2(8)=3。f(n)=n²,即c=2。c=2 < log_b(a)=3,属于情况一。- 因此,
T(n) = Θ(n^{log_2(8)}) = Θ(n³)。
-
斯特拉森算法:递归式为
T(n) = 7 * T(n/2) + Θ(n²)。a=7,b=2,log_b(a)=log_2(7)≈2.81。f(n)=n²,即c=2。c=2 < log_b(a)≈2.81,属于情况一。- 因此,
T(n) = Θ(n^{log_2(7)}) ≈ Θ(n^{2.81}),优于立方复杂度。
主定理不适用的递归案例 📝
最后,我们看一个主定理不能直接应用的递归式,例如中位数查找算法中的:T(n) = T(n/5) + T(7n/10) + Θ(n)。
对于这类问题,我们需要回归到复杂度的定义,使用代入法进行归纳证明。
- 假设:对于所有小于
n的规模,有T(k) ≤ C * k成立(上界)。 - 推导:
T(n) = T(n/5) + T(7n/10) + Θ(n) ≤ C*(n/5) + C*(7n/10) + D*n (根据假设和Θ定义) = (9C/10 + D) * n - 完成归纳:选择足够大的常数
C,使得(9C/10 + D) ≤ C,即可证明T(n) = O(n)。下界T(n) = Ω(n)的证明类似。
总结 🎯
本节课中我们一起学习了:
- 加权区间调度的优化动态规划解法,通过改变决策顺序(从最早请求开始)和记忆化,将复杂度优化至
O(n log n)。 - 斯特拉森矩阵乘法,一个巧妙的分治算法,通过减少子问题数量(从8个到7个)实现了优于
O(n³)的复杂度。 - 主定理,一个强大的工具,用于快速求解形式规范的递归式的渐近复杂度。
- 当递归式不符合主定理标准形式时,可以使用代入法进行归纳证明来分析复杂度。
这些知识是设计和分析高效算法的重要基础。
L3:分治:快速傅里叶变换 🌀





在本节课中,我们将要学习一种强大的分治算法——快速傅里叶变换。这是一种在数字信号处理、音频压缩等领域广泛使用的算法,其核心目标是在 O(n log n) 时间内高效地完成多项式乘法。
概述:多项式与运算
多项式通常表示为一系列系数。例如,一个次数为 n-1 的多项式 A(x) 可以写作:
A(x) = a_0 + a_1*x + a_2*x^2 + ... + a_{n-1}*x^{n-1}
我们也可以将其视为一个系数向量 [a_0, a_1, ..., a_{n-1}]。
对于多项式,我们通常关心三种运算:
- 求值:给定一个具体的
x值,计算A(x)。 - 加法:计算两个多项式的和
C(x) = A(x) + B(x)。 - 乘法:计算两个多项式的乘积
C(x) = A(x) * B(x)。
求值和加法都可以在 O(n) 时间内完成。然而,朴素的乘法算法(直接应用卷积公式)需要 O(n^2) 的时间。本节课的目标就是利用快速傅里叶变换,将多项式乘法的时间复杂度降至 O(n log n)。
多项式的不同表示法
为了高效地进行乘法,我们需要在不同的多项式表示法之间进行转换。主要有两种表示法:
- 系数表示法:即我们熟悉的
[a_0, a_1, ..., a_{n-1}]形式。 - 点值表示法:通过在一组互不相同的点
{x_0, x_1, ..., x_{n-1}}上对多项式进行求值,得到一组值{y_0, y_1, ..., y_{n-1}},其中y_k = A(x_k)。这n个点值对唯一确定了一个n-1次多项式。
这两种表示法在执行三种运算时各有优劣:
| 运算 | 系数表示法 | 点值表示法 |
|---|---|---|
| 求值 | O(n) |
O(n) (如果点已给定) |
| 加法 | O(n) |
O(n) (对应点值相加) |
| 乘法 | O(n^2) |
O(n) (对应点值相乘) |
从上表可以看出,在点值表示法下,多项式乘法变得异常简单,只需将对应点的值相乘即可。这为我们提供了一个思路:先将多项式从系数表示转换为点值表示,然后在点值表示下进行 O(n) 的乘法,最后再将结果转换回系数表示。
问题的关键就在于这两种表示法之间的转换。朴素的转换(求值或插值)是 O(n^2) 的。接下来,我们将看到如何利用分治策略和复数的特殊性质,在 O(n log n) 时间内完成这一转换。
分治策略与单位根
我们的目标是计算多项式 A(x) 在 n 个特定点 {x_0, x_1, ..., x_{n-1}} 上的值。假设 n 是 2 的幂次(可以通过补零实现)。
分治的核心思想是将多项式按奇偶次项拆分:
A(x) = A_even(x^2) + x * A_odd(x^2)
其中:
A_even(x^2) = a_0 + a_2*x^2 + a_4*x^4 + ...A_odd(x^2) = a_1 + a_3*x^2 + a_5*x^4 + ...
这样,原问题(在 n 个点上求值 A(x))被转化为两个子问题(在 n 个点的平方上求值两个 n/2 次的多项式 A_even 和 A_odd)。
为了使分治有效,我们需要选择的点集 {x_k} 满足一个关键性质:当它们被平方后,得到的点集会缩小到原来的一半。这样,子问题的规模(点的数量)才会减半。
复平面上的单位根恰好满足这一性质。n 次单位根是方程 ω^n = 1 的 n 个复数解,它们均匀分布在复平面的单位圆上。第 k 个 n 次单位根可以表示为:
ω_n^k = e^{i * (2πk / n)} = cos(2πk/n) + i * sin(2πk/n)
其中 i 是虚数单位。
单位根具有以下重要性质:
- 消去引理:
(ω_n^k)^2 = ω_{n/2}^k。这意味着,当我们对n个n次单位根求平方时,会得到n/2个n/2次单位根(每个出现两次)。 - 折半引理:正是由于消去引理,平方后的点集大小减半,这保证了分治递归中问题规模的缩减。
因此,我们选择 x_k = ω_n^k 作为求值点。基于此的分治算法就是快速傅里叶变换。
快速傅里叶变换算法
FFT 是一个递归算法,用于计算多项式在 n 个 n 次单位根上的值(即从系数表示到点值表示的转换)。
以下是算法的伪代码描述:
function FFT(a):
// 输入:系数向量 a = [a_0, a_1, ..., a_{n-1}],n 是 2 的幂
// 输出:点值向量 y = [A(ω_n^0), A(ω_n^1), ..., A(ω_n^{n-1})]
if n == 1:
return a // 多项式是常数,点值就是它本身
// 1. 分:按奇偶拆分系数
a_even = [a_0, a_2, a_4, ...]
a_odd = [a_1, a_3, a_5, ...]
// 2. 治:递归计算子问题
y_even = FFT(a_even) // 计算 A_even 在 ω_{n/2}^0, ω_{n/2}^1, ... 上的值
y_odd = FFT(a_odd) // 计算 A_odd 在 ω_{n/2}^0, ω_{n/2}^1, ... 上的值
// 3. 合:合并结果
for k from 0 to n/2 - 1:
ω = ω_n^k
y[k] = y_even[k] + ω * y_odd[k]
y[k + n/2] = y_even[k] - ω * y_odd[k] // 利用 ω_n^{k+n/2} = -ω_n^k 的性质
return y
该算法的时间复杂度满足递归式 T(n) = 2T(n/2) + O(n),由主定理可得 T(n) = O(n log n)。
快速傅里叶逆变换
上一节我们介绍了如何从系数表示快速转换到点值表示(FFT)。为了完成多项式乘法,我们还需要能将点值表示转换回系数表示,这个逆过程称为快速傅里叶逆变换。
令人惊喜的是,IFFT 的算法结构与 FFT 几乎完全相同。唯一的区别在于:
- 将 FFT 中使用的单位根
ω_n^k替换为其共轭复数ω_n^{-k}。 - 将最终得到的每个结果除以
n。
数学上可以证明,点值向量 y 与系数向量 a 之间的关系由以下矩阵方程描述:
y = V * a
其中 V 是一个范德蒙德矩阵,V[j][k] = (ω_n^k)^j。而逆变换的矩阵恰好是 V 的共轭转置除以 n:
a = (1/n) * conjugate(V) * y
因此,运行 FFT 算法,但将单位根替换为其共轭,最后将结果除以 n,就得到了 IFFT。
完整的快速多项式乘法算法
现在,我们可以将整个过程串联起来,实现 O(n log n) 的多项式乘法。
以下是完整的步骤:
- 加倍次数与补零:给定两个
n-1次多项式A(x)和B(x),其乘积C(x)的次数最多为2n-2。我们取N为大于2n-1的最小的 2 的幂,并将A和B的系数向量补零到长度N。 - 计算 FFT:分别对
A和B的系数向量应用 FFT,得到它们在N个N次单位根上的点值表示A*和B*。 - 点值相乘:计算
C*[k] = A*[k] * B*[k],对于k = 0, 1, ..., N-1。这步是O(N)的。 - 计算 IFFT:对点值向量
C*应用 IFFT,得到乘积多项式C(x)的系数向量。
整个算法的时间复杂度为 O(N log N),由于 N = O(n),所以也就是 O(n log n)。
应用与总结
本节课我们一起学习了快速傅里叶变换的原理与算法。FFT 的核心价值在于它极大地加速了卷积运算(多项式乘法的推广),这使其在众多领域成为基石算法:
- 数字信号处理:音频滤波(如高通、低通滤波器)、压缩(如 MP3)、降噪。
- 图像处理:图像模糊、锐化、特征提取等卷积操作。
- 快速大数乘法:可以将大整数视为以基数为变量的多项式,用 FFT 加速乘法。
- 求解偏微分方程:在谱方法中用于转换物理空间和频域空间。
FFT 巧妙地将一个 O(n^2) 的问题降至 O(n log n),其成功的关键在于:
- 利用了点值表示法下乘法简单的特性。
- 设计了基于分治策略的递归算法。
- 选择了具有折半性质的单位根作为求值点,保证了递归的有效性。
- 发现了正变换与逆变换在算法上的高度对称性。
理解 FFT 不仅掌握了一个高效算法,更领略了通过改变问题表示和利用数学结构来设计算法的美妙之处。
R2:2-3树与B树 🌳

在本节课中,我们将要学习2-3树与B树。这两种数据结构是二叉搜索树的重要扩展,特别适用于需要高效利用内存层次结构(如缓存与磁盘)的系统。我们将从B树的基本结构开始,逐步探讨其搜索、插入和删除操作的核心原理。

概述
B树是一种自平衡的树数据结构,它允许每个节点拥有多于两个子节点。与二叉搜索树相比,B树通过增加每个节点的分支数量来降低树的高度,从而减少在访问外部存储(如磁盘)时所需的I/O操作次数。本节将详细介绍B树的定义、性质以及核心操作。
B树的基本结构与性质
上一节我们介绍了平衡二叉搜索树,本节中我们来看看B树是如何定义的。

一棵B树具有以下关键性质:
- 它是一棵完全平衡的树,所有叶子节点都位于同一深度。
- 每个节点可以包含多个键和多个子节点指针。
- 存在一个称为分支因子的整数
B,它定义了节点子节点数量的上下界(根节点除外)。

以下是关于节点键值与子节点数量的具体规则:
- 设一个节点中包含的键的数量为
n。 - 那么该节点拥有的子节点数量为
n + 1。 - 对于非根节点,其键的数量
n满足:B - 1 ≤ n ≤ 2B - 1。 - 对于根节点,其键的数量
n满足:1 ≤ n ≤ 2B - 1(它可以只有一个键)。

这些规则确保了树的平衡与紧凑。例如,在一棵 2-3树(即 B=2 的B树)中,每个非根节点可以有2个或3个子节点(即包含1个或2个键)。

为何使用B树?💾
在了解了B树的结构后,你可能会问,为什么要在二叉搜索树之外使用B树?原因与计算机的内存层次结构有关。
在简单的计算模型中,我们通常假设所有内存访问速度相同。但实际上,计算机拥有多级存储:CPU寄存器、高速缓存、主内存(RAM)和磁盘。越靠近CPU的存储速度越快,但容量越小;磁盘速度慢,但容量大。
当数据量大到无法全部放入内存时,就必须存储在磁盘上。磁盘访问以“块”为单位,每次读取一个数据块(包含B个字节)到缓存中。如果我们使用二叉搜索树,每次访问一个节点都可能引发一次磁盘I/O,效率低下。
B树的设计巧妙之处在于,我们可以将节点的大小设置为约等于一个磁盘块的大小。这样,每次从磁盘读取一个节点,就能获取到多个键和子节点指针,从而在树中下降一层。树的高度从 O(log₂ N) 降低到 O(log_B N),由于 B 远大于2,因此显著减少了访问磁盘的次数。
B树的操作
理解了B树存在的意义后,我们接下来看看如何在B树上执行基本操作。
搜索操作 🔍
在B树中搜索一个键 K 的过程与二叉搜索树类似,但需要在每个节点内部进行顺序比较。
搜索算法步骤如下:
- 从根节点开始。
- 在当前节点中,将键
K与节点内已排序的键进行比较。 - 如果找到相等的键,则搜索成功。
- 否则,找到第一个大于
K的键,并进入其左侧的子节点;如果K大于所有键,则进入最右侧的子节点。 - 重复步骤2-4,直到到达叶子节点。如果在叶子节点仍未找到,则搜索失败。
由于树是平衡的,搜索的时间复杂度为 O(log_B N)。
插入操作 ➕
插入操作比搜索复杂,因为可能破坏B树的节点容量规则。核心思想是:先找到应插入的叶子节点,插入后如果节点“溢出”(键数超过 2B-1),则进行分裂。
分裂操作的步骤如下:
- 将溢出的节点(包含
2B-1个键)其中间的键(第B个键)提升到父节点。 - 以该中间键为界,将原节点分裂成两个新节点,分别包含
B-1个键。 - 将这两个新节点作为父节点中提升键的左右子节点。
- 提升操作可能导致父节点溢出,此时需要递归地对父节点进行分裂。
- 如果分裂一直传递到根节点,并使根节点溢出,则提升中间键创建一个新的根节点,树的高度增加1。
以下是一个插入后触发分裂的简要示例:
- 假设
B=3,一个叶子节点已满(有5个键)。现在要插入一个新键使其溢出。 - 将该节点的中间键(第3个键)提升至父节点。
- 原节点分裂为两个节点,各包含2个键。
删除操作 ➖
删除操作最为复杂,因为可能造成节点“下溢”(键数少于 B-1)。核心策略是:首先确保被删除的键在叶子节点上,然后处理可能的下溢。
删除算法的关键步骤如下:
步骤一:将删除移至叶子节点
如果要删除的键 K 不在叶子节点,则:
- 找到
K的左子树中的最大键(或右子树中的最小键),这个键必然在某个叶子节点上。 - 用这个叶子键替换
K。 - 问题转化为从叶子节点中删除那个被替换上来的键。
步骤二:从叶子节点删除并处理下溢
从叶子节点删除键后,如果该节点键数仍满足 ≥ B-1,则操作完成。否则,需要修复下溢,主要有两种策略:
以下是两种修复策略:
- 旋转:如果某个相邻兄弟节点有额外的键(键数
> B-1),则可以从父节点借一个键,并从兄弟节点移一个键上来。这类似于二叉搜索树中的旋转操作。 - 合并:如果左右兄弟节点都没有多余的键(键数刚好等于
B-1),则将该节点、一个兄弟节点以及父节点中分隔它们的键合并成一个新节点。这个操作会使父节点减少一个键,可能导致父节点发生下溢,因此可能需要递归向上修复。
总结
本节课中我们一起学习了2-3树与B树。我们从B树为适应内存层次结构而设计的背景出发,详细讲解了其多分支节点的结构定义和性质。随后,我们深入探讨了B树的三大核心操作:搜索、插入和删除。重点是插入时的节点分裂和删除时为维持平衡而进行的旋转与合并操作。掌握B树有助于理解数据库、文件系统等如何高效管理大规模数据。
L4:分治:vEB树 🌳





在本节课中,我们将要学习一种非常高效的数据结构——van Emde Boas树(简称vEB树)。这种数据结构能够以惊人的速度处理整数集合上的操作,其核心思想是巧妙的分治策略。我们将从基础概念开始,逐步构建出完整的vEB树,并理解其如何实现超快的查询速度。
概述
vEB树用于解决前驱/后继查询问题。我们存储一个来自大小为 u 的宇宙(即整数范围 [0, u-1])的整数集合 S(包含 n 个元素)。它需要高效支持以下操作:
- 插入(Insert):向集合
S中添加一个整数x。 - 删除(Delete):从集合
S中移除一个整数x。 - 后继(Successor):给定一个值
x,返回集合S中大于x的最小值。
使用平衡二叉搜索树(如AVL树),我们可以在 O(log n) 时间内完成这些操作。而vEB树的目标是将其加速到 O(log log u) 时间。在许多实际应用中(如网络路由表,u 可能是 2^32),log log u 是一个非常小的常数(例如5),这比 log n 快得多。
设计思路演进
上一节我们介绍了问题的定义和目标。本节中,我们来看看如何通过一系列改进,从简单的想法出发,最终构建出vEB树。
起点:位向量法
首先,考虑一个最直接的数据结构:位向量。
- 我们创建一个大小为
u的布尔数组A。 - 如果整数
i在集合中,则A[i] = 1,否则A[i] = 0。
操作分析:
- 插入(x):
A[x] = 1。时间复杂度:O(1)。 - 删除(x):
A[x] = 0。时间复杂度:O(1)。 - 后继(x):需要从位置
x+1开始向后扫描,直到找到下一个1。最坏情况时间复杂度:O(u)。
虽然插入和删除很快,但后继查询太慢。我们需要改进。
改进一:引入簇与摘要
为了加速后继查询,我们将宇宙(大小为 u)划分为多个簇。
- 设每个簇的大小为
cluster_size = √u(即u的平方根)。 - 那么簇的数量也为
num_clusters = √u。
我们维护两个结构:
- 簇数组:一个长度为
√u的数组,每个元素本身是一个位向量,用于管理该簇内的元素。 - 摘要向量:一个长度为
√u的位向量。摘要的第i位为1,当且仅当第i个簇为非空(即包含至少一个元素)。
后继查询算法:
- 首先在
x所属的簇内(簇号high(x) = floor(x / √u))查找后继。 - 如果在该簇内找到了,则返回结果。
- 如果没找到,则在摘要向量中查找,找到下一个为
1的位(即下一个非空簇)i‘。 - 最后,在簇
i‘中查找最小的元素(即第一个为1的位)。
操作分析:
- 后继查询需要在簇内和摘要向量中各做一次扫描,每次扫描成本为
O(√u)。 - 因此,后继查询的最坏时间复杂度为
O(√u)。 - 插入和删除仍为
O(1)(需要更新对应的簇和摘要位)。
这比纯位向量好,但还不够快。O(√u) 仍然远大于我们的目标 O(log log u)。
改进二:递归结构
关键洞察来了:我们不应该用简单的位向量来表示簇和摘要,而应该递归地使用相同的数据结构!
- 每个簇管理一个大小为
√u的子宇宙。 - 摘要向量管理
√u个簇的“非空”信息,这本身也是一个大小为√u的集合。
因此,我们定义vEB树结构 vEB(u) 如下:
vEB.min:存储该树中的最小元素(一个特殊的优化字段)。vEB.max:存储该树中的最大元素。vEB.cluster:一个大小为√u的数组,其中每个元素vEB.cluster[i]都是一个vEB(√u)结构,管理簇i中的元素。vEB.summary:一个vEB(√u)结构,用于记录哪些簇是非空的。
这里,high(x) 和 low(x) 函数用于在全局索引和簇内索引间转换:
high(x) = floor(x / √u)(簇号)low(x) = x % √u(簇内偏移)index(i, j) = i * √u + j(根据簇号和偏移重建全局索引)
现在,后继查询的递归算法如下:
function Successor(vEB V, int x):
// 情况1:x 小于当前树的最小值,后继就是最小值
if x < V.min:
return V.min
// 在x所在的簇i中查找后继
i = high(x)
j = Successor(V.cluster[i], low(x))
if j != NIL: // 在簇i内找到了后继
return index(i, j)
else: // 在簇i内没找到,需要找下一个非空簇
i_succ = Successor(V.summary, i)
if i_succ == NIL: // 没有后续的非空簇了
return NIL
else:
j_min = V.cluster[i_succ].min // 下一个非空簇的最小元素
return index(i_succ, j_min)
时间复杂度分析:这个算法进行了两次递归调用(一次在簇内,一次在摘要中)。这导致了递归式:T(u) = 2 * T(√u) + O(1)。根据主定理,这解出 T(u) = O(log u),仍然不是我们想要的 O(log log u)。
改进三:最大值优化
我们可以通过存储最大值 V.max 来避免一次递归调用。关键在于:当我们查询 x 在簇 i 中的后继时,如果 low(x) 已经大于等于该簇的最大值 V.cluster[i].max,那么我们肯定无法在该簇内找到后继,可以直接跳到摘要中查找下一个非空簇。否则,我们才需要在簇内查找。
优化后的算法:
function Successor(vEB V, int x):
if x < V.min:
return V.min
i = high(x)
// 关键判断:如果x的低位部分小于该簇的最大值,则后继一定在本簇内
if low(x) < V.cluster[i].max:
j = Successor(V.cluster[i], low(x))
return index(i, j)
else: // 否则,后继在下一个非空簇中
i_succ = Successor(V.summary, i)
if i_succ == NIL:
return NIL
else:
j_min = V.cluster[i_succ].min
return index(i_succ, j_min)
现在,我们每次只执行两个递归分支中的一个。递归式变为:T(u) = T(√u) + O(1)。这个递归式的解正是 T(u) = O(log log u)!我们终于达到了目标。
改进四:最小值的特殊处理与高效插入
为了也让插入操作达到 O(log log u),我们需要对最小值进行特殊处理。
V.min元素不递归存储在V.cluster中。它被单独保存在V.min字段里。- 当一个结构为空时,插入一个元素
x只需简单地设置V.min = V.max = x,成本为O(1)。 - 当插入的元素
x比当前V.min还小时,我们将原来的V.min与x交换,然后将原来的V.min递归插入到适当的簇中。 - 只有在向一个空簇插入第一个元素时,才需要递归地更新摘要
V.summary。而根据上一条,向空结构(空簇)插入第一个元素是O(1)的。
这种“惰性”处理最小值的策略,确保了插入操作在最坏情况下也只有一个“真正”的递归调用(要么插入簇,要么更新摘要),从而实现了 O(log log u) 的时间复杂度。
删除操作也遵循类似但稍复杂的对称逻辑,需要处理删除最小值、最大值等特殊情况,以维持结构的不变性,其时间复杂度同样为 O(log log u)。
总结
本节课中我们一起学习了van Emde Boas树的设计与演进。我们从最简单的位向量开始,通过引入簇和摘要的概念进行分治,然后递归地应用这一结构。通过存储最大值 (max) 来优化后继查询,避免不必要的递归分支。最后,通过对最小值 (min) 进行特殊且“惰性”的处理,实现了所有核心操作(插入、删除、后继)在 O(log log u) 时间内完成。vEB树是分治思想在数据结构设计中一个非常经典和优美的应用,它虽然理论复杂,但在诸如网络路由表等需要极快查询速度的实际场景中有着重要应用。
L5:平摊分析 🧮





在本节课中,我们将要学习一种重要的算法分析技术——平摊分析。平摊分析不关注单个操作的最坏情况成本,而是关注一系列操作的总成本,从而得出每个操作的平均成本。这种方法对于分析许多数据结构(如动态表、平衡树)的性能非常有用。我们将介绍四种主要的平摊分析方法,并通过多个例子来理解它们。
概述
平摊分析的核心思想是,某些操作可能偶尔非常耗时,但通过分析整个操作序列,可以证明每个操作的平均成本很低。这与金融中的“摊销”概念类似。我们主要关心算法的总运行时间,而非每个操作的单独时间。接下来,我们将通过几个经典例子来探讨不同的平摊分析方法。
聚合分析法
聚合分析法是最直观的平摊分析方法。它计算一个操作序列的总成本,然后除以操作次数,得到每个操作的平均(平摊)成本。
上一节我们介绍了平摊分析的基本概念,本节中我们来看看第一种具体方法——聚合分析法。
一个经典的例子是动态表的“表加倍”策略。在哈希表中,当元素数量n增长到与表大小m相等时,我们将表的大小加倍。单次加倍操作需要Θ(m)时间,这看起来代价很高。
然而,如果我们从空表开始,分析n次插入的总成本,情况就不同了。每次加倍的成本形成一个几何级数:1 + 2 + 4 + ... + 2^(log n) ≈ 2n。因此,n次插入的总成本为Θ(n),每次插入的平摊成本为常数。
以下是聚合分析法的关键步骤:
- 计算整个操作序列的总实际成本。
- 将总成本除以操作次数,得到平摊成本。
聚合分析法在操作序列明确且总和易于计算时非常有效。
平摊分析的一般定义
当操作类型混合时,我们需要一个更灵活的定义。我们可以为每种操作分配一个“平摊成本”,使得对于任何操作序列,分配的平摊成本总和总是大于或等于实际成本总和。
上一节我们通过聚合法分析了简单的序列,本节中我们来看看更通用的平摊成本定义。
形式化地说,对于一系列操作,如果实际成本总和为∑ actual_cost,我们分配的平摊成本总和为∑ amortized_cost,则需要满足:
∑ actual_cost ≤ ∑ amortized_cost
如果我们能证明每个操作的平摊成本是常数,那么总实际成本也就是线性的。在像Dijkstra这样的算法中,我们只关心总成本,因此平摊分析非常适用。
以2-3树为例,我们考虑三种操作:创建(常数时间)、插入(O(log n)时间)、删除(O(log n)时间)。由于不能删除未插入的元素,删除次数d总小于等于插入次数i。因此,总成本c + i*log n + d*log n ≤ c + 2i*log n。我们可以认为删除的平摊成本为0,而插入的平摊成本为2 log n,这仍然满足上述不等式。
会计分析法
会计分析法(或银行家算法)引入“银行账户”和“信用”的概念。我们为操作支付比其实际成本更多的“平摊费用”,并将多余的部分作为信用存入银行。当后续执行昂贵操作时,可以从银行提取信用来支付成本。
上一节我们定义了平摊成本,本节中我们通过会计分析法来动态管理这些成本。
关键规则是银行余额必须始终非负。这保证了存入的信用足以支付未来的昂贵操作,从而确保平摊成本总和是实际成本总和的上限。
再次以“表加倍”为例。每次执行普通插入时,我们除了支付常数时间成本外,还额外支付一个常数信用,并将该信用存储在插入的元素上。当数组已满需要加倍时,加倍操作的实际成本为Θ(m)。此时,数组中约有一半的元素(即上次加倍后插入的元素)存储有信用。我们可以使用这些信用来“支付”加倍操作的成本。只要每个信用代表的常数足够大,就能覆盖加倍的成本,使得加倍操作的平摊成本为0,而插入操作的平摊成本仍为常数。
核算法
核算法是会计分析法的一种变体,它允许将当前操作的(部分)成本“追溯”到过去的某个或某些操作上。这相当于让过去的操作为其导致的未来开销提前“买单”。
上一节我们将信用存入未来,本节我们换个视角,将开销归因于过去。
在“表加倍”的例子中,我们可以这样应用核算法:每次执行加倍操作时,将其Θ(m)的成本分摊到自上次加倍以来发生的所有插入操作上。因为每次加倍前,恰好有约m/2次插入发生了,所以对每次插入只收取常数费用。由于每次插入只会被收费一次(被它之后第一次加倍收费),因此每次插入的平摊成本仍是常数。
对于支持扩容和缩容的动态表(例如,当表100%满时加倍,当表25%满时减半),核算法也能清晰分析。我们保证在调整大小(加倍或减半)后,表是50%满的。要达到需要减半的状态,必须发生至少m/4次删除;要达到需要加倍的状态,必须发生至少m/2次插入。我们可以将每次调整大小的成本摊派到触发这次调整的、自上次调整以来发生的那些插入或删除操作上。每个操作同样只被收费常数次,因此插入和删除的平摊成本都是常数。
势能法
势能法是最形式化、最强大的平摊分析方法。它定义一个与整个数据结构状态相关的“势能函数”Φ(D)。每次操作的平摊成本定义为:
amortized_cost = actual_cost + ΔΦ = actual_cost + (Φ(D_after) - Φ(D_before))
我们要求势能函数始终非负,且初始状态(通常为空)的势能为0。这样,所有操作的总平摊成本∑ amortized_cost就等于总实际成本∑ actual_cost加上最终势能Φ(D_final),由于Φ(D_final) ≥ 0,因此总平摊成本是总实际成本的上限。
上一节的方法依赖于具体的收费策略,本节介绍的势能法则通过一个函数来统一刻画数据结构的“混乱”程度。
例子1:二进制计数器递增
考虑一个k位二进制计数器从0开始递增。一次递增的实际成本是翻转的位数(例如,从0111到1000翻转了4位)。最坏情况下单次成本为O(k)。
我们定义势能函数Φ为计数器中位值为1的个数。设一次递增操作翻转了t个尾部的1位为0,并将一个0翻转为1。那么实际成本为t+1。势能变化ΔΦ为:减少t个1,增加1个1,即ΔΦ = -t + 1。
因此,平摊成本为:amortized_cost = (t+1) + (-t+1) = 2。
所以,每次递增操作的平摊成本是常数O(1)。
例子2:2-3树的插入操作
在2-3树中插入一个键可能导致节点分裂,并向上传播。一次插入可能引发多达O(log n)次分裂。
我们定义势能函数Φ为树中3-节点(拥有两个键、三个子节点的节点)的数量。
插入过程中,每次分裂都会将一个3-节点(在接收一个键后变成临时的4-节点)转换成两个2-节点,并将一个键提升到父节点。这会使势能Φ减少1(一个3-节点消失)。分裂传播停止时,可能会在树顶创建一个新的3-节点,使势能增加1。
假设一次插入引发了s次分裂。那么实际成本为O(s)(忽略查找成本)。势能变化ΔΦ最多为-s + 1。
因此,平摊成本amortized_cost = O(s) + (-s + 1) = O(1)。这表明2-3树插入的分裂次数平摊下来是常数。
例子3:2-3-4-5树(B树)的插入与删除
为了同时高效处理插入和删除,我们使用节点容量更大的B树,例如2-5树(每个节点允许2到5个子节点)。
我们定义势能函数Φ为树中2-节点和5-节点的数量之和。这两类节点是“临界”节点,容易引发合并或分裂。
- 插入:可能导致5-节点分裂。每次分裂消耗一个5-节点(势能-1),产生两个非5-节点,并在父节点可能新增一个5-节点(势能+1)。平摊分析类似2-3树。
- 删除:可能导致2-节点合并。每次合并消耗一个2-节点(势能-1),并在父节点可能新增一个2-节点(势能+1)。
关键在于,在2-5树中,分裂和合并操作本身不会直接产生新的临界节点(2-节点或5-节点),从而可以将操作成本抵消掉势能的变化。最终可以证明,每次插入或删除的平摊成本(仅指结构调整,如分裂/合并的次数)为常数O(1)。这使得2-5树在需要频繁修改的场景中比2-3树更具优势。
总结
本节课中我们一起学习了平摊分析这一重要的算法分析技术。我们首先了解了其核心思想:通过分析操作序列的总成本来得到更优的平均性能上界。接着,我们系统地学习了四种分析方法:
- 聚合分析法:直接计算序列总成本并求平均,适用于简单序列。
- 会计分析法:通过“信用”机制,让廉价操作预存信用以支付未来的昂贵操作。
- 核算法:将昂贵操作的成本追溯分摊到之前引发该成本的操作上。
- 势能法:定义数据结构的势能函数,将操作的成本与势能变化联系起来,是最通用和强大的方法。
我们通过动态表扩容、二进制计数器、2-3树和2-5树等经典例子,深入理解了这些方法的应用。平摊分析揭示了即使单次操作可能很慢,但长期来看平均性能依然可以很高,这对于设计和分析高效数据结构至关重要。
L6:矩阵乘法、快速排序 🧮⚡





在本节课中,我们将要学习随机算法。我们将通过两个经典例子——矩阵乘法验证和快速排序——来理解随机算法的核心思想、分类以及如何分析其性能。我们将看到,通过引入随机性,我们可以在保证正确性或效率的前提下,获得比确定性算法更优的解决方案。
随机算法简介 🎲
随机算法是一种在执行过程中会生成随机数,并根据这些随机数做出决策的算法。这意味着,即使在相同的输入上多次运行,算法的执行路径和运行时间也可能不同。我们的目标是分析这类算法的期望运行时间,或者其输出正确的概率。
随机算法主要分为两类:
- 蒙特卡洛算法:算法可能很快(在期望多项式时间内运行),但可能不正确(以一定概率产生错误输出)。
- 拉斯维加斯算法:算法总是正确,但可能很快(在期望多项式时间内运行)。
此外,还存在大西洋城算法,它既可能不正确,也可能不快,但在实践中,我们通常希望将算法转化为蒙特卡洛或拉斯维加斯类型。
蒙特卡洛算法示例:矩阵乘法验证 ✅❌
上一节我们介绍了随机算法的基本概念,本节中我们来看看一个具体的蒙特卡洛算法例子:快速验证两个大矩阵的乘积结果是否正确。
问题与目标 🎯
假设我们有三个 n x n 矩阵 A, B, 和 C。我们想验证 C 是否确实等于 A 和 B 的乘积(即 C = A × B)。直接计算 A × B 需要 O(n³) 次乘法,我们希望找到一个更快的验证方法。
我们的目标是设计一个算法,其运行时间为 O(n²),并且满足:
- 如果 C = A × B,算法一定输出“是”(无假阴性)。
- 如果 C ≠ A × B,算法以至少
1/2的概率输出“否”。我们可以通过独立重复运行算法k次,将错误概率(假阳性)降低到1/2ᵏ。
Freivalds 算法 🧠
以下就是我们要分析的 Freivalds 算法。
算法步骤:
- 随机生成一个
n维二进制列向量 r,其中每个元素r[i]独立地以1/2的概率取0或1。 - 计算 P = A × (B × r) 和 Q = C × r。
- 如果 P = Q,则输出“是”,否则输出“否”。
时间复杂度分析:
算法需要进行三次矩阵-向量乘法:
- B × r:
O(n²) - A × (B × r):
O(n²) - C × r:
O(n²)
因此,单次运行的总时间复杂度为O(n²)。
正确性分析(无假阴性):
如果 C = A × B,那么根据矩阵乘法的结合律,有:
A × (B × r) = (A × B) × r = C × r
因此,算法必然输出“是”。
错误概率分析(假阳性)📉
困难的部分是分析当 C ≠ A × B 时,算法错误输出“是”的概率。我们定义差值矩阵 D = A × B - C。那么 C ≠ A × B 等价于 D ≠ 0(即 D 中至少有一个非零元素)。
算法输出“是”的条件是 A × (B × r) = C × r,这等价于 D × r = 0。所以,我们需要证明:当 D ≠ 0 时,Pr[D × r = 0] ≤ 1/2。
证明思路(计数论证):
- 假设 D ≠ 0,则存在至少一个非零元素
D[i][j] ≠ 0。 - 考虑所有可能的二进制向量 r。我们将那些导致
D × r = 0的 r 称为“坏”向量。 - 对于任意一个“坏”向量 r,我们构造一个与之对应的“好”向量 r' = r + v,其中 v 是一个只有第
j个元素为1,其余为0的向量(即“独热”向量)。 - 可以证明,
D × r' ≠ 0,因此 r‘ 是一个“好”向量。并且,从 r 到 r’ 的映射是一对一的。 - 由于每个“坏”向量都唯一对应一个“好”向量,所以“好”向量的数量至少和“坏”向量一样多。在所有
2ⁿ个可能的 r 中,“好”向量(能检测出错误)的比例至少为1/2。
因此,当 C ≠ A × B 时,算法单次运行发现错误的概率 ≥ 1/2。重复运行 k 次,错误概率降至 ≤ 1/2ᵏ,而总运行时间仅为 O(kn²)。
拉斯维加斯算法示例:随机快速排序 ⚡📊
接下来,我们转向一个总是正确但运行时间不确定的算法——拉斯维加斯算法的例子:随机快速排序。
快速排序回顾与动机 🔄
快速排序是一种基于“分治”策略的原址排序算法。其核心步骤是:
- 分区:从数组中选择一个“枢轴”元素,将数组重新排列,使得所有小于枢轴的元素在其左侧,大于枢轴的元素在其右侧。
- 递归:对左右两个子数组递归地进行快速排序。
- 合并:由于枢轴已在正确位置,递归完成后数组即有序。
快速排序的性能高度依赖于枢轴的选择。最坏情况下(例如数组已有序且总选第一个元素为枢轴),递归树会极度不平衡,导致时间复杂度为 O(n²)。
随机快速排序 🎯
为了获得更好的期望性能,我们引入随机性:在每次递归调用中,随机地从当前子数组中选取一个元素作为枢轴。这被称为随机快速排序。可以证明,其期望运行时间为 O(n log n)。
为了更直观地分析期望性能,我们考察一个稍作修改的版本——偏执快速排序。
偏执快速排序分析 🤔
偏执快速排序的步骤如下:
偏执快速排序(A):
重复:
随机选择枢轴 p
根据 p 对数组 A 进行分区,得到左子数组 L 和右子数组 G
直到 (|L| ≤ 3|A|/4 且 |G| ≤ 3|A|/4) // 确保分区不太失衡
递归排序 L
递归排序 G
算法逻辑: 它不断随机选择枢轴并进行分区,直到获得一个“不太坏”的分区(即两个子数组的大小都不超过原数组的 3/4)。这保证了递归树深度为 O(log n)。
期望运行时间分析:
令 T(n) 为排序 n 个元素的期望时间。一次分区操作耗时 O(n),记为 c·n。
- 在随机选择的枢轴下,子数组大小超过
3n/4的概率是多少?由于枢轴值均匀随机,它落在排序后数组中间1/2位置的概率是1/2。这意味着,获得一个“好”分区(|L|和|G|都≤ 3n/4)的概率≥ 1/2。 - 因此,获得一个好分区所需的期望尝试次数为
1 / (1/2) = 2次。每次尝试需要c·n时间。 - 一旦获得好分区,我们需要递归排序两个子数组,它们的大小最多为
3n/4。
由此,我们可以写出期望时间的递归式:
T(n) ≤ T(3n/4) + T(n/4) + 2c·n
递归树分析:
我们可以通过递归树来求解这个递推式。
- 根节点的工作量是
2c·n。 - 下一层,两个子问题的大小分别为
3n/4和n/4,它们的工作量之和小于2c·(3n/4 + n/4) = 2c·n。 - 实际上,每一层所有节点的工作量之和都
≤ 2c·n。 - 递归的深度是多少?由于每次递归数组大小至少缩减为原来的
3/4,所以深度为log_{4/3} n = O(log n)。
因此,总期望工作量 T(n) = O(n) * O(log n) = O(n log n)。
这个分析展示了随机性如何帮助我们以高概率获得平衡的分区,从而在期望上达到高效排序。标准的随机快速排序分析思路类似,但需要更精细的概率计算。
总结 📝
本节课中我们一起学习了随机算法的魅力。
- 我们首先了解了蒙特卡洛算法(可能快,可能正确)和拉斯维加斯算法(总是正确,可能快)的区别。
- 通过 Freivalds 算法,我们看到了如何利用随机性在
O(n²)时间内以高概率验证矩阵乘法的正确性,这比直接计算的O(n³)要快得多。 - 通过随机快速排序及其变体偏执快速排序的分析,我们理解了随机性如何帮助避免最坏情况,使得快速排序的期望运行时间达到理想的
O(n log n),同时它又是原址排序算法,在实践中非常高效。
随机化是算法设计中一个强大而优美的工具,它允许我们在时间复杂度和正确性之间进行灵活的权衡, often leading to simpler and more efficient algorithms compared to their deterministic counterparts.
R4:随机选择和随机快速排序 🎲




在本节课中,我们将学习并分析两个随机算法:随机选择(QuickSelect)和随机快速排序(Randomized Quicksort)。我们将重点关注它们的预期运行时间分析,并理解如何通过数学归纳和期望计算来证明其效率。

概述
上一节我们介绍了确定性的选择算法(如“中位数的中位数”方法),它虽然能保证最坏情况下的线性时间,但实现复杂。本节中,我们将看到它们的随机化版本,这些版本实现更简单,并且在期望意义下同样高效。我们将通过建立递归关系并计算其期望值来分析它们的性能。
随机选择算法(QuickSelect)🔍
随机选择算法用于在未排序的数组中找到第 i 小的元素。其核心思想与快速排序的分区过程类似。
算法步骤
以下是算法的具体步骤:
- 从数组
A中随机选择一个元素作为枢轴x。 - 将数组分为三部分:小于
x的元素、等于x的元素、大于x的元素。设小于x的元素有k-1个。 - 比较目标排名
i与k:- 如果
i == k,则x就是第i小的元素,直接返回x。 - 如果
i < k,则在左子数组(小于x的部分)中递归寻找第i小的元素。 - 如果
i > k,则在右子数组(大于x的部分)中递归寻找第i - k小的元素。
- 如果
运行时间分析
算法的运行时间取决于随机选择的枢轴 x 在排序后的位置 k。如果 k 靠近中间,问题规模会大幅减小;如果 k 接近两端,则减少得很少。因此,我们分析其期望运行时间。
设 T(n) 为在大小为 n 的数组上运行的期望时间。我们可以写出递归式:
T(n) ≤ (1/n) * Σ_{j=1}^{n} [ max( T(j-1), T(n-j) ) ] + Θ(n)
其中:
(1/n)是随机选中第j个元素的概率。max( T(j-1), T(n-j) )表示我们需要解决可能较大的那个子问题。Θ(n)是分区操作所需的时间。
为了求解这个递归式,我们猜测 T(n) = O(n),即存在常数 c 使得 T(n) ≤ c * n。通过数学归纳法可以证明此猜测成立。关键在于求和项 Σ max(j-1, n-j) 的值约为 (3/4)n²,代入后可以选取足够大的常数 c 使不等式成立。
结论:随机选择算法的期望运行时间为 O(n)。
随机快速排序算法(Randomized Quicksort)📊
随机快速排序是经典快速排序的随机化变体。其与随机选择的主要区别在于,分区后需要对两个子数组都进行递归排序,而不仅仅是其中一个。
运行时间分析
设 T(n) 为排序 n 个元素的期望时间。其递归关系与随机选择类似,但需要将两个子问题的耗时相加:
T(n) = (1/n) * Σ_{j=1}^{n} [ T(j-1) + T(n-j) ] + Θ(n)
= (2/n) * Σ_{j=0}^{n-1} T(j) + Θ(n)
现在,我们猜测 T(n) = O(n log n)。将这个猜测代入递归式进行验证:
T(n) ≤ (2/n) * Σ_{j=0}^{n-1} (c * j log j) + Θ(n)
通过积分近似等方法计算求和项 Σ j log j,可以证明存在常数 c 使得 T(n) ≤ c n log n 成立。
结论:随机快速排序的期望运行时间为 O(n log n)。
概念辨析:期望、平均与摊销时间 ⚖️
在算法分析中,期望运行时间、平均情况运行时间和摊销运行时间含义不同:
- 期望运行时间:针对随机化的算法,对所有可能的随机选择(如枢轴选择)取平均。不依赖于输入分布。
- 平均情况运行时间:针对确定性的算法,对所有可能的输入取平均。这通常需要对输入分布做出假设(如“所有排列等概率”),是一个较弱的保证。
- 摊销运行时间:针对一系列操作,考虑最坏情况下每个操作的平均成本。它分析的是操作序列的整体代价,而非单次操作。
随机化算法的优势在于,通过引入内部随机性,将对输入分布的依赖转移到了算法自身的随机选择上,从而为任意输入提供了良好的期望性能保证。
总结
本节课我们一起学习了两个重要的随机化算法:
- 随机选择(QuickSelect):用于在未排序数组中查找第
i小元素,其期望运行时间为O(n)。 - 随机快速排序:一种高效且实现简洁的排序算法,其期望运行时间为
O(n log n)。
我们通过建立递归式、计算数学期望,并利用猜测验证法(先猜测一个渐进上界,再用归纳法证明)分析了它们的性能。同时,我们辨析了“期望运行时间”与“平均情况运行时间”的关键区别:随机化算法通过内部随机性,为所有输入提供了性能保证,而不需要对输入分布做任何假设。
L7:跳跃表 🎲





在本节课中,我们将要学习一种名为“跳跃表”的随机化数据结构。跳跃表是一种相对年轻且易于实现的数据结构,它能够以很高的概率提供高效的动态集合操作,如搜索、插入和删除。我们将从基础概念开始,逐步分析其性能,并最终证明其搜索复杂度为对数级别。
概述
跳跃表的核心思想是通过构建多层有序链表来加速搜索过程。与传统的平衡二叉搜索树(如AVL树或红黑树)相比,跳跃表在实现上更为简单,同时通过随机化保证了良好的平均性能。本节课我们将深入探讨其结构、操作算法以及概率分析。
从链表到跳跃表
上一节我们介绍了数据结构的基本背景,本节中我们来看看跳跃表是如何从简单的链表演化而来的。
排序链表的局限性
我们从一个简单的排序链表开始。假设链表中有 `n`` 个元素,并且链表是排序的。即使链表有序,进行一次搜索(成员查询)的最坏情况时间复杂度仍然是 O(n)。这是因为我们只能从链表头部开始,逐个节点遍历,直到找到目标或确定其不存在。
引入“快车”与“慢车”链表
为了提升搜索效率,我们可以引入第二个链表。想象一下地铁系统:有“本地线”(停靠每一站)和“快线”(只停靠主要站点)。在数据结构中,我们构建两个排序链表:
- L0(底层链表):包含所有
n个元素。 - L1(高层链表):包含
L0中部分元素的子集,作为“快线”站点。
搜索算法如下:
- 从高层链表
L1的头部开始向右遍历。 - 如果下一个节点的值大于目标值,则“下降”到低层链表
L0的当前位置。 - 在低层链表
L0中继续向右遍历,直到找到目标或确定其不存在。
优化两层结构
如果我们希望最小化这种两层结构的最坏情况搜索成本,应该如何选择 L1 中的元素呢?
搜索成本主要来自两部分:
- 在
L1中遍历的成本。 - 在
L0中遍历的成本(由于从L1下降,我们只需遍历L0的一部分)。
通过数学优化可以发现,当 L1 中包含大约 √n 个元素,并且这些元素在 L0 中均匀分布时,总搜索成本可以降至 O(√n)。这比单链表的 O(n) 有了显著提升。
推广到多层
自然地,我们可以添加更多层级的链表来进一步优化。如果我们有 k 个排序链表,并且以最优方式组织,搜索成本可以降至 O(k * n^(1/k))。
特别地,当我们设置层级数 k = log n 时,搜索成本变为:
O(log n * n^(1/(log n))) = O(log n * 2) = O(log n)
这达到了我们期望的对数级别复杂度。这种多层结构看起来类似于一棵“树”,但节点之间是通过链表水平连接的。
跳跃表的结构与操作
上一节我们介绍了多层链表的静态理想结构,本节中我们来看看在动态插入和删除时,如何通过随机化来维持跳跃表的高效性。
跳跃表示例
一个典型的跳跃表包含多个层级(L0, L1, L2, ...)。其中:
- L0 包含所有元素。
- 更高层级的链表是低层级链表的子集。
- 如果一个元素出现在层级
i,那么它也必须出现在所有低于i的层级中。 - 每个层级都有指向
-∞(头部)和+∞(尾部)的哨兵节点,以简化边界处理。
搜索算法
搜索一个元素 x 的算法(向前搜索)如下:
- 从最高层链表的头部开始。
- 在当前层级向右遍历,直到下一个节点的值大于等于
x。 - 如果当前节点的值等于
x,则搜索成功。 - 否则,下降到下一层级。
- 重复步骤 2-4,直到到达
L0。如果在L0中仍未找到,则搜索失败。
插入算法
插入一个元素 x 的步骤如下:
- 搜索定位:使用搜索算法,找到
x在L0中应插入的位置(即前驱和后继节点)。同时,记录在每一层搜索路径中“下降”时的节点,这些节点是后续插入时需要更新的前驱节点。 - 插入底层:将
x插入到L0中确定的位置。 - 随机晋升:抛一枚公平的硬币。
- 如果结果为正面,则将
x也晋升到更高一层,并插入到该层相应的位置(基于步骤1记录的前驱节点)。然后重复抛硬币,决定是否继续向更高层晋升。 - 如果结果为反面,则晋升过程停止。
- 如果结果为正面,则将
- 更新指针:在每一层插入
x时,更新相关节点的前后指针。
这个随机晋升过程意味着,跳跃表的最终形状是概率性的,而不是像平衡树那样严格确定的。
删除算法
删除一个元素 x 的步骤如下:
- 搜索定位:使用搜索算法找到
x在所有层级中出现的位置。 - 逐层删除:从
x出现的最高层开始,逐层将其从链表中移除(更新前后节点的指针)。 - 清理空层:如果删除导致最高层变为空(仅剩头尾哨兵),则可以降低跳跃表的高度。
跳跃表的概率分析 🎯
上一节我们定义了跳跃表的操作,本节中我们通过概率分析来证明其高效的性能。
我们的目标是证明:对于一个包含 n 个元素的跳跃表,任何一次搜索操作的成本都以很高的概率为 O(log n)。
核心概念:高概率
在随机化算法分析中,“以很高的概率”(With High Probability, WHP)是一个比“期望值”更强的概念。它意味着某个事件发生的概率至少为:
1 - 1 / n^c
其中 c 是一个大于 0 的常数。随着 n 增大,这个概率无限接近于 1。
热身引理:层级数边界
我们首先证明,跳跃表的层级数不会太高。
引理:跳跃表中的层级数 L 以很高的概率为 O(log n)。
证明思路:
- 一个元素能出现在第
k层,意味着在插入它时,连续抛硬币得到了至少k次正面。 - 得到至少
c log n次正面的概率是(1/2)^(c log n) = 1 / n^c。 - 考虑所有
n个元素,根据联合界,至少有一个元素晋升超过c log n层的概率最多为n * (1 / n^c) = 1 / n^(c-1)。 - 因此,所有元素的晋升层数都小于等于
c log n的概率至少为1 - 1 / n^(c-1),这满足高概率的定义。所以,最大层级数L = O(log n)WHP。
关键技巧:反向搜索分析
直接分析向前搜索的路径是复杂的。一个更聪明的办法是分析反向搜索的路径:从搜索到的目标节点开始,向左、向上移动,直到回到左上角的头节点。
在反向路径中:
- 向上移动 对应于插入该节点时抛硬币得到正面(晋升)。
- 向左移动 对应于插入该节点时抛硬币得到反面(未晋升,或来自左侧节点的晋升)。
因此,反向搜索的总移动次数,等于为了产生路径上所有“向上”动作而抛掷硬币的总次数。
主要定理:搜索成本边界
定理:在包含 n 个元素的跳跃表中,一次搜索的代价以很高的概率为 O(log n)。
证明概要:
- 根据热身引理,跳跃表的最大层级
L = O(log n)WHP。这意味着反向路径中“向上”移动的次数最多为O(log n)。 - 反向路径的总移动次数,等价于抛一枚公平硬币,直到出现
O(log n)次正面所需要的总抛掷次数。 - 我们可以利用切尔诺夫界来分析这个抛硬币过程。切尔诺夫界告诉我们,对于一系列独立伯努利试验(如抛硬币),其成功次数偏离期望值的概率呈指数级衰减。
- 具体地,我们可以证明:要至少得到
c log n次正面,只需要抛掷d log n次硬币(d是某个大于c的常数),并且这件事以很高的概率成立。 - 因此,总移动次数(即总抛掷次数)以很高的概率为
O(log n)。 - 最后,我们需要确保“层级数有界”和“移动次数有界”这两个高概率事件同时发生。由于两者各自失败的概率都是
1/n的多项式分之一,它们的联合失败概率也可以通过联合界控制,因此两者同时成立的概率也很高。
至此,我们证明了搜索操作的成本以很高的概率为对数级别。
总结
本节课中我们一起学习了跳跃表这一优雅的随机化数据结构。我们从简单的排序链表出发,通过添加多层“快线”链表来优化搜索,并最终引入了随机晋升机制来支持动态操作。我们详细描述了跳跃表的搜索、插入和删除算法。
最重要的是,我们通过概率分析证明了跳跃表的效率:
- 跳跃表的层级数以很高的概率为 O(log n)。
- 任何搜索操作的成本以很高的概率为 O(log n)。
跳跃表将随机化的力量与简单的链表结构相结合,提供了一种在实现复杂度和理论性能之间取得优异平衡的动态集合解决方案。其分析中使用的“高概率”概念和反向分析技巧,在随机化算法设计中也非常有代表性。
L8:通用和完美哈希 🗂️





在本节课中,我们将要学习哈希表的高级概念,特别是如何通过随机化技术来实现更高效、更可靠的哈希表。我们将重点介绍两种强大的哈希方法:通用哈希和完美哈希。通用哈希允许我们在不假设输入数据随机性的情况下,获得常数级别的预期操作时间。而完美哈希则更进一步,可以为静态数据集(即数据不发生变化)提供无冲突的哈希表,从而实现常数级别的最坏情况搜索时间。
哈希表与字典问题回顾
上一节我们介绍了随机化数据结构如跳跃列表。本节中,我们来看看如何将随机化应用于哈希表,以解决字典问题。
字典是一种抽象数据类型(ADT),它需要维护一个由键(Key)组成的动态集合,并支持以下三种操作:
- 插入(Insert):将一个带有唯一键的项目加入集合。
- 删除(Delete):从集合中移除一个指定键的项目。
- 搜索(Search):查询一个指定的键是否存在于集合中(精确搜索)。
使用AVL树或跳跃列表可以在 O(log n) 时间内解决此问题。但我们的目标是利用哈希表实现常数级别的预期时间。
最简单的哈希表实现是链式哈希法:一个大小为 m 的数组,每个槽位指向一个链表,存放所有哈希到该槽位的项目。其性能依赖于哈希函数 h 将键均匀地映射到 m 个槽位。
在基础算法课程中,分析通常基于一个称为简单均匀哈希的假设:对于任意两个不同的键 k1 和 k2,它们发生碰撞(即 h(k1) = h(k2))的概率恰好是 1/m。然而,这个假设等价于假设输入数据(键)本身是随机的,这在现实中并不合理。我们希望设计一种方法,即使面对最坏情况的输入数据,也能保证良好的性能。
通用哈希 🎲
为了摆脱对输入数据的假设,我们引入随机性到哈希函数的选择中。我们不再使用一个固定的哈希函数,而是从一个精心设计的哈希函数族 H 中随机选择一个函数 h 来使用。这个函数族需要满足通用性。
通用哈希族的定义
一个哈希函数族 H 是通用的,如果对于任意两个不同的键 k 和 k‘,从 H 中随机均匀地选择一个哈希函数 h,这两个键发生碰撞的概率至多为 1/m。
公式表示为:对于所有 k ≠ k‘,Pr_{h ∈ H}[h(k) = h(k‘)] ≤ 1/m。
这里的概率来自于哈希函数 h 的随机选择,而与具体的键 k 和 k‘ 无关。这意味着,即使对手在知晓哈希函数族 H 后选择了最坏的键,但只要 h 是在我们构建哈希表时才随机选定的,碰撞的概率依然很低。
通用哈希的性能分析
如果我们使用一个通用的哈希函数族 H,并随机选择 h ∈ H 来构建链式哈希表,那么对于任意输入(无需随机假设),每个槽位中链表的预期长度最多为 1 + α,其中 α = n/m 是负载因子。
因此,插入、删除和搜索操作的预期时间复杂度为 O(1 + α)。如果保持 m = Θ(n),则所有操作都是常数预期时间。
一个通用的哈希函数族实例
我们需要一个易于计算且通用的哈希函数族。假设哈希表大小 m 是一个质数,并且将键 k 视为一个 r 位的 m 进制数(即 k = (k0, k1, ..., k_{r-1}),其中每个 ki ∈ {0, 1, ..., m-1})。
定义以下哈希函数族 H:每个函数由一个向量 a = (a0, a1, ..., a_{r-1}) 参数化,其中每个 ai 在 {0, 1, ..., m-1} 中均匀随机选择。
对于给定的键 k,哈希函数 h_a(k) 定义为 k 与 a 的点积模 m:
h_a(k) = (Σ_{i=0}^{r-1} a_i * k_i) mod m
可以证明,这个哈希函数族 H 是通用的。在实践中,我们可以通过随机生成一个向量 a 来快速获得一个哈希函数。
完美哈希 ✨
通用哈希提供了良好的预期性能。但对于静态数据集(键集合固定,只有搜索操作),我们可以实现更强的保证:完美哈希。完美哈希能构建一个完全没有冲突的哈希表,从而实现常数最坏情况搜索时间和线性空间。
两级哈希结构
完美哈希的核心思想是使用两级哈希结构:
- 第一级:使用一个从通用族中随机选取的哈希函数
h1,将n个键映射到大小为m = Θ(n)的主表中。主表的每个槽位j对应一个次级哈希表。 - 第二级:对于主表槽位
j,设有lj个键被映射到此。我们为这个槽位单独分配一个大小为mj = lj²的次级哈希表,并从一个通用哈希族中为其选择一个哈希函数h2_j。关键点在于,我们不断重选h2_j,直到在该次级表内所有lj个键之间没有发生冲突为止。
为什么它能工作?
- 无冲突搜索:由于每个次级哈希表内部都无冲突,要搜索一个键
k,我们先计算i = h1(k)找到主表槽位,再计算j = h2_i(k)找到次级表中的位置。如果该位置存在键且与k匹配,则搜索成功。整个过程是确定性的常数时间。 - 线性空间保证:虽然次级表大小是
lj²,但所有次级表大小的总和Σ lj²的期望值是O(n)。我们可以通过一个重试循环来保证:如果随机选出的h1导致Σ lj² > c * n(c为常数),我们就丢弃h1并重新选择。根据马尔可夫不等式,每次尝试成功的概率至少为1/2,因此期望上只需常数次重试。 - 次级表无冲突保证:对于有
lj个键的次级表,其大小为lj²。从通用哈希族中随机选一个函数h2_j,发生任何碰撞的概率(根据生日悖论原理)不超过(lj choose 2) * (1/lj²) ≈ 1/2。因此,期望上只需常数次重试就能为每个次级表找到一个无冲突的哈希函数。
构建时间
构建完美哈希表需要多项式时间(实际上是近乎线性的时间):
- 通过重试找到合适的
h1(保证总空间线性)需要O(n log n)预期时间。 - 为每个次级表找到无冲突的
h2_j需要O(n log² n)预期时间。
因此,总构建时间是高效可行的。
总结 📚
本节课中我们一起学习了两种强大的随机化哈希技术:
- 通用哈希:通过从一个通用的哈希函数族中随机选择哈希函数,我们可以在不对输入数据做任何假设的情况下,为动态字典的所有操作(插入、删除、搜索)提供常数级别的预期时间复杂度。
- 完美哈希:针对静态数据集,通过精巧的两级哈希结构和重试机制,我们可以构建一个完全无冲突的哈希表。这提供了常数最坏情况搜索时间和线性空间的强保证,尽管构建过程需要一定的预处理时间。
这两种方法展示了如何利用随机化来将平均情况下的优秀性能,转化为对最坏情况输入的可靠保证,是算法设计中“用随机化对抗不确定性”思想的经典体现。
R5:动态规划与哈希 🧩



在本节课中,我们将学习动态规划的核心思想,并通过几个经典例子来理解其应用。随后,我们将回顾通用哈希与完美哈希的概念,了解如何设计高效的哈希表来避免最坏情况。


概述 📋
动态规划是一种通过将复杂问题分解为子问题,并重用子问题的解来优化算法效率的方法。我们首先通过一个简单的路径计数问题来理解其基本思想,然后探讨找零问题和矩形堆叠问题。之后,我们将转向哈希技术,学习如何设计避免最坏情况碰撞的哈希函数。
动态规划基础
上一节我们介绍了课程概述,本节中我们来看看动态规划的基本思想。
动态规划的主要思想是将问题分解为子问题,并重用已解决的子问题的结果。我们始终关注算法的运行时间。
热身示例:机器人路径计数 🤖
假设有一个机器人位于坐标 (1, 1),它想要到达坐标 (m, n)。每一步,机器人只能向上或向右移动一格。问题是:机器人有多少条不同的路径可以到达目的地?
以下是解决此问题的思路:
- 我们将子问题定义为:从起点到达网格中任意一点 (i, j) 的不同路径数量。
- 对于点 (i, j),到达它的路径数等于从其左边点 (i-1, j) 来的路径数加上从其下边点 (i, j-1) 来的路径数。
- 边界情况:第一行和第一列的所有点都只有一条路径可达(只能一直向右或一直向上)。
核心公式:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
这个例子虽然简单,但很好地说明了动态规划的要点:解决子问题并重用结果。如果不记忆化结果,运行时间会变差。
运行时间分析:
- 唯一子问题数量:
O(m*n),网格中的每个点对应一个子问题。 - 每个子问题的合并工作量:
O(1),只需进行一次加法。 - 总运行时间:
O(m*n)。
动态规划应用示例
上一节我们通过一个简单问题理解了动态规划的思想,本节中我们来看看两个更复杂的应用。
示例一:找零问题 💰
我们有一套硬币面值(例如 1分,5分,10分),每种硬币数量无限。给定一个总金额 n,我们需要找出凑成该金额所需的最少硬币数量。为简化问题,我们假设总包含面值为1的硬币,以保证问题总有解。
问题定义:给定硬币面值数组 S 和总金额 n,求 min(硬币数量),使得所选硬币面值之和等于 n。
以下是解决此问题的一种思路:
- 子问题定义:令
dp[x]表示凑成金额x所需的最少硬币数。 - 状态转移:对于金额
x,我们可以选择一枚面值为s_i的硬币,那么剩余金额为x - s_i,问题转化为求dp[x - s_i]。我们需要遍历所有可能的硬币面值,选择使硬币总数最小的那个。 - 基础情况:
dp[0] = 0(凑成0元需要0枚硬币)。
核心递推式:
dp[x] = min_{s_i <= x} (1 + dp[x - s_i])
运行时间分析:
- 唯一子问题数量:
O(n),即从0到n的所有金额。 - 每个子问题的合并工作量:
O(m),其中m是硬币面值种类数,因为需要遍历所有面值。 - 总运行时间:
O(n * m)。
重要说明:这个算法的时间复杂度关于输入值
n是多项式级的,但n本身通常以二进制形式输入,其输入规模是log n。因此,该算法相对于输入规模是指数级的,这与背包问题是NP难的事实并不矛盾。
示例二:矩形块堆叠问题 📦
我们有 n 个矩形块,每个块有长度 l_i、宽度 w_i 和高度 h_i。我们希望将它们堆叠起来,使得总高度最大。约束条件是:只有当下方块的长度和宽度都严格大于上方块时,上方块才能放在下方块上。不允许旋转方块。
问题定义:给定一组方块,求一个满足上述约束的堆叠序列,使得总高度 Σh_i 最大。
以下是解决此问题的一种思路(类似加权区间调度):
- 排序:首先将所有方块按长度(或宽度)降序排序,以确保在考虑堆叠顺序时,只有排在后面的(更小的)方块可能放在前面方块之上。
- 子问题定义:令
dp[i]表示以排序后第i个方块作为底部时,能堆叠出的最大高度。 - 状态转移:对于方块
i,我们需要检查所有排在它前面且长度和宽度都大于它的方块j。dp[i]等于h_i加上所有兼容的dp[j]中的最大值。另一种思路是考虑是否选择方块i作为当前堆叠的底部。 - 另一种定义:
dp[i]表示考虑排序后的前i个方块时,能获得的最大高度。对于每个方块i,我们可以选择它(那么需要找到一个兼容的j放在它下面),或者不选择它。
核心递推式(一种可能):
dp[i] = max( h_i + max_{j < i, l_j > l_i, w_j > w_i}(dp[j]), dp[i-1] )
运行时间分析:
- 唯一子问题数量:
O(n)。 - 每个子问题的合并工作量:朴素方法需要扫描所有前面的方块以找到兼容的,为
O(n)。 - 总运行时间(朴素):
O(n^2)。可以通过更精细的数据结构(如按另一维度排序的二叉搜索树)进行优化。
哈希技术回顾
上一节我们探讨了动态规划的几个例子,本节中我们来看看如何设计哈希函数来避免最坏情况的性能。
动机与问题
我们希望创建一个大小为 m 的哈希表,插入 n 个键(n ≈ m),使得每个桶平均包含 O(1) 个键。键来自一个很大的宇宙 U。
一个负面结论是:对于任何确定性的哈希函数 h,如果 |U| > m^2,总存在一组输入键(至少 m 个),使得它们全部哈希到同一个桶中,导致最坏情况 O(n) 的查找时间。攻击者如果知道哈希函数,可以精心构造这组“攻击键”使系统性能恶化。
解决方案:通用哈希函数族
我们不预先固定一个哈希函数,而是从一个哈希函数族 H 中随机选取一个。即使攻击者知道 H,他也不知道本次具体使用哪个 h。
通用哈希函数族的定义:如果从族 H 中随机均匀地选择一个哈希函数 h,则对于任意两个不同的键 k1 和 k2,它们发生碰撞(即 h(k1) = h(k2))的概率至多为 1/m。其中 m 是哈希表的大小。
一个通用哈希函数族的例子:
h_{a,b}(k) = ((a * k + b) mod p) mod m
其中 p 是一个大于最大可能键值的素数,a ∈ {1, 2, ..., p-1},b ∈ {0, 1, ..., p-1}。a 和 b 是随机选取的。
证明概要:对于两个不同的键 k1, k2,碰撞条件等价于 a*(k1 - k2) ≡ 0 (mod m) mod p。可以证明,导致碰撞的坏 a 的数量最多约为 p/m 个。而 a 总共有 p-1 种选择,因此碰撞概率 ≤ (p/m) / (p-1) ≈ 1/m。
从通用哈希到完美哈希
上一节我们介绍了通用哈希,本节中我们来看看如何实现零碰撞的完美哈希。
完美哈希是指对于一组给定的键,哈希函数保证不发生任何碰撞。
方法一:简单但耗空间
直接使用通用哈希函数,但将哈希表大小 m 设置为 n^2。
- 原理:根据通用哈希性质,任意两键碰撞概率为
1/m = 1/n^2。共有C(n,2) ≈ n^2/2对键。利用联合界,存在至少一次碰撞的概率≤ (n^2/2) * (1/n^2) = 1/2。 - 结论:我们以至少
1/2的概率获得一个完美哈希函数。如果失败,只需重新随机选取一个哈希函数,重复尝试。期望尝试次数约为2次。 - 缺点:空间复杂度为
O(n^2),过高。
方法二:两级哈希(节省空间)
目标是使用 O(n) 空间实现完美哈希。
- 第一级:使用一个通用哈希函数
h1,将n个键哈希到n个主桶中。设第i个桶中的键数为n_i。可以证明E[Σ n_i^2] < 2n。 - 第二级:对每个主桶
i,分配一个大小为m_i = n_i^2的二级哈希表。为每个二级表独立选择一个通用哈希函数h_{2,i}。由于每个二级表很小(n_i个键,表大小n_i^2),根据方法一,我们可以在常数次尝试内以高概率为该桶找到一个无碰撞的完美哈希函数。 - 空间:第一级表空间
O(n)。第二级总空间Σ n_i^2,其期望值小于2n,因此总体期望空间为O(n)。 - 过程:如果第一次尝试的第一级哈希导致
Σ n_i^2过大(比如> 4n),或者某个二级表多次尝试仍无法找到完美哈希,则我们重新选择第一级的哈希函数h1,从头开始。整个过程可以在O(n)预期时间内完成。
注意:这种完美哈希构造方法主要适用于静态键集合(即所有键已知且后续不再插入或删除)。
总结 🎯
本节课中我们一起学习了:
- 动态规划的核心是分解子问题和重用结果。我们通过机器人路径、找零问题和矩形堆叠三个例子,实践了定义子问题、建立状态转移方程和分析运行时间的方法。
- 哈希技术方面,我们了解了确定性哈希在最坏情况下的问题,引入了通用哈希函数族的概念来以高概率保证平均性能。进一步,我们探讨了如何通过增大表空间(
O(n^2))或使用两级哈希(O(n)空间)来构造完美哈希,以实现绝对零碰撞。
动态规划是解决最优化问题的强大工具,而随机化哈希是构建高效、稳健数据结构的关键技术之一。理解它们的原理和适用场景,对于设计高效算法至关重要。
L9:范围树 🌳





在本节课中,我们将学习数据结构增强技术,特别是如何通过增强现有数据结构(如平衡搜索树)来支持更复杂的查询操作。我们将从简单的子树大小增强开始,逐步深入到更复杂的应用,如手指搜索和正交范围搜索。最后,我们将重点学习范围树,这是一种用于高效解决多维正交范围查询问题的强大数据结构。
简单树增强 📈
上一节我们介绍了数据结构增强的基本概念。本节中,我们来看看一个具体的简单增强例子:为每个节点存储其子树的大小。
简单树增强的核心思想是,在一个平衡搜索树(如AVL树或2-3树)的每个节点x上,额外存储一个函数f的值,该值基于以x为根的子树计算得出。我们将其存储在字段x.f中。
为了使增强可行,我们需要一个关键条件:节点x的x.f值必须能够仅根据其子节点的f值,在常数时间内计算出来。用公式表示,对于二叉树:
x.f = x.left.f + x.right.f + 1 // 以子树大小为例
对于有固定数量子节点的树(如2-3树),计算方式类似,只需对每个子节点的f值求和。
当树的结构因插入、删除或旋转而改变时,只有那些发生变化的节点及其所有祖先节点的f值需要更新。在平衡搜索树中,从任何节点到根的路径长度是O(log n),因此每次更新的总开销仅为O(log n)。
一个重要的应用是顺序统计树。通过将f定义为子树大小(size),我们可以在O(log n)时间内支持以下操作:
RANK(x):查询键x的排名(即它在所有键中的排序位置)。SELECT(i):查询排名为i的键。
以下是计算RANK(x)的算法思路:从节点x开始,向根节点遍历。每当从一个节点z向左移动到其父节点时(意味着z是其父节点的右孩子),就将z的左子树大小加1计入总数。最后加上x本身。
以下是SELECT(i)的算法思路:从根节点开始,设当前节点为x,其左子树大小为left_size。x的排名为left_size + 1。
- 如果
i == left_size + 1,则返回x。 - 如果
i < left_size + 1,则在左子树中递归查找排名i。 - 如果
i > left_size + 1,则在右子树中递归查找排名i - (left_size + 1)。
需要注意的是,并非所有函数都易于维护。例如,维护每个节点的深度(从根节点算起的距离)就很困难,因为一次旋转可能导致大量节点的深度发生变化。
手指搜索 👆
上一节我们学习了如何通过简单增强来支持排名和选择操作。本节中,我们来看看一种更复杂的增强,旨在实现手指搜索属性。
手指搜索的目标是:假设我们刚刚找到了键y所在的节点,现在要搜索另一个键x。我们希望搜索时间与x和y在排序顺序中的“距离”d = |RANK(x) - RANK(y)|呈对数关系,即O(log d)。当x和y很近时(例如后继关系),这比标准的O(log n)搜索快得多。
为了实现这一属性,我们需要对数据结构进行两项增强:
- 水平链接:在2-3树(或B+树)中,除了父子指针,在同一层的所有节点之间增加双向链表指针。
- 数据存储在叶子节点:所有键值仅存储在叶子节点中,内部节点只用于路由。
此外,我们还需要一个简单增强:在每个节点存储其子树中的最小键(min)和最大键(max)。
手指搜索算法如下:
- 从包含
y的叶子节点v开始。 - 循环执行以下步骤,直到当前节点
v的键范围[v.min, v.max]包含目标键x:- 如果
x < v.min,则令v = v.left_level_link(跟随水平左指针)。 - 如果
x > v.max,则令v = v.right_level_link(跟随水平右指针)。 - 然后,令
v = v.parent(上移到父节点)。
- 如果
- 一旦找到键范围包含
x的节点v,就在以v为根的子树中执行一次常规的向下搜索,找到x。
算法分析:在循环的第k步,我们至少跳过了2^k个键(因为2-3树是分支因子为2-3的树)。为了跳过d个键,我们最多需要O(log d)步循环。最后的向下搜索也在O(log d)时间内完成。因此,总时间复杂度为O(log d)。
范围树 🔲
上一节我们实现了在近邻搜索中表现优异的手指搜索。本节中,我们来看一个用于解决正交范围查询问题的强大数据结构——范围树。
问题定义:在d维空间中,给定一个静态的点集。查询是一个d维的轴对齐矩形(盒子)。我们需要高效地回答:
- 盒中有多少点?
- 列出(或找到前
k个)盒中的点。
目标:预处理点集,使得查询时间达到O(log^d n + k),其中k是输出点的数量。
一维范围查询
首先,考虑一维情况(d=1)。查询是一个区间[a, b]。
我们可以使用一棵平衡二叉搜索树(按点坐标排序)。进行范围查询[a, b]的算法如下:
- 分别查找
a和b在树中的位置(或前驱/后继)。 - 找到
a和b对应路径的最低公共祖先(LCA)。 - 从
a点向上走到LCA,对于路径上的每个节点,如果它是其父节点的左孩子,则将其右子树中的所有点加入答案。 - 从
b点向上走到LCA,对于路径上的每个节点,如果它是其父节点的右孩子,则将其左子树中的所有点加入答案。 - 如果区间是闭区间,还需检查
a和b节点本身。
这个算法返回一个隐式答案:O(log n)个完整的子树和O(log n)个单独节点。通过预先增强子树大小,我们可以在O(log n)时间内计算出答案的总数k。要实际列出前k个点,只需中序遍历这些子树,耗时O(k)。
二维范围查询
现在扩展到二维(d=2)。每个点有坐标(x, y)。查询是一个矩形[x1, x2] × [y1, y2]。
核心思想(分层结构):
- 我们首先建立一棵主树(x树),它按照点的
x坐标组织,与一维情况相同。 - 对于x树中的每个节点
v,我们关联一个辅助数据结构:一棵y树。这棵y树存储了以节点v为根的子树中所有点,但按照这些点的y坐标排序。
查询过程:
- 在
x树上执行一维范围查询[x1, x2]。如同之前一样,我们会得到O(log n)个“相关节点”(代表在x维度上完全落在区间内的子树)和一些单独节点。 - 对于每个得到的“相关节点”
v,我们不再需要检查其子树中的所有点(因为它们在x维度上都符合条件)。相反,我们访问与v关联的y树,并在y树上执行一维范围查询[y1, y2],从而高效地找出在y维度上也符合条件的点。 - 对于查询过程中遇到的单独节点,直接检查其
y坐标是否在[y1, y2]内。
复杂度分析:
- 查询时间:在
x树上有O(log n)个相关节点。对每个相关节点的y树进行查询需要O(log n)时间。因此,总查询时间为O(log^2 n + k)。 - 空间复杂度:每个点出现在
x树的多个节点的y树中(具体来说,是它在x树中所有祖先节点对应的y树里)。由于x树深度为O(log n),因此每个点被存储O(log n)次,总空间复杂度为O(n log n)。
更高维度
对于d维范围查询,我们可以递归地应用上述思想:
- 建立一棵主树(按第一维排序)。
- 每个节点关联一个
(d-1)维的范围树(负责剩下的维度)。
查询时间将达到O(log^d n + k),空间复杂度为O(n log^{d-1} n)。
总结 📝
本节课中我们一起学习了数据结构增强的强大技术。
- 我们从简单树增强开始,通过维护子树大小,实现了顺序统计树,支持快速的
RANK和SELECT操作。 - 接着,我们探讨了手指搜索,通过为2-3树添加水平链接并将数据存储在叶子节点,实现了搜索时间与键之间距离的对数关系
O(log d)。 - 最后,我们深入研究了范围树,这是一种用于多维正交范围查询的分层数据结构。我们详细分析了其在一维和二维情况下的构建与查询过程,以及其
O(log^d n + k)的查询时间复杂度和O(n log^{d-1} n)的空间复杂度。
这些增强技术展示了如何通过巧妙地组合和扩展基本数据结构,来解决日益复杂的查询问题,是算法设计中非常重要的范式。
L10:动态规划:高级DP 🧩





在本节课中,我们将学习动态规划(DP)的高级应用。我们将通过三个逐步深入的例子来探索DP的强大之处:最长回文子序列、最优二叉搜索树以及交替硬币游戏。每个例子都将展示如何将复杂问题分解为子问题,并高效地构造最优解。
最长回文子序列 🔄
上一节我们回顾了动态规划的基本概念。本节中,我们来看看第一个具体问题:寻找给定字符串中的最长回文子序列。子序列意味着字符可以不连续,但必须保持原有顺序。
问题定义
给定一个字符串 X[1..n],我们需要找到其最长的回文子序列的长度。例如,在字符串 “character” 中,最长回文子序列是 “carac”,长度为5。
动态规划解法
我们定义 L[i][j] 为子字符串 X[i..j] 的最长回文子序列的长度,其中 1 ≤ i ≤ j ≤ n。
以下是计算 L[i][j] 的递归关系(状态转移方程):
- 基本情况:如果
i == j,则L[i][j] = 1(单个字符是回文)。 - 如果
X[i] == X[j]且i+1 == j,则L[i][j] = 2(两个相同字符)。 - 如果
X[i] == X[j]且i+1 < j,则L[i][j] = 2 + L[i+1][j-1](两端字符相同,可加入回文)。 - 如果
X[i] != X[j],则L[i][j] = max(L[i+1][j], L[i][j-1])(舍弃一个字符,看剩余部分)。
算法实现与复杂度
我们可以使用一个二维数组以自底向上的迭代方式或带备忘录的递归方式实现上述递归。子问题的数量为 O(n²),每个子问题的计算是 O(1),因此总时间复杂度为 O(n²)。
为了构造出具体的回文序列而不仅仅是长度,我们需要在计算过程中记录决策路径(例如,是从哪个状态转移而来),最后通过回溯得到序列。
最优二叉搜索树 🌳
在理解了如何用DP处理序列问题后,我们来看一个更结构化的问题:最优二叉搜索树。这个问题中,贪婪算法看似可行,但实际会失败,这凸显了DP的必要性。
问题定义
我们有一组有序的键 K1 < K2 < ... < Kn 及其对应的搜索权重(或概率)w1, w2, ..., wn。目标是构建一棵二叉搜索树(BST),使得所有键的加权搜索成本最小化。一个键 Ki 的搜索成本是其深度(根节点深度为0)加1,再乘以它的权重 wi。
目标函数公式:
最小化 Σ (i=1 to n) [ wi * (depth_T(Ki) + 1) ]
为什么贪婪算法会失败?
一个直观的贪婪策略是:总是选择当前范围内权重最高的键作为子树的根节点。然而,这可能导致树结构不平衡,从而增加其他高权重键的深度,最终得不到全局最优解。存在反例证明此贪婪策略并非最优。
动态规划解法
由于我们不知道最优树的根节点是哪个键,DP的“猜测”策略在此发挥作用:我们枚举每个键作为根节点的可能性。
定义 e[i][j] 为包含键 Ki ... Kj 的最优二叉搜索树的最小加权搜索成本。w[i][j] 是这些键的权重之和 Σ (k=i to j) wk。
状态转移方程如下:
- 基本情况:如果
i == j,则e[i][j] = wi(只有一个键,深度为0,成本为wi*(0+1))。 - 对于
i ≤ j:
e[i][j] = min (r = i to j) { e[i][r-1] + e[r+1][j] + w[i][j] }
其中,r是枚举的根节点键Kr。e[i][r-1]和e[r+1][j]是左右子树的最优成本。w[i][j]的加入是因为当r成为根节点,其左右子树中所有键的深度都增加了1,因此总成本需要额外加上所有这些键的权重和。
算法复杂度
子问题数量为 O(n²),对于每个子问题 e[i][j],我们需要枚举 O(j-i+1) 个可能的根节点 r。因此,总时间复杂度为 O(n³)。
交替硬币游戏 🪙
最后,我们探讨一个涉及对抗性决策的问题:交替硬币游戏。这要求我们在模型中考虑对手的最优行为,是DP一个有趣的应用。
问题描述
有一排 n(n为偶数)枚硬币,其价值为 v1, v2, ..., vn。两个玩家轮流从这排硬币的最左端或最右端取走一枚硬币。玩家都希望自己取走的硬币总价值最大。假设对手也采取最优策略,作为先手玩家,你如何保证自己的最大收益?
动态规划解法
我们定义 V[i][j] 为当硬币序列剩余 vi ... vj 时,当前行动玩家(不一定是原始先手)能保证获得的最大价值。
状态转移需要考虑两个阶段:我方行动和对手行动。
- 我方行动:我可以选择最左边的
vi或最右边的vj。 - 对手行动:在我选择后,对手会在剩余的序列上采取最优行动,试图最大化他/她的收益,从而最小化我后续能获得的收益。
因此,状态转移方程如下:
V[i][j] = max { vi + min( V[i+2][j], V[i+1][j-1] ), // 我取vi,对手取后,我得到剩余序列的“保证最小”收益 vj + min( V[i+1][j-1], V[i][j-2] ) // 我取vj,对手取后,我得到剩余序列的“保证最小”收益 }
其中 min(...) 部分模拟了对手最优决策下,我下一轮能获得收益的最坏情况(因此取最小值作为保证)。
基本情况:
- 如果
i == j,只剩一枚硬币,V[i][j] = vi。 - 如果
i+1 == j,只剩两枚硬币,V[i][j] = max(vi, vj)。
算法复杂度与策略洞察
同样,子问题数量为 O(n²),每个子问题计算为 O(1),总复杂度为 O(n²)。
一个有趣的策略洞察是:作为先手玩家,你可以预先计算所有奇数索引硬币价值之和与所有偶数索引硬币价值之和。选择较大的那一组,并采取相应策略(例如,若奇数和大,则始终迫使自己取奇数位硬币),可以保证至少不输,并且通常能最大化收益。
总结 📚
本节课中我们一起学习了动态规划在三个高级问题中的应用:
- 最长回文子序列:展示了如何将序列问题分解为区间子问题,并通过状态转移高效求解。
- 最优二叉搜索树:说明了当问题结构涉及“选择根节点”时,DP通过枚举所有可能性来克服贪婪算法的局限性。
- 交替硬币游戏:引入了对抗性环境下的DP建模,关键是在状态转移中考虑对手的最优反应,以计算己方的“保证收益”。
这些例子体现了动态规划的核心思想:定义子问题,建立状态转移方程(递归关系),并以自底向上或带备忘录的方式避免重复计算,最终在多项式时间内解决指数级复杂度的原始问题。
L11:动态规划:所有对最短路径 🚀





在本节课中,我们将要学习如何解决“所有对最短路径”问题。我们将从回顾单源最短路径算法开始,然后探索如何将其扩展到解决所有顶点对之间的最短路径问题。我们将介绍几种动态规划方法,并最终学习一个巧妙的算法——约翰逊算法,它能在稀疏图中高效地处理包含负权边的图。
单源最短路径回顾
在深入所有对最短路径之前,我们先回顾一下单源最短路径问题的已知算法。这有助于我们理解后续扩展的基础。
以下是不同场景下的单源最短路径算法及其时间复杂度:
- 未加权图:使用广度优先搜索(BFS),时间复杂度为 O(V + E)。
- 非负权边图:使用迪杰斯特拉算法(Dijkstra),借助斐波那契堆,时间复杂度为 O(E + V log V)。
- 一般权边图(可含负权):使用贝尔曼-福特算法(Bellman-Ford),时间复杂度为 O(VE)。
- 有向无环图(DAG):通过动态规划(拓扑排序后应用松弛),时间复杂度为 O(V + E)。
所有对最短路径问题定义
所有对最短路径问题的目标是,给定一个有向图 G=(V, E) 和边权函数 w(可能包含负权),为图中每一对顶点 (u, v) 找到从 u 到 v 的最短路径权重 δ(u, v)。
一个直观的解决方案是,对图中的每个顶点运行一次单源最短路径算法。
以下是基于不同单源算法扩展得到的所有对算法时间复杂度:
- 运行 V 次 BFS:O(V² + VE)
- 运行 V 次 Dijkstra:O(VE + V² log V)
- 运行 V 次 Bellman-Ford:O(V²E)
在稠密图(E ≈ V²)中,运行 V 次 Bellman-Ford 会达到 O(V⁴),这是我们希望改进的。
动态规划方法一:基于边数限制
上一节我们回顾了基础算法,本节中我们来看看第一种动态规划思路。我们通过限制路径的边数来定义子问题。
我们定义子问题为:
d(u, v, m) = 从顶点 u 到顶点 v,最多使用 m 条边的最短路径权重。
我们如何求解 d(u, v, m) 呢?我们可以猜测最短路径的最后一条边。
以下是递推关系:
d(u, v, m) = min{ d(u, x, m-1) + w(x, v) },其中 x 遍历所有顶点。
基本情况:d(u, v, 0) = 0(如果 u == v),否则为 ∞。
通过按 m 从小到大的顺序进行三层循环(m, u, v),我们可以计算出结果。这个算法的时间复杂度为 O(V⁴),与运行 V 次 Bellman-Ford 相同。
与矩阵乘法的关联
动态规划方法一的递推式,让人联想到矩阵乘法。我们可以重新定义运算符,将“加法”视为“取最小值”,将“乘法”视为“加法”。
定义矩阵 W 为图的权重矩阵(W[i][j] 为边 (i, j) 的权重,无边则为 ∞)。定义 D(m) 为最多使用 m 条边的最短路径权重矩阵。
那么有:D(m) = D(m-1) “⊙” W,其中 “⊙” 是我们新定义的、基于 min 和 + 的矩阵乘法运算。
通过矩阵乘法的结合律,我们可以用重复平方(W → W² → W⁴ → ...)的方法加速计算,在 O(V³ log V) 时间内得到结果(计算 W^(n-1))。这比 O(V⁴) 有所改进。
动态规划方法二:Floyd-Warshall 算法
矩阵乘法方法引入了一个 log V 因子。现在,我们来看一个更优的动态规划思路——Floyd-Warshall 算法,它能达到 O(V³) 的时间复杂度。
我们重新定义子问题:
c(u, v, k) = 从顶点 u 到顶点 v,且所有中间顶点(即路径上除起点和终点外的顶点)都来自集合 {1, 2, ..., k} 的最短路径权重。
我们如何求解 c(u, v, k) 呢?关键思路是考虑顶点 k 是否出现在这条最短路径中。
以下是递推关系:
c(u, v, k) = min( c(u, v, k-1), c(u, k, k-1) + c(k, v, k-1) )
- 第一项:不经过顶点
k。 - 第二项:经过顶点
k(路径分解为u → ... → k和k → ... → v,且这两部分只使用前k-1个顶点作为中间点)。
基本情况:c(u, v, 0) = w(u, v)(即直接边的权重,无边则为 ∞)。
算法实现就是三层循环:
for k in range(1, n+1):
for u in range(1, n+1):
for v in range(1, n+1):
c[u][v] = min(c[u][v], c[u][k] + c[k][v])
最终 c[u][v] 即为 δ(u, v)。这是一个简洁而高效的算法。
约翰逊算法(用于稀疏图)
Floyd-Warshall 算法对于稠密图是很好的选择。但对于稀疏图,我们可以做得更好。约翰逊算法能在 O(V² log V + VE) 时间内解决所有对最短路径问题,这与在非负权图上运行 V 次 Dijkstra 算法的复杂度相当,但它能处理负权边(只要没有负权环)。
该算法的核心思想是重赋权。我们找到一个顶点映射函数 h: V -> R,使得对于每条边 (u, v),新的边权 w'(u, v) = w(u, v) + h(u) - h(v) 非负。
如果有了这样的 h,我们就可以在新图 (V, E, w') 上对每个顶点运行 Dijkstra 算法(因为边权非负)。神奇的是,新图中的最短路径与原图中的最短路径是一一对应的,并且原图的最短路径权重可以通过公式 δ(u, v) = δ'(u, v) - h(u) + h(v) 还原。
那么,如何找到这样的函数 h 呢?这等价于求解一个差分约束系统:h(v) - h(u) ≤ w(u, v) 对所有边 (u, v) 成立。
我们可以通过添加一个超级源点 s 并连接到所有其他顶点(边权为 0),然后运行一次 Bellman-Ford 算法来解决。令 h(v) = δ(s, v)(从 s 到 v 的最短路径权重)。如果图中没有负权环,Bellman-Ford 会成功计算出这些值,并且由此定义的 h 恰好满足我们的要求(根据三角不等式)。如果检测到负权环,则问题无解。
约翰逊算法的步骤如下:
- 预处理(重赋权):添加超级源点,运行 Bellman-Ford 得到
h(v)。若无负权环,则根据h计算所有边的新权重w'。 - 运行 Dijkstra:对于图中的每个顶点
u,在新图(V, E, w')上运行 Dijkstra 算法,得到所有δ'(u, v)。 - 还原答案:对于每一对
(u, v),计算δ(u, v) = δ'(u, v) - h(u) + h(v)。
总结
本节课中我们一起学习了解决“所有对最短路径”问题的多种策略。
- 我们从简单的“运行 V 次单源算法”开始,分析了其效率瓶颈。
- 接着,我们探索了两种动态规划思路:基于边数限制的方法(关联矩阵乘法)和 Floyd-Warshall 算法(基于中间顶点限制),后者达到了 O(V³) 的时间复杂度。
- 最后,我们学习了约翰逊算法,它通过巧妙的重赋权技术,将含有负权边(无负权环)的问题转化为非负权问题,从而能够利用更快的 Dijkstra 算法,在稀疏图上实现了接近 O(V²) 的效率。
对于稠密图,Floyd-Warshall 算法是简单实用的选择;对于稀疏图,约翰逊算法则更为高效。理解这些算法背后的动态规划思想和问题转化技巧,是掌握图算法设计的关键。
L12:贪心算法:最小生成树 🌳





在本节课中,我们将要学习一个经典的图算法问题——最小生成树。我们将看到两种解决此问题的贪心算法,并理解其背后的理论原理。贪心算法的核心思想是“每一步都做出当前看起来最优的选择”,我们将证明对于最小生成树问题,这种策略确实能得到全局最优解。
概述
最小生成树问题是指,在一个带权重的无向连通图中,寻找一棵连接所有顶点且总权重最小的树。我们将学习两种著名的贪心算法:Prim算法和Kruskal算法。它们都基于一个共同的定理,即“安全边”引理,该引理保证了贪心选择的正确性。
核心概念与定义
首先,让我们明确几个核心概念。
- 树:一个无环的连通图。
- 生成树:一个包含原图所有顶点,并且是原图子图的树。
- 最小生成树:所有生成树中,边的权重总和最小的那一个。
我们用公式定义一棵树 T 的权重:
w(T) = Σ w(e),其中 e ∈ T。
一个朴素的解决方法是尝试所有可能的生成树,但生成树的数量可能是指数级的,因此我们需要更高效的算法。
贪心算法理论基础
在深入具体算法前,我们先了解贪心算法有效的两个关键性质。
最优子结构
这个性质与动态规划中的思想类似。它指出,如果一个问题的最优解包含了其子问题的最优解,那么该问题就具有最优子结构。
对于最小生成树,具体表现为:假设我们知道某条边 e 属于某个最小生成树。如果我们“收缩”这条边(即将它的两个端点合并为一个顶点),那么在新图 G/e 中找到的最小生成树 T',加上原来的边 e,就构成了原图 G 的一个最小生成树。
这为我们提供了一个递归的思路:找到一个在最小生成树中的边,收缩它,然后在更小的图上递归求解。
贪心选择性质
这是贪心算法的核心。它表明,我们可以通过做出局部最优的选择(即贪心选择)来构造全局最优解,而无需考虑未来的影响。
对于最小生成树,一个关键的贪心选择性质是切割性质:
- 切割:将图的顶点集
V划分为两个非空集合S和V-S。 - 横跨边:一个端点在
S中,另一个端点在V-S中的边。 - 定理:对于任意一个切割,横跨该切割的所有边中,权重最小的那条边一定包含在某个最小生成树中。
证明(剪切-粘贴法):
- 假设存在一个最小生成树
T*不包含这条最小横跨边e=(u, v)。 - 由于
T*是树,u到v之间存在唯一路径P。因为u和v分属切割两侧,路径P上至少有一条边e'也横跨该切割。 - 将
T*中的边e'移除,并加入边e,得到新树T'。 - 由于
w(e) ≤ w(e'),所以w(T') ≤ w(T*)。因此T'也是一个最小生成树,且包含了边e。
这个性质保证了我们的贪心选择是“安全”的。
Prim算法 🚀
上一节我们介绍了贪心算法的理论基础,本节中我们来看看如何应用切割性质来构造第一个算法——Prim算法。它的思想与Dijkstra最短路径算法非常相似。
Prim算法从一个顶点开始,逐步“生长”出一棵最小生成树。在每一步,我们都有一个已包含在树中的顶点集合 S。我们考虑所有横跨切割 (S, V-S) 的边,并选择其中权重最小的一条加入树中,同时将这条边的新端点加入集合 S。
算法步骤
以下是Prim算法的实现步骤:
- 初始化:选择任意一个起始顶点
s。将所有顶点的“键值”初始化为无穷大(∞),表示从当前树集S到该顶点的最小边权。将s的键值设为0。使用一个最小优先队列Q来存储所有顶点(键值为优先级)。 - 循环:当
Q不为空时:
a. 从Q中取出键值最小的顶点u(即离当前树集S最近的顶点)。
b. 将u加入集合S。
c. 遍历u的所有邻接顶点v:
* 如果v仍在Q中(即未加入S),并且边(u, v)的权重w(u, v)小于v当前的键值:
* 更新v的键值为w(u, v)。
* 记录v的父节点为u(parent[v] = u)。 - 结束:算法结束后,所有
parent指针构成的边集就是最小生成树。
运行时间分析
Prim算法的运行时间取决于优先队列的实现:
- 使用二叉堆:
O((V+E) log V)。 - 使用斐波那契堆:
O(E + V log V)。
Kruskal算法 🔗
上一节我们学习了像Dijkstra一样逐步扩展的Prim算法。本节我们来看另一种思路的贪心算法——Kruskal算法。它不再从一个点扩展,而是直接全局地、按权重顺序考虑所有边。
Kruskal算法的核心思想非常简单:将所有边按权重从小到大排序,然后依次考虑每条边。如果加入当前边不会在已选择的边集中形成环,就将其加入最小生成树;否则就跳过。
算法步骤
以下是Kruskal算法的实现步骤:
- 初始化:将每个顶点视为一个独立的连通分量(使用并查集数据结构维护)。创建一个空集合
T用于存放最小生成树的边。 - 排序:将图
G中的所有边按权重非递减顺序排序。 - 循环:按顺序遍历每条边
e = (u, v):
a. 使用并查集的Find-Set操作检查u和v是否属于同一个连通分量。
b. 如果不属于(即加入e不会形成环):
* 将边e加入集合T。
* 使用并查集的Union操作合并u和v所在的连通分量。 - 结束:当
T中包含V-1条边时,算法结束,T即为最小生成树。
运行时间分析
Kruskal算法的运行时间主要消耗在排序和并查集操作上:
- 排序边:
O(E log E)。 - 并查集操作(
Find-Set和Union):对于E条边,近似O(E α(V)),其中α是增长极慢的阿克曼反函数。 - 总时间复杂度:
O(E log E),主要由排序决定。如果边权是较小整数,可使用线性时间排序(如基数排序),使总时间接近线性。
正确性说明
Kruskal算法的正确性同样基于切割性质。当我们考虑一条边 e=(u, v) 时,u 和 v 分属不同的连通分量。我们可以将 u 所在的连通分量视为切割的 S 侧,其余部分视为 V-S 侧。由于我们是按权重顺序考虑边的,e 就是横跨这个特定切割的第一条边(否则更小的边会先被加入并合并分量),因此 e 就是横跨该切割的最小权重边,根据切割性质,它属于某个最小生成树。
总结
本节课中我们一起学习了图论中的一个经典问题——最小生成树,并深入探讨了两种高效的贪心算法。
- Prim算法:从一个顶点出发,像“生长”一样逐步扩展最小生成树。它维护一个顶点集合
S,每次选择连接S与外部顶点的最小权重边。其实现类似于Dijkstra算法,使用优先队列,高效直观。 - Kruskal算法:从全局出发,按边权重排序,依次尝试加入边,并利用并查集判断是否会形成环。其思想简单,在边排序成本不高或边权范围较小时非常高效。
这两种算法都建立在切割性质这一核心定理之上,该定理保证了“横跨任意切割的最小权重边必在某个最小生成树中”,从而验证了贪心策略的正确性。理解这一理论是掌握最小生成树算法的关键。
R12:贪心算法 🎯





在本节课中,我们将学习贪心算法的核心思想,并通过三个经典问题来理解其应用。贪心算法在每一步都做出当前看来最优的选择,希望以此达到全局最优解。我们将探讨连续硬币找零、进程调度和区间着色问题,并学习如何证明贪心策略的正确性。
连续硬币找零问题 💰
上一节我们回顾了离散硬币的贪心算法。本节中,我们来看看一个变体:连续硬币找零问题。
假设你有 n 种金属,每种金属的价值为每公斤 Ci 美元。你需要赠予他人总价值恰好为 T 美元的金属。每种金属你有有限的数量 Wi 公斤。你的目标是最小化所赠金属的总重量。
核心思路:为了用最少的重量达到目标价值,应优先使用单位价值最高(即最昂贵)的金属。
以下是解决问题的步骤:
- 排序:将所有金属按单位价值
Ci降序排列。 - 贪心选择:从最昂贵的金属开始,尽可能多地使用它。
- 设当前金属单位价值为
Ci,可用数量为Wi。 - 计算达到剩余目标价值
T所需该金属的重量:need = T / Ci。 - 如果
need <= Wi,则使用need公斤该金属,问题解决。 - 如果
need > Wi,则用完所有的Wi公斤,更新剩余目标价值T = T - Ci * Wi,然后考虑下一种金属。
- 设当前金属单位价值为
正确性证明(交换论证):
假设存在一个最优解没有优先使用最昂贵的金属 i,而是使用了单位价值较低的金属 j(Cj < Ci)。设该解使用了 Kj 公斤的金属 j,其贡献价值为 Cj * Kj。
如果我们将这部分金属 j 替换为金属 i,为了获得相同的价值,只需要 (Cj * Kj) / Ci 公斤。由于 Ci > Cj,因此 (Cj * Kj) / Ci < Kj。这意味着替换后总重量减少,与“最优解”矛盾。因此,优先使用最昂贵金属的贪心策略是正确的。
进程调度问题 ⏱️
了解了如何最小化重量后,我们来看看如何安排进程以优化效率,即最小化平均完成时间。
假设有 n 个进程,每个进程 i 的运行时间为 Ti。你需要决定一个执行顺序。进程 i 的完成时间是其开始时间加上它自身及之前所有进程的运行时间之和。目标是最小化所有进程完成时间的平均值,等价于最小化完成时间的总和。
核心思路:为了最小化总完成时间,应该让运行时间短的进程先执行。这可以减少后续进程的等待时间。
算法:将进程按运行时间 Ti 升序排列,并依此顺序执行。
正确性证明(反证法):
假设存在一个最优顺序不是按升序排列的。那么在这个顺序中,必然存在一对相邻的进程,其中前一个进程 P_i 的运行时间大于后一个进程 P_j 的运行时间(即 T_i > T_j)。
考虑交换 P_i 和 P_j。交换后:
- 在
P_i之前的所有进程完成时间不变。 P_j(现在排在前面)的新完成时间比原来P_i的完成时间减少了T_i - T_j。P_i(现在排在后面)的新完成时间等于原来P_j的完成时间。- 在
P_i和P_j之后的所有进程完成时间不变。
设Δ = T_i - T_j > 0。交换后,P_j的完成时间减少了Δ,并且P_i及之后所有进程的完成时间都至少减少了Δ(因为P_i本身时间变短了)。因此,总完成时间严格减少,这与原顺序是最优的假设矛盾。所以,按运行时间升序排列的顺序是最优的。
区间着色(事件安排)问题 🎨
最后,我们处理一个资源分配问题:如何用最少的“克隆人”参加所有重叠的活动。
给定一系列时间区间(每个区间代表一个活动),区间之间可能重叠。你无法同时参加两个重叠的活动。目标是找到参加所有活动所需的最小“资源”数(例如,克隆人或会议室)。
核心思路:这是一个区间图着色问题。贪心策略是:按开始时间顺序处理区间,总是将当前区间分配给第一个可用的“资源”;如果没有可用资源,则开辟一个新的。
以下是算法步骤:
- 将所有区间按开始时间升序排序。
- 初始化一个资源列表(最初为空)。
- 遍历每个区间:
- 检查现有资源中,是否有某个资源的最后一个活动结束时间早于当前区间的开始时间(即不冲突)。
- 如果存在,则将当前区间分配给该资源,并更新该资源的最后结束时间。
- 如果不存在,则创建一个新的资源,并将当前区间分配给它。
正确性简要说明:
假设算法创建了第 m 个资源。在创建它的那一刻,当前区间与前面 m-1 个资源中的最后一个区间都发生冲突。这意味着存在一个时间点(当前区间的开始时间),被 m 个区间同时覆盖。因此,无论采用何种安排,至少需要 m 个资源。这证明了算法找到的资源数 m 是最小的。
总结与拓展 🚀
本节课中我们一起学习了三种贪心算法的应用:
- 连续硬币找零:通过优先使用单位价值最高的资源来最小化总消耗。
- 进程调度:通过优先执行短任务来最小化平均完成时间。
- 区间着色:通过按序分配并总使用第一个可用资源,来最小化所需资源总数。
贪心算法的关键在于每一步做出局部最优选择,并能够证明该选择能导向全局最优解。常用的证明方法包括交换论证和反证法。
在线算法拓展:对于进程调度问题,如果任务动态到达(在线情况),策略变为始终执行剩余时间最短的任务。这需要系统能中断当前任务。虽然可能导致长任务被不断推迟,但在所有任务优先级相同的情况下,这仍然是优化平均响应时间的最佳策略之一。
L13:最大流量,最小切割 💧





在本节课中,我们将要学习流网络、最大流问题以及与之密切相关的最小割概念。我们将从定义开始,逐步建立理解,并最终介绍解决最大流问题的核心思想——残差网络和增广路径。
流网络定义 📊
流网络是一个有向图 G = (V, E),它包含两个特殊的顶点:
- 源点 (Source, s):流的起点。
- 汇点 (Sink, t):流的终点。
图中的每条有向边 (u, v) 都有一个非负的容量 (Capacity),记作 c(u, v) ≥ 0。如果 (u, v) 不是图中的边,则定义其容量 c(u, v) = 0。
流 (Flow) 是一个定义在边上的函数 f: V × V → R,它满足以下三个性质:
- 容量限制 (Capacity Constraints):对于所有边
(u, v),流的大小不能超过该边的容量。f(u, v) ≤ c(u, v) - 斜对称性 (Skew Symmetry):从
u到v的流与从v到u的流互为相反数。f(u, v) = -f(v, u) - 流量守恒 (Flow Conservation):对于除源点
s和汇点t外的任何顶点u,流入该顶点的总流量等于流出该顶点的总流量。Σ_{v ∈ V} f(v, u) = Σ_{v ∈ V} f(u, v) = 0
一个流 f 的值 (Value) |f| 定义为从源点流出的总流量:
|f| = Σ_{v ∈ V} f(s, v)
最大流问题的目标就是找到一个流 f,使得其值 |f| 在所有满足上述约束的流中达到最大。
流的值等于进入汇点的流量 📈
上一节我们定义了流的值。本节中我们来看一个重要的性质:从源点流出的总流量,必然等于最终进入汇点的总流量。
定理:对于流网络 G 中的任意一个流 f,其值等于进入汇点 t 的流量。即:
|f| = Σ_{v ∈ V} f(v, t)
证明概要:
- 根据定义,
|f| = Σ_{v ∈ V} f(s, v)。 - 利用流量守恒性质(所有非源非汇顶点净流量为0)和斜对称性,可以将上述求和式进行变换。
- 最终可以推导出
Σ_{v ∈ V} f(s, v) = Σ_{v ∈ V} f(v, t)。
这个定理直观上很好理解:除了源点和汇点,流在途中不会凭空产生或消失,因此从源点出发的流量最终必然全部到达汇点。
割的概念及其容量 ✂️
为了分析并限制最大流的值,我们需要引入割 (Cut) 的概念。
一个 (s-t) 割 将顶点集 V 分割成两个不相交的子集 S 和 T,其中源点 s ∈ S,汇点 t ∈ T。
割的容量 (Capacity of a Cut) 定义为所有从 S 指向 T 的边的容量之和。
c(S, T) = Σ_{u ∈ S, v ∈ T} c(u, v)
穿过割的流量 (Flow across a Cut) 定义为所有从 S 指向 T 的边的流量之和(根据斜对称性,从 T 指向 S 的边贡献负流量)。
f(S, T) = Σ_{u ∈ S, v ∈ T} f(u, v)
一个关键引理是:对于任意一个 (s-t) 割 和任意一个流 f,穿过该割的流量恰好等于流的值 |f|。
即 f(S, T) = |f|。
结合容量限制,我们可以立即得到一个重要结论:对于任意流 f 和任意 (s-t) 割 (S, T),流的值不超过该割的容量。
|f| = f(S, T) ≤ c(S, T)
这意味着,网络中任意一个割的容量,都是最大流值的一个上界。特别地,容量最小的那个割(最小割)给出了最紧的上界之一。
残差网络与增广路径 🛠️
上一节我们知道了最小割限制了最大流。本节中我们来看看如何通过算法寻找最大流。核心工具是残差网络 (Residual Network)。
给定流网络 G 和一个流 f,其残差网络 G_f 定义了在现有流 f 的基础上,还能“推送”多少流量。
G_f的顶点集与原图G相同。- 对于
G中的每条边(u, v):- 如果
f(u, v) < c(u, v),则在G_f中创建一条边(u, v),其残差容量 (Residual Capacity) 为c_f(u, v) = c(u, v) - f(u, v)。这表示还可以沿原方向增加的流量。 - 如果
f(u, v) > 0,则在G_f中创建一条边(v, u),其残差容量为c_f(v, u) = f(u, v)。这表示可以沿反方向“退回”的流量,这为后续调整流(减少某条边上的流以为其他路径腾出空间)提供了可能。
- 如果
在残差网络 G_f 中,一条从源点 s 到汇点 t 的简单路径被称为增广路径 (Augmenting Path)。
增广路径的存在性至关重要:
- 如果在残差网络
G_f中存在一条增广路径,那么当前流f不是最大流。我们可以沿着这条路径推送流量(推送量为路径上所有边残差容量的最小值),从而增加总流量值。 - 如果在残差网络
G_f中不存在增广路径,那么当前流f就是最大流。
这就是 Ford-Fulkerson 方法 的核心思想:初始时设流 f 为零流,然后在残差网络中不断寻找增广路径并沿其增加流量,直到找不到增广路径为止。
总结 🎯
本节课中我们一起学习了最大流问题的基本框架。
- 我们首先定义了流网络和流,明确了容量限制、斜对称性和流量守恒三个核心性质。
- 我们证明了流的值等于进入汇点的流量。
- 我们引入了割的概念,并证明了对于任意流和任意割,流的值不超过割的容量,从而建立了最大流与最小割之间的联系。
- 最后,我们介绍了残差网络和增广路径的概念,它们是解决最大流问题的算法(如 Ford-Fulkerson 方法)的基础。通过不断在残差网络中寻找增广路径并增加流量,最终可以求得最大流。
下一次课程,我们将深入探讨最大流最小割定理的证明,分析 Ford-Fulkerson 算法的细节及其复杂度,并展示如何利用最大流算法解决像二分图匹配这样的实际问题。
L14:增量改进:匹配 🧩





在本节课中,我们将学习网络流算法的核心概念,特别是福特-富尔克森算法及其应用。我们将从回顾基本概念开始,然后深入算法的执行细节,并最终通过最大流最小割定理证明其正确性。此外,我们还将探讨算法的一个病态执行案例,并介绍其改进版本——埃德蒙兹-卡普算法。最后,我们将学习如何将网络流算法应用于一个实际问题:棒球淘汰赛的判定。
网络流基础回顾 📚
上一节我们介绍了网络流的基本框架,本节中我们来回顾一下关键概念,以确保我们理解一致。
一个流网络是一个有向图 G=(V, E),其中每条边 (u, v) 关联两个数字:流量 f(u, v) 和容量 c(u, v)。例如,(u, v): 3 表示流量为3,容量为5。
流必须满足两个约束:
- 容量约束:对于所有边
(u, v),有0 ≤ f(u, v) ≤ c(u, v)。 - 流量守恒:对于除源点
s和汇点t外的所有顶点v,流入v的总流量等于流出v的总流量。
我们的目标是找到从源点 s 到汇点 t 的最大流。流的值 |f| 定义为从源点 s 流出的总流量。
最大流最小割定理 🔗
上一节我们介绍了流和容量的概念,本节中我们来看看一个关键定理,它将最大流与网络中的“切割”联系起来。
一个割 (S, T) 是将顶点集 V 划分为两个集合 S 和 T,且满足 s ∈ S,t ∈ T。割的容量 c(S, T) 是从 S 指向 T 的所有边的容量之和。
最大流最小割定理指出,以下三个陈述是等价的:
- 存在一个割
(S, T)使得流f的值等于该割的容量,即|f| = c(S, T)。 - 流
f是一个最大流。 - 在残差图
G_f中,不存在从源点s到汇点t的路径。
这个定理是证明福特-富尔克森算法正确性的核心。特别地,当算法终止时(即残差图中没有增广路径),根据陈述3可推出陈述2,从而证明我们得到了最大流。
福特-富尔克森算法 🚀
理解了最大流最小割定理后,我们现在可以深入探讨福特-富尔克森算法本身。
该算法基于残差图 G_f 和增广路径的概念。残差图 G_f 与原图 G 顶点相同,但其边表示在原图中可以增加或减少流量的可能性。对于原图中的每条边 (u, v):
- 如果
f(u, v) < c(u, v),则在G_f中添加一条从u到v的边,其剩余容量为c_f(u, v) = c(u, v) - f(u, v)。 - 如果
f(u, v) > 0,则在G_f中添加一条从v到u的边,其剩余容量为c_f(v, u) = f(u, v)。
一条增广路径是残差图 G_f 中从 s 到 t 的一条简单路径。该路径的瓶颈容量是路径上所有边剩余容量的最小值。
以下是福特-富尔克森算法的伪代码:
initialize flow f to 0
while there exists an augmenting path p in the residual network G_f:
augment flow f along p by the bottleneck capacity c_f(p)
return f
算法的核心思想是:只要能在残差图中找到增广路径,就沿着该路径尽可能增加流量,直到无法找到为止。
算法病态案例与改进 💡
上一节我们看到了福特-富尔克森算法的基本流程,本节中我们来看看它的一个潜在缺陷以及如何改进。
福特-富尔克森算法本身没有指定如何寻找增广路径。如果选择不当,算法可能会进行极多次迭代。考虑一个简单的网络,其中边容量为巨大的整数(如10^9)。如果算法不幸地反复选择两条特定的、流量增减相互抵消的路径(例如 s->a->b->t 和 s->b->a->t),则每次只能增加1个单位的流量,导致迭代次数与容量值成正比,效率极低。
埃德蒙兹和卡普提出了一个关键改进:总是选择最短的增广路径(按边数计算),这可以通过在残差图上进行广度优先搜索来实现。这个策略被称为埃德蒙兹-卡普算法。
埃德蒙兹-卡普算法的重要性在于,它保证了增广次数为 O(VE)。由于每次广度优先搜索需要 O(E) 时间,因此算法的总时间复杂度为 O(VE^2)。这首次证明了最大流问题可以在多项式时间内解决,而不依赖于边容量的大小。
应用实例:棒球淘汰问题 ⚾
学习了网络流算法后,我们来看一个有趣的实际应用:判断一支棒球队在赛季中是否仍有理论可能赢得分区冠军(即未被“淘汰”)。
问题输入是各支队伍的当前胜场数、剩余比赛数,以及队伍之间尚未进行的比赛场次。我们需要判断,在剩余所有比赛结果最有利于目标队伍(比如底特律队)的情况下,它是否仍有可能获得最多胜场(或并列)。
我们可以将这个问题构建成一个最大流网络:
- 源点
s:连接一系列“比赛节点”,每条边的容量是两支特定队伍之间剩余的比赛场次。 - 比赛节点:连接到对应的“队伍节点”,容量为无穷大。
- 队伍节点:连接到汇点
t,每条边的容量是w5 + r5 - wi,其中w5和r5是目标队伍的当前胜场和剩余总场次,wi是队伍i的当前胜场。这个容量限制了队伍i在目标队伍全胜的前提下,最多还能赢多少场而不超过目标队伍。 - 计算该网络的最大流。如果最大流等于从源点
s出发的所有边容量之和(即所有剩余比赛都能被“分配”完),且不违反任何队伍节点的容量限制,则目标队伍未被淘汰。否则,它就被淘汰了。
这个构造巧妙地利用网络流来模拟“最佳情况”下比赛结果的分配,是网络流算法强大建模能力的一个经典例证。
本节课中我们一起学习了网络流的核心算法——福特-富尔克森算法。我们回顾了流网络的基本概念,学习了最大流最小割定理并用以证明算法正确性。我们探讨了算法在选择增广路径不当时可能出现的低效情况,并介绍了通过广度优先搜索选择最短增广路径的埃德蒙兹-卡普改进算法。最后,我们看到了如何将抽象的网络流算法应用于具体的棒球淘汰赛问题,展示了算法解决实际问题的强大能力。
R7:网络流量与匹配 💧





在本节课中,我们将要学习网络流算法及其应用。主要内容包括埃德蒙兹-卡普算法(Edmonds-Karp Algorithm)的详细分析,以及网络流在解决二分图匹配(Matching)和顶点覆盖(Vertex Cover)问题中的应用。
埃德蒙兹-卡普算法
上一节我们回顾了基础的福特-富尔克森算法(Ford-Fulkerson)。本节中我们来看看它的一个改进版本——埃德蒙兹-卡普算法。
埃德蒙兹-卡普算法是对福特-富尔克森算法的改进。其核心思想是:在残差图中寻找最短的增广路径(即边数最少的路径),而不是任意路径。这可以避免算法在某些情况下陷入低效循环。
以下是算法的主要步骤:
- 初始化流量
f为 0。 - 在残差图
G_f中,使用广度优先搜索(BFS)寻找从源点s到汇点t的最短路径p。 - 如果存在这样的路径
p,则沿该路径增加流量。增加的量c_f(p)是路径p上所有边的最小残差容量。
c_f(p) = min{ c_f(u, v) : (u, v) 在路径 p 上 } - 更新残差图,得到新的流量
f'。 - 重复步骤2-4,直到在残差图中找不到从
s到t的路径为止。
算法复杂度分析
我们需要证明埃德蒙兹-卡普算法的时间复杂度为 O(V * E^2)。
首先,分析单次迭代的复杂度:
- 步骤2(BFS寻找最短路径)的复杂度为 O(E)。
- 步骤3和4(增广流量并更新残差图)的复杂度为 O(V),因为路径长度不超过 V-1。
因此,单次迭代的复杂度为 O(E + V),可简化为 O(E)。
接下来,关键在于证明迭代次数是有限的。我们通过一个引理来证明。
引理:在算法执行过程中,对于任意顶点 v,从源点 s 到 v 在残差图中的最短路径长度 δ_f(v) 是非递减的。
证明概要(反证法):
- 假设在某次增广后,存在顶点
v使得δ_f'(v) < δ_f(v)。令v是满足此条件且δ_f'(v)最小的顶点。 - 设
u是增广后残差图G_f'中s到v的最短路径上v的直接前驱。因此有δ_f'(v) = δ_f'(u) + 1。 - 根据
v的最小性,有δ_f'(u) >= δ_f(u)。 - 现在考虑边
(u, v)。它出现在G_f'中,有两种可能:- 情况一:
(u, v)原本就存在于G_f中。那么δ_f(v) <= δ_f(u) + 1 <= δ_f'(u) + 1 = δ_f'(v),这与假设矛盾。 - 情况二:
(u, v)是在本次增广中新增的反向边。这意味着在增广路径p中,我们增加了边(v, u)的流量,从而在残差图中创建了反向边(u, v)。由于埃德蒙兹-卡普算法总是增广最短路径,因此δ_f(v) = δ_f(u) - 1。结合δ_f'(u) >= δ_f(u),可得δ_f'(v) = δ_f'(u) + 1 >= δ_f(u) + 1 = δ_f(v) + 2,这同样与δ_f'(v) < δ_f(v)的假设矛盾。
由此,引理得证。
- 情况一:
基于这个引理,我们可以证明每条边成为“关键边”(即增广路径上的瓶颈边)的次数至多为 O(V) 次。由于图中共有 E 条边,因此总的增广次数(即迭代次数)为 O(V * E)。
综合单次迭代复杂度 O(E),埃德蒙兹-卡普算法的总时间复杂度为 O(V * E^2)。
网络流应用:二分图匹配
理解了高效的网络流算法后,本节我们来看看它的一个重要应用:解决二分图最大匹配问题。
问题定义
给定一个二分图 G = (L ∪ R, E),其中 L 和 R 是互不相交的顶点集合,所有边都连接 L 中的一个顶点和 R 中的一个顶点。一个匹配 M 是边集 E 的一个子集,其中任意两条边都没有公共顶点。最大匹配是包含边数最多的匹配。
转化为最大流问题
我们可以将二分图最大匹配问题转化为一个最大流问题:
- 构造一个流网络:
- 添加一个源点
s,并从s向L中的每个顶点连接一条容量为 1 的边。 - 保持原二分图
G中L到R的所有边,并将容量设为 1。 - 添加一个汇点
t,并从R中的每个顶点向t连接一条容量为 1 的边。
- 添加一个源点
- 在这个流网络上求解从
s到t的最大流。 - 最大流的值就等于原二分图的最大匹配数。流量为1的
L->R边就构成了一个最大匹配方案。
原理:容量为1的边保证了每个 L 中的点(人)最多输出1单位流量(承担一项任务),每个 R 中的点(任务)最多接收1单位流量(被一个人承担)。这正好符合匹配的定义。
网络流应用:二分图最小顶点覆盖
与匹配紧密相关的另一个问题是最小顶点覆盖。
问题定义
给定一个无向图 G = (V, E),一个顶点覆盖是一个顶点子集 C ⊆ V,使得图中的每条边都至少有一个端点属于 C。最小顶点覆盖是顶点数最少的覆盖。
在二分图中,König定理指出:最大匹配的边数等于最小顶点覆盖的顶点数。
利用最大流求解最小顶点覆盖
根据上述转化,我们可以在流网络 N 上运行最大流算法(如埃德蒙兹-卡普算法)。在得到最大流后:
- 在残差图
G_f中,从源点s出发,寻找所有能通过残差容量大于0的边到达的顶点。 - 令这个顶点集合为
S。 - 那么,最小顶点覆盖
C可以通过以下方式得到:
C = (L \ S) ∪ (R ∩ S)
即,包含所有在L中但无法从s到达的点,以及所有在R中但可以从s到达的点。
这个构造的正确性基于最大流最小割定理。(S, T) 实际上定义了网络 N 的一个最小割,而上述方法构造出的 C 则对应于原二分图的一个最小顶点覆盖,其大小等于最小割的容量,即最大流的值(最大匹配数)。
总结
本节课中我们一起学习了:
- 埃德蒙兹-卡普算法:通过总是在残差图中寻找最短增广路径,将福特-富尔克森算法的最坏情况时间复杂度优化到 O(V * E^2)。我们详细分析了其正确性证明和复杂度证明的关键引理。
- 网络流的应用:
- 二分图最大匹配:通过构造一个特殊的流网络(源点连接左部,容量1;原边容量1;右部连接汇点,容量1),将匹配问题转化为最大流问题。
- 二分图最小顶点覆盖:利用求解最大流后得到的残差图,可以构造出一个最小顶点覆盖,其大小等于最大匹配数(König定理)。
这些内容展示了网络流算法不仅本身是一个强大的工具,还能作为解决图论中其他经典问题的通用框架。
L15:线性规划:LP、约简、单纯形 🧮





在本节课中,我们将学习线性规划(Linear Programming, LP)这一强大的通用优化技术。我们将了解其基本概念、如何将实际问题建模为线性规划,并初步探索求解线性规划的单纯形算法。
概述
线性规划是一种用于解决优化问题的数学方法,其目标是在一组线性约束条件下,最大化或最小化一个线性目标函数。它广泛应用于资源分配、生产计划、网络流等众多领域。本节课我们将学习线性规划的标准形式、如何将问题约简为线性规划,并介绍经典的单纯形算法。
线性规划简介与示例
上一节我们概述了线性规划的强大通用性。本节中,我们通过一个具体的“政治竞选”例子,来看看如何将一个实际问题表述为线性规划。
假设你需要通过广告宣传来赢得选举,目标是花费最少的资金。你有四种不同的政策议题(修路、枪支管制、农业补贴、汽油税)需要向三类不同的人口群体(城市、郊区、农村)进行广告宣传。每花费1美元在不同议题上,对不同群体产生的选票影响(可能为正或负)以及各群体的人口基数都是已知的。
我们的目标是:确定在每个议题上投入多少资金(变量 x1, x2, x3, x4),以最小化总花费,同时确保在每个群体中获得的选票数超过该群体总票数的一半(即赢得多数)。
以下是建模过程:
- 变量:设 x1, x2, x3, x4 分别代表在四个议题上投入的资金(美元)。
- 目标函数(最小化):总花费
Minimize: x1 + x2 + x3 + x4。 - 约束条件(确保每个群体获胜):根据表格数据,每个群体获得的选票必须超过其总票数的一半。
- 城市群体:
-2x1 + 8x2 + 0x3 + 10x4 >= 50,000 - 郊区群体:
5x1 + 2x2 + 0x3 + 0x4 >= 100,000 - 农村群体:
3x1 - 5x2 + 10x3 + 0x4 >= 25,000
- 城市群体:
- 非负约束:
x1, x2, x3, x4 >= 0
这就构成了我们的第一个线性规划模型。求解这个模型,就能找到最优的资金分配方案。
线性规划的标准形式与转换
上一节我们通过一个例子建立了线性规划模型。为了使用通用的求解算法,我们需要将线性规划转化为标准形式。
线性规划的标准形式定义如下:
- 目标:最大化。
- 约束:所有约束都是“小于等于”形式。
- 变量:所有变量非负。
用矩阵和向量表示为:
Maximize: c^T * x
Subject to: A * x <= b
x >= 0
其中,x 是变量向量,c 是目标函数系数向量,A 是约束系数矩阵,b 是约束右侧常数向量。
实际建模中,问题可能不符合标准形式。以下是常见的转换技巧:
以下是几种常见非标准形式的转换方法:
- 最小化转最大化:将目标函数乘以 -1。
Minimize c^T*x等价于Maximize -c^T*x。 - 变量无符号限制:若变量
xj可取任意值,可将其替换为两个非负变量的差:xj = xj' - xj'',其中xj', xj'' >= 0。 - 等式约束:等式
a^T*x = b等价于同时满足a^T*x <= b和-a^T*x <= -b。 - “大于等于”约束:约束
a^T*x >= b等价于-a^T*x <= -b。
通过以上转换,任何线性规划问题都可以化为标准形式,从而使用标准求解器。
对偶性与最优性证明
上一节我们学习了线性规划的标准形式。本节中,我们探讨一个重要的概念——对偶性,它能为我们提供最优解的“证书”。
对于任何一个线性规划(称为原问题):
Maximize: c^T * x
Subject to: A * x <= b
x >= 0
都存在一个与之关联的对偶问题:
Minimize: b^T * y
Subject to: A^T * y >= c
y >= 0
其中 y 是对偶变量向量。
对偶性理论指出,原问题的最优解值等于对偶问题的最优解值。这意味着,如果我们找到了原问题的一个可行解 x* 和对偶问题的一个可行解 y*,并且满足 c^T*x* = b^T*y*,那么 x* 就是原问题的最优解。
回到政治竞选的例子,最优解的总花费约为 $21,000。我们可以通过构造一组特殊的乘数(即对偶变量的值)来“证明”这个值是最优的。具体方法是:将原问题的三个约束分别乘以这组乘数后相加,可以得到一个不等式,其左边是总花费 x1+x2+x3+x4,右边是一个常数(即 $21,000)。这个不等式表明,任何可行解的总花费都不可能低于这个常数,从而证明了 $21,000 是最优值。这个构造过程本质上就是找到了对偶问题的一个可行解。
问题约简:将经典算法问题转化为LP
上一节我们看到了对偶性的理论力量。本节中,我们来看看线性规划的实践力量——如何将我们已经熟悉的算法问题“约简”为线性规划问题。
最大流问题
最大流问题可以自然地表述为线性规划。
- 变量:
f(u,v)表示边(u,v)上的流量。 - 目标:最大化从源点
s流出的总流量。 - 约束:
- 容量约束:
f(u,v) <= c(u,v)。 - 流量守恒:对于非源非汇的顶点
v,流入等于流出。 - 斜对称:
f(u,v) = -f(v,u)。
所有这些约束都是线性的。更强大的是,对于多商品最大流(多种流共享网络容量)等更复杂的问题,只需添加如f1(u,v) + f2(u,v) <= c(u,v)这样的线性约束即可建模,而专用算法可能更复杂或不存在。
- 容量约束:
单源最短路径问题
将最短路径问题转化为线性规划需要一些技巧。
- 变量:
d(v)表示从源点s到顶点v的距离。 - 约束:
- 三角不等式:对于每条边
(u,v),d(v) <= d(u) + w(u,v)。 - 源点距离:
d(s) = 0。
- 三角不等式:对于每条边
- 目标:最大化
d(t)(对于特定终点t)或Σ d(v)。
这里的关键洞察是:三角不等式约束是“小于等于”,为了得到最短路径,我们需要最大化目标函数,以迫使至少一条不等式取等号(即达到最短路径的边界)。通过几个简单例子的验证,可以理解这种最大化目标如何产生最短距离。
这些约简展示了线性规划作为“通用优化引擎”的威力。许多组合优化问题都可以通过巧妙的建模,转化为线性规划来求解。
单纯形算法简介
前面我们学习了如何建模。本节中,我们初步探索最著名的线性规划求解算法——单纯形法。它是一种迭代算法,虽然最坏情况下是指数时间复杂度,但在实际应用中通常非常高效。
单纯形法在松弛形式上操作。松弛形式将标准形式中的不等式通过引入松弛变量变为等式。例如,约束 x1 + 2x2 <= 4 变为 x1 + 2x2 + s = 4,其中 s >= 0 是松弛变量。
算法从一个基本可行解开始(通常将所有原始变量设为0,松弛变量等于约束右侧常数)。然后迭代进行以下步骤:
- 检查最优性:如果当前目标函数中所有非基本变量的系数都为非正(最大化问题),则当前解最优,算法停止。
- 选择进基变量:选择一个在目标函数中系数为正的非基本变量(因为它增加能提高目标值)。
- 选择离基变量:增加进基变量时,会减少某些基本变量的值。选择最先降为0的基本变量作为离基变量(以保持可行性)。
- 旋转:通过高斯消元法,交换进基变量和离基变量的角色(进基变量变为基本变量,离基变量变为非基本变量),得到一个新的等价松弛形式及其对应的基本可行解。
我们通过一个简单例子演示了一次旋转操作:
- 初始松弛形式:
z = 3x1 + x2 + 2x3,约束为x1 + x2 + 3x3 + x4 = 30,2x1 + 2x2 + 5x3 + x5 = 24,4x1 + x2 + 2x3 + x6 = 36,所有变量非负。 - 初始基本解:
(x1,x2,x3) = (0,0,0),(x4,x5,x6) = (30,24,36),目标值z=0。 - 选择
x1为进基变量(系数为正)。增加x1受第三个约束限制最紧(x6最先变为0),故选择x6为离基变量。 - 执行旋转(用
x6表示x1,并代入其他等式),得到新的松弛形式。新的基本解变为(x1,x2,x3) = (9,0,0),目标值提升至z=27。
单纯形法通过不断重复这种旋转操作,在可行解空间的顶点间移动,逐步提升目标函数值,直至找到最优解。
总结
本节课中我们一起学习了线性规划的核心内容。我们首先通过一个生动的例子学习了如何将实际问题建模为线性规划。接着,我们定义了线性规划的标准形式,并掌握了将各种形式转化为标准形式的方法。我们探讨了对偶性的重要概念,它提供了验证最优解的有力工具。然后,我们看到了线性规划的强大之处,能够将最大流、最短路径等经典算法问题通过约简来求解。最后,我们初步了解了经典的单纯形算法的基本思想和工作流程,它通过迭代地在可行域的顶点间移动来寻找最优解。线性规划是算法工具箱中一个极其强大的通用优化工具。
L16:P、NP、NP-完备性、归约


在本节课中,我们将学习计算复杂性理论中的核心概念:P类、NP类、NP-完备性以及归约。我们将通过一系列有趣的例子,从超级马里奥兄弟到拼图游戏,来理解如何证明一个问题是NP-完备的。课程的核心在于理解“归约”——将一个问题的输入转换为另一个问题的等价输入,从而证明问题的计算难度。
概述:P与NP
首先,我们回顾P类和NP类的定义。
P类包含所有可以在多项式时间内解决的问题。这里的“多项式时间”指的是运行时间是输入规模n的某个常数次幂,例如n²或n³。这是算法课程中我们主要关注的问题类型。
NP类则包含所有可以在非确定性多项式时间内解决的问题。非确定性计算模型允许计算机“幸运地”在常数时间内猜出正确的解。更实际的理解是,如果一个问题的答案是“是”,那么存在一个多项式大小的“证书”(或证明),并且存在一个多项式时间的验证算法来检查这个证书是否正确。因此,NP问题偏向于“是”的答案。
核心概念:NP-完备性
一个问题是NP-完备的,需要满足两个条件:
- 它属于NP类。
- 它是NP-难的。
NP-难意味着这个问题至少和NP类中的每一个问题一样难。如果一个问题既是NP-难的又属于NP类,那么它就是NP-完备的。
为了证明问题X是NP-难的,我们需要证明NP类中的任意问题Y都可以在多项式时间内归约到X。但实践中,我们不需要对每个NP问题都这样做。我们只需从一个已知的NP-完备问题(如3-SAT)出发,证明它可以归约到我们想证明的问题X。因为根据归约的传递性,NP中的所有问题都可以先归约到已知的NP-完备问题,再归约到X。
上一节我们介绍了P、NP和NP-完备性的基本定义,本节中我们来看看如何通过具体的“归约”来证明一个问题是NP-完备的。
归约实例:从3-SAT到超级马里奥兄弟
我们将展示如何将著名的NP-完备问题3-SAT归约到游戏“超级马里奥兄弟”的关卡通关问题。
3-SAT问题:给定一个由多个子句构成的布尔公式,每个子句是三个文字(变量或其否定)的逻辑或(OR)。问题是,是否存在对变量的真值赋值,使得整个公式为真(即可满足)。
超级马里奥兄弟问题:给定一个关卡(广义为单屏无滚动),判断马里奥是否能通关。
以下是构建归约的步骤:
- 变量选择小工具:对于公式中的每个变量,我们构建一个“变量小工具”。马里奥进入后,必须选择向左或向右落下,这分别代表将该变量赋值为“真”或“假”。一旦落下,无法返回,代表赋值不可更改。
- 子句满足小工具:对于每个子句,我们构建一个“子句小工具”。它包含三个问号砖块,分别对应子句中的三个文字。只有当马里奥之前选择的变量赋值使得该文字为“真”时,他才能在对应的路径上撞击砖块,产生一颗“无敌星”。
- 关卡遍历与验证:在设置完所有变量后,马里奥必须遍历所有子句小工具。每个小工具上方有一排火焰障碍。只有持有“无敌星”(即该子句被满足)时,马里奥才能安全通过。因此,马里奥能通关当且仅当存在一个变量赋值满足所有子句(即3-SAT公式可满足)。
- 交叉小工具:在连接变量与子句的“电线”交叉时,需要特殊的“交叉小工具”来确保路径不会意外连通,保证归约的正确性。
通过这个构造,我们将一个3-SAT公式转化成了一个等价的超级马里奥兄弟关卡。因此,超级马里奥兄弟的通关问题是NP-难的。由于给定一个通关路径(即一系列操作)可以在多项式时间内验证,所以它也在NP中,从而是NP-完备的。
更多NP-完备问题
接下来,我们利用归约,展示一系列其他有趣的问题也是NP-完备的。
三维匹配
问题描述:有三个互不相交的集合X、Y、Z,每个集合有n个元素。给定一个允许的三元组集合T ⊆ X × Y × Z。问题是能否从T中选出n个不相交的三元组,覆盖X、Y、Z中的所有元素。
证明思路:可以从3-SAT归约到三维匹配。我们为每个变量构造一个“齿轮”状的小工具,它有两种方式覆盖其内部点,分别代表“真”和“假”赋值。为每个子句构造的小工具,则需要至少一个来自变量小工具的“空闲”点才能被覆盖,这对应子句需要至少一个文字为真。通过精心设计点与三元组的对应关系,可以证明三维匹配是NP-完备的。
子集和
问题描述:给定一个整数集合S和一个目标整数t,问是否存在S的一个子集,其元素之和恰好等于t。
证明思路:可以从三维匹配归约到子集和。我们将每个允许的三元组编码成一个很大的整数(在某个大基数B下表示),这个整数在代表该三元组三个成分的位置上为1,其余为0。目标数t则设置为在所有位置上都是1的数。这样,选择一组和为t的数,就等价于选择一组覆盖所有元素且不冲突的三元组。由于这里构造的数字值可能非常大(与输入规模成指数关系),我们称子集和为弱NP-难问题。
分区
问题描述:给定一个正整数集合A,问能否将A划分成两个子集,使得两个子集的元素之和相等。
证明思路:子集和可以归约到分区。给定子集和问题实例(集合S和目标t),我们构造一个新的集合A‘ = S ∪ {σ + t, 2σ - t},其中σ是S中所有元素之和。可以证明,A‘能被平分当且仅当S中存在子集之和为t。因此,分区也是弱NP-完备的。
矩形填充与拼图
矩形填充问题:给定若干个小矩形和一个目标大矩形,问能否将所有小矩形不重叠地放入大矩形中。
证明思路:可以从(强NP-完备的)四划分问题归约到矩形填充。我们将每个整数表示为一组特定长宽的小矩形,目标矩形被划分为四个区域。成功填充等价于将整数集合划分成四个和相等的子集。
拼图问题:给定一堆边缘有特定凹凸形状的拼图块和一个目标框,问能否将它们拼合填满目标框。
证明思路:可以从矩形填充归约到拼图。通过为矩形填充问题中的每个矩形设计独特的边缘形状,使得它们只能按预定方式拼接,从而将矩形填充问题转化为拼图问题。由于归约过程中产生的拼图块数量是多项式规模的,这证明了拼图是(强)NP-完备的。
总结
本节课中我们一起学习了计算复杂性理论的核心内容。我们定义了P类(多项式时间可解)和NP类(多项式时间可验证)。我们深入探讨了NP-完备性的概念:一个问题如果属于NP且是NP-难的,那么它就是NP-完备的。证明NP-难度的关键工具是“归约”——将一个已知的NP-完备问题(如3-SAT)转化为目标问题。
我们通过一系列生动的例子实践了归约:
- 将3-SAT归约到超级马里奥兄弟关卡问题。
- 将3-SAT归约到三维匹配。
- 将三维匹配归约到子集和(弱NP-难)。
- 将子集和归约到分区(弱NP-难)。
- 将(强NP-难的)四划分问题归约到矩形填充,再归约到拼图问题。

这些证明展示了NP-完备性理论的强大与优美,它让我们能够理解从逻辑谜题到经典游戏等众多看似不同问题的内在计算难度本质。掌握归约的思想,是判断问题复杂性和设计高效近似算法的基础。
R16:NP完全问题

在本节课中,我们将学习NP完全问题的概念,并通过几个具体的归约示例,来理解如何证明一个问题是NP难的。我们将从哈密顿回路问题出发,逐步归约到哈密顿路径、独立集以及最大2-SAT问题。
P与NP概念回顾
上一节我们介绍了P和NP的基本概念,本节中我们来看看它们的精确定义。
P类问题是指那些可以在多项式时间内被确定性图灵机解决的决定性问题。形式化地说,对于一个决策问题,如果存在一个算法A,对于任意输入x,都能在多项式时间内输出答案0或1,则该问题属于P。
NP类问题是指那些可以在多项式时间内验证其解的正确性的问题。具体来说,给定一个输入x、一个证书(即一个可能的解)以及一个答案(0或1),存在一个多项式时间的验证算法,可以确认该答案是否正确。
显然,任何能在多项式时间内解决的问题,其解也必然能在多项式时间内被验证,因此P是NP的子集。
NP难问题与归约
NP难问题是指那些至少和NP中的任何问题一样难的问题。证明一个问题B是NP难的标准方法是进行“归约”。
归约的核心思想是:如果我们已知问题A是NP难的,并且能在多项式时间内将A的任意实例转化为问题B的一个实例,同时保证两个实例的答案一致,那么我们就可以说问题B也是NP难的。因为如果B能在多项式时间内解决,那么A也能在多项式时间内解决,这与A是NP难的事实矛盾。
用公式表示,即:若存在多项式时间归约函数R,使得对于A的任意实例x,满足 A(x) = B(R(x)),且已知A是NP难问题,则可推出B也是NP难问题。
从哈密顿回路归约到哈密顿路径
首先,我们来看一个相对简单的归约:从已知的NP难问题——哈密顿回路问题,归约到哈密顿路径问题。
哈密顿回路是指在给定的无向图中,找到一个经过每个顶点恰好一次并最终回到起点的环。哈密顿路径则只要求经过每个顶点恰好一次,但不要求回到起点。
以下是证明哈密顿路径是NP难的步骤:
-
证明哈密顿路径属于NP:这很简单。如果某人声称找到了一个哈密顿路径,他只需提供这个路径序列作为证书。我们可以在多项式时间内(例如线性时间)遍历该路径,检查它是否访问了每个顶点恰好一次,且相邻顶点间均有边相连,从而验证答案的正确性。
-
通过归约证明其是NP难的:我们已知哈密顿回路是NP难的。现在,我们构造一个从哈密顿回路实例到哈密顿路径实例的多项式时间归约。
- 归约方法:给定一个哈密顿回路问题的输入图G,我们通过“分裂”任意一个顶点v来构造新图G‘。具体做法是:将顶点v拆分为两个新的顶点v‘和v’‘。将原来所有指向v的边(入边)都指向v‘,将所有从v出发的边(出边)都改为从v’‘出发。
- 论证等价性:
- 如果原图G存在哈密顿回路,则该回路必然经过顶点v。将回路在v处“切断”,就得到了一条从v‘’开始,到v‘结束的哈密顿路径。因此,G‘存在哈密顿路径。
- 反之,如果新图G‘存在哈密顿路径,由于v’‘只有出边没有入边,它必须是路径的起点;同理,v’必须是路径的终点。将这条路径的起点v‘’和终点v‘重新合并为同一个顶点v,就得到了原图G中的一个哈密顿回路。
- 这个分裂顶点的操作可以在多项式时间内完成(例如,与顶点度数呈线性关系)。因此,我们完成了一个多项式时间归约,证明了哈密顿路径问题也是NP难的。
从团问题归约到独立集问题
接下来,我们看一个基于“互补”思想的归约:从团问题归约到独立集问题。
团是指图中的一个顶点子集,使得该子集中任意两个顶点之间都有边相连(即该子集构成的子图是完全图)。独立集则是指一个顶点子集,其中任意两个顶点之间都没有边相连。
以下是证明独立集是NP难的步骤:
- 证明独立集属于NP:证书就是给出的独立集顶点列表。验证算法只需检查列表中每一对顶点之间是否都没有边相连,这可以在多项式时间(如O(n²))内完成。

- 通过归约证明其是NP难的:我们已知团问题是NP难的。现在构造归约。
- 归约方法:给定一个团问题的输入图G,我们构造其补图G‘作为独立集问题的输入。补图G’拥有与G完全相同的顶点集,但边集恰好相反:在G中相连的顶点在G‘中不相连,在G中不相连的顶点在G’中相连。
- 论证等价性:图G中存在一个大小为k的团,当且仅当在其补图G‘中存在一个大小为k的独立集。因为一个顶点子集在G中两两相连(构成团),等价于该子集在G’中两两不相连(构成独立集)。
- 构造补图只需遍历原图的邻接关系,是多项式时间操作。因此,独立集问题被证明是NP难的。
从团问题归约到最大2-SAT问题
最后,我们看一个更复杂的归约:从团问题归约到最大2-SAT问题。最大2-SAT问题是:给定一组由两个文字(变量或其非)构成的子句,是否存在一种对变量的赋值,使得至少满足K个子句。
以下是证明最大2-SAT是NP难的步骤:

- 证明最大2-SAT属于NP:证书就是给出的变量赋值方案。验证算法只需将赋值代入每个子句,计算满足的子句数量是否至少为K,这显然是多项式时间的。

- 通过归约证明其是NP难的:我们已知“是否存在大小至少为K的团”这个问题是NP难的。我们将构造一个归约,将团问题实例转化为一个最大2-SAT实例。
- 变量设置:对于团问题输入图G的每个顶点i,我们创建一个布尔变量x_i。另外,创建一个辅助变量z。
- 子句构造:我们构造三类子句。
- 对于图中每一对没有边相连的顶点(i, j),添加子句:(¬x_i ∨ ¬x_j)。这个子句的含义是,这两个变量不能同时为真(即这两个顶点不能同时被选入团中)。
- 对于每个顶点i,添加子句:(x_i ∨ z)。
- 对于每个顶点i,添加子句:(x_i ∨ ¬z)。
- 阈值K‘设置:设|V|为顶点数,|Ē|为补图的边数(即原图中不存在的边的数量)。我们设置最大2-SAT的阈值K‘ = |Ē| + |V| + K。
- 论证等价性(方向一):如果图G有一个大小至少为K的团S。我们构造赋值:对于属于S的顶点i,设x_i = 1;否则设x_i = 0。设z = 1。计算满足的子句数:
- 第一类子句:因为S是团,其中任意两点在原图中都有边,所以对应的“无边”子句不存在。对于其他顶点对,根据赋值(至少有一个为0),子句(¬x_i ∨ ¬x_j)恒为真。故所有|Ē|个此类子句均满足。
- 第二类子句:因为z=1,所有|V|个子句(x_i ∨ z)恒为真。
- 第三类子句:只有当x_i=1时,(x_i ∨ ¬z)才为真。满足的子句数等于团的大小|S| ≥ K。
因此,总满足子句数 ≥ |Ē| + |V| + K = K‘。
- 论证等价性(方向二):如果最大2-SAT实例有一个赋值,使得满足至少K‘个子句。我们定义集合S = {顶点i | x_i = 1}。S可能不是一个团。我们通过以下“修复”过程,在不减少满足子句总数的前提下,将S变为一个团:
- 如果S中存在两个顶点i, j之间没有边(即违反了团的条件),那么子句(¬x_i ∨ ¬x_j)存在于第一类子句中。在当前赋值下(x_i = x_j = 1),该子句取值为假。
- 现在,考虑将x_i的值从1改为0。这会导致:
- 第三类子句(x_i ∨ ¬z)从真变为假(损失1个满足的子句)。
- 第一类子句(¬x_i ∨ ¬x_j)从假变为真(增加1个满足的子句)。
- 其他包含x_i的子句,由于x_i变为0,¬x_i变为1,只会增加或保持满足的数量,不会减少。
- 因此,这次修改不会减少总的满足子句数。我们不断重复此过程,移除S中所有导致“无边”的顶点对中的一个,直到S中任意两点都有边相连,即S成为一个团。设最终团的大小为|S|。
- 根据最终赋值,满足的子句总数可以计算为:|Ē|(第一类全满足)+ |V|(第二类全满足)+ |S|(第三类满足数)。已知这个总数 ≥ K‘ = |Ē| + |V| + K。两边消去|Ē|和|V|,得到|S| ≥ K。因此,我们得到了原图G中一个大小至少为K的团。
- 这个归约过程中,构造的子句数量是多项式级别的(O(|V|²)),因此是多项式时间归约。

总结


本节课中我们一起学习了NP完全问题的核心证明技术——归约。我们通过三个具体的例子:
- 从哈密顿回路归约到哈密顿路径,展示了如何通过修改问题实例的结构(分裂顶点)来建立等价性。
- 从团问题归约到独立集问题,展示了利用互补图这一巧妙变换进行归约。
- 从团问题归约到最大2-SAT问题,展示了一个更复杂的构造性归约,其中涉及变量设置、多类子句构造以及一个维护满足子句数不变的“修复”论证。

这些例子阐明了证明一个问题是NP难的关键:找到一个已知的NP难问题A,设计一个多项式时间的转换函数,将A的任意实例映射到问题B的一个实例,并证明两个实例的答案一致性。掌握归约方法,是理解计算复杂性理论中问题难度分类的基础。
L17:复杂性:近似算法 🧩





在本节课中,我们将学习近似算法的基本概念。当面对NP完全或NP难问题时,我们无法在多项式时间内找到精确的最优解。近似算法提供了一种实用的策略:它能在多项式时间内找到一个解,并保证该解的成本与最优解的成本之比不会超过某个特定的因子(近似比)。我们将通过几个经典问题来理解如何设计和分析近似算法。
什么是近似算法? 📊
上一节我们介绍了NP完全问题的挑战,本节中我们来看看一种应对策略:近似算法。
一种近似算法,对于规模为 n 的问题,其近似比可以参数化。我们定义一个近似比 ρ(n)。对于最小化问题,如果对于任何输入,算法产生的解的成本 C 满足 C / C_opt ≤ ρ(n),则该算法是一个 ρ(n) 近似算法。对于最大化问题,条件变为 C_opt / C ≤ ρ(n)。ρ(n) 可能是一个常数,也可能是 n 的函数(例如 log n)。
近似方案是更强大的工具。它接受一个额外的参数 ε(epsilon),允许我们通过投入更多计算时间来获得更接近最优的解(即近似比 1 + ε)。如果运行时间是 n 和 1/ε 的多项式,则称为完全多项式时间近似方案。
顶点覆盖问题 🎯
顶点覆盖问题要求找到一个最小的顶点集合,使得图中的每条边都至少有一个端点在这个集合中。这是一个NP完全问题。
一个直观的启发式算法是不断选择度数最大的顶点。然而,这种策略在最坏情况下可能产生近似比为 O(log n) 的解,其性能随问题规模增长。
以下是另一种简单的近似算法,它能保证在2倍最优解以内:
- 初始化边集
E' = E,顶点覆盖集C = ∅。 - 当
E'不为空时:- 从
E'中任意选择一条边(u, v)。 - 将
u和v加入C。 - 从
E'中删除所有与u或v相关联的边。
- 从
- 返回
C。
证明(2-近似):
设算法选取的边集为 A。由于算法选取的边没有公共端点(因为一旦选取一条边,就移除了其关联的所有边),因此覆盖这些边至少需要 |A| 个顶点。最优解 C_opt 必须覆盖所有边,包括 A 中的边,因此 |C_opt| ≥ |A|。算法最终选取的顶点数为 2|A|,所以有 |C| = 2|A| ≤ 2|C_opt|。证毕。
集合覆盖问题 📚
集合覆盖问题描述如下:给定一个全集 X 和 X 的一组子集 S1, S2, ..., Sm,目标是选择数量最少的子集,使得它们的并集等于 X。
一个自然的贪心启发式是迭代地选择能覆盖最多尚未被覆盖元素的子集。
以下是该贪心算法的近似比分析:
设最优解使用了 t 个子集。在算法的任意第 k 步,设剩余未被覆盖的元素集合为 X_k。由于 t 个子集能覆盖 X_k,根据鸽巢原理,其中至少有一个子集覆盖不少于 |X_k| / t 个元素。贪心算法会选择覆盖最多未覆盖元素的子集,因此它每一步至少能覆盖 |X_k| / t 个元素。这导致剩余集合大小至少按因子 (1 - 1/t) 减少。通过分析可知,算法选取的子集数量 k 满足 k/t ≤ ln|X| + 1。因此,该贪心算法是一个 (ln n + 1) 近似算法。
划分问题 ⚖️
划分问题要求将一个数字集合分成两个子集,使得两个子集的和尽可能接近。形式化地说,给定数字 S = {s1, s2, ..., sn},找到划分方案最小化 max(∑_{i∈A} si, ∑_{i∈B} si),其中 A ∪ B = S 且 A ∩ B = ∅。
一个平凡的2-近似算法是将所有数字放入一个集合,另一个集合为空。但我们可以做得更好。下面描述一个多项式时间近似方案:
该方案分为两个阶段,参数 m 与期望的精度 ε 相关(例如,令 m = 1/ε - 1)。
- 精确求解阶段:对前
m个最大的数字,通过穷举搜索所有2^m种划分方式,找到其最优划分(A', B')。这需要O(2^m)时间,当m较小时可行。 - 贪心分配阶段:将剩余的数字(从第
m+1个到第n个)按顺序依次添加到当前总和较小的那个子集中(即若sum(A) ≤ sum(B),则将当前数字加入A,否则加入B)。
算法思路:通过处理最大的 m 个数字来“播种”一个较好的初始解,然后以贪心方式处理较小的数字。分析表明,最终解的和 WA 满足 WA / L ≤ 1 + 1/(m+1) = 1 + ε,其中 L 是总和的二分之一(即最优解的下界)。因此,这是一个 (1+ε) 近似方案。由于运行时间在 n 上是多项式,但在 1/ε 上是指数(来自第一阶段),故它是一个多项式时间近似方案,而非完全多项式时间近似方案。
本节课中我们一起学习了近似算法的核心思想。我们看到了如何为顶点覆盖问题设计简单的2-近似算法,分析了集合覆盖问题的对数近似贪心算法,并探讨了针对划分问题的多项式时间近似方案。这些技术为我们处理实际中的NP难问题提供了有力的理论工具和实践指导。
L18:复杂性:固定参数算法 🎯





在本节课中,我们将学习一种处理NP难问题的新策略——固定参数算法。与近似算法不同,我们的目标是获得精确解,但允许运行时间在某个特定参数上呈指数级,而在问题整体规模上保持多项式级。我们将通过经典的“顶点覆盖”问题来理解这一概念。
概述
当我们面对一个NP难问题时,通常无法在多项式时间内找到精确解。固定参数算法提供了一种折中方案:算法的运行时间可以表示为 f(k) · poly(n),其中 k 是问题的一个参数(例如,解的大小),n 是输入的整体规模。只要参数 k 很小,即使问题整体规模很大,算法也能高效运行。
什么是固定参数可处理性?
上一节我们介绍了NP难问题的挑战,本节中我们来看看如何通过参数化来精确地解决它们。
一个参数化问题包含一个常规输入 x 和一个参数 k。我们的目标是设计一个算法,其运行时间主要依赖于参数 k,而对输入大小 n 的依赖是多项式级的。
定义:如果一个参数化问题可以在 O(f(k) · n^c) 时间内解决,其中 f 是 k 的任意函数(通常是指数函数),c 是一个与 k 无关的常数,那么该问题被称为固定参数可处理。
这个定义的关键在于,指数部分 f(k) 仅依赖于参数 k,而不依赖于输入大小 n。因此,对于固定的 k,算法在 n 上是多项式时间的。
顶点覆盖的参数化
让我们以顶点覆盖问题为例。给定一个图 G=(V, E) 和一个整数 k,我们需要判断是否存在一个大小不超过 k 的顶点覆盖。
我们将此参数化问题记为 k-顶点覆盖,其中参数 k 就是我们寻找的覆盖集的大小上限。
朴素算法及其局限性
首先,我们考虑一个简单的“暴力枚举”算法。
算法思路:枚举图中所有大小为 k 的顶点子集,检查每个子集是否是顶点覆盖。
以下是该算法的伪代码描述:
for each subset S of vertices with |S| = k:
mark all edges incident to vertices in S as covered
if all edges are covered:
return True
return False
运行时间分析:需要检查 C(|V|, k) 个子集,每个检查需要 O(|E|) 时间。因此,总运行时间为 O(|E| · |V|^k)。
这个运行时间是指数级的,并且指数 k 出现在底数 |V| 的指数部分。根据我们的定义,这不是一个良好的固定参数算法,因为当 k 增大时,运行时间会急剧增加。
有界搜索树算法
上一节我们看到了朴素算法的低效性,本节中我们来看看一种更聪明的固定参数算法——有界搜索树。
算法思路:利用顶点覆盖的性质进行递归猜测。对于任意一条边 (u, v),我们知道在最优覆盖中,u 和 v 至少有一个必须被选中。因此,我们可以递归地尝试两种可能性。
以下是算法的递归描述:
- 如果
k = 0且图中没有边,则返回True;如果k = 0但图中有边,则返回False。 - 选择任意一条边
(u, v)。 - 递归调用一:假设
u在覆盖中。将u及其关联边从图中删除,并将k减 1,在新图上递归求解。 - 递归调用二:假设
v在覆盖中。将v及其关联边从图中删除,并将k减 1,在新图上递归求解。 - 如果任一递归调用返回
True,则整个问题答案为True,否则为False。
运行时间分析:每次递归调用将 k 减少 1,并产生两个分支。因此,递归树的高度为 k,总节点数最多为 2^k。在每个节点,我们进行 O(|V|) 的工作(如删除顶点和边)。因此,总运行时间为 O(|V| · 2^k)。
这个运行时间符合固定参数可处理的定义:f(k) = 2^k,poly(n) = |V|。对于较小的 k,这是一个非常高效的精确算法。
核化:预处理的艺术
有界搜索树算法已经不错,但我们还可以通过核化技术进行优化。核化的核心思想是,在运行主要算法之前,先用多项式时间对输入进行预处理,将其简化为一个只与参数 k 相关的小规模等价实例。
定义:一个核化算法将输入 (x, k) 转换为另一个输入 (x‘, k’),使得:
(x, k)的答案与(x‘, k’)的答案相同。x‘的大小仅依赖于k,即|x‘| = g(k)。- 转换过程在多项式时间内完成。
如果一个问题存在核化算法,那么我们可以先运行核化,再在生成的小实例上运行任何算法(甚至是指数算法),从而得到总运行时间为 poly(n) + h(k) 的固定参数算法。
顶点覆盖的核化规则
对于顶点覆盖,我们可以应用一系列简化规则来缩小图的规模:
- 消除自环:如果存在自环
(u, u),则顶点u必须在覆盖中。将u及其关联边删除,并将k减 1。 - 消除重边:如果两个顶点间有多条边,只保留一条,因为覆盖其中一条即覆盖了所有。
- 处理高度数顶点:如果存在一个顶点
v,其度数大于当前参数k,则v必须在覆盖中(否则需要覆盖所有邻居,数量将超过k)。将v及其关联边删除,并将k减 1。 - 删除孤立顶点:删除所有度数为 0 的顶点,它们对覆盖没有贡献。
反复应用这些规则,直到无法继续。最终,我们得到一个图,其中每个顶点的度数最多为 k。
核的大小分析:在最终图中,覆盖集最多包含 k 个顶点,每个顶点最多覆盖 k 条边,因此总边数 |E‘| ≤ k²。由于没有孤立顶点,顶点数 |V‘| ≤ 2|E‘| ≤ 2k²。所以,核的总规模为 O(k²)。
如果应用规则后图的规模超过 O(k²),则可以直接判定不存在大小不超过 k 的顶点覆盖。
组合算法与性能
现在,我们可以将核化与有界搜索树算法结合:
- 首先,运行多项式时间的核化算法,将任意实例
(G, k)简化为一个规模为O(k²)的核(G‘, k’)。 - 然后,在核
(G‘, k’)上运行有界搜索树算法。
总运行时间:核化耗时 O(|V| + |E|)。在核上运行有界搜索树耗时 O(|V‘| · 2^k) = O(k² · 2^k)。因此,总运行时间为 O(|V| + |E| + k² · 2^k)。
这比单纯的有界搜索树算法 O(|V| · 2^k) 更优,尤其是当原图 G 很大而 k 很小时。
与近似算法的联系
最后,我们简要探讨固定参数算法与上一节课所学的近似算法之间的联系。
定理:如果一个优化问题(其最优解值为整数)存在一个高效多项式时间近似方案,那么其对应的参数化决策问题(参数为解值 k)是固定参数可处理的。
直观解释:假设我们有一个近似算法,对于任意 ε > 0,能在 poly(n, 1/ε) 时间内找到一个 (1+ε) 近似的解。如果我们设 ε = 1/(2k) 并运行该近似算法,得到的解值与最优解 OPT 的绝对误差将小于 1。由于 OPT 是整数,这意味着近似解的值实际上就等于 OPT。因此,我们通过近似算法精确地解决了决策问题,且运行时间为 poly(n, 2k),符合固定参数可处理的定义。
这个定理建立了两个领域之间的桥梁,有时可以用来证明某些问题不存在高效的近似算法。
总结
本节课中我们一起学习了固定参数算法的核心思想。我们了解到:
- 固定参数可处理性 允许算法运行时间在参数
k上呈指数级,而在输入大小n上呈多项式级。 - 通过 k-顶点覆盖 问题,我们分析了朴素的暴力枚举算法为何不是好的固定参数算法。
- 有界搜索树算法 利用问题结构进行智能猜测,实现了 O(|V| · 2^k) 的运行时间,是一个标准的固定参数算法。
- 核化技术 通过多项式时间的预处理,将问题实例简化为一个规模仅依赖于
k的“核”,从而可以结合任何算法获得更优的性能。我们为顶点覆盖构建了一个大小为 O(k²) 的核。 - 固定参数算法与近似算法之间存在深刻联系,一个领域的进展可以推动另一个领域的发展。
固定参数算法为我们提供了一套强大的工具,用于精确解决那些在整体上是NP难、但具有小参数的实际问题实例。
R18:近似算法:旅行商问题 🧳





在本节课中,我们将学习如何为旅行商问题设计近似算法。旅行商问题是一个经典的组合优化问题,目标是找到访问图中所有顶点并返回起点的最短回路。由于该问题是NP难的,我们将探讨在满足三角不等式的度量空间下,如何设计高效的近似算法来获得接近最优的解。
概述
旅行商问题要求在一个完全图中,找到一条访问每个顶点恰好一次并返回起点的最短哈密顿回路。对于一般的图,即使寻找一个常数因子的近似解也是NP难的。因此,我们引入“度量TSP”的概念,即图中任意两点间的距离满足非负性、对称性和三角不等式。在本节中,我们将介绍两种针对度量TSP的近似算法:一种简单的2-近似算法,以及一种改进的3/2-近似算法。
基本概念与定义
在深入算法之前,我们需要明确一些基本概念和术语。
首先,我们定义路径或边集 S 的成本 c(S) 为其中所有边权重的总和。这里 S 可以是一个多重集,即同一条边可以被计算多次。
我们的目标是找到一个回路 C,它访问图中的所有顶点,并且我们希望最小化该回路的成本 c(C)。我们假设存在一个最优的哈密顿回路,记作 H*(G),其成本为 c(H*(G))。
2-近似算法 🌲
上一节我们定义了问题的目标。本节中,我们来看看如何构建一个简单的2-近似算法。其核心思想是利用图的最小生成树。
算法步骤
以下是构建近似回路的主要步骤:
- 构造最小生成树:在给定的完全图
G中,计算其最小生成树T。最小生成树是连接所有顶点且总边权最小的树。 - 深度优先遍历:从任意根节点开始,对最小生成树
T进行深度优先搜索遍历。记录遍历过程中访问顶点的顺序。由于DFS会沿着每条边向下走一次,再回溯一次,因此每个顶点可能会被访问多次,每条边会被遍历两次。 - 生成初始回路:根据DFS遍历的顺序,我们可以得到一个访问了所有顶点的序列。将这个序列首尾相连,就构成了一个回路
C。注意,C中可能包含重复的顶点。 - 绕过重复顶点:利用三角不等式,我们可以“绕过”回路
C中重复出现的顶点。具体做法是,当遇到一个已经访问过的顶点时,直接跳过它,前往序列中的下一个未访问顶点。根据三角不等式,这种“捷径”的成本不会高于原路径的成本。经过此步骤,我们得到了一个访问每个顶点恰好一次的哈密顿回路C‘。
算法分析
现在,我们来分析这个回路 C‘ 的近似比。
- 设最小生成树
T的成本为c(T)。 - 在DFS遍历中,每条边被遍历两次,因此初始回路
C的成本为c(C) = 2 * c(T)。 - 由于我们通过“捷径”得到
C‘,根据三角不等式,有c(C‘) ≤ c(C)。 - 考虑最优哈密顿回路
H*(G)。如果我们从H*(G)中移除任意一条边,就会得到一棵生成树(因为一个回路去掉一条边变成了一条路径,而路径是一棵树)。这棵生成树的成本至少等于最小生成树T的成本。因此有:
c(T) ≤ c(H*(G)) - 综合以上不等式,我们可以得到:
c(C‘) ≤ c(C) = 2 * c(T) ≤ 2 * c(H*(G))
结论:我们构造的回路 C‘ 的成本不超过最优解成本的两倍。因此,这是一个2-近似算法。
改进至 3/2-近似算法 🔄
上一节的2-近似算法虽然简单,但存在一个明显的缺点:它在最小生成树的每条边上都走了两次,这看起来有些浪费。本节中,我们尝试改进这个近似比。
我们的新目标是构造一个欧拉回路,即一条遍历图中每条边恰好一次的回路。如果图中存在欧拉回路,我们就可以沿着它走,并且不会重复经过任何边,这有望得到更短的路径。
关键观察与概念
在介绍算法之前,我们需要几个关键概念:
- 子图最优解界限:对于顶点集
V的任意子集S,在子图G[S](由S诱导的子图)上的最优TSP回路成本,不超过在原图G上的最优TSP回路成本。即c(H*(S)) ≤ c(H*(G))。直观上,在原图的最优回路上跳过不属于S的顶点(利用三角不等式)只能降低成本。 - 最小权完美匹配:对于一个有偶数个顶点的完全图,我们可以找到其最小权完美匹配
M。完美匹配是指将顶点两两配对,使得每个顶点恰好属于一条匹配边。最小权完美匹配可以在多项式时间内求解(例如,使用基于线性规划的方法)。 - 欧拉回路存在条件:一个连通图存在欧拉回路(不重复地遍历所有边的回路)的充要条件是,图中每个顶点的度数均为偶数。
算法步骤:Christofides 算法
基于以上观察,Christofides 算法步骤如下:
- 构造最小生成树:与之前相同,计算图
G的最小生成树T。 - 识别奇度顶点:找出最小生成树
T中所有度数为奇数的顶点,记这个集合为O。可以证明,|O|是偶数。 - 计算最小权完美匹配:在由集合
O中的顶点诱导形成的子完全图G[O]上,计算其最小权完美匹配M。 - 构造欧拉图:将最小生成树
T和匹配M的边合并,得到一个新图H = T ∪ M。在H中,原来在T中为奇度的顶点,因为加入了匹配M的一条边,其度数增加了1,变为偶度;原来就是偶度的顶点,度数保持不变。因此,图H中所有顶点的度数都是偶数。 - 寻找欧拉回路:由于
H是连通的且所有顶点度数为偶,因此存在欧拉回路。我们可以找到这样一条回路E。 - 生成哈密顿回路:沿着欧拉回路
E访问顶点,遇到重复顶点时,利用三角不等式“绕过”它(即跳过它,直接前往下一个未访问的顶点)。这样就得到了一个访问每个顶点恰好一次的哈密顿回路C‘。
算法分析
现在分析回路 C‘ 的成本上界。
- 设欧拉回路
E的成本为c(E)。由于E遍历了H中的所有边,所以c(E) = c(T) + c(M)。 - 同样,通过“捷径”得到
C‘,有c(C‘) ≤ c(E)。 - 我们已经知道
c(T) ≤ c(H*(G))。 - 关键是要界定
c(M)。考虑最优回路H*(G)限制在顶点集O上形成的回路H*(O)。根据第一个关键观察,有c(H*(O)) ≤ c(H*(G))。 - 回路
H*(O)可以看作是由两个互不相交的完美匹配M1和M2构成的(沿着回路交替取边)。因此,c(H*(O)) = c(M1) + c(M2)。 - 由于
M是G[O]上的最小权完美匹配,其成本不大于M1和M2中的任何一个,更不大于它们的平均值。因此:
c(M) ≤ min(c(M1), c(M2)) ≤ (c(M1) + c(M2)) / 2 = c(H*(O)) / 2 ≤ c(H*(G)) / 2 - 将
c(T)和c(M)的界限相加,我们得到:
c(C‘) ≤ c(E) = c(T) + c(M) ≤ c(H*(G)) + (1/2) * c(H*(G)) = (3/2) * c(H*(G))
结论:Christofides 算法构造的回路 C‘ 的成本不超过最优解成本的 3/2 倍。因此,这是一个 3/2-近似算法。
总结
本节课中,我们一起学习了针对度量旅行商问题的两种近似算法。
- 我们首先介绍了一种简单的 2-近似算法。它通过构造最小生成树,进行DFS遍历并绕过重复顶点来实现。其核心思想是利用生成树成本对最优解的下界,以及三角不等式来保证近似比。
- 为了获得更好的近似比,我们引入了 Christofides 算法(3/2-近似算法)。该算法通过将最小生成树与奇度顶点集上的最小权完美匹配相结合,构造出一个所有顶点度为偶的图,从而可以找到欧拉回路。最后,再将欧拉回路转化为哈密顿回路。算法的分析依赖于子图最优解界限和完美匹配成本与最优解的关系。
这两种算法展示了如何利用经典图论概念(如生成树、匹配、欧拉回路)和问题本身的度量性质,为NP难问题设计出高效且性能有保证的近似解。
L19:同步分布式算法:对称破坏与最短路径生成树 🧩


在本节课中,我们将要学习分布式算法,这是一种在由多个处理器或节点组成的网络中运行的算法。我们将从同步分布式网络模型开始,探讨如何解决对称性破坏问题(如领导者选举)和计算图结构(如广度优先生成树和最短路径树)。我们将看到,在分布式环境中,由于并发、不确定性和潜在的故障,设计和分析算法会面临独特的挑战。
同步分布式网络模型 🖥️
上一节我们介绍了分布式算法的基本概念,本节中我们来看看其核心模型。我们从一个无向图开始,图中的每个顶点代表一个进程。我们用 n 表示网络中节点的总数。对于顶点 u,我们用 γ(u) 表示其邻居集合,度(u) 表示其邻居的数量。
每个进程是一个可以相互通信的活动实体。图的每条边代表一个双向通信信道。在同步模型中,计算以轮次进行。在每一轮中,每个进程根据其当前状态,决定在所有输出端口上发送什么消息。然后,所有发送的消息被传递到对应的接收进程。接收进程根据到达的消息更新自己的状态。我们通常忽略本地计算成本,重点关注通信成本,例如所需的轮次数和消息数。
领导者选举与对称性破坏 👑
在分布式系统中,通常需要选出一个领导者来协调任务。然而,当所有进程完全相同且是确定性的时候,在像团(完全图)这样的对称结构中选举领导者是不可能的。
定理:在一个由 n 个相同且确定性的进程组成的团中,不存在能选举出唯一领导者的算法。
证明思路:所有进程起始状态相同。通过归纳法可以证明,在每一轮之后,所有进程仍保持相同的状态。因此,如果有一个进程输出自己是领导者,所有其他进程也会做同样的事情,这就违反了唯一性的要求。
为了打破对称性,我们需要引入区分进程的方法。常见的方法有两种:
- 唯一标识符:每个进程拥有一个唯一的ID(例如,一个整数)。在团中,只需一轮通信,每个进程广播自己的ID,具有最大ID的进程即可选举自己为领导者。
- 随机性:进程从一个足够大的空间(例如,大小为
r ≥ n²/(2ε))中随机选择ID。这样,所有ID都不同的概率至少为1 - ε。算法重复进行ID选择和广播,直到选出一个唯一的最高ID。
极大独立集问题 🎯
极大独立集是指图的一个节点子集,其中没有两个节点相邻(独立性),并且不能再添加任何节点而不破坏独立性(极大性)。在分布式环境中,我们希望每个进程最终能判断自己是否在MIS中。
对于这个问题,即使没有唯一标识符,我们也可以使用随机算法。以下是经典的 Luby算法,它分阶段运行,每个阶段包含两轮:
以下是每个阶段中活动节点执行的操作:
- 第一轮:每个活动节点从一个足够大的范围(如
1到n⁵)中随机选择一个值,并发送给所有邻居。然后,它接收所有活动邻居发来的值。 - 决策:如果某个节点的值严格大于所有邻居的值,则它决定加入MIS,并输出“在集合中”。随后,它通知所有邻居。
- 第二轮:任何收到邻居“加入MIS”通知的节点,决定自己不在MIS中,并输出“不在集合中”,同时变为非活动状态。
- 在本阶段决定加入或退出的节点变为非活动状态。剩余的活动节点及其之间的边构成新图,进入下一阶段。
算法正确性与效率:该算法能保证最终产生的集合是极大独立集。关键在于,可以证明每个阶段结束时,图中剩余的边数期望值至少减少一半。因此,经过 O(log n) 个阶段后,所有节点都能以高概率完成决策。
广度优先搜索生成树 🌳
现在,我们考虑一个熟悉的问题:构造以特定根节点 v₀ 为源的广度优先搜索树。我们假设进程拥有唯一标识符,并且根节点 v₀ 是已知的。每个进程的输出是它在BFS树中的父节点。
同步BFS算法:
- 初始时,只有根节点
v₀被标记。 - 在每一轮,所有已被标记且在上轮决定发送消息的进程,向所有邻居发送
搜索消息。 - 如果一个未标记的进程收到
搜索消息,它将自己标记,并随机选择一个发送者作为其父节点。然后,它将在下一轮向自己的邻居发送搜索消息。 - 如果一个已标记的进程收到
搜索消息,则忽略它。
算法分析:
- 正确性:可以通过归纳法证明不变性:在
r轮结束后,所有距离根节点不超过r的节点都被标记,并且其父节点指向距离为r-1的节点。 - 消息复杂度:每条边在每个方向上最多传递一次
搜索消息,因此总消息数为O(|E|)。 - 轮次复杂度:算法在
D轮内结束,其中D是图的直径(从根节点出发的最大距离)。
扩展:算法可以轻松扩展,让节点计算到根的距离,或通过“收敛广播”方式让根节点感知整个树构建完成。
最短路径生成树 ⚖️
最后,我们考虑带权图中的最短路径树问题。每个边有一个权重,每个进程知道其相邻边的权重。目标是为每个节点找到到达根节点 v₀ 的最小权重路径,并确定其在这棵最短路径树中的父节点。
我们使用 分布式Bellman-Ford算法:
- 每个节点维护其当前到根节点的最佳距离估计
dist,初始时根节点为0,其他节点为∞。 - 在每一轮,每个节点将其当前的
dist估计发送给所有邻居。 - 当一个节点从邻居
u收到距离估计d_u时,它检查是否可以通过u获得更短路径:即d_u + weight(u, v) < dist(v)。如果是,则更新dist(v) = d_u + weight(u, v),并将u设为父节点。 - 进程持续进行多轮,直到距离估计不再更新。
算法特性:在同步设置中,经过最多 n-1 轮后,所有节点的距离估计将收敛到正确的最短路径值。消息复杂度较高,因为每条边在每轮都可能传递消息。

本节课中我们一起学习了同步分布式算法的基础。我们看到了对称性如何阻碍简单问题的解决,以及如何通过唯一标识符或随机性来打破对称。我们探讨了构建极大独立集、广度优先搜索树和最短路径树的分布式算法,并了解了使用不变量进行正确性证明的基本方法。这些算法展示了在处理器网络中协同解决问题的独特模式。在下一课中,我们将进入更复杂的异步分布式算法世界。
L20:异步分布式算法:最短路径生成树 🚀

在本节课中,我们将学习异步分布式算法,特别是如何构建最短路径生成树。我们将从回顾同步算法开始,然后深入探讨异步模型带来的复杂性,并学习如何在这种更具挑战性的环境中解决问题。
回顾:同步分布式算法
上一节我们介绍了同步分布式算法的基本模型。本节中,我们来看看我们之前讨论过的几个关键算法。
在同步模型中,图中的每个顶点都有一个关联的进程。进程通过通信信道(图的边)发送消息。算法在同步轮次中执行:每一轮,每个进程决定发送什么消息,消息被传递,然后所有进程根据收到的消息计算新状态。
我们讨论了三个主要问题:
- 领导人选举:在确定性且进程无法区分的情况下,无法保证选出领导者。但如果进程有唯一标识符(UID)或可以随机化,则可以快速选出领导者。
- 最大独立集(MIS):我们学习了 Luby算法。该算法经过多个阶段,每个阶段一些进程决定加入集合,其邻居则决定不加入。该算法能正确计算MIS,且很可能在对数轮次内完成。
- 广度优先生成树(BFS):假设已有一个领导者(根节点)。一个简单的算法是:根节点标记自己并向邻居发送“搜索”消息;收到消息的节点标记自己,将发送者设为自己的父节点,并继续向邻居转发消息。该算法的时间复杂度为网络直径,消息复杂度为边数
O(|E|)。
在一小时结束时,我们开始将BFS推广到带权图,即最短路径生成树问题。
同步最短路径生成树
上一节我们开始探讨最短路径生成树。本节中,我们来看看同步环境下的贝尔曼-福特算法。
在最短路径问题中,图的边带有权重。每个进程(节点)需要输出其到根节点 v0 的最短距离,以及在某个最短路径上的父节点。
我们回顾了同步版的贝尔曼-福特算法。每个节点维护一个到根节点的距离估计 dist。算法重复进行多轮,每轮中:
- 每个节点将其当前
dist发送给所有邻居。 - 每个节点从所有邻居处接收距离估计。
- 每个节点执行松弛操作:对于每个邻居
u,计算dist_u + weight(u, v)。如果这个值小于当前dist_v,则更新dist_v并将父节点设为u。
算法核心(松弛步骤):
对于节点 v:
收到邻居 u 发来的距离估计 d_u
新估计 = d_u + weight(u, v)
如果 新估计 < 当前 dist_v:
dist_v = 新估计
parent_v = u
正确性关键:在 r 轮之后,每个节点的距离估计对应于从根节点出发、经过最多 r 跳的最短路径。由于图中最长简单路径的跳数不超过 n-1(n 为节点数),因此在 n-1 轮后,所有距离估计都会稳定到正确的最短路径值。
复杂度:
- 时间:
O(n)轮(依赖于节点总数,而非直径)。 - 消息:每轮每条边可能发送消息,因此最坏情况下为
O(|E| * n)。
关于子指针和终止:获取子节点指针比BFS更复杂,因为父节点关系可能在算法过程中改变。节点需要向旧父节点发送“非父”消息,并可能多次重建子节点集合。终止检测可以使用收敛广播,但由于树结构会变化,节点可能需要多次参与广播过程。
异步分布式算法模型
上一节我们处理了同步环境。本节中,我们进入更具挑战性的异步分布式算法世界。
在异步模型中,没有全局轮次。进程以自己的速度运行,消息可以在任意延迟后到达。这引入了更多的不确定性和复杂性。我们不再能精确描述每一步发生了什么,而是关注算法的抽象属性(如不变性和最终正确性)。
系统组件:
- 进程:与图中每个顶点关联的自动机。有状态变量,能执行发送和接收消息的动作。
- 信道:连接两个进程的自动机,建模为先进先出(FIFO)队列。当进程发送消息时,消息被添加到队列末尾;当信道传递消息时,从队列头部移除并交付给接收进程。
执行模型:系统通过一系列单独的步骤运行。任何组件(进程或信道)只要其某个动作被“启用”(例如,进程有待发送的消息,或信道队列非空),就可以执行该动作。步骤顺序是任意的。
时间度量:由于没有同步时钟,我们通常用“实时”来分析复杂度,并假设:
- 本地计算时间有上界
L。 - 信道传递其队列头部的消息有时间上界
D。
基于这些假设,可以推导出算法完成时间的上界,但这些时间假设对进程本身是不可见的,仅用于外部分析。
异步广度优先生成树
上一节我们介绍了异步模型。本节中,我们首先尝试将简单的同步BFS算法直接移植到异步环境。
简单的想法是:当节点收到第一条“搜索”消息时,它将发送者设为自己的父节点,并立即向所有邻居转发该消息。
伪代码概览:
状态变量:parent(初始为null), reported(布尔值)
当收到来自 sender 的“搜索”消息:
如果 parent == null:
parent = sender
将“搜索”消息加入发送缓冲区(准备发给所有邻居)
然而,这个算法在异步环境下会失败。由于消息延迟任意,一个距离根节点很远的节点可能通过一条长而快的路径先收到“搜索”消息,从而错误地确定父节点,导致生成的树不是广度优先的。
解决方案:采用类似贝尔曼-福特的松弛思想,但针对跳数(无权重)。每个节点跟踪到根的跳数估计 hops。
- 当收到邻居
u发来的跳数h_u时,计算new_hops = h_u + 1。 - 如果
new_hops小于当前hops,则更新hops,将父节点设为u,并向邻居传播新的跳数估计。
这样,节点可以纠正因异步消息顺序导致的错误父节点选择,最终稳定到正确的BFS树。
复杂度与终止:消息复杂度可能因多次更正而变高。终止检测同样可以使用收敛广播,但由于估计值会变,节点可能需要多次参与广播过程。
异步最短路径生成树
上一节我们解决了异步BFS问题。本节中,我们考虑最一般的情况:在异步带权图中构建最短路径生成树。
我们现在要结合两种复杂性:
- 权重:需要找到总权重最小的路径(如同步贝尔曼-福特)。
- 异步:消息延迟任意,可能导致节点先收到非最优路径的信息。
算法自然延伸自异步BFS和同步贝尔曼-福特。每个节点维护距离估计 dist。当从邻居 u 收到距离估计 d_u 时:
- 计算
new_dist = d_u + weight(u, v)。 - 如果
new_dist < current_dist_v,则更新dist_v = new_dist,设置parent_v = u,并将新的距离估计发送给所有邻居。
算法核心(异步松弛):
当节点 v 收到来自邻居 u 的距离 d_u:
候选距离 = d_u + weight(u, v)
如果 候选距离 < dist_v:
dist_v = 候选距离
parent_v = u
将 dist_v 加入发送缓冲区(准备发给所有邻居)
这个算法能同时处理因权重产生的更正(发现更轻的路径)和因异步产生的更正(发现更少跳数的路径)。

正确性:可以证明一个安全性属性:算法执行过程中,任何节点持有的距离估计总是等于从根节点到该节点的某条实际路径的权重。同时,还有一个活性属性:最终,每个节点都会收敛到正确的最短距离。
最坏情况复杂度:问题在于,在异步和权重的双重作用下,最坏情况下的性能可能非常差。
- 一个节点可能会收到大量连续改进的距离估计。
- 考虑一个精心构造的图:一条主干路径权重为0,但包含许多条权重为
2^k, 2^(k-1), ..., 1的迂回路径。在异步执行中,末端的节点可能按2^k, 2^(k-1)+?, ...的顺序收到指数数量级(O(2^k))的不同距离估计。 - 这导致消息复杂度可能达到指数级
O(2^n)。 - 时间复杂度:由于信道中可能堆积指数级数量的消息,清空它们也需要指数时间。
终止:尽管性能可能很差,算法最终是正确的。终止检测依然可以借助收敛广播来实现,根节点最终会知道整个计算已完成。
总结与展望
本节课中,我们一起学习了异步分布式算法,重点关注了最短路径生成树问题。
我们从同步算法的回顾开始,理解了轮次模型下的BFS和贝尔曼-福特算法。然后,我们进入了异步模型,认识到其核心挑战在于消息传递和进程执行的任意时序。我们看到了简单的异步BFS算法会失败,并通过引入距离(跳数)估计和松弛操作来纠正它。最后,我们将问题扩展到带权图,得到了异步贝尔曼-福特算法。该算法虽然最终正确,但在最坏情况下可能具有指数级的消息和时间复杂度,这揭示了在无约束异步环境中设计高效算法的难度。
本节内容指向分布式算法中一些更高级的主题,例如:
- 同步器:在异步网络上模拟同步算法的技术。
- 逻辑时钟:用于推理异步事件顺序的工具。
- 快照算法:捕获分布式系统全局状态的方法。
- 容错:处理进程失败或恶意行为。
- 自稳定:使系统从任意状态收敛到合法状态。
这些主题构成了分布式计算理论丰富而深刻的研究领域。
本教程根据麻省理工学院公开课(MIT OpenCourseWare)6.046J课程内容整理,遵循知识共享许可协议。您的支持有助于MIT继续提供免费优质教育资源。
R10. 分布式算法 🧩





在本节课中,我们将学习分布式算法中的两个核心问题:环状网络中的领导者选举和网络节点计数。我们将探讨如何设计算法,使其在同步和异步网络环境中都能正确工作,并分析其消息复杂度。
领导者选举:环状网络中的挑战
上一节我们介绍了分布式算法的基本概念,本节中我们来看看一个经典问题:在环状网络拓扑中进行领导者选举。在讲座中,我们看到的示例是完全连接的“单击”网络,每个节点都能直接与其他所有节点通信。解决方案是让每个节点生成一个唯一的用户标识符(UID)或随机数,如果自己的ID是最大的,则宣布自己为领导者。
然而,在环状网络中,每个节点只能与左右两个邻居通信。核心思想仍然是让每个节点生成一个ID,并收集其他节点的ID以判断自己是否为最大值。困难在于如何仅通过邻居传递信息,让所有节点都能看到其他人的ID。
朴素解决方案:消息广播
以下是实现信息广播的一种简单方法:
- 每个节点生成一个随机ID。
- 每一轮中,每个节点将自己的ID(或已知的最大ID)发送给右邻居。
- 经过
n轮(n为节点数)后,每个节点都获得了所有其他节点的ID。 - 每个节点比较所有ID,如果最大值等于自己的ID,则宣布自己为领导者。
这个方案的消息复杂度是 O(n²),因为总共有 n 个节点,每个节点发送 n 条消息。
优化方案:仅传播较大ID
一个优化思路是:节点只转发比自己已知ID更大的ID,丢弃较小的ID,因为较小的ID没有机会成为领导者。
核心逻辑伪代码:
# 节点初始状态
my_id = generate_random_id()
known_max_id = my_id
# 当从邻居收到消息时
def on_receive_message(received_id):
if received_id > known_max_id:
known_max_id = received_id
send_to_neighbor(known_max_id) # 继续传播
# 否则,丢弃该消息
然而,在最坏情况下(例如ID按升序排列),任何节点都无法丢弃消息,复杂度仍然是 O(n²)。
高效算法:倍增法(二分搜索思想)
为了达到 O(n log n) 的消息复杂度,我们可以采用类似倍增的策略:
- 第1轮:每个节点向左右邻居发送自己的ID(跳数为1)。
- 后续轮次:只有在前一轮中,其ID在左右
2^(i-1)跳范围内是局部最大值的节点,才会继续参与第i轮。它向左右发送消息,跳数增至2^i。 - 消息处理:中间节点递减跳数并转发。当跳数减至0时,末端节点比较ID:如果收到的ID大于自身ID,则沿原路径返回“继续”消息;否则返回“停止”消息。
- 终止:如果节点从两个方向都收到“继续”消息,则它在该轮胜出,进入下一轮。否则,它变为非活动状态。
经过大约 log n 轮后,只会剩下一个活动节点,即领导者。每轮活动的节点数约减半,每条消息传播 O(2^i) 跳,总消息复杂度为 O(n log n)。该算法在同步和异步网络中均适用。
网络节点计数:构建生成树
现在,我们来看第二个问题:计算一个未知网络中的节点总数。我们希望算法在同步和异步网络中都能工作。
高级策略是首先在网络中构建一棵生成树,然后通过子节点向父节点聚合计数的方式,自底向上计算出总节点数。
生成树构建算法(异步适应版)
在讲座中,我们看到了广度优先搜索(BFS)生成树算法。标准的BFS算法在异步网络中可能无法得到真正的BFS树,但可以得到一个有效的生成树。以下是其异步适应版本的核心步骤:
每个节点维护以下状态变量:
parent: 初始为None。children: 初始为空集合。neighbors_responded: 初始为所有邻居的集合,用于追踪已收到回复的邻居。
算法过程:
- 初始化:指定一个节点为根节点,将其
parent设为self,并向所有邻居发送SEARCH消息。 - 处理 SEARCH 消息:当节点
u从邻居v收到SEARCH消息时:- 如果
u.parent为None,则将parent设为v,并向v发送PARENT(true)消息作为确认。然后,u向自己的其他所有邻居转发SEARCH消息。 - 如果
u已有父节点,则向v发送PARENT(false)消息表示拒绝。
- 如果
- 处理 PARENT 消息:当节点
u从邻居v收到PARENT(b)消息时:- 将
v从neighbors_responded列表中移除。 - 如果
b为true,则将v加入children集合。
- 将
- 终止与聚合(收敛广播):
- 节点需要等待,直到其
children集合中的所有子节点都报告完成。 - 为此,我们引入
DONE消息。当一个节点的children集合为空(即它是叶子节点)时,它向父节点发送DONE(1)消息,报告其子树包含1个节点(自己)。 - 中间节点收到所有子节点的
DONE(count)消息后,计算自己子树的总节点数:total = 1 + sum(child_counts)。然后向自己的父节点发送DONE(total)。 - 根节点最终收到所有子节点的报告后,计算出的
total就是整个网络的节点数n。
- 节点需要等待,直到其
核心聚合逻辑:
# 节点状态变量新增
total = 1 # 包含自己
def on_receive_done(child_node, child_count):
children_done.add(child_node)
total += child_count
if children_done == children: # 所有子节点都已报告
if is_root:
print("Total nodes in network:", total)
else:
send_to_parent(DONE(total))
总结
本节课中我们一起学习了两个分布式算法的基础问题及其解决方案。
-
环状网络领导者选举:我们分析了朴素广播法(O(n²))、基于比较的优化法(最坏仍为O(n²))以及高效的倍增法(O(n log n))。倍增法通过多轮次、指数级扩大比较范围的方式,快速淘汰非最大ID的节点,显著降低了消息复杂度。
-
网络节点计数:我们将问题分解为两个阶段。首先,通过异步生成树构建算法,在网络中建立一棵以某节点为根的父母-孩子关系树。然后,利用收敛广播技术,让叶子节点开始,逐层向上聚合子树节点数量,最终在根节点得到网络总节点数。这个算法框架是许多分布式聚合操作(如求和、求极值)的基础。
理解这些算法有助于掌握分布式系统中信息传播、协调与计算的核心模式。
L21:密码学:哈希函数 🔐





在本节课中,我们将要学习哈希函数在密码学中的应用。与之前用于构建高效字典的哈希函数不同,密码学哈希函数具有一系列特殊的安全属性,使其能够用于密码保护、文件完整性验证、数字签名和安全拍卖等场景。我们将详细探讨这些属性及其应用。
概述 📋
哈希函数将任意长度的输入字符串映射到一个固定长度的输出。在密码学中,我们要求哈希函数是确定性的、公开的,并且其行为看起来是随机的。核心挑战在于,我们需要用实际可计算的函数来近似一个理想的“随机预言机”。
一个理想的随机预言机可以看作一本无限容量的书。当首次查询一个输入 x 时,它通过抛硬币 d 次(d 为输出位数)来随机生成一个 d 位字符串作为 h(x),并记录在书中。之后对相同 x 的查询都会返回书中记录的值,从而保证确定性。然而,这种预言机在现实中无法实现,我们需要用多项式时间可计算的伪随机函数来近似它。
密码学哈希函数的属性 🛡️
上一节我们介绍了密码学哈希函数的基本概念和理想模型。本节中,我们来看看为实现各种安全应用,哈希函数需要满足哪些具体属性。
以下是五个核心的安全属性:
-
单向性 (One-Wayness / Preimage Resistance)
- 定义:给定输出
y,难以找到任意输入x,使得h(x) = y。 - 公式描述:对于给定的
y,寻找x使得h(x) = y在计算上是不可行的。 - 说明:这保证了从哈希值反向推导出原始输入是极其困难的。
- 定义:给定输出
-
抗碰撞性 (Collision Resistance, CR)
- 定义:难以找到两个不同的输入
x和x‘(x ≠ x‘),使得h(x) = h(x‘)。 - 公式描述:寻找
(x, x‘)使得x ≠ x‘且h(x) = h(x‘)在计算上是不可行的。 - 说明:这保证了无法找到两个不同的输入产生相同的哈希值。
- 定义:难以找到两个不同的输入
-
目标抗碰撞性 (Target Collision Resistance, TCR)
- 定义:给定一个特定的输入
x,难以找到另一个不同的输入x‘(x ≠ x‘),使得h(x) = h(x‘)。 - 公式描述:对于给定的
x,寻找x‘使得x ≠ x‘且h(x) = h(x‘)在计算上是不可行的。 - 说明:这是比抗碰撞性更弱的一个属性。抗碰撞性要求找不到任何碰撞对,而目标抗碰撞性只要求对于给定的一个特定输入,找不到与之碰撞的另一个输入。
- 定义:给定一个特定的输入
-
伪随机性 (Pseudo-Randomness)
- 定义:哈希函数的输出应当与随机字符串不可区分。
- 说明:虽然无法实现真正的随机预言机,但实际哈希函数应模拟其随机行为,使得输出看起来没有规律。
-
不可延展性 (Non-Malleability)
- 定义:给定
h(x),难以计算出与x有特定关系(如x‘ = x + 1)的另一个值x‘的哈希值h(x‘)。 - 说明:这防止了攻击者根据一个已知的哈希值,构造出另一个相关输入的哈希值。
- 定义:给定
属性之间的关系 🔗
我们已经了解了各个属性的定义。本节中,我们来看看这些属性之间存在怎样的逻辑关系。
- 抗碰撞性 (CR) 蕴含目标抗碰撞性 (TCR):如果一个哈希函数是抗碰撞的,那么它自然也是目标抗碰撞的。因为如果连任意找一对碰撞都做不到,那么针对一个特定输入找碰撞就更做不到了。
- 单向性 (OW) 与抗碰撞性 (CR) 相互独立:
- 存在满足单向性,但不满足目标抗碰撞性(更不用说抗碰撞性)的哈希函数。
- 示例:假设
h(x)是单向的。构造h‘(a, b, x2, ..., xn) = h(a ⊕ b, x2, ..., xn)。这里a和b是额外输入位。h‘仍是单向的,因为要逆推需要知道a⊕b的值。但h‘不满足 TCR,因为(a=0, b=0)和(a=1, b=1)这两组输入会产生相同的哈希值(因为0⊕0 = 1⊕1 = 0),从而构成碰撞。
- 示例:假设
- 也存在满足目标抗碰撞性,但不满足单向性的哈希函数。
- 示例:假设
h(x)是 TCR 的。构造h‘(x):如果|x| <= n,则输出x本身(原样泄露);否则输出h(x)。对于长输入,h‘继承了h的 TCR 属性。但对于短输入,h‘(x)直接输出x,因此给定输出y(短字符串),很容易找到原像x = y,从而破坏了单向性。
- 示例:假设
- 存在满足单向性,但不满足目标抗碰撞性(更不用说抗碰撞性)的哈希函数。
这些例子表明,在设计和选择哈希函数时,需要根据具体应用明确所需的安全属性。
哈希函数的应用实例 💻
理解了哈希函数的属性后,本节我们来看看这些属性如何在实际场景中发挥作用。
以下是几个关键的应用场景及其所需的哈希函数属性:
-
密码存储
- 场景:系统不存储用户明文密码
pw,而是存储其哈希值h(pw)。登录时,系统计算用户输入密码pw‘的哈希值h(pw‘),并与存储的h(pw)比较。 - 所需属性:单向性 (OW)。即使攻击者获得了存储的哈希值,也无法反推出原始密码。目标抗碰撞性在此场景中并非必需,因为系统可以限制尝试次数,且发生碰撞导致误接受的概率极低。
- 场景:系统不存储用户明文密码
-
文件完整性校验
- 场景:为文件
F计算哈希值h(F)并安全存储。之后需要验证文件是否被篡改时,重新计算当前文件F‘的哈希值h(F‘),并与之前存储的h(F)比较。 - 所需属性:目标抗碰撞性 (TCR)。攻击者的目标是修改文件内容后,使新文件的哈希值与原哈希值相同。这正对应了给定原文件
F(和h(F)),寻找一个不同的F‘使得h(F) = h(F‘)。
- 场景:为文件
-
数字签名
- 场景:对长消息
M直接进行数字签名开销大。通常做法是先计算消息的哈希值h(M),然后对h(M)进行签名。 - 所需属性:目标抗碰撞性 (TCR) 和 不可延展性。攻击者如果能在给定
M和h(M)的情况下,找到另一个消息M‘使得h(M) = h(M‘),那么他对M的签名就同样适用于M‘,这可能导致欺诈(例如,将“支付$20”的签名用于“支付$10000”)。
- 场景:对长消息
-
密封投标拍卖
- 场景:投标者 Alice 提交其出价
x的承诺c(x)(例如c(x) = h(x)),而非明文x。开标时,她揭示x,并证明h(x)等于之前提交的承诺。 - 所需属性:
- 单向性 (OW):在揭示前,承诺
h(x)不泄露出价x。 - 抗碰撞性 (CR):防止 Alice 事后找到另一个出价
x‘使得h(x) = h(x‘),从而在开标时根据对自己有利的情况选择揭示x或x‘。 - 不可延展性:防止其他投标者根据看到的承诺
h(x),计算出h(x+1)等,从而构造出刚好比x高的出价。
- 单向性 (OW):在揭示前,承诺
- 场景:投标者 Alice 提交其出价
总结 🎯
本节课中我们一起学习了密码学哈希函数的核心知识。我们从理想的随机预言机模型出发,探讨了实际哈希函数必须满足的五大安全属性:单向性、抗碰撞性、目标抗碰撞性、伪随机性和不可延展性。我们分析了这些属性之间的逻辑关系,并通过密码存储、文件校验、数字签名和密封拍卖等具体应用,深入理解了为何不同的场景需要哈希函数具备不同的属性组合。密码学哈希函数是构建现代安全系统的基石,其设计需要在安全性和计算效率之间取得精妙的平衡。
L22:密码学:加密 🔐





在本节课中,我们将要学习密码学中的核心概念——加密。我们将从对称密钥加密开始,探讨其工作原理和局限性,然后深入讲解密钥交换的经典问题与解决方案。最后,我们将转向非对称密钥加密,重点剖析著名的RSA算法,并讨论密码学系统背后的计算复杂性假设。
对称密钥加密 🔑
上一节我们介绍了密码学的整体目标,本节中我们来看看最基础的加密形式——对称密钥加密。它假设通信双方(例如爱丽丝和鲍勃)共享一个秘密密钥。
对称密钥加密的基本方程非常简单:
- 加密过程:
C = E(M, K)C代表密文。M代表明文或消息。K代表秘密密钥。E代表加密函数。
- 解密过程:
M = D(C, K)D代表解密函数。
这里的核心要求是可逆性。知道密钥 K 后,从密文 C 恢复明文 M 应该是一个简单的操作(通常是线性时间复杂度)。这与我们之前学习的哈希函数(单向、不可逆)有本质区别。
对称密钥加密算法(如AES)通常基于可逆操作构建,例如:
- 置换:重新排列数据的顺序。
- 异或运算:
A XOR B XOR B = A,其自身就是逆操作。
对称密钥加密速度快,适合加密大量数据(如流媒体视频)。但它引出了一个关键问题:爱丽丝和鲍勃最初如何安全地共享那个秘密密钥 K?
密钥交换难题与迪菲-赫尔曼协议 🤝
我们已经了解了对称加密需要一个共享密钥,但如何在不安全的信道上建立这个共享密钥呢?这引出了经典的“海盗谜题”。
海盗谜题场景:
爱丽丝和鲍勃在两个岛上,需要通过海盗出没的海域交换秘密。他们各有带锁的箱子和对应的钥匙。海盗会检查所有经过的货物:如果箱子未上锁,他们会打开查看;如果箱子已上锁,他们会原样送达;如果他们看到单独的钥匙,则会没收钥匙。
解决方案(物理世界):
- 爱丽丝将秘密放入箱子,用她的锁
Lock_A锁上,寄给鲍勃。 - 鲍勃收到后,加上自己的锁
Lock_B,然后将双锁箱子寄回给爱丽丝。 - 爱丽丝用自己的钥匙打开
Lock_A,将只剩Lock_B的箱子寄给鲍勃。 - 鲍勃用自己的钥匙打开
Lock_B,获得秘密。
整个过程中,在运输的始终是锁着的箱子,没有钥匙在传输,因此海盗无法获得秘密。这个方案依赖于一个关键假设:两把锁可以独立地锁在同一个箱子上,且操作顺序可交换(即先锁A再锁B,与先锁B再锁A效果相同)。
数学抽象:迪菲-赫尔曼密钥交换
上述物理过程可以被抽象成一个优美的数学协议,即迪菲-赫尔曼密钥交换协议。
以下是协议步骤:
- 爱丽丝和鲍勃公开约定一个大素数
p和一个整数g(g是有限域GF(p)的一个生成元)。 - 爱丽丝 选择一个私密的随机数
a,计算A = g^a mod p,并将A发送给鲍勃。 - 鲍勃 选择一个私密的随机数
b,计算B = g^b mod p,并将B发送给爱丽丝。 - 爱丽丝 收到
B后,计算共享密钥K = B^a mod p = (g^b)^a mod p = g^(b*a) mod p。 - 鲍勃 收到
A后,计算共享密钥K = A^b mod p = (g^a)^b mod p = g^(a*b) mod p。
最终,爱丽丝和鲍勃得到了相同的共享密钥 K,而窃听者只能看到公开的 p, g, A, B。
安全性基于的计算难题:
- 离散对数问题:已知
g^a mod p,求解私钥a在计算上是困难的。 - 迪菲-赫尔曼问题:已知
g^a mod p和g^b mod p,求解g^(a*b) mod p在计算上是困难的。
迪菲-赫尔曼协议完美模拟了海盗谜题:g^a 和 g^b 相当于“锁着的盒子”,私钥 a 和 b 相当于“钥匙”。然而,它和海盗谜题一样,面临中间人攻击的威胁:如果攻击者马尔能够截获并替换双方发送的 A 和 B,他就能分别与爱丽丝和鲍勃建立共享密钥,从而窃听所有通信。解决这个问题需要身份认证,这正是非对称密钥加密(公钥加密)可以提供的功能。
非对称密钥加密与RSA算法 🗝️
上一节我们看到了密钥交换的挑战,本节中我们来看看非对称密钥加密如何解决身份认证和保密性问题。在非对称加密中,每个参与者拥有一对数学上关联的密钥:公钥(公开)和私钥(保密)。
公钥加密流程:
- 加密:任何人可以使用鲍勃的公钥
PK_B对消息M加密,得到密文C = Encrypt(M, PK_B)。 - 解密:只有鲍勃可以使用他自己的私钥
SK_B对密文解密,恢复消息M = Decrypt(C, SK_B)。
公钥需要被认证(例如通过数字证书),以防止中间人攻击。接下来,我们重点学习第一个也是最著名的公钥加密算法——RSA。
RSA密钥生成:
- 爱丽丝选择两个大素数
p和q。 - 计算
n = p * q。n的长度(例如2048位)决定了安全性。 - 计算欧拉函数
φ(n) = (p-1) * (q-1)。 - 选择一个整数
e,满足1 < e < φ(n),且e与φ(n)互质(通常选e=65537)。 - 计算
e关于φ(n)的模逆元d,即满足e * d ≡ 1 (mod φ(n))的d。 - 公钥 为
(n, e)。 - 私钥 为
(d, p, q)(实际存储d即可,p和q可丢弃,但知道它们能加速运算)。
RSA加密与解密:
- 加密(发送给爱丽丝):
C ≡ M^e (mod n) - 解密(爱丽丝执行):
M ≡ C^d (mod n)
RSA为什么有效?(正确性证明)
我们需要证明 (M^e)^d ≡ M (mod n)。
根据密钥生成,有 e*d ≡ 1 (mod φ(n)),即 e*d = 1 + k*φ(n),k 为某整数。
因此,(M^e)^d ≡ M^(e*d) ≡ M^(1 + k*φ(n)) ≡ M * (M^φ(n))^k (mod n)。
根据费马小定理(及其推广欧拉定理),当 M 与 n 互质时,有 M^φ(n) ≡ 1 (mod n)。因此上式 ≡ M * 1^k ≡ M (mod n)。
当 M 与 n 不互质(由于 n=p*q,M 是 p 或 q 的倍数)时,通过分别对 mod p 和 mod q 进行类似分析,并利用中国剩余定理,也能证明结论成立。因此,RSA加解密对于所有 M < n 都成立。
RSA的安全性基于的计算难题:
- 大整数分解问题:从公开的
n中分解出p和q。如果分解成功,则可轻易计算出φ(n)和私钥d。 - RSA问题:在不知道私钥
d的情况下,从密文C和公钥(n, e)中恢复明文M。即求解M^e ≡ C (mod n)中的M。
密码学与计算复杂性 🧩
我们讨论了RSA和迪菲-赫尔曼协议,它们的安全性都依赖于特定的计算难题(因式分解、离散对数)。这些难题属于 NP问题(可以在多项式时间内验证一个解),但它们并非NP完全问题。
一个有趣的现象是:许多NP完全问题(如三染色问题、背包问题)虽然在最坏情况下极难求解,但在平均情况下往往存在高效解法或启发式算法。例如,一个随机大图很可能包含一个“小团”,从而快速判断其不可三染色。
历史上,人们曾尝试基于NP完全问题(如背包问题)构建公钥密码系统(如Merkle-Hellman背包密码系统)。该系统利用“超递增背包”(易解)生成私钥,再通过模运算将其转换为一个看似困难的“一般背包”(NPC)作为公钥。然而,这些系统几乎都被迅速攻破,因为攻击者无需解决最难的NPC实例,而是可以利用公钥结构中的特殊性质,在平均情况下轻松破解。
核心区别:
- 密码学适用的难题(如因式分解):在平均情况下也是困难的。需要精心选择参数(大素数),使得几乎所有实例都同样难解。
- NP完全问题:仅在最坏情况下被证明是困难的。存在大量容易解决的实例,使得基于其构建的密码系统在平均情况下不安全。
这正是RSA等密码系统能够经受时间考验,而许多基于NPC问题的系统失败的根本原因。
总结 📚
本节课中我们一起学习了密码学加密的核心内容:
- 对称密钥加密:双方共享同一密钥进行加解密,速度快,但密钥分发是挑战。
- 密钥交换:通过迪菲-赫尔曼协议,双方可以在不安全的信道上协商出一个共享密钥,其安全性基于离散对数难题。
- 非对称密钥加密:使用公钥/私钥对,解决了密钥分发和身份认证问题。我们深入剖析了RSA算法的密钥生成、加解密过程及其数学原理(基于费马小定理),其安全性依赖于大整数分解难题。
- 计算复杂性视角:成功的密码系统依赖于平均情况下困难的数学问题(如因式分解),而非仅在最坏情况下困难的NP完全问题。这是设计安全密码系统时需要理解的关键概念。
L23:Cache-Oblivious 算法:中值和矩阵 🧠





在本节课中,我们将要学习缓存遗忘算法。这是一种设计高效算法的新思路,它不需要知道计算机缓存的具体参数(如块大小B和缓存大小M),却能在各种硬件上自动实现高效的数据访问。我们将通过中值查找和矩阵乘法两个经典例子,来理解其核心思想和工作原理。
概述
在传统的算法分析中,我们通常假设访问内存中任何数据的成本是相同的。然而,在真实的计算机系统中,存在一个由多级缓存、主存、磁盘等构成的内存层次结构。离CPU越近的存储(如L1缓存)速度越快,但容量越小;离CPU越远的存储(如磁盘)速度越慢,但容量越大。访问不同层级的数据,延迟差异可达数百万倍。
缓存效率算法的核心目标,就是通过精心设计数据访问模式,来最小化在慢速存储(如磁盘)和快速存储(如缓存)之间传输数据块的次数。本节课将介绍一种更优雅的方法——缓存遗忘算法。它让算法本身无需知晓具体的缓存参数,却能自动适应任何内存层次结构,实现近乎最优的性能。
内存层次结构与模型
上一节我们概述了缓存效率的重要性,本节中我们来看看如何形式化地建模这个问题。
我们首先关注一个简化的两级模型:一个小的、快速的缓存(Cache)和一个大的、慢速的磁盘(Disk)。
- CPU只能直接处理缓存中的数据。
- 缓存和磁盘都被划分为大小为 B 个字的块。
- 缓存的总容量为 M 个字,即可以存放 M/B 个块。
- 当CPU需要的数据不在缓存中时,必须将包含该数据的整个块从磁盘读入缓存,这称为一次“内存传输”。
- 如果缓存已满,则需要根据某种策略(如LRU)移出一个旧块。
缓存遗忘模型 是这个模型的变体。算法编写时不知道B和M的值,它像编写普通算法一样访问内存中的单个字。而计算机系统会自动将包含该字的整个块加载到缓存中。我们的目标是分析这种“自动”行为下,算法会产生多少次内存传输。
简单的缓存遗忘算法:扫描
在深入复杂算法前,我们先看一个简单的例子:扫描数组。
以下是计算数组元素和的Python风格代码:
def sum_array(arr):
total = 0
for i in range(len(arr)):
total += arr[i]
return total
这个算法顺序访问数组arr的每个元素。在缓存遗忘模型中,当我们访问第一个元素时,系统会加载包含它的整个块(B个元素)。接着访问后续元素时,只要它们在同一块内,就无需新的内存传输。因此,总的传输次数大约为 O(n/B + 1),这几乎就是读取数据所需的最小次数。
类似地,反转数组(使用首尾两个指针向中间遍历)等需要常数个并行扫描的算法,其内存传输次数也是 O(n/B)。只要缓存能容纳几个块,这类顺序访问的算法在缓存遗忘模型中就非常高效。
缓存遗忘分治策略
上一节我们介绍了简单的顺序访问模式,本节中我们来看看构建高效缓存遗忘算法的核心策略——分治法。
分治法的流程是:分解问题、递归求解子问题、合并结果。在缓存遗忘分析中,关键在于选择正确的递归基础情况。我们不再在问题规模为O(1)时停止,而是在问题规模小到可以放入缓存或仅占少数几个块时停止。分析时,我们假设知道B和M,并计算此B和M下的内存传输次数。
直觉是:当子问题小到能完全装入缓存后,解决它所需的数据都在缓存中,后续计算不再引发内存传输。因此,整个算法的成本主要由递归树中“问题规模约等于缓存大小”的那一层决定。
案例一:中值查找
现在,让我们将分治思想应用于一个具体问题:在未排序数组中查找中位数。
我们使用第二课介绍过的线性时间最坏情况算法,但需要做一个小调整以保证缓存友好。
算法步骤:
- 将数组划分为每组5个元素的序列。
- 对每组进行排序,并找出每组的中位数。
- 关键调整:将所有中位数收集并连续存储在一个新数组中。
- 递归地找出这个中位数数组的中位数
x。 - 用
x作为枢轴原数组进行划分,得到小于和大于x的两个连续子数组。 - 在其中一个子数组上递归查找中位数。
缓存遗忘分析:
- 步骤2、5涉及对数组的几次扫描,成本为 O(n/B)。
- 步骤3确保递归调用总是在连续存储的数据上进行。
- 步骤4和6是递归调用。
- 设
MT(n)为处理规模n的问题所需的内存传输次数,我们得到递归式:
MT(n) = MT(n/5) + MT(7n/10) + O(n/B) - 基础情况:当问题规模
n小到可以放入缓存(即n = O(M))或仅占常数个块(即n = O(B))时,MT(n) = O(1)或O(M/B)。
通过递归树分析(成本由根节点主导),可以证明 MT(n) = O(n/B)。这意味着该算法以近似最优的次数读取了数据。
案例二:矩阵乘法
上一节我们分析了中值查找,本节中我们来看看另一个经典问题——矩阵乘法,如何通过分治获得更好的缓存性能。
假设我们要计算两个 n x n 矩阵 Z = X * Y。标准的三层循环算法,即使按行扫描,也需要约 O(n³/B) 次内存传输。
我们可以采用分块递归的策略:
- 将每个矩阵划分为4个
n/2 x n/2的子矩阵。 - 矩阵乘法可以递归地通过8个子矩阵的乘法和加法来计算(例如,
Z11 = X11*Y11 + X12*Y21)。 - 关键调整:矩阵必须按递归布局存储。即先存储左上子块的所有元素,然后是右上、左下、右下子块,并且每个子块内部也递归地采用同样的布局。这保证了递归调用总是在连续的内存块上进行。
- 递归计算8个子矩阵乘法,然后合并结果。
缓存遗忘分析:
- 设
MT(n)为计算n x n乘法的内存传输次数。 - 加法合并涉及扫描,成本为 O(n²/B)。
- 递归式为:
MT(n) = 8 * MT(n/2) + O(n²/B) - 基础情况:当三个矩阵的总大小(
3n²)能放入缓存(即n = O(√M))时,MT(n) = O(M/B)。
通过分析递归树,最终可得:
MT(n) = O( n³ / (B * √M) )
与标准算法的 O(n³/B) 相比,我们获得了一个 1/√M 的加速因子。由于缓存容量M通常很大(例如数MB或GB),这个加速效果非常显著。
关于块替换策略的说明
在缓存遗忘模型中,我们假设系统使用LRU(最近最少使用)或FIFO(先进先出)等策略来管理缓存块。一个重要的理论结果是:使用LRU策略、缓存大小为M的算法,其产生的内存传输次数,不会超过使用最优策略、缓存大小为M/2的算法的2倍。由于算法性能通常只对M有平缓的依赖(如多项式依赖),这个常数因子的资源差异不会显著影响渐近复杂度。这证明了我们的模型假设是合理的。
总结
本节课中我们一起学习了缓存遗忘算法的核心思想。我们了解到:
- 真实计算机存在巨大的内存访问延迟差异,算法设计必须考虑数据局部性。
- 缓存遗忘算法无需知晓缓存参数B和M,通过像编写普通算法一样访问数据,就能自动适应内存层次结构。
- 分治法是构建缓存遗忘算法的主要技术,关键在于递归布局数据以确保子问题数据连续,并选择合适的基础情况(当数据能放入缓存时)。
- 我们分析了中值查找和矩阵乘法两个案例,看到通过巧妙的分治和布局,可以将内存传输次数从朴素的 O(n/B) 或 O(n³/B) 优化到近乎最优的 O(n/B) 或 O(n³/(B√M))。
缓存遗忘算法将效率优化的工作从算法编写者转移到了算法分析者,使得代码更简洁、通用性更强,是处理大规模数据的有力工具。
R11:密码学:更多原语 🔐





在本节课中,我们将学习密码学中的更多基础概念,包括数字签名、消息认证码以及哈希树等原语。我们将探讨它们的工作原理、安全属性以及一些实际应用中的注意事项。
数字签名 ✍️
上一节我们介绍了哈希函数及其应用。本节中,我们来看看数字签名。数字签名用于验证消息的真实性,它是一对函数。
数字签名包含两个函数:
- 签名函数:使用一个秘密密钥和一条消息,生成一个签名
σ。 - 验证函数:使用一个公钥、一条消息和一个签名,输出
true或false。
我们用秘密密钥来签名,用公钥来验证。这意味着,发送者应该是唯一能对消息签名的人,而任何人都可以验证该消息确实来自该发送者。
我们希望数字签名具备以下属性:
- 正确性:如果签名
σ确实是由正确的签名函数生成的,那么验证函数应输出true。 - 不可伪造性:没有秘密密钥的攻击者,即使看到了一些消息-签名对,也无法为一条新消息生成一个有效的签名。
以下是构建数字签名的一种早期尝试(基于RSA):
# 不安全的RSA签名方案(示例)
signature = message^d mod n # 使用私钥d签名
is_valid = (signature^e mod n) == message # 使用公钥(e, n)验证
然而,这个方案存在安全漏洞,例如乘法攻击:攻击者可以将两条消息的签名相乘,得到一条新消息的有效签名。
一个改进方案是引入哈希函数:
# 改进的RSA签名方案(使用哈希)
signature = hash(message)^d mod n
is_valid = (signature^e mod n) == hash(message)
这可以抵御上述乘法攻击,因为哈希函数破坏了乘法同态性。然而,其安全性依赖于哈希函数的单向性和抗碰撞性,并且是“启发式安全”,缺乏形式化证明。
消息认证码 (MAC) 🔑
我们已经介绍了非对称密钥原语(公钥加密、数字签名)和对称密钥原语(加密)。如果通信双方共享一个秘密密钥,一方如何验证消息确实来自另一方?这就需要消息认证码。
MAC的定义与数字签名类似,但只使用一个共享密钥。
- MAC生成函数:使用密钥
K和消息M,生成一个认证码T。 - 验证函数:验证者重新计算消息的MAC,并与接收到的
T进行比较。
MAC也需要正确性和不可伪造性。一个简单的想法是使用带密钥的哈希:
# 简单的MAC构造(示例)
mac = hash(key || message) # “||” 表示连接
但需要注意连接顺序和填充,以避免某些攻击。在实践中,有更安全的专门MAC算法(如HMAC)。
数字签名和MAC的一个实际区别是不可否认性:数字签名由于使用私钥,提供了不可否认性(发送方事后不能否认发送过);而MAC双方共享密钥,任何一方都可以生成有效的MAC,因此不提供不可否认性。
哈希树 (Merkle Tree) 🌳
考虑一个云存储场景:你如何确保从服务器下载的文件是完整且未被篡改的最新版本?仅存储每个文件的哈希值会占用大量本地空间。哈希树(或称Merkle树)提供了高效的解决方案。
哈希树的结构如下:
- 对每个数据块(文件)计算哈希值,作为叶子节点。
- 将相邻两个叶子节点的哈希值连接起来,计算其哈希值,作为它们的父节点。
- 递归地进行此操作,直到生成一个根哈希值。
本地只需存储这个根哈希。要验证某个数据块:
- 服务器提供该数据块,以及从该数据块到根哈希路径上所有兄弟节点的哈希值。
- 客户端根据这些信息重新计算路径上的哈希,最终得到根哈希,并与本地存储的根哈希比较。
如果哈希函数是抗碰撞的,那么哈希树也是抗碰撞的。攻击者无法在不被察觉的情况下修改任何数据块,因为那将导致根哈希值改变。更新一个数据块只需要更新从该叶子到根路径上的 O(log n) 个哈希值。
背包密码系统回顾 🎒
最后,我们快速回顾一下背包密码系统。其思想是利用“超递增序列”背包问题的易解性和一般背包问题的难解性。
加密过程是计算一个子集和:C = Σ (mi * wi) mod m,其中 mi 是消息位,wi 是公钥序列。
解密时,利用私钥将 C 转换回超递增序列域,从而轻松求解。
然而,背包密码系统面临一个困境:为了保证正确解密,模数 m 需要大于超递增序列的总和,这限制了序列元素的大小,可能导致密度过低而容易受到攻击(如低密度攻击)。虽然大多数背包方案已被攻破,但其追求比RSA等数论方案更快的加解密速度的动机仍然值得思考。
最初的动机——基于NP完全问题构建密码系统——遇到了根本性挑战:密码学需要问题在“平均情况”下困难,而NP完全性只保证“最坏情况”下困难。
本节课中我们一起学习了数字签名、消息认证码和哈希树等密码学原语。数字签名提供了身份认证和不可否认性;MAC在共享密钥场景下提供消息认证;哈希树则是一种高效的数据完整性验证结构。最后,我们回顾了背包密码系统的原理与挑战,理解了基于计算复杂性构建密码系统的微妙之处。
L24:Cache-Oblivious 算法:搜索和排序 🧠





在本节课中,我们将学习缓存遗忘算法,并探讨其在计算机科学中两个最基本问题——搜索和排序上的应用。我们将回顾计算模型,分析经典算法的缓存性能,并介绍能达到最优缓存效率的缓存遗忘算法。
模型回顾 📊
上一节我们介绍了两种计算模型:外部存储器模型及其变体——缓存遗忘模型。本节中我们来看看它们的具体定义。
基本模型是外部存储器模型。这是一个两级内存层次结构:CPU与缓存被视为一体,它们之间可以即时通信。缓存总大小为 M 个字,并被划分为大小为 B 的块。因此,缓存中最多有 M/B 个块。当问题规模 n 很大时,数据主要存储在磁盘上。磁盘也被划分为大小为 B 的块。在该模型中,程序不能直接访问单个数据项,只能以块为单位进行读写。每次块读写称为一次“内存传输”,算法的目标是最小化内存传输的次数。
缓存遗忘模型是外部存储器模型的一个变体。算法不允许知道缓存参数 M 和 B。系统会自动管理块的加载和驱逐。当CPU访问一个不在缓存中的数据项时,系统会自动加载其所在的整个块。如果缓存已满,则采用最近最少使用(LRU)策略驱逐一个块。LRU策略驱逐的是缓存中最近被CPU使用最少的块。
LRU 策略的性能分析 📈
我们有一个定理来描述LRU策略的性能。如果将LRU在大小为 M 的缓存上需要执行的块读取(或驱逐)次数,与一个能看到未来所有访问序列的最优离线算法(OPT)在大小为 M/2 的缓存上需要执行的次数进行比较,那么LRU的次数最多是OPT的2倍。
以下是该定理的简要证明思路:
- 我们将内存访问的时间线划分为多个“阶段”。每个阶段定义为:从阶段开始起,直到访问了
M/B + 1个不同的块为止。 - 在每个阶段内,LRU最多会发生
M/B次缓存缺失(即需要加载新块)。 - 对于OPT(缓存大小为
M/2),在每个阶段开始时,其缓存中最多有(M/2)/B个块。而阶段内会访问M/B个不同的块,因此OPT至少需要加载M/B - M/(2B) = M/(2B)个新块。 - 因此,在每个阶段,LRU的代价最多是OPT代价的2倍(
(M/B) / (M/(2B)) = 2)。
这个定理表明,尽管LRU不知道未来,但在资源(缓存大小)减半的情况下,其性能与最优离线算法仅相差一个常数因子(2倍)。这证明了LRU策略以及缓存遗忘模型的合理性。
搜索问题 🔍
现在,我们来看如何在数组中搜索元素。假设我们有 n 个静态元素,需要支持查询操作:给定元素 x,找到集合中小于 x 的最大元素。
方法一:有序数组与二分查找
最直接的方法是将元素按顺序存储在数组中,然后进行二分查找。
分析:二分查找会访问大约 log₂ n 个元素。在最坏情况下,这些元素可能分布在不同的块中。因此,内存传输次数的上界大约是 log₂ n。更精确的分析表明,其复杂度为 O(log₂ n - log₂ B + 1),即 O(log₂ (n/B))。这比普通的 log₂ n 有改进,但还不够好。
方法二:B树
B树是外部存储器模型中高效的数据结构。每个节点存储 Θ(B) 个键,使得一个节点可以装入一个块中。
分析:在B树中搜索时,从根节点到叶节点的路径长度(树高)为 O(log_B n)。遍历路径时,每下降一层只需加载一个块(即一次内存传输)。因此,总的内存传输次数为 O(log_B n),即 O(log₂ n / log₂ B)。这比有序数组的二分查找要好得多。
局限性:B树需要预先知道块大小 B 来设计节点大小,因此它不是缓存遗忘的。
方法三:缓存遗忘搜索(van Emde Boas 布局)
为了在不已知 B 的情况下达到接近最优的搜索效率,我们可以使用一种特殊的二叉树内存布局,称为 van Emde Boas 布局。
算法思想:
- 构建一棵包含
n个元素的完美平衡二叉搜索树(BST)。 - 递归地布置这棵树:将树从高度中间切开,得到顶部一棵高度约为
(log₂ n)/2的子树和底部多棵较小的子树。 - 在内存中,先连续存储顶部子树的 van Emde Boas 布局,然后递归地、连续地存储每一棵底部子树的布局。
搜索操作:在这样布局的BST上进行常规的二叉搜索。
性能分析:
- 递归布局保证了任意一个高度为
O(log₂ B)的子树(即包含约B个节点)会被连续存储在O(1)个块中。 - 一次搜索从根节点到叶节点的路径会经过
O(log₂ n)个节点。 - 这条路径会穿过
O(log₂ n / log₂ B)棵这样的子树。 - 每进入一棵新的子树,最多需要2次内存传输来加载其所在的块(因为子树连续存储,最多跨越两个块)。一旦一个子树的块被加载到缓存中,在该子树内的后续访问都是免费的。
- 因此,总的内存传输次数为
O(log₂ n / log₂ B),即O(log_B n),这与B树的效率在常数因子内相同,且不需要知道参数B。
排序问题 🔢
接下来,我们探讨如何对 n 个元素进行排序。
方法一:通过 B树 插入排序
一个直观的方法是使用B树:依次将 n 个元素插入B树,然后进行中序遍历输出有序序列。
分析:每次插入需要 O(log_B n) 次内存传输,n 次插入的总成本为 O(n log_B n)。这并不理想。
方法二:缓存敏感的归并排序
我们考虑归并排序。标准的二路归并排序的递归式为:
MT(n) = 2 * MT(n/2) + O(n/B)
其中 MT(n) 表示对 n 个元素排序所需的内存传输次数,O(n/B) 是合并两个已排序子数组的成本(通过并行扫描)。
求解:
- 递归树共有
O(log₂ n)层。 - 每一层所有子问题合并的总成本为
O(n/B)。 - 因此,总成本
MT(n) = O((n/B) * log₂ n)。
这个界是 O(n log₂ n / B),比通过B树插入排序的 O(n log_B n) = O(n log₂ n / log₂ B) 要好,因为除了 log₂ n,我们还除以了 B。
方法三:多路归并排序(达到最优)
为了进一步优化,我们可以进行 M/B 路归并排序。
算法思想:
- 将数组递归地分成
M/B个子数组(每个大小约为n / (M/B)),并分别排序。 - 合并时,我们需要同时扫描这
M/B个已排序的子数组。由于缓存中可以容纳M/B个块(每个子数组的当前块),因此这次多路合并的成本仍然是O(n/B)。
递归式:
MT(n) = (M/B) * MT(n / (M/B)) + O(n/B)
求解:
- 递归树的高度变为
O(log_{M/B} (n/B))。 - 每一层的合并成本总和仍为
O(n/B)。 - 因此,总成本
MT(n) = O((n/B) * log_{M/B} (n/B))。
这个界 O((n/B) log_{M/B} (n/B)) 被证明是外部存储器模型中排序问题的最优复杂度。它同时包含了除以 B 和除以 log₂ (M/B) 的因子,比之前的二路归并排序更好。
缓存遗忘实现:
要达到缓存遗忘的最优排序,算法需要假设“高缓存假设”,即缓存足够大,能容纳至少 B^(1+ε) 个字(例如,M = Ω(B^2))。在此假设下,可以通过一种称为“漏斗排序”的复杂递归归并算法来实现相同的渐进最优界,而无需显式知道 M 和 B。
课程总结与延伸 🎓
本节课中我们一起学习了缓存遗忘算法在搜索和排序问题上的应用。
- 搜索:通过 van Emde Boas 布局,我们可以在二叉搜索树上实现
O(log_B n)次内存传输的搜索,这与需要知道B的B树性能相当,且是缓存遗忘的。 - 排序:通过多路归并排序,我们可以达到
O((n/B) log_{M/B} (n/B))次内存传输的最优排序复杂度。在缓存遗忘模型中,需要额外的“高缓存假设”来实现相近的性能。
这些技术可以进一步推广到更动态的场景。例如,可以设计缓存遗忘的优先级队列,支持插入、删除最小值等操作,每个操作仅需 O((1/B) log_{M/B} (n/B)) 次内存传输。使用这样的优先级队列进行 n 次插入和删除,自然就得到了一个最优的排序算法。
对算法更深入的学习可以引导你探索许多高级课程,例如高级算法、计算几何、高级数据结构、分布式算法、算法博弈论、网络优化、随机化算法、密码学、并行编程以及计算复杂性理论等。每门课都打开了一个独特而迷人的算法世界。


浙公网安备 33010602011771号