斯坦福算法笔记-全-
斯坦福算法笔记(全)
001:为何学*算法 🧠

在本节课中,我们将探讨学*算法设计与分析的重要性。我们将从算法的基本定义开始,逐步了解其在计算机科学、技术创新乃至更广泛领域中的核心作用。
什么是算法?
算法是一组明确定义的规则,本质上是一个用于解决特定计算问题的“配方”。
例如:
- 你可能有一组数字,需要将它们重新排列成有序序列。
- 你可能有一张地图、一个起点和一个终点,需要计算从起点到终点的最短路径。
- 你可能面临多个需要在不同截止日期前完成的任务,需要确定完成任务的顺序,以确保所有任务都能在各自的截止日期前完成。
为何学*算法?
上一节我们了解了算法的基本概念,本节中我们来看看学*算法的几个关键原因。
原因一:计算机科学工作的基础
理解算法和数据结构的基础知识,对于从事计算机科学几乎所有分支的严肃工作都至关重要。这也是斯坦福大学计算机科学系所有学位(学士、硕士和博士)都要求学*这门课程的原因。
以下是几个具体例子:
- 路由和通信网络 依赖于经典的最短路径算法。
- 公钥密码学的有效性 依赖于数论算法。
- 计算机图形学 需要几何算法提供的计算原语。
- 数据库索引 依赖于平衡搜索树数据结构。
- 计算生物学 使用动态规划算法来衡量基因组相似性。
原因二:技术创新的关键驱动力
算法在现代技术创新中扮演着关键角色。一个最明显的例子是搜索引擎,它使用一系列复杂的算法来高效计算各个网页与给定搜索查询的相关性。其中最著名的算法是谷歌目前使用的PageRank算法。
事实上,在2010年12月提交给美国白宫的一份报告中,总统科技顾问委员会指出,在许多领域,算法改进带来的性能提升,甚至远远超过了处理器速度提升所带来的显著性能增益。

原因三:理解世界的全新视角
虽然这超出了本课程的范围,但算法正越来越多地被用作观察计算机科学和技术之外过程的新视角。
例如:
- 对量子计算的研究为量子力学提供了新的计算视角。
- 经济市场中价格波动可以被富有成效地视为一种算法过程。
- 甚至进化也可以被有效地看作一种出奇有效的搜索算法。
原因四:智力的挑战与乐趣
最后两个学*算法的原因听起来可能有些随意,但都包含不少真理。

首先,具有挑战性的课程在努力攻克后,能让人感觉比开始时更聪明。希望这门课程能为你们中的许多人提供类似的体验。
其次,希望到课程结束时,我能说服你们中的一些人同意我的观点:算法设计与分析本身就充满乐趣。这是一项需要精准性与创造性罕见结合的事业。它有时确实令人沮丧,但也极易让人上瘾。
从抽象回归具体
现在,让我们从这些崇高的概括中回归,变得更加具体。请记住,我们从小就开始学*和使用算法了。
本节课中,我们一起学*了算法的基本定义,并深入探讨了学*算法的四大原因:它是计算机科学的基础、技术创新的核心、理解世界的新透镜,同时也是一项充满智力挑战和乐趣的活动。在接下来的课程中,我们将开始具体探索各种经典的算法思想与技术。
002:整数乘法

概述
在本节课中,我们将要学*整数乘法问题。我们将首先精确地定义这个计算问题,明确其输入和期望的输出。接着,我们将回顾你在小学三年级时学到的整数乘法算法,并分析其性能。最后,我们将提出一个核心问题:我们能否找到比这个“三年级算法”更好的解决方案?

问题定义
在深入算法之前,我们需要精确地定义整数乘法问题。

输入:两个 n 位数字的整数 X 和 Y。这里的 n 可以非常大,例如在需要处理大数的密码学应用中,n 可能达到数千甚至更大。


输出:这两个整数的乘积,即 X * Y。
三年级算法回顾
上一节我们定义了问题,本节中我们来看看解决这个问题的经典算法——三年级算法。这个算法通过一系列明确的规则,将输入的两个数字转换为其乘积。


以下是该算法在一个具体例子(1234 乘以 5678)中的步骤演示:


- 为第二个数字(乘数)的每一位计算一个部分积。
- 从最低位开始,用该位数字去乘第一个数字(被乘数)的每一位,并处理进位。
- 每计算完一个部分积,就在下一个部分积的末尾添加一个零(相当于左移一位)。
- 最后,将所有部分积相加,得到最终结果。

算法性能分析
在了解了算法的步骤后,我们来分析它的性能。我们通过计算算法执行的基本操作数量来评估其性能。这里,基本操作定义为两个单位数字的加法或乘法。

我们的目标是分析基本操作数量如何随输入数字的位数 n 增长。
以下是三年级算法所需操作数量的非正式分析:
- 计算一个部分积:最多需要对第一个数字的
n位各做一次乘法,以及处理进位带来的最多n次加法。因此,计算一个部分积最多需要2n次操作。 - 计算所有部分积:共有
n个部分积(对应第二个数字的n位),所以总共最多需要n * 2n = 2n²次操作。 - 将所有部分积相加:这最多需要另一个
2n²量级的操作。
结论:三年级算法执行的操作总数大致为 4n²,即其时间复杂度是输入长度 n 的二次函数,记作 O(n²)。
这意味着,如果输入数字的位数 n 翻倍,算法所需的操作数将变为原来的四倍;如果 n 变为四倍,操作数将变为原来的十六倍。



寻求更优解
在三年级时,你或许认为这是唯一或最优的乘法方式。然而,作为一名算法设计者,我们需要培养一种不满足于现状的品质。
算法设计领域一本重要的早期教科书曾指出:“优秀算法设计师最重要的原则,或许是拒绝满足。”

我们可以将其更简洁地概括为算法设计师的座右铭:“我们能否做得更好?”
当你面对一个像三年级整数乘法算法这样直接但可能并非最优的解决方案时,这个问题尤其贴切。现在,是时候尝试回答这个问题了。
总结
本节课中我们一起学*了整数乘法问题。我们精确地定义了问题,回顾并分析了经典的“三年级算法”,发现其时间复杂度为 O(n²)。最后,我们提出了算法设计的核心精神:永不满足,并始终追问“我们能否做得更好?”。在接下来的课程中,我们将探索更高效的整数乘法算法。
003:Karatsuba乘法 🧮

在本节课中,我们将学*一种不同于小学所学的整数乘法算法——Karatsuba乘法。我们将从直观的例子入手,逐步理解其递归思想,并最终掌握这个看似“神秘”但高效的算法。
概述
我们通常使用小学所学的竖式乘法来计算两个整数的乘积。然而,算法设计的世界远比我们想象的要丰富。本节将介绍一种名为Karatsuba乘法的递归算法,它通过巧妙的数学变换,用更少的递归调用完成乘法运算。
一个神秘的例子
让我们通过一个具体例子来感受Karatsuba乘法的神奇之处。我们将计算 1234 × 5678,但使用一套全新的步骤。

首先,将每个数字分成两半,并定义新的变量:
- 对于 x = 5678:前半部分
A = 56,后半部分B = 78。 - 对于 y = 1234:前半部分
C = 12,后半部分D = 34。


以下是计算步骤:

步骤一:计算 A × C 和 B × D。
A × C = 56 × 12 = 672B × D = 78 × 34 = 2652

步骤二:计算 (A + B) 和 (C + D) 的和,然后相乘。
A + B = 56 + 78 = 134C + D = 12 + 34 = 46(A+B) × (C+D) = 134 × 46 = 6164

步骤三:从步骤二的结果中减去步骤一的两个结果。
6164 - 2652 - 672 = 2840

步骤四:将三个结果按特定方式组合。
- 将
A×C的结果672后补 4 个零:6720000 - 将步骤三的结果
2840后补 2 个零:284000 - 将
B×D的结果2652保持不变:2652 - 将三者相加:
6720000 + 284000 + 2652 = 7006652
验证可知,7006652 正是 1234 × 5678 的正确结果。这个计算过程与小学算法完全不同,但得出了相同的答案。

递归乘法思想
上一节我们看到了一个神奇的计算序列,本节中我们来看看其背后的递归思想。要理解Karatsuba算法,首先需要了解一个更简单的递归乘法方法。
数字的分解

给定两个 n 位数 x 和 y,我们可以将它们各自分解为前半部分和后半部分:
公式表示:
x = A * 10^(n/2) + B
y = C * 10^(n/2) + D
其中,A 和 C 是各自的前半部分(各 n/2 位),B 和 D 是各自的后半部分。

在我们的例子中:
x = 5678,A = 56,B = 78,n=4,10^(n/2) = 100y = 1234,C = 12,D = 34
乘积的表达式

将 x 和 y 的分解式相乘,我们可以得到它们的乘积表达式:

公式推导:
x * y = (A*10^(n/2) + B) * (C*10^(n/2) + D)
= A*C * 10^n + (A*D + B*C) * 10^(n/2) + B*D
我们称这个表达式为 (星号表达式)。
这个表达式表明,计算 x * y 这个大问题,可以转化为计算四个更小的子问题:A*C、A*D、B*C 和 B*D。这就是递归的基础。

简单的递归算法
基于星号表达式,一个直接的递归算法思路如下:
算法步骤:
- 递归地计算
A*C。 - 递归地计算
A*D。 - 递归地计算
B*C。 - 递归地计算
B*D。 - 当所有递归调用返回结果后,按照星号表达式进行组合:
- 将
A*C的结果后补 n 个零。 - 将
(A*D + B*C)的结果后补 n/2 个零。 - 将
B*D的结果保持不变。 - 将上述三项相加,得到最终乘积。
- 将
递归基(Base Case): 当输入的两个数字都只有一位时,直接相乘并返回结果。
这个算法虽然可行,但它进行了 四次 递归调用。接下来,我们将看到Karatsuba如何将其优化为仅需 三次 递归调用。
Karatsuba乘法算法
上一节我们介绍了需要四次递归调用的简单递归乘法。本节中,Karatsuba算法的核心洞察在于,我们并不需要单独计算 A*D 和 B*C,而只需要它们的和 (A*D + B*C)。通过一个巧妙的数学技巧,我们可以用三次递归调用得到这个和。
关键的优化
回顾星号表达式,我们真正关心的三个系数是:
A*C(A*D + B*C)B*D
Karatsuba算法通过以下步骤,仅用三次递归调用计算出这三个量:
算法步骤:
- 递归计算
P1 = A * C。 - 递归计算
P2 = B * D。 - 递归计算
P3 = (A + B) * (C + D)。
现在,关键的技巧来了。让我们展开 P3:
P3 = (A+B)*(C+D) = A*C + A*D + B*C + B*D
如果我们从 P3 中减去 P1 和 P2:
P3 - P1 - P2 = (A*C + A*D + B*C + B*D) - (A*C) - (B*D) = A*D + B*C
看!我们得到了所需的第二个系数 (A*D + B*C),而无需单独计算 A*D 或 B*C。
完整的计算流程
因此,完整的Karatsuba乘法递归算法如下:

伪代码描述:
function KaratsubaMultiply(x, y):
if x 和 y 都是个位数:
return x * y
将 x 分解为 A 和 B (A是前半部分,B是后半部分)
将 y 分解为 C 和 D
P1 = KaratsubaMultiply(A, C)
P2 = KaratsubaMultiply(B, D)
P3 = KaratsubaMultiply(A+B, C+D)
middle_term = P3 - P1 - P2 # 这就是 (A*D + B*C)
n = x 和 y 的位数
return P1 * 10^n + middle_term * 10^(n/2) + P2
这个算法在递归调用之外,只进行了简单的加法和减法操作,以及最后的位数补齐(乘以10的幂)和求和。
总结

本节课中我们一起学*了Karatsuba整数乘法算法。我们从一个小学生算法截然不同的神秘计算例子开始,揭示了算法设计的多样性。然后,我们逐步推导:
- 首先,我们理解了如何将大整数乘法问题分解为更小的子问题,并建立了一个需要四次递归调用的简单递归算法框架。
- 接着,我们看到了Karatsuba算法的核心优化:通过计算
(A+B)*(C+D)并利用一次减法,巧妙地用三次递归调用替代了原来的四次,从而得到了所需的中间项(A*D + B*C)。
Karatsuba算法展示了即使在整数乘法这样基础的问题上,通过深入的数学洞察和巧妙的算法设计,也能获得令人惊喜的优化。虽然我们现在还不知道它是否比小学算法更快,但这为我们打开了一扇门,去探索“分治算法”这一强大的算法设计范式的效率分析。在后续课程中,我们将获得分析此类算法运行时间的工具箱。
004:关于本课程 📚
在本节课中,我们将了解这门课程的整体情况。我们将介绍课程涵盖的核心主题、你将能学到的技能、课程对学*者的背景要求、相关的学*资料以及用于自我评估的工具。
课程涵盖的主题 📋

本课程内容对应斯坦福大学一门为期10周的本科生及研究生必修课程的前半部分。课程将围绕五个高级主题展开,这些主题有时会相互关联。
以下是本课程将涵盖的五个核心主题:
- 算法性能分析的词汇:这是讨论算法设计与分析的基础。
- 分治算法设计范式:一种通过递归分解问题来解决问题的强大方法。
- 算法设计中的随机化:利用随机性来设计简洁、优雅且高效的算法。
- 图论推理的基本操作:处理图结构数据的快速计算原语。
- 基本数据结构的使用与实现:组织数据以支持高效查询的关键工具。
课程的目标是在每个主题上提供入门介绍和基本认知。当然,每个主题都有远超本课程时间所能涵盖的丰富内容。

上一节我们概述了课程的整体框架,本节中我们来详细看看第一个主题:算法性能分析。
主题一:算法性能分析
这是最短但也可能是最枯燥的部分,但它是严肃思考算法设计与分析的先决条件。这里的核心概念是大O表示法。

大O表示法在概念上是一种建模选择,它决定了我们衡量算法性能(如运行时间)的粒度。事实证明,清晰、高层次地思考算法设计的“最佳点”是忽略常数因子和低阶项,专注于算法性能如何随输入规模增大而扩展。大O表示法正是将这一“最佳点”数学化的方式。
其核心公式为:T(n) = O(f(n)),表示算法运行时间 T(n) 的增长速度不超过函数 f(n) 的某个常数倍。

理解了分析工具后,我们来看看本课程重点讲解的一种强大的算法设计方法。
主题二:分治算法
在算法设计中,没有能解决所有计算问题的“银弹”。尽管如此,仍存在一些通用的算法设计技术,它们作为高层次的问题解决方法,在众多不同领域都有成功应用。这些相对广泛适用的技术构成了本课程的主干。
本课程将深入探讨一种算法设计范式:分治算法。分治算法的思想是:首先将问题分解为更小的子问题,然后递归地解决这些子问题,最后快速地将子问题的解组合成原始问题的解。
例如,在上一个视频中,我们看到了两个这种类型的算法——两个用于大整数乘法的分治算法。在后续视频中,我们将看到分治算法的多种应用,包括排序、矩阵乘法、计算几何中的最*邻问题等。此外,我们还将介绍一些分析此类递归算法运行时间的强大方法。
一个典型的分治算法伪代码框架如下:
function DivideAndConquer(problem):
if problem is small enough:
return solve directly
else:
subproblems = divide(problem)
solutions = []
for each sub in subproblems:
solutions.append(DivideAndConquer(sub))
return combine(solutions)
除了确定性的方法,随机性也能为算法设计带来意想不到的简洁与高效。
主题三:随机化算法
随机化算法在其执行过程中会“抛硬币”,即对于固定的输入,多次运行随机化算法可能会得到不同的执行过程。允许算法内部使用随机性,常常能为各种计算问题带来简单、优雅且实用的解决方案,这听起来可能并不直观。
典型的例子是随机快速排序,我们将在几节课中详细讨论该算法及其分析。我们还将涉及随机素数测试、图划分的随机化方法,并讨论随机性在哈希函数和哈希映射中的应用原理。


掌握了算法设计范式,我们还需要一些“趁手”的工具来处理数据。接下来,我们看看那些快如闪电的计算原语。

主题四:图论计算原语
本课程的一个主题,也是我希望你能掌握的具体技能之一,是熟悉一系列用于操作数据的计算原语。它们速度极快,在某些意义上几乎是“免费”的——即调用这些原语所需的时间仅比你检查或读取输入数据的时间多一点点。
当你拥有一个如此快速的原语时,你应该随时准备在预处理步骤中应用它,只要它看起来可能有帮助。排序就是这种形式的一个典型例子。但也有一些原语能操作更复杂的数据,比如图。
图数据结构包含顶点和连接顶点对的边,它可以建模多种类型的网络。尽管图比简单数组复杂得多,但仍有许多极其快速的原语可用于推理其结构。在本课程中,我们将重点学*计算连通性信息和最短路径的原语,并简要介绍这些原语如何被用于研究社交网络中的信息结构。
最后,任何高效的算法都离不开精心组织的数据。我们来认识一下算法的“后勤部长”——数据结构。
主题五:基本数据结构
数据结构通常是设计快速算法的关键组成部分,它负责以支持快速查询的方式组织数据。不同的数据结构支持不同类型的查询。
我假设你熟悉基础编程课程中常见的数据结构,包括数组、向量、列表、栈和队列。希望你也接触过树和堆,或者愿意在课外稍作了解。课程中我们会简要回顾这些数据结构。
我们将详细讨论两种极其有用的数据结构:
- 平衡二叉搜索树:这种数据结构能动态维护一组元素的排序,同时支持大量查询操作,且查询时间与集合大小的对数成正比。
- 哈希表(或哈希映射):这种数据结构能跟踪动态集合,同时支持极快的插入和查找查询。我们将讨论此类数据结构的典型用途及其典型实现背后的原理。
以上便是本五周课程的核心内容。当然,算法设计与分析中还有许多重要概念无法在本课程中涵盖。
后续课程与扩展主题 📈
其中一些主题将在后续课程《算法设计与分析2》中讲解,该课程对应斯坦福10周课程的后半部分。该课程的第一部分侧重于另外两种算法设计范式:贪心算法(应用于最小生成树、调度、信息论编码)和动态规划算法(应用于基因组序列比对、通信网络中的最短路径协议)。

该课程的第二部分涉及NP完全问题及其应对策略。NP完全问题是在著名的“P是否等于NP”猜想下,被认为无法被任何计算高效算法解决的问题。我们将讨论NP完全性理论,并重点关注它对你作为算法设计者意味着什么。我们还将探讨处理NP完全问题的几种方法,包括正确解决特殊情况的快速算法、具有可证明性能保证的快速启发式算法,以及比暴力搜索质上更快的指数时间算法。
当然,还有许多重要主题无法纳入这两个五周课程。根据需求,未来可能会有关于更高级主题的进一步课程。
了解了学什么,你可能会问:我能从中获得什么?接下来,我们谈谈学*本课程的收获。
课程收获与技能提升 🚀
首先,尽管这不是一门纯粹的编程课,但它应该能让你成为更好的程序员。你将获得大量描述和推理算法的练*;学*算法设计范式(即适用于不同领域多种问题的高级问题解决策略)以及预测此类算法性能的工具;你将学*几种用于处理数据的极快速子程序,以及几种可用于自己程序中的有用数据结构。

其次,虽然这不是一门纯粹的数学课,但我们最终会进行相当多的数学分析,这反过来会提高你的数学和分析技能。你可能会问,为什么数学与这门看似更像编程课的算法设计与分析课程相关?我的目标是解释事物为何如此,为什么我们以某种方式分析算法,为什么各种超快算法确实超快。好的算法思想通常需要非平凡的数学分析才能正确理解。你将获得对本课程讨论的特定算法和数据结构的基本洞察,希望这些洞察能更广泛地应用于你的其他工作中。
第三,对于那些在其他学科工作的学*者,本课程应帮助你学*如何以算法方式思考。在学*算法之后,你会发现它们几乎无处不在。正如上一个视频所说,算法思维在计算机科学和技术之外的领域(如生物学、统计学和经济学)正变得越来越有用和普遍。
第四,如果你想在某种意义上感觉自己是一名“持证”的计算机科学家,那么你肯定需要对我们涵盖的所有主题有基本的了解。学*算法的一大乐趣在于,你真的感觉自己在学*过去50年计算机科学的许多“经典之作”。上完这门课,当有人在计算机科学鸡尾酒会上开关于迪杰斯特拉算法的玩笑时,你将不再感到被排除在外。
最后,毫无疑问,学*这些材料有助于应对技术面试问题。需要明确的是,我在这里的唯一目标是教你算法,而不是专门为你准备面试。但多年来,无数学生告诉我,掌握本课程的概念使他们能够出色地回答所有被问到的技术问题。我告诉过你,这是基础的东西。

明确了收获,我们来看看学*本课程需要哪些准备。
预备知识与背景要求 🎯
首先,我设想的学*者是至少懂一些编程的人。例如,考虑上一讲,我们讨论了一种递归方法将两个数字相乘,我提到了某个数学表达式如何自然地转化为递归算法。我假设你对递归程序有一定的熟悉度。如果你能根据我给出的大纲编写出递归整数乘法算法,那么你应该能很好地适应本课程。

虽然我针对的是懂一些编程的人,但我对你知道哪种具体的编程语言不做任何假设。任何标准的命令式语言,如C、Java或Python,对本课程来说都完全没问题。为了使课程尽可能面向更多程序员,并促进在相对抽象和概念性的层面上思考编程,我不会用任何特定的编程语言描述算法。
相反,在讨论算法时,我将只使用高级伪代码或通常简单的英语。我的归纳假设是,你能够将这种高级描述转换为你最喜欢的编程语言中的工作程序。事实上,我强烈鼓励每位观看讲座的人对我们讨论的所有算法进行这样的转换,这将确保你对它们的理解和欣赏。
其次,对于这些讲座,我设想的学*者是至少拥有适度数学经验的人,尽管可能有些生疏。具体来说,我希望你能识别逻辑论证(即证明)。此外,我希望你以前见过两种证明方法:数学归纳法和反证法。我还需要你熟悉基本的数学符号,如标准量词和符号。关于随机化算法和哈希的几节课,如果你在生活中某个时刻接触过离散概率,会更容易理解。但除了这些基础知识,讲座将是自包含的,你甚至不需要知道任何微积分。

我想你们中的许多人过去都学过数学,但可能需要复*一下。网上有大量免费资源,我鼓励你去探索并找到一些你喜欢的。我特别想推荐一套很棒的免费讲义,名为《计算机科学数学》,由Eric Lehman和Tom Leighton编写,在网上很容易找到。这些讲义涵盖了我们所需的所有先决条件以及大量其他内容。
为了让你能更顺利地学*,这里有一些推荐的学*资料。
学*资料与工具 📖
本着使本课程尽可能广泛适用的精神,我们将所需的辅助材料保持在最低限度。讲座旨在自成一体,我们将始终以PowerPoint和PDF格式提供讲义。偶尔,我们也会提供一些额外的讲义。
本课程不需要教科书。也就是说,我们将学*的大部分材料在许多优秀的算法书中都有很好的介绍。我在这里特别列出四本书:前三种对我思考和教授算法的方式产生了重大影响。关于第二本书(作者Dasgupta, Papadimitriou, Vazirani)的一个很酷的事情是,作者已将其版本免费提供在线。同样,第四本书的作者也基本上将完整版本免费提供在线,并且与我们将要涵盖的材料很匹配。
如果你正在寻找本课程中某部分内容的更多细节,或者仅仅是我给出的解释之外的不同解释,所有这些书对你来说都是很好的资源。还有许多优秀的算法教科书我没有列入此列表,我鼓励你去探索并找到你自己的最爱。
在我们的作业中,有时会要求你编写一个算法并用它来解决一个因规模太大而无法手工解决的具体问题。我们不关心你使用什么编程语言或开发环境来做这件事,因为我们只要求你提供最终答案。因此,我们不要求任何特定的东西,只要求你能够编写和执行程序。如果你需要关于如何设置合适编码环境的帮助或建议,我们建议你通过课程讨论论坛向其他学生寻求帮助。
最后,我们来谈谈课程评估,这能帮助你检验学*成果。

课程评估与作业 📝
本课程本身没有官方成绩,但我们将布置每周作业。我们布置作业有三个不同的原因:第一是为了自我评估,让你有机会测试对材料的理解,从而弄清楚哪些主题你已经掌握,哪些还需要努力。第二是为了给课程提供一些结构,包括截止日期,为你提供学*所有主题的额外动力。截止日期还有一个非常重要的副作用,它同步了班级中的许多学生,这当然使课程讨论论坛成为学生寻求和提供帮助以理解课程材料的更有效工具。我们布置作业的最后一个原因是满足那些除了学*课程材料外,还希望在智力上挑战自己的学*者。
本课程有数万名学生,因此作业必须能够自动评分,这显然是至关重要的。目前,我们仅处于此类免费在线课程的1.0代,因此可用于自动评分评估的工具目前还相当初级。我们会尽力而为,但我必须诚实地告诉你,使用当前的工具集来测试对算法设计与分析的深入理解是困难的,甚至可能是不可能的。因此,虽然本在线课程的讲座内容丝毫没有从原始斯坦福版本中缩水,但我们将布置的必做作业和考试不如校园版课程的要求那么高。为了弥补这一事实,我们将偶尔通过视频或补充作业提出可选的算法设计问题。我们没有能力为这些评分,但我们希望你会发现它们有趣且具有挑战性,并希望你能通过课程讨论论坛与其他学生讨论可能的解决方案。
我希望以上讨论解答了你关于本课程的大部分问题。现在,让我们进入我们聚集于此的真正原因:学*更多关于算法的知识。
005:归并排序动机与示例 🧩
在本节课中,我们将学*如何分析一个算法。我们将通过回顾著名的归并排序算法,并给出其运行所需操作次数的数学上精确的上界,来首次体验算法分析的过程。

概述
归并排序是一个古老但高效的排序算法,它完美地体现了“分治”这一算法设计范式。我们将探讨其工作原理,分析其性能,并理解为何它在许多场景下优于更简单的排序算法。
为何从归并排序开始?


尽管归并排序的历史可以追溯到1945年,但它至今仍在实践中被广泛使用,是许多编程库中的标准排序算法。选择它作为起点的原因如下:
- 分治范式的典范:归并排序清晰地展示了如何将问题分解为子问题、递归求解并合并结果。
- 性能优势:相比选择排序、插入排序和冒泡排序等具有O(n²) 性能的简单算法,归并排序能提供更好的运行时间。
- 校准预备知识:对归并排序的讨论有助于你判断自己的背景知识是否与本课程的预期受众水平相符。
- 分析方法的引入:对归并排序的分析将自然引出本课程分析算法的一般方法,包括最坏情况分析、渐进分析和递归树方法。
排序问题
归并排序旨在解决经典的排序问题。

输入:一个包含n个任意顺序数字的数组。
目标:输出一个数组,其中的数字按从小到大的顺序排列。
例如,对于输入数组 [5, 4, 1, 8, 7, 2, 6, 3],目标是得到输出数组 [1, 2, 3, 4, 5, 6, 7, 8]。为简化讨论,我们假设数组中的元素互不相同。
归并排序工作原理
归并排序是一个递归算法。其核心思想是分治:
- 分:将数组分成两半。
- 治:递归地对每一半进行排序。
- 合:将两个已排序的半部分合并成一个完整的已排序数组。
让我们通过一个示例来可视化这个过程。考虑输入数组 [5, 4, 1, 8, 7, 2, 6, 3]。
首先,算法将数组分成左半部分 [5, 4, 1, 8] 和右半部分 [7, 2, 6, 3]。通过递归调用,左半部分被排序为 [1, 4, 5, 8],右半部分被排序为 [2, 3, 6, 7]。
最后,通过一个名为 “合并” 的过程,将这两个已排序的子数组合并成最终的排序结果 [1, 2, 3, 4, 5, 6, 7, 8]。合并操作通过遍历两个子数组,按顺序选取元素来高效地完成。
总结

本节课我们一起学*了归并排序算法的动机和基本示例。我们了解到归并排序是分治算法设计范式的典型代表,它通过递归地将问题分解、求解并合并,实现了比简单排序算法更优的性能。在接下来的章节中,我们将深入探讨其具体的伪代码实现和运行时间的详细分析。
006:归并排序伪代码与运行时间分析 🧠
在本节课中,我们将学*归并排序算法的具体伪代码实现,并对其运行时间进行深入分析。我们将从伪代码的讲解开始,逐步过渡到对算法效率的量化评估。
归并排序伪代码

上一节我们介绍了归并排序的基本思想,本节中我们来看看其具体的伪代码实现。首先,我们给出一个高层次的算法框架,暂时忽略合并子程序的具体实现细节。
以下是归并排序的顶层伪代码:

MergeSort(A):
if length(A) <= 1:
return A
else:
left = MergeSort(A[0...mid])
right = MergeSort(A[mid...end])
return Merge(left, right)
这段伪代码体现了分治策略的核心:递归地将数组分成两半,分别排序,然后合并结果。需要说明的是,为了专注于核心概念,我们在此忽略了一些实现细节,例如处理奇数长度数组的边界情况,以及递归调用时如何传递子数组的具体编程语言细节。
合并子程序伪代码
归并排序中相对复杂的部分是合并步骤。递归调用完成后,我们得到两个已排序的子数组,需要将它们合并成一个完整的有序数组。
以下是合并步骤的详细伪代码:
Merge(A, B):
// A和B是两个已排序的输入数组
// C是输出数组
i = 0, j = 0, k = 0
while k < length(A) + length(B):
if i < length(A) and (j >= length(B) or A[i] <= B[j]):
C[k] = A[i]
i = i + 1
else:
C[k] = B[j]
j = j + 1
k = k + 1
return C
合并算法的核心思想是:同时遍历两个已排序的子数组,每次比较两个数组当前指针所指的元素,将较小的元素复制到输出数组中,并移动相应的指针。这个过程持续进行,直到所有元素都被处理完毕。
合并子程序的运行时间分析
在了解了归并排序的伪代码后,我们自然要问:它比插入排序等简单算法好在哪里?本节我们将分析归并排序的运行时间。
我们首先分析合并子程序的运行时间,这是一个更简单的起点。根据上面的伪代码,我们可以逐条计算其执行的操作数量。
以下是合并子程序运行时间的粗略计算:
- 初始化操作(
i=0, j=0):计为2次操作。 while循环:执行m次(m为两个子数组的总长度)。- 每次迭代包含一次比较、一次赋值和一次指针递增,计为3-4次操作。
综合来看,合并一个总长度为 m 的数组,运行时间最多为 4m + 2 次操作。为了后续分析的简便,我们可以使用一个更宽松但正确的上界:最多 6m 次操作。
归并排序的整体运行时间分析
分析归并排序的整体运行时间更具挑战性,因为它涉及递归调用。这里存在两种力量的博弈:一方面,递归导致子问题数量呈指数级增长;另一方面,每个子问题的规模在不断减半。

我们将证明以下关于归并排序运行时间上界的重要结论:

归并排序对 n 个元素的数组进行排序,最多需要 6n * log₂n + 6n 次操作。
这个结论表明,归并排序的运行时间与 n log n 成正比,而不是像插入排序那样与 n² 成正比。为了理解这个改进的意义,我们需要认识对数函数 log n 的增长速度远慢于线性函数 n,因此 n log n 比 n² 要小得多,尤其是在 n 很大时。
例如:
- 当
n=32时,log₂32 = 5。 - 当
n=1024时,log₂1024 = 10。
随着 n 的增大,log n 的增长极其缓慢,这使得归并排序在处理大规模数据时具有显著的速度优势。

本节课中我们一起学*了归并排序的伪代码实现,并对其运行时间进行了初步分析。我们了解到,归并排序通过分治策略,将排序问题分解为更小的子问题,并通过高效的合并步骤组合结果,最终实现了 O(n log n) 的时间复杂度,这比许多简单排序算法的 O(n²) 要高效得多。在接下来的课程中,我们将进一步深入证明这个运行时间上界。
007:归并排序分析 📊
在本节课中,我们将对归并排序算法进行运行时间分析,特别要证明递归分治的归并排序算法比插入排序、选择排序和冒泡排序等简单排序算法性能更优。具体目标是数学论证以下结论:对包含 n 个数字的数组进行排序,归并排序最多执行常数乘以 n log n 次操作,即最多执行 6n log n + 6n 行代码。
递归树方法 🌳

为了证明上述结论,我们将使用递归树方法。该方法的核心思想是将递归归并排序算法完成的所有工作以树状结构表示,其中每个节点的子节点对应其进行的递归调用。这种树状结构有助于以有趣的方式统计算法的总工作量,并极大简化分析过程。
具体来说,递归树的结构如下:


在树的第 0 层,我们有一个根节点,对应归并排序的最外层调用。由于归并排序每次递归会进行两次调用,因此该树是二叉树。根节点处理整个输入数组。

在第 1 层,有两个子问题,分别对应输入数组的左半部分和右半部分。每个第 1 层的递归调用又会进行两次递归调用,分别处理原始输入数组的四分之一,形成第 2 层的四个子问题。此过程持续进行,直到递归达到基本情况,即数组大小为 0 或 1。
现在,我们提出一个问题:在递归树的底部,即对应基案例的叶子节点,位于哪一层?


正确答案是第二项。递归树的层数基本与输入数组大小的对数成正比。原因是每深入一层递归,输入大小就减半。从最外层的 n 开始,第一层递归处理大小为 n/2 的数组,第二层处理大小为 n/4 的数组,依此类推,直到数组大小不超过 1。因此,递归层数正是将 n 除以 2 直到结果不超过 1 所需的次数,这正是以 2 为底的 n 的对数定义。
由于第一层是第 0 层,最后一层是第 log₂n 层,总层数为 log₂n + 1。


这里假设 n 是 2 的幂,这并非大问题,分析可轻松扩展到 n 不是 2 的幂的情况,但这样我们无需考虑分数,log₂n 为整数。
逐层工作量计算 🔢
现在回到递归树,我们重新绘制它。在树的底部,即叶子节点,对应基案例,当 n 是 2 的幂时,这些基案例恰好是单元素数组。

以这种方式组织归并排序的工作量,允许我们逐层统计工作量,这是一种特别方便的方法,可以计算所有执行的代码行数。
为了更详细地理解,我们需要识别一个特定模式。首先,在递归树的第 J 层,有多少个不同的子问题?其次,对于第 J 层的每个子问题,输入大小是多少?



正确答案是第三项。在第 J 层,恰好有 2ᴶ 个不同的子问题。因为归并排序每次调用自身两次,所以每层子问题数量翻倍。同时,输入大小每次递归减半,因此经过 J 层后,每个子问题处理的数组长度为 n / 2ᴶ。
现在,我们利用这个模式实际计算归并排序执行的所有代码行数。关键思想是逐层统计工作量。明确地说,第 J 层的工作量是指该层 2ᴶ 个归并排序实例完成的工作,不包括它们各自的递归调用,也不包括树中更低层递归完成的工作。
回顾归并排序算法,它只有三行代码:两次递归调用和一次合并子程序调用。在第 J 层,我们不计算递归调用,只计算合并子程序的调用。我们已经知道,合并子程序在大小为 m 的输入上最多需要 6m 行代码。
固定第 J 层,我们知道子问题数量为 2ᴶ,每个子问题的大小为 n / 2ᴶ,并且知道合并子程序在此类输入上的工作量。我们只需乘以 6,然后相乘,得到第 J 层所有子问题的总工作量。

具体计算如下:从第 J 层的子问题数量 2ᴶ 开始,每个子问题的输入大小为 n / 2ᴶ。合并子程序在大小为 n / 2ᴶ 的数组上最多执行 6 * (n / 2ᴶ) 行代码。因此,第 J 层的总工作量是子问题数量乘以每个子问题的工作量:
工作量 ≤ 2ᴶ * 6 * (n / 2ᴶ) = 6n
有趣的是,我们得到了一个与层数 J 无关的上界 6n。这意味着在根节点(第 0 层)我们最多执行 6n 次操作,在第 1 层也最多执行 6n 次操作,第 2 层也是如此,依此类推。

这种现象发生的原因在于两种竞争力量之间的完美平衡:一方面,子问题数量随着递归树层数增加而翻倍;另一方面,每个子问题的工作量随着层数增加而减半。这两者相互抵消,得到了与层数无关的上界 6n。
总工作量计算 📈
既然我们得到了每层工作量的上界,那么计算归并排序的总工作量就变得非常简单。我们只需将层数乘以每层的上界。已知层数为 log₂n + 1(包括第 0 层到第 log₂n 层),每层上界为 6n。

因此,总工作量上限为:
总工作量 ≤ (log₂n + 1) * 6n = 6n log₂n + 6n
这正是我们最初声称的上界:归并排序最多执行 6n log₂n + 6n 次操作。


总结 🎯

本节课中,我们一起学*了归并排序算法的运行时间分析。通过递归树方法,我们逐层统计了算法的工作量,并证明了其时间复杂度上界为 O(n log n)。这一结果表明,随着输入规模 n 的增大,归并排序的性能远优于插入排序、选择排序等简单迭代排序算法。我们使用的主要工具是递归树,它帮助我们清晰地看到子问题数量与规模的平衡,从而简化了计算过程。
008:算法分析指导原则 📚
在本节课中,我们将学*算法分析的三个核心指导原则。这些原则构成了我们评估和比较算法性能的基础框架,帮助我们定义什么是“快速”算法。
上一节我们完成了对归并排序算法的首次分析,接下来我们将退一步,明确在分析归并排序并解释结果时所做的三个假设。这三个假设将成为本课程后续内容中,我们推理算法和定义“快速”算法的指导原则。
指导原则一:最坏情况分析 🎯
第一个指导原则是我们使用了所谓的最坏情况分析。这意味着我们推导出的上界 6n log n + 6n 适用于长度为 n 的每一个输入数组。除了输入长度 n 之外,我们对输入本身没有任何假设。即使存在一个对手,其唯一目的是设计出使算法运行尽可能慢的输入,该对手所能造成的最坏情况也由 6n log n + 6n 这个上界所限定。
这种最坏情况保证在我们的归并排序分析中自然得出。你可能会想,还有其他分析方法吗?确实存在其他方法,例如平均情况分析和基准测试,但本课程不会重点讨论它们。
- 平均情况分析:在假设不同输入具有特定相对频率的前提下,分析算法的平均运行时间。例如,在排序问题中,可以假设所有可能的输入数组出现的概率相等。
- 基准测试:预先约定一组(例如10或20个)基准输入,这些输入被认为代表了算法的实际或典型输入。
平均情况分析和基准测试在某些场景下是有用的,但要使它们有意义,你必须对问题领域有深入的了解,需要知道哪些输入更常见,哪些输入更能代表典型情况。相比之下,最坏情况分析在定义上就不对输入来源做任何假设。
因此,最坏情况分析特别适用于通用子程序——即你在设计时并不知道它们将如何被使用或处理何种输入。此外,进行最坏情况分析(正如本课程所做)的另一个好处是,它在数学上通常比分析算法在某种输入分布下的平均性能,或理解算法在特定基准输入集上的详细行为要容易处理得多。这种数学上的易处理性体现在我们的归并排序分析中,我们并没有刻意去分析最坏情况,但它自然地出现在我们对算法运行时间的推理中。
指导原则二:忽略常数因子和低阶项 🔢
第二个指导原则是,在本课程中分析算法时,我们不会过分纠结于小的常数因子或低阶项。我们在归并排序分析早期就看到了这种思想的应用。当时我们讨论归并子程序所需的代码行数,首先将其上界定为 4m + 2(对于长度为 m 的数组),然后我们说,就把它当作 6m 吧,用一个更简单、更宽松的上界来工作。这已经是不担心常数因子微小变化的一个例子。
你可能会问,我们为什么要这样做?我们真的能这样做吗?以下是支持这一指导原则的理由:
- 数学上的简便性:如果我们不必精确确定主导常数因子和低阶项是什么,数学处理会简单得多。我们在归并排序分析中已经使用了这一点。
- 分析层面的适当性:考虑到本课程描述和分析算法的层面,过分纠结于精确的常数因子是完全不合适的。回想我们对归并子程序的讨论。我们用伪代码写出了该子程序,并给出了
4m + 2的代码行数执行分析。我们也注意到,根据你如何计算循环增量等,具体应该算作多少行代码是有些模糊的。因此,即使在那里,由于伪代码的欠规范性,小的常数因子也可能混入。当伪代码被翻译成实际的编程语言(如C或Java)时,代码行数会进一步偏离,虽然不多,但仍然是小的常数因子。当这样的程序被编译成机器码时,根据具体的处理器、编译器、编译器优化和编程实现等,你会看到更大的差异。总结来说,因为我们将在超越任何特定编程语言的层面上描述算法,所以指定精确的常数是不合适的。 精确的常数最终由更依赖于机器的方面决定,比如程序员是谁、编译器是什么、处理器是什么等等。 - 实践中的可行性:坦率地说,我们就是能够这样做。有人可能会担心,忽略小的常数因子会导致我们误入歧途,使我们推导出一些结果,这些结果暗示一个算法很快,而实际上在实践中很慢,反之亦然。但对于本课程讨论的问题,即使我们不跟踪低阶项和常数因子,我们也将获得极其准确的预测能力。当数学分析表明一个算法很快时,它确实会很快;当分析表明它不快时,情况也确实如此。因此,我们失去了一点信息的粒度,但没有失去我们真正关心的东西——即关于哪些算法会比另一些更快的准确指导。
前两个理由我认为是相当明显的,第三个理由更像是一个断言,但随着本课程的进行,我们将一次又一次地证实它。请注意,我并不是说常数因子在实践中不重要。显然,对于关键程序,常数因子非常重要。如果你正在运行一个生死攸关的关键循环,请务必疯狂地优化常数。关键在于,对于我们在本课程中将要进行的那种算法分析来说,在分析中理解微小的常数因子是不合适的粒度级别。
指导原则三:渐*分析 📈
第三个原则是,我们将使用所谓的渐*分析。这意味着我们将关注大输入规模的情况,即算法性能随着输入规模 n 增大(趋于无穷大)时的表现。
当我们解释归并排序的界限时,这种对大输入规模的关注已经很明显了。我们是如何描述归并排序的界限的?我们说,哦,它需要的操作数量与常数乘以 n log n 成正比,并且我们非常随意地宣布,这比任何运行时间与操作数量成二次方依赖关系的算法都要好。例如,我们论证归并排序比插入排序更好、更快,而根本没有讨论常数因子。
从数学上讲,我们是在说归并排序的运行时间(我们知道可以表示为函数 6n log₂ n + 6n)优于任何对 n 有二次依赖的函数,即使是一个常数很小的函数,比如 ½ n²(这大致是插入排序的运行时间)。这是一个数学陈述,当且仅当 n 足够大时才成立。一旦 n 变大,左边的表达式肯定小于右边的表达式。但对于小的 n,由于主导项更小,右边的表达式实际上会更小。因此,在说归并排序优于插入排序时,我们的偏向是关注具有大 n 的问题。
那么,这合理吗?关注大输入规模是一个合理的假设吗?答案当然是肯定的。我们关注大输入规模的原因是,坦率地说,只有那些问题才是有趣的。如果你只需要对100个数字排序,使用任何你想要的方法,在现代计算机上都会瞬间完成。你不需要知道分治范式。你可能会想,随着计算机根据摩尔定律变得越来越快,如果最终所有问题规模都能在超快计算机上轻松解决,那么思考算法分析是否真的不重要?但事实上,情况恰恰相反。
摩尔定律(计算机变得越来越快)实际上意味着我们的计算雄心自然会增长。我们自然会关注越来越大的问题规模,而 n² 算法和 n log n 算法之间的差距将变得越来越大。另一种思考方式是,随着计算机变得更快,你能解决的问题规模能增大多少。如果你使用的算法运行时间与输入规模成正比,那么如果计算机速度提高4倍,你就能解决规模大4倍的问题。而如果你使用的算法运行时间与输入规模的平方成正比,那么计算机速度提高4倍,你只能解决规模翻倍的问题。随着时间的推移,我们将看到不同算法方法之间更惊人的差距。
为了更清楚地说明这一点,让我展示几个图表。
我们在这里看到的是两个函数的图表。实线函数是我们证明的归并排序上界,即 6n log₂ n + 6n。虚线是对插入排序运行时间的一个估计,即 ½ n²。我们在图表中看到了之前讨论过的确切行为:对于小的 n(图表下方),实际上因为 ½ n² 的主导常数更小,它确实是一个更小的函数,这种情况一直持续到大约90左右的交叉点。但超过 n = 90 后,n² 项的二次增长压倒了它常数较小的事实,开始变得比另一个函数 6n log n + 6n 更大。在90以下的区域,它预测插入排序会更好;在90以上的区域,它预测归并排序会更快。
有趣的是,让我们缩放x轴,看看远超过90这个交叉点的情况。让我们将数量级提高,直到数组大小达到1500。我想强调的是,这些仍然是非常小的问题规模。如果你只需要对大小为1500的数组排序,你真的不需要知道分治或其他我将要讨论的东西,这在现代计算机上是一个相当微不足道的问题。
我们看到的是,即使对于这里非常适中的问题规模(比如大小为1500的数组),插入排序界限中的二次依赖也远远超过了它常数因子较低的事实。在这个大区域中,两种算法之间的差距正在扩大。当然,如果我再增加10倍、100倍或1000倍,达到真正有趣的问题规模,这两种算法之间的差距会更大,甚至会是巨大的。
话虽如此,我并不是说你在实现算法时应该完全无视常数因子。对这些常数因子有一个大致的了解仍然是好的。例如,在许多编程库中可以找到的高度优化的归并排序版本中,实际上由于常数因子的差异,一旦问题规模下降到某个特定阈值以下(比如7个元素左右),算法实际上会从归并排序切换到插入排序。因此,对于小问题规模,你使用常数因子较小的算法(插入排序);对于大问题规模,你使用增长率更好的算法(归并排序)。
总结与展望 🎓
本节课中我们一起学*了算法分析的三个核心指导原则。
回顾一下,我们的第一个指导原则是,我们将进行最坏情况分析,寻求对算法运行时间的性能界限,这些界限不做领域假设,也不对算法接收到的给定长度的输入做任何假设。第二个指导原则是,我们不会过分关注常数因子或低阶项,考虑到我们描述算法的粒度级别,这样做是不合适的。第三个原则是,我们将关注算法对于大问题规模的增长率。
将这三个原则结合在一起,我们得到了快速算法的数学定义:我们将追求那些最坏情况运行时间随着输入规模增长而缓慢增长的算法。
让我告诉你应该如何解释我刚刚写在这个方框里的内容。左边显然是我们想要的——我们想要在实现时运行快速的算法。右边是一个提出的快速算法的数学替代物。左手边不是一个数学定义,右手边是,正如我们将在下一系列讲座中明确的那样。因此,我们将快速算法与那些具有良好渐*运行时间(即运行时间随输入规模增长缓慢)的算法等同起来。

我们对数学定义有什么期望?我们希望一个“甜点”。一方面,我们希望一些我们能够真正推理的东西。这就是为什么我们“放大视野、眯起眼睛”,忽略常数因子和低阶项——我们无法跟踪所有东西,否则我们将永远无法分析任何东西。另一方面,我们不想“把婴儿和洗澡水一起倒掉”,我们希望保留预测能力。事实证明,对于本课程将要讨论的问题,这个定义是推理算法的“甜点”。使用渐*运行时间进行最坏情况分析,将能够证明许多定理,为基本算法建立许多性能保证,同时,我们也将具有良好的预测能力——理论所倡导的算法,实际上在实践中也以快速著称。
最后我需要解释的是,“运行时间随输入规模增长缓慢”是什么意思?答案在一定程度上取决于上下文,但对于我们将要讨论的几乎所有问题,圣杯将是拥有所谓的线性时间算法,即指令数量与输入规模成正比的算法。我们并不总是能够实现线性时间,但在某种意义上,这是最好的情况。注意,线性时间甚至比我们在排序中通过归并排序实现的还要好。归并排序运行得有点超线性,它是 n log n,其中 n 是输入规模。如果可能,我们希望是线性时间。这并不总是可能的,但这是我们对于本课程将讨论的大多数问题所追求的目标。
展望未来,接下来的系列视频将有两个目标。首先,在分析方面,我将正式描述渐*运行时间的含义,介绍大O符号及其变体,解释其数学定义,并给出一些例子。其次,在设计方面,我们将获得更多应用分治范式解决其他问题的经验。

下次见!
009:渐*分析核心概要 🎯

在本节课中,我们将要学*渐*分析。这是每一位严肃的程序员和计算机科学家用来讨论算法高级性能的通用语言,因此它是一个至关重要的主题。本视频旨在衔接课程介绍中已讨论的高级概念与我们将从下个视频开始建立的数学形式化体系。在进入数学形式化之前,我们需要确保这个主题有充分的动机,你对它要达成的目标有坚实的直觉,并且已经看过几个简单直观的例子。让我们开始吧。
动机与核心思想
渐*分析为讨论算法的设计与分析提供了基本词汇。虽然它是一个数学概念,但它绝非为了数学而数学。你经常会听到资深程序员说某段代码运行时间是 O(n),而另一段是 O(n²)。理解这些陈述的含义非常重要。

这种词汇之所以无处不在,是因为它找到了一个讨论算法高级性能的“最佳平衡点”。一方面,它足够粗略,可以忽略掉所有你希望忽略的细节,例如依赖于架构、编程语言、编译器选择等的细节。


另一方面,它又足够精确,能够用于对不同高级算法方案进行预测性比较,尤其是在处理大规模输入时。正如我们之前讨论的,大规模输入在某种意义上才是真正有趣的,因为它们需要我们发挥算法的创造力。例如,渐*分析将使我们能够区分排序、整数乘法等问题的优劣方法。

核心原则:忽略常数因子与低阶项

大多数资深程序员会告诉你,渐*分析的主要目的是忽略常数因子和低阶项。


从长远来看,如果你只记得关于渐*分析的七个词,我希望你记住的就是这七个词。
我们如何证明采用这种形式化方法是合理的呢?低阶项,顾名思义,随着我们关注大规模输入(即算法创造力真正重要的场景)而变得越来越无关紧要。至于常数因子,它们高度依赖于运行环境、编译器、语言等具体细节。如果我们想忽略这些细节,那么采用一个不过分关注常数因子的形式化方法就是合理的。

示例:归并排序
回想我们分析归并排序算法时,我们给出了其运行时间的一个上界:6n log n + 6n,其中 n 是输入数组的长度。这里的低阶项是 6n,它比 n log n 增长得慢。我们将其忽略。主导常数因子是 6,我们也将其忽略。经过这两步忽略,我们得到了一个更简单的表达式:n log n。
相应的术语是:归并排序的运行时间是 O(n log n)。换句话说,当你说一个算法的运行时间是 O(f(n)) 时,你的意思是,在忽略低阶项和主导常数因子之后,你得到的是函数 f(n)。直观上,这就是大 O 符号的含义。

需要明确的是,我绝不是在断言常数因子在设计和分析算法时从不重要。我的意思是,当你思考高级算法方案,或者想比较解决一个问题的根本不同方法时,渐*分析通常是指导你哪种方法性能更好的正确工具,尤其是在处理相当大的输入时。当然,一旦你确定了一个特定的算法解决方案,你可能会努力改进其常数因子,甚至改进低阶项。如果你的初创公司的未来取决于你实现某几行代码的效率,那么请务必让它尽可能快。
四个简单示例
在本视频的剩余部分,我将通过四个非常简单的例子来阐述。如果你已经熟悉大 O 符号,可以直接跳到下个视频开始学*数学形式化。但如果你是第一次接触,我希望这些简单的例子能帮助你入门。
示例一:在数组中搜索整数 🔍

我们从一个非常基本的问题开始:在数组中搜索给定的整数。


我们将分析解决这个问题的直接算法:对数组进行线性扫描,检查每个元素是否是目标整数 t。

代码依次检查每个数组元素。如果找到目标 t,则返回 true;如果扫描完整个数组都没找到,则返回 false。
问题:根据数组长度 n,这个算法的运行时间(用大 O 表示法)是多少?
答案:O(n),或者说该算法的运行时间相对于输入长度 n 是线性的。
原因:执行的代码行数取决于输入。在最坏情况下(即 t 不在数组中),代码会扫描整个数组 A 并返回 false。执行的操作次数是一个常数(初始设置等)加上对数组中每个元素执行的常数次操作。无论这个常数是 2、3 还是 4,它都会被大 O 符号方便地忽略。因此,总操作数与 n 成线性关系,所以大 O 表示法就是 O(n)。

示例二:顺序双循环 ➡️➡️

上一个示例中我们看到了一个循环。接下来三个示例,我们将看看处理两个循环的不同方式。在这个例子中,我们考虑一个循环后跟另一个循环,即顺序执行的两个循环。


我们研究一个与上一个类似的问题:现在给定两个长度均为 n 的数组 A 和 B,我们想知道目标 t 是否存在于其中任何一个数组中。我们同样分析直接算法:先搜索 A,如果在 A 中没找到 t,再搜索 B。如果都没找到,则返回 false。
问题:这段新代码的运行时间(用大 O 表示法)是多少?
答案:O(n)。


原因:实际计算的操作数当然不会和上次完全一样,它大约是上一段代码的两倍,因为我们需要搜索两个长度为 n 的数组。但无论这个倍数是多少,作为一个独立于输入长度 n 的常数,在我们使用大 O 表示法时都会被忽略。因此,和上一个算法一样,这也是一个线性时间算法,运行时间为 O(n)。
示例三:嵌套双循环(比较两个数组) 🔄

让我们看一个更有趣的双循环例子,这次循环不是顺序执行,而是嵌套的。具体来说,我们研究的问题是:判断两个给定的长度为 n 的输入数组是否包含一个相同的数字。
我们将分析解决这个问题的最直接算法:比较所有可能性。对于数组 A 的每个索引 i 和数组 B 的每个索引 j,我们检查 A[i] 是否等于 B[j]。如果相等,返回 true;如果穷尽所有可能性都没找到相等的元素,则返回 false。
问题:这段代码的运行时间(用大 O 表示法)是多少?
答案:O(n²)。我们也可以称之为二次时间算法,因为运行时间相对于输入长度 n 是二次的。对于这类算法,如果你将输入长度加倍,算法的运行时间将增加 4 倍,而不是像前两段代码那样增加 2 倍。


原因:同样,常数设置成本被忽略。对于数组 A 的每个固定索引 i 和数组 B 的每个固定索引 j,我们只执行常数次操作。关键区别在于,这个双重 for 循环总共有 n² 次迭代。在第一个例子中,单个 for 循环只有 n 次迭代。在第二个例子中,因为一个 for 循环在另一个开始前就结束了,所以总共只有 2n 次迭代。而在这里,外层 for 循环的每次迭代(共 n 次),内层 for 循环都要执行 n 次迭代,总共就是 n * n,即 n² 次迭代。

示例四:嵌套双循环(在单个数组中查找重复项) 🔍🔍
让我们以最后一个例子结束,它同样是嵌套 for 循环,但这次我们是在单个数组 A 中查找重复项,而不是比较两个不同的数组。
以下是用于解决此问题(检测输入数组 A 是否有重复项)的代码。与上一张幻灯片中比较两个数组的代码相比,只有两个小改动:
- 将数组
B的引用改为A,即比较A[i]和A[j]。 - 内层
for循环的索引j从i+1开始,而不是从1开始。如果从1开始,代码实际上会将A中每对不同的元素比较两次,这显然是多余的,你只需要比较一次就能知道它们是否相等。


问题:这段代码的运行时间(用大 O 表示法)是多少?

答案:O(n²)。这段代码的运行时间也是二次的。
原因:和所有例子一样,这段代码的运行时间与这个双重 for 循环的迭代次数成正比(每次迭代做常数工作,常数被大 O 忽略)。我们只需要计算这个双重 for 循环有多少次迭代。
我的观点是,大约有 n² / 2 次迭代。有两种理解方式:首先,这段代码与上一段代码的区别在于,我们不再重复计数,而是只计数一次,这节省了大约一半的迭代次数。当然,这个 1/2 的因子同样会被大 O 符号忽略,所以大 O 运行时间不变。另一种论证是:迭代次数对应于从 1 到 n 中选出两个不同索引 i 和 j 的所有可能组合数。简单的组合计数告诉我们,这样的选择有 C(n, 2) 种,即 n(n-1)/2。再次忽略低阶项和常数因子,我们仍然得到相对于输入数组 A 长度的二次依赖关系。
总结
本节课中,我们一起学*了渐*分析的核心概念。我们了解到,它是讨论算法性能的高级语言,核心在于忽略常数因子和低阶项,从而专注于算法在大规模输入下的增长趋势。我们通过四个简单的代码示例(线性扫描、顺序循环、嵌套循环比较两数组、嵌套循环查找单数组重复项)直观地感受了如何判断算法的运行时间是 O(n) 还是 O(n²)。现在,你应该对渐*分析的目标和大 O 符号的直观定义有了较强的认识。接下来,我们将进入更严谨的数学形式化定义,并分析更多有趣的算法。
010:大O记号详解 📊
在本节课中,我们将正式学*渐*记号,特别是大O记号。我们会通过多种方式理解其含义,并学*如何用数学语言严格定义它。
概述

大O记号用于描述定义在正整数上的函数,我们通常称之为 T(n)。在算法分析中,T(n) 通常代表算法在最坏情况下,其运行时间随输入规模 n 变化的函数。本节课的核心问题是:当我们说一个函数 T(n) 是 O(f(n)) 时,究竟意味着什么?这里的 f(n) 是一个基础函数,例如 n 或 log n。
大O记号的多种理解方式
我将通过几种不同的方式来解释大O记号的真正含义。首先,让我们从文字定义开始。

文字定义
一个函数 T(n) 是 O(f(n)),意味着最终,对于所有足够大的 n 值,T(n) 都被 f(n) 的一个常数倍所上界约束。


接下来,我们将这个文字定义转化为图形,然后再转化为正式的数学定义。
图形化理解


我们可以想象 T(n) 是图中的蓝色函数,而 f(n) 是图中的绿色函数。起初,f(n) 可能位于 T(n) 下方。但当我们把 f(n) 乘以一个常数(例如2倍)后,得到的函数(图中橙色线)最终会与 T(n) 相交,并且从此之后永远大于 T(n)。

在这种情况下,我们就可以说 T(n) 确实是 O(f(n))。因为从某个点(足够大的 n)开始,常数倍(2倍)的 f(n) 始终是 T(n) 的上界。
数学定义
现在,让我们给出一个可用于形式化证明的数学定义。如何用数学语言表达“最终被一个常数倍上界约束”?
我们称存在两个常数 C 和 n₀,使得对于所有 n ≥ n₀,都有:
T(n) ≤ C * f(n)

这两个常数的作用是量化文字定义中的“常数倍”和“足够大”:
- C 量化了 f(n) 的“常数倍”。
- n₀ 量化了“足够大”,它是我们要求 C * f(n) 成为 T(n) 上界的起始阈值。
回到图形中,C 就是2,而 n₀ 就是 2 * f(n) 与 T(n) 相交的那个点。
证明思路与“游戏”比喻
根据这个定义,要证明 T(n) 是 O(f(n)),你需要找出这样两个常数 C 和 n₀,并确保对于所有 n ≥ n₀,不等式 T(n) ≤ C * f(n) 都成立。
你可以将其想象成一场“游戏”:
- 你的目标:证明不等式成立。
- 对手的目标:证明不等式不成立。
- 游戏规则:你必须先行动,即先选择你的策略——确定常数 C 和 n₀。然后,对手可以任意选择一个大于 n₀ 的 n 值。
- 胜负判定:如果无论对手选择多大的 n,不等式都成立,那么你就拥有必胜策略,T(n) 就是 O(f(n))。反之,如果你找不到这样的 C 和 n₀,使得对手总能找到一个足够大的 n 来推翻不等式,那么 T(n) 就不是 O(f(n))。
重要注意事项
最后需要强调一点:这里所说的“常数” C 和 n₀,必须是独立于 n 的。这意味着当你应用定义选择常数时,n 不能出现在常数的表达式中。C 应该是一个像100或一百万这样的固定数字。
总结
本节课我们一起学*了算法分析中的核心工具——大O记号。我们从文字描述、图形化表示和严格的数学定义等多个角度理解了它的含义。大O记号 O(f(n)) 描述了函数 T(n) 在输入规模 n 足够大时,其增长速率不会超过 f(n) 的某个常数倍。我们还学*了如何通过寻找常数 C 和 n₀ 来形式化地证明大O关系。在接下来的课程中,我们将通过具体例子来巩固这一概念。
011:基础示例 🧮
在本节课中,我们将通过两个基础示例来学*如何正式地证明一个函数是另一个函数的大O,以及如何证明它不是。我们将运用大O记法的定义,并理解它如何忽略常数因子和低阶项。


多项式的大O证明
上一节我们介绍了大O记法的正式定义,本节中我们来看看如何应用这个定义。第一个示例是证明一个多项式函数的大O记法。
核心概念:对于一个k次多项式 T(n),其最高次项 n^k 决定了其渐*增长率。即:
T(n) = O(n^k)
证明过程:
假设 T(n) 是一个k次多项式:
T(n) = a_k * n^k + a_{k-1} * n^{k-1} + ... + a_1 * n + a_0
其中 k 是正整数,系数 a_i 可以是任意实数(正或负)。
为了证明 T(n) = O(n^k),我们需要找到常数 C 和 n_0,使得对于所有 n ≥ n_0,都有 T(n) ≤ C * n^k。
以下是证明步骤:
- 我们选择
n_0 = 1。 - 我们选择常数
C为所有系数绝对值的和:
C = |a_k| + |a_{k-1}| + ... + |a_1| + |a_0| - 现在,对于任意
n ≥ 1,我们推导T(n)的上界:
T(n) = a_k * n^k + ... + a_1 * n + a_0
≤ |a_k| * n^k + ... + |a_1| * n + |a_0|(将系数替换为其绝对值,因为n ≥ 0,值只会增大或不变)
≤ |a_k| * n^k + ... + |a_1| * n^k + |a_0| * n^k(因为对于n ≥ 1,有n^k ≥ n^{k-1} ≥ ... ≥ n ≥ 1,用n^k替换所有更低的幂次,值只会增大)
= (|a_k| + ... + |a_1| + |a_0|) * n^k
= C * n^k
因此,我们证明了对于所有 n ≥ 1,都有 T(n) ≤ C * n^k。根据大O定义,T(n) = O(n^k) 成立。
这个证明验证了大O记法的核心目的:对于多项式,我们只需关注最高次项。
证明“不是”大O
理解了如何证明“是”大O之后,我们来看看如何证明一个函数“不是”另一个函数的大O。我们将证明 n^k 不是 O(n^{k-1})。
核心概念:不同幂次的多项式在渐*意义上是不同的。n^k 的增长严格快于 n^{k-1}。
证明方法:反证法。
证明过程:
假设结论不成立,即 n^k = O(n^{k-1})。
根据大O定义,这意味着存在常数 C 和 n_0,使得对于所有 n ≥ n_0,都有:
n^k ≤ C * n^{k-1}
对于 n ≥ 1 且 k ≥ 1,我们可以在不等式两边同时除以 n^{k-1}(这是一个正数),得到:
n ≤ C,对于所有 n ≥ n_0。
这个结论显然是错误的,因为它声称所有足够大的正整数 n 都被一个固定常数 C 所限制。例如,取 n = C + 1,就与 n ≤ C 矛盾。
因此,我们的初始假设 n^k = O(n^{k-1}) 是错误的。这证明了 n^k 不是 O(n^{k-1})。
这个示例巩固了我们的理解:大O记法能够区分不同增长率的多项式。
总结
本节课中我们一起学*了两个基础但重要的示例:
- 证明“是”大O:我们通过为多项式
T(n)构造合适的常数C和n_0,正式证明了T(n) = O(n^k),展示了如何应用定义并忽略低阶项和常数因子。 - 证明“不是”大O:我们使用反证法证明了
n^k ≠ O(n^{k-1}),验证了大O记法能够正确区分不同增长级别的函数。

这些练*帮助我们熟悉了大O记法定义的正式运用,为后续分析更复杂算法的效率打下了基础。
012:大Ω与Θ记号 📊
在本节课中,我们将继续学*渐*记号的正式内容。我们已经讨论了大O记号,它是渐*记号中迄今为止最重要且最普遍的概念。为了内容的完整性,我们还将介绍大O记号的两个*亲:大Ω记号和Θ记号。
大Ω记号 📈
上一节我们介绍了大O记号,它类似于“小于等于”。本节中我们来看看大Ω记号,它类似于“大于等于”。
大Ω记号的形式定义与大O记号非常相似。我们说一个函数 T(n) 是另一个函数 f(n) 的大Ω,如果最终(即对于足够大的n),T(n) 被 f(n) 的一个常数倍所下界约束。我们用与大O记号完全相同的方式来量化“常数倍”和“最终”这两个概念,即显式地给出两个常数 C 和 n₀,使得对于所有足够大的 n(即所有 n ≥ n₀),T(n) 都被 C * f(n) 从下方界定。
公式: T(n) = Ω(f(n)) 当且仅当 ∃ 常数 C > 0, n₀ > 0,使得 ∀ n ≥ n₀,有 T(n) ≥ C * f(n)。

为了直观理解,请看下图。假设我们有一个函数 T(n),如绿色曲线所示。另一个函数 f(n) 在 T(n) 上方。但当我们用 1/2 乘以 f(n) 后,得到的结果最终总是位于 T(n) 下方。在这个例子中,T(n) 确实是 f(n) 的大Ω。


关于常数的选择,这里使用的倍数 C 显然是 1/2。和之前一样,n₀ 是两个函数的交点,即在此点之后,C * f(n) 永远位于 T(n) 下方。


Θ记号 ⚖️
接下来,我们介绍Θ记号,它相当于“等于”。这意味着一个函数同时是 f(n) 的大O和大Ω。
一种等价的思考方式是:最终 T(n) 被夹在两个不同的 f(n) 的常数倍之间。我把它写下来,并留给你验证这两个概念是等价的,即一个蕴含另一个,反之亦然。

公式: T(n) = Θ(f(n)) 当且仅当 ∃ 常数 C₁ > 0, C₂ > 0, n₀ > 0,使得 ∀ n ≥ n₀,有 C₁ * f(n) ≤ T(n) ≤ C₂ * f(n)。

所谓“被夹在两个倍数之间”,是指我们选择两个常数:一个较小的 C₁ 和一个较大的 C₂。对于所有 n ≥ n₀,T(n) 都位于这两个常数倍之间。
算法设计中的惯例 💡
算法设计者有时会有些随意,他们使用大O记号来代替Θ记号。这是一个常见的惯例,在本课程中我也会经常遵循这个惯例。
让我举个例子。假设我们有一个子程序,它对一个长度为 n 的数组进行线性扫描,查看数组中的每个条目,并对每个条目执行常数量的工作。例如,合并子程序就大致是这种类型的子程序。
尽管这样一个算法(子程序)的运行时间显然是 Θ(n)(它对n个条目中的每一个都做常数工作,所以正好是 Θ(n)),但我们通常只说它的运行时间是 O(n),而不会费力去强调它是 Θ(n) 这个更强的陈述。

我们这样做是因为,作为算法设计者,我们真正关心的是上界——我们想要算法运行时间的保证。因此,我们自然关注上界,而不是那么关注下界。所以不要感到困惑。偶尔会有一个量明显是 Θ(f(n)),而我只会做出它是 O(f(n)) 这个较弱的陈述。





理解测验 ✅
接下来的测验旨在检查你对这三个概念——大O、大Ω和大Θ记号的理解。
假设 T(n) = 3n² + 2n + 1。以下哪些陈述是正确的?
以下是可能的陈述:
- T(n) = O(n)
- T(n) = Ω(n²)
- T(n) = Θ(n²)
- T(n) = Ω(n)
- T(n) = O(n³)
正确的陈述是后三个。我希望其背后的高层次直觉是相当清晰的。T(n) 显然是一个二次函数。我们知道线性项在n增大时并不重要。既然它是二次增长,那么第三个陈述应该是正确的:它是 Θ(n²)。
同时,它是 Ω(n)。Ω(n) 并不是对 T(n) 渐*增长率的一个很好的下界,但它是合法的。确实,作为一个二次增长函数,它至少和线性函数增长得一样快,所以它是 Ω(n)。
同理,O(n³) 不是一个很好的上界,但它是一个合法的上界,是正确的。T(n) 的增长率至多是三次的,实际上至多是二次的,但它确实至多是三次的。
如果你想正式证明这三个陈述,只需展示适当的常数即可。
- 为了证明它是 Ω(n),你可以取 n₀ = 1 和 C = 12。
- 为了证明它是 O(n³),你可以取 n₀ = 1 和 C = 4。
- 为了证明它是 Θ(n²),你可以做类似的事情,只是结合两个常数,例如取 n₀ = 1,C₁ = 12,C₂ = 4。
我将留给你验证,使用这些常数选择,大Ω、大Θ和大O的形式定义将得到满足。

小o记号 🔍
最后介绍一个渐*记号,我们不会经常使用它,但你偶尔会看到,所以我简要提一下。这被称为小o记号,与大O记号相对。
大O记号非正式地类似于“小于等于”关系,而小o则是“严格小于”关系。直观上,它意味着一个函数的增长严格慢于另一个函数。
公式: T(n) = o(f(n)) 当且仅当 ∀ 常数 C > 0,∃ 常数 n₀ > 0,使得 ∀ n ≥ n₀,有 T(n) ≤ C * f(n)。
这个定义与大O记号的区别在于:要证明一个函数是另一个函数的大O,我们只需要展示一个常数 C,使得 C * f(n) 最终成为 T(n) 的上界。相比之下,要证明某物是另一个函数的小o,我们必须证明一些更强的东西:对于每一个常数 C,无论多小,对于每一个 C,都存在某个足够大的 n₀,使得在此之后 T(n) 被 C * f(n) 从上界约束。


对于那些希望更熟悉小o记号的人,我将留作一个练*来证明:对于所有多项式幂次 k,实际上 n^(k-1) 是 n^k 的小o。
公式: n^(k-1) = o(n^k)
还有一个类似的小ω记号概念,表示一个函数增长严格快于另一个函数,但这个不常见,我就不多说了。
历史背景与总结 📜
让我用一段引用来结束本视频,这段话来自我的同事高德纳在1976年发表的一篇文章,他被广泛认为是算法形式分析的奠基人。通常很难明确指出某种符号为何以及在哪里被一个领域普遍采用,但在渐*记号的情况下,其来源非常清楚。
这种符号并非由算法设计者或计算机科学家发明,自19世纪以来它就在数论中使用。但正是高德纳在1976年提出,这应该成为讨论增长率,特别是算法运行时间的标准语言。他在文章中说道:
“基于这里讨论的问题,我建议SIGACT(ACM特别兴趣小组,关注理论计算机科学,特别是算法分析)的成员以及计算机科学和数学期刊的编辑采用上述定义的大O、大Ω和Θ记号,除非能在合理的时间内找到更好的替代方案。”

显然,更好的替代方案没有被找到。自那时起,这已成为讨论算法运行时间增长率的标准方式,也是我们将在这里使用的方式。

本节课总结:
在本节课中,我们一起学*了渐*记号的扩展内容。我们定义并理解了大Ω记号(下界,类似于“≥”)、Θ记号(紧确界,类似于“=”)以及简要提及了小o记号(严格上界,类似于“<”)。我们还了解了算法分析中常用大O代替Θ的惯例,并通过例子和测验加深了理解。最后,我们回顾了这些记号在计算机科学中被标准化的历史背景。掌握这些记号对于精确分析和比较算法的效率至关重要。
013:补充示例与复*(可选)📚

在本节课中,我们将通过三个额外的示例来练*渐*符号的使用。我们将学*如何形式化地证明一个函数是另一个函数的大O,如何证明一个函数不是另一个函数的大O,以及如何使用Θ符号来证明两个函数的渐*等价性。
证明函数的大O关系 🔍
上一节我们介绍了渐*符号的基本概念,本节中我们来看看如何形式化地证明一个函数是另一个函数的大O。
我们考虑函数 f(n) = 2^(n+10)。我们的目标是证明 f(n) 是 O(2^n)。
根据大O符号的定义,我们需要找到两个常数 C 和 n₀,使得对于所有足够大的 n(即 n ≥ n₀),都有:
2^(n+10) ≤ C * 2^n
以下是证明步骤:
- 我们从左边开始:2^(n+10)
- 利用指数运算法则,我们可以将其重写为:2^(n+10) = 2^n * 2^10
- 计算 2^10 得到 1024。因此,2^(n+10) = 1024 * 2^n
- 现在,我们可以选择常数 C = 1024 和 n₀ = 1。对于所有 n ≥ 1,不等式 2^(n+10) ≤ 1024 * 2^n 显然成立。
因此,我们证明了 2^(n+10) 是 O(2^n)。证明的关键在于找到一个合适的常数 C,使得不等式对所有足够大的 n 都成立。
证明函数不是大O关系 ❌
接下来,我们看一个反例,证明一个函数不是另一个函数的大O。
我们考虑函数 g(n) = 2^(10n)。我们声称 g(n) 不是 O(2^n)。
我们采用反证法。假设 2^(10n) 是 O(2^n)。根据定义,存在常数 C 和 n₀,使得对于所有 n ≥ n₀,有:
2^(10n) ≤ C * 2^n
以下是推导矛盾的过程:

- 将不等式两边同时除以 2^n(因为 n 为正数,所以 2^n > 0),得到:
2^(10n) / 2^n ≤ C - 根据指数运算法则,2^(10n) / 2^n = 2^(10n - n) = 2^(9n)。因此,不等式变为:
2^(9n) ≤ C - 这个不等式意味着,对于所有足够大的 n,函数 2^(9n) 被一个固定常数 C 所上界。
- 然而,当 n 趋于无穷大时,2^(9n) 会趋于无穷大,不可能被一个固定常数永远上界。这产生了矛盾。

因此,我们的假设是错误的,2^(10n) 不是 O(2^n)。这个例子说明,指数上的常数因子(如乘以10)会彻底改变函数的渐*增长率。
使用Θ符号证明渐*等价性 ⚖️
最后,我们来看一个更复杂的例子,练*使用Θ符号。Θ符号表示两个函数在渐*意义下“相等”。
声明:对于任意两个定义在正整数上的正函数 f(n) 和 g(n),它们的逐点最大值 max(f(n), g(n)) 与它们的和 f(n) + g(n) 是Θ关系。即:
max(f(n), g(n)) 是 Θ(f(n) + g(n))
根据Θ符号的定义,我们需要找到常数 C₁, C₂ 和 n₀,使得对于所有 n ≥ n₀,有:
C₁ * (f(n) + g(n)) ≤ max(f(n), g(n)) ≤ C₂ * (f(n) + g(n))
以下是证明过程。我们首先观察两个基本不等式:
-
上界证明:对于任意 n,最大值显然不会超过两者之和。
max(f(n), g(n)) ≤ f(n) + g(n)
这直接给出了上界,我们可以选择 C₂ = 1。 -
下界证明:我们需要证明最大值至少是两者之和的一部分。
考虑 2 * max(f(n), g(n))。它等于 max(f(n), g(n)) + max(f(n), g(n))。
由于 max(f(n), g(n)) 至少等于 f(n) 和 g(n) 中的每一个,因此:
2 * max(f(n), g(n)) ≥ f(n) + g(n)
将两边同时除以2,得到:
max(f(n), g(n)) ≥ (1/2) * (f(n) + g(n))
这给出了下界,我们可以选择 C₁ = 1/2。
综合以上两点,我们证明了对于所有 n ≥ 1(这里 n₀ = 1),有:
(1/2) * (f(n) + g(n)) ≤ max(f(n), g(n)) ≤ 1 * (f(n) + g(n))
这恰好满足了Θ定义的要求。因此,max(f(n), g(n)) 是 Θ(f(n) + g(n))。这个结论非常有用,它表明在渐*分析中,取两个函数的最大值(即考虑最坏情况)与考虑它们的总和,其增长率是相同的(相差常数倍)。
总结 📝
本节课中我们一起学*了三个关于渐*符号的补充示例:
- 我们通过选择常数 C = 1024,形式化地证明了 2^(n+10) 是 O(2^n)。
- 我们使用反证法,通过推导出矛盾(2^(9n) ≤ C 对无穷大的 n 不成立),证明了 2^(10n) 不是 O(2^n)。
- 我们利用不等式推导,证明了对于任意正函数 f 和 g,max(f, g) 是 Θ(f + g),其中常数 C₁ = 1/2, C₂ = 1。

这些练*帮助我们更深入地理解了如何运用大O和Θ符号的定义来分析和证明函数的渐*关系。
014:计数逆序对的O(n log n)算法 I
概述
在本节课中,我们将学*如何应用分治算法设计范式来解决一个具体问题:计算数组中逆序对的数量。我们将从回顾分治范式的一般步骤开始,然后定义逆序对问题,并探讨其应用场景。最后,我们将构思一个基于分治的高效算法框架,其目标时间复杂度为 O(n log n)。
分治范式回顾

上一节我们介绍了分治算法的基本思想。现在,我们来具体回顾其三个核心步骤。
分治范式包含以下三个概念性步骤:
- 分解:将原问题划分为更小的子问题。有时这只是概念上的划分,有时则需要在代码中实际复制输入数据(例如创建新数组传递给递归调用)。
- 解决:递归地解决子问题。例如,在归并排序中,我们将数组分成两半,然后递归地对每一半进行排序。
- 合并:将子问题的解组合成原问题的解。这通常是算法中最需要巧思的部分。例如,在归并排序中,递归调用后,我们需要将两个已排序的半边数组合并成一个完整的有序数组。
逆序对问题定义

在深入算法细节之前,我们首先需要明确要解决的问题是什么。
我们被给定一个长度为 n 的数组 A 作为输入。为简化问题,我们假设数组包含数字 1 到 n 的某种排列。问题的目标是计算该数组的逆序对数量。
一个逆序对由一对数组索引 (i, j) 定义,其中 i < j,但数组元素满足 A[i] > A[j]。也就是说,位置靠前的元素反而比位置靠后的元素大。
- 如果数组是已排序的(即 1, 2, 3, ..., n),则逆序对数量为 0。
- 反之,任何其他排列都会产生非零数量的逆序对。

示例
考虑一个包含6个元素的数组:A = [1, 3, 5, 2, 4, 6]。
以下是该数组中的所有逆序对:
- (3, 2):对应索引 (1, 3),因为 A[1]=3 > A[3]=2。
- (5, 2):对应索引 (2, 3),因为 A[2]=5 > A[3]=2。
- (5, 4):对应索引 (2, 4),因为 A[2]=5 > A[4]=4。
因此,该数组共有 3 个逆序对。
问题应用与最大逆序对数量
了解为什么需要解决这个问题以及逆序对数量的边界,有助于我们理解算法的设计目标。
应用场景:衡量列表相似度
逆序对计数的一个主要应用是量化两个排序列表之间的相似度(或不相似度)。例如,比较两个人对10部电影的排名。
- 根据你的喜好对电影排序(从最喜欢到最不喜欢)。
- 对于你列表中的每一部电影,记录你朋友给它的排名。
- 将这些排名值组成一个数组。
- 计算该数组的逆序对数量。
如果你们的排名完全一致,逆序对数为 0。逆序对越多,表明你们的偏好差异越大。这种技术在协同过滤(例如电商推荐系统)中非常有用,通过寻找偏好相似的用户来推荐商品。
最大逆序对数量
对于一个长度为 n 的数组,逆序对数量的最大值是多少?
答案是:当数组完全逆序时(即 n, n-1, ..., 2, 1),每一对 (i, j)(其中 i < j)都是逆序的。因此,最大逆序对数量等于所有可能的索引对数量,即组合数 C(n, 2)。
用公式表示为:
最大逆序对数 = n * (n - 1) / 2
对于 n=6,最大值为 15。

算法设计:从暴力法到分治法
明确了问题后,我们开始设计算法。首先看一个简单但低效的方法。
暴力算法
最直接的方法是使用双重循环检查每一对索引 (i, j)(其中 i < j),判断 A[i] > A[j] 是否成立。若是,则计数器加一。
# 伪代码示例:暴力法
count = 0
for i from 0 to n-2:
for j from i+1 to n-1:
if A[i] > A[j]:
count += 1
return count
该算法的时间复杂度为 O(n²),因为需要检查 C(n, 2) 对元素。对于大规模数据,这显然不够高效。
分治算法框架
我们的目标是利用分治思想,设计一个 O(n log n) 的算法,灵感来源于归并排序。
首先,我们将数组的逆序对分为三类。假设数组长度为 n,中点为 mid = n/2:
- 左逆序对:两个索引 i 和 j 都位于左半部分(即 i, j ≤ mid)。
- 右逆序对:两个索引 i 和 j 都位于右半部分(即 i, j > mid)。
- 分裂逆序对:较小的索引 i 在左半部分(i ≤ mid),而较大的索引 j 在右半部分(j > mid)。
分治算法的策略如下:
- 递归解决:通过递归调用,分别计算左半部分的左逆序对和右半部分的右逆序对。
- 合并解决:设计一个子程序,在线性时间 O(n) 内计算跨越左右两半的分裂逆序对。

如果这三个步骤都能正确完成,那么总的逆序对数就是这三部分之和。
算法的高层框架如下:
- 基准情况:如果数组只有一个元素,逆序对数为 0。
- 分解与递归:
leftCount = 递归计算左半部分的逆序对rightCount = 递归计算右半部分的逆序对
- 合并:
splitCount = 计算分裂逆序对(关键步骤)
- 返回结果:
return leftCount + rightCount + splitCount
如果能实现一个运行时间为 O(n) 的计算分裂逆序对子程序,那么根据与归并排序相同的递归树分析(或主定理),整个算法的时间复杂度将为 O(n log n)。
挑战与下节预告
我们设定了一个颇具雄心的目标:在线性时间内计算出可能多达 O(n²) 个的分裂逆序对。这能做到吗?
答案是肯定的。关键在于,我们不能逐一检查所有可能的左右元素对(那样是 O(n²))。我们需要一个更聪明的方法,在合并两个已排序子数组的过程中,“顺便”统计出分裂逆序对的数量。这正是我们下一节要详细讲解的核心内容。

总结
本节课我们一起学*了:
- 分治范式的回顾:分解、解决、合并。
- 逆序对问题的正式定义及其应用场景,如衡量排名列表的相似度。
- 逆序对数量的最大值为 n*(n-1)/2。
- 设计了基于分治的算法高层框架,通过递归计算左、右逆序对,并留下在线性时间内计算分裂逆序对的关键子任务待实现。
- 确立了算法的目标时间复杂度为 O(n log n)。
在下一节中,我们将深入探讨如何高效实现“计算分裂逆序对”这一核心步骤,完成整个算法。
015:计数逆序对的O(n log n)算法 II
概述
在本节课中,我们将学*如何高效地计算一个数组中的逆序对数量。我们将基于分治思想,并巧妙地利用归并排序的过程,在O(n log n)的时间内完成计数。
分治方法回顾
上一节我们介绍了使用分治法计算数组逆序对的基本思路。该方法将数组分成左右两半,递归地计算左半部分和右半部分的逆序对数量。
然而,分治法面临一个关键挑战:如何高效地计算“分裂逆序对”。分裂逆序对指的是一个元素在左半部分,另一个元素在右半部分,且左元素大于右元素的数对。这类逆序对不会被左右两边的递归调用所统计。
问题的核心在于,分裂逆序对的数量可能高达O(n²),但我们必须在O(n)时间内完成统计,才能实现O(n log n)的总运行时间。
结合归并排序的巧妙想法
本节中我们来看看一个非常巧妙的想法,它能让我们实现目标。这个想法就是“搭载”在归并排序之上。
这意味着,我们将要求递归调用做更多的工作,以便让统计分裂逆序对的任务变得更简单。这类似于在数学归纳法中,有时需要加强归纳假设来推进证明。
我们将要求递归调用不仅统计传入数组的逆序对数量,还要在过程中对数组进行排序。为什么不呢?我们知道归并排序可以在O(n log n)时间内完成排序,而这正是我们追求的运行时间。所以,不妨加上排序这一步,也许它能在合并步骤中帮助我们。事实上,它确实会。
为什么要求递归调用做更多工作?
为什么我们要对递归调用提出更高的要求呢?正如我们将在接下来的几张幻灯片中看到的,归并子程序似乎就是为统计分裂逆序对数量而设计的。当你合并两个已排序的子数组时,你会自然地发现所有的分裂逆序对。
让我更清楚地说明,我们如何改进之前的高层算法,使得递归调用同时进行排序。
以下是之前提出的高层算法,我们只是递归地统计左右两边的逆序对,然后有一个尚未实现的子程序CountSplitInv来负责统计分裂逆序对的数量。
现在,我们将对这个算法进行如下增强:
- 我们将算法重命名为
SortAndCount。 - 递归调用同样调用
SortAndCount。 - 现在我们知道,每个递归调用不仅会返回子数组的逆序对数量,还会返回一个已排序的版本。
- 从第一个递归调用,我们将得到已排序的数组
B。 - 从第二个递归调用,我们将得到已排序的数组
C。 - 现在,
CountSplitInv除了统计分裂逆序对,还负责合并两个已排序的子数组B和C。 - 因此,
CountSplitInv将输出一个数组D,它是原始输入数组A的已排序版本。
为了反映其更宏大的目标,我们也应该重命名这个子程序,称之为MergeAndCountSplit。
我们不应该被要求合并子程序合并两个已排序子数组B和C的任务吓倒,因为我们已经知道如何在O(n)时间内完成合并。所以问题在于,在完成这项工作的同时,我们能否在额外的O(n)时间内统计分裂逆序对的数量?我们将看到这是可以的,尽管这并非显而易见。
归并如何揭示分裂逆序对
为了理解为什么合并过程能自然地揭示分裂逆序对的数量,让我们回顾一下归并排序中原始归并子程序的定义。
以下是我们在几个视频前看过的相同伪代码,我重命名了数组的字母以符合当前的符号表示。
我们得到两个已排序的子数组(来自递归调用),称之为B和C,长度均为n/2。我们的责任是生成B和C的已排序组合,即一个长度为n的输出数组D。
思路很简单:你取两个已排序的子数组B和C,以及你需要填充的输出数组D。使用索引k,你从左到右遍历输出数组D(这是外部for循环的作用)。你维护指针i和j,分别指向已排序子数组B和C的当前位置。唯一的观察是:无论尚未复制到D的最小元素是什么,它必须是B中尚未见过的最左元素,或者是C中尚未见过的最左元素。由于B和C已排序,剩余元素中的最小值必须是B或C中下一个可用的元素。
因此,你只需以显而易见的方式进行:比较两个候选的下一个要复制的元素,查看B[i]和C[j],哪个更小就复制哪个。if语句的第一部分处理B包含较小元素的情况,else语句处理C包含较小元素的情况。
这就是归并的工作原理:你并行地遍历B和C,从左到右按排序顺序填充D。
无分裂逆序对的情况
为了理解这与数组的分裂逆序对有什么关系,请思考一个具有以下属性的输入数组A:该数组没有任何分裂逆序对。也就是说,这个输入数组A中的每一个逆序对要么是左逆序对(两个索引都≤ n/2),要么是右逆序对(两个索引都> n/2)。
现在的问题是:给定这样一个数组A,在合并步骤中,对于这样一个没有分裂逆序对的输入数组A,已排序的子数组B和C看起来是什么样子?
正确答案是第二个:如果数组没有分裂逆序对,那么前半部分的所有元素都小于后半部分的所有元素。
为什么?考虑逆否命题:假设前半部分有一个元素大于后半部分的任何一个元素,仅这一对元素就构成一个分裂逆序对。所以,如果你没有分裂逆序对,那么左半部分的所有元素都小于右半部分的所有元素。
更重要的是,思考一下在具有此属性的数组上执行归并子程序的情况,即在一个左半部分所有元素都小于右半部分所有元素的输入数组A上。
归并会做什么?记住,它总是在寻找剩余元素中较小的那个:B中剩余的第一个元素或C中剩余的第一个元素,并将其复制过去。那么,如果B中的所有元素都小于C中的所有元素,那么在C被触及之前,B中的所有元素都将被复制到输出数组D中。因此,在没有分裂逆序对(即分裂逆序对数量为零)的输入数组上,归并的执行过程异常简单:首先遍历B并复制其所有元素,然后直接连接C。两者之间没有交错。所以,没有分裂逆序对意味着在B耗尽之前,绝对不会从C复制任何元素。
这表明,从第二个子数组C复制元素可能与原始数组中的分裂逆序对数量有关,事实确实如此。我们将看到一个普遍模式:从第二个数组C复制元素到输出数组的过程,揭示了原始输入数组A中的分裂逆序对。
详细示例分析
让我们回到上一个视频中的例子,这是一个包含六个元素的数组:[1, 3, 5, 2, 4, 6]。
我们进行递归调用。实际上,数组的左半部分[1, 3, 5]和右半部分[2, 4, 6]都已经排序,所以递归调用中不会进行排序。你会从两个递归调用中得到零个逆序对。记住,在这个例子中,所有的逆序对都是分裂逆序对。
现在,让我们跟踪在这两个已排序子数组上调用的归并子程序,并尝试找出其与原始六元素数组中分裂逆序对数量的联系。
初始化索引i和j,分别指向这两个子数组的第一个元素。左边的是B,右边的是C,输出是D。
我们做的第一件事是将B中的1复制到输出数组。1被复制过去,我们将B的索引推进到3。这里没有发生什么有趣的事情,没有理由统计任何分裂逆序对。确实,元素1不涉及任何分裂逆序对,因为它比所有其他元素都小,并且它在第一个索引。
当我们从第二个数组C复制元素2时,事情变得有趣得多。注意,在这一点上,我们已经偏离了在无分裂逆序对数组上会看到的简单执行过程。现在,在耗尽B之前,我们从C复制了东西。我们希望这能揭示一些分裂逆序对。
我们复制了2,并将C的指针j向前推进。需要注意的关键点是:这揭示了两个分裂逆序对,即涉及元素2的两个分裂逆序对:(3, 2)和(5, 2)。
为什么会这样?原因是我们复制2是因为它小于B和C中所有我们尚未查看的剩余元素。特别是,2小于B中剩余的元素3和5。而且,因为B是左数组,3和5的索引必须小于这个2的索引。所以这些是逆序对:2在原始输入数组中更靠右,但它却小于B中这些剩余的元素。B中剩余两个元素,这就是涉及元素2的两个分裂逆序对。
现在让我们回到归并子程序,看看接下来会发生什么。接下来我们从第一个数组复制3,我们意识到从第一个数组复制时,至少就分裂逆序对而言,没有发生什么有趣的事情。然后我们复制4,再次发现一个分裂逆序对:(5, 4)。同样,原因是给定4在B中剩余元素之前被复制,它必须小于5,但因为它位于右半部分数组,它的索引必须更大。所以它必然是一个分裂逆序对。
现在,归并子程序的其余部分执行没有任何意外:5被复制(我们知道从左数组复制是“无聊”的),然后我们复制6(从右数组复制通常是有趣的,但如果左数组为空,则不涉及任何分裂逆序对)。
你会记得在之前的视频中,原始数组中的三个逆序对(3,2)、(5,2)和(5,4),我们通过仅仅留意何时从右数组C复制,就以一种自动化的方式发现了它们。
一般性结论
这确实是一个普遍原则。让我陈述这个一般性主张。
主张:不仅在这个特定的例子或特定的执行过程中,而且无论输入数组是什么,无论可能有多少分裂逆序对,涉及右半部分数组某个元素的分裂逆序对,恰好就是在该元素被复制到输出数组时,左数组中剩余的元素。
这正是我们在例子中看到的模式。在右数组C中,我们有元素2、4和6。记住,每个分裂逆序对根据定义都涉及一个来自前半部分的元素和一个来自后半部分的元素。因此,对于分裂逆序对的计数,我们可以根据它们涉及的右数组元素进行分组。
对于2、4和6:
2涉及的分裂逆序对是(3,2)和(5,2)。3和5正是当我们复制2时B中剩余的元素。4涉及的分裂逆序对是(5,4)。5正是当我们复制4时B中剩余的元素。6不涉及任何分裂逆序对。确实,当我们将6复制到输出数组D时,B是空的。
一般性论证:这相当简单。让我们聚焦于左半部分数组(即前半部分元素)中的一个特定元素x,并检查哪些y(即原始输入数组右半部分的哪些元素)与x构成分裂逆序对。
有两种情况,取决于x是在y之前还是之后被复制到输出数组D。
- 如果
x在y之前被复制到输出数组D,那么由于输出是按排序顺序的,这意味着x小于y。因此,不会构成分裂逆序对。 - 如果
y在x之前被复制到输出数组D,那么同样因为我们是按排序顺序从左到右填充D,这必然意味着y小于x。此时x仍然留在左数组B中,所以它的索引小于y(y来自右数组)。因此,这确实是一个分裂逆序对。
将这两种情况结合起来,表明与y构成分裂逆序对的B中的元素x,正是那些将在y之后被复制到输出数组的元素。也就是说,它们恰好就是在y被复制时B中剩余的元素数量。这就证明了一般性主张。
这张幻灯片确实是关键的洞见。
算法实现与运行时间分析
既然我们完全理解了为什么在合并两个已排序子数组时统计分裂逆序对很容易,那么将其转化为代码并得到一个同时进行合并和统计分裂逆序对数量的线性时间子程序实现,就是一件简单的事情了。然后,在整体的递归算法中,它将具有与归并排序相同的O(n log n)运行时间。
让我们花一点时间填充这些细节。
我不会写出完整的伪代码,只会写出你需要如何增强几页前讨论的归并伪代码,以便在归并的同时统计分裂逆序对。这将直接遵循之前的主张,该主张指出了分裂逆序对与归并过程中左数组剩余元素数量的关系。
思路很自然:当你按照之前的伪代码合并两个已排序子数组时,只需维护一个运行总数,记录你遇到的分裂逆序对数量。

你有一个已排序子数组B,一个已排序子数组C,你将它们合并到一个输出数组D中。当你遍历D(k从1到n)时,将计数从零开始,每次从B或C复制一个元素时,你都将其增加某个值。

增量是多少?我们刚刚看到,涉及从B复制的操作不计入。当我们从B复制时,我们不看分裂逆序对,只有当我们从C复制时才看。每个分裂逆序对恰好涉及B和C各一个元素,因此我们不妨通过C中的元素来计数。一个给定的C元素涉及多少个分裂逆序对?恰好就是在它被复制时B中剩余的元素数量。
这告诉我们如何增加这个运行计数。从前一页的主张可以直接得出,这个运行总数的实现精确地统计了原始输入数组A所拥有的分裂逆序对数量。
回想一下,左逆序对由第一个递归调用统计,右逆序对由第二个递归调用统计。每个逆序对要么是左逆序对、右逆序对,要么是分裂逆序对,恰好是这三种类型之一。因此,通过这三个不同的子程序(两个递归调用和这里的合并计数),我们成功地统计了原始输入数组的所有逆序对。
这就是算法的正确性。

运行时间是多少? 回想在归并排序中,我们首先分析归并的运行时间,然后讨论整个归并排序算法的运行时间。让我们在这里也简要地做同样的事情。
这个同时进行合并和统计分裂逆序对数量的子程序的运行时间是多少?有我们在合并中所做的工作,我们已经知道那是线性的。这里唯一额外的工作是增加运行计数,这对于D的每个元素是常数时间(每次我们复制一个元素时,我们对运行计数进行一次加法操作)。所以每个元素常数时间,总体线性时间。
我在这里的表述有点不严谨,但这是非常常规的不严谨。通过写O(n) + O(n) = O(n)来表述。当你做这样的陈述时要小心:如果你把O(n)加到自己身上n次,那不会是O(n);但如果你把O(n)加到自己身上常数次,它仍然是O(n)。作为练*,你可能想写出正式版本的含义。
基本上,存在某个常数C1,使得合并部分最多需要C1 * n步。存在某个常数C2,使得其余工作最多需要C2 * n步。当我们将它们相加时,我们得到最多(C1 + C2) * n步,这仍然是O(n),因为C1 + C2是一个常数。
所以,归并的线性工作加上运行计数的线性工作,使得子程序总体上是线性工作。
现在,通过我们在归并排序中使用的完全相同论证,因为我们有两个对半大小数组的递归调用,并且在递归调用之外做线性工作,所以总体运行时间是O(n log n)。
因此,我们确实只是搭载在归并排序之上,在排序的同时进行计数,运行时间保持为O(n log n)。
总结
本节课中,我们一起学*了如何高效计算数组的逆序对数量。我们基于分治策略,并创造性地将计数过程与归并排序相结合。核心在于,在合并两个已排序子数组时,通过观察从右半部分数组复制元素的时机,可以线性时间内统计出所有的“分裂逆序对”。最终,我们得到了一个与归并排序时间复杂度相同(O(n log n))的优雅算法。
016:Strassen次立方矩阵乘法算法 🧮
在本节课中,我们将学*如何应用分治算法设计范式来解决矩阵乘法问题。我们将从最直观的立方时间算法出发,探讨一种递归分治方法,并最终介绍Strassen的次立方时间矩阵乘法算法。该算法通过巧妙的数学变换,将递归调用次数从8次减少到7次,从而实现了运行时间的根本性改善。
矩阵乘法问题定义
首先,我们需要明确矩阵乘法问题的定义。我们关注三个矩阵 X、Y 和 Z,并假设它们都是 N × N 的方阵。矩阵中的元素可以是整数、有理数或来自某个域,关键在于我们可以对它们进行加法和乘法运算。
两个矩阵 X 和 Y 相乘得到 Z 的规则是:Z 中第 i 行第 j 列的元素 Zᵢⱼ,等于 X 的第 i 行与 Y 的第 j 列的点积。

用公式表示如下:
Zᵢⱼ = Σₖ₌₁ⁿ (Xᵢₖ × Yₖⱼ)
这里,输入大小并非 n,而是 n²,因为每个矩阵有 n² 个元素。因此,一个理想的矩阵乘法算法,其运行时间最好能达到 O(n²)。我们的目标是探索能否接*这个理想值。

为了确保理解,我们来看一个具体的2×2矩阵乘法例子。给定矩阵:
[ A B ] [ E F ]
[ C D ] × [ G H ]
其结果为:
[ AE+BG AF+BH ]
[ CE+DG CF+DH ]
朴素算法及其复杂度

根据上述定义,最直接的算法是使用三重循环计算每个输出元素。


以下是该算法的核心逻辑:
def naive_matrix_multiply(X, Y):
n = len(X)
Z = [[0 for _ in range(n)] for _ in range(n)]
for i in range(n):
for j in range(n):
for k in range(n):
Z[i][j] += X[i][k] * Y[k][j]
return Z

该算法的运行时间是 Θ(n³)。因为计算每个 Zᵢⱼ 需要 Θ(n) 时间,而总共有 n² 个这样的元素需要计算。
分治法的初步尝试
受到整数乘法分治算法(如Karatsuba算法)的启发,我们很自然地想将分治法应用于矩阵乘法。分治法的步骤是:
- 分解:将原问题划分为更小的子问题。
- 解决:递归地解决这些子问题。
- 合并:将子问题的解合并为原问题的解。
对于 n × n 矩阵,我们将其划分为四个 n/2 × n/2 的象限(或称为分块矩阵)。假设我们将矩阵 X 和 Y 分块如下:
X = [ A B ] Y = [ E F ]
[ C D ] [ G H ]
其中 A, B, C, D, E, F, G, H 都是 n/2 × n/2 的矩阵。
那么,乘积 Z = X × Y 也可以表示为分块形式:
Z = [ AE+BG AF+BH ]
[ CE+DG CF+DH ]
这个公式与2×2矩阵乘法的形式完全一致,只是这里的元素本身是矩阵。

基于此,我们可以设计第一个递归算法:
- 递归计算8个必要的矩阵乘积:A×E, B×G, A×F, B×H, C×E, D×G, C×F, D×H。
- 按照上述公式,通过矩阵加法组合这些乘积结果,得到最终的 Z。
然而,这个算法需要进行8次递归调用,每次处理规模为 n/2 的问题,外加 Θ(n²) 的矩阵加法时间。根据主定理(将在后续课程中详细讲解),该算法的运行时间仍然是 Θ(n³),与朴素迭代算法相同。
Strassen算法的核心思想
既然简单的分治没有带来改善,那么关键问题在于:能否减少递归调用的次数?Strassen算法的惊人之处就在于,它通过一系列巧妙的线性组合,将递归调用次数从8次减少到了7次。
Strassen算法同样分为两步,但计算过程更为精巧:
- 递归计算7个精心构造的矩阵乘积(P₁ 到 P₇)。
- 通过加/减运算组合这7个乘积,得到最终结果矩阵的四个象限。

虽然第二步中的加/减运算比朴素递归算法稍多,但这只增加了常数倍的时间,仍然为 Θ(n²)。而将递归调用从8次减为7次,却对总运行时间产生了根本性的影响,使其从立方时间降低到了次立方时间(具体为 O(n^log₂7) ≈ O(n^2.81))。
Strassen算法详解
让我们具体看看Strassen算法是如何构造这7个乘积的。我们继续使用之前的分块表示。
以下是需要计算的7个乘积:
- P₁ = A × (F - H)
- P₂ = (A + B) × H
- P₃ = (C + D) × E
- P₄ = D × (G - E)
- P₅ = (A + D) × (E + H)
- P₆ = (B - D) × (G + H)
- P₇ = (A - C) × (E + F)
在计算这些乘积之前,我们需要先进行一些矩阵的加法和减法(如 F-H, A+B 等),这些操作可以在 Θ(n²) 时间内完成。然后,我们对这7个表达式进行7次递归调用,计算 n/2 × n/2 矩阵的乘积。

最关键的是,我们可以仅用这7个乘积 P₁ 到 P₇,通过更多的加/减运算,重构出原始乘积 X × Y 的四个象限:
- Z 的左上角 (原为 AE+BG) = P₅ + P₄ - P₂ + P₆
- Z 的右上角 (原为 AF+BH) = P₁ + P₂
- Z 的左下角 (原为 CE+DG) = P₃ + P₄
- Z 的右下角 (原为 CF+DH) = P₁ + P₅ - P₃ - P₇

我们可以验证其中一个等式。例如,展开左上角的表达式:
P₅ + P₄ - P₂ + P₆
= (AE + AH + DE + DH) + (DG - DE) - (AH + BH) + (BG + BH - DG - DH)
= AE + BG
经过一系列项的对消,我们恰好得到了 AE + BG,与定义相符。其他等式的验证也类似。
因此,Strassen算法确实能用7次递归调用完成矩阵乘法。减少一次递归调用,在递归的每一层都会累积优势,最终实现了运行时间的质变。
总结
本节课我们一起学*了矩阵乘法的算法演进。
- 我们首先明确了矩阵乘法的定义和朴素的 O(n³) 算法。
- 接着,我们尝试应用分治法,将矩阵划分为象限进行递归乘法,但最初的递归方案仍需8次调用,未能超越立方时间。
- 最后,我们深入探讨了Strassen的突破性算法。该算法通过极其巧妙的数学构造,将递归调用次数减少到7次,从而首次实现了矩阵乘法的次立方时间复杂度(O(n^log₂7))。
Strassen算法展示了算法设计的巨大威力:即使对于像矩阵乘法这样基础且被深入研究的问题,通过深刻的洞察和精巧的设计,仍然有可能取得根本性的效率提升。在接下来的课程中,我们将学*“主定理”,它可以用来严格分析这类分治算法的运行时间。
017:最*点对O(n log n)算法 I(进阶可选)🔍
在本节课中,我们将学*一个非常酷的分治算法,用于解决最*点对问题。这是一个计算几何领域的问题,给定平面上的若干点,目标是找出彼此距离最*的一对点。计算几何算法在机器人学、计算机视觉和图形学等领域非常重要。这个算法相对进阶,其正确性证明也颇具技巧性,因此请做好准备。我们将以比平时稍快的节奏进行讲解。
问题定义

我们被给定平面上的 n 个点,每个点由其 x 坐标和 y 坐标定义。在本问题中,我们关注两点之间的欧几里得距离。
我们用 d(p_i, p_j) 表示点 p_i 和 p_j 之间的欧几里得距离。其计算公式为:
d(p_i, p_j) = sqrt((x_i - x_j)^2 + (y_i - y_j)^2)
问题的目标是,在所有点对中,找出距离最小的那一对。
初步观察
首先,为了简化,我们假设所有点的 x 坐标互不相同,所有点的 y 坐标也互不相同。这个假设不影响算法的核心思想,处理坐标相同的情况只需稍作扩展。
接下来,让我们回顾之前学过的计算逆序对问题,它与本问题有相似之处。第一个相似点是,如果我们满足于 O(n^2) 的算法,那么问题很简单:只需暴力枚举所有点对,计算距离并记录最小值即可。这显然是一个正确的算法,但时间复杂度是 Θ(n^2)。
关键问题是:我们能否运用算法技巧做得更好?能否得到一个优于暴力枚举所有点对的算法?你可能会直觉地认为,因为问题涉及 n^2 数量级的对象,我们可能本质上就需要做 n^2 量级的工作。但回想计算逆序对时,尽管一个数组中可能存在多达 n^2 个逆序对,我们依然通过分治得到了 O(n log n) 的算法。那么,对于最*点对问题,我们能否做类似的事情呢?

一维情况的启示
为了寻找排序可能有助于我们突破 O(n^2) 障碍的证据,让我们先看一个问题的特例,即一维版本:所有点都位于一条直线上。
在一维情况下,解决最*点对问题的一个方法是:首先将点按坐标排序。排序后,最*的点对必然是排序序列中相邻的点。因此,我们只需遍历所有 n-1 个相邻点对,找出距离最小的即可。
更正式地说,解决一维问题的步骤如下:
- 使用归并排序等算法,在
O(n log n)时间内将点按坐标排序。 - 线性扫描排序后的点,计算每个相邻点对的距离,并记录最小值。
在下图的例子中,绿色圆圈标出的就是最*点对。

虽然这不能直接解决我们最初的二维平面问题,但它表明,即使在一维情况下存在 n^2 个点对,我们也能借助排序,以 O(n log n) 的时间击败朴素的暴力搜索。因此,本节课的目标就是为二维情况设计一个同样优秀的 O(n log n) 算法。
高层思路与预处理

我们将成功实现这个目标。首先,让我们从高层思路开始。
第一步是尝试复制在一维情况下成功的方法。在一维中,我们首先按坐标排序。在二维中,点有 x 和 y 两个坐标,因此有两种排序方式。我们干脆两种都排。

这实际上是算法的预处理步骤:
- 我们调用归并排序,生成按
x坐标排序的点集副本,记为P_x。 - 我们再生成第二个按
y坐标排序的点集副本,记为P_y。
这个预处理步骤耗时 O(n log n)。考虑到我们的目标就是 O(n log n) 的算法,预先排序点集并无害处。这体现了本课程的一个主题:识别那些可以“免费”使用的基本操作。排序就是典型的“免费”基本操作之一。尽管我们还不完全清楚排序为何有用(受一维情况启发),但先做起来总是好的。
然而,我们不能将一维的类比进行得太远。特别是,我们不能指望仅仅通过线性扫描 P_x 或 P_y 数组就能识别出最*点对。
考虑一个例子:假设有6个点。
- 两个蓝点
x坐标很*,但y坐标很远。 - 两个绿点
y坐标很*,但x坐标很远。 - 两个红点在
x和y坐标上都不算太远,它们才是最*点对。


在按 x 排序的数组 P_x 中,一个蓝点会夹在两个红点之间,红点并非相邻。同样,在按 y 排序的数组 P_y 中,一个绿点会夹在两个红点之间。因此,仅线性扫描相邻点对是无法发现红点这个最*点对的。
所以,在预处理(内部使用了分治的归并排序)之后,我们将再次运用分治算法来计算最*点对。

分治框架
在将分治应用于最*点对问题之前,让我们简要回顾一下分治算法设计范式。
- 分:将问题划分为更小的子问题。对于最*点对,我们将按
x坐标将点集P分成左右两半,分别记为Q(左半部分)和R(右半部分)。 - 治:递归地解决子问题。即递归计算
Q中的最*点对和R中的最*点对。 - 合:这是分治算法最具创造性的部分。给定子问题的解,如何快速恢复原问题的解?对于最*点对,问题在于:已知左半部分最*点对和右半部分最*点对,如何快速找出整个点集
P的最*点对?我们将把大部分时间花在这里。
现在,让我们更精确地描述这个分治算法。

算法步骤详解
算法的输入是经过预处理后得到的两个已排序数组:P_x(按 x 排序)和 P_y(按 y 排序)。
- 分:给定
P_x,很容易识别出左半部分的点(x坐标最小的n/2个点,记为Q)和右半部分的点(x坐标最大的n/2个点,记为R)。为了递归调用,我们需要Q和R各自按x和y排序的版本Q_x,Q_y,R_x,R_y。由于P_x和P_y已排序,通过线性扫描即可生成这些子列表,耗时O(n)。注:我们省略了基本情况(例如点数为2或3时),此时可直接用常数时间暴力求解。


-
治:递归调用
Closest-Pair算法于两个子问题。- 在左半部分
Q上调用,返回最*点对(p_1, q_1)。 - 在右半部分
R上调用,返回最*点对(p_2, q_2)。
- 在左半部分
-
定义 δ:令
δ = min( d(p_1, q_1), d(p_2, q_2) )。δ代表递归调用找到的左右两部分内部最*点对距离的较小者。 -
处理“跨边界”点对:原问题
P的最*点对可能完全在Q中(幸运情况),完全在R中(幸运情况),或者一个在Q一个在R(“跨边界”或“分割”情况)。在幸运情况下,递归调用已经找到了答案。但在“跨边界”情况下,我们需要一个专门的子程序来寻找可能比δ更*的跨边界点对。我们称这个子程序为Closest-Split-Pair。 -
合:比较三个候选点对——
(p_1, q_1)、(p_2, q_2)以及Closest-Split-Pair返回的点对(如果存在),返回距离最小的那个。
算法的正确性依赖于 Closest-Split-Pair 子程序的正确实现。接下来,我们分析一下该子程序需要达到怎样的性能,才能保证整体算法为 O(n log n)。
对 Closest-Split-Pair 的期望
通过类比归并排序和计算逆序对,我们可以分析整体算法的递归式。除了两次规模减半的递归调用外,如果 Closest-Split-Pair 能在 O(n) 时间内完成,那么整体算法的递归树将带来 O(n log n) 的工作量,加上预处理排序的 O(n log n),总时间即为 O(n log n)。
因此,我们的目标就是实现一个能在 O(n) 时间内正确运行的 Closest-Split-Pair 子程序。
但这里有一个关键且微妙的想法:我们实际上不需要一个总是能正确计算任意点集最*跨边界点对的完整子程序。我们只需要它在特定“不幸情况”下正确即可。

关键观察:我们只在“不幸情况”下才真正需要 Closest-Split-Pair 的结果,即当整个点集 P 的最*点对恰好是跨边界点对,并且其距离严格小于 δ 时。在“幸运情况”下,递归调用已经给出了答案,我们并不关心跨边界点对。
利用这个观察,我们可以要求 Closest-Split-Pair 子程序只保证在“存在距离小于 δ 的跨边界点对”这一前提下正确工作。这比要求它总是正确要容易,并将使我们能够实现线性的 O(n) 算法。
因此,我们修改高层算法,将 δ 作为参数传递给 Closest-Split-Pair 子程序。
Closest-Split-Pair 子程序实现
现在,让我们描述 Closest-Split-Pair 的实现。首先明确我们对它的要求:
- 运行时间:始终为
O(n)。 - 正确性:不需要总是计算最*的跨边界点对,但必须保证:如果存在距离严格小于
δ的跨边界点对,那么该子程序必须找到它(或找到距离同样小于δ的某个跨边界点对)。
以下是该子程序的伪代码思路,其运行时间分析是简单的,但正确性要求则非常不显然。
-
过滤与聚焦:
- 设
x̄为左半部分点集Q中最大的x坐标(即P_x中第n/2个点的x坐标)。这可以常数时间从P_x获得。 - 我们只关注那些
x坐标落在区间[x̄ - δ, x̄ + δ]内的点。这些点构成一个以x̄为中心、宽度为2δ的垂直条带。 - 从已按
y排序的数组P_y中,通过线性扫描,提取出所有位于此垂直条带内的点,并按y坐标保持排序,记这个有序列表为S_y。此步骤耗时O(n)。
![]()
- 设
-
线性扫描与有限比较:
- 初始化
best = δ,best_pair = null。 - 对
S_y中的点进行线性扫描。对于S_y中的每个点p_i(从第1个到倒数第8个),我们只考虑它后面至多7个点p_{i+1}, p_{i+2}, ..., p_{i+7}。 - 对于每一对这样的点
(p_i, p_{i+j})(j从1到7),计算其距离d(p_i, p_{i+j})。 - 如果
d(p_i, p_{i+j}) < best,则更新best和best_pair。 - 扫描结束后,返回
best_pair。
![]()
- 初始化
运行时间分析:外层循环遍历 S_y(最多 n 个点),内层循环只执行常数次(最多7次)。每次迭代进行常数量的工作(计算距离和比较)。因此,总运行时间为 O(n)。
正确性声明的惊人之处:这个算法能正确工作的关键在于以下声明(我们将在下节课证明):
声明:假设存在一个跨边界点对
(p, q)(p在左,q在右),且其距离d(p, q) < δ。那么:
(A)p和q都属于S_y(即位于宽度为2δ的垂直条带内)。
(B)p和q在有序列表S_y中彼此非常接*,它们的索引差最多为7。

这个声明的第二部分尤其令人惊讶,它保证了我们只需要检查每个点后面紧邻的7个点,就足以捕捉到任何可能比 δ 更*的跨边界点对。
结论与展望
如果上述声明成立,那么我们可以得出以下推论:
- 推论1:在“不幸情况”下(即整个点集的最*点对是跨边界点对),
Closest-Split-Pair子程序将正确识别出该最*点对(或一个距离同样小于δ的点对)。因为根据声明,任何距离小于δ的跨边界点对都会出现在S_y中,并且索引差不超过7,因此必然会被我们“检查每个点后7个点”的循环所考察到。 - 推论2:结合
O(n)的Closest-Split-Pair和O(n log n)的递归框架及预处理,我们得到了一个正确的、运行时间为O(n log n)的二维最*点对算法。
本节课我们一起学*了最*点对问题的定义、一维情况的解法启示、以及基于分治的高层算法框架,并详细介绍了关键的 Closest-Split-Pair 子程序的设计思路及其惊人的效率声明。下节课,我们将深入证明那个关键的声明,从而完成整个算法的正确性论证。
018:最*点对 O(n log n) 算法 II(进阶可选)🔍
在本节课中,我们将要学*如何证明分治最*点对算法的正确性。该算法能在 O(n log n) 时间内找到平面上所有点对中距离最*的一对。
概述 📋
上一节我们介绍了分治最*点对算法的基本框架和核心子程序。本节中,我们将专注于证明该算法的正确性。具体来说,我们将证明一个关键的正确性断言:如果存在一个跨越左右两半的“分裂点对”,且其距离小于递归调用返回的最小距离 δ,那么我们的线性时间子程序一定能找到它。
算法回顾 🔄
首先,让我们回顾一下算法的整体流程。
算法流程:
- 预处理:将所有点按 x 坐标排序,并备份一份按 y 坐标排序的列表。这需要 O(n log n) 时间。
- 分治:
- 划分:将点集垂直划分为左半部分 Q 和右半部分 R。
- 征服:递归计算左半部分 Q 和右半部分 R 的最*点对距离,分别记为 δ_left 和 δ_right。令 δ = min(δ_left, δ_right)。
- 合并:调用
countSplitPair子程序,在线性时间内寻找是否存在一个点对,其中一个点在 Q 中,另一个点在 R 中,且距离小于 δ。
我们已经论证过算法的总运行时间为 O(n log n)。现在,剩下的任务是证明其正确性。

正确性断言 📜
算法的正确性归结为以下断言:
正确性断言:
考虑任意一个分裂点对 (p, q),其中点 p 来自左半部分 Q,点 q 来自右半部分 R。进一步假设这是一个“有趣的”分裂点对,即它们之间的距离小于参数 δ(δ 是递归调用返回的左右两部分内部最*点对距离的最小值)。那么:
A. 点 p 和点 q 都会通过筛选步骤,进入按 y 坐标排序的列表 S_y(即它们都位于以中位 x 坐标 x̄ 为中心、宽度为 2δ 的垂直条带内)。
B. 点 p 和点 q 在数组 S_y 中的位置索引相差不超过 7。这意味着我们子程序中的双重循环(对每个点检查其后最多 7 个点)一定会考虑并比较这对点。
如果这个断言成立,那么当整个点集的最*点对恰好是分裂点对时,我们的子程序就一定能找到它,从而保证整个算法的正确性。
接下来,我们将分别证明这两个部分。
证明部分 A:点位于垂直条带内 📏
部分 A 的证明相对简单。我们的假设是:存在点 p = (x1, y1) ∈ Q,点 q = (x2, y2) ∈ R,且它们之间的欧几里得距离 d(p, q) < δ。
首先,一个简单的观察是:如果两个点的欧几里得距离小于 δ,那么它们在每个坐标轴上的差值也必然小于 δ。用公式表示就是:
| x1 - x2 | < δ 且 | y1 - y2 | < δ
这是因为根据距离公式 d = sqrt((x1-x2)² + (y1-y2)²),如果 d < δ,那么每一项 (x1-x2)² 和 (y1-y2)² 都必须小于 δ²。

现在,我们来证明 p 和 q 的 x 坐标都位于区间 [x̄ - δ, x̄ + δ] 内。
- 由于 p 来自左半部分 Q,而 x̄ 是左半部分最右边的 x 坐标,所以有
x1 ≤ x̄。 - 由于 q 来自右半部分 R,而 x̄ 是左半部分最右边的 x 坐标(即右半部分从 x̄ 开始),所以有
x2 ≥ x̄。
想象一下,x1 和 x2 被一根长度为 δ 的“绳子”拴着。x1 不能跑到 x̄ 右边,所以即使 x1 紧贴 x̄,x2 由于被绳子牵着,也无法超过 x̄ + δ。同理,x2 不能跑到 x̄ 左边,所以即使 x2 紧贴 x̄,x1 也无法低于 x̄ - δ。
因此,p 和 q 的 x 坐标都落入了垂直条带内,它们都会出现在筛选后的集合 S_y 中。部分 A 得证。
证明部分 B:索引位置相* 🔢
部分 B 的结论更令人惊讶:这对最*的分裂点对在 S_y 中几乎是相邻的。这是算法高效的关键。我们将通过一个“思想实验”和两个引理来证明。
构建关键图示 🎨
我们考虑一个由 8 个格子组成的区域:
- 中心:垂直条带的中线 x = x̄。
- 高度:每个格子的高度为 δ/2。我们总共放置两行、四列,共 8 个这样的格子。
- 底部:底部边界设置为点 p 和 q 中较小的那个 y 坐标(记为 y_min)。
这个图示仅用于推理,算法本身并不绘制这些格子。
引理一:相关点位于图示内 📍

引理 1:所有属于 S_y 且其 y 坐标介于 p 和 q 的 y 坐标之间的点,都必然位于这 8 个格子之中。
证明:
- y 坐标范围:由于 d(p, q) < δ,我们知道
|y1 - y2| < δ。我们设置的底部是 y_min,顶部则是 y_min + δ。因此,任何 y 坐标介于 p 和 q 之间的点,其 y 坐标自然落在[y_min, y_min + δ]这个高度范围内。 - x 坐标范围:根据 S_y 的定义,其中的点都位于垂直条带内,即 x 坐标在
[x̄ - δ, x̄ + δ]之间。而我们图示的宽度正好覆盖了这个范围(中心 x̄,左右各两列,每列宽度 δ/2,总宽度 2δ)。
结合以上两点,这些点既在正确的 y 区间内,也在正确的 x 区间内,因此必然落入这 8 个格子之一。
引理二:每个格子至多一个点 🚫

引理 2:在这 8 个格子中,每个格子至多包含点集中的一个点。
证明(反证法):
假设某个格子中包含两个点 A 和 B。
- 同侧性:由于每个格子完全位于中线 x̄ 的左侧或右侧,因此 A 和 B 必然来自点集的同一半(要么都在左半 Q,要么都在右半 R)。
- 邻*性:格子很小,边长为 δ/2。即使 A 和 B 位于格子的对角位置,它们之间的最大距离也是
sqrt((δ/2)² + (δ/2)²) = δ / √2 < δ。
现在,我们利用 δ 的定义。δ 被定义为左右两半内部最*点对距离的最小值。也就是说,在同一个半侧(Q 或 R)内,任意两点之间的距离至少为 δ。
然而,我们刚刚找到了两个点 A 和 B,它们位于同一半侧,且距离严格小于 δ。这与 δ 的定义矛盾。
因此,假设不成立,每个格子中至多有一个点。
完成部分 B 的证明 ✅
结合引理 1 和引理 2:
- 引理 1 说,所有 y 坐标在 p 和 q 之间的 S_y 点都在 8 个格子里。
- 引理 2 说,每个格子至多一个点。
所以,在这 8 个格子中,点的总数不超过 8 个。这包括了点 p 和点 q 本身(它们各占一个格子)。
考虑点 p 和点 q 在按 y 坐标排序的数组 S_y 中的位置。在最坏情况下,可能有最多 6 个其他点的 y 坐标介于它们之间,并占据了其他 6 个格子。这意味着,从 p(或 q)在 S_y 中的位置开始,向后检查最多 7 个位置,就一定能遇到 q(或 p)。

因此,我们子程序中的双重循环(对每个点检查其后 7 个点)必定会扫描到这对距离小于 δ 的分裂点对 (p, q)。部分 B 得证。
总结 🎓
本节课中,我们一起学*了如何证明分治最*点对算法的正确性。
- 核心思路:算法的正确性依赖于一个关键断言——任何距离小于 δ 的跨左右分裂点对,在筛选后的列表 S_y 中索引相差不超过 7。
- 证明结构:
- 我们首先证明了这样的点对必然位于算法考察的垂直条带内(部分 A)。
- 然后,通过构造一个 8 格子的思想实验,并证明两个引理(所有相关点落入格子、每个格子至多一个点),我们证明了它们在 S_y 中位置相*(部分 B)。
- 结论:这确保了
countSplitPair子程序在线性时间内一定能找到最*的分裂点对(如果存在的话)。结合递归处理左右两部分内部点对,整个算法便能正确找出平面点集中的最*点对,且运行时间为 O(n log n)。

这个算法巧妙地结合了分治策略和几何洞察力,是算法设计与分析中的一个经典范例。
019:主方法动机 🎯
在本节课中,我们将学*主方法。主方法是一个通用的数学工具,用于分析分治算法的运行时间。我们将从介绍其动机开始,然后给出其形式化描述,并通过六个示例进行讲解。最后,我们将用三节课的时间讨论主方法的证明,特别强调其三种情况的概念性解释。
需要说明的是,本节课的数学内容比前两节稍多,但这并非为了数学而数学。我们的努力将换来这个强大工具——主方法——的回报。它具有很强的预测能力,能为我们提供关于哪些分治算法可能运行得更快、哪些可能更慢的指导。事实上,一个新的算法思想通常需要数学分析来正确评估,本节课就是这一普遍现象的一个例证。

动机示例:整数乘法
作为一个动机示例,考虑计算两个n位数相乘的问题。回想我们最初的课程,我们都学过迭代的小学乘法算法,该算法需要的基本操作(单数字的加法和乘法)数量随着数字位数n呈二次方增长。


另一方面,我们也讨论了一种使用分治范式的有趣递归方法。分治法需要识别更小的子问题。对于整数乘法,我们需要识别想要相乘的更小的数字。因此,我们采用显而易见的方式,将两个数字各自分解为左半部分和右半部分的数字。为方便起见,我假设数字位数n是偶数,但这并不重要。
将X和Y这样分解后,我们可以展开乘积并观察结果。让我们把这个表达式框起来,称之为星号表达式。
我们从一个显而易见的递归算法开始,即直接计算星号表达式。星号表达式包含四个涉及n/2位数的乘积:AC、AD、BC和BD。因此,我们进行四次递归调用来计算它们,然后以自然的方式完成计算,即根据需要补零并将这三个项相加得到最终结果。
我们使用所谓的递归式来分析此类递归算法的运行时间。为了引入递归式,让我先定义一些符号:T(n)。这是我们真正关心的量,即我们想要上界的量。具体来说,它表示这个递归算法在最坏情况下相乘两个n位数所需的操作次数。这正是我们想要上界的量。
递归式只是用更小数字的T值来表达T(n)的一种方式,即用其递归调用所做的工作来表达算法的运行时间。
每个递归式都有两个组成部分。首先,它有一个基础情况,描述没有进一步递归时的运行时间。在这个整数乘法算法中,像大多数分治算法一样,基础情况很简单:当输入变小(这里是一位数)时,运行时间只是常数——你只需将两个数字相乘并返回结果。我将其表示为:T(1) ≤ 常数。我不打算具体说明这个常数是多少,你可以认为它是1或2,这对后续内容并不重要。
递归式的第二个组成部分是重要部分,它描述了不在基础情况下、进行递归调用时的一般情况。你只需将运行时间写成两部分:首先是递归调用所做的工作,其次是当前在此处所做的工作(递归调用之外的工作)。在这个递归整数乘法算法中,正如我们讨论的,恰好有四次递归调用,每次调用处理一对n/2位数。这给出了4 * T(n/2)。在递归调用之外,我们做的工作是将递归调用的结果补零并相加。可以验证,小学加法实际上以位数n的线性时间运行。因此,递归调用之外的工作量是线性的,即O(n)。
综合起来,我们得到该算法的递归式:
- 基础情况:T(1) ≤ 常数
- 一般情况:T(n) ≤ 4T(n/2) + O(n)

高斯算法与改进的递归式
现在让我们转向第二个更巧妙的整数乘法递归算法,它可以追溯到高斯。高斯的洞见是意识到,在我们试图计算的星号表达式中,实际上我们只关心三个基本量,即表达式中三个项的系数。这让我们希望也许可以用三个递归调用而不是四个来计算这三个量。事实上,我们可以。

我们像以前一样递归计算AC和BD。然后我们计算(A+B)与(C+D)的乘积。一个非常巧妙的事实是,如果我们给这三个乘积编号为1、2和3,那么我们关心的最终量——10^(n/2)项的系数,即AD+BC——恰好是第三个乘积减去前两个乘积。这就是新算法。
新的递归式是什么?基础情况显然和以前完全一样。那么问题在于,一般情况如何变化?正确的答案是第二个选项:与第一个递归式相比,唯一的变化是递归调用的数量从四个减少到三个。

有几个快速说明。首先,当我说有三个递归调用,每个处理n/2位数时,我有点不严谨。因为当你取和A+B和C+D时,它们可能实际上有n/2+1位。但我们可以忽略这一点,仍然称每个递归调用处理n/2位数。通常,额外的+1在最终分析中并不重要。其次,我忽略了递归调用之外线性工作的具体常数因子。实际上,在高斯算法中,这个常数因子比具有四个递归调用的朴素算法要大一些,但它只是一个常数因子,在大O表示法中会被忽略。

因此,高斯算法的递归式是:
- 基础情况:T(1) ≤ 常数
- 一般情况:T(n) ≤ 3T(n/2) + O(n)

对比与未知

让我们看看这个递归式,并将其与另外两个递归式进行比较,一个更大,一个更小。首先,正如我们注意到的,它与朴素递归算法的前一个递归式的区别在于少了一个递归调用。我们不知道这两种递归算法的运行时间是多少,但我们应该确信这个算法(高斯算法)肯定只会更好。另一个对比点是归并排序。想想归并排序算法的递归式会是什么样子。它几乎与此相同,只是把3换成2。归并排序进行两次递归调用,每次处理一半大小的数组,在递归调用之外,它做线性工作,即归并子程序。我们知道归并排序的运行时间是O(n log n)。所以高斯算法会更差,但我们不知道差多少。
因此,虽然我们对这个算法的运行时间可能或多或少有一些线索,但老实说,我们并不知道高斯递归整数乘法算法的运行时间到底是什么。这并不明显,我们目前对此没有直觉,我们不知道这个递归式的解是什么。但它将是接下来我们要处理的通用主方法的一个特例。

总结
本节课中,我们一起学*了引入主方法的动机。我们通过整数乘法的例子,回顾了朴素分治算法和高斯改进算法的递归式,并对比了归并排序的递归式。我们发现,对于高斯算法,我们得到了递归式 T(n) ≤ 3T(n/2) + O(n),但目前我们无法直观判断其解。这引出了对一种通用分析工具的需求,即主方法,它将在接下来的课程中帮助我们解决这类问题。
020:主定理的形式化表述 📘

在本节课中,我们将学*主定理的精确数学表述。主定理是一个用于分析递归算法运行时间的强大工具,它能够处理特定格式的递归式,并直接输出算法运行时间的上界。
递归算法的通用格式

上一节我们介绍了主定理的通用性,本节中我们来看看其精确的数学表述形式。主定理适用于所有子问题规模相同的递归算法。

基本假设
首先,主定理(至少在本课程给出的版本中)只适用于所有子问题规模完全相同的递归算法。例如,在归并排序中,有两个递归调用,每个调用处理原数组的一半。因此,归并排序满足此假设。同样,在我们之前的整数乘法算法中,所有子问题处理的整数位数都是原问题的一半。
如果递归算法处理的子问题规模不同(例如,一个处理三分之一,另一个处理三分之二),那么本节介绍的主定理将不适用。虽然存在更通用的主定理版本可以处理不平衡的子问题规模,但这超出了本课程的范围。我们介绍的这个版本足以覆盖我们将要看到的大多数例子。
递归式的标准格式
接下来,我们描述主定理所适用的递归式格式。更通用的主定理版本可以处理更多类型的递归式,但这里给出的版本相对简单,并且足以覆盖你可能遇到的大多数情况。
递归式包含两个部分:
- 一个相对不重要但必要的基本情况。我们做一个显而易见的假设:当输入规模减小到足够小时,递归停止,子问题可以在常数时间内解决。这个假设在本课程的所有例子中都成立,因此我们不再深入讨论。
- 包含递归调用的一般情况。
我们假设递归式具有以下格式:
T(n) ≤ a * T(n/b) + O(n^d)
其中:
n是输入规模。a是递归调用的次数(子问题的数量)。a是一个不小于1的整数。b是每次递归调用前输入规模缩小的因子。b是一个大于1的常数(例如,递归处理一半问题则b=2)。d是递归调用之外所做工作的运行时间的指数。d是一个不小于0的常数(d=0表示常数时间的工作)。
需要强调的是,a、b 和 d 都是常数,它们是独立于输入规模 n 的数字(如1,2,3等)。
关于公式中的 O(n^d) 项,我们暂时忽略大O记号中隐藏的常数因子,这在证明主定理时不会影响最终结论。当然,指数 d 本身非常重要,它决定了工作是常数级、线性级还是平方级等。
主定理的精确表述 🧮
在建立了上述符号体系后,我们现在可以精确地陈述主定理。
给定一个形如 T(n) ≤ a * T(n/b) + O(n^d) 的递归式,其解(即运行时间上界)由以下三种情况之一给出,具体取决于 a 与 b^d 的比较结果:
情况1: 如果 a = b^d,那么 T(n) = O(n^d * log n)。
情况2: 如果 a < b^d,那么 T(n) = O(n^d)。
情况3: 如果 a > b^d,那么 T(n) = O(n^(log_b a))。
以下是几点说明:
- 这个版本的主定理只给出了运行时间的上界(Big-O),这是因为我们在递归式中也使用了Big-O。这符合本课程作为算法设计者的视角,即我们主要关注最坏情况运行时间的上界保证。
- 作为一个练*,你可以尝试证明:如果将递归式加强为
T(n) = a * T(n/b) + Θ(n^d),那么主定理结论中的所有Big-O都可以加强为Θ,从而得到渐*精确的解。 - 注意两种对数处理方式的差异:
- 在情况1的
log n中,我们没有指定对数的底数。这是因为不同底数的对数之间只相差一个常数因子,而这个常数因子被Big-O记号隐藏了。 - 在情况3的指数
log_b a中,我们必须明确指出对数的底数b,因为指数上的常数差异会导致运行时间发生质变(例如,从线性时间变为平方时间)。
- 在情况1的
总结与预告
本节课中,我们一起学*了主定理的精确数学表述。我们明确了其适用范围(子问题规模相同),定义了递归式的标准格式 T(n) ≤ a * T(n/b) + O(n^d),并完整陈述了依赖于 a 与 b^d 比较结果的三个解的情况。

目前这三个公式可能看起来有些神秘。在接下来的课程中,我们将首先通过多个例子(包括解决高斯递归整数乘法算法的运行时间问题)来应用主定理,然后我们将证明主定理本身。通过分析和证明,这三个案例及其对应的公式将变得非常自然且易于理解。
021:主方法应用示例 🧮
在本节课中,我们将通过六个不同的具体示例,学*如何应用主方法(Master Method)来分析递归算法的运行时间。我们将回顾主方法的核心公式,并通过实例理解其三种不同情况的适用场景。
主方法回顾
上一节我们介绍了主方法的基本概念,本节中我们来看看如何具体应用它。主方法适用于特定格式的递归式,该格式由三个常数 A、B 和 D 参数化。
- A 表示递归调用的次数,即需要解决的子问题数量。
- B 表示子问题规模相对于原问题规模的缩小因子。
- D 表示在递归调用之外所做工作的运行时间的指数。
递归式的形式为:
T(n) ≤ A * T(n/B) + O(n^D)
给定一个符合此格式的递归式,其运行时间由以下三种情况之一决定,具体取决于 A 与 B^D 之间的关系:
- 若 A = B^D,则
T(n) = O(n^D * log n)。 - 若 A < B^D,则
T(n) = O(n^D)。 - 若 A > B^D,则
T(n) = O(n^(log_B A))。
主方法初看可能有些难以理解,因此让我们通过一些具体示例来掌握其应用。
示例一:归并排序 ✅
我们从已知运行时间的算法开始。以下是归并排序的参数识别过程:
- A (递归调用次数):归并排序进行两次递归调用,因此
A = 2。 - B (规模缩小因子):每次递归处理原数组的一半,因此
B = 2。 - D (合并步骤的指数):合并步骤是线性时间的,因此
D = 1。
接下来,我们判断属于哪种情况:
A = 2,B^D = 2^1 = 2。两者相等,属于情况一。
根据情况一的公式,运行时间为 O(n^D * log n) = O(n^1 * log n) = O(n log n)。这与我们已知的结果一致,验证了主方法的正确性。
示例二:二分查找 🔍
现在,我们来看二分查找算法。以下是其参数识别过程:
- A (递归调用次数):二分查找每次只进行一次递归调用(进入左半部分或右半部分),因此
A = 1。 - B (规模缩小因子):每次递归处理原数组的一半,因此
B = 2。 - D (比较步骤的指数):每次递归调用前只进行一次常数时间的比较,因此
D = 0。
接下来,我们判断属于哪种情况:
A = 1,B^D = 2^0 = 1。两者相等,属于情况一。
根据公式,运行时间为 O(n^D * log n) = O(n^0 * log n) = O(log n)。这再次确认了二分查找的对数时间复杂度。
示例三:整数乘法(朴素递归) ➗
我们来看一个更复杂的例子:未使用高斯技巧的递归整数乘法算法。以下是其参数识别过程:
- A (递归调用次数):该算法对四个
n/2位数的乘积进行递归计算,因此A = 4。 - B (规模缩小因子):每次递归处理原数字位数的一半,因此
B = 2。 - D (组合步骤的指数):通过补零和加法进行组合,这是线性时间操作,因此
D = 1。
接下来,我们判断属于哪种情况:
A = 4,B^D = 2^1 = 2。由于 A > B^D,属于情况三。
根据情况三的公式,运行时间为 O(n^(log_B A)) = O(n^(log_2 4)) = O(n^2)。这与我们小学所学的迭代算法复杂度相同,说明朴素的递归分治并未带来改进。
示例四:整数乘法(使用高斯技巧) ✨
现在,我们应用高斯技巧来优化上述算法,将递归调用次数从4次减少到3次。以下是更新后的参数:
- A (递归调用次数):优化后只需三次递归调用,因此
A = 3。 - B (规模缩小因子):规模缩小因子不变,
B = 2。 - D (组合步骤的指数):组合步骤的复杂度不变,
D = 1。
接下来,我们判断属于哪种情况:
A = 3,B^D = 2^1 = 2。仍然满足 A > B^D,属于情况三。
运行时间为 O(n^(log_B A)) = O(n^(log_2 3)) ≈ O(n^1.59)。这显著优于 O(n^2),展示了巧妙的分治策略如何提升算法效率。
示例五:斯特拉森矩阵乘法 🧮
斯特拉森矩阵乘法算法是分治法的另一个经典应用。以下是其参数识别过程:
- A (递归调用次数):通过巧妙计算,将递归调用次数从8次减少到7次,因此
A = 7。 - B (规模缩小因子):每次递归处理原矩阵规模的一半(在维度上),因此
B = 2。 - D (组合步骤的指数):组合步骤涉及矩阵的加法和减法,对于
n x n矩阵是O(n^2)操作,因此D = 2。

接下来,我们判断属于哪种情况:
A = 7,B^D = 2^2 = 4。由于 A > B^D,属于情况三。

运行时间为 O(n^(log_B A)) = O(n^(log_2 7)) ≈ O(n^2.81)。这优于朴素矩阵乘法的 O(n^3) 复杂度。
示例六:触发情况二的虚构示例 ⚖️
前面的例子涵盖了情况一和情况三。为了完整起见,我们构造一个触发情况二的示例。考虑以下递归式:
T(n) = 2T(n/2) + O(n^2)
以下是其参数识别过程:

- A (递归调用次数):
A = 2。 - B (规模缩小因子):
B = 2。 - D (组合步骤的指数):组合步骤是平方时间的,因此
D = 2。

接下来,我们判断属于哪种情况:
A = 2,B^D = 2^2 = 4。由于 A < B^D,属于情况二。
根据情况二的公式,运行时间为 O(n^D) = O(n^2)。这个结果有些反直觉:虽然递归结构类似归并排序,仅将合并步骤从线性改为平方,但总运行时间并非 O(n^2 log n),而仅仅是 O(n^2)。这表明整个算法的运行时间主要由最外层(递归树根节点)的合并工作所主导。
总结 📝
本节课中,我们一起学*了如何应用主方法分析六种不同的递归算法。我们首先回顾了主方法的三种情况及其判定条件。随后,我们逐步分析了归并排序、二分查找、两种整数乘法算法、斯特拉森矩阵乘法以及一个虚构示例,识别了各自的 A、B、D 参数,并确定了所属情况,从而得出了它们的渐*运行时间上界。

通过这些实例,我们验证了主方法是一个强大而便捷的工具,能够快速分析符合其格式的递归算法的复杂度,无需展开复杂的递归树或进行代入法证明。关键在于准确识别算法中的递归调用次数、问题规模缩小因子以及递归外工作的复杂度指数。
022:主定理证明 I

在本节课中,我们将开始学*主定理的证明。主定理为特定形式的递归关系提供了一个通用的解决方案。我们将通过递归树的方法来分析,这种方法与我们分析归并排序时使用的方法类似。通过理解递归树的结构,我们可以直观地理解主定理三种情况背后的含义。
证明概述与假设
上一节我们介绍了主定理及其三种情况。本节中,我们来看看证明的起点和我们将要使用的一些简化假设。

首先,我们假设递归关系具有以下明确的形式:
T(n) = a * T(n/b) + c * n^d
其中,当 n = 1 时,T(1) = c。这里的常数 c 与一般情况大O表示法中隐藏的常数相同。为了简化分析,我们进一步假设 n 是 b 的幂次方。这些假设不会影响结论的一般性,但能使推导过程更清晰。
构建递归树
证明的核心方法是使用递归树,这与我们分析归并排序时的方法完全一致。让我们回顾一下递归树的结构。
在递归树的第0层(根节点),我们对应算法最外层的初始调用,输入规模为 n。
在第1层,我们对应第一组递归调用。
在第2层,我们对应由第一组递归调用所产生的下一组递归调用,依此类推,直到树的叶子节点,叶子节点对应不再进行递归的基本情况。

为了分析运行时间,我们需要理解递归树中每一层的模式。具体来说,对于给定的深度 j,我们需要知道:
- 在第
j层有多少个不同的子问题(即递归调用)。 - 每个第
j层的子问题处理的输入规模是多大。
以下是关于递归树层级结构的分析:
- 在第
j层,共有a^j个子问题。 - 每个子问题的输入规模为
n / b^j。
这是因为每次递归调用会产生 a 个新的子问题,所以子问题数量每层乘以 a。同时,每次递归调用输入规模会除以 b,所以经过 j 层递归后,输入规模变为 n / b^j。整个递归树的层数(从0层到叶子节点)为 log_b(n) + 1。
计算单层工作量
现在,让我们模仿归并排序的分析,计算递归树中某一特定层 j 所完成的工作总量(不包括该层子问题后续递归调用所做的工作)。

计算第 j 层总工作量的方法如下:
- 确定该层的子问题数量:
a^j。 - 确定每个子问题完成的工作量:根据递归关系,每个规模为
n / b^j的子问题,在递归调用之外所做的工作不超过c * (n / b^j)^d。
因此,第 j 层的总工作量 Work(j) 可以表示为:
Work(j) ≤ (a^j) * [c * (n / b^j)^d]
我们可以对这个表达式进行简化,分离出与层级 j 相关和无关的部分:
Work(j) ≤ c * n^d * (a / b^d)^j
请注意,在这个表达式中,比值 a / b^d 首次出现。这暗示了 a 与 b^d 的相对大小可能在分析中起到关键作用,而这正好对应了主定理的三种情况。
计算总工作量
为了得到算法的总运行时间 T(n),我们需要将递归树所有层的工作量求和。即,对从 j=0 到 j=log_b(n) 的所有 Work(j) 进行求和。
因此,总工作量满足以下不等式:
T(n) ≤ Σ_{j=0}^{log_b(n)} [c * n^d * (a / b^d)^j]
由于 c 和 n^d 是与求和索引 j 无关的常数,我们可以将它们提到求和符号外面:
T(n) ≤ c * n^d * Σ_{j=0}^{log_b(n)} (a / b^d)^j
我们把这个关键的表达式标记为 (★):
T(n) ≤ c * n^d * Σ_{j=0}^{log_b(n)} r^j,其中 r = a / b^d
本节总结

本节课中,我们一起学*了主定理证明的第一步。我们通过建立递归树模型,计算了每一层的工作量,并最终将总运行时间 T(n) 的界表示为一个几何级数的和 (★)。这个级数的公比 r = a / b^d 将成为我们后续分析的核心。在下一节中,我们将深入分析这个几何级数,并根据 r 小于1、等于1或大于1的三种不同情况,推导出主定理的三种不同结果。
023:主定理三种情况的直观解释 🧠

在本节课中,我们将深入理解主定理证明的直观部分。我们将探讨递归算法运行时间背后的“拉锯战”,并解释为何会自然产生三种不同的情况。通过理解子问题增殖率与工作量缩减率之间的竞争关系,我们可以直观地预测每种情况下的算法运行时间。

在上一节视频中,我们通过递归树方法分析了分治算法的运行时间上界,并得到了一个复杂的表达式。本节中,我们将不再进行具体计算,而是专注于解读这个表达式,为其赋予实际意义,并理解这种解读如何自然地引出主定理的三种情况,同时为我们将看到的运行时间提供直觉。
递归树中的关键表达式
回顾上一节,我们通过聚焦于递归树的特定层级 J 来界定算法完成的工作量。我们计算了该层级的子问题数量 A^J 乘以每个子问题的工作量 C * (N / B^J)^D,从而得到了表达式:C * N^D * (A / B^D)^J。
我们最终得到的表达式 * 是这个量在所有对数级别 J 上的总和。尽管这个表达式看起来很复杂,但我们可能走对了方向,因为主定理的三种情况正是由 A 与 B^D 的比较关系决定的,而在这个表达式中,我们恰好看到了比值 A / B^D。
让我们深入探讨,理解为何这个比值对分治递归算法的性能至关重要。
算法性能的“拉锯战” ⚔️
主定理本质上描述的是两种对立力量之间的“拉锯战”:一种是“善”的力量,一种是“恶”的力量,它们分别对应着 B^D 和 A 这两个量。
- 参数
A(恶的力量):A代表算法进行的递归调用次数,即递归树中一个节点的子节点数量。从根本上说,A衡量的是随着递归深度增加,子问题增殖的速率。它是下一层子问题数量比上一层多的倍数。 - 参数
B^D(善的力量):B是输入规模随递归层级J缩小的因子。D是递归调用外所做工作关于输入规模的指数(例如,线性工作D=1,二次工作D=2)。我们真正关心的是每个子问题工作量的缩减程度,这正是B^D。因此,B^D代表了工作量缩减的速率。
因此,主定理的三种情况对应着这场“拉锯战”的三种可能结果:平局、恶的力量获胜(A > B^D)以及善的力量获胜(B^D > A)。

为了更好地理解,请思考递归树中每层工作量的变化趋势:随着层级加深,每层总工作量是增加、减少还是保持不变?
每层工作量的变化趋势 📈📉
以下是关于每层工作量变化的陈述,其中第三个是错误的:
- 如果子问题增殖速率
A小于 工作量缩减速率B^D,则随着递归树层级加深,完成的工作量减少。 - 如果子问题增殖速率
A大于 工作量缩减速率B^D,则随着递归树层级加深,完成的工作量增加。 - 如果子问题增殖速率
A等于 工作量缩减速率B^D,则随着递归树层级加深,完成的工作量可能增加也可能减少。 - 如果子问题增殖速率
A等于 工作量缩减速率B^D,则递归树每一层完成的工作量相同。
让我们逐一分析:
- 陈述1为真:善的力量(工作量缩减)压倒了恶的力量(子问题增殖),因此每层工作量递减。
- 陈述2为真:原因正好相反,恶的力量获胜,每层工作量递增。
- 陈述3为假:根据
A与B^D的比较关系,我们可以明确得出工作量是递增还是递减的结论。 - 陈述4为真:这是善与恶力量的完美平衡。子问题在增殖,但每个子问题的工作量以完全相同速率缩减,两者抵消,导致每层工作量相同。这正是我们分析归并排序时遇到的情况。
从直觉到运行时间预测 🔮
总结一下,主定理的三种情况对应于子问题增殖与工作量缩减之间战斗的三种可能结果:平局、子问题增殖更快、或工作量缩减更快。

- 情况1(平局):速率完全相同,相互抵消。那么递归树每一层的工作量应该相同。在这种情况下,我们可以轻松预测运行时间:我们知道有对数数量的层级,每层工作量相同,并且我们知道根节点的工作量(由递归式给出,渐*为
N^D)。因此,对于log N层,每层做N^D的工作,我们期望运行时间为N^D * log N。 - 情况2(工作量缩减更快):每个子问题的工作量缩减速度超过了子问题的增殖速度。那么随着递归层级的加深,工作量越来越少。最坏的情况(工作量最大的层级)出现在根层级。最简单的可能结果是根层级的工作量主导了整个算法的运行时间(其他层级的影响只差一个常数因子)。如果这个最简单的结果成立,我们期望运行时间与根节点的工作量成正比,即
N^D。 - 情况3(子问题增殖更快):子问题增殖如此迅速,以至于超过了每个子问题工作量的节省。工作量随着递归层级增加而增加。这里最坏的情况将出现在叶子节点,那一层的工作量将比其他任何层级都多。同样,如果最简单的可能结果成立,也许叶子节点的工作量(在常数因子内)主导了算法的运行时间。由于叶子节点对应基本情况,每个叶子做常数工作量,因此我们期望运行时间在最简单的情况下与递归树中叶子节点的数量成正比。
本节总结 📝
在本节中,我们一起学*了:
- 递归树本质上分为三种类型:每层工作量相同的树、工作量随层级递减(根节点最坏)的树、以及工作量随层级递增(叶子节点最坏)的树。
- 正是子问题增殖速率
A与工作量缩减速率B^D之间的比值,决定了我们面对的是哪一种递归树。 - 我们基于直觉对三种情况下的运行时间做出了预测:对于情况1,我们相当确信是
N^D log N;对于情况2,我们希望是N^D;对于情况3,我们希望它与叶子节点数量成正比。
现在,让我们用主定理的正式陈述来检验这些直觉。在三种情况中,前两种与我们的直觉完全吻合:情况1是 Θ(N^D log N),情况2(根节点最坏)确实是 Θ(N^D)。然而,情况3仍然存在一个谜团:我们的直觉说它应该与叶子节点数量成正比,但定理给出的却是 Θ(N^{log_B A}) 这个有趣的公式。

在下一节视频中,我们将揭开这个联系的神秘面纱,并为这些断言提供一个正式的证明。
024:主定理证明 II 🔍
在本节课中,我们将完成主定理的证明。我们将回顾递归树的分析,并利用几何级数的知识,将之前视频中关于三种递归树情况的直觉转化为严格的数学证明。
递归树与工作量表达式回顾
上一节我们介绍了如何使用递归树来分析递归算法的工作量。我们聚焦于树的第 J 层,识别出该层的总工作量,然后对所有层求和,得到了一个相当复杂的表达式:
公式:
T(n) = c * n^d * Σ_{j=0}^{log_b(n)} (a / b^d)^j

我们为这个表达式赋予了语义,并意识到比值 a / b^d 是区分三种根本不同类型递归树的关键:
- 情况一:
a = b^d,每层工作量相同。 - 情况二:
a < b^d,每层工作量递减。 - 情况三:
a > b^d,每层工作量递增。
这为我们提供了主定理三种情况的直觉,甚至预测了可能的运行时间。接下来,我们需要将这种直觉转化为严谨的证明。
情况一:工作量恒定
首先,我们来看最简单的情况,即情况一。我们假设 a = b^d。
公式:
a = b^d
在这种情况下,子问题增殖的速率与每个子问题工作量减少的速率完美平衡。现在,检查表达式 (a / b^d)^j,当 a = b^d 时,这个比值等于 1。因此,对于所有 j,(a / b^d)^j 也等于 1。
于是,求和变得非常简单。求和结果就是 1 自加 log_b(n) + 1 次。所以,总和等于 log_b(n) + 1。这个结果将乘以与求和无关的 c * n^d 项。
总结: 当 a = b^d 时,表达式 T(n) 等于 c * n^d * (log_b(n) + 1)。用大 O 记号表示,就是 O(n^d * log n)。我们通常省略对数的底数,因为不同底数的对数只相差一个常数因子,可以被大 O 记号中的常数隐藏。
情况一的证明到此结束。这确实是最简单的情况。
几何级数预备知识
当 a 不等于 b^d 时(即 a < b^d 或 a > b^d),我们需要借助几何级数的知识。为此,我们进行一个简短的讨论。
考虑一个常数 r(r > 0 且 r ≠ 1)。假设我们求和 r 的幂,从 r^0 到 r^k。这个和有一个简洁的封闭形式公式:
公式:
Σ_{j=0}^{k} r^j = (r^{k+1} - 1) / (r - 1)
为了建立直觉,可以考虑两个典型值:
- 当
r = 2时,我们求和1 + 2 + 4 + 8 + ...。 - 当
r = 1/2时,我们求和1 + 1/2 + 1/4 + 1/8 + ...。
这个公式可以通过归纳法证明,我们将其留作练*。我们关注的是这个事实能为我们带来什么。
我们将用这个公式来形式化一个观点:在递归树中,如果工作量随层数增加,则叶子节点主导总运行时间;如果工作量随层数减少,则根节点主导运行时间。我们可以忽略递归树的其他层。

基于这个公式,我们得到两个重要推论:
- 当
r < 1时: 右边的表达式(r^{k+1} - 1) / (r - 1)可以被上界1 / (1 - r)所限定。关键在于,这是一个常数(与求和项数k无关)。这意味着,当我们对一系列r < 1的项求和时,第一项(1)占主导地位,无论我们加多少项,总和都不会超过某个常数。 - 当
r > 1时: 通过一点代数运算,我们可以将右边表达式上界为r^k乘以一个与k无关的常数。这意味着,整个和不会超过最大(即最后)一项的常数倍。在这个意义上,级数的最大项主导了整个和。
总结: 当我们对一个常数 r 的幂求和时,如果 r > 1,则最大的幂主导了和;如果 r < 1,则和只是一个常数。
情况二:工作量递减
现在,我们应用几何级数的知识来证明主定理的情况二。在情况二中,我们假设 a < b^d。
公式:
a < b^d
这意味着子问题增殖的速率被每个子问题工作量减少的速率所淹没。这是工作量随递归树层级递减的情况。我们的直觉是,在最简单的情况下,我们希望所有工作(至多差一个常数因子)都在根节点完成。
令比值 r = a / b^d。由于 a < b^d,所以 r < 1。我们的求和表达式 Σ (a / b^d)^j 正是对常数 r(r < 1)的幂求和。
根据上一节的结论,任何这样的和都被一个与求和项数无关的常数所限定。因此,表达式 T(n) 等于 c * n^d 乘以一个常数。在大 O 记号中,我们可以说 T(n) 的上界是 O(n^d)。
这精确地证实了我们的直觉:在这种工作量递减的递归树中,算法的总运行时间确实由根节点主导。总工作量仅仅是树第 0 层(根节点)工作量的常数倍。
情况三:工作量递增与叶子节点
最后,我们进入证明中最具挑战性的部分:情况三。在情况三中,我们假设 a > b^d。
公式:
a > b^d
从概念上讲,我们假设子问题增殖的速率超过了每个子问题工作量减少的速率。这是递归树中工作量随层级递增的情况,最多的工作在叶子节点完成。再次利用几何级数的事实,我们可以精确地实现我们的期望:实际上我们只需要关心叶子节点,可以忽略其他工作,最多损失一个常数因子。
同样,我们记比值 r = a / b^d。在这种情况下,r > 1。这个求和是对一系列 r > 1 的幂求和。
根据几何级数的结论,这样的和被求和中的最大(即最后)项所主导,它们被该项的一个常数倍所限定。因此,我们可以将表达式 T(n) 简化为以下形式(用大 O 记号表示,同时隐藏来自原递推式的常数 c 和来自几何级数结论的常数):
公式:
T(n) = O( n^d * (a / b^d)^{log_b(n)} )
现在,我们处理这个看似复杂的表达式。首先,关注 (1 / b^d)^{log_b(n)} 这一部分:
推导:
(1 / b^d)^{log_b(n)} = b^{-d * log_b(n)} = (b^{log_b(n)})^{-d} = n^{-d}
神奇的是,这个 n^{-d} 将与表达式前面的 n^d 相抵消。于是,我们只剩下:
公式:
T(n) = O( a^{log_b(n)} )
a^{log_b(n)} 是一个非常有意义的量。它描述了递归树中叶子节点的数量。回想一下,在情况三的直觉中,我们认为工作量可能由叶子节点的工作量主导,而叶子节点的工作量与叶子数量成正比。
为什么这是叶子数量?在递归树中,第 0 层有 1 个节点。每向下一层,节点数变为上一层的 a 倍。这个过程持续到我们到达叶子节点。输入规模从根节点的 n 开始,每次除以因子 b,直到变为 1。因此,叶子节点恰好位于第 log_b(n) 层。所以,叶子节点的数量就是分支因子 a 乘以我们实际乘以 a 的次数,即层数 log_b(n) 次,结果为 a^{log_b(n)}。
因此,我们在数学上以一种非常酷的方式证实了关于主定理情况三的直觉。我们证明了在情况三中,当 a > b^d 时,运行时间是 O(叶子节点数量),正如直觉所预测的那样。
最终形式与总结
然而,这留下了一个最后的疑问:回顾主定理的陈述,在情况三中,运行时间写的是 O(n^{log_b(a)}),而不是 O(a^{log_b(n)})。我们之前多次使用情况三的公式来评估高斯递归整数乘法算法、Strassen矩阵乘法算法等。
原因很简单:a^{log_b(n)} 和 n^{log_b(a)} 是完全相同的量。如果你不相信,可以对两边取以 b 为底的对数,你会发现两边相等。

公式:
a^{log_b(n)} = n^{log_b(a)}
你可能会问,为什么不在主定理中直接陈述运行时间是 O(a^{log_b(n)})(即递归树的叶子数量)这个更有概念意义的形式?原因是,虽然左边的表达式在概念上更有意义,但右边的形式 O(n^{log_b(a)}) 在应用时最为方便。回想我们之前用主定理评估算法运行时间的例子,当我们代入 a 和 b 的具体数值时,右边的形式超级方便。
无论如何,无论你选择将情况三的运行时间视为与树的叶子数量成正比,还是视为与 n^{log_b(a)} 成正比,证明都已经完成。这就是情况三,也是最后一个情况。至此,主定理证明完毕。
核心概念回顾
证明主定理是一项艰巨的工作,我们不期望有人能复述所有细节。但是,这个证明中有几个高层次的概念点值得长期记住:
- 通用分析: 我们从为递归算法绘制递归树开始,以通用方式逐层计算算法的工作量。这部分证明与
a、b、d之间的关系无关。 - 三种树类型: 我们识别出三种根本不同类型的递归树:每层工作量相同、随层递增、随层递减。记住这一点,你甚至可以回忆起三种情况的运行时间。
- 运行时间推导:
- 情况一(工作量恒定): 我们知道有对数数量的层级,在根节点做
n^d的工作,因此运行时间为O(n^d log n)。 - 情况二(工作量递减): 我们知道根节点占主导(至多差一个常数因子),可以忽略其他层,根节点做
n^d的工作,因此总运行时间为O(n^d)。 - 情况三(工作量递增): 叶子节点占主导。叶子数量为
a^{log_b(n)},等于n^{log_b(a)},这就是主定理情况三中运行时间的比例。
- 情况一(工作量恒定): 我们知道有对数数量的层级,在根节点做

本节课中,我们一起学*了如何将递归树的直觉分析,通过几何级数的工具,转化为对主定理三种情况的严格证明。我们明确了每种情况下主导工作量的部分(根节点、所有层、叶子节点),并最终得到了熟悉的主定理公式。
025:快速排序概述 🚀
在本节课中,我们将要学*著名的快速排序算法。快速排序因其高效、优雅且在实际应用和编程库中广泛使用而备受推崇。我们将概述其核心思想、关键子程序以及后续课程中将要深入探讨的细节。
排序问题回顾

上一节我们介绍了分治算法,本节中我们来看看快速排序如何解决经典的排序问题。排序问题的输入是一个包含 n 个数字的数组,这些数字以任意顺序排列。例如,输入数组可能如下所示:
[3, 8, 2, 5, 1, 4, 7, 6]

我们的目标是输出这些相同数字的一个版本,但按递增顺序排列。为了简化讨论,我们假设输入数组中没有重复元素。

核心子程序:分区 🎯
快速排序的核心思想在于一个称为“分区”的子程序。这个子程序围绕一个“枢轴”元素来重新排列数组。
分区过程如下:
- 选择枢轴:从数组中选取一个元素作为枢轴。目前,我们可以简单地选择数组的第一个元素。
- 重新排列:重新排列数组,使得所有小于枢轴的元素都位于其左侧,所有大于枢轴的元素都位于其右侧。
关键点:分区不要求小于或大于枢轴的两部分内部是有序的,它只进行“部分排序”,将元素分成两个“桶”。
示例:
对于输入数组 [3, 8, 2, 5, 1, 4, 7, 6],选择 3 作为枢轴。一个合法的分区结果可能是:
[2, 1, 3, 8, 5, 4, 7, 6]
- 左侧
[2, 1]均小于3。 - 右侧
[8, 5, 4, 7, 6]均大于3。 - 枢轴元素
3本身已经位于其在最终有序数组中的正确位置。

为何分区如此重要? 💡
分区不仅是迈向完全排序的一步,它还具备两个关键特性,使其成为快速排序高效的基础:
- 线性时间复杂度:分区可以在 O(n) 时间内完成,其中
n是数组大小。更重要的是,它的实现只需要进行元素交换,不需要分配额外的内存空间。 - 启用分治策略:分区后,原始数组被枢轴分割成两个更小的子问题(左侧小于枢轴的部分和右侧大于枢轴的部分)。我们只需递归地对这两个部分进行排序即可。

快速排序的高层描述 ⚙️
基于分区的思想,快速排序算法(由 Tony Hoare 于1961年左右发明)的高层描述非常简洁:
算法 QuickSort(A):
- 基准情况:如果数组
A的长度为 0 或 1,则它已经有序,直接返回。 - 选择枢轴:从数组
A中选择一个元素作为枢轴p。 - 分区:调用
Partition(A, p)子程序。结果是,p被移动到其正确位置,左侧是所有< p的元素(称为L),右侧是所有> p的元素(称为R)。 - 递归:递归调用
QuickSort(L)和QuickSort(R)。
与归并排序的区别:快速排序是一种“先处理,后递归”的分治算法。它在递归调用之前通过分区完成主要工作,递归调用后无需合并步骤。
伪代码描述如下:
function QuickSort(A, low, high):
if low >= high then return
pivot_index = ChoosePivot(A, low, high) // 选择枢轴
pivot_new_index = Partition(A, low, high, pivot_index) // 分区
QuickSort(A, low, pivot_new_index - 1) // 递归排序左侧
QuickSort(A, pivot_new_index + 1, high) // 递归排序右侧

后续内容预告 📚
在接下来的课程中,我们将深入探讨以下内容:
- 分区实现:详细讲解如何在 O(n) 时间内、仅通过交换完成分区操作。
- 正确性证明:形式化证明快速排序算法的正确性。
- 枢轴选择策略:探讨不同的枢轴选择方法(如选择第一个元素、随机选择等)及其对算法性能的影响。
- 随机化快速排序:引入通过随机选择枢轴来保证平均性能的版本。
- 数学分析:分三部分证明随机化快速排序的平均时间复杂度为 O(n log n),且隐藏常数很小。我们将使用分解原理、线性期望以及分析“元素对被比较的概率”等技巧。

本节课中我们一起学*了快速排序算法的核心思想。我们了解到,快速排序通过一个高效的分区子程序,将问题分解并递归解决,从而实现了优雅且高效的排序。其“原地”排序的特性(几乎不需要额外内存)使其在实践中极具竞争力。在接下来的课程中,我们将揭开其高效背后的实现细节与数学原理。
026:基于枢轴的分区 🎯

在本节课中,我们将深入学*快速排序算法的核心实现细节,特别是其关键的分区子程序。我们将详细探讨如何在线性时间内、仅使用常数级额外空间,围绕一个枢轴元素重新排列数组。
概述
快速排序算法的核心思想是围绕一个枢轴元素对输入数组进行分区。分区完成后,所有小于枢轴的元素都位于其左侧,所有大于枢轴的元素都位于其右侧。这样,枢轴元素就找到了其在最终排序数组中的正确位置。之后,算法只需递归地对枢轴的左侧和右侧子数组进行排序即可。

分区子程序的目标
上一节我们介绍了快速排序的基本思想,本节中我们来看看其核心——分区子程序的具体实现。
分区子程序的任务是:给定一个数组和一个枢轴元素,重新排列数组,使得:
- 所有小于枢轴的元素都位于枢轴左侧。
- 所有大于枢轴的元素都位于枢轴右侧。

例如,对于数组 [3, 8, 2, 5, 1, 4, 7, 6],若选择第一个元素 3 作为枢轴,一个合法的分区结果可能是 [2, 1, 3, 8, 5, 4, 7, 6]。注意,小于 3 的元素 [2, 1] 和大于 3 的元素 [8, 5, 4, 7, 6] 内部的顺序并不重要。
简单(但非原地)的分区方法
在深入原地分区算法之前,我们先看一个简单易懂但需要额外空间的方法,这有助于理解分区的本质。

如果允许使用 O(n) 的额外空间,实现线性时间的分区非常简单。以下是其步骤:
- 创建一个与原数组等长的新数组。
- 遍历原数组。
- 若当前元素小于枢轴,则将其放入新数组的左侧(从左向右填充)。
- 若当前元素大于枢轴,则将其放入新数组的右侧(从右向左填充)。
- 遍历完成后,将枢轴元素放入新数组中间剩余的空位。
这种方法直观有效,但它需要线性级的额外内存。接下来,我们将学*如何在不使用额外数组的情况下完成同样的任务。
原地分区算法的高层思想
现在,我们转向核心的原地分区算法。为了简化,我们假设枢轴是数组的第一个元素。如果枢轴在其他位置,只需一个常数时间的交换操作,将其与第一个元素交换即可。
算法的核心是进行一次线性扫描。在扫描过程中,我们将维护一个关键的不变性(Invariant),以确保已处理的部分始终是正确分区的。
以下是算法在任何时刻维护的数组状态示意图:
[ Pivot | < Pivot | > Pivot | Unseen ]
^ ^ ^
| | |
(L) (i) (j)

- Pivot: 枢轴元素,始终位于数组起始位置(直到最后一步)。
- < Pivot: 已扫描且小于枢轴的元素区域。
- > Pivot: 已扫描且大于枢轴的元素区域。
- Unseen: 尚未扫描的元素区域。
我们用两个指针来维护边界:
- 指针
j: 指向已扫描与未扫描区域的分界点。 - 指针
i: 指向小于枢轴区域与大于枢轴区域的分界点。
算法的目标就是在每次扫描一个新元素时,通过交换操作,维持上述分区结构。
通过示例理解算法
在给出伪代码之前,让我们通过一个具体的例子来走查这个算法。我们将对数组 A = [3, 8, 2, 5, 1, 4, 7, 6] 进行分区,枢轴 p = A[0] = 3。
初始化:
- 枢轴
p = 3。 i = 1,j = 1。此时“小于区”和“大于区”都为空。- 数组状态:
[3, 8, 2, 5, 1, 4, 7, 6] -
p i/j

第1步 (j=1, 元素 A[1]=8):
8 > 3,属于“大于区”。简单地将j右移即可。- 操作:
j++(现在j=2)。 - 数组状态不变:
[3, 8, 2, 5, 1, 4, 7, 6] -
p i j
第2步 (j=2, 元素 A[2]=2):
2 < 3,属于“小于区”。为了维持不变性,我们需要将这个新发现的“小元素”放到“小于区”的末尾。具体做法是:将A[j](2) 与A[i](8,当前“大于区”的第一个元素) 交换,然后同时将i和j右移。- 操作:
swap(A[i], A[j])->swap(8, 2);i++;j++。 - 数组状态变为:
[3, 2, 8, 5, 1, 4, 7, 6] -
p i j - “小于区”现在是
[2],“大于区”是[8]。
第3步 (j=3, 元素 A[3]=5):
5 > 3,属于“大于区”。简单地将j右移。- 操作:
j++(现在j=4)。 - 数组状态:
[3, 2, 8, 5, 1, 4, 7, 6] -
p i j
第4步 (j=4, 元素 A[4]=1):
1 < 3,属于“小于区”。再次交换A[j](1) 与A[i](8),然后移动i和j。- 操作:
swap(A[i], A[j])->swap(8, 1);i++;j++。 - 数组状态变为:
[3, 2, 1, 5, 8, 4, 7, 6] -
p i j - “小于区”现在是
[2, 1],“大于区”是[5, 8]。
后续步骤 (j=5,6,7, 元素 4,7,6):
- 剩余元素
4, 7, 6都> 3。对于每一个,我们都只需将j右移。 - 最终,当
j移出数组边界后,扫描结束。 - 最终数组状态:
[3, 2, 1, 5, 8, 4, 7, 6] -
p i (j 已越界)
最终调整:
- 此时,枢轴
3还在数组开头。我们需要将其与“小于区”的最后一个元素(即A[i-1],也就是1)交换,将其放入正确位置。 - 操作:
swap(A[L], A[i-1])->swap(3, 1)。 - 最终分区结果:
[1, 2, 3, 5, 8, 4, 7, 6] - 验证:
3左侧的[1, 2]都小于3;右侧的[5, 8, 4, 7, 6]都大于3。
分区算法的伪代码
通过上面的示例,算法的逻辑已经清晰。以下是该分区子程序的伪代码:
function Partition(A, left, right):
// 选择第一个元素作为枢轴
pivot = A[left]
// i 指向“小于区”的末尾的下一个位置(即“大于区”的开始)
i = left + 1
// j 遍历从 left+1 到 right 的所有元素
for j = left + 1 to right:
if A[j] < pivot:
// 如果当前元素小于枢轴,需要将其纳入“小于区”
swap(A[i], A[j])
i = i + 1 // “小于区”向右扩张一位
// 如果 A[j] >= pivot,则什么都不做,j++ 会自然将其留在“大于区”
// (隐含在 for 循环的 j++ 中)
// 将枢轴元素交换到其正确位置(即“小于区”的末尾)
swap(A[left], A[i - 1])
// 返回枢轴的最终位置,供快速排序递归使用
return i - 1

算法分析

现在我们已经有了完整的算法,让我们来分析其关键特性:
- 时间复杂度:算法只包含一个从
left+1到right的for循环。在每次迭代中,我们只进行常数次操作(比较和可能的交换)。因此,总时间复杂度为O(n),其中n = right - left + 1是待分区子数组的长度。 - 空间复杂度:算法只使用了几个额外的变量(
pivot,i,j),没有分配任何与输入规模相关的额外数组。因此,它是一个原地算法,空间复杂度为O(1)。 - 正确性:正确性的核心在于维护之前提到的循环不变式。我们可以通过归纳法证明,在
for循环的每一次迭代开始和结束时,以下性质都成立:A[left+1 ... i-1]中的所有元素都< pivot。A[i ... j-1]中的所有元素都>= pivot。A[j ... right]是尚未处理的元素。
当循环结束时(j = right+1),所有元素都已处理,且满足前两条性质。最后的swap操作将枢轴放置到i-1的位置,从而完成了整个分区。
总结

本节课中我们一起学*了快速排序算法的核心——基于枢轴的原地区分子程序。我们首先明确了分区的目标,然后从一个需要额外空间的简单方法入手,逐步推导出高效的原位算法。通过详细的示例走查和伪代码分析,我们理解了算法如何利用双指针 i 和 j 在一次线性扫描中完成数组的重排,并维护关键的不变性。该算法以 O(n) 的时间和 O(1) 的额外空间高效地完成任务,为快速排序的递归分治策略奠定了坚实的基础。下一节,我们将探讨如何选择枢轴,以及不同的选择策略如何影响快速排序的整体性能。
027:快速排序正确性证明(复*可选)📚
在本节课中,我们将学*如何使用数学归纳法来严格证明快速排序算法的正确性。我们将回顾归纳法的基本格式,并将其应用于快速排序,以证明无论选择何种枢轴元素,该算法都能正确地对任意长度的数组进行排序。
归纳法证明格式回顾 📝
上一节我们介绍了证明快速排序正确性的目标。本节中,我们来看看将用于证明的工具——数学归纳法。

归纳法通常用于证明一个关于所有正整数 n 的断言 P(n)。对于快速排序,我们的断言 P(n) 是:快速排序总能正确地对长度为 n 的数组进行排序。
一个归纳法证明包含两个部分:
- 基础情况:证明断言对于最小的
n(通常是n=1)成立。 - 归纳步骤:假设断言对所有小于
n的正整数k(即P(k)成立)都成立,然后证明在此假设下,断言对n本身(即P(n))也成立。
如果成功完成了这两个步骤,就证明了断言 P(n) 对所有正整数 n 都成立。
快速排序正确性证明 🧮
现在,让我们将归纳法框架应用于快速排序。
基础情况:n = 1
当数组长度为 1 时,证明是简单的。长度为 1 的数组本身就是有序的。快速排序在 n=1 时直接返回输入数组,不做任何操作,这确实返回了一个有序数组。因此,我们直接证明了 P(1) 成立。

归纳步骤:n >= 2
现在,我们进入证明的核心部分。我们固定一个任意大于等于 2 的整数 n,并假设归纳假设成立:快速排序对所有长度严格小于 n 的数组都是正确的。我们需要证明,在此假设下,快速排序对任意长度为 n 的数组 A 也是正确的。
以下是证明步骤:
- 选择枢轴与分区:快速排序首先任意选择一个枢轴元素
p(选择方式不影响正确性)。然后,算法围绕p对数组进行分区。分区结束后,数组被重新排列为:[小于 p 的元素],p,[大于 p 的元素]。枢轴p被放置在其最终排序后应在的正确位置。 - 定义子数组:设第一个部分(小于
p的元素)的长度为k1,第二个部分(大于p的元素)的长度为k2。关键点在于,由于枢轴p本身不包含在这两个部分中,因此k1和k2都严格小于n。 - 应用归纳假设:根据我们的归纳假设(
P(k)对所有k < n成立),快速排序对长度为k1和k2的子数组的递归调用将是正确的。即,P(k1)和P(k2)成立。 - 组合结果:
- 第一个递归调用正确排序了所有小于
p的元素。 - 枢轴
p已经位于其正确位置(大于左侧所有元素,小于右侧所有元素)。 - 第二个递归调用正确排序了所有大于
p的元素。
将这三部分按顺序拼接起来,就得到了输入数组A的一个正确排序版本。
- 第一个递归调用正确排序了所有小于
由于数组 A 是任意一个长度为 n 的数组,这便证明了断言 P(n) 成立。又因为 n 是任意大于等于 2 的整数,我们完成了归纳步骤的证明。
总结 ✨

本节课中,我们一起学*了如何使用数学归纳法来严格证明快速排序算法的正确性。我们首先回顾了归纳法的标准格式,然后将其应用于快速排序:
- 在基础情况(
n=1)中,我们直接验证了算法的正确性。 - 在归纳步骤中,我们假设算法对更小的数组正确,然后通过分析快速排序的分区过程和递归调用,证明了在此假设下,算法对大小为
n的数组也必然正确。
这个证明的关键在于,无论枢轴如何选择,分区操作都能将枢轴置于其最终位置,并产生两个更小的子问题,从而允许我们应用归纳假设。这确保了快速排序在任何输入上都能输出正确的排序结果。
028:选择优质枢轴

概述
在本节课中,我们将要学*快速排序算法中一个至关重要的环节:如何选择枢轴元素。枢轴的选择直接决定了算法的运行效率,我们将探讨不同选择策略带来的影响,并最终理解为什么随机选择枢轴是一种强大且实用的方法。

快速排序算法回顾
上一节我们介绍了快速排序算法的整体框架。快速排序首先调用两个子程序,然后进行两次递归调用。
以下是快速排序的高层描述:
def quicksort(arr):
if len(arr) <= 1:
return arr
pivot = choose_pivot(arr) # 选择枢轴
left, right = partition(arr, pivot) # 分区
return quicksort(left) + [pivot] + quicksort(right) # 递归排序

第一个子程序 choose_pivot 负责从输入数组的 n 个元素中选择一个作为枢轴元素。第二个子程序 partition 负责根据枢轴重新排列数组元素,使得:
- 枢轴
P被放置在其最终的正确位置。 - 所有小于
P的元素(顺序任意)位于其左侧。 - 所有大于
P的元素(顺序任意)位于其右侧。
完成分区后,算法只需递归地对左侧和右侧的子数组进行排序,无需额外的合并步骤。我们之前已经了解到,partition 子程序可以在线性时间内完成,并且是原地操作,几乎不需要额外的存储空间。
枢轴质量对性能的影响
现在,我们来讨论快速排序算法的运行时间。这正是枢轴选择变得至关重要的地方。
快速排序的运行时间高度依赖于枢轴的选择质量。枢轴的质量由其划分出的两个子问题的平衡程度决定。
- 高质量枢轴:能将数组大致均等地分成两个子问题。
- 低质量枢轴:会导致划分出的子问题严重不平衡。
为了理解枢轴质量的含义及其影响,让我们通过几个小测验来探索。
最坏情况分析
第一个小测验旨在探索快速排序算法的一种最坏情况执行场景,即枢轴选择极不适用于特定输入数组时会发生什么。
具体来说,假设我们使用最朴素的 choose_pivot 实现(如在分区视频中讨论的),即总是选择子数组的第一个元素作为枢轴。同时,假设快速排序的输入数组已经是排好序的(例如,包含数字 1 到 8 的数组 [1,2,3,4,5,6,7,8])。
问题:在此情况下,快速排序在已排序数组上的运行时间是多少?

答案:是 平方时间 O(n²)。
对于排序算法而言,平方时间的性能是较差的,因为我们已经有了运行时间为 O(n log n) 的归并排序,它要快得多。如果我们满足于平方时间的性能,直接使用插入排序即可。
原因分析:
让我们思考在这个不幸的情况下会发生什么。对于已排序数组 [1, 2, ..., n]:
- 第一层递归:选择第一个元素
1作为枢轴。调用partition后,由于没有元素小于1,左侧子数组为空,右侧子数组包含[2, 3, ..., n](仍为有序)。 - 第二层递归:在右侧子数组
[2, 3, ..., n]上,选择第一个元素2作为枢轴。分区后,左侧为空,右侧为[3, 4, ..., n]。 - 后续递归:此过程将持续下去,每次递归调用都处理一个长度仅比上一次少 1 的子数组(
n-1,n-2,n-3, ...),直到处理最后一个元素。
运行时间计算:
在每一层递归中,partition 子程序都需要查看其输入数组中的每个元素。因此,总运行时间至少是各级递归调用中数组长度之和:
n + (n-1) + (n-2) + ... + 1
这个和是 Θ(n²)。一个简单的理解方式是:该和式的前 n/2 项每项都至少是 n/2,因此总和至少是 (n/2) * (n/2) = n²/4。显然,总和也至多是 n²。因此,在这种糟糕的输入和枢轴选择下,快速排序的运行时间是平方级的。
最好情况分析
理解了最坏情况性能后,我们再来讨论其最好情况运行时间。我们通常不单纯为了最好情况而分析算法,但这样做有助于:
- 更好地理解算法工作原理。
- 为后续的平均情况分析设定一个目标(平均情况性能不可能优于最好情况)。
那么,什么是最好的情况?我们能期望的最高质量枢轴是什么?理想情况下,我们希望枢轴能将数组完美地分成两个大小约为 n/2 的子问题。能够实现这种完美均等划分的元素称为数组的中位数(median),即恰好有一半元素小于它,一半元素大于它。
问题:假设在每一次递归调用中,我们都神奇地选到了当前子数组的中位数作为枢轴,从而每次都得到完美的 50-50 划分。在这种情况下,算法的运行时间是多少?

答案:是 O(n log n)。
原因分析:
在这种情况下,支配快速排序运行时间的递归式与支配归并排序运行时间的递归式完全匹配,而我们已知后者的解是 O(n log n)。
具体递归式如下:
设 T(n) 为在长度为 n 的数组上运行快速排序的时间。
- 递归调用部分:由于枢轴是中位数,我们产生两个递归调用,每个处理的子问题规模最多为 n/2。因此这部分时间是
2 * T(n/2)。 - 当前层工作:我们需要执行
choose_pivot和partition。假设choose_pivot只需线性时间,而我们已经知道partition也只需线性时间。因此这部分时间是 O(n)。
综合起来,递归式为:T(n) ≤ 2T(n/2) + O(n)
根据主定理或与归并排序相同的论证,可得 T(n) = O(n log n)。实际上,由于 partition 确实需要 Θ(n) 时间,我们可以将结果加强为 T(n) = Θ(n log n)。
这个小测验的要点是:即使在最好情况下,即使我们在整个快速排序过程中都能神奇地获得完美枢轴,我们能期望的最好上界也是 O(n log n),不会比这更好了。
随机化算法:随机选择枢轴
前面的测验指出了一个关于快速排序实现的核心问题:我们究竟该如何选择枢轴? 我们现在知道枢轴对运行时间有巨大影响,可能差至 O(n²),也可能好至 O(n log n)。我们当然希望达到 O(n log n) 的性能。
关键思想是:随机选择枢轴。这是我们将要看到的第一个“杀手级”随机化算法应用。随机化算法允许在代码中引入随机性(如抛硬币),从而在平均情况下获得良好的性能。
具体来说,每次递归调用快速排序时,面对一个长度为 k 的子数组,我们从 k 个候选枢轴元素中均匀随机地选择一个(每个元素被选中的概率为 1/k)。每次递归调用都会做出一个新的随机选择。
随机化是算法设计中的一个重要突破。使用随机性可以使算法更优雅、更简单、更容易编码、更快,或者解决一些原本难以解决的问题。快速排序是展示其威力的第一个绝佳例子。
直觉理解:为什么随机枢轴有效?
在进入严谨的数学分析之前,让我们先建立一些直觉,理解为什么在快速排序内部引入随机性可能是个好主意。
第一层直觉:不必完美,足够好即可
在最好情况分析中,我们假设总是选中位数。但实际上,要获得 O(n log n) 的运行时间,并不需要每次都完美命中位数。只要枢轴能提供*似平衡的划分,我们就已经足够好了。

具体来说,假设我们总是能选到一个保证 25-75 或更好 划分的枢轴(即两个递归调用处理的子数组大小都不超过原数组的 75%)。可以证明(例如通过递归树论证),在这种情况下,快速排序的运行时间仍然是 O(n log n)。因此,我们将能产生 25-75 或更好划分的枢轴定义为“足够好”的枢轴。
第二层直觉:获得“足够好”的枢轴并不难
那么,随机选到一个“足够好”的枢轴的概率有多大呢?考虑一个包含数字 1 到 100 的数组。
- 哪些元素能给出 25-75 或更好的划分?答案是任何介于 26 到 75 之间(包含)的元素。
- 如果选择 ≥26 的元素,左侧子数组至少包含元素 1-25(≥25%)。
- 如果选择 ≤75 的元素,右侧子数组至少包含元素 76-100(≥25%)。
- 在 100 个元素中,有 50 个元素(26到75)满足条件。因此,随机选择一个元素,它有 50% 的概率是“足够好”的枢轴。
高层面的希望是:只要足够频繁地(例如一半的时间)获得这些“足够好”的划分,平均运行时间就有望达到 O(n log n)。
定理陈述与总结
上述直觉虽然令人鼓舞,但我们需要严谨的数学分析来确认随机化快速排序确实有效。这引出了算法研究中一个反复出现的主题:当你尝试实现一个解决方案并产生一个新想法时,数学分析能从根本上解释这个想法的优劣。
在接下来的视频序列中,我们将证明关于快速排序的以下定理:
定理:对于任意长度为 n 的输入数组(不对数据做任何假设),采用随机选择枢轴实现的快速排序的平均运行时间为 O(n log n)(实际上是 Θ(n log n))。
需要明确的是,这是一个关于输入的最坏情况保证。定理开头强调“对于任意输入数组”,意味着我们绝对没有对数据做任何假设。这是一个完全通用的排序子程序。
定理中出现的“平均”一词,不是对输入数据分布的假设(我们绝不假设输入数组是随机的)。这里的“平均”完全来源于算法内部由我们代码控制的随机性。
随机算法有一个有趣的性质:即使在相同的输入上反复运行,由于内部随机性,每次执行都可能不同,运行时间也会波动。我们的测验表明,快速排序在给定输入上的运行时间可以在 O(n log n) 到 O(n²) 之间波动。而这个定理告诉我们,对于每一个可能的输入数组,虽然运行时间确实在波动,但平均来看,它被 O(n log n) 所主导,几乎和最好情况一样好。这就是随机化快速排序的惊人之处:偶尔出现的 O(n²) 情况无关紧要,平均行为总是像 O(n log n) 一样优秀。

本节课总结
本节课中,我们一起学*了快速排序算法中枢轴选择的关键作用。
- 我们回顾了快速排序的框架,理解了
partition子程序的工作方式。 - 我们通过分析发现,枢轴的质量(划分的平衡性)直接决定了算法的运行时间,最坏情况(如已排序数组+选择首元素为枢轴)会导致 O(n²) 的性能,而最好情况(总是选中位数)可达到 O(n log n)。
- 我们引入了随机化算法的概念,提出通过随机选择枢轴来应对未知的输入。
- 我们建立了直觉:要获得好的性能,枢轴不必完美,只需经常能提供*似平衡的划分即可,而随机选择有很大概率做到这一点。
- 最后,我们陈述了将要证明的核心定理:对于任意输入,随机化快速排序的平均运行时间为 O(n log n),这是一个强大的最坏情况输入下的平均性能保证。
接下来的课程将深入进行概率论回顾和该定理的严谨数学分析。
029:分解原理 🧩
在本节课中,我们将对快速排序算法的随机化实现进行数学分析,并证明其平均运行时间为 O(n log n)。这是课程中首次分析随机化算法,因此我们将首次引入概率论知识。
概述
我们将分析快速排序算法的随机化版本,其中每个递归子调用都均匀随机地选择一个主元。我们将证明,对于任意长度为 n 的输入数组,仅考虑算法内部随机性(而非输入数据)的平均运行时间为 O(n log n)。这意味着无论输入如何,快速排序的典型行为都更接*其最佳情况(n log n),而非最坏情况(n²)。
预备知识
在开始分析前,你需要了解离散概率论的基础知识:
- 样本空间:所有可能随机结果(即所有可能的随机主元选择序列)的集合。
- 随机变量:定义在样本空间上、取实数值的函数。
- 期望值:随机变量的平均值。
- 期望的线性性质:这是分析快速排序所需的关键性质。
如果你对这些概念不熟悉或需要复*,建议先观看课程网站上的概率论复*视频,或阅读 Eric Lehman 和 Tom Leighton 的《计算机科学数学》在线讲义。
建立分析框架

首先,我们固定一个任意的长度为 n 的输入数组 A,并以此作为快速排序的输入。整个分析将围绕这个固定的数组展开。
样本空间与随机变量
- 样本空间 Ω:所有可能的随机主元选择序列 σ 的集合。
- 核心随机变量 C:对于给定的主元序列 σ,定义 C(σ) 为快速排序算法执行的比较次数。这里的“比较”特指对输入数组中两个不同元素(例如,比较第三个元素和第七个元素的大小)的操作。
快速排序的运行时间主要由比较操作决定。更正式地说,存在一个常数 c,使得对于任何主元序列 σ,快速排序执行的总操作数 RT(σ) 满足:
RT(σ) ≤ c * C(σ)
因此,要证明平均运行时间为 O(n log n),只需证明平均比较次数为 O(n log n)。即,我们需要证明:
E[C] = O(n log n)
分解原理的引入
随机变量 C 本身很复杂,难以直接分析。我们将采用一种称为分解原理的方法:
- 将我们关心的复杂随机变量 C 分解为一系列更简单的随机变量之和。
- 这些简单随机变量本身可能不直接重要,但易于分析。
- 利用期望的线性性质,将这些简单随机变量的分析结果组合起来,从而理解 C 的期望值。
这种方法不仅适用于快速排序,也适用于分析许多其他随机化算法。
定义基础构件:指示器随机变量
为了应用分解原理,我们需要定义一组简单的随机变量作为基础构件。
首先,引入一些符号:
- 用 Z_i 表示输入数组 A 中第 i 小的元素(即第 i 个顺序统计量)。注意,Z_i 不一定是原始数组中第 i 个位置的元素,而是排序后最终会出现在第 i 个位置的元素。

现在,我们定义关键的简单随机变量族:
对于给定的主元序列 σ,以及满足 1 ≤ i < j ≤ n 的索引 i 和 j,定义 X_ij 为:
X_ij(σ) = Z_i 和 Z_j 在快速排序执行过程中被比较的次数
重要性质:对于任意一对元素 (Z_i, Z_j),它们在整个快速排序过程中最多被比较一次,也可能一次都不比较。因此,X_ij 是一个指示器随机变量,其取值只能是 0 或 1。它指示了“Z_i 和 Z_j 是否被比较”这一事件(1 表示发生,0 表示未发生)。
原因:在快速排序中,比较只发生在 partition 子程序中,且每次比较都涉及当前递归调用的主元。如果 Z_i 和 Z_j 第一次被比较,那么其中之一必定是当时的主元。该主元在此次划分后,将不会进入任何后续的递归调用,因此 Z_i 和 Z_j 再无机会被比较。
应用分解原理
现在,我们将复杂的随机变量 C 用简单的指示器随机变量 X_ij 表示出来。

由于每一次比较都恰好涉及一对元素 (Z_i, Z_j)(其中 i < j),因此总的比较次数 C 可以表示为所有可能元素对的比较指示器之和:
C = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} X_ij
这个等式对任何主元序列 σ 都成立。
接下来,我们应用期望的线性性质:
E[C] = E[ Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} X_ij ] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} E[X_ij]
期望的线性性质非常强大,即使这些 X_ij 随机变量之间并不独立,该性质依然成立。
由于 X_ij 是指示器随机变量(取值 0 或 1),其期望值恰好等于事件“X_ij = 1”发生的概率:
E[X_ij] = 0 * Pr(X_ij = 0) + 1 * Pr(X_ij = 1) = Pr(X_ij = 1)

而 Pr(X_ij = 1) 正是元素 Z_i 和 Z_j 在快速排序过程中被比较的概率。
将上述结果结合起来,我们得到核心表达式(记为 (*)):
E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} Pr( Z_i 与 Z_j 被比较 )
本节总结与后续计划
在本节中,我们为快速排序的平均运行时间分析建立了框架:
- 我们明确了分析目标:证明平均比较次数
E[C] = O(n log n)。 - 我们引入了分解原理,将复杂的比较次数随机变量 C 分解为一系列简单的指示器随机变量 X_ij 之和。
- 利用期望的线性性质,我们将问题转化为计算所有元素对 (Z_i, Z_j) 被比较的概率之和。
至此,分析的第一部分(分解)已经完成。我们得到了一个清晰的路线图:要证明 E[C] = O(n log n),现在只需要计算概率 Pr( Z_i 与 Z_j 被比较 ),然后将其代入双重求和式 (*) 并进行计算。

在下一节中,我们将深入分析,精确计算出任意一对元素 Z_i 和 Z_j 在快速排序中被比较的概率。这将是完成证明的关键一步。
030:关键洞见 🔍
在本节课中,我们将深入分析随机化快速排序的平均运行时间,并证明其为 O(n log n)。我们将聚焦于一个核心问题:对于输入数组中的任意一对元素,它们在算法执行过程中被比较的概率是多少?通过精确计算这个概率,我们可以最终推导出平均比较次数的上界。
上一节我们引入了随机变量,并将总比较次数的期望表示为一个关于所有元素对的双重求和。本节中,我们来看看这个求和式中每一项——即一对特定元素被比较的概率——如何计算。
核心洞见:何时进行比较?
为了分析元素 Zᵢ(第 i 小的元素)和 Zⱼ(第 j 小的元素,其中 i < j)被比较的概率,我们需要理解快速排序的执行过程。


考虑从 Zᵢ 到 Zⱼ(包含两端)这 J - I + 1 个元素组成的集合。在算法递归的早期阶段,只要选择的枢轴(pivot)不来自这个集合,那么这个集合中的所有元素就会被一起传递到同一个递归调用中。这是因为枢轴要么比它们都小,要么比它们都大,无法将它们分开。
这种情况会持续下去,直到第一次从这个集合中选择一个元素作为枢轴。这个“第一次选择”的时刻至关重要,它决定了 Zᵢ 和 Zⱼ 的命运。
以下是两种可能的情况:
-
情况一:枢轴是 Zᵢ 或 Zⱼ
- 如果第一次从该集合中选出的枢轴恰好是 Zᵢ 或 Zⱼ,那么在围绕该枢轴进行划分(Partition)时,枢轴会与子数组中的所有其他元素进行比较。
- 因此,Zᵢ 和 Zⱼ 必定会在这次划分中被比较。
-
情况二:枢轴是 Zᵢ₊₁ 到 Zⱼ₋₁ 中的某个元素
- 如果第一次选出的枢轴是介于 Zᵢ 和 Zⱼ 之间的某个元素(例如 Zₖ, i < k < j),那么情况则相反。
- 划分时不会比较:在划分子程序中,只有枢轴会与其他元素比较。既然 Zᵢ 和 Zⱼ 都不是枢轴,它们此刻不会被比较。
- 未来也不会比较:由于这个枢轴值介于 Zᵢ 和 Zⱼ 之间,划分操作会将 Zᵢ 放入左子数组,将 Zⱼ 放入右子数组。在后续的所有递归调用中,它们将永远分离,再无比较的机会。
因此,Zᵢ 和 Zⱼ 被比较的充要条件是:在集合 {Zᵢ, Zᵢ₊₁, ..., Zⱼ} 中,Zᵢ 或 Zⱼ 在任何介于它们之间的元素之前被选为枢轴。

计算精确概率
我们的快速排序实现总是从当前子数组中均匀随机地选择枢轴。在第一次从上述集合中选枢轴时,集合中的每一个元素被选中的机会均等。

集合中共有 J - I + 1 个元素。
- 导致比较(情况一)的有利结果有 2 个(即选中 Zᵢ 或 Zⱼ)。
- 导致不比较(情况二)的结果有
J - I - 1个(即选中任何一个中间元素)。

因此,Zᵢ 和 Zⱼ 被比较的概率就是有利结果数与总可能结果数之比:
P(Zᵢ 与 Zⱼ 被比较) = 2 / (J - I + 1)
这个简洁的公式就是快速排序分析中的关键结论。它表明,两个元素被比较的概率仅取决于它们在排序顺序中的距离,而与它们在初始输入数组中的具体位置无关。
代入期望公式

回顾上一节,我们得到总比较次数 C 的期望为:
E[C] = Σᵢ Σⱼ>ᵢ P(Zᵢ 与 Zⱼ 被比较)

现在,我们可以将刚刚推导出的精确概率公式代入:

E[C] = Σᵢ Σⱼ>ᵢ [ 2 / (j - i + 1) ]
这个表达式(我们称之为 表达式 ★)就是我们需要评估的双重求和。它看起来复杂,但通过一些代数技巧,我们可以证明它的值是 O(n log n)。
本节课中我们一起学*了快速排序分析的核心洞见:我们精确地刻画了任意一对元素在算法中被比较的条件,并推导出了其概率的简洁公式 2/(j-i+1)。这为最终证明随机化快速排序的平均时间复杂度为 O(n log n) 奠定了坚实的基础。下一节,我们将通过数学计算来完成对这个求和表达式 ★ 的评估。
031:最终计算 📊

在本节课中,我们将完成对随机化快速排序算法的平均运行时间分析。我们将整合前几节的结果,通过一个巧妙的数学技巧,最终证明其平均运行时间为 O(n log n)。
回顾与目标
上一节我们介绍了如何将快速排序的总比较次数 C 分解为一系列指示随机变量 X_ij 的和,并精确计算了任意一对元素被比较的概率。本节中,我们来看看如何将这些结果结合起来,完成最终的计算。
我们证明的目标是:对于长度为 n 的任意输入数组,随机化快速排序(每次随机均匀选择枢轴元素)的平均运行时间是 O(n log n)。
精确的期望表达式
基于之前的分析,我们得到了一个完全精确的表达式,用于描述快速排序的平均比较次数 E[C]:
E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} 2 / (j - i + 1)
这个双重求和精确地计算了所有可能的元素对 (i, j) 被比较的概率(即 2/(j-i+1))之和。到目前为止,我们的推导没有任何*似。
从双重求到单重求和
我们的目标是证明 E[C] = O(n log n)。直接观察双重求和,其项数高达 O(n²) 量级,但每一项的值很小。为了得到一个紧致的上界,我们需要更巧妙地处理这个求和。
思路是固定外层求和中的索引 i,然后分析内层求和的最大可能值。
- 对于固定的 i,内层求和为:Σ_{j=i+1}^{n} 1 / (j - i + 1)
- 当 j 从 i+1 增加到 n 时,分母 (j - i + 1) 从 2 增加到 (n - i + 1)。因此,内层求和是形如 1/2 + 1/3 + 1/4 + ... + 1/(n-i+1) 的调和数部分和。
- 在所有可能的 i 中,当 i=1 时,这个内层求和达到最大值:1/2 + 1/3 + ... + 1/n。
因此,我们可以对原始表达式 E[C] 进行放缩(上界估计):
E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} 2 / (j - i + 1) ≤ Σ_{i=1}^{n} 2 * [Σ_{k=2}^{n} 1/k]
这里,我们做了两处宽松处理以简化计算:
- 将外层求和上限从 n-1 放宽到 n。
- 用最大的内层和(即 Σ_{k=2}^{n} 1/k)来统一上界所有内层和。
于是,问题简化为证明:
E[C] ≤ 2n * [Σ_{k=2}^{n} 1/k]
并且关键是要证明这个调和和 Σ_{k=2}^{n} 1/k 的大小仅为 O(log n)。
证明调和和为 O(log n)
以下是证明调和数 H_n = Σ_{k=1}^{n} 1/k 的增长速度约为 log n 的经典方法。
我们可以通过几何图形来直观理解并证明其上界。
考虑函数 f(x) = 1/x 在区间 [1, n] 上的积分。这个积分代表了曲线下的面积。
现在,观察从 k=2 到 n 的和 Σ 1/k。每一项 1/k 可以看作是一个宽度为 1、高度为 1/k 的矩形的面积(该矩形的x轴范围是 [k-1, k])。
将这些矩形从左到右排列,你会发现函数 f(x) = 1/x 的曲线恰好经过每个矩形右上角的顶点。由于曲线是凸的,在区间 [k-1, k] 上,曲线 f(x) 始终位于矩形顶部之下。因此,从 x=1 到 x=n,曲线下的面积 ∫_{1}^{n} (1/x) dx 严格大于从 k=2 开始的这些矩形面积之和。

用数学公式表达:
Σ_{k=2}^{n} 1/k ≤ ∫_{1}^{n} (1/x) dx
计算这个积分:
∫_{1}^{n} (1/x) dx = ln(x) |_{1}^{n} = ln(n) - ln(1) = ln(n)
因此,我们得到:
Σ_{k=2}^{n} 1/k ≤ ln(n)
这就证明了调和和的上界是自然对数 ln(n),即 O(log n)。
完成定理证明
将上述结果代入我们对 E[C] 的上界估计中:
E[C] ≤ 2n * [Σ_{k=2}^{n} 1/k] ≤ 2n * ln(n)
所以,随机化快速排序的期望比较次数 E[C] = O(n log n)。

由于快速排序的(平均)运行时间主要由比较操作主导,并且其实现是原地进行的(只需常数额外空间),因此我们可以得出结论:随机化快速排序的期望运行时间也是 O(n log n)。
总结
本节课中我们一起学*了快速排序算法平均情况分析的收官步骤。
- 我们首先回顾了之前得到的精确期望表达式。
- 接着,我们通过将双重求和放缩为单重求和,简化了问题。
- 然后,我们利用积分作为上界,巧妙地证明了关键调和和 Σ 1/k 为 O(log n)。
- 最终,我们整合所有步骤,严格证明了随机化快速排序的平均运行时间为 O(n log n),并且常数因子较小(约为 2)。这从数学上完整解释了为何快速排序在实践中如此高效。
032:核心概念与期望线性性 🎲
在本节课中,我们将回顾概率论的核心概念,这些概念对于理解快速排序的平均时间复杂度分析、图的最小割随机算法以及哈希表性能分析至关重要。我们将从样本空间和事件开始,逐步介绍随机变量、期望以及极其重要的期望线性性。课程最后将通过一个负载均衡的例子,将这些概念串联起来。
样本空间

样本空间是分析随机过程的基础,它包含了所有可能发生的结果的集合。我们用符号 Ω 表示样本空间。在算法设计中,我们通常可以假设 Ω 是一个有限集,这使得我们处理的是离散概率,比一般概率论更简单。
除了定义所有可能的结果,我们还需要定义每个结果发生的概率。每个结果的概率必须是非负的,并且所有结果的概率之和必须为 1,这确保了恰好有一件事情会发生。
为了具体说明这些抽象概念,我们将使用两个简单的例子。

例子一:掷两个六面骰子
- 样本空间 Ω 包含 36 种可能的骰子组合结果。
- 假设骰子是均匀的,每个结果出现的概率相等,均为 1/36。
例子二:快速排序的随机主元选择
- 考虑快速排序最外层调用中随机选择主元的过程。假设数组长度为 n。
- 样本空间 Ω 包含 n 种可能的选择,对应数组的 n 个索引。
- 根据算法构造,每个索引被选为主元的概率相等,均为 1/n。
事件

事件是样本空间 Ω 的一个子集,即一组可能发生的事情。事件的概率就是该事件包含的所有结果的概率之和。

以下两个小测验将帮助你练*这些概念,并计算我们两个例子中事件的概率。
测验一:两个骰子之和为 7
考虑掷两个骰子,事件“两个骰子点数之和等于 7”的概率是多少?
正确答案是 1/6。原因如下:
该事件包含的具体结果是 (1,6), (2,5), (3,4), (4,3), (5,2), (6,1),共 6 种。每个结果的概率是 1/36,因此该事件的概率是 6 * (1/36) = 1/6。
测验二:快速排序中获得良好分割的概率
在快速排序最外层调用中,随机选择一个主元。我们希望获得一个“良好”的分割,即两个子数组的大小都至少是原数组的 25%(即 25-75 分割或更好)。获得这种良好分割的概率是多少?
正确答案是 50%。原因如下:
当主元选自数组中间 50% 的元素时(即排除最小的 25% 和最大的 25%),就能得到 25-75 或更好的分割。因此,有利的主元选择占总选择数 n 的一半,概率为 1/2。

随机变量
随机变量本质上是对随机结果进行度量的统计量。形式上,它是定义在样本空间 Ω 上的实值函数。给定一个随机结果,它会输出一个数值。
在算法设计中,我们最常关心的随机变量是随机算法的运行时间。例如,快速排序算法的运行时间就是一个随机变量,它取决于算法中所有随机选择(如主元选择)的结果。

以下是我们在两个例子中定义的随机变量:

例子一:两个骰子之和
随机变量 X 定义为两个骰子点数的和。对于任意一个骰子组合结果,X 输出一个介于 2 到 12 之间的整数。

例子二:快速排序递归调用的大小
随机变量 Y 定义为传递给第一个递归调用的子数组的大小,等价于输入数组中小于随机主元的元素个数。Y 的取值范围是从 0(选到最小值)到 n-1(选到最大值)。
期望
随机变量的期望就是它的平均值,自然地按各种可能结果的概率进行加权。对于随机变量 X,其期望记作 E[X]。
数学上,期望定义为对所有可能结果 i 求和:E[X] = Σ_i (X(i) * P(i)),其中 X(i) 是结果 i 发生时 X 的值,P(i) 是结果 i 的概率。
以下两个小测验要求你计算上一节定义的两个随机变量的期望。

测验三:两个骰子之和的期望
随机变量“两个骰子点数之和”的期望(平均值)是多少?
正确答案是 7。有多种方法可以计算,最便捷的是使用我们即将介绍的期望线性性。


测验四:快速排序递归调用大小的期望
在快速排序最外层调用中,传递给第一个递归调用的子数组大小的期望值是多少?或者说,平均有多少个元素小于随机选择的主元?
正确答案是 (n-1)/2,大致是元素个数的一半。可以通过对称性理解:左右两个递归调用的期望大小应该相等,它们总共包含 n-1 个元素,因此每个的期望大小是 (n-1)/2。也可以直接根据期望定义计算:E[Y] = (0 + 1 + 2 + ... + (n-1)) / n = (n-1)/2。

期望线性性
期望线性性是一个极其重要且常用的性质,在分析随机算法和随机过程时无处不在。它的内容很简单:
假设有一组定义在同一样本空间上的随机变量 X1, X2, ..., Xn。那么,这些随机变量和的期望,等于它们各自期望的和。用公式表示为:
E[X1 + X2 + ... + Xn] = E[X1] + E[X2] + ... + E[Xn]

期望线性性之所以如此强大,是因为它总是成立,无论这些随机变量之间是否独立。这一点与乘积的期望不同,乘积的期望通常只在随机变量独立时才等于期望的乘积。
简单示例:两个骰子之和的期望(使用线性性)
令 X1 为第一个骰子的点数,X2 为第二个骰子的点数。单个骰子的期望是 (1+2+3+4+5+6)/6 = 3.5。根据期望线性性,两个骰子点数之和的期望为 E[X1 + X2] = E[X1] + E[X2] = 3.5 + 3.5 = 7。这比枚举 36 种结果要简单得多。
证明
期望线性性的证明非常直接,本质上是交换求和顺序。根据期望定义,右边是 Σ_j ( Σ_i (Xj(i) * P(i)) )。交换求和顺序得到 Σ_i ( P(i) * Σ_j Xj(i) ),而 Σ_j Xj(i) 正是随机变量和在结果 i 时的值,因此整个表达式等于 E[Σ_j Xj],即左边。所以,线性性本质上源于加法运算与求平均运算的可交换性。
应用示例:负载均衡

现在,我们通过一个负载均衡的例子,将本节学到的所有概念串联起来。

问题描述
假设有 n 个计算进程需要分配到 n 台服务器上。我们采用一种极其简单(“懒惰”)的策略:将每个进程独立地、随机地分配到任意一台服务器,每台服务器被选中的概率均为 1/n。问题是:这种随机分配策略下,单台服务器的平均负载(即期望的进程数量)是多少?

解决方案
- 定义样本空间:每个进程有 n 种分配选择,因此总共有 n^n 种可能的分配结果。由于每个进程的分配是均匀随机的,所以每个结果出现的概率相等,均为 1/(n^n)。

-
定义随机变量:我们关心单台服务器的负载。由于服务器对称,我们只需关注第一台服务器。定义随机变量 Y 为分配给第一台服务器的进程数量。
-
利用期望线性性简化:直接计算 E[Y] 需要枚举 n^n 种结果,这不可行。我们引入指示随机变量。对于每个进程 j (1 ≤ j ≤ n),定义:
Xj = 1,如果进程 j 被分配到第一台服务器;否则Xj = 0。
显然,Y = X1 + X2 + ... + Xn。 -
计算单个指示变量的期望:对于任意 j,
E[Xj] = 0 * P(Xj=0) + 1 * P(Xj=1) = P(Xj=1)。根据我们的随机分配策略,进程 j 被分配到第一台服务器的概率是 1/n。因此,E[Xj] = 1/n。

- 应用期望线性性:
E[Y] = E[X1 + X2 + ... + Xn] = E[X1] + E[X2] + ... + E[Xn] = n * (1/n) = 1。
结论
尽管分配策略非常简单随机,但平均来看,每台服务器只承载 1 个进程。这体现了随机化在算法设计中的威力:通过简单的随机选择,往往能获得良好的平均性能。快速排序正是这样一个例子,它通过在每次递归调用中随机选择主元,成为了一个高效且广泛使用的排序算法。
总结
在本节课中,我们一起回顾了概率论的五个核心概念:
- 样本空间:所有可能结果的集合。
- 事件:样本空间的子集,其概率是所含结果概率之和。
- 随机变量:定义在样本空间上的实值函数。
- 期望:随机变量的加权平均值。
- 期望线性性:随机变量和的期望等于期望的和。这是一个极其强大且常用的工具。

我们通过掷骰子和快速排序的例子阐述了这些概念,并最终在负载均衡问题中展示了如何综合运用它们进行分析。理解这些基础是学*后续随机算法分析的关键。
033:概率论回顾 II
概述
在本节课中,我们将继续学*概率论的基础知识。我们将重点介绍两个紧密相关的核心概念:条件概率与独立性。理解这些概念对于分析某些随机算法至关重要。

样本空间与事件回顾

在开始新内容之前,我们先快速回顾一下上一节的核心概念。样本空间(Ω)代表了随机过程所有可能结果的集合。每个结果都有一个已知的概率 P(i),且所有结果的概率之和为 1。
事件(例如事件 X 或 Y)仅仅是样本空间的一个子集。一个事件的概率就是该事件包含的所有结果的概率之和。
条件概率
上一节我们介绍了概率的基本概念,本节中我们来看看条件概率。条件概率讨论的是在已知另一个事件发生的情况下,某个事件发生的概率。
设 X 和 Y 是同一样本空间中的两个事件。我们可以用文氏图来思考:Ω 代表所有可能发生的事,X 和 Y 是其中的两个区域,它们可能有交集,也可能没有。

我们想要定义的是:在已知事件 Y 发生的情况下,事件 X 发生的概率,记作 P(X | Y)。
其定义非常直观:既然已知 Y 发生,我们就把关注范围缩小到 Y 这个区域。在这个新世界里,我们关心的是 Y 中有多大比例也被 X 占据。因此,条件概率的公式定义为:

P(X | Y) = P(X ∩ Y) / P(Y)
条件概率示例
为了确保您理解条件概率的定义,我们来看一个掷两个骰子的经典例子。
问题:已知两个骰子的点数之和为 7,求至少有一个骰子点数为 1 的概率。
解答:
- 定义事件:
- X:至少有一个骰子点数为 1。
- Y:两个骰子点数之和为 7。
- 事件 Y 包含 6 种等可能的结果:(1,6), (2,5), (3,4), (4,3), (5,2), (6,1)。
- 事件 X ∩ Y 是同时满足“和为7”和“至少有一个1”的结果,即 (1,6) 和 (6,1),共 2 种。
- 根据定义计算:
- P(X ∩ Y) = 2/36
- P(Y) = 6/36
- P(X | Y) = (2/36) / (6/36) = 1/3
因此,在已知点数和为7的条件下,至少有一个骰子为1的概率是三分之一。

事件的独立性
理解了条件概率后,我们自然可以引出独立性的概念。两个事件 X 和 Y 是独立的,当且仅当下面的等式成立:
P(X ∩ Y) = P(X) * P(Y)
这个定义有一个更直观的解释:事件 X 和 Y 独立,当且仅当 P(X | Y) = P(X)。也就是说,知道 Y 发生与否,完全不影响 X 发生的概率。对称地,也有 P(Y | X) = P(Y)。
重要警告:独立性是一个微妙的概念,直觉常常会出错。即使是专业的研究人员,也常常因为误用对独立性的直觉而犯错。一个实用的经验法则是:除非两个变量在构造上明显独立(例如算法中明确设定的独立随机选择),否则在分析时应先假设它们是相关的。
随机变量的独立性
之前我们讨论了事件的独立性,现在将其推广到随机变量。随机变量是从样本空间到实数的函数。
两个随机变量 A 和 B 是独立的,意味着对于它们取任意值的组合,相应的事件都是独立的。非正式地说,知道其中一个变量的值,不会给你任何关于另一个变量值的信息。

形式上,A 和 B 独立当且仅当对任意值 a, b,都有:
P(A=a 且 B=b) = P(A=a) * P(B=b)
独立随机变量的期望乘积
独立性的一个非常有用的性质是关于期望的。对于两个独立的随机变量 A 和 B,它们的乘积的期望等于它们期望的乘积:
E[A * B] = E[A] * E[B]
请注意:这个性质仅当随机变量独立时才成立。这与期望的线性性质(E[A+B] = E[A] + E[B])不同,线性性质不需要任何前提条件。
推导简述:
E[AB] = Σ_a Σ_b (a * b * P(A=a 且 B=b))
由于独立性,P(A=a 且 B=b) = P(A=a) * P(B=b)
因此,E[AB] = (Σ_a a * P(A=a)) * (Σ_b b * P(B=b)) = E[A] * E[B]
综合示例:辨析独立与依赖
让我们通过一个具体的例子,将上述概念串联起来,并展示判断独立性有时并不直观。
设定:定义三个随机变量 X1, X2, X3。
- X1 和 X2 是独立的,各自以 1/2 的概率取 0 或 1。
- X3 由 X1 和 X2 决定:X3 = X1 XOR X2(异或运算:两者相同时为0,不同时为1)。
样本空间有四个等可能的结果:
(X1, X2, X3) = (0,0,0), (1,0,1), (0,1,1), (1,1,0)
以下是关于独立性的两个论断:
论断一:X1 和 X3 是独立的。
这或许有些反直觉,因为 X3 部分依赖于 X1。但检查所有结果:(X1, X3) 取值为 (0,0), (1,1), (0,1), (1,0),每种组合概率均为 1/4。这就像两个独立的公平硬币投掷,因此它们独立。

论断二:随机变量 (X1 * X3) 和 X2 是不独立的。
我们将通过反驳“期望乘积性质”来证明。如果它们独立,则应有 E[(X1X3) * X2] = E[X1X3] * E[X2]。
- 计算右边:
- 由于 X1 和 X3 独立(论断一),E[X1*X3] = E[X1] * E[X3] = (1/2) * (1/2) = 1/4。
- E[X2] = 1/2。
- 因此右边 = (1/4) * (1/2) = 1/8。
- 计算左边 E[X1 * X3 * X2]:
- 查看样本空间:在四种结果中,X1 * X3 * X2 的值分别为 0, 0, 0, 0。
- 因此,E[X1 * X3 * X2] = 0。
- 由于 0 ≠ 1/8,期望乘积性质不成立,所以 (X1*X3) 和 X2 不是独立的。
这个例子说明,即使变量间存在某种关系,独立性也可能以非平凡的方式出现或消失,必须严格依据定义或性质进行判断。
总结
本节课中我们一起学*了概率论回顾的第二部分。我们深入探讨了条件概率的定义与计算,并在此基础上引出了事件和随机变量的独立性概念。我们特别强调了独立性的微妙性,以及一个关键性质:对于独立随机变量,乘积的期望等于期望的乘积。最后,通过一个综合示例,我们练*了如何辨析变量间的独立与依赖关系。掌握这些概念是分析复杂随机算法的基础。
034:随机选择算法
在本节课中,我们将学*一个与排序紧密相关的重要问题:选择问题。我们将重点介绍一个非常实用的随机化算法,它能在线性期望时间内解决选择问题,这比通过排序间接解决(O(n log n))要快。我们将看到,这个算法是快速排序思想的一个巧妙变体。
上一节我们介绍了选择问题的定义,本节中我们来看看如何利用快速排序中的分区思想来直接解决它。

选择问题定义
输入与排序问题相同:一个包含 n 个互异元素的数组 A。此外,还会给定一个整数 i (1 ≤ i ≤ n),表示我们想要寻找的“顺序统计量”。
目标是输出数组中第 i 小的元素。

核心概念:
- 第 i 阶顺序统计量:数组中第 i 小的元素。
- 特殊情况:
- i = 1:最小值。
- i = n:最大值。
- i = (n+1)/2(n 为奇数)或 i = n/2(n 为偶数):中位数。

中位数是选择问题的典型代表,它比平均值更能抵抗数据中的异常值(例如被破坏的数据点)的影响。
一个简单的解法:归约到排序
我们首先思考一个现成的解法。
以下是解决选择问题的一个简单两步算法,其时间复杂度为 O(n log n):
- 对输入数组进行排序(例如使用归并排序)。
- 返回排序后数组的第 i 个元素。
这展示了一个重要的概念:归约。我们将选择问题归约到了我们已经会解决的排序问题。
然而,算法设计者的信条是:我们能否做得更好? 对于选择问题,我们可能期望达到线性时间 O(n)。但要实现这一点,我们必须证明选择问题本质上比排序问题更容易,不能仅仅依赖排序这个“重型武器”。

随机选择算法
我们将要介绍的算法是快速排序思想的一个变体。它是一个随机化算法,其期望运行时间为 O(n)。与快速排序类似,其分析过程也非常优雅。
算法核心:分区子程序
算法的基石是快速排序中的 Partition 子程序。它选择一个主元,并重新排列数组,使得:
- 所有小于主元的元素位于其左侧(顺序任意)。
- 所有大于主元的元素位于其右侧(顺序任意)。
- 主元被放置在其在最终排序数组中的正确位置上。
代码描述(伪代码思想):
Partition(A, left, right):
pivot = A[random index between left and right]
// 通过交换操作,将数组重排,使得 pivot 位于其最终位置 p
// 返回位置 p
算法思路
关键思想是:在分区之后,我们知道主元是数组中的第 j 小元素(j 是主元在分区后数组中的位置)。然后,根据我们要找的第 i 小元素与主元的关系,我们只需要在一个子数组中进行递归,而不是像快速排序那样处理两个子数组。
具体递归策略如下:
- 若 i == j:主元恰好就是我们要找的第 i 小元素。直接返回主元。
- 若 i < j:我们要找的元素比主元小,它必然位于主元的左侧子数组中。我们在左侧子数组(大小为 j-1)中递归寻找第 i 小元素。
- 若 i > j:我们要找的元素比主元大,它必然位于主元的右侧子数组中。我们在右侧子数组(大小为 n-j)中递归寻找第 (i - j) 小元素。
算法伪代码
RSelect(A, n, i):
if n == 1:
return A[0] // 基本情况
// 随机选择主元并分区
Choose pivot p uniformly at random from A[0..n-1]
Partition A around p -> 得到主元位置 j
if i == j:
return p // 幸运情况
else if i < j:
// 递归搜索左侧
return RSelect(Left part of A, j-1, i)
else: // i > j
// 递归搜索右侧
return RSelect(Right part of A, n-j, i-j)
算法运行时间分析
算法的运行时间高度依赖于主元选择的质量。

最坏情况
如果每次递归都极其不幸地选择了当前数组中的最小(或最大)元素作为主元,那么每次递归只能将问题规模减小 1。寻找中位数可能需要约 n/2 次递归,每次递归仍需处理*乎原规模的数组,从而导致总运行时间达到 O(n²)。
公式描述:
最坏情况递归式:T(n) = T(n-1) + O(n) => T(n) = O(n²)

理想情况
如果每次递归都能幸运地选择到当前数组的中位数作为主元,那么每次递归都能将问题规模减半。

公式描述:
理想情况递归式:T(n) ≤ T(n/2) + O(n)
根据主定理(情况2),可得 T(n) = O(n)。
期望情况分析(核心结论)
关键在于,随机选择的主元虽然不总是中位数,但平均而言,它们能很好地平衡两个子数组的大小,从而保证算法的期望运行时间是线性的。
定理:对于任意长度为 n 的输入数组,随机选择算法 RSelect 的期望运行时间为 O(n)。
这里的“期望”是针对算法内部的随机选择(代码中的随机数),而不是针对输入数据。这意味着该算法是通用的,对任何输入都保证线性期望时间。

下一节视频将详细证明这个线性期望时间的结论。证明将巧妙地运用概率论中的指示器随机变量和期望的线性性质。
总结

本节课中我们一起学*了选择问题及其一个高效的随机化解决方案。
- 我们首先将选择问题归约到排序,得到了一个 O(n log n) 的解法。
- 接着,我们介绍了 随机选择算法,它改编自快速排序的分区思想,但每次只递归处理包含目标元素的那一侧子数组。
- 我们分析了算法的最坏情况(O(n²))和理想情况(O(n))。
- 最重要的结论是:该算法的期望运行时间为 O(n),这意味着对于任何输入,平均来看它都能在线性时间内找到指定的顺序统计量。这证明了选择问题确实比全排序问题更简单。
- 我们还提到了存在确定性的线性时间选择算法(如“中位数的中位数”方法),但因其常数较大且实现复杂,在实践中不如本随机算法常用。
035:随机选择算法分析 📊
在本节课中,我们将学*随机选择算法的数学分析。我们将证明,对于任意长度为 n 的输入数组,该算法的平均运行时间是线性的,即 O(n)。这非常惊人,因为它几乎只比读取输入所需的时间多一点,并且比排序更快。这表明选择问题本质上比排序问题更容易。

算法回顾 🔄
上一节我们介绍了随机选择算法,本节中我们来看看它的具体分析。首先,让我们回顾一下算法步骤,它类似于快速排序:
- 输入:一个数组和要查找的第
i小元素(第i阶统计量)。 - 基准情况:如果数组只有一个元素,直接返回。
- 选择枢轴:从当前数组中均匀随机地选择一个元素作为枢轴
p。 - 划分:围绕枢轴
p划分数组,得到左半部分(元素小于p)和右半部分(元素大于p)。设枢轴在划分后的新位置为j。 - 递归选择:
- 如果
j == i,则枢轴恰好是第i小元素,直接返回p。 - 如果
j > i,则目标元素在左半部分,在左半部分(长度为j-1)中递归寻找第i小元素。 - 如果
j < i,则目标元素在右半部分,在右半部分(长度为n-j)中递归寻找第i - j小元素。
- 如果
该算法的核心操作是划分(Partition),其时间复杂度为线性,即 O(m),其中 m 是当前子数组的长度。我们记这个常数为 C,即一次划分需要 C * m 次操作。
分析思路与定义 📝
为了分析平均运行时间,我们需要追踪算法递归过程中子问题规模缩小的进度。我们引入“阶段(Phase)”的概念来量化进度。
定义:阶段 j
算法在某个递归调用中处于阶段 j,当且仅当当前处理的子数组长度 m 满足:
(3/4)^(j+1) * n <= m < (3/4)^j * n
例如:
- 阶段 0:子数组长度在
[0.75n, n)之间。最外层的递归调用总是处于阶段 0。 - 阶段 1:子数组长度在
[(0.75)^2 * n, 0.75n)之间,即大约[0.5625n, 0.75n)。
阶段编号 j 越大,表示子数组规模相对于原始输入 n 缩小得越多,即算法取得了更多进展。
定义:X_j
令 X_j 表示算法在整个执行过程中,处于阶段 j 的递归调用的总次数。这是一个随机变量,其值取决于算法随机选择的枢轴。
运行时间上界 ⏱️
基于以上定义,我们可以推导出算法运行时间的一个上界。
算法总运行时间 ≤ 所有阶段中,每个阶段内各递归调用所执行工作的总和。

具体来说,对于每个阶段 j:
- 该阶段内共有
X_j个递归调用。 - 每个处于阶段
j的递归调用,其处理的数组长度m至多为(3/4)^j * n。 - 每个递归调用中,划分操作的工作量至多为
C * m。
因此,我们可以得到总运行时间 T 的上界:
T <= Σ_j [ X_j * C * ( (3/4)^j * n ) ]
我们将这个重要的上界记为公式 (★):
T <= C * n * Σ_j [ (3/4)^j * X_j ]
我们的目标是计算这个随机变量 T 的期望值 E[T]。
分析 E[X_j]:转化为抛硬币实验 🪙
上一节我们得到了运行时间的上界公式,本节中我们来看看如何分析其中的关键随机变量 X_j 的期望值 E[X_j]。
一个关键的观察是:如果一个枢轴产生了 25-75 或更好的划分(即左右两部分都至少包含 25% 且至多包含 75% 的元素),那么无论接下来递归进入哪一边,新的子问题规模都将不超过原问题的 75%。 这意味着,只要在一次递归调用中选到了这样的“好枢轴”,算法就必定会离开当前阶段 j,进入阶段 j+1 或更高阶段。
那么,随机选到一个好枢轴的概率是多少呢?在一个包含 m 个元素的数组中,排名在中间 50% 的元素(即第 0.25m 到第 0.75m 小的元素)都能产生 25-75 或更好的划分。因此,每次选择枢轴时,选到好枢轴的概率至少是 50%。
由此,我们可以将“在阶段 j 中经历多少次递归调用”这个问题,转化为一个更简单的抛硬币实验:
- 抛一枚公平硬币(正面概率 50%,反面概率 50%)。
- 持续抛掷,直到第一次出现正面为止。
- 问:总共需要抛掷的次数
N的期望值E[N]是多少?
对应关系:
- 抛到“正面”:对应在一次递归调用中选到了“好枢轴”。这会导致离开当前阶段,就像抛硬币实验因出现正面而停止。
- 抛到“反面”:对应选到了“坏枢轴”。在最坏情况下,算法可能仍停留在当前阶段,需要继续下一次递归调用(即继续抛硬币)。
显然,在阶段 j 中经历的递归调用次数 X_j,其期望值不会超过这个抛硬币实验中抛掷次数 N 的期望值。即:
E[X_j] <= E[N]

计算抛硬币实验的期望 🧮
现在我们来计算抛掷公平硬币直到第一次出现正面所需次数 N 的期望值 E[N]。N 是一个参数为 1/2 的几何分布随机变量。
我们可以用一个巧妙的方法来求解 E[N]:
考虑第一次抛掷的结果:
- 有
1/2的概率抛出正面,此时实验停止,总次数为1。 - 有
1/2的概率抛出反面。抛出反面后,实验从头开始,后续所需的抛掷次数期望值仍然是E[N]。因此,在这种情况下,总次数的期望是1 + E[N]。
根据期望的定义,我们可以建立方程:
E[N] = (1/2) * 1 + (1/2) * (1 + E[N])
解这个方程:
E[N] = 1/2 + 1/2 + (1/2)E[N]
E[N] - (1/2)E[N] = 1
(1/2)E[N] = 1
E[N] = 2
所以,E[N] = 2。这意味着 E[X_j] <= 2,对于任何一个阶段 j,我们平均只需要经历不超过 2 次递归调用。
完成证明:计算期望运行时间 ✅
现在,我们拥有了完成证明所需的所有部件。回顾我们的运行时间上界公式 (★):
T <= C * n * Σ_j [ (3/4)^j * X_j ]
我们对两边取期望,并应用期望的线性性质(和的期望等于期望的和):
E[T] <= C * n * Σ_j [ (3/4)^j * E[X_j] ]

将 E[X_j] <= 2 代入:
E[T] <= C * n * Σ_j [ (3/4)^j * 2 ]
E[T] <= 2C * n * Σ_j [ (3/4)^j ]
最后的求和是一个无穷几何级数,公比 r = 3/4 < 1,其和为:
Σ_{j=0}^{∞} (3/4)^j = 1 / (1 - 3/4) = 1 / (1/4) = 4
因此:
E[T] <= 2C * n * 4 = 8C * n
由于 C 是一个常数,我们证明了随机选择算法的期望运行时间 E[T] = O(n)。
总结 📚
本节课中我们一起学*了随机选择算法的平均情况分析。
- 我们首先回顾了算法,其核心是随机选择枢轴并进行一次递归。
- 为了分析,我们引入了“阶段”的概念来量化递归的进度,并定义了随机变量
X_j来计数每个阶段的递归调用次数。 - 通过观察发现,每次递归有至少 50% 的概率选到能将问题规模缩减至 75% 以下的“好枢轴”,这使我们能够将分析
E[X_j]的问题转化为一个简单的抛硬币实验。 - 我们计算了抛硬币直到出现正面所需次数的期望值为
2,从而得出E[X_j] <= 2。 - 最后,我们利用运行时间上界公式和期望的线性性质,计算出算法的总期望运行时间为
O(n),即线性时间。

这个结果非常强大:对于任何输入数组,仅通过随机化,我们就能在线性平均时间内解决选择问题,这比先排序再选择更快,揭示了选择问题在计算上的固有简易性。
036:确定性选择算法(进阶可选)🔍
在本节课中,我们将学*一种用于解决选择问题的确定性算法。该算法不使用任何随机化,却能在最坏情况下保证线性时间复杂度。我们将详细探讨其工作原理、步骤,并分析其性能。
概述 📋
上一节我们介绍了随机选择算法(RSelect),它通过随机选择枢轴元素来高效地找到数组的第 i 小元素。本节中,我们将探讨一种确定性选择算法(DSelect),它不使用随机化,而是通过一种巧妙的“中位数的中位数”方法来选择枢轴,从而在最坏情况下也能达到线性时间复杂度。

问题回顾 📝
选择问题的目标是:给定一个包含 n 个互异元素的数组 A 和一个介于 1 到 n 之间的整数 i,找出数组中第 i 小的元素(即第 i 阶统计量)。例如,当 i = n/2 时,我们寻找的是中位数。
公式表示:
给定数组 A[1..n] 和整数 i (1 ≤ i ≤ n),目标是找到元素 x,使得恰好有 i-1 个元素小于 x。
随机选择算法(RSelect)快速回顾 🔄
在深入确定性算法之前,让我们简要回顾随机选择算法,因为 DSelect 可以看作是其修改版。
RSelect 的核心步骤如下:
- 随机选择枢轴:从数组中均匀随机选择一个元素作为枢轴 p。
- 分区:围绕枢轴 p 对数组进行分区,将小于 p 的元素移到其左侧,大于 p 的元素移到其右侧。设枢轴最终位于位置 j。
- 递归查找:
- 如果 j == i,则枢轴 p 就是我们要找的第 i 小元素,直接返回。
- 如果 j > i,则在左侧子数组(小于 p 的部分)中递归寻找第 i 小元素。
- 如果 j < i,则在右侧子数组(大于 p 的部分)中递归寻找第 (i - j) 小元素。
该算法的优势在于,随机选择的枢轴通常能带来较好的分割(接* 50-50 分割),从而在期望上达到 O(n) 的时间复杂度。
确定性算法的挑战与核心思想 💡
现在的问题是:如果不允许使用随机化,我们如何确定性地选择一个“好”的枢轴?一个好的枢轴应能产生平衡的分割,即分区后左右两部分的元素数量尽可能接*。
一个完美的枢轴是中位数,但寻找中位数本身就是我们要解决的问题,这似乎陷入了循环。

确定性选择算法(DSelect)的核心思想是:使用“中位数的中位数”作为真实中位数的*似,并用它作为枢轴。虽然它不一定是真正的中位数,但可以证明它是一个足够好的*似,能保证递归调用时问题规模以恒定比例缩小。


确定性选择算法(DSelect)步骤详解 🛠️
以下是 DSelect 算法的具体步骤,它与 RSelect 的主要区别在于枢轴选择子程序。
代码框架:
def DSelect(A, i):
# 基础情况:如果数组长度很小,直接排序并返回第 i 个元素
if len(A) <= 1:
return A[0]
# 步骤 1 & 2: 选择枢轴 p(使用“中位数的中位数”方法)
# 1. 将数组 A 分成 n/5 组,每组 5 个元素(最后一组可能少于 5 个)
# 2. 对每组进行排序(例如使用归并排序),并找出每组的中位数
# 3. 将这些中位数收集到一个新数组 C 中
# 4. 递归调用 DSelect 找出数组 C 的中位数(即第 len(C)/2 小元素),将其作为枢轴 p
# 步骤 3: 围绕枢轴 p 对数组 A 进行分区
# 将数组分为三部分:小于 p 的元素、等于 p 的元素、大于 p 的元素
# 设 p 在分区后的位置为 j
# 步骤 4: 根据 j 与 i 的关系决定下一步
if j == i:
return p
elif j > i:
# 在左侧子数组中递归寻找第 i 小元素
return DSelect(左侧子数组, i)
else: # j < i
# 在右侧子数组中递归寻找第 (i - j) 小元素
return DSelect(右侧子数组, i - j)
以下是关键步骤的详细说明:
枢轴选择子程序(“中位数的中位数”)
这是算法中最巧妙的部分,目的是确定性地找到一个*似中位数。
- 分组:将输入数组 A(长度为 n)逻辑上划分为大约 n/5 组,每组 5 个元素(最后一组可能不足 5 个)。
- 找各组中位数:对每个包含 5 个元素的组分别进行排序(因为规模小,任何排序方法均可),然后取出每组的中位数(即排序后的第三个元素)。这样我们得到了大约 n/5 个“第一轮获胜者”。
- 递归找中位数:将这 n/5 个中位数复制到一个新数组 C 中。然后,递归调用
DSelect算法本身,在数组 C 中寻找其中位数(即第floor(len(C)/2)小的元素)。这个找到的“中位数的中位数”就是最终选定的枢轴 p。

注意:这里出现了一个递归调用,用于在更小规模(n/5)的问题上寻找中位数。这是算法正确性和效率分析的关键。
算法结构:递归调用次数 🔢
理解 DSelect 的递归结构很重要。以下是算法中递归调用的位置:
- 在枢轴选择过程中:为了计算“中位数的中位数”,我们需要递归地在数组 C(大小为 n/5)上调用 DSelect。
- 在主分区之后:根据枢轴位置 j 与目标 i 的比较结果,在左侧或右侧子数组上递归调用 DSelect。
因此,在每次非基础情况的调用中,DSelect 总共会进行两次递归调用。一次用于内部选择“好枢轴”,另一次用于解决主要的子问题。这确保了算法最终会终止,因为每次递归调用处理的问题规模都严格小于原问题。
为什么是线性时间复杂度? 📈
直观上,选择“中位数的中位数”作为枢轴 p 能保证它不是一个特别“坏”的枢轴。可以证明,至少有 30% 的元素小于等于 p,也至少有 30% 的元素大于等于 p。这意味着,在分区之后,无论我们递归到哪一边,需要处理的子问题规模最多是原规模的 70%。

递归式推导:
设 T(n) 为 DSelect 在最坏情况下的运行时间。
- 分组和找各组中位数需要 O(n) 时间(对 n/5 个大小为 5 的组排序)。
- 递归地在大小为 n/5 的数组 C 上寻找中位数:
T(n/5)。 - 分区操作需要 O(n) 时间。
- 递归解决一个规模至多为 0.7n 的子问题:
T(0.7n)。
因此,递归式为:T(n) <= T(n/5) + T(0.7n) + O(n)
通过主定理或递归树法可以证明,此递归式的解为 T(n) = O(n)。关键在于两个递归调用的规模之和 (n/5 + 0.7n) = 0.9n 严格小于 n,确保了每次递归总工作量线性减少,从而聚合为线性总和。
与随机选择算法(RSelect)的比较 ⚖️
| 特性 | 随机选择算法 (RSelect) | 确定性选择算法 (DSelect) |
|---|---|---|
| 时间复杂度 | 期望 O(n) | 最坏情况 O(n) |
| 最坏情况 | O(n²)(概率极低但可能) | O(n)(有保证) |
| 随机化 | 需要 | 不需要 |
| 空间复杂度 | 原地 (O(1) 额外空间) | 非原地 (需要 O(n) 额外空间存储数组 C) |
| 实际性能 | 通常更快,常数因子小,且利用缓存局部性 | 较慢,常数因子大,且需要额外内存操作 |
| 核心思想 | 随机枢轴通常足够好 | 使用“中位数的中位数”保证枢轴质量 |
重要提示:尽管 DSelect 具有优美的理论保证,但在实际应用中,RSelect 通常是更好的选择,因为它更简单、更快且是原地操作。DSelect 的主要价值在于其理论意义,证明了线性时间选择可以在不依赖随机化的情况下实现。
算法发明者 👨🔬
这个精妙的算法由五位杰出的计算机科学家在 1973 年提出:
- Manuel Blum
- Robert W. Floyd
- Vaughan Pratt
- Ronald L. Rivest
- Robert Tarjan
值得一提的是,这五位作者中有四位(Blum, Floyd, Rivest, Tarjan)后来都获得了计算机科学最高荣誉——图灵奖,这充分说明了该算法的深远影响和创造性。
总结 🎯

本节课中我们一起学*了确定性选择算法(DSelect)。我们了解到:
- 问题:在无需随机化的情况下,如何最坏情况下以 O(n) 时间找到第 i 阶统计量。
- 核心技巧:使用“中位数的中位数”作为枢轴,它能保证分区相对平衡。
- 算法步骤:包括分组、找各组中位数、递归求中位数的中位数、分区、以及根据情况递归查找。
- 递归结构:算法包含两次递归调用,一次用于选择枢轴,一次用于解决子问题。
- 时间复杂度分析:通过递归式
T(n) = T(n/5) + T(0.7n) + O(n)证明了最坏情况下的线性复杂度。 - 实际考量:虽然理论保证强大,但由于较大的常数因子和额外的内存需求,在实践中随机选择算法(RSelect)通常更受青睐。
确定性选择算法是算法设计中一个经典的范例,展示了如何通过巧妙的构思,在不依赖随机性的情况下达到最优的理论性能边界。
037:确定性选择算法分析 I(进阶可选)🔍
在本节课中,我们将要学*如何分析确定性选择算法。我们将证明,该算法在任何可能的输入上都能在线性时间内运行。我们将从回顾算法开始,逐步分析其各个步骤的时间复杂度,并最终证明其线性时间性能。
算法回顾
上一节我们介绍了确定性选择算法的基本思想。本节中,我们来看看算法的具体步骤。该算法基于随机选择算法,但通过一个精心设计的子程序来选择枢轴元素,以确保其质量。
算法的核心是 choose_pivot 子程序,它本质上实现了一个两轮淘汰赛:
- 第一轮比赛:将输入数组分成若干组,每组包含五个元素。对每组进行排序(例如使用归并排序),并选出每组的中位数(即第三大的元素)作为“第一轮胜者”。
- 第二轮比赛:将所有第一轮胜者复制到一个新数组
C中,然后递归调用选择算法本身,找出数组C的中位数。这个中位数就是最终的枢轴元素p。

选定枢轴后,算法像随机选择算法一样进行分区,并根据目标元素与枢轴的相对位置,递归地在左侧或右侧子数组中继续查找。

步骤一:排序小组的时间分析
首先,我们来分析算法中看似最耗时的部分:对每组五个元素进行排序。以下是关键点:
- 排序一个包含五个元素的数组只需要常数时间。例如,使用归并排序,其操作数上限约为 120 次。
- 这个常数 120 来源于归并排序的公式。对于长度为
m的数组,归并排序的操作数约为6m * (log₂ m + 1)。代入m = 5,log₂ 5 < 3,得到6 * 5 * (3 + 1) = 120。 - 总共有
n/5个这样的小组。因此,步骤一的总操作数最多为120 * (n/5) = 24n,这显然是 O(n),即线性时间。
所以,尽管涉及排序,但由于每组规模极小且组数是线性的,这一步的整体开销是线性的。
建立递归式
现在,让我们分析整个七行算法。我们采用分析确定性分治算法的标准范式:建立递归式。递归式 T(n) 表示算法在长度为 n 的输入上的最大操作数,它由两部分组成:递归调用在更小子问题上的工作,以及本地(非递归)部分的工作。
以下是逐行分析:

- 排序小组:如上所述,时间为 Θ(n)。
- 复制胜者到数组 C:显然是线性时间 Θ(n)。
- 递归调用选择算法找中位数:这是在数组
C上进行的,C的长度为n/5。因此,这部分时间是 T(n/5)。 - 分区:与快速排序一样,分区操作是线性时间 Θ(n)。
- 常数时间操作:可忽略。
- 根据情况递归查找:这里有一个递归调用,但其输入规模未知,取决于分区后子数组的大小。我们暂时将其记为 T(?)。


综合以上,我们得到递归式:

T(n) ≤ T(n/5) + T(?) + c*n (对于某个常数 c)
当 n = 1 时,T(1) = 1(常数时间)。
我们分析的关键障碍在于那个未知的 T(?)。

关键引理:枢轴的质量保证
为了替换掉 T(?),我们需要理解枢轴 p 的质量。以下引理至关重要:
引理:通过上述两轮淘汰赛选出的枢轴 p,能保证将数组分割为至少 30-70 的比例(或更好)。也就是说,至少有 30% 的元素小于 p,也至少有 30% 的元素大于 p。
这意味着,无论我们在第6或7行进行哪一侧的递归,递归调用的输入规模最多是原数组的 70%。因此,我们可以用 T(0.7n) 来替换 T(?)(为简化分析,忽略细微的加减常数)。

证明思路:
为了证明这个引理,我们进行一个思维实验。将 n 个元素排列成一个网格:
- 每列代表一个五人小组,共
n/5列。 - 每列内,元素从下到上按从小到大排列。因此,中间行(第三行)的元素就是各组的“第一轮胜者”(中位数)。
- 各列从左到右,按其中位数(第一轮胜者)的值从小到大排列。

设 k = n/5,令 x_i 为第 i 小的中位数。那么,我们的枢轴 p 就是 x_{k/2}(即所有中位数的中位数)。
现在考虑网格中位于 p 左下方(西南方向)的所有元素:
- 由于列按中位数排序,
p左侧所有列的中位数都小于p。 - 由于每列内从上到下递增,这些列中位于
p所在行以下的两个元素也必然小于其中位数,从而小于p。
因此,整个西南区域(至少(k/2) * 3 ≈ (n/5)/2 * 3 = 0.3n个元素)都小于p。
对称地,p 右上方(东北方向)的所有元素都大于 p,数量也至少为 0.3n。
这就证明了枢轴 p 至少能将数组划分为 30-70 的比例。

当前进展与下一步
至此,我们证明了选择枢轴的子程序能保证产生一个相当好的分割。这允许我们将递归式更新为:
T(n) ≤ T(n/5) + T(0.7n) + c*n
然而,证明引理本身需要付出代价——它依赖于一个递归调用 T(n/5)。一个自然的问题是:为了获得好的分割而付出的这个额外递归代价,是否会抵消掉分割带来的好处,从而无法实现线性时间?
我们似乎还没有完全赢得胜利。在下一节中,我们将完成这个递归式的求解,最终证明 T(n) = O(n),即确定性选择算法确实在线性时间内运行。
本节课中我们一起学*了确定性选择算法的结构,并分析了其关键步骤的时间复杂度。我们证明了用于选择枢轴的子程序虽然涉及递归和排序,但其本地操作是线性时间的。更重要的是,我们通过一个巧妙的网格论证,证明了该子程序能保证选出的枢轴至少产生 30-70 的分割。这为我们最终证明算法的线性时间复杂度奠定了关键基础。在下一讲中,我们将完成最后的分析。
038:确定性选择算法分析 II(进阶可选)🔍
在本节课中,我们将完成确定性选择算法线性时间复杂度的证明。我们将分析算法的递归式,并证明其运行时间为 O(n)。

概述
上一节我们介绍了基于“中位数的中位数”思想的确定性选择算法,并证明了其核心引理:该算法能保证每次递归调用至少修剪掉30%的元素。本节中,我们将分析算法的整体时间复杂度,验证寻找优质主元的成本是否被其带来的良好分割效益所抵消。
递归关系分析

我们定义 T(n) 为算法 dselect 在输入数组长度为 n 时的最坏情况运行时间。
在一般情况下(忽略递归调用),算法执行了线性数量的操作:
- 步骤1:将数组逻辑分组并排序(每组大小为常数5)。
- 复制和划分操作。
因此,存在一个常数 C(大于1),使得递归调用之外的工作量最多为 C * n。
对于递归调用:
- 第一个递归调用(用于帮助选择主元)始终作用于 n/5 大小的数组。其工作量可表示为 T(n/5)。
- 第二个递归调用(实际的选择操作)的大小取决于主元的质量。根据我们证明的关键引理,该递归调用最多作用于 70% 的原始数组,即 0.7n。
综合以上,我们得到算法的递归式:
T(n) ≤ C*n + T(n/5) + T(0.7n)
我们的目标是求解此递归式,并希望证明 T(n) = O(n)。
求解递归式:猜测与验证法
由于该递归式的子问题规模不同,不适用于主定理。我们将采用一种灵活的方法:猜测与验证(或称归纳法证明)。
我们希望证明存在一个常数 A(独立于 n),使得对于所有 n ≥ 1,都有 T(n) ≤ A * n。若此成立,则根据定义,T(n) = O(n)。

为了便于证明,我们选择 A = 10C。接下来,我们将通过数学归纳法验证此猜测。
以下是证明步骤:
1. 归纳基础 (n=1)
根据递归式的基础情况,我们有 T(1) = 1。
我们需要证明 T(1) ≤ A * 1。
由于 A = 10C 且 C ≥ 1,因此 A ≥ 10 > 1。不等式 1 ≤ A 显然成立。
2. 归纳假设
假设对于所有 k < n,命题 T(k) ≤ A * k 均成立。
3. 归纳步骤 (证明 T(n) ≤ A * n)
我们从递归式出发:
T(n) ≤ C*n + T(n/5) + T(0.7n)
现在,我们对两个递归项应用归纳假设,因为 n/5 和 0.7n 都小于 n:
- T(n/5) ≤ A * (n/5)
- T(0.7n) ≤ A * (0.7n)
将不等式代入原递归式:
T(n) ≤ Cn + A(n/5) + A*(0.7n)
= n * [C + A/5 + 0.7A]
= n * [C + (0.2A + 0.7A)]
= n * [C + 0.9A]
回忆我们的目标是证明 T(n) ≤ A * n。观察上式,如果我们能证明 n * [C + 0.9A] ≤ n * A,即 C + 0.9A ≤ A,则归纳步骤完成。
将我们选择的 A = 10C 代入验证:
C + 0.9*(10C) = C + 9C = 10C = A
因此,C + 0.9A = A,不等式成立。
于是,我们证明了:
T(n) ≤ n * [C + 0.9A] = n * A = A * n
归纳步骤完成。

总结
本节课中,我们一起学*了如何分析确定性选择算法的时间复杂度。
- 建立递归式:我们首先根据算法结构,推导出其运行时间的递归关系:T(n) ≤ C*n + T(n/5) + T(0.7n)。
- 求解递归式:由于子问题规模不均,我们采用了“猜测与验证”的归纳法。
- 完成证明:通过巧妙地选择常数 A = 10C,并利用数学归纳法,我们严格证明了 T(n) ≤ A * n,从而确立了算法的运行时间为 O(n)。
至此,我们完成了整个确定性线性时间选择算法的设计与分析,证明了其理论上的高效性。
039:基于比较排序的Ω(n log n)下界(进阶可选)🔬

在本节课程中,我们将探讨一个关于排序算法性能的根本性问题:我们能否设计出比 O(n log n) 更快的通用排序算法?我们将证明,对于一大类被称为“基于比较的排序算法”,其时间复杂度存在一个 Ω(n log n) 的下界。这意味着,像归并排序和快速排序这样的算法,在某种意义上已经达到了最优。

什么是基于比较的排序算法?🤔
上一节我们介绍了排序算法的基本目标,本节中我们来看看一类特定的算法。基于比较的排序算法是指那些仅通过比较元素对来访问输入数组中元素的算法。它不能直接查看或操作单个元素的具体值,只能询问“元素A是否大于元素B?”。
你可以将其视为一个通用排序函数,它接收一个比较函数指针作为参数,用于比较抽象数据类型。算法本身对数据的具体内容一无所知,只能通过这个“比较API”来工作。我们课程中讨论过的所有排序算法都属于此类。
以下是基于比较排序算法的例子:
- 归并排序:仅通过比较和复制元素来工作。
- 快速排序:仅通过比较和交换元素来工作。
- 堆排序(后续会学到):通过建堆和提取最小元素来工作,也仅使用比较操作。
为了更清晰地理解这个概念,我们来看看哪些算法不是基于比较的排序。这些算法通常需要对数据做出额外假设,并直接查看元素的值。
以下是非基于比较排序算法的例子:
- 桶排序:假设数据服从特定分布(如均匀分布)。算法会查看每个元素的具体值,并根据值将其放入对应的“桶”中。
- 计数排序:假设数据是范围有限的小整数。算法会遍历数组,根据每个元素的具体整数值进行计数,然后输出。
- 基数排序:假设数据是整数。算法按位(例如从最低有效位到最高有效位)进行排序,通常以计数排序作为子程序。
这些非基于比较的算法通过直接“窥探”元素值并进行分桶操作,在特定假设下可以突破 Ω(n log n) 的下界,达到线性时间复杂度 O(n)。然而,它们牺牲了通用性。
下界证明的核心思想 🧠
现在,我们回到基于比较的排序。我们要证明:任何正确的、确定性的、基于比较的排序算法,在最坏情况下都需要至少 Ω(n log n) 次比较。
证明思路如下:
- 考虑对一个长度为
n的数组进行排序。由于算法只关心元素的相对次序,我们可以假设数组包含数字1到n的某种排列。总共有 n! 种不同的可能输入。 - 设算法在最坏情况下需要进行 K 次比较。
- 算法的执行过程可以看作一棵决策树。每次比较产生一个二元分支(是或否)。因此,算法最多只能有 2^K 条不同的执行路径。
- 为了让算法能正确区分所有 n! 种不同的输入,我们必须有足够多的执行路径来对应每一种输入。也就是说,必须满足:2^K ≥ n!。
- 如果 2^K < n!,根据鸽巢原理,至少有两种不同的输入会遵循完全相同的比较路径(得到完全相同的比较结果序列)。算法无法区分它们,因此不可能同时对两者都正确排序。这证明了不等式 2^K ≥ n! 是算法正确的必要条件。
- 现在我们对 n! 进行估算,以求解 K 的下界。一个简单的下界是:
n! ≥ (n/2)^(n/2)。(因为乘积中至少有n/2个因子大于等于n/2) - 对不等式两边取以2为底的对数:
K ≥ log₂(n!)K ≥ log₂((n/2)^(n/2))K ≥ (n/2) * log₂(n/2)- 因此,K ∈ Ω(n log n)。

总结与延伸 📚
本节课中,我们一起学*了基于比较排序算法的性能下界。
我们证明了,任何正确的、确定性的、基于比较的排序算法,在最坏情况下都需要 Ω(n log n) 次比较。这解释了为什么像归并排序(最坏情况 O(n log n))和快速排序(平均情况 O(n log n))这样的算法,在通用排序任务中已经达到了渐进最优。
值得注意的几点:
- 这个下界也适用于随机化的基于比较排序算法(如随机化快速排序)的期望运行时间。这意味着随机化快速排序在平均情况下也是最优的。
- 这个下界可以通过使用更精确的斯特林公式
n! ≈ √(2πn)(n/e)^n来加强,从而得到更紧的常数因子,但渐进结论不变。 - 要突破这个下界,必须像桶排序或计数排序那样,放弃“仅通过比较访问数据”的限制,并对输入数据做出额外假设。

因此,当你需要一个通用的、不依赖于数据特性的排序例程时,O(n log n) 就是你所能期望的最好结果。
040:图与最小割
概述
在本节课中,我们将学*图论中的最小割问题,并介绍一个名为“随机收缩算法”的简单而优雅的随机算法。我们将证明这个算法确实有效。本节内容可以看作是连接我们之前讨论的随机化主题与即将开始的图论主题之间的桥梁。我们刚刚在排序和搜索的背景下讨论了随机化,在本课程后期讨论哈希时还会再次涉及。在复*随机化和概率论的过程中,我想展示随机化在另一个完全不同的领域——图论中的应用,而不仅仅是排序和搜索。这是本节课程的一个高级目标。
第二个目标是,我们将开始初步接触图论。在接下来的几周里,我们将讨论许多基础的图论原语。本节内容将帮助我们预热,熟悉图的基本概念、词汇以及图算法的基本形态。
另一个值得一提的亮点是,与我们在本课程中讨论的大多数内容相比,这个收缩算法是一个相对较新的算法。所谓“较新”,我指的是它大约有20年的历史。这意味着至少我们中的大多数人在这个算法被发明时已经出生。在这样一门导论课程中,我们将要学*的大多是“经典但优秀”的内容,有些甚至来自50年前。尽管过去50年世界和技术发生了巨大变化,但计算机科学中如此久远的想法至今仍然有用,这令人惊叹。第一代计算机科学家发现的东西至今仍然相关。然而,算法仍然是一个充满活力的领域,有许多开放性问题。有机会时,我会尝试让大家一窥这个事实。因此,我想指出,这个算法比我们将要看到的大多数其他算法(可以追溯到90年代)要相对新一些。
图的基本概念
让我们来谈谈图。从根本上说,图用于表示一组对象之间的成对关系。因此,图包含两个基本要素。

首先,是你所谈论的对象。这些对象有两个非常常见的名称,你必须同时知道这两个同义词。
- 第一个名称是顶点。Vertex是单数,vertices是复数。
- 另一个可互换的名称是节点。
我将用大写字母 V 来表示顶点集合。
这些是对象。现在我们要表示成对关系,这些对被称为边,用大写字母 E 表示。
图有两种类型,两者都非常重要,在应用中经常出现,你应该了解这两种类型。
- 无向图
- 有向图
这取决于边本身是无向的还是有向的。
边可以是无向的,这意味着这个对是无序的。一条边只有两个顶点,两个端点,比如 u 和 v,你不区分哪个是第一,哪个是第二。
边也可以是有向的,此时你得到的是一个有向图。在这里,一个对是有序的。因此,你有一个“第一个顶点”或“第一个端点”以及“第二个顶点”或“第二个端点”的概念。它们通常分别被称为尾和头。偶尔,虽然我会尽量避免使用这个术语,你会听到有向边被称为弧。
我认为,如果我画一些图,所有这些都会清晰得多。实际上,图过去常被称为“点和线”。“点”指的是顶点,所以这里有四个点或四个顶点。“边”就是线。表示一条边的方式就是在该边对应的两个端点(两个顶点)之间画一条线。这是一个具有四个顶点和五条边的无向图。

我们同样可以拥有这个图的有向版本。让我们仍然有四个顶点和五条边。但为了表明这是一个有向图,并且每条边都有一个第一个顶点和一个第二个顶点,我们将在线上添加箭头。箭头指向第二个顶点或边的头部。第一个顶点通常被称为边的尾部。
图是完全基础性的,它们不仅出现在计算机科学中,还出现在各种不同的学科中,社会科学和生物学是两个突出的例子。让我举几个你可能使用它们的理由,但实际应用有成百上千种。
- 一个非常字面的例子是道路网络。想象一下,你在某个网络应用或软件中输入请求,要求从A点开车到B点。它所做的就是操作道路网络的某种表示,这种表示不可避免地会被存储为一个图,其中顶点对应交叉路口,边对应单个道路。
- 网络通常可以被有效地视为一个有向图。这里的顶点是各个网页,边对应超链接。一条边的第一个顶点(尾部)是包含超链接的页面,第二个顶点(头部)是超链接指向的页面。这就是作为有向图的网络。
- 社交网络很自然地用图来表示。这里的顶点对应社交网络中的个体,边对应关系,比如好友链接。我鼓励你思考一下,在当今流行的社交网络中,哪些是无向图,哪些是有向图,我们有一些有趣的例子。
- 即使在没有明显网络结构的情况下,图也常常很有用。举个例子,先修课程约束。你可能正在考虑,比如,你是一名大学新生,正在查看你的专业,比如计算机科学专业,你想知道要上哪些课程以及顺序。你可以考虑以下图:你专业中的每门课程对应一个顶点,如果课程A是课程B的先修课程(即必须在开始课程B之前完成),则从课程A到课程B画一条有向边。这是一种使用有向图来表示对象对之间依赖关系或时间顺序的方法。
以上就是图的基本语言。现在让我谈谈图中的割,因为本系列讲座将讨论所谓的最小割问题。
图的割
图的一个割的定义非常简单。它只是将图的顶点划分成两个组 A 和 B,并且这两个组都应该是非空的。
为了用图片描述这一点,让我给出在无向图和有向图情况下割的示意图。
对于无向图,你可以想象画出你的两个集合A和B。一旦你定义了集合A和B,边就属于以下三类之一:
- 两个端点都在A中的边。
- 两个端点都在B中的边。
- 一个端点在A中,另一个端点在B中的边。

这就是从一个特定割AB的视角来看,图的通用形态。
有向图的图片类似,你同样会有一个A和一个B。你有两个端点都在A中的有向边,两个端点都在B中的有向边。现在你实际上还有另外两类边:
- 从左到右穿过割的边,即尾顶点在A中,头顶点在B中。
- 以相反方向穿过割的边,即尾在B中,头在A中。
通常,当我们谈论割时,我们关心的是有多少条边穿过一个给定的割。我的意思是以下内容。
割 AB 的交叉边是满足以下性质的边:
- 在无向情况下,定义很直观:一个端点在A中,另一个端点在B中,这就是穿过割的含义。
- 在有向情况下,你可以提出几种合理的定义来说明哪些边穿过割。通常,在本课程中,我们将专注于只考虑从左到右穿过割的边,忽略从右到左穿过的边。也就是说,穿过割的边是那些尾在A中、头在B中的边。
参考我们的两张割的示意图:对于无向图,所有这三条蓝色边都将是穿过割AB的边,因为它们有一个端点在左侧,一个端点在右侧。对于有向图,我们只有两条交叉边,即从左到右、尾在A中、头在B中的那两条。向后交叉的那一条不计入,我们不把它算作割的交叉边。

接下来的小测验只是为了确保你理解了图的割的定义。

小测验: 在一个有n个顶点的图中,有多少个不同的割?
- n
- n²
- 2ⁿ
- n!
这个测验的答案是第三个选项。回想一下割的定义:它只是将顶点分成两个集合A和B的一种方式。两者都应该是非空的。我们有n个顶点,本质上每个顶点都有一个二进制自由度:我们可以决定它是进入集合A还是集合B。对于n个顶点中的每一个,有两种选择,这总共给了我们 2ⁿ 种可能的选择,即 2ⁿ 个可能的割。但这稍微有点不准确,因为回想一下,割不能有空的集合A或空的集合B。在 2ⁿ 个选项中,有两个是不允许的。严格来说,数量是 2ⁿ - 2,但在提供的四个选项中,2ⁿ 无疑是最接*的答案。

最小割问题
最小割问题正是你所想的那样。我给你一个图作为输入,在这指数级数量的割中,我希望你为我找出一个具有最少交叉边数量的割。
一些快速说明:
- 首先,这个割的名称是最小割。最小割是具有最少交叉边数量的割。
- 其次,为了澄清,在输入中,我甚至允许所谓的平行边。在许多应用中,平行边可能没有意义,但对于最小割问题,允许平行边是很自然的,这意味着你有两条边对应完全相同的顶点对。
- 最后,你们当中经验更丰富的程序员可能想知道“给你一个图作为输入”具体是什么意思,你可能想知道这具体是如何表示的。下一个视频将详细讨论这一点,即表示图的流行方式以及我们通常在本课程中如何做,特别是通过所谓的邻接表。

好的,我想确保每个人都完全理解最小割问题在问什么。让我为你画一个特定的图。

这个图有8个顶点和相当多的边。我想让你回答的是:这个图中的最小割值是多少?也就是说,具有最少交叉边数量的最小割有多少条交叉边?
小测验: 上图中最小割的交叉边数量是多少?
- 1
- 2
- 3
- 4
正确答案是第二个选项,最小割值是2。证明这一点的割基本上是将图分成两半。在这种情况下,只有两条交叉边:这一条和这一条。我留给你去检查,没有其他割的交叉边数量少至2条。在这个例子中,当我们取最小割时,得到了一个非常平衡的分割。一般来说,这不一定成立。有时,甚至单个顶点就可以定义一个图的最小割。我鼓励你思考一个证明这一点的具体例子。
为什么关心最小割?
那么,为什么你应该关心计算最小割呢?这是一个被称为图划分的问题类型中的一个。给你一个图,你想把它分成两块或多块。这类图划分问题在各种各样令人惊讶的应用程序中频繁出现。让我从高层次上提几个例子。
- 网络弱点识别:一个非常明显的应用是当你的图表示一个物理网络时。识别像最小割这样的东西可以让你识别网络中的弱点。也许这是你自己的网络,你想了解在哪里需要加强基础设施,因为它在某种意义上是网络的热点或薄弱点。或者也许是别人的网络,你想知道他们网络中的薄弱点在哪里。事实上,大约15年前有一些解密的文件显示,美国和苏联军队在冷战期间实际上对计算最小割非常感兴趣,因为他们正在寻找例如最有效的方法来破坏对方国家的交通网络。
- 社区检测:这是当今社交网络分析中的一个重要应用。问题在于,在一个巨大的图中,比如Facebook上所有人的图,你如何识别那些看起来紧密联系、关系密切的小群体?你希望从中推断出存在某种社区,也许他们都上同一所学校,也许他们有相同的兴趣,也许他们是同一个生物家族的一部分,等等。在某种程度上,如何最好地定义社交网络中的社区仍然是一个开放性问题。但作为一种快速而粗略的一阶启发式方法,你可以想象寻找那些一方面内部高度互联,但与图的其他部分连接相当薄弱的区域。像最小割问题这样的子程序可以用来识别这些小的、内部密集互联但与外部连接薄弱的部分。
- 图像分割:割问题在视觉领域也应用广泛。例如,一种使用它们的方式是所谓的图像分割。这里的情况是,你得到一个二维数组作为输入,其中每个条目是来自某个图像的像素。给定一个像素的二维数组,定义一个图是非常自然的:如果两个像素相邻(左右或上下相邻),你就在它们之间放一条边。这样就得到了所谓的网格图。现在,与这里讨论的基本最小割问题不同,在图像分割中,最自然的是使用边权重,其中一条边的权重基本上是你期望这两个像素来自同一对象的可能性。为什么你可能期望两个相邻的像素来自同一对象?也许它们的颜色映射几乎完全相同,你只是期望它们是同一事物的一部分。一旦你定义了具有合适边权重的网格图,现在你运行图划分或最小割类型的子程序,希望它识别的割能“切出”图片中的一个连续对象。然后你这样做几次,就能得到给定图片中的主要对象。
这个列表远未穷尽最小割和图划分子程序的应用,但我希望它能作为足够的动力,让你观看本系列讲座的其余部分。

总结
在本节课中,我们一起学*了图的基本概念,包括顶点、边以及无向图与有向图的区别。我们重点介绍了图的割的定义,即把顶点划分成两个非空集合A和B。在此基础上,我们定义了最小割问题:在给定图中寻找交叉边数量最少的割。我们还探讨了最小割问题在网络弱点分析、社交网络社区检测以及图像分割等多个领域的实际应用价值,为理解后续将介绍的随机收缩算法奠定了重要基础。
041:图表示方法 📊
在本节课中,我们将学*图论的基础知识,特别是如何衡量图的大小以及如何表示图。这些是讨论图算法之前必须掌握的基本概念。
图的基本概念
图由两个基本要素构成。首先,是我们讨论的对象集合,这些对象可以称为顶点,也可以称为节点。其次,我们使用边来表示对象之间的成对关系。边可以是无向的,此时它们是无序对;边也可以是有向的,从一个顶点指向另一个顶点,此时它们是有序对,我们称之为有向图。
图的规模
当我们讨论图的大小或图算法的运行时间时,需要明确输入规模的含义。与数组不同,数组只有一个长度参数,而图有两个不同的参数控制其大小:顶点数和边数。通常,我们用 N 表示顶点数,用 M 表示边数。
接下来,我们通过一个思考题来理解边数 M 如何依赖于顶点数 N。
思考题:边数的范围
考虑一个无向图,它有 n 个顶点,没有平行边(即任意一对顶点之间最多只有一条边)。同时,假设图是连通的(即图是一个整体,不能分成两个没有边连接的部分)。对于这样的图,其边数 M 的最小可能值和最大可能值是多少?
正确答案是第一个选项。一个连通无向图的最少边数是 n - 1。一个没有平行边的无向图的最大边数是 n * (n-1) / 2,也就是 n 选 2。

为什么最少需要 n-1 条边?
想象一下逐条添加边。初始时,图有 n 个孤立的顶点,即 n 个独立部分。每添加一条边,最多能将两个独立部分融合成一个。因此,要将 n 个部分减少到 1 个部分,至少需要添加 n-1 条边。树状图正好达到这个下界。

为什么最多是 n 选 2 条边?
显然,边数最多的情况是完全图,即每对顶点之间都有一条边。由于没有平行边且边是无序的,最多有 n 选 2 种可能的边。
稀疏图与稠密图
了解了边数如何随顶点数变化后,我们来讨论稀疏图和稠密图的区别。区分这两个概念很重要,因为某些数据结构和算法更适合稀疏图,而另一些则更适合稠密图。
为了精确描述,我们使用标准符号:
- N 表示图的顶点数。
- M 表示图的边数。
从之前的思考题我们知道,在大多数应用中(假设图连通且无平行边),边数 M 至少是 N 的线性函数(至少为 N-1),最多是 N 的二次函数(最多为 N 选 2)。
虽然实践中人们对这个术语的使用有些宽松,但基本概念是:
- 稀疏图:边数更接*下界,即接*线性。
- 稠密图:边数更接*上界,即接*二次。

通常,如果边数超过 N 乘以某个对数项,我们倾向于认为它是稠密图。
图的表示方法
接下来,我们讨论图的两种表示方法。本课程主要使用第二种,但第一种邻接矩阵也值得简要了解。
邻接矩阵

邻接矩阵是一种直观的想法,用一个矩阵来表示图中的边。
首先描述无向图的情况。矩阵用大写 A 表示,是一个 n x n 的方阵,其中 n 是图的顶点数。矩阵元素 A[i][j] 的含义是:当且仅当顶点 i 和 j 之间存在边时,其值为 1。这里假设顶点被命名为 1, 2, 3, ..., n。
邻接矩阵可以很容易地扩展以适应平行边、边权重或有向边:
- 对于平行边,可以让 A[i][j] 表示顶点 i 和 j 之间的边数。
- 对于边权重,可以让 A[i][j] 表示边 i-j 的权重。
- 对于有向图,如果弧从 i 指向 j,可以设 A[i][j] = +1;如果从 j 指向 i,可以设 A[i][j] = -1。

我们可以从多个维度评估一种数据结构或表示方法,其中两个重要的维度是:所需的资源量(此处指空间)以及数据结构支持的操作。
邻接矩阵的空间需求是多少?
答案是 n²,这与边的数量 M 无关。这是存储一个 n x n 矩阵最直接的方式。虽然对于稀疏图可以使用稀疏矩阵技巧来优化,但基本思想就是存储 n² 个条目。每个条目只需存储一个比特位来表示边是否存在,因此常数因子很小,但空间复杂度仍然是顶点数的二次方。这对于稠密图(M 接* n²)是合适的,但对于稀疏图(M 接*线性)则非常浪费。
邻接表

邻接表是本课程将主要使用的表示方法,它包含几个组成部分。
首先,将顶点和边作为独立的实体进行跟踪,因此需要为每个实体维护一个数组或列表。

其次,我们希望这两个数组能以明显的方式相互引用:给定一个顶点,我们希望知道它涉及哪些边;给定一条边,我们希望知道它的端点是什么。
具体来说:
- 每条边将有两个指针,分别指向它的两个端点(对于有向图,则区分头顶点和尾顶点)。
- 每个顶点将指向所有包含它的边(对于无向图,这很明确;对于有向图,通常顶点跟踪所有以它为尾的边,即所有可以从该顶点出发经过一条边到达的边。也可以额外存储一个数组来跟踪指向它的边,但这会增加存储开销)。
邻接表的空间需求是多少?
正确答案是第三个选项:Θ(M + N),我们可以将其视为图大小的线性空间。
我们来分别计算这四个组成部分的空间:
- 顶点列表:存储 n 个顶点,每个顶点需要常数空间,因此空间为 Θ(n)。
- 边列表:存储 m 条边,每条边需要常数空间,因此空间为 Θ(m)。
- 边的端点指针:每条边有两个指针指向其端点,每个指针是常数空间,因此总空间为 Θ(m)。
- 顶点指向边的指针:这看起来可能让人担心,因为一个顶点可能涉及很多边。但是,请注意,每个这样的指针(顶点指向某条边)都对应着第3类中的一个指针(那条边指回该顶点)。因此,第4类指针的总数与第3类指针的总数相同,也是 Θ(m)。
将四部分相加,我们得到 Θ(n) + Θ(m) + Θ(m) + Θ(m) = Θ(m + n)。这也可以认为是 Θ(max(M, N))。因此,邻接表的空间复杂度与图的“成分”数量(顶点数加边数)成正比,这正是我们期望的。

如何选择表示方法
面对这两种图表示方法,你可能会问:应该记住并使用哪一种?
答案通常是:视情况而定。这取决于两个因素:图的密度(即 M 与 N 的关系)以及你需要支持的操作类型。
鉴于本课程的内容以及我心中的应用场景,我可以给出一个明确的答案:在本课程中,我们将主要关注邻接表。
原因有二:
- 操作需求:本课程涉及的大多数图原语(如图搜索)非常适合用邻接表实现。进行图搜索时,你到达一个节点,沿着出边前往另一个节点,如此继续。邻接表是进行图搜索的完美工具。邻接矩阵虽然对某些图操作很好,但不是本课程的重点。
- 图密度与应用:如今,许多图原语的动机来自于海量网络。例如,万维网可以被有效地视为一个有向图,其中顶点是单个网页,有向弧对应从一个页面指向另一个页面的超链接。万维网图的顶点数保守估计约有 10¹⁰(100亿)个。这接*当前计算机能力的极限,但仍在极限之内。如果使用邻接矩阵,N² 将达到 10²⁰,这远远超出了任何可行范围。而邻接表呢?万维网中顶点的平均出度大约是10,因此边数大约是 10¹¹,邻接表的空间开销与此成正比。这虽然也极具挑战性,但仍在当前技术的能力范围内。因此,使用邻接表表示,我们可以在像万维网图这样的大规模稀疏图上进行非平凡的计算。
本节课总结
在本节课中,我们一起学*了图论的基础知识。我们明确了图的规模由顶点数 N 和边数 M 两个参数描述,并探讨了边数在连通无向图中的取值范围(N-1 到 N选2)。我们区分了稀疏图和稠密图的概念。重点介绍了两种图的表示方法:邻接矩阵和邻接表,分析了它们的空间复杂度分别为 Θ(N²) 和 Θ(M + N)。最后,我们得出结论,基于本课程的操作需求和典型应用(大规模稀疏图),邻接表将是主要使用的表示方法。
042:随机收缩算法
概述
在本节课中,我们将学*一个非常巧妙的随机算法——随机收缩算法,用于计算图的最小割。我们将首先回顾最小割问题的定义,然后详细解释随机收缩算法的工作原理,并通过示例演示其执行过程。最后,我们将探讨该算法成功的概率,这需要一些条件概率的知识。
最小割问题回顾

给定一个无向图作为输入,图中允许存在平行边(即连接同一对顶点的多条边)。事实上,在算法执行过程中,平行边会自然产生。我们的目标是计算图的一个割。
一个割是将图的顶点划分为两个非空集合 A 和 B。穿过割的边数是指那些一个端点在 A 中、另一个端点在 B 中的边的数量。在所有指数级数量的可能割中,我们希望找到一个具有最少穿越边数的割,即最小割。
随机收缩算法
这个算法由 David Karger 在 20 世纪 90 年代初于斯坦福大学攻读博士学位时提出。其基本思想是使用随机抽样。就像我们在快速排序中了解到的那样,随机抽样在某些场景下(特别是排序和搜索)是一个好主意。Karger 收缩算法的突破性在于,它证明了随机抽样对于解决基本的图问题也非常有效。
以下是算法的工作原理。我们只有一个主循环。
算法主循环
该算法的核心是一个 while 循环。循环的每一次迭代都会将图中的顶点数量减少一个。当图中只剩下两个顶点时,算法终止。
在每一次迭代中,我们进行随机抽样:从当前图中剩余的所有边中,均匀随机地选择一条边(每条边被选中的概率相同)。

选择一条边后,我们进行收缩操作:取这条边的两个端点,称为顶点 U 和顶点 V,然后将它们融合成一个代表两者的单一顶点。
这种合并可能会产生平行边,即使之前没有。这是允许的,我们会保留这些平行边。合并也可能产生自环(即两个端点是同一个顶点)。自环是无意义的,因此一旦出现,我们就会将其删除。
每次迭代都会减少剩余顶点的数量。我们从 n 个顶点开始,最终剩下 2 个。因此,在 n-2 次迭代后,我们停止。此时,我们返回由最后两个顶点所代表的割。
你可能会疑惑“由最后两个顶点所代表的割”是什么意思,接下来的示例会使其变得清晰。
算法示例
假设输入图是一个包含 4 个节点和 5 条边的图,形状像一个正方形加上一条对角线。
示例执行轨迹一
由于是随机算法,它可能以不同的方式运行。我们来看两种不同的执行轨迹。
在第一次迭代中,这 5 条边被选中的概率相等,均为 20%。为具体起见,假设算法恰好选择了左边的这条边进行收缩。
收缩后,左边的两个顶点融合成了一个“超节点”,而右边的两个顶点保持不变。原来连接这两个被收缩顶点的边消失了。剩下的边被“拉入”融合过程:顶部的边和对角线现在变成了连接超节点和右上顶点的平行边。此外,底部的边连接右下节点和超节点。
这就是一次迭代。现在图中剩下 3 个顶点(一个超节点和两个原始节点)和 4 条边(包括平行边)。
我们进入第二次迭代。此时剩余 4 条边,每条边被选中的概率为 25%。假设我们选择了两条平行边中的一条(例如,带有一条标记的那条)进行收缩。
现在,顶点数从 3 减少到 2。右下顶点从未参与任何收缩,保持不变。另一个顶点是一个超节点,代表了原始的三个顶点(左两个在第一次迭代中融合,右上顶点在本次迭代中融合进来)。
被收缩的边消失。其他三条边:最右边的边(无标记)连接两个最终节点;带两条标记的边也连接相同的两个节点(只是超节点变得更大了);而那条与我们收缩边平行的边(另一条带一条标记的边)变成了一个自环。根据算法,自环被自动删除。
现在,我们完成了 n-2 次迭代,只剩下两个节点。我们返回对应的割:割的一个集合 A 是所有融合到超节点中的原始顶点(本例中是除右下节点外的三个节点),集合 B 是对应另一个超节点的原始顶点(本例中就是右下节点本身)。
这个割确实是一个最小割,它有两条穿越边(最右边和最底下的边)。可以验证,该图中不存在穿越边少于两条的割。
示例执行轨迹二
现在来看另一种可能的执行情况。假设第一次迭代与之前相同,收缩了最左边的边。

但在第二次迭代中,假设我们选择了最右边的边进行收缩(这也是 25% 的概率)。
收缩后,我们同样剩下两个节点。被收缩的边消失,而其他三条带标记的边保留下来,成为连接这两个最终节点的平行边。
这对应一个割:A 是左边的两个顶点,B 是右边的两个顶点。这个割有三条穿越边。由于我们已经知道存在一个只有两条穿越边的割,因此这个割不是最小割。
算法分析
从示例中我们了解到,收缩算法有时能识别出最小割,有时则不能。这取决于它做出的随机选择,即它选择了哪些边进行收缩。
一个显而易见的问题是:这个算法有用吗?具体来说,它得到正确答案的概率是多少?我们知道这个概率大于 0 且小于 1,但它究竟是接* 1 还是接* 0?
我们处于一个熟悉的位置:我们有一个看似相当不错的算法,但并不知道它是否真的有效,也不知道它成功的频率。为了回答这个问题,我们需要进行一些数学分析,特别是会用到条件概率。
对于需要复*条件概率和独立性的同学,可以参考相关资源进行回顾。一旦掌握了这个数学工具,我们就能彻底解决这个问题,得到关于收缩算法成功计算最小割频率的精确答案。
总结

本节课我们一起学*了用于求解最小割问题的随机收缩算法。我们回顾了最小割的定义,详细阐述了算法的步骤,并通过具体图例演示了算法可能产生的不同结果。我们看到,算法的成功与否取决于其随机选择。为了量化其性能,我们需要借助概率论(尤其是条件概率)进行分析,这将是下一部分内容的核心。
043:收缩算法分析 📊

在本节课中,我们将要学*如何分析卡格尔提出的随机收缩算法,以计算无向图的最小割。我们将精确计算该算法成功的概率,并探讨如何通过重复运行来显著提升成功率。
收缩算法回顾
上一节我们介绍了最小割问题。本节中我们来看看收缩算法的具体分析。
我们被给定一个无向图作为输入,图中可能包含平行边。目标是计算所有可能的割中,拥有最少交叉边数量的那个割。
例如,在下图中,最小割是 {A, B},因为它只有两条交叉边。


卡格尔提出的随机收缩算法基于随机采样。算法包含 n-2 次迭代,每次迭代将顶点数减少一个。我们从 n 个顶点开始,最终减少到两个顶点。减少顶点数的方法是将两个顶点收缩或融合在一起。
以下是选择收缩哪对顶点的方法:
- 我们从剩余的边中均匀随机地选择一条边。
- 如果选中的边端点是
U和V,我们将U和V合并成一个超节点。 - 如果合并产生了自环,我们会在继续之前删除它们。

经过 n-2 次迭代后,只剩下两个顶点。这两个顶点自然地定义了一个割:一个超节点对应原始图中被融合到该超节点的顶点集合 A,另一个超节点对应集合 B。

成功概率的定义
本视频的目标是回答以下问题:算法输出特定最小割 (A, B) 的概率是多少?
我们首先建立基本符号:
- 固定一个输入的无向图
G。 - 用
n表示顶点数,m表示边数。 - 固定一个特定的最小割
(A, B)。如果图有多个最小割,我们只关注这个特定的割(A, B),并仅当算法输出这个特定割时才将其定义为成功。 - 用
k表示最小割的大小,即跨越割(A, B)的边数。 - 将这
k条跨越边记为集合F。
算法何时成功?
为了清晰理解算法何时成功,我们需要思考算法执行过程中可能出错的地方。

假设在某个迭代中,我们选择了集合 F 中的一条边进行收缩。由于这条边的一个端点在 A,另一个在 B,收缩操作会将 A 中的一个节点和 B 中的一个节点融合在一起。这意味着在算法最终输出的割中,这两个节点将位于割的同一侧,因此输出不会是期望的割 (A, B)。
反之,如果在所有 n-2 次迭代中,算法从未收缩 F 中的任何边,那么 A 中的顶点始终只与 A 中的顶点融合,B 中的顶点也始终只与 B 中的顶点融合。最终,所有 A 中的节点被归入一个超节点,所有 B 中的节点被归入另一个超节点,算法输出的正是期望的最小割 (A, B)。
总结:收缩算法成功输出割 (A, B),当且仅当它从未选择集合 F 中的边进行收缩。
因此,成功概率等于算法在所有迭代中均未收缩 F 中任何边的概率。
分析成功概率
我们将成功概率定义为事件 ¬S1 ∩ ¬S2 ∩ ... ∩ ¬S_{n-2} 的概率,其中 S_i 表示在第 i 次迭代中“搞砸”(即收缩了 F 中的边)。

我们将分步分析这个概率。
第一步:分析第一次迭代
首先,我们分析在第一次迭代中不搞砸的概率。
在第一次迭代中,我们从所有 m 条边中均匀随机选择一条边。集合 F 中有 k 条“危险”边。因此,第一次迭代搞砸的概率是 k/m。
为了得到一个用顶点数 n 表示的更有用的界限,我们做一个关键观察:在原始图 G 中,每个顶点的度数至少为 k。这是因为每个顶点本身定义了一个割(该顶点在一侧,其余 n-1 个顶点在另一侧),而任何割的交叉边数至少为 k,所以该顶点的度数(即该割的交叉边数)至少为 k。
利用图论中的握手引理:所有顶点的度数之和等于 2m。由于每个度数至少为 k,我们有:
sum(度数) >= k * n
因此:
2m >= k * n => m >= (k * n) / 2
结合第一次迭代失败概率 P(S1) = k/m,我们得到:
P(S1) = k/m <= k / ((k * n) / 2) = 2/n
所以,第一次迭代成功的概率至少为 1 - 2/n。

第二步:分析前两次迭代
现在,我们分析在前两次迭代中都不搞砸的概率。
根据条件概率,这等于第一次迭代成功的概率乘以在第一次成功条件下第二次迭代也成功的概率。
我们已经知道第一次成功的概率至少为 1 - 2/n。
对于第二次迭代,在已知第一次未收缩 F 中边的条件下,图中仍有 k 条危险边。我们需要计算此时选中危险边的概率。这个概率是 k / (剩余边数)。
为了得到下界,我们需要剩余边数的下界。类似于第一步的推理,在收缩一次后的图中,每个剩余节点(超节点)仍然对应原始图中的一个割,因此其度数至少为 k。此时图中剩余 n-1 个顶点。再次应用握手引理,剩余边数至少为 (k * (n-1)) / 2。
因此,在第一次成功的条件下,第二次迭代失败的概率至多为:
k / (剩余边数) <= k / ((k * (n-1)) / 2) = 2/(n-1)
所以,在第一次成功的条件下,第二次成功的概率至少为 1 - 2/(n-1)。

推广到所有迭代
我们可以将上述模式推广到所有迭代。在第 i 次迭代时,图中剩余 n-i+1 个顶点。每个顶点的度数仍至少为 k,因此剩余边数至少为 (k * (n-i+1)) / 2。在之前所有迭代均未收缩 F 中边的条件下,第 i 次迭代失败的概率至多为 2/(n-i+1),成功的概率至少为 1 - 2/(n-i+1)。
因此,算法最终成功(即从未收缩 F 中任何边)的概率至少为以下乘积:
P(成功) >= (1 - 2/n) * (1 - 2/(n-1)) * (1 - 2/(n-2)) * ... * (1 - 2/3)
其中最后一项对应倒数第二次迭代(当剩余3个顶点时)。
将 1 - 2/x 重写为 (x-2)/x,上述乘积变为:
P(成功) >= [(n-2)/n] * [(n-3)/(n-1)] * [(n-4)/(n-2)] * ... * [1/3]
观察这个乘积,分子和分母的项会大量抵消。最终,只剩下分子中最小的两项(1 和 2)以及分母中最大的两项(n 和 n-1):
P(成功) >= (2 * 1) / (n * (n-1)) = 2 / (n(n-1))
为了简化,我们可以用一个更宽松但更简洁的下界:
P(成功) >= 1 / n^2
结论:对于具有 n 个顶点的图,收缩算法输出特定最小割 (A, B) 的概率至少为 1/n^2。
通过重复试验提升成功率

虽然 1/n^2 的成功概率看起来不高,但请注意,图中总共有指数级数量的割。随机猜测一个割的成功概率约为 1/2^n,相比之下 1/n^2 要好得多。它是一个多项式倒数,这意味着我们可以通过重复独立运行算法来显著降低失败概率。
设我们独立运行算法 N 次。定义事件 T_i 为第 i 次运行成功。我们关心的是所有 N 次运行都失败的概率。
由于每次运行独立,且单次失败概率至多为 1 - 1/n^2,因此所有运行都失败的概率至多为:
P(全部失败) <= (1 - 1/n^2)^N
为了分析这个上界,我们使用一个有用的微积分事实:对于所有实数 x,有 1 + x <= e^x。令 x = -1/n^2,则 1 - 1/n^2 <= e^{-1/n^2}。
因此:
P(全部失败) <= (e^{-1/n^2})^N = e^{-N / n^2}
现在,我们可以选择重复次数 N 来使失败概率变得任意小。
- 如果令
N = n^2,则P(全部失败) <= e^{-1} ≈ 0.37。成功概率提升到了约63%。 - 如果令
N = n^2 * ln(n),则P(全部失败) <= e^{-ln(n)} = 1/n。失败概率降到了1/n,对于大的n来说已经非常小。
总结:通过运行算法 O(n^2 log n) 次,我们可以将成功概率从 1/n^2 提升到接*1。这是一种通用的概率放大技术。
关于运行时间的说明
收缩算法本身并不复杂,但按照我们描述的方式(运行 O(n^2 log n) 次,每次至少需要查看所有边 O(m)),总运行时间是 O(m * n^2 log n),这是一个多项式时间,但比本课程中将看到的大部分算法都要慢。
需要指出的是,存在更优化的实现方式(例如,利用之前运行的信息来指导后续搜索),可以大幅减少运行时间,但这些内容超出了本课程的范围。

本节课中我们一起学*了如何分析随机收缩算法的成功概率。我们推导出单次运行找到特定最小割的概率至少为 1/n^2,并展示了如何通过重复独立运行 O(n^2 log n) 次,将算法的整体失败概率降低到可忽略的水平。这体现了随机算法中通过重复试验进行概率放大的强大技巧。
044:最小割计数 🧮
在本节课中,我们将探讨一个有趣的图论问题:一个具有 n 个顶点的无向图,最多可以有多少个不同的最小割?我们将通过分析随机收缩算法的性质来证明一个精确的上界。

概述
我们已知一个图可能拥有多个不同的最小割(即拥有最少跨越边数的割)。例如,在树结构中,每个单独的叶子节点都能形成一个最小割,因此一棵树拥有 n-1 个最小割。本节我们将证明,对于任意具有 n 个顶点的无向图,其最小割的数量最多为 n 选 2,即 n * (n-1) / 2。这是一个多项式级别的上界,在许多应用中非常有用。
下界:循环图示例 🔄

首先,我们通过一个简单的例子来建立下界,即证明存在图能达到 n 选 2 个最小割。
考虑一个具有 n 个顶点的循环图(例如,n=8 时的八边形)。以下是其关键性质:
- 在循环图中,移除任意一条边,图依然连通,不构成割。
- 但是,移除任意两条不同的边,就会将图分成两个连通部分,从而定义一个割。
- 这个割恰好有两条跨越边。在循环图中,任何割至少需要两条边才能断开图的连接,因此这确实是一个最小割。
- 由于选择不同的边对会产生不同的分割,因此循环图中不同最小割的数量就等于从
n条边中无序选取 2 条的方式数。
因此,我们得到下界公式:
最小割数量 ≥ C(n, 2) = n(n-1)/2

循环图达到了这个数量,证明了 n 选 2 是可能达到的下界。
上界:基于收缩算法的证明 ⬆️
上一节我们看到了一个达到 n 选 2 个最小割的例子。本节中我们来看看如何证明这是所有图的上限。证明的关键在于回顾随机收缩算法的分析。


设图 G 有 n 个顶点,并假设它有 T 个不同的最小割,分别记为 (A1, B1), (A2, B2), ..., (AT, BT)。
回顾我们对基本收缩算法(单次运行,非重复试验版本)的成功概率分析:
- 我们预先指定一个特定的最小割
(A, B)。 - 我们定义算法“成功”当且仅当其最终输出结果恰好是我们指定的这个割
(A, B)。输出其他最小割被视为失败。 - 我们证明了算法输出这个特定最小割的概率至少为
2 / [n(n-1)],即1 / C(n, 2)。
现在,我们将这个分析应用到图 G 的每一个最小割上。对于第 i 个最小割 (Ai, Bi):
- 定义事件
Si为:收缩算法输出结果为割(Ai, Bi)。 - 根据上述分析,我们有:
P(Si) ≥ 1 / C(n, 2)。
接下来是一个关键观察:事件 S1, S2, ..., ST 是互斥的。因为算法单次运行只能产生一个确定的输出结果,它不可能同时输出两个不同的割。

对于互斥事件,它们的概率之和等于它们并集的概率。以下是其逻辑:
- 所有可能结果的概率总和为 1。
- 事件
S1, S2, ..., ST互不重叠,都包含在所有可能结果中。 - 因此,这些互斥事件的概率之和不可能超过 1。
我们可以将此关系用公式表达:
P(S1 ∪ S2 ∪ ... ∪ ST) = Σ_{i=1 to T} P(Si) ≤ 1
将每个事件概率的下界代入:
T * [1 / C(n, 2)] ≤ Σ_{i=1 to T} P(Si) ≤ 1
对不等式进行整理,我们得到:
T ≤ C(n, 2)
这正好证明了,任意具有 n 个顶点的无向图,其最小割的数量 T 最多为 n 选 2。
总结
本节课中我们一起学*了如何计数最小割:
- 我们首先通过循环图的例子,证明了最小割数量可以达到下界 C(n, 2) = n(n-1)/2。
- 随后,我们巧妙地利用了对随机收缩算法的分析。通过定义算法“成功”为输出一个预先指定的特定最小割,并利用其成功概率下界以及事件互斥的性质,我们推导出了最小割数量的上界也是 C(n, 2)。
- 结合上下界,我们得出结论:一个具有
n个顶点的无向图,其最小割的数量恰好最多为n 选 2个。这是一个优美的多项式上界,说明了图的最小割数量并不会指数级爆炸。
045:图搜索概述 🗺️
在本节课中,我们将要学*图搜索这一基础问题,以及与之紧密相关的寻找图中路径的问题。

为什么需要图搜索?
图搜索的核心目标是判断图中是否存在从点A到点B的路径。这个问题有非常广泛的应用场景。
以下是几个典型的应用示例:

- 物理网络连通性:例如电话网络或道路网络。一个正常运作的电话网络必须确保从任何地点都能呼叫到其他任何地点。同样,一个国家内的道路网络也需要保证连通性。
- 逻辑网络分析:例如电影演员网络。在这个图中,节点代表演员,如果两位演员曾出现在同一部电影中,则他们之间有一条边。我们可以研究这个网络的连通性,例如计算“贝肯数”——即通过共同出演关系,从任意演员到凯文·贝肯所需的最少“跳数”(边数)。
- 寻找最短路径:这是图搜索的一个非常实际的应用。例如,在使用地图应用查询从当前位置到餐厅的路线时,我们就是在图中寻找一条路径,并且通常希望找到距离最短或预计时间最短的路径。
- 抽象问题求解:我们可以将路径抽象为一系列决策,将初始状态引导至目标状态。这使得图搜索在人工智能规划等领域非常普遍。例如,求解数独谜题可以看作在一个巨大的有向图中搜索路径:节点代表部分完成的谜盘,边代表在某个空位填入一个数字的决策。目标是从初始谜盘状态(节点)出发,找到一条到达完全填满且符合规则的谜盘状态(节点)的路径。机器人抓取物体等规划问题也可类似建模。
- 计算连通分量:这与图搜索紧密相关,本身也有许多应用。对于无向图,连通分量对应图的各个独立部分,可以用于简单的聚类分析。对于有向图,计算连通分量有助于理解网络(如万维网)的结构。
综上所述,高效地进行图搜索是一项基础且应用广泛的图算法原语。幸运的是,在本课程的这个部分,我们将学*到解决图搜索、连通分量计算等问题的快速算法。这些算法都将在线性时间(O(m + n),其中 m 为边数,n 为节点数)内运行,常数因子也很小,几乎与读取输入数据一样快。因此,你可以放心地将这些子程序作为工具来分析和理解图数据。
图搜索的通用方法
有多种系统性的图搜索方法。本课程将重点介绍两种非常重要的方法:广度优先搜索(BFS) 和 深度优先搜索(DFS)。不过,所有图搜索方法都有一些共同点。
上一节我们介绍了图搜索的应用,本节中我们来看看图搜索算法的通用框架。
图搜索子程序通常以一个起始顶点(常称为源点)作为输入。算法的目标是找出从该源点出发所有可达的顶点。“可达”意味着存在一条从源点出发到达该顶点的路径。

例如,给定一个包含三个部分的无向图,若源点 S 位于最左侧的节点,那么从 S 出发可达的顶点就恰好是它所在部分的四个顶点。算法应能自动且高效地发现这四个顶点。
我们希望在线性时间内完成搜索,即时间复杂度为 O(m + n),这意味着我们不会重复探索图的任何部分。

现在,让我们来看一个通用的图搜索方法。这个方法目前是未完全定义的(underspecified),存在多种具体实现方式,其中两种特定的实现将分别对应广度优先搜索和深度优先搜索。以下是这个通用方法的步骤:
- 初始化:将所有顶点标记为“未探索”,唯独将源点
S标记为“已探索”。 - 循环执行以下步骤,直到无法继续:
- 寻找一条边,其一端在“已探索”区域,另一端在“未探索”区域。
- 如果找不到这样的边,则算法终止。
- 如果找到,则将该边的“未探索”端点顶点标记为“已探索”,并将其纳入“已探索”区域。
- 返回步骤2继续循环。
这个算法保证了两个关键性质:第一,它最终会找到所有从源点 S 可达的顶点;第二,它不会重复探索任何顶点或边。算法的正确性可以通过反证法证明:假设存在一个从 S 可达的顶点 V 在算法结束时仍未被探索,那么在从 S 到 V 的路径上,必然存在一条连接“已探索”顶点 U 和“未探索”顶点 W 的边。然而,只要存在这样的边,算法的循环就不会终止,它会继续探索 W。这与算法已终止且 V 未探索的假设矛盾。
两种重要的具体策略
通用方法中的关键选择在于:当存在多条连接“已探索”和“未探索”区域的边时,接下来探索哪一个未探索的顶点?不同的选择策略导致了具有不同特性和应用的不同图搜索算法。
广度优先搜索 (BFS) 🧭

广度优先搜索的策略是按“层”探索节点。
- 第0层:源点
S。 - 第1层:
S的所有邻居。 - 第2层:第1层节点的所有尚未被访问过的邻居。
- 以此类推。
BFS 以一种相对“谨慎”的方式探索图。它与最短路径距离有密切关系:如果一个节点在第 i 层,那么从源点 S 到该节点的最短路径长度就是 i(即最少边数)。这正是计算“贝肯数”或地图导航最短路径所需要的。
在实现上,为了达到线性时间复杂度,BFS 通常使用 队列(Queue) 这种“先进先出”(FIFO)的数据结构来管理待探索的节点。
除了最短路径,BFS 也可用于计算无向图的连通分量。
深度优先搜索 (DFS) 🔍
深度优先搜索则采用一种更“激进”的策略:它试图沿着一条路径尽可能深地探索,直到无法继续,然后回溯到最*的分叉点选择另一条路径。这很像走迷宫时的策略。
DFS 有其独特的应用场景:
- 拓扑排序:对于有向无环图(DAG),DFS 可以产生一个拓扑排序,即将所有节点排成一个线性序列,使得图中所有的有向边都从序列中前面的节点指向后面的节点。这在处理具有依赖关系的任务调度时非常有用(例如课程选修的先后顺序)。
- 有向图的强连通分量:在无向图中,BFS 和 DFS 都可用来计算连通分量。但在有向图中,连通性的定义更复杂(强连通分量),而 DFS 是计算它的关键工具。

DFS 同样可以在线性时间 O(m + n) 内完成。其实现通常使用 栈(Stack) 这种“后进先出”(LIFO)的数据结构,或者利用程序递归调用时隐式形成的调用栈,从而写出非常简洁的递归代码。
总结
本节课中我们一起学*了图搜索的基础概念。我们了解了图搜索在物理网络、逻辑关系、路径规划、问题求解等多个领域的广泛应用。我们介绍了一个通用的图搜索框架,并指出了其核心决策点。最后,我们概述了两种最重要且高效的具体搜索策略:广度优先搜索(BFS)和深度优先搜索(DFS)。BFS 擅长寻找最短路径并按层探索,而 DFS 则擅长深入探索、拓扑排序和计算有向图的强连通分量。在接下来的课程中,我们将深入探讨这两种算法的细节及其实现。
046:广度优先搜索-BFS-基础 🚀
在本节课中,我们将深入探讨图搜索的第一个具体策略——广度优先搜索,并了解其应用。
概述
我们将学*广度优先搜索的基本原理、线性时间实现方法,以及如何用它来计算最短路径和图的连通分量。核心在于,BFS能够系统地、无重复地探索图中的节点。

BFS的直观理解与应用
上一节我们介绍了图搜索的通用概念,本节中我们来看看广度优先搜索的具体思路。
BFS从给定的起点开始,按“层”系统地探索图的节点。起点本身构成第0层(L0)。然后,探索起点的所有邻居,这些邻居构成第1层(L1)。接着,探索L1中所有节点的、且未被归入前几层的邻居,这些节点构成第2层(L2),以此类推。
以下是BFS可以完成的任务:
- 计算最短路径:在无权图中,从起点到某个节点的最短路径长度,恰好等于该节点所在的层数。
- 计算连通分量:对于无向图,BFS可以找出所有互相连通的节点集合,即图的各个“部分”。
我们的目标是实现线性时间复杂度,即 O(m + n),其中 n 是节点数,m 是边数。
BFS的线性时间实现 🛠️

理解了BFS的分层思想后,我们来看看如何用代码高效地实现它。
算法的输入是图 G(可以是无向图或有向图)和起点 s。为了避免重复探索,我们需要一个布尔数组来标记每个节点是否已被访问。初始时,所有节点均标记为“未探索”。
实现的关键是使用一个队列数据结构。队列遵循“先进先出”原则,支持在常数时间内从队首移除元素、在队尾添加元素。
以下是BFS算法的伪代码描述:
BFS(Graph G, start vertex s):
# 初始化
mark all vertices as unexplored
mark s as explored
initialize an empty queue Q
Q.enqueue(s) # 将起点放入队列
# 主循环
while Q is not empty:
v = Q.dequeue() # 从队首取出一个节点
for each edge (v, w) incident to v: # 遍历v的所有邻居w
if w is unexplored:
mark w as explored
Q.enqueue(w) # 将新发现的节点放入队尾
让我们通过一个例子来理解代码的执行过程。假设我们有下图,起点为 S。节点旁边的数字表示它们被探索(标记)的顺序。

- 初始:
S被标记,放入队列Q = [S]。 - 循环1:取出
S。检查其邻居A和B,两者均未探索,于是标记并依次加入队列。Q = [A, B]。A和B成为第1层。 - 循环2:取出
A。检查其邻居S(已探索)和C(未探索)。标记C并加入队尾。Q = [B, C]。 - 循环3:取出
B。检查其邻居S(已探索)、C(已探索)和D(未探索)。标记D并加入队尾。Q = [C, D]。C和D成为第2层。 - 循环4:取出
C。检查其邻居A,B,D(均已探索)和E(未探索)。标记E并加入队尾。Q = [D, E]。 - 后续循环:处理
D和E时,它们的邻居都已被探索,因此没有新节点加入。队列最终变空,算法结束。E成为第3层。
可以看到,队列的“先进先出”特性保证了节点严格按照层数递增的顺序被处理。
BFS的正确性与时间复杂度分析 ✅
我们已经看到了BFS的运行过程,现在来正式分析它的两个重要性质。
性质一:探索的完备性
在算法结束时,被标记为“已探索”的节点,恰好是所有从起点 s 出发有路径可达的节点。无论图是有向还是无向,这个结论都成立。这保证了BFS不会遗漏任何可达节点,也不会探索不可达节点。
性质二:线性时间复杂度
BFS的运行时间是线性的。更精确地说,设从起点 s 可达的节点数为 n_s,可达的边数为 m_s,那么主循环的运行时间为 O(n_s + m_s)。
以下是原因分析:



- 节点开销:每个可达节点只会被加入队列一次、移除队列一次,每次操作是常数时间。总开销为 O(n_s)。
- 边开销:每条可达边最多被检查两次(分别从其两个端点检查),每次检查是常数时间。总开销为 O(m_s)。
因此,总时间复杂度为 O(n_s + m_s)。对于整个图的搜索(即 s 能到达所有节点),时间复杂度就是 O(n + m)。
总结
本节课中我们一起学*了广度优先搜索。我们从分层探索的直观理解出发,学*了如何使用队列数据结构来实现BFS,并分析了其O(n + m)的线性时间复杂度和探索所有可达节点的正确性。BFS不仅是基础的图搜索策略,更是后续计算最短路径和连通分量等问题的强大工具。在接下来的课程中,我们将进一步探索BFS的这些应用。
047:BFS与最短路径

在本节课中,我们将学*广度优先搜索算法的一个关键应用:计算从起点到图中所有其他节点的最短路径距离。我们将看到,只需对基础的BFS代码进行微小的修改,就能高效地计算出这些距离。
概述
上一节我们介绍了广度优先搜索的基本框架。本节中我们来看看如何利用BFS来计算图中节点间的最短路径距离。我们将定义“最短路径距离”的概念,并展示如何通过BFS的“层”结构自然地计算出它。
最短路径距离的定义
首先,我们定义一些符号。给定一个起始节点 S,我们用 DIST(V) 来表示从 S 到节点 V 的最短路径距离。这个距离指的是从 S 到 V 的路径上所需的最少“跳数”,也就是路径上的最少边数。这个定义对于无向图和有向图都适用。在有向图中,路径必须沿着弧的正确(向前)方向遍历。
修改BFS算法以计算距离
为了计算最短路径距离,我们只需要在之前展示的BFS代码基础上增加非常少量的额外代码。其核心思想是跟踪每个节点所属的“层”,而每一层恰好对应着距离起始点 S 的最短路径距离。
以下是需要添加的额外代码:
首先,在初始化步骤中,我们需要为每个节点设置一个初步的距离估计值。
- 对于起始节点 S,我们知道从 S 到 S 的路径长度为0(空路径)。因此,我们设置
DIST(S) = 0。 - 对于所有其他顶点 V,我们最初并不知道是否存在通往它的路径。因此,我们暂时将它们的距离设为正无穷(
DIST(V) = +∞)。当然,一旦我们实际发现了一条通往 V 的路径,就会更新这个值。
其次,在算法的主循环中,当我们探索边并发现新节点时,需要计算其距离。
- 当我们从队列 Q 的前端取出一个顶点 V,并遍历它的边时,假设我们正在考虑边 V-W。
- 如果 W 是首次被发现(即未被探索过),那么除了像之前一样将其标记为已探索并加入队列末尾外,我们还需要计算它的距离。
- W 的距离被设置为:
DIST(W) = DIST(V) + 1。也就是说,W 的距离比首先发现它的那个顶点 V 的距离多1。
算法运行示例
让我们通过一个运行示例来观察这个过程。假设我们有以下简单的无向图,并以 S 为起点:
S
/ \
A B
| |
C---D
|
E
- 初始化:设置
DIST(S) = 0,其他节点距离为∞。将 S 放入队列 Q。 - 处理 S:从 Q 中取出 S。查看其邻居 A 和 B。
- 发现 A:
DIST(A) = DIST(S) + 1 = 0 + 1 = 1。将 A 标记并加入 Q。 - 发现 B:
DIST(B) = DIST(S) + 1 = 0 + 1 = 1。将 B 标记并加入 Q。
- 发现 A:
- 处理 A:从 Q 中取出 A。查看其邻居 S(已探索)和 C。
- 发现 C:
DIST(C) = DIST(A) + 1 = 1 + 1 = 2。将 C 标记并加入 Q。
- 发现 C:
- 处理 B:从 Q 中取出 B。查看其邻居 S(已探索)和 D。
- 发现 D:
DIST(D) = DIST(B) + 1 = 1 + 1 = 2。将 D 标记并加入 Q。
- 发现 D:
- 处理 C:从 Q 中取出 C。查看其邻居 A(已探索)、D(已探索)和 E。
- 发现 E:
DIST(E) = DIST(C) + 1 = 2 + 1 = 3。将 E 标记并加入 Q。
- 发现 E:
- 算法继续,最终所有可达节点都被探索,并获得了正确的最短路径距离。
最终距离为:DIST(S)=0, DIST(A)=1, DIST(B)=1, DIST(C)=2, DIST(D)=2, DIST(E)=3。这些距离值正好对应着BFS探索中的层数。
算法正确性简述
我们希望证明:对于从 S 可达的任意顶点 V,BFS计算出的 DIST(V) 等于 V 在BFS层中的编号 I,当且仅当 V 和 S 之间的最短路径有 I 条边。

我们可以通过数学归纳法来证明这个结论:
- 归纳基础:对于第0层(即 S 本身),我们正确设置了
DIST(S)=0。 - 归纳步骤:假设对于所有第
0层到第I-1层的节点,BFS都正确计算了距离。考虑一个第 I 层的节点 V。根据BFS的定义,V 之所以在第 I 层,是因为它被一个第I-1层的节点 U 首次发现,并且在此之前未被更早的层发现。根据归纳假设,DIST(U) = I-1。当算法通过边 U-V 发现 V 时,它会设置DIST(V) = DIST(U) + 1 = (I-1) + 1 = I。因此,第 I 层的所有节点都获得了正确的距离标签 I。
通过归纳,结论对所有层都成立。
BFS的特殊性与对比
在结束这个应用之前,需要强调:只有广度优先搜索能保证计算出最短路径距离。我们之前讨论过一族图搜索策略(如DFS),它们都能找到所有可达节点,BFS是其中之一。但计算最短路径距离是BFS特有的附加属性。特别是,深度优先搜索通常不能计算出最短路径距离。
相比之下,下一个应用——计算无向图的连通分量——则不是BFS独有的。例如,你也可以使用深度优先搜索来完成,效果一样好。
总结
本节课中我们一起学*了如何利用广度优先搜索来计算图中从起点到所有其他节点的最短路径距离。我们定义了最短路径距离,并通过在基础BFS算法中增加简单的距离跟踪逻辑来实现它。我们看到,BFS的层序遍历结构天然地对应着最短路径的跳数,这使得它成为解决无权图最短路径问题的理想工具。同时,我们也明确了这是BFS相对于DFS的一个独特优势。
048:BFS与无向图连通性 🔗

在本节课中,我们将要学*如何使用广度优先搜索(BFS)来计算无向图的连通分量。我们将了解连通分量的正式定义,探讨其应用场景,并学*一个高效的线性时间算法。

概述
图搜索算法(如BFS)的核心思想在无向图和有向图中基本一致,主要区别在于处理连通性问题时。本节课我们将专注于无向图,学*如何识别图中的各个“部分”,即连通分量。连通分量是图中最大的相互连通的区域,即从该区域内的任一顶点出发,都可以通过路径到达该区域内的任何其他顶点。

连通分量的正式定义
为了更精确地定义连通分量,我们可以使用等价关系。对于无向图G,我们在顶点上定义一个关系:顶点U与顶点V相关(记作U ~ V),当且仅当图中存在一条从U到V的路径。
这个关系 U ~ V 是一个等价关系,因为它满足以下三个性质:
- 自反性:每个顶点都与自身相连。
V ~ V总是成立,因为存在一条空路径。 - 对称性:如果U到V有路径,那么V到U也有路径。
U ~ V蕴含V ~ U。 - 传递性:如果U到V有路径,且V到W有路径,那么U到W也有路径。
U ~ V且V ~ W蕴含U ~ W。
这个等价关系的等价类就是图的连通分量。每个等价类都是一个最大的顶点集合,其中任意两个顶点都是相互连通的。
连通分量的应用
计算连通分量有许多实际应用:
- 网络诊断:检查一个物理网络(如互联网)是否断裂成多个部分。
- 社交网络分析:例如,在演员合作网络中,判断是否所有演员都能通过合作电影连接到凯文·贝肯。
- 数据可视化:识别图的不同部分,以便分开显示。
- 数据聚类:给定一组对象(如文档、图像、基因组)及其两两之间的相似度分数,可以构建一个图:每个对象是一个节点,如果两个对象的相似度足够高(例如,分数低于某个阈值),则在它们之间添加一条边。这个图的连通分量就构成了数据中高度相似的对象的“簇”。这是一种快速、线性的聚类启发式方法。
使用BFS计算连通分量
现在,我们来看看如何利用BFS作为核心子程序,通过一个简单的外层循环,在线性时间内计算出图的所有连通分量。
以下是算法的伪代码:
# 初始化:将所有节点标记为“未探索”
将所有节点标记为 unexplored
# 外层循环:确保检查图中的每一个节点
for 每个节点 i (从1到N):
if i 是 unexplored:
# 启动一次BFS,探索i所在的整个连通分量
BFS(G, i)
算法步骤解析
- 初始化:算法开始时,所有节点都被标记为“未探索”。
- 外层循环:按任意顺序(例如从1到N)遍历所有节点。这个循环确保每个节点最终都会被检查到。
- 避免重复工作:在从一个节点
i开始探索之前,先检查它是否已经被探索过。如果i是“未探索”的,说明我们遇到了一个新的连通分量。 - 启动BFS:以节点
i为起点调用BFS子程序。这次BFS会探索并标记i所在连通分量内的所有节点为“已探索”。 - 继续循环:BFS结束后,返回外层循环,继续检查下一个节点。由于BFS已经标记了该分量中的所有节点,后续循环遇到这些节点时会跳过,从而避免了在每个连通分量内部进行重复的BFS调用。
运行时间分析
该算法的时间复杂度是 O(n + m),其中n是顶点数,m是边数。原因如下:
- 节点开销:外层循环遍历每个节点一次,进行常数时间的检查,开销为O(n)。初始化标记也是O(n)。
- BFS内部开销:每个节点只会被包含在一次BFS调用中。在那次调用中,BFS会为该节点做常数量的工作。因此,所有BFS调用中处理节点的总开销是O(n)。
- 边开销:每条边也只会被属于其连通分量的那一次BFS调用处理一次(从它的某个端点),处理每条边也是常数时间。因此,处理所有边的总开销是O(m)。

综上所述,整个算法的总运行时间与图的顶点数和边数之和成线性关系。
总结
本节课中我们一起学*了无向图连通分量的概念及其计算方法。我们了解到连通分量可以通过等价关系精确定义,并且在网络分析、数据聚类等领域有广泛应用。核心的算法是:通过一个外层循环遍历所有节点,对每个未被探索的节点启动一次广度优先搜索(BFS),BFS会探索并标记该节点所在的整个连通分量。这个算法高效、直观,其运行时间为 O(n + m),是处理图连通性问题的强大工具。
049:深度优先搜索(DFS)基础 🧭

在本节课中,我们将学*图搜索的第二种核心策略——深度优先搜索(DFS)。我们将了解它的基本思想、工作原理、代码实现,并探讨其在有向无环图(DAG)中寻找拓扑排序的应用。
上一节我们介绍了广度优先搜索(BFS),它是一种谨慎、逐层探索的策略。本节中我们来看看它的“激进”表亲——深度优先搜索。
探索策略对比
如果广度优先搜索是谨慎试探的探索策略,那么深度优先搜索(简称DFS)则是其更为激进的版本。DFS的策略是积极深入探索,仅在必要时回溯。这很像人们在迷宫中采用的策略。
为了解释其含义,让我们通过之前讨论BFS时使用的同一个示例来演示DFS的工作过程。
DFS运行示例
假设我们从节点S开始调用深度优先搜索。以下是具体过程:
- 我们从S开始。显然,接下来有两个地方可以去:节点A或节点B。和BFS一样,DFS在此时也是不确定的,我们可以选择任意一个。为了和BFS例子保持一致,我们先去A。
- A成为我们探索的第二个节点。现在,与BFS(会自动去探索同层的另一个节点B)不同,DFS的唯一规则是:我们必须接着去探索A的直接邻居之一。
- 我们可能会去B,但此刻我们去B不是因为它是S的邻居,而是因为它是A的邻居。为了清晰地展示区别,我们假设我们积极地深入探索,从A去到了C。
- 此时,DFS策略依然是继续深入。所以我们去C的一个直接邻居,假设接下来我们去了E。
- E成为第四个被访问的节点。从E出发(不考虑我们来的那条边),只有一个邻居D,所以我们从E去到D。
- D是第五个被看到的节点。从D出发,我们有一个选择:可以去B,也可以去C。假设我们从D去到了C。
- 这时,我们到达了之前访问过的节点C。和往常一样,我们会记录已经访问过的地方。因此,此时我们必须从C回溯到D。
- 我们退回到D。现在,D还有另一条出边有待探索,即通往B的边。
- 于是,我们最终绕过了整个外圈,第六个访问了节点B。
- 现在,无论我们尝试探索哪里,都会发现已经去过的地方。从B尝试去S,已访问过,回溯到B;尝试去A,已访问过,再次回溯到B。
- 探索完B的所有选项后,我们必须从B回溯。我们回到D。
- 从D出发,B和C都已探索,所以我们必须回溯到E。
- 从E出发,唯一的出边D已探索,所以回溯到C。
- 从C回溯到A。
- 从A出发,我们实际上还没有探索过通往B的这条边,但尝试后会发现B已访问过,于是回溯回A。
- 最后,回溯回S。即使在S,也还有一条额外的边(S-B)有待探索。我们尝试它,但当然,当我们看过去时,B已经访问过,于是我们回溯到S。
- 至此,我们查看了每条边一次,搜索停止。
这就是深度优先搜索的工作方式:你沿着一条路径深入探索,只要可能就前往直接邻居,直到遇到已访问过的地方,然后开始回溯。
为何需要DFS?
你可能会想,既然已经有了广度优先搜索,为什么还需要另一种图搜索策略?BFS看起来非常棒:它在线性时间内运行,保证能找到你想找的一切,能计算最短路径,如果嵌入一个for循环还能计算连通分量。似乎别无所求了。
然而,深度优先搜索也有其令人印象深刻的一系列应用,这些应用未必能用BFS复现。我们将重点关注其在有向图中的应用。本视频将讨论一个简单的应用,而一个更复杂的应用将有单独的视频专门讲解。
- 本视频应用:计算有向无环图(即没有有向环的有向图)的拓扑排序。
- 后续视频应用:计算有向图中的强连通分量。
DFS的运行时间本质上与BFS相同,都是我们所能期望的最佳情况——线性时间。同样,我们并不假设边一定很多,边数可能远少于顶点数。因此,在这些连通性应用中,线性时间意味着 O(m + n)。
DFS代码实现
现在,让我们谈谈深度优先搜索的实际代码。有几种实现方式。

一种方法是只对广度优先搜索的代码做一些小的修改。主要区别在于:不使用具有先进先出行为的队列,而是换用具有后进先出行为的栈。如果你不知道栈是什么,应该查阅编程教材或网络资料。栈支持在前端进行常数时间的插入和删除操作,而队列则支持在后端进行常数时间的删除操作。栈的操作就像自助餐厅的餐盘架:你放入一个盘子,最后放进去的那个,当你取走最上面一个时,它就是最后放进去的那个。在栈的上下文中,这称为压入和弹出,两者都是常数时间操作。因此,如果你把队列换成栈,并做一些其他小修改,BFS就变成了DFS。
为了多样性和优雅性,我将向你展示一个递归版本。深度优先搜索非常自然地可以表述为一个递归算法,这也是我们将在这里讨论的版本。
深度优先搜索以图G作为输入(可以是有向图或无向图,对于有向图,只需确保沿边的正确方向探索,这通常由图的邻接表数据结构自动处理)。和往常一样,我们为图的每个顶点保留一个布尔值,记录我们是否访问过它。
当然,一旦我们从S开始探索,我们最好标记我们已经到过那里(可以理解为设置一个标志)。记住,DFS是一种激进的搜索,因此我们会立即尝试递归地搜索从S出发的、我们尚未访问过的任何邻居。如果我们发现一个新顶点(一个从未到过的地方),我们就从那个节点递归调用深度优先搜索。
以下是递归DFS的伪代码框架:
DFS(graph G, vertex s):
mark s as explored
for each edge (s, v) in G.outgoingEdges(s):
if v is not explored:
DFS(G, v)
DFS的基本保证
深度优先搜索的基本保证与广度优先搜索完全相同:它能找到所有我们希望找到的东西,并且在线性时间内完成。原因再次在于,这只是我们本系列视频开始时介绍的通用搜索过程的一个特例。它对应于在已探索节点区域和未探索节点区域之间的多条交叉边中做出选择的一种特定方式,本质上是总是偏向于最*发现的已探索节点。

和广度优先搜索一样,运行时间将与你要发现的连通分量的大小成正比。基本原因是:每个节点只被查看一次(布尔标志确保我们不会多次探索同一个节点),而每条边最多被查看两次(从每个端点各一次)。
DFS与连通分量

鉴于深度优先搜索和广度优先搜索都满足上述两个完全相同的声明,这意味着如果我们要计算无向图中的连通分量,我们同样可以使用一个外层的for循环,并将DFS作为内层循环的主力。对于无向图,使用深度优先搜索或广度优先搜索都能在 O(m + n) 的线性时间内找到所有连通分量。
因此,接下来我想重点介绍深度优先搜索的一个特定应用,即寻找有向无环图的拓扑排序。这将是展示DFS独特威力的一个绝佳例子。

本节课中我们一起学*了深度优先搜索(DFS)的基础知识。我们了解了其“深入探索,必要时回溯”的激进策略,通过示例逐步追踪了其运行过程,并探讨了递归实现的代码框架。我们还明确了DFS与BFS一样,能在线性时间内完成搜索并找到所有可达节点。最后,我们预告了DFS的一个关键应用——计算有向无环图的拓扑排序,这将在后续内容中详细展开。
050:拓扑排序 🧭

在本节课中,我们将要学*拓扑排序。这是一种针对有向无环图(DAG)的顶点排序方法,它确保图中所有的有向边都从排序中靠前的顶点指向靠后的顶点。我们将探讨拓扑排序的定义、应用场景,并学*两种计算它的算法。

拓扑排序的定义
首先,我们来明确什么是拓扑排序。
本质上,它是图顶点的一种排序,使得图中所有的弧(有向边)在排序中只向前走。
我们可以通过用数字1到n标记顶点来编码一个排序,这只是为了表示每个顶点在这个排序中的位置。形式上,存在一个函数 f,它将图G的顶点映射到1到n之间的整数,每个数字1到n恰好被一个顶点取到(这里n是图G的顶点数)。
这仅仅是编码排序的一种方式。真正重要的属性是:图G的每一条有向边在排序中都向前走。也就是说,如果 (u, v) 是有向图G的一条有向边,那么尾部 u 的 f 值应该小于头部 v 的 f 值。这意味着当你沿着正确的方向遍历这条有向边时,它的 f 值会更高。

让我举个例子来让这个概念更清晰。
假设我们有一个非常简单的有四个顶点的有向图。
让我展示这个图的两种完全合法的拓扑排序。
你可以做的第一件事是将 S 标记为1,V 标记为2,W 标记为3,T 标记为4。
另一种选择是以相同的方式标记它们,除了交换 V 和 W 的标签。所以,如果你愿意,你可以将 V 标记为3,W 标记为2。
这些标签真正要编码的是顶点的排序。所以蓝色的标签可以理解为编码了我们将 S 放在第一,然后是 V,接着是 W,最后是 T 的排序。而绿色的标签可以理解为相同的节点排序,只是 W 在 V 之前。
重要的是,在这两种情况下,边的模式完全相同,特别是所有的边在这个排序中都向前走。所以无论哪种情况,我们都有从 S 到 V 和从 S 到 W 的边。因此,无论 V 和 W 的顺序如何,从图示上看都是一样的。然后对称地,有从 V 和 W 到 T 的边。
你会注意到,无论我们以何种顺序放置 V 和 W,这四条边在每一个排序中都向前走。现在,如果你试图把 V 放在 S 之前,那将行不通,因为如果 V 在 S 之前,从 S 到 V 的边就会向后走。类似地,如果你把 T 放在除了最后位置以外的任何地方,你都不会得到一个拓扑排序。事实上,这是这个有向图仅有的两种拓扑排序。我鼓励你自己去验证这一点。
拓扑排序的应用
现在,谁关心拓扑排序呢?
这实际上是一个非常实用的子程序,在各种应用中都会出现。基本上,每当你想对一系列任务进行排序,而这些任务之间存在“先决条件”约束时,就会用到它。所谓先决条件约束,是指一个任务必须在另一个任务之前完成。例如,你可以考虑像计算机科学专业这样的本科专业中的课程。这里的顶点将对应所有的课程,如果课程A是课程B的先修课程(即你必须先修A),那么就会有一条从课程A到课程B的有向边。那么你当然想知道一个可以修这些课程的顺序,使得你总是在修完先修课程后才修该课程。这正是拓扑排序将要完成的事情。
存在性与算法
因此,一个合理的问题是:什么时候一个有向图具有拓扑排序?当一个图确实有这样的排序时,我们如何得到它?
首先,图要具有拓扑排序,有一个非常明确的必要条件,那就是它最好是无环的。换句话说,如果一个有向图有有向环,那么肯定不可能有拓扑排序。
我希望这个原因相当清楚。考虑任何一个有有向环的有向图,以及任何声称的顶点排序方式。现在,只需逐个遍历环上的边。你从这个环上的某个地方开始。如果第一条边向后走,那么你已经搞砸了,你已经知道这个排序不是拓扑的,因为不能有边向后走。所以显然,这个环的第一条边必须向前走。但现在你必须遍历这个环上的其余边,最终你会回到起点。所以,如果你一开始向前走,那么在某个时刻,你必须向后走。那条边向后走,就违反了拓扑排序的性质。这对每一个排序都是如此。因此,有向环排除了拓扑排序的可能性。
现在的问题是,如果你没有环呢?这个条件是否足够强,能保证你有一个拓扑排序?没有循环优先约束这个明显的障碍,是否就是唯一的障碍?
事实证明,答案不仅是肯定的——只要没有任何有向环,你就保证有一个拓扑排序——而且我们甚至可以通过深度优先搜索在线性时间内计算出一个拓扑排序。
在向你展示将计算拓扑排序非常巧妙且高效地归约到深度优先搜索之前,让我先介绍一个相当好但稍微不那么巧妙、效率也稍低的解决方案,以帮助你建立对有向无环图及其拓扑排序的直觉。
基于“汇点”的直观算法
对于这个直接的解决方案,我们将从一个简单的观察开始。
每个有向无环图都有我称之为“汇点”的顶点,即没有任何出弧的顶点。
在我们上一张幻灯片探讨的四个顶点的有向无环图中,恰好有一个汇点,就是最右边的这个顶点,它没有出弧。其他三个顶点都至少有一条出弧。

现在,为什么有向无环图必须有一个汇点呢?
假设它没有。假设它没有汇点。那将意味着每个顶点都至少有一条出弧。那么,如果每个顶点都有一条出弧,我们能做什么呢?我们可以从一个任意节点开始。我们知道它不是汇点,因为我们假设没有任何汇点,所以有一条出弧,让我们跟着它。我们到达某个其他节点。根据假设,没有汇点,所以这不是汇点,所以有一条出弧,让我们跟着它。我们到达另一个节点。那个节点也有一条出弧,让我们跟着它。如此继续。我们就这样一直跟着出弧走。只要愿意,我们可以一直这样做,因为每个顶点都至少有一条出弧。但是,顶点的数量是有限的,对吧?这个图有n个顶点。所以如果我们跟着n条弧走,我们将看到n+1个顶点。根据鸽巢原理,我们必然会看到重复的顶点。例如,也许在我从这个顶点取走出弧后,我回到了之前见过的某个顶点。
那么,我们做了什么?当我们通过追踪这些出弧并重复访问一个顶点时,我们展示了一个有向环。而这正是我们假设不存在的东西。我们讨论的是有向无环图。换句话说,我们刚刚证明了一个没有汇点的图必须有一个有向环。因此,一个有向无环图必须至少有一个汇点。
现在,我们利用这个非常简单的观察来计算有向无环图的拓扑排序。
让我们做一个小思想实验。假设这个图确实有一个拓扑排序。让我们想想在这个拓扑排序中排在最后的顶点。
记住,任何在排序中向后走的弧都是违规的,所以我们必须避免这种情况,我们必须确保每条弧在排序中都向前走。现在,对于任何有出弧的顶点,我们最好把它放在除了最后位置以外的某个地方,对吧?因为我们放在最后位置的节点,它的所有出弧最终都会在拓扑排序中向后走。它们没有别的地方可去,这个顶点是最后一个。换句话说,如果我们计划成功计算一个拓扑排序,排序中最后位置的唯一候选顶点就是汇点。只有汇点才能放在那里。如果我们把一个非汇点放在那里,那就完了,不可能成功。
幸运的是,如果它是有向无环的,我们知道存在一个汇点。
所以,让 v 是图G的一个汇点。如果有多个汇点,我们任意选择一个。我们将 v 的标签设置为可能的最大值。所以如果有n个顶点,我们将把它放在第n个位置。然后我们就在图的其余部分上递归,它只有n-1个顶点。
这在右边的例子中如何工作呢?在第一次迭代或最外层的递归调用中,唯一的汇点是这个用绿色圈出的最右边的顶点。总共有四个顶点,我们将给它标签4。
然后,在标记了那个为4之后,我们删除那个顶点以及所有与它相连的边,然后在图的剩余部分上递归。那将是左边三个顶点加上最左边的两条边。
现在,在我们删除了顶点4及其所有关联边之后,这个图有两个汇点。所以这个顶部顶点和底部顶点在剩余图中都是汇点。所以在下一个递归调用中,我们可以选择其中任何一个作为我们的汇点,因为我们有两个选择,这会产生两个拓扑排序,这正是我们在例子中看到的那两个。但是,例如,如果我们选择这个顶点作为我们的汇点,那么它得到标签3。然后我们只在最西北的两个边上递归。这个顶点是该图中唯一的汇点,它得到标签2。然后我们在只有一个节点的图上递归,它得到标签1。
为什么这个算法有效?我们只需要两个快速的观察。首先,我们需要论证,在每一次迭代或每一次递归调用中,我们确实可以找到一个汇点,可以将其分配到尚未填充的最后位置。原因在于,如果你取一个有向无环图并从中删除一个或多个顶点,你仍然会得到一个有向无环图。你不可能仅仅通过去掉一些东西来创建环,你只能破坏环。我们开始时没有环,所以在所有中间的递归调用中,我们都没有环。根据我们的第一个观察,总是存在一个汇点。
其次,我们必须论证我们确实产生了一个拓扑排序。记住这意味着什么:对于图的每一条边,它在排序中都向前走,即弧的头部被分配的位置比弧的尾部晚。
这简单地源于我们总是使用汇点。考虑被分配到位置 i 的顶点 v。这意味着当我们只剩下 i 个顶点时,v 是一个汇点。那么,当只剩下前 i 个顶点时,v 是汇点,它在原始图中具有什么属性呢?这意味着它所有的出弧都必须指向那些已经被删除并分配了更高位置的顶点。因此,对于每个顶点,当它实际被分配一个位置时,它是一个汇点,并且它只有来自尚未分配位置的顶点的入弧。它的出弧都向前指向那些已经被分配了更高位置并且之前已从图中删除的顶点。
深度优先搜索算法
现在,我们已经掌握了一个相当合理的解决方案,用于计算有向无环图的拓扑排序。特别是,记住我们观察到,如果一个图确实有有向环,那么当然不可能有拓扑排序。无论你如何排序顶点,环上的某些边都必须向后走。而上一张幻灯片的解决方案表明,只要没有环,就保证确实存在拓扑排序。事实上,它是一个构造性证明,一个给出算法的构造性论证:你所做的就是不断地一个一个地去掉汇点,并从右到左填充排序,就像你不断地剥离这些汇点一样。
这是一个相当好的算法,速度不算太慢。实际上,如果你恰当地实现它,你甚至可以让它在线性时间内运行。但我想通过一个深度优先搜索的应用来结束这个视频,这是一个非常巧妙、非常高效的计算有向无环图拓扑排序的方法。
我们只需要对我们之前的深度优先搜索子程序做两个相当小的修改。第一件事是,我们必须将它嵌入到一个for循环中,就像我们在计算无向图的连通分量时对广度优先搜索所做的那样。这是因为在计算拓扑排序时,我们最好给每个顶点一个标签,我们最好至少查看每个顶点一次。为了做到这一点,我们只需确保有一个外层的for循环。然后,如果我们有多个分量,我们只需根据需要多次调用DFS。第二件事是,我们将添加一点簿记工作,这将确保每个节点都得到一个标签,实际上这些标签将定义一个拓扑排序。
让我们不要忘记深度优先搜索的代码。这里你被给定一个图G(在这种情况下我们感兴趣的是有向无环图)和一个起始顶点 s。
你所做的是,一旦到达 s,就非常积极地开始尝试探索它的邻居。当然,你不会访问任何你已经去过的顶点。你记录你访问过谁。如果你发现任何你以前没有见过的顶点,你立即开始递归调用那个节点。
我说过,我们需要做的第一个修改是将其嵌入到外层的for循环中,以确保每个节点都得到标记。我将称那个子程序为 DFS-Loop。它不接收起始顶点。初始化时,所有节点都未被探索。我们还将跟踪一个全局变量,我称之为 current_label。它将被初始化为 n,每当我们完成探索一个新节点时,我们将递减它。这些将恰好是 f 值,这些将是我们输出的拓扑排序中顶点的确切位置。

在主循环中,我们将遍历图的所有节点。例如,我们只需扫描节点数组。像往常一样,我们不想做任何重复的工作。所以,对于已经在之前的DFS调用中被探索过的顶点,我们不再从它开始搜索。当我们计算无向图的连通分量时,将广度优先搜索嵌入for循环中,这应该都很熟悉。如果我们到达一个尚未探索过的图的顶点 v,那么我们就调用DFS,以该顶点作为起点。
最后我需要添加的是,我需要告诉你 f 值是什么,即顶点到位置的实际分配是什么。正如我所预示的,我们将使用这个全局的 current_label 变量,它将让我们从右到左为顶点分配位置,非常模仿我们在递归解决方案中一个一个去掉汇点的过程。
那么,分配顶点位置的正确时机是什么时候呢?事实证明,正确的时机是我们完全完成对该顶点的处理时,也就是我们即将从栈中弹出对应于该顶点的递归调用时。
所以,在我们遍历完给定顶点的所有出边的for循环之后,我们设置 f(s) 等于当前的 current_label,然后我们递减 current_label。
就是这样。这就是整个算法。那么,声称的是,产生的 f 值(你会注意到将是n到1之间的整数,因为DFS最终会在每个顶点上被调用一次,并且在结束时它将得到某个整数赋值,每个人都将得到一个不同的值,最大的是n,最小的是1)将是一个拓扑排序。
显然,这个算法和DFS本身一样快得惊人,只增加了一点微不足道的额外簿记工作。
让我们看看它在我们的运行示例中是如何工作的。假设我们有这个我们已经相当熟悉的四节点有向图。它有四个顶点,所以我们将 current_label 变量初始化为等于4。
假设在外层的DFS循环中,我们从某个顶点比如 V 开始。注意,在外层的for循环中,我们最终以完全任意的顺序考虑顶点。假设我们首先从顶点 V 调用DFS。会发生什么?从 V 唯一能去的地方是 T,然后在 T 无处可去。所以,我们递归调用DFS在 T,没有边可以遍历,我们完成了for循环,所以 T 将被分配一个等于当前标签的 f 值,当前标签是 n,这里 n 是顶点数,即4。所以 f(T) 将得到...抱歉,T 将得到赋值,标签4。然后现在我们完成了 T,我们回溯到 V。当我们完成 T 时,我们递减当前标签。我们回到 V,现在没有更多的弧可以探索了,所以for循环结束,所以我们完成了深度优先搜索,所以它得到新的当前标签,现在是3。再次,完成 V 后,我们递减当前标签,现在降到2。
现在我们回到外层的for循环,也许我们考虑的下一个顶点是顶点 T,但我们已经去过那里了,所以我们不会在 T 上调用DFS。然后在那之后,我们尝试在 S 上调用。所以也许 S 是for循环考虑的第三个顶点。我们还没有见过 S,所以我们从顶点 S 开始调用DFS。
从 S,有两条弧可以探索。一条到 V,V 我们已经见过,所以弧 S->V 不会发生任何事情。但另一方面,弧 S->W 将导致我们递归调用DFS在 W 上。从 W,我们尝试查看从 W 到 T 的弧,但我们已经去过 T 了,所以我们什么都不做。这样就完成了 W 的处理。所以深度优先搜索然后在顶点 W 完成。W 得到当前标签的赋值,所以 f(W) = 2。我们递减当前标签,现在它的值是1。现在我们回溯到 S,我们已经考虑了 S 的所有出弧,所以我们完成了 S,它得到当前标签,即1。这确实是我们几页幻灯片前展示的这个图的两个拓扑排序之一。
算法分析与总结
以上就是算法的完整描述以及它在具体示例中的工作原理。让我们讨论一下它的关键属性、运行时间和正确性。
就运行时间而言,这个算法的运行时间是线性的,这正是你想要的。运行时间是线性的原因与这些图搜索算法通常在线性时间内运行的通常原因相同:你明确地跟踪你去过哪些节点,这样你就不会两次访问它们,所以你只对每个节点做常数量的工作;并且在有向图中,每条边实际上只在访问该边的尾部时查看一次,所以你也只对每条边做常数量的工作。
当然,另一个关键属性是正确性,即我们需要证明你保证能得到一个拓扑排序。
这意味着什么?这意味着每条边、每条弧在排序中都向前走。所以如果 (u, v) 是一条边,那么 f(u)(分配给 u 的标签)小于分配给 v 的标签。
正确性证明分为两种情况,取决于深度优先搜索首先访问顶点 u 和 v 中的哪一个。由于我们的for循环会遍历图G的所有顶点,深度优先搜索将恰好从每个顶点被调用一次。u 或 v 都可能先被访问,两者都是可能的。

首先,假设 u 在 v 之前被DFS访问。那么会发生什么?记住深度优先搜索的作用:当你从一个节点调用它时,它将找到从该节点可到达的所有东西。所以如果 u 在 v 之前被访问,那意味着 v 还没有被探索,所以它是可以被发现的候选。此外,有一条直接从 u 到 v 的弧。所以从 u 调用的DFS肯定会发现 v。而且,对应于节点 v 的递归调用将在 u 的递归调用之前完成并从程序栈中弹出。理解这一点的最简单方法是思考深度优先搜索的递归结构。当你从 u 调用深度优先搜索时,那个递归调用将对所有相关的邻居(包括 v)进行进一步的递归调用,并且 u 的调用在 v 的调用完成之前不会从栈中弹出,这是因为栈或递归算法的后进先出性质。
因为 v 的递归调用在 u 之前完成,这意味着它将获得比 u 更大的标签。记住,随着越来越多的递归调用从栈中弹出,标签不断递减。所以这正是我们想要的。
现在,第二种情况是什么?情况二:v 在 u 之前被访问。这里我们利用了图没有环的事实。因为有一条从 u 到 v 的直接弧,这意味着不可能有任何从 v 一路回到 u 的有向路径,否则就会创建一个有向环。
因此,从 v 调用的DFS不会发现 u。从 v 到 u 没有有向路径(同样,如果有,就会是一个有向环)。所以它根本找不到 u,所以 v 的递归调用再次会在 u 甚至被压入栈之前就完成。所以在我们甚至开始考虑 u 之前,我们就已经完全完成了 v 的处理。因此,出于同样的原因,由于 v 的递归调用先完成,它的标签将更大,这正是我们想要证明的。
本节课中,我们一起学*了拓扑排序的概念及其在有向无环图中的应用。我们探讨了拓扑排序存在的必要条件(无环),并学*了两种计算它的算法:一种是基于不断移除汇点的直观方法,另一种则是利用深度优先搜索的巧妙且高效的方法。后者通过在DFS递归调用完成时从后向前分配标签,实现了线性时间的拓扑排序计算。
051:计算强连通分量-算法 🔗
概述
在本节课中,我们将学*如何为有向图计算强连通分量。我们已经掌握了在线性时间内计算无向图连通分量的方法,现在将注意力转向有向图。好消息是,我们同样可以获得一个极其快速的算法来计算有向图的连通性信息。我们将学*一个基于深度优先搜索的线性时间算法,其常数因子非常小。
强连通分量的定义
上一节我们介绍了无向图的连通分量,本节中我们来看看如何定义有向图的“连通块”。对于有向图,我们通常研究的是强连通性。


一个图是强连通的,如果你可以从任意一点通过有向路径到达任意其他点,反之亦然。强连通分量则是图中内部强连通的最大区域,即从区域内任意节点A到任意节点B都存在有向路径。
更正式地,我们可以在图的节点上定义一个等价关系。我们说节点U与节点V相关,如果存在从U到V的有向路径,并且也存在从V到U的有向路径。强连通分量就是这个等价关系的等价类。

算法动机与挑战
以下是理解算法为何有效的一些关键点:
- 从正确节点开始DFS的重要性:如果我们从一个强连通分量内部的节点开始深度优先搜索,DFS将发现该分量内的所有节点。然而,如果从一个“错误”的节点开始,DFS可能会发现多个强连通分量的并集,甚至整个图,这无法揭示分量结构。
- 算法的核心思想:Kosaraju算法的巧妙之处在于,它通过一个预处理步骤(本身也是一次DFS)来计算出后续DFS应该从哪些节点开始,以确保每次调用DFS都能恰好发现一个强连通分量。
Kosaraju算法 🧠
Kosaraju算法证明了以下定理:有向图的强连通分量可以在线性时间O(m + n)内计算出来,其中m是边数,n是节点数。该算法本质上只是两次深度优先搜索。

算法包含三个步骤:
- 反转所有弧:将有向图
G的所有边方向反转,得到反向图G_rev。 - 在反向图上进行第一次DFS:在
G_rev上运行深度优先搜索(DFS-Loop)。这次搜索的主要目的是计算每个节点的完成时间。 - 在原图上进行第二次DFS:在原始图
G上再次运行深度优先搜索(DFS-Loop)。但这次,我们按照完成时间递减的顺序来处理节点。在这次搜索中,我们会为每个节点标记一个领导者,属于同一个强连通分量的节点将具有相同的领导者。
算法子程序详解
算法的核心是DFS-Loop子程序,它会被调用两次。

全局变量:
t:全局时间计数器,用于在第一次DFS中记录节点的完成顺序(完成时间)。s:全局领导者变量,用于在第二次DFS中记录当前DFS调用的发起节点。
DFS-Loop(Graph G) 伪代码:
t = 0
s = NULL
for each vertex i in G (processed in a specified order):
if i is not explored:
s = i
DFS(G, i)
DFS(Graph G, start node i) 伪代码:
标记 i 为已探索
设置 leader[i] = s // 记录i的领导者是当前DFS的源点s
for each edge (i, j) in G.outgoingEdges(i):
if j is not explored:
DFS(G, j)
// 所有从i出发的边都已处理完毕
t = t + 1
设置 finishing_time[i] = t // 记录i的完成时间
两次调用DFS-Loop的区别:
- 第一次调用(在
G_rev上):- 处理节点的顺序:按节点原始编号
n, n-1, ..., 1。 - 目的:计算
finishing_time[]。忽略leader[]。
- 处理节点的顺序:按节点原始编号
- 第二次调用(在
G上):- 处理节点的顺序:按
finishing_time值递减的顺序(即完成时间最晚的节点最先处理)。 - 目的:计算
leader[]。忽略finishing_time[](它们已在上一步计算好)。
- 处理节点的顺序:按

算法示例演示
让我们通过一个具体例子来理解算法如何工作。

第一步:在反向图上计算完成时间
假设我们有一个9个节点的有向图,并已将其反向。节点初始编号为1到9。我们在反向图上运行DFS-Loop,按编号9到1的顺序处理节点。

以下是DFS探索的一种可能顺序及其结果:
- 从节点9开始DFS,最终探索顺序可能为:9 -> 6 -> 3 -> (回溯) -> 6 -> 8 -> 2 -> 5 -> (回溯) -> 2 -> 8 -> 6 -> 9。
- 然后从节点7开始新的DFS:7 -> 4 -> 1 -> (回溯) -> 4 -> 7。
最终计算出的完成时间(finishing_time)可能如下(节点:完成时间):
- 节点3: 1
- 节点5: 2
- 节点2: 3
- 节点8: 4
- 节点6: 5
- 节点9: 6
- 节点1: 7
- 节点4: 8
- 节点7: 9
关键点:完成时间反映了在反向图G_rev上DFS结束处理每个节点的顺序。
第二步:在原图上按完成时间顺序进行DFS并寻找领导者
现在,我们回到原始图G(将边方向恢复)。我们不再使用节点原始编号,而是使用上一步计算出的完成时间作为节点的新标签。
然后,我们在G上运行第二次DFS-Loop,但处理节点的顺序是按新标签(完成时间)从大到小,即:节点9(原节点7)-> 节点8(原节点4)-> ... -> 节点1(原节点3)。
过程如下:
- 从节点9(原7)开始DFS。它只能访问节点9、8、7(按新标签)。这些节点获得领导者 9。这恰好是一个强连通分量。
- 外层循环继续。下一个未探索的、标签最大的节点是节点6(原9)。从它开始DFS,新探索节点6、1、5(按新标签)。它们获得领导者 6。这是另一个强连通分量。
- 下一个未探索的、标签最大的节点是节点4(原6)。从它开始DFS,新探索节点4、2、3(按新标签)。它们获得领导者 4。这是最后一个强连通分量。
最终,每个节点都被赋予了一个领导者,相同领导者的节点集合就构成了一个强连通分量。
算法性能与总结
性能分析:Kosaraju算法的主体是两次深度优先搜索。每次DFS的时间复杂度是O(m+n)。额外的簿记操作(如记录完成时间、领导者)是常数时间或线性时间。因此,总时间复杂度为O(m+n),非常高效。
实现细节:在第二次DFS时,要按完成时间递减顺序处理节点,无需排序(O(n log n))。可以在第一次DFS后,直接将节点按完成时间存入一个数组,第二次循环时逆序读取即可,只需O(n)时间。

总结:本节课我们一起学*了Kosaraju算法,这是一个用于计算有向图强连通分量的优雅而高效的算法。其核心在于:
- 通过在反向图上进行DFS,计算出一个神奇的节点处理顺序(完成时间)。
- 然后在原图上按此顺序(完成时间递减)进行DFS,每次DFS调用会自动“剥离”出一个完整的强连通分量,并通过领导者标签记录下来。
这个算法充分利用了深度优先搜索的特性,以线性时间解决了问题,是图算法中的一个经典范例。
052:计算强连通分量-分析 🔍
在本节课中,我们将学*并证明Kosaraju两遍深度优先搜索算法的正确性。该算法能以线性时间计算有向图的强连通分量。我们将从理解算法的核心思想开始,逐步剖析其工作原理,并最终完成严谨的证明。
有向图的元图结构 🌐

上一节我们介绍了强连通分量的概念,本节中我们来看看有向图在更高层次上的结构。
每个有向图都有两个层次的粒度。从宏观角度看,其强连通分量自然地诱导出一个有向无环图,我们称之为元图。
以下是元图的定义:
- 元图的节点:每个强连通分量本身被视为元图中的一个节点。例如,
C1, C2, ..., Ck。 - 元图的边:如果在原图
G中,存在一条从强连通分量C中的某个节点指向强连通分量C'中某个节点的边,那么在元图中就存在一条从C指向C'的有向边。
公式:设 G = (V, E) 为有向图,其强连通分量为 {C1, C2, ..., Ck}。则其诱导的元图 G_SCC = (V_SCC, E_SCC) 定义为:
V_SCC = {C1, C2, ..., Ck}E_SCC = {(Ci, Cj) | 存在 u ∈ Ci, v ∈ Cj,使得 (u, v) ∈ E}

为什么这个元图保证是无环的?假设元图中存在一个环,例如 C1 -> C2 -> ... -> Cm -> C1。这意味着在原图中,你可以从 C1 到达 C2,从 C2 到达 C3,...,最终从 Cm 回到 C1。根据强连通分量的定义,C1, C2, ..., Cm 中的所有节点实际上都可以互相到达,因此它们应该属于同一个强连通分量,这与它们是不同分量的假设矛盾。
这个事实不仅对计算强连通分量有用,也帮助我们理解有向图的整体结构:在强连通分量内部,结构可能非常复杂,但在分量之间,结构是简单的有向无环图。
反向图与强连通分量 🔄

为了深入理解Kosaraju算法,我们需要思考反向操作对强连通分量的影响。
考虑以下问题:将有向图 G 的所有边反向,得到图 G_rev。G_rev 的强连通分量与 G 的强连通分量有何关系?
正确答案是:强连通分量完全相同。
原因在于,强连通关系是双向的。如果两个节点 u 和 v 在 G 中强连通(即存在 u 到 v 和 v 到 u 的路径),那么在 G_rev 中,只需将这两条路径反向,就得到了 v 到 u 和 u 到 v 的路径,因此它们仍然强连通。反之,如果 u 和 v 在 G 中不强连通,那么在 G_rev 中也不会强连通。
因此,在Kosaraju算法中,第一遍在反向图上进行DFS所揭示的强连通分量结构,与第二遍在原始图上进行DFS时面对的强连通分量结构是完全一致的。

关键引理及其推论 📝
理解了元图结构和反向图的性质后,我们现在可以陈述驱动Kosaraju算法正确性的核心引理。
关键引理:考虑原图 G 中两个“相邻”的强连通分量 C1 和 C2。所谓“相邻”,是指存在一条从 C1 中某个节点 i 指向 C2 中某个节点 j 的边(即 (i, j) ∈ E)。
现在,我们在反向图 G_rev 上运行第一遍DFS,并计算每个节点的完成时间 f(v)。
令 f_max(C) 表示强连通分量 C 中所有节点的最大完成时间。
那么,引理断言:f_max(C2) > f_max(C1)。

注意:完成时间是在反向图
G_rev上计算的。在G_rev中,边(j, i)存在,但C1和C2作为节点集合保持不变。
我们先假设这个引理成立,并探讨其直接推论,这将帮助我们理解算法的整体流程。
推论:在整个图 G 中,具有最大完成时间的节点必然位于一个汇点强连通分量中。
汇点强连通分量是指在元图 G_SCC 中没有出边的分量(即没有边指向其他SCC)。
推论的证明(反证法):
- 设
C_max是拥有最大完成时间f_max的强连通分量。 - 假设
C_max不是汇点SCC,即它在元图中有一条出边指向另一个SCC,记为C_next。 - 根据关键引理,因为存在从
C_max到C_next的边(在原图中),所以f_max(C_next) > f_max(C_max)。 - 这与
C_max拥有最大完成时间的假设矛盾。
因此,C_max必须是一个汇点SCC。
这个推论至关重要。它告诉我们,在第一遍DFS计算出的顺序中,完成时间最大的节点位于一个“无处可去”的分量里。
算法正确性证明 🏆
基于关键引理及其推论,我们现在可以证明Kosaraju算法的正确性。

回忆一下,算法的核心挑战在于:从何处开始DFS才能恰好发现一个完整的强连通分量,而不会混入其他分量?
- 坏的起点:如果从一个源点SCC(在元图中没有入边的分量)开始DFS,由于它能到达图中很多其他节点,DFS会探索出一大片区域,无法区分出单个SCC。
- 好的起点:如果从一个汇点SCC(在元图中没有出边的分量)开始DFS,那么DFS探索将仅限于该分量内部,因为没有任何边可以“逃逸”到其他分量。
Kosaraju算法的巧妙之处在于,第一遍在反向图上的DFS所生成的按完成时间降序排列的节点顺序,恰好保证了第二遍DFS总是从汇点SCC开始。
以下是证明步骤:
-
第一步(发现第一个SCC):
- 根据推论,全局完成时间最大的节点
v位于某个汇点SCC,记为C*。 - 在第二遍DFS中,我们首先从
v开始探索。 - 因为
C*是汇点SCC,从v出发的DFS只能访问到C*内部的节点,无法到达其他SCC。因此,这次DFS调用将恰好发现C*中的所有节点。
- 根据推论,全局完成时间最大的节点
-
递归步骤(剥离并重复):
- 一旦
C*中的所有节点都被标记为“已探索”,在后续的DFS调用中,它们将被忽略。 - 此时,我们可以将
C*从图中“删除”,考虑剩余的图G'。 - 在
G'中,我们再次考虑剩余节点中完成时间最大的节点。对于这个剩余图,关键引理依然成立(因为元图结构是层次化的)。因此,这个节点必然位于G'的某个汇点SCC中。 - 于是,下一次DFS调用又将从一个(新图环境下的)汇点SCC开始,并恰好发现该SCC的所有节点。
- 一旦
-
归纳过程:
- 上述过程不断重复。每一次,第二遍DFS的外层循环(按完成时间降序)都会选取当前剩余图中完成时间最大的节点,该节点必然位于当前剩余图的一个汇点SCC中,从而DFS恰好发现该SCC。
- 从元图的角度看,算法实际上是按照逆拓扑序依次剥离各个强连通分量。
因此,只要关键引理成立,Kosaraju算法就能正确找出所有强连通分量。

关键引理的证明 🧠
现在,我们来填补证明的最后一块拼图:证明关键引理本身。
引理重述:设 C1 和 C2 是原图 G 中两个不同的SCC,且存在边 (i, j) ∈ E,其中 i ∈ C1, j ∈ C2。令 f(v) 为在反向图 G_rev 上运行第一遍DFS得到的完成时间。则 max_{x ∈ C2} f(x) > max_{y ∈ C1} f(y)。
证明思路:
我们在反向图 G_rev 的上下文中进行推理。在 G_rev 中,存在边 (j, i),但SCC集合 C1 和 C2 保持不变。
考虑第一遍DFS(在 G_rev 上)首次探索到 C1 ∪ C2 中任意节点的时刻。有两种情况:
情况一:首次探索到的节点 v ∈ C1。
- 由于在元图中,从
C2到C1有边(对应原图的(i, j)反向),并且元图是无环的,因此不可能存在从C1到C2的路径(否则会形成环,导致C1和C2合并)。 - 因此,从
v开始的DFS探索将完全局限于C1内部,在探索完C1的所有节点之前,不会接触到C2的任何节点。 - 所以,
C1中所有节点的完成时间,都将小于后续任何C2中节点的完成时间。故f_max(C2) > f_max(C1)。
情况二:首次探索到的节点 v ∈ C2。
- 此时,我们从
C2中的一个节点开始DFS。 - 由于存在边
(j, i) ∈ G_rev(j ∈ C2,i ∈ C1),并且C1和C2各自强连通,因此从v出发,可以到达C2和C1中的所有节点。 - 深度优先搜索的特性是:一个节点
v的DFS调用只有在其所有可达节点都被完全探索后才会结束(即赋予完成时间)。 - 因此,节点
v的完成时间f(v),将是所有从v可达节点(包括整个C1和C2)中最后被完成的。 - 这意味着
f(v)大于C1中所有节点的完成时间。由于v ∈ C2,所以f_max(C2) ≥ f(v) > f_max(C1)。
无论在哪种情况下,结论都是 f_max(C2) > f_max(C1)。引理得证。
总结 📚
本节课中我们一起学*了Kosaraju算法正确性的完整证明。我们来回顾一下核心要点:
- 元图视角:任何有向图的强连通分量都诱导出一个有向无环图(DAG),这简化了我们对图结构的理解。
- 反向图性质:反转有向图的所有边,不会改变其强连通分量。
- 关键引理:对于存在边相连的两个SCC,在反向图DFS的完成时间上,下游SCC的最大完成时间总是大于上游SCC的。这是算法正确性的基石。
- 算法逻辑:
- 第一遍DFS(在反向图上)计算完成时间,其顺序的魔力在于能“定位”汇点SCC。
- 第二遍DFS(在原始图上)按完成时间降序访问节点,这等价于按逆拓扑序处理元图中的SCC。
- 每次从一个汇点SCC开始DFS,恰好能完整剥离该SCC,而不会越界。
- 深度优先搜索的作用:关键引理的证明(尤其是情况二)深度依赖于DFS“彻底探索所有可达节点后才回溯”的特性,这是BFS不具备的。
Kosaraju算法是一个高效、优雅的图算法范例,它巧妙地运用了图的基本性质(强连通分量、元图DAG)和DFS的特性,在线性时间内解决了有向图强连通分量的计算问题。
053:网络结构分析(选学)

在本节中,我们将探讨如何将图算法应用于超大规模图的分析,特别是以万维网(Web)图的结构研究为例。我们将了解强连通分量(SCC)算法如何帮助揭示互联网的宏观结构,并讨论相关的“小世界”现象。
什么是Web图?

Web图是一个有向图,其中:
- 顶点 对应网页。
- 有向边 对应从一个网页指向另一个网页的超链接。
例如,一个个人主页可能包含指向其研究论文、课程页面甚至喜欢的唱片店的链接。这些链接构成了有向边,尾部是包含链接的页面,头部是链接指向的页面。整个互联网就是由数十亿这样的页面和链接构成的巨大有向图。
分析Web图的挑战
理解Web图结构的主要挑战在于其巨大的规模。即使在互联网的“石器时代”(约2000年),一次典型研究分析的数据集也包含约2亿个节点和15亿条边。
在当时的计算环境下(没有MapReduce、Hadoop等现代大数据处理工具),研究人员必须从头开始设计算法。一个关键问题是:在计算资源有限的情况下,我们能获取关于这个图的最详细结构信息是什么?
上一节我们介绍了图的基本概念,本节中我们来看看一个核心的图算法如何被用于解决这个实际问题。

强连通分量(SCC)算法的应用
我们学过的线性时间强连通分量(SCC)算法成为了回答上述问题的理想工具。它可以在合理的时间内,为整个Web图计算出完整的连通性信息。
研究人员(以Andrei Broder等人为代表)对Web图运行SCC算法后,发现其结构可以用一个“领结”模型来形象描述。
领结模型的结构
以下是领结模型的各个组成部分:
-
核心(Giant SCC):这是一个巨大的强连通分量,可以被视为Web的“核心”。在此区域内的任意两个网页,都可以通过一系列超链接相互到达。研究表明,这个核心大约包含了全部节点的28%。
-
IN部分:这些是能到达核心,但无法从核心到达的强连通分量集合。例如,一个全新创建并链接到核心网站,但尚未被核心网站反链的页面,就可能位于IN部分。
-
OUT部分:这些是能从核心到达,但无法返回核心的强连通分量集合。例如,一些公司网站的政策禁止链接到站外,使得其内部虽然强连通,但成为了一个“只进不出”的区域。


-
卷须与管子(Tendrils & Tubes):
- 卷须:指那些与IN或OUT部分相连,但既不与核心相连,也不彼此连接的区域。
- 管子:指那些直接从IN部分连接到OUT部分,完全绕过核心的路径。
-
孤岛(Disconnected Components):一些与其他部分完全断开连接的独立强连通分量。
一个有趣的发现是,核心、IN、OUT以及剩余部分(卷须、管子等)的规模大致相当,各占约四分之一。
小世界现象
虽然Web核心(巨型SCC)的规模可能比预期小,但其内部的连接却异常高效,体现了“小世界”属性。
“小世界”现象俗称“六度分隔”,源于社会心理学家Stanley Milgram在1967年的著名实验。实验发现,平均只需通过5到6个中间人,就能将一封信从美国内布拉斯加州的陌生人传递到马萨诸塞州波士顿的一位特定医生手中。
在网络科学中,“小世界”属性意味着:
- 网络中任意两点间通常存在很短的路径。
- 更重要的是,无需全局信息,仅凭本地决策(例如,将信息传递给看似更接*目标的人),就能高效地找到这些短路径。
Web的巨型SCC就具有这种属性:内部链接丰富,短路径众多,信息路由相对容易。
网络科学的其他研究方向
强连通分量分析只是算法与信息网络研究交叉的一个早期且经典的例子。当前该领域还有许多活跃且有趣的方向:

- 网络演化模型:Web是动态变化的。如何建立数学模型来模拟新页面、新链接的产生与消失过程?
- 信息传播动力学:研究信息(如新闻、观点、病毒式内容)如何通过Web图或社交网络(如Facebook、Twitter)传播。
- 社区发现:如何自动、可靠地识别网络中联系紧密的节点群(即“社区”)?这比简单的图割方法要复杂得多。
这些问题不仅在数学和技术层面极具吸引力,也能帮助我们更好地理解我们所处的世界。

总结
本节课中我们一起学*了:
- Web图是一个以网页为顶点、超链接为有向边的大规模图。
- 利用强连通分量(SCC)算法,可以高效分析其宏观结构,并发现其符合“领结模型”,包含核心、IN、OUT、卷须和管子等部分。
- Web的核心部分具有“小世界”属性,节点间路径短且易于路由。
- 图算法是分析复杂网络的基础,在网络演化、信息传播和社区发现等前沿研究中扮演着核心角色。
若您希望深入了解网络、人群与市场,推荐阅读David Easley和Jon Kleinberg的著作《Networks, Crowds, and Markets》。
054:Dijkstra最短路径算法



概述
在本节课中,我们将要学*计算机科学中的一个经典算法——Dijkstra最短路径算法。该算法用于解决单源最短路径问题,即在给定一个有向图(或无向图)、每条边具有非负长度以及一个源点的情况下,计算从源点到图中所有其他顶点的最短路径长度。
问题定义
我们有一个包含 N 个顶点和 M 条边的图。每条边 E 都有一个非负的长度 L(E)。我们还有一个指定的起始顶点,称为源点 S。我们的目标是计算从源点 S 到图中每一个其他顶点 V 的最短路径距离。
一条路径的长度是其包含的所有边的长度之和。例如,一条包含三条边(长度分别为1、2、3)的路径,其总长度为 1 + 2 + 3 = 6。最短路径距离是所有从 S 到 V 的路径中长度最小的那个。
为了简化讨论,我们假设从源点 S 到图中所有其他顶点都存在路径。同时,一个至关重要的假设是:图中所有边的长度都是非负的。如果存在负长度的边,Dijkstra算法将无法保证正确性。
为何需要新算法?
你可能会想到,我们之前学过的广度优先搜索(BFS) 也能计算最短路径。确实如此,但BFS仅在所有边长度均为1的特殊情况下有效。在边长度各不相同的一般情况下(例如在道路导航应用中,不同道路的里程或通行时间不同),BFS无法给出正确的最短路径。

一种想法是将一条长度为 L 的边替换为由 L 条长度为1的边组成的路径,从而将问题转化为BFS可解的问题。然而,当边长度可能非常大时(例如1000),这种转换会极大地增加图的规模,导致算法效率低下。因此,我们需要一个能直接在原图上高效运行的算法,这正是Dijkstra算法要解决的问题。
Dijkstra算法核心思想
Dijkstra算法可以看作是BFS在边权非负情况下的推广。其核心思想是贪心地逐步确定从源点到其他顶点的最短距离。

算法维护两个集合:
- 集合 X:已经确定了最终最短路径距离的顶点集合。
- 数组 A:存储从源点 S 到每个顶点 V 的当前最短路径距离估计值。
- 数组 B(辅助理解):存储从源点 S 到每个顶点 V 的当前最短路径本身(实际实现中通常不需要)。
算法从源点 S 开始,初始化 A[S] = 0,B[S] 为空路径,并将 S 加入 X。然后,算法重复以下步骤,直到 X 包含所有顶点:
- 观察所有从 X 内部指向 X 外部的边(即“跨越边界”的边)。
- 对于每一条这样的边 (V, W)(其中 V 在 X 中,W 不在 X 中),计算一个得分:
A[V] + L(V, W)。这表示从 S 到 V 的已知最短距离,加上从 V 直接到 W 的边的长度。 - 在所有跨越边界的边中,选出得分最小的那条边,记为 (V, W)。
- 将顶点 W* 加入集合 X。
- 将
A[W*]的值设置为这个最小得分,即A[V*] + L(V*, W*)。 - 将
B[W*]设置为路径B[V*]后接边 (V, W)。
直观上,我们每次都选择“看起来”离源点最*的那个尚未处理的顶点(W*),并确认当前到达它的路径就是最短路径。
算法伪代码与示例
以下是算法的高层伪代码描述:
初始化:
X = {S}
A[S] = 0
B[S] = 空路径
For 所有其他顶点 v:
A[v] = 无穷大
B[v] = 未定义

While (X 不等于所有顶点集合 V):
找到边 (v*, w*),其中 v* 在 X 中,w* 不在 X 中,且使得 A[v*] + L(v*, w*) 最小
将 w* 加入 X
A[w*] = A[v*] + L(v*, w*)
B[w*] = B[v*] + 边 (v*, w*)
让我们通过一个简单例子来理解算法过程。考虑下图,源点为 S,边上的数字表示长度。
S --1--> V
| |
4 2
| |
v v
W --3--> T

算法执行步骤如下:
- 初始:
X = {S},A[S]=0。 - 迭代1:跨越边为 (S, V) 得分 0+1=1, (S, W) 得分 0+4=4。最小得分边为 (S, V)。将 V 加入 X,
A[V]=1,B[V] = S->V。 - 迭代2:
X = {S, V}。跨越边有 (S, W) 得分4, (V, W) 得分 1+2=3, (V, T) 得分 1+2=3(假设有边V->T)。最小得分边为 (V, W)(得分3)。将 W 加入 X,A[W]=3,B[W] = S->V->W。 - 迭代3:
X = {S, V, W}。跨越边有 (W, T) 得分 3+3=6, (V, T) 得分 1+2=3。最小得分边为 (V, T)(得分3)。将 T 加入 X,A[T]=3,B[T] = S->V->T。 - 结束。最终得到最短路径距离:
A[S]=0,A[V]=1,A[W]=3,A[T]=3。

关键假设:非负边权
Dijkstra算法的正确性严重依赖于边长度非负的假设。如果图中存在负长度的边,该算法可能得出错误结果。
考虑一个反例:图中有三个顶点 S, A, B。边为:S->A 长度 1,S->B 长度 2,A->B 长度 -2。从 S 到 B 的最短路径是 S->A->B,长度为 1 + (-2) = -1。但Dijkstra算法会先处理 A(因为 A[S->A]=1 小于 A[S->B]=2),将 A 加入 X 并设置 A[A]=1。然后,在处理从 X 出发的边时,它会通过 A->B 更新 A[B] 为 1 + (-2) = -1。然而,此时 B 可能已经被错误的距离(2)处理过并加入了 X,算法不会再去修正它。或者,在另一种实现中,它可能得出错误的最短路径。总之,负权边会破坏算法的贪心选择策略。
对于包含负权边的图,需要使用其他算法,如 Bellman-Ford 算法。
总结
本节课我们一起学*了Dijkstra最短路径算法。我们首先定义了单源最短路径问题,并解释了为何在边权各异的情况下需要超越广度优先搜索的新方法。接着,我们详细阐述了Dijkstra算法的贪心核心思想:维护一个已确定最短距离的顶点集合 X,并反复选择从 X 出发、当前最短距离 + 边权 最小的边所指向的顶点加入 X,同时更新其距离。我们通过伪代码和示例演示了算法的执行过程。最后,我们强调了边权非负这一关键假设对于算法正确性的必要性,并简要提及了存在负权边时的替代算法。
在接下来的课程中,我们将深入探讨Dijkstra算法的正确性证明,并研究如何利用高效的数据结构(如堆)来实现它,从而达到*乎线性的运行时间。
055:Dijkstra算法示例与局限性

在本节课中,我们将通过一个具体示例,详细演示Dijkstra算法如何逐步计算单源最短路径。我们还将探讨该算法的一个关键限制:它无法正确处理包含负权边的图。
算法初始化 🚀
我们从一个简单的图开始,源点为S。算法的初始化步骤是直观的。
- 我们将源点S到自身的最短路径距离
A[S]设为0。 - 源点S到自身的最短路径
B[S]设为空路径。 - 初始时,集合
X(已确定最短路径的顶点集合)仅包含源点S。
现在,我们进入算法的主循环。
第一轮迭代 🔄
上一节我们完成了初始化,本节中我们来看看算法的第一轮迭代。此时,集合 X 中只有源点S。我们需要找出所有“跨越边界”的边,即尾节点在 X 内、头节点在 X 外的边。
以下是第一轮迭代中跨越边界的边:
- 边
S -> V,其长度为1。 - 边
S -> W,其长度为4。

对于每条跨越边界的边 (v, w),我们计算其 Dijkstra贪婪分数,公式为:
贪婪分数 = A[v] + 边(v, w)的长度
其中 A[v] 是到顶点 v 的当前最短路径距离。
- 边
S -> V的贪婪分数为A[S] + 1 = 0 + 1 = 1。 - 边
S -> W的贪婪分数为A[S] + 4 = 0 + 4 = 4。
我们选择贪婪分数最小的边 S -> V。算法执行以下操作:
- 将顶点
V加入集合X。现在X = {S, V}。 - 设置
A[V] = 1(即该边的贪婪分数)。 - 设置
B[V]为B[S]后追加边S -> V,即路径[S -> V]。

第二轮迭代 🔄
在上一轮,我们将顶点V纳入了已知集合。现在,集合 X = {S, V}。我们再次找出所有跨越边界的边。
以下是当前跨越边界的边:
- 边
S -> W(长度4)。 - 边
V -> W(长度2)。 - 边
V -> T(长度6)。
我们计算每条边的贪婪分数:
S -> W:A[S] + 4 = 0 + 4 = 4V -> W:A[V] + 2 = 1 + 2 = 3V -> T:A[V] + 6 = 1 + 6 = 7
贪婪分数最小的边是 V -> W(分数为 3)。算法执行以下操作:
- 将顶点
W加入集合X。现在X = {S, V, W}。 - 设置
A[W] = 3。 - 设置
B[W]为B[V]后追加边V -> W,即路径[S -> V -> W]。
第三轮(最终)迭代 🔄
经过前两轮,只剩下顶点T不在集合 X 中。本轮我们将确定到达T的最短路径。当前跨越边界的边有两条。
以下是最终迭代中跨越边界的边及其贪婪分数:
- 边
V -> T:A[V] + 6 = 1 + 6 = 7 - 边
W -> T:A[W] + 3 = 3 + 3 = 6
边 W -> T 的贪婪分数更小(6)。算法执行最终操作:
- 将顶点
T加入集合X。现在X包含了所有顶点。 - 设置
A[T] = 6。 - 设置
B[T]为B[W]后追加边W -> T,即路径[S -> V -> W -> T]。

至此,算法结束。数组 A 中存储的即为从源点S到所有顶点的最短路径距离。

负权边带来的挑战 ⚠️
我们刚刚看到Dijkstra算法在一个边权均为非负的图上正确工作。然而,该算法有一个重要的前提条件:图中不能有负权边。为什么这是一个问题呢?
一个自然的想法是:能否通过给所有边加上一个足够大的常数,使所有权重变为非负,然后再运行Dijkstra算法?遗憾的是,这种方法行不通。
原因在于,不同路径的边数可能不同。给每条边加上相同的常数 C 后,一条有 k 条边的路径,其总长度会增加 k * C。这改变了不同路径长度之间的相对关系,可能导致原本最短的路径不再是新图上的最短路径。
考虑以下包含负权边的简单图:
顶点: S, V, T
边:
S -> V: 1
S -> T: -2
V -> T: -5
- 路径
S -> T的长度为-2。 - 路径
S -> V -> T的长度为1 + (-5) = -4(更短)。
如果我们尝试给所有边加上 5 使其非负,得到:
S -> V: 6
S -> T: 3
V -> T: 0
- 新图中,路径
S -> T的长度为3。 - 路径
S -> V -> T的长度为6 + 0 = 6。
最短路径发生了反转!因此,这种简单的“平移”归约是无效的。

Dijkstra算法在负权图上的失败 ❌
更严重的是,如果我们在包含负权边的原图上直接运行Dijkstra算法,它会产生错误的结果。
让我们在上面的三节点图上模拟Dijkstra算法:
- 初始化:
A[S]=0,X={S}。 - 第一轮迭代: 跨越边界的边是
S->V(分数1)和S->T(分数-2)。算法选择分数更小的边S->T。 - 结果: 算法将
T加入X,并错误地确定A[T] = -2,B[T] = [S->T]。
然而,我们已知从 S 到 T 真正的最短路径是 S->V->T,长度为 -4。Dijkstra算法过早地将 T 标记为“已解决”,而忽略了通过 V 的、更短的路径,因为它基于贪婪策略,在存在负权边时,局部最优的选择不保证全局最优。
总结 📝
本节课中我们一起学*了Dijkstra算法的工作流程及其关键限制。
- 我们逐步演示了Dijkstra算法如何通过迭代扩展“已知区域”
X,并利用贪婪分数选择边,来计算单源最短路径。 - 我们了解到,Dijkstra算法的正确性依赖于图中所有边的权重均为非负这一关键前提。
- 我们通过反例看到,当图中存在负权边时,Dijkstra的贪婪选择策略会失效,导致计算出错误的最短路径距离。
因此,在处理可能包含负权边的图时,需要使用其他算法,如Bellman-Ford算法。在下一节课中,我们将深入探讨如何证明Dijkstra算法在非负权图上的正确性。
056:Dijkstra算法正确性证明 🧠
在本节课中,我们将学*如何证明Dijkstra算法在任意边权非负的有向图中,确实能计算出从源点到所有其他顶点的正确最短路径。
算法回顾
首先,让我们回顾一下Dijkstra算法的基本思想。它与广度优先搜索等图搜索算法一脉相承。算法维护一个已处理顶点的集合 X。初始时,X 仅包含源点 S,从 S 到自身的距离自然为0。
算法的主体是一个循环,共进行 n-1 次迭代,每次迭代将一个当前不在 X 中的顶点加入 X。我们维持一个不变式:对于 X 中的每个顶点,我们已经计算出了从 S 到该顶点的最短路径距离估计值,以及该最短路径本身。我们始终假设从源点 S 到图中任何其他顶点 V 至少存在一条路径,并且所有边的长度都是非负的。


Dijkstra算法的核心在于如何精心选择下一个从 X 外部加入 X 的顶点。具体做法是:扫描所有跨越“前沿”的边。所谓“前沿”,是指那些起点(尾部)在 X 内,而终点(头部)在 X 外的边。

对于每一条这样的边 (v, w),我们计算一个 Dijkstra贪婪分数,其定义为:
贪婪分数 = d[v] + l(v, w)
其中,d[v] 是我们已计算出的从 S 到 v 的最短路径距离,l(v, w) 是边 (v, w) 的长度。

在所有跨越前沿的边中,我们选择贪婪分数最小的那条边,记为 (v, w)。然后,我们将顶点 w* 加入 X,并设置:
- 从 S 到 w* 的最短路径距离
d[w*] = d[v*] + l(v*, w*) - 从 S 到 w* 的最短路径为:之前计算出的到 v* 的最短路径,再拼接上边 (v, w)


证明目标



我们将要证明的定理是:对于任何边权非负的有向图,Dijkstra算法都能正确计算出所有最短路径距离。即,对于每个顶点 V,算法计算出的值 A[V] 恰好等于真正的最短路径距离 L[V]。

这个算法由荷兰计算机科学家Edsger Dijkstra在20世纪50年代末提出,他因其贡献在1972年获得了图灵奖。

证明结构:归纳法
我们将采用数学归纳法来证明算法的正确性。归纳的基础是迭代次数。
基础情况 是平凡的。在循环开始前,我们设置从 S 到 S 的距离为0,路径为空路径。这显然是正确的(这里也用到了边权非负的假设,确保了没有比0更短的路径)。
归纳步骤 是证明的关键。我们假设在之前的所有迭代中,算法都已正确计算了 X 中所有顶点的最短路径。也就是说,对于所有 V ∈ X,有 A[V] = L[V],并且我们记录的最短路径 B[V] 确实是一条真正的从 S 到 V 的最短路径。

现在,考虑当前迭代。算法选择了一条边 (v, w),并将 w* 加入 X。根据算法定义,我们为 w* 设置的最短路径 B[w*] 是 B[v*] 加上边 (v, w),其长度 A[w*] 为 A[v*] + l(v*, w*)。


根据归纳假设,B[v*] 是一条真正的从 S 到 v* 的最短路径,其长度为 L[v*]。因此,A[w*] = L[v*] + l(v*, w*),并且 B[w*] 是一条从 S 到 w* 的、长度为此值的真实路径。

为了完成归纳步骤,我们需要证明:不存在任何其他从 S 到 w* 的路径,其长度比 L[v*] + l(v*, w*) 更短。换句话说,我们找到的这条路径就是全局最短的。

关键论证:任意路径的下界
考虑任意一条从 S 到 w* 的路径 P。由于 S 在 X 内,而 w* 在本次迭代开始时不在 X 内,因此路径 P 必定在某个时刻首次穿越“前沿”,从 X 内的某个顶点 y,通过一条边 (y, z),到达 X 外的顶点 z。此后,路径可能继续游走,最终到达 w*。
因此,路径 P 可以分解为三部分:
- 从 S 到 y 的前缀(完全在 X 内)。
- 跨越前沿的边 (y, z)。
- 从 z 到 w* 的后缀(可能在 X 内外穿梭)。
现在,我们来分析路径 P 的长度下界:
- 前缀部分:这是一条从 S 到 y 的路径,其长度至少等于从 S 到 y 的最短路径距离
L[y]。根据归纳假设,L[y] = A[y]。 - 跨越边部分:其长度就是
l(y, z)。 - 后缀部分:由于所有边权非负,这部分路径的长度至少为 0。
综合以上,我们得到路径 P 的长度满足:
len(P) ≥ A[y] + l(y, z)
应用贪婪选择准则
请注意,边 (y, z) 是一条从 X 内(y)指向 X 外(z)的边,因此在本轮迭代中,它也是Dijkstra算法考虑的候选边之一。
Dijkstra算法的贪婪选择准则是:选择所有候选边中贪婪分数 A[tail] + l(tail, head) 最小的那条。我们本轮选择的边是 (v, w),其贪婪分数为 A[v*] + l(v*, w*)。

由于 (y, z) 也是候选边,根据算法选择规则,必有:
A[v*] + l(v*, w*) ≤ A[y] + l(y, z)
结合我们之前对任意路径 P 的下界分析,就有:
A[v*] + l(v*, w*) ≤ A[y] + l(y, z) ≤ len(P)
这意味着,对于任意从 S 到 w* 的路径 P,其长度都不小于我们算法为 w* 计算出的路径长度 A[v*] + l(v*, w*)。
因此,我们算法找到的这条路径确实是从 S 到 w* 的最短路径,其长度 A[w*] 就是真正的最短路径距离 L[w*]。
总结
本节课中,我们一起学*了Dijkstra算法正确性的完整证明。我们通过数学归纳法,证明了在边权非负的假设下,算法的每一步迭代都能正确选择一个顶点并确定其最短路径。证明的核心在于:
- 利用归纳假设,确保已处理顶点信息正确。
- 分析任意竞争路径的结构,将其分解并利用边权非负性得到下界。
- 最后,应用算法的贪婪选择准则,证明算法找到的路径长度不大于这个下界,从而证明其最优性。
这个严谨的论证确保了Dijkstra算法在所述条件下的可靠性,为其在实际应用(如网络路由、地图导航等)中奠定了坚实的理论基础。
057:Dijkstra算法实现与时间复杂度 🚀
在本节课中,我们将要学*如何实际实现Dijkstra最短路径算法。特别是,我们将看到如何通过巧妙使用堆数据结构,将算法的运行时间提升至*乎线性,从而获得一个极其高效的实现。
概述 📋
Dijkstra算法用于解决单源最短路径问题。给定一个有向图和一个源顶点S,图中每条边都有一个非负的长度。我们的目标是找出从源顶点S到图中每个其他顶点V的最短路径距离。我们假设从S到每个顶点都存在路径,否则可以通过简单的预处理步骤检测出来。
Dijkstra算法回顾 🔄
上一节我们介绍了Dijkstra算法的基本思想,本节中我们来看看它的具体实现细节。
Dijkstra算法由一个主循环驱动。算法维护一个不断演化的集合X,其中包含已被处理的顶点。我们保持一个不变式:对于X中的每个顶点,我们已经计算出了从源点S到它的最短路径距离。
算法开始时,X仅包含源顶点S,其最短路径距离为0。算法的关键在于每次迭代中如何选择下一个要加入X的顶点。

以下是算法的核心步骤:
- 我们只关注那些跨越“前沿”的边,即尾在X中、头在X之外的边。
- 对于每条这样的边,我们计算Dijkstra贪心分数:
分数 = 已计算出的尾顶点最短距离 + 边的长度。 - 我们选择贪心分数最小的边
(v*, w*)。 - 我们将该边的头顶点
w*加入集合X。 - 我们计算
w*的最短路径距离为v*的距离加上边(v*, w*)的长度。
在之前的讲解中,我们使用了两个数组:数组A存储最短路径距离,数组B存储最短路径本身。但在实际实现中,我们通常只需要数组A来计算距离,因此我们将忽略与数组B相关的操作。
朴素实现的时间复杂度 ⏱️

在讨论高效实现之前,我们先分析一下如果按照上述伪代码进行朴素实现,算法的时间复杂度是多少。
以下是需要考虑的因素:
- 主循环迭代次数:算法需要将除源点外的所有顶点加入X,因此主循环会执行
n-1次迭代。 - 每次迭代的工作量:在每次迭代中,我们需要扫描所有边,找出那些跨越前沿的边,并计算每条边的贪心分数,最后选出分数最小的边。这需要检查每条边,因此每次迭代的工作量与边数
m成正比。
因此,朴素实现的总运行时间与 (n-1) * m 成正比,即 O(m * n)。对于顶点和边数都达到数百或数千的图,这种实现尚可接受,但对于百万级别顶点的大规模图,我们需要更快的算法。

利用堆数据结构加速 ⚡
答案是肯定的,我们可以做得更好。我们无需改变算法本身,而是通过改变数据组织方式来获得加速。这是本课程中首次利用数据结构来提升算法性能,我们将看到算法设计与数据结构设计之间美妙的互动。
你可能会问,是什么线索表明数据结构可以加速Dijkstra算法?关键在于观察算法的工作瓶颈:在每次主循环迭代中,我们都在进行一次最小值的计算——在所有跨越前沿的边中找出贪心分数最小的边。我们反复进行这种最小值计算。

是否存在一种数据结构,其存在的意义就是为了快速执行最小值计算?答案是堆。
堆数据结构快速回顾 📚
堆通常被逻辑上视为一棵完全二叉树(尽管通常用数组实现)。其关键性质是堆性质:每个节点的键值都不大于其子节点的键值。这保证了最小元素位于树根。
堆支持以下主要操作,每个操作的时间复杂度都是 O(log n),其中n是堆中元素的数量:
extract_min():取出并返回根节点(最小元素),然后将最后一个叶子节点移到根位置,并通过“下沉”操作恢复堆性质。insert(key):将新元素作为最后一个叶子节点插入,然后通过“上浮”操作恢复堆性质。delete(element):删除堆中任意位置的元素(通过交换和上浮/下沉操作实现)。
将堆应用于Dijkstra算法 🧠

在Dijkstra算法的主循环中,我们需要反复寻找最小值。堆擅长在对数时间内找到最小值,这比朴素实现中的线性时间要好得多。
第一个巧妙但重要的想法是:我们将在堆中存储顶点,而不是边。回顾Dijkstra算法的伪代码,我们关注边只是为了确定下一个要加入X的顶点(即该边的头顶点)。因此,我们可以直接“切中要害”,在堆中维护尚未加入X的顶点。当我们从堆中执行 extract_min 时,它将告诉我们下一个要加入X的顶点。
我们需要维护两个关键的不变式:
- 堆中只包含尚未处理的顶点(即
V - X中的顶点)。 - 对于堆中的每个顶点
v,其键值是“所有从X指向v的边中,最小的Dijkstra贪心分数”。
让我们通过一个例子来理解第二个不变式。假设在算法某次迭代中,有三条边从X指向 V-X 中的顶点,它们的贪心分数分别是7、3和5。其中,顶点A有两条边指向它(分数7和3),顶点B有一条边指向它(分数5),顶点C没有从X指向它的边。
- 顶点A的键值应为两条边中的较小分数,即 3。
- 顶点B的键值应为唯一指向它的边的分数,即 5。
- 顶点C没有符合条件的边,其键值视为 正无穷。
这种设计可以看作将“单轮锦标赛”(朴素实现中扫描所有边找最小)转变为“两轮淘汰赛”:
- 第一轮(本地锦标赛):
V-X中的每个顶点,在所有从X指向它的边中,选出分数最小的作为本地优胜者(即该顶点的键值)。 - 第二轮(总决赛):堆通过
extract_min操作,从所有本地优胜者中选出分数最小的顶点,这就是最终要加入X的顶点w*。
这样,当我们从堆中提取最小元素时,不仅得到了正确的顶点 w*,其键值(根据不变式2)正好就是 w* 的Dijkstra贪心分数,也就是我们需要计算的最短路径距离。因此,只要我们能够有效地维护这两个不变式,堆实现就能以更高效的方式模拟朴素实现的所有计算。
维护堆不变式 🛠️
维护第一个不变式很简单:当我们从堆中提取一个顶点并加入X后,它自然就从堆中移除了。关键在于如何维护第二个不变式。

当我们将一个新顶点 w 从堆中提取并加入X时,前沿发生了变化。这导致一些新的边开始跨越前沿,特别是所有从 w 出发指向 V-X 中顶点的边。对于这些边的头顶点 v(如果 v 仍在堆中),由于现在多了一条从X(具体是从新加入的 w)指向它的候选边,它的“本地锦标赛”多了一个参赛者。因此,顶点 v 的键值可能需要更新(减小)。
以下是维护不变式的伪代码:

# w 是刚从堆中提取并加入X的顶点
for each edge (w, v) in outgoing edges from w:
if v is still in the heap (i.e., v not in X):
# 计算新候选边的贪心分数
new_score = distance[w] + length(w, v)
# 如果新分数小于v当前的键值,则更新
if new_score < heap.get_key(v):
heap.delete(v) # 从堆中删除v
heap.insert(v, new_score) # 以新键值重新插入v
注意,顶点 v 的新键值只能是两种可能之一:要么保持原来的键值(如果新边 (w, v) 的分数不是最小的),要么更新为新边的分数 distance[w] + length(w, v)。这个更新操作(一次删除加一次插入)的时间复杂度是 O(log n)。
时间复杂度分析 📊
现在我们来统计新实现的总运行时间。算法的所有主要工作都通过堆API完成。
extract_min操作:每个顶点(除源点外)被加入X时执行一次,共 O(n) 次。- 键更新操作(
delete+insert):每条边(u, v)最多触发一次对顶点v的键更新(当u先于v被加入X时)。因此,键更新操作的总次数为 O(m) 次。
每次堆操作(extract_min, insert, delete)的时间复杂度是 O(log n)。
因此,使用堆优化的Dijkstra算法的总运行时间为 O((m + n) log n)。由于我们假设图是弱连通的(从S可达所有顶点),m 至少为 n-1,所以可以简化为 O(m log n)。
与朴素实现的 O(m * n) 相比,这是一个巨大的提升!通过巧妙地使用堆数据结构,我们为计算最短路径这个极其重要的问题,获得了一个真正快速高效的算法。
总结 🎯
本节课中我们一起学*了Dijkstra算法的具体实现及其时间复杂度优化。

- 我们首先回顾了Dijkstra算法解决单源最短路径问题的基本框架。
- 接着,我们分析了算法的朴素实现,其时间复杂度为 O(m * n),这对于大规模图来说效率较低。
- 然后,我们引入了堆数据结构作为加速的关键。堆能够在 O(log n) 时间内支持插入、删除和提取最小元素操作。
- 我们详细阐述了如何将堆应用于Dijkstra算法:在堆中存储顶点而非边,并维护“顶点键值为从已处理集合指向它的边的最小贪心分数”这一关键不变式。
- 我们解释了在算法运行过程中,当新顶点加入已处理集合时,如何高效地更新受影响的顶点的堆键值,以维护不变式。
- 最后,我们分析了优化后算法的时间复杂度:总共进行 O(m) 次堆操作,每次操作耗时 O(log n),因此总运行时间为 O(m log n)。这比朴素实现快得多,使得算法能够处理顶点数达到百万级别的图。
通过本次学*,我们看到了算法设计与数据结构设计的紧密结合,利用合适的数据结构可以极大地提升经典算法的性能。
058:数据结构概述 📚
在本节课中,我们将学*数据结构的基本概念、重要性以及如何根据应用需求选择合适的数据结构。我们将探讨不同数据结构支持的操作,并理解为什么掌握这些知识对程序员至关重要。
数据结构的目的与重要性
数据结构的主要任务是以能够快速且有效地访问数据的方式组织数据。这是软件中几乎所有主要部分都会用到的核心技能。
以下是数据结构的一些常见例子:
- 简单结构:列表、栈、队列。
- 复杂结构:堆、搜索树、哈希表。
- 其他相关结构:布隆过滤器、并查集等。
为什么存在多种数据结构?🔍
上一节我们介绍了数据结构的基本目的,本节中我们来看看为什么会有如此多样的数据结构。原因在于,不同的数据结构支持不同的操作集合,因此各自适合不同类型的任务。
让我用一个具体的例子来提醒你,这个例子在我们讨论图搜索(特别是广度优先搜索和深度优先搜索)时出现过。

- 实现广度优先搜索时,正确的数据结构是队列。因为它支持从后端的快速(常数时间)插入和从前端的快速(常数时间)删除。
- 相比之下,深度优先搜索是一种具有不同需求的算法。由于其递归性质,栈更适合深度优先搜索。因为它支持从前端的常数时间删除和从前端的常数时间插入。
因此,栈的后进先出特性适合深度优先搜索,而队列的先进先出操作适合广度优先搜索。
选择合适的数据结构
因为不同的数据结构适合不同类型的任务,所以你应该了解基本数据结构的优缺点。一般来说,一个数据结构支持的操作越少,其操作速度就越快,所需的空间开销也越小。
因此,作为程序员,仔细思考应用程序的需求至关重要。你需要明确数据结构必须提供哪些操作,然后选择正确的数据结构——即支持你所需的所有操作,但理想情况下不包含多余操作的那个。
数据结构知识的四个层次 📊
以下是关于数据结构知识水平的四个层次划分:
- 第0层:无知层。处于此层的人从未听说过数据结构,也不知道组织数据可以产生本质上更好的软件(例如,本质上更快的算法)。
- 第1层:鸡尾酒会认知层。这里显然指的是最极客的鸡尾酒会。处于此层的人至少能就基本数据结构进行对话。他们听说过堆、二叉搜索树等概念,可能也知道一些基本操作,但在自己的程序中使用或在技术面试场景中会显得生疏。
- 第2层:扎实掌握层。处于此层的人对数据结构有扎实的了解。他们能自如地在自己的程序中作为客户端使用数据结构,并且很清楚哪种数据结构适合哪种类型的任务。
- 第3层:硬核程序员/计算机科学家层。处于此层的人不满足于仅仅作为数据结构的客户端在程序中使用它们,他们实际上理解这些数据结构的内部原理、如何编码以及如何实现,而不仅仅是如何使用。
本课程的教学重点 🎯
我猜测你们中很大一部分人最终会在自己的程序中使用数据结构。因此,学*不同数据结构的操作及其适用场景,将成为你们作为程序员的一项非常强大的技能。另一方面,我敢打赌,你们中很少有人需要从头开始实现自己的数据结构,而不是仅仅作为客户端使用各种标准编程库中已有的数据结构。
考虑到这一点,我的教学将重点放在将你们提升到第2层。我的讨论将聚焦于各种数据结构支持的操作和一些典型应用。希望通过这些内容,能培养你们对于何种数据结构适合何种任务的直觉。
如果时间允许,我也会为那些希望更上一层楼、想了解这些数据结构内部原理和典型实现方式的同学,提供一些可选材料。
总结

本节课中我们一起学*了数据结构的基本概念。我们了解到,数据结构的核心目的是高效组织数据,而多种数据结构的存在是为了满足不同任务对操作集合的不同需求。作为程序员,关键在于根据应用需求,选择支持必要操作且没有冗余的数据结构。本课程旨在帮助大家达到能够扎实掌握并正确应用常见数据结构的水平。
059:堆的操作与应用 📚
在本节课中,我们将要学*堆(Heap)这种数据结构。我们将明确堆支持哪些操作、这些操作的运行时间保证,并了解堆在哪些类型的问题中非常有用。在后续的视频中,我们会深入探讨堆的具体实现细节,但本节课我们主要聚焦于如何以“客户端”的身份来使用堆。

概述 📋
堆是一种用于存储多个对象的容器,每个对象都有一个键值(Key),例如一个数字,以便我们可以比较不同对象的键值大小。堆支持两个核心操作:插入(Insert)和提取最小值(Extract Min)。通过堆,我们可以在对数时间内高效地完成这些操作,从而优化那些需要反复计算最小值(或最大值)的算法。
堆的核心操作 ⚙️
关于一个数据结构,你需要记住的首要事情是它支持哪些操作,以及这些操作的预期运行时间。对于堆,其核心操作如下:

以下是堆支持的两个基本操作:
- 插入(Insert):将一个带有键值的对象添加到堆中。
- 提取最小值(Extract Min):从堆中移除并返回具有最小键值的对象。
注意:我们同样可以定义支持提取最大值(Extract Max)的“最大堆”。如果你只有“最小堆”,可以通过将所有键值取反后再插入,这样提取最小值就相当于提取最大值。堆通常不同时支持提取最小值和最大值,如果需要同时支持这两种操作,可以考虑使用二叉搜索树。
关于运行时间,在标准的堆实现中,插入和提取最小值操作的时间复杂度都是 O(log n),其中 n 是堆中对象的数量。这个对数是以 2 为底的,并且常数因子很小。
堆的其他操作 🛠️
除了核心操作,堆还可以支持一些其他有用的操作。

以下是堆可能支持的一些附加操作:
- 堆化(Heapify):这是一个批量初始化操作,可以在 O(n) 的线性时间内,将 n 个对象一次性构建成一个堆。这比逐个插入(O(n log n))要高效。
- 任意删除(Delete):虽然有些复杂,但堆可以实现删除堆中任意元素(而不仅仅是最小值)的操作,时间复杂度也是 O(log n)。这个操作在我们使用堆来加速迪杰斯特拉(Dijkstra)算法时会用到。
堆的应用场景 💡
使用堆最常见的原因是,你发现程序正在反复进行最小值计算,尤其是通过穷举搜索的方式。许多应用都有这个特点:一个朴素的算法通过暴力搜索反复计算最小值,而简单地应用堆数据结构可以极大地提升其速度。

应用一:堆排序(Heap Sort)🔢
让我们回到最经典的计算问题之一:对无序数组进行排序。
选择排序(Selection Sort)是一种非常直观但次优的算法。它的过程是:扫描整个数组找到最小元素,放到第一位;在剩下的 n-1 个元素中找最小,放到第二位;依此类推。这显然需要进行多次线性扫描,是一个 O(n²) 的算法。
这个算法正符合“反复进行最小值计算”的模式。我们可以用堆来优化它,得到的算法就是堆排序。
给定堆数据结构,堆排序算法非常简单:
- 将数组中所有元素插入堆中。
- 反复从堆中提取最小值,并依次放入结果数组中。
堆排序的运行时间是多少?我们进行了 n 次插入和 n 次提取,共 2n 次堆操作。每次堆操作是 O(log n),因此总时间复杂度为 O(n log n)。
让我们退一步欣赏一下:我们将最缺乏想象力的 O(n²) 选择排序,通过识别出重复最小值计算的模式并换用堆,就得到了一个 O(n log n) 的排序算法。这与归并排序(Merge Sort)的时间复杂度相同,也与随机化快速排序(Randomized Quick Sort)的平均时间复杂度相同。更重要的是,堆排序是一种基于比较的排序算法,而任何基于比较的排序算法其时间复杂度下界就是 Ω(n log n)。因此,堆排序在这个意义上是最优的。此外,堆排序也是一个非常实用的算法,速度很快。



应用二:事件模拟与优先队列(Priority Queue)⏱️
这个应用几乎不言自明,也是堆的典型用法。在这个场景下,堆通常被称为优先队列。
想象你正在编写一个物理世界模拟软件,比如一个篮球视频游戏。在这个应用中的对象是事件记录。例如,一个事件是“球将在特定时间到达篮筐”。每个事件都有一个自然的键值:时间戳,即该事件预定在未来发生的时间。
在这种模拟中,一个需要反复解决的问题是:找出下一个将要发生的事件。这又是一个最小值计算。你可以维护一个所有已调度事件的无序列表,每次通过线性扫描计算最小值,但你会反复进行这个操作。这时就应该想到:堆可能正是我需要的。
如果你将这些事件记录存储在堆中(键值为时间戳),那么当你需要知道下一个事件时,只需调用 Extract Min,堆就能在对数时间内将下一个事件交给你。
应用三:中位数维护(Median Maintenance)📊

这是一个不那么明显的堆应用。问题描述如下:数字一个接一个地到来(作为一个流)。在每次接收到一个新数字后,你需要快速给出当前所有已接收数字的中位数。要求是,每次计算中位数的时间复杂度只能是 O(log i),其中 i 是当前已接收的数字数量。
提示:你可以尝试用两个堆来解决这个问题。
解决方案是使用两个堆:
- H_low:一个支持
Extract Max的最大堆。 - H_high:一个支持
Extract Min的最小堆。
核心思想是维护一个不变式:将当前看到的所有数字分成两半,较小的一半保存在 H_low 中,较大的一半保存在 H_high 中。
基于这个将元素按两个堆分割的关键思想,你需要明确两点:
- 如何仅用 O(log i) 的工作量在每一步维持这个不变式。
- 这个不变式如何让你解决中位数问题。
对于第一点,当新数字到来时,我们将其与 H_low 的最大值(即较小一半中的最大值)和 H_high 的最小值(即较大一半中的最小值)比较,决定插入哪个堆。插入后,如果两个堆的大小失衡(差值超过1),则从一个堆中提取元素插入另一个堆,以重新平衡。这些操作只涉及常数次堆操作,因此是 O(log i)。
对于第二点,一旦不变式成立,中位数就很容易得到:
- 如果已接收数字总数 i 是偶数,中位数就是
H_low的最大值和H_high的最小值的平均值(或任选其一,根据定义)。 - 如果 i 是奇数,中位数就是元素较多的那个堆的堆顶元素。
应用四:加速迪杰斯特拉算法(Dijkstra‘s Algorithm)🚀
我们将在专门讨论迪杰斯特拉算法运行时间的视频中详细探讨这个应用,但这里需要提一下,以强调数据结构如何加速算法,尤其是在内循环中进行最小值计算时。
迪杰斯特拉最短路径算法有一个核心的 while 循环,至少在其朴素实现中,每次循环迭代似乎都需要遍历图的边来计算一个最小值。这正好落入了堆的“能力范围”:反复的最小值计算。
通过精心部署堆,迪杰斯特拉算法的运行时间可以从一个相当大的多项式时间(顶点数和边数的乘积)大幅下降到*乎线性时间:O(m log n),其中 m 是边数,n 是顶点数。这比不使用堆而进行重复穷举搜索要快得多。
总结 🎯

本节课我们一起学*了堆数据结构。我们明确了堆支持的两个核心操作——插入和提取最小值,以及它们 O(log n) 的运行时间保证。我们还探讨了堆的几种典型应用:从堆排序、事件模拟(优先队列),到中位数维护问题,再到加速迪杰斯特拉算法。这些应用都体现了同一种模式:用高效的对数时间堆操作,替代低效的线性或多项式时间最小值搜索,从而带来显著的性能提升。记住,当你发现算法在反复进行最小值(或最大值)计算时,堆很可能就是你的优化利器。
060:堆的实现细节-进阶选学 📚
在本节课中,我们将深入探讨堆(Heap)数据结构的实现细节。我们将学*如何从零开始编写一个堆,重点关注其核心操作——插入和提取最小值——的实现原理。通过理解堆的树形逻辑视图和底层数组实现,你将掌握构建高效堆结构的关键。
堆的概念回顾 🧠
堆是一种容器,用于存储对象。每个对象除了可能包含其他数据外,还必须有一个可比较的键(Key),例如社会保险号、网络边的权重或事件的时间戳等。
对于任何数据结构,首要的是记住它支持的操作及其时间复杂度。堆主要支持两个操作:
- 插入(Insert):向堆中插入一个对象,时间复杂度为 O(log n),其中 n 是堆中对象的数量。
- 提取最小值(Extract Min):从堆中取出并返回具有最小键值的对象。如果存在多个具有相同最小键值的对象,堆将返回其中一个(具体是哪一个未指定)。该操作的时间复杂度也为 O(log n)。
堆还支持其他高级操作,如批量插入(线性时间)和从堆中间删除,但本课将聚焦于插入和提取最小值这两个核心操作的实现。
堆的两种视图:树与数组 🌳➡️📊
要理解堆的工作原理,必须同时掌握它的两种视图:逻辑上的树形结构和物理上的数组实现。
树形视图(逻辑结构)
在概念上,我们将堆视为一棵满足特定条件的二叉树:
- 有根(Rooted):有一个根节点。
- 二叉树(Binary):每个节点最多有两个子节点(0个、1个或2个)。
- 完全(Complete):树的结构尽可能“满”。这意味着除了最底层,其他层都被完全填满,而最底层的节点从左到右依次填充。

下图展示了一个包含9个节点的“尽可能完全”的二叉树示例:

堆属性(Heap Property)
堆属性规定了对象在树结构中的排列顺序:
对于树中的每一个节点 X(无论是根节点、叶节点还是内部节点),存储在 X 处的对象的键值必须不大于其所有子节点的键值。
节点 X 可能有 0个、1个 或 2个子节点。无论哪种情况,所有子节点的键值都应至少等于 X 的键值。
下图展示了一个包含7个节点、允许重复键值的堆示例:



堆属性虽然对对象排列施加了有用的结构,但并未唯一确定排列方式。同一组键值可以有不同的堆组织形式。关键在于,在任何堆中,根节点必须具有最小的键值。这正好符合我们快速提取最小值的需求。

数组视图(物理实现)
虽然我们在脑海中将堆组织成树形,但在实际编码中,我们并不真正使用指针来构建树。相反,堆通常被更高效地实现为一个数组。
让我们看看如何将上一节中的树自然地映射到数组表示。我们按层级顺序将节点放入数组。
以下是一个包含9个元素的堆及其数组表示:



映射规则:
- 根节点(层级0)放入数组第一个位置(索引1)。
- 接着放入层级1的所有节点。
- 然后放入层级2的所有节点,依此类推。
你可能会好奇,我们如何在不使用指针的情况下,在树形结构和数组实现之间自由转换?秘诀在于,由于我们保持了二叉树的完全平衡,我们可以直接从数组索引计算出父子关系,而无需显式指针。
数组索引与树节点的关系 🔗
在数组实现中(假设索引从1开始),父子节点关系可以通过简单的算术运算确定:
-
寻找父节点:
- 对于索引为 i 的节点(i > 1),其父节点的索引为
parent(i) = floor(i / 2)。 - 例如,索引2和3的节点的父节点是索引1;索引4和5的节点的父节点是索引2。
- 对于索引为 i 的节点(i > 1),其父节点的索引为
-
寻找子节点:
- 对于索引为 i 的节点,其左子节点的索引为
left_child(i) = 2 * i,右子节点的索引为right_child(i) = 2 * i + 1。 - 当然,如果计算出的索引超出了数组范围,则表示该子节点不存在(例如,叶节点)。
- 对于索引为 i 的节点,其左子节点的索引为

这种实现方式带来了显著优势:
- 存储高效:无需为指针分配额外空间,所有对象直接存储在数组中。
- 计算快速:通过简单的除以2或乘以2操作(甚至可以利用位运算加速)即可遍历树结构,比指针跳转更快。
在接下来的两节中,我们将基于这种数组实现,探讨如何以 O(log n) 的时间复杂度实现插入和提取最小值操作。
操作实现:插入(Insert)操作 ⬆️
我们将通过示例来说明插入操作的工作原理,而不提供具体的伪代码。相信通过讨论,你将能够自己编写出插入和提取最小值的代码。
假设我们有一个现有的堆(如下图中蓝色部分所示),现在需要插入一个键值为 K 的新对象。


步骤 1:放置新元素
为了维持树的完全平衡性,新元素只能被放置在最后一个位置,即成为最底层最右侧的新叶节点。在数组实现中,这对应于将新元素追加到数组末尾。这是一个常数时间操作。
步骤 2:恢复堆属性(Bubble Up)
放置新元素后,堆属性可能被破坏。我们需要通过一种称为 “上浮”(Bubble Up) 或“上滤”(Sift Up)的过程来修复。

- 情况 A:幸运插入。如果新键值 K(例如7或10)大于或等于其父节点的键值,那么堆属性依然保持,插入操作立即完成(仅需常数时间)。
- 情况 B:需要修复。如果新键值 K(例如5)小于其父节点的键值,则违反了堆属性。
修复过程是一个循环:
- 将新节点与其父节点进行比较。
- 如果新节点的键值更小,则交换它们的位置。
- 交换后,以新节点(现在处于父节点的位置)为起点,重复步骤1和2,继续与其新的父节点比较。
- 这个过程一直持续到以下两种情况之一发生:
- 新节点的键值不再小于其父节点的键值。
- 新节点已到达根节点(索引1)。
在我们的例子中,插入5后,它先与12交换,再与8交换,最后停在键值为4的根节点之下,因为 5 > 4,堆属性得以恢复。

关键点验证:
- 正确性:上浮过程最终会停止,并确保堆属性在整个树中得到恢复。
- 时间复杂度:由于堆是完全二叉树,其高度约为 log₂(n)。在最坏情况下,新元素可能需要从最底层一直上浮到根节点,每层进行常数次比较和交换。因此,插入操作的最坏情况时间复杂度为 O(log n)。
操作实现:提取最小值(Extract Min)操作 ⬇️
提取最小值操作负责从堆中移除具有最小键值的对象并将其返回。同样,我们通过示例和 “下沉”(Bubble Down) 过程来说明。
步骤 1:移除根节点
最小值保证在根节点。因此,我们首先移除根节点(例如下图中键值为4的节点)并将其返回给调用者。


步骤 2:填充空缺
移除根节点后,树结构出现空缺。为了维持树的完全平衡结构,我们选择最后一个节点(即数组的最后一个元素,图中键值为13的节点)来填充这个空缺。我们将其移动到根节点的位置。
步骤 3:恢复堆属性(Bubble Down)
将最后一个节点提升到根位置后,堆属性几乎必然被破坏(根节点的键值13大于其子节点)。我们需要通过 “下沉” 过程来修复。
下沉过程的决策比上浮稍复杂,因为一个节点有两个可能的子节点可以交换。
- 从根节点开始,将其与它的两个子节点比较。
- 如果当前节点的键值大于任何一个子节点,则它应该与键值较小的那个子节点交换。(关键:必须与较小的子节点交换,否则无法修复堆属性)
- 交换后,当前节点下沉到子节点位置。
- 以这个新位置为起点,重复步骤1-3,继续与其新的子节点比较。
- 这个过程一直持续到以下两种情况之一发生:
- 当前节点的键值不大于其任何子节点。
- 当前节点已成为叶节点(没有子节点)。
在我们的例子中:
- 根节点13的子节点是4和8。较小的子节点是4。
- 错误尝试:如果与较大的子节点8交换,会引入新的违规(8 > 4),问题没有解决。
- 正确操作:与较小的子节点4交换。交换后,13位于之前4的位置,其子节点变为9和4(原12的位置)。堆属性在13和4之间仍然被破坏。
- 继续下沉:13与较小的子节点4交换。交换后,13到达叶节点位置,堆属性完全恢复。

关键点验证:
- 正确性:下沉过程最终会停止(到达叶节点或找到正确位置),并确保堆属性在整个树中得到恢复。
- 时间复杂度:与插入操作类似,在最坏情况下,节点可能需要从根节点一直下沉到叶节点。堆的高度为 O(log n),每层进行常数次比较和交换。因此,提取最小值操作的时间复杂度也为 O(log n)。
总结 🎯
本节课我们一起深入学*了堆数据结构的实现细节:
- 双重视图:我们理解了堆同时具备逻辑上的树形结构(完全二叉树)和物理上的数组实现。数组实现通过索引计算(
parent(i) = i/2,child(i) = 2*i, 2*i+1)来模拟指针,实现了存储和计算的高效性。 - 核心操作原理:
- 插入(Insert):将新元素放在数组末尾(树的最底层最右侧),然后通过 “上浮”(Bubble Up) 操作,不断与父节点比较并交换,直到恢复堆属性。
- 提取最小值(Extract Min):移除并返回根节点(最小值),将数组最后一个元素移到根位置,然后通过 “下沉”(Bubble Down) 操作,不断与较小的子节点比较并交换,直到恢复堆属性。
- 时间复杂度:由于堆是完全二叉树,其高度为 O(log n)。插入和提取最小值操作在最坏情况下都只需遍历树的高度,因此它们的时间复杂度都是 O(log n)。

通过窥探堆的内部实现机制,希望你不仅加深了对这一重要数据结构的理解,也感受到了底层算法设计的巧妙与严谨。现在,你已经具备了从零开始实现一个高效堆数据结构的知识基础。
061:平衡搜索树操作与应用 🧮
在本节课中,我们将要学*一种非常重要的数据结构——平衡二叉搜索树。我们将从用户的角度出发,了解它能提供哪些操作,然后深入其内部实现,理解这些操作为何具有特定的时间复杂度。
概述
平衡二叉搜索树可以被视为一个“动态的”有序数组。它不仅能支持在有序数组上可以进行的几乎所有操作,还能高效地处理数据的插入和删除,从而适应动态变化的数据集。
有序数组支持的操作
为了理解平衡二叉搜索树的价值,我们先回顾一下,当数据存储在一个有序数组中时,我们可以轻松完成哪些操作。
以下是几个关键操作:

-
搜索:使用二分查找算法,可以在对数时间内完成搜索。其核心思想是每次比较后,将搜索范围缩小一半。
# 伪代码示例:二分查找 def binary_search(arr, target): low, high = 0, len(arr) - 1 while low <= high: mid = (low + high) // 2 if arr[mid] == target: return mid elif arr[mid] < target: low = mid + 1 else: high = mid - 1 return -1 # 未找到 -
选择:给定一个顺序统计量
i(例如寻找第i小的元素),在有序数组中,这可以在常数时间内完成,只需返回数组第i个位置的元素。寻找最小值和最大值是选择问题的特例。 -
前驱与后继:给定一个元素,寻找其前一个(更小)或后一个(更大)的元素。在有序数组中,这只需向前或向后移动一个索引,是常数时间操作。
-
排名:查询有多少个键值小于或等于给定的键。这可以通过搜索该键并查看其终止位置来实现,时间复杂度为
O(log n)。 -
顺序输出:按从小到大的顺序输出所有元素。只需从左到右扫描数组即可,时间复杂度为
O(n)。
从静态到动态:平衡二叉搜索树的优势
上一节我们介绍了有序数组支持的各种高效操作。然而,有序数组在处理动态数据时存在瓶颈。

主要问题在于,为了在有序数组中插入或删除一个元素并保持有序性,通常需要移动大量元素,导致线性时间复杂度 O(n)。这在频繁更新的场景下是不可接受的。
平衡二叉搜索树的设计目标,正是要在支持上述所有丰富操作的同时,还能高效地处理插入和删除。
平衡二叉搜索树的操作与性能
平衡二叉搜索树在动态环境中提供了与有序数组相媲美的功能集,并加入了高效的更新操作。
以下是它支持的操作及其时间复杂度(n 为树中元素数量):
- 搜索:
O(log n)。与有序数组的二分查找效率相同。 - 选择:
O(log n)。相比有序数组的常数时间略有牺牲,但仍然很快。 - 最小值/最大值:
O(log n)。通常通过跟踪树的最左或最右节点来实现。 - 前驱/后继:
O(log n)。相比数组的常数时间有所增加。 - 排名:
O(log n)。与有序数组效率相同。 - 顺序输出:
O(n)。通过中序遍历实现,效率与数组相同。 - 插入:
O(log n)。这是相比静态数组的关键优势。 - 删除:
O(log n)。同样是处理动态数据的关键优势。
核心公式可以概括为:平衡二叉搜索树 ≈ 有序数组的功能 + O(log n) 时间的插入与删除。
与其他数据结构的比较
平衡二叉搜索树功能强大,但并非所有场景下的最优解。选择数据结构时,需根据具体需求权衡。
以下是几种数据结构的简要对比:
- 有序数组:如果数据集是静态的,不需要插入和删除,那么有序数组是实现所有查询操作的最快选择。
- 堆:如果核心需求仅是快速插入、删除以及获取最小(或最大)值(例如实现优先队列),那么堆是更简单、常数因子更优的选择。堆不能同时高效获取最小和最大值,也不支持基于键值的复杂查询。
- 哈希表:如果只需要快速的插入、查找和删除,而不关心元素之间的顺序关系(如最小值、排名、顺序遍历等),那么哈希表能提供平均情况下的常数时间操作,是更高效的选择。

因此,当你需要一个功能全面(支持基于顺序的各种查询)且动态(支持高效更新)的数据结构时,平衡二叉搜索树往往是理想的选择。
总结
本节课中我们一起学*了平衡二叉搜索树的核心价值与应用场景。我们了解到,它通过巧妙的树形结构和平衡机制,在动态数据集中实现了*乎有序数组的全部查询功能,同时保证了插入和删除操作的高效性。虽然在某些特定操作上不如更专门的数据结构(如堆或哈希表)快,但其功能的全面性使其成为处理需要维护顺序信息的动态数据集时的强大工具。在接下来的课程中,我们将深入探讨其实现原理。
062:二叉搜索树基础 - 第一部分 🌲
在本节课中,我们将要学*二叉搜索树的基础知识。我们将了解其核心概念、基本结构以及如何实现搜索和插入操作。请注意,本节课不涉及树的平衡性,那将是后续课程的内容。
二叉搜索树的动机 🎯
上一节我们介绍了数据结构的基本概念,本节中我们来看看为什么需要二叉搜索树。本质上,平衡的二叉搜索树是有序数组的动态版本。
它几乎能完成有序数组的所有操作,虽然时间可能稍长,但依然非常快。更重要的是,它是动态的,支持插入和删除操作。在有序数组中,每次插入或删除都可能需要线性时间,这在大多数应用中代价过高。相比之下,在(平衡的)搜索树中,你可以在对数时间内完成插入、删除和搜索,这与有序数组的二分查找效率相当。此外,你还可以解决选择问题(如查找最小/最大值),虽然不像有序数组那样是常数时间,但对数时间仍然很好。你还可以线性时间(每个元素常数时间)按序输出所有键值。
二叉搜索树的结构 🏗️
现在我们已经了解了二叉搜索树的优势,接下来看看它是如何组织的。本节内容对平衡和非平衡的搜索树都适用。
二叉搜索树的核心要素如下:
- 树中的每个节点对应一个存储的键。通常,节点还包含一个指向更多关联数据的指针。
- 节点之间通过指针连接。为简化起见,我们假设每个节点有三个指针:一个指向左子节点,一个指向右子节点,一个指向父节点。这些指针可以为空(
null)。
以下是二叉搜索树最根本的属性,我们称之为搜索树属性:
对于树中的任意节点,设其键值为 x,则其左子树中所有节点的键值都小于 x,其右子树中所有节点的键值都大于 x。
此属性在树的每个节点都必须成立。上述定义假设键值互异。若允许重复键值,只需约定如何处理相等情况,例如规定左子树键值小于或等于当前节点键值,右子树键值严格大于当前节点键值。


搜索树属性确保了搜索的简易性。例如,如果你在根节点为17的树中搜索23,由于23 > 17,根据属性,23只可能存在于右子树中,因此你可以立即忽略整个左子树。这非常类似于二分查找的思想。
需要注意的是,搜索树属性与堆属性不同。堆属性(如最小堆要求父节点小于子节点)是为了快速提取最小元素,而搜索树属性是为了高效搜索。
搜索树的高度变化 📊
理解二叉搜索树的一个重要点是,对于同一组键值,可以存在许多不同的、都满足搜索树属性的树结构,它们的高度可能差异很大。
树的高度(或称深度)是指从根节点到最远叶节点所经过的边数(或跳数)。
- 最佳情况:树是完美平衡的,高度约为 O(log n)。
- 最坏情况:树退化成一条链(例如所有节点只有左子节点或只有右子节点),高度为 O(n)。
高度的差异直接影响操作效率,这也是后续课程中需要实现平衡机制(如AVL树、红黑树)的原因。
基本操作实现 ⚙️
了解了二叉搜索树的基本结构后,我们现在可以讨论如何实现其支持的各种操作。以下将给出高级描述,足以指导你自己编写代码。
搜索操作
搜索操作直接利用了搜索树属性,过程非常直观。
- 从根节点开始。
- 比较当前节点键值 x 与目标键值 k:
- 如果 k == x,搜索成功,返回该节点。
- 如果 k < x,根据属性,目标只可能在左子树中。沿左子指针递归搜索左子树。
- 如果 k > x,根据属性,目标只可能在右子树中。沿右子指针递归搜索右子树。
- 搜索在两种情况下终止:
- 找到目标节点(成功)。
- 遇到空指针(
null),表示目标不在树中(失败)。


插入操作
插入操作建立在搜索的基础上。我们先考虑键值互异的情况。
- 首先,搜索待插入的键值 k。由于无重复,此次搜索必定失败,并终止于一个空指针。
- 在此次失败搜索终止的空指针位置,创建新节点存储 k,并将该空指针(来自其父节点)指向新节点。

如果允许重复键值,只需对插入逻辑稍作调整。例如,当搜索过程中遇到键值等于 k 的节点时,可以约定继续在其左子树(或右子树)中搜索,直到遇到空指针,再将新节点插入该位置。
一个值得思考的练*是:按照此过程插入新节点后,树是否仍然保持搜索树属性?答案是肯定的。

总结 📝
本节课中我们一起学*了二叉搜索树的基础知识。我们了解了其作为动态有序数组替代品的动机,掌握了其核心的搜索树属性,认识了树结构的高度可变性,并初步探讨了搜索和插入两个基本操作的高层实现逻辑。这些是理解更复杂平衡树结构的重要基石。在接下来的课程中,我们将深入探讨删除、遍历等其他操作,以及如何保持树的平衡。
063:二叉搜索树基础 - 第二部分 🧮
在本节课中,我们将继续学*二叉搜索树,深入探讨其核心操作,包括查找、插入、删除、遍历以及如何高效地计算顺序统计量(如第k小元素)。我们将分析这些操作的运行时间,并理解树的高度如何成为影响性能的关键因素。
搜索与插入的运行时间分析 ⏱️
上一节我们介绍了二叉搜索树的搜索和插入操作。本节中我们来看看这些操作在最坏情况下的运行时间取决于什么。
以下是一个包含n个不同键的搜索树的四个参数,哪一个决定了搜索或插入操作的最坏情况时间?
- 树中节点的总数
n - 树中叶子节点的数量
- 树的高度
h - 树中内部节点的数量
正确答案是第三个:树的高度 h 决定了搜索或插入操作的最坏情况时间。这意味着仅仅知道键的数量 n 不足以推断最坏情况的搜索时间,还必须了解树的结构。
为了理解这一点,让我们回顾之前用过的两个例子。一个例子是平衡良好的树,另一个例子虽然包含完全相同的五个键,但极度不平衡,本质上像一个链表。

在任何搜索树中,执行搜索或插入操作的最坏情况时间,与从根节点出发,沿着左或右子节点指针,直到遇到空指针所需跟随的最大指针数量成正比。当然,在成功的搜索中,你会在遇到空指针之前终止,但在最坏情况下(或插入操作中),你会一直走到空指针。

- 在左边的平衡树中,你最多跟随三个这样的指针。例如,搜索
2.5时,你会跟随一个左指针,然后一个右指针,再一个右指针,然后遇到空指针,总共跟随了三个指针。 - 在右边的不平衡树中,你可能需要跟随多达五个指针。例如,搜索键
0时,你会连续遍历五个左指针,最后才遇到末尾的空指针。
因此,运行时间不是常数。在最坏情况下,你必须到达树的底部。如果你有一个像左边那样平衡良好的二叉搜索树,运行时间将与键的数量 n 的对数成正比。如果你有一个像右边那样糟糕的搜索树,运行时间将与键的数量 n 成正比。一般来说,搜索或插入时间将与树的高度 h(即从根节点到叶子节点所需的最大跳数)成正比。
更多支持的操作:最小值、最大值、前驱与后继 🔍
现在让我们继续探讨搜索树支持、但堆和哈希表等动态数据结构不支持的一些操作。
首先是最小值和最大值操作。相比之下,在堆中,你可以轻松找到最小值或最大值,但不能同时轻松找到两者。而在搜索树中,可以非常容易地找到最小值或最大值。
查找最小值

一种思考方式是:在搜索树中搜索负无穷大。你从根节点开始,持续跟随左子节点指针,直到用完为止(即遇到空指针)。你访问的最后一个键必然是树中的最小键。
原因如下:假设从根节点开始。如果根节点不是最小值,那么最小值必然在左子树中。于是你跟随左子节点指针,然后重复这个论证。如果你还没有找到最小值,那么相对于当前位置,它必然在左子树中。你只需迭代,直到无法再向左移动为止。
例如,在我们的示例搜索树中,如果我们持续跟随左子节点指针,会从 3 开始,走到 1,然后尝试从 1 向左走,遇到空指针,于是返回 1,而 1 确实是这棵树中的最小键。
查找最大值
既然我们已经介绍了如何计算最小值,那么如何计算最大值也就不难猜到了。当然,要计算最大值,我们只需对称地跟随右子节点指针,这保证能找到树中的最大键。这就像搜索键值正无穷大一样。
查找前驱
前驱操作是指:给定树中的一个键(元素),找到比它小的下一个最大元素。例如,3 的前驱是 2,2 的前驱是 1,5 的前驱是 4,4 的前驱是 3。
计算前驱有两种情况:一种非常简单,另一种稍显复杂。
简单情况:当键为 K 的节点拥有非空的左子树时。在这种情况下,你只需要找到该节点左子树中的最大元素。这就是 K 的前驱。
我们可以通过检查示例中拥有左子树的节点来验证这一点。实际上,只有两个节点拥有非空左子树:3 和 5。3 的左子树中的最大键是 2,这确实是 3 的前驱。5 的左子树只包含元素 4,该子树中的最大值也是 4,这确实是整个搜索树中 5 的前驱。
较复杂情况:当键为 K 的节点根本没有左子树时。此时,左子指针为空,无法提供帮助。右子指针对于计算前驱也毫无用处,因为根据搜索树的定义,右子树只包含大于 K 的键。因此,要找到前驱,我们必须跟随父指针,可能不止一个。
为了说明如何跟随父指针,让我们看看右边示例搜索树中的几个例子。
- 从节点
2开始。我们跟随它的父指针到达1,而1正是2在这棵树中的前驱。所以计算2的前驱似乎只需要跟随一次父指针。 - 从节点
4开始。我们跟随它的父指针到达5,但5不是4的前驱,而是后继。我们再跟随一次父指针,到达3。所以,从4开始,我们需要跟随两次父指针。
关键在于,你只需持续跟随父指针,直到到达一个键值小于你起始键值的节点。此时你可以停止,该节点保证就是前驱。
另一种理解终止条件的方式是:当你第一次“左转”时停止。即,当你从一个节点移动到其父节点,并且该节点是其父节点的右子节点时。在从 2 开始的例子中,我们第一次移动就是左转(2 是 1 的右子节点),我们一步就找到了前驱。在从 4 开始的例子中,第一步是右转(4 是 5 的左子节点),但下一步我们左转,就到达了一个小于起始点 4 的节点。
这两种关于终止条件的描述本质上是相同的。鼓励你仔细思考为什么它们是完全相同的停止条件。
其他细节:
- 如果你从没有前驱的唯一节点(即最小值节点)开始,你将永远不会触发这个终止条件。例如,从搜索树中的节点
1开始,不仅左子树为空(意味着你应该开始遍历父指针),而且当你遍历父指针时,你只会向右移动,永远不会左转。这就是你检测到自己处于搜索树最小值的方式。 - 如果你想计算一个键的后继而不是前驱,显然只需在整个描述中交换“左”和“右”即可。
以上是关于搜索树中各种排序操作(最小值、最大值、前驱和后继)的高层次解释。
运行时间分析
现在,让我问你一个与讨论搜索和插入时相同的问题:这些操作在最坏情况下需要多长时间?
答案与之前相同:与树的高度 h 成正比。解释也完全相同。
为了理解对高度的依赖关系,让我们专注于问题中提到的最大值操作。其他三个操作的最坏情况运行时间与高度成正比,原因完全相同。
最大值操作是做什么?从根节点开始,持续跟随右子节点指针,直到用完为止(遇到空指针)。因此,运行时间不会比最长路径(从根节点到某个叶子节点的特定路径)更长。另一方面,从根节点到最大键的路径很可能就是树中最长的路径,它可能决定了搜索树的高度。
例如,在我们不平衡的例子中,对于最小值操作来说,这是一棵糟糕的树。如果你在这棵树中寻找最小值,你将不得不遍历从 5 一直到 1 的每一个指针。当然,对于最大值操作也存在类似的不利情况,例如 1 是根节点,而 5 作为叶子节点位于最底部。
中序遍历:按序输出所有键 📋

搜索树可以做的另一件事是模仿排序数组的功能:在线性时间内,以每个元素常数时间的代价,按顺序打印出所有键。显然,在排序数组中,这很简单:只需使用一个从数组开头到结尾的 for 循环,逐个打印键。
在搜索树中,有一个非常优雅的递归实现可以完成完全相同的事情,这被称为二叉搜索树的中序遍历。
和往常一样,你从起点开始,即搜索树的根节点。用一点符号表示:让我们把以 R 的左子节点为根的搜索树称为 T_L,把以 R 的右子节点为根的搜索树称为 T_R。
在我们的运行示例中,根节点是 3,T_L 对应仅包含元素 1 和 2 的搜索树,T_R 对应仅包含元素 5 和 4 的子树。

记住,我们希望按键值递增的顺序打印键。特别是,我们想打印的第一个键是所有键中最小的。所以我们绝对不想先打印根节点的键。例如,在我们的搜索树示例中,根节点的键是 3,我们不想先打印它,我们想先打印 1。
那么最小值在哪里?根据搜索树性质,它必然在左子树 T_L 中。因此,我们只需递归处理 T_L。
通过递归(或者你更喜欢归纳法)的魔力,递归处理 T_L 将完成按从小到大的顺序打印 T_L 中所有键的任务。
这非常酷,因为 T_L 恰好包含所有小于根节点键的键。记住,这是搜索树的性质:所有小于根节点键的键都在左子树中,所有大于根节点键的键都在右子树中。
在我们的具体例子中,第一个递归调用将打印出键 1 和 2。现在,如果你想一想,这正是打印根节点键的完美时机。我们希望按递增顺序打印所有键。我们已经处理了所有小于根节点键的键,而递归处理右侧将处理所有大于它的键。因此,在两个递归调用之间(这就是它被称为“中序”遍历的原因),我们打印根节点 R 的键。
显然,这在我们的具体例子中有效:第一个递归调用打印出 1 和 2,此时是打印 3 的完美时机,然后递归调用将打印出 4 和 5。更一般地说,对右子树的递归调用将再次通过递归或归纳的魔力,按递增顺序打印出所有大于根节点键的键。
这个伪代码的正确性,即这种所谓的中序遍历确实按递增顺序打印键,可以通过一个相当直接的归纳证明来验证。这与我们在课程早期讨论的分治算法正确性的归纳证明精神非常相似。
中序遍历的运行时间
该过程的运行时间是线性的,即 O(n),其中 n 是搜索树中键的数量。原因在于,对树中的每个节点恰好有一次递归调用,并且在每次递归调用中只做常数工作。
更详细地说,中序遍历按递增顺序打印键,特别是它恰好打印每个键一次。每个递归调用恰好打印一个键值。因此,恰好有 n 次递归调用,而每次递归调用只做一件事(打印),所以是 n 次递归调用,每次常数时间,总体运行时间为 O(n)。
删除操作 🗑️
在大多数数据结构中,删除是最困难的操作,搜索树也不例外。让我们深入探讨删除操作的工作原理。

删除操作有三种不同的情况。首要任务是定位包含键 K 的节点,即我们想要删除的节点。例如,假设我们试图从示例搜索树中删除键 2。首先需要找出它在哪里。
搜索树中的一个节点可能拥有的子节点数量有三种可能性:它可能没有子节点(0个子节点),可能有一个子节点,也可能有两个子节点。相应地,删除操作的伪代码也将有三种情况。
情况一:删除的节点没有子节点(0个子节点)
这是最简单的情况,例如从搜索树中删除键 2。在这种情况下,我们可以毫无保留地直接从搜索树中删除该节点。不会出任何问题,因为没有子节点依赖于该节点。

情况二:删除的节点有一个子节点
这种情况也不算太糟,例如从搜索树中删除 5。你需要做的就是将被删除的节点“剪接”出来,这会在树中留下一个空洞,然后将被删除节点的唯一子节点提升到被删除节点之前的位置。
例如,在我们的五节点搜索树中,如果我们想删除 5,我们会把它从树中取出,留下一个空洞,然后我们用它的唯一子节点 4 替换原来 5 的位置。如果你仔细想想,这工作得很好,因为它保留了搜索树性质。搜索树性质规定,例如右子树中的所有内容都必须大于该节点的键。现在我们把 4 作为 3 的新右子节点,但 4 及其可能拥有的任何子节点原本就是 3 的右子树的一部分,所以所有这些内容都必须大于 3。因此,将 4 及其所有后代作为 3 的右子节点没有问题,搜索树性质实际上得到了保留。
情况三:删除的节点有两个子节点
这是最困难的情况。在我们的运行示例中,只有当我们想删除根节点,即从树中删除键 3 时,才会发生这种情况。问题在于,如果你试图将这个节点从树中撕掉,会留下一个空洞,而且不清楚将任何一个子节点提升到这个位置是否可行。你可以盯着我们的示例搜索树,试着理解如果你试图把 1 提升为根节点,或者试图把 5 提升为根节点会发生什么问题——问题就会出现。
这与我们在堆中遇到相同问题时的处理方式形成了有趣的对比。因为堆的性质在某些意义上可能不那么严格,当我们想要删除一个有两个子节点的元素时(假设我们想执行提取最小操作),我们只需提升两个子节点中较小的那个。在这里,我们需要更努力一些。实际上,我们将使用一个非常巧妙的技巧,将有两个子节点的情况简化为之前已经解决的0个或1个子节点的情况。
以下是识别我们将对其应用0子节点或1子节点操作的节点的非常巧妙的方法:我们将从键 K 开始,计算 K 的前驱。记住,前驱是树中比 K 小的下一个最大键。例如,键 3 的前驱是 2,这是树中下一个最小的键。
一般来说,让我们称这个前驱为 L。这看起来可能有点复杂:我们正在实现一个树操作(删除),却突然调用了另一个树操作(前驱)。在某种程度上你是对的,删除是一个非平凡的操作。但它并不像你想的那么糟糕,原因如下:当我们计算这个前驱时,我们实际上处于前驱操作的简单情况中。
回想一下如何计算前驱?这取决于你是否拥有非空的左子树。如果你没有非空左子树,那么你需要向上跟随父指针,直到找到一个键小于你起始键的节点。但如果你有左子树,那就简单了:你只需找到该节点左子树中的最大元素,那必然就是前驱。而找到最大值很容易:你只需持续跟随右子节点指针,直到无法再跟随为止。
这里很酷的一点是,因为我们只在被删除节点拥有两个子节点(因此必然有非空左子树)的情况下才进行此前驱计算,所以当我们说“计算 K 的前驱 L”时,你所要做的就是跟随 K 的左子节点(因为有两个子节点,所以左子节点非空),然后持续跟随右子节点指针,直到无法再跟随为止,那就是前驱 L。
现在,实现搜索树删除操作的相当精彩的部分来了:交换这两个键 K 和 L。
例如,在我们的示例搜索树中,我们将在根节点位置放一个 2,在原来 2 的叶子节点位置放一个 3。第一次看到这个操作,你可能会觉得有点疯狂,甚至像是在作弊——我们似乎完全无视了搜索树的规则。实际上,检查一下我们的示例搜索树发生了什么:我们交换了 3 和 2,但这不再是一棵搜索树了!我们有一个 3 在 2 的左子树中,而 3 大于 2,这是不允许的,违反了搜索树性质。
我们怎么能这样做呢?我们可以这样做,因为我们无论如何都要删除 3,所以在一天结束时,我们最终会得到一棵搜索树。我们可能暂时破坏了搜索树性质,但我们已经把 K 交换到了一个非常容易摆脱的位置。
我们是如何计算 K 的前驱 L 的?最终,这是一个寻找最大值的计算,涉及持续跟随右子节点指针直到卡住。L 就是我们卡住的地方。“卡住”是什么意思?这意味着 L 的右子指针为空。它没有两个子节点,特别是它没有右子节点。
一旦我们将 K 交换到 L 的旧位置,K 现在就没有右子节点了。它可能有也可能没有左子节点。在右边的例子中,它在新的位置也没有左子节点。但一般来说,它可能有一个左子节点,但它肯定没有右子节点,因为那是一个寻找最大值计算卡住的位置。
如果我们想删除一个只有0个或1个子节点的节点,嗯,我们知道该怎么做——我们在上一张幻灯片中已经介绍过。要么直接删除它(这就是我们在运行示例中所做的),要么在 K 的新节点确实有一个左子节点的情况下,执行剪接操作:即撕掉包含 K 的节点,该节点的唯一子节点将占据该节点之前的位置。
现在,有一个练*(我在这里不做,但强烈鼓励你在私下里仔细思考)是证明这个删除操作保留了搜索树性质。粗略地说,当你进行交换时,你可能会违反搜索树性质(正如我们在例子中看到的),但所有违规都涉及你即将删除的节点。所以一旦你删除了那个节点,就没有其他违反搜索树性质的地方了,因此,你就得到了一棵搜索树。

删除操作的运行时间
这次不难猜出运行时间,因为它基本上就是一次前驱计算加上指针重连。就像前驱和搜索操作一样,它由树的高度 h 决定。
选择与排名操作:通过扩充数据结构实现 📊
让我简要介绍一下之前提到的最后两个操作:select(选择)和 rank(排名)。记住,select 就是选择问题:我给你一个顺序统计量,比如 17,我希望你返回树中第17小的键。rank 是:我给你一个键值,我想知道树中有多少个键小于或等于该值。
为了高效地实现这些操作,我们实际上需要一个小的新想法:用每个节点的附加信息来扩充二叉搜索树。所以现在,一个搜索树将不仅包含一个键,还包含关于树本身的信息。
这个想法通常被称为扩充你的数据结构。对于搜索树来说,最经典的扩充可能是在每个节点不仅记录键值,还记录以该节点为根的子树中的节点数量。
让我们称这个为 size(x),它是以 x 为根的子树中的树节点数量。
为了确保你明白我的意思,让我告诉你示例搜索树中五个节点的 size 字段应该是什么。再次记住,我们考虑的是以给定节点为根的子树中有多少个节点,或者等价地说,从该节点跟随子指针可以到达多少个不同的树节点。
- 从根节点开始,当然可以到达所有人。每个人都在以根节点为根的树中,所以那里的
size是5。 - 相比之下,如果你从节点
1开始,你可以到达1,或者你可以跟随右子指针到达2。所以在节点1,size是2。 - 在键值为
5的节点,出于同样的原因,size是2。 - 在两个叶子节点,以叶子节点为根的子树就是叶子本身,所以那里的
size是1。

一旦你知道一个节点的两个子树的 size,就有一个简单的方法来计算该节点的 size。如果搜索树中的一个给定节点 x 有子节点 y 和 z,那么以 x 为根的子树中有多少个节点?嗯,有那些在以 y 为根的左子树中的节点,有那些在以 z 为根的右子树中的节点,然后还有 x 本身。
size(x) = size(y) + size(z) + 1
一般来说,每当你扩充一个数据结构时(当我们讨论红黑树时会再次谈到),你必须付出代价。你维护的额外数据可能有助于加速某些操作,但每当你有修改树的操作(特别是插入和删除)时,你必须注意保持这些额外数据的有效性,即维护它们。
对于这些子树大小,在插入和删除操作下维护它们相当直接,不会过多影响插入和删除的运行时间,但这确实是你应该离线思考的问题。
例如,当你执行插入操作时,记住它是如何工作的:你本质上进行一次搜索,沿着左和右子指针向下直到树底,遇到空指针,然后在那里插入新节点。现在你需要做的是,沿着那条路径回溯,对所有新插入节点的祖先,将它们的子树大小增加 1。


实现选择操作
让我们通过展示如何在已扩充的搜索树中实现选择过程(给定一个顺序统计量)来结束本视频。在每个节点,你都知道以该节点为根的子树的大小。
和往常一样,你从起点开始,在搜索树中就是根节点。假设根节点有子节点 y 和 z。y 或 z 可能为空,这没问题,我们只需将空节点的 size 视为 0。
搜索树性质表明:所有小于存储在 x 处的键的键,恰好都在 x 的左子树中;树中所有大于 x 处键的键,恰好都在 x 的右子树中。
假设我们被要求找到搜索树中的第 i 个顺序统计量,即树中存储的第 i 小的键。它会在哪里?我们应该在哪里查找?嗯,这将取决于树的结构,实际上它将取决于子树的大小。这正是我们跟踪它们的原因,以便能够快速做出关于如何导航树的决定。
举一个简单的例子:假设 x 的左子树包含,比如说,25 个键。记住,y 本地知道其子树的确切数量。所以从 x 出发,我们可以在常数时间内知道 y 子树中有多少个键,假设是 25。根据搜索树的定义性质,这些是树中任何地方最小的 25 个键。x 比它们都大,x 的右子树中的所有内容也都比它们大。所以最小的 25 个顺序统计量都在以 y 为根的子树中。显然,我们应该在那里递归。显然,答案就在那里。因此,我们可以递归处理以 y 为根的子树,然后我们在这个新的、更小的搜索树中再次寻找第 i 个顺序统计量。
另一方面,假设当我们从 x 开始时,我们询问 y:你的子树中有多少个节点?也许 y 本地存储的数字是 12。所以 x 的左子树中只有 12 个东西。好吧,x 本身比它们都大,所以 x 将是第 13 大的顺序统计量。它是树中第 13 大的元素。其他所有东西都在右子树中。所以特别是,第 i 个顺序统计量将在右子树中。因此,我们将在右子树中递归。现在,我们在寻找什么?我们不再寻找第 i 个顺序统计量了。最小的 12 个东西都在 x 的子树中,x 本身是第 13 小的。所以我们在寻找剩余元素中第 (i - 12 - 1) 小的。这种递归非常类似于我们在课程早期讨论的分治选择算法。
为了填充更多细节,让 a 表示 y 处的子树大小。如果 x 没有左子节点,我们将 a 定义为 0。
超级幸运的情况是,当左子树中恰好有 i - 1 个节点时,这意味着这里的根节点 x 本身就是第 i 个顺序统计量。记住,它比左子树中的所有东西都大,比右子树中的所有东西都小。
但在一般情况下,我们要么在左子树递归,要么在右子树递归。
- 当左子树的数量足够大,保证它包含了第
i个顺序统计量时,我们在左子树递归。这恰好发生在它的size至少为i时,因为左子树拥有搜索树中任何地方最小的键。 - 在最后一种情况下,当左子树太小,不仅不包含第
i个顺序统计量,而且x也太小,不是第i个顺序统计量时,我们在右子树递归,知道我们已经丢弃了原始树中任何地方最小的a + 1个键值。
这个过程的正确性与我们早期讨论的选择算法的归纳正确性几乎完全相同。实际上,搜索树的根节点充当了一个枢轴元素,左子树中的所有内容都小于根节点,右子树中的所有内容都大于根节点中的元素,这就是递归正确的原因。

至于运行时间,我希望从伪代码中可以明显看出,每次递归我们做常数时间的工作。我们能递归多少次?当我们不断向下移动树时,我们能向下移动的最大次数与树的高度成正比。所以这再次与树的高度 h 成正比。
以上就是 select 操作。有一种类似的方法可以编写 rank 操作(记住,这是给你一个键值,你想计算存储的键中小于或等于该目标值的数量)。同样,你使用这些扩充的搜索树,同样你可以得到与高度成正比的运行时间。我鼓励你离线思考如何实现 rank 的细节。
总结 🎯
在本节课中,我们一起深入学*了二叉搜索树的各种核心操作及其性能分析。我们了解到,几乎所有基本操作(搜索、插入、删除、查找前驱/后继、选择/排名)的运行时间在最坏情况下都与树的高度 h 成正比。因此,保持树的平衡(即高度接* log n)对于确保高效操作至关重要。我们还学*了如何通过中序遍历按序输出所有键,以及如何通过扩充子树大小信息来高效支持选择与排名操作。这些知识为我们后续学*更高级的平衡搜索树(如AVL树、红黑树)奠定了坚实的基础。
064:红黑树 🎄
在本节课中,我们将超越之前讨论的普通二叉搜索树,开始探讨平衡二叉搜索树。当你需要对操作时间有实时性保证时,这类搜索树是理想的选择,因为它们能保证始终保持平衡。这意味着树的高度能保证是对数级的,进而保证了搜索树所支持的所有操作(如搜索、插入等)的时间复杂度,相对于存储的键值数量,也是对数级的。
二叉搜索树回顾

首先,我们快速回顾一下二叉搜索树的基本性质。在搜索树的每个节点上,如果你向左走,你只会看到比起始节点键值小的键;如果你向右走,则只会看到比起始节点键值大的键。
一个重要的观察是:给定一组键值,可以构造出许多不同的、合法的二叉搜索树。例如,对于键值1到5,一方面可以构造出高度仅为2的平衡搜索树;另一方面,也可能构造出退化成链表形式的“链”,其高度对于n个元素可能高达n-1。
因此,树的高度在最坏情况下可能是线性的,而在最好情况下是对数级的。这显然促使我们追求具有额外性质的搜索树——即平衡搜索树,我们无需担心其高度,因为它总能保持良好的平衡,高度始终是对数级的。


平衡搜索树的核心思想

平衡搜索树的高层思想非常直观。由于树是二叉的,每层节点数最多翻倍,因此要容纳所有存储的键值,层数至少需要是对数级的。我们的目标就是确保树在插入和删除操作后,高度始终保持在对数级。
如果能做到这一点,我们就能获得一系列丰富的操作,且所有操作都能在对数时间内完成。通常,我们用 N 表示树中存储的键值数量。
多种平衡搜索树简介
存在许多种不同的平衡搜索树,它们之间差异并不巨大。本节课将重点介绍其中一种流行的结构:红黑树。
- AVL树:发明于红黑树之前,其不变式与红黑树略有不同。
- 伸展树:由Sleator和Tarjan发明。与红黑树和AVL树仅在插入/删除时调整不同,伸展树即使在执行查找(搜索) 操作时也会调整自身结构,因此有时被称为自调整树。它实现简单,但保证的性质却非常惊人。
- B树与B+树:超越了二叉范式,每个节点可存储多个键值,并拥有多个分支。这在数据库实现中非常相关,目的是为了更好地匹配内存层次结构。虽然这有点超出本节范围,但理解红黑树的许多直观概念可以迁移到这些平衡树数据结构上。
红黑树的不变式

红黑树与二叉搜索树基本相同,但它始终维护着一些额外的不变式。本节将首先介绍这些不变式是什么,然后解释它们如何保证树的高度为对数级。
红黑树有四个不变式,其精髓主要在于后两个:
- 颜色位:每个节点除了存储键值外,还额外存储一个比特信息,用于表示该节点是红色还是黑色。
- 根节点为黑:搜索树的根节点始终是黑色。
- 无连续红节点:如果一个节点是红色的,那么它的两个子节点必须是黑色的。这也意味着,如果一个节点是红色的,它的父节点(如果存在)必须是黑色的。因此,树中任何位置都不会出现两个连续的红色节点。
- 黑高相同:从根节点出发,到达任意一个空指针(
null)的路径上,经过的黑色节点数量必须完全相同。这可以理解为一次不成功的搜索:从根开始,根据比较结果向左或向右,直到遇到空指针。无论走哪条路径,沿途经过的黑色节点总数必须相等。

示例分析
反例:链式结构无法成为红黑树
我们声称,即使是只有三个节点的链式结构也无法成为红黑树。
考虑一个包含键值1、2、3的链(根为1,右子为2,2的右子为3)。根据不变式2,根节点1必须是黑色。由于不变式3禁止连续红节点,节点2和3不能同时为红色。

假设我们将节点2设为红色,节点3设为黑色。现在检查不变式4:
- 搜索键值0:路径为 1(黑) -> 左空指针。经过1个黑节点。
- 搜索键值4:路径为 1(黑) -> 2(红) -> 3(黑) -> 右空指针。经过2个黑节点(1和3)。
两条路径的黑节点数不同,违反了不变式4。可以验证,无论如何为节点2和3着色,都会违反不变式3或4。因此,这个链不是红黑树。
正例:平衡树可以成为红黑树
一个完全平衡的搜索树很容易成为红黑树。考虑一个三节点树,根为5(黑),左子为3(黑),右子为7(黑)。这个着色方案满足所有四个不变式。
动态插入的简单情况
搜索树数据结构的要点在于它是动态的,需要支持插入和删除。每次插入新节点时,我们必须为其着色,并可能因此破坏某些不变式。

以下是一些简单情况,插入后无需大量工作即可维持红黑树性质:
假设我们有一个所有节点均为黑色的红黑树(根5,左3,右7)。现在插入键值6,它将成为7的左子节点。
- 如果我们将6着为黑色,将违反不变式4(搜索5.5和搜索1的路径黑高不同)。
- 解决方法:将6着为红色。这样,6对不变式4是“不可见”的,所有根到空指针的路径黑高仍然相等,且没有产生连续红节点(7是黑,6是红)。
类似地,如果再插入8作为7的右子,并将其着为红色,也能维持所有不变式。
实际上,节点的着色方案并非唯一。例如,我们可以将6和8重新着为黑色,同时将7着为红色。这同样满足所有不变式,因为将红色“上推”到7并没有改变任何路径上的黑色节点总数。
以上仅展示了无需大量调整的简单插入案例。在一般情况下,持续插入或执行删除操作时,需要更复杂的工作(如旋转)来维护这四个不变式。相关实现细节将在可选视频中介绍。
红黑树的高度证明
这些看似任意的红黑树不变式,其核心意义在于:只要搜索树满足这些不变式,其高度就必然很小,从而所有操作都会很快。

定理:每个拥有n个节点的红黑树,其高度为 O(log n)。更精确地说,高度最多为 2 * log₂(n+1)。
证明:
- 关于一般二叉树的引理:假设在一棵二叉树中,从根到任意空指针的路径上至少包含 k 个节点。那么,这棵树的前 k 层必须是满的(即包含一个深度为 k-1 的完美平衡二叉树)。因此,树的节点总数 n 至少为 2ᵏ - 1。
- 用n表示k,得到:k ≤ log₂(n+1)。即,最短的根到空指针路径长度不超过 log₂(n+1)。

-
应用到红黑树:对于一棵有n个节点的红黑树,根据上述引理,存在某条根到空指针的路径,其总节点数不超过 log₂(n+1)。那么,这条路径上的黑色节点数显然也不超过 log₂(n+1)。
-
利用不变式放大:
- 不变式4(黑高相同):上一步得出的黑色节点数上限,适用于所有从根到空指针的路径。即,任意路径的黑节点数 ≤ log₂(n+1)。
- 不变式3(无连续红节点):在任意路径上,红色节点不能连续出现。因此,在包含 B 个黑节点的路径上,最多还能有 B 个红节点(红黑交替出现是最坏情况)。
- 因此,任意路径的总节点数(高度相关) ≤ 黑节点数 + 红节点数 ≤ B + B ≤ 2 * log₂(n+1)。
这就证明了红黑树的高度最多是对数级别的两倍,从而保证了所有基于高度的操作(搜索、插入、查找前驱等)都能在对数时间内完成。
总结
本节课我们一起学*了红黑树这一重要的平衡二叉搜索树。我们首先回顾了二叉搜索树并引出了对平衡结构的需求。接着,我们介绍了红黑树的四个核心不变式,并通过正反示例理解了它们的含义。最后,我们证明了这些不变式如何强制保证红黑树的高度为 O(log n),这是其高效操作的理论基础。
理解不变式及其对高度的控制作用是每个程序员都应掌握的知识。虽然维护这些不变式的插入和删除算法更为复杂(涉及旋转等操作),但因其可以在不影响操作效率的前提下实现,红黑树得以在许多实际应用中被广泛使用,成为程序员工具箱中重要的数据结构之一。
065:旋转操作进阶选学 🔄

在本节课中,我们将深入探讨平衡二叉搜索树实现中的核心操作——旋转。我们将了解旋转操作的基本原理、两种类型(左旋与右旋),以及它们如何在不破坏二叉搜索树性质的前提下,通过常数时间的指针重连实现局部平衡。
旋转操作概述
上一节我们介绍了平衡二叉搜索树的基本概念。本节中,我们来看看实现这些数据结构的一个关键基础操作:旋转。所有平衡二叉搜索树的实现,无论是红黑树、AVL树还是B树,都依赖于旋转操作来维持平衡。
旋转操作的目标非常明确:通过重连少数几个指针(即进行常数量的工作),在局部范围内重新平衡搜索树,同时确保不违反二叉搜索树的性质。
旋转的两种类型
旋转操作分为两种:左旋转和右旋转。执行旋转时,总是针对搜索树中的一对父子节点进行操作。
- 如果子节点是父节点的右孩子,则使用左旋转。
- 如果子节点是父节点的左孩子,则使用右旋转。右旋转在某种意义上可以看作是左旋转的逆操作。
左旋转详解
让我们通过一个具体场景来理解左旋转。假设在搜索树中有一个节点 X,它有一个右孩子 Y。

在这个结构中:
- X 可能有一个父节点 P。
- X 有一个左子树,我们称之为 A(可能为空)。
- Y 有两个子树:左子树 B 和右子树 C。
为了理解旋转如何保持搜索树性质,我们需要明确图中各元素的大小关系:
- Y 是 X 的右孩子,所以 Y > X。
- 子树 A 中的所有键值都小于 X。
- 子树 C 中的所有键值都大于 Y。
- 子树 B 中的所有键值严格介于 X 和 Y 之间(即 X < B < Y)。
左旋转的根本目的是反转节点 X 和 Y 的父子关系。目前,X 是父节点,Y 是子节点。我们希望重连指针,使得 Y 成为新的父节点,而 X 成为其子节点。
为了实现这个目标,几乎只有一种方式能将所有部分重新组装起来:
- 处理父子关系:由于 X < Y,当 X 成为 Y 的孩子时,它必须是左孩子。Y 将继承 X 原来的父节点 P。
- 重新分配子树:
- 子树 A(所有值小于 X 和 Y)自然地成为 X 的左孩子(保持不变)。
- 子树 C(所有值大于 X 和 Y)自然地成为 Y 的右孩子(保持不变)。
- 子树 B(所有值介于 X 和 Y 之间)需要被放置到唯一剩余的空位:X 的右孩子。这完美地保持了搜索树的性质,因为 B 中的节点既在 X 的右子树中(大于 X),也在 Y 的左子树中(小于 Y)。
通过以上步骤,我们仅通过重连常数个指针,就完成了结构的转换。
右旋转:左旋转的逆操作
如果你理解了左旋转,那么理解右旋转就很简单了。右旋转是左旋转的逆操作。
当面对一对父子节点,其中子节点是父节点的左孩子时,如果你想反转它们的父子关系(使旧的子节点成为新的父节点,旧的父节点成为新的子节点),就需要使用右旋转。
同样,给定这个目标,也只有一种独特的方式来重新组装图中的各个部分(父节点、子节点及其三个相关子树),以实现目标,使 Y 成为 X 的父节点。其原理与左旋转完全对称。
旋转操作的优良特性
以下是旋转操作值得称道的特性:
- 常数时间复杂度:旋转操作只涉及重连固定数量的指针,因此可以在 O(1) 时间内完成。
- 保持搜索树性质:正如我们详细讨论的,旋转操作精心设计了指针的重连方式,确保了二叉搜索树的性质(左子树所有节点小于根节点,右子树所有节点大于根节点)在操作后依然成立。
正是这些优良特性,使得旋转操作成为所有平衡搜索树实现中无处不在的基础原语。
总结与后续
本节课中,我们一起学*了平衡二叉搜索树的核心操作——旋转。我们详细探讨了左旋转和右旋转的原理,并了解了它们如何通过常数时间的指针操作来局部调整树结构,同时保持搜索树的性质。

当然,这还不是平衡搜索树实现的全貌。一个完整的实现还需要精确规定何时以及如何部署这些旋转操作。在接下来的视频中,你将对此有一个初步的了解。但如果你希望更深入地理解,我再次鼓励你去查阅全面的数据结构教科书、浏览网络上丰富的平衡搜索树演示资料,或者研究这些数据结构的开源实现代码。
066:红黑树插入操作详解 🧠

在本节课中,我们将深入探讨红黑树数据结构中插入操作的实现细节。红黑树通过维护四个不变性来保证其对数高度,从而支持对数时间复杂度的操作。插入操作可能会破坏这些不变性,因此我们需要通过重新着色和旋转来修复它们。本节将重点介绍插入操作的核心思想和关键步骤,确保初学者能够理解并掌握这一重要概念。
红黑树不变性回顾
上一节我们介绍了红黑树的基本概念和旋转操作,本节中我们来看看插入操作如何维护这些不变性。红黑树必须满足以下四个不变性:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色。
- 红色节点不能有红色的子节点(即不能出现连续的红色节点)。
- 从根节点到任意空指针的每条路径上,黑色节点的数量必须相同。
其中,第三和第四个不变性共同确保了红黑树的高度始终是对数级别的。
插入操作的基本思路
插入操作的基本策略是:首先像普通二叉搜索树一样插入新节点,然后检查并修复可能被破坏的不变性。我们有两种修复工具:重新着色和旋转。
以下是插入操作的基本流程:
- 按照二叉搜索树的规则插入新节点
X,使其成为一个叶子节点。 - 将新节点
X暂时着为红色。 - 如果
X的父节点Y是黑色,则插入完成,所有不变性均满足。 - 如果
X的父节点Y是红色,则违反了不变性3,出现“双红”问题,需要进一步处理。
处理“双红”问题:情况1
当新节点 X 和其父节点 Y 都是红色时,我们进入“双红”处理流程。情况1处理的是当 X 的叔父节点 Z(即 Y 的兄弟节点)存在且为红色的情形。
以下是情况1的处理步骤:
- 将父节点
Y和叔父节点Z重新着为黑色。 - 将祖父节点
W重新着为红色。 - 此时,
X和Y之间的“双红”问题被消除。 - 但是,将
W变为红色后,可能会在W与其父节点之间产生新的“双红”问题。 - 将
W视为新的“红色节点”,递归向上检查并处理,直到不再出现“双红”或到达根节点。
如果递归过程中将根节点染成了红色,只需最后将其重新着为黑色即可。这不会破坏不变性4,因为根节点出现在所有路径上。
处理“双红”问题:情况2
情况2处理的是当 X 的叔父节点 Z 不存在或为黑色的情形。这种情况可以通过常数次数的重新着色和旋转来彻底修复所有不变性。
以下是情况2的核心思路:
- 通过一次或两次旋转(左旋或右旋)来调整树的结构。
- 配合一次或多次重新着色,确保消除“双红”问题。
- 经过这些操作后,所有四个不变性都将得到恢复,且树的高度保持平衡。
由于具体旋转和着色步骤取决于 X、Y、W 之间的相对位置关系(例如,X 是 Y 的左孩子还是右孩子),这里不展开所有子情况。关键在于知道,通过 O(1) 次操作即可完成修复。

插入操作总结
本节课中我们一起学*了红黑树插入操作的完整流程。让我们总结一下关键步骤:
- 标准插入:将新节点
X作为红色叶子节点插入。 - 检查父节点:若父节点为黑,则完成。
- 处理双红:若父节点为红,则根据叔父节点的颜色进入不同情况。
- 情况1(叔父为红):重新着色并将问题向上传播。
- 情况2(叔父为黑或不存在):通过常数次旋转和重新着色彻底修复。
- 保证复杂度:整个插入过程最多进行
O(log n)次向上传播(情况1),因此总时间复杂度为O(log n)。
通过这种方法,红黑树在每次插入后都能高效地恢复平衡,维持其强大的性能保证。
067:哈希表操作与应用 🗂️

在本节课中,我们将开始讨论哈希表。我们将首先关注哈希表支持的操作以及一些经典应用场景。

哈希表极其有用。如果你想成为一名认真的程序员或计算机科学家,学*哈希表是必不可少的。许多人在过去的编程中可能已经使用过哈希表。有趣的是,哈希表支持的操作种类并不多,但它所支持的操作,其性能都非常出色。
哈希表是什么?🔍
从概念上讲,忽略所有实现细节,你可以将哈希表视为一个数组。数组的一个巨大优势是支持即时随机访问。例如,如果你想获取数组中第17个位置的元素,只需几条机器指令即可完成;想修改第23个位置的内容,也可以在常数时间内完成。
让我们思考一个应用场景:你想记住朋友的电话号码。如果你的朋友们名字都是1到10,000之间的整数,那么你可以直接维护一个长度为10,000的数组。例如,要存储你最好的朋友173的电话号码,只需使用这个数组中第173个位置即可。只要所有朋友的名字都是1到10,000之间的整数,这种基于数组的解决方案就非常有效。

当然,现实中你的朋友名字更有趣,比如Alice、Bob、Carol,还有姓氏。理论上,你可以为每一个可能遇到的名字(比如至少30个字母)在数组中预留一个位置。但这个数组会大到无法实现,其大小可能达到26的30次方。
你真正需要的是一个大小合理的数组,比如大约能容纳你所有朋友的数量(几千个左右)。这个数组的位置不是由1到10,000这样的整数索引,而是由你朋友的名字索引。你希望基于朋友的名字对这个数组进行随机访问。例如,你只需查找这个数组的“Alice”位置,就能在常数时间内获得Alice的电话号码。从概念层面讲,这就是哈希表能为你做的事情。
哈希表内部有很多“魔法”,我们将在其他视频中讨论。你需要一个映射,将你关心的键(如朋友的名字)映射到某个数组的数值位置,这由所谓的哈希函数完成。如果实现得当,哈希表就能提供这种功能,就像一个其位置由你存储的键来索引的数组。
哈希表的用途与操作 📝
你可以将哈希表的用途理解为维护一个可能不断演变的集合。这个集合的内容因应用而异,可以是任何事物。例如,如果你运营一个电子商务网站,你可能需要跟踪交易;或者跟踪人员,比如你的朋友及其相关数据;又或者跟踪IP地址,以了解访问你网站的唯一访客。
更正式地说,哈希表的基本操作包括:
- 插入:能够将数据插入哈希表。
- 删除:在许多(但非所有)应用中,也需要能够删除数据。
- 查找:通常是最重要的操作。
所有这三个操作都是基于键进行的。键通常是所关注记录的唯一标识符。例如,对于员工,可以使用社会保险号;对于交易,可以使用交易ID号;IP地址本身就可以作为键。有时,你只跟踪键本身(例如,仅记录IP地址列表,没有关联数据)。但在许多应用中,键会附带一堆其他数据(例如,员工的社会保险号附带该员工的其他信息)。当你进行插入、删除或查找时,都基于这个键。例如,在查找时,你将键输入哈希表,哈希表会返回与该键关联的所有数据。
有时,人们将支持这些操作的数据结构称为字典。哈希表的主要目的是支持类似字典的查找功能。但我觉得这个术语有点误导,因为大多数字典是按字母顺序排列的,支持类似二分查找的操作。我想强调的是,哈希表不维护其存储元素的顺序。如果你需要基于顺序的操作(如查找最小值或最大值),哈希表可能不是合适的数据结构,你可能需要堆或搜索树。
但对于那些基本上只需要查找数据、判断某物是否存在或获取其值的应用,你应该立刻想到哈希表,它很可能是这个应用的完美数据结构。

哈希表的性能保证 ⚡
看着这些支持的操作列表,你可能会觉得哈希表能做的事情不多。但再次强调,它做的事情,做得非常、非常好。
哈希表提供的第一要义是以下惊人的保证:所有这些操作都在常数时间内运行。这就像将哈希表视为一个数组,其位置由你的键方便地索引。就像数组支持常数时间的随机访问一样,哈希表也让你能在常数时间内基于键进行查找。
注意事项与前提条件 ⚠️
当然,这里有两个重要的前提条件:
- 实现质量:哈希表很容易被糟糕地实现。如果实现得不好,你就无法获得上述性能保证。这个保证是针对正确实现的哈希表而言的。如果你使用的是知名库中的哈希表,通常可以假设它实现得不错。但如果你需要自己实现哈希表和哈希函数(与我们将讨论的许多其他数据结构不同,有些人在职业生涯中可能确实需要这样做),那么只有实现得好才能获得这个保证。我们将在其他视频中详细讨论“实现得好”意味着什么。
- 最坏情况保证:与我们在本课程中解决的大多数问题不同,哈希表不提供最坏情况保证。你不能说对于任何可能的数据集,哈希表都能提供常数时间操作。实际情况是,对于非病态数据,在正确实现的哈希表中,你将获得常数时间的操作。
我们将在其他视频中进一步讨论这两个问题。目前,只需记住要点:哈希表在满足几个前提条件下,能提供常数时间的性能。
哈希表的应用场景 🚀
我们已经介绍了哈希表支持的操作以及理解它们的推荐方式,现在让我们将注意力转向一些应用场景。所有这些应用在某种意义上都是哈希表的简单用法,但它们都非常实用,经常出现。

应用一:去重
我们将讨论的第一个经典应用是从一堆数据中移除重复项,也称为去重问题。
在去重问题中,输入本质上是一个对象流。这里的“流”可以有两种典型情况:
- 你有一个巨大的文件(例如,记录你运营的网站上发生的一切的日志,或某天商店中所有交易的记录),你正在逐行遍历这个庞大的文件。
- 你随着时间的推移不断接收到新数据。例如,你正在运行部署在互联网路由器上的软件,数据包以极快的恒定速率通过该路由器。你可能会查看每个数据包中的发送方IP地址。
你的任务是忽略重复项,只记住在这个流中看到的不同对象。不难想象为什么在各种应用中需要执行此任务。例如,如果你运营一个网站,你可能希望跟踪在给定一天或一周内看到的唯一访客;如果你在进行网络爬虫,你可能希望识别重复文档并只记住它们一次(例如,搜索引擎不希望搜索结果中前两个链接指向不同URL的相同页面)。
使用哈希表的解决方案简单得可笑:
- 每当流中出现一个新对象时,你就在哈希表中查找它。
- 如果它已存在,那么它是重复项,你忽略它。
- 如果它不存在,那么这是一个新对象,你将其插入哈希表以记住它。
就这样,问题解决。

当流结束后(例如,读取完大文件后),如果你只想报告所有唯一对象,哈希表通常支持线性扫描,你可以直接报告所有不同的对象。
应用二:两数之和问题
让我们继续讨论第二个应用,可能稍微复杂一点,但仍然相当简单。这是编程项目五的主题,称为两数之和问题。
你被给予一个包含 n 个数字的数组作为输入,这些整数没有特定顺序。同时,你被给予一个目标总和,我们称之为 T。
你想知道的是:在这 n 个给定的整数中,是否存在两个整数,它们的和等于 T?
最明显和朴素的方法是检查输入中所有可能的整数对,这显然是一个 O(n²) 的算法。但我们需要问:我们能做得更好吗?是的,我们可以。
首先,让我们看看如果不使用任何数据结构(如哈希表),一个聪明的方法会怎么做。一个合理的改进方案如下:
- 第一步:预先对数组进行排序。例如,使用归并排序或堆排序,这需要 O(n log n) 时间。
- 第二步:对于数组中的每个元素 x,我们寻找其互补元素 T - x。由于数组已排序,我们可以使用二分查找来寻找 T - x。每个二分查找需要 O(log n) 时间。因为我们需要对 n 个元素中的每一个都执行一次查找,所以第二步的总时间是 O(n log n)。
因此,整个算法的时间复杂度是 O(n log n)(排序)+ O(n log n)(查找)= O(n log n)。这已经比 O(n²) 有了很大的改进。
但是,我们可以做得更好。对于两数之和问题,没有理由认为 O(n log n) 是下界。显然,因为数组是无序的,我们必须查看所有整数,所以我们不可能做得比线性时间更好,但我们可以通过哈希表实现线性时间。
此时你可能会问:这个问题的什么线索表明我们应该使用哈希表?哈希表将极大地加速任何主要工作是重复查找的应用。如果我们检查这个 O(n log n) 解决方案,一旦我们有了为每个 x 搜索 T - x 的想法,我们就会意识到,我们之所以需要排序数组,只是为了支持查找。二分查找所做的就是查找。因此,我们发现第二步的所有工作都来自重复查找。我们为每次查找支付了对数时间,而哈希表可以在常数时间内完成查找。所以,重复查找——叮咚!——让我们使用哈希表。这确实为这个问题提供了线性时间的解决方案。
基于哈希表的惊人保证,我们得到了两数之和问题的以下惊人解决方案(当然,同样受限于关于使用正确实现的哈希表和非病态数据的相同前提条件):
- 第一步:不是排序,而是将数组中的所有元素插入哈希表。插入是常数时间,所以这一步是 O(n) 时间。
- 第二步:与 O(n log n) 解决方案一样,对于数组中的每个 x,我们在哈希表中使用常数时间的查找操作来寻找其匹配元素 T - x。
如果对于某个 x,你确实找到了匹配元素 T - x,那么你可以报告 x 和 T - x,这证明确实存在一对整数其和为 T。如果对于输入数组 A 中的每个元素,你都无法在哈希表中找到匹配元素 T - x,那么肯定不存在和为 T 的整数对。因此,这个算法能正确解决问题。
常数时间的插入意味着第一步是 O(n) 时间。常数时间的查找意味着第二步也是 O(n) 时间。至少在我们之前讨论的前提条件下是如此。
哈希表的广泛应用 🌐
令人惊讶的是,计算机科学中有多少不同的应用本质上都可以归结为重复查找操作。因此,拥有像哈希表支持的这样超快的查找操作,使得这些应用能够扩展到惊人的规模。这确实令人惊叹,并且推动了许多现代技术的发展。让我再举几个例子,但如果你观察周围或在网上做一些研究,很快就会发现更多。
- 编译器:最初促使研究人员认真思考支持超快查找的数据结构,是在人们最初构建编译器的时候(大约20世纪50年代)。在编程语言的早期,为了找出之前定义和未定义的内容而进行的重复查找,成为编译器的一个瓶颈。哈希表的早期应用之一就是支持超快查找,以加快编译时间,跟踪函数和变量名等。
- 网络路由器:哈希表技术对互联网上的路由器软件也非常有用。例如,你可能希望阻止来自某些来源的网络流量。你可能怀疑某个IP地址已被垃圾邮件发送者接管,因此你只想忽略来自该IP地址的任何流量,甚至不希望它到达终端主机。路由器面临的任务本质上是一个查找问题:它可能有一个拒绝流量的IP地址黑名单,当数据包以极快的速率到达时,路由器需要立即查找该发送方IP地址是否在黑名单中,如果在,则丢弃数据包;如果不在,则让其通过。
- 搜索算法(如棋类程序):这里的“搜索算法”指的是像国际象棋程序这样进行博弈树探索的算法。我们已经在本课程中讨论了很多关于图搜索的内容,但在讨论广度优先搜索和深度优先搜索时,我们考虑的是基本上可以写下来的图(可以存储在机器的主内存或大型集群上)。然而,在国际象棋程序的背景下,你感兴趣的图比网络图要大得多得多。这个图的节点对应于国际象棋棋盘上所有可能的棋子配置,边对应于合法的移动。你无法写下这个图,因此无法像之前讨论的那样精确实现广度优先或深度优先搜索。但你仍然希望进行图探索,让你的计算机程序推理你下一步可能走的棋的短期影响。在图搜索中,一个非常重要的特性是你不想做冗余工作,不想重新探索已经探索过的地方。如果你不能写下整个图,那么仅仅记住你去过的地方就突然变成了一个非平凡的问题。但记住你去过的地方,从根本上说,就是一个查找操作。这正是哈希表的用武之地。更具体地说,在国际象棋程序中,你可以将遇到的每一个棋盘配置插入哈希表。在探索某个配置之前,你先在哈希表中查找是否已经探索过它。如果已经探索过,就不再费心;如果没有,则进行探索并将其插入哈希表。这样,在有限的时间预算内(比如三分钟),程序可以系统地探索尽可能多的配置,而不会浪费任何工作重复探索同一个配置。
当然,这些只是冰山一角。我只是想强调几个看起来相当不同的应用,以说服你哈希表无处不在。它们无处不在的原因是,对快速查找的需求无处不在。令人惊讶的是,有多少技术仅仅是由重复的快速查找驱动的。
作为课后思考,我鼓励你想想自己的生活或世界上的技术,猜猜哈希表可能在哪里使某些东西运行得飞快。我相信你不需要几分钟就能想出一些好例子。
总结 📚

在本节课中,我们一起学*了哈希表的核心概念。我们了解到,哈希表可以被概念化为一个由键索引的数组,它支持基于键的插入、删除和查找操作,并且在理想条件下能在常数时间内完成这些操作。我们探讨了哈希表性能保证的两个重要前提:正确的实现和非病态数据。接着,我们通过去重和两数之和这两个经典问题,具体看到了哈希表如何提供高效简洁的解决方案。最后,我们了解了哈希表在编译器、网络路由和复杂搜索算法(如棋类程序)等广泛领域的应用,认识到快速查找是驱动许多现代技术的核心需求之一。哈希表因其高效的查找能力,成为了计算机科学中不可或缺的基础数据结构。
068:哈希表实现细节 - 第一部分 🧮
在本节课中,我们将深入探讨哈希表的工作原理,特别是哈希函数的设计理念以及如何处理哈希表中不可避免的“碰撞”问题。我们将学*两种主流的碰撞解决方法,并理解哈希函数的核心作用。
哈希表概述
哈希表的核心目标是支持快速的查找操作。无论是记录网站交易、管理员工信息、追踪IP地址,还是存储国际象棋的棋盘配置,哈希表都能高效地支持插入、查找和删除操作。理想情况下,这些操作都能在常数时间内完成,但这依赖于哈希表的正确实现以及数据本身不具有“病态”特性。
哈希表的基本结构
上一节我们介绍了哈希表的目标,本节中我们来看看其基本实现结构。哈希表旨在结合数组的快速访问和链表的空间效率。
两种朴素方案的对比
以下是两种基础的、但各有缺陷的数据结构方案:

- 基于数组的方案:为所有可能存储的元素(即“全域”U)预留一个巨大的数组。优点是能在常数时间内进行插入、删除和查找。缺点是所需空间与全域大小成正比,这在许多应用中是不可行的。
- 基于链表的方案:仅存储实际存在的元素集合S。优点是空间仅与集合S的大小成正比。缺点是为了查找一个元素,通常需要遍历大部分链表,时间复杂度与链表长度成正比。
哈希表的目标是融合两者的优点:获得数组的常数时间操作和链表的线性空间消耗。
哈希表的折中方案
为了实现这一目标,哈希表使用一个数组,但其大小n仅与待存储的集合S的大小大致相当(例如,n约为S大小的两倍)。这个数组的每个位置被称为一个“桶”。
一个合理的疑问是:集合S是动态变化的,如何确定一个固定大小的数组?为了聚焦核心概念,本视频假设集合S的大小波动不大。在实际实现中,可以通过动态调整数组大小(例如,当元素过多时扩容并重新插入所有元素)来处理这个问题,这属于实现中的“锦上添花”部分。
哈希函数与碰撞
现在,我们有了一个空间合理的数组。接下来需要一个机制,将全域U中的任意元素(如IP地址、姓名)映射到这个数组的特定位置(桶索引)。负责这个映射的函数就是哈希函数。
公式:h(x) -> {0, 1, ..., n-1}
其中,x是来自全域U的键(如“Alice”),h(x)是哈希函数计算出的桶索引。例如,h("Alice") = 17意味着应将Alice的信息存储在数组的第17个桶中。
然而,一个根本性的问题随之而来:碰撞。当两个不同的键x和y被哈希函数映射到同一个桶时(即h(x) = h(y)),就发生了碰撞。
生日悖论与碰撞的必然性
碰撞为何不可避免?这可以用“生日悖论”来理解。生日悖论指出,在一个仅有23人的房间里,有两人生日相同的概率就超过了50%。更一般地说,当样本数量达到可能结果总数的平方根级别时,碰撞就极有可能发生。
应用到哈希表中,假设我们有n个桶,哈希函数将键均匀地随机映射到这些桶。那么,仅需大约√n个键插入哈希表,就极有可能发生碰撞。例如,对于一个有10,000个桶的哈希表,仅插入约100个元素就可能发生碰撞,而此时表的填充率仅为1%。因此,碰撞是哈希的固有现象,必须设计方法来处理它。
碰撞解决方法
既然碰撞无法避免,我们需要策略来解决它。以下是两种在实践中非常普遍的方法。
方法一:链地址法
链地址法(又称分离链接法)是一种直观且易于分析的解决方案。
其核心思想是:每个桶不再只存储一个元素,而是存储一个链表。所有被哈希到同一个桶的元素都放在这个链表中。
操作流程:
- 插入:计算键的哈希值找到对应桶,然后将新元素插入该桶的链表头部(或尾部)。
- 查找/删除:计算哈希值找到对应桶,然后在该桶的链表中执行标准的链表查找或删除操作。

代码示意:
# 假设 buckets 是一个链表数组
bucket_index = hash_function(key) % len(buckets)
linked_list = buckets[bucket_index]
# 在 linked_list 中执行插入、查找或删除操作
示例:
假设哈希表有4个桶。经过一些插入后,状态可能如下:
- 桶0:
[Alice] - 桶1:
[](空链表) - 桶2:
[Bob -> Daniel](Bob和Daniel发生碰撞,被链在一起) - 桶3:
[Carol]
方法二:开放定址法
开放定址法采用不同的思路:每个桶严格只存储一个元素。当发生碰撞时,它会按照一个预定的“探测序列”在哈希表中寻找下一个可用的空桶。
核心概念:哈希函数不再返回单个桶索引,而是生成一个探测序列 [h1(x), h2(x), h3(x), ...],依次尝试这些位置,直到找到空桶为止。

以下是两种常见的探测策略:
- 线性探测:如果目标桶
h(x)已被占用,则依次尝试h(x)+1,h(x)+2, ... 直到找到空桶。 - 双重哈希:使用两个哈希函数
h1(x)和h2(x)。首先尝试h1(x)。如果被占用,则后续尝试的位置是(h1(x) + i * h2(x)) mod n,其中i=1,2,3,...。
操作流程:
- 插入:按照探测序列依次检查每个桶。如果桶为空,则插入元素;如果桶已被占用且键相同(对于更新操作),则处理更新;如果键不同,则继续探测。
- 查找:按照相同的探测序列查找,直到找到该键或遇到空桶(说明键不存在)。
- 删除:删除操作较为复杂,不能简单地将桶置空,否则会中断后续元素的查找路径。通常采用“惰性删除”标记。
两种方法的选择
两种方法各有优劣,没有绝对的胜者。选择时可以参考以下经验法则:
- 空间考量:如果内存空间非常紧张,开放定址法可能更优,因为它没有存储指针的额外开销。
- 删除操作:如果删除是频繁或关键的操作,链地址法更简单直接。开放定址法的删除实现起来更复杂。
- 性能测试:对于关键代码,最佳实践是同时实现两种方法并进行性能测试,因为其表现可能与内存层次结构等底层细节有关。
哈希函数的设计
到目前为止,我们讨论了哈希表的结构和碰撞处理,但尚未涉及一个核心问题:哈希函数本身应该如何设计? 这是一个研究深入且兼具艺术与科学的问题。
一个理想的哈希函数应具备以下特点:
- 确定性:相同的输入总是产生相同的输出。
- 高效性:计算速度要快。
- 均匀性:能将键均匀地分布到所有桶中,最小化碰撞。
哈希函数的设计是下一部分将要深入探讨的主题。
本节课中我们一起学*了哈希表实现的核心细节。我们首先回顾了哈希表结合数组与链表优点的基本结构,然后重点探讨了碰撞的必然性(由生日悖论揭示),并详细介绍了处理碰撞的两种主要方法:链地址法和开放定址法。最后,我们指出了哈希函数设计的重要性,为后续内容做好了铺垫。理解这些基础概念是掌握高效哈希表实现的关键。
069:哈希表实现细节(第二部分)🔍

在本节课中,我们将深入探讨哈希函数的设计原则及其对哈希表性能的影响。我们将从哈希函数的基本要求出发,分析常见的设计误区,并介绍一些实用的设计方法。

哈希函数的重要性

上一节我们介绍了哈希表的基本实现方式(链地址法和开放地址法)。本节中我们来看看哈希函数如何影响哈希表的性能。
在链地址法中,插入操作是常数时间的,但查找和删除操作的性能取决于链表的长度。如果哈希函数能将数据均匀分布到各个桶中,每个链表的长度将大致相等,从而保证操作的效率。反之,如果哈希函数将所有数据映射到同一个桶中,链表长度将变得很长,导致操作退化为线性时间。
类似地,在开放地址法中,操作的性能由探测序列的长度决定。好的哈希函数能均匀分布数据,减少探测次数;而差的哈希函数可能导致探测序列过长,降低性能。
因此,哈希函数的设计对哈希表的性能至关重要。
理想哈希函数的特性
基于上述分析,我们可以总结出理想哈希函数应具备的两个特性:
- 均匀分布数据:哈希函数应尽可能将数据均匀分布到各个桶中,避免出现某些桶过满而其他桶为空的情况。
- 高效计算:哈希函数的计算应快速且占用常数时间,因为每次操作(插入、查找、删除)都需要调用哈希函数。
完全随机函数是均匀分布的理想模型,但无法在实际中使用,因为它需要存储所有随机选择,导致计算和存储开销过大。因此,我们需要在均匀分布和高效计算之间找到平衡。

常见哈希函数设计误区
以下是设计哈希函数时容易犯的错误:
- 使用关键字段的部分信息:例如,使用电话号码的前三位作为哈希值。如果大部分电话号码属于同一地区(如区号415),这些数据将全部映射到同一个桶中,导致性能下降。
- 忽略数据的分布特性:例如,使用内存地址的低位作为哈希值。如果内存地址都是偶数,且哈希表的大小也是偶数,那么所有奇数桶将永远为空,浪费空间并降低性能。
这些例子表明,设计哈希函数时需要仔细考虑数据的特性和分布,避免简单的映射导致性能问题。

设计哈希函数的实用方法
设计哈希函数通常分为两个步骤:
- 生成哈希码:将非数值类型的键(如字符串)转换为整数。例如,对于字符串,可以遍历每个字符,将字符的ASCII码累加并乘以一个常数,生成一个整数。
- 压缩函数:将生成的整数映射到哈希表的桶中。最简单的方法是使用取模运算:
h(x) = x mod n,其中n是桶的数量。
为了确保均匀分布,桶的数量n应选择为质数,避免与数据共享公因子。此外,n不应接*2的幂或10的幂,以减少数据模式对分布的影响。
以下是一个简单的字符串哈希函数示例:
def hash_function(key, n):
hash_code = 0
for char in key:
hash_code = (hash_code * 31 + ord(char)) % n
return hash_code

这种方法虽然简单,但在大多数情况下能提供可接受的性能。对于关键应用,建议进一步研究更先进的哈希函数设计方法。
总结
本节课中我们一起学*了哈希函数的设计原则及其对哈希表性能的影响。我们强调了均匀分布数据和高效计算的重要性,并分析了常见的设计误区。最后,我们介绍了一种实用的哈希函数设计方法,包括生成哈希码和压缩函数两个步骤。记住,设计哈希函数时需要仔细考虑数据特性,避免简单的映射导致性能问题。对于关键应用,建议深入研究更先进的设计方法。
070:病态数据集与全域哈希动机
在本节课中,我们将深入探讨哈希表,理解其表现出优异性能(实际上是常数时间性能)的条件。本视频的核心要点是解释一个概念:每一个哈希函数都有其自身的“氪石”——一个能使其性能急剧下降的病态数据集。这将为后续视频中需要谨慎处理的数学问题提供动机。
哈希表快速回顾
哈希表的根本目的是实现极快的查找,理想情况下是常数时间查找。当然,为了有东西可查,必须允许插入操作,所以所有哈希表都支持这两种操作。有时哈希表也允许删除元素,这取决于底层的具体实现。在使用链地址法(每个桶一个链表)时,删除操作很容易实现;而在开放寻址法中,删除可能比较复杂,有时甚至会被忽略。
当我们最初讨论哈希表时,我鼓励你像看待数组一样从逻辑上思考它。区别在于,哈希表不是通过数组位置索引,而是通过存储的键来索引。就像数组通过随机访问支持常数时间查找一样,哈希表也是如此。
然而,哈希表有一些“细则”。首先,哈希表必须被正确实现。这意味着两件事:一是桶的数量应与存储的元素数量相匹配(我们稍后会详细讨论);二是必须使用一个足够好的哈希函数。我们在之前的视频中讨论过糟糕哈希函数的危害,在接下来的视频中,我们将对哈希函数提出更严格的要求。第二个“细则”是,你最好没有遇到病态数据。在某种意义上,每个哈希表都有其“氪石”,即一个会使其性能变得相当糟糕的病态数据集。
冲突处理方法
在关于实现细节的视频中,我们也讨论了哈希表如何不可避免地处理冲突。在哈希表填满之前很久,你就会开始遇到冲突。因此,你需要某种方法来处理映射到同一个桶的两个不同键。以下是两种流行的方法:
- 链地址法:这是一个非常自然的想法,你只需将所有哈希到同一个桶的元素都保存在该桶中。你通过一个链表来跟踪它们。例如,在第17号桶中,你会找到所有哈希到桶17的元素。
- 开放寻址法:这种方法要求每个桶只存储一个键。如果两个元素都映射到桶17,你必须为其中一个找到另一个位置。处理方式是,你要求哈希函数不仅提供一个桶,而是提供一个完整的探测序列。如果你尝试插入桶17但17已被占用,你就转到探测序列中的下一个桶尝试插入,如果再次失败,就转到第三个桶,依此类推。我们简要提过两种指定探测序列的方法:一种是线性探测(失败后尝试18、19、20...直到找到空桶);另一种是双重哈希(使用两个哈希函数的组合,第一个指定初始探测桶,第二个指定后续每次探测的偏移量)。
在本课程中,我们通常会更多地讨论链地址法,这并不意味着它更重要,而是因为链地址法在数学上更容易分析,我们可以给出完整的证明。而开放寻址法的完整证明超出了本课程的范围。
负载因子
有一个非常重要的参数在决定哈希表性能方面起着重要作用,那就是负载因子,通常用 α 表示。
公式:α = (已插入且未删除的元素数量) / (哈希表中的桶数量)

正如你所料,向哈希表中插入的元素越多,负载因子就越大。保持元素数量不变而增加桶的数量,负载因子就会减小。
为了确保负载因子的概念清晰,并且你清楚不同的冲突解决策略,下一个测验将询问关于链地址法和开放寻址法哈希表中相关 α 值的范围。

测验:对于使用链地址法和开放寻址法实现的哈希表,以下关于负载因子 α 的陈述哪一项是正确的?
- α > 1 对两者都有意义。
- α > 1 对两者都没有意义。
- α > 1 对链地址法有意义,但对开放寻址法没有意义。
- α > 1 对开放寻址法有意义,但对链地址法没有意义。
正确答案是第三个选项:负载因子大于1对链地址法有意义,但对开放寻址法没有意义。原因很简单:在开放寻址法中,每个桶只能存储一个对象。一旦对象数量超过桶的数量,就没有地方放置剩余的对象,哈希表会在负载因子大于1时崩溃。另一方面,在链地址法中,负载因子大于1没有明显问题。例如,负载因子等于2意味着你向一个有1000个桶的哈希表中插入了2000个对象,理想情况下每个桶的链表里只有两个对象。
良好性能的必要条件
现在,让我们做一个简单但非常重要的观察,这是哈希表获得良好性能的必要条件。这涉及到第一个“细则”:如果你期望获得良好性能,就必须正确实现哈希表。
要点:只有保持负载因子为常数,你才能获得常数时间的查找。

对于开放寻址法的哈希表,这一点非常明显,因为你需要 α 不仅为 O(1),而且必须小于1(小于100%),否则哈希表甚至没有空间存放所有项目。即使对于使用链地址法实现的哈希表(负载因子大于1至少是有意义的),如果你想要常数时间的操作,也必须保持负载因子不要比1大太多。例如,如果你有一个有 n 个桶的哈希表,并哈希了 n log n 个对象,那么每个桶的平均对象数将是对数级的。记住,当你进行查找时,在哈希到桶之后,你必须遍历该桶中的链表进行穷举搜索。因此,如果你有 n log n 个对象和 n 个桶,你预期的查找时间更像是 O(log n),而不是常数时间。
对于开放寻址法,我们不仅需要 α = O(1),而且需要 α < 1。实际上,α 最好远低于1,你不希望开放寻址哈希表的负载接*90%或类似的值。
我希望这一页的要点是清晰的:如果你想要良好的哈希表性能,你需要负责的事情之一就是控制负载因子。对于链地址法,保持它最多是一个小常数;对于开放寻址法,保持它远低于100%。
你可能会想,如何控制负载因子?毕竟,你编写这个哈希表时,并不知道客户端会用它做什么,他们可以随意插入或删除。你能控制的是桶的数量,即 α 的分母。实际的哈希表实现会跟踪哈希表中存储的元素数量(分子)。随着分子增长,实现会确保分母以相同的速率增长,即增加桶的数量。如果 α 超过了某个目标值(比如0.75或0.5),你可以将桶的数量翻倍。定义一个新的哈希表,使用一个范围加倍的新哈希函数,这样分母加倍,负载因子就下降了一半。这就是控制它的方法。如果空间非常宝贵,你也可以在发生大量删除时(例如在链地址法中)缩小哈希表。

每个哈希函数都有其“氪石”
上一节我们讨论了为了获得期望的哈希表性能保证,必须在底层正确控制负载因子。接下来,我们必须做对的第二件事是使用足够好的哈希函数。一个好的哈希函数能将数据均匀地分散到各个桶中。最理想的情况是,一个哈希函数能独立于数据而表现良好——这也是本课程迄今为止的主题:无论输入是什么,算法都能保证(例如)运行得非常快。你可能会期望从这样的课程中学到“秘密的”总能表现良好的哈希函数。
不幸的是,这样的哈希函数并不存在。对于每一个哈希函数,它都有自己的“氪石”——一个病态数据集,会使其性能变得和你见过的最糟糕的常数哈希函数一样差。
原因很简单,这是哈希函数从巨大的宇宙(键空间)压缩到相对较少数量的桶这一过程的必然结果。让我详细说明。
固定任何一个你能想象到的最聪明的哈希函数 h。这个哈希函数将某个宇宙 U 映射到索引为 0 到 n-1 的桶。在所有有趣的情况下,宇宙的大小是巨大的,U 的基数远大于 n。
根据鸽巢原理的变体,至少有一个桶必须包含至少宇宙中 1/n 比例的键。也就是说,存在一个桶 i (0 ≤ i ≤ n-1),使得至少有 |U|/n 个键在哈希函数 h 下被映射到 i。
理解这一点的方法是记住哈希函数的映射图景:原则上,宇宙中的每个键都被映射到这些桶中的一个。哈希函数必须把每个键放到 n 个桶中的某一个里,所以其中一个桶必须至少包含所有可能键的 1/n。一个更具体的思考方式是:想象一个用链地址法实现的哈希表,并在脑海中想象你将宇宙中的每一个键都哈希进这个表。这个表会极度拥挤(你永远无法在计算机上存储它,因为它包含了 U 的全部元素),但它只有 n 个桶,所以其中一个桶必须至少包含总元素数的 1/n。
这里的要点是:无论哈希函数是什么,无论你把它设计得多聪明,总会存在某个桶(比如31号桶),它获得了至少其“公平份额”(宇宙的 1/n)的映射。现在,要构造我们的病态数据集,我们只需从这些映射到31号桶的元素中挑选。我们可以让这个数据集尽可能大,因为 |U|/n 是难以想象的大(因为 U 本身难以想象的大)。
对于这样的数据集,所有元素都会发生冲突,哈希函数将每个元素都映射到31号桶。这将导致糟糕的哈希表性能,与朴素的链表解决方案没有区别。例如,在链地址法中,31号桶里会有一个包含所有已插入元素的链表。对于开放寻址法,可能稍微复杂一些,但如果所有元素都冲突,你最终基本上也会得到线性时间的性能,与常数时间性能相去甚远。
对于那些认为这似乎只是无意义的抽象数学的人,我想指出两点:
- 至少,这些病态数据集表明,我们将不得不以不同于以往讨论算法的方式来讨论哈希函数。当我们讨论归并排序时,我们说它无论输入是什么都在 O(n log n) 时间内运行。对于哈希函数,我们将无法说哈希表无论输入是什么都有良好的性能,本页幻灯片证明了这是错误的。
- 虽然这些病态数据集不太可能随机出现,但有时你会担心有人为你的哈希函数构造病态数据,例如在拒绝服务攻击中。
Crosby 和 Wallach 在2003年的一篇研究论文中给出了一个非常聪明的例证。他们的主要观点是,存在许多现实世界的系统(他们最有趣的应用是一个网络入侵检测系统),你可以通过利用设计不良的哈希函数使其瘫痪。这些系统都关键性地使用了哈希表,其可行性完全依赖于从哈希表获得常数时间性能。如果你能为这些哈希表展示一个病态数据集,使其性能退化到线性(即退化为简单的链表解决方案),这些系统就会被破坏。Crosby 和 Wallach 研究的系统通常表现出两个特性:一是它们是开源的,你可以检查代码看到它们使用的哈希函数;二是哈希函数通常非常简单,主要是为速度而设计,因此很容易通过检查代码逆向工程出一个真正破坏哈希表(使其性能退化为线性)的数据集。
解决方案:随机化与全域哈希族

那么,我们该如何应对“每个哈希函数都有病态数据集”这一事实呢?这个问题既有实际意义(如果我们担心有人构造病态数据集进行拒绝服务攻击,应该使用什么哈希函数?),也有数学意义(如果我们不能给出像之前那样的数据无关保证,如何从数学上说明哈希函数具有良好的性能?)。
让我提出两种解决方案。
第一种方案更侧重于实际层面,即如果你担心有人构造病态数据集,应该实现什么样的哈希函数。答案是使用加密哈希函数,例如 SHA-2(一个针对不同桶数的哈希函数族)。这些内容超出了本课程的范围,你会在密码学课程中学到更多。我想指出的一点是,像 SHA-2 这样的加密哈希函数本身也有其病态数据(它们自己的“氪石”)。它们在实践中表现良好的原因是,找出这个病态数据集是不可行的。与 Crosby 和 Wallach 在应用程序源代码中找到的、易于逆向工程出坏数据集的简单哈希函数不同,对于 SHA-2 这样的函数,没人知道如何逆向工程出坏数据集。这里的“不可行”是密码学意义上的,类似于说如果正确实现 RSA 加密,破解它是不可行的,或者分解大数在一般情况下是不可行的。
我想提到的第二种解决方案是使用随机化,这在实际应用和数学分析上都是合理的。具体来说,我们不会设计一个单一的聪明哈希函数(因为我们已经知道单一的哈希函数必然有病态数据集),而是设计一个非常聪明的哈希函数族,然后在运行时随机选择其中一个函数使用。
现在,我们希望(并且能够)为哈希函数族证明的保证,其精神非常类似于快速排序。回想一下,在快速排序算法中,对于几乎任何固定的枢轴选择序列,都存在一个病态输入会使快速排序退化为 O(n²) 运行时间。我们的解决方案是随机化快速排序:不是在运行时预先承诺任何特定的选择枢轴方法,而是随机选择枢轴。我们证明了关于快速排序的什么?我们证明了对于任何可能的输入数组,快速排序的平均运行时间是 O(n log n),其中平均是对快速排序运行时随机选择求取的。
在这里,我们将做同样的事情。我们现在可以说:对于任何数据集,平均而言(关于我们运行时选择的哈希函数),哈希函数将表现良好,即它会将数据均匀地分散开。我们颠倒了上一节幻灯片中的量词顺序。上一节说:如果我们预先承诺一个单一的哈希函数(固定一个 h),那么就存在一个能破坏该函数的数据集。这里我们把它颠倒过来:对于每个固定的数据集,随机选择的哈希函数平均而言将在该数据集上表现良好,就像在快速排序中一样。
请注意,这并不意味着我们不能让程序开源。我们仍然可以发布代码,说明“这是我们的哈希函数族,代码中将从这个集合中随机选择一个哈希函数”。关键在于,通过检查代码,你无法知道算法在运行时做出了什么随机选择,因此你对实际的哈希函数一无所知,也就无法为运行时选择的哈希函数逆向工程出病态数据集。
接下来的几个视频将详细阐述这第二种解决方案:使用运行时随机选择的哈希函数,作为一种在每一个数据集上(至少平均而言)都能表现良好的方法。
路线图

让我简要介绍一下接下来的内容。我将把关于这种随机化解决方案细节的讨论分为三个部分,分布在两个视频中。
在下一个视频中,我们将从定义开始:什么是我所说的“哈希函数族”,使得随机选择一个时,你很可能会做得很好? 这个定义被称为全域哈希函数族。
一个数学定义本身几乎没有价值。为了有价值,它必须满足两个属性:
- 必须存在有趣且有用的例子满足该定义。也就是说,必须存在有用的、满足这个全域族定义的哈希函数。因此,第二部分将向你展示它们确实存在。
- 数学定义需要有应用价值。也就是说,如果你能满足定义,那么好事就会发生。这将是第三部分。

本节课中,我们一起学*了哈希表获得良好性能的两个关键前提:控制负载因子,以及认识到单一哈希函数必然存在病态数据集。我们探讨了使用加密哈希函数或随机化(从精心设计的哈希函数族中随机选择)作为应对病态数据集的实用方案,并引出了“全域哈希”这一核心数学概念,为下一节课的深入分析奠定了基础。
071:全域哈希定义与示例
概述
在本节课中,我们将要学*全域哈希的定义及其构造方法。我们将首先理解为什么单一哈希函数无法在所有数据上表现良好,然后介绍如何通过随机选择哈希函数来保证平均性能。我们将详细定义全域哈希族,并展示一个具体的、易于计算的哈希函数族示例,该示例满足全域哈希的定义。
为什么需要全域哈希?
上一节我们介绍了单一哈希函数在面对特定“病态”数据时可能表现极差。本节中我们来看看如何通过随机化方案来解决这个问题。

核心思想是:我们不再依赖单一的哈希函数,而是准备一个哈希函数族。在运行时,我们随机从这个族中选择一个函数来使用。这样,无论输入数据是什么,我们都能保证在平均情况下获得良好的性能。
以下是实现这一目标的三步计划:
- 提出一个“好的”随机哈希函数的数学定义,即全域哈希族。
- 展示存在简单、易计算且满足该定义的哈希函数示例。
- 分析使用全域哈希(特别是链地址法)时哈希表的性能,证明其操作具有常数期望时间复杂度。
本视频将涵盖前两部分。

全域哈希族的定义
让我们正式定义什么是“好的”随机哈希函数族。
我们假设有一个固定的全域(例如IP地址、人名等),记作 U。同时,我们确定了哈希桶的数量 n。
我们称一个哈希函数族 H 是全域的,当且仅当它满足以下条件:
对于全域中任意两个不同的键 x 和 y,从族 H 中随机均匀地选择一个哈希函数 h,则 x 和 y 发生碰撞(即
h(x) = h(y))的概率至多为 1/n。
用公式表示:
Pr_{h ∈ H}[h(x) = h(y)] ≤ 1/n, 其中 x ≠ y,且 x, y ∈ U。
1/n 这个概率从何而来?它源于一个理想但不可行的“黄金标准”——完全均匀随机哈希。在这种理想情况下,每个键被独立、均匀地分配到一个桶中。对于两个不同的键,第二个键与第一个键碰撞的概率恰好是 1/n。因此,全域哈希的定义要求我们设计的实用哈希函数族,其碰撞概率不能比这个理想标准更差。
定义辨析

这个定义相当微妙。为了帮助理解,请思考以下问题:另一个看似合理的定义是“对于任意键 k 和任意桶 i,随机选择哈希函数 h 使得 h(k) = i 的概率为 **1/n`”。这个定义与全域哈希的定义是否等价?
正确答案是:不等价。
存在满足新定义但不满足全域哈希定义的函数族,也存在同时满足两者的函数族。
- 同时满足的例子:取 H 为所有从全域 U 映射到 n 个桶的函数的集合。随机从这个族中选择函数,就等同于完全随机哈希,它显然同时满足两个定义。
- 仅满足新定义的例子:考虑一个非常小的族 H,它只包含 n 个常数函数。第 i 个函数总是将所有键映射到桶 i。对于任意键 k 和桶 i,恰好有 1/n 的概率选到那个总是输出 i 的常数函数。因此它满足新定义。但对于任意两个不同的键 x 和 y,在任何常数函数下它们都必然碰撞,碰撞概率为 1,远大于 1/n,因此它不是全域哈希族。
这个例子表明,新定义的性质不足以保证良好的哈希性能,而全域哈希的定义才是我们所需要的强保证。

构建实用的全域哈希函数
数学定义的价值在于其有用性。这需要两点:
- 存在性:存在我们关心的、易于实现的对象(即简单易算的哈希函数)满足该定义。
- 有效性:满足该定义能带来好的结果(即优秀的哈希表性能)。
本节我们将兑现第一个承诺:构造一个满足全域哈希定义的、实用的哈希函数族。下一个视频将兑现第二个承诺,分析其性能。
我们以IP地址为例,但构造方法是通用的。
一个IP地址可以看作一个32位整数,通常表示为4个8位部分,即一个四元组 (x1, x2, x3, x4),其中每个 xi 在0到255之间。
我们计划使用的哈希函数与上一节提到的“快速粗糙”哈希函数类似,但会乘以一组随机系数,即取一个随机线性组合。关键的不同在于,我们将能证明这个函数族是全域的。

构造步骤:
- 选择桶的数量 n。n 应大致与要存储的对象数量相当(例如,对象数量的两倍)。为确保理论性质,n 应为一个素数。例如,若要存储约500个IP地址,可选择
n = 997。 - 定义哈希函数族。族中的每个函数由一组四个系数
a = (a1, a2, a3, a4)唯一确定,其中每个ai是从{0, 1, ..., n-1}中随机均匀选取的整数。 - 对于给定的系数
a,哈希函数h_a对输入IP地址x = (x1, x2, x3, x4)的计算方式为:
h_a(x) = (a1*x1 + a2*x2 + a3*x3 + a4*x4) mod n
代码描述:
def h_a(x, a, n):
# x: 四元组 (x1, x2, x3, x4), 例如IP地址
# a: 四元组 (a1, a2, a3, a4), 随机系数
# n: 桶的数量(素数)
result = 0
for i in range(4):
result += a[i] * x[i]
return result % n
这个函数族非常实用:
- 存储开销小:每个哈希函数只需存储四个系数
(a1, a2, a3, a4)。 - 计算速度快:仅需四次乘法、三次加法和一次取模运算,是常数时间复杂度。

令人惊叹的是,如此简单高效的函数族,竟然满足全域哈希的严格定义。
证明概要:为什么这个构造是全域的?
我们需要证明:对于任意两个不同的IP地址 x = (x1, x2, x3, x4) 和 y = (y1, y2, y3, y4),随机选择系数 a 时,碰撞概率 Pr[h_a(x) = h_a(y)] ≤ 1/n。
证明思路:
- 设定与化简:假设
x和y在第四个分量上不同(x4 ≠ y4,其他情况证明类似)。碰撞条件h_a(x) = h_a(y)经过代数变换后,等价于:
a4 * (x4 - y4) ≡ - (a1*(x1-y1) + a2*(x2-y2) + a3*(x3-y3)) (mod n) - 延迟决策原则:我们先固定随机系数
a1, a2, a3的值。此时上述等式右边是一个固定的数(记作C)。我们只需关注,在a4随机选择时,等式成立的概率。 - 关键观察:由于
x4 ≠ y4,且我们确保n是大于每个xi的素数(这里n > 255),因此(x4 - y4) mod n是一个非零整数。在模素数n的运算中,一个非零数乘以一个随机均匀的a4(模n),其结果a4 * (x4 - y4) mod n也会是均匀分布在{0, 1, ..., n-1}上的。- (此处涉及初等数论:当
n为素数,d ≠ 0 (mod n)时,集合{a*d mod n | a = 0,...,n-1}恰好是{0,...,n-1}的一个排列。)
- (此处涉及初等数论:当
- 得出结论:等式左边
a4 * (x4 - y4) mod n均匀随机,它等于右边固定值C的概率恰好是 1/n。这意味着,对于任意固定的a1, a2, a3,最多只有 1/n 的a4选择会导致碰撞。因此,在所有系数(a1, a2, a3, a4)的随机选择中,碰撞的总概率也至多是 1/n。
这就完成了证明,表明我们构造的哈希函数族 H = {h_a} 是全域的。
总结
本节课中我们一起学*了:
- 全域哈希的定义:一个哈希函数族是全域的,如果对于任意两个不同的键,随机选择族中函数导致它们碰撞的概率不超过 1/n(即理想随机哈希的水平)。
- 定义的意义:该定义抓住了“平均性能良好”的本质,且比一些看似合理的弱定义更强。
- 具体的构造:我们给出了一个基于随机线性组合和模素数运算的、简单实用的哈希函数族构造(以IP地址为例),并概述了其满足全域性定义的证明。
通过使用全域哈希,我们能够抵御针对特定哈希函数的“病态”数据攻击,为哈希表的稳定高效性能奠定了理论基础。下一节,我们将分析在实际哈希表(使用链地址法)中应用全域哈希所带来的性能保证。
072:全域哈希链式分析-进阶选学 🧮
在本节课中,我们将要学*如何形式化地分析哈希表的性能。我们将证明,如果从全域哈希函数族中随机选择一个哈希函数,并配合链地址法,那么哈希表的所有操作都能获得期望的常数时间复杂度。
哈希表性能保证概述
我们之前观察到,任何固定的哈希函数都可能遭遇最坏情况的数据集。作为解决方案,我们引入了全域哈希函数族的概念,它允许我们在运行时随机选择一个哈希函数。上一节我们看到了一个简单且高效的全域哈希函数族示例。
本节的目标是严格证明:如果你从一个全域哈希函数族(如上节所述)中均匀随机地选取一个哈希函数,那么哈希表的所有操作都能保证期望的常数时间性能。
全域哈希函数族定义回顾
在开始证明之前,让我们回顾一下全域哈希函数族的定义。这个定义是我们证明性能保证的基础。
我们讨论的是一个哈希函数集合 H。这个集合代表了所有你可能在运行时决定使用的哈希函数。宇宙 U(例如所有IP地址)和桶的数量 n(例如10000)是固定的。
一个函数族 H 被称为全域的,当且仅当对于宇宙 U 中任意两个不同的元素 x 和 y,以下条件成立:
公式:
Pr_{h ∈ H}[h(x) = h(y)] ≤ 1/n
这意味着,对于任意一对不同的IP地址,随机从族 H 中选择一个哈希函数 h,它们发生碰撞(被映射到同一个桶)的概率不超过 1/n。如果 n = 10000,那么这个概率最多是万分之一。
定理:卡特-韦格曼定理
我们现在要阐述并证明的定理,其思想源于卡特和韦格曼1979年的论文。它为哈希表的性能分析提供了一个清晰、模块化的框架。
定理保证:
对于使用链地址法(每个桶一个链表)实现的哈希表,如果我们从全域哈希函数族 H 中均匀随机地选择一个哈希函数 h,那么所有操作(插入、删除、查找)的期望运行时间都是常数。

前提条件(注意事项):
- 期望值: 保证是针对哈希函数
h的随机选择取期望值。 - 任意数据集: 此保证对任意存储在哈希表中的数据集
S都成立。这类似于随机化快速排序的保证:无论输入是什么,期望运行时间都是O(n log n)。 - 控制负载因子: 要获得常数时间性能,必须确保哈希表的负载因子
α是一个常数。
公式:
α = |S| / n
其中|S|是表中对象的数量,n是桶的数量。 - 哈希函数评估速度: 评估哈希函数
h本身必须在常数时间内完成。上节介绍的简单线性组合哈希函数满足此条件。
证明思路:聚焦于不成功查找
哈希表支持多种操作,但分析不成功查找的运行时间就足够了。在链地址法中,操作步骤是:先哈希到对应桶,再遍历该桶的链表。
不成功查找是最坏情况,因为它需要遍历完整个链表才能确认元素不存在。因此,我们分析不成功查找的时间。
运行时间构成:
- 计算哈希值
h(x):假设为常数时间O(1)。 - 遍历目标桶中的链表:时间与该链表的长度成正比。
因此,关键在于分析目标桶的链表长度。我们将其定义为一个随机变量 L。
公式:
查找时间 = O(1) + O(L)
由于 L 依赖于随机选择的哈希函数 h,所以 L 本身是一个随机变量。我们的目标是计算其期望值 E[L]。如果 E[L] = O(1),那么期望查找时间就是 O(1)。
分解原理的应用
为了计算 E[L],我们将使用一个强大的通用技巧:将复杂随机变量分解为简单的指示器随机变量之和,然后利用期望的线性性质。
以下是三个步骤:
- 识别目标变量: 我们关心的是链表长度
L。 - 分解为指示器变量: 将
L表示为多个 0/1 随机变量的和。 - 应用线性期望:
E[L]等于这些指示器变量期望值的和。
接下来,让我们应用这个原理。

将链表长度分解为指示器变量
设数据集为 S(哈希表中存储的对象)。我们正在查找一个不在 S 中的对象 x。
对于数据集 S 中的每一个对象 y,我们定义一个指示器随机变量 Z_y:
公式:
Z_y = 1,如果 h(y) = h(x)(即 y 与 x 碰撞)
Z_y = 0,其他情况
Z_y 指示了特定的 y 是否不幸地与我们要查找的 x 被映射到了同一个桶。
现在,观察链表长度 L。L 恰好等于所有与 x 碰撞的 y 的数量。因此,我们可以写出:
公式:
L = Σ_{y ∈ S} Z_y
这个等式恒成立,无论选择了哪个哈希函数 h。

计算期望链表长度
现在我们计算 E[L]。
公式推导:
E[L] = E[ Σ_{y ∈ S} Z_y ] (根据分解)
= Σ_{y ∈ S} E[ Z_y ] (根据期望的线性性质)
对于一个 0/1 指示器随机变量 Z_y,其期望值恰好等于 Z_y = 1 的概率。
公式:
E[ Z_y ] = Pr( Z_y = 1 ) = Pr( h(y) = h(x) )
这个概率就是对象 y 与查找键 x 发生碰撞的概率。
应用全域哈希性质
根据全域哈希函数族的定义,对于任意两个不同的元素 x 和 y,碰撞概率有上界:
公式:
Pr( h(y) = h(x) ) ≤ 1/n
其中 n 是桶的数量。
将此上界代入我们的期望计算中:
公式推导:
E[L] = Σ_{y ∈ S} E[ Z_y ]
≤ Σ_{y ∈ S} (1/n)
= |S| / n
= α (负载因子)
我们之前假设负载因子 α 是一个常数(即 |S| = O(n))。因此,我们得到:

公式:
E[L] ≤ α = O(1)
完成证明
我们已经证明了,在随机选择的全域哈希函数下,不成功查找时期望遍历的链表长度 E[L] 是一个常数。
由于:
- 计算哈希函数需要常数时间。
- 期望的链表遍历时间是常数。

因此,不成功查找的期望总时间是常数 O(1)。这个结论自然延伸到插入、删除和成功查找操作(它们的运行时间不会比不成功查找更差)。

至此,卡特-韦格曼定理得证。

总结 🎯
本节课中,我们一起学*了如何严格分析哈希表的性能。我们证明了:

- 通过使用链地址法和从全域哈希函数族中随机选择的哈希函数,可以保证哈希表操作具有期望的常数时间复杂度。
- 证明的关键步骤是:
- 将性能分析归结为计算目标桶的期望链表长度
E[L]。 - 利用指示器随机变量和期望的线性性质,将
E[L]的计算转化为求和问题。 - 应用全域哈希的定义,将碰撞概率上界为
1/n。 - 最终得出
E[L] ≤ α,在负载因子α为常数的条件下,E[L] = O(1)。
- 将性能分析归结为计算目标桶的期望链表长度
这个定理完美地实现了我们最初的目标:一种对任意输入数据都有效的、具有期望常数时间性能的哈希表方案。
073:开放寻址哈希表性能分析
在本节课中,我们将要学*开放寻址哈希表的性能分析。上一节我们介绍了使用链地址法实现的哈希表,并证明了在良好条件下其具有常数级别的期望性能。本节中我们来看看另一种重要的哈希表实现范式——开放寻址法,并探讨其性能特点。
开放寻址法回顾
与链地址法不同,开放寻址法要求每个槽位最多只能存储一个对象。因此,它仅在负载因子 α < 1 时才有意义。当需要插入新对象时,如果哈希函数指定的初始槽位已被占用,则需要根据一个探测序列继续寻找空槽位。
以下是两种常见的探测序列生成策略:
- 双重哈希:使用两个哈希函数 H1 和 H2。H1 决定初始探测位置,H2 决定每次探测失败后的增量偏移。
- 线性探测:仅使用一个哈希函数决定初始位置。若该位置被占,则顺序检查下一个槽位(即位置加1),直到找到空位。


理想化性能分析
对开放寻址策略进行严格的数学分析非常复杂。为了获得直观的性能预期,我们引入一个启发式假设:哈希函数使得所有可能的 n! 种探测序列出现的概率均等。请注意,实际使用的哈希函数(如双重哈希或线性探测)并不满足此假设,但该分析为我们提供了一个最佳情况下的性能参考基准。
在这个理想化假设下,向哈希表中插入一个新对象的期望时间约为 1 / (1 - α),其中 α 是负载因子(存储对象数 / 槽位总数)。
这个公式意味着:
- 当 α 远离1时(例如 α = 0.5),期望插入时间 1 / (1 - 0.5) = 2 次探测,性能优异。
- 当 α 接*1时(例如 α = 0.9),期望插入时间 1 / (1 - 0.9) = 10 次探测,性能急剧下降。
- 因此,使用开放寻址法时,必须严格控制负载因子,通常建议保持在 0.7 甚至更低。
相比之下,链地址法在通用哈希下,操作时间期望为 1 + α,即使 α > 1 也能正常工作,对负载因子的容忍度更高。

性能公式推导
接下来,我们理解为何在理想化假设下,期望插入时间是 1 / (1 - α)。我们可以通过一个简单的抛硬币实验来类比推导。


考虑第一次探测。假设当前负载因子为 α,则一个随机槽位为空的可能性是 1 - α。因此:
- 以概率 1 - α,第一次探测就找到空位,插入成功。
- 以概率 α,第一次探测失败(槽位被占),需要继续探测。


这个过程类似于抛一枚有偏硬币,其中抛出“正面”(代表找到空位)的概率为 p = 1 - α。那么,成功插入所需的探测次数 N,就等价于连续抛这枚硬币直到第一次出现“正面”所需的次数。
我们需要计算随机变量 N 的期望值 E[N]。以下是推导过程:
- 首先,我们至少需要进行一次尝试(第一次抛硬币)。
- 如果第一次尝试失败(概率为 α),那么从第二次尝试开始,我们面临的情况与最初完全相同,仍需 E[N] 次尝试才能成功。
因此,我们可以建立如下方程:
E[N] = 1 + α * E[N]
- 解这个方程:
E[N] - α * E[N] = 1
(1 - α) * E[N] = 1
E[N] = 1 / (1 - α)
这就证明了在理想化抛硬币模型下,期望探测次数为 1 / (1 - α)。由于实际探测过程不会重复检查同一个槽位,其成功概率略高于此模型,因此 1 / (1 - α) 是实际期望插入时间的一个有效上界。
线性探测的性能分析
对于线性探测,之前“所有探测序列等可能”的假设完全不成立,因为一旦确定了初始探测位置,后续探测序列就完全确定了(线性扫描)。因此,我们需要一个更贴合线性探测特点但仍属理想化的假设:不同键的初始探测位置是均匀随机且相互独立的。
在这个假设下,我的同事、算法大师高德纳(Donald Knuth)在50年前(1962年)证明了一个经典结果:使用线性探测时,在负载因子 α 下,插入操作的期望时间约为 1 / (1 - α)²。


这个结果意味着:
- 线性探测的性能曲线比双重哈希等策略的 1 / (1 - α) 更陡峭。
- 例如,当 α = 0.5 时,期望插入时间约为 1 / (1 - 0.5)² = 4 次探测。
- 当 α = 0.9 时,期望插入时间将激增至约 1 / (1 - 0.9)² = 100 次探测。
- 尽管如此,只要 α 有界且远离1,平均操作时间仍然是常数级别。
虽然线性探测的理论性能稍差,但在实践中,由于其实现简单且常能与内存层次结构良好交互,它仍然被广泛使用。在实际应用中,应根据具体场景(如数据特性、性能要求)通过测试来选择合适的哈希表实现策略。
总结



本节课中我们一起学*了开放寻址哈希表的性能分析。我们首先在“所有探测序列等可能”的理想化假设下,推导出插入操作期望时间为 1 / (1 - α),并强调了控制负载因子的重要性。随后,我们针对线性探测这一特定策略,介绍了高德纳在其更合理的假设下得出的结果:期望时间约为 1 / (1 - α)²。这些分析表明,尽管开放寻址法对负载因子更为敏感,但只要设计得当,它依然能提供高效的平均性能。在实际应用中,建议通过原型测试来确定最适合特定场景的哈希表实现方案。
074:布隆过滤器基础 🧠
在本节课中,我们将要学*布隆过滤器。这是一种由 Burton Bloom 在 1970 年提出的数据结构。布隆过滤器是哈希表的一种变体,你会从中看到许多哈希表讨论中的熟悉概念。布隆过滤器的优势在于,它比普通的哈希表更节省空间。然而,它也存在一个缺点:在进行查找操作时,存在非零的误报概率。尽管如此,对于某些应用场景来说,这仍然是一个巨大的优势。因此,这是一个非常酷的想法和数据结构,在实践中应用相当广泛。现在,让我们开始讨论它。
我们将按照讨论新数据结构的常规流程进行。
首先,我会告诉你布隆过滤器支持哪些操作,以及这些操作的性能表现。换句话说,就是与这个数据结构对应的 API 是什么。


其次,我会谈谈它的适用场景,即一些潜在的应用。
然后,我们将深入其内部实现,讲解一些实现细节,重点解释为什么布隆过滤器会带来这样的性能权衡。
操作与性能
布隆过滤器存在的首要原因与哈希表完全相同。它支持超快的插入和超快的查找。你可以将数据存入其中,并可以查询之前是否存入过某个数据。
当然,你可能会想,我们已经知道一个支持超快插入和查找的数据结构——哈希表。为什么我还要向你介绍另一个具有完全相同操作的数据结构呢?
让我来告诉你布隆过滤器相对于普通哈希表的优缺点。
优点
最大的优点是,布隆过滤器比哈希表更节省空间。无论哈希表是使用链地址法还是开放寻址法实现,布隆过滤器都能为每个对象占用更少的空间。事实上,正如我们将看到的,使用布隆过滤器占用的空间甚至比对象本身还要小。
缺点
首先,这主要适用于那些你只想记住“见过哪些值”的应用场景。你并不是要存储指向对象本身的指针,而只是想记住这些值。因此,布隆过滤器的第一个缺点是:因为我们希望极其节省空间,甚至不想记住对象本身,只关心是否见过它,所以我们无法在布隆过滤器中存储对象,甚至是指向对象的指针。我们只会记住见过什么和没见过什么。
有些人可能知道这种哈希表变体的术语叫“哈希集合”,以区别于完整的哈希表或哈希映射。
第二个缺点是,至少在我将要描述的标准布隆过滤器实现中,不支持删除操作。你只能插入,不能删除。删除的情况与使用开放寻址法实现的哈希表非常相似。并不是说不能实现支持删除的布隆过滤器,你可以,并且有相关的变体,但这需要更多的工作,我们在此不讨论。因此,至少在标准布隆过滤器中,你应该将其视为适用于删除不是首要操作的场景。
第三个缺点,也是我们在之前任何数据结构中未曾见过的,是布隆过滤器实际上会犯错。那么,这种数据结构可能犯什么错误呢?你只是在查找东西而已。
一种错误是漏报。这意味着你之前插入了某个东西,然后查找它时,哈希表或布隆过滤器却说它不存在。布隆过滤器不会出现这种形式的漏报。你插入一个东西,之后查找它,它肯定会确认你之前插入过。
但是,布隆过滤器会出现误报。这意味着,尽管你从未将某个给定的 IP 地址插入到布隆过滤器中,但如果你之后查找它,它可能会说你插入过。因此,在某种意义上,布隆过滤器中有时会存在“幻影”对象,它认为这些对象已被插入,但实际上并没有。
既然我现在向你展示了两个功能基本相同的哈希表和布隆过滤器,你可能会想知道哪一个更合适、更有用。由于两者之间存在这些权衡,答案正如你所料:取决于应用场景。
如果应用场景中空间确实非常宝贵,你可能需要考虑使用布隆过滤器,特别是当少量的误报概率不是致命问题时。如果你的应用场景绝对不能接受误报,那么当然不应该使用布隆过滤器,而应该考虑使用哈希表。
应用场景
那么,在哪些情况下人们确实会使用布隆过滤器呢?要么是你真的非常关心空间,要么是你不太在意误报概率。
以下是布隆过滤器的一些应用场景:
- 拼写检查器:这是布隆过滤器最早的应用之一,大约在40年前。实现方式如下:首先有一个插入阶段,你基本上会遍历整个字典,逐词将每个有效单词插入布隆过滤器。之后,当你收到某人写的新文档时,你会逐词检查。对于每个单词,你查询它是否在布隆过滤器中。如果布隆过滤器说是,则将其视为拼写正确的单词;如果不在,则视为拼写错误的单词。误报概率意味着这不是一个完美的拼写检查器,有时你会查找一个拼写错误的单词,而布隆过滤器(以很小的概率)会误判为合法单词。这在当时空间非常宝贵的年代是一个优势。
- 禁止密码列表:另一个至今仍然相关的应用是跟踪禁止使用的密码列表。你可能希望阻止用户使用过于简单、容易猜测或过于常见的密码。实现方式与拼写检查器类似:首先将所有你不想让任何人使用的密码插入布隆过滤器。然后,当用户尝试输入新密码时,你在布隆过滤器中查找它。如果得到肯定的查找结果,你就告诉用户这个密码不行,需要选择另一个。在这个应用中,你实际上并不关心错误(误报)。假设错误率是1%或0.1%,那只是意味着偶尔(每100或1000个用户中)有一个用户输入了一个完全强壮的密码,却被布隆过滤器拒绝,他们只需要再输入一次而已,这没什么大不了的。如果空间宝贵,使用这种超轻量级的数据结构来跟踪这些被阻止的密码绝对是一个优势。
- 网络路由器软件:如今,布隆过滤器的一个杀手级应用无疑是部署在网络路由器上的软件。这些路由器负责在互联网上传输数据包。布隆过滤器在网络路由器中找到肥沃的应用土壤有几个原因:首先,空间预算通常有限;其次,需要超快的数据结构来处理以惊人速率涌入的数据包。布隆过滤器是网络路由器上许多不同任务的主力,例如跟踪被阻止的IP地址、跟踪缓存内容以避免不必要的查找、维护统计数据以检查拒绝服务攻击等等。
总结
作为一名专业程序员,关于布隆过滤器你应该记住什么?这个工具在你的工具箱中起什么作用?
就支持的操作而言,它与哈希表相同,目的是实现超快的插入和查找。但布隆过滤器是哈希表的一个更轻量级的版本,因此更节省空间。不过,它有一个缺点,即存在较小的误报概率。这些是你在决定是否在某个应用中使用这种数据结构时应该记住的关键特性。
实现原理
在讨论了操作和适用场景之后,让我们进入下一个层次,深入内部看看它们是如何实现的。这确实是一个非常简洁而酷的想法。
与哈希表类似,布隆过滤器本质上包含两个组成部分:首先是一个数组,其次是哈希函数,实际上是多个哈希函数。
我们将有一个随机访问数组,但与之前讨论的拥有 N 个桶或槽位的哈希表不同,这个数组中的每个条目将只是一个单个比特。数组中的每个条目只能取两个值:0 或 1。
思考布隆过滤器占用空间的方式是依据每个已插入对象所占用的比特数。如果你插入了一个数据集 S,那么总比特数为 n(数组长度),已插入对象的数量是 S 的基数。所以,n / |S| 就是这个数据结构中为数据集中的每个条目所使用的比特数。
你可以调整布隆过滤器,使这个比率成为不同的数值。但现在,我建议你将这个比率设想为 8。也就是说,对于存储在布隆过滤器中的每个对象,你只使用 8 比特的内存。这将帮助你体会到这些数据结构有多么神奇,因为也许我们的数据集是像 IP 地址这样的东西,它有 32 比特。我这里说比率是 8,意味着我们绝对没有实际存储 IP 地址本身。我们插入这个 32 比特的对象,却只使用 8 比特的内存来以某种方式记住它是否存在。同样,每个对象 8 比特肯定比保留指向某处关联内存的指针要少得多。因此,这是一种真正令人印象深刻的、使用最少空间来跟踪我们见过和没见过什么的方法。
其次,我们需要一个映射:给定一个对象(例如一个 IP 地址),如何确定我们之前是否见过它?
在布隆过滤器中,重要的是拥有不止一个哈希函数,而是多个哈希函数。我们用 K 表示布隆过滤器中哈希函数的数量,K 应该是一个小的常数,比如 3、4、5 左右。显然,使用多个哈希函数比只使用一个要复杂一些,但这并不是什么大问题。回想一下我们在讨论通用哈希时的内容,我们确定了在整个函数族中平均表现良好的哈希函数。因此,你可以从通用族中随机选择 K 个独立的哈希函数。实际上,在实践中,通常似乎只需要使用两个不同的哈希函数,然后生成这两个哈希函数的 K 个不同线性组合。但为了本视频的目的,我们假设我们已经做了足够的工作来获得 K 个不同的良好哈希函数,这就是我们将在布隆过滤器中使用的。
插入和查找的代码都非常优雅。让我们从插入开始。
插入操作
假设我们有一个新的 IP 地址,我们想把它放入这个布隆过滤器。我们该怎么做?我们只需用这个新对象评估我们的 K 个哈希函数。每个哈希函数都会告诉我们数组中的一个索引位置,然后我们只需将这 K 个比特位设置为 1。
在进行插入时,我们甚至不需要关心这些比特位之前的值是 0 还是 1。我们只是随意地进入并将这 K 个比特位设置为 1,无论它们之前是什么。
查找操作

查找操作如何实现呢?我们所要做的就是检查之前插入操作必然留下的“足迹”。如果我们查找一个 IP 地址,并且我们知道它过去某个时间被插入过,那么当我们评估 K 个哈希函数时发生了什么?我们去了数组中适当的位置,并将所有这些比特位设置为 1。所以现在我们只需检查那是否确实发生了。也就是说,当我们获得一个新的 IP 地址并查找它时,我们评估所有 K 个哈希函数,查看对应的 K 个位置,并验证这 K 个比特位是否确实都被设置为 1。
我希望通过检查这段非常优雅的代码,你能很快明白两件事:我们永远不会有漏报,但我们可能有误报。让我们逐一讨论。
记住,漏报意味着布隆过滤器说某个东西不存在,而实际上它存在。也就是说,我们插入某个东西,然后稍后查找它,布隆过滤器却拒绝了。这不会发生,因为当我们插入某个东西时,我们将相关的 K 个比特位设置为 1。请注意,当一个比特位是 1 时,它将永远是 1,比特位永远不会被重置回 0。所以,如果任何东西曾经被插入过,我们随后查找它,我们肯定会确认所有这些比特位都是 1。因此,我们永远不会被之前插入过的东西拒绝。
另一方面,完全有可能出现误报。完全有可能存在一个“幻影”对象,我们进行查找时,布隆过滤器返回“是”,而我们从未插入过那个对象。例如,假设 K=3,我们使用三个不同的哈希函数。考虑某个固定的 IP 地址,也许这三个哈希函数告诉我们相关的比特位是 17、23 和 36。可能我们从未插入过这个 IP 地址,但我们插入了 IP 地址 #2,在它的插入过程中,第 17 位被设置为 1。我们插入了另一个 IP 地址 #3,第 23 位被设置为 1。然后我们插入了 IP 地址 #4,第 36 位被设置为 1。三个不同的 IP 地址负责设置这三个不同的比特位。但不管怎样,我们并不记得这一点。然后,当我们查找我们真正关心的这个 IP 地址时,我们做什么?我们检查第 17 位,它是 1;检查第 23 位,它是 1;检查第 36 位,它也是 1。就布隆过滤器所知,这个东西确实被插入过,所以它会说“是,它在表中”。这就是我们产生误报的方式:所有指示给定对象是否在布隆过滤器中的比特位,都可能被其他对象的插入操作提前设置了。
核心要点
我希望在讨论的这个阶段,有两点是清楚的:
- 这个布隆过滤器的想法确实提出了一种超级节省空间的哈希表变体的可能性。我们一直在讨论将比特数设置为大约是你存储对象数量的 8 倍,所以你每个对象只使用 8 比特。对于大多数对象来说,这比仅仅在一个简单数组中存储对象本身要少得多。再次强调,如果是 IP 地址,我们只使用了实际存储这些 IP 地址所需空间的 25%。
- 布隆过滤器中不可避免地会有一些错误。我们会有误报,即我们查找某个东西时,它说存在,但实际上不存在。
我希望这两点是清楚的。实际上不清楚的是最终结论:这真的是一个有用的想法吗?要让这个想法有用,必须满足一个条件:即使在每个对象占用空间很小的情况下,误报概率也能非常小。如果我们不能同时让这两者都很小,这就是一个坏主意,我们应该总是使用哈希表。
为了评估这个想法的质量,我们将不得不进行一些数学分析。这就是我将在接下来的几张幻灯片中向你展示的内容。
本节课中,我们一起学*了布隆过滤器的基础知识。我们了解了它是一种空间效率极高的数据结构,支持快速的插入和查找操作,但代价是存在一定的误报概率,且不支持删除。我们探讨了它的几个典型应用场景,并深入其实现原理,看到了它如何通过一个比特数组和多个哈希函数来工作。最后,我们指出了评估其有效性的关键在于能否在极小空间占用的同时保持足够低的误报率,这需要进一步的数学分析。
075:布隆过滤器启发式分析

概述
在本节课中,我们将要学*布隆过滤器的启发式分析。我们将通过数学推导,精确地理解布隆过滤器在空间消耗与错误率之间的权衡关系,并找出一个能同时实现较小空间占用和可控错误概率的“最佳平衡点”。
空间与正确性的权衡
上一节我们介绍了布隆过滤器的基本工作原理。本节中我们来看看其核心的性能权衡。
直观上,布隆过滤器需要在两种资源之间进行权衡:一种是空间消耗(即使用的比特数),另一种是正确性(即错误率)。我们希望使用的空间越多,犯的错误就越少。反之,如果过度压缩表格,让更多不同的对象共享比特位,那么错误率就会上升。
我们接下来分析的目标,就是在定量层面上精确地理解这种权衡曲线。一旦理解了这条曲线,我们就能问:是否存在一个“最佳平衡点”,能让我们得到一个既节省空间,错误概率又可管理的有用数据结构?
启发式分析假设
为了进行数学分析,我们将采用一个启发式假设。这个假设非常强,在实际应用中使用的哈希函数通常无法满足它,但它能帮助我们推导出布隆过滤器的性能保证。在实际实现中,你应该验证你的实现是否达到了理想分析所预测的性能。如果使用良好的哈希函数和非病态数据,许多实证研究表明,性能将与启发式分析的预测相当。



以下是该假设的具体内容:
- 我们假设所有的哈希行为都是完全随机的。
- 对于每个哈希函数
H和每个可能的对象x,哈希函数为该对象给出的数组位置(槽位)首先服从均匀随机分布。 - 并且,该输出独立于所有其他哈希函数在所有其他对象上的所有输出。
分析比特位被置为1的概率
我们的设置如下:我们有 n 个比特位和一个数据集 S,我们已经将 S 插入到布隆过滤器中。我们的最终目标是理解错误率,即假阳性概率——一个我们从未插入到布隆过滤器中的对象,看起来却像被插入过的概率。
但作为初步步骤,我们想了解在插入数据集 S 后,数组中比特位被置为1的情况。具体来说,让我们关注数组中的一个特定位置(根据对称性,具体是哪个位置无关紧要),并问:在插入整个数据集 S 后,数组中某个给定比特位被设置为1的概率是多少?
以下是计算这个概率的步骤:
- 首先考虑该比特位保持为0的概率。它初始为0,并且只能从0变为1。
- 要让它保持为0,它必须“躲过”在整个数据集插入过程中抛向布隆过滤器的所有“飞镖”。
- 每个被插入的对象都会导致
k个“飞镖”(由k个哈希函数决定)被均匀随机且独立地“抛向”数组。任何被飞镖击中的位置都会被置为1。 - 一个给定的飞镖击中这个特定比特位的概率是
1/n,因此它“错过”这个比特位的概率是1 - 1/n。 - 总共有
k * |S|个飞镖(|S|是数据集S的大小,即插入的对象数量)。 - 该比特位躲过所有飞镖的概率是
(1 - 1/n)^(k * |S|)。 - 因此,该比特位被置为1的概率就是
1减去它保持为0的概率。
公式:给定比特位为1的概率 p 为:
p = 1 - (1 - 1/n)^(k * |S|)

简化表达式并引入关键参数
上面的表达式有些复杂。我们可以使用一个简单的估算技巧来简化它。对于任何实数 x,有 1 + x ≤ e^x。这里我们取 x = -1/n,从而得到:
(1 - 1/n)^(k * |S|) ≤ e^(- (k * |S|) / n)
因此,概率 p 的上界为:
p ≤ 1 - e^(- (k * |S|) / n)
为了进一步简化,我们引入一个关键参数 b,它表示每个对象使用的比特数。定义 b = n / |S|。那么上面的表达式可以重写为:
p ≤ 1 - e^(-k / b)
这里我们已经能看到预期的权衡关系:如果每个对象使用的比特数 b 非常大(即空间充足),指数项趋*于0,p 趋*于0,意味着数组中1的密度很低。这应该会转化为较低的假阳性概率,我们将在下一步进行精确分析。
计算假阳性概率
上一节的结论并不是我们最终关心的量。我们关心的是假阳性概率,即一个从未被插入的对象 x 被误判为存在的概率。
对于一个不在数据集 S 中的给定对象 x,要使其查询结果为“存在”(即发生假阳性),必须满足一个条件:指示 x 成员资格的所有 k 个比特位都必须被设置为1。
我们已经计算了单个比特位被设置为1的概率(上界)。因此,假阳性概率 ε 就是这 k 个独立事件同时发生的概率:
公式:假阳性概率 ε 的上界为:
ε ≤ (1 - e^(-k / b))^k
这个公式正是我们想要的,它定量地描述了空间使用量(通过 b 体现)与错误概率 ε 之间的直观权衡。随着 b 增大(使用更多空间),ε 会减小。
优化哈希函数数量 k
到目前为止,我们一直将 k 视为一个小常数(如2、3、4、5)。现在有了这个定量公式,我们可以回答如何最优地设置 k。
对于固定的每个对象比特数 b,我们可以选择 k 来最小化错误率 ε。这是一个微积分优化问题。通过求解,可以得到最优的 k 值大约为:
k ≈ (ln 2) * b ≈ 0.693 * b
换句话说,布隆过滤器最优实现中使用的哈希函数数量,与每个对象使用的比特数 b 成线性比例关系,大约是 b 的0.693倍。当然,这通常不是整数,只需向上或向下取整即可。
空间与错误率的最终权衡关系
现在我们已经知道了如何为给定的空间量最优地设置 k 以最小化错误。将这个最优的 k 值代回假阳性概率公式,我们可以得到空间与错误率之间的直接权衡关系,并得到一个非常简洁的答案。
具体来说,在最优选择哈希函数数量 k 的情况下,错误率 ε 随每个对象使用的比特数 b 呈指数级下降:
公式:
ε ≈ (1/2)^( (ln 2) * b ) ≈ (0.5)^(0.693 * b)

这里的关键定性结论是:ε 随着 b 的增大而迅速下降。如果你将分配给每个对象的比特数翻倍,错误率就会平方,这对于已经很小的错误率来说,会使其变得非常非常小。
当然,这是一个包含两个变量的方程。如果你愿意,也可以解这个方程,将空间需求 b 表示为错误容忍度 ε 的函数:
公式:
b ≈ 1.44 * log₂(1/ε)
正如预期,当 ε 越来越小(你希望错误越来越少)时,空间需求 b 会增加。
布隆过滤器实用吗?
最后一个问题是:布隆过滤器是一个有用的数据结构吗?能否设置参数,从而获得真正有意义的空间-错误权衡?答案是完全肯定的。
让我们看一个例子。回到之前提到的每个对象使用8比特(b = 8)的情况。根据粉色公式,我们应该使用5或6个哈希函数,此时的错误概率大约为2%。对于我们讨论过的许多应用场景来说,这已经足够好了。如果你将比特数翻倍到每个对象16比特(b = 16),那么错误概率将变得非常小,大约在1/5000左右。
总结
本节课中,我们一起学*了布隆过滤器的启发式分析。通过分析,我们得出了其假阳性概率的定量公式 ε ≤ (1 - e^(-k / b))^k,并找到了最优哈希函数数量 k ≈ 0.693 * b。最终,我们得到了空间与错误率的核心权衡关系:ε 随 b 指数下降(ε ≈ (0.5)^(0.693b)),或等价地,所需空间 b ≈ 1.44 * log₂(1/ε)。
至少在理想化分析中(在实际实现中应进行验证,但经验表明,使用良好实现的布隆过滤器和非病态数据完全可以达到此类性能),即使为每个对象分配少得可笑的空间(远少于存储对象本身),布隆过滤器也能实现快速的插入和查询。虽然它确实存在假阳性,但其错误率是高度可控的。正是这一点,使得布隆过滤器在许多应用中成为赢家。
076:概述与互联网路由应用 🚀
在本课程中,我们将学*算法设计与分析的核心原理及其在解决具体问题中的应用。我们将探讨多种通用设计范式,并通过实例展示这些技术如何应用于实际问题。课程将涵盖分治、图搜索、贪心算法、动态规划等主题,并深入分析其在各类计算问题中的具体实现。
算法设计的核心:原理与实例的交互
算法设计与分析是通用原理与解决具体问题的实例化之间的交互。虽然没有万能钥匙或单一技术能解决所有计算问题,但存在一些经过数十年验证的通用设计原则,这些原则在不同应用领域中反复证明有效。本课程将重点探讨这些原则。
例如,在第一部分中,我们将学*分治算法设计范式和图搜索原理等。另一方面,我们将研究这些技术的具体实例。在第一部分中,我们将探讨分治技术及其在斯特拉森矩阵乘法、归并排序和快速排序中的应用。在图搜索部分,我们将以著名的迪杰斯特拉最短路径算法作为高潮。

学*这些内容不仅因为作为计算机科学家或程序员,我们需要了解这些算法的功能,还因为它们为我们提供了一个工具箱,一套可自由使用的基本构件,我们可以将其作为构建块应用于自己的计算问题中。
课程的第二部分将继续这一叙述,我们将学*非常通用的算法设计范式,如贪心算法和动态编程算法,以及许多应用,包括一些经典算法。在本视频及下一个视频中,我想通过挑选两个我们将在课程后期详细研究的应用来激发大家的兴趣,具体是在课程的动态编程部分。
首先,对于这两个问题,我认为它们的重要性不言而喻。其次,这些都是相当棘手的计算问题,我预计你们大多数人目前并不知道解决这些问题的好算法,设计一个算法将具有挑战性。第三,到本课程结束时,你们将掌握解决这两个问题的高效算法。事实上,你们将学到更好的东西:掌握通用的算法设计技术,这些技术是解决这两个问题的特例,并有可能解决你们自己项目中遇到的问题。
在开始这两个视频之前,有一个说明:它们的讲解层次比课程大部分内容更高,这意味着不会有任何方程或数学推导,不会有具体的伪代码,并且我会略过许多细节。重点只是传达我们将要学*的精神,并说明我们将要学*的技术应用范围。
互联网路由:作为图的最短路径问题 🌐
首先,我想讨论的是分布式最短路径路由,以及它为何是互联网运作的基础。
让我从一个非常非数学的断言开始:我们可以将互联网有效地视为一个图,即顶点和边的集合。这个说法显然有歧义,正如我们将讨论的,它可能意味着许多事情,但在这个特定视频中,我希望你们主要理解以下解释。
具体来说,顶点我指的是互联网的终端主机和路由器,即生成流量的机器、消费流量的机器以及帮助流量从一处传输到另一处的机器。边将是有向的,旨在表示物理或无线连接,表明一台机器可以通过两者之间的物理链路或直接无线连接直接与另一台机器通信。通常,你会看到双向边,这样如果机器A可以直接与机器B通信,那么机器B也可以直接与机器A通信,但你肯定希望允许非对称通信的可能性。

例如,想象我从我的斯坦福账户发送一封电子邮件给我在康奈尔大学读研究生时的一位老导师。那么,这封邮件数据必须从我在斯坦福的本地机器迁移到我在康奈尔的导师的机器上。这是如何发生的呢?最初,有一个本地传输阶段。这些数据必须从我的本地机器到达斯坦福网络内一个可以与外界通信的地方,就像如果我想去康奈尔旅行,我必须先使用本地交通工具到达旧金山机场,只有从那里我才能乘坐飞机。这个数据可以从斯坦福网络逃逸到外部世界的机器被称为网关路由器。
斯坦福的网关路由器将其传递给一个负责横跨国家的网络。据我所知,斯坦福的商业互联网服务提供商是Cogent。当然,他们有自己的网关路由器,可以与斯坦福的网关路由器通信,反之亦然。当然,这两个节点及其之间的边只是嵌入在这个由互联网所有终端主机和路由器组成的大规模图中的极小一部分。这就是本视频中我们将要讨论的图的主要版本,但让我暂停一下,提几个与互联网相关且本身也很有趣的其他图。
一个引起了极大兴趣和研究的图是由网络诱导的图。在这里,顶点将代表网页,边(肯定是有向的)代表一个网页指向另一个网页的超链接。例如,我的主页是这个庞大图中的节点。正如你可能期望的,从我的主页到这个课程页面有一个链接。当然,使用有向边来忠实地建模网络是至关重要的。例如,这个课程的主页到我在斯坦福的个人主页没有有向边。
网络在90年代中期到后期真正爆发,因此在过去的15年多里,关于网络图的研究很多。我相信你不会惊讶地听到,在上个十年中期左右,人们对社交网络的特性变得非常兴奋。这些当然也可以被富有成效地视为图。这里的顶点将是人,链接将表示关系,例如Facebook上的好友关系或Twitter上的关注关系。注意,不同的社交网络可能对应于无向图或有向图,例如,Facebook对应于无向图,Twitter对应于有向图。
路由挑战与算法需求 🛣️

现在让我们回到我想关注的第一个解释,即顶点是终端主机和路由器,边仅代表直接的物理或无线连接,表明两台机器可以直接相互通信。回到那个图,让我们回到我发送电子邮件给康奈尔某人的故事,这些数据必须以某种方式从我的本地机器传输到康奈尔的某个本地机器。
具体来说,这些数据必须从斯坦福网关路由器(实际上是斯坦福网络的“机场”)到达康奈尔网关路由器(康奈尔一侧的“降落机场”)。现在,要准确弄清楚斯坦福和康奈尔之间路由的结构并不容易,但我可以向你保证的一件事是,斯坦福网关路由器和康奈尔网关路由器之间没有直接的物理链路。两者之间的任何路由都将包含多个跳数,会有中间站点。而且这样的路由不会是唯一的。如果你可以选择一条经过休斯顿、亚特兰大和华盛顿特区的路线,你如何将其与一条经过盐湖城和芝加哥的路线进行比较?希望你的第一直觉是一个完美的想法:在其他条件相同的情况下,偏好某种意义上“最短”的路径。
在这个上下文中,“最短”可能意味着很多事情,思考不同的定义很有趣,但为简单起见,让我们只关注最少的跳数,即最少的中间站点数。如果我们想实际执行这个想法,我们显然需要一个算法,给定源和目的地,计算两者之间的最短路径。

希望你觉得有能力讨论这个问题,因为本课程第一部分的亮点之一就是讨论了迪杰斯特拉最短路径算法,以及使用堆实现的几乎线性时间的极快实现。我们在讨论迪杰斯特拉算法时确实提到了一个注意事项,即它要求所有边权非负,但在互联网路由的背景下,你能想象的几乎任何边度量都会满足这个非负性假设。
然而,尝试直接应用迪杰斯特拉最短路径算法来解决这个分布式互联网路由问题存在一个严重问题,这个问题是由现代互联网的巨大分布式规模引起的。可能在20世纪60年代,当你有12个节点的ARPANET时,你可以勉强运行迪杰斯特拉最短路径算法,但在21世纪,斯坦福网关路由器不可能在本地维护整个互联网图的合理准确模型。
我们如何规避这个问题?是否因为互联网如此庞大,运行任何最短路径算法都是根本不可能的?希望在于,如果我们能有一个允许分布式实现的最短路径算法,其中节点可以仅与其邻居(与其直接连接的机器)进行交互(可能是迭代式的),但又能以某种方式收敛到拥有到所有目的地的准确最短路径?
也许你尝试的第一件事是寻找迪杰斯特拉算法的一种实现,其中每个顶点只使用本地计算。如果你看迪杰斯特拉的伪代码,这似乎很难做到,它看起来不像一个可本地化的算法。因此,我们将学*一种不同的最短路径算法。它也是一个经典算法,绝对是经典汇编中的一员。它叫做贝尔曼-福特算法。正如你将看到的,贝尔曼-福特算法可以被视为一种动态编程算法,并且它确实仅使用本地计算就能正确计算最短路径。每个顶点仅在与它直接连接的其他顶点之间进行轮次通信。此外,我们将看到这个算法也能处理负边权,而迪杰斯特拉算法则不能。但不要认为迪杰斯特拉算法过时了,在可以进行集中式计算的情况下,它仍然具有更快的运行时间。
这里真正令人惊叹的是,贝尔曼-福特算法可以追溯到20世纪50年代,那不仅是互联网之前,甚至是ARPANET之前,互联网在任何人眼中都还没有一丝曙光。然而,它确实是现代互联网路由协议的基础。不用说,将贝尔曼-福特的概念转化为在非常复杂的现代互联网中实际进行路由,需要大量艰苦的工程工作和进一步的想法,但这些协议的基础都可以追溯到贝尔曼-福特算法。
总结 📚
本节课中,我们一起学*了算法设计是通用原理与具体问题实例化之间的交互。我们探讨了将互联网建模为图的概念,并理解了在互联网这种大规模分布式系统中进行路由时面临的挑战。我们认识到,虽然迪杰斯特拉算法是经典的最短路径算法,但其集中式特性使其难以直接应用于互联网路由。因此,我们引入了贝尔曼-福特算法作为解决方案,它通过分布式和迭代式的本地计算,能够有效地在像互联网这样的图中计算最短路径,这为其成为现代路由协议(如BGP)的核心思想奠定了基础。这展示了算法设计原理如何适应并解决现实世界中的大规模工程问题。
077:应用-序列比对 🧬

在本节课中,我们将学*序列比对问题。这是一个计算基因组学中的基础问题,用于衡量两个字符串(通常代表基因组片段)的相似性。我们将了解问题的定义、动机,并初步探讨其计算挑战。

问题定义与动机

上一节我们介绍了课程背景,本节中我们来看看序列比对问题的具体定义。

给定字母表(通常是 {A, C, G, T})上的两个字符串。这两个字符串通常代表一个或多个基因组的片段。例如,两个输入字符串可以是:
AGGGCTAGGCA
请注意,两个输入字符串的长度不一定相同。
非正式地说,序列比对问题的目标是判断两个输入字符串的相似程度。但“相似”需要更精确的定义。
为什么需要解决这个问题?以下是两个主要动机:
- 推测未知基因组区域的功能:通过将人类基因组中未知功能的区域与已充分理解的基因组(如小鼠基因组)中的相似区域进行比较,可以推测其可能具有相同或相似的功能。
- 理解物种间的进化关系:通过比较不同物种的基因组相似性,可以推断它们是否具有直接的进化关系,或者它们是否从一个共同祖先独立演化而来。基因组相似性可以作为进化树中亲缘关系远*的度量。
如何定义“相似性”

上一节我们了解了问题的动机,本节中我们来看看如何形式化地定义字符串的“相似性”。

让我们重新审视之前的例子:AGGGCT 和 AGGCA。直观上,它们似乎比不同更相似。这种直觉从何而来?
一种使其更精确的方法是注意到这两个字符串可以很好地“对齐”。我们可以将较长的字符串 A G G G C T 写下,并在其下方写下较短的字符串 A G G C A。为了使两个字符串长度相同,我们插入一个“缺口”(一个空格)。在这个例子中,我们将空格放在似乎“缺失”了一个 G 的位置。
这个对齐方式“好”在哪里?它并不完美,两个字符串并非逐字符匹配,但它只有两个小缺陷:
- 我们不得不插入一个缺口。
- 最后一列存在一个不匹配(
T对A)。
这种直觉促使我们根据两个字符串的“最高质量”对齐(即“最好”的对齐)来定义它们的相似性。
我们离正式的问题陈述更*了一步,但仍有待确定:我们如何比较并偏好一种对齐方式胜过另一种?例如,是“三个缺口且无错配”更好,还是“一个缺口加一个错配”更好?

在本视频中,我们暂时搁置这个问题。我们假设这个问题已经通过实验解决,即作为输入的一部分,我们已经知道插入一个缺口的成本以及各种类型错配的惩罚是多少。
正式问题陈述与Needleman-Wunsch分数

上一节我们讨论了如何衡量对齐的质量,本节中我们给出序列比对的正式定义。

除了两个由 {A, C, G, T} 组成的字符串外,输入还提供:
- 一个非负数,表示在对齐中插入每个“缺口”所招致的成本。
- 对于每对可能错配的字符(例如
A与T),输入也给出相应的惩罚值。
给定这些输入,序列比对算法的职责是输出一个对齐方案,使得所有惩罚(缺口成本和错配惩罚)的总和最小。
另一种理解这个“最小惩罚对齐”输出的方式是:我们试图找到一种成本最低的解释,来说明其中一个字符串是如何演变成另一个的。我们可以将“缺口”视为撤销了过去发生的某个“删除”操作,而将“错配”视为代表了一次“突变”。

这个可能的最小总惩罚值,即最优对齐的价值,非常著名和基础,以至于拥有自己的名字:Needleman-Wunsch分数。这个量以两位作者的名字命名,他们在1970年的《分子生物学杂志》上提出了计算最优对齐的高效算法。
现在,我们终于有了两个字符串“相似”的正式定义:它意味着它们有一个较小的Needleman-Wunsch分数,即接*零的分数。例如,如果你有一个包含大量基因组片段的数据库,根据这个定义,你将把具有最小NW分数的片段定义为最相似的片段。
计算挑战与算法需求
上一节我们定义了衡量相似性的分数,本节中我们来看看计算这个分数所面临的挑战。
需要指出的是,这种基因组相似性的定义本质上是算法性的。除非存在一种高效算法,能够给定两个字符串和这些惩罚值,计算出这两个字符串之间的最佳对齐,否则这个定义将完全无用。如果你无法计算这个分数,你就永远不会用它作为相似性的度量。

这一观察给我们带来了巨大压力,要求我们设计一种寻找最佳对齐的高效算法。我们该怎么做?我们总是可以退回到暴力搜索:遍历两个字符串所有可能的对齐方式,计算每种对齐方式的总惩罚,并记住最好的那个。
显然,暴力搜索的正确性不是问题。根据定义,它本质上是正确的。问题在于它需要多长时间。让我们问一个更简单的问题:有多少种不同的对齐方式?我们需要尝试多少种可能性?
具体来说,假设我给你两个长度为500的字符串(这是一个合理的长度)。以下哪个英文短语最能描述可能性的数量(即两个各有500个字符的字符串的对齐方式数量)?
A. 像本课程的学生人数一样多(约5万)
B. 像地球上的人口一样多(约70亿)
C. 像已知宇宙中的原子数量一样多(约10^80)
D. 比已知宇宙中的原子数量还要多
答案是 D。两个长度为500的字符串的可能对齐数量甚至比已知宇宙中的原子数量还要多。你可以说服自己,可能性的数量至少是 2^500。因为 10 <= 2^4,我们可以将这个数字下界估计为 10^125,这比宇宙中的原子数量要大得多。
关键点在于,即使在几百个字符的规模上,设想实施暴力搜索也是完全荒谬的。即使字符串长度小得多,比如一二十个,你也永远不会运行暴力搜索,因为它行不通。请注意,这种组合爆炸问题不会因为等待摩尔定律的帮助而消失。这是一个根本性的限制,它意味着除非你有一个快速、聪明的算法,否则你将永远无法计算你关心的字符串的对齐。
值得高兴的是,你将在本课程后续内容中学*到这样一种快速而聪明的算法。更好的是,它只是更通用的算法设计范式——动态规划——的一个直接实例化。
总结
本节课中我们一起学*了序列比对问题。我们了解了它在计算基因组学中的重要性,以及如何通过定义“缺口”和“错配”的惩罚来形式化地衡量两个字符串的相似性,并引入了Needleman-Wunsch分数作为最优对齐的度量。我们还认识到,由于可能对齐方案的数量是天文数字,暴力搜索不可行,这引出了对高效算法(如动态规划)的需求。
078:贪心算法导论 🎯
在本节课中,我们将要学*一种新的算法设计范式——贪心算法。我们会先回顾已学过的算法设计范式,然后介绍贪心算法的基本概念、特点,并讨论其正确性证明的常见方法。
算法设计范式回顾
在算法设计中,没有适用于所有计算问题的“万能钥匙”。因此,本课程的重点是讨论那些能应用于多种不同问题和领域的通用技术,即算法设计范式。
以下是几个我们已经接触过的算法设计范式:
- 分治算法:典型例子是归并排序。其步骤是:将问题分解为更小的子问题,递归地解决子问题,然后将结果组合成原问题的解。例如,在归并排序中,递归地对两个子数组排序,然后合并结果得到原输入数组的排序版本。
- 随机化算法:通过在代码内部进行随机选择,常常能设计出更简单、更实用或更优雅的算法。一个典型应用是使用随机主元的快速排序算法,此外在哈希函数设计中也有应用。


接下来,我们将要深入讨论的范式是贪心算法。这类算法迭代地做出“短视”的决策。事实上,我们在第一部分已经见过一个贪心算法的例子:迪杰斯特拉最短路径算法。本课程最后将要讨论的范式是动态规划,这是一个非常强大的范式,能解决我们之前提到的序列比对和分布式最短路径等核心问题。

什么是贪心算法?


我不会给出一个正式的定义,因为关于哪些算法严格属于贪心算法,一直存在很多讨论。但我可以提供一个非正式的描述,作为判断贪心算法的经验法则。
一般来说,贪心算法会做出一系列决策,每个决策都是短视的——即它基于当前可获得的信息,看起来是当时最好的选择。然后,算法希望这些局部最优选择最终能导向全局最优解。
理解贪心算法的最好方法是看例子,接下来的课程会提供多个案例。但我想指出,我们其实已经在本课程的第一部分见过一个贪心算法的例子:迪杰斯特拉最短路径算法。
迪杰斯特拉算法为何是贪心的?

回顾迪杰斯特拉算法的伪代码,其核心是一个主 while 循环。算法在每次循环迭代中处理一个新的目标顶点。总共有 n-1 次迭代(n 是顶点数)。算法对每个给定的目的地只计算一次最短路径,之后从不回头重新审视这个决策。从这个意义上说,它的决策是短视且不可撤销的,这正是迪杰斯特拉算法属于贪心算法的原因。
贪心算法范式的一般讨论

在深入例子之前,让我们从总体上讨论一下贪心算法设计范式。为了更清晰地理解,我们可以将其与我们已经深入研究过的分治算法范式进行比较和对比。
以下是几个关键的对比点:


- 设计的难易程度:贪心算法的一个优点(也是缺点)是它通常非常容易应用。针对一个问题,常常很容易想出看似合理的贪心算法,甚至多个不同的版本。这与分治算法形成对比,分治算法通常需要灵光一现,找到正确的分解问题方式。
- 运行时间分析:分析贪心算法的运行时间通常比分析分治算法容易得多。对于分治算法,我们需要理解递归多层的运行时间,问题规模在减小,但子问题数量在增加,因此我们需要借助主定理等强大工具。而对于贪心算法,运行时间分析常常是一行代码的事,通常很明显工作由某个子程序(如排序)主导,而我们知道排序使用合理算法需要
O(n log n)时间。 - 正确性证明:这是对比的关键点。对于贪心算法,我们通常需要付出更多努力来理解其正确性。对于分治算法,正确性证明通常是相当直接的归纳证明。但对于贪心算法,情况完全不同。通常,即使对于一个正确的贪心算法,我们也很难直观理解它为什么正确,更不用说如何证明它了。
一个非常重要的提醒:如果你多年后只记得关于贪心算法的一件事,我希望是:它们常常是不正确的。尤其是对于那些你自己提出的、感觉非常自然的算法,你可能会因为偏爱而认为它一定是正确的,但事实往往并非如此。

贪心算法的不正确性:一个例子

为了让你立即练*识别看似自然但实则错误的贪心算法,让我们回顾本课程第一部分关于迪杰斯特拉算法的一个要点。
在第一部分,我们强调了迪杰斯特拉算法是一个著名的、运行速度极快的算法,能计算所有最短路径。但请记住,我们证明迪杰斯特拉算法正确时有一个关键假设:给定网络中的每条边都具有非负长度。我们不允许负边长的存在。

虽然许多应用只关心非负边长,但确实存在需要负边长的应用。让我们通过一个小测验来回顾为什么迪杰斯特拉算法在边长为负时是不正确的。
考虑一个简单的三边网络(如绿色图所示),边已标注长度。注意从 V 到 W 的边具有负长度 -2。问题是:考虑源顶点 S 和目标顶点 W,迪杰斯特拉算法计算出的最短路径距离是多少?实际的、真正的最短路径距离又是多少?(路径长度定义为路径上各边长度的总和)
答案分析:
- 实际最短路径距离:从
S到W有两条路径。直接路径S->W长度为2。经过V的路径S->V->W长度为3 + (-2) = 1。因此,实际最短路径距离是1。 - 迪杰斯特拉算法的输出:回顾伪代码,在第一次迭代中,算法会贪婪地找到离
S“最*”的顶点。此时,W(距离2)比V(距离3)更*。因此,算法会基于当前信息,不可撤销地将S到W的最短路径距离计算为2,之后不会再重新考虑这个决策。所以迪杰斯特拉算法会终止并输出错误的结果2。
这并不与我们第一部分证明的结论矛盾,因为我们是在所有边长为非负的假设下证明其正确性的,而本例违反了该假设。这里的要点是:很容易写出一个贪心算法,尤其是你自己想出来的,你内心深处可能相信它总是正确的。但更多时候,你的贪心启发式方法可能仅仅是一个启发式方法,总存在一些实例让它做出错误的事情。在贪心算法设计中,请牢记这一点。

如何证明贪心算法的正确性?
既然我已经提醒了你贪心算法设计的风险,现在让我们转向正确性证明。也就是说,如果你有一个实际上是正确的贪心算法(我们将在接下来的课程中看到一些著名的例子),你如何确立这一事实?或者,如果你有一个贪心算法但不知道它是否正确,你该如何着手判断?


坦白说,证明贪心算法的正确性更像是一门艺术而非科学。与分治范式那种公式化的方式不同(有评估递归的黑盒方法、证明正确的模板),证明贪心算法的正确性需要很多创造力,并且带有一点特设的味道。
尽管如此,像往常一样,我会强调那些反复出现的主题和方法。让我从非常高的层次告诉你,你可能会如何着手进行证明。你可能会在看过一些例子后,再回来看这部分内容,那时它会更有意义。
以下是两种常见的方法:
- 归纳证明:这是我们的老朋友(或者说是“宿敌”,取决于你的看法)。贪心算法会顺序做出一系列不可撤销的决策。这里的归纳将基于算法所做的决策。回顾我们对迪杰斯特拉算法正确性的证明,那正是我们采用的方式:对主
while循环的迭代次数进行归纳。在每次迭代中,我们计算到一个新目的地的最短路径,并且总是证明:假设之前所有的计算都是正确的(归纳假设),那么当前迭代的计算也是正确的。通过归纳,算法所做的一切都是正确的。一些教科书称这种方法为 “贪心保持领先”,意味着你一步步证明贪心算法始终在做正确的事。 - 交换论证:这是证明许多贪心算法正确性的第二种常用方法。你尚未在本课程中见过交换论证的例子,所以我们接下来会进行介绍。它有不同的形式:
- 一种形式是反证法:假设贪心算法不正确,然后证明你可以取一个最优解,交换其中的两个元素,从而得到一个更好的解,这当然与你始于一个最优解的假设矛盾。
- 另一种形式是逐步转换:证明你可以通过一系列交换,将一个最优解逐步转换成贪心算法输出的解,并且在此过程中不会使解变差。这表明贪心算法的输出实际上也是最优的。形式上,这可以通过对将最优解转换为你的解所需的交换次数进行归纳来完成。

最后,我必须再次强调,证明贪心算法正确性背后没有太多固定公式。你常常需要相当有创造力,可能需要结合方法一和方法二的各个方面,或者做一些完全不同的事情。任何严谨的证明都是可行的。

总结

本节课中,我们一起学*了贪心算法这一重要的算法设计范式。我们回顾了算法设计范式的概念,将贪心算法与分治算法进行了对比,明确了贪心算法短视决策、不可撤销的核心特征。我们通过迪杰斯特拉算法的例子说明了贪心算法的应用,也通过它在负边权下失效的例子警示了贪心算法常常并不正确。最后,我们探讨了证明贪心算法正确性的两种主要思路:归纳证明和交换论证。在接下来的课程中,我们将通过具体例子深入实践这些概念和方法。
079:贪心算法应用-最优缓存 🧠

在本节课中,我们将学*贪心算法的一个经典应用:最优缓存问题。我们将了解缓存的基本概念、缓存未命中的类型,并介绍一个理论上最优的贪心算法——最远将来算法。虽然这个算法在实际中无法直接实现,但它为设计实用的缓存算法提供了重要指导。

什么是缓存问题? 💾
上一节我们介绍了贪心算法的基本概念,本节中我们来看看它在缓存问题中的应用。

缓存问题涉及两种内存:一种是大而慢的内存,可以存储所有可能用到的数据;另一种是小而快的缓存,访问速度远快于前者。这种场景在计算机科学的多个领域都很常见,例如计算机架构、操作系统和网络。

核心概念:
- 大内存:存储所有数据,但访问慢。
- 缓存:存储部分数据,访问快,但容量有限。
缓存如何工作? 🔄
当客户端请求访问某个数据时,我们称之为页面请求。该数据保证存在于大内存中,但如果不在缓存中,就需要将其加载到缓存中,这个过程称为缓存未命中或页面错误。
缓存未命中时,必须从缓存中驱逐一个现有数据,为新数据腾出空间。驱逐哪个数据,就是缓存管理算法需要决策的问题。
以下是缓存工作的一个简单示例:
假设缓存有四个槽位,初始存储数据 A、B、C、D。
- 请求数据 C → 命中,无需操作。
- 请求数据 D → 命中,无需操作。
- 请求数据 E → 未命中,需驱逐一个数据(假设驱逐 A)并加载 E。
- 请求数据 F → 未命中,需驱逐一个数据(假设驱逐 B)并加载 F。
- 请求数据 A → 未命中,需驱逐一个数据并重新加载 A。
- 请求数据 B → 未命中,需驱逐一个数据并重新加载 B。
在这个例子中,由于驱逐了 A 和 B,导致后续对它们的请求产生了额外的未命中。如果当初驱逐的是 C 和 D,就可以避免这两次未命中。
这个例子说明了两个要点:
- 缓存管理涉及在未命中时选择驱逐对象。
- 缓存未命中分为两种:不可避免的未命中(如首次请求新数据)和算法相关的未命中(由不当的驱逐决策导致)。
最优算法:最远将来算法 🎯
那么,如何最小化缓存未命中的次数呢?Belady 在 1960 年代给出了一个优雅的答案。

定理:一个自然的贪心算法——最远将来算法,是缓存问题的最优算法。它能最小化在所有可能的缓存管理方式中产生的缓存未命中次数。

最远将来算法的规则很简单:当需要从缓存中驱逐一个数据时,选择将来最长时间内不会被再次请求的那个数据。

其思想是:你“后悔”驱逐某个数据的时刻,就是它下次被请求的时刻。因此,驱逐那个下次请求时间最晚的数据,可以将“后悔”推迟得最久。
在前面的例子中,最远将来算法会正确地选择驱逐 C 和 D,而不是 A 和 B。
算法的实用价值与实践意义 ⚙️
你可能会问:这个算法需要预知未来,这在实际中不可能实现,它有什么用呢?
这个定理的实用性体现在两个方面:

1. 指导实用算法设计
最远将来算法为设计可实现的算法提供了思路。一个著名的衍生算法是 LRU(最*最少使用)算法。
LRU 算法用过去预测未来:它认为最*被访问过的数据,在不久的将来很可能再次被访问;而很久没被访问的数据,将来一段时间内也可能不会被访问。因此,当需要驱逐时,LRU 选择过去最久未被访问的数据作为“将来最久不会被访问”的代理。
只要数据访问模式具有局部性(最*访问过的数据倾向于在*期再次被访问),LRU 就能很好地*似最远将来算法。在许多应用中,LRU 是实用缓存算法的黄金标准。
2. 作为理想化的性能基准
最远将来算法可以作为一个完美的、假设性的基准,用于评估实际缓存算法的性能。

例如,在实现了一个缓存系统(如使用 LRU)后,你可以分析过去几天的请求日志:
- 计算你的算法(如 LRU)产生了多少次未命中。
- 计算如果“预知未来”采用最远将来算法,会产生多少次未命中(这是可计算的,因为你现在拥有了“未来”的日志)。
如果两者性能接*(例如 LRU 只比最优情况差几个百分点),说明数据具有局部性,且你的算法表现良好。如果性能差距很大,则说明需要重新设计或调整你的缓存策略。
关于算法正确性证明的说明 📝
在本课程中,对于大多数贪心算法,我们都会严格证明其正确性。但 Belady 的这个定理是一个例外,其证明(通常使用“交换论证”法)相当复杂和精妙。
尽管这个算法看起来直观,但严谨地证明它是最优的并不容易。许多操作系统教科书会提及这个算法及其最优性,但往往省略证明。有兴趣的读者可以尝试挑战自己证明它,这将帮助你深入理解贪心算法正确性证明中的微妙之处。
总结 📚
本节课中我们一起学*了:
- 缓存问题的核心:管理小而快的缓存,以服务来自大而慢内存的页面请求序列,目标是最小化缓存未命中。
- 最优贪心算法:最远将来算法在理论上能最小化未命中次数,但其需要预知未来,无法直接实现。
- 实践意义:该理论算法为 LRU 等实用算法提供了设计灵感,并可作为评估实际算法性能的黄金标准基准。
理解这个理论最优算法,为我们设计和评估高效的缓存策略奠定了坚实的基础。
080:贪心算法在调度问题中的应用
在本节课中,我们将学*如何将贪心算法应用于调度问题,即如何在共享资源上安排作业顺序,以优化特定目标。我们将从一个简单的单资源调度场景开始,定义问题的输入、输出以及需要优化的目标函数。
问题定义
上一节我们介绍了贪心算法的应用领域,本节中我们来看看一个具体的调度问题。我们假设只有一个共享资源,例如一个计算机处理器,需要处理多个作业。每个作业都有两个已知参数:权重(W_j)和长度(L_j)。权重表示作业的重要性,长度表示作业处理所需的时间。我们的目标是找到一个作业序列,以最小化加权完成时间之和。
完成时间的定义
为了理解优化目标,我们首先需要定义作业的完成时间。完成时间是指从开始处理到该作业完成所经过的总时间。

以下是完成时间的计算规则:
- 第一个被调度的作业,其完成时间等于其自身的长度:
C_1 = L_1。 - 第二个被调度的作业,其完成时间等于第一个作业的长度加上自身的长度:
C_2 = L_1 + L_2。 - 以此类推,第
i个作业的完成时间等于所有排在其前面的作业长度之和,再加上其自身的长度:C_i = sum(L_1 to L_{i-1}) + L_i。
目标函数:最小化加权完成时间之和

在定义了完成时间后,我们可以明确问题的优化目标。我们并非简单地最小化所有作业的完成时间,而是希望最小化加权完成时间之和。其数学公式为:

最小化:sum_{j=1}^{n} (W_j * C_j)
其中,W_j 是作业 j 的权重,C_j 是作业 j 的完成时间。这个目标函数等价于最小化以输入权重为权重的平均完成时间。
示例说明
让我们通过一个例子来巩固理解。假设有三个作业,其长度和权重如下:

- 作业1:长度
L_1 = 1,权重W_1 = 3 - 作业2:长度
L_2 = 2,权重W_2 = 2 - 作业3:长度
L_3 = 3,权重W_3 = 1
如果我们按照作业1、作业2、作业3的顺序调度,那么:
- 作业1的完成时间
C_1 = 1 - 作业2的完成时间
C_2 = 1 + 2 = 3 - 作业3的完成时间
C_3 = 1 + 2 + 3 = 6
加权完成时间之和为:(3*1) + (2*3) + (1*6) = 3 + 6 + 6 = 15。可以验证,对于这个具体的输入,该调度顺序确实是最优的。

总结


本节课中我们一起学*了调度问题的基本定义。我们明确了问题的输入是每个作业的权重和长度,输出是一个作业序列,而优化目标是最小化加权完成时间之和(sum(W_j * C_j))。在接下来的课程中,我们将探讨如何设计贪心算法来高效地解决这个问题。
081:贪心算法

在本节课中,我们将学*如何为最小化加权完成时间之和的调度问题设计一个贪心算法。我们将重点关注设计算法的过程,这个过程本身可以应用于解决其他问题。
概述
我们面临一个计算问题:给定 n 个作业,每个作业有各自的权重 w_j 和长度 l_j。我们需要在所有 n! 种可能的作业排序中,找到一种排序,使得所有作业的加权完成时间之和最小。作业 j 的完成时间 C_j 定义为它开始前所有作业的长度之和加上它自身的长度。我们的目标是找到最小化 ∑ w_j * C_j 的排序。
贪心算法通过迭代地做出看似最优的局部决策来构建解,对于这种顺序决策问题,尝试贪心算法是合理的。
从特殊案例中寻找直觉
为了设计算法,我们首先考虑问题的两个特殊案例,在这些案例中,最优解是直观的。
案例一:所有作业长度相同,但权重不同。
在这种情况下,所有作业的完成时间序列是固定的(例如,1, 2, 3, ..., n)。为了最小化加权和,我们希望权重最大的作业获得最小的完成时间。因此,应该优先安排权重更高的作业。
案例二:所有作业权重相同,但长度不同。
在这种情况下,安排一个作业会迫使后续所有作业等待它完成。为了最小化对后续作业的负面影响,应该优先安排长度最小的作业。
推广到一般情况
上一节我们介绍了两种特殊情况下的直觉。本节中我们来看看当作业的权重和长度都不同时,如何将这两种直觉结合起来。
当两个作业中,一个权重更高但长度也更长时,我们的两条经验法则就产生了冲突。为了解决这个冲突,一个自然的想法是:能否将每个作业的长度 l_j 和权重 w_j 聚合成一个单一的“分数” score(j)?然后,我们只需按照分数从高到低的顺序安排作业即可。
这个聚合函数需要满足:权重越高,分数越高;长度越长,分数越低。
以下是两种最简单的满足条件的函数:
- 差值函数:
score(j) = w_j - l_j - 比值函数:
score(j) = w_j / l_j
这样,我们就得到了两个候选的贪心算法:按差值排序和按比值排序。
排除错误的候选算法
上一节我们提出了两个候选的贪心算法。本节中,我们将通过一个反例来快速排除其中一个。
当你有多个候选算法时,一个高效的策略是构造一个输入,使得不同算法产生不同的输出,从而证明至少有一个算法是错误的。
考虑一个包含两个作业的简单实例:
- 作业1:长度
l_1 = 5,权重w_1 = 3 - 作业2:长度
l_2 = 2,权重w_2 = 1
让我们计算两种排序方式的结果:
- 按差值排序:
score(1) = 3-5 = -2,score(2) = 1-2 = -1。因此先安排作业2,再安排作业1。- 完成时间:
C_2 = 2,C_1 = 2+5 = 7 - 加权完成时间之和:
(1*2) + (3*7) = 2 + 21 = 23
- 完成时间:
- 按比值排序:
score(1) = 3/5 = 0.6,score(2) = 1/2 = 0.5。因此先安排作业1,再安排作业2。- 完成时间:
C_1 = 5,C_2 = 5+2 = 7 - 加权完成时间之和:
(3*5) + (1*7) = 15 + 7 = 22
- 完成时间:
在这个例子中,按比值排序得到了更优(更小)的目标函数值(22 < 23)。因此,按差值排序的贪心算法并不总是正确的,我们可以将其排除。
算法与复杂度分析
经过上一节的筛选,我们剩下按比值 w_j / l_j 排序的贪心算法作为主要候选。虽然反例证明了另一个算法错误,但这并不自动保证当前算法总是正确。其正确性需要严格的证明,这将是后续课程的重点。
不过,我们可以轻松分析这个候选算法的运行时间。该算法非常简单:
- 为每个作业
j计算比值w_j / l_j。 - 根据这个比值对作业进行降序排序。
- 按照排序后的顺序执行作业。
因此,该算法本质上归结为一次排序操作。根据第一部分的知识,我们可以在 O(n log n) 时间内完成排序,这对于贪心算法来说是典型的高效表现。
总结

本节课中我们一起学*了为调度问题设计贪心算法的过程。我们从特殊案例(等长或等权)中获得直觉,提出了两种自然的聚合函数(差值和比值)来生成贪心规则。通过构造一个简单的反例,我们快速排除了按差值排序的算法。最终,我们得到了一个按权重与长度比值排序的候选贪心算法,其运行时间为 O(n log n)。在接下来的课程中,我们将深入探讨并证明这个算法的正确性。请记住,在设计算法时,对贪心算法保持健康的怀疑态度,直到看到完整的正确性证明,这是一个好*惯。
082:正确性证明第一部分

概述
在本节课中,我们将学*如何证明一个贪心算法的正确性。具体来说,我们将分析一个旨在最小化加权完成时间总和的作业调度算法。我们将使用一种称为“交换论证”的技术来证明该算法在所有输入下都能产生最优解。

证明计划

上一节我们介绍了贪心算法及其目标。本节中,我们来看看如何通过反证法来证明其正确性。
我们首先固定一个任意的作业实例,即一组具有权重和长度的作业。我们的目标是证明贪心算法总能为此实例生成最优调度。
为了简化初始证明,我们做出一个假设:所有作业的权重与长度之比(即比率)都是互不相同的。我们将在后续处理比率相等的情况。
此外,我们约定一种记法:将作业按比率从高到低重新编号,作业1的比率最高,作业2次之,依此类推,作业N的比率最低。在这种记法下,贪心调度方案非常简单,就是按编号顺序执行作业:1, 2, 3, ..., N。
现在,我们假设贪心算法不是最优的。这意味着存在另一个不同的最优调度方案,我们称之为 Sigma star。
关键观察
以下是证明的关键观察点:
由于最优调度方案 Sigma star 不同于贪心调度方案(即顺序1, 2, ..., N),它必然包含一对连续执行的作业,其中较早执行的作业的编号比较晚执行的作业的编号更大。



为什么这是真的?因为唯一一个能让作业编号在执行过程中始终保持递增顺序的调度方案,就是贪心调度方案(1, 2, ..., N)。任何其他方案都必然会在某个地方出现编号“下降”的情况,即一个编号较大的作业在一个编号较小的作业之前执行。

交换论证
根据我们的高层次证明计划,我们需要通过反证法推导出一个矛盾。具体做法是:构造一个比 Sigma star 更好的调度方案,从而与 Sigma star 的最优性假设相矛盾。


我们通过一个“交换”的思想实验来实现这一点。

假设在 Sigma star 中,我们找到了这样一对连续作业 I 和 J,其中 I 在 J 之前执行,但 I 的编号大于 J 的编号。Sigma star 的局部顺序可以表示为:...(一些作业)... I J ...(后续作业)...
我们执行的交换操作是:仅交换 I 和 J 的执行顺序,而保持它们之前和之后的所有作业顺序不变。交换后的顺序变为:...(一些作业)... J I ...(后续作业)...


总结

本节课中,我们一起学*了贪心算法正确性证明的初步框架。我们设定了证明场景,做出了简化假设,并指出了最优调度方案中必然存在一对可以交换的“逆序”作业。下一节,我们将深入分析这个交换操作对加权完成时间总和的具体影响,从而完成整个证明。
083:08_01_04_正确性证明第二部分

在本节课中,我们将继续学*用于最小化加权完成时间总和的贪心算法的正确性证明。我们将深入理解上一节视频结尾提出的“交换作业”操作所带来的影响。


上一节我们介绍了证明的核心思路:通过反证法,假设存在一个与贪心算法结果不同的最优调度方案,那么其中必然存在一对相邻作业,其中较早的作业具有较高的索引(即较低的权重与长度比值)。本节中我们来看看交换这对作业后,会发生什么。
交换作业的影响分析
以下是交换作业 I 和 J 后,对所有作业完成时间的影响分析:
- 作业 I 和 J 之外的作业:它们的完成时间不受影响。因为无论 I 和 J 的顺序如何,排在它们之前或之后的作业集合没有变化,等待时间总和不变。
- 作业 I:它的完成时间增加。因为它现在需要等待作业 J 完成。具体来说,其完成时间的增加量等于作业 J 的长度
L_J。 - 作业 J:它的完成时间减少。因为它不再需要等待作业 I 完成。具体来说,其完成时间的减少量等于作业 I 的长度
L_I。
成本效益分析
基于以上分析,我们可以对这次交换进行成本效益核算。
- 成本:由作业 I 的完成时间增加导致。成本值为作业 I 的权重
W_I乘以增加的时间L_J,即成本 = W_I * L_J。 - 效益:由作业 J 的完成时间减少带来。效益值为作业 J 的权重
W_J乘以减少的时间L_I,即效益 = W_J * L_I。
利用贪心规则推导矛盾
现在,我们利用一个关键事实:在假设的最优调度 Sigma* 中,作业 I 的索引高于作业 J(即 i > j)。根据我们的索引规则(比值 W/L 降序排列),这意味着作业 I 的比值低于作业 J 的比值。
用公式表示这个关系:
W_I / L_I < W_J / L_J


为了更清晰地比较成本与效益,我们对不等式两边同时乘以 L_I * L_J 以消去分母:
W_I * L_J < W_J * L_I
观察这个不等式,它的左边正是我们计算出的交换成本 W_I * L_J,而右边正是交换效益 W_J * L_I。

这个不等式 成本 < 效益 意味着,如果我们对假设的最优调度 Sigma* 执行交换作业 I 和 J 的操作,得到的新调度的加权完成时间总和将严格小于原调度 Sigma* 的总和。
但这与我们的前提“Sigma* 是最优调度”相矛盾。一个最优解不可能通过简单的局部交换变得更好。
因此,我们的初始假设(存在一个与贪心算法结果不同的最优调度)是错误的。这证明了贪心算法产生的调度确实是全局最优的。

本节课中我们一起学*了贪心算法正确性证明的第二部分。我们分析了交换一对违反贪心规则的作业所带来的具体影响,并通过成本效益计算,利用比值不等式推导出了矛盾,从而完成了整个证明。这确认了按照 W_i / L_i 降序排列的贪心策略对于最小化加权完成时间总和问题是最优的。
084:处理平局情况-进阶选学 🧩

在本节课中,我们将重新审视用于最小化加权完成时间总和的贪心算法,并给出一个更稳健、更通用的正确性证明,该证明也能处理不同工作比率相等(即平局)的情况。
概述


上一节我们介绍了在假设工作权重与长度之比(w_i / l_i)各不相同的情况下,贪心算法的正确性证明。本节中,我们将放宽这一假设,允许比率存在平局,并证明无论如何处理平局,贪心算法都能产生最优解。我们将使用一种不依赖于反证法的交换论证。
证明计划

与之前类似,我们将针对任意一个输入实例论证算法的正确性。固定一个输入实例,并采用以下符号:
- 令
σ表示贪心算法的输出调度。 - 令
σ*表示任意一个其他调度(即“竞争者”)。
我们的目标是证明 σ 至少和 σ* 一样好。由于 σ* 是任意的,这意味着贪心算法的输出至少和所有其他调度一样好,因此 σ 是最优的。

为简化符号,我们假设贪心调度 σ 就是 (1, 2, 3, ..., n)。这只是一个重命名工作的约定,不影响论证的一般性。
核心论证
现在,我们固定一个任意的竞争调度 σ*。如果 σ* 恰好等于 σ,则无需证明。如果 σ* 不等于 σ,那么它必然包含一个相邻逆序对。
以下是相邻逆序对的定义:
- 在调度中,存在两个连续执行的工作
i和j,其中j紧接在i之后执行。 - 并且,
i的索引大于j的索引(即,在贪心排序(1, 2, ..., n)中,i排在j后面)。

由于贪心算法按 w_i / l_i 的非递增顺序排序,索引更高意味着比率相等或更小。因此,对于这样的相邻逆序对 (i, j),我们有:
w_i / l_i ≤ w_j / l_j

通过交叉相乘,这等价于:
w_i * l_j ≤ w_j * l_i
这个不等式具有重要的语义。回顾之前的证明,交换 i 和 j 会导致:
- 成本:工作
i的完成时间增加了l_j,导致目标函数值增加w_i * l_j。 - 收益:工作
j的完成时间减少了l_i,导致目标函数值减少w_j * l_i。
因此,交换 (i, j) 带来的净收益为 (w_j * l_i) - (w_i * l_j)。根据上面的不等式,这个净收益大于等于零。
这意味着,在调度 σ* 中交换一个相邻逆序对,不会使调度变差(目标函数值可能不变,也可能减小)。
通过迭代交换完成证明
上述操作不仅不会使调度变差,还有一个关键性质:它恰好将逆序对的数量减少1(因为交换的是相邻逆序,不会产生新的逆序)。
基于此,我们可以构造一个证明过程:
- 从任意竞争调度
σ*开始。 - 如果
σ*就是贪心调度σ,则证明完成。 - 否则,
σ*中必然存在一个相邻逆序对。我们交换这个逆序对,得到一个新的调度σ*'。- 根据论证,
σ*'的目标函数值 ≤σ*的目标函数值。 - 同时,
σ*'的逆序对数量比σ*少1。
- 根据论证,
- 对新的调度
σ*'重复步骤2和3。
为什么这个过程会终止?因为一个调度的逆序对数量最多为 C(n, 2) = n(n-1)/2(即所有工作完全逆序排列时)。每次交换都严格减少逆序对数量,因此我们最终必然会在有限步内得到逆序对数量为0的调度,即贪心调度 σ。
在整个过程中,我们通过一系列交换操作,将任意调度 σ* 转化成了贪心调度 σ,并且每一步都没有使目标函数值变差。因此,最终的贪心调度 σ 的目标函数值 ≤ 初始调度 σ* 的目标函数值。
由于 σ* 是任意的,这证明了贪心调度 σ 至少和所有可能的调度一样好,即它是最优解。
与冒泡排序的关联
熟悉冒泡排序算法的读者可能会发现,上述证明中在调度 σ* 上反复交换相邻逆序对的过程,本质上就是在对其应用冒泡排序。我们通过“消除逆序”的方式,逐步将其转化为有序(即贪心)序列,并且在此过程中目标函数值只降不升,从而证明了有序序列(贪心调度)的最优性。
总结


本节课中,我们一起学*了如何扩展贪心算法的正确性证明,以处理工作权重与长度之比存在平局的情况。我们放弃了反证法,转而采用一种构造性的迭代交换论证:
- 我们证明了交换任意相邻逆序对不会使调度变差。
- 我们通过反复进行这种交换,可以将任何竞争调度转化为贪心调度,且每一步都保持目标函数值不增加。
- 这最终证明了贪心调度至少和所有其他调度一样好,因此是最优的。
这个证明不仅解决了平局问题,也展示了交换论证法的另一种灵活运用。
085:最小生成树问题定义


在本节课中,我们将学*一个基础图论问题——最小生成树问题。我们将了解其形式化定义、应用场景,以及后续课程中将探讨的两种著名贪心算法。

问题定义
上一节我们介绍了最小生成树问题的背景。本节中,我们来看看其形式化定义。
最小生成树问题的输入是一个无向图 G=(V, E),其中每条边 e 都有一个成本 c(e)。输出是一个总成本最小的生成树 T。
以下是生成树 T 必须满足的两个条件:
- 无环性:
T中不能包含任何环。 - 连通性:对于图
G中的任意两个顶点,T中都存在一条路径将它们连接起来。
生成树 T 的成本是其所有边成本的总和:cost(T) = Σ_{e∈T} c(e)。我们的目标是找到成本最小的生成树。
示例说明
为了更直观地理解,我们来看一个简单的例子。
考虑一个包含四个顶点(A, B, C, D)和五条边的图,边成本如下:
- A-B: 1
- A-D: 2
- B-D: 3
- B-C: 4
- C-D: 5
以下是几个子图的例子:
- 子图 {A-B, B-D, C-D}:成本为 1+3+5=9。该子图无环且连通所有顶点,因此是一个生成树,但不是成本最小的。
- 子图 {A-B, A-D, B-C}:成本为 1+2+4=7。该子图同样无环且连通,是一个生成树,并且在这个例子中是唯一的最小生成树。
- 子图 {A-B, A-D, B-D}:成本为 1+2+3=6。虽然成本更低,但这个子图包含环(A-B-D-A),因此不是一个生成树。

简化假设
为了专注于核心算法思想,我们在后续讨论中将做出两个简化假设。
以下是这两个假设及其原因:
- 图是连通的:输入图
G本身是连通的。如果图不连通,则不存在生成树。这个条件可以通过广度优先搜索(BFS)或深度优先搜索(DFS)在线性时间内轻松验证。对于非连通图,算法可以稍作修改以计算每个连通分量的最小生成树(即最小生成森林)。 - 边成本互异:图中所有边的成本都不相同。这个假设主要是为了简化正确性证明。我们即将学*的普里姆算法和克鲁斯卡尔算法,即使在有相同成本边的情况下也仍然是正确的。
算法概览

了解了问题定义后,我们来看看解决它的高效方法。
最小生成树问题之所以有趣,是因为存在多种正确的贪心算法。我们将在后续课程中重点学*其中两种最著名的算法:
- 普里姆算法:该算法与迪杰斯特拉最短路径算法有诸多相似之处,核心思想是从一个顶点开始,逐步“生长”出一棵树。
- 克鲁斯卡尔算法:该算法按照边成本从小到大的顺序考虑边,并使用并查集数据结构来避免形成环。
这两种算法都非常高效。通过使用合适的数据结构(如堆或并查集),它们的运行时间可以达到 O(m log n),其中 m 是边数,n 是顶点数。这几乎是线性时间,仅比读取图输入所需的时间略多。

本节课中,我们一起学*了最小生成树问题的形式化定义、性质以及解决该问题的算法概览。从下一节课开始,我们将深入探讨普里姆算法的具体步骤、正确性证明及其高效实现。
086:Prim最小生成树算法 🌳
在本节课中,我们将要学*第一个最小生成树算法——Prim算法。我们将通过一个具体的例子来理解其工作原理,然后给出通用的伪代码描述,并讨论其正确性证明的思路。

算法工作原理示例

上一节我们介绍了最小生成树问题的定义,本节中我们来看看Prim算法是如何工作的。在展示任何伪代码之前,我们先通过一个例子来图解算法。
我们将使用与上一视频相同的示例图,它有四个顶点和五条边。



算法的计划是每次添加一条边来“生长”一棵树。这个过程类似于霉菌的生长:我们从一颗“种子”顶点开始,然后在算法的每次迭代中“吸收”一个新的顶点。这与Dijkstra的最短路径算法有相似之处。在Dijkstra算法中,我们从给定的源顶点开始生长。在最小生成树问题中,我们没有源顶点,但事实证明,我们可以从任意一个顶点开始,选择哪个顶点并不影响最终结果。


在每次迭代中,我们将添加一条边,以连接一个与当前已覆盖顶点相邻的新顶点。作为一个贪心算法,Prim算法将简单地选择允许它覆盖一个新顶点的最便宜的边。
在算法开始时,我们实际上还没有覆盖任何边。我们把自己看作是正在从右上角的顶点开始生长。那么,有哪些边可以让我们连接一个相邻的顶点呢?有两条边:成本为1的顶部边(连接左上角顶点),以及成本为2的右边(连接右下角顶点)。我们将执行贪心选择,选择成本更低的边,即成本为1的边。


至此,我们的树覆盖了顶部的两个顶点。


在下一个迭代中,我们希望再添加一条边以覆盖一个新的顶点。现在,从我们当前已覆盖的区域“伸出”的、能让我们连接一个新顶点的边有三条:成本为2、3和4的边。成本为2和3的边可以让我们连接到右下角的顶点;成本为4的边可以让我们连接到左下角的顶点。我们将再次执行贪心选择,从这三条候选边中选择最便宜的一条,即成本为2的边。



现在,我们生长的“霉菌”覆盖了除左下角顶点之外的所有顶点。

在最后的迭代中,我们希望再包含一条边,以覆盖最后剩下的左下角顶点。请注意,那条成本为3的边我们从未添加,但它已经被我们生长的树所覆盖了。因此我们将忽略它,因为添加这条成本为3的边不会让我们覆盖任何新顶点,反而会创建一个我们不需要的环。所以,现在有两条边可以让我们覆盖一个额外的顶点:成本为4的边和成本为5的边。我们将执行贪心选择,选择成本为4的边。
当我们拥有了成本为1、2和4的边后,我们就得到了一棵生成树:没有环,并且沿着粉色边可以从任何一个顶点到达任何其他顶点,总成本为7。从上一视频可以回忆,这确实是该图的最小成本生成树。



当然,这个在只有四个顶点和五条边的玩具示例中正确运行的简单过程,并不意味着它在一般情况下就是一个好算法。接下来,让我们正式地定义这个通用算法。

通用算法与伪代码
对于一个通用图,从一个起点开始像霉菌一样生长,每次迭代贪心地覆盖一个新顶点,直到完成,具体意味着什么呢?让我们在下一张幻灯片上阐明伪代码。


以下是Prim的最小生成树算法伪代码:
我们首先进行两行初始化。我们将维护一个顶点集合 X,它代表我们目前已经覆盖的顶点。我们需要一个“种子”顶点来开始这个过程。选择哪个顶点无关紧要,最终我们都会得到相同的树。因此,我们任意选择一个顶点 s 作为生长的起点。
我们维护的另一个东西当然是树本身,它最初是空的集合 T。我们将在每次迭代中向其中添加一条边。
整个算法过程中我们将保持一个不变式:当前在集合 T 中的边,覆盖了当前在集合 X 中的顶点。
然后是我们的主 while 循环,这是算法的核心部分,它与Dijkstra算法中的循环非常相似。每次迭代负责选择一条跨越当前边界的边,从而将一个新顶点纳入覆盖范围。它同样是贪心的,但选择标准比Dijkstra算法更简单:我们只看哪条边能让我们以最低成本覆盖一个新顶点。
只要还有尚未覆盖的顶点,循环就会继续。
在每次迭代中,我们搜索那些能让我们覆盖一个新顶点的边。具体是哪些边呢?我们希望边的一个端点在已覆盖的顶点集合 X 内,另一个端点不在 X 内(即,在外部)。如果一条边以这种方式跨越边界,那么添加它就可以将覆盖的顶点数增加一个。
如果边 e 是所有这种跨越边界的边中最便宜的一条,那么我们就将它添加到当前的树 T 中。这条边中那个不在 X 中的端点 v,就是我们在本次迭代中要添加到 X 的顶点。
一次迭代的语义是:我们试图在花费尽可能少的情况下,增加被覆盖的顶点数量。正是在这个意义上,Prim算法是一种贪心算法。
与通常的贪心算法一样,这看起来足够自然,但远不清楚它是否正确,即它是否总是能计算出一棵最小生成树。事实上,仔细想想,甚至不能明显看出它一定能计算出一棵生成树(无论是否最小)。但它是正确的,让我们在下一张幻灯片上精确地陈述这一点。
正确性声明与证明计划

关键主张是:Prim算法是正确的。对于任何连通的输入图,它保证输出一棵具有最小可能成本的生成树。

在我们深入任何细节之前,让我通过说明证明计划来结束本视频。我们将分两部分来证明这个定理:
- 第一部分:首先,我们将证明算法会输出某棵生成树(可能不是最小的)。即使这一点也并非微不足道。
- 第二部分:然后,我们将论证输出的生成树实际上就是成本最小的那棵。
证明的两个部分都很有趣。
- 对于第一部分(论证输出的是生成树),我们将回顾一些关于图、割以及图中生成树的预备知识。
- 对于第二部分(论证最优性),我们将依赖于最小生成树的一个非常简洁的性质,称为割性质。


我很高兴地告诉大家,我们在这里两部分所做的工作将在以后结出更多的果实。当证明另一个MST算法——Kruskal算法的正确性时,我们将重用这些要素。
对于那些更愿意讨论运行时间而非正确性的同学,请不要担心,在完成这个正确性证明之后,我们会讨论如何快速实现Prim算法,特别是使用堆(heaps)将其运行时间降低到接*线性的 O(m log n) 界限。
本节课中我们一起学*了Prim最小生成树算法的工作原理,通过示例理解了其贪心生长的过程,并查看了其通用伪代码。我们还概述了证明其正确性的两步计划,为后续深入分析奠定了基础。
087:Prim算法正确性证明(第一部分)

在本节课中,我们将开始探讨为什么Prim算法是正确的,即为什么对于每个连通图,它总能输出该图的最小生成树。本视频我们将设定一个更适度的目标:首先证明Prim算法总能输出一个生成树,暂时不讨论其最优性。


图割的基本概念
上一节我们介绍了Prim算法的目标。本节中,我们来看看证明所需的一个核心工具:图割。图割是理解Prim算法正确性的关键。
一个图的割,简单来说,就是将其顶点集划分为两个非空子集。我们可以将其中一个子集想象为集合A,另一个为集合B。
那么,图中的边如何分布呢?有三种情况:
- 边的两个端点都在集合A内。
- 边的两个端点都在集合B内。
- 边的两个端点分别位于A和B中。

我们将第三种情况的边称为横跨割 (A, B) 的边。
对于一个给定的割,可能有多条边横跨它。同样,对于图中的一条边,通常也存在多个割使得这条边横跨其中。
为了更好地理解,让我们回顾一个关于图割数量的简单性质。


对于一个有 n 个顶点的图,大约有多少个割?选项有:大约 n 个,大约 n² 个,大约 2^n 个,大约 n^n 个。
正确答案是第三个:2^n。一个具有 n 个顶点的图本质上拥有 2^n 个割。这是因为我们可以为每个顶点做一个二元决策:它要么属于A,要么属于B。n 个二元决策导致 2^n 种不同的结果。严格来说,由于割要求两个集合非空,这排除了两种可能性(全部分配给A或全部分配给B),所以确切的数量是 2^n - 2。
关于图割的三个关键事实
接下来,我们将陈述并证明关于图割的三个简单事实。一旦掌握了这三个事实,我们就能证明本视频开头的断言:Prim算法总能输出一个生成树。
事实一:空割引理
空割引理旨在为我们提供一种判断图是否连通的新方法。具体来说,它从图不连通的角度来表述。
断言:一个图是不连通的,当且仅当我们能找到一个没有任何边横跨的割。
回忆一下,图连通的定义是:对于图中任意两个顶点,都能找到一条路径连接它们。因此,这里说的是“存在一对顶点之间没有路径”等价于“存在一个没有横跨边的割”。

证明:
这是一个“当且仅当”的陈述,需要从两个方向证明。
-
(⇐)从右向左:假设存在一个割 (A, B) 没有横跨边。我们需要证明图不连通。只需从割的两边各取一个顶点
u ∈ A和v ∈ B。由于没有边横跨割 (A, B),任何从u到v的路径都必须穿过这个割,但没有任何边可供穿过。因此,u和v之间不存在路径,图不连通。 -
(⇒)从左向右:假设图不连通,即存在一对顶点
u和v,它们之间没有路径。我们需要构造一个没有横跨边的割。- 定义集合
A为从u出发在图中可达的所有顶点(即u所在的连通分量)。 - 定义集合
B为所有不在A中的顶点(即其他连通分量)。 - 根据定义,
u ∈ A,v ∈ B,所以A和B都非空,这构成了一个合法的割 (A, B)。 - 现在证明没有边横跨这个割。假设存在一条边
e横跨 (A, B),其一端在A中,另一端在B中。那么,从u到A中那个端点的路径(由A的定义保证)加上边e,就构成了一条从u到B中那个端点的路径,这与B的定义(B中的顶点从u不可达)矛盾。因此,不存在这样的横跨边。
- 定义集合
关键点:空割引理为我们提供了一种判断连通性的新视角:图是连通的当且仅当它没有空割。
事实二:双交叉引理
双交叉引理的核心内容是:如果一个图中的环横跨了一个割,那么它必须横跨这个割至少两次(即偶数次),不可能只横跨一次。
直观理解:考虑一个割 (A, B) 和一条横跨该割的边 e(一端在A,一端在B)。假设 e 属于某个环 C。因为环必须闭合并回到起点,如果它从A通过 e 进入B,那么它必须通过另一条边从B返回A,才能完成循环。因此,环横跨割的次数至少是两次。
这个引理的一个直接推论非常有用,我们称之为孤独割推论。
事实三:孤独割推论
孤独割推论是确保算法不产生环的一个工具。
推论:如果一条边 e 是横跨某个割 (A, B) 的唯一一条边(即它是“孤独的”),那么这条边 e 不可能属于图中的任何环。
证明:如果 e 属于某个环,根据双交叉引理,这个环必须横跨割 (A, B) 至少两次。但 e 是横跨该割的唯一一条边,没有其他边可供环进行第二次横跨,这就产生了矛盾。因此,e 不可能在任何环中。

证明Prim算法输出生成树

现在,我们已经准备好了所有工具,可以证明Prim算法的第一个正确性部分:对于任何连通输入图,Prim算法总能输出一个生成树(暂不考虑最优性)。
我们将分三步进行论证。
步骤一:算法语义的正确性
首先,我们注意到算法在整个运行过程中维护的两个集合的语义是正确的:
X:当前已被生成树覆盖的顶点集合。T:当前已被选入生成树的边集合。
算法的意图是,边集 T 始终张成顶点集 X(即 T 中的边连接了 X 中的所有顶点,且在 X 内形成一棵树)。通过观察算法伪代码(特别是每次迭代添加一个新顶点和一条连接该顶点与 X 的边),可以直观理解这一点。如果需要严格证明,可以进行简单的归纳,这里不再赘述。
步骤二:证明算法覆盖所有顶点(连通性)
一个生成树必须包含所有顶点。根据步骤一,我们只需要证明算法终止时,X 等于所有顶点的集合 V。
查看算法的主循环:每次迭代,我们都向 X 中添加一个新顶点。算法可能失败的唯一情况是,在某个迭代中,当 X 还未包含所有顶点时,我们无法找到一条连接 X 和 V - X 的边。
如果发生这种情况,那就意味着割 (X, V - X) 是一个空割(没有横跨边)。根据空割引理,存在空割意味着原图是不连通的。但这与我们的输入图是连通的假设相矛盾。因此,这种情况永远不会发生。算法在每次迭代中总能找到一条边,从而最终使 X = V。
步骤三:证明算法不产生环(无环性)
一个生成树不能包含环。我们需要证明算法选择的边集 T 始终是无环的。
我们将论证,每当算法添加一条新边时,这条边都不会在 T 中创建任何环。
考虑算法在某个迭代中的快照:
- 当前已选边集为
T。 - 当前已覆盖顶点集为
X。 - 未覆盖顶点集为
V - X。

此时,割 (X, V - X) 是一个合法的割。根据算法构造,T 中的所有边都完全位于 X 内部(因为它们连接的是 X 中的顶点),所以没有边横跨割 (X, V - X)。
Prim算法在本次迭代中,只搜索那些一端在 X、一端在 V - X 中的边,即只搜索横跨割 (X, V - X) 的边。假设它选择并添加了边 e。
在边 e 被添加到 T 的那一刻,它成为了横跨割 (X, V - X) 的唯一一条边(因为 T 中之前的边都不横跨该割)。根据孤独割推论,作为横跨该割的唯一成员,边 e 不可能属于 T 中的任何环。
由于每次添加新边时,我们都能找到这样一个割,使得新边在添加时刻是“孤独的”,因此算法永远不会创建环。

总结
本节课中,我们一起学*了:
- 图割的定义:将顶点集划分为两个非空子集。
- 三个关键的图割性质:
- 空割引理:图不连通 ⇔ 存在空割。
- 双交叉引理:环横跨割的次数必为偶数。
- 孤独割推论:横跨割的唯一一条边不可能属于任何环。
- 利用这些性质,我们证明了Prim算法的第一个正确性部分:对于任何连通图,Prim算法总能输出一个生成树(即一个覆盖所有顶点且无环的边集)。在下一节课中,我们将继续证明这个生成树实际上是最小权重的,即最小生成树。
088:正确性证明二

在本节课中,我们将学*如何证明普里姆算法总能输出一个最小生成树。我们将借助一个称为“割性质”的关键定理来完成证明。通过理解这个性质,我们将看到普里姆算法每一步的贪心选择为何是安全的,并最终构成最优解。


上一节我们证明了普里姆算法至少会输出一个生成树。本节中,我们将证明它输出的是最小生成树。

在设计贪心算法时,我们总会面临一个核心问题:如何确保当前看似短视的决策不会在未来导致错误?普里姆算法每一步都选择一条边加入树中,且永不反悔。我们如何保证这个决策是正确的?
对于最小生成树问题,存在一个优美的条件,它能告诉我们何时可以放心地将一条边加入生成树,而绝无后顾之忧。这个条件被称为割性质。
割性质 ✂️
割性质是一个非常重要的定理,其内容如下:
考虑图
G中的任意一条边e。假设存在图的一个割(A, B)(即将顶点集划分为两个非空集合A和B),使得边e是所有横跨该割的边中成本最低的。那么,边e必定属于图G的每一个最小生成树。
换句话说,如果你能为一条边找到一个割,使得它是该割上最便宜的“桥”,那么这条边就必须出现在最终的最小生成树里。
为了让大家更好地感受这个性质,我们来看一个简单的例子。
性质应用示例 🔍
考虑一个由4个节点和4条边组成的环,各边成本分别为1、2、3、4。
以下是几个应用割性质的例子:
- 第一个割:将右上角的节点单独放在割的一侧,其余三个节点放在另一侧。横跨这个割的边有成本为1和2的两条边。成本为1的边是最便宜的。因此,根据割性质,成本为1的边必须在最小生成树中。
- 第二个割:将右下角的节点单独放在割的一侧,其余三个节点放在另一侧。横跨这个割的边有成本为2和3的两条边。成本为2的边是最便宜的。因此,成本为2的边也必须在最小生成树中。
- 第三个割:将左下角的节点单独放在割的一侧,其余三个节点放在另一侧。横跨这个割的边有成本为3和4的两条边。成本为3的边是最便宜的。因此,成本为3的边必须在最小生成树中。
通过观察这些例子,我们可以发现一个关键点:对于一条边,我们只需要找到一个能证明它是该割上最便宜边的割,就足以断定它属于最小生成树。例如,成本为2的边在第一个割中并不是最便宜的,但我们在第二个割中找到了证明它的理由。
另一方面,我们无法为成本为4的边找到任何一个割,使其成为该割上最便宜的边。这符合预期,因为成本为4的边确实不在最小生成树中。
关于唯一性的说明:割性质的结论中提到了“最小生成树”,这暗示了唯一性。在本课程中,我们假设所有边的成本都是互不相同的。在这种情况下,最小生成树确实是唯一的。如果边成本存在相同值,则可能有多个不同的最小生成树,割性质的表述也需要稍作调整。
现在,让我们利用割性质来证明普里姆算法的正确性。

证明普里姆算法的正确性 ✅
我们假设割性质成立(其证明本身需要一些技巧,我们将在另一个视频中单独讨论)。基于此,我们来论证普里姆算法总能输出最小生成树。
回顾上一节的结论:普里姆算法的输出 T* 是一个生成树(即连通所有顶点且无环)。

现在,让我们仔细观察普里姆算法的伪代码。在算法的每一步迭代中:
- 我们有一个已纳入生成树的顶点集合
X。 - 其余顶点构成集合
V - X。 (X, V - X)构成了图的一个割。- 算法会暴力搜索所有横跨这个割的边,并选择其中成本最低的一条加入树中。
这正是割性质所描述的场景!割性质说:“横跨一个割的最便宜的边,必须属于最小生成树。” 而普里姆算法在每一步迭代中,恰好选择的就是这样一条边。
因此,算法 T* 中每一条边的加入,都满足割性质的前提条件。根据割性质的结论,这些边都必须属于最小生成树。这意味着,T* 中的所有边都是最小生成树的边,即 T* 是最小生成树的一个子集。

然而,T* 本身已经是一个生成树(连通且无环)。如果我们向一个生成树中添加任何不属于它的边,就会产生环,从而破坏树的定义。因此,T* 不可能只是最小生成树的一个真子集,它必须就是整个最小生成树本身。
由此,我们得出结论:对于任意连通的输入图,普里姆算法输出的 T* 就是该图的最小生成树。

本节课中我们一起学*了割性质,并利用它证明了普里姆算法的正确性。我们了解到,该算法每一步选择当前割上最小边的贪心策略,并非短视行为,而是由割性质保证的、通向全局最优解的必然步骤。这完美解释了为何这个简单的贪心算法总能找到最小生成树。
089:割性质证明

在本节课中,我们将学*并证明一个关于最小生成树的关键性质——割性质。这个性质是普里姆算法等贪心算法正确性的核心保证。我们将通过一个严谨的证明,来理解为什么“跨越某个割的最便宜边”一定属于最小生成树。

上一节我们介绍了割性质的基本概念,本节中我们将通过反证法和交换论证来严格证明它。


证明思路

证明将采用反证法,其核心思路与证明加权完成时间调度算法正确性的思路类似。我们将从一个假设不包含特定边的最优解(最小生成树)出发,通过交换一条更贵的边与这条特定边,构造出一个成本更低的生成树,从而得出矛盾。

具体来说,如果割性质不成立,那么存在一个图、一个割以及一条跨越该割的最便宜边 e,但这条边 e 却不属于最小生成树 T*。我们的计划是将这条缺失的边 e 与 T* 中某条更贵的边进行交换,从而得到一个成本更低的生成树,引出矛盾。
第一次交换尝试
让我们从一个初步的交换论证开始。
假设我们有一个图的一个割 (A, B),边 e 是跨越这个割的最便宜边。根据反证法假设,这条最便宜的边 e 不属于最小生成树 T*。

然而,T* 必须包含至少一条其他跨越该割 (A, B) 的边。原因在于,如果 T* 不包含任何跨越该割的边,那么 A 和 B 两部分将不连通,这与生成树的定义矛盾。因此,T* 包含另一条跨越该割的边,我们称之为 f。
由于 e 是最便宜的跨越边,而 f 是另一条跨越边,所以 f 的成本严格高于 e。此时,我们似乎可以执行交换:从 T* 中移除 f,加入 e,期望得到一个新的、成本更低的生成树,从而完成反证。
交换的复杂性
然而,图论中的交换比调度问题中的交换更为微妙。在调度中,交换两个任务总是得到另一个有效的调度。但在图中,从一个生成树中移除一条边并加入一条新边,不一定能得到另一个生成树。
以下是一个关键问题:当我们从生成树 T* 中移除一条边 f 并加入一条新边 e 时,我们是否总是得到一个新的生成树?答案是否定的。考虑下图示例:

A侧 B侧
(u)---e---(v)
| |
f |
| |
(x)---e'--(y)
假设粉色边构成生成树 T*,它包含边 f 和 e‘,但不包含最便宜边 e。如果我们简单地用 e 交换 f,得到的新图可能包含环(例如 u-x-y-v-u),并且右上角的顶点可能变得不连通,因此它不是一个生成树。
这个例子告诉我们,不能随意交换任意一条跨越割的边。我们需要找到一条“合适”的边进行交换,以确保交换后得到的仍然是一个生成树。
找到正确的交换边
幸运的是,我们总能找到这样一条合适的边。以下是寻找方法:
-
构造环:将缺失的最便宜边
e加入最小生成树T*。由于T*原本在e的两个端点u和v之间就存在一条路径,加入e后必然会形成一个环,我们称这个环为C。T* + e => 产生环 C -
应用双重跨越引理:根据“双重跨越引理”,如果一个环跨越了一个割至少一次,那么它必须跨越这个割至少两次。在我们的设定中,环
C通过边e已经跨越了割(A, B)一次,因此它必须包含另一条也跨越该割的边,我们称之为e‘。 -
执行交换:现在,我们用边
e交换环C中的边e‘(e‘原本在T*中)。即,从T*中移除e‘,加入e,得到一个新图T‘。T‘ = (T* \ {e‘}) ∪ {e}
为什么 T‘ 是一个生成树?
- 无环性:加入
e创造了环C,而移除e‘(它是环C的一部分)恰好破坏了这个环。 - 连通性:移除环上的一条边不会破坏图中任意两点间的连通性,因为环上的任意两点间仍有替代路径。
因此,T‘ 是一个具有 |V|-1 条边、连通且无环的图,所以它是一个生成树。

完成证明
由于 e 是跨越割 (A, B) 的最便宜边,而 e‘ 是另一条跨越该割的边,所以有:
cost(e) < cost(e‘)
因此,新生成树 T‘ 的总成本为:
cost(T‘) = cost(T*) - cost(e‘) + cost(e) < cost(T*)
但这与 T* 是最小生成树(成本最低)的假设矛盾。
这个矛盾源于我们最初“割性质不成立”的假设。因此,该假设必须为假,从而证明了割性质:对于任意一个图,给定任意一个割,跨越该割的最便宜边一定属于该图的某个最小生成树。在边权互异的情况下,这条边属于唯一的最小生成树。
本节课中我们一起学*了割性质的完整证明。我们通过反证法,假设存在一条不属于最小生成树的最便宜跨越边,然后通过将其加入树中形成环,利用双重跨越引理找到环上另一条跨越同割的边,执行交换后得到了一个成本更低的生成树,从而引出矛盾,证明了原性质。这个性质是理解普里姆、克鲁斯卡尔等贪心最小生成树算法为何正确的基础。
090:快速实现一

概述
在本节课中,我们将要学*普里姆算法的实现细节与运行时间分析。我们将从分析其朴素实现开始,然后探讨如何通过巧妙运用堆数据结构来显著提升算法效率。
算法回顾与朴素实现分析
上一节我们介绍了普里姆算法的正确性。本节中我们来看看它的实现。
普里姆算法通过逐步添加边来构建最小生成树。它维护两个集合:已覆盖的顶点集合 X 和已选中的边集合 T。算法从一个任意顶点 s 开始,每次迭代选择一条连接 X 与 V-X 的最小成本边,并将其对应的新顶点加入 X。
如果直接按此伪代码实现,运行时间是多少?
初始化步骤仅需常数时间。主循环的迭代次数为 n-1 次,其中 n 是顶点数。每次迭代需要检查所有边以找到跨越当前割的最小成本边,这可以在 O(m) 时间内完成,其中 m 是边数。

因此,朴素实现的总运行时间为 O(m * n)。这个多项式时间算法已经比检查所有生成树要高效得多。但算法设计者总会问:我们能做得更好吗?
加速的核心思想:使用堆
为了加速普里姆算法,我们将采用与加速迪杰斯特拉算法相同的大思路:部署一个合适的数据结构。




主循环中反复执行的操作是在所有跨越割的边中寻找最小值。什么样的数据结构能加速重复的最小值计算?答案就是堆。堆的专长正是加速重复的最小值计算。
以下是堆支持的关键操作及其时间复杂度:
- 插入:将带有键值的新对象插入堆中。
- 提取最小值:移除并返回键值最小的对象。
- 删除:从堆中删除任意指定对象。
- 所有这些操作都可以在 O(log N) 时间内完成,其中 N 是堆中对象的数量。
堆在底层通常实现为满足堆性质的完全二叉树:每个父节点的键值都小于其子节点的键值。这使得最小值总是位于根节点。
堆的部署策略
我们的直觉是,既然普里姆算法需要重复的最小值计算,堆似乎很合适。第一个自然的想法是让堆存储边,并以边成本作为键值。这样,提取最小值就能直接给我们一条边。
这已经是一个很好的想法,能将运行时间提升至 O(m log n)。但我们将探讨一种更巧妙、在实践中更优的实现方式:让堆存储顶点而非边。
这种更高级的实现方式运行时间也是 O(m log n),但常数更优。其核心思想与我们加速迪杰斯特拉算法时所用的想法非常相似。
基于顶点的堆实现方案
我们的计划是维护两个不变式。

第一个不变式描述堆的内容:堆中存储所有尚未被覆盖的顶点,即 V - X 中的顶点。这样,我们从堆中提取最小值时,得到的就是下一个要加入 X 的顶点。
第二个不变式定义堆中顶点的键值:对于堆中的每个顶点 v(属于 V - X),其键值定义为所有连接 v 与已覆盖集合 X 的边中成本的最小值。如果没有这样的边,则键值定义为 +∞。
用公式表示,对于 v ∈ V - X:
key[v] = min{ cost(e) | e = (u, v), u ∈ X },若这样的边存在;否则 key[v] = +∞。
实现细节
给定这个使用堆的高级方案,我们需要思考三个问题:如何初始化堆以满足不变式;如何利用堆高效模拟每次迭代;以及如何在算法执行过程中维护这些不变式。
1. 堆的初始化
在预处理步骤中,我们需要设置堆以满足两个不变式。
开始时,X 只包含起始顶点 s。V - X 包含其余 n-1 个顶点。对于 V - X 中的每个顶点 v,其初始键值就是边 (s, v) 的成本(如果存在),否则为 +∞。
通过一次 O(m) 的边扫描,我们可以计算出每个需要入堆顶点的键值。然后将这 n-1 个顶点插入堆中,插入操作的总成本为 O(n log n)。因此,初始化总时间为 O(m + n log n),在渐进意义上可记为 O(m log n)。

2. 模拟主循环迭代
假设两个不变式成立,那么从堆中提取最小值就能忠实地模拟朴素实现中的暴力搜索。
提取最小值操作返回的是下一个应加入 X 的顶点 w。同时,连接 w 与 X 且成本等于 key[w] 的那条边,就是本次迭代应加入边集合 T 的边。
这可以看作是一个两轮淘汰赛:
- 第一轮(本地优胜):每个在 V-X 中的顶点 v,通过其键值
key[v]记住它连接到 X 的最佳(最便宜)边。 - 第二轮(全局优胜):堆的提取最小值操作,从所有第一轮的本地优胜者中,选出成本最小的那一个,即全局跨越割的最小成本边。
因此,每次迭代的核心操作就是一次堆的提取最小值,这可以在 O(log n) 时间内完成。
3. 维护不变式
当我们从堆中提取一个顶点 w 并将其加入 X 后,割发生了变化。一些原本在 V-X 中的顶点,现在可能有了新的、更便宜的边连接到新的 X 集合(特别是连接到新加入的顶点 w)。
因此,对于每个与 w 相邻且仍在堆中的顶点 v(即 v ∈ V-X),我们需要检查边 (w, v) 的成本是否小于 key[v] 的当前值。如果是,我们就需要更新顶点 v 在堆中的键值为这个更小的值。这可以通过堆的删除后重新插入操作,或专门的“键值减小”操作来完成,时间复杂度为 O(log n)。
每个顶点最多被加入 X 一次,每条边最多触发一次对相邻顶点的键值检查/更新。因此,维护不变式的总开销是 O(m log n)。
总结
本节课中我们一起学*了如何高效实现普里姆算法。
我们首先分析了其 O(m * n) 的朴素实现。然后,我们引入了堆数据结构来加速重复的最小值查找操作。我们探讨了一种实用的策略:在堆中存储顶点而非边,并为每个顶点维护一个键值,表示它连接到已覆盖部分的最小边成本。
通过精心维护堆和顶点键值,我们能够在 O(log n) 时间内模拟每次主循环迭代,并在 O(m log n) 的总时间内维护数据结构。这使得普里姆算法的总运行时间从 O(m * n) 提升到了 O(m log n),对于稠密图而言是一个显著的加速。
091:快速实现二
概述
在本节课中,我们将学*如何维护Prim算法在堆(Heap)实现中的两个关键不变量,特别是第二个不变量——每个不在最小生成树集合中的顶点的键值(key)应等于跨越当前割(cut)且关联于该顶点的最小边权值。我们将通过一个具体例子理解键值的变化,并学*如何在每次迭代后高效地更新这些键值,以确保算法正确运行。最后,我们将分析该实现的时间复杂度。
维护不变量
上一节我们介绍了使用堆来高效查找每次迭代中的最佳边。本节中我们来看看如何确保算法运行过程中,两个关键不变量始终保持成立。
我们知道,如果这些不变量成立,那么每次迭代中只需执行一次extract-min操作就能找到最佳边。但如何确保这些不变量在整个算法过程中一直得到维护呢?
为了理解维护不变量(特别是第二个不变量)时可能出现的问题,并确保大家对堆中顶点键值的定义有清晰的认识,让我们来看一个例子。
示例:键值的变化
在这个例子中,我们有一个包含六个顶点的图。我们已经运行了Prim算法的三次迭代,因此六个顶点中有四个已经在集合 X 中。剩下的两个顶点 v 和 w 尚未加入 X,它们属于集合 V - X。
对于图中的五条边,我们用蓝色标出了它们的权值。其他边的权值与此问题无关,无需考虑。
问题如下:
- 根据我们为不在 X 中的顶点定义键值的语义,当前顶点 v 和 w 的键值应该是多少?
- 在我们再运行一次Prim算法迭代后,顶点 w 的新键值应该是多少?
首先,回忆键值的语义:键值应该是所有关联于该顶点且跨越当前割的边中,权值最小的那条边的权值。
对于顶点 v,有四条关联边,权值分别为1、2、4和5。权值为1的边没有跨越割,权值为2、4和5的边跨越了割。其中最小的是2,因此 v 当前的键值是 2。
对于顶点 w,有两条关联边,权值分别为1和10。权值为1的边没有跨越割,权值为10的边跨越了割,因此 w 当前的键值是 10。
接下来,执行一次Prim算法迭代。算法会将具有最小键值的顶点从右侧(V - X)移动到左侧(X)。v 的键值是2,w 的键值是10,因此 v 将被移动。
一旦 v 被移动,集合 X 现在包含了五个顶点,只剩下顶点 w 不在其中。关键点在于,随着集合 X 的改变,割的边界也改变了。因此,跨越新割的边集也发生了变化。
一些边不再跨越割(例如权值为2、4、5的边,它们现在完全位于 X 内部),而一些新的边开始跨越割。具体来说,边 v-w 之前完全在 V - X 内部,现在因为端点 v 被拉到了左侧,它开始跨越割了。
因此,w 的键值现在改变了。它现在有两条关联边跨越割:权值为1的边和权值为10的边。其中最小的是1,所以 w 的新键值从 10 下降为 1。
这个示例的启示是:一方面,设置堆来维护这两个不变量非常棒,因为一个简单的extract-min操作就能实现Prim算法中原本的暴力搜索。另一方面,extract-min操作会破坏我们键值的语义,我们可能需要为顶点重新计算键值。
更新键值的伪代码
幸运的是,在extract-min操作后恢复第二个不变量并不困难,因为extract-min造成的“破坏”是局部的。
具体来说,考虑哪些边现在开始跨越割,而之前没有。唯一改变集合隶属关系的顶点是刚刚被移动的顶点 v。因此,这些边必须是关联于顶点 v 的边。如果边的另一个端点 w 已经在 X 中,那么这条边现在完全进入 X 内部,我们无需再关心它。但是,如果另一个端点 w 不在 X 中,那么随着 v 被拉到左侧,这条边 v-w 现在开始跨越割,而之前没有。
因此,我们的计划很直接:对于每个“危险”的顶点,即每个与 v 关联且另一个端点 w 不在 X 中的顶点,我们只需追踪到另一个端点 w,并重新计算它的键值。
必要的重新计算并不复杂,基本上有两种情况:
- 另一个端点 w 现在多了一条候选边(即 v-w)跨越割。
- w 的新键值要么是这条新边 v-w 的权值,要么是它原有键值(对应另一条跨越割的边)中更小的那个。
以下是更新键值的核心逻辑伪代码:
For each edge (v, w) where w is NOT in X:
// w的旧键值(存储在堆中)是 old_key[w]
// 新候选边的权值是 cost(v, w)
new_candidate = cost(v, w)
if new_candidate < old_key[w]:
// 需要更新w在堆中的键值
Decrease-Key(w, new_candidate) // 将w的键值减小为new_candidate
算法流程与实现细节
至此,我们完成了基于堆的Prim算法实现中,如何维护不变量一和二的概要描述。
- 每次迭代,执行一次
extract-min操作找到下一个要加入的顶点。 - 在
extract-min之后,运行上述伪代码来恢复不变量二。 - 然后,就可以为下一次迭代做好准备了。
对于那些不仅想理解概念,还想深入实现细节的同学,有一个微妙之处需要考虑:如何从堆中删除一个元素?通常,堆的删除操作需要知道元素在堆中的位置。因此,更自然的实现方式是维护一些额外的簿记信息,来记录每个顶点在堆数组中的当前位置。这是一个值得思考的实现细节。
时间复杂度分析
现在,让我们进行最后的运行时间分析。
第一个论断是,该算法的主要工作都通过堆操作完成。因此,只需计算堆操作的数量即可,我们知道每个堆操作可以在对数时间内完成。
以下是堆操作的计数:
- 初始化:在预处理步骤中,我们执行一批
Insert操作来初始化堆。 - 主循环:
while循环恰好有 n-1 次迭代(n为顶点数)。在每次迭代中,我们恰好执行一次Extract-Min操作。 - 键值更新:需要关注的是那些由“减小键值”触发的堆操作(即
Decrease-Key)。关键在于,要以边为中心的方式来计数这些操作。一个重要的结论是:图中的每条边最多只会触发一次Decrease-Key操作(即一次删除-重新插入组合)。
我们可以精确指出触发这次Decrease-Key的时刻:当一条边 (v, w) 的两个端点中,第一个被吸入集合 X 时,就会为另一个仍在堆中的端点触发一次潜在的键值更新检查。当第二个端点也被吸入 X 时,由于它已被移出堆,就不再需要维护其键值了。
因此,堆操作的总数最多是:
- 初始化插入:O(n)
- 提取最小元素:O(n)
- 键值减小操作:每条边最多一次,即 O(m)
由于输入图是连通的,边数 m 至少是 n-1,因此 O(m) 支配了 O(n)。所以,堆操作的总数为 O(m)。

每个堆操作(Insert, Extract-Min, Decrease-Key)的时间复杂度是 O(log n),其中 n 是堆中对象的数量(这里不超过顶点总数 n)。
因此,算法的总运行时间为:
O(m log n)
总结

本节课中我们一起学*了Prim算法基于堆的高效实现中,如何维护关键的不变量。我们通过一个例子理解了顶点键值在算法运行中的动态变化,并学*了在每次迭代后通过局部更新来高效恢复这些键值的方法。最后,我们分析了该实现的时间复杂度为 O(m log n)。这是一个非常出色的运行时间,使得最小生成树问题成为了一个在实际中几乎“零成本”可解的基础图算法问题。
092:17_02_02_Kruskal最小生成树算法 🌳

概述
在本节课中,我们将学*求解最小生成树问题的第二个优秀贪心算法——Kruskal算法。我们将理解其工作原理,证明其正确性,并探讨其高效实现所需的数据结构。
算法背景与动机
上一节我们介绍了Prim算法,本节中我们来看看Kruskal算法。你可能会问,既然已经有了Prim算法,为何还要学*第二个?主要有三个原因。
以下是学*Kruskal算法的三个理由:
- 算法本身很优秀:它是算法领域的经典之作,在理论和实践中都与Prim算法具有竞争力,是解决最小生成树问题的另一个杰出贪心方案。
- 学*新数据结构的机会:为了高效实现Kruskal算法,我们将学*并应用一个本课程尚未讨论的新数据结构——并查集。这类似于我们使用堆来高效实现Prim算法。
- 与聚类算法的联系:Kruskal算法与某些类型的聚类算法有着非常有趣的联系。理解这种联系有助于我们更好地理解聚类上下文中的自然贪心算法。
问题回顾与假设
在深入Kruskal算法之前,让我们简要回顾最小生成树问题及其基本假设。
问题定义:输入是一个无向图 G,每条边都有一个成本。算法的任务是输出一个生成树,即一个无环且连通(任意两个顶点间都有路径)的子图。在所有可能指数级多的生成树中,算法应输出总边成本最小的那一个。
基本假设:
- 输入图是连通的(这是存在生成树的必要条件)。虽然Kruskal算法可以优雅地扩展到非连通图,但本节课不讨论这种情况。
- 为简化证明,我们假设所有边成本互不相同(即没有并列成本)。请注意,Kruskal算法在存在并列成本时同样是正确的,只是本节课的证明不涵盖该情况。
- 我们将再次使用证明Prim算法正确性时最重要的工具之一——割性质。
割性质回顾:如果图中存在一条边,并且你能找到一个割,使得这条边是穿过该割的所有边中成本最低的,那么这条边必定属于最小生成树。它保证了包含这条边是“安全”的。我们将在证明Kruskal算法正确性时再次使用这个性质。
Kruskal算法工作原理 🛠️

与介绍Prim算法时一样,在展示伪代码之前,我们先通过一个例子来理解Kruskal算法的工作原理。你会发现它非常直观。


考虑以下这个具有5个顶点和7条边的图,蓝色数字标注了各边的成本。

Kruskal与Prim的核心思想差异:
- 在Prim算法中,我们从一个起点出发,像霉菌生长一样,每次迭代都保证子图连通并覆盖一个新的顶点。
- 在Kruskal算法中,我们放弃了每一步都保持子图连通的要求。它很乐意并行地生长多个小树片段,只在算法最后才将它们合并起来。
Kruskal算法的基本步骤:我们简单地按成本从低到高的顺序查看所有边。每次选择当前成本最低且尚未查看的边。当然,有一个限制:我们不能引入环。因此,我们会跳过那些会导致环的边。除此之外,我们只需按顺序选择下一条最便宜的边。
让我们在上述5顶点示例中运行算法:
- 选择成本为1的边:这是全局成本最低的边,我们将其加入生成树。
- 选择成本为2的边:这是下一个成本最低的边,加入生成树。注意,此时这两条边是互不相连的,这体现了Kruskal算法不要求中间步骤连通的特点。
- 选择成本为3的边:这是下一个成本最低的边。加入后,它恰好将之前两个独立的片段连接成了一个连通分量。
- 考虑成本为4的边:这是下一个成本最低的边。但是,加入它会与成本为2和3的边形成一个三角形(环)。这是不允许的,因此我们跳过这条边。
- 选择成本为5的边:这是下一个成本最低且不会形成环的边,我们将其加入。此时,我们已经有了一个包含4条边的生成树(顶点数n=5,边数n-1=4),算法可以停止。
- (补充说明:我们也会考虑成本为6和7的边,但它们都会形成环,因此被跳过。)
经过对边的一次排序扫描,我们得到了图中由粉色边构成的生成树。我们将看到,不仅在这个例子中,而且在任何图中,Kruskal算法输出的都是最小成本生成树。
Kruskal算法伪代码 📝
有了直观理解后,下面的伪代码就不会令人意外了。我们希望只对边进行一次排序扫描。
预处理:首先,我们需要对边按成本进行排序。为了使伪代码简洁,我们假设边已按成本从低到高重新编号为 e1, e2, e3, ..., em。
以下是Kruskal算法的核心伪代码:
// 输入:图G,边已按成本升序排序为 e1, e2, ..., em
// 输出:最小生成树T
T = ∅ // 初始化空树
for i = 1 to m:
if T ∪ {ei} 不包含环:
T = T ∪ {ei} // 将边ei加入树T
return T
算法维护一个进行中的树 T。它简单地按排序顺序遍历所有边。对于每条边,除非它会导致环(这是坏主意),否则就将其加入 T。遍历结束后,返回 T。
可以想象一些优化,例如,一旦 T 中包含了 n-1 条边(构成生成树所需的最小边数),就可以提前终止循环。但本节课我们将分析这个简洁的三行版本。

后续学*路径
与讨论Prim算法时类似,我们接下来的步骤是:
- 证明正确性:首先,我们需要理解为什么Kruskal算法总能输出一个生成树,并且为什么这个生成树具有最小成本。
- 分析运行时间:然后,我们将分析一个朴素实现的运行时间。
- 探讨高效实现:最后,我们将学*如何使用合适的数据结构(特别是并查集)来实现一个高速版本的Kruskal算法。
总结



本节课我们一起学*了Kruskal最小生成树算法。我们了解了它与Prim算法在哲学上的差异:Kruskal算法通过按成本顺序贪心地添加边来并行构建多个连通分量,最终合并成完整的最小生成树。我们通过示例演示了其工作流程,并给出了核心伪代码。在接下来的课程中,我们将深入探讨其正确性证明以及如何高效地实现它。
093:Kruskal算法正确性证明 🧩


在本节课中,我们将学*如何证明Kruskal最小生成树算法的正确性。我们将通过三个步骤来论证:首先证明算法输出的是一个生成树,然后证明该生成树是最小成本的。我们将依赖“割性质”这一核心概念来完成证明。

生成树性质的证明 🌳


上一节我们介绍了证明的整体计划,本节中我们首先来论证Kruskal算法的输出是一个生成树。生成树需要满足两个条件:无环性和连通性。

无环性证明
Kruskal算法的伪代码明确排除了会形成环的边。以下是算法中避免环的核心逻辑:
对边按权重升序排序
初始化空集合 T
for 每条边 e (按排序顺序):
if 将 e 加入 T 不会形成环:
将 e 加入 T
因此,算法的输出 T* 不可能包含任何环。

连通性证明
要证明输出 T* 是连通的,我们需要借助“空割引理”。该引理指出:一个图是连通的,当且仅当对于图的每一个割,都至少有一条边横跨该割。
因此,我们只需证明对于任意割 (A, B),T* 都至少包含一条横跨边。
论证过程如下:
- 假设输入图 G 是连通的,则对于任意割 (A, B),G 中至少有一条横跨边。
- Kruskal算法会按权重顺序扫描 G 中的每一条边。
- 考虑算法第一次遇到横跨割 (A, B) 的边 e 的时刻。
- 在此时刻,集合 T* 中尚未包含任何横跨该割的边(因为 e 是第一次被遇到)。
- 根据“孤独割推论”,如果一条边是横跨某个割的唯一边(即“孤独”的),那么它不可能属于任何环。
- 因此,将边 e 加入当前集合不会形成环,算法必定会将其加入 T*。
- 由于割 (A, B) 是任意的,这意味着 T* 横跨了所有割,因此 T* 是连通的。
综上所述,Kruskal算法的输出 T* 是一个生成树。
最小生成树性质的证明 ⚖️

上一节我们证明了Kruskal算法输出的是生成树,本节中我们来看看如何证明它是最小成本的。我们将论证算法选择的每一条边都符合“割性质”,因此都是某个最小生成树的一部分。
割性质的应用回顾
割性质指出:对于图的任意割,如果一条边是该割上权重最小的横跨边,那么这条边必然属于图的某个最小生成树。


在Prim算法的证明中,这一点是显然的,因为Prim算法就是依据“选择某割的最小横跨边”来运行的。但Kruskal算法的伪代码中并没有显式地提到“割”。因此,我们需要证明:Kruskal算法在添加每条边时,都等价于选择了某个割的最小横跨边。
中间迭代状态分析
让我们“冻结”Kruskal算法的任意一次迭代。假设此时算法已选择的边集为 T,即将加入的边为 e = (u, v)。
由于算法决定加入边 e,我们知道在当前的边集 T 下,顶点 u 和 v 必定位于不同的连通分量中(否则加入 e 会形成环)。
构造关键割

基于 u 和 v 分属不同分量这一事实,我们可以构造一个割 (A, B):
- 将 u 所在的连通分量中的所有顶点放入集合 A。
- 将图中所有其他顶点放入集合 B。
- 显然,u ∈ A,v ∈ B,且当前边集 T 中没有边横跨这个割(因为 A 就是一个完整的连通分量)。
论证 e 是该割的最小横跨边
现在,我们需要证明边 e = (u, v) 是这个割 (A, B) 上权重最小的横跨边。
论证过程如下:
- 边 e 横跨了割 (A, B)。
- 由于当前边集 T 中没有边横跨该割,因此 e 将是算法考虑的所有边中,第一个被遇到的横跨割 (A, B) 的边。
- 我们之前已经论证过:Kruskal算法必定会选取它遇到的第一个横跨任何给定割的边(因为此时加入该边不会形成环)。
- 同时,Kruskal算法是按权重升序检查边的。
- 因此,作为算法遇到的第一个横跨割 (A, B) 的边,e 也必定是原始图 G 中所有横跨该割的边里权重最小的那一条。
这恰好满足了“割性质”的条件:e 是割 (A, B) 的最小横跨边。因此,将 e 加入生成树是正确的选择,它属于某个最小生成树。
由于算法在每次迭代中加入的边都通过这样的割被证明是合理的,所以最终构建出的整个生成树 T* 就是一个最小生成树。
总结 📝
本节课中我们一起学*了Kruskal算法正确性的完整证明。
- 首先,我们证明了算法的输出是一个生成树,这通过论证其无环性(算法显式避免环)和连通性(利用“空割引理”和“孤独割推论”)来完成。
- 接着,我们证明了该生成树是最小成本的。核心在于展示算法加入的每一条边 e,都对应于一个特定的割,并且 e 是该割上权重最小的横跨边,从而符合割性质。这利用了算法按权重排序和遇到横跨割的第一条边必选这两个关键行为。
因此,Kruskal算法总能正确地找到给定连通图的最小生成树。
094:通过并查集实现Kruskal算法一


概述
在本节课中,我们将学*如何高效地实现Kruskal算法。首先,我们会分析一个简单实现的时间复杂度。然后,我们将引入一个名为“并查集”的数据结构,它能显著加速算法中的关键操作——循环检测,从而使Kruskal算法的运行时间达到*乎线性的水平。
Kruskal算法回顾
上一节我们证明了Kruskal算法的正确性。本节中,我们来看看它的实现细节。
Kruskal算法是一种贪心算法,它按成本从低到高的顺序考虑边。以下是其简洁的伪代码:
- 排序预处理:将所有边按成本升序排列。为方便起见,我们重命名边,使得
e1是最便宜的边,em是最贵的边。 - 线性扫描:初始化一个空集合
T,它将最终成为我们的生成树。然后,我们按顺序检查每条边ei。 - 决策规则:对于每条边
ei,如果将其加入集合T不会在T中形成环路,则将其加入T;否则,跳过该边。

算法的核心在于每一步都检查添加边是否会创建环路。
朴素实现的时间复杂度分析
让我们逐步分析上述伪代码的朴素实现需要多少时间。
-
第一步:排序。对
m条边进行排序需要 O(m log n) 时间。这里我们使用log n而非log m,因为在最小生成树问题中,图可以假设为简单图(无平行边),因此边数m最多为O(n^2),所以log m和log n在O表示法中可以互换。 -
第二步:主循环。主循环有
m次迭代。每次迭代的关键工作是检查添加当前边是否会形成环路。如何进行环路检查?假设当前边连接顶点
u和v。检查添加(u, v)是否会形成环路,等价于检查在当前已选边集T中,u和v是否已经连通(即是否存在一条u到v的路径)。如果已连通,添加边会形成环路;否则,不会。我们可以使用广度优先搜索(BFS)或深度优先搜索(DFS)来检查连通性。从顶点
u开始,在由边集T构成的子图中进行搜索,看是否能到达v。由于T中最多有n-1条边,因此一次搜索的时间复杂度为 O(n)。
综上所述,朴素实现的总时间复杂度为:
排序时间 O(m log n) + 循环时间 O(m * n) = O(m * n)。
这个结果与Prim算法朴素实现的时间复杂度相同。虽然它是多项式时间,远优于检查所有指数级数量的生成树,但我们希望做得更好,实现*乎线性的运行时间。
引入并查集数据结构
瓶颈在于每次循环中耗时的 O(n) 环路检查。如果我们能有一个数据结构,可以在常数时间内完成这种检查,那么主循环的总时间将降至 O(m)。这样,排序步骤 O(m log n) 将成为新的瓶颈,从而使Kruskal算法的总运行时间降至 O(m log n)。
并查集正是这样一个“神奇”的数据结构,它能支持我们所需的快速连通性查询和合并操作。
注:本教程将介绍一个基础版本的并查集,它足以实现 O(m log n) 的目标。存在更高级的优化(如“按秩合并”和“路径压缩”),能提供更好的摊销时间复杂度,但基础版本已满足我们当前的需求。
并查集的核心概念



并查集用于维护一个对象集合的动态划分(即分组)。初始时,每个对象自成一个组。它主要支持两种操作:

- Find(查找):给定一个对象,返回它所属组的唯一标识符(组名)。
- Union(合并):给定两个组的标识符,将这两个组合并为一个新组。
与Kruskal算法的关联

现在,我们来看看如何将并查集应用于Kruskal算法。
- 对象:图中的每个顶点。
- 组:对应于由当前已选边集
T定义的连通分量。 - 算法过程:
- 初始时,每个顶点独自形成一个连通分量(即一个组)。
- 当Kruskal算法考虑一条边
(u, v)时,它需要知道u和v当前是否属于同一个连通分量(即同一组)。这可以通过两次Find操作来实现:Find(u)和Find(v)。如果返回值相同,则说明u和v已连通,添加边(u, v)会形成环路,应跳过。 - 如果
Find(u)!=Find(v),则说明u和v属于不同连通分量,添加边(u, v)是安全的。在将边加入T后,这两个连通分量被合并为一个。这通过一次Union操作来实现:Union(Find(u), Find(v))。
通过这种方式,并查集完美地模拟了Kruskal算法中连通分量的动态合并过程,并能高效地回答连通性查询。
总结
本节课中,我们一起学*了Kruskal算法的高效实现路径。我们首先分析了其朴素实现具有 O(m * n) 的时间复杂度。然后,我们引入了并查集数据结构,它能够以极快的速度支持查找和合并操作。通过将图中的顶点作为对象,连通分量作为组,我们可以利用并查集在*乎常数时间内完成Kruskal算法中的环路检查,从而将算法的总运行时间降低到 O(m log n),这与Prim算法(使用堆优化)的效率是 competitive 的。在下一节中,我们将深入探讨并查集的具体实现细节。
095:通过并查集实现Kruskal算法二


在本节课中,我们将学*如何使用并查集数据结构来高效地实现Kruskal算法,特别是如何以常数时间检查环路,并通过巧妙的优化将总运行时间控制在*乎线性的水平。
目标与基本思想
上一节我们介绍了Kruskal算法的框架,本节中我们来看看如何高效地检查环路。我们的目标是能够在Kruskal算法中以常数时间检查加入一条边是否会形成环路。
并查集数据结构实现的第一也是最基本的思想是:为Kruskal算法当前已选边构成的每个连通分量维护一个链式结构。所谓链式结构,是指图中的每个顶点都有一个额外的指针字段。此外,在每个连通分量中,我们会指定一个顶点(具体是哪个无关紧要)作为该分量的领导者顶点。
我们将维护一个关键的不变性:每个顶点通过其额外指针,都指向其所在连通分量的领导者顶点。
例如,假设有两个不同的连通分量,一个包含顶点U、V、W,另一个包含X、Y、Z。U可能是第一个分量的领导者,X是第二个分量的领导者。那么,V和W的指针应指向U,U的指针指向自身;Y和Z的指针应指向X,X的指针指向自身。这样,每个分量实际上继承了其领导者顶点的“名字”。
常数时间环路检查
有了这个不变性,进行常数时间的环路检查就变得非常简单。检查加入边(U, V)是否会形成环路,本质上就是检查U和V是否已经在同一个连通分量中。
给定两个顶点U和V,我们如何知道它们是否在同一个连通分量中?我们只需跟随它们各自的领导者指针,看是否到达同一个顶点。如果它们在同一个分量中,我们会得到相同的领导者;如果在不同分量中,则得到不同的领导者。因此,检查环路只需比较U和V的领导者指针是否相等,这显然是常数时间操作。
更一般地说,在这种并查集数据结构中实现Find操作的方法是:给定一个顶点,只需跟随其领导者指针,并返回最终到达的顶点。
只要这个简单数据结构的不变性得到满足,我们就能实现所需的常数时间环路检查。
维护不变性的挑战
然而,每当数据结构发生变化时(例如进行Union操作合并两个分组),我们都需要担心不变性是否会被破坏,以及如何在不做过多工作的情况下恢复它。
在Kruskal算法的上下文中,情况如下:
- 当一条边会形成环路时,我们跳过它,不改变数据结构。
- 当一条边不会形成环路时,Kruskal算法要求我们将此边加入正在构建的集合T中,这会将两个连通分量融合为一个。

问题在于融合会破坏不变性。原来有两个领导者,现在必须只有一个。我们必须更新一些领导指针以恢复不变性。

为了确保你理解这个重要问题,请思考:在最坏情况下,为了恢复不变性,可能需要更新多少个领导指针?
答案是:可能需要与顶点数n成线性关系的指针更新次数。一个简单的理解方式是想象Kruskal算法添加的最后一条边,它将最后两个连通分量合并为一个。这两个分量可能各有n/2个顶点。从两个领导者变为一个,其中一组n/2个顶点必须将其领导指针更新为另一组的领导者。
这令人担忧,因为我们希望算法接*线性时间。如果每次边添加(共O(m)次)都可能触发线性次数的指针更新,那将导致二次时间复杂度。
优化一:保留较大分量的领导者
幸运的是,这只是第一个想法。第二个优化非常自然:在合并两个分量A和B时,我们不计算全新的领导者,而是重用其中一个分量的领导者(例如A的领导者)。这样,只有来自另一个分量(B)的顶点需要重写其领导指针。
那么,应该保留哪个领导呢?显然,应该保留较大分量的领导者。这样需要重写的指针更少。例如,如果一个分量有1000个顶点,另一个有100个,保留大分量的领导者只需更新100个指针;反之则需要更新1000个。

为了实现这个优化,我们需要快速判断哪个分量更大。我们可以扩充数据结构,为每个分量维护一个size字段(记录分量中的顶点数)。这样就能在常数时间内比较两个分量的大小,并在常数时间内决定保留哪个领导者。合并后,新分量的size就是两个旧分量size之和。
然而,即使有这个优化,在最坏情况下(例如最后合并两个大小均为n/2的分量),单次合并仍然可能需要更新Θ(n)个领导指针。因此,这个优化虽然在实际中很聪明,但在我们的渐*运行时间分析中似乎没有带来改善。

顶点视角的分析与对数界
但是,如果我们从顶点中心的视角来看待所有领导指针更新的总工作量呢?
假设你是图中的一个顶点。在Kruskal算法开始时,你处于自己的孤立分量中,指向自己。随着算法运行,你的领导指针会周期性地被更新。在优化策略下(总是保留较大分量的领导者),你的指针只会在你所在的分量是较小分量时被更新。

关键结论来了:在整个Kruskal算法过程中,每个顶点的领导指针最多被更新O(log n)次。
原因如下:假设你所在的某个分量有k个顶点。当你的领导指针被更新时,意味着你的分量(大小为k)与一个至少同样大的分量合并了(因为你的分量是较小的那个)。因此,合并后你所属的新分量大小至少是2k。也就是说,每次你的领导指针被更新,你所属的分量大小至少翻倍。你从一个大小为1的分量开始,而一个连通分量的大小不会超过n。因此,你所能经历的“翻倍”次数最多是log₂ n次。这就限制了你作为图中一个顶点,其领导指针被更新的次数。
运行时间分析


基于这个非常棒的性质,我们现在可以对使用并查集数据结构的Kruskal算法进行良好的运行时间分析。
算法的工作主要分为三部分:
- 预处理排序:将边按权重从小到大排序。这需要O(m log n)时间(注意,通常我们说O(m log m),而m最多为O(n²),所以O(m log n)是等价的)。
- 循环检查:主循环迭代O(m)次,每次检查一条边。利用并查集,我们可以在常数时间内通过比较端点领导指针是否相等来完成环路检查。这部分总时间为O(m)。
- 维护并查集:每次向集合T中添加新边(即合并两个分量)时,需要更新领导指针以恢复不变性。我们不再分析单次合并的最坏情况,而是进行全局分析。根据顶点视角的分析,所有顶点领导指针更新的总次数为 n * O(log n) = O(n log n)。
综合来看,运行时间由三部分相加:O(m log n) + O(m) + O(n log n)。由于m通常至少为n-1(连通图),因此瓶颈在于预处理排序步骤的O(m log n)。整个主循环的工作量O(m + n log n)被排序步骤所主导。
因此,使用并查集实现的Kruskal算法的总运行时间为 O(m log n)。这与使用堆实现的Prim算法的理论性能相匹配。
总结
本节课中我们一起学*了如何用并查集高效实现Kruskal算法:
- 我们通过为每个连通分量维护领导指针,实现了常数时间的环路检查(
Find操作)。 - 我们遇到了合并分量时需要更新大量指针以维护不变性的挑战。
- 我们引入了保留较大分量领导者的优化,这虽然不能改善单次合并的最坏情况,但改变了分析视角。
- 通过从单个顶点的视角分析,我们得出了每个顶点的领导指针最多更新O(log n)次的关键结论。
- 基于此,我们进行了全局运行时间分析,得出总时间为O(m log n),其瓶颈在于边的排序预处理。

与基于堆的Prim算法一样,Kruskal算法也因此获得了*乎线性的运行时间(仅比读入输入多一个对数因子),在实践中极具竞争力。
096:最小生成树研究现状与开放问题 🧩

在本节进阶选学内容中,我们将探讨计算最小生成树问题的研究前沿。我们将回顾已有的优秀算法,了解理论上的最优解,并揭示该领域至今仍存在的开放性问题。

算法现状回顾
上一节我们介绍了Prim算法和Kruskal算法。这两种算法在配合合适的数据结构时,都能在接*线性的时间内运行,时间复杂度为 O(m log n),其中 m 是边数,n 是顶点数。
一方面,我们应该对这些算法感到满意,因为它们仅比读取输入所需的时间慢一个对数因子。但另一方面,优秀的算法设计者永不满足,他们总会问:我们能否做得更好?也许我们可以超越 m log n。
理论上的突破
令人惊讶的是,至少在理论上,我们可以做得比这些经典实现更好。以下是几个在渐进时间复杂度上优于 m log n 的MST算法参考文献。
- 随机化线性时间算法:如果你能接受随机化算法(就像我们在快速排序算法中那样),那么最小生成树问题可以在线性时间内解决。这显然是最优的算法,因为计算最小生成树必须查看整个图。该算法由Karger和Tarjan等人提出,其时间复杂度为 O(m),仅比读取输入所需时间多一个常数因子。
- 确定性*线性时间算法:如果我们不满足于仅基于算法随机性的期望运行时间,而想要一个确定性算法,情况则不同。目前我们尚不清楚是否存在确定性的线性时间MST算法,这是一个开放性问题。但我们知道存在一个运行时间极其接*线性的确定性算法,其时间复杂度为 O(m α(n))。

关于逆阿克曼函数 α(n)
这里的 α(n) 是逆阿克曼函数。定义阿克曼函数及其逆函数需要一些工作,此处不展开。你只需知道这是一个增长极其缓慢的函数。
为了让你体会其缓慢程度,这里有一个比逆阿克曼函数增长快得多的函数 log* n(迭代对数)作为对比:
以下是 log* n 的定义方法:
- 在计算器中输入一个数字 n。
- 反复按下 log 按钮(计算以2为底的对数)。
- 记录需要按多少次 log 按钮,结果才会降至 1 以下。这个次数就是 log* n。
log* n 是“幂塔函数”的逆函数。你可以尝试在计算器或电脑中输入你能想到的最大数字(比如一连串的9),然后计算它的 log* n,结果很可能在 5 左右。由此可见,log* n 的增长已经慢得惊人,而逆阿克曼函数 α(n) 的增长比 log* n 还要慢得多。
因此,算法社区几乎已经确定了MST问题的时间复杂度,但在确定性情况下尚未完全解决。正确答案介于 O(m) 和 O(m α(n)) 之间,我们尚不清楚具体是哪一个。
未解之谜与开放问题
事情甚至变得更加奇特。Pettie和Ramachandran的一项研究在某种意义上“解决”了确定性MST问题。他们提出了一个算法,并证明了其时间复杂度是最优的(即没有其他算法能在渐进意义上比它更好)。然而,他们并未明确计算出该算法的具体时间复杂度。所以,我们已知该问题存在一个最优的时间复杂度,也已知一个能达到此复杂度的算法,但至今我们仍不知道这个最优的时间复杂度,作为图大小的函数,具体是什么。
以上是我们对最小生成树问题已知的一些最前沿进展。接下来,让我提几个至今我们仍不知道的事情。
随机化算法的开放问题
你可能会认为随机化算法领域没有开放问题了,因为我们知道解决问题需要线性时间,并且我已经告诉过你存在期望运行时间为线性的随机化算法。但我们还想要一个不仅线性时间,而且足够简单的算法,简单到可以教给本科生(比如在本课程中),或者至少能在研究生课程中讲授。目前的线性时间算法不具备这个特性,它们甚至复杂到无法在研究生课程中完整覆盖。
要实现这个目标,解决一个看似更简单的任务就足够了:为 MST验证问题 设计一个简单的随机化线性时间算法。

- MST问题:你需要从指数级数量的生成树中优化,找到总边权最小的那一个。
- MST验证问题:我给你一个候选的生成树(它可能最优,也可能不是),你只需要检查它是否是最优的。此外,如果它不是最优的,你应该告诉我哪些边不在最小生成树中(即哪些边太贵,应该被丢弃)。
之所以解决这个看似更简单的问题就足够了,是因为Karger和Tarjan论文的核心内容是一个归约:一个从生成树优化问题到MST验证问题的随机化归约。该论文中新颖的、线性的、随机化的内容其实非常简单,我曾在研究生课程中讲授。但它需要一个MST验证子程序作为黑盒,而目前已知的线性时间MST验证实现都相当复杂。因此,找到一个能在线性时间内运行的、简单的MST验证方法,你就能得到一个简单的最优MST算法。
确定性算法的开放问题
确定性算法的终极目标很明显:我们渴望有一个运行在线性时间的确定性MST算法。或者,至少我们需要弄清楚确定性MST算法可能的最佳时间复杂度到底是什么。
总结与延伸阅读
本节课中,我们一起学*了最小生成树问题的研究前沿。我们了解到,尽管Prim和Kruskal算法已经非常优秀,但理论上存在更快的随机化线性时间算法和确定性*线性时间算法。同时,我们也认识到,即使经过过去50年左右计算机科学家在算法设计与分析上的惊人进步,我们仍然对一些完全基础的事情缺乏理解,例如确定性线性时间算法的存在性、最优时间复杂度的精确表达式,以及简单随机化线性时间算法的构造。这意味着未来仍有伟大的思想等待被发现。
如果你对本视频中讨论的内容感兴趣,并想了解更多关于这些高级最小生成树算法的知识,我推荐Jason Eisner撰写的一篇综述文章《State of the Art Minimum Spanning Tree Algorithms》。这篇文章虽然已有约15年历史,但仍然是学*这些高级材料的绝佳资源。
097:聚类应用 🎯
在本节课中,我们将学*最小生成树问题的一个重要应用领域:聚类问题。我们将从一个非正式的目标描述开始,逐步形式化一个具体的优化目标,并最终推导出一个与最小生成树算法紧密相关的贪心算法来解决它。
聚类问题概述

上一节我们深入探讨了最小生成树问题及其算法。本节中,我们来看看它在聚类问题上的应用。

在聚类问题中,输入是一组点,我们可以将其视为嵌入在某个空间中的对象。实际上,这些点很少是真正几何意义上的点,它们通常代表我们关心的对象,例如网页、图像或数据库记录,只是被表示为空间中的点。
给定一组对象,我们希望将它们聚类成一些在某种意义上“连贯”的组。对于有机器学*背景的听众,这个问题通常被称为无监督学*,因为数据没有标签,我们是在未标注的数据中寻找模式。
定义相似性度量
以上描述比较模糊。为了更精确,我们假设输入的一部分是一个相似性度量。对于任意两个对象,我们有一个函数给出一个数字,表示它们之间的相似程度,或者更准确地说,是不相似程度。

为了保持几何比喻,我们将这个函数称为距离函数。一个很酷的地方是,我们不需要对这个距离函数施加太多假设。我们唯一要假设的是它具有对称性,即从点 P 到点 Q 的距离与从点 Q 到点 P 的距离相同。
距离函数示例:
- 几何距离:如果点确实在 R^M 空间中,可以使用欧几里得距离或其他范数(如 L1 或 L∞ 范数)。
- 应用特定距离:在许多应用领域存在广泛接受的相似性或距离度量。例如,对于基因序列,距离可以是两个基因组片段最佳比对所需的惩罚值。
形式化聚类目标
现在我们有了距离函数,那么“连贯的组”意味着什么?距离小(相似)的对象通常应该在同一组中,而距离大(不相似)的对象则应该主要在不同组中。
如何评估一个聚类的好坏?实际上,形式化这个问题的方法有很多。我们将采用一种基于优化的方法:我们提出一个关于聚类的目标函数,然后寻找优化该目标函数的聚类。
需要提醒的是,这不是唯一的方法,但优化是一种自然的方法。就像在调度应用中一样,人们研究的目标函数不止一种。一个非常流行的目标是 K-means 目标函数。在本讲座中,我们将采用一个特定的目标函数,它足够自然,并且能让我们研究与最小生成树算法相关的自然贪心算法。

定义目标函数:间距
在聚类问题中,一个常见问题是:要使用多少个簇?为了简化,在本视频中,我们假设输入的一部分 K 指明了应该使用的簇的数量。因此,我们假设你知道需要多少个簇。
我们将要研究的目标函数是根据被分隔的点对(即被分配到不同簇的点对)来定义的。只要有多于一个簇,就必然存在一些被分隔的点对。最令人担忧的被分隔点对是那些最相似、距离最小的点对。我们希望被分隔的点尽可能远离。因此,我们特别关注那些彼此靠*却被分隔的点对。
这就是我们的目标函数值,称为聚类的间距。它定义为所有被分隔点对中,距离最*的那一对点之间的距离。
我们希望所有被分隔的点对都尽可能远离,因此我们希望间距越大越好。
这自然引出了形式化的问题陈述:给定输入(距离度量,即每对点之间的距离)和期望的簇数量 K,在所有将点划分为 K 个簇的方法中,找到使间距最大化的聚类。
设计贪心算法
让我们设计一个旨在使间距尽可能大的贪心算法。为了便于讨论,我们使用一个包含六个黑点的示例点集。
这个贪心算法的好主意是:先不担心最终只能输出 K 个簇的约束。在算法过程中,我们实际上会处于不可行状态(即簇的数量多于 K 个),只有在算法结束时,我们才会减少到 K 个簇,得到最终可行的解。这让我们可以自由地将过程初始化为一个退化解:每个点都在自己的簇中。
在我们的示例中,初始时有六个粉色的孤立簇。一般来说,初始有 n 个簇,而我们需要减少到 K 个。
现在回想一下间距目标:遍历所有被分隔的点对(在退化解中就是所有点对),找出最令人担忧的被分隔点对,即彼此最接*的点对。间距就是这些最*被分隔点对之间的距离。
在贪心算法中,你希望尽可能增加目标函数。在这种情况下,做法非常明确。假设给你一个聚类,你想让间距变大。唯一的方法是:找到当前距离最*的一对被分隔点,并让它们不再被分隔,即把它们放入同一个簇中。从某种意义上说,为了增加目标函数,你必须查看定义当前目标的那对点(最*的一对被分隔点),并融合它们所在的簇。
在我们的例子中:
- 初始间距由右上角最*的一对点定义。为了增大间距,我们融合它们所在的簇。簇数从 6 变为 5。
- 重新评估新聚类的间距。现在最*的一对被分隔点似乎是右下角的那一对。我们融合它们所在的簇。簇数从 5 变为 4。
- 再次评估。当前间距由图片最右侧的一对点定义。我们融合包含这两个点的两个簇(此时每个簇包含两个点),合并成一个包含四个点的簇。簇数从 4 变为 3。
假设我们正好需要 K=3 个簇,那么此时贪心算法将停止。
算法伪代码与关联
现在,让我们更一般地阐述这个贪心算法的伪代码。它正是基于以上讨论所期望的样子。
伪代码:最大间距聚类贪心算法
输入:点集,距离函数 d(., .),期望簇数 K
初始化:每个点自成一个簇
当 当前簇的数量 > K 时:
找到距离最*的两个点 p 和 q,且它们位于不同的簇中
合并包含点 p 和点 q 的两个簇
输出:最终得到的 K 个簇
我希望你花点时间看看这段伪代码,并尝试将其与我们课程中学过的一个算法联系起来,特别是最*学过的一个算法。我希望它能让你强烈地想起一个我们已经学过的算法。
具体来说,我希望你看到这个贪心算法与 Kruskal 算法(用于计算最小成本生成树)之间有很强的相似性。

实际上,我们可以认为这个贪心聚类算法完全等同于 Kruskal 的最小生成树算法,只不过它被提前终止了——当图中剩余的连通分量数量恰好为 K 时停止,即在添加最后 K-1 条边之前停止。
为了确保对应关系清晰:
- 聚类问题中的对象(点)对应图中的顶点。
- 聚类问题输入中的距离(每对点之间)对应最小生成树问题中的边成本。
- 由于我们为每一对点都定义了距离(边成本),我们可以认为聚类问题中的边集是完全图。

这种使用类似 MST 的标准一次融合一个分量的凝聚式聚类有一个名字,叫做单连接聚类。
单连接聚类是一个好方法。如果你处理聚类问题或无监督学*问题,它绝对应该是你工具箱中的一个工具。在下一个视频中,我们将通过证明它确实在所有可能的 K 聚类中最大化间距,来从特定角度证明其合理性。
但即使你不关心间距目标函数本身,你也应该熟悉单连接聚类,因为它还有许多其他优良特性。
本节总结
本节课中,我们一起学*了如何将最小生成树算法应用于聚类问题。我们从一个非正式的聚类目标出发,定义了间距这一具体的优化目标,并设计了一个旨在最大化间距的贪心算法。关键发现是,这个算法本质上就是 Kruskal 最小生成树算法的变体,被称为单连接聚类。这再次展示了核心算法思想在不同问题领域中的强大通用性。
098:聚类算法正确性证明 🧮

在本节课中,我们将学*并证明用于聚类的贪心算法的正确性。我们将证明,该算法在所有可能的K聚类中,能够最大化“间距”这一指标。
你或许曾希望,我们能直接从各种贪心最小生成树算法的正确性证明中,推导出这个聚类贪心算法的正确性。然而,情况似乎并非如此。在最小生成树问题中,我们关注的是最小化某些边的成本;而在这里,我们关注的是一个不同的目标——最大化间距。因此,我们确实需要从头开始进行证明。尽管如此,我们将使用的论证方法对你来说应该并不陌生,它既类似于我们证明“割性质”时使用的交换论证,也可能让你回想起更早之前,我们在调度问题中使用的贪心算法证明。
证明框架设定
现在,让我们为证明建立符号体系。和往常一样,我们将审视算法的输出,它达到了某个目标函数值,即某个间距。我们将考虑一个任意的竞争者,即另一个提出的聚类方案,并证明我们的方案至少和它一样好,即我们的间距至少和它一样大。
具体来说,我们用 C₁, C₂, ..., Cₖ 表示算法输出的聚类。我们的聚类有一个间距,即所有被分隔的点对之间的最小距离,我们称之为 S。
我们用 Ĉ₁, Ĉ₂, ..., Ĉₖ 表示我们的竞争者,即另一个K聚类方案。
我们想要证明什么?我们想要证明,这个任意的其他聚类的间距不会大于我们的间距。如果我们能证明这一点,那么由于这个聚类是任意的,就意味着贪心聚类的间距和任何其他聚类一样大,因此它最大化间距,这正是我们想要证明的。

换一种说法,我们想要找出一对点,它们在聚类 Ĉ₁, ..., Ĉₖ 中被分隔开,并且这对点之间的距离小于或等于 S。

处理平凡情况
首先,让我快速处理一个平凡情况。如果 Ĉ 与 C 相同(可能只是重命名了聚类),那么显然,在两个聚类中被分隔的点对是完全相同的,因此间距也完全相同。这不是我们需要担心的情况。
有趣的情况是当 Ĉ 与 C 有根本性不同时,即它们不仅仅是贪心聚类的一个排列。我们在这里将要进行的操作,在精神上类似于我们在调度问题正确性证明中所做的。在那个证明中,我们论证了任何与贪心调度不同的调度,在某种意义上都存在一个局部缺陷。我们识别出了一对相邻的任务,它们在某种意义上相对于贪心排序是“乱序”的。

这里的类比是,我们将论证:对于任何不仅仅是贪心聚类排列的聚类,必然存在一对点,它们在 Ĉ 中的分类方式与在 C 中不同。所谓“不同”,我指的是它们在贪心聚类中被分在同一个簇(例如点 P 和 Q 属于同一个簇 Cᵢ),然而在这个替代聚类(不仅仅是贪心聚类的排列)中,它们被分到了不同的簇(例如 P 在 Ĉᵢ,而 Q 在另一个 Ĉⱼ)。

证明的核心思路
现在,我想将证明分为一个简单情况和一个复杂情况。为了解释为什么简单情况是简单的,让我们先观察这个贪心聚类算法的一个性质。
该算法的理念是“会哭的孩子有奶吃”。也就是说,距离最*的一对被分隔的点,应该优先被合并。因此,由于算法总是合并距离最*的一对被分隔的点,如果你观察算法在每次迭代中合并的点对序列(这些点对决定了后续迭代的间距),这些“最差分隔点对”之间的距离只会随着时间推移而增加。在算法开始时,整个点集中距离最*的点对被直接合并;之后,一些距离更远的点对成为被分隔的点并决定间距,然后它们也被合并;依此类推。所以,如果你观察贪心算法直接合并的点对之间的距离序列,这个序列只会递增,并且最终以贪心算法的最终间距 S 结束。在某种意义上,贪心算法输出的间距,就是如果我们再运行一次贪心迭代将会合并的那对点之间的距离(尽管我们不被允许这样做)。
关键在于:对于贪心算法直接合并的每一对点,它们之间的距离最多为 S。
简单情况
简单情况如下:假设我们找到了一对点 P 和 Q,一方面,它们在贪心聚类中属于同一个簇;另一方面,它们在 Ĉ 聚类中属于不同的簇。如果这对点 P 和 Q 不仅最终在同一个贪心簇中,而且实际上曾被贪心算法直接合并过(即在某个迭代中,它们决定了间距并被贪心算法选中以合并其所在的簇),那么根据我们刚才的论证,P 和 Q 之间的距离不会超过贪心聚类的间距 S。由于 P 和 Q 在 Ĉ 聚类中位于不同的簇,它们被 Ĉ 分隔开了,因此它们的距离(≤ S)为 Ĉ 聚类的间距提供了一个上界(可能 Ĉ 有距离更*的被分隔点对,但至少 P 和 Q 是被分隔的,所以它们的距离是间距的一个上界)。
记住,我们想要证明的是这个替代聚类的间距不可能比我们的贪心间距更好,它必须至多和 S 一样大。所以,在 P 和 Q 被贪心算法直接合并的这个简单情况下,证明就完成了。
复杂情况
那么复杂情况就是当 P 和 Q 只是间接合并的。你可能在想,如果两个点从未被直接合并过,它们怎么会最终在同一个簇里呢?让我们画个图来看看这是如何发生的。
问题是,两个点 P 和 Q 可能最终在同一个贪心簇中,并不是因为贪心算法曾明确考虑过这对点,而是因为其他点对直接合并所形成的一条路径或级联效应。
例如,假设在贪心算法的某个迭代中,点 P 与点 A₁(A₁ 不同于 Q)被明确考虑并直接合并,因此 P 和 A₁ 最终在同一个簇中。同样地,点 Q 可能与某个点 Aₗ(不同于 P)直接合并。在其他时候,一些完全不相关的点对,比如 A₂ 和 A₃,也可能被直接合并。然后,在某个时刻,A₁ 和 A₂ 因为成为距离最*的一对被分隔点而被贪心算法考虑并合并,依此类推。
图中的边旨在表示直接合并,即那些在贪心迭代的某个时刻因为决定了间距而被明确融合的点对。但最终,贪心聚类将包含所有这些合并的结果。
如果你感到困惑,让我指出,我们在讨论Kruskal最小生成树算法时,确实看到了完全相同的情况。在Kruskal算法的中间状态,在它添加了一些边但尚未构成生成树时,中间状态是一系列不同的连通分量。那些之间有被选中边的顶点当然会在同一个连通分量中,但一个连通分量内部可能包含很长的路径。因此,在Kruskal算法的中间状态,你可能会有顶点在同一个连通分量中,尽管我们并未直接选择它们之间的边,而是通过一条被选中的边路径连接了它们。这里发生的情况完全相同。
现在,我们掌握的情况是:如果一对点被直接合并,我们知道它们距离很*,它们之间的距离最多为间距 S。坦白说,对于那些没有被直接合并的点对之间的距离,我们一无所知,它们只是“偶然地”最终进入了同一个簇。
但这被证明是足够的。这实际上足以论证这个竞争者聚类 Ĉ 的间距不会超过 S,不会比我们的更好。让我们看看为什么。
将复杂情况归约为简单情况
给定 P 和 Q 在同一个贪心簇中,必然存在一条由直接合并构成的路径,迫使它们进入同一个簇。让我们将这条路径中涉及的中间点记为 A₁, A₂, ..., Aₗ。

现在,证明的这一部分,我们基本上是将复杂情况归约为简单情况。我们有了这对点 P 和 Q。记住,不仅它们在同一个贪心簇中,而且它们在竞争者 Ĉ 聚类中位于不同的簇。假设点 P 在某个簇 Ĉᵢ 中,而 Q 在别处,具体来说,不在 Ĉᵢ 中。
现在,想象你进行一次徒步旅行。你从点 P 出发,沿着这条路径(由直接合并构成)向 Q 前进。你从 Ĉᵢ 内部开始,最终到达外部。因此,在你徒步的某个点上,你必定会穿越边界。你将会第一次逃离 Ĉᵢ 并进入某个其他簇。这一定会发生。
让我们称 Aⱼ 和 Aⱼ₊₁ 为你从该簇内部走到外部时所经过的一对连续点。


现在,我们又回到了简单情况。我们现在处理的是一对被贪心算法直接合并的、且被 Ĉ 分隔开的点对。记住,我们设定这条路径是由直接合并构成的。特别地,Aⱼ 和 Aⱼ₊₁ 是直接合并的,因此,它们之间的距离最多为 S。同样,由于是直接合并,其距离至多是贪心聚类的间距 S,而同时作为被 Ĉ 分隔的点对,它也为 Ĉ 聚类的间距提供了一个上界。
完成证明
这意味着我们贪心聚类的间距 S 至少和竞争者的一样好。由于竞争者是任意的,因此我们的贪心算法是最优的。这就完成了证明。
总结
在本节课中,我们一起学*了如何证明用于最大化间距的聚类贪心算法的正确性。我们首先建立了证明框架,定义了算法输出和竞争者。然后,我们处理了平凡情况,并深入探讨了核心论证。我们将证明分为简单情况(点对被直接合并)和复杂情况(点对被间接合并)。通过构造一条由直接合并构成的路径,并识别路径上第一次跨越竞争者聚类边界的一对点,我们将复杂情况巧妙地归约为简单情况,从而证明了对于任何竞争者聚类,其间距都不会超过我们贪心算法的间距 S。这最终确立了贪心算法在最大化间距这一目标上的最优性。
099:惰性合并-进阶选学 🧩

在本节课中,我们将要学*一种高级的、可选的并查集数据结构实现方法,即“惰性合并”技术。我们将深入探讨其设计思想与性能分析,并理解其与之前实现方法的区别。
回顾:并查集数据结构
上一节我们介绍了并查集数据结构的基本概念。现在,让我们快速回顾一下。
并查集数据结构用于维护一个对象集合的划分。它支持两个核心操作:
- 查找:给定一个对象,返回其所在组的名称。
- 合并:给定两个对象,将它们所在的组合并为一个组。
在克鲁斯卡尔最小生成树算法中,我们使用查找操作来检查添加边是否会形成环,使用合并操作来融合两个连通分量。
我们之前讨论的实现为每个组维护一个链表结构。每个对象有一个指针指向其组的“领导者”。查找操作是常数时间的,但合并操作需要更新较小组中所有对象的指针,使其指向新组的领导者。通过“按大小合并”的优化,一系列 n 次合并操作的总时间复杂度可以控制在 O(n log n)。
引入惰性合并方法
本节中,我们来看看一种不同的实现方法:惰性合并。其核心思想是,在每次合并操作中,我们尝试只更新一个指针。
让我们通过一个简单的例子来理解这个方法。假设有6个对象,当前分为两组:
- 组1(领导者为1):对象 1, 2, 3
- 组2(领导者为4):对象 4, 5, 6
在之前的实现中,合并这两个组时(假设新领导者为4),我们需要更新对象1、2、3的指针,使它们都直接指向4。
惰性合并的新思路很简单:我们只更新其中一个领导者的指针。例如,我们只将对象1的父指针从指向自己改为指向4。这样,对象2和3仍然指向对象1,而对象1现在指向4。结果我们得到了一个更深(两层)的树形结构,而不是一个所有节点都直接指向根节点的浅层树。
在数组表示法中,这体现为:
- 旧方法:合并后,数组
parent中parent[1],parent[2],parent[3]的值都变为4。 - 新方法:合并后,只有
parent[1]的值从1变为4,而parent[2]和parent[3]的值仍为1。
惰性合并的通用操作与权衡
在通用情况下,每个组可以看作一棵有向树,根节点即领导者。合并两个组时,我们找到两个对象所在树的根 r1 和 r2,然后将其中一个根节点的父指针指向另一个根节点。这相当于将一棵树作为子树连接到另一棵树的根节点下。
以下是惰性合并方法的优缺点:
优点:
- 合并操作的核心部分(链接两个根节点)非常简洁,只需更新一个指针。
缺点:
- 合并操作本身并不完全是常数时间,因为它需要先执行两次查找操作来找到根节点。
- 更关键的是,由于节点不再直接指向根节点,查找操作的成本增加了。现在,要找到一个对象所属的组,我们必须沿着父指针链向上遍历,直到找到根节点。

因此,惰性合并是否真的能带来性能优势并不明显。这需要深入且精妙的分析,也是后续课程的重点。此外,为了获得良好的性能,我们还需要引入一些优化。下一个视频将介绍第一个关键优化:按秩合并。你可能会想,当需要合并两棵树时,我们如何决定将哪棵树的根作为另一棵树的孩子?这正是“按秩合并”要解决的问题。
本节课中,我们一起学*了并查集的惰性合并方法。我们理解了其基本思想——通过减少合并操作中的指针更新次数来换取更简单的合并步骤,但也认识到这可能导致查找操作变慢。在接下来的课程中,我们将通过分析和优化(如按秩合并)来探究这种方法的实际效能。
100:按秩合并优化

概述
在本节中,我们将深入探讨并查集数据结构的一种“惰性合并”实现方法。我们将重点关注第一个关键优化——按秩合并,它通过智能选择合并方向来避免生成低效的链状结构,从而提升操作性能。
回顾:惰性合并的基本思想
上一节我们介绍了并查集的基本概念。在惰性合并实现中,每个对象维护一个父指针,而非直接指向组领袖。这些指针共同构成一系列有向树,每棵树代表当前分区中的一个组。
树的根节点(指向自身的对象)即为该组的领袖。与“积极合并”要求树深度仅为1不同,惰性合并允许树生长得更深。
初始化时,每个对象自成一组,父指针指向自己,均为根节点。
查找(Find) 操作通过从给定对象X开始,沿父指针向上遍历,直至找到根节点(父指针指向自身的对象),该根节点即为组领袖。
合并(Union) 操作给定两个对象X和Y:
- 分别对
X和Y执行Find,得到其根节点S1和S2。 - 将
S1或S2之一设置为另一个的子节点(即进行一次指针更新)。
目前,我们尚未规定在合并时如何选择父子关系。
问题:随意合并的性能缺陷
如果我们在合并时随意选择将哪个根节点作为另一个的子节点,可能导致最坏情况下的性能问题。
考虑以下场景:我们持续将单元素组合并到已构建的组中,且每次都将新加入的单元素设为新根。经过一系列合并后,我们可能得到一个长度为线性级别的链。
在这种情况下,对链底部的对象执行Find操作需要线性时间。由于Union操作内部调用了两次Find,因此其运行时间也将是线性的。
这提示我们需要一种更智能的合并策略。
优化:引入秩(Rank)的概念
为了避免生成链状结构,我们引入秩作为每个对象的第二个字段(第一个是父指针)。秩是一个整数值。
当前,我们可以将秩理解为:从对象X所在树的任意叶子节点到X本身所需经过的最大跳数(即路径上的指针遍历次数)。特别地,对于根节点,其秩等于整棵树的深度。
初始化时,每个对象的秩为0。
示例:考虑一个包含两个组的并查集状态。叶节点的秩为0(空路径)。某些内部节点的秩为1。较大树的根节点秩为2。通常,一个节点的秩等于其所有子节点中最大秩加1。
策略:按秩合并(Union by Rank)
按秩合并的核心思想是:在合并两棵树时,将秩较小的树的根节点,作为秩较大的树的根节点的子节点。这样做的目的是避免加深原本已经较深的树。
如果两棵树的根节点秩相等,则任意选择一方作为新根,另一方作为其子节点。
以下是按秩合并的伪代码框架:
function Union(x, y):
s1 = Find(x) // 找到x的根
s2 = Find(y) // 找到y的根
if s1.rank > s2.rank:
s2.parent = s1 // s2成为s1的子节点
else if s1.rank < s2.rank:
s1.parent = s2 // s1成为s2的子节点
else: // 秩相等
s1.parent = s2 // 任意选择,这里让s1成为s2的子节点
s2.rank = s2.rank + 1 // 新根的秩需要加1
秩的更新规则

在执行合并操作后,我们需要考虑秩的维护。首先,只有直接参与指针重连的两个根节点S1和S2的秩可能发生变化,其他所有对象的秩保持不变。
以下是秩的具体更新规则:
- 如果两树根节点秩不同:将秩较小的根节点挂到秩较大的根节点下。合并后,两者的秩均保持不变。因为新树的深度(即新根的秩)等于原来较深那棵树的深度。
- 如果两树根节点秩相同:任意选择一方作为新根。合并后,新根的秩需要增加1,而成为子节点的那个根节点的秩不变。这是因为合并后出现了一条更长的从叶子到新根的路径。
示例说明:
- 情况一(秩不同):假设
S1秩为2,S2秩为1。将S2作为S1的子节点。原来S1树中存在需要2跳的路径,S2树中最长路径为1跳。合并后,从原S2树中的叶子到新根S1的路径最多为1(到S2)+ 1(到S1)= 2跳,未超过原S1树的深度,故S1秩不变。 - 情况二(秩相同):假设
S1和S2秩均为R。将S1作为S2的子节点。原来S1树中存在需要R跳的路径。合并后,该路径需要额外一跳才能到达新根S2,即需要R+1跳。因此,新根S2的秩必须增加为R+1。
总结
本节课我们一起学*了并查集惰性合并实现中的第一个关键优化——按秩合并。
我们首先分析了随意合并可能导致链化,从而使Find和Union操作退化为线性时间。接着,我们引入了秩的概念来量化树的深度。最后,我们制定了按秩合并的策略:总是将秩较小的树合并到秩较大的树下,仅在秩相等时增加新根的秩。这一优化有效地控制了树的生长,避免了最坏情况的发生,为后续实现高效操作奠定了基础。
101:按秩合并分析-进阶选学 📚

在本节课中,我们将深入分析并查集数据结构中“按秩合并”优化策略的有效性。我们将证明,通过这种优化,find和union操作的最坏情况运行时间可以被限制在对数级别。这是理解并查集高效性的关键一步。
数据结构回顾 🔍
上一节我们介绍了“惰性合并”方法。本节中,我们来看看其具体实现和关键属性。
在惰性合并的实现中,每个节点维护一个父指针。这些父指针共同构成了一组有向树。每棵树的根节点(即父指针指向自身的节点)被定义为其所在集合的代表元(领导者)。
find操作:要查找对象X的领导者,只需沿着父指针链向上遍历,直到到达根节点。因此,find操作的最坏情况运行时间取决于从任意对象到其根节点所需遍历的最长父指针路径长度。union操作:给定两个对象X和Y,需要合并它们所在的树。首先对两者调用find找到各自的根,然后将一棵树的根作为另一棵树根的子节点。
如果不加优化地随意合并,可能导致树变得非常不平衡(例如形成长链),从而使操作退化为线性时间。
按秩合并优化 ⚙️
为了防止树变得过于“细长”,我们引入了“按秩合并”优化。其核心思想是:在合并两棵树时,总是将较浅的树的根节点,作为较深的树的根节点的子节点。
以下是具体规则:
- 每个节点
X维护一个秩(rank)。在当前(未引入路径压缩时),我们保持一个不变式:节点X的秩等于从其某个叶子节点到X所需遍历的指针的最大数量。因此,所有节点中的最大秩,就是从任意叶子到任意根的最长路径长度,这也就是find操作运行时间的上界。 - 执行
union(X, Y)时:- 找到
X和Y的根节点root_x和root_y。 - 比较
root_x.rank和root_y.rank。 - 如果秩不同,将秩较小的根作为秩较大的根的子节点。秩不更新。
- 如果秩相同,则任意选择一方(例如
root_y)作为新根,将另一方(root_x)作为其子节点。此时,新根(root_y)的秩需要增加1(root_y.rank = root_y.rank + 1),以反映树深度的增加。
- 找到
分析目标与简单属性 🎯
我们的目标是证明:在使用按秩合并优化后,任何节点的最大秩始终以 O(log n) 为界,其中n是数据结构中的对象总数。由于find的最坏情况时间由最大秩决定,而union操作包含两次find和常数时间指针重连,因此两者都将具有对数级的时间复杂度。
首先,我们从不变式和操作规则中推导出几个简单但至关重要的属性。
以下是三个基本属性:
- 秩只增不减:对象的秩只会随着
union操作(当它成为新根时)增加,永远不会减少。find操作不改变任何秩。 - 非根节点秩冻结:只有根节点的秩可能增加。一旦一个对象成为非根节点(即拥有一个非自身的父节点),它的秩将在此后永久冻结,不再改变。
- 沿路径秩严格递增:根据秩的定义(父节点秩 = 1 + 子节点最大秩),在从叶子到根的路径上,节点的秩是严格递增的。即对于任意父子关系,有
parent.rank > child.rank。
核心引理:秩引理及其证明 📖
上述属性引出了一个更强大、也是本次分析核心的结论——秩引理。它不仅对证明当前的对数界至关重要,在后续引入路径压缩并证明更优界限时也会反复使用。
秩引理:考虑对数据结构执行任意一系列union操作(find操作不影响结构,可忽略)。对于任意非负整数r,在任何时刻,秩恰好为r的对象的数量最多为 n / 2^r。
推论:取 r = ⌊log₂n⌋,则秩为 ⌊log₂n⌋ 的对象最多有1个,且不存在秩更大的对象。因此,最大秩 ≤ ⌊log₂n⌋,从而证明了操作的对数时间复杂度。
为了证明秩引理,我们将其分解为两个子论断。
论断一:同秩子树不相交
论断:如果两个对象X和Y具有相同的秩r,那么它们的子树(即所有能通过父指针到达该对象的节点集合)是不相交的。
证明(反证法):
假设存在对象Z,同时位于X和Y的子树中(即从Z出发可以到达X和Y)。由于父指针形成树结构,从Z到其根节点的路径是唯一的。因此X和Y必须都在这条路径上,其中一个必然是另一个的祖先。根据属性3(路径上秩严格递增),祖先的秩严格大于后代的秩。这与X和Y秩相同的前提矛盾。因此,假设不成立,子树必然不相交。
论断二:子树大小下界
论断:任何秩为r的对象,其子树大小(即子树中包含的对象数量)至少为 2^r。
证明(归纳法):
我们对已执行的union操作数量进行归纳。
- 基础情况:0次
union时,每个对象都是独立的根,秩为0,子树大小为1 = 2^0。论断成立。 - 归纳步骤:假设在
k次union后论断成立,考虑第k+1次union。- 简单情况:如果本次
union没有改变任何节点的秩。那么所有节点的秩不变,而子树大小只会增加(因为合并了树)。根据归纳假设,原先满足“子树大小 ≥ 2^秩”,现在依然满足。 - 关键情况:本次
union导致某个节点的秩增加。这只会发生在合并两个秩相同的根节点时。设两个根节点为S1和S2,秩均为r。合并后,假设S2成为新根,其秩增加为r+1。
我们需要验证S2的新子树大小是否满足至少2^(r+1)。S2的新子树由原来的子树和S1的整个子树合并而成。- 根据归纳假设,在合并前,
S1.subtree_size ≥ 2^r,S2.subtree_size ≥ 2^r。 - 因此,合并后
S2的新子树大小 ≥2^r + 2^r = 2^(r+1)。
论断在归纳步骤中依然成立。
- 简单情况:如果本次
根据归纳法,论断二得证。

完成秩引理证明
固定一个秩值r。根据论断二,每个秩为r的节点都“拥有”至少2^r个不同的对象(在其子树中)。根据论断一,这些子树彼此不相交。由于对象总数为n,因此最多能有 n / 2^r 个这样的不相交集合,即最多有 n / 2^r 个秩为r的节点。秩引理得证。
总结 🏁
本节课中,我们一起学*了并查集“按秩合并”优化策略的严格性能分析。
- 我们首先回顾了惰性合并与按秩合并的规则,并指出了分析目标:证明最大秩为 O(log n)。
- 接着,我们推导了数据结构的三个基本属性:秩单调递增、非根节点秩冻结、路径上秩严格递增。
- 然后,我们引入并证明了核心的秩引理:秩为
r的节点数不超过n / 2^r。通过将其分解为“同秩子树不相交”和“子树大小下界”两个子论断,并分别用反证法和归纳法完成了证明。 - 秩引理直接推出最大秩 ≤ ⌊log₂n⌋。由于
find操作耗时与路径长度(即所涉节点的秩)成正比,而union操作主要耗时在于两次find,因此我们得出结论:在使用按秩合并优化后,find和union操作的最坏情况时间复杂度均为 O(log n)。
这为并查集数据结构的效率提供了第一个坚实的理论保证,是理解其强大功能的重要基石。在后续课程中,我们将看到如何通过“路径压缩”优化,进一步改善其均摊时间复杂度。
102:路径压缩进阶选学

概述
在本节课中,我们将学*并查集数据结构的一个关键优化技术——路径压缩。我们将了解其工作原理、如何与按秩合并结合使用,并探讨它如何将操作的平均时间复杂度降低到一个极小的值——迭代对数函数 log* n。
路径压缩的动机
上一节我们介绍了按秩合并,它保证了树的高度为 O(log n)。本节中我们来看看如何通过路径压缩进一步优化查找操作的性能。
我们的目标是避免重复的、冗余的工作。在按秩合并的并查集中,最坏情况的查找操作需要从叶子节点遍历到根节点,可能需要进行 O(log n) 次指针跳转。如果反复对同一个叶子节点进行查找,我们就会反复遍历相同的路径。
路径压缩的核心思想是:既然在一次查找操作中我们已经遍历了从节点到根节点的整条路径,为什么不顺便“压缩”这条路径,让路径上的所有节点都直接指向根节点呢?这样,后续的查找操作就会快得多。
路径压缩的工作原理
路径压缩在 find 操作中实施。当从某个节点 x 开始查找其根节点 r 时,我们会遍历从 x 到 r 路径上的所有节点。
以下是路径压缩的步骤:
- 正常执行查找操作,找到根节点
r。 - 在回溯过程中,将路径上遇到的每个节点(除了根节点
r)的父指针直接重定向到根节点r。
用伪代码描述如下:
def find(x):
if parent[x] != x:
parent[x] = find(parent[x]) # 递归查找并压缩路径
return parent[x]
或者用迭代方式:
def find(x):
root = x
# 第一步:找到根节点
while parent[root] != root:
root = parent[root]
# 第二步:路径压缩,将路径上所有节点指向根
while parent[x] != root:
next_node = parent[x]
parent[x] = root
x = next_node
return root
从树的角度看,路径压缩使得树变得更浅、更“茂盛”。从数组表示法的角度看,我们直接更新了相关索引的父指针值,使其指向根节点。
路径压缩与按秩合并的配合
一个关键点是,路径压缩优化不改变任何节点的秩(rank)。
我们回顾一下秩的维护规则:
- 初始化时,每个节点的秩为 0。
- 秩仅在
union操作中可能改变:当合并两个秩相同的树时,新根的秩会加一。 - 在
find操作中进行路径压缩时,我们只重写父指针,不修改任何节点的秩。
这意味着,引入路径压缩后,秩不再精确等于节点的子树高度,但它仍然是一个有效的上界。秩所代表的是一种“潜力”或“历史高度”,它保证了树的结构不会变得太差。
以下是一个重要的推论:所有仅关于秩的引理(例如上一节证明的“秩为 r 的节点最多有 n/2^r 个”),在引入路径压缩后依然成立。因为无论是否进行路径压缩,我们对秩的修改方式是完全相同的。
路径压缩的性能分析
路径压缩的益处显而易见:它加速了后续的 find 操作。但其理论上的性能提升究竟有多大呢?
1973年,Hopcroft 和 Ullman 证明了以下定理:
考虑一个包含 n 个对象的并查集,使用按秩合并和路径压缩优化。对于任意长度为 m 的
union和find操作序列,处理整个序列的总时间复杂度为 O(m · log n)*。
这里的 log n* (读作“log star n”)是迭代对数函数。它的定义是:对数字 n 反复应用以 2 为底的对数运算,直到结果小于或等于 1 所需的次数。
这个函数增长得极其缓慢:
- log* 2 = 1
- log* 4 = 2
- log* 16 = 3
- log* 65536 = 4
- log* (2^65536) ≈ 5
事实上,对于所有在现实宇宙中可能出现的 n 值(例如,远小于宇宙原子总数 10^80),log* n 的值都不会超过 5。这意味着,在并查集的实际应用中,经过路径压缩优化后,每个操作的平均时间成本几乎可以看作一个非常小的常数。
这远远优于我们之前仅使用按秩合并得到的 O(log n) 单次操作时间复杂度。
总结
本节课中我们一起学*了并查集的终极优化技巧——路径压缩。
- 动机:避免在反复查找同一节点时重复遍历相同路径。
- 操作:在
find操作中,将路径上所有节点的父指针直接指向根节点。 - 配合按秩合并:路径压缩不修改节点的秩,所有关于秩的引理依然有效,这为分析其卓越性能奠定了基础。
- 性能:Hopcroft-Ullman 定理证明,结合按秩合并与路径压缩的并查集,处理 m 次操作的总时间复杂度为 O(m · log* n)。由于 log* n 增长极慢,这使得每个操作的平均时间成本在实际应用中*乎常数。

至此,我们获得了一个*乎完美的并查集实现,它简洁、高效,并能应对海量数据操作,是许多算法(如 Kruskal 最小生成树算法)的核心组件。在接下来的课程中,我们将基于这些思想进行更深入的理论分析。
103:路径压缩-Hopcroft-Ullman分析一-进阶选学

📋 概述
在本节课程中,我们将学*并证明带路径压缩的并查集数据结构的第一个性能保证。这个由Hopcroft和Ullman提出的定理指出,对于任意包含M次操作的序列,其总工作量最多为M乘以一个增长极慢的函数log* n。我们将深入探讨证明背后的直觉和核心思想。
🎯 性能保证与定理陈述
考虑一个使用惰性合并、按秩合并以及路径压缩优化的并查集数据结构。对于一个包含M次合并与查找操作的任意序列,其保证是:在整个操作序列过程中,你所做的总工作量最多为操作次数M乘以增长极慢的函数log* n。
需要记住,log* n定义为:在得到一个小于1的结果之前,你需要对n应用对数函数的次数。例如,2的65536次方是一个极其巨大的数字,但其log*值仅为5。
这个定理无论M是多少都成立,无论你执行的操作是很少还是非常多。我们将重点关注M在渐进意义上至少与n一样大的情况,即M是Ω(n)。你可以自行思考,我们即将看到的论证为何能推出无论M为何值定理都成立。
在转向证明之前,关于这个保证还有一个重要的说明:定理并非声称每一次查找和合并操作都在O(log* n)时间内运行。因为一个更强的、声称每个操作都是O(log* n)的陈述通常是错误的。总会有一些操作花费超过log* n的时间。
一方面,我们知道即使没有路径压缩,也没有操作会比对数时间更慢。有了路径压缩,我们也不会做得比log n更差。因此,最坏情况时间界限是log n,但有些操作确实可能运行得那么慢。然而,在一个包含m次操作的序列中,我们每次操作平均完成的工作量仅为log* n。这正是我们在第一次使用积极合并的并查集实现中所做的那种所谓的“摊还分析”。我们同意特定的合并操作可能需要线性时间,但在一系列合并操作中,我们只花费了对数时间。这里的情况类似,区别在于我们在一系列操作中得到了一个显著更好的平均运行时间界限log* n。
💡 证明思路与直觉
在深入证明细节之前,我们先花一点时间讨论证明计划,特别是我们试图在后续证明中精确化和数学化编码的性能背后的直觉。
如果我们希望证明一个优于没有路径压缩时受困的log n界限,那么安装所有这些快捷方式必须从本质上加速查找和合并操作。在某种程度上,很明显事情必须被加速,因为你用单个指针替换了一条旧的指针链,所以你只能更快。

但如何跟踪这种进展?如何将这种直觉编译成一个严格的保证?
以下是核心思路:让我们聚焦于一个对象X,它此刻不再是根节点,其父节点是自身以外的某个节点。
从我们按秩合并的分析中,需要记住的一点是:一旦一个对象不再是根节点,它的秩就永远冻结了。我们在没有路径压缩的背景下证明了这一点。但请再次记住,在有路径压缩时,我们以完全相同的方式操作秩。所以这仍然成立。如果你是一个对象且不再是根节点,你的秩将永远不会再改变。
我们现在当然希望的是,起源于这个对象X的查找操作运行得很快,不仅如此,随着时间的推移,由于我们进行了越来越多的路径压缩,它们应该变得越来越快。
核心思路如下:我们推理查找操作最坏情况运行时间(或者说,为了到达根节点可能需要遍历的最长父指针序列)的方法是,我们将考虑从对象X向上遍历这些父指针直到根节点时观察到的秩的序列。
让我举个例子。最坏情况会是:假设我们有一个数据结构,最大秩大约是100。我们可能看到的最长秩序列、最坏情况序列会是:我们在一个秩为0的对象处开始一个查找操作,遍历一个父指针到达其父节点,其秩为1;再遍历其父指针,秩为2;然后是3、4,依此类推直到100。请记住,每当我们遍历一个父指针时,秩必须严格递增,正如我们讨论过的,无论有没有路径压缩这都是成立的。因此,在最坏情况下,要从0到100,你必须遍历100个指针。
这很糟糕,不是吗?如果我们每次遍历一个父指针时,秩的增加不是1,而是一个大得多的数字,那该多好。例如,如果我们从0到10,到20,到30,到40,依此类推,那将保证我们只需10步就能到达最大秩节点100。所以,重申一下,关键在于:如果我们能在对象与其父节点的秩之间有一个更好、更大的下界,那就意味着在我们可能看到的节点可能秩中进展更迅速,并转化为更快的查找、更少的父指针遍历。
📊 进展度量与路径压缩的益处
基于“秩之间的巨大差距意味着快速进展”这一思路,我想为给定的非根对象X提出一个进展度量:X的秩(请再次记住,它永远冻结了)与其当前父节点的秩之间的差距。
这个进展度量是一个好的选择,原因有二:首先,正如我们刚刚讨论的,如果你能控制这个差距,如果你能为其设定一个下界,那么这就能给你一个搜索时间的上界。其次,这个差距允许我们量化安装这些快捷方式(即路径压缩)的好处。具体来说,每当你安装一个新的快捷方式,重新连接一个对象的父指针指向树中更高的位置时,它的新父节点的秩将严格大于其旧父节点。这意味着这个差距只会变得更大。总结来说,路径压缩改善了这个进展度量。
也就是说,如果一个对象X之前有一个父节点P,然后其父指针被重新连接到另一个节点P‘,那么P’的秩大于P的秩。
为了确保这一点绝对清晰,让我们画几个示意图例子。
首先,抽象地思考:考虑一个对象X,假设它有某个父节点P,并假设树的根是某个P‘,即树中更上游的某个祖先。请记住,当你沿着父指针向上遍历树时,秩总是递增的。所以这意味着P的秩严格大于X,P’的秩严格大于P。因此,当你将X的父指针从P重新指向P‘时,它获得了一个新的父节点,并且这个新父节点是其旧父节点的祖先,因此它的秩必须严格更大。由于这个原因,X的秩(永远固定)与其新父节点秩之间的差距,大于其秩与其旧父节点秩之间的差距。
你也可以在我们上一视频中使用的七个对象运行示例中看到这种效果的实际作用。
我已经展示了那个示例树在路径压缩前后的情况(用粉色表示)。在路径压缩之前,我按照按秩合并的方式定义了秩,使得每个节点的秩等于从叶子到该节点的最长路径长度。当然,当我们应用路径压缩时,我们不改变秩。我们观察到什么?恰好有两个对象的父指针被重新连接:即对象1和4的父指针被重新指向直接指向7。结果,对象1与其父节点之间的秩差距从仅仅1(其秩与节点4的秩之差)跃升到了3(其秩与其新父节点7的秩之差)。类似地,对象4的秩差距从1跳到了2。它的秩只比其旧父节点6小1,但比其新父节点7小2。
🧱 证明的核心构件
信不信由你,我们实际上已经拥有了Hopcroft-Ullman分析的两个关键构建模块。
构建模块一:是我们几个视频前讨论过的秩引理。无论有没有路径压缩,具有给定秩r的对象数量不能太多,最多有n / 2^r个。
构建模块二:就是我们刚刚讨论的,每次在路径压缩下更新一个父指针时,该对象的秩与其新父节点秩之间的差距必须增长。
证明的其余部分只是对这两个构建模块的最佳利用。现在让我向你展示细节。
📝 总结
本节课中,我们一起学*了Hopcroft-Ullman对带路径压缩的并查集性能分析的第一部分。我们明确了定理的陈述:对于任意M次操作的序列,总工作量为O(M log* n)。我们探讨了证明的核心直觉,即通过跟踪对象与其父节点秩之间的“差距”作为进展度量,并理解了路径压缩如何通过增大这个差距来加速后续操作。最后,我们指出了完成证明所需的两大核心构件:秩的数量限制引理,以及路径压缩会增大秩差距的性质。下一节我们将利用这些构件完成正式的证明。
104:路径压缩-Hopcroft-Ullman分析二-进阶选学 🔍

在本节课中,我们将深入探讨Hopcroft-Ullman分析的第二部分,重点是利用“秩块”和“好/坏节点”的定义,来全局地分析路径压缩下并查集操作的平摊时间复杂度。我们将证明,在路径压缩优化下,每个find操作的平均时间复杂度为O(log* n)。
秩块的定义 📊
上一节我们介绍了秩引理和路径压缩提升父节点秩的核心概念。本节中,我们来看看如何利用“秩块”来量化这种提升。
首先,我们需要建立一些符号和定义。第一个是秩块的定义。
对于一个给定的n(数据结构中对象的数量),我们如下定义秩块:
- 0和1各自构成一个块。
- 下一个块是2, 3, 4。
- 再下一个块从5开始,最后一项是2⁴,即16。
- 接着的块从17开始,直到2¹⁶,即65536。
- 以此类推,直到我们拥有足够多的秩块来覆盖n。
实际上,由于秩最多为log n,我们只需要覆盖到log n,但这不会影响常数级别以上的结果,所以我们可以直接覆盖到n。
那么有多少个秩块呢?关注每个秩块的最大成员。一个给定秩块的最大成员是2的(前一个秩块的最大成员)次方。因此,对于第t个秩块,其最大成员大致是 2^(2^(...^2))(t次)。你需要进行多少次这样的操作才能达到n?根据定义,你需要 log n* 次。这就是秩块的数量,也是 log n* 进入分析的地方。
这个定义可能看起来非常晦涩。让我解释一下应该如何理解它们。
秩块旨在编码我们在上一张幻灯片中提到的直觉:当一个对象的父节点的秩远大于该对象本身的秩时,我们应该感到“满意”。为什么?因为当我们遍历该对象的父指针时,我们在秩空间中取得了巨大进展,而在秩空间中取得巨大进展的次数是有限的,这意味着父指针遍历次数少,即find操作快。
这些秩块是定义“足够进展”的一种非常巧妙的方式。具体来说,当一个对象的秩与其父节点的秩位于不同的秩块时,我们就感到满意;如果它们位于同一个秩块中,我们就不满意。
例如,如果你在一个秩为8的对象处,然后去到其秩为18的父节点,那么我们满意,因为18位于包含8的秩块之后的下一个秩块中。另一方面,如果你从秩8去到秩15,那么我们不满意,我们称之为对象秩与其父节点秩之间的差距不够大。
好节点与坏节点 🏷️
基于这个想法,我们来做另一个定义。
考虑一个给定的时间快照(即我们已经进行了一些find和union操作序列)。在此时刻,我们将一些对象称为“好”的,另一些称为“坏”的。以下是判断好坏的标准:
- 首先,如果你是树的根节点,你是好的。
- 如果你是根节点的直接后代(即你的父节点是你所在树的根节点),那么你也是好的。
- 如果不是(即你在树中更深的位置),那么当且仅当你的父节点的秩位于一个严格大于你自身秩的秩块中时,你才是好的。
这个定义的作用是将我们所做的工作分为两部分:
- 访问好节点所做的工作。
- 访问坏节点所做的工作。
访问好节点所做的工作将非常容易界定。访问坏节点所做的工作总量,则需要一个单独的全局分析来界定。这种二分法与我们在分析使用带急切合并的并查集数据结构实现Kruskal算法时遇到的情况完全相同。在那里,Kruskal算法中的部分工作可以逐次迭代轻松界定(例如,每次环检查只花费常数时间),但还有一种更复杂的工作(即所有领导指针的更新),需要通过单独的论证进行全局界定。这里将发生完全相同的事情:好节点可以按操作逐个界定,而坏节点则需要一个全局分析来控制所有操作的总工作量。
更精确地说,我这样设置定义,使得每次操作中访问好节点所做的工作量都以 O(log n)* 为界。
确实,在一次find操作中(例如从某个对象X开始),你最多可能访问多少个好节点?你沿着父指针一直向上遍历到根节点。首先,有根节点本身。其次,有根节点的直接后代。这是2个。我们把它们放在一边。你在路径上遇到的其他好节点呢?根据定义,当你访问一个好节点时,其父节点的秩位于一个比该节点自身秩更大的秩块中。也就是说,每次你从一个好节点遍历其父指针时,你都会前进到下一个秩块。而总共只有 log n* 个秩块。因此,你最多只能前进 log n* 次。所以,你将看到的好节点总数是 O(log n)*。
总工作量分解 ⚖️
现在,让我们来表达在所有find和union操作中完成的总工作量,即这两部分之和:访问好节点的工作量(我们现在知道每次操作仅为 log n*),加上访问坏节点的工作量(目前我们还不知道有多大)。
总工作量 = (访问好节点的工作) + (访问坏节点的工作)
坏节点工作量的全局界定 🎯
现在,让我们着手进行对坏节点总工作量的全局界定,这实际上是整个定理的核心。
让我们快速回顾一下定义:成为一个坏节点意味着什么?
- 你不是根节点。
- 你不是根节点的直接后代(即你有一个祖父节点)。
- 你的父节点的秩并不在更晚的秩块中,它恰好与你自身的秩位于同一个秩块中。这就是你“坏”的含义。
我们如何界定在坏节点上花费的工作量?让我们一次分析一个秩块。
固定一个任意的秩块,假设对于某个整数K,其最小秩是K+1,最大秩是2^K。
现在,我要用到我们的两个主要构建模块。第一个是秩引理,我稍后会请你记住它。但首先,我想使用我们的另一个构建模块:路径压缩会增加对象与其父节点之间的秩差。这就是我们现在要用的。
具体来说,考虑一次find操作以及它访问的一个坏对象X。由于X是坏的,它不是根节点,也不是根节点的直接后代。因此,根节点是其父节点更上层的祖先。所以,X的父指针将在随后的路径压缩中被改变,它将被重新连接到指向根节点(其先前父节点的一个严格祖先)。因此,其新父节点的秩将严格大于其先前父节点的秩。
只要X在处于坏的状态时被访问,这种情况就会持续发生。它不断获得新的父节点,而这些新父节点的秩总是严格大于前一个父节点。那么,在X的父节点的秩增长到足以位于后续秩块之前,这种情况能发生多少次?
X的秩块中的最大值是2K。请记住,X是一个非根节点,其秩是永久冻结的,所以它始终卡在这个秩块中。一旦其父节点的秩更新到至少2K+1,那么该秩就必须大到足以位于下一个秩块中。在那一刻,X不再是坏的。它的父指针取得了如此大的进展,进入了另一个秩块。现在,我们必须称它为好的。
当然,一旦X以这种方式变成好的,它将永远是好的。它不是根节点,永远不会再成为根节点。它的秩永远冻结,而其父节点的秩只能上升。所以,一旦你是好的,一旦你父节点的秩足够大,它在剩余的时间里都将保持足够大。
好的,我们快完成了。让我们确保没有忘记我们已经做的任何事情。
我们正在以这两种方式界定总工作量。首先,每次操作我们访问 log n* 个好节点。所以,对于M次操作,好节点部分的总工作量是 O(M log n)*。加上在M次操作中访问坏节点的次数,我们将全局界定这部分工作,但我们将按秩块进行。
我们固定了一个秩块K+1到2^K。我们在上一张幻灯片中证明,对于每个最终冻结秩位于此秩块中的对象X,它在处于坏的状态时被访问的次数(在它永远变成好节点之前可以被访问的次数)以 2^K 为上界。
应用秩引理完成分析 ✅
现在我们已经使用了我们的一个关键构建模块(路径压缩增加父节点秩),让我们使用另一个构建模块:秩引理。
秩引理指出,在任何给定时刻,对于任何可能的秩r,当前秩为r的对象数量不可能超过 n / 2^r。
让我们使用秩引理来上界可能有多少个节点的最终冻结秩位于这个秩块中(即最终冻结秩在K+1到2^K之间)。
我们可以对所有在秩块中的秩进行求和。从K+1开始,直到2^K。根据秩引理,对于给定的i值,我们知道最终秩为i的对象最多有 n / 2^i 个。通过通常的几何级数求和,整个和可以上界为 n / 2^K。

现在,这开始看起来像一个神奇的巧合。当然,我们在分析中做了许多定义,特别是我们构建了秩块,以便这种神奇的事情发生。具体来说,一个秩块的居民数量(n / 2K**)乘以一个居民在处于坏的状态时被访问的最大次数(**2K),这两个数相乘实际上与秩块无关。我们将这两者相乘:每个对象的访问次数 2^K,对象的数量 n / 2^K,我们得到什么?我们得到 n。
这仅仅计算了在一个给定秩块中访问坏对象的次数。但并没有那么多秩块,记住,只有 log n* 个。
因此,这意味着访问坏节点所花费的总工作量,在所有秩块上求和,是 O(n log n)*。
最终结论 🏁
结合好节点和坏节点的界限,我们得到总工作量为 O(M log n + n log n)**。
在开始时我提到,有趣的情况是当 M = Ω(n) 时。在这种情况下,这个界限就是 O(M log n)*。本质上,如果你有一个非常稀疏的union操作集合,你可以将这个分析分别应用于每个有向树。
以上就是完整的故事:对卓越的Hopcroft-Ullman分析的完整阐述,证明了在路径压缩下,每次操作的平均时间为 O(log n)*。
尽管这个分析已经很出色,但你还可以做得更好。这是接下来几个视频的主题。
105:Ackermann函数进阶选学 📚

在本节课中,我们将深入探讨Ackermann函数及其逆函数。这些函数在计算机科学中,特别是在分析并查集(Union-Find)数据结构的性能时,扮演着重要角色。我们将从定义开始,逐步理解其惊人的增长特性,并将其与之前学过的log*函数进行对比。
Ackermann函数的定义 📖
Ackermann函数是一个具有两个参数的递归函数,通常记作 A(k, r),其中 k ≥ 0,r ≥ 1。它的定义如下:
- 当 k = 0 时,对于任意 r,有 A(0, r) = r + 1。这被称为后继函数。
- 当 k > 0 时,A(k, r) 表示将函数 A(k-1, ·) 连续应用 r 次到参数 r 上。用数学公式表达为:
A(k, r) = A(k-1, A(k-1, ... A(k-1, r)...)) (共 r 次应用)
虽然定义简洁,但要真正理解这个函数的增长行为,我们需要通过具体的例子来感受。
探索Ackermann函数的增长 🚀
上一节我们介绍了Ackermann函数的递归定义,本节中我们来看看当固定 k 值时,它作为 r 的函数表现如何。
以下是当 k=1 时,函数 A(1, r) 的行为:
- A(1, r) = 2r。因为 A(1, r) 是将后继函数(加1)应用 r 次,从 r 开始,最终得到 r + r = 2r。
接下来,我们看看 k=2 的情况。
以下是当 k=2 时,函数 A(2, r) 的行为:
- A(2, r) = r * 2^r。因为 A(2, r) 是将“加倍函数” A(1, ·) 应用 r 次,这相当于乘以 2^r。
现在,让我们进入 k=3 的领域,这里开始展现出爆炸性的增长。
以下是 A(3, 2) 的计算过程:
- A(3, 2) = A(2, A(2, 2))。
- 首先计算 A(2, 2) = 2 * 2^2 = 8。
- 然后计算 A(2, 8) = 8 * 2^8 = 2048。
- 因此,A(3, 2) = 2048。
对于一般的 r,A(3, r) 是将函数 A(2, ·)(即 r * 2^r)应用 r 次。其结果至少是一个高度为 r 的“2的幂塔”(例如,22...^2)。这种增长已经远超指数级。
当我们来到 k=4 时,增长变得难以直观想象。
以下是 A(4, 2) 的计算思路:
- A(4, 2) = A(3, A(3, 2)) = A(3, 2048)。
- 这意味着我们需要计算 A(3, 2048),而 A(3, r) 本身至少是高度为 r 的幂塔。
- 因此,A(4, 2) 至少是一个高度为2048的幂塔。这个数字之大,已经超出了任何实际计算的范围。
逆Ackermann函数 🔄
理解了Ackermann函数的惊人增长后,我们自然要问它的逆函数是怎样的。逆Ackermann函数,记作 α(n),在算法分析中至关重要。
我们固定参数 r = 2,定义 α(n)(对于 n ≥ 4)为:使得 A(k, 2) ≥ n 成立的最小整数 k。
换句话说,α(n) 回答了“需要多‘高级’的Ackermann函数(从 A(1,·) 开始数),才能将数字2提升到至少 n”这个问题。
让我们通过具体数值来感受 α(n) 的增长是多么缓慢。
以下是 α(n) 函数的部分取值:
- α(4) = 1,因为 A(1, 2)=4 ≥ 4,而 A(0,2)=3 < 4。
- 对于 n = 5, 6, 7, 8,有 α(n) = 2,因为 A(2, 2)=8 是第一个达到或超过这些值的函数。
- 对于 n 从 9 到 2048,有 α(n) = 3,因为 A(3, 2)=2048。
- 对于 n 从 2049 直到一个高度为2048的幂塔,有 α(n) = 4。
事实上,对于任何在现实物理宇宙中可想象、可表示的数字 n,α(n) 的值都不会超过 5。它的增长比我们之前认为已经极慢的 log n* 函数还要慢得多。

与 log* 函数的对比 ⚖️
为了凸显逆Ackermann函数的增长之慢,我们将其与迭代对数函数 log n* 进行对比。log* n 表示需要对 n 连续取多少次以2为底的对数,结果才能小于等于1。
以下是两个函数增长率的直观对比:
| 逆Ackermann函数 α(n) | 迭代对数函数 log* n |
|---|---|
| α(n) = 1 对应 n = 4 | log* n = 1 对应 n = 2 |
| α(n) = 2 对应 n ≤ 8 | log* n = 2 对应 n ≤ 4 |
| α(n) = 3 对应 n ≤ 2048 | log* n = 3 对应 n ≤ 16 (即 222) |
| α(n) = 4 对应 n ≤ 一个高度为2048的幂塔 | log* n = 4 对应 n ≤ 65536 (即 222^2) |
| α(n) = 5 对应难以想象的巨大数字 | log* n = 5 对应 n ≤ 2^65536 |
通过对比可以发现,让 log n* 增长到4(对应n约6.5万)所需的 n,在 α(n) 的尺度下,仅仅处在 α(n)=3 的范围内。而 α(n) 要增长到4,所需的 n 是一个高度为2048的幂塔,这个数字大到需要写满2048行“2的幂塔”表达式才能描述,这远远超过了 log n* 在任何可想象范围内的值。这清晰地表明,α(n) 是一种在渐进意义上增长得无比缓慢的函数。
总结 📝
本节课中我们一起学*了Ackermann函数及其逆函数。
- 我们首先定义了双参数的Ackermann递归函数 A(k, r),并通过计算 k=1,2,3,4 的例子,切身感受到了其超越指数、超越幂塔的爆炸性增长。
- 接着,我们定义了其逆函数——逆Ackermann函数 α(n),它表示将2提升到至少 n 所需的最小Ackermann函数“等级”。
- 最后,通过将其与 log n* 函数对比,我们认识到 α(n) 是一种增长极其缓慢的函数,对于所有有意义的输入,其值实际不超过5。这个函数在并查集数据结构的紧致性能分析(Tarjan定理)中扮演着核心角色,标志着其时间复杂度几乎是线性的。
106:路径压缩-Tarjan分析一-进阶选学


在本节课程中,我们将证明并查集数据结构在按秩合并与路径压缩优化下的时间复杂度上界,即著名的 O(m * α(n)) 界,其中 α(n) 是逆阿克曼函数。这是算法与数据结构发展史上的一颗璀璨明珠。
概述
我们将跟随Dexter Kozen在其著作《算法设计与分析》中的思路,证明对于任意包含 m 次查找与合并操作的序列,总工作量不超过 m * α(n),其中 n 是数据结构中的对象数量。证明的框架将与我们之前学*的Hopcroft-Ullman的 O(m log n)* 分析非常相似。
上一节我们定义了阿克曼函数及其逆函数,为理解这个上界做好了准备。本节中,我们将深入证明的核心。
回顾Hopcroft-Ullman分析
为了超越 O(m log n)* 的分析,我们需要回顾其两个核心构件。
第一个构件是秩引理。它告诉我们,对于任意给定秩 r,最多有 n / 2^r 个对象。这个上界随着秩 r 的增加呈指数级下降。
第二个构件是路径压缩带来的进展。我们曾论证,每次路径压缩更新一个节点的父指针时,该节点都会获得一个秩严格更大的新父节点。
Hopcroft-Ullman分析巧妙地结合了这两个构件。我们定义了“秩块”的概念,并通过区分“好节点”和“坏节点”来限定工作量。
Tarjan分析的核心思想
为了得到更好的上界,我们不能仅仅更优地利用原有的两个构件。Tarjan分析的关键在于强化第二个构件。我们将证明,路径压缩不仅使父节点的秩增加1,而且通常会使父节点的秩大幅增加。
令人惊叹的是,接下来的证明长度与Hopcroft-Ullman分析基本相同,步骤也几乎完美对应,但得出的上界更优,论证也更为精妙。
定义进展度量:δ(x)
在Hopcroft-Ullman分析中,我们使用“秩块”来衡量沿父指针前进时的进展。在这里,我们定义一个新的统计量 δ(x) 来扮演相同的角色,它度量一个对象与其父节点在秩空间中的“差距”。
δ(x) 的定义仅对非根对象 x 有意义。请记住,一旦一个对象成为非根节点,其秩将永远固定不变。
我们定义 δ(x) 为满足以下条件的最大整数 k:
rank(parent(x)) ≥ A_k(rank(x))
其中 A_k 是我们在上一节定义的阿克曼函数的第 k 级函数。
δ(x) 的值越大,表示对象 x 的父节点秩比其自身秩大得越多。因为阿克曼函数增长极其迅速,所以随着 δ 值的增大,父子节点间的秩差距会急剧扩大。
以下是一些简单示例,帮助理解 δ(x):
- δ(x) ≥ 0 总是成立,因为父节点秩至少比子节点大1,而 A_0(r) = r+1。
- δ(x) ≥ 1 成立当且仅当父节点秩至少是子节点秩的两倍,因为 A_1(r) = 2r。
- δ(x) ≥ 2 成立当且仅当父节点秩至少是 r * 2^r,因为 A_2(r) = r * 2^r。
一个重要性质是,对于非根对象 x,其 δ(x) 的值只会随时间增加。因为 x 的秩固定,而其父节点在路径压缩中会不断被替换为秩更大的节点,因此不等式右侧只会增大。
此外,δ(x) 的可能取值数量是有限的。对于秩至少为2的任何对象 x,其 δ(x) 值不会超过 α(n),即逆阿克曼函数在 n 处的值。这是因为秩的上界是 n(实际上更紧的是 log n),而 α(n) 的定义确保了 A_k(2) 在 k 超过 α(n) 时会超过 n。
重新定义好节点与坏节点
有了新的进展度量 δ(x),我们现在需要重新定义“好”节点和“坏”节点。这个定义将起到与Hopcroft-Ullman分析中相同的作用:好节点的访问次数可以轻松地按每次操作限定,而证明的主要部分将用于全局分析坏节点的总访问次数。
一个对象 x 被称为坏节点,当且仅当它同时满足以下四个条件。如果它不满足其中任何一个条件,则被称为好节点。
以下是成为坏节点的四个条件:
- x 不是根节点。
- x 不是根节点的直接子节点。
- (条件1和2的动机与Hopcroft-Ullman分析相同:确保坏节点在被查找访问后,其父指针会被路径压缩更新。根节点及其直接子节点的父指针在压缩后保持不变,因此我们将它们排除在坏节点之外,单独考虑。)
- x 的秩至少为2。
- (这个条件主要是为了技术上的方便,与我定义逆阿克曼函数的方式有关,我们给秩为0或1的节点也“放行”。)
- 存在 x 的一个祖先节点 y(通过父指针可达),使得 δ(x) = δ(y)。
- (这是确保路径压缩能带来巨大进展的关键性条件。它意味着,如果一个节点与其某个祖先有相同的 δ 值,那么它就被标记为“坏”,我们需要仔细分析这类节点的行为。)
好节点的数量上界
这个定义的一个关键作用是,我们可以轻松地限定在任何一次查找操作中访问的好节点数量。
问题: 在任意一次查找操作所遍历的路径上,最多可能有多少个好节点?
答案: O(α(n)),即逆阿克曼函数的量级。
解释: 让我们逐一考虑四个条件:
- 由于条件1,最多有1个节点因为是根节点而被视为好节点。
- 由于条件2,最多有1个节点因为是根的直接子节点而被视为好节点。
- 由于条件3,秩为0和1的节点各最多有1个(因为沿着父指针,秩严格递增),所以最多有2个节点因此被视为好节点。
- 现在考虑条件4。对于每一个可能的 δ 值(共有 O(α(n)) 个可能值),在一条路径上,所有具有相同 δ 值的节点中,只有最靠*根节点的那个可能不满足条件4(因为它上方没有具有相同 δ 值的祖先),从而可能成为好节点。其他具有相同 δ 值的节点,因为上方存在那个最靠*根的同 δ 值节点作为祖先,所以都满足条件4,会被归类为坏节点。
因此,在任何查找路径上,好节点的总数最多为 1 + 1 + 2 + α(n) = O(α(n))。
本节总结
本节课中,我们一起学*了Tarjan对并查集时间复杂度分析的初步框架。
我们首先回顾了Hopcroft-Ullman分析的基础,然后引入了Tarjan分析的核心思想:通过定义新的度量 δ(x) 来更精确地量化路径压缩带来的进展。接着,我们基于 δ(x) 重新定义了好节点与坏节点,并证明了在任何单次查找操作中,访问的好节点数量最多为 O(α(n))。

这为最终证明总时间复杂度 O(m * α(n)) 奠定了重要基础。下一节,我们将面临分析中最具挑战性的部分:全局分析所有查找操作中访问坏节点的总次数。
107:路径压缩 - Tarjan 分析二(进阶选学)🚀

在本节课中,我们将深入分析并查集数据结构中“坏节点”的访问次数上界。我们将看到,通过路径压缩,每次访问坏节点都会显著提升其父节点的秩,从而在全局上限制了对坏节点的总访问次数。最终,我们将得到一个令人惊叹的结论:对于任意包含 n 个对象和 m 次操作的序列,总工作量是 O((n + m) * α(n)),其中 α(n) 是增长极其缓慢的逆阿克曼函数。
上一节我们分析了“好节点”的访问次数,并得到了每个操作最多访问 α(n) 个好节点的结论。本节中,我们来看看如何从全局角度,限制所有操作中对“坏节点”的总访问次数。
核心论证在于证明:在任意包含 m 次 find 和 union 操作的序列中,对坏节点的总访问次数上界为 O(n * α(n))。
以下是论证的关键:当你访问一个坏节点时,随后的路径压缩会极大地增加该对象与其父节点秩之间的差距。
让我们在某个 find 操作访问一个坏对象 X 的时刻,冻结数据结构的状态。根据坏节点的定义(特别是第三条和第四条标准),我们知道:
rank(x) ≥ 2。- 设其
delta值为k。 - 它有一个父节点
P。 - 它有一个祖先节点
Y,满足delta(y) = k。Y也有一个父节点P'。
现在考虑路径压缩的效果。X 的父指针将被重连到这棵树的根节点(根节点至少是 P' 或更高)。让我们比较 X 的新父节点(秩至少为 rank(P'))和旧父节点 P 的秩。
以下是推导出的不等式链:
rank(X的新父节点) ≥ rank(P')rank(P') ≥ A_k(rank(y))(根据delta(y)=k的定义)rank(y) ≥ rank(P)(因为Y是X的祖先,秩沿树向上单调不减)
因此,我们得到:
rank(X的新父节点) ≥ A_k(rank(P))
结论:对坏对象 X 的一次访问和随后的路径压缩,会使其新父节点的秩至少是 A_k 函数作用于其旧父节点秩的结果。
现在,假设我们反复访问同一个坏对象 X。设 X 的秩为 r(r ≥ 2)。每次访问(当它仍是坏节点时)都会将其父节点的秩至少提升为 A_k 作用于前一个父节点的秩。
经过 r 次这样的访问,父节点的秩至少是 A_k 函数作用于 r 自身 r 次的结果。根据阿克曼函数的定义,这等于 A_{k+1}(r)。
这意味着,在 r 次访问后,X 的父节点秩变得如此之大,以至于 delta(x) 这个衡量父子节点秩差距的统计量至少可以增加 1(因为现在父节点秩至少是 A_{k+1}(rank(x)))。
然而,delta(x) 的取值范围是有限的:
- 它是一个非负整数。
- 它只能增加。
- 它的最大值不超过逆阿克曼函数
α(n)。
因此,一个秩为 r 的坏对象 X,在整个操作序列中被访问(且处于坏状态)的总次数上界为:
r * α(n)
我们已经完成了最困难的部分。现在,让我们将所有部分整合起来,得到最终的总工作量上界。
我们需要界定整个操作序列中对所有坏节点的总访问次数。根据上一节的结论,对于一个秩为 r 的坏对象 X,其被访问次数的上界是 r * α(n)。我们需要对所有对象求和。
为了避免朴素求和(n * log n * α(n))带来的松散上界,我们利用秩引理:秩为 r 的节点数最多为 n / 2^r。
以下是计算总访问次数上界的步骤:
- 按秩分组求和,而不是按对象求和。
- 将常数因子
α(n)提到求和式外。
总访问次数 ≤ Σ_{所有对象X} [rank(X) * α(n)]
= α(n) * Σ_{所有秩 r} [ r * (秩为 r 的对象数量) ]
≤ α(n) * Σ_{r=0}^{∞} [ r * (n / 2^r) ] (应用秩引理)
= n * α(n) * Σ_{r=0}^{∞} (r / 2^r)

其中,求和式 Σ_{r=0}^{∞} (r / 2^r) 是一个收敛的常数(例如,等于 2)。因此,我们得到:
对坏节点的总访问次数 = O(n * α(n))
结合我们对好节点(每个操作 O(α(n)))和坏节点(全局 O(n * α(n)))的分析,对于 m 次操作,并查集的总工作量上界为:
O(m * α(n) + n * α(n))
通常我们假设 m = Ω(n)(操作数不少于对象数),否则可以分别分析每个树。因此,最终我们得到著名的Tarjan 界:
O((n + m) * α(n))
这个时间复杂度无限接*于线性,但并非严格的线性,相差一个逆阿克曼函数因子 α(n)。
理论意义与历史评论 🏛️
从实践角度看,对于任何可想象的数据规模 n,α(n) 最多为 4,因此该数据结构在实践中几乎是线性的。然而,从理论角度,我们并未证明并查集有严格的线性时间上界。
一个自然的问题是:我们能做得更好吗?
- 更紧的分析? Tarjan 在其原始论文中证明了否定的答案:对于这个特定的“按秩合并+路径压缩”数据结构,存在一些操作序列,使得总工作量确实达到
Ω(m * α(n))。因此,我们的分析是渐进紧的。 - 更好的数据结构? Tarjan 曾大胆猜想:无论你设计多么聪明的并查集数据结构,都不可能达到严格的线性时间。 这个猜想后来被 Fredman 和 Saks 在 1989 年证明。任何并查集数据结构,在最坏情况下,都必须承受平均每次操作
Ω(α(n))的工作量。
这个结果是算法与数据结构领域的一个分水岭时刻。它向人们揭示,即使是一个如此实用、自然的问题,其最优解的理论分析也必然涉及到递归理论中定义的阿克曼函数及其逆函数。这展现了该领域令人惊讶的深度与美感,并预示着它将吸引一代又一代的科学家持续探索。
本节课中,我们一起学*了 Tarjan 对并查集“按秩合并+路径压缩”算法的最优分析。我们看到了如何通过区分“好节点”和“坏节点”,并利用路径压缩会指数级提升父节点秩的特性,最终将总工作量上界定为 O((n + m) * α(n))。更重要的是,我们了解到这个上界是紧的,并且任何并查集数据结构都无法突破这个由逆阿克曼函数决定的渐进下界。这标志着算法理论分析的一个非凡成就。
108:引言与动机 🎯

在本节课中,我们将学*贪心算法设计范式的最后一个应用:数据压缩。具体来说,我们将介绍一种用于构建特定类型的前缀自由二进制码(称为霍夫曼码)的贪心算法。
我们将通过本视频来铺垫背景知识。
定义二进制码
首先,我们定义什么是二进制码。二进制码是一种将通用字母表中的符号以计算机可以理解的方式记录下来的方法。它本质上是一个函数,将字母表 Σ 中的每个符号映射到一个二进制字符串(即由0和1组成的序列)。
字母表 Σ 可以是任何符号集合。一个简单的例子是小写字母a到z,加上空格和一些标点符号,总共可能有32个符号。如果你需要用二进制编码32个符号,一个显而易见的方法是使用长度为5的32个不同的二进制字符串,每个符号分配一个。这就是一种固定长度编码,因为我们为字母表中的每个符号使用了相同数量的比特(这里是5位)。这与ASCII码的原理非常相似。
当然,本课程的一个核心思想是追问:我们何时能比显而易见的解决方案做得更好?在这个上下文中,问题是:我们何时能比固定长度编码做得更好?答案是:在某些符号的出现概率远高于其他符号的重要情况下,我们可以通过使用可变长度编码来节省比特数,从而更高效地编码信息。
可变长度编码在实践中应用非常广泛,例如MP3音频文件的编码。在MP3编码标准中,初始阶段会进行模数转换,进入数字域后,就会应用我们将在这些视频中学*的霍夫曼码来进一步压缩文件长度。众所周知,压缩(尤其是像霍夫曼码这样的无损压缩)是件好事,它能让文件更小,下载更快。
可变长度编码带来的新问题
从固定长度编码转向可变长度编码时,会引发一个新问题。让我们通过一个简单的例子来说明。
假设我们的字母表 Σ 只有四个字符:A、B、C、D。明显的固定长度编码是:A=00,B=01,C=10,D=11。
现在,假设我们想使用更少的比特,尝试一种可变长度编码。一个直观的想法是,尝试让其中几个字符只用一个比特。例如,我们不用“00”表示A,而只用“0”;不用“11”表示D,而只用“1”。这似乎能节省比特,看起来更好。
但这里有一个问题:假设有人给你一个编码后的传输序列“001”。那么,导致这个编码版本的最初符号序列是什么?
答案是:无法确定。原因是,采用可变长度编码后,现在存在歧义。在这个编码方案下,可能有不止一个原始符号序列会产生输出“001”。具体来说,序列“AB”(A=0,B=01)和序列“AA”(A=0,A=0)都会产生“001”。这与固定长度编码形成对比:在固定长度编码中,给定一个比特序列,你清楚地知道一个字母在哪里结束,下一个在哪里开始(例如,每个符号用5位编码,你只需读取5位就能识别一个符号)。而在没有额外预防措施的可变长度编码中,一个符号从哪里开始、下一个从哪里开始是不明确的。
解决方案:前缀自由码
为了解决可变长度编码中符号边界不明确的问题,我们将要求我们的可变长度编码是前缀自由的。这意味着,当我们编码一组符号时,要确保对于原始字母表 Σ 中的任意两个符号 i 和 j,它们对应的编码满足:任何一个编码都不是另一个编码的前缀。
回顾上一节的例子,那个编码就不是前缀自由的。例如,“0”(A的编码)是“01”(B的编码)的前缀,这导致了歧义。同样,“1”(D的编码)是“10”(C的编码)的前缀,也导致了歧义。如果编码之间互不为前缀(我们稍后会详细阐述这一点),那么就不存在歧义,给定0和1的序列,就有唯一的方法解码并重建原始的符号序列。
你可能会觉得这个属性太强了,但确实存在有趣且实用的可变长度编码满足前缀自由属性。再举一个简单的例子,还是编码字母A、B、C、D。我们可以让符号A只用一个比特“0”来编码。当然,为了是前缀自由的,B、C、D的编码都必须以比特“1”开头,否则就不满足前缀自由。我们可以这样编码:B = 10。现在,C和D的编码必须既不以“0”开头,也不以“10”开头,也就是说,它们必须以“11”开头。我们可以编码C为 110,D为 111。
这就是一个可变长度码,比特数在1到3之间变化,但它是前缀自由的。我们可能想要使用可变长度编码的原因,是为了利用给定字母表中符号出现频率不均匀的特性。让我们在下一节看一个具体的例子,展示这类编码能带来的好处。
可变长度编码的优势示例
让我们继续使用四个符号的字母表:A、B、C、D。假设在我们的应用领域中,我们有关于每个符号出现频率的准确统计数据。具体来说,假设我们知道A是最可能出现的符号,占60%;B占25%;C占10%;D占5%。
你可能会问,我们如何知道这些统计数据?在某些领域,你会有很多专业知识(例如,在基因组学中,你知道A、C、G、T的通常频率)。对于像MP3文件这样的应用,你可以在完成模数转换后,取文件的中间版本,直接统计每个符号的出现次数,从而得到准确的频率。
现在,让我们比较两种编码的性能:
- 明显的固定长度码:每个字符使用2位。
- 上一节提到的、也是前缀自由的可变长度码(A=
0, B=10, C=110, D=111)。
我们将通过计算平均每个字符需要多少比特来衡量这些编码的性能,平均值是基于四个不同符号的频率计算的。
对于固定长度编码,很简单,每个符号正好是2比特。
对于右侧粉红色显示的可变长度编码,给定这些符号频率,编码字母表 Σ 中的一个字符平均需要多少比特?
正确答案是第二个选项:平均每个字符1.55比特。计算过程如下:
- 60%的时间(遇到A)只使用1比特,这是节省大量比特的关键。
- 25%的时间(遇到B)使用2比特,表现也不错。
- 10%的时间(遇到C)和5%的时间(遇到D)我们需要付出代价,每个使用3比特,但它们的出现频率很低。
将这些结果按频率加权平均,我们得到 0.61 + 0.252 + 0.13 + 0.053 = 1.55。
算法机会与问题定义
这个例子揭示了一个非常巧妙的算法机会:给定一个字母表以及(通常不均匀的)符号频率,我们现在知道,显而易见的固定长度编码方案不一定是最优的。我们可以使用可变长度的前缀自由码来改进它。
因此,我们想要解决的计算问题是:哪一个编码是最好的?我们如何获得最优的压缩效果?哪个可变长度码能给出这个字母表中符号的最小平均编码长度?

霍夫曼码就是解决这个问题的方案。我们将在下一个视频中开始构建它们。
本节课中,我们一起学*了二进制码的基础概念,比较了固定长度编码与可变长度编码的差异,并指出了可变长度编码可能带来的歧义问题。为了解决这个问题,我们引入了前缀自由码的概念。通过一个具体示例,我们看到了利用符号频率信息,前缀自由的可变长度编码可以显著降低平均编码长度,从而引出了寻找最优前缀自由码这一核心算法问题,而霍夫曼算法正是解决该问题的贪心策略。
109:问题定义 📚

在本节课中,我们将学*如何将“最优二进制前缀编码”问题转化为一个清晰的数学问题。我们将通过将编码与二叉树建立对应关系,来形式化地定义我们的优化目标。
上一节我们介绍了前缀编码的基本概念及其优势。本节中,我们将通过二叉树这一工具,来精确地定义我们所要解决的问题。

从编码到二叉树 🌳
理解这个问题的关键在于将二进制编码视为二叉树。为了让你理解这种对应关系,让我们回顾上一节中看到的三个二进制编码例子,并看看它们对应什么样的树。
我们继续使用包含A、B、C、D四个符号的字母表。
1. 固定长度编码的树
明显的固定长度编码(A=00, B=01, C=10, D=11)对应一棵有四个叶子的完全二叉树。
按以下方式标记这棵树:
- 从左到右标记叶子为A到D。
- 标记每条边:如果对应左子节点关系,则标记为0;如果对应右子节点关系,则标记为1。
你会发现,从根节点到叶子的路径上的比特序列与固定长度编码相对应。例如,对于符号C,从根节点到标记为C的叶子的路径,首先遇到一个1(右子节点),然后遇到一个0(左子节点),得到序列“10”,这与C的编码相同。
2. 非前缀编码的树
当我们最初尝试变长编码以激发前缀属性时,我们研究了一个将A的双00替换为单0,将D的双11替换为单1的编码。这个编码不是前缀编码,但我们仍然可以将其表示为二叉树,只是它不再是完全平衡的。
以同样的方式标记边:左子边标记为0,右子边标记为1。将根的左、右子节点分别标记为A和D,两个叶子标记为B和C。
这样标记后,我们为各个符号提出的编码与从根节点到带有这些符号的节点的路径上的比特序列之间,存在同样的对应关系。例如,标记为D的节点,从根节点出发的路径只有一个比特1,这与D的提议编码一致。
这个编码不是前缀编码,因此存在歧义。这种歧义在树中也很明显:提示你存在歧义的属性是,树中有内部节点被标记了符号。符号并不像第一个固定长度编码的树那样只出现在叶子节点。
3. 前缀编码的树
现在,让我们画出上一节看到的最后一个例子——变长但前缀编码的树。
这棵树不是完美平衡的,但它的标签只出现在叶子节点上。
以我们一直使用的方式标记这棵树的边:所有左子边标记为0,所有右子边标记为1。从左到右标记叶子为A到D。你会看到,与之前两棵树一样,从根节点到叶子的比特序列与为该叶子提出的编码一致。例如,标记为C的叶子,你需要遍历一个右子节点、另一个右子节点,然后一个左子节点才能到达,序列是“110”,这正是符号C的提议编码。
一般对应关系与关键属性 🔑
一般来说,任何二进制编码都可以用这种方式表示为一棵树:
- 左子指针标记为0。
- 右子指针标记为1。
- 各个节点标记为给定字母表的符号。
- 从根节点向下到标记有给定符号的节点的比特,对应于该符号的提议编码。
将编码视为树的妙处在于,那个看似抽象且麻烦的重要属性——前缀条件,在这些树中以非常清晰的方式显现出来。

前缀条件等价于:只有叶子节点可以有标签,内部节点不允许有标签。
原因在于,我们这样设置使得编码对应于从根节点到标记节点的路径上的比特。因此,一个编码是另一个编码的前缀,就对应于一个节点是另一个节点的祖先。所以,如果所有标签都在叶子节点,那么没有节点是另一个节点的祖先,也就没有前缀。


解码过程与编码长度 📏
这种编码的树表示法的另一个很酷的地方是,解码过程变得直观明了。
给定一个来自前缀自由二进制编码的0和1序列,解码过程如下:
- 从序列开头开始,并位于树的根节点。
- 每当看到一个0,就向左走;每当看到一个1,就向右走。
- 最终会到达一个叶子节点。该叶子有一个标签,那就是被编码的符号。
- 到达叶子后,重新开始,回到根节点。
例如,使用我们运行示例中的四字母字母表的变长前缀编码,如果给定序列“0 110 111”,你会这样做:
- 从根开始,看到0,跟随左子指针,立即到达标记为A的叶子。输出A作为第一个符号。
- 重新开始,回到根。看到1,向右;再看到1,再向右;看到0,向左。到达标记为C的叶子。输出C。
- 重新开始,回到根。看到1,向右;再看到1,向右;再看到1,向右。到达标记为D的叶子。输出D。
通过反复遍历树,你将这个0和1序列解码为“A C D”。由于每次到达标签时你都知道自己在叶子节点(无处可去),而在每个内部节点(未标记)你知道需要期待另一个比特,因此从未产生任何歧义。
关于这种对应的最后一个要点是:符号的编码长度(编码各个符号所需的比特数)就是树中对应叶子节点的深度。
例如,在我们的运行示例中,符号A是唯一一个只需要1比特编码的,它也是树中唯一一个在第一层的叶子。类似地,B需要2比特,出现在下一层;需要3比特的C和D出现在第三层。
这种对应关系是构造性的:如何编码一个给定的符号?就是从根节点到该叶子路径上的比特,而比特的数量就是从根节点到该叶子所需的指针遍历次数,也就是该叶子在树中的深度。
问题的形式化定义 🎯
现在,我们处于一个绝佳的位置,可以真正清晰地定义问题了。
输入:只是一堆不同符号 i(来自某个字母表 Σ)的频率。我将使用 p_i 表示符号 i 的频率。
我们知道要优化什么:我们希望最小化编码一个符号所需的期望比特数,其中平均值是根据提供的各个符号的频率计算的。
现在,让我们利用我们新发现的与二叉树的对应关系来表达这个目标函数,特别是编码长度即叶子深度的概念。
给定一棵对应前缀自由二进制编码的树 T(即它应该是一棵二叉树,并且这棵树的叶子应该与 Σ 的符号一一对应),我们定义 L(T) 为平均编码长度。
平均编码长度公式:
L(T) = Σ (p_i * depth_T(i))
其中求和遍历字母表的所有符号 i。


解释:
- 我们对字母表的所有符号求和。
- 每个符号
i的权重是其频率p_i(这是输入的一部分)。 - 编码符号
i需要多少比特?就是给定树T中标记为i的叶子的深度depth_T(i)。
我们的目标就是让这个 L(T) 尽可能小。

例如,使用上一节的数据:字母A、B、C、D的频率分别为60%、25%、10%、5%。
- 如果使用完全二叉树(即固定长度编码),我们得到每个字符2比特。
- 如果使用优化过的倾斜树(让A只花1比特,而C和D承受3比特),那么平均编码长度降至1.55,正如上一节所见。


目标与算法责任 🏁
那么,目标是什么?我们算法的责任是什么?
目标:在所有叶子与 Σ 的符号一一对应的二叉树中,计算能使这个平均编码长度 L(T) 尽可能小的那一棵,即最小化我们的目标函数 L。
事实证明,霍夫曼的贪心算法可以做到这一点。更多细节将在后续课程中介绍。

本节课总结:在本节课中,我们一起学*了如何将寻找最优前缀编码的问题转化为一个关于二叉树的最小化问题。我们建立了编码与二叉树的对应关系,明确了前缀条件在树中表现为“标签仅存在于叶子节点”,并形式化地定义了优化目标——最小化加权深度和 L(T)。这为后续学*霍夫曼算法奠定了坚实的基础。
110:贪心算法

在本节课中,我们将学*霍夫曼算法,这是一种用于构造最优前缀无歧义二进制编码的贪心算法。我们将从问题定义开始,逐步理解算法的设计思路、具体步骤,并最终看到其实现方式。
概述
我们面临的计算问题是:给定一个字母表及其每个符号的出现频率,需要构造一个前缀无歧义的二进制编码,使得编码的平均长度最小。这种编码可以对应一棵二叉树,其中每个叶子节点代表一个符号,其编码长度等于该叶子节点的深度。
从分治到贪心:构建树的思路
上一节我们介绍了如何用树来表示编码。本节中我们来看看如何构建这棵树。
一种自然的想法是采用自顶向下的分治策略:将符号集分成两组,递归地为每组构建子树,然后将它们合并。然而,这种方法(称为香农-范诺编码)并非最优。
霍夫曼在他的学期论文中发现,自底向上的贪心方法才是构建最优编码树的正途。这种方法不仅保证最优性,而且算法速度极快。
自底向上合并:核心操作
以下是自底向上构建树的基本思想:
我们从一个包含所有符号的叶子节点集合开始。然后,我们反复执行合并操作:选择两个现有的子树,将它们作为左右孩子连接到一个新的内部节点下。经过 n-1 次合并后,我们就得到了一棵完整的树。
那么,关键问题在于:在每一步中,我们应该合并哪两棵子树?一个短视的贪心选择标准是什么?
合并的代价与贪心准则
为了理解合并的影响,我们需要分析合并操作如何影响最终的平均编码长度。
每次合并都会引入一个新的内部节点。对于参与合并的两棵子树中的所有符号来说,这个新节点将成为它们从叶子到根路径上的一个新节点,这意味着这些符号的编码长度都将增加1位。
因此,如果我们希望最小化加权平均编码长度,一个合理的贪心策略是:在每一步,合并当前频率最低的两个符号(或子树)。因为增加低频符号的编码长度,对总平均长度的负面影响最小。
这引出了我们的递归策略。
递归子问题与霍夫曼算法
基于上述贪心思想,我们可以递归地定义算法。以下是算法的核心步骤:
- 基础情况:如果字母表只有两个符号,则直接用一个0和一个1编码它们。
- 递归步骤:
- 找到频率最低的两个符号,记为
a和b。 - 将它们从当前字母表中移除,并添加一个新的“元符号”
ab,其频率为freq(a) + freq(b)。 - 递归地为这个新的、规模更小的字母表(少了一个符号)构建最优编码树
T‘。 - 在树
T‘中,找到代表元符号ab的叶子节点,将其“分裂”:替换为一个新的内部节点,该节点的左右孩子分别是代表a和b的叶子节点。 - 返回这棵修改后的树
T作为原问题的解。
- 找到频率最低的两个符号,记为
算法示例
让我们通过一个具体例子来演示霍夫曼算法。假设有符号 A, B, C, D,频率分别为 60, 25, 10, 5。
- 初始状态:四个叶子节点
(A:60), (B:25), (C:10), (D:5)。 - 合并频率最低的
C和D,生成元符号(CD:15)。现在集合为(A:60), (B:25), (CD:15)。 - 合并频率最低的
B和CD,生成元符号(BCD:40)。现在集合为(A:60), (BCD:40)。 - 合并最后两个符号
A和BCD。此时达到基础情况(两个符号),递归开始返回。 - 从递归返回时,逐步“分裂”元符号:
- 将代表
A和BCD的树中的BCD叶子分裂,得到B和CD作为其孩子。 - 再将代表
CD的叶子分裂,得到C和D作为其孩子。
- 将代表
- 最终得到编码树:
A的编码为0,B为10,C为110,D为111。
算法伪代码
以下是霍夫曼算法的伪代码描述:
function Huffman(Sigma, frequencies):
if |Sigma| == 2:
return a tree with two leaves labeled with the two symbols
else:
let a, b be the two symbols with the smallest frequencies
// 创建新字母表 Sigma'
Sigma' = Sigma - {a, b} ∪ {meta-symbol ab}
freq(ab) = freq(a) + freq(b)
// 递归求解
T' = Huffman(Sigma', frequencies')
// 将元符号 ab 分裂为 a 和 b
In T', find the leaf labeled 'ab'
Replace that leaf with an internal node having two children:
left child = leaf labeled 'a'
right child = leaf labeled 'b'
return the modified tree T
总结

本节课中我们一起学*了霍夫曼算法。我们了解到,通过采用自底向上的贪心策略,在每一步合并频率最低的两个符号/子树,并递归地将问题规模缩小,可以高效地构造出最优的前缀无歧义编码树。算法的直觉在于,让低频符号承受编码长度增加的代价更为划算。虽然算法的正确性需要严格的证明(这将是下一节的内容),但其设计思路清晰而优雅,是贪心算法设计的经典范例。
111:复杂示例详解
在本节课中,我们将通过一个更复杂的示例,详细演示霍夫曼贪心算法的执行过程。我们将使用一个包含六个字符的字母表,并逐步构建出最优的前缀编码树。
概述

霍夫曼算法通过反复合并频率最低的两个符号来构建最优前缀编码树。本节将通过一个包含六个字符(A, B, C, D, E, F)及其对应权重(3, 2, 6, 8, 2, 6)的示例,完整展示算法的每一步操作及其对应的树形结构变化。
初始状态
我们有一个六字符的字母表。每个字符及其权重如下:
- A: 3
- B: 2
- C: 6
- D: 8
- E: 2
- F: 6
注意:即使所有权重之和不等于1,问题定义依然明确。如果你*惯使用概率,可以将这六个数字除以它们的总和27。
初始时,我们只知道每个字符都将成为最终树中的一个叶子节点。
算法步骤详解
上一节我们介绍了算法的基本思想,本节中我们来看看它在这个复杂示例中是如何一步步执行的。
步骤 1:合并最小权重的符号
在霍夫曼算法的第一步,我们找到权重最小(即频率最低)的两个符号。
以下是当前所有符号及其权重:
- A (3)
- B (2)
- C (6)
- D (8)
- E (2)
- F (6)
在这个例子中,权重最小的符号是 B 和 E,它们的权重都是2。我们将这两个符号合并成一个新的“元符号” B-E。这相当于现在就确定B和E在最终的树中将是兄弟节点。
合并后,我们的字母表减少到五个符号:
- A (3)
B-E(4) // 权重为 B和E的权重之和:2 + 2 = 4- C (6)
- D (8)
- F (6)
至此,我们知道B和E将是兄弟节点,而A、C、D、F都将是叶子节点,但具体结构尚未确定。
步骤 2:再次合并最小权重的符号
现在,我们继续在当前的五个符号中寻找权重最小的两个。
以下是当前所有符号及其权重:
- A (3)
B-E(4)- C (6)
- D (8)
- F (6)
权重最小的符号是 A (权重3),其次是元符号 B-E (权重4)。因此,在这一步,我们将A与B-E合并。
合并后,字母表减少到四个符号:
A-B-E(7) // 权重为 A和B-E的权重之和:3 + 4 = 7- C (6)
- D (8)
- F (6)
对于树的结构,我们现在确定了符号A将作为兄弟节点B和E的“叔叔”(即与B-E节点同级)。C、D、F仍然是叶子节点。
步骤 3:继续合并
现在,我们在四个符号中继续寻找权重最小的两个。
以下是当前所有符号及其权重:
A-B-E(7)- C (6)
- D (8)
- F (6)
权重最小的两个符号是 C 和 F,它们的权重都是6。我们将它们合并。
合并后,字母表减少到三个符号:
A-B-E(7)- D (8)
C-F(12) // 权重为 C和F的权重之和:6 + 6 = 12
除了已知的信息外,我们现在还确定了C和F将在最终的树中成为兄弟节点。
步骤 4:倒数第二次合并
现在,我们在剩下的三个符号中寻找权重最小的两个。
以下是当前所有符号及其权重:
A-B-E(7)- D (8)
C-F(12)
权重最小的两个符号是 A-B-E (权重7) 和 D (权重8)。我们将它们合并。
合并后,字母表减少到两个符号:
A-B-D-E(15) // 权重为 A-B-E 和 D 的权重之和:7 + 8 = 15C-F(12)
此时,我们已经知道了最终树中根节点下两个主要子树的结构。
步骤 5:最终合并
当我们只剩下两个符号时,唯一能做的就是将它们融合在一起,即通过一个共同的根节点将这两棵子树连接起来。
我们将 A-B-D-E (权重15) 和 C-F (权重12) 合并,形成最终的根节点,其权重为 15 + 12 = 27。
这样就得到了霍夫曼算法的最终输出树。

生成前缀编码
上一节我们得到了最终的树形结构,本节中我们来看看如何从这棵树得到具体的前缀编码。
按照惯例,我们将树中所有的左分支标记为 0,所有的右分支标记为 1。
一个字符的编码就是从根节点遍历到该字符所在叶子节点路径上遇到的 0 和 1 的序列。
根据最终的树,我们可以得到每个字符的霍夫曼编码:
- A:
0 0 0 - B:
0 0 1 0 - C:
1 0 - D:
0 1 - E:
0 0 1 1 - F:
1 1
总结

本节课中我们一起学*了如何通过一个复杂的六字符示例,逐步应用霍夫曼贪心算法。我们经历了寻找最小权重符号、合并生成新节点、更新符号列表的多次迭代,最终构建出最优前缀编码树,并从中推导出每个字符的具体编码。这个过程清晰地展示了霍夫曼算法如何通过局部最优选择(每次合并频率最低的两个项)来达到全局最优解(最小化编码总长度)。
112:霍夫曼算法正确性证明(第一部分) 🧩

在本节课中,我们将学*如何证明霍夫曼算法的正确性。具体来说,我们将证明这个贪心算法总能计算出平均编码长度最小的前缀自由二进制码。
证明概述
我们将通过数学归纳法来证明霍夫曼算法的正确性。证明的核心思想是:首先处理基础情况,然后利用归纳假设,通过“交换论证”来证明算法的每一步合并操作都是最优的。
符号与表达式
首先,我们回顾一下平均编码长度的表达式。对于一个给定的树 T,平均编码长度定义为:

[
\text{Average Encoding Length} = \sum_{i \in \Sigma} p_i \cdot \text{depth}(i)
]
其中:
- Σ 是字母表。
- p_i 是符号 i 的频率(作为输入的一部分给出)。
- depth(i) 是树 T 中对应符号 i 的叶节点的深度。
这个表达式在证明中会频繁使用。
归纳法结构
我们将对字母表的大小 N 进行归纳证明。这与我们在第一部分证明迪杰斯特拉算法正确性时使用的归纳法有相似之处:我们假设算法在较小的输入上正确,然后证明在当前输入上也正确。
基础情况

当字母表大小为 2 时,霍夫曼算法会输出一个简单的树:用一个比特 0 编码一个符号,用比特 1 编码另一个符号。这是最优的,因为每个符号至少需要一个比特来编码,而该树恰好为每个符号使用了一个比特。因此,霍夫曼算法在这个平凡的特殊情况下是最优的。
归纳步骤

现在,我们关注字母表大小至少为 3 的任意问题实例。归纳假设是:霍夫曼算法在任何较小的输入(即字母表更小的问题)上都能返回正确的解。
为了从归纳假设(算法在较小输入上正确)过渡到归纳步骤(算法在当前输入上正确),我们需要仔细研究原始输入(字母表 Σ)与通过合并两个符号生成的小子问题(字母表 Σ')之间的关系。
算法步骤与符号对应关系
回顾霍夫曼算法的伪代码,算法会选取频率最小的两个符号,我们称之为 A 和 B,并将它们合并为一个新的“元符号” AB。这个新符号的频率是 A 和 B 频率之和:p_AB = p_A + p_B。
上一节我们介绍了算法的直觉:合并两个符号 A 和 B,相当于承诺在最终输出的树中,A 和 B 作为兄弟节点出现(即它们拥有相同的父节点)。
因此,存在一种一一对应关系:
- 一方面,是叶子节点标记为 Σ' 符号的树 T'(其中没有单独的 A 和 B 叶子,只有一个标记为 AB 的叶子)。
- 另一方面,是叶子节点标记为原始字母表 Σ 的树 T,但要求 A 和 B 恰好是兄弟节点。
给定一个如左图所示的树 T'(叶子标记为 Σ'),我们可以通过分裂标记为 AB 的叶子节点,创建一个内部节点并赋予其两个标记为 A 和 B 的子节点,从而得到右图形式的树 T。反之亦然。

我们将这种特殊的、A 和 B 为兄弟节点的 Σ 字母表树集合记为 X_AB。
目标函数值的对应关系
这种子问题解与原始问题特定形式解之间的对应关系,有一个重要性质:它(几乎)保持了目标函数值,即平均编码长度。
考虑任意一对匹配的树 T' 和 T(T 是由 T' 分裂 AB 叶子得到的)。让我们计算它们平均编码长度的差值。
T 的平均编码长度:
[
L(T) = \sum_{i \in \Sigma} p_i \cdot \text{depth}_T(i)
]
T' 的平均编码长度:
[
L(T') = \sum_{j \in \Sigma'} p_j \cdot \text{depth}_{T'}(j)
]
由于 Σ 和 Σ' 几乎相同,唯一的区别是 Σ' 有元符号 AB,而 Σ 有单独的符号 A 和 B。同时,树 T 和 T' 也几乎相同,唯一的区别是 T' 有一个 AB 叶子,而 T 在下一层有两个对应的 A 和 B 节点。
因此,当我们计算差值 L(T) - L(T') 时,除了涉及 A、B 和 AB 的项,其他所有项都相互抵消了:
[
L(T) - L(T') = [p_A \cdot \text{depth}T(A) + p_B \cdot \text{depth}T(B)] - [p \cdot \text{depth}(AB)]
]
现在,我们利用已知关系进行简化:
- 频率关系:p_AB = p_A + p_B
- 深度关系:设 AB 在 T' 中的深度为 d。由于 T 是通过分裂 AB 叶子得到的,那么 A 和 B 在 T 中的深度都是 d + 1。
代入这些关系:
[
\begin{aligned}
L(T) - L(T') &= [p_A \cdot (d+1) + p_B \cdot (d+1)] - [(p_A + p_B) \cdot d] \
&= (p_A + p_B)(d+1) - (p_A + p_B)d \
&= p_A + p_B
\end{aligned}
]
关键结论是:对于任何这样一对匹配的树 T' 和 T,它们的平均编码长度之差是一个常数 p_A + p_B。这个常数不依赖于我们所选择的特定树的结构。
这意味着,在树 T'(对应子问题 Σ')和树 T(对应原始问题 Σ 且 A、B 为兄弟)之间,最小化平均编码长度的问题是等价的,只差一个固定的偏移量。这为我们的归纳证明奠定了坚实的基础。
本节课中,我们一起学*了霍夫曼算法正确性证明的第一部分。我们建立了证明的框架,使用归纳法并定义了关键符号。最重要的是,我们证明了原始问题的最优解(要求 A、B 为兄弟)与子问题的最优解之间,其目标函数值只相差一个常数。在下一节中,我们将利用这个关系来完成归纳步骤的证明。
113:正确性证明二



在本节中,我们将完成霍夫曼算法正确性的证明。上一节我们通过计算建立了原始问题与子问题之间的对应关系。本节中,我们将利用这个对应关系,并证明一个关键引理,从而最终证明霍夫曼算法总能生成最优前缀码。
归纳假设与对应关系
我们首先回顾归纳假设。在任何归纳证明中,都必须严重依赖归纳假设。归纳假设指出:当霍夫曼算法递归处理规模更小的字母表 σ' 时,它能最优地解决该子问题。它返回的树 T'^ 能最小化关于字母表 σ' 及其对应频率的平均编码长度。
结合上一节的计算,我们可以放大这个归纳假设的威力。我们知道,对于那个规模更小的子问题(我们本身并不关心它),递归调用能最优地解决它,返回树 T'^。在所有叶子节点按 σ' 标记的树中,这棵树是最好的,它最小化了平均编码长度。

但我们在上一节学到,在子问题的可行解(字母表为 σ' 的树)与原始问题(我们真正关心的问题)中具有特定形式的可行解之间,存在一一对应关系。这种特定形式就是 A 和 B 恰好是兄弟节点。此外,这种对应关系保留了目标函数值(平均编码长度),或者至少保留到一个常数,这对我们的目的来说已经足够。
因此,结论是:在最小化子问题所有可行解的平均编码长度时,我们的递归调用实际上为我们做了更多。它实际上是在最小化原始问题(字母表为 σ)在一个可行解子集上的平均编码长度,这个子集就是 A 和 B 为兄弟节点的那些解。
关键问题与引理
现在,这对我们有帮助吗?这取决于情况。我们的原始目标是在所有可行解中获得可能的最小平均编码长度。而我们刚刚发现,我们只是在一部分解(即 A 和 B 恰好是兄弟节点的解)中获得了最佳方案。因此,如果不存在 A 和 B 是兄弟节点的最优解,那么只在这些“较差”的解中优化对我们没有帮助。另一方面,如果存在一个最优解位于集合 X_AB 中(即 A 和 B 是兄弟节点),那么我们就成功了。因为集合中存在最优解,而我们的递归调用正在寻找集合中的最佳解,所以我们将会找到一个最优解。
因此,真正的大问题是:我们能否保证在第一次迭代中合并 A 和 B 是安全的?我们能否保证存在一个最优解,一棵同样具有 A 和 B 为兄弟节点这一属性的最佳可能树?
以下关键引理(我将在下一页证明)将完成霍夫曼定理正确性的证明:确实,在集合 X_AB 中总是存在一个最优解,即存在一个最优解,其中频率最低的两个符号 A 和 B 确实是兄弟节点。
在给出完整证明之前,我先引导你思考并培养直觉,理解为什么我们希望这个关键引理成立。直觉与我们最初设计贪心算法时的想法相同:你希望哪些符号承受较长的编码长度?你希望它们是频率最低的符号,因为你想最小化平均值。如果你观察任何一棵树,总会有一个最底层,一些最深的叶子节点。你真的希望频率最低的叶子节点 A 和 B 位于那个最底层。它们可能都在最底层,但可能不是兄弟节点。但如果你想想,在同一层的符号中,它们承受的编码长度完全相同,因此你可以以任何方式排列它们,这不会影响你的平均编码长度。所以,一旦你把 A 和 B 放在最底层,你完全可以让它们成为兄弟节点,这不会造成任何区别。最终的结论是:你可以取任何树(比如一棵最优树),通过交换频率最低的元素 A 和 B 到最底层并成为兄弟节点,你不可能让树变得更差,只能让它变得更好(或保持不变)。这将证明存在一棵具有所需属性的最优树,其中 A 和 B 确实是兄弟节点。
这个直觉相当准确,但它不是证明。以下是证明。

关键引理证明:交换论证

我们将使用交换论证。我们取一棵任意的、最小化平均编码长度的最优树,称之为 T*(可能有很多这样的树,随便选一棵,哪棵都行)。计划是展示一棵至少同样好(甚至更好)的树,它满足我们想要的属性,即 A 和 B 作为兄弟节点出现。
首先,我们需要确定将符号 A 和 B 交换到哪里。我们有了树 T,让我们看看 T 的最深层,并在该层找到任意一对兄弟节点,称它们为 X 和 Y。在右边的绿色树中,我们在最底层有两对兄弟节点的选择,选哪对无关紧要,假设我们选最左边的一对,称它们为 X 和 Y。A 和 B 必须在这棵树中的某个位置,让我在 T* 中任意挑选两个叶子节点作为 A 和 B 可能所在位置的例子。
现在,我们将执行交换。我们将通过仅交换叶子节点的标签,从 T* 得到一棵新树 T^。具体来说,交换叶子节点 X 和 A 的标签,同样交换叶子节点 Y 和 B 的标签。
交换后,我们得到一棵新的有效树,其叶子节点再次用 σ 的各种符号标记。此外,通过我们对 X 和 Y 的选择,我们通过这次交换强制这棵树符合属性:我们强制 A 和 B 成为兄弟节点,它们占据了原本是兄弟节点的 X 和 Y 的位置。
为了完成证明,我们将展示 T^ 的平均编码长度只能小于或等于 T* 的平均编码长度。由于 T* 是最优的,这意味着 T^ 也一定是最优的,并且它也满足 A 和 B 是兄弟节点这一所需属性。这将完成关键引理的证明,从而完成霍夫曼算法正确性的证明。
直观原因是:当我们进行交换时,A 和 B 继承了 X 和 Y 原先的深度,反之,X 和 Y 继承了 A 和 B 原先的深度。因此,A 和 B 的深度只能变得更差(更深或不变),而 X 和 Y 的深度只能变得更好(更浅或不变)。由于 X 和 Y 是频率更高的符号,整体的成本效益分析会带来净收益,树只会变得更好(或不变)。但为了精确说明,让我展示具体的计算。
让我们看看由树 T* 给出的平均编码长度与交换后由树 T^ 给出的平均编码长度之间的差值。类似于上一页的计算,大部分项会抵消,因为树 T* 和 T^ 并没有那么大的不同。唯一不同的是符号 A、B、X、Y 的位置。因此,我们将有来自 T* 的四个正项(对应这四个符号),以及来自树 T^ 的四个负项(同样对应这四个符号)。让我展示一下在抵消所有可以抵消的项之后,剩下的八个项如何以一种略显巧妙但清晰的方式书写如下:
我们将有一个乘积代表涉及符号 A 和 X 的四个项,然后有一个涉及 B 和 Y 的类似项。在第一个乘积中,我们看两个差值的乘积:一方面是 X 和 A 的频率之差(记住它们是互相交换的两个东西),另一方面是它们在原始树 T* 中的深度之差。因为 B 和 Y 也被交换了,所以我们有一个完全类似的涉及它们的项。
我以这种略显巧妙的方式重写这个差值的原因是,现在各种假设所起的作用变得完全明显。具体来说,为什么 A 和 B 具有最低可能频率这一点很重要?我们在证明中还没有用到这一点,但它对我们的贪心算法是根本性的。其次,为什么我们选择 X 和 Y 在原始树 T* 的最深层?让我们从第一对乘积开始,即频率的差值。X 的频率减去 A 的频率,以及 Y 和 B 之间类似。因为 A 和 B 具有所有符号中最小的频率,这些差值必须是非负的,X 和 Y 的频率只能更大。第二对差值也必须是非负的,这仅仅是因为我们选择 X 和 Y 在最深层,它们的深度必须至少与 A 和 B 在原始树 T* 中的深度一样大。
由于所有四个差值都是非负的,这意味着它们的乘积之和也是非负的。这意味着 T* 的平均编码长度至少和 T^ 的一样大。因此,T^ 也必须是最优的,并且它还额外满足 A 和 B 是兄弟节点这一所需属性。这意味着我们的递归调用,即使我们承诺合并 A 和 B,承诺返回一棵它们是兄弟节点的树,这也是没有损失的,是一个安全的合并。这就是为什么霍夫曼算法最终在最小化所有可能二进制前缀码的平均编码长度时,能够返回一棵最优树。
实现与运行时间分析

让我最后简要说明如何实现霍夫曼算法以及你应该能够达到什么样的运行时间。
如果你只是简单地实现我展示给你的伪代码,你会得到一个运行时间不太理想(二次时间)的算法,而且还使用了大量递归。
为什么是二次时间?你每次有一个递归调用,并且总是将符号数量减少一个,所以递归调用的总次数将与字母表中的符号数量 n 成线性关系。在不计算后续调用所做工作的情况下,每个递归调用中你需要做多少工作?你必须搜索找出频率最低的符号 A 和 B,这基本上是一个找最小值的计算,因此每个线性次数的递归调用都需要线性时间。

我们不断在每个递归调用中重复进行这些最小值计算,我希望这能触发一个灵感:也许数据结构会有帮助。哪种数据结构适合加速重复的最小值计算?正如我们在 Dijkstra 和 Prim 等算法中看到的,那就是堆。
当然,当你定义堆时,必须说明键是什么。由于我们总是想知道频率最小的符号是什么,最明显的做法是使用频率作为键。那么唯一的问题是,当你进行合并时会发生什么?显然,只需将这两个符号的频率相加,并将这个新的元符号(其键等于你刚从堆中提取的两个元素的键之和)重新插入堆中。这不仅会很好地工作,而且将达到 O(n log n) 的时间复杂度,并且很容易以迭代方式实现。这将开始成为一个非常快速的霍夫曼算法实现。


事实上,你可以做得更好。你可以将霍夫曼算法的实现归结为单次调用排序子程序,然后仅进行线性量的工作。在最坏情况下,你仍然会渐进地受限于 O(n log n) 的实现,因为如果你对所排序的内容一无所知,你无法比 O(n log n) 排序更快。但在这个实现中,常数因子会更优。正如我们在第一部分讨论过的,有时在特殊情况下,你可以在排序上击败 O(n log n)。例如,如果你的所有数据都可以用少量比特表示,那么你可以做得比 O(n log n) 更好。
我将把这个更快的实现留作一个有点不简单的练*。不过,我会给你一个提示:如果你将排序作为预处理步骤,那么在这个实现中,你甚至不需要使用堆数据结构,你可以使用更原始的数据结构——队列。然而,你可能想使用两个队列。所以,下次你排队买咖啡时,可以好好想想:如何仅通过一次排序调用(使符号按频率排序),然后使用两个队列进行线性工作,来实现霍夫曼算法。
总结
本节课中,我们一起完成了霍夫曼算法正确性的证明。我们首先利用归纳假设和子问题与原始问题的对应关系,将问题转化为证明一个关键引理:总存在一个最优解,其中频率最低的两个符号是兄弟节点。接着,我们通过严谨的交换论证证明了该引理,展示了如何通过交换叶子节点标签,将任意最优树转化为一棵满足所需属性且性能不差的新树。最后,我们简要讨论了算法的实现,指出通过使用堆数据结构可以达到 O(n log n) 的运行时间,并提示了利用排序和双队列可能实现更优的常数因子甚至更好的渐进复杂度。至此,我们证明了霍夫曼算法能够为给定频率的符号集生成平均编码长度最小的二进制前缀码。
114:引言-路径图中的加权独立集

在本节课中,我们将开始学*动态规划这一重要的算法设计范式。我们将从一个具体的计算问题——在路径图中寻找最大权重独立集——入手,逐步推导出解决方案。通过这个过程,我们将自然地引出动态规划的核心思想。
问题定义
我们首先来明确要解决的问题。这是一个图论问题,但图的结构非常简单:我们只关注路径图。路径图由 n 个顶点组成,这些顶点排成一条直线,每个顶点只与它的前一个和后一个顶点相连(如果存在的话)。
除了图结构,输入还包括每个顶点的一个非负权重。例如,下图是一个包含4个顶点的路径图,其顶点权重分别为1、4、5、4。
顶点: 1 -- 2 -- 3 -- 4
权重: 1 4 5 4
算法的任务是输出一个独立集。独立集是顶点的一个子集,其中没有两个顶点是相邻的。在路径图中,这意味着不能选择任何一对连续的顶点。
例如,在4个顶点的路径图中,有效的独立集包括:空集、仅顶点1、仅顶点2、仅顶点3、仅顶点4、顶点1和3、顶点2和4、顶点1和4。而顶点2和3则不能同时被选择,因为它们是相邻的。
我们的目标不是找到任意一个独立集,而是找到总权重最大的那个独立集,这就是最大权重独立集问题。
接下来,我们将回顾已学过的算法设计范式,看看它们是否能有效解决此问题。这将为我们引入动态规划这一新方法做好铺垫。
现有范式的局限性
暴力搜索 🔍
最直接的方法是暴力搜索,即枚举所有可能的独立集,并记录总权重最大的那个。这种方法无疑是正确的,但效率极低。即使在路径图中,独立集的数量也是顶点数 n 的指数级。因此,对于大规模问题,暴力搜索并不可行。
贪心算法 🤔
我们刚刚学完贪心算法,很自然会想到它。一个直观的贪心策略是:每一步都选择当前权重最高且不与已选顶点相邻的顶点。
让我们用之前的4顶点例子测试这个算法:
- 最优解是选择顶点2(权重4)和顶点4(权重4),总权重为8。
- 贪心算法会先选择权重最高的顶点3(权重5)。之后,由于顶点2和4都与3相邻,唯一可行的选择只剩下顶点1(权重1)。最终得到总权重为6,并非最优。
这个例子提醒我们,贪心算法虽然简单,但常常无法保证得到最优解。对于此问题,目前没有已知的贪心算法能保证正确性。
分治算法 🧩
分治法是我们早期学*的一个强大范式。对于路径图,一个自然的想法是:将路径从中间“切开”,递归地求解左半部分和右半部分的最大权重独立集,然后尝试合并结果。
然而,这种方法存在一个根本性问题:子问题的解可能在边界处发生冲突。
再次考虑4顶点的例子:
- 左半部分(顶点1,2)的最优解是选择顶点2(权重4)。
- 右半部分(顶点3,4)的最优解是选择顶点3(权重5)。
- 当我们尝试合并这两个解时,发现顶点2和3是相邻的,违反了独立集的定义。
虽然在这个小例子中修复冲突看似容易,但在大规模问题中,如果两个子问题的解在分割点附*发生冲突,要快速、正确地合并它们以获得全局最优解,是非常困难的。
分治法可以解决这个问题(例如达到 O(n^2) 时间复杂度),但效率不够高。我们即将开发的动态规划算法将在线性时间 O(n) 内解决它。
总结
本节课我们一起学*了最大权重独立集问题的定义,并回顾了暴力搜索、贪心算法和分治算法在解决此问题时的局限性。我们发现,这些现有范式要么效率太低,要么无法保证最优解,要么在合并子问题时面临困难。

这促使我们需要一种新的思路。在接下来的课程中,我们将从这个问题出发,逐步推导出一种更高效、更系统的解决方案——动态规划算法。我们将看到,动态规划如何通过巧妙地组织子问题并避免分治法的合并难题,从而优雅地解决此类优化问题。
115:路径图中加权独立集的最优子结构 🧩

在本节课中,我们将学*一种新的算法设计范式——动态规划。我们将通过解决“路径图中加权独立集”这个具体问题,来理解动态规划的核心思想:分析最优解的结构。我们将看到,最优解必然由更小子问题的最优解以特定方式构成。
从已知范式到新思路
上一节我们回顾了分治、贪心等算法设计范式,发现它们都不太适合高效计算路径图的最大权重独立集。
本节中,我们将为一种新范式——动态规划——奠定基础。这种范式的关键方法是:首先推理最优解的结构。
我们所说的“推理最优解的结构”,是指寻找以下形式的陈述:无论最优解具体是什么,它都必须具备某种特定形式,并且必须以规定的方式从子问题的最优解构建而成。
事实上,在我们讨论分治和贪心算法时,这种推理是隐含的。而在动态规划中,我们将使其系统化。例如,许多分治算法正确性的隐含前提就是:整个问题的最优解必须能够以规定的方式,由更小子问题的解来表达和构建。
那么,进行这种思想实验、试图理解最优解可能样子的动机是什么呢?计划是:我们将把最优解的候选范围缩小到一个相对较小的集合。对于一个小的候选集,我们可以通过暴力搜索来选出最好的一个。
一旦你精通动态规划,就会学到一课:对你试图计算的对象本身进行推理,这绝非循环论证。请记住,我们的目标是设计一个算法来计算最优解。而现在,我建议你进行一个思想实验,假设你已经计算出了最优解,或者有人把它放在银盘上递给了你。这种“白日梦”可能非常有成效:思考一下,如果我确实有一个最优解,我能对它说些什么?它会是什么样子?这种形式的观察实际上可以为计算那个确切对象照亮道路,我们将在接下来的视频中看到这一点。
好了,哲学讨论到此为止。让我们具体一点。
问题定义与符号说明
我们有一个路径图。顶点带有权重。我们想要找到最大权重的独立集。
让我们再次进行这个思想实验。假设有人把最优解递给了我们。我们能对其结构说些什么?
在推理这个最大权重独立集时,我们将使用以下符号:
- S 表示那个最优解(即最大权重独立集)中的顶点集合。
- v_n 表示输入图最右边、最后一个顶点。
这是一个不言自明的陈述:路径的最后一个顶点 v_n,要么在 S 中,要么不在。这将为我们推理最优解时提供两种情况。
情况一:最优解不包含最后一个顶点
让我们从 v_n 被排除在最优解 S 之外的情况开始。
令 G' 表示从原图 G 中移除最右边的顶点 v_n 后得到的路径图。
首先,我们做几个简单的观察:
- 集合 S 是 G 中的一个独立集,并且它不包含最后一个顶点。因此,我们同样可以将集合 S 视为较小图 G' 的一个独立集。如果它在 G 中不包含连续顶点,那么在 G' 中也不会。
- 但事实上,我们可以说得更多:S 不仅是 G' 中任意一个旧的独立集,它必须是 G' 中的一个最优(即最大权重)独立集。
为什么?因为如果在 G' 中存在比 S 更好的独立集(称为 S),我们可以将这个完全相同的独立集 S 视为 G 中的一个独立集,它当然在 G 中仍然比 S 更好。但这与我们假设 S 是 G 的最大权重独立集相矛盾。
总结:如果原路径图 G 的最大权重独立集 S 不包含最右边的顶点,那么它可以简单地用一个更小子问题的最优解来描述:它就是少一个顶点的路径图 G' 的一个最大权重独立集。
情况二:最优解包含最后一个顶点
情况一为情况二做了热身,情况二类似但稍微复杂一些。
现在,我们假设最大独立集 S 确实使用了最后一个顶点 v_n。
根据独立集的定义,不能选择两个相邻的顶点。因此,由于选择了最右边的顶点 v_n,S 在这种情况下绝对不能包含倒数第二个顶点 v_{n-1}。
我们记 G'' 为从 G 中移除最右边的两个顶点后得到的路径。
现在,让我们尽力模仿情况一中的论证。在情况一中,我们说 S 也必须是 G' 的一个独立集。在这里,这说不通,因为 S 包含了最后一个顶点,所以我们甚至不能谈论它是任何更小图的子集。
然而,如果我们考虑集合 S 去掉最后一个顶点 v_n 的部分(即 S \ {v_n}),它实际上是 G'' 的一个独立集,因为请记住,S 不能包含倒数第二个顶点。
与情况一类似,我们可以说得更强一些:去掉 v_n 的 S 不仅是 G'' 中任意一个旧的独立集,它实际上必须是 G'' 中的一个最优独立集,必须具有最大可能的权重。
推理是相似的:假设去掉 v_n 的 S 不是 G'' 中可能的最佳独立集,那么存在另一个称为 S* 的独立集,它甚至更好,权重更大。我们如何得出矛盾呢?如果我们只是将这个位于 G'' 中的、甚至更大的独立集 S* 加上 v_n,我们就得到了整个图 G 的一个合法的独立集,其总权重甚至比 S 的还要大。但这与 S 的最优性相矛盾。
例如,你可以想象这个所谓的最优解 S 总权重为 1100,由两部分组成:来自 G'' 中顶点的权重为 1000,而 v_n 本身的权重为 100。在矛盾论证中,你会说:假设在 G'' 中存在一个独立集,其权重甚至超过 1000,比如 1050。那么,我们只需将最后一个顶点 v_n 加到这个集合上,就得到了原图 G 中一个权重为 1150 的独立集。但这与 S 本应是权重约 1100 的最优解这一事实相矛盾。
请注意,我们在这个论证中使用图 G'' 的原因是为了确保:无论 S* 是什么,无论 G'' 的这个独立集是什么,我们都可以放心地加上 v_n,而不必担心可行性问题。因为 S* 可能拥有的最右边的顶点是倒数第三个顶点 v_{n-2}。所以,当我们通过添加最右边的顶点 v_n 来扩展它时,无需担心可行性。
归纳最优解的结构
为了确保你不至于只见树木不见森林,让我提醒你我们的高层计划是什么,并指出我们实际上在这个问题中已经相当成功地执行了这个计划。
计划是:缩小最优解可能是什么的候选范围,推理最优解的形式,并论证它必须以特定的方式呈现。
我们在上一页证明了什么?我们证明了最优解实际上只能是以下两种情况之一:
- 它排除最后一个顶点,并且它就是 G' 的最大权重独立集。
- 或者,如果它包含最右边的顶点,那么它必须是 G'' 的最大权重独立集加上这个最后一个顶点 v_n。
对于最优解可能是什么样子,只有两种可能性,并且都是用更小子问题的最优解来描述的。
从推理到算法思路
这个推理的一个推论是:如果有一只“小鸟”告诉我们处于哪种情况(即 v_n 是否在最优解中),我们就可以通过在适当的子问题上递归来简单地完成求解。
- 如果小鸟告诉我们最优解不包含 v_n,我们就在 G' 上递归。
- 如果小鸟告诉我们 v_n 在最优解中,我们就在 G'' 上递归,然后将结果加上 v_n。
当然,并没有这样的小鸟,我们也不知道这个最右边的顶点是否在最优解中。
但是,嘿,只有两种可能性,对吧?这里有一个想法,也许听起来有点疯狂,但为什么不两种可能性都尝试一下,然后返回更好的那个呢?

为什么我说这可能听起来疯狂?因为如果你盯着这个想法思考,并考虑在递归遍历图时尝试两种可能性所带来的影响,这可能会开始感觉有点像暴力搜索。事实上,它就是。这只是暴力搜索的一种递归组织形式。
然而,正如我们将在下一个视频中看到的,如果我们能巧妙地消除冗余,我们实际上可以用线性时间来实现这个想法。
本节总结
本节课中,我们一起学*了动态规划思想的起点:分析最优解的结构。我们以路径图的最大权重独立集问题为例,证明了最优解必然属于两种明确的结构之一:要么不包含最后一个顶点,等同于少一个顶点的子图的最优解;要么包含最后一个顶点,等同于少两个顶点的子图的最优解加上该顶点。这个关键的观察将问题分解成了更小的子问题,为我们下一节设计高效的动态规划算法奠定了基础。
116:路径图中加权独立集的线性时间算法 📈

在本节课中,我们将学*如何为路径图(Path Graph)设计一个计算最大权重独立集(Maximum Weight Independent Set)的线性时间算法。我们将从一个直观但低效的递归思路出发,通过识别并消除其巨大的冗余计算,最终得到一个高效、简洁的动态规划算法。
上一节我们通过思想实验,精确分析了路径图中最优解(最大权重独立集)必须满足的结构。本节中,我们将把这一分析转化为一个高效的线性时间算法。
从递归思路到指数时间算法
首先,快速回顾上一节的核心结论。我们论证了两点:
- 如果路径图的最大权重独立集不包含最右侧的顶点
V_n,那么它必然是去掉V_n后得到的子图G'的最大权重独立集。 - 如果最大权重独立集包含最右侧的顶点
V_n,那么在移除V_n后,剩余部分必然是去掉最右侧两个顶点后得到的子图G''的最大权重独立集。
因此,如果我们能知道处于哪种情况,就可以递归地计算 G' 或 G'' 的最优解,然后相应地返回结果(对于 G'' 的情况,需将 V_n 加入结果中)。由于我们无法预知是哪种情况,一个自然的想法是尝试两种情况。
以下是这个递归算法的伪代码描述:
def mwis_recursive(G):
if G is empty:
return 0, []
if G has only one vertex v:
return weight(v), [v]
# 情况1:不包含最右顶点 V_n
opt1, set1 = mwis_recursive(G_without_last_vertex)
# 情况2:包含最右顶点 V_n
opt2, set2 = mwis_recursive(G_without_last_two_vertices)
opt2 += weight(V_n)
set2.append(V_n)
# 返回权重更大的那个解
if opt1 >= opt2:
return opt1, set1
else:
return opt2, set2
这个算法的好消息是它是正确的,能保证返回最大权重独立集。其证明可以通过归纳法完成,与分治算法的证明模板类似。
然而,坏消息是,这个算法需要指数时间,本质上与暴力搜索无异。原因在于,在每次递归调用之前,我们只排除了一个或两个顶点(进展甚微),却产生了两个递归分支。这种“进展小、分支多”的模式导致了指数级的运行时间。
关键洞察:子问题的本质
这引出了一个关键问题:在这个产生指数级递归调用的算法中,所有不同的子问题总共有多少个?
答案是:仅有线性个(O(n))不同的子问题。
尽管递归调用数量是指数级的,但我们实际需要解决的子问题类型却很少。因为在整个递归过程中,无论经过怎样的调用序列,你得到的子问题总是原始图的一个前缀(即由前 i 个顶点导出的子图,i 从 0 到 n)。因此,最多只有 n+1 个本质上不同的子问题。
由此我们得出结论:先前算法的指数运行时间,完全源于对完全相同的子问题进行了一遍又一遍的重复求解。
动态规划:消除冗余,实现线性时间
上述观察为我们实现线性时间算法提供了可能。一旦解决了一个子问题,我们就记住答案,后续需要时直接查表即可,无需重复计算。这种方法被称为记忆化(Memoization)。
更清晰、更高效的实现方式是采用自底向上(Bottom-up) 的动态规划方法,系统地从小问题解决到大问题。
具体步骤如下:

- 定义子问题:令
G_i表示由前i个顶点构成的子图。 - 创建数组:设数组
dp[0...n],其中dp[i]存储子图G_i的最大权重独立集的总权重。 - 初始化边界情况:
dp[0] = 0(空图)dp[1] = weight(v1)(只有一个顶点)
- 递推计算:对于
i从 2 到n,根据上一节的分析,G_i的最优解有两种可能:- 不包含顶点
v_i:则最优解等于dp[i-1]。 - 包含顶点
v_i:则最优解等于weight(v_i) + dp[i-2](因为不能包含相邻的v_{i-1})。
因此,递推公式为:
dp[i] = max(dp[i-1], weight(v_i) + dp[i-2])
- 不包含顶点
- 最终,
dp[n]即为整个图的最大权重独立集的总权重。若要重构出具体的顶点集合,可以反向追踪决策过程。
以下是该算法的核心循环代码描述:
def mwis_dp(weights): # weights[1...n] 存储顶点权重
n = len(weights)
dp = [0] * (n + 1)
dp[0] = 0
dp[1] = weights[1]
for i in range(2, n + 1):
dp[i] = max(dp[i-1], dp[i-2] + weights[i])
return dp[n]
# 可通过额外数组记录决策来重构独立集
算法分析与总结
运行时间:该算法有一个从 2 到 n 的简单循环,每次迭代执行常数时间操作,因此总运行时间为 O(n),即线性时间。
正确性:算法的正确性基于上一节对最优解结构的严格分析。递推公式 dp[i] = max(dp[i-1], weight(v_i) + dp[i-2]) 穷举了 G_i 最优解的两种可能情况,并选取更优者。由于我们以自底向上的方式确保 dp[i-1] 和 dp[i-2] 已是最优解,因此 dp[i] 也能得到最优解。
本节课中,我们一起学*了如何为路径图上的加权独立集问题设计线性时间算法。我们从递归思想出发,发现了子问题数量有限的特性,进而通过动态规划消除了指数级冗余,最终得到了高效、优雅的解决方案。这个例子清晰地展示了动态规划的核心思想:定义重叠子问题,存储子问题解,避免重复计算。
117:路径图中加权独立集的重构算法 🧩

在本节课中,我们将学*如何从动态规划算法生成的表格中,重构出加权独立集问题的最优解本身,而不仅仅是其总权重值。上一节我们介绍了计算最优解权重的线性时间算法,本节中我们来看看如何利用该算法留下的“线索”,高效地重建出构成最优解的具体顶点集合。
概述
我们已有的算法能在线性时间内计算出路径图中加权独立集的最大总权重。然而,该算法仅输出一个数值(最优权重),并未告诉我们哪些顶点构成了这个最优集合。本教程将展示一种标准的动态规划重构技术,它能在不显著增加时间和空间开销的前提下,从已填充的动态规划表格中逆向推导出最优解。
重构算法原理
重构算法的核心思想基于原算法的正确性。原算法的每一行代码都在比较两种可能的候选解:
- 情况一:最优解不包含当前顶点
v_i,其值等于子问题G_{i-1}的最优解值。 - 情况二:最优解包含当前顶点
v_i,其值等于v_i的权重加上子问题G_{i-2}的最优解值。
原算法通过 max 操作选择两者中较大的值填入表格。这个选择过程本身就记录了“当前顶点是否应被包含在最优解中”的关键信息。因此,我们可以逆向扫描已填充的表格,通过检查每个条目是由哪种情况计算得出的,来逐步确定哪些顶点属于最优解。
重构算法步骤
以下是重构算法的具体步骤,它接收由前向算法填充的数组 A 作为输入。
- 初始化:创建一个空集合
S用于存放最优解的顶点。设置当前索引i = n(从最后一个顶点开始)。 - 逆向扫描:使用
while循环从右向左扫描数组A。 - 决策判断:在每一步,比较
A[i-1]与A[i-2] + w_i的大小。- 如果
A[i-1] >= A[i-2] + w_i,则意味着在计算A[i]时,情况一胜出。这表明顶点v_i不在最优解中。我们只需将索引i减 1,继续考察前一个顶点。 - 否则,意味着 情况二 胜出。这表明顶点
v_i在最优解中。我们将v_i加入集合S,并将索引i减 2(因为包含v_i后,需要跳过与之相邻的前一个顶点v_{i-1})。
- 如果
- 循环终止:当索引
i减少到 0 时,循环结束。 - 返回结果:最终,集合
S即为原图G的一个最大权重独立集。
其伪代码描述如下:
def reconstruct_optimal_set(A, weights):
S = set() # 用于存储最优解的顶点集合
i = len(A) - 1 # 从最后一个索引开始
while i >= 1:
if i == 1:
# 处理边界情况:只有第一个顶点
if weights[1] > 0:
S.add(v_1)
break
if A[i-1] >= A[i-2] + weights[i]:
# 情况一胜出,排除 v_i
i -= 1
else:
# 情况二胜出,包含 v_i
S.add(v_i)
i -= 2
return S
算法正确性与效率

- 正确性:算法的正确性可以通过归纳法证明。归纳步骤利用了与我们一直使用的相同的案例分析:对于子图
G_i,其最优解只有两种可能形式。重构算法通过检查前向算法留下的“决策记录”(即A[i]是由哪种情况计算得出的),准确地判断了当前顶点v_i是否应被包含,从而逐步拼凑出全局最优解。 - 运行时间:重构算法包含一个
while循环,最多迭代n次,每次迭代执行常数时间的工作。因此,就像前向填充算法一样,这个逆向重构过程也仅需 线性时间 O(n),非常高效。
总结
本节课中我们一起学*了动态规划中一个至关重要的技巧:解的重构。我们了解到,设计良好的动态规划算法在填充表格时,其决策过程本身就编码了最优解的结构信息。通过逆向追踪这些决策,我们可以在不存储完整中间解的情况下,高效地重建出最终的最优解。这种方法既节省了空间,又保持了算法的高效性,是动态规划实践中的标准做法。
118:动态规划原理 🧩

在本节课中,我们将学*动态规划范式的一般原理。我们将通过回顾路径图中最大权重独立集问题的线性时间算法作为具体实例,来阐述这些核心原则。
概述
动态规划是一种通过将复杂问题分解为一系列相互关联的子问题,并系统地解决这些子问题来求解原问题的方法。其关键在于定义合适的子问题,并找到它们之间的递推关系。
子问题的性质
一个有效的动态规划算法依赖于一组精心选择的子问题。这些子问题需要满足几个关键性质。
性质一:子问题数量可控
子问题的总数不应过大,因为算法至少需要处理每个子问题一次。在最佳情况下,解决每个子问题需要常数时间,因此子问题的数量直接决定了算法时间复杂度的下界。
在最大权重独立集的例子中,我们做得很好:我们只有 n+1 个子问题(对应图的每个前缀),从而实现了线性时间复杂度。
性质二:存在递推关系
这是动态规划的核心。必须存在“较小”子问题和“较大”子问题的概念。算法从最小的子问题开始,逐步解决更大的子问题。关键在于,对于任何一个给定的子问题,我们能够利用所有更小子问题的解,快速且正确地推导出当前子问题的解。
这种关系通常通过一个递推式来表达,它定义了当前子问题的最优解如何由更小子问题的最优解组合而成。
在我们的独立集算法中,递推关系如下:设 OPT[i] 为前 i 个顶点构成的子图 G_i 的最大权重独立集的总权重。那么:
OPT[i] = max(OPT[i-1], OPT[i-2] + w_i)
其中 w_i 是第 i 个顶点的权重。这个递推式表明,G_i 的最优解要么不包含顶点 i(继承 G_{i-1} 的解),要么包含顶点 i(则不能包含顶点 i-1,因此继承 G_{i-2} 的解并加上 w_i)。
一旦有了这样的递推式,它自然引出一个填表算法:我们创建一个表格,其中每个表项对应一个子问题的最优解,然后按照从小到大的顺序,利用递推式填充整个表格。
性质三:能解答原问题
在解决了所有子问题之后,我们必须能够从中得到原始问题的答案。这个性质通常会自动满足,因为在大多数情况下,原始问题本身就是最大的那个子问题。
在独立集的例子中,最大的子问题 G_n 就是原始图本身。因此,当我们填满表格后,最后一个表项 OPT[n] 就是我们要的答案。
设计动态规划算法的关键
以上概念目前可能有些抽象,我们将在后续课程中看到更多例子来加深理解。所有例子都将展示动态规划范式的强大力量和灵活性,这是你必须掌握的一项技术。
当你尝试设计自己的动态规划算法时,关键在于找出正确的子问题定义。如果你找准了子问题,其他步骤通常会以相当程式化的方式迎刃而解。
对于动态规划的初学者而言,与其凭空想象子问题,不如模仿我们在独立集问题中的推理过程:通过分析最优解的结构,来思考如何首次发现这些子问题。这是一个你可以复用的过程,以便将这种范式应用到你自己项目中出现的问题上。
关于“动态规划”名称的由来
你或许会好奇,为什么这种方法被称为“动态规划”。这里的“规划”并非指编写代码,它与“数学规划”或“线性规划”中的“规划”是同一个意思,更接*于“计划过程”的含义。

让我们听听动态规划的发明者之一,理查德·贝尔曼(我们将在课程稍后学*他的贝尔曼-福特算法)的解释。他在自传中谈到20世纪50年代发明此方法时的背景:
那时对数学研究来说并不是好年头。我在兰德公司工作。我们在华盛顿有一位非常有趣的绅士,名叫威尔逊,他是国防部长。实际上,他对“研究”这个词有一种病态的恐惧和憎恨……我不是随便用这个词,我用得很精确。如果有人在他在场时使用“研究”这个词,他的脸会涨红,变得很激动。
你可以想象他对“数学”这个词会有什么感觉。兰德公司受雇于空军,而空军的上司实质上是威尔逊。因此,我觉得我必须做点什么,来掩盖我在兰德公司内部实际上是在做数学这一事实。
用什么标题?什么名字?首先,我对计划和决策制定感兴趣。但是,“计划”这个词由于各种原因并不好。因此,我决定使用“规划”这个词。
“动态”作为一个形容词有一个非常有趣的属性:你不可能用“动态”这个词来表达贬义。试着想一些组合,看能否让它有贬义的意思。这是不可能的。
因此,我认为“动态规划”是个好名字。这是一个连国会议员都无法反对的东西。所以我就用它来涵盖我的活动。
总结
本节课我们一起学*了动态规划的一般原理。我们了解到,动态规划的成功依赖于定义一组数量可控、且能通过递推关系联系起来的子问题。算法通过系统地解决从最小到最大的所有子问题,并最终从最大子问题的解中得到原问题的答案。掌握动态规划的关键在于学会如何通过分析问题结构来定义合适的子问题。在接下来的课程中,我们将通过更多实例来巩固这一强大的算法设计范式。
119:背包问题 🎒

在本节课中,我们将学*动态规划的第二个应用实例:著名的背包问题。我们将展示如何沿用计算路径图最大独立集时使用的相同方法,来推导出这个问题的经典动态规划解法。
问题定义
背包问题的输入由 n 个物品组成。
每个物品 i 都有一个价值 vᵢ(对我们来说越大越好)和一个大小 wᵢ。
我们假设这两个值都是非负的。对于物品大小,我们额外假设它们是整数。
除了这 2n 个数字,我们还给定一个称为容量的数值 W。我们同样假设它是非负整数。
这些整数假设的作用将在后续说明。
在背包问题中,算法的任务是选择一个物品的子集。
我们的目标是最大化所选物品的总价值,即 ∑ vᵢ。
那么,是什么阻止我们选择所有物品呢?限制在于,所选物品的总大小必须不超过背包容量 W。
虽然可以想象一个窃贼带着容量为 W 的背包入室行窃的故事,但这实际上低估了该问题的重要性。背包问题非常基础,经常作为更大任务的子程序出现。本质上,每当你有一定量的资源预算,并希望以最聪明的方式使用它时,这就是一个背包问题。
设计动态规划算法
现在,让我们按照开发动态规划算法的步骤来思考。
动态规划解决方案的关键在于找出正确的子问题集合。我们将像处理最大独立集问题一样,通过对最优解进行思想实验,来推导背包问题的子问题。
这个思想实验的最终成果将是一个递推关系式,它告诉我们一个子问题的最优值如何依赖于更小子问题的最优值。
思想实验
首先,固定一个背包问题的实例,并让 S 表示一个最优解(即价值最大的可行解)。
我们之前的思想实验从一个内容无关的陈述开始:路径的最后一个顶点要么在最优解中,要么不在。那么,在背包问题中,什么是“最右顶点”的类比呢?与路径图不同,给定的物品没有内在的顺序性,它们只是一个无序集合。但将物品按 1, 2, 3, ..., n 的顺序来思考实际上是有用的。那么,“最右顶点”的类比就是最后一个物品 n。
因此,我们这里要使用的内容无关陈述是:要么最后一个物品 n 属于最优解 S,要么不属于。
我们将再次从简单的情况开始:当它不属于时。
在路径图问题中,我们论证了在类似情况下,如果我们从图中删除最右边的边,那么该图的最大权重独立集必须是最优的。这里的类比主张是:如果我们从背包实例中删除最后一个物品 n,集合 S 应该仍然是最优的。
论证过程完全相同,几乎是一个微不足道的反证法:如果在前 n-1 个物品中存在一个不同的解 S*,其价值比 S 还大,那么我们可以将其视为包含所有 n 个物品的、更优的背包可行解,但这与 S 的最优性假设相矛盾。

接下来,我们通过一个小测验来一起分析稍微复杂一些的情况二。
假设背包最优解确实使用了这最后一个物品 n。
现在,我们希望讨论这个解如何由某个更小子问题的最优解组合而成。如果我们打算删除最后一个物品,就不能直接讨论 S,因为 S 包含最后一个物品。因此,在讨论其最优性之前,我们需要从 S 中移除最后一个物品。这类似于独立集问题中,在讨论更小子问题的最优性之前,我们从最优解中移除最右边的顶点。
那么问题是:如果我们取最优解 S 并移除物品 n,那么剩余的解在什么意义上是最优的?换句话说,对于哪种背包实例(如果有的话),它是一个最优解?

正确答案是 C。
回到独立集问题,我们说如果移除最右边的顶点,那么剩下的部分对于移除最右边两个顶点后得到的残差独立集问题是最优的。在这里,当我们从最优解 S 中移除物品 n 时,结论是:我们得到的结果对于涉及前 n-1 个物品且剩余背包容量为 W - wₙ 的背包问题是最优的。也就是说,原始的背包总容量中,为第 n 个物品预留(或扣除)了空间。
在给出简要证明之前,让我先解释一下为什么其他几个选项不正确。
- 选项 B:希望你能快速排除。它不符合单位检查。W 是背包容量,单位是大小;vₙ 是物品价值,单位是货币。谈论这两者的差值没有意义,就像苹果和橘子。
- 选项 D:如果你担心可行性问题。从 S 中移除物品 n 后,剩余物品的总大小最多为 W - wₙ。因此,S - {n} 对于这个减少后的剩余容量 W - wₙ 确实是可行的。
- 选项 A:这是一个非常自然的猜测,但结果是不正确的。可能存在比 S - {n} 更聪明地使用前 n-1 个物品的方法,如果你拥有完整的背包容量 W 可以使用的话。这是一个更微妙的点,你可以作为一个很好的练*来说服自己 A 是错误的。
那么,为什么选项 C 是正确的呢?其精神与我们加权独立集思想实验中的情况二相同。
证明采用通常的反证法,类似于我们在加权独立集问题中情况二的论证。
假设存在一个比 S - {n} 更好的解 S*,用于剩余容量为 W - wₙ 的子问题。那么我们可以做什么来得出矛盾呢?
我们只需取 S(它只涉及前 n-1 个物品),然后将物品 n 加入其中。由于 S 的总大小最多为 W - wₙ,而物品 n 的大小为 wₙ,因此结果的总大小最多为 W。所以,将 S* 扩展加入物品 n 是一个可行解。
如果 S* 的价值大于 S - {n},那么包含 n 的 S* 的价值就大于 S。例如,如果 S 的总价值是 1100,其中 100 来自物品 n,那么 S - {n} 的价值是 1000。如果 S* 更好,价值为 1050,那么我们把 n 加回去,总价值就是 1150,这就与 S 的最优性(总价值仅为 1100)相矛盾。
请注意这里发生了什么:在考虑残差问题之前,我们从背包容量中扣除 wₙ,实际上是在为物品 n 预留缓冲空间。这就是为什么当我们把 n 加回解 S* 时,我们知道它是可行的。这类似于在独立集问题中,为了确保当我们把顶点 n 加回去时的可行性,我们删除了倒数第二个顶点作为缓冲。
思想实验的意义
这个思想实验的意义何在?其意义在于说明:最优解,无论它是什么,都只能有两种形式之一。
我们已经将候选解的范围缩小到两种可能性:
- 你直接继承少一个物品但容量相同的子问题的最优解。
- 你查看少一个物品且容量减少 wₙ 的子问题的最优解,然后将其扩展加入物品 n。
只有这两种可能性。
因此,如果我们知道这两种情况中哪一种是正确的,如果我们知道物品 n 是否在最优解中,那么我们就能以某种方式递归地计算出解的其他部分。
正如这足以让我们开始为加权独立集设计动态规划算法一样,对于背包问题也是如此。我将在下一个视频中向你展示。
总结
本节课中,我们一起学*了背包问题的定义,并通过思想实验分析了其最优解的结构。我们发现,最优解要么不包含最后一个物品,直接继承前 n-1 个物品在容量 W 下的最优解;要么包含最后一个物品,由前 n-1 个物品在容量 W - wₙ 下的最优解加上物品 n 的价值构成。这个关键的观察为我们下一节推导动态规划递推式奠定了基础。
120:动态规划算法 🧠

在本节课中,我们将学*如何将背包问题的最优子结构转化为递推关系,并最终实现一个动态规划解决方案。我们将从定义子问题开始,逐步构建递推公式,并最终通过伪代码实现算法。
子问题定义与递推关系
上一节我们讨论了最优解必须由更小子问题的最优解构成。现在,我们将其转化为递推关系。
首先,引入一些符号。用 V(i, x) 表示仅使用前 i 个物品,且总容量不超过 x 时,能获得的最大价值。这与独立集问题中的 G_i 类似,但这里我们使用两个索引,因为子问题可以通过减少物品数量或减少剩余容量两种方式变小。
根据上一节的讨论,最优解有两种可能形式:
- 直接继承少一个物品(即前 i-1 个物品)且容量不变(仍为 x)时的最优解。
- 决定使用第 i 个物品,获得其价值 v_i,然后将其与“使用前 i-1 个物品,且容量减少为 x - w_i(w_i 为第 i 个物品的重量)”时的最优解组合。
因此,递推关系如下:
V(i, x) = max( V(i-1, x), v_i + V(i-1, x - w_i) )
一个边界情况是:如果第 i 个物品的重量 w_i 大于当前允许的容量 x,则无法选择它,此时 V(i, x) = V(i-1, x)。
确定子问题范围
在第一步中,我们思考了最优解结构并推导出递推关系。现在,第二步是精确确定我们需要关心的所有子问题。
与路径图上的最大加权独立集问题类似,每次递归查找子问题解时,我们总是从物品列表的末尾移除物品。因此,我们需要考虑所有可能的前缀,即对于所有 i 值,考虑前 i 个物品构成的子问题。
然而,对于背包问题,子问题变小的第二种方式是减少剩余容量。回想我们的思维实验案例2,当我们想知道一个保证使用当前物品 i 的最优解时,必须在查找对应的子问题最优解之前减少容量。
这里我们利用输入假设:所有物品大小和背包容量 W 都是整数。因此,每次我们“剥离”掉的容量也是整数。在最坏情况下,我们需要考虑所有可能的剩余容量值:0, 1, 2, ..., 直到原始背包容量 W。
至此,我们明确了子问题的范围:它们由索引 i(物品数量)和 x(剩余容量)共同定义。
实现动态规划算法
现在我们已经完成了前两步:明确了子问题并找到了计算更大子问题解的公式。剩下的就是创建一个表格,并系统地使用递推关系填充它,从最小的子问题开始,直到解决最大的子问题。
以下是算法的伪代码。我们将使用一个二维数组 A 来存储子问题的解,因为子问题由两个索引定义。
初始化二维数组 A[0..n][0..W]
// 初始化:没有物品时,价值为0
For x = 0 to W:
A[0][x] = 0
// 系统地填充表格
For i = 1 to n:
For x = 0 to W:
// 情况1:不选第 i 个物品
case1 = A[i-1][x]
// 情况2:选第 i 个物品(前提是能装下)
if w_i <= x:
case2 = v_i + A[i-1][x - w_i]
else:
case2 = -∞ // 或一个很小的数,表示不可行
A[i][x] = max(case1, case2)
返回 A[n][W]
关键点:当我们需要求解特定 i 和 x 的子问题时,我们已经计算并存储了所有所需更小子问题的解(在之前的外层循环迭代中)。因此,我们可以通过常数时间查找来使用它们。
当双重循环完成后,数组 A 中 A[n][W] 位置的值就是我们想要的答案:在可以使用任何物品且总容量为 W 的条件下,能获得的最大价值。
算法分析与扩展
时间复杂度分析
算法的运行时间分析很简单:我们计算子问题的数量,并查看每个子问题需要做多少工作。
- 子问题由 i 和 x 索引。
- i 有 n+1 种选择(0 到 n)。
- x 有 W+1 种选择(0 到 W)。
- 因此,总共有 Θ(n * W) 个子问题。
- 对于每个子问题,我们只对先前计算出的解进行一次比较(常数时间工作)。
- 所以,总体运行时间为 O(n * W)。
正确性与解的重构
正确性的证明遵循与之前动态规划算法相同的模板:通过对问题规模进行归纳,并使用我们的案例分析(思维实验)来形式化地证明归纳步骤。
与之前的独立集算法类似,这个算法计算的是最优解的值,而不是最优解本身(它返回一个数字,而不是物品的子集)。但是,我们可以通过回溯填充好的数组 A 来重构出一个最优解。
重构思路:从最大的子问题(i=n, x=W)开始,查看填充该表项时使用了哪种情况(情况1或情况2)。
- 如果使用了情况1,则知道应该排除最后一个物品(第 n 个)。
- 如果使用了情况2,则知道应该包含最后一个物品,并且知道接下来应该回溯到哪个子问题(i=n-1, x=W - w_n)继续这个过程。
建议你在实践中尝试实现这个重构算法,这将帮助你更好地掌握动态规划范式中的解重构环节。
总结

本节课中,我们一起学*了如何为背包问题设计动态规划算法。我们从分析最优子结构并建立递推关系 V(i, x) = max( V(i-1, x), v_i + V(i-1, x - w_i) ) 开始。然后,我们确定了需要解决的子问题范围是所有 i(0到n)和所有 x(0到W)的组合。接着,我们通过一个双重循环系统地填充二维表格来实现算法,最终在 A[n][W] 中得到最优解的值。我们还分析了算法的时间复杂度为 O(n * W),并简要讨论了如何通过回溯来重构出具体的物品选择方案。
121:46_04_04_示例回顾-选学 📚

在本节课中,我们将通过一个具体的例子,回顾动态规划算法在解决背包问题中的应用。我们将一步步填充动态规划表格,并演示如何通过回溯来重构最优解。
我们已经掌握了两种动态规划算法。我们学会了如何计算路径中的加权独立集,也了解了解决著名背包问题的动态规划方案。但在继续学*更多有用且著名的动态规划算法之前,让我们先停下来做一个检查。我们将通过一个动态规划算法解决背包问题的完整示例,以确保一切都清晰明了。

首先,让我们回顾一下背包算法的关键点。我们有一个二维数组 A。初始化时,当 i = 0(即不能使用任何物品)时,最优解的值自然是 0。然后,我将重写前几个视频中提到的递推关系。
递推公式:
A[i][x] = max( A[i-1][x], v_i + A[i-1][x - w_i] )

在算法的主循环中,当考虑一个给定的物品 i 和剩余的背包容量 x 时,我们比较两种方案并取较优者:要么继承不包含物品 i 时的最优解(即 A[i-1][x]),要么选择物品 i,获得其价值 v_i,但剩余容量从 x 减少到 x - w_i,并查找对应子问题的最优解 A[i-1][x - w_i]。
具体示例


让我们看一个包含四个物品的实例。初始背包容量 W = 6。四个物品的价值和重量如下表所示:
| 物品编号 (i) | 价值 (v_i) | 重量 (w_i) |
|---|---|---|
| 1 | 3 | 4 |
| 2 | 2 | 3 |
| 3 | 4 | 2 |
| 4 | 4 | 3 |
我们将采用最直接的动态规划算法实现,即显式地构建一个二维数组 A。索引 i 的范围是 0 到 n(物品数),索引 x 的范围是 0 到 W(初始容量)。虽然填充这个表格时可以进行很多优化,但为了确保基本算法完全清晰,我们现在先坚持使用朴素实现。
以下是填充动态规划表格的步骤说明。
初始化
首先进行初始化。当 i = 0(没有物品可用)时,无论剩余容量 x 是多少,最优值都是 0。因此,我们将表格最左边一列(对应 i = 0)全部填充为 0。
主循环填充
接下来进入主循环。外层循环依次考虑每个物品 i(即从左到右处理每一列)。对于固定的 i,内层循环考虑所有可能的剩余容量 x(从 0 到 W),即从下到上填充该列。
以下是逐列填充的详细过程:
第1列 (i = 1,物品1:v=3, w=4)
物品1的重量是4。因此,当剩余容量 x 小于4时(即 x = 0, 1, 2, 3),我们无法选择物品1,只能继承左边一列(i=0)的值,即 0。

当 x = 4 时,我们有了选择:可以继承左边的 0,或者选择物品1(价值3)并加上子问题 A[0][0] 的值(0)。显然,选择物品1得到价值 3 更优。
当 x = 5, 6 时,同样可以选择物品1,得到价值 3(因为选择物品1后,剩余容量不足以再装其他物品,子问题值仍为0)。因此,该列顶部两行也填入 3。
第2列 (i = 2,物品2:v=2, w=3)
物品2的重量是3。因此,当 x = 0, 1, 2 时,无法选择物品2,只能继承左边一列(i=1)的值(分别是0, 0, 0)。
当 x = 3 时,我们可以选择物品2(价值2)并加上 A[1][0] 的值(0),得到2。这优于继承左边 A[1][3] 的值(0)。因此填入 2。
当 x = 4 时,出现第一个有趣的决策。我们有两个非平凡选项:
- 继承左边
A[1][4]的值:3。 - 选择物品2(价值2)并加上
A[1][1]的值(0),得到 2。
显然,继承左边的 3 更优。
当 x = 5, 6 时,同样继承左边 A[1][5] 和 A[1][6] 的值(都是3)更优。因此该列顶部两行填入 3。
第3列 (i = 3,物品3:v=4, w=2)

物品3的重量是2。因此,当 x = 0, 1 时,无法选择物品3,继承左边 A[2][0] 和 A[2][1] 的值(0, 0)。

当 x = 2 时,选择物品3(价值4)优于继承左边的0。填入 4。

当 x = 3 时,选择物品3(价值4)优于继承左边的2。填入 4。
当 x = 4 时,选择物品3(价值4)优于继承左边的3。填入 4。
当 x = 5 时:
- 选项1:继承左边
A[2][5]的值:3。 - 选项2:选择物品3(价值4)并加上
A[2][3]的值(2),得到 6。
选择物品3更优,填入 6。
当 x = 6 时:
- 选项1:继承左边
A[2][6]的值:3。 - 选项2:选择物品3(价值4)并加上
A[2][4]的值(3),得到 7。
选择物品3更优,填入 7。
第4列 (i = 4,物品4:v=4, w=3)
物品4的重量是3。因此,当 x = 0, 1, 2 时,无法选择物品4,继承左边 A[3][0]、A[3][1]、A[3][2] 的值(0, 0, 4)。
当 x = 3 时:
- 选项1:继承左边
A[3][3]的值:4。 - 选项2:选择物品4(价值4)并加上
A[3][0]的值(0),得到 4。
两者相等,任选其一,填入 4。
当 x = 4 时:
- 选项1:继承左边
A[3][4]的值:4。 - 选项2:选择物品4(价值4)并加上
A[3][1]的值(0),得到 4。
两者相等,填入 4。
当 x = 5 时:
- 选项1:继承左边
A[3][5]的值:6。 - 选项2:选择物品4(价值4)并加上
A[3][2]的值(4),得到 8。
选择物品4更优,填入 8。
当 x = 6 时:
- 选项1:继承左边
A[3][6]的值:7。 - 选项2:选择物品4(价值4)并加上
A[3][3]的值(4),得到 8。
选择物品4更优,填入 8。
至此,我们完成了动态规划表格的前向填充。表格的右上角 A[4][6] = 8 就是整个背包问题的最优解值。
重构最优解 🧩
在完成前向填充后,如果我们想得到具体的最优物品组合,可以通过反向回溯来实现。
我们从最大的子问题开始,即 A[4][6]。我们询问:这个值 8 是通过递推关系的哪个分支得到的?这指导我们判断是否选择了物品4。
查看计算过程,A[4][6] 并非直接继承自左边的 A[3][6](值为7),而是通过选择物品4(价值4)加上 A[3][3](值为4)得到的。这意味着最优解中包含物品4。
确定了物品4在解中后,我们回溯到用于构建当前解的那个子问题,即 A[3][3](因为 6 - w_4 = 3)。
接着,我们同样询问 A[3][3] 的值 4 是如何得到的。它并非继承自左边的 A[2][3](值为2),而是通过选择物品3(价值4)加上 A[2][1](值为0)得到的。这意味着最优解中也包含物品3。
然后,我们回溯到 A[2][1](因为 3 - w_3 = 1)。此时,剩余容量很小,A[2][1] 的值 0 是直接继承自左边的 A[1][1],这意味着我们没有选择物品1或物品2。

继续向左回溯到 A[1][1],它同样继承自 A[0][1](值为0)。当我们到达 i = 0 时,回溯结束。
因此,我们重构出的最优解是:选择物品3和物品4。总价值为 4 + 4 = 8,与动态规划表格的结果一致。
总结 ✨
本节课中,我们一起通过一个具体的背包问题实例,完整演练了动态规划算法的两个核心步骤:
- 前向填充:我们系统地使用递推公式
A[i][x] = max(A[i-1][x], v_i + A[i-1][x-w_i])填充了动态规划表格,最终在A[n][W]处得到了问题的最优值。 - 反向回溯:我们从最终的最优值出发,根据表格中每个值是如何计算出来的(是继承还是选择当前物品),逆向追踪,最终重构出组成最优解的具体物品集合。
这个例子清晰地展示了动态规划如何将复杂问题分解为重叠子问题,并通过存储子问题的解来避免重复计算,从而高效地找到全局最优解。
122:最优子结构 🔍

在本节课中,我们将学*动态规划中的一个核心概念——最优子结构,并以序列比对问题为例,详细解析如何识别并利用最优子结构来设计高效算法。
概述
序列比对问题是计算基因组学中的一个基础问题,其目标是为两个给定的字符串找到一个“最佳”比对方式,以最小化总惩罚值。我们将运用动态规划的通用“配方”,通过分析最优解的结构,将其分解为更小的子问题,从而推导出高效的递归解法。
序列比对问题回顾
给定两个字符串 X 和 Y,长度分别为 m 和 n。同时,我们已知插入一个空位的惩罚值,以及任意两个字符不匹配的惩罚值(通常,字符与自身匹配的惩罚为0)。
一个可行的比对方案,是通过在两个字符串中插入空位,使它们变得等长。我们的目标是,在所有指数级数量的可能比对中,找到总惩罚值最小的那个。总惩罚值是所有空位惩罚与所有不匹配惩罚之和。
应用动态规划“配方”
动态规划解决方案的关键在于确定正确的子问题集合。我们将通过分析最优解的结构来推导这些子问题。
上一节我们回顾了问题定义,本节中我们来看看如何分析最优解的结构。
最优解的结构分析
让我们进行一个思想实验:假设有人已经将最优比对方案交给了我们,它会是什么样子?我们可以将最优比对可视化:上方是字符串 X 及其插入的空位,下方是字符串 Y 及其插入的空位,两者长度相等。
类比之前解决独立集和背包问题的经验,我们的案例分析总是聚焦于最优解的“最后部分”。在这里,我们同样可以聚焦于最优比对的最后一个位置。
以下是最后一个位置可能出现的三种相关情况:
- 无空位:匹配 X 的最后一个字符
x_m与 Y 的最后一个字符y_n。 - 上为空位:匹配 Y 的最后一个字符
y_n与一个空位(即x_m与空位对齐)。 - 下为空位:匹配 X 的最后一个字符
x_m与一个空位(即y_n与空位对齐)。
注意:我们不考虑上下同时为空位的情况,因为删除这两个空位总能得到一个惩罚值更低(或相等)的更好比对,因此最优解中不会出现这种情况。
这个案例分析背后的希望是,我们能将寻找最优解的可能性,归结为仅仅三个候选方案,每个候选对应最后位置的一种情况。
构建候选解与子问题
对于上述三种情况,最优解必然由某个更小子问题的最优解以简单方式扩展而成。我们定义:
X' = X去掉最后一个字符x_mY' = Y去掉最后一个字符y_n
以下是针对三种情况的断言:
- 情况1(匹配
x_m与y_n):最优比对必然由X'和Y'的一个最优比对,加上末尾的(x_m, y_n)匹配列构成。 - 情况2(
x_m与空位匹配):最优比对必然由X'和Y的一个最优比对,加上末尾的(x_m, -)列构成。 - 情况3(
y_n与空位匹配):最优比对必然由X和Y'的一个最优比对,加上末尾的(-, y_n)列构成。
这个断言意味着,如果我们知道最优解属于哪种情况,那么问题就简化为求解一个更小的子问题。
最优子结构证明(以情况1为例)
现在我们来证明情况1下的断言。其他两种情况证明类似。
证明(反证法):
- 假设我们有一个
X和Y的最优比对A*,其最后一个位置匹配(x_m, y_n)。 - 去掉最后一列,我们得到
X'和Y'的一个比对A'。设其惩罚值为P。 - 为了推出矛盾,假设
A'不是X'和Y'的最优比对。那么存在一个X'和Y'的更好比对B',其惩罚值P* < P。 - 现在,我们可以用
B'来构造一个新的X和Y的比对B:先采用B'对齐X'和Y',然后在末尾加上(x_m, y_n)这一列。 - 计算
B的总惩罚值:总惩罚(B) = P* + α(x_m, y_n),其中α是匹配惩罚函数。 - 而原最优比对
A*的总惩罚值为:总惩罚(A*) = P + α(x_m, y_n)。 - 由于
P* < P,显然总惩罚(B) < 总惩罚(A*)。 - 这与
A*是X和Y的最优比对这一假设矛盾。
因此,我们的假设不成立,A' 必须是 X' 和 Y' 的一个最优比对。证毕。
这个证明清晰地展示了最优子结构性质:一个问题的最优解包含其子问题的最优解。
总结

本节课中,我们一起学*了:
- 序列比对问题的定义与目标。
- 如何通过聚焦于最优解的最后位置,进行案例分析,将其归结为三种可能情况。
- 如何根据每种情况,断言最优解必然由某个更小子问题的最优解扩展而成。
- 通过反证法证明了这一断言,从而确立了该问题的最优子结构性质。
有了最优子结构,我们就能自然地写出递归关系(递推式),进而通过动态规划自底向上地高效求解序列比对问题。下一节,我们将基于此推导出具体的动态规划算法。
123:动态规划算法 🧩

在本节课中,我们将学*如何为序列比对问题设计一个高效的动态规划算法。我们将从理解最优解的结构开始,定义子问题,推导递推关系,并最终构建出完整的算法。
从最优解结构到子问题定义
上一节我们分析了序列比对问题的最优解结构,本节中我们来看看如何基于这个结构来定义子问题。
我们思考了对字符串X和Y的最优比对,并注意到其最后一个位置的内容只有三种情况:没有空位、上方有空位或下方有空位。在情况一中,字符X_M和Y_N匹配,我们证明了由此产生的、针对更小字符串X‘和Y’的比对本身也必须是最优的。在情况二中,字符X_M与一个空位匹配,由此产生的、针对X‘和Y的比对本身也必须是最优的。在情况三中,字符Y_N与一个空位匹配,由此产生的、针对X和Y‘的比对本身也必须是最优的。
这种断言表明,序列比对问题的最优解仅依赖于三个更小子问题的解。这些子问题涉及从原始字符串右侧“剥离”一个或两个字符。这与我们之前在线图的独立集问题和背包问题中遇到的情况类似。在独立集问题中,我们只关心通过移除一或两个顶点得到的子问题,即原始线图的前缀。在背包问题中,我们通过移除最后一件物品并可能减少背包容量来得到子问题,因此我们使用两个参数来跟踪子问题:物品的前缀和剩余的背包容量。
在序列比对问题中,子问题通过从第一个字符串和/或第二个字符串中剥离字符而变小。因此,我们再次使用两个不同的参数:一个用于跟踪从第一个字符串剥离了多少,另一个用于跟踪从第二个字符串剥离了多少。我们关心的所有相关子问题都涉及两个原始输入字符串X和Y的前缀。也就是说,我们关心的唯一子问题具有形式X_i和Y_j,其中X_i表示X的前i个字母,Y_j表示Y的前j个字母。
推导递推关系
现在,让我们从用于动态规划算法的子问题,转向我们将要使用的递推关系。这个递推关系本质上将我们对最优解及其如何依赖于更小子问题解的理解,编译成一个易于使用的数学公式。
我将使用符号P(i, j)来表示对应子问题(即涉及前缀X_i和Y_j的问题)的最优解值。那么,对于给定的正整数i和j,P(i, j)是什么?有三种可能性。
以下是三种情况对应的公式:
- 情况一:最优比对的最后一个位置没有空位。它匹配前缀
X_i的最后一个字符(即x_i)和前缀Y_j的最后一个字符(即y_j),然后重用更小字符串X_{i-1}和Y_{j-1}的最优比对。- 公式:
P(i, j) = P(i-1, j-1) + penalty(x_i, y_j)
- 公式:
- 情况二:第一个字符串的最后一个字母
x_i与一个空位匹配。在这种情况下,对应比对的总惩罚是一个空位的惩罚,加上X的前i-1个字母与Y的前j个字母的最优比对惩罚。- 公式:
P(i, j) = P(i-1, j) + gap_penalty
- 公式:
- 情况三:第二个字符串的最后一个字母
y_j与一个空位匹配。我们支付一个空位的惩罚,然后支付X的前i个字母与Y的前j-1个字母的最优比对惩罚。- 公式:
P(i, j) = P(i, j-1) + gap_penalty
- 公式:
我们知道最优解必须是这三种情况之一,但我们不知道是哪一种。因此,在递推关系中,我们实际上将对这三种结果进行暴力搜索,我们只需选择三种可能性中的最小值。
递推关系总结如下:
P(i, j) = min(
P(i-1, j-1) + penalty(x_i, y_j),
P(i-1, j) + gap_penalty,
P(i, j-1) + gap_penalty
)
其正确性直接源于我们之前对最优解必须呈现何种样式的理解。
处理边界情况
在我们陈述算法之前,让我们确保正确处理边界情况,即当i或j等于零时的基本情况。
具体来说,P(i, 0)和P(0, i)的值是多少(其中i是某个非负整数)?
P(i, j)表示X的前i个字母与Y的前j个字母的最优比对的总惩罚。考虑P(i, 0),这意味着将X的前i个字母与Y的前0个字母(即空字符串)进行比对。将任何字符串与空字符串匹配的最佳方式是向空字符串中插入空位以使长度相等。如果字符串长度为i,则需要插入i个空位。该比对的惩罚就是i乘以单个空位的惩罚。因此,P(i, 0) = i * gap_penalty。同理,P(0, j) = j * gap_penalty。
动态规划算法
现在我们可以给出算法了。与所有动态规划算法一样,一旦知道了子问题和关联其解的递推关系,剩下的就很简单了:我们只需系统地从小到大解决所有子问题。
我们将使用一个二维数组A来跟踪所有这些子问题的解。之所以使用二维数组,是因为我们有两个独立的参数来跟踪子问题的大小:一个用于处理X的字母数,另一个用于处理Y的字母数。这与背包问题类似,在那里我们也用两个维度来跟踪涉及的物品数量和剩余的背包容量。
我们已经明确了基本情况,因此我们只需在预处理步骤中解决它们。如果两个索引中有一个为零,则最优解值就是空位惩罚乘以非零索引的值。
现在我们进入双重循环。由于数组有两个索引,我们需要双重循环。每当我们处理一个子问题时,我们只需评估递推关系,调用已计算的更小子问题的解。
在编写动态规划算法代码时,你应该始终进行一项完整性检查:查看递推关系的右侧,查看你声称已解决的、用于解决当前子问题的那些子问题,确保你确实已经解决了那些子问题。在本例中,我们没问题,因为相关子问题的索引都小于我们当前正在填充的条目。确实,三个相关的子问题A[i-1][j-1]、A[i-1][j]和A[i][j-1]都已在双重循环的早期迭代中被计算出来,它们就在那里等待以常数时间被查找。
算法伪代码如下:
def sequence_alignment(X, Y, gap_penalty, mismatch_penalty):
m = len(X)
n = len(Y)
# 初始化二维数组 A
A = [[0] * (n+1) for _ in range(m+1)]
# 初始化边界条件
for i in range(m+1):
A[i][0] = i * gap_penalty
for j in range(n+1):
A[0][j] = j * gap_penalty
# 填充数组
for i in range(1, m+1):
for j in range(1, n+1):
match_cost = A[i-1][j-1] + (0 if X[i-1] == Y[j-1] else mismatch_penalty)
gap_in_X = A[i-1][j] + gap_penalty
gap_in_Y = A[i][j-1] + gap_penalty
A[i][j] = min(match_cost, gap_in_X, gap_in_Y)
return A[m][n] # 最优比对的总惩罚
算法正确性与运行时间分析
一旦你弄清楚了动态规划解决方案的关键要素(即子问题和递推关系),为什么算法会正确以及其运行时间是多少就几乎是不言自明的。
为什么算法正确? 即为什么算法终止时,每个条目A[i][j]都等于对应子问题的真实最优惩罚P(i, j)?这仅仅是因为我们的递推关系是正确的(这是所有难点所在),然后我们只是系统地解决了所有子问题。形式上,如果你喜欢,可以通过归纳法来证明。
运行时间 很容易评估。在这个双重循环的每次迭代中,我们只做常数量的工作:只需在常数时间内查找三个值并进行几次比较。有多少次循环?外循环有m次迭代,内循环有n次迭代。因此,总工作量是m和n的乘积,即运行时间与两个字符串长度的乘积成正比,为 O(m * n)。
重构最优比对
根据应用场景,你可能满足于让算法为你计算最优比对的NW分数(总惩罚),或者你可能实际上对比对本身感兴趣。正如我们在线图的独立集问题中讨论的那样,通过回溯已填充的表格,你确实可以重构出一个最优解。
让我简要介绍一下其工作原理,它将遵循相同的模板。你可以私下思考其具体细节。
假设你已经运行了上一张幻灯片上的算法,并填充了这个二维数组A的所有条目。现在我们要进行回溯。我们从哪里开始回溯这个已填充的表格?我们从我们实际关心的问题开始,即最大的问题A[m][n],这是我们想要比对的那个。
我们知道这个最优比对是三种候选情况之一,我们知道该比对最后一个位置的内容有三种可能的情况。此外,当我们填充表格的这个条目时,我们明确比较了三种可能性以找出哪一种是最好的。因此,也许在前向传递过程中,我们实际上缓存了比较结果;或者在最坏情况下,我们可以返回去重新计算,找出是三种情况中的哪一种被用来填充这个条目。根据三种候选情况中的哪一种被使用,它告诉我们最优比对的最后一个位置应该是什么内容。
- 如果情况一被用来填充这个条目,我们应该匹配
x_m和y_n。 - 如果情况二被用来填充这个条目,我们应该匹配
x_m与一个空位。 - 如果情况三被用来填充这个条目,我们应该匹配
y_n与一个空位。
如果出现平局,我们可以任意选择其中任何一个,所有选择都将导致最优比对。

当然,在确定了最后一个位置的操作后,我们就有了一个涉及X'和/或Y'的诱导子问题,这告诉我们应该转到表格的哪一个前一个条目。然后我们重复这个过程:再次找出是三种情况中的哪一种被用来填充这个条目,这告诉我们如何填充比对的下一个最右侧位置。我们一直持续这个过程,直到我们“掉出”表格。
当你“掉出”表格时,你该怎么做?一旦索引i或j一路降到零,现在你就没有选择了:现在其中一个字符串是空的,而另一个还有一些符号,所以你应该只插入适当数量的空位以使长度相等。
一件相当巧妙的事情是,这个回溯过程是高效的。事实上,它通常比前向传递高效得多。对于前向传递,你必须填充所有m * n个条目。但是在这个回溯过程中,每次回溯时,两个索引中至少有一个会递减,这意味着你将在 O(m + n) 时间内完成回溯,并得到原始两个字符串的最优比对。
本节课中我们一起学*了序列比对问题的动态规划解决方案。我们从分析最优解的结构出发,定义了基于字符串前缀的子问题,并推导出了关键的递推关系。基于此,我们构建了一个系统填充二维表格的算法,其运行时间为O(m*n)。最后,我们还了解了如何通过回溯已填充的表格,在O(m+n)时间内重构出具体的最优比对序列。
124:最优二叉搜索树问题定义 🎯

在本节课中,我们将学*动态规划范式的一个更复杂的应用:计算最优二叉搜索树。我们将探讨如何构建一个搜索树,使其在给定键值访问概率的情况下,平均搜索时间最小化。
问题背景与动机 🌲
上一节我们介绍了动态规划的基本思想,本节中我们来看看它在搜索树优化问题上的具体应用。首先,我们假设你已经掌握了二叉搜索树数据结构的基础知识。如果需要复*,可以回顾课程第一部分的相关内容。
二叉搜索树存储对象,每个对象有一个来自全序集合的键。搜索树性质规定:对于树中任意一个键值为 x 的节点,其左子树中的所有键必须小于 x,其右子树中的所有键必须大于 x。这个性质必须在树的每个节点上同时成立。
搜索树性质的目的是使搜索操作像在有序数组中二分查找一样直观。例如,如果你要查找键为 17 的对象,从根节点开始,根据根节点的键值决定向左还是向右递归搜索。
最初在讨论红黑树等平衡二叉搜索树时我们提到,对于一组给定的键,存在许多不同的有效搜索树。一个自然的问题是:在所有可能的搜索树中,哪一棵是最好的?
你可能会觉得似曾相识,因为在讨论红黑树时我们已经从某个角度提出并回答了这个问题。当时我们认为,最好的选择是保持树的高度尽可能小的平衡二叉搜索树,从而使最坏情况下的搜索时间(与高度成正比)尽可能小,即与树中对象数量的对数成正比。
但现在,让我们做出与讨论霍夫曼编码时类似的假设:我们实际上拥有关于树中每个项目被搜索频率的准确统计数据。例如,我们可能知道项目 X 将被搜索 80% 的时间,而 Y 和 Z 各占 10%。在这种情况下,我们能否改进完全平衡的搜索树方案?
一个具体例子 🔍
为了让问题更具体,我们比较两个候选方案:
- 平衡树:
Y为根,X和Z为子节点。 - 链式树:
X为根,Y是其右子节点,Z是Y的右子节点。
假设搜索频率为:X: 80%, Y: 10%, Z: 10%。一个节点的搜索时间定义为从根节点到找到该节点所经过的节点总数(包括目标节点本身)。例如,根节点的搜索时间为 1。
以下是两种树的平均搜索时间计算:
-
平衡树:
- 搜索
X(80%概率)需经过Y和X,时间为2。贡献值:0.8 * 2 = 1.6 - 搜索
Y(10%概率)时间为1。贡献值:0.1 * 1 = 0.1 - 搜索
Z(10%概率)需经过Y和Z,时间为2。贡献值:0.1 * 2 = 0.2 - 总加权搜索时间:
1.6 + 0.1 + 0.2 = 1.9
- 搜索
-
链式树:
- 搜索
X(80%概率)时间为1。贡献值:0.8 * 1 = 0.8 - 搜索
Y(10%概率)需经过X和Y,时间为2。贡献值:0.1 * 2 = 0.2 - 搜索
Z(10%概率)需经过X、Y和Z,时间为3。贡献值:0.1 * 3 = 0.3 - 总加权搜索时间:
0.8 + 0.2 + 0.3 = 1.3
- 搜索
这个例子揭示了一个有趣的算法机会:当访问频率不均匀时,明显的“平衡”解决方案未必是最优的。如果能让访问频率极高的项目更靠*根部以减少搜索时间,像链式树这样的不平衡树可能更好。因此,核心问题是:给定一组项目和已知的访问频率,哪棵搜索树能使平均搜索时间最小化?
形式化问题定义 📝
我们被告知有 n 个对象需要存储在搜索树中,并且知道每个对象的访问频率。为了简化表示,假设项目按键值从小到大命名为 1 到 n。p_i 表示搜索具有第 i 小键值的项目的频率。
你可能会好奇这些频率从何而来。这取决于具体应用。有些应用没有这类统计数据,这时你可能需要转向通用的平衡二叉搜索树解决方案(如红黑树),以保证每次搜索都相对较快。但也有很多应用可以获取相当准确的搜索频率统计数据,例如拼写检查器。通过扫描文档,你可以统计不同单词的查找频率,并利用这些估计为未来的文档构建高度优化的二叉搜索树。如果频率随时间变化,你可以定期(如每天或每周)根据最新统计数据重建搜索树。
无论如何,如果你有幸拥有这些统计数据,你的目标就是构建一棵搜索树,它既要满足搜索树性质,又要使平均搜索时间尽可能小。
以下是平均搜索时间的公式及一些符号定义:
- 用
C(T)表示提议的搜索树T的(加权)平均搜索时间。 - 本课程我们专注于所有搜索都成功的情况(只搜索树中存在的键)。算法可以轻松扩展到包含不成功搜索的情况。
- 如果只有成功搜索,那么我们只对树中存储的
n个元素求平均。
公式如下:
C(T) = Σ (p_i * [search time for key i in T])
其中,在树 T 中搜索键 i 的时间等于该节点在树中的深度加 1。例如,如果键在根节点,深度为 0,搜索时间记为 1。
一个次要的说明:为方便起见,我们不要求 p_i 的和为 1。它们可以是任意正数。因此,我们有时称 C(T) 为加权搜索时间而非平均搜索时间。但在思考时,你可以将其视为和为 1 的概率这一典型特例。
例如,当 p_i 是概率(和为 1)时,我们可以将红黑树作为参考基准。但正如所见,当这些概率不均匀时,通常可以做得更好。本计算问题的要点就是利用给定概率中的非均匀性,构建可能的最优不平衡搜索树。
与霍夫曼编码的异同 ⚖️
许多同学可能已经注意到最优二叉搜索树问题与我们之前在贪心算法部分解决的霍夫曼编码问题之间的相似性。霍夫曼编码在所有前缀无关的二进制编码中最小化平均编码长度。
以下是两个问题的相似之处和关键区别,特别是为什么我们不能直接重用霍夫曼算法来解决最优二叉搜索树问题。
相似之处:
在两个问题中,算法的输出形式都是二叉树,目标都是(大致上)最小化关于所提供频率的平均深度。在霍夫曼编码中,对象是字母表中的字符;在二叉搜索树中,对象是来自全序集合的带键项目。
重要区别:
- 约束条件不同:
- 在霍夫曼编码问题中,输出必须是前缀无关编码。用树的语言来说,这意味着被编码的符号必须对应输出树的叶子节点,不能对应内部节点。
- 在最优二叉搜索树问题中,我们没有前缀无关的约束。树中的每个节点(无论是叶子还是内部节点)都可以被标记为一个对象。但我们有一个不同的、看似更难的约束需要处理:搜索树性质。
- 排序要求:
- 在霍夫曼编码中,字母表的符号没有顺序,谈论“小于”没有意义。
- 在最优二叉搜索树中,我们被赋予了这些键,并且它们存在全序关系。我们输出的树必须满足搜索树性质:对于输出树中的每个节点,其左子树中的所有键必须小于该节点的键,右子树中的所有键必须大于该节点的键。这是一个我们必须满足的硬性约束。
这个约束的“更难”之处在于,没有贪心算法(包括霍夫曼算法)能够解决最优二叉搜索树问题。相反,我们必须转向更复杂的工具——动态规划,来设计一个计算最优二叉搜索树的有效算法。我们将在下一个视频中开始开发这个解决方案。

总结 📚
本节课中,我们一起学*了最优二叉搜索树问题的定义。我们了解到,当键的访问频率已知且不均匀时,平衡树不一定是最优选择。问题的目标是构建一棵满足二叉搜索树性质的树,以最小化加权平均搜索时间 C(T) = Σ p_i * (depth(i) + 1)。虽然此问题与霍夫曼编码有相似之处,但由于必须遵守搜索树性质这一更复杂的约束,我们需要使用动态规划而非贪心算法来求解。下一节我们将开始探讨如何使用动态规划解决这个问题。
125:最优子结构


在本节中,我们将探讨如何解决最优二叉搜索树问题。我们已经正式定义了该问题,现在将思考如何求解。在确定使用动态规划作为尝试的范式后,我们将按照常规方式进行:从最优解中寻找线索,探究它如何由更小子问题的最优解构成。
首先,让我们回顾一下问题的正式描述。我们有 n 个对象需要存储在搜索树中。为简便起见,我们按它们的键值顺序将其命名为 1, 2, 3, ..., n。同时,我们被赋予反映不同对象被搜索频率的权重,即 P1 到 PN,它们是正数。通常我们认为这些概率之和为 1,但事实上我们不会使用这个性质,它们可以是任意正数。目标是输出一个满足二叉搜索树性质、包含所有对象 1 到 n 的搜索树,并且在所有这样的搜索树中,最小化加权搜索时间,即所有对象 i 的 概率(i) * (深度(i) + 1) 之和。
如果你因为贪心算法成功解决了看似相似的最优前缀码问题(霍夫曼编码)而感到自信,我想花点时间指出,贪心算法不足以、也不正确来解决最优二叉搜索树问题。
如果我们设计一个贪心算法,什么样的直觉会引导出特定的贪心规则呢?观察目标函数,很明显我们希望访问频率高的对象位于或接*根节点,而访问频率低的对象位于树的底层,如叶子节点。
那么,我们如何将这种直觉转化为贪心算法呢?一种可能受霍夫曼算法成功启发的思路是采用自底向上的方法。非正式地说,我们希望从最底层的叶子节点开始,那里放置访问频率最低的对象。然而,任何合理实现这种自底向上贪心规则的方法都不会奏效。让我展示一个简单的反例。
假设我们有四个对象 1, 2, 3, 4。右边粉色部分展示了两种可能适用于这四个键的有效搜索树。假设频率如下:对象 1 被搜索的概率是 2%,对象 2 是 23%,对象 3 是 73%(大部分时间),对象 4 是 1%。任何坚持将最低频率对象放在树最底层的贪心算法都不会产生右边的树,因为右边的树中 2% 的对象比 1% 的对象更深。相反,这种贪心算法可能会产生左边的树,它以对象 2 为根,对象 4 在深度为 2 的最底层。但你应该能轻易说服自己,对于这些概率,右边的树才是最优的,因为对象 3 是大部分时间被搜索的,你希望它在根节点,而不是对象 2。
我意识到这里有些非正式,但我希望你能理解,一个朴素的自底向上贪心算法实现(如果你仔细想想,这正是我们在霍夫曼算法中所做的)在这里行不通。同样,自顶向下的方法也是如此。最简单的自顶向下方法可能是取最常被搜索的对象放在根节点,然后在该最常访问元素下递归地构建适当的左右子树。
让我再次非正式地展示一个类似的反例。我们将使用完全相同的四个对象和完全相同的两棵树,但改变数字。现在,假设对象 1 几乎不被搜索,仅占 1% 的时间。其他三个对象每个被搜索的频率大约各占三分之一。但让我打破平局,使对象 2 成为最常被搜索的,占 34%。在这种情况下,贪心算法会将 34% 的节点放在根节点,而实际上应该发生的是,你希望为对象 2、3、4 构建一个完美平衡的子树,因为每个对象大约占搜索的三分之一。所以,给对象 3 分配 33% 的搜索,对象 4 分配 32% 的搜索。同样,我留给你去验证这确实是一个反例:左边贪心算法产生的树的平均搜索时间大约为 2,而右边树的平均搜索时间大约为 5/3。我们希望产生右边的树,但这里提出的贪心算法会产生左边的树。
当然,这并没有穷尽所有可能尝试的贪心算法,你可以尝试其他方法,但它不会成功。目前没有已知的贪心算法能成功解决最优二叉搜索树问题。
因此,特别是如果我们专注于自顶向下的方法,根节点的选择、在最上层做什么选择,对两个不同子问题的形态有着难以预测的影响。这不仅阻碍了自顶向下的贪心方法,也阻碍了朴素的分治方法。例如,如果我们只是想将键分成前半部分和后半部分,递归计算这两个部分各自的最优二叉搜索树,然后将它们重新组合,搜索树性质要求我们必须用一个根节点来连接这两个子解,这个根节点是两个子问题之间的中位数。但谁能说中位数就是一个好的根选择呢?因为其影响会进一步延伸到树的下层,也许那是一个糟糕的根节点。
然而,尝试递归地解决这个问题是非常诱人的。我们试图输出这棵二叉树,它具有递归结构。要是我们知道应该选择哪个根节点就好了,那样我们就可以递归两次:一次构建最优左子树,一次构建最优右子树。
所以,要是我们知道正确的根节点就好了。这听起来开始有点熟悉了。实际上,在我们所有的动态规划解决方案中,我们总是说,要是有一个“小精灵”告诉我们解的这一小部分信息就好了,那么我们就可以通过查表或递归计算其余部分的解,并轻松地将其扩展回原始问题的解。也许这里情况相同,也许要是有个小精灵告诉我们根节点是什么,那么我们就可以查表或递归计算更小子集问题的最优解,然后把所有东西粘贴在一起,问题就解决了。那就太好了。
和往常一样,我们希望通过一个最优子结构引理来使这一点精确化。我们希望理解最优二叉搜索树问题的解必须如何由更小子问题的最优解构成。在接下来的测验中,我将请你猜测适当的最优子结构引理是什么,然后在我们确定了正确的陈述之后,我将向你展示证明。


很好,我期待的答案是第四个,即 D,这是所有陈述中最强的一个。
第一个要点是,作为二叉搜索树的子树,T1 和 T2 本身也是对其所包含键有效的二叉搜索树。不仅如此,我们接下来几页将要证明的论断是,它们确实是最优的——在所有可能包含那些对象的搜索树中,它们最小化了加权搜索时间。这排除了 A 和 B,我们可以说得比 C 更强:这两棵树对于它们包含的项都是最优的。实际上,我们确切地知道 T1 和 T2 中包含哪些项,这是由搜索树性质决定的。搜索树性质表明,在每个节点(特别是这里的根节点),根节点左侧的所有元素都小于它,右侧的所有元素都大于它。因此,根据假设根节点是 r,我们知道对象 1 到 r-1 必须在某个地方,而它们唯一可能在的地方就是左子树 T1 中。所以,这正好是 T1 的内容。类似地,T2 的内容正好是对象 r+1 到 n。因此,这两个子树都是最优的,并且我们确切知道它们包含哪些键:左边是所有小于 r 的键,右边是所有大于 r 的键。
好的,这就是本次测验结束时的情况。我们已经确定了我们希望为真的陈述。我们真的希望一个最优二叉搜索树必须必然以这种方式构成:由根节点左侧和右侧键的最优二叉搜索树组成。如果这是真的,凭借我们现有的经验,我们或许可以设想动态规划算法可能是什么样子。如果不是这样,说实话我不知道如何开始。如果这不成立,算法会是什么样子真的不清楚。
在接下来的几页中,我想向你证明这一点。形式不会与我们已见过的有太大不同,我认为不会有大的意外,但这非常重要,这确实是算法能够工作的全部原因。我仍然会给你一个完整的证明。
126:最优二叉搜索树的最优子结构证明 🔍

在本节中,我们将学*如何证明最优二叉搜索树问题具有最优子结构性质。这是应用动态规划解决该问题的关键理论基础。我们将通过反证法,严谨地推导出:如果一个二叉搜索树对于整个键集合是最优的,那么它的左右子树也必然分别是其对应键子集的最优解。
上一节我们介绍了最优二叉搜索树问题的定义,本节中我们来看看其最优子结构性质的证明。
假设我们有一个针对键 1 到 n(对应频率为 P1 到 Pn)的最优二叉搜索树 T,其根节点为 r。
我们试图证明的是:其左子树 T1(包含键 1 到 r-1)必须是该子集的最优二叉搜索树,其右子树 T2(包含键 r+1 到 n)也必须是该子集的最优二叉搜索树。
我们将采用反证法进行证明。假设上述结论不成立,这意味着对于两个子问题(1 到 r-1 或 r+1 到 n)中的至少一个,存在一个加权搜索成本更低的二叉搜索树。我们以左子树 T1 不是最优的情况为例进行证明。
如果 T1 不是最优的,那么必然存在一个针对键 1 到 r-1 的、更优的搜索树,我们称之为 T1*。
为了引出矛盾,我们将构造一个针对所有键 1 到 n 的、比 T 更优的搜索树,但这与 T 是最优的假设相矛盾。构造方法是对树 T 进行“剪切-粘贴”手术:移除其左子树 T1,并将更优的子树 T1* 粘贴上去,得到的新树记为 T*。
为了完成反证,我们只需证明 T* 的加权搜索成本严格小于 T 的加权搜索成本。接下来我们将通过计算来展示这一点。
首先,让我们展开原始树 T 的加权搜索时间定义。
加权搜索成本公式为:
C(T) = Σ_{i=1}^{n} [ P_i * depth_T(i) ]
其中 depth_T(i) 是在树 T 中搜索键 i 所需的比较次数(深度加一)。
计算的关键在于,将树 T 的总搜索成本用其左右子树 T1 和 T2 的搜索成本表示出来。这将使我们能够轻松分析“剪切-粘贴”操作带来的影响。
我们可以将求和项按三类键进行分桶:
- 根节点键
r。 - 左子树
T1中的键(1到r-1)。 - 右子树
T2中的键(r+1到n)。
因此,成本可以重写为:
C(T) = P_r * 1 + Σ_{i=1}^{r-1} [ P_i * depth_T(i) ] + Σ_{i=r+1}^{n} [ P_i * depth_T(i) ]
根节点 r 的搜索成本为 1。
接下来,我们建立在大树 T 中的搜索深度与在子树中搜索深度的关系。对于左子树 T1 中的任意键 i,在 T 中搜索它时,需要先访问根节点 r(一次比较),然后进入左子树 T1 进行搜索。因此:
depth_T(i) = 1 + depth_{T1}(i) (对于 i 在 T1 中)
同理,对于右子树 T2 中的任意键 i:
depth_T(i) = 1 + depth_{T2}(i) (对于 i 在 T2 中)
将这两个关系代入上面的成本公式:
C(T) = P_r * 1 + Σ_{i=1}^{r-1} [ P_i * (1 + depth_{T1}(i)) ] + Σ_{i=r+1}^{n} [ P_i * (1 + depth_{T2}(i)) ]

展开并整理项:
C(T) = P_r + Σ_{i=1}^{r-1} P_i + Σ_{i=r+1}^{n} P_i + Σ_{i=1}^{r-1} [ P_i * depth_{T1}(i) ] + Σ_{i=r+1}^{n} [ P_i * depth_{T2}(i) ]
现在,我们来审视这三个求和式:
- 第一项
P_r + Σ_{i=1}^{r-1} P_i + Σ_{i=r+1}^{n} P_i其实就是所有频率P_i的总和Σ_{i=1}^{n} P_i。这是一个常数,与树的结构无关。 - 第二项
Σ_{i=1}^{r-1} [ P_i * depth_{T1}(i) ]正是左子树T1的加权搜索成本C(T1)。 - 第三项
Σ_{i=r+1}^{n} [ P_i * depth_{T2}(i) ]正是右子树T2的加权搜索成本C(T2)。
因此,我们得到了一个关键公式:
C(T) = Σ_{i=1}^{n} P_i + C(T1) + C(T2)
这个代数关系适用于任何二叉搜索树:一棵树的总成本等于所有键的频率之和加上其左右子树的成本。
现在,将这个推理应用到我们通过“剪切-粘贴”得到的新树 T* 上。T* 的根节点同样是 r,其左子树是更优的 T1*,右子树与 T 相同,仍是 T2。因此:
C(T*) = Σ_{i=1}^{n} P_i + C(T1*) + C(T2)
根据我们的假设,T1* 是比 T1 更优的解,即 C(T1*) < C(T1)。由于 Σ_{i=1}^{n} P_i 是常数,且 C(T2) 相同,比较两个总成本公式:
C(T*) - C(T) = [Σ_{i=1}^{n} P_i + C(T1*) + C(T2)] - [Σ_{i=1}^{n} P_i + C(T1) + C(T2)] = C(T1*) - C(T1) < 0
因此,C(T*) < C(T)。
这产生了矛盾:我们最初假设 T 是针对所有键的最优二叉搜索树,但现在我们构造出了一个成本更低的树 T*。这个矛盾源于我们最初的假设——T 的某个子树不是最优的。因此,假设不成立。
结论:最优二叉搜索树确实具有最优子结构性质。一个全局最优二叉搜索树的左右子树,必定分别是其对应键子集上的最优二叉搜索树。这个性质保证了我们可以通过组合子问题的最优解来构造原问题的最优解,从而为动态规划算法提供了基础。
本节课中我们一起学*了如何使用反证法证明最优二叉搜索树的最优子结构性质。我们通过将总成本分解为常数项与子树成本之和,清晰地展示了替换一个更优的子树将导致整体得到一个更优的树,从而完成了证明。这是理解后续动态规划求解步骤的核心。
127:动态规划算法一


在本节课中,我们将学*如何将最优二叉搜索树问题的结构理解,转化为一个多项式时间的动态规划算法。我们将从回顾最优子结构引理开始,识别相关的子问题,并最终构建出完整的动态规划递推关系。
回顾最优子结构引理
上一节我们介绍了最优二叉搜索树问题的最优子结构性质。本节中,我们来看看如何利用这个性质来设计算法。
假设我们有一个针对给定键集合1到n及其概率的最优二叉搜索树,并且该树有一个根节点R。那么,根据二叉搜索树的性质,它有两个子树T1和T2。我们知道这两个子树的确切构成:T1必须包含键1到R-1(假设键已排序),而右子树T2必须包含键R+1到n。此外,T1和T2本身分别是这两个键集合的有效搜索树。最重要的是,我们在上一节证明了它们对于各自的子问题是最优的:T1对于键1到R-1及其对应概率是最优的,T2对于键R+1到n及其对应频率是最优的。
识别相关子问题
现在我们已经理解了最优解必须由更小子问题的解以简单方式构成。让我们退一步思考:既然我们最终关心的是原始问题的最优解,那么哪些子问题是相关的?我们必须解决哪些子问题?

以下是需要考虑的相关子问题集合:
- 所有子集:原始项的所有子集。
- 所有前缀和后缀:原始项的所有前缀和后缀。
- 所有连续区间:原始项的所有连续区间。
在序列比对问题中,当我们查看子问题时,我们是从一个或两个字符串中移除字符,因此我们关心对应于两个字符串前缀的子问题。然而,二叉搜索树问题的有趣之处在于,当我们查看最优子结构引理中的子问题时,我们可能要考虑两个:我们不仅仅是从右侧移除元素,我们同时关心由左子树和右子树诱导出的子问题。在第一种情况下,我们查看的是起始项的一个前缀,这类似于我们之前见过的许多例子。但在第二种情况下,对应于子树T2的子问题,实际上是我们起始项的一个后缀。换句话说,我们关心的子问题,是那些通过丢弃起始项的一个前缀或一个后缀而得到的子问题。
考虑到最优解的值仅直接依赖于通过丢弃项的前缀或后缀得到的子问题,我们需要思考整个相关子问题的集合。也就是说,对于原始项1到n的哪些子集S,计算仅包含S中项的最优二叉搜索树的值是重要的?
在解释正确答案(第三个选项)之前,让我们先讨论一个非常自然但不正确的答案,即第二个选项。确实,第二个答案似乎与最优子结构引理有最好的对应关系。最优子结构引理指出,最优解必须由某个前缀上的最优解和某个后缀上的最优解在共同根节点下联合而成。因此,我们肯定关心所有项的前缀和后缀的解。但我们关心的不仅仅是这些。
也许理解这一点最简单的方法是考虑最优子结构引理的递归应用。最终,相关的子问题将对应于在整个递归实现过程中解决的所有不同子问题。让我们考虑递归树中的一个示例路径。在最顶层的递归中,你拥有整个项集,比如有100个项1到100。你将遍历并尝试所有可能的根节点。在某个时刻,你尝试根节点23,看看它的效果如何。你必须递归地在项1到22上最优地构建一个搜索树,同样地,在项24到100上递归。现在,让我们深入到这个第一个递归调用中,你在项1到22上递归。在这里,你再次尝试所有可能的根节点,有22种选择。在某个时刻,你将尝试根节点17,这又会引发两个递归调用。第二个递归调用将在项18到22上进行。这个子问题是被传递给这个递归调用的项(原始项的一个前缀)的一个后缀。因此,在这种情况下,项18到22是原始前缀1到22的一个后缀。总的来说,当你思考这个递归的多个层级时,每一步你都在做的是:要么从开头(一个前缀)删除一块项,要么从结尾删除一块项,但你可能会交错进行这两种操作。因此,你并不总是拥有原始项集的一个前缀或后缀。但正确的是,你将拥有某个连续的项集。如果你的子问题中最小的项是i,最大的项是j,那么你将拥有它们之间的所有项。这是因为你只从左侧或右侧移除项。这就是为什么C是正确答案。你需要比仅仅前缀和后缀更多的子问题。
构建动态规划算法
好了,识别相关子问题有点棘手,但现在我们已经掌握了它们,动态规划算法将像往常一样水到渠成。相关的子问题集合以一种非常机械的方式解锁了整个范式的力量。现在让我们来填写所有细节。
第一步是形式化递推关系,即给定子问题的最优解如何依赖于更小子问题的值。这将是一个数学公式,编码了我们在最优子结构引理中已经证明的内容。然后,我们将使用这个公式在动态规划算法中填充一个表格,以系统地求解所有子问题的值。
让我们引入一些符号来放入我们的递推公式中。

我们将用两个索引i和j来索引子问题,这是因为我们有两个自由度:连续项区间的起始点i和结束点j。


对于给定的i和j的选择(当然i应小于等于j),我将用大写C_ij表示仅包含从i到j的连续项集的最优二叉搜索树的加权搜索成本。当然,概率的权重与原始问题完全相同,它们只是在这里被继承下来,即p_i到p_j。
现在让我们来陈述递推关系。对于给定的子问题C_ij,我们将根据更小子问题的最优解来表达最优二叉搜索树的值。最优子结构引理告诉我们如何做到这一点。

最优子结构引理指出,如果我们知道根节点R(这里R将介于项i和j之间),那么最优解必须由两个更小子问题的最优解在根节点下联合而成。但我们不知道根节点是什么。有j-i+1种可能性,它可以是i到j(包含)之间的任何值。因此,像往常一样,我们将对我们已识别的相对较小的候选集合进行暴力搜索。
我们将暴力搜索编码为显式地取一个最小值。


选择一个根节点R,位于i和j之间(包含)。给定R的选择,我们将继承仅包含项i到R-1这个前缀的最优解的加权搜索成本。在我们的符号中,这将是C(i, R-1)。类似地,我们获取项R+1到j这个后缀的最优解的加权搜索成本。如果你回顾我们对最优子结构引理的证明,你会看到我们做了一个计算,给出了树的加权搜索成本如何依赖于其子树的加权搜索成本的公式。除了由两个搜索树各自贡献的加权搜索成本外,我们还加上一个常数,即我们正在处理的项中所有概率的总和。在这里,这个总和是p_k的和,其中k的范围从子问题的第一个项i到最后一个项j。
我们需要处理的一个额外边界情况是:如果我们选择根节点为第一个项i,那么第一个递归项C(i, i-1)没有意义。同样地,如果我们选择根节点为j,那么最后一项C(j+1, j)也没有意义。请记住,索引应该是按顺序的。在这种情况下,我们只需将这些大写C解释为零。
为什么这个递推关系是正确的?所有繁重的工作都在我们证明最优子结构引理时完成了。我们在那里证明了什么?我们证明了最优解必须是j-i+1种可能情况之一。给定根节点,其余部分就为我们确定了。递推关系通过定义,对我们已识别的唯一候选集合进行暴力搜索。因此,它确实是一个用更小子问题的最优解来表达最优解值的正确公式。

总结
本节课中,我们一起学*了如何为最优二叉搜索树问题构建动态规划算法。我们从回顾最优子结构引理开始,理解了最优解如何由更小子问题的解构成。接着,我们识别出相关的子问题集合是所有连续的项区间,而不仅仅是前缀或后缀。最后,我们形式化了递推关系,该关系通过枚举所有可能的根节点并组合更小子问题的最优解,来计算任意连续区间的最优加权搜索成本。这为我们下一步实现具体的动态规划表格计算算法奠定了基础。
128:动态规划算法二
在本节课中,我们将学*如何将最优二叉搜索树问题的递推公式,系统地转化为一个动态规划算法。我们将详细讲解子问题的定义、求解顺序、算法的具体实现步骤,并分析其时间复杂度。
子问题定义与求解顺序
上一节我们推导出了最优二叉搜索树价值的“魔法公式”(递推关系)。本节中,我们来看看如何系统地求解这些子问题。与往常一样,按照从小到大的顺序求解子问题至关重要。
在最优二叉搜索树问题中,衡量子问题大小的自然方式是子问题中包含的项目数量。如果子问题从项目 i 开始,到项目 j 结束,那么其大小为 j - i + 1。我们将以此作为子问题规模的度量。
算法实现:二维数组与循环
为了实现动态规划,我们需要一个二维数组 A。数组的维度为2,因为子问题由两个自由度索引:区间的起始索引 i 和结束索引 j。

以下是填充数组 A 的核心逻辑:
- 外层循环控制子问题的大小
s,确保我们先求解所有较小的子问题,再处理较大的子问题。s代表j与i的差值,即s = j - i。 - 内层循环控制区间的起始索引
i。
对于每个由 i 和 j = i + s 定义的子问题,我们通过暴力枚举所有可能的根节点 r(从 i 到 i + s)来应用递推公式。公式的核心是:
A[i][j] = min_{r in [i, j]} ( sum_{k=i}^{j} p_k + A[i][r-1] + A[r+1][j] )
关于公式右侧的两次数组查找,有两点需要注意:
- 当选择的根
r等于起始项i时,A[i][r-1]无意义,应视为0。同样,当r等于结束项j时,A[r+1][j]也应视为0。在实际编码中需要处理这些边界情况。 - 我们必须确保在计算
A[i][j]时,公式右侧引用的A[i][r-1]和A[r+1][j]这两个子问题的值已经被计算出来。由于外层循环按s从小到大进行,而r-1 - i和j - (r+1)都严格小于s,因此这个条件总是满足的。
当两层循环完成后,我们最终需要的答案存储在 A[1][n] 中,即包含所有项目的最优二叉搜索树的价值。
算法执行过程可视化
我们可以将二维数组 A 想象成一个网格:
- x轴对应起始索引
i。 - y轴对应结束索引
j。


我们只关心 j >= i 的部分(表格的西北部分)。算法的执行过程是按对角线填充这个上三角区域:
- 当
s=0时,我们填充主对角线(i, i),这些是单项目子问题的基案例,A[i][i] = p_i。 - 每次外层循环递增
s,我们就移动到更西北方向的下一条对角线。 - 在内层循环中,我们沿着当前对角线从西南向东北填充。
- 填充某个位置
(i, j)时,我们只需要查找位于更低对角线(即规模更小的子问题)上的两个值A[i][r-1]和A[r+1][j]。

算法正确性与时间复杂度分析
算法的正确性基于之前证明的最优子结构引理。只要递推公式正确,并且我们按此系统性地填充数组,通过归纳法即可证明动态规划算法的正确性。



现在我们来分析算法的时间复杂度。我们遵循常规步骤:计算需要解决的子问题数量,以及解决每个子问题所需的工作量。

- 子问题数量:所有满足
1 <= i <= j <= n的(i, j)对。这大约是n^2/2个,即 Θ(n²)。 - 每个子问题的工作量:对于每个子问题
(i, j),我们需要枚举所有可能的根节点r(从i到j),共j-i+1个选项。对于每个候选根,我们进行常数时间的计算(求和与查表)。因此,解决一个子问题的时间是 O(n)。
综合来看,总的时间复杂度是 Θ(n³)。这是一个多项式时间算法,远优于枚举所有指数级数量的二叉搜索树的暴力方法,适用于 n 在几百数量级的问题。

算法优化:一个有趣的事实
虽然 Θ(n³) 的时间可以接受,但并非极快。这里有一个有趣的事实:存在一种方法可以显著加速这个动态规划算法。
我们可以保持相同的二维数组和语义,但通过利用最优二叉搜索树问题中蕴含的额外结构,来避免在每个子问题中都进行 O(n) 的暴力根节点搜索。其核心思想是,在求解较小规模子问题时获得的信息,可以用来推断当前子问题中哪些根节点可能是最优的,从而将搜索范围缩小到平均常数个候选根。
这种优化技巧可以将总运行时间从 Θ(n³) 降低到 Θ(n²)。这极大地提升了算法能处理的问题规模,从几百提高到几千甚至上万,非常巧妙。如果你感兴趣,可以查阅相关原始论文或网络资源以了解其详细实现。

本节课中我们一起学*了如何将最优二叉搜索树的递推关系实现为一个动态规划算法。我们明确了子问题的定义和求解顺序,描述了使用二维数组和双重循环的算法框架,并通过可视化方式理解了其执行过程。最后,我们分析了算法 Θ(n³) 的时间复杂度,并简要介绍了一种可将其优化至 Θ(n²) 的进阶方法。
129:单源最短路径再探 🔄

在本节课中,我们将重新审视单源最短路径问题。我们已经使用迪杰斯特拉(Dijkstra)的贪心算法解决了这个问题,现在让我们看看动态规划范式能为同一个问题带来什么。
问题定义回顾
首先,让我们快速回顾一下问题的定义:输入是什么,输出是什么?
我们给定一个图。在讨论最短路径时,我们将只讨论有向图。
图的每条边都有一个长度,我们用 C_AB 表示。一个顶点,我们用小写 s 表示,被指定为源顶点。
我们假设图是简单的,即没有平行边。原因与最小生成树问题相同:如果你有一堆平行边,由于我们只关心最短路径,你完全可以只保留长度最小的那条边,扔掉其他副本。
该问题算法的职责是计算从源顶点 s 到每个其他可能目的地 v 的最短路径长度。当我们谈论路径长度时,总是指该路径中所有边的成本之和。
例如,如果每条边的成本都是1,那么我们讨论的就是跳数,这个问题可以通过广度优先搜索解决。但在单源最短路径问题中,我们通常关心的是长度可能差异很大的边。
现有方案回顾:迪杰斯特拉算法
现在,让我们回顾一下现有的解决方案——迪杰斯特拉算法,并审视其优缺点。
迪杰斯特拉算法是一个很棒的算法,前提是你的图能放入计算机主内存,并且所有边成本都是非负的。如果你使用堆(heaps)来实现迪杰斯特拉算法,你将得到一个非常快速且始终正确的最短路径算法。
在本课程的第一部分,我们解释了一种使用堆的实现,其运行时间为 O(m log n),其中 m 是边数,n 是顶点数。这个实现与我们之前讨论的用于计算最小生成树的普里姆(Prim)算法几乎相同。理论上,如果你使用更复杂的堆数据结构,甚至可以获得更好的渐*运行时间 O(m + n log n)。但就目前而言,我们可以将 O(m log n) 视为迪杰斯特拉算法在边长为非负的图上的基准线。
迪杰斯特拉算法的局限性
既然迪杰斯特拉算法如此出色,我们为何还要拒绝它并研究其他最短路径算法呢?主要有两个缺点,我们之前提到过,现在再重申一下。
第一个缺点:如果你处理的图中某些边成本可能为负值,那么迪杰斯特拉算法的输出可能不正确。其正确性依赖于边成本非负的假设。我们在课程第一部分以及本课程刚开始讨论贪心算法及其普遍的不正确性时,都看到过具体的例子。
坦率地说,对于许多最短路径算法的应用场景,你永远不会遇到负边。例如,如果你正在实现一个计算驾驶路线的程序,无论你使用里程还是时间,都不会有负长度的边,除非我们发明了时间旅行机器。
但并非所有最短路径问题的应用都如此具体,涉及从空间中的点A到点B的实际路径计算。更抽象地说,这个问题只是帮助你找到一个最优的决策序列。想象一下,你正在管理一堆金融资产,你将单笔交易建模为网络中的一条边,这条边将你从一种特定的库存状态带到另一种状态。当你卖出东西时,可能会产生正收益,对应正的边长度;但当你买入东西时,当然需要花钱,这可能对应负的边长度。
第二个缺点,我们在本课程的介绍视频中提到过,是迪杰斯特拉算法看起来高度集中化。如果你只是将整个图存储在一台机器的内存中,这不是问题。但如果你谈论的是互联网路由,让一个路由器拥有整个互联网的完整地图来本地计算路由是完全不切实际的。这促使我们寻找一种更适合分布式路由的不同最短路径算法。
贝尔曼-福特算法:一举解决两个问题
我们将能够通过贝尔曼-福特(Bellman-Ford)算法一举解决这两个缺点,这将是动态规划算法设计范式的又一个实例。
尽管贝尔曼-福特算法比最早的阿潘特(Apant)版本早了整整10年,但它仍然是现代互联网路由协议的基础。当然,从抽象的贝尔曼-福特算法到实用的互联网路由解决方案,还需要很多工程步骤,我们将在另一个视频中稍作讨论,但这确实是这一切的起点。
开发前的预备知识:负边与负环
在开始开发贝尔曼-福特算法之前,我们需要处理一些微妙的预备知识。我们需要明确,在存在负边成本,特别是存在负成本环的情况下,如何定义最短路径。负成本环是指边成本之和小于零的有向环。
例如,你可以想想我在右边画的这个绿色示意图。图中某处嵌入了一个有四个边的有向环。环中的一些边具有负成本,一些具有正成本,但总体上,该环的总成本为负(遍历所有四条边后成本为-2)。
让我们思考一下,在这样的图中,应该如何定义从源顶点 s 到某个目的地 v 的最短路径。具体来说,我们是否允许路径中包含环?
允许路径包含环的后果
首先,让我们思考允许路径包含环的后果。这个提议实际上没有意义,因为如果你允许遍历环,通常从 s 到 v 将没有最短路径。原因是,如果你有一个像这个绿色图中总成本为-2的负环,并且你实际上可以从源点 s 到达它,那么对于每一条路径,你实际上都可以找到另一条更短的路径:你只需再遍历一次有向环,整个路径长度就会再减少-2,而且没有什么能阻止你一遍又一遍地这样做。因此,最短路径要么被认为是未定义的,要么你可以认为它在极限情况下是负无穷。
禁止路径包含环的后果
如果允许路径有环行不通,为什么不探索禁止路径中有环的选项呢?这个版本的问题定义非常明确,是一个完全合理的计算问题,你可能很想解决它。
但这里的问题要微妙得多。问题是,这个问题是所谓的NP完全问题。这是我们下周会更多讨论的内容,但底线是,这些问题都是难解问题。对于NP完全问题,没有已知的计算高效(多项式时间)算法。不幸的是,在存在负环的情况下计算最短无环路径,就是这样一个问题。
更准确地说,如果你提出一个保证正确且保证是多项式时间的算法,能在存在负环的情况下总是计算出最短路径,其后果将是所谓的 P = NP。我们将在后面的视频中更正式地讨论这一点,但如果你真的提出了这样的算法,你应该立即向克莱数学研究所报告,他们会有一百万美元的奖金等着你。
对于那些已经熟悉NP完全性的同学来说,证明这个问题是NP难的将是一个简单的练*,只需从哈密顿路径问题归约即可。
折中方案:聚焦于无负环的图
看来我们陷入了两难境地。如果允许路径中有环,我们得不到一个有意义的、合理的问题。如果排除环,我们得到了一个完全有意义但不幸在计算上难解的问题。
以下是我们的处理方式。目前,我们将只关注那些没有负环的图。当然,我们允许单个边为负,但此刻我们要求输入图的每个有向环的总长度都是非负的。它可以有一些正边成本和一些负边成本,但总体上必须是非负的。
如果这个假设让你感到困扰,我并不怪你。但好消息是,正如我们将看到的,贝尔曼-福特算法可以轻松检查这个条件是否成立。如果输入图中存在负环,它能轻松检测出来。
因此,贝尔曼-福特算法将要解决的最短路径问题版本如下:给定一个可能包含负边成本的输入图(可能包含也可能不包含负环),贝尔曼-福特算法要么正确计算从源点到所有目的地的最短路径(正如你所愿),要么它会“放弃”。但它会提供一个很好的理由来解释为何放弃——它会向你展示输入图中的一个负环。当然,在存在负环的情况下计算最短路径是难解的,所以在这种情况下,你必须对贝尔曼-福特算法网开一面。因此,对于给定的输入图,它要么显示一个负环,要么给出所有所需的最短路径距离。这就是我们将要努力实现的保证。
无负环假设的用处:一个测验
我将以一个小测验结束这个视频,帮助你理解为什么“无负环”这个假设可能对算法有用。
现在,我只想让你思考一下:假设我向你保证输入图没有负环,问题是,需要多少跳(多少条边)才能保证你找到了 s 到某个给定目的地 v 之间的最短路径?

以下是选项:
a. n-1
b. n
c. n+1
d. 2n
正确答案是 a。这是“无负环”假设有用的主要原因之一。它让你能够控制需要多少条边才能确保你找到了一条最短路径。
具体来说,假设你在某个 s 和某个 v 之间有一条至少有 n 条边的路径。如果你至少有 n 条边,意味着这条路径访问了至少 n+1 个顶点。但总共只有 n 个顶点。所以,如果你访问了至少 n+1 个顶点,意味着你访问了某个顶点两次,比如 x。这意味着你的路径内部有一个从 x 回到 x 的环。现在,如果你把这个环“撕掉”,即删除那些边,你会得到另一条从 s 到 v 的路径。并且因为这个有向环(像所有环一样)必须是非负的,所以移除这个环后,总长度(边成本之和)只会减少或保持不变。所以,你给我看一条至少有 n 条边的从 s 到 v 的路径,我就能给你看一条边数更少、且长度不会更长的路径。这表明,一条最短路径最多有 n-1 条边,这正是测验的正确答案。
总结
本节课中,我们一起回顾了单源最短路径问题及其经典解法迪杰斯特拉算法的优缺点。我们探讨了在图中存在负边,特别是负环时,定义和计算最短路径所面临的挑战。为了克服迪杰斯特拉算法的局限性(无法处理负边、高度集中化),我们引入了贝尔曼-福特算法作为动态规划的实例。我们明确了贝尔曼-福特算法将要解决的问题版本:处理可能包含负边但无负环的图,并能检测负环的存在。最后,我们通过一个测验理解了“无负环”假设的关键作用——它保证了最短路径的边数不会超过 n-1,这为设计高效算法提供了基础。在接下来的课程中,我们将深入探讨贝尔曼-福特算法的具体实现。
130:最优子结构 🧩

在本节中,我们将开始开发贝尔曼-福特算法,作为动态规划范式的一个实例。我们将以常规方式开发它,通过理解最优解如何必然由更小子问题的最优解组成。
概述
我们将探讨在输入图可能包含负边成本的情况下,如何解决单源最短路径问题。我们将首先关注不包含负环的图,并推导出最优子结构性质,这是构建动态规划算法的关键第一步。
回顾与问题定义
上一节我们讨论了输入图可能包含负边成本的情况。现在,让我们精确地定义我们要解决的问题。
输入是一个有向图,每条边都有一个成本 C(e)。我们允许这些边成本为负。同时,我们给定一个源顶点 s。
我们希望计算从源点 s 到所有其他目标顶点 v 的最短路径距离。路径的长度定义为路径上所有边成本的总和。
对于包含负环的输入图,如果允许路径包含环,则最短路径长度可能未定义(为负无穷)。如果不允许环,则计算是NP难问题。因此,如果算法无法计算最短路径,它至少应该输出一个负环作为失败的原因。
本节中,我们将为不包含负环的输入图开发算法。一旦算法能处理这种情况,扩展到能检测负环的通用情况将相对容易。
动态规划应用于图问题的挑战
将动态规划范式应用于图问题通常具有挑战性。原因之一是图本质上不是顺序对象。我们只是给定一组无序的顶点和一组无序的边。
然而,对于这个特定的最短路径问题,我们的输出——路径——是顺序对象。这给了我们希望:我们可以陈述并证明一个最优子结构引理,说明最优解(最短路径)如何由更小的最短路径构建而成。
但如何定义“更小”和“更大”的子问题仍然不明确。例如,我们希望能智能地处理可能的目标顶点 v,但如果不首先知道最短路径距离,这并不清楚如何做到。
贝尔曼-福特算法的关键思想
贝尔曼-福特算法的一个关键且巧妙的想法是引入一个额外的参数,为我们提供子问题规模的明确定义。这个参数将控制我们允许从源点 s 到目标顶点 v 的路径中包含多少条边。
让我们通过一个例子来解释。考虑右侧这个包含五个顶点的绿色图。
在贝尔曼-福特算法中,我们将为每个可能的目标顶点以及每条路径允许的边数限制定义一个子问题。
例如,假设我们关注从 s 到目标 t,并且只考虑边数不超过 2 条的路径。在这个约束下,图中从 s 到 t 的最短路径长度是 4。底部那条有三条边的路径不被允许,因为当前子问题只允许最多 2 条边。
如果我们将边预算增加到 3,那么对应的最短路径距离就从 4 降到了 3,因为我们现在可以利用底部那条有三条边的路径。
这里的要点是,这为我们提供了子问题规模的明确概念:允许在从源点到给定目标的路径中使用的边数越多,子问题就“越大”。
最优子结构引理
现在,我们正式陈述并证明最优子结构引理。我们将处理任意的输入图(可能包含负环,也可能不包含)。
像所有最优子结构引理一样,其陈述形式是:子问题的最优解必须是由更小子问题的最优解以简单方式组合而成的少数几个候选解之一。
我们如何索引一个给定的子问题?将有一个我们关心的目标顶点 v。同时,如前所述,将有一个预算 i,限制从 s 到 v 的路径中允许使用的边数。i 是一个正整数(1 或更大)。
假设 P 是一个最优解,即在所有从 s 开始、在 v 结束、且最多包含 i 条边的路径中,P 具有最小的边成本总和(最小长度)。
一个细微之处:因为我们是在完全一般性下证明这个引理(即输入图可能包含负环),我们需要允许路径 P 在需要时使用环,包括可能多次使用负环。注意,我们不担心路径无限次使用环,因为它有有限的边数预算 i。
在这种设定下,路径 P 可能是什么?我们将有两种情况:
情况 1:路径未用尽预算
如果路径 P 没有用尽其全部的边预算 i,即它只包含 i-1 条或更少的边,那么 P 自然也是从 s 到 v 且最多包含 i-1 条边的最短路径。
情况 2:路径用尽了预算
如果从 s 到 v 且最多包含 i 条边的最短路径实际上用尽了其全部预算,即使用了所有 i 条边。
类比我们之前所有的动态规划算法,我们考虑从最优解 P 中“剥离”最后一部分。这里,我们将剥离路径 P 的最后一条边。
剥离最后一条边后,我们得到一条边数少一的路径 P',它从 s 开始,在某个顶点 w 结束,且最多包含 i-1 条边。这里的断言是:P' 不仅仅是任意一条从 s 到 w 且最多包含 i-1 条边的路径,它实际上就是这样的最短路径。
注意,在这种情况下,P' 恰好有 i-1 条边,而不仅仅是“最多” i-1 条边。但这里声称的更强断言(P' 是所有最多包含 i-1 条边的路径中最优的)将很有用。
引理证明
这个引理的陈述比证明更复杂。让我们简要地讨论一下证明过程。
情况 1 的证明是完全平凡的,与我们之前在其他算法中看到的明显矛盾相同。
情况 2 的证明将采用我们常用的“剪切-粘贴”矛盾法。
假设存在一条路径 Q 比 P' 更好。即 Q 从 s 开始,在 w 结束,包含 i-1 条或更少的边,并且其边成本总和严格小于 P' 的成本总和。
那么,如果我们只是将 P 的最后一段(即边 w -> v)附加到 Q 上,我们就得到了一条从 s 开始、到 v 结束、最多包含 i 条边的新路径。这条新路径的总成本严格小于原始路径 P 的成本。但这与 P 在所有从 s 开始、在 v 结束、且最多包含 i 条边的路径中最优的假设相矛盾。
因此,假设不成立,P' 必须是从 s 到 w 且最多包含 i-1 条边的最短路径。证明完毕。
理解候选解数量
为了确保你理解刚刚的内容,让我们进行一个小测验。
问题:对于给定输入图中的某个目标顶点 v,涉及 v 的子问题的最优解有多少个候选?
答案是:取决于目标顶点 v。具体来说,候选解的数量由该顶点的入度决定,即输入图中以 v 为终点的边的数量。
原因:
- 情况 1 贡献了一个可能的候选解:对于给定的
i和v,最优解可能只是继承了目标为v、预算为i-1条边时的最优解。 - 情况 2 看似只贡献了另一个候选解,但实际上它包含多个候选,每个候选对应最后一条边
(w, v)的一个不同选择w。具体来说,对于每个可能的w,都有一个候选最优解,它由从s到w且最多使用i-1条边的最短路径,加上边w -> v构成。
因此,总候选数 = 1(来自情况1) + v 的入度(来自情况2)。
总结
本节课中,我们一起学*了贝尔曼-福特算法动态规划思路的起点——最优子结构。
我们首先明确了在允许负边权但不允许负环(或能检测负环)的图中求解单源最短路径问题的目标。然后,我们指出了将动态规划应用于图问题的核心挑战:缺乏自然的顺序。贝尔曼-福特算法通过引入路径边数预算 i 这一关键参数巧妙地解决了这个问题,从而明确定义了子问题的规模。
我们正式陈述并证明了最优子结构引理:对于目标顶点 v 和边预算 i,其最优解(最短路径)要么是预算为 i-1 时的最优解,要么是由某个前驱顶点 w 在预算 i-1 下的最优解加上边 (w, v) 构成。这个引理的证明采用了经典的“剪切-粘贴”反证法。

最后,我们分析了子问题候选解的数量,它等于 1(继承上一预算的解)加上目标顶点 v 的入度。这为下一节将最优子结构转化为可计算的递推关系奠定了基础。
131:基本算法一

概述
在本节中,我们将学*如何将最短路径问题中的最优子结构性质,转化为一个动态规划递推式,并由此推导出贝尔曼-福特算法的基本版本。我们将理解算法如何通过限制路径的边数(预算)来逐步构建最短路径,并探讨在图中没有负权环的情况下,算法如何保证正确性。
最优子结构到递推式
上一节我们介绍了最短路径问题中存在的最优子结构。本节中,我们将根据动态规划的通用方法,将这个性质转化为一个递推公式,该公式定义了子问题最优解与其更小子问题最优解之间的关系。
我们使用符号 L(i, v) 来表示对应子问题的最优解值。每个子问题由两个参数索引:
- v:子问题中我们感兴趣的目的地。
- i:子问题中允许从源点 s 到 v 的路径所使用的最大边数(预算)。
需要说明几点细节:
- 我们之前证明的最优子结构引理适用于一般图 G,它可能包含负权环。因此,在从 s 到 v 的最短路径中,我们必须允许环的存在。我们之所以不担心环被无限次遍历,是因为我们有限制边数的预算 i。
- 如果不存在使用最多 i 条边从 s 到 v 的路径(当 i 很小时,对许多目的地 v 确实如此),我们定义 L(i, v) = +∞。
递推式对每个正整数 i 和每个可能的目的地 v 进行定义。它表明,子问题的最优解值是最优子结构引理中识别的所有可能候选解中的最佳者。
以下是递推式:
L(i, v) = min( L(i-1, v), min_{(w, v) ∈ E} { L(i-1, w) + c_{wv} } )
解释:
- 情况一候选解:直接继承使用最多 i-1 条边从 s 到 v 的最优路径长度,即 L(i-1, v)。
- 情况二候选解:考虑所有可能的最后一条边 (w, v)。对于每个选择,其路径长度为:使用最多 i-1 条边从 s 到 w 的最短路径长度 L(i-1, w),加上最后一条边 (w, v) 的成本 c_{wv}。我们从所有这些可能性中取最小值。
该递推式的正确性直接源于最优子结构引理。我们知道这些是仅有的候选解,并且根据递推式的定义,我们选择其中最好的一个。无论图 G 是否包含负权环,这个递推式对所有正的 i 值都是正确的。
无负权环假设的作用
现在,让我们看看假设输入图 G 没有负权环是如何有用的。
我们之前有一个测验讨论了“无负权环”假设的用途。具体来说,我们论证了 n-1 条边总是足以捕获从 s 到任何可能目的地的最短路径。原因如下:
- 假设没有负权环。固定一个目的地 v。
- 考虑一条至少有 n 条边的路径。由于它有至少 n 条边,它访问了至少 n+1 个顶点。
- 图中只有 n 个顶点,因此该路径必定访问了某个顶点至少两次。
- 在两次连续访问同一个顶点之间,存在一个有向环。根据假设,没有负权有向环,所有环的权重都是非负的。
- 如果我从这条路径中丢弃这个有向环,我将得到一条通往同一目的地 v 的新路径,并且其总长度只会减少(或保持不变)。丢弃环只会使路径更短(或不增长)。
- 因此,存在一条没有重复顶点(即最多有 n-1 条边)的最短路径。
这个观察结果与我们的递推式有什么关系呢?它告诉我们,只需要计算递推式,评估 i 值直到 n-1 的子问题。如果没有负权环,给子问题分配超过 n-1 条边的预算是没有意义的。因为当 i 达到 n-1 时,我们保证已经找到了最短路径。
为了明确这一点,我们正式写下在贝尔曼-福特算法中将要解决的子问题集合,这些子问题足以正确计算没有负权环的输入图 G 的最短路径。
子问题集合是计算所有最短路径长度 L(i, v),其中:
- v 遍历所有顶点(目的地)。
- i 从 0 到 n-1。
这是一个相当简洁的子问题集合。虽然它看起来数量很多(有 n 个目的地和 n 个预算值,共 n² 个),但请记住,这个问题的输出大小是线性的(我们需要为每个目的地 v 输出一个数字)。因此,对于我们负责计算的每个统计数据,我们实际上只有线性数量的子问题,这与我们讨论过的其他动态规划算法一样好。
贝尔曼-福特算法伪代码
现在,我们可以轻松地写出著名的贝尔曼-福特算法的伪代码。
由于我们的子问题由两个参数(边预算 i 和目的地 v)索引,我们将使用一个二维数组 A。我们通过边预算 i 来衡量子问题的大小,这是贝尔曼-福特算法中引入边预算来控制子问题大小的核心思想。
以下是算法的伪代码:
// 初始化
令 A 为一个二维数组,维度为 [0..n-1][所有顶点 v]
对于每个顶点 v:
A[0][v] = ∞ // 使用0条边无法到达任何其他顶点
A[0][s] = 0 // 从s到s的空路径长度为0
// 主循环
for i = 1 to n-1:
for 每个顶点 v:
// 情况一:继承 i-1 条边时的最优解
A[i][v] = A[i-1][v]
// 情况二:考虑所有可能的最后一条边 (w, v)
for 每条指向 v 的边 (w, v):
if A[i-1][w] + c_{wv} < A[i][v]:
A[i][v] = A[i-1][w] + c_{wv}
// 最终答案
对于每个顶点 v:
最短路径距离 d[v] = A[n-1][v]
解释:
- 基础情况 (i=0):如果 v 恰好等于源点 s,则可以使用空路径到达,长度为 0。如果 v 是 s 以外的任何顶点,则无法使用 0 条边从 s 到达 v,我们定义其最优解值为 +∞。
- 主循环:我们有两个嵌套的
for循环。与大多数动态规划算法不同,这里的循环顺序很重要。外层循环必须按子问题大小 i 递增的顺序进行,以确保在需要时,所有更小的子问题都已解决。- 对于每个 i 和 v,我们直接将递推式转化为代码。
- 情况一提供了一个候选解:A[i][v] = A[i-1][v]。
- 情况二为每条指向 v 的边 (w, v) 提供一个候选解:A[i-1][w] + c_{wv}。我们取所有候选解中的最小值。
如前所述,如果输入图 G 没有负权环,则该算法将正确终止,并计算出从 s 到所有目的地的最短路径。最终答案将存储在最大的子问题中,即 A[n-1][v] 中。
正确性主要源于最优子结构引理,同时,“无负权环”的假设保证了取 i = n-1 足够大,能够捕获最终答案。
总结

本节课中,我们一起学*了如何将最短路径的最优子结构性质形式化为一个动态规划递推式。我们引入了边预算 i 的概念来控制子问题规模,并基于此推导出了贝尔曼-福特算法的基本版本。我们了解到,在假设图中没有负权环的前提下,只需计算 i 从 0 到 n-1 的子问题,就能确保算法找到所有正确的最短路径。算法的伪代码清晰地展示了如何通过迭代填充一个二维数组来实现这一过程。
132:贝尔曼-福特算法详解 🧮

在本节课中,我们将深入学*贝尔曼-福特算法。这是一种用于在带权有向图中计算单源最短路径的动态规划算法,尤其适用于处理包含负权边的图。我们将通过一个具体的例子,逐步解析算法的执行过程,分析其时间复杂度,并探讨一些实用的优化技巧。
算法执行步骤演示
上一节我们介绍了贝尔曼-福特算法的基本思想和递推关系。本节中,我们通过一个包含五个顶点的具体图例,来一步步演示算法的执行过程。
考虑以下包含五个顶点的图,蓝色数字标注了各边的权值(成本)。

我们将逐步遍历外层循环的索引 i。由于有五个顶点,i 将取值 0, 1, 2, 3, 4。让我们看看每一轮子问题计算的结果。
在基础情况下,当 i = 0 时,从源点 S 到自身的距离为 0,对于所有其他顶点,子问题的值定义为 +∞。
让我再次写下递推关系,以防你忘记:
A[i, v] = min{ A[i-1, v], min_{(w, v) ∈ E} { A[i-1, w] + c_wv } }
现在我们进入主循环,从 i = 1 开始。
我们以任意顺序遍历顶点并计算递推式。
- 节点
S将直接继承上一步的解决方案,它仍然满足总长度为0的空路径。 - 节点
V当然不希望继承上一轮(i = 0)的+∞解。实际上,当i = 1时,顶点v的子问题解将是2。这是因为我们可以选择最后一条边为(S, V),其长度为2,而上一次迭代(i = 0)时S的子问题值是0。 - 同理,
X的新子问题值将是4,因为我们可以选择最后一条边为(S, X),并将该边的成本4加到S在上次迭代(i = 0)时的子问题值上。 - 节点
W和T希望摆脱它们的+∞解并获得有限值。你可能会想,因为V和X现在有了有限距离,这些值会传播到节点W和T。这确实会发生,但我们必须等到下一次迭代,即i = 2。原因是,如果你查看代码或递推式,当我们在给定迭代i计算子问题时,我们只使用前一次迭代i-1的子问题解,而不使用当前迭代i中已经发生的任何更新。因此,由于当i = 0时,A[0, V]和A[0, X]都是+∞,A[1, W]和A[1, T]也将是+∞。
现在让我们继续外层 for 循环的下一次迭代,当 i = 2 时。
- 顶点
S的子问题解不会改变,你不会得到比0更好的结果,所以它将保持不变。 - 类似地,在顶点
V,你不会得到比2更好的结果,所以它在此次迭代中也保持不变。 - 然而,在顶点
X发生了一些有趣的事情。在递推式中,你当然可以选择继承之前的解,即一个选项是将A[2, X]设为4。但实际上有一个更好的选择。具体来说,如果我们选择最后一条边为从V到X的单位成本弧,我们将该单位成本加到V在上次迭代(i = 1)时的子问题值2上。2 + 1 = 3。这将是i = 2的此次迭代中X的新子问题值。
正如所宣传的那样,在 i = 1 的迭代中对顶点 V 和 X 的更新,现在在 i = 2 时传播到了顶点 W 和 T。因此,W 和 T 摆脱了它们的 +∞ 值,并分别获得了值 4 和 8。
请注意,我将顶点 T 标记为 8,而不是 7。我计算出的 A[2, T] 是 8。原因同样是,本次迭代中的相同更新,特别是 X 从 4 降到 3 这一事实,不会在同一迭代中反映到其他节点上。我们必须等到外层 for 循环的下一次迭代,这些更新才会发生。因此,我们使用的是 X 的过时信息,即当 i = 1 时,它的解值是 4。我们正是用这个信息来更新 T 的解值,所以是 4 + 4 = 8。
在倒数第二次迭代中,当 i = 3 时,S、V、X、W 处的大部分值保持不变,实际上我们已经计算出了最短路径,所以它们都将直接继承前一次迭代的解。
但在顶点 T,它将利用顶点 X 在 i = 2 迭代中改进的解值,因此它的 8 被更新为 7,反映了前一次迭代中 X 的改进。
此时,我们实际上已经完成了计算,得到了到所有目的地的最短路径。但算法还不知道我们已经完成,所以它仍然会执行外层 for 循环的最后一次迭代,即 i = 4。但每个节点都只是继承了前一轮的解。此时,算法终止。
时间复杂度分析
在我们讨论过的大多数动态规划算法中,运行时间分析都很简单。贝尔曼-福特算法从运行时间分析的角度来看则更有趣。请在下面的测验中思考一下。
正确答案是 B,即在所有这些运行时间界限中,这是最小的且实际上正确的界限。让我解释为什么它是边数乘以顶点数,同时也评论一下其他选项。
-
选项 A:O(n²)。这是子问题的数量。子问题由
i(介于0和n-1之间)和目的地v的选择索引。每个都有n种选择,所以恰好有n²个子问题。如果我们每次评估一个子问题只花费常数时间,那么贝尔曼-福特的运行时间确实是O(n²)。在本课程讨论的大多数动态规划算法中,确实每个子问题只花费常数时间求解。一个例外是最优二叉搜索树问题,在一般情况下我们花费线性时间。这里,像最优二叉搜索树一样,我们可能花费超过常数的时间来解决一个子问题。原因是我们必须对可能超常数的候选列表进行暴力搜索。原因是,每条指向目的地v的边都提供了一个候选解。候选数量与顶点的入度成正比,最大可以达到n-1,与顶点数成线性关系。这就是为什么贝尔曼-福特算法的运行时间通常可能比O(n²)差。 -
选项 C:O(n³)。确实,
O(n³)是贝尔曼-福特算法运行时间的有效上界,但它不是可能的最紧上界。为什么它是一个有效上界?如前所述,有O(n²)个子问题。每个子问题做多少工作?它与顶点的入度成正比,顶点的最大入度是O(n)。因此,对O(n²)个子问题中的每一个进行线性工作,导致立方级的运行时间。
然而,对贝尔曼-福特算法有一个更紧、更好的分析。
- 选项 B:O(mn)。为什么
O(mn)比n³大?在稀疏图中,m是Θ(n);在稠密图中,m是n²。所以如果是稠密图,O(mn)确实不小于O(n³)。但如果图不是稠密的,那么这个上界确实是改进过的。
为什么这个界限成立?请从以下角度思考所有子问题的总工作量:我们只需取外层 for 循环单次迭代中所做的工作量,然后乘以外层循环的迭代次数 n。
那么,在外层循环的给定迭代(给定 i)中,我们做了多少工作?它就是所有顶点入度的总和。当我们考虑顶点 v 时,我们做的工作与其入度成正比,并且在外层循环的给定迭代中,我们考虑每个顶点 v 一次。但我们知道所有入度之和有一个更简单的表达式:这个和恰好等于 m,即图中边的数量。在任何有向图中,边的数量恰好等于所有入度之和。一个简单的理解方式是:取你最喜欢的有向图,想象你一次一条边地将边插入图中,从空的边集开始。每次插入一条新边,显然图中的边数增加 1,同时恰好有一个顶点的入度增加 1(即你刚插入的边的头顶点)。因此,无论有向图是什么,入度之和与边数总是相同的。这就是为什么总工作量是 O(mn),优于 O(n³)。
算法优化技巧
基本贝尔曼-福特算法的一些优化是可能的。让我在本视频结束时快速介绍一个关于提前停止的优化。另请参阅一个关于算法更复杂的空间优化的单独视频。
基本版本的算法,外层 for 循环运行 n-1 次。通常,你不需要全部迭代。我们已经在简单示例中看到,最后一次迭代没有做任何有用的工作,它只是继承了前一次迭代的解。
一般来说,假设在早于最后一次的某次迭代中,比如当前索引值为 j,恰好没有任何变化,在每个目的地 v,你只是重用了在外层 for 循环前一次迭代中重新计算的最优解。那么,如果你仔细想想,在下一次迭代中会发生什么?你将用完全相同的输入集进行完全相同的计算集,因此你将得到完全相同的输出集。也就是说,在下一次迭代中,你将再次仅仅从前一次迭代继承最优解,并且这种情况将一次又一次地发生,直到永远。
因此,特别地,当你到达外层 for 循环的第 n-1 次迭代时,你将拥有与现在完全相同的解值集。我们已经证明,迭代 n-1 结束时的结果是正确的,它们是真正的最短路径距离。如果你现在就已经掌握了它们,那么不妨中止算法,并将它们作为最终的、正确的最短路径距离返回。


总结


本节课中,我们一起学*了贝尔曼-福特算法的详细执行步骤。我们通过一个五顶点图例,逐步跟踪了算法各轮迭代中距离值的更新过程,理解了为什么更新需要多轮迭代才能传播到所有节点。我们深入分析了算法的时间复杂度,得出其核心运行时间为 O(mn),并解释了其与子问题数量及顶点入度的关系。最后,我们探讨了一个简单的优化技巧——提前停止检测,它可以在算法实际收敛时提前终止循环,提升效率。掌握这些细节,有助于你更扎实地理解和应用这一重要的最短路径算法。
133:检测负环 🔍
在本节中,我们将学*如何扩展贝尔曼-福特算法,使其能够检测输入图中是否存在负成本环。我们将看到,只需在算法中增加一次额外迭代,就能在不显著增加运行时间的情况下完成这一检测。

概述
到目前为止,我们已经了解了在没有负成本环的输入图中,贝尔曼-福特算法如何正确计算从源顶点 S 到所有目标顶点 V 的最短路径。但是,如果输入图中确实存在负成本环呢?在这个简短的视频中,我们将看到如何扩展贝尔曼-福特算法,使其能够轻松检查输入图是否包含负成本环,同时保持其运行时间基本不变。
核心概念与扩展
上一节我们介绍了贝尔曼-福特算法在无负环图中的正确性。本节中,我们来看看如何修改它以检测负环。
以下断言将指出贝尔曼-福特算法的适当扩展。具体来说,该断言根据贝尔曼-福特算法的行为,描述了输入图中是否存在负成本环。
我们设想将外层循环多运行一次迭代,即当 I = n 时,对所有目标顶点 V 运行相同的旧递推关系。那么,断言是:输入图 G 没有负环,当且仅当从这额外一批子问题中我们没有获得任何新信息。也就是说,当且仅当对于每个可能的目标顶点 V,A[n][V] 与 A[n-1][V] 完全相同。等价地,输入图确实存在负成本环,当且仅当存在某个子问题,存在某个目标顶点 V,通过为这次额外迭代运行贝尔曼-福特算法,我们看到 V 处有改进。
我们将在下一张幻灯片中证明这个断言。这并不难,但我希望你能立即清楚该断言的含义,以及我们如何检查负成本环。
检测负环的步骤
现在,给定一个没有任何承诺的任意输入图(它可能有负成本环,也可能没有),你该怎么做?你运行贝尔曼-福特算法,但多运行一次迭代。你将外层 for 循环索引 I 一直运行到 N,然后检查:在最后一次迭代中,是否有某个子问题的值发生了变化?如果没有,如果你的所有 A[n-1][V] 都与 A[n][V] 相同,那么根据断言,你知道没有负成本环。根据我们之前的工作,我们知道贝尔曼-福特算法是正确的。因此,我们像以前一样愉快地返回 A[n-1][V] 作为正确的最短路径距离。
另一方面,如果你注意到存在一个顶点 V,使得 A[n][V] 不同于(小于)A[n-1][V],那么根据断言,你会说:“嘿,存在负环。” 因此,我不会为你计算最短路径距离。这没有意义。存在负成本环,你只能放弃。
当然,贝尔曼-福特算法中的这一次额外迭代对其运行时间的影响可以忽略不计,它仍然是 O(m * n)。
关于断言的说明
在这个断言中,我撒了一点谎。有一个边缘情况我没有正确处理。当我写下这个断言时,我考虑的是输入图 G 的常见情况,即存在一条从 S 到每个其他目标 V 的路径,也就是说,所有最短路径距离都是有限的输入图。如果不是这种情况,那么所述的断言就不正确。一种理解方式是考虑一个退化实例,其中源顶点 S 根本没有出弧,而其他顶点可能形成一个负成本环。在这种图中,该断言的左侧为假,但右侧却得到满足。
因此,为了修改它以适应可能具有无限距离的图,我将修改左侧,改为:G 有一个从源顶点 S 可达的负成本环。
实际上,如果你想检测输入图中是否存在负环(无论是否从 S 可达),你可以使用贝尔曼-福特算法通过各种技巧来解决这个问题。例如,给定一个输入图,你可以添加一个虚拟的额外顶点,并从该顶点向所有其他顶点添加长度为 0 的弧,然后在该图上运行贝尔曼-福特算法,如果存在负成本环,它将被检测出来。
证明断言
既然我们知道了为什么希望断言成立,现在让我们理解它为什么成立。让我们进入证明。
该断言断言了一个“当且仅当”的关系:左侧是输入图没有负成本环的属性,右侧是如果你多运行一次迭代,贝尔曼-福特算法不会做出任何更改的属性。
像这样的证明有两个部分:假设左侧成立,证明右侧;假设右侧成立,证明左侧。这两个部分中,如果你仔细想想,我们已经完成了一个。当我们证明贝尔曼-福特算法对于没有负成本环的图是正确的时候,我们就已经完成了。也就是说,如果左侧成立,如果输入图没有负成本环,我们已经论证过,你不需要将外层 for 循环运行超过 I = n - 1。这足以捕获最短路径。因此,特别是,取任意大的 I,例如 I = n,你也不会看到更短的路径。你将得到完全相同的子问题解。
那么,内容就是反向方向。因此,让我们假设我们多运行了一次贝尔曼-福特迭代,并且没有任何子问题解发生变化。我警告过你,当输入图没有从 S 到所有其他顶点的路径并且你有无限距离时,存在这个边缘情况。我将这些细节留给你,所以让我们只关注从 S 到其他所有顶点都存在路径的情况,特别是这些子问题值将是有限的。
用一点符号表示,我将使用小写 d(v) 表示顶点 V 在最后两次迭代(当 i = n - 1 和 i = n 时)中子问题的公共值。
现在的计划是,我们将仔细研究用于评估这些子问题的公式。它就在贝尔曼-福特算法的伪代码中盯着我们。从那里,我们将得到一个将这些 d 值相互关联的不等式,并且从那个不等式,我们将能够轻松推断出输入图的每个环确实是非负的——这就是陈述的左侧。
我们用什么公式来填充表格的这次额外迭代?A[n][V],但我们只是取了两者中较好的一个:一方面是 A[n-1][V],即前一次迭代的解;另一方面是使用最后一跳 (W, V) 并连接一条最多有 n-1 条边到 W 的路径以及那条边 (W, V) 的最佳候选者。
还要注意,用我们的新符号表示这些小的 d 值(子问题在第 n-1 次和第 n 次迭代中的公共值),我们可以将这个公式的左侧写为 d(V),在情况 2 的子问题中,我们可以将 A[n-1][W] 写为 d(W)。
因为这个等式的左侧是右侧一系列候选者的最小值,如果我们实例化,如果我们放大右侧任何一个候选者,即任何最后一跳 (W, V) 的选择,我们得到的东西至少和左侧一样大。同样,左侧是所有候选者中最小的。
因此,特别是对于给定的最后一跳 (W, V) 的选择,我们得到 d(V) <= d(W) + c(W, V),其中 c(W, V) 是从 W 到 V 的边的长度。
实际上,这个不等式所说的只是,从 S 到 V 的一条路径的一种方式是取一条从 S 到 W 的路径并连接最后一跳 (W, V)。到 V 的最短路径只能比这个经由 W 的特定候选者更好。

现在,记住我们试图证明什么:我们试图证明输入图没有负成本环。让我们只选择我们最喜欢的环 C,并证明它具有非负成本。
这将是我们刚刚写下的粉色不等式的巧妙应用。具体来说,我们将对该不等式在环中的所有边上求和。如果我稍微重新排列一下那个粉色不等式,就会很清楚。
让我们看看环 C 中边长的和。记住,这就是我们想要证明是非负的。
我们对大 C 中的边 (W, V) 求和,对于每条边,我们查看其成本 c(W, V)。根据粉色不等式,我们可以用环 C 中边的端点 d 值之差的求和来下界这个和。
注意,对于环上的一条给定弧 (W, V),这条弧的尾部 W 的 d 值以系数 +1 出现,而这条弧的头部 V 的 d 值以系数 -1 出现。
但是,环当然有一个非常特殊的属性:环的每个顶点恰好作为某条弧的尾部出现一次,也恰好作为某条弧的头部出现一次。因此,环上每个顶点的 d 值将出现一次,系数为 +1,出现一次,系数为 -1。所以我们得到巨大的抵消,只剩下零。
因此,环 C 具有非负成本。C 是任意环,所以它同时适用于输入图中的所有环。这正是我们试图证明的。
总结
本节课中,我们一起学*了如何扩展贝尔曼-福特算法以检测负成本环。我们看到,输入图中负成本环的存在与否,可以通过贝尔曼-福特算法在额外一次迭代中的行为来表征。这就是为什么很容易扩展基本算法来检查负环,而不影响其运行时间。
134:空间优化 🧠

在本节课中,我们将学*如何优化贝尔曼-福特算法的空间复杂度。我们将从回顾基础算法的空间需求开始,然后探讨如何将空间复杂度从二次降低到线性,同时保留重建最短路径的能力。
空间需求分析 📊
上一节我们讨论了贝尔曼-福特算法的各种运行时间优化,本节中我们来看看如何优化算法所需的空间。
为了明确方向,我们先思考一下目前所研究的基础算法需要多少空间。
基础贝尔曼-福特算法所需的空间与顶点数量呈二次方关系,即 Θ(n²)。这是因为空间主要由我们填充的二维数组主导。每个子问题只占用常量空间,而子问题的数量是 n² 个。其中一个索引是允许使用的边数预算(i 从 0 到 n-1),另一个索引是目的地(共有 n 个)。
在本视频中,我们将讨论如何做得更好,如何仅使用线性空间而非二次方空间。
空间优化思路 💡
初始观察很简单,让我们回到用于填充数组所有条目的公式。
我们总是考虑一大堆候选方案。候选方案是什么?要么我们可以继承上一轮迭代的解决方案 A[i-1, v],要么我们可以选择某个顶点 w 作为最后一段路径 w→v,然后我们将上一轮中到 w 的最佳解决方案粘贴过来,即 A[i-1, w] + c(w, v)。
但关键在于,观察这个公式,我们关心哪些子问题的值?其实并不多。特别是,所有有趣候选方案共有的特点是它们都来自上一轮迭代。右侧所有项的第一个索引始终是 i-1。
因此,一旦我们完成了某一轮 i 值的子问题计算(即计算了所有 v 的 A[i, v]),我们就可以丢弃之前所有轮次的子问题结果(A[i-1], A[i-2] 等)。我们只需要最新一轮的结果来正确推进计算。
如果我们这样做,我们跟踪的唯一子问题就是当前轮次 i 正在填充的那些,以及我们记住的上一轮 i-1 的解决方案。这意味着在任何给定时刻,我们只跟踪 O(n) 个不同的子问题。
这个线性空间界限比初看起来更令人印象深刻。因为如果你考虑算法在这个问题中的职责,我们必须输出线性数量的结果。我们不只是输出一个数字,而是输出 n 个数字(从源点 s 到每个可能目的地的最短路径距离)。因此,空间实际上是我们必须计算的每个统计量的常数倍。
空间优化的潜在影响 ⚠️
现在是一个暂停的好时机,思考几秒钟这种空间优化可能带来的负面影响。
假设我们这样做,无论是在贝尔曼-福特最短路径还是其他动态规划算法的背景下,我们丢弃了看似不再需要的旧子问题解的值,以推进递推关系。这种空间优化有什么缺点吗?
答案取决于你的目标。如果你只关心最优解的值,而不是最优解本身,那么你完全可以丢弃所有不再需要的旧子问题,以推进递推关系。这根本不会对你有任何损害。
然而,如果你想要最优解本身,而不仅仅是它的值呢?过去我们是如何实现这一点的?我们使用了重建算法。重建算法是如何工作的?你获取整个填充好的表格,然后向后追溯。在填充好的表格的每个条目中,你查看哪个候选方案胜出,即填充该条目时哪个比较胜出,这告诉了你最优解必须是什么样子的一个片段,然后你向后遍历表格并重复这个过程。如果你丢弃了大部分填充好的表格,你将如何运行这个重建算法?在贝尔曼-福特算法的背景下,如果你没有记住之前所有迭代的所有子问题,你将如何重建最短路径?
你当然可以想象,例如在路由应用中,你不仅想知道最短路径长度为 17,你还想知道我们应该走哪条路线才能从点 A 到点 B。
重建最短路径的解决方案 🛠️
在本视频的剩余部分,我将描述贝尔曼-福特算法的一个解决方案。我将向你展示如何在保持每个顶点常数空间保证的同时,恢复重建最短路径的能力。
思路是,对于给定的 i 值和给定的目的地 v,我们不仅要跟踪一条信息(从 s 到 v 使用最多 i 条边的最短路径的长度),还要跟踪第二条信息,即该路径上的倒数第二个顶点。这样每个子问题仍然只需要常数空间。
我们将这个二维数组称为 B,我们称其条目为前驱指针。这个指针指向从 s 到 v 使用最多 i 条边的最短路径上,目的地 v 的前驱顶点。当然,如果 i 足够小,可能不存在从 s 到 v 的路径,在这种情况下,我们只有一个空的前驱指针。
前驱指针如何工作 🔗
暂时忘记我们试图实现的空间优化,让我们先观察一下,如果我们正确计算了这些 B 值,那么简单地遍历前驱指针就能重建最短路径。为什么这是正确的?
有两个原因。第一个原因是,请记住我们假设输入图没有负成本环,因此最短路径最多有 n-1 条边。所以,B[n-1, v] 实际上存储了从 s 到 v 的无边预算最短路径的最后一跳。在 B[n-1, v] 中,最后一批前驱指针(如果我们正确计算了它们)告诉我们到 v 的最短路径的最后一跳。
正确性的另一部分来自最优子结构引理。回想一下,当我们开始讨论最短路径及其最优子结构时,我们说,如果我们有一个“小鸟”告诉我们最短路径的最后一跳是什么,那么最短路径就只是那一跳与从 s 到倒数第二个顶点 w 的最短路径连接起来。而这些前驱指针正是我们存储在 v 处的“小鸟”,它告诉我们最后一跳是 w→v。因此我们知道,除了最后一跳,从 w 回到 s 的最短路径,通过遍历,你就能重建出从 s 到 w 的路径的其余部分。
因此,如果我们正确计算了前驱指针,那么它们就能在事后通过简单的遍历来重建最短路径。
计算前驱指针 📝
这就给我们留下了正确计算这些前驱指针的任务。这并不难,我想你们中的许多人可以自己填写细节,但让我快速概述一下。
总的来说,我们有一个二维数组 B,它由边预算 i 和目的地 v 索引。我们应该用从 s 到 v 使用最多 i 条边的最短路径上的最后一跳来填充 B[i, v]。如果不存在这样的路径,那么它就是 null。对于基本情况,即 i = 0 时,每个人的前驱指针都是 null。
我们填充条目 B[i, v] 的方式将取决于在竞争成为 A[i, v] 的最短路径时,哪个候选方案胜出。本质上,这个前驱指针 B[i, v] 只是缓存了我们为找到 A[i, v] 的最短路径而进行的竞争结果。
为了使其精确,让我们回忆一下我们用来计算 A[i, v] 的公式,给定上一批子问题的解。
情况1 是你从上一轮继承解决方案 A[i-1, v]。此外,每个可能的最后一跳选择 w, v 都提供了本轮最优解的另一个候选方案,你将支付子问题 A[i-1, w] 的最优解加上边 wv 的长度。
我们将填充 B 数组,基本上只是为了反映我们计算 A[i, v] 解时发生的情况。本质上,我们在二维数组 B 中所做的是记住负责提供从 s 到 v 的新最短路径的最新顶点 w。
在情况1(无聊的情况)中,在迭代 i 时,我们只是从上一轮继承解决方案。当然,在 B 条目中,我们也只是继承上一轮的最后一跳。也就是说,如果我们使用情况1来填充 A[i, v],那么我们只需设置 B[i, v] = B[i-1, v]。
有趣的情况是当 A[i, v] 使用公式的情况2填充时。即当从 s 到 v 的最短路径突然改善,给定 i 跳的预算,而不是仅仅 i-1 跳。在这种情况下,我们只需缓存我们为评估公式而进行的暴力搜索的结果,即我们只需记住在本轮中达到最小值的倒数第二个顶点 w 的选择。

B[i, v] = w,其中 w 是使 A[i-1, w] + c(w, v) 最小化的顶点。
请注意,就像我们用来填充 A 数组的公式一样,要计算这些 B[i, v],我们只需要知道来自上一轮(第 i-1 轮)的信息。因此,就像 A 数组一样,我们可以丢弃所有早于上一轮的前驱指针。因此,我们再次只需要每个目的地 v 的常数空间,来维护从 s 到 v 使用最多 i 跳的最短路径距离和前驱指针。
处理负成本环 🔄
这对于没有负成本环的输入图来说非常棒。我们不仅可以在每个目的地使用常数空间计算最短路径,还可以在相同的空间中计算前驱指针,从而允许重建最短路径。
你可能还想要处理确实有负成本环的图。在另一个视频中,我们展示了检查输入图是否有负成本环可以通过贝尔曼-福特算法的一个简单扩展轻松解决:你在外部 for 循环上增加一次额外的迭代,让 i 的范围不仅到 n-1,而且一直到 n。如果你在额外迭代中(当 i = n 时)看到某个目的地 v 有改进,那么这保证存在一个负成本环,并且是充分必要条件。
同样地,你可能想要实际的最短路径,而不仅仅是它们的值;你可能想要一个负成本环,而不仅仅是知道它存在。
事实证明,你也可以使用贝尔曼-福特算法和前驱指针来解决负成本环的重建问题,方式与我们在这里维护它们的方式完全相同。我不会讨论解决方案的所有细节,我将把它留给你作为一个有点不简单的练*来思考这到底是如何工作的。但要点如下:
你运行贝尔曼-福特算法,按照本幻灯片上的方式维护前驱指针。如果输入图确实有负成本环,那么在某个迭代中,你会在前驱指针中看到一个环。此外,那个前驱指针环必须是原始输入图的一个负成本环。这意味着,检测负成本环(如果存在)就简化为检查前驱指针中是否存在环,这当然可以在每次迭代中使用深度优先搜索来解决。
总结 📚
本节课中我们一起学*了如何优化贝尔曼-福特算法的空间复杂度。我们从分析基础算法的二次空间需求开始,然后探讨了通过仅保留最*一轮子问题解将空间降至线性的方法。我们进一步学*了如何通过维护前驱指针,在保持线性空间的同时,重建最短路径本身。最后,我们简要提及了该方法也可用于检测和重建负成本环。通过这种优化,算法在空间效率上得到了显著提升,同时保持了完整的功能性。
135:互联网路由(全)🌐

在本节中,我们将探讨贝尔曼-福特算法如何为现代互联网路由协议提供理论基础。我们将了解该算法如何从理论走向实践,并讨论为实现这一转变所需的关键修改。我们将重点关注三个主要问题及其解决方案,最终得到一个与当今互联网协议工作原理非常接*的模型。
从贝尔曼-福特算法到分布式路由
在课程开始时我们提到,贝尔曼-福特算法为现代互联网路由协议奠定了基础。本视频将补充一些细节。
首先,当你查看贝尔曼-福特算法的代码时,直观上它似乎是一种分布式算法。计算给定子问题 A[I, V] 时,顶点 V 只需要知道所有可能的前一跳(即所有能直接与 V 通信的顶点)在上一轮迭代中的子问题解。因此,在每一轮中,每个顶点在某种意义上只与其直接连接的邻居顶点通信。
然而,从基础的贝尔曼-福特算法过渡到实际可用的路由协议,需要解决一系列工程挑战。接下来,我们将重点讨论其中一些主要问题及其高层解决方案。通过对基础算法进行这些修正,我们将得到一个与当今互联网协议工作原理惊人相似的模型。
需要说明的是,这里的讨论将相对简要,并有意进行一定程度的简化。若想深入理解此主题,建议阅读网络相关书籍、选修网络课程或在网上查阅更多资料。
修改一:目的地驱动的路由
我们将讨论对基础贝尔曼-福特算法的三项修改,并按从最简单到最复杂的顺序进行。
第一项也是最简单的修改,其动机源于互联网路由是目的地驱动的这一事实。对于在互联网中传输的数据,你并不太关心它来自哪里,真正关心的是它需要去哪里。这与传统邮件路由类似:我只需写明收件人地址,邮件系统便会根据目的地进行路由。
在互联网中,数据包根据其目的IP地址,决定需要经过哪些中间路由器序列才能到达最终目的地。
为了适应目的地驱动的路由,只需将贝尔曼-福特算法中的所有方向反转。我们不再从一个源顶点 S 出发计算所有最短路径,而是以一个目的顶点 T 为终点,计算从所有可能起点到它的最短路径。每个顶点存储的不再是从 S 到该顶点的最短路径上的前驱指针,而是到目的地 T 的最短路径上的第一跳。
当然,互联网中的路由器不能只为单一目的地 T 优化,它必须准备好处理发往互联网中任何地方的数据。因此,每个顶点需要存储到每一个相关目的地 T 的最短路径距离和第一跳信息。
这听起来计算量很大,因为世界上存在大量IP地址。但得益于互联网的分层结构,大多数计算机并不需要知道如何到达所有目的地。例如,斯坦福网络内的一台计算机,只需要知道如何到达网络内的其他计算机(数量不多),以及如何到达斯坦福的网关路由器。对于发往斯坦福网络外的数据,它只需将责任委托给网关路由器即可。
另一方面,位于互联网核心的路由器确实需要知道如何到达各种不同的地方,其路由表条目可能多达数十万条。因此,路由表的实现是网络工程师重点研究的领域,涉及硬件和软件优化,并致力于控制路由表规模,例如避免IP地址碎片化。
基于贝尔曼-福特的最短路径协议有时被称为距离向量协议。这里的“距离向量”指的是,在给定顶点处,有一个以可能目的地 T 为索引的向量,记录了到所有这些目的地的最短路径距离和第一跳信息。
修改二:处理异步性
我们需要解决的第二个问题更为严重,但也不算太糟糕,那就是异步性问题。
回顾基础的贝尔曼-福特算法,它是同步的:我们小心地构建了外层循环,确保所有索引为 I-1 的子问题在索引为 I 的任何子问题开始求解之前都已解决。但在互联网中,不同的路由器、计算机运行速度不同,物理连接的带宽也不同,无法保持同步,无法在互联网规模上实现同步的轮次。
但令人称奇的是,贝尔曼-福特算法对于子问题的求解顺序具有惊人的鲁棒性。实际上,只要以任何合理的顺序求解,最终仍能计算出正确的最短路径。
为了解释这一点,让我们将贝尔曼-福特算法从基于拉取改为基于推送。
基础算法是基于拉取的:在每次外层迭代 I 中,每个顶点 V 会向其邻居询问它们的最新信息(即上一轮外层循环的子问题解值)。我们将改为基于推送:每当你的子问题值发生变化,有新的信息要说时,你就直接告诉所有邻居,将信息“推送”给它们,无论它们是否需要。
以下是一个简单的例子,希望能阐明这个想法。
考虑这个绿色网络,假设我们试图计算从所有地方到目的地 T 的最短路径(注意,我们已经从源驱动切换到了目的地驱动路由)。
- 初始时,
T知道它以成本0到达自己,其他所有顶点只有+∞。 - 开始时,目的地
T将通知其所有邻居,它可以通过长度为0的路径到达自己。因此,它会通知V和W(U不直接连接T,暂未获知此信息)。 - 由于互联网是异步的,即使
T同时向V和W发送消息,我们也不知道V或W谁先收到。假设W先得知T有成本为0的路径。 - 此时,
W更新其到T的最短路径估计值,从+∞降至4(边W-T的成本)。 - 由于我们采用基于推送的实现,
W有了新信息,它需要告诉所有邻居。于是,它告诉邻居U,它有一条长度为4的路径到T。 - 同时,
T发给V的消息仍在网络中传输。假设W发给U的消息先于T发给V的消息到达。 U得知后,计算出一条经W到T、总成本为7(U-W成本3 +W-T成本4)的路径。- 之后,
T的消息到达V。V更新其到T的估计值,从+∞降至2(边V-T成本)。 V随后通知其邻居U,它有一条长度为2的路径到T。U收到后,发现这比之前的路径(成本7)更优,于是更新其路径为经V到T,总成本为3(U-V成本1 +V-T成本2)。
在这个特定例子中,这种异步的、基于推送的贝尔曼-福特实现正确地计算出了最短路径。事实上,这在任何网络中都是普遍成立的。当然,陈述这一事实时,我们假设图中没有负环。在互联网路由的背景下,负环通常不是问题,因为所有边的长度(成本)通常被视为非负的。

算法最终会收敛,本质上是因为每次更新都严格降低了某个顶点到目的地 T 的最短路径距离估计值,而可能的配置状态是有限的,因此最终必然会达到正确的最短路径距离。
这个粗略的收敛性论证只给出了在正确计算最短路径之前可能需要的更新次数的指数级上界。事实上,至少在理论最坏情况下,这种异步版本的贝尔曼-福特算法可能需要指数级的更新次数才能成功找到所有最短路径,这是一个值得思考的非平凡问题。
因此,如果你有机会在同步或集中式环境中实现贝尔曼-福特算法,你确实应该使用我们在基础版本中讨论的同步版本。如果你处于异步环境中,除了异步实现外别无选择,此时你只能希望在你的特定网络中,收敛时间远好于最坏情况的指数级上界。
历史背景与延伸
20世纪50年代提出的原始贝尔曼-福特算法,加上我们目前讨论的这些修改,催生了直到20世纪70年代仍在部署的路由协议。如果你想了解更多,可以查阅 RIP 和 RIP2 互联网路由协议。
如果你对此非常感兴趣,可以查看与这些协议相关的征求意见稿,例如 RFC 1058。RFC(征求意见稿)是互联网社区审议拟议修改和协议的一种机制。如果你想了解具体细节,RFC 是一个很好的查阅来源。
总结
本节课中,我们一起学*了贝尔曼-福特算法如何应用于互联网路由。我们首先了解了互联网路由是目的地驱动的,因此需要反转算法的计算方向。接着,我们探讨了在异步网络环境中,需要将算法从同步的、基于拉取的模型改为异步的、基于推送的模型,并理解了其收敛原理。这些修改使得贝尔曼-福特算法从一个理论算法,转变为一个接*实际互联网路由协议(如RIP)的实用基础。
136:互联网路由二(可选)🌐

在本节中,我们将探讨互联网路由中一个关键且实际的问题:网络链路故障对分布式最短路径算法的影响。我们将分析经典的“计数到无穷”问题,并了解现代互联网如何通过“路径向量协议”来解决这一挑战。
上一节我们介绍了异步贝尔曼-福特算法在静态网络中的收敛性。本节中我们来看看当网络链路频繁发生故障时,会出现什么问题。
网络故障与“计数到无穷”问题 🚨
互联网中的节点和链路会不断发生故障。虽然我们之前论证了异步贝尔曼-福特最短路径路由协议在静态网络中能保证收敛到正确的最短路径,但这假设了网络是静态的,没有链路频繁通断。
在存在故障的情况下,一个特别简单但严重的问题被称为“计数到无穷”问题。我将展示一个特别设计的简化版本来阐明要点,但实际上很容易构造出更现实的例子。这个问题不仅仅是分布式贝尔曼-福特算法的理论缺陷,在20世纪70年代的路由协议实践中确实被观察到了。
想象一个超级简单的网络,包含顶点S、V和T。存在从S到V的双向弧,以及一条从V到T的弧。所有边的成本均为1,并且我们正在计算到目的地T的路由。
代码描述初始状态:
顶点: S, V, T
边: S<->V (成本1), V->T (成本1)
到T的最短路径距离: d(T)=0, d(V)=1, d(S)=2
为了更现实的例子,你可以将S和V之间的弧视为代表具有多跳的更长路径。无论如何,假设我们已成功计算了这个基本网络上的最短路径。
故障发生时的错误传播过程 🔄
问题是互联网中的链路会不断故障。在某个时刻,从V到T的这条链路可能会中断。
此时,V会注意到其到T的链路已失效,并必须将其到T的最短路径估计重置为正无穷(+∞)。为了恢复与T的连接,V会询问其邻居是否拥有到T的路径以及路径长度。具体来说,V会询问S。
S会回复:“是的,我有一条到T的路径,距离仅为2。” V则会想:“太好了!我当前到T的估计是+∞。我可以以成本1到达S,而S说它可以以成本2返回T,那么我就得到了一条长度为3到T的路径。”
当然,这个推理的缺陷在于,S原本依赖于V才能到达T,而现在V却反过来打算在它的“认知”中利用S来绕回T。
这已经说明了在存在链路故障的情况下,天真地实现异步贝尔曼-福特算法的危险性。
那么“计数到无穷”从何而来呢?你可以想象,对于该协议的某些实现,S随后会说:“哦,V刚刚将其到T的最短路径估计修订为3了。我的下一跳是V,所以如果V到T需要更长时间,那么我到T也需要更长时间。” 于是,S将其最短路径距离更新为4。
这个过程会持续下去:V看到S的距离变为4,于是将自己的距离更新为5(1+4),S再更新为6,如此循环,数值将无限增长。
从距离向量到路径向量协议 🛡️
故障给分布式最短路径路由协议的收敛带来了问题,不仅仅是“计数到无穷”,还有其他问题。这是一个棘手的问题。在20世纪80年代,人们提出了许多针对基本协议的“补丁”方案来解决故障,其中一些方案的名字相当奇特,例如“带毒性逆转的水平分割”。
但最终,人们从所谓的“距离向量协议”转向了“路径向量协议”。
在路径向量协议中,每个顶点不仅维护到每个可能目的地的下一跳,它们实际上还在本地保存从V到每个目的地T将使用的整个路径。
这颇具讽刺意味,因为这本质上是我们一直在刻意避免的动态规划中重构最优解的方法。回想我们最初讨论重构算法时(回溯路径图的最大独立集问题),我们首先说可以在正向遍历数组时不仅存储最优解的值,还存储最优解本身。但我们当时认为,这不是最好的主意,因为它浪费时间和空间。更聪明的方法是通过对已填充的表格进行反向遍历来重构最优解。
但这里的情况是,这种不使用额外时间或空间的优化版重构算法,其鲁棒性不足以承受互联网路由的变幻莫测。因此,我们采用了更“原始”的解决方案,即直接存储最优解(路径),而不仅仅是解的值(距离)。这就解释了为什么这被称为路径向量协议:距离向量协议存储到每个可能目的地的距离,而这里我们实际上存储到每个可能目的地的路径。
缺点正如我们一直所说的:如果存储最优解而不仅仅是最优解的值,将占用更多空间。事实上,这样做会显著增加路由器中路由表的大小,这是一个不争的事实。

路径向量协议的优势 ✨
但转向路径向量协议不仅增加了鲁棒性,还实现了新功能。
以下是路径向量协议带来的两个重要好处:
-
增强的故障鲁棒性:我不会详细讨论如何利用这些额外的路径信息来更好地处理故障,但在我们设计的动机示例中,你可以看到,如果S和V实际上知道它们正在使用的到T的整个路径,它们就能确保不会陷入这个“计数到无穷”的问题。
-
支持基于策略的路由:传递整个路径允许顶点对路由表达比仅基于链路成本更复杂的偏好。例如,假设你是一家小型互联网服务提供商,你与AT&T的合同比与Sprint的合同有利得多,因此通过AT&T作为中介发送流量的成本远低于通过Sprint。现在假设你正在运行这个分布式最短路径协议,你的两个邻居分别提供两条到达某个目的地T的路径:第一条路径经过AT&T,第二条路径经过Sprint。当然,你知道这些信息的唯一原因是它是一个路径向量协议,你传递的是实际路径,可以看到中间经过哪些节点。那么你就可以自由决定:“我将使用经过AT&T的路径作为我到T的路径,并且我将把这个路径通告给其他人。我会忽略经过Sprint的那条路径。”
边境网关协议(BGP)大致就是这样工作的,该协议管理着流量如何通过互联网核心进行路由。
本节课中我们一起学*了互联网路由中由链路故障引发的“计数到无穷”问题,并了解了现代互联网如何通过从距离向量协议演进到路径向量协议来解决这一问题。路径向量协议通过存储完整路径信息,不仅增强了网络在故障下的鲁棒性,还为实现基于商业策略的复杂路由选择提供了可能。这为我们关于最短路径算法自20世纪50年代以来,在过去半个世纪中如何对互联网路由演化起到基础性作用的讨论画上了句号。
137:问题定义 🎯

在本节课中,我们将要学*全对最短路径问题的定义。我们将探讨为什么需要计算图中所有顶点对之间的最短路径,并分析如何利用已有的单源最短路径算法来解决此问题,同时评估不同情况下的算法效率。
为什么需要全对最短路径?
上一节我们介绍了单源最短路径问题。本节中我们来看看,为什么我们不应该满足于仅计算从一个源点到所有其他顶点的最短路径?如果我们想知道从每一个顶点到每一个其他顶点的最短路径距离,该怎么办?

问题正式定义
全对最短路径问题的正式定义如下:
我们通常被给定一个有向图 G,其边具有长度 Ce。你可以考虑所有边长度均为非负的特殊情况,但我们同样对边长度可能为负的情况感兴趣。
与单源最短路径问题不同,这里没有指定的源顶点。问题的目标是计算对于每一对顶点 U 和 V,从 U 开始到 V 结束的最短路径长度。
与问题的单源版本一样,这并非全部情况。如果输入图 G 包含负权环,那么根据你如何定义“最短路径”,问题要么没有意义,要么在计算上是棘手的。因此,如果存在负权环,我们就不必计算最短路径距离,但我们需要正确报告图中包含负权环,这是我们不计算正确最短路径长度的理由。
能否用现有工具解决?
如果你看到这个问题时想:“我们不是已经有足够丰富的工具箱来解决全对最短路径问题了吗?” 这是一个很好的想法。从许多意义上说,答案是肯定的。
让我们来探索这个想法,使其更精确。在下面的思考中,我将问你:假设我给你一个能正确且快速解决单源最短路径问题的子程序(黑盒)。你需要调用这个黑盒子多少次,才能正确解决全对最短路径问题?
以下是调用次数的可能选项:
- A. 1次
- B. m次(m是边数)
- C. n次(n是顶点数)
- D. n²次
正确答案是 C。你需要调用单源最短路径子程序 n 次,其中 n 是输入图中的顶点数。
为什么?如果你指定任意一个顶点作为源点 S,然后运行提供的子程序,它将为你计算从该 S 到所有目的地的最短路径距离。这样,你就计算出了 n 个最短路径距离(对应所有以 S 为起点的路径)。而你需要负责计算所有顶点对(共 n² 对)的距离。可能的起点有 n 个不同的选择,因此你只需遍历所有这些选择,对每个起点调用一次提供的算法,就能得到所有 n² 个最短路径距离。
我们应该满足于这个方案吗?
我们应该满足于这个简单地运行 n 次单源最短路径算法的方案吗?还是我们期望做得更好?
答案将取决于两个因素:
- 输入图的边权是否全为非负,还是更一般地也允许负边权。
- 图是稀疏的(边数 m 接* n)还是稠密的(边数 m 接* n²)。

情况一:所有边权非负
边权是否全为非负很重要,因为它决定了我们可以使用哪个单源最短路径子程序。在边权全为非负的“快乐”情况下,我们可以使用 Dijkstra 算法 作为主力。
记住,Dijkstra 算法速度极快,我们基于堆的实现运行时间为 O(m log n)。如果你运行它 n 次,总运行时间自然是 O(n * m * log n)。

- 在稀疏图情况下(m ≈ n),这将是 O(n² log n)。
- 在稠密图情况下(m ≈ n²),这将是 O(n³ log n)。
对于稀疏图,这个结果相当不错。你不太可能比针对每个源点运行一次 Dijkstra 算法做得更好。原因是我们需要输出 n² 个值(每对顶点 U、V 的最短路径距离)。而这里的运行时间仅仅是 n² 乘以一个额外的对数因子。
然而,对于稠密图,情况则模糊得多。是否存在从根本上快于立方时间(O(n³))的算法来解决稠密图的全对最短路径问题,至今仍是一个开放性问题。
如果你想说服某人可能无法做得比立方时间更好,你可能会这样论证:需要计算的最短路径距离数量是平方级的(n²)。而对于给定的一对 U 和 V,最短路径可能包含线性数量的边。因此,你肯定无法在线性时间内计算出一对顶点之间的最短路径,所以要做平方级次计算,就必然是立方时间。
但需要明确的是,这并非证明,只是一个模糊的论证。为什么不是证明?因为我们有可能做一些工作,这些工作同时与许多最短路径问题相关,你实际上不必为每个问题平均花费线性时间。
作为一个启发,让我提醒你关于矩阵乘法。如果你写下两个矩阵相乘的定义,看定义会觉得这显然是一个立方级问题。似乎根据定义,你必须做立方级的工作量。然而,这种直觉是完全错误的。从 Strassen 算法开始,以及后来的许多算法,我们现在知道存在从根本上优于朴素立方时间算法的矩阵乘法算法。如果你有一个非平凡的问题分解方法,你可以消除一些冗余工作,做得比直接解法更好。那么,对于全对最短路径问题,是否存在类似 Strassen 的改进?目前无人知晓。
情况二:允许负边权的一般情况
在这种情况下,我们不能使用 Dijkstra 算法作为我们的单源最短路径子程序,我们必须转而使用 Bellman-Ford 算法,因为这是两者中唯一能处理负权边的算法。
记住,Bellman-Ford 算法比 Dijkstra 算法慢。我们证明的运行时间上界是 O(m * n)。如果我们运行它 n 次,我们得到的总运行时间是 O(m * n²)。
O(n² * m) 的运行时间有多好?
- 如果图是稀疏的(m ≈ n),那么这是 O(n³)。
- 如果图是稠密的(m ≈ n²),那么我们得到了本课程中第一个四次方运行时间 O(n⁴)。
我希望你对稀疏图情况的立方运行时间上界并不特别满意。而现在,当我们谈论四次方运行时间时,这真的显得过于高昂了。因此,希望你正在思考:对于稠密图情况,一定有比仅仅运行 n 次 Bellman-Ford 算法更好的方法。
确实存在更好的方法,那就是 Floyd-Warshall 算法。我们将在下一个视频中开始讨论它。
本节课中我们一起学*了全对最短路径问题的定义,分析了通过多次调用单源最短路径算法(Dijkstra 或 Bellman-Ford)来解决该问题的思路,并评估了在不同图结构(稀疏/稠密)和边权(非负/含负)情况下的算法效率。我们认识到对于稠密图或含负权图,简单的多次调用方法效率低下,从而引出了对更优算法(如 Floyd-Warshall)的需求。
138:最优子结构

概述
在本节课中,我们将学*所有点对最短路径问题的最优子结构性质。我们将从零开始推导一个算法,而不是依赖于将其归约为单源最短路径问题。本节将重点阐述最优子结构引理,为下一节推导著名的弗洛伊德-沃舍尔动态规划算法奠定基础。
算法背景与比较
上一节我们讨论了通过多次运行单源最短路径子程序来解决所有点对最短路径问题的性能。弗洛伊德-沃舍尔算法在许多情况下是更好的解决方案。
首先,对于允许边权为负的一般图,弗洛伊德-沃舍尔算法表现良好。之前的解决方案是运行贝尔曼-福特算法 n 次,无法使用迪杰斯特拉算法,因为它在负边权情况下通常不正确。运行贝尔曼-福特算法 n 次的时间复杂度是 O(n²m)。即使在稀疏图的最佳情况下,弗洛伊德-沃舍尔算法的 O(n³) 时间复杂度与之相当,并且在稠密图中表现更好(n 次贝尔曼-福特是 O(n⁴),而弗洛伊德-沃舍尔是 O(n³))。
其次,对于边权非负的图,归约为单源问题实际上是一个很好的解决方案,因为迪杰斯特拉算法非常快。运行迪杰斯特拉算法 n 次的时间复杂度是 O(n m log n)。对于稀疏图,您会希望使用 n 次迪杰斯特拉而不是弗洛伊德-沃舍尔,因为您将获得大约 O(n²) 的运行时间,而不是这里的 O(n³)。对于稠密图,运行 n 次迪杰斯特拉的时间复杂度大约也是 O(n³),与弗洛伊德-沃舍尔算法大致相同,两者在实践中性能相*。
弗洛伊德-沃舍尔算法的一个常见应用是计算二元关系的传递闭包。在图论语言中,您可以将其视为计算所有点对的可达性。这是所有点对最短路径问题的一个特例,您只想知道每对顶点之间的最短路径距离是有限的还是无限的。如果您只关心传递闭包问题,可以在弗洛伊德-沃舍尔算法中进行一些优化以加快常数因子。
虽然 O(n³) 的运行时间可能并不十分出色,但这是目前已知的最优算法之一。是否存在显著优于 O(n³) 的算法来解决所有点对最短路径问题,至今仍是一个开放性问题。
定义子问题
现在,让我们形式化所有点对最短路径问题的最优子结构,弗洛伊德-沃舍尔算法正是利用了这一点。
首先需要说明的是,将动态规划应用于图问题可能具有挑战性,因为输入没有明确的顺序。一个巧妙的解决方案是引入一个额外的参数来定义子问题的大小顺序。
在贝尔曼-福特算法中,我们引入了参数 i 作为路径中允许使用的边数(或顶点数)的预算。这自然地在子问题之间诱导了一个顺序:边预算越大,子问题越大。
在弗洛伊德-沃舍尔算法的解决方案中,我们将采取类似但更严格的方法。我们不仅会限制在给定起点和终点之间的路径中允许使用的顶点数量,还会限制允许使用的顶点的具体身份。
具体做法是,我们对顶点集 V 施加一个任意的顺序。我们将顶点命名为 1, 2, 3, ..., n。然后,使用符号 V^K 表示前 K 个顶点的前缀,即顶点 1 到 K。
最优子结构引理
与贝尔曼-福特的情况不同,我首先为没有负环的输入图证明最优子结构引理。我们将在算法完成后处理负环的情况。目前假设图中没有负环。
那么子问题是什么呢?它与贝尔曼-福特非常相似。在贝尔曼-福特中,我们解决单源最短路径问题,需要为每个目的地计算一些东西,这给了我们线性数量的子问题。然后,对于给定的目的地,我们有参数 i 控制边预算,这又是另一个线性因子,所以我们有二次数量的子问题。
在这里情况相同,只是我们还需要遍历所有起点。因此,我们将得到三次数量的子问题。具体来说,子问题由以下选择定义:
- 一个起点
i(从 1 到 n 的某个顶点)。 - 一个终点
j(从 1 到 n 的另一个顶点)。 - 一个界限
k,它规定了在路径内部允许使用哪些顶点(只能是顶点 1 到 k)。
约束仅适用于路径内部的顶点(中间节点),不适用于起点 i 和终点 j 本身。
现在,我们专注于这个子问题的一个最优解。在所有从顶点 i 开始、在顶点 j 结束,并且严格在 i 和 j 之间仅包含顶点 1 到 k 作为内部节点的路径中,我们寻找长度最短的那一条。由于我们考虑的是没有负环的情况,我们可以假设这条路径是无环的。
子问题示例
为了确保您理解这些子问题,让我们看一个例子。
假设起点 i 是我们任意标记为 17 的顶点。终点 j 是我们标记为 10 的顶点。假设当前的 k 是 5。
考虑下图(想象这是某个更大图的一小部分):
(图中显示顶点17和10之间有一条直接长度为-20的路径,经过顶点7;以及另一条经过顶点4和5、长度为3的路径)
从 17 到 10 的最短路径显然是底部那条 2 跳的路径,总长度为 -20。然而,对于我们正在处理的子问题,k = 5。这意味着我们对路径中间可以使用的顶点有额外的约束:只能使用前 5 个顶点(1 到 5)作为中间节点。
这个约束不适用于起点和终点。任何从 i 到 j 的路径都必须包含顶点 i 和 j。约束仅适用于路径中间的顶点。
不幸的是,底部那条 2 跳的路径使用了节点 7。节点 7 大于 5,因此该路径不满足约束,不允许使用。因此,从 17 到 10 且仅使用前 5 个标记顶点作为中间节点的最短路径,将是顶部的 3 跳路径,其长度为 3。
最优子结构陈述
现在,让我们进入最优子结构引理的完整陈述。这实际上是我们最*见过的最简单的引理之一,它只涉及两种情况。
情况一:最短路径不使用顶点 k
如果从 i 到 j 且仅使用顶点 1 到 k 作为中间节点的最短路径 P,根本没有使用顶点 k,那么它必然也是从 i 到 j 且仅使用顶点 1 到 k-1 作为内部节点的最短路径。

情况二:最短路径使用顶点 k
假设路径 P 确实在中间使用了顶点 k。那么我们可以将路径 P 视为由两个子路径组成:
- 第一个子路径
P1,从i开始,到达顶点k。 - 第二个子路径
P2,从k开始,到达终点j。
以下是关键点:在路径 P 上,i 和 j 之间的内部节点都在 1 到 k 之间。此外,路径 P 是无环的,因此顶点 k 恰好出现一次。所以,如果我们将路径 P 分成 P1 和 P2 两部分:
- 在
P1中,严格在i和k之间,只有顶点 1 到k-1。 - 在
P2中,严格在k和j之间,只有顶点 1 到k-1。
因此,这两条路径 P1 和 P2 都可以被视为更小子问题的可行解,这些子问题对允许的内部节点有更严格的预算 k-1。而且,它们不仅仅是更小子问题的可行解,它们本身就是这些子问题的最优解。
这个性质非常巧妙:具有最大索引的内部节点 k 只是将最短路径分割成了两个更小子问题的最短路径。
总结
本节课中,我们一起学*了所有点对最短路径问题的最优子结构性质。我们定义了基于顶点前缀的子问题,并分析了两种情况:最短路径是否使用允许集合中的最大索引顶点 k。这种结构化的分析方式,为下一节推导出高效的弗洛伊德-沃舍尔动态规划算法提供了清晰的理论基础。该引理的证明思路与贝尔曼-福特算法相似,鼓励您将其作为练*来完成。
139:弗洛伊德-沃舍尔算法

在本节课中,我们将学*如何将“所有点对最短路径”问题的最优子结构,编译成一个动态规划算法,即弗洛伊德-沃舍尔算法。我们将从理解基础情况开始,逐步构建算法,并讨论如何处理负成本环以及如何重构最短路径。
基础情况
上一节我们介绍了子问题的定义,它包含三个索引:起点 i、终点 j 和预算 k。本节中我们来看看如何确定这些子问题的基础情况,即当 k = 0 时的情况。
以下是填充 A[i][j][0] 的规则:
- 如果
i等于j,则存在一条空路径,其长度为0。 - 如果
i不等于j,且i和j之间有一条直接相连的边,则A[i][j][0]等于该边的成本C[i][j]。 - 如果
i不等于j,且i和j之间没有直接相连的边,则不存在任何不使用内部节点的路径,因此A[i][j][0]被定义为正无穷+∞。
算法实现
在明确了基础情况后,我们现在可以系统地编写弗洛伊德-沃舍尔算法的完整代码。算法的核心是使用一个三重循环,从小到大解决所有子问题。
# 假设 n 为顶点数量,C 为邻接矩阵(无边则为无穷大)
A = [[[0 for _ in range(n+1)] for _ in range(n)] for _ in range(n)]
# 初始化基础情况 (k=0)
for i in range(n):
for j in range(n):
if i == j:
A[i][j][0] = 0
elif C[i][j] is not None: # 存在直接边
A[i][j][0] = C[i][j]
else:
A[i][j][0] = float('inf')
# 动态规划主循环
for k in range(1, n+1):
for i in range(n):
for j in range(n):
# 情况1:不经过顶点k
candidate1 = A[i][j][k-1]
# 情况2:经过顶点k
candidate2 = A[i][k-1][k-1] + A[k-1][j][k-1]
# 取两者中的较小值
A[i][j][k] = min(candidate1, candidate2)
算法的正确性依赖于最优子结构引理,该引理指出,任何最短路径要么完全不使用顶点 k,要么必然经过顶点 k。我们通过比较这两种情况来更新子问题的解。由于有三重循环,每层循环最多迭代 n 次,因此算法的总运行时间为 O(n³)。
处理负成本环
一个常见的问题是,如果输入图中存在负成本环,算法会如何表现?我们的最优子结构引理和算法正确性论证都基于图中没有负成本环的假设。然而,算法本身无论图中是否存在负环都会执行。
幸运的是,有一个简洁的方法来检测负环。算法运行完毕后,只需检查最终结果(即 k = n 时)的对角线元素 A[i][i][n]。
以下是检测方法:
- 如果对于任何顶点
i,A[i][i][n]的值是负数,则说明图中存在负成本环。 - 如果所有对角线元素都是
0,则图中没有负成本环,并且A[i][j][n]给出的就是正确的从i到j的最短路径距离。
直观上,如果存在一个负成本环,并且 y 是该环上编号最大的顶点,那么当算法外层循环 k 等于 y 时,从环上某点 x 到其自身 (A[x][x][y]) 的路径长度就会被更新为这个负环的长度。这个负值会在后续迭代中一直保持,最终体现在 A[x][x][n] 上。
重构最短路径
在计算出所有点对的最短路径距离后,我们通常还想知道具体的路径序列。与贝尔曼-福特算法类似,我们需要在算法运行过程中存储额外的信息。

我们将维护一个二维数组 B,其中 B[i][j] 用于记录从顶点 i 到顶点 j 的某条最短路径上编号最大的内部顶点。
以下是更新 B 数组的方法:
- 在算法的动态规划循环中,当我们计算
A[i][j][k]时,如果发现通过顶点k的路径(即candidate2)比不经过k的路径(candidate1)更短,那么我们不仅更新A[i][j][k],同时将B[i][j]设置为当前的k。 - 如果
candidate1更短或相等,则B[i][j]保持不变。
当算法结束时,B 数组就存储了重构路径所需的信息。要重构从 i 到 j 的最短路径,可以递归地进行:
- 查询
mid = B[i][j]。如果mid为None或i == j,说明路径是直接的或为空。 - 否则,最短路径由从
i到mid的最短路径,加上从mid到j的最短路径组成。 - 递归地对
(i, mid)和(mid, j)执行步骤1和2,直到路径被完全分解。
这种方法保证了重构过程的时间复杂度与路径长度成正比。
总结
本节课中我们一起学*了弗洛伊德-沃舍尔算法。我们从定义子问题和基础情况出发,构建了一个基于动态规划的三重循环算法,用于解决所有点对最短路径问题,其时间复杂度为 O(n³)。我们还探讨了算法如何处理可能存在的负成本环——通过检查最终结果的对角线元素来检测。最后,我们介绍了如何通过维护一个额外的 B 数组来记录路径上的关键顶点,从而能够重构出具体的最短路径序列。这个算法简洁而强大,是理解动态规划在图论中应用的经典范例。
140:重加权技术

概述
在本节课中,我们将要学*约翰逊算法的核心思想——重加权技术。这项技术能够巧妙地将带有负权边的图转化为所有边权均为非负的图,从而允许我们使用更高效的迪杰斯特拉算法来解决原本需要贝尔曼-福特算法处理的问题。
从问题到思路
当我们开始讨论全源最短路径问题时,我们注意到,通过遍历所有可能的源点,该问题可以简化为多次调用单源最短路径子程序。运行时间取决于图是否包含负权边。如果图没有负权边,我们可以使用运行速度极快的迪杰斯特拉算法(时间复杂度约为 O(m log n))。如果图包含负权边,则运行时间会显著增加(约为 O(n²m))。
约翰逊算法令人惊讶地表明,即使图包含负权边,全源最短路径问题也可以通过一次贝尔曼-福特算法加上 n 次迪杰斯特拉算法来解决,最终达到 O(nm log n) 的运行时间,这与非负权边情况下的运行时间相同。
这听起来似乎好得令人难以置信。我们如何能将带有负权边的图转化为所有边权均为非负的图呢?接下来,我们将探讨这个关键的重加权技术。
简单的边平移法为何失效
一个很自然的想法是:如果图中存在负权边,为什么不简单地为每条边的权值加上一个常数,使其变为非负呢?例如,如果最负的边权是 -5,就给所有边权加上 5。
然而,这种方法并不总是有效。考虑一个简单的图,其中从源点 S 到终点 T 有两条路径:一条经过两个权值为 1 的边(总长为 2),另一条经过一个权值为 3 的边(总长为 3)。显然,最短路径是第一条。如果我们给所有边权加上 2,第一条路径的总长变为 6,第二条变为 5,最短路径就改变了。
结论是:只有当 S 到 T 之间的所有路径都包含相同数量的边时,为每条边加上一个常数才能保证最短路径不变。这通常不成立,因此我们需要更聪明的方法。
顶点重加权技术
现在,我们介绍核心的重加权技术。假设我们有一个图,每条边 e 有一个原始长度 c(e)。此外,我们为每个顶点 v 分配一个权重 p(v),这个权重可以是任意实数。
我们利用这些顶点权重来重新定义每条边的长度。对于一条从顶点 u 指向顶点 v 的边 e,其新长度 c'(e) 定义为:
c'(e) = c(e) + p(u) - p(v)
这个变换有一个非常重要的性质。
以下是该性质的推导过程:
考虑任意一条从源点 S 到终点 T 的路径 P。假设它在原始图中的总长度为 L。那么,在新图(使用 c' 作为边权)中,这条路径的总长度 L' 是多少?
L' = Σ c'(e) (对路径 P 上的所有边 e 求和)
= Σ [c(e) + p(u) - p(v)]
= Σ c(e) + Σ p(u) - Σ p(v)
在求和过程中,路径内部顶点的权重 p(v) 会作为一条边的头出现一次(带负号),又作为下一条边的尾出现一次(带正号),因此相互抵消。最终,只剩下路径起点 S 的权重(作为第一条边的尾,带正号)和终点 T 的权重(作为最后一条边的头,带负号)未被抵消。
因此,我们得到:
L' = L + p(S) - p(T)
关键结论:对于任意固定的起点 S 和终点 T,重加权技术会将所有从 S 到 T 的路径的长度都增加完全相同的值,即 p(S) - p(T)。这意味着,路径之间的长度排序保持不变,原始图中的最短路径,在新图中依然是最短路径。
通往非负权边的桥梁
既然重加权技术能保持最短路径不变,我们就可以利用它来“改造”图。我们的目标是:找到一组顶点权重 p(v),使得变换后的所有新边权 c'(e) 都成为非负数。
如果能够找到这样一组权重,那么我们就成功地将一个可能包含负权边的图,转化成了一个所有边权均为非负的图。在这个新图上,我们就可以放心地使用高效的迪杰斯特拉算法来求解最短路径问题。

一个自然的问题是:这样的权重总是存在吗?答案是:只要原始图中没有负权循环,这样的顶点权重就总是存在的。计算这组权重并非微不足道,但代价并不高昂。事实上,运行一次贝尔曼-福特算法就足以计算出这组权重。
总结
本节课中,我们一起学*了约翰逊算法的基石——重加权技术。我们了解到:
- 简单的全局边权平移会改变最短路径,因此无效。
- 基于顶点权重的重加权技术(
c'(e) = c(e) + p(u) - p(v))能够保持任意两点间所有路径的长度相对顺序不变。 - 只要图中没有负权循环,就总能找到一组顶点权重,使得变换后的所有边权为非负。
- 这为我们提供了一条路径:通过一次贝尔曼-福特算法计算顶点权重,将图转化为非负权图,然后就可以用 n 次迪杰斯特拉算法高效解决全源最短路径问题。
在下一节中,我们将看到约翰逊算法如何将这些想法整合成一个完整、高效的算法。
141:约翰逊算法详解

概述
在本节课中,我们将要学*约翰逊算法。该算法巧妙地运用了“重赋权”技术,将可能包含负权边的全源最短路径问题,转化为一次贝尔曼-福特算法加上N次迪杰斯特拉算法的组合,从而提升计算效率。
上一节我们介绍了重赋权技术的基本原理,本节中我们来看看如何利用该技术构建约翰逊算法。
算法核心思想
约翰逊算法的核心思想是:通过添加一个虚拟源点并计算其到所有原图中顶点的最短路径距离,将这些距离作为每个顶点的“权重”。然后,利用这些权重对原图的所有边进行重赋权,使得所有边权变为非负值。最后,在重赋权后的新图上,对每个顶点运行一次迪杰斯特拉算法,即可高效地计算出原图的全源最短路径。
算法步骤详解
步骤一:添加虚拟源点
首先,我们面临一个问题:为了计算顶点权重,我们需要一个能到达图中所有顶点的源点。但在原图中,任意选定的源点可能无法到达所有其他顶点。
以下是解决此问题的方法:
- 我们在原图G中添加一个新的顶点,记为
s。 - 从
s向原图G中的每一个顶点v添加一条有向边,其边权为0。
这样,从s到任意顶点v都至少存在一条直接路径(长度为0),从而保证了最短路径距离是有限的。同时,添加s不会改变原图中任意两个顶点之间的最短路径,也不会影响原图是否存在负权环。
步骤二:计算顶点权重
接下来,我们以新添加的顶点s为源点,计算它到原图G中所有其他顶点的最短路径距离。
由于原图可能包含负权边,我们需要使用能够处理负权边的贝尔曼-福特算法来完成此计算。
我们将计算得到的最短路径距离,定义为每个顶点v的权重p(v)。公式如下:
p(v) = δ(s, v),其中δ(s, v)表示从s到v的最短路径距离。
步骤三:边重赋权
获得所有顶点权重p(v)后,我们对原图G中的每一条边e = (u, v)进行重赋权,得到新的边权c'(e)。
重赋权的公式为:
c'(e) = c(e) + p(u) - p(v)
其中,c(e)是边e的原始长度,p(u)是边尾顶点u的权重,p(v)是边头顶点v的权重。
关键性质:经过此变换,新图中所有边的长度c'(e)都将变为非负值。同时,对于原图中任意两个顶点s和t,任意一条s->t路径的长度变化量是恒定的(等于p(s) - p(t)),因此最短路径得以保留。
步骤四:计算全源最短路径
现在,我们得到了一个所有边权均为非负的新图。在这个新图上,我们可以放心地使用更高效的迪杰斯特拉算法。
以下是最终的计算步骤:
- 对于原图G中的每一个顶点
u,将其作为源点。 - 在新图(使用边权
c')上运行迪杰斯特拉算法,计算从u到所有其他顶点v的最短路径距离δ'(u, v)。 - 为了得到原图G中真实的距离
δ(u, v),我们需要进行逆变换。公式为:
δ(u, v) = δ'(u, v) - p(u) + p(v)
总结

本节课中我们一起学*了约翰逊算法。该算法通过“添加虚拟源点 -> 贝尔曼-福特算法计算顶点权重 -> 重赋权得到非负权图 -> N次迪杰斯特拉算法计算新图最短路径 -> 逆变换还原原图距离”这一系列步骤,巧妙地解决了包含负权边(但无负权环)的全源最短路径问题。其时间复杂度主要取决于N次迪杰斯特拉算法的开销,在采用合适的优先队列时,可以比直接使用N次贝尔曼-福特算法或弗洛伊德-沃舍尔算法更加高效。
142:约翰逊算法详解
在本节课中,我们将全面学*约翰逊算法。该算法用于解决带权有向图中所有顶点对之间的最短路径问题,并能处理图中存在负权边的情况。我们将分步解析算法原理、执行过程及其正确性证明。

算法概述
约翰逊算法的核心思想是通过“重赋权”技术,将可能包含负权边的图转换为一个所有边权均为非负的新图。然后,在新图上对每个顶点运行一次迪杰斯特拉算法,高效地计算出所有顶点对之间的最短路径。如果原图存在负权环,算法也能正确检测并报告。
算法步骤详解
步骤一:添加辅助顶点
首先,我们需要确保图中存在一个能到达所有其他顶点的源点。为此,我们向原图 G 中添加一个新的顶点 s。同时,添加 n 条从 s 指向原图中每个顶点的新边,这些新边的长度均为零。我们称这个扩展后的图为 G'。
# 伪代码表示
def add_auxiliary_vertex(G):
G_prime = G.copy()
s = new_vertex()
G_prime.add_vertex(s)
for v in G.vertices:
G_prime.add_edge(s, v, weight=0)
return G_prime, s
步骤二:运行贝尔曼-福特算法
接下来,我们以新添加的顶点 s 为源点,在扩展图 G' 上运行贝尔曼-福特算法,计算 s 到所有其他顶点的最短路径距离。
贝尔曼-福特算法将执行以下两种操作之一:
- 正确计算出从源点
s到所有其他顶点的最短路径距离。 - 正确报告输入图
G'中存在负权环。
如果算法报告存在负权环,由于新顶点 s 没有入边,不可能位于任何环上,因此该负权环必然存在于原图 G 中。此时,算法可以安全终止并报告原图存在负权环。
从这一步开始,我们假设图 G 和 G' 中均不存在负权环,因此贝尔曼-福特算法已正确计算出从 s 到所有顶点的最短路径距离。我们将这些距离值记为 p(v),作为每个顶点 v 的“势能”或权重。
步骤三:计算新的边权
利用上一步计算出的顶点势能 p(v),我们对原图 G 中的每条边进行重赋权。对于从顶点 u 指向顶点 v 的边 e,其新的长度 c'(e) 计算公式如下:
c'(u, v) = c(u, v) + p(u) - p(v)
其中 c(u, v) 是边的原始长度。我们稍后将证明,经过这样处理得到的所有新边权 c' 均为非负值。
# 伪代码表示
def reweight_edges(G, potentials_p):
for edge (u, v) in G.edges:
new_weight = G.weight(u, v) + potentials_p[u] - potentials_p[v]
G_prime.set_weight(u, v, new_weight)
return G_prime
步骤四:运行迪杰斯特拉算法
由于新图 G' 中所有边权均为非负(待证明),我们现在可以对每个顶点 u 作为源点,运行一次迪杰斯特拉算法。这将计算出在新边权 c' 下,从每个顶点 u 到所有其他顶点 v 的最短路径距离。我们记这个距离为 d'(u, v)。
步骤五:还原真实最短路径距离
上一步计算出的 d'(u, v) 是基于修改后的边权 c' 的最短路径距离,而我们需要的是基于原始边权 c 的真实最短路径距离 d(u, v)。
幸运的是,根据重赋权的性质,这两个距离之间存在一个固定的偏移量。具体来说,从 u 到 v 的任何路径,其长度在重赋权前后变化的值恒为 p(u) - p(v)。因此,我们可以通过以下公式还原真实距离:
d(u, v) = d'(u, v) - p(u) + p(v)
对所有顶点对 (u, v) 应用此公式,即可得到最终的所有顶点对最短路径距离。
算法性能分析
现在,我们来分析约翰逊算法每一步的时间复杂度。
以下是各步骤的时间开销:
- 步骤一:添加一个顶点和
n条边,时间复杂度为O(n)。 - 步骤二:运行一次贝尔曼-福特算法,时间复杂度为
O(m * n)。 - 步骤三:为每条边计算新权重,时间复杂度为
O(m)。 - 步骤四:运行
n次迪杰斯特拉算法。若使用二叉堆实现,单次时间复杂度为O(m log n),总时间为O(n * m log n)。 - 步骤五:为
n^2个距离对进行还原计算,时间复杂度为O(n^2)。
对于稀疏图(m = O(n)),步骤四主导总运行时间,算法整体时间复杂度为 O(n^2 log n)。这显著优于之前能处理负权边的两种方案:运行 n 次贝尔曼-福特算法(O(n^3))或使用弗洛伊德-沃舍尔算法(O(n^3))。
更重要的是,即使在所有边权均为非负的特殊情况下,约翰逊算法的性能也与直接运行 n 次迪杰斯特拉算法相当。这表明,通过一次额外的贝尔曼-福特算法预处理,我们就能以几乎相同的效率处理包含负权边的通用情况。
关键性质证明:新边权非负
最后,我们证明步骤三中定义的新边权 c' 确实总是非负的。
证明:
考虑任意一条从顶点 u 到 v 的边 e,其原始长度为 c(u, v)。
顶点势能 p(u) 和 p(v) 是贝尔曼-福特算法计算出的从辅助源点 s 到 u 和 v 的最短路径距离。
根据最短路径的定义,从 s 到 v 的最短路径距离 p(v),不会长于任何其他从 s 到 v 的路径。特别地,我们可以先走从 s 到 u 的最短路径(长度为 p(u)),再经过边 e 到达 v,这条路径的长度为 p(u) + c(u, v)。因此,我们有如下不等式:
p(v) ≤ p(u) + c(u, v)

对这个不等式进行移项,得到:
c(u, v) + p(u) - p(v) ≥ 0
而左边正是我们定义的新边权 c'(u, v)。因此,对于任意边 e,都有 c'(e) ≥ 0。证明完毕。
总结
本节课中,我们一起学*了约翰逊算法的完整流程。该算法通过添加辅助顶点、运行一次贝尔曼-福特算法进行势能计算、对边进行重赋权使其非负、然后运行 n 次迪杰斯特拉算法,最终高效地解决了带负权边的所有顶点对最短路径问题。其核心优势在于,对于稀疏图,它将时间复杂度从立方级 O(n^3) 降低到了 O(n^2 log n),并且在不含负权边的特殊情况下,性能与最优算法持平。
143:多项式时间可解问题与P类


在本节课中,我们将开始学*本课程的最后一个主题。首先,我们将探讨什么是NP完全问题或计算上难解的问题。其次,我们将讨论从算法角度应对这些问题的有效策略。
让我们首先通过多项式时间可解性来正式定义计算易处理性,即定义复杂度类P。
计算易处理性的定义
正如你们通过过去几周的辛勤工作所知,本算法设计与分析课程的重点是学*针对基础计算问题的实用算法及相关支撑理论。到目前为止,我们已经掌握了大量此类问题,例如排序、搜索、最短路径、最小生成树、序列比对等等。
然而,一个令人遗憾的事实是,迄今为止我向你们展示的计算问题样本存在相当大的偏见。对于许多重要的计算问题,包括你们在自己的项目中很可能遇到的问题,目前并没有已知的高效算法,更不用说极其快速的算法了。
尽管本课程的重点是算法能做什么,而非不能做什么,但如果不讨论困扰专业算法设计师和严肃程序员的“难解性”这一幽灵,我认为教授算法知识是不完整的。
正如你的程序员工具箱应包含多种设计范式(如动态规划、贪心算法、分治法)和多种数据结构(如哈希表、平衡搜索树等),这个工具箱也应包含对计算上难解(即NP完全)问题的认识,甚至可能包括如何证明问题是NP完全的。这是因为这些问题很可能出现在你自己的项目中。在斯坦福大学,经常有研究生来到我的办公室,就他们项目中出现的算法问题寻求建议。通常,他们在我的白板上描述问题几分钟后,就很容易看出这实际上是一个NP完全问题。他们不应该寻求一个高效且完全正确的算法,而应转而采用我们稍后将讨论的应对NP完全问题的策略之一。
因此,在接下来的系列视频中,我的目标是通过定义NP完全性来形式化计算难解性的概念,并给出几个例子。我将要给出的关于NP完全性的相对简短的讨论,并不能替代对该主题的正式学*。我鼓励你们寻找教科书或网络上的其他免费课程来了解更多。事实上,我认为NP完全性是一个值得从多个不同角度至少学*两次的主题。对于那些已经学*过的同学,我希望我的讲解能与你之前见过的处理方法形成互补。确实,NP完全性是计算机科学向更广泛科学界输出的最著名、最重要和最具影响力的成果之一。
随着如此多不同的学科变得越来越计算化(我想到的包括数学科学、自然科学,甚至社会科学),这些领域确实别无选择,只能面对并学*NP完全性,因为它真正地决定了什么可以计算完成,什么不能。
我在这里的观点将毫不掩饰地站在算法设计师的立场。特别是在我们讨论了NP完全性之后,本课程的其余部分将重点讨论针对这些问题的算法方法:如果你面对一个NP完全问题,你应该怎么做?
因此,与其一开始就形式化计算难解性,不如从定义计算易处理性开始,这样更简单一些。
多项式时间可解性的定义
这引出了极其重要的多项式时间可解性定义。这个定义有点拗口,但它基本上就是你所想的那样。
定义:一个问题被称为多项式时间可解,当且仅当存在一个多项式时间算法可以解决它。也就是说,存在一个算法和一个常数K,使得如果你向该算法输入一个长度为N的输入,它将在O(N^K)的时间内正确地解决问题(无论是正确回答是/否,还是正确输出一个最优解等)。
在我们讨论过的大多数问题中,输入长度是显而易见的,例如顶点数加边数,或者需要排序的数字数量等。对于一个抽象问题,非正式地,我鼓励你将输入长度N视为在计算机上描述该给定实例所需的击键次数。
是的,严格来说,要成为多项式时间可解,你只需要对于某个常数K,运行时间是O(N^K)。即使K等于10000,也足以满足这个定义的要求。当然,对于具体问题,你会看到较小的K值。在本课程中,K通常是1、2或3。
对于那些想知道随机算法的人,我们当然在第一部分看到过一些很酷的随机算法例子。为了简化讨论,当我们讨论计算难解性时,让我们只考虑确定性算法。但同时,请放心,我们即将得出的所有定性结论,普遍认为同样适用于随机算法。具体来说,我们不认为存在这样的问题:确定性算法需要指数时间,而随机化却能神奇地让你获得多项式时间。甚至有数学证据表明情况确实如此。
复杂度类P
这就引出了复杂度类P的定义。P被定义为所有多项式时间可解问题的集合,即所有允许用多项式时间算法解决的问题的集合。对于那些听说过P与NP问题的人,这里的P就是同一个P。
在本课程中,我们已经展示了许多P类问题的例子,这实际上就是本课程的整个重点:序列比对、最小割、排序、最小生成树、最短路径等等。
但有两个例外。一个是我们当时明确讨论过的,即当我们讨论具有负边成本的图中的最短路径时。我们指出,如果你有负成本环,并且你想要的是简单的(不包含任何环的)最短路径,那么这被证明是一个NP完全问题(对于那些了解归约的人来说,这是从哈密顿路径问题的一个简单归约)。因此,我们没有给出该版本最短路径问题的多项式时间算法,我们只是避开了它。
第二个例子非常微妙。信不信由你,我们实际上并没有为背包问题给出一个多项式时间算法。
这需要解释一下,因为当时在我们的动态规划算法中,我敢打赌你觉得它就是一个多项式时间算法。让我们回顾一下它的运行时间。在背包问题中,输入是n个具有价值和尺寸的物品,以及一个背包容量(一个正整数W)。我们的二维数组表有Θ(n * W)个子问题,我们花费常数时间填充每个条目。因此,我们动态规划算法的运行时间是Θ(n * W)。
另一方面,一个背包问题实例的长度是多少?正如我们刚才所说,背包问题的输入是2n+1个数字:n个尺寸、n个价值和背包容量。自然地,要写下这些数字中的任何一个,你只需要log(数字的大小)。如果你有一个整数K,你想用二进制写下来,那就是log₂(K)位;如果你想用十进制数字写下来,那就是log₁₀(K)位,这相差一个常数因子。关键是,输入长度将与n成正比,并与数字大小的对数(特别是log(W))成正比。
这是理解为什么动态规划算法在输入长度意义下是指数级的另一种方式。让我用一个类比。假设你正在解决某个图的割问题。记住,一个图中的割的数量相对于顶点数是指数级的,对于n个顶点大约是2^n。这意味着如果你在进行暴力搜索,我只要在图中多添加一个顶点,你的算法运行时间就会翻倍。这是指数增长。
但实际上,在背包问题的动态规划算法中,同样的事情正在发生。假设我们用十进制写下所有内容。我只需在背包容量后面多加一个0(即乘以10)。那么,你必须解决的子问题数量就会增加10倍。同样,我只是在输入中增加了一个数字(一次击键),而你的运行时间却被乘以了一个常数因子。因此,这同样是相对于输入编码长度的指数增长。
我们未能为背包问题获得多项式时间算法并非偶然,因为它实际上是一个NP完全问题。同样,我们稍后会解释NP完全的含义。
对P类的解读
以上就是复杂度类P的数学定义。但比数学更重要的是其解读:你应该如何看待P类?如何看待多项式时间可解性?对于实践中的算法设计师和程序员来说,属于P类(多项式时间可解)可以被视为计算易处理性的一个粗略试金石。
现在,将运行时间为O(N^K)(对于某个常数K)的算法与实际中计算高效的算法等同起来是不完美的。当然,有些算法在原则上是多项式时间的,但在实践中太慢而无用。相反,有些算法不是多项式时间的,但在实践中却很有用。你在编写背包问题的动态规划解决方案时已经编写了一个,在未来的讲座中我们还会看到更多例子。
更一般地说,任何有勇气写下像复杂度类P这样精确数学定义的人,都需要准备好接受一个必然性:这个定义将容纳一些你希望它没有的边缘情况,并且会排除一些你希望它没有的边缘情况。这是数学性质的二元性与现实的模糊性之间不可避免的摩擦。
这些边缘情况绝不是忽视或否定此类数学定义的借口。恰恰相反,这种摩擦使得你能写下如此简洁的数学定义(每个计算问题要么满足,要么不满足),并且它能如此有效地指导将问题分类为“实践中易处理的”和“实践中难处理的”,这更加令人惊讶。我们现在有40年的经验表明,这个定义在那种分类中异常有效。
一般来说,P类中的计算问题可以使用现成的解决方案很好地解决,正如我们在本课程许多例子中看到的那样。而普遍认为不属于P类的问题,在实践中通常需要大量的计算资源、人力资源和领域专业知识来解决。
一个著名的例子:旅行商问题
我们已经顺便提到了几个被认为不是多项式时间可解的问题。但让我在这里暂停一下,告诉你们一个更著名的问题:旅行商问题。
旅行商问题听起来与最小生成树问题并没有太大不同,而后者我们现在知道有一系列贪心算法可以在*线性时间内运行。TSP的输入是一个无向图。我们假设它是一个完全图,即每对顶点之间都有一条边。每条边都有一个成本,并且这个问题是非平凡的,即使每条边的成本只是1或2。
让我用绿色画一个有四个顶点的例子。
TSP问题的算法职责是输出一个环游,即一个访问每个顶点恰好一次的环。在所有环游(你可以将其视为顶点的一个排列,即访问它们的顺序)中,你想要的是最小化边成本总和的那个。
例如,在我画的绿色图中,最小成本环游的总成本是13。
TSP问题陈述起来足够简单,显然你可以通过暴力搜索在大约n!的时间内解决它,只需尝试所有顶点的排列。但你们知道,在本课程中我们已经看到许多例子,你可以做得比暴力搜索更好。而TSP问题,人们至少从20世纪50年代就开始从计算角度认真研究,包括像乔治·丹齐格这样的优化领域的大人物。
尽管研究了60多年,但迄今为止,还没有已知的多项式时间算法可以解决旅行商问题。事实上,早在1965年,杰克·埃德蒙兹在一篇名为《路径、树和花》的杰出论文中,就猜想旅行商问题不存在多项式时间算法。

*50年后的今天,这个猜想仍未解决。正如我们将看到的,这等价于猜想P ≠ NP。
那么,你将如何形式化地证明这个猜想?在没有证明的情况下,你将如何积累证据支持这个猜想成立?这些将是接下来视频的主题。

总结
本节课中,我们一起学*了计算易处理性的正式定义,即通过多项式时间可解性来定义复杂度类P。我们明确了P类问题在实践中通常被认为是“易处理的”,并理解了其数学定义与直观解读之间的微妙关系。我们还回顾了背包问题的动态规划算法,认识到它在输入长度意义下实际上是指数级的,并由此引出了对NP完全问题的初步认识。最后,我们介绍了一个著名的、被认为不属于P类的问题——旅行商问题,为后续深入探讨NP完全性和计算难解性奠定了基础。
144:归约与完全性

📖 概述
在本节课中,我们将学*如何证明像旅行商问题这类问题的计算困难性。核心思想是通过“完全性”这一形式化概念,而它又依赖于两个问题之间的“归约”关系。
🔗 归约:定义与直观理解
上一节我们介绍了计算困难性的概念。本节中,我们来看看如何通过“归约”来形式化地比较两个问题的难度。
我们说一个计算问题 π₁ 可以归约到另一个问题 π₂,其含义是:如果你拥有一个能高效解决 π₂ 的算法,那么你就能利用它来高效地解决 π₁。换句话说,如果有人给你一个解决 π₂ 的多项式时间算法,你就可以把它当作一个子程序,构建出一个解决 π₁ 的多项式时间算法。
以下是归约的一些具体例子:
- 计算中位数归约到排序:要计算一个数组的中位数,一个完全正确的方法是先对数组进行排序(例如使用归并排序),然后返回中间的元素。
- 检测环归约到深度优先搜索:要检查一个图是否包含环,你可以直接在该图上运行深度优先搜索。如果存在环,你会在探索过程中遇到一条指向仍在探索中的顶点的回边。
- 所有点对最短路径归约到单源最短路径:要解决所有点对最短路径问题,一个解决方案是多次调用单源最短路径算法(如 Dijkstra 算法),每次选择不同的源点。
经验丰富的算法设计者总是在寻找使用归约的机会。面对一个新问题时,首先应该思考:这个问题是否只是我已知问题的变体? 这种“归约”的正面应用,扩展了我们可以用算法高效解决问题的范围。
⚖️ 归约的“阴暗面”:证明困难性
上一节我们看到了归约如何扩展可解问题的边界。本节中,我们来看看如何利用归约的“反面”来证明问题的困难性。
假设问题 π₁ 可以归约到问题 π₂。作为算法设计者,我们通常考虑的是乐观情况:我们已经有解决 π₂ 的高效算法,从而也能解决 π₁。
但让我们思考一下这个命题的逆否命题。如果我们不相信 π₁ 能在多项式时间内解决,而 π₁ 又可以归约到 π₂,那么我们同样不能相信 π₂ 能在多项式时间内解决。
用公式表示这个逻辑:
如果 π₁ 可归约到 π₂,且 π₁ 无法在多项式时间内解决,那么 π₂ 也无法在多项式时间内解决。
这就是归约的“阴暗面”应用。它不再是将可解性从 π₂ 扩展到 π₁,而是将不可解性从 π₁ 传播到 π₂。因此,当我们说“π₁ 可归约到 π₂”时,也意味着 π₂ 至少和 π₁ 一样困难。
🏆 完全性:成为“最难”的问题
上一节我们定义了如何比较两个问题的难度。本节中,我们来看看如何定义一个问题是“一整类”问题中最难的,这就是“完全性”的概念。
考虑一个计算问题的集合 C。我们称一个问题 π 是 C-完全 的,如果满足以下两个条件:
- π 属于集合 C。
- 集合 C 中的每一个问题都可以归约到 π。
这意味着 π 不仅是 C 中的一员,而且是 C 中“最难”的问题——它至少和 C 中的其他所有问题一样困难。
这个形式化概念正好符合我们的战略计划:通过证明旅行商问题(TSP)和“一大堆”其他问题一样难,来积累其难解的证据。那么,这个“一大堆”问题——集合 C——应该如何选择呢?
为了提供最强有力的证据,我们希望 C 尽可能大。最雄心勃勃的想法是证明 TSP 是所有计算问题中最难的。但这过于雄心勃勃了。例如,著名的“停机问题”就比 TSP 更难。艾伦·图灵在1936年证明,不存在任何算法(无论多慢)能保证永远正确地解决停机问题。而 TSP 虽然困难,但通过暴力搜索(尝试所有 n! 种排列)总能在有限时间内解决。

因此,我们需要调整思路。我们或许可以证明:在所有能够通过(某种形式的)暴力搜索解决的问题中,TSP 是最难的一个。这引出了对“可通过暴力搜索解决”问题的精确定义,也就是下一节将要讨论的核心概念:复杂度类 NP。
📝 总结
本节课中我们一起学*了:
- 归约:通过将一个问题转化为另一个问题,来比较它们的难度。拥有解决后者的算法,就能解决前者。
- 归约的双重用途:既可以用来扩展可高效解决问题的范围(光明面),也可以用来证明一个问题的困难性,即如果 A 可归约到 B 且 A 很难,则 B 也很难(阴暗面)。
- 完全性:如果一个问题是某个问题类别 C 中的一员,并且 C 中所有其他问题都能归约到它,那么它就是 C-完全的,即该类中最难的问题。
- 战略方向:为了证明 TSP 的难解性,我们的目标是证明它是某个“足够大”的问题类别(特别是那些理论上可通过暴力搜索验证解的问题类别,即 NP 类)中的完全问题。这为理解 P 与 NP 问题奠定了基础。
145:NP完全性定义与解释一


在本节课中,我们将要学*计算复杂性理论的核心概念之一:NP 复杂性类。我们将探讨NP问题的定义,理解其与“暴力搜索可解”问题的关系,并初步了解NP完全性这一重要概念,它用于证明某些问题在计算上是极其困难的。
NP复杂性类的定义 🧩
上一节我们讨论了旅行商问题等难题的求解难度。本节中,我们来看看如何从数学上定义一类“可被高效验证解”的问题,即NP问题。
一个计算问题(例如旅行商问题,或本课程中讨论过的几乎所有其他问题)属于复杂性类 NP,当且仅当它满足以下两个标准:
- 解的长度是输入规模的多项式函数:这是一个基本前提,它确保候选解的描述不会过长。
- 解的正确性可在多项式时间内验证:这是关键属性。如果有人向你提供一个NP问题的候选解,你可以在多项式时间内验证这个解是否正确。
为了理解这个抽象定义,让我们看一些具体例子。
实例分析:旅行商问题 🧳
考虑一个旅行商问题的实例,它由一组顶点和顶点间的距离定义。假设我们想知道是否存在一条总长度不超过1000的旅行路线。
暂时搁置检查n!条路线中是否存在满足条件路线的问题。我们思考一个更简单的问题:验证一条给定的特定路线是否满足条件。
这个验证问题很简单。一条路线由访问顶点的顺序指定。这个顺序的长度显然是输入长度的多项式函数。你需要做的只是将路线中遍历的n条边的长度相加,并检查总和是否不超过1000。
这个论证表明,检查是否存在总成本不超过某个阈值的旅行路线确实属于复杂性类NP。你可以用多项式长度写出一个候选解(只需指定顺序),并且可以在线性时间内验证一个候选解(一条提议的路线)是否满足阈值。
如果你对我从计算最优路线问题转向研究“是否存在满足阈值的路线”这个看似更简单的问题感到困惑,请注意,前者可以通过对阈值进行二分搜索,转化为后者的多次询问来解决。
实例分析:约束满足问题 🔗
除了优化问题,另一类重要的NP问题是约束满足问题。
在约束满足问题中,你有一组变量(最简单的情况是二元或布尔变量)和一个约束列表。每个约束为变量的一个子集指定了允许的取值组合。
一个简单的例子是3-SAT问题。它涉及布尔变量(每个变量Xi可以是0或1)。子句可以看作禁止变量三元组的特定赋值组合。例如,一个子句可能禁止同时将x3赋值为1、x5赋值为0、x8赋值为1。问题是:是否存在一个对所有变量的0/1赋值,能同时满足所有约束?
这类约束满足问题也属于复杂性类NP。如果你有一个满足所有约束的变量赋值提议,你可以简单地写下每个变量的值。验证也很容易:只需逐个遍历所有约束,检查提议的变量值组合是否在允许的列表中。
NP与暴力搜索的关系 🔍
在本视频开头,我提到NP类代表了像旅行商问题那样可通过暴力搜索解决的问题。现在让我们观察,基于“高效验证解”的NP定义,确实意味着这些问题可以用指数时间的暴力搜索解决。
以下是暴力搜索的基本思路:
for each candidate_solution in all_possible_solutions:
if verify(candidate_solution) is True:
return candidate_solution
在这个过程中,NP定义的两个属性作用清晰:
- 第一个属性(解的长度是多项式)限制了可能解的数量。给定长度的比特串数量最多是输入规模的指数级。
- 第二个属性(多项式时间验证)确保你能对每个(指数多的)可能性,在多项式时间内验证它是否确实是正确解(例如,是否是一条短旅行路线,或是否是一个满足约束的赋值)。

NP类的广泛性与NP完全性的意义 🌐
由于成为NP类成员的要求非常弱,所以NP类非常庞大。成为NP成员本质上只需要能够高效识别一个解(即“当你看到它时,你能认出来”)。可以想象,我们思考的许多计算问题都满足这个属性,例如几乎任何图问题、排序问题、大多数约束满足问题等。
当然,并非所有自然计算问题都属于NP。停机问题就是一个极端例子,它实际上是不可判定的(根本不存在算法)。在某些应用领域也存在一些既不属于NP也不可判定的自然问题(例如模型检查领域中的许多问题就比NP更难)。
NP类的广泛性意味着NP完全性是计算难解性的有力证据。回忆一下,一个问题对某个问题集是“完全的”意味着什么?它意味着这个问题和该集合中的任何其他问题一样难。集合中的每一个其他问题都可以归约到这个完全问题。
因此,假设你对仅仅一个NP完全问题拥有多项式时间算法。根据归约的定义,你将自动获得NP中每一个计算问题的多项式时间算法,即每一个你能高效验证解的问题。也就是说,在多项式时间内解决哪怕一个NP完全问题,都将意味着 NP = P(即NP中的每个问题都能在多项式时间内解决)。
如果P等于NP,其影响将是深远的。仅举一例,我们所知的现代电子商务将像纸牌屋一样崩塌,因为它依赖于RSA等密码系统的安全性,而这又假定了因数分解等问题的计算难解性。然而,即使是对最晦涩的NP完全问题的高效算法,也将自动意味着(至少在原则上)存在因数分解的多项式时间算法。
总结与展望 📝
本节课中我们一起学*了NP复杂性类的核心定义:解可在多项式时间内验证的问题集合。我们通过旅行商问题和3-SAT问题验证了这个定义,并理解了NP问题与指数时间暴力搜索的等价关系。最后,我们探讨了NP类的广泛性,并引出了NP完全性作为证明问题内在难解性的强有力工具:证明一个问题是NP完全的,就等于证明了如果存在该问题的多项式时间算法,那么NP中成千上万的其他难题也将迎刃而解,这相当于证明了P=NP。
在接下来的课程中,我们将深入探讨如何证明一个问题是NP完全的,并了解NP完全问题在现实中的普遍性。

146:NP完全性定义与解释二

在本节课中,我们将深入探讨NP完全性的定义,并理解为什么这个概念在计算机科学及其他领域如此重要。我们将看到,NP完全性不仅是一个理论概念,它还为我们提供了一种强有力的工具,用以判断一个计算问题是否本质上是难以高效解决的。
对于自认为是计算机科学家的各位,希望你们在看到这个定义时能感到自豪。我们的学科提出了“NP完全问题”这样一个伟大的概念,这确实很酷。这个概念意味着存在一个通用问题,它能同时编码所有那些你能高效验证其解决方案的计算问题。
然而,这里仍然存在一个棘手的问题。通常,当你面对一个数学定义时,比如我刚才给出的NP完全性定义,你应该要求两件事。
第一件事,你应该要求解释为什么需要关心这个定义。也就是说,如果满足这个定义,会带来什么有趣的结果?我认为,对于NP完全性的定义,我已经给出了一个非常令人满意的答案。我论证过,如果一个问题是NP完全的,那么这就是计算难解性的有力证据。因为,如果假设存在一个针对这个NP完全问题的多项式时间算法,那么它将自动高效地解决成千上万个基本的计算问题,即所有你能高效验证解决方案的问题。
但你应该向提出数学定义的人要求的第二件事是:例子。我关心的事情真的符合这个定义,并且是NP完全的吗?我还没有展示任何例子。确实,当你看到这个解释——一个能同时编码所有具有高效可验证解的问题——你可能会想,这样的对象真的存在吗?
NP完全性理论之所以如此强大,并且在过去40年里从计算机科学扩展到其他众多学科,正是因为这个问题也有一个极其令人满意的答案。事实证明,有大量问题不仅仅是NP问题(即不仅仅具有高效可验证的解),成千上万的问题实际上是NP完全的,其难度与NP中的任何其他问题一样高。
因此,NP完全性的定义,以及令人惊讶地存在NP完全问题这一事实,都要归功于史蒂夫·库克和列昂尼德·莱文各自独立的工作。库克和莱文各自独立地提出了基本相似的理论。库克当时(至今仍是)在多伦多大学,而莱文当时在“铁幕”之后,在苏联工作,因此他的成果在西方被知晓花费了一些时间。如今,莱文是波士顿大学的教授。
库克和莱文不仅证明了基本的存在性结果,还给出了一些暗示,表明人们真正关心的问题也可能是NP完全的,例如一些约束满足问题,如3SAT。但NP完全性的广阔范围,即最终被证明是NP完全的问题的广度,首次在理查德·卡普1972年的一篇论文中变得清晰。在那篇论文中,他展示了21个不同问题是NP完全的,包括旅行商问题以及许多不同领域数十年来一直停滞不前的各种问题。现在,NP完全性成为阻碍许多不同领域高效算法进展的根本障碍。
NP完全性另一个令人惊叹之处,也是它能够成功地从理论计算机科学扩展到更广泛的计算机科学领域,进而扩展到工程学和其他科学领域的一个重要原因,在于我们可以相当容易地站在这些巨人的肩膀上,证明新的、你所关心的问题也是NP完全的。
想象一下,有一个你非常关心的计算问题π,它对你正在进行的项目至关重要,但你被卡住了。你已经尝试了数周来解决它,用尽了你工具箱里的所有方法:贪心算法、分治法、动态规划、随机化,你尝试了书中所有的数据结构——哈希表、堆、搜索树——但都不起作用,你无法想出一个高效的算法。
😡 此时,你应该考虑一种可能性:问题可能不在于你缺乏聪明才智或创造力,也不在于你的编程工具箱中工具太少,而可能在于你试图解决的计算问题本质上是难解的。
当你达到这种沮丧的境地时,是时候考虑应用以下两步法来证明问题π是NP完全的。当然,仅仅因为你证明了它是NP完全的,问题并不会消失,但你应该采用不同的算法策略来应对它。在本课程剩余部分,我们将讨论一些处理NP完全问题最流行的策略。
让我在非常高的层次上陈述这个两步法。

第一步,你需要选定一个合适的NP完全问题π‘。
第二步,你需要证明π‘可以归约到你关心的问题π。这表明你的问题至少和这个NP完全问题一样难,因为NP完全问题可以归约到你的问题,因此,假设你的问题在NP中,那么它也是NP完全的。
显然,成功执行这个两步法的关键在于细节。你可能想知道:我到底怎么知道应该使用哪个NP完全问题π‘?其次,我该如何构思从这个NP完全问题π‘到我自己的问题π的归约?
但不要被这两个步骤吓倒。只需一点练*,你实际上可以在这两个步骤上都做得很好,并在许多不同情况下成功执行这个方案。
让第一步不那么令人生畏的一点是,存在一些优秀的NP完全问题列表,特别是那些在构思你自己的归约时往往有用的简单问题。其中最经典的列表是加里和约翰逊合著的《计算机与难解性》一书。它出版于1979年,但极其有用。我想不出还有哪本超过30年的计算机科学书籍能像这本书一样有用。
当然,仍然存在一个问题:你实际上如何构思从一个已知的NP完全问题π‘到你真正关心的问题π的归约?但这一步也不要被吓倒。首先,作为一个算法研究者,无论如何你都应该一直在思考归约。这对你来说应该是一种非常自然的思维方式。例如,当我们第一次讨论所有点对最短路径时,我们很快观察到它可以归约到单源最短路径问题。因此,这种用一个问题解决另一个问题的思维方式,在设计NP完全性归约时同样有用。
此外,有很多资源可以帮助你掌握NP完全性归约。你可以查阅各种算法教科书,它们通常有很多例子。加里和约翰逊的书是一本很好的参考。还有一些在线课程更深入地研究NP完全性。这些资源会为你提供许多NP完全性归约的例子,提供一些如何自己构思归约的技巧,最重要的是,熟能生巧。
因此,我强烈建议你利用这些资源。我认为你会很高兴将NP完全性作为你工具箱的一部分。当然,如果你无意中花费数周或数月的时间试图证明P等于NP,那对任何人都没有好处。
本节课中,我们一起学*了NP完全性概念的重要性及其实际应用。我们了解到,NP完全性不仅是理论上的里程碑,更是实践中判断问题难解性的关键工具。通过库克、莱文和卡普等人的工作,我们认识到存在大量NP完全问题,它们构成了算法设计中的根本障碍。最后,我们介绍了一个实用的两步法,用于证明新问题是NP完全的,并鼓励通过练*和利用现有资源来掌握这一强大工具。
147:P与NP问题 🧠

在本节课中,我们将学*计算机科学中一个核心且未解的问题:P与NP问题。我们将探讨P类与NP类问题的定义、它们之间的关系,以及为什么这个问题如此重要且难以解答。
概述 📋
P与NP问题是计算机科学和数学中最著名的开放问题之一。它探讨了“容易求解”的问题与“容易验证解”的问题之间的关系。本节课将解释P类和NP类的定义,讨论P是否等于NP的猜想,并探讨这一问题的深远意义。
P类与NP类的定义 🔍
上一节我们概述了P与NP问题的背景,本节中我们来看看P类和NP类的具体定义。
P类问题指那些可以在多项式时间内解决的问题。用公式表示,若一个问题存在一个算法,其运行时间为O(n^k),其中k为常数,则该问题属于P类。
NP类问题则具有以下性质:给定一个候选解,我们可以在多项式时间内验证该解是否正确。这意味着,虽然找到解可能很难,但验证解的正确性相对容易。
P是否等于NP? ❓
上一节我们介绍了P类和NP类的定义,本节中我们来看看关于P是否等于NP的猜想。
广泛认为P不等于NP。这意味着,能够高效验证解并不保证能够高效找到解。这一猜想最早由Edmonds在1965年提出,远早于NP和P这些术语的正式出现(1971年)。然而,我们必须强调,目前没有证明支持这一猜想。P与NP问题仍然是计算机科学中最重大的开放问题之一。
以下是支持P不等于NP的两个主要理由:
- 经验证据:许多聪明的研究者长期致力于为NP完全问题寻找高效算法,但半个多世纪以来无人成功。
- 哲学思考:P等于NP意味着“解决问题”和“验证解决方案”同样困难。这与我们的直觉相悖,例如,从头证明一个数学定理通常比审阅他人的证明要困难得多。
问题的难度与意义 🏆
上一节我们讨论了P不等于NP的猜想,本节中我们来看看这个问题的难度和重要性。
P与NP问题不仅是计算机科学的核心问题,也是整个数学领域的重大难题。2000年,克莱数学研究所将其列为七个“千禧年大奖难题”之一,解决任何一个问题可获得100万美元奖金。然而,这个问题的意义远超过奖金本身,它将深刻改变我们对计算本质的理解。
证明P不等于NP之所以极其困难,是因为多项式时间算法空间异常丰富且充满反直觉的可能性。例如,在矩阵乘法问题上,斯特拉森算法打破了“必须进行三次方运算”的直觉,展示了更高效的方法。这提醒我们,可能仍有未知的高效算法隐藏在P类问题的“生态系统”中。
术语“NP”的由来 📖
上一节我们探讨了问题的难度,本节中我们来看看术语“NP”本身的含义。

一个常见的误解是NP代表“非多项式”。实际上,NP代表“非确定性多项式时间”。这源于一个等价的、基于非确定性图灵机模型的NP定义。然而,对于算法设计者和程序员而言,从“高效验证解”的角度来理解NP更为直观和实用。
关于术语的选择,历史上曾有过有趣的讨论。在Cook和Karp的开创性工作后,学界意识到需要统一术语。Donald Knuth在1974年进行了一次投票,“NP完全性”最终获胜并被采用。一个未被采纳的有趣提议是“PET”问题,其含义可根据P与NP问题的最终答案而灵活变化(例如,“可能指数时间问题”或“先前指数时间问题”),但这已成为一个有趣的历史注脚。
总结 ✨
本节课中,我们一起学*了P与NP问题的核心内容。我们定义了P类(多项式时间可解)和NP类(多项式时间可验证)问题,讨论了P不等于NP的普遍猜想及其依据,并理解了这个问题在理论和实践上的极端重要性与难度。最后,我们还了解了“NP”这一术语的历史由来。掌握这些概念是理解计算复杂性理论的基础。
148:NP完全问题的算法方法 🎯

在本节课中,我们将探讨面对NP完全问题时,如何运用不同的算法策略来应对。尽管NP完全问题在计算上被认为是困难的,但这并不意味着我们束手无策。我们将介绍三种核心策略,帮助你理解和处理这类问题。
从坏消息到好消息的过渡
上一节我们讨论了NP完全性这个“坏消息”,即世界上存在计算上难以处理的问题,而且它们相当普遍,你很可能在自己的项目中遇到。
好消息是,NP完全性绝非死刑判决。事实上,我们的算法工具箱已经足够丰富,可以提供多种不同的策略来应对NP完全问题。
假设你发现了一个计算问题,你的新创业公司的成功就取决于它。也许你已经花了几个星期尝试了所有方法:所有算法设计范式、所有数据结构、所有基本原语,但都没有成功。最终,你决定尝试证明这个问题是NP完全的,并且成功了。现在,你明白了为什么你几周的努力没有结果。但这并没有改变这个事实:这个问题决定了你项目的成功。你应该怎么办?
好消息是,NP完全性当然不是死刑判决。总有人在解决,或者至少*似解决NP完全问题。然而,知道你的问题是NP完全的,确实告诉了你应该设定怎样的期望。你不应该期望像处理其他计算问题(如排序或单源最短路径)那样,找到一个通用的、超快的算法。
除非你处理的是异常小或结构良好的输入,否则你将不得不付出相当大的努力来解决这个问题,并且可能做出一些妥协。
本课程的其余部分将致力于解决或*似解决NP完全问题的策略。在本视频的其余部分,我将为你介绍这些策略是什么,以及你可以期待什么。
策略一:关注可计算的特殊情况
与往常一样,我将专注于跨多个应用领域的通用策略。这些通用原则应该只是一个起点,你应该结合你在需要解决的具体问题中所拥有的领域专业知识来运用和扩展它们。
第一种策略是专注于与NP完全问题相关的、在计算上易于处理的特殊情况。你需要思考你的领域或你正在处理的数据集有什么特别之处,并尝试理解是否存在可以在你的算法中利用的特殊结构。
以下是几个我们已经在本课程中看到的例子:
- 加权独立集问题:在一般图中,加权独立集问题是NP完全的。但在图是路径的特殊情况下,我们看到了一个线性时间的动态规划算法可以精确解决该问题。事实上,可处理性的边界可以远远超出路径图。例如,对于树形图,你仍然可以高效地进行动态规划来计算加权独立集。你甚至可以为更广泛的图类(称为有界树宽图)获得计算高效的算法。
- 背包问题:我们的动态规划背包算法的运行时间是物品数量乘以背包容量
W。由于指定容量W只需要log W位,我们不称之为多项式时间算法。但是,如果你只需要解决背包容量不太大的实例(例如,容量W是O(n)),那么我们的动态规划算法运行时间就是O(n²),这确实是一个针对小容量背包特殊情况的多项式时间算法。
接下来,让我提几个我们将在后续视频中看到的例子:
- 2-SAT问题:2-SAT是一种约束满足问题。
2表示每个约束只涉及一对变量的联合取值。一个典型的2-SAT约束会为两个变量指定三种允许的联合赋值和一种禁止的赋值。例如,对于变量x3和x7,允许同时为真、同时为假、x3为真x7为假,但禁止x3为假x7为真。3-SAT问题类似,但约束涉及三个变量的联合取值,并禁止八种可能性中的一种。3-SAT问题是经典的NP完全问题。但如果每个约束的大小只有两个(即2-SAT),那么问题就变成了多项式时间可解的。我们将讨论一种局部搜索算法来检查是否存在一个变量赋值能同时满足所有给定的约束。 - 顶点覆盖问题:这是一个图问题。顶点覆盖是独立集的补集。在独立集中,你不能从同一条边取两个顶点;而在顶点覆盖问题中,你必须从每条边中至少取一个顶点。你想要的是最小化顶点权重之和的顶点覆盖。同样,这在一般情况下是NP完全问题。但我们将专注于最优解很小的特殊情况,即存在一个小的顶点集合,使得每条边至少有一个端点在这个小集合中。我们将看到,对于这种特殊情况,使用一种智能的穷举搜索,我们实际上可以在多项式时间内解决问题。
让我重申,这些可处理的特殊情况主要是作为构建块,你可以在处理NP完全问题时,基于它们构建可能更复杂的方法。
为了让这一点更具体,让我设想一个场景。假设你的项目面对的不是2-SAT,而是一个3-SAT实例。你可能会感到沮丧,因为3-SAT是NP完全的。也许你有1000个变量,你当然不能对2¹⁰⁰⁰种可能的赋值方式进行暴力搜索。
但好消息是,由于你拥有领域专业知识,你理解这个问题的实例。你知道虽然有1000个变量,但真正关键的可能只有20个。你感觉所有的核心问题都归结于这20个核心变量如何赋值。
现在,也许你可以将一些暴力搜索与这些可处理的特殊情况结合起来。例如,你可以枚举这20个核心变量的所有2²⁰种赋值方式(大约一百万种,这并不算太糟)。然后,对于这一百万种情况中的每一种,你检查是否有可能将这20个变量的赋值扩展到其他980个变量,使得所有约束都得到满足。原始问题可解,当且仅当存在一种对这20个变量的赋值方式,可以成功扩展到其他980个变量。
因为这些都是关键变量,是所有问题的核心。也许一旦你为它们全部赋了0或1,剩余的SAT实例就变得易于处理了。例如,它可能就变成了一个简单的2-SAT实例,然后你就可以在多项式时间内解决它。这就给了你一种混合方法:顶层进行暴力搜索,对20个变量的每种赋值猜测使用可处理的特殊情况。这样你就有了一个可行的方案。
我希望这很清楚,这只是你可能将我们正在开发的各种构建块组合成更复杂方法来处理NP完全问题的一种可能方式。通常,处理NP完全问题需要相当复杂的方法,毕竟它们是NP完全的,你必须尊重这一点。
策略二:采用启发式算法
完成上述讨论后,让我提一下我们将在接下来的讲座中探讨的另外两种策略。
第二种策略在实践中非常常见,即求助于启发式算法,也就是不保证正确的算法。
到目前为止,我们在课程中还没有真正看到启发式算法的例子。那些参加过第一部分课程的同学,也许我们可以将Karger的随机最小割算法归类为启发式算法,因为它确实有一个很小的失败概率,可能找不到最小割。但在接下来的讲座中,我将重点介绍一些例子。
我将使用背包问题作为案例研究。我们将看到,我们的工具箱(包含各种算法设计范式)不仅对设计正确的算法有用,对设计启发式算法也有用。具体来说,我们将使用贪心算法设计范式为背包问题得到一个相当好的算法,并使用动态规划算法设计范式为背包问题得到一个优秀的启发式算法。
策略三:设计比暴力搜索更聪明的精确算法
最后的策略适用于你不愿意放松正确性要求,或者不愿意考虑启发式算法的情况。当然,对于一个NP完全问题,如果你总是要求正确,你就不期望它在多项式时间内运行。但仍然有机会拥有一些算法,虽然在最坏情况下是指数时间,但比朴素的暴力搜索更聪明。
事实上,我们已经看到了一个可以被解释为实现此策略的例子,那就是背包问题。在背包问题中,朴素的暴力搜索会遍历所有可能的物品子集,检查一个子集是否适合背包,如果适合,则记录其价值,然后返回具有最大价值的可行解。其时间与2ⁿ成正比,其中n是物品数量。
我们的动态规划算法的运行时间是n × W。当然,如果背包容量W非常大(例如它本身是2ⁿ),这并不比2ⁿ好。但正如我们所论证的,如果W较小,这个算法会更快。而且,正如你在第三次编程作业中学到的,有时即使W很大,动态规划也会大大优于暴力搜索。
我将再展示几个例子:
- 旅行商问题:朴素的暴力搜索大约需要
n!的时间,其中n是顶点数。我们将给出一种基于动态规划的替代解决方案,其运行时间仅为2ⁿ,这比n!好得多。 - 顶点覆盖问题:我们将在后续视频中详细讨论这个例子。考虑问题的这个版本:检查是否可能有一个只使用
k个顶点的顶点覆盖。朴素的暴力搜索运行时间为nᵏ,即使k很小,这也变得荒谬。但我们将展示一个更聪明的算法,虽然仍然是k的指数时间,但其运行时间仅为2ᵏ乘以图的大小。

总结
在本节课中,我们一起学*了应对NP完全问题的三种主要算法策略:
- 关注可计算的特殊情况:识别并利用问题实例中的特殊结构,将其转化为已知的可解子问题。
- 采用启发式算法:在可接受*似解或概率性正确的情况下,使用高效但不保证绝对正确的算法。
- 设计更聪明的精确算法:即使问题是指数级的,也通过动态规划等技巧设计比朴素暴力搜索更高效的精确算法。
理解这些策略将帮助你在面对计算难题时,能够系统地思考并找到可行的解决方案。记住,NP完全性是一个分类,而不是一个障碍;它指导我们调整期望并选择合适的方法。
149:顶点覆盖问题

在本节课中,我们将学*一个被称为“顶点覆盖问题”的经典NP完全问题。我们将探讨其定义、应用场景以及处理此类问题的通用策略。特别地,我们会为顶点覆盖问题设计一个精确算法,该算法虽然在最坏情况下仍是指数级时间复杂度,但相比朴素的暴力搜索有显著改进。这展示了即使面对NP完全问题,算法设计依然大有可为。
问题定义
顶点覆盖问题的输入是一个无向图。目标是找出该图的一个“顶点覆盖”的最小尺寸,即最小基数。
什么是顶点覆盖?
我们称一个顶点的子集S为一个顶点覆盖,如果对于图中的每一条边,该边至少有一个端点(也可能两个端点都在)位于集合S中。
当然,总存在一个可行的解:你可以选择所有顶点,这显然是一个顶点覆盖。但困难的问题在于,如何以最节约的方式确保从每条边中选出一个端点。
应用场景
这个问题可以代表许多场景。例如,假设你正在组建一个团队(可能是程序员、律师或足球运动员)。将图中的顶点视为你可以招募的潜在人选,将边视为团队可能需要完成的潜在任务,而边上的两个顶点代表能够完成该任务的两个人。那么,一个顶点覆盖就意味着:雇佣足够多的人,使得对于每一个任务,你的团队中至少有一人能够完成它。
这个问题可以有各种推广,例如为每个顶点赋予权重(代表需要支付的薪水),或者将边推广为“超边”(表示超过两个人能完成一个任务)。但为了我们的目的,无向图上的无权顶点覆盖问题已经足够有趣。
一个小测验
为了确保问题定义清晰,我们来看一个测验。
问题: 考虑两个图:一个是有n个顶点的星形图,另一个是有n个顶点的团(完全图)。它们的最小顶点覆盖大小分别是多少?
答案:
- 星形图: 最小顶点覆盖大小为1。只需选择中心顶点即可覆盖所有边。
- 团(完全图): 最小顶点覆盖大小为n-1。任何少于n-1个顶点的集合都会遗漏至少两个顶点,而这两个顶点之间的边将无法被覆盖;而任意n-1个顶点的集合都能覆盖所有边。
处理NP完全问题的策略
对于一般图,计算最小顶点覆盖是一个NP完全问题,除非P=NP,否则不存在多项式时间算法。这是一个坏消息。因此,我们需要回顾之前讨论过的处理NP完全问题的策略。
上一节我们介绍了顶点覆盖问题及其NP完全性。本节中,我们来看看应对此类问题的三种主要策略。
策略一:识别可计算处理的特殊情况
最理想的情况是,你在实际应用中需要解决的问题恰好落在某个可计算处理的特殊情形内。更常见的情况是,应用中的实例不一定属于这些特殊情况,但为各种特殊情况准备好子程序仍然有用。我们过去讨论过“混合方法”的潜力:或许你可以进行少量的暴力搜索,而在搜索的大部分分支中,剩余问题会落入一个可计算处理的特殊情况。
对于顶点覆盖问题,存在一些有趣的可计算处理的特殊情况,这与我们过去讨论独立集问题的思路非常相似。
以下是顶点覆盖问题的一些可计算处理的特殊情况:
- 路径图、树、有界树宽图: 与独立集问题类似,顶点覆盖问题在树(以及更一般的有界树宽图)上可以通过动态规划在多项式时间内解决。这可以作为一个练*。
- 二分图: 二分图(即没有奇环的图)上的顶点覆盖问题也可以在多项式时间内解决。这可以通过“最大流问题”来解决,这超出了本课程的范围。
在接下来的视频中,我将介绍另一种算法,它针对顶点覆盖问题的另一个特殊情况:当所求的顶点覆盖很小时(例如,顶点数量的对数级别或更小)。
策略二:使用启发式算法
第二种策略是放宽对正确性的要求,专注于设计启发式算法——那些运行快速但未必产生最优解的算法。
对于顶点覆盖问题,存在一些相当好的启发式算法。例如,可以使用贪心算法设计范式来生成一些启发式。我不会在这里详细讨论,因为我认为将时间花在讨论背包问题的启发式算法上会更好。但请注意,如果明智地做出贪心选择,可以为顶点覆盖问题获得相当好的*似保证。

策略三:设计改进的精确算法
第三种策略是坚持要求正确性。在这种情况下,除非你意图证明P=NP,否则你不应期望对一般实例获得多项式时间算法。
因此,虽然你预期的是指数级运行时间,但你仍然希望获得一个在质量上优于朴素暴力搜索的运行时间。这正是下一个视频的重点。我们将给出一个确实基于枚举的算法,但它是一种比朴素暴力搜索更聪明的枚举方式,从而获得更快的指数级运行时间,使我们能够解决更广泛的问题。
总结
本节课中,我们一起学*了顶点覆盖问题的定义及其作为NP完全问题的性质。我们探讨了它的一个直观应用场景,并通过小测验加深了对问题定义的理解。接着,我们回顾了处理NP完全问题的三种通用策略:寻找可计算处理的特殊情况、使用启发式算法以及设计改进的精确算法。我们特别指出了顶点覆盖问题在树和二分图等特殊情况下的可解性,并预告了下一讲将重点介绍一种针对“小规模”顶点覆盖的改进型精确算法。
150:顶点覆盖的智能搜索(全)

在本节课中,我们将学*如何为顶点覆盖问题开发一个精确算法。该算法在最坏情况下运行时间为指数级,但相比朴素的暴力搜索,它在性质上更优。一个重要的推论是,当最优解规模很小(至多为对数级别)时,顶点覆盖问题在计算上是可处理的。
问题定义与动机
首先,让我们快速回顾一下顶点覆盖问题。输入是一个无向图 G。图的一个顶点覆盖是一个顶点的子集,它包含图中每条边的至少一个端点。顶点覆盖问题的目标是计算最小规模的顶点覆盖。
我们考虑该问题的一个变体:额外给定一个目标值 K(一个正整数),我们想知道是否存在一个规模为 K 或更小的顶点覆盖。从本质上讲,这并没有让问题变得更简单。如果你能解决这个给定目标 K 的版本,你就能解决原始问题(即找出最小覆盖规模),只需对 K 从 1 到 N 的所有可能值运行这个子程序即可。
我们之所以关注 K 较小的情况,是因为在实际应用中,我们可能只对规模合理的解感兴趣。例如,在组建团队的场景中,每个顶点代表一个潜在的团队成员,边代表任务,端点代表能执行该任务的两个人。你可能只愿意在能找到规模合理的团队(即小规模顶点覆盖)时,才承接项目。或者,你可能拥有领域知识,知道你的图确实存在小规模的顶点覆盖。
朴素暴力搜索的局限性
从算法角度看,关注 K 较小的情况是有用的。如果你要寻找一个仅包含 K 个顶点的顶点覆盖,你可以直接检查所有 K 个顶点的子集。子集的数量是 n 选 K,对于小的 K,这大约是 θ(n^K)。因此,只要 K 是常数,这种朴素的暴力搜索原则上可以在多项式时间内运行。
然而,在实际中,除非 K 非常小(例如 3 或 4),否则很难运行这种朴素算法。这自然引出了一个问题:我们能否做得更好?是否存在更智能的搜索方法?
答案是肯定的。接下来,我们将介绍一种更智能的搜索方法,它允许我们处理在性质上更大的 K 值。
子结构引理
这种搜索算法将基于一个引理来驱动,其精神类似于我们在动态规划算法中关于最优解的推理。我们称之为子结构引理。
考虑一个输入图 G,我们的算法任务是检查 G 是否存在一个规模为 K 的顶点覆盖。同时,考虑图中的一条边,假设是 U 和 V 之间的边。
与动态规划中的最优子结构类似,我们将考虑通过某种方式减少原始实例的规模。具体来说,我们将考虑两个更小的图:一个是删除顶点 U 及其所有关联边后得到的图 G_U;另一个是删除顶点 V 及其所有关联边后得到的图 G_V。
引理断言:我们关心的原始问题(G 是否存在规模为 K 的顶点覆盖)可以转化为关于更小图 G_U 和 G_V 的类似问题。具体来说:
G 存在规模为 K 的顶点覆盖,当且仅当 G_U 或 G_V 中至少一个存在规模为 K-1 的顶点覆盖。
引理证明
这是一个“当且仅当”陈述,因此证明包含两个部分。
第一部分(右推左):
假设 G_U 或 G_V(不妨设为 G_U)确实存在一个规模为 K-1 的顶点覆盖 S。我们需要证明 G 也存在一个规模为 K 的顶点覆盖。
考虑原始图 G 的边集。它可以分为两类:与 U 关联的边集 F_U,以及不与 U 关联的边集 E_U。图 G_U 的边集正是 E_U。
由于 S 是 G_U 的顶点覆盖,它包含了 E_U 中每条边的至少一个端点。现在,如果我们取集合 S 并加入顶点 U,就得到了原始图 G 的一个顶点覆盖:S 负责覆盖 E_U 中的所有边,而 U 则负责覆盖 F_U 中的所有边(因为这些边都与 U 关联)。因此,我们得到了一个规模为 K 的顶点覆盖。

第二部分(左推右):
现在假设原始图 G 确实存在一个规模为 K 的顶点覆盖 S。我们需要证明这必然反映在两个子图之一中:G_U 或 G_V 必须本身有一个规模为 K-1 的顶点覆盖。
考虑我们最初选定的边 (U, V)。由于 S 是 G 的顶点覆盖,它必须包含 U 或 V(或两者)。不妨设 U 在 S 中。
再次将 G 的边集分解为 E_U(不与 U 关联的边)和 F_U(与 U 关联的边)。集合 S 是 G 的顶点覆盖,因此它包含每条边的至少一个端点。顶点 U 是 F_U 中所有边的一个端点,但它不是 E_U 中任何边的端点。
这意味着,S 中除 U 外的其他 K-1 个顶点,必须负责包含 E_U 中所有边的端点。因此,如果我们从 S 中移除 U,剩下的 K-1 个顶点就构成了 G_U 的一个顶点覆盖。
这就完成了引理的证明。
从引理到算法
上一节我们介绍了子结构引理,它揭示了原问题与更小子问题之间的关系。本节中,我们来看看如何利用这个引理设计一个更智能的搜索算法。
该引理直接启发了一个递归算法:
- 如果图 G 没有边,那么空集就是一个顶点覆盖(规模为0)。如果此时 K ≥ 0,则答案为“是”。
- 如果 K = 0 但图仍有边,则不存在顶点覆盖,答案为“否”。
- 否则,选择任意一条边 (U, V)。根据引理,原问题有解当且仅当在 G_U(删除U)或 G_V(删除V)中,存在规模为 K-1 的顶点覆盖。因此,我们递归地检查这两个更小的子问题。
这个算法的运行时间如何?在最坏情况下,它会产生一个深度为 K 的递归树,因为每次递归调用 K 减少1。在每一层,问题会分支成两个子问题。因此,递归调用的总数最多约为 2^K。在每个递归调用中,我们需要处理图(删除顶点),这可以在多项式时间内完成(例如 O(n+m))。因此,总运行时间为 O(2^K * poly(n))。
这与朴素的 O(n^K) 搜索相比是一个质的飞跃。当 K 是常数时,两者都是多项式时间,但前者的指数底数是固定的2,而后者指数底数是 n。更重要的是,当 K = O(log n) 时,2^K 是 n 的多项式(因为 2^{c log n} = n^c),这意味着对于最优解规模至多为对数级别的图,我们可以在多项式时间内精确求解顶点覆盖问题。
总结
本节课中,我们一起学*了顶点覆盖问题的一个智能搜索算法。我们首先定义了问题,并讨论了关注小规模解 K 的动机。接着,我们指出了朴素暴力搜索 O(n^K) 的局限性。然后,我们介绍并证明了一个关键的子结构引理,该引理将原问题归结为两个更小的子问题。最后,基于这个引理,我们设计了一个递归算法,其运行时间为 O(2^K * poly(n))。这个算法在性质上优于朴素搜索,并且使得在最优解规模很小(例如 K = O(log n))的特殊情况下,顶点覆盖问题变得计算上可处理。
151:顶点覆盖的智能搜索(二)🔍

在本节课中,我们将学*如何利用顶点覆盖问题的子结构性质,设计一个比朴素暴力搜索更高效的递归搜索算法。我们将详细解析算法的步骤、正确性证明以及时间复杂度分析。
上一节我们介绍了顶点覆盖问题的子结构引理,本节中我们来看看如何基于该引理自然地导出一个递归搜索算法。
该算法是递归的。我们无需详细说明基础情况,即当图最多只有一个顶点,或参数k等于0或1时的处理。我们假设图至少有两个顶点,且k至少为2。
这个算法可能会让你想起我们讨论过的某些动态规划算法的递归实现。同样地,那些算法在没有记忆化的情况下会呈现指数级运行时间。我们将在这里看到一个指数级的运行时间。对于我们所有的动态规划算法,我们能够巧妙地将子问题从小到大组织,从而避免重复解决相同的子问题,最终得到多项式时间复杂度的界限。而在这里,我们将受限于指数级的时间复杂度界限。考虑到这是一个NP完全问题,这并不奇怪。但如果你想深入理解,可以尝试对这个递归搜索算法进行改进,尝试构思一个多项式时间版本,尝试提出一个可以系统求解的、按从小到大顺序排列的小型子问题集合。这些尝试最终必然会受阻,但你会更好地理解这个递归解决方案的非平凡之处。
以下是算法的第一步:
- 我们任意选择一条边
(u, v)。
为什么关注一条边是有用的?根据顶点覆盖的定义,它必须包含 u 或 v。这就是我们的初始线索。
我们将乐观地进行。我们正在寻找一个大小为 k 的顶点覆盖。因此,我们假设存在这样一个解。子结构引理告诉我们什么?它指出,如果存在这样的解,那么必然地,要么 G\{u},要么 G\{v},或者两者本身,拥有大小仅为 k-1 的小型顶点覆盖。此外,将这样的顶点覆盖扩展为 G 的小型覆盖很简单,只需添加缺失的顶点即可。
因此,我们首先采纳一个工作假设:G\{u} 拥有一个大小仅为 k-1 的小型顶点覆盖。让我们递归地尝试找到它。如果我们的递归搜索返回一个解,即 G\{u} 的一个大小为 k-1 的顶点覆盖,那么我们就成功了。我们只需将其与顶点 u 合并,就得到了原始图 G 的大小为 k 的顶点覆盖。
如果那个递归调用未能找到小型顶点覆盖,我们会说,好吧,那么 v 必定是获得小型顶点覆盖的关键。因此,我们做完全相同的事情:递归地在 G\{v} 中搜索大小为 k-1 的顶点覆盖。
如果第二个递归调用也未能找到大小仅为 k-1 的小型顶点覆盖,那么根据子结构引理的逆否命题,我们知道原始图 G 不可能拥有大小为 k 的顶点覆盖。如果它有,子结构引理告诉我们两个递归调用中必有一个会成功;既然两者都失败了,我们就能正确地得出结论:原始图没有小型顶点覆盖。
正确性分析是直截了当的。形式上,你可以通过归纳法进行。归纳假设将保证两个递归调用的正确性。因此,在较小的子问题 G\{u} 和 G\{v} 上,递归调用能正确检测是否存在大小为 k-1 的顶点覆盖。子结构引理保证我们能正确地将两个递归调用的结果编译成我们算法的输出。因此,如果两个递归调用中有一个成功,我们就成功了,只需按要求输出大小为 k 的顶点覆盖。这是关键部分:如果两个递归调用都失败,那么根据子结构引理,我们正确地报告失败。我们知道原始图 G 中不存在大小为 k 或更小的顶点覆盖。
接下来,让我们通过一个特设分析来评估运行时间。让我们考虑在整个算法执行过程中可能产生的递归调用数量,或者等价地,算法生成的递归树中的节点数量。然后,我们将得出在一个给定的递归调用中(不包括其递归子调用所做的工作)所做工作的上限。
现在,是精彩的部分,这确实是参数 k 值较小这一规则发挥作用的地方。我注意到,每次递归时,我们将要寻找的顶点覆盖的目标大小减1。记住,如果我们的输入参数是 k,那么我们的每个递归调用都涉及参数值 k-1。
这限制了递归深度,或者说该算法生成的递归树的深度,最多为 k。初始时,你从 k 开始;每次递归,你将其减1;当 k 大约为1或0时,你将到达基础情况。
将这两个观察结果结合起来:分支因子以2为界,递归深度以 k 为界。在整个算法生命周期内,递归调用的总数将不超过 2^k。
此外,如果你检查伪代码,你会发现除了递归子调用外,并没有太多其他操作。因此,即使是一个非常草率的实现,你需要从输入图 G 构造出 G\{u} 和 G\{v},即使你粗糙地完成这项工作,你也会得到一个线性时间界限:每个递归调用(不包括后续递归子调用所做的工作)的工作量为 O(m)。

这意味着该算法所做总工作量的一个上限是:递归调用数量 2^k 乘以每次调用的工作量 m,即 2^k * m。
现在,你当然会注意到这个运行时间在 k 上仍然是指数级的。但除非我们打算证明 P = NP,否则它必须是指数级的。别忘了顶点覆盖问题是NP完全的。
那么我们完成了什么?之前我们有一个通过暴力搜索的平凡指数时间算法。为什么我们要经历这些步骤来提出第二个具有这种指数运行时间界限的算法呢?嗯,这是因为这个运行时间界限在性质上优于我们之前朴素的暴力搜索。
以下是两种理解为什么这个运行时间界限在性质上更优越的方式:
首先,让我们仅从数学角度审视这个运行时间,并问:在仍然拥有多项式时间算法的情况下,k 最大可以取多大?回想一下我们朴素的暴力搜索算法,你只需尝试每个包含 k 个顶点的子集,时间复杂度为 θ(n^k)。因此,如果 k 是常数,它是多项式时间;如果 k 大于常数,它就是超多项式时间。
对于这个运行时间界限 2^k * m,我们可以让 k 增长到与图大小的对数一样大,并且仍然拥有一个多项式时间算法。
现在,让我们从这些数学界限过渡到思考实际实现这些算法并在实际数据上运行它们。
对于朴素的暴力搜索,你有 n^k 的运行时间。即使对于一个相对较小的图,比如说 n 大约是50或100,除了 k 值极小(3、4,也许5,如果你幸运的话)的情况,你将无法运行这个朴素的暴力搜索算法。因此,朴素的暴力搜索适用范围非常有限。
相比之下,我们更智能的搜索算法可以容纳明显更大的 k 值范围。记住,这里的运行时间是 2^k 乘以图的大小。因此,即使对于 k 值高达20的情况,对于许多感兴趣的图网络,你也将能够在合理的时间内实现并运行这个算法。
本节课中,我们一起学*了如何基于子结构引理设计一个针对顶点覆盖问题的递归搜索算法。我们分析了算法的正确性,并推导出其时间复杂度为 O(2^k * m)。虽然这仍然是指数级的,但相比朴素的 O(n^k) 暴力搜索,它在 k 值较大时具有显著的实践优势,能够处理更大范围的参数 k。这体现了针对NP难问题设计参数化算法时,利用问题结构特性优化搜索策略的重要性。
152:旅行商问题(TSP)的动态规划解法 🧳
在本节课中,我们将重新审视著名的旅行商问题(TSP)。我们之前讨论TSP时,是在NP完全性的背景下,结论是悲观的。然而,本节将带来好消息:我们可以设计出比朴素暴力搜索更高效的算法。这将是动态规划算法设计范式的又一个巧妙应用。


问题回顾与挑战

首先,让我们简要回顾一下旅行商问题。输入非常简单:一个完全无向图,其中每条边都有一个非负成本。算法的任务是找出访问每个顶点恰好一次的最小成本方式,即输出一个顶点排列(一个“环游”),使得对应n条边的成本总和最小。

例如,在一个四顶点的网络中,最小成本环游的总成本可能是13。
当然,你可以使用暴力搜索解决这个问题。暴力搜索的运行时间大约是 O(n!),这只能解决大约12到14个顶点的问题。
在本节及后续内容中,我们将开发一个解决TSP的动态规划算法。当然,TSP是NP完全问题,我们不期望有多项式时间算法。但这个动态规划算法将比暴力搜索快得多,其运行时间为 O(n² * 2ⁿ)。虽然2ⁿ是指数级的,但比n!要好得多。实际上,你可以用这个算法解决n大约为30的问题。
尽管与我们可以排序的数组大小或计算强连通分量/最短路径的图规模相比,这仍然很小,但对于NP完全问题来说,即使解决中等规模的问题也需要付出巨大努力。这个算法证明了,即使对于NP完全问题,也有机会改进暴力搜索。
寻找最优子结构
我们计划采用动态规划方法解决TSP。这意味着我们需要寻找最优子结构:一种方式,使得最优解必然由更小子问题的最优解以某种简单方式扩展而成。那么,对于TSP,这可能是什么样子呢?


在我们使用动态规划解决的所有问题中,与TSP最相似的是单源最短路径问题。我们可以将环游视为一条从某个顶点(比如顶点1)出发,最终回到自身的路径,当然,约束条件是它必须在途中恰好访问每个其他顶点一次。我们希望最小化这条从1回到自身的路径的总成本。
这听起来很像我们希望最小化从某个源点到某个目的地的路径长度。你可能会想起,我们解决单源最短路径问题的动态规划算法是贝尔曼-福特算法。


初步尝试:基于边预算的子问题
那么,贝尔曼-福特算法中的子问题是什么样子的呢?其巧妙之处在于使用边预算来衡量子问题规模。贝尔曼-福特中的一个典型子问题是:计算从给定源点到某个目的地顶点V、使用最多I条边的最短路径长度。

类比于此,我们可以考虑这样的子问题:我们希望找到从起始节点(顶点1)到某个其他顶点J、使用最多I条边的最短路径。

具体来说,让我们如下定义子问题:对于给定的选择I(代表允许使用的边数)和给定的目的地J(假设顶点编号为1到N),我们用 L(I, J) 表示从起始顶点1到目的地J、使用最多I条边的最短路径长度。
如果我们尝试使用这些子问题来建立动态规划算法,你认为我们能得到TSP的多项式时间算法吗?
答案是否定的。这个提议的子问题集合不会产生多项式时间算法。原因不是子问题数量太多(只有O(n²)个),也不是无法从较小的子问题正确计算较大子问题的值(这与贝尔曼-福特完全相同)。问题在于语义不正确:通过解决这些子问题,我们实际上无法得到原始旅行商问题实例的最优解。
我们希望的情况是,在解决了所有子问题之后,原始问题的答案就存在于最大的子问题中。这里最大的子问题对应于I = n,即允许在路径中使用最多n条边。但这些子问题只指定了允许使用的边数上限I,并没有强制你必须使用全部的I条边。这意味着当我们查看这些最大的子问题时,最短路径可能使用多达n条边,但通常不会。它们会使用远少于n条边,跳过许多顶点,而这并不是一个旅行商环游。旅行商必须恰好访问每个顶点一次,这些子问题没有强制执行这一点。
第二次尝试:强制使用恰好I条边
这个问题似乎不难修复。我们只需在每个子问题中坚持,最短路径必须使用恰好I条边,而不是最多I条边。
然而,这个子问题集合虽然不像前一个那样完全错误,但答案仍然是否定的。我们仍然无法得到多项式时间算法。子问题数量没有改变,仍然是二次的,并且你仍然可以基于较小子问题的解来高效求解较大的子问题。
问题在于语义仍然不正确。仅仅解决了所有这些子问题,并不意味着你能从中提取出最小成本旅行商环游的成本。
简要来说,问题在于我们没有强制执行不能多次访问同一个顶点的约束。我们希望的是,当我们查看最大的子问题(I = n,目的地J = 1)时,那条从1回到自身、恰好有n条边的最短路径就是一个环游,因此就是最小成本旅行商环游。但情况未必如此。仅仅因为这条路径有n条边,并且始于1终于1,并不意味着它是一个环游。例如,它可能两次访问顶点7和23,结果却从未访问顶点14或29。因此,当I = n且J = 1时,最大子问题计算出的数值可能远小于真正的最小成本旅行商环游的答案。
第三次尝试:禁止重复访问顶点
一个好的算法设计师必须具备韧性。让我们认识到上一个提议的缺陷:我们没有强制执行不能重复访问顶点的约束。让我们再次改变子问题的定义,明确禁止重复访问顶点。
具体来说,我们以与之前完全相同的方式索引子问题:每个预算I和每个目的地J对应一个子问题。对于给定的I和J,子问题的值现在定义为:从1开始、结束于J、使用恰好I条边、且不允许重复访问顶点的最短路径长度。唯一的例外是,如果J等于1,那么你当然可以在开头和结尾都有1,但对于最短路径中的内部顶点,我们不允许重复。
那么,你认为这个改进后的子问题集合能让我们得到旅行商问题的多项式时间算法吗?
这个问题的答案比前几个更微妙。正确答案从C切换到了B。子问题数量仍然是二次的,我们仍然不期望多项式时间算法,但现在,有了这个不同的子问题定义,它们确实捕捉到了TSP的本质。具体来说,看看最大的子问题:取I = n,J = 1。这个子问题的职责是计算从1到1、恰好有n条边且内部顶点不重复的最短路径。这正是我们最初的问题,正是TSP。
问题在于,你无法基于较小子问题的解来高效求解较大的子问题。较小的子问题对于解决较大的子问题并不是很有用,原因有些微妙。
递归关系的困境与启示

我们希望像之前所有的动态规划算法一样,可以制定一个递归关系,告诉你如何基于较小子问题的解来填充(求解)较大的子问题。对于这些子问题,甚至有一个自然的猜测,即递归关系可能是什么样。

递归关系通常源于对最优解必须是什么样子的思考实验。因此,你会想专注于一个特定的子问题:一个给定的目的地J和一个给定的边预算I。你会说,让我们思考一个最优解。那是什么?那是一条路径。它始于1,终于J,恰好有I条边,没有重复顶点,并且在所有这样的路径中,它具有最小长度。
很自然地,我们可以类比贝尔曼-福特算法说:如果一个小小的边界条件能告诉我这条从1到J的最短路径上的倒数第二个顶点K是什么,那不是很酷吗?



如果我知道最短路径上的倒数第二个顶点是K,那么这条路径的长度当然就是一条从1到K、使用恰好I-1条边且无重复顶点的最优路径的长度,再加上最后一段从K到J的跳跃。


当然,你不知道倒数第二个顶点K是什么,但在动态规划中,这没什么大不了的,我们只需尝试所有的可能性。



我们可以将这种暴力搜索编码为一个最小值计算,遍历所有合理的K选择。显然,K不应等于J,它应该是J之前的某个其他顶点。忽略一些基本情况,我们也排除K等于1(起始顶点)的情况。从图示上看,我们设想的是取一条从1到K的最短路径(这可以在某个较小的子问题中预先计算),然后将最后一段从K到J的跳跃拼接上去。

这听起来很不错,对吧?这听起来不就像是可能为TSP的多项式时间算法提供了关键要素吗?


问题在于:我们已经定义了子问题,禁止重复访问顶点。因此,当我们计算一个子问题时,最好确保我们尊重“不允许重复访问”的约束。如果我们设想将一条从1到K、无重复的最短路径与最后一段K到J的跳跃拼接起来,这可能会导致重复访问J。特别是,我们无从得知那条从1到K、使用恰好I-1条边且无重复顶点的最短路径是否可能经过J。如果它经过了J,那么拼接最后一段KJ就会导致一个循环,即第二次访问J。
这个缺陷意味着提议的递归关系是不正确的。递归关系计算出的值,在一般情况下,小于实际的、尊重“无重复访问”约束的、从1到J使用恰好I条边的最短路径长度。实际长度可能比这个递归关系计算出的值大。

故事的寓意是:旅行商问题中“每个顶点恰好访问一次”的约束是一个相当棘手的约束,我们需要付出相当大的努力来确保它得到满足。

我们将要使用的解决方案,在某种意义上,是这里递归关系的逻辑下一步。我们需要能够知道关于子问题的更多信息,而不仅仅是它们在哪里结束。我们实际上需要知道用于从1旅行到K的路径上顶点的身份。我们需要知道顶点V是否在那条路径上。因此,我们将查看一个大得多的子问题集合,在那里我们不仅记住目的地,还记住所有中间站点。这个想法将转化为一个动态规划算法(当然不是多项式时间的)。具体细节即将揭晓。
总结
本节课中,我们一起学*了如何为NP完全的旅行商问题设计动态规划算法。我们首先回顾了问题定义与暴力搜索的局限性。接着,我们尝试通过类比单源最短路径问题来寻找最优子结构,经历了三次定义子问题的尝试:
- 基于边预算上限:无法保证访问所有顶点。
- 强制使用恰好I条边:仍无法避免顶点重复访问。
- 禁止重复访问顶点:子问题语义正确,但无法建立有效的递归关系,因为拼接路径时可能引入重复顶点。
最终我们发现,关键障碍在于“每个顶点恰好访问一次”的约束难以在简单的递归中维护。这引导我们得出核心洞见:为了处理这个约束,子问题必须记忆更多信息——不仅仅是路径的终点和长度,还需要记住已经访问过的顶点集合。这为下一节中最终的高效(虽仍是指数级)动态规划算法奠定了基础。
153:TSP的动态规划算法 🧳

在本节课中,我们将学*如何为旅行商问题设计一个比暴力搜索更快的动态规划算法。上一节我们介绍了TSP问题的挑战性,本节中我们来看看如何运用动态规划的标准步骤来解决它。
概述
旅行商问题要求找到一条访问所有城市恰好一次并返回起点的最短路径。由于这是一个NP完全问题,直接的暴力搜索(时间复杂度为O(n!))在n较大时不可行。我们将定义一个基于子集的动态规划状态,将时间复杂度降低到O(n² * 2ⁿ)。
子问题定义
在单源最短路径问题中,子问题通常只需知道路径的终点和长度。但对于TSP,我们必须确保路径不重复访问顶点。因此,子问题需要记住路径访问过的所有顶点。
我们定义子问题 A[S, j] 如下:
- S:一个顶点集合,必须包含起点1和终点j。
- j:路径的终点。
- A[S, j] 的值:从顶点1出发,恰好访问集合S中所有顶点一次,最终到达顶点j的最短路径长度。
这个定义的关键在于,它记录了访问了哪些顶点,但没有记录访问这些顶点的顺序。如果记录顺序,子问题数量将是阶乘级(O(n!))。而只记录子集,则将其减少到指数级(O(2ⁿ)),这正是算法优于暴力搜索的来源。
最优子结构与递推关系
与贝尔曼-福特算法类似,我们通过关注路径的“最后一跳”来建立最优子结构。
对于一个给定的子问题A[S, j],假设其最优路径P的最后一跳是从某个顶点k到j。那么,路径P的前半部分P‘一定是从1到k,并且恰好访问了集合 S - {j} 中所有顶点一次的最短路径。
根据最优子结构性质,A[S, j]可以通过检查所有可能的“倒数第二个顶点”k来递归求解。
以下是递推关系:
A[S, j] = min { A[S - {j}, k] + C[k][j] }
其中 k ∈ S 且 k ≠ j
公式解释:
A[S - {j}, k]:从1到k,访问除j外S中所有顶点的最短路径长度。C[k][j]:从顶点k到顶点j的直接距离(成本)。min:对所有可能的k取最小值。
这个递推关系确保,在求解规模为|S|的子问题时,我们只需要规模更小(|S|-1)的子问题的解。

算法伪代码
以下是动态规划算法的实现步骤:
首先,我们需要初始化基础情况。
基础情况:
当集合S只包含起点1,且终点j=1时,路径长度为0。其他包含额外顶点且j=1的情况不可行,设为无穷大。
// 初始化
for 所有包含顶点1的集合S:
for 每个顶点j:
if S == {1} 且 j == 1:
A[S][j] = 0
else:
A[S][j] = INFINITY
接下来,我们系统地求解所有子问题。外层循环按照子问题规模(即集合S的大小)从小到大进行。
// 动态规划主循环
for m = 2 to n: // m是子问题规模(S的大小)
for 每个大小为m且包含顶点1的集合S:
for 每个属于S且不等于1的顶点j:
// 应用递推关系
A[S][j] = INFINITY
for 每个属于S且不等于j的顶点k:
candidate = A[S - {j}][k] + C[k][j]
A[S][j] = min(A[S][j], candidate)
在求解完所有子问题后,我们还需要最后一步来得到完整的TSP环游解。最大的子问题A[V, j]给出了从1出发,访问所有顶点后到达j的最短路径。要形成环游,我们还需要从j返回起点1。
计算最终答案:
min_tour_cost = INFINITY
for j = 2 to n:
candidate = A[V][j] + C[j][1] // V是所有顶点的集合
min_tour_cost = min(min_tour_cost, candidate)
return min_tour_cost
算法分析
- 正确性:基于最优子结构引理和递推关系的正确性,通过归纳法可证明算法的正确性。
- 时间复杂度:
- 子问题数量:约有 2ⁿ 个集合S,每个集合对应n个可能的终点j,故子问题总数约为 n * 2ⁿ。
- 每个子问题的计算:需要遍历S中可能的顶点k(最多n个),故每个子问题计算时间为O(n)。
- 总时间复杂度:O(n² * 2ⁿ)。
- 空间复杂度:需要存储所有子问题的解,为 O(n * 2ⁿ)。
总结
本节课中我们一起学*了如何为NP完全的旅行商问题设计动态规划算法。我们定义了基于顶点子集的子问题状态,建立了最优子结构并推导出递推关系,最终实现了时间复杂度为O(n² * 2ⁿ)的算法。虽然这仍然是指数时间,但相比O(n!)的暴力搜索已是巨大改进,展示了即使对于难解问题,通过巧妙的算法设计也能获得显著的性能提升。
154:贪心背包启发式算法 🎒


在本节课中,我们将学*如何为NP完全问题(以背包问题为例)设计和分析启发式算法。启发式算法通常运行速度快,但不保证100%正确。我们将重点探讨两种针对背包问题的启发式算法:一种基于贪心算法设计范式,另一种则巧妙运用动态规划思想。




概述:应对NP完全问题的三种策略

上一节我们介绍了NP完全问题的挑战性。本节中,我们来看看处理这类问题的三种通用策略,它们都适用于背包问题。
以下是三种主要策略:

- 识别计算上易处理的特殊情况:如果你幸运地发现,你的应用实例恰好属于某个计算上易处理的特殊情况(例如,背包容量较小的背包问题),那么问题就迎刃而解了。
- 设计和分析启发式算法:这类算法不保证100%最优,但通常运行速度很快。这是我们本节课的重点。
- 坚持使用精确算法:对于NP完全问题,这意味着接受指数级运行时间,但力求比朴素的暴力搜索(如枚举所有子集,时间复杂度为
O(2^n))有质的提升。例如,背包问题的动态规划解法时间复杂度为O(n * W),在大多数情况下比暴力搜索快得多。
背包问题回顾
在深入探讨启发式算法之前,让我们简要回顾一下背包问题的定义。
输入:给定 n 个物品,每个物品 i 有一个正的价值 v_i 和一个正的重量 w_i,以及一个背包容量 W。
目标:选择一个物品子集 S,使得子集中物品的总重量不超过背包容量 W,同时最大化这些物品的总价值。
用公式表示,即:
最大化: Σ(i∈S) v_i
约束条件: Σ(i∈S) w_i ≤ W
这是一个基础且常见的问题,本质上是关于如何在资源约束下做出最优选择。
贪心启发式算法设计 🚀
既然我们愿意为了速度而放松对正确性的绝对要求,贪心算法设计范式就重新成为解决背包问题的一个可行思路。贪心算法通常运行极快。

贪心排序策略



一个自然的贪心思路是按某种顺序逐个考虑物品,并不可逆地决定是否将其放入背包。关键问题在于:按什么顺序排序物品?



以下是几种可能的排序方式:



- 按价值降序:优先选择价值高的物品。但这很天真,因为它忽略了物品的重量。一个价值高但占满整个背包的物品,可能不如一个价值稍低但重量极轻的物品。
- 按重量升序:优先选择重量轻的物品。这同样不全面,因为它忽略了物品的价值。



回顾我们之前用贪心算法解决“最小化加权完成时间”调度问题的经验,每个作业也有两个参数(长度和权重)。当时的解决方案是按权重与长度的比值(即“单位权重完成时间”)降序来调度作业。


类比到背包问题,每个物品也有两个参数(价值和重量)。我们既偏好高价值,也偏好低重量。因此,一个自然的排序标准是价值与重量的比值,即“单位重量的价值”或“性价比”。我们按这个比值从高到低考虑物品。

基础贪心算法步骤



基于上述思路,基础贪心算法步骤如下:
- 计算每个物品的价值重量比
v_i / w_i。 - 按比值从高到低对物品排序。
- 按此顺序遍历物品。对于每个物品,如果它能放入当前背包剩余容量,则放入;否则,跳过它并继续检查后续物品(注:视频中为简化分析,采用了“一旦放不下就停止”的版本,但实践中检查所有物品更合理)。



基础贪心算法的缺陷
考虑一个简单的例子:
- 物品1:价值=2,重量=1,比值=2
- 物品2:价值=1000,重量=1000,比值=1
- 背包容量 W = 1000
基础贪心算法会先放入物品1(价值2),但背包剩余容量999不足以放入物品2,算法停止。最终解价值为2。然而,最优解是只放入物品2,价值为1000。这个例子表明,基础贪心算法的解可能任意差(与最优解相差任意倍)。
改进的贪心算法
为了解决上述缺陷,我们在算法末尾增加一个简单的“安全检查”步骤:
步骤3(新增):比较两个候选解,返回价值更高的那个。
- 候选解A:基础贪心算法得到的解。
- 候选解B:单个价值最高的物品。

这个改进看似只是针对特定反例的“补丁”,但它显著提升了算法的最坏情况性能保证。
性能保证定理
定理:上述三步贪心算法(按价值重量比排序贪心选取,最后与单件最高价值物品比较)是一个 1/2 *似算法。这意味着对于任何背包问题实例,该算法返回的解的总价值至少是最优解价值的 50%。

此外,该算法运行速度极快,主要开销在于排序,因此时间复杂度为 O(n log n)。
在接下来的课程中,我们将证明这个定理。我们还会看到,在某些额外的实例假设下,其性能保证会远优于50%。


总结


本节课我们一起学*了如何为NP完全的背包问题设计启发式算法。

- 我们首先回顾了应对NP完全问题的三种策略。
- 接着,我们运用贪心算法设计范式,提出了一个按“价值重量比”排序的贪心启发式算法。
- 我们发现基础版本存在缺陷,并通过增加一个简单的“与单件最高价值物品比较”的步骤,将其改进为一个具有最坏情况性能保证(1/2*似) 的算法。
- 这个改进的贪心算法不仅原理简单直观,而且运行效率极高(O(n log n)),非常适合作为解决复杂背包问题的快速、可靠的启发式方法。
下一节,我们将深入证明这个1/2*似定理,并探索如何利用动态规划技术设计出精度可调的、更强大的启发式算法。
155:贪心背包启发式算法分析一


在本节课中,我们将要分析针对背包问题的三步贪心启发式算法,并解释为何它在最坏情况下仍具有良好的性能保证。
我们的目标是证明,这个三步贪心算法输出的解,其价值总是至少达到最优解(即满足背包容量限制的最大价值解)价值的一半。
分析的核心思想 🧠
分析的关键思想在于一个思想实验,我们使用一个“作弊”算法作为衡量标准。

回顾一下贪心启发式算法的第一步:我们按照“性价比”(即价值与尺寸的比值 价值/尺寸)的非递增顺序对物品进行排序。
在贪心算法的第二步中,我们按照这个顺序装入物品。可能前 K 个物品(对于某个 K 值)可以完全装入背包,但第 K+1 个物品就装不下了,此时我们停止这一步。
思想实验是想象我们的算法可以作弊,将第 K+1 个物品的一部分装入背包,从而完全填满背包。例如,如果我们装入了前 K 个物品后,背包只剩下 7 个单位的剩余容量,而第 K+1 个物品的尺寸是 10,那么我们设想取该物品的 70% 来填满背包。其价值也按比例计算,即装入 70% 的物品,我们获得其 70% 的价值。

我们将这个作弊算法的输出称为贪心分数解。
举一个非常简单的例子:假设背包容量为 3,有两个物品,尺寸均为 2。一个价值为 3,另一个价值为 2。在贪心分数解中,我们先考虑性价比更高的物品 1(比值为 3/2),它完全装入背包。接着考虑物品 2,它只能装入 50% 到背包中。解的总价值是物品 1 的完整价值 3,加上物品 2 价值的 50%(即 1),所以贪心分数解的价值是 4。
贪心分数解的性质 📊
在接下来的测验中,我们将探讨贪心分数解相对于背包实例的可行解具有什么性质。
正确答案是 D:贪心分数解总是至少和最佳的非分数解(即整数解)一样好,事实上,它可能严格更好。确实,在上一个简单的双物品例子中,贪心分数解严格优于每一个可行的整数解。当然,并非每个实例中它都严格更好,因为有些实例中贪心分数解恰好没有使用分数,只是用 100% 的各种物品完全填满了背包,那么它就不可能比最优的非分数解更好了。
我不会提供非常详细的证明,但会概述论证过程。
断言:对于任何背包实例,贪心分数解的总价值保证至少与每个可行解(即每个非分数解)的总价值一样大。

这个证明非常适合本课程的贪心算法部分。一种正式的证明方法是使用交换论证,我将在此进行高层面的概述,细节留作练*。这非常类似于我们证明“按比率排序能最小化单台机器上一批作业的加权完成时间之和”的贪心算法最优性。

我们如何证明贪心分数解和每个可行解(每个非分数解)一样好呢?固定一个这样的解,称之为 S。这是一个能装入背包的物品子集。
设想一个场景:贪心分数解装入了物品 1,然后物品 2 无法完全装入,但如果我们取物品 2 的 80%,就能完全填满背包。这可能就是贪心分数解。而我们考虑的非分数解 S 可能同样装入了物品 1,但它没有装无法完全放入的物品 2,而是使用了更小的物品 4,并且可能还留有一些未使用的背包容量。
有趣的情况是 S 装入了一些贪心分数解没有装入的物品。假设 S 通过贪心分数解未装入的物品,占用了背包容量中的 L 个单位。
另一方面,贪心分数解完全填满了背包,它使用了所有 W 个单位的空间。因此,如果 S 中有 L 个单位的物品不在贪心分数解中,那么贪心分数解中也必须有至少 L 个单位的物品不在 S 中。


这可能会让人觉得两个解是半斤八两,各装了一些对方没有的东西。但贪心分数解确实更好。为什么?因为根据贪心准则,所有未被贪心分数解装入的物品,其性价比都低于它装入的所有物品。因此,贪心分数解中缺失的这 L 个单位物品,其价值低于它包含的那 L 个单位物品。
由于贪心分数解中包含但 S 中缺失的 L 个单位物品,比 S 中包含但贪心分数解中缺失的 L 个单位物品能更好地利用空间,通过简单的代数运算可以证明,贪心分数解的总价值确实至少等于 S 的总价值。由于 S 是任意选取的,这表明贪心分数解在某种意义上优于最优解——它优于所有非分数的可行解。
思想实验的意义 🎯
此刻可能还不清楚我们为何要进行这个思想实验,它发生在一个允许分数化装入物品的幻想世界中。而我们真正关心的是我们的三步贪心算法,它存在于不能分数化装入物品的现实世界。这个思想实验有什么用呢?正如我们将在下一张幻灯片的分析中看到的,它提供了一个有用的衡量标准,一个我们可以用来比较三步贪心算法性能的假设基准。
本节课中我们一起学*了:分析背包问题三步贪心启发式算法的核心思想,即通过构造一个允许分数化装入的“贪心分数解”作为性能上界。我们了解到,这个贪心分数解的价值总是至少与任何可行的整数解一样好,这为后续证明原始贪心算法的*似比(至少达到最优解价值的一半)奠定了重要基础。下一节我们将利用这个基准来完成正式的性能保证证明。
156:贪心背包启发式算法分析二
📖 概述
在本节中,我们将深入分析一个用于解决背包问题的三步贪心启发式算法。我们将证明该算法在最坏情况下也能保证获得至少50%的最优解价值。同时,我们也将探讨如何通过施加额外假设或设计更优的算法来获得更好的性能保证。


🔍 算法回顾与初步分析
上一节我们介绍了三步贪心算法的框架。本节中我们来看看如何分析其性能保证。

算法的第一步是将物品按单位尺寸价值(价值/尺寸)非递增排序。第二步是贪心地选取能放入背包的最大前缀物品,假设这个最大前缀包含前K个物品。第K+1个物品是第一个无法在已有选择下放入背包的物品。
算法的第三步是考虑两个候选解并选取较优者。第一个候选解是第二步的输出。因此,我们可以肯定地说,三步贪心算法的输出至少与第二步选取的前K个物品的总价值一样好。
第二个候选解是单独考虑价值最大的单个物品。在贪心算法第三步中,被考虑的单个物品就包括那个在第二步中“卡住”我们的第K+1个物品。因此,无论三步贪心算法的输出价值是多少,它都至少等于第K+1个物品的单独价值。

将这两个不等式相加,左边得到的是三步贪心算法输出价值的两倍。右边得到的是前K个物品加上第K+1个物品,即前K+1个物品的总价值。
📐 与分数贪心解建立联系
现在,我们可以将这个不等式与分数贪心解联系起来。分数贪心解会装入前K个物品,然后对于卡住的第K+1个物品,它会用该物品的一个合适分数填满背包剩余容量,其获得的价值也按装入的比例计算。
我们右边绿色部分(前K+1个物品的100%)甚至比分数贪心解更好。分数贪心解包含前K个物品的100%和第K+1个物品的一部分。因此,分数贪心解的价值比我们这里的右边部分更差。


而我们整个思想实验的关键在于论证分数贪心解甚至优于最优解,其价值至少与任何可行背包解一样高。
将不等式两边同时除以二,我们就证明了定理:三步贪心算法的输出保证至少是最优解价值的50%。


这是一个非平凡的、最坏情况下的性能保证,适用于我们简单且速度极快的三步贪心启发式算法。


🤔 追求更好的性能保证
或许你希望获得比最优解的50%更好的性能。我们有以下几种途径可以尝试。


最佳情况是,我们能够仅仅通过改进对这个贪心启发式的分析来提升保证,而不改变算法或做任何新假设。其次,如果我们非常喜欢这个算法(例如因为它速度快),我们可以尝试识别出能让我们证明比这个适用于所有实例的50%保证更好的性能保证的额外实例假设。第三,我们可以尝试设计一个确实具有更好性能保证的更好算法。
然而,第一种最佳情况——仅仅改进对这个贪心算法的分析——是不可行的。分析无法被锐化,因为对于某些实例,50%的界限是可以达到的。因此,我们将处理另外两种方法,并确实在额外实例假设下或使用更复杂的算法获得更好的性能保证。


📉 证明50%界限是紧的
以下是一个例子,表明确实存在背包实例,使得这个三步贪心算法可能只获得接*最优解50%的价值。
在这个例子中,我们设背包容量 W = 1000。有三个物品:
- 物品1:价值
502,尺寸501。 - 物品2和3:价值均为
500,尺寸均为500。
贪心算法会如何操作?第一步,它按价值/尺寸比排序。物品1的比率略大于1,因此会先于物品2或3被考虑。第二步,算法将物品1装入背包,这仅留下499单位的剩余容量,不足以放入物品2或3。因此,第二步的输出就是物品1本身。第三步,算法考虑价值最大的单个物品,这又是物品1。因此,贪心算法将输出仅包含物品1的解,价值为502。
然而,存在一个更好的解:放弃物品1,选择物品2和3。它们都能放入背包并完全填满,总价值为1000,这几乎是贪心解价值的两倍。

这个例子告诉我们,如果我们想要优于50%的性能保证,我们有两个选择。第一,如果我们真的想继续分析我们的三步贪心算法,我们将不得不对实例做出额外假设,以证明优于50%的性能保证。因为存在一些实例,其性能确实差到只有最优解的50%。第二,我们可以研究更好的算法,这些算法在最坏情况实例上可能具有更好的保证。

🚀 在额外假设下获得更好保证
我们可以对背包实例施加几种不同的假设,从而能够为贪心启发式证明更好的性能保证。这里展示一个既简单又在实践中出现的许多背包实例中都满足的条件。

让我们关注那些没有任何物品尺寸特别大(相对于背包容量)的背包实例。具体来说,假设每个物品的尺寸最多是背包容量 W 的10%。
这个假设很有用。考虑我们贪心算法的第二步:我们按单位价值非递增排序并逐个装入物品。在某个时刻,我们会卡住,即存在某个第K+1个物品,如果我们试图放入,会溢出背包容量。根据假设,这个第K+1个物品的尺寸最多是原始背包容量的10%。因此,如果放入它会溢出,意味着当前背包可用容量小于10%,即背包目前已装入的物品已占满90%或更多。
这听起来很不错。我们的贪心准则确保了我们最终使用的背包容量部分是以最有价值的方式使用的,并且我们刚刚注意到,实例的这个假设意味着我们几乎用尽了整个背包。
为了使这个直觉精确化,我们将贪心算法(即使在第二步之后)的输出与我们最喜欢的假设基准——分数贪心解进行比较。这两个解的区别是什么?它们几乎相同。唯一的区别是分数贪心解能够用第K+1个物品的一个合适分数填满背包的最后一点(我们知道最多10%)。因此,在我们的解中缺失的价值最多是分数贪心解最后那10%部分的价值。
在分数贪心解中,物品是按单位价值递减顺序装入的。因此,这最后10%的分数贪心解也是最不重要的部分,它对容量的利用价值最低。所以,这最后最多10%的分数贪心解最多只能占其总价值的10%。因此,我们的贪心算法解中缺失的部分,最多是分数贪心解总价值的10%。也就是说,我们至少获得了分数贪心解价值的90%。
根据我们的思想实验,分数贪心解甚至优于最优解,至少与任何可行背包解一样好。因此,我们的贪心算法(即使不使用第三步)的输出也至少是最优解价值的90%。
这个推理同样适用于10%以外的其他数值。例如,如果你只知道每个物品尺寸最多是背包的20%,那么贪心解将至少是最优解的80%。另一方面,如果你知道每个物品尺寸最多是背包容量的1%,那么仅两步贪心算法就能让你获得至少99%的最优解价值。

📝 总结
本节课中我们一起学*了如何分析三步贪心背包启发式算法的性能。我们证明了该算法在最坏情况下能保证获得至少50%的最优解价值,并通过构造实例表明这个50%的界限是紧的,无法通过单纯改进分析来突破。为了获得更好的性能保证,我们探讨了两种途径:一是对问题实例施加额外假设(如限制单个物品的最大尺寸比例),从而在相同算法下证明更优的*似比;二是设计更复杂的算法(如基于动态规划的启发式算法)。这为我们理解和改进*似算法的性能提供了清晰的思路。
157:背包问题的动态规划启发式算法 🎒

在本节课中,我们将学*如何为NP完全的背包问题设计一个启发式算法。该算法允许用户通过一个误差参数ε来平衡精度与运行时间,最终得到一个保证至少为最优解价值(1-ε)倍的可行解。
上一节我们介绍了背包问题的贪心启发式算法,本节中我们来看看如何利用动态规划实现一个更强大的*似方案。
算法的高层思路 💡
我们无法为NP完全问题设计精确的多项式时间算法,但可以追求次优目标:一个任意接*最优解的*似算法。用户提供一个误差参数ε,算法则返回一个价值至少为最优解(1-ε)倍的解。
听起来可能过于理想,但存在一个关键点:运行时间会随着ε减小而增加。这为用户提供了一个精度与运行时间的权衡旋钮。对于大多数NP完全问题(如顶点覆盖问题),这种任意接*的*似被认为是不可能的,但背包问题是一个幸运的例外。
为了实现这一目标,核心思路是:对原始背包实例进行轻微“按摩”(变换),将其转化为一个我们能在多项式时间内精确求解的特殊情况。然后,求解这个变换后的问题。只要变换没有丢失太多信息,变换后问题的最优解就能很好地*似原始问题的最优解。
可计算处理的特殊情况 🔧
到目前为止,我们已知两种可利用动态规划精确求解的背包问题特殊情况:
- 物品尺寸和背包容量为小整数:运行动态规划算法,时间复杂度为
O(n * W),其中W是背包容量。若W是多项式大小,则算法是多项式时间的。 - 物品价值为小整数:存在另一种动态规划算法,其时间复杂度为
O(n² * V_max),其中V_max是最大物品价值。若所有物品价值都是多项式大小的小整数,则该算法也是多项式时间的。
出于技术原因(稍后会详细说明),第二种特殊情况——物品价值为小整数——更适合用于构建我们的启发式算法。
算法详述:两步走策略 🚶♂️🚶♂️
基于上述思路,我们的算法分为两个清晰的步骤:
第一步:变换实例(舍入物品价值)
我们将每个物品的原始价值 v_i 向下舍入到某个参数 M 的最*倍数。M 的具体值稍后根据ε确定。定义变换后的价值 v̂_i 为:
v̂_i = floor(v_i / M)
其中 floor 是向下取整函数。这样,所有 v̂_i 都变成了(可能较小的)整数。
第二步:求解变换后实例
我们使用上述第二种动态规划算法,精确求解变换后的背包实例。该实例的物品尺寸 w_i 和背包容量 W 均与原始实例相同,只有物品价值被替换为 v̂_i。

关键观察与参数M的作用 ⚖️
以下是关于算法的重要观察:
- 可行性保证:算法输出的解保证是可行的(即能放入背包),因为第二步求解时使用的是原始、准确的尺寸和容量。
- 参数M的双重角色:
- 控制运行时间:动态规划算法的运行时间为
O(n² * max(v̂_i))。由于v̂_i ≈ v_i / M,M越大,v̂_i越小,运行越快。 - 控制精度:
M越大,舍入时丢弃的价值信息(低阶比特)越多,解的精度可能越差。 - 因此,
M就像一个旋钮:调大它,运行更快但精度更低;调小它,精度更高但运行更慢。
- 控制运行时间:动态规划算法的运行时间为
接下来的分析将证明,我们可以为 M 选择一个“甜点”值,使其在保持多项式运行时间的同时,实现由ε指定的高精度。
本节课中我们一起学*了为背包问题设计基于动态规划的*似算法的核心框架。我们了解到,通过巧妙地舍入物品价值,将原始NP难问题转化为一个可在多项式时间内精确求解的“价值为小整数”的特殊情况,再结合对参数 M 的调控,我们有望在精度和效率之间取得完美平衡。下一节我们将深入分析,具体确定 M 与 ε 的关系,并严格证明算法的*似保证。
158:动态规划法再探背包问题 🎒

在本节课中,我们将学*背包问题的第二种动态规划解法。这种解法是构建多项式时间内任意接*最优解的启发式算法的关键组成部分。
上一节我们介绍了基于背包容量的动态规划解法。本节中,我们将转向一种基于物品价值的动态规划解法。

问题回顾
在背包问题中,输入包含 n 个物品。每个物品 i 有一个正的价值 vᵢ 和一个正的尺寸 wᵢ。此外,我们被给定一个背包容量 W。

在本课程之前的动态规划部分,我们已经设计了一种算法。该算法假设物品尺寸和背包容量都是整数。其运行时间为 O(n·W)。这意味着当背包容量 W 不太大(即关于 n 是多项式级别)时,我们得到了一个多项式时间算法。
然而,对于构建在多项式时间内具有任意好*似比的启发式算法来说,基于容量小的特殊情况并不是最佳选择。相反,我们真正需要的是在物品价值较小的特殊情况下的多项式时间解法。这正是本视频要提供的解法。
新算法的核心思路
我们将假设所有物品的价值 vᵢ 都是整数(尺寸 wᵢ 和背包容量 W 可以是任意值)。我们将开发的动态规划解法的运行时间为 O(n²·v_max),其中 v_max 表示任意物品的最大价值。
这两种算法的运行时间具有对称性:
- 第一种算法关注尺寸,运行时间与可能的最大总尺寸(即 W)成正比。
- 第二种算法关注价值,运行时间与可能的最大总价值(即 n·v_max)成正比。
- 在两种情况下,运行时间都是我们需要担心的最大数量乘以物品数量 n。
子问题定义
由于大家已经完成了动态规划的“训练营”,我们将直接切入正题,定义相关的子问题。
这里的子问题将是我们第一个背包动态规划算法的一个变体。相同之处在于,我们仍然有一个索引 i,它指定了在给定子问题中允许使用物品的前缀范围。



不同之处在于第二个参数。在第一个算法中,第二个参数 x 表示剩余的可用容量,我们在此约束下最大化价值。现在,我们将它反过来:第二个参数 x 表示我们力求达到的价值目标。我们希望在达到至少价值 x 的前提下,最小化所需使用的总尺寸。

以下是子问题的形式化定义:
- 索引 i:范围从 0 到 n,对应我们可能感兴趣的所有物品前缀。
- 参数 x:范围是所有可能感兴趣的总价值目标。由于物品价值是整数,任何物品子集的总价值也是整数,因此我们只需考虑 x 的整数值。此外,我们永远不需要担心尝试实现大于 n·v_max(或所有 vᵢ 的总和)的总价值。
- 子问题值 S(i, x):定义为仅使用前 i 个物品,达到总价值至少为 x 时,所需的最小总尺寸。如果使用前 i 个物品根本无法达到价值 x(例如,它们的总价值已经小于 x),则将其定义为 +∞。
用公式表示:
S(i, x) = min{ total size of a subset of first i items with total value ≥ x }
S(i, x) = +∞,如果这样的子集不存在。


递推关系

现在让我们写下自然的递推关系。假设 i ≥ 1。
其结构与第一个背包动态规划算法完全相同。我们聚焦于一个子问题 (i, x)。对于最后一个物品 i,它要么在最优解中,要么不在。这给了我们两种情况,递推式将对这两种情况进行暴力搜索。由于我们试图最小化达到给定价值目标所需的总尺寸,这里的暴力搜索将采取取最小值的形式。
以下是两种候选的最优解:

- 不使用物品 i:那么最优解直接继承自仅使用前 i-1 个物品达到相同价值目标 x 的最小尺寸解。即:S(i-1, x)。
- 使用物品 i:这为最优解的重量贡献了 wᵢ。在此最优解中,除物品 i 外的其余物品,其自身必须达到价值目标 x - vᵢ。根据常见的“剪切-粘贴”论证,在所有具有此性质的前 i-1 个物品的子集中,它们必须具有最小的总重量。即:wᵢ + S(i-1, x - vᵢ)。
边界情况:如果 vᵢ 实际上大于 x,那么 x - vᵢ 为负数。我们只需将 S(i-1, x - vᵢ) 解释为 0,因为仅物品 i 自身就满足了价值目标 x。
因此,完整的递推关系为:
S(i, x) = min{ S(i-1, x), wᵢ + S(i-1, x - vᵢ) }
其中,当 x - vᵢ < 0 时,定义 S(i-1, x - vᵢ) = 0。
算法伪代码
以下是新动态规划算法的伪代码。
# 初始化
Let V_total = n * v_max # 或所有 v_i 的总和
Initialize a 2D array A[0..n][0..V_total] to +∞
# 基础情况:i = 0 (不允许使用任何物品)
A[0][0] = 0
# 对于 x > 0,A[0][x] 保持为 +∞ (无法达到任何正价值)
# 填充表格
for i from 1 to n:
for x from 0 to V_total:
# 情况1:不使用物品 i
candidate1 = A[i-1][x]
# 情况2:使用物品 i
if x - v[i] <= 0:
candidate2 = w[i] # 仅物品 i 就足够了
else:
candidate2 = w[i] + A[i-1][x - v[i]]
A[i][x] = min(candidate1, candidate2)
# 寻找最优解
optimal_value = 0
for x from V_total down to 0:
if A[n][x] <= W: # 存在总尺寸不超过背包容量的方案达到价值 x
optimal_value = x
break
return optimal_value
算法解析与运行时间
在第一个背包动态规划算法中,有一个子问题 (n, W) 直接对应原始问题(使用所有物品,在容量 W 内最大化价值)。因此,填完表后可以直接在常数时间内得到答案。
然而,在这个新的动态规划算法中,没有一个子问题直接对应我们想解决的原始问题(在容量 W 内最大化价值)。它们告诉我们的是:对于每个目标价值 x,达到该价值所需的最小尺寸是多少。
那么,如何找到原始问题的最优解呢?方法如下:
- 算法运行完毕后,我们查看最后一批子问题,即 i = n 对应的行。
- 我们从最高的可能目标价值 x(例如 V_total)开始,向下扫描。
- 我们寻找第一个(即最大的)目标价值 x,使得 A[n][x] ≤ W。这意味着存在一个物品子集,其总价值至少为 x,且总尺寸不超过背包容量 W。
- 这个 x 就是原始背包问题的最优解值。
运行时间分析:
- 子问题数量:外层循环 i 从 1 到 n,内层循环 x 从 0 到 n·v_max。所以总子问题数为 O(n²·v_max)。
- 每个子问题的工作量:递推关系只考虑两个候选值,执行常数时间操作。
- 最终扫描:扫描最后一行需要 O(n·v_max) 时间。

因此,总运行时间由每个子问题的常数工作主导,为 O(n²·v_max),正如之前所承诺的。
总结
本节课中我们一起学*了背包问题的第二种动态规划解法。与第一种关注背包容量的方法不同,这种方法关注物品的价值。我们定义了子问题 S(i, x) 为使用前 i 个物品达到至少价值 x 的最小尺寸,并建立了相应的递推关系。通过填充一个规模为 O(n²·v_max) 的动态规划表格,并在最后进行扫描,我们可以在物品价值为整数且最大价值 v_max 不太大的情况下,高效地求解背包问题。这种解法是构建更高级*似算法的重要基石。
159:动态规划启发式算法分析 🧮

在本节课中,我们将分析基于动态规划的背包问题启发式算法。我们将精确理解该算法的运行时间如何依赖于期望的精度参数 ε。

算法回顾
我们的启发式算法包含两个主要步骤。
以下是算法的两个步骤:
- 调整物品价值:我们将物品价值进行舍入,使其变为相对较小的整数。具体来说,对于每个物品 i,我们定义其调整后的价值
V_hat_i为原始价值V_i除以参数 M 后向下取整的结果。公式表示为:V_hat_i = floor(V_i / M) - 调用动态规划算法:我们调用之前为背包问题设计的第二个动态规划解决方案,并将这些新的
V_hat值、原始物品大小以及原始背包容量作为输入。


这个算法有一个未确定的参数 M。我们至少定性地理解 M 的作用:它影响着算法在精度和运行时间之间的权衡。


上一节我们介绍了算法的两个步骤,本节中我们来看看参数 M 如何影响算法的表现。
具体来说:
- M 越大:我们对原始价值
V_i的舍入操作丢失的信息就越多,这会导致精度降低。 - M 越大:转换后的物品价值
V_hat_i就越小。由于动态规划算法的运行时间与最大物品价值成正比,因此运行时间更快。

总结:M 越大,精度越低,但算法运行越快。

分析计划 📊
我们的分析计划如下。
首先,我们将研究精度约束。客户会给我们一个正参数 ε,我们的责任是输出一个可行解,其总价值至少是最优解总价值的 (1 - ε) 倍。这个精度约束将转化为我们能使用的 M 的上限。M 越大,精度损失越大。因此,我们要解决的第一个问题是:在保证计算出的解在最优解的 (1 - ε) 倍以内的前提下,M 最大可以取多大?
一旦我们解决了这个问题,知道了 M 的最大允许值,我们就会评估在此 M 值下算法的运行时间。
精度分析:M 的允许上限

要回答第一个问题,即在不违反 (1 - ε) 精度约束的前提下,M 最大能取多大,关键在于详细理解舍入后的物品价值 V_hat_i 与原始价值 V_i 之间的关系。
我们从舍入操作的定义中可以得出两个基本不等式。

以下是两个基本不等式:
V_i >= M * V_hat_iM * V_hat_i >= V_i - M

这两个不等式直接来自舍入操作的定义,内容相对简单。但算法的第二步为我们提供了一个更强大的第三个不等式。
第三步,我们针对调整后的价值 V_hat_i 最优地解决了一个背包问题实例。这意味着,如果使用 V_hat_i 来衡量价值,我们的启发式算法输出的解 S 比任何其他解都要好,包括针对原始问题(使用原始价值 V_i)的最优解 S*。
因此,我们得到第三个不等式:
3. sum_{i in S} V_hat_i >= sum_{i in S*} V_hat_i
这三个不等式是我们分析的基础。幸运的是,它们足以帮助我们确定在保证 (1 - ε) 精度的前提下,M 的最大允许值。
为了看清这一点,让我们将这些不等式串联起来。第三个不等式内容最丰富,它表明我们针对转换后的价值 V_hat 最优地解决了问题。我们从它开始。
不等式 3 说,我们启发式解 S 中 V_hat 的总和,至少与任何其他解(特别是原始最优解 S*)中 V_hat 的总和一样大。这很好,但问题在于它用错误的数据(转换后的 V_hat)来证明解 S 的性能,而我们真正关心的是原始价值 V。
因此,我们需要以不等式 3 为种子,通过不等式 1 和 2,将不等式左右两边的 V_hat 用原始价值 V 重写,从而生长出一条不等式链。在向左扩展时,我们希望量值变得越来越大;在向右扩展时,我们希望量值变得越来越小。最终,我们希望得到一个关于启发式解 S 的*似最优性结果。

首先,为了便于使用不等式 1 和 2,我们将不等式 3 两边同时乘以 M(这仍然是有效的):
M * sum_{i in S} V_hat_i >= M * sum_{i in S*} V_hat_i

现在,我们来扩展这个不等式链。



扩展左侧:我们需要附加一个只会更大的量,即用原始价值 V_i 来上界 M * V_hat_i。不等式 1 正好说明 M * V_hat_i 永远不会大于原始价值 V_i。因此,我们对解 S 中的每个物品应用不等式 1,得到:
sum_{i in S} V_i >= M * sum_{i in S} V_hat_i


扩展右侧:我们需要一个只会更小的量,即用原始价值 V_i 的某个函数来下界 M * V_hat_i。V_i 可能大于 M * V_hat_i(因为我们向下舍入了),但不等式 2 说明它最多只能大 M。所以,从 V_i 中减去 M,就得到了 M * V_hat_i 的下界。我们对最优解 S* 中的每个物品应用这个关系,得到:
M * sum_{i in S*} V_hat_i >= sum_{i in S*} (V_i - M) = (sum_{i in S*} V_i) - (M * |S*|)

现在,将这三个部分串联起来,我们得到:
sum_{i in S} V_i >= M * sum_{i in S} V_hat_i >= M * sum_{i in S*} V_hat_i >= (sum_{i in S*} V_i) - (M * |S*|)

由于最优解 S* 最多包含 n 个物品,我们可以粗略地将其下界为 (sum_{i in S*} V_i) - (M * n)。


因此,最终的不等式链简化为:
sum_{i in S} V_i >= (sum_{i in S*} V_i) - (M * n)
现在,尘埃落定,我们得到了解 S 相对于最优解 S* 的性能保证。基本上,我们与最优解的差距就是这个误差项 M * n。正如我们所料,M 越大,我们偏离最优解的程度就越大(误差随 M 线性增长)。

我们努力要回答的问题是:在保证解在最优解的 (1 - ε) 倍以内的前提下,M 最大能取多大?
为了实现 (1 - ε) 的*似比,我们需要确保解的最坏情况误差 M * n 不超过最优解价值的 ε 倍。也就是说,我们需要将 M 设置得足够小,使得 M * n <= ε * (sum_{i in S*} V_i)。


这个不等式告诉我们如何设置算法中的参数 M。但你会正确地指出,右边有一个我们实际上不知道的量——最优解 S* 的价值,而这正是我们最初试图*似计算的东西。
因此,我们将使用最优解价值的一个粗略下界。我们做一个平凡的假设:假设每个物品的大小都不超过背包容量(即每个物品都能单独放入背包)。任何不满足此条件的物品显然可以在预处理步骤中删除。那么,最优解的价值至少与最大价值物品的价值一样大。记 V_max = max_i V_i,则有 sum_{i in S*} V_i >= V_max。

为了满足所需的精度约束,我们只需要将 M 设置得足够小,使得 M * n <= ε * V_max 即可。当然,将 M 设置得更小(使得 M * n 不大于更小的数)也是足够的。
因此,在我们的算法中,我们只需将 M 设置为使 M * n 等于 ε * V_max 的那个数。即:
M = (ε * V_max) / n
请注意,这些都是算法已知的参数:物品数量 n、客户提供的参数 ε,并且很容易计算最大物品价值 V_max。因此,算法可以据此设置 M。

至此,我们完成了对第一个问题的回答:在 (1 - ε) 的精度约束下,我们可以使用的 M 最大为 (ε * V_max) / n。
运行时间分析 ⏱️
现在,我希望大家都屏住呼吸,因为记住,M 不仅控制精度,还控制运行时间。我们对数字的缩放越激进(M 越大),算法就越快。现在的问题是:我们是否被允许取足够大的 M,使得最终的启发式算法运行时间是多项式时间的?
启发式算法的运行时间主要由第二步决定,即调用动态规划子程序。该子程序的运行时间是 n^2 * (传递给它的最大物品价值),也就是最大的缩放价值 max_i V_hat_i。
那么,关键问题是:这些缩放后的物品价值 V_hat_i 最大能有多大?

对于任意物品 i,V_hat_i 是通过取原始价值 V_i,向下舍入,然后除以 M 得到的。因此,V_hat_i 最大可能为 V_i / M,这显然至多是 V_max / M。
现在,代入我们对 M 的选择 M = (ε * V_max) / n。令人高兴的是,两个 V_max 相互抵消,只剩下 n / ε。

因此,每个传递给第二步动态规划子程序的转换价值的上界是 n / ε。代入运行时间公式,我们得到总运行时间为 n^2 * (n / ε) = n^3 / ε。


运行时间确实会随着 ε 的减小而增加(ε 越小,运行时间越长)。当然,对于任何 NP 完全问题,你都会预期到这一点。当 ε 越来越小时,你需要取更小的 M 来保证精度,这导致对物品价值的缩放不那么激进,从而传递给动态规划子程序的转换价值更大,运行时间也就更长。

尽管如此,这个分析表明,对于背包问题,完整的运行时间-精度权衡谱系是可能的。如果你想要任意接*的*似解,你都可以通过此算法得到。
总结 📝
本节课中,我们一起学*了如何分析基于动态规划的背包问题启发式算法。
- 我们首先回顾了算法的两个步骤:调整(舍入)物品价值和调用动态规划子程序。
- 我们分析了参数 M 在精度与运行时间权衡中的作用。
- 通过建立并串联三个关键不等式,我们推导出在保证 (1 - ε) *似精度的前提下,M 的最大允许值为
(ε * V_max) / n。 - 最后,我们评估了在此 M 值下算法的运行时间,证明其为
O(n^3 / ε),这是一个多项式时间算法,并且可以通过调整 ε 来获得任意精度的*似解。

这个分析展示了如何通过巧妙的舍入和动态规划,为 NP 难的背包问题设计出有效的*似方案。
160:最大割问题与局部搜索算法 🧩
在本节课中,我们将学*如何利用局部搜索算法来*似求解最大割问题。最大割问题是NP完全问题,这意味着在一般情况下,我们无法在多项式时间内找到最优解。然而,通过局部搜索,我们可以高效地找到一个质量不错的*似解。
最大割问题定义
上一节我们介绍了课程背景,本节中我们来看看最大割问题的具体定义。
给定一个无向图 G,算法的任务是输出一个割,即将顶点划分为两个非空集合 A 和 B,使得跨越两个集合的边数最大化。用公式表示,即最大化 |E(A,B)|,其中 E(A,B) 是连接 A 和 B 的边的集合。
与我们在课程第一部分学*过的最小割问题不同,最大割问题在计算上是难处理的,它是NP完全的。更准确地说,其判定问题版本(即判断是否存在一个割能切割至少特定数量的边)是NP完全的。除非P等于NP,否则不存在多项式时间算法。
然而,对于某些特殊情况,如二分图,最大割问题可以在线性时间内解决。例如,使用广度优先搜索(BFS)可以高效地找到二分图的最大割。

局部搜索算法概述
上一节我们定义了最大割问题,本节中我们来看看如何使用局部搜索算法来*似求解它。

局部搜索是一种通用的启发式算法设计范式。其核心思想是:我们始终维护一个候选解(即一个割),并通过局部修改(即小的调整)来迭代地改进它。
以下是局部搜索算法求解最大割问题的步骤:
- 初始化:从图的一个任意割开始,将顶点任意分配到集合 A 和 B 中。
- 迭代改进:检查每个顶点 v。对于顶点 v,定义:
- C_v(A,B):与 v 关联且跨越当前割的边的数量。
- D_v(A,B):与 v 关联且不跨越当前割的边的数量。
- 顶点切换:如果存在一个顶点 v,使得 D_v(A,B) > C_v(A,B),那么将 v 从一个集合移动到另一个集合(例如,从 A 移到 B)将会增加跨越割的边数。净增量为 D_v(A,B) - C_v(A,B)。
- 终止条件:重复步骤2和3,直到对于所有顶点 v,都有 D_v(A,B) ≤ C_v(A,B)。此时,算法停止并返回当前割。
算法性能分析
上一节我们介绍了算法的步骤,本节中我们来看看它的性能如何。
我们通常从两个维度评估算法:解的质量和所需的计算资源。
运行时间分析
该算法保证在多项式时间内终止。原因如下:
- 每次执行
while循环迭代(即切换一个顶点),跨越割的边数至少增加1。 - 在一个简单图中,边的总数最多为 O(n²),其中 n 是顶点数。
- 因此,
while循环最多执行 O(n²) 次。每次迭代可以高效实现,故总运行时间为多项式时间。
解的质量保证
虽然该局部搜索算法不能保证找到最大割(鉴于问题是NP完全的,这不可能),但它提供了一个有理论保证的*似解。
具体来说,该算法返回的割所包含的跨越边数,至少是所有边数的一半。由于最大割的边数不可能超过总边数,这也意味着该解至少是最优解的50%。

证明思路:当算法终止时,对于每个顶点 v,有 D_v ≤ C_v。将所有顶点的这两个量分别求和,并利用每条边根据其端点被计算的方式,可以推导出跨越边的总数至少是总边数的一半。


总结

本节课中我们一起学*了如何应用局部搜索算法来*似求解NP完全的最大割问题。我们首先明确了问题的定义,然后逐步讲解了算法的流程:从任意割开始,通过反复将能够增加割边数的顶点切换到对面集合来改进解,直到无法改进为止。我们分析了该算法能在多项式时间内运行,并且其输出的解至少包含图中总边数一半的跨越边,即具有50%的*似保证。这展示了局部搜索作为解决难处理优化问题的一种实用且有效的启发式方法。
161:最大割问题(第二部分)🔍
在本节课中,我们将深入探讨最大割问题的局部搜索算法。我们将分析该算法的性能保证,并将其与简单的随机算法进行比较。同时,我们也会讨论该算法在加权图版本中的表现。
算法性能保证的紧致性
上一节我们介绍了局部搜索算法。本节中我们来看看其性能保证的紧致性。
定理中关于该算法的分析是紧致的。在不额外假设输入的情况下,50%的性能保证无法被改进。
以下是一个例子,它本身是一个二分图,因此是最大割问题的一个易处理的特殊情况。但即使在二分图上,局部搜索算法也可能返回一个割,其边数仅为全局最优最大割的50%。

例子如图所示,有四个顶点和四条边。它是一个二分图,因此最佳割是将U和W放在一组,V和X放在另一组,这样可以切割所有四条边。
另一方面,局部搜索算法的一个可能输出是割:U和V在组A,W和X在组B。这个割只有两条交叉边,仅为最大可能值的50%。然而,它是局部最优的。如果你将四个顶点中的任何一个切换到另一组,你会得到一个割,其中一边有三个顶点,另一边只有一个顶点。由于图中每个顶点的度数为2,所有这些割也都只有两条交叉边。
与随机算法的比较
第二个需要注意的点是,对于最大割问题,50%的性能保证可能并不令人特别印象深刻。
事实上,即使我仅仅随机均匀地选择一个割,即对于每个顶点,我独立地抛一枚公平的硬币。如果硬币正面朝上,我将该顶点放入A组;如果反面朝上,则放入B组。
以这种方式选择的随机割,其交叉边的期望数量已经是图中总边数的50%。
以下是这个事实的一个简要证明。你们中的一些人可能记得第一部分中,我介绍了分析复杂随机变量期望值的分解原理。你有一个关心的复杂随机变量,你将其表示为指示随机变量(只取0和1值的随机变量)的和,然后应用期望的线性性质。这将计算复杂期望的问题简化为简单期望的和。事实证明,分解原理可以完美地证明这个事实。
我们关心的复杂随机变量是随机割的交叉边数量。构成它的0-1指示随机变量就是给定边是否穿过随机割。即,对于输入图G的一条边E,我定义随机变量 X_E 为:如果它的两个端点最终在不同组中则为1,如果在同一组中则为0。
那么,这些指示随机变量 X_E 的期望值是多少?对于任何指示随机变量,它等于 X_E 等于1的概率,即边E被随机割切割的概率。假设边E的端点是U和V。有四种可能性:U和V都在A组;U和V都在B组;U在A组,V在B组;U在B组,V在A组。这四种结果的可能性相同,概率各为四分之一。在前两种情况下,这条边不被随机割切割(两个端点在同一边),在后两种情况下,它被切割(端点在两边)。因此,在随机割中这条边被切割的概率是二分之一,所以 X_E 的期望值是二分之一。
现在,我们只需应用期望的线性性质。我们关心的是随机割交叉边的期望数量。根据定义,交叉边的数量就是所有边E的 X_E 之和。所以,我们关心的随机变量(交叉边数量)的期望值,根据期望的线性性质,就是这些指示随机变量 X_E 的期望值之和。每个 X_E 的期望值是二分之一,对每条边求和,总和就是边数除以二,正如所述。
再次感谢您容忍我的小插曲。其要点是,仅仅取一个随机割就能为最大割问题提供50%的性能保证。但是,为局部搜索算法辩护(它也仅获得50%的性能保证),需要指出的是,要获得比50%更好的性能保证,需要花费很长时间,并且实际上需要付出相当大的努力,才能在多项式时间内为最大割问题找到这样的算法。最著名的此类算法是由Goemans和Williamson在1994年提出的,它需要使用一种称为半定规划的工具,这甚至比线性规划更强大。
局部搜索性能保证的证明

现在让我们证明,局部搜索算法保证输出一个割,其交叉边数量至少是图中总边数的一半。

选择一个你喜欢的局部最优割(A, B)。这里的局部最优意味着算法可能返回的割,即不可能通过将单个顶点从一侧交换到另一侧来改进割的值。

由于是局部最优,对于这个割(A, B)和V中的每个顶点,与该顶点关联且穿过割的边的数量必须至少与和该顶点关联但不穿过割的边的数量一样大。用之前的符号表示,即 C_v ≥ D_v。否则,交换顶点v将给我们一个更好的割。

因此,我们有n个这样的不等式,每个顶点一个。我们可以合法地将这n个不等式求和,合并为一个。
现在,让我们首先关注求和不等式的右侧,即图中所有顶点关联的、穿过割的边的数量之和。
关键点在于:右侧的这个和计算的是什么?它计算的是穿过割(A, B)的每条边恰好两次。考虑一条边,比如(u, w),它穿过割(A, B)。它在右侧被计算两次:一次当v = u时,一次当v = w时。

我们可以将完全相同的推理应用于左侧。这个和计算的是什么?它计算的是每条非交叉边恰好两次。再次考虑一条边,比如(u, x),它的两个端点都在同一边。这个和会在v = u时计算它一次,然后在v = x时再计算一次。
现在我们想将这个局部最优割的交叉边数量与总边数进行比较。在左侧,我们缺少交叉边。为了将其补全为所有边,我们只需将这个不等式的两边都加上两倍的交叉边数量。

在左侧,我们得到所有边数量的两倍。现在在右侧,我们有四倍的交叉边数量。
将不等式两边同时除以4,我们证明了定理:穿过(A, B)的边的数量确实是图中总边数的50%或更多。

扩展到加权最大割问题

简要讨论一下我们为最大割问题的局部搜索得出的结论如何扩展到最大割问题的一个自然加权版本,是很有趣的。
那么,关于未加权特殊情况的哪些事实可以扩展到加权情况,哪些事实不能呢?

首先,对于加权最大割问题,采用局部搜索方法仍然完全合理。现在,对于给定割中的每个顶点,你只需查看关联的、穿过割的边的总权重,以及关联的、不穿过割的边的总权重。每当不穿过割的边的权重大于穿过割的边的权重时,这就是一个通过将顶点移动到对侧来改进当前割的机会。
一个很酷的事情是,我们刚刚建立的性能保证(局部搜索输出的50%)可以延续到加权情况,证明基本上保持不变。我将留给你们在私下里验证这一点。
同样,在加权情况下,随机割仍然能获得图中总权重的50%,所以也许这个性能保证确实没什么值得大书特书的。
失效的是运行时间分析。还记得在未加权情况下是如何进行的吗?我们论证了任何割最多可能有 C(n, 2) 条交叉边,并且由于局部搜索的每次迭代都会增加交叉边的数量,它必须在二次方次数的迭代内停止。这意味着很容易在多项式时间内实现该算法。
思考未加权最大割问题特殊之处的一种方式是:尽管图有指数级数量的不同割,但所有这些指数级多的割只取多项式数量的不同目标函数值。交叉边的数量在0到 C(n, 2) 之间。相比之下,一旦边可以有任何旧的权重,现在你可能有指数级数量的割,具有指数级数量的不同目标函数值(即总权重的不同值)。这意味着,仅仅因为你在每次迭代中严格提高了穿过割的总权重,并不意味着你会在多项式次数的迭代内收敛。事实上,证明存在加权最大割的实例,其中局部搜索在收敛前需要指数级次数的迭代,是一项非常不平凡的任务。
总结

本节课中我们一起学*了最大割问题局部搜索算法的性能分析。我们证明了该算法保证能找到至少包含图中50%边数的割,并指出这个保证是紧致的,无法在不增加假设的情况下改进。我们还将该算法与简单的随机割算法进行了比较,发现随机算法也能达到相同的性能保证。最后,我们探讨了该算法在加权图版本中的适用性,指出性能保证可以延续,但运行时间分析在加权情况下不再成立,算法可能需要指数时间才能收敛。
162:局部搜索原理一
概述
在本节课中,我们将学*局部搜索技术的一般原理。我们将从抽象层面理解局部搜索如何工作,探讨其核心组成部分,并分析在设计局部搜索算法时需要做出的关键决策。
候选解空间与邻域定义

上一节我们通过最大割问题的具体例子了解了局部搜索。本节中,我们将从更一般的角度来讨论这项技术。
让我们抽象地思考一个计算问题。假设存在一个候选解集合 X。你可能想搜索是否存在具有特定性质的解,或者想找到在某种意义下最优的解。
例子:
- 在我们上一节的视频中,X 是某个给定图 G 的所有割。
- 其他例子包括旅行商问题实例的所有环游,或者某个约束满足问题中变量的所有可能赋值。
局部搜索方法的一个基本要素是邻域的定义。也就是说,对于每个候选解 x(属于所有可能解的空间 X),你需要定义哪些其他解 y 是 x 的邻居。
以下是几个具体例子:
- 最大割问题:在上一节中,我们隐含地将一个给定割的邻居定义为,通过将单个顶点从一侧移动到另一侧所能得到的所有割。
- 约束满足问题:对于像 2-SAT 或 3-SAT 这样的问题,一种常见方法是定义两个变量赋值为邻居,当且仅当它们仅在一个变量的取值上不同。对于布尔变量(取值为真或假),这意味着通过翻转单个变量的值,可以从一个赋值得到另一个赋值。
- 旅行商问题:有很多合理的方式来定义邻域。一种简单且流行的方法是定义两个 TSP 环游为邻居,当且仅当它们在尽可能少的边数上不同。思考一下你会发现,两个 TSP 环游可能不同的最小边数是 2。例如,第一个环游可能包含边 (U, V) 和 (W, X),而它的邻居环游则可能包含交叉边 (U, X) 和 (V, W),两个环游的所有其他边都相同。

局部搜索通用流程
一旦你确定了计算问题的候选解空间,并为每个候选解确定了邻域,你就可以运行局部搜索了。局部搜索会迭代地改进当前解,总是移动到相邻的更好解,直到无法进一步改进为止。
我将在这里指定一个高度未确定的局部搜索版本,以强调在实际项目中应用局部搜索时需要做出的众多设计决策。我们将在接下来的几张幻灯片中讨论不同设计决策的可能实例化。
通用局部搜索流程:
- 初始化:从某个候选解 x 开始搜索。
- 迭代改进:
- 检查当前解 x 的所有邻居 y。
- 如果找到一个更优的邻居 y,则将当前解更新为 y。
- 重复此过程。
- 终止:如果当前解 x 的所有邻居都不比它更优,则 x 是局部最优解,算法终止并返回 x。
我们上一节讨论的最大割局部搜索算法,正是这个通用流程的一个实例化。其中,所有解的集合 X 就是给定图的所有割,并且定义两个割为邻居,当且仅当可以通过将单个顶点从一组移动到另一组来从一个割得到另一个割。
在那个算法中,我们从某个任意割开始,反复搜索更优的相邻解(即尝试查看是否有办法通过移动一个顶点来获得更好的割)。如果找到这样的更优邻居,我们就从那个更好的解开始迭代。当我们无法再迭代时(即没有更好的相邻割),我们就停止并返回最终结果。
你可以将这种通用的局部搜索过程以完全相同的方式应用于其他问题。例如,考虑旅行商问题。假设我们像上一张幻灯片那样定义邻域:两个环游是邻居当且仅当它们恰好有两条边不同,而其他 n-2 条边相同。你会怎么做?你从某个任意的 TSP 环游开始,然后迭代:不断寻找更优的相邻解。也就是说,从当前环游出发,考虑所有通过恰好改变当前环游的两条边所能到达的环游,检查其中是否有任何环游的成本严格更小。如果有,就从那个新的更优解开始迭代。当你无法再迭代时(即所有相邻解的成本都至少与你当前正在处理的解一样高),你就得到了一个局部最优环游,并将其作为最终输出返回。

局部搜索的设计决策与性能考量
在本视频的剩余部分,我将继续在高层面上讨论局部搜索,谈谈你需要做出的一些设计决策,以及可以预期的一些性能特征。在我们完成这个高层讨论之后,我们将继续学*局部搜索的另一个具体例子,特别是针对约束满足问题 2-SAT 的例子。

让我们从关于刚才展示的通用局部搜索流程中三个未确定特性的常见问题开始。
问题一:如何选择初始起点 x?
要回答这个问题,让我粗略地将你可能使用局部搜索的情况分为两类。
第一类情况:你确实依赖局部搜索来为优化问题找到一个良好的*似解。除了通过某种局部搜索方法外,你完全不知道如何接*最优解。
第二类情况:你已经有了一些相当好的启发式方法,似乎能为你的优化问题提供相当不错的解,你只想将局部搜索用作后处理步骤,以做得更好。
我们先从第一类情况开始,即你把所有希望都寄托在局部搜索上,需要它为你提供一个相当好的优化问题解。这是一个棘手的情况。局部搜索保证会给你一个局部最优解,但在许多问题中,局部最优解可能比全局最优解差很多。在最大割问题的特殊情况下,我们有一个 50% 的性能保证,这已经只是一个一般的保证。但对于大多数优化问题,即使是那种性能保证也是不可能的,存在比全局最优解差得多的局部最优解。另一方面,你知道存在一些好的局部最优解,特别是全局最优解本身也是一个局部最优解。
现在,如果你只运行一次局部搜索,很难评估返回给你的解的质量。你运行算法,它给你一个解,成本是 79217。这算好还是坏?谁知道呢?
一个明显的改进方法是多次运行局部搜索,比如数千次甚至数百万次,然后在你的局部搜索算法不同运行返回的所有局部最优解中,选择最好的一个作为最终解。
为了鼓励你的局部搜索算法在反复运行时返回不同的局部最优解,你需要在某些地方做出随机决策。一个极其常见的引入随机决策的点是在局部搜索的步骤 1,即选择初始起点 x。
例子:
- 在最大割问题中,你可能想从一个随机割开始,每个顶点以相等概率被分配到 A 或 B。
- 在旅行商问题中,你可能想从一个随机环游开始,即顶点的一个随机排列。
- 在约束满足问题中,你可以通过独立地给每个变量赋予随机值来开始。
如果这看起来有点像向飞镖盘扔飞镖,那确实如此。事实证明,对于许多极其困难的问题,最先进的技术并不比用不同的随机初始位置运行局部搜索的独立试验,并返回你找到的最佳局部最优解要好多少。
现在,假设你处于第二类更愉快的情况,你对优化问题有更好的把握。也许你已经知道如何使用贪心算法或数学规划,总之,你有一些技术可以为某些优化问题生成接*最优的解。但是,为什么不把这些接*最优的解变得更好呢?你该怎么做?将你当前启发式方法套件的输出,输入到一个局部搜索后处理步骤中。毕竟,局部搜索只会移动到更好的解,它只能让你已经相当好的解变得更好。

总结
本节课中,我们一起学*了局部搜索的一般原理。我们定义了候选解空间和邻域这两个核心概念,并概述了局部搜索的通用迭代流程:从初始解开始,不断移动到更优的邻居解,直到达到局部最优。我们还探讨了关键的设计决策,特别是如何选择初始解,并分析了在依赖局部搜索作为主要求解方法或作为后处理优化工具两种不同场景下的策略。理解这些基本原理,为我们接下来将局部搜索应用于具体问题(如 2-SAT)奠定了基础。
163:局部搜索原理二
在本节课中,我们将继续探讨通用局部搜索算法中尚未明确的几个关键设计决策。我们将重点关注当存在多个更优的相邻解时如何选择、如何定义邻域结构,以及算法的性能保证问题。
上一节我们介绍了局部搜索算法的基本框架,本节中我们来看看算法中其他几个需要明确的关键点。
如何选择相邻解
while 循环的条件是“如果存在一个更优的相邻解 Y,则将当前解重置为 Y”。然而,通常可能存在多个合法的 Y 可供选择。例如,在最大割问题中,对于一个给定的割,可能有多个顶点,将其切换到另一组后能产生严格更多的交叉边。
当存在多个更优的相邻解时,应该选择哪一个?这是一个棘手的问题,现有理论并未给出明确的答案。正确的答案似乎高度依赖于具体问题领域。要回答这个问题,很可能需要你对自己感兴趣的数据进行大量的实验。
一个通用的观点是:当你使用局部搜索从头开始生成优化问题的*似解时,你希望向算法中注入随机性,以引导它探索解空间,并在多次独立运行中返回尽可能多的不同的局部最优解。你会记住这些局部最优解中最好的一个,并最终返回它。
因此,除了起始点之外,这是第二个可以向局部搜索注入随机性的机会。如果你有多个可能的改进解 Y,可以随机选择一个。
或者,你也可以尝试更聪明地选择 Y。例如,如果存在多个更优的相邻解 Y,你可以选择最好的那个。在最大割问题中,如果有很多不同的顶点,切换其组别都能产生更优的割,那么就选择那个能使交叉边数量增加最多的顶点。在旅行商问题中,在所有成本更低的相邻旅行路线中,选择总成本最低的那条。
这是一个非常合理的规则,用于决定如何选择 Y。它是短视的,你可以想象用更复杂的方法来决定下一步该选择哪个 Y。事实上,如果这是你真正关心的问题,你可能需要努力找出在你的应用中效果良好的、用于选择 Y 的启发式方法。
如何定义邻域
为了得到一个精确定义的局部搜索算法,你必须回答的第三个问题是:邻域是什么?对于许多问题,在如何定义邻域方面有很大的灵活性,理论同样没有给出关于应如何设计邻域的明确答案。这似乎又是一个高度依赖于领域的问题。如果你想在感兴趣的问题上使用局部搜索,很可能需要你通过实验来探索哪些邻域选择似乎能带来局部搜索算法的最佳性能。
你可能需要解决的一个问题是:确定你的邻域应该有多大。为了说明这一点,让我们回到最大割问题。在那里,我们将一个割的邻居定义为:通过取一个顶点并将其移动到另一组所能到达的其他割。这意味着每个割有 O(n) 个相邻的割。
但是,如果我们愿意,很容易扩大邻域。例如,我们可以将一个割的邻居定义为:通过取两个或更少的顶点并将它们切换到相反组所能到达的那些割。现在每个割将有 O(n^2) 个邻居。更一般地,我们当然可以允许一次局部移动在两侧之间交换 k 个顶点,那么邻域大小将是 O(n^k)。
以下是扩大邻域大小的优缺点:
- 缺点:一般来说,邻域越大,你在每一步中寻找改进解所需投入的时间就越多。例如,在最大割问题中,如果我们只允许在每次迭代中交换一个顶点,那么我们只需要搜索线性数量的选项来判断是否存在改进解。另一方面,如果我们在一次迭代中允许交换两个顶点,我们必须搜索二次数量的可能性来验证当前是否局部最优。因此,邻域越大,检查当前是否局部最优或是否存在应转向的更好解所需的时间通常就越长。
- 优点:扩大邻域大小的好处是,你将只有更少的局部最优解。一般来说,你修剪掉的一些局部最优解将是你不想要的糟糕解。回顾我在上一个视频中给出的例子,该例子证明了简单的最大割局部搜索可能偏离最优解达 50%。你会看到,如果我们只是将邻域扩大到允许在同一迭代中交换两个顶点,那么在那个四顶点的例子中,局部搜索将保证产生全局最大割——那些糟糕的局部最优割已被修剪掉。
现在,即使你允许在一次迭代中交换两个顶点,也会有更复杂的例子表明局部搜索可能偏离 50%。但在许多实例中,允许更大的邻域将使局部搜索获得更好的性能。
总结一下,在将局部搜索启发式设计范式应用于计算问题之前,你头脑中应该明确的一个高层次设计决策是:你有多关心解的质量,以及你有多关心所需的计算资源。
- 如果你非常关心解的质量,并且愿意等待或投入大量硬件资源,这建议使用更大的邻域。虽然搜索速度较慢,但你最终可能得到更好的解。
- 如果你真的想要一个快速但粗糙的解决方案,希望它速度快,并且不太关心解的质量,这建议使用更简单、更小的邻域,这些邻域搜索速度快,但要知道你可能会得到一些不太好的局部最优解。
让我重申,这些只是指导方针,并非所有计算问题的金科玉律。特别是对于局部搜索,你的前进方式必须由你的应用的具体情况来指导。因此,请确保你编写多种不同的方法,看看哪种有效,然后采用它。
算法的性能保证
对于最后两个问题,让我们假设你已经解决了前三个问题,并且有了一个完全指定的局部搜索算法。你已经决定了你的邻域到底是什么,在高效的可搜索性和你最终可能获得的解质量之间找到了适合你的平衡点,你已经决定了如何生成初始解,并且决定了当有多个相邻的更优解时,下一步将选择哪一个。
现在让我们谈谈你可以从局部搜索算法中期望得到什么样的性能保证。首先,让我们谈谈运行时间,并从最基础的问题开始:这个局部搜索算法是否至少能保证最终收敛?
在你可能遇到的许多场景中,答案是肯定的。我的意思是:


假设你正在处理一个计算问题,其中可能的解集是有限的,而且你的局部搜索由一个目标函数控制,并且你定义更优的相邻解的方式是:它具有更好的目标函数值。这正是我们在最大割问题中所做的。在那里,解空间是有限的,它只是指数级数量的图割的集合,我们的目标函数就是交叉边的数量。同样,对于旅行商问题,解空间是有限的,它只是大约 n! 个可能的旅行路线,同样,你如何决定下一步选择哪条路线?你寻找一个能降低目标函数值(即旅行总成本)的路线。
只要你具备这两个属性——有限性和目标函数的严格改进——局部搜索就保证会终止。你不可能循环,因为每次迭代,你都会得到一个目标函数值严格更好的解,并且你不能永远继续下去,最终你会尝试完有限多个可能的解。

当然,没有理由对局部搜索的有限收敛性感到惊讶,毕竟暴力搜索同样能在有限时间内终止。所以,这门课是关于拥有运行快速的高效算法,因此你真正想问的问题是:局部搜索是否保证在多项式时间内收敛?

这里的答案通常是否定的。当我们研究无权最大割问题时,那是一个例外,它证明了规则的存在:在那里,我们只需要二次数量的迭代就能找到一个局部最优解。但正如我们顺便提到的,即使你只是转到最大割问题的加权版本,在最坏情况下,局部搜索可能已经需要指数级的迭代次数才能停止并得到一个局部最优解。
然而,在实践中,情况相当不同,局部搜索启发式算法通常能相当快地找到局部最优解。


目前,我们还没有一个非常令人满意的理论来解释或预测局部搜索算法的运行时间。如果你想了解更多,可以搜索关键词“平滑分析”。
局部搜索算法的另一个优点是,即使你处于算法需要很长时间才能找到局部最优解的不幸情况,你总是可以提前停止它。所以,当你开始搜索时,你可以直接设定:如果在 24 小时后还没有为我找到一个局部最优解,就告诉我到目前为止找到的最佳解。
解的质量保证
除了运行时间,我们还希望从解的质量方面来衡量局部搜索算法的性能。那么,解的质量会好吗?
这里的答案绝对是否定的,最大割问题再次是证明规则的例外。这是一个非常特殊的问题,你至少可以证明关于局部最优解的某种性能保证。对于你可能想应用局部搜索设计范式的大多数优化问题,将存在与全局最优解相差甚远的局部最优解。此外,这不仅仅是理论上的病态情况,实践中的局部搜索算法有时确实会产生极其糟糕的局部最优解。
这又回到了我们之前提出的一个观点:如果你使用局部搜索不是仅仅作为后处理的改进步骤,而是实际上从头开始生成一个希望接*最优的优化问题解,那么你不想只运行一次,因为你不知道会得到什么。相反,你应该运行它多次,在过程中做出随机决策——无论是从随机起点开始,还是选择随机的改进解进行移动——以便尽可能好地探索所有解的空间。你希望通过多次执行局部搜索,获得尽可能多的不同的局部最优解,然后你可以记住最好的那个。希望至少最好的那个能非常接*全局最优解。
本节课中我们一起学*了局部搜索算法中关于选择相邻解、定义邻域大小以及算法性能(运行时间和解质量)的关键设计决策和权衡。我们了解到,这些决策通常没有普适的“最佳答案”,而是需要根据具体问题领域、对解质量和计算速度的侧重,通过实验来确定。最后,我们强调了通过多次随机运行来探索解空间,从而获得更好*似解的重要性。
164:2-SAT问题

概述


在本节课中,我们将学*一个用于解决2-SAT问题的非典型局部搜索算法。该算法不寻常之处在于,它被证明能在多项式时间内解决这个重要且有趣的问题。2-SAT问题也为我们展示了局部搜索如何应用于约束满足问题。
2-SAT问题定义
我们之前已经多次提及SAT问题,现在让我们给出该计算问题的精确定义。
输入包含n个变量x1到xn,以及M个子句。每个变量都是布尔变量,即可以设置为真或假。每个子句是两个文字的析取(逻辑或)。文字可以是一个变量或其否定。
以下是一个例子,包含四个变量和四个子句:

- 第一个子句是 x1 ∨ x2。这个子句在x1为真或x2为真时得到满足。只有当x1和x2同时为假时,该子句才不满足。
- 第二个子句是 ¬x1 ∨ x3。只有当x1为真且x3为假时,该子句才不满足。
- 第三个子句是 x3 ∨ x4。只有当x3和x4同时为假时,该子句才不满足。
- 第四个子句是 ¬x2 ∨ ¬x4。只有当x2和x4同时为真时,该子句才不满足。
由于我们关心所有子句是否能被同时满足,通常用逻辑与符号连接这些子句。这就是一个2-SAT实例的样子。我们感兴趣的是一个决策问题:给定这样一个实例,我们想知道是否存在一种对n个变量的真值赋值,使得所有M个子句都得到满足。
2-SAT的可满足性
上面给出的2-SAT实例确实是可满足的,实际上存在不止一种满足赋值。以下是产生其中一种的方法:
- 从第一个子句开始,要满足它,x1或x2必须设为真。我们设x1为真,这满足了第一个子句。
- 现在看第二个子句。由于我们设x1为真,要满足第二个子句,必须设x3为真。我们就这样做。
- 设x3为真不仅满足了第二个子句,也满足了第三个子句。
- 最后剩下第四个子句。我们需要确保x2或x4为假。我们可以将它们都设为假。这样就满足了所有子句。
关于约束满足问题的计算易处理性(或难处理性),我们已经了解很多。2-SAT在这方面并不例外,我们知道关于它的许多事情。它的特殊之处在于它是多项式时间可解的。有多种方法可以证明这一点。
解决2-SAT的其他方法
学过第一部分课程的同学可能已经熟悉一种非常好的方法:将2-SAT问题归约到计算一个有向图的强连通分量。这种归约实际上表明2-SAT问题可以在线性时间内解决。
另一种确立其计算易处理性的方法是使用回溯算法。这里的回溯类似于我们在为顶点覆盖问题提供更快的指数时间算法时所做的工作,适用于具有小最优解的实例。
这两种证明计算易处理性的方法都不简单。这里不讨论它们,因为我们将把时间花在一个局部搜索算法上。
局部搜索算法概述
我们将重点讨论一个非常酷的随机局部搜索算法,它同样能在多项式时间内解决2-SAT实例。
需要澄清的是,正如我们之前讨论的,通常局部搜索算法不能保证在多项式时间内运行。即使在最大割问题的加权版本中也是如此。这个2-SAT例子是罕见的情况,我们可以证明它能快速收敛到正确答案。
局部搜索不仅可以用于识别可满足性的计算易处理版本,还可以改进对NP完全版本可满足性的朴素暴力搜索。

3-SAT问题
具体来说,让我们谈谈3-SAT问题。3-SAT与2-SAT类似,只是子句包含三个文字而不是两个。因此,2-SAT实例中的一个子句可以被视为禁止一对变量的四种可能联合赋值中的一种,而3-SAT实例中的一个子句可以被视为禁止三个变量的八种可能联合值中的一种。
将子句长度从2增加到3,问题就从计算易处理变为计算难处理。事实上,3-SAT在某种意义上是典型的NP完全问题。库克-列文定理通常被表述为“3-SAT是NP完全的”。
但仅仅因为它是NP完全的,并不意味着我们不能提出任何有趣的算法。暴力搜索会尝试变量的每一种可能赋值,这大约需要2^n的时间。值得注意的是,随机局部搜索可以显著改进朴素暴力搜索的运行时间。
局部搜索算法的优势

与大约2n的运行时间相比,我们将要讨论的2-SAT算法(在多项式时间内运行)的一个非常巧妙的变体,用于3-SAT时,运行时间大约为(4/3)n,远优于2^n。

这是一个相对较新的结果,大约只有十年历史,由Schöning提出。这里我们重点讨论如何使用随机局部搜索在多项式时间内解决2-SAT。对3-SAT的暴力搜索改进将留到以后讨论。
Papadimitriou算法
这个算法由Papadimitriou在20多年前提出,其非常优雅的伪代码如下:

for j = 1 to 2n^2:
if 当前赋值满足所有子句:
return 当前赋值
else:
任选一个未被满足的子句C
在子句C的两个变量中,随机均匀地选择一个变量v
翻转变量v的值(真变假,假变真)
该算法有两个循环,但外循环的责任只是运行一定预算的独立随机试验。对于那些学过第一部分的同学,让我与Karger的随机收缩算法做个类比:对于那个最小割算法,我们有一个基本的随机算法,然后我们运行它很多次(很多独立试验),希望其中一次试验能找到最小割。这里的情况类似:算法的核心在内循环,它有一定的概率找到一个满足赋值,然后我们多次运行这个基本子程序,希望其中一次试验能找到满足赋值。
算法详解
从概念上讲,你应该只关注外循环的某一次固定迭代。它们都完全相同,只是使用不同的随机数。
在给定的一次外循环迭代中,我们将进行直接的局部搜索。我们必须指定解空间。解空间就是所有为n个变量分配真值的方式,即所有可能的赋值。我们必须指定相邻解。相邻解就是只在一个变量的值上不同的两个赋值。
我们必须决定从哪里开始局部搜索。我们将以最明显的方式进行随机化:从变量的均匀随机赋值开始。确定随机初始赋值后,我们就进行局部搜索,即不断翻转变量,试图提高当前赋值的质量。
这个算法与我们上一视频讨论的通用局部搜索算法略有不同的一点是:我将对我们在放弃之前进行的局部移动次数设置一个先验界限。一个明显的动机是,如果我们想要一个多项式时间算法,这将确保算法不会运行太多步骤。这个神奇的数字是2n^2,这是我们在放弃并返回到外循环的下一次独立试验之前将进行的变量翻转次数。记住,n表示变量的数量。
在内循环的每一代中,我们首先检查当前赋值是否确实可满足,即是否满足所有子句。如果是,我们就完成了,停止并报告这一事实。
假设当前赋值不满足。这意味着存在一个或多个子句当前未被满足。例如,可能有一个涉及变量x3和x7的子句,我们不幸地将x3设为真,x7设为假,而这正是该子句所禁止的联合值,即该子句可能是¬x3 ∨ x7。
接下来我们做什么?我们进行局部搜索的一次迭代。我们尝试改进我们的赋值。我们怎么做?我们选择一个未被满足的子句。如果有很多这样的子句,我们任意选择一个,然后尝试通过翻转该子句中一个变量的值来修正这个子句。
在我们的例子中,我们要么将x3从真翻转为假,这将满足该子句;要么将x7从假翻转为真,这也将满足该子句(假设子句是¬x3 ∨ x7)。这两个变量翻转中的任何一个都会成功地将该子句从未满足变为满足。你可以考虑各种关于决定翻转两个变量中哪一个的巧妙启发式方法,但我们将采用最简单的方式:我们以50-50的概率在两者之间随机选择。

算法的潜在风险
现在,是什么让这个算法思考起来如此令人担忧?我猜测这个非常优雅的算法在1991年之前没有被分析的原因之一是,当你翻转其中一个变量时,你可能会造成一些严重的损害。我的意思是,是的,你开始时关注的那个约束将从未被满足变为被满足。但就你所知,所有其他子句可能会从被满足变为未被满足。
例如,你取变量x3并将其从真翻转为假。这对¬x3 ∨ x7这个子句来说很棒,它变为真,变为被满足。但可能还有无数其他子句,其中x3以非否定形式出现,比如x3 ∨ 其他变量。也许通过将x3从真切换到假,其中一些子句现在变得不满足了。因此,当你进行这种局部搜索迭代时,你并不总是在增加被满足子句的数量。在这个意义上,这是一个非常非标准的局部搜索算法。
算法终止与正确性
为了完成对这个算法的描述,我必须告诉你如果它从未找到满足赋值会发生什么。当然,如果它找到了这样的赋值,它会适可而止,直接返回那个满足赋值。但在所有这些2n^2 log n步之后,如果它从未找到满足赋值,它会做一件非常傲慢的事情:它直接声明,如果我没找到满足赋值,我认为它不存在。
关于这个用于2-SAT的随机局部搜索算法,我们可以做出两个明显的正面陈述。首先,它以概率1在多项式时间内运行,无论算法的随机抛硬币结果如何。这是因为我们明确控制了局部搜索的迭代次数,它是O(n^2 log n)。因此,即使是你能想象到的最草率的实现,它也将在多项式时间内运行。
让我们转向正确性问题,并将2-SAT实例分为可满足的(存在满足赋值)和不可满足的。这个局部搜索算法的第二个明显的好属性是,如果你输入一个不可满足的实例,它总是正确的。为什么?在这样的实例上,算法会做什么?它会在这些2n^2 log n步中,在可能的赋值中翻找,尝试不同的赋值。显然,没有一个会是满足的,因为不存在满足赋值。最终,算法将正确得出结论:该实例确实是不可满足的。
关键问题
但关键问题是,在可满足的实例上会发生什么?如果在指数级大的赋值空间中,存在某个满足所有赋值的赋值,这个算法能否在仅仅2n^2 log n步内找到一个?
现在我必须稍微调整一下这个问题。这毕竟是一个随机算法,有微小的概率随机抛硬币会合谋阻碍算法找到满足赋值。所以我真正想问的是:是否可能以非常接*1的概率,在每个可满足的2-SAT实例上,这个简单的随机局部搜索算法实际上都能找到一个满足赋值?
总结
本节课中,我们一起学*了Papadimitriou提出的用于解决2-SAT问题的随机局部搜索算法。我们了解了2-SAT问题的精确定义,探讨了其可满足性,并对比了解决该问题的其他方法(如图强连通分量归约和回溯算法)。我们重点剖析了这个局部搜索算法的伪代码、执行步骤以及其潜在的风险(翻转变量可能破坏其他已满足子句)。最后,我们讨论了算法的运行时间(多项式时间)和正确性(对不可满足实例总是正确,对可满足实例能以高概率找到解)。这个算法展示了局部搜索在解决特定约束满足问题上的强大能力和理论保证。
165:直线上的随机游走 🚶♂️
在本节课中,我们将学*非负整数线上随机游走的基本性质。理解这些性质对于后续分析帕帕·迪米特里(Papa Dimitri)的随机局部搜索算法至关重要。本节我们将专注于一个简单的随机过程,并探讨其核心统计特性。
背景与设定
上一节我们介绍了随机游走的概念,本节中我们来看看一个具体的模型。假设你刚参加完一场极其困难的考试(例如算法课),精神恍惚,完全不知道自己身处何方。你只能沿着一条直线跌跌撞撞地前进。在每个时间步,你有50%的概率向左走一步,也有50%的概率向右走一步。

我们将你的位置定义为距离街道尽头(一个死胡同)的步数,该点称为位置0。因此,通过在每个时间步随机向左或向右移动,你的位置要么增加1,要么减少1,每种情况的概率各为50%。
唯一的例外是当你到达位置0时。由于那是死胡同,你无法再向左走,因此在下一个时间步,你将以100%的概率向右踉跄一步,到达位置1。
我们假设你的考场位于位置0(即死胡同处)。换句话说,在时间0,你处于位置0。
核心问题:平均回家时间
关于非负整数线上的随机游走,有许多有趣的问题。我们感兴趣的是以下问题:假设你的宿舍距离考场有 n 步之遥,即你的宿舍位于位置 N。如果你头脑清醒,你可以沿着直线径直走,只需 N 步就能从考场回到宿舍。然而,你现在处于考后恍惚状态,只能随机游走。那么,平均需要多少步,你才能首次到达位置 N(即回到家)?


为了说明一个样本轨迹,假设你的宿舍仅在三步之外的位置3。
- 在时间1,你必然从位置0移动到位置1。
- 接下来,如果你幸运地向右踉跄一步,则到达位置2,距离家仅一步之遥。
- 但不幸的是,你可能又向后踉跄回到位置1。
- 接着,你可能再次向前踉跄到位置2。
- 这一次,你的势头可能带你一路向前到家,到达位置3。
在这个轨迹中,你花了5步才到达位置3的宿舍。你可以想象更快的轨迹(例如径直走),当然也可能有更慢的轨迹(在最终到达位置3之前多次向左踉跄)。
精确定义与直觉
让我们精确地定义这个统计量,并检验你对其随 n 增长的直觉。

对于一个非负整数 n,我们用大写字母 T_n 表示该随机游走首次到达位置 N 所需的步数。T_n 是一个随机变量,其值取决于你每一步随机“抛硬币”的结果。一旦揭示了所有随机抛硬币的结果(即每一步是向左还是向右),你就可以计算出 T_n 的具体值,就像我们在上一个幻灯片的样本轨迹中所做的那样。
以下是一个非平凡的问题:T_n 的期望值 E[T_n] 如何作为目标位置 N 的函数增长?我们并不要求你现在就给出证明(这将是本节后续大部分内容的目标),只需做出最佳猜测。


正确答案是 Θ(n²)。事实上,更酷的是,我们将证明 E[T_n] 恰好等于 n²,对于每个非负整数 n 都成立。
证明思路:解决更一般的问题


在本课程中,我努力提供既不太冗长又能传授知识的证明。本节将展示一个巧妙的证明,表明到达位置 N 所需的期望步数恰好是 n²。
这个巧妙证明背后的核心思想是解决一个更一般的问题:我们不仅要计算从位置0到位置 N 的随机游走的期望步数,还要更一般地计算从每个中间位置 i 到位置 N 的随机游走的期望步数。这是一个好主意,因为它给出了 n+1 个我们试图计算的统计量,并且很容易从这些不同量之间的关系中求解出所有值。
以下是使其精确的符号定义:对于一个非负整数 i,用 Z_i 表示如果你从位置 i 开始,首次到达位置 n 所需的随机游走步数。根据定义,Z_0 恰好等于 T_n(从0开始到达 n 所需的步数)。
有哪些 Z 的期望值容易计算?显然,如果取 i = n,那么 Z_n 是从 n 首次到达 n 所需的平均步数,这将是0。
对于其他的 i 值,我们不会立即显式求解 E[Z_i],而是将不同 Z_i 的期望值相互关联起来。
建立期望方程
以下是建立这些关系的方法:
-
从 i=0 开始:我们不知道 E[Z_0](这正是我们要计算的)。但我们知道,每条从0开始、在 n 结束的游走,都始于从0到1的一步,然后接着一条从1到 n 的游走。因此,这样一条游走的期望长度就是 1(第一步)加上从位置1到 n 的随机游走的期望长度,即 E[Z_1]。所以有:
E[Z_0] = 1 + E[Z_1] -
对于中间值 i (1 ≤ i ≤ n-1):我们可以将 E[Z_i] 与 E[Z_{i-1}] 和 E[Z_{i+1}] 关联起来。考虑从位置 i 开始的随机游走的第一步:
- 有50%的概率向左走到 i-1,后续过程是从 i-1 到 n 的随机游走。
- 有50%的概率向右走到 i+1,后续过程是从 i+1 到 n 的随机游走。
因此,通过条件期望(或直观理解),我们可以得到:
E[Z_i] = 1 + 0.5 * E[Z_{i-1}] + 0.5 * E[Z_{i+1}]
将等式两边乘以2并整理,我们得到一个更简洁的关系式:
E[Z_i] - E[Z_{i+1}] = 2 + (E[Z_{i-1}] - E[Z_i])

这个等式的解释是:考虑一对连续的起始点 (i-1, i) 和 (i, i+1)。从更靠*目标的位置开始显然有优势。这个等式表明,当你将这对连续的起始点向目标滑动一个位置时,优势会放大 2。也就是说,从 i 开始相对于从 i-1 开始节省的步数,比从 i+1 开始相对于从 i 开始节省的步数少 2。
求解期望值
现在我们有了这个庞大的约束系统,可以同时求解所有这些期望值,特别是我们真正关心的 E[Z_0]。
根据第一个关系,我们有:
E[Z_0] - E[Z_1] = 1
根据递推关系,如果我们把起始点对向右滑动一个位置,差值会增加2。因此:
- E[Z_1] - E[Z_2] = 1 + 2 = 3
- E[Z_2] - E[Z_3] = 3 + 2 = 5
- ...
- E[Z_{n-1}] - E[Z_n] = (2n - 1)
现在,让我们写下从 i=0 到 i=n-1 的所有这 n 个方程:
- E[Z_0] - E[Z_1] = 1
- E[Z_1] - E[Z_2] = 3
- E[Z_2] - E[Z_3] = 5
...
n. E[Z_{n-1}] - E[Z_n] = (2n - 1)

将所有这些方程相加。左边会发生大规模抵消:E[Z_1] 到 E[Z_{n-1}] 都出现了一次正号和一次负号,因此全部消去。右边是 E[Z_0] - E[Z_n]。

我们知道 E[Z_n] = 0,并且 E[Z_0] = E[T_n]。所以左边就是 E[T_n]。
右边是所有奇数的和:1 + 3 + 5 + ... + (2n-1)。这个和等于 n²。(可以将首尾配对:1+(2n-1)=2n, 3+(2n-3)=2n, ...,共有 n/2 对,每对和为 2n,总和为 n²;当 n 为奇数时,中间项为 n,同样可得 n²)。
因此,我们完成了证明:
E[T_n] = n²
一个有用的推论:马尔可夫不等式应用
在分析帕帕·迪米特里的算法时,我们实际上不会直接使用关于期望的这个结论,而是使用一个简单的推论,该推论给出了随机游走步数超过其期望值两倍的概率上界。
具体来说,我们将使用以下事实:随机游走首次到达位置 N 所需的步数严格超过 2n² 的概率不超过 50%。这是一个简单而有用的不等式——马尔可夫不等式的特例。
证明:设 P 为我们感兴趣的概率,即 P(T_n > 2n²)。

我们从 T_n 的期望值出发,已知 E[T_n] = n²。根据期望的定义:
n² = E[T_n] = Σ_{k=0}^{∞} [ k * P(T_n = k) ]

我们将这个求和分成两部分:k ≤ 2n² 的部分和 k > 2n² 的部分。
n² = Σ_{k=0}^{2n²} [ k * P(T_n = k) ] + Σ_{k=2n²+1}^{∞} [ k * P(T_n = k) ]
现在,我们对右边进行一些粗略的下界估计:
- 第一个求和项 ≥ 0(因为 k 和概率都非负)。
- 在第二个求和项中,对于所有 k > 2n²,有 k ≥ 2n² + 1 > 2n²。因此,我们可以用 2n² 替换每个 k,得到一个下界:
Σ_{k=2n²+1}^{∞} [ k * P(T_n = k) ] ≥ Σ_{k=2n²+1}^{∞} [ 2n² * P(T_n = k) ] = 2n² * P(T_n > 2n²) = 2n² * P
将这两个下界代入原式,我们得到:
n² ≥ 0 + 2n² * P
整理不等式:
P ≤ 1/2
这就完成了推论的证明。它本质上是 E[T_n] = n² 这一核心结论的一个简单推论。我们将在下一节分析帕帕·迪米特里的算法时用到这个推论。
总结

本节课中,我们一起学*了非负整数线上随机游走的一个关键性质:
- 我们定义了从位置0首次到达位置 N 所需步数的随机变量 T_n。
- 通过解决一个更一般的、从任意位置 i 出发的问题,我们巧妙地证明了 T_n 的期望值 E[T_n] = n²。
- 利用马尔可夫不等式,我们得到了一个实用推论:游走步数超过 2n² 的概率不超过 50%。
理解这个随机游走模型及其期望行为,是分析后续更复杂随机算法(如帕帕·迪米特里的算法)的重要基础。在下一节中,我们将看到这个结论如何被直接应用。
166:Papadimitriou算法分析 🧮

在本节课中,我们将学*如何利用对非负整数上随机游走基本性质的理解,来分析Papadimitriou算法。我们将看到,随机化的局部搜索如何为2-SAT问题产生一个多项式时间算法。
算法回顾 🔄
首先,让我们回顾一下Papadimitriou的2-SAT算法细节。考虑一个有 n 个布尔变量的2-SAT实例。该算法包含两个循环:
- 外层循环:运行
log₂ n次独立的随机试验。 - 内层循环:每次外层循环迭代中,进行随机局部搜索。

以下是具体步骤:
- 初始化:从随机赋值开始。对
n个变量中的每一个独立地抛一枚公平硬币,决定其初始值为真或假。 - 搜索预算:获得
2n²次局部移动的预算,试图将初始赋值转换为一个满足的赋值。 - 迭代搜索:在
2n²次内层迭代中:- 首先检查当前赋值是否满足所有子句。如果满足,算法结束。
- 如果不满足,则至少存在一个(可能多个)不满足的子句。我们任意选取其中一个。
- 由于是2-SAT实例,该子句涉及两个变量。我们以均匀概率随机选择其中一个变量,并翻转其布尔值。
- 输出结果:如果在
2n² * log₂ n次局部移动后,算法仍未找到满足的赋值,则断言该实例不可满足。



该算法显然在多项式时间内运行,并且当实例不可满足时总能正确报告。然而,当至少存在一个满足赋值时,算法成功找到它的概率并不显然。这正是本视频分析的主题。

核心洞察:定义正确的进度度量 📈
上一节我们介绍了算法的流程,本节中我们来看看分析的关键。Papadimitriou算法最自然的进度度量可能是“当前满足的子句数量”。然而,当你翻转一个变量的赋值时,这个数量实际上可能会减少——你修复了一个子句,但可能破坏了其他多个子句。
尽管如此,如果我们定义正确的进度度量,就可以通过随机游走的类比来论证算法会随着时间取得进展,并最终找到一个满足的赋值。
让我们看看具体细节。


我们假设实例是可满足的,因此至少存在一个满足赋值。我们任意选取其中一个,称之为 A*。
我们用 A_t 表示算法在内层循环完成 t 次迭代后所考虑的赋值。A_0 是外层循环本次迭代开始时随机的初始赋值。



本次分析的精妙之处在于使用正确的进度度量:我们不去计算当前赋值满足的子句数量,而是去计算它与参考满足赋值 A* 一致的变量数量。





定义随机变量 X_t 为赋值 A_t 与 A* 取值相同的变量数量。显然,X_t 是一个介于 0 到 n 之间的整数。如果 X_t = n,则意味着 A_t 与 A* 完全相同,因此 A_t 本身就是一个满足赋值,算法将成功终止。
分析进展:与随机游走的联系 🚶♂️

现在我们来分析算法如何在平均意义上取得进展。
假设当前我们查看赋值 A_t,并且它不是一个满足赋值。那么存在至少一个不满足的子句。算法任意选取其中一个,假设该子句涉及变量 x_i 和 x_j。
一个简单的观察是:我们的当前赋值 A_t 未能满足这个子句,而参考满足赋值 A* 自然满足它。因此,A* 必然对变量 x_i 或 x_j 中的至少一个赋予了与 A_t 不同的值。
Papadimitriou算法将以均匀概率随机选择 x_i 或 x_j,并翻转该变量当前的值。
以下是可能的情况:
- 情况一(有利):如果
A_t对x_i和x_j的赋值都与A*相反。那么无论算法选择翻转哪个变量,翻转后我们都会比之前多一个变量与A*一致。即X_{t+1} = X_t + 1。 - 情况二(随机):如果
A_t与A*恰好对两个变量中的一个一致(例如,在x_i上一致,在x_j上不一致)。- 幸运情况:如果算法翻转了那个不一致的变量(
x_j),则结果与情况一相同:X_{t+1} = X_t + 1。 - 不幸情况:如果算法翻转了那个已经一致的变量(
x_i),则翻转后我们会在该变量上变得不一致,导致X_{t+1} = X_t - 1。
- 幸运情况:如果算法翻转了那个不一致的变量(
此时,我希望大家能联想到上一节视频中关于非负整数上随机游走的内容。在那里,我们也有一个随机变量(游走者的位置),在每个时间步,它都以50%的概率增加1或减少1。
然而,X_t 的随机过程与我们之前研究的标准随机游走并不完全相同。以下是三个主要区别:
- 移动概率:在标准随机游走中(除非在位置0),总是50%左移或右移。在Papadimitriou算法中,有时向左移动(
X_t减少)的概率可能是0%(即情况一,100%向右移动)。 - 起始位置:标准随机游走定义从位置0开始。而
X_0通常大于0,因为随机初始赋值很可能与A*在某些变量上一致。 - 停止条件:标准随机游走在首次到达位置
n时停止。在Papadimitriou算法中,如果X_t = n,算法必然停止(因为找到了A*)。但算法也可能在X_t < n时停止,因为它可能找到了A*之外的另一个满足赋值。
从最坏情况到一般情况的分析 🛡️
那么,我们上一节对随机游走的分析还有用吗?令人惊讶的是,上述三个差异中的每一个都只会有助于算法。这意味着,在实际运行中,算法终止的速度可能比单纯从随机游走分析中推测的还要快。
我们可以这样思考:想象我们被迫在最坏情况下运行Papadimitriou算法:
- 实例只有一个满足赋值
A*(无法提前因找到其他解而停止)。 - 初始赋值与
A*在所有变量上都相反(即X_0 = 0)。 - 每次选择不满足子句时,都“被迫”遇到情况二(即子句中一个变量与
A*一致,另一个不一致),从而只有50%的概率取得进展。
在这种最坏情况下,X_t 的演化过程与从0开始、在 2n² 步内试图到达 n 的标准随机游走完全相同。
因此,在最坏情况下,单次外层循环迭代(即 2n² 步内)失败的概率,就等于随机游走在 2n² 步内未能到达 n 的概率。根据上一节的分析,这个失败概率至多为 1/2,即成功概率至少为 1/2。
对于任何非最坏情况(例如起始 X_0 > 0、存在其他解、有时遇到情况一),算法的成功概率只会更高。因此,在任何情况下,单次外层循环迭代的成功概率都至少为 50%。
整体成功概率计算 🎯
上一节我们分析了单次迭代的成功率,现在我们来计算整个算法的成功率。
单次外层循环迭代失败的概率至多为 1/2。算法独立运行 log₂ n 次这样的迭代。所有迭代都失败的概率至多为:
(1/2)^{log₂ n} = 1/n
因此,整个算法至少找到一次满足赋值的成功概率至少为:
1 - 1/n
这正是定理所声称的结果。

总结 📝

本节课中,我们一起学*了Papadimitriou算法如何巧妙地利用随机局部搜索解决2-SAT问题。通过定义与某个固定满足赋值之间的一致变量数量 X_t 作为进度度量,我们将算法的行为与一个非负整数上的随机游走联系起来。分析表明,即使在最坏情况下,单次搜索的成功概率也至少为1/2。通过独立重复 log₂ n 次搜索,我们将整体失败概率降低到 1/n 以下,从而得到了一个高效且高成功率的随机算法。这个分析展示了概率分析和问题结构洞察在算法设计中的强大力量。
167:稳定匹配(可选)🎯

在本节课中,我们将要学*一个经典且有趣的算法问题——稳定匹配。我们将了解其定义、应用场景,并学*一个著名的算法来求解它。稳定匹配问题在经济学、计算机科学等多个领域都有重要应用,例如大学招生、医疗住院医师匹配等。
概述
在算法设计与分析课程中,我们已经学*了许多著名算法、基础数据结构以及关键应用。尽管课程内容已经非常充实,但仍有一些重要的主题未能涵盖。在接下来的内容中,我们将简要介绍一些未涉及的技术,例如二分图匹配和最大流问题,并希望激发大家进一步学*的兴趣。算法领域充满活力,仍有许多基础问题和模型有待探索。
稳定匹配问题定义
稳定匹配问题可以被视为一个图论问题。图中有两组节点,分别用大写字母 U 和 V 表示。为方便起见,我们通常称这两组节点为“男士”和“女士”。一个非必要的简化假设是,这两组节点的数量相等,记这个共同的数量为 n。
例如,当 n = 3 时,第一组节点可以是 A, B, C,第二组节点可以是 D, E, F。
稳定匹配问题的关键在于,每个节点都有对其他节点的偏好排序。具体来说:
- 每个 U 中的节点都对 V 中的所有节点有一个排名列表。
- 每个 V 中的节点都对 U 中的所有节点有一个排名列表。
在这个例子中,假设所有左侧节点(A, B, C)的偏好一致:D > E > F。而右侧节点的偏好则各不相同:
- D 的偏好:A > B > C
- E 的偏好:B > C > A
- F 的偏好:C > A > B
这些节点和排名列表可以代表许多现实场景。例如,一组是大学,另一组是申请者;或者一组是医院,另一组是寻求住院医师职位的医学院毕业生。双方都根据各自的偏好对对方进行排序。
给定这些数据(两组节点和它们的偏好列表),我们的目标是计算一个稳定匹配。
什么是稳定匹配?
首先,稳定匹配必须是一个完美匹配。完美匹配意味着 U 中的每个节点都恰好与 V 中的一个节点配对,反之亦然。
除了是完美匹配,它还必须满足稳定性,即不存在“阻碍对”。这意味着,对于任意一对没有匹配在一起的节点 u(来自 U)和 v(来自 V),他们不匹配必须有充分的理由。具体来说,要么 u 严格更喜欢它当前的匹配对象 v' 而不是 v,要么 v 严格更喜欢它当前的匹配对象 u' 而不是 u。
这个定义有现实动机。如果一个完美匹配不满足稳定性,那么存在一对未匹配的节点 u 和 v,他们都更倾向于对方而不是自己当前的伴侣。这将促使他们“私奔”,从而破坏整个匹配安排。例如,在学生与大学的匹配中,这样的不稳定对会促使学生和大学私下达成协议,破坏原有的录取结果。
Gale-Shapley 算法 🧠
现在,我们来介绍一个极其优雅且著名的算法,用于计算稳定匹配:Gale-Shapley 求婚算法。Lloyd Shapley 因其在此算法上的贡献,于 2012 年获得了诺贝尔经济学奖。
我们将通过一个例子来解释算法,然后给出通用的伪代码。
我们从空匹配开始,即没有人匹配任何人。只要还存在某个未匹配的“男士”(左侧节点),我们就任意选择一个未匹配的男士 u,让他向他偏好列表中尚未拒绝过他的、排名最高的“女士”(右侧节点)求婚。
让我们用之前的例子来演示:
- 初始状态:无人匹配。
- 选择未匹配的男士 C。C 向他最喜欢的女士 D 求婚。D 暂时接受。
- 选择未匹配的男士 B。B 也向他最喜欢的女士 D 求婚。D 比较 B 和 C,根据她的偏好(B > C),她拒绝 C 并与 B 暂时订婚。
- 选择未匹配的男士 A。A 向他最喜欢的女士 D 求婚。D 比较 A 和 B,根据她的偏好(A > B),她拒绝 B 并与 A 暂时订婚。
- 现在 B 和 C 未匹配。选择 C。C 的下一个选择是 E(因为已被 D 拒绝)。E 暂时接受 C。
- 选择 B。B 的下一个选择是 E(因为已被 D 拒绝)。E 比较 B 和 C,根据她的偏好(B > C),她拒绝 C 并与 B 暂时订婚。
- 最后,C 未匹配。C 向他列表中最后的女士 F 求婚。F 接受(C 是她的首选)。
最终得到的完美匹配是:A-D, B-E, C-F。可以验证这是一个稳定匹配。
算法伪代码
以下是 Gale-Shapley 算法的通用伪代码描述:
初始化所有男士和女士为未匹配状态
while 存在一个未匹配且仍有可求婚女士的男士 u:
令 w 为 u 的偏好列表中,尚未拒绝过他的、排名最高的女士
if w 未匹配:
u 和 w 暂时订婚
else: // w 已与 m‘ 订婚
if w 更喜欢 u 而不是 m‘:
解除 w 与 m‘ 的婚约
u 和 w 暂时订婚
else:
w 拒绝 u
该算法维持一个不变性:当前的订婚集合总是一个匹配(不一定完美)。每个男士最多与一位女士订婚,每位女士也最多与一位男士订婚。
算法正确性证明 ✅
Gale-Shapley 定理指出,该算法不仅会快速终止(最多 O(n²) 次迭代),而且会终止于一个稳定匹配。这实际上构造性地证明了稳定匹配总是存在,这是一个非常不显然的事实。
证明分为三个部分:
1. 算法在 O(n²) 步内终止。
因为每个男士最多向每位女士求婚一次,所以总求婚次数不超过 n * n = n² 次。

2. 算法终止时得到一个完美匹配。
用反证法。假设终止时某位男士 u 未匹配,这意味着他向所有 n 位女士求婚并被全部拒绝。但一位女士只有在有更优的求婚者时才会拒绝他人。因此,u 被所有女士拒绝意味着所有女士在算法过程中都曾被求婚并订婚过。算法中,女士一旦订婚,之后只会更换为更优的伴侣,不会恢复单身。所以终止时所有女士都处于订婚状态。由于男女数量相等,所有女士都订婚意味着所有男士也必须订婚,这与 u 未匹配矛盾。因此,终止时必为完美匹配。
3. 算法得到的完美匹配是稳定的。
我们需要证明不存在阻碍对。考虑任意一对未匹配的男士 u 和女士 v。
- 情况一:u 从未向 v 求过婚。这意味着 u 在算法中与他最终匹配的女士 w 求婚时,w 在 u 的偏好列表中排在 v 之前,即 u 更喜欢 w 而不是 v。因此 (u, v) 不是阻碍对。
- 情况二:u 曾向 v 求过婚。那么他们最终未匹配,必定是因为 v 在当时(或之后)接受了一个她比 u 更喜欢的男士的求婚。在算法中,女士的伴侣只会越来越好。因此,在算法终止时,v 的伴侣 m‘ 一定是她比 u 更喜欢的人。因此 (u, v) 也不是阻碍对。
由于 (u, v) 是任意未匹配对,故该匹配是稳定的。
总结
本节课我们一起学*了经典的稳定匹配问题。我们了解了其定义,即一个不存在“阻碍对”的完美匹配。我们重点学*了著名的 Gale-Shapley 求婚算法,该算法通过多轮“求婚”和“选择”的简单过程,总能找到一个稳定匹配。我们还概述了该算法的正确性证明,包括其终止性、完美匹配性和稳定性。这个算法不仅优美高效,而且在实际生活中有广泛的应用,是算法设计中一个重要的典范。
168:匹配、流量与布雷斯悖论(可选)🚦
在本节课中,我们将学*三个相互关联的核心概念:二分图匹配、最大流问题,以及一个有趣的现象——布雷斯悖论。我们将了解如何将匹配问题转化为流问题,并探讨在自私用户行为下,网络“改进”反而可能导致整体性能下降的悖论。

二分图匹配问题
上一节我们讨论了稳定匹配问题,它存在高效解的一个关键原因是,原则上可以将任意节点与另一侧的任意节点匹配。虽然节点有偏好,可能不希望与某些节点匹配,但并没有硬性约束阻止某些配对。
当存在这种硬性约束时,我们就得到了经典的二分图匹配问题。

二分图匹配问题的输入是一个二分图。这意味着可以将顶点分为两组,一组称为 U,另一组称为 V。每条边恰好连接两组中的一个顶点。换句话说,存在一个图的切割,能切断每一条边。
目标是计算一个匹配。匹配是指一个边的子集,其中任意两条边都没有公共端点。我们的目标是计算规模最大的匹配。
例如,在右侧的粉色图中,存在多个由三条边组成的匹配。可以验证,图中不存在完美匹配(即无法找到包含四条边的匹配)。
二分图匹配是一个极其基础的问题,它涉及在存在配对约束的情况下,尽可能多地配对对象。
好消息是,最大匹配问题可以在多项式时间内解决。事实上,不仅在二分图中(我们这里讨论的),甚至在一般的非二分图中,该问题也是多项式时间可解的。非二分图的情况需要更复杂的算法,而二分图的情况可以轻松地归约到另一个你应该知道的问题——最大流问题。
接下来,我们将描述最大流问题。为什么二分图匹配可以归约为最大流问题,这留给你作为一个很好的练*。
最大流问题
最大流问题可以在无向图或有向图中研究。有向图的情况在某种意义上更为通用。
以下是问题的定义:
- 输入:一个有向图。
- 特殊顶点:一个称为源点 S,另一个称为汇点 T。
- 容量:每条边 e 有一个容量 c(e),表示该边可以容纳的最大流量或交通量。
非正式地说,目标是在尊重各边容量的前提下,从源点 S 向汇点 T 推送尽可能多的“东西”。你可以将其类比为从 S 产生的电流流向 T,或者从 S 注入、流经代表管道的边、最后从 T 排出的水流。关键在于,除了 S 和 T 之外,每个顶点都满足流量守恒,即流入的量必须等于流出的量。S 是流量产生的地方,T 是流量流出的地方。
作为一个简单示例,假设在右侧的粉色网络中,我给每条边分配容量为 1。在这个网络中,你可以从 S 推送到 T 的最大流量是 2 个单位。实现方法是:通过顶部路径 S -> V -> T 发送 1 个单位,通过底部路径 S -> W -> T 发送第二个单位。
好消息是,最大流问题可以在多项式时间内精确求解。有许多方法可以实现,有许多不同的酷算法可以解决最大流问题。最简单的方法本质上是贪心算法,即一次沿着一条路径路由流量。
需要指出的一点是,最大流问题最明显的贪心方法并不奏效。最明显的做法是:找到一条每条边都有剩余容量的路径,沿着该路径发送流量,然后重复。
这个朴素贪心算法的次优性在本幻灯片所示的四顶点网络中已经很明显了。
假设在第一次迭代中,你选择沿着路径 S -> V -> W -> T 推送流量。这条路径有三条边,容量均为 1,因此你可以沿着这条曲折的路径推送 1 个单位的流量。但如果你这样做,你就同时阻塞了其他路径(S->V->T 和 S->W->T),无法再沿着这些路径发送更多流量。因此,这个朴素的贪心算法只计算出了值为 1 的流,而我们已知最大流值为 2。
因此,必须采用一个更宽泛的增广路径概念,允许沿反向发送流量,这实际上相当于撤销之前迭代的增广。有了这个更宽泛的增广路径定义,由此产生的贪心算法确实能保证计算出最大流。
此外,如果你在贪心算法的每次迭代中聪明地选择使用哪条增广路径,你可以证明其运行时间界限是多项式的。实际上,也存在许多不基于增广路径的多项式时间算法来解决最大流问题。
事实上,最大流问题解决方案的多样性,让我很难给你一个关于该问题运行时间的简洁结论。但大致来说,我们还没有达到*似线性时间的算法,但我们有很多算法并不比这差太多,比如在二次方范围内(例如,类似贝尔曼-福特算法的 M * N 运行时间界限)。在实践中,许多这些算法的表现远好于二次方。
一件很酷的事情是,尽管最大流问题非常经典(增广路径算法最早由福特和富尔克森在 20 世纪 50 年代研究),但在 21 世纪,我们仍然看到了一些关于最大流的非常好的新进展,例如基于随机抽样或与电网连接的新算法。
自私路由与布雷斯悖论
在某些流网络的应用中,流量不是由某个集中式算法计算出来的,而是由许多参与者的行为产生的。例如,当你开车从家到公司时,没有人告诉你必须走哪条路线,而是你根据自己的偏好选择从家到公司的路线,比如你可能选择可用的最短路线。
这种网络中的自私行为是 21 世纪的一个主要研究课题。让我向你展示一个有趣的、适合在鸡尾酒会上谈论的例子,叫做布雷斯悖论。
想象我们有一个流网络(你可以把道路交通看作一个简单的例子)。让我们关注这样一个情况:一群早晨的通勤者从一个共同的郊区(用顶点 S 表示)出发,前往附*的城市(用顶点 T 表示),他们大致在同一时间出发,并且都可以选择他们想要的、从 S 到 T 的任意路线。
为了更好地反映交通网络中的问题,我们不再将链路视为具有容量,而是认为它们具有延迟函数。对于每条边,都有一个函数,描述随着使用该边的交通量变化,所有交通所承受的旅行时间是多少。正如你从自身经验所知,道路越拥堵,通过该道路所需的时间就越长。
作为说明,在右侧的粉色网络中,我们给两条道路赋予恒定的延迟函数,始终等于 1。这些道路可以看作是拥有无限车道,但也相当长。因此,无论有多少交通使用它,通过其中一条道路总是需要 1 小时。
相比之下,另外两条道路的旅行时间会随着拥堵而增加。为了简单起见,我们使用恒等函数,因此如果 100% 的交通使用其中一条道路,则需要 1 小时;如果 50% 的交通使用其中一条道路,那么所有这些交通通过该道路需要半小时,依此类推。

现在让我们看看这个粉色网络。我们有一个单位的自私交通(代表成百上千名选择自己从 S 到 T 路线的司机)。假设司机只想尽快到达 T,他们希望最小化旅行时间。问题是,这种聚合的自利行为会产生哪种流?
两条路线完全对称。每条路线都有一条总是需要 1 小时的道路,以及第二条旅行时间与使用它的交通比例成正比的道路。由于对称性,一旦达到稳定状态,我们预计一半的交通使用顶部路径,一半的交通使用底部路径。在 50/50 的交通分配下,两条路线的总旅行时间都是 1.5 小时。

现在,我提到了布雷斯悖论。那么悖论是什么?想象我们认为这 1.5 小时的旅行时间完全无法接受。此外,想象我们拥有一种新技术,有人刚刚发明了一种传送装置,我们想将其安装在这个网络中,以便人们能比以前更快地到达工作地点。让我们在顶点 V 安装一个这样的传送器,允许人们瞬间旅行到顶点 W。我们在粉色网络中增加第五条从 V 到 W 的边来表示这一点,其延迟函数恒等于零。
那么,增加这个允许你从 V 瞬间移动到 W 的传送装置会带来什么后果呢?
假设你是这些司机中的一员,目前正忍受着 1.5 小时的通勤时间。毫无疑问,你会想放弃旧路线来使用这个新传送器。你会想把你的旧路径(无论是 S->V->T 还是 S->W->T)切换到新的曲折路径 S->V->W->T。看起来这样你只需要 1 小时就能到达公司:在边 S->V 上花费半小时,瞬间传送,然后在边 W->T 上再花费半小时。
但问题在于:不仅你会切换路径使用传送装置,其他成千上万的司机也会这样做。因此,在我们用这个传送装置增强网络之后的新稳定状态下,每个人都会使用曲折路径 S->V->W->T。
既然每个人都在做完全相同的事情,所有的交通都使用边 S->V,将其旅行时间推高到 1 小时。所有人都使用边 W->T,也将其旅行时间推高到 1 小时。因此,尽管我们直观上只让网络变得更好,但在假设每个人都自私地选择其最小旅行时间路径的唯一新稳定状态下,通勤时间实际上变得更糟了。每个人的通勤时间从 1.5 小时跃升到了 2 小时。这就是布雷斯悖论:在存在自私用户的情况下,对网络的改进可能使每个人的结果变得更糟。
这就是布雷斯悖论,由德国数学家布雷斯于 1968 年发现。下次你参加一个书呆子鸡尾酒会时,可以用它来让你的朋友和同事感到惊讶。实际上,布雷斯悖论有一个物理实现,你可能觉得在那个场合更有用。
其思想是,你将使用细绳和弹簧作为材料。细绳旨在执行恒定延迟函数的功能。细绳当然是无弹性的物体,其长度与你施加的力无关。另一方面,弹簧是有弹性的,其长度与施加在弹簧上的力成正比。因此,它们扮演的角色类似于网络中的线性延迟函数。
布雷斯悖论表明,存在一种将细绳和弹簧连接在一起的方式,然后将这个细绳和弹簧的组合装置悬挂在一个固定的基座(比如桌子下面)上。你在这些细绳和弹簧的底部悬挂一个重物,使其伸展。
通常,当你有一个支撑着某个重物的装置,然后你开始“切断”——拿一把剪刀从这个装置的中间剪掉东西——你会预期装置变弱,因此重物会进一步下垂向地面。但同样地,布雷斯悖论表明,从一个网络中移除一个看似有帮助的传送装置实际上可以改善、缩短自私的通勤时间。这表明,从装置的中间剪掉细绳实际上可以让重物悬浮得离地面更远。这不仅仅是假设,我课堂上的学生已经建造了这些细绳和弹簧装置,并演示了这种悬浮现象。如果你在 YouTube 上搜索,我打赌你能找到一些视频。在家试试吧!
总结
本节课中,我们一起学*了三个核心概念。首先,我们介绍了二分图匹配问题,即在有硬性约束的二分图中寻找最大规模的配对。接着,我们探讨了更通用的最大流问题,它可以通过巧妙的贪心增广路径算法在多项式时间内求解,并且二分图匹配可以归约为此问题。最后,我们揭示了布雷斯悖论这一反直觉现象:在网络用户自私地选择最短路径时,为网络增加新的、快速的连接(如传送装置)反而可能导致所有人的旅行时间增加。这提醒我们,在设计和分析复杂系统(尤其是交通网络)时,必须考虑个体理性行为可能导致的集体非理性结果。
169:线性规划及其扩展(可选)📘

在本节课中,我们将简要介绍线性规划及其两个重要扩展:凸规划和整数线性规划。线性规划是一个既能在理论上高效求解,又具有极强通用性的问题框架。我们还将探讨一些课程中未深入讨论的重要算法主题,例如几何算法和面向海量数据集的算法。
线性规划简介
上一节我们介绍了算法中的多种范式,本节中我们来看看线性规划。线性规划是一个可以高效求解的问题,无论是在理论还是实践中。同时,它是一个非常通用的框架。
以下是线性规划的核心描述:
- 目标:优化一个线性函数(最大化或最小化)。
- 约束:在一组线性约束条件下进行优化。
从几何角度看,这对应于在一个可行域内寻找最佳点(最优点)。这个可行域可以表示为多个半空间的交集。

尽管为了直观,我们通常在二维平面(多边形)中描绘它,但线性规划的威力在于它能高效解决维度极高(例如百万维)的问题。
线性规划的威力与求解
线性规划包含了许多特殊问题。例如,最大流问题可以轻松编码为线性规划的一个特例。
以下是最大流问题转化为线性规划的简单说明:
- 决策变量:为图中的每条边设置一个变量,表示该边上的流量。
- 目标函数:最大化从源点流出的总流量(一个线性函数)。
- 线性约束:确保流入每个顶点的流量等于流出该顶点的流量(流量守恒约束)。
尽管通用性强,线性规划问题仍可被高效求解。乔治·丹齐格在20世纪40年代系统性地提出了线性规划的公式化和算法求解,特别是他发明的单纯形法,至今仍是解决线性规划最重要的实用方法之一。
关于算法实现细节,无论是单纯形法还是内点法,都较为复杂。然而,学*如何将实际问题建模为线性规划,并利用开源或商业求解器(这些是算法工具箱中非常强大的黑盒工具)来求解,是非常有价值的。
线性规划的扩展
线性规划本身功能强大,但存在更强大的黑盒子程序。以下是两个重要的扩展方向。
凸规划
线性函数是凸函数的一个特例。如果你想在凸约束条件下最小化一个凸函数(或等价地,最大化一个凹函数),这同样是一个在理论上(多项式时间)和实践中都能高效求解的问题。虽然能处理的问题规模可能不如线性规划那么大,但也相当可观。
整数线性规划
整数线性规划类似于线性规划,但增加了额外的约束:某些决策变量必须取整数值。例如,决策变量取值为1/2在整数规划中是不允许的。
整数规划在一般情况下无法在理论上高效求解(例如,很容易将顶点覆盖等NP完全问题编码为整数规划的特例)。但对于某些特定领域,存在相当先进的求解技术。因此,如果你有一个NP完全问题并确实需要解决它,整数规划是一个值得深入学*的技巧。
其他重要算法主题
除了线性规划,还有许多重要的算法主题我们未及深入讨论。以下是一些值得关注的领域。
几何算法
我们几乎未讨论几何算法,唯一的例外是在第一部分讨论过的用于最*点对问题的分治算法。几何算法的研究大致分为两类:低维问题和高维问题。
低维几何算法通常指二维或三维空间中的问题,例如凸包问题。给定一组点,凸包问题是找出哪些点位于点集的凸包边界上。
在平面上理解凸包问题的一个直观方法是:将点想象为钉在木板上的钉子,用一个足够大的橡皮筋套住所有钉子然后松开,橡皮筋会收紧并包裹住最外围的钉子,这些钉子就构成了凸包。
计算凸包有很多用途。例如,在3D图形中检测两个运动物体是否碰撞,你无需记住物体的所有细节,只需跟踪它们的凸包即可——当凸包相交时,物体发生碰撞。
高维几何算法处理成千上万甚至更高维度的问题。你可能会问,为什么会有成千上万维的点?一个例子是信息检索。你可以将每个文档表示为一个高维空间中的点:每个坐标对应一个感兴趣的词,坐标值是该词在文档中出现的频率。这样,文档相似性查询就转化为高维空间中的最*邻查询:给定一个新文档(点),在已有的点集中找出欧几里得距离最*的点。
持续运行的算法
在算法设计与分析课程中,我们主要关注经典的计算模式:给定输入,进行计算,产生输出,然后结束。但在现实世界中,许多算法的工作永不停止。
本课程中提及的两个应用是:
- 缓存问题:例如,为操作系统管理缓存的算法需要无限期地运行。
- 网络路由:例如,互联网路由算法需要持续应对链路故障、新节点和新数据,无限期地做出路由决策。
理解这些持续运行、实时决策的算法的理论和实践,很大程度上基于我们从经典计算范式中学到的经验,但它本身也是一个值得研究的有趣课题。
海量数据集算法
21世纪算法研究的一个主要关注点是海量数据集,即数据量大到无法放入单台机器的主内存。
一个重要主题是流式算法。这些算法需要以极小的空间,快速处理如消防水管般高速产生的数据流,并将其提炼成少数准确的统计信息。例如,运行在网络路由器本地、以极快速度处理数据包的算法,或负责汇总望远镜观测产生数据的算法。

另一个重要但当前理论尚不成熟的主题,是如何利用并行性来处理海量数据集,例如使用MapReduce或Hadoop系统所倡导的分布式系统方法。

总结
本节课中我们一起学*了线性规划的基本概念及其高效求解的特性,了解了它如何将最大流等问题作为特例包含在内。我们还探讨了线性规划的两个扩展:凸规划和整数线性规划。最后,我们简要介绍了课程中未深入讨论的几个重要算法领域,包括低维与高维的几何算法、持续运行的算法以及面向海量数据集的流式算法和并行计算。这些主题展示了算法领域广阔而深入的研究图景。
170:结语 🎓

在本节课中,我们将一起回顾整个算法课程的学*旅程,并对所学内容进行总结。
课程概述
我们的旅程至此结束,这门课程也迎来了尾声。
我已经教授这门课程材料多年,但它从未停止过带来乐趣,也从未变得乏味。算法研究是学*计算机科学中许多经典思想的绝佳途径。这门学科历史上许多最杰出的思想,都在这些算法课程中得以展现。
上一节我们回顾了课程的核心内容,本节中我们来看看学*这门课程的最终收获与意义。
学*成果总结
因此,你学*了许多优雅而巧妙的算法与概念。你掌握了一系列可以在自己的编程项目中应用的实用技术,并且这两者之间存在着显著且巨大的交集。
以下是你在本课程中获得的核心能力:
- 掌握了经典算法思想:你理解了分治、动态规划、贪心算法等核心范式。
- 学会了分析与设计:你能够分析算法的时间与空间复杂度,并运用所学设计解决方案。
- 构建了知识体系:你将图论、搜索、排序、数据结构等知识融会贯通。
我知道这并非总是易事。算法与数据结构的设计和分析领域的前沿知识是一个艰深的课题。在我们之前的计算机科学家们是富有创造力的杰出个体。
展望未来
现在,你已经能够站在这些巨人的肩膀上,在他们的思想基础上,将他们的理念应用到你自己的项目之中。
如果我想让事情显得更充满希望一点,我或许会期望这门课程让你有了一些改变。也许你对计算机科学多了一点热情,或者在求知欲上多了一点好奇。也许,比起我们刚开始的时候,你变得更聪明了一点。
课程总结

本节课中,我们一起学*了整个算法课程的收官部分。我们回顾了从经典算法到实用技术的丰富内容,认识到站在前人智慧之上进行创新的重要性,并展望了将所学知识应用于未来项目的可能性。
期待下次再见。👋

浙公网安备 33010602011771号