斯坦福算法启蒙笔记-全-

斯坦福算法启蒙笔记(全)

001:为什么学*算法

在本节课中,我们将探讨学*算法设计与分析的重要性。我们将从算法的基本定义开始,逐步了解其在计算机科学及其他领域的核心作用。


什么是算法?

算法是一组明确定义的规则,本质上是一个用于解决特定计算问题的“配方”。

例如:

  • 你可能有一组数字,需要将它们重新排列成有序序列。
  • 你可能有一张地图、一个起点和一个终点,需要计算从起点到终点的最短路径。
  • 你可能面临多个需要在不同截止日期前完成的任务,需要确定完成这些任务的顺序,以确保所有任务都能在各自的截止日期前完成。

为什么学*算法?

上一节我们介绍了算法的基本概念,本节中我们来看看学*算法的几个关键原因。

首先,理解算法和数据结构的基础知识,对于从事计算机科学几乎所有分支的严肃工作都至关重要。这也是为什么在斯坦福大学,本系提供的每一个学位——学士、硕士和博士——都要求学*这门课程。

以下是算法在不同领域应用的一些例子:

  • 通信网络中的路由依赖于经典的最短路径算法
  • 公钥密码学的有效性依赖于数论算法
  • 计算机图形学需要几何算法提供的计算原语。
  • 数据库索引依赖于平衡搜索树数据结构。
  • 计算生物学使用动态规划算法来测量基因组相似性。
  • 这样的例子还有很多。

其次,算法在现代技术创新中扮演着关键角色。举一个最明显的例子:搜索引擎使用一系列复杂的算法来高效计算各个网页与给定搜索查询的相关性。其中最著名的搜索算法是谷歌目前使用的PageRank算法

事实上,在2010年12月提交给美国白宫的一份报告中,总统科技顾问委员会指出,在许多领域,算法改进带来的性能提升,甚至远远超过了处理器速度提升所带来的显著性能增益

第三,尽管这超出了本课程的范围,但算法正越来越多地被用作观察计算机科学和技术之外过程的新视角。

例如:

  • 对量子计算的研究为量子力学提供了一个新的计算视角。
  • 经济市场中的价格波动可以被富有成效地视为一种算法过程
  • 甚至进化也可以被有效地看作一种异常高效的搜索算法

最后,学*算法还有两个听起来可能有些轻率,但都包含不少真理的原因。我不知道你们怎么想,但当我还是学生时,我最喜欢的课程总是那些具有挑战性的课程,在我努力攻克之后,会觉得自己比开始时聪明了一些。我希望这门课程能为你们中的许多人提供类似的体验。

此外,我希望到课程结束时,我能说服你们中的一些人同意我的观点:算法设计与分析本身就充满乐趣。这是一项需要精准性与创造性罕见结合的事业。它有时确实会令人沮丧,但也非常令人着迷。


从抽象到具体

现在,让我们从这些崇高的概括中回到具体层面,并记住:我们从小就开始学*和使用算法了


本节课中我们一起学*了算法的基本定义,并探讨了学*算法的多重重要性,包括其在计算机科学各领域的核心应用、对技术创新的推动作用,以及其作为一种思维工具的广泛价值。

002:整数乘法

概述

在本节课中,我们将要学*一个基础的算法问题:整数乘法。我们将首先精确地定义这个问题,然后回顾你在小学三年级可能学过的乘法算法,并分析其性能。最后,我们将提出一个算法设计师的核心问题:我们能否做得更好?


问题定义

许多课程讲座将遵循一个模式:首先定义一个计算问题,明确其输入和期望的输出,然后给出一个将输入转换为输出的算法。

对于整数乘法问题,输入是两个 n 位数字的整数 XY。数字的长度 n 可以是任意值,但为了便于理解,你可以想象 n 非常大,例如数千位甚至更多,就像在某些需要处理极大数字的密码学应用中一样。

期望的输出很简单,就是乘积 X × Y


小学乘法算法

上一节我们定义了整数乘法问题,本节中我们来看看解决这个问题的经典算法:小学三年级学*的乘法算法。

我们将通过计算该算法执行的基本操作数量来评估其性能。目前,我们将基本操作定义为两个单位数字的加法或乘法。接下来,我们将分析该算法所需的基本操作数量,作为输入数字位数 n 的函数。

以下是该算法在一个具体例子(1234 × 5678)上的演示。我们的重点应放在算法执行的基本操作数量上,在本例中,输入数字是4位数。

算法步骤

  1. 计算部分积:为第二个数字的每一位计算一个部分积。
    • 首先计算 4 × 5678。
    • 然后计算 3 × 5678,并在结果末尾添加一个零(即左移一位)。
    • 接着计算 2 × 5678,并在结果末尾添加两个零。
    • 最后计算 1 × 5678,并在结果末尾添加三个零。
  2. 求和:将所有部分积相加,得到最终结果。

算法分析

你可能在三年级就意识到这个算法是正确的:无论起始整数 XY 是什么,只要正确执行此过程和所有中间计算,算法最终都会终止并输出正确的乘积 X × Y

但你当时可能没有考虑执行这个算法直到结束所需的时间,即完成前所需的单位数字加法或乘法的基本操作数量。

现在,让我们快速非正式地分析一下,作为输入长度 n 的函数,所需操作的数量。

  • 计算第一个部分积(例如 4 × 5678)时,我们需要将第一个数字的每一位(共 n 位)与第二个数字的当前位相乘,并进行进位处理。这最多需要 2n 次基本操作。
  • 对于每一个部分积(共有 n 个),情况都是类似的,每个最多需要 2n 次操作。因此,生成所有部分积最多需要 2n × n = 2n² 次操作。
  • 最后,将所有部分积相加也需要可比数量的操作,最多再需要约 2n² 次操作。

核心结论:随着输入数字变大(即位数 n 增加),小学乘法算法执行的操作数量大致以 常数 × n² 的速度增长。也就是说,其时间复杂度是输入长度 n二次方

公式表示:操作数量 ≈ c · n²,其中 c 是一个常数。

例如:

  • 如果输入长度(位数)加倍,使用此算法必须执行的操作数量将增加四倍
  • 如果输入长度变为四倍,操作数量将增加十六倍

寻求更好的算法

根据你三年级时的性格,你可能认为这个步骤是计算两个数字乘积的唯一或至少是最优方法。

但是,如果你想成为一名严肃的算法设计师,就必须摆脱这种顺从的怯懦。一本关于算法设计与分析的重要早期教科书(Aho, Hopcroft, Ullman 著)中有一句我非常喜欢的话:

“也许对于优秀的算法设计师来说,最重要的原则就是拒绝满足。”

我可以将其更简洁地总结为:作为算法设计师,你应该将“我们能否做得更好?”这句话作为你的座右铭。

当你面对一个计算问题的朴素或直接解法时(例如整数乘法的小学算法),这个问题尤其适用。

你在三年级时可能没有问过自己:我们能否比这种直接的乘法算法做得更好?现在,是时候寻找答案了。


总结

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

  1. 精确地定义了整数乘法这个计算问题,其输入是两个 n 位整数,输出是它们的乘积。
  2. 回顾并分析了小学乘法算法,确认其正确性,并分析了其时间复杂度为 O(n²),即操作数量随输入位数呈二次方增长。
  3. 引入了算法设计中的一个核心精神:永不满足,并始终追问“我们能否做得更好?”,为后续探索更高效的算法奠定了基础。

003:Karatsuba乘法

在本节课中,我们将要学*一种不同于小学所学的整数乘法算法——Karatsuba乘法。我们将通过一个具体的例子来了解其基本思想,然后系统地学*其递归实现原理,并理解它如何通过巧妙的数学变换减少计算量。

上一节我们回顾了小学的整数乘法算法,本节中我们来看看一种截然不同的方法。

🧩 一个神秘的例子

首先,我们通过一个具体例子来感受Karatsuba乘法的计算过程。我们将计算两个整数 X = 5678Y = 1234 的乘积。

以下是计算步骤:

  1. 将每个数字分成两半。令 A = 56, B = 78, C = 12, D = 34
  2. 计算 A * C = 56 * 12 = 672
  3. 计算 B * D = 78 * 34 = 2652
  4. 计算 (A + B) * (C + D) = (56+78) * (12+34) = 134 * 46 = 6164
  5. 计算 (A+B)*(C+D) - A*C - B*D = 6164 - 672 - 2652 = 2840
  6. 最后,将上述结果按如下方式组合:
    • A*C 的结果 672 后补4个零,得到 6720000
    • 将步骤5的结果 2840 后补2个零,得到 284000
    • B*D 的结果 2652 保持不变。
    • 将这三个数相加:6720000 + 284000 + 2652 = 7006652

这个结果 7006652 正是 5678 * 1234 的正确乘积。这个计算过程看起来像是魔术,但它揭示了一个重要事实:整数乘法存在多种不同的算法

🔍 递归思想的引入

在解释Karatsuba乘法之前,我们先看一个更直观的递归乘法思路。假设我们有两个n位数 XY

我们可以将 XY 分别表示为前后两半的组合:

  • X = A * 10^(n/2) + B
  • Y = C * 10^(n/2) + D

其中 A, B, C, D 都是 n/2 位的数。那么它们的乘积可以展开为:

X * Y = (A * 10^(n/2) + B) * (C * 10^(n/2) + D)
      = A*C * 10^n + (A*D + B*C) * 10^(n/2) + B*D

我们称这个表达式为 公式(*)

这个表达式直接给出了一个递归算法:要计算 X*Y,我们只需要递归地计算四个更小的乘积 A*C, A*D, B*C, B*D,然后将结果按公式(*)组合起来。当数字小到只有一位时(递归的基准情况),直接相乘即可。

🚀 Karatsuba乘法的优化

上一节我们介绍了一个需要四次递归调用的朴素递归算法。Karatsuba乘法的精妙之处在于,它发现我们只需要三次递归调用。

观察公式(*),我们真正关心的系数只有三个:A*C, (A*D + B*C), B*D。Karatsuba的关键技巧是,我们并不需要分别计算 A*DB*C,而只需要计算它们的和。

以下是具体做法:

  1. 递归计算 P1 = A * C
  2. 递归计算 P2 = B * D
  3. 递归计算 P3 = (A + B) * (C + D)

现在,注意 P3 展开后是:

P3 = (A+B)*(C+D) = A*C + A*D + B*C + B*D

那么,我们需要的中间项 (A*D + B*C) 可以通过一个简单的减法得到:

(A*D + B*C) = P3 - P1 - P2

这样,我们仅通过三次递归调用就得到了公式(*)所需的全部三个系数。之后,我们像之前一样,将 P1 左移 n 位,将 (P3-P1-P2) 左移 n/2 位,再加上 P2,就得到了最终结果。

📝 总结

本节课中我们一起学*了Karatsuba乘法算法。我们从一个小例子出发,感受到了算法设计空间的丰富性。然后,我们推导了整数乘法的递归表达式,并在此基础上,学*了Karatsuba如何利用 (A+B)*(C+D) 这一个额外的乘积,通过巧妙的加减运算,将所需的递归调用次数从四次减少到三次。这展示了即使在整数乘法这样基础的问题上,也存在着令人惊叹的优化空间。至于Karatsuba算法是否真的比小学算法更快,我们将在后续课程中学*“分治算法”的分析工具后给出答案。

004:归并排序的动机与示例 🧩

在本节课中,我们将学*如何分析一个算法。我们将通过回顾著名的归并排序算法,并给出其运行时间上界的数学精确描述,来初步了解算法分析的实际过程。

概述

归并排序是一个经典的排序算法,它完美地体现了“分治”这一算法设计范式。尽管它已有数十年的历史,但因其高效性,至今仍在许多编程库中被广泛使用。本节课,我们将详细探讨归并排序的工作原理,并分析其性能。

为什么从归并排序开始?

选择归并排序作为起点有多个原因。首先,它是分治范式的典型应用,能清晰地展示该范式的思想、分析挑战及其带来的优势。其次,与一些更简单直观的排序算法(如选择排序、插入排序、冒泡排序)相比,归并排序能提供更好的性能。这些简单算法通常具有O(n²)的运行时间,而归并排序,正如我们将看到的,性能更优。

此外,讨论归并排序有助于你评估自己的预备知识水平。本课程假设你具备扎实的编程基础,能够将算法的高层思想转化为实际代码。归并排序的分析也将自然地引出本课程分析算法的一般方法:我们关注最坏情况下的行为,进行渐*分析(即关注运行时间的增长率,而非低阶项或常数因子的微小变化),并使用递归树方法进行计算。这种方法具有很好的通用性,可用于分析多种递归和分治算法。

排序问题

归并排序旨在解决排序问题。其输入是一个包含n个任意顺序数字的数组,目标是输出一个从小到大排序的数组。

例如,给定输入数组:
[5, 4, 1, 8, 7, 2, 6, 3]
目标输出数组为:
[1, 2, 3, 4, 5, 6, 7, 8]

为简化讨论,我们假设输入数组中的元素互不相同。你可以思考,如果存在重复元素,算法和分析会有何不同。

归并排序图解

归并排序是递归算法,它通过调用自身来解决更小的子问题。其核心思想是典型的分治策略:将输入数组分成两半,递归地对每一半进行排序,然后将两个已排序的子数组合并成一个完整的排序数组。

让我们通过一个例子来图解这个过程。考虑上面的输入数组 [5, 4, 1, 8, 7, 2, 6, 3]

  1. 分解:算法首先将数组分成左右两半。

    • 左半部分:[5, 4, 1, 8]
    • 右半部分:[7, 2, 6, 3]
      (可以想象这些子数组被复制到新数组中,再传递给递归调用。)
  2. 递归求解:通过递归调用的“魔力”(或归纳法),每个递归调用将正确排序其接收的子数组。

    • 对左半部分递归调用后,得到排序结果:[1, 4, 5, 8]
    • 对右半部分递归调用后,得到排序结果:[2, 3, 6, 7]
  3. 合并:最后一步是合并。算法需要将这两个已排序的长度为4的子数组合并,产生最终的8元素排序数组 [1, 2, 3, 4, 5, 6, 7, 8]

合并步骤需要以计算高效的方式实现。基本思路是使用两个指针分别遍历两个已排序的子数组,比较指针所指元素,将较小的元素复制到输出数组中,并移动相应的指针。我们将在后续内容中提供更多细节。

总结

本节课我们一起学*了归并排序算法的动机和基本示例。我们了解到归并排序是分治算法的典范,通过递归地将问题分解、解决子问题再合并结果,来实现高效的排序。我们还明确了本课程分析算法的基本框架:关注最坏情况、进行渐*分析、并使用递归树方法。在接下来的课程中,我们将深入探讨归并排序的具体实现、合并操作的细节,并正式分析其运行时间。

005:归并排序伪代码

概述

在本节课中,我们将学*归并排序算法的伪代码实现。我们将详细探讨算法的递归结构,并重点分析合并步骤的实现细节。最后,我们将开始讨论归并排序的运行时间,并将其与更简单的排序算法进行比较。


归并排序伪代码

上一节我们介绍了归并排序的基本思想,本节中我们来看看它的伪代码实现。

首先,我们讨论归并排序算法的高层伪代码,暂时忽略合并子程序的具体实现。在这个层面上,算法应该非常简单明了。

算法将包含两次递归调用,然后是一个合并步骤。

需要说明的是,这里的伪代码并非可以直接翻译成代码,虽然非常接*。我忽略了一些细节,以便我们专注于核心概念。

以下是几个被忽略的细节:

  1. 在任何递归算法中,都需要有基准情况。当输入足够小时,我们不进行递归,直接返回一个简单的答案。对于排序问题,基准情况是当数组有零个或一个元素时,它已经是有序的,无需任何操作,直接返回即可。为了清晰起见,我没有在伪代码中写出基准情况,但在实际实现中必须包含。
  2. 我忽略了当数组长度为奇数时该如何处理。例如,一个有9个元素的数组,显然需要将其拆分为5和4或4和5。你可以选择任意一种方式,这没有问题。
  3. 我没有讨论如何将子数组传递给递归调用的具体细节。这在一定程度上取决于编程语言。这正是我希望避免的,我希望讨论的是超越任何特定编程语言实现的概念。

因此,我将在这个层面上描述算法。


合并步骤的实现

相对而言,困难的部分是如何实现合并步骤。递归调用已经完成了它们的工作,我们得到了两个已排序的、各包含一半数字的子数组(左半部分和右半部分)。我们如何将它们合并成一个有序数组?

在上一节中,我已经用文字描述了基本思想:通过并行遍历两个已排序的子数组,按顺序填充输出数组。现在让我们更详细地看看这个过程。

以下是合并步骤的伪代码。

首先,为我们将要讨论的角色引入一些名称:

  • C 表示输出数组。这是我们最终要输出的、按排序顺序排列的数字数组。
  • AB 表示两次递归调用的结果。第一次递归调用给了我们数组 A,它包含输入数组的左半部分,且已排序。类似地,B 包含输入数组的右半部分,也已排序。

我们需要并行遍历两个已排序的子数组 AB。因此,我引入两个计数器:

  • I 用于遍历 A
  • J 用于遍历 B

IJ 初始化为1,指向各自数组的开头。

现在,我们将对输出数组进行一次简单的遍历,按递增顺序填充它。我们总是从两个已排序子数组的并集中取出最小的元素。

合并步骤的核心思想是认识到:在 AB 中,你尚未查看的最小元素必定位于两个列表之一的最前面

例如,在算法开始时,整体最小元素在哪里?无论它落在 A 还是 B 中,它都必须是该数组中最小的元素。因此,整体最小元素要么是 A 中的最小元素,要么是 B 中的最小元素。你只需检查这两个位置,将较小的那个复制过来,然后重复这个过程。

变量 K 的作用是从左到右遍历输出数组,这是我们填充数组的顺序。我们当前查看的是第一个数组的第 I 个位置和第二个数组的第 J 个位置,这表示我们已经深入到这两个数组的程度。我们比较哪个位置的元素当前最小,然后将最小的那个复制过来。

如果 A 中第 I 个位置的元素较小,我们就复制它。当然,我们必须递增 I,以便更深入地探查列表 A。对于 B 中当前位置元素较小的情况,操作是对称的。

再次说明,为了专注于整体思路而不被细节困扰,我忽略了一些边界情况。如果你真的想实现这个算法,必须添加一些额外的检查来处理当 IJ 到达数组末尾的情况。此时,你需要将剩余的所有元素复制到 C 中。

以下是清理后的伪代码版本,与上一张幻灯片上写的内容相同,即合并步骤的伪代码。

Merge (A, B):
    Let C be an empty output array.
    Initialize i = 1, j = 1.
    For k = 1 to n:
        If A[i] < B[j]:
            C[k] = A[i]
            i = i + 1
        Else:
            C[k] = B[j]
            j = j + 1
    Return C

这就是合并算法。


归并排序的运行时间分析

现在,让我们进入本讲的核心部分:归并排序能产生一个有序数组,那么是什么(如果有的话)使它比更简单的非分治算法(例如插入排序)更好?换句话说,归并排序算法的运行时间是多少?

我不会给出运行时间的完全精确定义,这有充分的理由,我们稍后会讨论。但直观上,你应该这样理解算法的运行时间:想象你正在调试器中运行算法,每次按下回车键,程序就通过调试器前进一步。基本上,运行时间就是执行的操作数量,即执行的代码行数。所以问题是,在程序最终终止之前,你需要在调试器中按多少次回车键。

我们感兴趣的是,当输入数组有 n 个数字时,归并排序会执行多少行这样的代码。这是一个相当复杂的问题。

让我们从一个更适中的目标开始。与其思考归并排序这个不断调用自身的疯狂递归算法执行了多少次操作,不如先思考当我们对两个已排序的子数组执行一次合并时,会执行多少次操作。这似乎是一个更容易入手的起点。

让我提醒你合并子程序的伪代码。

让我们来计算一下将执行多少次操作。

首先是初始化步骤。我们为这两个初始化各计一次操作,称之为 2次操作。即 i = 1j = 1

然后是这个 for 循环。显然,for 循环总共执行 n 次。

在这个 for 循环的每一次迭代中,执行了多少条指令?

  1. 我们有一次比较:比较 A[i]B[j]
  2. 无论比较结果如何,我们都会再做两次操作:进行一次赋值(C[k] = A[i]C[k] = B[j]),然后递增相关变量(i = i + 1j = j + 1)。
  3. 也许我还会说,为了递增 k,我们将其计为第四次操作。

因此,在这个 for 循环的 n 次迭代中,每次我们将执行 4次操作

综上所述,合并子程序的运行时间结论是:对于一个包含 m 个数字的数组,合并子程序的运行时间最多是 4m + 2

需要说明几点:

  1. 我改变了字母表示,请不要混淆。在上一张幻灯片中,我们考虑的是输入大小为 N。这里我将变量名改为了 M,这在我们考虑归并排序递归处理更小的子问题时会很方便,但本质上是相同的。
  2. 在具体计算代码行数时存在一些模糊性。也许你会争辩说,实际上每次循环迭代应该计为两次操作,而不是一次,因为你不仅需要递增 k,还需要将其与上限 n 进行比较。那样的话,可能是 5m + 2 而不是 4m + 2

事实证明,如何计算执行的代码行数上的这些微小差异并不重要,我们很快就会明白原因。所以,让我们约定:对于恰好有 M 个条目的数组,合并操作需要 4M + 2 次操作。

现在,请允许我稍微滥用一下我们的约定,使用一个虽然正确但极其粗略的不等式。我保证这会让未来的计算更简单。与其用 4M + 2(这有点让我烦恼),我们干脆称之为最多 6M。因为 M 至少为 1,所以 6M 总是大于等于 4M + 2。这非常粗略,对于大的 M,这些数字并不接*,但为了未来的简单性,我们就继续粗略下去吧。

我不指望有人会对合并子程序完成执行所需代码行数的这个相当粗糙的上界留下深刻印象。关键问题是,归并排序需要多少行代码来正确排序输入数组,而不仅仅是这个子程序。

实际上,分析归并排序似乎要令人生畏得多,因为它会不断产生自身的递归版本。当我们考虑递归的各个层级时,需要分析的事物数量呈指数级增长。

然而,我们有一个有利因素:每次进行递归调用时,输入都比开始时小得多,只有输入数组的一半大小。因此,一方面存在子问题数量的爆炸式增长,另一方面连续的子问题只需要解决越来越小的子问题。调和这两种力量将推动我们对归并排序的分析。

好消息是,我将能够向你展示对归并排序所需代码行数的完整分析,并且事实上能够给出一个非常精确的上界。

以下是我们将在本讲剩余部分证明的论断:

论断:归并排序从不需要超过 6n * log₂n + 6n 次操作,就能正确排序一个包含 n 个数字的输入数组。

让我们讨论一下:这个结果好吗?知道这是归并排序所需代码行数的上界,这是一个胜利吗?

是的,它是。它展示了分治范式的优势。回想一下我们简要讨论过的更简单的排序方法,如插入排序、选择排序和冒泡排序,我声称它们的性能由输入大小的二次函数主导,即它们需要常数乘以 次操作来排序长度为 n 的输入数组。

相比之下,归并排序最多需要常数乘以 n * log n(不是 ,而是 n * log n)行代码来正确排序输入数组。

为了感受这是一种什么样的优势,让我提醒一下那些生疏的或者出于某种原因对对数感到恐惧的人,对数到底是什么。

理解对数的方式如下:考虑 X轴 代表 N,从 1 到无穷大。为了比较,让我们考虑恒等函数 f(n) = n

让我们将其与对数进行对比。就我们的目的而言,我们可以这样理解对数:log₂(n) 就是你在计算器中输入数字 n,然后不断除以 2,直到得到的数小于 1,你数一数总共除以 2 的次数。

例如:

  • 如果你输入 32,你需要除以 2 五次才能降到 1,所以 log₂(32) = 5
  • 如果你输入 1024,你需要除以 2 十次才能降到 1,所以 log₂(1024) = 10

关键在于,如果 log(1000) 大约是 10,那么对数比输入本身要小得多

从图形上看,对数函数看起来像一条随着 n 增大而迅速变得非常平坦的曲线。我鼓励你在家使用计算机或图形计算器更精确地绘制一下,但对数函数的增长速度比恒等函数慢得多

因此,运行时间与 n * log n 成正比的排序算法,尤其是当 n 很大时,比运行时间为常数乘以 的排序算法要快得多


总结

本节课中,我们一起学*了归并排序算法的伪代码实现。我们首先概述了算法的高层结构,包括递归调用和合并步骤。接着,我们深入探讨了合并步骤的具体实现,理解了如何通过并行遍历两个已排序的子数组来高效地合并它们。最后,我们开始了对归并排序运行时间的分析,通过估算合并子程序的操作次数,并初步将其与简单排序算法的二次方运行时间进行对比,揭示了归并排序 n * log n 时间复杂度所带来的显著性能优势,特别是在处理大规模数据时。在接下来的课程中,我们将完成对这个运行时间上界的严格证明。

006:归并排序分析 🧮

在本节课中,我们将对归并排序算法进行运行时间分析。我们将通过数学论证来证实,这种递归分治的归并排序算法,其性能优于你可能知道的更简单的排序算法,如插入排序、选择排序和冒泡排序。

具体目标是,从数学上论证之前视频中提出的一个主张:为了对一个包含 n 个数字的数组进行排序,归并排序算法最多需要执行常数乘以 n log n 次操作。这是它最多会执行的代码行数,具体来说是 6n log₂ n + 6n 次操作。

递归树方法 🌳

为了证明这个主张,我们将使用一种称为递归树的方法。

上一节我们明确了分析目标,本节中我们来看看如何通过递归树来达成这个目标。

递归树方法的核心思想是,将递归的归并排序算法完成的所有工作,以一种树形结构写出来。树中给定节点的子节点,对应于该节点进行的递归调用。这种树形结构的意义在于,它能以一种有趣的方式帮助计算算法完成的总体工作量,从而极大地简化分析。

具体来说,这棵树是什么样的呢?在第0层,我们有一个根节点。

这对应于归并排序最外层的调用。我称这一层为第0层。这棵树是二叉树,因为归并排序的每次调用都会进行两次递归调用。因此,两个子节点对应于归并排序的两次递归调用。在根节点,我们操作整个输入数组。在下一层(第1层),我们有两个子问题:一个对应输入数组的左半部分,另一个对应右半部分。

当然,第1层的这两个递归调用各自又会进行两次递归调用,每个调用操作原数组的四分之一。这些是第2层的递归调用,共有4个。这个过程会一直持续,直到递归在基本情况(数组大小为0或1)时结束。

现在有一个问题:在这个对应基本情况的递归树底部,叶子节点位于哪一层?

正确答案是第二个选项。递归树的层数基本上与输入数组大小的对数成正比。原因在于,随着递归的每一层,输入大小都会以因子2减少。如果在最外层输入大小为 n,那么第一组递归调用每个操作大小为 n/2 的数组;在第2层,每个数组大小为 n/4,依此类推,直到递归在输入数组大小为1或更小时的基本情况结束。

换句话说,递归的层数恰好等于你需要将 n 除以2多少次,才能得到一个小于等于1的数。回想一下,这正是以2为底的 n 的对数 log₂ n 的定义。由于第一层是第0层,最后一层是第 log₂ n 层,所以总层数实际上是 log₂ n + 1

写下这个表达式时,我假设 n 是2的幂。这不是大问题,分析可以很容易地扩展到 n 不是2的幂的情况,但这样我们就不必考虑分数,log₂ n 就是一个整数。

让我们回到递归树,快速重画一下。

在树的底部,我们有叶子节点,即不再进行递归的基本情况。当 n 是2的幂时,这些基本情况恰好对应于单元素数组。

这就是归并排序调用对应的递归树。以这种方式组织归并排序执行的工作,其动机在于它允许我们逐层计算工作量。我们将看到,这是一种特别方便的方法,可以统计所有被执行的不同代码行。

为了更详细地理解这一点,我需要你识别一个特定的模式。首先,第一个问题是:在这个递归树的给定第 j 层,有多少个不同的子问题?这是关于层数 j 的函数。第二个问题是:对于第 j 层的每个不同子问题,输入大小是多少?即传递给位于递归树第 j 层的子问题的数组大小是多少?

正确答案是第三个选项。

首先,在给定第 j 层,恰好有 2^j 个不同的子问题。第0层有一个最外层的子问题,它有两个递归调用,这些是第1层的两个子问题,依此类推。通常,由于归并排序调用自身两次,子问题的数量每层翻倍,这给出了第 j 层子问题数量的表达式 2^j

另一方面,通过类似的论证,输入大小每次都在减半。随着每次递归调用,你传递给你被给予的输入的一半。所以在递归树的每一层,我们看到的是前一层输入大小的一半。经过 j 层后,由于我们开始时输入大小为 n,每个子问题将操作长度为 n / 2^j 的数组。

逐层计算工作量 ⚙️

现在让我们利用这个模式,实际计算归并排序执行的所有代码行数。如前所述,关键思想是逐层计算工作量。需要明确的是,当我谈论第 j 层完成的工作量时,我指的是那 2^j 个归并排序实例所做的工作,不包括它们各自的递归调用,也不包括在树中更低层递归中将要完成的工作。

回顾一下,归并排序是一个非常简单的算法,它只有三行代码:首先是一个递归调用(我们在第 j 层不计算这个),然后是另一个递归调用(同样不计算),第三行只是调用合并子程序。

因此,在递归调用之外,归并排序所做的就是一次合并子程序的调用。

进一步回顾,我们已经很好地理解了合并子程序在大小为 m 的输入上所需的代码行数。根据我们在上一个视频中的分析,它最多使用 6m 行代码。

让我们固定一个层数 j。我们知道有多少个子问题:2^j 个。我们知道每个子问题的大小:n / 2^j。我们知道合并在这样的输入上需要多少工作:我们只需乘以6。然后我们乘出来,就得到了在第 j 层所有子问题上完成的工作量。

以下是更详细的步骤:

我们从第 j 层不同子问题的数量开始,我们注意到这个数量最多是 2^j

我们还观察到,每个第 j 层的子问题被传递一个长度为 n / 2^j 的数组作为输入。我们知道,当合并子程序被给予一个大小为 n / 2^j 的数组时,它将执行最多6倍于该数量的代码行。

因此,为了计算第 j 层完成的总工作量,我们只需将问题数量乘以每个子问题完成的工作量。然后,某种奇妙的事情发生了:我们得到了 2^j 的抵消,并得到了一个上界 6n

这个上界与层数 j 无关。所以我们在根节点最多做 6n 次操作,在第1层最多做 6n 次操作,在第2层也是如此,依此类推。它独立于层数。

从数学上讲,发生这种情况的原因是两个竞争力量之间的完美平衡:首先,子问题的数量随着递归树的每一层而翻倍;但其次,我们每个子问题所做的工作量随着递归树的每一层而减半。由于这两者相互抵消,我们得到了一个独立于层数 j 的上界 6n

计算总工作量 📊

现在,这就是它如此酷的原因:我们并不真正关心某一特定层的工作量,我们关心的是归并排序在所有层上完成的总工作量。如果我们有一个独立于层数的每层工作量上界,那么我们的总上界计算就变得非常简单。

我们该怎么做?我们只需取层数,我们知道层数是多少:恰好是 log₂ n + 1。记住,层数是从0到 log₂ n(包含)。然后,我们对这 log₂ n + 1 层中的每一层都有一个上界 6n

因此,如果我们展开这个量,我们恰好得到了之前声称的上界:即归并排序执行的操作数最多为 6n * log₂ n + 6n

总结 🎯

本节课中我们一起学*了归并排序算法的运行时间分析。这就是为什么它的运行时间以常数乘以 n log n 为上界。特别是当 n 变大时,这远远优于更简单的迭代算法,如插入排序或选择排序。

我们通过递归树方法,逐层分析了算法的工作量,并利用子问题数量翻倍与每个子问题工作量减半相互抵消的特性,得出了简洁的总工作量上界公式 6n log₂ n + 6n。这个分析清晰地展示了归并排序高效的原因。

007:算法分析的三大指导原则 🧭

在本节课中,我们将学*算法分析的三个核心指导原则。这些原则构成了我们后续课程中定义“快速算法”和进行算法推理的基础。

概述

我们已经完成了对归并排序算法的首次分析,即对其运行时间上界的推导。接下来,我们将退一步,明确在分析归并排序并解释结果时所做的三个假设。这三个假设将被我们采纳为指导原则,用于在后续课程中推理算法,并定义所谓的“快速算法”。

原则一:最坏情况分析

上一节我们介绍了对归并排序的分析,本节中我们来看看第一个指导原则:最坏情况分析

最坏情况分析意味着,我们推导出的上界(例如 6n log n + 6n)适用于每一个长度为 n 的输入数组。我们在分析时,除了输入长度 n 之外,没有对输入数据做任何假设。即使存在一个“对手”,其唯一目的就是构造出能让算法运行最慢的恶意输入,我们的上界依然成立。

你可能会问,还有其他分析方法吗?确实存在,例如平均情况分析基准测试。平均情况分析是在假设不同输入出现频率的基础上,分析算法的平均运行时间。基准测试则是预先商定一组被认为能代表算法典型或实际输入的测试用例。这两种方法在某些场景下很有用,但它们要求你对问题领域有深入了解,知道哪些输入更常见或更具代表性。

相比之下,最坏情况分析不假设任何输入分布,因此特别适用于通用子程序的设计,即你无法预知其将被如何使用或处理何种输入。此外,最坏情况分析在数学上也通常比分析特定输入分布下的平均性能或特定基准测试下的行为更易于处理。我们在归并排序分析中已经看到了这一点,我们并没有刻意去分析最坏情况,但推理过程自然导出了最坏情况下的上界。

原则二:忽略常数因子和低阶项

在第一个原则中,我们明确了分析的范围。现在,我们来看第二个指导原则:在分析算法时,我们不过度关注小的常数因子和低阶项

这个原则在我们分析归并排序的合并子程序时已经体现。我们首先将其代码行数上界定为 4m + 2(对于长度为 m 的数组),但随后我们简化为 6m,使用了一个更简单、更宽松的上界来进行后续分析。

以下是采用此原则的三个理由:

  1. 数学上的简便性:不过分纠结于精确的常数因子和低阶项,数学分析会容易得多
  2. 分析层次的匹配性:在本课程描述的算法抽象层次上,过度关注精确常数是不合适的。我们使用伪代码描述算法,其转换为具体编程语言(如 C 或 Java)时,代码行数会因实现细节(如循环计数方式)而产生微小差异。进一步编译成机器码后,差异还会因处理器、编译器及优化选项而更大。因此,精确常数最终由更底层的、机器相关的因素决定。
  3. 实践中的可行性:对于本课程讨论的问题,即使忽略常数因子和低阶项,我们仍能获得极强的预测能力。当数学分析表明一个算法快速时,实践中它通常确实快速;反之亦然。我们虽然损失了一些信息的粒度,但没有丢失我们真正关心的核心:准确判断哪些算法比另一些更快。

需要明确的是,我并非说常数因子在实践中不重要。对于关键程序,常数因子极其重要,应尽力优化。但就本课程所要进行的算法分析而言,纠结于微小的常数因子是不合适的粒度。

原则三:渐*分析

在明确了不过度关注常数细节后,我们来看第三个指导原则:渐*分析

这意味着我们将聚焦于大规模输入,关注算法性能随输入规模 n 增大(趋于无穷)时的表现。这个焦点在我们解释归并排序的上界时已经显现。我们声称其操作数正比于 n log n,并断言这优于任何运行时间与 n 成二次方关系的算法(如插入排序的 (1/2)n²)。

这是一个数学陈述,当且仅当 n 足够大时才成立。对于小的 n,由于常数因子更小,二次方项可能反而更小。但当我们说归并排序优于插入排序时,隐含的假设是我们关注的是大规模输入。

这个假设合理吗?答案是肯定的。原因如下:

  • 只有大规模问题才真正有趣:如果你只需要排序100个数字,在现代计算机上任何方法都能瞬间完成,无需了解分治等高级范式。
  • 摩尔定律的反向效应:计算机速度的不断提升(摩尔定律)并未降低算法分析的重要性,反而提升了我们对计算规模的野心。我们自然会更关注更大规模的问题,此时 n log n 算法与 算法之间的性能鸿沟会越来越宽
  • 可解决问题规模的放大效应:假设计算机速度提升4倍。对于运行时间与 n 成正比的算法,你能解决的问题规模也提升4倍。而对于运行时间与 成正比的算法,你只能解决大约2倍大的问题。

为了更直观地说明,请看下图,它比较了归并排序的上界 6n log₂ n + 6n(实线)和插入排序的估计运行时间 (1/2)n²(虚线)。

可以看到,在 n 较小时(大约90以下),由于常数因子更小,插入排序的曲线更低。但一旦超过这个交叉点,二次增长的 项就会压倒常数因子的优势,使得归并排序的曲线更低。随着 n 进一步增大(例如到1500,这仍然是现代计算机上微不足道的问题规模),二次依赖带来的差距已经非常明显。对于真正有趣的大规模问题,这个差距将是巨大的。

当然,这并不意味着在实现算法时应完全无视常数因子。事实上,许多高度优化的归并排序库会在问题规模降到某个阈值(如7个元素)以下时,切换到常数因子更小的插入排序。对于小规模问题,使用常数因子小的算法;对于大规模问题,使用增长率更优的算法。

总结与展望

本节课中我们一起学*了算法分析的三大指导原则:

  1. 最坏情况分析:为所有可能的输入提供性能保证。
  2. 忽略常数因子和低阶项:在适当的抽象层次上进行简化分析。
  3. 渐*分析:聚焦于算法在大规模输入下的增长率。

将这三个原则结合起来,我们得到了一个“快速算法”的数学定义:我们追求那些最坏情况运行时间随输入规模增长缓慢的算法

  • 左侧(目标):实际运行快速的算法。
  • 右侧(数学代理):具有良好渐*运行时间的算法(即运行时间随输入规模增长缓慢)。

这个定义是一个“甜点”:一方面,它足够抽象,忽略常数细节,使我们能够进行大量数学推理和证明定理;另一方面,它保留了强大的预测能力,理论推荐的算法在实践中也确实是已知的高效算法。

那么,“增长缓慢”具体指什么?这取决于上下文,但对于本课程将讨论的大多数问题,圣杯是获得线性时间算法,即运行时间与输入规模 n 成正比。这比归并排序的 n log n 还要好。虽然并非总能达到线性时间,但这将是我们追求的目标。

展望接下来的课程,我们将有两个目标:

  1. 分析方面:正式介绍渐*运行时间的概念,引入大O记号及其变体,解释其数学定义并举例说明。
  2. 设计方面:获得更多应用分治范式解决其他问题的经验。

下次见。

008:渐*分析入门 🚀

在本节课中,我们将要学*渐*分析。这是每一位严肃的程序员和计算机科学家用来讨论算法高层次性能的通用语言,因此是一个至关重要的主题。本节课的目标是衔接课程介绍中已讨论的高层次概念与下一节将开始建立的数学形式化体系。在进入数学形式之前,我们希望确保你充分理解这个主题的动机,对其目标有坚实的直觉,并看过几个简单的直观例子。

渐*分析的核心思想 💡

渐*分析为讨论算法的设计与分析提供了基本词汇。虽然它是一个数学概念,但绝非为了数学而数学。你经常会听到资深程序员说某段代码的运行时间是 O(n),而另一段是 O(n²)。理解这些说法的含义非常重要。

这种词汇之所以无处不在,是因为它找到了一个讨论算法高层次性能的“最佳平衡点”。一方面,它足够粗略,可以忽略掉所有你希望忽略的细节,例如依赖于特定架构、编程语言或编译器的细节。另一方面,它又足够精确,能够用于对不同高级算法方案进行预测性比较,尤其是在处理大规模输入时。正如我们之前讨论的,大规模输入才是真正需要算法智慧的地方。

例如,渐*分析能让我们区分排序算法的优劣,以及整数乘法算法的优劣。

核心原则:忽略常数因子和低阶项 ⚙️

大多数资深程序员会告诉你,渐*分析的主要目的就是忽略前导常数因子和低阶项。当然,渐*分析的内容远不止这七个字,但长远来看,如果你只记住关于渐*分析的七个字,我希望就是这七个字。

我们如何证明采用这种形式化方法是合理的呢?低阶项,顾名思义,在处理大规模输入时会变得越来越无关紧要。而常数因子则高度依赖于运行环境、编译器、语言等具体细节。如果我们想忽略这些细节,那么采用一个不过分关注前导常数因子的形式化方法就是合理的。

示例:归并排序
回忆我们分析归并排序算法时,给出的运行时间上界是 6n log n + 6n,其中 n 是输入数组的长度。这里的低阶项是 6n,它比 n log n 增长得慢,所以我们将其丢弃。前导常数因子是 6,我们也将其忽略。经过这两步简化,我们得到了一个更简单的表达式:n log n

相应的术语是:归并排序的运行时间是 O(n log n)。换句话说,当你说一个算法的运行时间是 O(f(n)) 时,你的意思是:在丢弃低阶项并忽略前导常数因子之后,剩下的就是函数 f(n)。直观上,这就是大O符号的含义。

需要明确的是,我并非断言常数因子在算法设计与分析中从不重要。我的意思是,当你思考高层次算法方案、比较解决同一问题的根本不同方法时,渐*分析通常是指导你判断哪种方法性能更好的正确工具,尤其是在处理较大规模输入时。当然,一旦你确定了解决某个问题的具体算法方案,你完全可以努力优化其前导常数因子,甚至改进低阶项。如果你的创业公司的未来依赖于某几行代码的实现效率,那么请务必让它尽可能快。

简单示例解析 🔍

在接下来的内容中,我们将通过四个非常简单的例子来加深理解。如果你已经熟悉大O符号,可以跳过这部分直接进入下一节的数学形式化内容。但如果你是初次接触,希望这些例子能帮助你建立认知。

示例一:在数组中搜索整数

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

算法分析
我们分析解决这个问题的直接算法:线性扫描数组,检查每个元素是否是目标整数 t。代码依次检查每个数组元素,如果找到 t 则返回 true,如果扫描完整个数组都没找到,则返回 false

那么,你认为这个算法的运行时间,作为数组长度 n 的函数,用大O表示法是什么?

答案与解释
答案是 O(n),或者说该算法的运行时间相对于输入长度 n线性的。

为什么?让我们思考这段代码会执行多少次操作。实际上,执行的代码行数取决于输入:取决于目标 t 是否在数组 A 中,以及如果在的话,它位于数组的哪个位置。但在最坏情况下,t 不在数组中,代码会扫描整个数组 A 然后返回 false。此时的操作次数是一个常数(例如初始设置和返回布尔值)加上对数组中每个元素执行的常数次操作。无论这个常数是2、3还是4,它都会被大O表示法方便地忽略掉。因此,总操作次数与 n 成线性关系,所以大O表示法就是 O(n)

示例二:顺序执行的两个循环

上一个例子我们分析了单个循环。接下来,我们看看两个循环以不同方式组合的情况。首先,我们看一个循环接着另一个循环执行,即两个循环顺序执行的情况。

问题描述
我们研究一个与上一个类似的问题:现在给定两个数组 AB,假设长度都是 n,我们想知道目标 t 是否存在于其中任何一个数组中。我们再次查看直接算法:先搜索 A,如果在 A 中没找到 t,再搜索 B。如果 B 中也没找到,则返回 false

那么,对于这段更长的代码,用大O表示法,它的运行时间是多少?

答案与解释
答案与上次相同:O(n)

如果我们实际计算操作次数,它当然不会和上次完全一样,大约是上一段代码的两倍,因为我们需要搜索两个长度均为 n 的不同数组。无论之前做了多少工作,现在都要做两遍。当然,这个“两倍”是一个独立于输入长度 n 的常数,在我们使用大O表示法时会被忽略。因此,这个算法和上一个一样,是一个线性时间算法,运行时间为 O(n)

示例三:嵌套循环(比较两个数组)

现在让我们看一个更有趣的两个循环的例子,这次不是顺序执行,而是嵌套执行。具体来说,我们看一个问题:判断两个给定的输入数组(长度均为 n)是否包含一个相同的数字。

算法分析
我们查看解决这个问题的最直接算法:比较所有可能性。对于数组 A 的每个索引 i 和数组 B 的每个索引 j,我们检查 A[i] 是否等于 B[j]。如果相等,返回 true。如果穷尽所有可能性都没有找到相等的元素,则返回 false

那么,用大O表示法,作为数组长度 n 的函数,这段代码的运行时间是多少?

答案与解释
这次答案变了。这段代码的运行时间不是 O(n),而是 O(n²)。我们也可以称之为二次时间算法,因为运行时间相对于输入长度 n 是二次的。这类算法的特点是,如果你将输入长度加倍,算法的运行时间将增加四倍,而不是像前两段代码那样只增加两倍。

为什么是 O(n²)?同样,有一些常数级的设置成本会被大O表示法忽略。对于数组 A 的每个固定索引 i 和数组 B 的每个固定索引 j,我们只执行常数次操作。具体常数无关紧要,因为它会被大O表示法忽略。不同之处在于,这个双重 for 循环总共有 次迭代。在第一个例子中,单个 for 循环只有 n 次迭代。在第二个例子中,因为一个 for 循环在另一个开始前就结束了,所以总共只有 2n 次迭代。而在这里,外层 for 循环的每次迭代(共 n 次),内层 for 循环都要执行 n 次迭代,这就得到了 n * n,即 次总迭代。这就是这段代码的运行时间。

示例四:嵌套循环(在单个数组中查找重复项)

让我们以最后一个例子结束。这又是一个嵌套 for 循环的例子,但这次我们是在单个数组 A 中查找重复项,而不是比较两个不同的数组 AB

算法分析
这是我们要分析的用于检测输入数组 A 是否有重复项的代码。相对于上一张幻灯片上比较两个数组的代码,这里只有两个小改动:

  1. 第一个改动毫不意外:将所有对数组 B 的引用改为 A,即比较 A[i]A[j]
  2. 第二个改动更微妙一些:我改变了内层 for 循环,让索引 ji+1 开始(i 是外层 for 循环的当前值),而不是从索引 1 开始。如果从 1 开始,代码仍然是正确的,但会浪费资源。因为那样每个不同的元素对会被比较两次,而实际上只需要比较一次就知道它们是否相等。

那么,用大O表示法,这段代码的运行时间是多少?

答案与解释
这个问题的答案和上一个相同:O(n²)。也就是说,这段代码也具有二次运行时间。

我希望有一点是清楚的:这段代码的运行时间与这个双重 for 循环的迭代次数成正比。和所有例子一样,每次迭代我们做常数级的工作,我们不关心这个常数,它会被大O表示法忽略。所以我们只需要弄清楚这个双重 for 循环有多少次迭代。

我的观点是,这个双重 for 循环大约有 n² / 2 次迭代。有几种方式可以理解:我们讨论过这段代码与上一段代码的区别在于,我们只计数一次,而不是两次,这为我们节省了迭代次数上的一个因子 2。当然,这个 1/2 因子无论如何都会被大O表示法忽略,所以大O运行时间不会改变。另一种论证方式是:对于 1n 之间每一对不同的索引 ij,都有一次迭代。一个简单的计数论证表明,这样的不同 ij 的选择有 C(n, 2) 种,即 n * (n-1) / 2。再次忽略低阶项和常数因子,我们仍然得到相对于输入数组 A 长度的二次依赖关系。

总结 📝

本节课我们一起学*了渐*分析的初步概念。我们了解到,渐*分析是一种用于讨论算法性能的通用语言,其核心是忽略常数因子和低阶项,从而专注于算法在大规模输入下的增长趋势。我们通过四个简单的例子(线性搜索、顺序循环、嵌套循环比较两个数组、嵌套循环查找单个数组中的重复项)直观地感受了如何判断算法的运行时间是 O(n) 还是 O(n²)。这些例子帮助我们建立了对大O符号的初步直觉。在接下来的课程中,我们将正式进入数学形式化定义,并分析更多有趣的算法。

009:-09-2 大O符号详解 📊

在本节课中,我们将要学*算法分析中的一个核心概念——大O符号。我们将给出其正式定义,并通过多种方式理解其含义,为后续学*算法复杂度分析打下基础。

概述

在接下来的系列视频中,我们将正式介绍渐*符号,特别是大O符号,并会通过多个例子进行讲解。大O符号关注定义在正整数上的函数,我们通常称之为 T(n)。在绝大多数情况下,T(n) 具有相同的语义:它表示一个算法在最坏情况下,其运行时间与输入规模 n 之间的函数关系。

本节视频的核心问题是:当我们说一个函数 T(n)O(f(n)) 时,究竟意味着什么?这里的 f(n) 是一个基础函数,例如 nlog n

大O符号的含义

我将通过多种方式来解释大O符号的真正含义。首先,让我们从一个英文定义开始。

英文定义

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

图形化解释

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

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

在这种情况下,我们就可以说 T(n) 确实是 O(f(n))。原因在于,对于所有足够大的 n(即图中向右足够远的位置),f(n) 的一个常数倍(这里是2倍)构成了 T(n) 的一个上界。

数学定义

最后,让我给出一个可用于正式证明的数学定义。我们如何用数学语言表述“最终被一个常数倍所上界约束”?

我们称存在两个常数 cn₀,使得对于所有 n ≥ n₀,都有:
T(n) ≤ c * f(n)

这两个常数的作用是量化英文定义中的“常数倍”和“足够大”。

  • c 显然量化了 f(n) 的“常数倍”。
  • n₀ 则量化了“足够大”,它是我们要求 c * f(n) 成为 T(n) 上界的起始阈值。

回到之前的图片,cn₀ 是什么?c 就是2,而 n₀ 就是交点处的横坐标。如果我们看 2 * f(n)T(n) 相交的点,然后垂直向下到横轴,这个值就是相关的 n₀

这就是正式定义。要证明某个函数是 O(f(n)),你需要给出这两个常数 cn₀,并且必须确保对于所有 n ≥ n₀,不等式 T(n) ≤ c * f(n) 都成立。

游戏化理解

理解大O证明的一种方式是将其想象成一场游戏。你想证明这个不等式成立,而你的对手想证明它对于足够大的 n 不成立。

  • 你必须先行动:你的策略是选择常数 cn₀
  • 然后你的对手被允许选择任何一个大于 n₀ 的数 n

当且仅当你能在这场游戏中拥有必胜策略时,即你能预先确定常数 cn₀,使得无论对手选择多大的 n,这个不等式都成立,那么该函数就是 O(f(n))。如果你没有必胜策略,那么它就不是 O(f(n))。无论你选择什么 cn₀,你的对手总能通过选择一个足够大的 n 值来推翻这个不等式。

关于“常数”的重要说明

最后,我想强调一点:这些常数必须是独立于 n 的。当你应用这个定义并选择常数 cn₀ 时,它们不能包含 nc 应该像100或一百万这样的固定数字,是与 n 无关的常数。

总结

本节课中我们一起学*了如何从多个角度理解大O符号:

  1. 英文定义:它描述了函数在输入规模足够大时,其增长速率的上界。
  2. 图形化表示:通过图像直观地展示了常数倍函数如何最终成为原函数的上界。
  3. 数学定义:给出了用于严格证明的公式 T(n) ≤ c * f(n) (对于所有 n ≥ n₀)
  4. 游戏化视角:将证明过程理解为一种策略游戏,有助于掌握其逻辑本质。

记住,大O符号的核心是描述算法的渐*增长趋势,它忽略了常数因子和低阶项,专注于输入规模非常大时的行为。下一节,我们将通过具体例子来应用这些概念。

010:大O记号基础示例 🧮

在本节课中,我们将通过两个基础示例来学*如何正式证明一个函数属于大O记号,以及如何证明一个函数不属于大O记号。这些示例将帮助我们验证大O记号的核心目的:抑制常数因子和低阶项,并让我们熟悉其形式化定义的应用。

上一节我们详细讨论了大O记号的形式化定义。本节中,我们来看看如何应用这个定义进行证明。

示例一:证明多项式属于大O记号 ✅

我们首先证明以下命题:如果一个函数 T(n) 是一个 k 次多项式,那么 T(n)O(n^k)

命题:设 T(n) 是一个 k 次多项式,形式如下:
T(n) = a_k * n^k + a_{k-1} * n^{k-1} + ... + a_1 * n + a_0
其中 k 是任意正整数,系数 a_i 可以是任意正数或负数。那么,T(n)O(n^k)

这个命题的数学含义是:大O记号确实会抑制多项式中的常数因子和低阶项。当 n 趋于无穷大时,多项式中最高次项 n^k 主导了其增长。

证明过程

回忆一下,要证明 T(n)O(n^k),关键在于找到一对常数 Cn_0,使得对于所有 n ≥ n_0,都有 T(n) ≤ C * n^k 成立。

为了使证明简单明了(尽管选择常数的过程看起来有些神秘),我们将直接给出这对常数,然后验证它们满足条件。

以下是常数的选择:

  • n_0 = 1
  • C = |a_k| + |a_{k-1}| + ... + |a_1| + |a_0|,即所有系数绝对值的和。

我们需要证明:对于所有 n ≥ 1,都有 T(n) ≤ C * n^k

以下是证明步骤:

  1. T(n) 的定义开始:
    T(n) = a_k * n^k + a_{k-1} * n^{k-1} + ... + a_1 * n + a_0

  2. 将每个系数 a_i 替换为其绝对值 |a_i|。由于 n ≥ 1,如果某个系数从负数变为正数,整个表达式的值只会增大(或不变)。因此:
    T(n) ≤ |a_k| * n^k + |a_{k-1}| * n^{k-1} + ... + |a_1| * n + |a_0|

  3. 现在,将每一项中的 n 的幂次统一替换为最高次幂 n^k。因为 n ≥ 1,所以 n^k ≥ n^{k-1} ≥ ... ≥ n ≥ 1。用更大的 n^k 替换较低的幂次,整个表达式的值只会增大。因此:
    T(n) ≤ |a_k| * n^k + |a_{k-1}| * n^k + ... + |a_1| * n^k + |a_0| * n^k

  4. 提取公因子 n^k
    T(n) ≤ (|a_k| + |a_{k-1}| + ... + |a_1| + |a_0|) * n^k

  5. 根据我们选择的 C,括号内的部分正是 C。因此:
    T(n) ≤ C * n^k

至此,我们完成了证明。对于所有 n ≥ 1T(n) 确实被 C * n^k 所限定。

关于常数选择的说明:在实际证明中,我们通常通过“逆向工程”来推导出可行的 Cn_0。即先假设它们存在,然后像上面一样进行推导,看看需要 Cn_0 满足什么条件才能使不等式成立,从而确定它们的值。

示例二:证明函数不属于大O记号 ❌

接下来,我们证明一个“非示例”:对于任意 k ≥ 1n^k 不是 O(n^{k-1})

这个结论符合我们的直觉:不同的多项式幂次在大O记号下不应“坍缩”为同一个类别。如果这个结论不成立,那说明我们的定义有问题。

证明过程:反证法

我们将使用反证法来证明。首先,假设我们想证明的结论不成立。

  1. 假设反面成立:假设 n^kO(n^{k-1})

  2. 根据大O定义:如果假设成立,那么根据定义,存在正常数 Cn_0,使得对于所有 n ≥ n_0,都有:
    n^k ≤ C * n^{k-1}

  3. 推导矛盾:由于 n ≥ 1k ≥ 1,我们可以将不等式两边同时除以 n^{k-1}(这是一个正数,不会改变不等号方向):
    n ≤ C
    这个不等式意味着:对于所有足够大的 n(即 n ≥ n_0),n 的值都被一个常数 C 所限定。

  4. 发现矛盾:上述结论显然错误。因为正整数序列 {n} 是无界增长的,不可能全部小于或等于某个固定的常数 C。例如,取 n = C + 1(或 ⌊C⌋ + 2 等),它就不满足 n ≤ C

  5. 得出结论:由于我们从一个假设出发,推导出了一个错误的结论,因此原假设不成立。所以,n^k 不是 O(n^{k-1})

这个证明确认了:在大O记号的意义下,n^kn^{k-1} 代表了不同的增长级别,它们不会混淆。


本节课中我们一起学*了如何应用大O记号的形式化定义。通过第一个示例,我们证明了多项式函数的增长率由其最高次项决定。通过第二个示例,我们使用反证法证明了不同幂次的函数属于不同的大O类别。这两个基础示例巩固了我们对大O记号核心思想——关注渐*增长的主导项——的理解。

011:大Ω与Θ符号 🧮

在本节课中,我们将继续学*渐*符号的正式定义。我们已经讨论了大O符号,它是渐*符号中最为重要且应用最广泛的概念。为了内容的完整性,本节将介绍大O符号的两个*亲:大Ω符号和大Θ符号。如果大O符号类似于“小于或等于”,那么大Ω和大Θ符号则分别类似于“大于或等于”和“等于”。接下来,让我们更精确地理解它们。

大Ω符号的定义 📈

大Ω符号的形式定义与大O符号的定义非常相似。我们说一个函数 T(n) 是另一个函数 f(n) 的大Ω,如果最终(即对于足够大的n),T(n)f(n) 的一个常数倍所下界约束。我们用与之前完全相同的方式来量化“常数倍”和“最终”这两个概念,即明确给出两个常数 cn₀,使得对于所有足够大的n(即所有 n ≥ n₀),都有 T(n) ≥ c * f(n)

与大O符号类似,我们可以用图像来直观理解。假设我们有一个函数 T(n),其图像可能像一条绿色曲线。而另一个函数 f(n) 的图像在 T(n) 之上。但当我们用常数 c(例如1/2)乘以 f(n) 后,得到的曲线最终会始终位于 T(n) 之下。在这个例子中,T(n) 就是 f(n) 的大Ω。其中,常数 c 就是1/2,而 n₀ 则是两条曲线相交的点,即在此点之后,c * f(n) 永远位于 T(n) 下方。

大Θ符号的定义 ⚖️

大Θ符号相当于“等于”。它意味着一个函数同时是 f(n) 的大O和大Ω。另一种等价的思考方式是:最终,T(n) 被夹在两个不同的 f(n) 的常数倍之间。我们可以这样表述:存在常数 c₁c₂n₀,使得对于所有 n ≥ n₀,都有 c₁ * f(n) ≤ T(n) ≤ c₂ * f(n)。你可以自行验证这两种定义是等价的。

算法设计中的常见用法 💻

算法设计者有时会有些“随意”,他们经常使用大O符号来代替大Θ符号。这是一种常见的惯例,在本课程中我也会经常遵循。让我举个例子:假设我们有一个子程序,它对一个长度为n的数组进行线性扫描,检查每个元素并对每个元素执行常数量的工作。例如,归并子程序就大致属于这种类型。尽管这种算法/子程序的运行时间显然是 Θ(n)(因为它对n个元素中的每一个都做常数工作),但我们通常只会说它的运行时间是 O(n),而不会特意强调它是 Θ(n)

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

理解测验 ✅

接下来的测验旨在检查你对大O、大Ω和大Θ这三个概念的理解。

假设 T(n) = 3n² + 2n + 1。以下哪些陈述是正确的?

  1. T(n) = O(n²)
  2. T(n) = Ω(n)
  3. T(n) = Θ(n²)
  4. T(n) = O(n³)

最后三个陈述都是正确的。从高层次直觉来看,原因应该相当清楚:T(n) 显然是一个二次函数。我们知道,随着n增大,线性项的影响不大。既然它是二次增长,那么第三个陈述(Θ(n²))就是正确的。同时,它也是 Ω(n)。虽然 Ω(n) 作为 T(n) 渐*增长率的下界并不精确,但它是合法的。确实,作为一个二次增长函数,它至少和线性函数增长得一样快,所以它是 Ω(n)。同理,O(n³) 虽然不是一个很好的上界,但也是一个合法的上界。T(n) 的增长率最多是三次的(实际上最多是二次的),但它确实最多是三次的。

如果你想正式证明这三个陈述,只需给出合适的常数即可。例如:

  • 要证明它是 Ω(n),可以取 n₀ = 1c = 12
  • 要证明它是 O(n³),可以取 n₀ = 1c = 4
  • 要证明它是 Θ(n²),可以类似地组合两个常数,例如取 n₀ = 1c₁ = 12c₂ = 4

你可以自行验证,使用这些常数选择,大Ω、大Θ和大O的形式定义都能得到满足。

小o符号简介 🔍

最后介绍一个渐*符号,我们不会经常使用它,但偶尔会见到,所以我想简要提一下。这被称为小o符号,与大O符号相对。如果说大O符号非正式地类似于“小于或等于”关系,那么小o符号就是“严格小于”关系。直观上,它意味着一个函数的增长速度严格慢于另一个函数。

形式上,我们说函数 T(n)f(n) 的小o,当且仅当:对于所有常数 c > 0,都存在一个常数 n₀,使得对于所有 n ≥ n₀,都有 T(n) ≤ c * f(n)

这个定义与大O符号的区别在于:要证明一个函数是另一个函数的大O,我们只需要找到一个常数 c,使得 c * f(n) 最终成为 T(n) 的上界。相比之下,要证明某个函数是另一个函数的小o,我们需要证明一个强得多的命题:对于每一个常数 c(无论多小),都存在一个足够大的 n₀,使得在此之后 T(n) 都被 c * f(n) 所上界约束。

对于那些希望更深入了解小o符号的同学,我留一个练*:证明对于所有多项式幂次 k,n^(k-1) 确实是 n^k 的小o。即:n^(k-1) = o(n^k)

还有一个表示一个函数增长严格快于另一个函数的小ω符号,但这个符号不常见,我就不再赘述了。

历史背景与总结 📜

让我用一段引用来结束本视频。这段话出自我的同事高德纳在1976年发表的一篇文章,他被广泛认为是算法形式分析的奠基人。通常很难明确指出某种符号是在何时何地成为某个领域的通用语言的,但对于渐*符号来说,这一点非常清楚。这种符号并非由算法设计者或计算机科学家发明,它在19世纪起就在数论中使用了。但正是高德纳在1976年提出,这应该成为讨论增长率(特别是算法运行时间)的标准语言。

他在文章中说道:“基于这里讨论的问题,我建议SIGACT(ACM特别兴趣小组,专注于理论计算机科学,特别是算法分析)的成员以及科学和数学期刊的编辑们采用上述定义的大O、大Ω和大Θ符号,除非能在合理的时间内找到更好的替代方案。”

显然,更好的替代方案并未出现。自那时起,这便成为了讨论算法运行时间增长率的标准方式,也是我们将在本课程中使用的方式。

本节课总结 🎯

在本节课中,我们一起学*了渐*符号家族的另外两个重要成员:

  • 大Ω符号 (Ω):表示函数的渐*下界,类似于“大于或等于”。
  • 大Θ符号 (Θ):表示函数的紧确界,当函数同时具有相同增长率的上界和下界时使用,类似于“等于”。

我们还了解了算法设计中常用大O符号来泛指上界的惯例,并简要介绍了表示“严格小于”关系的小o符号。最后,我们回顾了渐*符号成为计算机科学标准语言的历史渊源。掌握这些符号对于精确分析和比较算法的效率至关重要。

012:渐进符号补充示例复*(可选)📚

在本节中,我们将通过三个额外的示例来练*渐进符号(Big O、Theta)的使用。这些示例将帮助你更深入地理解如何形式化地证明一个函数是另一个函数的渐进上界,以及如何证明两个函数渐进相等。


示例一:证明函数是 Big O 🧮

上一节我们介绍了渐进符号的基本概念,本节中我们来看看如何形式化地证明一个函数是另一个函数的 Big O。

目标:证明函数 2^(n+10)2^n 的 Big O。

根据 Big O 的定义,我们需要找到两个常数 Cn₀,使得对于所有足够大的 n(即 n ≥ n₀),以下不等式成立:
2^(n+10) ≤ C * 2^n

以下是证明步骤:

  1. 从等式 2^(n+10) 开始。
  2. 利用指数法则将其重写为 2^10 * 2^n
  3. 计算 2^10 = 1024,因此得到 1024 * 2^n
  4. 此时,我们可以选择常数 C = 1024n₀ = 1
  5. 对于所有 n ≥ 1,不等式 2^(n+10) = 1024 * 2^n ≤ 1024 * 2^n 显然成立。

因此,我们证明了 2^(n+10)O(2^n)


示例二:证明函数不是 Big O ❌

接下来,我们看一个反例,学*如何证明一个函数不是另一个函数的 Big O。

目标:证明函数 2^(10n) 不是 2^n 的 Big O。

我们采用反证法。假设 2^(10n)O(2^n),那么根据定义,存在常数 Cn₀,使得对于所有 n ≥ n₀,有:
2^(10n) ≤ C * 2^n

以下是推导矛盾的过程:

  1. 将不等式两边同时除以 2^n(因为 n 为正数,2^n > 0)。
  2. 得到 2^(10n) / 2^n ≤ C
  3. 根据指数运算法则,2^(10n) / 2^n = 2^(10n - n) = 2^(9n)
  4. 因此,不等式变为 2^(9n) ≤ C,对于所有 n ≥ n₀ 成立。
  5. 然而,2^(9n) 随着 n 的增长趋向于无穷大,而 C 是一个固定常数。这个不等式不可能对所有足够大的 n 都成立。

由此得出矛盾,故假设不成立。所以,2^(10n) 不是 O(2^n)


示例三:使用 Theta 符号证明渐进相等 ⚖️

最后,我们来看一个更复杂的例子,它涉及 Theta 符号,用于证明两个函数渐进相等。

目标:对于任意两个定义在正整数上的函数 f(n)g(n),证明 max(f(n), g(n))Θ(f(n) + g(n))

这里,max(f, g) 表示逐点取最大值,即对于每个 n,取 f(n)g(n) 中较大的那个值。

根据 Theta 符号的定义,我们需要找到常数 C₁, C₂n₀,使得对于所有 n ≥ n₀,有:
C₁ * (f(n) + g(n)) ≤ max(f(n), g(n)) ≤ C₂ * (f(n) + g(n))

以下是推导过程,我们假设 f(n)g(n) 始终输出非负值(这在算法分析中是合理的):

  1. 上界证明
    对于任意 nmax(f(n), g(n)) 是两个非负数中较大的那个。显然,它不会超过这两个数的和,即:
    max(f(n), g(n)) ≤ f(n) + g(n)
    这给出了上界,其中 C₂ = 1

  2. 下界证明
    同样,对于任意 nmax(f(n), g(n)) 至少是 f(n)g(n) 的平均值的一半。因为:
    2 * max(f(n), g(n)) ≥ f(n) + g(n) (两个“较大值”之和至少等于总和)
    将两边同时除以 2,得到:
    max(f(n), g(n)) ≥ (1/2) * (f(n) + g(n))
    这给出了下界,其中 C₁ = 1/2

  3. 综合
    结合以上两个不等式,我们得到对于所有 n ≥ 1(这里 n₀ 可以取 1):
    (1/2) * (f(n) + g(n)) ≤ max(f(n), g(n)) ≤ 1 * (f(n) + g(n))
    这恰好满足了 Theta 符号的定义。

因此,我们证明了 max(f(n), g(n))Θ(f(n) + g(n))


总结 📝

本节课中我们一起学*了三个渐进符号的补充示例:

  1. 我们通过选择常数 C=1024n₀=1,形式化地证明了 2^(n+10)O(2^n)
  2. 我们使用反证法,通过推导出 2^(9n) ≤ C 这一矛盾,证明了 2^(10n) 不是 O(2^n)
  3. 我们利用不等式推导,证明了对于任意非负函数 fg,其逐点最大值 max(f, g) 渐进等于它们的和 f+g,即 max(f, g) = Θ(f + g)

这些练*巩固了我们对 Big O 和 Theta 符号定义的理解,并展示了如何运用这些定义进行严格的数学证明。

013:计算逆序数的O(n log n)算法 I

概述

在本节课中,我们将学*如何应用分治算法设计范式来解决一个新问题:计算数组的逆序数。我们将从回顾分治范式的一般步骤开始,然后定义逆序数问题,并探讨其应用场景。最后,我们将设计一个基于分治思想的高层算法框架,其目标是达到O(n log n)的时间复杂度。

分治范式回顾

上一节我们介绍了分治算法设计范式。本节中,我们来回顾其三个核心步骤,为后续解决具体问题打下基础。

分治范式包含以下三个概念性步骤:

  1. 分解:将原始问题划分为更小的子问题。有时这只是概念上的划分,有时则需要实际复制部分输入数据(例如创建新数组)以传递给递归调用。
  2. 征服:递归地解决子问题。例如,在归并排序中,递归地对数组的左半部分和右半部分进行排序。
  3. 合并:将子问题的解组合成原始问题的解。这通常是算法中最需要巧思的部分。例如,在归并排序中,递归调用后,我们需要将两个已排序的半边数组合并成一个完整的有序数组。

逆序数问题定义

现在,让我们转向计算逆序数这个具体问题,看看如何应用分治范式。

首先,我们正式定义问题:给定一个长度为n的数组A作为输入。为简化问题,我们假设数组包含数字1到n,但这些数字以某种顺序排列。问题的目标是计算这个数组的逆序数

那么,什么是逆序?一个逆序是指一对数组索引(i, j),其中i < j,但数组元素满足A[i] > A[j]。也就是说,前面的元素比后面的元素大

显然,如果数组是按升序排序的(即1, 2, 3, ..., n),那么逆序数为0。反之,任何其他排列都会产生非零的逆序数。

让我们看一个例子。假设有一个包含六个元素的数组,顺序为:1, 3, 5, 2, 4, 6。这个数组有多少逆序呢?

我们需要找出所有前面元素大于后面元素的配对:

  • (5, 2):5在2前面,且5 > 2。
  • (3, 2):3在2前面,且3 > 2。
  • (5, 4):5在4前面,且5 > 4。

因此,该数组的逆序对是(3,2), (5,2), (5,4),总共有3个逆序。

逆序数的应用

你可能会问,为什么要解决这个问题?原因有几个,其中一个主要应用是作为一种数值相似性度量,用于量化两个不同排序列表之间的接*程度。

例如,假设我和你的一位朋友各自对10部看过的电影进行排名(从最喜欢到最不喜欢)。我可以根据这些排名生成一个数组:数组的第一个元素是你朋友对你最喜欢的电影的排名,第二个元素是你朋友对你第二喜欢的电影的排名,依此类推。

  • 如果你们的排名完全一致,这个数组的逆序数将为0。
  • 一般来说,逆序数越多,表明两个列表的差异越大。

这种度量在协同过滤中很有用。例如,购物网站通过分析你的购买历史(你的“排名”),找到与你偏好相似的其他顾客,然后根据这些相似顾客的购买记录向你推荐新产品。计算逆序数可以帮助识别哪些顾客具有相似的偏好。

逆序数的范围

为了确保我们理解一致,让我们思考一个简短的问题:一个数组可能拥有的最大逆序数是多少?

对于一个包含n个元素的数组,最大逆序数出现在数组完全逆序(即降序排列)时。此时,每一对索引(i, j)(其中i < j)都构成一个逆序。这样的配对总数是:

公式: n * (n - 1) / 2

对于6个元素的数组,最大逆序数就是15。

暴力算法与分治思路

现在,我们关注如何尽可能快地计算数组的逆序数。

一种选择是暴力算法:使用双重循环遍历所有索引对(i, j)(其中i < j),检查每一对是否构成逆序,并累加计数。这个算法是正确的,但时间复杂度是O(n²),因为需要检查大约n²/2对元素。

作为优秀的算法设计者,我们总是要问:我们能做得更好吗? 答案是肯定的,我们将使用分治方法。

我们的分解方式将直接借鉴归并排序:递归地处理数组的左半部分和右半部分。为了理解仅通过递归我们能取得多少进展,让我们将数组的逆序分为三种类型:

假设有一个逆序对(i, j),其中i < j。设数组长度为n

  1. 左逆序:如果ij都位于数组的前半部分(即 i, j <= n/2)。
  2. 右逆序:如果ij都位于数组的后半部分(即 i, j > n/2)。
  3. 分裂逆序:如果i位于前半部分,而j位于后半部分(即 i <= n/2j > n/2)。

当我们递归调用算法处理数组的左半部分时,如果实现正确,我们将成功计算出所有纯粹位于左半部分的逆序,即左逆序。同样,对右半部分的递归调用将计算出所有右逆序

剩下的问题就是如何计算分裂逆序。我们不应感到意外,即使在递归调用完成其工作后,仍然有一些剩余工作要做。这就像归并排序一样:递归调用神奇地排序了左半部分和右半部分,但在它们返回后,我们仍然需要将两个已排序的数组合并成一个。在这里,递归之后,我们需要“清理”并计算分裂逆序的数量。

例如,回顾之前那个六元素数组[1, 3, 5, 2, 4, 6],你会发现所有的逆序((3,2), (5,2), (5,4))实际上都是分裂逆序。因此,递归调用将返回0,而该示例的所有工作都将由“计算分裂逆序”的子程序完成。

高层算法框架

让我们总结一下目前的进展,并给出一个高层算法描述(其中部分细节尚未指定):

  1. 基准情况:如果数组只有一个元素,则没有逆序,直接返回0。
  2. 分解与征服
    • 通过递归调用计算左逆序的数量。
    • 通过递归调用计算右逆序的数量。
    • 调用一个(尚未实现的)子程序计算分裂逆序的数量。
  3. 合并结果:由于每个逆序要么是左逆序、右逆序,要么是分裂逆序,且只能属于其中一类,因此我们可以简单地将这三部分的结果相加并返回。

代码框架如下:

def count_inversions(arr):
    n = len(arr)
    # 基准情况
    if n <= 1:
        return 0
    # 分解
    mid = n // 2
    left = arr[:mid]
    right = arr[mid:]
    # 征服:递归计算左逆序和右逆序
    left_inv = count_inversions(left)
    right_inv = count_inversions(right)
    # 合并:计算分裂逆序 (待实现)
    split_inv = count_split_inversions(arr, left, right) # 假设的函数
    # 返回总和
    return left_inv + right_inv + split_inv

目标与挑战

我们的高层攻击计划是如何计算逆序数。当然,我们需要具体说明如何计算分裂逆序的数量,并且我们希望这个子程序运行得很快。

类比归并排序,在递归调用之外,归并子程序只做了线性工作。在这里,我们也希望只使用线性时间来完成分裂逆序的计数。

如果我们能成功实现一个正确且线性的count_split_inversions子程序,那么整个递归算法的时间复杂度将是O(n log n)。原因与归并排序达到O(n log n)的原因完全相同:有两个对半规模问题的递归调用,而递归调用外部我们只进行线性工作。我们可以直接套用为归并排序设计的递归树论证,它在这里同样适用。

需要意识到的是,用线性时间计算分裂逆序的数量是一个相当有雄心的目标。分裂逆序的数量可能非常多(最坏情况下可达n²/4个,全是分裂逆序)。我们试图用线性时间来计算一个可能数量级为平方的事物。这真的能做到吗?

是的,可以做到。 我们将在下一个视频中看到如何实现。

总结

本节课中,我们一起学*了如何将分治范式应用于计算数组逆序数的问题。我们定义了逆序数,探讨了其应用场景,并分析了暴力解法的局限性。通过将逆序分类为左、右、分裂三种类型,我们设计了一个高层分治算法框架。该框架的关键在于,在递归调用解决了左、右逆序后,需要一个高效的线性时间子程序来计算剩余的分裂逆序。如果能够实现这一点,整个算法就能达到O(n log n)的优异时间复杂度。在下一节,我们将深入探讨如何实现这个关键的线性时间计数步骤。

014:计算逆序对的O(n log n)算法 II 🧮

在本节课中,我们将学*如何通过一个巧妙的分治算法,在O(n log n)时间内计算一个数组中的逆序对总数。我们将看到,通过将计算过程与归并排序相结合,可以高效地解决这个问题。


分治策略的挑战

上一节我们介绍了计算逆序对的分治方法。该方法将数组分成两半,递归地计算左半部分和右半部分的逆序对。然而,我们识别出了一个关键挑战:如何快速计算“分裂逆序对”的数量。分裂逆序对指的是第一个索引在数组左半部分,第二个索引在数组右半部分的逆序对。这些逆序对会被两个递归调用所遗漏。

问题的核心在于,分裂逆序对的数量可能高达O(n²)。为了达到我们期望的O(n log n)运行时间,我们需要在线性时间内完成分裂逆序对的计算。


结合归并排序的巧妙想法

这里有一个非常巧妙的想法,可以让我们实现目标:将计算过程“搭载”在归并排序之上

这意味着,我们将要求递归调用完成更多的工作,以便让计算分裂逆序对的任务变得更简单。这类似于在数学归纳法中,有时需要加强归纳假设来推进证明。因此,我们将要求递归调用不仅计算传入数组的逆序对数量,还要在过程中对数组进行排序。

为什么不这样做呢?我们知道归并排序可以在O(n log n)时间内完成排序,而这正是我们追求的运行时间。所以,不妨将排序功能加入进来,也许它能在合并步骤中帮助我们。事实上,正如我们将要看到的,它确实能。

为什么我们要对递归调用提出更高的要求呢?在接下来的几页幻灯片中,我们会看到,归并子程序似乎就是为计算分裂逆序对数量而设计的。当你合并两个已排序的子数组时,你会自然地发现所有的分裂逆序对。


算法升级:排序与计数

让我更清楚地说明,我们之前的高层算法将如何升级,使得递归调用同时进行排序。

以下是之前提出的高层算法,我们递归地计算左半部分和右半部分的逆序对,然后有一个尚未实现的子程序CountSplitInv负责计算分裂逆序对的数量。

现在,我们将对这个算法进行如下增强:

  • 我们将算法名称从Count改为SortAndCount
  • 递归调用同样调用SortAndCount。因此,现在我们知道每个递归调用不仅会计算子数组中的逆序对数量,还会返回一个已排序的版本。
  • 从第一个递归调用中,我们将得到一个已排序的数组B(即传入子数组的排序版本)。
  • 从第二个递归调用中,我们将得到一个已排序的数组C
  • 现在,CountSplitInv子程序除了计算分裂逆序对之外,还负责合并两个已排序的子数组BC

因此,CountSplitInv将负责输出一个数组D,它是原始输入数组A的排序版本。为了反映其更宏大的目标,我应该将这个子程序重命名为MergeAndCountSplitInv

我们不应该因为要求合并子程序去合并两个已排序的子数组BC而感到畏惧,因为我们已经知道如何在线性时间内完成合并。问题在于,在完成这项工作的同时,我们能否在线性时间内额外计算出分裂逆序对的数量?我们将看到这是可以做到的,尽管这并非显而易见。

此时,你可能会问:我们为什么要这样做?为什么我们要给自己增加更多的工作?再次强调,我们希望这样做能让计算分裂逆序对变得更容易。


归并如何揭示分裂逆序对

为了理解为什么要求递归调用进行排序能让CountSplitInv变得更容易,让我们回忆一下归并排序中原始归并子程序的定义。

以下是我们在几个视频前讨论过的相同伪代码,我重命名了数组的字母以符合当前的符号表示。我们被给予两个已排序的子数组(来自递归调用),称它们为BC,长度均为n/2。我们的责任是生成BC的排序组合,即一个长度为n的输出数组D

其思想很简单:你取两个已排序的子数组BC,以及你负责填充的输出数组D。在外层for循环中,你将从左到右遍历输出数组D。你将为排序子数组BC分别维护指针ij。唯一的观察是:无论尚未复制到D的最小元素是什么,它必须是B中尚未见过的最左元素,或者是C中尚未见过的最左元素。由于BC已排序,剩余的最小元素必须是BC中下一个可用的元素。因此,你只需以显而易见的方式进行:比较两个候选的下一个要复制的元素,查看B[i]C[j],哪个更小就复制哪个。if语句的第一部分处理B包含较小元素的情况,else语句处理C包含较小元素的情况。

这就是归并的工作原理:你并行地遍历BC,从左到右按排序顺序填充D

现在,为了理解这与数组的分裂逆序对有什么关系,请思考一个具有以下属性的输入数组A该数组没有任何分裂逆序对。也就是说,这个输入数组A中的每一个逆序对要么是左逆序对(两个索引都≤ n/2),要么是右逆序对(两个索引都严格大于n/2)。

那么问题来了:给定这样一个数组A,在合并步骤中,已排序的子数组BC会是什么样子?

正确答案是第二个选项。如果一个数组没有分裂逆序对,那么前半部分的所有元素都小于后半部分的所有元素。

为什么?考虑逆否命题:假设前半部分有一个元素大于后半部分的任何一个元素,仅这一对元素就构成一个分裂逆序对。因此,如果你没有分裂逆序对,那么左半部分的所有元素都小于右半部分的所有元素。

更重要的是,思考一下在具有此属性的数组上执行归并子程序的情况,即在一个左半部分所有元素都小于右半部分所有元素的输入数组A上。归并会做什么?记住,它总是在寻找剩余元素中较小的那个:B中剩余的第一个元素或C中剩余的第一个元素,并将其复制过去。如果B中的所有元素都小于C中的所有元素,那么在C被触及之前,B中的所有元素都会被复制到输出数组D中。因此,在没有分裂逆序对(即零个分裂逆序对)的输入数组上,归并的执行过程异常简单:首先遍历B并复制其所有元素,然后直接拼接C。两者之间没有交错。

所以,这表明从第二个子数组C复制元素可能与原始数组中的分裂逆序对数量有关,事实确实如此。我们将看到一个普遍模式:从第二个数组C复制元素到输出数组D的过程,揭示了原始输入数组A中的分裂逆序对


通过示例理解模式

让我们回到上一个视频中的例子,这是一个包含六个元素的数组:[1, 3, 5, 2, 4, 6]。我们进行递归调用。实际上,数组的左半部分[1, 3, 5]和右半部分[2, 4, 6]都已经排序,所以不需要进行排序工作。你会从两个递归调用中得到零个逆序对。记住,在这个例子中,所有的逆序对都是分裂逆序对。

现在,让我们跟踪在这两个已排序子数组上调用的归并子程序,并尝试找出其与原始六元素数组中分裂逆序对数量的联系。

我们初始化索引ij,指向每个子数组的第一个元素。左边的子数组是B,右边的是C,输出是D

首先,我们将B中的元素1复制到输出数组。1被复制过去,我们将索引i前进到3。这里没有发生什么有趣的事情,没有理由计算任何分裂逆序对。确实,元素1不涉及任何分裂逆序对,因为1小于所有其他元素,并且它也在第一个索引位置。

当我们从第二个数组C复制元素2时,事情变得有趣得多。注意,在这一点上,我们已经偏离了在没有分裂逆序对的数组上会看到的简单执行过程。现在,我们在耗尽B之前就从C复制了元素。因此,我们希望这能揭示一些分裂逆序对。

我们复制了2,并将第二个指针jC中前进。需要注意的关键点是:这揭示了两个分裂逆序对,即涉及元素2的逆序对:(3, 2)(5, 2)

为什么会这样?原因是我们复制2是因为它小于BC中所有我们尚未查看的元素。特别是,2小于B中剩余的元素35。而且,由于B是左数组,35的索引必须小于这个2的索引。因此,这些是逆序对:2在原始输入数组中更靠右,但它却小于B中这些剩余的元素。B中剩余两个元素,这就是涉及元素2的两个分裂逆序对。

现在让我们回到归并子程序,看看接下来会发生什么。接下来,我们从第一个数组复制元素,我们意识到从第一个数组复制元素时,至少在分裂逆序对方面,没有发生什么有趣的事情。

然后我们复制4,再次发现一个分裂逆序对:(5, 4)。原因同样是:鉴于4是在B中剩余元素(即5)之前被复制的,它必须小于5,但由于它来自右半部分数组,它的索引也更大,所以它必然是一个分裂逆序对。

现在,归并子程序的其余部分执行没有任何意外:5被复制(我们知道从左数组复制是“无聊”的),然后我们复制6(从右数组复制通常是“有趣”的,但如果左数组为空,则不涉及任何分裂逆序对)。

你会回忆起之前的视频,这些就是原始数组中的逆序对:(3,2), (5,2), (5,4)。我们通过一种自动化的方法发现了它们,只需留意何时从右数组C复制元素。

这确实是一个普遍原则。


普遍原则的陈述与证明

让我来陈述这个普遍主张:不仅在这个特定的例子或特定的执行过程中,无论输入数组是什么,无论有多少分裂逆序对,涉及数组右半部分某个元素的那些分裂逆序对,恰好就是在该元素被复制到输出数组时,左数组中剩余的元素数量。

这正是我们在例子中看到的模式。在右数组C中,我们有元素246。记住,根据定义,每个分裂逆序对必须涉及一个来自前半部分的元素和一个来自后半部分的元素。因此,对于计算分裂逆序对,我们可以根据它们涉及的右数组元素进行分组。

对于246

  • 2涉及的分裂逆序对是(3,2)(5,2)35正是当我们复制2B中剩余的元素。
  • 4涉及的分裂逆序对是(5,4)5正是当我们复制4B中剩余的元素。
  • 没有涉及6的分裂逆序对,确实,当我们复制6到输出数组D时,B中的元素已经为空。

那么普遍的论证是什么呢?这很简单。让我们聚焦于左数组B中的一个特定元素x(即属于原始数组前半部分的元素),并检查哪些y(即来自原始数组后半部分的元素)会与x形成分裂逆序对。

这有两种情况,取决于x是在y之前还是之后被复制到输出数组D中:

  1. 如果xy之前被复制到输出数组D,那么由于输出是按排序顺序的,这意味着x小于y。因此,不会形成分裂逆序对。
  2. 如果yx之前被复制到输出数组D,那么同样因为我们是按排序顺序从左到右填充D,这必然意味着y小于x。此时x仍然留在左数组B中,所以它的索引小于yy来自右数组)。因此,这确实是一个分裂逆序对。

将这两种情况结合起来,可以得出:与y形成分裂逆序对的B中的元素x,正是那些在y被复制之后才会被复制到输出数组的元素。也就是说,这些元素的数量恰好等于当y被复制时B中剩余的元素数量。这就证明了普遍主张。

这一页幻灯片是关键的洞见所在。


实现细节与运行时间分析

既然我们完全理解了为什么在合并两个已排序子数组时计算分裂逆序对很容易,那么将其转化为代码,并得到一个同时进行合并和计算分裂逆序对数量的线性时间子程序,就是一件简单的事情了。这个子程序在整体递归算法中将具有O(n log n)的运行时间,就像归并排序一样。

让我们花一点时间来填充这些细节。我不会写出完整的伪代码,而是写出你需要如何增强几页幻灯片前讨论的归并伪代码,以便在归并的同时计算分裂逆序对。这将直接遵循之前的普遍主张,该主张指出了分裂逆序对与归并过程中左数组剩余元素数量的关系。

其思想很自然:在按照先前伪代码合并两个已排序子数组的同时,只需维护一个运行总数,记录我们遇到的分裂逆序对数量。你有一个已排序子数组B,一个已排序子数组C


你将它们合并到一个输出数组D中。当k从1遍历到n时,你从0开始计数,每次从BC复制元素时,根据某种规则递增这个计数。

那么递增规则是什么?我们刚刚看到,涉及从B复制的操作不计入分裂逆序对。只有当我们从C复制元素时,我们才查看分裂逆序对。每个分裂逆序对恰好涉及BC中的一个元素。因此,我们可以通过C中的元素来计数。一个给定的C元素涉及多少个分裂逆序对?正是当它被复制时B中剩余的元素数量。这告诉了我们如何递增这个运行计数。

根据前一页幻灯片的普遍主张,这个运行总数的实现恰好计算了原始输入数组A所拥有的分裂逆序对数量。回想一下,左逆序对由第一个递归调用计数,右逆序对由第二个递归调用计数。每个逆序对要么是左逆序对、右逆序对,要么是分裂逆序对,恰好是这三种类型之一。因此,通过这三个不同的子程序(两个递归调用和这里的合并计数),我们成功地计算出了原始输入数组的所有逆序对。这就是算法的正确性。

运行时间是多少?回想一下在归并排序中,我们首先分析了归并的运行时间,然后讨论了整个归并排序算法的运行时间。让我们在这里也简要地做同样的事情。

这个子程序(同时进行合并和计算分裂逆序对)的运行时间是多少?这里有我们在合并过程中所做的工作,我们已经知道这是线性的。这里唯一额外的工作是递增这个运行计数,这对于D的每个元素来说是常数时间(每次我们复制一个元素时,我们对运行计数进行一次加法操作)。因此,对于D的每个元素是常数时间,总体上是线性时间。

我在这里的表述有些随意(以一种非常常规的方式),但确实有些随意:通过写O(n) + O(n) = O(n)。当你做这样的陈述时要小心。如果你把O(n)加到自己身上n次,那将不是O(n)。但如果你把O(n)加到自己身上常数次,它仍然是O(n)。作为练*,你可能想写出正式版本的含义。基本上,存在某个常数c1,使得合并工作最多需要c1 * n步;存在某个常数c2,使得其余工作最多需要c2 * n步。当我们将它们相加时,我们得到最多(c1 + c2) * n步,这仍然是O(n),因为c1 + c2是一个常数。

因此,归并子程序总体上是线性工作。现在,通过我们在归并排序中使用的完全相同论证,因为我们有两个对半大小数组的递归调用,并且在递归调用之外做线性工作,所以总体运行时间是O(n log n)。

我们确实只是“搭载”在归并排序之上,在常数因子范围内,顺路完成了计数工作,但运行时间保持为O(n log n)。


总结

在本节课中,我们一起学*了如何通过一个巧妙的分治算法高效计算数组的逆序对总数。核心思想是将计数过程与归并排序相结合。我们升级了算法,要求递归调用不仅计数,还返回排序后的子数组。关键在于,在合并两个已排序子数组的过程中,每当从右半部分子数组复制一个元素时,左半部分子数组中剩余的元素数量恰好就是涉及该元素的分裂逆序对数量。利用这一洞察,我们可以在线性时间内完成合并与计数,从而使整个算法达到O(n log n)的最优运行时间。

015:最*点对问题 I(高级篇)🎯

在本节课中,我们将学*一个非常巧妙的分治算法,用于解决最*点对问题。这个问题是:给定平面上的若干个点,找出其中距离最*的一对点。这是我们第一次接触计算几何领域的应用,该领域研究如何推理和操作几何对象,这类算法在机器人学、计算机视觉和计算机图形学等领域非常重要。

这部分内容相对高级,比我们之前见过的分治应用要复杂一些。算法本身有些巧妙,其正确性证明也相当不平凡。请注意,由于内容更深入,本视频的讲解节奏会比大多数其他视频稍快一些。

问题定义

我们被给定平面上的 n 个点,每个点由其 x 坐标和 y 坐标定义。在本问题中,我们关注两点之间的欧几里得距离。我们用 d(pi, pj) 表示点 pipj 之间的欧几里得距离。用坐标表示,其公式为:

d(pi, pj) = sqrt((xi - xj)^2 + (yi - yj)^2)

问题的目标很明确:在所有点对中,找出距离最小的那一对。

初步观察

首先,为了简便起见,我们做一个假设:所有点的 x 坐标互不相同,所有点的 y 坐标也互不相同。这个假设并非必需,算法可以扩展到处理坐标相同的情况,你可以课后思考如何实现。

接下来,让我们与之前学过的计算逆序对问题做个类比。第一个相似点是,如果我们满足于一个 O(n^2) 的算法,那么这个问题并不难。我们可以简单地通过暴力搜索解决:设置一个双重循环,遍历所有不同的点对,计算每对的距离,并记住最小的那个。这显然是一个正确的算法,但它需要遍历 O(n^2) 对点,因此运行时间是 Θ(n^2)

和往常一样,问题是:我们能否运用一些算法技巧来做得更好?能否找到一个比这种遍历所有点对的朴素算法更优的算法?你可能会本能地认为,既然问题涉及 O(n^2) 个不同的对象(点对),我们可能本质上就需要做 O(n^2) 的工作。但请回忆,在计算逆序对时,我们利用分治法得到了一个 O(n log n) 的算法,尽管一个数组中可能存在多达 O(n^2) 个逆序对。那么问题是:我们能否在这里为最*点对问题做类似的事情?

在计算逆序对时,获得 O(n log n) 时间算法的关键之一是利用排序子程序。我们借助归并排序在 O(n log n) 时间内计算逆序对数量。那么,对于最*点对问题,排序是否也能以某种方式帮助我们突破 O(n^2) 的障碍呢?

为了证明排序确实有助于我们以优于 O(n^2) 的时间计算最*点对,让我们先看一个问题的特例,或者说一个更简单的版本:当所有点都在一维空间(即一条直线上)时,而不是在二维平面中。

一维情况下的解法

在一维版本中,所有点都位于一条直线上,并且我们以任意顺序给出这些点(不一定是排序好的)。

解决一维最*点对问题的一个方法是:首先对点进行排序。显然,最*的点对必然是排序后相邻的点。因此,你只需遍历 n-1 对相邻点,找出距离最*的一对即可。

更正式地说,解决一维版本问题的步骤如下:

  1. 根据点唯一的坐标(因为是一维)对点进行排序。使用归并排序,我们可以在 O(n log n) 时间内完成。
  2. 然后线性扫描这些点(这需要 O(n) 时间),对于每一对相邻点,计算它们的距离,并记住其中最小的那一对,最后返回它。这必然就是最*点对。

下图用绿色圆圈标出了最*点对,这是我们通过排序和线性扫描发现的。

当然,这并不能直接解决我们最初的问题。我们想要的是在平面(二维)中寻找最*点对,而不是在直线(一维)上。但我想指出的是,即使在直线上,也存在 O(n^2) 个不同的点对,因此暴力搜索在一维情况下仍然是 O(n^2) 的算法。至少在一维情况下,我们可以利用排序来突破朴素暴力搜索的界限,在 O(n log n) 时间内解决问题。因此,本讲的目标将是设计一个同样优秀的算法来处理二维情况:我们希望在 O(n log n) 时间内解决平面上的最*点对问题。

我们将成功实现这个目标。我将向你展示一个用于二维最*点对的 O(n log n) 时间算法。这需要几个步骤,让我们从高层次的方法开始。

高层次方法

首先,我们尝试模仿在一维情况下奏效的方法。在一维情况下,我们首先根据点的坐标对它们进行排序,这非常有用。在二维情况下,点有两个坐标:x 坐标和 y 坐标,因此有两种排序方式。所以,我们第一步就是对点进行两种排序。这可以看作是一个预处理步骤:我们获取输入的点集,使用归并排序一次,根据 x 坐标对它们进行排序(得到点的一份副本),然后制作点的第二份副本,根据 y 坐标进行排序。我们将这些排序后的点集副本分别称为 Px(按 x 坐标排序的点数组)和 Py(按 y 坐标排序的点数组)。我们知道归并排序需要 O(n log n) 时间,所以这个预处理步骤总共需要 O(n log n) 时间。既然我们的目标是获得一个运行时间为 O(n log n) 的算法,那么先对点进行排序是可行的。我们现在甚至不知道将如何使用这个事实,但这并无害处,不会影响我们获得 O(n log n) 时间算法的目标。

这实际上说明了一个更广泛的要点,也是本课程的主题之一。我希望你从这门课程中获得的一点是:理解什么是“免费原语”——即那些你可以对数据执行的基本操作或操作,它们基本上是“无成本”的。这意味着,如果你的数据集能放入计算机的主内存,你基本上可以调用这个原语,它会运行得非常快,你甚至可以在不知道原因的情况下使用它。排序是典型的免费原语(尽管我们会在课程后面看到更多)。在这里,我们正是运用了这个原则。我们甚至还不完全理解为什么可能需要排序的点,只是受一维情况的启发,觉得它可能有用。所以,让我们先根据 xy 坐标对点进行排序。

通过类比一维情况,排序点可能有用,但我们不能将这个类比推得太远。特别是,我们不能仅仅通过对这些数组进行简单的线性扫描来识别最*点对。

为了说明这一点,考虑以下例子:我们看一个有六个点的点集。其中有两个点(用蓝色表示)的 x 坐标非常接*,但 y 坐标相距甚远。另外还有一对点(用绿色表示)的 y 坐标非常接*,但 x 坐标相距甚远。然后还有一对红点,它们在 x 坐标和 y 坐标上都不是特别远。在这个六点集合中,最*的点对是红点对。然而,这对红点在两个排序数组中都不会是连续的。在按 x 坐标排序的数组 Px 中,这个蓝点会夹在两个红点之间,它们不会是相邻的。同样,在按 y 坐标排序的数组 Py 中,这个绿点会夹在两个红点之间。因此,如果你只是线性扫描 Px 和/或 Py 并查看连续的点对,你甚至不会注意到这对红点。

因此,在完成预处理步骤(即调用两次归并排序)之后,我们将采用一个相当不平凡的分治算法来计算最*点对。实际上,在这个算法中,我们两次应用了分治法:第一次是在排序子程序内部(假设我们使用归并排序算法,分治法在那里被用来获得 O(n log n) 的运行时间),然后我们将以一种新的方式在排序后的数组上再次使用它。这就是我接下来要讲的内容。

分治法设计范式回顾

在将其应用于最*点对问题之前,让我们简要回顾一下分治算法设计范式。通常,第一步是找到一种方法将问题分解成更小的子问题。有时这需要相当多的巧思,但在最*点对问题中不会。我们将完全按照在归并排序和计算逆序对问题中所做的那样进行:我们将输入点集分成左半部分和右半部分。这里,我们将根据点的 x 坐标,递归处理左半部分的点,并递归处理右半部分的点。

“征服”步骤通常不需要任何巧思,它只是意味着你解决第一步中识别的子问题,即递归地解决它们。我们将在这里递归计算左半部分点的最*点对和右半部分点的最*点对。

分治算法中所有创造性的部分都体现在“合并”步骤中:给定子问题的解,你如何恢复原始问题(你真正关心的问题)的解?对于最*点对,问题将是:给定你已经计算出的左半部分点的最*点对和右半部分点的最*点对,你如何快速找出整个点集中的最*点对?这是一个棘手的问题,我们将花费大部分时间来解决它。

最*点对分治算法详述

现在让我们更精确地阐述最*点对的分治方法。我们实际上开始拼写我们的最*点对算法。输入是在预处理步骤之后得到的:我们调用了两次归并排序,得到了点的两个排序副本 Px(按 x 坐标排序)和 Py(按 y 坐标排序)。

第一步是划分步骤。由于我们有点按 x 坐标排序的副本 Px,很容易识别出最左边的半数点(即 n/2 个最小的 x 坐标)和右半部分的点(即 n/2 个最大的 x 坐标)。我们将分别称这两个子集为 QR

我省略了基本情况。基本情况就是你想象的那样:当点的数量很少时(比如两个或三个点),你可以通过暴力搜索在常数时间内解决问题,只需查看所有点对并返回最*的一对。所以,可以认为输入中至少有四个点。

为了递归调用 closestPair 函数处理左半部分和右半部分,我们需要 QRx 坐标和 y 坐标排序的版本。我们将通过对 PxPy 进行适当的线性扫描来形成这些排序子列表。我鼓励你在课后仔细思考,甚至编写代码来实现:给定已有的 PxPy,如何形成 QxQyRxRy?如果你仔细想想,因为 PxPy 已经排序好了,生成这些排序子列表只需要线性时间。这有点像归并排序中合并子程序的反向操作——这里我们是拆分而不是合并。同样,这可以在线性时间内完成,这是你以后应该仔细思考的问题。

这就是划分步骤。现在我们进行征服,即递归调用 closestPair 处理两个子问题。当我们对左半部分的点 Q 调用 closestPair 时,我们将得到 Q 中真正的最*点对,我们称之为 p1q1。也就是说,在所有两个点都位于 Q 中的点对里,p1q1 最小化了它们之间的距离。

类似地,我们将第二个递归调用的结果称为 p2q2。也就是说,p2q2 是在所有两个点都位于 R 中的点对里,具有最小欧几里得距离的那一对。

从概念上讲,有两种情况:幸运的情况和不幸运的情况。在原始点集 P 中,如果我们幸运,整个 P 中最*的点对实际上都位于 Q 中,或者都位于 R 中。在这种幸运的情况下,我们已经完成了任务。如果整个点集中最*的点对恰好都在 Q 中,那么第一个递归调用就会找到它们,我们直接得到 p1q1。类似地,如果整个 P 中最*的点对都在右侧 R 中,那么第二个递归调用(仅对 R 操作)就会把它们交给我们。

在不幸运的情况下,P 中最*的点对恰好是分割的,即一个点位于左半部分 Q,另一个点位于右半部分 R。请注意,如果整个 P 中最*的点对是分割的,一半在 Q,一半在 R,那么两个递归调用都不会找到它,因为这对点没有传递给任何一个递归调用,所以它不可能被返回给我们。因此,如果最*点对恰好是分割的,在这两个递归调用之后,我们还没有识别出最*点对。

这与我们在计算逆序对时发生的情况完全类似:对数组左半部分的递归调用计算了左逆序对,对右半部分的递归调用计算了右逆序对,但我们仍然需要计算分割逆序对。所以,在这个最*点对算法中,我们仍然需要一个特殊用途的子程序来计算分割最*点对,即一个点在 Q、一个点在 R 的情况。就像在计算逆序对时一样,我将写下这个子程序,暂时不实现它,我们将在本讲的剩余部分快速弄清楚如何实现它。

如果我们有一个正确的 closestSplitPair 实现,它以原始点集(按 xy 坐标排序)作为输入,并返回最小的分割点对(一个点在 Q,一个点在 R),那么我们就完成了。因为最*点对要么在左侧,要么在右侧,要么是分割的。步骤二到四计算了每个类别中的最*点对,所以这些是最*点对唯一可能的候选者,我们只需返回它们中最好的一个。

因此,如果我们有一个正确的 closestSplitPair 子程序实现,那就意味着我们有一个正确的 closestPair 实现。那么运行时间呢?closestPair 算法的运行时间将部分取决于 closestSplitPair 的运行时间。在下一个测验中,我希望你思考一下,对于 closestSplitPair 子程序,我们应该追求什么样的运行时间。

运行时间目标

考虑到我们预处理步骤(两次归并排序)已经是 O(n log n),并且我们计划使用分治递归,如果 closestSplitPair 子程序能在 O(n) 时间内运行,那么整个算法的递归部分(类似于归并排序)也将是 O(n log n),从而整体达到 O(n log n) 的目标。因此,正确的选择是第二个:closestSplitPair 应该在 O(n) 时间内运行。

推理过程与我们之前对归并排序和计算逆序对的算法分析类似。在这个算法中,我们有一个预处理步骤(两次归并排序,O(n log n)),然后是一个递归算法:它进行两次递归调用,每个调用处理规模为原问题一半的子问题,并且在递归调用之外,根据假设,closestSplitPair 子程序做 O(n) 的工作。这与证明归并排序为 O(n log n) 的递归树完全相同,因此预处理步骤之后的递归部分也是 O(n log n)。这给了我们整体的 O(n log n) 运行时间上界。记住,这正是我们追求的目标:我们在一维情况下已经通过排序得到了 O(n log n) 算法,本讲的目标就是为二维版本获得一个 O(n log n) 算法。这太棒了。

换句话说,目标应该是拥有一个正确的、线性时间的 closestSplitPair 子程序实现。如果我们能做到这一点,我们就大功告成了,得到了期望的 O(n log n) 算法。

现在,我将继续向你展示如何实现 closestSplitPair。但在那之前,我想指出一个微妙但关键的想法,这将使我们能够获得这个线性时间的正确实现。

关键洞察

关键洞察是:我们实际上并不需要一个完全通用的、总是能正确计算任意点集分割最*点对的 closestSplitPair 子程序实现。我不会向你展示一个总是能正确计算任意点集分割最*点对的线性时间子程序。原因是,那实际上是一个比我们所需更困难的问题。为了拥有一个正确的递归算法,我们并不需要一个总是为每个点集正确计算分割最*点对的子程序。

请记住,有幸运的情况和不幸运的情况。幸运的情况是:整个点集 P 中最*的点对恰好完全位于左半部分的点集 Q 中,或者完全位于右半部分的点集 R 中。在这种幸运的情况下,我们的一个递归调用将识别出这个最*点对,并将其交给我们。在这种情况下,我们根本不在乎分割点对是什么,我们无需查看分割点对就能得到正确答案。

只有在不幸运的情况下,即最*点对恰好是分割的时候,我们才需要这个线性时间子程序来找出它。也就是说,我们只需要这个子程序在最*点对是分割对时正确工作。

这在某种意义上是一个相当平凡的观察,但如何利用这个观察——即我们只需要解决一个严格更简单的问题——则需要很多巧思。正是这个事实将使我接下来要展示的线性时间实现成为可能。

修改后的高层递归算法

现在,让我们稍微重写一下高层递归算法,以利用这个观察:closestSplitPair 子程序只需要在不幸运的情况下(即当分割最*点对确实比任一递归调用的结果更*时)正确运行即可。

在调用 closestSplitPair 之前,我们要看看递归调用的效果如何。我们将定义一个参数 δ,它代表我们通过递归调用找到的最*点对的距离,即左半部分最*点对距离 d(p1, q1) 和右半部分最*点对距离 d(p2, q2) 中的最小值。

然后,我们将把这个 δ 作为参数传递给 closestSplitPair 子程序。我们稍后会看到为什么这会有用,目前我们只是将其作为一个参数传递。然后,和之前一样,我们只需比较三个候选最*点对(左、右、分割),并返回其中最好的一个。

closestSplitPair 子程序实现

在描述它之前,让我明确一下我们对这个子程序的要求:为了得到一个正确且运行时间为 O(n log n)closestPair 算法,我们需要什么?

正如你在测验中看到的,我们希望 closestSplitPair 的运行时间总是 O(n)。至于正确性,我们不需要它总是计算分割最*点对,但我们需要它在存在距离严格小于 δ 的分割点对时,能够计算出这个分割最*点对。也就是说,当存在一个分割点对,其距离比任一递归调用的结果都更好时,子程序必须找到它。

现在我们已经明确了需求,让我们继续看看 closestSplitPair 子程序的伪代码。我提前说明:很容易看出这个子程序以线性时间 O(n) 运行;但 closestSplitPair 的正确性要求将非常不明显。事实上,在我展示完伪代码后,你可能不会相信我。你会看着伪代码想:“你在说什么?” 但在关于最*点对的第二个视频中,我们实际上将证明这是一个正确的子程序。

它是如何工作的呢?让我们来看一个点集。

首先,我们进行一个过滤步骤:我们将修剪掉许多点,专注于一个点的子集。我们要查看的子集是那些位于一个大致以点集中点为中心的垂直条带中的点。

具体来说,我们定义 为左半部分最大的 x 坐标。也就是说,在按 x 坐标排序的点中,我们看第 n/2 小的 x 坐标。在这个有六个点的例子中,这意味着我们想象在从左数第三个点处画一条线,那就是

由于我们输入中有一个按 x 坐标排序的点副本 Px,我们可以通过访问 Px 数组的相关条目在常数时间内找出

现在,我们将使用传递给我们的参数 δ。记住 δ 是什么:在递归算法中调用 closestSplitPair 子程序之前,我们进行了两次递归调用,找到了左侧和右侧的最*点对,δ 是这两个距离中较小的那个。δ 这个参数控制着我们是否真正关心分割最*点对:我们只关心是否存在距离小于 δ 的分割点对。

那么,我们如何使用 δ 呢?它将决定我们条带的宽度。条带的宽度将为 ,并以 为中心。我们要做的第一件事是:永远忽略那些不落在这个垂直条带中的点。算法的其余部分将只操作位于这个条带中的点集 P 的子集,并且我们将按 y 坐标排序来跟踪它们。

形式化地说,点位于这个条带中的条件是:其 x 坐标在区间 [x̄ - δ, x̄ + δ] 内。

我们如何构造这个按 y 坐标排序的集合 Sy 呢?幸运的是,我们输入中已经有一个按 y 坐标排序的点集 Py。因此,要从 Py 中提取 Sy,我们只需要对 Py 进行一次简单的线性扫描,检查每个点的 x 坐标是否在条带内。这可以在线性时间内完成。

我还没有向你展示为什么拥有这个排序集合 Sy 是有用的。但如果你暂且相信,拥有这个垂直条带中按 y 坐标排序的点是有用的,那么你现在就能明白为什么在算法一开始、甚至还没有进行任何递归之前就进行归并排序是有用的了。记住我们对 closestSplitPair 子程序的运行时间目标:我们希望它在线性时间内运行。这意味着我们不能在 closestSplitPair 子程序内部进行排序,那会花费太长时间。幸运的是,由于我们在 closestPair 算法一开始就一劳永逸地进行了排序,从这些已排序的点列表中提取排序子列表可以在线性时间内完成,这符合我们这里的目标。

现在,到了子程序的其余部分,你可能永远不会相信它能做任何有用的事情。我声称,本质上通过对 Sy 进行一次线性扫描,我们就能够在有趣的不幸运情况下(即存在距离小于 δ 的分割点对时)识别出分割最*点对。

以下是我所说的对 Sy 进行线性扫描的含义:

在进行扫描时,我们将跟踪迄今为止看到的特定类型的最*点对。让我引入一些变量来跟踪我们迄今为止看到的最佳候选。有一个变量 best,我们将其初始化为 δ(记住,除非分割点对的距离严格小于 δ,否则我们不感兴趣)。然后我们将跟踪点本身,所以将 bestPair 初始化为 null

以下是线性扫描的过程:

我们按 y 坐标的顺序遍历 Sy 中的点。嗯,并不完全是 Sy 中的所有点,我们会在倒数第八个点处停止,你马上就会明白为什么。然后,对于数组 Sy 的每个位置 i,我们调查该数组中随后的七个点。

也就是说,对于 j 从 1 到 7,我们查看 Sy 的第 i 个和第 i+j 个条目。

如果 Sy 看起来像这里的数组,那么在这个双重循环的任何给定点,我们通常查看一个索引 i(数组的第 i 个条目中的一个点),然后查看数组中一个非常*的点(第 i+j 个条目),因为这里的 j 最多为 7。所以我们不断地查看数组中的点对,但我们并不是查看所有点对,我们只查看那些彼此非常接*的点对——在数组中彼此位置相差不超过七个。

对于每一对 (i, j),当我们查看这两个点时,我们计算它们的距离,看看它是否比我们过去查看过的所有这种形式的点对都要好。如果更好,我们就记住它。也就是说,我们只记住这种特定形式(ii+j)的点对中,迄今为止看到的最佳(即最*)点对。

更详细地说,如果当前点对 pq 的距离小于我们迄今为止看到的最佳距离 best,那么我们将 bestPair 重置为 pq,并将 best(迄今为止看到的最短距离)重置为 pq 之间的距离。就这样。

一旦这个双重循环结束,我们只需返回 bestPair

closestSplitPair 的一种可能执行情况是,它从未找到距离小于 δ 的点对。在这种情况下,它将返回 null。然后,在外层的 closestPair 调用中,显然你会将 null 点对解释为具有无限距离。所以,如果你调用 closestSplitPair 而它没有返回任何点,那么解释就是没有有趣的分割点对,你只需返回两个递归调用结果 (p1, q1)(p2, q2) 中较好的那个。

至于这个子程序的运行时间,这里发生了什么?我们在初始化变量时做了一些常数工作。然后注意,Sy 中的点数最多可能是 P 中的所有点,所以最多有 n 个点。因此,外层循环有 O(n) 次迭代。但关键是:在内层循环中,通常双重循环会导致二次运行时间,但在这个内层循环中,我们只查看常数个其他位置(最多七个)。对于这七个位置中的每一个,我们只做常数量的工作(计算距离、进行一些比较、重置变量)。因此,对于 O(n) 次外层迭代中的每一次,我们做常数量的工作。这给了我们算法这一部分的 O(n) 运行时间。

正如我所承诺的,分析这个 closestSplitPair 子程序的运行时间并不具有挑战性。我们只是以一种直接的方式查看了所有操作,并且由于关键的线性扫描中每个索引只做常数工作,整体运行时间是 O(n),正如我们所愿。这确实意味着我们的整体递归算法将具有 O(n log n) 的运行时间。

完全不明显、甚至可能令人难以置信的是,这个子程序满足我们想要的正确性要求。记住我们需要什么:我们需要每当处于不幸运的情况时(即当整个点集中最*的点对确实是分割的时候),这个子程序必须找到它。而它确实能做到。这一点在下面的正确性声明中得到了精确表述。

正确性声明

让我用任意一个距离小于 δ 的分割点对(不一定是最接*的那个)来表述这个声明。

声明:假设存在一个分割点对 p(在左侧)和 q(在右侧),使得该点对的距离 d(p, q) < δ。那么:

  1. pq 都是 Sy 的成员(即它们都位于宽度为 的垂直条带内)。
  2. pq 在排序数组 Sy 中几乎彼此相邻,它们彼此相距不超过 7 个位置。

第一部分(A部分)说,如果存在一个这种类型的分割点对 pq,那么 pq 都是 Sy 的成员。让我重新画一下示意图。记住 Sy 是什么:Sy 是那个垂直条带。我们通过中位数 x 坐标画一条线,然后向两边各扩展 δ 将其加粗,然后只关注位于这个垂直条带中的点。

请注意,我们的 closestSplitPair 子程序如果要返回一对点,它返回的点对 (p, q) 必然属于 Sy。因为它首先过滤到 Sy,然后对 Sy 进行线性搜索。所以,如果我们想相信我们的子程序能识别最佳分割点对,那么这样的分割点对必须出现在 Sy 中,它必须在过滤步骤中幸存下来。这正是声明 A 部分的内容。

这是声明的 B 部分,也是更引人注目的部分:pq 在这个排序数组 Sy 中几乎是彼此相邻的。它们不一定紧挨着,但非常接*,彼此相距不超过 7 个位置。这确实是算法中令人惊讶的部分,也是使整个算法可行的关键。

为了确保我们都清楚一切,让我们证明:如果我们能证明这个声明,那么我们就完成了,我们就有了一个正确且快速的最*点对算法实现。我当然欠你一个对这个声明的证明,这将是下一个视频的全部内容。但让我们先展示一下,如果声明成立,我们就大功告成了。

推论

如果这个声明成立,那么下面的推论1也成立:
推论1:如果我们处于之前讨论的不幸运情况(即整个点集 P 中最*的点对不是完全在左侧,也不是完全在右侧,而是一个在左、一个在右,也就是说它是一个分割点对),那么 closestSplitPair 子程序将正确识别出这个分割最*点对,从而也就是整个点集的最*点对。

为什么这是真的?closestSplitPair 做了什么?它有一个双重循环,因此显式地检查了许多点对,并记住了它所检查的所有点对中最*的一对。那么,closestSplitPair 检查一对点需要满足哪些条件?首先,点 pq 都必须通过过滤步骤,进入数组 SyclosestSplitPair 只搜索数组 Sy)。其次,它只搜索那些在 Sy 中几乎相邻的点对(彼此相距不超过七个位置)。但在满足这两个条件的点对中,closestSplitPair 肯定会计算出其中最*的一对,因为它显式地记住了最好的一个。

现在,声明的内容是什么?声明保证,每一个潜在有趣的分割点对(即每一个距离小于 δ 的分割点对)都满足被 closestSplitPair 子程序检查所必需的两个条件。首先,根据 A 部分,如果你有一个有趣的分割点对(距离小于 δ),那么它们都会通过过滤步骤,进入数组 Sy。B 部分说,它们在 Sy 中几乎是相邻的。因此,如果你有一个有趣的分割点对(距离小于 δ),那么它们实际上最多相距七个位置。因此,closestSplitPair 将检查所有这样的分割点对(所有距离小于 δ 的分割点对),并且通过构造,它将计算出它们之中最*的一对。

再次强调,在不幸运的情况下(最佳点对是分割点对),这个声明保证了 closestSplitPair 将计算出最*点对。

因此,在处理好正确性之后,我们可以将其与之前关于运行时间的观察结合起来。推论2 说明:如果我们能证明这个声明,我们就拥有了我们想要的一切:一个正确的、O(n log n) 的最*点对算法实现。通过进一步的努力和更多的巧思,我们复制了在一维情况下仅通过排序就能获得的保证。

再次强调,这些推论只有在声明确实成立的情况下才成立。而我还没有为这个声明提供任何理由,事实上,即使这个声明的陈述本身也令人有点震惊。所以,如果我是你,我会要求解释为什么这个声明是成立的。而这正是我将在下一个视频中要给你的内容。

总结

在本节课中,我们一起学*了解决二维最*点对问题的一个高级分治算法框架。我们首先定义了问题,并与一维情况进行了类比,认识到排序可能是一个有用的预处理步骤。接着,我们概述了分治法的整体思路:将点集按 x 坐标分成左右两半,递归求解,然后处理跨越中线的“分割点对”这一棘手情况。

我们引入了关键的 closestSplitPair 子程序,它利用预先的排序和参数 δ,通过过滤到垂直条带并仅检查条带内按 y 坐标排序后彼此接*的有限点对(最多检查随后的7个点),从而在线性时间内运行。我们陈述了关于该子程序正确性的关键声明,并说明了如果该声明成立,整个算法就是正确且高效的。

本节课的核心在于理解算法的高层结构、closestSplitPair 子程序如何利用预处理排序和递归信息 (δ) 来高效工作,以及正确性所依赖的那个非平凡声明。在下一讲中,我们将深入探究并证明那个令人惊讶的正确性声明,从而完成整个算法的构建。

016:最*点对算法正确性证明 🧮

在本节课中,我们将证明上一讲中讨论的分治最*点对算法的正确性。我们将逐步分析算法的核心步骤,并证明其能在线性时间内找到跨越左右两部分的最*点对。

算法回顾

给定平面上的 n 个点,算法步骤如下:

  1. 首先按 x 坐标和 y 坐标对点进行排序,耗时 O(n log n)
  2. 进入递归分治阶段:
    • 划分:将点集分为左半部分 Q 和右半部分 R。
    • 征服:递归计算左半部分 Q 和右半部分 R 各自的最*点对距离,记为 δ
    • 合并:存在一种“幸运”情况,即全局最*点对完全位于左侧或右侧,此时递归调用已给出答案。但还存在“不幸运”情况,即最*点对跨越左右两侧。为了获得 O(n log n) 的总运行时间,我们需要一个线性时间的子程序,用于计算跨越左右两侧的最佳点对(即“分裂对”)。

上一讲中,我们已论证了算法的整体运行时间为 O(n log n)。本节课的核心任务是证明其正确性。

核心子程序与正确性声明

分裂点子程序的工作流程如下:

  1. 过滤步骤:考虑一个位于点集中间位置的垂直条带(以 x̄ 为中心,左右各延伸 δ)。只保留落入此条带的点,构成按 y 坐标排序的列表 Sy
  2. 线性扫描:遍历 Sy 中的点。对于每个索引 i,仅检查其后最多 7 个位置(即索引 j 满足 i < j ≤ i+7)的点。计算这些点对的距离,并记录最佳的一对。

算法的正确性可归结为以下声明的证明:

正确性声明:考虑任意一个分裂对 (p, q),其中 p 来自左侧 Q,q 来自右侧 R。假设该点对是“有趣的”,即其欧几里得距离小于 δ(δ 是左右两侧各自内部最*点对距离的最小值)。那么:

  • (A) p 和 q 都会通过过滤步骤,出现在列表 Sy 中。
  • (B) p 和 q 在数组 Sy 中的位置索引差不超过 7,因此会被上述双循环检查到。

如果此声明成立,则算法正确。下面我们开始证明。

证明部分 A:点对位于垂直条带内

假设点 p 坐标为 (x₁, y₁),点 q 坐标为 (x₂, y₂)。根据假设,它们的欧几里得距离小于 δ。

一个简单但关键的观察是:如果两点欧几里得距离小于 δ,那么它们在每个坐标轴上的差值也必然小于 δ。即:

  • |x₁ - x₂| < δ
  • |y₁ - y₂| < δ

现在证明部分 (A)。我们需要证明 x₁ 和 x₂ 都位于垂直条带内,即满足:x̄ - δ ≤ x ≤ x̄ + δ。

  • 由于 p 来自左半部分 Q,而 x̄ 是左半部分最右侧点的 x 坐标,因此有 x₁ ≤ x̄
  • 由于 q 来自右半部分 R,而 x̄ 是左半部分最右侧点的 x 坐标,因此有 x₂ ≥ x̄
  • 结合 |x₁ - x₂| < δ,我们可以想象 x₁ 和 x₂ 被一条长度小于 δ 的“绳子”拴着。
    • x₁ 不能移动到 x̄ 右侧。即使 x₁ 移动到最右侧的 x̄,x₂ 由于被绳子牵引,也无法移动到超过 x̄ + δ 的位置。
    • 同理,x₂ 不能移动到 x̄ 左侧。即使 x₂ 移动到最左侧的 x̄,x₁ 也无法移动到超过 x̄ - δ 的位置。

因此,x₁ 和 x₂ 都位于区间 [x̄ - δ, x̄ + δ] 内,即它们都包含在垂直条带中,并进入过滤后的列表 Sy。部分 (A) 得证。

证明部分 B:点对在 Sy 中位置接*

部分 (B) 的断言更令人惊讶:它不仅要求 p 和 q 在 Sy 中,还要求它们在按 y 坐标排序的列表中几乎相邻(索引差 ≤ 7)。这意味着,在过滤后,我们只需对 Sy 中的点进行线性扫描(检查每个点与其后最多 7 个点),就足以找到最*的分裂对。

论证的核心是一个思想实验:我们绘制 8 个边长为 δ/2 的小方格。

  • 方格布局:以垂直条带中心线 x = x̄ 为界,左右各布置两列,上下共两行,总计 8 个方格。
  • 垂直范围:方格的底部对齐点 p 和 q 中较小的 y 坐标(记为 y_min),顶部在 y_min + δ 处。

以下是论证所需的两条引理:

引理 1:所有 Sy 中 y 坐标介于 p 和 q 之间的点,都必须落在这 8 个方格之内。

  • y 坐标论证:由于 |y₁ - y₂| < δ,因此 p 和 q 的 y 坐标差值小于 δ。所有 y 坐标介于它们之间的点,其 y 坐标自然也在 [y_min, y_min + δ] 范围内。
  • x 坐标论证:根据 Sy 的定义,其中的点 x 坐标必须在 [x̄ - δ, x̄ + δ] 内,而这正是这 8 个方格覆盖的横向范围。
    因此,引理 1 成立。

引理 2:每个方格内至多包含点集中的一个点。

  • 我们使用反证法。假设某个方格内有两个点 A 和 B。
  • 推论 1:A 和 B 必须位于点集的同一侧(同属左侧 Q 或同属右侧 R)。因为每个方格完全位于 x̄ 的左侧或右侧,因此其中的点只能来自同一半部分。
  • 推论 2:A 和 B 的距离很*。即使它们位于方格的对角位置,其最大距离也为 √((δ/2)² + (δ/2)²) = δ/√2 < δ。
  • 矛盾:δ 的定义是左右两侧各自内部最*点对距离的最小值。但推论 1 和 2 展示了一对位于同一侧且距离严格小于 δ 的点 (A, B),这与 δ 的定义矛盾。
    因此,假设不成立,引理 2 得证。

完成证明

结合引理 1 和引理 2:

  • 引理 1 指出,所有相关的竞争点(y 坐标在 p, q 之间)都位于这 8 个方格内。
  • 引理 2 指出,每个方格至多有一个点。
  • 因此,在这 8 个方格内,点的总数不超过 8 个。这包括了点 p 和 q 本身(它们占据两个方格)。

在最密集的情况下,除了 p 和 q 所在的方格外,其余 6 个方格可能各有一个点,且这些点的 y 坐标都介于 p 和 q 之间。这意味着,在按 y 排序的列表 Sy 中,从 p(或 q)的位置开始,向后扫描最多 7 个位置,就一定能遇到另一个点 q(或 p)。

因此,任何距离小于 δ 的分裂对,都必然会被我们“检查每个点与其后 7 个点”的双重循环所发现。部分 (B) 得证。

总结

本节课中,我们一起学*了如何严格证明分治最*点对算法的正确性。我们首先回顾了算法框架,然后将其正确性归结为一个核心声明的证明。通过几何直观和严谨的数学推导(利用 δ 的定义和反证法),我们证明了:

  1. 任何距离足够*的跨越点对,都会被过滤步骤保留在垂直条带内。
  2. 这些点对在按 y 排序的列表中是如此接*,以至于只需检查每个点与其后常数个(7个)点,就一定能找到它们。

这最终确立了该算法是一个正确且运行时间为 O(n log n) 的优美算法,用于求解平面最*点对问题。

017:主方法入门 🧮

在本节课中,我们将学*主方法。这是一种用于分析分治算法运行时间的通用数学工具。我们将从介绍主方法的动机开始,然后给出其正式描述,并通过六个示例进行讲解。最后,我们将用三节课的时间讨论主方法的证明,特别强调其三种情况的概念性解释。

需要说明的是,本节课的内容比前两节课更具数学性。但这并非为了数学而数学,我们的努力将换来一个强大的工具——主方法。它具有很强的预测能力,能为我们提供关于哪些分治算法可能运行得更快、哪些可能更慢的指导。事实上,一个新的算法思想通常需要数学分析来正确评估,本节课就是这一普遍现象的一个例证。

动机:整数乘法问题

作为引入主方法的动机,我们考虑计算两个n位数相乘的问题。在第一组课程中我们回顾过,我们都学过迭代式的小学乘法算法,它所需的基本操作(单数字的加法和乘法)数量随着位数n呈二次方增长。

另一方面,我们也讨论了一种使用分治范式的有趣递归方法。分治法需要识别更小的子问题。对于整数乘法,我们需要识别更小的数字进行相乘。因此,我们采用了一种显而易见的方式:将两个数字各自拆分为左半部分和右半部分的数字。

为方便起见,我们假设位数n是偶数,但这实际上并不重要。以这种方式分解X和Y后,我们可以展开乘积并观察结果。

让我们把这个表达式框起来,称之为星号表达式

我们最初从一个简单的递归算法开始,即直接计算星号表达式。也就是说,星号表达式包含了四个涉及n/2位数的乘积:AC、AD、BC和BD。因此,我们进行四次递归调用来计算它们,然后以自然的方式完成计算:根据需要补零,并将这三个项相加得到最终结果。

递归算法的运行时间分析:递推关系

我们分析此类递归算法运行时间的方法是使用所谓的递推关系。为了引入递推关系,首先让我定义一些符号:T(n)。这是我们真正关心的量,我们想要上界的量。即,这是该递归算法在最坏情况下相乘两个n位数所需的操作数。这正是我们想要上界的量。

递推关系则是一种用T(更小的数) 来表达T(n) 的方式,即用递归调用所做的工作来表达算法的运行时间。

每个递推关系都有两个组成部分:

  1. 基本情况:描述没有进一步递归时的运行时间。在这个整数乘法算法中,像大多数分治算法一样,基本情况很简单:当输入足够小(此处为两个一位数)时,运行时间只是常数。你只需将两个数字相乘并返回结果。我将其表示为:T(1) ≤ 某个常数。我不打算具体说明这个常数是多少,你可以认为是1或2,这对后续分析没有影响。
  2. 一般情况:这是重要的部分,描述当不在基本情况中、需要进行递归调用时的情况。你只需将运行时间写成两部分:递归调用所做的工作,以及递归调用之外、当前步骤所做的工作。

在这个递归整数乘法算法中,递推关系应该是显而易见的。正如我们所讨论的,恰好有四次递归调用,每次调用处理一对n/2位数。这给出了4 * T(n/2)。在递归调用之外,我们做了什么?我们将递归调用的结果补上一些零,然后将它们相加。可以验证,小学加法算法实际上运行时间与位数成线性关系。因此,递归调用之外所做的工作总量是线性的,即O(n)

综合起来,我们得到该算法的递推关系:

  • 基本情况:T(1) ≤ c (c为常数)
  • 一般情况:T(n) ≤ 4T(n/2) + O(n)

更聪明的递归算法:高斯方法

现在,让我们转向第二个更聪明的整数乘法递归算法,其思想可追溯至高斯。高斯的洞见在于认识到,在我们试图计算的星号表达式中,我们真正关心的基本量只有三个:表达式中三个项的系数。这让我们希望,也许我们可以只用三次递归调用来计算这三个量,而不是四次。事实上,我们可以做到。

我们的做法是:

  1. 像之前一样递归计算 A * C
  2. 像之前一样递归计算 B * D
  3. 计算 (A + B) * (C + D)

一个非常巧妙的事实是:如果我们给这三个乘积编号为1、2、3,那么我们关心的最终量——10^(n/2)项的系数,即 AD + BC——恰好等于第三个乘积减去第一个再减去第二个

这就是新的算法。那么新的递推关系是什么?基本情况显然和之前完全相同。所以问题在于,一般情况如何变化?

以下是几个要点:

  • 唯一的变化是递归调用的数量从4次减少到了3次
  • 当我们说每次递归调用处理n/2位数时,这里有一点不严谨:计算A+B和C+D时,结果可能具有n/2+1位。但作为*似,我们仍然称之为n/2位,这个额外的+1在最终分析中不会产生影响。
  • 我忽略了递归调用之外线性工作的具体常数因子。实际上,在高斯算法中,这个常数因子比朴素的四次递归调用算法要大一些,但它只是一个常数因子,在大O表示法中会被忽略。

因此,高斯算法的递推关系为:

  • 基本情况:T(1) ≤ c
  • 一般情况:T(n) ≤ 3T(n/2) + O(n)

递推关系的比较

让我们看看这个递推关系,并将其与另外两个递推关系进行比较:一个更大,一个更小。

首先,正如我们注意到的,它与朴素递归算法的递推关系不同之处在于少了一次递归调用。虽然我们不知道这两种递归算法的具体运行时间,但我们可以确信,这个(高斯算法)肯定只会更好。

另一个对比点是归并排序。想想归并排序算法的递推关系会是什么样子。它几乎与此相同,只是把3换成了2。归并排序进行两次递归调用,每次处理一半大小的数组,在递归调用之外进行线性工作(即合并子程序)。我们知道归并排序的运行时间是O(n log n)。所以高斯算法会比归并排序差,但我们不知道差多少。

因此,虽然我们对这个算法的运行时间可能比某个值大还是小有一些线索,但老实说,我们目前并不知道高斯递归整数乘法算法的运行时间到底是什么。这并不明显,我们目前对此没有直觉,也不知道这个递推关系的解是什么。但它将是接下来我们要解决的通用主方法的一个特例。


本节课总结:在本节课中,我们一起学*了引入主方法的动机。我们以整数乘法问题为例,回顾了分治算法的思想,并学*了如何用递推关系来描述递归算法的运行时间。我们比较了朴素递归算法和高斯改进算法的递推关系,并指出目前尚无法直接求解这些递推关系,这引出了对通用分析工具——主方法的需求。在接下来的课程中,我们将正式学*主方法。

018:-19-4 2 主定理的形式化陈述 10分钟

在本节课中,我们将学*主定理的精确数学陈述。上一节我们介绍了主定理的通用性,本节中我们来看看其形式化定义。

主定理本质上是一个用于求解递归式的“黑箱”。它接收特定格式的递归式作为输入,并输出该递归式的解,即递归算法运行时间的一个上界。

假设条件

主定理需要一些假设条件。首先,我将给出的这个版本仅适用于所有子问题规模完全相同的递归算法。例如,在归并排序中,有两个递归调用,每个调用处理数组的一半,因此满足此假设。在我们讨论的两个整数乘法算法中,所有子问题处理的整数位数都是原问题的一半,同样满足此假设。

如果递归算法处理的子问题规模不同(例如,一个处理数组的三分之一,另一个处理三分之二),那么我将给出的主定理版本将不适用。存在更通用的主定理版本可以处理不平衡的子问题规模,但这超出了本课程的范围。对于本课程将看到的大多数例子,当前版本已足够。一个值得注意的例外是线性时间选择算法的确定性版本(可选视频内容),该算法有两个处理不同规模子问题的递归调用,分析其递归式需要使用其他方法,而非主定理。

递归式格式

接下来,我将描述主定理所适用的递归式格式。存在更通用的主定理版本,但我将给出的这个版本相对简单,并且涵盖了您可能遇到的大多数情况。

递归式包含两个部分:相对次要但必要的基准情况,以及递归调用的一般情况。

以下是关于基准情况的假设:

  • 我们做一个显而易见的假设,即当输入规模减小到足够小时,递归停止,子问题在常数时间内解决。本课程中遇到的每个问题都满足此假设,因此不再进一步讨论。

以下是关于一般情况的假设:

  • 对于长度为 n 的输入,其运行时间上界由 a 个递归调用决定。
  • 每个子问题的规模完全相同,是原输入规模的 1/b
  • 因此,有 a 个递归调用,每个处理规模为 n/b 的输入。
  • 在递归调用之外,算法还会做一些额外工作,其时间复杂度为 O(n^d),其中 d 是一个参数。

因此,除了输入规模 n,我们还需要明确三个参数的含义:

  • a:子问题的数量,即递归调用的次数。a 可以小至 1,也可以是更大的整数。
  • b:在应用递归调用前,输入规模缩小的因子。b 是一个大于 1 的常数。例如,如果递归处理原问题的一半,则 b = 2
  • d:“合并步骤”(即递归调用之外所做工作)的运行时间中的指数。d 可以小至 0,表示递归调用之外的工作量是常数。

需要强调的是,abd 都是常数,它们是独立于输入规模 n 的数字,例如 1、2、3 或 4。

关于 O(n^d) 项的一个说明:一方面,我有些随意,没有追踪大 O 记号中隐藏的常数因子。在实际证明主定理时,我会明确这个常数,但它实际上不会影响分析结果。另一方面,指数 d 本身非常重要,因为它决定了这部分时间是常数、线性、二次方还是其他级别。

主定理陈述

给定上述格式的递归式,主定理将给出运行时间的上界。主定理包含三个著名的情形。

决定属于哪种情形的触发条件是比较两个数字:ab^d

以下是三种情形及其对应的运行时间上界:

  • 情形 1:如果 a = b^d,则运行时间为 O(n^d * log n)
  • 情形 2:如果 a < b^d,则运行时间为 O(n^d)
  • 情形 3:如果 a > b^d,则运行时间为 O(n^(log_b a))

关于对数的一些说明:

  • 在情形 1 中,对数项的底数未指定,因为不同底数的对数之间只相差一个常数因子,而该常数因子在大 O 记号中被忽略。
  • 在情形 3 中,对数出现在指数中,此时常数因子至关重要(例如,线性时间和二次时间的区别),因此必须明确指出对数的底数为 b

总结与后续

本节课中我们一起学*了主定理的精确数学陈述。我们明确了其适用的递归式格式(T(n) ≤ a * T(n/b) + O(n^d)),以及决定最终运行时间上界的三个关键参数(abd)和三种比较情形。

目前这三种情形可能看起来非常神秘。在接下来的视频中,我们将通过多个例子(包括解决高斯递归整数乘法算法的运行时间问题)来应用主定理,然后证明主定理本身。通过分析和证明,这三种情形将变得清晰而自然。

019:主定理应用示例 🧮

在本节课中,我们将通过六个具体的例子来应用主定理,以求解不同分治算法的运行时间。我们将回顾主定理的三种情况,并通过实例理解如何确定参数并得出结果。

主定理回顾 📚

上一节我们介绍了主定理的基本形式。本节中,我们来看看如何具体应用它。主定理适用于特定格式的递归式,该格式由三个常数 ABD 参数化。

  • A 指递归调用的次数,即需要解决的子问题数量。
  • B 是子问题规模相对于原问题规模的缩小因子。
  • D 是递归调用之外所做工作的运行时间指数。

递归式的形式为:

T(n) ≤ A * T(n/B) + O(n^D)

此外,还有一个未写出的基本情况:当问题规模降至某个常数以下时,可以直接在常数时间内解决问题。

给定一个符合此格式的递归式,其运行时间由以下三种情况之一给出,具体取决于 A(递归调用次数)与 B^D 之间的关系:

  1. 情况一:当 A = B^D 时,运行时间为 O(n^D * log n)
  2. 情况二:当 A < B^D 时,运行时间上界为 O(n^D)
  3. 情况三:当 A > B^D 时,运行时间为 O(n^(log_B A))

主定理初看可能难以理解,因此让我们通过一些具体示例来掌握它。

示例分析 🔍

以下是六个具体的算法示例,我们将逐一应用主定理进行分析。

示例一:归并排序

我们从已知运行时间的算法开始,即归并排序。主方法的优势在于,我们只需识别三个相关参数 ABD 的值,然后代入即可得到答案。

  • A 是递归调用次数。在归并排序中,我们进行两次递归调用,所以 A = 2
  • B 是子问题规模缩小因子。我们对数组的一半进行递归,所以 B = 2
  • D 是递归调用外工作的指数。归并排序在递归调用外进行归并操作,这是线性时间子程序,所以 D = 1

关键触发条件是 AB^D 的关系。这里 A = 2B^D = 2^1 = 2,两者相等。这属于情况一。因此,运行时间上界为 O(n^D * log n) = O(n^1 * log n) = O(n log n)。这与我们已知的结果一致,验证了主定理的正确性。

示例二:二分查找

接下来,我们看看二分查找算法。主定理同样适用于此,并能告诉我们其运行时间。

以下是二分查找的参数确定过程:

  • A:在二分查找中,每次只进行一次递归调用(根据比较结果递归左半部分或右半部分),所以 A = 1
  • B:每次递归处理一半的数组,所以 B = 2
  • D:递归调用外只进行一次比较,这是常数时间操作,所以 D = 0

我们再次处于情况一,因为 A = 1B^D = 2^0 = 1。因此,递归式的解为 O(n^D * log n) = O(n^0 * log n) = O(log n)。这确认了二分查找的运行时间为对数级。

示例三:整数乘法(朴素递归)

现在,我们转向一些更复杂的例子,从第一个整数乘法的递归算法开始。该算法对四个 n/2 位数的乘积进行递归,然后通过补零和线性时间加法重新组合。

以下是该算法的参数:

  • A:该算法进行四次递归调用,所以 A = 4
  • B:每次递归处理数字位数减半,所以 B = 2
  • D:递归调用外进行加法和补零操作,这是线性时间,所以 D = 1

接下来确定主定理的情况:A = 4B^D = 2^1 = 2。由于 A > B^D,我们处于情况三。运行时间由公式 O(n^(log_B A)) 给出。代入参数:O(n^(log_2 4)) = O(n^2)。这与我们在小学学到的迭代算法具有相同的平方级操作数,表明在没有高斯技巧的情况下,递归方法并未带来改进。

示例四:整数乘法(使用高斯技巧)

如果我们利用高斯技巧,将递归调用次数从四次减少到三次,运行时间会如何变化?

以下是使用高斯技巧后的参数:

  • A:递归调用次数减少到 3,所以 A = 3
  • B:子问题规模仍为一半,B = 2
  • D:递归调用外的工作仍是线性时间,D = 1

我们仍然处于情况三,因为 A = 3 仍大于 B^D = 2。运行时间为 O(n^(log_B A)) = O(n^(log_2 3))log_2 3 约等于 1.59,因此运行时间约为 O(n^1.59)。这优于平方时间 O(n^2),尽管不如 O(n log n) 快。总结来说,结合递归和高斯技巧,我们可以超越小学所学的迭代算法。

示例五:斯特拉森矩阵乘法

第五个例子针对看过斯特拉森矩阵乘法算法视频的观众。该算法的关键思想类似于整数乘法中的高斯技巧。

以下是斯特拉森算法的参数:

  • A:通过巧妙计算,递归调用次数从 8 次减少到 7 次,所以 A = 7
  • B:每个子问题规模是原问题的一半,B = 2
  • D:递归调用外的工作量与矩阵规模(维度)呈线性关系,但就矩阵条目数(n^2)而言是二次的,所以 D = 2

我们再次处于情况三,因为 A = 7 大于 B^D = 4。运行时间为 O(n^(log_2 7)),约等于 O(n^2.81)。这优于朴素迭代算法所需的立方时间 O(n^3),再次展示了巧妙分治法的优势。

示例六:虚构示例(展示情况二)

前五个例子涵盖了情况一和情况三。为了完整性,我们看一个触发情况二的虚构递归式。

考虑以下递归式:T(n) = 2T(n/2) + O(n^2)。它与归并排序类似,但合并步骤的工作量从线性变为二次。

其参数为:

  • A:两次递归调用,A = 2
  • B:子问题规模减半,B = 2
  • D:递归调用外工作为二次,D = 2

这里 B^D = 4,严格大于 A = 2。这触发了情况二。情况二的运行时间就是 O(n^D),即 O(n^2)。这个结果可能有点反直觉:与归并排序的 O(n log n) 相比,仅仅将合并步骤从线性改为二次,总运行时间就变成了平方级,而不是 O(n^2 log n)。主定理给出了更紧的上界,表明整个算法的运行时间主要由最外层递归调用(递归树的根)之外的工作决定。

总结 📝

本节课中,我们一起学*了如何应用主定理分析六种不同分治算法的运行时间。我们回顾了主定理的三种情况,并通过归并排序、二分查找、两种整数乘法算法、斯特拉森矩阵乘法以及一个虚构示例,实践了如何确定参数 ABD 并判断所属情况,从而快速得出运行时间上界。主定理是分析符合特定格式的递归式的强大工具,能帮助我们高效理解算法的效率。

020:主定理证明(第一部分)

概述

在本节中,我们将开始学*主定理的证明过程。主定理为特定形式的递归关系提供了通用解法。我们将通过递归树的方法,逐步分析递归算法的运行时间,并理解主定理三种情况背后的概念。


主定理回顾

上一节我们介绍了主定理的基本形式。主定理适用于以下形式的递归关系:

T(n) ≤ a * T(n/b) + O(n^d)

其中:

  • a 表示递归调用的次数。
  • b 表示每次递归调用时问题规模缩小的因子。
  • d 表示算法在递归调用之外所做工作的指数。

主定理的解决方案分为三种情况,具体取决于 ab^d 的比较关系。


证明概述

本次证明将是我们目前遇到的最长证明,跨越本视频及后续两个视频。证明的核心是概念性的,我们将通过递归树的方法进行分析,类似于归并排序的运行时间分析。

证明的关键在于理解主定理三种情况对应的三种不同类型的递归树。记住这些概念后,您将无需死记硬背任何运行时间公式,包括第三种情况中较为复杂的公式。

在开始证明之前,我们做出以下简化假设,以便分析更加清晰。


简化假设

首先,我们假设递归关系具有以下形式:

T(n) ≤ a * T(n/b) + C * n^d

这里,我们明确写出了所有常数。我们假设基本情况在输入规模为1时触发,且基本情况的运算次数最多为C。这个常数C与递归关系一般情况中的大O符号所隐藏的常数相同。

其次,我们假设 nb 的幂。一般情况的分析基本相同,只是稍微繁琐一些。


递归树方法

在最高层次上,主定理的证明方法应该让您感到非常自然。我们只需回顾归并排序的分析方法,并尝试将其推广到一般情况。

以下是递归树的基本结构:

  • 第0层(根节点)对应最外层的递归调用,输入规模为 n
  • 第1层对应第一批递归调用。
  • 第2层对应第一批递归调用所进行的递归调用,依此类推,直到树的叶子节点,对应基本情况,不再进行递归。

在归并排序分析中,我们识别出了一个关键模式:在给定的深度 j,我们需要知道该层有多少个不同的子问题,以及每个子问题的输入规模是多少。

以下是关于递归树层级的关键信息:

  • 在第 j 层,有 a^j 个子问题。
  • 每个子问题的输入规模为 n / b^j

这是因为每次递归调用会产生 a 个新的子问题,且每次递归调用将输入规模缩小 b 倍。因此,经过 j 层递归后,子问题数量增加为 a^j,输入规模缩小为 n / b^j

递归树的总层数为 log_b(n) + 1,从第0层到第 log_b(n) 层。


计算每层的工作量

现在,我们模仿归并排序的分析方法,计算递归树中每一层的工作量。具体步骤如下:

  1. 聚焦于特定层 j
  2. 计算该层所有子问题的工作量总和,不包括后续递归调用所做的工作。

首先,第 j 层的子问题数量为 a^j

其次,每个子问题的输入规模为 n / b^j。根据递归关系,每个子问题在递归调用之外所做的工作量不超过 C * (n / bj)d

因此,第 j 层的总工作量可以计算为:

工作量(j) = (a^j) * [C * (n / bj)d]

简化这个表达式,我们可以将其重写为:

工作量(j) = C * n^d * (a / bd)j

这里,我们首次看到了 ab^d 的比值,这提示我们这两个量的相对大小可能在分析中起到关键作用。


计算总工作量

为了计算算法的总工作量,我们需要将每一层的工作量相加。总工作量可以表示为:

总工作量 = Σ [工作量(j)],其中 j 从 0 到 log_b(n)

代入工作量表达式,我们得到:

总工作量 = C * n^d * Σ [(a / bd)j],其中 j 从 0 到 log_b(n)

这个表达式虽然看起来复杂,但它包含了主定理证明的关键信息。后续的证明将致力于解释和理解这个表达式,并展示它如何导致三种不同情况下的运行时间界限。


总结

在本节中,我们开始了主定理的证明。我们通过递归树的方法,分析了递归算法的运行时间,并推导出了总工作量的关键表达式。下一节中,我们将进一步解释这个表达式,并探讨它如何对应主定理的三种情况。记住,理解递归树的概念比死记硬背公式更为重要。

021:主定理三种情况的解读 🧮

在本节课中,我们将学*如何解读主定理的三种情况。我们将通过理解递归树中“子问题增殖”与“单子问题工作量缩减”之间的博弈,来直观地解释为什么算法运行时间会呈现三种不同的渐*行为。


在上一节视频中,我们通过递归树方法,对符合特定形式的递归式所描述的算法运行时间进行了上界分析。我们得到了一个复杂的表达式。本节中,我们不进行任何计算,而是专注于为这个表达式赋予语义,理解这种解读如何自然地引出主定理的三种情况,并为主定理中出现的运行时间提供直观解释。

回顾上一节的内容,我们对算法总工作量的界定方法是:聚焦于递归树的第 J 层。该层的工作量计算公式为:该层的子问题数量 a^J 乘以每个子问题的工作量 c * (n / b^J)^d。这给出了如下表达式:

c * n^d * (a / b^d)^J

上一节结尾的表达式 (star) 正是这个表达式在所有对数级别 J 上的总和。

尽管这个表达式看起来很复杂,但我们可能正走在正确的道路上。主定理有三种情况,具体属于哪种情况由 ab^d 的比较关系决定。而在这个表达式中,我们恰好看到了这个比值:a / b^d。让我们深入理解,为什么这个比值对于分治递归算法的性能至关重要。

主定理的本质是两种对立力量之间的“拔河比赛”:一方是“善的力量”,另一方是“恶的力量”。它们分别对应量 b^da

让我们从参数 a 开始。a 定义为算法进行的递归调用次数,即递归树中一个节点的子节点数量。因此,a 从根本上代表了随着递归深度增加,子问题增殖的速率。它是下一层子问题数量相对于上一层的增长因子。我们可以将 a 视为 子问题增殖速率

“恶的力量”就体现在这里:算法可能运行缓慢的原因在于,随着我们深入递归树,子问题会越来越多,这有些令人担忧。

“善的力量”则在于,随着递归层数 J 的增加,每个子问题所需的工作量在减少。工作量减少的程度 precisely 由 b^d 决定。我们将其称为 工作量缩减速率

你可能会问,为什么是 b^d,而不是 b?请记住 b 表示随着递归层数 J 增加,输入规模缩小的因子。但我们真正关心的不是子问题的输入规模本身,而是它如何决定解决该子问题所需的工作量。这就是参数 d 发挥作用的地方。

例如,考虑递归调用外的工作量是线性的(d=1)还是二次的(d=2)。如果 b=2d=1(即对一半输入进行递归,并做线性工作),那么不仅输入规模以因子 2 下降,每个子问题的工作量也以因子 2 下降。这正是归并排序中的情况。如果 d=2(每个子问题做二次工作),输入规模减半意味着递归调用只做当前层 25% 的工作量(因为 2^2 = 4)。因此,输入规模以因子 b 下降,但我们真正关心的每个子问题工作量的下降因子是 b^d。这就是为什么 b^d 是主导“善的力量”的基本量。

那么,问题就在于这两种对立力量之间的“拔河比赛”结果如何。主定理的三种情况,本质上对应了“工作量缩减速率”与“子问题增殖速率”之间博弈的三种可能结果:平局、恶的力量获胜(a > b^d)以及善的力量获胜(b^d > a)。

为了更好地理解,请思考以下问题:根据 ab^d 的比较关系,递归树中每层的工作量是随着层数增加而上升、下降,还是保持不变?

以下是关于这个问题的陈述,其中三个为真,一个为假:

  1. 如果 a < b^d,则每层工作量随着递归树深度增加而减少。
  2. 如果 a > b^d,则每层工作量随着递归树深度增加而增加。
  3. 如果 a = b^d,则每层工作量随着递归树深度增加而增加。
  4. 如果 a = b^d,则每层工作量相同。

让我们逐一分析。首先,考虑第一种情况:假设子问题增殖速率 a 严格小于工作量缩减速率 b^d。这是“善的力量”占优的情况。每个子问题工作量的节省速度超过了子问题的增殖速度。虽然子问题数量在增加,但每个子问题的节省增加得更多。因此,随着递归树层数加深,我们做的工作越来越少。

第二种情况为真,原因完全相同。如果子问题增殖如此迅速,以至于超过了每个子问题带来的节省,那么随着深入递归树,我们将看到工作量在增加。

既然前两种为真,第三种陈述就是错误的。我们可以根据子问题增殖速率是严格大于还是严格小于工作量缩减速率来得出结论。

最后,第四种陈述也是正确的。这是“善的力量”与“恶的力量”之间的完美平衡。子问题在增殖,但我们每个子问题的节省以完全相同的速率增加。两种力量相互抵消,使得递归树的每一层都完成完全相同的工作量。这正是我们分析归并排序算法时发生的情况。


让我们总结一下,并通过这种解读来理解它如何帮助我们预测主定理中看到的一些运行时间上界。

主定理的三种情况对应了子问题增殖与单子问题工作量缩减之间博弈的三种可能结果:一种是平局,一种是子问题增殖更快,一种是工作量缩减更快。

在速率完全相同的平局情况下,工作量在递归树的每一层都相同。在这种情况下,我们可以轻松预测运行时间:我们知道有对数数量的层,每层工作量相同,并且我们肯定知道根节点做了多少工作(根据原始递归式,根节点渐*地完成 n^d 的工作量)。因此,对于每个对数层都有 n^d 的工作量,我们期望运行时间为 n^d * log n

正如我们刚才讨论的,当每个子问题的工作量缩减速度甚至快于子问题增殖速度时,随着递归树层数加深,我们做的工作越来越少。特别是,工作量最大的层(最坏情况)出现在根层。最简单可能的结果是,根层的工作量主导了整个算法的运行时间,其他层的工作量在常数因子内无关紧要。虽然这不明显为真,但如果我们期待最简单的结果(即根节点工作量最大),我们可能期望运行时间与根节点的运行时间成正比。正如刚才讨论的,我们知道根节点是 n^d

基于同样的推理,当不等式翻转(a > b^d),子问题增殖如此迅速,以至于超过了每个子问题带来的节省时,工作量随着递归层数增加而增加。这里,最坏情况将出现在叶子节点,那一层的工作量将比其他任何层都多。同样,如果你期待最简单的结果,也许叶子节点的工作量就主导了算法在第三种情况下的运行时间(在常数因子内)。考虑到叶子节点对应基本情况,每个叶子节点做常数工作量,在最简单的场景下,我们期望运行时间与递归树中的叶子节点数量成正比。


让我们总结在本节视频中学到的内容。我们现在理解了,从根本上存在三种不同类型的递归树:

  1. 每层工作量相同的树。
  2. 工作量随层数增加而减少的树(根节点是最坏层)。
  3. 工作量随层数增加而增加的树(叶子节点是最坏层)。

此外,正是 a(子问题增殖速率)与 b^d(单子问题工作量缩减速率)之间的比值,决定了我们处理的是这三种递归树中的哪一种。

更进一步,直观上,我们现在对三种情况下期望看到的运行时间有了预测:

  • 对于情况一(平局),我们相当确信是 n^d log n
  • 对于情况二(根节点最坏),我们期望(或希望)运行时间是 n^d
  • 对于情况三(叶子节点最坏,每个叶子/基本情况做常数时间),我们期望它与叶子节点数量成正比。

现在,让我们根据主定理的正式陈述来检验这种直觉,我们将在下一个视频中更正式地证明它。

在三种情况的正式陈述中,我们看到它们至少与我们的直觉在两点或三点上匹配:

  • 在第一种情况下,我们看到了预期的 n^d * log n
  • 在第二种情况下(根节点最坏),最简单的可能结果 O(n^d) 正是其断言。
  • 在第三种情况下,仍然有一个谜团有待解释。我们的直觉说,这应该与叶子节点数量成正比,但我们却得到了一个有趣的公式:O(n^{log_b a})。在下一个视频中,我们将揭开这个联系的神秘面纱,并为这些断言提供正式的证明。

本节课中,我们一起学*了如何通过“子问题增殖”与“工作量缩减”的博弈来直观解读主定理的三种情况。我们理解了比值 a / b^d 如何决定递归树中工作量随深度变化的趋势(增加、减少或不变),并据此对算法总运行时间做出了直观预测。这为我们接下来正式证明主定理奠定了坚实的理解基础。

022:主定理证明(第二部分)📚

在本节课中,我们将完成主定理的证明。我们将把之前视频中建立的直观理解,转化为一个严谨的数学证明。我们将逐一验证主定理的三种情况,并最终理解第三种情况中那个看似复杂的表达式。

回顾与目标

上一节我们通过递归树分析了递归算法的工作量,得到了一个核心表达式 star

star = C * n^d * Σ_{j=0}^{log_b(n)} (a / bd)j

我们认识到,比值 r = a / b^d 决定了递归树的三种基本类型:

  • r = 1:每层工作量相同。
  • r < 1:工作量随层级递减。
  • r > 1:工作量随层级递增。

这为我们理解主定理的三种情况提供了直觉。本节的目标是将这种直觉转化为严格的证明。

情况一:完美平衡 ⚖️

首先,我们来看最简单的情况,即情况一。我们假设 a = b^d

这意味着子问题增殖的速率与每个子问题工作量减少的速率恰好抵消,达到了完美的平衡。现在,让我们检查表达式 star

a = b^d 时,比值 r = a / b^d = 1。因此,对于所有的 j(a / bd)j = 1^j = 1

于是,求和部分变得非常简单:

Σ_{j=0}^{log_b(n)} 1 = log_b(n) + 1

将这个结果代入 star 表达式,我们得到:

star = C * n^d * (log_b(n) + 1)

用大O记法表示,我们可以写作 O(n^d log n)。这里我们省略了对数的底数,因为不同底数的对数只相差一个常数因子,这个常数可以被大O记法中的隐藏常数吸收。

至此,情况一的证明完成。这是最简单的情况。

几何级数引理 📈

a ≠ b^d 时(即 r ≠ 1),我们需要处理一个几何级数的求和。为此,我们先做一个简短的数学引理。

考虑一个常数 r > 0r ≠ 1。我们要求和:

S = 1 + r + r^2 + ... + r^k

这个和有一个简洁的封闭形式公式:

S = (r^{k+1} - 1) / (r - 1)

这个公式可以通过数学归纳法证明,我们在此将其留作练*。我们更关心的是这个公式能为我们带来什么洞见。

以下是两个关键的推论:

  1. r < 1 时,这个和 S 可以被一个常数上界所限制,即 S ≤ 1 / (1 - r)。这个常数与求和的项数 k 无关。直观上,当 r=1/2 时,级数 1 + 1/2 + 1/4 + ... 收敛于 2。这意味着级数的首项(1)主导了整个和。
  2. r > 1 时,这个和 S 可以被最后一项的常数倍所限制,即 S ≤ 常数 * r^k。直观上,当 r=2 时,级数 1 + 2 + 4 + ... + 2^k 的和总是小于 2 * 2^k。这意味着级数的最大项(最后一项)主导了整个和。

总结来说:当我们对一个常数 r 的幂次求和时,若 r>1,则最大项主导总和;若 r<1,则总和本身是一个常数。

情况二:工作量递减 📉

现在,我们应用几何级数的知识来证明情况二。在情况二中,我们假设 a < b^d

这意味着子问题增殖的速率被每个子问题工作量减少的速率所淹没。这是工作量随递归树层级递减的情况。我们的直觉是,所有工作量(在一个常数因子内)都集中在根节点完成。

由于 a < b^d,比值 r = a / b^d < 1r 是一个常数(依赖于 a, b, d,但不依赖于 n)。

在表达式 star 中,求和部分正是 Σ_{j=0}^{log_b(n)} r^j,其中 r < 1

根据几何级数引理,这样的和有一个常数上界(与项数 log_b(n) 无关)。因此,表达式 star 可以简化为:

star = C * n^d * O(1) = O(n^d)

这精确地证实了我们的直觉:在这种工作量递减的递归树中,算法的总运行时间由根节点的工作量主导,其他层级的工作量只贡献一个常数因子。

情况三:工作量递增与叶子节点 🍃

最后,我们来看最具挑战性的情况三。我们假设 a > b^d

这意味着子问题增殖的速率超过了每个子问题工作量减少的速率。这是工作量随递归树层级递增的情况,大部分工作将在叶子节点完成。我们的直觉是,我们只需要关心叶子节点的工作量。

再次使用几何级数引理。此时 r = a / b^d > 1。求和 Σ_{j=0}^{log_b(n)} r^j 由它的最大项(即最后一项,当 j = log_b(n) 时)主导,最多相差一个常数因子。

因此,我们可以将 star 表达式简化为(用大O记法吸收常数 C 和来自几何级数的常数因子):

star = O( n^d * (a / bd) )

这个表达式看起来很复杂,但我们可以进行惊人的简化。让我们专注于后半部分:

(a / bd) = a^{log_b(n)} * (b{-d}) = a^{log_b(n)} * b^{-d * log_b(n)}

我们知道 b^{log_b(n)} = n,所以 b^{-d * log_b(n)} = (b{log_b(n)}) = n^{-d}

于是,n^d * n^{-d} 相互抵消!我们最终得到:

star = O( a^{log_b(n)} )

这个表达式 a^{log_b(n)} 有着非常自然的解释:它正是递归树中叶子的数量!

为什么?让我们思考递归树的结构:

  • 在根节点(第0层),有1个节点。
  • 每向下一层,节点数量乘以分支因子 a
  • 递归持续进行,直到问题规模缩小到1,这发生在第 log_b(n) 层。
  • 因此,叶子节点的总数就是 a^{log_b(n)}

这完美地证实了我们的直觉:在情况三中,算法的运行时间由叶子节点的工作量主导,即 O(叶子节点数量)

然而,在主定理的标准陈述中,情况三的运行时间写作 O(n^{log_b(a)})。这两个表达式其实是等价的,因为:

a^{log_b(n)} = n^{log_b(a)}

你可以通过对两边取以 b 为底的对数来验证这一点。虽然 a^{log_b(n)} 在概念上(叶子数量)更直观,但 n^{log_b(a)} 在实际应用(代入 a, b 的具体数值计算)时更为方便。

无论如何,我们已经证明了情况三。至此,主定理的证明全部完成!🎉

总结与核心要点 🎯

本节课我们一起完成了主定理的证明。虽然证明细节繁多,但有几个高层次的概念要点值得长期记忆:

  1. 递归树分析:我们从递归树出发,逐层累计算法的工作量,得到了一个通用表达式。
  2. 三种树型:我们认识到递归树有三种基本类型(工作量恒定、递增、递减),这直接对应主定理的三种情况。
  3. 运行时间直觉
    • 情况一 (a = b^d):每层工作量相同,共有 O(log n) 层,每层做 O(n^d) 工作,总时间为 O(n^d log n)
    • 情况二 (a < b^d):工作量递减,根节点主导,总时间为 O(n^d)
    • 情况三 (a > b^d):工作量递增,叶子节点主导。叶子数量为 a^{log_b(n)},等价于 n^{log_b(a)},总时间为 O(n^{log_b(a)})

记住这些核心概念,你就能深刻理解主定理为何成立,并能自信地应用它来分析递归算法的运行时间。

023:快速排序概述

在本节课中,我们将要学*著名的快速排序算法。快速排序因其高效、优雅和实用性,深受计算机科学家和程序员的喜爱。我们将了解其核心思想、工作原理以及为何它在实际应用中常常优于归并排序。

排序问题回顾

上一节我们介绍了归并排序,本节我们来看看快速排序。首先,我们回顾一下排序问题。

排序问题的输入是一个包含 n 个数字的数组,这些数字以任意顺序排列。例如,输入数组可能如下所示:

[3, 8, 2, 5, 1, 4, 7, 6]

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

核心子程序:分区

快速排序的核心思想在于一个名为 分区 的子程序。这个子程序围绕一个 枢轴元素 来重新排列数组。

以下是分区的具体步骤:

  1. 选择枢轴:从数组中选择一个元素作为枢轴。目前,我们可以简单地选择数组的第一个元素。
  2. 重新排列:重新排列数组,使得所有小于枢轴的元素都位于其左侧,所有大于枢轴的元素都位于其右侧。

例如,对于数组 [3, 8, 2, 5, 1, 4, 7, 6],选择 3 作为枢轴。一种合法的分区结果是:

[2, 1, 3, 8, 5, 4, 7, 6]
  • 左侧 [2, 1] 的所有元素都小于枢轴 3
  • 右侧 [8, 5, 4, 7, 6] 的所有元素都大于枢轴 3

请注意,分区并不要求左侧或右侧桶内的元素自身有序,它只完成了部分的排序工作。然而,枢轴元素本身已经被放置在了其在最终有序数组中正确的位置上。

分区为何重要

分区之所以是关键,基于以下两个重要特性:

  1. 线性时间复杂度:分区可以在 O(n) 时间内完成,其中 n 是数组大小。更重要的是,它的实现只需要进行元素交换,几乎不需要额外的内存空间。
  2. 缩小问题规模:分区后,原始排序问题被分解为两个更小的子问题——排序左侧元素和排序右侧元素。这自然引出了分治法的解决方案。

快速排序算法的高层描述

快速排序算法由托尼·霍尔大约在1961年发现,它是一个典型的分治算法。

以下是其高层描述(伪代码):

function quicksort(array, left, right):
    if left >= right:
        return  // 基本情况:数组为空或只有一个元素
    pivot_index = choose_pivot(array, left, right) // 选择枢轴
    pivot_index = partition(array, left, right, pivot_index) // 分区,返回枢轴最终位置
    quicksort(array, left, pivot_index - 1) // 递归排序左侧
    quicksort(array, pivot_index + 1, right) // 递归排序右侧

与归并排序不同,快速排序在递归调用之后没有“合并”步骤。一旦完成分区并递归排序了两侧,整个数组就已经是有序的了。

后续内容预告

在接下来的课程中,我们将深入探讨快速排序的各个细节:

  • 分区实现:展示如何在 O(n) 时间内、仅通过交换完成分区操作。
  • 正确性证明:形式化地证明快速排序算法的正确性。
  • 枢轴选择策略:探讨不同的枢轴选择方法如何影响算法性能。
  • 随机化快速排序:介绍通过随机选择枢轴来获得良好平均性能的版本。
  • 运行时间分析:分三部分进行数学分析,证明随机化快速排序的平均运行时间为 O(n log n),且隐藏的常数因子很小。我们将学*使用指示器随机变量和期望的线性性质来分析复杂随机变量的通用方法。

总结

本节课中我们一起学*了快速排序算法的概述。我们了解到,快速排序通过一个高效的分区子程序,将数组围绕枢轴元素分成两部分,然后递归地对这两部分进行排序。其代码优雅、运行高效且是原地排序算法,这些特点使其成为最受欢迎的排序算法之一。在接下来的课程中,我们将深入其实现细节并完成严谨的数学分析。

024:围绕基准元素进行分区 🎯

在本节课中,我们将深入学*快速排序算法的核心——分区子程序。我们将详细探讨如何围绕一个基准元素对数组进行重新排列,使其满足“基准左侧元素均小于基准,右侧元素均大于基准”的性质,并且我们将学*如何在线性时间内、仅使用常数级额外空间(原地操作)高效地实现这一过程。

分区子程序的目标

快速排序的关键思想是围绕一个基准元素对输入数组进行分区。这包含两个步骤:首先,选择一个基准元素;其次,重新排列数组以满足分区性质。

例如,给定数组 [3, 8, 2, 5, 1, 4, 7, 6],我们选择第一个元素 3 作为基准。一个合法的分区结果可能是 [2, 1, 3, 8, 5, 4, 7, 6]。在这个结果中:

  • 基准元素 3 位于其最终排序后的正确位置。
  • 所有小于 3 的元素(1, 2)都在其左侧。
  • 所有大于 3 的元素(4, 5, 6, 7, 8)都在其右侧。

分区完成后,我们只需递归地对基准左侧和右侧的子数组进行排序即可。因此,实现一个高效的分区子程序至关重要。

简单的分区方法(使用额外空间)

如果我们不关心空间效率,允许使用额外的线性空间,那么实现分区会非常简单。

以下是其基本思路:

  1. 创建一个与原数组等长的空数组 B
  2. 遍历原数组 A
  3. 对于每个元素 A[i]
    • 如果 A[i] < pivot,则将其放入 B左侧(从左向右填充)。
    • 如果 A[i] > pivot,则将其放入 B右侧(从右向左填充)。
  4. 遍历完成后,将基准元素 pivot 放入 B 中剩余的空位。

这种方法的时间复杂度是 O(n),但空间复杂度也是 O(n),因为它需要一个额外的数组。

高效的原位分区方法

上一节我们介绍了使用额外空间的简单方法,本节中我们来看看如何在不使用额外数组的情况下,仅通过交换操作在线性时间内完成分区。我们将假设基准元素是数组的第一个元素(这可以通过一次常数时间的交换操作实现)。

算法的核心思想是在单次线性扫描中维护一个不变式。我们使用两个指针 ij

  • j:指向当前扫描到的元素,是“已处理”和“未处理”区域的分界。
  • i:指向“已处理区域”内,最后一个小于基准的元素的下一个位置。换句话说,A[l+1...i-1] 都是小于基准的,A[i...j-1] 都是大于基准的。

初始状态时,i = j = l+1l 是子数组左边界,基准在 A[l])。我们通过一个 for 循环让 jl+1 遍历到 r(右边界)。

在每一步,我们检查新元素 A[j]

  • 情况1:A[j] > pivot。这很简单,我们只需将 j 加1,扩大“大于基准”的区域。i 保持不变。
  • 情况2:A[j] < pivot。这破坏了不变式,因为一个小于基准的元素出现在了“大于基准”的区域之后。为了修复,我们需要:
    1. A[j]A[i](即“大于基准”区域的第一个元素)交换。
    2. i 加1,因为现在“小于基准”的区域扩大了一个位置。
    3. j 加1。

扫描完成后,数组结构为:[pivot, <pivot的元素们, >pivot的元素们]。最后,我们将基准 A[l]A[i-1](即最后一个小于基准的元素)交换,基准就到达了其最终的正确位置。

算法示例

让我们通过一个具体例子来演示上述过程。数组为 A = [3, 8, 2, 5, 1, 4, 7, 6],基准 pivot = 3

以下是每一步的状态(| 表示 ij 的位置):

  1. 初始:[3, |8, 2, 5, 1, 4, 7, 6]i=j=1
  2. j=1, A[j]=8 > 3:无事发生,j++[3, 8, |2, 5, 1, 4, 7, 6]i=1
  3. j=2, A[j]=2 < 3:交换 A[j](2) 和 A[i](8),i++, j++[3, 2, 8, |5, 1, 4, 7, 6]i=2
  4. j=3, A[j]=5 > 3j++[3, 2, 8, 5, |1, 4, 7, 6]i=2
  5. j=4, A[j]=1 < 3:交换 A[j](1) 和 A[i](8),i++, j++[3, 2, 1, 5, 8, |4, 7, 6]i=3
  6. j=5,6,7A[j]=4,7,6> 3,仅 j++
  7. 循环结束:[3, 2, 1, 5, 8, 4, 7, 6]i=3
  8. 最后交换:交换 A[l](3) 和 A[i-1](1)。得到:[1, 2, 3, 5, 8, 4, 7, 6]。✅ 分区完成。

分区子程序的伪代码

理解了算法流程后,我们现在可以给出清晰的形式化描述。以下是分区子程序的伪代码:

function Partition(A, l, r):
    // 输入:数组 A,左右边界 l, r
    // 输出:基准元素的最终位置索引
    pivot = A[l]          // 选择第一个元素作为基准
    i = l + 1             // i 指向小于基准区域的末尾下一个位置

    for j = l+1 to r do:  // j 遍历整个子数组
        if A[j] < pivot then:
            // 交换 A[j] 和 A[i],将小的元素移到前面
            swap(A[j], A[i])
            i = i + 1     // 小于基准的区域扩大
        // 如果 A[j] >= pivot,则什么都不做,j 会在循环中自增
    end for

    // 将基准元素交换到正确位置(即 i-1 处)
    swap(A[l], A[i-1])
    return i-1            // 返回基准的最终位置,用于后续递归

算法分析

现在我们来分析一下这个分区算法的性能。

  • 时间复杂度:算法只包含一个从 l+1rfor 循环,循环内执行常数时间的比较和可能的交换操作。因此,对于长度为 n = r - l + 1 的子数组,时间复杂度为 Θ(n)
  • 空间复杂度:算法只使用了几个额外的变量(pivot, i, j),没有分配任何与输入规模成比例的额外数组。因此,它是一个原地算法,空间复杂度为 O(1)
  • 正确性:算法的正确性可以通过我们之前讨论的循环不变式来严格证明:
    • 不变式:在 for 循环的每次迭代开始时,对于任意索引 k
      • 如果 l < k < i,则 A[k] < pivot
      • 如果 i ≤ k < j,则 A[k] ≥ pivot
    • 这个不变式在循环开始时(i=j=l+1,两个区域为空)显然成立。
    • 在循环的每一步,无论是 A[j] ≥ pivot(仅 j++)还是 A[j] < pivot(交换后 i++, j++),都能维持这个不变式。
    • 循环结束时(j = r+1),整个数组 A[l+1...r] 被划分为 < pivot≥ pivot 两部分。最后将 A[l](基准)与 A[i-1] 交换,就得到了完整的分区结果。

总结

本节课中我们一起学*了快速排序算法的核心——分区子程序。我们首先明确了分区的目标,即围绕基准元素重排数组。接着,我们对比了使用额外空间的简单方法。然后,我们深入探讨了高效的原位分区算法,该算法通过维护 ij 两个指针,在一次线性扫描中完成所有工作。我们通过详细的示例演示了算法的执行过程,并给出了正式的伪代码。最后,我们分析了该算法在线性时间复杂度和常数空间复杂度上的优异性能,并通过循环不变式论证了其正确性。

掌握这个高效的分区方法是理解快速排序后续内容(如基准选择策略、算法复杂度分析)的重要基础。在接下来的课程中,我们将探讨如何选择基准元素,以及不同的选择策略如何影响快速排序的整体效率。

025:快速排序正确性回顾 🧠

在本节中,我们将学*如何使用数学归纳法来严格证明快速排序算法的正确性。我们将回顾归纳法的基本结构,并将其应用于快速排序,确保无论选择何种枢轴元素,算法都能正确排序任意长度的数组。


归纳法证明格式回顾

上一节我们介绍了快速排序的基本思想,本节中我们来看看如何用归纳法证明其正确性。首先,我们需要回顾归纳法证明的标准格式。

归纳法用于证明一个关于正整数 n 的断言 P(n) 对所有正整数都成立。证明分为两个部分:基础情况归纳步骤

以下是归纳法证明的两个核心步骤:

  1. 基础情况:证明断言 P(1) 成立。
  2. 归纳步骤:假设对于所有小于 n 的正整数 k,断言 P(k) 都成立(这称为归纳假设),然后证明在此假设下,断言 P(n) 也成立。

如果成功完成这两个步骤,就证明了 P(n) 对所有正整数 n 都成立。


快速排序的正确性断言

现在,让我们将归纳法应用于快速排序。我们关心的断言 P(n) 定义如下:

P(n):快速排序算法总能正确排序长度为 n 的任意数组。

我们的目标是证明 P(n) 对所有 n ≥ 1 都成立。


基础情况:n = 1

基础情况非常简单。当数组只有一个元素时,它本身就是有序的。快速排序在 n=1 时直接返回输入数组,不做任何处理。因此,它返回的确实是一个有序数组。

通过这个简单的论证,我们直接证明了 P(1) 成立。


归纳步骤:n ≥ 2

现在进入归纳步骤。我们固定一个 n ≥ 2 的值,并假设归纳假设成立:即对于所有 k < nP(k) 都成立。换句话说,我们假设快速排序能正确排序任何长度小于 n 的数组。

我们需要证明,在此假设下,P(n) 也成立。为此,我们考虑一个任意的长度为 n 的输入数组 A

以下是快速排序在数组 A 上的操作步骤:

  1. 选择枢轴:算法任意选择一个元素作为枢轴 p。枢轴的选择方式不影响正确性,只影响运行时间。
  2. 分区:算法围绕枢轴 p 对数组进行分区。分区完成后,数组被重新排列,使得:
    • 枢轴 p 位于某个最终位置。
    • 所有小于 p 的元素都在 p 的左侧(我们称这部分为 L)。
    • 所有大于 p 的元素都在 p 的右侧(我们称这部分为 R)。

关键观察是,分区后,枢轴 p 已经位于它在最终排序数组中的正确位置。

L 的长度为 k1R 的长度为 k2。由于枢轴 p 本身不属于 LR,因此 k1k2 都严格小于 n(即 k1, k2 ≤ n-1)。

根据我们的归纳假设 P(k1)P(k2),我们知道快速排序能正确排序子数组 LR。因此:

  • L 的递归调用会将其元素正确排序。
  • R 的递归调用会将其元素正确排序。

最后,将排序好的 L、枢轴 p 和排序好的 R 按顺序拼接起来,就得到了原始数组 A 的一个完整排序版本。

由于数组 A 是任意选择的,这便证明了断言 P(n) 成立。又因为 n 是任意大于等于2的整数,我们完成了归纳步骤。


总结

本节课中我们一起学*了如何用数学归纳法严格证明快速排序算法的正确性。我们首先回顾了归纳法的证明格式,然后定义了关于快速排序的正确性断言 P(n)。通过证明基础情况 P(1) 和归纳步骤(在假设 P(k) 对所有 k < n 成立的前提下证明 P(n)),我们得出结论:无论采用何种方式选择枢轴元素,快速排序总能正确排序任意长度的输入数组。这个证明框架同样适用于分析其他分治算法的正确性。

026:选择好的枢轴元素 🎯

在本节课中,我们将深入探讨快速排序算法的核心环节——如何选择枢轴元素。枢轴元素的选择直接决定了算法的效率,我们将看到,一个糟糕的选择可能导致算法性能急剧下降,而一个聪明的随机化策略则能保证算法在绝大多数情况下都表现出色。

快速排序算法回顾 🔄

上一节我们介绍了快速排序算法的基本框架。快速排序的核心思想是“分而治之”,其高层描述如下:

  1. 选择枢轴:从输入数组中选取一个元素作为枢轴。
  2. 分区:围绕枢轴重新排列数组,使得小于枢轴的元素都在其左侧,大于枢轴的元素都在其右侧。枢轴元素本身则被放置在其最终的正确位置上。
  3. 递归排序:对枢轴左侧和右侧的两个子数组递归地调用快速排序。

一旦分区完成,我们只需递归地解决两个子问题,无需像归并排序那样需要一个额外的“合并”步骤。我们之前已经看到,分区子程序可以在线性时间内完成,并且是原地操作,几乎不需要额外的存储空间。

枢轴质量的重要性 ⚖️

现在,大家可能会问:快速排序是一个好算法吗?它运行得快吗?这个标准很高,因为我们已经有归并排序这样一个非常优秀且实用的 O(n log n) 算法。

目前,我们尚无法讨论快速排序的运行时间,因为我们缺少关键信息:快速排序的运行时间严重依赖于枢轴元素的选择方式,即所选枢轴的质量

那么,什么是枢轴的质量呢?简单来说:

  • 一个高质量的枢轴能将数组大致均匀地分割成两个子问题。
  • 一个低质量的枢轴会导致极不平衡的子问题。

为了理解枢轴质量的含义及其影响,让我们通过几个小测验来探索。

最坏情况分析:糟糕的枢轴选择 😱

第一个测验旨在探索快速排序算法的一种最坏情况执行。当为特定的输入数组选择非常不合适的枢轴时会发生什么?

具体来说,假设我们使用最朴素的choosePivot实现,就像在分区视频中讨论的那样:我们总是选取数组的第一个元素作为枢轴。同时,假设快速排序的输入数组是一个已经排好序的数组(例如,数字1到8按顺序排列)。

问题:如果我们总是使用子数组的第一个元素作为枢轴,那么快速排序在这个已排序数组上的运行时间是多少?

答案是:O(n²)

对于排序算法来说,平方阶的运行时间是不理想的。如果我们满足于平方阶,就不需要这些相对复杂的排序算法,直接使用插入排序即可。

原因分析
让我们思考一下在这个已排序数组 [1, 2, 3, ..., n] 上会发生什么。

  1. 第一层递归:枢轴是第一个元素 1。分区后,小于 1 的部分为空,大于 1 的部分是 [2, 3, ..., n],长度为 n-1,并且仍然有序。
  2. 第二层递归:在子数组 [2, 3, ..., n] 上,枢轴是 2。分区后,我们得到空数组和 [3, 4, ..., n]
  3. 后续递归:这个过程会持续下去,每次递归都只减少一个元素的大小(n-2, n-3, ...),直到最后处理单个元素。

运行时间计算
在每一层递归中,我们都需要调用分区子程序,它需要查看传递给它的数组中的每个元素。因此,总工作量至少是:
n + (n-1) + (n-2) + ... + 1

这个和是 Θ(n²)。一个简单的理解方式是:这个和的前 n/2 项每一项都至少是 n/2,所以总和至少是 (n/2) * (n/2) = n²/4。显然,总和也至多是

因此,在这种糟糕的输入和枢轴选择下,快速排序的运行时间是平方阶的。

最好情况分析:理想的枢轴选择 ✨

理解了最坏情况后,让我们来讨论其最佳情况运行时间。我们通常不单纯为了最佳情况而分析算法,但这样做有助于:

  1. 更好地理解算法的工作原理。
  2. 为后续的平均情况分析设定一个目标(平均情况不可能优于最佳情况)。

那么,什么是最好的情况?我们能期望的最高质量枢轴是什么?理想情况下,我们希望选择一个能将数组完美地分成两半的枢轴,即中位数——恰好有一半元素小于它,一半元素大于它。这将给我们一个完美的 50-50 分割。

问题:假设在每一次递归调用中,我们都神奇地选到了当前子数组的中位数作为枢轴,从而在每次递归前都得到一个完美的 50-50 分割。在这种神奇的、最佳可能的情况下,算法的运行时间是多少?

答案是:O(n log n)

原因分析
在这种情况下,支配快速排序运行时间的递归式与支配归并排序运行时间的递归式完全匹配,而我们已知后者的解是 O(n log n)。

具体来说,对于长度为 n 的数组,运行时间 T(n) 满足:
T(n) = 2 * T(n/2) + O(n)

  • 2 * T(n/2):因为枢轴是中位数,所以会产生两个递归调用,每个处理的子问题规模最多为 n/2
  • O(n):这是递归调用之外的工作量,包括选择枢轴(假设为线性时间)和执行分区(已知为线性时间)。

根据主定理或与归并排序相同的论证,这个递归式给出了 O(n log n) 的运行时间上界。实际上,由于分区子程序确实需要 Θ(n) 的时间,我们可以将结果加强为 Θ(n log n)

这个测验的要点是:即使在最佳情况下,即使我们在整个快速排序过程中神奇地获得了完美的枢轴,我们能期望的最好结果也是 O(n log n) 上界,不会比这更好了。

关键问题:如何选择枢轴? 🤔

前面的测验指出了一个关于快速排序实现的核心问题:我们究竟该如何选择枢轴? 我们现在知道,枢轴的选择对算法运行时间有巨大影响,可能差至 O(n²),也可能好至 O(n log n)。我们当然希望处于 O(n log n) 这一边。

快速排序将成为我们看到的第一个“随机化算法”的杀手级应用。随机化算法的思想是允许算法在代码中“抛硬币”,从而在平均情况下获得良好的性能。

核心思想是:随机选择枢轴。

具体来说,每次递归调用快速排序时,面对一个长度为 k 的子数组,我们在 k 个候选枢轴元素中随机均匀地选择一个(每个元素被选中的概率为 1/k)。每次递归调用都会做出一个新的随机选择。

这是我们第一个随机化算法的例子。在随机化算法中,即使你输入完全相同的数据,不同的执行过程也可能不同,因为算法代码内部存在随机性。

随机化为何有效?直觉与希望 🌈

在深入严谨的数学分析之前,让我们先建立一些直觉,理解为什么随机选择枢轴可能是一个好主意。

第一步:*乎平衡的分割就足够好
在最后一个测验中,我们看到如果每次都能选中位数,就能得到 O(n log n) 的运行时间。但事实上,要获得 O(n log n) 的运行时间,并不需要每次都神奇地选中位数。只要我们能获得大致平衡的分割,结果就会很好。

具体来说,假设我们总是能选择一个保证 25-75 分割或更好的枢轴(即两个递归调用处理的子数组大小都不超过原数组的 75%)。那么,快速排序在这种情况下的运行时间仍然是 O(n log n)。这意味着,一个能产生“足够好”平衡的枢轴就足以保证我们期望的效率。

第二步:获得“足够好”的枢轴并不难
现在,关键是要意识到,我们并不需要非常幸运才能获得一个 25-75 分割。这是一个相当适中的目标,而这个适中的目标就足以带来 O(n log n) 的运行时间。

考虑一个包含数字 1 到 100 的数组。

  • 哪些元素能给我们 25-75 或更好的分割?
  • 任何在 26 到 75 之间(包含两端)的元素都可以!因为如果选择 ≥26 的元素,左侧子问题至少包含元素 1-25(≥25%);如果选择 ≤75 的元素,右侧子问题至少包含元素 76-100(≥25%)。
  • 在 100 个元素中,有 50 个元素(26到75)满足条件,即 50% 的概率我们能选到一个“足够好”的枢轴。

所以,高层次的希望是:足够频繁地(比如一半的时间),我们能得到这些“足够好”的 25-75 或更好的分割,这似乎暗示着平均 O(n log n) 的运行时间是一个合理的期望。

定理陈述:随机化快速排序的保证 📜

上述直觉虽然令人鼓舞,但我们需要严谨的数学分析来确认它是否真的有效。这正是算法研究中反复出现的主题:当你有一个新想法时,最终需要借助数学分析来从根本上解释其好坏。

在接下来的视频中,我们将证明关于快速排序的以下定理:

定理:对于每一个长度为 n 的输入数组(不对数据做任何假设),采用随机选择枢轴实现的快速排序的平均运行时间O(n log n)(实际上是 Θ(n log n))。

这是一个关于随机化快速排序非常酷的定理。需要明确的是,这是一个关于输入的最坏情况保证。定理开头说“对于每一个输入数组”,意味着我们绝对没有对数据做任何假设。这是一个完全通用的排序子程序,你可以在任何情况下使用它,即使你不知道数据来自何处,这些保证仍然成立。

定理中出现的“平均”一词,不是对输入数据随机性的假设。输入数组可以是任何东西。这里的“平均”完全来源于我们算法代码内部的随机性,即我们自己引入并负责的随机性。

随机化算法有一个有趣的特性:即使在相同的输入上反复运行,也会得到不同的执行过程,因此运行时间会变化。我们的测验表明,快速排序在给定输入上的运行时间可以在 O(n log n) 的最佳情况和 O(n²) 的最坏情况之间波动。这个定理告诉我们,对于每一个可能的输入数组,虽然运行时间确实在这两个边界之间波动,但最佳情况在平均意义上占主导地位。平均而言,它几乎和最佳情况一样好,这就是快速排序如此神奇的原因。O(n²) 的情况偶尔可能出现,但这无关紧要,你几乎永远不会看到它;在随机化快速排序中,你总是会看到类似 O(n log n) 的行为。

总结 📝

本节课我们一起学*了快速排序中枢轴选择的关键性。我们了解到:

  1. 枢轴质量决定性能:糟糕的选择(如已排序数组中选择第一个元素)会导致 O(n²) 的最坏情况;而理想的选择(中位数)则能带来 O(n log n) 的最佳情况。
  2. 随机化是解决方案:通过在每个递归步骤中随机均匀地选择枢轴,我们可以将算法的命运交给概率。
  3. 直觉与保证:虽然不一定每次都能选中位数,但有很大概率(例如50%)选到能产生*似平衡分割的“足够好”的枢轴,这足以保证良好的平均性能。
  4. 严谨的结论:数学分析证明,对于任何输入,随机化快速排序的平均运行时间O(n log n),这是一个强大的最坏情况输入、平均性能保证。

这标志着随机化算法作为一个强大工具登上了舞台。在接下来的课程中,我们将深入进行概率论回顾(可选)并完成该定理的严谨分析。

027:分解原理 🧩

在本节课中,我们将开始对随机化快速排序算法的运行时间进行数学分析。具体来说,我们将证明快速排序的平均运行时间是 O(n log n)。这是我们课程中遇到的第一个随机化算法,因此其分析也将是我们首次需要运用概率论知识。

预备知识 📚

在开始分析之前,你需要了解离散概率论的一些基本概念:

  • 样本空间:如何建模所有可能发生的事件,即随机选择的所有可能结果。
  • 随机变量:定义在样本空间上、取实数值的函数。
  • 期望值:随机变量的平均值。
  • 期望的线性性质:这是分析快速排序所需的一个关键且非常简单的性质。

如果你对这些概念不熟悉或感到生疏,建议在继续观看前进行复*。你可以参考课程网站上的概率论复*视频,或者阅读 Eric Lehman 和 Tom Leighton 的免费在线讲义《计算机科学数学》,其中涵盖了所有必要知识。

定理陈述与目标 🎯

我们之前提到,对于随机化实现的快速排序算法(即在每个递归子调用中均匀随机地选择一个主元),有以下断言:对于任意长度为 n 的输入数组(即使是“最坏情况”的输入),算法内部随机性(而非输入数据)所导致的平均运行时间为 O(n log n)

我们知道快速排序的最佳情况运行时间是 O(n log n),最坏情况是 O(n²)。这个定理断言,无论输入数组是什么,快速排序的典型行为都更接*最佳情况,而非最坏情况。接下来的视频将证明这一点。

建立分析框架 🔧

首先,我们需要建立必要的符号,并明确样本空间和我们关心的随机变量。

  1. 固定输入:我们固定一个任意的、长度为 n 的输入数组 A。在整个分析过程中,我们都将针对这个固定的数组进行。
  2. 样本空间 Ω:样本空间包含了算法中所有可能的随机性结果。在这里,随机性来自于我们选择的随机主元序列。因此,Ω 就是快速排序可能选择的所有主元序列的集合。
  3. 核心随机变量 C:对于给定的主元序列 σ(即样本空间中的一个点),我们定义随机变量 C(σ) 为快速排序执行的比较次数。这里的“比较”特指对输入数组中两个不同元素进行的比较(例如,比较第三个元素和第七个元素哪个更小)。给定主元序列后,快速排序就变成了一个确定性算法,因此比较次数 C(σ) 是一个确定的数字。
  4. 运行时间与比较次数的关系:我们真正关心的是运行时间,而不仅仅是比较次数。但可以论证,快速排序所做的主要工作就是元素比较。更正式地说,存在一个常数 c,使得对于任何主元序列 σ,快速排序执行的总操作数 RT(σ) 满足:
    RT(σ) ≤ c * C(σ)
    这意味着运行时间主要由比较次数决定。因此,要证明平均运行时间是 O(n log n),我们只需证明平均比较次数是 O(n log n)

我们的目标:证明对于任意固定的长度为 n 的输入数组 A,随机变量 C(比较次数)的期望值满足:
E[C] = O(n log n)

分解原理介绍 🧠

我们确定了关心的随机变量 C,但它本身非常复杂,其值在 O(n log n)O(n²) 之间波动,难以直接处理。

我们曾用递归式和分析递归树的方法来分析分治算法。但对于随机化的快速排序,传统的“主定理”并不直接适用,因为子问题的大小是随机的(取决于随机选择的主元质量),而主定理通常要求子问题大小相同。

因此,我们将采用一种称为分解原理的方法。其核心思想是:将一个我们关心的、复杂的随机变量,分解为许多简单的、我们并不直接关心但易于分析的随机变量之和,然后利用期望的线性性质将它们重新组合起来。这种方法将成为我们分析快速排序(以及后续课程中如哈希等其他主题)的主要工具。

定义基础构件:指示器随机变量 🧱

为了应用分解原理,我们需要定义一组简单的随机变量作为基础构件。这些变量将记录特定元素对之间的比较情况。

首先引入一些符号:

  • 我们用 z_i 表示输入数组 A 中第 i 小的元素(也称为第 i 阶顺序统计量)。注意,z_i 不是原始未排序数组中第 i 个位置的元素,而是排序后最终会出现在第 i 个位置上的元素。

现在,我们为每一对元素定义一个指示器随机变量:

  • 对于给定的主元序列 σ,以及满足 1 ≤ i < j ≤ n 的索引 ij,定义随机变量 X_{ij}(σ) 为:在快速排序执行过程中,元素 z_iz_j 被比较的次数。

关键观察:对于任意一对元素 (z_i, z_j),它们在整个快速排序过程中最多被比较一次,也可能一次都没有。因此,X_{ij} 是一个指示器随机变量,它只能取值为 0 或 1。X_{ij} = 1 表示 z_iz_j 被比较了一次。

为什么最多比较一次? 回顾快速排序的 partition 子程序,所有的元素比较都发生在这里,并且每次比较都涉及当前递归调用中选定的主元。假设 z_iz_j 第一次被比较,那么此时其中一个是主元。在这次比较之后,该主元会被排除在后续的所有递归调用之外,因此 z_iz_j 再也没有机会在同一个递归调用中相遇并被比较。

应用分解原理与期望线性性质 ⛓️

现在,我们可以将复杂的随机变量 C(总比较次数)用这些简单的随机变量 X_{ij} 表示出来。因为每一次比较都恰好涉及一对元素 (z_i, z_j)(其中 i < j),所以有:

C = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} X_{ij}

这个双重求和遍历了所有可能的元素对 (i, j),并将它们对应的比较指示器变量加起来,就得到了总比较次数。

接下来,我们应用期望的线性性质。该性质指出,和的期望等于期望的和,并且无论这些随机变量是否相互独立,该性质都成立。这一点非常重要,因为 X_{ij} 之间并不是独立的。

E[C] = E[ Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} X_{ij} ] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} E[X_{ij}]

由于每个 X_{ij} 都是取值为 0 或 1 的指示器随机变量,它的期望值恰好等于 X_{ij} = 1 的概率:

E[X_{ij}] = 0 * Pr(X_{ij}=0) + 1 * Pr(X_{ij}=1) = Pr(X_{ij}=1)

Pr(X_{ij}=1) 正是元素 z_iz_j 在快速排序过程中被比较的概率。

将上述结果结合起来,我们得到了一个关键的表达式(记为 (*)):

E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} Pr( z_i 与 z_j 被比较 )

本节总结与后续计划 📝

在本节中,我们一起学*了如何为快速排序的平均情况分析建立框架。我们通过分解原理,将分析复杂的总比较次数期望 E[C] 的问题,转化为了分析许多简单的、关于特定元素对是否被比较的概率 Pr(z_i 与 z_j 被比较) 的问题。

分解原理的一般步骤

  1. 确定目标:明确你关心的复杂随机变量 Y(例如,快速排序的比较次数)。
  2. 分解为和:将 Y 表示为一系列更简单的随机变量(通常是指示器变量)之和:Y = Σ X_l
  3. 应用线性期望:利用期望的线性性质,得到 E[Y] = Σ E[X_l]
  4. 计算概率:对于指示器变量,E[X_l] = Pr(X_l = 1)。因此,问题最终归结为计算一系列特定事件的概率。

现在,我们成功地将问题简化了。在下一节中,我们将深入探讨这个概率:对于给定的 ijz_iz_j 被比较的概率究竟是多少?我们将得到一个关于 ij 的精确表达式。然后,在第三部分,我们将把这个表达式代入上面的求和式 (*) 中,并计算出总和,最终证明它等于 O(n log n)。让我们继续前进,找出这个概率的精确表达式。

028:关键洞察与概率推导 🧠

在本节课中,我们将继续证明随机化快速排序的平均运行时间为 O(n log n)。我们将深入探讨一个核心概念:如何精确计算输入数组中任意两个元素在排序过程中被比较的概率。


概述

上一节我们介绍了分析框架,定义了随机变量 C(总比较次数)和指示变量 X_ij(元素 Z_iZ_j 是否被比较)。通过期望的线性性质,我们将问题转化为计算每个 X_ij 等于 1 的概率。本节的核心目标是推导出这个概率的精确表达式。


关键洞察:比较何时发生?

考虑输入数组中第 i 小和第 j 小的元素,记为 Z_iZ_j(其中 i < j)。我们关注的是从 Z_iZ_j(包含两端)的这组元素,即集合 {Z_i, Z_i+1, ..., Z_j},共 j - i + 1 个元素。

在快速排序的执行过程中,只要选择的枢轴(pivot)不来自这个集合,那么整个集合就会被传递到同一个递归调用中。这些元素“和谐共处”,直到其中一个首次被选为枢轴。

当集合中某个元素首次被选为枢轴时,有两种情况:

  1. 枢轴是 Z_i 或 Z_j:在这种情况下,Z_iZ_j 必定会被比较,因为枢轴在分区(partition)过程中会与子数组中的所有其他元素进行比较。
  2. 枢轴是 Z_i+1 到 Z_j-1 中的某个元素:在这种情况下,Z_iZ_j 永远不会被比较。因为:
    • 在本次分区中,只有枢轴会与其他元素比较,而 Z_iZ_j 都不是枢轴。
    • 由于枢轴的值介于 Z_iZ_j 之间,分区操作会将 Z_i 划入左子数组,将 Z_j 划入右子数组。此后,它们将在不同的递归调用中处理,再无相遇机会。

因此,Z_iZ_j 被比较的充要条件是:在集合 {Z_i, Z_i+1, ..., Z_j} 中,Z_iZ_j第一个被选为枢轴的元素。


推导精确概率

我们的快速排序实现总是从当前子数组中均匀随机地选择枢轴。因此,在集合 {Z_i, Z_i+1, ..., Z_j}j - i + 1 个元素中,每个元素首次成为枢轴的概率是相等的。

  • 导致比较的枢轴选择:有 2 种(即选择 Z_iZ_j)。
  • 总的可能首次枢轴选择:有 j - i + 1 种。

所以,Z_iZ_j 被比较的概率为:

概率公式:

P(Z_i 与 Z_j 被比较) = 2 / (j - i + 1)

例如,对于第 3 小和第 7 小的元素(i=3, j=7),它们被比较的概率是 2 / (7 - 3 + 1) = 2/5 = 40%


整合到期望计算中

现在,我们可以将这个精确的概率表达式代入上一节得到的期望公式中。快速排序在给定输入数组上的平均比较次数 E[C] 为:

期望公式:

E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} P(Z_i 与 Z_j 被比较)
     = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} [2 / (j - i + 1)]

我们得到了一个关键的表达式(记为 )。这个双重求和代表了算法所有可能的元素对比较概率的总和。


本节总结

在本节中,我们完成了快速排序平均情况分析中最核心、最概念化的部分:

  1. 我们明确了分析目标:计算任意两个元素 Z_iZ_j 被比较的概率。
  2. 我们获得了关键洞察:两个元素被比较,当且仅当在它们之间的所有元素中,其中一个元素是第一个被选为枢轴的。
  3. 利用随机枢轴选择的均匀性,我们推导出了该概率的精确公式:P = 2 / (j - i + 1)
  4. 我们将此公式代入期望表达式,得到了一个需要求和的式子

至此,所有困难的概念性工作已经完成。下一节,我们将通过代数运算来评估这个求和式 ,并最终证明它属于 O(n log n),从而完成整个证明。

029:分析III - 最终计算(高级可选)

在本节课中,我们将完成对快速排序算法的分析,最终证明其随机化实现的平均运行时间为 O(n log n)。我们将整合之前的工作,并通过巧妙的数学技巧完成证明。

上一节我们精确推导出了快速排序平均比较次数的表达式。本节中,我们来看看如何对这个表达式进行化简和上界估计,从而得到我们想要的 O(n log n) 结论。

回顾与目标

我们正在证明的是:对于快速排序的随机化实现(即每次随机均匀地选择主元),对于任意长度为 n 的输入数组,其平均运行时间(对主元选择的随机性取平均)是 O(n log n)

我们已经完成了大量工作。我们定义了关键随机变量 C,即快速排序在输入数组中对元素对进行的比较总次数。通过分解法,我们将 C 表示为一系列指示随机变量 X_ij 的和,每个 X_ij 记录第 i 小和第 j 小的元素是否被比较。利用期望的线性性质,我们只需理解不同元素对的比较概率。我们已精确得出,对于第 i 小和第 j 小的元素,在随机选择主元时,它们被比较的概率恰好是 2 / (j - i + 1)

综合以上,我们得到了控制快速排序平均比较次数的精确表达式:

期望比较次数 E[C] = Σ_{i=1}^{n-1} Σ_{j=i+1}^{n} [2 / (j - i + 1)]

到目前为止,我们的分析是完全精确的,没有进行任何*似。这让我们看到快速排序的常数因子很小,主要就是这个因子 2

化简求和表达式

我们的目标是证明 E[C] = O(n log n)。观察上面的双重求和表达式,其项数约为 n^2 量级,若简单地将每项上界估计为常数(如 1/2),会得到一个 O(n^2) 的上界,这不是我们想要的。因此,我们需要更巧妙地评估这个和式。

思路是固定外层求和中的索引 i,然后分析内层求和 Σ_{j=i+1}^{n} [1 / (j - i + 1)] 的大小。

对于固定的 i,内层求和从 j = i+1 开始,到 j = n 结束。随着 j 增大,分母 (j - i + 1) 增大,因此求和项 1/(j-i+1) 减小。最大的项出现在 j = i+1 时,其值为 1/2。随后的项依次为 1/3, 1/4, ...,直到最后一项(当 i=1j=n 时,该项为 1/n)。

为了简化,我们对原始表达式(标记为 *)进行上界估计。我们保留因子 2。外层求和有大约 ni 值(更精确是 n-1,但我们可以宽松地取 n)。对于每个 i,我们用可能的最大内层和来替代其实际的内层和。最大的内层和出现在 i=1 时,其项为 1/2 + 1/3 + ... + 1/n

因此,我们可以进行变量替换,令 k = j - i + 1。当 ji+1n 变化时,k2 变化到 n-i+1。最坏情况下(i=1),k2n。于是,我们得到上界:

E[C] ≤ 2 * n * Σ_{k=2}^{n} (1/k)

现在问题转化为:证明这个单重求和 Σ_{k=2}^{n} (1/k) 的大小仅为对数级 O(log n)。如果成立,那么整体上界就是 2n * O(log n) = O(n log n),并且常数因子相当合理。

证明调和数和的对数上界

以下是证明 Σ_{k=2}^{n} (1/k) ≤ ln n 的步骤。

我们可以通过几何图形来直观理解并证明这个上界。

考虑在坐标轴上,沿 x 轴标出正整数点。沿 y 轴标出分数 1/k 的值。我们画出一系列宽度为 1、高度分别为 1/2, 1/3, 1/4, ... 的矩形。这些矩形的面积分别是 1/2, 1/3, 1/4, ...。

现在,在这个图上叠加连续函数 f(x) = 1/x 的图像。注意,这条曲线恰好经过每个矩形右上角的顶点。

我们要证明的和 1/2 + 1/3 + ... + 1/n 正是这些矩形的面积之和。而曲线 y=1/x 下从 x=1x=n 的面积(即积分)包含了所有这些矩形(从第二个开始)的面积,因为曲线在每个矩形的东北角上方或经过。

用数学语言表达:

Σ_{k=2}^{n} (1/k) ≤ ∫_{1}^{n} (1/x) dx

根据微积分基本知识,∫ (1/x) dx = ln x。因此:

∫_{1}^{n} (1/x) dx = ln n - ln 1 = ln n

所以,我们证明了:

Σ_{k=2}^{n} (1/k) ≤ ln n

完成定理证明

将这一结果代回我们的上界表达式:

E[C] ≤ 2 * n * ln n

因此,快速排序在任意长度为 n 的输入上,平均比较次数最多为 2n ln n,这显然是 O(n log n)

这仅仅是比较次数。正如我们之前观察到的,快速排序的平均运行时间主要由比较次数决定,不会比这多太多。此外,正如我们在讨论实现细节时提到的,它是原地排序算法,几乎不需要额外存储空间。

本节课中我们一起学*了如何通过分解法、期望线性性质和巧妙的积分上界估计,最终严谨地证明了随机化快速排序的平均运行时间为 O(n log n),从而解释了快速排序为何如此高效。

030:概率回顾(上)🎲

概述

在本节课中,我们将回顾概率论中的几个核心概念,这些概念对于理解随机化算法的分析至关重要。我们将从样本空间和事件开始,然后讨论随机变量、期望值,以及一个极其重要的性质——期望的线性性。最后,我们将通过一个负载均衡的例子,将这些概念串联起来。


样本空间

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

样本空间是我们分析随机过程时,所有可能发生结果的集合。它定义了我们将要讨论概率和平均值的“宇宙”。我们通常用符号 Ω 表示样本空间。

在算法设计中,我们通常可以将 Ω 视为一个有限集,这就是我们只处理离散概率的原因,这比更一般的概率论要简单得多。

除了定义所有可能的结果,我们还需要定义每个结果发生的概率。每个结果的概率应至少为零(非负),并且所有结果的概率之和必须为一,因为最终恰好会发生一件事。

为了更具体地理解这些抽象概念,我们将使用两个简单的例子:

  • 例子一:掷两个六面骰子。样本空间是这两个骰子所有可能结果的组合,共 36 种。假设骰子质地均匀,则每个结果出现的概率相等,均为 1/36
  • 例子二:快速排序的随机主元选择。我们只关注快速排序最外层调用中随机选择主元的过程。假设数组长度为 n,那么样本空间就是所有可能的 n 个主元选择(对应数组索引 1 到 n)。根据算法定义,每个主元被选中的概率相等,均为 1/n

事件

上一节我们定义了样本空间,本节中我们来看看事件。

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

以下是两个练*,帮助你熟悉这些概念,特别是计算我们两个例子中的事件概率。

练*一:掷两个骰子
考虑“两个骰子点数之和等于 7”这个事件。其概率是多少?

答案:1/6

解释:
该事件包含以下 6 个结果:(1,6), (2,5), (3,4), (4,3), (5,2), (6,1)。每个结果的概率为 1/36,因此事件概率为 6 * (1/36) = 1/6。

练*二:快速排序的随机主元
在快速排序最外层调用中,随机选择一个主元。我们希望得到一个“合理”的划分,即划分后的两个子数组大小都至少是原数组的 25%(即 25-75 划分或更好)。随机选择的主元满足此条件的概率是多少?

答案:1/2

解释:
我们希望主元既不在最小的 25% 元素中,也不在最大的 25% 元素中。换句话说,主元需要来自中间 50% 的元素。在 n 个均匀随机的选择中,满足条件的数量约为 n/2。因此,概率为 (n/2) / n = 1/2。


随机变量

上一节我们讨论了事件及其概率,本节中我们来看看随机变量。

随机变量本质上是衡量随机结果某种统计量的函数。形式上,它是定义在样本空间 Ω 上的实值函数。给定一个随机结果(即一个具体的“世界状态”),它会输出一个数值。

在算法设计中,我们最常关心的随机变量是随机化算法的运行时间。例如,在快速排序算法中,运行时间就是一个随机变量:如果我们知道代码所有随机选择(如抛硬币)的结果,就能确定一个具体的运行时间(例如多少毫秒)。

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

  • 掷两个骰子:一个简单的随机变量是输入两个骰子的结果,输出它们的点数之和。这个随机变量可以取 2 到 12 之间的整数值。
  • 快速排序的随机主元:考虑一个随机变量,它表示传递给第一个递归调用的子数组的大小(即元素个数)。等价地,它是输入数组中小于随机选择的主元的元素数量。这个随机变量可以取 0(选择最小元素)到 n-1(选择最大元素)之间的整数值。

期望值

上一节我们引入了随机变量,本节中我们来看看它的一个核心特征——期望值。

随机变量的期望值就是它的平均值,并且是按照各种可能结果的概率进行加权后的平均值。

考虑一个随机变量 X。它的期望值(也称预期值)记作 E[X]。数学上,它定义为对所有可能结果 i 求和:E[X] = Σ_i (X(i) * P(i)),其中 X(i) 是结果 i 发生时 X 的值,P(i) 是结果 i 发生的概率。

以下是两个练*,要求你计算上一节定义的两个随机变量的期望值。

练*三:两个骰子点数之和的期望值
两个骰子点数之和的平均值(期望值)是多少?

答案:7

解释:
有多种方法计算。最直接的是利用期望的线性性(下一节概念)。也可以暴力枚举所有 36 种结果并求和,或者利用对称性配对(如和为2与和为12的概率相同,平均为7等)。最终结果是 7。

练*四:快速排序递归调用子数组大小的期望值
在快速排序最外层调用中,传递给第一个递归调用的子数组的平均大小(期望值)是多少?等价地,平均有多少个元素小于随机选择的主元?

答案:(n-1)/2

解释:
可以直接根据期望定义计算。设随机变量 X 为子数组大小。选择第 k 小的元素作为主元的概率是 1/n,此时子数组大小为 k-1。因此,期望值为:
E[X] = Σ_{k=1}^{n} ( (k-1) * (1/n) ) = (0 + 1 + 2 + ... + (n-1)) / n = (n-1)/2
直观上,由于两个递归调用是对称的,它们总共包含 n-1 个元素,因此每个的期望大小约为一半。


期望的线性性

上一节我们定义了期望值,本节中我们来看看一个极其重要的性质——期望的线性性。

期望的线性性是一个非常简单但超级重要的性质,在分析随机化算法和随机过程中无处不在。

定理(期望的线性性):
假设有一组定义在同一个样本空间上的随机变量 X1, X2, ..., Xn。那么,这些随机变量之和的期望值等于它们各自期望值的和。即:
E[X1 + X2 + ... + Xn] = E[X1] + E[X2] + ... + E[Xn]

这个性质之所以如此有用,是因为它总是成立,无论这些随机变量之间是否相互独立。这一点非常强大,因为对于乘积,如果没有独立性,类似的等式通常不成立。

证明思路:
证明非常简单,本质上只是交换求和顺序。从等式右边开始,写出每个期望的定义(对结果求和),然后交换两个求和的顺序,即可得到左边和的期望的定义。

一个简单示例:
回顾计算两个骰子点数之和的期望。定义 X1 为第一个骰子的点数,X2 为第二个骰子的点数。易知 E[X1] = E[X2] = 3.5。根据线性性,E[X1+X2] = E[X1] + E[X2] = 3.5 + 3.5 = 7。这比直接枚举 36 种结果要简单得多。


应用示例:负载均衡

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

问题描述:
n 个计算进程需要分配到 n 台服务器上。我们采用一种极其“懒惰”的策略:将每个进程独立地、随机地分配到任意一台服务器,每台服务器被选中的概率均为 1/n。问题是:从平均(期望)来看,这种随机分配的策略效果如何?具体来说,一台服务器(比如第一台)的期望负载(即分配到的进程数)是多少?

解决方案:

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

  2. 定义随机变量:我们关心第一台服务器的负载。设随机变量 Y 为分配给第一台服务器的进程数量。

  3. 利用期望的线性性:直接计算 Y 的期望需要枚举 n^n 种结果,不可行。我们引入指示随机变量。对于每个进程 j (1 ≤ j ≤ n),定义:
    Xj = 1(如果进程 j 被分配到第一台服务器),否则 Xj = 0
    显然,Y = X1 + X2 + ... + Xn

  4. 计算单个指示变量的期望:对于任意进程 j,它被分配到第一台服务器的概率是 1/n。因此,E[Xj] = 0 * P(Xj=0) + 1 * P(Xj=1) = 1/n

  5. 应用线性性E[Y] = E[X1] + E[X2] + ... + E[Xn] = n * (1/n) = 1

结论:
尽管分配策略是完全随机的,但平均来看,每台服务器的期望负载仅为 1。这表明,至少在期望意义上,这种简单的随机分配策略效率很高,每台服务器平均只承担一个进程。这体现了随机化在算法设计中的威力:通过简单的随机选择,往往能获得良好的平均性能。快速排序算法(通过随机选择主元获得高效平均性能)正是这一思想的典型代表。


总结

本节课我们一起回顾了概率论的几个基础概念:

  1. 样本空间:所有可能结果的集合。
  2. 事件与概率:样本空间的子集及其发生可能性。
  3. 随机变量:将随机结果映射为实数的函数。
  4. 期望值:随机变量的加权平均值。
  5. 期望的线性性:和之期望等于期望之和,这是一个极其强大且常用的工具。

我们通过掷骰子和快速排序的例子理解了这些概念,并最终在负载均衡问题中应用它们,展示了随机化策略的平均效果分析。这些工具是分析随机化算法(如快速排序、随机最小割、哈希表性能)的基石。

031:比较排序的Ω(n log n)下界

概述

在本节中,我们将探讨一个关于排序算法性能的根本性问题:我们能否设计出比 O(n log n) 更快的排序算法?我们将聚焦于比较排序这一特定算法类别,并证明其性能存在一个下界,即任何正确的比较排序算法在最坏情况下都需要至少 Ω(n log n) 次比较。这意味着像归并排序和快速排序这样的算法,在比较排序的范畴内,已经达到了理论上的最优效率。

什么是比较排序?

上一节我们介绍了排序算法的基本概念,本节中我们来看看一个重要的分类:比较排序。

比较排序算法是指那些仅通过比较元素对来访问输入数组中元素的算法。这类算法不直接操作单个元素的值,也不对数据的分布做任何假设。它们只关心元素之间的相对顺序,通过一个“比较”API来工作。你可以将其想象成一个通用排序函数,它接收一个用于比较抽象数据类型的函数指针。

以下是我们课程中讨论过的比较排序算法示例:

  • 归并排序:仅通过比较和复制元素来工作。
  • 快速排序:仅通过比较和交换元素来工作。
  • 堆排序(后续会学到):通过构建堆并提取最小元素来工作,也仅使用比较。

非比较排序示例

为了更清晰地理解比较排序的限制,我们来看几个非比较排序的例子。这些算法通过直接查看元素的值(而非仅比较)来工作,因此可以绕过 n log n 的下界,但通常需要对数据做出额外假设。

以下是几种著名的非比较排序算法:

  • 桶排序:假设数据在某个区间(如[0,1])上均匀分布。算法根据元素值将其分配到不同的“桶”中,然后对每个小桶单独排序。在理想分布下,其时间复杂度可达 O(n)
  • 计数排序:假设数据是范围有限的小整数(例如0到K,K=O(n))。算法统计每个值出现的次数,然后直接按顺序输出。其时间复杂度为 O(n + K)
  • 基数排序:假设数据是整数。算法从最低位到最高位,逐位进行稳定排序(常以内层调用计数排序实现)。对于位数有限的整数,其时间复杂度也可达 O(n)

总结来说,比较排序只能通过比较API访问数据,不能进行类似上述算法的“分桶”操作。当你可以对数据做出特定假设时,非比较排序可能更快。但如果你需要一个通用的、不依赖数据特性的排序程序,那么比较排序的 n log n 下界是无法避免的。

Ω(n log n)下界证明

现在,我们来理解为什么任何正确的确定性比较排序算法在最坏情况下都需要 Ω(n log n) 次比较。我们将通过一个概念性的论证来阐明其核心思想。

我们考虑一个固定的比较排序算法,并专注于一个特定的输入长度 n。由于算法只关心顺序,我们可以假设输入数组是数字 1 到 n 的某种排列。总共有 n!(n的阶乘)种不同的可能输入。

K 是该算法在最坏情况下执行的比较次数。对于每一个输入,算法会产生一个由比较结果(是/否)组成的序列,长度不超过 K。因此,算法所有可能的执行路径最多有 2^K 种(每个比较有两种可能结果)。

关键矛盾点:如果 2^K < n!,即可能的执行路径数少于可能的输入数,那么根据鸽巢原理,至少有两个不同的输入会引导算法走完全相同的执行路径,得到完全相同的比较结果序列。对于算法而言,这两个输入是无法区分的。因此,如果算法能正确排序其中一个输入,它必然会对另一个输入输出错误的结果。这与算法的正确性矛盾。

由此,我们得出结论:2^K ≥ n!

为了得到 K 的下界,我们对 n! 进行一个简单的下界估计:
n! ≥ (n/2)^(n/2)
取以2为底的对数:
K ≥ log₂( (n/2)^(n/2) ) = (n/2) * log₂(n/2)
这证明了 K = Ω(n log n)。因此,任何正确的确定性比较排序算法在最坏情况下都必须进行至少 Ω(n log n) 次比较。

总结

本节课中我们一起学*了比较排序算法的性能下界。我们首先明确了比较排序的定义,并将其与桶排序、计数排序等非比较排序进行了区分。随后,我们通过基于鸽巢原理执行路径分析的论证,证明了任何正确的确定性比较排序算法在最坏情况下都需要 Ω(n log n) 次比较。这一重要结论意味着,像归并排序和堆排序(最坏情况 O(n log n))以及快速排序(平均情况 O(n log n))这样的算法,在比较排序的框架内已经达到了理论上的最优效率。

032:随机选择算法 🎲

在本节课中,我们将学*一个与排序紧密相关的重要问题:选择问题。我们将设计并分析一个极其实用的随机算法来解决它,并证明其期望运行时间是线性的,即对于长度为 n 的输入数组,运行时间为 O(n)。这与快速排序的 O(n log n) 期望运行时间形成对比。此外,我们还将探讨如何在不使用随机化的情况下,在线性时间内确定性地解决选择问题。

选择问题定义

选择问题的输入与排序问题相同:给定一个包含 n 个不同元素的数组。此外,还需要指定一个介于 1n 之间的整数 i,表示要查找的顺序统计量。目标是输出数组中第 i 小的元素。

公式定义
给定数组 A 和整数 i1 ≤ i ≤ n),找到元素 x,使得恰好有 i-1 个元素小于 x

例如,对于数组 [10, 8, 2, 4],第三顺序统计量是 8。第一顺序统计量是最小值,第 n 顺序统计量是最大值。中位数是典型的选择问题,当 n 为奇数时,中位数是第 (n+1)/2 小的元素;当 n 为偶数时,通常取第 n/2 小的元素。

中位数比平均值更稳健,因为单个异常值会严重影响平均值,但对中位数影响很小。本教程假设数组元素互异,但算法可以轻松适配包含重复元素的情况。

现有解决方案:归约到排序

我们已有一个相当不错的算法来解决选择问题,它通过归约到排序问题来实现。

以下是该算法的两个简单步骤,其运行时间为 O(n log n)

  1. 对输入数组进行排序(例如使用归并排序)。
  2. 返回排序后数组的第 i 个元素。

这展示了计算机科学中一个超级有用且基础的概念:归约。我们通过将选择问题归约到已知如何解决的排序问题,从而获得了 O(n log n) 的解决方案。

然而,优秀的算法设计师总会问:我们能做得更好吗?线性时间似乎是可能的目标,因为最坏情况下我们至少需要查看所有元素。但对于排序问题,我们有充分的理由满足于 O(n log n) 的时间上界。事实上,对于所谓的比较排序,你无法在平均或最坏情况下做得比 O(n log n) 更好(这将在可选视频中详细说明)。

因此,如果我们想为选择问题获得优于 O(n log n) 的通用算法,就必须证明选择在根本上比排序更容易,从而设计出线性时间算法。这正是我们接下来要做的。

随机选择算法设计

我们将展示选择确实比排序更简单,可以设计出线性时间算法。这个算法可以看作是快速排序范式的修改,同样是一个随机算法,其期望运行时间对于任何输入数组都是线性的。

对于排序,我们有像归并排序这样的确定性 O(n log n) 算法。那么选择是否有类似的不使用随机化的线性时间算法呢?答案是肯定的,但算法更复杂,因此不如随机算法实用。其核心思想是使用一种称为“中位数的中位数”的技巧来确定性选择主元。我们将在可选视频中讨论它。

重要提示:如果你要实际实现一个选择算法,应该使用本视频讨论的随机版本,因为它常数更小且是原地操作。

接下来,我们将开发如何修改快速排序范式来直接解决选择问题。

回顾分区子程序

与快速排序一样,分区子程序是我们选择算法的主力。它的输入是一个乱序数组,其任务比排序更简单:首先选择一个主元元素,然后重新排列数组,使得:

  • 所有小于主元的元素都在其左侧(可以是乱序)。
  • 所有大于主元的元素都在其右侧(可以是乱序)。

这会将主元放置在其在最终排序数组中应有的正确位置。对于快速排序,这使我们能够递归地对两个较小的子问题进行排序。

适应选择问题

现在,假设我们选择算法的第一步是选择一个主元并对数组进行分区。关键问题是:我们该如何递归?我们需要理解如何通过在一个较小规模的子问题中递归查找合适的顺序统计量,来找到原始输入数组的第 i 顺序统计量。

让我们通过一个具体例子来思考。假设有一个包含10个元素的数组,我们选择了一个主元,分区后主元位于第3的位置(即它是第三小的元素)。而我们原本要查找的是第五小的元素。

由于主元是第三小的元素,而我们要找的第五小的元素肯定比它大,因此目标元素必然位于主元的右侧。所以,我们应该在右侧子数组上递归。那么,在右侧子数组中我们要找的是第几小的元素呢?我们丢弃了主元及其左侧的所有元素(共3个),这些元素都小于目标元素。因此,在剩下的元素中,我们要找的就是第 5 - 3 = 2 小的元素。

随机选择算法描述

我们将这个算法称为 RSelect(随机选择)。

算法输入

  • 数组 A,长度为 n
  • 整数 i1 ≤ i ≤ n,表示要查找的顺序统计量

算法步骤

  1. 基准情况:如果 n == 1,则返回数组中唯一的元素。
  2. 选择主元:从数组的 n 个元素中均匀随机地选择一个作为主元 p
  3. 分区:围绕主元 p 对数组进行分区。分区后,设:
    • j = 主元 p 在分区后数组中的位置(即它是第 j 小的元素)。
    • 左子数组 L 包含所有小于 p 的元素。
    • 右子数组 R 包含所有大于 p 的元素。
  4. 递归选择
    • 如果 j == i,说明主元恰好就是我们要找的第 i 小元素,直接返回 p
    • 如果 j > i,说明目标元素在左子数组 L 中。在 L 上递归调用 RSelect,查找第 i 小的元素。
    • 如果 j < i,说明目标元素在右子数组 R 中。在 R 上递归调用 RSelect,查找第 i - j 小的元素。

代码框架

def RSelect(A, i):
    n = len(A)
    if n == 1:
        return A[0]
    # 随机选择主元索引
    pivot_index = random.randint(0, n-1)
    p = A[pivot_index]
    # 分区操作,返回主元最终位置 j
    j = partition(A, p) # partition 函数需实现标准分区逻辑
    if j == i:
        return p
    elif j > i:
        return RSelect(A[:j], i) # 递归左子数组
    else: # j < i
        return RSelect(A[j+1:], i - j) # 递归右子数组

算法正确性与运行时间分析

正确性

该算法的正确性可以通过归纳法证明,其思路与快速排序正确性证明类似。无论算法的随机硬币翻转结果如何,无论选择什么随机主元,该算法都能保证输出正确的第 i 顺序统计量。

运行时间与主元质量

与快速排序一样,该算法的运行速度取决于主元的质量。“好”的主元能产生平衡的分割,从而保证每次递归时问题规模都能显著减小。

最坏情况:如果每次都非常不幸地选择了最小(或最大)元素作为主元,那么每次递归只能从输入数组中剥离一个元素。为了找到中位数,你需要进行大约 n/2 次递归调用,每次调用处理的数组大小至少是原始数组的常数倍。这将导致总运行时间达到 O(n²)。虽然这种情况发生的概率极低,但理论上存在。

最好情况(直觉):如果每次都能幸运地选择中位数作为主元,那么每次递归调用都处理一个大小至多为 n/2 的子问题。这会产生递归式 T(n) ≤ T(n/2) + O(n)。根据主定理(情况2),这确实能得出 T(n) = O(n),即线性时间。

期望运行时间声明

我们声称,对于任意长度为 n 的输入数组,随机选择算法的期望运行时间是线性的,即 O(n)

需要强调的是:

  1. 我们对数据没有任何假设,输入数组可以是任意的。这个保证适用于你输入该随机算法的任何数组,因此它是一个完全通用的子程序。
  2. 期望值来源于算法代码内部的随机性(随机选择主元),而不是输入数据的随机分布。

在下一节中,我们将深入分析并证明这一线性期望运行时间。

总结

本节课中,我们一起学*了选择问题及其一个非常实用的随机算法解决方案。我们首先定义了选择问题,并回顾了通过归约到排序的 O(n log n) 解决方案。接着,我们探讨了能否获得线性时间算法,并引入了随机选择算法 RSelect。该算法通过随机选择主元、分区,并仅在包含目标元素的那个子数组上递归,巧妙地修改了快速排序的范式。我们讨论了算法的正确性,并分析了其运行时间如何依赖于主元的质量,最后声明了其线性期望运行时间。在接下来的课程中,我们将完成对这一线性时间界的数学证明。

033:随机选择算法分析

在本节课中,我们将要学*随机线性时间选择算法的数学分析。我们将证明,对于任意长度为N的输入数组,该算法的平均运行时间是线性的。这非常了不起,因为它几乎不比读取输入所需的时间多,并且比排序更快。这表明选择问题本质上比排序问题更容易,无需先排序,可以直接在O(n)时间内解决。

算法回顾

上一节我们介绍了随机选择算法的基本思想,本节中我们来看看其具体的分析过程。首先,让我们回顾一下算法步骤。

该算法与快速排序类似,围绕一个枢轴进行分区,但只进行一次递归,而不是两次。给定一个长度为n的数组,我们要寻找第i小的元素(第i阶统计量)。

以下是算法的核心步骤:

  1. 基本情况:如果数组只有一个元素,则直接返回该元素。
  2. 选择枢轴:从输入数组中均匀随机地选择一个元素作为枢轴P。
  3. 分区:围绕枢轴P对数组进行分区,将数组分为小于P的元素和大于P的元素两部分。
  4. 判断与递归
    • 设枢轴P在分区后的新位置为j。
    • 如果 j == i,则P就是我们要找的第i小元素,直接返回P。
    • 如果 j > i,说明目标元素在枢轴左侧(较小的部分)。我们在左侧子数组(长度为 j-1)上递归寻找第i小的元素。
    • 如果 j < i,说明目标元素在枢轴右侧(较大的部分)。我们在右侧子数组(长度为 n-j)上递归寻找第 (i-j) 小的元素。

分析思路

我们可能会想沿用分析快速排序时的方法,即定义指示器随机变量来计算元素比较的期望次数。虽然这也能得出线性时间的平均界,但过程稍显繁琐。

由于选择问题的特殊结构(只进行一次递归),我们可以采用一种更巧妙的方法。算法的核心工作与快速排序相同,都是分区(partition)子程序,其时间复杂度为线性,记为 O(n)。为了清晰,我们用一个常数 C 来表示分区操作及递归调用外的其他工作,即每次递归调用外的工作量不超过 C * (当前数组长度)

我们的目标是追踪算法递归过程中数组长度的缩减进度。为此,我们引入“阶段”的概念。

核心概念:阶段 (Phase)

我们定义算法的执行处于 阶段j,如果当前递归调用处理的数组长度 m 满足:

(3/4)^(j+1) * n <= m < (3/4)^j * n

其中 n 是原始输入数组的长度。

公式解释

  • j=0 时,阶段0的数组长度在 (3/4)*nn 之间。最外层的递归调用必然处于阶段0。
  • 阶段编号 j 越大,对应的数组长度上限 (3/4)^j * n 越小,表示算法取得了越多的进展(数组被缩减得越多)。
  • 从一个阶段进入下一个更高级的阶段,意味着数组长度至少缩减了25%。

我们定义随机变量 X_j,它表示算法在整个执行过程中,处于阶段 j 的递归调用的总次数。

运行时间上界

基于阶段和 X_j 的定义,我们可以对算法的总运行时间 T 给出一个上界。

在每个阶段 j 的递归调用中:

  1. 该次调用外的工作量不超过 C * (当前数组长度)
  2. 根据阶段定义,阶段 j 中的数组长度不超过 (3/4)^j * n

因此,算法总运行时间满足:

T <= Σ_j [ X_j * C * ( (3/4)^j * n ) ]

我们称这个上界为不等式 (★)。注意,TX_j 都是随机变量,其值取决于算法运行时随机选择的枢轴。

我们的目标是求期望运行时间 E[T]。根据期望的线性性质,我们有:

E[T] <= Σ_j [ E[X_j] * C * ( (3/4)^j * n ) ]

关键步骤:期望 E[X_j] 的上界

现在,问题的核心转化为估算每个阶段 j 中递归调用次数的期望值 E[X_j]

这里需要一个重要的观察:如果一个枢轴产生了25-75或更好的分割(即分区后,左右两部分都至少包含25%且至多包含75%的元素),那么无论接下来算法递归到哪一边,新的子问题规模都将不超过原问题的75%。这保证了本次递归调用结束后,算法一定会进入一个编号更大的阶段(即 j 会增加)。

在随机选择枢轴时,获得这样一个“好枢轴”的概率是多少?对于一个元素各不相同的数组,恰好有50%的元素(排名在25%到75%之间的元素)可以作为这样的好枢轴。因此,每次递归调用中,选到好枢轴的概率至少是1/2

于是,我们可以将“阶段 j 中的递归过程”与一个简单的抛硬币实验联系起来:

  • 抛到正面:对应选到一个好枢轴,导致阶段 j 结束。
  • 抛到反面:对应选到一个坏枢轴,算法可能仍停留在阶段 j(考虑最坏情况)。

那么,阶段 j 中递归调用的次数 X_j,其期望值 E[X_j] 就不超过“持续抛一枚公平硬币,直到第一次出现正面所需抛掷次数”的期望值。

抛硬币实验的期望

设随机变量 N 为抛一枚公平硬币直到第一次出现正面所需的抛掷次数。计算其期望 E[N] 有一个巧妙的方法:

考虑第一次抛掷的结果:

  • 有1/2的概率得到正面,此时 N=1
  • 有1/2的概率得到反面,此时我们需要重新开始抛掷,期望抛掷次数为 1 + E[N](1代表第一次已抛,E[N]代表重新开始后的期望次数)。

因此,我们可以建立方程:

E[N] = (1/2) * 1 + (1/2) * (1 + E[N])

解这个方程:

E[N] = 1/2 + 1/2 + (1/2)E[N]
E[N] - (1/2)E[N] = 1
(1/2)E[N] = 1
E[N] = 2

所以,平均需要抛掷2次才能看到第一次正面。由此我们得到结论:对于任意阶段 jE[X_j] <= 2

完成证明

现在,我们将 E[X_j] <= 2 代入之前得到的期望运行时间上界:

E[T] <= Σ_j [ E[X_j] * C * ( (3/4)^j * n ) ]
     <= Σ_j [ 2 * C * ( (3/4)^j * n ) ]
     = 2 * C * n * Σ_j [ (3/4)^j ]

剩下的工作是计算几何级数 Σ_j (3/4)^j 的和。这是一个公比为 r = 3/4 < 1 的无穷几何级数,其和为 1 / (1 - r)

Σ_{j=0}^{∞} (3/4)^j = 1 / (1 - 3/4) = 1 / (1/4) = 4

因此,

E[T] <= 2 * C * n * 4 = 8 * C * n

由于 C 是一个常数,我们证明了随机选择算法的期望运行时间是 O(n),即线性的。

总结

本节课中我们一起学*了随机线性时间选择算法的严格数学分析。我们通过引入“阶段”的概念来追踪算法进度,并将阶段内的递归调用次数与抛硬币实验类比。利用期望的线性性质和一个几何级数的求和,我们最终证明了对于任何输入数组,该算法的平均运行时间都是线性的(O(n))。这个结果非常强大,它表明我们可以在不比读取输入多太多时间的情况下,解决选择问题,且无需先对数组进行排序。

034:确定性选择算法详解 🧠

在本节课中,我们将学*一种用于解决选择问题的确定性算法。选择问题是指,给定一个数组和一个顺序统计量 i,我们需要找出数组中第 i 小的元素。我们将深入探讨这个算法的设计思路、工作原理,并分析其时间复杂度。


回顾随机选择算法

上一节我们介绍了随机选择算法(RSelect)。该算法在实践中非常高效,其期望运行时间为 O(n)。该算法的核心是随机选择一个主元(pivot),然后根据主元对数组进行分区,并递归地在相应的子数组中继续查找。

然而,随机选择算法在最坏情况下的运行时间可能达到 O(n²)。本节中,我们将探讨一种确定性选择算法(DSelect),它能在最坏情况下保证 O(n) 的运行时间,且完全不依赖随机性。


确定性选择算法的核心思想

确定性选择算法的关键在于如何确定性地选择一个“好”的主元。一个好的主元是指,在分区后能产生一个相对平衡的划分,即左右两部分的元素数量大致相等。

中位数的中位数

算法的核心策略是使用“中位数的中位数”作为主元的*似值。具体步骤如下:

  1. 分组:将输入数组 A(长度为 n)划分为 ⌈n/5⌉ 个小组,每组包含 5 个元素(最后一组可能少于 5 个)。
  2. 寻找小组中位数:对每个小组进行排序(例如使用归并排序),并找出每个小组的中位数(即第 3 小的元素)。
  3. 递归寻找中位数:将所有小组的中位数收集到一个新数组 C 中。然后,递归地调用选择算法本身,找出数组 C 的中位数。这个中位数就是我们的主元 p

以下是选择主元步骤的伪代码描述:

def choose_pivot(A):
    # 1. 将数组A分成每组5个元素
    groups = split_into_groups_of_five(A)
    # 2. 对每组排序并取中位数
    medians = [sorted(group)[len(group)//2] for group in groups]
    # 3. 递归地找出中位数的中位数
    pivot = select(medians, len(medians)//2)
    return pivot

确定性选择算法完整流程

现在,我们将选择主元的步骤整合到完整的选择算法框架中。以下是确定性选择算法 DSelect 的步骤概述:

  1. 基础情况:如果数组长度 n 为 1,则直接返回该元素。
  2. 选择主元:使用上述“中位数的中位数”方法确定性地选择一个主元 p
  3. 分区:围绕主元 p 对数组进行分区,将所有小于 p 的元素移到其左侧,大于 p 的元素移到其右侧。设主元最终位于位置 j
  4. 递归搜索
    • 如果 j == i,那么主元 p 就是我们要找的第 i 小元素,直接返回 p
    • 如果 j > i,则第 i 小元素位于主元左侧的子数组中。我们在左侧子数组上递归调用 DSelect,寻找第 i 小的元素。
    • 如果 j < i,则第 i 小元素位于主元右侧的子数组中。我们在右侧子数组上递归调用 DSelect,寻找第 i - j 小的元素。

以下是算法的递归调用结构示意图:

DSelect(A, i):
    if n == 1: return A[0]
    p = choose_pivot(A)        // 递归调用 #1
    j = partition(A, p)
    if j == i: return p
    elif j > i: return DSelect(A[0:j-1], i)      // 递归调用 #2
    else: return DSelect(A[j+1:n], i-j)          // 递归调用 #2

重要提示:与随机选择算法只有一个递归调用不同,确定性选择算法在每次调用中可能进行两次递归调用。一次用于选择主元(choose_pivot 内部),另一次用于在分区后的子数组中继续搜索。


算法性能与特点

时间复杂度分析

确定性选择算法最精妙的部分在于其时间复杂度分析。通过选择“中位数的中位数”作为主元,可以保证每次分区后,至少有 3n/10 个元素被确定性地排除在下一轮递归之外。这确保了递归问题的规模以几何级数递减。

最终,通过递归树分析,可以证明算法的运行时间满足以下递归式:
T(n) ≤ T(n/5) + T(7n/10) + O(n)

该递归式的解为 T(n) = O(n)。这意味着,对于任何输入,确定性选择算法都能在最坏情况下以线性时间运行

与随机选择算法的比较

以下是两种算法的关键对比:

  • 运行时间保证
    • RSelect期望 O(n),最坏 O(n²)
    • DSelect最坏情况 O(n)
  • 实践性能
    • RSelect 通常更快,因为其隐藏常数更小,且是原地操作(无需额外内存)。
    • DSelect 由于需要创建额外数组 C 来存储中位数,并且递归调用开销更大,所以实际常数因子更大。
  • 算法思想
    • RSelect 利用随机性作为“平衡划分”的廉价代理。
    • DSelect 通过精巧的确定性结构(中位数的中位数)来保证平衡划分。

总结与历史背景

本节课中我们一起学*了确定性选择算法。我们了解到,通过“中位数的中位数”这一巧妙设计,我们可以在完全不依赖随机性的情况下,解决选择问题并保证最坏情况下的线性时间复杂度。

尽管在实践中随机选择算法更常被使用,但确定性选择算法以其理论上的优雅和强大的最坏情况保证,在算法史上占有重要地位。该算法由五位杰出的计算机科学家(Blum, Floyd, Pratt, Rivest, Tarjan)于1973年提出,其中四位后来都获得了图灵奖,这足以证明其思想的深刻性与影响力。

总而言之,确定性选择算法展示了如何通过深入的结构性分析来克服随机算法的局限性,是算法设计中“以巧破力”的典范。

035:确定性选择算法分析(第一部分) 🧮

在本节中,我们将深入分析由Blum、Floyd、Pratt、Rivest和Tarjan提出的确定性选择算法。我们的目标是证明该算法在任何输入上都能在线性时间内运行。我们将首先回顾算法步骤,然后逐步分析其时间复杂度,并证明其核心引理:算法保证能找到一个“30-70分割”或更好的枢轴元素。

算法回顾 🔄

上一节我们介绍了随机选择算法,本节中我们来看看其确定性版本。该算法的核心思想是,不再随机选择枢轴,而是通过一个精心设计的子程序来选择一个有质量保证的枢轴。

以下是 choose_pivot 子程序的关键步骤,它本质上实现了一个两轮淘汰赛:

  1. 第一轮比赛:将输入数组 A 划分为若干组,每组5个元素(最后一组可能少于5个)。对每组内的5个元素进行排序(例如使用归并排序)。每组排序后的中位数(即第三大的元素)成为该组的“胜者”。
  2. 收集胜者:将所有 n/5 个第一轮胜者(即各组的中位数)复制到一个新数组 C 中。
  3. 第二轮比赛(决赛):递归地在数组 C 上调用 DSelect 算法,以找到 C 的中位数。这个中位数就是我们的最终枢轴 p
  4. 得到枢轴 p 后,算法流程与随机选择算法相同:围绕 p 对原数组 A 进行划分,得到左右两部分,然后根据目标顺序统计量的位置,递归地在左侧或右侧子数组中继续查找。

时间复杂度初步分析 ⏱️

现在,让我们分析这个算法的工作量。我们将采用分析确定性分治算法的经典范式:建立递归式。递归式 T(n) 表示算法在长度为 n 的输入上的最大操作数,它由两部分组成:递归调用在更小子问题上的工作,以及递归调用之外本地完成的工作。

以下是算法各步骤的工作量估算:

  • 步骤1:排序每组5个元素。排序一个长度为5的数组是常数时间操作(例如,归并排序约需120次操作)。我们有 n/5 组,因此总时间为 O(n)
  • 步骤2:复制胜者到数组C。这显然是 O(n) 时间。
  • 步骤3:递归调用寻找中位数。这是在数组 C 上递归调用 DSelectC 的长度为 n/5。因此,这部分工作量记为 T(n/5)
  • 步骤4:围绕枢轴划分。划分操作是线性时间的,即 O(n)
  • 步骤6或7:在子数组上递归。这里有一次递归调用,但其输入规模未知,取决于划分后子数组的大小。我们暂时将其记为 T(?)

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

T(1) = 1
T(n) ≤ c*n + T(n/5) + T(?)   (对于某个常数 c > 0)

我们似乎卡住了,因为 T(?) 的大小未知。为了解决这个问题,我们需要一个关键引理来界定这个未知递归调用的大小。

关键引理:保证良好的分割比 🔑

上一节我们列出了递归式,但被未知的递归规模所阻碍。本节中我们来看看一个关键引理,它将证明我们精心选择的枢轴能保证产生一个良好的分割。

引理:由 choose_pivot 子程序选择的枢轴 p,能保证将原数组划分为两部分,使得每一部分至少包含 30% 的元素。也就是说,我们得到了一个“30-70分割”或更好的结果。

证明思路
为了证明至少有30%的元素小于 p,我们进行一个思维实验。将 n 个元素排列成一个 5 行、n/5 列的网格:

  • 每列对应一个5元素组,且列内元素从下到上递增(最小在底部)。
  • 每列的中间元素(即第三行)是该组的中位数(第一轮胜者),我们用特殊标记表示。
  • 各列从左到右按照其中位数的值递增排列。

k = n/5x_i 为第 i 小的中位数。那么我们的枢轴 p 就是 x_{k/2}(即所有中位数的中位数)。

现在考虑网格中位于 p 西南方向(即左下方)的所有元素:

  1. 由于列按中位数排序,p 左边的所有中位数 x_1, x_2, ..., x_{k/2 - 1} 都小于 p
  2. 在每一列中,中位数下方的两个元素也小于该列的中位数。
  3. 根据“小于”关系的传递性,p 左边各列的中位数下方的两个元素,必然也小于 p

因此,整个西南区域(大约占网格的 (k/2) * 3 ≈ (n/10) * 3 = 3n/10 个元素)都小于 p。这证明了至少有约 30% 的元素小于枢轴。

对称地,考虑 p 东北方向(即右上方)的所有元素,可以证明至少有约 30% 的元素大于枢轴。

因此,无论递归调用发生在划分后的哪一边,其输入规模最多为原数组的 70%。我们可以用 T(7n/10) 来替换递归式中的 T(?)

本节总结 📝

本节课中我们一起学*了确定性选择算法时间分析的第一个关键部分。

  1. 算法回顾:我们回顾了通过“中位数的中位数”方法选择枢轴的确定性选择算法步骤。
  2. 建立递归式:我们分析了算法各步骤的复杂度,并建立了包含未知子问题规模的递归式:T(n) ≤ c*n + T(n/5) + T(?)
  3. 证明关键引理:我们通过巧妙的网格思维实验,证明了算法选择的枢轴能保证至少产生一个“30-70分割”。这意味着递归式中未知的部分 T(?) 可以替换为 T(7n/10)

至此,我们得到了一个完整的递归关系:T(n) ≤ c*n + T(n/5) + T(7n/10)。在下一节中,我们将求解这个递归式,最终证明 T(n) = O(n),即确定性选择算法在线性时间内运行。虽然为了得到这个良好的枢轴我们付出了额外的递归代价(T(n/5)),但下一节的分析将表明,这个代价是可控的,不会影响整体的线性时间复杂度。

036:确定性选择算法分析(第二部分)🎯

在本节课中,我们将完成确定性选择算法线性时间复杂度的证明。我们已经讨论了算法核心思想——通过“中位数的中位数”方法选择枢轴,并证明了该枢轴能保证至少30-70的分割比。本节我们将分析算法的整体运行时间,并证明其确实为线性时间复杂度。


递归关系式回顾

上一节我们介绍了确定性选择算法(dselect)的递归结构。现在,我们来正式定义其运行时间。

T(n) 表示 dselect 算法在输入数组长度为 n 时的最坏情况运行时间。

在递归调用之外,算法执行以下线性时间操作:

  1. 将数组逻辑分组(每组5个元素)并排序。
  2. 复制中位数数组。
  3. 根据枢轴进行划分(Partitioning)。

这些步骤的总工作量不超过 c * n,其中 c 是一个大于1的常数。

算法包含两次递归调用:

  1. 第一次递归调用(第3行):用于计算“中位数的中位数”作为枢轴。它总是在 n/5 大小的数组上执行。
  2. 第二次递归调用(第6或7行):在划分后的子数组上执行。根据我们证明的关键引理(30-70引理),最坏情况下,子数组的大小不超过 0.7n

因此,我们可以得到以下递归关系式:

T(n) ≤ c*n + T(n/5) + T(0.7n)

我们的目标是求解这个递归式,并希望证明 T(n) = O(n)


求解递归式:猜测与验证法

我们之前学*的归并排序、Strassen算法等递归式都可以直接套用主定理(Master Method)求解。然而,主定理要求所有子问题的规模相同,而我们的递归式包含两个不同规模(n/50.7n)的子问题,因此无法直接应用。

这里,我们将采用一种更灵活的方法:猜测与验证法(Guess and Check)。我们首先猜测算法运行时间是线性的,然后通过数学归纳法来验证这个猜测。

我们希望证明:存在一个常数 A(与 n 无关),使得对于所有 n ≥ 1,都有 T(n) ≤ A * n

如果上述成立,根据大O符号的定义,即可证明 T(n) = O(n)

为了方便证明,我们直接给出常数 A 的选择:A = 10c。这里的 c 就是递归式中代表线性工作量的那个常数。


归纳法证明

现在,我们使用数学归纳法来证明命题:对于所有 n ≥ 1T(n) ≤ A * n,其中 A = 10c

1. 基础情况 (Base Case)

n = 1 时,根据算法定义,T(1) = 1
我们需要验证 T(1) ≤ A * 1
由于 A = 10cc ≥ 1,因此 A ≥ 10。显然,1 ≤ A 成立。基础情况得证。

2. 归纳假设 (Inductive Hypothesis)

假设对于所有 k < n,命题都成立,即 T(k) ≤ A * k

3. 归纳步骤 (Inductive Step)

我们需要证明当输入规模为 n 时,命题也成立,即 T(n) ≤ A * n

我们从递归关系式出发:
T(n) ≤ c*n + T(n/5) + T(0.7n)

根据归纳假设,因为 n/5 < n0.7n < n,我们可以将 T(n/5)T(0.7n) 用上界替换:
T(n) ≤ cn + A(n/5) + A*(0.7n)

合并含有 n 的项:
T(n) ≤ n * [c + A/5 + 0.7A]
T(n) ≤ n * [c + (0.2A + 0.7A)]
T(n) ≤ n * [c + 0.9A]

现在,代入我们选择的 A = 10c
T(n) ≤ n * [c + 0.9*(10c)]
T(n) ≤ n * [c + 9c]
T(n) ≤ n * [10c]
T(n) ≤ A * n

这正是我们需要证明的结论。归纳步骤完成。


结论

通过数学归纳法,我们成功证明了对于递归式 T(n) ≤ c*n + T(n/5) + T(0.7n),其解满足 T(n) = O(n)

由于 T(n) 代表确定性选择算法 dselect 的最坏情况运行时间,因此我们最终得出结论:基于“中位数的中位数”方法实现的确定性选择算法,其最坏情况时间复杂度是线性的,即 O(n)

本节课中,我们一起学*了如何分析一个具有不同规模子问题的递归算法。我们回顾了算法的递归结构,建立了递归关系式,并巧妙地运用“猜测与验证法”结合数学归纳法,最终证明了该确定性选择算法具备线性时间复杂度这一重要性质。

001:-01-9 图和最小割 🧩

在本节课中,我们将要学*图论中的最小割问题,并介绍一个名为“随机收缩算法”的随机算法。这个算法极其简单优雅,以至于让人难以置信它竟然有效,而这正是我们将要证明的。你可以将这几节课视为从随机化讨论到图论讨论之间的一个过渡。我们刚刚在排序和搜索的背景下结束了随机化的讨论,并将在课程后期讨论哈希时再次涉及。在复*随机化和概率的中间阶段,我想在另一个完全不同的领域——图论领域,而非排序和搜索——为你展示随机化的另一个应用。这是这几节课的一个高层次目标。

第二个目标是,我们将开始初步接触图论。在接下来的几周里,我们将讨论基础的图论原语。这给了我们一个机会,开始熟悉图的词汇、一些基本概念以及图算法的样貌。

另一个值得一提的好处是,至少与本课程中讨论的大部分内容相比,这个收缩算法是一个相对较新的算法。所谓相对较新,我的意思是它大约有20年的历史。我知道并非所有人,但至少我们中的大多数人在这个算法被发明时已经出生。在这类入门课程中,我们将要涵盖的大部分内容都是“经典佳作”,有些甚至有50年历史。尽管过去50年世界和技术发生了巨大变化,但这么久远的计算机科学思想仍然有用,这本身就很神奇。第一代计算机科学家发现的东西至今仍然相关,这令人惊叹。话虽如此,算法学仍然是一个充满活力的领域,有许多开放性问题。当有机会时,我会尝试让你一窥这个事实。因此,我想指出,这是一个相对较新的算法,可以追溯到90年代,而我们将看到的大多数其他算法则更古老。


图的基本概念

现在我们来谈谈图。从根本上说,图用于表示一组对象之间的成对关系。因此,图包含两个组成部分。

首先,是你谈论的对象。这些对象有两个非常常见的名称,你必须同时知道这两个名称,尽管它们完全同义。第一个名称是顶点。Vertex是单数,vertices是复数。它们也常被称为节点

我将用大写字母 V 表示顶点集合。这些就是对象。

现在,我们想要表示成对关系,这些对被称为,并用大写字母 E 表示。

图有两种类型,两者都非常重要,在应用中经常出现,所以你应该了解这两种类型。它们是无向图有向图,这取决于边本身是无向的还是有向的。

边可以是无向的,这意味着这个对是无序的。一条边只有两个顶点,两个端点,比如 uv,你不需要区分哪个是第一,哪个是第二。

或者,边可以是有向的,在这种情况下你得到的是一个有向图。这里,一个对是有序的,所以你确实有第一个顶点(或第一个端点)和第二个顶点(或第二个端点)的概念。它们通常分别被称为。偶尔,虽然我会尽量避免使用这个术语,你会听到有向边被称为

我认为,如果我画一些图,所有这些会清晰得多。事实上,图过去常被称为“点和线”。“点”指的是顶点,所以这里有四个点或四个顶点。“边”就是线。表示其中一条边的方法是在该边的两个端点(即它对应的两个顶点)之间画一条线。这是一个具有四个顶点和五条边的无向图。

我们同样可以拥有这个图的有向版本。让我们仍然有四个顶点和五条边。但为了表明这是一个有向图,并且每条边都有一个第一个顶点和一个第二个顶点,我们将在线上添加箭头。箭头指向第二个顶点或边的头部。因此,第一个顶点通常被称为边的尾部。

图是基础中的基础,它们不仅出现在计算机科学中,还出现在各种不同的学科中,社会科学和生物学是两个突出的例子。让我仅凭记忆列举几个你可能使用图的原因,但实际上有数百或数千种其他原因。

一个非常字面的例子是道路网络。想象一下,你在某个网络应用或软件中输入请求,要求从A点开车到B点。它为你计算路线。它所做的是操作道路网络的某种表示,而这种表示不可避免地会存储为一个图,其中顶点对应交叉路口,边对应单个道路。

万维网通常被富有成效地视为一个有向图。这里,顶点是各个网页,边对应超链接。因此,一条边的第一个顶点(尾部)将是包含超链接的页面,第二个顶点(边的头部)将是超链接指向的页面。这就是作为有向图的万维网。

社交网络很自然地表示为图。这里,顶点对应社交网络中的个体,边对应关系,比如好友链接。我鼓励你思考一下,在当今流行的社交网络中,哪些是无向图,哪些是有向图,我们有一些有趣的例子。

即使在没有明显网络结构的情况下,图也常常很有用。让我举一个例子,写下先修课程约束。我的意思是,你可能想到,比如,你是一名大学新生,正在考虑你的专业,比如计算机科学专业,你想知道要修哪些课程以及顺序如何。你可以考虑以下图:你专业中的每门课程对应一个顶点,如果课程A是课程B的先修课程(即必须在开始课程B之前完成),则从课程A到课程B画一条有向边。这是一种使用有向图表示对象之间依赖关系或时间顺序的方法。


图的割

以上就是图的基本语言。现在让我谈谈图中的,因为这几节课将要讨论所谓的最小割问题

图的一个割的定义非常简单。它只是将图的顶点分组划分成两个组,A和B,并且这两个组都应该是非空的。

为了用图片描述这一点,让我为你展示在无向图和有向图情况下割的示意图。

对于无向图,你可以想象画出你的两个集合A和B。一旦你定义了集合A和B,边就属于以下三类之一:两个端点都在A中的边;两个端点都在B中的边;以及一个端点在A中,另一个端点在B中的边。这就是从一个特定割AB的视角来看,图的通用样貌。

有向图的示意图类似。你同样有一个A和一个B。你有两个端点都在A中的有向边,两个端点都在B中的有向边。现在你实际上还有另外两类边:从左到右穿过割的边(即尾顶点在A中,头顶点在B中);以及从右到左穿过割的边(即尾顶点在B中,头顶点在A中)。

通常当我们谈论割时,我们关心的是有多少条边穿过一个给定的割。我的意思是:

割AB的交叉边是满足以下性质的边:

  • 在无向情况下,这完全符合你的想象:一个端点在A中,另一个端点在B中。这就是“穿过割”的含义。
  • 在有向情况下,关于哪些边穿过割,你可以提出许多合理的定义。通常,在本课程中,我们将专注于只考虑从左到右穿过割的边(即尾在A中,头在B中),而忽略从右到左穿过的边。

因此,参考我们的两张割的示意图:对于无向图,这三条蓝色边都将是穿过割AB的边,因为它们一端在左侧,一端在右侧。对于有向图,我们只有两条交叉边,即从左到右穿过、尾在A中头在B中的那两条。向后穿过的那一条不算作割的交叉边。


最小割问题

现在,最小割问题正是你所想的那样。我给你一个图作为输入,在这指数级数量的割中,我希望你为我找出一个交叉边数量最少的割。

几点快速说明:

  1. 这个割的名称是最小割。最小割是交叉边数量最少的割。
  2. 为了澄清,在输入中,我将允许所谓的平行边。在许多应用中,平行边可能没什么意义,但对于最小割问题,允许平行边是很自然的。这意味着你可以有两条边对应完全相同的顶点对。
  3. 你们中经验更丰富的程序员可能想知道“给你一个图作为输入”具体是什么意思。你可能想知道图是如何精确表示的。下一个视频将讨论图的流行表示方法,以及我们在这门课程中通常如何做,特别是通过所谓的邻接表

最小割的应用

那么,为什么你应该关心计算最小割呢?这是图划分这类问题中的一个。给你一个图,你想把它分成两块或多块。这类图划分问题在各种各样令人惊讶的应用中频繁出现。让我在高层面上提几个。

一个非常明显的应用是,当你的图表示一个物理网络时,识别像最小割这样的东西可以让你识别网络中的弱点。也许这是你自己的网络,你想了解哪里需要加强基础设施,因为它在某种意义上是你网络的热点或薄弱点。或者,也许是别人的网络,你想知道他们网络中的薄弱点在哪里。事实上,大约15年前有一些解密的文件显示,冷战时期的美国和苏联军方实际上对计算最小割非常感兴趣,因为他们正在寻找诸如“最有效破坏对方国家交通网络的方法”之类的东西。

另一个在当今社交网络分析中很重要的应用是社区检测的概念。问题是,在一个巨大的图中,比如Facebook上所有人的图,你如何识别那些看起来紧密联系、关系密切的小群体?你希望从中推断出他们是某种社区——也许他们都上同一所学校,也许他们有相同的兴趣,也许是同一个生物家族的一部分,等等。如何在社交网络中最好地定义社区,在某种程度上仍然是一个开放性问题。但作为一个快速而粗略的一阶启发式方法,你可以想象寻找那些一方面内部高度互联,但与图的其他部分连接相当薄弱的区域。像最小割问题这样的子程序可以用来识别图中这些内部密集互联但与外部连接薄弱的小区域。

最后,割问题在视觉中也经常使用。例如,一种使用方式是在所谓的图像分割中。这里的情况是,你得到一个二维数组作为输入,其中每个条目是来自某个图像的像素。给定一个像素的二维数组,定义一个图是非常自然的:如果两个像素是相邻的(即左右或上下紧挨着),你就在它们之间放一条边。这样就得到了所谓的网格图。与这里讨论的基本最小割问题不同,在图像分割中,最自然的是使用边权重,其中一条边的权重基本上是你预期这两个像素来自同一物体的可能性。为什么你可能预期两个相邻像素来自同一物体?也许它们的颜色映射几乎完全相同,你只是期望它们是同一物体的一部分。一旦你定义了这个具有合适边权重的网格图,现在你运行图划分或最小割类型的子程序。希望它识别出的割能将图片中的一个连续物体“撕”下来。然后你这样做几次,就能得到给定图片中的主要物体。

这个列表远未穷尽最小割和图划分子程序的应用,但我希望它能作为足够的动力,让你观看本系列接下来的课程。


总结

本节课中,我们一起学*了图的基本概念,包括顶点、边、无向图与有向图。我们定义了图的割,并引出了最小割问题,即寻找交叉边数量最少的割。我们还探讨了最小割在网络弱点分析、社交网络社区检测和图像分割等多个领域的应用,为后续深入学*图算法奠定了基础。

002:图表示方法

概述

在本节中,我们将学*图的基本表示方法。我们将探讨如何衡量图的大小,以及两种主要的图表示方式:邻接矩阵和邻接表。理解这些表示方法对于后续学*图算法至关重要。

图的基本概念

图由两个基本要素构成。首先,我们有一组对象,这些对象可以称为顶点或节点。其次,我们使用边来表示这些对象之间的成对关系。

边可以是无向的,此时它们是无序对;边也可以是有向的,从一个顶点指向另一个顶点,此时它们是有序对,我们称之为有向图。

图的规模参数

当我们讨论图的大小或图算法的运行时间时,需要考虑输入规模的含义。与数组不同,图的大小由两个参数控制:顶点数量和边数量。

我们通常使用以下符号:

  • N 表示顶点数量
  • M 表示边数量

边数量的范围

对于具有N个顶点的无向连通图(没有平行边),边数量M的范围是:

  • 最小边数:M ≥ N-1
  • 最大边数:M ≤ N×(N-1)/2(即组合数C(N,2))

最小边数的原因是:要从N个孤立顶点开始构建连通图,每添加一条边最多能将两个连通分量合并为一个。从N个分量减少到1个分量,至少需要N-1条边。

最大边数的原因是:在没有平行边的情况下,最多可以在每对顶点之间都有一条边,总共有C(N,2)种可能的边。

稀疏图与稠密图

了解边数量如何随顶点数量变化后,我们来讨论稀疏图和稠密图的区别。这个区别很重要,因为某些数据结构和算法更适合稀疏图,而其他则更适合稠密图。

虽然这个术语在实际使用中有些灵活,但基本概念是:

  • 稀疏图:边数量接*下界,即接*线性关系(M ≈ O(N))
  • 稠密图:边数量接*上界,即接*二次关系(M ≈ O(N²))

在大多数应用中,M至少是N的线性函数(如果图是连通的),最多是N的二次函数。

图的表示方法

接下来我们讨论图的两种表示方法。本课程主要使用第二种方法,但第一种方法也值得了解。

邻接矩阵表示法

邻接矩阵是一种直观的表示方法,使用矩阵来表示图中的边。

对于无向图,邻接矩阵A是一个N×N的方阵,其中:

  • A[i][j] = 1 当且仅当顶点i和j之间存在边
  • A[i][j] = 0 表示没有边

这种表示法可以扩展以适应各种情况:

  • 平行边:A[i][j]可以存储i和j之间的边数量
  • 带权边:A[i][j]可以存储边的权重
  • 有向图:可以使用+1和-1表示边的方向

空间需求分析

邻接矩阵的空间需求是O(N²),这与边的实际数量M无关。对于稠密图,这是可以接受的;但对于稀疏图,这种表示法会浪费大量空间。

邻接表表示法

邻接表是本课程主要使用的表示方法,它有多个组成部分。

以下是邻接表的基本结构:

  1. 顶点数组:存储所有顶点
  2. 边数组:存储所有边
  3. 边到端点的指针:每条边存储指向其两个端点的指针
  4. 顶点到边的指针:每个顶点存储指向与其相连的所有边的指针

空间需求分析

邻接表的空间需求是θ(M + N),我们可以将其视为图的线性空间。

具体分析如下:

  • 顶点数组:θ(N)
  • 边数组:θ(M)
  • 边到端点的指针:θ(M)(每条边两个指针)
  • 顶点到边的指针:θ(M)(与边到端点的指针一一对应)

总空间为θ(M + N),这在大多数情况下是理想的表示方式。

表示方法的选择

面对这两种图表示方法,你可能会问:应该记住哪种?应该使用哪种?

答案取决于两个因素:图的密度和你需要支持的操作类型。

对于本课程,我们将主要关注邻接表,原因如下:

操作需求

本课程涉及的大多数图原语都与图搜索相关。邻接表非常适合图搜索操作:到达一个节点,跟随出边到达另一个节点,依此类推。

图密度和应用场景

许多图原语的动机来自大规模网络。以万维网为例,它可以被看作一个有向图:

  • 顶点:单个网页(约100亿个)
  • 有向边:超链接

如果使用邻接矩阵,N=10¹⁰,则N²=10²⁰,这远远超出了当前技术的处理能力。而使用邻接表,由于平均度数约为10,M≈10¹¹,虽然也很大,但在当前技术范围内是可处理的。

总结

在本节中,我们一起学*了图的基本表示方法。我们了解了如何用N和M衡量图的大小,探讨了稀疏图和稠密图的区别,并详细分析了邻接矩阵和邻接表两种表示方法的空间需求和适用场景。

对于本课程后续内容,我们将主要使用邻接表表示法,因为它既适合图搜索操作,又能有效处理大规模稀疏图,如万维网这样的现实世界网络。

003:-03-10 图搜索概述

在本节课中,我们将要学*图搜索这一基础问题,以及与之紧密相关的寻找图中路径的问题。图搜索是理解图结构和解决众多实际问题的核心工具。

🧭 为什么需要图搜索?

图搜索的应用场景非常广泛。以下是几个关键原因:

  • 物理网络的连通性:例如电话网络或公路网,我们需要确保网络中任意两点之间都是连通的。如果加利福尼亚州的电话无法接通犹他州,那将是一场灾难。因此,网络功能的一个基本条件就是任意两点间存在路径。
  • 逻辑网络的分析:以电影演员网络为例,节点代表演员,如果两位演员曾出现在同一部电影中,则他们之间有一条边。我们可以研究这个网络的连通性,例如著名的“培根数”——衡量任意演员通过共同出演关系,最少需要多少步(边)能联系到演员凯文·培根。这本质上是在寻找最短路径。
  • 路径规划:在使用地图应用寻找从当前位置到餐厅的最佳路线时,我们就是在图中寻找一条路径,通常还是最短路径(按距离或时间衡量)。
  • 抽象决策序列:图搜索的思维可以抽象化。路径可以看作是从初始状态(如一个未完成的数独谜题)到目标状态(已完成的谜题)的一系列决策(在空格中填入数字)。这种抽象思维使得图搜索在人工智能规划等领域无处不在。
  • 计算连通分量:这与图搜索紧密相关,本身也有许多应用。对于无向图,连通分量对应图的各个“部分”,可以用于简单的聚类分析。对于有向图,计算连通分量有助于理解网络(如万维网)的结构。

总之,高效地搜索图是一项基础且应用广泛的图算法原语。好消息是,本节课程中讨论的所有算法都将是线性时间的,运行速度几乎和读取输入数据一样快,因此你可以放心地在分析图数据时使用它们。

🔍 图搜索的通用方法

有多种系统性的图搜索方法。本课程将重点介绍两种非常重要的方法:广度优先搜索(BFS)深度优先搜索(DFS)。不过,所有图搜索方法都有一些共同点。

以下是任何图搜索算法的高层思路:

  • 输入:算法接受一个起始顶点(通常称为源点 s)。
  • 目标:找出从源点 s 出发所有可达的顶点。所谓“可达”,是指存在一条从 s 到该顶点的路径。
  • 效率要求:我们希望高效地完成搜索,避免重复探索。算法运行时间应为 O(m + n),其中 n 是顶点数,m 是边数。

现在,我们来看一个通用的图搜索框架。这个框架是未完全指定的,存在多种实例化方式,其中两种特定的实例化将分别得到广度优先搜索和深度优先搜索。

以下是该通用方法,旨在找出所有可达顶点且每个部分只探索一次:

  1. 初始化时,将所有顶点标记为“未探索”,唯独将起始点 s 标记为“已探索”。
  2. 将已探索的顶点集合视为算法已“征服”的领土,其边界之外是未探索的领土。
  3. 算法的主循环是:只要存在一条边,其一个端点在已探索集合内,另一个端点在未探索集合内,就执行以下操作:
    • 选择一条这样的边(具体选择哪条,算法未指定)。
    • 将该边中那个未探索的端点标记为“已探索”,将其纳入已征服的领土。
  4. 当不存在这样的边时,算法终止。

算法正确性:无论以何种方式实例化这个通用搜索过程(即无论按什么规则选择边),算法终止时,被标记为“已探索”的顶点恰好就是所有从 s 出发可达的顶点。这个结论对无向图和有向图均成立。

证明思路(反证法)
假设存在一个顶点 v,它可以从 s 到达(即存在路径 P),但算法结束时 v 却是“未探索”的。沿着路径 P 从已探索的 s 走到未探索的 v,必然存在路径上的第一条边 (u, w),使得 u 已探索而 w 未探索。然而,如果存在这样的边,我们的通用算法就不会终止,它会继续探索 w。这与算法已终止且 v 未探索的假设矛盾。因此,算法不可能遗漏任何可达顶点。

⚖️ 两种重要的搜索策略

通用方法中的模糊之处在于:在每次循环迭代中,通常有多条边横跨“已探索”和“未探索”的边界。选择哪条边(即接下来探索哪个未探索的顶点)的不同策略,导致了具有不同性质和应用的图搜索算法。

上一节我们介绍了图搜索的通用框架,本节中我们来看看两种最重要的具体策略。

1. 广度优先搜索(BFS)🚀

核心思想:BFS 按“层”探索顶点。

  • 第 0 层:只有起始点 s
  • 第 1 层:s 的所有邻居。
  • 第 2 层:第 1 层顶点的所有尚未被访问过的邻居。
  • 以此类推。

实现关键:使用队列(Queue) 这种先进先出(FIFO)的数据结构来管理待探索的顶点。

主要应用

  • 计算最短路径(边数最少):在无权图中,顶点所在的层数就等于从 s 到该顶点的最短路径长度。这正是计算“培根数”或简单网络跳数所需的方法。
  • 计算无向图的连通分量:通过循环调用 BFS,可以找出图的所有连通部分。

2. 深度优先搜索(DFS)🔄

核心思想:DFS 采取一种更“激进”的策略,它沿着一条路径尽可能深地探索,直到无法继续,然后回溯到最*的分叉点选择另一条路径。这类似于走迷宫时的策略。

实现关键:使用栈(Stack) 这种后进先出(LIFO)的数据结构。DFS 也常以递归形式实现,此时调用栈隐式地充当了栈数据结构。

主要应用

  • 拓扑排序:对于有向无环图(DAG),DFS 可以产生一个顶点的线性序列(拓扑序),使得图中所有的边都从序列中前面的顶点指向后面的顶点。这在处理具有依赖关系的任务调度时非常有用(例如课程选修的先后顺序)。
  • 计算有向图的强连通分量:在有向图中定义“连通部分”(强连通分量)更为微妙,而 DFS 提供了计算它们的线性时间算法(Kosaraju 算法或 Tarjan 算法)。
  • 探索图的结构:DFS 的探索顺序有助于发现图中的环、关节点等结构。

📊 总结

本节课中我们一起学*了图搜索的基础概念和通用框架。我们了解到图搜索的目标是找出从给定起点出发所有可达的顶点,并且存在高效的线性时间算法来实现。

我们重点比较了两种核心的搜索策略:

  • 广度优先搜索(BFS) 按层推进,适用于计算最短路径和无向图连通分量。
  • 深度优先搜索(DFS) 深入优先再回溯,适用于拓扑排序和计算有向图的强连通分量。

这两种策略都使用简单的数据结构(队列或栈)即可在 O(m + n) 时间内完成搜索,是分析图数据时强大而高效的基本工具。在接下来的课程中,我们将对它们进行更深入的探讨。

004:广度优先搜索基础

在本节课中,我们将深入学*图的第一种具体搜索策略——广度优先搜索,并探讨其应用。

概述

广度优先搜索是一种系统性地探索图节点的方法,它从给定的起点开始,按“层”向外探索。本节将介绍其核心思想、实现细节以及线性时间复杂度的保证。

广度优先搜索的直观理解与应用

上一节我们介绍了图搜索的通用策略,本节中我们来看看广度优先搜索的具体做法。其计划是从给定的起点开始,按层系统地探索图的节点。

让我们思考以下示例图,其中 S 是广度优先搜索的起点。

起点 S 将构成第 0 层,我们称之为 L0。
然后,S 的邻居节点将成为第一层,即我们在探索 S 之后紧接着探索的顶点,这些是 L1。
第二层将是 L1 中顶点的邻居顶点,但这些顶点本身不在 L1 或 L0 中。因此,C 和 D 将成为第二层。
例如,S 本身是第 1 层中这些节点的邻居,但我们已经在前一层中计算过它,因此不将 S 计入 L2。
最后,L2 的邻居中尚未被归入任何层的是 E,因此那将是第 3 层。再次注意,C 和 D 是彼此的邻居,但它们已被归入第二层,因此它们属于第二层而非第三层。

这就是广度优先搜索的高层概览。我们将在下一张幻灯片中讨论如何精确实现它。

以下是你可以用广度优先搜索完成的一些事情,我们将在本视频中探讨:

  • 计算最短路径:最短路径距离恰好对应于这些层。例如,如果你将 S 视为电影图中的凯文·贝肯节点,那么乔恩·哈姆将在从凯文·贝肯开始的广度优先搜索的第二层中出现。
  • 计算无向图的连通分量:我们将在线性时间内计算图的各个部分。

在整个关于图基本操作的视频序列中,我们的目标都是达到线性时间这一“圣杯”。请记住,在图中有两个不同的规模参数:边的数量 M 和节点的数量 N。在这些视频中,我不假设 M 和 N 之间存在任何特定关系,任何一个都可能更大。因此,线性时间将意味着 O(M + N)。

线性时间实现

现在,让我们讨论如何在线性时间内实际实现广度优先搜索。

伪代码如下。输入是一个图 G。我将以无向图为例进行解释,但整个过程对于有向图的工作方式完全相同。显然,在无向图中,你可以沿任意方向遍历边;而在有向图中,你必须小心地只沿弧的预期方向(从尾部到头部,即向前)遍历。

正如我们在讨论图搜索通用策略时提到的,我们不想重复探索任何节点,那肯定是低效的。因此,我们将为每个节点保留一个布尔值,标记我们是否已经探索过它。默认情况下,我们假设节点是未探索的,只有当我们明确标记它们时,它们才被视为已探索。

我们将用起始顶点 S 初始化搜索:将 S 标记为已探索,然后将其放入我之前称为“已征服区域”的队列中。为了达到线性时间,我们需要以一种略微非朴素但相当直接的方式来管理这些节点,即通过一个队列。队列是一种先进先出的数据结构。

回想一下,在图搜索的通用系统方法中,技巧是在某个 while 循环的每次迭代中,向已征服区域添加一个新顶点,识别一个现在将被探索的未探索节点。

这个 while 循环将转化为我们检查队列是否非空的过程。我们假设队列数据结构支持在常数时间内进行该查询,这很容易实现。如果队列不为空,我们从中移除一个节点。因为它是队列,所以从队首移除节点可以在常数时间内完成。我们称从队列中取出的节点为 V。

现在,我们将查看它的邻居节点(即与其共享边的顶点),并检查它们中是否有尚未被探索的。如果 W 是我们以前没见过的节点(即未探索),这意味着它在未征服区域中,这很好,我们有了一个新的目标。我们可以将 W 标记为已探索,将其放入我们的队列中,这样我们就推进了边界,并且比之前多了一个已探索的节点。

同样,根据构造,队列支持在队尾进行常数时间的添加操作,因此我们将 W 放在那里。

让我们看看这段代码在我们上一张幻灯片看到的同一个图中是如何执行的。我将按照节点被探索的顺序为它们编号。

显然,第一个被探索的节点是 S,队列从这里开始。

现在,当我们执行代码时会发生什么?在 while 循环的第一次迭代中,我们问队列是否为空。不,它不为空,因为 S 在里面。因此,我们移除队列中的节点(在这种情况下是唯一的节点 S),然后迭代遍历与 S 相连的边。这里有两个:S 和 A 之间的边,以及 S 和 B 之间的边。算法没有告诉我们应该先看哪条边,实际上这无关紧要,任何一种都是广度优先搜索的有效执行。但为了具体起见,我们假设在两条可能的边中,我们先看边 (S, A)。

然后我们问:A 已经被探索过了吗?没有,这是我们第一次见到它。所以我们说,太好了,这是新的探索目标。我们可以将 A 添加到队列的末尾,并将 A 标记为已探索。因此,A 将成为第二个被标记的顶点。

在标记 A 为已探索并将其添加到队列后,我们回到 for 循环。现在我们继续处理 S 的第二条边,即 S 和 B 之间的边。我们问:B 已经被探索过了吗?这是我们第一次见到它。所以现在我们对 B 做同样的事情:将 B 标记为已探索,并将其添加到队列的末尾。此时,队列中首先是 A 的记录(因为它是我们在取出 S 后第一个放入的),然后 B 跟在 A 后面。

此时队列看起来是这样的。现在我们回到 while 循环,我们问队列是否为空?当然不是,它实际上有两个元素。现在我们从队列中移除第一个节点,在这种情况下是节点 A(它是在节点 B 之前放入的)。现在,我们查看所有与 A 相连的边。A 有两条相连的边:一条与 S 共享,另一条与 C 共享。

如果我们看 A 和 S 之间的边,那么在 if 语句中我们会问:S 已经被探索过了吗?是的,它已经被探索过了,那是我们开始的地方,所以没有理由对 S 做任何事情,它已经被移出队列了。因此,在 A 的这个 for 循环中,有两次迭代:一次涉及与 S 的边,我们完全忽略它;另一次是 A 与 C 共享的边,而 C 我们还没见过。所以在 for 循环的那部分,我们说,啊哈,C 是个新东西,新节点!我们可以将其标记为已探索并放入队列。这将是我们的第四个节点。

现在队列如何变化?我们移除了 A,所以现在 B 在队首,我们在队尾添加了 C。现在同样的事情发生:我们回到 while 循环,队列不为空,我们取出第一个顶点(这次是 B)。B 有三条相连的边:一条与 S 相连(无关紧要,我们已经见过 S);一条与 C 相连(也无关紧要,因为我们已经见过 C,虽然我们刚刚才见到它,但我们已经见过了);但 B 和 D 之间的边是新的。这意味着我们可以将节点 D 标记为已探索并添加到队列中。因此,D 将是我们看到的第五个节点。

现在队列中有元素 C,后面跟着 D。我们回到 while 循环,从队列中取出 C。它现在有四条边:与 A 的边无关紧要(我们已经见过 A);与 B 的边无关紧要(我们已经见过 B);与 D 的边无关紧要(我们已经见过 D);但我们还没见过 E。所以当我们在 for 循环中处理 C 和 E 之间的边时,我们说,啊哈,E 是新的。因此,E 将成为第六个也是最后一个被标记为已探索的顶点,它将被添加到队列的末尾。

然后,在 while 循环的最后两次迭代中,D 将被移除,我们将遍历它的三条边,这些边都不相关,因为我们已经见过其他三个端点。然后我们回到 while 循环,移除 E。E 的边也不相关,因为我们已经见过其他端点。现在我们再次回到 while 循环,队列为空,我们停止。这就是广度优先搜索。

为了理解这如何模拟我们之前讨论的“层”的概念,请注意节点是根据它们所在的层进行编号的:S 是第 0 层。然后,由 S 导致被添加到队列的两个节点 A 和 B,编号为 2 和 3。第二层的边恰好是我们在处理第一层节点(即 A 和 B)时被添加到队列的节点,也就是 C 和 D。第三层中唯一的节点 E,是在我们处理第二层顶点 C 和 D 时被添加到队列的。

因此,通过使用先进先出的数据结构——队列,我们最终确实按照我们之前讨论的层来处理节点。

算法正确性与时间复杂度

广度优先搜索是一种很好的图探索方式,因为它满足我们在上一个视频中划定的两个高层目标:首先,它能找到所有可找到的节点,且仅找到这些节点;其次,它没有冗余,不会重复探索任何节点,这是其线性时间实现的关键。

更正式地说:

声明一:在算法结束时,我们探索过的顶点恰好是那些存在从 S 到该顶点的路径的顶点。这个声明无论你在无向图还是有向图中运行 BFS 都同样有效。当然,在无向图中,我们指的是从 S 到 V 的无向路径;而在有向图中,我们指的是从 S 到 V 的有向路径(即路径中的每条弧都沿正向遍历)。

为什么这是真的?这基本上对于任何特定形式的图搜索策略(广度优先搜索是其中之一)都成立,我们已经更普遍地证明了这一点。如果你很难将广度优先搜索解释为我们通用搜索算法的一个特例,你也可以直接参考我们为通用搜索算法所做的证明,并将其复制到广度优先搜索中。因此,很明显,如果你实际找到了某个节点(即它被标记为已探索),那只是因为你找到了一系列引导你到达那里的边。反之,要证明任何存在从 S 到 V 路径的节点都会被找到,可以通过反证法进行:查看 BFS 成功探索的从 S 到 V 路径的部分,然后问为什么它没有再多走一步?在到达 V 之前,它永远不会终止。你也可以直接复制我们在上一个视频中为通用搜索策略所做的相同证明。总之,广度优先搜索能找到所有你想找到的节点,它只遍历路径,所以你不会找到任何没有路径可达的节点,但它也绝不会错过任何有路径可达的节点。

声明二:运行时间正是我们想要的。我将以一种对后续讨论连通分量有用的形式来陈述它。

主 while 循环的运行时间(忽略任何预处理或初始化)与 N_s 和 M_s 成正比,其中 N_s 是从 S 可达的节点数,M_s 是从 S 可达的边数。

这个声明的原因,通过检查代码就变得很清楚。

让我们回到代码,统计完成的所有工作。我将忽略初始化,只关注主 while 循环。

我们可以总结在 while 循环中完成的总工作如下:首先考虑顶点。在这次搜索中,我们只处理从 S 可找到的顶点,即 N_s 个。对于给定的节点,我们将其插入队列并从中删除,因此我们处理任何一个节点的次数不会超过一次。所以,对于我们看到的每个顶点,都有常数时间的开销。这就是与 N_s 成正比的部分。

对于一条边,我们可能会看它两次。对于边 (v, w),我们可能在第一次查看顶点 v 时考虑它,也可能在查看顶点 w 时再次考虑它。每次我们查看一条边,都做常数工作。这意味着我们将对每条边做常数工作。我们最多查看每个顶点一次,最多查看从 S 可找到的每条边两次。当我们查看某个东西时,我们做常数时间的工作。因此,总运行时间将与从 S 可找到的顶点数加上从 S 可找到的边数成正比。

这真的很棒。我们有一个线性时间实现的、非常优秀的图搜索策略。而且,我们只需要非常基本的数据结构(一个队列)就能让它以较小的常数快速运行。

总结

本节课中,我们一起学*了广度优先搜索的基础知识。我们了解了其按层探索的直观思想,详细分析了使用队列实现的线性时间算法,并确认了其正确性(能找到所有可达节点)和高效性(O(N_s + M_s) 时间复杂度)。这为后续利用 BFS 解决最短路径、连通分量等问题奠定了坚实的基础。

005:BFS与最短路径

概述

在本节课中,我们将要学*广度优先搜索算法的一个核心应用:计算图中从一个起点到所有其他节点的最短路径距离。我们将看到,只需对基础的BFS代码进行微小的修改,就能高效地计算出这些距离。

最短路径的概念

让我们从最短路径的概念开始。假设我给你一个电影演员关系图,并指定凯文·贝肯作为起点。问题是:到达另一个演员,例如约翰·汉姆,所需的最少“跳数”或路径上的最少边数是多少?

为此,我们引入一些符号。我将使用 DIST(v) 来表示从起点 s 到节点 v 的最短路径距离。这个距离定义为从 s 出发到达 v 的所有路径中,边数最少的那条路径的边数。这个概念同样适用于无向图或有向图。在有向图中,我们总是沿着弧的正确方向(向前)遍历。

在BFS中计算最短路径

为了实现最短路径计算,我们只需要在之前展示的BFS代码基础上增加非常少量的额外代码。这只会带来很小的常数开销,其核心思想是跟踪每个节点属于哪一层,而这些层恰好对应着距离起点 s 的最短路径距离。

那么,额外的代码是什么呢?首先,在初始化步骤中:

  • 我们为从起点 s 到顶点 v 的最短路径距离设置一个初步估计值。
  • 如果 v 等于起点 s,我们知道可以通过长度为0的路径(空路径)从 s 到达 s,因此设置 DIST(s) = 0
  • 对于其他所有顶点,我们最初一无所知,不知道是否存在通往它们的路径。因此,我们暂时将所有非起点顶点的距离设为 +∞。当然,一旦我们实际发现了通往顶点 v 的路径,就会修正这个值。

你需要添加的另一处额外代码位于探索边的过程中:

  • 当你从队列前端取出一个顶点 v,然后遍历它的边时,你会考虑其中一条边 (v, w)
  • 按照惯例,如果边的另一端 w 已经被处理过,则直接忽略它,再次查看将是冗余工作。
  • 但如果是第一次见到顶点 w,那么除了之前所做的操作(将其标记为已探索并放入队列末尾)之外,我们还需要计算它的距离。
  • 顶点 w 的距离,将比首先发现它的那个顶点 v 的距离多1。即:DIST(w) = DIST(v) + 1

实例演示

回到我们广度优先搜索的运行示例,让我们看看会发生什么。

首先,我们从顶点 s 开始,在初始化中设置 DIST(s) = 0,其他顶点的距离未知。

  1. 初始步骤:将 s 放入队列,进入主 while 循环。
  2. 队列非空,取出 s,查看其邻居 ad。假设我们先处理边 (s, a)
    • 这是第一次见到 a,因此标记 a 为已探索,将其放入队列末尾。
    • 执行额外步骤:计算 a 的距离。s 是发现 a 的顶点,DIST(s)=0,所以设置 DIST(a) = 0 + 1 = 1。这相当于 a 属于第一层。
  3. 仍在处理 s 的边,现在处理边 (s, b)
    • 第一次见到 b,标记并放入队列。
    • 设置 DIST(b) = DIST(s) + 1 = 1b 也属于第一层。
  4. 处理完 s 的所有邻接点后,回到 while 循环。队列包含 ab
  5. 取出队列第一个顶点 a,查看其关联边。忽略已见的 (s, a),处理 (a, c)
    • 第一次见到 c,标记并放入队列。
    • 设置 DIST(c) = DIST(a) + 1 = 1 + 1 = 2c 属于第二层。
  6. 处理队列中的下一个顶点 b。忽略已见的 (s, b)(b, c),处理 (b, d)
    • 第一次通过 b 发现 d,标记并放入队列。
    • 设置 DIST(d) = DIST(b) + 1 = 1 + 1 = 2d 属于第二层。
  7. 处理顶点 c。在它的边中,第一次见到 e
    • 标记并放入队列。
    • 设置 DIST(e) = DIST(c) + 1 = 2 + 1 = 3e 属于第三层。

算法的剩余部分照常进行。你会注意到,计算出的最短路径标签恰好就是我们之前定义的层。此时,你应该很容易相信这个结论在一般情况下是成立的:对于从 s 可达的任意顶点 v,广度优先搜索计算出的距离等于 i,当且仅当 v 位于我们之前定义的第 i 层。而位于第 i 层,正意味着 vs 之间的最短路径距离是 i 跳(i 条边)。

算法正确性简述

我不想花时间给出这个结论的超级严格的证明,但让我概述一下基本思路。你可以通过归纳法来证明,归纳的对象是层数 i。你想要证明的是,所有属于给定第 i 层的节点,BFS确实为它们计算出了距离 i

成为一个第 i 层的节点意味着什么?首先,你在之前的任何层(0到 i-1)中都没有出现过。其次,你是某个第 i-1 层节点的邻居,并且是在所有第 i-1 层节点都被处理后才第一次被发现的。

归纳假设告诉我们,对于更低层的所有节点,距离都被正确计算了。因此,具体来说,那个在第 i-1 层、负责在第 i 层发现你的节点 v,其计算出的距离是 i-1。你的距离被赋值为比它的距离多1,也就是 i。这样就完成了归纳步骤,证明了第 i 层上的所有节点确实获得了与 s 的最短距离标签 i

BFS的特殊性与下个应用预告

在结束这个应用之前,我想强调,只有广度优先搜索能为我们提供这种最短路径的保证。我们有一系列图搜索策略,它们都能找到所有可达的节点,广度优先搜索是其中之一。但计算最短路径距离是BFS一个特殊的附加属性。特别是,深度优先搜索通常计算最短路径距离,这确实是广度优先搜索的独有特性。

相比之下,在下一个应用中——计算无向图的连通分量——情况则不同。这个应用并非BFS所独有,例如,你也可以在这里使用深度优先搜索,效果一样好。

总结

本节课中,我们一起学*了如何利用广度优先搜索算法计算图中从单一源点到所有其他顶点的最短路径距离。我们了解到,只需在标准BFS流程中增加简单的距离记录步骤——初始化时设置起点距离为0、其他点为无穷大,并在首次发现新节点时将其距离设为发现者距离加1——即可高效完成计算。我们通过实例演示了这一过程,并简要讨论了其正确性。最后,我们指出了计算最短路径是BFS特有的优势,为学*图算法的下一个重要应用做好了准备。

006:BFS与无向图连通性

在本节课中,我们将学*如何使用广度优先搜索算法来计算无向图的连通分量。连通分量是图论中的一个核心概念,它帮助我们理解图的整体结构,判断网络是否完整,甚至用于数据聚类分析。

连通分量的定义

上一节我们介绍了广度优先搜索的基本原理,本节中我们来看看如何用它来解决一个具体问题:识别无向图中的连通分量。

首先,我们需要明确什么是连通分量。直观上,一个无向图的连通分量是图中一个“最大”的区域,在这个区域内,任意两个顶点之间都存在一条路径。为了更精确地定义,我们引入一个等价关系。

设图G的顶点集为V。我们定义顶点U和V之间存在关系(记作U ~ V),当且仅当在G中存在一条从U到V的路径。这个关系满足以下三个性质,因此是一个等价关系:

  • 自反性:对于任意顶点V,存在一条从V到V的路径(例如空路径)。公式表示为:∀v ∈ V, v ~ v
  • 对称性:如果存在从U到V的路径,由于图是无向的,那么也存在从V到U的路径。公式表示为:u ~ v ⇒ v ~ u
  • 传递性:如果存在从U到V的路径和从V到W的路径,那么通过连接这两条路径,可以得到从U到W的路径。公式表示为:(u ~ v ∧ v ~ w) ⇒ u ~ w

这个等价关系将顶点集V划分成若干个互不相交的等价类。每个等价类就是图的一个连通分量,即一个“最大的”相互连通的顶点集合。

连通分量的应用

理解连通分量有什么用呢?以下是几个常见的应用场景:

以下是连通分量的一些实际应用:

  • 网络诊断:互联网服务提供商需要确保其网络中的任何两个节点都能相互通信。这等价于检查代表该网络的图是否是一个连通图(即只有一个连通分量)。
  • 社交网络分析:例如,在电影演员合作网络中,你可以查询是否每个演员都能通过合作链连接到凯文·贝肯。这本质上是一个连通性问题。
  • 数据可视化:当需要可视化一个大型网络时,首先识别出不同的连通分量,然后分别展示每个分量,可以使图表更清晰易懂。
  • 数据聚类:给定一组对象(如文档、图片、基因组)及其两两之间的相似度评分,可以构建一个图:节点代表对象,如果两个对象的相似度超过某个阈值(即非常相似),则在它们之间添加一条边。这个图的连通分量自然就形成了相似对象的聚类。这是一种快速、线性的聚类启发式方法。

使用BFS计算连通分量

现在,让我们探讨如何使用广度优先搜索作为核心子程序,高效地找出无向图的所有连通分量。算法的核心思想是:系统地遍历每个顶点,如果遇到一个尚未被探索的顶点,就从它开始进行一次BFS,这次BFS所探索到的所有顶点就构成了一个连通分量。

以下是计算无向图所有连通分量的算法伪代码:

# 假设图G有n个顶点,编号为1到n
将所有顶点标记为“未探索”
for i = 1 to n:
    if 顶点 i 是“未探索”的:
        # 从顶点i开始进行一次BFS
        BFS(G, i)
        # 此次BFS访问到的所有顶点构成一个连通分量

让我们通过一个例子来理解算法是如何工作的。考虑一个包含10个顶点、具有三个连通分量的图。

算法执行步骤如下:

  1. 外层循环从 i=1 开始。顶点1未被探索,因此从顶点1启动BFS。
  2. 这次BFS会探索顶点1所在的整个连通分量(例如,可能包含顶点1, 3, 5, 7, 9)。探索完成后,将这些顶点标记为“已探索”。
  3. 外层循环继续,i=2。顶点2未被探索(因为它不在第一个分量中),因此从顶点2启动BFS。
  4. 这次BFS探索第二个连通分量(例如,顶点2和4),并标记它们为“已探索”。
  5. i=3, 4, 5 时,我们发现这些顶点已被探索过,因此跳过,不执行BFS。
  6. i=6 时,顶点6未被探索,启动BFS探索第三个连通分量(例如,顶点6, 8, 10)。
  7. 循环继续直到 i=10,所有顶点都已被处理。

这个算法有两个关键点:

  • 正确性:BFS从某个顶点出发,能且仅能访问到该顶点所在连通分量中的所有顶点。外层循环确保每个顶点都会被检查到。因此,算法不会遗漏任何顶点或连通分量。
  • 避免重复工作:一旦一个顶点在某个BFS中被标记为“已探索”,后续循环就不会再以它为起点进行BFS。这确保了每个顶点只被处理一次。

算法时间复杂度分析

这个算法的时间复杂度是线性的,即 O(n + m),其中n是顶点数,m是边数。原因如下:

以下是算法各部分的耗时分析:

  • 初始化:将所有n个顶点标记为“未探索”需要O(n)时间。
  • 外层循环:循环本身执行n次,每次检查顶点状态为常数时间,共O(n)。
  • BFS调用:每个顶点只会作为起点被BFS探索一次(当它是其所在分量中第一个被外层循环遇到的顶点时),或者在BFS过程中作为邻居被访问一次。在每个BFS内部,我们对每个顶点和每条边都只做常数量的工作。
  • 边处理:图中的每条边,只会在其所属连通分量的那次BFS中被访问至多两次(从两个端点各一次)。因此,所有BFS中对边的总处理时间是O(m)。

将以上所有部分相加,总时间复杂度为O(n + m)。

总结

本节课中我们一起学*了无向图连通分量的概念及其计算方法。我们首先给出了连通分量基于等价关系的精确定义,然后列举了它在网络诊断、数据聚类等多个领域的应用。核心内容是,通过结合一个外层循环和广度优先搜索子程序,我们可以在 O(n + m) 的线性时间内找出图的所有连通分量。算法高效的关键在于利用BFS系统性地探索每个连通区域,并通过“已探索”标记避免重复计算。这种以BFS为基础模块解决更复杂图论问题(如连通性)的思路,在图算法设计中非常典型。

007:深度优先搜索基础 🧭

在本节课中,我们将要学*图搜索的第二种核心策略——深度优先搜索。我们将从它与广度优先搜索的对比开始,通过一个具体例子来理解其工作原理,最后介绍其代码实现。

深度优先搜索策略概述

上一节我们介绍了广度优先搜索,它是一种谨慎、逐层探索的策略。本节中我们来看看它的“表亲”——深度优先搜索。如果说广度优先搜索是“谨慎的探索者”,那么深度优先搜索就是“激进的探险家”。它的核心计划是尽可能深入地探索,只有在必要时才进行回溯。这种策略类似于我们在迷宫中探索时,倾向于沿着一条路走到头,直到碰壁再返回。

深度优先搜索运行示例

为了清晰地说明深度优先搜索的工作方式,我们使用与讲解广度优先搜索时相同的图作为示例。假设我们从节点 S 开始进行深度优先搜索。

以下是搜索过程的具体步骤:

  1. 从起点 S 开始探索。
  2. S 有两个邻居:AB。深度优先搜索在此处是未确定的,我们可以选择任意一个。假设我们选择先探索 A
  3. 现在,与广度优先搜索不同,我们不会去探索 S 的另一个邻居 B。深度优先搜索的规则是:必须接着探索当前节点(A)的某个直接邻居
  4. 假设我们选择从 A 继续深入,探索其邻居 C
  5. C 出发,我们继续深入探索其邻居 E
  6. E 出发,其唯一的未访问邻居是 D,因此我们探索 D
  7. 现在,从 D 出发,有两条边:通往 B 和通往 C。假设我们选择通往 C
  8. 此时,我们到达了已访问过的节点 C。按照规则,我们需要从 C 回溯到 D
  9. 回溯到 D 后,我们发现还有另一条边(通往 B)未探索。于是我们沿着这条边探索 B
  10. B 出发,尝试探索其邻居 SA,但发现它们都已被访问过。因此,我们从 B 回溯到 D
  11. D 回溯到 E,再从 E 回溯到 C,接着回溯到 A
  12. A,我们检查最后一条边(通往 B),发现 B 已访问,于是回溯到 S
  13. S,检查最后一条边(通往 B),发现 B 已访问。至此,所有边均被探索一次,搜索结束。

通过这个例子,我们可以看到深度优先搜索的核心模式:沿着一条路径不断深入,直到无法继续(遇到已访问节点或没有邻居),然后回溯到上一个分岔点,尝试另一条路径。

深度优先搜索的应用价值

你可能会问,既然已经有了强大(线性时间、能计算最短路径和连通分量)的广度优先搜索,为什么还需要深度优先搜索?

深度优先搜索拥有自己独特的、令人印象深刻的应用领域,这些应用是广度优先搜索难以替代的。我们将重点介绍它在有向图中的应用:

  • 一个简单应用(本视频讨论):计算有向无环图的拓扑排序。
  • 一个复杂应用(后续视频专门讨论):计算有向图的强连通分量

深度优先搜索的运行时间与广度优先搜索一样,都是我们所能期望的最佳情况——线性时间。在图的连通性应用中,线性时间通常表示为 O(m + n),其中 m 是边数,n 是顶点数。这个复杂度考虑到了边数可能远少于顶点数的情况。

深度优先搜索的代码实现

现在,让我们来看看深度优先搜索的实际代码。实现方式有多种。

一种方法是基于广度优先搜索的代码进行微调,主要区别在于将队列(先进先出)替换为(后进先出)。栈支持在常数时间内从前端进行插入(push)和删除(pop)操作。

为了展示多样性和优雅性,我们将介绍一种递归版本的实现。深度优先搜索非常自然地可以表述为一个递归算法。

深度优先搜索的输入是一个图 G(可以是无向图或有向图)。对于有向图,只需确保沿着邻接表中边的正确方向进行探索即可。

与广度优先搜索类似,我们需要为每个顶点维护一个布尔值,记录其是否已被访问过。

以下是递归实现的核心思路:

def DFS(graph, vertex, visited):
    """
    从给定顶点开始进行深度优先搜索。
    :param graph: 图的数据结构(如邻接表)
    :param vertex: 当前访问的顶点
    :param visited: 记录顶点访问状态的列表/字典
    """
    # 标记当前顶点为已访问
    visited[vertex] = True
    print(f"访问顶点: {vertex}")  # 或者执行其他操作

    # 递归地探索所有未访问的邻居
    for neighbor in graph[vertex]:
        if not visited[neighbor]:
            DFS(graph, neighbor, visited)

深度优先搜索的基本性质与保证

深度优先搜索的基本保证与广度优先搜索完全相同:

  1. 完备性:它能找到所有可达的顶点。
  2. 效率:它在线性时间 O(m + n) 内完成。

原因在于,深度优先搜索同样是我们本系列视频开始时介绍的“通用图搜索过程”的一个特例。它只是定义了在“已探索区域”和“未探索区域”之间的边界上,如何选择下一条探索的边——它总是偏向于选择最*才发现的已探索节点的邻居

运行时间与所探索的连通分量大小成正比,因为:

  • 每个节点最多被访问一次(由布尔标记保证)。
  • 每条边最多被查看两次(从它的两个端点各一次)。

深度优先搜索的特定应用:拓扑排序

由于深度优先搜索和广度优先搜索在上述基本性质上完全一致,因此在无向图中计算连通分量时,两者可以互换使用,都能在线性时间内完成任务。

所以,我们更关注深度优先搜索独有的应用。其中一个关键应用就是为有向无环图寻找一个拓扑排序

拓扑排序是指将DAG的所有顶点排成一个线性序列,使得对于图中的每一条有向边 (u, v),顶点 u 在序列中都出现在顶点 v 之前。这在实际中常用于表示任务间的依赖关系(例如课程先修关系、编译顺序)。

深度优先搜索通过其“完成时间”的概念,可以非常高效地生成一个逆拓扑序,稍作处理即可得到拓扑排序。这将是我们在后续内容中深入探讨的主题。


本节课中我们一起学*了深度优先搜索的基础知识。我们了解了它作为一种“激进”的图搜索策略,其核心是尽可能深入地探索,并在必要时回溯。我们通过一个详细的例子追踪了其执行过程,并介绍了其递归实现的代码框架。最后,我们明确了DFS与BFS同样具有线性时间复杂度和搜索完备性,并引出了其独特的应用场景——例如寻找有向无环图的拓扑排序,这为后续的学*奠定了基础。

008:-08-10 拓扑排序 🧭

在本节课中,我们将要学*拓扑排序。这是一种针对有向无环图的顶点排序方法,它确保图中所有的有向边都从排序靠前的顶点指向排序靠后的顶点。这种排序在任务调度、课程安排等有依赖关系的场景中非常有用。

什么是拓扑排序? 📖

首先,我们来定义什么是有向图的拓扑排序。

本质上,它是图顶点的一种排序,使得图中所有的弧(即有向边)在排序中只向前指。

我们可以通过给顶点标记数字1到n来编码一个排序,这只是为了表示每个顶点在这个排序中的位置。形式化地说,存在一个函数F,它将图G的顶点映射到1到n之间的整数,每个数字恰好被一个顶点使用(n是图G的顶点数)。

真正重要的属性是:图G的每条有向边在排序中都向前指。也就是说,如果(U, V)是有向图G的一条有向边,那么尾部U的F值应该小于头部V的F值。即,当你沿着边的正确方向遍历时,这个有向边会指向一个F值更高的顶点。

让我举个例子来更清楚地说明这一点。假设我们有一个非常简单的、有四个顶点的有向图。

我将展示这个图的两种完全合法的拓扑排序。

第一种做法是:标记S为1,V为2,W为3,T为4。
另一种选择是:以同样的方式标记它们,但交换V和W的标签。所以,你可以标记V为3,W为2。

这些标签真正要编码的是顶点的顺序。蓝色的标签可以理解为编码了顺序:S第一,然后是V,接着是W,最后是T。而绿色的标签可以理解为相同的节点顺序,只是W在V之前。

重要的是,在这两种情况下,边的模式完全相同,特别是所有的边在这个排序中都向前指。无论V和W的顺序如何,从S到V和从S到W的边看起来都一样。同样地,从V和W到T的边也是如此。

你会注意到,无论我们以何种顺序放置V和W,这四条边在每种排序中都向前指。现在,如果你试图把V放在S之前,那将行不通,因为如果V在S之前,从S到V的边就会向后指。类似地,如果你把T放在除最后位置之外的任何地方,你都不会得到一个拓扑排序。事实上,这是这个有向图仅有的两种拓扑排序。我鼓励你自己去验证这一点。

拓扑排序的应用场景 🎯

那么,谁关心拓扑排序呢?实际上,这是一个非常有用的子程序,在各种应用中都会出现。基本上,每当你想对一系列任务进行排序,而这些任务之间存在“先决条件”约束时,就会用到它。所谓先决条件约束,是指一个任务必须在另一个任务之前完成。

例如,你可以考虑像计算机科学专业这样的本科专业中的课程。这里的顶点将对应于所有的课程,如果课程A是课程B的先修课程(即你必须先修A),那么就会有一条从课程A到课程B的有向边。那么,你当然想知道一个可以修读这些课程的顺序,以便你总是在修完先修课程后才修读该课程。这正是拓扑排序将要实现的目标。

存在性与必要条件 🔍

因此,一个合理的问题是:有向图何时具有拓扑排序?当一个图确实有这样的排序时,我们如何得到它?

首先,图要具有拓扑排序,有一个非常明确的必要条件,那就是它最好是无环的。换句话说,如果一个有向图有有向环,那么它肯定不可能有拓扑排序。

我希望这个原因相当清楚。考虑任何一个确实有有向环的有向图,并考虑任何声称的顶点排序方式。现在,只需逐个遍历环上的边。你从这个环上的某个地方开始,如果第一条边向后指,那么你已经搞砸了,你已经知道这个排序不是拓扑的——没有边可以向后指。因此,显然这个环的第一条边必须向前指。现在你必须遍历这个环上的其余边。最终你会回到起点。所以,如果你一开始向前走,那么在某个时刻你必须向后走。这样,那条边就向后指,违反了拓扑排序的性质。这对于每一种排序都是如此。因此,有向环排除了拓扑排序的可能性。

现在的问题是:如果你没有环呢?这个条件是否足够强,能保证你有一个拓扑排序?是否存在除了明显的循环先决条件约束之外的其他障碍,使得无法无冲突地排序任务?

一个直观的算法 💡

事实证明,答案不仅是肯定的——只要没有任何有向环,你就能保证有一个拓扑排序——而且我们甚至可以通过深度优先搜索在线性时间内计算出一个排序。

在展示如何通过深度优先搜索非常巧妙且高效地计算拓扑排序之前,让我先介绍一个相当好但稍微不那么巧妙、效率也稍低的解决方案,以帮助你建立对有向无环图及其拓扑排序的直觉。

对于这个直接的解决方案,我们将从一个简单的观察开始。

每个有向无环图都有一个我称之为汇点的顶点,即没有任何出弧的顶点。

在我们上一张幻灯片探讨的四个节点的有向无环图中,恰好有一个汇点,就是最右边的这个顶点,它没有出弧。其他三个顶点都至少有一条出弧。

那么,为什么有向无环图必须有一个汇点呢?假设它没有。假设它没有汇点,那就意味着每个顶点都至少有一条出弧。那么,如果每个顶点都有一条出弧,我们能做什么呢?我们可以从一个任意节点开始。我们知道它不是汇点,因为我们假设没有汇点,所以它有一条出弧,让我们跟着它走。我们到达另一个节点。根据假设,没有汇点,所以这个节点也不是汇点,所以它有一条出弧,让我们跟着它走。我们到达另一个节点。那个节点也有一条出弧,让我们跟着它走。如此继续。我们就这样一直跟着出弧走。只要每个顶点都至少有一条出弧,我们就可以想走多久就走多久。但是,顶点的数量是有限的,比如说这个图有n个顶点。所以,如果我们跟着n条弧走,我们将看到n+1个顶点。根据鸽巢原理,我们必然会看到一个重复的顶点。例如,也许在我从这个顶点出发的弧之后,我又回到了之前见过的那个顶点。

那么,我们做了什么?当我们通过追踪这些出弧并重复访问一个顶点时,会发生什么?我们已经展示了一个有向环。而这正是我们假设不存在的东西——我们讨论的是有向无环图。换句话说,我们刚刚证明了一个没有汇点的图必须有一个有向环。因此,一个有向无环图必须至少有一个汇点。

基于汇点的递归算法 🔄

现在,我们来看看如何利用这个非常简单的观察来计算有向无环图的拓扑排序。

让我们做一个小思想实验。假设这个图确实有一个拓扑排序。让我们想想在这个拓扑排序中排在最后的顶点。

记住,任何在排序中向后指的弧都是违规的,所以我们必须避免这种情况,我们必须确保每条弧在排序中都向前指。现在,对于任何有出弧的顶点,我们最好把它放在除最后位置之外的某个地方。因此,我们放在最后位置的节点,它的所有出弧最终都会在拓扑排序中向后指。它们没有别的地方可去,因为这个顶点是最后一个。换句话说,如果我们计划成功计算一个拓扑排序,那么排序中最后位置的唯一候选顶点就是汇点。只有汇点放在那里才行得通。如果我们把一个非汇点放在那里,那就完蛋了,这是不可能发生的。

幸运的是,如果图是有向无环的,我们知道存在一个汇点。

所以,设V是图G的一个汇点。如果有多个汇点,我们任意选择一个。我们将V的标签设置为可能的最大值。因为有n个顶点,我们将把它放在第n个位置。然后,我们只需在图的其余部分上递归,这部分现在只有n-1个顶点。

让我们看看这个例子是如何工作的。在第一次迭代或最外层的递归调用中,唯一的汇点是这个用绿色圈出的最右边的顶点。总共有四个顶点,我们将给它标签4。然后,在标记了4之后,我们删除那个顶点以及所有与它相连的边,并在图的剩余部分上递归。这将是左边三个顶点加上最左边的两条边。现在,在我们删除了顶点4及其所有关联边之后,这个图有两个汇点:上面这个顶点和下面这个顶点在剩余图中都是汇点。所以,在下一个递归调用中,我们可以选择其中任何一个作为我们的汇点,因为我们有两个选择,这会产生两种拓扑排序,这正是我们在例子中看到的那两种。但是,例如,如果我们选择这个顶点作为我们的汇点,那么它得到标签3。然后我们只在最西北的两个边上递归,这个顶点是该图中唯一的汇点,得到标签2。然后我们在一个节点的图上递归,它得到标签1。

算法正确性证明 ✅

为什么这个算法有效?我们只需要两个快速的观察。

首先,我们需要论证,在每次迭代或每次递归调用中,我们确实可以找到一个汇点,并将其分配到尚未填充的最后位置。原因在于,如果你取一个有向无环图并从中删除一个或多个顶点,你仍然会得到一个有向无环图——你不可能仅仅通过去掉一些东西来创建环,你只能破坏环。我们开始时没有环,所以在所有中间的递归调用中,我们都没有环。根据我们的第一个观察,总是存在一个汇点。

其次,我们必须论证我们确实产生了一个拓扑排序。记住这意味着什么:对于图的每条边,它在排序中都向前指,即弧的头部被分配的位置比弧的尾部晚。

这简单地源于我们总是使用汇点这一事实。考虑被分配到位置i的顶点V。这意味着当我们只剩下i个顶点的图时,V是一个汇点。那么,它在原始图中具有什么属性呢?这意味着它所有的出弧都必须指向那些已经被删除并分配了更高位置的顶点。因此,对于每个顶点,当它实际被分配一个位置时,它是一个汇点,并且它只有来自尚未标记的顶点的入弧。它的出弧都向前指向那些已经被分配了更高位置并先前从图中删除的顶点。

基于深度优先搜索的高效算法 ⚡

现在,我们已经掌握了一个相当合理的解决方案,用于计算有向无环图的拓扑排序。特别是,我们观察到,如果一个图确实有有向环,那么当然不可能有拓扑排序。然而,上一张幻灯片的解决方案表明,只要没有环,就保证拓扑排序确实存在。事实上,它是一个构造性的证明,一个构造性的论证,给出了一个算法:你只需不断地一个一个地去掉汇点,并从右到左填充排序,就像你不断地剥离这些汇点一样。

这是一个相当好的算法,速度不慢,实际上,如果你正确实现它,你甚至可以让它在线性时间内运行。但我想通过一个深度优先搜索的应用来结束这个视频,这是一个非常巧妙、非常高效的计算有向无环图拓扑排序的方法。

我们只需要对我们之前的深度优先搜索子程序做两个相当小的修改。

第一件事是,我们必须将它嵌入到一个for循环中,就像我们在计算无向图的连通分量时对广度优先搜索所做的那样。这是因为在计算拓扑排序时,我们最好给每个顶点一个标签,我们最好至少查看每个顶点一次。为了做到这一点,我们将确保有一个外层的for循环,然后如果我们有多个连通分量,我们只需根据需要多次调用DFS。

第二件事是,我们将添加一点簿记工作,这将确保每个节点都得到一个标签,事实上,这些标签将定义一个拓扑排序。

让我们不要忘记深度优先搜索的代码。这里你被给定一个图G(在这种情况下,我们提到我们感兴趣的是有向无环图)和一个起始顶点S。你要做的是,一旦你到达S,就非常积极地开始尝试探索它的邻居。当然,你不会访问任何你已经去过的顶点。要记录你访问过谁。如果你发现任何你以前没见过的顶点,你立即开始递归调用那个节点。

我说过,我们需要做的第一个修改是将其嵌入到一个外层的for循环中,以确保每个节点都得到标记。我将调用那个子程序DFS_loop。它不接收起始顶点作为参数。初始化时,所有节点都未被探索。我们还将跟踪一个全局变量,我称之为current_label。它将被初始化为n,每当我们完成探索一个新节点时,我们就递减它。这些值将恰好是F值,也就是我们输出的拓扑排序中顶点的位置。

在主循环中,我们将遍历图的所有节点。例如,我们只需扫描节点数组。像往常一样,我们不想做重复的工作,所以对于已经在之前的DFS调用中被探索过的顶点,我们不再从它开始搜索。当我们计算无向图的连通分量时,将广度优先搜索嵌入for循环中,这应该都很熟悉。如果我们遇到一个尚未探索的图顶点V,那么我们就调用DFS,以该顶点作为起点。

最后我需要补充的是,我需要告诉你F值是什么,即顶点到位置的实际分配是什么。正如我预示的那样,我们将使用这个全局的current_label变量,它将让我们从右到左分配顶点的位置,非常模仿我们在递归解决方案中所做的事情,在那里我们一个一个地去掉汇点。

那么,什么时候是给顶点分配其位置的正确时机呢?事实证明,正确的时机是我们完全完成对该顶点的处理时,即我们即将从堆栈中弹出对应于该顶点的递归调用时。

所以,在我们遍历完给定顶点的所有出边的for循环之后,我们设置F[S]等于当前的current_label值。然后我们递减current_label

就是这样。这就是整个算法。

算法示例演示 🧪

声称将要产生的结果是:产生的F值将是一个拓扑排序。你会注意到,这些值将是n到1之间的整数,因为DFS最终会在每个顶点上被调用一次,并且在结束时都会得到一个整数分配,每个人都会得到一个不同的值,最大的是n,最小的是1。

让我们看看它在我们运行的例子中是如何工作的。假设我们有这个我们已经很熟悉的四个节点的有向图。

它有四个顶点。所以我们将current_label变量初始化为等于4。

假设在外层的DFS循环中,我们从某个顶点开始,比如顶点V。注意,在外层的for循环中,我们最终会以完全任意的顺序考虑顶点。假设我们首先从顶点V调用DFS。那么会发生什么?

从V唯一能去的地方是T。然后在T,没有地方可去。所以我们调用DFS(T),没有边可以遍历,for循环结束,因此T将被分配一个F值,等于当前的current_label,也就是n,这里n是顶点数,即4。所以F[T]将得到4。

然后,我们完成了T的处理,我们回溯到V。当我们完成T时,我们递减current_label,我们回到V,现在没有更多的出弧需要探索,所以for循环结束,所以我们完成了深度优先搜索,所以它得到新的current_label,现在是3。同样,在完成V之后,我们递减current_label,现在降到2。

现在,我们回到外层的for循环。也许我们考虑的下一个顶点是顶点T,但我们已经去过那里了,所以我们不会在T上调用DFS。然后,也许在那之后,我们尝试在S上调用。所以S可能是for循环考虑的第三个顶点。我们还没有见过S,所以我们从顶点S开始调用DFS。

从S出发,有两条弧可以探索:一条到V(我们已经见过V,所以弧S->V不会发生任何事情),但另一方面,弧S->W将导致我们递归调用DFS(W)。从W,我们尝试查看从W到T的弧,但我们已经去过T了,所以我们什么也不做。这样就完成了W的处理,所以深度优先搜索然后在顶点W处结束,W得到current_label的分配,所以F[W] = 2。我们递减current_label,现在它的值是1。现在我们回溯到S,我们已经考虑了S的所有出弧,所以我们完成了S的处理,它得到当前的current_label,即1。这确实是我们几页前展示的这个图的两种拓扑排序之一。

算法性能与正确性分析 📊

这就是算法的完整描述以及它在具体示例中的工作原理。让我们讨论一下它的关键属性:运行时间和正确性。

就运行时间而言,这个算法的运行时间是线性的,这正是你想要的。运行时间是线性的原因与这些图搜索算法通常在线性时间内运行的常见原因相同:你明确地跟踪你去过哪些节点,这样你就不会重复访问它们,所以你只对每个节点做常数量的工作;在有向图中,每条边实际上你只在访问该边的尾部时查看一次,所以你也只对每条边做常数量的工作。

当然,另一个关键属性是正确性,即我们需要证明你保证能得到一个拓扑排序。

这意味着什么?这意味着每条边、每条弧在排序中都向前指。所以如果(U, V)是一条边,那么算法分配给U的标签F[U]小于分配给V的标签F[V]

正确性证明分为两种情况,取决于深度优先搜索首先访问顶点U和V中的哪一个。

由于我们的for循环会遍历图G的所有顶点,深度优先搜索将恰好从每个顶点被调用一次。U或V都有可能先被访问,两者都是可能的。

情况一:假设U在V之前被DFS访问。那么会发生什么?记住深度优先搜索的作用:当你从一个节点调用它时,它将找到从该节点可到达的所有节点。如果U在V之前被访问,那意味着V还没有被探索,所以它是被发现的候选者。此外,有一条直接从U到V的弧,所以从U调用的DFS肯定会发现V。而且,对应于节点V的递归调用将在U的递归调用之前完成并从程序堆栈中弹出。理解这一点的最简单方法是思考深度优先搜索的递归结构。当你从U调用深度优先搜索时,那个递归调用将对所有相关的邻居(包括V)进行进一步的递归调用。U的调用在V的调用完成之前不会被弹出堆栈,这是因为堆栈或递归算法的后进先出性质。

因为V的递归调用在U之前完成,这意味着它将获得一个比U更大的标签。记住,随着越来越多的递归调用从堆栈中弹出,标签会不断减小。这正是我们想要的。

情况二:这是V在U之前被访问的情况。这里我们利用了图没有环的事实。因为有一条从U到V的直接弧,这意味着不可能有任何从V一路回到U的有向路径,否则就会形成一个有向环。

因此,从V调用的DFS不会发现U。同样,如果存在从V到U的有向路径,就会有一个有向环,所以它根本找不到U。因此,V的递归调用再次会在U的调用甚至被推入堆栈之前就被弹出。所以,在我们甚至开始考虑U之前,我们就已经完全处理完了V。因此,出于同样的原因,由于V的递归调用先完成,它的标签将会更大,这正是我们想要证明的。

总结 📝

本节课中,我们一起学*了拓扑排序。我们首先定义了拓扑排序的概念,即对有向无环图的顶点进行排序,使得所有有向边都从排序靠前的顶点指向靠后的顶点。我们探讨了拓扑排序在任务调度等场景中的应用。

我们分析了拓扑排序存在的必要条件:图必须是无环的。接着,我们介绍了一个直观的基于递归删除汇点的算法,并理解了其工作原理。

最后,我们重点学*了一个基于深度优先搜索的高效线性时间算法。该算法通过外层循环确保访问所有顶点,并在DFS递归调用完成时,按逆序为顶点分配标签,从而自然地得到一个拓扑排序。我们通过示例演示了算法的执行过程,并分情况证明了其正确性。

拓扑排序是深度优先搜索的一个经典应用。在接下来的视频中,我们将探讨一个更有趣的应用:计算有向图的强连通分量,那时我们将需要两次深度优先搜索。

009:计算强连通分量算法 🧩

在本节课中,我们将要学*如何为有向图计算强连通分量。我们将从一个直观的定义开始,逐步理解一个基于深度优先搜索的、运行速度极快的线性时间算法——Kosaraju算法。

概述

在上一节中,我们掌握了如何在线性时间内计算无向图的连通分量。现在,我们将注意力转向有向图。好消息是,我们同样能获得一个极其快速的原语来计算有向图的连通性信息。但我们需要更深入地思考,因为定义有向图中的“连通块”并不像无向图那样直观。

强连通分量的定义

首先,我们需要明确什么是有向图的“连通块”。考虑一个四节点的有向图。一方面,这个图在物理意义上是“一整块”;另一方面,你无法从任意一个节点通过有向路径到达另一个任意节点(例如,从最右边的节点无法到达最左边的节点)。

因此,对于有向图,通常研究的是强连通性。如果一个有向图中,从任意节点A到任意节点B都存在一条有向路径,并且从B到A也存在一条有向路径,那么这个图就是强连通的。

强连通分量 则是图中满足强连通性的最大区域,即在这些区域内,你可以从任何一点沿着有向路径到达任何其他点。

更正式的定义是,我们可以在图的节点上定义一个等价关系:节点U与节点V相关,当且仅当存在从U到V的有向路径从V到U的有向路径。强连通分量就是这个等价关系的等价类。

让我们用一个更复杂的图来具体说明。下图有四个强连通分量:左侧的三角形、右侧的三角形、顶部的一个单节点,以及底部的一个带对角线的有向四环。

每个被圈出的区域内部都是强连通的。同时,这些区域是“最大”的,因为如果你取两个不同圈中的节点对,要么没有从第一个到第二个的路径,要么没有从第二个返回第一个的路径。实际上,这个黑色图中强连通分量的结构,精确地反映了我们开始时用红色绘制的那个四节点有向无环图的结构。

算法动机与挑战

我们已经了解了强连通分量的定义。现在的问题是:如何高效地计算它们?

我们即将介绍的算法基于深度优先搜索,这是它速度极快的主要原因。你可能会疑惑,图搜索和计算分量有什么关系?它们看起来并不直接相关。

让我们回到之前展示的那个有向图。为了理解DFS为何可能有用,假设我们从红色节点开始调用DFS。DFS的保证是:它能找到所有从起点“可到达”的节点,仅此而已。从红色节点出发,DFS将恰好发现三角形中的三个节点,即这个强连通分量。

这看起来很酷!似乎我们只需进行一次DFS,就能得到一个SCC。如果我们能反复这样做,就能得到所有SCC。

然而,事情可能出错。如果我们从底部绿色的节点启动DFS呢?从该节点出发,不仅可以到达其自身SCC中的四个节点,还可以通过蓝色弧线到达红色三角形中的三个节点。因此,这次DFS调用将捕获这七个节点,即多个SCC的并集。

最坏情况下,如果从左边的节点启动DFS,它将发现整个图,这完全没有揭示强连通分量的结构。

关键点在于:如果从正确的位置调用DFS,你就能揭示一个SCC;如果从错误的位置调用,你将得不到任何有用信息。

接下来算法的魔力在于,我们将展示如何通过一个非常巧妙的预处理步骤(讽刺的是,它本身也是一次DFS调用),在线性时间内精确计算出后续DFS应该从哪些起点开始,使得每次调用恰好得到一个强连通分量,不多不少。

Kosaraju 算法

我将要展示的算法归功于Kosaraju。该算法表明:

定理:有向图的强连通分量可以在线性时间内计算完成。我们将看到,其常数也非常小,本质上只是两次深度优先搜索。

我们通常假设边数 m 至少和节点数 n 一样多,但计算连通分量时,图可能非常稀疏,因此线性时间指的是 O(m + n)

Kosaraju算法简单得令人震惊。它只有三步:

  1. 反转所有弧:将给定图的所有有向边反向。目前尚不清楚为何要这样做。
  2. 第一次DFS(在反向图上进行):在反向图上运行深度优先搜索。一种优化方法是直接在原图上运行DFS,但沿着边反向遍历,这模拟了在反向图上的搜索。这里写的“DFS循环”是指通常的技巧:用一个外层循环确保访问图的所有节点(即使图不连通),对每个尚未访问的节点分别启动DFS。
  3. 第二次DFS(在原图上进行):再次运行深度优先搜索,但这次是在原始有向图上。

此时,你可能会觉得这完全不合理。我们试图计算真实的强连通分量,但所做的只是搜索图——一次正向,一次反向——这似乎不是在计算任何东西。

诀窍在于一点简单的簿记,开销很低,但仍能保持算法的极速。在第二次DFS的搜索过程中,它将以一种非常自然的方式,一次一个地发现强连通分量。当我们进行示例时,这会变得非常明显。

为了让第二次DFS以这种神奇的方式工作(一次发现一个SCC),它必须以特定的顺序执行DFS,即按照特定的顺序遍历图中的节点。而这正是第一次遍历的任务。

在反向图上的深度优先搜索将计算出一个节点顺序。当第二次深度优先搜索按照这个顺序遍历节点时,它将在线性时间内一次一个地发现SCC。

让我再详细说明一下簿记的形式,然后展示在进行深度优先搜索时如何维护这些簿记。

我们将有顶点完成时间的概念,这将在第一次遍历(在反向图上进行DFS)时计算。我们将在第二次遍历中使用这个数据。在第二次遍历中,我们不是以任意顺序遍历节点,而是确保按照这些完成时间的递减顺序来处理顶点。

关于第二次DFS如何发现并报告它找到的强连通分量:我们将为第二次遍历中的每个节点标记一个领导者。其思想是,同一个强连通分量中的节点将被标记为完全相同的领导者节点。一旦我们进行具体示例,这一切会更加清晰。

算法细节与子程序

Kosaraju算法的核心是 DFS_Loop 子程序。它以一个图作为输入(而不是一个起始节点),它将循环遍历可能的起始节点。

为了计算完成时间,我们将跟踪一个初始化为0的全局变量 tt 用于计算到目前为止我们已经完全探索完毕的节点数量。这就是我们在第一次遍历中计算那些神奇顺序时使用的变量。

我们还将有第二个全局变量 s 来计算领导者,这只在第二次遍历中相关。s 将跟踪最*一次启动DFS的顶点。

为了代码简洁,我将所有簿记都放在 DFS_Loop 中,但实际上 DFS_Loop 被调用两次:一次在反向图上,一次在正向图上。我们只需要在反向图的第一次遍历中计算完成时间,只需要在正向图的第二次遍历中计算领导者。

我们需要遍历顶点,问题是以什么顺序遍历?这在两次遍历中会不同。让我们假设在子程序中,节点以某种方式标记为1到n。

在第一次深度优先搜索中,顺序是完全任意的(例如节点的名称或它们在数组中的位置)。第二次运行 DFS_Loop 时,正如前面提到的,我们将使用完成时间作为标签。

以下是 DFS_Loop 的工作流程:

# 全局变量
t = 0  # 用于完成时间的计数器
s = None  # 当前领导者(在第二次遍历中使用)
explored = [False] * (n+1)  # 标记节点是否已被访问
finishing_time = [0] * (n+1)  # 节点的完成时间
leader = [0] * (n+1)  # 节点的领导者

def DFS_Loop(Graph G, order):
    for i in order:  # order 在第一次遍历是 [n, n-1, ..., 1],第二次是依完成时间降序
        if not explored[i]:
            s = i  # 为第二次遍历设置领导者
            DFS(G, i)

def DFS(Graph G, start_node i):
    explored[i] = True
    leader[i] = s  # 记录领导者(第二次遍历有效)
    for each arc (i, j) in G.outgoing(i):
        if not explored[j]:
            DFS(G, j)
    t += 1
    finishing_time[i] = t  # 记录完成时间

DFS 中,当我们首次遇到一个节点时,我们将其标记为已探索。一旦一个节点被标记,它就在这次 DFS_Loop 的整个调用期间都是已探索状态。

我们的簿记工作之一是跟踪DFS是从哪个顶点调用的。当节点 i 首次被遇到时,我们记住 s(启动此DFS的节点)就是 i 的领导者。

然后我们进行常规的深度优先搜索:立即查看从 i 出发的弧,并尝试递归地对任何尚未访问的邻居进行DFS。

一旦 for 循环完成(即检查完 i 的所有出边),我们就认为完成了节点 i。此时,我们递增全局计数器 t,并将节点 i 的完成时间设置为当前的 t 值。

由于深度优先搜索保证恰好访问每个节点一次,并恰好完成每个节点一次,全局计数器 t 将从1到n。第一个完成的节点完成时间为1,下一个为2,依此类推,最后一个完成的节点完成时间为n。

示例演示

让我们通过一个仔细的例子使这一切更加具体。我认为,如果你能自己在一个具体例子上跟踪部分算法,对大家会更好。

我为你画了一个有9个节点的图。假设我们已经执行了算法的第一步,即已经反转了图。因此,幻灯片上的这个蓝色图就是反转后的图。

节点以某种任意方式标记为1到9。记住,在 DFS_Loop 例程中,我们应该从上到下(从n到1)处理节点。

我的问题是:在算法的第二步,当我们在蓝色图上运行 DFS_Loop 并从最高标签9依次向下处理到最低标签1时,我们会计算出怎样的完成时间?

实际上,根据DFS选择探索哪条出边的不同决定,你可能会得到不同的完成时间。但我给出了四组可能的完成时间选项,只有其中一组可能是 DFS_Loop 在这个图上输出的结果。

答案是第四组选项。这是你可能看到的唯一一组完成时间。让我们跟踪 DFS_Loop,看看如何得到这组完成时间。

记住在主循环中,我们从最高的节点9开始,然后下降到最低的节点1。

  1. 我们从节点9启动DFS。
  2. 从9只能到6,标记9和6为已探索。
  3. 从6可以去3或8。假设我们先去3(为了匹配第四组结果)。
  4. 从3只能到9,但9已探索,所以跳过。此时3没有其他出边,因此完成。递增 t=1finishing_time[3] = 1
  5. 回溯到6,现在探索另一条边到8。
  6. 从8必须到2,从2必须到5,从5必须到8(已探索)。完成5,t=2, finishing_time[5] = 2
  7. 回溯到2,完成2,t=3, finishing_time[2] = 3
  8. 回溯到8,完成8,t=4, finishing_time[8] = 4
  9. 回溯到6,完成6,t=5, finishing_time[6] = 5
  10. 回溯到9,完成9,t=6, finishing_time[9] = 6
  11. 外层循环继续:i=8(已探索),跳过。i=7(未探索),启动DFS。
  12. 从7可以去4或9。假设先检查9(已探索),所以去4。
  13. 从4必须到1,从1必须回7(已探索)。完成1,t=7, finishing_time[1] = 7
  14. 回溯到4,完成4,t=8, finishing_time[4] = 8
  15. 回溯到7,完成7,t=9, finishing_time[7] = 9

这样就得到了第四组完成时间:[7, 3, 1, 8, 2, 5, 9, 4, 6](分别对应节点1-9)。

现在,让我们在正向图上进行第二次遍历。第一次遍历的目的是计算一个神奇的顺序,即这些完成时间。现在,我们将丢弃原始的节点名称,用红色的完成时间替换蓝色的原始名称。同时,我们需要处理原始图,这意味着必须将弧的方向反转回原始方向。

因此,当我重画这个图时,你会看到两个变化:首先,所有弧的方向反转回原始方向;其次,所有节点的名称从原始名称更改为我们刚刚计算的完成时间。

这是具有新节点名称且弧方向已反转的新图。现在,我们再次在这个图上运行DFS,并且我们仍然按照标签从高到低(9到1)的顺序处理节点。在第二次遍历中,我们不需要计算完成时间,只需要跟踪领导者。记住,一个顶点的领导者是首次发现该节点的DFS调用起点。

会发生什么?

  1. 外层循环从 i=9 开始,从节点9调用DFS。节点9成为当前领导者。
  2. 从9只能到7,从7只能到8,从8只能回9(已见)。回溯。这次调用发现了节点9、7、8,它们都获得领导者9。这正好是图的一个SCC
  3. 外层循环继续:i=8(已见),i=7(已见),i=6(未见)。从节点6调用DFS,重置领导者 s=6
  4. 从6可以去9或1。假设先探索9(已见),所以回溯后探索1。
  5. 从1必须到5,从5必须回6(已见)。回溯。这次调用新发现了节点6、1、5,它们都获得领导者6。这是另一个SCC
  6. 外层循环继续:i=5(已见),i=4(未见)。从节点4调用DFS。
  7. 从4可以尝试去5(已见),所以去2。
  8. 从2必须到3,从3必须回4(已见)。回溯。这次调用新发现了节点4、2、3,它们都获得领导者4。这是最后一个SCC
  9. 外层循环检查剩余节点(3,2,1)均已见,算法结束。

我们看到,使用第一步DFS遍历计算出的完成时间,第二次遍历中,图的强连通分量就像放在银盘上一样,一次一个地呈现在我们面前。每次调用DFS新发现的节点恰好是一个SCC,不多不少。

算法性能与总结

当然,这仅仅是一个例子。你不应该仅凭一个例子就认为这个算法总是有效。我将在下一个视频中给出一般性论证。但希望这至少提供了一个合理性的论证,这个三步算法不再显得完全疯狂。

有一件事我希望很清楚:无论这个算法正确与否,它的速度是极快的。你所做的几乎就是两次深度优先搜索。正如我们过去所看到的,深度优先搜索的运行时间与图的大小成线性关系,因此Kosaraju的两遍算法也是如此。

这里有一些细节需要思考(例如在第二遍中如何按完成时间降序处理节点,而不想进行 O(n log n) 的排序),但直觉就是:这基本上就是正确实现的双重DFS。

本节课总结
我们一起学*了有向图中强连通分量的概念。理解了Kosaraju算法的核心思想:通过第一次在反向图上的DFS,计算出一个特殊的节点处理顺序(完成时间);然后第二次在原图上按照此顺序(降序)进行DFS,每次DFS调用恰好会探索出一个完整的强连通分量,并将其所有节点标记为同一个领导者。该算法的时间复杂度为 O(m + n),效率极高。虽然我们通过示例看到了其工作原理,但要完全理解其正确性,还需要更一般的证明。

010:计算强连通分量分析

在本节课中,我们将要学*并证明Kosaraju两遍深度优先搜索算法的正确性。该算法能以线性时间计算有向图的强连通分量。

概述:有向图的元图结构

上一节我们介绍了Kosaraju算法的基本步骤,本节中我们来看看其背后的原理。首先,我们需要理解有向图的一个重要性质。

每个有向图在宏观上都具有一个简单的结构。具体来说,有向图的强连通分量自然地诱导出一个有向无环图,我们称之为元图

以下是元图的定义:

  • 元图的节点:每个强连通分量本身被视为元图中的一个节点。假设有K个强连通分量,记为 C1, C2, ..., Ck
  • 元图的边:如果在原图G中,存在一条从强连通分量 Ci 中的某个节点指向 Cj 中某个节点的边,那么在元图中就存在一条从节点 Ci 指向节点 Cj 的有向边。

公式元图边(Ci -> Cj) 存在 ⇔ 在原图G中,∃ u ∈ Ci, v ∈ Cj 使得边 (u -> v) 存在。

为什么元图保证是无环的?假设元图中存在一个环,例如 Ci -> Cj -> ... -> Ci。这意味着在原图中,你可以从 Ci 到达 Cj,也可以从 Cj 最终回到 Ci。根据强连通分量的定义,CiCj 中的所有节点将变得相互可达,因此它们本应属于同一个强连通分量,这与它们是不同的分量相矛盾。所以元图不可能有环。

关键引理

理解了元图的概念后,我们现在可以阐述驱动Kosaraju算法正确性的核心引理。

考虑两个相邻的强连通分量 C1C2,这里“相邻”意味着在原图中存在一条从 C1 中的某个节点 i 直接指向 C2 中某个节点 j 的边。

假设我们已经对反向图运行了第一遍DFS循环,并计算出了每个节点的完成时间 f(v)

引理断言:设 C1 中所有节点的最大完成时间为 max_f(C1)C2 中所有节点的最大完成时间为 max_f(C2)。那么,max_f(C2) 一定大于 max_f(C1)

公式若存在边 i(∈ C1) -> j(∈ C2),则 max_f(C2) > max_f(C1)。

为了推进证明,我们暂时假设这个引理成立,并探讨其直接推论。

推论:最大完成时间位于“汇”SCC中

基于上述引理,我们可以得出一个重要推论。

考虑整个图中具有最大完成时间 f(v) 的那个节点 v。这个节点 v 必然位于一个“汇”强连通分量中。“汇”SCC指的是在元图中没有出边的SCC(即没有边指向其他SCC)。

证明(反证法)
假设节点 v 所在的SCC(记为 C)不是一个汇SCC,即它有一条出边指向另一个SCC C‘。根据我们的关键引理,C‘ 中的最大完成时间将大于 C 中的最大完成时间。这与 v 的完成时间是整个图中最大的这一前提矛盾。因此,v 必须位于一个汇SCC中。

这个推论至关重要,因为它告诉我们算法第二遍DFS的起点(即具有最大完成时间的节点)位于一个“安全”的位置。

算法正确性证明(基于引理)

现在,我们利用这个推论来完成算法正确性的证明。

回忆我们最初关于DFS用于寻找强连通分量的讨论:从一个“错误”的节点(例如源SCC中的节点)开始DFS,可能会探索整个图,无法分离出单个SCC;而从“正确”的节点(例如汇SCC中的节点)开始,则只会探索该SCC本身。

Kosaraju算法的精妙之处在于,其第一遍DFS在反向图上计算出的完成时间顺序,恰好能确保第二遍DFS总是从“正确”的节点——即汇SCC中的节点——开始。

以下是证明思路:

  1. 发现第一个SCC:根据推论,第二遍DFS首先从具有最大完成时间的节点 v 开始,而 v 位于某个汇SCC(记为 C*)中。从 v 开始的DFS将探索所有从 v 可达的节点。由于 C* 是汇SCC,没有边指向其他SCC,因此这次DFS只会发现 C* 中的所有节点,不会涉足其他SCC。这样,我们就正确地找到了第一个强连通分量 C*
  2. 递归剥离与处理:在DFS过程中,C* 中的所有节点都被标记为已探索。在后续的第二遍DFS循环中,这些节点将被忽略。从效果上看,这相当于我们从原图中删除了 C*,然后在剩余的图上重新运行算法。
  3. 重复过程:在剩余的图中,我们再次考虑具有最大完成时间的节点(当然是尚未探索的节点中)。根据关键引理在剩余图上的类似推理(因为元图结构是层次化的),这个节点必然位于剩余图的某个汇SCC中。再次调用DFS,将发现下一个SCC。
  4. 归纳进行:这个过程持续进行。每次DFS调用都从一个汇SCC中的节点开始,发现一个完整的SCC并将其从考虑中移除。最终,所有SCC都将按照其在元图中拓扑排序的逆序被逐一发现。

因此,如果关键引理成立,那么Kosaraju算法就能正确计算所有强连通分量。

关键引理的证明

现在,我们来填补证明中最后的空白,证明关键引理本身。

我们需证明:若存在边 i(∈ C1) -> j(∈ C2),则在第一遍(对反向图进行的)DFS中,有 max_f(C2) > max_f(C1)

考虑反向图。反向后,原来的边 i -> j 变成了 j -> i。重要的是,反向图的强连通分量与原图完全相同。

我们在反向图上运行第一遍DFS。考虑算法首次探索到 C1 ∪ C2 中任一节点的时刻。设这个首次遇到的节点为 v。这里有两种情况:

情况一:vC1

  • 在这种情况下,DFS从 v 开始,会探索所有从 v 可达的节点。
  • 由于元图是无环的,并且我们已经有一条从 C2C1 的边(j -> i),因此不可能存在从 C1C2 的路径(否则会形成环,导致 C1C2 合并)。
  • 因此,从 v 开始的DFS会探索完整个 C1,但无法到达 C2 中的任何节点。
  • C2 中的节点只能在后续的外层循环中被首次探索。这意味着,C1 中所有节点的探索和完成都发生在 C2 中任何节点被探索之前。
  • 所以,C1 中所有节点的完成时间都小于 C2 中所有节点的完成时间。结论 max_f(C2) > max_f(C1) 自然成立。

情况二:vC2

  • 在这种情况下,DFS从 v 开始。
  • 由于存在从 C2C1 的边(j -> i),并且 C1C2 内部都是强连通的,因此从 v 出发可以到达 C1 ∪ C2 中的所有节点。
  • 深度优先搜索的特性是:一个节点的DFS调用只有在其所有可达节点都被完全探索后才会返回并标记该节点为“完成”。
  • 因此,节点 v 的完成时间,将晚于所有从它可达的节点(包括 C1C2 中的所有节点)的完成时间。
  • 特别地,v 的完成时间(它是 C2 中某个节点的完成时间)将大于 C1 中所有节点的完成时间。所以同样有 max_f(C2) > max_f(C1)

无论哪种情况,结论都成立。至此,关键引理得证。

总结

本节课中我们一起学*了Kosaraju两遍DFS算法正确性的完整证明。我们首先引入了元图的概念,理解有向图的强连通分量构成一个有向无环图。随后,我们陈述并证明了关键的完成时间引理,该引理指出在相邻SCC中,靠后的SCC拥有更大的最大完成时间。基于此,我们推导出最大完成时间节点必位于汇SCC中的推论。最后,我们展示了算法如何利用这个性质,通过第二遍DFS,按照元图拓扑逆序依次“剥离”出每一个强连通分量,从而高效、正确地解决问题。整个证明巧妙地结合了图的结构性质与深度优先搜索的行为特性。

011:11-10 网络的结构(可选) 🌐

在本节可选视频中,我们将探讨为何需要能够处理超大规模图的高速算法。具体来说,我们将回顾一项著名的研究,该研究计算了万维网(Web)图的强连通分量,并揭示了其独特的“领结”结构。

上一节我们设计和分析了用于图推理的快速算法。本节中,我们来看看这些算法在分析真实世界超大规模图(如万维网)时的应用价值。

什么是网络图?

网络图是一个有向图。

  • 顶点 对应网页。
  • 有向边 对应超链接。边的尾部是包含链接的页面,头部是点击链接后到达的页面。

例如,个人主页可能包含指向研究论文、课程页面的链接,甚至指向喜欢的唱片店。当然,整个网络图是分布在全球服务器上的海量结构。

研究背景与挑战

在2000年左右,尽管互联网尚处早期,但网络图的规模已非常庞大。本研究(由Andre Broder等人完成)使用的数据包含约2亿个节点15亿条边

分析如此大规模图的主要挑战在于其海量规模。当时还没有MapReduce、Hadoop等大数据处理工具,研究者必须从头开始构建计算方法。他们选择了线性时间算法,特别是使用深度优先搜索来计算强连通分量。

网络的“领结”结构 🎀

通过计算强连通分量,研究者发现了网络图呈现出一个“领结”形状的结构。以下是该结构的主要组成部分:

1. 巨型强连通分量
这是“领结”的中心结。它是一个巨大的强连通区域,意味着从该区域内的任何一个网页出发,都可以通过一系列超链接到达区域内的任何其他网页。这被认为是网络的核心。

2. IN 区域
这是“领结”的左翼。它包含许多(通常较小的)强连通分量,可以从这些分量到达巨型SCC,但无法从巨型SCC到达它们。新创建的、只向外链接而尚未被广泛链接的页面常位于此区域。

3. OUT 区域
这是“领结”的右翼。它同样包含许多强连通分量,可以从巨型SCC到达它们,但无法从它们返回巨型SCC。一些公司网站因政策禁止外链而位于此区域。

4. 其他部分

  • 管道:直接从IN区域连接到OUT区域的边,绕过了巨型SCC。
  • 卷须:从IN区域伸出但未连接到巨型SCC的部分,或从OUT区域接入的部分。
  • 孤岛:与巨型SCC没有连通关系的孤立强连通分量。

研究发现,这四个主要部分(巨型SCC、IN、OUT、其他)的规模大致相当,各占节点总数的25%左右。这个比例在后续研究中保持相对稳定。

小世界属性

虽然核心的巨型SCC只占约四分之一,但它具有异常良好的连接性,即小世界属性(俗称“六度分隔”)。

这个概念源于社会心理学家斯坦利·米尔格拉姆1967年的实验:他发现平均只需通过约6个中间人,就能将信件从内布拉斯加州的陌生人传递到波士顿的一位医生手中。

在网络科学中,小世界属性意味着:

  • 网络中任意两点间存在短路径
  • 只需遵循简单启发式规则(如“向目标方向转发”),就能高效地找到这些短路径。

研究表明,网络的巨型SCC就具有这种丰富的短路径结构,使得信息路由非常高效。

前沿研究方向

网络图的研究远不止于强连通分量计算。当前许多有趣的研究方向包括:

以下是几个活跃的研究领域:

  1. 网络演化模型:如何用数学模型描述网络随时间的动态增长与变化?
  2. 信息传播动力学:研究信息(如新闻、观点)如何在网络(如社交网络)中传播。
  3. 社区发现:如何更精确地定义和识别网络中紧密连接的子图(社区)?这比简单的割集方法要复杂得多。

这些研究不仅具有数学和技术挑战,也能帮助我们更好地理解所处的世界。若想深入了解,推荐阅读David Easley和Jon Kleinberg的著作《Networks, Crowds, and Markets》。

总结

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

  1. 如何应用强连通分量算法分析真实的万维网图。
  2. 网络图呈现出包含巨型SCC、IN区域、OUT区域等的“领结”结构。
  3. 网络的巨型核心具有小世界属性,支持高效的信息路由。
  4. 图算法是理解复杂信息网络结构的基础,并引出了网络演化、社区发现等前沿研究方向。

012:Dijkstra 最短路径算法

在本节课中,我们将要学*计算机科学中的一个经典算法——Dijkstra 最短路径算法。我们将从问题定义开始,理解其与广度优先搜索的区别,然后详细讲解算法的伪代码和核心思想,并通过示例演示其运行过程。最后,我们会讨论算法适用的前提条件。

问题定义:单源最短路径

我们面临的问题称为“单源最短路径”问题。其目标类似于计算行车路线。算法的输入是一个图(在本讲座中,我们主要处理有向图,但该算法经过微小调整后也适用于无向图)。我们通常用 M 表示边的数量,用 N 表示顶点的数量。

输入还包括两个额外的要素:

  1. 对于每条边 E,我们被赋予一个非负的长度,记作 L(e)。在行车路线应用中,L(e) 可以表示道路的里程或预计行驶时间。
  2. 第二个要素是一个起始顶点,我们称之为源点,记作 S

我们的任务是,对于网络中的每一个其他顶点 V,计算从源点 S 到目标顶点 V最短路径的长度

一条路径的长度定义为路径上所有边的长度之和。例如,一条包含三条边(长度分别为1、2、3)的路径,其总长度为 1 + 2 + 3 = 6。最短路径距离则定义为所有从 SV 的路径中,长度最小的那个值。

为了简化讨论,我们做出两个假设:

  1. 便利性假设:图中存在从源点 S 到其他每个顶点 V 的有向路径。如果某些顶点不可达,其最短路径距离定义为正无穷。这个假设并非必需,因为我们可以通过预处理(如广度/深度优先搜索)识别并删除不可达部分。
  2. 关键性假设:图中所有边的长度都是非负的。Dijkstra 算法不适用于存在负长度边的图。对于包含负权边的应用,需要使用其他算法,如 Bellman-Ford 算法。

为何广度优先搜索不够用?

你可能会想到,我们之前已经通过广度优先搜索(BFS)解决过最短路径问题。这个想法既对也不对。

BFS 确实能计算最短路径,但它仅在每条边的长度都为 1 的特殊情况下有效。我们现在要解决的是更一般化的问题:边的长度可以是任意的非负值。

例如,在一个边权各不相同的图中,BFS 会按照“跳数”(边数)最少来寻找路径,但这可能不是长度最短的路径。在像行车路线这样的应用中,道路长度或时间显然各不相同,因此我们需要能处理一般边权重的算法。

一个聪明的想法是:能否将一条长度为 L 的边替换为由 L 条长度为 1 的边组成的路径,从而将问题转化为 BFS 可解的单位权重问题?理论上可以,但当边权非常大时(例如 1000),这种替换会极大地膨胀图的规模,导致算法效率低下。因此,我们需要一种能直接在原图上高效运行的算法,这就是 Dijkstra 算法。

Dijkstra 算法核心思想与伪代码

Dijkstra 算法可以看作是广度优先搜索在边权非负情况下的优雅推广。其核心思想是逐步扩张一个已确定最短路径的顶点集合 X

以下是算法的主要步骤和伪代码概述:

我们维护两个数组:

  • A[v]:存储从源点 S 到顶点 v 的(当前已知)最短路径距离。
  • B[v]:存储从 Sv 的(当前认为的)最短路径本身(实际实现中通常只存储前驱节点)。

初始化

  • 将源点 S 加入集合 X
  • 设置 A[S] = 0B[S] = [S](空路径)。

主循环
X 未包含所有顶点时,重复以下步骤:

  1. 观察所有从 X 内部指向 X 外部的边(即“跨越边”)。
  2. 对于每条这样的边 (v, w)(其中 v ∈ X, w ∉ X),计算一个得分:A[v] + L(v, w)。这表示从 Sv 的最短已知距离,加上直接从 vw 的边的长度。
  3. 选择得分最小的那条边,记其端点为 (v*, w*)
  4. 将顶点 w* 加入集合 X
  5. 设置 A[w*] = A[v*] + L(v*, w*)
  6. 设置 B[w*] = B[v*] + [w*](即路径延续)。

循环结束时,数组 A 中存储的就是从源点 S 到所有顶点的最短路径距离。

算法示例演示

让我们通过一个简单例子来理解算法的执行过程。考虑以下带权有向图,源点为 S

(假设有一个包含顶点 S, A, B, C, D 的图,边权分别为:S->A=4, S->B=2, A->C=1, B->A=1, B->C=5, C->D=3)

初始化

  • X =
  • A[S]=0, B[S]=[S]
  • A[A]=A[B]=A[C]=A[D]=∞

迭代 1

  • 跨越边:(S,A) 得分 0+4=4;(S,B) 得分 0+2=2。
  • 最小得分边是 (S,B),得分 2。所以 v*=S, w*=B。
  • 将 B 加入 X:X =
  • 更新:A[B] = 2;B[B] = [S, B]

迭代 2

  • 跨越边:(S,A) 得分 0+4=4;(B,A) 得分 2+1=3;(B,C) 得分 2+5=7。
  • 最小得分边是 (B,A),得分 3。所以 v*=B, w*=A。
  • 将 A 加入 X:X =
  • 更新:A[A] = 3;B[A] = [S, B, A]

迭代 3

  • 跨越边:(A,C) 得分 3+1=4;(B,C) 得分 2+5=7。
  • 最小得分边是 (A,C),得分 4。所以 v*=A, w*=C。
  • 将 C 加入 X:X =
  • 更新:A[C] = 4;B[C] = [S, B, A, C]

迭代 4

  • 跨越边:(C,D) 得分 4+3=7。
  • 唯一跨越边是 (C,D),得分 7。所以 v*=C, w*=D。
  • 将 D 加入 X:X =
  • 更新:A[D] = 7;B[D] = [S, B, A, C, D]

算法结束。最终结果:A[S]=0, A[B]=2, A[A]=3, A[C]=4, A[D]=7。

负权边的影响:一个反例

Dijkstra 算法的正确性依赖于边权非负的假设。让我们看一个简单的反例。

考虑一个包含三个顶点 S, A, B 的图:

  • 边 S->A,长度 5
  • 边 S->B,长度 2
  • 边 B->A,长度 -4

从 S 到 A 的最短路径显然是 S->B->A,总长度为 2 + (-4) = -2。

现在运行 Dijkstra 算法:

  1. 初始化 X={S},A[S]=0,A[A]=A[B]=∞。
  2. 迭代1:跨越边 (S,A)得分5,(S,B)得分2。选择 (S,B),将 B 加入 X,设 A[B]=2。
  3. 迭代2:跨越边 (S,A)得分5,(B,A)得分 2+(-4)=-2。选择 (B,A),将 A 加入 X,设 A[A]=-2。

结果错误:算法得出的 A[A] = -2。但注意,此时顶点 A 和 B 都已加入 X,算法认为处理完毕。然而,存在一条更短的路径吗?没有,-2 已经是最短的了。等等,这个结果看起来是对的?让我们仔细检查。

问题出在哪里?关键在于,当存在负权边时,“已处理”集合 X 的贪心选择策略不再安全。在第二步,我们基于“当前已知最短距离”选择了 B->A 边,并认为 A 的最短距离就是 -2。但是,如果图中存在从 A 出发指向 X 内部(比如指向 S 或 B)的负权边,那么就有可能通过再次离开 X 并返回,找到一条更短的、包含环路的路径。Dijkstra 算法一旦将顶点加入 X,就永远不会重新考虑或更新其距离,因此在负权存在的情况下可能得到错误结果。

为了构造一个算法确实出错的经典反例,需要存在一个从已处理顶点出发,经过负权边,能更新另一个已处理顶点距离的结构。上述简单例子中,因为 A 之后没有出边,所以巧合地得到了正确答案。一个更典型的反例是:S->A=1, S->B=4, A->B=2, B->A=-5。Dijkstra 会先处理 A(距离1),然后错误地认为 B 的最短距离是 min(4, 1+2)=3,而实际上通过 B->A->B 的环路,距离可以无限减小(存在负权环时最短路径无定义)。这清楚地说明了为什么负权边会破坏 Dijkstra 算法的贪心正确性基础。

总结

本节课中我们一起学*了 Dijkstra 最短路径算法。我们从单源最短路径的问题定义出发,理解了其与广度优先搜索在处理一般非负边权时的区别。接着,我们深入探讨了算法的核心思想:通过维护一个已确定最短路径的顶点集合 X,并利用 Dijkstra 贪心准则 A[v] + L(v, w) 在每一步中选择下一个要处理的顶点。我们详细分析了算法的伪代码,并通过一个实例逐步演示了其运行过程。最后,我们探讨了算法对边权非负这一关键假设的依赖,并通过反例说明了当图中存在负权边时,算法的贪心选择将导致错误结果,这引出了对算法正确性证明的必要性(将在后续视频中讲解),也提示我们在有负权需求时应转向如 Bellman-Ford 等其他算法。

013:Dijkstra算法示例与局限性

在本节课中,我们将通过一个具体示例,详细演示Dijkstra算法如何计算单源最短路径。我们还将探讨该算法的一个关键限制:它无法正确处理包含负权边的图。

🧭 Dijkstra算法示例演示

上一节我们介绍了Dijkstra算法的核心思想和伪代码。本节中,我们来看看该算法在一个具体图上的执行过程。

我们将使用以下带权有向图作为示例,目标是计算从源点 S 到所有其他顶点的最短路径。

初始化阶段

算法开始时,我们以最直接的方式进行初始化。

  • 从源点 S 到其自身的最短路径距离 A[S] 设为 0
  • S 到其自身的最短路径 B[S] 设为空路径。
  • 初始时,已处理顶点集合 X 仅包含源点 S

第一轮迭代

现在,我们进入主 while 循环。在循环中,我们扫描所有“跨越边界”的边,即尾端在集合 X 内、头端在集合 X 外的边。

以下是第一轮迭代中需要扫描的边:

  • (S, V),其Dijkstra贪心评分为:A[S] + 边长 = 0 + 1 = 1
  • (S, W),其Dijkstra贪心评分为:A[S] + 边长 = 0 + 4 = 4

由于边 (S, V) 的评分更低,我们选择它。这对应了上一节幻灯片中的 (v*, w*)

算法随后指示我们:

  1. 将顶点 V 加入集合 X。现在 X = {S, V}
  2. 计算 V 的最短路径信息:
    • A[V] = 1(即其贪心评分)。
    • B[V] = B[S] + 边(S, V) = 空路径 + (S, V)

第二轮迭代

现在,我们进入下一轮 while 循环,此时集合 X 包含 SV

以下是本轮需要扫描的跨越边及其贪心评分:

  • (S, W)A[S] + 4 = 0 + 4 = 4
  • (V, W)A[V] + 2 = 1 + 2 = 3
  • (V, T)A[V] + 6 = 1 + 6 = 7

(V, W) 的评分最低,因此我们选择它。

算法随后指示我们:

  1. 将顶点 W 加入集合 X。现在 X = {S, V, W}
  2. 计算 W 的最短路径信息:
    • A[W] = 3
    • B[W] = B[V] + 边(V, W) = 路径(S, V) + (V, W)

第三轮(最终)迭代

进入最后一轮迭代,集合 X 包含 SVW,仅剩顶点 T 未被处理。

以下是本轮需要扫描的跨越边及其贪心评分:

  • (V, T)A[V] + 6 = 1 + 6 = 7
  • (W, T)A[W] + 3 = 3 + 3 = 6

(W, T) 的评分更低,因此我们选择它。

算法随后指示我们:

  1. 将顶点 T 加入集合 X。现在 X = {S, V, W, T}
  2. 计算 T 的最短路径信息:
    • A[T] = 6
    • B[T] = B[W] + 边(W, T) = 路径(S, V, W) + (W, T)

最终结果

算法结束。最终,集合 X 包含了所有顶点。A 数组给出了从源点 S 到所有顶点的最短路径距离,B 数组给出了对应的具体路径。通过对比之前的测验,可以确认Dijkstra算法在这个例子中计算出了正确的最短路径。

然而,我必须再次强调,仅凭一个简单的例子就断定算法总是正确是不严谨的。有些算法在小例子上运行良好,但在更复杂的情况下会失效。因此,我需要提供一个证明,说明Dijkstra算法不仅在当前网络,而且在任何边权非负的网络中都能正确工作。实际上,它在边权非负的网络中保证正确。为了帮助你理解这一点,让我们通过一个反例来展示当图中存在负权边时,Dijkstra算法会出什么问题。

⚠️ Dijkstra算法的局限性:负权边

在展示具体的反例之前,让我先回答一个你可能想到的非常好的问题:为什么负权边会带来这么大的麻烦?我们能否通过某种方式,将包含负权边的最短路径问题,转化为我们已经知道如何解决的非负权边问题呢?

一个看似合理的错误想法

作为计算机科学家或严谨的程序员,面对问题时,你总是希望寻找方法将其简化为已知如何解决的更简单问题。一个很自然的想法是:给图中所有边的权值加上一个足够大的常数,使得所有边权变为非负,然后直接运行Dijkstra算法。

这个想法行不通。

原因在于,图中不同的路径可能包含不同数量的边。假设一条路径有5条边,另一条有2条边。如果你给每条边都加上常数 C,那么第一条路径的长度会增加 5C,而第二条只增加 2C。由于不同路径的长度变化量不同,这可能会彻底改变哪条路径是最短的。在新的边权下最短的路径,未必是原始边权下最短的路径。

具体反例说明

考虑一个非常简单的三顶点图,顶点为 SVT,边权如下所示:(S, V)=1(V, T)=-5(S, T)=-2

在这个图中:

  • 两跳路径 S -> V -> T 的长度为 1 + (-5) = -4
  • 直接路径 S -> T 的长度为 -2
  • 显然,-4 < -2,所以路径 S -> V -> T 才是真正的最短路径。

现在,尝试执行那个“错误想法”:为了消除负权,我们给所有边加上常数 5(因为最大的负数是 -5)。新的边权变为:(S, V)=6(V, T)=0(S, T)=3

此时:

  • 路径 S -> V -> T 的新长度为 6 + 0 = 6
  • 路径 S -> T 的新长度为 3
  • 现在,3 < 6,路径 S -> T 变成了最短路径。

如果我们在这个新图上运行Dijkstra算法,它会错误地报告 S -> T 是最短路径,而这在原始图中是错误的。因此,这种简单的“加常数”归约方法是无效的。

Dijkstra算法在负权图上的失败

不仅如此,即使我们直接在原始(含负权)图上运行Dijkstra算法,它也会产生错误的输出。让我们看看为什么。

算法初始化:A[S] = 0X = {S}

第一轮迭代,扫描跨越边:

  • (S, V) 的贪心评分:A[S] + 1 = 0 + 1 = 1
  • (S, T) 的贪心评分:A[S] + (-2) = 0 - 2 = -2

(S, T) 的评分 -2 更小,因此算法会选择它。于是:

  1. T 加入集合 X
  2. 计算 T 的最短路径信息:A[T] = -2B[T] = (S, T)

但这是错误的! 我们之前已经看到,从 ST 真正的最短路径长度是 -4(路径 S -> V -> T),而不是 -2。Dijkstra算法在这个简单的三节点图上就计算出了错误的最短路径距离。

📝 总结

本节课中我们一起学*了Dijkstra算法的具体执行步骤,并通过一个示例演示了其工作过程。更重要的是,我们探讨了Dijkstra算法的一个关键限制:它无法正确处理包含负权边的图。我们不仅通过逻辑推理解释了为何不能简单地将负权图转化为非负权图来求解,还通过一个具体的反例展示了Dijkstra算法在负权图上会直接得出错误结果。

到目前为止,我们为Dijkstra算法在非负权图上的正确性提供了一些合理性,但也埋下了怀疑的种子。要断言Dijkstra算法在非负权图中总是正确,我们需要一个严密的证明。这正是下一节视频的主题。

014:-14-11 3 Dijkstra算法正确性证明(高级)

在本节课程中,我们将学*并证明Dijkstra算法在边权均为非负值的任意有向图中,确实能计算出正确的最短路径。

概述

我们将证明Dijkstra算法的正确性。证明的核心思想是使用数学归纳法,并依赖于一个关键事实:算法在每一步都选择具有最小“Dijkstra贪婪分数”的边来扩展已处理的顶点集合。我们将看到,只要所有边的长度都是非负的,这个贪心策略就能保证我们找到的路径确实是最短的。

Dijkstra算法回顾

首先,让我们回顾一下Dijkstra算法的基本框架。它的精神与我们之前学过的图搜索原语(特别是广度优先搜索)非常相似。

  • 算法维护一个已处理顶点的集合 X
  • 初始时,X 只包含源顶点 S。从 S 到自身的距离为0,最短路径为空路径。
  • 算法进入一个主循环,共进行 n-1 次迭代,每次迭代将一个当前不在 X 中的顶点加入 X

我们维护一个不变式:对于集合 X 中的所有顶点,我们已经计算出了从源点 S 到该顶点的最短路径距离的估计值,并且也计算出了最短路径本身。我们始终假设从源点 S 到其他每个目标顶点 V 至少存在一条路径,我们的任务是计算最短的那条。此外,我们必须假设所有边的长度都是非负的,否则Dijkstra算法可能会失败。

算法的关键选择

Dijkstra算法的核心在于如何谨慎地选择下一个从 X 外部加入 X 的顶点。具体做法如下:

我们扫描跨越“前沿”的边。所谓“前沿”,指的是连接已处理区域(X 内)和未处理区域(X 外)的边。也就是说,我们查看所有尾在 X 内、头在 X 外的边。

对于每一条这样的边,我们计算其 Dijkstra贪婪分数。该分数的定义是:我们已经计算出的、从 S 到尾顶点 v 的最短路径距离(因为 vX 中),再加上这条边 vw 本身的长度。

在所有从左到右跨越前沿的边中,我们选择具有最小贪婪分数的边。记这条边为 (v*, w*)。顶点 w* 将被加入 X

然后,我们计算这个新顶点 w* 的最短路径距离:它等于到 v* 的最短路径距离加上边 (v*, w*) 的长度。而最短路径本身,就是之前计算出的到 v* 的最短路径,再在末尾加上这条边 (v*, w*)

待证明的命题

我们将要证明的正式命题是:对于任意有向图,只要没有负边权,Dijkstra算法就能完美地计算出所有正确的最短路径距离。这意味着算法实际计算出的距离 A[v],恰好等于真实的最短路径距离 L[v]

这个算法及其正确性由荷兰计算机科学家Edsger Dijkstra在20世纪50年代末确立,他后来在1972年获得了图灵奖。

证明结构:归纳法

我们的证明将采用归纳法。基本思想是:算法的每一次迭代,当我们承诺某个新顶点的最短路径距离时,这个承诺都是正确的。因此,归纳的形式是基于Dijkstra算法的迭代次数。

和大多数归纳法证明一样,基础情况是平凡的。在开始主循环之前,我们承诺从 SS 的最短路径距离为0,最短路径为空路径。这显然是正确的(这里也利用了边权非负的假设,因为任何非空路径的长度都不可能小于0)。

困难的部分在于归纳步骤,即证明算法未来的所有决策都是正确的。在证明中,我们必须用到“每条边长度非负”这个假设,否则定理就不成立。

归纳步骤:设定与目标

现在,让我们进入归纳步骤。首先,我们需要陈述归纳假设:我们假设到目前为止没有犯任何错误。

更正式地说,对于集合 X 中的每一个顶点 v(即我们已经处理过的所有顶点),我们计算出的最短路径距离估计值 A[v] 实际上就是真实的最短路径距离 L[v]。同时,我们计算出的最短路径 B[v] 实际上就是一条从 Sv 的真实最短路径。

我们假设这在当前迭代之前的所有迭代中都成立,并将在证明当前迭代的正确性时利用这个假设。

在当前迭代中,我们选择了一条边 (v*, w*),并将这条边的头顶点 w* 加入集合 X

根据算法的定义,我们为 w* 指定的“最短路径”是:之前计算出的(据称是)从 Sv* 的最短路径 B[v*],然后在末尾加上直接边 (v*, w*)

用图表示,就是我们已经有了一条从 Sv* 的路径,然后我们在末尾加上一跳到达 w*。我们将把这一整条路径赋值给 B[w*]

现在,让我们使用归纳假设。归纳假设说所有之前的迭代都是正确的,因此我们之前为 v* 计算出的路径 B[v*] 确实是一条从 Sv* 的真实最短路径。所以,这条路径的长度就是 L[v*]L[v*] 的定义就是从 Sv* 的真实最短路径距离)。

因此,我们为 w* 展示的这条路径(即 B[v*] 加上边 (v*, w*))的长度就是 L[v*] + l(v*, w*)。根据算法,我们为 w* 计算的距离 A[w*] 正是Dijkstra贪婪分数,即到尾部 v* 的计算距离加上边 (v*, w*) 的长度。根据归纳假设,A[v*] 是正确的,等于 L[v*],所以 A[w*] = L[v*] + l(v*, w*)

到目前为止,我们还没有进行证明的核心部分,只是在“摆放多米诺骨牌”。我们在当前迭代中做了两件事:

  1. 我们为从源点到新顶点 w* 的距离给出了一个估计值:L[v*] + l(v*, w*)
  2. 我们在数组 B 中存储了一条从 Sw* 的真实路径,其长度正好是这个值。

现在,为了完成归纳步骤(从而完成Dijkstra算法的正确性证明),我们需要证明:我们展示的这条从 Sw* 的路径,不仅仅是任意一条路径,而是所有可能路径中最短的那一条。换句话说,我们需要证明图中任何其他从 Sw* 的路径 P,其长度都至少等于这个画圈的值 L[v*] + l(v*, w*)

证明核心:任意路径的下界

让我们开始证明。考虑任意一条从 Sw* 的路径 P。我们需要证明它的长度至少是 L[v*] + l(v*, w*)

这条路径 P 可能看起来非常复杂,但我们有一个关键观察:任何从 S 开始、到达 w* 的路径,都必须穿越“前沿”。因为路径始于 S,而 S 始终在集合 X 内;路径终于 w*,而 w* 在本轮迭代开始时不在 X 内。因此,路径 P 在某个时刻必须从 X 内部穿越到 X 外部。

让我们关注路径 P 第一次穿越前沿的时刻。假设它通过一条从顶点 y 到顶点 z 的边穿越前沿。也就是说,路径 P 的形式是:先是一段完全在 X 内的前缀(从 Sy),然后通过边 (y, z) 第一次离开 X 到达 z,之后可能再随意游走,但最终到达 w*。注意,zw* 可能是同一个顶点,这完全不影响论证。

现在,我们利用这个结构来给出路径 P 长度的下界。

我们将路径 P 分解为三部分:

  1. 前缀:从 Sy,完全在 X 内。
  2. 跨越边:从 yz 的直接边 (y, z)
  3. 后缀:从 zw* 的剩余部分。

让我们分别分析这三部分的长度下界:

  • 后缀 (z -> w*):这部分路径可能包含多条边。由于我们假设所有边长度非负,因此这部分路径的总长度至少为 0。这是我们在证明中第一次(也是唯一一次)使用“边权非负”的假设。
  • 跨越边 (y -> z):这部分就是边 (y, z) 本身的长度 l(y, z)
  • 前缀 (S -> y):这部分是从 Sy 的一条路径。显然,它的长度至少等于从 Sy 的最短路径距离 L[y]。根据我们的归纳假设,顶点 yX 中,我们已经正确计算出了它的最短路径距离 A[y] = L[y]。因此,前缀的长度至少为 L[y]

将这三部分的下界相加,我们得到路径 P 的总长度至少为:
L[y] + l(y, z) + 0 = L[y] + l(y, z)

应用Dijkstra贪婪准则

为什么这个下界有用呢?我们还有一个尚未使用的关键假设:Dijkstra算法的贪婪选择准则。

请注意,边 (y, z) 是一条从左(X 内,y)到右(X 外,z)跨越前沿的边。因此,在本轮迭代中,这条边完全有资格被算法考虑用来扩展前沿。

根据Dijkstra贪婪准则,算法在所有这样的边中,选择了具有最小Dijkstra贪婪分数的那一条,即 (v*, w*)。贪婪分数的定义正是 L[尾顶点] + l(边)

因此,对于边 (y, z),其贪婪分数 L[y] + l(y, z) 必然大于或等于我们选择的边 (v*, w*) 的贪婪分数 L[v*] + l(v*, w*)。因为我们是选取了最小的那个。

于是,我们得到:
路径 P 的长度 ≥ L[y] + l(y, z) ≥ L[v*] + l(v*, w*)

这正是我们需要证明的!我们证明了任意一条从 Sw* 的竞争路径 P,其长度都至少等于我们算法为 w* 计算出的路径长度。

总结

让我们总结一下证明的所有关键步骤:

  1. 算法构造的路径:Dijkstra算法为 w* 构造了一条路径:之前计算出的到 v* 的最短路径加上边 (v*, w*)。这条路径的长度是 L[v*] + l(v*, w*)
  2. 竞争路径分析:为了证明这是最短的,我们考虑任意一条竞争路径 P。我们观察到 P 必须穿越前沿,并将其分解为前缀、跨越边和后缀三部分。
  3. 长度下界
    • 前缀长度 ≥ 到其终点 y 的最短距离 L[y](由归纳假设保证)。
    • 跨越边长度 = l(y, z)
    • 后缀长度 ≥ 0(由边权非负假设保证)。
    • 因此,P 的长度 ≥ L[y] + l(y, z)
  4. 贪婪选择决胜:边 (y, z) 是跨越前沿的候选边。Dijkstra算法选择了贪婪分数最小的边 (v*, w*)。因此,L[y] + l(y, z) ≥ L[v*] + l(v*, w*)
  5. 得出结论:结合3和4,得到 P 的长度 ≥ L[v*] + l(v*, w*)。这意味着算法找到的路径不比其他任何路径长,因此它就是最短路径。

这个论证构成了归纳步骤,证明了单次迭代的正确性。由于我们有一个平凡正确的基础情况,并且每一次迭代的正确性都依赖于之前所有迭代的正确性,因此通过数学归纳法,我们证明了Dijkstra算法的所有迭代都是正确的。最终,算法为所有顶点计算出的最短路径距离都是正确的。

本节课中,我们一起学*了Dijkstra算法正确性的完整证明。 我们看到了如何利用归纳法、路径分解、非负边权假设以及算法本身的贪婪选择准则,来严谨地论证该算法在边权非负的有向图中总能找到最短路径。理解这个证明有助于我们深入掌握贪心算法的设计思想及其正确性保证的条件。

015:实现与运行时间 🚀

在本节课中,我们将学*如何实际实现Dijkstra最短路径算法。特别是,我们将看到如何通过使用数据结构,获得一个几乎达到线性时间的、极其高效的实现。

概述 📋

我们解决的问题是单源最短路径问题。给定一个有向图和一个源顶点 S,我们假设从 S 到其他每个顶点 V 都存在一条路径(如果不成立,可以通过简单的预处理步骤检测)。我们的任务是找出从源顶点 S 到每个可能目的地 V 的最短路径。此外,图中的每条边都有一个非负的长度,我们用 L(e) 表示。

Dijkstra算法回顾 🔄

上一节我们介绍了Dijkstra算法的核心思想。本节中,我们来看看它的具体实现细节。

Dijkstra算法由一个主循环驱动。算法过程中,我们逐步将一个顶点添加到一个不断演化的集合 X 中。X 是到目前为止已处理的顶点集合。我们维护一个不变式:对于每个已处理的顶点,我们已经计算出了我们认为的到该顶点的最短路径距离。

  • 初始时,X 仅包含源顶点 S。从 S 到自身的距离自然是 0
  • 算法的巧妙之处在于如何确定每次迭代中要添加到集合 X 的顶点。
  • 首先,我们只关注那些跨越边界的边,即尾在 X 内、头在 X 外的边。
  • 对于每条这样的跨越边,我们计算 Dijkstra贪婪分数,其定义为:到弧尾顶点的已知最短路径距离 + 该弧的长度。
  • 我们为每条跨越边计算这个分数,然后选择分数最小的那条边 (v*, w*)
  • 我们将该边的头顶点 w* 添加到集合 X 中,并计算到 w* 的最短路径距离为:到 v* 的已知距离 + 边 (v*, w*) 的长度。

在之前的解释中,我使用了两个数组:数组 A 用于计算最短路径距离,数组 B 用于记录最短路径本身。然而,在实际实现算法时,我们并不需要数组 B。因此,在讨论真实实现时,我们将忽略与数组 B 相关的所有指令。

朴素实现的运行时间 ⏱️

在讨论高效实现之前,我们先分析一下如果按照伪代码直接实现(不使用特殊数据结构),算法的运行时间是多少。

以下是分析步骤:

  1. 主循环迭代次数:算法在将所有顶点都加入 X 后终止。初始时 X 有1个顶点,因此需要 n-1 次迭代。
  2. 每次迭代的工作量:在每次迭代中,我们需要扫描所有边,检查哪些是“合格”的边(尾在 X 内,头在 X 外)。我们可以为每个顶点维护一个布尔变量来跟踪它是否在 X 内。然后,在所有合格的边中,通过穷举搜索找出具有最小Dijkstra分数的边。为每条边计算Dijkstra分数是常数时间操作。

因此,朴素实现的总运行时间与顶点数 n 和边数 m 的乘积成正比,即 O(m * n)

对于具有数百或数千个顶点的小型图,这种实现尚可接受。但我们希望算法能扩展到更大的图,例如具有百万级顶点的图。答案是肯定的,我们可以做得更好。

利用数据结构加速 ⚡

我们无需改变算法本身,而是通过改变算法过程中数据的组织方式来获得加速。这是本课程中第一次使用数据结构来获得算法速度的提升,我们将看到算法设计和数据结构设计之间美妙的相互作用。

你可能会问,是什么线索表明数据结构可能有助于加速Dijkstra算法?关键在于观察工作量来自哪里。在每次主循环迭代中,我们都在进行最小值计算——寻找具有最小Dijkstra分数的边。我们反复进行最小值计算。是否存在一种数据结构,其存在的理由正是为了执行快速的最小值计算?答案是肯定的,那就是数据结构。

在接下来的描述中,我假设你已经熟悉堆数据结构。以下是堆的快速回顾:

  • 堆在逻辑上被视为一棵完全二叉树(尽管通常用数组实现)。
  • 堆的关键性质是堆属性:每个节点的键值必须不大于其两个子节点的键值。这确保了所有键值中的最小值位于树的根节点。
  • 提取最小值(extract-min):只需取出根节点,这就是返回的最小元素。然后将最底层最右边的叶子节点(最后一个元素)交换到根位置,并根据需要将其“冒泡”下沉以恢复堆属性。
  • 插入(insert):将新元素作为新的最底层最右边的叶子节点插入,然后根据需要将其“冒泡”上浮以恢复堆属性。
  • 在Dijkstra算法中,我们还需要能够从堆中间删除元素,这同样可以通过交换元素并根据需要上浮或下沉来实现。
  • 由于堆维护为一棵基本平衡的二叉树,树的高度大约是 log₂(n),其中 n 是堆中元素的数量。
  • 因为每个操作都只需在树的每一层做常数工作量,所以所有这些操作都在 O(log n) 时间内运行。

堆数据结构与Dijkstra算法之间的直观联系在于:Dijkstra算法的主循环每次迭代都需要找到一个最小值。堆擅长在对数时间内找到最小值,这比朴素实现中的线性时间要好得多。

使用堆加速Dijkstra算法 🏎️

现在让我们看看如何使用堆来加速Dijkstra最短路径算法。

由于主循环的每次迭代都负责挑选一条边,你可能会认为我们将把边存储在堆中。但第一个巧妙而重要的想法是:我们实际上用堆来存储顶点,而不是边。

回顾Dijkstra算法的伪代码,我们关注一条边的唯一原因是为了推断出哪个顶点(即该边的头顶点)应该被添加到集合 X 中。因此,我们将直接切入主题,只保留尚未在 X 中的顶点。当我们从堆中提取最小值时,它会告诉我们下一个应该添加到集合 X 中的顶点是哪个。

我们需要在脑海中想象Dijkstra算法在某个中间迭代时的画面:集合 X 中有一批顶点(源顶点加上到目前为止我们已处理的其他顶点),然后是所有尚未处理的顶点 V - X。在这两个集合之间有跨越边界的边。

堆中顶点的键值定义 🔑

因为我们存储的是顶点而不是边,所以我们必须巧妙地定义堆中顶点的键值

我们将维护以下不变式:顶点 V 的键值是所有以该顶点为头、且尾在 X 内的边中,最小的Dijkstra贪婪分数

让我通过一个例子来解释。假设有三个跨越边,它们的Dijkstra分数分别是7、3和5。我们来看 V - X 中这三个顶点的键值应该是什么:

  • 对于最上面的顶点,有两条不同的边以其为头,且尾都在 X 内。那么该顶点的键值应该是这两条边中较小的Dijkstra分数,即 3
  • 对于第二个顶点,只有一条以其为头、尾在 X 内的边。因此该顶点的键值就是那条唯一边的分数,即 5
  • 对于第三个顶点,根本没有以其为头、尾在 X 内的边。对于任何没有合格边指向的 X 外顶点,我们认为其键值为 +∞

理解这些键值的一种方式是:我们把原来“一轮定胜负”的锦标赛,变成了一个“两轮淘汰赛”。

  • 第一轮(本地锦标赛)V - X 中的每个顶点在其所有“入边”(尾在 X 内,头为该顶点)中,选出Dijkstra分数最小的那条边作为“本地优胜者”。堆只记住这些第一轮的优胜者分数(作为该顶点的键值)。
  • 第二轮(总决赛):当我们从堆中执行 extract-min 时,实际上就是在执行第二轮总决赛。堆从所有顶点的本地优胜者中,选出分数最小的那个顶点。

关键在于,如果我们能成功维护这两个不变式,那么当我们从堆中提取最小值时,我们将得到完全正确的顶点 w*,即下一个应该添加到集合 X 的顶点。堆会“端上银盘”递给我们,与之前通过穷举搜索边所计算出的选择完全相同。

在Dijkstra算法中,我们不仅要找到下一个顶点 w*,还要计算其最短路径距离。记住,我们计算的最短路径距离就是Dijkstra贪婪分数,而在这里,Dijkstra贪婪分数正是该顶点在堆中的键值。因此,我们也在以更高效的方式复现朴素实现中的计算。

维护不变式与键值更新 🛠️

我们已经展示了,如果有一个具备这些属性的数据结构,我们就可以模拟朴素实现。现在,我必须展示如何在不做太多工作的情况下维护这些不变式。

维护不变式一(堆中存储的是 V - X 中的顶点)基本上会自行处理。真正的技巧在于如何维护不变式二(键值定义)。

让我指出,这是一个微妙的问题。当我们在一次迭代中从堆中提取一个顶点 W 并将其概念上添加到集合 X 时,XV - X 之间的边界发生了变化。这导致跨越边界的边也发生了变化。

  • 有些边以前跨越边界,现在不再跨越(例如指向新加入顶点 W 的边),这些边我们不关心。
  • 棘手的是,有些边以前不跨越边界,现在却开始跨越了。这些边正是从 W 指出去的边。

为什么这很棘手?因为不变式二要求,对于堆中的每个顶点(即仍在 V - X 中的顶点),其键值必须是所有从 X 指向该顶点的边中最小的Dijkstra分数。当我们将 W 移入 X 后,对于堆中某些顶点,现在可能有新的边(从 W 出发)指向它们。因此,这些顶点的最小Dijkstra分数(即键值)可能降低了,我们需要更新这些键值以维持不变式二。

困难的部分在于更新,但好的一面是:破坏是局部的。我们知道哪些顶点的键值可能需要更新:正是那些位于从 W 出发的边的头顶点

以下是更新键值的伪代码:

当从堆中提取顶点 W 后(即概念上将 W 加入 X):
    对于 W 的每一条出边 (W, V):
        如果 V 仍在堆中(即 V 不在 X 内):
            计算新的候选键值:distance[W] + length(W, V)
            如果这个新值 < V 当前的键值:
                将 V 从堆中删除
                将 V 的键值更新为新值
                将 V 重新插入堆中

作为一个优化,请注意顶点 V 的键值只能以一种方式改变。V 的键值是其所有“入边”(尾在 X 内)中Dijkstra分数的最小值。在加入 W 后,V 的本地锦标赛只是多了一个新参赛者:边 (W, V)。因此,新的键值只能是以下两者之一:

  1. 原来的键值(如果新边 (W, V) 的分数更大或相等)。
  2. 新边 (W, V) 的分数(如果它更小)。

这三行代码(删除、更新、插入)共同完成了一次对数时间的键值更新。

运行时间分析 📊

让我们统计一下这个新实现的运行时间。你需要理解的一点是,几乎所有的工作都是通过堆的API完成的。每个堆操作的时间复杂度是 O(log n),因为堆中存储的是顶点,数量不会超过 n

我们执行了哪些堆操作?

  1. extract-min(提取最小值):在 while 循环的每次迭代中执行一次。共有 n-1 次迭代,所以是 O(n log n)

  2. 键值更新(删除+插入):每次提取最小值后,我们可能需要更新从该顶点出发的边的头顶点的键值。关键是从边的视角来看,而不是顶点的视角。

    • 对于图中的每条边 (V, W),它最多只会触发一次键值减小操作(即一次删除加一次插入)。这种情况发生在边尾 V 被加入 X,而边头 W 仍在堆中(即 X 外)时。
    • 如果 W 先于 V 被加入 X,那么这条边根本不会触发对 V 的键值更新。

因此,堆操作的总数是:extract-min 操作的 O(n) 次,加上由边触发的键值更新操作的 O(m) 次。由于我们假设图是弱连通的(从 S 可到达所有顶点),这意味着 m ≥ n-1。所以总操作数可以简化为 O(m) 次堆操作。

每次堆操作耗时 O(log n),因此,使用堆实现的Dijkstra算法的总运行时间为 O(m log n)

这个算法的常数因子通常很小,因此对于计算最短路径这样一个实用的问题来说,这是一个非常、非常快速的算法。

我们在讨论图搜索和连通性时有点被“惯坏”了,似乎所有问题都能在线性时间 O(m + n) 内解决。这里我们多了一个对数因子,但这仍然非常出色。O(m log n) 的运行时间比朴素实现的 O(m * n) 要快得多。堆数据结构的巧妙运用,为我们解决计算最短路径这个极具实际意义的问题,提供了一个真正意义上的高速算法。

总结 🎯

本节课中,我们一起学*了Dijkstra最短路径算法的高效实现。

  • 我们首先分析了朴素实现的运行时间为 O(m * n),这对于大规模图来说太慢。
  • 接着,我们引入了数据结构,它擅长在 O(log n) 时间内进行最小值操作,这正是Dijkstra算法每次迭代的核心。
  • 我们设计了一个巧妙的方案:在堆中存储顶点,并将顶点的键值定义为“所有从已处理集合 X 指向该顶点的边中最小的Dijkstra分数”。
  • 我们详细说明了如何在算法过程中维护这个键值定义,特别是在将新顶点加入 X 后,需要更新受影响的顶点的键值,这一操作可以在 O(log n) 时间内完成。
  • 最终,我们分析了基于堆的Dijkstra算法的运行时间为 O(m log n),这相比朴素实现是一个巨大的提升,使得算法能够高效处理大规模图。

通过结合算法设计(Dijkstra的贪心策略)和恰当的数据结构(堆),我们获得了一个既优雅又高效的解决方案。

016:数据结构概述

在本节课中,我们将要学*数据结构的基本概念、重要性以及学*数据结构的不同层次。理解数据结构的核心在于知道如何根据任务需求选择合适的结构来组织数据,以实现高效的数据访问和操作。

为什么需要数据结构?

数据结构是几乎所有主要软件的基础。它的核心任务是以能够快速、有效访问的方式组织数据。掌握何时以及如何使用基本数据结构,是每位程序员必备的核心技能。

数据结构的多样性

存在许多数据结构,从简单的列表、栈和队列,到更复杂但非常有用的堆、搜索树、哈希表,以及它们的变体,如布隆过滤器、并查集等。

之所以存在如此繁多且令人眼花缭乱的数据结构,是因为不同的数据结构支持不同的操作集合,因此它们各自适合不同类型的任务。

一个具体例子:图搜索

上一节我们介绍了数据结构的基本概念,本节中我们来看看一个具体应用。回想我们在讨论图搜索,特别是广度优先搜索和深度优先搜索时提到的例子。

  • 广度优先搜索 适合使用队列。队列支持从后端的快速(常数时间)插入和从前端的快速(常数时间)删除。
    • queue.push(item) // 插入后端
    • queue.pop() // 从前端删除
  • 深度优先搜索 由于其递归性质,更适合使用。栈支持从前端的快速(常数时间)插入和删除。
    • stack.push(item) // 插入前端
    • stack.pop() // 从前端删除

栈的后进先出特性适合深度优先搜索,而队列的先进先出操作则适用于广度优先搜索。

权衡:操作与效率

因为不同数据结构适合不同任务,所以你需要了解基本数据结构的优缺点。一般来说,一个数据结构支持的操作越少,其操作速度通常越快,所需的空间开销也越小。

因此,作为程序员,仔细思考应用程序的需求至关重要。你需要明确数据结构必须支持哪些操作,然后选择正确的数据结构——即支持你所需全部操作,但理想情况下不包含多余操作的那个。

数据结构知识的四个层次

以下是关于数据结构知识掌握程度的四个层次划分:

  • 第0层:无知。从未听说过数据结构,没有意识到组织数据可以产生本质上更好的软件(例如,更快的算法)。
  • 第1层:认知层。至少能就基本数据结构进行对话。听说过堆、二叉搜索树等概念,或许了解一些基本操作,但在自己的程序或技术面试中使用时会感到生疏。
  • 第2层:熟练应用层。对数据结构有扎实的了解。能够自如地在自己的程序中作为“客户端”使用它们,并且很清楚哪种数据结构适合哪种类型的任务。
  • 第3层:深入理解层。不满足于仅仅作为数据结构的客户端来使用,而是真正理解这些数据结构的内部原理、如何编码以及如何实现。

本课程的教学重点

我猜测,你们中的大多数人最终会在自己的程序中使用数据结构。因此,学*不同数据结构的操作及其适用场景,将是一项对编程能力大有裨益的技能。另一方面,我敢打赌,很少有人需要从头开始实现自己的数据结构,而不是仅仅作为客户端使用各种标准编程库中已有的数据结构。

考虑到这一点,我的教学将重点放在将你们提升到第2层。讨论将集中在各种数据结构支持的操作和一些典型应用上。希望通过这些内容,培养你们对于何种数据结构适合何种任务的直觉。

如果时间允许,我也会为那些希望更上一层楼、学*这些数据结构内部原理和经典实现方式的同学,提供一些可选材料。

总结

本节课中我们一起学*了数据结构的基本概念。我们了解到,数据结构是高效软件的基础,其核心价值在于根据特定操作需求来组织数据。我们通过图搜索的例子看到了队列和栈如何因其不同的操作特性而适用于不同的算法。最后,我们明确了本课程的目标是帮助大家达到能够熟练选择并应用合适数据结构的水平。

017:操作与应用 🧱

在本节课中,我们将要学*堆(Heap)这种数据结构。我们将明确堆支持哪些操作、这些操作的预期运行时间,并了解堆适用于解决哪些类型的问题。我们首先关注如何作为使用者来使用堆,其具体实现细节将在后续视频中探讨。

堆的基本概念

堆是一种用于存储多个对象的容器。每个对象都应有一个键(Key),例如一个数字,以便我们可以比较不同对象的键值大小。

示例对象与键:

  • 对象可以是员工记录,键是社会保险号。
  • 对象可以是网络中的边,键是边的长度或权重。
  • 对象可以表示事件,键是该事件预定发生的时间。

堆支持的操作与运行时间

对于一个数据结构,首先需要记住它支持哪些操作,其次是这些操作的预期运行时间。

堆本质上支持两个基本操作:

  1. 插入(Insert):将一个带键的对象插入堆中。
  2. 提取最小键值对象(Extract Min):从堆中移除并返回具有最小键值的对象。

在我们的讨论中,允许键值重复(平局)。当从存在重复最小键值的堆中执行提取操作时,规范并未指定返回哪一个,你只会得到其中一个具有最小键值的对象。

当然,你也可以选择实现一个最大堆(Max Heap),它总是返回具有最大键值的对象。如果你只有最小堆,可以通过在插入前将所有键值取反,然后使用 Extract Min 来模拟提取最大键值对象。

运行时间保证:
在堆的标准实现中,上述两个操作的运行时间都是对数级别的,具体是 O(log n),其中 n 是堆中对象的数量。其常数因子通常很小。

堆的附加操作

除了核心操作,堆还可以支持一些附加操作,值得了解。

以下是堆可以支持的一些附加操作:

  • 堆化(Heapify):此操作可以在线性时间内初始化一个堆。如果你有 n 个对象,逐个插入需要 O(n log n) 时间,但堆化操作能以 O(n) 的时间批量完成。
  • 任意删除(Delete):虽然有些微妙,但可以实现从堆中删除任意元素(而不仅仅是最小值),其运行时间也是对数级别的。我们将在使用堆加速迪杰斯特拉算法时用到此操作。

堆的应用场景

上一节我们介绍了堆的基本操作,本节中我们来看看堆的典型应用场景。使用堆最常见的原因是,你发现程序正在执行重复的最小值计算,尤其是通过穷举搜索的方式。我们将看到,通过简单地应用堆,可以极大地提升这类算法的速度。

应用一:堆排序(Heap Sort)

让我们从最经典的计算问题——排序开始。选择排序(Selection Sort)是一种直观但次优的算法:它反复扫描未排序数组,找到最小元素并放到正确位置。这需要进行 O(n) 次线性扫描,总时间为 O(n^2)

这正符合“重复进行穷举搜索以计算最小值”的模式。我们可以用堆来优化。

堆排序算法:

  1. 将数组中的所有元素插入一个最小堆。
  2. 反复从堆中提取最小元素,并依次放入结果数组中。

运行时间分析:
我们进行了 n 次插入和 n 次提取,每次操作耗时 O(log n)。因此,总运行时间为 O(n log n)

代码描述:

def heap_sort(arr):
    heap = MinHeap()
    for num in arr:
        heap.insert(num)
    sorted_arr = []
    while not heap.is_empty():
        sorted_arr.append(heap.extract_min())
    return sorted_arr

总结:
我们通过识别选择排序中的重复最小值计算模式,引入堆数据结构,成功地将一个 O(n^2) 的算法优化为最优的 O(n log n) 比较排序算法。堆排序是一个实用且高效的算法。

应用二:事件模拟与优先队列(Priority Queue)

在这个应用中,堆通常被称为优先队列。设想你需要编写一个物理世界模拟软件,例如篮球视频游戏。

场景:

  • 对象是事件记录(如“球在特定时间到达篮筐”)。
  • 键是事件的时间戳(预定发生的时间)。
  • 模拟需要反复找出下一个即将发生的事件(即时间戳最小的事件)。

如果维护一个无序事件列表并通过线性扫描找最小值,效率低下。这同样是重复的最小值计算问题。

解决方案:
将所有事件记录存储在一个最小堆中,键为时间戳。每当需要获取下一个事件时,只需执行一次 O(log n)Extract Min 操作即可。

应用三:动态中位数维护(Median Maintenance)

这是一个不那么直观但很能体现堆巧思的应用。

问题描述:
你被要求实时接收一系列数字(一张张索引卡)。在每次接收到一个新数字后,你需要立即返回当前所有已接收数字的中位数。要求每次计算中位数的耗时仅为 O(log i),其中 i 是当前已接收的数字数量。

解决方案提示:
使用两个堆

以下是使用两个堆维护动态中位数的核心思路:

  • H_low(低堆):一个最大堆,用于存储较小的一半数字。
  • H_high(高堆):一个最小堆,用于存储较大的一半数字。
  • 关键不变量:始终保持 H_low 中的元素是当前所有数字中较小的一半,H_high 中的是较大的一半。当总数为奇数时,允许其中一个堆多一个元素。

算法流程:

  1. 将新数字 xH_low 的最大值(即当前较小一半中的最大值)比较。
  2. 如果 x 小于等于该值,则将其插入 H_low;否则插入 H_high
  3. 重新平衡:如果某个堆的元素数量比另一个堆多出超过1个,则从元素多的堆中提取其极值(从 H_low 提取最大值,或从 H_high 提取最小值),并插入另一个堆。
  4. 查询中位数
    • 如果两个堆大小相等,中位数是两个堆顶元素的平均值。
    • 如果大小不等,中位数是元素更多的那个堆的堆顶元素。

每次插入和可能的重新平衡都只涉及常数次堆操作,因此每次更新耗时 O(log i)

应用四:加速迪杰斯特拉算法(Dijkstra‘s Algorithm)

迪杰斯特拉最短路径算法有一个核心的 while 循环,在朴素实现中,每次循环迭代都需要通过穷举搜索计算一个最小值。这再次落入了堆的“能力范围”。

通过精心部署堆(具体是一个优先队列),我们可以将迪杰斯特拉算法的运行时间从 O(mn)(其中 m 是边数,n 是顶点数)这个较大的多项式时间,显著降低到*乎线性的 O(m log n)。虽然比线性多了一个对数因子,但这使得该算法变得极其高效。具体细节将在专门讨论迪杰斯特拉算法优化的视频中阐述。

总结 🎯

本节课中我们一起学*了堆数据结构。我们明确了堆的核心是支持插入提取最小(或最大)值操作,且这些操作能在 O(log n) 的对数时间内完成。我们认识到,当遇到需要重复计算最小值(尤其是通过穷举搜索)的问题时,应考虑使用堆来优化。通过堆排序、事件模拟(优先队列)、动态中位数维护以及加速迪杰斯特拉算法等多个应用实例,我们看到了堆如何将低效的算法转化为高效、实用的解决方案。

018:-18-12 堆的实现细节进阶 🧱

在本节课中,我们将深入学*堆(Heap)数据结构的实现细节。我们将探讨如何从零开始编写一个堆,重点关注其核心操作——插入和提取最小值——的底层实现逻辑。我们将看到,堆虽然逻辑上是一棵树,但在实际代码中通常使用数组高效实现。

堆的回顾与核心操作

上一节我们介绍了堆的基本概念。本节中,我们来看看它的具体实现。首先,我们需要明确堆的核心用途和它支持的操作。

堆是一个容器,用于存储对象。每个对象除了可能包含其他数据外,还必须有一个可比较的键(Key),例如社保号、网络边的权重或事件的时间戳。

堆主要支持两个高效操作:

  1. 插入(Insert):向堆中添加一个新对象。
  2. 提取最小值(Extract Min):从堆中移除并返回具有最小键值的对象。

这两个操作的时间复杂度都是 O(log n),其中 n 是堆中对象的数量。堆允许键值重复,当有多个对象具有相同的最小键值时,提取操作会返回其中之一(具体是哪一个未作规定)。

堆的两种视图:树与数组

要理解堆的工作原理,必须同时掌握它的两种视图:逻辑上的树形结构和物理上的数组存储。我们将从树形视图开始,这有助于解释堆操作的原理。

堆的逻辑结构:完全二叉树

在概念上,我们将堆视为一棵满足特定条件的二叉树:

  • 有根(Rooted):有一个根节点。
  • 二叉树(Binary):每个节点最多有两个子节点。
  • 完全(Complete):树的结构尽可能“满”。这意味着除了最底层,所有层都被完全填满,并且最底层的节点都尽可能靠左排列。

例如,一个包含9个节点的“尽可能满”的二叉树结构如下(图中应为树形结构,此处用文字描述):

  • 第0层(根):1个节点。
  • 第1层:2个节点。
  • 第2层:4个节点。
  • 第3层:2个节点(从左到右填充)。

堆序性质

堆序性质(Heap Property)规定了对象在这棵树中的排列顺序:

对于树中的任意节点 X,存储在 X 处的对象的键值必须不大于其所有子节点的键值。

这意味着,如果一个节点有零个、一个或两个子节点,那么所有这些子节点的键值都至少和该节点的键值一样大。

一个关键推论是:堆中的最小键值始终位于根节点。这正好方便了我们实现“提取最小值”操作。

堆的物理实现:数组映射

虽然我们在脑海中将堆想象成树,但实际编码时并不使用指针来构建树节点。由于堆是完全二叉树,我们可以用一种非常高效的方式——数组——来实现它。

以下是将树形堆映射到数组的方法:

  1. 将树的节点按层(从上到下,从左到右)的顺序放入数组。
  2. 根节点放在数组的第一个位置(索引 1,为简化计算,有时也从索引 0 开始,但本教程使用索引 1)。
  3. 接着放入第一层的所有节点,然后是第二层,以此类推。

示例:一个包含9个元素的堆,其数组表示如下:

  • 数组索引:[1, 2, 3, 4, 5, 6, 7, 8, 9]
  • 对应键值:[4, 4, 8, 4, 9, 9, 12, 13, 5] (此顺序对应树的层级遍历)

父子节点关系的计算

数组实现的妙处在于,我们不需要存储指针就能快速找到任何节点的父节点或子节点。这些关系可以通过简单的算术计算得出:

  • 父节点:对于数组中索引为 i 的节点(i > 1),其父节点的索引是 floor(i / 2)
    • 公式:parent(i) = i // 2 (整数除法)
  • 子节点:对于索引为 i 的节点,其左子节点索引为 2*i,右子节点索引为 2*i + 1
    • 公式:left_child(i) = 2 * i, right_child(i) = 2 * i + 1

示例

  • 节点在索引 2,其父节点在索引 1 (2//2=1),子节点在索引 4 (2*2) 和 5 (2*2+1)。
  • 节点在索引 3,其父节点在索引 1 (3//2=1),子节点在索引 6 (2*3) 和 7 (2*3+1)。

这种实现方式带来了两大优势:

  1. 空间高效:无需额外空间存储指针。
  2. 速度高效:通过简单的乘除运算(甚至可以用更快的位运算实现)即可访问父子节点,常数时间内完成。

堆操作的实现

了解了堆的数组表示后,我们来看看如何实现插入和提取最小值操作。我们将通过示例来说明,你可以很容易地将这些步骤推广到通用情况并编写代码。

操作一:插入(Insert)

插入操作的目标是将一个新元素加入堆,并保持堆序性质和完全二叉树结构。

步骤

  1. 添加到末尾:为了保持树的完全性,新元素必须被添加到树最后一层最右边的空缺位置,也就是数组的末尾。这是一个常数时间操作。
  2. 向上调整(Bubble Up / Sift Up):将新元素与其父节点比较。如果新元素的键值小于其父节点的键值,就违反了堆序性质。
    • 交换这两个节点的位置。
    • 重复此过程,将新元素不断与新的父节点比较并交换,直到:
      a) 新元素的键值不再小于其父节点的键值。
      b) 新元素到达了根节点。

示例:假设我们有一个堆,要插入键值为 5 的元素。

  1. 将 5 放在数组末尾(成为某个节点的左子节点)。
  2. 发现 5 小于其父节点 12,交换它们。
  3. 现在 5 的父节点是 8,5 小于 8,再次交换。
  4. 现在 5 的父节点是 4,5 大于 4,停止。堆序性质恢复。

时间复杂度分析:由于堆是完全二叉树,其高度约为 log₂n。在最坏情况下,新元素需要从最底层一直交换到根节点,因此插入操作的时间复杂度为 O(log n)

操作二:提取最小值(Extract Min)

提取最小值操作的目标是移除并返回堆中的最小元素(根节点),同时保持堆的结构和性质。

步骤

  1. 移除根节点:记录根节点的值(即最小值)以便返回。
  2. 用末尾元素填充根位:将堆中最后一个元素(数组末尾的元素)移动到根节点的位置。这保证了树结构的完全性。
  3. 向下调整(Bubble Down / Sift Down):现在根节点的元素可能破坏了堆序性质。将其与它的两个子节点比较。
    • 如果它比至少一个子节点大,则将其与较小的那个子节点交换。
    • 重复此过程,将该元素不断与新的子节点比较并交换,直到:
      a) 它不大于它的任何子节点。
      b) 它到达了叶子节点(没有子节点)。

示例:从堆中提取最小值。

  1. 移除根节点 4(最小值)。
  2. 将最后一个元素 13 移到根节点。
  3. 现在根节点 13 大于子节点 4 和 8。与较小的子节点 4 交换。
  4. 现在 13 的位置其子节点是 9 和 4(另一个4)。与较小的子节点 4 交换。
  5. 现在 13 成为叶子节点,停止。堆序性质恢复。

时间复杂度分析:与插入操作类似,向下调整的过程最多需要从根节点移动到叶子节点,经过 log₂n 层,每层进行常数次比较和交换。因此,提取最小值操作的时间复杂度也是 O(log n)

总结 🎯

本节课中,我们一起深入探讨了堆数据结构的实现细节:

  1. 双重视角:堆在逻辑上是一棵满足堆序性质的完全二叉树,在物理上则使用数组高效实现。
  2. 高效映射:利用完全二叉树的特性,可以通过简单的索引计算(parent(i)=i//2, child(i)=2*i, 2*i+1)在常数时间内找到任何节点的父节点和子节点,无需指针。
  3. 核心操作实现
    • 插入:将新元素置于数组末尾,然后通过向上调整(Bubble Up) 恢复堆序。
    • 提取最小值:移除根节点,将末尾元素移至根位,然后通过向下调整(Bubble Down) 恢复堆序。
  4. 时间复杂度:由于堆的高度为 O(log n),插入和提取最小值操作的时间复杂度均为 O(log n)

理解了这些底层机制后,你不仅能够使用堆,更能从零开始实现它,并深刻理解其高效性的来源。这正是成为一名更硬核的程序员和计算机科学家的重要一步。

019:-19-13 Balanced Search Trees Operations and Applications

在本节课中,我们将要学*最后一个,但同样重要的数据结构:平衡二叉搜索树。我们将从客户端的视角开始,了解这个数据结构支持哪些操作,以及它的实际用途。接着,我们将深入其内部实现,理解这些操作为何具有特定的运行时间。

📚 概述:平衡二叉搜索树是什么?

平衡二叉搜索树可以被视为一个动态版本的排序数组。如果你的数据存储在这种结构中,你几乎可以执行所有在静态排序数组上能进行的操作。此外,它还能高效地处理数据的插入和删除,从而适应动态变化的数据集。

🔍 从排序数组看支持的操作

为了理解平衡二叉搜索树支持的操作,让我们先从一个排序数组开始,看看在这种存储方式下可以轻松完成哪些任务。

假设我们有一个存储数值数据的数组。这些数值通常是每条记录的唯一标识符,例如员工ID、社保号或网络数据包ID等。

以下是排序数组上容易实现的一些操作:

1. 搜索
在排序数组中搜索通常使用二分查找。其核心思想是每次递归都将搜索范围缩小一半,因此运行时间为对数时间 O(log n)

2. 选择
选择问题是指,给定一个顺序统计量 i,找出数组中第 i 小的元素。在排序数组中,这非常简单:直接返回数组中第 i 个位置的元素即可,运行时间为常数时间 O(1)。寻找最小值和最大值是选择问题的特例。

3. 前驱与后继
给定一个元素(例如23),前驱操作返回数组中比它小的下一个元素(例如17),后继操作返回比它大的下一个元素(例如30)。在排序数组中,这只需要向前或向后移动一个位置,运行时间为常数时间 O(1)

4. 排名
排名操作询问数据集中有多少个键小于或等于给定的键。例如,23的排名是6。实现排名操作与搜索类似:对给定键执行二分查找,搜索终止的位置索引(或相邻位置)即为其排名。其运行时间也为对数时间 O(log n)

5. 顺序输出
按顺序(如从小到大)输出所有存储的键。在排序数组中,只需从左到右扫描一次数组即可,运行时间为线性时间 O(n)

🚀 动态数据的需求与平衡二叉搜索树的优势

上一节我们介绍了排序数组支持的一系列强大操作。然而,现实世界的数据通常是动态变化的。例如,公司员工名单会随着新员工加入和老员工离职而改变。因此,我们需要一个不仅能支持上述操作,还能高效处理插入和删除的数据结构。

在排序数组中,插入和删除操作通常需要移动大量元素以维持有序性,其运行时间为线性时间 O(n),这在频繁更新的场景下是不可接受的。

平衡二叉搜索树的设计目标,正是要实现与排序数组同样丰富的操作集合,同时支持快速的插入和删除。虽然部分操作(如选择、前驱、后继)的运行时间会从常数时间变为对数时间,但所有核心操作(包括插入和删除)都能在对数时间内完成。

以下是平衡二叉搜索树支持的操作及其运行时间总结:

  • 搜索O(log n)
  • 选择O(log n) (在排序数组中为 O(1)
  • 最小值/最大值O(log n) (在排序数组中为 O(1)
  • 前驱/后继O(log n) (在排序数组中为 O(1)
  • 排名O(log n)
  • 顺序输出O(n)
  • 插入O(log n) (排序数组为 O(n)
  • 删除O(log n) (排序数组为 O(n)

关键结论是:如果你的数据具有来自全序集(如数值)的键,并且你需要利用这些键的排序信息进行多种处理,那么平衡二叉搜索树是一个非常强大的选择。

⚖️ 与其他数据结构的比较

尽管平衡二叉搜索树功能强大,但我们也需要了解其他数据结构的适用场景,因为它们在某些特定任务上可能表现更优。

1. 排序数组
如果你的数据集是静态的,不需要插入和删除,那么排序数组是最佳选择。它能以常数或对数时间完成所有查询操作,且实现简单高效。

2. 堆
堆是一个动态数据结构,支持对数时间的插入和删除(O(log n)),并能高效地追踪最小(或最大)元素。然而,它不能同时追踪最小和最大值,也不支持基于键的任意搜索、排名等操作。如果你的应用场景类似于优先队列,只需要快速获取最值并进行插入删除,那么堆是更轻量、更高效的选择(常数因子更优)。

3. 哈希表
哈希表极其擅长处理插入、查找(有时包括删除),并能提供平摊常数时间 O(1) 的性能。但是,哈希表完全不维护键之间的任何顺序信息。如果你只需要快速判断一个键是否存在或进行快速检索,而不关心最小值、最大值或排序,那么哈希表是比平衡二叉搜索树更优的选择。

📝 总结

本节课中,我们一起学*了平衡二叉搜索树的核心概念。我们首先从客户端视角出发,将其理解为动态的排序数组,并详细列举了它支持的一系列丰富操作,包括搜索、选择、前驱后继、排名以及顺序遍历,特别是其高效的对数时间插入和删除能力。

接着,我们探讨了平衡二叉搜索树与其他数据结构的比较:对于静态数据,排序数组更优;对于只需要最值操作的动态场景,堆更高效;对于仅需快速存在性检查的场景,哈希表性能最佳。平衡二叉搜索树的强大之处在于它在一个数据结构中集成了对有序键值的多种动态操作,是处理需要利用顺序信息的动态数据集的理想选择之一。

020:-20-13 二叉搜索树基础,第一部分 🌳

在本节课中,我们将学*如何实现二叉搜索树的基础知识。我们不会在本视频中讨论平衡性,这将在后续视频中深入探讨。我们将关注所有二叉搜索树(无论是否平衡)都具备的通用特性。

概述:为什么需要二叉搜索树? 🎯

首先,让我们回顾一下使用这种数据结构的原因。平衡版本的二叉搜索树本质上是一个动态版本的排序数组。它几乎能完成排序数组的所有操作,虽然时间可能稍长,但仍然非常快。更重要的是,它是动态的,支持高效的插入和删除操作。

在排序数组中,每次插入或删除都可能需要线性时间,这在大多数应用中代价过高。相比之下,平衡搜索树可以在对数时间内完成插入、删除和搜索操作。此外,它还能解决选择问题(如查找最小值或最大值),并能在线性时间内按顺序输出所有键值。

二叉搜索树的结构 🏗️

接下来,我们来看看二叉搜索树是如何组织的。本节内容对平衡和非平衡搜索树均适用,平衡性将在后续视频中讨论。

二叉搜索树的核心要素是节点和指针。树中的每个节点对应一个存储的键值。在实际数据结构中,节点通常包含一个键值和一个指向更多信息(如员工数据)的指针。为简化讨论,我们假设节点只包含键值。

每个节点包含三个指针:一个指向左子节点,一个指向右子节点,一个指向父节点。这些指针可以为空。例如,在右侧的示例树中,键值为1的节点左子指针为空,右子指针指向键值为2的节点,父指针指向键值为3的节点。

搜索树属性 🔑

搜索树属性是二叉搜索树最根本的特性。它断言:对于树中的任意节点,其左子树中的所有键值都小于该节点的键值,其右子树中的所有键值都大于该节点的键值。

用公式表示,若节点键值为 x,则:

  • 左子树中所有键值 < x
  • 右子树中所有键值 > x

此属性必须对树中的每一个节点都成立。我们假设所有键值互异。若允许重复键值,只需约定如何处理相等情况,例如规定左子树键值 ≤ x,右子树键值 > x

这个属性并非随意设定,而是为了使搜索操作变得简单。它确保了在搜索时,每一步都能明确地排除一半的搜索空间,这与二分查找的思想一致。

搜索树属性与堆属性的区别 ⚖️

这里需要区分搜索树属性与之前学过的堆属性。堆属性要求父节点小于(或大于)其子节点,旨在使查找最小(或最大)值变得容易(根节点即是)。而搜索树属性旨在优化搜索过程:要查找更小的值就向左,要查找更大的值就向右。两者服务于不同的操作优化目标。

搜索树的形态多样性 🌲

理解搜索树形态的多样性非常重要,这对后续理解平衡性至关重要。对于同一组键值集合,可以存在多种不同的、都满足搜索树属性的树结构。

例如,对于键值集合 {1, 2, 3, 4, 5},可以构造出高度为2的平衡树,也可以构造出高度为4的链状树(如5->4->3->2->1)。树的高度(从根到叶子的最长路径跳数)可以从最佳情况下的 O(log n) 到最坏情况下的 O(n)

基本操作实现 ⚙️

理解了二叉搜索树的基本结构后,我们现在可以探讨如何实现其支持的各种操作。以下将逐一介绍这些操作的高层描述,足以指导你编写自己的实现。

搜索操作 🔍

搜索操作直接利用了搜索树属性。我们从根节点开始查找键值 K

  1. 若当前节点键值等于 K,搜索成功。
  2. K 小于当前节点键值,根据搜索树属性,K 只可能存在于左子树中,因此递归搜索左子树。
  3. K 大于当前节点键值,则递归搜索右子树。
  4. 如果沿着指针走到了空值(null),则说明 K 不在树中,搜索失败。

这个过程确保了每一步都能缩小搜索范围。

插入操作 ➕

插入操作建立在搜索的基础上。我们首先假设键值不重复:

  1. 首先,搜索要插入的键值 K。由于没有重复,搜索必然会失败,并终止于一个空指针。
  2. 此时,我们只需将这个空指针(来自其父节点)重新指向一个新创建的、包含键值 K 的节点。

例如,要在示例树中插入键值6,搜索会从根3到5,然后试图访问5的右子节点(为空)。我们就在5的右子节点位置插入新节点6。

如果允许重复键值,只需在搜索时约定遇到相等键值后的处理方式(例如,总是继续搜索左子树),最终同样会在一个空指针处插入新节点。

一个值得思考的练*是:按照此过程插入新节点后,整棵树依然保持搜索树属性。

总结 📝

本节课我们一起学*了二叉搜索树的基础知识。我们了解了其作为动态排序数组替代品的动机,掌握了其核心的搜索树属性,并认识到同一组键值可以构成高度差异巨大的不同搜索树。我们还初步探讨了搜索插入这两个基本操作的高层实现逻辑,它们都巧妙地利用了搜索树属性来高效地定位目标位置。在接下来的课程中,我们将继续学*删除、遍历等其他操作,并深入探讨如何维护树的平衡性。

021:-21-13 二叉搜索树基础(第二部分) 🌳

在本节课中,我们将继续学*二叉搜索树,深入探讨其核心操作的实现原理与时间复杂度。我们将涵盖搜索、插入、查找最小/最大值、查找前驱/后继、中序遍历、删除以及选择与排名等操作,并理解树的高度如何影响这些操作的性能。


搜索与插入的时间复杂度 ⏱️

上一节我们介绍了二叉搜索树的搜索和插入操作。本节中,我们来看看这些操作在最坏情况下的运行时间受什么因素影响。

二叉搜索树包含N个不同的键,以下哪个参数决定了搜索或插入操作的最坏情况时间?

  • 树中键的数量 N
  • 树中叶子节点的数量
  • 树的高度
  • 树中节点的平均深度

正确答案是第三个:树的高度决定了搜索或插入操作的最坏情况时间。这意味着,仅知道键的数量N不足以推断最坏情况的搜索时间,还必须了解树的结构。

为了理解这一点,让我们回顾之前用过的两个例子。一个树是平衡良好的,另一个树包含完全相同的五个键,但却是极度不平衡的,实际上就像一个链表。

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

  • 在左边的平衡树中,你最多需要跟随三个这样的指针。例如,搜索2.5时,你会跟随一个左指针,然后一个右指针,再一个右指针,然后遇到空指针,总共跟随了三个指针。
  • 在右边的不平衡树中,你可能需要跟随多达五个指针,然后第五个指针才为空。例如,搜索键0时,你将连续遍历五个左指针,最后在末尾遇到空指针。

因此,在最坏情况下,操作时间与树的高度成正比。如果是一棵像左边那样平衡良好的二叉搜索树,时间复杂度与键的数量N的对数成正比,即 O(log N)。如果是一棵像右边那样糟糕的搜索树,时间复杂度则与键的数量N成正比,即 O(N)。总的来说,搜索或插入时间与树的高度成正比,即从根节点到叶子节点所需的最大跳数。


更多支持的操作:最小/最大值、前驱/后继 🔍

让我们继续探讨搜索树支持的一些其他操作,这些操作是堆和哈希表等动态数据结构所不具备的。

首先是最小值和最大值操作。相比之下,在堆中,你可以轻松找到最小值或最大值,但不能同时轻松找到两者。而在搜索树中,可以非常容易地找到最小值或最大值。

让我们从最小值开始。一种思考方式是,在搜索树中搜索负无穷。你从根节点开始,持续跟随左子指针,直到无法继续(遇到空指针)。你最后访问的那个键必然是树中的最小键。因为,假设根节点不是最小值,那么最小值必然在左子树中。你跟随左子指针,然后重复这个论证。如果你还没有找到最小值,那么相对于当前位置,它必然在左子树中。你只需迭代,直到无法再向左移动。

例如,在我们运行的搜索树示例中,如果我们持续跟随左子指针,会从3开始,到1,然后尝试从1向左移动,遇到空指针,于是返回1。1确实是这棵树中的最小键。

既然我们已经了解了如何计算最小值,那么如何计算最大值也就不难猜到了。当然,要计算最大值,我们只需对称地跟随右子指针,这保证能找到树中的最大键,就像搜索正无穷的键一样。

接下来是计算前驱。这意味着给定树中的一个键(树中的一个元素),你想找到下一个最小的元素。例如,3的前驱是2,2的前驱是1,5的前驱是4,4的前驱是3。

我将简要说明,以便在合理时间内覆盖所有操作。需要指出的是,这里有一个非常简单的情况和一个稍复杂的情况。

简单情况是:当包含键K的节点有一个非空的左子树时。在这种情况下,你想要的正是该节点左子树中的最大元素。我留给你去正式证明,对于确实有非空左子树的键,这确实是计算前驱的正确方法。让我们通过检查示例树中具有左子树的节点来验证这一点。实际上只有两个节点有非空的左子树:节点3有一个非空的左子树,其左子树中的最大键确实是2,而2就是3的前驱;节点5的左子树只包含元素4,该子树中的最大值也是4,而4确实是整个搜索树中5的前驱。

较复杂的情况是:如果一个键根本没有左子树,该怎么办?给定这个包含键K的节点,你只有三个指针,根据假设,左指针为空,所以它无法带你去任何地方。右子指针,如果你仔细想想,对于计算前驱是完全无用的,因为前驱是一个小于给定键K的键,而根据二叉搜索树的定义,右子树只包含大于K的键。因此,要找到前驱,我们必须跟随父指针,可能不止一个父指针。

为了说明如何跟随父指针,让我们看看右边我们最喜欢的搜索树中的几个例子。从节点2开始。我们知道必须跟随一个父指针,当我们跟随2的父指针时,我们到达1,而1正是这棵树中2的前驱。所以计算2的前驱似乎只需要跟随父指针。

再看另一个例子,节点4。当我们跟随4的父指针时,我们到达5,而5不是4的前驱,它是4的后继。我们想要一个比我们起始点小的键,我们跟随了父指针,但它变大了。然而,如果我们再跟随一个父指针,我们就到达3。所以从2开始,我们需要跟随一个父指针;从4开始,我们需要跟随两个父指针。关键在于,你只需要持续跟随父指针,直到到达一个键小于你自身键的节点,此时你可以停止,这保证就是前驱。

希望你觉得这很直观。我必须说明,我并没有正式证明这是有效的,对于那些想要更深入理解搜索树及其神奇属性的人来说,这是一个很好的练*。

我还应该提到另一种解释终止条件的方式:当你第一次向左转时,你就会停止对父指针的搜索。也就是说,你第一次从一个右子节点转到其父节点时(即发生了一次向左的转向),你就到达了前驱。例如,当我们从2开始时,我们向左转(2是1的右子节点),我们一步就到达了前驱。相反,当我们从4开始时,第一步是向右(4是5的左子节点,所以它小于5),但当我们下一步向左转时,我们到达了一个节点,它不仅小于5,而且小于我们的起始点4。实际上,你会在第一次向左转时看到一个小于你起始点的键。

这是另一种陈述,我认为很直观,但形式上并不完全明显。我再次鼓励你仔细思考为什么这两种终止条件的描述是完全相同的。无论你是在第一次找到小于起始点的键时停止,还是在第一次跟随一个父指针(该指针从一个节点出发,且该节点是其父节点的右子节点)时停止,你都会在完全相同的时刻停止。我鼓励你思考为什么这些是完全相同的停止条件。

还有一些其他细节:如果你从唯一没有前驱的节点(即最小值节点)开始,你将永远不会触发这个终止条件。例如,如果你从搜索树中的节点1开始,不仅左子树为空(这意味着你应该开始遍历父指针),而且当你遍历父指针时,你只会向右转,永远不会向左转,这是因为没有前驱。这就是你检测自己处于搜索树最小值的方式。当然,如果你想计算一个键的后继而不是前驱,显然只需在整个描述中交换左和右即可。

以上就是对这些不同排序操作(最小值、最大值、前驱、后继)在搜索树中如何工作的高层次解释。让我问你一个与讨论搜索和插入时相同的问题:这些操作在最坏情况下需要多长时间?

答案与之前相同:与树的高度成正比。解释也完全相同。为了理解对高度的依赖,让我们只关注问题中提到的最大值操作。其他三个操作,由于完全相同的原因,在最坏情况下的运行时间也与高度成正比。最大值操作是做什么的?你从根开始,持续跟随右子指针,直到用完它们(遇到空指针)。所以运行时间不会比最长路径更差,它是一条从根到叶子的特定路径。因此,运行时间永远不会超过树的高度。另一方面,从根到最大键的路径很可能就是树中最长的路径,可能就是决定搜索树高度的路径。例如,在我们运行的不平衡例子中,对于最小值操作来说,这将是一棵糟糕的树。如果你在这棵树中寻找最小值,那么你将不得不遍历从5一直到1的每一个指针。当然,对于最大值操作,也存在一个类似的糟糕搜索树,其中1是根,5作为叶子在最底部。


中序遍历:按序输出所有键 📋

使用搜索树可以做的另一件事是,按排序顺序线性时间打印出所有键,每个元素耗时恒定。这模仿了排序数组的功能。显然,在排序数组中这很简单,你只需使用一个从开始到结束的for循环,逐个打印键。

在搜索树中,有一个非常优雅的递归实现可以完成完全相同的事情,这被称为二叉搜索树的中序遍历

和往常一样,你从起点开始,即搜索树的根节点。用一点符号表示:让我们把以R的左子节点为根的搜索树称为T_L,把以R的右子节点为根的搜索树称为T_R。

在我们的运行示例中,根是3,T_L将对应仅包含元素1和2的搜索树,T_R将对应仅包含元素5和4的子树。

记住,我们想按递增顺序打印键。特别是,我们想打印的第一个键是所有键中最小的。所以我们绝对不想先打印根节点的键。例如,在我们的搜索树示例中,根节点的键是3,我们不想先打印它,我们想先打印1。那么最小值在哪里?根据搜索树属性,它必然在左子树T_L中。所以我们将递归处理T_L。

通过递归的魔力(或者你更喜欢归纳法),递归处理T_L将完成的任务是:我们将按从小到大的顺序打印出T_L中的所有键。这很酷,因为T_L恰好包含所有小于根节点键的键。记住,这是搜索树属性:所有小于根节点键的元素都必须在左子树中,所有大于根节点键的元素都必须在右子树中。

在我们的具体例子中,第一个递归调用将打印出键1和2。现在,如果你想一想,这正是打印根节点键的完美时机。我们想按递增顺序打印所有键。我们已经处理了所有小于根节点键的元素。递归处理右侧将处理所有大于它的元素。所以在两个递归调用之间(这就是为什么它被称为中序遍历),我们想打印出R的键。显然,这在我们的具体例子中是有效的:递归调用打印出1和2,这是打印3的完美时机,然后递归调用将打印出4和5。更一般地说,对右子树的递归调用将再次通过递归或归纳的魔力,按递增顺序打印出所有大于根节点键的键。

这个伪代码是正确的,这个所谓的中序遍历确实按递增顺序打印键,这一事实可以通过一个相当直接的归纳证明来验证。它与我们在课程早期讨论的分治算法正确性的归纳证明精神非常一致。

那么中序遍历的运行时间呢?声明是:此过程的运行时间是线性的,即O(n),其中n是搜索树中键的数量。 原因是对树中的每个节点恰好有一次递归调用,并且在每次递归调用中只做恒定的工作。更详细地说,中序遍历按递增顺序打印键,特别是它恰好打印每个键一次。每个递归调用恰好打印一个键值。所以恰好有n次递归调用,而递归调用所做的只是打印一个东西。因此,n次递归调用,每次恒定时间,总体运行时间为O(n)。


删除操作:最复杂的部分 🗑️

在大多数数据结构中,删除是最困难的操作,搜索树也不例外。让我们深入探讨删除是如何工作的。删除有三种不同的情况。

首先要做的是定位包含键K的节点,即我们想要删除的节点。例如,假设我们正试图从运行的示例搜索树中删除键2。首先我们需要找出它在哪里。

搜索树中的一个节点可能拥有的子节点数量有三种可能性:它可能有零个子节点,可能有一个子节点,可能有两个子节点。对应于这三种情况,删除的伪代码也将有三种情况。

让我们从最简单的情况开始:当节点有零个子节点时,就像我们想从搜索树中删除键2的情况。那么,我们当然可以毫无保留地直接从搜索树中删除该节点。不会出任何问题,没有子节点依赖于该节点。

然后是中等难度的情况:当包含K的节点有一个子节点时。这里的例子是,如果我们想从搜索树中删除5。中等情况也不算太糟,你需要做的就是将被删除的节点拼接出来。这会在树中创建一个空洞,但随后被删除节点的唯一子节点将占据被删除节点之前的位置。

例如,在我们的五节点搜索树中,如果我们想删除5,我们会把它从树中取出,这会留下一个空洞,但我们只需用其唯一子节点4替换之前由5占据的位置。如果你仔细想想,这工作得很好,因为它保留了搜索树属性。记住,搜索树属性规定,比如说右子树中的所有元素都必须大于该节点的键。我们让4成为3的新右子节点,但4及其可能拥有的任何子节点始终是3的右子树的一部分。所以所有这些元素都必须大于3。因此,将4及其所有后代作为3的右子节点没有问题,搜索树属性实际上被保留了。

最后是困难的情况:当被删除的节点有两个子节点时。在我们运行的五节点例子中,只有当我们想删除根节点(即从树中删除键3)时,才会发生这种情况。问题当然是,你可以尝试将这个节点从树中撕掉,但随后会出现一个空洞,并且不清楚将任一子节点提升到那个位置是否可行。你可能会盯着我们的示例搜索树,试图理解如果你试图将1提升为根或将5提升为根会发生什么问题——问题就会发生。这与我们在堆中遇到相同问题时形成有趣的对比,因为堆属性在某种意义上可能限制较少,当我们想删除具有两个子节点的元素时(假设我们想执行提取最小操作),我们只需提升两个子节点中较小的那个。在这里,我们需要更努力一些。实际上,这将是一个非常巧妙的技巧。我们将做一些事情,将有两个子节点的情况简化为之前已解决的零个或一个子节点的情况。

以下是非常巧妙的方法:我们识别一个节点,我们将对其应用K=0或K=1的操作。我们将从K开始,计算K的前驱。记住,这是树中下一个最小的键。例如,键3的前驱是2,这是树中下一个最小的键。一般来说,让我们称此前驱为L。

这看起来可能很复杂:我们正在实现一个树操作(即删除),却突然调用了另一个树操作(前驱),这是我们几页幻灯片前介绍过的。在某种程度上你是对的,删除是一个非平凡的操作。但情况并不像你想象的那么糟,原因如下:当我们计算这个前驱L时,我们实际上处于前驱操作的简单情况中。概念上,你如何计算前驱?这取决于。它取决于什么?它取决于你是否有非空的左子树。如果你没有非空的左子树,那么你就必须做这件事:向上跟随父指针,直到找到一个小于你起始点的键。但如果你有一个左子树,那就很容易了。你只需找到左子树的最大值,那必然就是前驱。记住,找最大值很容易:你只需持续跟随右子指针,直到无法再跟随为止。很酷的是,因为我们只在节点有两个子节点的情况下才进行此前驱计算,所以我们只需要处理它有非空左子树的情况。因此,当我们说“计算K的前驱L”时,你要做的就是跟随K的非空左子节点(因为它有两个子节点),然后持续跟随右子指针,直到无法再跟随为止,那就是前驱。

现在,这是在搜索树中实现删除操作的相当精彩的部分:你交换这两个键K和L。例如,在我们的运行搜索树中,我们将在根位置放一个2,而不是原来的3;在叶子位置放一个3,而不是原来的2。第一次看到这个,你可能会觉得有点疯狂,甚至像是在作弊,好像我们完全无视了搜索树的规则。实际上,检查一下我们的示例搜索树发生了什么:我们交换了3和2,这不再是一棵搜索树了,对吧?我们有一个3在2的左子树中,而3大于2,这是不允许的,这违反了搜索树属性。哎呀,那么我们怎么能这样做呢?我们可以这样做,因为我们无论如何都要删除3,所以我们最终会得到一棵搜索树。我们可能稍微破坏了搜索树属性,但我们已经将K交换到了一个很容易摆脱的位置。

我们是如何计算K的前驱L的?最终,那是最大值计算的结果,这涉及到持续跟随右子指针直到卡住。L就是我们卡住的地方。“卡住”是什么意思?这意味着L的右子指针为空。它没有两个子节点,特别是它没有右子节点。一旦我们将K交换到L的旧位置,K现在就没有右子节点了。它可能有也可能没有左子节点。在右边的例子中,它在其新位置也没有左子节点。但一般来说,它可能有一个左子节点,但它肯定没有右子节点,因为那是一个最大值计算卡住的位置。

如果我们想删除一个只有零个或一个子节点的节点,嗯,我们知道该怎么做,我们在上一张幻灯片中已经介绍过:要么直接删除它(这就是我们在这个运行例子中所做的),要么在K的新节点确实有一个左子节点的情况下,你会执行拼接操作,即撕掉包含K的节点,该节点的唯一子节点将占据该节点之前的位置。

这里有一个练*,我不会在这里做,但我强烈鼓励你在私下里仔细思考:这个删除操作保留了搜索树属性。粗略地说,当你进行这种交换时,你可能会违反搜索树属性,正如我们在这个例子中看到的,但所有违规都涉及你即将删除的节点。所以一旦你删除了那个节点,就没有其他违反搜索树属性的情况了。因此,你就得到了一棵搜索树。

至于运行时间,这次不难猜出是什么,因为它基本上就是这些前驱计算之一,加上一些指针重连。就像前驱和搜索一样,它由树的高度决定


选择与排名操作 📊

让我简单谈谈最后提到的两个操作:选择和排名。记住,选择就是选择问题:我给你一个顺序统计量,比如17,我希望你返回树中第17小的键。排名是:我给你一个键值,我想知道树中有多少个键小于或等于该值。

为了高效地实现这些操作,我们实际上需要一个小的新想法:用每个节点的附加信息来增强二叉搜索树。所以现在,一个搜索树不仅包含一个键,还包含关于树本身的信息。

这个想法通常被称为增强你的数据结构。对于像这样的搜索树,最经典的增强可能是在每个节点不仅跟踪一个键值,还跟踪以该节点为根的子树中的树节点数量。

让我们称之为size(x),它是以x为根的子树中的树节点数量。

为了确保你明白我的意思,让我告诉你,在我们运行的搜索树示例中,五个节点的size字段应该是什么。再次记住,我们考虑的是以给定节点为根的子树中有多少个节点,或者等价地说,从该节点跟随子指针可以到达多少个不同的树节点。

  • 从根节点开始,你当然可以到达所有人。每个人都在以根为根的树中。所以那里的size是5。
  • 相反,如果你从节点1开始,你可以到达1,或者你可以跟随右子指针到达2。所以在节点1,size是2。
  • 在键值为5的节点,出于同样的原因,size是2。
  • 在两个叶子节点,以叶子为根的子树就是叶子本身。所以那里的size是1。

一旦你知道一个节点的两个子树的size,就有一个简单的方法来计算该节点的size。如果搜索树中的一个给定节点有子节点Y和Z,那么以X为根的子树中有多少个节点?有那些在左子树(以Y为根)中的节点,有那些在右子树(以Z为根,即可从Z到达的子节点)中的节点,还有X本身。公式为:size(x) = size(y) + size(z) + 1

一般来说,每当你增强一个数据结构时(这是我们讨论红黑树时会再次谈到的事情),你必须付出代价。你维护的额外数据可能有助于加速某些操作,但每当你执行修改树的操作(特别是插入和删除)时,你必须注意保持这些额外数据的有效性,保持它们的维护。

就这些子树大小而言,在插入和删除下维护它们相当直接,不会太影响插入和删除的运行时间,但这确实是你在课外应该思考的事情。例如,当你执行插入时,记住它是如何工作的:你本质上执行一次搜索,跟随左和右子指针向下到树的底部,直到遇到空指针,那就是你插入新节点的地方。现在你需要做的是,沿着那条路径回溯,对新插入节点的所有祖先,将它们的子树大小增加1。

让我们通过展示如何在已增强的搜索树中实现选择过程(给定一个顺序统计量)来结束这个视频。在增强的搜索树中,每个节点都知道以该节点为根的子树大小。

当然,和往常一样,你从起点开始,在搜索树中就是根节点。假设根节点有子节点Y和Z。Y或Z可能为空,这没问题,我们只需将空节点的size视为零。

搜索树属性是什么?它说,小于存储在x处的键的键,正是那些在x的左子树中的键;树中大于x处键的键,正是你将在x的右子树中找到的键。

假设我们被要求找到搜索树中的第17个顺序统计量,即树中存储的第17小的键。它会在哪里?我们应该在哪里查找?嗯,这将取决于树的结构,实际上将取决于子树大小——这正是我们跟踪它们的原因,这样我们就可以快速做出关于如何导航通过树的决定。

举个简单的例子,假设x的左子树包含,比如说,25个键。记住,x本地确切地知道其子树中有多少键,所以从x我们可以在恒定时间内计算出y子树中有多少键,假设是25。根据搜索树的定义属性,这些是树中任何地方最小的25个键。x大于它们所有,x右子树中的所有元素也都大于它们所有。所以最小的25个顺序统计量都在以y为根的子树中。显然,我们应该在那里递归,答案就在那里。所以我们递归处理以Y为根的子树,然后我们再次在这个新的、更小的搜索树中寻找第17个顺序统计量。

另一方面,假设当我们从x开始时,我们询问y:你的子树中有多少个节点?也许y本地存储的数字是12,所以x的左子树中只有12个东西。好吧,x本身大于它们所有,所以x将是第13大的顺序统计量,它是树中第13大的元素。其他所有元素都在右子树中。所以特别是,第17个顺序统计量将在右子树中。因此,我们将在右子树中递归。现在我们在寻找什么?我们不再寻找第17个顺序统计量了。最小的12个元素都在x的左子树中,x本身是第13小的,所以我们正在寻找剩余元素中第4小的。这种递归非常类似于我们在课程早期讨论的分治选择算法。

为了填充更多细节,让我们用a表示在y处的子树大小。如果x没有左子节点,我们将定义a为零。

超级幸运的情况是,当左子树中恰好有i-1个节点时,这意味着这里的根x本身就是第i个顺序统计量。记住,它大于其左子树中的所有元素,并且小于其右子树中的所有元素。

但在一般情况下,我们要么在左子树递归,要么在右子树递归。

  • 当左子树的数量足够大,保证它包含第i个顺序统计量时,我们在左子树递归。当左子树的大小至少为i时,就会发生这种情况,因为左子树拥有搜索树中任何地方最小的键。
  • 在最后一种情况下,当左子树太小,不仅不包含第i个顺序统计量,而且x也太小而不能成为第i个顺序统计量时,我们在右子树递归,知道我们已经丢弃了原始树中最小的a+1个键值。

此过程的正确性几乎与我们早期讨论的选择算法的归纳正确性完全相同。实际上,搜索树的根充当了一个枢轴元素,左子树中的所有元素都小于根,右子树中的所有元素都大于根中的元素。这就是递归正确的原因。

至于运行时间,我希望从伪代码中可以明显看出,每次递归我们做恒定时间的工作。我们能递归多少次?我们不断向下移动树,我们能向下移动的最大次数与树的高度成正比。所以这再次与高度成正比

以上就是选择操作。有一种类似的方法来编写排名操作:记住,这是给你一个键值,你想计算小于或等于该目标值的存储键的数量。同样,你使用这些增强的搜索树,同样你可以获得与高度成正比的运行时间。我鼓励你在课外思考如何实现排名的细节。


总结 📝

本节课中,我们一起深入学*了二叉搜索树的核心操作及其性能。我们了解到,几乎所有基本操作(搜索、插入、查找最小/最大值、前驱/后继、删除、选择、排名)的运行时间在最坏情况下都与树的高度成正比。平衡良好的树(高度为O(log N))能提供高效的性能,而不平衡的树(高度为O(N))则性能低下。我们还学*了中序遍历如何以线性时间输出排序后的键,以及通过维护子树大小信息,可以实现高效的选择和排名操作。理解这些基础是后续学*如何保持二叉搜索树平衡(例如通过AVL树、红黑树)的关键。

022:旋转操作进阶(可选)🔄

在本节课中,我们将深入探讨平衡二叉搜索树实现中的一个核心概念——旋转操作。我们将了解旋转操作的基本原理、类型以及它们如何在不破坏二叉搜索树性质的前提下,通过常数时间的指针重连来实现局部再平衡。

概述

在上一节中,我们介绍了平衡二叉搜索树的基本概念。本节中,我们将深入其实现细节,聚焦于所有平衡二叉搜索树实现(如红黑树、AVL树、B树等)都使用的一个关键原语——旋转操作。

旋转操作的核心思想

旋转操作的目的是通过仅重连少数几个指针(即执行常数工作量),在局部重新平衡搜索树,同时不违反二叉搜索树的性质。

旋转操作有两种类型:左旋和右旋。无论哪种情况,旋转操作都是针对搜索树中的一个父子节点对进行的。如果子节点是父节点的右子节点,则使用左旋;如果子节点是父节点的左子节点,则使用右旋,右旋在某种意义上可以看作是左旋的逆操作。

左旋操作详解

让我们以一个具体的场景为例:假设在搜索树中有一个节点 X,它有一个右子节点 Y

以下是该场景的通用结构图:

        P (可能是空节点)
        |
        X
       / \
      A   Y
         / \
        B   C

为了理解旋转如何保持搜索树性质,我们首先需要明确图中各元素之间的大小关系:

  • YX 的右子节点,因此 Y > X
  • 子树 AX 的左侧,因此 A 中的所有键值 < X
  • 子树 CY 的右侧,因此 C 中的所有键值 > Y
  • 子树 BX 的右子树中,也在 Y 的左子树中,因此 B 中的所有键值介于 XY 之间,即 X < B < Y

左旋的根本目标是反转节点 XY 的关系。目前,X 是父节点,Y 是子节点。我们希望重连指针,使得 Y 成为新的父节点,而 X 成为其子节点。

以下是实现这一目标的重连步骤:

  1. 处理父节点关系

    • Y 的新父节点变为 X 的旧父节点 P
    • X 的新父节点变为 Y
  2. 处理子树关系

    • 子树 A(所有键值小于 XY)保持作为 X 的左子节点。
    • 子树 C(所有键值大于 XY)保持作为 Y 的右子节点。
    • 子树 B(所有键值介于 XY 之间)被移动到 X 的右子节点位置。

经过左旋操作后,树的结构变为:

        P
        |
        Y
       / \
      X   C
     / \
    A   B

可以看到,所有键值的大小关系依然得到保持,搜索树性质得以保留。

右旋操作

理解了左旋操作后,右旋操作就很容易理解了,因为它本质上是左旋的逆操作。右旋应用于父节点 X 和其左子节点 Y 的场景,目标同样是反转它们的父子关系,使 Y 成为新的父节点,X 成为其右子节点。其重组组件的方式与左旋对称。

旋转操作的优良特性

以下是旋转操作值得称道的特性:

  • 常数时间复杂度:旋转操作仅涉及重连固定数量的指针,因此可以在 O(1) 时间内完成。
  • 保持搜索树性质:如上所述,经过精心设计的指针重连,旋转操作能够保持二叉搜索树的正确排序性质。

正是这些优良特性,使得旋转操作成为所有平衡搜索树实现中普遍使用的核心原语。

总结

本节课中,我们一起学*了平衡二叉搜索树实现中的关键原语——旋转操作。我们详细探讨了左旋操作的原理和步骤,并指出右旋是其逆操作。旋转操作通过常数时间的指针调整,在局部重新平衡树结构,同时严格保持二叉搜索树的性质,这是其强大和通用之处。

当然,旋转操作本身并不是完整的平衡搜索树实现方案。一个完整的实现还需要精确规定在何时以及如何部署这些旋转操作。在接下来的视频中,我们将以红黑树为例,初步了解这些策略。若想更深入地理解,建议查阅全面的数据结构教材、观看网上的平衡搜索树演示,或研究相关的开源实现代码。

023:哈希表的操作与应用 🗂️

在本节课中,我们将要学*哈希表。我们将首先关注哈希表支持的操作以及一些典型的应用场景。

哈希表是一种极其有用的数据结构。如果你想成为一名专业的程序员或计算机科学家,学*哈希表是必不可少的。许多同学可能已经在自己的程序中使用过哈希表。有趣的是,哈希表支持的操作种类并不多,但它所支持的操作,其性能都非常出色。

什么是哈希表? 🤔

从概念上讲,如果忽略所有实现细节,你可以将哈希表看作一个数组。数组的一个巨大优势是支持即时随机访问。例如,如果你想获取数组中第17个位置的元素,只需几条机器指令即可完成。同样,修改数组中第23个位置的内容也可以在常数时间内完成。

让我们考虑一个应用场景:你想记住朋友的电话号码。如果你的朋友们名字都是1到10,000之间的整数,那么你可以简单地维护一个长度为10,000的数组。例如,要存储你最好的朋友(编号173)的电话号码,只需使用这个数组中第173个位置即可。只要所有朋友的名字都是1到10,000之间的整数,这个基于数组的解决方案就非常完美。

然而,现实中你的朋友名字更有趣,比如Alice、Bob、Carol,还有姓氏。理论上,你可以为每一个可能遇到的名字(比如至少30个字母长的名字)在数组中预留一个位置。但这个数组会大到无法实现,其大小可能达到26的30次方。

因此,你真正需要的是一个大小合理的数组,比如几千个位置,其索引不是1到10,000的整数,而是你朋友的名字。你希望能够基于朋友的名字对这个数组进行随机访问。例如,你只需查找这个数组的“Alice”位置,就能在常数时间内获得Alice的电话号码。从概念层面讲,这就是哈希表能为你做的事情。

哈希表的内部实现有很多“魔法”,我们将在其他视频中讨论。你需要一个映射,将你关心的键(如朋友的名字)映射到某个数组的数值索引位置,这由所谓的哈希函数完成。如果实现得当,哈希表就能提供这种功能,就像一个其位置由你存储的键来索引的数组。

哈希表的目的与操作 📝

你可以将哈希表的目的理解为维护一个可能动态变化的“东西”的集合。当然,你维护的“东西”会因应用而异,可以是任何事物。例如,如果你运营一个电子商务网站,你可能需要跟踪交易记录;或者你可能需要跟踪人员信息,比如你的朋友及其相关数据;又或者你可能需要跟踪IP地址,以了解访问你网站的唯一访客。

更正式地说,哈希表的基本操作包括:

  • 插入:向哈希表中插入数据。
  • 删除:在许多(但非所有)应用中,需要能够删除数据。
  • 查找:通常是最重要的操作。

所有这三个操作都是基于进行的。键通常是所关注记录的唯一标识符。例如,对于员工,可以使用社会保险号;对于交易,可以使用交易ID号;IP地址本身就可以作为键。有时,你只跟踪键本身(例如,仅记录IP地址列表)。但在许多应用中,键会附带一堆其他数据(例如,员工的社会保险号附带其他员工信息)。当你进行插入、删除或查找时,都基于这个键。例如,在查找时,你将键输入哈希表,哈希表会返回与该键关联的所有数据。

有时,人们将支持这些操作的数据结构称为字典。哈希表的主要目的是支持类似字典的查找功能。但我觉得这个术语有点误导性,因为大多数字典是按字母顺序排列的,支持类似二分查找的操作。我想强调的是,哈希表不维护其存储元素的顺序。如果你需要基于顺序的操作(如查找最小值或最大值),哈希表可能不是合适的数据结构,你可能需要堆或搜索树。

但对于那些基本上只需要查找“某个东西是否存在”的应用,你应该立刻想到哈希表。它很可能是这类应用的完美数据结构。

哈希表的性能保证 ⚡

看到这些支持的操作,你可能会觉得哈希表能做的事情不多。但再次强调,它做的事情,做得非常、非常好。

哈希表提供的第一要义是以下惊人的保证:所有操作(插入、删除、查找)都在常数时间内运行。这就像将哈希表视为一个数组,其位置由你的键方便地索引。就像数组支持常数时间的随机访问一样,哈希表也让你能在常数时间内基于键进行查找。

当然,这里有两个重要的注意事项:

  1. 实现质量:哈希表很容易被糟糕地实现。如果实现得不好,你就无法得到这个保证。这个保证是针对正确实现的哈希表而言的。如果你使用的是知名库中的哈希表,可以假设它实现得不错。但如果你需要自己实现哈希表和哈希函数(与我们将讨论的其他数据结构不同,有些同学在职业生涯中可能确实需要这样做),那么只有实现得好才能获得这个保证。我们将在其他视频中详细讨论“实现得好”意味着什么。
  2. 最坏情况保证:与我们在本课程中解决的大多数问题不同,哈希表不提供最坏情况保证。你不能说对于任何可能的数据集,哈希表都能提供常数时间操作。实际情况是,对于非病态数据,在正确实现的哈希表中,你将获得常数时间的操作。

我们将在其他视频中进一步讨论这两个问题。目前,只需记住要点:在满足一些注意事项的前提下,哈希表能提供常数时间的性能。

哈希表的应用实例 🌟

我们已经介绍了哈希表支持的操作以及理解它们的方式,现在让我们将注意力转向一些应用。所有这些应用在某种意义上都是哈希表的简单使用,但它们都非常实用,经常出现。

应用一:去重(删除重复项)

第一个我们将讨论的典型应用是从一堆数据中删除重复项,也称为去重问题

在去重问题中,输入本质上是一个对象流。这里的“流”可以有两种典型情况:

  1. 你有一个巨大的文件(例如,网站运行日志或某天商店的所有交易记录),你正在逐行遍历这个文件。
  2. 你随着时间的推移不断接收新数据(例如,部署在互联网路由器上的软件,数据包以极快的恒定速率通过路由器,你可能查看每个数据包中的发送方IP地址)。

你的任务是忽略重复项,只记住在这个流中看到的不同对象。不难想象为什么在各种应用中需要这样做。例如,运营网站时,你可能想跟踪某天或某周内的不同访客;进行网络爬虫时,你可能想识别重复文档并只记住一次(搜索引擎显然希望避免在搜索结果中显示指向不同URL的相同页面)。

使用哈希表的解决方案简单得可笑:

  • 每当流中出现一个新对象,你就去哈希表中查找它。
  • 如果它存在,那么它是重复的,忽略它。
  • 如果它不存在,那么这是一个新对象,记住它(插入哈希表)。

就这样。当流结束后(例如,读完大文件后),如果你想报告所有唯一对象,哈希表通常支持线性扫描,你可以直接报告所有不同的对象。

应用二:两数之和问题

让我们继续看第二个应用,可能稍微不那么简单,但仍然很容易理解。这就是编程项目五的主题。

这个问题被称为两数之和问题。你被给予一个包含n个数字的数组(这些整数没有特定顺序)和一个目标总和T。你想知道,在这给定的n个整数中,是否存在两个整数之和等于T?

最明显和朴素的方法是检查输入中所有可能的整数对(共有 n choose 2 对),这显然是一个 O(n²) 的算法。

但我们可以做得更好。首先,看看如果不使用哈希表等数据结构,一个聪明的改进方案是什么:

  1. 第一步:预先对数组进行排序(例如,使用归并排序或堆排序),时间复杂度为 O(n log n)
  2. 第二步:对于排序后数组中的每个元素x,我们寻找其互补元素 T - x。由于数组已排序,我们可以使用二分查找,每次查找耗时 O(log n)。我们对n个元素都进行这样的查找,因此第二步总时间为 O(n log n)

这是一个非常不错的改进,从 O(n²) 提升到了 O(n log n)。但我们还可以做得更好。对于两数之和问题,我们没有理由受限于 O(n log n) 的下界。显然,因为数组未排序,我们必须查看所有整数,所以我们不可能做得比线性时间更好,但我们可以通过哈希表实现线性时间

此时你可能会问,这个任务的什么线索提示我们应该使用哈希表?哈希表将极大地加速任何主要工作是重复查找的应用。如果我们检查 O(n log n) 的解决方案,一旦我们有了为每个x搜索 T - x 的想法,就会意识到,我们实际上只需要这个排序数组来支持查找。二分查找所做的就是查找。所以,第二步的所有工作都来自重复查找。我们为每次查找支付了对数级的时间,而哈希表可以在常数时间内完成。因此,重复查找——叮咚!——让我们使用哈希表。这确实为我们提供了该问题的线性时间解决方案。

基于哈希表的惊人保证,我们得到了两数之和问题的以下惊人解决方案(当然,同样受限于关于正确实现哈希表和非病态数据的注意事项):

  1. 第一步:不是排序,而是将数组中的所有元素插入哈希表。插入是常数时间,所以这一步是线性时间 O(n)
  2. 第二步:与 O(n log n) 解决方案一样,对于数组中的每个x,我们在哈希表中使用常数时间的查找操作寻找其匹配元素 T - x
    • 如果对于某个x,你找到了匹配元素 T - x,那么你可以报告x和 T - x,证明确实存在一对整数之和为目标T。
    • 如果对于输入数组A中的每一个元素,你都没能在哈希表中找到匹配元素 T - x,那么肯定不存在和为T的整数对。

这个算法正确解决了问题。常数时间的插入使得第一步是 O(n) 时间,常数时间的查找使得第二步也是线性时间(至少在我们之前讨论的注意事项前提下)。

更多应用场景 💡

令人惊讶的是,计算机科学中有多少不同的应用本质上都可以归结为重复查找操作。因此,拥有像哈希表支持的这样超快的查找操作,使得这些应用能够扩展到惊人的规模。这确实令人惊叹,并推动了许多现代技术的发展。让我再举几个例子,如果你观察周围或做一些网络研究,很快会发现更多。

  • 编译器:最初促使研究人员深入思考支持超快查找的数据结构,是在人们首次构建编译器时(大约20世纪50年代)。为了弄清楚之前定义了什么、没定义什么而进行的重复查找,成为早期编程语言中编译器的瓶颈。哈希表的早期应用之一就是支持超快查找,以加快编译时间,跟踪函数、变量名等。
  • 网络路由器:哈希表技术对于互联网上的路由器软件也非常有用。例如,你可能想阻止来自某些源的网络流量。你可能怀疑某个IP地址已被垃圾邮件发送者接管,因此你想忽略来自该IP地址的任何流量,甚至不让它到达终端主机。路由器面临的任务本质上是一个查找问题:数据包以极快的速率到达,你需要立即查找它是否在黑名单中。如果是,则丢弃;如果不是,则放行。
  • 搜索算法加速:这里的“搜索算法”指的是像国际象棋程序这样的博弈树探索。我们已经在本课程中讨论了很多关于图搜索的内容,但在讨论广度优先搜索和深度优先搜索时,我们考虑的是基本上可以写下来的图(可以存储在机器主内存或大型集群中)。然而,在国际象棋程序的背景下,你感兴趣的图比网络图大得多。图的节点对应于棋盘上所有可能的棋子配置,这是一个极其庞大的数字,你永远无法写下所有顶点。图中的边将带你从一个配置到另一个配置,对应于合法的移动。你无法写下这个图,因此不能像之前讨论的那样精确实现BFS或DFS。但你仍然想进行图探索,让你的计算机程序推理你下一步可能走的棋的短期影响。在图的搜索中,一个非常重要的属性是你不想做冗余工作,不想重新探索已经探索过的地方。如果你不能写下整个图,仅仅记住你去过的地方就突然变成了一个不简单的问题。但记住你去过的地方,从根本上说,就是一个查找操作。这正是哈希表的用武之地。更具体地说,在国际象棋程序中,你可以将探索过的每一个棋盘配置放入哈希表。在探索某个配置之前,你先在哈希表中查找是否已经探索过它。如果是,就不再费心去探索。这样,国际象棋程序就能在有限时间内系统地探索尽可能多的配置,而不会浪费任何工作重复探索同一个配置。

当然,这些只是冰山一角。我只是想强调几个看起来相当不同的应用,以说服你哈希表无处不在。它们无处不在的原因是,对快速查找的需求无处不在。令人惊讶的是,有多少技术仅仅是由重复的快速查找所驱动的。作为课后作业,我鼓励你思考一下自己的生活或世界上的技术,猜猜哈希表可能在哪里使某些东西运行得飞快。我想你不需要几分钟就能想出一些好例子。

总结 📚

本节课中,我们一起学*了哈希表的基础知识。我们首先将哈希表概念化为一个由键索引的数组,理解了它支持插入、删除和查找这三种基本操作,并且这些操作在理想情况下都能在常数时间内完成。我们探讨了哈希表性能保证的两个重要注意事项:实现质量和缺乏最坏情况保证。接着,我们通过去重和两数之和这两个典型问题,看到了哈希表在实际应用中的强大与简洁。最后,我们还了解了哈希表在编译器、网络路由和博弈树搜索等更广泛领域的应用,认识到快速查找是驱动许多现代技术的核心需求之一。哈希表因其高效的查找能力,成为了计算机科学中不可或缺的基础数据结构。

024:哈希表实现细节(第一部分)🔍

在本节课中,我们将深入探讨哈希函数的工作原理,并介绍其实现的一些高级原则。我们将学*哈希表如何通过巧妙的数组和函数设计,实现快速的插入、删除和查找操作,同时保持与存储数据量成比例的空间占用。


哈希表概述

哈希表的核心目标是支持超快速的查找操作。无论是记录网站交易、管理员工信息、追踪IP地址,还是存储国际象棋程序中的棋盘配置,哈希表都能让你高效地插入数据,并在之后快速判断某项数据是否存在。我们将讨论的实现通常也支持删除操作。哈希表能在常数时间内执行这些操作,但前提是哈希表被正确实现,并且数据在某种意义上不是“病态的”。

基本结构与挑战

在没有数据结构的情况下,维护一个动态集合的解决方案并不理想。

  • 基于数组的解决方案:为宇宙中每一个可能存在的元素在数组中预留一个位置。这能实现常数时间的操作,但所需空间与宇宙大小成正比,这在许多应用中是不可行的。
  • 基于链表的解决方案:只存储集合中实际存在的元素,空间与集合大小成正比。但查找一个元素是否存在,通常需要遍历大部分链表,所需时间与链表长度成正比。

哈希表的目标是结合两者的优点:既想获得基于数组方案的常数时间操作,又想获得基于链表方案的、与存储集合大小成正比的线性空间。

为了实现这个目标,我们将使用一个基于数组的方案,但这个数组不会很大。数组的长度 n 大约与我们存储的集合 S 的大小相当(例如,n 大约是 S 的两倍)。数组的每个位置被称为一个“桶”。

关于动态集合的说明:集合 S 的大小会动态变化。为了简化讨论,本视频假设 S 的大小波动不大。在实际实现中,可以通过监控元素数量,在集合过大时扩容数组(例如翻倍并重新插入所有元素),在集合过小时缩容数组。这些是哈希表实现中的次要细节,本课将聚焦于核心原理。

哈希函数的作用

现在,我们有了一个空间合理的数组。接下来,我们需要一种方法,将我们关心的元素(如IP地址、姓名)映射到这个数组的特定位置。负责这种从宇宙中的键到数组位置转换的对象,就是哈希函数

形式上,一个哈希函数 h 以键 x(如IP地址)作为输入,并输出该数组中的一个位置(索引,例如从0到 n-1)。哈希函数告诉我们应在哪个位置存储给定的键及其关联数据。

不可避免的冲突

哈希表实现中的一个根本性问题是冲突。当两个不同的键 xy 被哈希函数映射到同一个桶时,就发生了冲突。

冲突是不可避免的。这可以用“生日悖论”来理解:即使哈希表非常空(例如只有1%的占用率),只要数据集的大小达到桶数量的平方根级别,就很可能发生冲突。例如,一个有10,000个桶的哈希表,仅需大约100个元素,就很可能出现冲突。

因此,我们必须有处理冲突的方法。

冲突解决方法

以下是两种最普遍的冲突解决方法。

方法一:链地址法

这是一种非常自然的解决方案,也相对容易进行数学分析。

核心思想:对于哈希到同一个桶的所有元素,我们回退到之前提到的基于链表的解决方案。每个桶不再只包含零个或一个元素,而是包含一个可以容纳任意数量元素的链表。

操作实现:要执行插入、删除或查找操作,只需将键哈希到对应的桶,然后在该桶内的链表中执行相应的链表操作。

示例

  • 桶0:Alice -> NULL
  • 桶1:NULL
  • 桶2:Bob -> Daniel -> NULL (Bob和Daniel发生冲突,存储在同一个链表中)
  • 桶3:Carol -> NULL

方法二:开放定址法

这种方法在数学上分析更复杂,但在实践中非常重要。

核心思想:不为指针预留额外空间,每个桶只存储一个对象。如果尝试插入一个键时,其哈希值指向的桶已被占用,则按照一个预定的“探测序列”去检查其他桶,直到找到一个空桶为止。

探测序列策略

  • 线性探测:如果桶 h(x) 已满,则尝试桶 h(x)+1,然后 h(x)+2,依此类推。
  • 双重哈希:使用两个哈希函数 h1h2。首先尝试桶 h1(x)。如果已满,则尝试桶 h1(x) + h2(x),然后 h1(x) + 2*h2(x),依此类推。对于不同的键,h2(x) 提供了不同的偏移量。

方法选择建议

两种方法各有优劣,没有一种在所有情况下都占优。

  • 空间考量:如果空间非常宝贵,可以考虑开放定址法,因为它避免了链地址法中链表指针带来的额外空间开销。
  • 删除操作:在开放定址法中实现删除操作比在链地址法中更复杂。如果删除是关键操作,这可能使你倾向于选择链地址法。
  • 实践建议:对于关键代码,最好的方法是两种都实现并进行性能测试,因为很难预测它们与内存层次结构等的交互情况。两者都在各自的情境中有用。

哈希函数的设计

到目前为止,我们还没有讨论哈希函数本身。它应该是什么样的函数?这是一个被广泛研究的问题,至今仍兼具艺术性与科学性。


本节课中,我们一起学*了哈希表的核心实现思想。我们了解到,哈希表通过一个大小适中的数组和哈希函数,将庞大的键空间映射到有限的桶中。我们探讨了冲突的必然性,并学*了两种主流的冲突解决方法:链地址法和开放定址法。最后,我们指出了哈希函数设计本身是一个关键且复杂的课题,为后续的深入讨论奠定了基础。

025:哈希表实现细节(第二部分)

在本节课中,我们将要学*哈希函数对哈希表性能的关键影响,探讨如何设计一个“好”的哈希函数,并了解一些在实践中需要避免的常见陷阱。

哈希函数的重要性

上一节我们介绍了哈希表的两种主要实现方式:链地址法和开放寻址法。本节中我们来看看,在这两种实现中,哈希函数如何直接影响操作的运行时间。

链地址法中的性能

在链地址法中,插入操作是常数时间的。这要求一个简单的优化:将新元素插入到对应桶的链表头部。

然而,查找和删除操作的运行时间则取决于对应桶中链表的长度。以下是其工作原理:

  • 当查找一个键 X 时,我们首先计算 H(X) 来确定目标桶。
  • 然后,我们需要在该桶的链表中进行线性搜索,以确定 X 是否存在。

因此,操作的运行时间与最长的链表长度成正比。哈希函数的质量决定了数据在各个桶之间的分布是否均匀。

开放寻址法中的性能

在开放寻址法中,没有链表。操作的运行时间由探测序列的长度决定,即为了找到目标键或一个空桶需要检查的桶的数量。

同样,探测序列的长度也取决于哈希函数。一个好的哈希函数应能均匀地分散数据,从而保持较短的探测序列。

综上所述,哈希表的性能,无论采用哪种实现方式,都极大地依赖于所使用的哈希函数。

理想哈希函数的特性

基于以上直觉,我们可以总结出一个理想哈希函数应具备的两个关键特性。

首先,它应能带来良好的性能。以链地址法为例,我们希望哈希函数能尽可能均匀地将数据分散到不同的桶中,使得所有链表的长度大致相等。对于开放寻址法,我们希望哈希函数能将数据均匀地分散到所有可能的探测位置上。

在哈希领域,数据均匀分布的“黄金标准”是完全随机函数。即,对于每个输入键,都独立、随机地将其映射到任意一个桶。

其次,哈希函数的计算必须高效。每次插入、删除或查找操作都需要计算一次哈希值。如果我们希望这些操作是常数时间的,那么哈希函数的计算也必须是常数时间的。

正是第二个特性使得我们无法真正实现完全随机的哈希。因为那意味着我们需要为每个插入的键“记住”其随机映射结果,这实际上退化成了朴素的基于列表的解决方案,计算哈希函数本身就需要线性时间。

因此,我们的目标是两全其美:一个可以用常数空间存储、在常数时间内计算,但同时又能像完全随机函数一样均匀分散数据的哈希函数。

糟糕的哈希函数示例

设计哈希函数是一门兼具艺术与科学的学问。如果你只从本视频中记住一件事,那就是:设计糟糕的哈希函数非常容易,而糟糕的哈希函数会导致远比你预期要差的性能。

以下是两个具体的糟糕设计示例。

示例一:基于电话号码的哈希

假设键是10位美国电话号码,我们选择 N = 1000 个桶。一个糟糕的哈希函数是取电话号码的前三位(即区号)作为哈希值。

这之所以糟糕,是因为来自同一地区的大量电话号码(如区号415)都会被映射到同一个桶(桶415),导致该桶的链表极长,而许多其他桶(对应无效区号)则完全空置,造成空间浪费和性能低下。

一个稍好但仍属平庸的哈希函数是取电话号码的最后三位。这假设了最后三位数字是均匀分布的,但现实中可能并非如此,仍可能存在不易察觉的模式导致分布不均。

示例二:基于内存地址的哈希

假设键是对象的内存地址(均为4的倍数,即为偶数)。我们同样选择 N = 1000 个桶。考虑使用取模运算作为压缩函数:H(x) = x mod 1000

由于所有内存地址 x 都是偶数,而 1000 也是偶数,因此 x mod 1000 的结果也必然是偶数。这意味着哈希函数永远无法输出奇数,导致哈希表中至少一半的桶(所有奇数编号的桶)被保证是空的。

这个例子揭示了一个普遍问题:如果所有数据元素都与桶的数量 N 共享一个公因子(例如都是2的倍数,且N也是2的倍数),就会导致大量桶必然空置。

设计更好的哈希函数

现在我们已经了解了糟糕哈希函数的例子,自然会问:什么是好的哈希函数?设计一个好的哈希函数相当棘手。这里介绍一种常见且“不算明显糟糕”的快速设计方法,适用于大多数非关键场景。对于关键任务代码,则需要更深入的研究。

哈希函数的设计可以分解为两个步骤:

  1. 生成哈希码:将非数值型键(如字符串)转换为一个(可能很大的)整数。
  2. 压缩函数:将这个大的整数映射到桶的索引范围(0N-1)内。

对于已经是数值的键(如社保号),可以跳过第一步。

生成哈希码(以字符串为例)

将字符串转换为整数有标准方法。一种常见思路是遍历每个字符,将其ASCII码值整合到一个累加和中。通常的作法是维护一个“运行总和”,对每个新字符,将当前总和乘以一个常数,然后加上新字符的值,必要时取模以防止溢出。这是一个非常简单的子程序。

压缩函数与取模法

最直接的压缩函数就是取模运算:hash_code mod N。它的优点是极其简单,易于编码和快速计算。

问题在于如何选择 N 以确保良好的数据分布。基于之前糟糕示例的教训,我们有以下经验法则:

首要法则:选择 N 为质数。
这可以最大限度地减少数据与 N 共享非平凡公因子的可能性,从而避免大量桶必然空置的问题。你总能找到一个与你计划存储的元素数量相*的质数作为 N

次要优化:N 不应太接*2的幂或10的幂。
这是因为数据中的模式(如内存地址的低位、电话号码的十进制位)常常以2为底或10为底的数字形式出现。选择远离这些幂次方的质数,有助于更均匀地分散具有此类模式的数据集。

总结

本节课中我们一起学*了哈希函数的核心作用。我们了解到,哈希表的性能高度依赖于哈希函数能否将数据均匀分散到各个桶中。我们看到了设计糟糕哈希函数的例子(如直接使用电话号码区号),并理解了其导致的性能问题。最后,我们介绍了一种实用的哈希函数设计方法:将键转换为整数后,使用取模运算进行压缩,并强调应选择质数作为桶的数量 N,且最好远离2的幂和10的幂,以获得相对较好的分布效果。

需要牢记的是,本节介绍的是快速实现可用的方法,并非哈希函数设计的尖端技术。对于关键应用,务必进行更深入的研究、参考最佳实践,并通过原型测试来找到最适合你具体场景的实现方案。

026:病态数据集与通用哈希动机

在本节课中,我们将深入学*哈希表,并更深入地理解它们在何种条件下能表现出优异的性能。我们将探讨一个核心观点:每个哈希函数都有其“克星”——一个特定的病态数据集,这引出了后续视频中需要谨慎处理的数学问题。

哈希表快速回顾

哈希表的根本目的是实现极快的查找操作,理想情况下是常数时间查找。当然,为了有数据可查,哈希表也必须支持插入操作。有时,哈希表也允许删除元素,这取决于底层的具体实现。例如,在使用链地址法(每个桶对应一个链表)时,删除操作很容易实现;而在使用开放地址法时,删除可能比较复杂。

我们最初讨论哈希表时,鼓励你像看待数组一样从逻辑上理解它。区别在于,哈希表不是通过数组位置索引,而是通过存储的键来索引。就像数组支持常数时间的随机访问一样,哈希表也旨在支持常数时间的查找。

然而,哈希表有一些附加条件。第一个条件是哈希表必须被正确实现。这包含两层含义:一是桶的数量应与存储的数据量相匹配;二是必须使用一个足够好的哈希函数。我们在之前的视频中讨论过劣质哈希函数的风险,在接下来的视频中,我们将对哈希函数提出更严格的要求。第二个条件是数据不能是“病态的”。在某种意义上,每个哈希表都有其克星,存在一个能使其性能变得非常糟糕的病态数据集。

处理冲突的方法

在关于实现细节的视频中,我们讨论了哈希表如何不可避免地处理冲突。在哈希表被填满之前,你就会遇到冲突。因此,你需要某种方法来处理映射到同一个桶的两个不同键。有两种流行的方法:

链地址法:这是一个非常自然的想法,你只需将所有哈希到同一个桶的元素都保留在该桶中。你通过一个链表来跟踪所有这些元素。例如,在第17号桶中,你会找到所有哈希到桶17的元素。

开放地址法:这种方法要求每个桶只存储一个键。如果两个键都映射到桶17,你必须为其中一个找到另一个位置。处理方式是,你要求哈希函数不仅提供一个桶,而是提供一个完整的探测序列。如果你尝试插入到桶17但17已被占用,你就转到探测序列中的下一个桶尝试插入,如果再次失败,就继续尝试序列中的第三个桶,依此类推。我们简要提过两种指定探测序列的方式:

  • 线性探测:如果你在桶17失败,就移到18,然后是19、20、21,直到找到一个空桶并插入新元素。
  • 双重哈希:你使用两个哈希函数的组合。第一个哈希函数指定初始探测的桶,第二个哈希函数指定后续每次探测的偏移量。例如,对于元素“Alice”,如果两个哈希函数分别给出数字17和23,那么对应的探测序列将是:首先尝试17,失败则尝试40,再失败则尝试63,然后是86,以此类推。

在本课程中,我们通常会更多地讨论链地址法,这并不意味着链地址法更重要,而是因为它在数学上更容易分析,使我们能够给出完整的证明。而开放地址法的完整证明超出了本课程的范围。

负载因子

有一个非常重要的参数在决定哈希表性能方面起着重要作用,那就是负载因子,通常用 α 表示。

负载因子的定义很简单:α = (已插入且未删除的元素数量) / (哈希表中桶的数量)

正如你所料,当你向哈希表中插入越来越多的元素时,负载会增加。在保持哈希表中元素数量不变的情况下增加桶的数量,负载会下降。

为了确保负载因子的概念清晰,并且你清楚不同的冲突解决策略,下一个测验将询问关于链地址法和开放地址法实现中相关α的范围。

正确答案是第三个选项。负载因子大于1对于使用链地址法实现的哈希表是有意义的(尽管可能不是最优的),但对于使用开放地址法的哈希表则没有意义。原因很简单:在开放地址法中,每个桶只能存储一个对象。因此,一旦对象数量超过桶的数量,就没有地方放置剩余的对象,哈希表会在负载因子大于1时崩溃。另一方面,在链地址法中,负载因子大于1没有明显问题。例如,你可以想象负载因子等于2,即向一个有1000个桶的哈希表中插入2000个对象,在理想情况下,每个桶的链表里只有两个对象。

接下来,我们做一个简单但非常重要的观察,这是哈希表获得良好性能的必要条件,这也涉及到第一个注意事项:如果你期望获得良好性能,就必须正确实现哈希表。

第一个要点是:只有保持负载因子为常数,你才能获得常数时间的查找

对于使用开放地址法的哈希表,这一点非常明显,因为你需要α不仅为O(1),还必须小于1(即小于100%满载),否则哈希表会因为无法容纳所有项目而崩溃。即使对于使用链地址法实现的哈希表,虽然负载因子大于1在逻辑上可行,但如果你想要常数时间的操作,也必须保持负载因子不要远大于1。例如,如果你有一个包含n个桶的哈希表,并哈希了n log n个对象,那么每个桶的平均对象数将是对数级的。记住,当你进行查找时,在哈希到桶之后,你必须遍历该桶中的链表进行穷举搜索。因此,如果你在n个桶的哈希表中存储了n log n个对象,你预期的查找时间更像是对数时间,而不是常数时间。

对于开放地址法,我们讨论过,你不仅需要α等于O(1),还需要α小于1。实际上,α最好远低于1,你不希望让开放地址表的负载达到90%或类似的程度。所以,我写的是“需要α远小于1”,这意味着你不希望负载增长到太接*100%,否则性能会下降。

再次强调,我希望这一页的要点是清晰的:如果你想要良好的哈希表性能,你需要负责的事情之一就是控制负载因子。对于开放地址法,保持负载因子最多为一个小的常数;对于链地址法,保持负载因子远低于100%。

你可能会想,控制负载是什么意思?毕竟,你编写这个哈希表时,并不知道客户端会用它做什么,他们可以随意插入或删除。那么,如何控制α呢?实际上,在哈希表实现内部,你可以控制的是桶的数量,即α的分母。如果分子(元素数量)开始增长,你可以在某个点让分母也增长。实际的哈希表实现会跟踪哈希表的人口(存储的对象数量)。随着插入的东西越来越多,实现会确保分母以相同的速率增长,即桶的数量也增加。如果α超过了某个目标值(比如0.75或0.5),你可以将哈希表中的桶数量翻倍。你定义一个新的哈希表,使用一个范围加倍的新哈希函数。现在,由于分母翻倍,负载下降了一半。这就是你控制它的方法。同样,如果空间非常紧张,你也可以在发生大量删除时(例如在链地址法实现中)缩小哈希表。

这是关于为了获得哈希表性能的期望保证,在底层必须正确处理的第一点:你必须控制负载,使哈希表的大小大致等于你存储的对象数量。

第二件必须正确处理的事情,我们在实现视频中已经提到过,就是必须使用足够好的哈希函数。一个好的哈希函数能将数据均匀地分散到各个桶中。真正理想的是一个能独立于数据而表现良好的哈希函数,这也是本课程迄今为止的主题:无论输入是什么,算法都能保证运行得非常快。你可能会想,这正是你想从这样的课程中学到的东西:学*那个总是表现良好的“秘密”哈希函数。

不幸的是,我不会告诉你这样的哈希函数。原因不是我备课不充分,也不是因为人们不够聪明而未能发现这样的函数。问题要根本得多:这样的函数不可能存在。也就是说,每个哈希函数都有其克星,存在一个病态数据集,在该数据集下,这个哈希函数的性能会和你见过的最糟糕的常数哈希函数一样差

原因相当简单,这实际上是哈希函数从某个巨大的宇宙(键空间)压缩到一个相对较小数量的桶时,不可避免的结果。让我详细说明。

固定任何一个你能想象到的最聪明的哈希函数h。这个哈希函数将某个宇宙U映射到索引为0到n-1的桶。记住,在所有有趣的情况下,宇宙的大小是巨大的,所以U的基数应该比n大得多,这是我在这里的假设。

例如,你可能在记录人名,宇宙U是长度最多为30个字符的字符串集合,而n在任何应用中都将远小于26的30次方。

现在,我们使用鸽巢原理的一个变体。我断言,这n个桶中至少有一个桶,必须至少有宇宙中键数量的1/n部分映射到它。也就是说,存在一个桶i(0 ≤ i ≤ n-1),使得至少有 |U| / n 个键在哈希函数h下被映射到i。

理解这一点的方法是记住哈希函数的映射图景:原则上,它将宇宙中的每个键映射到这些桶中的一个。哈希函数必须把每个键放到n个桶中的某一个里,所以其中一个桶必须至少获得所有可能键的1/n部分。一个更具体的思考方式是,想象一个用链地址法实现的哈希表,并在脑海中想象你将宇宙中的每一个键都哈希进这个哈希表。这个哈希表将过度拥挤到疯狂的程度,你永远无法在计算机上存储它,因为它将包含U的全部对象,但它只有n个桶,所以其中一个桶必须至少有U/n比例的对象。

这里的要点是:无论哈希函数是什么,无论你构建得多么巧妙,总会存在某个桶(比如31号桶),它获得了宇宙中至少其“公平份额”的映射。既然我们已经识别出这个桶(31号桶),其中至少有宇宙的1/n部分映射到它,那么要构建我们的病态数据集,我们只需从这些映射到31号桶的元素中挑选。

对于这样的数据(我们可以让这个数据尽可能大,因为|U|/n是难以想象的大,因为U本身难以想象的大),在这个数据集中,所有东西都会冲突。哈希函数将每一个元素都映射到31号桶,这将导致糟糕的哈希表性能,其性能并不比朴素的链表解决方案好。例如,在桶31发生冲突的哈希表中,你只会找到一个包含所有插入哈希表内容的链表。对于开放地址法,可能更难看出会发生什么,但同样,如果所有东西都冲突,你最终基本上会得到线性时间的性能,与常数时间性能相去甚远。

现在,对于那些认为这似乎只是无意义的抽象数学的人,我想说明两点。首先,至少这些病态数据集告诉我们,我们将不得不以一种不同于以往讨论算法的方式来讨论哈希函数。当我们讨论归并排序时,我们说它无论输入是什么都在O(n log n)时间内运行;讨论Dijkstra算法时,它无论输入是什么都在O(m log n)时间内运行;深度优先搜索、广度优先搜索都是线性时间,无论输入是什么。对于哈希函数,我们将不得不说一些不同的话:我们不能说哈希表无论输入是什么都有良好的性能,这一页幻灯片表明那是错误的。

我想指出的第二点是,虽然这些病态数据集当然不太可能随机出现,但有时你会担心有人为你的哈希函数构造病态数据,例如在拒绝服务攻击中。Crosby和Wallach在2003年的一篇研究论文中有一个非常聪明的例子说明了这一点。

Crosby和Wallach的主要观点是,存在许多现实世界的系统(他们最有趣的应用可能是一个网络入侵检测系统),你可以通过利用设计不良的哈希函数使它们瘫痪。这些应用都关键性地使用了哈希表,而这些系统的可行性完全依赖于从哈希表获得常数时间的性能。因此,如果你能为这些哈希表展示一个病态数据集,使其性能退化为线性(退化为简单的链表解决方案),这些系统就会被破坏,它们会崩溃或无法运行。

我们在上一张幻灯片中看到,每个哈希表确实都有自己的克星,即存在一个病态数据集。但问题是:如果你试图对这些系统之一进行拒绝服务攻击,你如何想出这样的病态数据集?Crosby和Wallach研究的系统通常表现出两个属性:首先,它们是开源的,你可以检查代码,看到它们使用的是哪个哈希函数;其次,哈希函数通常非常简单,它是为了速度而设计的,结果就是,仅仅通过检查代码,就很容易逆向工程出一个确实能破坏哈希表、使其性能退化为线性的数据集。

例如,在网络入侵检测应用中,有一个哈希表只是记录经过的数据包的IP地址,因为它正在寻找可能表明某种入侵的数据包模式。Crosby和Wallach展示了如何向系统发送一批带有巧妙选择的发送者IP的数据包,确实使系统崩溃,因为哈希表的性能爆炸性增长到不可接受的程度。

那么,我们该如何应对“每个哈希函数都有病态数据集”这一事实呢?这个问题既有实际意义(例如,如果我们担心有人构造病态数据集实施拒绝服务攻击,我们应该使用什么哈希函数?),也有数学意义(如果我们不能给出像之前那样的数据独立保证,我们如何在数学上说明哈希函数具有良好的性能?)。

让我提两种解决方案。第一种解决方案更多是针对实际问题的:如果你担心有人构造病态数据集,你应该实现什么样的哈希函数?有一些叫做加密哈希函数的东西,例如SHA-2(它实际上是一个针对不同桶数的哈希函数族)。这些内容超出了本课程的范围,你会在密码学课程中学到更多。显然,你可以用这些关键词在网上查找并阅读更多信息。

我想指出的一点是,像SHA-2这样的加密哈希函数本身也有病态数据,它们有自己的“克星”版本。它们在实践中表现良好的原因是,找出这个病态数据集是不可行的。与Crosby和Wallach在其应用程序源代码中发现的非常简单的哈希函数不同(在那里很容易逆向工程出坏的数据集),对于像SHA-2这样的东西,没有人知道如何逆向工程出坏的数据集。当我说“不可行”时,我指的是通常的密码学意义上的不可行,类似于人们会说如果正确实现RSA密码系统,破解它是不可行的,或者分解大数在一般情况下是不可行的,等等。

关于加密哈希函数我就说这么多。我还想提第二种解决方案,它在实践中是合理的,并且我们可以在数学上对其说一些事情,那就是使用随机化

更具体地说,我们不会设计一个单一的巧妙哈希函数,因为我们已经知道,单一的哈希函数必然有病态数据集。相反,我们将设计一个非常巧妙的哈希函数族,然后在运行时,我们将从这个族中随机选择一个哈希函数使用。

现在,我们想要并且能够证明的关于哈希函数族的保证,将非常类似于快速排序的精神。回想一下,在快速排序算法中,对于几乎任何固定的枢轴选择序列,都存在一个病态输入,会使快速排序退化为二次运行时间。我们的解决方案是随机化快速排序,即不在运行时预先承诺任何特定的选择枢轴的方法,而是随机选择枢轴。我们证明了关于快速排序的什么?我们证明了对于任何可能的输入(任何可能的数组),快速排序的平均运行时间是O(n log n),其中平均是对快速排序运行时随机选择求取的。在这里,我们将做同样的事情:现在我们将能够说,对于任何数据集,平均而言(关于我们运行时选择的哈希函数),哈希函数将表现良好,即它会均匀地分散数据。我们颠倒了上一张幻灯片中的量词:之前我们说,如果我们预先承诺一个单一的哈希函数(固定一个h),那么存在一个能破坏该哈希函数的数据集;这里我们颠倒了它,我们说对于每个固定的数据集,随机选择的哈希函数平均将在该数据集上表现良好,就像在快速排序中一样。

请注意,这并不意味着我们不能让我们的程序开源。我们仍然可以发布代码,说明“这是我们的哈希函数族,代码中将从这个集合中随机选择一个哈希函数”。但关键在于,通过检查代码,你将无法知道算法在运行时做出了什么随机选择,因此你对实际的哈希函数一无所知,也就无法为运行时选择的哈希函数逆向工程出病态数据集。

接下来的几个视频将详细阐述第二种解决方案,即使用运行时随机选择的哈希函数,作为一种在每一个数据集上(至少在平均意义上)都能表现良好的方法。

让我给你一个接下来学*路径的路线图。

我将把这个随机化解决方案的细节讨论分成三个部分,分布在两个视频中。

在下一个视频中,我们将从一个定义开始:我所说的“哈希函数族”是什么意思,以至于如果你随机选择一个,你很可能会做得相当好。这个定义被称为通用哈希函数族

然而,一个数学定义本身几乎毫无价值。要使它有价值,它必须满足两个属性:首先,必须有有趣且有用的例子满足这个定义,也就是说,必须存在有用的、符合这个通用族定义的哈希函数。所以第二件事将是向你展示它们确实存在。数学定义需要的另一件事是应用,即如果你能满足这个定义,那么好事就会发生。这将是第三部分。

总结

本节课中,我们一起学*了哈希表性能的关键因素。我们明确了要获得常数时间操作,必须控制负载因子α为常数。更重要的是,我们认识到不存在一个对任何数据集都表现完美的单一哈希函数,每个哈希函数都存在能使其性能急剧下降的病态数据集。这促使我们转向使用随机化策略,即从一个精心设计的哈希函数族中随机选择哈希函数,从而为任何固定的输入数据集提供平均意义上的良好性能保证,这为后续学*通用哈希函数族的概念奠定了基础。

027:布隆过滤器基础

在本节课中,我们将要学*一种名为布隆过滤器的数据结构。这是一种由Burton Bloom在1970年提出的、比普通哈希表更节省空间的数据结构。它的代价是,在进行查找操作时,存在一个非零的误报概率。尽管如此,它在许多实际应用中仍然非常有用。

概述:布隆过滤器是什么?🤔

布隆过滤器是哈希表的一种变体。它支持快速的插入和查找操作,但其核心优势在于极高的空间效率。与需要存储键值对或对象指针的哈希表不同,布隆过滤器仅用于记录一个元素是否存在于集合中。这种设计带来了空间上的巨大节省,但也引入了允许误报(即可能错误地认为一个未插入的元素存在)的特性。

操作与性能:API与权衡⚖️

上一节我们介绍了布隆过滤器的基本概念,本节中我们来看看它具体支持哪些操作,以及其性能特点。

布隆过滤器主要支持两种操作:

  • 插入:将一个元素添加到过滤器中。
  • 查找:查询一个元素是否“可能”存在于过滤器中。

其性能特点是插入和查找都非常快,时间复杂度通常为O(k),其中k是一个小常数(哈希函数的数量)。

你可能会问,既然哈希表也能实现快速的插入和查找,为什么还需要布隆过滤器?关键在于两者的权衡:

优点:

  • 极高的空间效率:布隆过滤器每个元素占用的空间远小于哈希表,甚至可以比元素本身占用的空间还小。

缺点:

  1. 仅存储成员信息:它不存储元素对象本身或指向它们的指针,只记录“见过”或“没见过”的状态。这类似于哈希集合
  2. 不支持删除:在基础的布隆过滤器实现中,很难安全地删除一个元素(类似于开放寻址法哈希表)。虽然有支持删除的变体,但更复杂。
  3. 存在误报:这是布隆过滤器独有的缺点。它不会有漏报(即已插入的元素一定被认为存在),但会有误报(即未插入的元素可能被误认为存在)。

因此,选择使用布隆过滤器还是哈希表,取决于具体应用场景。如果空间极其宝贵,且可以容忍小概率的误报,那么布隆过滤器是理想选择。如果必须保证100%的准确性,则应使用哈希表。

应用场景:布隆过滤器用在哪里?💡

了解了布隆过滤器的特性后,我们来看看它在哪些实际场景中能发挥作用。以下是几个典型的应用示例:

  • 拼写检查器:早期(约40年前)的一个应用。将所有字典单词插入布隆过滤器。检查文档时,对每个单词进行查找。若过滤器返回“存在”,则视为拼写正确;否则视为错误。虽然有小概率误报(将错词判为对),但在当时空间紧张的情况下是很好的权衡。
  • 禁用密码列表:将过于简单或常见的密码插入布隆过滤器。当用户设置新密码时,进行查找。若过滤器返回“存在”,则拒绝该密码,要求用户重设。在此场景中,极低的误报率(如0.1%)是可以接受的,因为这仅意味着极少数用户的强密码被错误拒绝,需要再输一次。
  • 网络路由器:这是当今布隆过滤器的“杀手级应用”。路由器需要处理海量数据包,对空间和速度要求极高。布隆过滤器可用于:
    • 跟踪被阻止的IP地址。
    • 记录缓存内容,避免不必要的查找。
    • 维护统计数据以检测拒绝服务攻击等。

实现原理:窥探内部机制🔧

前面我们讨论了布隆过滤器的用途,现在让我们深入其内部,看看它是如何实现的。其核心思想简单而巧妙。

布隆过滤器主要由两部分组成:

  1. 一个比特数组:一个长度为m的数组,其中每个位置只存储一个比特(0或1),而不是一个桶或一个对象。空间效率以每个对象占用的比特数 m/n 来衡量,其中n是插入元素的数量。我们可以将其调整到很小的值,例如8比特/对象。这意味着,即使要存储一个32位的IP地址,我们也只用了8位来“记住”它,空间效率极高。
  2. 多个哈希函数:我们使用k个独立的、均匀分布的哈希函数(k是一个小常数,如3、4、5)。在实践中,有时使用两个哈希函数并通过线性组合生成k个哈希值就足够了。

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

插入操作:
当一个新对象x需要插入时,我们计算k个哈希值,并将数组中对应位置的比特位设置为1(无论它们之前是0还是1)。

def insert(x):
    for i in range(k):
        index = hash_i(x) % m
        bit_array[index] = 1

查找操作:
当查找一个对象x时,我们计算k个哈希值,并检查数组中所有对应位置的比特位是否都为1。如果全是1,则返回“可能存在”;如果任何一个为0,则返回“肯定不存在”。

def lookup(x):
    for i in range(k):
        index = hash_i(x) % m
        if bit_array[index] == 0:
            return False # 肯定不存在
    return True # 可能存在(但有误报概率)

为何没有漏报,却有误报?🎯

从上面优雅的代码中,我们可以快速理解其错误特性:

  • 没有漏报:一旦一个元素被插入,其对应的k个比特位就被设置为1。由于比特位只会从0变成1,而不会从1变回0(在基础实现中),所以后续查找该元素时,一定会发现所有比特位都是1。因此,已插入的元素永远不会被误判为不存在
  • 存在误报:考虑一个从未插入的对象y。可能由于其他不同对象的插入,凑巧把y对应的k个比特位都设置成了1。当查找y时,过滤器会发现所有位都是1,从而错误地认为y存在。这就是误报

因此,布隆过滤器用极小的空间开销和允许小概率误报的代价,换取了极高的空间效率。一个关键问题是:我们能否在保持极低空间开销的同时,也将误报概率控制得非常低? 这需要通过数学分析来回答,也是评估布隆过滤器是否实用的关键。

总结📚

本节课中我们一起学*了布隆过滤器。我们了解到它是一种空间效率极高的 probabilistic data structure(概率数据结构),支持快速插入和查找。其核心原理是使用一个比特数组多个哈希函数来记录集合的成员信息。

布隆过滤器不会产生漏报,但会产生误报。这使得它特别适用于那些空间成本是关键约束,且可以容忍偶尔误报的应用场景,例如早期的拼写检查、现代网络路由器中的高速数据过滤、以及禁用密码列表等。

你需要记住,布隆过滤器是你编程工具箱中一个用于空间优化的特殊工具,在需要以极小内存快速判断“是否存在”且能接受微小错误率的场合,它会非常有用。

028:布隆过滤器启发式分析

在本节课中,我们将要学*布隆过滤器的启发式分析。我们将通过一个简化的数学模型,来精确理解布隆过滤器在空间使用和错误率之间的权衡关系。通过分析,我们将找到设置参数(如哈希函数数量)的最优方法,并最终评估布隆过滤器在实际应用中的实用性。

分析目标与假设

上一节我们介绍了布隆过滤器的基本工作原理,本节中我们来看看如何对其性能进行量化分析。我们直观上知道,布隆过滤器需要在两种资源之间进行权衡:一种是空间消耗(使用的比特数),另一种是正确性(错误率)。我们希望理解,随着我们使用更多空间,错误率如何下降;反之,随着我们压缩表格、更多地复用比特,错误率如何上升。

为了使数学分析易于处理,我们将做出一个启发式假设。这个假设虽然在实际使用的哈希函数中并不严格成立,但它能帮助我们推导出布隆过滤器的性能保证。在实际实现中,你应该验证你的实现是否达到了理想分析所预测的性能水平。

以下是我们的核心假设:

  • 我们假设所有的哈希行为都是完全随机的。
  • 对于每个哈希函数 H 和每个可能的对象 x,哈希函数为该对象给出的数组位置(槽位)首先是均匀随机的。
  • 并且,该输出独立于所有其他哈希函数在所有其他对象上的所有输出。

比特位被设置为1的概率

在分析错误率之前,我们先计算一个中间量:在将数据集 S 插入布隆过滤器后,数组中某个特定比特位被设置为1的概率。由于对称性,我们可以关注任意一个比特位,比如第一个比特位。

设总比特数为 n,哈希函数数量为 k,插入的对象数量为 |S|。一个比特位要始终保持为0,必须“躲过”所有插入操作中所有哈希函数产生的“飞镖”(即哈希命中)。每次哈希命中该比特位的概率是 1/n,未命中的概率是 1 - 1/n

以下是计算过程:

  1. 一个对象插入时,会进行 k 次哈希,相当于投掷 k 次飞镖。
  2. 总共有 |S| 个对象被插入,因此总共投掷的飞镖数为 k * |S|
  3. 该比特位躲过所有飞镖的概率是 (1 - 1/n)^(k * |S|)
  4. 因此,该比特位最终被设置为1的概率 p 为:
    p = 1 - (1 - 1/n)^(k * |S|)

为了简化这个表达式,我们引入一个*似公式:对于任意实数 x,有 (1 + x) ≤ e^x。令 x = -1/n,我们可以得到:
p ≤ 1 - e^(- (k * |S|) / n)

我们进一步引入符号 b 来表示每个对象平均使用的比特数,即 b = n / |S|。那么上述不等式可以重写为:
p ≤ 1 - e^(-k / b)

这个表达式已经初步揭示了权衡关系:当每个对象分配的空间 b 增大时,指数项趋*于0,p 趋*于0,即数组中1的密度变小,这应该会降低错误率。

错误率(假阳性概率)分析

上一节我们计算了单个比特位为1的概率,本节中我们来看看如何计算我们真正关心的量——错误率。对于一个从未被插入布隆过滤器的对象 x,要发生一次假阳性(错误地认为它在集合中),必须满足一个条件:x 的所有 k 个哈希位都已经被设置为1。

由于我们假设哈希函数是独立的,每个比特位为1的概率(*似)为 p。因此,所有 k 个比特位都为1的概率,即假阳性概率 ε,约为 pk 次方:
ε ≈ (1 - e^(-k / b))^k

这个公式就是我们寻求的量化权衡曲线。它明确显示了错误率 ε 如何随着每个对象使用的比特数 b 的增加而指数级下降。

参数优化与实用性评估

现在我们有了权衡公式,可以回答一个关键问题:如何设置哈希函数的数量 k?我们的目标是在固定的空间预算 b 下,最小化错误率 ε

以下是优化步骤:

  1. 对于固定的 b,将 ε 视为 k 的函数。
  2. 通过微积分求导(此处作为练*),可以找到使 ε 最小的最优 k 值。
  3. 最优解*似为:k ≈ ln(2) * b,其中 ln(2) ≈ 0.693

将最优的 k 值代回错误率公式,我们可以得到在最优配置下,空间与错误率的关系:
ε ≈ (1/2)^(ln(2) * b) ≈ (0.6185)^b

或者,我们也可以将所需的比特数 b 表示为目标错误率 ε 的函数:
b ≈ 1.44 * log₂(1/ε)

这个关系非常有力:错误率随 b 指数下降。这意味着,只需将每个对象分配的比特数翻倍,就能将错误率平方,从而使其急剧减小。

那么,布隆过滤器实用吗?答案是肯定的。让我们看一个例子:

  • 如果设置 b = 8(即每个对象仅用8比特,1字节),根据公式,最优 k 约为5或6,此时的错误率 ε 大约为 2%。这对于许多应用(如缓存旁路检查、拼写检查器)来说已经足够好。
  • 如果将 b 增加到16(2字节),错误率会骤降到约 0.02%(五千分之一),而空间开销依然极小。

总结

本节课中我们一起学*了布隆过滤器的启发式分析。我们通过“完全随机哈希”的假设,推导出了空间使用率(b,每个对象的比特数)与假阳性错误率(ε)之间的精确权衡公式:ε ≈ (1 - e^(-k / b))^k。通过优化哈希函数数量 k(约为 0.693 * b),我们得到了最优性能,其错误率随空间增加呈指数下降:ε ≈ (0.6185)^b

分析表明,布隆过滤器是一个非常实用的数据结构。即使为每个对象分配极少的空间(如8-16比特,远小于存储对象本身),也能在实现快速插入和查找的同时,将错误率控制在一个可接受的低水平(如1%-0.02%)。这使得它在需要极高效空间利用的众多场景中成为获胜方案。在实际应用中,应使用良好的哈希函数并在真实数据上测试,以确保达到接*理论分析的性能。

001:-01-贪心算法简介 🎯

概述

在本节课中,我们将要学*一种新的算法设计范式——贪心算法。我们会将其置于更广阔的算法设计背景中,回顾已学的范式,并展望后续内容。同时,我们会探讨贪心算法的核心思想、特点,以及如何分析其正确性。


算法设计范式概览

在算法设计中,不存在能解决所有计算问题的“银弹”。因此,本课程的重点是讨论适用于不同领域、不同问题的通用技术,即算法设计范式。这些范式是跨越多种应用的高级问题解决策略。

以下是几个例子:

  • 分治算法:我们在第一部分开始学*。典型例子是归并排序。其步骤是:将问题分解为更小的子问题,递归求解子问题,最后合并结果得到原问题的解。
  • 随机化算法:我们在第一部分有所涉及。其思想是让代码内部进行随机选择。这通常能带来更简单、实用或优雅的算法,例如使用随机主元的快速排序算法。
  • 贪心算法:这是我们将要讨论的下一个主要范式。这类算法迭代地做出“短视”的决策。
  • 动态规划:这是本课程将讨论的最后一个范式,它是一种非常强大的技术,能解决我们之前提出的序列比对和分布式最短路径等核心问题。

什么是贪心算法?🤔

我不会提供一个正式定义,因为关于哪些算法精确属于贪心算法一直存在争议。但我可以给出一个非正式描述,作为判断贪心算法的经验法则。

通常,贪心算法会做出一系列决策,每个决策都是短视的——即它在当下看起来是个好主意,然后你希望最终一切都能顺利。

理解贪心算法的最佳方式是看例子,后续课程会提供多个案例。但我想指出,我们实际上已经在课程第一部分见过一个贪心算法的例子:Dijkstra最短路径算法

Dijkstra算法为何是贪心的?

回顾Dijkstra算法的伪代码,其核心是一个主while循环。算法在每次循环迭代中处理一个新的目标顶点。因此,对于n个顶点的图,总共有n-1次迭代。算法对每个目标顶点只有一次计算最短路径的机会,之后不会再回头重新审视这个决策。从这个意义上说,它的决策是短视且不可撤销的,这正是Dijkstra算法属于贪心算法的原因。


贪心算法范式的一般讨论

在深入探讨之前,让我们从更宏观的角度讨论贪心算法设计范式。这个讨论可能有些抽象,建议在看过几个例子后再回顾本节内容,届时理解会更深刻。

我将通过与我们已深入学*的分治算法范式进行比较和对比来展开。

以下是几个关键的对比点:

  1. 设计的难易程度

    • 贪心算法:其优点(也是弱点)在于非常容易应用。通常很容易为一个问题构思出看似合理的贪心算法,甚至多个不同的版本。
    • 分治算法:通常构思一个合理的分治算法比较棘手。你往往需要一个“灵光一现”的时刻,才能找到正确分解问题的方式。
  2. 运行时间分析

    • 贪心算法:分析其运行时间通常要简单得多。通常只需一行代码就能说明,例如,主要工作量由一个排序子程序主导,而我们知道排序需要 O(n log n) 时间。
    • 分治算法:分析其运行时间曾颇具挑战性。我们必须理解递归多层的运行时间,一方面问题规模在减小,另一方面子问题数量在增加。我们不得不借助主定理等强大工具来分析。

  1. 正确性证明
    • 贪心算法:我们需要付出更多努力来理解其正确性。通常,即使对于一个正确的贪心算法,我们也很难直观理解它为何正确,更不用说如何证明。许多看似自然的贪心算法其实是错误的
    • 分治算法:我们之前没有过多讨论正确性证明,因为它通常是一个相当直接的归纳证明。

贪心算法的不正确性:一个警示 ⚠️

为了让你立即体会到自然贪心算法普遍存在的不正确性,让我们回顾本课程第一部分关于Dijkstra算法的一个要点。

在证明Dijkstra算法正确时,我们做了一个重要假设:图中所有边的长度都是非负的。我们不允许负边长的存在。

现在,通过一个小测验来回顾为什么Dijkstra算法在允许负边长时是不正确的,尽管它看起来如此自然。

考虑一个简单的图,包含三个顶点S、V、W和三条边:

  • S -> V:长度为3
  • S -> W:长度为2
  • V -> W:长度为-2

问题

  1. Dijkstra算法计算出的从S到W的最短路径距离是多少?
  2. 实际上,从S到W的真正最短路径距离是多少?(路径长度是路径上各边长度之和)

答案与分析

  • 实际最短路径距离:从S到W有两条路径。直接路径S->W长度为2。间接路径S->V->W长度为3 + (-2) = 1。因此,实际最短路径距离是1。
  • Dijkstra算法的输出:根据其伪代码,在第一次迭代中,它会“短视”地找到离S最*的顶点。此时,W(距离2)比V(距离3)更*。因此,它会不可撤销地将S到W的最短路径距离计算为2,之后不会再考虑经由V的路径。所以,Dijkstra算法会错误地输出2。

这并不违背我们在第一部分证明的结论,因为那个证明的前提就是所有边长为非负。这个例子提醒我们:构思一个贪心算法很容易,尤其是自己构思时,你内心可能深信它总是正确的。但更多时候,你的贪心启发式方法可能仅仅是个启发式方法,在某些实例上会出错。


如何证明贪心算法的正确性?🔍

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

坦率地说,证明贪心算法的正确性更像是一门艺术而非精确科学。与分治范式那种公式化的方式不同,贪心算法的正确性证明需要更多创造力和一些特设的技巧。不过,其中仍有一些反复出现的主题和方法。

以下是两种常见的高级证明方法,建议在看过具体例子后再来回顾,理解会更清晰。

方法一:归纳证明(或“贪心保持领先”)

贪心算法会顺序做出一系列不可撤销的决策。这里的归纳将基于算法所做的决策进行。回顾我们对Dijkstra算法正确性的证明,那正是通过归纳进行的:我们归纳证明主while循环的每一次迭代。我们假设之前的所有计算都是正确的(归纳假设),然后证明当前迭代的计算也是正确的。通过归纳,算法所做的一切都是正确的。一些教科书称这种方法为“贪心保持领先”,意即你一步步证明贪心算法始终在做正确的事。

方法二:交换论证

这是另一种在许多情况下有效的证明方法。你尚未在本课程中见过交换论证的例子,所以我们接下来会通过交换论证来证明几个著名贪心算法的正确性。

它有两种常见的思路:

  1. 反证法思路:假设贪心算法不正确,然后证明你可以取一个最优解,交换其中的两个元素,从而得到一个更好的解。这与你最初假设拥有一个最优解相矛盾。
  2. 转换思路:逐步将一个最优解通过一系列“交换”操作,转换成贪心算法输出的解,并且在此过程中不使解变差。这表明贪心算法的输出实际上也是最优的。形式上,这可以通过归纳完成,归纳基础是将最优解转换为你的解所需的交换次数。

最后,我必须再次强调,证明贪心算法正确性没有太多固定公式。你通常需要相当有创造性,可能需要结合方法一和方法二,甚至需要完全不同的、任何严谨的证明方法。


总结

本节课我们一起学*了贪心算法设计范式的入门知识。我们将其置于算法设计范式的全景中,理解了贪心算法通过一系列短视、不可撤销的决策来求解问题的核心特征。我们以Dijkstra算法为例说明了其贪心本质,并特别警示了贪心算法常常不正确的特性,尤其是在允许负边长的图中Dijkstra算法会失效。最后,我们探讨了证明贪心算法正确性的两种高级方法:归纳证明交换论证,并指出这通常需要创造性的思维。在接下来的课程中,我们将通过具体例子深入实践这些概念。

002:问题定义

在本节课中,我们将学*如何将贪心算法应用于调度问题。具体来说,我们将研究如何在共享资源上安排作业顺序,以优化特定的目标函数。

调度领域有众多应用场景,本课程将介绍其中两种。今天,我们将从一个简单的场景开始:假设只有一个共享资源(例如一个计算机处理器),并且有一系列作业需要处理。我们的核心算法问题是:我们应该以何种顺序来安排这些作业?

为了回答这个问题,我们需要更精确地定义数学模型。首先,让我们明确每个作业的特征。

作业的参数

每个作业 J 都有两个已知的非负实数参数:

  1. 权重 W_J:表示作业的重要性。权重越高,通常意味着该作业更值得被优先处理。
  2. 长度 L_J:表示处理该作业所需的时间。为简化问题,我们假设每个作业的长度是已知的。

因此,问题的输入是 n 个作业,每个作业由权重 W_J 和长度 L_J 定义。输出则是这 n 个作业的一个排列顺序。

优化目标:完成时间与加权和

为了评估一个调度顺序的好坏,我们需要引入完成时间的概念。

一个作业的完成时间 C_J 定义如下:

  • 对于第一个被调度的作业,其完成时间就是它自身的长度。
  • 对于后续的作业,其完成时间是它之前所有作业的长度之和,再加上它自身的长度。

用公式表示,如果作业 J 被安排在第 k 个位置,其完成时间为:
C_J = L_(1) + L_(2) + ... + L_(k-1) + L_J

显然,我们希望所有作业的完成时间都尽可能小。但在任何调度中,靠前的作业完成时间短,靠后的作业完成时间长,这必然需要在不同作业之间进行权衡。权衡的方式取决于我们选择优化的目标函数。

本节课我们将关注一个非常自然的目标函数:最小化加权完成时间之和

其数学表达式为:
最小化: Σ (W_J * C_J)
其中,J 从 1 到 nW_J 是作业权重,C_J 是作业完成时间。

这等价于最小化以输入权重为权重的加权平均完成时间。

示例说明

让我们通过一个例子来巩固理解。假设有三个作业:

作业 权重 (W) 长度 (L)
1 3 1
2 2 2
3 1 3

考虑调度顺序:作业1 -> 作业2 -> 作业3。

以下是计算过程:

  1. 作业1(第一个):完成时间 C1 = 1。加权完成时间 = 3 * 1 = 3
  2. 作业2(第二个):完成时间 C2 = 1 + 2 = 3。加权完成时间 = 2 * 3 = 6
  3. 作业3(第三个):完成时间 C3 = 1 + 2 + 3 = 6。加权完成时间 = 1 * 6 = 6

该调度方案的加权完成时间总和为:3 + 6 + 6 = 15。可以验证,对于这个特定的输入,在所有6种可能的调度顺序中,这个顺序确实最小化了加权完成时间之和。

总结

本节课我们一起学*了调度问题的基本定义。我们明确了每个作业由权重和长度两个参数描述,定义了作业的完成时间,并确立了我们的优化目标是最小化加权完成时间之和,其公式为 Σ (W_J * C_J)

在接下来的课程中,我们将探讨如何设计高效的算法,为任意给定的作业列表(包含权重和长度)找到能实现这一最优目标的调度顺序。

003:贪心算法

在本节课中,我们将学*如何设计一个贪心算法,以最小化一组给定作业的加权完成时间之和。更重要的是,我们将重点关注推导这个贪心算法的过程,因为这个过程本身可以应用于解决其他问题。

我们将首先研究问题的一个特殊案例,在这个案例中,最优解是直观的。通过分析这些特殊案例,我们将引出几种自然的贪心算法。接着,我们将探讨如何从这些候选算法中筛选出唯一正确的那个。我们将在后续课程中证明这个算法的正确性。

问题回顾

首先,让我们简要回顾一下我们要解决的问题。计算问题的输入包含 n 个作业,每个作业都有权重和长度。在 n! 种可能的作业排序方式中,我们需要找到一种排序,使得加权完成时间之和最小。

回忆上一节课的内容,作业的完成时间是指从开始到该作业完成所经过的总时间。这等于该作业之前所有作业的长度之和,再加上该作业本身的长度。

我们的目标是设计一个贪心算法,能够始终解决这个问题。

为什么尝试贪心算法?

为什么贪心算法似乎是解决调度问题的合理方法?通常,贪心算法并不能保证总是有效,可能需要更复杂的方法。但调度问题本身的结构使其成为尝试贪心算法的好地方。贪心算法通过迭代地做出局部最优决策来构建解,并希望最终能得到一个良好的结果。我们正在研究一个排序问题,其定义就是按顺序调度作业。这种解决方案的迭代性质表明,如果问题足够简单,或许存在一个贪心算法,能够简单地按照正确的顺序逐个调度作业。我们将验证这种方法是否适用于最小化加权完成时间之和。

从特殊案例入手

让我们从积极的角度开始思考,假设确实存在一个贪心算法可以解决这个问题。考虑到我们正处于课程的贪心算法部分,你可能对此并不感到意外。但假设存在这样一个算法,我们该如何发现它呢?一个有用的技巧是,首先关注问题的一些特殊案例,在这些案例中,应该如何进行是相对清晰的。这个技巧不仅适用于这个问题,在现实生活中也很有用。

对于这个问题,我们需要考虑两个特殊案例:

  1. 假设所有作业的长度完全相同,但权重不同。那么你认为应该按什么顺序调度作业?
  2. 假设所有作业的权重完全相同,但长度不同。那么你认为应该按什么顺序调度作业?

案例一:长度相同,权重不同

首先,当所有作业长度相同时,你应该优先调度权重更大的作业。这很直观,因为权重的语义表示重要性更高,所以权重高的作业应该排在前面。从最小化加权完成时间之和的公式来看,如果所有作业长度相同,那么无论采用何种顺序,完成时间序列都是相同的。例如,如果所有作业长度为 1,那么完成时间序列将是 1, 2, 3, ..., n。为了使加权和最小,你需要将最高的权重与最小的完成时间(即最早的位置)相关联,也就是说,你需要将权重高的作业安排在前面。

案例二:权重相同,长度不同

第二个案例是作业权重相同但长度不同,我认为这稍微微妙一些。在这种情况下,你应该总是倾向于调度长度小的作业。在其他条件相同的情况下,这样做的原因是,在某个位置调度一个作业会迫使所有后续作业等待该作业完成。因此,无论你将哪个作业安排在最前面,它都会对所有 n-1 个后续作业产生负面影响。在其他条件相同的情况下,你希望将最小的作业放在前面,以最小化对后续作业的影响。如果你觉得这不太直观,我建议你看一个非常简单的例子:两个作业,权重都为 1,一个长度为 1,另一个长度为 2。如果你先调度小作业,完成时间分别为 1 和 3,总和为 4。但如果你先调度大作业,完成时间分别为 2 和 3,加权完成时间之和为 5,更大。

推广到一般情况

下一步是超越我们理解透彻的特殊案例,转向我们可能还不理解的一般情况。假设所有权重和长度都各不相同。如果我们有两个作业,并且上述两条经验法则给出相同的建议,那很好。如果一个作业的权重更高且长度更小,那么显然这个作业应该排在前面。但是,如果我们的两条经验法则——偏好高权重作业和偏好短作业——给出了相互矛盾的建议呢?例如,我们有一对作业,其中一个权重更高(优先级更高),但长度也比另一个大。哪个应该排在前面?

让我们再次保持积极的态度,尝试思考可能有效的最简单算法。这不能保证它一定有效,但它可能有效。我们有两个不同的参数:长度和权重。也许我们可以将这两个参数聚合成一个单一的参数,即每个作业的单一分数。这样,如果我们按照分数从高到低的顺序调度作业,就能始终得到最优解。那就太好了。你只需为每个作业将这两个数字编译成一个,然后排序即可。

当然,问题在于我们究竟应该如何选择这个聚合函数。我们如何将长度和权重编译成一个数字?作为指导原则,我们应该回顾我们的特殊案例,并确保我们尊重我们的两条经验法则。因此,在其他条件相同的情况下,我们应该偏好权重更高的作业。这意味着,如果我们计划按分数从高到低调度作业,那么更高的权重应该导致更高的分数。同时,如果长度更大,那应该降低分数,我们应该偏好长度小的作业。

这个想法留下了一个问题:我们究竟如何将作业的长度和权重聚合成一个数字?现在,我希望你思考一分钟,你可以使用哪些最简单的函数。这些是数学函数,它们以作业的长度和权重作为输入,输出一个单一的分数。这个函数应该具有这样的性质:它随着作业权重的增加而增加,随着作业长度的增加而减少。这个问题有不止一个答案,但请想出一些这个函数可能是什么样子的想法。

提出候选算法

确实,有许多函数具有这些性质。为了具体起见,我将写下我认为最简单的两个具有这些性质的函数。一个基于两个数字的差,另一个基于两个数字的比。

如果你要使用基于差的函数,并且希望它随权重增加而增加,随长度增加而减少,那么显然应该使用 权重 - 长度。这个值有时可能是负数,但这并不影响我们,算法仍然是定义良好的。

如果你要使用基于比的函数,并且希望它随权重增加而增加,随长度增加而减少,那么合理的比是 作业的权重 / 作业的长度

当然,对于你的某个评分函数,可能会出现并列的情况。我们允许任意打破并列。

现在,我们在这里看到的是我在贪心算法高层讨论中承诺过的东西的具体实例。也就是说,贪心算法的一个优点和缺点是它们真的很容易被提出和构想出来。在这个简单的问题中,我们现在有了两个相互竞争的贪心算法。由于这两个算法做不同的事情,最多只有一个算法总是正确的。至少有一个算法有时会是错误的。

排除错误算法

作为算法设计者,现在的过程是,也许我们可以通过展示一个算法不能正确工作的例子,来排除至少一个提出的贪心算法。我想强调的是,在你自己的算法设计探索中,很可能会出现这种情况。你可能遇到某个问题,还不确定如何解决。你已经构思了几个提议的算法。一个好的做法是,快速排除其中一些算法,认为它们不是解决问题的正确方法或好方法。

在这个背景下,我们有两个贪心算法。让我们快速找出其中一个的错误,证明它并不总是正确的。我们该怎么做呢?一个聪明的方法是,想出一个输入,使得两个算法做出不同的调度。如果它们做不同的事情,那么最多只有一个算法是正确的,至少有一个算法是不正确的。这就是我们的计划。

为了执行这个目标,像往常一样,我们希望尽可能保持简单。那么,能导致两个算法行为不同的最简单实例是什么?显然,一个作业是不够的,因为只有一种可能的可行解。但是,对于两个作业,我们可能可以让一个算法以一种顺序调度它们,而另一个算法以相反的顺序调度。事实上,不难想出一个包含两个作业的实例,使得它们的行为不同。让我现在为你写下这样一个实例。

假设我给你两个作业。第一个作业比第二个作业更长且更重要。具体来说,它的长度是 5,权重是 3。第二个作业的长度仅为 2,但权重仅为 1。

现在,我希望你执行我们提出的两个贪心算法。第一个算法按差值排序,第二个算法按比率排序。我希望你在这个包含两个作业的输入上执行它们,计算加权完成时间之和,然后回答两种调度对应的加权完成时间之和分别是多少。

正确答案是选项 B。让我们简要地看一下原因。

首先,确保我们理解哪个算法产生哪个调度。第一个作业有更好的比率,其比率是 5/3,而第二个作业的比率是 1/2,更小。然而,第二个作业有更大的差值,其差值是 -1,而第一个作业的差值更负,为 -2。因此,第一个按差值排序的算法将先调度第二个作业,然后调度第一个作业。第二个算法将先调度第一个作业,然后调度第二个作业。

接下来,只需计算这两种调度的目标函数值。对于第一个调度(第二个作业先),第二个作业权重为 1,完成时间为 2;第一个作业权重为 3,完成时间为 7(2+5)。这给我们总和 12 + 37 = 2 + 21 = 23。对于第二个算法产生的调度(第一个作业先),权重为 3 的作业完成时间(因为是第一个)仅为 5;然后权重为 1 的第二个作业完成时间为 7(5+2)。总和为 35 + 17 = 15 + 7 = 22。因此,按差值排序得到的总和为 23,按比率排序得到的总和为 22。在这个例子中,比率法表现得比差值法更好,所以差值法对于这个具体例子来说肯定不是最优的。

我们完成了什么?

我们所做的是,非常快速地排除了一个自然提出的贪心算法。我们知道按差值排序并不总是正确的。再次强调,它在特殊情况下是正确的,例如当所有长度相等或所有权重相等时,但在一般情况下是不正确的。

尽管如此,请记住我在贪心算法高层讨论中给出的警告:贪心算法经常是错误的。仅仅因为我们知道算法一有时不正确,完全不能意味着算法二就保证是正确的。对于同一个问题,很容易想出多个错误的贪心算法。然而,对于这个特定的贪心算法——算法二(按比率排序),它恰好总是正确的。但在没有提供证明、没有严谨的论证解释其正确性之前,你当然不应该相信这个说法。在了解真相之前,始终对贪心算法的性能保持健康的怀疑态度。

这实现了我对贪心算法高层讨论的另一个承诺:即当它们正确时,证明其正确性通常相当困难。这将是接下来几个视频的主题:这个贪心启发式算法的正确性证明。

我们讨论的关于贪心算法的第三个也是最后一点是,其运行时间通常不难分析。相对于分治算法,这是我们得到的一个便利。这一点在这里当然也成立。这个算法做什么?它只是计算这些比率,然后按比率对作业进行排序。因此,该算法本质上简化为一次排序计算。当然,从第一部分我们知道,可以在 O(n log n) 时间内完成排序。

总结

本节课中,我们一起学*了如何为最小化加权完成时间之和的调度问题设计贪心算法。我们从理解特殊案例(等长异权和等权异长)入手,提炼出“偏好高权重”和“偏好短作业”两条直觉。接着,我们尝试将长度和权重聚合成单一分数,自然引出了两种候选贪心算法:按 差值(权重 - 长度) 排序和按 比率(权重 / 长度) 排序。通过构造一个简单的两作业反例,我们快速排除了按差值排序的算法。最终,我们确定了按比率排序的算法作为候选,并指出其正确性需要严格的证明(将在后续课程中给出),而其运行时间仅为 O(n log n)。这个过程展示了设计贪心算法的典型思路:从特例获得直觉,提出候选,并通过反例快速排除错误选项。

004:调度应用的正确性证明 - 第一部分

在本节课中,我们将学*如何证明我们设计的贪心算法的正确性,该算法旨在最小化加权完成时间之和。

概述

我们将证明我们设计的第二个贪心算法——即根据每个作业的权重与长度之比进行降序排序的算法——对于所有可能的输入都是正确的。这意味着它总能输出最小化加权完成时间之和的作业序列。

设计这个贪心算法并不困难,分析其运行时间(与排序相同,为 O(n log n))也不难,但证明其正确性却相当棘手。我们将使用一种称为“交换论证”的方法来完成证明,这是贪心算法正确性证明中少数几个反复出现的原理之一。

证明计划

我们将从高层次概述证明计划,然后在后续幻灯片中深入细节。

首先,我们将固定一个任意的实例,即任意一组作业的权重和长度描述。由于我们必须证明算法总是正确的,因此我们只需在这个任意实例上证明其正确性。

对于没有比值相等的情况,我们将采用反证法进行证明。这意味着我们假设要证明的结论为假,并从中推导出明显错误或不一致的结论。

假设该主张为假,意味着存在一个实例,使得贪心算法没有产生最优解。也就是说,存在另一个未被贪心算法输出的解,其目标函数值优于贪心算法的解。

我们引入一些符号来设定这个场景。令 Σ 表示贪心算法生成的调度方案。如果我们的主张为假,则意味着 Σ 不是最优调度,存在另一个更优的调度方案,我们称之为 Σ*

为了完成反证法证明,我们需要推导出明显错误的东西。我们将证明,从“贪心算法非最优且存在更优调度 Σ*”这一假设出发,可以构造出另一个比 Σ* 更优的调度方案(即具有更小的目标函数值)。为什么这是一个矛盾呢?因为根据假设,Σ* 是最优的。如果我们证明了存在比 Σ* 更优的方案,那么 Σ* 就不是最优的,这就完成了反证法证明。

深入细节

现在,让我们开始填充这个证明计划的细节,使其更加严谨。

在本视频及下一个视频中,我们将假设所有作业的权重与长度之比都是互不相同的。当然,在一般情况下,这可能不成立。我们将提供一个单独的处理比值相等情况的论证。

我将做出第二个假设,但与第一个假设不同,第二个假设没有实质内容,只是关于符号的约定。我假设通过重命名作业,使得作业1具有最高的比值,作业2具有第二高的比值,依此类推,作业n具有最小的比值。

由于这个符号转换,贪心调度方案变得非常简单:它首先调度作业1,然后调度作业2,接着是作业3,依此类推,直到作业N。

因此,我们有一个非平凡的假设(我们将单独处理比值相等的情况),以及一个平凡的假设(只是为了简化符号)。

现在,让我们推导一些有实质内容的东西。

关键观察

给定贪心调度方案就是按顺序 1, 2, 3, ..., n 调度作业,并且假设贪心解不是最优的,存在另一个不同的最优调度方案 Σ*

我断言 Σ* 中必须包含一对连续的作业。也就是说,在调度方案 Σ* 中的某个位置,我可以找到一对连续执行的作业,使得这对作业中较早执行的那个作业具有较大的索引(即比值较低)。

我将这两个作业称为 ij,其中 i 较早执行。由于最优解 Σ* 不同于顺序 1, 2, 3, ..., N 的调度,因此在调度中必然存在某处,两个连续执行的作业中,较早的作业 i 的索引比较晚的作业 j 的索引更大。

为什么这是真的?推理如下:唯一一个索引随着从最早作业到最晚作业执行而只增不减的调度方案,就是按 1, 2, 3, ..., N 顺序调度作业的方案。除了 1, 2, 3, ..., N 之外,没有其他调度方案具有索引始终递增的性质。因此,任何不同于 1, 2, 3, ..., N 的调度方案都必须包含一对连续的作业,其中较早的作业具有比较晚的作业更大的索引。

这个观察在证明的其余部分非常重要。请确保你花时间仔细思考并确信它是正确的。

交换论证的核心

现在,我可以解释交换论证中的“交换”了。

让我总结一下到目前为止讨论的两个关键点:

  1. 我们改变了符号约定,使得索引越大,比值越小,而这正是贪心算法将输出的调度顺序。
  2. 假设最优调度方案 Σ* 是其他方案,我们知道它包含一对连续的作业,其中较早的作业具有较大的索引。

请记住本视频第一张幻灯片中的高层次证明计划:我们正在进行反证法证明,需要推导出一个矛盾。我们将通过展示一个比 Σ* 更优的调度方案来实现这一点,从而与 Σ* 假定的最优性相矛盾。

我们如何做到这一点呢?通过一次交换。

这次交换将采取一个思想实验的形式。我们将取这个据称最优的调度方案 Σ*,然后仅交换作业 ij 的顺序,而保持所有其他作业不变。

因此,Σ* 由各种作业组成,我们将其统称为“其他部分”,然后是作业 i,紧接着是作业 j,之后可能还有一些在 j 之后执行的作业。记住,我们可以选择 ij,使得 i 的索引大于 j,尽管 i 被安排得更早。

然后我们执行这次交换。ij 之前的部分与之前相同,ji 之后的部分也与之前相同,但我们将让 ij 以相反的顺序出现。

接下来我们必须理解的关键是:这次交换的后果是什么?代价是什么?好处是什么?这将是下一个视频的开始。

总结

在本节课中,我们开始学*如何证明最小化加权完成时间之和的贪心算法的正确性。我们概述了使用反证法和交换论证的证明计划。我们引入了符号约定,并推导出一个关键观察:任何不同于贪心顺序的最优调度中,必然存在一对连续执行的“逆序”作业(即索引较大的作业先于索引较小的作业执行)。基于此,我们提出了通过交换这对作业的顺序来构造更优解的思想。在下一节中,我们将详细分析这次交换对目标函数值的影响,从而完成证明。

005:正确性证明 - 第二部分

在本节课中,我们将继续学*如何证明最小化加权完成时间总和的贪心算法的正确性。我们将深入分析上一节末尾提出的交换两个作业所带来的影响,并最终完成整个证明。

上一节我们介绍了证明的核心思路:通过反证法,假设存在一个与贪心算法结果不同的最优调度方案,并从中找到一对违反贪心排序规则的连续作业。本节中,我们来看看交换这对作业会对所有作业的完成时间产生何种具体影响。

交换作业对完成时间的影响

以下是交换作业 ij 后,对其他作业完成时间的影响分析:

  • 作业 ij 之外的作业:它们的完成时间不受影响。因为无论 ij 的顺序如何,排在它们之前或之后的作业集合没有变化,所以这些作业需要等待的总处理时间保持不变。
  • 作业 i:它的完成时间增加。在交换前,ij 之前完成;交换后,i 需要等待 j 完成才能开始。具体来说,其完成时间的增加量恰好等于作业 j 的长度 l_j
  • 作业 j:它的完成时间减少。在交换前,j 需要等待 i 完成;交换后,j 不再需要等待 i。具体来说,其完成时间的减少量恰好等于作业 i 的长度 l_i

成本效益分析

基于以上分析,我们可以对这次交换进行成本效益核算。

  • 成本:由作业 i 的完成时间增加引起。成本值为作业 i 的权重 w_i 乘以它增加的时间 l_j

    成本公式: cost = w_i * l_j

  • 收益:由作业 j 的完成时间减少引起。收益值为作业 j 的权重 w_j 乘以它减少的时间 l_i

    收益公式: benefit = w_j * l_i

利用贪心排序规则得出矛盾

现在,我们利用一个关键事实:在假设的最优调度方案 Sigma* 中,作业 i 的索引高于作业 j(即 i > j)。根据我们的索引规则(比值 w/l 越高,索引越小),这意味着作业 i 的比值严格低于作业 j 的比值。

用不等式表示为:

w_i / l_i < w_j / l_j

为了更清晰地比较成本和收益,我们将不等式两边同时乘以 l_i * l_j 以消去分母:

w_i * l_j < w_j * l_i

观察这个不等式,它的左边正是我们计算出的交换成本 (w_i * l_j),而右边正是计算出的交换收益 (w_j * l_i)。

不等式 w_i * l_j < w_j * l_i 表明,交换作业 ij 所带来的收益大于成本。这意味着,如果我们对假设的最优方案 Sigma* 执行这次交换,得到的新调度方案的加权完成时间总和将严格小于原 Sigma* 的总和。

但这与 Sigma* 是最优方案的假设相矛盾。因此,我们的初始假设(存在一个与贪心算法结果不同的最优调度)是错误的。贪心算法产生的调度方案确实是最优的。

总结

本节课中我们一起学*了贪心调度算法正确性证明的第二部分。我们详细分析了交换一对违反贪心规则的作业对整体目标函数的影响,并通过精确的成本效益计算,结合贪心排序规则(按 w/l 降序排列),推导出了一个矛盾,从而完成了整个证明。这证实了“按权重与长度比值降序排序”的贪心策略对于最小化加权完成时间总和问题是绝对正确的。

006:处理并列情况的调度应用

概述

在本节课中,我们将重新审视用于最小化加权完成时间总和的贪心算法。我们将给出一个更健壮、更通用的正确性证明,该证明也能处理不同工作权重与长度比值出现并列的情况。核心概念将通过公式代码进行描述。

证明计划

上一节我们介绍了在比值互不相同情况下的证明。本节中,我们将采用一个不同的、更通用的证明计划。

我们将证明,对于任意输入实例,贪心算法产生的调度方案 Sigma 至少与任何其他调度方案 Sigma* 一样好。由于 Sigma* 是任意的,这意味着贪心算法的输出是最优的。

符号与假设

我们将沿用之前的符号。贪心算法按工作的权重与长度比值 w_i / l_i 的非递增顺序(即从大到小)对工作进行排序。当比值相同时,可以任意打破平局。我们将证明,无论如何处理并列情况,算法都是正确的。

为简化表述,我们假设贪心算法的输出顺序恰好是 1, 2, 3, ..., n。这只是一个符号上的重命名,不失一般性。

关键观察

现在,固定任意一个其他调度方案 Sigma*。如果 Sigma* 与贪心调度 Sigma 完全相同,则无需证明。

如果 Sigma*Sigma 不同,那么 Sigma* 中必然存在一对连续执行的工作 (i, j),其中 j 紧接在 i 之后执行,但 i 的索引(在贪心排序中的位置)高于 j。这意味着在贪心排序中,i 排在 j 之后,因此其比值 w_i / l_i 小于或等于 j 的比值 w_j / l_j

用公式表示这个关系:

w_i / l_i <= w_j / l_j

通过交叉相乘,我们得到:

w_i * l_j <= w_j * l_i

交换操作的分析

上一节我们分析了交换一对连续工作的效果。现在,我们考虑交换 Sigma* 中的 ij

  • 收益:工作 j 的完成时间减少了 l_i,因此加权完成时间的总收益为 w_j * l_i
  • 成本:工作 i 的完成时间增加了 l_j,因此加权完成时间的总成本为 w_i * l_j

因此,交换 ij 带来的净变化为:

净变化 = 收益 - 成本 = w_j * l_i - w_i * l_j

根据我们推导出的不等式 w_i * l_j <= w_j * l_i,可以得出净变化 >= 0

这意味着,交换这样一个“相邻逆序对”不会使调度方案 Sigma* 变得更差。它可能变得更好(如果不等式是严格的),也可能保持不变(如果比值相等,即出现并列)。

构造性证明

上一节我们通过反证法得出结论。本节我们将采用一个构造性的论证。

我们注意到,交换一个相邻逆序对有两个关键性质:

  1. 它不会使目标函数(加权完成时间总和)变差(净变化 >= 0)。
  2. 它恰好将逆序对的数量减少 1(因为交换的是相邻的逆序对,不会产生新的逆序)。

以下是证明的核心步骤:

  1. 从任意竞争调度 Sigma* 开始。
  2. 检查 Sigma* 是否与贪心调度 Sigma (1, 2, ..., n) 相同。
    • 如果相同,则 Sigma* 不优于 Sigma,证明完成。
    • 如果不同,则 Sigma* 中必然存在一个相邻逆序对 (i, j)
  3. 交换这个相邻逆序对 (i, j)。根据分析,得到的新调度 Sigma*' 至少和 Sigma* 一样好,并且逆序对数量减少 1。
  4. Sigma*' 作为新的 Sigma*,重复步骤 2。

这个过程不能永远持续下去,因为初始的逆序对数量最多为 n*(n-1)/2(即所有工作完全逆序排列时)。因此,经过有限次(最多 n*(n-1)/2 次)交换后,我们必然会将原始的 Sigma* 转换成了贪心调度 Sigma

在整个转换过程中,每一步都没有使调度方案变差。因此,最终的贪心调度 Sigma 至少和最初的任意调度 Sigma* 一样好。这就证明了贪心算法的最优性。

与冒泡排序的类比

熟悉冒泡排序算法的读者可能会发现,上述证明过程本质上是在对竞争调度 Sigma* 应用冒泡排序。我们不断地交换相邻的逆序对,最终将其“排序”成贪心顺序 1, 2, ..., n,并且在此过程中目标函数值从未增加。这直观地说明了贪心顺序的最优性。

总结

本节课中,我们一起学*了如何为处理加权完成时间调度问题的贪心算法提供一个更通用的正确性证明。我们放弃了反证法,转而使用一个构造性的交换论证。我们证明了,通过反复交换竞争调度中的相邻逆序对,可以将其逐步转化为贪心调度,且每一步都不会使解的质量变差。这有力地证明了,无论工作间的权重长度比值是否存在并列,按该比值非递增顺序进行调度的贪心算法总能产生最优解。

007:-07-HUFFMAN CODES_ Introduction and Motivation

在本节课中,我们将学*贪心算法设计范式的最后一个应用:数据压缩。具体来说,我们将介绍一种用于构建特定类型前缀自由二进制码(称为霍夫曼码)的贪心算法。

我们将用这个视频来铺垫背景知识。

二进制码的定义

首先,我们来定义什么是二进制码。二进制码是一种将通用字母表中的符号以计算机能够理解的方式记录下来的方法。它本质上是一个函数,将字母表 Σ 中的每个符号映射到一个二进制字符串(即由0和1组成的序列)。

字母表 Σ 可以是任何东西。一个简单的例子是小写字母a到z,加上空格和一些标点符号,总共可能有32个符号。

如果你有32个符号需要用二进制编码,一个显而易见的方法是使用长度为5的二进制字符串,因为正好有32个不同的5位二进制串。这样,每个符号都使用相同长度的5位二进制码进行编码。这类似于ASCII码的工作原理,是一种定长编码

从定长编码到变长编码

本课程的一个核心思想是:我们何时能比显而易见的解决方案做得更好?在这个上下文中,问题是:我们何时能比定长编码做得更好?

答案是:当某些符号出现的概率远高于其他符号时。在这种情况下,我们可以通过使用变长编码,用更少的比特来编码信息。这是一个非常实用的想法,变长编码在实践中被广泛使用,例如在MP3音频文件的编码中。在MP3编码标准中,完成模数转换后,会应用霍夫曼码(即本视频将要教授的内容)来进一步压缩文件长度。压缩,特别是像霍夫曼码这样的无损压缩,是很有益的,因为它能让文件下载更快或文件体积更小。

变长编码带来的新问题

从定长编码转向变长编码会带来一个新问题。让我们用一个简单的例子来说明。

假设我们的字母表 Σ 只有四个字符:A、B、C、D。

  • 明显的定长编码是:A=00, B=01, C=10, D=11。
  • 假设我们想使用更少的比特,尝试使用变长编码。一个自然的想法是让其中几个字符只用一个比特。
    • 例如,我们不用00表示A,而只用0
    • 不用11表示D,而只用1

这似乎只用了更少的比特,应该更好。但问题是:如果有人给你一个编码后的传输序列001,原始符号序列应该是什么?

答案是:无法确定。原因是,采用变长编码后,现在存在歧义。在这个编码方案下,可能有多个原始符号序列会导致输出001

  • 序列AB(A=0, B=01)会得到001
  • 序列AA(A=0, A=0, B=1?)也会得到001

这与定长编码形成对比。对于定长编码,例如每个符号用5位编码,你只需读取5位就知道是什么符号,再读下5位,依此类推。而对于变长编码,如果没有进一步的预防措施,就不清楚一个符号在哪里结束,下一个符号在哪里开始。这是我们使用变长编码时必须确保解决的一个额外问题。

解决方案:前缀自由码

为了解决变长编码中符号边界不清晰的问题,我们将要求我们的变长编码是前缀自由的。

这意味着,当我们编码一组符号时,要确保对于原始字母表 Σ 中的任意一对符号 ij,它们对应的编码满足:任何一个编码都不是另一个编码的前缀

回顾上一节的例子,那个编码就不是前缀自由的。例如,0(A的编码)是01(B的编码)的前缀,这导致了歧义。同样,1(D的编码)是10(C的编码)的前缀,也导致了歧义。

如果编码是前缀自由的(我们稍后会详细说明),那么就没有歧义。给定一串0和1,就有唯一的方法解码并重建原始的符号序列。

前缀自由码的示例

你可能会觉得这个属性太强了。但实际上,存在许多有趣且有用的变长码满足前缀自由属性。

一个简单的例子,还是编码字母A、B、C、D:

  • 我们可以让符号A只用一个比特:0
  • 为了保持前缀自由,B、C、D的编码都必须以比特1开头(否则就会与A的编码0构成前缀关系)。
  • 我们可以将B编码为10
  • 现在,C和D的编码必须既不以0开头,也不以10开头,也就是说它们必须以11开头。
  • 我们可以将C编码为110,将D编码为111

这样我们就得到了一个变长码,编码长度在1到3位之间变化,并且它是前缀自由的。

变长编码的优势:利用符号的非均匀频率

我们可能希望使用变长编码的原因是为了利用给定字母表中符号的非均匀出现频率。

让我们通过一个具体例子来展示这类编码能带来的好处。

继续使用我们的四符号字母表:A、B、C、D。
假设我们通过应用领域的统计知识,确切知道每个符号的出现频率:

  • A是最常见的符号,假设60%的符号是A。
  • B占25%。
  • C占10%。
  • D占5%。

你如何知道这些统计数据?在某些领域,你会有丰富的专业知识(例如在基因组学中,你知道A、C、G、T的通常频率)。对于像MP3文件这样的应用,你可以在完成模数转换后,直接对中间版本的文件进行统计,计算每个符号的出现次数,从而得到精确的频率。

现在,让我们比较两种编码方案的性能:

  1. 定长码:对四个字符中的每一个都使用2位。
  2. 变长前缀自由码:如上一节所述,A=0, B=10, C=110, D=111

我们将通过计算编码一个字符所需的平均比特数来衡量这些编码的性能,平均值基于四个不同符号的频率。

  • 对于定长编码,每个符号恰好使用2位,平均也是2位/字符。
  • 对于右侧粉色的变长编码,给定这些符号频率,编码字母表 Σ 中的一个字符平均需要多少位?

正确答案是第二个:平均1.55位/字符

计算过程如下:

  • 60%的时间(遇到A)只使用1位,这是节省大量比特的关键。
  • 25%的时间(遇到B)使用2位。
  • 10%的时间(遇到C)使用3位。
  • 5%的时间(遇到D)使用3位。
  • 加权平均:0.60 * 1 + 0.25 * 2 + 0.10 * 3 + 0.05 * 3 = 0.60 + 0.50 + 0.30 + 0.15 = 1.55

算法机会与问题定义

这个例子揭示了一个很好的算法机会:给定一个字母表以及通常不均匀的符号频率,我们现在知道显而易见的定长码方案不一定是最优的。我们可以使用变长前缀自由码来改进它。

因此,我们想要解决的计算问题是:哪一个变长码是最好的?我们如何获得最优压缩?哪个变长码能给出这个字母表中符号的最小平均编码长度?

霍夫曼码就是解决这个问题的方案。我们将在下一个视频中开始构建它。

总结

本节课中,我们一起学*了数据压缩的背景知识。我们定义了二进制码,并比较了定长编码和变长编码。我们了解到变长编码在符号频率不均匀时可以更高效,但它带来了解码歧义的问题。为了解决这个问题,我们引入了前缀自由码的概念,它确保了编码的唯一可解码性。最后,我们通过一个例子展示了变长前缀自由码如何显著降低平均编码长度,从而引出了寻找最优前缀自由码(即霍夫曼码)的算法问题。

008:-08-HUFFMAN CODES_ Problem Definition

📖 概述

在本节课中,我们将学*霍夫曼编码问题的精确定义。我们将理解如何将二进制前缀码与二叉树联系起来,并最终形式化我们的优化目标。


🌳 将编码视为二叉树

上一节我们介绍了寻找最优二进制前缀码的问题。为了精确地定义这个问题,将编码视为二叉树是非常有用的。本节中,我们将建立这种联系。

任何二进制编码都可以用一棵树来表示。我们约定:指向左子节点的边标记为 0,指向右子节点的边标记为 1。树中的节点用给定字母表中的符号标记。从根节点到标记为某个符号的节点的路径上的比特序列,就对应于该符号的编码。

以下是三种编码示例及其对应的树形表示:

示例1:定长编码

对于字母表 {A, B, C, D},使用编码 {00, 01, 10, 11}。这对应一棵有四个叶子的完全二叉树。叶子从左到右依次标记为 A, B, C, D。路径比特与编码完全一致,例如,到叶子 C 的路径是 右(1) -> 左(0),编码为 10

示例2:非前缀码

考虑一个非前缀码:A=0, B=01, C=10, D=1。这对应一棵非完全的树。关键区别在于,符号 AD 被标记在了内部节点上,而不仅仅是叶子节点。这导致了歧义:比特 0 可能代表 A,也可能是 B 编码 01 的前缀。这种歧义在树中表现为符号出现在内部节点。

示例3:前缀码

考虑一个前缀码:A=0, B=10, C=110, D=111。这对应一棵所有符号都仅出现在叶子节点的树。这种结构保证了前缀自由属性:因为没有一个叶子节点是另一个叶子节点的祖先,所以没有一个编码是另一个编码的前缀。


🔍 树表示法的优势

将编码视为树有两个关键优势。

首先,前缀自由条件的检查变得非常直观。在树表示中,前缀自由条件等价于:所有符号标签必须且仅能出现在叶子节点上。如果一个符号出现在内部节点,那么它的编码就会成为其子树中其他所有编码的前缀。

其次,解码过程在树中变得一目了然。给定一个由 01 组成的比特流,解码算法如下:

  1. 从根节点开始。
  2. 读取下一个比特:
    • 如果是 0,则移动到左子节点。
    • 如果是 1,则移动到右子节点。
  3. 重复步骤2,直到到达一个叶子节点。输出该叶子节点标记的符号。
  4. 返回根节点,从步骤1开始处理下一个比特。

例如,使用我们的前缀码解码比特流 0 1 1 0 1 1 1

  • 0 -> 到达叶子 A,输出 A
  • 1 -> 1 -> 0 -> 到达叶子 C,输出 C
  • 1 -> 1 -> 1 -> 到达叶子 D,输出 D
    解码结果为 A, C, D。整个过程没有歧义。

最后,一个重要的对应关系是:符号的编码长度等于对应叶子节点在树中的深度。例如,在我们的前缀码树中,A 的深度为1(编码长度1),B 的深度为2(编码长度2),CD 的深度为3(编码长度3)。


🎯 问题的形式化定义

基于树表示法,我们现在可以给出霍夫曼编码问题的精确定义。

输入

  • 一个字母表 Σ,包含 n 个符号。
  • 每个符号 i ∈ Σ 都有一个给定的频率(或概率)pᵢ。所有频率之和为 1。

可行解

  • 一棵二叉树 T。
  • 树 T 的叶子节点与字母表 Σ 中的符号一一对应。这代表了一个二进制前缀码

目标函数

  • 我们希望最小化编码的平均长度 L(T)。
  • 平均长度 L(T) 定义为各符号编码长度的加权和,权重即为其频率。

用公式表示如下:

L(T) = Σ (pᵢ * depth_T(i))

  • i 遍历字母表 Σ 中的所有符号。
  • pᵢ 是符号 i 的频率。
  • depth_T(i) 是树 T 中标记为符号 i 的叶子节点的深度(即从根节点到该叶子节点的边数,也就是编码所需的比特数)。

问题目标
所有满足条件的二叉树 T 中,找到使平均编码长度 L(T) 最小的那一棵树。霍夫曼的贪心算法将为我们解决这个问题。


📝 总结

本节课中,我们一起学*了霍夫曼编码问题的完整定义。

  1. 我们建立了二进制前缀码二叉树之间的对应关系。
  2. 我们了解到,前缀自由属性等价于树中所有符号标签必须位于叶子节点
  3. 我们看到了如何利用树进行直观的解码
  4. 我们认识到,符号的编码长度等于其在树中的深度
  5. 最终,我们形式化了问题的输入(符号及其频率)、可行解(对应前缀码的二叉树)和优化目标(最小化加权平均深度 L(T))。

有了这个清晰的问题定义,我们就可以在接下来的课程中探讨霍夫曼的贪心算法是如何高效地构造出这棵最优树的。

009:-09-HUFFMAN CODES_ A Greedy Algorithm

概述 📚

在本节课中,我们将要学*霍夫曼编码,这是一种用于数据压缩的贪心算法。我们将了解它如何通过构建一棵最优前缀码二叉树,来最小化编码的平均长度。


问题定义与背景

上一节我们介绍了数据压缩和前缀码的基本概念。本节中我们来看看霍夫曼算法要解决的具体计算问题。

问题的输入是字母表 Σ 中每个符号 i 的频率。算法的目标是计算一个最优编码。这个编码必须满足以下条件:

  • 必须是二进制的(只使用0和1)。
  • 必须是前缀码,即任何两个字符的编码,一个都不能是另一个的前缀。这是为了确保解码时没有歧义。
  • 编码一个字符所需的平均比特数(根据输入频率加权平均)应尽可能小。

这类编码对应着二叉树。前缀码条件意味着字母表 Σ 的符号与树的叶子节点一一对应。每个符号的编码长度等于其对应叶子节点的深度。

我们可以形式化地定义平均编码长度。给定一棵合法的树 T,其平均编码长度 L(T) 为:
L(T) = Σ (频率_i * 深度_i)
其中,求和遍历字母表中的所有符号。我们的目标是找到使这个值最小的树 T。

这个任务与我们之前见过的都不同。输入只是一组数字(频率),但我们必须输出一棵完整的树。如何从一堆数字出发,以一种合理、有原则的方式构建出这样一棵树呢?


构建树的思路:自顶向下 vs. 自底向上

让我们思考一下如何从非结构化的输入构建这棵树。一个很自然但被证明是次优的想法是采用自顶向下的方法,这也可以看作是分治算法设计范式的一个实例。

分治范式通常包括将给定子问题分解为多个更小的子问题,递归求解,然后将解组合成原问题的解。由于树具有递归子结构,很自然地会考虑将这个范式应用于此问题。具体来说,我们希望依靠递归调用来构建左子树和右子树,然后将结果在一个共同的根节点下合并。

然而,如何将符号分成两组并不明确。一种想法是,为了从编码的第一个比特中获得最大收益(即最多信息),你可能希望将符号分成两组,使每组的总频率尽可能接*50%。

这种自顶向下的方法有时被称为香农-法诺编码。但霍夫曼在他的学期论文中发现,自顶向下的方法并非最佳途径。正确的方法是自底向上地构建树。这样不仅能得到最优编码,还能得到一个构建速度极快的贪心算法。


自底向上构建与合并操作

那么,自底向上是什么意思呢?我们首先从一堆节点开始,每个节点标记为字母表中的一个符号。实际上,我们是从树的叶子节点开始,然后进行连续的合并。在每一步,我们将当前的两棵子树链接在一起,使它们成为一个新内部节点的左右子树。

让我们通过一个例子来理解。假设我们要构建一棵叶子节点为 A、B、C、D 的树。

  1. 第一次合并:将叶子节点 C 和 D 作为兄弟节点链接到一个共同的祖先节点下。
  2. 第二步:将叶子节点 B 与上一步得到的子树(包含节点 C、D 及其共同祖先)合并。
  3. 最后,我们别无选择,只能将剩下的两棵子树合并,最终得到一棵完整的树。

我希望现在能直观地理解,自底向上的方法是构建具有指定叶子节点集合的系统方法。如果我们有一个包含 n 个符号的字母表,我们就从 n 个叶子节点开始。一次合并操作会引入一个新的、未标记的内部节点,并将两棵旧子树合并为一棵。这样,我们处理的子树数量就减少了一棵。

如果我们从 n 个叶子节点开始,进行 n-1 次连续的合并,一方面我们会引入 n-1 个新的未标记内部节点,另一方面我们会构建出一棵单一的树,这棵树的叶子节点与字母表符号一一对应,正如我们所愿。


贪心准则:如何选择合并对象?

现在,我不指望你对“我们应该合并什么以及为什么”有任何直觉。即使我们只想设计一个贪心算法,只想做出一个当前看起来不错的短视决策,我们该如何做呢?指导我们合并特定树对的贪心准则是什么?

我们可以用与最小生成树问题类似的方式来重新审视这个困境。当你做出不可撤销的决策时,最担心的是这个决定会在以后回过头来困扰你。你只会在算法结束时才意识到,在算法早期犯了一个可怕的错误。

就像最小生成树问题一样,我们问:什么时候可以确信包含一条边是安全的?在这里,我们必须进行合并。我们想进行连续的合并,如何知道一次合并是安全的,不会妨碍我们最终计算出最优解呢?

以下是一种看待问题的方式,它至少能为我们提供一个关于这个问题的直观猜想(证明将在下一节视频中给出)。

当我们合并两棵子树时,会产生什么影响?每次合并都会引入一个新的内部节点,将这两棵子树统一在其下。在最终的树中,这个节点将成为这两棵子树中所有叶子节点从根到叶路径上的又一个节点。

换句话说,如果你的符号所在的子树与另一棵子树合并了,你会很“沮丧”,因为你的编码中又多了一个比特。在返回最终树根的路上,你又多了一个必须经过的节点。

让我们看一个例子来更清楚地说明这一点。我们使用简单的四字母表 A、B、C、D。

  • 初始时,每个符号都是自己的叶子节点,编码长度为 0 比特。
  • 第一次合并 C 和 D:引入一个新的内部节点。结果,C 和 D 的编码长度增加了 1 比特。
  • 第二次合并 B 与包含 C 和 D 的子树:引入另一个内部节点。这给 B、C、D 的编码各增加了 1 比特。
  • 最后一次合并所有内容:每个人的编码长度又增加了 1 比特。

你会发现,一个符号的最终编码长度正好等于其所在子树所经历的合并次数。每次你的子树与另一棵子树合并,你的编码就会增加一个比特,因为在通往最终树根的路径上,你会多经过一个内部节点。

在这个例子中,符号 C 和 D 在三次迭代中都经历了合并,所以它们的编码长度是 3。符号 B 只在后两次迭代中被合并,所以编码长度是 2。

这非常有帮助,因为它将我们算法的实际操作(即合并)与我们关心的目标函数(即平均编码长度)联系了起来:合并会使参与合并的符号的编码长度增加 1


设计贪心启发式算法

这让我们可以过渡到如何设计合并的贪心启发式算法。让我们只考虑第一次迭代。我们有 n 个原始符号,必须选择两个进行合并。记住,合并的后果是,我们选择的两个符号的编码长度将增加 1 比特。

我们想要做的是,根据给定的频率,最小化平均编码长度。那么,我们最不介意让哪一对符号的编码长度增加呢?答案显然是频率最低的那对符号。因为增加它们的编码长度,对总平均长度的影响最小。

所以,贪心合并启发式算法就是:在每一步,合并当前频率最低的两个符号。这似乎是进行第一次迭代的一个非常好的想法。

接下来的问题是,我们如何递归地进行下去?让我们通过下面的小测验来思考。


递归与元符号

我们同意贪心启发式算法的第一次迭代将合并频率最低的两个符号,记作 a 和 b。问题是,接下来我们如何取得进一步进展?

一个非常好的做法是,我们能够以某种方式在更小的子问题上进行递归。那么是哪个更小的子问题呢?合并符号 a 和 b 意味着什么?在我们最终构建的树中,由于我们合并了 a 和 b,我们强制算法输出一棵 a 和 b 是兄弟节点(拥有完全相同父节点)的树。

这意味着 a 和 b 的编码除了最低位比特外,其余部分将完全相同。a 的编码是一串比特后跟一个 0,b 的编码是相同的前缀比特后跟一个 1。因此,对于我们的递归,我们可以将它们视为同一个符号

我们引入一个新的元符号,称之为 ab,它代表 a 和 b 的结合,意味着它代表了 a 或 b 中任意一个的所有出现频率。

但请记住,我们研究的计算问题的输入不仅仅是字母表,还有该字母表中每个符号的频率。那么,当我们引入这个新的元符号 ab 时,我们应该为这个元符号定义什么频率呢?

根据合并操作的语义,为了使递归有意义,我们应该将这个新元符号的频率定义为它所替代的两个符号的频率之和。因为元符号 ab 旨在代表 a 和 b 的所有出现,所以应该是它们的频率之和。


霍夫曼算法描述

我现在准备正式描述霍夫曼的贪心算法。让我先通过一个例子来描述,然后通用的代码就会不言自明。

我们使用通常的例子:字母 A、B、C、D,频率分别为 60、25、10、5。

  1. 我们从每个符号作为自己的节点开始,标注频率。
  2. 贪心启发式算法说,首先合并频率最小的两个节点,即 C(10) 和 D(5)。
  3. 合并后,我们用元符号 CD(频率 15)替换它们。
  4. 现在,我们运行贪心算法的下一次迭代,合并当前频率最小的两个节点:B(25) 和 CD(15)。
  5. 现在我们只剩下两个符号:原始符号 A(60) 和元符号 BCD(40)。
  6. 当只剩下两个节点时,这就是霍夫曼算法的基础情况。只有一种合理的方式来编码它们:一个为 0,一个为 1。递归调用返回一棵有两个叶子节点(A 和 BCD)的树。
  7. 随着递归的展开,我们实际上“撤销”合并。对于每次合并,我们对相应的元节点进行拆分,用一个内部节点和两个子节点(对应合并成该元节点的符号)来替换它。
    • 首先,拆分 BCD 节点,得到左子节点 B,右子节点 CD。
    • 然后,拆分 CD 节点,得到左子节点 C,右子节点 D。

最终,我们得到了一棵完整的霍夫曼编码树。


通用算法伪代码

根据具体例子的讨论,霍夫曼算法的通用伪代码如下:

function Huffman(字母表 Σ, 频率数组 f):
    if |Σ| == 2:
        返回一棵树,有两个叶子节点,分别标记为 Σ 中的两个符号
    else:
        令 a 和 b 为 Σ 中频率 f[a] 和 f[b] 最小的两个符号
        定义新的字母表 Σ' = Σ \ {a, b} ∪ {ab} // 移除 a, b,添加元符号 ab
        定义新频率 f'[ab] = f[a] + f[b]
        对于 Σ' 中其他符号 x,f'[x] = f[x]
        // 递归求解更小的子问题
        T' = Huffman(Σ', f')
        // 将解 T' 扩展为原问题的解
        在 T' 中找到标记为 ab 的叶子节点
        将该叶子节点替换为一个新的内部节点
        令该内部节点的左子节点为标记 a 的新叶子节点
        令该内部节点的右子节点为标记 b 的新叶子节点
        返回修改后的树 T'


总结 🎯

本节课中我们一起学*了霍夫曼编码算法。我们了解到:

  1. 该算法采用自底向上的构建方式,通过连续的合并操作来构建前缀码二叉树。
  2. 其核心的贪心选择是:在每一步,总是合并当前频率最低的两个符号(或子树)。
  3. 为了实现递归,算法引入了元符号的概念,其频率为合并符号的频率之和。
  4. 霍夫曼算法能高效地产生最优前缀码,从而最小化数据的平均编码长度。

与所有贪心算法一样,我们可能有直觉认为这是个好主意,但需要严谨的论证来确保其最优性,这将是下一节视频的主题。

010:霍夫曼编码 - 一个更复杂的示例 🧩

在本节课中,我们将通过一个更复杂的示例,详细讲解霍夫曼贪心算法的执行过程。我们将使用一个包含6个字符的字母表,并演示如何一步步构建出最优前缀码。

为了确保霍夫曼贪心算法的过程清晰明了,我们将通过一个稍大、更复杂的示例来讲解。

我们使用一个包含6个字符的字母表。这些字符分别是A、B、C、D、E、F。
我们假设给定这些字符的权重分别为3、2、6、8、2、6。
请记住,即使这些权重之和不等于1,这个问题也是定义明确的。如果你更*惯使用实际概率,可以将这六个数字除以27。

第一步:合并最小权重的符号

在霍夫曼贪心算法的第一步中,我们找到具有最小权重(即最小频率)的字母。
在这个例子中,那就是字母B和E。它们的权重都是2。
接下来,我们将这两个字母合并成一个“元字母”,这实际上相当于现在就确定B和E在最终的树中将是兄弟节点。
合并之后,我们的字母表减少到五个符号。符号B和E被合并符号B-E取代,而B-E的权重是B和E的权重之和,即4。

我们可以想象我们的树通过这些迭代慢慢成形。所以在第一步之后,我们知道B和E将成为兄弟节点,并且我们知道A、C、D和F将成为叶子节点。到目前为止,我们只知道这些。

第二步:继续合并最小权重的符号

在下一步迭代中,我们再次寻找权重最小的两个符号。
这里,权重最小的符号是A,它的权重是3。其次是合并符号B-E,它的组合权重是4。这是当前五个符号中第二小的。
所以在这一步,我们将A与B-E合并。

现在我们的字母表减少到四个符号:合并符号A-B-E,其累积权重为7;以及原始符号C、D和F,它们保持原始权重6、8和6。

就我们的树而言,我们现在已经确定符号A将作为兄弟节点B和E的“叔叔”出现。同样,C、D和F,我们只知道它们最终会出现在树的某个叶子位置。

第三步:识别并合并新的最小权重对

在第三步中,我们将再次挑选权重最小的两个符号。
在这种情况下,权重最小的两个符号是C和F,每个的权重都是6。
在我们的新字母表中,我们仍然有符号A-B-E,权重仍为7。我们仍然有符号D,权重仍为8。但现在我们有了一个新的合并符号C-F,其新权重是12。

就我们的树而言,除了我们已经知道的信息外,我们现在还确定了C和F将在最终的树中成为兄弟节点。

第四步:构建更大的子树

在第四步,我们合并权重最小的两个符号。那将是权重为7的A-B-E和权重为8的D。
这使我们只剩下两个符号:A-B-D-EC-F。现在我们知道最终树根的两个子树分别会是什么样子。

第五步:完成树的构建

现在我们只剩下两个符号,唯一能做的就是将这两个符号融合成一个。通过用一个共同的根节点将它们连接起来,将这两个子树融合成一个单一的树,这就得到了霍夫曼算法的最终输出。

生成前缀码

这个树对应什么样的前缀自由码?和往常一样,我们将所有左分支标记为零,所有右分支标记为一。
现在,和往常一样,一个字符的编码就是当你从根节点遍历到该叶子节点时看到的零和一符号序列。

以下是每个字符的编码:

  • A 编码为 000
  • B 编码为 0010
  • C 编码为 10
  • D 编码为 01
  • E 编码为 0011
  • F 编码为 11

总结

本节课中,我们一起学*了霍夫曼贪心算法在一个复杂示例上的完整应用过程。我们从六个带权重的字符开始,通过反复合并当前权重最小的两个符号,逐步构建出最优的二进制前缀码树。这个过程清晰地展示了贪心选择如何导向全局最优解,最终我们根据构建好的树为每个字符分配了唯一的二进制编码。

011:11-HUFFMAN CODES_ 正确性证明 1

在本节课中,我们将学*如何证明霍夫曼算法的正确性。这意味着我们将证明,这个贪心算法总是能计算出平均编码长度最小的前缀自由二进制码。

概述

我们将证明霍夫曼算法总能产生最优的前缀自由二进制码。证明的核心思想是使用数学归纳法,并结合交换论证来证明算法的每一步贪心选择都是合理的。

首先,让我们回顾一下平均编码长度的表达式,这在证明中会频繁用到。

平均编码长度表达式

平均编码长度可以用树 T 来表示。我们对字母表 Σ 中的所有符号 i 进行求和,每个项根据输入中给定的频率 p_i 进行加权。一个符号 i 的编码长度,恰好等于树 T 中对应叶节点的深度 depth_T(i)

因此,平均编码长度 L(T) 的公式为:

L(T) = Σ_{i ∈ Σ} p_i * depth_T(i)

证明方法

霍夫曼算法的证明过程很巧妙,它让我们有机会重温证明各种贪心算法正确性的高级主题。

我们将对字母表 Σ 的大小 n 进行归纳证明。这与我们在第一部分证明迪杰斯特拉算法正确性时有些相似,通过归纳法,我们假设算法在前几步是正确的,然后证明当前步骤也正确。在归纳步骤中,我们将使用交换论证来证明,任何最优解都可以在不使其变差的情况下,调整成与我们的解相似的形式。这就是我们论证每一次合并都是合理且正确的方法。

更精确地说,我们将对字母表的大小 n 进行归纳。为了使问题非平凡,我们假设字母表大小至少为2。

基础情况

与任何归纳证明一样,我们从基础情况开始,然后进行归纳步骤,在归纳步骤中我们可以假设归纳假设成立。

基础情况是当我们只有一个包含两个字母的字母表时。回顾霍夫曼算法,你会看到它在基础情况下做了显而易见的事情:它输出一棵树,其中一个符号用比特 0 编码,另一个符号用比特 1 编码。这是你能做到的最好情况,因为每个符号至少需要一个比特来编码,而这棵树恰好为每个符号使用了一个比特。因此,霍夫曼算法在这个平凡的特殊情况下是最优的。

归纳步骤

对于归纳步骤,我们关注字母表大小至少为3的任意问题实例。

当然,在进行归纳证明时,你真正拥有的优势是归纳假设。我们假设要证明的断言(在本例中是霍夫曼算法的正确性)对所有更小的 n 值都成立。也就是说,如果我们在任何更小的输入上调用算法(正如这个递归算法所做的那样),我们可以假设算法会返回该更小子问题的正确解。

为了理解我们如何从归纳假设(即假设我们在所有更小的输入上都是正确的)过渡到归纳步骤(即断言我们在当前输入上也是正确的),我们需要更仔细地观察原始输入(及其字母表 Σ)与更小的子问题(及其字母表 Σ',其中两个字母被融合为一个)之间的关系。我们通过递归调用,假设能正确解决这个更小的子问题。

符号与对应关系

回顾霍夫曼算法伪代码中的符号表示。

算法所做的就是取出频率最小的两个符号,我们称它们为 AB,然后用一个单一的符号 AB(一个代表 AB 存在的元符号)替换这两个符号。在之前的测验中,我们讨论过如何合理地定义这个新元符号 AB 的频率,即 AB 的频率之和。

在上一个视频中,当我们为自底向上连续合并的贪心算法建立直觉时,我们注意到,当你合并两个符号 AB 时,你实际上是在承诺最终输出的树中,符号 AB 作为兄弟节点出现,即它们拥有完全相同的父节点。

因此,在以下两种树之间存在一一对应关系:一种是叶子节点标记为 Σ' 中符号的树(即没有标记为 AB 的叶子,而是有一个标记为 AB 的叶子);另一种是原始字母表 Σ 的树,其中符号 AB 恰好是兄弟节点。

给定一棵如左图所示的树(即给定一棵树 T',其叶子根据 Σ' 标记),你可以(事实上这正是霍夫曼算法所做的)拆分带有元符号 AB 的叶子,创建一个内部节点,并赋予它两个带有标签 AB 的叶子。这样就产生了如右图形式的树。

反之,给定一棵如右图形式的树(即其叶子根据 Σ 标记,并且恰好 AB 作为该树的叶子出现),你可以通过将 AB 收缩在一起,将它们吸收到它们的父节点中,并将父节点标记为 AB,从而生成一棵 Σ' 的树。因此,你可以在这两种类型的树之间来回转换:一种是 Σ' 的任意树,另一种是 Σ 的特定类型的树(即 AB 恰好是兄弟节点的树)。

ΣAB 是兄弟节点的这组树非常重要,我们给它一个专门的符号表示。让我用 X_{AB} 表示那些叶子根据 Σ 标记,并且恰好 AB 作为兄弟节点出现的树。这将是 Σ 的一些树,但不是全部,只是那些 AB 是兄弟节点的树。

目标函数值的对应关系

这种更小子问题的解与原始问题特定形式的解之间的对应关系,有一个重要的性质:它保留了目标函数值,即保留了平均编码长度。这并不完全准确,但足够接*我们的目的。它在一个固定常数范围内保留了平均编码长度。让我通过计算来演示这一点。

考虑任意一对匹配的树 T'T。所谓匹配,我指的是 T' 是任何叶子根据 Σ' 标记的树,而 T 是你以通常方式拆分带有元标签 AB 的叶子后得到的树:你用一个内部节点和带有标签 AB 的子节点替换它。这将是相应的树 T。取任意这样一对匹配的树,让我们看看它们的平均编码长度之间的差异。

记住,一棵树的平均编码长度只是对相关字母表中的符号求和。我们这里的情况是,ΣΣ' 几乎完全相同,唯一的区别是 Σ' 有元符号 AB,而 Σ 有单独的符号 AB。此外,两棵树 TT' 也几乎完全相同,唯一的区别是 T' 有一个带有元标签 AB 的叶子,而 T 在下一层有两个对应的节点,标签分别为 AB

因此,当我们取这两个和的差值时,除了树 T 贡献的两个项(一个用于标签为 A 的叶子,一个用于标签为 B 的叶子)和树 T' 贡献的一个带负号的项(对应于标签为 AB 的叶子)之外,其他所有项都抵消了。所以,尘埃落定后,我们剩下的是:

T 中叶子 A 的项:p_A * depth_T(A)
T 中叶子 B 的类似项:p_B * depth_T(B)
以及带负号的项:p_{AB} * depth_{T'}(AB)

但我们肯定还没有完成简化。我们看到的这些频率之间以及这些深度之间存在着密切的关系。

让我们从频率开始。我们如何定义元符号 AB 的频率?回想一下我们的测验,将其定义为 AB 的频率之和是合理的。

关于深度呢?你知道,符号 AB 在树 T' 中处于某个深度,假设是深度 d。记住,T 是通过简单地拆分叶子 AB 并赋予它两个带有符号 AB 的子节点而从 T' 获得的。因此,如果元符号 ABT' 中的深度是 d,那么叶子 ABT 中的深度将是 d+1。所以深度就是之前在树 T' 中的深度加一。

这些关系将导致第二波的抵消。为了更清楚,让我们称 ABT' 中的深度为 d。所以 AB 在树 T 中的深度都是 d+1

因此,第一项变为 p_A * (d+1)
第二项变为 p_B * (d+1)
第三项变为 (p_A + p_B) * d
当尘埃再次落定时,我们剩下一个常数 p_A + p_B,即两个频率之和。

我希望你真正理解的一点是,这两个平均编码长度之间的差值只是一个常数。它不依赖于我们开始时选择哪棵树。如果我们选择一棵完全平衡的树并计算这个差值,我们得到某个常数(例如 p_A + p_B)。如果我们选择一些完全不同的、非常不平衡的树对来计算这个差值,我们仍然得到完全相同的常数 p_A + p_B

这兑现了我之前给你的承诺:我们不仅在叶子标记为 Σ' 的树与特定类型的、叶子根据 Σ 标记的树(即 AB 是兄弟节点的树)之间存在这种自然的对应关系,而且这种对应关系保留了平均编码长度(准确地说,是在一个通用常数范围内保留了它)。这对我们的目的来说已经足够了,我们马上就会看到。

总结

本节课中,我们一起学*了霍夫曼算法正确性证明的第一部分。我们定义了平均编码长度的表达式,并介绍了使用归纳法和交换论证的证明框架。我们建立了原始问题与递归子问题之间解的对应关系,并证明了这种对应关系在常数范围内保持了目标函数值。这为下一部分完成归纳证明奠定了坚实的基础。

012:正确性证明 2

概述

在本节课中,我们将完成哈夫曼算法正确性的证明。我们将回顾归纳假设,理解递归调用如何帮助我们解决原始问题,并最终通过一个关键的交换引理证明:合并频率最低的两个符号A和B是安全的,因为总存在一个最优解使得它们是兄弟节点。


回顾与设定

上一节我们通过计算,建立了原始问题与合并A、B后子问题之间的对应关系。本节中,我们来看看如何利用归纳假设,并证明一个关键性质,从而完成整个证明。

我们拥有归纳假设:当哈夫曼算法递归地处理更小的字母表 Σ‘ 及其对应频率时,它能返回一个最优树 T‘_hat,该树能最小化关于 Σ‘ 的平均编码长度。

结合上一节的计算,我们知道:在原始字母表 Σ 的所有可行解中,存在一个子集 X_AB,其中的树都满足 A 和 B 是兄弟节点。并且,这个子集与子问题 Σ‘ 的所有可行解之间存在一一对应关系,且目标函数值(平均编码长度)只相差一个常数。

因此,递归调用在最小化子问题平均编码长度的同时,实际上也在最小化原始问题在子集 X_AB 上的平均编码长度。


关键问题与引理

现在的问题是:我们只优化了子集 X_AB 中的解,这足够吗?如果全局最优解根本不在 X_AB 中,那么我们的努力就白费了。

因此,证明的关键在于以下引理:

关键引理:对于任意字母表 Σ 及其频率,总存在一个最优前缀编码树(即平均编码长度最小的树),使得频率最低的两个符号 A 和 B 是兄弟节点。

如果这个引理成立,那么全局最优解就在子集 X_AB 中。既然我们的递归调用找到了 X_AB 中的最优解,那么这个解也就是原始问题的全局最优解。


引理证明:交换论证

我们将使用交换论证来证明这个关键引理。

  1. 选取任意最优树:设 T_star 是原始问题的一个最优解(平均编码长度最小)。可能存在多个,任选其一即可。
  2. 定位最深兄弟节点:在树 T_star 中,找到最深层的一对兄弟叶子节点,记为 X 和 Y。
  3. 执行交换:通过交换叶子节点上的标签,构造一棵新树 T_hat。具体操作为:将 A 的标签与 X 的标签交换,同时将 B 的标签与 Y 的标签交换。
  4. 结果:交换后,A 和 B 成为了兄弟节点(因为它们占据了原来 X 和 Y 的位置),并且 T_hat 仍然是一棵合法的、叶子节点标记为 Σ 中符号的编码树。

为了完成证明,我们需要证明 T_hat 的平均编码长度 不大于 T_star 的平均编码长度。既然 T_star 是最优的,那么 T_hat 也必然是最优的,并且它满足 A 和 B 是兄弟节点的性质。


成本差异分析

让我们计算交换前后,平均编码长度(ABL)的变化。只有涉及符号 A, B, X, Y 的项会发生变化,其他项相互抵消。

freq(s) 为符号 s 的频率,depth_T(s) 为符号 s 在树 T 中的深度。

交换前后,ABL 的差值可以整理为:

Δ = ABL(T_star) - ABL(T_hat)
  = [freq(X) - freq(A)] * [depth_Tstar(X) - depth_Tstar(A)]
    + [freq(Y) - freq(B)] * [depth_Tstar(Y) - depth_Tstar(B)]

以下是分析每一项为何非负的原因:

  • freq(X) - freq(A) ≥ 0:因为 A 是频率最低的符号之一,所以 X 的频率不低于 A。
  • depth_Tstar(X) - depth_Tstar(A) ≥ 0:因为我们选择 X 位于最深层,所以 X 的深度不小于 A 的深度。
  • 同理,freq(Y) - freq(B) ≥ 0depth_Tstar(Y) - depth_Tstar(B) ≥ 0

因此,Δ ≥ 0。这意味着 ABL(T_star) ≥ ABL(T_hat)。由于 T_star 是最优的,T_hat 不可能更好,所以实际上 ABL(T_star) = ABL(T_hat)。因此,T_hat 也是一个最优解,并且它满足 A 和 B 是兄弟节点。

这就证明了关键引理:总存在一个最优解,其中频率最低的两个符号是兄弟节点。


算法实现与复杂度

我们已经证明了哈夫曼算法的正确性。现在来看看如何高效地实现它。

朴素实现

如果直接按照伪代码实现,我们会得到一个递归算法。在每次递归调用中,我们需要找到当前频率最低的两个符号。这需要线性扫描时间。由于总共进行 n-1 次合并(n 为符号数量),总时间复杂度为 O(n²)

使用堆优化

反复查找最小元素的操作提示我们可以使用堆(Heap)数据结构。以频率为键,将所有符号放入一个最小堆中。

以下是优化后的步骤:

  1. 将所有符号及其频率插入最小堆。
  2. 当堆中元素多于一个时:
    a. 弹出两个频率最小的元素(A 和 B)。
    b. 创建一个新的“元符号”,其频率为 freq(A) + freq(B)
    c. 将这个新元符号插入堆中,它代表一个内部节点,其子节点为 A 和 B。
  3. 堆中最后剩下的元素就是哈夫曼树的根节点。

每次堆操作(插入、删除最小元素)的时间复杂度为 O(log n)。总共进行 O(n) 次堆操作,因此总时间复杂度为 O(n log n)。这是一种非常高效且易于实现的迭代方法。

更快的实现(提示)

事实上,通过一次排序加上线性时间的处理,可以实现哈夫曼算法。基本思路是:

  1. 首先将所有符号按频率排序
  2. 然后使用两个队列进行线性时间合并:
    • 一个队列(Q1)存放已排序的原始符号。
    • 另一个队列(Q2)存放新生成的元符号(内部节点)。
    • 每次合并时,只需比较 Q1 和 Q2 的队首元素,取出频率较小的两个进行合并,并将新节点放入 Q2。

这种方法在渐进复杂度上仍然是 O(n log n)(因为排序需要 O(n log n)),但常数更小。在某些特殊情况下(例如频率可以用少量比特表示,可以使用基数排序),甚至可以突破 O(n log n) 的下限。


总结

本节课中,我们一起完成了哈夫曼算法正确性的证明:

  1. 我们利用归纳假设,将递归调用对子问题的优化,与原始问题中特定子集(A、B为兄弟的树)的优化联系起来。
  2. 通过交换论证,我们证明了关键引理:总存在一个最优编码树,其中频率最低的两个符号是兄弟节点。这保证了合并它们是一个“安全”的贪心选择。
  3. 因此,哈夫曼算法通过反复合并频率最低的符号,最终能构造出全局最优的前缀编码树。
  4. 最后,我们讨论了算法的实现,指出使用可以将时间复杂度优化到 O(n log n),并提示了利用排序和双队列可能获得更优的常数因子。

至此,哈夫曼算法——这个优雅而实用的贪心算法——的正确性得到了完整的阐述。

013:Prim最小生成树算法_MST问题定义 🌳

在本节课中,我们将学*贪心算法设计范式如何应用于一个基础的图论问题——计算最小生成树。最小生成树问题是贪心算法设计的绝佳实践场,因为几乎任何针对它设计的贪心算法似乎都能奏效。我们将介绍其中两个著名的算法,证明其正确性,并展示如何利用合适的数据结构实现它们,以达到极高的运行效率。

问题定义

上一节我们介绍了本课程的目标。本节中,我们来看看最小生成树问题的正式定义。

首先,让我们非正式地理解一下我们想要完成的任务。本质上,我们的目标是以尽可能低的成本将一系列点连接起来。在抽象问题中,这些对象可以代表非常具体的事物,例如计算机网络中的服务器;也可以代表更抽象的概念,例如将文档(如网页)建模为空间中的点,并希望以某种方式将它们连接起来。

我之所以花时间讲解最小生成树问题,主要是出于教学目的。它是一个极好的问题,可以磨练你的贪心算法设计和正确性证明技能。同时,它也为我们提供了另一个机会,来欣赏数据结构与图算法快速实现之间美妙的相互作用。当然,最小生成树问题也有实际应用,一个非常酷的应用是在聚类分析中,我将在后续视频中详细讨论。它也出现在网络领域,如果你搜索“生成树协议”,会找到相关信息。

正如开头所说,最小生成树问题的非凡之处在于,它不仅允许一个正确的贪心算法,事实上存在多个正确的贪心算法。我们将讨论其中最著名的两个,但除此之外,还有一些其他的算法。

我们将要讨论的第一个算法是Prim最小生成树算法。这个算法可以追溯到50多年前的1957年。你会发现,Prim算法与Dijkstra最短路径算法有着惊人的相似之处。因此,当你知道Dijkstra在几年后也独立发现了这个算法时,可能不会感到惊讶。但事实上,直到很久以后人们才注意到,这个完全相同的算法在25年前就已经被一位数学家Jarník首先发现了。因此,你有时会听到它被称为Jarník算法或Prim-Jarník算法。为了简洁并与该领域的一些主要教科书保持一致,我将在整个讲座中称其为Prim算法。

我们将要介绍的另一个同样著名的算法是Kruskal最小生成树算法。据我所知,这确实是Kruskal首先发现的,时间大约与Prim在50年代中期提出其算法的时间相同。

当我说这些算法“极快”时,是指它们几乎以线性时间运行,具体来说是图边数的线性时间。我们将看到,通过使用适当的数据结构,我们可以让每个算法在 O(m log n) 的时间内运行,其中 m 是图中的边数,n 是图中的顶点数。

我们将采用与加速Dijkstra算法完全相同的方式来加速Prim算法,即使用数据结构。Kruskal算法的一个很酷的地方在于,它将给我们一个机会来学*一种新的数据结构——并查集数据结构,这本身也很有趣。

为了让大家对这个惊人的运行时间有更直观的认识,我想强调,它不仅因为几乎线性而显得出色,而且计算生成树所需的时间几乎与读取输入图的时间一样多。请记住,仅读取输入图就需要线性时间 O(m)。此外,图可以有数量巨大的不同生成树,是指数级的数量。因此,这些算法在某种意义上是在“大海捞针”,它们没有时间检查所有的生成树,却能找到其中最优的那一个。

这些看似神奇的算法是如何做到的呢?为了讨论细节,让我们从下一张幻灯片开始,正式定义最小生成树问题。

输入与输出

上一节我们了解了问题的背景和意义。本节中,我们来具体看看最小生成树问题的输入和输出是什么。

在MST问题中,这是一个图问题,因此输入的主要部分是一个包含顶点和边的图。需要强调的是,对于MST问题,我们只考虑无向图。这与我们在课程第一部分讨论最短路径问题时不同,那时我们处理的是有向图。对于有向图,存在一个类似于最小生成树的问题,通常被称为“最优分支问题”,并且有快速的算法解决它,但这些算法略微超出了本课程的范围,因此我们不会涉及。我们只讨论无向图及其最小生成树。

每当讨论图问题时,都需要说明图是如何表示的,这是我们在第一部分详细讨论过的内容。如果你不记得了,我建议你回去复*关于图表示的视频。对于MST问题,我们假设图以邻接表的形式给出。这意味着我们给定一个顶点数组、一个边数组,并且有指针将顶点连接到其关联的边,也将边连接回其两个端点。

除了图本身,输入还包括每条边的成本。我们将使用符号 c_e 表示边 e 的成本。与我们对最短路径问题的讨论形成另一个对比的是,我们实际上并不关心边成本是正还是负,它们可以是任何数字。

输出应该是什么,这并不难猜,它就在问题定义中:输出应该是图的最小成本生成树。但让我们深入解释一下这具体意味着什么。

首先,我们所说的树的成本(通常是一个子图的成本,即边的子集)是什么意思?我们只是将输出树中所有边的成本求和

另一个问题是,什么叫做“跨越所有顶点”的树?让我来确切地告诉你这意味着什么。这个子图 T 应该具有两个属性:

  1. 它不能有任何,即树中不能有任何循环。
  2. 所谓“跨越所有顶点”,我的意思是这个子图是连通的。也就是说,使用 T 中的边,可以从图的任何一个顶点到达任何其他顶点。这就是“跨越所有顶点”的含义。

例如,考虑以下具有四个顶点和五条边的图。我给每条边标上了成本,在这个例子中只是1到5之间的整数。

以下是几个子图的例子:

  • 让我们从三条边 ABBDCD 开始。这个子图满足属性1和2。也就是说,它没有环,并且跨越了所有顶点。如果你从这四个顶点中的任何一个出发,你都可以仅使用红色边到达其他三个顶点。因此,这个红色子图是一个生成树。然而,它并不是最小成本生成树。
  • 存在另一个生成树,其成本更低,边成本之和更小,即边 ACABBD。这个子图也没有环,也是连通的,但边成本之和仅为7,比上一个生成树的8要小。事实上,这个粉色子图是这个图中唯一的最小生成树。
  • 存在一个具有三条边的子图,其边成本之和甚至更小,即三角形 ABBDAD。但这个浅蓝色子图,这个三角形,不是一个生成树。事实上,它在两个方面都不符合要求。它显然有一个环(循环),并且它也不连通。因此,无法仅通过浅蓝色边从顶点 C 到达其他三个顶点中的任何一个。所以它也不满足属性1。

因此,一般的MST问题是:给定一个无向图(例如这个四节点五边的图,或者在实际问题中可能是一个大得多的图),你需要快速识别出最小生成树,就像这个例子中的粉色子图那样。

简化假设

上一节我们明确了问题的输入和输出。本节中,为了便于讲解,我们将做出两个温和的简化假设。

这些假设并不重要,因为即使这些假设不成立,本讲座的所有结论仍然有效。但它们将使讲座更容易理解,让我们能够专注于要点,而不被不太相关的细节分散注意力。

以下是我们在所有关于最小生成树的讲座中将要做出的两个假设:

  1. 连通图假设:我们假设输入图 G 本身是连通的。也就是说,G 包含从任一顶点到任何其他顶点的路径。

    • 为什么做此假设? 如果这个假设不成立,那么问题甚至没有明确定义。如果图不连通,那么它的任何子图当然也不连通,因此它没有生成树,我们不清楚要做什么。
    • 那些还记得我们在第一部分中涵盖的内容(特别是图搜索)的人应该认识到,这个条件很容易在预处理步骤中检查。只需运行广度优先搜索或深度优先搜索。我们知道如何在线性时间内实现这些算法,它们会特别告诉你输入图是否连通。
    • 你可能还会想,如果图不连通,我们是否应该放弃?你可以定义一个更通用的最小生成树问题版本,称为最小生成森林,基本上你想要一个成本最小的子图,尽可能多地连接各个部分。本质上,它负责在原始图的每个连通分量内计算一个生成树。使用我将在这里展示的算法(Prim算法、Kruskal算法),它们很容易修改以解决输入图不连通的更一般问题。但再次强调,为了简单起见,让我们只关注连通图的情况,它包含了所有主要思想。
  2. 边成本互异假设:我们假设在输入图中,所有边的成本是互不相同的。

    • 从我们之前对调度算法的探讨中,你已经*惯了这种“无平局”的假设,我们将在这里做类似的事情。
    • 再次强调,这个假设并不重要,因为我们所涵盖的算法(Prim算法、Kruskal算法)即使输入中存在成本相等的边,无论平局如何打破,它们仍然是正确的。因此,这些算法在广泛意义上都是正确的。
    • 话虽如此,我实际上不会向你证明它们在存在平局的情况下也是正确的。请记住,在我们的调度应用中,在没有平局的情况下给出正确性证明要容易一些,我给出了那个证明。然后,可选地,有一个稍微复杂一些的论证来处理平局。你可以在这里做同样的事情,但我不会提供给你,我将留给热心的观众自己去研究。


本节课中我们一起学*了最小生成树问题的定义、输入输出形式以及为了简化讲解所做的两个基本假设。我们了解到,MST问题旨在为一个连通的无向图找到一个无环且连通(即跨越所有顶点)的子图,并且要求这个子图所有边的成本之和最小。我们还简要提及了即将学*的Prim和Kruskal这两个著名的贪心算法。在接下来的课程中,我们将深入探讨这些算法的具体步骤、正确性证明以及如何利用数据结构高效实现它们。

014:Prim最小生成树算法

在本节课中,我们将要学*第一个最小生成树算法——Prim算法。我们将通过一个具体例子来理解其工作原理,然后详细阐述其通用伪代码,并讨论其正确性证明的思路。

算法工作原理示例

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

在演示过程中,你会发现它与Dijkstra最短路径算法有明显的相似之处。

算法的计划是每次添加一条边来“生长”一棵树。这个过程就像霉菌生长一样,从一个“种子”顶点开始,然后在算法的每次迭代中“吸收”一个新的顶点。这与Dijkstra算法相似。在Dijkstra算法中,我们从哪个顶点开始生长是明确的,因为我们有一个给定的源顶点。但在最小生成树问题中,我们没有源顶点。不过,我们可以任意选择一个顶点作为起点,选择哪个顶点并不影响最终结果。


计划是在每次迭代中,我们添加一条边,并“跨越”一个与当前已跨越顶点相邻的新顶点。作为一个贪心算法,Prim算法将简单地选择那条允许它跨越一个新顶点的、成本最低的边。

在算法开始时,我们实际上还没有跨越任何顶点。我们把自己看作是正在从右上角的顶点开始生长。那么,我们可以通过哪些边来跨越一个相邻的顶点呢?有两条边。上面有一条成本为1的边,如果包含它,我们将能跨越左上角的顶点。或者右边有一条成本为2的边,如果包含它,我们将能跨越右下角的顶点。我们将执行贪心策略,选择成本更低的边,即成本为1的边。

到目前为止,我们的树所跨越的顶点是顶部的两个顶点。

在下一个迭代中,我们希望再添加一条边来跨越一个新的顶点。现在,我们看到从当前已跨越的区域“伸出来”三条边,它们都能让我们跨越一个新的顶点。这些边的成本分别是2、3和4。选择成本为2或3的边将允许我们跨越右下角的顶点。选择成本为4的边将允许我们跨越左下角的顶点。我们将执行贪心策略。在这三条候选边中,我们选择最便宜的一条,即成本为2的边。

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

在最后的迭代中,我们希望再包含一条边,以便跨越最后一个剩余的顶点,即左下角的顶点。注意,这里有一条我们从未添加的成本为3的边,但它已经被我们生长的树所包含了。我们将忽略它,因为添加这条成本为3的边不会让我们跨越更多顶点。实际上,它会创建一个我们不需要的环。所以,我们将考虑那两条能让我们跨越额外顶点的边:成本为4的边和成本为5的边。我们将执行贪心策略,选择成本为4的边。

当我们拥有了成本为1、2和4的边后,我们就得到了一棵生成树。图中没有环,并且沿着粉色边,任意两个顶点之间都存在路径,总成本为7。你可能还记得上一节的内容,这确实是这个图的最小成本生成树。


当然,这个在只有四个顶点和五条边的简单例子中正确运行的简单过程,本身并不能说明什么。你不应该立即得出结论,认为这在一般情况下是一个好算法,尽管事实确实如此。接下来,让我们正式地定义这个通用算法。

通用伪代码

对于一个通用图,从一个起点开始像霉菌一样生长,每次迭代跨越一个新顶点,并始终以贪心方式推进,直到完成,这具体意味着什么?让我们在下一页详细说明伪代码。

以下是Prim最小生成树算法的伪代码。我们从两行初始化开始。

我们将维护一个顶点集合 X。这代表我们目前已经跨越的顶点。同样,我们需要一个“种子”顶点来启动这个过程。选择哪个顶点并不重要,无论从哪里开始,最终都会得到相同的树。我们任意选择一个顶点,称之为 s,作为生长的起点。

我们维护的另一个东西当然是树 T,它初始为空。我们将在每次迭代中向其中添加一条边。

我们将在整个算法中保持一个不变式:当前存在于集合 T 中的边,跨越了当前存在于集合 X 中的顶点。

然后是我们的主 while 循环,这是算法的核心部分,它与Dijkstra算法中的循环非常相似。即,每次迭代负责选择一条穿过当前“前沿”的边,推进以包含一个新顶点,并且它同样是贪心的。选择标准将与Dijkstra算法不同,事实上更简单:我们不再看路径长度,而是直接看哪条允许我们跨越新顶点的边成本最低。

只要还有我们尚未跨越的顶点,循环就会继续。

我们所做的是,搜索那些允许我们跨越一个新顶点的边。哪些边符合条件呢?我们希望边的一个端点在集合 X 内(即我们的树已经跨越的顶点),另一个端点不在 X 内(即尚未被跨越)。如果有一条边以这种方式穿过“前沿”,一端在 X 内,一端在 X 外,这就是我们在一次迭代中将已跨越顶点数增加一的方式。

如果边 e 是所有这样穿过前沿的边中成本最低的一条(一端在 X 内,一端在 X 外),那么这就是我们将在本次迭代中添加到当前树 T 中的边。那个不在 X 中的端点,就是我们在本次迭代中要添加到 X 里的顶点。

再次强调,每次迭代的语义是:我们试图以尽可能低的成本增加被跨越的顶点数量。正是在这个意义上,Prim算法是一种贪心算法。

和通常的贪心算法一样,这看起来足够自然,但完全不清楚它是否正确,即它是否总是能计算出一棵最小生成树。事实上,仔细想想,甚至不能明显看出它一定能计算出一棵生成树(无论是否最小)。但它是正确的,让我们在下一页精确地阐述这个声明。

正确性声明与证明计划

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

在我们深入任何细节之前,让我先通过说明证明计划来结束本节视频。我们将分两部分来证明这个定理。

首先,我们将确定算法确实输出了一棵生成树(可能不是最小的,但即使是这一点也并非显而易见)。然后,我们再论证输出的生成树实际上是最小成本的。

证明的两个部分都很有趣。对于第一部分(论证算法输出了一棵生成树),我们将回顾一些关于图、割以及图中生成树的预备知识。

对于第二部分(论证最优性),我们将依赖于生成树(特别是最小生成树)的一个非常简洁的性质,称为“割性质”。

我很高兴地告诉大家,我们在这里两部分所做的工作将在以后产生进一步的成果。在证明另一个MST算法——Kruskal算法的正确性时,我们将重用这些要素。

对于那些更愿意讨论运行时间而不是正确性的人,请不要担心,你们的时间会到来的。在我们完成这个正确性证明之后,我将讨论如何快速实现Prim算法,特别是使用堆数据结构,我们将把运行时间降低到接*线性的 O(m log n) 界限。

本节课中我们一起学*了Prim最小生成树算法的工作原理、通用伪代码以及其正确性证明的基本思路。我们通过一个例子直观理解了算法如何以贪心方式逐步构建生成树,并概述了证明其总能找到最小生成树的两个关键步骤。

015:-15-_ 快速实现 1

概述

在本节课中,我们将要学*Prim算法的高效实现方法。我们已经理解了Prim算法的原理及其正确性,现在将转向实现细节和运行时间分析。我们将从分析朴素的实现开始,然后探讨如何通过使用堆数据结构,将算法速度提升至接*线性时间。


朴素实现分析

上一节我们介绍了Prim算法的正确性,本节中我们来看看它的实现效率。首先,让我们简要回顾Prim算法的伪代码。

Prim算法通过每次添加一条边来逐步生成树,每次迭代跨越一个新的顶点。它维护两个集合:X(已跨越的顶点集合)和T(已选中的边集合)。算法从一个任意顶点s和空集开始,在主循环的每次迭代中,向树中添加一条新边,并将该边跨越的新顶点加入X。当所有顶点都被跨越时,算法终止,并得到一个最小生成树。

如果我们直接按此实现算法,运行时间会是多少?

初始化步骤仅需常数时间,可以忽略。主循环的迭代次数恰好是 n-1 次,其中 n 是顶点数。每次迭代本质上是对所有边进行一次暴力搜索,寻找跨越当前割(即一端在X内,一端在V-X内)且成本最低的边。每次迭代可以在 O(m) 时间内完成,其中 m 是边数。

因此,总运行时间为 O(m * n)。这个时间复杂度已经是多项式级别,远优于检查所有可能的生成树(数量可能是指数级)。然而,我们总是可以问:能否做得更好?


使用堆进行加速

加速Prim算法的核心思想,与我们在第一部分加速Dijkstra算法时使用的思想完全相同:部署一个合适的数据结构。

Prim算法主循环中反复需要执行的操作是:在所有跨越当前割的边中找到成本最低的那条。这本质上是一个重复的最小值计算问题。而堆数据结构正是为此而生,它能高效支持重复的最小值计算。

让我们简要回顾堆的操作和运行时间:

  • 堆存储一组对象,每个对象都有一个来自全序集(如数字、边成本)的键值。
  • 堆支持的主要操作有:
    • 插入:将新对象及其键值插入堆中。
    • 提取最小值:移除并返回键值最小的对象。
    • 删除:从堆中删除任意指定对象。
  • 所有这些操作都可以在 O(log N) 时间内完成,其中 N 是堆中对象的数量。

堆在底层通常实现为完全二叉树(逻辑上),并满足堆性质:每个父节点的键值都小于其子节点的键值。这使得最小值始终位于根节点,可以快速访问。插入或删除元素后,通过“上浮”或“下沉”操作来恢复堆性质。

现在,让我们回到如何巧妙使用堆来加速Prim算法的问题上。


堆的部署策略

我们的直觉是,因为Prim算法需要重复计算最小值(最便宜的跨越边),这正好是堆的专长。那么如何使用堆呢?

第一个不错的想法是用堆来存储边,键值就是边的成本,因为我们的最小值计算最终要选出一条边。这样,当我们从堆中提取最小值时,就能直接得到一条边。这已经是一个很好的改进,使用这种方式可以将运行时间提升至 O(m log n)

然而,这里有一个小难点:Prim算法需要的不仅仅是最便宜的边,而是最便宜的跨越当前割的边。堆可能会给你一条很便宜的边,但它可能并不跨越当前割。因此,需要额外的检查来确保找到的边是跨越割的最小边。

我们将不深入探讨这种实现的细节,而是转向一种更巧妙、更实用的方法,这种方法与我们在Dijkstra算法中使用的快速实现非常相似。


更优的堆部署策略

关键点在于:我们不用堆来存储边,而是存储顶点

更详细地说,我们的计划是维护两个不变式:

  1. 堆内容不变式:堆中存储的是尚未被跨越的顶点,即 V - X 中的顶点。
  2. 键值不变式:堆中每个顶点 v 的键值,定义为所有连接 v 与集合 X 的边中成本的最小值。如果不存在这样的边,则键值定义为 +∞

通过图片可以更清楚地理解这个定义。在算法的某个快照中,我们有已跨越的顶点集合 X(左侧)和未跨越的顶点集合 V - X(右侧)。对于右侧的任意顶点 v,我们查看所有从 v 连接到左侧 X 的边,这些边跨越了当前割。顶点 v 的键值就是这些边中成本的最小值。

给定这种使用堆实现Prim算法的高级方法,我们现在需要思考几个问题:

  1. 如何初始化堆,使得在算法开始时这两个不变式得到满足?
  2. 如果不变式成立,我们如何快速且正确地模拟Prim算法主循环的每次迭代?
  3. 在算法运行过程中,如何维护这些不变式?

初始化堆

首先,我们考虑如何在预处理步骤中设置堆,以满足两个不变式。

在算法开始时,X 仅包含一个任意的起始顶点 sV - X 包含其他 n-1 个顶点。对于 s 以外的任意顶点 v,其初始键值就是连接 vs 的边中成本的最小值(如果存在),否则为 +∞

我们可以通过一次 O(m) 的边扫描来计算每个需要入堆的顶点的键值。然后,将这 n-1 个顶点插入堆中,插入操作的成本是 O(n log n)

因此,初始化总成本为 O(m + n log n)。在渐*表示法中,由于我们假设图是连通的(否则不存在生成树),边数 m 至少为 n-1,所以 m 至少与 n 同阶,甚至更大。因此,O(m + n log n) 可以简化为 O(m log n)


模拟主循环迭代

接下来,我们验证在不变式成立的前提下,如何通过堆操作来模拟Prim算法的主循环迭代。

通过构造,这一点将完美实现。我们设置堆和键值定义的方式,使得从堆中提取最小值的操作,能够忠实地模拟朴素实现中的暴力搜索。

具体来说,假设不变式成立,当我们从堆中调用 extract-min 时:

  • 它提供给我们的就是下一个应该加入 X 的顶点。
  • 同时,连接该顶点与 X 的成本最低的边,就是本次迭代中应该加入集合 T 的边。

可以这样理解:我们实际上是在用一场两轮淘汰赛来模拟朴素实现中的暴力搜索。

  1. 第一轮(本地优胜):对于割右侧(V-X)的每个顶点,它“记住”了连接它与左侧(X)的成本最低的边(即其键值)。这确定了每个顶点本地的“最佳候选边”。
  2. 第二轮(全局优胜)extract-min 操作在所有右侧顶点的本地优胜者中,找出成本最低的那一个。这条边就是跨越当前割的成本最低的边。

因此,通过一次 extract-min 操作,我们就找到了本次迭代需要添加的边和顶点。


总结

本节课中我们一起学*了Prim算法的高效实现。我们从分析朴素的 O(m * n) 实现开始,然后引入了堆数据结构来加速重复的最小值计算。我们探讨了两种使用堆的策略:存储边和存储顶点。重点介绍了一种更优的、基于存储顶点的策略,它通过维护两个关键不变式,并利用堆的 extract-min 操作,巧妙地模拟了算法的主循环,将运行时间提升至 O(m log n)。在下一节中,我们将继续探讨如何维护堆的不变式,以完成整个算法的实现。

016:快速实现(第二部分)🚀

在本节课中,我们将要学*如何高效地实现Prim算法,特别是如何通过堆数据结构来维护算法运行过程中的关键不变量。我们将重点关注在每次迭代后,如何高效地更新堆中顶点的键值,以确保算法能持续找到正确的边。


概述

上一节我们介绍了使用堆来加速Prim算法的核心思想,即维护两个关键不变量,以便通过简单的 extract-min 操作找到每次迭代中跨越切割的最小边。本节中我们来看看,在执行了 extract-min 操作后,如何修复被破坏的不变量,特别是第二个不变量(每个不在集合X中的顶点的键值,等于连接该顶点与集合X中顶点的所有边中的最小成本)。我们将通过一个具体例子来理解问题,并给出修复不变量的伪代码,最后分析算法的整体运行时间。


通过例子理解键值更新

为了理解在维护不变量(特别是第二个不变量)时出现的问题,并确保我们对堆中顶点键值的定义有共同的理解,让我们来看一个例子。

在这个例子中,图中包含六个顶点。实际上,我们已经运行了Prim算法的三次迭代。因此,六个顶点中有四个已经在集合 X 中,剩下的两个顶点 vw 尚未在 X 中,它们位于 V - X 集合里。对于五条边,我用蓝色标出了它们的成本;其他边的成本与本问题无关,因此无需考虑。

以下是需要回答的问题:

  1. 根据我们为不在 X 中的顶点定义键值的语义,当前顶点 vw 的键值应该是什么?
  2. 在我们再运行一次Prim算法的迭代后,顶点 w 的新键值应该是什么?

正确答案是第四个选项。让我们看看原因。

首先,回忆一下键值的语义:键值应该是所有一端连接该顶点,另一端跨越切割的边中,成本最小的那条边的成本。

  • 对于节点 v,有四条关联边,成本分别为1、2、4和5。成本为1的边没有跨越切割(因为它的另一端 a 也在 V - X 中),而成本为2、4和5的边跨越了切割。其中最便宜的是2,所以 v 当前的键值是2。
  • 对于节点 w,有两条关联边,成本分别为1和10。成本为1的边没有跨越切割(因为它的另一端 v 也在 V - X 中),成本为10的边跨越了切割,并且是唯一的候选边,所以它的键值是10。

问题的第三部分问:当我们再执行一次Prim算法的迭代时会发生什么?Prim算法会将具有最小键值的边从右侧(V - X)移动到左侧(X)。v 的键值是2,w 的键值是10,因此 v 将被从右侧移动到左侧。

一旦发生这种情况,我们现在有了一个新的集合 X,其中包含了第五个顶点 v。新的 X 集合包含了除顶点 w 之外的所有顶点。

关键点在于,随着我们改变了集合 X,切割的边界也改变了。因此,跨越这个新切割的边集自然也不同了。有些边不再跨越切割,有些边则新成为跨越切割的边。

  • 不再跨越切割的边是成本为4和5的边(连接 vX 中已有的顶点)。任何连接刚被移动的顶点(v)和已经在左侧(X)中的顶点的边,现在都被“吸收”到了 X 内部。
  • 另一方面,边 v-w 之前完全位于 V - X 内部,现在随着它的一个端点 v 被拉到左侧,它开始跨越切割。

我们为什么关心这个?关键在于 w 的键值现在改变了。它过去只有一条关联边跨越切割,即成本为10的边。现在有了新的切割,它有两条关联边跨越切割:成本为1的边和成本为10的边。这两条边中更便宜的是成本为1的边,这现在决定了它的键值,从10降到了1。

这个例子的启示是:一方面,设置堆来维护这两个不变量非常棒,因为一个简单的 extract-min 操作就允许我们实现Prim算法中之前的暴力搜索。但另一方面,提取操作会破坏原有状态,它打乱了我们键值的语义,我们可能需要为顶点重新计算键值。


修复不变量的伪代码

在下一张幻灯片中,我将展示一段伪代码,用于在不断演化的切割边界背景下重新计算键值。

幸运的是,在执行 extract-min 后恢复第二个不变量并不那么痛苦。原因是 extract-min 造成的破坏是局部的。

更具体地说,让我们思考哪些边现在可能跨越切割,而之前并没有。唯一改变集合成员身份的顶点是 v。因此,这些边必须是与 v 相关联的边。如果另一端点已经在 X 中,那么我们就不关心这条边了(它刚刚被吸收进 X)。但如果另一端点 w 不在 X 中,那么随着 v 被拉到左侧,现在这条边就跨越了边界,而之前并没有。

因此,我们关心的边就是那些与 v 相关联,且另一端点 w 不在 X 中的边。

我们的计划就是显而易见的那种:对于每个“危险”的顶点,即每个与 v 相关联且另一端点 w 不在 X 中的顶点,我们只需追踪到另一端点 w,然后重新计算它的键值。我们对所有相关的 w 都这样做。

必要的重新计算并不困难,基本上有两种情况。对于另一个端点 w,现在它多了一条候选边跨越切割,即那条也与刚移动的顶点 v 相关联的边 v-w。那么,要么这条新的边 v-ww 最便宜的本地候选边,要么不是。我们只需取这两个选项中较小的那个。

以下是更新键值的核心逻辑,可以用伪代码表示:

当从堆中提取出顶点 v(即将其加入集合 X)后:
    对于每一条与 v 相连的边 (v, w):
        如果 w 不在集合 X 中:
            计算边 (v, w) 的成本 c
            如果 c < w 当前的键值:
                将 w 的键值更新为 c
                (在堆中执行 decrease-key 操作)

算法概念描述与实现细节

这就完成了如何在整个基于堆的Prim算法实现中维护不变量一和二的概要描述。每次迭代中,你执行一次 extract-min,随后运行这段伪代码来恢复第二个不变量,这样你就可以为下一次迭代做好准备了。

对于那些不仅想从概念上理解这个实现,还想深入了解细节、做到尽善尽美的人来说,可能需要思考一个微妙的问题:如何实现从堆中删除一个元素?问题在于,从堆中删除通常需要给定位置,而这里我只谈到从堆中删除一个顶点,这并不完全匹配。实际上,更自然的说法是“从堆中删除位置 i 的顶点”。因此,要实现这一点,一个自然的方法是进行一些额外的簿记,以记住哪个顶点在堆中的哪个位置。对于注重细节的你们,这是值得思考的问题。但以上是对算法完整的概念性描述。


运行时间分析 🧮

现在让我们继续进行最后的运行时间分析。

第一个主张是,该算法的所有非平凡工作都通过堆操作进行。也就是说,只需计算堆操作的数量就足够了,我们知道每个堆操作都在对数时间内完成。

好的,让我们计算所有的堆操作。

  1. 初始化插入:我们在预处理步骤中进行一系列插入操作来初始化堆。这最多是 O(n) 次插入。
  2. 主循环中的提取:主 while 循环恰好有 n-1 次迭代,在每次迭代中我们恰好执行一次 extract-min。这又是 O(n) 次操作。
  3. 键值减少操作:你应该关心的是那些由需要减少不在 X 中的顶点的键值而触发的堆操作(删除和重新插入)。确实,在Prim算法的一次迭代中,将单个顶点移入 X 可能需要大量的堆操作。因此,以正确的方式(即以边为中心的方式)来计算这些操作的数量很重要。主张是:图的每条边最多只会触发一次 decrease-key 操作(即一次删除-重新插入组合)。我们甚至可以 pinpoint 发生这次删除和重新插入的时刻:它发生在第一个端点(无论是 v 还是 w)被吸入左侧集合 X 的那次迭代。这将为另一个端点触发插入-删除操作。当第二个端点被吸入左侧时,你就不关心了,因为另一个端点已经被从堆中取出,无需再维护其键值。

这意味着堆操作的数量最多是顶点数的两倍(初始插入和提取),加上边数的两倍(每条边最多触发一次 decrease-key)。我们再次利用输入图是连通的这一事实,因此边的数量在渐进意义上至少是顶点数量。所以我们可以说,堆操作的数量最多是边数 m 的一个常数倍。

正如我们所讨论的,每个堆操作的运行时间与堆中对象数量的对数成正比,在这种情况下是 O(log n)。因此,我们得到的总运行时间是 O(m log n)

对于这个相当不简单的最小生成树问题来说,这是一个非常令人印象深刻的运行时间。当然,如果我们能去掉这个对数因子,达到线性时间,那就更好了。但我们对这个运行时间应该感到相当满意,它只比读取输入所需的时间慢一个对数因子。这与我们进行排序所得到的运行时间类型相同。实际上,这使得最小生成树问题成为了一个“几乎免费”的原语。如果你有一个图,它能放入计算机的主内存,这个算法是如此之快,以至于你可能都不知道为什么还要关心图的最小生成树——为什么不直接计算呢?它基本上是零成本的。这就是该算法的速度。


总结

本节课中我们一起学*了Prim算法高效实现的关键部分。我们首先通过一个具体例子,理解了在每次从堆中提取最小键值顶点后,其他顶点键值可能需要更新的原因。接着,我们给出了修复第二个不变量的核心逻辑和伪代码,其核心思想是:当顶点 v 加入集合 X 后,只需检查所有与 v 相连且另一端不在 X 中的顶点 w,并用边 v-w 的成本去尝试更新 w 的键值。最后,我们分析了算法的整体运行时间为 O(m log n),这是一个非常高效的复杂度,使得求解最小生成树问题在实际应用中变得非常快速。

017:-17-_ 正确性证明 1

在本节中,我们将开始讨论为什么Prim算法是正确的,即为什么它总能对每个连通图输出该图的最小生成树。本节我们将设定一个更适中的目标:仅证明Prim算法输出的是一个生成树,暂时不讨论其最优性。即使只是证明这一点也并非易事,并且通过这个过程,我们将有机会深入了解图的一些基本性质,特别是图的割。

概述:图割的概念

本课程第一部分的毕业生当然已经熟悉图割的概念。我们曾通过Karger的随机算法详细研究过如何计算图的最小割。这里的割概念是相同的。让我们回顾一下以唤醒记忆。

一个图的割,简单来说,就是将其顶点集划分为两个非空子集。形象地说,我们设想图G的一些顶点在一个子集A中,其余顶点在另一个子集B中。

那么边是如何分布的呢?一条边的两个端点有三种情况:

  • 两个端点都在集合A中,即A内部的边。
  • 两个端点都在集合B中。
  • 我们最感兴趣的是第三种情况:边的两个端点恰好一个在A中,一个在B中。我们说这样的边横跨割(A, B)。

割的定义看似简单,但割,特别是它们与边的关系,可能非常有趣且有用。如图所示,对于一个给定的割,可能有多条边横跨它。同样,对于图的一条边,通常会有多个割被这条边横跨。

为了更好地理解这一点,让我们回顾一个关于图中割的简单性质。

图中有多少个割?

具体来说,对于一个有n个顶点的图,大约有多少个割?大约是n,n²,2ⁿ,还是nⁿ?这四个答案都不完全精确,但其中有一个比其他三个更接*精确表达式。

正确答案是第三个:2ⁿ。一个n个顶点的图本质上拥有2ⁿ个割。所以割的数量是指数级的,非常多。

为什么是这样?实际上,你可以想象为每个顶点做一个二元决策:它要么进入A,要么进入B。n个二元决策导致2ⁿ种不同的结果。为什么说这“稍微”不正确?因为严格来说,一个割要求两个集合都非空,A和B都不能为空。这排除了两种可能性(所有顶点都在A或都在B)。因此,严格来说,图的不同割的数量是 2ⁿ - 2

接下来,我们将陈述并证明关于图中割的三个简单事实。一旦有了这三个事实,我们就能证明本节开头的论断,即Prim算法总是输出一个生成树。

三个关键引理

1. 空割引理

空割引理旨在为我们提供一种新的方式来描述图何时是连通的。具体来说,我将用它来描述图何时是不连通的。

论断是:一个图是不连通的,当且仅当我们能找到该图的一个割,且没有边横跨这个割。

回忆一下我们如何定义图是连通的:这意味着对于图中的任意两个顶点,我们都能在图中找到一条从一个顶点到另一个顶点的路径。因此,我们说“不连通”(即存在一对顶点之间没有路径)等价于“存在一个没有横跨边的割”。

让我们快速证明一下。作为一个“当且仅当”陈述,证明需要分两部分进行。

第一部分(从右向左推):假设存在一个割(A, B),且没有G的边横跨这个割。目标是找出一对顶点,它们之间没有路径,从而证明图不连通。找出这样一对顶点很容易:只需从割的两边各取一个顶点,记为u(在A中)和v(在B中)。为什么在G中从u到v没有路径?因为从u到v的任何路径都必须横跨割(A, B),但没有可用的边来横跨这个割,因此这条路径不可能存在。

第二部分(从左向右推):假设图不连通,则必然存在一对顶点u和v,它们之间没有路径。我们现在需要展示某个割(A, B),使得图G没有边横跨它。技巧在于定义集合A为在G中从u可达的所有顶点。另一种理解是,A就是我们在课程第一部分讨论过的u所在的连通分量。然后,我们将所有不在A中的顶点放入集合B。根据定义,u在A中,而根据假设v从u不可达,所以v在B中。因此,两个集合都非空,这确实构成了图G的一个合法割。剩下的就是注意到没有边横跨这个割。为什么?如果存在一条边横跨割(A, B),一端在A,一端在B,那么根据定义,存在从u到A中所有其他顶点的路径。如果有一条边从A伸出,那将给我们一条通往B中某个顶点的路径。但B中的顶点根据定义是从A不可达的,这就产生了矛盾。因此,没有边横跨这个割,该割是“空”的。

空割引理的关键点在于:它为我们提供了一种新的方式来讨论图是否连通。图是不连通的当且仅当存在空割;图是连通的当且仅当没有空割。

2. 双横跨引理

本质上,双横跨引理说的是:如果一个图中的环横跨一个割,那么它必须横跨该割两次。它不能只横跨一次。

具体来说,考虑图的一个割(A, B)。假设有一条边e,其端点分别在A和B中。进一步假设这条边e属于某个环C。观察图片你会发现,这个引理的论断是显而易见的:因为环必须绕回自身,如果它有一条边连接割的两侧,就必须有一条路径连接这两个端点回到彼此,而这条路径必须再次横跨割(A, B)。

实际上,双横跨引理是一个更强论断的特殊情况,这个更强论断同样容易理解:对于图的任何割和任何环(环起点和终点相同),它必须横跨这个割偶数次。它可能横跨0次,但不会只横跨1次。它可能横跨2次、4次(如果来回穿梭)、6次等等。但如果它横跨的次数严格大于0,则至少必须横跨2次。这就是双横跨引理的要点。

3. 孤独割推论

孤独割推论是双横跨引理的一个简单推论。让我说明这个推论的意义:在这些生成树算法中,为了确保输出一个生成树,我们必须特别确保不创建任何环。这个推论就是一个论证我们不会创建环的工具。

我们如何确保一条边不会创建环呢?这里有一个方法:假设存在一个割(A, B),使得边e是唯一横跨这个割的边。也就是说,e在这个割上是“孤独”的。那么,根据双横跨引理,这条边不可能在任何环中。如果它在某个环中并且横跨了一个割,那个环就必须再次横跨该割,那么边e就不会是孤独的,它会有同伴。因此,如果你在一个割上是孤独的,就意味着你不可能在环中。

证明Prim算法输出生成树

现在,我们已经准备好了所有工具,可以证明Prim算法正确性的第一部分了:即论证Prim算法在给定连通图时,总能输出一个生成树(暂时不讨论最优性,这将在下一节讨论)。

我们将分三步进行论证。第一步,你可能需要回顾一下Prim算法的伪代码以记起相关符号。

第一步:验证算法语义
算法在其运行过程中维护两个集合:一个是集合X(意图是已覆盖的顶点),另一个是边集T(已选择的边)。意图是当前的边集T总是覆盖当前的顶点集X。第一步就是验证这确实是事实。这里不进行形式化证明,因为这通常被认为是显而易见的。如果你想要严谨的证明,可以自行补充细节,这是一个直接的归纳法,没有复杂的意外。

第二步:证明算法输出覆盖所有顶点
我们试图论证算法的输出是一个生成树。让我们回顾一下这意味着什么。需要检查两个属性:首先,不能有任何环;其次,它必须覆盖所有顶点,即必须存在一条仅使用树边T的路径连接任意两个顶点。让我们按相反顺序证明这两点。

第二步将论证算法输出的东西确实覆盖了所有顶点。根据证明的第一部分,我们只需要证明算法终止时,X等于V(所有顶点)。那么我们就知道T覆盖了V中的所有顶点。这怎么会不发生呢?回顾伪代码,查看主while循环。每个while循环迭代,我们都向X添加一个新顶点。可能出什么问题?唯一可能出错的是,在我们覆盖所有顶点之前的某个迭代中,当我们扫描围绕X的边界时,找不到边。这是我们未能在给定迭代中增加X中顶点的唯一方式。但这意味着什么?如果在某个迭代中,我们找不到一个端点在X内、另一个端点在V-X中的边,那么我们就展示了一个空割:割(X, V-X)将没有横跨边。现在我们可以使用空割引理,该引理说如果存在空割,则图是不连通的。但根据假设,我们处理的是连通的输入图,所以这不可能发生。因此,算法永远不会卡住,我们总是能因为原图是连通的而将X增加一个顶点。这意味着算法终止时,T覆盖了所有顶点。

第三步:证明算法从不创建环
我们需要论证Prim算法在其选择的边集T中从不创建任何环。我们将依次讨论Prim算法添加的每条边,并论证每当添加一条新边时,这条边不可能在集合T中创建任何环。

为了理解原因,让我们在某个给定迭代时对算法进行快照。此时有一个当前的边集T和一个顶点集X(T中的边覆盖了X)。V-X是尚未被T覆盖的顶点。当然,我们可以将(X, V-X)视为图的一个割。在此时刻,T中的所有边都属于一种类型:它们的两个端点都在X内部。根据构造,它们都没有任何端点在V-X中。因此,到目前为止选择的边都没有横跨割(X, V-X)。

那么,在这个迭代中将要添加什么类型的边呢?Prim算法只搜索那些一个端点在X内、一个端点在X外的边,即只搜索横跨割(X, V-X)的边。因此,在这个迭代中添加的边将成为这个割的“开拓者”:目前还没有边横跨这个割,但在这个迭代中添加的边将肯定横跨这个割。

所以,在边e被添加到树T的那一刻,它将是横跨割(X, V-X)的唯一成员。根据孤独割推论,作为T中横跨这个割的唯一成员,它不可能参与任何环。记住,如果它参与了T中的某个环,那个环就必须在其他地方再次横跨这个割,但并没有其他边横跨这个割。e是唯一的一条。这就是为什么当我们添加一条新边时,它不可能创建任何环:它是横跨这个特定割的唯一成员。

总结

在本节中,我们一起学*了图割的基本概念及其三个关键性质:空割引理、双横跨引理和孤独割推论。利用这些工具,我们成功地证明了Prim算法的一个重要性质:对于任何连通输入图,Prim算法总能输出一个生成树。我们证明了算法输出的边集能覆盖所有顶点(利用空割引理和图的连通性),并且不包含任何环(利用孤独割推论和算法每次迭代选择的边都是特定割上的唯一横跨边这一事实)。这为下一节证明该生成树是最小生成树奠定了基础。

018:-18-_ 正确性证明 2

在本节课中,我们将学*如何证明普里姆算法的正确性。我们将借助一个称为“割性质”的关键定理,来论证普里姆算法输出的生成树确实是成本最小的生成树。


我们已经通过热身证明了普里姆算法至少会输出一个生成树。现在,让我们继续前进,实际证明它输出的是一个最小成本生成树。

为了证明这个定理,我们必须直面设计贪心算法时总会遇到的核心困境。在贪心算法中,你做出的是不可撤销的决定。例如在普里姆算法中,我们将一条边加入树中,之后不会再重新考虑它。你如何能确定自己没有犯错?如何保证当前看似短视的决定实际上是一个好决定,不会在未来带来麻烦?

对于最小生成树问题,存在一个优美的条件,它能告诉你何时可以保证将一条边加入生成树而不会后悔。这个条件保证了某条边必须属于最小生成树。它被称为“割性质”,这是我们下一张幻灯片要讨论的主题。


割性质

这是一个非常重要的性质。它陈述了什么?

考虑图中的一条边 e,我们想知道将它加入当前树中是否安全。以下是保证你不会后悔将此边加入树中的充分条件。这个条件是用“割”来表述的。

假设你能找到一个割 (A, B),满足以下性质:在图 G 中所有恰好横跨这个割的边里,边 e 是横跨此割的最便宜的边。也就是说,不仅我们的边 e 要横跨割 (A, B),它还必须是横跨此割的所有边中最便宜的那条。

如果满足这个条件,那么我们肯定希望将边 e 包含在我们的解中。事实上,边 e 必须是图 G任何最小生成树的成员。

在本视频中,我们将假设割性质成立。这绝非显而易见,它确实需要一个证明。我将在另一个单独的视频中给出证明,它基于一个巧妙的交换论证,有点技巧性。在本视频中,我们假设它为真,并想看看它能为我们带来什么。

我将很快向你们展示,割性质实际上蕴含了普里姆算法的正确性。但为了先感受一下它,让我们在一个更简单的图中看看它。

让我们看一个四元环,4个节点,4条边,边成本分别为1、2、3和4。

让我们看几个割。

首先,看一个割,其中割的一侧是右上角的顶点,另一侧是其他三个顶点。这个割有两条边横跨:成本为1的边和成本为2的边。成本为1的边是横跨此割的最便宜的边,因此根据割性质,成本为1的边必须在最小生成树中。

我们看了一个割,应用割性质,它告诉了我们一条必须放入最小生成树的边。这很酷。

让我们看另一个割。考虑一个割,其中一侧只有右下角的顶点,另一侧是其他三个顶点。这个割有两条边横跨:成本为2和成本为3的边。成本为2的边是横跨此割的最便宜的边,因此根据割性质,它必须在最小生成树中。很好,我们知道成本为2的边必须在里面。

现在,让我指出一件有趣的事情:成本为2的边并不是在它横跨的每一个割中都是最便宜的。还记得当我们看第一个割时,成本为2的边实际上是横跨那个割的最贵的边。但我们找到了另一个割,使得它是该割中最便宜的,这就足以用割性质来证明它了。换句话说,对于割性质而言,重要的是你只需要为一条边找到一个割,使得它在该割中最便宜,就足以得出结论:它必须在最小生成树中。

类似地,我们可以看第三个割,只包含左下角顶点和其他三个顶点。情况相同:有两条边横跨此割,成本为3和成本为4。成本为3的边是横跨此割的最便宜的边,所以我们知道它必须在最小生成树中。同样,当我们看第二个割时,它没有告诉我们成本为3的边是否在最小生成树中,但看第三个割时,就足以得出结论:成本为3的边必须在最小生成树中。

这样,我们就可以使用割性质来构建整个最小生成树。

另一方面,你无法使用割性质来证明成本为4的边应该被包含。对于任何你选择的、成本为4的边横跨的割,总会有其他更便宜的边也横跨它。因此,你永远无法用割性质来证明包含成本为4的边是合理的——你最好不能证明,因为4确实不在最小生成树中。

一个快速的旁注:有些人可能想知道我在割性质的结论中写了什么。我说了“G的最小生成树”,这似乎表明最小生成树是唯一的。这值得快速说明一下。首先,如果边成本不唯一,存在并列情况,那么你当然可以有多个不同的最小生成树,这时割性质的陈述需要稍作修改。但在本课程中,我们假设边成本互不相同,所以这不是问题。事实上,下一张幻灯片的结果将表明,在边成本互不相同的情况下,最小生成树是唯一的。这并不明显,但我们很快会证明它。


应用割性质证明普里姆算法

好的,为了完成这个视频,我想做的是:假设割性质为真,然后由此论证普里姆算法是正确的,总是输出一个最小生成树。割性质的证明非平凡,值得拥有自己的视频,你可以单独观看。

给定我们已经开发的工具,这个论证实际上会相当简短。

让我们假设割性质是一个真实的陈述,并从前一个视频的结论开始构建。前一个视频论证了普里姆算法输出一个生成树(没有论证它是最小的,但论证了它是一个生成树,它覆盖所有顶点且没有环)。让我们称算法结束时普里姆算法的输出为 T*

现在,凝视割性质,再凝视普里姆算法的伪代码。普里姆算法的每次迭代中发生了什么?我们有一个集合 X,那是我们目前已经覆盖的部分。其余部分是 V - X。所以 (X, V - X) 构成了一个割。普里姆算法下一步选择包含什么?它暴力搜索横跨这个割的所有边,并添加其中最便宜的一条。这正好落在割性质的适用范围内。

割性质说的是什么?它说横跨割的最便宜的边必须在最小生成树中。它们完美地结合在一起。普里姆算法在每次迭代中明确地选择了一条满足割性质假设的边,因此该边必须在最小生成树中。

记住,割性质的结论是:如此被证明合理的边必须属于最小生成树。因此,如果 T* 中的每一条边都能由割性质证明是合理的,那么 T* 中的每一条边都在最小生成树中,所以 T* 是最小生成树的一个子集。

但是,T* 本身,正如我们已经论证的,已经是一个生成树。如果你向 T* 添加更多的边,它将不再是一个生成树,因为你会产生环。如果一个图是连通的,每对顶点之间都有路径,此时添加一条新边,你就会闭合一条路径,得到一个环。所以 T* 已经是一个生成树,你不可能有比它更大的东西同时还是一个生成树。

因此,T* 必须就是那个最小成本生成树,不可能再有其他东西。

由于这个原因,T* 实际上必须是图的最小成本生成树。由于输入图是任意的(仅假设是连通的),在假设割性质成立的前提下,这就完成了对普里姆最小生成树算法正确性的证明。


总结

本节课中,我们一起学*了证明普里姆算法正确性的核心思路。我们首先引入了关键的“割性质”定理,该定理指出:对于图中的任意一个割,横跨该割的最便宜的边一定属于图的最小生成树。接着,我们观察到普里姆算法在每一步迭代中,正是从当前已访问顶点集合 X 和未访问顶点集合 V-X 构成的割中,选择最便宜的横跨边加入树中。因此,算法加入的每一条边都满足割性质的条件,从而都必须属于最小生成树。最终,由于算法输出的 T* 本身已经是一个生成树,并且它完全由这些必须属于最小生成树的边构成,所以 T* 就是唯一的最小生成树。这便完成了证明。

019:Kruskal最小生成树算法 🧩

在本节课中,我们将要学*求解最小生成树问题的第二个优秀贪心算法——Kruskal算法。我们将了解其工作原理、证明其正确性,并探讨如何高效地实现它。


算法概述

上一节我们介绍了Prim算法,本节中我们来看看Kruskal算法。Kruskal算法是解决最小生成树问题的另一个经典贪心算法,它在理论和实践中都与Prim算法竞争激烈。学*Kruskal算法有三个主要原因:首先,它是一个非常酷且重要的算法;其次,为实现其高效版本,我们将学*一个新的数据结构——并查集;最后,Kruskal算法与某些聚类算法有深刻的联系,这为我们理解聚类问题提供了新视角。

问题回顾与假设

首先,让我们简要回顾最小生成树问题。输入是一个无向图G,每条边都有一个成本。算法的任务是输出一个生成树,即一个无环且连通的子图(任意两个顶点间都有路径),并且在所有可能的生成树中,其边的总成本最小。

在本课程关于最小生成树的讲解中,我们遵循以下假设:

  1. 输入图是连通的(这是存在生成树的必要条件)。
  2. 所有边的成本互不相同(无并列情况)。请注意,Kruskal算法在成本有并列时同样正确,但为简化证明,我们在此假设成本唯一。
  3. 我们将使用“割性质”来证明算法的正确性。该性质指出:如果一条边是某个割中成本最小的跨越边,那么这条边一定属于某个最小生成树。这保证了在贪心选择中包含该边是安全的。

Kruskal算法工作原理

与Prim算法从起点“生长”出一棵树不同,Kruskal算法采用了一种不同的思路。它不要求每一步都保持子图的连通性,而是乐意并行地构建多个小树片段,只在算法最后将它们合并起来。

Kruskal算法的核心思想非常简单:按成本升序考虑所有边,如果加入当前边不会在已选择的边集中形成环,就将其加入生成树;否则,跳过该边。

让我们通过一个例子来直观理解。考虑以下具有5个顶点和7条边的图,边上蓝色数字代表成本。

以下是算法的执行步骤:

  1. 首先,选择成本最低的边(成本为1),将其加入树中。
  2. 接着,选择下一个成本最低的边(成本为2),加入树中。注意,此时已选的两条边是互不相连的。
  3. 然后,选择成本为3的边。这条边连接了之前两个互不相连的部分,使它们融合成一个连通分量。
  4. 接下来,考虑成本为4的边。如果加入这条边,它会与成本为2和3的边形成一个三角形(环),因此我们必须跳过它。
  5. 继续选择成本为5的边。加入它不会形成环,因此将其纳入。
  6. 此时,我们已经选择了4条边(n-1条,n=5),形成了一个生成树,算法可以停止。如果继续检查,成本为6和7的边也会因为会形成环而被跳过。

最终,我们得到了由粉色边构成的最小生成树。

算法伪代码

基于上述直觉,Kruskal算法的伪代码非常简洁。首先,我们需要对边按成本进行排序。

以下是算法的核心步骤:

// 预处理:将边按成本升序排序,重命名为 e1, e2, ..., em
Sort edges by cost and re-index as e1, e2, ..., em

T = ∅ // 初始化生成树为空集

for i = 1 to m do
    if (T ∪ {ei}) has no cycle then
        T = T ∪ {ei} // 加入边ei

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-algo-ilmn/img/ceed78c5da317d9fc1e651dbe9d89e06_6.png)

return T

为了简化分析,我们使用这个三行版本。在实际实现中,可以加入优化,例如当已选中n-1条边时提前终止循环。

本节总结

本节课中我们一起学*了Kruskal最小生成树算法。我们了解了它与Prim算法不同的“并行生长”哲学,即按边成本排序后依次尝试加入,并避免形成环。通过一个具体例子,我们直观地看到了算法的工作过程,并给出了其简洁的伪代码描述。

在接下来的课程中,我们将首先证明Kruskal算法的正确性,然后分析其朴素实现的时间复杂度,最后引入并查集数据结构来实现其高效版本。

020:-20-_ 通过并查集实现克鲁斯卡尔算法

在本节课中,我们将要学*如何高效地实现克鲁斯卡尔算法。我们已经理解了该算法的正确性,现在将注意力转向实现细节。我们将从一个简单但时间复杂度较高的实现开始,然后介绍一种名为“并查集”的数据结构,它能将算法的运行时间显著提升至接*线性。

算法回顾与简单实现

上一节我们介绍了克鲁斯卡尔算法的正确性,本节中我们来看看它的实现。首先,让我们简要回顾一下该算法的优雅伪代码。

克鲁斯卡尔算法是一种贪心算法,它按成本从低到高的顺序考虑所有边。算法从一个排序预处理步骤开始,为方便起见,我们将边重命名,使得 e1 是最便宜的边,em 是最昂贵的边。接着是一个简单的线性扫描循环,只要可能,我们就将边加入集合 T(最终将构成生成树)。排除一条边的唯一原因是它会与已选边构成环。只要不构成环,我们就乐观地将其加入。正如我们所见,这是一个总能输出最小成本生成树的正确算法。

如果直接实现上述伪代码,运行时间是多少呢?让我们按步骤分析。

  1. 排序边:这需要 O(m log n) 时间。这里 m 表示边数,n 表示顶点数。注意,由于图中边数 m 最多为 O(n²),因此 log mlog n 在大O表示法中可以互换使用。
  2. 主循环:共有 m 次迭代。每次迭代需要检查将当前边加入已选边集 T 是否会构成环。

检查环的关键在于:判断新边端点 uv 在当前的边集 T 中是否已经存在路径。如果存在路径,加入此边将构成环;如果不存在,则不会。我们可以使用图搜索算法(如广度优先搜索或深度优先搜索)来检查 uvT 中的连通性。由于 T 中最多有 n-1 条边,因此每次搜索的时间复杂度为 O(n)

综上所述,总运行时间为排序步骤的 O(m log n) 加上主循环的 O(m * n)。后者占主导地位,因此总体运行时间为 O(m * n)

这个运行时间与普里姆算法的简单实现相同。虽然这是一个多项式时间,比检查所有指数级数量的生成树要好得多,但我们希望做得更好,实现接*线性的运行时间。

引入并查集进行优化

我们希望能加速循环检查。并查集数据结构将允许我们以*乎常数的时间完成环检查。如果每次迭代只需常数时间,那么主循环的总时间将降至 O(m),排序步骤将成为瓶颈,整体运行时间将降至 O(m log n)

现在,让我简要介绍一下这个神奇的数据结构,以及它如何与克鲁斯卡尔算法关联。我们将在下一个视频中深入其细节。

并查集数据结构的核心思想是维护一组对象的划分(即分区)。它将对象集合划分为若干个互不相交的子集(或称“组”),这些子集的并集是整个集合。

这个数据结构主要支持两种操作:

  • 查找:给定一个对象,返回该对象所属组的名称。
  • 合并:给定两个组的名称,将这两个组合并为一个新组。

那么,为什么这个数据结构对加速克鲁斯卡尔算法有用呢?让我们看看其中的联系。

在克鲁斯卡尔算法中,我们可以这样理解其过程:算法开始时,边集 T 为空,每个顶点自成一个独立的连通分量。每次算法添加一条新边到 T,实际上是将两个当前的连通分量融合为一个。

因此,在实现中:

  • 对象:并查集中的对象对应于图的顶点。
  • :并查集中的组对应于当前已选边集 T 所定义的连通分量。

每次克鲁斯卡尔算法添加一条新边时,我们就需要调用一次合并操作,将这条边两个端点所在的连通分量(即组)融合在一起。而检查一条边是否会构成环,就等价于检查它的两个端点是否已经属于同一个组(即同一个连通分量),这可以通过查找操作快速完成。

通过使用并查集,我们可以将每次迭代中耗时的图搜索替换为高效的查找操作,从而大幅提升算法效率。

总结

本节课中我们一起学*了克鲁斯卡尔算法的实现策略。我们首先分析了一个简单直接的实现,其运行时间为 O(m * n)。然后,我们引入了并查集数据结构,它通过高效地维护顶点连通分量的信息,允许我们以*乎常数的时间完成环检查,从而将算法的整体运行时间优化至 O(m log n)。在接下来的课程中,我们将深入探讨并查集的具体实现细节。

021:-21- 通过并查集实现克鲁斯卡尔算法 2

在本节课中,我们将学*如何使用并查集数据结构高效地实现克鲁斯卡尔算法,特别是如何以*乎常数的时间复杂度检查图中是否会形成环。我们将深入探讨并查集的核心思想、实现细节以及其时间复杂度分析。

🎯 目标与基本思想

我们的目标是在克鲁斯卡尔算法中,能够以常数时间检查添加一条边是否会形成环。

并查集数据结构实现的第一也是最基本的思想是:为每个连通分量(即克鲁斯卡尔算法当前已选边构成的组件)维护一个链接结构。所谓链接结构,是指图的每个顶点都有一个额外的指针字段。

此外,对于每个连通分量,我们将指定其中一个顶点(具体是哪个无关紧要)作为该分量的领导者顶点

我们将维护一个关键的不变量:给定顶点通过其额外指针,指向其所在连通分量的领导者顶点

例如,假设有两个不同的连通分量,一个包含顶点 U、V、W,另一个包含顶点 X、Y、Z。U 可能是第一个分量的领导者,X 是第二个分量的领导者。那么根据不变量,V 和 W 应该指向 U,U 指向自身;同样,Y 和 Z 指向 X,X 指向自身。

在这个设置中,一个非常简单的做法是:每个分量实际上继承了其领导者顶点的“名字”。我们通过代表顶点(即领导者)来指代一个分量。

令人惊讶的是,即使只是对连通分量搭建这样一个简单的框架,只要不变量得到满足,就足以实现常数时间的环检查。

🔍 如何进行环检查

检查添加边 (U, V) 是否会形成环,归结为检查 U 和 V 之间是否已经存在路径。而 U 和 V 之间已经存在路径,当且仅当它们位于同一个连通分量中。

给定两个顶点 U 和 V,我们如何知道它们是否在同一个连通分量中?我们只需分别跟随它们的领导者指针,看看是否到达同一个顶点。如果它们在同一个分量中,我们会得到相同的领导者;如果在不同分量中,则得到不同的领导者。因此,检查环只需要比较 U 和 V 的领导者指针是否相等,这显然是常数时间。

更一般地说,在这种并查集数据结构中实现 Find 操作的方法是:对于给定的顶点,只需跟随其领导者指针,并返回最终到达的顶点。

只要在这个简单的数据结构中,不变量得到满足,我们就实现了常数时间环检查的理想方案。

⚠️ 维护不变量的挑战

然而,数据结构中一个反复出现的主题是:每当执行一个会改变数据结构的操作时(例如,执行 Union 操作将两个分量合并),我们必须担心不变量是否会被破坏。如果会,如何在不做过多工作的情况下恢复不变量。

在克鲁斯卡尔算法的上下文中,情况如下:当我们愉快地进行常数时间环检查时,如果一条边会形成环,我们什么也不做,跳过该边,不改变数据结构,继续前进。

问题在于,当我们遇到一条不会形成环的新边时,克鲁斯卡尔算法要求我们将这条边加入正在构建的集合 T 中,这将融合两个连通分量。但请记住,我们有不变量:每个顶点都应指向其分量的领导者。如果原来有分量 A 和分量 B,它们都指向各自的领导者顶点,那么当这两个分量融合为一个时,我们必须更新一些领导者指针。具体来说,以前有两个领导者,现在必须只有一个,我们必须重新连接领导者指针以恢复不变量。

为了确保你理解这个重要问题,请思考以下问题:考虑在克鲁斯卡尔算法的某个时刻,添加一条新边导致两个连通分量融合为一个。为了恢复不变量,你必须进行一些领导者指针更新。在最坏情况下,可能需要更新多少个领导者指针?

答案是第三个选项:可能需要更新与顶点数 n 成线性关系的指针数量。一个简单的理解方式是:想象克鲁斯卡尔算法要添加到集合 T 的最后一条边,它将最后两个连通分量融合为一个。这两个分量可能恰好大小相同,各有 n/2 个顶点。从两个领导者指针减少到一个,其中一组 n/2 个顶点将不得不继承另一组的领导者指针。因此,其中一组中的 n/2 个顶点需要更新其领导者指针。

这令人沮丧,因为我们希望时间复杂度接*线性。但如果我们的每一次线性数量的边添加都可能触发线性数量的领导者指针更新,这似乎会导致二次时间复杂度。

💡 优化思路:按大小合并

不过,请记住,我介绍并查集数据结构时只说了第一个想法是“想法一”,想必还有“想法二”。这里就是,而且非常自然。如果你自己编写并查集数据结构的实现,你很可能会自然地做这个优化。

考虑分量 A 与分量 B 合并的时刻。这两个分量目前各有自己的领导者顶点,该组中的所有顶点都指向该领导者顶点。现在,当它们融合时,你要做的第一件显而易见的事情是:我们不必费心计算一个全新的领导者,而是直接重用组 A 或组 B 的领导者。这样,例如,如果我们保留组 A 的领导者,那么需要重新连接的领导者指针只来自分量 B。分量 A 中的顶点可以保持它们原来的领导者和领导者指针不变。

这是第一点:让两个分量的新并集继承其中一个组成部分的领导者。

现在,如果你要保留两个领导者中的一个,你会保留哪一个?也许一个分量有 1000 个顶点,另一个分量只有 100 个顶点。那么,给定选择,你当然会保留更大的那个分量的领导者。这样,你只需要重新连接第二个较小分量的 100 个领导者指针。如果你保留了第二个较小分量的领导者,你将不得不重新连接第一个分量的 1000 个指针,这看起来既愚蠢又浪费。因此,实现合并的明显方法是:总是保留较大分量的领导者,并重新连接较小分量中所有顶点的指针

你应该注意到,为了实际实现这种“总是保留较大分量的领导者”的优化,你必须能够快速确定两个分量中哪个更大。但你可以通过扩充我们讨论过的数据结构来方便地做到这一点:只需为每个分量记录其中包含多少个顶点,即为每个分量维护一个 size 字段。这允许你在常数时间内检查两个不同分量的人口,并在常数时间内找出哪个更大。同时注意,当你融合两个分量时,很容易维护 size 字段,它只是两个组成部分大小之和。

🔄 重新审视最坏情况

现在让我重新审视之前幻灯片上的问题:在最坏情况下,考虑到这种优化,合并两个分量时,你可能需要重新连接多少个领导者指针(渐*意义上)?

不幸的是,答案仍然是第三个选项。原因与上一张幻灯片完全相同:仍然可能发生这样的情况,例如在克鲁斯卡尔的最后一次迭代中,你合并的两个分量大小都是 n/2。所以,无论你选择哪个领导者,你都将不得不更新 n/2 个或 θ(n) 个顶点的领导者指针。

因此,虽然这显然是一个聪明的实用优化,但在我们对运行时间的渐*分析中,它似乎并没有为我们带来任何好处。

🧠 换个角度:顶点视角的分析

然而,如果我们以下面不同的方式思考所有这些领导者指针更新所做的工作呢?

与其询问一次合并可能触发多少次更新,不如采用以顶点为中心的视角。假设你是这个图中的一个顶点。

最初,在克鲁斯卡尔算法开始时,你处于自己孤立的连通分量中,你指向自己,你是自己的领导者。然后,随着克鲁斯卡尔算法的运行,你的领导者指针会周期性地被更新。在某个时刻,你不再指向自己,而是指向某个其他顶点;然后,在某个时刻,你的指针再次被更新,指向另一个顶点,依此类推。

考虑到我们的新优化,在整个克鲁斯卡尔算法的过程中,你作为图中的一个顶点,你的领导者指针会被更新多少次?

答案非常酷,是第二个选项:对数次

虽然“总是让两个组的并集继承较大组的领导者指针”这一规则下,一次合并仍然可能触发线性数量的领导者指针更新,但每个顶点在整个克鲁斯卡尔算法过程中,只会看到其领导者指针被更新对数次

原因是什么?假设你是一个顶点,你在某个组中,该组可能有 20 个顶点。现在,假设在某个时刻,你的领导者指针被更新了。为什么会发生这种情况?这意味着你的 20 个顶点的组与某个其他更大的组合并了。请记住,只有在合并中你处于较小的组时,你的领导者指针才会被重新连接。所以你加入的组至少和你原来的组一样大。因此,你的新连通分量的大小至少是你之前的两倍。

所以结论是:每当你作为一个顶点,你的领导者指针被更新时,你所属的分量的人口至少是之前的两倍。你从一个大小为 1 的连通分量开始,一个连通分量不可能有超过 n 个顶点。因此,你可能需要承受的翻倍次数最多是 log₂ n。这就限制了你作为图中一个顶点将看到的领导者指针更新次数。

⏱️ 运行时间分析

基于这个非常酷的观察,我们现在可以对使用并查集数据结构的克鲁斯卡尔算法进行良好的运行时间分析。

当然,我们没有改变预处理步骤:我们仍然在算法开始时将边从最便宜到最昂贵进行排序,这仍然需要 O(m log n) 时间。

除了这个排序预处理步骤之外,我们还需要做什么工作?从根本上说,克鲁斯卡尔算法就是关于这些环检查。在 for 循环的每次迭代中,我们必须检查添加给定边是否会与我们已添加的边形成环。整个并查集框架(这些链接结构)的意义在于,给定一条边,我们可以通过查看其端点的领导者指针,在常数时间内检查环——当且仅当它们的领导者指针相同时,才会形成环。因此,对于环检查,在 O(m) 次迭代中,每次我们只做常数时间的工作。

但最后的工作来源是维护这个并查集数据结构:每次我们向集合 T 添加一条新边时,都要恢复不变量。这里的好主意是:我们不会仅仅限制每次迭代中这些领导者指针更新的最坏情况运行时间,因为那可能太昂贵(一次迭代就可能达到线性)。相反,我们将进行全局分析,考虑所有领导者指针更新的总次数。在上一张幻灯片中我们观察到,对于单个顶点,它只会经历对数次的领导者指针更新。因此,对于所有 n 个顶点,领导者指针更新的总工作量仅为 O(n log n)。

所以,尽管我们可能在这个 for 循环的某一次迭代中完成线性数量的指针更新,但我们在领导者指针更新的总次数上仍然有这个 O(n log n) 的全局上界,这非常酷。

回顾这个统计,我们观察到了一个惊人的事实:克鲁斯卡尔算法这个实现的瓶颈实际上是排序。我们在预处理步骤 O(m log n) 中做的工作比在整个 for 循环 O(m + n log n) 中做的还要多。因此,我们得到了 O(m log n) 的总体运行时间上界,这与我们使用堆实现的普里姆算法所达到的理论性能相匹配。

📝 总结

本节课中我们一起学*了如何通过并查集数据结构高效实现克鲁斯卡尔算法。我们首先介绍了并查集的基本思想,即为每个连通分量维护领导者指针以实现常数时间的环检查。接着,我们探讨了在合并分量时维护不变量的挑战,并引入了“按大小合并”的优化策略。通过从顶点视角进行全局分析,我们证明了每个顶点的领导者指针最多更新 O(log n) 次,从而得出算法总时间复杂度为 O(m log n),其中瓶颈在于边的排序操作。与使用堆的普里姆算法一样,克鲁斯卡尔算法在实践中也具有很强的竞争力。

022:惰性合并

在本节课程中,我们将学*并查集数据结构的一种高级实现方法,即“惰性合并”策略。我们将探讨其核心思想、实现方式以及性能分析。

首先,需要明确几点。本节内容属于高级选学材料,因此要求学*者具备更强的学*动力。讲解将保持清晰,但某些细节可能需要学*者暂停思考以加深理解。本节重点在于数据结构设计背后的思想深度和性能分析,而非其应用场景。初次接触这些内容感到困惑是正常的,这代表着一个提升理解能力的机会。

现在,让我们回顾一下并查集数据结构的基本概念。

并查集回顾 🔄

并查集用于维护一组对象的动态划分。在Kruskal最小生成树算法的快速实现中,我们曾讨论过它。

数据结构支持两个核心操作:

  • 查找:给定一个对象,返回其所在集合的名称(即代表元)。
  • 合并:给定两个对象,将它们所属的集合合并为一个。

我们之前学*过一种实现方式:为每个集合维护一个链表结构,集合中所有对象都直接指向其代表元。这种实现下,查找操作是常数时间的,但合并操作需要更新较小集合中所有对象的指针。通过“按大小合并”的优化,可以保证一系列n次合并操作的总时间复杂度为O(n log n)

在本系列视频中,我们将探讨一种不同的实现方法。

惰性合并策略 🦥

我们的新目标是:尝试在每次合并操作中,只更新一个指针。

让我们通过一个简单的例子来理解这个想法。假设有6个对象,初始分为两个集合:{1,2,3}(代表元为1)和{4,5,6}(代表元为4)。

在旧实现中,合并这两个集合(假设新代表元为4)需要更新对象1、2、3的指针,使它们都直接指向4。

新想法很简单:我们只更新其中一个集合代表元(例如1)的指针,让它指向另一个集合的代表元(4)。这样,对象2和3仍然指向1,但通过1的新指针,它们间接地以4为新的代表元。结果我们得到了一棵更深(两层)的树,而非所有节点都直接指向根节点的浅层树。

在数组表示法中,这体现为:合并前,数组parent[1,1,1,4,4,4]。旧方法合并后变为[4,4,4,4,4,4]。新方法合并后,只更新parent[1] = 4,数组变为[4,1,1,4,4,4]

一般情况下的合并操作

一般情况下,给定两个对象,它们各自属于某个集合,这些集合可以看作是以代表元为根的有向树。合并时,我们找到两个对象所在树的根节点r1r2,然后将其中一个根节点的父指针指向另一个根节点,即将一棵树安装为另一棵树的子树。

以下是合并操作的一般步骤:

  1. 对给定对象xy,分别执行查找操作,找到它们的根节点root_xroot_y
  2. 如果root_x不等于root_y,则将root_x的父指针指向root_y(或反之)。这仅涉及一次指针更新。

惰性合并的权衡

惰性合并方法的优点在于合并操作本身(在找到根节点后)非常简洁。但需要注意的是,合并操作的成本主要在于两次查找根节点的过程,而不仅仅是最后那一次指针更新。

这种方法的主要问题在于,查找操作不再显然是常数时间。因为父指针不再直接指向根节点,所以从给定对象x出发,可能需要遍历一系列父指针才能到达根节点。

由于这些权衡,惰性合并方法是否是一种好策略、能否带来收益,并不显而易见。这需要相当微妙的分析,也是后续课程的重点。此外,它还需要一些优化,下一节视频将介绍第一个优化——“按秩合并”。你可能会想,当进行合并并需要将一棵树的根安装到另一棵树下时,该如何选择哪棵树的根作为子节点?正确的答案就是“按秩合并”。

总结 📝

本节课我们一起学*了并查集数据结构的“惰性合并”高级实现策略。我们回顾了并查集的基本操作,并引入了新方法的核心思想:在合并两个集合时,只更新一个指针(将一个集合的根节点指向另一个集合的根节点),而不是更新较小集合中的所有指针。我们通过例子和数组表示法理解了这一过程,并分析了其带来的权衡——合并操作看似简单,但查找操作的成本可能增加。这引出了对性能进行深入分析以及引入进一步优化(如按秩合并)的必要性。

023:克鲁斯卡尔算法正确性证明 🧩

在本节课中,我们将学*如何证明克鲁斯卡尔最小生成树算法的正确性。我们将通过三个清晰的步骤来完成证明:首先证明算法输出的是一个生成树,然后证明该生成树是最小成本的。

概述

为了证明这个正确性定理,我们固定一个任意的连通输入图 G。我们用 T* 表示克鲁斯卡尔算法在此输入图上运行时的输出。

与普里姆算法的高层证明计划类似,我们将分三步进行。首先,我们将确立一个更基本的目标:证明克鲁斯卡尔算法输出的是一个生成树,此时暂不讨论其最优性。然后,我们将利用割性质证明它不仅是生成树,而且是最小成本生成树。本证明假设您已熟悉我们为证明普里姆算法正确性而建立的工具。

第一步:证明输出无环

生成树的一个重要性质是无环。观察克鲁斯卡尔算法的伪代码,很明显它不会产生任何环,因为任何会创建环的边都被明确排除在输出之外。

第二步:证明输出连通

不那么明显的是,只要输入图是连通的,克鲁斯卡尔算法就会输出一个连通的子图,从而构成一棵生成树。

为了论证输出是连通的,证明将分为两个子部分。首先,我们需要回顾所谓的“空割引理”。这是讨论图何时不连通(或等价地,何时连通)的一种方式。回想一下,一个图是连通的,当且仅当对于每一个割,都至少有一条边穿过该割。

因此,要证明 T* 是连通的,我们只需证明对于每一个割,T* 都至少有一条边穿过它。

论证过程

让我们从一个任意的割 (A, B) 开始。利用输入图 G 是连通的这一假设,G 肯定至少包含一条穿过此割的边。问题在于 T* 是否包含一条穿过此割的边。

论证的关键点在于:根据定义,克鲁斯卡尔算法会扫描所有边一次,即它恰好考虑原始输入图的每条边一次。现在,考虑这个至少有一条 G 的边穿过的割 (A, B),让我们快进克鲁斯卡尔算法,直到它第一次考虑一条穿过此割 (A, B) 的边。

核心主张是:算法看到的这第一条边肯定会被包含在最终输出 T* 中。

为何成立?

让我们回顾“孤独割推论”。该推论指出,如果一条边是穿过某个割的唯一一条边(即它在割中是“孤独的”),那么它就不能参与任何环。因为如果它在环中,该环会绕回来并第二次穿过这个割。

这与我们当前的场景有何关联?如果这是克鲁斯卡尔算法遇到的穿过此割的第一条边,那么到目前为止的树 T* 肯定不包含任何穿过此割的边(因为它甚至还没见过任何穿过此割的边)。因此,在遇到这第一条边的时刻,包含这条边不可能创建环,因为这条边在割 (A, B) 中是孤独的。

总结:穿过一个割的第一条边保证会被克鲁斯卡尔算法选中,因为它不会创建环。这就是为什么至少有一条克鲁斯卡尔算法的输出边穿过这个特定的割 (A, B)。由于 (A, B) 是任意的,所有割都有 T* 的某条边穿过,因此 T* 是连通的。

这就完成了证明中论证克鲁斯卡尔算法输出一个生成树的部分。现在,我们必须继续论证它实际上是一个最小成本生成树。

第三步:证明输出是最小生成树

我们将采用与证明普里姆算法相同的方式来论证这一点:我们将论证克鲁斯卡尔算法选择的每一条边都由割性质证明是合理的,即满足割性质的假设。

回想我们对普里姆算法的正确性证明,这足以论证正确性——输出是最小成本生成树。如果算法输出一个生成树,并且从未犯错,那么它必然是最小成本生成树。这对克鲁斯卡尔算法也将成立。

这个陈述对普里姆算法来说很容易证明,因为普里姆算法的定义就是基于选择某个割中最便宜的边,所以它天生适合应用割性质。但对克鲁斯卡尔算法则不然。如果你看伪代码,没有任何地方讨论过基于割来选择最便宜的边。

因此,我们必须证明克鲁斯卡尔算法在效果上,在添加每条边时,都无意中挑选了穿过某个割的最便宜边。我们实际上必须在正确性证明中展示出这个割是什么。这就是我们在这里需要做的。

论证过程

让我们在克鲁斯卡尔算法添加一条新边的某个任意迭代处“冻结”算法。我们需要证明这条边由割性质证明是合理的。假设这条边的端点是 uv,并用大写 T 表示算法到目前为止选中的边的当前集合。

让我们思考一下在克鲁斯卡尔算法的一个中间迭代中,世界的状态是什么样的。克鲁斯卡尔算法保持无环的不变性,但请记住,它并不保持当前边集形成连通集合的不变性。因此,一般来说,在克鲁斯卡尔算法的中间迭代中,你会得到许多“碎片”——许多漂浮在图中的迷你小树(可以看作是相对于当前边集 T 的连通分量),也可能有一些孤立的顶点漂浮着。

我们还能说什么?如果当前边的端点是 uv,并且克鲁斯卡尔算法决定将边 (u, v) 添加到当前集合 T 中,那么我们肯定知道 T没有 uv 之间的路径。因为如果存在路径,那么这条新边就会创建一个环,克鲁斯卡尔算法就会跳过这条边。既然它没有跳过,说明 uv 当前位于不同的“碎片”中——相对于当前边集,它们处于不同的连通分量里。

现在记住,最终如果我们要应用割性质,我们必须以某种方式从某处展示出一个割来证明这条特定边的合理性。这就是我们得到割的地方。这个论证与我们证明空割引理(用空割来刻画不连通性)时的论证非常相似。

我们将说:看,相对于我们目前拥有的树边(绿色边),从 uv 没有路径。这意味着我们可以找到一个割,使得 u 在一侧,v 在另一侧,并且没有边穿过这个割

证明的关键部分

这也是我们实际使用“克鲁斯卡尔是一个贪心算法”这一事实的部分——它按成本从低到高的顺序考虑边。请注意,到目前为止我们还没有使用这个事实,而我们最好使用它。

主张是:我们正在添加的这条边 (u, v) 不仅穿过这个割 (A, B),而且它实际上是穿过这个割的最便宜的边。原始输入图 G 中穿过此割的边不可能比它更便宜。

为何成立?

让我们回忆在论证克鲁斯卡尔算法输出连通时,我们是如何结束上一部分的。我们说过:固定任意一个你喜欢的割,冻结克鲁斯卡尔算法,在它第一次考虑某条穿过该割的边时,我们注意到克鲁斯卡尔算法保证会将这第一条穿过该割的边包含在其最终解中。这第一条被考虑的边不可能与已选边创建环,因此它不会触发克鲁斯卡尔算法中的排除标准,这条边将被选中。

那么,成为穿过一个割的第一条边有何意义?由于贪心准则——因为克鲁斯卡尔按成本从低到高考虑边——它遇到的穿过一个割的第一条边,也必然是穿过该割的最便宜的边。

整合论证,完成证明

让我们记住我们进展到哪一步。我们正专注于克鲁斯卡尔算法的一次迭代。它即将把边 (u, v) 添加到过去已选的边集 T 中。我们已经展示了一个割 (A, B),其性质是:没有先前选中的边(T 中的边)穿过这个割。边 (u, v) 将是第一条被选中并穿过此割的边。

我们刚刚论证过,克鲁斯卡尔算法保证会挑选穿过一个割的第一条边。因此,由于目前还没有任何已选边穿过割 (A, B),当前边 (u, v) 必须是克鲁斯卡尔算法所见过的、穿过此割 (A, B)第一条边。

既然它是见过的穿过此割的第一条边,那么它也必须是输入图中穿过此割的最便宜的边。这正是割性质的假设条件。这就是为什么在当前这次任意迭代中添加的边 (u, v) 是由割性质证明合理的。

总结

在本节课中,我们一起学*了克鲁斯卡尔最小生成树算法的正确性证明。

  1. 证明输出是生成树:我们首先证明了算法输出无环,然后利用“空割引理”和“孤独割推论”证明了在输入图连通的条件下,算法输出也是连通的,从而构成一棵生成树。
  2. 证明输出是最小生成树:这是证明的核心。我们通过考察算法添加任意一条边 (u, v) 的时刻,构造了一个割 (A, B),使得 uv 分居两侧,且已选边集 T 不穿过该割。由于算法按权重升序考虑边,(u, v) 是它遇到的穿过此割的第一条边,因此也是穿过此割的最轻边。这恰好满足了割性质的条件,从而证明每次添加的边都是某个最小生成树的一部分。由于算法最终得到一个生成树,且其每条边都属于某个最小生成树,故其输出就是全局的最小生成树。

这个证明巧妙地结合了图的基本性质(连通性与割)、算法的贪心策略(按权重排序)以及最小生成树的关键定理(割性质),清晰地展示了克鲁斯卡尔算法为何总能找到最优解。

024:聚类应用

概述

在本节课中,我们将学*最小生成树问题的一个重要应用:聚类问题。我们将从一个非正式的目标描述开始,逐步形式化一个具体的优化目标——间距最大化,并最终推导出一个与克鲁斯卡尔算法高度相似的贪心算法来解决它。


聚类问题的目标

到目前为止,我们已经花了相当多的时间讨论最小生成树问题。我们这样做有多个动机。首先,它是研究贪心算法的一个绝佳问题。你可以提出许多不同的贪心算法,并且非常不寻常的是,它们似乎都有效。因此,你得到了正确的贪心算法,但其正确性背后的驱动因素相当微妙。你也得到了很多关于图论和交换论证的练*。

其次,花时间在这些算法上是值得的,因为它让我们更多地练*了数据结构以及如何使用它们来加速算法。具体来说,堆用于加速普里姆算法,并查集数据结构用于加速克鲁斯卡尔算法。

我们讨论它们的第三个原因是它们本身就有应用。这就是本视频和下一个视频的主题,我们将重点关注聚类问题的应用。

让我先非正式地谈谈聚类的目标,然后在下一张幻灯片上给出一个精确的目标函数。

在聚类问题中,输入是端点,我们将其视为嵌入在空间中的点。实际上,在我们关心的底层问题中,很少是真正本质上是几何的,即本质上是空间中的点。通常,我们是将我们关心的东西(可能是网页、图像或数据库)表示为空间中的点。

给定一堆对象,我们希望将它们聚类成,在某种意义上,连贯的组。对于那些有机器学*背景的人来说,你经常会听到这个问题被称为无监督学*,意思是数据没有标签。我们只是在数据未被标注的情况下寻找数据中的模式。

这显然是一个相当模糊的问题描述,所以让我们更精确一点。我们将假设输入的一部分是我们称之为相似性度量的东西,意思是对于任意两个对象,我们将有一个函数给出一个数字,表示它们彼此之间有多相似,或者更确切地说,有多不相似。为了与几何隐喻保持一致,我们将把这个函数称为距离函数。

一个很酷的事情是,我们不需要对这个距离函数施加很多假设。我们将假设的一件事是它是对称的,即从 P 到 Q 的距离与从 Q 到 P 的距离相同。

那么有哪些例子呢?如果你想真正遵循几何隐喻,如果你将这些点表示为某个维度 M 的空间 R^M 中的点,你可以直接使用欧几里得距离,或者如果你喜欢其他范数,比如 L1 或 L∞ 范数。在许多应用领域,有广泛接受的相似性或距离度量。一个例子是序列,正如我们在介绍性讲座中讨论过的,两个基因组片段最佳比对的罚分。

现在有了这个距离函数,拥有连贯的组意味着什么?那些彼此距离小、相似的东西,通常应该在同一个组里;而那些非常不相似、彼此距离很大的东西,你期望它们大多在不同的组里。

那么,我们如何评估一个聚类的好坏,即它在将相*的点放在一起、将不相似的点放在不同组方面做得如何?老实说,有很多方法来形式化这个问题。我们将采用的方法是基于优化的方法:我们将假设一个关于聚类的目标函数,然后寻找优化该目标函数的聚类。我想警告你,这不是解决问题的唯一方法,还有其他有趣的方法,但优化是一种自然的方法。此外,就像在我们的调度应用中一样,人们研究的目标函数不止一个,而且都有很好的动机。一个非常流行的目标函数是 K 均值目标,我鼓励你查阅并了解更多。对于本讲座,我将只采用一个特定的目标函数,它足够自然,但绝不是唯一的,甚至不是主要的。不过,它将服务于研究一个与最小生成树算法相关的自然贪心算法的目的,该算法在精确意义下是最优的。

让我发展陈述目标函数和优化问题所需的术语。

聚类问题中经常出现的一个问题是,你要使用多少个簇。为了在本视频中保持简单,我们将假设输入的一部分 K 指明了你应该使用多少个簇。因此,我们将假设你知道需要多少个簇。

在某些应用领域,这是一个完全合理的假设。例如,你可能知道你想要恰好两个簇(K=2)。在某些领域,你可能根据过去的经验有很好的领域知识,知道你需要多少个簇,这很好。另外,在实践中,如果你真的不知道 K 应该是多少,你可以继续运行我们将要讨论的算法,尝试一堆不同的 K 值,然后使用某个度量标准或只是目测,找出哪个是最好的解决方案。

我们将要研究的目标函数是根据被分隔的点对来定义的,即被分配到不同簇的点。现在,如果你有多个簇,不可避免地会有一些被分隔的点对。有些点在一个组里,其他点在另一个组里。因此,被分隔的点是不可避免的,而最令人担忧的被分隔点是最相似的那些,即距离最小的点。如果点被分隔,我们希望它们相距很远。因此,我们特别关注那些被分隔的相*点。这将成为我们的目标函数值,称为聚类的间距。它是被分隔点对中距离最*的那一对的距离

现在,我们希望聚类的间距如何?我们希望所有被分隔的点都尽可能远离,因为我们希望间距大,越大越好。这自然引出了形式化的问题陈述:给定输入距离度量(即每对点之间的距离),以及期望的簇数量 K,在所有将点聚类成 K 个簇的方法中,找到使间距尽可能大的聚类。

让我们开发一个贪心算法,旨在使间距尽可能大。为了便于讨论,我将使用一个示例点集,在本幻灯片右上角只有六个黑点。

这个贪心算法背后的好主意是,不要担心最终我们只能输出 K 个不同簇的约束。实际上,在整个算法过程中,我们将是不可行的,会有太多的簇。只有在算法结束时,我们才会减少到 K 个簇,这将是我们的最终可行解。这使我们能够自由地初始化过程,从每个点都在自己簇中的退化解开始。

在我们的示例点集中,我们有这六个粉红色的孤立簇。一般来说,你将有 n 个簇,而我们必须减少到 K 个。现在让我们记住间距目标是什么:在间距目标中,你遍历所有被分隔的点对。对于这个退化解,所有点对都是被分隔的,你查看最令人担忧的被分隔对,即那些彼此最接*的点。所以间距就是被分隔点对中最*一对的距离。

在贪心算法中,你希望尽可能增加你的目标函数。但实际上,在这种情况下,事情相当简单明了。假设我给你一个聚类,你想让间距变大。你能做到这一点的唯一方法是,取当前最*的一对被分隔点,使它们不再被分隔,即将它们放在同一个簇中。因此,在某种意义上,为了增加目标函数值,你显然需要做什么:你必须查看定义目标的点对(即最*的一对被分隔点),并且必须融合它们,必须融合它们所在的簇,使它们不再被分隔。在这个例子中,在我看来,最*的一对点(当然是被分隔的)是右上角的这一对。所以,如果我们想让间距变大,那么我们将它们融合到一个共同的簇中。

我们开始时是六个簇,现在减少到五个。现在我们重新评估这个新聚类的间距。我们问,最*的一对被分隔点是什么?在我看来,这似乎是右下角的这一对。同样,我们通过合并聚类来增加间距的唯一方法是,将这两个孤立的簇合并为一个。

我们再做一次。我们说哪对点决定了当前的间距?即当前被分隔的点对中彼此最*的点。在我看来,这似乎是图片最右边的这一对。合并两个簇并使间距实际上升的唯一方法是,合并包含决定当前间距的点对的簇。所以在这种情况下,两个各有两个点的不同簇将坍缩成一个包含四个点的簇。假设我们最终想要三个簇,这正是我们目前的情况。此时,贪心算法将停止。

现在让我更一般地阐述贪心算法的伪代码,但根据到目前为止的讨论,这正是你所期望的。好的,我希望你盯着这个伪代码看10秒钟或你需要的时间,并尝试将其与我们课程中见过的算法联系起来,特别是我们最*见过的一个算法。我希望它能让你强烈地想起我们已经介绍过的一个算法。具体来说,我希望你看到这个贪心算法与计算最小成本生成树的克鲁斯卡尔算法之间有很强的相似性。

确实,我们可以认为这个贪心聚类算法与克鲁斯卡尔的最小成本生成树算法完全相同,只是提前中止了,在剩下 K 个连通分量时停止,即在最后 K-1 条边被添加之前。

为了确保对应关系清晰,图是什么?边是什么?边成本是什么?聚类问题中的对象(即点)对应于图中的顶点。聚类问题输入的另一部分是距离,它为每对点给出。所以这些扮演了最小生成树问题中边成本的角色。由于我们对每一对点都有一个距离或边成本,我们可以将聚类问题中的边集视为完全图,因为我们对每一对都有边成本或距离。

这种类型的凝聚聚类有一个名字:这种使用类似 MST 的标准一次融合一个分量的想法,被称为单链接聚类。因此,单链接聚类是一个好主意。如果你从事任何聚类问题或无监督学*问题的工作,它绝对应该是你工具箱中的一个工具。我们将在下一个视频中以一种特定的方式证明其存在价值,届时我们将展示它确实在所有可能的 K 聚类中最大化间距。

但即使你本身不关心间距目标函数,你也应该熟悉单链接聚类。它还有许多其他优良特性。

总结

本节课我们一起学*了如何将最小生成树算法应用于聚类问题。我们首先定义了聚类的目标——最大化被分隔点对之间的最小距离(即间距),然后介绍了一个贪心算法。该算法从每个点自成一簇开始,反复合并距离最*的两个簇,直到剩下 K 个簇为止。我们认识到,这个算法本质上就是提前终止的克鲁斯卡尔算法,它被称为单链接聚类,是解决此类问题的一个有效工具。

025:-25- 引言 - 路径图中的带权独立集问题 📚

在本节课中,我们将开始学*动态规划。这是一个通用的算法设计范式,正如我在课程开始时提到的,它有许多著名的应用。然而,我现在还不会直接告诉你什么特性使得一个算法成为动态规划算法。相反,我们的计划是在接下来的几个视频中,从一个非平凡的具体计算问题出发,从头开始开发一个算法。这个问题就是在路径图中寻找最大权独立集。

这个具体问题将迫使我们发展一系列新思路。一旦我们解决了这个问题,我们将退一步,指出我们解决方案中的哪些特征使其成为一个动态规划算法。然后,我们将带着开发动态规划算法的“公式”和一个具体实例,继续探讨该范式的更多、通常也更难的应用。

事实上,动态规划范式尤其需要练*才能掌握。根据我的经验,学生起初会觉得它违反直觉,并且常常难以将其应用到他们未曾见过的问题上。但好消息是,动态规划相对而言是模式化的,肯定比我们最*学*的贪心算法更模式化。通过足够的练*,你应该能掌握这个强大且广泛适用的新工具。

接下来,让我介绍我们将在未来几个视频中研究的具体问题。这是一个图论问题,但非常简单。事实上,我们只关注路径图,即仅由一条包含 n 个顶点的路径构成的图。输入的另一个部分是每个顶点的一个非负数,我们称之为权重。例如,这是一个包含四个顶点的路径图,我们给顶点赋予权重 1、4、5 和 4。

算法的任务是输出一个独立集。这意味着图顶点的一个子集,其中没有两个顶点是相邻的。因此,在简单路径图的上下文中,它意味着你必须返回一些顶点,并且总是避免连续的一对顶点。例如,在一个四个顶点的路径中,独立集的例子包括空集、任何单个顶点、顶点 1 和 3、顶点 2 和 4,以及顶点 1 和 4。你不能返回顶点 2 和 3,因为它们是相邻的,这是不允许的。

为了使问题更有趣,我们想要的不是任何一个旧的独立集,而是顶点权重之和尽可能大的那个,这就是最大权独立集问题。接下来,我将利用这个具体问题来回顾我们迄今为止见过的各种算法设计范式。在此过程中,我们将看到它们对于这个问题实际上都不太奏效,这将激励我们设计一种新方法,而这种方法最终将成为动态规划。

首先,作为我们标准的“沙袋”,考虑暴力搜索。这将需要遍历所有独立集,并记住总权重最大的那个。当然,这是正确的,但通常这需要指数时间。即使在路径图中,独立集的数量也是顶点数 n 的指数级。

我们还知道哪些其他算法设计范式呢?我们刚刚完成了一个关于贪心算法的大章节。我们当然可以考虑它。对于大多数问题,提出贪心算法很容易,这个问题也不例外。我认为,计算最大权独立集最自然的贪心算法可能是:在每个步骤中,选择你尚未选择的、权重最高的顶点。当然,你必须担心可行性。记住,我们不允许输出连续的顶点。因此,如果任何顶点因相邻而被排除,我们就忽略它;在那些保持可行性的顶点中,我们将权重最高的那个包含到当前的集合中。

让我重新绘制上一张幻灯片中的四节点路径图,并问你:这个贪心算法在这个四节点路径上会计算出什么?这与最优解(即具有最大总权重的独立集)相比如何?正确答案是第二个选项。让我们看看原因。

首先看最优解,即最大权独立集。记住,独立集禁止选择相邻或连续的顶点。因此,在这种情况下,唯一合理的考虑方案是第一个和第三个顶点、第二个和第四个顶点,或者第一个和第四个顶点。其中最好的是第二个和第四个顶点,总权重为 8。

那么贪心算法呢?它首先选择整体权重最高的顶点,即权重为 5 的顶点。不幸的是,这阻止了算法选择两个权重为 4 的顶点中的任何一个。保持可行性的唯一剩余选项是选择权重为 1 的顶点。这给了我们一个权重为 6 的独立集。

如果这个贪心算法不正确,你当然可以尝试设计其他类型的贪心算法,但我不知道有任何贪心方法能真正最优地解决这个问题。

这令人失望,但我们还没有穷尽我们的算法设计范式。还记得我们在本课程第一部分早期学到的非常强大的分治法吗?它似乎可以在这里应用。我们有许多成功的应用,其中输入是一个数组,我们将其分成两部分,递归处理两边,然后合并结果。在这里,路径图看起来与数字数组没有太大不同。因此,分治法的明显方法是:将路径分成两条路径,每条长度是原路径的一半,递归计算每条路径中的最大权独立集,然后以某种方式合并结果。

但分治法的问题在我们简单的四顶点例子中就已经很明显了。如果我们递归处理左半部分(即前两个顶点),并计算一个最大独立集,那将只是第二个顶点本身。如果我们独立地递归处理右半部分(顶点 3 和 4),右半部分的最大权独立集将是权重为 5 的顶点。现在,当递归完成,我们得到子解时,我们有了第二个顶点和第三个顶点。但问题是,这两个解的并集存在冲突:我们不能同时输出第二个和第三个顶点,它们是连续的、相邻的,这是不允许的。此外,在一个四节点图中,修复这个冲突似乎很容易,但在一个有数千个节点的大图中,如果两个子问题相遇的地方存在冲突,如何快速修复并获得原始问题的可行且最优的解,这一点一点也不明显。

在某种意义上,分治范式比贪心方法更适合这个问题,因为我知道一些分治算法可以最优地解决这个问题,其运行时间为二次时间。但以分治的方式做得比这更好似乎相当具有挑战性。而在我们将要开发的基于动态规划的算法中,我们将以线性时间解决这个问题。

本节课中,我们一起学*了最大权独立集问题的定义,并回顾了暴力搜索、贪心算法和分治法对该问题的适用性。我们发现,暴力搜索效率低下,贪心算法可能无法得到最优解,而分治法在合并子问题时面临挑战。这为引入动态规划这一新方法做好了铺垫。在接下来的课程中,我们将详细探讨如何应用动态规划高效地解决此问题。

026:路径图中的最大权独立集 - 最优子结构

在本节课中,我们将要学*如何为路径图计算最大权独立集。我们已经尝试了多种算法设计范式,但发现它们都不太适用。因此,我们将引入一种新的思路:首先分析最优解的结构。这种方法的核心是,最优解必然以某种特定方式由更小子问题的最优解构建而成。通过这种分析,我们可以将候选解的范围缩小到一个较小的集合,从而有可能通过搜索找到最佳解。

分析最优解的结构

上一节我们介绍了分析最优解结构的重要性。本节中,我们来看看如何具体应用到路径图的最大权独立集问题上。

我们使用以下符号:设 S 表示最大权独立集中的顶点集合,v_n 表示输入路径图最右侧的顶点。

关于最后一个顶点 v_n,它要么在最优解 S 中,要么不在。这为我们提供了两种分析情况。

情况一:v_n 不在最优解中

G' 表示从原图 G 中移除最右侧顶点 v_n 后得到的路径图。

以下是关于情况一的几个关键观察:

  • 集合 SG 的一个独立集。由于它不包含最后一个顶点,因此它也可以被视为较小图 G' 的一个独立集。
  • 更重要的是,S 不仅是 G' 的一个独立集,它必须是 G' 的一个最大权独立集。原因如下:如果 G' 中存在一个比 S 更好的独立集 S*,那么将 S* 视为 G 的独立集,其权值将大于 S,这与 SG 的最优解矛盾。

因此,如果原路径图 G 的最大权独立集 S 不包含最右侧顶点,那么它可以直接描述为一个更小子问题(G')的最优解。

情况二:v_n 在最优解中

现在假设最大独立集 S 确实包含了最右侧顶点 v_n

根据独立集的定义,不能选择相邻的顶点。因此,由于选择了 v_nS 绝对不可能包含倒数第二个顶点 v_{n-1}

G'' 表示从 G 中移除最右侧两个顶点后得到的路径图。

以下是关于情况二的分析:

  • 集合 S 减去顶点 v_n 后,是图 G'' 的一个独立集(因为 S 不包含 v_{n-1})。
  • 同样,我们可以得出更强的结论:S 减去 v_n 后,必须是 G'' 的一个最大权独立集。推理与情况一类似:如果 G'' 中存在一个更好的独立集 S*,那么将 v_n 加入 S* 会构成 G 的一个独立集,其总权值将大于 S,这与 S 的最优性矛盾。这里使用 G'' 是为了确保无论 S* 是什么,加入 v_n 后都不会产生相邻顶点,从而保证可行性。

结论与启示

我们刚才成功执行了高层计划:通过推理最优解的形式,论证了它必须呈现为特定的样子。

我们证明了最优解只可能是以下两种情况之一:

  1. 它不包含最后一个顶点,那么它就是子图 G' 的最大权独立集。
  2. 它包含最后一个顶点 v_n,那么它就是子图 G'' 的最大权独立集加上顶点 v_n

这个结论意味着,最优解的可能形式被缩小到仅由更小子问题的最优解构建的两种可能性。

由此得到一个推论:如果我们知道属于哪种情况(即 v_n 是否在最优解中),我们就可以通过递归求解相应的子问题来构造全局最优解。

  • 如果 v_n 不在最优解中,我们递归求解 G'
  • 如果 v_n 在最优解中,我们递归求解 G'',然后将 v_n 加入结果。

当然,我们并没有“小鸟”来告诉我们属于哪种情况。但是,既然只有两种可能性,一个看似疯狂但可行的想法是:尝试两种可能性,然后返回更好的那个。

这听起来可能像暴力搜索,事实上,如果递归地尝试所有可能性,它确实是一种递归组织的暴力搜索。然而,正如我们将在下一课看到的,如果我们能巧妙地消除冗余计算,就可以在线性时间内实现这个想法。

本节课中,我们一起学*了如何通过分析最优子结构来理解路径图最大权独立集问题的解。我们证明了最优解必然由更小子图的最优解以两种特定方式之一构成,这为设计高效算法(动态规划)奠定了关键基础。

027:路径图中的最大权独立集 - 线性时间算法 🚀

在本节课中,我们将学*如何将之前对路径图中最大权独立集(WIS)的理论分析,转化为一个高效的线性时间算法。我们将从递归思路出发,识别其效率瓶颈,并最终通过动态规划思想实现一个快速、正确的算法。


上一节我们通过思想实验,精确分析了路径图中最大权独立集的结构。本节中,我们将基于这个分析来构建一个线性时间算法。

首先,快速回顾上一节的核心结论。我们论证了两点:

  1. 如果路径图的最大权独立集不包含最右侧的顶点 v_n,那么它必然是去掉 v_n 后得到的子图 G' 的最大权独立集。
  2. 如果最大权独立集包含 v_n,那么在移除 v_n 后,剩余部分必然是去掉 v_n 及其前一个顶点后得到的子图 G'' 的最大权独立集。

因此,如果我们能知道处于哪种情况,就可以递归地计算 G'G'' 的最优解,然后相应地返回结果(对于 G'' 的情况,需要加上 v_n)。

由于我们无法预知是哪种情况,一个自然的想法是尝试两种情况。以下是这个递归算法的伪代码:

def wis_recursive(G):
    if G is empty:
        return 0
    if G has only one vertex v:
        return weight(v)

    # 情况1:不包含最后一个顶点 v_n
    option1 = wis_recursive(G_without_last_vertex)

    # 情况2:包含最后一个顶点 v_n
    option2 = wis_recursive(G_without_last_two_vertices) + weight(v_n)

    return max(option1, option2)

这个算法的好消息是它是正确的,能保证返回最大权独立集。其正确性可以通过归纳法严格证明,其思路与分治算法类似。

然而,这个算法的坏消息是它的运行时间是指数级的,本质上与暴力搜索无异。原因在于,每次递归前我们只排除了一个或两个顶点,进展甚微,却导致了大量的递归分支,从而产生了指数级的递归调用。


这引出了一个关键问题:在所有这些指数级的递归调用中,虽然每个调用都处理一个子图作为子问题,但本质上不同的子问题究竟有多少个?

答案是:只有线性个。尽管递归调用数量是指数级的,但我们只会遇到线性数量的不同子问题。具体来说,由于我们总是从图的右侧(末端)移除顶点,因此递归过程中遇到的任何子问题,都必然是原始输入图的一个前缀(即由前 i 个顶点导出的子图)。因此,我们只需要关心 n+1 个不同的子问题(对应前缀 G_0, G_1, ..., G_n)。

由此我们得出结论:之前递归算法的指数运行时间,完全源于对完全相同的子问题进行了大量重复计算。


这个观察为我们实现线性时间算法提供了可能。既然每个子问题只需计算一次,一个明显的优化方法是:缓存(或记忆化)每个子问题的结果。第一次计算某个子问题时,将其答案存储起来;后续再遇到该子问题时,直接查表返回结果,无需重新计算。

更优的实现方式是采用自底向上的迭代方法,系统地从小问题解决到大问题。

以下是具体的算法步骤:

  1. 定义数组 dp[0...n],其中 dp[i] 表示子图 G_i(由前 i 个顶点构成)的最大权独立集的总权重。
  2. 处理边界情况:
    • dp[0] = 0 (空图)
    • dp[1] = weight(v1) (只有一个顶点)
  3. 使用循环,从 i = 2n,依次计算 dp[i]
    • 根据上一节的分析,G_i 的最优解有两种可能:
      1. 不包含顶点 v_i:此时最优解等于 dp[i-1]
      2. 包含顶点 v_i:此时最优解等于 dp[i-2] + weight(v_i)
    • 因此,dp[i] = max(dp[i-1], dp[i-2] + weight(v_i))

以下是算法的核心代码描述:

def wis_path_graph(weights):
    n = len(weights)
    dp = [0] * (n + 1)
    dp[0] = 0
    dp[1] = weights[0]  # 注意索引偏移

    for i in range(2, n + 1):
        dp[i] = max(dp[i-1], dp[i-2] + weights[i-1]) # weights[i-1] 对应顶点 v_i 的权重

    return dp[n]

这个算法的运行时间分析非常简单:只有一个从 2 到 n 的循环,每次迭代执行常数时间操作,因此总时间是 O(n),即线性时间。

算法的正确性源于它与递归算法做出完全相同的决策,只是避免了重复计算子问题。其正确性同样可以通过归纳法证明。


本节课中我们一起学*了如何为路径图上的最大权独立集问题设计动态规划算法。我们从低效的递归思路出发,通过观察子问题的重叠性,引入了缓存(记忆化)思想,并最终实现了高效的自底向上迭代算法。这个算法的核心在于一个简单的递推公式:dp[i] = max(dp[i-1], dp[i-2] + w_i),它完美地捕捉了问题的最优子结构,并以线性时间解决了问题。

028:重构算法 🧩

在本节课中,我们将学*如何从动态规划算法生成的表格中,重构出最大权独立集问题的具体最优解,而不仅仅是其最优值。上一节我们介绍了一个优雅的线性时间算法来计算最优值,本节中我们来看看如何利用该算法留下的“线索”来恢复出实际的顶点集合。

概述

我们之前已经得到了一个解决路径图(Path Graph)上加权独立集问题(Weighted Independent Set, WIS)的算法。该算法非常优雅,能在 O(N) 的线性时间内计算出最优解的总权重。然而,这个算法只告诉我们最优解的值(例如184),并没有告诉我们具体由哪些顶点构成这个独立集。本节的目的是展示如何利用上一节算法填充的表格,来重构出一个实际的最优解。

算法回顾与问题定义

首先,让我们快速回顾一下上一节的算法。该算法通过填充一个数组 A 来工作,其中 A[i] 存储了由前 i 个顶点构成的子图 G_i 中最大权独立集的总权重。其核心递推公式如下:

A[0] = 0
A[1] = w1
For i = 2 to n:
    A[i] = max(A[i-1], A[i-2] + wi)

当算法结束时,A[n] 中的值(例如184)就是整个图 G 的最大权独立集的总权重。

为什么需要重构?

在许多实际应用中,仅仅知道最优值是不够的。我们通常希望知道是哪些顶点构成了这个总权重为184的独立集。一个直观的想法是修改算法,让数组 A 的每个条目不仅存储最优值,还存储达到该值的顶点集合。然而,在动态规划中,这通常不是首选方法,因为它会不必要地浪费时间和空间。更聪明的方法是在需要时,根据已填充的表格来重构最优解。

有趣的是,我们的一行算法并没有“掩盖踪迹”,它在表格中留下了足够的线索,让我们可以像侦探一样回溯并重构出最优解。

重构算法的核心思想

重构可能性的关键在于我们算法的正确性。我们已经证明,算法能正确填充数组的每个条目。回想我们证明正确性时的思路实验:对于最后一个顶点 v_n,最优解只可能有两种情况:

  1. 情况一:最优解不包含 v_n。此时,最优解就是前 n-1 个顶点构成的子图 G_{n-1} 的最优解。
  2. 情况二:最优解包含 v_n。此时,由于独立集的性质,v_{n-1} 不能被包含,因此最优解是 v_n 的权重加上前 n-2 个顶点构成的子图 G_{n-2} 的最优解。

算法在计算 A[n] 时,通过 max 操作明确比较了这两种情况:

  • 如果 A[n-1] >= A[n-2] + w_n,则情况一胜出,意味着最优解不包含 v_n
  • 否则,情况二胜出,意味着最优解包含 v_n

这个比较结果就是我们重构的起点。 已填充的表格 A 就是我们的“知更鸟”,它通过记录哪个“情况”被用来填充每个 A[i],间接告诉了我们每个顶点 v_i 是否应该被包含在最优解中。

重构算法步骤

重构算法以正向算法填充好的数组 A 和顶点权重列表 w 作为输入。它将从右向左(从 i=ni=1)扫描数组,并根据每个位置 i 的填充方式来决定是否将顶点 v_i 加入解集 S

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

ReconstructWIS(A[0...n], w[1...n]):
    Initialize an empty set S
    i = n
    While i >= 1:
        // 判断A[i]是由哪种情况计算得出的
        If (i == 1) OR (A[i-1] >= A[i-2] + w[i]):
            // 情况一胜出:不包含v_i
            i = i - 1
        Else:
            // 情况二胜出:包含v_i
            Add v_i to set S
            i = i - 2
    Return S

让我们详细解释一下这个循环:

  • 条件判断:我们检查在计算 A[i] 时,是 A[i-1](情况一)更大,还是 A[i-2] + w[i](情况二)更大。注意边界条件 i=1 时,只有情况一(A[0])可选。
  • 情况一(排除顶点):如果 A[i-1] 更大或相等,说明最优解不包含 v_i。因此我们跳过 v_i,并将索引 i 减1,去考察前一个顶点。
  • 情况二(包含顶点):如果 A[i-2] + w[i] 更大,说明最优解包含 v_i。因此我们将 v_i 加入解集 S。由于包含 v_i 意味着不能包含 v_{i-1},我们将索引 i 减2,直接跳到 v_{i-2} 进行考察。

算法正确性与复杂度分析

正确性:重构算法的正确性可以通过归纳法严格证明。归纳假设对于子图 G_i,算法能正确重构出其最大权独立集。在归纳步骤中,算法利用了我们反复使用的案例分析:对于当前顶点 v_i,最优解只有两种可能。算法通过比较 A[i-1]A[i-2] + w[i] 显式地确定了是哪种情况,并据此决定包含或排除 v_i,然后递归地(通过移动索引)解决剩余子问题。因此,最终输出的集合 S 确实是原图 G 的一个最大权独立集。

时间复杂度:重构算法包含一个 while 循环,最多迭代 n 次(每次迭代索引 i 至少减1)。每次迭代只进行常数时间的比较和操作。因此,与正向算法一样,这个反向的重构过程也是线性 O(N) 时间的,速度极快。

总结

本节课中我们一起学*了如何从动态规划表格中重构具体解。我们首先指出了仅获得最优值的不足,然后揭示了已填充的动态规划表格中蕴含了关于每个顶点是否在最优解中的关键信息。基于此,我们设计了一个从右向左扫描表格的重构算法,该算法通过检查每个位置 A[i] 是由哪种候选情况计算得出,来决定是否将顶点 v_i 加入最终解集。这个重构过程同样高效,仅需线性时间,并且严格保证了输出集合是最优解。至此,我们完整地解决了路径图上的加权独立集问题,既能计算最优值,也能给出具体的解。

029:动态规划原理

在本节课中,我们将要学*动态规划范式的一般原理。我们将通过回顾之前解决路径图最大权独立集问题的具体算法,来引出并理解动态规划的核心思想和关键属性。


动态规划的核心:子问题

我们刚刚设计出了第一个动态规划算法。那个用于计算路径图最大权独立集的线性时间算法,确实是通用动态规划范式的一个具体实例。

我之前推迟阐述该范式的通用原理,是因为我认为通过具体例子来理解它们效果最好。现在我们有了一个可以关联的例子,让我来介绍这些指导原则。在接下来的课程中,我们还会看到更多示例。

解锁动态规划范式解决问题潜力的关键,在于识别出一组合适的子问题。这些子问题必须满足一系列属性。

在我们计算路径图最大权独立集的算法中,我们有 n+1 个子问题,每个子问题对应图的一个前缀。正式地说,我们算法中的第 i 个子问题,是计算仅由前 i 个顶点组成的子图 G_i 的最大权独立集。


子问题应具备的属性

以下是子问题集合需要具备的关键属性。

1. 子问题数量不应过多

你希望子问题集合具备的第一个属性是,它不应该太大。你不应该有太多不同的子问题。原因在于,在最理想的情况下,你解决每个子问题都需要花费常数时间。因此,子问题的数量是你算法运行时间的一个下界。

在最大独立集的例子中,我们做得很好。我们只有线性数量的子问题,并且确实对每个子问题只做了常数级别的工作,从而获得了整体的线性运行时间上界。

2. 存在“较小”与“较大”子问题的概念

第二个属性,也是真正关键的一点,是应该存在较小子问题较大于问题的概念。在路径图独立集的上下文中,这很容易理解。子问题是原始图的前缀,顶点越多,子问题就越大。

一般来说,在动态规划中,你系统地解决所有子问题,从最小的子问题开始,逐步解决越来越大的子问题。为了让这种方法有效,必须满足一个条件:对于给定的子问题,在已知所有较小子问题的解的情况下,能够容易地推断出当前子问题的解。也就是说,先前子问题的解足以快速且正确地计算出当前子问题的解。

较大子问题和较小子问题之间的关系通常通过递推关系来表达。递推关系说明了给定子问题的最优解如何作为较小子问题最优解的函数。这正是我们独立集算法中的情况。我们确实有一个递推关系:

公式: OPT(i) = max(OPT(i-1), OPT(i-2) + w_i)

这个递推关系表明,图 G_i 的最大独立集权值是两个候选方案中较好的一个。我们通过思想实验证明了这一点:要么继承前一个子问题(G_{i-1})的最大独立集权值,要么取倒数第二个子问题(G_{i-2})的最优解,并加上当前顶点 v_i 的权值。

这是我们将会反复看到的模式:为各种计算问题定义子问题,并使用递推关系来表达给定子问题的最优解如何仅依赖于较小子问题的解。

就像在独立集的例子中一样,一旦有了这样的递推关系,它自然会导致一种填表算法。表中的每个条目对应一个子问题的最优解,你使用递推关系,从较小的子问题开始,逐步填充到较大的子问题。

3. 能够回答原始问题

第三个属性通常无需过多担心,它通常会自行解决。但毋庸置疑,在你完成了解决所有子问题的工作之后,你必须能够回答原始问题。

这个属性通常会自动满足,因为在大多数情况下(虽然不是全部),原始问题本身就是你所有子问题中最大的那个。在独立集的例子中正是如此,我们最大的子问题 G_n 就是原始图。所以一旦我们填满了整个表格,最终条目中等待我们的就是原始问题的期望解。


如何设计动态规划算法

我意识到目前这些内容有些抽象,我们只有一个具体例子来关联这些抽象概念。我鼓励你在看到更多例子后重新审视这些概念,而我们确实会看到更多例子。

所有即将到来的例子都应该清楚地展示动态规划范式的力量和灵活性。这确实是一项你必须掌握的技术。

当你尝试设计自己的动态规划算法时,关键在于找出正确的子问题是什么。如果你确定了子问题,通常其他一切都会以相当程式化的方式水到渠成。

如果你已经是动态规划的黑带高手,你可能只需盯着问题看,就能凭直觉知道正确的子问题集合是什么,然后就能迅速开始解决问题。当然,对于动态规划的白带新手,还有很多训练要做。

因此,在接下来的例子中,我们不会凭空提出子问题,而是将经历与独立集问题相同的过程,尝试通过推理最优解的结构,来弄清楚你最初是如何想出这些子问题的。这个过程你应该能够模仿,并将其应用到你自己的项目中遇到的问题上。


“动态规划”名称的由来

也许你曾希望,一旦你看到了动态规划的组成部分,一切都会变得清晰,明白它到底为什么叫“动态规划”。但可能并非如此。

“编程”这个词在这里是时代误用。它并不是指你们大多数人可能认为的“编码”。这与“数学规划”或“线性规划”等短语中的时代误用相同,它更多指的是一个规划过程。

但为了了解完整的故事,让我们来看看理查德·贝尔曼本人,他可以说是动态规划的发明者(你将在课程稍后看到他的贝尔曼-福特算法)。他在自传中回答了这个问题,谈到了他在20世纪50年代发明它时的情景:

“那几年对数学研究来说并不是好年头。我在一个叫兰德公司的地方工作。在华盛顿有一位非常有趣的绅士,名叫威尔逊,他是国防部长。实际上,他对‘研究’这个词有一种病态的恐惧和憎恨。我不是轻率地使用这个词,我是精确地使用它——如果有人在他面前使用‘研究’这个词,他的脸会涨红,会变得暴躁。你可以想象他对‘数学’这个词的感受。兰德公司受雇于空军,而空军的老板就是威尔逊。因此,我觉得我必须做点什么来保护威尔逊和空军,使他们不知道我实际上在兰德公司内部做数学。什么标题?什么名字我可以选择?首先,我对规划和决策制定感兴趣。但是‘规划’这个词,由于各种原因,不是一个好词。因此,我决定使用‘编程’这个词。‘动态’作为一个形容词有一个非常有趣的属性,那就是不可能以贬义的方式使用‘动态’这个词。试着想一些组合,可能会给它一个贬义的含义。这是不可能的。因此,我认为动态编程是个好名字。这是一个连国会议员都无法反对的东西。所以我就用它作为我活动的总称。”


总结

本节课中,我们一起学*了动态规划的核心原理。我们了解到,动态规划的关键在于定义一组合适的子问题,这些子问题需要满足数量可控、存在大小顺序并能通过递推关系由小问题解出大问题解等属性。通过回顾路径图最大权独立集算法的实例,我们看到了这些原理如何具体应用。最后,我们还了解了“动态规划”这一名称的历史由来。掌握这些基本原理,是理解和设计更多动态规划算法的基础。

030:背包问题

在本节课中,我们将学*动态规划的第二个经典应用:背包问题。我们将展示如何沿用之前求解路径图最大独立集问题的相同思路,来推导出这个著名问题的动态规划解法。

概述

接下来的两个视频将介绍动态规划的第二个应用实例。这是一个非常著名的问题,称为背包问题。我们将展示如何遵循与计算路径图独立集完全相同的步骤,来得出这个问题的著名动态规划解法。

现在,让我们直接进入背包问题的定义。

背包问题定义

输入由 n 个物品组成。

每个物品都有一个价值 vᵢ(对我们来说越大越好)和一个大小 wᵢ

我们假设这两个值都是非负的。对于物品大小,我们额外假设它们是整数

除了这两个 n 维数组,我们还给定一个称为容量的数值 W。同样,我们假设它也是非负整数。

这些整数假设的作用将在后续说明。

在背包问题中,算法的任务是选择一个物品的子集。我们的目标是最大化所选物品的总价值。

那么,是什么阻止我们选择所有物品呢?限制是所选物品的总大小不能超过容量 W

我可以讲一个关于小偷带着容量为 W 的背包入室盗窃,想带走最好战利品的老套故事,但这实际上低估了这个问题的重要性。背包问题是一个非常基础的问题,经常作为更大任务的子程序出现。基本上,每当你有一个资源预算,并希望以最聪明的方式使用它时,这就是背包问题。你可以想象它在许多场景中都会出现。

动态规划算法设计思路

现在,让我们执行开发动态规划算法的标准步骤。记住,任何动态规划解决方案的关键在于找出正确的子问题集合。我们将通过思考最优解的结构,来推导背包问题的子问题,就像我们为最大权独立集所做的那样。

这个思考实验的最终成果将是一个递推关系式,一个公式,它告诉我们一个子问题的最优值如何依赖于更小子问题的最优值。

思考实验开始

首先,固定一个背包问题的实例,并让 S 表示一个最优解(即价值最大的可行解)。

我们之前的思考实验从一个无关内容的陈述开始:路径的最后一个顶点要么在最优解中,要么不在。那么,在背包问题中,什么类似于“最右边的顶点”呢?与路径图不同,给定的物品没有内在的顺序性,它们只是一个无序集合。但实际上,将物品按顺序编号为 1, 2, 3, ..., n 来思考是有用的。那么,“最右边的顶点”的类比就是最后一个物品

因此,我们在这里要使用的无关内容的陈述是:要么最后一个物品属于最优解 S,要么不属于

我们将再次从简单的情况开始:当它不属于时。

在路径图问题中,我们论证了在类似情况1下,如果我们只是从图中删除最右边的边,那么最大权独立集必须是最优的。这里的类似主张是:如果我们从背包实例中删除最后一个物品 n,集合 S 应该仍然是最优的。

论证过程完全相同,几乎是一个微不足道的矛盾:如果在第一个 n-1 个物品中存在一个不同的解 S*,其价值比 S 还大,那么我们同样可以将其视为包含所有 n 个物品的、更优的背包可行解,但这与 S 的最优性假设相矛盾。

情况二:最后一个物品在最优解中

现在,让我们通过一个小测验来一起探讨稍微复杂一些的情况二。

假设最优的背包解确实使用了这最后一个物品 N。

现在,我们希望说明这个解在某种意义上是由一个更小子问题的最优解组合而成的。所以,如果我们打算删除最后一个物品,我们就不能直接讨论 S,因为 S 包含了最后一个物品。因此,在讨论其最优性之前,我们需要从 S 中移除最后一个物品。这类似于在独立集问题中,我们在讨论更小子问题的最优性之前,从最优解中移除最右边的顶点。

那么问题是:如果我们取最优解 S,移除物品 N,那么剩余的解在什么意义下是最优的?换句话说,对于哪种背包实例(如果有的话),它是一个最优解?

正确答案是 C

回到独立集问题,我们说过,如果我们移除最右边的顶点,那么剩下的部分对于移除最右边两个顶点后得到的残差独立集问题是最优的。在这里,当我们从最优解 S 中移除物品 n 时,结论是:我们得到的是对于涉及前 n-1 个物品剩余背包容量为 W - wₙ 的背包问题的最优解。也就是说,原始的背包容量中为第 n 个物品预留或删除了空间。

在我给出简要证明之前,让我先简要解释为什么其他几个答案不正确。

首先,答案 B,我希望你能快速排除,它类型不匹配。W 是背包容量,单位是大小;vₙ 是物品价值,单位是价值。谈论这两者的差值没有意义,就像苹果和橘子。

答案 D,如果你担心可行性问题。如果你取 S 并移除物品 n,你做了什么?你取了一个总大小至多为 W 的物品集合(根据 S 的可行性),然后从中移除了一个大小为 wₙ 的物品。因此,剩余部分的总大小至多为 W - wₙ。所以,S - {n} 确实对于这个减少后的剩余背包容量 W - wₙ 是可行的。

更微妙的一点是答案 A,这是一个很自然的猜测,但结果证明是不正确的。事实证明,如果你有完整的背包容量 W 可以使用,可能存在比 S - {n} 更聪明地使用前 n-1 个物品的方法。这是一个更微妙的点,你可以作为一个很好的练*来说服自己 A 是错误的:没有理由认为当你从 S 中取出物品 n,并且仍然使用原始背包容量时,这必须是最优的,这不会成立。

那么,为什么 C 是正确的呢?这将与我们加权独立集思考实验中情况二的精神相同。

让我给出证明。证明将采用与我们在加权独立集问题中论证情况二时相同的矛盾法。

假设存在一个比 S - {n} 更好的解(在剩余容量 W - wₙ 下),称这个假设更好的解为 S。那么我们如何得出矛盾呢?我们只需取 S(它只涉及前 n-1 个物品),然后把物品 n 加进去。由于 S* 的总大小至多为 W - wₙ,而物品 n 的大小为 wₙ,结果的总大小至多为 W,所以取 S* 并扩展以包含物品 n 是一个可行解。如果 S* 的价值比 S - {n} 高,那么包含 n 的 S* 的价值就比 S 高。

例如,如果 S 的总价值是 1100,其中 100 来自物品 n,那么 S - {n} 的价值是 1000。如果 S* 更好,价值是 1050,那么我们只需把 n 加回去,它就具有 1150 的价值,这与 S 的最优性(总价值仅为 1100)相矛盾。

注意这里发生了什么:在查看残差问题之前,我们从背包容量中减去 wₙ,实际上是在为物品 n 预留一个缓冲区。这就是为什么当我们把 n 放回解 S* 时,我们知道它是可行的。这类似于在独立集问题中删除倒数第二个顶点作为缓冲区,以确保当我们把顶点 n 加回时是可行的。

思考实验的意义

那么,这个我们已经完成的整个思考实验的意义是什么?同样,其意义在于说明:最优解,无论它是什么,必须只有两种形式之一。我们已经将候选列表缩小到两种可能性:

  1. 你直接继承少一个物品、相同容量的背包问题的最优解。
  2. 你查看少一个物品、容量减少 wₙ 的背包问题的最优解,然后用物品 n 扩展它。

只有这两种可能性。所以,再次强调,如果我们只知道这两种情况中哪一种是正确的,即我们只知道物品 n 是否在最优解中,那么在某种意义上,我们就可以递归地计算出解的其余部分。正如这足以让我们开始为加权独立集设计动态规划算法一样,对于背包问题也是如此,我将在下一个视频中展示。

总结

本节课中,我们一起学*了背包问题的定义,并通过一个思考实验分析了其最优解的结构。我们发现,包含 n 个物品、容量为 W 的背包问题的最优解,必然属于以下两种情况之一:

  • 情况一:最优解不包含物品 n,那么它等于 (n-1, W) 子问题的最优解。
  • 情况二:最优解包含物品 n,那么它等于 (n-1, W - wₙ) 子问题的最优解加上物品 n 的价值 vₙ。

这个关键的观察为我们下一节构建动态规划递推公式和算法奠定了基础。

031:动态规划算法 🎒

概述

在本节课中,我们将学*如何为背包问题设计一个动态规划算法。我们将从理解最优解的结构开始,推导出递推关系,然后将其转化为一个系统的动态规划解决方案。

最优解结构与递推关系

上一节我们讨论了最优解必须由更小子问题的最优解组成。现在,我们准备将其转化为一个递推关系,并最终形成背包问题的动态规划解法。

为了将我们之前的讨论整理成递推式,首先引入一些符号。我们用 V(i, X) 表示满足两个约束的解所能获得的最大价值:

  1. 只能使用前 i 个物品。
  2. 所选物品的总大小最多为 X

这与独立集问题中的符号 G_i(前 i 个顶点的图)类似。我们现在使用两个索引而不是一个,因为子问题可以通过两种方式变得更小。回想一下我们思维实验的第二种情况,当我们观察一个更小的子问题时,既可能少一个物品,也可能剩余容量减少。因此,在考虑子问题时,我们需要同时跟踪允许使用的物品数量和允许使用的总容量。

让我们用这个符号来表达上一视频的结论。我们上一视频发现,最优解必须具有以下两种形式之一:

  • 要么直接继承少一个物品(即前 i-1 个物品)但容量相同的背包实例的最优解(第一种情况)。
  • 要么取一个子问题的最优解,该子问题少一个物品,并且容量减少了当前物品的重量,然后通过使用第 i 个物品将其扩展为当前子问题的最优解。

因此,V(i, X),即当前子问题的最优值,是两种可能性中较好的那个,所以我们取最大值。

递推公式如下:

V(i, X) = max( V(i-1, X), V(i-1, X - w_i) + v_i )

其中:

  • V(i-1, X) 是不使用第 i 个物品的情况。
  • V(i-1, X - w_i) + v_i 是使用第 i 个物品的情况,v_i 是物品 i 的价值,w_i 是物品 i 的重量。

一个快速的边界情况:如果第 i 个物品的重量 w_i 大于我们允许的容量 X,那么我们当然不能使用它,因此我们被迫使用第一种情况。

这就是背包问题的递推关系。这完成了第一步,即思考最优解结构并用其设计递推式。

确定子问题范围

现在进入第二步,我们将精确确定需要关心哪些子问题。

在我们的路径图最大权重独立集例子中,每次递归或查找子问题解时,都是通过从图的右侧移除顶点获得的。因此,我们只需要关心图的所有可能前缀。

在这里的一维情况下,我们有类似的情况。每当我们查看更小的子问题时,总是少一个物品,我们总是在查找之前删除最后一个物品。因此,我们需要考虑所有可能的物品前缀,即对于所有 i 值,前 i 个物品的子问题。

然而,对于背包问题,子问题变小的还有第二种方式。回想我们思维实验的第二种情况,当我们想知道保证使用当前物品 i 的最优解时,我们必须在查找相应子问题的最优解之前减少容量。也就是说,我们不仅剥离物品,还在剥离背包容量的一部分。

这里我们将使用我们在开始时提到的假设:输入中所有物品大小都是整数,背包容量也是整数。背包容量从 W 开始,每次我们从它剥离一些整数单位的容量。因此,在所有子问题中,我们处理的都是整数背包容量。所以,在最坏情况下,可能出现的各种子问题就是所有可能的剩余容量选择:0, 1, 2, ...,一直到原始背包容量 W

现在我们在第二步中处于有利位置。我们已经精确找出了需要关心的子问题。在第一步中,我们找到了一个公式,可以根据较小子问题的解来求解较大的子问题。剩下的就是写下一个表格,并系统地使用递推式填充它,从最小的子问题开始,直到最大的子问题。

动态规划算法实现

以下是伪代码,它将再次非常简单。

在这个算法中,我们要填充的数组 A 是二维的,这与加权独立集问题中的一维数组形成对比。这是因为我们的子问题有两个不同的索引,我们既要跟踪允许使用的物品集合,也要跟踪需要遵守的容量。

以下是算法的核心步骤:

  1. 初始化:填充所有 i = 0 的条目(即没有物品可用),此时最优解值当然是 0。
  2. 系统填充:通过双重循环系统地遍历所有子问题。
  3. 计算每个子问题:对于给定的子问题(允许使用前 i 个物品,剩余容量为 X),我们使用推导出的递推式来计算表格中的条目。这本质上是对两种可能性进行暴力搜索:
    • 第一种情况:继承少一个物品但容量相同的最优解。
    • 第二种情况:使用物品 i,并将其与来自前 i-1 个物品、且为物品 i 留出足够空间的最优解组合。
  4. 边界处理:如果当前物品的重量 w_i 严格大于容量 X,则忽略第二种情况,直接令 A[i, X] = A[i-1, X]
  5. 返回结果:双重循环完成后,存储在 A[n, W] 中的就是我们想要的解,即可以使用任何物品且可以使用整个原始背包容量 W 的最大价值解。

伪代码示例:

初始化二维数组 A[0..n, 0..W]
for x = 0 to W:
    A[0, x] = 0

for i = 1 to n:
    for x = 0 to W:
        if w_i > x:
            A[i, x] = A[i-1, x]
        else:
            A[i, x] = max( A[i-1, x], A[i-1, x - w_i] + v_i )

返回 A[n, W]

一个关键点是,当我们需要求解给定 i 和 X 的子问题时,我们已经拥有了所有所需子问题的解。具体来说,在外层 for 循环的前一次迭代中,我们已经计算了评估此递推式所需的两个相关子问题的解,它们正等待着我们进行常数时间的查找。

算法分析

运行时间分析非常简单,就像最大独立集问题一样:我们只需计算子问题的数量,并查看每个子问题做了多少工作。

子问题由 i 和 X 共同索引。i 有 n+1 种选择,X 有 W+1 种选择。这给了我们 θ(n * W) 个不同的子问题。对于每个子问题,我们只对先前计算的解进行一次比较,即每个子问题做常数工作,从而得到 O(n * W) 的总体运行时间。

其他要点

与最大独立集问题类似,本算法也存在一些相同的细节:

  • 正确性:证明方式与之前的动态规划算法和分治算法完全相同,通过问题规模进行归纳,并使用我们的案例分析(思维实验)来形式化地证明归纳步骤。
  • 重构最优解:此算法计算的是最优解的值,而不是最优解本身(它返回一个数字,而不是物品的实际子集)。但是,就像独立集问题一样,你可以通过回溯已填充的数组来重构一个最优解。直觉是:从最大的子问题开始,查看填充该条目时使用了两种情况中的哪一种。如果使用了第一种情况,你知道应该排除最后一个物品;如果使用了第二种情况,你知道应该包括最后一个物品。同时,这两种情况也告诉你应该回溯到哪个子问题以继续该过程。

总结

本节课中,我们一起学*了如何为背包问题设计动态规划算法。我们从分析最优解的结构出发,推导出了关键的递推关系 V(i, X) = max( V(i-1, X), V(i-1, X - w_i) + v_i )。然后,我们确定了需要求解的所有子问题(共 n*W 个),并系统地实现了填充二维表格的算法。该算法的时间复杂度为 O(n * W),并且可以通过回溯表格来重构出具体的物品选择方案。

032:背包问题示例

在本节课中,我们将通过一个具体的例子,详细演示如何应用动态规划算法解决背包问题。我们将一步步填充动态规划表格,并最终通过回溯找到最优解的具体物品组合。

概述

我们已经掌握了两种动态规划算法:计算路径图中带权独立集的方法,以及解决经典背包问题的动态规划方案。在继续学*更多算法之前,我们通过一个完整的示例来确保对背包问题动态规划算法的理解清晰无误。

算法核心回顾

首先,我们回顾一下背包问题动态规划算法的关键点。我们使用一个二维数组 A 来存储子问题的解。

  • 初始化:当物品索引 i = 0(即不允许使用任何物品)时,无论背包容量 x 是多少,最优解的值都是 0
  • 递推关系:对于物品 i 和剩余容量 x,最优解 A[i][x] 是以下两者的最大值:
    1. 不选物品 i:继承 A[i-1][x] 的值。
    2. 选择物品 i:获得物品 i 的价值 v_i,加上剩余容量 x - w_i 下的最优解 A[i-1][x - w_i]

用公式表示如下:

A[0][x] = 0, 对于所有 x
A[i][x] = max( A[i-1][x], v_i + A[i-1][x - w_i] ), 如果 x >= w_i
A[i][x] = A[i-1][x], 如果 x < w_i

示例问题设定

我们来看一个具体的例子。假设有 4 个物品,背包初始容量 W = 6。物品的价值和重量如下表所示:

物品 (i) 价值 (v_i) 重量 (w_i)
1 3 4
2 2 3
3 4 2
4 4 3

我们将按照最直观的方式实现算法,显式地构建并填充二维数组 A,其中 i 的范围是 0n(4),x 的范围是 0W(6)。

逐步填充动态规划表

步骤 1:初始化

首先,我们初始化表格。根据规则,当 i = 0(没有物品可用)时,所有 A[0][x] 都等于 0。这对应表格最左边的一列。

步骤 2:处理物品 1 (i=1)

现在,我们进入主循环。外层循环处理物品索引 i,内层循环处理剩余容量 x。我们从 i=1(物品1)开始。

物品1的重量 w_1 = 4。这意味着当剩余容量 x 小于 4 时,我们无法选择物品1,只能继承 i=0 时的解。

以下是填充 i=1 这一列的过程:

  • x = 0, 1, 2, 3 时:A[1][x] = A[0][x] = 0
  • x = 4 时:我们可以选择物品1(价值3),剩余容量变为 4-4=0,对应 A[0][0]=0,总价值为 3+0=3。也可以不选,继承 A[0][4]=0。显然,选择物品1更优,所以 A[1][4] = 3
  • x = 5, 6 时:同理,选择物品1总是比不选(价值0)更优,所以 A[1][5] = 3A[1][6] = 3

步骤 3:处理物品 2 (i=2)

现在处理物品2,其重量 w_2 = 3,价值 v_2 = 2

  • x = 0, 1, 2 时:无法选择物品2,A[2][x] = A[1][x](分别为0, 0, 0)。
  • x = 3 时:选择物品2得到 2 + A[1][0] = 2,不选得到 A[1][3] = 0。选择更优,A[2][3] = 2
  • x = 4 时:选择物品2得到 2 + A[1][1] = 2,不选得到 A[1][4] = 3。不选更优,A[2][4] = 3
  • x = 5, 6 时:选择物品2分别得到 2 + A[1][2] = 22 + A[1][3] = 2,而不选分别得到 A[1][5] = 3A[1][6] = 3。不选更优,所以 A[2][5] = 3A[2][6] = 3


步骤 4:处理物品 3 (i=3)

物品3的重量 w_3 = 2,价值 v_3 = 4

  • x = 0, 1 时:无法选择,A[3][x] = A[2][x](0, 0)。
  • x = 2 时:选择得到 4 + A[2][0] = 4,不选得到 A[2][2] = 0。选择更优,A[3][2] = 4
  • x = 3 时:选择得到 4 + A[2][1] = 4,不选得到 A[2][3] = 2。选择更优,A[3][3] = 4
  • x = 4 时:选择得到 4 + A[2][2] = 4,不选得到 A[2][4] = 3。选择更优,A[3][4] = 4
  • x = 5 时:选择得到 4 + A[2][3] = 4 + 2 = 6,不选得到 A[2][5] = 3。选择更优,A[3][5] = 6
  • x = 6 时:选择得到 4 + A[2][4] = 4 + 3 = 7,不选得到 A[2][6] = 3。选择更优,A[3][6] = 7


步骤 5:处理物品 4 (i=4)

物品4的重量 w_4 = 3,价值 v_4 = 4

  • x = 0, 1, 2 时:无法选择,A[4][x] = A[3][x](0, 0, 4)。
  • x = 3 时:选择得到 4 + A[3][0] = 4,不选得到 A[3][3] = 4。两者相等,A[4][3] = 4
  • x = 4 时:选择得到 4 + A[3][1] = 4,不选得到 A[3][4] = 4。两者相等,A[4][4] = 4
  • x = 5 时:选择得到 4 + A[3][2] = 4 + 4 = 8,不选得到 A[3][5] = 6。选择更优,A[4][5] = 8
  • x = 6 时:选择得到 4 + A[3][3] = 4 + 4 = 8,不选得到 A[3][6] = 7。选择更优,A[4][6] = 8

至此,动态规划表填充完成。表格右上角 A[4][6] = 8 就是整个问题的最优解值。

回溯构造最优解

知道最优解值是 8 之后,我们通过回溯来确定具体选择了哪些物品。我们从最大的子问题 A[4][6] 开始。

  1. 查看 A[4][6]:它的值 8 是如何得到的?它是通过选择物品4(价值4)并加上 A[3][3] 的值 4 得到的,而不是通过继承 A[3][6]=7 得到的。因此,物品4在最优解中。
  2. 回溯到 A[3][3]A[3][3]=4 是如何得到的?它是通过选择物品3(价值4)并加上 A[2][1]=0 得到的,而不是继承 A[2][3]=2因此,物品3也在最优解中。
  3. 回溯到 A[2][1]A[2][1]=0 是直接继承自 A[1][1]=0,这意味着在剩余容量为1时,没有选择物品2或物品1。
  4. 继续向左回溯到 i=0,回溯结束。

因此,最优解包含物品3和物品4,总价值为 4 + 4 = 8,总重量为 2 + 3 = 5,未超过背包容量6。

总结

本节课中,我们一起学*了如何通过一个具体示例来应用动态规划解决背包问题。我们系统地完成了以下步骤:

  1. 定义子问题A[i][x] 表示考虑前 i 个物品、在容量限制 x 下的最大价值。
  2. 建立递推关系:基于“选”或“不选”当前物品做出决策。
  3. 自底向上填表:从最小的子问题开始,逐步计算并填充整个动态规划表。
  4. 读取最优值:表格右下角(或右上角,取决于定义)即为问题的最优解值。
  5. 回溯构造解:通过追踪递推决策的过程,找出组成最优解的具体物品集合。

通过这个详细的例子,我们巩固了对背包问题动态规划算法的理解,为后续学*更复杂的动态规划问题打下了坚实的基础。

033:序列比对的最优子结构 🔬

在本节课中,我们将学*序列比对问题,并运用动态规划的思想来分析其最优子结构。我们将看到,一个最优的全局比对方案,其组成部分也必然是对应子问题的最优解。理解这一点是设计高效动态规划算法的关键。


问题回顾

序列比对是计算基因组学中的一个基础问题。其目标是计算两个字符串之间的相似度度量,该度量定义为最佳比对的总惩罚值(也称为Needleman-Wunsch分数)。

输入

  • 两个字符串 X(长度为 m)和 Y(长度为 n)。
  • 各种惩罚值:插入空位的代价(δ),以及任意两个字符不匹配的代价(α(xi, yj))。通常,字符与自身匹配的惩罚为0。

可行解空间:通过向两个字符串中插入空位,使它们长度相等,从而得到的所有比对方案。

目标:在所有指数级数量的比对方案中,找到总惩罚值最小的那个。总惩罚值是所有插入的空位和所有不匹配列的惩罚值之和。


应用动态规划思想

上一节我们回顾了动态规划的基本框架。现在,我们将这个框架应用于序列比对问题。动态规划解决方案的关键在于找出正确的子问题集合。我们将通过分析最优解的结构来推导出这些子问题。

具体来说,我们将研究最优比对方案在最后一个位置可能呈现的几种情况,并证明最优解必然由更小子问题的最优解构成。


最优解的结构分析

让我们思考一个最优比对方案。我们可以将其可视化:上方是字符串 X 及其插入的空位,下方是字符串 Y 及其插入的空位,两者长度相等。

为了分析最优解的结构,我们借鉴之前解决独立集和背包问题的经验:关注最优解的“最后部分”。在这里,我们关注最优比对的最后一个位置。

以下是最后一个位置可能出现的三种相关情况

  1. 情况一:最后一个位置匹配了两个字符串的最后一个字符(即 X[m]Y[n] 对齐)。
  2. 情况二:最后一个位置匹配了 X 的最后一个字符和一个空位(即 X[m] 与一个空位对齐)。
  3. 情况三:最后一个位置匹配了 Y 的最后一个字符和一个空位(即 Y[n] 与一个空位对齐)。

注意:我们不考虑最后一个位置上下都是空位的情况,因为删除这两个空位会得到一个惩罚值更低(或相等)的更好比对,这与最优性矛盾。

我们希望证明,对于上述每一种情况,原始问题的最优解都可以通过组合一个更小子问题的最优解来获得。


定义子问题与候选解

让我们定义更小的子问题。设:

  • X' = X 去掉最后一个字符 X[m]
  • Y' = Y 去掉最后一个字符 Y[n]

现在,针对三种情况,我们描述对应的候选最优解结构:

  • 情况一(X[m] 匹配 Y[n]:如果我们从最优比对中移除最后一列,剩下的“诱导比对”必然是字符串 X'Y' 的一个最优比对。
  • 情况二(X[m] 匹配空位):如果我们从最优比对中移除最后一列,剩下的“诱导比对”必然是字符串 X' 和完整 Y 的一个最优比对。
  • 情况三(Y[n] 匹配空位):如果我们从最优比对中移除最后一列,剩下的“诱导比对”必然是完整 X 和字符串 Y' 的一个最优比对。

这个断言意味着,原始问题的最优解只能是以下三个候选者之一,每个候选者对应一种末尾情况,并由一个更小子问题的最优解扩展而来。


最优子结构证明(以情况一为例)

我们现在证明情况一的断言。其他情况的证明思路类似。

断言:在最优比对中,如果最后一列是 X[m] 匹配 Y[n],那么去掉最后一列后得到的 X'Y' 的比对,也必须是 X'Y' 的最优比对。

证明(反证法):

  1. 假设原比对是最优的,但其诱导出的 X'Y' 的比对 不是 最优的。
  2. 那么,存在一个对 X'Y' 的更好比对,其总惩罚值 P* 严格小于原诱导比对的惩罚值 P
  3. 现在,我们可以将这个更好的 X'Y' 的比对,与最后一列的匹配 (X[m], Y[n]) 组合起来,形成一个新的 XY 的完整比对。
  4. 这个新比对的总惩罚值为:P* + α(X[m], Y[n])
  5. 由于 P* < P,我们有 P* + α(X[m], Y[n]) < P + α(X[m], Y[n])
  6. P + α(X[m], Y[n]) 正是我们最初假设的“最优”比对的总惩罚值。
  7. 因此,我们找到了一个总惩罚值更低的比对,这与最初假设的“最优性”矛盾。
  8. 所以,我们的假设不成立。诱导比对必须是 X'Y' 的最优比对。

证毕。


总结

本节课中,我们一起学*了序列比对问题的最优子结构性质。我们分析了最优比对方案在最后一个位置的三种可能情况,并证明了在每种情况下,原始问题的最优解都必然由一个更小子问题(涉及前缀字符串)的最优解构成。

具体来说:

  • 若末尾是字符匹配,则子问题是 X[1..m-1]Y[1..n-1] 的比对。
  • X[m] 匹配空位,则子问题是 X[1..m-1] 与完整 Y 的比对。
  • Y[n] 匹配空位,则子问题是完整 XY[1..n-1] 的比对。

这个关键洞察为我们下一节课推导动态规划递推关系并设计高效算法奠定了坚实的基础。

034:序列比对 - 动态规划算法 🧬

在本节课中,我们将学*如何为序列比对问题设计一个高效的动态规划算法。我们将从理解最优解的结构开始,推导出递归关系,并最终构建出完整的算法。

概述

我们已经知道,序列比对问题的最优解必然属于三种候选情况之一。基于此,我们可以轻松地构建一个递归关系,识别出相关的子问题,并最终推导出一个高效的动态规划算法。

最优解的结构

上一节我们分析了字符串 X 和 Y 的最优比对,并注意到其最后一个位置的内容只有三种情况:

  1. 没有空位,即 X[M]Y[N] 匹配。
  2. 顶部有空位,即 X[M] 与一个空位匹配。
  3. 底部有空位,即 Y[N] 与一个空位匹配。

我们证明了,在这三种情况下,由剩余字符串 X' 和/或 Y' 诱导出的比对本身也必须是最优的。这意味着,原始问题的最优解仅依赖于三个更小子问题的最优解。

子问题的定义

与之前的独立集和背包问题类似,子问题通过从字符串末尾“剥离”字符而变小。这里有两个维度:从第一个字符串剥离,或从第二个字符串剥离。因此,我们使用两个参数 ij 来跟踪子问题的大小。

我们关心的唯一相关子问题涉及两个原始输入字符串 XY 的前缀。即,子问题具有形式 (X_i, Y_j),其中 X_i 表示 X 的前 i 个字母,Y_j 表示 Y 的前 j 个字母。

递归关系

现在,让我们从子问题转向动态规划算法中要使用的递归关系。递归关系将我们对最优解如何依赖于更小子问题解的理解,编译成一个易于使用的数学公式。

我们用 P[i, j] 表示子问题 (X_i, Y_j) 的最优解值(即最小罚分)。

对于给定的 ijP[i, j] 的计算有三种可能性:

情况 1:最优比对的最后一个位置没有空位。即匹配 x_iy_j,然后加上 X_{i-1}Y_{j-1} 的最优比对罚分。

罚分 = P[i-1, j-1] + 字符匹配罚分(x_i, y_j)

情况 2:最后一个位置,x_i 与一个空位匹配。

罚分 = P[i-1, j] + 空位罚分

情况 3:最后一个位置,y_j 与一个空位匹配。

罚分 = P[i, j-1] + 空位罚分

由于我们不知道最优解具体是哪种情况,因此在递归中,我们只需对这三种可能性进行暴力搜索,并选择罚分最小的一个。

递归公式

P[i, j] = min(
    P[i-1, j-1] + 字符匹配罚分(x_i, y_j),
    P[i-1, j] + 空位罚分,
    P[i, j-1] + 空位罚分
)

边界条件

在陈述系统性地解决所有子问题的算法之前,我们需要正确处理边界情况,即当 ij 等于 0 时的基例。

考虑 P[i, 0]P[0, j]P[i, 0] 表示将 X 的前 i 个字母与空字符串比对。最优方式是在空字符串中插入 i 个空位,因此罚分为 i * 空位罚分。同理,P[0, j] = j * 空位罚分

动态规划算法

一旦我们确定了子问题和递归关系,动态规划算法就水到渠成了。我们只需系统地、从小到大解决所有子问题。

我们将使用一个二维数组 A 来记录所有子问题的解。A 的两个维度分别对应我们处理的两个字符串的前缀长度。

以下是算法的伪代码:

def sequence_alignment(X, Y, gap_penalty, mismatch_penalty):
    m = len(X)
    n = len(Y)
    # 初始化二维数组 A
    A = [[0 for _ in range(n+1)] for _ in range(m+1)]

    # 初始化边界条件
    for i in range(m+1):
        A[i][0] = i * gap_penalty
    for j in range(n+1):
        A[0][j] = j * gap_penalty

    # 使用双重循环填充数组
    for i in range(1, m+1):
        for j in range(1, n+1):
            # 计算三种情况的罚分
            case1 = A[i-1][j-1] + mismatch_penalty(X[i-1], Y[j-1]) # 注意索引偏移
            case2 = A[i-1][j] + gap_penalty
            case3 = A[i][j-1] + gap_penalty
            # 取最小值
            A[i][j] = min(case1, case2, case3)

    # 最优比对的总罚分存储在 A[m][n] 中
    return A[m][n], A

正确性检查:在编写动态规划代码时,应确保在计算 A[i][j] 时,递归右侧用到的子问题 A[i-1][j-1]A[i-1][j]A[i][j-1] 都已经被计算过。在本算法的循环顺序下,这一点是得到保证的。

算法分析与重构比对

正确性:算法的正确性直接源于递归关系的正确性。我们系统地解决了所有子问题,因此可以通过归纳法严格证明。

运行时间:双重循环共有 m * n 次迭代,每次迭代执行常数时间的工作(三次数组查找和比较)。因此,总运行时间为 O(m * n),与两个字符串长度的乘积成正比。

重构最优比对:通常我们不仅需要知道最优比对的罚分,还需要知道比对本身。与处理线图独立集问题类似,我们可以通过回溯已填充的表格 A 来重构最优解。

以下是回溯过程的高级思路:

  1. 从我们最终关心的最大子问题 A[m][n] 开始。
  2. 查看是哪个候选情况(情况1、2或3)导致了 A[m][n] 的值。这决定了最优比对最后一个位置的内容:
    • 如果是情况1,则匹配 X[m]Y[n]
    • 如果是情况2,则匹配 X[m] 和一个空位。
    • 如果是情况3,则匹配 Y[n] 和一个空位。
  3. 根据所选情况,移动到对应的前一个子问题(例如,情况1对应 A[m-1][n-1])。
  4. 重复步骤2和3,直到索引 ij 变为0。
  5. 当其中一个索引为0时,意味着一个字符串已为空。此时,只需在另一个字符串的剩余字符前插入相应数量的空位即可。

这个回溯过程非常高效,通常只需要 O(m + n) 的时间,远小于填充整个表格的 O(m * n) 时间。

总结

本节课中,我们一起学*了为序列比对问题设计动态规划算法的完整过程。我们从分析最优解的结构入手,定义了基于字符串前缀的子问题,并推导出了关键的递归关系。基于此,我们构建了一个运行时间为 O(m*n) 的算法来填充动态规划表格,计算最优比对的罚分。最后,我们还了解了如何通过回溯表格,在 O(m+n) 时间内重构出具体的最优比对序列。这个“定义子问题 -> 建立递归 -> 填充表格 -> 可选回溯”的框架,是解决许多动态规划问题的通用模式。

035:最优二叉搜索树问题定义

在本节课中,我们将学*动态规划范式的一个稍微复杂的应用,即计算最优二叉搜索树的问题。我们将探讨如何构建一个搜索树,使其在给定键值访问概率的情况下,最小化平均搜索时间。

二叉搜索树回顾

上一节我们介绍了动态规划的基本概念,本节中我们来看看它在搜索树问题上的具体应用。首先,我们需要回顾二叉搜索树的基础知识。

二叉搜索树是一种数据结构,其中每个节点包含一个对象,该对象有一个来自全序集合的键值。搜索树属性规定:对于树中的任意节点,假设其键值为 X,那么其左子树中所有节点的键值必须小于 X,其右子树中所有节点的键值必须大于 X。这个属性必须同时适用于树中的每一个节点。

搜索树属性的目的是使搜索操作像在有序数组中执行二分查找一样直观。例如,如果你要查找键值为 17 的对象,从根节点开始,根据根节点的键值决定向左还是向右递归搜索。

多种可能的搜索树

最初在讨论红黑树等平衡二叉搜索树时我们提到,对于一组给定的键值,存在许多不同的有效搜索树。为了理解这一点,我们假设世界上只有三个键值:XYZ,且 X < Y < Z

以下是几种可能的搜索树结构:

  • 一种明显的平衡树结构是将中间元素 Y 作为根节点,X 作为左子节点,Z 作为右子节点。
  • 此外,还存在两种链式搜索树:一种以最小的元素 X 为根节点,另一种以最大的元素 Z 为根节点。

鉴于存在多种可能的解决方案,一个自然的问题是:在所有可能性中,哪一种搜索树是最好的?

从平衡树到加权最优

如果你有似曾相识的感觉,那是因为我们在讨论红黑树时已经从一个角度提出并回答了这个问题。当时我们认为,最好的方法是保持二叉搜索树的平衡,使树的高度尽可能小,从而保证最坏情况下的搜索时间(与高度成正比)尽可能小,即与树中对象数量的对数成正比。

但现在,让我们做出与讨论霍夫曼编码时相同的假设:我们实际上拥有关于树中每个项目被搜索频率的准确统计数据。例如,我们可能知道项目 X 将被搜索 80% 的时间,而 YZ 各只被搜索 10% 的时间。在这种情况下,我们能否改进完全平衡的搜索树解决方案?

让我们通过比较两个候选方案来使这个问题更具体:

  1. 平衡树:以 Y 为根节点,XZ 作为子节点。
  2. 链式树:以 X 为根节点,Y 作为其右子节点,Z 作为 Y 的右子节点。

在这个例子中,节点 X 的搜索频率为 80%YZ 各为 10%。节点的搜索时间定义为从根节点到找到目标节点所经过的节点数量(包括目标节点本身)。例如,根节点的搜索时间为 1

计算两个树的加权搜索时间:

  • 平衡树:搜索 X 需经过 2 个节点(贡献 0.8 * 2 = 1.6),搜索 Y1 个节点(贡献 0.1),搜索 Z2 个节点(贡献 0.2)。总加权搜索时间为 1.9
  • 链式树:搜索 X1 个节点(贡献 0.8),搜索 Y2 个节点(贡献 0.2),搜索 Z3 个节点(贡献 0.3)。总加权搜索时间为 1.3

这个例子的寓意是,当访问频率不均匀时,明显“平衡”的解决方案未必是最优的。你可能希望使用不平衡的树(如链式树),让访问频率极高的项目更靠*根节点,从而减少其搜索时间。

问题正式定义

因此,我们面临的形式化问题是:给定一组项目和已知的访问频率,哪棵搜索树能最小化平均搜索时间?

我们被告知有 n 个对象需要存储在搜索树中,并且知道每个对象的访问频率。为了简化表示,我们假设项目按它们的键值从小到大命名为 1nP_i 表示搜索具有第 i 小键值的项目的频率。

你可能会问这些频率从何而来。这取决于具体的应用场景。有些应用可能没有这类统计数据,这时你可能需要转向通用的平衡二叉搜索树解决方案(如红黑树),以保证每次搜索都相对较快。但也不难想象存在能获得准确搜索频率统计的应用,例如拼写检查器。通过扫描文档,你可以估计不同单词被查询的频率,并利用这些估计构建高度优化的二叉搜索树。如果频率随时间变化,你可以定期(如每天或每周)根据最新统计数据重建搜索树。

无论如何,如果你有幸拥有此类统计数据,你的目标就是构建一棵既满足搜索树属性,又能使平均搜索时间尽可能小的搜索树。

加权搜索时间公式

让我们写下平均搜索时间的公式,并引入一些符号。我们用 C(T) 表示提议的搜索树 T 的平均搜索时间。在这些课程中,我们将专注于所有搜索都成功的情况(即只搜索树中存在的项目)。算法可以轻松扩展以容纳不成功的搜索及其频率统计,但目前我们只对树中存储的 n 个元素进行平均。

公式如下:
C(T) = Σ_{i=1}^{n} [ p_i * (search_time_T(i)) ]
其中,p_i 是搜索键值 i 的频率(或概率),search_time_T(i) 是在树 T 中查找键值 i 所需的搜索时间。

正如我们在测验中讨论的,在给定树 T 中查找给定键值 i 的搜索时间,就是你到达包含 i 的节点所需访问的节点数量。这恰好等于该节点在树中的深度加 1。例如,如果键值恰好在根节点,根节点的深度为 0,我们计其搜索时间为 1。因此,搜索时间 = 深度 + 1。

一个次要的细节是,为了便利,我们不要求 p_i 的和必须为 1。当然,如果 p_i 是概率,它们之和应为 1。但我允许它们是任意正数。因此,我有时将 C(T) 称为加权搜索时间,而不是平均搜索时间。不过,在思考时,你仍然可以将其视为 p_i 和为 1 的标准特例。

例如,在 p_i 是概率(和为 1)的情况下,我们总是可以将红黑树作为参考基准。但正如我们所看到的,当 p_i 不均匀时,通常可以做得更好。这正是本计算问题的意义所在:利用给定概率中的非均匀性,构建可能的最佳非平衡搜索树。

与霍夫曼编码的异同

我相信许多人已经注意到最优二叉搜索树这个计算问题,与我们之前在贪心算法部分已经解决的霍夫曼编码问题之间的相似性。霍夫曼编码在所有前缀无关的二进制编码中最小化平均编码长度。

让我们精确地理解两个问题之间的相似性和差异,特别是为什么我们不能直接重用已有的霍夫曼算法来解决最优二叉搜索树问题。

两个问题非常相似的地方在于输出的形式。在两个问题中,算法的职责都是输出一棵二叉树,并且目标都是最小化某种意义上的平均深度(或类似度量)。平均值是根据我们关心的一组对象(霍夫曼编码中是字母表中的字符,二叉搜索树中是一组具有全序键值的对象)的提供频率来计算的。

在最优二叉搜索树的情况下,我们平均的实际上是深度加一,但思考一下,这与平均深度本质上是相同的。

更重要的是理解霍夫曼编码所解决的问题与我们在此必须解决的计算问题之间的差异。

在霍夫曼编码的情况下,我们必须输出一个二进制编码,关键约束是它必须是前缀无关的。用树的语言来说,这意味着被编码的符号必须对应于输出树的叶子节点,符号不能对应于树的内部节点。

在最优二叉搜索树问题中,我们没有这种前缀无关的约束。因此,我们树的每个节点(无论是叶子还是内部节点)都将有一个标签(即一个对象)。但是,我们有一个不同的、看似更难的约束需要处理:搜索树属性。

回想一下,在霍夫曼编码的情况下,我们甚至没有对字母表的符号进行排序,没有哪个符号小于另一个的概念。在那个上下文中谈论搜索树属性是没有意义的。而在这里,我们被赋予了这些键值,并且它们存在全序关系,我们输出的树必须满足搜索树属性。也就是说,在我们输出的树中,对于每个节点,其左子树中的所有键值必须小于该节点的键值,其右子树中的所有键值必须大于该节点的键值。这是一个我们必须满足的约束。

这个约束更“难”,在于没有贪心算法(无论是霍夫曼算法还是其他)能够解决最优二叉搜索树问题。相反,我们必须转向更复杂的工具——动态规划,来设计一个计算最优二叉搜索树的高效算法。我们将在下一个视频中开始开发这个解决方案。

总结

本节课中,我们一起学*了最优二叉搜索树问题的定义。我们回顾了二叉搜索树的基本属性,理解了对于同一组键值存在多种有效结构。通过具体例子,我们认识到当键值访问频率不均匀时,传统的平衡树不一定能提供最小的平均搜索时间,从而引出了寻找最优二叉搜索树的需求。我们正式定义了问题的输入(带频率的键值集合)和优化目标(最小化加权搜索时间 C(T)),并比较了该问题与霍夫曼编码问题的核心异同,指出由于搜索树属性的约束,需要采用动态规划而非贪心算法来求解。下一节,我们将开始利用动态规划来构建解决此问题的算法。

036:最优子结构

在本节课中,我们将学*最优二叉搜索树问题,并探讨其最优子结构性质。这是设计动态规划算法的关键一步。


问题定义

首先,让我们回顾一下最优二叉搜索树问题的正式定义。

我们有 n 个对象需要存储在搜索树中。为简化起见,我们按它们的键值顺序将它们命名为 1, 2, 3, ..., n。同时,我们被赋予一组频率或权重 P1, P2, ..., Pn,这些正数反映了不同对象被搜索的频率。通常,这些概率之和为1,但算法本身不依赖于此,它们可以是任意正数。

我们的目标是输出一棵满足二叉搜索树性质的搜索树,它包含所有对象 1n。在所有这样的搜索树中,它应该最小化加权搜索时间。加权搜索时间的计算公式为:对每个对象 i,将其被搜索的概率 Pi 乘以其在树中的搜索时间(即深度 depth(i) 加1),然后对所有 i 求和。

公式表示:

最小化: Σ [ Pi * (depth(i) + 1) ], 其中 i 从 1 到 n

贪心算法为何失效

在上一节,我们介绍了最优前缀码问题,并成功用霍夫曼算法(一种贪心算法)解决了它。本节中我们来看看,对于最优二叉搜索树问题,贪心算法是否同样有效。

一个直观的想法是:我们希望访问频率高的对象靠*树根,频率低的对象位于树的底层(如叶子节点)。基于此,可以设计两种贪心策略:

  1. 自底向上策略:从最底层开始,将访问频率最低的对象作为叶子节点。这类似于霍夫曼算法的思路。
  2. 自顶向下策略:将访问频率最高的对象直接放在根节点,然后递归地为左右子树构造最优结构。

然而,这两种策略都是错误的。以下是两个简单的反例:

反例一(针对自底向上策略):
假设有四个对象 {1, 2, 3, 4},其访问频率分别为 2%, 23%, 73%, 1%。任何坚持将最低频率对象(1%)放在最底层的贪心算法,可能会产生左边的树(以2为根,4在深度2)。但实际上,最优的是右边的树(以3为根,2%的对象深度反而比1%的对象深),因为访问频率最高的对象(73%)被放在了根节点。

反例二(针对自顶向下策略):
使用同样的四个对象,但改变频率:{1%, 34%, 33%, 32%}。贪心算法会选择频率最高的对象2(34%)作为根节点,得到左边的树。但实际上,最优的是右边的平衡树(以3为根,2、3、4各占约三分之一),因为三个高频对象频率相*,平衡树能获得更小的平均搜索时间。

这些反例表明,简单的贪心规则无法解决此问题。问题的难点在于,选择根节点会对左右子树的结构产生难以预测的深远影响。


转向动态规划与递归思路

既然贪心算法行不通,我们转向动态规划。动态规划的核心是识别最优子结构:最优解是否由子问题的最优解构成?

对于二叉搜索树,其结构天然具有递归性。一个诱人的想法是:如果我们知道最优树的根节点 r 是什么,那么问题就简化为:

  • 为键值小于 r 的所有对象(即 1 ... r-1)构造一棵最优左子树。
  • 为键值大于 r 的所有对象(即 r+1 ... n)构造一棵最优右子树。

这听起来很熟悉,就像我们之前解决动态规划问题的思路:如果有一个“小提示”告诉我们解决方案的某个关键部分(这里是根节点),我们就可以通过组合子问题的最优解来轻松构建整体最优解。


最优子结构引理

为了将上述思路形式化,我们需要一个最优子结构引理。以下哪个陈述正确地描述了最优二叉搜索树的结构?

假设我们有一棵包含键值 1n 的最优二叉搜索树 T,其根节点为 r。令 T1 为左子树,T2 为右子树。那么:

A. T1T2 是二叉搜索树。
B. T1T2 是二叉搜索树,且对于它们各自包含的键值集合是最优的。
C. T1 是键值 1...r-1 的最优二叉搜索树,T2 是键值 r+1...n 的最优二叉搜索树。
D. T1 是键值 1...r-1 的最优二叉搜索树,T2 是键值 r+1...n 的最优二叉搜索树。

正确答案是 D。

解释如下:

  • 根据二叉搜索树的性质,左子树 T1 必须包含所有键值小于 r 的对象,即 1 ... r-1。右子树 T2 必须包含所有键值大于 r 的对象,即 r+1 ... n
  • 引理进一步断言,T1 不仅是 1...r-1 的一棵二叉搜索树,而且是最优的(即最小化其内部对象的加权搜索时间)。T2 同理。
  • 选项A和B不够强,选项C没有明确指出子树包含的确切键值集合。D是最精确、最强的陈述。

这个引理是整个动态规划算法能够工作的基石。如果它不成立,我们将无从下手。


引理证明概要(思路)

证明采用反证法,思路与我们之前证明其他最优子结构性质类似:

  1. 假设相反:假设 T 是最优树,但其左子树 T1 对于键值 1...r-1 不是最优的。那么,存在另一棵针对相同键值集的更优搜索树 T1‘(其加权搜索成本更低)。
  2. 构造新树:我们可以用 T1‘ 替换 T 中的左子树 T1,得到一棵新的搜索树 T‘。由于 T1‘T1 包含相同的键值集,且根节点 r 不变,T‘ 仍然是一棵合法的二叉搜索树。
  3. 成本分析:比较 T‘T 的总成本。
    • 对于右子树 T2 中的所有节点,以及根节点 r,它们在两棵树中的深度相同,因此贡献的成本相同。
    • 对于左子树中的节点,在 T‘ 中的成本严格低于在 T 中的成本(因为 T1‘ 更优)。
  4. 得出矛盾:因此,T‘ 的总成本严格小于 T 的总成本。这与 T 是最优树的假设矛盾。
  5. 结论:所以,最初的假设错误,T1 必须是最优的。同理可证 T2 也是最优的。

这个证明确认了我们的直觉:整体最优必然要求局部最优。


本节总结

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

  1. 最优二叉搜索树问题的正式定义:目标是构建一棵二叉搜索树,最小化给定访问频率下的加权搜索时间。
  2. 贪心算法的局限性:通过具体反例,我们看到了自顶向下和自底向上贪心策略在此问题上都会失败。
  3. 动态规划的关键思路:将问题递归分解,关键在于识别根节点。
  4. 最优子结构引理:一棵最优二叉搜索树的左右子树,必须分别是其对应键值范围(小于根和大于根)内的最优二叉搜索树。这是设计动态规划算法的理论基础。

在下一节中,我们将基于这个最优子结构性质,正式推导出动态规划的状态定义、递推关系,并最终构建出解决最优二叉搜索树问题的算法。

037:最优子结构证明

在本节课中,我们将学*最优二叉搜索树问题的一个关键性质:最优子结构。我们将通过反证法,详细证明如果一棵树对于所有键是最优的,那么它的左右子树也必然分别是其对应键集上的最优二叉搜索树。理解这个证明是后续设计动态规划算法的基础。

证明概述与假设

我们首先设定证明的场景。假设我们有一棵对于键 1n(对应频率为 P1Pn)的最优二叉搜索树 T。这棵树的根节点是某个键 r

我们想要证明的命题是:树 T 的左子树 T1(包含键 1r-1)必须是这些键上的最优二叉搜索树;同时,其右子树 T2(包含键 r+1n)也必须是这些键上的最优二叉搜索树。

我们将采用反证法来证明。这意味着我们假设上述命题不成立,然后推导出一个矛盾。

反证法的起点

如果命题不成立,那么至少对于其中一个子问题(1r-1r+1n),存在一棵具有更小加权搜索成本的二叉搜索树。我们假设左子树 T1 不是最优的来进行证明,右子树的情况完全对称。

因此,如果 T1 不是最优的,那么必然存在另一棵针对键 1r-1 的搜索树 T1*,其加权搜索成本比 T1 更低。

上一节我们建立了反证的前提,本节中我们将通过构造一棵新的全局树来引出矛盾。

构造更优的全局树

现在,我们通过“剪切-粘贴”的方式,用更优的子树 T1* 替换掉原树 T 中的左子树 T1,从而得到一棵新的全局树 T*

为了完成反证(从而证明最优子结构引理),我们只需要证明新树 T* 的加权搜索成本严格小于原树 T 的成本。这将与 T 是最优树的假设相矛盾。

接下来,我们将通过计算来清晰地展示这一点。

加权搜索成本的计算与分析

我们首先展开原最优树 T 的加权搜索成本的定义。对于树 T 中的每个键 i,其搜索成本是它的频率 P_i 乘以在 T 中搜索到它所需的深度(或访问节点数)。

计算的关键在于,我们希望将树 T 的总搜索成本用其左右子树 T1T2 的搜索成本来表达。这样我们就能轻松分析“剪切-粘贴”操作带来的影响。

我们将求和项按三个部分拆分:根节点 r、左子树 T1 中的键、右子树 T2 中的键。

  • 根节点 r:其贡献为 P_r * 1,因为根节点的深度为1。
  • 左子树中的键 (i < r):在树 T 中搜索这些键时,你需要先访问根节点 r(1次),然后再在子树 T1 中搜索。因此,对于 T1 中的键 i,其在 T 中的搜索成本等于 1 + (在 T1 中搜索 i 的成本)
  • 右子树中的键 (i > r):同理,其在 T 中的搜索成本等于 1 + (在 T2 中搜索 i 的成本)

基于以上分析,我们可以将 T 的总加权搜索成本 C(T) 重写如下:

C(T) = P_r * 1 
       + Σ_{i=1}^{r-1} [ P_i * (1 + cost_in_T1(i)) ] 
       + Σ_{i=r+1}^{n} [ P_i * (1 + cost_in_T2(i)) ]

接下来,我们展开并合并同类项:

C(T) = P_r 
       + Σ_{i=1}^{r-1} P_i + Σ_{i=1}^{r-1} [ P_i * cost_in_T1(i) ] 
       + Σ_{i=r+1}^{n} P_i + Σ_{i=r+1}^{n} [ P_i * cost_in_T2(i) ]

现在,让我们审视这四项:

  1. P_r + Σ_{i=1}^{r-1} P_i + Σ_{i=r+1}^{n} P_i:这其实就是所有键的频率之和 Σ_{i=1}^{n} P_i。这是一个常数,与树的结构无关。
  2. Σ_{i=1}^{r-1} [ P_i * cost_in_T1(i) ]:这正是左子树 T1 的加权搜索成本 C(T1)
  3. Σ_{i=r+1}^{n} [ P_i * cost_in_T2(i) ]:这正是右子树 T2 的加权搜索成本 C(T2)

因此,我们得到了一个关键公式:

C(T) = 常数 + C(T1) + C(T2)

这个推导虽然始于最优树 T,但其代数过程适用于任何二叉搜索树。对于任何树,其总成本都可以表示为常数加上其左右子树的成本。

完成矛盾推导

现在将这个推理应用到我们通过剪切粘贴得到的新树 T* 上。T* 的根节点同样是 r,其左子树是 T1*,右子树与 T 相同,是 T2。因此,它的成本为:

C(T*) = 常数 + C(T1*) + C(T2)

回顾我们的假设:T1* 是比 T1 更优的树,即 C(T1*) < C(T1)。由于常数项和 C(T2)C(T)C(T*) 中是相同的,我们可以立即得出:

C(T*) = 常数 + C(T1*) + C(T2)
      < 常数 + C(T1) + C(T2)
      = C(T)

这证明了 C(T*) < C(T)。但我们最初假设 T 是针对所有键 1n最优二叉搜索树,这意味着不可能存在成本比它更低的树。T* 的存在与此假设矛盾。

因此,我们最初的假设(T1 不是最优的)是错误的。同理可证 T2 也必须是最优的。这就完成了最优二叉搜索树具有最优子结构性质的证明。

总结

本节课中我们一起学*了最优二叉搜索树最优子结构性质的完整证明。我们通过反证法,假设最优树的子树不是最优的,然后构造出一棵全局成本更低的树,从而引出矛盾。证明的核心步骤是将全局树的搜索成本分解为常数项与其左右子树成本之和。这个性质是后续应用动态规划高效求解最优二叉搜索树问题的基石。

038:-38-_ 动态规划算法 1

📖 概述

在本节课中,我们将学*如何为最优二叉搜索树问题设计一个多项式时间的动态规划算法。我们将从回顾最优子结构引理开始,识别出所有相关的子问题,并最终形式化一个递推关系,从而系统地计算出所有子问题的最优解。

🔍 回顾最优子结构引理

上一节我们介绍了最优二叉搜索树问题的最优子结构。现在我们来快速回顾一下我们在上一个视频中证明的引理。

假设我们有一个针对给定键集合1到n及其概率的最优二叉搜索树,并且这个最优二叉搜索树的根节点是R。那么,根据二叉搜索树的性质,它有两个子树T1和T2。我们知道这两个子树各自包含的确切键集合:T1必须包含键1到R-1(我们通常假设键是按排序顺序排列的),而右子树T2必须包含键R+1到n。此外,T1和T2本身分别是这两个键集合的有效搜索树。最后,我们在上一个视频中证明,它们对于各自的子问题是最优的:T1对于键1到R-1及其对应的权重或概率是最优的,T2对于键R+1到n及其对应的频率是最优的。

🧩 识别相关子问题

现在我们已经理解了最优解必须由更小子问题的解以简单方式构成。让我们退一步思考:既然我们最终关心的是原始问题的最优解,那么哪些子问题是相关的?哪些子问题是我们在求解过程中必须解决的?

例如,在线图的独立集问题中,我们观察到,要解决一个子问题,我们需要知道从右侧移除一个或两个顶点后得到的子问题的答案。因此,我们最终关心的是对应于图前缀的所有子问题。在背包问题中,我们需要理解涉及少一个物品和可能减少的剩余背包容量的子问题,这导致我们关心对应于所有物品前缀和所有整数剩余背包容量的子问题的解。在序列比对中,当我们查看子问题时,我们是从一个或两个字符串中移除一个字符,因此我们关心对应于两个字符串各自前缀的子问题。

现在,二叉搜索树问题的一个有趣之处在于,当我们查看最优子结构引理中的子问题时,我们可能会考虑两个子问题,而不仅仅是从右侧移除。我们既关心由左子树诱导的子问题,也关心由右子树诱导的子问题。在第一种情况下,我们查看的是起始物品的一个前缀,这类似于我们在许多例子中看到的情况。但在第二种情况下,对应于子树T2的子问题实际上是我们起始物品的一个后缀。换句话说,我们关心的子问题是通过丢弃起始物品的一个前缀或一个后缀而得到的。

基于最优解的值仅取决于通过丢弃物品前缀或后缀得到的子问题这一观察,请思考以下问题:对于原始物品1到n的哪些子集S,计算仅包含S中物品的最优二叉搜索树的值是重要的?

在解释正确答案(第三个选项)之前,让我先谈谈一个非常自然但不正确的答案,即第二个选项。确实,第二个答案似乎与最优子结构引理有最好的对应关系。最优子结构引理指出,最优解必须由某个前缀的最优解和某个后缀的最优解在一个共同的根R下联合构成。因此,我们肯定关心所有物品前缀和后缀的解,但我们关心的不仅仅是这些。

也许理解这一点最简单的方法是考虑最优子结构引理的递归应用。最终,相关的子问题将对应于在整个递归实现过程中解决的所有不同子问题。让我们考虑递归树中的一个示例路径。在最顶层的递归中,你有整个物品集,假设有100个物品1到100。你正在尝试所有可能的根节点。在某个时刻,你尝试根节点23,看看它的效果如何。你必须递归地调用一次,为物品1到22最优地构建一个搜索树,同样地为物品24到100构建一个搜索树。现在,让我们深入到这个第一个递归调用中,你递归地处理物品1到22。在这里,你再次尝试所有可能的根节点,有22个选择。在某个时刻,你会尝试根节点17,同样会有两个递归调用。第二个递归调用将针对物品18到22,这是传递给这个递归调用的物品(原始物品的一个前缀)的一个后缀。因此,在这个例子中,物品18到22是原始前缀1到22的一个后缀。总的来说,当你思考这个递归的多个层级时,每一步你都在做的是要么从开头删除一块物品(一个前缀),要么从末尾删除一块物品(一个后缀),但你可能会交错进行这两种操作。因此,你并不总是拥有原始物品集的一个前缀或后缀,但正确的是,你将拥有一些连续的物品集合。如果你的子问题中最小的物品是i,最大的物品是j,那么你将拥有介于i和j之间的所有物品。这是因为你只从左侧或右侧移除物品。这就是为什么C是正确答案,你需要比仅仅前缀和后缀更多的子问题。

📝 形式化递推关系

好的,识别相关子问题有点棘手,但现在我们已经掌握了它们,动态规划算法将像往常一样水到渠成。相关的子问题集合以一种非常机械的方式解锁了整个范式的力量。现在让我们来填写所有细节。

第一步是形式化递推关系,即给定子问题的最优解如何依赖于更小子问题的值。这将是一个数学公式,编码了我们在最优子结构引理中已经证明的内容。然后,我们将使用这个公式在动态规划算法中填充一个表格,系统地求解所有子问题的值。

让我们引入一些符号来放入我们的递推公式中。

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

对于给定的i和j的选择(当然i应该小于等于j),我将用大写C_ij表示仅包含从i到j的连续物品集合的最优二叉搜索树的加权搜索成本。当然,权重或概率与原始问题完全相同,它们只是在这里被继承下来,即p_i到p_j。

现在让我们陈述递推关系。对于给定的子问题C_ij,我们将根据更小子问题的最优解来表达最优二叉搜索树的值。最优子结构引理告诉我们如何做到这一点。

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

因此,我们选择某个根节点r,介于i和j之间(包含)。给定根节点r的选择,我们将继承仅包含物品i到r-1这个前缀的最优解的加权搜索成本,用我们的符号表示就是C(i, r-1)。同样地,我们获取包含物品r+1到j这个后缀的最优解的加权搜索成本。如果你回顾我们对最优子结构引理的证明,你会看到我们进行了一个计算,给出了树的加权搜索成本如何依赖于其子树的加权搜索成本的公式。除了两个搜索树各自贡献的加权搜索成本外,我们还加上一个常数,即我们正在处理的物品中所有概率的总和。在这里,这个总和是p_k的和,其中k的范围从该子问题的第一个物品i到最后一个物品j。

我们需要处理的一个额外边界情况是:如果我们选择根节点为第一个物品i,那么第一个递归项就没有意义,我们将得到C(i, i-1),这是未定义的。同样地,如果我们选择根节点为j,那么最后一项将是C(j+1, j),这也是未定义的。记住,索引应该是按顺序的。因此,在这种情况下,我们只需将这些大写C解释为0。

为什么这个递推关系是正确的?所有繁重的工作都在我们证明最优子结构引理时完成了。我们在那里证明了什么?我们证明了最优解必须是j-i+1种可能情况之一,它只取决于根节点的选择。给定根节点,其余部分就为我们确定了。递推关系通过定义对唯一的候选集合进行暴力搜索,因此它确实是基于更小子问题最优解来表达最优解值的正确公式。

🎯 总结

本节课中,我们一起学*了如何为最优二叉搜索树问题构建动态规划算法。我们首先回顾了最优子结构引理,理解了最优解如何由更小子问题的解构成。接着,我们识别出所有相关的子问题,即所有连续的物品区间。最后,我们形式化了一个递推关系,该关系通过尝试所有可能的根节点并组合更小子问题的最优解,来系统地计算任意连续物品区间的最优二叉搜索树成本。这为我们下一步实现具体的动态规划算法奠定了坚实的基础。

039:-39-_ 动态规划算法 2

在本节课中,我们将学*如何为最优二叉搜索树问题实现一个动态规划算法。我们将从理解递推公式开始,然后系统地解决子问题,并分析算法的时间复杂度。最后,我们会简要提及一个可以显著提升算法性能的优化方法。

概述

上一节我们推导出了求解最优二叉搜索树价值的递推公式。本节中,我们将基于这个“魔法公式”来构建一个具体的动态规划算法。我们将定义子问题,确定求解顺序,并用代码描述算法流程。同时,我们也会分析其时间复杂度,并了解一个可以将其从立方级优化到平方级的“有趣事实”。

系统求解子问题

既然我们已经有了递推公式,剩下的工作就是系统地求解子问题。和往常一样,以正确的顺序(从小到大)求解子问题至关重要

在最优二叉搜索树问题中,衡量子问题大小的自然方式是看子问题中包含的项目数量。对于一个从索引 i 开始到索引 j 结束的子问题,其大小为 j - i + 1。我们将以此作为子问题大小的度量标准。

构建动态规划数组

接下来,我们请出我们信赖的数组。这个数组的维度将是二维的,因为子问题有两个自由度:连续区间的起始索引结束索引

外层 for 循环用于控制子问题的大小。它确保我们在处理更大的子问题之前,先解决所有更小的子问题。具体来说,我们将使用一个索引 s。在外层循环的每次迭代中,无论 s 的当前值是多少,我们只考虑大小为 s + 1 的子问题。你可以将 s 理解为较大索引 j 与较小索引 i 之间的差值:s = j - i

内层 for 循环控制我们所考察的连续区间的第一个项目,也就是 i

现在,我们要做的就是根据数组条目重写递推公式,并使用变量替换,其中 s 对应 j - i

对于一个给定的子问题,起始于项目 i,结束于项目 i + s,我们通过暴力枚举来选取最佳根节点。根节点 r 将位于 ii + s 之间。无论选择哪个根节点,我们都会加上一个常数项,即所有概率 p_k 的和,其中 k 的范围是从第一个项目 i 到最后一个项目 i + s。然后,我们还要查看两个相关子问题(一个从 i 开始到 r-1 结束,另一个从 r+1 开始到 i+s 结束)的先前计算出的最优解值。

以下是该递推关系的伪代码描述:

# 假设 A 是一个二维数组,A[i][j] 存储从 i 到 j 的最优解值
# 假设 prob_sum(i, j) 能快速返回从 i 到 j 的概率之和
for s in range(0, n-1):          # 子问题大小控制
    for i in range(1, n-s+1):    # 起始位置控制
        j = i + s
        best = INFINITY
        constant = prob_sum(i, j)
        for r in range(i, j+1):  # 枚举所有可能的根节点
            left_cost = A[i][r-1] if r > i else 0
            right_cost = A[r+1][j] if r < j else 0
            total_cost = constant + left_cost + right_cost
            if total_cost < best:
                best = total_cost
        A[i][j] = best

关于上述公式右侧的两个数组查找,有两点需要说明:

  1. 如果我们选择第一个项目 i 作为根节点,那么第一个查找(左子树)就没有意义;如果选择最后一个项目作为根节点,第二个查找(右子树)就没有意义。在这种情况下,我们应将这些查找结果理解为 0。在实际实现中,你需要包含处理这些边界情况的代码。
  2. 第二个说明是我们通常的“健全性检查”。当你编写动态规划算法时,在写下填充数组条目的公式后,务必确保公式右侧进行的任何数组查找,其对应的子问题确实已经被计算过并可供常量时间查找。在本算法中,无论我们选择哪个根节点,两个相关的子问题所涉及的项目数都严格少于原始问题。因此,右侧的两个子问题查找值肯定已经在外层 for 循环的某次更早的迭代中被计算出来了。外层循环保证了我们从项目数最少的子问题开始,逐步求解到项目数最多的子问题。

当然,在两个 for 循环完成后,我们真正关心的是 A[1][n] 中的答案,即所有项目的最优二叉搜索树价值,这也是最终的输出。

算法执行的可视化理解

有些学生喜欢从图形角度理解这些双重循环。我们可以将二维数组 A 想象成一个网格。

  • X 轴 对应索引 i,即我们正在考察的项目集合的第一个项目。
  • Y 轴 对应索引 j,即当前集合的最后一个项目。

让我们标出这个网格的对角线。这些是 i = j 的子问题,即只包含单个元素的子问题。

我们只解决 j 至少与 i 一样大的问题,这意味着我们实际上只填充表格的左上角(西北部分)。我们从不费心去填充表格的右下角(东南部分),直接将其视为 0

在动态规划算法的第一次外层迭代中(即 s = 0 时),算法会依次解决 n 个单项目子问题。因此,在内层 for 循环的第一次迭代中,它将解决子问题 A[1][1],下一次迭代解决 A[2][2],然后是 A[3][3],依此类推。在每种情况下,两个数组查找都对应于 0,我们只是用基本情况(A[i][i] 就是项目 i 的概率)填充这条对角线。

随着动态规划算法的进行,我们将按对角线填充表格的左上角部分。每次我们增加外层 for 循环中的索引 s,我们就向上移动到下一个最西北的对角线。然后,当我们遍历所有可能的 i 值时,我们将逐个填充该对角线,从西南向东北移动。

当我们在其中一条对角线上填充一个子问题的值时,我们只需要查找位于更低对角线上的两个子问题的值。更低的对角线对应于项目数严格更少的子问题。

算法正确性与时间复杂度分析

以上就是计算给定一组项目及其概率的最优二叉搜索树价值的动态规划算法。关于正确性,其逻辑与我们过去所见相同:所有的繁重工作都在于证明最优子结构引理,该引理保证了我们递推公式的正确性。既然我们的“魔法公式”是正确的,并且我们只是系统地应用它,那么动态规划算法的正确性就可以通过归纳法直接得出。

然而,让我们对算法的运行时间做一些分析。

我们遵循通常的步骤:先看需要解决多少个子问题,然后看解决每个子问题需要做多少工作。

  • 子问题数量:所有可能的 ij 的组合,其中 i <= j。换句话说,这大致是那个 n x n 网格的一半,所以大约是 n^2 / 2,我们称其为 Θ(n^2),即平方数量级的子问题。

    子问题数量 = Θ(n^2)

  • 每个子问题的工作量:对于每个子问题,我们必须评估这个递推公式。从概念上讲,这是对我们已识别出的候选解进行暴力搜索。这个动态规划算法与我们最*看到的其他算法(序列对齐、背包问题、线图中的独立集)之间的一个区别在于,最优解的可能选项实际上相当多。这是我们第一次遇到的暴力搜索不是仅仅在常数个可能性中进行的情况。我们必须尝试每一个可能的根节点。我们给定子问题中的每个项目都是一个候选根节点,我们要尝试所有可能。

    给定起始项目 i 和结束项目 j,总共有 j - i + 1 个项目,我们必须为每个选择做常数工作。

    每个子问题的工作量(最坏情况) = O(j - i + 1) = O(n)

因此,有些子问题(当 ij 非常接*时)我们可以快速评估,只需常数时间。但对于我们需要处理的大部分子问题(常数比例),这将需要线性时间 Θ(n)。总的来说,这给了我们一个立方级的运行时间 Θ(n^3)

`总运行时间 = Θ(n^2) * O(n) = O(n^3)`

性能评估与优化前景

我认为这个运行时间“还算可以,但不算出色”。它是多项式时间,这很好。它当然比枚举所有指数级数量的可能二叉搜索树要快得多,因此它完胜暴力搜索。但我不会称其为“极快”或“免费原语”之类的东西。你能够处理 n 在几百数量级的问题规模,但可能无法处理 n 在几千数量级的问题。这将覆盖一些你想使用此最优二叉搜索树算法的应用场景,但不是全部。所以它对某些事情有好处,但并非通用解决方案。

另一方面,这里有一个有趣的事实 😊:你实际上可以显著加速这个动态规划算法。

你可以保持完全相同的二维数组和完全相同的语义(每个索引仍然对应于项目 ij 之间的最优二叉搜索树),但你实际上可以只用总共 n^2 的时间(即平均每个子问题常数工作量)来填充整个表格的所有 n^2 个条目。

这个有趣的事实非常巧妙,肯定比我们在这个视频中讨论的内容更复杂,但并非不可能理解。如果你感兴趣,我鼓励你回头查阅原始论文或在网上搜索关于这个动态规划算法优化加速版本的其他资源。从一个非常高的层面(3万英尺视角)来看,其目标是避免在每个子问题中都对所有可能的根节点进行这种暴力搜索。事实证明,最优二叉搜索树问题中存在良好的结构,允许你利用在更小子问题中已完成的工作。在更小的子问题中,你已经搜索了一堆候选根节点,利用这些先前暴力搜索的结果,你可以推断出当前根节点集合中哪些子集可能决定递推关系。这让你可以避免搜索所有可能的候选根节点,而只关注一个非常小的集合(事实上,平均而言,在所有子问题上平均只需考察常数个可能的根节点)。不用说,这将运行时间从立方级加速到平方级,显著增加了你现在可以应用此算法的问题规模。现在,你不再局限于几百的规模,而肯定能够解决几千规模的问题,甚至可能使用这个平方时间算法解决十万规模的问题。非常酷!

总结

本节课中,我们一起学*了如何为最优二叉搜索树问题实现一个动态规划算法。我们从递推公式出发,定义了二维状态数组,并通过双重循环按子问题规模从小到大的顺序系统地求解。我们分析了该算法具有 Θ(n^3) 的时间复杂度。最后,我们了解了一个重要的优化方向,通过利用问题本身的结构,可以将算法时间复杂度降低到 Θ(n^2),从而能够处理更大规模的问题。

040:单源最短路径问题回顾

在本节课中,我们将重新审视单源最短路径问题。这是一个我们已经使用迪杰斯特拉贪心算法解决过的问题。现在,让我们看看动态规划范式能为解决同一问题带来什么。

问题定义回顾

首先,快速回顾一下问题的定义:输入是什么,输出是什么?

我们被给定一个有向图。当我们讨论最短路径时,我们只讨论有向图。图的每条边都有一个长度,我们用 C_e 表示。一个顶点,我们用小写 s 表示,被指定为源顶点。我们假设图是简单的,即没有平行边。原因与最小生成树问题相同:如果我们有一堆平行边,由于我们只关心最短路径,可以只保留长度最小的那条边,丢弃其他副本。

该问题算法的职责是计算从源顶点 s 到每个其他可能目的地 v 的最短路径长度。当我们谈论路径长度时,我们总是指该路径中所有边的成本之和。例如,如果每条边的成本都是1,那么我们讨论的就是跳数,这个问题可以通过广度优先搜索解决。但在单源最短路径问题中,我们通常关心的是长度可能差异很大的边。

现有方案回顾:迪杰斯特拉算法

现在,回顾我们对该问题的现有解决方案——迪杰斯特拉算法,并审视其优缺点。

迪杰斯特拉算法是一个优秀的算法,前提是图能放入计算机主内存,并且所有边成本非负。如果使用堆来实现迪杰斯特拉算法,你将得到一个非常快速且始终正确的最短路径算法。

在本课程的第一部分,我们解释了使用堆的实现,其运行时间为 O(m log n),其中 m 是边数,n 是顶点数。这个实现与我们在本课程前面讨论的用于计算最小生成树的普里姆算法实现几乎相同。理论上,如果使用更奇特的堆数据结构,可以获得更好的渐*运行时间,即 O(m + n log n)。但目前,我们只需知道对于边长为非负的图,使用迪杰斯特拉算法的时间复杂度是 O(m log n)

迪杰斯特拉算法的局限性

既然迪杰斯特拉算法如此优秀,我们为何要拒绝它并研究其他最短路径算法呢?主要有两个缺点,让我们现在重申一下。

首先,如果你处理的图中某些边成本可能为负,那么迪杰斯特拉算法的输出可能不正确。其正确性依赖于边成本非负的假设。我们在课程第一部分以及本课程刚开始讨论贪心算法及其普遍的不正确性时,都看到了具体的例子。

坦率地说,对于许多最短路径算法的应用,你永远不会遇到负边。例如,如果你正在实现一个计算驾驶路线的程序,无论你使用里程还是时间,都不会有负长度的边,至少在我们最终发明出时间旅行机器之前是这样。

但并非所有最短路径问题的应用都如此具体,涉及从点A到点B的实际空间路径计算。更抽象地说,这个问题只是帮助你找到一个最优的决策序列。想象一下,你正在管理一堆金融资产,例如,你将单笔交易建模为网络中的一条边,当你卖出东西时,可能会产生正收益,对应正的边长;但当你买入东西时,当然需要花钱,这可能对应负的边长。

第二个缺点,我们在本课程的介绍视频中提到过,是迪杰斯特拉算法似乎是高度集中式的。如果你只是将整个图存储在一台机器的主内存中,这不是问题。但如果你谈论的是互联网路由,让一个路由器拥有整个互联网的最新完整地图以本地计算路由是完全不切实际的。这促使我们寻找一种更适合分布式路由的不同最短路径算法。

引入贝尔曼-福特算法

我们将能够通过贝尔曼-福特算法一举解决这两个缺点,这将是动态规划算法设计范式的又一个实例。

尽管贝尔曼-福特算法比最早的阿帕网版本早了整整十年,但它仍然是现代互联网路由协议的基础。当然,从抽象的贝尔曼-福特算法到实用的互联网路由解决方案,还需要经过许多工程步骤,我们将在单独的视频中稍作讨论,但这确实是这一切的起点。

算法开发前的预备知识

在开始开发贝尔曼-福特算法之前,我们需要处理一些微妙的预备知识。我们需要明确在存在负边成本,特别是存在负成本环(即边成本之和小于零的有向环)的情况下,如何定义最短路径。

例如,可以想想我在右边画的这个绿色示意图,图中某处嵌入了一个有四个边的有向环。环中的一些边具有负成本,一些具有正成本,但总体上该环的总成本为负(遍历所有四条边后成本为-2)。

让我们思考一下,在这样的图中,特别是从源顶点 s 到某个目的地 v 的最短路径应该如何定义?我们是否允许路径中包含环?

首先,思考允许路径包含环的后果。这个提议实际上没有意义,因为如果允许遍历环,通常从 sv 将不存在最短路径。原因是,如果你有一个像这个绿色图中总成本为-2的负环,并且你实际上可以从源 s 到达它,那么对于每一条路径,你实际上可以找到另一条更短的路径:你只需再遍历一次有向环,整个路径长度就会再减少-2,没有什么能阻止你一遍又一遍地这样做。因此,最短路径要么可以认为是未定义的,要么可以认为在极限情况下是负无穷。

禁止环的路径定义

如果允许路径包含环行不通,为什么不探索禁止路径中包含环的选项呢?这个版本的问题是完全明确定义的。它是一个完全合理的计算问题,你可能很想解决。

但这里的问题要微妙得多。问题是,这个问题被称为 NP完全问题。这是我们下周将更多讨论的内容,但底线是,这些都是难处理的问题。对于NP完全问题,没有已知的计算高效、多项式时间的算法。不幸的是,这就是其中一个问题:在存在负环的情况下计算最短的无环路径。

更准确地说,如果你提出一个保证正确且保证多项式时间的算法,总是能在存在负环的情况下计算最短路径,其后果将是所谓的 P = NP。我们将在后面的视频中更正式地讨论这一点,但如果你提出了这样的算法,你当然应该立即向克莱数学研究所报告,他们将有一百万美元的奖金等着你。

对于那些已经熟悉NP完全性的人来说,证明这个问题是NP难的将是一个简单的练*,只需从哈密顿路径问题归约即可。

折中方案:假设无负环

看来我们陷入了两难境地。如果允许路径中包含环,我们得不到一个有意义的、合理的问题。如果排除环,我们得到一个完全有意义但不幸的是计算上难处理的问题。

以下是我们的处理方式。目前,我们将只关注那些没有负环的图。我们当然允许单个边为负,但我们将暂时坚持输入图的每个有向环的总长度都是非负的。它可以有一些正边成本和一些负边成本,但总体上必须是非负的。

如果这个假设让你感到困扰,我并不怪你。但好消息是,正如我们将看到的,贝尔曼-福特算法可以轻松检查这个条件是否成立。如果输入图中存在负环,它能轻松检测出来。因此,贝尔曼-福特算法将要解决的最短路径问题版本如下:给定一个可能具有负边成本的输入图(可能包含也可能不包含负环),贝尔曼-福特算法要么正确计算从源点到所有目的地的最短路径(正如你所期望的),要么它会“放弃”,但会提供一个充分的理由说明它为何放弃——它会向你展示输入图中的一个负环。当然,在存在负环的情况下计算最短路径是难处理的,所以在这种情况下,贝尔曼-福特算法可以被“放过一马”。因此,对于给定的输入图,它要么展示一个负环,要么给出所有所需的最短路径距离。这就是我们将要努力实现的保证。

理解无负环假设的用处

我将以一个小测验结束本视频,帮助你理解为什么无负环这个假设可能对算法有用。现在,我只想让你思考:假设我向你保证输入图没有负环,问题是,需要多少跳(多少条边)才能保证你找到了 s 到某个给定目的地 v 之间的最短路径?

正确答案是 a。这是无负环假设有用的主要原因之一。它让你能够控制需要多少条边才能确保你找到了一条最短路径。

具体来说,假设你有一条从某个 s 到某个 v 的路径,它至少有 n 条边。如果你至少有 n 条边,意味着该路径访问了至少 n+1 个顶点。但总共只有 n 个顶点。所以,如果你访问了至少 n+1 个顶点,意味着你访问了某个顶点两次,比如 x。这意味着你的路径内部有一个从 x 回到 x 的环。现在,如果你剔除这个环,即删除那些边,你会得到另一条从 sv 的路径。并且因为这个有向环(像所有环一样)必须是非负的,通过移除这个环,总长度(边成本之和)只会减少。所以,你给我看一条从 sv 的、至少有 n 条边的路径,我就能给你看一条边数更少、并且至少一样短(可能更短)的路径。这表明,最短路径(或一条最短路径)最多有 n-1 条边,这就是测验的正确答案。

本节总结

在本节课中,我们一起回顾了单源最短路径问题及其经典解法迪杰斯特拉算法的局限性,特别是它对非负边长的要求和集中式处理的特性。接着,我们引入了贝尔曼-福特算法作为解决这些局限性的动态规划方案。我们深入探讨了在存在负边或负环时定义最短路径的复杂性,并最终确立了贝尔曼-福特算法将要解决的问题框架:对于可能含有负边但无负环的图,正确计算最短路径;若存在负环,则能有效检测并报告。最后,我们通过一个测验理解了“无负环”假设的关键作用——它保证了最短路径的边数不会超过 n-1,为后续算法的设计奠定了基础。

041:最优子结构

在本节课中,我们将开始开发贝尔曼-福特算法,作为动态规划范式的一个实例。我们将按照通常的方式,通过理解最优解如何必然由更小子问题的最优解构成来开发它。

首先,让我们快速回顾一下上一视频中关于单源最短路径问题输入图可能包含负边权时的微妙之处。

问题定义与目标

输入是一个有向图,每条边都有一个边权 C(e)。我们允许这些边权为负,并且给定一个源顶点 s

我们的目标是计算从源点 s 到所有其他目标顶点 v 的最短路径距离。路径的长度定义为路径上所有边权的总和。

上一视频我们讨论过,当输入图包含负权环时,这个目标是有问题的。如果允许路径包含环,那么最短路径长度可能未定义(为负无穷大)。如果不允许环,那么计算最短路径在计算上是难处理的(NP难问题)。因此,如果算法无法计算最短路径,它至少应该输出一个负权环作为失败的原因。

在本视频中,我将首先为不包含负权环的输入图开发贝尔曼-福特算法。当然,在这些情况下,算法应该输出正确的最短路径。一旦我们为无负环图建立了贝尔曼-福特算法,我们将看到将其扩展到一般情况(包含负环的图)是相当容易的,并且能在不影响基本算法运行时间的情况下输出这样一个环。

动态规划与最优子结构

着眼于为最短路径问题设计动态规划算法,让我们开始思考最优子结构,即最短路径如何必然由更小子问题(更短路径)的最优解构成。

形式化的最优子结构引理陈述起来会有点繁琐。让我先花一页幻灯片来告诉你如何思考这个问题。

将动态规划范式应用于图问题通常很棘手。原因之一是图本质上不是顺序对象,没有明显的顺序。我们只是给定一个无序的顶点集和一个无序的边集。当然,一个特例是路径图,这是我们动态规划的第一个例子,在那里图的顶点有明显的顺序。但不像序列比对那样有明显的从左到右的顺序,在图问题中如何排序并不明确。

然而,对于这个特定的图问题,我们的输出(路径)确实是顺序对象,这给了我们希望。我们可以陈述并证明一个最优子结构引理,讨论最优解(最短路径)如何必须以某种方式由“更小”的最短路径构建而成。

不幸的是,如何正确定义“更小”和“更大”的子问题仍然远非显而易见。例如,你希望有一个智能的顺序来处理可能的目标顶点 v,但如果不首先知道最短路径距离,如何做到这一点并不清楚。这是一个微妙的问题,我鼓励你在私下里认真思考,以便更好地理解贝尔曼-福特解决方案的非平凡之处。

贝尔曼-福特算法的关键思想

贝尔曼-福特算法的一个关键且很好的想法是引入一个额外的参数,为我们提供子问题大小的明确定义。对于给定的目标顶点 v,这个参数将控制我们允许在从源点 s 到目标 v 的路径中使用多少条边。

为了解释这一点,让我们看幻灯片右侧这个有五个顶点的绿色图。

在贝尔曼-福特算法中,我们将为每个可能的目标顶点和每个可能的路径边数限制定义一个子问题。例如,假设我们看源点 s 和目标 t,并且我们考虑只有两条边或更少的路径。那么在这个图中,从 st 的、受此约束的最短路径长度为 4。底部那条有三条边的路径不是选项,因为当前子问题只允许两条边或更少。如果我们将边预算增加到 3,那么对应的最短路径距离将从 4 下降到 3,因为突然我们可以使用底部的三条边路径。

这里的重点是,它为我们提供了一个明确的子问题大小概念:你被允许在从源点到给定目标的路径中使用的边越多,那个子问题就“越大”。

形式化最优子结构引理

现在,我们将在完全一般性的情况下陈述并证明这个最优子结构引理。我们将处理任意的输入图,它们可能包含负环,也可能不包含。

像所有最优子结构引理一样,其陈述形式是:子问题的最优解必须是少数候选解之一,这些候选解以简单的方式由更小子问题的最优解构成。

那么如何索引一个给定的子问题呢?将有一个我们关心的目标顶点 v。正如上一张幻灯片所说,将有一个预算 i,限制在从 sv 的路径中允许使用的边数。我们将用 i 表示这个预算。

因此,对于每个可能的目标顶点 v 和每个可能的边预算 ii 是一个正整数,1 或更大),都有一个子问题。

假设 P 是一个最优解,即在所有从 s 开始、到 v 结束、且最多包含 i 条边的路径中,P 具有最小的边权和(最小长度)。

一个微妙之处是,因为我们是在完全一般性(即输入图可能包含负环)下证明这个引理,我们需要允许路径 P 使用环,包括可能多次使用负环。注意,我们不担心这条路径无限次使用环,因为它有一个有限的边预算 i

有了这个设定,P 可能是什么候选解呢?我们将有两种情况。

情况 1:路径未用尽边预算
如果路径 P 没有用尽其全部的边预算,即如果它只有 i-1 条边或更少,那么自然地,P 必须是那个从 sv 的、最多有 i-1 条边的最短路径。

情况 2:路径用尽了边预算
非平凡的情况是,当从 sv 的、最多有 i 条边的最短路径实际上用尽了其全部预算,使用了所有 i 条边。

类比我们之前所有的动态规划算法,我们考虑从最优解中剥离最后一部分。这里,我们将从路径 P 中剥离最后一条边。

我们得到了什么?我们得到了一条少一条边的路径。这很好,它将对应某个更小的子问题,因为它最多有 i-1 条边。另一方面,请注意,如果我们从一条从 sv 的路径中剥离最后一条边,我们得到一条从 s 到其他某个顶点(我们称之为 w)的路径。

因此,从 P 剥离最后一条边得到一条路径 P',它从 s 开始,到 w 结束,最多有 i-1 条边。我希望你能猜到,我们的主张是:P' 不仅仅是任意一条从 sw 的、最多有 i-1 条边的路径,它实际上是这样一条最短路径。

在这种情况下,我们当然知道 P' 恰好有 i-1 条边,而不仅仅是至多 i-1 条边。但这里声称的更强断言(P' 在所有最多有 i-1 条边的路径中是最优的)将是有用的。

引理证明

这个引理的陈述比证明更难。让我们快速讨论一下证明过程,它与我们之前看到的最优子结构引理相同。

情况 1 完全 trivial,是我们许多其他情况 1 中见过的明显矛盾。

情况 2 将是我们通常的“剪切-粘贴”矛盾法。

假设存在一条路径 QP' 更好。即 Qs 开始,到 w 结束,包含 i-1 条边或更少,并且其边权和严格小于 P' 的边权和。

那么,如果我们只是将 P 的最后一段(即边 w -> v)附加到 Q 上,我们就得到了一条从 s 开始、到 v 结束、最多有 i 条边的路径。这条新路径 Q + (w->v) 的总边权和严格小于原始路径 P 的边权和。但这与 P 在所有从 s 开始、到 v 结束、且最多有 i 条边的路径中最优的假设相矛盾。

这就是最优子结构引理的证明,它足够简单。和往常一样,下一步是将这个引理编译成一个递推关系。非正式地说,递推关系将对最优解的可能候选进行暴力搜索。

理解候选解数量

为了确保你理解刚刚发生的事情,让我们进行一个小测验。

问题是:对于输入图的某个给定目标顶点 v,涉及 v 的子问题的最优解有多少个候选?

正确答案是第二个选项:答案取决于你谈论的是哪个目标顶点 v,而决定子问题数量的因素是该顶点的入度,即输入图中以 v 为头的边的数量。

为什么是这样?

  • 情况 1 贡献了一个可能的候选:对于给定的 i 和给定的 v,你可能只是继承了目标为 v、预算为 i-1 条边的最优解。
  • 情况 2 看似只贡献了另一个候选,但实际上它包含了多个候选,每个候选对应一个最后一段跳转 w -> v 的选择。具体来说,对于每个 w 的选择,它贡献了一个候选最优解,即从 s 到那个 w 的、最多使用 i-1 条边的最短路径,再加上边 w -> v

因此,候选解的总数等于 1(来自情况 1) + 顶点 v 的入度(来自情况 2)

总结

本节课中,我们一起学*了贝尔曼-福特算法动态规划思路的起点——最优子结构分析。我们明确了在允许负边权但(暂时)假设无负环的图中,如何通过引入路径边数预算 i 来定义子问题。我们证明了关键的最优子结构引理:从源点 s 到顶点 v、最多使用 i 条边的最短路径,要么是 sv 最多使用 i-1 条边的最短路径,要么是 s 到某个前驱顶点 w 最多使用 i-1 条边的最短路径再加上边 (w, v)。这个引理自然地引出了对最优解候选的枚举,其数量取决于目标顶点 v 的入度。这为下一节推导动态规划递推公式奠定了坚实的基础。

042:贝尔曼-福特算法基础

概述

在本节课中,我们将学*贝尔曼-福特算法的基础版本。我们将从理解最短路径问题的最优子结构开始,推导出动态规划递推公式,并最终构建出完整的算法。我们将重点关注算法如何通过引入“边预算”这一概念来控制子问题规模,并解释为何在图中没有负环时,该算法能正确工作。


上一节我们介绍了最短路径问题的最优子结构。本节中,我们来看看如何将其转化为一个动态规划递推公式。

我们用符号 Lᵢᵥ 来表示对应子问题的最优解值。每个子问题由两个参数索引:目标顶点 V 和允许从源点 SV 的路径中使用的最大边数 i

需要注意几个细节。首先,我们允许路径中包含环,因为我们通过有限的边预算 i 来限制路径长度,从而避免了无限循环遍历负环的问题。其次,如果不存在从 SV 且最多使用 i 条边的路径,我们定义 Lᵢᵥ+∞

递推关系将对每个正整数 i 和每个可能的目标顶点 V 进行定义。它表明,子问题的最优解值是最优子结构引理中识别的所有可能候选方案中的最佳者。

以下是递推公式:

Lᵢᵥ = min( Lᵢ₋₁ᵥ, min_{(w,v)∈E} ( Lᵢ₋₁_w + c_{wv} ) )

公式的第一部分(Lᵢ₋₁ᵥ)对应情况一:继承使用最多 i-1 条边找到的从 SV 的最短路径。第二部分对应情况二:考虑所有可能的最后一条边 (w, v),其路径长度为到达 w 的最短路径(使用最多 i-1 条边)加上边 (w, v) 的成本 c_{wv},然后取其中的最小值。

该递推关系的正确性直接源于最优子结构引理。无论图 G 是否包含负环,此递推对所有正的 i 值都是正确的。


现在,让我们看看假设输入图 G 没有负环是如何有用的。

我们之前讨论过无负环假设的用途。具体来说,我们论证了 n-1 条边总是足以捕获从 S 到任何可能目的地的最短路径。原因如下:假设没有负环,固定一个目标顶点 V。考虑一条至少有 n 条边的路径。由于它至少有 n 条边,它必然访问至少 n+1 个顶点。但图中只有 n 个顶点,因此路径必须至少访问某个顶点两次。在两次连续访问同一个顶点之间,存在一个有向环。根据假设,没有负的有向环,所有环的成本都是非负的。因此,如果我从路径中丢弃这个环,我将得到一条到达同一目的地 V 的新路径,并且其总长度只会减少。丢弃环只会使路径更短。这就是为什么存在一条没有重复顶点(即最多使用 n-1 条边)的最短路径。

这个观察结果与我们的递推关系有何关联?它告诉我们,我们只需要计算 i 值最大到 n-1 的子问题。如果没有负环,给子问题分配大于 n-1 的边预算是没有意义的,因为当 i 达到 n-1 时,我们保证已经找到了最短路径。

为了明确这一点,我们正式列出贝尔曼-福特算法中将要解决的子问题。对于没有负环的输入图 G,这些子问题足以正确计算最短路径。

以下是需要解决的子问题集合:

  • 计算所有最短路径长度 Lᵢᵥ
  • 目标顶点 V 涵盖所有顶点。
  • 边预算 i 的范围从 0n-1

这是一个相当简洁的子问题集合。虽然它看起来数量很多(有 n 个顶点和 n 个可能的 i 值,总共 个子问题),但请记住,这个问题的输出大小是线性的(我们需要为每个目的地 V 输出一个数字)。因此,对于我们负责计算的每个统计数据,我们实际上只有线性数量的子问题,这与我们讨论过的其他动态规划算法一样好。


现在,我们可以简单地写出著名的贝尔曼-福特算法的伪代码。

由于我们的子问题由两个参数(边预算 i 和目标顶点 v)索引,我们将使用一个二维数组 A。子问题的大小通过边预算 i 来衡量,这是贝尔曼-福特算法中引入边预算来控制子问题规模的核心思想。

基础情况是当 i = 0 时。此时我们讨论的是使用零条边从 S 到达某个顶点 V。如果 V 恰好等于 S,那么我们可以通过空路径实现,空路径的长度为 0。如果 VS 以外的任何顶点,那么显然无法使用零条边从 S 到达 V,在这种情况下,我们将最优解值定义为 +∞

接下来,我们进入常规的双重循环。与大多数动态规划算法不同,这里的循环顺序很重要。必须确保在需要时,所有更小的子问题都已解决。这意味着外层的 for 循环应该按子问题大小 i 进行索引。

正如我们所讨论的,在无负环的情况下,我们不需要让 i 超过 n-1。对于每个 i 的选择,我们解决所有对应的子问题(即所有目标顶点 V)。对于每个 (i, V) 对,我们只需用代码写出递推公式中陈述的公式。

以下是贝尔曼-福特算法的伪代码:

// 初始化
令 A[0][s] = 0
对于所有其他顶点 v ≠ s:
    令 A[0][v] = +∞

// 主循环
对于 i = 1 到 n-1:
    对于每个顶点 v ∈ V:
        // 情况1:继承使用 i-1 条边的最优解
        A[i][v] = A[i-1][v]
        // 情况2:考虑所有可能的最后一条边 (w, v)
        对于每条边 (w, v) ∈ E:
            如果 A[i-1][w] + c_{wv} < A[i][v]:
                A[i][v] = A[i-1][w] + c_{wv}

如果输入图 G 恰好没有负环,那么该算法将终止,并得到从 S 到所有目的地的最短路径。这些答案将存储在最大的子问题 A[n-1][v] 中。

正确性通常主要源于最优子结构引理。但在这种情况下,无负环的假设也保证了取 i = n-1 足够大,能够捕获最终答案。


我们稍后将讨论贝尔曼-福特算法的运行时间。但首先,让我们通过一个简单的例子来确保所有这些内容都清晰易懂。

总结

本节课中,我们一起学*了贝尔曼-福特算法的基础版本。我们从最短路径的最优子结构出发,推导出了动态规划递推公式 Lᵢᵥ = min( Lᵢ₋₁ᵥ, min_{(w,v)∈E} ( Lᵢ₋₁_w + c_{wv} ) )。我们解释了在图中没有负环的假设下,只需计算 i0n-1 的子问题就足够了。最后,我们给出了算法的完整伪代码,并概述了其正确性基础。该算法通过巧妙地引入边预算来控制问题规模,是处理可能带有负权边(但无负环)图中单源最短路径问题的有效工具。

043:贝尔曼-福特算法基础(第二部分)

在本节课中,我们将通过一个具体的例子,逐步演示贝尔曼-福特算法如何计算图中所有节点的最短路径。我们将分析算法的运行时间,并探讨一个简单的优化技巧。

算法演示

上一节我们介绍了贝尔曼-福特算法的递推公式和基本框架。本节中我们来看看该算法在一个具体图例上的执行过程。

考虑以下包含五个顶点的图。图中用蓝色数字标注了各条边的成本。

我们将逐步遍历外层循环的索引 I。由于有五个顶点,I 将取值 0, 1, 2, 3, 4。让我们看看每一轮子问题计算的结果。

在基础情况下,当 I = 0 时,从源点 S 到自身的距离为 0,对于所有其他顶点,子问题的值被定义为 +∞

以下是递推公式,以防你忘记:
A[i, v] = min{ A[i-1, v], min_{(u,v)∈E} { A[i-1, u] + c_uv } }

现在我们进入主循环,从 I = 1 开始。

我们以任意顺序遍历顶点并评估递推公式。

  • 节点 S 将直接继承上一步的解决方案,它仍然满足总长度为 0 的空路径。
  • 节点 V 当然不希望继承上一轮(I=0)的 +∞ 解。实际上,当 I=1 时,顶点 V 的子问题解将是 2。这是因为我们可以选择最后一条边为 (S, V),其长度为 2,而 S 在上一轮(I=0)的子问题值为 0
  • 同理,X 的新子问题值将是 4,因为我们可以选择最后一条边为 (S, X),并将该边的成本 4 加到 S 在上一轮(I=0)的子问题值上。
  • 现在,节点 WT 希望摆脱它们的 +∞ 解并获得有限值。你可能会想,既然 VX 现在有了有限距离,这些值会传播到节点 WT。这确实会发生,但我们必须等到下一次迭代,即 I = 2。原因是,如果你查看代码或递推公式,当我们计算给定迭代 I 的子问题时,我们只使用前一次迭代 I-1 的子问题解,而不使用当前迭代 I 中已经发生的任何更新。因为当 I=0 时,A[0, V]A[0, X] 都是 +∞,所以 A[1, W]A[1, T] 也将是 +∞

现在让我们继续外层循环的下一次迭代,当 I = 2 时。

  • 顶点 S 的子问题解不会改变,你不会得到比 0 更好的结果,所以它将保持不变。
  • 类似地,在顶点 V,你不会得到比 2 更好的结果,所以它在此次迭代中也保持不变。
  • 然而,在顶点 X 发生了一些有趣的事情。在递推公式中,你当然可以选择继承之前的解,所以一个选择是将 A[2, X] 设为 4。但实际上有一个更好的选择。具体来说,如果我们选择最后一条边为从 VX 的单位成本弧,我们将该单位成本加到 V 在上一轮(I=1)的子问题值上,即 22 + 1 = 3。所以这将是 I=2 这次迭代中 X 的新子问题值。
  • 正如所宣传的那样,在 I=1 迭代中对顶点 VX 的更新,现在在 I=2 时传播到了顶点 WT。因此,WT 摆脱了 +∞ 值,分别获得了值 48

请注意,我将顶点 T 标记为 8,而不是 7。我计算出的 A[2, T]8。原因同样是,本次迭代中的更新(特别是 X4 降到 3 这一事实)不会在同一迭代中反映到其他节点。我们必须等到外层循环的下一次迭代,它们才会发生。因此,我们使用的是 X 的过时信息,即当 I=1 时,它的解值是 4,我们正是用这个信息来更新 T 的解值,所以是 4 + 4 = 8

在倒数第二次迭代中,当 I = 3 时,SVXW 的大部分值保持不变,实际上我们已经计算出了最短路径,所以它们都将直接继承上一轮的解决方案。

但在顶点 T,它将利用顶点 XI=2 迭代中改进的解值,因此它的 8 被更新为 7,反映了 X 在上一轮的改进。

此时,我们实际上已经完成了计算,得到了所有目的地的最短路径。但算法还不知道我们已经完成,所以它仍然会执行外层循环的最后一次迭代,即 I = 4,但此时每个节点都只是继承了上一轮的解决方案,然后算法终止。

运行时间分析

对于我们讨论过的大多数动态规划算法,运行时间分析都很简单。贝尔曼-福特算法从运行时间分析的角度来看更有趣一些。

以下是运行时间界限,其中最小且正确的界限是 B,即边数乘以顶点数。

让我们解释为什么它是 O(m * n),并评论其他选项。

  • 答案 AO(n²)。这是子问题的数量。子问题由 I(介于 0n-1 之间)和目的地 V 的选择索引。每个都有 n 种选择,所以正好有 个子问题。如果我们每次评估一个子问题只花费常数时间,那么贝尔曼-福特的运行时间确实是 O(n²)。在本课程讨论的大多数动态规划算法中,确实每个子问题只花费常数时间求解。一个例外是最优二叉搜索树问题,通常我们花费线性时间。这里也一样,像最优二叉搜索树一样,我们可能花费超过常数的时间来求解一个子问题。原因是我们必须对候选列表进行暴力搜索,而候选数量可能非常大。原因是,每条指向目的地 V 的边都提供了一个候选解。候选数量与顶点 V 的入度成正比,而最大入度可以达到 n-1,与顶点数成线性关系。这就是为什么贝尔曼-福特算法的运行时间通常可能比 O(n²) 差。

  • 答案 CO(n³)。这确实是贝尔曼-福特算法运行时间的有效上界,但它不是可能的最紧上界。为什么它是一个有效上界?如前所述,有 O(n²) 个子问题。每个子问题的工作量是多少?它与顶点的入度成正比,顶点的最大入度是 n-1,即 O(n)。所以,对 O(n²) 个子问题中的每一个进行线性工作,导致立方级的运行时间。

然而,对贝尔曼-福特算法有一个更紧、更好的分析。

为什么 O(m * n)O(n³) 更好?在稀疏图中,mΘ(n);在稠密图中,m。所以,如果是稠密图,O(m * n) 确实不小于 O(n³)。但如果图不是稠密的,那么这个上界确实是改进过的。

为什么这个界限成立?考虑所有子问题上的总工作量如下:

我们只需取外层循环单次迭代中所做的工作量,然后乘以外层循环的迭代次数 n

那么,在外层循环的给定一次迭代(给定 I 值)中,我们做了多少工作?它就是所有顶点入度的总和。当我们考虑顶点 V 时,我们做的工作量与其入度成正比,并且在外层循环的给定迭代中,我们考虑每个顶点 V 一次。

但我们知道所有入度之和有一个更简单的表达式:这个和恰好等于 m,即图中边的数量。在任何有向图中,边的数量正好等于所有入度之和。一个简单的理解方法是:取你喜欢的有向图,想象你一次一条边地将边插入图中,从空的边集开始。显然,每次插入一条新边,图中的边数增加 1,同时恰好有一个顶点的入度增加 1(即你刚插入的那条边的头顶点)。因此,无论有向图是什么,入度之和与边的数量总是相等的。这就是为什么总工作量是 O(m * n),优于 O(n³)

算法优化

基本贝尔曼-福特算法可以进行一些优化。让我在本视频结束时快速介绍一个关于提前停止的简单优化。关于算法的一个更重要的空间优化,请参见另一个单独的视频。

基本版本的算法,外层循环运行 n-1 次。通常,你不需要全部迭代。我们已经在简单示例中看到,最后一次迭代没有做任何有用的工作,它只是继承了上一轮的解决方案。

一般来说,假设在最后一次迭代之前的某次迭代中,比如当前索引值为 J,恰好没有任何变化,在每个目的地 V,你都只是重用了在外层循环上一轮迭代中重新计算的最优解。那么,如果你仔细想想,在下一次迭代中会发生什么?你将用完全相同的输入集进行完全相同的计算集,因此你将得到完全相同的输出集。也就是说,在下一次迭代中,你仍然只会继承上一轮的最优解,并且这种情况将一直持续下去。

特别是,当你到达外层循环的第 n-1 次迭代时,你将拥有与现在完全相同的解值集。我们已经证明,第 n-1 次迭代结束时的结果是正确的,它们是真正的最短路径距离。如果你现在已经掌握了它们,那么不妨中止算法,并将它们作为最终的、正确的最短路径距离返回。

总结

本节课中我们一起学*了贝尔曼-福特算法的具体执行步骤。我们通过一个例子,观察了算法如何通过多轮迭代逐步更新和传播最短路径估计值,直至收敛到正确解。我们分析了算法的运行时间为 O(m * n),这比朴素的 O(n³) 分析更精确。最后,我们介绍了一个简单的优化技巧:如果在某轮迭代中所有节点的最短路径估计值都没有发生变化,算法可以提前终止,因为后续迭代将不会产生新的结果。

044:检测负成本环

在本节课中,我们将要学*如何扩展贝尔曼-福特算法,使其能够检测输入图中是否存在负成本环。我们将理解一个关键定理,它描述了算法行为与图中是否存在负环之间的关系,并学*如何通过增加一次迭代来实现检测功能。

概述

到目前为止,我们已经看到,在没有负成本环的输入图中,贝尔曼-福特算法能够正确计算从源顶点 s 到所有目标顶点 v 的最短路径。但对于确实存在负成本环的输入图,情况会如何呢?在这个简短的视频中,我们将看到如何扩展贝尔曼-福特算法,在几乎不改变其运行时间的前提下,轻松检查输入图是否包含负成本环。

核心定理与检测方法

上一节我们介绍了贝尔曼-福特算法在无负环图中的正确性,本节中我们来看看如何检测负环。

以下定理指明了贝尔曼-福特算法的适当扩展方式。具体来说,该定理根据贝尔曼-福特算法的行为,描述了输入图中是否存在负成本环。

实际上,我们考虑让贝尔曼-福特算法多运行一次迭代。目前,我们在外层循环索引 i 等于 n-1 时停止算法。对于这个定理,我们设想将外层 for 循环多运行一次迭代,即当 i 等于 n 时,对所有目标顶点 v 运行相同的旧递推关系。

那么,该定理断言:输入图 G 没有负环,当且仅当我们从这额外的一批子问题中没有获得任何新信息。也就是说,当且仅当对于每个可能的目标顶点 vA[n][v]A[n-1][v] 完全相同。等价地,输入图确实有负成本环,当且仅当存在某个子问题,即存在某个目标顶点 v,在该次额外迭代中我们看到 v 处的值有所改进(变小)。

我们将在下一张幻灯片中证明这个定理。证明并不太难。但我希望定理的含义以及我们如何检查负成本环是立即清楚的。

现在,给定一个没有任何保证的任意输入图(它可能有负成本环,也可能没有),我们该怎么做?你运行贝尔曼-福特算法,但多运行一次迭代。你让外层 for 循环的索引 i 一直运行到 n。然后你检查:在最后一次迭代中,是否有子问题的值发生了变化?如果没有,如果你的所有 A[n-1][v] 都与 A[n][v] 相同,那么根据定理,你就知道图中没有负成本环。根据我们之前的工作,我们知道贝尔曼-福特算法是正确的。因此,我们可以像以前一样,愉快地返回 A[n-1][v] 作为正确的最短路径距离。

另一方面,如果你注意到存在一个顶点 v,使得 A[n][v]A[n-1][v] 不同(更小),那么根据定理,你就说:“嘿,存在负环。” 因此,我不会为你计算最短路径距离,这没有意义。图中存在负成本环,算法终止。

当然,贝尔曼-福特算法中的这一次额外迭代对其运行时间的影响可以忽略不计,它仍然是 O(m * n)

定理的边界情况说明

在这个定理中,我有一点小小的谎言。有一个边界情况我没有妥善处理。当我写下这个定理时,我考虑的是输入图 G 的常见情况,即存在一条从源点 s 到每个其他目标顶点 v 的路径。也就是说,所有最短路径距离都是有限值的输入图。如果不是这种情况,那么如上所述的定理就不正确。理解这一点的一个简单例子是一个退化实例:源顶点 s 根本没有出弧,而其余的顶点可能形成一个负成本环。在这种图中,该定理的左侧(G 没有负环)是假的,但定理的右侧(算法行为)是满足的。

因此,为了修改定理以适用于可能包含某些无限距离的图,我只需将左侧修改为:G 没有从源顶点 s 可达的负成本环。

现在,如果你实际上想检测输入图中是否存在负环(无论是否从 s 可达),你可以使用各种技巧,再次利用贝尔曼-福特算法来解决这个问题。例如,给定一个输入图,你可以添加一个虚拟的额外顶点,并添加从该顶点到所有其他顶点的弧,弧长为 0。然后在该图上运行贝尔曼-福特算法,如果存在负成本环,它将被检测出来。

定理证明

既然我们知道了为什么希望这个定理成立,现在让我们来理解它为什么成立。让我们进入证明部分。

该定理断言了一个“当且仅当”的关系:左侧是输入图没有负成本环的性质;右侧是如果你多运行一次迭代,贝尔曼-福特算法不会做出任何改变的性质。

像这样的证明有两个部分:假设左侧成立,证明右侧;假设右侧成立,证明左侧。这两个部分中,如果你仔细想想,我们已经完成了一个。当我们证明贝尔曼-福特算法对于没有负成本环的图是正确的时候,我们就已经完成了。也就是说,如果左侧成立(输入图没有负成本环),我们已经论证过,你不需要将外层 for 循环运行超过 i = n-1 次,这足以捕获最短路径。因此,特别地,取任意大的 i,例如 i = n,你也不会看到更短的路径,你将得到完全相同的子问题解。

那么,证明的核心内容就是反向方向。因此,让我们假设我们多运行了一次贝尔曼-福特迭代,并且所有子问题的解都没有改变。我之前警告过,当输入图中不存在从 s 到所有其他顶点的路径,并且你有一些无限距离时,存在边界情况。这些细节我将留给你处理,所以让我们只关注从 s 到其他所有顶点都存在路径的情况,特别是这些子问题的值将是有限的。

用一点符号表示:我将使用小写 d(v) 来表示顶点 v 在最后两次迭代(当 i = n-1i = n 时)中子问题的公共值。

现在的计划是,我们将仔细审视用于评估这些子问题的公式。它就在贝尔曼-福特算法的伪代码中注视着我们。从那个公式中,我们将得到一个关于这些 d 值的不等式,从这个不等式中,我们将能够轻松地推断出输入图的每个环确实是非负的——这正是定理左侧的陈述。

我们用什么公式来填充表格的这额外一次迭代(即 A[n][v])呢?我们只是取以下两者中较好的一个:一方面是 A[n-1][v](前一次迭代的解);另一方面是使用最后一条边 (w, v) 的候选路径中的最佳者,该路径将一条最多有 n-1 条边的到 w 的路径与边 (w, v) 连接起来。

用我们的新符号 d 值(即第 n-1 次和第 n 次迭代中子问题的公共值)来表示,我们可以将这个公式的左侧写为 d(v),在情况 2 的子问题中,我们可以将 A[n-1][w] 写为 d(w)

因为这个等式的左侧是右侧一系列候选值的最小值,如果我们具体化、聚焦于右侧的任何一个候选值,即任何最后一条边 (w, v) 的选择,我们得到的东西至少和左侧一样大(因为左侧是所有候选值中最小的)。因此,特别地,对于最后一条边 (w, v) 的给定选择,我们得到 d(v) <= d(w) + c(w, v),其中 c(w, v) 是从 wv 的边的长度。

实际上,这个不等式所说的就是:获得一条从 sv 的路径的一种方法是,取一条从 sw 的路径并连接最后一条边 (w, v)。到 v 的最短路径只能比这条经过 w 的特定候选路径更好。

现在记住我们要证明什么:我们试图证明输入图没有负成本环。让我们任意选取一个环 C,并证明它具有非负成本。

这将是我们刚刚写下的粉色不等式的巧妙应用。具体来说,我们将对该不等式在环的所有边上求和。如果我稍微重新排列一下那个粉色不等式,就会很清楚。

让我们看看环 C 中边长的和。记住,这正是我们想要证明是非负的东西。我们对环 C 中的所有边 (w, v) 求和,对于每条边,我们查看它的成本 c(w, v)。根据粉色不等式,我们可以用环 C 中边的端点 d 值之差的求和来下界这个和。

注意,对于环上的一条给定弧 (w, v),这条弧的尾部 wd 值以系数 +1 出现,而这条弧的头部 vd 值以系数 -1 出现。

但是,环当然有一个非常特殊的性质:环上的每个顶点恰好作为某条弧的尾部出现一次,也恰好作为某条弧的头部出现一次。因此,环上每个顶点的 d 值将出现一次系数为 +1,一次系数为 -1。所以我们得到大量的抵消,最后只剩下 0

因此,环 C 具有非负成本。由于 C 是任意选取的环,所以输入图中所有环同时都具有这个性质——这正是我们试图证明的。

总结

本节课中我们一起学*了如何扩展贝尔曼-福特算法以检测负成本环。我们理解了一个关键定理:输入图中存在负环,当且仅当算法在额外运行一次迭代时,某些子问题的值会继续改善。基于此,我们只需在标准算法结束后多运行一次迭代,并检查子问题值是否变化,即可在不显著增加运行时间(仍为 O(m * n))的情况下完成检测。对于存在从源点不可达的负环等边界情况,可以通过添加虚拟源点等技术来处理。这个扩展使得贝尔曼-福特算法成为一个更鲁棒的最短路径算法。

045:问题定义

在本节课中,我们将要学*全对最短路径问题。我们将了解这个问题的定义,并探讨如何利用已有的单源最短路径算法来解决它,同时分析不同情况下的算法效率。


为什么需要全对最短路径?

我们为什么只满足于计算从一个源点到所有可能目的地的最短路径?如果我们想知道从每一个顶点每一个其他顶点的最短路径距离呢?

问题正式定义

全对最短路径问题的正式定义如下:

我们通常被给定一个有向图 G,其边具有长度 Ce。你可以考虑所有边长度都为非负的特殊情况,但我们同样对边长度可以为负的情况感兴趣。

与单源最短路径问题不同,这里没有指定的源点。问题的目标是计算对于每一对顶点 U 和 V,从 U 开始到 V 结束的最短路径的长度。

与问题的单源版本一样,这还不是全部。如果输入图 G 包含一个负权环,那么根据你如何定义“最短路径”,问题要么没有意义,要么在计算上是棘手的。因此,如果存在负权环,我们就不必计算最短路径距离,但我们需要正确地报告图中包含负权环。这是我们不计算正确最短路径长度的理由。


利用现有工具箱

如果你看到这个问题时,心里想:“我们不是已经有一个足够丰富的工具箱来解决全对最短路径问题了吗?” 这是一个很好的想法。从很多意义上说,答案是肯定的。

让我们来探索这个想法,并将其具体化。在下面的测验中,我将问你:

假设我给你一个黑盒子子程序,它能正确且快速地解决单源最短路径问题。你需要调用这个黑盒子子程序多少次,才能正确解决全对最短路径问题?

正确答案是 C:你需要调用单源最短路径子程序 n 次,其中 n 是输入图中的顶点数。

为什么?如果你指定一个任意顶点作为源点 S,然后运行提供的子程序,它将为你计算从该 S 到所有目的地的最短路径距离。这样,你就计算出了 n 个最短路径距离,即所有以这个特定顶点 S 为起点的距离。总共有 n 种可能的起点选择。因此,你只需遍历所有这些选择,对每个选择调用一次提供的算法,这样你就得到了你需要负责的 个最短路径距离。


我们应该满足于这个方案吗?

我们应该满足于这个简单地运行 n 次单源最短路径算法的方案吗?还是我们期望做得更好?

答案将取决于两个因素:

  1. 输入图是只有非负边权,还是更普遍地也允许负边权
  2. 图是稀疏的(边数 M 接* n)还是密集的(边数 M 接* )。

上一节我们介绍了利用单源算法解决全对问题的基本思路,本节中我们来看看在不同图类型下的效率。

情况一:所有边权非负

边权是否全为非负很重要,因为它决定了我们可以使用哪个单源最短路径子程序。

在理想情况下,所有边权非负,我们可以使用 Dijkstra 算法作为主力。记住,我们基于堆的实现版本的 Dijkstra 算法速度极快,运行时间为 O(M log n)。如果你运行它 n 次,总运行时间自然是 O(n * M * log n)

  • 对于稀疏图:这将是 O(n² log n)
  • 对于密集图:这将是 O(n³ log n)

对于稀疏图,这实际上已经非常出色了。你不太可能比针对每个源点运行一次 Dijkstra 算法做得更好。原因是我们需要输出 个值(每对顶点 UV 的最短路径距离),而这里的运行时间只是 乘以一个额外的对数因子。

然而,对于密集图,情况就模糊得多。是否存在从根本上快于立方时间(O(n³))的算法来解决密集图的全对最短路径问题,至今仍是一个开放性问题。

如果你想说服别人可能无法做得比立方时间更好,你可能会这样论证:需要计算的最短路径距离数量是平方级的(n²)。对于给定的一对 U 和 V,最短路径可能包含线性数量的边。因此,你肯定无法在线性时间内计算出一对顶点之间的最短路径。所以,要做平方级次计算,总时间就必然是立方级的。

但我想澄清,这不是一个证明。这只是一个模糊的论证。为什么不是证明?因为我们有可能做一些工作,这些工作同时与许多最短路径问题相关,你实际上不必为每个问题平均花费线性时间。

作为一个启发性的例子,让我提醒你矩阵乘法。如果你写下两个矩阵相乘的定义,看定义,它似乎显然是一个立方级问题。似乎根据定义,你必须做立方级的工作量。然而,这种直觉是完全错误的。从 Strassen 算法开始,以及许多后续算法,我们现在知道存在从根本上优于朴素立方时间算法的矩阵乘法算法。如果你有一个非平凡的问题分解方法,你可以消除一些冗余工作,做得比直接解法更好。对于全对最短路径问题,是否存在类似 Strassen 的改进?没有人知道。

情况二:允许负边权

现在让我们讨论更一般的情况:允许有负边权的输入图。

在这种情况下,我们不能使用 Dijkstra 算法作为我们的单源最短路径子程序,因为它是唯一能处理负权边的两个算法之一。我们必须转而使用 Bellman-Ford 算法

记住,Bellman-Ford 算法比 Dijkstra 算法慢。我们证明的运行时间上界是 O(M * n)。如果我们运行它 n 次,我们得到运行时间 O(M * n²)

运行时间 O(n² * M) 有多好?

  • 如果图是稀疏的(M 是 Θ(n)),那么这是 O(n³)
  • 如果图是密集的(M 是 Θ(n²)),我们现在看到本课程中第一个四次方运行时间 O(n⁴)

我希望你对稀疏图情况的立方运行时间上界并不特别满意。但现在当我们讨论四次方运行时间时,这真的显得过于高昂了。

因此,希望你现在在想:对于密集图的情况,一定有比仅仅运行 n 次 Bellman-Ford 算法更好的方法。

确实存在更好的方法,那就是 Floyd-Warshall 算法。我们将在下一个视频中开始讨论它。


总结

本节课中我们一起学*了全对最短路径问题的定义。我们探讨了如何通过 n 次调用单源最短路径算法(Dijkstra 或 Bellman-Ford)来解决它,并分析了在不同图结构(稀疏/密集,有无负权边)下的算法效率。我们发现,对于允许负权边的密集图,朴素方法的运行时间高达 O(n⁴),这促使我们去寻找更优的算法,为下一节学* Floyd-Warshall 算法做好了铺垫。

046:全对最短路径的最优子结构

概述

在本节课中,我们将学*全对最短路径问题的最优子结构。我们将为著名的弗洛伊德-沃舍尔算法奠定理论基础,该算法利用动态规划高效地解决此问题。我们将看到如何通过限制路径中允许使用的中间顶点来定义子问题,并推导出关键的最优子结构性质。


性能对比与算法背景

上一节我们讨论了通过多次调用单源最短路径子程序来解决全对最短路径问题的方法。本节中,我们将从头开始开发一个独立的动态规划算法。

弗洛伊德-沃舍尔算法能在 O(n³) 时间内解决全对最短路径问题,即使图中包含负权边。这个运行时间与图的稀疏度无关。

以下是不同场景下的性能对比:

  • 含负权边的图:由于无法使用迪杰斯特拉算法,之前的方法是运行 n 次贝尔曼-福特算法,时间复杂度为 O(n²m)。对于稀疏图,这与弗洛伊德-沃舍尔算法的 O(n³) 相当;对于稠密图(m ≈ n²),n 次贝尔曼-福特将导致 O(n⁴) 的运行时间,而弗洛伊德-沃舍尔算法保持 O(n³),优势明显。
  • 仅含非负权边的图:运行 n 次迪杰斯特拉算法是很好的选择,时间复杂度为 O(n m log n)。对于稀疏图,这比 O(n³) 更优。对于稠密图,两者复杂度都在 O(n³) 量级,实际性能需通过测试比较。

该算法也常用于计算关系的传递闭包(即判断图中任意两点是否连通),此时可进行特定优化。

目前,是否存在显著优于 O(n³) 的全对最短路径算法仍是一个开放性问题。


定义子问题

现在,让我们形式化全对最短路径的最优子结构。首先,我们需要为图中的顶点任意规定一个顺序。假设顶点集 V 被标记为 1, 2, 3, ..., n。我们用 V^(k) 表示前 k 个顶点的集合,即 {1, 2, ..., k}

我们暂时假设图中没有负权环。处理负权环的情况将在算法完成后讨论。

子问题的定义模仿了贝尔曼-福特算法的思路,但增加了对路径中间顶点的限制。具体定义如下:

对于一个子问题,我们需要指定三个参数:

  1. 起点 i(1 ≤ i ≤ n)
  2. 终点 j(1 ≤ j ≤ n)
  3. 一个上界 k(0 ≤ k ≤ n)

该子问题的目标是:在所有从 ij 的路径中,找出这样一条最短路径——该路径上除了起点 i 和终点 j 之外,所有中间顶点的编号都不超过 k(即只能来自集合 V^(k))。

由于我们假设没有负权环,可以认为这条最短路径是无环的。

子问题的总数是 O(n³),因为 i, j, k 各有 n 种可能。

子问题示例

假设起点 i = 17,终点 j = 10,且 k = 5。考虑下图:

(图示:顶点17通过顶点7以代价-20连接到顶点10;同时,顶点17通过顶点3和顶点4以总代价3连接到顶点10)
  • 从 17 到 10 的全局最短路径是经过顶点 7 的两跳路径,总长度为 -20
  • 然而,对于子问题 (i=17, j=10, k=5),路径的中间顶点只能使用编号 1 到 5 的顶点。顶点 7 的编号大于 5,因此该路径不被允许。
  • 所以,该子问题下的最短路径是经过顶点 3 和 4 的三跳路径,总长度为 3

最优子结构引理

理解了子问题的定义后,我们现在可以陈述并理解最优子结构引理。这是弗洛伊德-沃舍尔算法的核心。

P 是子问题 (i, j, k) 的一条最短路径(即从 ij,且所有中间顶点编号 ≤ k)。

对于路径 P,只有两种情况:

情况一:路径 P 不使用顶点 k 作为中间顶点

如果最短路径 P 根本不经过顶点 k(作为中间顶点),那么 P 的所有中间顶点编号实际上都 ≤ k-1。因此,路径 P 也必然是子问题 (i, j, k-1) 的一条最短路径。

公式表示:
dist(i, j, k) = dist(i, j, k-1)

情况二:路径 P 使用顶点 k 作为中间顶点

如果最短路径 P 使用了顶点 k(作为中间顶点),那么我们可以将 P 分解为两段:

  • P1:从 ik 的子路径。
  • P2:从 kj 的子路径。

由于 P 是无环的,顶点 k 只出现一次。因此:

  • P1 中,起点是 i,终点是 k,所有严格在 ik 之间的中间顶点编号都 ≤ k-1
  • P2 中,起点是 k,终点是 j,所有严格在 kj 之间的中间顶点编号都 ≤ k-1

这意味着 P1 是子问题 (i, k, k-1) 的一条最短路径,而 P2 是子问题 (k, j, k-1) 的一条最短路径。

公式表示:
dist(i, j, k) = dist(i, k, k-1) + dist(k, j, k-1)

引理总结

综合以上两种情况,我们得到以下递推关系:

如果 k = 0:
    dist(i, j, 0) = 边(i, j)的权值(若边存在),否则为无穷大。

如果 k ≥ 1:
    dist(i, j, k) = min( dist(i, j, k-1), dist(i, k, k-1) + dist(k, j, k-1) )

这个递推式优美地展示了:当允许使用更多顶点(从 k-1k)时,新的最短路径要么延续旧的最短路径(不使用新顶点 k),要么通过新顶点 k 进行“中转”,并将问题分解为两个更小的子问题。


总结

本节课中,我们一起学*了全对最短路径问题的动态规划基础。

  1. 我们首先对比了弗洛伊德-沃舍尔算法与其他方法的性能。
  2. 接着,我们通过对顶点任意排序并限制可用的中间顶点前缀,巧妙地定义了 O(n³) 个子问题。这是将动态规划应用于图问题的关键技巧。
  3. 最后,我们推导出了核心的最优子结构引理。该引理指出,子问题 (i, j, k) 的最短路径,要么与子问题 (i, j, k-1) 的最短路径相同,要么是由子问题 (i, k, k-1)(k, j, k-1) 的两条最短路径拼接而成,并通过顶点 k 连接。

这个清晰的最优子结构关系,直接引出了下一讲将要介绍的弗洛伊德-沃舍尔动态规划算法。

047:全对最短路径与弗洛伊德-沃舍尔算法 🧭

在本节课中,我们将学*如何将全对最短路径问题的最优子结构,转化为一个动态规划算法,即弗洛伊德-沃舍尔算法。我们将从基础情况开始,逐步构建算法,并讨论如何处理负成本循环以及如何重构最短路径。


基础情况

上一节我们识别了全对最短路径问题的最优子结构。现在,我们将其编译成一个动态规划算法。首先,让我们通过一个测验来确定基础情况。

我们的子问题有三个索引:起点 i、终点 j 和预算 k。预算 k 控制我们允许在最短路径中作为内部节点使用的顶点。因此,我们将使用一个三维数组 A

最小的子问题集出现在 k = 0 时。测验要求我们填写所有 A[i][j][0] 的值。

以下是填写规则:

  • 如果 i 等于 j,则存在一条空路径,其长度为 0
  • 如果 i 不等于 j,但 ij 直接相连,则路径长度就是直接边的成本 C[i][j]
  • 如果 i 不等于 j,且 ij 不直接相连,则没有不使用内部节点的路径,因此长度为 +∞

弗洛伊德-沃舍尔算法

确定了基础情况后,我们现在可以写出完整的弗洛伊德-沃舍尔算法。我们将直接跳到代码实现,因为我们已经对构建递推关系有了足够的练*。

算法使用一个三维数组 A。基础情况 k = 0 在预处理步骤中根据上述规则填充。

核心是一个三重循环。重要的是,我们必须先解决最小的子问题,子问题的大小由 k 控制,因此 k 必须放在最外层循环。

# 假设 n 是顶点数量,graph 是邻接矩阵,graph[i][j] 表示从 i 到 j 的边成本,若无边则为 INF
INF = float('inf')
A = [[[INF for _ in range(n)] for _ in range(n)] for _ in range(n+1)]

# 基础情况: k = 0
for i in range(n):
    for j in range(n):
        if i == j:
            A[0][i][j] = 0
        elif graph[i][j] is not None: # 存在直接边
            A[0][i][j] = graph[i][j]
        else:
            A[0][i][j] = INF

# 动态规划递推
for k in range(1, n+1):
    for i in range(n):
        for j in range(n):
            # 情况1: 不使用顶点 k 作为内部节点
            candidate1 = A[k-1][i][j]
            # 情况2: 使用顶点 k 作为内部节点
            candidate2 = A[k-1][i][k-1] + A[k-1][k-1][j] # 注意索引调整
            A[k][i][j] = min(candidate1, candidate2)

在代码中,为了计算 A[i][j][k],我们取最优子结构引理中确定的两个候选值的最小值:

  1. 候选值1:继承上一轮外层循环的解,即 A[i][j][k-1]
  2. 候选值2:使用节点 k 作为内部节点。此时,最短路径必然由从 ik 的最短路径和从 kj 的最短路径拼接而成,即 A[i][k][k-1] + A[k][j][k-1]

算法的正确性依赖于最优子结构引理。运行时间方面,三重循环各迭代 n 次,总共有 O(n³) 个子问题,每个子问题执行常数时间的工作,因此总运行时间为 O(n³)


处理负成本循环

现在,让我们解答两个常见问题。第一个问题是:如果输入图包含负成本循环怎么办?

我们的最优子结构引理和算法正确性论证都假设输入图没有负成本循环。但算法本身无论输入如何都会运行并填充三维数组。

有一个巧妙的方法可以检测负成本循环:扫描最终轮次(k = n)计算出的数字的对角线。如果输入图存在负成本循环,那么对于至少一个顶点 i,条目 A[i][i][n] 将是一个负数。

直观理解是:考虑负循环上标号最大的顶点 Y。当算法外层循环 k 达到 Y 时,计算从循环上某点 X 到自身的路径 A[X][X][Y]。候选值之一将是经过 Y 的整个循环的两半路径之和,即循环的总长度(负数)。这个负数从此将被记录并持续到算法结束。

因此,使用弗洛伊德-沃舍尔算法解决通用全对最短路径问题(图可能包含负循环)的流程如下:

  1. 运行算法。
  2. 扫描 A[i][i][n] 对于所有 i
  3. 如果发现任何负数,则声明存在负成本循环。
  4. 如果对角线全为 0,则 A[i][j][n] 就是正确的最短路径距离。


重构最短路径

第二个常见问题是:在运行弗洛伊德-沃舍尔算法后,如何重构具体的从 ij 的最短路径序列?

与贝尔曼-福特算法类似,我们需要在正向计算过程中存储额外的信息。我们将使用一个二维数组 B,其条目 B[i][j] 记录了在从 ij 的某条最短路径上,内部节点中标签最大的那个顶点

在正向计算过程中,每当我们在递推中使用了第二种情况(即通过顶点 k 更新了 A[i][j][k] 的值),我们就把 B[i][j] 设置为当前的 k。这表示 k 是导致这次更新的“关键”顶点。

假设我们已经正确计算了所有 B[i][j] 的值。现在要重构从源点 s 到终点 t 的最短路径:

  1. 查询 B[s][t]。假设它返回顶点 m。这意味着最短路径可以分解为:s -> ... -> m -> ... -> t,并且 m 是路径上内部节点中最大的。
  2. 然后,我们递归地重构从 sm 的最短路径,以及从 mt 的最短路径。
  3. 递归的基准情况是当 B[s][t] 未定义(例如,st 直接相连或 s == t)时,此时路径就是边 (s, t) 或空路径。

这个过程一定会终止,因为每次递归调用都会确定路径上的一个顶点,而路径最多包含 n 个顶点。


总结

本节课中,我们一起学*了弗洛伊德-沃舍尔算法。我们从其最优子结构出发,定义了三维动态规划状态,并确定了基础情况。我们看到了算法如何通过三重循环,系统地计算所有顶点对之间的最短路径距离,运行时间为 O(n³)。此外,我们还探讨了算法如何扩展以检测输入图中的负成本循环,以及如何通过维护一个辅助数组 B重构具体的最短路径。这个算法是解决稠密图上全对最短路径问题的经典且高效的方法。

048:算法设计实战指南 🧭

在本节课中,我们将一起学*一个系统化的“算法设计实战指南”。这个指南包含13个步骤,旨在帮助你高效地应对新的计算问题。我们将从最省力的方法开始,逐步深入到更复杂的设计范式,并探讨如何处理NP难问题。

概述

恭喜你完成了《算法详解》系列的学*。现在,你已经拥有了一个丰富的算法工具箱,可以应对各种计算问题。面对如此多的工具,如何最高效地使用它们呢?本节视频将介绍一个我本人在面对新问题时常用的“配方”。随着你经验的积累,我鼓励你发展出最适合自己的方法。

13步算法设计指南

第一步:尝试现成算法

首先,尝试“偷懒”。看看你的新问题是否可以直接归结为你已经知道如何解决的问题,或者其特例。例如,许多问题本质上都是最短路径问题,你可以直接使用Dijkstra算法或广度优先搜索来解决。

第二步:应用“四大免费原语”

如果第一步不成功,继续尝试省力的方法。考虑应用我们在本系列中学到的“四大免费原语”(如排序、连通分量计算等)。这些子程序运行在线性或*线性时间,如果能简化你的问题,何乐而不为呢?

第三步:评估朴素解法

接下来,明确最显而易见的朴素解法是什么(例如穷举搜索)。然后评估这个解法是否已经足够好。例如,如果图的顶点数只有10个,那么用穷举搜索解决旅行商问题完全可行。

第四步:尝试贪心算法

如果朴素解法不够好,现在需要开始思考算法设计范式。通常,最好的起点是贪心算法。你可以为问题构思多种贪心策略并在小例子上测试。虽然它们很可能在某些输入上失败,但观察失败的具体案例能帮助你更好地理解问题。

第五步:考虑分治算法

如果贪心算法不奏效,可以考虑分治算法。这种范式适用于输入可以自然分割的问题(例如数组可以分成左右两半)。你尝试递归地解决每个子问题,然后合并结果。

第六步:转向动态规划

从分治算法很自然地过渡到动态规划。如果你尝试分治时,发现合并递归解需要大量重复计算,这通常是一个信号,表明你应该考虑动态规划。应用动态规划的关键在于理解:最优解必须由更小子问题的最优解以有限的方式构建而成。一旦有了这种洞察,通常就能自然地写出递归式,并自底向上地填充表格来解决问题。

第七步:利用数据结构优化

假设你在前六步中成功设计出了一个正确的算法。接下来要问:我们能做得更好吗?一个重要的优化方向是部署数据结构。数据结构的用武之地是重复进行同类型计算

以下是常见操作与数据结构的对应关系:

  • 重复最小/最大值计算:调用堆(Heap),将操作从线性时间加速到对数时间。
  • 重复集合成员查找:调用哈希表(Hash Table),支持接*常数的插入和查找时间。

选择数据结构时,要遵循简约原则:使用能满足操作需求的最简单、最轻量的数据结构,这样操作速度才能最快。

第八步:考虑随机化

在优化算法时,另一个需要审视的方向是随机化。例如,如果在算法中需要从多个元素中选择一个,尝试随机选择可能会带来意想不到的加速效果。我们在系列中看到的快速排序和用于寻找长路径的着色编码算法都是随机化的成功应用。

第九步:诊断NP难问题

如果前八步都失败了,你仍然没有找到在合理时间内解决问题的正确算法,那么是时候考虑这个问题可能没有高效算法,例如它是一个NP难问题

这一步的目标是诊断问题是否为NP难,以避免浪费时间寻找“好得不真实”的算法。你可以咨询专家,或者如果你掌握了NP难问题的证明技巧(如本书第22章所述),可以尝试自己证明。证明通常分为两步:

  1. 选择一个已知的NP难问题。
  2. 将已知的NP难问题归约到你的问题。因为归约沿着归约方向传播难解性,这就能证明你的问题也是NP难的。

第十步:妥协于正确性(设计启发式算法)

如果确认问题是NP难的,你必须做出妥协。假设你决定妥协于正确性,即追求快速但允许在某些输入上出错。这时,所有之前用于设计精确算法的范式(分治、动态规划)依然有用,但贪心算法设计范式因其“通常不正确”的特性,反而成为设计快速启发式算法最常用的起点。

第十一步:尝试局部搜索

除了之前学过的范式,我们在第四部分学到的局部搜索算法设计范式对设计启发式算法也极为有用。如果你能清晰定义问题的可行解集合、目标函数以及“局部移动”的方式(即如何从一个可行解变到另一个相*的解),就值得尝试局部搜索。它既可以作为独立的启发式算法,也可以作为其他算法(如贪心算法)的后处理步骤,对已有解进行改进。

第十二步:妥协于速度(设计精确算法)

现在考虑另一条路:假设你决定妥协于速度,即追求在所有输入上都正确,但接受它可能很慢(指数时间)。这时,动态规划再次展现出强大威力。虽然对于NP难问题,动态规划算法在最坏情况下仍需要指数时间(例如因为子问题数量是指数级的),但它通常能显著优于朴素的穷举搜索。背包问题和旅行商问题的Bellman-Held-Karp算法就是经典例子。

第十三步:使用“魔法黑盒”

最后,如果你需要精确算法,但动态规划不适用或仍然太慢,可以考虑使用一些“半可靠的黑盒”求解器,例如混合整数规划(MIP)可满足性问题(SAT) 求解器。MIP更适合编码优化问题,而SAT更适合搜索可行解。如果你的问题能方便地编码成MIP或SAT问题,就值得用最新的求解器尝试一下。

总结

本节课我们一起学*了由13个步骤组成的算法设计实战指南。我们从尝试最省力的现成工具开始,逐步深入到贪心、分治、动态规划等核心设计范式,并探讨了如何利用数据结构和随机化进行优化。最后,我们学*了面对NP难问题时,如何在正确性与速度之间做出妥协,并介绍了相应的算法设计策略。请记住,这个指南只是一个起点和模型。随着你算法经验的积累和技能的提升,你将能够发展出属于你自己的、个性化的算法设计方法论。

至此,我们的《算法详解》系列之旅暂时告一段落。算法与数据结构的学*永无止境,希望本系列课程不仅为你提供了实用的工具,也激发了你对计算机科学更深的好奇与热情。站在巨人的肩膀上,愿你能在自己的项目中创造性地应用这些 brilliant 的思想。下次再见!

001:概述与预备知识 🎯

在本节课中,我们将学*《算法启蒙》第四册的概述,了解课程内容、学*目标以及学*本课程所需的预备知识。我们将探讨NP难问题的本质,以及面对这类问题时算法设计者可以采取的策略。

课程概述

本书的主题是NP难问题及其应对方法。现实世界中出现的许多计算问题都属于所谓的NP难问题。我们相信,这类问题无法通过前几册书中介绍的“始终正确且快速”的算法来解决。这意味着,当你在自己的项目中遇到NP难问题时,必须在正确性速度上做出妥协。

妥协于正确性:快速启发式算法

如果你选择妥协于正确性,你将进入快速启发式算法的领域。这类算法总是能快速运行,但在某些情况下可能无法输出正确的解。算法设计者的目标是设计一个至少在某种意义上是*似正确的快速启发式算法。

我们将重新审视一个古老的算法设计范式——贪心算法,并探讨其在设计快速启发式算法中的应用。我们还将学*一种你可能未曾接触过的技术——局部搜索,它在实践中对许多NP难问题非常有效。

以下是本部分将涉及的案例研究:

  • 旅行商问题
  • 调度问题
  • 团队招聘问题
  • 社交网络中的影响力最大化问题

妥协于速度:精确但可能较慢的算法

另一种选择是妥协于速度。这里你将设计一种始终保证正确的算法,但它并非在所有输入上都能快速运行,甚至在某些情况下可能需要指数时间。此处的目标是设计一个至少比穷举搜索等朴素算法有所改进的算法。

我们将再次回顾动态规划这一算法设计范式,并探讨其在改进NP难问题穷举搜索方面的应用。同样,我们还将学*一些新工具,特别是针对混合整数规划可满足性问题的先进求解器。

以下是本部分将涉及的案例研究:

  • 旅行商问题(再次探讨)
  • 在蛋白质-蛋白质相互作用网络中寻找信号通路
  • 几年前美国进行的一项涉及无线频谱的高风险拍卖

识别NP难问题

本书还将赋予你识别NP难问题的能力,这对于实际工作至关重要。你不希望无意中浪费时间,为一个可能根本不存在“始终正确且快速”算法的问题去设计这样的算法。

你将熟悉几个著名的NP难问题,例如:

  • 可满足性问题
  • 图着色问题
  • 哈密顿路径问题

通过实例,你还将学*NP难归约的技巧,从而具备证明新问题也是NP难的能力。

P vs NP 问题

最后,如果你曾听说过P与NP猜想并想知道它究竟是什么,本课程也将对此进行讲解。

详细内容概览

上一节我们介绍了课程的整体目标,本节中我们来看看各章节的详细内容安排。

第19章:什么是NP难问题

第19章将宏观地解释什么是NP难问题,它对算法设计者意味着什么,以及处理NP难问题时可用的工具。本章还将提供一个简单的“食谱”,帮助你在工作中识别NP难问题。

本章的目标是双重的:

  1. 在学完本章后,你将获得对NP难问题准确但较为浅显的理解,这本身已非常有用。
  2. 本章将为你奠定基础,为后续章节的深入学*提供背景。

第20章:妥协于正确性

第20章将探讨妥协于正确性的策略,即研究那些保证快速运行,但仅在某种意义上是*似正确的算法。

本章前半部分将聚焦于具有可证明性能保证的启发式算法,主要使用贪心算法来保证解接*最优。
本章后半部分将讨论局部搜索及其变体,它们通常没有可证明的保证,但在实践中对解决许多NP难问题却异常有效。

第21章:妥协于速度

第21章将讨论处理NP难问题的另一种妥协方式——妥协于速度。你希望算法始终正确,但愿意接受它有时会运行超过多项式时间。

本章前半部分将讨论具有可证明保证的算法,展示动态规划算法如何为一些有趣的问题(包括旅行商问题)改进穷举搜索。
本章后半部分将探讨一些在实践中同样异常有效但缺乏可证明保证的方法,特别是针对混合整数规划和可满足性问题的先进求解器。

第20章和第21章旨在丰富你的算法工具箱。如果有人给你一个问题并告诉你它是NP难的,你就知道该怎么做,有一些工具可以尝试。

第22章:识别NP难问题

但还有一个问题:如果遇到一个问题,你不知道它是否是NP难的,你如何判断是应该应用刚刚学到的新工具箱,还是回到设计快速精确算法的旧工具箱呢?这正是第22章要解决的问题。

本章旨在赋予你快速识别NP难问题的能力。这样,无需他人告知,你就能自己判断问题是否为NP难。如果是,你就可以运用在前两章中学到的技能。

第23章:深入理解(可选)

在前四章(19-22)中,对NP难问题的浅显理解足以满足我们的讨论需求。作为算法设计者,如果你只想用NP难理论来指导如何处理各种问题,浅显的理解就足够了。

然而,如果你想知道更多,比如NP和NP难的数学定义究竟是什么,或者你想了解P与NP猜想及其现状,我们将在第23章讨论所有这些内容。这是一组可选视频,适合那些希望深入数学原理的学*者。我们将重点介绍P与NP猜想以及一些更强的变体,如指数时间假说。

第24章:案例研究:高风险的频谱拍卖

作为“甜点”,最后一章(第24章)将探讨这个算法工具箱在一个涉及数百亿美元的真实高风险应用中的实践。

该应用涉及美国联邦通信委员会在2016年至2017年间进行的一次拍卖,目的是出售无线频谱许可证。政府希望一次性将大量许可证出售给出价最高者。

事实证明,用于此应用的拍卖从根本上涉及计算困难的问题,即NP难问题。因此,在2016年实际部署的拍卖实现中,使用了本课程中将学到的工具箱中令人惊讶的广泛部分。

在这个案例研究中,我们将看到从图着色问题,到基于贪心算法的快速启发式算法,再到可满足性求解器的使用。这一切旨在将本书和本课程的所有主题汇集在一起,同时也希望向你展示,到本课程结束时你将掌握的是一套相当复杂的工具箱,它能在重要应用中成为决定成败的关键技能。

预备知识

本课程是四册系列中的第四部分,因此我将假设你对前几部分的一些最重要概念有基本的了解。

以下是所需的基础知识:

  • 渐*记法:特别是用于分析算法运行时间的大O记法。
  • 基本数据结构:例如堆或搜索树。
  • 图论基础:例如,你可以使用广度优先搜索或深度优先搜索算法高效地搜索图,并且可以使用迪杰斯特拉等算法计算最短路径。
  • 算法设计范式:例如,一些贪心算法和动态规划算法的例子。

你不需要是数学天才才能学完本课程,但我希望你对数学不完全陌生。

以下是所需的数学基础:

  • 如果你在幻灯片上写下求和符号来对一系列数字求和,我希望你以前见过。
  • 希望你见过归纳证明和反证法的例子。
  • 如果我写下对数函数或指数函数,希望这不会让你感到太害怕。

你可以通过观看前三册的视频、阅读本系列的前几本书,或者很久以前通过其他教材学*过课程来获得这些背景知识。无论你如何获得这些背景知识,这都很好,这就是我期望你在学*本课程前已经掌握的内容。

总结

本节课中我们一起学*了《算法启蒙》第四册的课程概述。我们了解到,本课程将聚焦于NP难问题,并探讨两种主要的应对策略:妥协于正确性的快速启发式算法,以及妥协于速度的精确算法。我们还将学*如何识别NP难问题,并可选地深入理解其背后的数学理论。最后,我们将通过一个真实的频谱拍卖案例,看到所学工具箱的综合应用。要学*本课程,你需要具备算法、数据结构和基本的数学知识作为预备。

002:最小生成树与旅行商问题的算法之谜 🧩

在本节课中,我们将要学*两个看似相似但计算复杂度截然不同的问题:最小生成树问题和旅行商问题。我们将探讨它们之间的核心差异,并初步了解什么是NP难问题。


许多算法入门书籍,包括《算法启蒙》的前三册,都存在一种选择偏差。它们主要关注那些总能找到正确解且运行速度极快的算法,以及通常非常巧妙的算法。毕竟,学*一个聪明的算法捷径既有趣又能赋予我们力量。

然而,这种精心挑选的问题集合并不能代表算法设计的现实。在现实中,计算难解性的幽灵时常困扰着算法设计师。尽管存在许多拥有快速且正确算法的问题,例如图的搜索、连通分量、最短路径、序列比对等,但同样存在大量重要问题,它们似乎无法被总是快速且正确的算法解决。

意识到这一严峻现实后,两个问题立刻浮现出来。首先,如何识别一个问题属于这些难解问题,从而避免无意中浪费时间试图为其设计总是快速且正确的算法?其次,当知道一个问题本质上是计算难解时,应该如何调整目标?算法工具箱中又有哪些工具可以帮助我们实现这些调整后的目标?本视频系列以及《算法启蒙》第四册的目的,就是为你提供这两个问题的详尽答案。

最小生成树问题 🌲

计算难解问题有时看起来很像简单问题,我们需要适当的训练才能区分它们。让我们从一个希望你已经熟悉的问题开始:著名的最小生成树问题

最小生成树问题的输入是一个无向图。该图应该是连通的,意味着它是一个整体,你可以通过路径从图中的任意顶点到达其他任意顶点。每个边都有一个实数值的边成本,我们用 c(e) 表示边 e 的成本。

最小生成树算法的职责是计算图的一个生成树,并且在所有生成树中,它应该计算出一个最小化树中边成本总和的生成树。生成树顾名思义,它是一棵树(无环),并且是生成树(覆盖图的所有顶点)。换句话说,对于每对顶点 vw,在生成树 T 中应该存在一条从 vw 的路径。

例如,考虑一个具有四个顶点和五条边的图。

每条边都标有其成本。观察这个图,你会发现最小成本生成树包含成本为1、2和4的边。因为你有四个顶点需要连接,确实需要三条边。唯一比1、2、4成本更低的三条边组合是1、2和3,但它们会形成一个三角形,这既不是无环的,也不能覆盖所有顶点。因此,你能做到的最好选择就是1、2和4。所以,在这个例子中,最小生成树的总成本是7。

那么,最小生成树问题有多难?一方面,一个图可以有非常多的生成树。例如,组合数学中有一个著名的结果叫做凯莱定理,它指出:如果一个图有 n 个顶点,并且它是完全图(即所有 n2 条边都存在),那么这个 n 个顶点的完全图有 n^(n-2) 个不同的生成树。在本系列的这个阶段,你应该非常熟悉指数级数字增长极快的概念。例如,当 n=50 时,n^(n-2) 已经超过了已知宇宙中估计的原子数量。

这对我们意味着什么?图可以有海量的生成树。这意味着你能想到的最朴素的算法——穷举搜索(即逐个检查每个生成树并记住你见过的最好的那个)——除了在极小的图上,完全是一个无望实现的算法。生成树的数量如此之多,穷举搜索绝对不是快速算法。

但另一方面,你希望知道的是,尽管可能性数量是指数级的,但存在快速算法能够非常迅速地锁定所有生成树中最好的那个。我们在之前的视频中详细讨论过两种算法:普里姆算法(有点像迪杰斯特拉算法,缓慢地生长一棵树以覆盖整个图)和克鲁斯卡尔算法(对边进行排序,然后单次遍历,像小碎片一样生长生成树,最后融合在一起)。我们看到,如果使用适当的数据结构(例如普里姆算法用堆,克鲁斯卡尔算法用并查集数据结构),这两种算法都有极快的实现。它们可以在几乎线性的时间内运行(线性时间加上一个额外的对数因子),这非常惊人,尤其是考虑到它们是在海量的生成树中寻找最优解。

旅行商问题 🧳

现在让我们看第二个著名问题:旅行商问题。实际上,我们在之前的书籍和播放列表中甚至没有提到这个问题,但正如我们将在第四部分看到的,旅行商问题将扮演一个核心角色,它是最著名的NP难问题之一。有趣的是,这个问题听起来非常像最小生成树问题。

旅行商问题的输入,与MST问题类似,是一个无向图。在MST问题中,我们假设图是连通的;在TSP中,为了方便,我们假设它是一个完全图,即所有 n2 条边都存在(n 是顶点数)。与MST问题一样,每条边都有一个实数值的边成本。不要被MST要求连通而TSP要求完全图所困扰,这完全是一个表面的区别。例如,常规的MST问题完全等价于完全图上的MST问题,因为如果你给我一个不完全的图,我可以添加所有缺失的边并赋予它们极高的成本,这样MST就永远不会使用这些超高成本的边,它只会在原始图中找到MST。所以,当我们说图是完全的时,基本上不失一般性。

旅行商问题算法的职责不是返回一个生成树,而是返回一个旅行旅行商回路,同样要求最小化边成本的总和。什么是旅行商回路?它只是图中的一个行走路径,恰好访问每个顶点一次,并且结束于起点

例如,想象一个具有四个顶点的完全图。一个回路可能沿着边界走,即四条边界边。另一个回路可能使用锯齿形边。

为了确保你理解完全无向图中的回路含义,我们来看一个测验。

问题是:在一个有 n 个顶点(n 至少为3)的旅行商问题实例中,有多少个不同的旅行商回路?答案选项中的 n! 是阶乘函数,即1到 n 所有整数的乘积。例如,3! = 64! = 245! = 120,等等。注意,阶乘函数比 2^n 增长得更快。和往常一样,我鼓励你在这里暂停视频,思考一下这个测验,得出你的答案猜测,然后再继续播放视频,我们将讨论解决方案。

正确答案是 B:1/2 * (n-1)!。例如,如果 n=4,则有3个不同的回路。在上一张幻灯片中,我们看了四个顶点的完全图。我们看到了两个回路,实际上还有第三个回路,它也使用锯齿形边,但这次使用的是侧边而不是顶边和底边。一般来说,公式是 1/2 * (n-1)!。为什么答案是B?你可能直观地感觉到旅行商回路和顶点顺序之间存在对应关系。回路感觉就像你选择访问顶点的顺序,这可能会让你觉得选项D(n!)可能是正确答案。但实际上,所有顶点顺序都重复计算了回路。每个回路实际上被计算了 2n 次。首先,它被多算了 n 次,因为对于每个起点的选择,最终得到的都是同一个回路。其次,一个回路可以沿两个方向遍历,这给出了两个不同的顺序。所以,每个不同的回路对应 2n 个不同的顺序。总共有 n! 个顺序,因此就剩下 1/2 * (n-1)! 个不同的回路。

所以,回路的数量非常多,但这至少表明旅行商问题可以在有限的时间内解决——时间有限但很长。至少,你可以通过穷举搜索来解决旅行商问题,系统地枚举这 1/2 * (n-1)! 个回路中的每一个,并记住最好的那个。你可以自己在一个四顶点的例子中尝试穷举搜索。

这个测验的正确答案是第二个选项 B:13。同样,你可以通过枚举这个四顶点图中的三个不同回路来验证。一个是沿着边界的回路,总成本确实是13;一个是使用顶部和底部边的锯齿形路径,总成本为15;还有一个是使用锯齿形和侧边的回路,总成本为14。其中最便宜的是13,即沿着边界的回路。

对于像这样的小实例(四个顶点,或者五个、六个顶点),仅仅枚举指数级的回路并记住最好的那个没什么大不了的。但这只适用于最小的实例。作为算法设计师,我们有责任问:我们能否比朴素的穷举搜索做得更好?是否存在类似于MST问题的算法,能够神奇地在旅行商问题指数级大小的解空间中快速找到最小成本的那个“针”?

问题的根本差异 ⚖️

尽管这两个问题的陈述表面上相似,但旅行商问题实际上似乎比最小生成树问题根本性地困难得多

我可以给你讲一个关于旅行商问题的俗套故事,但那真的会贬低这个问题的价值,它实际上非常基础。每当你有一堆任务需要完成,并且完成一个任务的成本取决于你之前完成的任务时,你就遇到了一个TSP实例。例如,想象你在工厂里组装一批不同的汽车,你可以想象工厂需要处于某种特定配置才能组装特定类型的汽车,你也可以想象在将工厂从A型车重新配置为B型车时,可能会产生设置成本或转换成本。如果你需要组装许多不同类型的汽车,并试图找出正确的顺序,以便在最短的设置时间内全部组装完毕,这正是一个旅行商问题。

或者,对于一个非常不同的应用,在计算基因组学中,你可以想象你有一堆基因组短片段,它们部分重叠,你想逆向推导出这些片段在基因组上最可能的排列顺序。如果我给你每对片段的配对合理性度量(例如,基于它们最长公共子串的长度),那么逆向推导出最可能的顺序,这又是一个旅行商问题。

如果你想了解更多关于旅行商问题的应用及其迷人的历史,可以查阅Applegate、Dixby、Faal和Cook在2006年出版的一本好书。

TSP一直有很多自然应用,显然也具有巨大的美学吸引力。由于这两个原因,许多最伟大的优化思想者长期以来都在努力研究TSP的算法,至少可以追溯到20世纪50年代。许多非常严肃的人都在认真思考如何解决TSP。尽管经过70年的努力,许多杰出的头脑参与其中,但直到今天(2020年),仍然没有已知的快速算法来解决旅行商问题。还没有人提出哪怕接*普里姆算法和克鲁斯卡尔算法(用于最小生成树问题)那样好的算法。

更准确地说,我应该说明“快速算法”的含义。记得在本系列的第一部分或早期视频播放列表中,我们一致认为,快速算法应该是指运行时间随输入规模线性或接*线性增长的算法。那将是极快的算法。但在这里,我实际上是在谈论一个非常宽松的快速算法概念,即任何具有多项式运行时间的算法。先别提极快的运行时间,甚至没有人知道一个保证在 n^100 时间内运行的旅行商问题算法(n 是顶点数),甚至 n^10000 时间的算法也不知道。

对于这种令人沮丧的现状(即我们不知道任何保证多项式运行时间解决TSP的算法),有两种可能的解释。解释一:实际上存在一个快速算法,只是我们还不够聪明,没有发现它,它正等待着被发现。解释二:实际上我们找不到这样的算法,是因为根本不存在这种类型的算法。直到今天,我们也不知道这两种情况哪一种是真实的——是存在算法但我们没找到,还是根本不存在算法。

但大多数专家相信第二种解释,这等价于著名的 P ≠ NP 猜想。实际上,早在1967年(甚至在P与NP问题被正式提出之前),Jack Edmonds在一篇名为《最优分支》的著名论文中就推测,事实上不存在好的TSP算法,其中Edmonds所说的“好”是指运行时间随输入规模呈多项式函数增长。

TSP是NP难问题的一个例子。这意味着,在这个著名的数学猜想(P ≠ NP猜想)下,如果该猜想成立,那么Edmonds就是对的:事实上,不存在保证多项式时间的旅行商问题算法。随着我们继续学*,我们将看到,不仅仅是TSP是NP难的,不幸的是,计算难解性实际上是一个普遍现象,许多许多问题都是NP难的,其计算难度与旅行商问题相当。


本节课中我们一起学*了最小生成树问题和旅行商问题,这两个问题在表面上相似,但在计算复杂度上存在根本差异。我们了解到,尽管MST存在高效的算法(如普里姆和克鲁斯卡尔算法),但TSP被认为是NP难问题,目前没有已知的多项式时间算法,并且大多数专家相信这样的算法可能根本不存在。这引出了对计算难解性更广泛的探讨。在接下来的视频中,我们将讨论NP难度的不同专业级别,以及本系列中哪些部分最适合你想要达到的理解水平。

003:可能的专业水平层级 🎯

在本节课中,我们将学*如何界定你在掌握NP难问题及其算法含义方面所追求的专业水平层级。我们将探讨从零基础到专家级别的不同阶段,并明确每个阶段所需的知识和技能。

概述

本节内容旨在帮助你识别自己当前所处的专业水平,并明确未来希望达到的目标层级。理解这些层级有助于你规划学*路径,高效地投入时间。

专业水平层级详解

上一节我们介绍了本节的主题,本节中我们来看看具体的专业水平层级划分。

层级 0:完全不了解

在层级0,你从未听说过NP难问题。你不知道某些计算问题本质上是棘手的,无法用快速算法解决。当然,如果你遇到这些问题,你也不知道该如何处理。

层级 1:初步认知(鸡尾酒会水平)

达到层级1意味着你对NP难问题有了初步的认知。如果有人在谈话中提到它,你大致能明白他们在说什么。以下是该层级的关键认知:

  • 你知道NP难问题通常被认为是“困难”的。
  • 你理解“NP难”这个标签意味着,对于大规模问题实例,可能不存在既快速又总能给出正确答案的算法。
  • 你知道,如果你在工作中遇到一个被标记为NP难的问题,你需要采取行动。你可能需要重新表述问题、降低解决问题的期望,或者投入更多资源(包括人力和计算资源)。

例如,如果你管理的软件项目涉及算法组件,当团队工程师告诉你他们遇到了一个NP难问题时,你至少需要具备层级1的专业知识来理解情况的严重性。

如果满足于停留在层级1,阅读本书第19章或观看对应的初始系列视频就足够了。

层级 2:算法工具箱(赋能工程师)

对于对算法感兴趣的软件工程师而言,达到层级2可能是最具赋能性的。在此层级,你拥有丰富的算法工具箱,可以在自己的项目中遇到NP难问题时取得进展。

令人高兴的是,本系列前几册书中出现的所有算法设计范式,尤其是贪心算法动态规划,也将成为应对NP难问题的工具箱的一部分。我们还将看到一些新工具加入,例如局部搜索混合整数规划求解器

要将你的水平提升到层级2,你需要阅读本书第20章和第21章,或观看对应的视频。第20章专注于启发式算法(即放弃绝对正确性以保留速度),而第21章则探讨相反的折衷方案(即始终保持正确,并希望比朴素的穷举搜索做得更好)。

层级 3:问题诊断专家

在层级3,你不仅知道遇到NP难问题时该怎么做,还知道如何识别它们。在此层级,你的同事会带着问题来找你,帮助你诊断他们是需要更努力地思考一个快速且正确的算法,还是该问题确实是NP难的。

可以想象,当我为学生、同事或行业人士提供建议时,我经常同时运用层级2和层级3的工具箱。当然,一旦你运用层级3的工具箱确定问题是NP难的,你就可以切换到层级2的工具箱,设计算法在NP难的前提下尽力做到最好。

层级 4:黑带大师级(理论理解)

最后是NP难问题的黑带大师级,即层级4。这适用于初露头角的理论家,或那些真正希望从数学上深入理解NP难性和P与NP猜想本质的人。

在此层级,你可以拿起记号笔走到白板前,向你的同事准确解释整个“P与NP”问题。这将由第23章对应的可选视频涵盖。不阅读第23章,你也能完美理解本书的其他所有内容;但如果你想要更深入的数学理解,那么一定要查看第23章对应的视频。

总结

本节课中我们一起学*了关于掌握NP难问题的五个专业水平层级:

  • 层级0:完全不了解。
  • 层级1:具备初步认知,知道NP难意味着什么及其影响。
  • 层级2:掌握实用的算法工具箱,能对NP难问题取得实际进展。
  • 层级3:能够诊断并识别NP难问题。
  • 层级4:具备深入的理论理解,能解释P与NP等核心概念。

你应该根据自己愿意投入的时间以及认为值得达到的水平,来决定瞄准哪个层级。希望这些视频能帮你厘清最优的时间投入方式,让你根据想要达到的水平,明确知道该阅读什么和观看什么。

明确了这些层级之后,现在让我们进入第19章的核心内容,开始建立关于“简单问题”和“困难问题”含义的非正式理解。我们下节课见。

004:19.3_ 简单与困难问题

在本节课中,我们将初步、非正式地理解一个问题在计算上是“简单”还是“困难”意味着什么。我们将学*NP难理论如何对问题进行精确分类,区分像最小生成树这样的“简单”问题和像旅行商问题这样的“困难”问题。

一个简化的二分法

首先,让我们通过一个高度简化的二分法来理解NP难理论的核心观点。

在NP难理论中,一个“简单”问题指的是存在一个算法,其运行时间可以表示为输入规模的多项式函数。理想情况下,我们希望算法像最小生成树算法那样快(接*线性时间)。但对于NP难理论的目的而言,即使是二次方、三次方,甚至 n^100 时间复杂度的算法(其中 n 是输入规模),也足以将问题归类为“简单”。

简单问题运行时间公式O(n^d),其中 d 是一个与 n 无关的常数。

相比之下,一个“困难”问题(如旅行商问题被推测的那样)在最坏情况下需要指数级的时间。任何正确的算法,对于某些输入,其运行时间将像输入规模的指数函数那样增长。

这个二分法并非100%准确,它忽略了一些我们稍后将详细讨论的微妙之处。但作为一个入门级的简化理解,记住这个观点非常有帮助。

简单问题:多项式时间可解

上一节我们介绍了简单与困难问题的简化二分法。本节中,我们来看看什么是“简单”问题,即多项式时间可解的问题。

多项式时间算法是指运行时间由输入规模 n 的某个多项式函数(如 O(n^d)d 为常数)所限定的算法。我们在本系列的前三部分已经见过许多这样的算法。

以下是我们在之前学*中遇到的一些多项式时间算法的例子:

  • 归并排序:排序是一个简单问题,因为归并排序算法以 O(n log n) 的时间运行,这几乎是线性的。
  • Kosaraju算法:用于计算有向图的强连通分量,该算法运行时间为 O(m + n),其中 m 是边数,n 是顶点数。
  • Dijkstra最短路径算法:在边长为非负的有向图中,计算从起点到所有其他顶点的最短路径。与Prim的最小生成树算法类似,它能在接*线性的时间内运行。
  • Prim和Kruskal最小生成树算法:两者在配合合适的数据结构时,都能获得接*线性的运行时间。
  • 序列比对问题:我们看到了一个动态规划算法,其运行时间为 O(m * n),其中 mn 是两个输入字符串的长度。
  • Floyd-Warshall全对最短路径算法:该算法运行时间为 O(n^3)n 是顶点数。

所有这些算法的运行时间都由输入规模的多项式函数限定,因此它们都是多项式时间算法。

多项式时间算法定义:一个算法,如果其运行时间为 O(n^d),其中 n 是输入长度,d 是一个与 n 无关的常数,则该算法是多项式时间的。

这个定义可能看起来非常宽松(例如 n^77 也算)。然而,我们也见过非多项式时间的算法,例如穷举搜索最小生成树,其运行时间是指数级的。任何指数函数最终都会比任何多项式函数增长得快得多。

下图清晰地展示了这一点:尽管 100n^2 初始值较高,但在 n 约为13或14时,2^n 就会超越它,之后指数函数会以惊人的速度增长。

多项式 vs. 指数增长:
100n^2 (多项式)  vs.  2^n (指数)

这种差异的一个有趣含义是,随着计算能力的增长(摩尔定律),我们处理的问题规模也越来越大。问题规模越大,多项式时间算法和指数时间算法之间的性能鸿沟就越显著。因此,这种区分随着技术进步而变得更加重要。

另一种思考方式是考虑固定的时间预算(例如一小时或一天)。对于多项式时间算法,计算能力翻倍,能处理的问题规模也会成倍(或接*成倍)增加。但对于指数时间算法(如 O(2^n)),计算能力翻倍仅能将可处理的问题规模增加 1。因此,多项式时间算法才能真正从技术进步中受益。

在NP难理论中,我们定义“简单”问题为可由多项式时间算法解决的问题。根据这个定义,我们上面列举的所有问题(排序、最短路径、序列比对、最小生成树等)都是多项式时间可解的。

困难问题:NP难问题

上一节我们明确了多项式时间可解问题的定义。本节中,我们来看看如何定义“困难”问题,特别是NP难问题。

考虑旅行商问题。如果我们认为它不属于上一节定义的“简单”问题(即不存在多项式时间算法),我们如何收集证据来支持这个观点?

最直接的证据当然是数学证明,但至今无人能证明TSP不存在多项式时间算法。不过,我们可以收集一些间接证据。

第一个间接证据是,TSP是一个极其著名的问题,许多顶尖学者研究了数十年(约70年)仍未找到多项式时间算法。这虽然不能证明不可能,但强烈暗示了其难度。

然而,NP难理论提供了更强有力的证据。它表明,如果存在TSP的多项式时间算法,那么将自动为成千上万个其他目前未解决的难题也提供多项式时间算法。本质上,NP难理论揭示了包括TSP在内的数千个计算问题都是“同一问题”的不同伪装,它们的计算命运紧密相连。因此,试图为TSP设计快速算法,就等于在同时挑战这成千上万个相关难题。

你可能会说,同样有很多人尝试证明TSP没有快速算法也失败了。区别在于,人类似乎更擅长证明计算可行性(即设计巧妙算法),而不太擅长证明不可行性。如果TSP真有快速算法,以目前的研究投入和时长来看,尚未被发现是相当令人惊讶的。而如果它没有快速算法,鉴于当前的数学工具水平,我们尚未能证明这一点则不那么令人意外。因此,大多数专家倾向于相信TSP不存在多项式时间算法。

那么,什么是NP难问题?基本上,它意味着有强有力的证据表明该问题是难解的,即如果存在多项式时间算法解决它,将自动解决成千上万的其他难题。

在接下来的许多视频中,我们将采用一个临时定义:一个问题被认为是NP难的,如果存在多项式时间算法解决它会推翻一个著名的数学猜想——P ≠ NP猜想。

反过来理解:如果P ≠ NP猜想成立(大多数人相信如此),那么包括TSP在内的任何NP难问题都不存在多项式时间算法(即使是 n^100n^10000 的算法也不存在)。

P ≠ NP猜想(非正式表述):验证一个给定解的正确性,在本质上应该比从头开始寻找一个解要容易。例如,检查一个已完成的数独答案是否合规,远比你自己解出这个数独要简单快捷。对于TSP,验证一条路径的总成本是否小于等于某个值,也远比找到这样一条路径容易。这个猜想看似直观,但由于多项式时间算法可能非常精巧和出人意料(例如Strassen的矩阵乘法算法),我们至今无法证明它。尽管如此,大多数专家相信P ≠ NP是成立的。

澄清与总结

现在,让我们回顾并澄清本节开头给出的简化解释与NP难真实定义之间的差异。

在视频开头,我说NP难理论将“简单”问题识别为多项式时间可解,将“困难”问题识别为在最坏情况下需要指数时间。但现在我告诉你,“困难”问题的真实定义是NP难,而NP难意味着如果存在多项式时间算法解决它,将推翻P ≠ NP猜想。这与“需要指数时间”的说法并不完全相同。

以下是几个关键差异点:

  1. 条件性:NP难的难解性是有条件的,它依赖于P ≠ NP猜想这一尚未被证明的数学猜想。如果P = NP(即猜想错误),那么许多NP难问题实际上可以在多项式时间内解决。
  2. 介于多项式与指数之间:即使P ≠ NP成立,它也只是排除了多项式时间算法。这并不意味着算法必须运行指数时间。存在一些函数,其增长速度比任何多项式都快,但比任何指数函数都慢,例如 n^(log n)2^(√n)。这些仍然是NP难问题潜在算法运行时间的候选。
  3. 指数时间假设:对于本课程将讨论的大多数NP难问题,专家们不仅相信没有多项式时间算法,而且相信没有亚指数时间算法,即确实需要指数时间。这由一个更强的猜想——指数时间假设所形式化。
  4. 可解性:本课程讨论的所有NP难问题,至少可以通过穷举搜索在指数时间内解决。世界上还存在一些甚至指数时间也无法解决的问题(如停机问题)。
  5. 非严格的二分法:虽然绝大多数自然问题要么是P(简单),要么是NP难(困难),但也存在少数例外,如整数分解图同构问题,它们被认为是介于两者之间的“中间”问题。

尽管如此,开头的简化二分法——NP难通常意味着在最坏情况下需要指数时间——对于覆盖你将遇到的大多数情况以及作为一个易于记忆的总结来说,仍然是一个非常好的*似。

本节课中,我们一起学*了计算问题“简单”与“困难”的初步概念。我们明确了“简单”问题对应于多项式时间可解的问题,并看到了许多例子。对于“困难”问题,我们引入了NP难的概念,理解到其难解性通常基于P ≠ NP猜想,并且对于许多经典问题(如TSP),这实际上意味着需要指数级计算时间。我们还澄清了简化模型与精确定义之间的细微差别。

接下来,我们将概述当在实际应用中遇到NP难问题时,算法工具箱中有哪些策略可以使用。

005:NP难问题的算法策略 🛠️

在本节课中,我们将要学*面对NP难问题时,可以采取的三种核心算法策略。这些策略通过在不同方面做出妥协,使得我们能够在实践中处理这些计算上棘手的问题。

概述

NP难问题在现实世界中非常普遍。假设你在一个项目中遇到了一个对项目成功至关重要的计算问题,但尝试了所有已知的算法设计范式、数据结构和基础原语后,仍然找不到高效的算法。最终,你意识到这个问题是NP难的。这解释了之前的努力为何失败,但问题本身并未消失。好消息是,NP难并不意味着完全无解。通过应用足够的算法技巧和资源,我们通常可以在实践中(至少*似地)解决这些问题。NP难性对算法设计者提出了挑战,并设定了合理的期望:我们不应期望找到像排序、最短路径或序列比对那样既快速又永远正确的“完美”算法,除非处理的是非常小或结构特殊的实例。

三种妥协策略

NP难性排除了同时具备三个理想属性的算法(假设P≠NP猜想成立)。这三个理想属性是:通用性(适用于所有输入)、正确性(总能给出正确答案)和快速性(理想情况下是多项式时间)。因此,相应地,我们可以选择在以下三个方面之一做出妥协。

以下是三种主要的妥协策略:

  1. 妥协于通用性:放弃解决所有可能输入的NP难问题,转而专注于处理一个特定的、更易处理的输入子集。对于这个子集,问题可能变得多项式时间可解。
  2. 妥协于正确性:放弃算法总能给出精确正确答案的要求,转而寻求在大多数情况下正确,或者总能给出*似正确答案的算法(即启发式算法或*似算法)。
  3. 妥协于速度:放弃多项式时间运行的要求,接受算法在最坏情况下可能需要超多项式时间(如指数时间),但力求设计出比穷举搜索快得多的精确算法。

接下来,我们将详细阐述这三种策略。

策略一:妥协于通用性 🎯

第一种策略是妥协于通用性,即放弃尝试解决NP难问题的所有可能输入,转而将注意力限制在所有可能输入的一个子集上。在最理想的情况下,对于你所关注的这个子集,问题实际上会变得多项式时间可解,你将能够为该特殊类别的输入设计出既快速又永远正确的算法。

如果你一直跟随本系列书籍或视频,你已经见过几个针对NP难问题特殊情况的快速精确算法的例子。

以下是两个例子:

  • 加权独立集问题:给定一个无向图,每个顶点有一个非负权重。目标是找到一个独立集(即一组互不相邻的顶点),使得其总权重最大。这个问题在一般情况下是NP难的。然而,在路径图(顶点排成一条线)或树图的特殊情况下,我们可以使用动态规划在线性时间内精确求解最大权重独立集。
  • 背包问题:给定n个物品,每个物品有整数价值和整数体积,以及一个整数背包容量。目标是选择总体积不超过容量且总价值最大的物品子集。背包问题在一般情况下也是NP难的。我们之前学过一种动态规划算法,其运行时间为 O(n * C),其中n是物品数量,C是背包容量。需要注意的是,只有当容量C以n的多项式为界时,这个算法才是多项式时间的。如果C非常大(例如 2^n),那么运行时间将是指数级的,而输入规模(描述数字所需的位数)可能只是多项式的,这解释了为何该算法不违反P≠NP猜想。

关于妥协通用性的工作,看起来与我们在第一部分到第三部分所做的所有工作完全相同。第一到第三部分的重点就是开发一个工具箱,用于设计总是正确且总是快速的算法。这个工具箱尤其可以应用于NP难问题的特殊情形,当这些情形实际上是多项式时间可解的时候。

这里可以做一个总体评论并给予一些鼓励:如果在现实生活中必须处理NP难问题,保持坚持和不放弃至关重要。作为算法设计者,你已经知道这一点,但对于在NP难问题上取得进展而言,这一点比以往任何时候都更重要。通常,你必须“用尽一切办法”来真正获得你想要的进展。

让我举一个随机例子,说明如何组合工具箱中的不同工具。假设老板给你一个相当大的图(例如10000个顶点),需要计算最大权重独立集。你不能使用穷举搜索,因为图有10000个顶点。如果它是一个树,你可以用动态规划在线性时间内解决问题。假设它不是树,而是包含许多循环。也许你似乎陷入了困境。但想象一下,你运用领域专业知识,意识到在这10000个顶点中,有20个顶点是最重要的,并且这20个顶点实际上与图中的每个循环都相交。换句话说,当你从图中移除这20个顶点时,剩下的图是无环的,只是一些树的集合。如果是这种情况,你确实可以精确地解决这个问题:通过结合穷举搜索和动态规划的混合方法来计算最大权重独立集。

具体做法是:你对这20个特殊顶点的所有子集进行穷举搜索,猜测哪些属于独立集,哪些不属于。然后移除这些顶点,剩下一个无环图,现在你可以应用动态规划在线性时间内解决剩余问题。这将需要检查大约 2^20(约一百万)个子集,每个子集对应一个可以在线性时间内解决的子问题。总体计算量可能在数百亿次操作,现代笔记本电脑可以在可接受的时间内完成。相比之下,如果不使用这种技巧,直接进行穷举搜索,当n超过40时,操作次数就会超过万亿。

关键要点是:即使你的应用问题并不完全等同于某个NP难问题的计算易处理特例,你仍然可以将特例的解决方案作为更复杂算法的构建模块。

策略二:妥协于正确性 🤖

第二种处理NP难问题的算法策略是妥协于正确性。这在时间紧迫的应用中是一个特别受欢迎的选择,因为你确实需要算法快速运行,并且愿意牺牲一点正确性来实现这一点。这类不保证永远正确的算法通常被称为启发式算法

在本系列中,我们还没有真正见过许多不保证正确的算法解决方案的例子。事实上,我能想到的唯一例子是在第二部分末尾讨论数据结构时提到的布隆过滤器。布隆过滤器是哈希表的一种“表亲”,它使用更少的空间,但代价是存在较小的误报率。那是一个不总是给出正确答案的数据结构例子。因此,我认为这将是我们第一次讨论不总是给出正确答案的算法。

在设计启发式算法时,你是有意放弃正确性。当然,你希望尽可能少地放弃正确性,你希望启发式算法在某种意义上仍然是*似正确的。也许它在大多数你可能遇到的输入上都是正确的,或者甚至对于所有输入,你都有某种可证明的保证,确保算法至少是*似正确的。

对于优化问题(目标是计算一个具有最佳目标函数值的可行解,例如总成本最低的旅行商路线),“几乎正确”可能最容易理解。它意味着算法输出一个可行解,其目标函数值接*最佳可能值,例如一条总成本不比最优路线高太多的旅行商路线。

你现有的用于设计快速精确算法的工具箱,对于设计快速启发式算法也直接有用。例如,在本视频播放列表的后面,我们将看到用于从调度到团队招聘问题,再到社交网络中影响力最大化等一系列问题的贪心启发式算法。我们将讨论的所有这些启发式算法都附有*似正确性的证明,保证对于每个输入,启发式算法的输出都在最佳可能目标函数值的一个适度常数因子范围内。

需要说明的是,有些作者将这类保证目标函数值在最优值常数倍范围内的算法称为*似算法,而将“启发式算法”这个术语保留给没有这种可证明保证的算法。我们不会做这种区分,对我们来说,启发式算法指的是不总是正确的算法,它可能具有*似正确性的可证明保证,也可能没有。

这些例子实际上是重新审视了我们算法工具箱中久经考验的可靠成员——贪心算法,并将其重新定位,不是用于精确算法,而是用于快速启发式算法。

我还想介绍一种我们在本书系列中尚未讨论过的技术,它特别适合许多不同的NP难问题,尽管它通常没有可证明的保证,但在实践中对解决NP难问题常常异常有效,这种技术就是局部搜索

策略三:妥协于速度 ⚡

我们将讨论的第三种也是最后一种处理NP难问题的策略是妥协于速度。在这里,我们将关注精确算法,这适用于那些确实不能在正确性上妥协的应用。在保证正确的前提下,你希望算法尽可能快。

对于NP难问题,再次假设P≠NP猜想成立,你不应期望多项式时间,甚至不应期望在最坏情况下是亚指数时间。你必须准备好接受指数级的最坏情况运行时间。但希望是,在大多数情况下,你仍然能比穷举搜索做得更好。

这可能意味着几件不同的事情:

  1. 算法通常运行得很快,例如在多项式时间内,或者甚至是低次多项式时间内,至少对于你应用中经常出现的输入是如此。也许不是永远,但大多数时候你看到的是非常快的运行时间。
  2. 你可能希望有一个可证明的保证,表明对于问题的每一个输入,你保证比穷举搜索运行得更快。

在第二种情况下,即使我们保证对每个输入都比穷举搜索快,我们仍然应该预期算法在最坏情况下以指数时间运行。毕竟,问题是NP难的。但我们将看到几个例子,虽然仍然是指数时间算法,但它们保证能比穷举搜索做得显著更好

第一个例子是针对旅行商问题。正如我们所知,穷举搜索的时间规模是 n!(n的阶乘)。我们将使用动态规划来设计一个算法,其运行时间规模为更小的 2^n(仍然是指数级,但比 n! 好),再乘以n的一个多项式函数。

我们还将看一个结合了随机化的动态规划算法,用于在图中寻找长路径,该算法已应用于在蛋白质-蛋白质相互作用网络中寻找信号通路。具体来说,如果我们在图中寻找长度为K的路径(即跨越K个顶点,K-1条边),朴素的穷举搜索规模为 n^K,其中n是顶点数,K是目标路径长度。而我们结合随机化和动态规划的方法将把运行时间降低到大约 e^K(其中e是自然对数的底数,约2.718)乘以图大小的线性函数。

这是两个非常酷的例子,我们再次重新审视了工具箱中已有的一个工具——动态规划。我们曾努力掌握这项技能,在这里我们看到了它的另外几个非常好的应用,将其应用于NP难问题,并且即使在最坏情况下也比穷举搜索更快。

要在规模达到数千或更大的NP难问题实例上取得进展,通常需要额外的工具,这些工具没有比穷举搜索更好的最坏情况运行时间保证,但在实践中却异常有效。我想在本视频播放列表的后面部分向你介绍其中两种工具:混合整数规划求解器(MIP求解器)和可满足性问题求解器(SAT求解器)。

这里的“求解器”指的是一种现成的、经过专家实现和精细调优、在实践中表现非常出色的实现。事实证明,许多NP难优化问题(如旅行商问题等)都可以编码为混合整数规划问题。同样,许多是/否问题(例如检查一系列对稀缺资源的请求是否能全部满足)自然可以转化为可满足性问题。每当你面对一个可以轻松指定为MIP或SAT问题的NP难问题时,尝试应用最新、最先进的求解器是非常值得的。当然,MIP或SAT求解器无法保证在合理时间内解决你的特定实例(毕竟问题是NP难的),但它们构成了在实践中处理NP难问题的最前沿技术。

总结

在本节课中,我们一起学*了面对NP难问题时可以采取的三种核心算法策略。

首先,我们了解到妥协于通用性意味着专注于问题的易处理特例,并利用已有的算法工具箱(如动态规划)为这些特例设计快速精确的算法。我们甚至可以将特例解决方案作为构建块,嵌入到更复杂的混合算法中。

其次,妥协于正确性引导我们使用启发式或*似算法。这些算法放弃永远正确,以换取速度,并可能提供*似最优解的可证明保证。贪心算法和局部搜索是这类策略的典型代表。

最后,妥协于速度涉及设计最坏情况下为指数时间,但比朴素穷举搜索快得多的精确算法。动态规划结合其他技巧(如随机化)可以在此方面发挥强大作用。此外,利用成熟的MIP或SAT求解器也是处理许多可编码NP难问题的强大实用方法。

重要的是要记住,NP难问题无处不在,遇到它们是正常的。NP难性意味着(在P≠NP的假设下)我们无法同时获得通用性、正确性和快速性。然而,NP难性并非死刑判决。通过运用算法技巧、投入足够的资源,并愿意在上述一个或多个方面做出明智的妥协,我们通常能够在实践中有效地处理这些问题。接下来的课程将深入探讨后两种妥协策略的具体技术和应用。

006:证明NP难性的简单方法 🧪

在本节课中,我们将学*如何证明一个问题是NP难的。核心方法是掌握一个简单的“两步法”配方,并通过归约来传播计算难解性。


概述

在算法研究中,识别NP难问题至关重要,这能避免我们徒劳地为其寻找“过于理想”的多项式时间算法。为此,我们需要掌握两项核心技能:一是了解一系列已知的NP难问题;二是熟练运用归约法,将一个已知的NP难问题归约到我们关心的问题上,从而证明后者的NP难性。

上一节我们介绍了NP难问题的基本概念,本节中我们来看看如何具体证明一个问题是NP难的。


归约:传播可解性与难解性

作为算法学*者,我们*惯于寻找归约。当我们遇到一个新问题时,会思考它是否能归约到某个已知如何解决的问题上。归约可以将一个问题的可解性(即多项式时间可解性)传播到另一个问题。

更正式地说,如果问题A可以归约到问题B,并且B存在多项式时间算法,那么A也存在多项式时间算法。可解性传播的方向与归约方向相反。

然而,NP难理论则利用归约来传播难解性。如果问题A是NP难的,并且A可以归约到问题B,那么问题B也是NP难的。难解性传播的方向与归约方向相同。

以下是归约的数学定义:

定义:如果任何解决B的算法都能被“轻松地”转化为解决A的算法,则称问题A归约到问题B。

这里的“轻松地”转化,意味着转化过程本身是一个多项式时间算法,并且它调用B求解子程序的次数也是输入规模的多项式函数。


证明NP难性的两步法

基于上述原理,我们得到一个证明问题NP难性的简单配方:

  1. 第一步:选择一个已知的NP难问题A。
  2. 第二步:将问题A归约到你所关心的问题B。

如果你成功完成了这个归约,那么你就证明了问题B至少和问题A一样难。由于A是NP难的,因此B也是NP难的。


归约实例回顾

为了加深理解,让我们回顾一些之前见过的、用于传播可解性的归约例子:

以下是几个归约的例子:

  • 中位数查找归约到排序:对数组排序后,中位数就是中间元素。因此,任何O(n log n)的排序算法都能给出一个O(n log n)的中位数查找算法。
  • 全源最短路径归约到单源最短路径:要计算图中所有顶点对之间的最短距离,可以对每个顶点运行一次单源最短路径算法(如Bellman-Ford算法)。如果单源算法的时间复杂度是O(mn),那么全源算法的时间复杂度就是O(mn²)。
  • 最长公共子序列归约到序列比对:通过将序列比对问题中的间隙惩罚设为1,并将不匹配代价设为一个非常大的数,序列比对的动态规划算法就可以用来求解最长公共子序列问题,时间复杂度同为O(mn)。

这些例子展示了如何利用已知问题的快速算法,为其他问题构造快速算法。


应用两步法:证明“无环最短路径”问题是NP难的

现在,让我们通过一个具体例子来实践两步法。我们将证明“无环最短路径”问题(即在允许负权边的图中,寻找不包含环的最短路径)是NP难的。

第一步:选择已知的NP难问题

我们选择有向哈密顿路径问题作为已知的NP难问题A。

  • 输入:一个有向图G,以及指定的起点S和终点T。
  • 目标:判断图中是否存在一条从S到T的哈密顿路径(即恰好访问每个顶点一次的路径)。

我们暂且接受“有向哈密顿路径问题是NP难的”这一结论。

第二步:归约到“无环最短路径”问题

我们需要构造一个归约,使得如果我们有一个能解决“无环最短路径”问题的“魔法黑盒”,我们就能利用它来解决“有向哈密顿路径”问题。

以下是归约的具体步骤:

  1. 构造实例:给定一个有向哈密顿路径问题的实例(图G,起点S,终点T)。我们构造一个“无环最短路径”问题的实例:使用同一个图G和同一个起点S,并为图中的每条边赋予长度-1
  2. 调用子程序:将构造好的实例(图G,起点S,所有边长为-1)输入到假设存在的“无环最短路径”求解子程序中。
  3. 解读答案:子程序会返回从S到所有顶点的最短无环路径距离。我们只关心从S到T的距离。设图G有n个顶点。
    • 如果从S到T的最短无环路径距离恰好是 -(n-1),则原始图G存在S到T的哈密顿路径。
    • 否则,原始图G不存在S到T的哈密顿路径。

归约正确性分析

  • 如果G存在哈密顿路径:这条路径恰好有n-1条边,且无环。在我们构造的实例中,其长度就是-(n-1)。由于这是访问所有n个顶点所可能达到的最长(边数最多)的无环路径,因此它也是该实例中从S到T的最短(因为边权为负,路径越长,总权值越小)无环路径。子程序会返回距离-(n-1)。
  • 如果G不存在哈密顿路径:那么任何从S到T的无环路径最多只能访问少于n个顶点,其边数最多为n-2条。因此,在我们构造的实例中,任何无环路径的长度都大于(即比-(n-1)大,例如-(n-2))。子程序返回的距离将严格大于-(n-1)。

因此,通过检查子程序返回的S到T的距离是否为-(n-1),我们可以正确判断原问题实例的答案。这证明了“有向哈密顿路径问题”可以归约到“无环最短路径问题”。

结论

由于我们假设“有向哈密顿路径问题”是NP难的,并且它可归约到“无环最短路径问题”,根据两步法,我们得出结论:“无环最短路径问题也是NP难的”。

这解释了为什么我们在学*最短路径算法(如Dijkstra算法、Bellman-Ford算法)时,都要求图中没有负环,或者只处理非负权边。如果存在能高效处理含负环图中无环最短路径的算法,那就意味着我们为NP难问题找到了多项式时间解法,从而否定了P≠NP的猜想。


总结

本节课中我们一起学*了证明问题NP难性的核心方法:

  1. 我们理解了归约是传播问题可解性与难解性的关键工具。
  2. 我们掌握了一个简单的两步法配方:1) 选取已知NP难问题;2) 将其归约到目标问题。
  3. 我们通过将有向哈密顿路径问题归约到无环最短路径问题的具体例子,实践了这个配方,并证明了后者的NP难性。

掌握这个方法能帮助我们在面对复杂问题时,快速判断其内在的计算难度,从而合理选择算法设计策略(如寻求*似算法、启发式方法或限制问题范围)。在后续章节中,我们将看到更多NP难问题的例子以及更复杂的归约技巧。

007:新手常见错误 🚫

在本节中,我们将探讨关于NP难问题的一些常见误解和错误表述。理解这些内容有助于你在学术讨论和实际工作中更准确地使用相关概念,避免因表述不当而被视为“新手”。

概述

NP难是一个技术性较强的话题,但对程序员和计算机科学家而言又高度相关。在日常交流中,人们有时会对严格的数学定义进行一些宽松的解释,以方便沟通。然而,某些关于NP难的不准确表述会明显暴露你对概念的生疏,而另一些则被广泛接受。本节将列出五个常见的新手错误,并解释哪些不准确表述在文化上是可接受的。

五个常见的新手错误

以下是五个需要避免的常见错误。

错误一:误解“NP”的含义

第一个错误关乎“NP”这个缩写词的含义。重要的是记住它不代表什么,而不是它代表什么。它不代表“非多项式”。虽然普遍认为NP难问题无法在多项式时间内解决,但“NP”并非“非多项式”的缩写。如果你真的好奇,它代表“非确定性多项式时间”。更多细节将在本系列课程末尾的选修讲座中讨论。

错误二:混淆“NP难”与“NP问题”

第二个常见错误是,当想说一个问题“NP难”时,却说它是“NP问题”或“属于NP”。如果你继续观看关于NP复杂性类形式定义的选修视频,你会了解到,说一个问题“属于NP”实际上是在说关于该问题的正面特性,即可验证性,而非难解性。例如,我给你一个填好的数独,你可以快速验证它是否是一个有效解。因此,当谈论计算困难时,务必加上“难”字,说“NP难”。

错误三:认为NP难仅是学术概念

第三个错误是认为NP难仅仅是学术概念,与实际应用无关。这是一个很大的误解。确实,NP难并非“死刑判决”,有许多成功驯服NP难问题的实际案例,前提是投入足够的人力和计算资源。本系列后续视频将展示几个例子。然而,在现实世界中,也有大量因NP难带来的挑战而不得不修改甚至完全放弃计算问题的案例。人们更倾向于夸耀成功解决NP难问题的经历,而非多次失败的尝试,因此你听到的成功案例远多于失败案例。但请相信,失败案例大量存在。如果NP难在实践中不重要,为什么快速启发式算法在实践中如此普遍?如果总能解决难题,就没有必要诉诸启发式方法了。事实上,现代电子商务所依赖的密码系统(如RSA)的安全性,就建立在“大数分解在计算上困难”的假设之上。如果有一个能快速可靠解决NP难问题的“魔法盒”,那将催生高效的因数分解算法,从而破解RSA密码系统——据我们所知,这尚未发生。

错误四:认为硬件进步能解决NP难问题

第四个错误是认为随着计算机速度越来越快,今天困难的问题明天就会变得简单。正如我们过去讨论的,计算技术的进步(例如摩尔定律)实际上使NP难理论变得更加相关。请记住,随着计算能力提升,我们感兴趣解决的问题规模也在增长。问题规模越大,多项式运行时间和指数运行时间之间的差距就越大。因此,随着技术越来越好,NP难问题变得比以往任何时候都更相关。

错误五:归约方向错误

第五个错误很难避免,即使是专业理论计算机科学家有时也会犯。这个错误关乎设计归约时弄错了方向。请记住,当我们证明一个问题是NP难时,难解性沿着归约的相同方向传播。如果你将问题A归约到问题B,那么难解性是从A传播到B。在构思NP难证明及其涉及的归约时,通常会有一种强烈的诱惑去做错误方向的归约,仅仅因为那是我们*惯的方向。你必须记住,如果你想证明问题B是难解的,难解性必须从某个已知的难问题沿着归约方向传播到B。也就是说,你必须将一个已知的难问题A归约到你的目标问题B,而不是反过来。这是非常容易犯的错误。唯一的解决办法是,每次你认为自己证明了一个问题是NP难时,回过头再三检查归约方向是否正确,即难解性是否从已知的难问题流向感兴趣的问题。

三种可接受的不准确表述

以上是五个需要避免的新手错误。接下来,让我以三种在文化上可接受的不准确表述作为总结。严格来说,这些陈述在数学上并不完全正确,但如果你这样表述,每个人都能理解你的意思,不会动摇别人对你掌握NP难概念的信心。

可接受表述一:假设P≠NP猜想为真

第一种可接受的不准确表述是直接假设P≠NP猜想为真,即认为验证问题的解从根本上比从头开始想出解更困难。正如我们所讨论的,时至今日我们仍不知道P≠NP猜想是真是假。我们的直觉强烈认为它应该是真的。因此,虽然感觉像是在等待我们的数学技术赶上直觉,但大多数计算机科学家或多或少将P≠NP视为自然法则,并据此进行推理。

可接受表述二:将“NP难”与“NP完全”视为同义词

第二种可接受的不准确表述是将两个术语视为同义词,而实际上它们并非完全相同。这两个术语是“NP难”和“NP完全”。基本上,“NP完全”是“NP难”的一种特定形式。细节有些技术性,我仅在那些讨论NP复杂性类形式定义的选修视频中讨论。如果你关注算法方面,一个问题究竟是NP完全还是NP难,其实并不重要。关键结论是一样的:假设P≠NP猜想成立,这些问题都没有多项式时间算法能解决。因此,无论你的问题是NP完全还是NP难,你都必须做出妥协,这将在后续视频中讨论。

可接受表述三:将NP难等同于最坏情况下需要指数时间解决

第三种可接受的不准确表述是将NP难等同于在最坏情况下需要指数时间来解决。这基本上是我首次引入该术语时给出的过度简化的总结。正如我们后来所见,这种说法忽略了一些细微之处。例如,有些问题甚至无法在指数时间内解决(如停机问题)。还有一些问题似乎处于中间状态——太难以至于无法用多项式时间解决,但又不够难到成为NP难问题(如大整数分解)。但一般来说,日常工作中的计算机科学家或多或少会将NP难与最坏情况下的指数时间可解性等同起来。因此,如果你在非正式对话中隐含地将这两者等同,没有人会感到惊讶。

总结

本节课我们一起学*了关于NP难问题的五个常见新手错误和三种可接受的不准确表述。我们明确了“NP”的正确含义、区分了“NP难”与“NP问题”、认识到NP难的实际相关性、理解了硬件进步无法从根本上解决NP难问题,并学会了如何正确设计归约方向。同时,我们也了解到在非正式交流中,假设P≠NP、混用“NP难”与“NP完全”、以及将NP难等同于指数时间复杂性,通常是可被接受的简化表述。

本章(第19章)的视频序列到此结束,我们直观地解释了什么是NP难、它对算法设计者意味着什么、遇到NP难问题时可以做什么、以及如何自行证明问题是NP难的。接下来,我们将丰富你的算法工具箱,为你提供遇到NP难问题时可以取得进展的新工具。接下来的重点将是快速启发式算法。这是我们在愿意牺牲一点正确性(即有时允许出错),但确实需要快速算法时,对NP难问题做出的一种妥协。我们将在下一个视频序列中探讨这些内容。

008:最小化最大完工时间(第一部分)📅

在本节课中,我们将学*一个经典的调度问题——最小化最大完工时间问题。我们将探讨其定义,并介绍一种简单快速的贪心算法(格雷厄姆算法),同时分析该算法的*似性能保证。

问题定义 🎯

在调度问题中,需要分配的任务通常被称为“作业”,而资源则被称为“机器”。一个“调度方案”就是为每个作业指定由哪台机器处理。

我们假设不同作业有不同的长度,用 L_j 表示作业 j 的长度。最常见的优化目标是:如何分配作业,使得所有作业尽可能快地完成。

为了形式化这个目标,我们定义一个目标函数来量化调度方案的好坏。

首先,定义一台机器的负载:即分配给该机器的所有作业的长度之和。

接着,我们关注所有机器负载中最大的那个,这被称为一个调度方案的最大完工时间

我们的目标就是最小化这个最大完工时间。

需要注意的是,机器负载仅取决于分配给它的作业长度之和,与作业在机器上的处理顺序无关。因此,我们只关心每个作业被分配到哪台机器。

一个快速测验 ✅

为了确保理解机器负载和最大完工时间的定义,请看以下例子:

假设有两台机器和四个作业,长度分别为:1, 2, 2, 3。

  • 调度方案A:机器1处理长度为2和2的作业;机器2处理长度为1和3的作业。
  • 调度方案B:机器1处理长度为2和3的作业;机器2处理长度为1和2的作业。

在调度方案A中,机器1的负载是4,机器2的负载是4,因此最大完工时间为4。
在调度方案B中,机器1的负载是5,机器2的负载是3,因此最大完工时间为5。

最小化最大完工时间问题 ⚙️

现在,我们可以正式定义最小化最大完工时间问题:

输入m 台相同的机器,n 个作业,每个作业 j 有一个处理时间(长度)L_j
目标:将每个作业分配给一台机器,使得最终调度方案的最大完工时间(即最大机器负载)尽可能小。

这个问题在实际中非常常见。例如,如果作业代表一个并行计算任务(如MapReduce或Hadoop程序)的各个部分,那么整个计算任务的完成时间就由调度方案的最大完工时间决定。

问题的复杂性与启发式算法 💡

与本章讨论的所有问题一样,最小化最大完工时间问题是一个NP难问题。这意味着我们无法找到一个对所有输入都快速且精确求解的通用算法。

因此,我们必须做出妥协,考虑快速启发式算法。这类算法对所有输入都运行很快,并且在某种意义上是“*似正确”的。

贪心算法范式 🔄

贪心算法设计范式是构思启发式算法的绝佳起点。其思想是:通过一系列“短视”的决策,迭代地、一步一步地构建解决方案,并希望最终结果良好。

贪心算法有两个主要优点:

  1. 易于构思,对于许多问题都容易想到。
  2. 通常非常简单,因此运行速度极快(常为线性或*线性时间)。

当然,贪心算法的主要缺点是它们往往“过于简单”,无法在所有情况下都得到精确的最优解。然而,对于NP难问题,任何快速的多项式时间算法都注定无法始终正确。因此,贪心算法的这个“缺点”恰恰符合我们对快速启发式算法的期望,使其成为设计这类算法的完美起点。

应用于我们的问题:格雷厄姆算法 🧠

现在,让我们将贪心算法范式应用于最小化最大完工时间问题。

我们需要将每个作业分配给一台机器。一个自然的贪心策略是:按某种顺序(例如任意顺序)逐个处理作业,并在处理每个作业时,将其** irrevocably (不可撤销地)分配给当前负载最轻**的机器。

这个算法被称为格雷厄姆算法。其逻辑很直观:为了平衡负载、最小化最大值,我们总是将新作业交给当前“最闲”的机器。

算法实现与运行时间 ⏱️

以下是格雷厄姆算法的一个简单实现思路:

  • 维护一个数组,记录每台机器的当前负载(初始为0)。
  • 遍历所有 n 个作业。
  • 对于每个作业,扫描所有 m 台机器,找到当前负载最小的那台。
  • 将该作业分配给那台机器,并更新其负载。

这个简单实现的运行时间是 O(m * n)

我们可以利用数据结构进行优化。注意到,在每次迭代中,我们都在进行“查找最小值”的操作。这提示我们可以使用堆(优先队列)。使用一个最小堆来维护机器的负载,可以将每次查找和更新的时间从 O(m) 降低到 O(log m)。因此,优化后的运行时间为 O(n log m)

算法性能分析:一个例子 📊

贪心算法通常运行很快,但关键问题是:它产生的调度方案质量如何?

考虑以下输入:有5台机器,21个作业。其中20个作业的长度为1,第21个作业的长度为5。

格雷厄姆算法会如何处理?

  1. 前20个长度为1的作业会被尽可能均匀地分配。处理完它们后,每台机器上都有4个作业,负载均为4。
  2. 当处理长度为5的第21个作业时,所有机器的负载都是4。根据贪心规则,它会将其分配给当前负载最小的机器(任意一台)。这使得该机器的负载变为9。
  3. 最终,格雷厄姆算法产生的调度方案的最大完工时间为 9

最优调度可能是怎样的?
我们可以“预留”一台机器专门处理那个长作业(长度为5)。剩下的4台机器处理20个长度为1的作业,每台分配5个,负载为5。这样,所有机器的负载都是5。
因此,最优调度方案的最大完工时间可以低至 5

这个例子表明,格雷厄姆算法并不总是最优的。在这个例子里,它的结果(9)比最优解(5)差了将*一倍。

*似性能保证:一个“保险单” 🛡️

你可能会担心,是否存在更极端的例子,让格雷厄姆算法的结果糟糕透顶?

令人欣慰的是,我们可以为格雷厄姆算法证明一个*似性能保证。这个保证就像一份“保险单”,它告诉我们,即使在最坏的情况下,算法的结果也不会差得太离谱。

定理:对于任意输入,设 OPT 为最优调度的最大完工时间,设 M 为机器数量。格雷厄姆算法输出的调度方案的最大完工时间 Graham 满足:
Graham <= (2 - 1/M) * OPT

解读

  • M=2 时,保证 Graham <= 1.5 * OPT
  • M=5 时(如上例),保证 Graham <= 1.8 * OPT。我们的例子中 Graham=9, OPT=5,恰好达到了 1.8 倍这个上界。
  • 随着机器数 M 增加,这个上界趋*于 2 * OPT

这个定理的意义在于:即使是在人为构造的最坏情况输入下,格雷厄姆算法给出的最大完工时间也永远不会超过最优解的两倍。 在实际中,算法在更“自然”的输入上通常表现得比这个理论界限好得多。

证明思路(简要) 🧮

证明的核心基于两个观察:

  1. 关于最优解:在任何调度中(包括最优调度),总负载是固定的(所有作业长度之和)。因此,平均负载 L_avg = (所有作业长度之和) / m 是一个下界,即 OPT >= L_avg。同时,最长作业的长度 L_max 也是一个下界,因为任何调度都必须处理这个作业,即 OPT >= L_max

  2. 关于格雷厄姆算法:考虑最终负载最大的那台机器 M*。在 M* 被分配其最后一个作业 J*那一刻之前,根据算法规则,M* 是当时所有机器中负载最轻的。这意味着,在加入 J* 之前,M* 的负载不超过当时的平均负载,从而也不超过最终的平均负载 L_avg

结合这两点:

  • M* 的最终负载 = (加入 J* 前的负载) + L_J*
  • <= L_avg + L_max
  • <= OPT + OPT (因为 L_avg <= OPTL_max <= OPT
  • = 2 * OPT

更精细的分析可以得到 (2 - 1/M) * OPT 这个更紧的界。


本节课中,我们一起学*了最小化最大完工时间这一NP难问题。我们介绍了其形式化定义,并设计了一个简单快速的贪心算法——格雷厄姆算法。尽管该算法不能保证最优,但我们证明了其强大的*似性能保证:它产生的解不会比最优解差两倍以上。这为我们在实践中使用快速启发式算法提供了信心。在下一节,我们将探讨针对该问题的其他算法思路。

009:最小化完工时间(第二部分)

概述

在本节课中,我们将学*如何分析格雷厄姆算法(Graham's algorithm)的*似性能,并介绍一种改进的算法——最长处理时间算法(LPT算法)。我们将通过严格的数学证明,理解这些算法在最坏情况下与最优解的差距,并学*如何利用排序等预处理步骤来提升*似比。


格雷厄姆算法的*似比证明

上一节我们介绍了用于最小化完工时间的格雷厄姆贪心算法。本节中,我们将正式证明其*似性能。

首先,我们回顾一下符号:

  • m 表示机器数量。
  • L1Ln 表示 n 个作业的长度。
  • 最优完工时间(最小可能完工时间)记为 M*
  • 格雷厄姆算法产生的完工时间记为 M

证明的目标是证明 M 不会比 M* 大太多,具体来说,最多大 (2 - 1/m) 倍。

直接比较 MM* 很困难。因此,我们引入两个中间量作为桥梁:最大作业长度平均机器负载。我们将分别建立它们与 MM* 的关系。

对最优解 M* 的两个下界

以下是两个简单的下界,它们都小于或等于最优完工时间 M*

下界1:最大作业长度
任何调度都必须将每个作业分配到某台机器上。因此,如果某个作业长度为 L_max,那么至少有一台机器的负载至少为 L_max,这意味着完工时间至少为 L_max。因此,最优完工时间 M* 至少和最长作业一样长。

公式: M* >= max(L1, L2, ..., Ln)

下界2:平均机器负载
在一个调度中,所有机器负载的总和恰好是所有作业长度的总和,因为每个作业只被分配一次。即:总负载 = Σ_{j=1}^{n} L_j

最理想的情况是所有机器负载完全平衡,即每台机器的负载都是 (总负载 / m)。任何调度方案的完工时间都不可能比这个完美平衡的理想情况更好。因此,最优完工时间 M* 至少是平均负载。

公式: M* >= (1/m) * Σ_{j=1}^{n} L_j

这两个下界将在后续证明中起到关键作用。


分析格雷厄姆算法产生的调度

现在,我们来分析格雷厄姆算法产生的调度 M,目标是将其与上述两个下界联系起来,从而关联到 M*

我们需要形式化证明中的第二步直觉:最忙机器与最闲机器的负载差,最多是一个作业的长度

我们引入一些符号:

  • 令机器 I 为格雷厄姆算法调度下负载最大的机器(即其负载等于 M)。
  • 令作业 J 为算法分配给机器 I 的最后一个作业。
  • L_hat_I 为在作业 J 被分配之前,机器 I 的负载。

考虑算法将作业 J 分配给机器 I 的那个时刻。根据算法规则,在那一刻,机器 I 是当时负载最小的机器。因此,它的负载 L_hat_I 不可能超过当时的平均机器负载。

当时的平均负载是(已分配作业的总长度 / m)。所以有:
L_hat_I <= (1/m) * (L1 + L2 + ... + L_{j-1})

最终的完工时间 M 等于机器 I 分配作业 J 之前的负载加上作业 J 本身的长度:
M = L_hat_I + L_J

结合以上两个式子,我们可以推导出 M 的上界:
M <= (1/m) * (L1 + L2 + ... + L_{j-1}) + L_J

为了简化表达式,我们在第一项的和中加入作业 J 及其之后作业的长度(这些长度都是正数,只会使上界更大):
M <= (1/m) * (L1 + L2 + ... + L_n) + (1 - 1/m) * L_J

现在,我们得到了 M 的一个上界,它由两部分组成:

  1. (1/m) * (所有作业长度之和),即平均负载。
  2. (1 - 1/m) * L_J,即作业 J 长度的一个比例。

这正是我们需要的!因为这两个部分都可以用之前得到的 M* 的下界来约束。

  • 根据下界2,平均负载 (1/m) * Σ L_j <= M*
  • 根据下界1,任何作业的长度,包括 L_J,都满足 L_J <= M*

将这两个不等式代入 M 的上界公式:
M <= (1 - 1/m) * M* + 1 * M* = (2 - 1/m) * M*

证明完成。 我们证明了格雷厄姆算法产生的完工时间 M 最多是最优完工时间 M*(2 - 1/m) 倍。这是一个可靠的“保险策略”:即使在最坏情况下,性能也不会差于最优解的两倍。


能否做得更好?LPT算法

作为算法设计者,我们总是要问:能否找到性能保证更好的快速启发式算法?

答案是肯定的。我们只需要在格雷厄姆算法之前加入一个熟悉的、几乎免费的预处理步骤:排序

回顾之前测验中的反例,格雷厄姆算法先处理了许多短作业,导致最后不得不将长作业单独放在一台机器上,造成不平衡。如果我们先将作业按长度从大到小排序,再运行格雷厄姆算法,就可以避免这个问题。这个算法被称为最长处理时间(LPT)算法

LPT算法步骤:

  1. 排序:将作业按处理时间(长度)从大到小排序。
  2. 贪心分配:对于排序后的作业列表,依次将每个作业分配给当前负载最小的机器。

其运行时间主要由排序决定,为 O(n log n),加上使用堆(Heap)进行贪心分配的 O(n log m),总体效率很高。


LPT算法的性能分析

LPT算法总是最优的吗?让我们通过一个测验来感受一下。

测验(略)结论:存在实例使得LPT算法产生的完工时间(19)大于最优完工时间(15)。因此,LPT不是绝对最优的。

但关键问题是:它的最坏情况保证比格雷厄姆算法更好吗?是的。可以证明,对于LPT算法,其完工时间 M 满足:
M <= (3/2 - 1/(2m)) * M*

这个上界 (约1.5倍) 比格雷厄姆算法的 (约2倍) 更严格。通过更精细的分析(此处略去),实际上可以证明一个更紧的界:M <= (4/3 - 1/(3m)) * M*。当机器数量 m 很大时,最坏性能趋*于最优解的 4/3 倍(即最多差33%)。

证明思路(简述)

LPT算法证明是对格雷厄姆算法证明的精细化,核心在于改进了“单个作业可能造成的最大损害”这一估计。

在格雷厄姆算法分析中,我们说作业 J 的长度 L_J <= M*
在LPT算法中,由于作业已排序,我们可以证明:如果作业 J 不是前 m 个最长的作业之一,那么有更强的结论 L_J <= M* / 2

理由(鸽巢原理):考虑最长的 m+1 个作业。无论如何调度,都必须将其中两个作业放在同一台机器上,因此最优完工时间 M* 至少等于这两个作业长度之和。由于作业已排序,这两个作业的长度都至少等于第 m+1 长的作业的长度。因此,对于所有非前 m 长的作业 J,有 M* >= 2 * L_J

在LPT调度中,导致最大完工时间的那台机器 I 上的最后一个作业 J,如果它前面还有作业(即机器 I 至少有两个作业),那么 J 就不可能是前 m 个作业之一(因为前 m 个作业会被分配到 m 台空机器上)。因此,强结论 L_J <= M* / 2 适用于它。

将这个更强的界代入格雷厄姆算法的证明框架中,就能得到 M <= (3/2 - 1/(2m)) * M* 的保证。


总结

本节课我们一起学*了:

  1. 格雷厄姆算法的*似比证明:我们通过引入最大作业长度和平均负载两个下界作为桥梁,证明了其完工时间最多是最优解的 (2 - 1/m) 倍。
  2. LPT算法及其改进:我们通过在格雷厄姆算法前加入排序步骤,得到了最长处理时间(LPT)算法。该算法具有更好的理论保证,其完工时间最多约为最优解的 1.5 倍(更精细的分析可达 4/3 倍)。
  3. 证明技术的核心:比较启发式算法解与最优解时,常通过寻找最优解的下界和启发式解的上界,并建立它们之间的联系。对于调度问题,最大作业长度和平均负载是两个非常实用的下界。

这些贪心启发式算法简单、快速,并且在实践中通常能产生接*最优的解,是解决NP难调度问题的有效起点。

010:最大覆盖问题的贪心启发式算法 - Part 1

在本节课中,我们将要学*最大覆盖问题,并介绍一个用于解决该问题的简单而自然的贪心启发式算法。我们将从问题定义开始,通过实例理解其应用场景,然后详细描述贪心算法的步骤,并初步探讨其性能。

问题定义与应用场景

上一节我们介绍了本课程的主题。本节中我们来看看最大覆盖问题的具体定义。

最大覆盖问题的输入包含 M 个子集,我们将其记为 t₁ 到 t_M,它们属于某个全集 U。同时还有一个预算,即一个正整数 K。
例如,在我们的团队组建例子中,可以想象全集 U 是所有可能的技能集合(或所有可能的场上位置)。同时,每个你可以雇佣的人对应一个子集,该子集包含此人拥有的技能。K 则是你的预算,即允许雇佣的人数。
目标是雇佣 K 个人,使得他们代表的技能尽可能多。用集合术语来说,我们希望选择 K 个子集,使得这些子集的并集能覆盖全集 U 中尽可能多的元素。这被称为子集集合的覆盖值。
例如,具体来说,可以想象给定一个数组对应于子集,另一个数组对应于全集 U 的元素。

然后可以有相应的指针来回指向,即从每个子集指向它包含的元素,以及从每个元素指向包含它的子集。

为了确保问题完全清晰,我们来看一个快速测验。

在这张幻灯片的右侧部分,展示了一个最大覆盖问题的可能输入。
总共有 16 个元素,所有小黑圈就是元素,可以看到它们排列成一个 4x4 的网格,总共 16 个。
然后有 6 个不同颜色的子集。
输入的最后一部分是预算,即 k 值。假设允许你从 6 个子集中挑选 4 个。

那么问题是,在所有从这 6 个子集中挑选 4 个的方式中,你能达到的最大可能覆盖值是多少?

正确答案是第三个。使用四个子集无法覆盖全部 16 个元素,但可以覆盖其中的 15 个,实际上有几种不同的方式。
你需要选择子集 t₄ 和 t₆,它们完全不重叠,一起已经覆盖了 10 个元素。
然后你肯定想选 t₂,它虽然有一些冗余元素,但确实覆盖了 4 个新元素,这样总共覆盖了 14 个。
现在,无论选择 t₃ 还是 t₅,都能再增加一个元素,总计达到 15 个。
需要注意的一点是,你永远不想选择子集 t₁。它是一个大集合,包含 6 个元素,但这些元素基本上已经被其他子集覆盖了。所以 t₁ 虽然大,但有些冗余,这就是为什么它没有出现在任何最优解中。

一般来说,最大覆盖问题之所以棘手,是因为子集之间存在重叠。
例如,某些技能可能很常见,意味着它们被许多子集覆盖。
其他技能可能很稀有,只被少数子集覆盖。
因此,一个理想的子集应该是大的,且冗余元素少,基本上就是一个拥有许多独特技能的团队成员。

一些最大覆盖问题经常出现,不仅仅限于我们目前提到的团队组建应用。
例如,假设你想在一个城市中选择 K 个新的消防站位置,以最大化居住在一英里范围内的居民数量。
这正是一个最大覆盖问题。全集元素对应于居民,每个子集对应于一个可能的消防站位置,该子集的元素是居住在该位置一英里范围内的居民。
对于一个更复杂的例子,假设你想让一群人参加一个活动,比如一场音乐会。你需要开始为活动做准备,但只有时间说服你的 K 个朋友来。
然而,无论你成功招募了哪些朋友,他们都可以带上他们的朋友,而他们的朋友又会带上朋友的朋友,依此类推。

我们可以用一个有向图来可视化这个问题。
图的顶点将对应于人,如果 W 会跟随 V 参加活动(即如果 V 决定来,W 作为 V 的朋友会一起来),那么我们将有一条从一个人 V 指向另一个人 W 的有向边。
例如,考虑这个蓝色的有向图,它有 8 个顶点。
假设你招募了顶点 1。那么 2 会跟随 1 来参加活动,然后一旦 2 来了,3 和 5 也会跟随。
所以如果你招募了 1,最终会有这总共 4 个不同的顶点出现在活动中。
另一个有趣的案例是顶点 6。你会看到 6 没有入边。这意味着没有其他人能说服 6 来。
但是,如果 6 决定来,那么许多人会跟随,特别是 8 会跟随,然后是 5 和 7,接着是 3。
所以如果你决定同时招募 1 和 6,你最终会让这 8 个人中的 7 个出现在活动中。
像这样最大化出席人数正是一个最大覆盖问题。全集元素对应于人,或者等价于图中的顶点。每个人对应一个子集,表示最终会跟随此人参加活动的人。在图论中,该子集对应于从该顶点通过有向路径可到达的顶点。
例如,从这个顶点 1,你可以看到它的“团块”包含了所有从 1 通过有向路径可到达的顶点。
类似地,浅蓝色的团块包含了所有从 6 通过有向图可到达的顶点。
由于招募一组 K 个人而产生的总出席人数,正是对应的 K 个子集所实现的覆盖值。

贪心算法介绍

上一节我们了解了最大覆盖问题的定义和例子。本节中我们来看看一个解决该问题的自然算法思路。

我希望这能让你相信最大覆盖问题是一个基本问题。如果有一个现成的子程序能为你快速解决这个问题,那将是非常好的。
不幸的是,我们将在视频播放列表的第四部分看到,最大覆盖问题是一个 NP 难问题,所以我们必须在某些方面做出妥协。再次强调,这是播放列表中我们在正确性上妥协的部分。因此,我们将有一个通用且快速的算法,但它并不总是最优的。如果它能接*最优,就像上一节中我们的调度贪心算法那样,那将是最佳情况,因为问题是 NP 难的。

现在,和许多问题一样,贪心算法是解决最大覆盖问题的一个非常自然的起点。
算法最终负责输出 K 个子集。
那么,为什么不迭代地构建我们的解,一次选择一个集合,在每一步都做出短视的决策呢?
如果 k = 1,如果只允许选择一个子集,这不是一个难题。如果只能选一个子集,你想选的就是最大的那个,因为覆盖值将只是你选择的那个集合的大小。
当 k = 2 时,事情就变得有点意思了。假设允许选择两个子集,并且你已经决定选择最大的那个。
接下来你想选哪个?你可以选第二大的那个。
但正如我们在例子中看到的,重叠真的很重要。
所以真正重要的是,下一个子集覆盖了多少个新元素,即它增加了多少覆盖值。
因此,最合理的贪心准则应该是:总是选择能覆盖最多未被先前子集覆盖的新元素的子集。

这就是最大覆盖问题的一个著名贪心算法,我们简称它为贪心覆盖算法。

算法伪代码与运行时间

上一节我们介绍了贪心算法的核心思想。本节中我们来看看它的具体实现步骤。

该算法的伪代码相当简单。
只有一个简单的 for 循环,进行 k 次迭代,对应于我们需要选择的每个子集。
我们初始化一个集合 K,用于存储我们选择的集合的索引,初始为空。
在 K 次迭代的每一次中,我们选择一个子集。我们选择哪个子集?我们只是贪心地增加覆盖值。在第 j 次迭代中,我们选择能最大化新覆盖元素数量的子集,即未被前 j-1 个已选子集覆盖的元素数量。

我在这里使用了符号 F_c,我们在几页幻灯片前介绍过,它只是索引在集合 K 中的所有子集的并集的大小。
另外请注意,在这个 argmax 步骤中,它是在所有子集中搜索能最大程度增加覆盖值的那个。这个 argmax 永远不会被已经在集合 K 中的子集达到,因为那只会使覆盖值增加零。如果更清晰的话,可以认为这个 argmax 只在我们尚未选择的子集上进行。

和贪心算法一样,运行时间分析并不那么有趣,至少对于算法的直接实现来说是这样。
那么直接实现会是什么样子呢?主循环有 k 次迭代。
如何实现一次循环迭代?你必须评估这个 argmax,所以你必须遍历 M 个子集并找到最佳的那个。你可以直接对 M 个子集进行穷举搜索。对于每个子集,你必须计算选择该子集将带来的覆盖值增加量。你可以通过逐个访问该子集中的元素,并检查哪些已经被覆盖,哪些尚未被覆盖来实现。
在这种直接实现中,你有一个因子 k 来自循环迭代次数,一个因子 M 来自对 M 个子集选择的穷举搜索,然后评估覆盖值增加量所需的时间与该子集的大小成正比。

所以贪心算法可能不像我们在调度应用中看到的算法那样快如闪电,但它肯定是一个多项式时间算法。
那么真正的问题,和启发式算法一样,是它的性能如何。
贪心覆盖算法输出的解,与在完美世界中使用(例如)穷举搜索所能实现的解相比,有多好?

算法性能分析:反例

上一节我们讨论了算法的运行时间。本节中我们通过具体例子来看看贪心算法的性能可能有多差。

很容易想出一个简单的例子,其中贪心覆盖算法没有做对,它可能输出一个次优解。
例如,在这张幻灯片的右上角,有一个只有 4 个元素和 3 个子集的例子。将参数 K 视为等于 2。
所以如果允许你选择两个子集,很明显,如果你做出正确的选择,选择 t₁ 和 t₂,你可以覆盖所有 4 个元素。

但是我们的贪心算法,我们不知道它在第一次迭代中会做什么,至少假设它任意打破平局。
在第一次迭代中,它很可能选择集合 t₃。一旦选择了 t₃,无论它在第二次迭代中选择 t₁ 还是 t₂,最终都只能覆盖 4 个元素中的 3 个。
如果你担心我们假设了贪心算法在最坏情况下的平局处理,请放心,这个例子的一个更复杂版本即使贪心算法以最佳方式处理平局,也会显示出完全相同的情况。
所以这并不奇怪,我们完全预料到会有一些例子,其中贪心覆盖算法无法覆盖在完美世界中所能覆盖的那么多元素。
记住我说过这是一个 NP 难问题,我们已经看到贪心覆盖算法在多项式时间内运行,所以除非我们打算反驳 P = NP 猜想,否则肯定存在它不输出最优解的例子,这就是其中之一。

但是,一旦你看到这样一个简单的例子,你可能会担心,在更复杂的例子中,情况可能会变得更糟,至少如果参数 K 比 2 大一点,情况可能会更糟。让我们在这个测验中看看。

让我们看一个更复杂的例子,它有 81 个元素,排列成一个 9x9 的网格,然后还有 5 个子集。这里的预算是 3,所以允许从 5 个子集中选择 3 个。问题是通常的那些:首先,在最佳情况下,任意三个子集所能实现的最大覆盖值是多少?其次,贪心覆盖算法将实现的覆盖值是多少?我要求的是在任意平局处理下的答案。

正确答案是第二个,答案 B。
很容易看出,有一种方法可以选择三个子集来覆盖所有 81 个元素,只需选择 T₁、T₂ 和 T₃,即红色、浅蓝色和洋红色的子集。
贪心算法最终可能会这样做。但在任意平局处理下,就我们所知,贪心算法的第一次迭代可能会选择集合 T₄,它覆盖了 27 个新元素,就像 T₁、T₂ 和 T₃ 一样。所以 T₄ 是可能被选中的。
如果它确实选择了集合 T₄,现在它必须决定下一步做什么。T₅(棕色集合)将覆盖 18 个元素,这些元素都没有被 T₄ 覆盖,所以 T₅ 将覆盖 18 个新元素。
另一方面,如果你看 t₁、T₂ 和 T₃,它们 27 个元素中有 9 个已经被 T₄ 覆盖了,所以选择它们中的任何一个都只能覆盖 18 个新元素。
所以这又是一个四方的平局,它们都可能被选中。就我们所知,贪心算法可能会选择 T₅。
如果它这样做了,现在用它的最后一个集合,它可以选择 T₁、T₂ 或 T₃ 中的任何一个,无论选哪个都无关紧要。
总覆盖值是 T₄ 的 27 个加上 T₅ 的 18 个,总计 45,再加上从 T₁、T₂ 或 T₃ 中任选一个带来的 12 个,总共是 57。

性能下界与*似比

上一节我们看到了两个具体的反例。本节中我们将其推广,并理解贪心算法性能的理论下界。

我们现在已经看到了两个不同的例子,一个是可以选择两个子集,贪心算法可能只实现 3 的覆盖值,而 4 是可能的。换句话说,它可能只获得最大可能覆盖值的 75%。
然后在 K=3 的最后一个测验中,我们看到了一个例子,它只获得了最大可能覆盖值的 57/81,大约是 70.4%。

希望到目前为止我们看到的两个例子,一个有 4 个元素,一个有 81 个元素,它们有相似的模式。你可能会看到某种模式正在出现,并且可以想象尝试将这些例子推广到所有正整数值 K。
事实上你可以做到。我鼓励你将其作为一个练*来推导。例如,你可以将一堆元素排列在一个网格中,网格的边长是 k^(k-1),这推广了我们之前的 2x2 和 9x9 网格。所以边长是 k^(k-1)。你将得到 2k - 1 个子集(当 k=2 时我们有 3 个,k=3 时有 5 个)。一般来说,你会有 2k - 1 个子集。
如果你为所有正整数 K 推广这个例子,以下是你对贪心覆盖算法及其解的质量的了解。
没错,一般来说,对于每个正整数 K,我们迄今为止看到的两个例子的推广表明,贪心覆盖算法可能输出的解的覆盖值仅为最大可能值的 1 - (1 - 1/k)^K 倍。

这有点拗口,但我们先来验证一下这个公式。
代入 k = 2。我们得到什么?那么 1 - 1/k 等于 1/2,将其平方得到 1/4,从 1 中减去它得到 3/4。这验证了它确实推广了我们在 k=2 时看到的情况。
如果代入 k = 3,1 - 1/k 是 2/3,现在将其立方得到 8/27,然后从 1 中减去它得到 19/27,这正好与我们测验中看到的 57/81 比例相同。
所以这个公式确实外推了我们已经看到的 k=2 和 k=3 的情况。如果你推导这两个例子的推广,你会发现这是正确的。
那么应该如何理解这个结果呢?我们知道当 k=2 时是 75%,k=3 时大约是 70.4%,但对于其他 K 值呢?
每当你遇到这样一个复杂的单变量表达式并想理解它的行为时,你应该立即绘制它的图像。我已经为你做了这件事,那就是你在右侧看到的图表。
实线就是这个表达式 1 - (1 - 1/k)^K 对所有正实数的插值。
你看那条曲线,立刻就能明白发生了什么:它在下降,我们大概预料到了。k 越大,从贪心覆盖算法得到的比例似乎越小。
另一方面,好的一点是它并没有一直下降到 0。它有一个渐*线。
如果你放大看,会发现渐*线大约在 63.2%。
所以无论我们把这个特定的例子族构造得多么大,我们永远无法证明贪心覆盖算法获得的最大可能覆盖值比例会低于 63.2%。
你们中的一些人可能想知道这是怎么回事。为什么这个东西渐*于 63.2%?
原因是当 k 变大时,1/k 变小。
一般来说,对于小的 x,量 1 - x 和 e^(-x) 非常相似。我鼓励你绘制这个图像自己看看。1 - x 是一条直线,而 e^(-x) 是一条曲线,它们在 0 点恰好相切。所以如果接* 0,它们彼此非常接*。
这意味着 1 - 1/k 的行为类似于 e^(-1/k),这里的 e 是 2.71...,即欧拉数。
所以 (1 - 1/k)^k 的行为类似于 (e(-1/k))k = e^(-1) = 1/e。
然后 1 - 1/e *似等于 0.632。
这就是你如何正式证明,无论 k 取多大,这个表达式永远不会低于 63.2%。

贪心覆盖算法如此简单自然,可能是你为最大覆盖问题写下的第一个启发式算法。
鉴于其简单性,你可能想知道这个奇怪的超越数 1 - 1/e 是如何出现在这个超自然算法中的。
你可能会想,哇,也许这只是我们在这张幻灯片上讨论的这类构造例子的产物。
我们接下来将看到,事实完全相反。我们将为贪心覆盖算法证明一个*似正确性保证,表明这是你可能遇到的最坏例子族。
换句话说,1 - 1/e 根本不是我们特定例子的产物,它是这个超自然贪心算法*似正确性的根本所在。

总结

本节课中我们一起学*了最大覆盖问题及其一个简单自然的贪心启发式解法。
我们首先定义了问题,并通过团队组建、消防站选址和社交网络邀请等例子理解了其广泛的应用场景。
接着,我们详细描述了贪心覆盖算法的步骤:在每一步,都选择能增加最多新覆盖元素的子集。
我们分析了算法的简单实现及其多项式运行时间。
然后,我们通过构造具体的反例,发现贪心算法并不总能得到最优解,并且其*似性能存在一个下界,即覆盖值可能只有最优解的 1 - (1 - 1/k)^K 倍。
最后,我们观察到当 k 很大时,这个比例趋*于 1 - 1/e ≈ 63.2%,并指出这并非偶然,而是算法性能的理论极限。在接下来的课程中,我们将为这个*似比提供严格的理论证明。

011:最大覆盖问题的贪心启发式算法 - 第二部分

概述

在本节课中,我们将学*最大覆盖问题贪心启发式算法的*似正确性保证。我们将深入探讨一个关键引理,并证明该算法总能保证获得至少 1 - (1 - 1/k)^k 倍于最优解的覆盖范围。

*似保证定理

对于最大覆盖问题的任意输入,无论全集大小或子集数量如何,只要预算为 k,贪心覆盖算法保证输出的解,其覆盖范围至少是最优解(即使用 k 个子集所能达到的最大覆盖范围)的 1 - (1 - 1/k)^k 倍。

例如:

  • k = 2 时,贪心算法保证获得至少 75% 的最大可能覆盖。
  • k = 3 时,保证获得至少 70.4% 的最大覆盖。
  • 无论 k 多大,保证获得至少 63.2% 的最大覆盖(即 1 - 1/e)。

这是一个最坏情况下的保证。在实际输入中,贪心算法的表现通常会好得多,更接* 100%。

关键引理:贪心算法的持续进展

上一节我们介绍了算法的*似保证,本节中我们来看看这个保证背后的核心原理。关键在于一个引理,它表明贪心算法在每次迭代中都能有效地“蚕食”最优解的一部分。

该引理指出:在贪心算法的每一次迭代中,其覆盖范围的增量至少是当前“不足”的 1/k

定义

  • C*:使用 k 个子集所能达到的最大覆盖范围(最优解)。
  • C_j:贪心算法在前 j 次迭代后已覆盖的元素数量。
  • 不足C* - C_{j-1},即当前覆盖与最优覆盖的差距。

引理公式化
对于每次迭代 j,有:
C_j - C_{j-1} >= (C* - C_{j-1}) / k

这意味着,如果当前覆盖距离最优解还差 D 个元素,那么在下一次迭代中,贪心算法至少能覆盖 D/k 个新元素。

关键引理的证明

现在我们来证明这个关键引理。证明的核心思想是比较贪心算法的选择和某个参考解(例如最优解)的选择。

设定

  • K_hat 为某个由 k 个子集索引组成的参考集合(例如一个最优解)。
  • 考虑贪心算法的任意一次迭代 j。此时,贪心算法已选择了前 j-1 个子集。

核心不等式
证明依赖于以下不等式:
sum_{i in K_hat} [ |T_i \ C_{j-1}| ] >= C* - C_{j-1}

理解该不等式
我们可以通过一个图示来理解。想象有两个圆:

  1. 蓝色圆:代表参考解 K_hat 覆盖的所有元素(数量为 C*)。
  2. 品红色圆:代表贪心算法目前已覆盖的所有元素(数量为 C_{j-1})。

两个圆之间的绿色区域,就是被参考解覆盖但尚未被贪心算法覆盖的元素。这个区域的大小恰好等于 C* - C_{j-1}

现在看不等式左边:它是对于 K_hat 中的每一个子集 T_i,计算如果将其加入当前贪心解能新增多少覆盖(即 |T_i \ C_{j-1}|),然后将这 k 个值相加。

为什么左边 >= 绿色区域?

  • 如果我们一次性将 K_hat 中的所有 k 个子集都加入当前解,新增的覆盖正好是绿色区域。
  • 然而,左边计算的是分别加入每个子集所获新增覆盖的总和。如果某些元素同时属于 K_hat 中的多个子集,它们在左边的和中会被重复计算多次,而在一次性加入所有子集时只被计算一次。
  • 因此,左边的和至少和绿色区域一样大(通常更大)。

完成证明
既然左边是 k 个正数的和,那么其中至少有一个数不小于它们的平均值。即存在某个 i* in K_hat,使得:
|T_{i*} \ C_{j-1}| >= (C* - C_{j-1}) / k

根据贪心算法的选择准则,它在迭代 j 中选择的子集,其带来的新增覆盖至少和任何可选子集(包括这个 T_{i*})一样多。因此:
C_j - C_{j-1} >= |T_{i*} \ C_{j-1}| >= (C* - C_{j-1}) / k

这就完成了关键引理的证明。

从关键引理推导*似保证

有了关键引理,我们现在可以通过数学推导,证明贪心算法的最终覆盖范围满足定理所述的*似比。

我们将关键引理应用于贪心算法的每一次迭代。

第 k 次(最后一次)迭代
根据引理:
C_k - C_{k-1} >= (C* - C_{k-1}) / k
整理得:
C_k >= C* / k + (1 - 1/k) * C_{k-1} (1)

第 k-1 次迭代
根据引理:
C_{k-1} - C_{k-2} >= (C* - C_{k-2}) / k
整理得:
C_{k-1} >= C* / k + (1 - 1/k) * C_{k-2} (2)

代入与推导
将不等式 (2) 代入不等式 (1) 中的 C_{k-1}
C_k >= C* / k + (1 - 1/k) * [ C* / k + (1 - 1/k) * C_{k-2} ]
= C* / k * [1 + (1 - 1/k)] + (1 - 1/k)^2 * C_{k-2}

重复此过程
继续对 C_{k-2}, C_{k-3}, ... 应用关键引理并代入,直到 C_0(初始覆盖为 0)。最终我们会得到:
C_k >= (C* / k) * [1 + (1 - 1/k) + (1 - 1/k)^2 + ... + (1 - 1/k)^{k-1}]

利用几何级数求和公式
中括号内的和是一个几何级数。设 r = 1 - 1/k,则和为:
(1 - r^k) / (1 - r) = (1 - (1 - 1/k)^k) / (1/k) = k * [1 - (1 - 1/k)^k]

得到最终结果
将此和代入 C_k 的不等式:
C_k >= (C* / k) * { k * [1 - (1 - 1/k)^k] }
C_k >= C* * [1 - (1 - 1/k)^k]

这正是我们要证明的定理:贪心算法的覆盖范围至少是最优覆盖 C*1 - (1 - 1/k)^k 倍。

总结

本节课中我们一起学*了最大覆盖问题贪心启发式算法的性能保证。

  1. 我们首先陈述了定理:算法保证获得至少 1 - (1 - 1/k)^k 倍于最优解的覆盖。
  2. 我们证明了一个关键引理,表明算法在每次迭代中都能至少弥补当前与最优解差距的 1/k
  3. 通过将关键引理迭代应用 k 次,并利用几何级数求和公式,我们最终推导出了定理中的*似比公式。

这个分析不仅提供了性能保证,也揭示了之前测验中的构造实例实际上是可能的最坏情况。这个贪心算法及其分析框架,将为我们接下来学*社交网络中的影响力最大化问题奠定基础。

012:影响力最大化的贪心启发式算法

在本节课中,我们将学*影响力最大化问题,并探讨一个用于解决该问题的贪心启发式算法。我们将从社交网络和级联模型的基本概念开始,逐步深入到问题定义和算法分析。

概述

影响力最大化是社交网络分析中的一个经典问题。其核心目标是:在给定预算(即种子节点数量)的情况下,选择一组初始用户(种子节点),使得信息(如新闻、产品)通过社交网络传播后,最终被激活的用户数量期望值最大化。我们将介绍一个基于贪心策略的算法(KKT算法),并分析其性能保证。

社交网络与级联模型

上一节我们介绍了NP难问题的背景,本节中我们来看看一个具体的应用问题——影响力最大化。首先,我们需要理解问题发生的场景:社交网络。

我们可以将社交网络建模为一个有向图 G=(V, E)。其中,顶点 V 代表人,有向边 (v, w) ∈ E 表示 vw 有影响力(例如,w 在Twitter上关注了 v)。

级联模型描述了信息(如一篇文章或一个梗)如何在网络中传播。存在许多被深入研究的级联模型,这里我们关注一个简单的模型,它由两个参数定义:

  • 激活概率 p:一个介于0和1之间的实数。
  • 种子节点集合 S:一个顶点的子集。

在该模型中,每个顶点处于活跃非活跃状态(例如,点击了文章链接或未点击)。过程开始时,所有种子节点 S 被设置为活跃状态,其他所有节点均为非活跃状态。节点一旦被激活,状态将不再改变。

激活过程如下:每个活跃节点 v 有一次机会去激活每一个它所能影响的、且连接边尚未被“翻转”的非活跃邻居 w。对于每条这样的边 (v, w),我们进行一次 biased coin flip(偏置硬币翻转),其出现正面的概率为 p

以下是翻转硬币后的规则:

  • 若结果为正面:边 (v, w) 的状态变为“活跃”,且若 w 此前为非活跃,则将其状态更新为活跃。
  • 若结果为反面:边 (v, w) 的状态变为“非活跃”,顶点 w 的状态保持不变。

这个过程持续进行,直到不存在这样的“机会”:即不存在一个活跃节点,其拥有指向未翻转边的邻居。最终,所有被激活的节点,恰好是从某个种子节点出发,通过一条全部由“活跃”边构成的有向路径可达的节点。

影响力最大化问题定义

基于上述级联模型,我们可以正式定义影响力最大化问题。

输入

  1. 一个有向图 G=(V, E)(社交网络)。
  2. 一个激活概率 p ∈ [0,1]
  3. 一个预算 K(正整数)。

定义:对于给定的种子节点集合 S,用随机变量 A(S) 表示在级联过程结束后被激活的顶点集合。我们定义集合 S影响力 f(S) 为最终被激活节点数量的期望值:
f(S) = E[ |A(S)| ]
这里的期望值是对级联过程中所有硬币翻转结果取平均。

目标:找到一个至多包含 K 个顶点的种子集合 S*,使得其影响力最大化:
S* = argmax_{S ⊆ V, |S| ≤ K} f(S)

这个问题是NP难的。一个现实世界的例子是:你拥有 K 份免费产品,希望选择 K 个初始用户赠送,以最大化产品的最终总采用人数。

KKT贪心算法

面对这个NP难问题,我们追求一个快速且*似正确的启发式算法。与解决最大覆盖问题的思路类似,一个自然的贪心算法应运而生,即KKT算法。

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

初始化空集合 S = ∅
for i = 1 to K:
    选择顶点 v ∈ V \ S,使得边际影响力增益 f(S ∪ {v}) - f(S) 最大
    将 v 加入集合 S
返回集合 S

简单来说,算法迭代 K 轮,在每一轮中,它“短视地”选择那个能为当前种子集合带来最大边际影响力提升的单个顶点。

算法运行时间分析

现在,我们来分析一下该算法的运行时间。一个直接的实现会面临计算挑战。

算法有 K 轮迭代。每轮需要遍历所有 n 个顶点,并对每个候选顶点 v 计算其边际增益 f(S ∪ {v}) - f(S)。这本质上需要计算两个集合的影响力 f(·)

问题在于,根据定义,计算一个集合 S 的影响力 f(S) 需要求期望值,这涉及到对图中所有 m 条边的硬币翻转结果(共 2^m 种可能性)进行平均。如果采用最朴素的方法直接求和,那么计算一次 f(S) 就需要 O(2^m) 的时间,这使得总运行时间达到 O(K * n * 2^m),是指数级的。

然而,在实践中,我们可以通过随机采样来高效地估计 f(S)。具体方法是:进行多次独立的随机实验,在每次实验中按照概率 p 模拟所有边的硬币翻转,运行级联过程,并记录被激活的节点数。然后将多次实验的平均激活节点数作为 f(S) 的估计值。使用这些估计值来运行贪心算法,可以在实际中取得很好的效果。

算法性能保证

尽管计算精确的影响力是困难的,但KKT贪心算法在理论上有优异的性能保证。

定理:令 S_greedy 为KKT贪心算法返回的种子集合,S* 为最优的种子集合。那么,贪心算法的影响力至少是最优解的 (1 - 1/e) 倍:
f(S_greedy) ≥ (1 - 1/e) * f(S*)
其中 e 是自然对数的底数。

这个保证与我们之前为最大覆盖问题分析的贪心算法保证完全相同。这是一个非常令人满意的结果,因为最大覆盖问题可以被视为影响力最大化的一个特例。对于这个更一般、更困难的问题,我们能期望的最好结果就是达到与特例问题相同的*似比,而KKT算法正好做到了这一点。

证明的直观思想在于,影响力函数 f(S) 可以表示为许多不同的“事件出席”问题(即最大覆盖问题)的加权平均。之前对最大覆盖问题的分析可以自然地扩展到这种加权平均的情形。

总结

本节课中我们一起学*了社交网络中的影响力最大化问题。

  1. 我们首先将社交网络建模为有向图,并引入了一个基于激活概率 p 的简单级联传播模型。
  2. 接着,我们正式定义了影响力最大化问题:在预算 K 的限制下,选择种子集合以最大化最终激活节点数的期望值 f(S)
  3. 针对这个NP难问题,我们介绍了KKT贪心启发式算法,该算法迭代地选择能带来最大边际影响力增益的节点。
  4. 我们讨论了算法的运行时间,指出直接计算精确影响力是指数级的,但可通过随机采样进行高效估计。
  5. 最后,我们给出了算法的理论性能保证:其解的影响力至少是最优解的 (1 - 1/e) 倍。这个强保证说明了贪心策略在此类问题上的有效性。

013:影响力最大化的贪心启发式算法证明

在本节课中,我们将学*影响力最大化问题的贪心启发式算法(KKT算法)的*似正确性证明。我们将看到,影响力函数可以被视为多个覆盖函数的加权平均,从而将问题与最大覆盖问题联系起来,并复用其分析框架。

上一节我们介绍了影响力最大化问题的贪心启发式算法。本节中,我们将深入探讨其理论证明,核心在于将影响力函数形式化为覆盖函数的加权平均。

影响力函数的形式化

首先,我们形式化地定义影响力最大化问题的输入。我们有一个有向图 G,每条边有一个介于0和1之间的激活概率 p,以及一个正整数 K,代表我们要选择的种子顶点数量。为方便起见,我们考虑包含后处理步骤的级联模型版本。这意味着在过程结束后,所有未被“翻转”的边都将被最终确定状态。该过程的关键在于,最终被激活的顶点,正是那些从某个种子顶点出发,通过一条由激活边构成的有向路径可达的顶点。

接下来,作为一个思想实验,假设我们拥有预知能力,能提前知道哪些边最终会被激活。这相当于我们预先抛掷了所有硬币,而不是像级联模型那样按需抛掷。那么,如果我们知道了激活边的集合 H,影响力最大化问题就完全简化为一个最大覆盖问题。

  • 基础集合是社交网络的所有顶点 V
  • 每个顶点对应一个子集,该子集包含从该顶点出发,通过激活边(即子图 (V, H) 中的有向路径)可达的所有顶点。

例如,回到之前看过的四顶点图。假设激活边恰好是 A→BB→DC→D,这三条边构成了集合 H。那么:

  • 对应顶点 A 的子集包含从 A 沿激活边可达的所有顶点,即 {A, B, D}
  • 对应顶点 C 的子集包含从 C 沿激活边可达的所有顶点,即 {C, D}

此时,最终被激活的人数,恰好等于对应种子顶点的这些子集的覆盖大小。例如,如果选择对应 AC 的子集,那么你将激活所有四个顶点,因为这两个子集的并集覆盖了全部四个顶点。

当然,我们实际上无法提前知道激活边的集合 HH 是一个随机集合,不同的 H 有不同的出现概率。但最终,给定种子顶点集合 S 的影响力 f(S),正是这个覆盖函数在所有可能的激活边集合 H 上的期望值。

这个期望值如何计算?我们可以对所有可能遇到的激活边子集 H 进行求和(共有 2^m 个,其中 m 是边数)。每个 H 都有一个出现的概率 p(H)。一旦固定了激活边集合 H,你就得到了一个覆盖函数,其基础集合是顶点,每个顶点对应一个子集,该子集包含从该顶点出发,沿着 H 中的激活边构成的路径可达的其他顶点。

严谨地说,我们这里使用的是全期望定律,将原始期望(影响力的定义)写成了以激活边集合 H 为条件的条件期望的概率加权平均。如果不理解这个术语也不必担心,直观上很清楚:存在 2^m 种不同的激活边子集可能性,每种可能性有自己的概率,每种情况有自己的覆盖函数,而影响力就是这 2^m 个覆盖函数的相应加权平均。

复用最大覆盖问题的分析框架

这听起来是个好消息。我们已经知道,对于单个覆盖函数,贪心算法能获得我们想要的*似正确性保证。现在,我们处理的不是一个覆盖函数,而是许多覆盖函数的平均。但希望分析框架仍然适用,让我们来验证这一点。

我们主要需要一个新版本的、在最大覆盖问题中称为“关键引理”的东西。这个引理断言贪心算法在每次迭代中都能取得显著进展。在最大覆盖中,我们希望说 K 次迭代中的每一次都增加了可观的覆盖范围。在这里,我们希望论证 KKT 算法的每次迭代都至少将当前种子顶点集合的影响力增加一个给定的量。

关键引理的形式将与最大覆盖问题中的完全相同。我们将用当前解在该迭代中的“不足”来界定每次迭代中影响力的增加量。

  • I* 表示使用 K 个种子顶点可能达到的最大影响力。
  • 在 KKT 算法的某次迭代中,其当前解的影响力与最大可能值 I* 的差距即为“不足”。
  • 保证的进展是:每次迭代,你至少能将影响力增加该“不足”的 1/K 倍。

这与最大覆盖问题中的引理字面上完全相同,只是原来的最大覆盖 C* 现在是最大影响力 I*,原来已覆盖的元素数量现在是 KKT 解的当前影响力 f(S_{j-1}),其他完全一样。

我们可能记得,在贪心覆盖算法的*似正确性证明中,我们有两部分:一个像这样的保证进展的关键引理,以及第二部分通过一些代数运算得到最终的*似正确性保证。既然这个关键引理与最大覆盖中的保证完全相同,那么证明的第二部分将完全不变地继续适用。只要这个引理为真,我们就能得到想要的结果:KKT 算法保证至少获得最大可能影响力的 1 - (1 - 1/K)^K 倍。因此,我们将在本视频结束时证明这个关键引理,并宣告胜利,因为*似正确性保证会以与之前完全相同的方式得出。

证明关键引理

首先引入一些符号。考虑一个任意的最优解,即某个包含 K 个顶点的集合,记作 S。所谓最优,即它们具有最大可能的影响力 I

我们的计划是:先固定一个可能的激活边子集,这给我们一个覆盖函数;然后,我们将直接复用我们已经为覆盖问题所做的分析;最后在末尾取加权平均。

首先,固定你喜欢的任意边子集 H(从 2^m 个可能中任选一个)。然后我们有一个对应的覆盖函数,记作 f_H。记住,这个覆盖函数表示:给定 H 恰好是激活边,给定你选择了一个特定的种子顶点集合 S,有多少顶点可以从某个种子顶点沿着这些激活边可达。正如我们所见,这正是一个覆盖函数。

我们之前为最大覆盖问题及其贪心算法的*似正确性证明付出了很多努力,当然希望在这里尽可能多地复用那些工作。回想一下(或者回看那个视频),在*似正确性证明中,我们有一个类似的关键引理,涉及覆盖范围而非影响力。而在改进关键引理时,我们有一个关键的断言,一个非常重要的不等式(当时我们讨论的是“绿色区域”,一个量大于绿色区域,另一个量小于绿色区域)。我们当时说的是:一方面考虑截至当前的覆盖不足;另一方面,考虑在思想实验中,将最优解中的每个子集添加到当前解中,能获得多少额外的覆盖;后者至少是前者。换句话说,总是存在一个选项(最优解中的一个子集),能让你获得至少 1/K 倍于你当前不足(你的覆盖范围小于最大可能覆盖范围的程度)的进展。

我们只需将这个完全相同的不等式抄写下来,并用由激活边子集 H 诱导出的覆盖函数 f_H 来实例化它。同样,给定集合 Sf_H(S) 就是通过 H 中的激活边路径从 S 可达的顶点数量。我在这里字面上抄下了那个不等式,使用了我们当前的覆盖函数 f_H

  • 不等式的左边:对最优解 S* 中的每个顶点 v 进行思想实验,询问如果我们将这个顶点 v 添加到贪心算法的当前解 S_{j-1} 中,影响力(在此固定 H 下是覆盖)会增加多少。我们对 S* 中的 K 个顶点各做一次这个实验,然后求和。这是较大的量。
  • 不等式的右边:是我们当前关于这个覆盖函数 f_H 的覆盖不足(赤字),即我们的覆盖范围 f_H(S_{j-1}) 没有达到 S* 的覆盖范围 f_H(S*) 的程度。

到目前为止,我们还没有证明任何新东西。这只是直接复用了我们覆盖分析中关键断言的关键不等式。

利用影响力是覆盖函数的加权平均

现在,让我们实际利用影响力是覆盖函数的加权平均这一事实,来完成对 KKT 算法保证的证明。

这个不等式是针对一个固定的 H(一个固定的最终激活边集合)的。因此,我们实际上不只有一个这样的不等式,而是有 2^m 个(m 是边数)。对于 H 的每一种选择(边的每一个子集),我们都有一个这种形式的不等式。

接下来我们要用的技巧是:既然我们已经知道影响力是覆盖函数的加权平均,那么我们就取这些不等式(每个覆盖函数一个),用定义影响力时使用的完全相同权重对它们进行加权平均。权重就是 H 真正成为激活边集合的概率 p(H)

让我展示一下“取这 2^m 个不等式的加权平均”具体是什么意思。首先,对于对应激活边子集 H 的不等式,两边同时乘以该集合真正成为级联模型中激活边集合的概率 p(H)。因为如果 a ≥ b,两边乘以同一个正数,不等式仍然成立。

现在,我们有了一个由 p(H) 加权的不等式。同样,我们并不真正关心 p(H) 的具体形式,但它有一个闭式公式:p^{|H|} * (1-p)^{m-|H|}

我们对每一个可能的边子集都有这样一个不等式。我们想要一个加权平均,所以最后,我们只需将这 2^m 个不等式全部加起来,得到另一个不等式(如果 a1 ≥ b1a2 ≥ b2,那么 a1 + a2 ≥ b1 + b2)。

我们这样做是为了复用覆盖分析。我们知道,只有固定了激活边集合 H,我们才有一个覆盖函数。然后我们知道需要回到影响力,而影响力是这些覆盖函数的加权平均。因此,对每个覆盖函数单独得到的不等式取相同的加权平均,是一个合理的尝试。现在,它真的会神奇地奏效。

观察当我们交换求和顺序时会发生什么。我所做的只是两边乘以 p(H),并尽可能地将 p(H) 带入求和内部。此外,在左边,我无害地颠倒了求和的顺序:现在我先对最优解 S* 中的顶点求和,然后再对可能的激活边子集求和。

为什么这是一个好步骤?我们以前见过这种对 H 求和 p(H) * f_H(...) 的形式。记住,影响力正是覆盖函数的加权平均,所涉及的覆盖函数正是这些 f_H,而权重正是这些 p(H)。因此,影响力 f(.) 正是 ∑_H p(H) * f_H(.)

这意味着我们即将成功。这意味着所有这些对 H 的求和,我们知道它们的另一个名字:这就是对应顶点子集的影响力。

例如,左边第一个对 H 的求和,不过是 KKT 算法前 j-1 次选择的顶点加上最优解 S* 中这个顶点 v 所构成集合的影响力。类似地,左边第二个对 H 的求和,就是 KKT 算法前 j-1 次选择的顶点集合 S_{j-1} 的影响力。

在右边,我们得到 S* 的影响力 I*,以及 S_{j-1} 的影响力 f(S_{j-1})

这就是我们在最大覆盖分析中最重要的不等式的类比。现在我们有了影响力最大化问题的类似不等式。既然我们知道了这个,我们基本上就成功了。证明的其余部分与最大覆盖的证明完全相同。

这个关键不等式告诉我们什么?它告诉我们,如果我们考虑这 K 个思想实验(贪心算法当前解是前 j-1 次迭代选出的 S_{j-1},我们对最优解 S* 中的每个顶点 v 做一次实验:如果现在就将这个特定顶点加入贪心解,影响力会增加多少),那么这个不等式说明这些思想实验结果的和,有一个下界,即当前的影响力赤字(最大可能影响力 I* 比 KKT 算法已获得的影响力 f(S_{j-1}) 大的部分)。

下一步,我们使用相同的策略:K 个数的最大值至少等于它们的平均值。在这个不等式的左边,我们有 K 个数的和,平均值就是和除以 K,所以其中一个数必须至少和平均值一样大。换句话说,在最优解 S* 中,存在一个顶点,它至少能获得左边求和的 1/K 倍,这当然意味着它也至少能获得右边求和的 1/K 倍,因为右边只可能更小。

也就是说,在最优解 S* 中存在一个顶点,如果你现在就将它添加到贪心算法的当前解中,保证影响力至少会增加当前影响力赤字的 1/K 倍。

KKT 算法可能会也可能不会选择这个顶点。但它是贪心算法,它选择在当前迭代中能最大化影响力增加的那个顶点。因此,无论 KKT 算法实际做了什么,它获得的影响力增加至少会有相同的下界:至少是左边不等式求和的 1/K 倍,因此也至少是右边不等式的 1/K 倍,即 1/K * (f(S*) - f(S_{j-1}))

回顾关键引理的陈述,这正是我们需要证明的。我们现在已经完成了关键引理的证明。再次强调,KKT 算法的*似正确性保证从这里开始,将通过我们用于最大覆盖分析的完全相同的代数运算得出。

总结

本节课中我们一起学*了影响力最大化问题贪心启发式算法的理论证明。我们证明了即使影响力最大化是比最大覆盖更一般的问题,类似的贪心算法(KKT算法)也能获得同样好的*似保证:选择2个种子顶点时,你至少能获得75%的最大可能影响力;选择3个时,至少70.4%;无论选择多少个种子顶点,你至少能获得约63.2%的最大可能影响力。这只是一个“保险策略”,告诉你最坏情况下的理论下限。在实际的、更现实的实例中,你应该期望这个启发式算法表现得更好,获得的影响力会比这个最坏情况分析所暗示的更接*100%。

这结束了我们关于使用贪心算法设计范式的快速启发式算法的讨论,也结束了我们有可证明*似正确性保证的启发式算法讨论。但我们关于第20章的讨论尚未结束,因为我还想为你的工具箱添加一个重要工具——局部搜索,尽管它通常没有可证明的保证,但在许多实际案例中,对于NP难问题仍然极其有效。

014:TSP的2-OPT启发式算法 - 第1部分

在本节课中,我们将学*如何为旅行商问题设计一个启发式算法。我们将从一个简单的贪心算法开始,然后介绍一种更强大的技术——局部搜索,并具体讲解其核心变体之一:2-OPT启发式算法。

概述:从NP难问题到启发式算法

NP难问题总是令人头疼。在我们之前研究过的几个问题中,如集合覆盖最小化、最大覆盖问题和影响力最大化问题,我们至少都拥有快速的启发式算法,并且这些算法享有*似正确性的保证,就像一份“保险单”。

然而,对于包括旅行商问题在内的一系列其他NP难问题,我们并不期望存在具有此类*似正确性保证的快速算法。事实上,如果存在这样的算法,将推翻P不等于NP的猜想。因此,如果你面对的是这类问题,并且确实需要一个快速算法,你唯一的选择就是设计一个启发式算法。虽然它没有“保险单”,但至少能在你的应用中出现的大多数或所有输入上表现良好。

局部搜索及其众多变体,正是这类技术中最强大、最灵活的一种。在本视频中,我们将从零开始为旅行商问题开发一个启发式算法,这将迫使我们发展出一些新思路。在下一部分,我们将退一步,识别出该TSP启发式算法中体现局部搜索原则的要素。掌握了应用局部搜索的模板以及针对TSP的具体实例后,你将能够很好地将此技术应用到自己的项目中。

旅行商问题回顾

首先,让我们快速回顾一下旅行商问题的定义。

问题的输入是一个完全无向图。这意味着有n个顶点,并且所有n选2条无向边都存在。此外,每条边都有一个实数值的成本,就像最小生成树问题中一样。



目标则是计算一个旅行商回路。所谓旅行商回路,是指一个访问每个顶点恰好一次的环。你从某个地方出发,经过n步后,访问完所有其他顶点并回到起点。在所有回路中,我们希望找到总边成本最小的那一个。

旅行商问题是一个非常著名的问题。如果你好奇为什么在本系列书的前三部分没有讨论它,那是因为不幸的是,它是一个NP难问题。我们将在视频列表中对应第22章的部分亲自证明这一点。但现在,让我们暂且相信TSP是NP难的。我们需要在正确性或速度上做出妥协。

探索简单的贪心启发式算法

为了感受如何为TSP设计启发式算法,让我们在这个测验中探索一个你可能想到的最简单的算法,类似于Prim算法在TSP问题上的类比。

这是一个贪心启发式算法,被称为TSP的最*邻启发式算法。你只需从任意一个你喜欢的顶点(称为小a)开始,然后以贪心、短视的方式,一次一条边地构建回路。从起始顶点A出发,你有n-1个顶点可以作为下一个访问点,你只需前往离你最*的那个,即对应边成本最小的那个顶点。此时你访问了两个顶点,还剩下n-2个。在所有这些剩余的顶点中,你前往最*的那个,即边成本最小的那个。然后你重复这个过程。当你重复了n-1次后,你得到了一条访问每个顶点恰好一次的路径。当然,在最后一步,你必须回到起点。这就是TSP的最*邻启发式算法。

接下来,我希望你计算出最*邻启发式算法在右侧幻灯片绘制的这个5顶点示例中会做什么。

除了找出最*邻启发式算法的输出外,我还希望你找出最优的、成本最小的旅行商回路。花几秒钟时间,计算出两者,然后我们将讨论答案。

答案是a。最小可能的回路成本是23,而最*邻启发式算法的回路成本是29。

让我们按相反的顺序来看这两个事实。首先从最*邻启发式算法开始。该算法将从顶点A开始,它查看其他四个顶点,发现整个图中成本最小的边与它相邻,并通向B。这肯定是最*邻启发式算法第一次迭代会选择的边。

现在,回路到达B后,它必须决定接下来是去C、D还是E。在这三个顶点中,B离C最*,到达C的成本仅为2,而到达D或E的成本分别为3或6。

现在回路在C点,只剩下两个选项。它必须接下来前往顶点D或顶点E,两个选项都不太好,但两者中较好的是沿着成本为7的边前往E。

从这里开始,回路的选择是强制的。此时只有一个未访问的顶点,所以它必须从E前往D。

当然,最后它必须返回起点,所以最终它从D回到A。

因此,最*邻启发式算法的回路如下所示,其总成本加起来确实是29。那么最优回路呢?虽然不一定立即显而易见,但总共只有12种选择,你可以对它们进行穷举搜索。确实存在一个成本为23的回路,我在这里用洋红色标出。

这个测验的启示是什么?我们在这个具体例子中看到,最*邻启发式算法不一定能计算出最小成本的旅行商回路。我们对此并不感到惊讶,因为我已经告诉过你TSP是NP难的。这个算法显然在多项式时间内运行,所以如果它是正确的,那将推翻P不等于NP的猜想,我们不期望这种情况发生。

然而,与我们之前看到的三个具有良好*似正确性保证的启发式算法不同,这个贪心算法可能离最佳回路相去甚远。要理解这一点,请记住这个回路的最后一步是强制的。因此,即使从D到E的最后一步成本高达十亿,这个回路仍然会选择那条边,因为在遍历了其余部分后,那是它唯一剩下的选择。

对于最*邻启发式算法来说,这将是一个非常糟糕的例子。你可以想象使用更复杂的贪心算法来规避这个特定例子,但不幸的是,所有贪心算法,实际上所有多项式时间算法,在更复杂的TSP实例中似乎都会遭受类似的命运。

寻求改进:局部搜索的思想

那么,我们能做得更好吗?这里有一个自然的想法:谁说我们必须在最*邻启发式算法结束时就必须停止?如果我们把该启发式算法得到的回路作为一个起点,然后贪婪地寻找进一步改进它的方法呢?

为了理解这可能如何运作,在这个测验中,我希望你思考一下,要对一个回路进行最小的修改以得到另一个不同的回路,需要改变什么。


这个测验的答案是第三个。两个包含n个顶点的回路可以共享n-2条边,但不能更多。为什么它们不能共享n-1条边?因为一旦我告诉你一个回路的n-1条边,它就唯一地确定了最后一条边必须是什么。将其变成回路的唯一方法是连接两个端点。因此,如果两个回路共享n-1条边,它们实际上必须共享所有边,并不是不同的。另一方面,你可以有仅相差两条边的不同回路。让我们看一个5顶点实例的例子。

一方面,你可以想象一个五边形回路,这就像我们上一个测验示例中沿着外圈走。或者,你可以有这条浅蓝色的回路,它使用了外圈的三条边和两条内部交叉的边。这就是两个不同的、访问五个顶点的回路,它们有三条公共边,这是可能的最大值。

记住这个测验的重点。我们对最*邻启发式算法不太满意,然后我们问,为什么我们必须止步于它的输出?为什么不能进一步贪婪地改进它?我们想知道可能导致更好回路的最小改变。在那个测验中,我们看到,你或许可以移除两条边,然后放入两条不同的边,这可能会给你一个更好的回路。这种移除两条边并放回两条不同边的修改,被称为2-交换

理解2-交换操作

那么,2-交换具体是如何工作的呢?你有一个初始回路T,你从中移除两条边,然后插入两条不同的边。你需要选择两条边,它们应该有不同的端点。一条边是(v, w),另一条是(u, x),总共四个不同的端点。你把它们移除,这会将你的回路断开成两条路径。对于四个端点,在两条路径之间总共有三种不同的配对方式。其中一种会给你开始的回路,一种会给你两个不相交的环(这不是一个回路),第三种会给你一个新的回路,而这正是你想要的。

例如,在幻灯片上的这个图示中,v当前与w配对。两个候选的更改是:将v改为与x配对,或者将v改为与u配对。如果我们将v与x配对,从而将u与w配对,那么我们就得到一个新的回路,使用这两条洋红色的边。但是,如果我们将v与u配对,并被迫将w与x配对,我们添加这些绿色的边,那么我们得不到一个可行的解,只会得到两个不相交的环,这当然不是我们想要的。这就是2-交换:你取出这两条蓝边,然后放入相应的洋红边,得到一个新的回路。

自然地,修改回路会改变其总成本。那么,一次给定的2-交换能带来多少回路成本的下降呢?

好消息是,我们移除了边(v, w)和(u, x),所以回路成本将下降我们之前为这些边支付的代价。另一方面,我们插入了这些新边(在例子中是(u, w)和(v, x)),所以我们现在必须为这些边付费,这会抵消掉回路成本的下降。因此,我们感兴趣的是那些下降为正的2-交换,即我们移除的边带来的好处超过了我们添加的新边的成本。如果一个2-交换具有这种性质,即严格降低了回路成本,我们称之为改进型2-交换

2-OPT启发式算法

现在,你可能已经猜到了TSP的2-OPT启发式算法是什么。你只需用一个任意回路(例如,最*邻贪心启发式算法的输出)来初始化它,然后只要有可能,就继续贪婪地进一步改进回路。每次改进时,你都进行最小的必要修改以获得新回路,即进行一次2-交换,并且这个2-交换应该是改进型的,意味着你移除的边的成本应超过你插入的边的成本。你尽可能长时间地这样做。当没有更多的改进型2-交换时,你停止并返回该回路作为最终结果。

在伪代码中,2-change子程序以一条回路和该回路中两条无公共端点的边作为输入,然后执行相应的2-交换。它移除给定的边(v, w)和(u, x),然后添加能给你一个新回路的那对边,即将v与u或x配对,然后将w与另一个配对,选择那个能给你新回路的配对方式。

总结

在本节课中,我们一起学*了如何为NP难的旅行商问题设计启发式算法。我们从回顾TSP定义开始,然后探讨了一个简单的贪心算法——最*邻启发式算法,并指出了其局限性。接着,我们引入了局部搜索的思想,并详细介绍了其核心操作:2-交换。我们定义了改进型2-交换,并最终构建了2-OPT启发式算法的基本框架:从一个初始回路开始,反复应用改进型2-交换,直到无法进一步改进为止。这为我们处理缺乏*似保证的困难优化问题提供了一个强大而灵活的工具。在下一部分,我们将更抽象地审视这个算法,提炼出局部搜索的一般原则。

015:15-20.4_ TSP的2-OPT启发式算法 - 第2部分

在本节课中,我们将通过一个具体例子,详细学*2-OPT启发式算法如何工作。我们将分析其运行步骤,并讨论算法的运行时间与解的质量。


2-OPT算法工作示例

上一节我们介绍了2-OPT算法的基本思想。本节中,我们通过一个具体例子来演示其运行过程。

我们使用之前测验中出现过的同一个例子。下图展示了该实例的5个顶点及其边权。

需要提醒的是,如果我们从顶点A开始运行最*邻启发式算法,最终会得到一个遍历外围的环游,其总成本为29。我们将以此作为2-OPT算法的初始解。

初始迭代

我们以这个浅蓝色环游(总成本29)初始化2-OPT算法。我们想知道能否通过一次2-交换使其变得更好。检查方法之一是枚举所有可能的2-交换,并观察每个交换的效果。

以下是所有可能的2-交换数量:
对于n个顶点,可能的2-交换数量为 n * (n - 3) / 2。当n=5时,结果为5。

现在,让我们检查从这个初始外围环游出发,通过一次2-交换可以得到的五个不同环游。

每个绿色环游与浅蓝色环游共享五条边中的三条。它们都包含三条外围边,然后用两条交叉边将这三条边连接成一个环游。

接下来,我们需要判断这些2-交换中是否存在改进的交换,即是否能得到一个总成本严格更低的环游。

以下是五个环游的总成本:

  1. 第一个环游:成本31(更差)。
  2. 第二个环游:成本27(优于29)。
  3. 第三个环游:成本32(更差)。
  4. 第四个环游:成本24(优于29)。
  5. 第五个环游:成本25(优于29)。

因此,存在三个改进的2-交换选项(第二、第四、第五个)。算法需要决定选择哪一个。一种自然的启发式策略是:按顺序枚举2-交换,一旦找到一个改进的交换,就立即执行它。

如果采用此策略,在本例中,我们会执行第一个遇到的改进交换(即第二个环游),从而移动到总成本为27的新环游。

第二次迭代

现在,我们进入while循环的下一次迭代,从新的成本27环游开始重复整个过程。

我们需要检查从这个环游出发的五个可能的2-交换,看是否有改进的。其中一个交换会直接撤销我们刚刚做的操作,回到五边形环游。因此,我们只需关注其他四个能产生新环游的2-交换。

现在的问题是,这四个环游中是否有比当前成本27环游更好的?让我们计算每个环游的成本:

  1. 第一个环游:成本24(优于27)。
  2. 第二个环游:成本23(优于27,且这是最优环游的成本)。
  3. 第三个环游:成本31(更差)。
  4. 第四个环游:成本30(更差)。

因此,存在两个改进的2-交换选项(第一和第二个)。如果继续采用“找到即执行”的策略,我们会移动到第一个遇到的改进环游,即成本24的环游。

第三次迭代

进入while循环的第三次迭代。我们再次询问:能否通过2-交换使这个成本24的环游变得更好?同样有五个可能的2-交换,其中一个会直接回到我们刚离开的环游。让我们看看其他四个2-交换的结果。

检查这四个环游的成本,看是否有低于24的:

  1. 第一个环游:成本32(更差)。
  2. 第二个环游:成本24(与当前环游成本相同,但严格改进要求成本降低,故不计为改进)。
  3. 第三个环游:成本25(更差)。
  4. 第四个环游:成本26(更差)。

这意味着没有改进的2-交换可用。此时,2-OPT算法停止,并返回当前环游作为最终输出。

对于这个特定例子,算法最终输出的是成本24的环游。

算法分析:运行时间与解质量

与所有算法一样,我们需要讨论其运行时间以及在何种程度上算法是正确的。

运行时间分析

首先,算法是否会在有限时间内终止?while循环是否会永远运行?
虽然旅行商环游的数量是指数级的((n-1)!/2),但总数是有限的。更重要的是,根据定义,2-OPT算法while循环的每次迭代都会严格降低当前环游的总成本(例如从29降到27,再降到24)。这意味着你永远不会重复看到同一个环游,因为每个新环游都比之前见过的所有环游都更好。因此,即使在最坏情况下算法遍历了每一个环游,它也将在指数级但有限的时间内终止。

当然,我们从不满足于指数级的运行时间上界,我们希望2-OPT算法能更快,最好是多项式时间。

算法的总运行时间等于主while循环的迭代次数乘以每次迭代的执行时间。每次迭代基本上需要遍历所有可能的2-交换以寻找改进的交换。如果实现得当,这需要O(n²)的时间,因为可能的2-交换数量是n*(n-3)/2

那么,关键问题是迭代次数是否总是多项式级别?这里有一些好消息和坏消息。

坏消息是,确实存在精心构造的病理实例,使得2-OPT算法的主while循环会执行指数级的迭代次数。

好消息有两点:

  1. 在实际应用中,在现实的输入实例上,几乎永远不会遇到这些病理例子。2-OPT算法几乎总是能相当快地收敛,例如在O(n²)次迭代内。
  2. 我们并不一定要运行2-OPT算法直到完全结束。查看伪代码可知,你可以在任何时候中断这个算法。它始终维护着一个可行的环游。例如,你可以设置一个计时器(10分钟、1小时等),时间一到,如果算法尚未停止,就输出它找到的最新且最好的环游。这类算法有时被称为“随时算法”。

解的质量分析

2-OPT算法肯定会在你初始化的环游基础上进行改进,这是好消息。但从我们的例子中我们也知道,它不一定能计算出可能的最佳环游。在我们的例子中,它输出了成本24的环游,但存在一个成本23的更优环游。

关于解的质量,同样有坏消息和好消息。

坏消息是,与最小生成树、最大覆盖和影响力最大化等问题不同,对于TSP,2-OPT算法没有可证明的*似正确性保证。确实存在一些复杂且人为构造的例子,使得2-OPT算法的输出环游可能比最优环游差任意多倍。

好消息是,从经验上看,2-OPT算法的表现相当令人印象深刻。其性能在一定程度上取决于应用场景和处理的实例类型,但非常常见的是,它能返回总成本在最优解10%到20%以内的环游,并且在许多应用中,可以可靠地达到与最优解相差几个百分点的水平。

因此,这非常令人鼓舞。显然,如果我们能像贪心启发式算法那样有一个“保险政策”会更好,但至少在实际应用出现的实例类型上,2-OPT算法经验上表现相当好,输出的环游总成本不会比最小可能成本高出太多。

事实上,当业界人士向我请教一个看起来类似TSP的问题时,我通常会建议他们从2-OPT启发式算法开始,并结合我们将在下一个视频中讨论的一些改进技巧。如果你在自己的项目中需要处理TSP类型的问题,这是一个极好的起点。


总结

本节课中,我们一起学*了2-OPT启发式算法如何通过具体例子逐步改进环游。我们分析了其迭代过程,并讨论了算法的运行时间特性(最坏情况指数级,但实际表现良好)和解的质量(无理论保证,但经验上接*最优)。我们还了解到它可以作为“随时算法”使用,为实际应用提供了灵活性。

在下一个视频中,我们将跳出细节,展示2-OPT算法如何体现局部搜索的一般原则。

016:局部搜索原理(第1部分)

在本节课中,我们将要学*局部搜索算法的基本原理。局部搜索是一种通过局部移动来探索可行解空间,并逐步改进目标函数值的算法范式。我们将通过一个具体的例子来理解其核心概念,并学*如何将其应用到不同的问题中。

上一节我们介绍了旅行商问题的2-opt启发式算法,本节中我们来看看如何将其抽象为一种通用的算法设计范式。

局部搜索的元图视角

让我们快速回顾上一节讨论的旅行商问题2-opt启发式算法。我们可以将2-opt算法视为在一个非常大的图中行走。这个图我们称之为元图

为了解释元图,我们使用一个包含5个顶点的TSP实例作为例子。元图的顶点对应于该实例的所有旅行商环路。对于一个5顶点的实例,环路的数量是 (n-1)! / 2 = 12。因此,这个元图有12个顶点。

我将这12个环路排列成四行。最上面一行是周长环路,即最*邻启发式算法的输出。最下面一行是“补集”环路,它不包含周长环路的任何一条边,而包含所有其他五条边,形状类似一个星形。其他10个环路放在中间两行。

接下来,我为这12个环路标注它们的成本。

现在,我来定义元图的边。元图的边对应于2-交换操作。换句话说,如果两个环路可以通过一次2-交换相互转换,或者说它们共享五条边中的三条,那么它们在元图中就是相邻的。

例如,考虑最顶部的周长环路。在第二行中,有五个环路,它们恰好是我们在上一节运行2-opt算法时,第一次迭代中检查的五个相邻环路。也就是说,顶部环路的五个相邻环路正是第二行的五个环路。

底部的六个环路在某种意义上是对顶部六个环路的“反射”,我们只是切换了哪些边在内、哪些边在外。底部的环路(星形)与第三行的五个环路中的每一个都通过2-交换相连。

对于第二行和第三行中的其他环路,每个环路都应该有五条入射边。我已经以这样的方式排列了环路:这些额外的边将连接到另一行的环路上。例如,第二行中间的环路将与第三行中除了正下方那个之外的所有环路相邻。

这就是与旅行商问题实例相关联的元图的定义:元图的顶点对应于TSP实例的环路,当且仅当两个环路仅相差两条边时,它们在元图中相邻。

在元图中可视化2-opt算法

为什么我要介绍这个元图?因为我们可以用它来非常清晰地可视化2-opt算法。

在我们的运行示例中,我们从最*邻启发式算法的输出(顶部的环路)开始。2-opt算法的工作方式是进行一次2-交换,即在元图中沿着一条边“走”到一个相邻的环路。在示例中,有五个选项,其中三个环路的总成本严格更小(成本为27、24、25的环路)。

我们处理的是找到第一个改进的2-交换就执行的变体。这导致算法沿着从顶部环路出发的第二条边,到达成本为27的环路(第二行第二个)。我们在图中走了一步,得到了2-opt算法在示例中采用的第二个环路。

然后我们重复这个过程:从成本27的环路出发,再次检查五个相邻环路。我们发现有两个改进的2-交换(第三行的第一个和第三个环路)。如果我们取找到的第一个,那么我们将从成本27的环路走到成本24的环路。

在进行两次2-交换(即在元图中走两步)之后,我们到达了成本24的环路。再次检查相邻环路,发现没有改进的移动。实际上,只有一个更好的环路(成本23),但它与我们当前所在的环路在同一行,而根据我们绘制的图,同一行的两个环路并不相邻。所有相邻环路的成本都大于或等于24。这意味着2-opt算法将在此停止。

更一般地说,任何TSP实例上2-opt算法的任何一次执行,都可以被视为在一个合适的元图中进行类似的行走。顶点对应于环路,如果两个环路通过2-交换相连则相邻。2-opt算法所做的是沿着一条路径行走,从一个环路到另一个环路,每个环路的成本都严格小于前一个,直到无法继续为止。

元图的规模

让我们通过一个小测验来确保定义清晰。

问题:假设我给你一个至少有4个顶点(n ≥ 4)的TSP实例。这个元图有多少个顶点和边?

正确答案是A

顶点数:元图的顶点对应于TSP实例的环路。环路的数量是 (n-1)! / 2。这就是元图的顶点数。

边数:计算图中边数的一种方法是遍历每个顶点,计算其入射边的数量,然后求和。最后除以2,因为每条边从两个端点各被计算了一次。

  • 顶点数:(n-1)! / 2
  • 每个顶点的入射边数:对于一个给定的环路,可以通过2-交换到达的相邻环路数量是 n * (n-3) / 2
  • 总边数 = [ ( (n-1)! / 2 ) * ( n * (n-3) / 2 ) ] / 2 = n! * (n-3) / 8

局部搜索的一般范式

现在,我想从旅行商问题和2-opt算法的具体案例研究出发,更一般地讨论局部搜索算法。

很酷的是,大多数局部搜索算法都可以像我们刚才看到的那样被看待——它本质上就是在可行解的元图中行走。

你甚至可以在可视化中添加第三个维度,其中某个可行解点的“高度”或“海拔”对应于该解的目标函数值。因此,在像我们刚才看的TSP这样的最小化问题中,这种行走总是向下的,走向海拔更低的可行解。如果是最大化问题,你就是在向上爬。实际上,局部搜索通常被称为爬山法,正是源于这种最大化问题的可视化:你在元图中一步步行走,总是走向更高点。

因此,大多数局部搜索算法都可以这样可视化:在可行解的元图中行走。不同的局部搜索算法主要区别在于它们对元图的选择。

  • 显然,对于不同的问题,你会有不同的可行解(不同的顶点集)。
  • 即使对于同一个问题,不同的局部搜索算法也可以对元图的边有不同的定义,即它们对哪些可行解在元图中相邻有不同的定义。

其次,不同的局部搜索算法在探索图的方式上也有所不同,我们将在下一个视频中详细讨论。

你也可能在连续优化(相对于我们这里讨论的离散优化问题)的背景下遇到局部搜索算法,其中最著名的是古老的梯度下降算法。它本质上也是爬山法,但搜索空间是欧几里得空间中的所有点,而不是有限的离散解集。梯度下降(或其随机变体)是现代机器学*(特别是监督学*,如训练神经网络)的核心工作算法。

局部搜索算法设计范式的六个步骤

现在,让我们详细阐述局部搜索算法设计范式的细节。如何将其应用到你自己的工作中遇到的问题?让我将其分解为六个步骤。

前三个步骤都涉及为你的应用定义合适的元图。

步骤一:定义可行解(顶点)
可行解应该对应于你关心的问题中的可行解。对于我们讨论的这类明确问题,答案通常是直接的。

  • TSP:可行解是环路。
  • 最小化完工时间:可行解是可能的调度方案。
  • 最大覆盖:可行解是从给定集合中选出的K个子集的集合。

步骤二:定义目标函数
要应用局部搜索,你需要明确你想要最大化或最小化的目标。同样,对于我们关注的问题,答案通常是显而易见的。

  • TSP:总成本。
  • 最小化完工时间:完工时间。
  • 最大覆盖:覆盖的元素数量。

步骤三:定义局部移动(边)
要完成元图的描述,你必须说明边是什么,即你认为哪些可行解是相邻的。元图的边正好对应于允许的局部移动。

这三个步骤是建模决策,你甚至需要在开始考虑实现局部搜索算法之前就做出这些决定。前两步基本上是精确定义问题(什么是允许的可行解,以及你想要优化什么),然后,在运行局部搜索之前,你必须说明局部搜索允许做什么(即允许的局部移动是什么)。

在完全定义了你的元图之后,你还需要做出几个更具算法性质的决策。

步骤四:初始化
你需要回答如何初始化局部搜索算法,即从哪个可行解开始这次元图行走?
例如,在我们的TSP案例研究中,我们通常考虑从最*邻启发式算法的输出开始。当然,我们也可以做出其他决定。

步骤五:选择改进移动
这是一个关于如何实现局部搜索算法细节的算法问题。正如我们在TSP示例中看到的,从一个可行解出发,可能有多个改进的局部移动可用,你需要决定实际执行哪一个。
例如,在我们的TSP运行示例中,我们总是选择找到的第一个改进的局部移动。

一旦你回答了所有这五个问题,你的局部搜索算法就基本确定了。你有了元图(由步骤一至三定义),知道了从哪里开始(步骤四的答案),并且知道了在众多可能的改进移动中如何选择每一步(步骤五的答案)。

通用局部搜索算法的伪代码

为了确保清晰,让我给你一个通用局部搜索算法的伪代码。在你按照范式做出步骤一至五的决策后,就可以运行这个算法。伪代码看起来就像TSP的2-opt算法,只是针对通用问题和通用的改进局部移动概念。

初始化:从某个任意的可行解 S 开始
while (存在从 S 出发的改进局部移动) {
    根据步骤五的规则,选择一个改进局部移动
    对 S 执行该移动,得到新的可行解 S'
    令 S = S'
}
返回 S

首先,你从某个任意的可行解开始(步骤四就是确定如何初始化)。然后是一个主 while 循环:只要存在从当前解出发的改进局部移动(即能产生更优目标函数值的移动,对于最大化问题是更大,对于最小化问题是更小),你就继续执行。你执行一个改进的局部移动。从一个给定的可行解出发,可能有很多移动,但步骤五的要点就是解决这种歧义,决定选择哪一个。

最终,局部搜索会终止,因为目标函数值在每次迭代中都在改进。最后,你将得到一个解,从该解出发没有改进的局部移动(每个局部移动要么保持目标函数值不变,要么使其变差)。这种类型的解被称为局部最优解。局部最优解是指无法通过任何局部移动改进的解。

应用示例:定义元图

这个讨论可能感觉有些抽象。我们确实有TSP的2-opt案例研究,但我还是想花几分钟时间,给你一些关于步骤一至五如何具体运作的实例。

让我们从前三个步骤开始,即定义可行解的元图。我们不仅看TSP的运行示例,也看看另外两个我们设计过快启式算法的问题:最小化完工时间问题和最大覆盖问题。

步骤一:可行解

  • TSP:环路。数量为 (n-1)! / 2
  • 最小化完工时间:调度方案。如果有 n 个作业和 M 台机器,总共有 M^n 种不同的调度方案。
  • 最大覆盖:从给定的 M 个子集中选出 K 个的集合。数量为 C(M, K)

步骤二:目标函数
在我们的运行示例中,这没什么好说的:TSP是总成本,最小化完工时间是完工时间,最大覆盖是覆盖数量。

请注意,一旦你做出了前两个决定(元图的顶点是什么,以及目标函数值是什么),此时你已经知道了“圣杯”解(即实例的全局最优解)是什么。就像在我们5顶点的TSP示例中,成本为23的环路。

只有在我们回答了步骤三的问题、定义了允许的局部移动之后,我们才能谈论局部最优解。

步骤三:局部移动
我们已经看到了一个选择允许局部移动的例子,即TSP的2-opt启发式算法,其中局部移动对应于2-交换。

当我们讨论最小化完工时间或最大覆盖时,我们实际上并没有使用局部搜索,因此不需要定义允许的局部移动。但我们现在可以退一步说,假设我们确实想用局部搜索来处理这些问题,我们会怎么做?

  • 最小化完工时间:可行解是调度方案。可能最简单的局部移动就是重新分配一个作业。你取一个在某台机器上的作业,将其重新分配到其他 M-1 台机器中的一台。这意味着,从每个调度方案出发,有 n * (M-1) 个不同的相邻调度方案(可通过局部移动到达)。
  • 最大覆盖:可行解对应于选出的K个子集的集合。可能最简单的局部移动就是交换。如果你有一个当前的K个子集的集合,你会取出其中一个,并用一个不同的子集替换它,从而得到一个新的K个子集的集合,希望覆盖更多。这里,你有 K 种选择来决定取出哪个子集,有 M-K 种选择来决定放入哪个子集。因此,从每个可行解出发,有 K * (M-K) 个相邻解。

一旦你回答了步骤三,你现在就完全指定了你所关心问题实例的局部最优解是什么。

  • 在我们运行的5顶点TSP示例中,我们有两个局部最优解:第三行的第一个和第三个环路。第一个不是全局最优解(全局最小值),但第三个是全局最小值。
  • 在最小化完工时间的背景下,如果局部移动只是重新分配一个作业,那么局部最优解是指任何单个作业的重新分配都无法使完工时间变小。也就是说,每个作业的重新分配要么使完工时间保持不变,要么使其变大。
  • 类似地,在最大覆盖中,如果我们使用交换作为局部移动,那么局部最优解是指一个K个子集的集合,其中用任何新子集替换现有子集都无法增加覆盖,要么覆盖保持不变,要么甚至变小。

我们在关于最小化完工时间和最大覆盖的测验中看到了许多可行解的例子。我鼓励你回顾一下我们看到的例子,并检查哪些实际上是局部最优的,哪些可以通过在其基础上进行进一步的局部搜索来改进。你会发现两种类型的例子。实际上,这触及了局部搜索一个非常直观的用途:作为后处理步骤,以进一步改进某些启发式算法(如贪心算法)的输出。例如,当我们运行Graham算法、LPT算法或最大覆盖算法时,我们没有这样做,但我们可以在最后附加一个后处理步骤,进行进一步的局部搜索,从而得到一个更好的解。当你在实践中实现这些算法时,可能需要考虑这一点。

本节课中我们一起学*了局部搜索算法的基本原理。我们了解到,局部搜索可以被视为在可行解构成的元图中行走,通过定义顶点(可行解)、边(局部移动)和目标函数来构建这个图。算法的执行过程就是从初始解出发,不断选择改进的局部移动,直到达到一个局部最优解。我们还探讨了如何将这一范式应用到不同的问题中,并指出了初始化策略和移动选择规则的重要性。理解这些核心概念是设计和应用有效局部搜索算法的关键。

017:局部搜索原理(第二部分)

在本节课中,我们将继续学*局部搜索算法。我们将探讨如何将局部搜索应用于同一问题的不同方式,并深入讨论算法的具体实现细节,包括初始化、改进移动的选择以及如何避免陷入低质量的局部最优解。最后,我们将总结局部搜索算法的适用场景。


3️⃣ 邻域大小的选择

上一节我们介绍了局部搜索的基本框架和邻域的概念。本节中我们来看看,即使对于同一个问题(如TSP),也可以定义不同的邻域结构。

将局部搜索应用于同一问题有不同的方式。换句话说,即使你已经确定了问题(步骤1)和全局最优解的定义(步骤2),对于步骤3(定义邻域移动)也有多种合理的选择。为了说明这一点,让我们再次回到旅行商问题。

我们之前研究了允许的局部移动是“2-交换”,即移除两条边,然后重新加入两条边。但为什么一次只能交换两条边呢?为什么不尝试一次交换三条边,甚至更多条边呢?

让我们从“3-交换”启发式算法开始,即在每次局部搜索迭代中,从一条回路中移除三条边,然后重新加入三条边。

例如,我们可以看这个示意图中的浅蓝色回路。这里我标出了三条不同的边,它们具有不同的端点:边(v, w)、(y, z)和(u, x)。这三条边将被移除。移除后,我们仍然会得到从u到v的路径、从w到y的路径和从x到z的路径。但现在,我们以不同的方式重新连接这六个顶点,从而得到一条新的回路。图中显示的方式是:我们直接用一条边连接v和z,类似地连接w和x,以及u和y。正如你所见,这给了我们一条回路,并且它肯定与我们开始时的那条回路不同。

有趣的是,与“2-交换”不同,“2-交换”中无论移除哪对边,都唯一地决定了新回路的样子。而“3-交换”则不是这样。即使你已经确定了要移除哪三条边,实际上有七种不同的方式来重新连接这六个顶点,从而得到与开始时不同的回路。因此,当你在局部搜索算法中使用“3-交换”时,对于每一组三条边,你实际上有七种可能的“3-交换”方式。你的改进移动将是任何一种移除三条边并以某种方式重新连接成回路,从而使总成本下降的方式。

“3-交换”启发式算法的正式定义不难猜到。它与“2-交换”类似,只是除了允许“2-交换”外,还允许“3-交换”。

现在我们有了针对同一问题(TSP)的两种真正的局部搜索算法:2-opt启发式算法和3-opt启发式算法。它们都是局部搜索算法,但并不相同,因为在3-opt中,你可以使用更丰富的“3-交换”族来进行改进。让我们通过下一个测验来更好地理解这两种不同的局部搜索算法将如何进行比较。


测验:比较邻域图

固定旅行商问题的任意一个实例(即固定顶点和所有边的成本)。我们有这两种局部搜索算法2-opt3-opt,每种算法都有自己的元图。

关于这些元图,下列哪些陈述是正确的?(注意:可能不止一个答案正确。)

  1. A. 3-opt的元图包含2-opt元图的所有边。
  2. B. 2-opt的元图包含3-opt元图的所有边。
  3. C. 2-opt的每个局部最优解也是3-opt的局部最优解。
  4. D. 3-opt的每个局部最优解也是2-opt的局部最优解。

正确答案是第一个(A)和最后一个(D)。

要理解原因,请记住3-opt启发式算法比2-opt启发式算法拥有更多的局部移动选项。2-opt只能使用“2-交换”,而3-opt可以使用“2-交换”,也可以根据需要选择使用“3-交换”。因为元图的边对应于允许的局部移动,而3-opt拥有更多的移动选项,所以它也拥有更多的边。这就是答案A正确的原因:如果你是2-opt元图H2中的一条边,那么你也是3-opt的一个允许的局部移动。

然后,如果你仔细想想,这意味着答案D也是正确的。因为如果你有一个顶点在元图H2中有一个具有更好目标函数值的邻居(即该顶点在H2中不是局部最优解),那么完全相同的这个局部移动、完全相同的这个邻居也表明该顶点在元图H3中也不是局部最优解。反过来,这意味着H3的每一个局部最优解肯定也是H2的局部最优解。

换句话说,如果你查看所有可能作为3-opt算法输出的回路(即所有它可能停滞的地方),这些回路也同样是2-opt算法可能停滞的地方。可能还有其他回路是2-opt的局部最优解(即没有改进的“2-交换”),但它们对于3-opt来说却不是局部最优解,因为存在改进的“3-交换”。


权衡:邻域大小的影响

当有人给你提供针对同一问题的多种算法时,你通常会想追问应该使用哪种算法以及在什么情况下使用。这正是我们在这里所做的。我向你展示了可以将局部搜索以多种方式应用于TSP。因此,如果你打算用局部搜索来攻击TSP(这是一个相当好的主意),你可能会问:允许“3-交换”有意义吗?还是我不应该费心?这其中有什么权衡?

老实说,对于局部搜索,这类问题通常最好通过经验来回答,即在你认为具有代表性的应用数据上尝试多种选项。但这个测验,特别是答案D,确实指出了更大邻域规模的一个普遍优势:随着你允许越来越多的局部移动,局部最优解的数量会越来越少。因此,局部搜索更不容易停滞在比全局最优解差很多的局部最优解上。

更大邻域规模的主要缺点是,它会减慢主循环中检查是否存在改进局部移动的速度。例如,在旅行商问题中,检查是否存在改进的“2-交换”需要O(n²)的时间(n为顶点数),而检查是否存在改进的“3-交换”则需要O(n³)的时间,因为存在立方数量级的潜在“3-交换”可能。

平衡大邻域规模利弊的一种启发式方法是:在满足每次迭代目标运行时间的前提下,使用尽可能大的邻域规模。例如,你可以设定每次迭代最多运行1秒或10秒,然后在这个预算内,使用你能承受的最大邻域,这样你就能拥有最少的糟糕局部最优解。


4️⃣ 算法决策:初始化与移动选择

现在我们已经非常透彻地讨论了应用局部搜索的建模决策(步骤1到3),让我们继续讨论步骤4和5中的算法决策,我们需要最终确定通用局部搜索算法的具体工作方式。具体来说,如何初始化?其次,当存在多个改进移动时,如何从中选择

初始化策略

让我们从第一个问题开始:如何初始化?在我们的TSP例子中,我们已经暗示了一个自然的做法:使用贪心启发式算法的输出进行初始化,例如最*邻启发式算法。

这个想法不仅适用于TSP。例如,如果你想使用局部搜索来解决最小化完工时间问题,你可以用LPT(最长处理时间优先)算法输出的调度方案进行初始化。这样,你至少从一个完工时间最多比最小可能值高出33%的调度方案开始,经过局部搜索后只会变得更好。

同样,如果你想使用局部搜索解决最大覆盖问题,你可以用我们研究过的贪心算法的输出进行初始化。

贪心初始化通常值得尝试。实际上,第二个经常非常好的初始化想法是:随机选择一个可行解

至少对于我们用作运行示例的问题来说,随机解的含义非常直接。例如,在TSP中,与其做任何贪心或聪明的操作,你实际上只需随机选择一个顶点的排列顺序,然后查看按该顺序访问顶点并返回起点的回路。在最小化完工时间问题中更简单:对于n个作业中的每一个,独立地、均匀随机地将其分配给M台机器中的一台。每个作业最初出现在每台机器上的概率相等,并且对所有n个作业独立进行。最大覆盖问题也是如此:可行解是所有K个子集的集合,所以你只需随机选择K个子集的集合。



为什么选择随机初始化?

如果你对此感到有点困扰,我表示理解。我们花了很大力气分析这些贪心算法,证明它们具有良好的*似正确性保证。为什么你要抛弃它们,转而做一些看似愚蠢和随机的事情呢?

但重要的是要认识到,仅仅因为你从一个更好的解开始局部搜索,并不意味着局部搜索会终止于一个更好的解。事实上,一个理想的初始化程序应该能快速找到一个不太差、同时又有很大局部改进空间的起始解。在许多情况下,随机解符合这个要求。



改进移动的选择规则

另一个算法决策是:正如我们所见,从一个给定的可行解出发,可能存在多个相互竞争的改进局部移动。为了使你的局部搜索算法完全确定,你需要说明你将使用哪一个。有几种方法,我们已经提到过几种。

  1. 第一个可行改进:这是我们运行示例中所做的。你可以枚举所有可能的局部移动,一旦找到一个改进的移动,就采用它。如果你希望确保主while循环的每次迭代尽可能快,这个规则很有意义。因为如果你在枚举过程中早期找到了一个改进的局部移动,你就可以停止,直接进入下一个可行解重新开始。

  2. 最速下降(最佳改进):如果你希望优先考虑目标函数在迭代间的改进速度,而不是每次迭代的运行时间,你可以做一件不同的事:耐心地查看所有局部移动(例如在2-opt中查看所有大约n²个可能的“2-交换”),然后选择能最大程度改进目标函数的那个移动。

第二个规则肯定会比第一个规则带来更慢的每次迭代运行时间,但你可能会希望因为你在迭代间积极地推进目标函数,从而最终执行更少的迭代次数。


  1. 随机选择改进移动:你可能想做的第三件事(我们之前没有提到),特别是如果你想鼓励你的局部搜索算法探索解空间,那就是在所有改进的局部移动中随机选择一个



随机选择的意义

我理解,这第三条规则可能让你觉得它结合了前两条规则的缺点:它每次迭代可能像第二条规则一样慢,而目标函数的改进速度可能像第一条规则一样慢,这似乎很糟糕。但当我们开始讨论通过向局部搜索算法注入随机性并运行多次独立试验来避免局部最优解时,这条规则的意义就会更加明显。这第三条规则在这种背景下最有意义。



5️⃣ 局部搜索的性能与质量

那么性能如何呢?如果你运行一个局部搜索算法,你应该期望它运行得快吗?你应该期望它输出高质量的解吗?

对于许多局部搜索算法,答案和权衡基本上与我们已经在TSP的2-opt启发式算法中看到的相同。让我简要回顾一下那些特性。

首先,我们不用担心局部搜索会进入无限循环。这是因为它所考虑的每个可行解都严格优于前一个。如果你只有有限数量的可行解(就像我们讨论的所有应用一样),那么局部搜索最终必然会停止在一个局部最优解上。

2-opt启发式算法一样,大多数局部搜索算法不幸地没有可证明的运行时间保证。会存在一些病态情况,它们需要非常多的迭代次数才能停止在一个局部最优解。有一些例外,有些局部搜索算法可以保证在多项式次数的迭代内停止,但它们确实是证明规律的例外。

好消息是,这实际上并不是将局部搜索应用于现实世界问题的太大障碍。这是因为在现实应用中出现的实例上,局部搜索往往收敛得非常快,在可容忍的时间内停止。就像2-opt一样,我们说过它通常需要超线性但亚二次方的迭代次数才能达到局部最优回路,这大致也是许多其他局部搜索算法的特征。

局部搜索在原则上可能需要很多次迭代才能停止在局部最优解,这并不那么重要的另一个原因是,你总是可以提前停止算法。你可以设置一个计时器,在一小时或一天后,当计时器响起时,你只需说:“嘿,局部搜索算法,把你找到的最*(也就是最好)的解给我。”


解的质量

让我们继续讨论你可以从局部搜索算法输出中期望的解的质量。和2-opt启发式算法一样,局部搜索算法通常没有可证明的*似正确性保证,不像我们在本章前三个贪心算法中看到的那样。同样,有一些例外,有些局部搜索算法确实有可证明的*似正确性保证,但它们再次是证明规律的例外。

好消息是,经验上局部搜索算法的表现似乎出奇地好。局部搜索返回一个相当好的局部最优解(不比全局最优解差太多)是非常常见的。也就是说,对于运行时间,你几乎永远不会在现实生活中看到局部搜索需要指数级迭代次数才能收敛的情况。但你肯定会在生活中看到局部搜索输出非常糟糕的局部最优解的情况。这确实可能发生,我们接下来将讨论如何调整局部搜索以最小化陷入这些糟糕局部最优解的机会。因此,局部搜索经常能给你一个高质量的解,但你不能指望它总是如此,它有时也会给出很差的解。



6️⃣ 避免低质量局部最优解的策略

低质量的局部最优解确实可能成为在实际应用中应用局部搜索的障碍。因此,了解你可以在基本局部搜索算法之上添加或注入的各种“附加功能”是非常值得的,目的是减少你最终陷入这些糟糕局部最优解的可能性。让我们看看几种可能的方法。

我们已经提到过的一件事是:如果你发现太多糟糕的解是局部最优的,只需允许更多的局部移动,其中一些解就会停止成为局部最优解。请记住,仅仅因为你是2-opt的局部最优解(没有改进的“2-交换”),你可能不是3-opt的局部最优解,因为可能存在改进的“3-交换”。一般来说,增加局部移动的数量会减少局部最优解的数量,因此你陷入任何糟糕局部最优解的可能性就更小。



注入随机性

但实际上,你应该尝试的第一件事,也是你能做的最简单、有时能产生巨大影响的事情,就是向你的局部搜索算法中注入随机性

我们已经提到了两个非常容易注入随机性的地方:

  1. 初始化:例如,不使用最*邻启发式算法来选择初始回路,而是均匀随机地选择一个初始回路。
  2. 改进移动的选择:当在多个改进移动中进行选择时,你可以随机选择其中一个。

现在,一旦你有了一个随机化的局部搜索算法版本,这很棒,你可以开始探索局部最优解的空间。只需一遍又一遍地运行你的局部搜索算法,进行独立试验。运行它100次,你会得到100个局部最优解。其中会有一些重复,但一般来说,你会在不同的运行中看到不同的局部最优解。在大多数应用中,你只需要其中一个解是好的——即使99个都是糟糕的局部最优解,但有一个是好的,你就会使用那个。

如果你真的非常渴望向局部搜索算法注入更多随机性,你甚至可以考虑以一定的概率允许算法采取使目标函数变差的移动



模拟退火思想

例如,这里有一个简单的方法(这大致对应于你可能听说过的“模拟退火”)。想象你处于某个可行解(例如在TSP中的某个旅行商回路)。

首先,你将均匀随机地选择一个局部移动(它可能改进也可能不改进目标函数)。例如,在TSP中,有大约 n*(n-3)/2 种不同的“2-交换”你可以做,你均匀随机地选择其中一个。

然后你说:好吧,如果我实际执行这个局部移动,目标函数会发生什么变化?

如果目标函数值保持不变或下降,那么没有理由不执行这个移动,直接执行它。

问题是,如果这是一个实际上会使目标函数变差的局部移动呢?现在,你将抛一枚硬币,并概率性地决定是否执行这个移动。

如果这个移动只使目标函数值变差一点点,那么你执行这个移动的概率将非常接*1(同样是为了随机探索)。但是,如果这个移动会使目标函数值变差很多,那么你实际有勇气执行这个局部移动的概率将非常低。

如果你不执行那个局部移动,你在下一次迭代中将停留在完全相同的可行解上,然后你将重新随机选择要考虑的局部移动。

需要指出的是,当你允许这种非改进移动时,局部搜索算法通常不会停止,它会永远运行下去。因此,这绝对是一种你需要在目标计算时间后中断的算法。


其他高级策略

你可以添加到局部搜索算法中的附加功能数不胜数。让我提一下至少在某些领域相当流行的两种类型。

  1. 历史依赖的邻域:这个想法是,不是一劳永逸地固定允许的局部移动,而是允许的局部移动可以取决于局部搜索算法迄今为止的轨迹。你为什么要这样做?例如,你可能想禁止那些似乎部分逆转了前一步移动、撤销了你刚刚所做事情的局部移动。例如,在TSP背景下,你可能想排除使用与上一步具有某些相同端点的“2-交换”。如果你听说过“禁忌搜索”或“Lin-Kernighan”启发式算法,它们都与这个想法有关。历史依赖邻域最强烈的动机之一来自于前面提到的允许非改进移动的局部搜索算法,因为一旦你允许非改进的局部移动,你就必须担心循环问题,担心你的局部搜索没有进展,没有探索解空间的不同部分。因此,历史依赖的邻域可以特别有效地防止局部搜索立即回到它刚才所在的地方。

  2. 种群方法:虽然我们目前讨论的局部搜索算法在整个执行过程中只维护一个可行解,但也有一种变体是维护一个可行解的种群。对于某个参数K(至少为2),算法将始终维护K个可行解。算法的每次迭代现在负责从K个旧解生成K个新解,例如,只保留当前K个解中最好的K个邻居,或者通过组合当前解对来产生新解。如果你听说过“遗传算法”或“束搜索”,它们都是基于这种思想。



7️⃣ 何时使用局部搜索?

作为已经学*到《算法详解》第4部分第20章的人,你了解了很多算法设计范式,而我现在又给了你另一个。因此,你应该问我的问题是:什么时候应该首先尝试局部搜索? 让我给你一个列表,说明为什么你可能想使用局部搜索。如果你的应用程序符合其中几条,我会建议你尝试一下。

  1. 计算能力不足时:当你没有足够的计算能力来最优地解决问题时,局部搜索是相关的。例如,可能是一个NP难问题,且实例规模相当大。也就是说,当你致力于使用快速启发式算法方法时,你应该考虑局部搜索。

  2. 不追求严格的理论保证时:可证明的保证并不是局部搜索算法的强项。正如我们所讨论的,在许多情况下,你无法证明它们保证快速停止(因为存在你永远不会看到的病态例子),你也无法一般性地证明解的质量保证(因为即使从经验上,你也会看到局部搜索算法输出非常低质量解的情况)。第二个观点的一个例外是,我们提到过你可以将局部搜索作为使用其他启发式算法后的后处理步骤。因此,如果你开始的启发式算法具有*似正确性保证(就像我们在本章研究的前三个贪心算法),那么你在最后加上局部搜索不仅会使结果更好,而且你继承了为局部搜索生成起始解的启发式算法的*似正确性保证。但总的来说,如果你在寻找可证明的运行时间保证和可证明的正确性属性,这通常不会是你首先寻找的设计范式。

  3. 需要快速实现原型时:局部搜索算法的一个优点是,至少在它们最基本的版本中,它们相当简单,很容易编码实现。因此,如果你需要一个问题的快速而粗糙的启发式算法,局部搜索通常是一个好的起点。请注意,为了榨取局部搜索的所有可能性能,你通常需要进行大量实验,这实际上可能需要相当多的时间。但仅仅是为了让基本版本启动和运行,这是一个相对容易的实现项目。

  4. 作为其他算法的改进步骤:正如我们提到的,局部搜索一个几乎无需动脑的用途是改进你可能从其他启发式算法获得的解。只要你有额外的计算时间可以投入来使问题变得更好,为什么不使用局部搜索看看效果如何呢?

  5. 需要“随时”算法时:局部搜索算法的另一个不寻常的好处是,你可以在任何时候停止它们,它们是“随时”算法。很多算法不是这样的。例如,稍后当我们谈到混合整数规划求解器时,如果你在五分钟后终止,它不会给你任何有用的东西,而局部搜索算法会。

  6. 其他高级求解器不适用时:说到混合整数规划和可满足性问题的先进求解器(我们将在几节课后更详细地讨论),它们可能是实践中处理NP难问题时局部搜索最激烈的竞争对手。如果你处于这些求解器对你有效的情况下,那么很好,使用它们。如果你能以它们能处理的格式指定你的问题,并且你的实例足够小或结构足够好,它们可以解决它们到最优性,那就更好了。因此,当这些求解器对你不起作用时,局部搜索就变得真正相关。原因之一可能是你的输入太大,求解器无法处理,而局部搜索是一种更简单的算法,有潜力扩展到更大的输入规模。第二个原因可能是你的优化问题有点奇怪,例如你不能以非常简单的方式写出目标函数(这是混合整数规划求解器所期望的)。注意,局部搜索需要关于你目标函数的信息非常少,你只需要能够对给定的可行解高效地评估目标函数。如果你能做到这一点,你就可以运行局部搜索,而先进的求解器对目标函数格式的要求要严格得多。



实验是关键

这些是应用程序的一些特征,如果你看到它们,你的脑海中应该亮起一盏灯,你会说:嗯,这看起来像是局部搜索是一个好技术的经典场景。

关于这个话题我要说的最后一件事是:为了从局部搜索算法设计范式中获得最大收益,实验至关重要。正如我们所看到的,局部搜索不仅仅是一种算法,它实际上是整个算法集合,你可以加入无数的附加功能。哪些附加功能是正确的,将取决于你应用程序的细节。因此,我强烈建议你获取一些具有代表性的应用实例,编写一堆不同版本的局部搜索,看看哪一个效果最好,然后使用它。


总结

本节课中我们一起学*了局部搜索算法的更多高级主题。我们探讨了如何通过定义更大的邻域(如“3-交换”)来减少局部最优解的数量,并分析了其利弊权衡。我们详细讨论了算法的具体决策:包括使用贪心或随机策略进行初始化,以及在多个改进移动中选择“第一个可行改进”、“最速下降”或“随机选择”等规则。我们认识到局部搜索通常缺乏严格的理论保证,但在实践中往往表现良好。为了应对可能陷入低质量局部最优解的问题,我们介绍了注入随机性(如随机初始化和随机选择移动)、允许非改进移动(模拟退火思想)、使用历史依赖邻域(如禁忌搜索)以及维护解种群(如遗传算法)等高级策略。最后,我们总结了局部搜索的适用场景:当问题规模大、需要快速启发式方案、作为其他算法的后处理步骤,或者当问题形式特殊、其他高级求解器不适用时,局部搜索是一个强有力的工具。要充分发挥其潜力,必须结合具体问题进行充分的实验和调优。

018:21.1_ 旅行商问题的贝尔曼-赫尔德-卡普算法 - 第1部分

在本节课中,我们将学*如何为NP难问题设计精确算法。我们将从一个经典问题——旅行商问题开始,并应用动态规划技术来设计一个比朴素穷举搜索快得多的精确算法。

概述:精确算法与动态规划

正如我们所见,对于NP难问题,我们无法同时兼顾正确性和速度。当我们不愿在正确性上妥协时,就只能在速度上做出妥协。本节的目标是设计精确算法。对于NP难问题,我们预期算法在某些情况下至少需要指数级运行时间。作为算法设计者,我们的目标是提出一种算法,它比朴素的穷举搜索等解决方案要好得多,并且在大多数情况下尽可能高效。

我们将从21.1节开始,应用一个老朋友——动态规划,来解决旅行商问题。这将为我们提供一个虽然是指数级但确实比穷举搜索好得多的算法。

重温旅行商问题

首先,快速回顾一下旅行商问题的定义,以及使用穷举搜索解决它需要多长时间。

在TSP中,输入是一个包含n个顶点的完全图。图中的每条无向边都有一个实数值成本,记为 Ce。目标是计算一个旅行商环路,即一个恰好访问每个顶点一次的环路。它从某个顶点开始,经过n-1跳访问所有其他顶点,最后回到起点。在所有环路中,我们希望找到边成本总和最小的那一个。

不幸的是,TSP是一个NP难问题。因此,如果我们想要一个精确算法,我们预期它至少在某些情况下需要指数级运行时间。那么问题是,我们能否有一些巧妙的算法思想,至少能改进朴素的穷举搜索?

为了设定基准,让我们记住穷举搜索解决TSP需要多长时间。旅行商环路的数量是 (n-1)!。因此,如果你要枚举每个环路,计算成本并记住最好的一个,你将花费 O(n) 的时间来处理每个 (n-1)! 环路,总运行时间为 O(n * n!),这本质上是 n! 量级。

这非常糟糕。我们已经对 2n 形式的运行时间感到不满,但 n!2n 大得多。为了回答 n!2n 大多少的问题,让我介绍一个著名的*似结果,称为斯特林*似。

斯特林*似给出了阶乘函数增长的极其准确的估计。具体来说,n! 的增长大致可以认为是 (n/e)n。这里e是常数2.718...。还有一个前导项 √(2πn),但这不太重要。更重要的是注意到 (n/e)n。当n变得适度大时,将一堆 n/e 相乘,这将比将一堆2相乘得到的指数大得多。这表明随着n增大,n! 的增长速度确实远快于 2n

例如,如果在现代计算机上运行一个运行时间按 n! 缩放的算法,你大概只能处理最多15个顶点的输入。而对于运行时间为 2n 的算法,你可以处理大约40个顶点的问题规模。虽然听起来不那么令人印象深刻,但请记住这些都是NP难问题,我们必须给予它们足够的尊重。因此,2nn! 好得多。

因此,这将成为我们为TSP设定的目标:朴素的穷举搜索运行时间为 n!,而 2n 虽然仍是指数级,但会快得多。所以我们将致力于设计一个运行时间大约为 2n 量级的TSP算法。

动态规划回顾

在上一章中,我们重温了一个熟悉的老算法设计范式——贪心算法,并看到了它们在为NP难问题设计快速启发式算法中的出色应用。在本章中,我们将再次重温工具箱中的另一个工具——动态规划。

动态规划的许多杀手级应用是针对多项式时间可解问题的,正如你在本系列之前的书籍和视频中所见。实际上,你已经看到了动态规划在NP难问题上的一个应用,因为背包问题实际上是NP难的,但我们为它提供了一个动态规划算法,这很不错。现在我们将看到另一个应用于旅行商问题的例子。

在深入之前,让我快速回顾一下动态规划的工作原理。

设计动态规划算法的关键在于找出正确的子问题集合。我们希望这些子问题具有一些属性:首先,子问题的数量不应该太多,因为我们将最终解决每个子问题。其次,解决每个子问题不应该花费太长时间,至少在我们已经解决了更简单的子问题之后。最后,在解决了所有子问题之后,应该很容易读出原始问题的实际答案。

例如,你可能记得的一些算法:在动态规划背包算法中,对于前i个物品的每个可能前缀(这是动态规划表的一个维度)和每个可能的整数剩余背包容量(另一个维度),都有一个单独的子问题。或者在贝尔曼-福特单源最短路径算法中,子问题由路径中允许的跳数参数化。给定的子问题会询问从起始顶点到某个目标顶点v的最短路径长度,该路径最多包含i条边。

想出这些神奇的子问题集合需要大量练*。当然,你现在已经身处第4部分,已经有了相当多的练*,我们将在接下来的几个视频中获得更多练*。为了提醒你,通常想出子问题的方法是进行这样的思维实验:思考最优解必须是什么样子。假设有人把最优解放在银盘上交给你,你想证明它必须由更小子问题的最优解以有限的方式组合而成。然后,你可以对可能的情况进行穷举搜索。这一点将在下一张幻灯片中具体说明。关键是,一旦你拥有具有所有这些属性的子问题集合,动态规划算法就几乎可以自己写出来了。你系统地解决所有子问题,从最简单的开始,到最难的结束,然后从子问题解决方案中推断出最终解。最后一步通常是微不足道的,因为原始问题通常就是你的一个子问题。

在许多情况下,动态规划算法的运行时间分析相当简单。例如,假设你拥有的子问题数量是 F(n),其中n表示输入大小。这可能是n的线性、二次方甚至更糟的函数。假设在给定你已经解决的更简单子问题的解的情况下,解决每个子问题的时间上限是 G(n)。再假设从所有子问题的解中提取最终解需要 H(n) 时间。那么我们就得到一个明显的运行时间上限:F(n) * G(n) + H(n)

当我们将动态规划应用于像TSP这样的NP难问题时,我们必须预期函数F、G或H中至少有一个是n的指数函数。

回顾一些经典的动态规划算法,例如背包问题、序列比对或贝尔曼-福特和弗洛伊德-沃歇尔算法,你会注意到函数G和H(即解决每个子问题的时间和后处理工作)从来都不大,它们总是 O(1) 常数或 O(n) 线性。而子问题的数量 F(n) 在我们考虑的不同动态规划算法中则大不相同。因此,如果我们要将动态规划应用于TSP,我们需要预期其中一个函数是指数级的。稍微思考一下,我们预期 F(n) 是指数级的。我们将在动态规划算法中看到指数数量的子问题。

为TSP设计动态规划算法

现在让我们通过这个思维实验来确定正确的子问题集合。让我们真正推理一下最优旅行商环路必须如何由更小子问题的最优解组成。

换句话说,假设有人把一个最优旅行商环路放在银盘上交给你,它必须是什么样子?这个环路访问所有顶点。如果我们愿意,可以认为它从标记为1的顶点开始(假设顶点编号从1到n),并最终回到该顶点。

我们在这些动态规划思维实验中看到的一个非常有效的技巧是推理最优解所做的最后一个决策。在这个上下文中,环路的边可以被认为是有序的,我们可以看最后一跳,即从某个顶点J回到1的边。

现在,我们想象做的是撤销最优解的这个最终决策,看看我们得到了什么,然后尝试理解对于什么子问题,那个结果是最优的。

如果我们从最优环路中移除这条最终边(1和J之间的边),我们会得到什么?现在我们得到一条路径,它在1和J之间,并且恰好访问每个顶点一次。此外,其中没有环路,因为我们是从一个环路开始的。

1和J之间的绿色路径不仅仅是访问每个顶点的任意一条1到J的路径。如果你仔细想想,它必须是成本最小的此类路径。没有其他方法可以在恰好访问每个顶点一次的情况下以更低的成本从1到达J。因为如果有,那么我们可以通过将1和J之间的边插回到那条据称更好的路径中,从而在原实例中获得一个更好的环路。

这是个好消息。这意味着如果我们只知道顶点J的身份,那么我们就知道整个环路的样子:它将是1和J之间的边,加上一条在1和J之间、恰好访问每个顶点一次、并且在此条件下具有最小可能成本的路径。这就是最优环路必须的样子。因此,实际上只有n个不同的候选者竞争成为最优旅行商环路,每个候选对应这个倒数第二个顶点J的一种可能性。

当然,我们事先并不知道J是什么,我们不知道最优环路访问的最后一个顶点是什么。但同样,只有线性数量的可能性,所以我们可以对最后一个顶点的n种不同可能性进行穷举搜索。

用数学写下来,我们可以写下对J可能性的穷举搜索。这就是 minj=2 to n 所做的。严格来说,不是n个候选,而是n-1个候选,因为1也不能是倒数第二个顶点。然后,对于给定的J猜测,你只需查看1和J之间的边成本,加上任何从1到J、无环路、恰好访问所有顶点一次的最小成本路径的成本。

如果你更喜欢递归地思考动态规划,这里的方法是:我们尝试J的所有可能性,对于J的每种选择,我们递归地计算从1到J、访问所有顶点的最小成本路径。

这一切听起来都很好。这告诉我们如何使用n-1次递归调用来计算最优环路成本,该子程序可以计算这些访问所有顶点的1-J无环路路径。下一个问题是,我们如何做到这一点?

在这里,事情变得有点棘手。让我们通过下面的测验来思考一下。

识别子问题

我们将问同样类型的问题。假设我们现在固定了J,并假设有人把我们需要的最小成本路径(从1到J,无环路且访问每个顶点)放在银盘上交给你。

同样,我们想思考这个最优解所做的最后一个决策。这将是结束于顶点J的最后一跳。所以,有一个倒数第二个顶点,称之为K,路径以边(K, J)结束。

现在,我们想思考移除最后一条边,看看剩下的子路径,然后我们想问:对于什么子问题,那个子路径是最优解?

让我们讨论一下这个测验的解决方案。这对于理解本节算法非常重要。

首先,因为我们从这条路径P开始(它从1到J,无环路,访问每个顶点,并且最后一跳是从K到J),子路径P'当然仍然是无环路的。它当然从1到K,因为我们移除了从K到J的最后一条边,并且它访问除J之外的所有顶点。因为P访问了每个顶点,我们移除了KJ跳,所以剩余的路径P'访问了V - {J}中的每个顶点。

至关重要的是,这条子路径P'确实不访问J。因为P只在最后一个端点访问了J一次,而P'我们移除了最后一跳,所以它不访问J。

这意味着答案A和C都是正确的。同时,答案B是不正确的。虽然P'确实是从1到K、访问V - {J}中所有顶点的某种无环路路径,但它不一定是最小成本的此类路径。因为谁能保证没有另一条路径,同样无环路,同样从1开始到K结束,同样访问V - {J}中的所有顶点,并且也访问顶点J呢?

为了说明我并非凭空捏造这种可能性,请考虑以下三个顶点的反例。在这个例子中,你可以看到,如果允许使用顶点J,那么1和K之间最短路径的长度会变得更短。如果它只能使用1和K,它必须选择成本为5的单跳路径。如果它也允许使用J,那么它可以做得更好,可以采用总成本为4的两跳路径。

换句话说,P'是一条被禁止使用顶点J的路径,它可能无法与允许使用顶点J的其他路径竞争。

好消息是,P'仍然是一个合适子问题的最优解,即后两个答案中提到的子问题类型。因此,D实际上是一个正确答案。

证明这一点的方法与我们在许多其他动态规划算法中看到的反证法、剪切粘贴论证完全相同。

从一个从1到J、无环路、恰好访问每个顶点一次的最优路径开始,即左上角的浅蓝色路径。

现在,我们考虑移除最后一跳(K, J)。这给我们留下了从1到K的剩余浅蓝色路径。我们想论证这是给定类型的最小成本路径,即一条从1到K、恰好访问除J之外的所有顶点一次、并且完全不访问顶点J的路径。

假设情况并非如此,假设实际上存在一条比此前缀更便宜的路径,满足完全相同的约束条件:从1开始到K结束,无环路,恰好访问V - {J}中的顶点,并且不访问J。让我们用洋红色画出这条路径。

如果洋红色路径的成本小于浅蓝色路径的成本,那么洋红色路径加上最后一跳(K, J)的成本就小于浅蓝色路径加上最后一跳(K, J)的成本。换句话说,这条洋红色路径加上最后一跳(K, J),就构成了一条比我们开始时更好的路径。

现在非常重要的是,洋红色路径不使用顶点J,这是我们的约束之一。如果洋红色路径使用了顶点J,那么当我们放入最后一跳(K, J)时,就会形成一个环路(第二次访问J),我们就不能满足无环路条件。但是因为我们假设洋红色路径是前缀P'的一种更优版本,它从1到K,无环路,访问V - {J}中的所有顶点,并且不访问J。

这意味着当我们取洋红色路径,加上最后一跳(K, J)时,我们得到一条无环路路径。至关重要的是,现在它访问包括J在内的每个顶点,并且其成本严格小于浅蓝色路径的成本。但这是一个矛盾,因为浅蓝色路径是此类成本最小的路径。

因此,我们的假设是错误的,P'确实是从1到K、访问V - {J}中所有顶点且不访问J的最小成本无环路路径。

总结

在本节课中,我们一起学*了如何为旅行商问题设计一个基于动态规划的精确算法。我们首先回顾了TSP问题和穷举搜索的局限性,然后重温了动态规划的核心思想。通过思维实验,我们分析了最优解的结构,并识别出合适的子问题:计算从起点1到终点j、访问特定顶点集合S中所有顶点恰好一次且不访问其他顶点的最小成本无环路路径。这为我们设计运行时间约为 O(n² * 2ⁿ) 的算法奠定了基础,这远优于朴素的 n! 穷举搜索。在下一节中,我们将基于这些子问题正式构建贝尔曼-赫尔德-卡普算法。

019:贝尔曼-赫尔德-卡普算法求解TSP(第二部分)🚀

在本节课中,我们将学*如何利用动态规划思想,为旅行商问题设计一个比暴力穷举更高效的精确算法。我们将深入理解贝尔曼-赫尔德-卡普算法的核心思想、子问题定义、递推关系以及算法实现细节。

概述

上一节我们通过一个思维实验,分析了最优TSP路径的结构。本节我们将基于这个结构,正式定义子问题,建立递推关系,并最终构建出完整的动态规划算法。

最优路径的结构与子问题定义

从上一节的测验中,我们得知了最优解的结构。如果你告诉我有一条从顶点1到顶点j、恰好访问每个顶点一次的最小成本路径,那么我知道这条路径只有n-2种可能的样子。一旦你告诉我倒数第二个顶点k,即路径的最后一段是(k, j),我就知道路径的前缀部分必须是:从1出发,到k结束,并且恰好访问顶点集V - {j}中所有顶点一次的最小成本路径。这正是子问题P'所最优求解的。

既然我们理解了从1到j的路径只有这n-2种可能性,我们就可以写出一个递推式,用这n-2个更小的最优解的成本来表达原最优解的成本。

为了简洁地描述递推式,我们引入一些符号。用C(S, j)表示满足以下四个属性的路径的最小成本:

  1. 路径从顶点1开始。
  2. 路径在顶点j结束。
  3. 路径是无环的,即不重复访问任何顶点。
  4. 路径恰好访问集合S中的每个顶点一次。

例如,幻灯片右上角的图示中,外部的洋红色圆圈代表所有顶点,顶部集合是S,底部是其他顶点V - S。浅蓝色路径表示它恰好访问S中的每个顶点一次。我们的符号C(S, j)就是计算任何类似路径的最小成本。

从测验中我们学到,一旦知道了k,我们就知道路径的其余部分:路径前缀必须是从1到k、无环且恰好访问顶点集S - {j}中所有顶点的最小成本路径。当然,原始路径最终到达j,还需要支付从k到j的那条边的成本。

因此,递推关系可以写作:
C(V, j) = min_{k ∈ V, k ≠ 1, j} [ C(V - {j}, k) + cost(k, j) ]

如果你更喜欢递归地思考动态规划,这基本上是说:为了解决原始问题,你需要进行n-2次递归调用,计算这些不同子问题的最优解,然后从这n-2个递归调用返回的解中选出最好的一个。当然,递归会继续下去,你需要解决右侧这些更小的子问题。如何解决呢?只需对更小的顶点集再次应用完全相同的递推式。

更一般地,如果我们在这个方程中用任意顶点子集S替换V,我们会得到完全相同的递推关系:
C(S, j) = min_{k ∈ S, k ≠ 1, j} [ C(S - {j}, k) + cost(k, j) ]

用文字描述:如果你给我看一条最优路径,它恰好访问顶点集S,从1出发,到达j,并且是无环的。如果你告诉我这条路径的倒数第二个顶点,我就知道S必须是什么样子:它将是一个子路径,现在它访问S中除最后一个端点j外的所有顶点,并且以无环的方式恰好访问其他顶点S - {j}一次,同时从1走到k。这就是适用于任何顶点子集S的递推式的一般版本。

这现在确切地告诉了我们子问题应该是什么。基本上,我们需要为每一个可能出现在这些递推式中的项准备一个子问题。因此,对于顶点子集S的每一种选择,以及对于最后一个顶点j的每一种选择,我们都需要一个单独的子问题来计算C(S, j)

子问题的范围与数量

那么,我们需要考虑哪些项呢?哪些S和j的选择实际上有意义?记住,S是路径应该访问的顶点集合,而路径应该从顶点1开始,所以集合S最好包含顶点1。同样记住,j是路径的终点,所以S最好也包含端点j。因此,对于每个j的选择,以及每个同时包含顶点1和该端点j的S的选择,你都会有一个这样的项。

坏消息是,这有很多子问题,是指数级的。因为有n个顶点,所以有2^n个不同的顶点子集。这里的S不能是任意顶点子集,有一些温和的约束,但你仍然需要担心指数级数量的不同S,再加上还有另一个线性于n的j的选择数量。

这很糟糕,因为存在指数级的子问题。但请记住,我们预料到了这一点。如果TSP是NP难的,那么如果我们打算应用动态规划,就需要预期在某个地方出现指数级的东西,要么是子问题的数量,要么是解决每个子问题所需的时间,要么是后处理步骤。回顾我们见过的许多例子,额外的复杂性似乎总是出现在子问题的数量上。所以我们实际上预料到会看到指数级的数量,这告诉我们可能走对了路。

我还想指出,虽然是指数级,但它比n!要好得多。它更像是2n,而不是n!。节省的来源在于,这些子问题不关心访问顶点集S的顺序。它追踪路径访问了哪些顶点子集,但不追踪访问它们的顺序。这就是为什么阶乘消失了,被简单的指数函数2N所取代。

算法框架与伪代码

我们几乎把所有事情都理顺了。我们通过思考最优解必须是什么样子的思维实验得出了子问题,这引导我们得到了递推式。现在我们只需要系统地从小到大解决所有这些子问题。根据路径访问的顶点数量(即集合S的大小),有一个非常自然的从小到大的顺序。记住,动态规划还有一个最终要素:你需要能够从子问题解中提取最终解。最常见的情况是,原始问题本身就是其中一个子问题。但这里不是,我们想要一个环游,而这里所有的子问题都在计算路径。不过,我们可以对环游访问的最后一个顶点的n-1种选择进行穷举搜索,然后直接代入我们最大的子问题解的值。

所有要素就位后,动态规划算法就水到渠成了。我们首先解决基本情况或最小的子问题,这对应于只有两个顶点(顶点1和某个其他顶点)的顶点子集S。然后我们将继续使用递推式解决下一个更大的子问题,即访问三个顶点的路径,然后是大小为4的子集,接着是5,等等。一旦我们处理完所有的子问题,我们将使用最后的方程来计算最终解。这个算法有时被称为贝尔曼-赫尔德-卡普算法,由贝尔曼以及赫尔德和卡普在1962年独立提出。

以下是算法的伪代码实现:

我们首先初始化一个数组,用于记录所有子问题的解。子问题由两个不同的参数参数化:S和j。所以它将是一个二维数组。S的选择大约有指数级数量(精确地说是2^(n-1) - 1),而j最多有n-1种选择(除顶点1外的所有顶点)。

基本情况对应于大小为2的顶点子集。它必须包含顶点1,然后会有某个其他顶点j,而j也是端点的唯一选项。那么从1到j且只访问1和j的最短路径必须是直接的单跳路径。其成本就是对应边的成本。

现在我们系统地解决所有子问题,从较小的子问题到较大的子问题。这里问题规模的自然概念是路径应该访问的顶点数量,即集合S的基数。所以我们从大小为3的子集开始,然后处理大小为4的,等等,直到S等于所有n个顶点。

现在我们有两个嵌套的for循环,循环遍历参数S和j的选择。由于j应该从集合S中选取,首先开始循环遍历当前大小的所有子集(大小为s)是合理的。

然后,对于给定的子集S,我们知道所有j的选择:它是S中除顶点1外的每个顶点。

现在,在这个内部循环的迭代中,实际上对应一个特定的子问题:计算C(S, j)。我们知道如何计算:只需使用递推式。它实际上就是对最优路径访问S中顶点时倒数第二个顶点k的所有选择进行穷举搜索。

一旦这三个for循环完成,我们就解决了所有的子问题。然后我们知道,最终解可以直接从最大的子问题(S等于V)中计算出来。

当你为动态规划算法编写伪代码时,总是要做的一个完整性检查是:当你计算子问题解数组中的一个条目时,你要确保在左手边,你在右手边需要的数组中的所有条目都已经计算好,因此可以用于常数时间查找。我们在这里看到情况确实如此。

在递推式的右手边,我们总是在查找比S少一个顶点的集合的值,即更小的子集。所有这些都将在最外层for循环的前一次迭代中计算好。

算法分析与总结

以上就是用于旅行商问题的贝尔曼-赫尔德-卡普动态规划算法。和往常一样,当我们介绍一个算法时,我们应该考虑它在正确性和运行时间方面的属性。正确性不是那么有趣,它只是动态规划算法的标准论证,你已经见过很多次了:通过对子问题规模进行归纳来论证,在归纳步骤中每个子问题都得到正确解决。为什么这是正确的?正确性来自于递推式的正确性,并且我们正确地填写了子问题的答案。为什么递推式是正确的?这可以追溯到我们一开始的最优子结构:我们观察到给定子问题的最优解只能有少数几种可能性,而递推式明确地对那少数几种可能性进行了穷举搜索,因此它必然计算出最优解。

对于运行时间,我们可以回到对动态规划算法的通用分析,我们只需计算子问题的数量,乘以每个子问题的时间,再加上后处理的工作量。

首先,有多少个子问题?这就是我们之前所说的f(n)。嗯,对于集合S有大约2n种选择(比这个少一点,但大致是2n),而对于第二个参数j,最多有n-1种选择。这意味着我们最多需要处理大约2^n * n个不同的子问题。

其次,解决每个子问题需要多少时间?这只是对倒数第二个顶点k的所有可能选择进行穷举搜索。在任何时候,k最多有n种可能的选择。所以填写每个数组条目将需要线性量的工作。

最后是后处理步骤,我们之前称之为h(n)。这里不是常数时间查找,而是伪代码的最后一行,我们对n-1种可能性进行穷举搜索。每种情况都可以在常数时间内评估,所以后处理(即最后一行)也将是O(n)。

在这个后处理的分析中,我假设你满足于只计算最优旅行商环游的总成本,而不是环游本身。但和动态规划中一样,正如你希望已经多次看到的,通过回溯填好的子问题数组,总是可以重建最优解本身。如果你以正确的方式实现这个算法,在正向传递中缓存适当的东西,你实际上可以在线性时间O(n)内重建最优环游本身。我鼓励你在私下里思考一下。

记住,动态规划算法运行时间界限的公式就是f * g + h,在这种情况下计算为n^2 * 2^n。

在这个运行时间分析中有一个细节需要注意:我假设你可以生成具有给定大小s的S子集的数量,其时间与这类子集的数量成正比。如果你仔细想想,这类子集的数量正好是C(n-1, s-1),因为你知道顶点1必须在里面。这可以实现,我鼓励你思考在具体实现中如何做到。你可以使用递归枚举,或者如果你真的想深入研究,可以查找一种叫做Gosper's hack的方法。

我们应该如何看待这个运行时间呢?嗯,心情复杂。一方面,它比穷举搜索好得多。能够显著超越穷举搜索(原本是n!)是非常令人满意的。根据斯特林*似,n!大约是(n/e)n。这比2n指数级地大,而这里我们只得到2n乘以一个多项式(n2)。所以一方面,看到你已经花费大量时间掌握的动态规划范式的又一个杀手级应用,能够击败一个超级基础问题的穷举搜索,这是非常令人满意的。

坏消息是,这个运行时间仍然不是那么好。穷举搜索算法也许最多能处理规模为15左右的问题(如果你幸运的话)。如果我们有一个运行时间为2n的算法,你可以处理到大约40。这是n2 * 2^n,你将能够处理更多像n=30这样的输入。所以与穷举搜索相比,我们能够处理的问题规模大约翻了一倍,这很好。但是,如果你有一个比这更大的旅行商问题,比如有数百或数千个顶点,你将无法使用这个动态规划算法。在那里,你将不得不求助于上一章讨论的启发式算法,或者你可以尝试使用最先进的混合整数规划求解器(我们将在本章后面讨论)。所以接下来我们将继续讨论动态规划在另一个问题上的应用:在网络中寻找长路径。它将再次允许我们大致将能够处理的问题规模翻倍,但实际上在生物学应用中,能够处理的问题规模翻倍对于获得有意义的结果至关重要。我们将在下一个视频中讨论这个问题。

020:颜色编码(第一部分)

在本节课中,我们将学*一种名为“颜色编码”的技术,用于在图中寻找一条长路径。这是一种结合了动态规划和随机化的巧妙方法,尤其适用于在生物网络中检测有意义的结构。

概述:从生物网络到图论问题

图在算法研究中无处不在,因为它很好地平衡了表达能力和可处理性。我们可以高效地对图进行搜索、计算连通分量、寻找最短路径等操作。图的应用领域也非常广泛,从道路网络到万维网,再到社交网络。

在本节中,我们将看到另一个应用实例:将动态规划与随机化相结合,用于检测生物网络中的有意义结构。让我们开始吧。

问题背景:蛋白质相互作用网络

在深入问题定义之前,我们先了解一下其背后的生物学动机。

细胞中的大部分工作由蛋白质(即氨基酸链)完成,而这些蛋白质通常协同作用。例如,一系列蛋白质可能将细胞膜产生的信号传递给调控DNA转录为RNA的蛋白质。理解这些信号通路以及它们如何因基因突变而改变,是开发新药对抗疾病的重要步骤。

蛋白质之间的相互作用很自然地可以建模为一个图,称为蛋白质-蛋白质相互作用网络(PPI网络)。这个图的顶点对应蛋白质,任何一对被认为会相互作用的蛋白质之间都有一条边。

最简单(也可能是你首先想寻找的)通路类型是线性通路,它对应于PPI网络中的一条路径。

问题定义:最小成本K路径问题

在PPI网络中寻找线性通路的问题,可以转化为最小成本K路径问题。这里的K路径指的是图中一条简单的(即无环的)路径,它包含 K-1 条边,因此访问 K 个不同的顶点。

形式化地,最小成本K路径问题的输入包含以下熟悉的成分:

  • 一个无向图 G
  • 图中每条边 e 都有一个实数值的边成本 c_e
  • 一个目标路径长度,即一个正整数 K

输出则是一条K路径。在所有图中可能的K路径中,我们希望找到总成本(即路径中 K-1 条边的成本之和)最小的那条。如果输入图 G 中根本不存在任何K路径,算法需要正确地报告这一事实。

回到生物学动机,边成本反映了嘈杂生物数据中不可避免的不确定性。更高的边成本意味着对相应蛋白质对确实相互作用的置信度更低。缺失的边实际上具有正无穷的成本。在PPI网络中,最小成本K路径对应于给定长度的最可信的线性通路。

在实际例子中,路径长度 K 可能在10到20之间,而图的顶点数 n 可能达到数千甚至数万。

问题的NP难性质

在本书的这个阶段,你不会惊讶地听到这是一个NP难问题。实际上,这或多或少是旅行商问题(TSP)的推广。既然TSP是NP难的,那么这个更一般的问题肯定也是NP难的。

初步尝试:动态规划

我们如何解决最小成本K路径问题呢?我们可以通过类比来推理。我们刚刚在之前的视频中学*了旅行商问题,这两个问题感觉非常相似。主要区别在于,最小成本K路径问题中你有一个目标路径长度 K,而在TSP中你寻找的是一个长度为 n-1 再加一条边的回路。

这种强烈的相似性表明,也许我们应该用同样的方法来解决这个问题,即使用动态规划。甚至在动态规划中,我们为什么不直接使用在TSP中效果很好的那种子问题呢?

换句话说,我们将再次使用一个由两个参数索引的子问题族:

  • 一个参数是路径的终点 v
  • 另一个参数是顶点子集 S,即这条路径访问过的顶点集合。

那么,对应于选择 Sv 的子问题的定义就是:计算任何v 结束、且恰好访问了集合 S 中所有顶点的路径的最小可能成本。与TSP的一个小区别是,这里的K路径可以从任何地方开始,不必像顶点1那样从一个指定顶点开始。

我们为每个合理的 S 选择(由于我们讨论的是K路径,我们只需要关心大小不超过 K 的集合 S)以及从集合 S 中选出的每个顶点 v 设置一个这样的子问题。

如果我们成功解决了所有这些子问题,那么我们就完成了。因为所有对应于大小为 K 的集合 S 的最大子问题中,最好的(成本最低的)那个子问题的解就是答案,即图中K路径的最小成本。

子问题数量分析

现在,我们来分析一下这些候选子问题的数量。

以下是关于子问题数量的几个选项:

  • O(n^K)
  • O(K * n^K)
  • O(K * 2^n)
  • O(2^n)

正确答案是第二个:O(K * n^K)

这个界限是两个参数的乘积,分别对应索引子问题的两个参数:

  • K 来自于所有不同的 v 的选择。对于一个给定的 S,最多有 Kv 的选择,这就是 K 的由来。
  • n^K 来自于合理的 S 选择的数量,即大小不超过 K 的子集数量。这包括空集(C(n,0))、单顶点集合(C(n,1))、顶点对(C(n,2)),一直到恰好包含 K 个顶点的子集(C(n,K))。

这个由 K+1 个二项式系数组成的和是 O(n^K)。当 K 很小时(这正是我们感兴趣的情况),子集数量确实是 n^K 的常数倍。

与穷举搜索的比较

我们应该如何看待这个界限?看起来这很像旅行商问题中的情况:当时我们有 n * 2^n 个子问题,n 对应终点的选择,2^n 对应子集的选择;这里我们有类似的形式,K 对应终点的不同选择,n^K 对应大小不超过 K 的子集的不同选择。这看起来很不错。

但我们需要将其与穷举搜索进行比较。

以下是关于穷举搜索运行时间的几个选项:

  • O(K * n^K)
  • O(K * 2^n)
  • O(n^K)
  • O(2^n)

这个测验的答案是第一个:O(K * n^K),这是最直接版本的穷举搜索的运行时间。最简单的穷举搜索并不显式地枚举路径,而是枚举 K 个顶点的有序元组(例如顶点17,接着顶点4,接着顶点23等)。一旦你有了 K 个顶点的列表,你可以在线性时间内检查它是否是图中的一条路径。如果是,你记录下它的成本,然后记住在所有最终对应K路径的元组中看到的最小成本。有 n^K 种选择来构成这些 K 元组,检查每个元组需要 O(K) 的工作量。

这个测验带来了麻烦。它表明我们完全不应该对第一个测验中得到的子问题数量界限感到高兴,因为子问题数量的界限是 O(K * n^K),与穷举搜索的运行时间完全相同。与TSP不同(在TSP中,那些子问题让我们相对于穷举搜索从 n! 加速到更接* 2^n),在这里我们根本没有得到加速。无论是穷举搜索还是动态规划,运行时间都将是 n^K 乘以某个多项式因子。对于我们所讨论的图类型(比如至少有1000个顶点),这完全是一个无用的算法,当 K=5 时就已经如此了。这简直是一场灾难,我们没有超越穷举搜索,我们需要新的思路。

引入颜色编码技术

为什么我们使用了这么多子问题?这是因为通过参数 S,我们跟踪了路径迄今为止访问过的确切顶点集合。由于最多有 K 个顶点,这意味着大约有 n^K 种可能的访问历史。我们从TSP的解决方案中继承了这个想法,即跟踪路径迄今为止访问过的确切子集。

我们在TSP中为什么要这样做?因为当我们有一个较小子问题的解(一条路径),并想通过在其末尾添加一条边来将其扩展为一个较大子问题的最优解时,我们需要确保这条边不会访问路径之前访问过的顶点,否则就会形成一个环。因此,跟踪路径迄今为止访问过的确切顶点是为了确保我们永远不会重复访问一个顶点。

这对于最小成本K路径问题听起来也很重要,因为我们仍然要求路径是无环的。但你可能会想,我们能否跟踪比路径迄今为止访问过的整个顶点子集更少的信息呢?

答案是肯定的,我们可以使用一种称为颜色编码的巧妙想法。

颜色编码分为两个步骤。第一步是顶点划分步骤。我们有一个顶点集 V 和目标路径长度 K。在第一步中,我们将顶点集分成 K 个不同的组。

这项技术之所以被称为颜色编码,是因为我们可以将这种分组视为为每个顶点分配一种颜色。例如,V1 是红色顶点,V2 是绿色顶点,V3 是蓝色顶点,V4 是黄色顶点。

我稍后会告诉你我们如何进行这种顶点划分。我们需要的主要性质是:某个最优解(即某条最小成本K路径)应该具有这样的属性:在这种着色(划分)下,它是全色的。也就是说,它的 K 个顶点中的每一个都应该有不同的颜色,或者换句话说,这条最优K路径的 K 个顶点应该属于不同的组。

例如,我们可能有一条全色路径,从 V2 开始,前进到 V3,然后到 V1 中的一个顶点,最后以 V4 中的一个顶点结束。

颜色编码的第二步解决了最小成本K路径问题,但有一个转折:你只想寻找全色路径。也就是说,在给定顶点划分(着色)的图中,在所有全色K路径中,你的责任是计算成本最小的那条。

请注意,全色路径自动就是K路径(自动包含 K 个顶点),但反之则不成立。存在不是全色的K路径。例如,一条从 V4 开始并结束于 V4,完全跳过 V2 的路径。

如果我们能成功执行这两个步骤,我们肯定就解决了我们关心的问题——原始的最小成本K路径问题。因为毕竟第二步计算的是最小成本全色路径,而第一步确保了最小成本全色路径实际上是原始图 G 中的最小成本K路径。

解决全色路径问题

现在让我们讨论并解决最小成本全色路径问题。

与之前类似,我们给定一个无向图,边具有实数值成本,但现在我们还额外给定一个将顶点集划分为 K 个组 V1VK 的划分(或者,可以将其视为用 K 种颜色对顶点进行着色)。

我们的责任是,在图中所有全色路径(即恰好包含每个组中一个顶点的路径)中,计算成本最小的那条。如果图中没有全色路径,我们应该正确地报告这一事实。

为什么这个全色约束有帮助?它为什么能让我们设计出更快的算法?

请记住,在原始的最小成本K路径问题中,我们之所以有 n^K 个子问题,是因为我们跟踪了路径迄今为止访问过的确切顶点。在全色情况下,你获得的最大节省在于:你不需要记住迄今为止看到的顶点的身份,只需要记住迄今为止看到的顶点的颜色。正如我们将看到的,子问题将不是由顶点子集索引,而是由颜色子集索引。虽然大小不超过 K 的顶点子集大约有 n^K 个,但颜色子集只有 2^K 个。2^Kn^K 要好得多。

继续到子问题的形式化定义,我需要一个快速的术语。一个 S-路径(这里 S 表示颜色的子集,即 {1,2,...,K} 的子集)指的是一条访问的顶点颜色恰好都在集合 S 中(S 中的每种颜色恰好一个顶点),并且不访问任何颜色不在 S 中的顶点的路径。

例如,如果 S 对应红色、黄色和蓝色,那么一条S-路径就是一条恰好访问一个黄色顶点、一个红色顶点和一个蓝色顶点的路径。

请注意,K路径正好对应于 S 为所有颜色集合(即 {1,...,K})时的S-路径。

与我们的第一次尝试和TSP类似,子问题将由两个参数索引。第二个参数是路径访问的最后一个顶点 v。但正如我们所说,我们不会跟踪路径访问了哪些确切顶点,只会跟踪它访问了哪些颜色。因此,对于每个颜色子集 S 的选择和每个终点 v 的选择,都有一个子问题。子问题的任务是计算任何是S-路径(即访问的顶点颜色恰好都在 S 中)并且以顶点 v 结束的路径的最小成本。

动态规划递推关系

这些子问题的最优解如何由更小子问题的最优解构成?这里的情况将与TSP中完全一样。

考虑一个子问题的最优解。对于某个 v 和某个颜色集 S,看看以顶点 v 结束的最小成本S-路径,称其为路径 P。像往常一样,我们考虑这个最优解所做的最后决策,即最后一步跳跃,比如从某个顶点 w 到顶点 v。我们所期望的是,一旦你知道了最后一步跳跃,一旦你知道了倒数第二个顶点 w,那么路径的前缀应该对于适当的更小子问题是最优的。事实上,由于我们在TSP中看到的完全相同的原因,情况确实如此。

这个子问题是什么?显然,这个路径前缀 P' 现在以顶点 w 结束,所以这将是那个参数的值。此外,我们知道它访问的颜色应该是原始路径 P 访问的颜色减去 v 的颜色。在这个图示中,我展示了 v 是黄色的,所以这个前缀路径 P' 对于颜色子集 S - {黄色} 和终点 w 将是最优的。

如果你想正式证明这一点,可以像过去多次那样进行反证:假设 P' 实际上不是其子问题的最优解,假设存在一条更好的路径 P'',成本更低。那么你可以取 P'',在其末尾加上最后一步跳跃 (w, v),得到一条成本比 P 更小的新路径,因此将是比 P 本身更好的 P 子问题的解。但这不可能发生,因为我们是从最优解 P 开始的。

这意味着,一旦你知道最优解的最后一步跳跃(倒数第二个顶点 w),你知道路径的其余部分必须是什么样子,那么只有非常有限数量的候选者竞争成为子问题的最优解。因此,我们的递推关系将只是对倒数第二个顶点 w 的可能性进行穷举搜索。对于连接到顶点 v 的每条边,都会有一个可能的 w 选择。

这个递推关系看起来几乎与我们在旅行商问题中得到的递推关系完全相同,这并不奇怪,因为我们是通过完全相同的推理得出的。唯一的区别是,这里颜色子集替代了原来的顶点子集。

算法伪代码

像往常一样,有了动态规划,一旦你找到了正确的子问题和连接它们解的递推关系,你就完成了,算法几乎可以自己写出来。

我们将这个算法称为 PanchromaticPath。这个算法除了图和边成本外,还给定一个着色(或顶点划分)。我将使用符号 σ(v) 表示分配给顶点 v 的颜色,这也是输入的一部分。

像往常一样,我们从子问题数组 A 开始。它是二维的,反映了索引我们子问题的两个参数。第一个参数是颜色子集,我们需要处理任何非空的颜色子集,所以有 2^K - 1S 的选择。然后有 n 种终点 v 的选择(这里 n 是顶点数)。

接下来我们处理基本情况。子问题的大小对应于 S 中颜色的数量。基本情况是最小的子问题,即 S 的大小为1,只包含一种颜色。因此,对于每种可能的颜色 i 和每个可能的终点 v,我们都有一个子问题。

例如,这个子问题要求的是:一条恰好访问一个红色顶点(没有其他顶点)并以顶点17结束的路径的最小成本长度。这里有两种情况:要么顶点17恰好是红色的,那么空路径符合要求,成本为零;要么顶点17不是红色的(比如是绿色的),那么就不存在这样的路径,子问题的解是正无穷。

现在,我们系统地解决所有子问题,从小到大。我们将有一个外层的 for 循环来跟踪子问题的大小 s(即当前正在查看的集合 S 中的颜色数量)。然后我们有另一个 for 循环,枚举具有目标大小 s 的颜色子集。接着我们还有另一个 for 循环,搜索第二个参数,即所有可能的终点 v 的选择。

一旦你指定了所有这些,我们就知道我们在讨论哪个子问题(S, v),然后我们只需调用递推关系来计算解。

最后一步是从我们最大子问题的解中提取最终解。你会注意到有 n 个不同的最大子问题,每个 v 的选择对应一个。那个子问题的解对应于恰好以顶点 v 结束的最小成本全色路径。在问题陈述中,我们并不关心路径在哪里结束,所以我们想穷举搜索 nv 的选择,并返回这些子问题解中最好的那个。

算法属性分析

这就是全色路径问题的伪代码。该算法计算任何全色路径(一条恰好有 K 个顶点,每种颜色恰好出现一次的路径)的最小成本。

让我们讨论一下算法的属性。

首先是正确性。像动态规划通常那样,正确性通过归纳法得出,基于子问题的大小。对于归纳步骤,你必须论证,假设你已经正确解决了所有更小的子问题,那么你就能正确解决一个子问题。这实际上就是证明递推关系的合理性,而证明递推关系合理性是通过我们的最优子结构推理来完成的。我们展示了子问题的最优解只能是有限数量候选者中的一个,我们的递推关系穷举搜索了所有这些候选者,其中最好的那个必定是最优解。因此,递推关系的正确性推动了归纳步骤,进而推动了 PanchromaticPath 算法的正确性。

像动态规划通常那样,我刚刚向你展示了基本版本,它只完成了通过子问题解数组的前向传递。如果你只关心知道最小成本全色路径的值,这个算法就可以了,但它不会给出路径本身。不过像往常一样,添加一个后处理重建步骤很简单,该步骤通过数组回溯并给出最小成本全色路径,这将是线性时间,甚至在线性于路径长度 K 的时间内完成。

运行时间分析更有趣一些,希望能让你愉快地回想起我们对贝尔曼-福特算法的运行时间分析。

解决单个子问题需要多少时间?比如对应于颜色子集 S 和终点 v 的子问题。递推关系必须对最优解的所有可能的最后一步跳跃进行穷举搜索,因此对于连接到 v 的每条边 (w, v),都有一个候选者。换句话说,递推关系必须搜索的不同情况的数量(每个情况使用常数时间)是顶点 v 的度数(即关联边的数量)。这就是三重 for 循环中单次迭代的运行时间。

现在让我们退一步,固定前两个 for 循环中的参数(子问题大小和该大小的特定集合 S),问一下在第三个 for 循环中解决那 n 个子问题总共花了多少时间。每个子问题的解决时间与该顶点的度数成正比,因此解决所有这些 n 个子问题将与所有顶点的度数之和成正比。

你可能知道无向图中所有顶点度数之和的另一个名称:2m,即边数 m 的两倍。因为在无向图中,每条边恰好为其两个端点的度数各贡献1,所以所有边上的贡献总和正好等于 2m

这意味着,对应于特定颜色子集 S 的所有子问题的运行时间是线性时间,即 O(m),其中 m 是图中的边数。为了精确,你可能需要写成 O(m + n),以防图是不连通的,但我们先忽略这一点。我们就说解决对应于特定颜色子集 S 的所有子问题需要 O(m) 时间。

那么总运行时间就只是 S 的选择数量乘以 O(m)。当然,颜色子集 S2^K 种选择,这给了我们最终的运行时间:2^K * m

运行时间评估

我们应该如何看待这个运行时间?可能心情复杂。一方面,看到运行时间中有一个指数因子 2^KK 是颜色数)是令人遗憾的。但另一方面,我们正在处理NP难问题的精确算法,所以我们必须预期某个地方会出现某种指数。再仔细想想,实际上我们大大超越了穷举搜索。记住,对于这些K路径问题,你必须枚举所有大小为 K 的顶点有序元组,大约有 n^K 个。因此,我们的运行时间不是按 n^K 缩放,而是按 2^K 缩放。对于我们感兴趣的那种参数选择(K 可能在10到20,n 可能在数百或数千),这是一个巨大的、巨大的差异,相对于穷举搜索是巨大的节省。

但最后一点坏消息是:这实际上并不是我们最初要解决的问题。我们最初想解决的是最小成本K路径问题。而我们展示的是,有了这个奇怪的额外约束(全色约束),加上这个转折,我们可以比穷举搜索快得多地解决问题。但是这个子程序如何用于我们真正关心的问题(没有全色约束的最小成本K路径)呢?

这就需要随机化登场了,我们将在下一部分介绍。

总结

本节课中,我们一起学*了颜色编码技术的第一部分。我们首先了解了最小成本K路径问题的生物学背景和定义,认识到它是一个NP难问题。我们尝试使用动态规划直接解决,但发现子问题数量与穷举搜索相当,没有优势。接着,我们引入了颜色编码的核心思想:通过将顶点划分为 K 个颜色组,并将问题转化为寻找最小成本全色路径。我们详细设计了解决全色路径问题的动态规划算法,其运行时间为 O(2^K * m),相对于 O(n^K) 的穷举搜索是一个巨大的改进。然而,这依赖于一个关键前提:我们需要一种方法对顶点进行着色,使得某条最优K路径恰好是全色的。如何实现这一步,我们将在下一部分探讨。

021:颜色编码(第二部分)

在本节课中,我们将要学*颜色编码算法的第一步:如何对图的顶点进行随机着色,以及如何通过多次独立试验来提高找到最优路径的概率。

上一节我们介绍了如何通过动态规划在给定着色方案下找到最小成本的全色路径。本节中我们来看看如何生成一个有效的着色方案。

随机着色与成功概率

颜色编码方法的第一步,是使用K种颜色对图的顶点进行着色,其中K是我们寻找的目标路径长度。目标是让图中某个最小成本的K路径变成全色的,即对于某个最优路径,其每个顶点都获得一种不同的颜色。

核心问题在于,当我们完全不知道最小成本的K路径是什么样子时,我们该如何做到这一点?毕竟,这正是我们首先要寻找的目标。

因此,我们需要从工具箱中拿出另一个工具:随机化。我们希望一个均匀随机着色——即每个顶点独立地、等概率地被赋予K种颜色之一——能有不错的机会将一条最优K路径变成全色的。如果情况如此,并且我们足够幸运,那么我们就可以使用我们刚刚设计的动态规划子程序来相对高效地恢复那条路径。

在接下来的测验中,让我们思考一下,在均匀随机着色下,一条给定的K路径变成全色的概率是多少。


正确答案是第四个选项,答案D:K! / K^K

让我们从可能发生的情况总数开始分析。我们有一条K路径P,它有K个不同的顶点。它的每个顶点都将从1到K中随机均匀地分配一种颜色。这意味着路径P的第一个顶点有K种可能的结果,第二个顶点也有K种可能,依此类推,直到第K个顶点。因此,这条路径P的K个顶点总共有 K^K 种可能的着色方案,并且根据定义,每种方案出现的概率恰好是 1 / K^K

第二个问题是关于分子:在这K^K种可能性中,有多少种会使路径P变成全色的?答案是 K!。原因如下:想象我们首先选择哪个顶点获得颜色1(比如红色)。有K种选择来决定哪个顶点变成红色。接下来,我们想确定哪个顶点是绿色的。它必须是剩下的K-1个未着色顶点之一。所以绿色顶点的选择数是K-1。然后,对于黄色顶点,我们有K-2种选择,依此类推,直到最后一种颜色只剩下1种选择。因此,总共有 K! 种着色方案能使路径P变成全色的。

概率分析与斯特林*似

我们应该如何解释这个答案?K! 和 K^K 都随着K的增长而快速增长,它们的比值看起来如何?

让我们用 p 表示这个比值。我们如何感受它的大小?在分子中,我们有K!。记得在几个视频之前,我展示了一个非常好的阶乘函数*似:斯特林*似。当时在讨论旅行商问题(TSP)的背景下,我只是想说明2^n时间算法比n!时间算法快多少。但在这里,斯特林*似将扮演更直接的角色。让我提醒你它说的是什么。


斯特林*似指出,n! 可以很好地*似为 (n/e)^n * √(2πn),其中e是2.718...。之前我们满足于注意到对于即使不大的n值,(n/e)n也比2n大得多。但在这里,让我们实际代入这个阶乘*似公式来简化我们的比值p。

我们将对分子应用斯特林*似,其中n由K扮演。注意到两个K^K项会相互抵消,我们可以将这个表达式简化如下:


这看起来相当糟糕。我们的单次试验成功概率p——即使用均匀随机着色将给定K路径变成全色路径的概率——随着K的增加呈指数级快速下降。你可以看到分母中有e^K。事实上,即使只代入K=7,这个概率也已经小于1%,这有点令人沮丧。

多次试验与成功保证

然而,谁说过我们只能进行一次均匀随机着色呢?随机算法可以做不同的事情,我们运行的次数越多,效果越好。因此,我们可以进行大量独立的随机试验,不断尝试不同的着色方案,不断调用我们的动态规划子程序来计算最小成本的全色路径。在所有试验中,我们只需记住看到过的所有全色路径中最好的那条。

我们只需要幸运一次。只要我们的随机着色中有一次成功地将一条最优K路径变成全色的,我们的动态规划子程序就保证能找到它。


所以问题不在于单次实验成功的概率是多少,而在于我们需要进行多少次试验,才能以至少(例如)99%的概率保证至少有一次试验成功。

这里有一个非常清晰的答案。让我们逐步构建它。从一次试验开始。一次试验成功的概率是p(很小),失败的概率是1-p(很大)。我们不会只进行一次试验,我们将进行T次独立的随机试验。T是一个我们可以选择的参数。我们想知道需要将T设置多大才能达到我们的目标。

如果一次试验失败的概率是1-p,第二次试验失败的概率也是1-p,依此类推。所有这些试验都是独立的,因此概率相乘。这意味着所有T次试验都失败的概率是 (1-p)^T。如果这种情况没有发生,即并非所有T次试验都失败,那么至少有一次成功了,而这正是我们关心的。因此,至少一次试验成功的概率将是 1 - (1-p)^T

这个表达式 1 - (1-p)^T 可能看起来有点乱。为了简化,让我们回忆一下几个视频前实际出现过的内容:线性函数 1-x 和指数函数 e^{-x} 之间的密切关系。我们之前在讨论最大覆盖和影响力最大化的神奇量 1 - (1 - 1/k)^k 为何收敛到63.2%时提到过这一点。当时我们利用了当x接*0时,1-x和e^{-x}非常接*这一事实。这里我们将使用 e^{-x} 总是大于等于 1-x 这一事实。1-x是一个线性函数,而e^{-x}是一条在零点与其相切的曲线。

因此,如果我们特别代入x = p,那么从这个图中我们发现 1-p ≤ e^{-p}。现在这就容易处理多了。我们有 (e{-p})T,这简化为 e^{-pT}。这意味着,我们至少一次试验成功的概率至少是 1 - e^{-pT}

这里真正重要的是,所有试验都失败的概率随着T的增加而迅速减小,当我们进行越来越多的独立随机试验时,这个概率呈指数级下降。

回到我们最初的问题:我们需要将T设置多大?需要多少次试验才能以至少99%的概率成功?这意味着失败概率最多为1%。我们要做的是,将我们得到的这个失败概率上界 e^{-pT} 设置为一个参数δ,这里δ将是0.01。

现在我们可以求解试验次数T作为δ的函数。我们发现,只要我们至少进行 T ≥ (1/p) * log(1/δ) 次试验,其中δ是我们愿意容忍的失败概率,那么这么多次试验就足以让我们以至少 1-δ 的概率获得至少一次成功。例如,如果单次试验的成功概率p是1%,那么1/p项将变成因子100。如果我们设δ为0.01(即我们希望99%的成功率),那么这会将100乘以大约5,告诉你进行500次试验就可以了,你几乎总能在其中至少一次试验中成功。

在颜色编码的背景下,我们使用这些均匀随机着色将K路径变成全色,我们知道单次试验的成功概率p是 √(2πK) / e^K(这是我们从斯特林*似得到的)。因此,在所需的试验次数中,这个值会被取倒数。所以我们需要进行的试验次数——在我们可能至少有一次成功(即给定的K路径变成全色)之前,需要实验均匀随机着色的次数——将是 (e^K / √(2πK)) * log(1/δ)

这看起来可能很庞大,是指数级于K的试验次数。但别忘了,在我们的动态规划子程序中,我们已经花费了指数级于K的时间。所以这个指数级于K的试验次数只会与那个时间相乘,我们将得到大致相同类型的运行时间。

算法伪代码与运行时间分析

为了确保清楚所有部分是如何组合在一起的,让我向你展示伪代码。

算法做的第一件事是计算需要多少次随机试验,这就是我们在上一张幻灯片中刚刚算出的:T = (e^K / √(2πK)) * log(1/δ),其中δ是用户提供的失败概率。

然后,我们将运行T次独立的随机试验。每次试验,我们选取一个全新的均匀随机着色。每次试验,我们调用我们的全色路径子程序,为那个特定的着色找到最小成本的全色路径。然后,我们只需记住在所有试验中看到过的最佳路径。

这就是颜色编码算法。它进行大量独立的随机试验,在每次独立试验中,它尝试对顶点进行均匀随机着色。

每次试验可能成功,也可能失败。成功意味着至少一条最小成本的K路径变成了全色的,在这种情况下,动态规划子程序将找到该路径或某个同等优秀的路径。失败意味着这种着色实际上没有将图中任何一条最小成本的K路径变成全色的,从而将它们全部从子程序的考虑范围内移除。在失败的情况下,子程序可能返回正无穷(如果该着色确实导致没有任何全色路径),或者如果子程序返回一条全色路径,它也不可能是最小成本的,因为没有任何最小成本路径是全色的,所以它将是原图中一条成本严格更高的K路径。


但关键是,我们只需要这些试验中的一次成功。只要至少有一次我们成功地对顶点进行着色,使得某条最小成本的K路径变成全色的,那么这个算法就是正确的。当然,我们已经选择了试验次数T,使得成功概率恰好是我们想要的:至少 1-δ

算法的运行时间如何?算法所做的几乎就是运行这些T次独立的随机试验。因此,运行时间就是试验次数T乘以每次试验的运行时间。

我们明确计算了试验次数:(e^K / √(2πK)) * log(1/δ)。让我们稍微宽松地处理上界,忽略那个√K因子,直接将试验次数称为 O(e^K * log(1/δ))

每次试验的时间完全由调用动态规划子程序计算最小成本全色路径所主导。如果你还记得,通过类似贝尔曼-福特风格的论证,该算法的运行时间是 2^K * M,其中M是图中的边数。

将两者相乘,我们得到运行时间为 (2e)^K * M * log(1/δ),其中δ是用户提供的失败概率。

我们应该如何看待这个运行时间?它远远优于穷举搜索。记住,穷举搜索需要枚举所有有序的K元组顶点,其规模大约是 n^K。而这里我们有一个运行时间上界,其规模是 常数K**。这个常数不像以前那么小,现在常数大约是5.5,但对于我们讨论的n和K值(例如K=10或20,n=几百或几千),**5.5K 远远优于 n^K 的运行时间。对于K=5,n^K就已经基本无用了。

固定参数算法与应用意义

这类算法有一个特殊的名称:用于NP难问题的精确算法,其运行时间虽然当然是指数级的,但指数依赖的方式相当受限,即指数依赖仅取决于衡量实例难度的特定参数。在K路径问题中,参数就是K。你寻找的路径越长,问题通常就越难。那些仅在参数上具有指数依赖性,而在输入规模上为多项式时间的算法,被称为固定参数算法。如果你想知道更多,鼓励你进行网络搜索了解这个术语。

这个特定的固定参数算法在实际应用中产生了相当大的影响。记得在本节开始时,我们谈到了在蛋白质-蛋白质相互作用网络中寻找长线性路径的应用,即在生物网络中寻找有意义的结构。在颜色编码出现之前,最先进的技术在K值很小(可能K大约为10)时就会陷入困境。随着颜色编码的发明(甚至可以追溯到2007年左右),当时的计算机使用这种算法,允许计算生物学家在拥有数千个顶点的PPI网络中找到长度高达20的线性路径。这确实使他们能够比以前更深入地理解这些生物网络的结构。

总结与下节预告

本节课中我们一起学*了颜色编码算法的核心思想:通过随机着色将寻找K路径的问题转化为寻找全色路径的问题,并利用动态规划高效求解。我们分析了单次随机着色成功的概率 p ≈ K! / K^K,并利用斯特林*似得到 p ≈ √(2πK) / e^K。为了以高概率(如99%)成功,我们需要进行 T = O(e^K * log(1/δ)) 次独立试验。算法的总运行时间为 O((2e)^K * M * log(1/δ)),这比穷举搜索的 O(n^K) 有了巨大改进,属于固定参数可处理算法,并在计算生物学等领域得到了成功应用。

这就结束了我们对颜色编码算法以及更一般的、具有可证明运行时间界限优于穷举搜索的NP难问题精确算法的讨论。在本章(第21章)的剩余部分,我将讨论不一定具有可证明运行时间界限优于穷举搜索,但在应用中解决NP难问题可能非常有效的尖端技术:混合整数规划和可满足性求解器。我们下次再开始讨论这些内容。

022:21.3 问题特定算法与“魔法盒子” 🧙‍♂️

在本节课中,我们将要学*“问题特定算法”与“魔法盒子”式通用求解器之间的区别与联系。我们将探讨如何通过“归约”将新问题转化为已知问题,并介绍两种强大的通用求解器:混合整数规划求解器和可满足性求解器。


上一节我们介绍了针对特定NP难问题(如旅行商问题)设计高效算法的方法。本节中,我们来看看另一种强大的策略:利用现成的“魔法盒子”式通用求解器。

我们之前主要关注为特定问题量身定制算法。例如,针对旅行商问题,我们开发了基于动态规划的贝尔曼-赫尔德-卡普算法,使其比穷举搜索更快。同样,针对最小成本K路径问题,我们设计了巧妙的“颜色编码”算法。这类定制算法能深入挖掘问题结构,提供理论保证,非常令人满意。

然而,面对一个新问题时,我们应首先自问:这个问题是否是我已知如何解决的问题的一个特例或变体? 如果答案是否定的,那么着手开发问题特定算法是合理的。即使答案是肯定的,但如果针对更通用问题的现有算法性能无法满足应用需求,那么针对特定问题进行优化也是合理的。


另一方面,在本系列课程中,我们也见过许多将问题归约为已知问题的例子。第一个例子是计算中位数:对数组排序并返回中间元素即可。我们还看到,全源最短路径问题可以通过对每个起点调用一次单源最短路径子程序来归约。最长公共子序列问题本质上也是序列比对问题的一个特例。

这种从问题A(你关心的问题)到问题B(你已知如何解决的问题)的归约,能够将问题B的计算可行性转移到问题A上。公式化地,如果存在一个从问题A到问题B的多项式时间归约,且问题B可在多项式时间内解决,那么问题A也可在多项式时间内解决。


到目前为止,我们考虑的归约都是将问题归约到我们自己已经知道如何高效解决的问题上。例如,在调用排序子程序计算中位数时,我们已经掌握了快速排序算法。

然而,归约的真正威力在于:即使你自己从未想出如何高效解决问题B,甚至从未为其编写过代码,只要有人给你一个能高效解决问题B的“魔法盒子”,你就能高效解决问题A。

你可以将这个“魔法盒子”想象成一个由一群超级聪明的人花费多年时间编写的、深奥难懂的软件。只要你拥有这个能可靠且高效解决问题B的“魔法盒子”,你就可以通过运行归约程序,在需要时调用这个解决问题B的子程序,从而解决你的问题A


“魔法盒子”听起来可能像纯粹的幻想,类似于独角兽或青春之泉。它们真的存在吗?在接下来的几个视频中,我将介绍两种最接*的*似物:混合整数规划求解器可满足性求解器

这里所说的“求解器”,在实践中通常指一个深奥的软件,它包含非常复杂的算法,经过精心调优和专家级实现,可以作为现成的软件供你使用。

在下一个视频中,我们将首先讨论混合整数规划求解器。在随后的视频中,我们将讨论SAT求解器,这里的SAT代表可满足性

MIP和SAT都是极其通用的问题,其表达能力足以涵盖本系列书中研究的所有问题作为其特例。尽管它们非常通用,但数十年的工程努力和智慧已经投入到最先进的MIP和SAT求解器中。因此,尽管它们解决的是NP难问题,但这些求解器通常能在合理时间内可靠地处理中等规模的实例。

求解器的性能因问题和许多其他因素而异很大,但为了给你一个概念:对于一个可以自然地编码为MIP或SAT问题的问题,输入规模在数千甚至数万的情况下,你或许有望在一天内甚至更快地解决。在某些应用中,MIP和SAT求解器对于大规模实例(甚至输入规模达百万级)也异常有效。


我在接下来两个视频中的目标相当适度。我不会详细讲解这些MIP和SAT求解器实际上是如何工作的,那需要一门完全独立的课程。相反,我希望让你做好准备,成为这些尖端技术的“知情用户”。

对于那些确实想了解更多实现细节的人,你可以搜索 分支定界法 来了解混合整数规划求解器的工作原理,或者搜索 冲突驱动子句学* 来了解新一代可满足性求解器。

那么,我们将学*什么呢?首先,我希望你知道这些技术确实存在,并且几乎触手可及。并非所有程序员都意识到,我们拥有这些被称为MIP和SAT求解器的“半可靠魔法盒子”,它们对于解决实际应用中的NP难问题可能极其有用。

其次,我希望让你更深入地理解混合整数规划和可满足性问题的通用性有多强。我将展示一些自然的NP难问题如何被自然地编码为这些求解器可以处理的特例。

最后,我们只会花相对较少的时间讨论MIP和SAT求解器,仅仅是浅尝辄止。但我会提供一些指引,如果你想更深入地学*,无论是了解实现细节(如搜索分支定界法或CDCL求解器),还是想在你自己的应用中实际使用这些工具(例如有哪些可用软件、如何入门),我们都会在接下来的视频中提及。


让我通过澄清你可能从这些视频中感受到的一些混合信息来总结。在开篇序列中,我们讨论了NP难性对算法设计者意味着什么,并指出你需要妥协:要么放弃精确性或正确性,要么牺牲运行时间并接受指数级算法。另一方面,我现在又告诉你,在实践中我们拥有这些“半可靠的魔法盒子”,可以解决混合整数规划和可满足性等NP难问题,进而覆盖许多其他问题作为特例。

我们如何调和这两点呢?关键在于,MIP和SAT求解器这些“魔法盒子”并非完全可靠,我只能称其为“半可靠”。当你将MIP或SAT求解器应用于你自己应用中的NP难问题时,基本上你需要“祈求好运”,并准备一个备用方案B。备用方案B可以是诸如快速启发式算法之类的东西。如果求解器无法解决问题,你必须有一个后备计划。毫无疑问,总会存在一些实例(包括相当小的实例)能让你的求解器束手无策。

对于NP难问题,MIP和SAT求解器这些“半可靠的魔法盒子”已经是我们能得到的最好工具了。

在下一个视频中,我将帮助你成为第一种“半可靠魔法盒子”——混合整数规划求解器——的知情用户。我们下次见。

023:混合整数规划求解器 🧮

在本节课中,我们将要学*一种被称为“混合整数规划求解器”的半可靠“魔法黑盒”。这是一种非常通用的工具,可以将许多离散优化问题表述为一种特殊形式,并尝试求解。

从背包问题看MIP

上一节我们介绍了求解NP难问题的思路,本节中我们来看看如何将具体问题转化为混合整数规划。让我们通过一个老朋友——背包问题,来初步感受一下这个过程。

背包问题回顾

首先,让我们回顾一下背包问题的定义。输入包含 2n + 1 个正整数:有 n 件物品,每件物品有一个价值和一个尺寸,最后一个数字 C 是背包的容量。目标是选择一个物品子集,使得子集的总价值尽可能高,同时满足子集的总尺寸不超过背包容量。

问题的规范明确了三件事:需要做出的决策、必须遵守的约束条件,以及需要优化的目标函数。

决策变量

我们需要为每件物品 i 做出一个二元决策:是否将其包含在子集中。一种方便的数值编码方式是使用 0-1 变量。我们引入变量 x_i

  • x_i = 1 表示物品 i 被包含在子集中。
  • x_i = 0 表示物品 i 被排除在子集外。

约束条件

背包问题只有一个约束:所选物品的总尺寸不得超过容量 C。用我们引入的决策变量 x_i 可以很容易地用算术表达这个约束。物品 i 如果被包含,则贡献其尺寸 s_i;如果不被包含,则贡献 0。因此,所选物品的总尺寸可以表示为:
∑_{j=1}^{n} s_j * x_j

目标函数

我们想要最大化所选物品的总价值。与总尺寸类似,总价值也可以很容易地用决策变量表示:
∑_{j=1}^{n} v_j * x_j

第一个混合整数规划

至此,你已经看到了你的第一个混合整数规划。为了确保清晰,让我们具体写出幻灯片右侧这个五物品实例的MIP。

以下是该实例的MIP描述:

  • 目标函数:最大化 6*x1 + 5*x2 + 4*x3 + 3*x4 + 2*x5
  • 约束条件5*x1 + 4*x2 + 3*x3 + 2*x4 + 1*x5 ≤ 10
  • 变量定义x1, x2, x3, x4, x5 ∈ {0, 1}

像这样简单的描述,可以直接输入到一个被称为混合整数规划求解器的“魔法黑盒”中。例如,使用领先的商业求解器(如Gurobi Optimizer),你只需将上述数学描述翻译成文本文件输入,它就能在瞬间告诉你最优解。对于这个例子,最优解是 x1 = 0, x2 = x3 = x4 = x5 = 1,即排除第一件物品,选择其他四件。

当然,这只是一个玩具示例。通常,当你使用MIP求解器时,你处理的是更大的实例,这时你会希望编写程序自动生成输入文件,或者直接通过求解器的API进行交互。

混合整数规划概述

现在,让我们更一般地讨论混合整数规划,而不仅仅是背包问题。

“混合”的含义

你可能会好奇“混合”指的是什么。这里的“混合”指的是求解器可以容纳不同类型的决策变量。到目前为止,我们只使用了二元变量(0或1)。更一般地,它们可以处理在一定范围内取整数值的变量,甚至在一定范围内取实数值的变量。因为可以混合实数值和整数值变量,所以被称为混合整数规划。

相关术语

需要注意的是,我所说的MIP有时也被称为其他名称。MIP确实是一个常用术语,但有些作者会称之为整数线性规划,以强调其线性方面(我们稍后会讨论)。还有些作者直接称之为整数规划,省略了“混合”。

线性规划:一个特殊且重要的子类

混合整数规划有一个非常有趣的特殊情况,即没有整数值或0-1决策变量,所有决策变量都是实数值。这种特殊类型的MIP被称为线性规划。最先进的求解器在线性规划上表现得非常出色。事实上,任何时候你使用求解器解决混合整数规划,在底层,求解器很可能正在求解成千上万个线性规划来辅助计算。并非巧合的是,线性规划问题是多项式时间可解的,而一般的混合整数规划是NP难的。因此,线性规划是混合整数规划中一个仍然非常强大、表达力丰富但相当易处理的特殊子类。

如何指定一个MIP

一般来说,指定一个MIP与我们讨论过的三个要素相同:

  1. 识别决策变量:明确需要做出哪些决策。
  2. 说明约束条件:明确必须遵守哪些限制。
  3. 定义目标函数:明确你想要优化什么。

线性限制

一个非常重要的限制是:约束条件和目标函数都必须是决策变量的线性函数

“线性”是什么意思?让我们回顾一下背包问题的整数规划。你会注意到,在目标函数和约束条件中,我们只是将决策变量乘以常数(如6、5、4),然后将它们相加,没有做任何其他操作。这就是“线性”的含义。

例如,不允许出现像 x_j^2(非线性)、x_j * x_k(非线性)、1 / x_j(非线性)或 log(x_j)(非线性)这样的表达式。约束条件和目标函数必须能够表示为决策变量乘以常数因子后的和。

最新的MIP求解器确实也能容纳有限类型的非线性项(如某些二次项),但当只有线性约束和目标函数时,它们通常运行得更快。这也是我们在这里关注的重点。

MIP的正式定义

现在,我可以为你正式定义混合整数规划问题。基本上,你被给定一个MIP的描述,你的工作就是在遵守约束条件的前提下找到最优解。

目标函数是线性的,你所能做的只是选择如何缩放不同的决策变量。因此,输入仅包含线性函数的系数:每个决策变量 x_j 对应一个系数 c_j。类似地,对于每个约束(与背包问题不同,MIP中完全可以有多个约束),也需要指定其线性系数。你还需要为每个约束指定一个右侧值。

例如,在背包问题中:

  • c_j 成为物品价值(目标函数中的系数)。
  • 我们只有一个约束(m = 1),该约束的系数等于物品尺寸(不等式约束的左侧)。
  • 右侧值 b 就是背包容量 C

因此,一个通用的混合整数规划如下:

  • 告诉你决策变量是什么以及它们允许取哪些值。
  • 通过系数告诉你一个线性目标函数。
  • 告诉你 m 个约束,同样是线性的,同样通过其系数指定。

MIP算法或MIP求解器的职责就是计算这个非常通用的优化问题的最优解。在所有允许的决策变量赋值方式中,在所有满足所有给定约束的方式中,找到具有最佳目标函数值的那个。如果你想最大化目标,你就需要找到满足所有约束且目标函数值尽可能高的变量赋值。

MIP的表达能力与应用

即使有约束条件和目标函数的线性限制,将NP难优化问题表述为混合整数规划也可能出奇地容易。我们在讨论背包问题时已经看到了一个例子。

为了进一步阐述这一点,想象我们有一个更难的背包问题,称为二维背包问题。现在,每件物品 j 除了价值 v_j 和尺寸 s_j 外,还有第三个参数:重量 w_j。除了背包容量 C 外,现在还有一个对总重量的类似限制 W。目标仍然是最大化所选物品的总价值,但受限于两个约束:总尺寸不超过 C,且总重量不超过 W

虽然你可以用动态规划解决二维背包问题,但将其表述为MIP同样简单:只需在我们已有的MIP中添加第二个约束即可。

不仅仅是背包问题,许多你熟悉的问题,例如:

  • 最大权独立集问题
  • 最小带宽问题
  • 最大覆盖问题

都很容易编码为混合整数规划。如果你想尝试用MIP求解器解决这些问题,完全可以一试。

混合整数规划也是精确求解旅行商问题的业界标准方法,尽管其应用比上述例子要复杂得多。如果你想了解更多关于如何将MIP应用于旅行商问题,建议你搜索“子环消除约束”。

表述方式的重要性

不仅许多问题可以自然地编码为混合整数规划,事实上,许多问题可以以多种不同的方式编码为混合整数规划。而且,表述方式的选择可能影响巨大。即使使用相同的求解器,不同的MIP表述也可能导致性能差异达到一个数量级或更多。

这意味着,如果你第一次尝试使用MIP求解器解决问题失败了,并不一定意味着这项技术不适用。可能只是意味着你需要尝试用其他方式将问题编码为MIP,以获得可接受的求解器性能。

如何开始使用MIP求解器

既然你已经跃跃欲试,想将这种半可靠的“魔法黑盒”——MIP求解器——应用到你关心的问题上,那么应该从哪里开始呢?

让我简要介绍一下在录制本视频时(2020年)的业界现状。不幸的是,商业求解器和非商业求解器之间存在巨大的性能差距,因此我将分别针对这两种情况给出建议。

商业求解器

目前,大多数专家会告诉你,Gurobi Optimizer 是最稳定可靠和健壮的求解器。如果你要选择一个亚军,你可能会选择 CPLEX(它在某些方面是Gurobi的前身)或 FICO Xpress

好消息是,如果你与大学有关联(是学生或教职工),你可以为这些求解器获取免费的学术许可证,尽管通常仅限于研究和教育用途。

非商业求解器

对于那些只能使用非商业求解器的人,如果你四处询问推荐,以下是你可能经常听到的四个名字(各种首字母缩写):

  1. SCIP
  2. CBC 求解器
  3. MIPCL
  4. GNU Linear Programming Kit (GLPK)

CBC和MIPCL求解器的许可协议比其他两个更宽松;其他两个仅限非商业使用免费。

建模语言

如果你开始认真对待这些MIP求解器,另一件你可能想了解的事情是:你可以将“为你的问题构建MIP模型”和“将你构建的模型以特定求解器要求的语法描述出来”这两个任务分离开。这可以通过使用高级的、求解器无关的建模语言来实现。

一个很好的例子是基于Python的 CVXPY。如果你选择使用这类建模语言,好处是你可以轻松地尝试该语言支持的所有求解器,以找出哪种求解器在你关心的输入类型上表现最好。你的高级规范会被自动编译成求解器期望的任何格式。

本节课中我们一起学*了混合整数规划求解器这一强大的工具。我们了解了如何将优化问题(如背包问题)表述为MIP,理解了MIP的基本构成(决策变量、线性约束、线性目标函数)以及“混合”和“线性”的含义。我们还探讨了MIP强大的表达能力,以及商业与非商业求解器的选择。最后,我们提到了使用高级建模语言可以方便地尝试不同求解器。

这涵盖了我想要告诉你的关于MIP求解器这种半可靠“魔法黑盒”的全部内容。还有另一种类型的此类“黑盒”——可满足性求解器,我将在下一个视频中介绍。

024:可满足性求解器 🧩

在本节中,我们将简要介绍可满足性(SAT)求解器的世界。这是我们将要讨论的第二种“半可靠魔法盒”。上一节我们介绍了用于优化问题的混合整数规划求解器,而本节中我们来看看用于可行性检查问题的SAT求解器。

概述

SAT求解器用于解决可满足性问题。这类问题通常不涉及优化某个数值目标函数,而是关注于判断一组约束条件是否能同时被满足,并可能找到一个可行的解。许多应用中的问题可以自然地编码为SAT问题。

图着色问题 🎨

图着色问题是图论中最古老的问题之一。输入是一个无向图和一个正整数K。目标是用K种颜色为图的顶点着色,使得每条边的两个端点颜色不同。这被称为K着色。

例如,考虑一个有六个辐条的轮图(共七个顶点)。当K=3时,该图是三着色的。我们可以将中心节点涂成黄色,然后在周边交替使用蓝色和绿色。

然而,对于有五个辐条的轮图,它就不是三着色的,实际上需要四种颜色。中心节点涂成黄色后,周边五个节点都必须使用非黄色(即蓝色或绿色)。但尝试交替使用蓝绿两色时,第五个节点会与相邻节点冲突,因此必须引入第四种颜色。

图着色问题不仅仅是理论问题,它有实际应用,例如课程安排(将课程分配到教室)和高风险的FCC激励拍卖案例。

从逻辑到SAT:基本概念 🔧

与混合整数规划求解器处理数值优化问题不同,SAT求解器基于逻辑形式。其决策变量是布尔变量,只能取真(True)或假(False)两个值。

一个真值指派是为每个决策变量分配一个值(真或假)。对于n个变量,有2^n种可能的真值指派。

约束条件(在SAT中常被称为子句)用于限定哪些真值指派是可行的。我们将使用一种看似简单但表达能力强大的约束形式:文字的析取。

  • 文字:一个决策变量(如 Xi)或其否定(如 ¬Xi)。
  • 析取:逻辑“或”运算(用符号 表示)。例如,X1 ∨ ¬X2 ∨ X3 表示 X1 为真, X2 为假, X3 为真。只要至少一个文字为真,整个析取式就为真。

一个包含K个文字的析取式很容易满足,在涉及变量的2^K种赋值中,只有一种(即所有文字的请求都被违背)会使其为假。

SAT问题定义 📝

SAT问题的输入是:

  1. 变量列表:n个布尔决策变量 X1, X2, ..., Xn
  2. 约束列表:m个约束,每个约束都是一个或多个文字的析取式。

SAT求解器的任务是:

  • 找到一个满足所有m个约束的真值指派。
  • 或者,如果不存在这样的真值指派(即实例不可满足),则正确报告这一事实。

SAT问题是理论计算机科学中最核心的问题之一。

将图着色编码为SAT问题 💻

如何将看似需要K种颜色的图着色问题,编码为只使用布尔变量的SAT问题呢?技巧在于为每个顶点创建K个布尔变量。

变量定义
对于每个顶点 v 和每种颜色 i (1 ≤ i ≤ K),创建一个布尔变量 X_{v,i}

  • 语义X_{v,i} = True 表示顶点 v 被赋予颜色 i

约束定义
我们需要两类约束来确保得到一个合法的K着色。

以下是构建约束的步骤:

  1. 每个顶点至少获得一种颜色
    对于每个顶点 v,添加一个约束:X_{v,1} ∨ X_{v,2} ∨ ... ∨ X_{v,K}。这确保了顶点 v 至少有一个颜色变量为真。

  2. 相邻顶点颜色不同
    对于每条边 (v, w) 和每种颜色 i,添加一个约束:¬X_{v,i} ∨ ¬X_{w,i}。这个析取式要求 X_{v,i}X_{w,i} 不能同时为真,即顶点 vw 不能同时被着色为 i

通过这两组约束,我们可以保证得到的真值指派对应一个合法的图着色(尽管一个顶点可能有多个颜色变量为真,但我们可以任意选择其中一个作为其颜色,这不会影响着色的合法性)。

使用SAT求解器 🧰

将问题编码为SAT后,我们可以将其输入到SAT求解器中。输入通常采用一种标准格式(如DIMACS CNF格式)。

例如,对于一个三角形图(三个顶点的完全图)的2着色问题,其SAT编码的输入文件可能如下所示:

p cnf 6 9
1 2 0
3 4 0
5 6 0
-1 -3 0
-1 -5 0
-3 -5 0
-2 -4 0
-2 -6 0
-4 -6 0

  • 第一行 p cnf 6 9 表示有6个变量和9个子句。
  • 正数表示变量,负数表示变量的否定。
  • 每行以0结束,表示一个子句的终止。
  • 前三个子句对应“每个顶点至少一种颜色”的约束。
  • 后六个子句对应“相邻顶点颜色不同”的约束。

将这个文件输入到如 MiniSAT 这样的求解器,它会迅速返回该图是否是2着色的(对于三角形,答案是否定的)。

SAT求解器生态与工具 🏆

SAT求解器领域的一个特点是开源生态非常活跃。全球的研究人员定期举办SAT竞赛,比较各自求解器的性能。许多优秀的求解器都是开源的。

  • 入门推荐MiniSAT 性能良好,易于使用,且采用宽松的MIT许可证。
  • 进阶工具可满足性模理论(SMT)求解器(如微软的 Z3)扩展了SAT的能力,可以处理更丰富的理论(如算术、数组等)。Z3同样免费且开源。

总结

本节课中我们一起学*了可满足性(SAT)求解器。我们了解到:

  1. SAT求解器是用于解决可行性问题的“半可靠魔法盒”,它基于逻辑而非算术。
  2. SAT问题的核心是寻找一组布尔变量的赋值,以满足一组由文字析取构成的约束。
  3. 许多实际问题(如图着色)可以编码为SAT问题。关键的编码技巧包括使用多个布尔变量表示多值选择,并用析取子句表达问题约束。
  4. 编码完成后,问题可以输入到如 MiniSAT 这样的求解器中自动求解。
  5. SAT求解器领域拥有活跃的开源社区和定期竞赛,推动了工具的快速发展。

至此,我们已经完成了对处理NP难问题各种策略的探讨:从妥协于正确性的启发式算法和局部搜索,到妥协于运行时间的精确算法(如动态规划),再到本章介绍的两种“魔法盒”——混合整数规划求解器和可满足性求解器。你现在已经具备了多种工具来尝试解决实践中遇到的NP难问题。

接下来的章节将解决另一个关键问题:如何判断一个问题是NP难的,从而避免为寻找其多项式时间精确算法而浪费时间。

025:归约回顾 🔄

在本节课中,我们将要学*如何证明一个问题是NP难的。我们将从回顾归约的基本概念开始,并理解它在证明NP难问题中的核心作用。

上一节我们介绍了NP难问题的基本概念,本节中我们来看看归约是如何成为证明NP难问题的关键工具的。

概述

归约是算法设计中的一个核心概念。它本质上是一种论证,表明如果你能高效地解决问题B,那么你就能利用解决B的方法来高效地解决问题A。更正式地说,一个从问题A到问题B的归约是一个算法,它接受问题A的一个实例作为输入,允许以子程序的形式调用解决问题B的算法(多项式次数),并允许在调用之外进行多项式量的额外计算。最终,这个归约算法必须能够正确报告最初输入的问题A实例的答案。

作为算法设计者,我们通常以“传播可解性”的正面形式来思考归约。例如,全对最短路径问题可以归约到单源最短路径问题。这意味着,如果我们知道如何解决单源最短路径问题,我们就自动知道如何解决全对最短路径问题。一般来说,只要存在一个从问题A到问题B的归约,并且存在一个解决B的多项式时间算法,那么这个归约就会自动为你提供一个解决A的多项式时间算法。换句话说,从A到B的归约将可解性从B传播到了A。

然而,在NP难理论中,归约的用途有所不同。它是一种更“狡猾”的用法,不是为了传播计算的可解性,而是为了传播计算的不可解性,即传播NP难性,但其传播方向与可解性相反。

当我们以传播可解性的正面形式使用归约时,可解性沿着归约的相反方向传播。如果问题A归约到问题B,那么B的可解性意味着A的可解性。因为你可以通过运行归约算法并使用假设的B的高效算法来得到A的算法。

计算不可解性(如NP难性)的传播方向与可解性相反,这意味着它沿着归约的相同方向传播。如果A归约到B,并且A是难解的(例如是NP难的),那么B在相同意义上也是难解的。

为了提醒你为什么这是真的,想象问题A是NP难的。这意味着一个解决A的多项式时间算法将推翻P不等于NP猜想。假设问题A归约到问题B。再假设我们实际上为B设计出了一个多项式时间算法。那么,通过这个归约,它将自动转化为一个解决A的多项式时间算法。但我们说过,这将推翻P不等于NP猜想。换句话说,即使是B的多项式时间算法也会推翻P不等于NP猜想,而这正是我们定义一个问题为NP难的临时标准。因此,如果A是NP难的,并且A归约到B,那么B也必须是NP难的。

这对我们来说意味着,存在一个极其简单的“配方”来证明一个问题是NP难的。

证明NP难性的简单配方

如果你在自己的项目中遇到了某个问题B,并且你怀疑B可能是NP难的,你可以按照以下步骤来证明它。

以下是证明问题B是NP难的两个步骤:

  1. 选择一个已知的NP难问题A。这需要你对已知的NP难问题有所了解。在本章中,你将学*19个NP难问题,其中任何一个都可以作为步骤1中问题A的合法选择。如果这19个还不够,书籍中还记载了数百个其他问题。
  2. 设计一个从问题A到你所关心的问题B的归约。这需要你具备在不同问题之间设计归约的技能。虽然我们之前的训练主要集中在使用归约传播可解性的正面用途上,但那些完全相同的归约技巧也可以反过来用于传播不可解性。随着我们在后续视频中的深入学*,我们将获得大量关于NP难证明中归约特殊技巧的实践。

如果你完成了这两个步骤,那么证明就完成了。你已知问题A是NP难的(根据假设),并且你将它归约到了B。由于难解性(NP难性)沿着归约的相同方向传播(从A到B),因此可以断定问题B也是NP难的。

总结

本节课中我们一起学*了归约在证明NP难问题中的核心作用。我们回顾了归约如何既能传播可解性,也能传播不可解性,并理解了其方向性的关键区别。最重要的是,我们掌握了一个简单的两步骤配方来证明新问题是NP难的:首先选择一个已知的NP难问题,然后设计一个从该问题到目标问题的归约。在接下来的课程中,我们将以此为基础,深入探讨具体的NP难问题实例和归约设计技巧。

026:3-SAT与库克-列文定理 🧩

在本节课中,我们将学*3-SAT问题以及计算机科学中一个极其重要的定理——库克-列文定理。该定理首次证明了一个问题是NP难的,为后续通过归约证明其他问题的NP难性奠定了基础。

库克-列文定理的提出与意义

上一节我们介绍了证明问题NP难性的两步法。如果反复应用这个方法,你将积累成千上万个NP难问题。但这个过程最初是如何开始的?第一个NP难问题从何而来?答案来自计算机科学中最著名和最重要的成果之一:库克-列文定理。

库克-列文定理的形式化陈述很简单:看似无害的3-SAT问题(即每个析取子句中最多包含三个文字的可满足性问题)实际上是一个NP难问题。

这个定理由斯蒂芬·库克和列昂尼德·列文独立证明。他们大约在1971年分别于多伦多和莫斯科完成了证明。由于历史原因,列文的工作在西方被广泛认知较晚,因此早期教科书常称之为“库克定理”,但两人都应获得荣誉。

他们不仅证明了3-SAT是NP难的,还暗示了可能有许多其他问题也是NP难的。这个预言在1972年由理查德·卡普实现,他直接受到库克工作的启发。卡普通过反复应用我们讨论过的两步法,展示了NP难性的全部威力。

以下是关于证明者及其贡献的要点:

  • 卡普最初的21个NP难问题列表,包含了本章将讨论的许多问题,这清楚地表明NP难性是阻碍许多不同领域(如旅行商问题)在众多著名问题上取得进展的根本障碍。
  • 库克和卡普分别于1982年和1985年获得了ACM图灵奖(计算机科学领域的最高荣誉,相当于诺贝尔奖)。
  • 列文的工作后来才得到充分认可,他于2012年获得了克努特奖。

为什么是“3”-SAT?

你可能会好奇“3-SAT”中的“3”从何而来。该问题定义为每个析取子句中最多包含三个文字。选择“3”的原因是,这是使该问题成为NP难的最小数字。

相比之下,2-SAT问题(每个子句最多包含两个文字)实际上可以在线性时间内解决。有几种方法可以做到,其中一种是通过归约到计算某个有向图的强连通分量问题。

SAT求解器与库克-列文定理的关系

在之前的视频中,我们在SAT求解器的背景下讨论过可满足性问题。SAT求解器是半可靠的“魔法黑盒”,在实践中确实能成功解决一些SAT问题。

需要明确的是,SAT求解器的半可靠性与库克-列文定理并不矛盾。库克-列文定理指出,你不可能拥有一个保证快速且正确的算法来解决3-SAT问题。而SAT求解器提供的并非保证正确且快速的算法,它们提供的是有时正确且快速的算法,这并非同一回事。

本章的立足点与证明思路

在本章中,我们不会深究库克-列文定理为何成立,也不会探讨其证明细节,我们将直接接受它作为已知事实。

我们的计划是站在这些巨人的肩膀上,假设3-SAT是一个NP难问题,然后通过归约生成另外18个NP难问题。如果你好奇如何像库克-列文定理那样从头开始证明一个问题的NP难性,我们将在下一章(第23章)对应的视频中讨论证明背后的高层次思路。我认为这个证明值得至少了解一次,但几乎没有人记得库克-列文定理的具体细节。大多数计算机科学家满足于作为该定理的“受教育用户”,像我们在本章中一样,将其与其他NP难问题一起用作工具,来证明你所关心的问题是NP难的。

3-SAT问题的精确定义

为了结束本视频,让我们确保所有人都完全清楚3-SAT问题到底是什么。如果你看过之前关于SAT求解器的视频,这里不会有新内容,但如果你没看过,我希望确保你确切知道我们在讨论什么问题。

3-SAT问题的输入由变量和约束组成,两者形式都非常简单。

所有决策变量都必须是布尔变量,因此它们只能取真或假值。给定n个布尔变量的集合,总共就有 2^n 种可能的真值赋值(即每个变量赋值为真或假的可能组合)。

我们将处理的唯一约束是文字的析取。一个文字要么是一个决策变量 Xi,要么是其否定 ¬Xi

析取就是逻辑或运算。A ∨ B 为真,当且仅当A为真,或B为真,或两者都为真。

在我们讨论SAT求解器时,我们允许文字的析取包含任意数量的文字。对于3-SAT问题,它是特殊情况,我们限制每个约束最多包含三个文字。

目标正如你所料:在这 2^n 种可能的真值赋值中,我们想知道是否存在任何一种赋值能同时满足所有约束(所有最多包含三个文字的析取子句)。如果没有这样的赋值,我们希望算法报告这一事实;如果存在满足的真值赋值,我们希望算法能直接返回一个给我们。

例如,考虑以下八个约束。需要明确一下符号:出现在每对文字之间的 代表逻辑或,这正是析取的含义。而出现在变量前面的倒L符号 ¬ 表示否定。因此,在右上角的约束中,有 ¬x2¬x3

如果输入是这三个变量 x1, x2, x3 以及这八个子句(每个包含三个文字),那么这将是一个不可满足的3-SAT实例。确实不存在满足的真值赋值。对于三个布尔变量,有八种可能的真值赋值,而这八个约束中的每一个恰好排除了那八种可能赋值中的一种。所以没有赋值剩下,因此它是不可满足的。

另一方面,如果我们删除这八个约束中的任何一个,我们将得到一个可满足的3-SAT实例,因为那样只会禁止七个对三个变量的赋值,还会剩下一个满足的赋值。

通常,当存在一个真值赋值满足所有约束时,我们称该实例为可满足的,否则称之为不可满足的

总结

本节课中我们一起学*了库克-列文定理,它给出了我们的第一个NP难问题:3-SAT是NP难的。基于这个事实,通过归约,我们将把NP难性传播到其他18个问题。在下一个视频中,让我们来具体了解所有这些问题和归约将是怎样的。

027:全局概览 🗺️

在本节中,我们将学*第22章的整体规划。我们将回顾如何通过归约从3SAT问题证明其他问题的NP难性,并梳理本章将要讨论的19个NP难问题及其间的18个归约关系。我们将首先通过一个小测验来巩固对归约方向的理解,然后概览所有问题与归约,并区分哪些归约是简单的,哪些是复杂的。

归约方向测验

上一节我们介绍了归约的概念。本节中我们来看看一个关于归约方向的小测验,以确保我们理解计算易处理性和难处理性是如何通过归约传播的。

假设我们已知背包问题可以归约为混合整数规划问题。这意味着存在一个多项式时间算法,可以将任意背包问题的实例转换为一个混合整数规划问题的实例。那么,以下哪些陈述是正确的?

以下是三个选项:

  • A. 混合整数规划问题的计算难处理性意味着背包问题的计算难处理性。
  • B. 背包问题的计算难处理性意味着混合整数规划问题的计算难处理性。
  • C. 混合整数规划问题的计算易处理性意味着背包问题的计算易处理性。

正确答案是B和C。

理解原因最简单的方法是回忆我们关于问题A归约为问题B的示意图,以及易处理性和难处理性的传播方向。假设问题A归约为问题B。当我们谈论传播易处理性时,它沿着与归约相反的方向传播。因此,如果问题B是易处理的(存在多项式时间算法),那么通过将归约算法与问题B的算法组合,我们就能得到问题A的多项式时间算法,从而证明A也是易处理的。

而难处理性则沿着与易处理性相反的方向传播,即与归约相同的方向。回想一下证明问题B是NP难的两步法:我们取一个已知的NP难问题A,然后将A归约为B。这样,难处理性就从A传播到了B。

在这个测验中,背包问题是问题A,混合整数规划问题是问题B。因此,计算难处理性沿着从背包问题到混合整数规划问题的方向传播,这正是选项B所陈述的。选项A将难处理性的传播方向弄反了。同理,选项C也是正确的,因为计算易处理性沿着相反的方向传播:给定一个解决混合整数规划问题的合理算法,通过将其与从背包问题到混合整数规划的归约组合,我们就能得到一个解决背包问题的合理算法。

问题与归约关系图

在明确了归约方向后,我们可以开始讨论本章将通过18个归约产生的19个NP难问题。

我们将用19个节点代表19个计算问题,其中包括我们在本系列视频中讨论过的所有问题。如果问题A可以归约为问题B,我们就画一条从A指向B的箭头。难处理性将沿着箭头的方向传播。所有NP难问题的源头——Cook-Levin定理的产物——我们将从3SAT问题开始。

接下来,我们将从3SAT出发,画出一个向外的有向树。NP难性将通过这个有向树从3SAT传播到其他18个问题。

以下是完整的关系图:

这张图可能看起来有些复杂,它包含了很多问题和18个归约。不过,其中一些归约我们已经见过,接下来我们将讨论哪些归约是简单的,哪些是困难的,并将占据接下来的四个视频。

简单的归约

在18个归约中,有11个相对简单,它们将作为本章的课后练*出现。以下是这些相对简单的归约及其简要说明。

从3SAT到SAT:这个归约是完全平凡的,因为3SAT本身就是SAT的一个特例(每个子句最多包含三个文字)。因此,任何能解决SAT的子程序都能自动解决3SAT。

从有向哈密顿路径问题到无负环最短路径问题:这个归约我们在第19章的开头已经见过。我们当时利用归约来传播难处理性,并简要展示了这个两步法的实例。我们当时假设有向哈密顿路径问题是NP难的(本章将证明),然后我们展示了该问题可以相当容易地归约为计算无负环最短路径的问题。这个归约很有趣,因为它解释了我们在本系列前几册中讨论的最短路径算法(如Bellman-Ford和Floyd-Warshall)的局限性:它们只能保证在图中没有负环的特殊情况下计算最短路径距离。正是通过这个归约,我们最终理解了原因:如果这些算法在更一般的情况下(如图中有负环)也能正确工作,那将实际上反驳P≠NP猜想。

从独立集问题到团问题:这两个都是关于无向图的问题。独立集是图中两两不相邻的顶点子集,而团则是两两相邻的顶点子集。独立集问题是寻找最大规模的独立集,最大团问题是寻找最大规模的团。如果我们有一个解决团问题的有效子程序,可以很容易地从中提取出解决独立集问题的算法:给定一个独立集问题的实例,我们取其补图(即翻转边的存在性),这样原图中的所有独立集就变成了补图中的团。然后运行假设的求最大团的子程序,得到结果后再将边翻转回来,就得到了原图的最大独立集。

从独立集问题到顶点覆盖问题:顶点覆盖问题是寻找一个最小的顶点子集,使得图中的每条边都至少有一个端点在这个子集中。这里的关键在于认识到,在任何图中,一个顶点子集是独立集,当且仅当它的补集是顶点覆盖。因此,如果我们有一个求解最小顶点覆盖的子程序,我们只需调用它找到最小顶点覆盖,然后取其补集,就得到了最大独立集。

从顶点覆盖问题到集合覆盖问题:集合覆盖问题的输入类似于最大覆盖问题:有一个基础元素集合,以及该集合的一系列子集。目标是用尽可能少的子集覆盖整个基础集合。顶点覆盖问题实际上是集合覆盖问题的一个特例。我们可以将图的边集视为基础集合,为每个顶点创建一个子集,包含所有与该顶点相连的边。这样,该集合系统的集合覆盖就精确对应于原图的顶点覆盖。

从集合覆盖问题到最大覆盖问题:这两个问题的输入几乎相同。区别在于,最大覆盖问题给定一个可以选择子集数量的预算K,目标是覆盖尽可能多的元素;而集合覆盖问题要求必须覆盖所有元素,目标是使用尽可能少的子集。如果我们有一个解决最大覆盖问题的子程序,可以用它来求解集合覆盖:我们依次询问该子程序,使用1个、2个、3个……个子集所能覆盖的最大元素数量。当某个K值首次使得最大覆盖算法能够覆盖整个基础集合时,这个K值以及对应的子集就是原集合覆盖实例的最小集合覆盖。

从最大覆盖问题到影响力最大化问题:我们在讨论影响力最大化时提到过,最大覆盖问题基本上是影响力最大化的一个特例。为了将最大覆盖实例视为影响力最大化实例,我们可以构造一个图:顶层顶点对应最大覆盖实例中的每个子集,底层顶点对应基础集合中的每个元素。对于每个子集顶点,我们添加一条指向其包含的每个元素顶点的有向边。将激活概率设置为1。此时,在该图中选择K个顶层顶点以最大化影响力,就精确对应于选择K个子集以最大化覆盖范围。

从旅行商问题到旅行商路径问题:旅行商路径问题是TSP的一个变体,它不要求形成闭合回路,而是一条访问所有顶点恰好一次的最短路径。从TSP到该问题的归约并不困难,可以通过对原TSP实例进行一些小的修改,使得当我们把修改后的图输入给求解旅行商路径的子程序时,可以“欺骗”该子程序为我们计算出原图中的最优回路。同样的思路也适用于我们详细讨论过的最小成本K路径问题(在带权图中寻找恰好访问K个不同顶点的最短路径),可以证明该问题也是NP难的。

有向与无向哈密顿路径问题之间的归约:由于我们关心的某些图问题最自然地出现在有向图中,而另一些则出现在无向图中,因此拥有哈密顿路径问题的两个版本(有向和无向)是有用的。这两个问题本质相同,存在非常简单的双向归约。例如,给定一个无向图和一个解决有向哈密顿路径的子程序,我们可以将每条无向边替换为两条方向相反的有向边。这样,在得到的双向有向图中找到的有向哈密顿路径很容易转换回原无向图中的哈密顿路径。反过来,从有向图到无向图的转换稍微复杂一些,但也不难,通过对图进行一些改造,使得在改造后的无向图中找到的哈密顿路径可以提取出原图中的有向哈密顿路径。

从SAT问题到混合整数规划问题:当我们有一个可行性问题(如图着色)时,SAT通常是最自然的编码方式。但如果我们愿意,也可以使用混合整数规划来编码。MIP是关于优化的,但如果我们只是想编码一个SAT实例,可以使用一个占位符目标函数(例如最大化0)。然后,我们需要将SAT实例中的逻辑约束(文字的析取)转换为算术约束(不等式)。通常,我们会为SAT实例中的每个布尔变量引入一个0-1决策变量(1代表真,0代表假)。这样,每个子句都可以很容易地转换为一个不等式。当然,也可以通过其他方式(如背包问题是MIP的特例)来论证MIP是NP难的。

从子集和问题到背包问题与机器调度最小化完工时间问题:子集和问题的输入是n个正整数和一个目标值,问题是判断是否存在一个子集,其和恰好等于目标值。我们将证明子集和问题是NP难的。这里需要指出,子集和问题基本上是背包问题和(两机器情况下的)机器调度最小化完工时间问题的一个特例。如果子集和是NP难的,那么这两个更一般的问题自然也是NP难的。对于背包问题,子集和对应物品大小等于物品价值的特殊情况。对于机器调度,则大致对应只有两台机器的特殊情况。

以上就是11个相对简单的归约练*。剩下的5个归约中,有4个我们将在接下来的四个视频中详细讲解。第5个归约(证明图着色问题是NP难的)虽然也不简单,但仍将作为练*。其难度与我们将要看到的归约大致相当。

困难的归约与学*目标

剩下的四个较难的归约是我们接下来的重点。

  1. 从3SAT到独立集问题:在下一个视频中,我们将首先展示如何将3SAT问题归约为独立集问题。一旦完成这个归约,并假设Cook-Levin定理(3SAT是NP难的),难处理性将沿着该路径一直传播到末端,包括最大覆盖和影响力最大化问题。
  2. 从3SAT到有向哈密顿路径问题:我们的第二个目标是确立旅行商问题的NP难性,这是经典的NP难问题。为此,我们需要两步:第一步是另一个从3SAT到图问题的归约,这次是到有向哈密顿路径问题。这将在第二个视频中完成。
  3. 从(无向)哈密顿路径问题到旅行商问题:这个归约将从有向哈密顿路径问题(通过前述归约已知是NP难的)出发。由于有向和无向哈密顿路径问题本质相同,我们也知道无向版本是NP难的。然后,存在一个相当简单的从无向哈密顿路径问题到旅行商问题的归约。这将在第三个视频中完成,也是四个归约中最简单的一个,最终兑现旅行商问题是NP难问题的承诺。
  4. 从独立集问题到子集和问题:最后一个归约在第四个视频中,展示如何将独立集问题(将在第一个视频中证明是NP难的)归约为子集和问题。由于子集和是背包问题和机器调度最小化完工时间问题的特例,这将立即确立这两个问题的NP难性。这个归约特别有趣,因为背包问题和子集和问题的输入只是一堆数字。我们知道可以用依赖于输入数值大小的伪多项式时间算法解决背包问题(及子集和)。但NP难性结果解释了为什么我们无法获得运行时间仅依赖于输入数字位数的真正多项式时间算法。

接下来的四个视频将填补这些空白,展示最后四个归约的证明。在开始之前,需要坦诚相告:NP难性归约,包括我们将要看到的一些,可能会有些繁琐和复杂。老实说,几乎没有人会记住任何NP难性证明的所有细节。尽管如此,学*其中几个仍然有很好的理由。以下是接下来四个视频中学*这些NP难性归约的目标:

目标一:兑现承诺。在本系列视频和书中,我们面对一个又一个问题时,总是说“可惜,这是NP难的”,并因此必须在正确性或运行时间上做出妥协。如果不解释为什么这些问题确实是NP难的,以及为什么我们需要那些妥协,会显得有些不够诚实。完成这些归约将兑现我们在第20和21章中做出的承诺。

目标二:提供工具。证明一个问题是NP难的两步法的第一步,是选择一个已知的NP难问题。通过学*这些归约,将巩固我们对一系列NP难问题的认识,你可以在自己进行NP难性归约时使用它们。如果19个问题还不够,可以参考Michael Garey和David Johnson的经典著作《Computers and Intractability: A Guide to the Theory of NP-Completeness》(1979年)。该书收录了300多个NP难问题的汇编,是构思自己归约的绝佳资料库。


目标三:建立信心。我并不指望你一年后甚至一周后还能记住这些归约的细节。但这个练*应该能让你感到更有能力。如果在未来的某个时候,你的老板交给你一个问题,并说“你今年的加薪取决于你能否在下周前证明这是NP难的”,你应该觉得,如果真有必要,你是可以做到的。它们虽然繁琐且针对特定问题,但看完接下来几个20分钟的视频示例后,你会理解它们,并感到:“是的,如果真需要我做,我可以。”

关于第22章的整体概览就介绍到这里。接下来,让我们深入具体的归约,从独立集问题开始。我们下一个视频见。


总结

本节课中,我们一起学*了第22章的全局规划。我们通过一个小测验巩固了对归约方向的理解,明确了计算易处理性和难处理性是如何传播的。我们概览了本章将要涉及的19个NP难问题和18个归约,并将它们分为11个简单的归约(多作为练*)和5个较难的归约。我们还明确了接下来四个视频的学*目标:详细讲解四个关键的归约,以证明独立集、有向哈密顿路径、旅行商以及子集和(进而背包和机器调度)等问题的NP难性,从而兑现之前的承诺,并为读者提供进行NP难性证明的工具和信心。

028:独立集问题是NP难的 🧩

在本节课中,我们将学*如何证明独立集问题(Independent Set Problem)是NP难问题。我们将通过一个从3-SAT问题到独立集问题的归约来完成证明。理解这个归约是掌握NP完全性理论的关键一步。

概述

我们将遵循一个两步走的“配方”来证明独立集问题是NP难的。首先,我们选择一个已知的NP难问题作为起点。目前,我们唯一已知的NP难问题是3-SAT问题(由Cook-Levin定理保证)。然后,我们将展示如何将任意一个3-SAT问题的实例,高效地转化为一个独立集问题的实例。如果这个转化(归约)是正确的,那么独立集问题的计算难度至少和3-SAT问题一样高,从而证明独立集问题也是NP难的。

独立集问题回顾

在开始归约之前,让我们快速回顾一下独立集问题。

  • 输入:一个无向图 G = (V, E)
  • 目标:计算一个独立集,即一个顶点子集 S ⊆ V,其中任意两个顶点都不相邻(即没有边连接)。我们通常寻找最大可能尺寸的独立集。

例如,在一个5个顶点构成的环(5-cycle)中,最大独立集的尺寸是2。你无法选出3个互不相邻的顶点。

我们之前讨论过带权独立集问题,每个顶点有一个非负权重,目标是找到总权重最大的独立集。而这里我们关注的是所有顶点权重均为1的特殊情况(即最大尺寸独立集)。我们将看到,即使这个特殊情况,对于一般图来说也是NP难的。

归约的核心思想

现在,我们面临一个挑战:如何将一个关于逻辑公式(3-SAT)的问题,转化成一个关于图(独立集)的问题?

3-SAT问题的输入是一组子句(clauses),每个子句是至多三个文字(literals,即变量或其否定)的析取(disjunction)。例如,一个子句可能是 (¬x1 ∨ x2 ∨ x3)。这个子句在请求:“请将 x1 设为假,或者将 x2 设为真,或者将 x3 设为真”。只要满足其中任何一个请求,该子句就被满足。

归约的关键想法是:在我们构造的图中,为每个子句中的每个文字(即每个变量赋值请求)创建一个顶点

例如,对于子句 (¬x1 ∨ x2 ∨ x3),我们创建三个顶点,分别代表“设 x1 为假”、“设 x2 为真”和“设 x3 为真”。

构造图 G

假设我们有一个3-SAT实例,有 n 个变量和 m 个子句。我们按以下步骤构造一个无向图 G = (V, E)

  1. 创建顶点(Vertices)

    • 对于第 i 个子句(它有 k_i 个文字,k_i ∈ {1,2,3}),我们创建 k_i 个顶点。
    • 总顶点集 V 就是所有子句对应的顶点集合的并集。
  2. 添加边(Edges)

    • 边集 E 包含两种类型的边:
      • 组内边(E1):在同一个子句对应的所有顶点之间添加边(即形成完全图)。这意味着,对于每个子句,其对应的顶点两两相连。如果子句有3个文字,则这3个顶点构成一个三角形。
      • 冲突边(E2):如果两个顶点(来自不同的子句)对同一个变量提出了相反的赋值请求(例如,一个请求 x 为真,另一个请求 x 为假),则在它们之间添加一条边。

这样构造的图 G 就编码了原始3-SAT实例的所有约束信息。

归约算法

有了构造图 G 的方法,我们现在可以描述完整的归约算法。假设我们有一个能解决独立集问题的“黑盒子”子程序(即我们的“洋红色框”)。

我们的目标是构建一个解决3-SAT问题的算法(“浅蓝色框”),其步骤如下:

  1. 输入:一个3-SAT公式,包含 n 个变量和 m 个子句。
  2. 构造图:按照上述方法,从3-SAT实例构造出对应的图 G
  3. 调用子程序:将图 G 输入到独立集问题的子程序中,获得一个最大尺寸的独立集 S
  4. 判断与输出
    • 如果独立集 S 的尺寸等于子句数量 m,那么我们可以从 S 中导出一个满足原始3-SAT公式的真值赋值(具体方法见下文),并输出这个赋值。
    • 如果独立集 S 的尺寸小于 m,那么我们断定原始的3-SAT公式是不可满足的,并输出“不可满足”。

这个算法除了调用一次独立集子程序外,构造图 G 只需要 O(m + n) 的额外时间。

正确性证明

我们需要证明这个归约总是正确的:当原始3-SAT公式可满足时,算法输出一个解;当不可满足时,算法正确报告不可满足。

首先,观察图 G 的两个基本性质,它们由构造方式保证:

  • 性质1(由边E1保证):任何独立集最多只能包含每个子句组(三角形)中的一个顶点。因此,图 G 中独立集的最大可能尺寸不超过子句数 m
  • 性质2(由边E2保证):任何独立集中的顶点所代表的变量赋值请求都是一致的,即不会对同一个变量既要求真又要求假。因此,从任何一个独立集 S,我们都可以导出一个(可能不唯一)与 S 中所有请求一致的真值赋值。

现在,我们分两种情况证明:

情况一:3-SAT实例是可满足的。
假设存在一个满足所有子句的真值赋值。对于每个子句,至少有一个文字在这个赋值下为真。我们从每个子句中挑选一个在这个赋值下为真的文字所对应的顶点。这样我们得到了一个包含 m 个顶点的集合 S‘

  • S‘ 中每个子句只有一个顶点,因此没有违反边E1。
  • 因为 S‘ 中的所有赋值请求都被同一个真值赋值所满足,它们之间不可能冲突,因此没有违反边E2。
    所以,S‘ 是图 G 中一个尺寸为 m 的独立集。因此,最大独立集尺寸至少为 m,结合性质1,可知最大独立集尺寸就是 m。我们的算法获得的独立集 S 尺寸为 m,并且根据性质2,我们可以从 S 导出一个满足所有子句的真值赋值。算法输出正确。

情况二:3-SAT实例是不可满足的。
我们使用反证法。假设在这种情况下,图 G 中存在一个尺寸为 m 的独立集 S

  • 根据性质1,S 必须恰好包含每个子句组中的一个顶点(因为尺寸已达上限 m)。
  • 根据性质2,从 S 我们可以导出一个一致的真值赋值。
  • 由于 S 包含了每个子句的一个顶点,这意味着导出的真值赋值满足了每个子句(因为每个被选中的顶点代表该子句的一个被满足的请求)。
    但这与“3-SAT实例不可满足”的前提矛盾。因此,假设不成立。当3-SAT实例不可满足时,图 G 的最大独立集尺寸小于 m。我们的算法会得到尺寸小于 m 的独立集,从而正确报告“不可满足”。

一个关键的细节:为什么需要组内边(E1)?

在归约中,组内边(E1)至关重要。考虑一个修改版的归约:我们只添加冲突边(E2),而省略组内边(E1)。

  • 此时,一个独立集可能从一个子句中选取多个顶点。
  • 假设一个3-SAT实例是不可满足的。虽然我们仍然不可能找到一个包含每个子句至少一个顶点的独立集(否则就能导出满足赋值),但我们可能会找到一个尺寸为 m 的独立集,它通过在某些子句中选取两个顶点,而在另一些子句中跳过顶点来凑够 m 个顶点。
  • 这样,我们的算法在遇到不可满足实例时,仍可能获得一个尺寸为 m 的独立集,从而错误地试图导出一个满足赋值,或者导致矛盾。这使得情况二的正确性证明失效。

因此,组内边(E1)确保了独立集的尺寸上限 m 与“覆盖所有子句”紧密绑定,这是归约正确的关键。

总结

本节课中,我们一起学*了如何通过归约来证明独立集问题是NP难的。

  1. 我们首先回顾了独立集问题的定义。
  2. 我们选择了3-SAT这个已知的NP难问题作为归约的起点。
  3. 我们详细描述了如何将任意一个3-SAT实例转化为一个无向图 G:为每个子句的每个文字创建顶点,并添加两种边(组内边和冲突边)来编码子句内部和变量赋值的一致性约束。
  4. 我们给出了完整的归约算法,并证明了其正确性,核心在于:原始3-SAT公式可满足当且仅当构造出的图 G 存在尺寸为 m(子句数)的独立集
  5. 最后,我们通过一个思考题强调了归约中细节(组内边)的重要性。

这个归约是NP完全性理论中的一个经典范例。一旦我们证明了独立集是NP难的,就可以用它作为跳板,通过更简单的归约去证明其他问题(如团问题、顶点覆盖问题等)也是NP难的,就像推倒一系列多米诺骨牌。在接下来的课程中,我们将看到更多这样的归约。

029:有向哈密顿路径问题是NP难的 🧩

在本节课中,我们将学*如何将一个已知的NP难问题(3SAT)归约到有向哈密顿路径问题,从而证明后者也是NP难的。我们将详细讲解归约的构造过程、工作原理以及正确性证明。


概述

我们将从3SAT问题出发,构造一个有向图。这个构造的核心思想是:利用图中的“钻石”结构序列来编码对布尔变量的赋值,并通过添加额外的“约束顶点”来确保只有满足所有子句的赋值才能对应图中的哈密顿路径。通过这种构造,我们将证明,原始3SAT实例是可满足的,当且仅当构造出的有向图存在一条从起点S到终点T的哈密顿路径。


问题定义

首先,让我们明确两个问题的定义。

3SAT问题

  • 输入:一个包含n个布尔变量和m个子句的布尔公式,每个子句是三个文字(变量或其否定)的析取(OR)。
  • 输出:一个满足所有子句的变量赋值(如果存在),否则报告“不可满足”。

有向哈密顿路径问题

  • 输入:一个有向图 G=(V, E) 以及两个指定的顶点 s(起点)和 t(终点)。
  • 输出:一条从 st 的路径,该路径恰好访问图中的每个顶点一次(即一条 s-t 哈密顿路径),或者报告这样的路径不存在。

我们的目标是展示:如果存在一个解决有向哈密顿路径问题的“黑盒”算法,那么我们就可以利用它来解决3SAT问题


归约构造思路

上一节我们介绍了归约的基本概念。本节中,我们来看看如何具体地将一个3SAT实例转化为一个有向图实例。

归约的核心是构造一个特殊的有向图。这个图的主体由一串“钻石”单元连接而成,每个钻石对应一个布尔变量。哈密顿路径在遍历每个钻石时,必须选择“向上”或“向下”走,这个选择将被解释为对相应变量的赋值(例如,向下=True,向上=False)。

然而,仅仅编码赋值是不够的,我们还需要强制路径对应的赋值满足所有子句。为此,我们将为每个子句添加一个“约束顶点”。通过精心设计从钻石内部到约束顶点的边,我们确保:只有当路径在某个钻石中选择了满足该子句所要求的方向时,它才能“绕道”访问对应的约束顶点,并顺利返回原路径。这样,一条能够访问所有顶点(包括所有约束顶点)的哈密顿路径,必然对应一个满足所有子句的赋值。


构造详解

以下是构造有向图 G 的具体步骤。

1. 创建变量钻石链

对于3SAT实例中的每个变量 x_i(共n个),我们创建一个钻石结构。这些钻石从左到右连接,形成一个“项链”。第一个钻石的左顶点是起点 s,最后一个钻石的右顶点是终点 t
每个钻石有两条内部路径:一条“向上”路径,一条“向下”路径。我们将每条内部路径细分为 2m + 1 段,从而引入 2m 个内部顶点。这些顶点将被用于连接约束顶点。

公式化描述

  • 总钻石数:n
  • 每个钻石内部路径上的顶点数:2m(为约束预留)
  • 起点 s 和终点 t 各一个顶点。
  • 每个钻石除内部路径外,还有顶部、底部和左右连接点,共约 3 个顶点。
  • 约束顶点数:m
  • G 的总顶点数约为 2n*m + m + 3n + 2

2. 添加约束顶点及边

对于第 j 个子句(共m个),我们添加一个约束顶点 C_j
对于子句中的每个文字(例如 x_i¬x_i):

  • 如果文字是 x_i(要求 x_i = True),则在第 i 个钻石的“向下”路径上,选择预留給第 j 对约束的上方顶点,添加一条边从该顶点指向 C_j,并添加另一条边C_j 指向该对的下方顶点
  • 如果文字是 ¬x_i(要求 x_i = False),则在第 i 个钻石的“向上”路径上,选择预留給第 j 对约束的下方顶点,添加一条边从该顶点指向 C_j,并添加另一条边C_j 指向该对的上方顶点

这样设计的目的是:只有当哈密顿路径按照子句要求的方向(True对应向下,False对应向上)遍历对应的钻石时,它才有可能在途中“绕道” C_j 顶点并返回,而不重复或遗漏任何顶点。


归约的执行过程

基于上述构造,我们可以描述整个归约算法:

  1. 输入:一个3SAT公式 φ,包含n个变量和m个子句。
  2. 构造:根据 φ,按照上述步骤构造有向图 G,并指定起点 s 和终点 t
  3. 调用子程序:将 (G, s, t) 输入到假定的“有向哈密顿路径问题”求解算法(黑盒)中。
  4. 输出转换
    • 情况A:如果黑盒返回一条 s-t 哈密顿路径 P,则根据 P 遍历每个钻石的方向(上/下)提取出一个变量赋值 A。将此赋值 A 作为3SAT的解返回。
    • 情况B:如果黑盒报告不存在 s-t 哈密顿路径,则报告3SAT公式 φ 不可满足。

正确性证明

我们需要证明这个归约是正确的:即原始3SAT实例 φ 是可满足的,当且仅当构造的图 G 存在一条 s-t 哈密顿路径。

关键观察

  1. 任何 G 中的哈密顿路径都必须按顺序遍历所有钻石。
  2. 在遍历每个钻石时,路径必须完整地走完一条内部路径(全部向上或全部向下),不能混合。
  3. 路径只能在遍历某个钻石的内部路径时,“绕道”访问一个约束顶点并立即返回,不能从约束顶点跳到其他钻石。
  4. 根据边的构造方式,只有当路径在钻石 i 中选择了满足子句 j 所要求的方向时,才有可能从该钻石访问约束顶点 C_j

证明(⇒)如果 φ 可满足,则 G 有哈密顿路径。

Aφ 的一个满足赋值。我们可以构造一条哈密顿路径 P

  • Ps 开始。
  • 对于每个变量 x_i,如果 A(x_i) = True,则 P 在钻石 i 中走向下路径;如果为 False,则走向上路径。
  • 对于每个子句 C_j,由于 A 满足它,至少有一个文字被满足。选择第一个这样的文字对应的钻石 i。当 P 遍历到钻石 i 中预留給约束 j 的那对顶点时,让它绕道访问顶点 C_j 并返回,然后继续完成钻石 i 的遍历。
  • P 最终到达 t
    容易验证,P 恰好访问每个顶点一次,是一条哈密顿路径。

证明(⇐)如果 G 有哈密顿路径,则 φ 可满足。

PG 中的一条 s-t 哈密顿路径。

  1. 根据观察1和2,P 定义了一个对每个变量的赋值 A(向下=True,向上=False)。
  2. P 必须访问所有约束顶点 C_j。根据观察3和4,P 访问某个 C_j 的唯一方式是,当它遍历某个钻石 i 时,其方向恰好满足了子句 C_j 中对变量 x_i 的要求。
  3. 因此,对于每个子句 C_j,赋值 A 都至少满足其中一个文字。这意味着 A 满足整个公式 φ

综上,归约是正确的。


总结

本节课中我们一起学*了如何将3SAT问题归约到有向哈密顿路径问题。

  1. 我们首先回顾了3SAT和有向哈密顿路径问题的定义。
  2. 然后,我们详细阐述了归约的构造思想:用钻石链编码变量赋值,用约束顶点强制满足子句。
  3. 接着,我们逐步讲解了如何具体构造对应的有向图 G,包括创建钻石、细分路径以及添加约束边。
  4. 最后,我们严格证明了归约的正确性,展示了 φ 可满足性与 G 存在哈密顿路径之间的等价关系。

由于3SAT是NP难的(由Cook-Levin定理保证),而我们的归约是多项式时间内完成的,因此我们得出结论:有向哈密顿路径问题是NP难的。这个结果为证明其他更复杂的问题(如下一讲将要介绍的旅行商问题)的NP难性奠定了基础。

030:证明旅行商问题是NP难的 🧳

在本节课中,我们将学*如何证明著名的旅行商问题是NP难问题。我们将通过一个归约来完成证明,这个归约将已知的NP难问题——无向哈密顿路径问题,转化为旅行商问题。

概述

上一节我们证明了有向哈密顿路径问题是NP难的。本节中,我们将利用这个结果,通过一个归约来证明旅行商问题同样是NP难的。这个归约的核心思想与我们之前证明“无环最短路径”问题是NP难时所用的方法类似,但需要处理图的有向与无向性质差异。

无向哈密顿路径问题

首先,我们明确归约的起点:无向哈密顿路径问题。该问题的输入与有向版本类似,但图是无向的。具体来说,给定一个无向图、一个起点 S 和一个终点 T,目标是计算一条从 ST 的路径,该路径需要恰好访问每个顶点一次。如果图中不存在这样的路径,算法应正确报告。

我们知道有向哈密顿路径问题是NP难的。虽然我们尚未直接证明无向版本也是NP难的,但在这两个版本之间存在相当简单的相互归约。因此,基于有向版本的NP难性,无向哈密顿路径问题同样是NP难的。本节我们将以此作为已知的NP难问题。

归约计划

我们的计划是将这个已知的NP难问题——无向哈密顿路径问题,归约到旅行商问题,从而证明旅行商问题也是NP难的。

与之前几节中需要将逻辑问题(如3-SAT)归约到图问题不同,本节的两个问题都涉及在无向图中寻找特定路径,因此它们之间的联系更加直观,归约过程也相对简单。

我们假设可以访问一个能解决旅行商问题的子程序(一个“黑盒”)。我们的目标是:给定一个无向哈密顿路径问题的实例,如何利用这个旅行商问题求解器,来获知原图中是否存在哈密顿路径。

归约构造

归约的起点是一个无向哈密顿路径问题的实例。例如,我们可能得到一个如右图所示的四顶点图。图中除了底部两个顶点之间的边缺失外,其他边都存在。可以注意到,在这个特定图中,实际上不存在从 ST 的哈密顿路径。

我们将通过两个简单的步骤来修改这个图:

  1. 添加额外顶点:我们向图中添加一个名为 v₀ 的新顶点。我们只将 v₀ 与顶点 ST 相连。这样就得到了一个新图,它比原图多一个顶点和两条边。
  2. 构造完全图并分配边成本:旅行商问题要求输入是一个完全图,并且每条边都有成本。因此,我们需要将上一步得到的图补充为完全图,并为所有边分配成本。
    • 对于原图中存在的边(包括我们添加的两条连接 v₀ST 的边),我们赋予成本 0
    • 对于所有缺失的、我们在这一步补充的边,我们赋予成本 1

现在,我们得到了一个完全图 G‘,并且每条边都有成本(绿色边成本为1,其他边成本为0)。此时,我们有了可以输入到假设的旅行商问题求解器中的实例。

我们调用这个求解器,它会返回一个最小成本的旅行商环路。这个环路的成本要么是 0(意味着它只使用了成本为0的边),要么至少为 1(意味着它使用了至少一条成本为1的边)。这个成本值将成为我们判断原图是否存在哈密顿路径的关键线索。

具体来说:

  • 如果旅行商求解器返回一个总成本为 0 的环路,我们如何从中提取出原图的 S-T 路径呢?在接下来的正确性证明中我们会看到,任何成本为0的环路必然包含连接额外顶点 v₀ 的那两条边。我们只需从环路中移除这两条边,剩下的部分将是一条从 ST、访问了原图所有顶点的路径,即原图的一个哈密顿路径。
  • 如果返回的环路成本大于 0,那么我们就得出结论:原图 G 中不存在哈密顿路径。

这就是整个归约过程。它只调用了一次假设的旅行商问题求解器。在调用之外,它只需要构造图 G‘,这最多需要 O(n²) 的时间(n是顶点数)。因此,这确实是一个有效的归约:只调用一次子程序,并辅以多项式时间的额外工作。

正确性证明

现在我们来证明这个归约的正确性。记住归约的方向:我们从无向哈密顿路径问题归约到旅行商问题。我们希望,无论初始图 G 是否包含哈密顿路径,这种状态都能反映在我们所构造的旅行商问题实例 G‘ 的最优环路成本上。

我们需要证明两点:

  1. 如果 G 有哈密顿路径,那么 G‘ 存在成本为 0 的环路。
  2. 如果 G 没有哈密顿路径,那么 G‘ 的所有环路成本都严格大于 0

观察:成本为0的环路

首先观察一下,如果存在一个成本为0的环路,它必须是什么样子。额外顶点 v₀ 在原图 G 中只与 ST 相连。在 G‘ 中,连接 v₀ST 的边成本为0,而连接 v₀ 与其他任何顶点的边成本都为1。

因此,任何旅行商环路都必须访问 v₀,而要以0成本做到这一点,唯一的方法就是使用那两条连接 v₀ST 的成本为0的边。

给定这样一个0成本的环路,如果我们移除这两条已知的边(连接 v₀ST 的边),剩下的是什么?我们得到了一条访问除 v₀ 外所有顶点的路径,其端点正是 ST

由于环路的总成本为0,这条 S-T 路径中的所有边成本也必须为0。而记住,只有那些在原图 G 中存在的边(或我们添加的两条特殊边)成本才为0。因此,这条 S-T 路径必然只由原图 G 中的边组成。这意味着它本身就是原图 G 中的一条哈密顿路径。

结论:如果旅行商求解器返回一个总成本为0的环路,我们可以立即从中提取出输入图 G 的一个哈密顿路径。

情况一:G 有哈密顿路径

假设原图 G 确实有一条哈密顿路径。我们声称,这样构造出的旅行商实例 G‘ 确实存在一个成本为0的环路。

构造方法很简单:取 G 中的那条哈密顿路径,然后添加上我们添加的那两条连接 STv₀ 的边。这样,我们就将一个哈密顿路径变成了一个环路。这个环路只使用了 G‘ 中成本为0的边(原图的边加上那两条特殊边)。因此,G‘ 存在成本为0的环路。

当我们调用假设的完美旅行商求解器时,它会返回一个成本为0的环路。根据之前的观察,我们可以从这个环路中提取出一个哈密顿路径。因此,在这种情况下,归约会正确地找到并返回一条哈密顿路径。

情况二:G 没有哈密顿路径

假设原图 G 没有哈密顿路径。那么,我们构造的旅行商实例 G‘ 不可能存在成本为0的环路。因为从任何成本为0的环路中,我们都能提取出一条哈密顿路径。既然这样的路径不存在,那么成本为0的环路也不可能存在。

因此,G‘ 的最小环路成本严格大于0。当我们调用旅行商求解器时,它会告诉我们最小环路成本大于0。此时,归约会查看这个“昂贵”的环路,并正确地得出结论:输入的图 G 没有哈密顿路径。

总结

在本节课中,我们一起学*了如何证明旅行商问题是NP难的。我们通过一个归约,将已知的NP难问题——无向哈密顿路径问题,转化为旅行商问题。这个归约的构造直观而简洁:

  1. 向原图添加一个只连接起点和终点的额外顶点。
  2. 将图补全为完全图,原边成本设为0,新增边成本设为1。
  3. 利用旅行商问题求解器计算最小成本环路。
  4. 根据环路成本是0还是大于0,判断原图是否存在哈密顿路径。

我们详细证明了该归约的正确性,涵盖了原图有哈密顿路径和没有哈密顿路径两种情况。由于无向哈密顿路径问题是NP难的,通过这个多项式时间的归约,我们证明了旅行商问题同样是NP难的。

接下来,我们将看到即使是只涉及数字的问题(例如即将介绍的背包问题)也可以是NP难的。

031:子集和问题是NP难的 🧩

在本节课中,我们将学*如何证明一个看似简单的数字问题——子集和问题——是NP难的。我们将通过从独立集问题到子集和问题的归约来完成证明。这个证明过程也将同时证明背包问题和最小化最大完工时间问题是NP难的。

概述

子集和问题是一个关于数字的简单问题:给定一组正整数和一个目标值,判断是否存在一个子集,其元素之和恰好等于该目标值。我们将展示,尽管这个问题看起来很简单,但它实际上是NP难的。证明方法是将其与一个已知的NP难问题——独立集问题——联系起来。

子集和问题定义

子集和问题的输入包括:

  • n 个正整数:A1, A2, ..., An
  • 一个目标值 T

问题的目标是:判断是否存在一个子集 S ⊆ {A1, A2, ..., An},使得该子集中所有元素的和恰好等于 T

用数学语言描述,即:
是否存在 S ⊆ {1, 2, ..., n},使得 Σ_{i∈S} Ai = T?

归约计划

为了证明子集和问题是NP难的,我们需要从独立集问题归约到它。这意味着,如果我们假设有一个能高效解决子集和问题的“黑盒子”(子程序),那么我们就可以利用它来高效地解决独立集问题。由于独立集问题是NP难的,这也就证明了子集和问题同样是NP难的。

上一节我们介绍了归约的基本思想,本节中我们来看看具体的构造方法。我们需要将图论问题(独立集)的实例,转化为一个数字问题(子集和)的实例。

归约构造思路

给定一个图 G = (V, E),其中顶点集 V = {v1, v2, ..., vn},边集 E = {e1, e2, ..., em},以及一个目标独立集大小 k。我们的目标是构造一组数字和一个目标值 T,使得存在和为 T 的子集,当且仅当图 G 中存在一个大小至少为 k 的独立集。

一个关键的观察是:为了使构造出的子集和实例是“困难”的,我们必须使用非常大的数字(例如,指数级大小)。因为如果数字很小,子集和问题(以及更一般的背包问题)实际上可以在多项式时间内通过动态规划解决。

以下是构造的核心思想:

  1. 为图中的每个顶点 vi 创建一个数字 Ai
  2. 为图中的每条边 ej 创建一个数字 Bj
  3. 每个数字 Ai 由一个高位数字(代表顶点)和 m 个低位数字(编码与该顶点相连的边)组成。
  4. 数字 Bj 的作用是“修正”在求和过程中可能出现的 0 位。

具体构造方法

让我们通过一个简单的例子来理解这个构造。假设我们有一个4个顶点的环(4-cycle)。

首先,为每个顶点创建一个数字 Ai。每个 Ai 的最高位(第 m+1 位)设为 1,代表这是一个顶点。后面的 m 位(对应 m 条边)中,如果边 ej 与顶点 vi 相连,则第 j 位为 1,否则为 0

对于4-cycle:

  • A1 (对应 v1,与边 e1, e4 相连): 1 1001 (即 11001)
  • A2 (对应 v2,与边 e1, e2 相连): 1 1100 (即 11100)
  • A3 (对应 v3,与边 e2, e3 相连): 1 0110 (即 10110)
  • A4 (对应 v4,与边 e3, e4 相连): 1 0011 (即 10011)

现在,考虑一个大小为2的独立集,例如 {v1, v3}。计算 A1 + A3 = 11001 + 10110 = 21111。有趣的是,另一个独立集 {v2, v4} 的和 A2 + A4 = 11100 + 10011 = 21111 也是这个值。而非独立集(如 {v1, v2})的和则是不同的值(22101)。

然而,对于奇数环(如5-cycle),这个简单构造会失败,因为不同的独立集可能产生不同的和(低位可能出现 0)。为了解决这个问题,我们引入边数字 Bj

对于每条边 ej,我们创建数字 Bj,它仅在对应的第 j 个低位上是 1,其余位都是 0。例如,对于5条边,B1 = 10000, B2 = 01000, B3 = 00100, B4 = 00010, B5 = 00001

现在,我们的目标值 T 设置为:高位等于我们寻找的独立集大小 k,所有 m 个低位都等于 1。例如,对于寻找 k=2 的独立集,T = 2 11111

如果一个顶点子集 S 是一个独立集,那么对于每条边,最多只有一个端点在 S 中。因此,在求和 Σ_{vi∈S} Ai 中,每个低位要么是 1(该边的一个端点在 S 中),要么是 0(该边的两个端点都不在 S 中)。对于结果为 0 的低位,我们可以简单地加上对应的 Bj 将其修正为 1。这样,我们就能得到目标和 T

反之,如果一个顶点子集 S 不是独立集,即包含某条边的两个端点,那么在求和时,对应的低位将会是 2(因为两个 1 相加)。此时,即使加上 Bj(值为 1),也会得到 3,而无法通过选择其他数字(因为只有 AiBj 能贡献 1)将其降回 1。因此,无法达到目标 T

正式归约算法

基于上述思路,我们可以形式化地描述从独立集问题到子集和问题的归约算法。

输入:一个图 G = (V, E)|V| = n|E| = m
输出:图 G 的最大独立集。

算法步骤

  1. 构造数字
    • 对于每个顶点 vi,令 Ai = 10^m + Σ_{ej ∈ Incident(vi)} 10^(m-j)。这里 10^m 提供高位的 1,求和项在 vi 关联的边对应的低位上放置 1
    • 对于每条边 ej,令 Bj = 10^(m-j)。这仅在对应 ej 的低位上有一个 1
  2. 搜索最大独立集大小 k
    • k = n 开始向下迭代到 1(也可以使用二分搜索优化)。
    • 对于每个 k,构造目标值 T = k * 10^m + (10^m - 1) / 9。这个数字的高位是 k,后面 m 位全是 1
    • 调用假设存在的子集和问题子程序,询问在数字集合 {A1,...,An, B1,...,Bm} 中,是否存在和为 T 的子集。
    • 如果子程序返回“是”并给出一个子集,则从该子集中提取出所有 Ai 对应的顶点 vi。这些顶点构成了一个大小为 k 的独立集。由于我们是降序搜索 k,这第一个找到的 k 就是最大独立集大小,返回这些顶点即可。
    • 如果子程序返回“否”,则继续尝试更小的 k

正确性证明

现在我们来论证这个归约的正确性。我们需要证明两点:

  1. 完备性:如果图 G 有一个大小为 k 的独立集,那么构造出的子集和实例存在和为 T 的子集。
  2. 可靠性:如果构造出的子集和实例存在和为 T 的子集,那么图 G 有一个大小为 k 的独立集。

证明

  • 完备性:设 SG 的一个大小为 k 的独立集。我们构造子集和问题的解如下:
    • 包含所有对应 S 中顶点的数字 Ai
    • 对于每条边 ej,如果它的两个端点都不在 S 中(即求和 Σ_{vi∈S} Ai 中第 j 位为 0),则包含数字 Bj
      由于 S 是独立集,每条边最多有一个端点在 S 中,所以初始求和 Σ Ai 中每个低位是 01。通过添加 Bj,我们将所有 0 位修正为 1。同时,高位恰好有 k1(来自 kAi)。因此,总和恰好等于目标 T
  • 可靠性:假设存在一个子集 X ⊆ {A1,...,An, B1,...,Bm},其和为 T
    • 观察高位:只有 Ai 在高位有 1Bj 的高位都是 0。由于 T 的高位是 k,所以 X 中必须恰好包含 kAi。设这 kAi 对应的顶点集合为 S
    • 观察低位:T 的每个低位都是 1。对于任意边 ej,能影响第 j 位的数字只有 Bj 和关联边 ej 的两个顶点的 Ai。如果 S 包含了 ej 的两个端点,那么即使不加 Bj,第 j 位已经是 2;如果加 Bj 则变成 3,都无法得到 1。因此,S 不可能同时包含 ej 的两个端点。这意味着 S 中的任意两个顶点都不相邻,即 S 是图 G 的一个独立集,且大小为 k

综上,归约是正确的。由于独立集问题是NP难的,且该归约是多项式时间的,因此子集和问题也是NP难的。

推论:其他问题的NP难性

子集和问题实际上是两个我们熟悉问题的特例:

  1. 背包问题:子集和是背包问题在物品价值等于物品重量时的特例。
  2. 最小化最大完工时间问题(在 identical machines 上):可以证明,子集和问题可以归约到该问题。

因此,通过证明子集和问题是NP难的,我们也一并证明了背包问题最小化最大完工时间问题是NP难的。

总结与后续

本节课中我们一起学*了如何证明子集和问题是NP难的。我们通过一个巧妙的归约,将图上的独立集问题转化为数字的子集和问题。这个证明不仅解决了子集和问题的复杂度分类,还连带证明了背包问题和最小化最大完工时间问题的NP难性。

通过这一系列NP难问题的归约证明,希望大家能够:

  1. 理解为什么之前课程中提到的许多问题需要启发式算法或指数级算法。
  2. 积累一批已知的NP难问题,作为未来证明新问题NP难性的基础。
  3. 掌握NP难性证明的基本模式和技巧,即使细节复杂,其核心思想是清晰可循的。

在接下来的课程中,你可以选择深入探讨NP完全理论背后的数学基础(如P vs NP问题),也可以跳转到具体的算法案例研究,看看如何将这些算法工具应用于解决实际中的复杂问题。

032:积累计算难解性证据 🧩

在本节课中,我们将学*如何为一个计算问题(例如旅行商问题)的难解性积累证据。核心思想是,通过证明大量其他问题都能“归约”到该问题,从而表明如果该问题存在多项式时间算法,那么所有这些问题也都能被高效解决。这反过来为该问题的内在难解性提供了强有力的证据。


从旅行商问题说起

上一节我们介绍了NP难问题的概念及其算法意义。本节中,我们来看看如何为一个具体问题(如旅行商问题)的难解性构建坚实的证据。

考虑旅行商问题(TSP)。早在1967年,Jack Edmonds就猜想不存在解决TSP的多项式时间算法。时至今日,我们仍不知道这个猜想是否正确。如果我们要采纳“TSP是难解的”这一工作假设,该如何为此积累证据呢?

许多杰出的研究者在过去70年里尝试并失败,这固然是间接证据。但我们能否做得更好,积累更强的证据呢?


核心策略:大规模归约

关键思想在于证明:一个针对TSP的多项式时间算法,不仅能解决TSP这一个未解问题,还能自动解决成千上万个其他未解问题。

我们可以通过以下两步为TSP的难解性积累证据:

  1. 首先,确定一个庞大的计算问题集合,我们称之为集合 C
  2. 其次,证明集合 C 中的每一个问题都能归约到旅行商问题。

这样,一个针对TSP的多项式时间算法,就能自动转化为解决集合 C 中所有问题的算法。反之,如果集合 C 中哪怕只有一个问题是难解的(即不存在多项式时间算法),也足以证明TSP同样是难解的。

注意:集合 C 越大,即你能归约到TSP的问题越多,你为TSP难解性构建的证据就越强。因此,我们希望 C 尽可能大。


归约的定义

我们一直并将继续使用以下定义:如果问题A能通过仅调用多项式次解决问题B的子程序,外加多项式量级的额外计算工作来解决,那么问题A就归约到问题B。

用公式表示,若存在多项式时间算法,使得:

解决A的算法 = 多项式次调用“解决B的黑盒” + 多项式时间的额外计算

则称 A ≤ₚ B

这种归约专门用于我们的目的(关注算法意义),它能将计算可行性从一个问题传递到另一个问题,反之亦然,也能传递计算难解性。这类归约有一个特定名称:库克归约(Cook Reduction),也称为多项式时间图灵归约。


选择归约集合的挑战

我们是否应该选择所有计算问题作为集合 C 呢?这过于雄心勃勃。尽管TSP很难,但世界上存在比TSP困难得多的计算问题,它们不可能归约到TSP。

有些问题甚至是不可判定的,这意味着无论给予多少时间,计算机都无法解决它们。最著名的例子是停机问题:给定一段程序(例如一千行Python代码),判断它最终会停止还是陷入无限循环。艾伦·图灵在1936年证明,不存在解决一般性停机问题的有限时间算法。

图灵1936年的论文之所以重要,有两点原因:

  1. 他引入了图灵机这一形式化数学模型,定义了计算机能做什么。
  2. 通过定义计算机的能力,他得以研究其局限性,并精确证明了计算机无法解决停机问题。

因此,从计算机科学诞生的第一天起,我们就深刻认识到计算机的局限性和解决困难计算问题时妥协的必要性。


重新思考集合 C

经过关于停机问题的讨论,TSP似乎不再那么“糟糕”了。我们虽然不知道如何在多项式时间内解决TSP,但肯定知道如何在有限(尽管是指数级)时间内通过穷举搜索解决它。这意味着停机问题不可能归约到TSP,因为如果存在这样的归约,将产生一个解决停机问题的有限算法,而这已被证明不存在。

让我们回到绘图板:必须确定要将哪些问题归约到TSP。我们希望集合 C 尽可能大,但现在已经明白不能包含所有问题。

如果限制TSP无法涵盖像停机问题这类问题的原因,在于TSP本身可以通过朴素的穷举搜索解决,那么我们或许至少可以将集合 C 定义为所有同样能通过朴素穷举搜索很好解决的问题。这些是可能合理归约到TSP的问题。

这听起来合理,但“所有同样能通过朴素穷举搜索很好解决的问题”在数学上究竟意味着什么?我们能否给出一个形式化的数学定义?

答案是肯定的。在下一个视频中,我们将开始为这个形式化的数学定义奠定基础。


本节总结

本节课中,我们一起学*了如何为计算问题的难解性积累证据:

  • 核心策略是证明一个庞大的问题集合 C 都能归约到目标问题(如TSP)。
  • 我们回顾了库克归约的正式定义。
  • 我们认识到集合 C 不能包含所有问题,特别是那些不可判定的问题(如停机问题)。
  • 因此,合理的思路是将 C 定义为在某种意义下与TSP“难度相当”的问题集合,即那些可能通过类似穷举搜索方式解决的问题。

这为我们接下来形式化定义NP类NP难概念做好了准备。

033:决策、搜索与优化问题 🧩

在本节中,我们将学*计算问题的三种主要类型:决策问题、搜索问题和优化问题。理解这些分类是正式定义NP复杂性类的基础。我们将探讨每种问题的特点、区别以及它们之间的相互联系。


三种计算问题类型 📝

在正式定义“可通过朴素穷举搜索解决的问题”(即NP类)之前,让我们先回顾并分类迄今为止所研究的计算问题的不同输入输出格式。

以下是三种不同类型的计算问题,它们似乎按复杂度递增的顺序排列。

1. 决策问题

决策问题是指算法仅需输出一个二元答案(是或否)的问题。

例如,3-SAT问题的决策版本。输入与往常一样,是一个3-SAT实例(即一组最多包含三个文字的析取子句)。对于决策版本,算法只需判断该实例是否可满足,并回答“是”(可满足)或“否”(不可满足)。决策版本不负责在解存在时实际生成一个满足赋值。

决策问题在构建计算复杂性理论时非常方便,但在实际应用中,它们是我们将要讨论的三种问题中最不常见的一种。通常,应用需要的是一个可行的解决方案,而不仅仅是知道它是否存在。

相应地,在整个视频系列中,我们只见过一个决策问题,那是在介绍证明问题为NP难的两步法时。在那个归约中,我们将有向哈密顿路径问题归约到了“无环最短路径问题”。如果你回顾那个归约,我们实际上使用的是有向哈密顿路径的决策版本。因此,算法仅根据图中是否存在哈密顿路径来输出“是”或“否”。

2. 搜索问题

接下来是搜索问题,这类问题在实际应用中确实会出现。

在这里,算法的职责是:给定一个实例,要么返回一个可行解,要么正确报告不存在可行解。我们在这个视频系列中已经见过几个搜索问题:SAT和3-SAT是典型版本,我给你一组文字的析取子句,你必须报告一个满足的真值赋值,或者正确报告不存在这样的赋值。图着色也是一个搜索问题,你需要展示一个K着色方案,或者正确报告它不可K着色。类似地,我们在前一章用于NP难归约的哈密顿路径版本也是搜索问题,要么报告一条哈密顿路径,要么正确报告不存在。子集和问题,现在想来,也是一个搜索问题。

3. 优化问题

我们在这个视频系列中讨论的大多数问题都是优化问题。

优化问题的算法不仅需要判断是否存在可行解,而且如果至少存在一个可行解,它还需要负责返回最好的那个。在优化问题中,你还需要指定一个要最大化或最小化的目标函数。算法需要在所有可行解中,返回一个具有最佳可能目标函数值的解。如果不存在可行解,算法应像往常一样正确报告这一事实。

我们一直在研究几个不同的优化问题,例如:旅行商问题(你想要一个最小成本的环游)、背包问题(你想要一个最大价值的可行解),以及类似的最小割、最大覆盖和影响力最大化问题。

所有三种类型的问题都涉及“可行解”的概念,其具体含义因问题而异。有时它对应于满足赋值,有时可能对应于哈密顿路径或旅行商环游等。对于优化问题,目标函数也是问题特定的,例如最小化总成本或最大化总价值等。


复杂性类与问题类型的关系 🔗

正如我们将在本章中讨论的,复杂性类通常只关注这三种类别中的一种问题,以避免类型检查错误。

因此,在下一个视频中正式定义复杂性类NP时,它将定义为一类搜索问题。我需要提醒你,大多数复杂性理论和算法书籍都根据决策问题而非搜索问题来定义复杂性类NP。他们这样做是因为这对于发展复杂性理论更为方便。我之所以不这样做,是因为决策问题与我们在这个视频系列中关注的自然算法问题距离更远。

不过,你其实不必担心这种区别。因为NP难度的所有算法含义,包括P是否不等于NP猜想是真是假,无论你根据决策问题还是搜索问题来定义复杂性类NP,所有这些都完全保持不变。


优化问题与搜索问题的转换 🔄

现在,你可能会担心,将复杂性类NP的定义限制在搜索问题上,会把像旅行商问题这样的优化问题排除在外,而这些显然是我们非常关心的问题。

但不用担心,优化问题有一个自然的搜索版本。例如,你可以通过以下方式将旅行商问题转化为搜索问题:输入除了像往常一样指定一个带边成本的图外,还指定一个目标函数值。因此,搜索问题可以是:例如,如果存在成本不超过1000的旅行商环游,请给我一个;否则,正确报告不存在质量如此高的环游。或者在背包问题中,你可以说:给我返回一个总价值至少为10,000的物品集合,或者正确报告不存在这样的物品子集。

一般来说,你可以通过指定目标函数值T,并询问是否存在目标函数值至少与T一样好的可行解,从而将优化问题转化为搜索问题。

优化问题的搜索版本只会更容易。如果你能解决优化问题,你当然能解决它的搜索版本。但实际上,也存在相反方向的归约。例如,对于旅行商问题,如果我给你一个能高效解决搜索版本的黑盒子子程序,该程序以TSP实例和目标环游成本作为输入,要么返回一个成本不超过目标的环游,要么正确报告不存在这样的环游,那么我可以在一个循环中反复使用这个子程序,对目标函数值T进行二分搜索,从而计算出最小成本的旅行商环游。因此,给定一个能解决搜索版本的黑盒子,我实际上也能解决优化问题。

当然,这通常不是你在实践中处理优化问题的方式。一般来说,对于优化问题,你会希望像我们在本书系列中一直做的那样直接解决它。但仅就本章的目的而言,我们只是试图弄清楚哪些问题是多项式时间可解的,哪些似乎不是,因此没有理由区分搜索版本和优化版本。其中一个多项式时间可解当且仅当另一个也是;类似地,其中一个NP难当且仅当另一个也是。


总结 📚

在本节课中,我们一起学*了计算问题的三种主要类型:决策问题、搜索问题和优化问题。我们明确了它们的定义、区别以及在实际应用和理论分析中的角色。特别地,我们了解到优化问题可以自然地转化为等价的搜索问题,并且对于判断计算复杂性的目的,这两者在多项式时间可解性和NP难度上是等价的。有了这些预备知识,现在我们知道当前的重点将完全放在搜索问题上,我们已准备好在下个视频中正式定义复杂性类NP。

034:NP类问题与易于验证的解

在本节课中,我们将要学*计算复杂性理论的核心概念之一:NP类。我们将了解NP类问题的定义、其与“朴素穷举搜索”的关系,以及为什么NP完全性问题是理解计算难度的关键。


概述:什么是NP类?

上一节我们介绍了如何通过归约来证明问题的难度。本节中,我们来看看一个形式化的概念:NP类。NP类包含了所有那些“解易于验证”的搜索问题。这意味着,如果有人给你一个候选答案,你可以在多项式时间内快速检查它是否正确。


NP类的正式定义

NP类专门由搜索问题构成。一个搜索问题包含可行解的概念,算法需要返回一个可行解,或者正确报告不存在可行解。

一个搜索问题属于NP类,需要满足两个条件:

以下是两个核心条件:

  1. 多项式长度描述:任何候选可行解都可以用输入规模的多项式长度的比特串来描述。即,存在常数 cd,使得描述长度不超过 c * n^d,其中 n 是输入规模。
  2. 高效可验证性:给定一个候选解(其长度已由条件1限定),存在一个多项式时间算法,能够验证该候选解是否确实是一个可行解。即,验证时间不超过某个 c' * n^d'

这就是计算机科学中最重要的定义之一:NP类的定义。需要提醒的是,NP代表“非确定性多项式时间”,源于其另一种基于非确定性图灵机的等价定义。但在算法研究的语境下,我们始终应将其理解为“具有高效可验证解的问题”。


NP类问题示例

我们之前见过的许多问题都满足这两个条件。

以下是几个典型的NP类问题示例:

  • 旅行商问题的搜索版本:给定图、边成本和目标值 T,要求找到总成本不超过 T 的环游,或报告不存在。
    • 候选解:一个顶点序列,描述长度为 O(n log n)
    • 验证:检查序列是否为合法环游(每个顶点恰好出现一次),并计算总成本是否 ≤ T。这可以在多项式时间内完成。
  • 3-SAT问题:给定一个3-CNF布尔公式,要求找到一个满足所有子句的真值赋值,或报告不可满足。
    • 候选解:一个对 n 个布尔变量的真值赋值,可用 n 个比特描述。
    • 验证:遍历所有子句,检查每个子句中是否至少有一个文字与赋值相符。这可以在多项式时间内完成。
  • 哈密顿路径问题:给定图,要求找到一条经过每个顶点恰好一次的路径,或报告不存在。
    • 候选解:一个顶点序列。
    • 验证:检查序列中的每条边是否存在于图中,且每个顶点恰好出现一次。
  • 调度问题(Makespan Minimization)的搜索版本:给定作业、机器和目标完工时间 T,要求找到完工时间不超过 T 的调度方案,或报告不存在。
    • 候选解:一个将作业分配到机器的方案。
    • 验证:计算最大机器负载(完工时间),并检查是否 ≤ T
  • 独立集问题的搜索版本:给定图和目标值 k,要求找到大小至少为 k 的独立集,或报告不存在。
    • 候选解:一个顶点子集。
    • 验证:检查该子集中任意两个顶点之间是否没有边相连。

NP类非常庞大,因为其准入条件非常宽松。几乎所有我们能想到的搜索问题的搜索版本都是NP类成员。


NP类与穷举搜索的关系

在第一章的第一个视频中,我们讨论过通过将大量问题归约到TSP来积累其难解性证据。最理想的目标是,将所有“同样能用朴素穷举搜索解决”的问题都归约到TSP。

现在,我们有了NP类的形式化定义(基于高效验证)。让我们将“高效验证”与“可用朴素穷举搜索解决”这两个概念联系起来。

任何NP问题都可以用与解决TSP类似的朴素穷举搜索算法来解决。

以下是该通用穷举搜索算法的步骤:

  1. 枚举所有候选解:根据NP定义的条件1,候选解的长度最多为 O(n^{d1})。因此,需要枚举的候选解总数最多是 2^{O(n^{d1})},这是一个指数级数量。
  2. 逐个验证:对于枚举出的每个候选解,利用NP定义的条件2,用一个多项式时间算法(O(n^{d2}) 时间)来检查它是否是一个可行解。
  3. 输出结果:如果找到一个可行解,则输出它;如果枚举完所有候选解都未找到,则正确报告无解。

这个通用算法的正确性是显然的,因为它检查了每一个可能的候选解。由于枚举步骤最多有指数级数量,而每一步验证需要多项式时间,因此整个算法的运行时间是指数级的 (2^{O(n^{d1})} * O(n^{d2}))


NP难问题的定义

NP类的准入条件很弱,因此几乎所有的搜索问题都属于NP。这意味着,如果像旅行商问题这样的问题,每一个NP问题都能归约到它,那将是TSP难解性的极强证据。因为如果存在TSP的多项式时间算法,那么将自动得到所有NP问题的多项式时间算法。

这种强难解性证据正是 NP难问题 的形式化定义:

一个问题被称为是NP难的,当且仅当NP类中的每一个问题都能(通过Cook归约)归约到这个问题。

换句话说,一个NP难问题的多项式时间算法将自动为NP类中的所有问题提供多项式时间算法。

注意:我们之前强调NP类只包含搜索问题。但优化问题(如TSP的优化版本)虽然不属于NP类,却可以是NP难的。事实上,本课程中讨论的所有优化问题都是NP难的。这里我们使用的是更宽松的Cook归约定义,它允许我们直接说“TSP是NP难的”。许多教材使用更严格的Karp归约(或称“多一归约”),在那定义下,只有搜索问题才能是NP难的。


库克-列文定理重温

现在我们已经理解了NP难的精确定义,可以更准确地理解库克-列文定理的意义。

库克-列文定理断言:3-SAT问题是NP难的

这意味着,每一个NP问题(每一个具有高效可验证解的问题)都可以归约到3-SAT问题。考虑到NP类的庞大规模和3-SAT表面上的简单性,这似乎令人难以置信。

证明思路简述

  1. 任取一个NP问题 A。根据NP定义,我们知道:
    • 其候选解描述长度不超过 c1 * n^{d1}
    • 存在一个多项式时间验证算法 V,能在 c2 * n^{d2} 步内验证一个候选解。
  2. 构造一个从 A 到 3-SAT 的归约。
  3. 变量设计
    • 解变量:用一组布尔变量直接编码一个候选解的比特位(利用了条件1)。
    • 状态变量:用一张二维表(行代表时间步,列代表内存位)的布尔变量,来编码验证算法 V 在给定候选解下的整个执行过程(利用了条件2和多项式时间限制)。
  4. 子句(约束)设计
    • 添加大量3-CNF子句,以强制状态变量表必须编码一个合法的、从初始状态开始、根据输入实例和候选解变量正确运行、并最终到达“接受”状态的计算过程。由于图灵机单步计算非常局部和简单,这些约束可以用3-CNF子句来表达。
  5. 归约完成:如果构造出的3-SAT实例可满足,那么从满足赋值的“解变量”部分可以直接读出问题 A 的一个可行解。如果不可满足,则问题 A 没有可行解。

因此,给定一个能解决3-SAT的“魔法黑盒”,我们就可以解决任何NP问题。这正证明了3-SAT是NP难的。


总结

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

  1. NP类的定义:由那些“候选解具有多项式长度描述”且“存在多项式时间验证算法”的搜索问题组成。
  2. NP类与穷举搜索:任何NP问题都可以通过枚举所有多项式长度候选解并逐一验证的指数时间算法来解决。
  3. NP难的定义:如果所有NP问题都能归约到某个问题,则该问题是NP难的。这为该问题的内在难度提供了最强形式的证据。
  4. 库克-列文定理的核心意义:3-SAT问题是NP难的,这意味着它是“NP世界”中最难的问题之一,所有NP问题都可以转化为3-SAT问题来求解。

理解NP类和NP难的概念,是探索计算复杂性前沿和识别那些可能不存在高效算法的问题的基石。下一节,我们将探讨著名的 P vs NP 问题

035:P != NP 猜想 🧩

在本节课中,我们将要学*计算机科学中最著名、最深奥的开放性问题之一:P 不等于 NP 猜想。我们将正式定义 P 类和 NP 类,并探讨这个猜想的意义、现状及其对计算世界可能产生的影响。

P 与 NP 的正式定义

在之前的视频中,我们曾非正式地介绍过 P 不等于 NP 猜想。它意味着,验证一个计算任务的解决方案,可能比从头开始寻找一个解决方案要根本性地容易。现在,我们终于可以正式定义这个猜想了。

我们知道,在 P 不等于 NP 猜想中,NP 代表那些具有高效可验证解的搜索问题。那么,P 又代表什么呢?P 是 NP 的一个子集,特指那些不仅解可高效验证,而且本身就能在多项式时间内被解决的问题。

以下是 P 类问题的例子:

  • 2SAT问题:每个约束条件是最多两个文字的析取。我们知道这个问题可以在多项式时间(甚至是线性时间)内解决,例如通过将其归约到计算有向图的强连通分量。
  • 最小生成树问题:需要注意的是,P 和 NP 讨论的都是搜索问题。因此,严格来说,属于 P 类的是最小生成树问题的搜索版本,即给定一个图和一个目标值,找到一个总权重不超过该值的生成树。

所以,P 不等于 NP 猜想中的 P 和 NP 含义如下:

  • NP:所有具有高效可验证解的搜索问题。
  • P:NP 中那些可以在多项式时间内解决的子集。

猜想的内容与两种可能性

那么,这个猜想具体说了什么?它断言这两个集合并不相同。根据定义,P 是 NP 的子集。因此,该猜想等价于说这个包含关系是严格的。换句话说,猜想认为至少存在一个 NP 类中的问题(即解可高效验证),但无法在多项式时间内解决。

如下图所示,宇宙可能处于两种状态之一,我们尚不知道是哪一种。

可能性一:P ≠ NP(猜想为真)
如果 P 不等于 NP 猜想成立,那么情况就如左图所示。P 类(如 2SAT、最小生成树搜索问题)是 NP 类的一个真子集。这意味着存在一些像 3SAT 或旅行商问题搜索版本这样的“困难”问题,它们虽然解可高效验证,但无法在多项式时间内解决。

可能性二:P = NP(猜想为假)
如果 P 等于 NP,情况就如右图所示,两个集合完全重合。这意味着,只要一个问题的解可以高效验证(即属于 NP),那么它就一定能在多项式时间内被解决。在这种情况下,所有 NP 问题,包括 3SAT 和旅行商问题,都将变得容易解决。

NP难问题的意义

上一节我们介绍了 P 与 NP 关系的两种可能性。本节中我们来看看,如果找到了某个 NP 难问题的多项式时间算法,意味着什么。

这里有一个精确的数学陈述:如果你为任何一个 NP 难问题找到了多项式时间算法,那么你就否证了 P 不等于 NP 猜想。

为什么?原因如下:

  1. 假设你有一个 NP 难问题(例如旅行商问题 TSP)。
  2. 根据定义,NP 难意味着 NP 中的每一个问题都可以归约到该问题。
  3. 归约会传递可解性。因此,如果你对 TSP 有一个多项式时间算法,那么这个算法经过归约转换后,就能为 NP 类中的所有问题提供多项式时间解法。
  4. 这恰恰意味着 P = NP,从而否证了 P ≠ NP 的猜想。

猜想的现状与重要性

现在,让我们来谈谈 P 不等于 NP 猜想的当前状况。这个猜想被公认为是计算机科学乃至整个数学领域最深奥的开放性问题之一。

例如,在 2000 年,克莱数学研究所提出了七个“千禧年大奖难题”,并为每个问题的解决者设立了一百万美元的奖金。P 与 NP 问题就是这七个问题之一。

截至本视频录制时(2020年),七个难题中只有一个被解决,即庞加莱猜想,它在 2006 年被格里戈里·佩雷尔曼证明。值得一提的是,佩雷尔曼后来拒绝了这笔奖金。

尽管无人确知猜想真假,但学界普遍持有一种观点:

  • 主流观点:绝大多数(约 90% 以上)理论计算机科学家相信 P ≠ NP

这种信念主要源于两点:

  1. 人类的算法设计经验:人类似乎非常擅长为可高效解决的问题设计算法。如果像旅行商问题这样的难题真的存在高效算法,以人类迄今展现的算法设计智慧却仍未发现它,这令人惊讶。
  2. 与现实体验的契合:我们的日常经验告诉我们,检查一个解决方案通常比从头构思一个方案要容易得多(例如解一个困难的数独)。如果 P = NP,则意味着解决难题所需的“创造力”可以被高效自动化,这与我们的直觉相悖。

如果 P = NP 会怎样?

那么,如果大家都错了,P 实际上等于 NP 呢?这一点其实有两种可能的情形,但讨论得并不充分:

情形一:实用的多项式时间算法
如果 NP 中的所有问题都能通过实际可用的、快速的算法解决,那将是一个影响极其深远、但可能性较低的场景。它意味着现代密码学的终结,因为基于大数分解、离散对数等难题的加密体系将被瞬间攻破。

情形二:理论上的多项式时间算法
另一种可能性稍高的场景是,存在一种仅在理论上满足多项式时间复杂性,但过于复杂或缓慢而无法在现实世界中实现和使用的算法。如果这样,P = NP 将几乎没有实际影响,只是表明“多项式时间可解”这个定义过于宽泛,未能准确捕捉我们真正关心的“在实际中可高效解决”这一概念。

总结与展望

本节课中,我们一起学*了 P 不等于 NP 猜想。我们正式定义了 P 类(多项式时间可解)和 NP 类(解可多项式时间验证),并理解了该猜想断言 P 是 NP 的真子集。我们探讨了猜想的两种可能性、NP 难问题与猜想的关系,以及学界的主流观点。最后,我们还分析了如果 P = NP 可能出现的不同场景。

从历史来看,1956 年哥德尔曾推测过相当于 P = NP 的观点,而 1967 年埃德蒙兹则提出了相当于 P ≠ NP 的猜想。谁是对的呢?随着时间推移,尽管研究方法不断丰富,但解决这个猜想的希望似乎反而变得更加渺茫。我们必须做好准备,这个问题的答案可能在未来很长一段时间内——数年、数十年,甚至更久——都不会揭晓。

在下一个视频中,我们将继续探讨未被证明的猜想,研究两个比 P ≠ NP 更强版本的猜想:指数时间假说及其强版本

036:NP完全性 🎯

在本节课中,我们将学*NP完全性的概念。这是NP难问题中一个更精确、更强大的子类。我们将了解什么是NP完全问题,它与NP难问题的区别,以及如何证明一个问题是NP完全的。


概述:什么是NP完全性? 🤔

上一节我们介绍了NP难问题的正式定义。本节中,我们来看看一个更具体的概念:NP完全性。

NP完全性本质上是NP难性的一种特定形式。例如,3SAT问题是一个NP难问题。这意味着,如果你有一个解决3SAT问题的多项式时间算法,你就可以通过归约,自动地为NP复杂性类中的所有问题(即所有具有高效可验证解的问题)构建多项式时间算法。

但事实上,对于3SAT和我们见过的大多数其他NP难问题,我们可以给出一个更精确的说法:它们是NP完全的。这意味着,一个高效的3SAT子程序不仅足以解决NP中的所有问题,而且实际上,NP中的每一个问题都只是3SAT问题的一个“薄薄伪装”的特殊情况。换句话说,像3SAT这样的NP完全问题具有普遍性,因为它们同时编码了NP复杂性类中的每一个问题。这听起来非常神奇,事实也确实如此。


11归约:定义“薄薄伪装” 👥

当我们说一个问题A是另一个问题B的“薄薄伪装”版本时,我们是什么意思呢?我们可以通过使用11归约来使这个概念数学化。

11归约是库克归约的一个特例(库克归约是我们整个视频系列中一直在使用的归约)。直观地说,11归约只被允许做最小限度的工作:它只能调用问题B的子程序一次。除此之外,它唯一能做的就是预处理其输入,以便将其输入给B,并对B的输出进行后处理,以作为其最终解决方案返回。

让我们重新绘制通常的示意图,以反映11归约施加的新限制。首先需要说明,11归约只在讨论一对搜索问题时才有意义。我们考虑将某个搜索问题A归约到另一个搜索问题B,例如搜索版本的TSP。

与库克归约不同,库克归约可以调用其子程序任意多项式次数,并可以任意使用这些调用的结果。而11归约的限制则严格得多:

  1. 它只允许调用子程序一次
  2. 它必须以非常特定的方式使用子程序的输出。

对于搜索问题,子程序要么返回问题B实例的一个可行解,要么报告无解。11归约中的“蓝盒子”必须直接复制这个答案:

  • 如果子程序报告无解,则“蓝盒子”必须报告其原始问题A的实例无可行解。
  • 如果子程序返回一个可行解,则11归约必须通过多项式时间的后处理,将其转换为原始问题A实例的一个可行解。

我们之前的归约是11归约吗? 🔍

我们已经在整个视频系列中看到了很多归约。现在你应该问的问题是:我们真的使用了库克归约的全部能力吗?还是我们无意中使用的本来就是11归约?

答案是:从技术上讲,我们并不总是进行11归约,但从本质上讲,我们确实是这样做的。

11归约只针对一对搜索问题定义。回顾我们见过的归约,许多都涉及优化问题。然而,如果你回顾那些归约,例如旅行商问题的NP难性证明,如果你看的是旅行商问题的搜索版本(即给定一个目标成本T),那么该归约就变成了一个11归约。

我们在第22章中进行的四个主要归约中的第二个,就是一个非常清晰的11归约例子:从3SAT问题到有向哈密顿路径问题的归约。我们给定一个3SAT实例,构造一个有向图,将其输入给有向哈密顿路径子程序。如果子程序说没有哈密顿路径,我们就报告没有可满足的赋值;如果它给出了一条哈密顿路径,我们就从中提取出一个可满足的赋值。这是一个典型的11归约。

再次强调,如果你回顾整个视频系列中的归约,并考虑所有讨论过的优化问题的搜索版本,我们看过的所有归约实际上都是11归约。


卡普归约:决策问题的11归约 📝

现在你已经了解了库克归约及其特例11归约。还有第三种归约需要简要提及,因为你在任何复杂性理论书籍和许多算法书籍中都可能看到它,即卡普归约(有时也称为多一归约或映射归约)。

卡普归约基本上就是11归约,但针对的是决策问题而非搜索问题。在决策问题中,算法只需报告“是”或“否”,而不需要实际构造一个可行解。因此,对于决策问题,示意图变得更加简单:子程序(“洋红色盒子”)只回答“是”或“否”;“蓝盒子”只是复述这个答案。

任何主要讨论决策问题(而非我们这里讨论的搜索问题)的书籍,都会使用卡普归约而非11归约。本系列视频之所以使用搜索问题,是因为从算法角度来看,它们更自然。


NP完全问题的正式定义 🧠

我们现在准备正式定义NP完全问题——NP类中最难的问题,它们同时将所有其他具有高效可验证解的问题编码为自己的特例。

NP完全性最好被视为NP难性的一种特定类型。让我先提醒你一下我们三个视频前最终得到的NP难问题的正式定义:

一个问题是NP难的,如果对于NP中的每一个问题A(即每一个具有高效可验证解的搜索问题),都存在一个从A到B的归约(在本系列中,我们一直使用库克归约)。这意味着,如果给定一个解决B的多项式时间子程序,你将自动获得解决NP类中所有问题的多项式时间算法。

要成为NP完全的,问题B必须满足一些额外的属性:

  1. 属于NP:首先,只有搜索问题才有资格成为NP完全的。因此,虽然优化版本的TSP是NP难的,但它不是NP完全的。而搜索版本的TSP确实是NP完全的。所以,NP完全性只指代搜索问题。
  2. NP难性(通过11归约):不仅B在算法上足以解决NP中的所有问题,而且实际上NP中的所有问题都只是B的“薄薄伪装”版本。我们通过11归约来表达“薄薄伪装”。因此,对于NP完全性,我们要求从每一个NP问题到B都存在一个11归约(而不仅仅是库克归约)。

第一个条件要求问题B同时编码了NP中的所有问题。第二个条件确保我们可以将NP完全问题解释为NP类中最难的问题,因此我们要求B本身确实是NP的成员(即B是一个具有高效可验证解的搜索问题)。


定义辨析与重要性 ⚠️

NP完全问题的这个定义是计算机科学整个历史上最重要的定义之一。需要明确的是,如果你查阅其他书籍,可能会看到略有不同的NP完全性定义,这可能会造成混淆。

我们这里处理的是搜索问题(算法需要在解存在时返回一个可行解)和11归约。而在许多书籍中,他们使用决策问题(只需报告解是否存在)和卡普归约(决策问题的11归约类比,甚至不需要后处理步骤,只需复述“是”或“否”的答案)。

我提到这些是为了避免混淆。除非你全职投入复杂性理论研究,否则如果你主要关注算法方面,不必担心存在多种问题类型和归约类型。就理论对如何处理不同问题的指导意义而言,无论你使用哪种定义,其算法含义是完全相同的。


NP完全问题真的存在吗?存在! ✅

NP完全问题的定义非常酷:一个具有高效可验证解的单一问题,同时编码了所有此类问题。NP完全问题真的可能存在吗?这有点令人惊讶。

答案是:存在。事实上,我们之前已经接触过的一个定理——库克-莱文定理——证明了这一点。当我第一次展示这个定理时,我简化了它,说它证明了3SAT问题是NP难的。实际上,它证明了更强的东西:3SAT问题实际上是NP完全的

原因在于,如果你回顾几讲前给出的库克-莱文定理证明草图,那个归约正是一个典型的11归约。它从一个抽象的NP问题A归约到3SAT问题,构造的3SAT实例使得其可满足赋值与问题A实例的可行解一一对应。然后,它只调用一次假定的3SAT子程序,并根据结果直接复制答案或进行后处理转换。这就是为什么库克-莱文定理实际上证明了3SAT是NP完全的。

一旦我们有了一个NP完全问题(如3SAT),我们就可以站在巨人的肩膀上,使用归约来生成更多的NP完全问题。


证明NP完全性的三步法 📋

这意味着,我们有一个非常简单的三步法来证明一个问题是NP完全的,其精神与我们证明问题NP难的两步法非常相似。

以下是三步法:

  1. 证明属于NP:首先,证明你试图证明的目标问题B确实属于NP类(即它是一个具有高效可验证解的搜索问题)。这是NP完全性的前提条件。
  2. 选择已知的NP完全问题A:选择一个已知的NP完全问题作为起点,例如3SAT。
  3. 使用11归约从A归约到B:使用一个11归约(而不是更一般的库克归约)将问题A归约到你的目标问题B。

如果你能完成这三步,那么你的问题B就是NP完全的。


NP完全问题的普遍性 🌍

这个简单的三步法已经被应用了无数次。因此,我们现在知道有成千上万个自然问题是NP完全的,包括来自工程、生命科学和社会科学各个领域的问题。

例如,我们讨论过的几乎所有优化问题的搜索版本,包括TSP、背包问题、最大覆盖、最小生成树等,实际上都不仅是NP难的,而且是NP完全的

如果第22章中的所有NP完全问题还不够,你可以查阅我之前提到的加里和约翰逊的经典著作,那里有数百个NP完全问题的更多例子。


总结 🎓

本节课中,我们一起学*了NP完全性的核心概念。我们了解到:

  • NP完全性是NP难性的一种更强形式,要求从NP中所有问题到该问题都存在11归约
  • 11归约是一种限制严格的归约,只允许调用一次子程序,并直接复制或简单转换其输出。
  • NP完全问题本身必须属于NP类(即具有高效可验证解)。
  • 库克-莱文定理证明了第一个NP完全问题(3SAT)的存在。
  • 利用已知的NP完全问题(如3SAT)和11归约,可以通过三步法证明其他问题是NP完全的。
  • 成千上万个实际问题被证明是NP完全的,这凸显了该概念在理论和实践中的核心重要性。

理解NP完全性,使我们能更精确地把握计算难题中最难的那一类,并为探索算法设计与复杂性理论的深层联系奠定了基础。

037:无线频谱的重新分配 📡

在本节课中,我们将学*一个关于NP难问题的实际案例研究。我们将看到,NP难并非一个纯粹的学术概念,它在解决现实世界的高风险经济问题时,会真实地制约我们计算上的可行选择。具体来说,我们将探讨美国联邦通信委员会(FCC)如何设计并实施一项名为“激励拍卖”的复杂算法,以重新分配宝贵的无线频谱资源。这个案例将展示我们之前学到的算法工具箱如何被整合运用来解决一个极其复杂的实际问题。

背景与动机 📺

上一节我们介绍了NP难问题的普遍性。本节中,我们来看看一个具体的、由NP难问题主导的现实世界挑战:无线频谱的重新分配。

无线频谱是一种稀缺的公共资源。在20世纪50年代,电视在美国迅速普及,电视节目完全通过无线电波进行空中传输。为了协调各电视台的传输并防止干扰,美国联邦通信委员会(FCC)将可用的广播频率(即频谱)划分为多个区块,每个区块称为一个“频道”,每个频道宽6兆赫兹(MHz)。

以下是关于电视频道划分的一些关键事实:

  • 频道与频率:例如,“频道14”实际上指的是470 MHz到476 MHz之间的频率。下一个频道15则占用476 MHz到482 MHz,以此类推。
  • 频道类型:频道14及以上的属于特高频(UHF)频道,起始于470 MHz。此外还有甚高频(VHF)频道,它们占用较低的频率,例如频道7-13使用174-216 MHz,频道2-6使用54-88 MHz。

在20世纪中叶,电视在频谱使用上没有太多竞争者,因此一大块宝贵的频谱被预留给了无线电视广播。然而,时间快进到21世纪,移动电话与基站之间交换的所有数据同样通过无线电波在空中传输。例如,在2020年,美国Verizon用户的下载数据可能使用746-756 MHz的频率,上传使用777-787 MHz的频率。

你可以注意到,这些用于移动数据的频率(700 MHz范围)高于我们之前讨论的电视频道频率(600 MHz及以下)。这不是偶然,而是为了避免干扰。为蜂窝数据预留的频谱部分与为无线电视预留的频谱部分并不重叠。

频谱需求的变迁 📶

随着移动和无线数据使用量在21世纪爆炸式增长,对专用频率的需求也急剧增加。然而,并非所有频率都适用于无线通信,因此频谱成为一种稀缺资源,现代技术对其需求如饥似渴。

另一方面,电视虽然仍然重要,但通过空中广播的“无线电视”已远不如20世纪中叶那样普及。事实上,大约85%-90%的美国家庭完全依赖有线电视(通过电缆传输,无需空中频谱)或卫星电视(使用高得多的频率)。因此,将最有价值的频谱“地产”继续留给无线电视,在20世纪中叶是合理的,但在21世纪初则不再那么合理。

FCC激励拍卖的目标 🎯

为了反映过去70年来频谱需求的转变,截至2020年,一项重大的频谱重新分配工作已接*完成。具体来说,在美国,自2020年7月13日之后,全国范围内将不再有任何电视台在原有的最高频段(频道38至51,对应频率614-698 MHz)上进行广播。

这项重新分配工作规模巨大:

  • 所有原在这些频道上广播的电视台,要么切换到较低频道,要么完全停止无线传输(可能仍通过有线和卫星电视广播)。
  • 总计有175家电视台交还了广播许可证并停止无线广播,同时约有1000家电视台需要切换频道。

清理出这14个频道(38-51)释放了84 MHz的频谱。这些频谱被重新组织并授予电信公司(如T-Mobile、AT&T和Comcast),用于在未来几年建设新一代无线网络(例如5G网络)。

经过重组后,原来的频道38-51变成了7个独立的5 MHz频段对(用于下载和上传),中间有保护间隔以避免干扰。这整个操作引出了一系列复杂的问题。

以下是需要回答的关键问题列表:

  • 在原有的所有电视台中,哪些应该停止无线广播?
  • 对于那些继续广播的电视台,哪些需要切换频道?它们的新频道应该是什么?
  • 对于那些交还许可证的电视台,适当的补偿应该是多少?
  • 在完成重组并创建了7个频段对之后,哪些电信公司有幸获得它们?它们应该为此支付多少费用?

这些问题都需要由“FCC激励拍卖”来回答。本质上,这是一个用于进行频谱重新分配的、庞大而复杂的算法。它严重依赖于我们在本系列课程中学到的、用于应对NP难问题的算法工具箱。

从下一个视频开始,我们将详细讨论这个算法是如何实际工作的。

总结 📝

本节课中,我们一起学*了NP难问题在现实世界中的一个重要应用案例:无线频谱的重新分配。我们回顾了电视广播频谱的历史,了解了21世纪移动数据爆炸带来的频谱需求变化,并引出了美国FCC为重新分配频谱(特别是清理出频道38-51)所面临的复杂挑战。这些挑战涉及资源分配、补偿和定价等一系列难题,其解决方案——FCC激励拍卖——是一个综合性的复杂算法。在接下来的课程中,我们将深入探讨这个算法是如何巧妙运用我们已学的算法技术来解决这些NP难问题的。

038:FCC激励拍卖中的许可证回购贪心启发式算法(第一部分)

在本节中,我们将学*美国联邦通信委员会激励拍卖中的一个核心问题:如何通过反向拍卖从电视台回购许可证以释放目标频谱。我们将从一系列简化假设开始,将问题建模为加权独立集问题,并探讨如何设计贪心启发式算法来求解这个NP难问题。


概述:FCC激励拍卖与反向拍卖

FCC激励拍卖包含两个部分:一个决定哪些电视台应切换频道或停播、并确定相应补偿的反向拍卖,以及一个决定谁将获得新释放的频谱区块及其支付价格的前向拍卖。本节我们将聚焦于该拍卖中最具创新性的部分:前所未有的反向拍卖,即从电视台回购许可证。


问题建模:从电视台回购许可证

首先,我们需要明确政府在反向拍卖中从电视台回购的具体内容。电视台的广播许可证由FCC授予,授权其在特定地理区域的某个频道上进行广播。FCC负责确保各电视台在其广播区域内受到极少或没有干扰。

值得注意的是,对于FCC激励拍卖而言,电视台的特定频道分配(例如第41频道)并不被视为许可证所有者的财产权的一部分。因此,需要国会授权这一解释,并允许拍卖根据需要重新分配频道。2012年,美国国会通过了《中产阶级税收减免和就业创造法案》,该法案授权了FCC激励拍卖。

反向拍卖的目标是从电视台收回足够的许可证,以释放目标数量的频谱,例如由第38至51频道占用的84 MHz频谱。


简化假设与问题形式化

为了初步理解“收回足够许可证以释放目标频谱”这一问题,我们从四个简化假设开始。这些假设并不完全合理,但有助于我们理解问题,后续我们将逐步放宽它们。

以下是四个初始简化假设:

  1. 单频道假设:所有在播电视台都被强制在同一个频道(例如第14频道)上广播。
  2. 无干扰条件:两个电视台可以在同一频道上同时广播,当且仅当它们的广播区域没有重叠。
  3. 已知价值:我们知道每个电视台的价值(以美元计)。
  4. 单边决策:政府可以单方面决定让任何电视台停播(后续我们将讨论实际的自愿补偿机制)。

在这些假设下,我们可以形式化我们面临的计算问题。

我们做出的根本决策是:决定哪些电视台保持播出,哪些电视台停播。

我们关心的目标函数是:最大化保持播出的电视台的总价值。

约束条件则由前两个简化假设指定:第二个假设禁止任何广播区域重叠的电视台在同一频道上广播;结合第一个假设(只有一个可用频道),这意味着我们只能允许广播区域互不相交的电视台同时保持播出。

总结来说,在这四个简化假设下,我们真正感兴趣的优化问题是:在保持播出的电视台广播区域互不相交的约束下,最大化这些电视台的总价值


识别问题:加权独立集

初次见到一个计算问题时,我们总希望识别它是否是我们已知问题的特例。那么,你认出这个优化问题了吗?

这实际上正是加权独立集问题

图中的顶点对应于我们可能希望保持播出的电视台。在独立集问题中,边代表冲突。在这里,如果两个电视台的广播区域重叠,则对应的两个顶点(电视台)存在冲突。

例如,假设有五个电视台,每个圆圈代表其广播区域,灰色三角形表示发射器。这张图将对应一个有五个顶点的图,每个电视台一个顶点。每对重叠的圆圈之间会有一条边。

电视台的价值将转化为顶点的权重。

认识到这个问题本质上是加权独立集问题,对我们来说不一定是好消息。在第22章的对应视频中,我们证明了独立集问题是NP难的,即使所有顶点权重都相同(例如权重均为1)。我们在第三部分的动态规划课程中看到,可以在路径图或更一般的树状图上解决加权独立集问题。但电视台的干扰模式远非树状结构。例如,纽约市的每个电视台都会与纽约市的其他每个电视台相互干扰,这将在图中形成一个大的团(即所有顶点都两两相邻),与树结构截然不同。

因此,我们已将问题识别为加权独立集问题,并且它似乎不是独立集问题中可在多项式时间内解决的特殊情况。


应对NP难问题:从精确算法到启发式算法

但无需放弃。本系列视频的核心观点就是:NP难并非死刑判决。我们现在拥有丰富的工具箱,可以尝试多种方法来解决这个加权独立集问题的实例。

我们可以从最雄心勃勃的目标开始:尝试精确解决这个问题,即真正找到广播区域互不相交、且总价值最大的电视台子集。我们能否在可容忍的时间内(例如一周或更短的计算时间)做到这一点?

答案一如既往地取决于问题规模的大小。如果我们只有大约30个电视台,甚至可以使用穷举搜索来找到无干扰且价值最大的电视台子集。但不幸的是,在美国,需要处理的是数千个电视台和数万个干扰约束,这远远超出了穷举搜索甚至第21章视频中讨论的动态规划技术的能力范围。

这意味着,如果我们真的想要一个精确算法,我们工具箱中只剩下一种可以尝试的工具:即我们讨论过的“半可靠魔法盒子”。对于优化问题(在无干扰约束下最大化总社会价值/电视台价值),首先应该想到的魔法盒子是混合整数规划求解器

确实,加权独立集问题很容易编码为混合整数规划。我鼓励你思考一下这种形式化会是什么样子,它非常直接。事实上,使用混合整数规划求解器正是FCC尝试的第一件事。

不幸的是,FCC面临的问题(数千个电视台和数万个干扰约束)规模太大,即使最新、最强大的混合整数规划求解器也难以处理。公平地说,最新、最强大的求解器是在我们将在后面讨论的更复杂的多频道版本问题上遇到了困难。

在100%正确算法的所有选项都用尽后,FCC此时别无选择,只能在正确性上做出妥协,转而使用快速的启发式算法。


贪心启发式算法设计

对于加权独立集问题,与许多其他NP难问题一样,贪心算法设计范式是开始构思快速启发式算法的绝佳起点。与许多问题类似,很容易想出多种可以使用的贪心算法。

你可能想到的第一个用于解决加权独立集问题的贪心算法,是模仿Kruskal算法背后的思想。Kruskal算法按从最具吸引力到最不具吸引力的顺序单遍扫描边,只要保持可行性就将其纳入解中。我们可以在这里做类似的事情。不过我们选择的是顶点。

因此,我们将对图的顶点进行单遍扫描。“最具吸引力”意味着最高权重,“最不具吸引力”意味着最低权重。所以我们将按顶点权重的降序进行遍历,然后只要当前顶点不破坏可行性(即该顶点不与我们在先前迭代中已选定的任何顶点相邻),就将其纳入我们的解中。

我们称该算法为基础贪心算法。它接收一个加权独立集问题的实例作为输入(即一个无向图以及每个顶点的非负权重),其职责是输出一个独立集(即一个所有顶点互不相邻的子集),并且在满足独立集条件的前提下,尽可能最大化独立集的总权重。

这个基础贪心算法可能是最自然的起点。我们并不一定期望它是最惊人的快速启发式算法,我们可能期望做得更好。这真的只是我们头脑风暴的开始,但它将通过探索它在一些例子上的表现,帮助我们更好地理解问题的复杂性。

让我们从一个五顶点的例子开始,看看这个算法会做什么。

这是我们之前用于展示电视台选择问题与独立集问题对应关系的完全相同的图。我已经用洋红色标出了五个顶点的权重。

如果基础贪心算法以这个图作为输入,它会做什么?它会单遍扫描顶点,从权重最高的顶点开始,到权重最低的顶点结束。这里权重最高的顶点是左下角那个,权重为4。贪心算法从空集开始,因此第一个顶点与它目前所选内容之间没有冲突(因为目前还没有选择任何内容)。所以贪心算法总是会在第一次迭代中选择该顶点。在这个例子中,它肯定会选择那个权重为4的顶点。

在第二、第三和第四次迭代中,算法将考虑三个权重为3的顶点(顺序任意)。但顺序实际上无关紧要,因为我们已经选定了权重为4的顶点,而该顶点与所有三个权重为3的顶点都相邻。因此,在第二次、第三次、第四次迭代中,当我们测试是否可以将这个新顶点纳入当前解而不破坏可行性时,答案是否定的。如果我们试图纳入其中一个权重为3的顶点,它将破坏可行性,因为每个这样的顶点都有一条边连接到我们已经选定的权重为4的顶点。

在第五次迭代(最后一次)中,贪心算法考虑权重最低的顶点(权重为2)。你会注意到该顶点实际上并不与权重为4的顶点相邻,因此纳入权重为2的顶点是安全的。这将结束贪心算法的运行,因此它将在其解中选择左下角和右上角的顶点。

因此,基础贪心算法的输出是一个总权重为6的独立集。很容易看出这不是最优解,不是最大权独立集。因为如果我们选择顶部的所有三个顶点,那也是一个独立集,总权重为8。而这正是最优解,即最大权独立集。

我们应该如何看待这个例子呢?目前还不明确。加权独立集问题众所周知是NP难问题,而这个基础贪心算法显然在多项式时间内运行。因此,我们完全预期会遇到这类输出非最优的例子。如果不存在这类输入,我们就将驳斥P等于NP的猜想,而这并不是我们预期会发生的事情。

但这里有一个更令人不安的例子,它表明我们可能实际上需要重新审视贪心算法并使用不同的变体。

在这个例子中,我们有一个星形图:有一个中心顶点,权重为2;然后有许多“辐条”顶点(在幻灯片上有11个辐条),每个外围顶点的权重为1。

基础贪心算法在这里会做什么?它将从权重最高的顶点开始,即权重为2的中心顶点,并选择它、锁定它,从而阻止它选择任何辐条顶点。

这对贪心算法来说是一个非常糟糕的结果。它只输出这个单顶点的独立集,总权重为2。我们希望它做什么?我们希望它选择了所有辐条顶点。在这种情况下,选择由所有辐条顶点组成的独立集将获得总权重11。

这个例子出了什么问题?基础贪心算法表现如此糟糕的原因在于,它只片面关注顶点的权重,而没有考虑将某个顶点纳入解中所带来的其他影响。特别是,基础贪心算法没有考虑到,选择这个中心顶点将阻止所有辐条顶点在未来被考虑。


改进贪心算法:考虑顶点度数与成本效益分析

为了避免基础贪心算法的错误,我们可以“歧视”那些具有高度数(即有许多邻居)的顶点,因为如果选择它们,会使得一大批其他顶点无法被考虑。

例如,我们可以进行成本效益分析。对于一个顶点V,我们可以说:如果选择这个顶点,我们获得其权重(比如10)。另一方面,如果选择这个顶点,它会使得一批顶点无法被考虑。具体来说,顶点V本身将永远不会再被考虑,而且V的所有邻居也将被排除在未来选择的范围之外。因此,我们“消耗”了 度数(V) + 1 个顶点(+1是V本身),获得的收益是 w(V)(该顶点的权重)。

因此,与其仅仅按权重从高到低单遍扫描顶点,我们可以考虑“性价比”,即选择该顶点所获得的权重与被排除在未来考虑之外的顶点数量之比。这将是一种替代的贪心算法,它会歧视高度数顶点。

更一般地,贪心算法可以在预处理步骤中,以任意方式计算顶点特定的乘数 β(v),然后按缩放后的权重(即 w(v)/β(v))的非递减顺序访问顶点。我们称之为通用贪心算法

我一直称其为贪心算法,但这实际上是一整个贪心算法家族。对于如何计算 β(v) 的每个公式,你都会得到一个不同的贪心算法。

我们之前讨论的基础贪心算法对应于为每个顶点v设置 β(v) = 1。我们也讨论了通过设置 β(v) = 1 + 顶点v的度数 来歧视高度数顶点的可能性。当然,你也可以尝试其他公式。

那么问题来了:在所有这些贪心算法中,你应该使用哪一个?这些顶点特定乘数的最佳选择是什么?

请记住,无论你使用多么聪明的公式来计算 β(v),总会有一些例子使得贪心算法无法返回最大权独立集,而是返回次优解。我这么说,是假设 β(v) 可以在多项式时间内计算,因此整个算法在多项式时间内运行,并且像往常一样,假设P不等于NP猜想成立。但对于任何你可能想到的合理 β(v),你都不会期望贪心算法在所有情况下都正确。

那么,你应该如何在定义这些 β(v) 的不同竞争方式中进行选择呢?最佳选择将取决于你所感兴趣的应用中通常出现的问题实例。这意味着,找出使用哪个 β(v) 的最佳方法是经验性的,即通过在代表性实例上尝试大量不同的可能性。

这实际上与处理实际应用中的NP难问题的一些通用建议相关联:尽可能利用领域特定知识。希望你能为你的应用获得一些代表性实例,这些实例可用于以最佳方式调整这些顶点特定乘数。

在FCC激励拍卖中,设计者拥有的一个优势是他们确实拥有代表性实例。他们确实拥有关于他们关心的加权独立集问题实例的领域知识,包括我们将在下一部分开始讨论的多频道泛化。你已经可以部分看出这一点:例如,这个独立集问题中的图是什么?顶点对应电视台,他们事先知道将参与FCC激励拍卖的所有电视台。图的边由干扰约束(重叠的广播区域)衍生而来,这些也是事先完全已知的,由所有电视台的现有许可证规定。至于顶点权重(即电视台价值),虽然不完全清楚(这些价值由许可证所有者掌握,FCC不一定事先知道),但可以根据历史数据(例如过去许可证的售价)做出有根据的猜测,并尝试一系列合理的可能性。

然后,他们使用这些代表性实例来调整参数,选择如何计算 β(v)。他们发现,通过仔细调整这些参数,在他们的代表性实例上,这个贪心算法通常能返回总权重非常接*最大可能值(即接*最优)的解,在大多数情况下超过最优值的90%。

你可能很想知道,在真实的FCC激励拍卖中,这些 β(v) 参数是如何设置的。我将真实的 β(v) 公式放在幻灯片底部。

在FCC激励拍卖中,一个电视台 vβ(v) 被定义为该电视台度数(即与其重叠的电视台数量,也就是会阻止其被分配到同一频道的电视台数量)的平方根,乘以该电视台所服务人口的平方根

我们已经看到了让 β 依赖于顶点(电视台)度数的意义:用于歧视高度数顶点,或者等价地说,歧视与许多其他电视台重叠、会阻止许多其他电视台播出的电视台。这里通过取平方根,对高度数顶点的惩罚比原始公式要轻一些,但其作用仍然是歧视与许多电视台重叠的电视台。

第二项(人口平方根项)的意义则更为微妙,坦白说也更具争议性。它的效果实际上是减少政府向可能无论如何都会停播的小型电视台支付的补偿,并且确实产生了这种预期效果。


总结

在本节中,我们一起学*了FCC激励拍卖中反向拍卖问题的建模过程。我们首先通过一系列简化假设,将“选择电视台以最大化总价值且避免干扰”的问题识别为加权独立集这一NP难问题。由于问题规模庞大,精确算法(如混合整数规划)可能不可行,因此我们转向设计快速的启发式算法。

我们探讨了最基础的贪心算法及其在星形图等实例上的局限性。为了改进,我们引入了通用贪心算法框架,它通过顶点特定乘数 β(v) 对顶点权重进行缩放,从而能够在决策时考虑顶点的度数(即选择该顶点会排除的潜在选择数量)。我们了解到,β(v) 的最佳选择依赖于具体的应用实例,需要通过经验性调参来确定。最后,我们看到了FCC在实际拍卖中使用的 β(v) 公式,它结合了电视台的度数和所服务人口,以达到特定的经济与政策目标。

下一节,我们将放宽最初的简化假设,探讨更复杂的多频道场景下的算法挑战。

039:24.3 可行性检查(第1部分)

在本节中,我们将学*如何将FCC激励拍卖中的可行性检查问题,即“重新打包”问题,转化为一个可满足性问题,并探讨其编码方式。我们将看到,尽管这是一个NP难问题,但通过巧妙的编码,我们可以利用现代的SAT求解器来尝试解决它。

从优化到可行性检查

在上一节中,我们为价值最大化问题开发了一种贪心启发式算法,目标是在给定频道数量下,打包价值最高的电视台,同时确保它们之间互不干扰。我们看到,在该贪心算法的每次循环中,都需要进行一次可行性检查,这本质上归结为一个图着色问题,而图着色问题是NP难的。

然而,该视频的一个启示是:如果我们能有一个“魔法盒子”来为我们执行可行性检查,那么我们实际上就可以运行那个贪心算法,并有望通过仔细调整那些电视台特定的乘数,可靠地生成总价值接*最大可能值的解决方案。

当然,谈论“魔法盒子”是美好的,但在这个案例研究中,我们对魔法盒子的梦想已经破灭过一次。原始的优化问题对于最先进的混合整数规划求解器来说都过于困难。你可能会问,为什么我们这次能期望做得更好呢?

我们这次的优势在于,贪心算法所需的子程序只负责可行性检查。给定一个图,判断它是否可以用K种颜色着色。而之前我们讨论的是优化问题,即在一个图中所有K可着色的子图中,找到总价值最高的那个。这是一个更难的问题。因此,这带来了希望:对于这个虽然仍是NP难但相对简单的可行性检查问题,我们或许能拥有一个“魔法盒子”,即使我们无法为更难的优化问题找到一个半可靠的“魔法盒子”。

鉴于我们已决定使用贪心方法来解决价值最大化问题,这种从优化思维到可行性检查思维的转变,也提示我们需要改变所使用的语言和技术。我们将从最初使用的算术和混合整数规划求解器,转向逻辑和可满足性求解器的语言。

将图着色编码为可满足性问题

我们第一次遇到图着色问题,是在介绍可满足性求解器的视频中。在那个视频中,我们展示了如何将图着色问题——即检查一个图是否K可着色——编码为一个可满足性问题的实例。这个公式化方法在这里直接适用于FCC激励拍卖。

首先,我们来回顾一下可满足性问题的基本要素。它包含决策变量和约束。决策变量非常简单,只能是布尔值(真或假)。约束同样简单,只是文字的析取(逻辑或),而文字要么是决策变量,要么是其否定。

起初,将图着色与可满足性问题结合可能显得别扭。因为在图着色中,你似乎需要的不是布尔变量,而是每个顶点一个K值变量,用于指定它被分配的颜色。而在可满足性问题中,我们只能使用简单的真/假变量。

但有一个简单的解决方法:对于每个顶点,我们不只使用一个决策变量,而是使用K个决策变量。即,为每个顶点v和每种颜色i设置一个决策变量 X_vi。这个变量的预期语义是:在一个真值赋值中,如果 X_vi 为真,则表示顶点v被分配了颜色i;如果它被分配了任何其他颜色,则 X_vi 应为假。

以下是编码的具体步骤:

约束一:防止相邻顶点同色

对于输入图的每条边 (u, v),我们需要K个约束,其中第i个约束禁止u和v同时被分配颜色i。这K个约束共同确保u和v获得不同的颜色。

如何实现呢?对于给定的边 (u, v) 和颜色 i,我们只需添加约束:¬X_ui ∨ ¬X_vi。这个析取式要求我们至少将 X_uiX_vi 之一设为假。唯一不满足该约束的情况是将两者都设为真,而这正好对应于将u和v都着为颜色i。因此,这第一类约束防止了任何一对相邻顶点被分配相同的颜色。

约束二:确保每个顶点至少获得一种颜色

仅有第一类约束还不够,因为将所有变量设为假就能轻易满足它们(这相当于不给任何顶点着色)。因此,我们需要第二类约束来强制每个顶点至少获得一种颜色。

对于每个顶点v,我们希望禁止其所有K个决策变量 X_v1X_vK 同时为假。这可以通过析取式实现:X_v1 ∨ X_v2 ∨ … ∨ X_vK

这些约束允许一个顶点有多个变量为真(即被分配多种颜色)。但即便如此,只要你从分配给每个顶点的颜色中任选一种,你仍然会得到一个有效的K着色,因为第一类约束已经排除了顶点u的任何颜色与顶点v的任何颜色之间的冲突。

约束三(可选):确保每个顶点仅获得一种颜色

如果你介意一个顶点可能被分配多种颜色,可以添加第三类约束来明确禁止这种情况。对于每个顶点v和每一对不同的颜色i和j,添加约束:¬X_vi ∨ ¬X_vj。这确保顶点v不会同时被分配颜色i和j。

以上就是将图着色问题编码为可满足性问题的全部内容。正如我们在关于SAT求解器的视频中所见,这正是你可以直接输入最先进的SAT求解器并观察其性能的格式。

适应FCC拍卖的实际约束

在实际的FCC激励拍卖中,可行性检查子程序基本上是一个图着色问题,但并不完全等同。存在一些需要调整的“曲折”之处,这涉及到我们之前四个简化假设中的第二个。

我们的第二个简化假设是:判断两个电视台是否干扰的测试非常简单,即仅当它们被分配到同一频道且广播区域重叠时才干扰。这大致解释了干扰的大部分情况,但存在一些复杂因素。

例如,对于某些特定的电视台对和频道对,将重叠的电视台分配到相邻频道(如频道14和15)也是不允许的。在某些情况下,它们之间需要至少两个频道的间隔。不幸的是,具体哪些电视台对和频道对会导致干扰是相当特殊的。

在FCC激励拍卖中,处理这个问题的方法是:由另一个团队负责编制一份清单,明确指出每一对电视台,哪些频道分配组合是被禁止的(即会产生干扰)。这份清单虽然编制不易,但一旦完成并交给负责构建可行性检查算法的团队,就非常容易整合到我们已有的可满足性公式中。

具体来说,回忆基本可满足性公式中的第一类约束。其中一个约束的形式是 ¬X_Ui ∨ ¬X_Vi。这个约束确保不会出现电视台U被分配到频道i,同时电视台V也被分配到频道i的情况。

但仔细想想,同样的约束逻辑完全适用于两个不同频道i和j的情况。例如,约束 ¬X_U,14 ∨ ¬X_V,15 将禁止将电视台U分配到频道14,同时将电视台V分配到相邻的频道15。

因此,给定另一个团队编制的这份“禁止频道分配对”清单,清单中的每一项都可以直接转化为可满足性公式中的一个约束。对于清单中的每一行,你只需添加一个这种形式的约束,包含两个文字,以排除那对特定的电视台接收那对特定的频道分配。

这样,我们就去掉了四个简化假设中的第二个。我们从一个非常简单的干扰判断假设,转向了实际应用中相当复杂但可被清单化的规则,而这份复杂清单正是我们实际在可满足性公式中使用的。

适应额外的资格限制

还有一个需要调整的“曲折”:在图着色实例中,任何顶点都可以接收K种颜色中的任何一种。但在FCC激励拍卖的应用中,情况并非完全如此。并非所有电视台都有资格使用所有23个可能的频道。

例如,与墨西哥接壤的电视台不能被分配到会干扰墨西哥境内现有电视台的频道。这种限制很容易在可满足性公式中适应。

具体做法是:每当有一个电视台v被禁止使用某个频道i时,我们只需在公式中省略那个决策变量 X_vi。这样,就没有机会将该电视台分配到频道i了。就这么简单。

我们能够如此轻松地调整基本的图着色可满足性公式,以适应这个特定应用中出现的特殊侧约束,这实际上说明了一个普遍原则(并非总是成立,但经常成立):像MIP和SAT求解器这样的半可靠“魔法盒子”,通常比专门为特定问题设计的算法更具灵活性。通常可以轻松调整一个MIP或SAT公式以适应侧约束,正如我们这里所做的。而有时,添加侧约束可能会严重破坏一个特定问题的算法,迫使你重新回到绘图板,重新思考如何设计算法。

定义“重新打包”问题

至此,我们已经了解了FCC激励拍卖中必须解决的可行性检查问题的定义。它几乎是一个图着色问题,但由于上一节提到的侧约束,又不完全是。让我们给这个可行性检查问题起一个自己的名字:重新打包问题

当我们之前讨论使用代表性实例来调整那些电视台特定乘数β_v时,我们提到过,实际上重新打包问题的许多方面是预先已知的。

例如,预先知道所有可能需要处理的电视台(大约几千个)。对于每个电视台,你确切知道它可以被分配到23个频道中的哪些(即它的资格集合 C_v)。此外,由于另一个团队开发的成对干扰约束清单,你也预先知道对于每一对电视台 (u, v),它们被允许接收哪些频道分配对(即允许的配对集合 P_uv)。

所有这些信息都是预先知道的。因此,我鼓励你将它们视为算法本身固有的一部分,而不是每次问题输入本身的一部分。但是,你预先不知道的是,当你在FCC贪心算法中对电视台进行单遍扫描时,具体会出现什么样的重新打包实例序列。因为你需要检查可行性的电视台集合,将取决于之前所有可行性检查问题的结果。所以,这实际上是问题的一个输入,正如我们在整个视频播放列表中所理解的那样。

实时地,算法将获得一个电视台子集,并负责判断它们是否可以被“重新打包”。

重新打包问题的正式定义如下:
给定一个电视台集合S(实时输入),算法需要判断它们是否能够同时播出。即,是否存在一种分配方式,为每个电视台v分配一个它符合条件的频道(来自集合 C_v),使得所有成对约束得到尊重:对于每一对电视台 (u, v),它们的频道分配对是允许的(属于集合 P_uv)。

如果存在这样的分配(即所有电视台可以在不产生干扰的情况下,使用K个频道重新打包),那么算法的责任是返回一个具有该属性的分配方案。如果不存在,那么算法的责任是正确报告该电视台集合是“不可打包的”,即无法让它们全部在不产生干扰的情况下播出。

这个重新打包问题,正是FCC贪心算法在其每次循环迭代中需要解决的问题。

问题规模与性能挑战

那么,我们如何解决它呢?正如我们所看到的,我们可以将其表述为可满足性问题。每当你遇到像这样的、可以自然编码为可满足性问题的可行性检查问题时,你都应该尝试使用最新、最先进的SAT求解器来解决它,看看它们表现如何。

那么,问题实例有多大呢?同样,我们谈论的是几千个电视台,数万个干扰约束。考虑到我们有23个频道,经过我们刚才讨论的可满足性公式化后,我们得到的SAT实例将拥有数万个决策变量和超过一百万个约束。

这是一个相当大的可满足性问题实例:数万个决策变量,超过一百万个约束。尽管如此,至少为了划定基准线,你可以将最新、最先进的SAT求解器应用于它们,看看表现如何。

它们的表现相当不错,考虑到SAT实例的规模,这仍然令人印象深刻。但是,来自最新SAT竞赛的现成求解器,在解决代表性的重新打包实例时,仍然经常需要10分钟或更长时间。

而这对于FCC来说实际上还不够好。FCC有一些非常雄心勃勃的算法目标:他们希望解决重新打包问题,不是在10分钟或更长时间内,而是在一分钟或更短的时间内。同样,当你拥有数万个变量和超过一百万个约束时,这听起来相当疯狂。

他们解决一个重新打包问题的时间预算如此之少,其中一个原因你已经看到:你不仅仅解决一次重新打包问题。在那个FCC贪心算法中,你需要在每次迭代中解决一个,而迭代有数千次。在下一个视频中,当我们讨论将FCC贪心算法实现为降序时钟拍卖时,我们会看到实际需要解决的重新打包实例数量更像是10万个。

这就是为什么FCC愿意给予的时间预算如此之小,这也部分解释了为什么拍卖需要很长时间才能完成——它花了几个月才完成,因为在此过程中你必须解决10万个重新打包实例。

那么,我们如何弥合我们已经达到的水平与我们所需水平之间的差距呢?即,最新现成SAT求解器解决这些问题所需的10多分钟,与我们目标的1分钟之间的差距。

为了做得更好,我们将不得不全力以赴,动用所有可能的方法。这将是下一节的内容。

本节总结

在本节中,我们一起学*了如何将FCC激励拍卖中的核心子问题——可行性检查(重新打包问题)——建模并编码为一个可满足性问题。我们回顾了将图着色问题转化为SAT实例的标准方法,并详细说明了如何通过添加约束来适应实际应用中的复杂干扰规则和资格限制。我们定义了“重新打包问题”,并了解了其问题规模以及对求解速度的严苛要求,为下一节探讨性能优化奠定了基础。

040:可行性检查(第二部分)

📖 概述

在本节课中,我们将学*美国联邦通信委员会激励拍卖的设计者们,为了在一分钟或更短时间内可靠地解决“重新打包”实例,所使用的三种关键技术。这些技术包括预求解器、预处理步骤以及并行SAT求解器组合。


🔍 预求解器:快速筛选简单实例

上一节我们介绍了可行性检查的嵌套结构。本节中,我们来看看设计者如何利用这种结构快速筛选出简单实例。

预求解器是用于快速检查实例是否可行(可打包)或不可行(不可打包)的快速检查方法。它们利用了FCC贪婪算法过程中产生的可行性检查实例的嵌套结构。

预求解器一:邻居子集检查

第一个预求解器是一个快速检查不可行性的方法。我们提出一个问题:假设我们只关注新站点V在集合S中可能产生干扰的邻居站点,构成子集T。我们检查是否至少能将新站点V与它的邻居子集T一起打包。如果连这个子集都无法打包,那么包含所有S的更大集合也肯定无法打包。

在纯图着色问题中,这相当于检查一个新顶点V及其邻居构成的子图是否K可着色。如果该子图不是K可着色的,那么整个大图也不是。

核心逻辑

  • 如果 T ∪ V 不可打包,则可正确推断 S ∪ V 不可打包。
  • 如果 T ∪ V 可打包,则 S ∪ V 的状态仍不确定,可能可行也可能不可行。

预求解器二:局部扩展检查

第二个预求解器显式地利用了集合S是可打包的这一事实,并且我们从前一次迭代中继承了S中所有站点的可行信道分配方案。

这个预求解器的目标是,以“懒惰”的方式,尝试将继承来的信道分配方案扩展到新站点V。我们只允许有限的自由度:对于所有不与新站点V相邻的站点(即 S - T 中的站点),我们固定其信道分配不变。然后,我们尝试在V的邻居集合T内,为T和V重新分配信道,以找到满足所有约束的分配方案。

核心逻辑

  • 如果成功找到 T ∪ V 的信道分配,且与固定的 S - T 分配兼容,则我们证明了 S ∪ V 是可打包的。
  • 如果失败(在固定 S - T 的条件下无法分配),S ∪ V 的真实状态仍不确定,因为解除固定约束后可能变得可行。

图着色示例

考虑一个纯图着色场景,S是一条有9个顶点的品红色路径,已有一个三着色方案(红、蓝、绿)。现在尝试加入橙色顶点V。

  • 情况一(成功):在继承的着色方案下,V的三个邻居分别是蓝、绿、红。通过将蓝色邻居重新着为绿色(其邻居为红色,可行),V的邻居变为绿、绿、红,从而可以将V着为蓝色。预求解器成功。
  • 情况二(失败):如果继承的路径着色方案略有不同(例如第三个顶点从红变为绿),则V的三个邻居颜色被固定为蓝、绿、红,且均无法改变(因为各自的邻居颜色限制)。此时V无法获得任何不冲突的颜色。预求解器失败,尽管整个图实际上是三可着色的。

动机:专注于新站点的邻居(集合T),而非整个S,是因为S可能包含数千个站点,而T通常只有个位数或十位数个站点。因此,检查 T ∪ V 的打包状态可以使用现成的SAT求解器快速完成。


🛠️ 预处理步骤:简化难题实例

对于通过了预求解器检查的较难实例,接下来会应用两个快速的预处理步骤来简化或缩小问题规模。这些步骤属于“几乎免费”的原语操作,执行速度极快。

步骤三:修剪“简单”站点

第一个想法是,从输入中修剪掉那些约束非常少、无关紧要的站点。

在纯图着色例子中,如果一个顶点的度数小于可用颜色数K,那么无论其邻居如何着色,总有一种颜色剩余可以分配给它。这样的顶点可以先被移除(修剪)。修剪后,可能会出现新的低度数顶点,可以继续修剪。这个过程反复进行,直到没有顶点可修剪为止。

好处

  1. 最终得到的较小图的K可着色性,与原图完全一致。
  2. 在较小的图上运行着色算法会快得多。
  3. 如果小图可着色,可以按修剪的逆序,将“简单”顶点加回去并赋予一个可用的颜色,从而轻松得到原图的着色方案。

在重新打包问题中,我们寻找类似的“简单”站点(即无论其邻居如何分配信道,总有一个信道可用),并进行修剪。

步骤四:分解为连通分量

第二个预处理步骤利用了图论中的连通分量概念。

在纯图着色问题中,如果一个图是不连通的,包含多个连通分量,那么各分量的着色问题完全独立。只需分别检查每个连通分量是否K可着色。只要有一个分量不可着色,整个图就不可着色;如果所有分量都可着色,则合并着色方案即可得到全图的着色。

在重新打包问题中,我们将站点视为图的顶点,如果两个站点可能相互干扰,则在它们之间连一条边。这样,不同连通分量中的站点绝不可能相互干扰。因此,重新打包问题可以完全分解为各个连通分量上的独立子问题。

好处:对于运行时间为超线性(如 O(n^2))的算法,分别解决多个较小实例的总时间,通常远小于直接解决一个大型实例的时间。这带来了显著的加速效果。


⚡ 并行SAT求解器组合:攻克最难题

那些通过了所有预求解器和预处理步骤的“最硬”重新打包实例,需要更强大的工具。直接使用现成的SAT求解器,虽然对一部分实例有效,但无法满足FCC“在一分钟内可靠解决”的要求。

设计者随后利用了两点:

  1. 现代多核处理器:他们使用八核工作站,可以并行运行八个算法。
  2. SAT求解器的性能异质性:对于不同的实例,同一个求解器的运行时间可能相差数个数量级;对于同一个实例,不同求解器的运行时间也可能相差巨大。不同求解器在不同类型的实例上各有专长。

基于以上观察,他们采用了并行SAT求解器组合策略。同时运行8个不同的SAT求解器,只要其中任何一个成功解决了实例,就立即返回答案。

组合的构建:这8个求解器的选择,本身也使用了一种贪心启发式算法(类似于第20章讨论的最大覆盖和影响力最大化问题)。他们顺序选择求解器,每次选择能在代表性实例上,相对于已入选组合的求解器,带来最大边际运行时间改善的那个求解器。

局部搜索的作用:值得一提的是,在这个组合中的几个SAT求解器,本身就是局部搜索算法。它们通过不断微调真值赋值来满足更多约束,在该案例中发挥了基础性作用。

综合运用预求解器、预处理和并行SAT求解器组合,最终使得FCC激励拍卖能够在一分钟内解决超过99%的重新打包实例,这些实例通常包含数万个变量和超过一百万个约束。


🛡️ 处理超时与算法鲁棒性

你可能会问,那剩下的1%实例怎么办?如果可行性检查子程序超时(一分钟内未返回结果),贪婪算法该如何处理?

FCC贪婪算法对其可行性检查子程序的失败具有高度容忍性。当子程序超时,意味着它无法确定 S ∪ V 是否可打包。

在这种情况下,算法必须遵循一个铁律:最终选定的站点集合必须是可打包的。因此,在缺乏可行性保证时,贪婪算法会选择保守策略,假设无法将V加入S,从而放弃V。

虽然这可能导致损失一些潜在价值(因为实际上V可能是可以加入的),但只要超时情况不频繁(正如实际拍卖中那样),这种价值损失是微小的。更重要的是,算法总能在一个可预测的时间内完成,并保证输出一个可行的解决方案。


🎯 总结

本节课我们一起学*了FCC激励拍卖中,用于高效解决NP难可行性检查问题的核心算法思想:

  1. 预求解器:利用问题结构的嵌套性,快速筛选出明显可行或不可行的简单实例。
  2. 预处理:通过修剪无关站点和分解连通分量,大幅简化难题实例的规模。
  3. 并行求解:利用SAT求解器的性能异质性和多核计算能力,并行运行一个精心挑选的求解器组合,以快速攻克最难实例。
  4. 鲁棒性设计:当检查超时时,算法采取保守策略以保证最终结果的可行性,使整个系统对子程序失败具有容忍性。

这些技术的结合,使得处理大规模、复杂的实际NP难问题成为可能。

041:最终成果 🏆

在本节课中,我们将总结FCC激励拍卖的最终成果。我们将回顾拍卖的关键数据、其多阶段运行过程,并理解算法工具如何促成这一复杂项目的成功。

上一节我们详细探讨了FCC激励拍卖的反向拍卖机制。本节中,我们来看看整个拍卖的最终运行结果和实际影响。

拍卖概况与结果

FCC激励拍卖持续了将*一年时间,从2016年3月开始,到2017年3月结束。

以下是拍卖的核心成果数据:

  • 参与方与补偿:在反向拍卖中,有*3000个电视台参与。其中,175个电视台放弃了它们的广播牌照以换取补偿。总补偿金额约为100亿美元,平均每个牌照约5000万美元,但不同地区的差异很大。
  • 频道重新分配:大约有1000个电视台被重新分配了广播频道。

正向拍卖与收入

接下来我们看看正向拍卖的情况,即政府将频谱出售给出价最高的竞标者。

我们从本系列的第一个视频中了解到,被释放的频谱经过了重组。原本是38至51频道,总计84 MHz的频谱,被重组为7对5 MHz的区块。每对区块中,一个用于上行传输,另一个用于下行传输。

在正向拍卖中,电信公司竞标的就是这些频谱“商品”。在全国416个被称为“部分经济区”的区域中,每个区域都出售这7对牌照中的每一个。这导致在正向拍卖中,同时售出了大约3000个牌照

正向拍卖的总收入为200亿美元

政府盈余与多阶段设计

这意味着FCC激励拍卖产生了*100亿美元的利润。在覆盖拍卖成本并处理完几项指定用途后,剩余超过70亿美元被直接用于减少美国财政赤字。这本身就是国会当初授权此拍卖的计划之一。

看到这些数字,你可能会想:政府很幸运,正向拍卖的200亿美元收入刚好超过了反向拍卖约100亿美元的采购成本。实际上,这引出了另一个问题:是谁决定了清理84 MHz(即14个频道)是最合适的频谱量?

事实上,实际的FCC激励拍卖还有一个我之前未提及的外层循环。这个额外的外层循环会向下搜索,以找到最佳的待清理频道数量。这也是拍卖持续*一年的原因之一,因为它经历了多个阶段,尝试了不同的清理目标。

以下是其多阶段搜索过程的简述:

  1. 第一阶段:拍卖非常雄心勃勃地尝试清理21个频道(126 MHz),这足以在每个区域创建10对牌照用于正向拍卖。此阶段严重失败,因为采购成本总计约860亿美元,而正向拍卖收入仅约230亿美元。
  2. 后续阶段:由于政府无意亏损600亿美元,拍卖进入第二阶段,清理目标降至19个频道。拍卖从第一阶段停止的地方恢复进行。
  3. 最终成功阶段:拍卖在第四阶段停止,即清理了14个频道(38至51频道)。这是第一个收入覆盖并超过成本的阶段,在该阶段产生了100亿美元的盈余。

总结与意义

至此,你已从算法角度了解了关于FCC激励拍卖的几乎所有关键信息。

这次拍卖取得了巨大成功。它将无线频谱从价值相对较低的地面电视用途,重新分配给了价值高得多的未来宽带应用。事实上,在未来几年我们享受5G网络时,许多网络使用的正是这次激励拍卖所释放的频谱。

除此之外,拍卖还创造了数十亿美元的盈余,用于减少政府赤字。通过本系列视频的学*,希望你能清楚地看到,若没有用于在实践中解决NP难问题的最先进算法工具箱,这种成功是绝不可能实现的。现在,在学完本书和本系列视频之际,这个工具箱你也可以宣称属于你了。😊

posted @ 2026-03-29 09:46  布客飞龙III  阅读(2)  评论(0)    收藏  举报