滑铁卢大学-CS341-算法笔记-全-
滑铁卢大学 CS341 算法笔记(全)
001:课程介绍 👋

在本节课中,我们将了解CS341算法课程的概况,包括授课教师、课程结构以及一些重要的课程安排信息。
大家好,欢迎来到CS341课程。我是Anna Lubiw教授,本学期我将与Trevor Brown教授共同讲授这门课程。我们之前都曾教授过这门课程,因此你们将得到经验丰富的指导。Trevor教授将负责前12次讲座,我将负责后12次讲座。本视频的目的是向大家问好,让你们看到我的面孔,并希望你们能安心地前来参加答疑时间。
Trevor Brown教授和我将在整个学期中安排答疑时间,共同布置作业,并在每周五进行辅导课。你们可以在课程网页上找到相关信息。
总之,我期待在答疑时间见到你们中的许多人,并祝愿你们有一个愉快的学期。我知道课程仍然在线进行可能令人失望,但另一方面,我们已经相当习惯这种方式了,至少这不再是一个冲击或意外。
我希望这将是一个美好的学期。谢谢,再见。
本节课中我们一起学习了CS341算法课程的基本介绍,包括授课教师的安排、课程结构以及答疑和辅导课等重要信息。
002:课程介绍与最大子数组问题
在本节课中,我们将学习滑铁卢大学CS341算法课程的第一讲内容。课程将介绍课程的基本信息、算法设计的重要性,并通过一个经典问题——最大子数组问题(Bentley‘s Problem)——来演示多种算法设计范式。
课程概述与安排
大家好,我是Trevor Brown,我将是你们在2021年冬季学期CS341算法课程的讲师之一。这门课程将由我和Annaluu共同讲授。
课程网站包含了教学大纲、日历、政策、幻灯片和作业等重要信息。建议你访问网站并记下作业截止日期等重要时间点,以免错过。
跟上课程进度至关重要,因为课程内容环环相扣。例如,如果你没有很好地理解分治算法,那么在后续学习动态规划时,可能会在理解必要的递推关系和递归背景上遇到困难。
我们将使用Piazza平台进行课程讨论。学生可以在上面提问,并鼓励互相解答。助教和讲师也会参与其中。所有重要的课程通知和作业澄清都会在Piazza上发布,请务必保持关注。
课程的所有部分(讲座、作业、期中考试、期末考试)都是统一的。如果你因严重问题可能错过作业截止日期,请尽可能提前通知我们。期中考试和期末考试将是带回家完成的考试。具体的评分细则可以在课程网站上找到。
本课程的教材是著名的《算法导论》(CLRS)。你可以购买实体书,也可以通过图书馆网站免费获取电子版。你需要掌握课程网站上列出的所有相关教材章节、讲座幻灯片及讨论内容。
课程还设有讨论环节,其主要目的是练习和巩固已学概念,而非引入新知识。目前计划将讨论环节设为可选。
学术诚信与协作规范
尤其是在线课程环境下,我们必须警惕抄袭的可能性。请小心避免无意间的抄袭行为。
我们允许学生之间进行一对一的高层次讨论,交流解决问题的通用思路,这适用于作业。但是,对于带回家的期中或期末考试,你绝对不能与其他同学讨论,这些考试需要你独立完成,以展示个人知识。
对于作业,我们通常允许你与个别同学进行高层次讨论。我们不建议进行大规模的群聊来分工解决问题,但你可以有一个伙伴一起讨论解题思路。
一个典型的建议是:不要从这类讨论中带走书面笔记。考虑到在线课程的实际情况,如果你们的讨论是通过文字进行的,这一点可能难以完全避免。因此,我的建议是:如果讨论的内容是那种你们可以面对面(比如在饮水机旁)交流,并且不需要借助白板来推导公式或记录细节就能理解的程度,那么这种讨论可能是可以接受的。反之,如果需要白板辅助来理清细节,那么就不应该与同学进行这样的讨论。请尽量将讨论保持在“探讨解题大致方向”的层面。
在截止日期之前,进行全班或大型群组的解决方案讨论是不允许的。截止日期过后,我们通常会发布官方解答,届时大家可以自由讨论各自的方法。
算法的重要性与课程目标
为什么CS341算法课程对你很重要?答案可能因人而异。从我的角度来看,算法是计算机科学的核心,经常出现在后续课程中,并且在技术面试中占据主导地位。掌握这些材料将使你的面试轻松许多。
设计算法也是一项极具创造性的工作,对于许多有趣的职位非常有用。虽然毕业后你可能不会直接实现基础算法,但在一些更有趣的工作中,你可能需要调整现有的基础算法以适应新的场景或约束,这能催生出许多真正有趣的工作。当然,这也是所有计算机科学学生的必修课。
什么是计算问题与算法?
非正式地说,一个计算问题是对输入和期望输出的描述。而算法,非正式地说,是一个定义明确的过程或一系列步骤,用于解决该计算问题,即接收输入并产生输出。算法是连接输入和输出的桥梁。
存在不同性能水平的算法。我们可以设计出性能越来越好的、更快的算法。当然,还有算法的正确性问题,有些算法可能根本不正确。
让我举几个你可能想解决的计算问题的例子:
- 排序:输入是一个任意顺序的整数数组,期望输出是同一个按递增顺序排列的整数数组。
- 矩阵乘法:输入是两个 n x n 矩阵 A 和 B,输出是矩阵 C,即 A 和 B 的乘积。
- 旅行商问题:输入是一组城市 S 以及每对城市之间的距离,输出是访问每个城市并返回起点的最短可能路径。这个问题在包裹配送、外卖路线规划等领域有应用。
这些问题可能具有截然不同的复杂度,我们需要分析这些问题的解决方案以确定其优劣。
算法分析与复杂度
你编写的每个软件程序都会使用某种资源。资源有多种类型:
- CPU指令:我们通常称之为时间。技术上并非所有指令执行时间相同,但为了简化分析,我们倾向于将所有指令视为相同并占用单位时间,因此我们称之为运行时分析。
- 内存:指RAM(而非磁盘空间),我们称之为空间使用量。
- 其他资源:如I/O、网络带宽、消息、锁和并发软件的同步开销等,本课程通常不讨论。
分析是研究算法使用多少资源或在何种程度上使用资源。我们通常使用大O表示法进行分析,以忽略常数因子。常数因子不如算法复杂度随问题规模增长而变化的方式有趣,因为今天我们可能解决特定规模的问题,而未来可能需要解决规模大得多的问题。算法能否扩展到未来使用,或者我们是否需要设计全新的算法,很大程度上取决于其复杂度随输入规模增长的变化方式。
我们可以大致将算法分类为:串行 vs. 并行、确定性 vs. 随机化、精确 vs. 近似等。在本课程中,我们主要讨论算法领域中的一个特定部分。
- 串行算法:单个执行线程,一次执行一条指令。
- 并行/并发算法:多个执行线程,同时执行多条指令。
- 确定性算法:给定相同输入,总是产生相同输出。
- 随机化算法:多次运行相同输入可能产生不同输出(需注意随机数种子等细节)。
- 精确算法:产生问题的精确(通常是最优)解。
- 近似算法:产生接近解的结果。
在本课程中,我们通常涵盖串行、确定性、精确算法。如果时间允许,课程后半段由Anna主讲的课程可能会涉及这个分类中的其他有趣领域。
可解性与P vs NP问题
到目前为止,我讨论的都是我们可以找到解决方案的问题。但可解性探讨的是:是否所有问题都有快速的解决方案?是否可能高效地解决所有问题?或者,是否可能解决它们?
对于某些问题,例如之前提到的旅行商问题,我们目前只找到了指数时间算法。等等,这似乎是一个许多公司(如配送司机调度)正在大规模解决的非常实际的问题。当我们只知道指数时间的精确算法时,他们是如何做到每辆车每天可能有10个配送点的大规模调度呢?答案是:我们只知道指数时间的精确算法,但实际上存在快速的近似算法,可以证明产生的结果在最优解的某个常数倍范围内。为了能在调度卡车出发的时间内获得解决方案,我们当然需要牺牲解的精确性。
有些问题,我们尝试寻找解决方案很久了,但一直未能找到快速解法。我们猜想,也许解决这些问题只有指数时间算法。这些算法效率非常低。以旅行商问题为例,非正式地说,指数时间意味着在你的配送路线上增加一个站点,计算最优路线所需的时间就会翻倍。这使得解决现实世界规模的输入变得极其不可行。
因此,可解性真正的问题是:是否存在绕过这一限制的方法?或者,关键的是,我们是否应该停止尝试,而将精力集中在近似算法上?关于可解性最著名(或至少最流行)的开放问题之一就是 P vs NP 问题。非正式地说(我们将在课程后期更正式地讨论),它探讨的是是否可能在多项式时间内解决此类问题,或者其中一些问题是否确实无法在多项式时间内解决,而必须在指数时间内解决。
课程涵盖主题
一方面,我们将讨论针对可解问题的基础且快速的算法。这些是我们知道如何找到快速解决方案的问题。其中一些算法你可能听说过,如归并排序;另一些如Strassen矩阵乘法,你可能没听说过。
我们还将讨论一些图算法,例如广度优先搜索和深度优先搜索(你们可能以前学过)、用于在加权图中寻找最短路径的Dijkstra单源最短路径算法,可能还有Bellman-Ford算法。对于所有节点对最短路径,我们将讨论Floyd-Warshall算法。对于构建最小生成树,我们将讨论Kruskal算法或Prim算法。我们还将讨论拓扑排序,用于对有依赖关系的图进行排序。这只是我们将要涵盖的基础算法的一小部分示例。
更重要的是,我们将涵盖常见的算法设计范式。我认为,不仅理解计算机科学界公认重要且有助于展示有用算法技术的一套标准基础算法很有用,积累算法技术工具包并接触多种不同的算法设计范式也很有用。所有这些算法在某种程度上都属于一个或两个常见的算法设计范式。如果你想进行任何算法设计,或者修改、扩展现有算法使其适应新用途,掌握这些范式至关重要。
我们将考虑的几种设计范式包括:
- 分治法:尝试递归地将问题分解为更小的子问题,然后依赖递归算法解决子问题,创造性体现在将子问题的解组合成整个问题的解。
- 贪心法:它以一种“贪心”的方式处理问题,根据当前掌握的局部信息(即你试图解决的问题空间的受限信息),选择看似正确的步骤。它不会探索所有可能的解决方案,也不会探索在给定时间点可以做出的所有可能决策以选择最优的,而是根据某种启发式方法选择解决问题的下一步。这就是为什么我们称之为“贪心”,因为它只是选择下一步的启发式方法。我们基本上是启发式地逐步构建解决方案,而证明这种局部启发式方法能产生最优解通常是这些算法中微妙的部分。
- 动态规划:通常不是“贪心”的,因为我们总是在探索选择下一个元素添加到解决方案中的所有可能方式,我们审视构建最优解的所有可能的下一步,并选择最优的。因此,我们的解决方案不依赖于局部启发式决策,而是依赖于对所有可能决策的仔细、系统性的探索,并选择可证明最优的那个。动态规划算法通常比贪心算法更微妙一些,但证明过程更直接。我认为动态规划兼具分治法和贪心法的元素,是一个有趣的范式。
- 穷举搜索/暴力法:如果你没有深入研究算法学,通常会想到这类算法。它们是最显而易见的解决方案:尝试所有可能的解,然后选出最优的。

我们还将投入时间学习用于分析算法的数学工具。想出一个算法并证明它能正确解决问题是一回事;能够向自己或他人论证你的解决方案运行速度足够快,或消耗的内存足够少,从而能在合理的时间或使用合理的计算硬件上运行并获得解决方案,则是另一回事。
我们将学习一系列分析算法的工具:
- 大O表示法:你们以前学过,但我们会回顾并扩展知识,可能引入小ω和小o表示法。
- 求解递推关系:这是一种数学工具,使得分析比简单循环和直线代码更复杂的算法(例如涉及递归的算法)成为可能。你需要像递归树法、代入法或主方法这样的工具来解决描述其复杂度的递推关系。
- 证明贪心算法最优性的工具:如交换论证和“贪心选择保持领先”论证。
在课程后期,我们将研究难解性问题。我们将仔细研究 P vs NP:可在多项式时间内解决的问题类 P,与可在非确定性多项式时间内解决的问题类 NP。我们将研究这两类问题,并研究属于这些不同问题类的一系列示例问题。我们将研究多项式时间归约(一种证明方法:如果你能在多项式时间内解决一个问题,那么通过将第一个问题的解决方案作为黑盒使用,你就能在多项式时间内解决另一个问题)。我们还将研究可判定性问题,即一个问题是否实际上有解,是否是可判定的。
课程收获
我希望你们能从CS341课程中学到什么?你们在学习CS341之前已经具备了相当多的计算机科学知识,学习过像CS240这样的课程,学习过数据结构、数学和各种选修课。但在没有对算法进行深入研究之前,你们的知识就像缺了一口的鸡蛋。我希望CS341能填补这个空缺,让你的知识变得完整。
你将带着以下收获离开:
- 一系列基础算法的知识:这些算法恰好也是求职面试中可能被问到的内容。
- 基本设计范式的知识:这对于构建自己的算法、调整现有算法,或者仅仅是在识别广泛模式以帮助你分类想要解决的问题,并将其与你见过的以类似方式解决的现有问题类别联系起来,都非常有用。
- 可解性与难解性的研究:这非常有用,因为即使在工业界工作中,你也 surprisingly 容易遇到一些你非常想解决,但结果发现(至少如果你想得到精确解的话)实际上无法解决的问题。能够识别出这是一个无法精确解决的问题类型是非常好的,因为你不希望永远徒劳地尝试解决它,而应该能够识别出你不可能精确解决它,并尝试找到某种近似解决方法。另一方面,有些问题可以解决,只是效率低下。也许未来某个极其聪明的人会找到在次指数时间内解决这些问题的方法,但目前活着的人中没有人知道如何在次指数时间内解决它们,而且我们某种程度上怀疑它们无法在次指数时间内解决。对于这些问题,你同样希望能够识别出你试图解决的问题实际上是其中之一,即所谓的NP完全或NP难问题。能够进行算法分析的数学技巧也很有用,因为当你编写一个算法时,能够快速进行一些粗略计算并说服自己它将在10秒还是五年内运行完毕,这是很好的。
我希望你们能带着所有这些知识和技能,成为一个快乐且完整的“鸡蛋”,自信地能够在现实世界中应用算法技术。
上一节我们介绍了课程的整体框架和算法设计的重要性,本节中我们来看看如何通过一个具体问题来实践这些概念。
Bentley‘s 问题(最大子数组问题)
好了,背景介绍得差不多了,让我们来讨论一下Bentley‘s问题。让我们通过这个实例来演示之前提到的几种算法设计范式。
什么是Bentley‘s问题?
给定一个包含 n 个整数的数组 A[1..n] 作为输入,你需要找到连续项之和的最大值。
让我们看几个例子来演示一下:
- 示例1:数组
[1, 7, 4, 0, 2, 1, 3, 1],所有元素非负。显然,最大连续和就是整个数组的和:19。 - 示例2:数组
[-1, -7, -4, -2, -1, -3, -1],所有元素为负。在这种情况下,最大连续和是不取任何元素的和:0。 - 示例3:数组
[-7, 4, 0, 2, -1, 3, -1]。我们不想包含开头的 -7。取[4, 0, 2]得到 6。如果包含后面的 -1 得到[4, 0, 2, -1]和为 5,再包含 3 得到[4, 0, 2, -1, 3]和为 8,这是最优解。
对于这些小型示例,很容易看出最优解。但如果 n 是十亿或百亿,突然之间决定最大和就变得相当困难了。
解决方案一:暴力法(三次方)
如果你被要求解决这个问题,可能会先想到最简单的解决方案。以下是最直接的暴力解法。
思路:尝试数组的每一个可能的子区间(连续项),计算其和,然后找出最大值。
伪代码:
max_sum = 0
for i = 1 to n:
for j = i to n:
sum = 0
for k = i to j:
sum = sum + A[k]
if sum > max_sum:
max_sum = sum
return max_sum
时间复杂度分析:三重嵌套循环。外层 i 循环执行 n 次。对于每个 i,中层 j 循环大约执行 n-i+1 次。对于每对 (i, j),内层 k 循环执行 j-i+1 次。总体时间复杂度是 O(n³)。
解决方案二:优化的暴力法(平方)
我们可以改进上面的暴力法。注意到在固定 i 的情况下,当 j 递增时,我们不需要每次都从头开始求和。
思路:在遍历 j 时,累加 A[j] 到当前和即可。
伪代码:
max_sum = 0
for i = 1 to n:
sum = 0
for j = i to n:
sum = sum + A[j] // 累加当前元素
if sum > max_sum:
max_sum = sum
return max_sum
时间复杂度分析:两重嵌套循环。外层 i 循环 n 次,内层 j 循环平均约 n/2 次。总体时间复杂度是 O(n²)。这比 O(n³) 好得多,对于更大的问题规模(如千万级别)是可行的,虽然可能较慢。
解决方案三:分治法(O(n log n))
我们可以做得更好,但需要使用更复杂的技术。第三种解决方案采用分治法。
思路:
- 将数组
A从中间分成左右两半(假设长度为 2 的幂以简化分析)。 - 最大和子数组的位置有三种可能:
- 情况1:完全位于左半部分。
- 情况2:完全位于右半部分。
- 情况3:跨越中间分界线。
- 递归地解决左半部分和右半部分的问题(情况1和2)。
- 用一种线性时间的方法找到跨越中点的最大和子数组(情况3)。
- 最终答案是这三种情况中的最大值。
关键点:如何线性时间找到跨越中点的最大和?
- 对于左半部分,从中点开始向左扫描,找到以中点为右端点的最大和(即
max_left_sum = max( sum(A[i..mid]) ),其中i从mid递减到1)。 - 对于右半部分,从中点+1开始向右扫描,找到以中点+1为左端点的最大和(即
max_right_sum = max( sum(A[mid+1..j]) ),其中j从mid+1递增到n)。 - 那么,跨越中点的最大和就是
max_left_sum + max_right_sum。
证明:假设存在一个跨越中点的子数组 A[i‘..j’] 具有更大的和。那么它的左半部分和或右半部分和必须大于我们找到的 max_left_sum 或 max_right_sum 之一,但这与我们找到 max_left_sum 和 max_right_sum 的方式矛盾。因此,max_left_sum + max_right_sum 就是跨越中点的最大和。
伪代码概述:
function max_subarray(A, low, high):
if low == high: // 基本情况
return max(0, A[low])
mid = (low + high) / 2
left_max = max_subarray(A, low, mid) // 递归解决左半部分
right_max = max_subarray(A, mid+1, high) // 递归解决右半部分
cross_max = find_max_crossing(A, low, mid, high) // 线性时间找跨越中点的和
return max(left_max, right_max, cross_max)
时间复杂度分析:这引出了递归式 T(n) = 2T(n/2) + O(n)。使用主定理或递归树法可得,时间复杂度为 O(n log n)。这非常高效,类似于排序算法的复杂度。
解决方案四:动态规划(线性时间)
最后,让我们看一个基于动态规划的解决方案。这个方案初看可能有些神奇,但它是最高效的。
思路:
我们定义两个递推关系:
include[j]:表示数组前缀A[1..j]中,必须包含元素 A[j] 的最大连续子数组和。exclude[j]:表示数组前缀A[1..j]中,必须不包含元素 A[j] 的最大连续子数组和。
观察:如果我们能计算出所有 j 的 include[j] 和 exclude[j],那么整个数组的最大连续和就是 max(include[n], exclude[n])。因为任何子数组要么包含最后一个元素,要么不包含。
递推关系(状态转移方程):
- 基础情况:
include[1] = A[1](只有一个元素且必须包含它)exclude[1] = 0(只有一个元素且必须排除它,空子数组和为0)
- 递推情况:
include[j] = max( A[j], A[j] + include[j-1] )- 解释:以
A[j]结尾的最大和,要么是A[j]自己单独作为一个子数组,要么是A[j]接上以A[j-1]结尾的最大和子数组(include[j-1])。
- 解释:以
exclude[j] = max( include[j-1], exclude[j-1] )- 解释:在
A[1..j]中不包含A[j]的最大和,其实就是A[1..j-1]中的最大和(可能包含也可能不包含A[j-1]),即include[j-1]和exclude[j-1]的较大者。
- 解释:在
计算与空间优化:
我们可以用两个数组来计算,但注意到计算 include[j] 和 exclude[j] 只需要 include[j-1] 和 exclude[j-1]。因此,我们可以只用两个变量来迭代,将空间复杂度从 O(n) 降低到 O(1)。
优化后的伪代码:
include = A[1]
exclude = 0
for j = 2 to n:
// 注意:计算新exclude需要旧的include,计算新include需要旧的include
new_exclude = max(include, exclude)
new_include = max(A[j], A[j] + include)
exclude = new_exclude
include = new_include
return max(include, exclude)
时间复杂度分析:只有一个简单的循环遍历数组,每次循环执行常数时间操作。因此,时间复杂度是 O(n),空间复杂度是 O(1)(除了输入数组)。
性能对比与总结
让我们比较一下这些算法的理论性能,并看看实际意义。
以下是基于旧系统(Pentium II)的实测和推测数据:
- O(n³) 算法:对于 n=10⁷,推测需要约 140,000 年。
- O(n²) 算法:对于 n=10⁷,需要约 2.5 小时。
- O(n log n) 算法:对于 n=10⁷,需要约 2 秒。
- O(n) 算法:对于 n=10⁷,需要约 0.5 秒。
这些数据表明,算法设计的选择至关重要。O(n³) 的算法即使对于现代硬件也是不可行的。同时,我们也注意到 O(n log n) 的分治算法在实践中可能已经“足够好”,不一定非要追求理论最优的线性算法。
本节课总结:
在本节课中,我们一起学习了CS341算法课程的介绍,包括课程安排、学术规范以及算法在计算机科学中的核心地位。我们深入探讨了最大子数组问题(Bentley‘s Problem),并展示了四种不同的解决方案:
- 三次方暴力法:最直观但效率极低。
- 平方暴力法:简单优化,效率提升但仍不理想。
- 分治法:采用递归“分而治之”的策略,达到 O(n log n) 的效率,是显著的飞跃。
- 动态规划法:通过定义状态和递推关系,以 O(n) 的线性时间高效解决问题,是理论上的最优解。

通过这些例子,我们初步领略了算法设计范式(如暴力、分治、动态规划)的威力,以及算法分析(复杂度计算)的重要性。理解不同算法在效率上的巨大差异,是设计高效软件系统的关键第一步。在接下来的课程中,我们将系统性地学习这些设计范式和分析工具。
003:算法分析基础
在本节课中,我们将学习算法分析的核心数学工具,包括大O记法、极限法则、求和公式以及对程序运行时间的分析方法。这些内容是理解和设计高效算法的基础。
大O记法回顾与扩展
上一节我们介绍了课程的基本信息,本节中我们来看看算法分析中最核心的工具——大O记法及其相关概念。
形式化定义
函数 f(n) 属于 O(g(n)) 的定义是:存在正常数 c 和 n₀,使得对于所有 n ≥ n₀,都有 0 ≤ f(n) ≤ c·g(n)。直观上,这意味着 f 的复杂度至多是 g 的复杂度。
函数 f(n) 属于 Ω(g(n)) 的定义是:存在正常数 c 和 n₀,使得对于所有 n ≥ n₀,都有 0 ≤ c·g(n) ≤ f(n)。直观上,这意味着 f 的复杂度至少是 g 的复杂度。
函数 f(n) 属于 Θ(g(n)) 的定义是:存在正常数 c₁, c₂ 和 n₀,使得对于所有 n ≥ n₀,都有 0 ≤ c₁·g(n) ≤ f(n) ≤ c₂·g(n)。这意味着 f 和 g 的复杂度在常数因子内相同。
小o与小ω记法
除了常见的大O记法,我们还需要了解小o和小ω记法。
函数 f(n) 属于 o(g(n)) 的定义是:对于所有正常数 c,都存在一个 n₀,使得对于所有 n ≥ n₀,都有 0 ≤ f(n) ≤ c·g(n)。这意味着 f 的渐进复杂度严格低于 g 的复杂度,它们相差不止一个常数因子。
函数 f(n) 属于 ω(g(n)) 的定义是:对于所有正常数 c,都存在一个 n₀,使得对于所有 n ≥ n₀,都有 0 ≤ c·g(n) ≤ f(n)。这意味着 f 的渐进复杂度严格高于 g 的复杂度。
关系与练习
以下是关于这些记法之间关系的一些练习,你可以尝试判断其正误。
- n² ∈ O(n³) 且 n² ∈ o(n³)
- n³ ∉ ω(n³)
- log n ∈ o(n)
- n log n ∈ Ω(n) 且 n log n ∈ ω(n)
- n log(n²) ∈ Θ(n log n)
- n ∉ Θ(n log n)
直观上,这些记法对应着不同的增长率比较关系:
- f ∈ O(g):f 的增长率 ≤ g 的增长率。
- f ∈ o(g):f 的增长率 < g 的增长率。
- f ∈ Ω(g):f 的增长率 ≥ g 的增长率。
- f ∈ ω(g):f 的增长率 > g 的增长率。
- f ∈ Θ(g):f 的增长率 = g 的增长率。
从第一性原理证明复杂度关系
理解了定义后,我们来看看如何运用这些定义(第一性原理)来证明函数的复杂度关系。
证明 f(n) = n² - 7n - 30 ∈ O(n²)
根据定义,我们需要找到正常数 c 和 n₀,使得对所有 n ≥ n₀,有 0 ≤ n² - 7n - 30 ≤ c·n²。
选择 c = 1。不等式 n² - 7n - 30 ≤ n² 对所有 n ≥ 0 成立。同时,n² - 7n - 30 ≥ 0 在 n ≥ 10 时成立(因式分解得根为10和-3)。因此,取 n₀ = 10 和 c = 1 即可满足定义。
证明 f(n) = n² - 7n - 30 ∈ Ω(n²)
我们需要找到正常数 c 和 n₀,使得对所有 n ≥ n₀,有 0 ≤ c·n² ≤ n² - 7n - 30。
选择 c = 1/2。不等式 0 ≤ (1/2)n² 对所有 n ≥ 0 成立。不等式 (1/2)n² ≤ n² - 7n - 30 等价于 (1/2)n² - 7n - 30 ≥ 0。求解该二次不等式,得到两个根均小于18。因此,取 n₀ = 18 和 c = 1/2 即可满足定义。结合 O(n²) 的证明,可知 f(n) ∈ Θ(n²)。
证明 f(n) = n² + n ∉ O(n)
根据定义,f(n) ∈ O(n) 意味着:∃ c>0, n₀>0, ∀ n ≥ n₀: 0 ≤ n² + n ≤ c·n。
其否定为:∀ c>0, n₀>0, ∃ n ≥ n₀: n² + n < 0 或 n² + n > c·n。
由于对于正数 n,n² + n 总是正数,我们只需证明后一种情况。
对于任意给定的 c 和 n₀,我们总能找到一个足够大的 n 使得 n² + n > c·n。整理得 n² + n - c·n > 0,即 n(n + 1 - c) > 0。由于 n > 0,这等价于 n > c - 1。
因此,只需取 n = max(n₀, c),即可满足 n ≥ n₀ 且 n > c - 1,从而证明 f(n) ∉ O(n)。
函数增长率比较与极限方法
为了比较不同函数的增长率,我们可以使用极限方法。
常见函数增长率排序(由慢到快)
以下是算法分析中常见的函数类型,按渐进增长率从低到高排列:
- 常数:O(1)
- 对数函数:O(log n)
- 多对数函数:O((log n)^k),其中 k 是常数
- 根式函数:O(n^c),其中 0 < c < 1
- 线性函数:O(n)
- 线性对数函数:O(n log n)
- 多项式函数:O(n^c),其中 c > 1
- 指数函数:O(c^n),其中 c > 1
- 阶乘函数:O(n!)
- 指数指数函数:O(n^n)
多项式函数和指数函数之间存在着巨大的效率鸿沟,通常将多项式时间算法视为“可处理的”,而指数时间算法视为“难处理的”。
使用极限比较增长率
对于在 n 足够大时为正的函数 f(n) 和 g(n),考虑极限 L = lim_{n→∞} f(n)/g(n):
- 若 L = 0,则 f(n) ∈ o(g(n))。
- 若 L = c (0 < c < ∞),则 f(n) ∈ Θ(g(n))。
- 若 L = ∞,则 f(n) ∈ ω(g(n))。
计算极限时,需要用到微积分中的法则,例如和、积、商的极限法则,以及洛必达法则(用于处理 0/0 或 ∞/∞ 型未定式)。
示例1:比较 n² - 7n - 30 与 n²
lim_{n→∞} (n² - 7n - 30) / n² = lim_{n→∞} (1 - 7/n - 30/n²) = 1。结果为正常数,故两者为 Θ 关系。

示例2:比较 (ln n)² 与 √n
lim_{n→∞} (ln n)² / √n = (∞/∞ 型,使用洛必达法则)
= lim_{n→∞} (2 ln n * (1/n)) / ((1/2) n^{-1/2}) = lim_{n→∞} (4 ln n) / √n = (再次使用洛必达法则)
= lim_{n→∞} (4/n) / ((1/2) n^{-1/2}) = lim_{n→∞} 8 / √n = 0。
结果为 0,故 (ln n)² ∈ o(√n)。
求和、序列与对数规则
在分析循环和递归算法时,求和公式与序列知识至关重要。
求和与O记法的代数规则
- 最大值规则:O(f(n) + g(n)) = O(max(f(n), g(n)))。对于 Θ 和 Ω 同理。
- 求和规则:∑_{i=a}^{b} O(f(i)) = O(∑_{i=a}^{b} f(i))。对于 Θ 和 Ω 同理。这在循环分析中非常有用。
常见序列求和公式
以下是分析算法时常用的求和公式:
- 等差数列:∑_{i=0}^{n-1} (a + d·i) = n·a + d·n(n-1)/2 ∈ Θ(n²) (若 d ≠ 0)。
- 等比数列:∑_{i=0}^{n-1} a·r^i。
- 若 r > 1: Θ(r^n)
- 若 r = 1: Θ(n)
- 若 0 < r < 1: Θ(1)
- 等差-等比数列:∑_{i=0}^{n-1} (a + d·i) r^i (r ≠ 1),有特定公式。
- 调和级数:∑_{i=1}^{n} 1/i ∈ Θ(log n)。
- p级数 (p>1):∑_{i=1}^{n} 1/i^p ∈ Θ(1) (因为其收敛)。
- 幂次和:∑_{i=1}^{n} i^c ∈ Θ(n^{c+1}),其中 c 是常数。
对数规则
掌握对数运算规则对简化表达式很有帮助:
- log(xy) = log x + log y
- log(x/y) = log x - log y
- log(x^y) = y log x
- log_b a = 1 / (log_a b)
- 换底公式:log_b a = (log_c a) / (log_c b)
- a^{log_b c} = c^
重要说明:在算法的大O记法中,我们通常省略对数的底数,因为对于常数底数 b 和 c,有 log_b n = (1 / log_c b) · log_c n,而 (1 / log_c b) 是常数,因此在 Θ 记法中可以忽略。即,所有常数底的对数函数都是同阶的。
运行时间分析与循环
本节我们将应用前面的数学工具来分析算法的运行时间。
运行时间定义
- 运行时间 T_M(I):程序 M 在输入实例 I 上运行所花费的时间(或执行的基本操作数)。
- 最坏情况运行时间 T_M(n):对于所有规模为 n 的输入 I,T_M(I) 的最大值。即 T_M(n) = max_{|I|=n} T_M(I)。本课程主要关注最坏情况分析。
- 平均情况运行时间:对于所有规模为 n 的输入 I,T_M(I) 的平均值。计算通常更困难。
计算模型:字RAM模型
为了进行理论分析,我们需要一个简化的计算模型。通常使用字RAM模型:
- 内存由“字”组成,每个字可存储一个整数(通常假设足够大以存储指针或合理大小的数据)。
- 访问任何内存字需要常数时间。
- 基本运算(算术、逻辑、赋值、比较等)需要常数时间。
循环分析策略
分析包含循环的代码时,可遵循以下步骤:
- 识别代码中的基本操作(常数时间步骤)。
- 将循环转换为求和表达式。
- 计算或简化求和式,得到关于输入规模 n 的函数 T(n)。
- 确定 T(n) 的渐进界(大O、大Ω、大Θ)。
有两种主要策略来得到 Θ 界:
- 策略一(分离边界):分别证明 T(n) ∈ O(g(n)) 和 T(n) ∈ Ω(g(n)),从而得出 T(n) ∈ Θ(g(n))。
- 策略二(全程Θ):在分析每一步时都使用 Θ 记法,最终组合得到 T(n) 的 Θ 界。
示例分析1:简单嵌套循环
sum = 0
for i = 1 to n:
for j = 1 to i:
sum = sum + (i - j)^2
print(sum)
外层循环 i 从 1 到 n。对于每个 i,内层循环 j 从 1 到 i,执行常数时间操作。
总时间 T(n) = ∑_{i=1}^{n} ∑_{j=1}^{i} Θ(1) = ∑_{i=1}^{n} Θ(i) = Θ(∑_{i=1}^{n} i) = Θ(n(n+1)/2) = Θ(n²)。
示例分析2:内层循环变量折半
for i = 1 to n:
j = i
while j >= 1:
// 常数操作
j = floor(j / 2)
外层循环 i 从 1 到 n。对于每个 i,内层 while 循环将 j 不断除以 2,直到 j < 1。循环次数为 Θ(log i)。
总时间 T(n) = ∑_{i=1}^{n} Θ(log i) = Θ(log(n!)) = Θ(n log n) (利用斯特林公式近似)。
示例分析3:三重嵌套循环(Bentley问题暴力解法)
maxSum = 0
for i = 1 to n:
for j = i to n:
sum = 0
for k = i to j:
sum = sum + A[k]
if sum > maxSum:
maxSum = sum
该算法计算数组最大子数组和的暴力解。
总时间 T(n) = ∑_{i=1}^{n} ∑_{j=i}^{n} ∑_{k=i}^{j} Θ(1) = ∑_{i=1}^{n} ∑_{j=i}^{n} Θ(j - i + 1) = Θ(∑_{i=1}^{n} ∑_{j=i}^{n} (j - i))。
- 证明 O(n³):由于 j-i ≤ n,且内层求和次数 ≤ n,可得 T(n) ≤ ∑_{i=1}^{n} ∑_{j=1}^{n} n = ∑_{i=1}^{n} n² = n³。故 T(n) ∈ O(n³)。
- 证明 Ω(n³):考虑 i ≤ n/2 且 j ≥ 3n/4 的循环部分。此时 j-i ≥ n/4,且这样的 (i, j) 组合约有 (n/2)(n/4) = n²/8 个,每个对应的内层循环 k 至少有 n/4 次迭代。因此,这部分的工作量至少是 (n²/8)(n/4) = n³/32 ∈ Ω(n³)。故 T(n) ∈ Ω(n³)。
结合两者,得 T(n) ∈ Θ(n³)。
总结

本节课中我们一起学习了算法分析的数学基础。我们回顾并形式化定义了大O、大Ω、大Θ、小o和小ω记法,学习了如何从第一性原理证明函数的复杂度关系。我们探讨了使用极限比较函数增长率的方法,并复习了关键的求和公式、序列知识以及对数运算规则。最后,我们将这些工具应用于程序运行时间分析,特别是循环结构的分析,并通过实例演示了如何推导算法的渐进时间复杂度。掌握这些内容是理解和设计高效算法的第一步。
004:归约
在本节课中,我们将要学习一个核心概念——归约。归约是一种利用一个问题的解决方案来解决另一个问题的方法。这就像在编程中,我们复用已解决的问题方案来处理更复杂的问题。我们将通过具体的例子,如“两数之和”与“三数之和”问题,来深入理解归约的概念和应用。
问题定义与基础概念
首先,我们明确一些基础概念。决策问题是指算法的任务是判断输出是“真”还是“假”。一个问题的实例是指具体的输入。例如,对于“两数之和”问题,一个实例可能是一个数组 [1, 2, 3] 和目标值 5。如果对于某个实例,正确答案是“真”,则称该实例为是实例;反之,则为否实例。
理解归约对于理解问题的复杂性以及不同问题复杂性之间的关系至关重要。虽然这部分内容在课程后期关于“难解性”的章节中会大量使用,但提前引入有助于我们逐步建立直观理解。
两数之和问题
两数之和问题的输入是一个整数数组 A(包含 a1 到 aN)和一个目标整数 T。如果数组中存在两个值(可以是同一个值)之和等于 T,则输出为“真”,否则为“假”。用公式表示,即寻找索引 i 和 j(允许 i = j),使得:
A[i] + A[j] = T
暴力解法
一个简单的暴力解法是使用两层循环遍历所有可能的索引对。
for i in range(1, n+1):
for j in range(i, n+1):
if A[i] + A[j] == T:
return True
return False
该算法的时间复杂度为 O(n²)。
优化解法:排序与二分查找
我们可以通过预处理来优化算法。预处理是指在算法开始时对输入进行修改,以启用更高效的后续计算。对于两数之和问题,我们可以先对数组进行排序。
排序后,对于每个 A[i],我们不再使用内层循环来寻找 A[j],而是寻找值等于 T - A[i] 的元素。这变成了一个搜索问题。在已排序的数组中,我们可以使用二分查找,将每次搜索的时间复杂度从 O(n) 降低到 O(log n)。
优化后的算法步骤如下:
- 对数组
A进行排序。 - 遍历每个索引
i。 - 在排序后的数组
A中使用二分查找,寻找值等于T - A[i]的元素。 - 如果找到,则返回“真”;如果遍历完所有
i都未找到,则返回“假”。
该算法的时间复杂度为:排序 O(n log n) + n 次二分查找 O(n log n) = O(n log n)。
进一步优化:双指针法
实际上,在两数之和问题中,我们可以在排序后使用双指针技术,在线性时间内完成搜索,从而将整体时间复杂度优化到 O(n log n)(排序占主导)。
双指针法的思路是:设置两个指针 i(初始指向数组开头)和 j(初始指向数组末尾)。计算 A[i] + A[j] 的和:
- 如果和等于
T,找到解。 - 如果和大于
T,则将j向左移动(减小和)。 - 如果和小于
T,则将i向右移动(增大和)。
重复此过程直到i和j相遇。如果仍未找到,则无解。
从两数之和到三数之和:归约的引入
上一节我们介绍了两数之和问题的几种解法。本节中,我们来看看一个与之相关但更复杂的问题——三数之和,并学习如何使用归约,借助两数之和的解法来解决它。
三数之和问题的输入同样是一个整数数组 A 和一个目标整数 T。输出为“真”的条件是存在三个值(允许相同)之和等于 T,否则为“假”。即寻找索引 i, j, k,使得:
A[i] + A[j] + A[k] = T
一个自然的想法是:既然我们已经有了解决两数之和的方案,能否用它来解决三数之和呢?这就是归约的核心思想。
归约的概念
假设我们有一个能解决两数之和问题的函数 solve_2sum。我们想要设计一个算法 reduce_3sum_to_2sum 来解决三数之和问题,并且这个算法会将 solve_2sum 作为一个“黑盒子”子程序来调用。这个子程序被称为谕示。
归约的一般模板是:接受三数之和问题的输入,通过某种预处理将其转换为两数之和问题的输入,然后调用 solve_2sum 来处理这个新输入,并根据其返回结果得出三数之和的答案。
如果三数之和问题可以通过这种方式,利用两数之和问题的解法来解决,我们就说 三数之和可以归约到两数之和,记作:3SUM ≤ 2SUM。这个符号可以理解为:解决 2SUM 的方案可以“流入”并用于解决 3SUM 问题。
一个具体的归约方案
以下是一个将三数之和归约到两数之和的具体算法:
def reduce_3sum_to_2sum(A, T):
# 预处理:对于每个可能的第一个元素 A[i]
for i in range(1, len(A)+1):
# 计算新的目标值
new_target = T - A[i]
# 调用两数之和的谕示,判断是否存在两个数之和为 new_target
if solve_2sum(A, new_target) == True:
return True
return False
算法思路:要找到三个数之和为 T,我们可以先固定一个候选数 A[i]。那么问题就转化为:在数组 A 中寻找另外两个数,使得它们的和等于 T - A[i]。这正是两数之和问题。我们遍历所有可能的 A[i],只要有一次 solve_2sum 返回“真”,则三数之和问题答案为“真”。
正确性证明:我们需要证明,对于任意输入 (A, T),存在 i, j, k 使得 A[i] + A[j] + A[k] = T,当且仅当存在某个索引 m,使得 solve_2sum(A, T - A[m]) 返回“真”。这组等价关系可以通过简单的代数重排得到。
时间复杂度分析:该归约算法有一个 O(n) 的循环。每次循环中调用一次 solve_2sum。因此,总运行时间为 n * Time(solve_2sum)。
- 如果
solve_2sum使用暴力解法(O(n²)),则总时间为O(n³)。 - 如果
solve_2sum使用排序后二分查找的解法(O(n log n)),则总时间为O(n² log n)。
更高效的归约:结合优化后的两数之和
我们可以利用更高效的两数之和算法来构建更快的三数之和归约。回顾一下,双指针法可以在 O(n) 时间内解决排序数组上的两数之和问题。
关键洞察是:在我们的归约中,虽然对每个 i 都要调用 solve_2sum,但每次调用的数组 A 是相同的。因此,我们可以将排序这个预处理步骤提到最外层循环之外,只做一次。
优化后的归约算法如下:
- 对输入数组
A进行排序(预处理,O(n log n))。 - 遍历每个索引
i(O(n)次循环)。 - 对于每个
i,在已排序的数组A上,使用双指针法在线性时间O(n)内寻找两个数,使其和等于T - A[i]。 - 如果找到则返回“真”,否则继续。
该算法总时间复杂度为:排序 O(n log n) + n 次线性搜索 O(n²) = O(n²)。这是三数之和问题一个经典且高效的解法。
更多归约示例
上一节我们通过三数之和的例子详细了解了归约的流程。本节中,我们来看看几个其他类型的归约示例,从简单到复杂,以加深理解。
简单归约:乘法归约到平方
假设我们想计算两个整数 X 和 Y 的乘积,但我们有一个“黑盒子”函数 compute_square(z) 可以计算任何整数 z 的平方。我们可以利用以下代数恒等式进行归约:
X * Y = ((X + Y)² - (X - Y)²) / 4
归约算法非常简单:
def reduce_multiply_to_square(X, Y):
sum_sq = compute_square(X + Y)
diff_sq = compute_square(X - Y)
return (sum_sq - diff_sq) // 4
这个归约将乘法问题转化为加法、减法和调用平方谕示的问题。
中等难度归约:三数之和(有目标)归约到三数之和(零目标)
考虑三数之和的一个变种 3SUM0:输入仅为数组 A,目标是判断是否存在三个元素之和为 0。现在,假设我们已有解决 3SUM0 的谕示 solve_3sum0,我们想用它来解决原始的三数之和问题(输入为 A 和 T)。
我们不能直接修改 solve_3sum0 的代码,而是要通过归约,将 (A, T) 的输入转化为 solve_3sum0 能处理的格式。
归约思路:我们想要 A[i] + A[j] + A[k] = T。这等价于 (3*A[i] - T) + (3*A[j] - T) + (3*A[k] - T) = 0(通过两边乘以3并重组项得到)。因此,我们可以构造一个新数组 B,其中每个元素 B[m] = 3 * A[m] - T。然后,调用 solve_3sum0(B)。如果 B 中存在三个元素之和为0,则等价于 A 中存在三个元素之和为 T。
def reduce_3sum_to_3sum0(A, T):
B = [3*x - T for x in A] # 预处理,构造新数组
return solve_3sum0(B) # 调用谕示
复杂归约:三数组三数之和归约到三数之和(零目标)
这是一个更具创造性的归约,展示了如何编码额外的约束。问题 3Array-3SUM0 的输入是三个数组 A, B, C,需要判断是否存在 A[i] + B[j] + C[k] = 0。我们想将其归约到 3SUM0(单数组版本)。
简单的数组拼接 A' = A + B + C 然后调用 solve_3sum0(A') 是错误的,因为 solve_3sum0 可能会从同一个原数组中选取两个或三个元素,这违反了“必须从三个不同数组各取一个元素”的约束。
归约技巧:我们需要修改元素,使得只有当从 A, B, C 中各取一个元素时,它们的和才有可能为0。一个巧妙的方法是给来自不同数组的元素添加不同的“模10余数”。
- 构造新数组:
D: 对于A中每个元素x,令d = 10*x + 1E: 对于B中每个元素y,令e = 10*y + 2F: 对于C中每个元素z,令f = 10*z - 3(等价于+7模10)
- 将
D, E, F拼接成一个大数组A'。 - 调用
solve_3sum0(A')。
原理:乘以10保留了数值的“主体”,而加1、加2、减3则赋予了来自不同数组的元素独特的“标签”(模10余数分别为1、2、7)。三个数之和为0意味着它们模10的和也必须为0。只有选择余数分别为1、2、7(即 1+2+7=10 ≡ 0 mod 10)的三个数,才可能满足条件,而这正好对应了从 A, B, C 中各取一个元素。选择两个或三个来自同一原数组的元素,其模10余数之和不可能为0。
def reduce_3array_3sum0_to_3sum0(A, B, C):
D = [10*x + 1 for x in A]
E = [10*y + 2 for y in B]
F = [10*z - 3 for z in C] # 或 10*z + 7
A_prime = D + E + F
return solve_3sum0(A_prime)
这个归约的正确性需要详细证明两个方向,核心在于论证变换后数组中和为0的三元组,其元素必然来自变换前三个不同的子数组。
归约的类型与总结
本节课中我们一起学习了归约这一核心算法设计技术。我们看到了几种不同结构的归约:
- 像
3SUM ≤ 2SUM这样的归约,在循环中多次调用谕示。 - 像
3SUM ≤ 3SUM0和3Array-3SUM0 ≤ 3SUM0这样的归约,它们将输入实例进行转换,然后只调用一次谕示。在决策问题的语境下,这种归约被称为多对一归约或卡普归约,它在计算复杂性理论中尤为重要。
归约是理解问题内在难度和建立问题间联系的强大工具。通过将新问题转化为已知问题,我们可以复用已有的高效算法,并洞察不同问题在复杂性上的层级关系。在课程后续关于难解性和NP完全性的部分,我们将大量运用这种归约的思想。



总结:本节课我们介绍了归约的概念,即使用一个问题的解法来解决另一个问题。我们以两数之和与三数之和为例,展示了如何设计归约算法、论证其正确性并分析复杂度。我们还探讨了从简单到复杂的多个归约实例,包括乘法归约到平方、三数之和变体间的归约,以及需要创造性编码约束的三数组问题归约。理解归约为我们提供了复用算法和比较问题复杂性的重要框架。
005:分治法入门
在本节课中,我们将要学习算法设计中的一个核心范式——分治法。我们将了解其基本概念、分析其复杂度的方法,并通过归并排序等经典例子来掌握其应用。
什么是分治法?
上一节我们介绍了算法分析的基础,本节中我们来看看分治法的核心思想。
你可能已经知道一些使用分治范式的算法,例如归并排序、快速排序和二分查找。现在,让我们从高层次来概括分治设计策略的特点。
分治法从一个问题实例 I(即算法的输入)开始。我们尝试将这个实例 I 分解成一个或多个更小的问题实例 I₁, I₂, ..., Iₖ。这些更小的实例被称为子问题。通常,我们希望这些子问题的规模比原问题 I 小,例如,规模减半。
在分解出子问题后,我们开始“征服”它们。对于所有子问题,我们递归地求解它们,从而获得一组解,即每个子问题对应一个解。
接下来,我们需要使用某种合适的组合函数,将所有子问题的解合并起来,以找到整个问题实例 I 的最终解 S。其核心思想是,我们通过组合函数处理所有子问题的解,得到一个更大的解 S,然后将其返回。
以下是分治法的原型或模板算法:
- 接受一个问题实例 I。
- 检查 I 是否为问题的基本情况。如果是,则立即返回该基本情况的结果(例如,在二分查找中,数组大小为1或0就是基本情况)。
- 将 I 分解为若干子问题。
- 定义一个数组来存储这些子问题的解。
- 遍历所有子问题,通过递归调用模板算法来计算每个子问题的解。
- 组合所有子问题的解,并返回结果。
如何分析分治算法?
上一节我们介绍了分治法的基本流程,本节中我们来看看如何分析其正确性和复杂度。
正确性分析
分析分治递归算法的正确性,与一般递归问题的正确性分析并无太大不同。这包括以下步骤:
- 证明你的基本情况是正确的。
- 归纳地假设你的子问题被正确求解。
- 证明如果子问题被正确求解,那么我们可以正确地将这些子问题的解组装成更大问题的解。
复杂度分析
分析分治算法的时间或空间复杂度则更为微妙。我们可以直接分析基本情况和组合函数的复杂度(它们通常是常数时间)。如果子问题数量不多,且构造子问题的工作量不大,那么分析构造子问题的时间复杂度也相对容易。
真正的难点在于分析这些递归调用的复杂度。递归调用的复杂度是多少?这将是本课要解决的核心问题。我们将学习使用一种称为递归关系的数学工具来建模这类算法的复杂度,并学习求解递归关系的方法,例如猜测验证法、代入法、递归树法和主定理。
分治法实例:归并排序
为了具体说明分治法,我们从一个熟悉的例子——归并排序的设计开始。选择归并排序是因为大家对其比较熟悉,这样在解释其如何契合分治范式时,就不会被问题本身的细节所干扰。
在归并排序中,问题实例是一个包含 n 个整数的数组 A,我们希望将其按递增顺序排序。问题实例的大小定义为 n,即数组的长度。这很直观,因为 A 是我们的输入。这里我们考虑字 RAM 模型,数组的每个元素是一个字,因此存储输入所需的空间就是 n 个字。
分治步骤
归并排序严格遵循分治模板:
- 分解:将数组 A 分割成两个子数组:左半部分 A_L(包含前
ceil(n/2)个元素)和右半部分 A_R(包含后floor(n/2)个元素)。 - 征服:递归地对这两个子数组调用归并排序,对它们进行排序。
- 合并:当两个子数组排序完成后,我们调用一个
merge函数,通过一次线性扫描将这两个已排序的数组合并成一个完整的已排序数组。
可视化过程
如果我们有一个数组,首先将其对半分割,然后递归地将每一半继续分割成四分之一、八分之一,依此类推,直到得到单个元素。单个元素是基本情况,无需排序。
然后,我们通过合并操作“征服”并沿着递归栈向上返回。例如,我们合并成对的元素,然后将合并后的结果继续向上合并,直到最终合并顶层的两个子数组 A_L 和 A_R,得到最终答案。
合并算法细节
合并两个已排序数组 L 和 R 的算法如下:
- 在每个数组的起始位置放置一个指针。
- 比较两个指针所指的元素。
- 将较小的元素添加到输出数组中,并移动该元素所在数组的指针。
- 重复此过程,直到所有元素都被插入输出数组,然后返回该输出数组。
以下是归并排序和合并函数的伪代码,大致符合之前展示的分治模板:
归并排序伪代码:
function mergeSort(A, n):
if n == 1:
return A
nL = ceil(n/2)
nR = floor(n/2)
A_L = A[0..nL-1]
A_R = A[nL..n-1]
sorted_L = mergeSort(A_L, nL)
sorted_R = mergeSort(A_R, nR)
return merge(sorted_L, sorted_R)
合并函数伪代码:
function merge(L, R):
result = []
i = 0, j = 0
while i < len(L) and j < len(R):
if L[i] <= R[j]:
result.append(L[i])
i = i + 1
else:
result.append(R[j])
j = j + 1
while i < len(L):
result.append(L[i])
i = i + 1
while j < len(R):
result.append(R[j])
j = j + 1
return result
分析归并排序的复杂度
之前提到过分治算法的复杂度分析有些微妙。现在让我们尝试分析归并排序。
- 检查基本情况并返回:常数时间 O(1)。
- 计算
ceil(n/2):常数时间 O(1)。 - 构造两个子数组:这需要复制元素。构造左数组需要 O(n_L) 时间,构造右数组需要 O(n_R) 时间,总计近似为 O(n)。
- 合并操作:对两个数组进行一次线性遍历,总工作量与两个数组的元素总数成正比,最多为 n,因此是 O(n)。
- 递归调用:这是有趣的部分。对规模为
ceil(n/2)和floor(n/2)的子数组进行递归调用的时间,可以用 T(ceil(n/2)) 和 T(floor(n/2)) 来表示。
直观上,归并排序的时间是线性工作加上两次递归调用的时间。因此,我们得到了一个递归的复杂度表达式。
递归关系:核心分析工具
这个图片有助于直观理解什么是递归关系,它是分析递归算法的关键工具。
形式上,假设我们有一个实数无限序列 a₁, a₂, ...。递归关系是一个公式,它用序列中一个或多个前项(a₁ 到 a_{n-1})来表达通项 a_n。例如,如果 a_n 是归并排序在问题规模为 n 时的复杂度,我们可以用归并排序在更小规模上的复杂度来表达它。
递归关系还会指定一个或多个初始值,这些对应于分治算法中的基本情况。
求解递归关系意味着找到一个不涉及任何前项(递归项)的 a_n 的封闭公式。有许多不同的方法可以求解递归关系,本课将首先介绍两种重要的方法:猜测验证法(涉及代入)和递归树法,然后再介绍主定理。
为归并排序建立递归关系
让我们用数学表达归并排序的复杂度。令 T(n) 表示对长度为 n 的数组运行归并排序所需的时间。
- 分解工作:O(n)。
- 合并工作:O(n)。
- 征服工作:两次递归调用,时间为 T(ceil(n/2)) + T(floor(n/2))。
因此,描述规模为 n 的数组上归并排序运行时的递归关系如下:
- 如果 n = 1,则 T(n) = Θ(1)。
- 如果 n > 1,则 T(n) = T(ceil(n/2)) + T(floor(n/2)) + Θ(n)。
这表达了归并排序分治算法的运行时间,但它不是我们通常看到的复杂度形式(如 Θ(n)、Θ(n²) 或 Θ(n log n))。大家可能记得归并排序是 Θ(n log n) 时间。我的主张是,这个递归关系实际上就是 Θ(n log n),只是目前还看不出来。让我们求解其封闭形式来验证。

为了使分析更简单,我们假设 n 是 2 的幂(即 n = 2^k,k 为整数),这样可以忽略取整函数。我们只分析 n 为 2 的幂的情况。
递归树法
递归树法适用于求解形式为 T(n) = a * T(n/b) + f(n) 的递归关系,其中 a 和 b 是常数。它对于函数项是 T(n/2)、T(n/4) 等情况特别有用,而不是 T(n-1)、T(n-2) 等。
递归树法通过构建一个树状图来分析递归调用。假设我们对规模为 n 的输入进行归并排序调用。该调用贡献线性工作加上两次递归调用。我们在图中画出这些递归调用:规模为 n/2 和 n/2,然后这些调用又产生规模为 n/4 的递归调用,依此类推,直到递归树的底部,即基本情况(排序大小为 1 的数组)。
我们考虑每个调用以及递归树每一层所做的工作量。如果我们计算每一层的表达式,然后将所有这些表达式在所有层上求和,就得到了整个递归调用树的总工作量,也就是整个归并排序调用的复杂度。
- 根节点(第 0 层):只有一个调用,问题规模为 n,工作量为 c * n(其中 c 是常数)。
- 第 1 层:有两个调用,每个调用的问题规模为 n/2,每个的工作量为 c * (n/2)。总工作量为 2 * c * (n/2) = c * n。
- 第 2 层:有四个调用,每个调用的问题规模为 n/4,每个的工作量为 c * (n/4)。总工作量为 4 * c * (n/4) = c * n。
- 模式:每一层 i 的总工作量都是 c * n。
那么有多少层呢?我们需要计算将 n 反复除以 2 直到得到 1 的次数。这正好是 log₂ n 次。
因此,总复杂度是 c * n * log₂ n,即 Θ(n log n)。
我们也可以不用画树,而用表格来表示递归树法,列出每一层的节点数、每个节点的问题规模、每个节点的工作量,然后计算每层的总工作量并求和,得到最终答案。表格和图形树是等价的表示方法。
形式化递归树法
对于一个示例递归关系 T(n) = 2T(n/2) + cn(当 n>1),且 T(1) = d(c 和 d 是常数),我们可以用递归树法求解:
- 从值为 T(n) 的单节点树开始。
- 将这棵树生长出两个子节点(因为系数是 2),值分别为 T(n/2)。根节点的值替换为 cn。
- 递归地重复此过程,直到节点接收到基本情况值。
- 对树中每一层的值求和,然后计算所有这些和的总和,即为最终的总复杂度。
猜测验证法(代入法)
猜测验证法在递归调用次数不多(例如,只有单个递归调用如 T(n-1))时特别有用。如果进行代入,表达式不会因更多 T 项而变得更复杂。
假设我们有以下递归关系:
T(n) = T(n-1) + 6n - 5,且 T(0) = 4。
猜测验证法基本上包括猜测封闭形式可能的样子,然后证明你的猜测是正确的。有两种主要方式:
- 代入法(展开法):递归地将 T(n) 的公式代入自身。用 n-1 替换公式中的 n 得到 T(n-1) 的表达式,然后将其代入原公式中的 T(n-1) 项。展开几层后,尝试识别模式,这些模式将帮助你猜测最终的封闭形式。然后,通常用归纳法证明猜测是正确的。
- 凭直觉猜测形式:根据经验,对封闭形式做出有根据的猜测。例如,观察此递归会进行 n 次递归调用,每次贡献大约 6n 的工作量,总和大致是 n² 量级。因此,可以猜测它是一个二次方程,形式为 an² + bn + c,其中 a, b, c 是未知常数。然后,将这些未知常数带入证明中,确定为了使证明成立,这些常数必须取何值。如果不存在这样的常数,则说明猜测错误。
无论采用哪种方法,最后都需要用归纳法证明猜测的封闭形式是正确的。
代入法示例
对于 T(n) = T(n-1) + 6n - 5, T(0)=4:
- 代入一次:T(n) = [T(n-2) + 6(n-1) - 5] + 6n - 5 = T(n-2) + 2*(6n-5) - 6。
- 代入两次:T(n) = T(n-3) + 3(6n-5) - 6(1+2)。
- 观察模式,猜测当展开到基本情况时:T(n) = T(0) + n(6n-5) - 6(1+2+...+(n-1))。
- 代入 T(0)=4 并简化求和公式,得到猜测:T(n) = 3n² - 2n + 4。
- 用归纳法证明此猜测正确。
凭直觉猜测形式示例
猜测 T(n) 具有形式 an² + bn + c。
- 基础步骤:T(0) = a0 + b0 + c = c,必须等于 4,所以 c=4。
- 归纳步骤:假设 guess(n) = T(n),证明 guess(n+1) = T(n+1)。
- 根据定义,T(n+1) = T(n) + 6(n+1) - 5 = guess(n) + 6n + 1。
- 代入 guess(n) = an² + bn + 4,得到 T(n+1) = an² + bn + 4 + 6n + 1 = an² + (b+6)n + 5。
- 同时,guess(n+1) = a(n+1)² + b(n+1) + 4 = an² + (2a+b)n + (a+b+4)。
- 令两者相等,比较系数:
- n² 系数:a = a。
- n 系数:2a+b = b+6 => 2a = 6 => a=3。
- 常数项:a+b+4 = 5 => 3+b+4=5 => b=-2。
- 因此,a=3, b=-2, c=4,猜测为 3n² - 2n + 4,与代入法结果一致。
主定理
在学习了手动求解递归关系的几种技巧后,现在介绍主定理,它为解决许多递归关系提供了明确的公式。我们先从简化版本开始。
简化主定理
假设一个算法的运行时间可以由以下形式的递归关系描述:
- 基本情况:T(1) = d(d 是常数)。
- 递归情况:T(n) = a * T(n/b) + Θ(n^y)。
直观解释:
- a:递归调用的次数。
- n/b:每个递归调用处理的子问题规模(将问题划分为规模为原问题 1/b 的子问题)。
- Θ(n^y):除了递归调用外,在构造子问题和合并解时所做的工作量,y 是常数。
我们要求 a ≥ 1,b ≥ 2,并且为了推导方便,假设 n 是 b 的幂。这个限制在实际使用主定理时可以放宽,结果不变。
令 x = log_b(a)。
那么,T(n) 的渐近复杂度为:
- 如果 y < x,则 T(n) ∈ Θ(n^x)。
- 如果 y = x,则 T(n) ∈ Θ(n^x log n)。
- 如果 y > x,则 T(n) ∈ Θ(n^y)。
直观理解:
- 情况1 (y < x):递归树的“叶节点权重很大”,总复杂度由叶节点层主导。
- 情况2 (y = x):递归树“平衡”,每一层贡献的工作量大致相同,总复杂度是每层工作量乘以层数(log n)。
- 情况3 (y > x):递归树的“根节点权重很大”,总复杂度由根节点的工作量主导。
简化主定理应用示例
以下是几个递归关系,我们可以快速应用主定理求解:
-
T(n) = 2T(n/2) + Θ(n)
- a=2, b=2, y=1。
- x = log_2(2) = 1。
- y = x,属于情况2 => T(n) ∈ Θ(n^1 log n) = Θ(n log n)。(归并排序)
-
T(n) = 3T(n/2) + Θ(n)
- a=3, b=2, y=1。
- x = log_2(3) ≈ 1.585。
- y < x,属于情况1 => T(n) ∈ Θ(n^{log_2(3)})。
-
T(n) = 4T(n/2) + Θ(n)
- a=4, b=2, y=1。
- x = log_2(4) = 2。
- y < x,属于情况1 => T(n) ∈ Θ(n^2)。
-
T(n) = 2T(n/2) + Θ(n^{3/2})
- a=2, b=2, y=3/2=1.5。
- x = log_2(2) = 1。
- y > x,属于情况3 => T(n) ∈ Θ(n^{3/2})。
一般形式主定理
简化主定理要求非递归项是 Θ(n^y) 形式。一般形式主定理则允许非递归项 f(n) 是 n 的任意函数。
递归关系:T(n) = aT(n/b) + f(n),其中 a ≥ 1, b > 1(整数,故 b ≥ 2),n 是 b 的幂。
令 x = log_b(a)。
那么,T(n) 的渐近复杂度为:
- 如果 f(n) ∈ O(n^{x-ε}),对于某个常数 ε > 0(即 f(n) 渐近小于 n^x),则 T(n) ∈ Θ(n^x)。
- 如果 f(n) ∈ Θ(n^x),则 T(n) ∈ Θ(n^x log n)。
- 如果 f(n) ∈ Ω(n^{x+ε}),对于某个常数 ε > 0,且满足正则条件:a f(n/b) ≤ c f(n),对于某个常数 c < 1 和所有足够大的 n,则 T(n) ∈ Θ(f(n))。
一般形式主定理适用于非递归项更复杂的情况,例如 f(n) = n log n。但验证正则条件有时需要额外工作。
使用递归树法处理复杂 f(n)
并非所有具有复杂 f(n) 的递归关系都需要依赖一般主定理。一些递归关系,即使 f(n) 较复杂(如 f(n) = n log n),也可以手工用递归树法求解。
例如,对于 T(n) = 2T(n/2) + n log n,且 T(1)=1,假设 n=2^j。
我们可以构建递归树并计算每一层的工作量:
- 第0层(根):1个节点,工作量 2^j * j = n log n。
- 第1层:2个节点,每个工作量 (2^{j-1}) * (j-1),总工作量 2 * 2^{j-1} * (j-1) = 2^j * (j-1) = n (log n - 1)。
- 第2层:4个节点,总工作量 n (log n - 2)。
- ...
- 叶节点层:2^j = n 个节点,每个工作量 1,总工作量 n。
将所有层的工作量求和,提取公因子 n,得到 n * [1 + (1+2+...+log n)]。等差数列和为 O((log n)²),因此总复杂度为 T(n) ∈ Θ(n (log n)²)。
放宽“n是b的幂”的限制
最后,简要说明如何放宽主定理中 n 必须是 b 的幂这一限制。如果 n 不是 b 的幂,在连续除以 b 时会出现非整数,需要在数学中引入取整函数,这使分析变得复杂。
一种技术是,通过考虑上下界来移除这个限制。例如,为了得到 T(n) 的一个 O 上界,我们可以考虑比 n 大的、最接近的 b 的幂次 b^j(即 b^{j-1} < n ≤ b^j)。由于对于更大的输入,运行时间不会更短,所以 T(n) ≤ T(b^j)。而 T(b^j) 可以直接用主定理求解,结果可以转化为用 n 表示,并且其中的常数因子可以被吸收,最终得到与简化主定理相同的 O 界形式。
类似地,为了得到 Ω 下界,可以考虑比 n 小的、最接近的 b 的幂次 b^{j-1}。结合上下界,就可以得到 Θ 界。这样就证明了即使不假设 n 是 b 的幂,主定理的结论仍然成立。
总结
本节课中我们一起学习了分治法的核心思想与分析技巧。我们从归并排序的实例入手,理解了分治的“分解-征服-合并”范式。为了分析这类递归算法的时间复杂度,我们引入了递归关系这一关键工具。我们深入探讨了两种求解递归关系的方法:递归树法和猜测验证法(代入法)。最后,我们介绍了强大的主定理(包括简化和一般形式),它为解决一大类分治算法的复杂度提供了快速公式。我们还讨论了如何放宽主定理中对问题规模的限制。掌握这些方法,将为分析和设计高效的分治算法奠定坚实基础。
006:分治算法(下)
在本节课中,我们将继续学习分治算法,并探讨其在三个经典问题上的应用:非支配点问题、大整数乘法以及矩阵乘法。我们将看到如何通过巧妙的分解和合并步骤,设计出比朴素算法更高效的解决方案。
非支配点问题 🧭
上一节我们介绍了分治算法的基本框架,本节中我们来看看如何将其应用于一个几何问题:非支配点问题。
问题定义
给定二维平面上的一组点 S。我们说一个点 (x1, y1) 支配 另一个点 (x2, y2),当且仅当 x1 > x2 且 y1 > y2。这意味着被支配的点位于支配点的西南方向。一个点如果没有被集合 S 中的任何其他点支配,则被称为非支配点。我们的目标是找出并输出 S 中的所有非支配点。
朴素算法
以下是解决该问题的朴素算法思路:
对于集合中的每一个点 P,我们检查它是否被任何其他点 Q 支配。如果没有,则 P 是非支配点。
该算法的时间复杂度为 O(n²),对于大规模输入效率较低。
分治算法
我们可以利用一个几何观察来设计更高效的算法:所有非支配点会形成一个从左上到右下递减的“阶梯”形状。
以下是分治算法的步骤:
- 预处理:首先将所有点按照 x 坐标升序排序。这需要 O(n log n) 时间。
- 分解:将排序后的点集 S 分成两个大小近似相等的子集 S1 和 S2,其中 S1 包含 x 坐标较小的前半部分点,S2 包含后半部分点。
- 征服:递归地求解 S1 和 S2 的非支配点集,分别得到结果集 Q 和 R。
- 合并:这是算法的关键。由于 S1 中的所有点都在 S2 中所有点的左侧,因此 S1 中的点不可能支配 S2 中的点。但是,S2 中的点可能支配 S1 中的点。合并时,我们只需要检查 S2 中 y 坐标最大的点(即 R 中的第一个点,因为递归结果按 x 排序后,y 是递减的)能支配 S1 中的哪些点。具体来说,我们从 Q 中移除所有 y 坐标小于
R[0].y的点,然后将剩余的 Q 与 R 合并即可。
合并步骤可以在 O(n) 时间内完成。
算法复杂度分析

设 T(n) 为算法处理 n 个点的时间。我们有:
- 排序: O(n log n)
- 递归: T(n) = 2T(n/2) + O(n)
根据主定理,递归部分的时间复杂度为 O(n log n)。加上排序的代价,总时间复杂度为 O(n log n),优于朴素算法。
大整数乘法 🧮
接下来,我们探讨如何用分治法加速大整数(位数很多,无法用计算机基本数据类型直接表示)的乘法。
问题定义
输入是两个 k 位的正整数 X 和 Y(二进制表示)。输出是它们的乘积 Z,一个最多 2k 位的正整数。
朴素算法
模拟竖式乘法,需要 O(k²) 次单位乘法和加法。
分治算法(朴素分解)
我们可以将每个 k 位数分成两半:
X = Xl * 2^(k/2) + XrY = Yl * 2^(k/2) + Yr
那么它们的乘积可以表示为:
X * Y = (Xl * Yl) * 2^k + (Xl * Yr + Xr * Yl) * 2^(k/2) + (Xr * Yr)
这需要计算 4 个规模为 k/2 的乘法子问题:Xl*Yl, Xl*Yr, Xr*Yl, Xr*Yr。
递归式为:T(k) = 4T(k/2) + O(k)。
根据主定理,其解为 O(k²),与朴素算法相同,没有改进。
卡拉楚巴算法
为了改进,我们需要减少子问题的数量。卡拉楚巴发现了一个技巧,可以用 3 次乘法完成计算:

- 计算
P1 = Xl * Yl - 计算
P2 = Xr * Yr - 计算
P3 = (Xl + Xr) * (Yl + Yr)
注意,P3 = Xl*Yl + Xl*Yr + Xr*Yl + Xr*Yr。
那么,我们需要的中间项 Xl*Yr + Xr*Yl = P3 - P1 - P2。
因此,X * Y = P1 * 2^k + (P3 - P1 - P2) * 2^(k/2) + P2。
现在递归式为:T(k) = 3T(k/2) + O(k)。
根据主定理,其时间复杂度为 O(k^(log₂3)) ≈ O(k^1.585),显著优于 O(k²)。
矩阵乘法 ⬜
最后,我们看看分治算法在矩阵乘法上的应用。
问题定义
给定两个 n×n 的矩阵 A 和 B,计算它们的乘积 C = A × B。
朴素算法
直接使用三重循环计算,时间复杂度为 O(n³)。
分治算法(朴素分解)
我们可以将每个矩阵划分为四个 (n/2)×(n/2) 的子矩阵:
A = | A11 A12 | B = | B11 B12 |
| A21 A22 | | B21 B22 |
那么乘积 C 也可以相应划分:
C = | C11 C12 | = | A11*B11 + A12*B21 A11*B12 + A12*B22 |
| C21 C22 | | A21*B11 + A22*B21 A21*B12 + A22*B22 |
这需要计算 8 个规模为 n/2 的矩阵乘法子问题。
递归式为:T(n) = 8T(n/2) + O(n²)。
根据主定理,其解为 O(n³),没有改进。
施特拉森算法
与卡拉楚巴算法思想类似,施特拉森设计了一种方法,只需进行 7 次 (n/2)×(n/2) 的矩阵乘法。
他定义了 7 个辅助矩阵:
P1 = A11 * (B12 - B22)
P2 = (A11 + A12) * B22
P3 = (A21 + A22) * B11
P4 = A22 * (B21 - B11)
P5 = (A11 + A22) * (B11 + B22)
P6 = (A12 - A22) * (B21 + B22)
P7 = (A11 - A21) * (B11 + B12)
然后,结果矩阵 C 的四个子块可以通过这些 P 矩阵的加法和减法组合得到:
C11 = P5 + P4 - P2 + P6
C12 = P1 + P2
C21 = P3 + P4
C22 = P5 + P1 - P3 - P7
现在递归式为:T(n) = 7T(n/2) + O(n²)。
根据主定理,其时间复杂度为 O(n^(log₂7)) ≈ O(n^2.81),优于朴素立方复杂度。
在实际应用中,当子矩阵规模变小时,通常会切换回朴素算法以获得更好的缓存性能和常数因子优势,因此施特拉森算法常以这种混合形式使用。
总结 📚
本节课中我们一起学习了分治算法在三个不同领域的精彩应用:
- 非支配点问题:通过排序和递归,将 O(n²) 的算法优化为 O(n log n)。
- 大整数乘法(卡拉楚巴算法):通过将 4 个子问题减少为 3 个,将复杂度从 O(k²) 降低到 O(k^1.585)。
- 矩阵乘法(施特拉森算法):通过将 8 个子问题减少为 7 个,将复杂度从 O(n³) 降低到 O(n^2.81)。


这些例子展示了分治算法的核心威力:通过巧妙地设计分解和合并步骤,减少需要解决的子问题数量或规模,从而获得渐进意义上的性能提升。它们也体现了算法设计中“牺牲简单性换取效率”的经典权衡。
007:贪心算法
在本节课中,我们将要学习贪心算法设计范式。我们将了解什么是贪心算法,它们通常用于解决哪类问题,并通过两个经典问题——区间选择与区间着色——来深入理解贪心算法的结构、实现和正确性证明。
概述:什么是优化问题?
在深入贪心算法之前,我们需要理解它们通常用于解决优化问题。一个优化问题包含以下要素:
- 问题实例:问题的输入。
- 约束条件:任何可行解都必须满足的条件。所有满足约束的解的集合构成可行域。
- 目标函数:一个将可行解映射到实数的函数。我们的目标是找到可行解中使该函数值最大化(如利润)或最小化(如成本)的解,这样的解称为最优解。
贪心算法是解决优化问题的一种策略,它通常简单高效,但证明其正确性(即总能找到最优解)往往是更具挑战性的部分。
贪心算法的核心思想与结构
贪心算法从一个空的部分解开始,通过一系列不可撤销的选择逐步构建完整解。在每一步,算法根据一个局部评估准则,从当前可做的选择集中,选择看起来最好的选项,而不考虑该选择对未来决策的全局影响。
以下是贪心算法的形式化描述:
- 部分解:一个由已做选择构成的元组
(x1, x2, ..., xi),它满足所有问题约束。 - 选择集
choice(X):对于当前部分解X,所有能扩展X且不违反约束的下一步选择y的集合。 - 局部评估函数
G(y):一个函数,用于评估选择y带来的局部收益或成本。 - 算法流程:
- 从空的部分解开始。
- 当未得到完整可行解时:
- 从当前选择集
choice(X)中,选择使G(y)最优(最大或最小)的y。 - 将
y加入部分解,形成新的部分解X' = (x1, ..., xi, y)。
- 从当前选择集
- 返回最终构建的可行解
X。
贪心算法的关键特性是不前瞻(不考虑未来选择)和不回退(不撤销已做选择)。其时间复杂度通常为 O(n log n),主要开销在于按 G 进行预处理排序,然后进行一次线性扫描。
问题一:区间选择
上一节我们介绍了贪心算法的通用框架,本节中我们来看看第一个具体问题:区间选择问题。
问题定义
- 输入:一组
n个时间区间A = {a1, a2, ..., an},每个区间ai有开始时间si和结束时间fi(fi >= si)。 - 可行解:一个区间子集
X ⊆ A,其中任意两个区间都不重叠(即互不相交)。 - 最优解:具有最大基数(即包含最多区间)的可行解。
直观上,这可以理解为在一个会议室中安排尽可能多的不冲突会议。

贪心策略尝试
对于此问题,我们可以尝试多种贪心策略:
- 每次选择开始时间最早且不与已选区间重叠的区间。
- 每次选择持续时间最短且不与已选区间重叠的区间。
- 每次选择结束时间最早且不与已选区间重叠的区间。
通过构造反例,可以证明策略1和策略2无法保证得到最优解。例如,策略1可能选择一个很长的早期区间,从而“挡住”多个更短的区间。
正确算法:按结束时间排序
策略3(按结束时间最早选择)是正确的。以下是算法伪代码:
def interval_selection(A):
# 按结束时间 fi 升序排序所有区间
sort A by increasing finish time fi
X = [] # 存储选择的区间
last_finish = -infinity
for interval in A:
if interval.start >= last_finish:
X.append(interval)
last_finish = interval.finish
return X
时间复杂度:排序 O(n log n),扫描 O(n),总计 O(n log n)。
正确性证明:“贪心领先”论证
我们需要证明算法返回的解 X 既是可行的(区间互不相交),也是最优的(区间数最多)。可行性由算法设计保证(只选择开始时间晚于上次结束时间的区间)。最优性证明采用“贪心领先”论证法:
-
定义与假设:
- 设贪心算法选择区间的顺序(按结束时间排序后)为
ai1, ai2, ..., aik。 - 设任意一个最优解
O包含的区间为aj1, aj2, ..., ajl(我们同样按结束时间升序重新排列它们,以便比较)。 - 为导出矛盾,假设最优解优于贪心解,即
l > k。
- 设贪心算法选择区间的顺序(按结束时间排序后)为
-
关键引理:对于任意
m(1 ≤ m ≤ k),有f(im) ≤ f(jm)。即贪心解的第m个区间的结束时间,不晚于最优解中第m个区间的结束时间。这可以通过数学归纳法证明,核心在于:由于ajm的开始时间晚于f(jm-1),且由归纳假设f(jm-1) ≥ f(im-1),因此ajm是贪心算法在第m步时的一个合法候选。贪心算法选择了结束时间最早的合法区间,故其选择的aim的结束时间一定不晚于f(jm)。 -
推出矛盾:
- 根据假设
l > k,最优解有第k+1个区间aj(k+1)。 - 由于区间不重叠,
aj(k+1)的开始时间s(j(k+1)) ≥ f(jk)。 - 根据关键引理,
f(jk) ≥ f(ik)。 - 因此
s(j(k+1)) ≥ f(ik)。这意味着区间aj(k+1)开始于贪心解最后一个区间aik结束之后。 - 但这样一来,
aj(k+1)应该是贪心算法的一个合法候选,且会被选中(因为它是结束时间最早的合法区间之一),这与贪心解只包含k个区间矛盾。
- 根据假设
-
结论:假设
l > k不成立,因此l ≤ k。贪心解包含的区间数不少于任何最优解,故其为最优。


问题二:区间着色
在学习了区间选择问题后,我们来看一个相关但目标不同的问题:区间着色问题。
问题定义
- 输入:与区间选择问题相同,一组
n个时间区间。 - 可行解:为每个区间分配一种颜色,使得任何两个重叠的区间颜色不同。
- 最优解:使用颜色总数最少的可行解。
直观上,这相当于用最少的会议室安排所有会议,每个会议室一种颜色。
贪心算法
算法思路:按开始时间处理区间,每次尝试为当前区间分配一个已有的、不冲突的颜色(即该颜色对应的最后一个区间已结束),若无法分配则创建新颜色。
基础版本伪代码(可能效率较低):
def interval_coloring_basic(A):
sort A by increasing start time si
color = [0] * n # 记录每个区间的颜色
finish_time = [] # finish_time[c] 记录颜色c的最后使用结束时间
d = 0 # 已使用的颜色数量
for i in range(n):
assigned = False
# 尝试复用现有颜色
for c in range(1, d+1):
if finish_time[c] <= A[i].start:
color[i] = c
finish_time[c] = A[i].finish
assigned = True
break
# 无法复用则创建新颜色
if not assigned:
d += 1
color[i] = d
finish_time[d] = A[i].finish
return d, color
时间复杂度:排序 O(n log n),内层循环在最坏情况下(颜色数 d 接近 n)导致总复杂度为 O(n log n + n*d),即 O(n^2)。
正确性证明
我们给出一个简洁的证明,说明算法使用的颜色数 D 是最优的。
- 设算法最终使用了
D种颜色。 - 设
fD是第一个被分配第D种颜色(即最后创建的颜色)的区间。 - 对于每种颜色
c = 1, 2, ..., D-1,考虑在fD结束之前开始的、最后一个被分配颜色c的区间,记为Lc。 - 可以论证,
fD与每一个Lc都重叠。因为如果某个Lc不与fD重叠(即Lc在fD开始前已结束),那么贪心算法在处理fD时本可以复用颜色c,而无需创建新颜色D,这与fD是第一个使用颜色D的区间矛盾。 - 因此,在
fD的开始时刻,存在D个区间(fD和D-1个Lc)同时重叠。这意味着任何合法着色至少需要D种颜色。 - 算法使用了
D种颜色,且D是所需颜色的下界,因此D就是最优解的颜色数。
效率优化:使用优先队列(堆)
基础版本的低效之处在于需要线性扫描所有颜色来寻找可复用的。我们可以维护一个最小堆,其中每个元素是 (结束时间, 颜色),按结束时间排序。这样,最早可用的颜色始终在堆顶。
优化版本伪代码:
def interval_coloring_heap(A):
sort A by increasing start time si
import heapq
heap = [] # 最小堆,元素为 (finish_time, color_id)
next_color = 1
color = [0] * n
# 处理第一个区间
color[0] = next_color
heapq.heappush(heap, (A[0].finish, next_color))
for i in range(1, n):
# 检查堆顶颜色是否可用
if heap and heap[0][0] <= A[i].start:
# 复用颜色
finish, c = heapq.heappop(heap)
color[i] = c
else:
# 创建新颜色
next_color += 1
color[i] = next_color
c = next_color
# 将更新后的颜色放回堆中
heapq.heappush(heap, (A[i].finish, c))
return next_color, color
时间复杂度:排序 O(n log n),每个区间涉及一次堆操作(O(log D)),总复杂度为 O(n log n + n log D)。由于 D ≤ n,最坏情况为 O(n log n),显著优于基础版本。
总结
本节课中我们一起学习了贪心算法。我们首先定义了优化问题及其组成部分。然后,我们阐述了贪心算法的核心思想:通过一系列基于局部最优的、不可撤销的选择来构建解。我们通过两个经典问题进行了深入探讨:
- 区间选择问题:目标是选择最多互不相交的区间。我们证明了按结束时间最早排序的贪心策略是正确的,并使用“贪心领先”法完成了严谨的最优性证明。
- 区间着色问题:目标是用最少的颜色给所有区间着色,使得重叠区间颜色不同。我们给出了按开始时间最早处理,并复用最早可用颜色的贪心算法。我们提供了一个巧妙的证明来说明其最优性,并介绍了使用优先队列来将算法效率优化到
O(n log n)。

贪心算法的设计关键在于找到合适的局部评价准则,而算法的难点和核心往往在于证明其全局最优性。
008:贪心算法(第二部分)
在本节课中,我们将学习两种经典的贪心算法问题:分数背包问题和找零问题。我们将探讨贪心策略的设计、算法的正确性证明,并理解为什么某些问题适合贪心算法而另一些则不适合。
分数背包问题
上一节我们介绍了贪心算法的基本思想。本节中,我们来看看一个具体问题:分数背包问题。
我们有一个背包,可以容纳总重量不超过 M 的物品。有 n 件物品,每件物品 i 有价值 P_i 和重量 W_i。在分数背包问题中,我们可以取走物品的任意一部分(即分数 X_i,其中 0 ≤ X_i ≤ 1)。目标是找到一个可行的解决方案 X,在不超过总重量限制 M 的前提下,最大化总价值 ∑(P_i * X_i)。
贪心策略的尝试
以下是几种可能的贪心策略:
- 策略一:按价值降序选择物品。 总是优先选择价值最高的物品。这个策略可能失败,因为一个价值很高但重量极大的物品会占用过多背包容量,导致总价值不高。
- 策略二:按重量升序选择物品。 总是优先选择最轻的物品。这个策略也可能失败,因为一个很轻但价值极低的物品对总价值的贡献很小。
最优贪心策略
最优的贪心策略是按单位重量价值(价值/重量)降序选择物品。直观上,我们希望用背包的每一单位重量来换取尽可能高的价值回报。
以下是该算法的伪代码:
函数 GreedyRationalKnapsack(P, W, M):
// 预处理:按 P_i / W_i 降序排序物品
将物品按 P_i / W_i 降序排序,得到排序后的数组 A
初始化数组 X[1..n] = 0 // 记录每件物品取走的比例
当前重量 current_weight = 0
对于 i 从 1 到 n:
如果 current_weight + W_i ≤ M:
// 可以取走整件物品
X[i] = 1
current_weight = current_weight + W_i
否则:
// 只能取走一部分来填满背包
X[i] = (M - current_weight) / W_i
current_weight = M
跳出循环
返回 X
算法正确性证明
我们需要证明算法产生的解 X 是可行且最优的。
可行性证明(非正式):
算法确保在每一步,取走的物品比例 X_i 都在 0 到 1 之间。循环结束时,要么所有物品都已考虑,总重量 current_weight ≤ M;要么因为背包被恰好填满而跳出循环,此时 current_weight = M。因此,解 X 总是可行的。
最优性证明(思路):
假设存在一个最优解 Y 与贪心解 X 不同。设 j 是第一个 X_j ≠ Y_j 的位置。可以论证,必有 Y_j < X_j(因为贪心算法会尽可能多地取单位价值高的物品 j)。
由于 Y 是可行解,在 j 之后必然存在某个物品 k(k > j)使得 Y_k > 0(否则 Y 的总价值将低于 X)。然而,由于物品是按单位价值降序排列的,有 P_j/W_j > P_k/W_k。
现在,我们可以构造一个新解 Y‘:从 Y_k 中移除一个微小的重量 δ,并将这部分重量用于增加 Y_j。具体调整如下:
Y‘_j = Y_j + δ / W_jY‘_k = Y_k - δ / W_k- 其他分量与
Y相同。
可以证明,对于足够小的正数 δ,新解 Y‘ 仍然是可行的。更重要的是,新解的总价值变化为 δ * (P_j/W_j - P_k/W_k) > 0。这意味着 Y‘ 的总价值比最优解 Y 还要高,产生了矛盾。因此,假设不成立,贪心解 X 就是唯一的最优解。
找零问题
接下来,我们看看另一个贪心算法的经典应用:找零问题。






问题的输入是一个硬币面额数组 D(例如加拿大硬币系统:[200, 100, 25, 10, 5, 1])和一个目标总额 T。目标是找出凑出总额 T 所需的最少硬币数量,并输出具体的硬币组合。
贪心算法
一个直观的贪心策略是:总是先使用面额最大的硬币。
以下是该算法的伪代码:
函数 GreedyCoinChange(D, T):
// 预处理:将硬币按面额降序排序
将数组 D 按降序排序
初始化数组 used[1..n] = 0 // 记录每种硬币使用的数量
对于 i 从 1 到 n:
used[i] = ⌊T / D[i]⌋ // 尽可能多地使用当前面额的硬币
T = T - used[i] * D[i] // 更新剩余需要凑的金额
如果 T > 0:
返回 “无解” // 无法用给定的硬币凑出目标金额
否则:
返回 used // 返回每种硬币的使用数量
算法分析与正确性
该算法的时间复杂度主要由排序步骤决定,为 O(n log n)。
对于特定硬币系统的正确性:
贪心算法对于某些硬币系统(如包含1分钱的旧版加拿大硬币系统)能产生最优解,但对于另一些系统(如旧版英国硬币系统)则不能。
以旧版加拿大硬币系统(面额:100, 25, 10, 5, 1)为例,我们可以通过归纳法证明贪心算法的最优性。证明的核心在于利用任何最优解都必须满足的几条性质(例如,5分硬币最多1个,1分硬币最多4个等),并分情况讨论(T 在不同面额区间内),论证贪心算法做出的选择(使用最大面额硬币)也必然包含在任何最优解中,从而将问题规约到一个更小的、由归纳假设保证能最优解决的子问题。
硬币系统的性质:
如果一个硬币系统中,每个较大面额都是下一个较小面额的整数倍(例如 [64, 16, 4, 1]),那么贪心算法一定是全局最优的。这是一个充分但不必要的条件。旧版加拿大硬币系统并不完全满足这个条件(25不是10的整数倍),但贪心算法依然最优。
总结
本节课我们一起学习了两个重要的贪心算法问题。
- 分数背包问题:我们找到了最优的贪心策略——按单位重量价值降序选择物品,并理解了其正确性证明的核心矛盾构造法。
- 找零问题:我们分析了“总是使用最大面额硬币”这一贪心策略,认识到其最优性依赖于具体的硬币系统,并通过一个特定系统的证明示例展示了如何利用最优子结构性质进行归纳证明。

这两个例子揭示了贪心算法的威力与局限:它能为某些问题提供简单高效的精确解,但其正确性需要仔细验证,并且不适用于所有类似问题(例如,0-1背包问题就是NP难的,无法用简单贪心法解决)。
009:贪心算法收尾与动态规划入门
概述
在本节课中,我们将要学习如何完善贪心算法的证明,特别是当某些简化假设(如物品单位价值互异)不成立时。随后,我们将正式引入动态规划这一强大的算法设计范式,通过斐波那契数列和钢条切割等例子,理解其核心思想、设计步骤和分析方法。
贪心算法证明的完善
上一节我们介绍了分数背包问题的贪心算法及其在单位价值互异假设下的证明。本节中我们来看看当单位价值可能相等时,如何修改证明。
原证明依赖于单位价值互异的假设,以确保存在唯一的最优解,并通过交换论证得出矛盾。当单位价值可能相等时,最优解可能不唯一,原证明方法失效。
关键思路是:我们不再与一个假定的“唯一”最优解比较,而是从所有最优解中,特意挑选出一个与贪心解 X 在最多索引上匹配的最优解 Y 来进行比较。如果贪心解 X 本身不是最优的,那么 Y 与 X 必然在某些位置不同。
假设 j 是第一个 X 与 Y 取值不同的物品索引。由于贪心算法总是尽可能多地取当前单位价值最高的物品,因此必有 Y_j < X_j。为了保持总重量不变,最优解 Y 必须在某个 k > j 的物品上取比贪心解 X 更多的量,即 Y_k > X_k。
我们的目标不再是直接证明交换能提高总价值(因为 j 和 k 的单位价值可能相等),而是证明我们可以修改 Y,得到一个与 X 在更多索引上匹配的新最优解 Y',从而与 Y 是“匹配索引最多”的最优解这一选择矛盾。
具体操作是:从物品 k 中移除 δ 重量,并添加到物品 j 中。δ 的取值需确保修改后,Y' 在索引 j 或 k 上与 X 变得一致,同时不违反可行性约束(即每个物品的选取量仍在 [0, 1] 之间)。
通过精心选择 δ(取两个可能调整重量中的较小者),我们可以证明新解 Y' 是可行的,并且其总价值 profit(Y') 满足:
profit(Y') = profit(Y) + δ * (P_j/W_j - P_k/W_k)
由于 j 在排序中先于 k,有 P_j/W_j ≥ P_k/W_k,因此上述变化量非负。
如果变化量为正,则 Y' 的总价值高于 Y,与 Y 是最优解矛盾。因此变化量必须为 0,这意味着 Y' 也是一个最优解。但 Y' 比 Y 多匹配了 X 的一个索引(j 或 k),这与我们选择 Y 是匹配索引最多的最优解矛盾。
由此,最初的假设(X 不等于 Y,即 X 非最优)不成立,从而证明了贪心解 X 就是最优解。这个证明技巧通过关注一个特定的、具有某种“极大性”的最优解,巧妙地处理了多解情况。
动态规划简介
上一节我们结束了贪心算法的讨论。本节中我们来看看一种新的算法设计范式——动态规划。在高层次上,动态规划类似于分治法,但通过避免子问题的重复计算,可以带来显著的效率提升。
动态规划的发明者理查德·贝尔曼曾解释其命名缘由,他想要一个能体现“多阶段”、“随时间变化”概念的词,并且“动态”这个词很难被用于贬义,因此选择了“动态规划”。
一个理解动态规划的好方法是将其与分治法对比。我们以一个简单问题——计算斐波那契数列为例。
一个低效的分治解法
以下是计算第 n 个斐波那契数的递归算法:
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
该算法的运行时间 T(n) 满足递归式:T(n) = T(n-1) + T(n-2) + O(1)。可以证明 T(n) 在 Ω(2^(n/2)) 和 O(2^n) 之间。由于输入 n 的二进制表示长度为 S = log n,因此 n 本身是 2^S,算法运行时间是双重指数级 Ω(2^(2^(S-1))) 的,效率极低。
算法低效的根本原因在于存在大量重叠子问题。例如,计算 fib(5) 时需要重复计算 fib(3)、fib(2) 等许多次。这种子问题的重叠性是动态规划大显身手的关键。
动态规划的核心要素
对于优化问题,设计动态规划算法通常遵循以下步骤。对于非优化问题(如斐波那契),可以忽略最优性相关部分。
- 最优子结构:问题的最优解包含其子问题的最优解。即,通过组合子问题的最优解,可以得到原问题的最优解。
- 定义子问题:定义一组子问题,使得原问题的解可以由这些子问题的解构造出来。通常,原问题是子问题集中最大或最后的一个。
- 推导递推关系:建立原问题与子问题解之间的数学关系(即递推式),并确定基础情况。
- 计算最优解(填表):根据递推关系,以自底向上的顺序计算并存储所有子问题的解(通常在一个表格中),确保计算每个问题时,它所依赖的子问题都已求解完毕。最终表格中的某个条目就是原问题的解。
斐波那契数的动态规划解法
现在,我们将上述步骤应用于斐波那契问题。
- 递归结构:第
n个斐波那契数F(n)可以表示为F(n-1)和F(n-2)的和。 - 定义子问题:子问题是计算
F(0), F(1), ..., F(n)。 - 递推关系:
F(0) = 0F(1) = 1F(i) = F(i-1) + F(i-2),对于i ≥ 2
- 自底向上计算:我们创建一个数组
F[0..n],按顺序从i=2到n计算F[i]。由于计算F[i]时,F[i-1]和F[i-2]已经计算好,这满足了自底向上的要求。
以下是伪代码实现:
def fib_dp(n):
if n <= 1:
return n
F = array of size n+1
F[0] = 0
F[1] = 1
for i from 2 to n:
F[i] = F[i-1] + F[i-2]
return F[n]
空间优化:观察发现,计算 F[i] 时只需要前两个值。因此,我们可以用几个变量代替整个数组,将空间复杂度从 O(n) 降为 O(1)。
正确性证明:
- 填表顺序:我们按索引递增顺序填表。计算
F[i]时,所需的F[i-1]和F[i-2]已在前面的迭代中计算并存储,满足自底向上条件。 - 归纳步骤:假设对于所有
j < i,F[j]已正确计算为第j个斐波那契数。根据递推关系F[i] = F[i-1] + F[i-2],以及斐波那契数的定义,F[i]也被正确计算。
运行时间分析:在单位成本模型中,循环 n 次,每次操作常数时间,运行时间为 O(n)。但需要注意输入规模:输入是整数 n,其二进制长度为 S = log n。因此,O(n) = O(2^S) 是指数时间。然而,与之前双重指数级的分治法相比,这已经是巨大的改进。在比特成本模型中,由于斐波那契数增长极快(约按黄金比例指数增长),大整数加法的成本随数字位数线性增长,分析会更复杂,总时间约为 O(n^2),这相对于输入规模 S 仍然是 O(2^(2S)) 指数时间,但远优于分治法。
动态规划技巧与注意事项
以下是分析动态规划算法时的几点提示:

- 计算模型:仔细考虑使用单位成本模型还是比特成本模型。当数值可能非常大时,比特成本模型更合适。
- 输入规模:始终以输入规模
S来表达运行时间。当被问及“是否是线性/平方时间算法”时,指的是相对于S而言。 - 自底向上填表:构建表格并自底向上计算是动态规划的核心。这与记忆化(递归+缓存)技术不同,本课程强调前者。
- 基础情况:基础情况至关重要,它们直接决定了递推的起点和最终结果。优雅地处理基础情况(如通过表格初始化)能使代码更简洁。
动态规划实例:钢条切割
为了展示一个稍复杂的动态规划应用,我们来看钢条切割问题。
问题描述:给定一根长度为 n 的钢条和一个价格表 p[1..n],其中 p[i] 表示长度为 i 的钢条的价格。将钢条切割成若干整数长度段(可以不切割)出售,求最大总收益。
示例:n=4, p=[1,5,8,9]。不切割收益为9;切成两段长度2,收益为5+5=10。因此最优收益为10。
设计动态规划解法
- 最优子结构:假设我们在位置
i进行第一次切割,将钢条分为长度i和n-i的两段。那么,原问题的最优收益等于长度i钢条的最优收益加上长度n-i钢条的最优收益。我们需要尝试所有可能的i来找到最大值。这体现了最优子结构:问题的最优解由相关子问题的最优解组合而成。 - 定义子问题:令
r[k]表示长度为k的钢条能获得的最大收益。我们需要求解r[1], r[2], ..., r[n]。 - 推导递推关系:
- 对于长度为
k的钢条,我们可以选择不切割,直接出售,收益为p[k]。 - 或者,在
i(1 ≤ i ≤ k-1) 处切割,收益为r[i] + r[k-i]。 - 因此,
r[k] = max(p[k], max_{1≤i≤k-1}(r[i] + r[k-i]))。 - 基础情况:
r[0] = 0(长度为0的钢条收益为0)。
- 对于长度为
- 自底向上计算:我们创建一个数组
r[0..n]。计算顺序是从k=1到n。计算r[k]时,需要用到所有r[i]和r[k-i],其中i < k。由于我们按k递增顺序计算,这些值都已准备好。
以下是伪代码实现:
def cut_rod(p, n):
let r[0..n] be a new array
r[0] = 0
for k from 1 to n:
r[k] = p[k] # 不切割的情况
for i from 1 to k-1:
r[k] = max(r[k], r[i] + r[k-i])
return r[n]
运行时间分析:算法包含两层嵌套循环,时间复杂度为 Θ(n^2)。在单位成本模型下,输入规模 S 为 Θ(n)(n 和 n 个价格值),因此 Θ(n^2) = Θ(S^2),这是一个平方时间算法。
总结与预告
本节课中我们一起学习了:
- 如何完善分数背包问题贪心算法的证明,通过选择“与贪心解匹配索引最多的最优解”来处理单位价值可能相等的情况。
- 动态规划的基本思想:利用最优子结构,定义子问题,建立递推关系,并通过自底向上填表来避免子问题重复计算。
- 通过斐波那契数列的实例,对比了低效的分治解法和高效的动态规划解法,并讨论了运行时间分析与计算模型的选择。
- 通过钢条切割问题,实践了动态规划解决优化问题的完整步骤。


下一讲,我们将继续深入动态规划,探讨更复杂的应用:
- 使用动态规划解决 0-1背包问题(比分数背包更难,没有高效的贪心解法)。
- 设计一个适用于任意货币体系的找零钱问题动态规划算法(纠正之前贪心算法仅对特定货币有效的局限性)。
- 我们将看到具有多维表格的动态规划问题,其填表顺序的确定将更具挑战性。
- 关于最优子结构的论证和输入规模的复杂计算也将变得更加重要。
010:分治法(下)
在本节课中,我们将学习分治法的最后一部分内容,涵盖选择问题和最近点对问题。我们将从选择问题开始,探讨如何高效地找到数组中第K小的元素,然后转向如何在二维平面中高效地找到距离最近的一对点。
选择问题
选择问题的输入是一个包含n个不同整数的数组A,以及一个介于1和n之间的整数K。输出是数组中第K小的整数。如果数组已排序,问题将变得非常简单(直接返回第K个元素)。因此,我们的目标是寻找一个渐近快于O(n log n)的解决方案,因为简单的排序后返回第K个元素的方法复杂度为O(n log n)。
以下是选择问题的几个特例:
- 最小值问题:当K=1时。
- 中位数问题:当K=n/2时。
- 最大值问题:当K=n时。
快速选择算法
一个简单的算法是直接排序并返回第K个元素,其复杂度为O(n log n)。一个在期望情况下更快的算法是快速选择。
快速选择算法的思路与快速排序类似:
- 选择一个枢轴元素
y。 - 围绕
y重构数组,使得所有小于y的元素都在其左侧,所有大于y的元素都在其右侧。此步骤耗时Θ(n)。 - 设重构后
y的索引为i_y。- 如果
K == i_y,则y就是第K小的元素,直接返回y。 - 如果
K < i_y,则第K小的元素在左侧子数组中,递归地在左侧子数组中寻找第K小的元素。 - 如果
K > i_y,则第K小的元素在右侧子数组中。由于左侧有i_y个更小的元素已被排除,因此需要在右侧子数组中寻找第(K - i_y)小的元素。
- 如果
以下是快速选择的伪代码:
def quick_select(A, k):
if len(A) == 1:
return A[0]
pivot = A[0] # 任意选择枢轴,例如第一个元素
left, right, pivot_index = restructure(A, pivot)
if k == pivot_index:
return pivot
elif k < pivot_index:
return quick_select(left, k)
else: # k > pivot_index
return quick_select(right, k - pivot_index)
复杂度分析
- 最坏情况:如果每次选择的枢轴都是当前数组的最小或最大元素,递归问题规模每次只减少1。此时递归式为
T(n) = T(n-1) + Θ(n),解得T(n) = O(n²)。 - 最好情况:如果每次选择的枢轴都能将数组均匀分成两半,递归式为
T(n) = T(n/2) + Θ(n),根据主定理,T(n) = O(n)。 - 平均情况(期望)分析:我们定义“好”枢轴为能将数组划分为至少1/4和至多3/4的两部分(即枢轴索引在
[n/4, 3n/4]之间)。随机选择枢轴时,它是“好”枢轴的概率是1/2。因此,在期望意义上,每两次递归调用我们就会遇到一个好枢轴。这两次调用引发的总工作量为O(n),而遇到好枢轴会将问题规模至少缩减n/4。这导出了平均情况递归式:T(n) = T(3n/4) + O(n)。根据主定理,T(n) = O(n)。因此,快速选择是一个期望线性时间的算法。
虽然快速选择在平均情况下很快,但其最坏情况 O(n²) 的复杂度仍不理想。接下来,我们将介绍一个能保证最坏情况线性时间的算法。
中位数的中位数算法
为了克服快速选择最坏情况的缺点,我们引入中位数的中位数算法。该算法的核心是确保每次递归都能选择一个“好”枢轴,从而保证最坏情况下的线性时间复杂度。
算法步骤
- 分组:将输入数组
A分成⌈n/5⌉组,每组最多5个元素。 - 找各组中位数:对每个5元组进行排序(常数时间),找出其中位数。这样我们得到一个包含
⌈n/5⌉个中位数的数组M。 - 找中位数的中位数:递归地调用本算法,找出数组
M的中位数(即第⌊|M|/2⌋小的元素)。将这个值作为枢轴y。注意,这里递归求解的问题规模是n/5。 - 分区:使用这个枢轴
y对原始数组A进行分区(类似快速选择),得到左子数组L、右子数组R和枢轴索引i_y。 - 递归选择:比较
K和i_y,根据比较结果,在L或R上递归调用本算法,寻找第K小或第(K - i_y)小的元素。
为何有效?复杂度分析
关键点在于,通过选择“中位数的中位数”作为枢轴,我们可以保证至少有 3n/10 的元素小于等于它,也至少有 3n/10 的元素大于等于它。这意味着,无论向哪边递归,子问题的大小最多为 7n/10。
由此得到递归式:
T(n) ≤ T(n/5) + T(7n/10) + O(n)
这里 T(n/5) 是寻找中位数的中位数的成本,T(7n/10) 是后续递归选择的成本,O(n) 是分组、找各组中位数和分区等线性工作的成本。
注意,n/5 + 7n/10 = 9n/10 < n。递归树每一层的工作量之和构成一个公比为 9/10 的几何级数:n + (9/10)n + (9/10)²n + ...,其和为 O(n)。因此,T(n) = O(n)。这是一个最坏情况线性时间的选择算法。
最近点对问题
最近点对问题的输入是二维平面上n个点的集合 P,目标是找到其中欧几里得距离最小的一对点。
一个朴素的解法是检查所有 C(n, 2) 对点,复杂度为 O(n²)。我们将利用分治法获得更优的解法。
分治策略
- 预处理:将所有点按x坐标排序,得到数组
P_x。 - 分:找到x坐标的中位数,用一条垂直线
L将点集分为左右数量大致相等的两部分Q和R。 - 治:递归地在
Q和R中分别找出最近点对,设其距离分别为δ_left和δ_right。令δ = min(δ_left, δ_right)。 - 合:最近点对要么完全在左侧,要么完全在右侧,要么一个在左一个在右(称为跨越点对)。前两种情况已由递归解决。关键在于高效地检查是否存在距离小于
δ的跨越点对。
高效寻找跨越点对
关键观察:对于跨越点对 (p, q)(设 p 在左,q 在右),如果其距离小于 δ,则 p 和 q 必定都落在以垂直线 L 为中心、宽度为 2δ 的垂直带状区域内。
进一步观察:对于带状区域内的任意一点 p,只需考虑其与带状区域内、y 坐标相差不超过 δ、且位于 p 上方的点(最多8个)即可。这是因为在 δ × 2δ 的矩形内,最多只能容纳8个点,使得任意两点距离不小于 δ(基于鸽巢原理和 δ 的定义)。
基于此,合并步骤的算法如下:
- 从
P_x中提取所有x坐标在[x_mid - δ, x_mid + δ]内的点,得到集合S。 - 将
S中的点按y坐标排序。 - 对于
S中每个点p,检查其与后续(y坐标更大的)最多8个点的距离,更新最小距离。
复杂度分析
设递归函数的时间为 T(n)。
- 排序
P_x耗时O(n log n)。 - 递归调用:
2 * T(n/2)。 - 合并步骤:提取带状区域
O(n),排序S耗时O(n log n),检查点对耗时O(n)(因为内层循环是常数次)。
因此,递归式为:T(n) = 2T(n/2) + O(n log n)。
利用递归树法或主定理(情况2),可得T(n) = O(n log² n)。
优化:Shamos算法
上述算法中,合并步骤的瓶颈在于每次递归都需要对带状区域内的点按y坐标排序(O(n log n))。Shamos算法通过预排序优化了这一步骤。

算法步骤:
- 预处理:创建两个数组:
P_x(按x坐标排序)和P_y(按y坐标排序)。 - 递归:在递归调用时,不仅传递子点集对应的
P_x子数组,还通过线性扫描P_y,筛选出属于当前子点集的点,从而得到已按y坐标排序的P_y子数组。此筛选步骤耗时O(n)。 - 合并:在合并时,可以直接使用已按y排序的
P_y子数组来寻找跨越点对,无需再次排序。
优化后的递归式为:T(n) = 2T(n/2) + O(n)。
这正是归并排序的递归式,其解为 T(n) = O(n log n)。因此,Shamos算法将最近点对问题的复杂度优化到了 O(n log n)。
总结
本节课我们一起学习了分治法的两个经典问题:
- 选择问题:我们探讨了快速选择算法(期望
O(n))及其最坏情况O(n²)的缺陷,进而引入了能保证最坏情况线性时间的中位数的中位数算法。 - 最近点对问题:我们首先设计了一个
O(n log² n)的分治算法,然后通过预排序y坐标的技巧(Shamos算法)将其优化到了O(n log n)。

这些算法展示了分治法在解决非平凡问题时的强大能力,以及通过精心设计合并步骤和利用问题特性来优化复杂度的技巧。下一讲,我们将开始新的主题:贪心算法。
011:第10讲 - 动态规划(续)
概述
在本节课中,我们将学习如何使用动态规划解决两个经典问题:0-1背包问题和硬币找零问题。我们将从定义问题开始,推导出递推关系,设计动态规划表格,并最终实现算法。课程内容旨在让初学者能够理解动态规划的核心思想与应用。
0-1背包问题:问题定义与递推关系
上一讲我们介绍了动态规划的基本概念。本节中,我们来看看如何将其应用于0-1背包问题。
0-1背包问题描述如下:我们有一个容量为 M 的背包和 n 件物品。每件物品 i 有一个重量 w_i 和一个价值 p_i。我们的目标是选择物品的一个子集放入背包,使得总重量不超过 M,同时总价值最大化。与分数背包问题不同,在0-1背包问题中,每件物品要么完整放入,要么不放入,不能分割。
为了用动态规划解决此问题,我们首先需要定义子问题。令 P(i, m) 表示仅考虑前 i 件物品(即物品1到物品i),在背包容量为 m 时能获得的最大价值。
现在,考虑最优解 O 是否包含第 i 件物品。这引出了两种可能的情况:
- 最优解不包含物品
i:那么,仅使用前i-1件物品在容量m下的最优解,就是使用前i件物品在容量m下的最优解。即P(i, m) = P(i-1, m)。 - 最优解包含物品
i:那么,我们必须先放入物品i,消耗其重量w_i。剩余容量为m - w_i,我们需要用前i-1件物品在这个剩余容量下获得最大价值。因此,P(i, m) = p_i + P(i-1, m - w_i)。
由于我们不知道最优解到底属于哪种情况,因此 P(i, m) 应该是这两种情况中的最大值。这给出了我们的核心递推关系:
递推关系(一般情况,i >= 2 且 m >= w_i):
P(i, m) = max( P(i-1, m), p_i + P(i-1, m - w_i) )
我们还需要处理一些基础情况:
- 当 i = 1 时(只有一件物品):
- 如果
m >= w_1,可以放入,则P(1, m) = p_1。 - 如果
m < w_1,无法放入,则P(1, m) = 0。
- 如果
- 当 m < w_i 时(当前物品太重):无法放入物品
i,因此P(i, m) = P(i-1, m)。
综合以上,我们得到完整的递推关系。
0-1背包问题:动态规划表格与算法实现
上一节我们推导出了递推关系。本节中,我们来看看如何将其转化为动态规划表格并实现算法。
我们将使用一个二维数组(或表格)P 来存储子问题的解,其中 P[i][m] 对应 P(i, m)。i 的范围是 1 到 n,m 的范围是 0 到 M。
以下是填充表格的步骤:
- 初始化(基础情况):填充第一行(
i=1)。对于每个容量m,如果m >= w_1,则P[1][m] = p_1;否则P[1][m] = 0。 - 递推填充:按
i从2到n的顺序逐行填充。对于每一行i,按m从0到M的顺序逐列填充。对于每个单元格(i, m):- 如果
m < w_i,则P[i][m] = P[i-1][m]。 - 否则,
P[i][m] = max( P[i-1][m], p_i + P[i-1][m - w_i] )。
- 如果
算法伪代码:
def knapsack_01(p, w, M):
n = len(p)
# 创建 (n+1) x (M+1) 的表格,多一行一列用于简化边界处理
P = [[0 for _ in range(M+1)] for _ in range(n+1)]
# 填充表格
for i in range(1, n+1):
for m in range(M+1):
if m < w[i-1]: # 注意索引调整,w 和 p 从0开始
P[i][m] = P[i-1][m]
else:
P[i][m] = max(P[i-1][m], p[i-1] + P[i-1][m - w[i-1]])
# 最优值存储在 P[n][M]
return P[n][M]
空间优化:观察发现,计算第 i 行时,只依赖于第 i-1 行。因此,我们可以将空间复杂度从 O(n*M) 降低到 O(M),只使用两个一维数组(或一个数组,从右向左更新)。
0-1背包问题:重构最优解与复杂度分析
上一节我们实现了计算最大价值的算法。本节中,我们来看看如何找出具体是哪些物品构成了最优解,并分析算法复杂度。
重构最优解
为了找出最优解包含哪些物品,我们可以从最终结果 P[n][M] 开始,逆向追踪决策过程:
- 初始化
i = n,m = M。 - 当
i > 0时:- 如果
P[i][m] == P[i-1][m],说明物品i没有被放入最优解。将i减1,m不变。 - 否则,说明物品
i被放入了最优解。记录物品i,然后将i减1,m减去w_i。
- 如果
- 重复步骤2直到
i = 0。记录下的物品列表即为最优解。
复杂度分析
- 时间复杂度:我们需要填充一个
n行M+1列的表格,每个单元格的计算是常数时间。因此,时间复杂度为 O(n * M)。 - 空间复杂度:使用二维表格时为 O(n * M),优化后可为 O(M)。
关于“多项式时间”的讨论:算法运行时间 O(n*M) 是否是输入规模的多项式函数?输入规模通常用存储所有数字(n, M, w_i, p_i)所需的比特数 S 来衡量。M 本身需要约 log M 比特存储。因此,M 相对于 log M 是指数级的。如果 M 非常大(例如 2^n),那么 O(n*M) 相对于输入规模 S 就是指数时间。然而,如果 M 本身是 n 的多项式(例如 M = n^2),那么算法就是多项式时间的。动态规划算法在处理物品数量 n 很大,但容量 M 相对较小的实例时表现优异。
硬币找零问题:动态规划解法
现在,让我们将注意力转向硬币找零问题。我们有一套硬币面额 d_1, d_2, ..., d_n(通常 d_1 = 1),和一个目标金额 T。目标是找出凑出金额 T 所需的最少硬币数量,每种面额的硬币可以使用任意多枚。

我们定义子问题:令 N(i, t) 表示仅使用前 i 种面额(d_1 到 d_i)凑出金额 t 所需的最少硬币数量。
考虑如何凑出金额 t 并使用第 i 种面额。我们可以使用 0 枚、1 枚、...、最多 floor(t / d_i) 枚第 i 种面额的硬币。如果我们决定使用 j 枚第 i 种面额的硬币,那么我们需要用前 i-1 种面额凑出剩余的金额 t - j * d_i,并且使用 j + N(i-1, t - j*d_i) 枚硬币。为了最小化总硬币数,我们需要遍历所有可能的 j。
因此,递推关系如下(i >= 2):
N(i, t) = min_{j=0 to floor(t/d_i)} [ j + N(i-1, t - j*d_i) ]
基础情况:
N(1, t) = t(因为只有面额为1的硬币,必须用t枚)N(i, 0) = 0(凑出0元需要0枚硬币)
硬币找零问题:算法实现与复杂度
上一节我们定义了硬币找零问题的递推关系。本节中,我们来看看如何实现它并分析其性能。
算法实现
我们将使用一个二维表格 N,其中 N[i][t] 存储 N(i, t) 的值。i 从 1 到 n,t 从 0 到 T。
算法伪代码:
def coin_change(d, T):
n = len(d)
N = [[float('inf')] * (T+1) for _ in range(n+1)]
# 基础情况
for t in range(T+1):
N[1][t] = t # 假设 d[0] == 1
for i in range(n+1):
N[i][0] = 0
# 递推填充
for i in range(2, n+1):
for t in range(1, T+1):
min_coins = float('inf')
for j in range(0, t // d[i-1] + 1): # j 是使用第 i 种硬币的数量
candidate = j + N[i-1][t - j * d[i-1]]
if candidate < min_coins:
min_coins = candidate
N[i][t] = min_coins
return N[n][T]
为了能重构出具体使用了哪些硬币,我们可以在计算时额外记录对于每个 N[i][t],最优解中使用了多少枚第 i 种硬币。
复杂度分析
- 时间复杂度:外层循环
i有n次,内层循环t有T次,最内层循环j最多T/d_i次。总时间复杂度粗略为 O(n * T^2)(假设面额都不太小)。更精确的分析取决于具体面额。 - 空间复杂度:表格大小为 O(n * T)。

关于“多项式时间”的讨论:与背包问题类似,运行时间 O(n * T^2) 是否是输入规模 S 的多项式?输入规模 S 约为 n * log T(存储 n 个面额和金额 T 所需的比特数)。如果 T 非常大,T^2 相对于 log T 是指数级的,算法可能不是多项式时间。然而,如果 T 是 n 的多项式(例如 T = n^k),那么算法就是多项式时间的。因此,该动态规划算法在目标金额 T 相对较小,或与硬币种类数 n 呈多项式关系时非常有效。
总结
本节课中,我们一起学习了两个重要的动态规划应用:0-1背包问题和硬币找零问题。
- 对于0-1背包问题,我们定义了子问题
P(i, m),推导了递推关系,实现了自底向上的表格填充算法,并学习了如何重构最优解以及分析其时间复杂度O(n*M)。 - 对于硬币找零问题,我们定义了子问题
N(i, t),推导了涉及多重选择的递推关系,实现了相应的动态规划算法,其时间复杂度约为O(n * T^2)。 - 我们深入讨论了这些算法的复杂度何时是输入规模的多项式函数,理解了动态规划在处理特定类型(物品多/金额大但容量/目标值适中)的大规模问题时的优势。

通过这两个例子,我们进一步巩固了设计动态规划算法的核心步骤:定义子问题、建立递推关系、确定计算顺序、实现并优化。
012:动态规划(三)🎯
在本节课中,我们将学习动态规划的最后两个经典问题:最长公共子序列 和 凸多边形最优三角剖分。我们还将了解一种与动态规划紧密相关的技术——记忆化搜索。
最长公共子序列问题 (LCS) 🧬
上一节我们介绍了动态规划的基本思想。本节中,我们来看看如何应用它来解决字符串处理中的一个经典问题:最长公共子序列。
问题定义:给定两个字符串 X 和 Y,长度分别为 M 和 N。我们需要找到一个字符串 Z,它是 X 和 Y 的子序列,并且长度最长。
子序列 是指从原字符串中删除零个或多个字符后,保持剩余字符相对顺序不变所形成的新字符串。
为什么贪心算法行不通?
一个直观的贪心想法是:按顺序遍历 X 的每个字符,在 Y 中寻找一个匹配字符,且该字符位于之前所有匹配字符的右侧。然而,这种方法并不总是能得到最优解。
反例:
X = "AbracadabraZ"Y = "AZbracadabra"
贪心算法可能过早地匹配了 Y 末尾的 Z,导致无法匹配后续更长的公共子序列 "Abracadabra"。这表明我们需要考虑所有可能性,这正是动态规划的优势所在。
定义子问题与建立递推关系
我们定义子问题:C[i][j] 表示字符串 X 的前 i 个字符(X[1..i])和字符串 Y 的前 j 个字符(Y[1..j])的 LCS 长度。
我们的目标是计算 C[M][N]。
考虑最后一个字符:
- 如果
X[i] == Y[j],那么这个字符一定属于 LCS。我们可以将其加入X[1..i-1]和Y[1..j-1]的 LCS 中。- 公式:
C[i][j] = C[i-1][j-1] + 1
- 公式:
- 如果
X[i] != Y[j],那么X[i]和Y[j]不可能同时属于 LCS。LCS 要么在X[1..i-1]和Y[1..j]中,要么在X[1..i]和Y[1..j-1]中。- 公式:
C[i][j] = max(C[i-1][j], C[i][j-1])
- 公式:
基础情况:当 i = 0 或 j = 0 时,一个字符串为空,LCS 长度为 0。
- 公式:
C[0][j] = 0,C[i][0] = 0
综合以上,我们得到完整的递推关系:
if i == 0 or j == 0:
C[i][j] = 0
elif X[i] == Y[j]:
C[i][j] = C[i-1][j-1] + 1
else:
C[i][j] = max(C[i-1][j], C[i][j-1])
算法实现与复杂度分析
以下是基于动态规划计算 LCS 长度的伪代码:
1. 初始化二维数组 C[M+1][N+1] 所有元素为 0。
2. for i from 1 to M:
3. for j from 1 to N:
4. if X[i] == Y[j]:
5. C[i][j] = C[i-1][j-1] + 1
6. else:
7. C[i][j] = max(C[i-1][j], C[i][j-1])
8. 返回 C[M][N]
时间复杂度:我们需要填充一个 (M+1) x (N+1) 的表格,每个单元格的计算是常数时间。因此总时间复杂度为 O(M * N)。
空间复杂度:算法使用了一个同等大小的二维数组,因此空间复杂度为 O(M * N)。
重构 LCS 字符串
为了不仅得到长度,还要输出 LCS 字符串本身,我们需要在填表时记录“决策路径”。
我们使用一个额外的二维数组 P(或称为 prev)来记录每个 C[i][j] 是从哪个子问题转移而来的:
- 如果
X[i] == Y[j],则P[i][j] = ‘↖‘,表示字符X[i]被纳入 LCS。 - 如果
C[i][j]来自C[i-1][j],则P[i][j] = ‘↑‘。 - 如果
C[i][j]来自C[i][j-1],则P[i][j] = ‘←‘。
填表完成后,我们从 P[M][N] 开始反向追踪:
- 遇到
‘↖‘,将X[i]加入 LCS(从后往前构建),然后移动到(i-1, j-1)。 - 遇到
‘↑‘,移动到(i-1, j)。 - 遇到
‘←‘,移动到(i, j-1)。 - 当
i == 0或j == 0时停止。
最后将收集到的字符序列反转,即得到 LCS。重构过程的时间复杂度为 O(M + N)。
凸多边形最优三角剖分问题 📐
接下来,我们看一个几何上的动态规划问题:凸多边形的最优三角剖分。
问题定义:给定一个凸 N 边形,其顶点按顺时针顺序为 Q1, Q2, ..., QN。用 N-3 条互不相交的对角线将其剖分成 N-2 个三角形。每个三角形的周长是其三条边长之和。目标是找到一种三角剖分,使得所有三角形周长之和最小。

寻找最优子结构
考虑多边形的一条边 (Q1, QN)。在任何三角剖分中,这条边必然属于某个三角形,设该三角形的第三个顶点为 Qk(1 < k < N)。
选择 Qk 后,我们得到了一个三角形 △Q1QkQN,并将原多边形分割成三个部分:
- 三角形
△Q1QkQN本身。 - 子多边形
Q1, Q2, ..., Qk(一个k边形)。 - 子多边形
Qk, Qk+1, ..., QN(一个N-k+1边形)。
关键观察:如果包含三角形 △Q1QkQN 的剖分是最优的,那么子多边形 Q1..Qk 和 Qk..QN 的剖分也必须分别是最优的。否则,我们可以用更优的剖分替换它们,从而得到整个多边形更优的剖分。

定义子问题与建立递推关系
我们定义子问题:S[i][j] 表示由连续顶点 Qi, Qi+1, ..., Qj 构成的凸多边形的最优三角剖分周长和(i < j)。
我们的目标是计算 S[1][N]。
递推关系:对于子多边形 Qi...Qj,我们需要选择其中一个顶点 Qk(i < k < j)与边 (Qi, Qj) 形成三角形。这个三角形将子多边形分割为 Qi...Qk 和 Qk...Qj 两个更小的子多边形。
- 公式:
S[i][j] = min_{i<k<j} ( S[i][k] + S[k][j] + Perimeter(△QiQkQj) )- 其中
Perimeter(△QiQkQj)是三角形Qi, Qk, Qj的周长。
- 其中
基础情况:当 j == i+1 或 j == i+2 时,多边形退化为一条边或一个三角形,无法或无需进一步剖分,其贡献的周长为 0。
- 公式:
S[i][i+1] = 0,S[i][i+2] = 0(实际上i+2时已经是一个三角形,其周长在父问题中计算)。
算法实现与复杂度分析
我们需要计算一个二维表 S,其中 i 和 j 满足 1 <= i < j <= N。有效的 (i, j) 对大约有 N^2/2 个。
填表顺序:观察递推式 S[i][j] 依赖于 S[i][k] 和 S[k][j],其中 i < k < j。这意味着:
S[i][k]位于同一行i,但列k更小。S[k][j]位于同一列j,但行k更大。
因此,一个可行的填表顺序是:按子多边形长度(len = j-i)从小到大计算。即先计算所有长度为 2 和 3 的基础情况,然后计算长度为 4,5,...,直到 N。
伪代码概述:
1. 初始化二维数组 S[N+1][N+1] 所有元素为 0。
2. for len from 3 to N: // 子多边形顶点数,len = j-i+1,但这里 len 代表跨度
3. for i from 1 to N-len+1:
4. j = i + len - 1
5. S[i][j] = INFINITY
6. for k from i+1 to j-1:
7. cost = S[i][k] + S[k][j] + Perimeter(Qi, Qk, Qj)
8. if cost < S[i][j]:
9. S[i][j] = cost
10. 返回 S[1][N]
时间复杂度:三层循环。外层 len 循环 O(N) 次,中层 i 循环 O(N) 次,内层 k 循环平均 O(N) 次。因此总时间复杂度为 O(N^3)。
空间复杂度:使用了一个 N x N 的二维数组,因此空间复杂度为 O(N^2)。
记忆化搜索 (Memoization) 💾
记忆化搜索是动态规划的一种“自顶向下”的实现方式。其核心思想是:在递归求解问题的过程中,将已经计算过的子问题的结果保存起来。当再次遇到相同的子问题时,直接返回保存的结果,避免重复计算。
以斐波那契数列为例
以下是使用记忆化搜索计算第 n 个斐波那契数的伪代码:
全局数组 memo[MAX_N],初始化为 -1(表示未计算)
function Fib(n):
if n == 0: return 0
if n == 1: return 1
if memo[n] != -1: // 已经计算过
return memo[n]
result = Fib(n-1) + Fib(n-2)
memo[n] = result // 保存结果
return result
与动态规划对比:
- 动态规划是“自底向上”的迭代,显式地定义了一个计算顺序并填充表格。
- 记忆化搜索是“自顶向下”的递归,利用递归调用和缓存表格来隐式地解决问题。
- 两者通常具有相同的时间复杂度。记忆化搜索的代码有时更直观,但递归会有额外的函数调用开销。动态规划通常更容易进行空间优化(例如,将二维数组优化为一维数组)。
总结 📝
本节课我们一起学习了动态规划在两类不同问题中的应用:
- 最长公共子序列 (LCS):我们通过定义
C[i][j]为前缀子串的 LCS 长度,建立了清晰的递推关系,并设计了 O(MN) 时间复杂度的算法。我们还学习了如何通过记录路径来重构出 LCS 字符串本身。 - 凸多边形最优三角剖分:我们通过选择与固定边构成三角形的中间顶点,将问题分解为两个更小的子多边形问题,建立了 O(N^3) 时间复杂度的递推解法。
最后,我们介绍了记忆化搜索技术,它提供了实现动态规划思想的另一种途径,即通过递归加缓存来避免子问题的重复计算。
理解这些问题的关键在于识别最优子结构,并正确定义状态和状态转移方程。掌握这些核心概念,你就能运用动态规划解决更多复杂的优化问题。
013:图算法基础

在本节课中,我们将学习图的基本概念、表示方法以及一种基础的图遍历算法——广度优先搜索。
概述
图是计算机科学中用于表示对象间关系的一种强大数据结构。本节课将首先介绍图的基本定义和术语,然后探讨两种存储图的数据结构:邻接矩阵和邻接列表。最后,我们将深入讲解广度优先搜索算法,并了解其性质和应用。
图的基本概念
图由顶点和边组成。我们通常用数字1到n来表示顶点。边是顶点对。
- 无向图:边是无序的顶点对。例如,边(1,2)与边(2,1)表示同一条边。
- 有向图:边是有序的顶点对。例如,边(1,2)表示从顶点1指向顶点2的边,与边(2,1)不同。
我们假设图中没有自环(即从顶点到自身的边),也没有平行边(即两个顶点间最多只有一条边)。
图的边数
一个图可以有多少条边?这取决于图的类型。
- 在无向图中,边的数量M的范围是
0 ≤ M ≤ n(n-1)/2。 - 在有向图中,边的数量M的范围是
0 ≤ M ≤ n(n-1)。
因此,边的数量总是 O(n²)。边数较少的图(例如 M = O(n))被称为稀疏图,而边数接近最大值的图被称为稠密图。某些算法在稀疏图上运行效率更高。
基本术语
以下是理解图算法所需的核心概念:
- 相邻/邻居:如果两个顶点之间存在一条边,则称它们相邻或互为邻居。
- 关联:如果顶点是某条边的端点,则称该顶点与该边关联。
- 度:
- 在无向图中,顶点的度是与该顶点关联的边的数量。
- 在有向图中,顶点的入度是指向该顶点的边的数量,出度是从该顶点指出的边的数量。
- 路径:路径是一个顶点序列
v₁, v₂, ..., vₖ,其中对于每个i,(vᵢ, vᵢ₊₁)都是图中的一条边。在有向图中,边的方向必须与路径方向一致。路径的长度是路径中边的数量。如果路径中的顶点不重复,则称该路径为简单路径。 - 环:环是一条起点和终点相同的路径。如果除了起点和终点外,环中的顶点不重复,则称该环为简单环。
- 树:树是一个连通且无环的无向图。
- 连通性:一个无向图是连通的,如果图中任意两个顶点之间都存在一条路径。一个图可以被分成几个连通的部分,每个部分称为一个连通分量。
图的表示方法
在算法中,我们通常将顶点编号为1到n。有两种主要的数据结构来存储图:邻接矩阵和邻接列表。
邻接矩阵
对于一个有n个顶点的图,邻接矩阵是一个 n × n 的矩阵 A。如果顶点 i 和 j 之间存在一条边,则 A[i][j] = 1,否则为0。
对于无向图,矩阵是对称的(A[i][j] = A[j][i])。对于有向图,则不一定对称。
示例:对于下图的无向图,其邻接矩阵为:
1 2 3 4
1 0 1 1 0
2 1 0 1 0
3 1 1 0 1
4 0 0 1 0
邻接列表
对于每个顶点 v,我们存储一个链表,列出所有与 v 相邻的顶点(对于有向图,则是列出 v 的所有出边邻居)。
示例:对于同一个无向图,其邻接列表为:
1 -> [2, 3]
2 -> [1, 3]
3 -> [1, 2, 4]
4 -> [3]
两种表示法的比较
| 操作 | 邻接矩阵 | 邻接列表 |
|---|---|---|
| 检查边 (u, v) 是否存在 | O(1) | O(1 + min(deg(u), deg(v))) |
| 列出顶点 v 的所有邻居 | O(n) | O(1 + deg(v)) |
| 列出所有边 | O(n²) | O(n + m) |
| 空间复杂度 | Θ(n²) | Θ(n + m) |
n是顶点数,m是边数,deg(v)是顶点v的度。- 邻接列表的空间复杂度与图的大小(顶点加边)成线性关系,这对于稀疏图尤其高效。
- 在本课程中,除非特别说明,我们将主要使用邻接列表来表示图。
计算模型
我们使用字RAM模型来分析图算法。在这个模型中:
- 输入大小以字为单位。一个图需要
Θ(n + m)个字来存储。 - 我们假设每个字有
O(log n)位,这足以存储一个顶点编号或地址。 - 基本操作(如访问内存、算术运算)花费常数时间。
这使得我们的运行时间分析可以简洁地表示为 O(n + m),即与输入大小成线性关系。
广度优先搜索
上一节我们介绍了图的基本表示方法,本节中我们来看看如何系统地探索一个图。广度优先搜索是一种用于遍历或搜索图的算法。它从某个源顶点开始,逐层地探索图中的顶点。
算法思想
BFS 按“层次”进行探索:
- 首先访问源顶点(第0层)。
- 然后访问所有与源顶点相邻的顶点(第1层)。
- 接着访问所有与第1层顶点相邻且未被访问过的顶点(第2层)。
- 依此类推,直到所有可达顶点都被访问。
算法伪代码
以下是 BFS 的核心伪代码,它从源顶点 v₀ 开始探索:
function BFS(G, v₀):
for each vertex v in G:
v.discovered = false
v.parent = null
v.level = infinity
v₀.discovered = true
v₀.level = 0
v₀.parent = null
Initialize an empty queue Q
Q.enqueue(v₀)
while Q is not empty:
v = Q.dequeue()
explore(v)
function explore(v):
for each neighbor u of v:
if not u.discovered:
u.discovered = true
u.parent = v
u.level = v.level + 1
Q.enqueue(u)
算法性质
BFS 算法具有三个重要性质:
- 生成树:通过
parent指针可以构建一棵以v₀为根的 BFS 树。树中的边称为树边。 - 层级性质:对于图中的任意边
(u, v),有|level(u) - level(v)| ≤ 1。即边只能连接同一层或相邻层的顶点。 - 最短路径:顶点
v的level(v)等于从源顶点v₀到v的最短路径的长度(边数)。这是 BFS 最重要的性质之一。
运行时间:BFS 访问每个顶点一次,并检查每条边两次(在无向图中)。因此,总运行时间为 O(n + m)。
BFS 的应用
利用 BFS 的性质,我们可以解决许多图论问题:
以下是 BFS 的一些典型应用:
- 求连通分量:通过多次从不同未访问顶点启动 BFS,可以找出图的所有连通分量。
- 求无权图最短路径:如前所述,BFS 能直接给出从源点到其他所有点的最短路径长度。
- 检测图中是否存在环:如果在 BFS 过程中发现一条连接同一层或相邻层中已访问顶点的“非树边”,则图中存在环。
- 判断二分图:一个图是二分图,当且仅当它可以被着色为两种颜色,使得每条边的两个端点颜色不同。利用 BFS 的层级性质,我们可以设计算法:将所有奇数层顶点染成一种颜色,偶数层顶点染成另一种颜色,然后检查是否有边连接两个同色顶点。如果没有,则是二分图;否则,该边与 BFS 树中连接两端点的路径会构成一个奇数长度的环,证明该图不是二分图。
总结

本节课我们一起学习了图算法的基础知识。我们首先定义了图、路径、环、树和连通性等核心概念。接着,我们比较了邻接矩阵和邻接列表两种图的存储方式,并确定在本课程中主要使用更节省空间的邻接列表。最后,我们深入探讨了广度优先搜索算法,理解了它按层遍历的思想、O(n+m) 的时间复杂度、其生成树和最短路径的性质,以及它在求解连通分量、检测环和判断二分图等问题上的应用。BFS 是许多更复杂图算法的基础。在下一讲中,我们将学习另一种重要的图遍历算法:深度优先搜索。
014:深度优先搜索

概述
在本节课中,我们将要学习一种重要的图遍历算法——深度优先搜索。我们将了解其工作原理、实现方式、关键性质,并探索其在寻找图的“割点”和“双连通分量”中的应用。
深度优先搜索简介
上一节我们介绍了广度优先搜索,它是一种“谨慎”的搜索,会先探索当前顶点的所有邻居,再进入下一层。本节中我们来看看另一种搜索策略——深度优先搜索。
深度优先搜索更像一种“大胆”的探索。它从起点出发,沿着一条路径尽可能深入地探索,直到无法继续前进,然后回溯到上一个顶点,尝试其他未探索的分支。
让我们通过一个例子来理解这个过程。
假设我们从顶点1开始深度优先搜索。
- 查看顶点1的邻接表,首先发现顶点2。
- 立即转向探索顶点2,而不是继续查看顶点1的其他邻居。
- 查看顶点2的邻接表,发现顶点5。
- 转向探索顶点5,查看其邻接表,发现顶点6。
- 转向探索顶点6,发现其唯一邻居顶点5已被访问过。
- 回溯到顶点5,继续查看其邻接表中的下一个顶点7。
- 探索顶点7,发现其邻居均已访问。
- 回溯到顶点5,再回溯到顶点2,继续查看其邻接表中的顶点4。
- 以此类推,直到探索完所有可达顶点。
在搜索过程中,我们构建了一棵深度优先搜索树。首次发现顶点u是通过边(v, u)时,这条边就是树边。其他连接已访问顶点的边称为非树边,在图中常用虚线表示。
以下是深度优先搜索的一些关键特性:
- 探索顺序:深度优先搜索会尽可能深入,然后回溯。
- 数据结构:与广度优先搜索使用队列不同,深度优先搜索隐式或显式地使用栈来管理待探索的顶点。
- 顶点状态:每个顶点有“未发现”、“已发现”(正在探索中)和“已完成”(其所有邻居均已处理)三种状态。
深度优先搜索的伪代码实现
深度优先搜索通常以递归方式实现,递归调用栈隐式地充当了存储顶点的栈。
以下是递归形式的深度优先搜索例程,从特定顶点v开始探索:
procedure DFS(v):
mark v as discovered
for each neighbor u in adjacency list of v:
if u is undiscovered:
set parent[u] = v // (v, u) 是树边
DFS(u)
else if u is not the parent of v:
mark edge (v, u) as a non-tree edge
mark v as finished
为了遍历整个图(可能包含多个连通分量),我们需要一个顶层驱动例程:
procedure DFS_Graph(G):
for each vertex v in G:
mark v as undiscovered
set parent[v] = NIL
for each vertex v in G:
if v is undiscovered:
DFS(v) // 以v为根开始一棵新的DFS树
时间复杂度分析:
该算法的时间复杂度为 O(n + m),其中 n 是顶点数,m 是边数。这是因为每个顶点被访问一次,每条边在其两个端点的邻接表中各被检查一次。
深度优先搜索的性质
深度优先搜索不仅遍历图,还赋予了图丰富的结构信息。
性质1:连通性
从顶点 v0 开始进行深度优先搜索,能够到达所有从 v0 出发可达的顶点。这保证了我们可以通过多次调用 DFS 来找出图的所有连通分量。
性质2:非树边连接祖先和后代
这是深度优先搜索的一个核心性质。在深度优先搜索树中,任何一条非树边都连接着一个顶点和它的祖先或后代。不会出现连接两个没有直系祖先-后代关系的顶点的非树边。
- 证明思路:假设存在非树边
(a, b),且a比b先被发现。在探索a时,我们会检查其所有邻居(包括b),因此b必定在a完成之前被发现。这意味着b位于以a为根的子树中,因此a是b的祖先。
性质3:发现时间与完成时间
我们可以记录每个顶点被发现(第一次访问)和完成(其所有邻居处理完毕)的时刻。这两个时间具有括号化结构。
- 令
d[v]和f[v]分别表示顶点v的发现时间和完成时间。 - 对于任意两个顶点
u和v,区间[d[v], f[v]]和[d[u], f[u]]要么完全分离(一个区间在另一个之外),要么一个区间完全包含另一个区间。它们不会部分重叠。 - 这反映了递归调用栈的行为:后进栈的顶点(后发现)会先完成并出栈。
应用:寻找割点与双连通分量
现在,我们来看深度优先搜索的一个重要应用:识别图的脆弱点。
割点的定义
在一个连通图中,如果移除顶点 v 及与其相连的所有边后,图变得不再连通,则称顶点 v 为一个割点(或关节点)。
割点意味着网络的单点故障风险。我们希望识别出它们。
利用深度优先搜索树识别割点
深度优先搜索树的结构为我们提供了判断割点的有效方法。
情况1:根顶点
深度优先搜索树的根顶点是割点,当且仅当它在树中有多于一个子节点。
- 原因:由于非树边只连接祖先和后代,根节点的不同子树之间没有边相连。移除根节点就断开了这些子树间的联系。
情况2:非根顶点
对于一个非根顶点 v,它是割点,当且仅当 v 存在某个子节点 u,使得以 u 为根的子树中,没有任何顶点能通过一条非树边连接到 v 的祖先。
- 直观理解:如果子树
u能“绕开”v连接到更上层,那么移除v就不会断开这部分图。否则,移除v就会使子树u孤立。
高效算法:Low 值计算
为了高效地判断上述条件,我们为每个顶点 v 定义一个 low[v] 值。
low[v]表示从顶点v出发,通过其后代顶点,最多能利用一条非树边“回溯”到的最高祖先(即发现时间d[.]最小)的发现时间。- 计算方式(递归定义):
low[v]初始化为d[v](自身)。low[v]可以更新为所有邻接顶点w的d[w]的最小值。low[v]还可以更新为其所有子节点u的low[u]的最小值。
利用 low 值,割点判断准则可以简化为:
- 根顶点:子节点数 > 1。
- 非根顶点
v:存在一个子节点u,满足low[u] >= d[v]。- 条件
low[u] >= d[v]意味着以u为根的子树中,没有顶点能通过非树边连接到v的祖先(最多连到v本身),因此v是割点。
- 条件
双连通分量
与割点紧密相关的是双连通分量。一个双连通分量是极大的、不包含割点的连通子图。寻找所有双连通分量(即“2-连通分量”)的算法也基于深度优先搜索和 low 值的计算,由 Hopcroft 和 Tarjan 在 1973 年提出,同样能在 O(n + m) 时间内完成。
总结
本节课中我们一起学习了:
- 深度优先搜索:一种基于栈/递归的图遍历算法,其特点是尽可能深入探索。
- 关键性质:非树边总是连接深度优先搜索树中的祖先和后代顶点;顶点的发现与完成时间具有括号化结构。
- 重要应用:利用深度优先搜索可以在线性时间内找出图的割点,并进一步找出双连通分量,这对于分析网络的可靠性至关重要。

在下一讲中,我们将探讨深度优先搜索在有向图中的应用。
015:第14讲 - 有向图的深度优先搜索

在本节课中,我们将学习如何将有向图上的深度优先搜索算法,探索其与无向图版本的区别,并了解其在检测环、拓扑排序和寻找强连通分量等三个重要问题上的应用。
有向图的深度优先搜索
上一节我们介绍了无向图的深度优先搜索。本节中,我们来看看在有向图中,深度优先搜索是如何工作的。
在有向图中,所有边都带有方向箭头。这意味着从顶点 V 到其邻居 U 的探索,只考虑从 V 出发指向 U 的边。
以下是深度优先搜索在有向图中的伪代码核心部分,新增的逻辑(用于区分边类型)用蓝色标出:
DFS(G):
for each vertex v in G:
v.discovered = False
v.finished = False
time = 1
for each vertex v in G (in some order):
if not v.discovered:
DFS-Visit(v)
DFS-Visit(v):
v.discovered = True
v.d = time; time += 1
for each neighbor u in v.adjacencyList: // 只考虑从v出发的边
if not u.discovered:
// 树边
DFS-Visit(u)
else if not u.finished:
// 后向边
label edge (v, u) as BACK_EDGE
else if u.d > v.d:
// 前向边
label edge (v, u) as FORWARD_EDGE
else:
// 横跨边
label edge (v, u) as CROSS_EDGE
v.finished = True
v.f = time; time += 1
算法的时间复杂度仍然是 O(n + m),其中 n 是顶点数,m 是边数。
边的分类
在无向图的深度优先搜索树中,非树边只有一种(后向边)。但在有向图中,非树边可以分为三类。以下是区分这四类边的方法:
假设我们正在检查从顶点 v 到顶点 u 的边 (v, u)。
- 树边:如果
u尚未被发现(not u.discovered),则(v, u)是树边。 - 后向边:如果
u已被发现但尚未完成处理(u.discovered and not u.finished),则(v, u)是后向边。它指向搜索树中的一个祖先。 - 前向边:如果
u已完成处理(u.finished),并且u的发现时间晚于v的发现时间(u.d > v.d),则(v, u)是前向边。它指向搜索树中的一个后代。 - 横跨边:如果
u已完成处理(u.finished),并且u的发现时间早于v的发现时间(u.d < v.d),则(v, u)是横跨边。它连接的是搜索树中无直系祖先-后代关系的两个分支,甚至可能连接两棵不同的搜索树。
应用一:检测有向环 🌀
深度优先搜索的第一个应用是检测有向图中是否存在有向环。
关键定理:一个有向图包含有向环,当且仅当在其深度优先搜索森林中存在后向边。
证明思路:
- 充分性(如果存在后向边,则存在环):如果存在一条后向边
(v, u),那么在深度优先搜索树中,从u到v存在一条由树边构成的路径。将这条路径与后向边(v, u)连接起来,就形成了一个有向环。 - 必要性(如果存在环,则必存在后向边):假设图中存在一个有向环
v1 -> v2 -> ... -> vk -> v1。设v1是该环中第一个被发现的顶点。根据深度优先搜索的性质,在完成v1之前,算法会递归地发现并处理v2, v3, ..., vk。当处理到vk并检查边(vk, v1)时,会发现v1已被发现但尚未完成(因为v1是vk的祖先)。根据分类规则,这条边(vk, v1)将被标记为后向边。
因此,检测环的算法就是运行深度优先搜索,并检查是否产生了任何后向边。
应用二:拓扑排序 📋
对于一个无环有向图,我们可以对其顶点进行拓扑排序。拓扑排序是一个顶点序列,使得对于图中的每一条有向边 (u, v),u 在序列中都出现在 v 之前。
算法:对图运行一次深度优先搜索,然后按顶点完成时间的逆序输出顶点,得到的就是一个拓扑排序。
证明思路:
我们需要证明对于任意边 (v, u),在逆序完成序列中,v 都出现在 u 之后(即在原序列中 v 在 u 之前)。考虑边 (v, u) 的类型:
- 树边或前向边:
u是v的后代,因此u会比v先完成。在逆序中,v自然在u之后。 - 横跨边:当检查边
(v, u)时,u已经完成。根据横跨边的定义,u的发现时间早于v的发现时间。由于无环图中不存在后向边,u不可能是v的祖先,因此u的完成时间也一定早于v的完成时间。在逆序中,v仍在u之后。 - 后向边:在无环有向图中,不存在后向边。
因此,所有边都满足要求,逆序完成序列即为一个有效的拓扑排序。
应用三:强连通分量 🔗
在有向图中,如果从任意顶点 u 到任意顶点 v 都存在一条有向路径,并且从 v 到 u 也存在一条有向路径,则称该图是强连通的。一个有向图的强连通分量是其极大的强连通子图。
测试图的强连通性
要测试图 G 是否强连通,可以:
- 从任意顶点
s出发,运行深度优先搜索,检查是否能到达所有其他顶点。 - 将图
G的所有边反向,得到图G_rev。 - 在
G_rev中从同一个顶点s出发,再次运行深度优先搜索,检查是否能到达所有顶点。
如果两步都成功,则原图G是强连通的。这是因为第二步等价于在原图中检查从所有顶点到s是否存在路径。
寻找所有强连通分量(Kosaraju 算法概要)
以下是寻找所有强连通分量的 Kosaraju 算法框架:
- 在图
G上运行深度优先搜索,记录每个顶点的完成时间。 - 计算图
G的转置图G^T(即所有边反向)。 - 在
G^T上运行深度优先搜索,但在主循环中,按照第一步得到的完成时间的降序(即最晚完成的顶点最先)来选取起始顶点。 - 在第二步的深度优先搜索中,每棵生成的深度优先搜索树,就对应原图
G中的一个强连通分量。
算法思想:
强连通分量有一个重要性质:如果将每个强连通分量收缩为一个“超顶点”,那么形成的图是一个有向无环图。算法第一步的搜索揭示了这种分量间的依赖顺序。第二步在反向图上按照完成时间降序搜索,可以确保每次搜索都局限在一个强连通分量内部,而不会“逃逸”到其他分量,从而正确地找出所有分量。
总结
本节课中我们一起学习了有向图的深度优先搜索。主要内容包括:
- 算法细节:理解了有向图深度优先搜索的伪代码实现,以及其与无向图版本的区别。
- 边的分类:掌握了有向图中树边、后向边、前向边和横跨边的定义与区分方法。
- 三大应用:
- 利用后向边的存在性来检测有向环。
- 对无环有向图,利用完成时间的逆序进行拓扑排序。
- 使用 Kosaraju 算法(两次深度优先搜索)来寻找有向图的强连通分量。

这些应用展示了深度优先搜索作为基础图遍历算法的强大威力。接下来,我们将进入新的图算法主题,例如最小生成树和最短路径算法。
016:最小生成树

在本节课中,我们将要学习最小生成树。我们将把数学课程中关于生成树的知识与数据结构课程中的知识结合起来,探讨寻找最小权重生成树的最佳算法。
什么是最小生成树?
我们有一个无向图,其边上有权重。权重函数将边集 E 映射到非负实数集。我们的目标是找到一个最小权重的边子集,这个子集能够连接图中的所有顶点。换句话说,我们想要一个连接所有顶点的连通子图。我们选择的边集 S 的权重之和应该是所有可能情况中的最小值。
让我们看一个例子。图中每条边都标有它的权重。一种可能的生成树是选择一个顶点,然后连接所有其他顶点,形成一个星形结构。在这个例子中,我们使用了权重为 4、5、6、7 的边,总权重为 7。问题是,我们能找到比总权重 7 更好的方案吗?
一种思考方式是,权重为 4 的边非常“重”,除非必要,否则不应该使用它。如果我们去掉这条边,图仍然是连通的,所以我们肯定可以不用它。另一种思考方式是,图中有一条权重为 0 的边,这是一个非常好的选择,我们应该尽可能使用它。
一个更好的方案是使用权重为 0 和权重为 1 的边,总权重为 4。通过尝试其他可能性,可以验证这是能得到的最小权重。
首先要注意的是,最小生成树是连接所有顶点的最小权重边集。这个边集将是一棵树。因为如果边集中包含一个环,那么我们可以移除环中的一条边,而仍然保持所有顶点的连通性。因此,最小生成树是一棵树。由此可知,最小生成树中的边数将是 n - 1,其中 n 是顶点数。这是树的基本性质。
简单情况:所有权重为 1
假设所有边的权重都是 1。那么我们可以使用深度优先搜索或广度优先搜索来找到一个连接所有顶点的树。只要图是连通的,从任意顶点开始的 DFS 或 BFS 都能找到一个生成树。这个操作的时间复杂度是 O(n + m),是线性的。
然而,当边上有权重时,寻找最小权重生成树需要比线性更多的时间。不过,我们可以立即做一件事:检查边数是否足够。首先检查 m 是否至少为 n - 1,否则不可能有足够的边来构成生成树。这个检查可以在 O(1) 时间内完成。
贪心算法与 Kruskal 算法
最小生成树可以使用贪心算法来寻找。有几种可能的贪心方法,我们将在本讲和下一讲中探讨它们。它们有不同的实现挑战,这是我们将要关注的重点。
第一种方法基于一个直观的想法:我们应该优先选择权重最小的边,然后继续按权重顺序添加边,但要确保不形成环。这种算法被称为 Kruskal 算法。
另一种贪心方法是从一个顶点开始,逐步扩展一个连通图。这更像是带权重的 BFS 或 DFS,这种算法被称为 Prim 算法。
第三种方法是反向思考:从最重的边开始,尝试丢弃它们,但要确保不破坏图的连通性。今天我们将重点讨论 Kruskal 算法,下一讲再讨论 Prim 算法。
Kruskal 算法详解
以下是 Kruskal 算法的更详细步骤:
- 将边按权重从小到大排序。权重
w(e1) <= w(e2) <= ...。平局情况任意处理。 - 初始化树
T为空集。 - 按顺序遍历排序后的边列表。对于每条边
e,如果将其加入当前集合T不会形成环,则将其加入T。
回到之前的例子,算法会先添加权重为 0 的边,然后是权重为 1 的边(假设先处理这条),接着是权重为 2 的边(如果它连接了新的顶点)。当遇到会形成环的边(例如另一条权重为 2 的边)时,则跳过它。当 T 中的边数达到 n - 1 时,可以提前停止。
在算法执行过程中,T 通常是一个森林,即其连通分量都是树,但尚未连接整个图。当我们考虑下一条边时,关键观察是:一条边会与 T 形成环,当且仅当 这条边的两个端点位于 T 的同一个连通分量中。因此,在实现时,我们需要检查两个顶点是否在同一个连通分量中。
算法正确性证明
贪心算法的正确性需要证明。我们将使用归纳法来证明,而不是反证法,这样结构更清晰。
我们通过归纳法证明:对于每个 i,都存在一个最小生成树,其前 i 条边与 Kruskal 算法生成的前 i 条边相同。
- 基础情况:
i = 0。显然存在一个最小生成树,其前 0 条边与算法生成的相同。 - 归纳步骤:假设存在一个最小生成树
M,其前i-1条边与算法生成的树T的前i-1条边相同。现在考虑算法添加的第i条边e,它连接顶点a和b。设C是当算法添加边e时包含顶点a的连通分量。
由于算法按权重顺序添加边,且 e 是第一个连接分量 C 与图其余部分 V-C 的边,因此 e 是所有横跨 C 和 V-C 的边中权重最小的(或之一)。
现在考虑最小生成树 M。如果将边 e 加入 M,会形成一个环。这个环必然包含另一条连接 C 和 V-C 的边,记为 e‘。根据算法性质,w(e’) >= w(e),且 e‘ 在排序中位于 e 之后。
我们进行交换:从 M 中移除边 e‘,并加入边 e,得到新的边集 M‘。我们需要证明:
M‘是一个生成树(可行性)。M‘的权重不大于M的权重(最优性)。
最优性:w(M‘) = w(M) - w(e‘) + w(e) <= w(M),因为 w(e‘) >= w(e)。
可行性:M 原本连通所有顶点。加入 e 后仍然连通。由于我们移除的是环 M ∪ {e} 中的一条边 e‘,移除环中的边不会破坏连通性。因此 M‘ 仍然连通所有顶点,并且边数仍为 n-1,所以它是一棵生成树。
因此,M‘ 是一个最小生成树,并且其前 i 条边与算法生成的 T 的前 i 条边相同(因为交换只涉及 e 和更晚的边 e‘)。归纳完成,算法正确。
算法实现与复杂度分析
接下来,我们考虑如何实现并分析 Kruskal 算法的运行时间。参数 n 是顶点数,m 是边数。
算法步骤回顾:
- 按权重对边排序。时间复杂度为
O(m log m)。由于m最多为O(n^2),且我们假设m >= n-1,所以O(m log m)等价于O(m log n)。 - 初始化树
T为空。 - 遍历排序后的边,对于每条边,检查其端点是否在
T的同一连通分量中。如果不是,则加入该边并合并两个分量。
检查两个顶点是否在同一连通分量,这可以归结为一个数据结构问题:并查集。
并查集问题
我们需要维护一个不相交集合的集合(初始时每个顶点自成一个集合)。支持两种操作:
Find(x):查找元素x属于哪个集合。Union(A, B):合并两个集合A和B。
在我们的应用中,元素是图的顶点,集合是当前森林 T 中的连通分量。
Kruskal 算法使用并查集
以下是使用并查集的 Kruskal 算法伪代码:
初始化:为每个顶点 v 创建一个集合 {v}
T = 空集
将边按权重排序
for 每条边 (u, v) in 排序后的边列表:
if Find(u) != Find(v):
将边 (u, v) 加入 T
Union(Find(u), Find(v))
我们需要实现并查集,然后分析其在此算法中的耗时。
简单的并查集实现
假设元素为 1 到 n。我们维护两个数据结构:
- 一个数组
S,S[x]表示元素x当前所属的集合标识符。 - 每个集合对应一个链表,存储其所有元素。
Find操作:直接查询数组 S,时间复杂度 O(1)。
Union操作:假设要合并集合 A 和 B。我们总是将较小的集合合并到较大的集合中(按元素个数计)。合并两个链表只需 O(1)。但我们需要更新较小集合中所有元素在数组 S 中的标识符,这需要时间与较小集合的大小成正比。
分析所有 Union 操作的总时间:考虑单个元素 x。每次 x 的 S 条目需要更新,都意味着它所在的集合被合并到了一个更大的集合中,并且新集合的大小至少是原集合的两倍(因为我们总是小集合并入大集合)。由于集合大小最大为 n,因此每个元素最多被更新 O(log n) 次。总共有 n 个元素,所以所有 Union 操作的总时间是 O(n log n)。
因此,简单并查集的总运行时间为:O(k + n log n),其中 k 是 Find 操作的次数。
Kruskal 算法的总复杂度
回到 Kruskal 算法:
- 排序:
O(m log n) Find操作:我们对每条边进行两次Find,共O(m)次,每次O(1),总时间O(m)。Union操作:最多进行n-1次合并,总时间O(n log n)。
因此,总运行时间为 O(m log n + n log n + m)。由于 m >= n-1,所以主导项是 O(m log n)。这与排序部分的时间复杂度相同,因此整个算法的时间复杂度为 O(m log n)。
进阶内容(扩展了解)
最后,我们简要介绍两个更深入的主题,作为知识的延伸。
1. 更高效的并查集实现
存在一种更精巧的并查集实现。它将每个集合表示为一棵有父指针的树,集合名存储在根节点。Union 操作时,将较小树的根指向较大树的根。Find(x) 操作时,从 x 向上遍历到根,同时应用“路径压缩”启发式:将路径上所有访问过的节点直接指向根,以缩短后续查找路径。
这种算法的运行时间非常高效,总操作次数 k 与一个增长极慢的函数 α(n)(反阿克曼函数)相关。对于所有实际输入规模,α(n) 不超过 5。因此,这种实现几乎是线性的,但在理论分析上不是严格的线性。
2. Kruskal 算法的推广:拟阵
Kruskal 算法可以推广到更一般的结构。考虑寻找最大权重生成树,只需将边按权重从大到小排序,然后同样地添加不形成环的边即可。
使这种贪心算法成立的结构需要满足两个性质:
- 遗传性:如果一个集合是独立的(无环),那么它的任何子集也是独立的。
- 交换性:如果两个独立集合
F和G,且|G| < |F|,那么存在F中的一个元素,可以将其加入G而保持独立性。
满足这两个性质的结构称为拟阵。Kruskal 的贪心算法可以用于任何拟阵,以找到最大权重的独立集。
拟阵的另一个经典例子是向量空间的线性无关性。元素是向量,独立集是线性无关的向量组。Kruskal 算法(在这种语境下)可以用于从一组向量中找出最大权重的线性无关组。
总结
本节课我们一起学习了:
- 最小生成树的定义和基本性质。
- Kruskal 贪心算法的原理和正确性证明。
- 使用并查集数据结构高效实现 Kruskal 算法,并分析了其
O(m log n)的时间复杂度。 - 了解了并查集的简单实现方法。
- (扩展)知道了存在更高效的并查集实现以及 Kruskal 算法可以推广到拟阵这一抽象结构。

在下一讲中,我们将继续讨论最小生成树的其他算法(Prim算法),并开始学习图中的最短路径问题。
017:最小生成树与最短路径算法

在本节课中,我们将完成对最小生成树(MST)的讨论,重点介绍普里姆算法。随后,我们将开始学习最短路径问题,并探讨计算机科学中最著名的算法之一——迪杰斯特拉最短路径算法。将这两个算法放在同一节课中讲解,是因为它们之间存在许多相似之处。
最小生成树回顾
我们有一个无向图,其边带有权重。我们的目标是找到一个最小权重的边子集,使得所有顶点保持连通。例如,对于下图,如果我们选取某个边子集,它确实能连接所有顶点,总权重为7。另一棵树连接所有顶点,权重为10。而最小生成树的权重仅为4。
我们之前讨论过,只要边权重非负,最小权重边集总是一棵树,没有理由包含环。上一讲我们介绍了克鲁斯卡尔算法来寻找最小生成树。今天,我们将从另一种算法——普里姆算法开始。
普里姆算法
普里姆算法以贪心方式增长一个连通分量。它与克鲁斯卡尔算法是两种不同的贪心算法。
其基本结构是:从任意顶点 s 开始,逐步增长一个包含 s 的连通分量 C。在任意时刻,C 是当前已到达的顶点集合。我们有一些尚未到达的顶点,以及一些从 C 连接到这些未到达顶点的边。算法的贪心之处在于,每一步都选择从 C 出发、连接到一个不在 C 中的顶点的最小权重边。
以下是算法的步骤:
- 初始化集合
C仅包含顶点s。 - 初始化树
T为空集。 - 当
C未包含所有顶点时:- 找到一条最小权重边
e,其一端在C中,另一端不在C中。 - 将边
e加入树T。 - 将边
e在C外的那个顶点v加入集合C。
- 找到一条最小权重边
关于其正确性,可以使用与证明克鲁斯卡尔算法类似的“交换论证”。事实上,存在一个引理可以同时证明普里姆算法和克鲁斯卡尔算法的正确性。
普里姆算法的实现与分析
我们在字 RAM 模型下分析算法,假设每个权重可以放入一个字中,基本算术运算为单位成本。我们以顶点数 n 和边数 m 来分析复杂度。
算法的关键步骤是反复寻找离开集合 C 的最小权重边。这自然让人想到需要在一个动态变化的集合中反复查找最小值,因此优先队列是合适的数据结构。
我们需要支持查找并删除最小元素的操作,以及插入和删除(或更新)操作。优先队列通常可以用堆来实现,每个操作的时间复杂度为 O(log k),其中 k 是优先队列中的元素数量。在我们的场景中,k 受限于总边数 m。
具体来说,我们需要:
- 查找最小边:这是一个
find-min操作。 - 更新边界集:当新顶点
v加入C时,所有连接C和v的边不再属于边界集,需要删除;所有从v出发连接到C外顶点的边新加入边界集,需要插入。通过遍历v的邻接表,并检查另一端点是否在C中,可以区分这两种边。
观察发现,每条边只会进入边界集一次,也只会离开边界集一次。因此,每条边对应一次插入和一次删除操作。
综合以上分析,运行时间如下:
find-min操作执行n-1次(每次添加一个顶点)。- 插入和删除操作各执行
m次。 - 每次优先队列操作成本为
O(log m)。
总运行时间为 O((n + m) log m)。由于 m 至少为 n-1(否则图不连通,无需寻找生成树),且 log m ≤ 2 log n(当 m ≤ n²),因此最终运行时间为 O(m log n)。
这与克鲁斯卡尔算法的运行时间相同。普里姆算法使用了更基础的优先队列数据结构,而克鲁斯卡尔算法需要并查集。
普里姆算法的优化
有两种优化方式:
- 维护顶点的优先队列:更高效的方法是维护一个包含
C外顶点的优先队列,队列大小最多为n。为每个顶点v维护一个键值,表示从C到v的最小权重边的权重。当C扩张时,需要更新与新加入顶点相邻的、仍在队列中的顶点的键值,这对应优先队列的decrease-key操作。使用堆实现时,decrease-key可以是O(log n)。总时间仍为O(m log n),但实践中队列更小,常数更优。 - 使用斐波那契堆:理论上最快的实现方式是使用斐波那契堆。它支持
O(1)摊还时间的decrease-key操作。此时总运行时间可降至O(n log n + m)。但由于斐波那契堆实现复杂,实践中通常使用标准堆。
最短路径问题
接下来我们转向最短路径算法。这是一个极其实际的问题,例如道路网络中的最短路线或网络中的最快消息路由。
在无权(边权为1)无向图中,广度优先搜索(BFS)可以在线性时间内解决单源最短路径问题。更一般地,我们考虑带权图(有向或无向),边权可能为非负或任意值。
在带权图中,路径的权重是路径上所有边权之和。最短路径即权重最小的路径。注意,最小生成树不一定包含任意两点间的最短路径。
一个朴素的想法是,如果边权都是整数,可以将一条权重为 w 的边替换为 w 条权重为1的边,从而转化为无权图问题。但这种方法的时间复杂度与权重值本身成比例,而非其对数,因此不是多项式时间算法,效率低下。
迪杰斯特拉算法
我们将首先学习迪杰斯特拉算法(1959年提出)。它适用于边权非负的图或有向图,用于解决单源最短路径问题,即从给定起点 s 到图中所有其他顶点的最短路径。
算法的输出可以是一棵最短路径树,其大小仅为 O(n),通过记录每个顶点的父节点来实现。
迪杰斯特拉算法的思想与普里姆算法相似:从起点 s 开始,逐步增长一棵最短路径树。我们维护一个集合 B,包含已确定最短路径的顶点。初始时 B 为空(或仅包含 s,取决于实现)。
在每一步,我们考虑所有从 B 内顶点 x 出发,连接到 B 外顶点 y 的边 (x, y)。我们贪心地选择使 dist(s, x) + weight(x, y) 最小的那条边对应的顶点 y,其中 dist(s, x) 是已确定的从 s 到 x 的最短距离。然后,我们声明 dist(s, y) = dist(s, x) + weight(x, y),将边 (x, y) 加入树中,并将 y 加入集合 B。
迪杰斯特拉算法的正确性证明
核心声明:上述步骤中选择的值 D = dist(s, x) + weight(x, y) 确实是从 s 到 y 的全局最短距离。
证明:假设对于所有在 B 中的顶点,我们已经知道其正确的最短距离。考虑任意一条从 s 到 y 的路径 π。路径 π 必须至少有一次离开集合 B。设 (u, v) 是 π 上第一条离开 B 的边(u 在 B 中,v 不在)。将 π 分解为:s 到 u 的部分 π1,边 (u, v),以及 v 到 y 的剩余部分 π2。
路径 π 的权重满足:
weight(π) = weight(π1) + weight(u, v) + weight(π2)
由于边权非负,weight(π2) ≥ 0。
因此,weight(π) ≥ weight(π1) + weight(u, v)。
又因为 u 在 B 中,dist(s, u) ≤ weight(π1)(dist(s, u) 是已知最短距离)。
所以,weight(π) ≥ dist(s, u) + weight(u, v)。
而算法选择的 D 是所有形如 dist(s, x) + weight(x, y‘) 值中的最小值,其中 x 在 B 中,y‘ 不在。显然 dist(s, u) + weight(u, v) 是这些候选值之一(对应 y‘ = v),因此 D ≤ dist(s, u) + weight(u, v)。
最终得到 weight(π) ≥ D。
由于 π 是任意路径,这证明了 D 是从 s 到 y 的最短距离。通过归纳法,算法能正确找出从 s 到所有顶点的最短距离。要重构具体路径,只需从目标顶点沿父指针回溯到 s。
迪杰斯特拉算法的实现与分析
与普里姆算法类似,高效实现迪杰斯特拉算法也使用优先队列(堆)。更优的方法是维护一个包含 B 外顶点的优先队列。每个顶点 v 的键值是其“暂定距离”——即当前已知的、从 s 出发经过 B 内顶点一步到达 v 的最短路径长度。
算法步骤:
- 初始化所有顶点的暂定距离为无穷大,
dist[s] = 0。 - 初始化集合
B为空。 - 使用暂定距离作为键值,将所有顶点加入最小堆。
- 当
B未包含所有顶点时:- 从堆中提取具有最小暂定距离的顶点
y(find-min和delete)。 - 将
y加入B。 - 对于
y的每个邻居z:- 如果
z不在B中,且dist[y] + weight(y, z) < dist[z]:- 更新
dist[z] = dist[y] + weight(y, z)。 - 更新堆中
z的键值(decrease-key或先删除再插入)。 - 设置
z的父节点为y。
- 更新
- 如果
- 从堆中提取具有最小暂定距离的顶点
运行时间分析:
- 每个顶点被提取一次,共
n次find-min/delete操作。 - 每条边最多引起一次键值更新(
decrease-key),共m次更新操作。 - 使用二叉堆时,每个操作成本为
O(log n)。 - 总运行时间为
O((n + m) log n),通常写作O(m log n)(假设图连通,m ≥ n)。
历史注记与总结
迪杰斯特拉设计此算法是为了展示计算机的实用性,他当时用它来查找荷兰铁路网中任意两城市间的最佳路线。在那个时代,离散算法和有限问题的研究并未受到数学界的广泛重视,直到计算机时代来临,这类问题的重要性才凸显出来。
本节课总结:
我们学习了普里姆最小生成树算法和迪杰斯特拉最短路径算法。
- 普里姆算法:贪心增长连通分量。实现基于优先队列,运行时间为
O(m log n)。了解其基本实现及使用顶点优先队列的优化。 - 迪杰斯特拉算法:贪心增长最短路径树。要求边权非负,适用于有向图和无向图,解决单源最短路径问题。实现同样基于优先队列,运行时间为
O(m log n)。理解其正确性证明的关键在于非负权重的假设。 - 两者异同:都是贪心算法,但每一步贪心的目标函数不同:普里姆算法最小化连接内外集合的单一边权;迪杰斯特拉算法最小化从源点到外部顶点的路径总权重(当前已知距离 + 边权)。

下一讲我们将继续探讨其他最短路径算法。
018:第17讲 - 最短路径算法(下)

在本节课中,我们将继续学习最短路径算法。上一节我们介绍了Dijkstra算法,本节中我们将探讨另外两个著名且高效的算法:Bellman-Ford算法和Floyd-Warshall算法。我们将了解它们如何工作、适用场景以及如何实现。
图与最短路径回顾
我们有一个带权图,路径的长度是其所有边权重的总和。目标是找到两点之间总权重最小的路径。
以下是图的两种常见存储方式,特别是当边带有权重时:
- 邻接矩阵:使用一个
n x n矩阵,其中matrix[u][v]存储边(u, v)的权重。若无边,则用特殊值(如∞或null)表示。空间复杂度为O(n²)。 - 邻接表:为每个顶点维护一个链表,但链表节点不再仅存储相邻顶点,而是存储关联边的编号。同时,我们维护一个边列表,记录每条边的权重和两个端点。这样,空间复杂度为
O(n + m),其中n是顶点数,m是边数。
最短路径问题分类
最短路径问题主要有三种形式:
- 单源单目标:给定起点
s和终点t,求s到t的最短路径。 - 单源多目标(单源最短路径):给定起点
s,求s到图中所有其他顶点v的最短路径。Dijkstra算法解决的就是此类问题。 - 所有点对最短路径:求图中每一对顶点
(u, v)之间的最短路径。
通常,解决第1类问题会转化为解决第2类问题。
负权边与负权环
Dijkstra算法要求边权非负。如果图中存在负权边,甚至负权环(环上各边权重之和为负),问题会变得复杂。
考虑一个包含负权环的图。理论上,可以无限次绕行该环,使得路径总权重趋于负无穷。为了避免这种无意义的情况,一个自然的想法是寻找最短简单路径(不重复经过顶点的路径)。然而,寻找最短简单路径是一个NP难问题,目前没有已知的多项式时间算法。
因此,在本节讨论的算法中,我们做出以下假设来避免负无穷的情况:
- 允许负权边。
- 但图中不能存在从起点可达的负权环(对于单源问题)或任何负权环(对于所有点对问题)。
有向图与无向图
本节算法主要针对有向图。对于无向图,通常可以将其每条无向边 (A, B) 替换为两条方向相反的有向边 A->B 和 B->A。
但是,如果原无向边的权重为负,那么替换后形成的两条有向边会立即构成一个权重为 2 * w(w为负)的负权环(A->B->A)。这会破坏算法的前提假设。因此,若图中存在负权边,本节算法不直接适用于无向图。若无负权边,则可以安全地进行转换。
算法一:有向无环图(DAG)中的单源最短路径
对于一种特殊情况——有向无环图,存在一个非常高效的线性时间算法。
核心思想:利用DAG的拓扑排序性质。我们可以对顶点进行编号 1...n,使得所有有向边 (i, j) 都满足 i < j。如果源点 s 不是编号1,我们可以忽略所有在 s 之前的顶点(因为从 s 无法到达它们),并重新调整编号使 s 成为顶点1。
算法步骤:
- 对图进行拓扑排序,并调整使源点
s对应顶点1。 - 初始化距离数组
d[],d[1] = 0,其他为∞。 - 按照顶点编号
i从1到n的顺序遍历:- 对于顶点
i的每一条出边(i, j):- 尝试松弛:
d[j] = min(d[j], d[i] + weight(i, j))
- 尝试松弛:
- 对于顶点
正确性:由于顶点按拓扑序处理,当处理到顶点 i 时,所有可能到达 i 的路径都已被考虑,因此 d[i] 已是最终最短距离。接着用 i 去更新其后继顶点。
时间复杂度:O(n + m)。每个顶点和每条边都被处理一次。
动态规划与最短路径
接下来介绍的两个算法都基于动态规划思想。最短路径问题具有最优子结构性质:如果最短路径 p 经过中间点 x,那么 p 中从起点到 x 的部分以及从 x 到终点的部分,也分别是这两点间的最短路径。
基于如何定义“更小的子问题”,有两种动态规划思路:
- 限制路径的边数:子问题是“使用不超过
k条边的最短路径”。这引出了 Bellman-Ford算法(用于单源问题)。 - 限制可用的中间顶点:子问题是“只能使用前
k个顶点作为中间点的最短路径”。这引出了 Floyd-Warshall算法(用于所有点对问题)。
算法二:Bellman-Ford算法(单源最短路径)
Bellman-Ford算法用于在可能含有负权边,但不含负权环的有向图中,求解单源最短路径问题。
动态规划定义:
令 dp[k][v] 表示从源点 s 到顶点 v,最多经过 k 条边的最短路径长度。
状态转移方程:
dp[0][s] = 0dp[0][v] = ∞(对于v != s)dp[k][v] = min( dp[k-1][v], min_{(u, v)∈E} { dp[k-1][u] + weight(u, v) } )- 第一部分:不使用第
k条边,即最多用k-1条边。 - 第二部分:使用第
k条边,且该边是(u, v)。那么路径由“最多k-1条边到u”加上边(u, v)构成。
- 第一部分:不使用第
为什么 k 最大到 n-1?:在 n 个顶点的图中,任何不含环的简单路径最多包含 n-1 条边。如果一条路径有 ≥ n 条边,则它必然包含环。如果是负权环,总权重可以无限降低,但我们假设不存在负权环;如果是非负环,移除它不会使路径变差。因此,考虑最多 n-1 条边足以找到最短路径。
算法实现(空间优化版):
我们可以将二维 dp 数组优化为一维数组 dist[],并直接迭代 n-1 轮。
初始化 dist[s] = 0,其他 dist[v] = ∞
for i = 1 to n-1:
for each edge (u, v) in graph:
if dist[u] + weight(u, v) < dist[v]:
dist[v] = dist[u] + weight(u, v)
如何记录路径?:类似Dijkstra算法,维护一个 parent[] 数组。当 dist[v] 通过边 (u, v) 被更新时,设置 parent[v] = u。最终从终点 v 逆向回溯 parent 数组即可得到路径。
检测负权环:再执行第 n 轮松弛操作。如果任何 dist[v] 值还能被更新,则说明图中存在从源点 s 可达的负权环。
时间复杂度:O(n * m)。需要进行 n-1 轮松弛,每轮遍历所有 m 条边。
算法三:Floyd-Warshall算法(所有点对最短路径)
Floyd-Warshall算法用于在可能含有负权边,但不含负权环的有向图中,求解所有点对之间的最短路径。
动态规划定义:
令 dp[k][i][j] 表示从顶点 i 到顶点 j,且中间顶点(不包括起点和终点)仅限于集合 {1, 2, ..., k} 的最短路径长度。
状态转移方程:
- 初始化 (
k=0):dp[0][i][i] = 0dp[0][i][j] = weight(i, j),如果边(i, j)存在。dp[0][i][j] = ∞,如果边(i, j)不存在且i != j。
- 状态转移 (
k >= 1):dp[k][i][j] = min( dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j] )- 第一部分:不经过顶点
k。 - 第二部分:经过顶点
k。路径分解为i -> ... -> k和k -> ... -> j,且这两部分都只使用前k-1个顶点作为中间点。
- 第一部分:不经过顶点
算法实现(空间优化版):
使用一个二维数组 dist[][] 即可。
初始化 dist[i][j]:
if i == j: dist[i][j] = 0
else if edge(i, j) exists: dist[i][j] = weight(i, j)
else: dist[i][j] = ∞
for k = 1 to n:
for i = 1 to n:
for j = 1 to n:
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
如何记录路径?:维护一个 next[][] 矩阵,next[i][j] 表示从 i 到 j 的最短路径上 i 之后的下一个顶点。
- 初始化:如果边
(i, j)存在,next[i][j] = j;否则为null。 - 更新:当发现经过
k的路径更短时,next[i][j] = next[i][k]。 - 重构路径:从
i开始,不断查询next[i][j]即可。
时间复杂度:O(n³)。三层嵌套循环。
空间复杂度:O(n²),用于存储 dist 和 next 矩阵。
总结
本节课我们一起学习了最短路径领域的两个核心算法:
- Bellman-Ford算法:解决单源最短路径问题,能处理负权边并检测负权环,时间复杂度为
O(n*m)。 - Floyd-Warshall算法:解决所有点对最短路径问题,代码简洁,时间复杂度为
O(n³)。
你需要理解:
- 负权环带来的挑战以及算法的前提假设。
- 两种动态规划思路(限制边数 vs 限制顶点)如何应用于不同问题。
- 算法的工作原理、时间/空间复杂度。
- 如何实现算法并重构出具体的最短路径。
- 如何利用Bellman-Ford算法检测负权环。

最短路径是图算法中的基石,掌握这些经典算法至关重要。接下来,课程将转向计算复杂性理论,探讨为何像“最短简单路径”这类问题没有已知的高效算法,即NP完全性问题。
019:穷举搜索

在本节课中,我们将要学习两种用于解决“困难”问题的算法设计范式:回溯法与分支限界法。这些方法适用于那些目前没有已知多项式时间高效算法的问题。我们将通过具体的决策问题(如子集和问题)和优化问题(如旅行商问题)来理解这两种方法的工作原理。
概述
到目前为止,我们已经学习了许多算法设计范式,如归约、分治法、贪心算法、动态规划,以及图算法。这些算法通常被认为是高效的,其运行时间随输入规模呈多项式增长。
然而,许多实际问题没有已知的高效算法。例如,0-1背包问题、旅行商问题以及存在负权边但不允许重复边的图最短路径问题。
面对这些难题,我们有几种选择:
- 使用启发式算法:运行速度快,但不保证运行时间或解的质量。
- 使用近似算法:通常有最坏情况运行时间分析,并能保证解的质量。
- 寻找精确解:即使需要指数时间,也尝试找到最优解。
即使你倾向于使用启发式算法,为了评估其性能,也需要在小规模实例上将其解与精确解进行比较。因此,学习如何寻找精确解是有价值的。
本节课我们将学习两种寻找精确解的系统性方法:回溯法用于决策问题,分支限界法用于优化问题。它们本质上都是在部分解的隐式图中进行搜索。
回溯法
回溯法用于解决决策问题,即判断是否存在满足特定条件的解。我们将以子集和问题为例进行说明。
子集和问题
在子集和问题中,给定 n 个元素,每个元素有一个正权重 w_i,以及一个目标总重量 W。问题是:是否存在一个元素子集 S,使得 S 中元素的权重之和恰好等于 W?
公式:是否存在 S ⊆ {1, 2, ..., n},使得 ∑_{i∈S} w_i = W?
例如,有5个元素,权重分别为 {2, 2, 3, 5, 7},目标 W=13。通过分析,可以得出不存在这样的子集。
子集和问题是NP完全问题,目前没有已知的多项式时间算法。穷举所有子集需要检查 2^n 种可能性。
回溯法的基本思想
回溯法通过构建一棵搜索树来系统地探索所有可能的子集。树的根节点代表空集 S = {},剩余集合 R 包含所有元素。每个节点代表一个部分解(当前的 S)和剩余可选择的元素(R)。
从根节点开始,我们依次对每个元素做出选择:将其加入 S 或不加入。这会产生两个子节点。如此递归进行,直到处理完所有元素,每个叶节点就对应一个完整的子集。
代码:搜索树的大小为 O(2^n)。
通用回溯算法
以下是回溯法的通用伪代码框架:
A = {根配置} # 活跃配置集合
while A 非空:
从 A 中移除一个配置 C
if C 是一个解:
输出 C 并停止
elif C 是一个死胡同:
丢弃 C
else:
通过做出更多选择,将 C 扩展为若干子配置 C1, C2, ...
将所有子配置加入集合 A
算法的具体行为取决于三个问题相关的部分:
- 如何判断一个配置是解?
如何判断一个配置是死胡同(无法扩展为有效解)?- 如何将一个配置扩展为子配置?
集合 A 的存储方式影响搜索策略:
- 栈(深度优先搜索):空间复杂度与树高相关(
O(n))。 - 队列(广度优先搜索):空间复杂度与树宽相关(
O(2^n))。 - 优先队列(最佳优先搜索):根据某种启发式优先级选择下一个要探索的配置。
对于子集和问题,通常使用栈以实现较低的空间占用。
应用于子集和问题
现在,我们将通用框架应用于子集和问题。一个配置 C 包含当前集合 S、剩余集合 R、S 的总重量 w 以及 R 的总重量 r。
探索配置 C 的步骤如下:
- 如果
w == W,则S就是一个解,算法成功。 - 如果
w > W(假设权重为正),则当前路径已超重,是死胡同。 - 如果
w + r < W,即使将R中所有元素加入S,总重量也无法达到W,也是死胡同。 - 否则,选择
R中的一个元素,创建两个子配置:一个将其加入S,另一个不加入。
该算法的运行时间为 O(2^n),因为最多探索 O(2^n) 个配置,每个配置的处理是常数时间。
子集和问题也有一个动态规划算法,运行时间为 O(nW)。当目标重量 W 较小时,动态规划更优;当 W 非常大时,回溯法(尤其是其空间效率)可能更合适。
探索所有排列
回溯法也可用于需要探索所有排列的问题,例如哈密顿回路问题:在给定图中,是否存在一个经过每个顶点恰好一次的环?
一个配置可以是一个部分顶点排列 P 和剩余顶点集合 R。根节点是空排列。扩展一个节点时,从 R 中选择一个顶点作为排列的下一个元素。对于哈密顿回路问题,在扩展过程中,如果发现当前部分排列中最后两个顶点之间没有边,或者某个顶点的度数超过2,则可以提前判定为死胡同。
探索所有排列的搜索树有 n! 个叶节点。
上一节我们介绍了用于决策问题的回溯法,本节中我们来看看用于优化问题的分支限界法。
分支限界法
分支限界法用于寻找优化问题的最优解(如最小化或最大化某个目标函数)。我们将以旅行商问题作为运行示例。
优化问题 vs. 决策问题
首先,明确优化问题与决策问题的区别:
- 决策问题:询问是否存在满足条件的解(例如,子集和、哈密顿回路)。
- 优化问题:在众多可行解中寻找最优解(例如,0-1背包、旅行商问题)。
旅行商问题是哈密顿回路问题的优化版本:给定一个带权无向完全图(边权非负),寻找一个访问每个顶点恰好一次并回到起点的环,使得环上所有边的权重之和最小。
旅行商问题具有重要的理论和实际意义,是NP难问题。
分支限界法基本思想
与回溯法类似,分支限界法也系统性地搜索解空间树。关键区别在于“限界”步骤:
- 维护当前最优解:记录迄今为止找到的最佳目标函数值(例如,最小权重)。
- 计算下界:对于每个部分解(配置),快速计算其可能扩展出的任何完整解的目标函数值的下界。
- 剪枝:如果某个配置的下界已经不优于当前最优解,那么继续探索这个分支不可能找到更好的解,因此可以安全地丢弃(剪枝)该配置。
这使我们能够避免探索大量无望的分支。
通用分支限界算法
以下是分支限界法的通用伪代码框架(以最小化问题为例):
A = {根配置} # 活跃配置集合,通常用优先队列存储
best_cost = ∞
while A 非空:
从 A 中移除**最有可能**的配置 C # 通常取下界最小的
将 C 扩展为若干子配置 children
for each 子配置 C_i in children:
if C_i 是一个完整解:
cost = 计算 C_i 的目标函数值
if cost < best_cost:
best_cost = cost # 更新最优值
# 注意:仍需继续搜索,可能还有更优解
elif C_i 是死胡同:
丢弃 C_i
else: # C_i 是部分解
lower_bound = 计算 C_i 的下界
if lower_bound < best_cost:
将 C_i 加入 A # 有潜力找到更优解
# 否则,下界 >= best_cost,剪枝
应用于旅行商问题
对于旅行商问题,我们采用一种基于边的搜索策略,而不是基于顶点排列。一个配置 C 由两个边集合定义:
IN:必须包含在最终回路中的边。OUT:必须排除在最终回路外的边。
分支:选择一个尚未决定的边 e,生成两个子配置:一个将 e 加入 IN,另一个将 e 加入 OUT。
识别死胡同(剪枝条件):如果一个配置满足以下任一条件,则无法扩展为有效哈密顿回路:
- 从图中移除
OUT中的边后,图不再连通。 IN中任意顶点的关联边数超过2(因为回路中每个顶点度数为2)。IN中的边形成了小于n个顶点的环(即非全环)。
计算下界:这是算法的核心。我们需要为配置 (IN, OUT) 计算一个旅行商回路权重的下界,且计算要快。我们通过解决一个松弛的、更容易的问题来实现:寻找最小权重1-树。
- 1-树定义:一个1-树由两部分组成:
- 在顶点
{2, 3, ..., n}上的一棵生成树。 - 连接顶点
1与这棵生成树的两条最小权重边。
- 在顶点
- 关键性质:任何哈密顿回路都是一个1-树(将回路中与顶点1相连的两条边去掉,剩下的路径就是其他顶点上的一棵树)。因此,最小权重1-树的权重 ≤ 最小权重旅行商回路的权重。
- 计算方法(针对配置
(IN, OUT)):- 暂时移除
OUT中的所有边。 - 将
IN中边的权重临时设为0(以确保它们被最小生成树算法优先选择)。 - 在修改后的图中,使用Kruskal或Prim算法,找出顶点
{2, ..., n}上的最小生成树。 - 找到连接顶点
1到该生成树的两条最小权重边(使用原始权重)。 - 计算这个1-树中所有边的原始权重之和。这个值就是当前配置的一个下界。
- 暂时移除
选择下一个配置:通常从优先队列 A 中选择下界最小的配置进行扩展,因为它最有可能导向最优解。
分支边的选择:也可以采用启发式策略,例如选择能最大程度提高下界的边进行分支,以加速剪枝。
结合这些技巧,分支限界法可以构成一个解决中小规模旅行商问题的竞争性精确算法。
总结
本节课我们一起学习了两种用于解决难解问题的穷举搜索技术:
- 回溯法:适用于决策问题。通过深度优先搜索解空间树,并利用约束条件提前剪枝无效分支。我们以子集和问题为例,展示了其工作原理。
- 分支限界法:适用于优化问题。在回溯的基础上,通过为部分解计算目标函数的下界,并与当前最优解比较,来剪掉不可能产生更优解的分支。我们以旅行商问题为例,详细说明了如何定义配置、进行分支、识别死胡同以及计算关键的下界(通过最小权重1-树)。

这两种方法是处理NP难问题时寻求精确解的重要工具。理解它们有助于你在面对新问题时,判断是应继续寻找高效算法,还是需要诉诸于穷举搜索或启发式方法。接下来的课程中,我们将深入探讨NP完全性理论,这将为我们理解问题的计算难度提供理论基础。
020:NP完全性入门

在本节课中,我们将开始学习NP完全性这一主题。我们将探讨多项式时间算法的概念,理解为什么某些问题看似无法在多项式时间内解决,并学习如何通过“归约”来证明不同问题的计算难度是等价的。
多项式时间与下界
上一节课程中,我们重点学习了算法设计与分析。在本节中,我们来看看算法的“下界”问题,即判断一个算法是否为解决某个问题的最佳算法。
假设我们为某个问题设计了一个算法A,其最坏情况运行时间是输入规模N的函数T(N)。一个重要的问题是:这个算法是否是最好的?我们是否需要回到设计阶段寻找更好的算法?
例如,在上一讲中,我们看到了用于解决最大独立集或子集和问题的分支限界法,其运行时间表现为 Θ(2^N)。这意味着运行时间是 O(2^N),并且存在无限多的问题实例,其运行时间确实如此。这是一个指数时间算法。那么,对于最大独立集或子集和问题,这真的是我们能做到的最好结果吗?
为了证明什么是最佳可能,我们需要证明任何其他解决最大独立集的算法,其最坏情况运行时间至少也这么糟糕。然而,证明这样的下界非常困难。对于子集和与最大独立集问题,目前还没有人能够证明这样的下界,但也没有人找到更快的算法。这就是本节课程要探讨的内容。
目前,计算机科学中下界研究的现状并不乐观。已知有些问题根本没有算法(艾伦·图灵在20世纪30年代已证明),有些问题只能在指数时间内解决,但这不包括前面提到的问题。还有一些精细的下界,例如证明排序至少需要 N log N 次比较,但这仅限于特定的计算模型(只计算输入数字的比较次数)。在通用计算模型中,这个下界并不成立。
因此,本节课程探索的主要开放性问题与下界有关。存在许多已知问题,如旅行商问题、0/1背包问题,目前无人知晓其多项式时间算法,但也无人能证明不存在多项式时间算法。
目前,人们能做到的最好结果是证明一大类问题在某种意义上是“等价”的:如果我们能在多项式时间内解决其中一个问题,那么我们就能在多项式时间内解决所有这些问题;反之,如果我们能证明其中一个问题不能在多项式时间内解决,那就意味着所有这些问题都不能。
在本节课程中,我们的焦点是多项式时间以及具有多项式时间算法的问题类别,这个类别被称为 P类。我们证明这类等价性的主要工具是“归约”。你们之前已经见过归约的概念。这类等价问题的名称就是 NP完全问题。我们将从花时间理解多项式时间和归约开始。
本节课程的目标是:
- 熟悉NP完全性的概念,这是计算机科学学位毕业生必须了解的。
- 能够识别一些NP完全问题,这可以防止你在未意识到自己在处理一个非常困难的开放性问题时,徒劳地尝试为NP完全问题寻找高效算法。
- 在本节课程结束后,能够自己完成一些NP完全性证明,从而理解其中涉及的要点,并掌握工具来证明你试图解决的问题属于这些难解的NP完全问题。
多项式时间的定义
首先,定义多项式时间:一个算法的运行时间是多项式时间,如果其运行时间(我们总是讨论渐近的、大O符号表示的最坏情况)是 O(N^K),其中N是输入规模,K是某个常数。
以下是几个例子:
- O(N^2) 是多项式时间吗?是的,常数K是2。
- O(N^5) 是多项式时间吗?是的。
- O(N log N) 是多项式时间吗?是的,因为 O(N log N) 受 O(N^2) 限制。
- O(2^N) 是多项式时间吗?不,那是指数时间。
- O(N!) 是指数时间,它不受多项式限制。
- O(N^1000000) 是多项式时间吗?K的值非常大,但它是一个常数,所以算作多项式时间。
- O(N(2(2^(2)))) 是多项式时间吗?即使我添加更多的2,指数部分仍然是一个常数,所以是多项式时间。
到目前为止,我们在本课程中学习的大多数算法都是多项式时间的。例外情况包括上一讲中看到的回溯法和分支限界算法,以及0/1背包问题的伪多项式时间算法(其运行时间类似于 N * W,其中W是实际数值而非其编码规模,因此不符合多项式时间定义)。
多项式时间的历史与评价
关于多项式时间定义的历史:它比图灵关于哪些问题有算法的结果要晚。非正式地,我们可以将多项式时间视为“好”或“高效”的算法。这个概念最早由Jack Edmonds引入,他直到退休前都是组合优化系的教授。在他1963年的论文中,他给出了在图中寻找最大匹配的多项式时间算法。
Jack Edmonds在那篇论文中写道,他将给出一个最大匹配的“好”算法。他说:“我声称存在一个好算法作为一个数学结果。有一个明显的有限算法(指尝试所有可能的匹配,数量是指数级的),但该算法的难度随图的大小呈指数增长。是否存在一个难度仅随图的大小代数(多项式)增长的算法,这一点完全不清楚。”
因此,Jack Edmonds是最早探索这一概念的人之一。这张幻灯片展示了Jack Edmonds在撰写那篇论文时的大致样貌。
在这张幻灯片中,我想挑战“多项式时间总是好的吗?”这个问题。如果一个算法的运行时间是 N100**,那真的高效吗?并不真的高效。但一个有趣的事实是,已知的具有此类运行时间的算法非常少。另一方面,对于非常大的N,**N100 仍然比类似 2^N 要好。
另一个需要注意的例外是,有些有用的或“好”的算法在最坏情况下实际上并不是多项式时间。最著名的例子可能是线性规划的单纯形算法。虽然存在更复杂的线性规划多项式时间算法,但单纯形算法仍然具有竞争力,因为它在大多数输入上表现良好。然而,单纯形算法的缺点是存在会导致其运行时间非常糟糕(指数时间)的输入。
另一个例外是随机算法,它们具有良好的期望运行时间,但可能具有糟糕的最坏情况运行时间。尽管如此,在大多数情况下,公平地说多项式时间是好的算法,而非多项式时间的算法是相当糟糕和低效的。
归约的定义
到目前为止的总结:我们将在本节课程中研究哪些问题可以在多项式时间内解决,哪些似乎不能。你现在应该知道什么是多项式运行时间以及为什么它重要。接下来,我们将讨论归约并定义这些不同的复杂性类别。
我们将从归约的定义开始。如果我们有问题X和问题Y,我们说“X归约到Y”,记作 X ≤ Y,如果拥有解决问题Y的算法,可以用来得到解决问题X的算法。为了使这个“小于等于”符号有意义,你应该认为这意味着X比Y更容易。这如何与定义相符?它说如果我能解决Y,那么我就能解决X。所以X是更容易的问题,对吧?
我们通过说“问题X在多项式时间内归约到问题Y”来细化这个定义,记作 X ≤_P Y,如果拥有解决问题Y的多项式时间算法,允许我们得到解决问题X的多项式时间算法。这是归约的基本定义。
X ≤_P Y 这个定义的一个重要推论是其逆否命题:如果X不能在多项式时间内解决(即我们有证明),那么Y也不能在多项式时间内解决。
即使我们没有这两个假设(既没有Y的算法,也没有X不能在多项式时间内解决的证明),证明一个归约仍然是有价值的。归约将表明两个问题是同等困难的。如果我证明 X ≤_P Y 且 Y ≤_P X,那么我就知道这两个问题是等价的:在多项式时间内解决一个就能解决另一个;证明一个不能在多项式时间内解决也就证明了另一个也不能。
这就是我们在NP完全性中遇到的情况:我们能够证明问题的等价性,但由于目前的研究水平,我们还无法找到多项式时间算法或下界。
归约示例:哈密顿路径与哈密顿环
我将展示一个归约的例子。事实上,它将具有我上一张幻灯片提到的特殊形式。我要看的问题是哈密顿环和哈密顿路径。它们被定义为访问图中每个顶点恰好一次的环或路径。
例如,这个小十二面体图有一条哈密顿路径(如图),它恰好访问每个顶点一次。然而,这个图没有哈密顿环。在这个特定情况下很容易看出,因为这里有一个度为1的顶点。如果有一个哈密顿环,我必须访问这个顶点然后离开它,但没有其他边可以让我离开。我不能走这条边回去,否则会重新访问那个顶点。
尽管哈密顿环和路径以数学家Hamilton命名,但在他研究之前的几个世纪,人们就已经在考虑它们了。特别是在9世纪的克什米尔,人们研究是否能在国际象棋棋盘上找到骑士的哈密顿环。这是一个8x8的棋盘,他们想知道骑士能否通过合法移动(如图),访问棋盘的每个方格恰好一次,没有任何重复。我在这里提供了一个链接,阅读起来非常有趣,但请注意这只是为了丰富知识,并非必需。
从算法的角度来看,我们研究的问题是:给定一个图,它是否有哈密顿环?或者给定一个图,它是否有哈密顿路径?
我在上一讲中提到,我们可以使用回溯算法来判断一个图是否有哈密顿路径或哈密顿环。那些是指数时间算法。事实是,目前没有人知道如何在多项式时间内解决这两个问题中的任何一个,因此回溯算法是已知最好的算法。
现在,我将向你们展示这两个问题实际上是等价的。我将展示其中一个方向,并将另一个方向留作练习。我们将证明 哈密顿路径 ≤_P 哈密顿环。
根据定义,这意味着如果存在解决右边这个较难问题(哈密顿环)的多项式时间算法,那么就存在解决左边问题(哈密顿路径)的多项式时间算法。等价的逆否命题是:如果不存在解决哈密顿路径的多项式时间算法,那么也不存在解决哈密顿环的多项式时间算法。
我们将根据定义来证明这个引理。假设我们有一个多项式时间算法(我称之为 A_cycle)用于哈密顿环。我们可以像调用子程序一样调用它,它是一个黑盒,我可以调用它并获得正确答案,并且它在输入规模的多项式时间内运行。我们现在的目标是设计一个用于哈密顿路径的多项式时间算法。
我们应该像设计任何算法一样思考这个问题:我们有输入(一个图G),期望输出是回答“图G是否有哈密顿路径?”(是或否)。
设计这个算法时,你首先可能想到的是,我们当然想利用这个 A_cycle 算法。如果我一开始就直接把我给定的图G送给环算法,会发生什么?如果 A_cycle 算法返回“是”,那意味着我的图G有一个哈密顿环。我们以多项式时间完成了这个测试。这看起来进展不错,对吧?如果一个图有哈密顿环,那么它就有哈密顿路径(我只需去掉环的最后一条边,就得到一条路径)。到目前为止这看起来不错。我已经回答了我想要的问题:G有哈密顿路径。
问题出现在我们考虑另一种可能性时。如果 A_cycle 返回“否”怎么办?那说明这个图没有哈密顿环。那么我们是否知道它是否有哈密顿路径呢?我们在上一张幻灯片看了这个十二面体图,我也想与这个可能的图进行比较。我们必须说,我们真的无法判断,我们不知道。
这里有一个例子:这个图G没有哈密顿环(所以它可能是算法返回“否”时出现的一个例子),但它确实有一条哈密顿路径。另一方面,这里有一个图,它没有哈密顿环,如果你仔细看,会发现它也没有哈密顿路径。这两个小例子表明,仅仅因为 A_cycle 返回“否”(在这两种情况下都是),并不意味着我能判断图是否有哈密顿路径。
因此,尽管这种方法不对,但它给了我们一些直觉:我们真正需要的是从我的图G构造出一个新图G‘,使得G有哈密顿路径当且仅当G’有哈密顿环。明白吗?如果我能够做到这一点,那么我就大功告成了,因为我会把我的图G‘送给算法 A_cycle,它会告诉我G’是否有哈密顿环,从而告诉我G是否有哈密顿路径。
因此,我们的计划应该是构造这个图G‘,使得G有哈密顿路径当且仅当我的新图G’有哈密顿环。让我们想想如何做到这一点。
你可能会想到的第一个想法是:我有这个图G,如果它有哈密顿路径,我想确保G‘有哈密顿环。那么,如果我尝试为G’添加一条边呢?嗯,如果我知道添加哪条边,那一切都好办。但问题是我们不知道添加哪条边,对吧?所以我们必须以某种方式处理这个问题。我一开始画出了路径,但我们不知道路径是什么。那是我们无法解决的难题。
这是第二个想法,用来克服这个困难。我有我的图G,它有一些顶点和一些边(我不知道它们具体是什么)。但我要做的是创建一个新的顶点,我称之为v,并将v连接到这边每一个可能的顶点,所有的顶点。现在思考一下,这就是G‘。所以G’有一个新顶点v,与G中的每个顶点相邻。
让我再举一个例子:如果我有我的小十二面体图,我的归约是做什么?我创建一个新顶点v,并将它连接到G中所有的顶点(无论有多少个)。
现在让我们稍微思考一下。如果我在G中有一条哈密顿路径,我能在G‘中得到一个环吗?当然可以,我只需用那两条边连接到v。注意,这条哈密顿路径从哪里开始和结束并不重要,我总是可以连接它。反过来,如果G’有一个哈密顿环,它必须从v到某个点,最终回到v,而环在G内部的部分将是一条路径。所以这个想法是可行的。
我已经更详细地写下了算法。记住我们试图做什么:我们的输入是一个图G,我们试图回答“G是否有哈密顿路径?”我们可用的工具是我们有一个测试哈密顿环的神奇算法 A_cycle。
以下是完整的算法细节:
- 我通过添加一个与G所有顶点相邻的新顶点v来构造图G‘。
- 然后我在G’上运行环算法
A_cycle。 - 无论它给出什么答案,我都返回那个答案。
像往常一样,当我们设计了一个算法,我们必须处理正确性并分析运行时间。我将首先看运行时间,然后再回到正确性问题(我已在上一张幻灯片概述,但会更仔细地讨论)。
首先,运行时间。这里的步骤1是构造这个图,通过添加一个新顶点和这些新边,这需要线性时间(O(N + M),顶点数加边数),这没问题。
步骤2是运行这个算法 A_cycle。我们假设(根据前提)它在多项式时间内运行。这里我们必须小心一点:它在其输入规模的多项式时间内运行,其输入是G‘。所以我必须关心G’的规模是多少。如果G有N个顶点和M条边,G‘有多大?G’有N+1个顶点(原始N个加上新增的1个)。它有多少条边?我有原始的M条边,加上我添加到顶点v的N条新边。所以 A_cycle 在 O(poly(N + M)) 时间内运行。通常,如果我将输入规模加倍,那仍然是多项式时间,对吧?所以算法在多项式时间内运行,这部分是好的。
接下来,让我们转向正确性问题。这里我们需要证明的是:G有哈密顿路径当且仅当这个算法返回“是”。我们将依赖的是:算法返回“是”当且仅当 A_cycle 返回“是”,对吧?这是我们的第一步。A_path 返回“是”答案当且仅当 A_cycle 返回“是”。这只是通过代码来看的,只是看代码如何工作。并且我们知道 A_cycle 工作正确,所以它返回“是”当且仅当G‘有哈密顿环。好的,这是因为该算法是正确的,A_cycle 是正确的。
所以我们真正需要证明的是:记住我们试图证明的是G有哈密顿路径当且仅当这个(算法返回“是”),我已经将其延伸为当且仅当G‘有哈密顿环。所以我需要证明G有哈密顿路径当且仅当G’有哈密顿环。
这仍然是一个“当且仅当”陈述,所以我们来看两个方向。
- 首先,假设G有一条哈密顿路径。假设它经过u1, u2, ..., uN,这是一条恰好访问每个顶点一次的路径。那么,在G‘中,我将把它变成一个环。如果你回想两张幻灯片前的图片,思路就是取这条哈密顿路径,并添加两条连接到v的边。所以哈密顿环是 v -> u1 -> ... -> uN -> v。这证明了一个方向。
- 另一个方向,如果G‘有一个哈密顿环,那么如果我从那个哈密顿环中删除顶点v,就会在G中得到一条哈密顿路径。就这张图片而言,这只是说我扔掉那两条边,剩下的就是这里的一条哈密顿路径。好的,这就完成了正确性证明。
这里我想评论一下这种算法的特殊形式。我们只运行一次算法 A_cycle。我只调用它一次,无论它给出什么答案,我都返回那个答案。这就是我之前提到的特殊类型的归约,它被称为“多一归约”,非正式地我称之为“一次性归约”,因为这是一个更容易记住的名字。我们如何记住这个一次性归约?因为我只调用这个算法一次,我只有一次机会,并且必须返回它给出的任何答案。我将在后面写下这个概念更正式的定义。
作为练习,我建议你找到另一个方向的归约,即证明哈密顿环问题在多项式时间内归约到哈密顿路径问题。遵循我们之前做的相同思路,这里的秘诀将是:如果我有一个图G作为哈密顿环问题的输入,我需要做的是构造一个图G‘作为哈密顿路径问题的输入,并以这样的方式构造:我的原始输入G有哈密顿环当且仅当G’有哈密顿路径。这给了你一个提示,即证明这个引理所需的主要内容,而实际进行这个构造所需的小技巧就是你需要让这个归约工作。
决策问题与P类
好的,这就完成了讲座的第二部分。我们将研究哪些问题似乎不能在多项式时间内解决,我们使用归约。从这部分讲座中你应该知道的是归约的定义,并吸收我们给出的将一个问题归约到另一个问题的例子。接下来,我们将给出这些类的一些定义。
P类和NP类被定义为决策问题的类。所以我们首先说明什么是决策问题以及为什么只关注它们就足够了。决策问题基本上是输出只是“是”或“否”答案的问题。NP完全性理论专注于决策问题,部分原因是这样更容易表述。如果这本身不是一个足够好的借口,但事实是优化问题和决策问题通常在多项式时间意义上是等价的。
让我们看一些决策问题的例子:
- 给定一个数字,它是素数吗?这里我们期望答案是“是,它是素数”或“不,它不是素数”。
- 给定一个图,它有哈密顿环吗?是或否。
- 这是旅行商问题的决策版本。我们通常认为这是一个优化问题,但这是决策版本:给定一个带权图G和一个数字K,我们想知道:这个图是否有一条长度小于等于K的旅行商路线?是或否。
在某些情况下,对应的(我们不是优化,我们只是想找到我们在决策问题中质疑其存在的东西)问题是:
- 对于“给定一个数字,它是素数吗?”,如果数字不是素数,告诉我它的质因数分解。例如,数字6是素数吗?不,它不是。但你要告诉我6等于2乘以3。
- 对于哈密顿环问题,我们实际上想找到那个环。
- 对于旅行商问题,我们想找到权重小于等于K的路线,并且我们也想找到最小的K并找到那条路线。
我们将讨论为什么决策问题和优化问题在多项式时间意义下通常是等价的。现在没有这方面的通用证明,这是另一个开放问题,但情况通常是可以的。让我先提到一个情况不行的例子:已知有一个多项式时间算法用于测试一个数字是否是素数(这是2002年一个相对较新的结果)。另一方面,没有已知的多项式时间算法可以分解数字。事实上,我们的公钥密码系统依赖于因子分解是困难的这一假设。所以这是一个已知决策版本和寻找因子版本在多项式时间意义上是否等价尚不清楚的情况。
然而,所有其他这些是NP完全问题的问题,它们的等价性是已知的。让我们看看其中一些结果,或者至少看一个例子。
我们将看最大独立集问题。记住,图中的独立集是一组顶点,其中任意两个顶点之间没有边相连。所以我可以把这个顶点和这个顶点放在一个独立集中,因为它们之间没有边。然而,我不能放入这个顶点和这个顶点,因为有一条边。
在这个特定的小图中,如果我们问最大规模的独立集是什么?我们不应该考虑包含度数很高的顶点,因为如果我把这个顶点放入独立集,它就排除了它所有的邻居,只剩下这个顶点可以与之共存,这将给出一个大小为2的独立集。然而,如果我们选择这个顶点、这个和这个,那么我们有一个大小为3的独立集。你可以检查其他可能性,看看3确实是我们在这个图中能获得的最大独立集。
对于独立集,优化版本是找到一个最大规模的独立集。问题的决策版本是:给定某个数字K,我们只问:是或否,是否存在一个大小至少为K的独立集?注意,我在这里问的是大小至少为K,因为这是一个最大化问题,我们想要尽可能大的独立集。找到一个小的独立集(比如大小为0)一点也不有趣。
让我们看看优化和决策在多项式时间意义上的等价性。在一个方向上,这真的很容易:如果我有一个用于优化问题的多项式时间算法,以下是我解决决策问题的方法:我只需取最优独立集,看它的大小,看看是否至少为K,对吧?所以只需检查我们通过假设在多项式时间内找到的最大值是否大于等于K。这就告诉了我们的是/否决策。
所以这个等价的另一个方向是更有趣的,它说:如果我有一个用于决策问题的多项式时间算法(只回答是或否,是否存在某个大小K的独立集),那么我们可以在多项式时间内解决优化问题。这与“20个问题”小工具有些共同点,也许你们有些人小时候玩过。这是一个小工具,你想着某样东西,然后小工具开始问是/否问题(它比面包盒大吗?),你输入是/否答案,最终小工具得出你正在想的东西的答案(你在想脚趾甲或你七岁时想的任何东西)。
让我们看看这个归约。首先,我们想找出独立集的最大大小是多少,我们可以通过尝试不同的K值来做到这一点,对吧?所以我们可以找到最大值,我称之为 K_opt(K的最优值),通过测试不同的K值。我们只对多项式时间感兴趣,所以我们甚至可以尝试K=1, 2, ...,直到N,或者我们可以更聪明地使用二分查找。为了做到这一点,我们正在使用决策算法。好的,我们假设我们有一个多项式时间决策算法。
在我们找到最大的 K_opt 之后,我们想找到一个实际大小为 K_opt 的独立集。做到这一点的技巧是逐个删除顶点,并说:你知道,如果我从图中删除这个顶点,我是否仍然有一个大小为3的独立集?答案是“是”,那么我实际上删除这个顶点并在更小的图上重复。当我删除这个顶点并问:是否仍然存在一个大小为3的独立集?我有一个三角形和另一个三角形,所以在删除这个之后我能获得的最大独立集是2。所以我说,哇,我不应该删除那个。这就是它的思路。
让我写下来。然后,为了找到一个大小为 K_opt 的独立集(我将独立缩写为IND),我们将逐个尝试顶点。在测试一个顶点后,我永远不会再回到它。所以,如果我发现图中(原始图,但删除某个顶点v后)的最大独立集大小仍然是相同的 K_opt(我可以用我的决策算法测试这一点),那么我将通过取图减去那个顶点v来缩减我的图。我可能留作练习来证明的断言是:在最后,图中剩下的所有顶点恰好构成一个大小为 K_opt 的独立集。好的,我只在删除一个顶点后仍然留下一个该大小的独立集时才删除它。这是证明这一点的核心。
然后,对于这个归约,我们应该做的另一件事是:这是第二个主张。这个算法在多项式时间内运行,假设我们的决策算法在多项式时间内运行。这里主要要检查的是我调用决策算法多少次,以及我要求决策算法运行的图规模是多少?所以这是假设决策算法是多项式时间的。好的,所以要检查我调用它多少次:我首先调用它N次(找到 K_opt),然后我为每个顶点再调用它N次,所以总共2N次,这是多项式次数。好的。
总结
有了为什么只关注决策问题是合理的这一理由,我们现在准备定义 P类。它是具有多项式时间算法的决策问题的类。同样重要的是说明我们使用的是哪种计算模型,在这种情况下我们测量的是比特复杂度。
在下一讲中,我们将正式定义 NP类。它似乎是一个更大的类(尽管没有人真正知道,也许P等于NP类),它是一个决策问题的大类,未知是否能在多项式时间内解决。NP类中最难的问题就是所谓的 NP完全问题。其主要理论是,它们在某种意义上是等价的:其中一个的多项式时间算法将为所有问题提供多项式时间算法。属于NP类的一些问题包括哈密顿路径或环问题、旅行商问题、独立集问题(作为决策版本)。
下一讲的一个预告是,这些问题的共同特征(实际上将是我们对NP类的定义)是:如果某个决策问题的答案是“是”,那么有一种简单的方法可以说服某人答案是“是”。例如,给定一个图,它有哈密顿环吗?如果有,你可以直接把环展示给某人,他们就会相信。另一方面,如果图没有哈密顿环,不清楚你能展示什么让他们快速验证这个图没有哈密顿环。这只是下一讲的预告。
本节课到此结束。总结一下,我们开始了对看似不能在多项式时间内解决的问题的研究。我们专注于决策问题。从这节课的最后一部分,你应该知道什么是决策问题,为什么只关注它们是合理的(特别是如何在优化问题和决策问题之间进行归约),以及最后,P类的定义。接下来,我们将定义NP类和NP完全性。

本节课就到这里。
021:第20讲 - NP完全问题入门

在本节课中,我们将学习计算复杂性理论中的核心概念:NP 类和 NP完全 问题。我们将了解NP问题的定义、验证算法的概念,并学习如何证明一个问题是NP完全的。
什么是NP问题?🤔
上一节我们介绍了P类问题,即存在多项式时间算法的决策问题。本节中,我们来看看一个更广泛的类:NP。
NP代表“非确定性多项式时间”。一个决策问题属于NP类,当且仅当对于任何答案为“是”的输入,都存在一个简短证明(证书),并且存在一个多项式时间验证算法来检查这个证书是否正确。
以下是NP问题的几个例子:
- 哈密顿回路问题:给定一个图,问是否存在一条经过每个顶点恰好一次的回路。
- 旅行商问题(决策版):给定带权图和数值K,问是否存在一条总权重不超过K的旅行商回路。
- 独立集问题:给定图和数值K,问是否存在一个大小至少为K的独立顶点集(即集合中任意两点无边相连)。
这些问题的共同特点是:如果答案是“是”,我们可以提供一个易于验证的证书。例如,对于独立集问题,证书就是那个大小为K的独立集本身。
验证算法与NP的正式定义 📝
为了更精确地定义NP,我们引入验证算法的概念。
一个针对决策问题X的验证算法A,接受两个输入:原始输入x和证书y。它输出“是”(验证通过)或“否”(验证不通过)。该算法需要满足:对于任何输入x,原问题的答案是“是”,当且仅当存在一个证书y使得算法A输出“是”。
如果算法A的运行时间是输入x和证书y大小的多项式函数,并且证书y的大小本身也是x大小的多项式函数,那么我们称X可以在多项式时间内验证。
NP类就是所有能在多项式时间内验证的决策问题的集合。
证明问题属于NP的例子 ✅
要证明一个问题属于NP,我们需要指明证书是什么,并描述验证算法。
例1:子集和问题
- 问题:给定一组数字和一个目标值W,问是否存在一个子集,其元素之和恰好等于W。
- 证书:满足条件的子集S。
- 验证:检查子集S中所有数字之和是否等于W。求和操作可在多项式时间内完成。
例2:旅行商问题(决策版)
- 问题:给定带权图和数值K,问是否存在长度不超过K的TSP回路。
- 证书:一个顶点排列(即回路顺序)。
- 验证:
- 检查该排列是否包含每个顶点恰好一次。
- 检查排列中相邻顶点(包括首尾)之间是否有边。
- 计算该回路所有边的权重之和,检查是否 ≤ K。
所有这些步骤都可在多项式时间内完成。
似乎不属于NP的问题 ❓
理解哪些问题似乎不属于NP同样重要。这里“似乎”是因为尚未被证明,但人们尚未找到将其归入NP的方法。
例1:唯一子集和问题
- 问题:给定数字集合和目标W,问是否存在唯一的子集使其和为W。
- 难点:如何提供一个简短的证书,既能证明存在一个解,又能证明没有其他解?枚举所有其他子集不是多项式大小的证书。
例2:平面Steiner树问题
- 问题:给定平面上一些点,允许添加额外的点(Steiner点),问是否存在一棵连接所有给定点(可能通过Steiner点)的树,其欧几里得总长度不超过K。
- 难点:
- 证书中的Steiner点坐标可能非常大甚至是无理数,难以表示。
- 验证总长度 ≤ K 需要计算平方根的和,目前未知如何在多项式时间内精确比较。
P、NP与co-NP的关系 🔗
- P ⊆ NP:任何有多项式时间算法的问题,自然可以在多项式时间内验证(证书为空,直接用原算法验证)。
- co-NP类:指那些“否”答案可以在多项式时间内验证的决策问题。例如“判断一个数是否为合数”,证书是两个大于1的因数a和b,验证a*b=n即可。
- P vs NP 问题:这是计算机科学中最著名的开放问题,问是否P = NP。若成立,则所有NP问题都有多项式时间算法。
- NP vs co-NP 问题:是否NP = co-NP?同样未知。
我们还知道,任何NP问题都可以在指数时间 O(2^{p(n)}) 内解决,其中p(n)是多项式函数。思路是暴力枚举所有可能的多项式大小证书。
什么是NP完全问题?🏆
NP完全问题是NP类中“最难”的问题。
定义:一个决策问题X是NP完全的,如果:
- X ∈ NP。
- 对于所有 Y ∈ NP,Y都可以在多项式时间内归约到X。
这意味着,如果任何一个NP完全问题存在多项式时间算法,那么所有NP问题都存在多项式时间算法(即P=NP)。反之,如果证明某个NP完全问题不存在多项式时间算法,则所有NP完全问题都不存在。
证明第一个NP完全问题非常困难,需要证明所有NP问题都能归约到它。但一旦有了第一个,证明其他问题NP完全就变简单了,因为归约具有传递性。
如何证明NP完全性:两步法 🛠️
要证明一个新问题Z是NP完全的,只需:
- 证明 Z ∈ NP。(给出证书和验证算法)
- 证明某个已知的NP完全问题 X ≤_p Z。(给出一个从X到Z的多项式时间归约)
由于归约的传递性,这等价于证明了所有NP问题都能归约到Z。
关键的第一个NP完全问题:SAT 📚
布尔可满足性问题(SAT) 是第一个被证明的NP完全问题(Cook-Levin定理)。
- 输入:一个布尔公式(由变量、与∧、或∨、非¬构成)。
- 问题:是否存在对变量的真值赋值,使得整个公式为真?
即使对SAT加以限制,它仍然是NP完全的:
- 合取范式SAT:公式是多个子句的合取(∧),每个子句是多个文字的析取(∨)。文字是变量或其否定。
- 3-SAT:每个子句恰好包含3个文字。3-SAT也是NP完全的。
有趣的事实:2-SAT(每个子句恰好2个文字)是多项式时间可解的,算法基于有向图的强连通分量。许多问题在参数为2时易解,为3时变为NP完全。
NP完全性证明实例:独立集问题 🧩
现在,我们运用两步法来证明独立集问题是NP完全的。
步骤1:证明独立集 ∈ NP
- 证书:一个大小为K的顶点集合S。
- 验证算法:
- 检查S中是否恰好有K个顶点。
- 检查S中任意两个顶点之间在图G中是否都没有边。
这两个检查都可在多项式时间内完成。
步骤2:证明一个已知NP完全问题可归约到独立集
我们选择 3-SAT 作为已知的NP完全问题。我们需要构造一个从3-SAT到独立集的多项式时间多一归约。
归约思路:
给定一个3-SAT公式F,包含m个子句。我们将构造一个图G和一个数值K,使得:
G 有一个大小至少为 K 的独立集 ⇔ F 是可满足的。
构造方法:
- 对公式F中的每个子句,在图G中创建一个三角形(3个顶点,两两相连)。三角形的三个顶点分别标记为该子句的三个文字。
- 对于所有互为相反的文字(如
x_i和¬x_i),在它们对应的顶点之间添加一条边。 - 设 K = m(子句的数量)。
正确性直观解释:
- 独立集需要选出K=m个顶点。由于每个三角形是团(完全子图),最多只能从每个三角形中选一个顶点。因此,一个大小为m的独立集必须从每个三角形中恰好选一个顶点。
- 蓝色边保证了不会同时选择互为相反的变量,从而保证赋值的一致性。
- 如果F可满足,则每个子句至少有一个真文字,选择对应顶点即可得到一个大小为m的独立集。
- 如果G有一个大小为m的独立集,则从每个三角形选了一个顶点(文字)。将这些文字设为真,即可得到一个满足F的赋值(注意,由于蓝色边的存在,赋值不会矛盾)。
时间复杂度:构造的图G有3m个顶点,边数最多为O(m^2),因此整个归约是多项式时间的。
至此,我们完成了独立集是NP完全的证明。
多一归约:NP完全性证明的蓝图 🗺️
在NP完全性证明中,我们通常使用一种特殊的归约——多项式时间多一归约。
其形式化描述是:存在一个多项式时间可计算的函数f,使得对于所有输入x:
x ∈ X ⇔ f(x) ∈ Y
在算法描述中,它体现为“一次性”使用假设存在的Y的算法:
- 将输入x转化为Y的输入f(x)。
- 运行假设存在的Y的算法。
- 直接输出Y算法的结果。
证明要点:
- 正确性:关键在于证明
x ∈ X ⇔ f(x) ∈ Y。 - 多项式时间:只需证明转换步骤(计算f(x))是多项式时间的。
总结 📖
本节课我们一起学习了:
- NP类的定义:答案是“是”时有简短证书且可快速验证的决策问题类。
- NP完全问题的定义:NP中最难的问题,所有NP问题都能归约到它。
- 证明问题NP完全的两步法:
- 第一步:证明该问题属于NP。
- 第二步:证明某个已知的NP完全问题可以多项式时间多一归约到该问题。
- 我们以独立集问题为例,完整展示了如何利用3-SAT进行归约证明。

掌握多一归约的构造和证明思路,是理解并证明更多问题NP完全性的关键。下节课我们将看到更多NP完全性证明的例子。
022:NP完全性深入探讨 🧩
在本节课中,我们将更深入地探讨NP完全性问题。我们将学习如何证明更多问题是NP完全的,并理解这些证明背后的核心思想。
概述
上一讲我们介绍了如何证明一个问题是NP完全的。本节中,我们将通过几个具体的例子来实践这一过程,包括团问题、顶点覆盖问题和哈密顿回路问题。我们将看到,尽管这些问题表面上看起来不同,但它们都可以通过多项式时间归约联系起来。
证明NP完全性的步骤回顾
要证明一个问题Z是NP完全的,需要两个步骤:
- 证明Z属于NP类:这意味着Z必须是一个判定问题,并且存在一个多项式时间的验证算法。具体来说,如果答案是“是”,我们需要提供一个“证书”,并能在多项式时间内验证该证书的正确性。
- 证明Z至少和某个已知的NP完全问题一样难:我们通过给出一个从已知的NP完全问题X到问题Z的多项式时间多一归约来证明这一点。
以下是关于归约类型的一些重要说明:
- 多一归约是一种特殊的图灵归约。在多一归约中,我们只调用一次假设存在的算法(用于解决问题Z),并直接输出其答案。
- 证明存在多一归约是更强的结果,也使得证明的结构更清晰,因此是更可取的。
- 一个开放问题是:对于任意两个NP问题,如果存在图灵归约,是否总是存在多一归约?尽管这在理论上尚未证明,但在所有已知情况下都成立,因此我们通常只考虑多一归约。
团问题是NP完全的 🎯
团问题的输入是一个无向图G和一个数字K。问题是:图G中是否存在一个大小至少为K的团?团是指一个顶点集合,其中每对顶点之间都有边相连。
证明步骤
第一步:证明团问题属于NP
- 证书:如果答案是“是”,证书就是构成团的顶点集合
C。 - 验证:验证算法检查两点:
- 集合
C的大小是否>= K。 C中每一对顶点是否在图中都有边相连。
- 集合
- 这个验证过程可以在多项式时间内完成。因此,团问题属于NP。
第二步:证明某个已知NP完全问题可归约到团问题
我们将使用独立集问题作为已知的NP完全问题。我们假设存在一个解决团问题的多项式时间算法,并利用它来构造一个解决独立集问题的算法。
归约构造:
给定独立集问题的输入:图G和数字K。
- 构造新图
G'为原图G的补图。补图G'与原图G有相同的顶点集,但边集相反:在G'中,两个顶点之间有边当且仅当在G中它们之间没有边。 - 设
K' = K。 - 将
(G', K')输入假设的团问题算法,并将其答案作为独立集问题的答案输出。
正确性证明:
关键在于观察团和独立集在补图中的关系。图G有一个大小至少为K的独立集,当且仅当其补图G'有一个大小至少为K的团。
时间复杂度:
构造补图最多需要O(n^2)时间,是多项式时间。

因此,我们通过从独立集到团的多项式时间多一归约,证明了团问题是NP完全的。
顶点覆盖问题是NP完全的 🛡️
顶点覆盖问题的输入是一个无向图G和一个数字K。问题是:图G中是否存在一个大小不超过K的顶点覆盖?顶点覆盖是指一个顶点集合S,使得图中的每条边都至少有一个端点在S中。
证明步骤
第一步:证明顶点覆盖属于NP(留作练习)。
第二步:证明独立集问题可归约到顶点覆盖问题

我们再次使用独立集作为已知NP完全问题。假设存在解决顶点覆盖的多项式时间算法。
归约构造:
给定独立集问题的输入:图G和数字K。
- 构造的新图
G'就是原图G本身。 - 设
K' = |V| - K,其中|V|是图的顶点总数。 - 将
(G', K')输入假设的顶点覆盖算法,并将其答案作为独立集问题的答案输出。

正确性证明:
核心观察是:一个集合S是图G的顶点覆盖,当且仅当其补集V \ S是G的独立集。
- 如果
G有一个大小至少为K的独立集I,那么V \ I就是一个大小至多为|V| - K的顶点覆盖。 - 反之,如果
G有一个大小至多为|V| - K的顶点覆盖S,那么V \ S就是一个大小至少为K的独立集。
因此,G有大小至少为K的独立集,当且仅当G有大小至多为|V| - K的顶点覆盖。
时间复杂度:
构造是常数时间的,只是做了一个减法计算K‘,因此是多项式时间。
历史背景与后续路线 📜
NP完全性理论由多伦多大学的斯蒂芬·库克和(独立发现的)前苏联的列昂尼德·莱文奠定。而大量具体的NP完全性证明(包括我们将要看到的)则归功于加州大学伯克利分校的理查德·卡普,其成果汇集在经典著作《计算机与难解性:NP完全性理论导论》中。
我们目前的证明路线图如下:已知3-SAT可归约到独立集,独立集可归约到团,独立集也可归约到顶点覆盖。接下来,我们将探讨哈密顿回路和子集和问题的NP完全性。
有向哈密顿回路问题是NP完全的 🔄
有向哈密顿回路问题的输入是一个有向图G。问题是:G中是否存在一个有向哈密顿回路?即一个经过每个顶点恰好一次并回到起点的有向环。
证明步骤
第一步:证明该问题属于NP(留作练习)。
第二步:证明3-SAT问题可归约到有向哈密顿回路问题
这是本讲中最精巧的归约之一。我们假设存在解决有向哈密顿回路的多项式时间算法,并利用它来解决3-SAT问题。
归约思路:
给定一个3-SAT公式,包含n个变量和m个子句。我们需要构造一个有向图G,使得G有哈密顿回路当且仅当该3-SAT公式是可满足的。
构造细节:
- 变量构件:对于每个变量
x_i,我们创建一条由许多顶点组成的“路径”。这条路径实际上是一个有向环的一部分,有两种遍历方式:从左到右(代表设置x_i = True)或从右到左(代表设置x_i = False)。路径上有很多“备用”顶点。 - 连接变量构件:将所有变量的路径首尾相连,形成一个大的框架。
- 子句构件:对于每个子句
C_j,我们创建一个单独的顶点c_j。 - 连接子句顶点:如果变量
x_i以正文字形式出现在子句C_j中,那么我们从x_i的“真路径”上的某个备用顶点添加两条边:一条从路径指向c_j,另一条从c_j指回路径的下一个顶点。这允许哈密顿回路通过“绕道”c_j来访问它。
如果变量x_i以负文字形式出现在子句C_j中,那么我们从x_i的“假路径”上的某个备用顶点添加类似的边,但方向与正文字情况相反。
关键是要确保连接不同子句的边使用路径上不同的备用顶点,互不干扰。
正确性证明(简述):
- 可满足性 => 哈密顿回路:如果公式可满足,则存在一组真值赋值。按照赋值遍历每个变量的路径(真则从左到右,假则从右到左)。对于每个子句,至少有一个文字为真,我们就从对应的路径位置绕道访问该子句顶点,然后返回。这样可以构造出一个哈密顿回路。
- 哈密顿回路 => 可满足性:如果图
G有一个哈密顿回路。通过分析回路的结构(特别是访问子句顶点c_j的唯一方式),可以证明它必须恰好以某种方式遍历每个变量路径(决定了变量的真值),并且每个子句顶点都必须通过其某个文字的路径被访问(意味着该文字被满足)。从而可以提取出一个满足公式的真值赋值。
时间复杂度:
构造的图顶点和边数量是公式长度的多项式倍,因此构造过程是多项式时间的。
无向哈密顿回路问题是NP完全的
证明了有向哈密顿回路是NP完全的之后,我们可以很容易地证明无向图上的哈密顿回路问题也是NP完全的。
证明思路:给出一个从有向哈密顿回路到无向哈密顿回路的多项式时间归约。
归约构造:
给定一个有向图G,构造一个无向图G‘:
- 对于
G中的每个顶点v,在G’中创建三个顶点:v_in,v_mid,v_out。 - 在
G‘中添加无向边(v_in, v_mid)和(v_mid, v_out)。 - 对于
G中的每条有向边(u -> v),在G’中添加无向边(u_out, v_in)。
直观理解:这个构造强制任何哈密顿回路在G‘中必须按... -> u_in -> u_mid -> u_out -> v_in -> ...的顺序访问与u相关的顶点,这模拟了在G中沿着u -> v方向移动。v_mid顶点的存在至关重要(留作练习思考:如果只用一条边(u_in, u_out)代替会出什么问题)。
正确性:可以证明,有向图G有哈密顿回路当且仅当无向图G‘有哈密顿回路。
旅行商问题是NP完全的 ✈️
旅行商问题的判定版本输入是一个图(边有权重)和一个数字K,问题是是否存在一个总权重不超过K的哈密顿回路(即旅行商环游)。

证明思路:可以从(有向或无向)哈密顿回路问题归约而来。给定一个哈密顿回路问题的实例(图G),构造一个TSP实例:将G的边权重设为1,不存在的边权重设为一个大数(如|V|+1),并令K = |V|。那么,G有哈密顿回路当且仅当存在一个权重为|V|的TSP环游。因此,TSP也是NP完全的。


总结
本节课中我们一起学习了多个NP完全性证明:
- 我们证明了团问题和顶点覆盖问题是NP完全的,主要利用了它们与独立集问题的紧密关系。
- 我们深入探讨了有向哈密顿回路问题的NP完全性证明,这是一个从3-SAT出发的经典而精巧的归约。
- 基于有向情况的证明,我们简述了无向哈密顿回路和旅行商问题也是NP完全的。

这些证明展示了如何将形式各异的问题通过多项式时间归约联系起来,是理解计算复杂性理论的核心。掌握这些证明方法对于完成相关作业和深入理解算法极限至关重要。
023:第22讲 - NP完全性进阶证明

在本节课中,我们将学习两个更具挑战性的NP完全性证明:子集和问题与电路可满足性问题。我们将看到如何将逻辑问题(如3-SAT)转化为数值问题(子集和),并追溯NP完全性理论的源头,证明电路可满足性是NP完全的。这些证明虽然复杂,但能帮助我们深入理解计算复杂性的基础。
子集和问题是NP完全的
上一节我们介绍了独立集、顶点覆盖和哈密顿环的NP完全性证明。本节中,我们来看看如何证明一个看似简单的数值问题——子集和问题——也是NP完全的。
子集和问题的定义如下:给定一组数字和一个目标值 W,问是否存在一个子集,其元素之和恰好等于 W。用公式表示,即是否存在子集 S 使得:
[
\sum_{a \in S} a = W
]
证明分为两部分:
- 子集和问题属于NP类(我们之前已作为示例证明过)。
- 将一个已知的NP完全问题(3-SAT)通过多项式时间多一归约到子集和问题。
这意味着,如果我们假设存在解决子集和问题的多项式时间算法,就能构造出解决3-SAT问题的多项式时间算法。
从3-SAT到子集和的归约思路
归约的核心思想是:选择子集中的数字,将对应于为3-SAT公式中的变量赋值真或假。我们将通过数字的位表示(这里使用十进制)来编码子句的信息。
以下是构造的具体步骤:
首先,我们创建一个0-1矩阵,称为“文字-子句关联矩阵”。
- 矩阵的每一行对应一个文字(例如,变量 x1 及其否定 ¬x1 各占一行)。假设有 n 个变量,则有 2n 行。
- 矩阵的每一列对应一个子句。假设有 m 个子句,则有 m 列。
- 如果某个文字出现在某个子句中,则在对应的行列位置置1,否则置0。每个子句恰好包含三个文字,因此每列恰好有三个1。
接下来,我们将矩阵的每一行视为一个十进制数。但为了满足子集和“精确等于目标值”的要求,并防止同时选择互斥的文字(如 x1 和 ¬x1),我们需要扩展这个矩阵。
完整的构造矩阵包含三部分:
- 核心部分:即上面描述的 2n 行 m 列的“文字-子句关联矩阵”。
- 松弛变量部分:针对每个子句列,新增两行。一行在该列位置为1,另一行在该列位置为2。这部分新增 2m 行,用于确保每个子句列的总和能恰好达到目标值4。
- 变量一致性部分:针对每个变量 xi,新增一列。在该列中,对应 xi 和 ¬xi 的两行位置为1,其余为0。这部分新增 n 列,用于确保只能选择 xi 或 ¬xi 中的一个。
最终,我们得到 2n + 2m 个数(即矩阵行数),每个数有 n + m 位十进制数字。目标数 W 是根据目标列和(变量列为1,子句列为4)构造的十进制数。
归约的正确性证明
正向:如果3-SAT公式可满足,则存在一组变量赋值。根据赋值选择对应的文字行(若 xi 为真,选 xi 行;若为假,选 ¬xi 行)。此时,每个变量列的和为1,满足约束。每个子句列的和为1、2或3(因为至少有一个文字为真)。然后,通过选择对应的松弛行(和为1时选两个松弛行;和为2时选值为2的松弛行;和为3时选值为1的松弛行),将每个子句列的和补足为4。这样就得到了一个和为 W 的数字子集。
反向:如果存在一个数字子集其和恰好为 W。由于每列最大可能和为6(三个文字行1+1+1,加上两个松弛行1+2),在十进制加法中不会产生进位,因此每列的和必须精确等于目标数字。变量列和为1保证了对于每个变量,我们只选择了 xi 或 ¬xi 中的一个,这定义了一组变量赋值。子句列和为4,而松弛行最多贡献3,这意味着至少有一个被选择的文字行在该列贡献了1,即该子句被满足。因此,整个公式可满足。
这个证明表明,NP完全性证明可以相当巧妙,并且我们可以利用数字编码逻辑信息,这本质上是计算机科学中将问题转化为比特编码的核心思想。
电路可满足性是NP完全的
现在,我们来追溯NP完全性理论的起点,证明电路可满足性是NP完全的。这是第一个被证明的NP完全问题。
电路的定义
一个电路是一个有向无环图。
- 它有一些源点(入度为0的顶点),这些点被标记为输入变量或常量0/1。
- 它有一个汇点(出度为0的顶点),称为输出。
- 内部节点有三种类型:
- 与门:有两个输入,任意多个输出。
- 或门:有两个输入,任意多个输出。
- 非门:有一个输入,任意多个输出。
给定输入值,电路以显而易见的方式计算输出值。
电路可满足性问题
电路可满足性问题的输入是一个电路,询问是否存在对输入变量的真值赋值,使得电路的输出为1(真)。
证明概要
证明分为两步:
- 电路可满足性属于NP:给定一个电路和一组赋值,我们可以在多项式时间内模拟电路的计算,验证输出是否为1。这部分较为简单。
- 电路可满足性是NP难的:我们需要证明,NP中的每一个问题都可以在多项式时间内归约到电路可满足性问题。
这是证明的关键。根据NP的定义,对于任意问题 Y ∈ NP,存在一个多项式时间验证算法 A。算法 A 接受两个输入:问题实例 y 和多项式长度的证书 g。当且仅当存在某个证书 g 使得 A(y, g) 输出“是”时,实例 y 才是 Y 的“是”实例。
我们的思路是将验证算法 A 转化为一个电路 C。
- 电路 C 的输入部分包括:已知的实例 y 的比特(作为固定0/1输入)和未知的证书 g 的比特(作为输入变量)。
- 电路 C 的内部结构模拟算法 A 的计算步骤。每个时间步的内存状态可以表示为逻辑门(与、或、非)的组合。
- 由于 A 是多项式时间算法,且证书 g 是多项式长度,因此模拟它的电路 C 也具有多项式规模。
这个构造过程本质上就是编译和硬件实现的过程:将算法转化为由逻辑门组成的电路。如果存在多项式时间算法解决电路可满足性问题,那么对于任何NP问题 Y,我们可以将其实例 y 和验证算法 A 转化为电路 C,然后检查 C 是否可满足,从而判断 y 是否为“是”实例。这就证明了电路可满足性是NP完全的。
3-SAT是NP完全的
在证明了电路可满足性是NP完全的之后,我们现在可以基于它来证明3-SAT也是NP完全的。这遵循标准的NP完全性证明模式。
证明思路
我们需要将电路可满足性问题归约到3-SAT问题。
- 输入:一个电路 C。
- 目标:构造一个3-CNF公式 F,使得 C 是可满足的当且仅当 F 是可满足的。
- 方法:为电路中的每个节点(包括输入、输出和内部门)引入一个布尔变量。然后,为每个逻辑门编写一组子句,这些子句用3-CNF形式精确描述该门的输入输出关系。
以下是针对不同类型门构造的子句示例(设 u 为门输出变量,v, w 为输入变量):
- 或门 (u = v ∨ w):
- 子句1: (¬u ∨ v ∨ w)
- 子句2: (u ∨ ¬v)
- 子句3: (u ∨ ¬w)
(后两个子句由 u ∨ (¬v ∧ ¬w) 转换而来)
- 与门 (u = v ∧ w):
- 子句1: (¬u ∨ v)
- 子句2: (¬u ∨ w)
- 子句3: (u ∨ ¬v ∨ ¬w)
- 非门 (u = ¬v):
- 子句1: (¬u ∨ ¬v)
- 子句2: (u ∨ v)
最后,添加一个子句要求输出节点的变量为真:(x_output)。
关键点与正确性
- 多项式时间构造:电路规模是多项式的,为每个门生成的子句数量是常数(最多4个),因此整个公式 F 的规模是多项式级别的,并且可以在多项式时间内构造。
- 正确性:
- 如果电路 C 可满足,则存在一组输入赋值使其输出为1。根据电路计算过程,为所有中间节点变量赋予相应的真值,这组赋值将满足公式 F 中的所有子句。
- 如果公式 F 可满足,则存在对所有变量(包括输入变量和中间变量)的一组赋值。由于 F 的子句精确编码了每个门的逻辑,这组赋值必然使得电路 C 中每个门的计算关系成立。同时,输出子句要求输出变量为真,因此电路 C 在这组输入赋值下输出为1,即 C 可满足。
这个证明完成了从电路可满足性到3-SAT的归约链,确立了3-SAT的NP完全性地位。
总结
本节课中我们一起学习了两个核心的NP完全性证明。
- 我们看到了如何通过巧妙的数字编码,将3-SAT问题归约到子集和问题,证明了这个看似简单的数值组合问题是NP完全的。
- 我们追溯了NP完全性理论的起源,通过“将任意NP问题的验证算法编译为电路”这一高层思想,论证了电路可满足性是NP完全的。随后,通过为电路中的每个逻辑门构造等价的3-CNF子句,我们将电路可满足性归约到3-SAT,从而也证明了3-SAT的NP完全性。

这些证明虽然比之前见过的更复杂,但它们展示了NP完全性理论的深度和普适性,说明了从逻辑约束到数值问题,许多看似不同的问题在计算难度上是相通的。理解这些基础证明,有助于我们更好地认识计算复杂性的本质。
024:NP完全性专题与开放问题 🧩

在本节课程中,我们将探讨NP完全性理论中一些有趣且较新的研究成果,以及几个著名的开放性问题。我们将了解一些经典问题如何被解决,以及另一些至今仍悬而未决的难题。
课程背景与概述
这是第22讲,是一节拓展性课程,内容为选修性质。课程作业不会基于本节内容。通常这类拓展讲座会安排在第24讲,但由于视频可以随时观看,且内容仍与NP完全性相关,因此现在安排是合理的。
之前我们学习的NP完全性证明都是几十年前(1970年代)的标准结果。今天,我们将介绍一些更有趣、更近期的NP完全性成果,以及关于NP完全性的一些开放性问题。除了“P是否等于NP”这个重大问题外,还有许多其他未解之谜。
经典开放问题的进展
上一节我们回顾了NP完全性的基础。本节中,我们来看看一些在历史上曾被列为开放问题,但后来得到解决的经典案例。这些案例都源自Garey和Johnson于1979年出版的著名著作,书中列出了当时未知是否为多项式时间可解或NP完全的问题。
以下是其中三个著名问题及其后续进展:
-
线性规划
- 状态:在该书出版后不久即被证明是多项式时间可解的。
- 说明:线性规划问题存在多项式时间算法,例如椭球法和内点法。一个有趣的未解问题是,是否存在一种算法,其步骤数在变量和约束的数量上是多项式的(类似于单纯形法,但被证明是多项式时间的)。
-
素数判定
- 状态:给定一个数字,判断它是否为素数。在1979年,这仍是一个开放问题。直到2002年,才发现了多项式时间算法。
-
图同构
- 状态:至今仍为开放问题。
- 问题描述:给定两个无向图,判断它们是否在忽略顶点标签的情况下完全相同。
- 示例:两个看似不同的图,如果通过重新标记顶点可以变得完全相同,则它们是同构的。此问题在化学等领域有重要应用,但至今既未找到多项式时间算法,也未证明是NP完全的。
有趣的NP完全性结果
在回顾了历史进展后,我们转向一些令人惊奇的、较新的NP完全性证明。这些结果展示了即使问题看起来受限,其判定版本也可能异常困难。
以下是几个引人注目的例子:
-
图边着色
- 问题:给定一个图和一个数字
k,能否用最多k种颜色为图的边着色,使得任何两条相邻边(共享一个顶点)颜色不同? - 有趣之处:根据Vizing定理,所需的最小颜色数只能是最大顶点度数
Δ或Δ+1。然而,判定是否能用Δ种颜色完成边着色本身是NP完全的。尽管答案只有两种可能,但区分它们依然是困难的。
- 问题:给定一个图和一个数字
-
最小权三角剖分
- 问题:给定平面上的一组点和一个数字
k,是否存在一种三角剖分(用不相交的直线段连接点,将区域划分为三角形),使得所有边的长度之和不超过k? - 状态:于2008年被证明是NP难的。
- 特殊之处:该问题未被证明属于NP。原因是验证边长之和是否小于等于
k涉及计算平方根之和,而目前尚不清楚如何在多项式时间内精确比较这样的和与k的大小。
- 问题:给定平面上的一组点和一个数字
-
N×N×N 魔方
- 问题:给定一个
N×N×N魔方的一个打乱状态和一个数字k,能否在最多k步内将其复原?(一步指旋转任意一层) - 状态:于2018年被证明是NP完全的。
- 注:对于标准的3×3×3魔方,由于其状态数是有限的(约430亿亿种),我们不讨论其“多项式时间”算法。其最大复原步数(称为“上帝之数”)在2013年被证明是20。
- 问题:给定一个
重要的开放性问题
了解了NP完全性的新证明后,我们来看一些至今仍未解决的重要开放性问题。这些问题既未被证明是多项式时间可解的,也未被证明是NP完全的,其中一些还具有独特的复杂性类别性质。
以下是两个关键的例子:
-
整数分解
- 判定问题形式:给定两个数字
n和k,n是否有一个小于等于k的因子? - 复杂性地位:该问题同时属于 NP 和 co-NP。
- 若答案为“是”(存在因子),证书就是该因子本身,验证其整除
n且大小不超过k即可。 - 若答案为“否”,证书可以是
n的质因数分解。由于可以在多项式时间内验证分解的正确性及每个因子都大于k,故属于 co-NP。
- 若答案为“是”(存在因子),证书就是该因子本身,验证其整除
- 意义与挑战:大多数同时属于NP和co-NP的问题都被认为是可能存在多项式时间算法的。整数分解的困难性是RSA等公钥加密系统的基础。然而,Shor在1994年已证明量子计算机可以在多项式时间内分解整数,这促使了“后量子密码学”的研究。
- 判定问题形式:给定两个数字
-
平凡纽结判定
- 问题:给定一个纽结图(三维空间中绳圈的二维投影),它是否表示“平凡纽结”(即可以连续变形为一个简单圆环,没有打结)?
- 示例:一个简单圆圈是平凡纽结;某些带交叉的图经过变形也能成为圆环;而另一些(如三叶结)则不能。
- 复杂性进展:
- 1999年:被证明属于 NP(如果是平凡纽结,存在证书可验证)。
- 2016年:被证明属于 co-NP(如果不是平凡纽结,也存在证书可验证)。
- 现状:与整数分解一样,它同时位于NP和co-NP中,但至今既未找到多项式时间算法,也未证明是NP完全的。
总结与下节预告
本节课中,我们一起学习了NP完全性理论中一些超越经典教材的内容。我们回顾了历史上几个著名开放问题的解决,探讨了几个出人意料且较新的NP完全性证明结果,并深入了解了“整数分解”和“平凡纽结判定”这两个同时位于NP与co-NP中的重要开放性问题。
接下来,课程将回归必修内容,重点讨论当面对一个NP完全问题时,我们可以采取哪些实用策略。我们已经学习过穷举搜索算法,下一讲(第23讲)我们将探讨另一种重要的补救方法:近似算法。

本节课程到此结束。
025:近似算法 🧮

在本节课中,我们将要学习如何处理NP难问题。具体来说,我们将探讨一种称为“近似算法”的方法。这些算法能在多项式时间内运行,并为解的质量提供可证明的保证。我们将通过两个经典问题——顶点覆盖问题和欧几里得旅行商问题——来理解近似算法的概念和设计思路。
顶点覆盖问题
上一节我们介绍了NP完全问题的背景,本节中我们来看看如何为顶点覆盖问题设计近似算法。
顶点覆盖问题是指:给定一个无向图,目标是找到一个最小的顶点集合,使得图中的每条边都至少有一个端点在这个集合中。这个问题的决策版本是NP完全的。
我们将分析两种贪心算法。
算法一:最大度优先贪心算法
算法一的工作流程如下:我们从一个空的顶点覆盖集合开始,然后重复以下步骤,直到所有边都被覆盖:选择图中当前度数最高的顶点,将其加入覆盖集,并移除所有与该顶点相连的边(因为这些边已被覆盖)。
以下是该算法的伪代码描述:
C = ∅
while 存在未被覆盖的边:
v = 图中当前度数最高的顶点
C = C ∪ {v}
移除所有与v相连的边
return C
该算法在多项式时间内运行,但它并不总能找到最小顶点覆盖。
算法二:边选择贪心算法
现在,我们来看看第二种算法。该算法同样从一个空的顶点覆盖集合 C 和包含所有边的集合 F 开始。
以下是该算法的伪代码描述:
C = ∅
F = E (所有边的集合)
while F ≠ ∅:
从F中任选一条边 (u, v)
C = C ∪ {u, v}
从F中移除所有与u或v相连的边
return C
该算法的核心思想是:每次选择一条未被覆盖的边,并将其两个端点都加入覆盖集。虽然这看起来有些“浪费”,但我们将证明它能提供一个很好的近似保证。
算法二的近似比分析
我们将证明算法二找到的顶点覆盖大小最多是最优解的两倍。
首先,观察算法选取的边构成了一个匹配。匹配是指一组没有公共端点的边。
其次,任何顶点覆盖都必须包含匹配 M 中每条边的至少一个端点。因此,最优覆盖 C_opt 的大小至少为 |M|。
算法得到的覆盖 C 的大小是 2|M|,因为每条被选的边贡献了两个顶点。
结合以上两点,我们得到:
|C| = 2|M| ≤ 2|C_opt|
这就证明了算法二具有近似比2。这意味着它能在多项式时间内找到一个解,其大小不超过最优解的两倍。
值得注意的是,算法一的近似比可证明为 O(log n),因此在可证明的保证方面,算法二更优。
独立集问题的启示
顶点覆盖与独立集问题密切相关。一个图的最小顶点覆盖的补集就是最大独立集。然而,尽管顶点覆盖有很好的近似算法,最大独立集问题却没有常数因子的近似算法(除非P=NP)。这说明了不同NP完全问题在近似难度上可能存在巨大差异。
欧几里得旅行商问题
上一节我们讨论了顶点覆盖的近似算法,本节中我们来看看另一个经典问题——欧几里得旅行商问题的近似解法。
旅行商问题是指:给定一个完全图,边上有权重,目标是找到一个访问每个顶点恰好一次并返回起点的哈密顿回路,且使其总长度最小。当顶点是平面上的点,且边权是欧几里得距离时,即为欧几里得TSP。它仍然是一个NP完全问题,但我们可以利用三角不等式来设计近似算法。
近似算法描述
该算法包含三个步骤:
- 计算最小生成树:为给定的点集计算一个最小生成树。这可以在多项式时间内完成。
- 生成“绕树”行走路径:从最小生成树的某个顶点开始,沿着树的边进行深度优先遍历。每次访问一条边就沿着它走两次(一去一回)。这样会生成一条访问所有顶点(但某些顶点会被重复访问)的闭合路径。
- 采取“捷径”:沿着上一步生成的路径行走,但跳过那些已经访问过的顶点,直接前往路径中下一个未访问的顶点。根据三角不等式,这种“捷径”的长度不会超过它所替代的原路径片段长度。
最终,我们得到的是一个访问每个顶点恰好一次的TSP环游。
算法近似比分析
我们将证明该算法找到的环游长度最多是最优环游长度的两倍。
令 T_MST 为最小生成树的总长度。
令 T_OPT 为最优TSP环游的长度。
首先,最小生成树的长度不超过最优TSP环游的长度。因为从最优环游中删除任意一条边,就会得到一棵生成树(不一定最小),所以最小生成树更短。
T_MST ≤ T_OPT
其次,算法第二步生成的“绕树”路径,其长度恰好是 2 * T_MST,因为每条边都走了两次。
最后,在第三步中,我们通过“取捷径”来避免重复访问顶点。根据三角不等式,每条捷径的长度都不大于它所绕过的原路径长度。因此,最终环游 T 的长度满足:
T ≤ 2 * T_MST
结合以上两个不等式,我们得到:
T ≤ 2 * T_MST ≤ 2 * T_OPT
因此,该算法对于欧几里得TSP具有近似比2。
关于近似算法的进一步说明
对于欧几里得TSP,存在更精确的近似方案。对于任意 ε > 0,都存在算法能找到长度不超过 (1+ε) * T_OPT 的环游。但需要注意的是,当 ε 趋近于0时,算法的运行时间可能会急剧增加(从多项式时间变为指数时间)。这属于更高级的算法课程内容。
总结
本节课中我们一起学习了近似算法的基本概念。我们通过顶点覆盖和欧几里得旅行商问题这两个例子,了解了如何设计能在多项式时间内运行,并为解的质量提供常数因子保证的算法。
关键要点包括:
- 近似算法的目标是快速找到“足够好”的解,而非最优解。
- 近似比是衡量算法性能的关键指标,对于最小化问题,它表示算法解值不超过最优解值的多少倍。
- 有些NP完全问题(如顶点覆盖、欧几里得TSP)存在很好的近似算法,而另一些问题(如独立集)则不存在(除非P=NP)。

理解哪些问题可以近似、近似的效果如何,是处理实际中NP难问题的重要工具。
026:不可判定性

概述
在本节课中,我们将学习计算机科学中的一个核心概念:不可判定性。我们将了解什么是不可判定问题,探索一些著名的例子,并学习如何证明一个问题是不可判定的。
引言与背景
到目前为止,本课程一直专注于算法的效率,即多项式时间与指数时间算法的对比,特别是在NP完全性部分。那里的坏消息是,一些NP完全问题似乎只有指数时间算法。而本讲座要讨论的是更坏的消息:存在一些问题根本没有算法,无论允许它运行多长时间。
首先描述几个这类问题的例子。
程序等价性问题
给定两个程序,它们是否做相同的事情?即,对于相同的输入,它们是否产生相同的输出?这是助教们在批改编程作业时必须处理的问题。他们想知道你编写的程序是否与执行正确操作的模型程序相同。不幸的是,没有算法可以完成这个任务。这就是为什么你的程序只在一组测试输入上运行,因为无法进行通用的测试。
铺砖问题
第二个被证明没有算法的问题是铺砖问题。给定一组有限的砖块,例如这13块3x4的砖,每块砖的每条边都有一种颜色。这些砖块不能旋转。我们要问的决策问题是:能否使用这些砖块铺满整个平面,并且颜色必须匹配?例如,可以将这块砖放在这块砖下面,因为它们在蓝色边上匹配;但不能将这块砖与这块砖并排放置,因为这里的颜色不匹配。
这是一个使用这13块砖铺砌平面一部分的例子,图中并未无限延伸。
这个决策问题没有算法。一个有趣的现象暗示了这个问题非常困难:这组砖块可以铺满平面,但只能非周期性地铺砌。你可能会想,如果砖块能铺满平面,并且本质上铺砌某个部分然后重复该部分,那么可以想象有一个算法能检查:对于平面的这个有限部分,我能否以某种方式铺砌,使得这部分可以连接到那部分,那么我就得到了平面的周期性铺砌。然而,存在只能非周期性铺砌的砖块这一事实,暗示了这个问题确实非常困难。
停机问题
第三个例子是停机问题,你可能在之前的课程中听说过。问题很简单:给定一个计算机程序,它会停机吗?这是一个没有算法的问题。
“它会停机吗?”这个问题可能让你想问:这是指在所有输入上、在某些输入上,还是在特定输入上?稍后会澄清,实际上所有这些版本都是不可判定的,没有算法能解决它们。
看一个小例子。这是一个非常小的程序:它从一个自然数开始,当数字不等于1时,我们减去2。很容易判断它何时停机:当且仅当给定一个奇数时它会停机,因为最终会减到1。如果给定偶数,会减到0,然后进入负数,所以这是一个容易判断是否停机的程序例子。
这是一个只长一点点的程序。没有人知道这个程序是否会停机。这个程序在做什么?它的停机条件相同,但我们检查:如果数字是偶数,则除以2;如果数字是奇数,则执行3n+1。举个小例子:如果从n=12开始,12是偶数,所以除以2得到6;6是偶数,除以2得到3;3不是偶数,所以执行3n+1得到10;10是偶数,除以2得到5;5是奇数,乘以3加1得到16;16是2的幂,然后会得到8、4、2、1,此时停机。
这里问的问题是:这个程序对所有输入都停机吗?没有人知道。经验证据表明,该程序在尝试过的每个输入上都停机了。我不知道尝试过的最大输入是多少,但你可以在维基百科页面上找到,它被称为3n+1问题,或者与一些人的名字相关联。著名数学家、组合学家保罗·埃尔德什对此的评价是:数学可能还没有准备好解决这样的问题。
比这个3n+1问题更一般地,我可以将任何关于数字存在的数学陈述转化为关于程序停机的问题。这意味着关于程序停机的问题可能非常深奥和困难。这是一般模式:如果我们有一个数学猜想,问是否存在一个数n使得φ(n)成立,那么我可以将其转化为一个程序:从n=1开始,当不满足φ时,继续检查下一个数。当且仅当找到一个满足φ(n)的数时,这个程序才会结束。
作为一个更具体的例子,我们可以考虑哥德巴赫猜想,它是未解决的,它问是否每个偶数都是两个素数之和。为了将其转化为“是否存在”的形式,我们问:是否存在一个形如2^n的数,它不是两个素数之和?然后你可以将其转化为这样一个小程序,没有人知道那个程序是否会停机。
因此,每当存在性问题悬而未决时,就像哥德巴赫猜想那样,就没有人知道相应的程序是否会停机。这正好证明了询问程序是否停机是非常深奥和困难的。
现在我们用定义来形式化这个概念:如果一个决策问题没有算法,我们就说它是不可判定的。稍微更一般地说,如果我们讨论的不仅仅是决策问题,而是更一般的问题,我们说一个问题如果没有算法,它就是不可解的。我们将坚持讨论决策问题并使用术语“不可判定”。
既然说“没有算法”,我们必须思考:究竟什么是算法?这在本课程一开始就讨论过。以下任何模型在定义什么是算法方面都是等价的:你可以用任何你喜欢的语言思考程序;可以思考伪代码(我们现在不关心运行时间,所以使用位模型还是单位成本模型都无关紧要);伪代码随机存取机是另一个合法的等价模型;最后是图灵机(你可能在CS245中见过提及,但它是CS360的主题)。这是一个计算模型,艾伦·图灵在电子计算机出现之前就在思考:计算某物意味着什么?他的想法是:一个人坐在那里进行计算。“计算机”这个词在历史上实际上是指进行计算的人。他想象这个人有无限的纸张供应、一条长纸带、一支铅笔、一块橡皮和一组指令(一组有限的指令,他们应该遵循)。可以证明,图灵设计的这个称为图灵机的模型,在哪些问题有算法、哪些没有方面,与其他所有模型是等价的。
今天我们将研究一些不可判定问题。
不可判定性的历史背景
现在,我先介绍一下不可判定性的历史背景。这既是对这些思想的介绍,本身也很有趣。
20世纪初,戈特洛布·弗雷格是许多数学家努力将数学建立在坚实基础上的一部分。他们试图制定一组公理,例如皮亚诺算术公理、集合论公理,这些都是你可能听说过的公理集。他们想找到一组公理,使得每个真实的数学陈述都可以仅使用基本逻辑规则从这些公理中证明出来。
他的计划被罗素悖论挫败了。让我讲讲罗素悖论。
罗素说:设S为所有不包含自身的集合的集合。悖论在于他问:S是它自身的成员吗?看看这个:如果S是它自身的成员,那么这个定义说S不应该包含自身。哎呀,这是一个矛盾。另一方面,如果你说,好吧,S不是它自身的成员,那么它满足这里的规则(不包含自身),所以根据规则应该把它放进去,这又是一个矛盾。所以无论哪种方式都会导致矛盾。
如果你不喜欢思考“所有集合的集合”,这里有两种等效的思考方式:一种是“书目”的类比。“书目”是一本只列出其他书籍的书。那么,“所有不列出自身的书目的书目”就是一种等效的思考方式。然后你问:这个书目应该列出自身吗?另一种更现代的方式是:链接到不链接到自身的网页的网页。有些网页可能是算法来源列表,这样的网页应该列出自身吗?也许它会。但你可以想象有一个网页,其列出内容的规则是:它列出那些不链接到自身的网页。当你问这个网页是否应该链接到自身时,你会得到同样的矛盾。
这个悖论在某种意义上意味着,一旦系统足够强大,你就可以得到自我指涉(谈论自身),从而导致矛盾。更具体地说,这里的问题是什么?罗素使用了集合的概念,我们对集合的含义都有一些直觉,但从形式数学的角度来看,你必须非常仔细地定义集合。因此,罗素悖论的出现是因为对于那个理论,你需要比“事物的集合”更仔细地定义集合。如果你学习数学基础课程,你会发现集合是以一种非常狭窄和谨慎的方式来定义的,以避免罗素悖论。
那么,罗素悖论如何应用于计算?它的思想将使我们能够证明存在不可判定问题。换句话说,我们将从假设开始:如果算法足够强大,能够判定一切,甚至包括停机问题,那么我们将能够判定一些自我指涉的东西并得到矛盾。这有点抽象,但我们会讲到。
这是伯特兰·罗素的照片。我稍后会回到弗雷格及其将数学建立在坚实基础上的尝试。
但首先让我简单介绍一下提出这个摧毁了弗雷格计划的悖论的伯特兰·罗素。他寿命很长,提出这个悖论摧毁弗雷格计划时还很年轻。他是一位博学者,是哲学家、逻辑学家、数学家、历史学家、作家、社会批评家、政治活动家,也是诺贝尔文学奖得主。
我非常喜欢阅读伯特兰·罗素的自传和传记。他的一生极其漫长且非常有趣,从这个列表中你可以猜到。他年幼时父母去世,由一位非常严格的维多利亚式祖母抚养长大。他年轻时生活非常孤独,数学将他从这种孤独生活的沮丧中拯救出来。但他的个人关系也非常有趣,他有多段婚姻和许多风流韵事,所以他的传记读起来很有趣。
我也认为,在新冠疫情肆虐、我们都在与隔离作斗争的今年,尝试向外看,了解更多关于其他人、其他历史时期的事情,将注意力从自己和这个艰难的时刻转移开,是件好事。因此我建议,如果你有时间,例如在假期,可以多读一些关于伯特兰·罗素的东西。他在一战期间是和平主义者,因为公开反对战争努力而入狱六个月。他在狱中做了什么?他写了一些数学书。所以,也许他的人生能给你们一些人带来启发。
回到弗雷格试图做的事情:他试图将数学建立在坚实的基础上,罗素悖论摧毁了他的计划。这是他为他的书写的引言,大意是:对于一个科学作家来说,几乎没有什么比在他的工作即将完成时,其大厦的根基之一被动摇更不幸的了,而伯特兰·罗素先生的一封信就让我陷入了这种境地,当时这本书的印刷工作已接近尾声。在这里,他更具体地写了罗素悖论说的是什么:那个不属于自身的类(我们称之为“所有不包含自身的集合的集合”),他在这里问的问题就是罗素悖论。
那么,弗雷格所处困境的解决方案是什么?那就是数学必须被更仔细地定义,集合必须被更仔细地定义。
在这种数学探索的脉络下,大卫·希尔伯特在1928年以更具体的方式阐述了弗雷格所关注的问题。他问:数学是完备的吗?完备性意味着每个数学陈述都可以被证明或证伪。他希望数学是这样。其次,他问:数学是一致的吗?一致性意味着任何有效的证明步骤序列(逻辑步骤)都不应导致矛盾。我们当然希望数学是一致的,否则一切都将崩溃。第三,他问:数学是可判定的吗?是否存在一个算法来确定每个陈述的真假?这以其德文名称“判定问题”而闻名。
希尔伯特提出这些问题时年纪已经比较大了。几年后,当时还年轻的哥德尔摧毁了前两个问题。他证明:每个足够强大的数学系统要么不一致,要么不完备。这对数学来说是毁灭性的消息。此外,他还表明,这样的系统无法在其自身的公理内证明其一致性。因此,从这一点来看,所有数学都建立在有些摇摇欲坠的基础上。
但这当然不是关于数学基础,而是关于计算。所以我们最感兴趣的是第三个问题:数学是可判定的吗?是否存在一个算法来确定任何陈述的真假?哥德尔没有回答这个问题。图灵回答了它。大约在同一时期,艾伦·图灵证明没有算法可以判定一切;存在不可判定的数学陈述;存在我们无法知道是否停机的程序(等价地)。
图灵的一生也非常有趣,你可以在这里读到他的生平,可以观看最近关于他生平的电影。我选择了一张他年轻时的照片,可能比这张更年轻,我想我选择的照片大约是你们所有修这门课的人的年龄,甚至更年轻。
这是图灵论文的图片,他在其中证明了存在不可判定问题。他用了德文名称来指代希尔伯特的问题。你会注意到他的论文是关于可计算数的。我一直谈论的是问题是否可判定,数学陈述是否可证明。你必须记住的是,数字可以编码一切。所以,关于程序是否停机的问题,我们可以将其编码为数字是否可计算的问题。
关于历史,我要说的就这些。你应该从第一部分了解的是不可判定决策问题的定义:它是一个没有算法的问题。
接下来,我们将转向更具技术性的内容,看看一些证明某些问题不可判定的方法。
证明不可判定性:停机问题
与NP完全性理论一样,第一个不可判定性证明是困难的。我不应该说困难,也许它并不真正困难,但它是不同的。它是一个独立的东西。在此之后,后续的不可判定性证明使用归约,就像我们使用归约来证明其他问题是NP完全的一样。现在我们的归约与之前的一个不同点是:我们不关心归约花费多少时间。
我们将证明不可判定的第一个问题是停机问题。我知道你以前见过这个陈述,可能也见过它的证明。
这个问题是:输入是一个算法或程序A,以及该程序的一个输入W。我们要问的决策问题是:算法A在输入W上是否停机?
定理:停机问题是不可判定的。
证明:这个证明的主要思想是使用自我指涉来得到矛盾,其思想与罗素悖论相同。
我们首先假设(为了导出矛盾)存在一个程序H,它能判定停机问题。具体来说,这个程序H的输入是一个程序A和一个输入W。你可能会想:程序可以作为另一个程序的输入吗?当然可以,编译器一直都在做这件事。所以H的输入是程序A和输入W,输出是这个问题的答案:是或否(程序A在输入W上是否停机?)。
我们假设存在一个能做到这一点的算法。现在,使用这个程序,我们将创建一个新程序,我称之为H'。H'的功能如下:它以一个程序B作为输入。然后它调用算法H,输入是B重复两次(即B和B)。这个我们假设存在的算法将回答是或否。如果它返回“否”,那么我们就停机。如果它返回“是”,我们就进入一个无限循环。你们都能用两行代码写一个无限循环,对吧?这就是我的程序H'的定义。
现在,H'将扮演罗素集合S的角色。罗素那个导致矛盾的问题“S是否包含S?”在这种情况下变成了什么问题?它变成了问题:H'是否在它自身的输入上停机?
让我们看看这个问题的答案可能是什么。H'在输入H'上停机吗?我们只是在问一个停机问题,并且我们之前说过,H回答这个问题:程序H判定,给定两个可能的输入,第一个输入是否在第二个输入上停机。所以,H'在输入H'上停机,当且仅当我们的停机程序,当我给它H'作为第一个输入和H'作为第二个输入时,返回“是”。
这只是根据H的定义。现在我要做的是回顾我们为H'编写的代码,看看这里发生了什么。所以,如果我把H'代入这里……那么,看H'的代码:当且仅当H'在输入……(想象一下,我在这里运行H',用H'代替B)所以我调用H(H', H'),如果它返回“是”,那么根据代码,我们会进入无限循环。所以,当且仅当H'在输入H'上永远循环。
这是一个矛盾,对吧?我们说的是:H'在输入H'上停机,当且仅当H'在输入H'上永远循环。所以我们得到了矛盾。
这里出了什么问题?我们假设存在一个解决停机问题的程序H。所以我们的假设——H存在——是错误的。因此,没有算法可以判定停机问题。
证明很短,但有点棘手,你必须理解它。可能值得多看几遍这些步骤,更仔细地检查这段代码以确保理解这些步骤。
使用归约证明其他问题不可判定
为了证明其他问题是不可判定的,我们将使用归约。以下是归约定义的提醒:这里不再有下标P,因为我不关心多项式时间。我只是说,如果拥有Y的算法能让我们构建X的算法,那么X可归约到Y。
我们在这里利用的是其逆否命题(这是一个等价的陈述):如果X是不可判定的,那么Y也是不可判定的。所以,如果这个较简单的问题没有算法,那么较难的那个也没有算法。
我们将看几个不可判定性的证明。我在这里写了一个小路线图:我们不会讨论我在第一或第二张幻灯片中展示的铺砖问题,它通过从停机问题直接归约被证明是不可判定的。我不打算展示那个,因为它有点难。但我们将遵循这个链条,展示这些各种问题是不可判定的。
我们将从这个问题开始:无输入停机问题。给定一个程序A(它没有任何输入),我们问:A会停机吗?
定理:这是一个不可判定的问题。
我们将通过证明停机问题(已知不可判定)可归约到无输入停机问题来证明它。这将表明无输入停机问题是不可判定的。
我们遵循与NP完全性证明相同的结构:我们假设有一个算法,我称之为P,它能判定无输入停机问题。所以这个算法的输入是一个程序A,输出是是或否(A是否停机?)。基于此,我们将构建一个算法来判定停机问题。这就是完成这个归约的内容。
我们需要构建的算法:输入是一个程序B连同输入W,我们必须判定该程序在输入W上是否停机。
我们拥有的算法(P)和我们试图构建的算法之间的区别在于:在这个情况(P)下,我们只知道如何回答一个没有输入的程序的问题;而在我们要构建的算法中,我们有一些额外的输入。
你们都知道怎么做这个。你们可能甚至在第一次学习编程时就做过。在学习编程语言时,输入输出语句常常有些棘手,因为你可能已经能够编写一个程序,并想在某个东西上运行它,但还没有掌握编程语言中的读取语句。那时你做了什么?你只是编写程序,并在其中硬编码你想要运行的输入。然后当你想在其他东西上运行时,你只需硬编码其他输入。这就是我们要在这里使用的技巧。
让我更详细地看一下。这是算法(记住我们正在构建一个算法)。这是代码:
这个算法将修改程序B的代码。记住我们有这个程序B作为输入,还有输入W。我们想在B的代码中硬编码W。我们将创建一个程序B',它在内部硬编码了W,并本质上在W上运行B。
我们正在做的一个小图示是:我有一个程序B,在某个地方有一行代码说“W是从输入读取的”。我想把它转换成另一个程序:除了这里我执行“W = 某个值”的赋值操作,而不是“从输入读取”语句外,其他一切都相同。
然后我们要做的是:我假设我有一个程序P,它可以判定某个其他程序B'在没有输入的情况下是否停机。这就是我们想为B'回答的问题。然后我们只需输出是或否的答案。这就是我的程序。
就正确性而言,这里需要观察的是:B'在没有输入的情况下停机,当且仅当B在输入W上停机。这由我们这里的小图示证明。这就是声称我们上面编写的这个算法是正确的所需的一切。
通常在这门课程中,我们总是说要分析运行时间。在这种情况下,我们需要分析运行时间吗?不需要,因为我们不关心多项式时间,我们不是在做一个多项式时间归约,我们只是在做一个归约,所以不需要进行运行时间分析。
这就完成了这里的证明。我们已经证明无输入停机问题是一个不可判定问题。
程序验证问题
我们将继续看几个例子。我可能不会给出证明的太多细节,但希望你们能感受一下它们的风格。
接下来看程序验证问题:给定一个程序,并给定一些关于输入和相应输出的规范(一个软件工程问题),我们想知道程序是否符合这些规范?需要注意的一点是,我们确实需要关于输入和输出的有限规范;我们需要以某种方式用有限的语句描述每个输入对应的输出应该是什么,因为一个问题总是需要有一个有限的规范。
定理:程序验证是一个不可判定的问题。尽管软件工程非常希望有一种方法来进行程序验证,但这是被证明不可能的。所以,自动程序检查是不可能的。
我们将证明我们的问题——程序验证——比无输入停机问题更难。换句话说,无输入停机问题可归约到程序验证。结构相同:我们假设有某个算法,我称之为V(用于验证),它能判定程序验证。所以它接受一个程序、输入输出规范,并判定是或否(程序是否符合这些规范?)。
基于此,我们将构建一个算法来判定无输入停机问题。这里的输入是一个程序A,我们试图回答的问题是:A会停机吗?
这里的想法是,我们将把“A是否停机”这个问题转化为“程序是否符合其规范”的问题。想法如下:我们将修改程序A,就像在前一个证明中一样。我只是在修改A的代码,我没有运行它。不要运行它,只是编辑它。更改代码,我将从中创建一个程序A'。
程序A'做什么?它读取输入(实际上它会忽略其输入,但为了程序规范,它从读取输入开始,因为程序规范接受输入并产生输出)。然后,我们将A的代码放在这里。然后我们在末尾添加一行,写着“输出1”。所以A'的代码基本上就是A的代码,但我们以一条读取语句开始,以一条输出语句结束。与程序A相比,它多了两行。
然后我将调用我的判定验证问题的算法,并问:A'是否符合以下规范?规范是:对于每个输入,输出都是1。这些是我可以在有限空间内写下来的规范。我问的问题是:A'是否具有无论给定什么输入都输出1的属性?当你查看这里的代码时,你会说:只要运行A时它停机并到达这条语句,它就会输出1。如果A不停机,它进入无限循环,我将永远无法到达这段代码的最后一行。这就是这里正确性的依据。
为了正确性,我需要说:是的,这个算法正确地判定了无输入停机问题。为此,正如我所说,我们需要证明:A'在所有输入上输出1,当且仅当程序A(无输入)停机。所以A'在所有输入上输出1,当且仅当A停机。这就完成了这个证明。
程序等价性问题
我们将再看一个例子。这是我第一张幻灯片上给出的第一个不可判定问题的例子:程序等价性问题。CS341的评分者会非常希望拥有这个功能来处理你们的编程作业:给定两个程序,它们是否做相同的事情?对于相同的输入,它们是否产生相同的输出?
定理:这是一个不可判定的问题,没有算法可以做到。
我们将遵循相同的模式,证明无输入停机问题可归约到程序等价性问题。因为无输入停机问题没有算法,所以这个问题也没有算法。
归约非常相似,我们将以前一张幻灯片中的相同方式构造A',这将是我们的第一个程序。我们的输入现在是两个程序:第一个是前一张幻灯片中的A'(我们想知道它是否对每个输入都输出1),程序B将是一个非常简单的代码片段,它只是读取输入并总是输出1。然后我们问:A'是否等价于这个平凡的程序B?我们将问A'和B是否等价。它们等价,当且仅当A停机。我将把细节留给你们作为练习。听别人讲解这些论证是一回事,自己思考细节是更好的事。但我想,在看过并能够研究前面的例子之后,你现在可以做到这一点。
总结与展望
好了,总结一下。还有更多有趣的不可判定问题,但我们不会在这里讨论更多。如果你选修CS360,你会看到更多这样的例子。
最后,作为将不可判定性主题与本课程早期部分联系起来的最后总结,我这里有一张整个大局的图片:可以在多项式时间内解决的决策问题类P;更大的类NP(看起来更大,我们实际上不知道,也许它是一样的);然后是更大的可判定问题类(无论我们花多少时间,是否存在一个算法可以做到);然后是所有决策问题的巨大类。我们今天展示的是,停机问题以及程序验证等各种其他问题,是位于可判定问题类之外的决策问题。
用集合符号表示,我们有:P是NP的子集(我们不知道是否相等),NP是多项式空间可解问题的子集(同样,我们不知道是否相等),多项式空间可解问题是指数运行时间(即2的某个n的多项式次方)可判定的决策问题类的子集。这些是可判定问题的子集,并且已知这两个类不相等。可判定问题是所有决策问题的真子集。
这就是我们今天研究的内容。关于这里的第一个链条,已知的是P不等于指数时间可判定的问题集。我说过这些是否相等是开放的,那些是否相等是开放的,那些是否相等是开放的,但已知这个不等于那个。所以沿着这个链条,某处必须有一个集合不等于下一个集合的情况,但具体在哪里是未知的。特别是,多项式时间可解问题是否等于多项式空间可解问题,这是一个开放问题。
在哪里可以了解更多关于不可判定性和复杂性类的知识?那将是CS360,尤其是CS365,它快速涵盖360的内容,然后进行更多的复杂性理论。像这样的问题将在那里探讨。我之前说过,在哪里可以了解更多关于算法的知识?那将是在下一门算法课程CS466中,还有CNO系开设的组合优化课程,涵盖匹配算法和网络流等内容。在其他机构的计算机科学课程中,这些内容可能是像这样一门课程的一部分,但因为我们有一个专门研究这些主题的系,所以这些是单独开设的课程。
本讲座总结:不可判定性。定义是什么?一些不可判定问题的例子,以及一些不可判定性的证明。你们当然应该掌握这个定义,并且应该知道如何进行归约来证明某个问题是不可判定的。

好了,这就是本讲座的内容,也是本课程的内容。

浙公网安备 33010602011771号