UIUC-CS374-算法笔记-全-

UIUC CS374 算法笔记(全)

001:字符串的定义与归纳证明

在本节课中,我们将学习字符串的递归定义,以及如何使用归纳法来证明关于字符串的简单性质。我们将从字符串、长度和连接操作的基本定义开始,然后通过两个具体的证明示例,展示归纳证明的标准结构和书写方式。

字符串的定义

一个字符串是一个有限的符号序列。符号来自一个称为字母表的有限集合,通常用大写希腊字母 Σ 表示。例如,二进制字母表是 {0, 1}。

字符串可以通过递归方式定义:

  • 空字符串,记作 ε。
  • 或者,一个符号 a 后跟一个字符串 x,记作 a · x 或简写为 ax

这个定义意味着任何字符串最终都可以通过有限次地“在字符串前添加一个符号”的操作,从空字符串构建出来。

字符串的长度

字符串的长度是一个将字符串映射到非负整数的函数。其递归定义与字符串的定义结构一致:

  • length(ε) = 0
  • length(ax) = 1 + length(x)

这个定义可以看作一个简单的递归算法:如果字符串非空,则长度等于 1 加上剩余部分(去掉第一个符号)的长度;如果字符串为空,则长度为 0。

字符串的连接

连接操作是将两个字符串“粘合”在一起形成一个新字符串的函数。给定字符串 wz,其连接 w · z 定义如下:

  • 如果 w 是空字符串 ε,则 ε · z = z
  • 如果 w 可以写成 ax(其中 a 是一个符号,x 是一个字符串),则 (ax) · z = a · (x · z)

这个定义同样是一个递归算法:如果第一个字符串非空,则输出其第一个符号,然后递归地将剩余部分与第二个字符串连接。

上一节我们介绍了字符串、长度和连接的基本定义。本节中,我们将使用归纳法来证明关于这些操作的一些基本性质。

归纳证明的结构

归纳法是证明关于递归定义对象(如字符串)的性质的强大工具。我们推荐使用以下结构来书写归纳证明:

  1. 声明任意对象:如果定理以“对于所有字符串...”开头,首先声明一个任意的字符串(或多个字符串)。
  2. 陈述归纳假设:假设定理对于所有“更小”的字符串(例如,所有长度小于当前字符串的字符串)成立。
  3. 进行详尽的情况分析:根据对象的递归定义(例如,字符串是空的或非空的)划分所有可能的情况。
  4. 逐行证明:在每种情况下,通过一系列等式变换进行证明,每一步都基于定义、简单算术、先前结果或归纳假设。

核心思想是:归纳假设相当于在递归调用中,对更小规模的问题,定理已经成立。

定理证明示例一:连接空字符串

定理:对于任意字符串 w,有 w · ε = w

证明
w 为一个任意字符串。我们需要证明 w · ε = w

归纳假设:假设对于所有比 w 短的字符串 x,有 x · ε = x

现在,我们根据字符串 w 的定义进行情况分析。

情况 1w 是空字符串 ε。
以下是该情况下的证明步骤:

w · ε = ε · ε    // 因为 w = ε
      = ε        // 根据连接的定义(第一个参数为空)
      = w        // 因为 w = ε

情况 2w 非空。根据定义,存在一个符号 a 和一个字符串 x,使得 w = ax
以下是该情况下的证明步骤:

w · ε = (a · x) · ε    // 因为 w = a · x
      = a · (x · ε)    // 根据连接的定义
      = a · x          // 根据归纳假设(x 比 w 短)
      = w              // 因为 w = a · x

由于以上两种情况涵盖了所有可能性,因此定理得证。

定理证明示例二:连接操作的长度

定理:对于任意字符串 wz,有 length(w · z) = length(w) + length(z)

证明
wz 为任意字符串。我们需要证明 length(w · z) = length(w) + length(z)

归纳假设:假设对于所有比 w 短的字符串 x,以及相同的字符串 z,有 length(x · z) = length(x) + length(z)

我们根据字符串 w 的定义进行情况分析。

情况 1w 是空字符串 ε。
以下是该情况下的证明步骤:

length(w · z) = length(ε · z)        // 因为 w = ε
              = length(z)            // 根据连接的定义
              = 0 + length(z)        // 基本算术
              = length(ε) + length(z)// 根据长度的定义
              = length(w) + length(z)// 因为 w = ε

情况 2w 非空。根据定义,存在一个符号 a 和一个字符串 x,使得 w = a · x
以下是该情况下的证明步骤:

length(w · z) = length((a · x) · z)          // 因为 w = a · x
              = length(a · (x · z))          // 根据连接的定义
              = 1 + length(x · z)            // 根据长度的定义
              = 1 + (length(x) + length(z))  // 根据归纳假设(x 比 w 短)
              = (1 + length(x)) + length(z)  // 加法结合律
              = length(a · x) + length(z)    // 根据长度的定义
              = length(w) + length(z)        // 因为 w = a · x

由于以上两种情况涵盖了所有可能性,因此定理得证。

总结

本节课中我们一起学习了字符串的递归定义,以及如何使用归纳法来证明关于字符串操作的性质。我们重点掌握了归纳证明的标准书写结构:

  1. 声明任意对象。
  2. 陈述(强)归纳假设。
  3. 进行基于定义的、详尽的情况分析。
  4. 在每种情况下给出清晰的、逐步的推导。

通过两个简单但完整的证明示例,我们展示了如何将直觉上“显然”的性质,转化为严谨的、基于定义和归纳假设的数学论证。这种方法是解决更复杂算法问题的基础。

002:归纳法、语言与正则表达式

在本节课中,我们将要学习归纳法在字符串证明中的应用,并引入形式语言理论中的核心概念:语言和正则表达式。我们将从回顾一个归纳法证明开始,然后探讨如何描述和操作字符串的集合。

归纳法证明回顾

上一节我们介绍了归纳法证明的基本思想。本节中,我们来看一个具体的例子:证明字符串连接操作满足结合律。

定理:对于任意字符串 W, Y, Z,有 (W · Y) · Z = W · (Y · Z)

证明
W, Y, Z 为任意字符串。我们将对 W 进行归纳证明。

情况 1W 非空。
假设 W = a · X,其中 a 是一个字符,X 是一个字符串。
根据连接操作的定义,我们可以展开等式左边:
(W · Y) · Z = ((a · X) · Y) · Z = a · ((X · Y) · Z)
根据归纳假设(因为 XW 短),我们有 (X · Y) · Z = X · (Y · Z)
因此,上式等于 a · (X · (Y · Z))
再次根据连接操作的定义,这等于 (a · X) · (Y · Z) = W · (Y · Z)

情况 2W 为空字符串 ε
(ε · Y) · Z = Y · Z (根据连接定义)
ε · (Y · Z) = Y · Z (根据连接定义)
因此,(ε · Y) · Z = ε · (Y · Z)

综上,对于所有字符串 W, Y, Z,结合律成立。

语言:字符串的集合

在讨论了单个字符串之后,我们自然要问:一个算法会接受哪些字符串?会拒绝哪些字符串?这就需要我们研究字符串的集合。

定义:给定一个字母表 Σ,一个 语言 是 Σ*(所有由 Σ 中字符构成的字符串的集合)的任意子集。

以下是一些语言的例子:

  • Σ*:所有字符串。
  • ∅:空集(不包含任何字符串)。
  • {ε}:只包含空字符串的集合。
  • {所有二进制字符串中,0的数量等于1的数量}。
  • {所有合法的Python程序}。

语言的操作

因为语言是集合,所以我们可以使用标准的集合操作,如并集(∪)、交集(∩)、差集(\)和补集。
此外,我们还可以定义两种专用于语言的新操作:

  1. 连接:语言 A 和 B 的连接记作 A · B,定义为所有字符串 x · y 的集合,其中 x ∈ Ay ∈ B

    • 示例:{“a”, “b”} · {“1”, “2”} = {“a1”, “a2”, “b1”, “b2”}。
    • 注意:∅ · L = L · ∅ = ∅;{ε} · L = L · {ε} = L。
  2. Kleene星号:语言 L 的 Kleene 星号记作 L*,定义为可以通过连接 L 中的字符串零次或多次得到的所有字符串的集合。

    • 形式化定义:L* = {ε} ∪ L ∪ (L · L) ∪ (L · L · L) ∪ ...
    • 示例:若 L = {“01”},则 L* = {ε, “01”, “0101”, “010101”, ...}。
    • 重要特例:Σ* 就是所有字符串的集合,这正是将字母表 Σ 视为单字符字符串语言后取 Kleene 星号的结果。

正则语言与正则表达式

利用上述操作(空集、单字符串、并集、连接、Kleene星号),我们可以构建出一类非常重要的语言,称为正则语言

定义:一个语言是正则的,当且仅当它可以通过以下规则从基础语言构建出来:

  1. ∅(空集)是正则的。
  2. {ε}(仅包含空串)是正则的。
  3. 对于字母表中的任意单个字符 a,{“a”} 是正则的。
  4. 如果 A 和 B 是正则语言,那么 A ∪ B(并集)也是正则的。
  5. 如果 A 和 B 是正则语言,那么 A · B(连接)也是正则的。
  6. 如果 A 是正则语言,那么 A*(Kleene星号)也是正则的。

为了更简洁地描述正则语言,我们使用正则表达式。这是一种紧凑的符号表示法,与上述定义规则一一对应。

正则表达式语法

  • 表示空集。
  • ε 表示语言 {ε}。
  • 字母表中的字符 a 表示语言 {“a”}。
  • 如果 R 和 S 是正则表达式,那么:
    • R + S 表示对应语言的并集(等价于 R ∪ S)。
    • RS(或 R·S)表示对应语言的连接。
    • R* 表示对应语言的 Kleene 星号。

运算符优先级:Kleene星号 (*) 优先级最高,接着是连接,最后是加号 (+)。括号可以用来改变优先级。

示例:正则表达式 0 + 10* 描述的语言是:一个单独的 0,或者一个 1 后面跟着零个或多个 0。即 {“0”, “1”, “10”, “100”, “1000”, ...}。

构建正则表达式示例

让我们尝试为“所有由0和1交替组成的字符串(即不包含’00’或’11’)”这一语言构建正则表达式。

思路:先寻找主要的重复模式。一个明显的模式是 (01)*,它生成如 ε, 01, 0101, ... 的字符串。但这漏掉了一些以 1 开头或以 0 结尾的字符串。

解决方案:在主要模式前后添加可选的部分。

  • 开头可以是 ε(空,直接进入(01)*模式)或 1(如果以1开头)。
  • 结尾在模式 (01)* 之后,可以是 ε(直接结束)或 0(如果最后多一个0)。

因此,一个可能的正则表达式是:(ε + 1)(01)*(ε + 0)
这等价于:(01)* + 1(01)* + (01)*0 + 1(01)*0,覆盖了所有情况。

另一种思考方式是状态机:我们处于“下一个期望字符是0”或“下一个期望字符是1”两种状态之一,并在读取字符后在这两个状态间切换。这引出了我们下节课将要学习的确定性有限自动机的概念。

总结

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

  1. 使用归纳法严谨地证明了字符串连接操作的结合律。
  2. 引入了语言作为字符串集合的概念。
  3. 学习了语言的基本操作:并、交、差、补,以及特有的连接Kleene星号操作。
  4. 定义了正则语言,即可以通过空集、单字符串、并、连接和Kleene星号这些基本构件组合而成的语言。
  5. 介绍了正则表达式作为一种简洁描述正则语言的符号系统,并通过例子演示了如何为特定模式构建正则表达式。

这些概念是形式语言理论和编译器设计的基础,下一讲我们将探讨如何用机器(自动机)来识别这些正则语言。

003:确定性有限自动机(DFA)介绍

在本节课中,我们将要学习一种称为确定性有限自动机(DFA)的简单计算模型。我们将了解它的形式化定义,并通过多个例子学习如何设计和理解DFA,以及它们如何识别特定的语言。

有限状态机简介

在上一讲中,我们讨论了字符串集合(语言)。我们可以将语言看作一个布尔函数的输出:输入一个字符串,如果该字符串“好”(属于该语言),则返回“是”,否则返回“否”。

本节中,我们将看看能够主动计算这些布尔函数的简单机器。这些机器接收一个字符串,并输出一个比特(0或1)。一个关键特性是,这些机器只能处于有限数量的配置或“状态”中。例如,一个魔方、一个机械计算器或一个时钟,它们都只有有限数量的可能状态。这种“有限状态”的特性使得我们能够精确地推理这些机器能做什么。

第一个DFA示例:长度为5的倍数的字符串

让我们从一个简单的语言开始:所有由0和1组成的、长度能被5整除的字符串集合。

核心概念:为了判断一个字符串是否属于这个语言,我们不需要记住整个字符串的长度,只需要记住当前读到的字符数量除以5的余数。这个余数只能是0、1、2、3、4这五种情况之一。

我们可以用一个有5个状态的DFA来表示这个过程。状态q表示“已读字符数 mod 5 = q”。

以下是该DFA的组成部分:

  • 状态集 Q{0, 1, 2, 3, 4}
  • 起始状态 s0 (开始前,已读0个字符)
  • 接受状态集 A{0} (只有当最终余数为0时,字符串长度才是5的倍数)
  • 转移函数 δ: 对于任何状态q和输入符号a(0或1),下一个状态是 (q + 1) mod 5。因为每读一个字符,计数就加1。

我们可以用状态图来可视化这个DFA:

  • 有5个圆圈,分别标记为0到4。
  • 起始状态0有一个指向它的箭头。
  • 接受状态0用双圈表示。
  • 每个状态都有两条箭头指向下一个状态:δ(q, 0) = (q+1) mod 5δ(q, 1) = (q+1) mod 5

模拟运行

  • 输入字符串“110”。从状态0开始。
    • 1 -> 到状态1。
    • 1 -> 到状态2。
    • 0 -> 到状态3。
    • 结束。最终状态是3,不是接受状态0,因此该字符串被拒绝
  • 输入空字符串“”。开始并结束于状态0,这是接受状态,因此被接受(因为0是5的倍数)。

DFA的形式化定义

现在,我们给DFA一个精确的数学定义。

一个确定性有限自动机(DFA) 是一个五元组 M = (Q, Σ, δ, s, A)

  • Q 是一个有限非空集合,称为状态集
  • Σ 是一个有限集合,称为字母表(例如,{0, 1})。
  • δ : Q × Σ → Q转移函数。它接收当前状态和一个输入符号,并决定下一个状态。
  • s ∈ Q起始状态
  • A ⊆ Q接受状态集

在我们的第一个例子中:

  • Q = {0,1,2,3,4}
  • Σ = {0,1}
  • δ(q, a) = (q + 1) mod 5
  • s = 0
  • A = {0}

扩展转移函数与语言接受

转移函数δ只处理单个输入符号。为了处理整个字符串,我们定义扩展转移函数 δ* : Q × Σ* → Q。它递归地定义如下:

  1. δ*(q, ε) = q (对于空字符串ε,状态不变)
  2. δ*(q, ax) = δ*( δ(q, a), x) (对于字符串ax,先根据第一个符号a转移一次,然后对剩余字符串x递归处理)

核心概念δ*(s, w) 给出了DFA从起始状态s开始,处理完整个字符串w后所到达的状态。

一个DFA M接受的语言 L(M) 定义为:
L(M) = { w ∈ Σ* | δ*(s, w) ∈ A }
即,所有能使DFA从起始状态开始,运行完毕后停在某个接受状态中的字符串的集合。

更多DFA设计示例

上一节我们介绍了DFA如何识别长度特性。本节中我们来看看如何识别字符串中的模式。

示例1:识别恰好包含6个‘1’的字符串

  • 思路:我们需要计算‘1’的数量,但同样只需跟踪数量除以6的余数。
  • 状态Q = {0,1,2,3,4,5},状态q表示“已读‘1’的数量 mod 6 = q”。
  • 起始状态0
  • 接受状态{0}(只有当‘1’的总数是6的倍数,即余数为0时,才恰好是6的倍数?注意:题目是“恰好包含6个‘1’”,但通常DFA更易识别“包含6k个‘1’”。严格“恰好6个”需要更多状态或不同构造,此处教授意图可能是“mod 6”。为准确,我们按视频描述“number of ones is divisible by 6”来构造)。
  • 转移函数
    • 0δ(q, 0) = q (状态不变,因为‘0’不影响‘1’的计数)。
    • 1δ(q, 1) = (q + 1) mod 6

示例2:识别包含子串“11”的字符串

  • 思路:我们需要记住最近看到的历史,特别是是否刚刚看到一个‘1’,以及是否已经看到了“11”。
  • 状态设计
    • 状态0:尚未看到‘1’,或者上一个字符是‘0’。未发现“11”。
    • 状态1:上一个字符是‘1’,但尚未发现连续的“11”。
    • 状态Y (接受):已经看到了子串“11”。
  • 转移函数
    • 状态0
      • 0: 留在状态0。
      • 1: 进入状态1(刚看到一个‘1’)。
    • 状态1
      • 0: 回到状态0(序列中断)。
      • 1: 进入状态Y(发现了“11”)。
    • 状态Y (接受)
      • 01: 留在状态Y(一旦发现“11”,无论后面是什么都接受)。
  • 接受状态集{Y}

这个DFA接受的语言可以用正则表达式描述为: (0+1)*11(0+1)*

从算法到DFA

任何只使用固定数量、取值范围有限的变量的算法,其行为都可以用一个DFA来模拟。因为算法所有可能的变量取值组合是有限的,每种组合可以对应DFA的一个状态。

考虑判断字符串是否包含“11”的算法:

found = False
last_two = “”
for each character a in input string:
    last_two = (last_two + a)[-2:] # 保持最后两个字符
    if last_two == “11”:
        found = True
return found

算法状态由变量found(True/False)和last_two(可能值:””, “0”, “1”, “00”, “01”, “10”, “11”)共同决定。这最多有2 * 7 = 14种状态组合,虽然比我们之前设计的3状态DFA大得多,但它确实对应一个DFA。存在算法可以将任何DFA最小化(合并等价状态,删除不可达状态),得到最简形式的DFA。

应用:识别能被5整除的二进制数

最后,我们看一个更有趣的例子:设计一个DFA,识别那些表示能被5整除的整数的二进制串。

核心概念:我们边读二进制位边计算数值除以5的余数。设当前余数为r,读到一个新比特a(0或1),则新的数值为 2r + a。因此新的余数为 (2r + a) mod 5

DFA构造

  • 状态集 Q{0, 1, 2, 3, 4},代表当前余数r
  • 起始状态 s0(空字符串代表数字0)。
  • 接受状态集 A{0}(余数为0表示能被5整除)。
  • 转移函数 δδ(r, a) = (2r + a) mod 5

例如,二进制数1010(十进制10):

  • 开始: r=0
  • 1r = (2*0+1) mod 5 = 1
  • 0r = (2*1+0) mod 5 = 2
  • 1r = (2*2+1) mod 5 = 5 mod 5 = 0
  • 0r = (2*0+0) mod 5 = 0
  • 结束于状态0(接受状态),因此1010(10)能被5整除。

总结

本节课中我们一起学习了确定性有限自动机(DFA)的基本概念。我们了解到:

  1. DFA是一种具有有限数量状态的简单计算模型。
  2. 它由状态集、字母表、转移函数、起始状态和接受状态集形式化定义。
  3. DFA通过扩展转移函数处理字符串,并接受那些能使它从起始状态运行到某个接受状态的所有字符串。
  4. 我们可以为多种语言设计DFA,例如基于长度、符号计数、模式出现或数值特性的语言。
  5. 任何只使用有限内存的算法,其核心逻辑都可以用一个DFA来表示。
    DFA是正则语言的识别器,它与正则表达式有着等价的计算能力,这为我们研究形式语言和计算理论奠定了基础。

004:DFA乘积构造与状态最小化

在本节课中,我们将学习确定性有限自动机(DFA)的两个重要概念:乘积构造和状态最小化。我们将看到如何通过组合两个简单的DFA来构建一个识别更复杂语言的DFA,以及如何识别并合并DFA中的冗余状态,从而得到一个更简洁、更高效的自动机。

DFA 回顾

上一节我们介绍了确定性有限自动机(DFA)的基本概念。一个DFA M 可以形式化地定义为一个四元组 M = (Q, Σ, δ, s, A)

  • Q 是有限状态集。
  • Σ 是有限的输入字母表。
  • δ: Q × Σ → Q 是转移函数。
  • s ∈ Q 是唯一的起始状态。
  • A ⊆ Q 是接受状态集。

DFA 通过读取输入字符串,并根据转移函数在状态间移动来工作。如果读完字符串后,DFA 停留在某个接受状态 q ∈ A,则该字符串被接受。DFA M 接受的所有字符串的集合称为 M 的语言,记作 L(M)

乘积构造

本节中,我们将探讨如何通过“乘积构造”将两个DFA组合成一个新的DFA。这个新DFA可以识别原有两个DFA语言的布尔组合(如交集、并集等)。

构造动机

假设我们有两个DFA:M1 识别语言 L1(例如,包含连续两个“1”的字符串),M2 识别语言 L2(例如,包含连续两个“0”的字符串)。我们想构建一个DFA来识别 L1 ∩ L2(即同时包含连续两个“1”和连续两个“0”的字符串)。

一个直观的想法是同时运行 M1M2。对于输入字符串中的每个字符,我们同时将其馈送给 M1M2,让它们各自独立地更新状态。当输入结束时,只有当 M1M2 都处于接受状态时,我们才接受整个字符串。

形式化定义

给定两个DFA:

  • M1 = (Q1, Σ, δ1, s1, A1)
  • M2 = (Q2, Σ, δ2, s2, A2)

我们定义它们的乘积DFA M = (Q, Σ, δ, s, A) 如下:

  1. 状态集 QQ = Q1 × Q2。新DFA的每个状态是一个有序对 (p, q),其中 p ∈ Q1q ∈ Q2。这代表了 M1 处于状态 pM2 处于状态 q 的“组合状态”。
  2. 起始状态 ss = (s1, s2)。我们从两个DFA的起始状态开始。
  3. 转移函数 δ:对于任意状态 (p, q) ∈ Q 和符号 a ∈ Σ,定义 δ((p, q), a) = (δ1(p, a), δ2(q, a))。这意味着我们分别应用 M1M2 的转移函数。
  4. 接受状态集 A:这里取决于我们想要实现哪种布尔运算。
    • 对于交集 L1 ∩ L2A = {(p, q) | p ∈ A1 **且** q ∈ A2}。要求两个组件DFA都接受。
    • 对于并集 L1 ∪ L2A = {(p, q) | p ∈ A1 **或** q ∈ A2}。要求至少一个组件DFA接受。
    • 对于其他布尔运算(如对称差、差集等),可以相应地定义接受条件。

关键引理与正确性

乘积构造的正确性基于一个关键引理,它描述了扩展转移函数 δ* 在乘积DFA中的行为:

引理:对于任意状态 p ∈ Q1, q ∈ Q2 和任意字符串 w ∈ Σ*,有:
δ*((p, q), w) = (δ1*(p, w), δ2*(q, w))

这个引理可以通过对字符串 w 的长度进行归纳来证明。它保证了乘积DFA在读取字符串 w 后到达的状态,正好是 M1M2 分别读取 w 后到达的状态对。

基于此引理,可以证明乘积DFA的语言确实是原语言按定义方式组合的结果。例如,对于交集构造:
w ∈ L(M) 当且仅当 δ*((s1, s2), w) ∈ A
当且仅当 (δ1*(s1, w), δ2*(s2, w)) ∈ {(p, q) | p ∈ A1 且 q ∈ A2}
当且仅当 δ1*(s1, w) ∈ A1 δ2*(s2, w) ∈ A2
当且仅当 w ∈ L(M1) w ∈ L(M2)
当且仅当 w ∈ L1 ∩ L2

乘积构造的意义

乘积构造展示了DFA的一个重要特性:状态可以分解为组件。新DFA的状态是组件DFA状态的组合。这种“状态即数据”的思想在计算机科学中反复出现,例如在动态规划(状态表示子问题的解)和图算法中。

此外,乘积构造自动产生的DFA可能包含冗余状态(例如从起始状态无法到达的状态,或者行为完全相同的状态),但这并不影响其正确性。我们通常先进行构造,再考虑优化。

状态最小化

上一节我们通过乘积构造得到了可能包含冗余状态的DFA。本节中,我们来看看如何识别并消除这些冗余,得到一个状态数最少的等价DFA。

状态等价与可区分性

两个DFA状态 pq 被称为可区分的,如果存在一个字符串 w,使得从 p 出发读取 w 到达的状态是接受状态,而从 q 出发读取 w 到达的状态是非接受状态(或者反之)。换句话说,存在一个“证据”字符串 w 能揭示 pq 行为的不同。

如果不存在这样的字符串,则称 pq 等价不可区分。等价的状态在功能上完全一样,可以合并为一个状态而不改变DFA接受的语言。

最小化算法概述

DFA最小化的核心思想是将所有状态划分成等价类,每个等价类内的状态彼此等价,然后将每个等价类压缩成一个单一状态。算法通常从一个粗糙的划分开始,然后不断细化:

  1. 初始划分:将所有状态分为两个组:接受状态组和非接受状态组。显然,接受状态和非接受状态是可区分的。
  2. 迭代细化:检查每个组内的状态对 (p, q)。对于字母表中的每个符号 a,查看 δ(p, a)δ(q, a) 是否属于当前划分中的不同组。如果是,则 pq 在当前划分下就是可区分的,应该被分到不同的组。重复此过程,直到划分不再改变。
  3. 构建最小DFA:最终的每个组成为一个新的状态。新DFA的转移函数定义为:从组 G 在符号 a 上转移到包含 δ(p, a) 的组(pG 中任意代表状态)。起始状态是包含原起始状态的组。接受状态是那些包含至少一个原接受状态的组。

示例

考虑一个识别“包含连续两个1”的DFA,经过最小化后,我们得到了熟悉的三个状态的结构:

  • 状态 S:尚未看到连续两个1。
  • 状态 A:刚看到一个1,但尚未形成连续两个1。
  • 状态 B:已经看到连续两个1(吸收状态)。

原DFA中其他更复杂的状态都被证明与这三个状态之一等价,因此被合并。

总结

本节课中我们一起学习了DFA的两个核心操作。

首先,我们深入探讨了乘积构造。通过将两个DFA的状态集进行笛卡尔积,并同步运行它们的转移函数,我们可以构建一个新的DFA,用于识别原语言的各种布尔组合(如交集、并集)。这体现了DFA的模块化能力和“组合状态”的思想。

其次,我们介绍了状态最小化的概念。通过定义状态的“可区分性”,我们可以系统地识别并合并DFA中的冗余状态,从而得到唯一(在同构意义下)的状态数最少的等价DFA。这帮助我们优化自动机,并加深对状态本质的理解。

这些工具不仅本身有用,也为后续证明某些语言不是正则语言(需要无穷多个状态来区分)奠定了基础。

005:Fooling集合与非确定性有限自动机(NFA)入门

在本节课中,我们将学习两种重要的概念:Fooling集合和NFA。首先,我们将探讨如何使用Fooling集合来证明一个语言不是正则的,并确定DFA的最小状态数。接着,我们将初步了解非确定性有限自动机(NFA)的基本定义和工作原理。

课程概述

本节课分为两个主要部分。第一部分,我们将深入探讨Fooling集合的概念,学习如何用它来证明一个语言不是正则的,并理解其与最小DFA状态数的关系。第二部分,我们将引入非确定性有限自动机(NFA),了解其与确定性有限自动机(DFA)的区别,并初步认识其形式化定义。

第一部分:Fooling集合

上一节我们介绍了DFA的乘积构造和状态最小化。本节中,我们来看看如何形式化地证明一个DFA是最小的,以及如何证明一个语言不是正则的。核心工具是Fooling集合。

状态的可区分性

在讨论Fooling集合之前,我们需要理解两个DFA状态何时是“可区分的”。

两个状态 qq' 被称为可区分的,当且仅当存在一个字符串 w,使得从 qq' 出发,读取 w 后到达的状态中,恰好有一个是接受状态。字符串 w 被称为区分这两个状态的后缀

递归地看,两个状态可区分,要么是因为其中一个本身就是接受状态而另一个不是(此时区分后缀是空串 ε),要么是因为存在某个输入符号 a,使得 δ(q, a)δ(q', a) 是可区分的。

从状态到字符串:Fooling集合概念

我们可以将状态可区分性的概念推广到语言本身,而不依赖于特定的DFA。

对于某个语言 L,两个字符串 xy 被称为可区分的,当且仅当存在一个字符串 z,使得 xz ∈ Lyz ∈ L,但不同时成立。字符串 z 是区分后缀。

一个字符串集合 F 被称为语言 L 的一个Fooling集合,如果 F 中任意两个不同的字符串都是可区分的(关于语言 L)。

Fooling集合的意义

Fooling集合的关键意义在于:任何识别语言 L 的DFA,其状态数必须至少等于 L 的任意一个Fooling集合的大小

原因在于,对于Fooling集合 F 中的每个字符串 w,在任何一个识别 L 的DFA中,从起始状态读取 w 后到达的状态必须是唯一的。如果两个不同的字符串 xy 到达了同一个状态,那么对于任何后缀 zxzyz 要么同时被接受,要么同时被拒绝,这与 xyF 中可区分(即存在一个 z 使得恰好一个被接受)相矛盾。

因此,如果我们能为语言 L 找到一个大小为 k 的Fooling集合,那么任何识别 L 的DFA至少需要 k 个状态。如果我们还能构造出一个恰好有 k 个状态的DFA来识别 L,那么我们就证明了最小DFA的状态数就是 k

应用:证明语言非正则

Fooling集合最强大的应用之一是证明某个语言不是正则的。

核心思路:如果一个语言 L 是正则的,那么它必然能被某个具有有限状态数 n 的DFA识别。因此,L 的任何一个Fooling集合的大小也必须是有限的(最多为 n)。反之,如果我们能为 L 构造出一个无限的Fooling集合,那么就证明了 L 不可能是正则的。

以下是使用Fooling集合证明语言非正则的标准论证模板:

  1. 定义语言 L
  2. 构造一个无限的字符串集合 F
  3. 证明 FL 的Fooling集合:即,任取 F 中两个不同的字符串 xy,都能找到一个区分后缀 z,使得 xz ∈ Lyz ∉ L(或反之)。
  4. 由于 F 是无限的,而任何DFA的状态数是有限的,所以不存在能识别 L 的DFA,即 L 不是正则的。

示例一:语言 {0^n 1^n | n ≥ 0}

证明

  • 语言 L: {0^n 1^n | n ≥ 0}
  • 构造Fooling集合 F: {0^n | n ≥ 0}(即所有仅由0组成的字符串)。
  • 证明 F 是 Fooling 集合
    • 任取 F 中两个不同的字符串 x = 0^iy = 0^ji ≠ j)。
    • 令区分后缀 z = 1^j
    • xz = 0^i 1^j。由于 i ≠ jxz ∉ L
    • yz = 0^j 1^j ∈ L
    • 因此,z 区分了 xy
  • 结论FL 的一个无限Fooling集合,故 L 不是正则的。

示例二:语言(回文串集合)

证明

  • 语言 L: {w ∈ {0,1}* | w = w^R}(所有回文串)。
  • 构造Fooling集合 F: {0^n 1 | n ≥ 0}(即一个由n个0后跟一个1组成的字符串)。
  • 证明 F 是 Fooling 集合
    • 任取 F 中两个不同的字符串 x = 0^i 1y = 0^j 1i ≠ j)。
    • 令区分后缀 z = 0^i
    • xz = 0^i 1 0^i。这是一个回文串(中心是1),所以 xz ∈ L
    • yz = 0^j 1 0^i。由于 i ≠ j,1不在字符串正中心,所以 yz 不是回文串,即 yz ∉ L
    • 因此,z 区分了 xy
  • 结论FL 的一个无限Fooling集合,故 L 不是正则的。

关键点:在寻找Fooling集合时,直觉上要抓住语言中需要“记住”或“计数”的无限信息。区分后缀通常被设计用来“检验”这个被记住的信息。

第二部分:非确定性有限自动机(NFA)简介

在结束了Fooling集合的讨论后,我们转向一种新的计算模型。本节中,我们将初步了解非确定性有限自动机(NFA),它是连接DFA和正则表达式的关键桥梁。

从确定性到非确定性

DFA是“确定性”的:在任何状态,读取一个输入符号后,下一个状态由转移函数唯一确定

NFA引入了“非确定性”:在任何状态,读取一个输入符号后,可能有零个、一个或多个可能的下一个状态。机器可以“选择”进入其中任何一个状态。直观上,你可以想象机器同时探索所有可能的选择路径(就像创建了平行宇宙)。

NFA的形式化定义

一个NFA是一个五元组:(Q, Σ, δ, s, A)

  • Q:有限状态集。
  • Σ:有限输入字母表。
  • δ:转移函数,形式为 δ: Q × Σ → P(Q)。这里 P(Q)Q 的幂集(即所有子集的集合)。δ(q, a) 给出的是在读入符号 a 后,从状态 q 出发所有可能到达的状态的集合
  • sQ:起始状态。
  • AQ:接受状态集。

NFA如何运行?

NFA接受一个字符串 w 的条件是:存在至少一条从起始状态 s 出发,根据转移函数 δ 选择路径,最终能到达某个接受状态 f ∈ A 的路径。

即使存在许多条路径最终到达拒绝状态,只要有一条路径到达接受状态,整个NFA就接受该输入字符串。

运行示例

考虑一个识别“以两个连续0或两个连续1结尾”的字符串的NFA。

  • 从起始状态,读入每个字符时,它都可以选择“留在起始状态”或“开始检查模式”。
  • 如果它“猜”对了模式开始的位置,并且后续输入匹配了模式(00或11),那么这条路径就会到达接受状态。
  • 只要输入字符串确实以00或11结尾,就存在这样一条“正确猜测”的路径,因此NFA接受该字符串。

NFA与DFA的关系

尽管NFA看起来比DFA更强大(因为它可以“猜测”),但在识别语言的能力上,它们是完全等价的。对于任何一个NFA,都存在一个识别完全相同语言的DFA(反之亦然)。这个DFA的状态集可以是原NFA状态集的幂集,模拟NFA所有可能的状态集合。我们将在后续课程中详细学习这种转换。

课程总结

本节课中我们一起学习了两个核心内容。

首先,我们深入探讨了Fooling集合。我们学习了如何定义字符串和状态的可区分性,理解了Fooling集合如何为语言所需的最小DFA状态数提供一个下界。更重要的是,我们掌握了使用无限Fooling集合来证明一个语言不是正则语言的通用方法,并通过 {0^n 1^n} 和回文串语言两个例子进行了实践。

其次,我们引入了非确定性有限自动机(NFA)。我们了解了NFA与DFA的关键区别在于其转移函数的非确定性,它允许从一个状态对同一个输入符号有多个可能的后续状态。我们讨论了NFA如何通过“存在一条接受路径”来定义字符串的接受,并指出了NFA与DFA在计算能力上是等价的。

这些概念为我们接下来学习克莱尼定理(正则表达式、DFA、NFA的等价性)以及更复杂的计算模型奠定了基础。

006:NFA变体与(大部分)克林定理 🧩

在本节课中,我们将学习非确定性有限自动机(NFA)的几种变体,并探讨克林定理的核心思想,即正则表达式、NFA和DFA在表达能力上是等价的。我们将看到如何在这些表示形式之间进行转换。

上一节我们介绍了NFA的基本概念,本节中我们来看看NFA的一些有用变体。

NFA的变体

NFA的定义可以灵活调整,以简化设计过程,同时不增加其计算能力。

允许多个起始状态

在标准的NFA定义中,只有一个起始状态。然而,我们可以允许NFA拥有多个起始状态。当机器开始运行时,它会非确定性地选择从哪个起始状态开始。

以下是如何将一个具有多个起始状态的NFA转换为只有一个起始状态的NFA:

  1. 创建一个新的起始状态 S_bar
  2. S_bar 出发,添加指向原NFA中所有起始状态的ε(空字符)转移。

这样,新NFA从 S_bar 开始,经过一次ε转移后,其行为就与原NFA完全相同。因此,允许多个起始状态只是一种设计上的便利。

允许ε转移

ε转移允许NFA在不消耗任何输入字符的情况下改变状态。这为设计NFA提供了极大的灵活性。

例如,一个状态 P 可以通过ε转移到状态 Q。这意味着,当机器处于状态 P 时,它可以“免费”地瞬间移动到状态 Q,而无需读取输入字符串中的下一个字符。

为了处理ε转移,我们定义了一个状态的 ε闭包

定义:状态 q 的ε闭包,记作 EpsilonReach(q),是所有可以从 q 出发,仅通过一系列ε转移就能到达的状态的集合。

我们可以利用ε闭包,将一个带有ε转移的NFA转换为一个等价的、不带ε转移的NFA。转换方法如下:

  1. 新NFA的起始状态集合是原NFA起始状态的ε闭包。
  2. 新NFA的接受状态集合保持不变。
  3. 对于新NFA中的每个状态(即原NFA的一个状态集合 P)和每个输入符号 a,其转移目标是:先对 P 中每个状态进行 a 转移,然后取所有这些目标状态的ε闭包的并集。

通过这种方式,我们消除了ε转移,但NFA接受的语言保持不变。

上一节我们看到了如何将NFA“简化”为更受限制的形式。接下来,我们将探讨一个核心问题:如何将表达能力看似更强的NFA,转换为确定性的DFA。

从NFA到DFA:子集构造法

NFA的非确定性虽然方便设计,但执行起来似乎难以捉摸。子集构造法揭示了NFA和DFA之间的深刻联系:任何NFA都可以转换为一个接受相同语言的DFA

其核心思想是:让DFA的每个状态,代表NFA在某个时刻可能处于的所有状态的一个集合

定义:给定一个NFA,其状态集为 Q,转移函数为 delta。我们可以定义一个DFA:

  • 状态集 Q'Q 的所有子集的集合,即幂集 2^Q
  • 起始状态 S'{s},其中 s 是NFA的起始状态(如果NFA有ε转移,则起始状态是 s 的ε闭包)。
  • 接受状态集 F':所有包含至少一个NFA接受状态的子集。
  • 转移函数 delta':对于DFA状态 P(即NFA状态的一个子集)和输入符号 a,其转移目标是:
    delta'(P, a) = ⋃_{q in P} delta(q, a)
    
    如果原NFA有ε转移,则此处 delta(q, a) 应理解为“进行 a 转移后再取ε闭包”。

这个构造出来的DFA模拟了NFA所有可能的执行路径。虽然理论上DFA的状态数可能高达 2^nn 是NFA状态数),但许多状态可能是从起始状态不可达的。

在实践中,我们使用 增量子集构造法(本质上是广度优先搜索BFS)来只生成可达的DFA状态:

  1. 从DFA的起始状态(即NFA起始状态的ε闭包)开始。
  2. 对于每个新发现的DFA状态 P,计算它对每个输入符号 a 的转移目标 delta'(P, a)
  3. 如果转移目标是一个新的状态集合,则将其加入待探索队列。
  4. 重复步骤2和3,直到没有新的状态集合出现。

这个过程生成的DFA通常比完整的幂集要小得多。

我们已经建立了NFA和DFA之间的等价性。现在,让我们看看如何将另一种强大的工具——正则表达式——纳入这个等价的框架中。

从正则表达式到NFA:汤普森构造法

克林定理的另一半是证明正则表达式也可以转换为等价的NFA。这里我们使用 汤普森构造法,它基于正则表达式的递归结构进行构造。

该算法为每个正则表达式 R 递归地构建一个具有以下特性的NFA:恰好一个起始状态和一个接受状态,并且这两个状态是不同的

以下是针对正则表达式五种情况的构造:

基础情况:

  1. R = ∅(空集):构造一个不接受任何字符串的NFA。它有一个起始状态和一个不接受状态,两者之间没有转移。
     (S) -...无转移...-> (F)
    
  2. R = ε(空串):构造一个只接受空串的NFA。通过一条ε转移连接起始状态和接受状态。
     (S) --ε--> (F)
    
  3. R = a(单个字符):构造一个接受单个字符 a 的NFA。通过一个标记为 a 的转移连接起始状态和接受状态。
     (S) --a--> (F)
    

递归情况:
假设我们已经为子表达式 AB 构建了NFA,其起始和接受状态分别为 As, AfBs, Bf
4. R = A | B(并):创建一个新的起始状态 S 和新的接受状态 F。从 S 添加ε转移到 AsBs。从 AfBf 添加ε转移到 F
ε (S) ----> (As) ... (Af) --ε--> (F) \ ^ \ ε / `-----> (Bs) ... (Bf) --ε-‘
5. R = AB(连接):将 A 的接受状态 AfB 的起始状态 Bs 用ε转移连接起来。整个NFA的起始状态是 As,接受状态是 Bf
(As) ... (Af) --ε--> (Bs) ... (Bf)
6. R = A*(克林星号):创建一个新的起始状态 S 和新的接受状态 F。添加以下ε转移:
* SAs(进入循环)。
* SF(跳过循环,对应零次重复)。
* AfAs(继续循环)。
* AfF(退出循环)。
ε +---> (As) ... (Af) --+ | ^ | (S) --ε-| |--ε--> (F) | | | +------ε--------------+

通过递归地应用这些规则,我们可以为任何给定的正则表达式构建出一个等价的NFA。这个NFA通常包含许多ε转移,但我们可以使用之前介绍的方法将其消除,或者直接使用子集构造法将其转换为DFA。

本节课中我们一起学习了NFA的两种实用变体(多起始状态和ε转移),深入探讨了将NFA转换为等价DFA的子集构造法及其增量实现(BFS),并介绍了将正则表达式转换为NFA的汤普森构造法。这些内容构成了克林定理的核心部分,揭示了正则表达式、NFA和DFA在描述正则语言上的内在统一性。在下节课中,我们将完成克林定理的最后一块拼图:如何从DFA转换回正则表达式。

007:语言变换

在本节课中,我们将学习如何对正则语言进行变换,并证明变换后的语言仍然是正则的。我们将通过构造新的自动机来实现这些变换,并理解其背后的核心思想。


概述

我们已知正则语言有三种等价的表示方式:正则表达式、确定性有限自动机(DFA)和非确定性有限自动机(NFA)。此外,正则语言在并集、交集和补集等布尔运算下是封闭的。本节课,我们将探讨其他几种对语言进行变换的操作,例如星号闭包、按位取反、反转以及取回文前半部分等,并证明这些操作同样保持正则性。核心方法是:给定一个接受原语言的自动机,我们能够构造一个新的自动机来接受变换后的语言。

上一节我们介绍了正则语言的等价表示和基本封闭性,本节中我们来看看更复杂的语言变换操作。


星号闭包变换

给定一个正则语言 L,证明 L*(L的星号闭包)也是正则的。

构造思路
我们从一个接受 L 的 DFA M 出发,目标是构造一个能接受 L* 的 NFA M‘。关键在于允许机器在读完一个属于 L 的子串后,非确定性地选择回到开始状态,以开始读取下一个子串。

正式构造
设 M = (Q, Σ, δ, s, A) 是接受 L 的任意 DFA。我们构造一个带 ε 转移的 NFA M‘ = (Q‘, Σ, δ‘, s‘, A‘) 如下:

  • Q‘ = Q ∪ {s_new, a_new},其中 s_new 和 a_new 是新状态。
  • s‘ = s_new。
  • A‘ = {a_new}。
  • δ‘ 定义如下:
    1. 从新开始状态 s_new 添加 ε 转移到原开始状态 s:δ‘(s_new, ε) = {s}
    2. 从原接受状态集 A 中的每个状态,添加 ε 转移到新接受状态 a_new:对所有 q ∈ A,δ‘(q, ε) = {a_new}
    3. 为了允许循环拼接,从新接受状态 a_new 添加 ε 转移回新开始状态 s_new:δ‘(a_new, ε) = {s_new}
    4. 保留原 DFA 的所有转移:对所有 q ∈ Q 和 a ∈ Σ,δ‘(q, a) = {δ(q, a)}

直觉解释
这个 NFA 从 s_new 开始,通过 ε 转移进入原 DFA 的模拟。在模拟过程中,每当到达原 DFA 的一个接受状态(意味着识别完 L 中的一个子串),它可以选择通过 ε 转移到 a_new 来结束整个计算,也可以选择通过 ε 转移回到 s_new 来非确定性地“重置”自动机,开始尝试识别下一个子串。通过这种方式,它能够猜测输入串如何被分割为多个属于 L 的子串。


按位取反变换

定义字符串上的变换 flip:将字符串中的每个 0 变为 1,每个 1 变为 0。将其扩展到语言上:FLIP(L) = { flip(w) | w ∈ L }。证明若 L 正则,则 FLIP(L) 也正则。

使用 DFA 的构造思路
我们可以修改原 DFA 的转移函数,使得它在读取符号时,实际内部模拟的是原 DFA 读取相反符号的行为。

正式构造
设 M = (Q, Σ, δ, s, A) 是接受 L 的任意 DFA。我们构造一个 DFA M‘ = (Q‘, Σ, δ‘, s‘, A‘) 如下:

  • Q‘ = Q
  • s‘ = s
  • A‘ = A
  • δ‘ 定义如下:对所有 q ∈ Q,
    • δ‘(q, 0) = δ(q, 1)
    • δ‘(q, 1) = δ(q, 0)

直觉解释
新机器 M‘ 的状态和接受条件与原机器 M 完全相同。唯一的区别在于,当 M‘ 读取输入符号 a 时,它内部调用 M 的转移函数,但传入的符号是 flip(a)。这相当于在输入流和原自动机 M 之间插入一个“转换器”,实时地将输入比特取反后再交给 M 处理。


字符串反转变换

定义字符串上的变换 reverse:将字符串字符顺序颠倒。将其扩展到语言上:REV(L) = { reverse(w) | w ∈ L }。证明若 L 正则,则 REV(L) 也正则。

构造思路
DFA 只能从左到右读入字符串。为了接受反转后的语言,我们需要让自动机从右向左“模拟”原 DFA 的运行。这可以通过将原 DFA 的所有转移边反向,并将开始状态和接受状态互换来实现,从而得到一个 NFA。

正式构造
设 M = (Q, Σ, δ, s, A) 是接受 L 的任意 DFA。我们构造一个 NFA M‘ = (Q‘, Σ, δ‘, S‘, A‘) 如下:

  • Q‘ = Q
  • S‘ = A (原 DFA 的所有接受状态成为新 NFA 的开始状态集)
  • A‘ = {s} (原 DFA 的开始状态成为新 NFA 的唯一接受状态)
  • δ‘ 定义如下:对所有 q ∈ Q 和 a ∈ Σ,
    δ‘(q, a) = { p ∈ Q | δ(p, a) = q }

直觉解释
新 NFA M‘ 的状态集与原 DFA 相同。原 DFA 中从状态 p 经符号 a 到状态 q 的转移 (δ(p, a) = q),在反转后的 NFA 中,对应着从状态 q 经符号 a 可以(非确定性地)转移到状态 p。开始状态和接受状态的互换是因为:原 DFA 从 s 开始,在 A 中结束接受;对于反转后的字符串,计算应从原接受的终点(即 A 中的状态)开始,反向运行到原起点 s 结束。


回文前半部分变换

定义语言变换 PAL(L)PAL(L) = { w | w · reverse(w) ∈ L }。即,所有那些与其自身反转拼接后属于 L 的字符串 w 的集合。证明若 L 正则,则 PAL(L) 也正则。

构造思路
接受 PAL(L) 的机器需要在读取 w 的同时,并行地模拟两个过程:一个原 DFA M 正向读取 w(作为回文的前半部分),另一个原 DFA M 的“反向版本” MR 反向读取 w(作为回文的后半部分的反向)。当 w 读取完毕时,如果这两个模拟过程处于同一个状态,则说明存在一条路径,使得原 DFA M 能从其开始状态,经过 w·reverse(w) 的转移,到达其某个接受状态。

正式构造
设 M = (Q, Σ, δ, s, A) 是接受 L 的任意 DFA。我们构造一个 NFA M‘ = (Q‘, Σ, δ‘, S‘, A‘) 如下:

  • Q‘ = Q × Q。每个状态是一个二元组 (p, q),跟踪正向模拟和反向模拟的状态。
  • S‘ = { (s, f) | f ∈ A }。正向模拟从 s 开始,反向模拟非确定性地从某个接受状态 f ∈ A 开始(猜测回文串的结束点)。
  • A‘ = { (q, q) | q ∈ Q }。当正向和反向模拟到达同一个状态 q 时接受。
  • δ‘ 定义如下:对 M‘ 中的状态 (p, q) 和输入符号 a,
    δ‘((p, q), a) = { (δ(p, a), r) | r ∈ Q 且 δ(r, a) = q }

直觉解释
这是一个更复杂的构造,充分利用了非确定性。机器同时运行两个副本:

  1. 正向副本:从状态 s 开始,对于输入 w 中的每个符号 a,简单地应用 δ 向前推进。
  2. 反向副本:从某个猜测的接受状态 f 开始。对于输入 w 中的每个符号 a,它需要沿着原 DFA 中以 a 标记进入当前状态的边向后走。由于可能有多条这样的边,所以这里需要非确定性猜测。

如果存在一个状态 q,使得正向副本在读完 w 后到达 q,同时反向副本通过一系列正确的猜测,在“反向”读完 w 后也到达 q,那么就意味着在原 DFA M 中,存在一条从 s 到 f 的路径,其标签恰好是 w·reverse(w)。因此,w 应被接受。


总结

本节课中我们一起学习了多种对正则语言进行变换的操作,并证明了这些变换后得到的语言仍然是正则的。关键要点在于掌握如何通过算法化的构造,从一个接受原语言的自动机(通常是 DFA)出发,构建一个新的自动机(可能是 DFA 或 NFA)来接受变换后的语言。这些构造的核心思想包括:利用非确定性进行猜测、模拟并行过程、以及巧妙地重新定义状态、开始条件和转移函数。理解这些构造有助于深化对正则语言封闭性和自动机计算能力的认识。

008:上下文无关文法

概述

在本节课中,我们将要学习上下文无关文法。这是一种比正则表达式更强大的工具,用于描述语言。我们将了解其基本组成部分、工作原理,并通过例子学习如何用它来描述具有递归结构的语言。


从正则语言到上下文无关语言

上一节我们介绍了正则语言,它由三种基本操作构成:序列选择重复。然而,许多语言无法用正则表达式描述。

一个经典的例子是语言 L = {0^n 1^n | n ≥ 0},即由相同数量的0和1组成的字符串。这个语言无法被任何有限状态自动机识别,因为识别它需要一个计数器来追踪0的数量,并与1的数量进行比较,这超出了有限状态的能力。

这种模式体现了递归的思想。例如,我们可以将0视为“入栈”,将1视为“出栈”。一个合法的字符串就是一系列“入栈”和“出栈”操作,最终栈为空且从未下溢。这种嵌套的、需要记忆“深度”的结构,正是上下文无关文法能够描述,而正则表达式不能的。


上下文无关文法简介

上下文无关文法是一种用于生成字符串的规则系统。它由四个部分组成:

  1. 终结符:构成最终字符串的字符。
  2. 非终结符:代表中间结构的符号。
  3. 产生式规则:规定如何将一个非终结符替换为一串终结符和非终结符。
  4. 起始符号:推导开始的非终结符。

其核心思想是:从起始符号开始,反复选择并应用产生式规则,将非终结符替换为相应的字符串,直到得到一个完全由终结符组成的字符串。所有能这样生成的字符串的集合,就是该文法定义的语言。


文法示例与分析

让我们分析一个具体的上下文无关文法 G

  • 非终结符S, A, B, C
  • 终结符0, 1
  • 起始符号S
  • 产生式规则
    • S → A | B
    • A → 0 A | 0 C
    • B → B 1 | C 1
    • C → ε | 0 C 1ε 代表空字符串)

以下是分析每个非终结符所生成语言的方法:

首先分析 C
规则 C → ε | 0 C 1 允许我们生成 ε,或者生成一个 0,后面跟着一个 C,再跟着一个 1。这本质上生成了语言 {0^n 1^n | n ≥ 0}

接着分析 B
规则 B → B 1 | C 1 允许我们在字符串右侧添加任意多个 1,最后必须应用一次 C 1。因此,B 生成的语言是 {0^m 1^n | n > m ≥ 0},即1的数量严格多于0的数量。

然后分析 A
规则 A → 0 A | 0 C 允许我们在字符串左侧添加任意多个 0,最后必须应用一次 0 C。因此,A 生成的语言是 {0^m 1^n | m > n ≥ 0},即0的数量严格多于1的数量。

最后分析起始符号 S
规则 S → A | B 意味着 S 生成的语言是 AB 生成语言的并集。所以,S 生成所有 01 数量不相等的字符串,即 {0^m 1^n | m ≠ n}

这个例子展示了上下文无关文法如何描述非正则语言。


更多例子与性质

正则语言都是上下文无关的。
任何正则表达式都可以转化为一个上下文无关文法。例如,正则语言 0*1* 可以用以下文法描述:
S → 0 S | S 1 | ε

并非所有语言都是上下文无关的。
存在一些语言,其结构过于复杂,无法用上下文无关文法描述。两个经典的例子是:

  1. {0^n 1^n 0^n | n ≥ 0}:要求三部分数量相等。
  2. {ww | w ∈ {0,1}*}:要求字符串由两个完全相同的子串连接而成。

文法的歧义性。
有时,同一个字符串可以由同一个文法的多棵不同的“语法分析树”生成,这样的文法称为歧义文法。歧义性会给语言解析带来困难,因此在设计编程语言语法时,我们通常追求无歧义文法


设计文法:平衡括号

让我们尝试为“平衡括号”语言设计一个文法。该语言包含所有正确匹配的括号串,例如 (), (())(), (()(()))

一种思考方式是:一个平衡括号串,要么是空串,要么是由一对匹配括号包裹着一个平衡串,再后接另一个平衡串。

基于此,我们可以写出以下文法:
S → ( S ) S | ε

另一种等价的、可能更直观的文法是:一个平衡括号串,总可以看作是以一个左括号开始,其对应的右括号将整个串分割为内部和后续两部分。
S → ( S ) S | ε (这与上式相同)

或者,我们可以明确区分“被括号包裹的串”和“串的连接”:
S → T S | ε
T → ( S )
这个文法也是无歧义的。


总结

本节课中,我们一起学习了上下文无关文法。我们了解到:

  • 上下文无关文法通过产生式规则递归,能够描述比正则语言更复杂的结构,例如嵌套的括号。
  • 文法的核心组成部分包括终结符非终结符产生式规则起始符号
  • 我们通过分析例子 {0^m 1^n | m ≠ n},掌握了从文法推导其所定义语言的方法。
  • 我们尝试为“平衡括号”语言设计文法,看到了对同一语言可能存在多种不同的文法描述。
  • 最后,我们知道了正则语言是上下文无关语言的真子集,并且存在不是上下文无关的语言。

上下文无关文法是编译器设计、自然语言处理等领域的基础工具,用于描述程序语法或句子结构。

009:图灵机与停机问题

在本节课中,我们将学习计算理论中的一个核心概念——图灵机。我们将了解图灵机如何作为通用计算模型,并探讨一个著名的不可判定问题:停机问题。

期中考试安排提醒

首先,提醒大家一个重要的事务性信息:下周一晚上将举行期中考试。考试地点已分配,但具体哪个班级去哪个教室仍在安排中。请密切关注课程网站、Discord和Ed讨论区的通知。

关于考试范围,需要明确以下几点:

  • 考试内容涵盖预备知识以及作业一、二、三、四的内容。
  • 今天课程的内容不会出现在期中考试中,也不会出现在未来的任何期中考试或作业中。目前阶段我们主要进行复习。

以下是考试政策:

  • 可以携带一张双面手写的“小抄纸”。
  • 不允许复印、打印或在平板上书写后打印。
  • 制作小抄的过程本身有助于巩固知识。

关于复习资源:

  • 有一份大量的模拟试题集。建议的策略是:针对每个题型(如设计DFA),挑选2-4道题目,在模拟考试条件下(20分钟,白纸)练习。如果感到困难,再寻求帮助。
  • 这些模拟题没有提供标准答案。这是因为本学期前四周我们已经通过作业、实验等提供了超过100道题的完整解答。现阶段,理解解题思路比记忆答案更重要。
  • Prairie Learn上也有大量自动评分的练习题。
  • 本周四的课程中,我将讲解一套模拟考试题,展示获得满分所需的答题细节。
  • 周五的实验课将是复习课。
  • 周日,HKN将在ECE 1002(主演讲厅)组织复习会。

其他注意事项:

  • 如果需要参加冲突考试,请尽早填写预约表。
  • 如果需要考试便利(如额外时间、低干扰环境),建议通过考试便利中心预约,请尽快操作。

从有限状态机到图灵机

上一节我们回顾了不同层次的语言和机器模型。现在,我们来看看更强大的计算模型。

我们之前学习了:

  • 正则语言:由正则表达式描述,对应确定有限自动机(DFA)非确定有限自动机(NFA)。它们模拟了代码中的顺序、分支和循环行为。
  • 上下文无关语言:由上下文无关文法描述,对应下推自动机(PDA)。它引入了递归(或函数调用)的行为,通过一个栈来管理。

这些模型在内存访问上都有限制:DFA的状态有限;PDA的栈内存虽然无限,但只能以“后进先出”的方式访问栈顶元素。

我们需要的是一种能够任意读写和再次访问无限内存的模型。这就是图灵机

图灵机:通用计算模型 🧠

图灵机是一个简单但功能强大的计算模型。其核心思想是:

  • 一个有限状态控制器(类似于DFA)。
  • 一条无限长的纸带,被划分为格子,每个格子可以写入一个符号(来自有限字母表)。
  • 一个读写头,每次位于纸带的一个格子上。

图灵机的每一步操作如下(基于当前状态和读写头所指的符号):

  1. 写入一个新符号(或相同的符号)到当前格子。
  2. 改变有限状态控制器的状态。
  3. 移动读写头向左或向右一格。

用伪代码描述单步转移函数:

δ(当前状态, 当前读到的符号) = (新状态, 要写入的符号, 移动方向)

其中移动方向 ∈ {左, 右}。

初始时,纸带上包含输入字符串,其余部分为空白符号,读写头位于输入最左端。图灵机通过一系列这样的步骤运行,最终进入特定的停机状态(接受或拒绝)而结束。

关键点:图灵机的纸带提供了无限的、可随机访问的存储空间。你可以移动到纸带远处,写下信息,留下标记,然后返回原处继续计算,稍后再根据标记找回信息。这模拟了现代计算机中内存的工作方式。

艾伦·图灵在23岁时提出了这个模型,旨在形式化“人类进行数学计算”的过程。他认为任何物理上可实现的计算过程都可以用图灵机来模拟,这一观点被称为丘奇-图灵论题

停机问题:一个不可判定的问题 ⚠️

图灵机的一个重要成果是解决了大卫·希尔伯特提出的“判定问题”:是否存在一个机械过程,能判断任意数学命题的真假?

图灵通过定义停机问题并证明其不可判定性,给出了否定的回答。

停机问题:给定一个图灵机 M 的描述(可以理解为程序源代码),问:M 是否会在有限步内停机?(即,是否会结束运行,而不是陷入无限循环?)

图灵证明了:不存在这样一个图灵机程序 H,它能够正确判定所有输入图灵机是否停机。

证明概要(反证法)

  1. 假设存在这样一个“万能判定器”图灵机 HH 本身总是停机):

    • 输入:任意图灵机 M 的描述。
    • 输出:如果 M 停机,则输出“是”;如果 M 永不停机,则输出“否”。
  2. 构造一个新的图灵机 D(“捣蛋机”):

    • 输入:任意图灵机 M 的描述。
    • 行为:
      • 首先,用 H 来判定 M
      • 如果 H 判定 M 停机,那么 D 就故意进入无限循环(永不停机)。
      • 如果 H 判定 M 永不停机,那么 D立刻停机

    用伪代码描述 D 的行为:

    def D(M):
        if H(M) == "停机":
            while True: pass  # 进入无限循环
        else:
            return            # 立即停机
    
  3. 考虑D它自身的描述作为输入时会发生什么(即运行 D(D)):

    • 如果 H 判定 D 停机 → 根据 D 的定义,D(D) 将进入无限循环(即不停机)。矛盾!
    • 如果 H 判定 D 永不停机 → 根据 D 的定义,D(D) 将立刻停机。矛盾!
  4. 结论:我们得到了逻辑矛盾。因此,最初的假设不成立。这样的万能判定器 H 不可能存在

这个证明的核心洞见是程序即数据:一个程序的源代码可以作为字符串输入给另一个程序。通过让程序分析自身,导致了自指悖论。

总结

本节课中,我们一起学习了:

  1. 图灵机的基本概念:它由有限状态控制器、无限长纸带和读写头构成,提供了通用计算的形式化模型。
  2. 丘奇-图灵论题:认为图灵机可模拟任何物理上可行的计算过程。
  3. 停机问题:判定任意给定程序是否会停机的问题。
  4. 停机问题的不可判定性:通过巧妙的自指构造反证法,图灵证明了不存在一个通用算法能解决停机问题。这是计算理论中第一个,也是最著名的不可判定问题,从根本上划定了算法能力的界限。

图灵的工作不仅奠定了计算机科学的基础,也深刻影响了数学和逻辑学。

010:期中考试1复习(重录)📚

在本节课中,我们将一起复习期中考试1的模拟试卷。我们将逐一讲解试卷中的五道题目,涵盖语言变换、DFA与正则表达式、归纳证明、Fooling集与上下文无关文法以及真/假判断题。课程旨在帮助你理解考试形式、题目类型以及解题思路,为即将到来的考试做好准备。

算法与计算模型:1:考试结构与说明 📝

本次期中考试包含五道大题,每道题分值均为10分。考试时长为120分钟。考试开始时,监考人员会分发包含所有题目的试卷。你有几分钟时间阅读所有题目,之后可以提问。随后,监考人员会分发答题册,你需要在答题册上作答。

以下是考试的重要说明:

  • 允许携带一张双面手写的“小抄”,除此之外不得使用其他任何资料。
  • 答题时请将答案写在答题册的黑色边框内,否则扫描仪可能无法识别。
  • 除非题目明确要求,否则无需写出完整的证明过程。
  • 考试结束时,请上交所有试卷和答题册。

算法与计算模型:2:题目一:语言变换 🔄

上一节我们介绍了考试的整体结构,本节中我们来看看第一道大题。这是一道关于语言变换的题目。

题目定义了一个函数 compressZeros(W),它接收一个由 01 组成的字符串 W,并将其中的每一个“零串”(连续的 0)长度压缩一半(向上取整)。例如,compressZeros(000001100111) 的结果是 00011011

给定一个任意的正则语言 L,你需要证明以下两个相关的语言也是正则的:

  1. { w ∈ {0,1}* | compressZeros(w) ∈ L }
  2. { compressZeros(w) | w ∈ L }

解题思路:
对于第一个语言,我们需要构造一个自动机,它读取输入字符串 w,但只将 compressZeros(w) 的结果(即每隔一个零丢弃一个)传递给接受 L 的DFA。为此,新自动机的状态需要记住两个信息:模拟的DFA的当前状态,以及当前是否处于一个零串的“偶数位置”(即下一个零是否应该被丢弃)。以下是状态和转移的核心描述:

  • 状态集Q' = Q × {even, odd},其中 Q 是原DFA的状态集。
  • 转移函数(部分):
    • even 模式读 0:将 0 传递给原DFA,并进入 odd 模式。
    • odd 模式读 0:不传递给原DFA,并进入 even 模式。
    • 1:将 1 传递给原DFA,并进入 even 模式(因为 1 结束了零串)。

对于第二个语言,我们需要构造一个NFA,它读取压缩后的字符串,并“猜测”原始的未压缩字符串是什么,然后将其传递给接受 L 的DFA。关键在于,当我们遇到一个零串的第一个 0 时,我们需要非确定性地猜测这个零串在原始字符串中是奇数长度(则只传递一个 0)还是偶数长度(则传递两个 0)。对于该零串后续的 0,则总是传递两个 0。新自动机的状态需要记住是否正处于一个零串中。

算法与计算模型:3:题目二:DFA与正则表达式 🧮

上一节我们处理了语言变换问题,本节中我们来看看如何为特定语言设计DFA和正则表达式。

题目要求为以下两个在字母表 {0,1} 上的语言分别描述一个接受它的DFA,并给出一个表示它的正则表达式。无需证明。

语言A:所有至少包含一个长度能被3整除的零串或一串的字符串。
语言B:所有不包含子串“100”或“011”的字符串。

解题思路(语言A):
一个“串”是指最大的、非空的、由相同字符组成的子串。我们需要跟踪当前正在读取的串的类型(01)及其长度模3的余数。DFA可以设计为两个并行的“模3计数器”循环,一个用于 0 串,一个用于 1 串。当从一个循环跳转到另一个循环时(即字符类型改变),需要重置计数器。任何处于“余数为0”状态(即串长度是3的倍数)的状态都是接受状态。一个可能的正则表达式思路是:(0+1)* ( (000)* 0* | (111)* 1* ) (0+1)*,但需要仔细处理边界以确保匹配的是完整的串。

解题思路(语言B):
我们可以通过分析来推导模式。如果一个字符串要避免“100”和“011”,那么一旦出现一个 1 后面跟着 0,或者一个 0 后面跟着 1,后续字符就必须严格交替(0101...1010...),否则就会产生 forbidden 子串。此外,字符串也可以全部是 0 或全部是 1。因此,语言B的字符串要么是 0*,要么是 1*,要么是 0* 后接交替模式 (01)*(10)*,并可能以 01 结尾。基于此可以构造DFA,其状态可以表示:“正在读全0前缀”、“正在读全1前缀”、“正在交替模式且上一个字符是0”、“正在交替模式且上一个字符是1”以及一个“失败”状态。

算法与计算模型:4:题目三:递归函数与归纳证明 📐

上一节我们设计了自动机和正则表达式,本节中我们使用归纳法来证明关于一个递归函数的性质。

定义递归函数 bond(w)

  • bond(ε) = ε
  • bond(0x) = 00 bond(x)
  • bond(1x) = 1 bond(x)

需要证明:

  1. 对所有字符串 w,有 |bond(w)| >= |w|
  2. 对所有字符串 x, y,有 bond(xy) = bond(x) bond(y)

解题思路:
这两个证明都采用对字符串 w(或 x)的结构归纳法。

对于第一部分,归纳假设为:对所有比 w 短的字符串 x,有 |bond(x)| >= |x|。然后分情况讨论 w 的形式(空串、以 0 开头、以 1 开头),利用 bond 和字符串长度的定义,结合归纳假设,推导出 |bond(w)| >= |w|

对于第二部分,固定 y,对 x 进行归纳。归纳假设为:对所有比 x 短的字符串 z,有 bond(zy) = bond(z) bond(y)。同样分三种情况讨论 x 的形式,利用 bond 的定义、归纳假设以及字符串连接运算的结合律,证明 bond(xy) = bond(x) bond(y)

算法与计算模型:5:题目四:非正则语言与上下文无关文法 🚫

上一节我们完成了归纳证明,本节我们来看一个需要证明语言非正则并为其设计文法的题目。

定义语言 L = { 0^a 1^b 0^c | a, b, c >= 0, 且 a=b 或 a=c 或 b=c }。即由 a 个0、b 个1、c 个0组成的字符串,其中至少有两组数量相等。

第一部分:证明 L 不是正则语言。
可以使用 Fooling Set 论证法。考虑语言的一个子集,例如令 c=0,则我们关注形如 0^a 1^b 且满足 a=ba=0b=0 的字符串。但为了构造一个清晰的Fooling集,我们可以考虑更简单的子集:F = { 0^n | n >= 1 }。可以证明,对于 F 中任意两个不同的字符串 0^i0^j,存在一个后缀 z(例如 1^i)使得 0^i 1^i ∈ L0^j 1^i ∉ L。因此 FL 的一个无限Fooling集,故 L 非正则。

第二部分:为 L 设计一个上下文无关文法(CFG)。
由于 L 是三个条件的并集(a=ba=cb=c),我们可以为每个条件设计一个子文法,然后用并集(|)连接。

  • 条件 a=b:生成形如 0^a 1^a 0^c 的字符串。这可以分解为 A -> A' C,其中 A' 生成 0^a 1^a(例如 A' -> 0 A' 1 | ε),C 生成 0^cC -> 0 C | ε)。
  • 条件 a=c:生成形如 0^a 1^b 0^a 的字符串。可以用递归匹配两端的零:B -> 0 B 0 | D,其中 D 生成中间的 1^bD -> 1 D | ε)。
  • 条件 b=c:生成形如 0^a 1^b 0^b 的字符串。可以分解为 E -> F C',其中 F 生成开头的 0^aF -> 0 F | ε),C' 生成 1^b 0^bC' -> 1 C' 0 | ε)。

最终文法起始符号 S 的产生式为:S -> A | B | E

算法与计算模型:6:题目五:真/假判断 ✅❌

最后,我们来看真/假判断题。对于每个陈述,判断其是否总是为真,并给出非常简短的解释。

以下是题目中的陈述及简要分析:

  1. 如果 2+2=5,那么 0 是奇数。 。前提为假时,蕴含式恒真(空真)。
  2. {0^n 1 | n>0} 是语言 {0^n 1 0^n | n>0} 唯一的无限Fooling集。 。对于任意非正则语言,都存在无穷多个不同的无限Fooling集。
  3. {0^n 1 0^n | n>0} 是上下文无关语言。 。可以构造CFG(例如 S -> 0S0 | 010)或下推自动机来接受它。
  4. 文法 S -> 0S0 | S11 | 01 生成语言 {0^n 1^n}。 。该文法生成的字符串中0和1的数量都是奇数,无法生成 0011 这样的字符串。
  5. 每个正则语言都能被一个恰好只有一个接受状态的DFA识别。 。反例:语言“长度不能被3整除的字符串”的最小DFA需要两个接受状态。
  6. 任何能被带ε转移的NFA判定的语言,也能被不带ε转移的NFA判定。 。可以通过消除ε转移的算法构造等价的NFA。
  7. 如果 L 是 {0,1} 上的正则语言,那么 {xy^c | x∈L, y∈L} 也是正则的(y^c 表示y的逐位取反)。 。这是两个正则语言 L 和 flip(L) 的连接,而 flip(L) 也是正则的(通过交换DFA中0和1的转移即可)。
  8. 如果 L 是 {0,1} 上的正则语言,那么 {ww^c | w∈L} 也是正则的。 。反例:令 L = 0*,则 {ww^c | w∈L} = {0^n 1^n},这不是正则语言。
  9. 正则表达式 (00+11) 表示所有长度为偶数的 {0,1} 上的字符串。* 。该表达式生成的是每个“串”长度都为偶数的字符串,无法生成 01(长度为2)。
  10. 如果 L1 和 L2 是正则语言,那么 (L1 ∪ L2) 也是正则语言。* 。根据正则语言在并集和Kleene星号运算下的封闭性。

本节课中我们一起学习了期中考试模拟卷的全部五类题目:语言变换的自动机构造、DFA与正则表达式的设计、递归函数的归纳证明、利用Fooling集证明非正则性及设计CFG、以及需要仔细辨析的真/假判断。希望这次复习能帮助你巩固知识,熟悉考试题型和解题方法。祝你在考试中取得好成绩!

011:递归

在本节课中,我们将要学习递归这一强大的算法设计工具。递归的核心思想是将一个复杂问题分解为更小的、同类型的子问题,然后通过解决这些子问题来构建原问题的解。我们将通过经典的“汉诺塔”和“归并排序”等例子,来理解递归算法的设计、正确性证明以及运行时间分析。


汉诺塔问题 🗼

汉诺塔是一个经典的递归谜题。我们有三个柱子(A、B、C)和 N 个大小不同的圆盘,这些圆盘最初都堆叠在柱子 A 上,从大到小自下而上排列。目标是将所有圆盘移动到柱子 B 上,并遵守以下规则:

  1. 每次只能移动一个圆盘。
  2. 只能移动某个柱子上最顶端的圆盘。
  3. 任何时候都不能将较大的圆盘放在较小的圆盘之上。

递归解法

我们的目标是编写一个算法 Move(N, A, B, C),将 N 个圆盘从柱子 A 移动到柱子 B,使用柱子 C 作为辅助。

以下是解决该问题的递归策略:

  1. 首先,递归地将最上面的 N-1 个圆盘从柱子 A 移动到柱子 C(使用 B 作为辅助)。这可以表示为 Move(N-1, A, C, B)
  2. 接着,将最大的圆盘(第 N 个)直接从柱子 A 移动到柱子 B。
  3. 最后,递归地将之前移到柱子 C 上的 N-1 个圆盘移动到柱子 B(使用 A 作为辅助)。这可以表示为 Move(N-1, C, B, A)

这个策略基于一个关键观察:要移动最大的圆盘,必须先将它上面的所有圆盘移开。移动这些较小的圆盘本身就是一个规模更小的汉诺塔问题。

算法实现与边界情况

上述策略假设 N > 0。当 N = 0 时,没有任何圆盘需要移动,算法应直接结束。因此,完整的算法需要包含一个基础情况。

以下是算法的伪代码描述:

函数 Move(N, A, B, C):
    如果 N == 0:
        返回 // 基础情况:无事可做
    否则:
        Move(N-1, A, C, B) // 步骤1:将 N-1 个盘移到 C
        将圆盘 N 从 A 移到 B // 步骤2:移动最大的盘
        Move(N-1, C, B, A) // 步骤3:将 N-1 个盘从 C 移到 B

正确性证明

该算法的正确性可以通过数学归纳法来证明。

  • 归纳基础:当 N = 0 时,算法正确无误(什么都不做)。
  • 归纳步骤:假设对于所有小于 N 的数值,算法都能正确工作。对于 N 个圆盘的情况:
    1. 根据归纳假设,步骤 1 能正确地将 N-1 个圆盘从 A 移到 C。
    2. 步骤 2 是单步操作,显然正确。
    3. 再次根据归纳假设,步骤 3 能正确地将 N-1 个圆盘从 C 移到 B。
      因此,整个算法对于 N 个圆盘也是正确的。

运行时间分析

T(N) 表示移动 N 个圆盘所需的总步数。根据算法,我们可以写出递推关系式:

  • T(0) = 0
  • T(N) = 2 * T(N-1) + 1 (当 N > 0 时)

我们可以通过观察前几项来猜测解:

  • T(1) = 1
  • T(2) = 3
  • T(3) = 7
  • T(4) = 15
  • T(5) = 31

模式显示 T(N) = 2^N - 1。我们可以用归纳法证明这个猜测:

  • 基础:T(0) = 2^0 - 1 = 0,正确。
  • 假设 T(N-1) = 2^(N-1) - 1 成立。
  • T(N) = 2 * T(N-1) + 1 = 2 * (2^(N-1) - 1) + 1 = 2^N - 2 + 1 = 2^N - 1

因此,汉诺塔算法的运行时间是 O(2^N)。对于 64 个圆盘,所需步数是一个天文数字,这也呼应了传说中的“世界末日”预言。


上一节我们介绍了递归思想在经典谜题中的应用,本节中我们来看看递归如何应用于一个更实际的算法问题:排序。

归并排序算法 📊

归并排序是体现“分治法”思想的经典递归排序算法。其核心思路是:将一个大数组排序的问题,分解为两个小数组的排序问题,然后将两个已排序的小数组合并成一个有序的大数组。

算法描述

归并排序的步骤如下:

  1. :将长度为 N 的未排序数组近似地分成两半。
  2. :递归地对左半部分数组进行归并排序。
  3. :递归地对右半部分数组进行归并排序。
  4. :将两个已排序的子数组合并成一个完整的已排序数组。

其中,合并两个已排序数组的过程(Merge)本身是一个线性时间 O(N) 的算法。它使用两个指针分别遍历两个子数组,每次将较小的元素放入输出数组中。

算法实现

以下是归并排序的高层伪代码:

函数 MergeSort(A[1..n]):
    如果 n <= 1:
        返回 A // 基础情况:单元素或空数组已有序
    否则:
        mid = floor(n / 2)
        left = MergeSort(A[1..mid])   // 递归排序左半部分
        right = MergeSort(A[mid+1..n]) // 递归排序右半部分
        返回 Merge(left, right)        // 合并已排序的两部分

Merge 函数的实现通常使用循环,比较两个子数组的当前元素,并将较小的复制到结果中。

正确性证明

归并排序的正确性同样可以通过归纳法证明:

  • 基础:当数组长度小于等于 1 时,它已经有序。
  • 归纳:假设算法能正确排序任何长度小于 N 的数组。对于一个长度为 N 的数组,算法递归地排序了两个长度均小于 N 的子数组(根据归纳假设,这一步正确)。然后,Merge 函数能够正确地将两个有序数组合并为一个有序数组。因此,整个数组被正确排序。

运行时间分析

T(N) 表示对 N 个元素进行归并排序所需的时间。我们可以建立以下递推关系:

  • T(N) = O(1),当 N <= 1 (常数时间的基础操作)
  • T(N) = 2 * T(N/2) + O(N),当 N > 1 (两次递归调用加上线性时间的合并)

为了求解这个递推式,我们可以使用 递归树法

  1. 在递归树的根节点(第 0 层),我们完成的工作量是 O(N)(合并整个数组)。
  2. 在第一层,有两个子问题,每个规模约为 N/2,每个子问题的合并工作量是 O(N/2),因此第一层的总工作量也是 O(N)
  3. 在第二层,有四个子问题,每个规模约为 N/4,总工作量仍是 O(N)
  4. 以此类推,递归树的每一层的工作量总和都是 O(N)

现在需要知道树有多少层。每次递归都将问题规模减半,因此树的深度大约是 log₂ N

总运行时间 = 层数 × 每层工作量 ≈ log N * O(N) = O(N log N)

递归树法清晰地展示了为什么归并排序比像插入排序(O(N²))这样的简单算法更高效。


上一节我们分析了在最好平衡情况下的递归算法,本节中我们来看看当递归划分不平衡时会发生什么。

快速排序算法 ⚡

快速排序是另一个著名的分治排序算法。与归并排序先简单分、后复杂合不同,快速排序是先进行复杂的“分”,然后递归地“治”,最后简单组合。

算法描述

快速排序的步骤如下:

  1. 选择枢轴:从数组中选择一个元素作为“枢轴”。
  2. 分区:重新排列数组,使得所有小于枢轴的元素都在其左侧,所有大于枢轴的元素都在其右侧(等于枢轴的元素可放任意一边)。分区操作完成后,枢轴元素就位于其最终排序后的正确位置。
  3. 递归:递归地对枢轴左侧和右侧的子数组进行快速排序。

算法实现

高层伪代码如下:

函数 QuickSort(A[low..high]):
    如果 low >= high:
        返回 // 基础情况:区间内元素少于2个
    否则:
        pivot_index = Partition(A, low, high) // 分区并返回枢轴最终位置
        QuickSort(A, low, pivot_index - 1) // 递归排序左半部分
        QuickSort(A, pivot_index + 1, high) // 递归排序右半部分

Partition 函数有多种实现方式(如 Lomuto 或 Hoare 分区方案),其核心是在线性时间 O(N) 内完成重排。

运行时间分析

快速排序的运行时间高度依赖于分区后两个子数组的大小是否平衡。

T(N) 为排序 N 个元素的时间,r 为分区后左子数组的大小。

  • 分区操作耗时 O(N)
  • 递归排序左子数组耗时 T(r)
  • 递归排序右子数组耗时 T(N - r - 1)

因此,递推关系为:T(N) = O(N) + T(r) + T(N - r - 1)

最坏情况:如果每次分区都极不平衡,例如总是选取到最小或最大的元素作为枢轴,那么一个子数组大小为 0,另一个为 N-1。此时递推式变为:
T(N) = O(N) + T(0) + T(N-1) ≈ O(N) + T(N-1)

展开这个递推式,我们得到:
T(N) ≈ O(N) + O(N-1) + O(N-2) + ... + O(1) = O(N²)

平均/最好情况:如果每次分区都能大致将数组平分(r ≈ N/2),那么递推式类似于归并排序:
T(N) = O(N) + 2 * T(N/2)

根据递归树法,其解为 O(N log N)

关键点

快速排序的性能关键在于枢轴的选择。在实践中,为了避免最坏情况,常采用随机选择枢轴或“三数取中”等策略。这样,在输入数据随机的情况下,快速排序的期望运行时间O(N log N),并且其常数因子通常比归并排序小,因此在实践中非常高效。


总结 🎯

本节课中我们一起学习了递归这一核心的算法设计范式。

  1. 我们通过汉诺塔问题,理解了递归的基本思维模式:将问题分解为更小的自身,并信任递归调用(递归仙子)能解决子问题。
  2. 我们分析了归并排序,看到了分治法的典型应用:均等划分、递归求解、线性合并。我们使用递归树法分析了其 O(N log N) 的运行时间。
  3. 我们探讨了快速排序,了解了另一种分治策略:通过线性时间分区进行复杂划分,然后递归求解。我们认识到其性能依赖于划分的平衡性,最坏情况为 O(N²),但通过随机化等技巧,期望性能可达 O(N log N)

递归的正确性通常通过归纳法证明,而其运行时间分析则常常涉及建立和求解递推关系式。掌握这些技巧是设计和理解高效算法的基石。

012:分治算法进阶 🧩

在本节课中,我们将深入学习分治算法的更多有趣变体,特别是快速排序的深入分析、快速选择算法以及一个用于大整数乘法的巧妙分治算法。我们将通过分析递归树来理解这些算法的运行时间。


快速排序回顾与分析 🔍

上一节我们介绍了分治的基本思想。本节中,我们来看看快速排序算法的具体实现及其运行时间分析。

快速排序的核心思想是选择一个“枢轴”元素,将数组划分为三部分:小于枢轴的元素、枢轴本身以及大于枢轴的元素。然后递归地对左右两部分进行排序。

以下是快速排序的伪代码核心部分:

def quicksort(A, lo, hi):
    if lo < hi:
        p = partition(A, lo, hi)  # 划分数组,返回枢轴索引
        quicksort(A, lo, p-1)     # 递归排序左半部分
        quicksort(A, p+1, hi)     # 递归排序右半部分

partition 子程序以线性时间 O(n) 运行。因此,快速排序的运行时间 T(n) 满足以下递推关系:

T(n) = O(n) + T(r-1) + T(n-r)

其中 r 是枢轴在划分后的排名(即它是第 r 小的元素)。运行时间取决于 r 的值。

为了进行最坏情况分析,我们假设输入总是导致最慢的运行速度。当枢轴是数组中的最小或最大元素时(即 r=1r=n),会出现最坏情况。此时递推式简化为:

T(n) ≤ O(n) + T(n-1)

求解此递推式,可得最坏情况运行时间为 O(n²)

然而,直观上,如果枢轴能大致位于数组中间,性能会好得多。假设我们总能神奇地选择一个枢轴,其排名 r 介于 n/32n/3 之间。那么最坏情况递推式变为:

T(n) ≤ O(n) + T(n/3) + T(2n/3)

我们可以通过绘制递归树来分析这个递推式。

以下是递归树的分析步骤:

  • 根节点的工作量为 n
  • 第一层子节点的工作量总和为 n/3 + 2n/3 = n
  • 第二层子节点的工作量总和同样为 n
  • 虽然树的左右分支深度不同(左侧深度约为 log₃ n,右侧深度约为 log_{3/2} n),但从大 O 记法的角度看,两者都是 Θ(log n)
  • 由于每一层的工作量总和都是 O(n),而深度是 O(log n),因此总运行时间为 O(n log n)

这个分析强化了我们的直觉:只要划分是相对平衡的(即子问题大小以常数因子缩小),快速排序就能在 O(n log n) 时间内运行。


快速选择算法与中位数的中位数 🎯

上一节我们看到,快速排序的效率依赖于选择一个好的枢轴。本节中我们来看看如何在线性时间内确定性地选择一个近似中位的枢轴,这引出了快速选择算法。

快速选择算法用于在未排序数组中找到第 k 小的元素。其思路与快速排序类似:

  1. 选择一个枢轴并对数组进行划分。
  2. 比较枢轴的排名 r 与目标 k
  3. 如果 r == k,则枢轴即为所求。
  4. 如果 k < r,则在左半部分递归查找第 k 小元素。
  5. 如果 k > r,则在右半部分递归查找第 k - r 小元素。

其运行时间递推式为:

T(n) = O(n) + max(T(r-1), T(n-r))

在最坏情况下(例如总是选择最小元素作为枢轴),这仍然是 O(n²)

关键问题在于:我们能否在线性时间内选择一个保证“不太偏”的枢轴?答案是肯定的,这就是“中位数的中位数”方法。

算法步骤:

  1. 将输入数组划分为 ⌈n/5⌉ 组,每组最多 5 个元素。
  2. 找出每组的中位数(对每组进行插入排序,取中间值,时间复杂度为常数)。
  3. 递归地计算这 ⌈n/5⌉ 个中位数组成的新数组的中位数。将这个值作为枢轴 mom(Median of Medians)。
  4. 使用 mom 作为枢轴对原始数组进行划分。
  5. 根据目标 kmom 排名的比较,在相应的子数组上递归。

为什么有效?
通过分析可以证明,mom 这个枢轴至少比 30% 的元素大,也至少比 30% 的元素小。因此,在划分后,递归调用处理的子数组大小最多为原数组的 7/10

由此得到运行时间递推式:

T(n) ≤ O(n) + T(n/5) + T(7n/10)

其中 T(n/5) 是递归计算中位数的中位数的时间,T(7n/10) 是在较大子数组上递归的时间。

递归树分析:

  • 根节点工作量:n
  • 第一层子节点工作量总和:n/5 + 7n/10 = 9n/10
  • 第二层子节点工作量总和将更少。
  • 每一层的工作量总和构成一个公比小于 1 的几何级数。
  • 因此,总运行时间由根节点主导,为 O(n)

这意味着我们可以在最坏情况 O(n) 时间内找到第 k 小的元素,进而也能在线性时间内找到一个优质的枢轴用于快速排序,从而理论上实现最坏情况 O(n log n) 的快速排序。


卡拉楚巴乘法算法 ✖️

最后,我们来看一个经典的分治算法应用:大整数乘法。我们从小学习的竖式乘法需要 O(n²) 时间,其中 n 是数字的位数。

假设有两个 n 位数 xy。我们可以将它们各自拆分为两个 n/2 位数:

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

那么它们的乘积为:

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

这需要计算四个 n/2 位数的乘积:ac, ad, bc, bd。对应的递推式为 T(n) = 4T(n/2) + O(n),通过递归树分析可知其解为 O(n²),没有改进。

卡拉楚巴算法的巧妙之处在于,它发现中间项 ad + bc 可以通过已经计算或将要计算的值组合得到:

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

因此,我们只需要计算三个 n/2 位数的乘积:ac, bd, 以及 (a+b)*(c+d)

新的运行时间递推式为:

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

递归树分析:

  • 根节点工作量:n
  • 第一层子节点工作量总和:3 * (n/2) = (3/2)n
  • 第二层子节点工作量总和:9 * (n/4) = (9/4)n
  • i 层的工作量总和为 (3/2)^i * n
  • 树的深度为 log₂ n
  • 总工作量是各层之和,这是一个递增的几何级数,因此由最后一层(叶子层)主导。
  • 叶子层的工作量约为 3^(log₂ n) * 基础工作量,利用对数恒等式 a^(log_b c) = c^(log_b a),可得总运行时间为 O(n^(log₂ 3)),约等于 O(n^1.585)

这比 O(n²) 有了显著改进。更先进的算法(如基于快速傅里叶变换的算法)可以达到 O(n log n),但卡拉楚巴算法因其相对简单,在实践中(如 Python 的大整数运算中)仍有应用。


总结 📝

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

  1. 快速排序的深入分析:通过递归树分析了在平衡划分下,快速排序能达到 O(n log n) 运行时间。
  2. 快速选择与中位数的中位数:介绍了一种确定性算法,能在最坏情况 O(n) 时间内找到第 k 小元素,其核心是通过递归地寻找“中位数的中位数”来保证良好的划分。
  3. 卡拉楚巴乘法算法:展示了一个经典的分治技巧,通过将四个递归调用减少为三个,将大整数乘法的时间复杂度从 O(n²) 降低到约 O(n^1.585)

这些例子体现了分治策略的强大和灵活性,以及通过精细的算法设计和分析,我们往往能够突破直觉上的性能瓶颈。

013:回溯算法

在本节课中,我们将学习一种新的递归算法——回溯算法。我们将通过几个经典例子,理解其核心思想:通过系统地尝试所有可能性来解决问题,即使这可能导致指数级的时间复杂度。我们首先关注算法的正确性,之后再考虑优化效率。


回溯算法简介

上一节我们介绍了分治算法,其核心是将大问题分解为规模按常数因子缩小的子问题。本节中,我们来看看回溯算法。它与分治算法类似,也是通过递归将问题分解为更小的子问题。但关键区别在于,回溯算法通常将规模为 n 的问题分解为规模为 n-1n-2 的子问题,即规模是加法式减小,而非乘法式减小。这通常会导致算法具有指数级的时间复杂度。

在深入具体例子前,请记住一个适用于所有算法问题的通用建议:先让它能运行,再让它运行得快。我们首先关注如何构建一个能正确工作的算法。


N皇后问题 👑

我们的第一个例子是著名的N皇后问题。问题描述是:在一个 n x n 的棋盘上放置 n 个皇后,使得它们彼此之间无法相互攻击(即不在同一行、同一列或同一对角线上)。

问题定义与递归思路

我们递归地解决这个问题。递归函数需要知道当前已经放置了哪些皇后,才能决定下一步的合法位置。因此,我们定义递归问题为:给定前 r 行皇后的位置,求解在剩余行放置皇后的所有可能方式。

以下是该算法的伪代码描述。核心思想是尝试当前行(第 r 行)所有可能的列位置,对于每个合法位置,放置皇后并递归处理下一行。

def place_queens(Q, r, n):
    # Q: 数组,Q[i] 表示第 i 行皇后所在的列 (1 <= i <= r-1)
    # r: 当前要放置皇后的行号
    # n: 棋盘大小
    if r == n + 1:
        # 所有行都已放置皇后,找到一个解
        print_solution(Q)
    else:
        for j in range(1, n + 1): # 尝试当前行的每一列 j
            legal = True
            # 检查放置在第 r 行第 j 列的皇后是否与之前行 (1...r-1) 的皇后冲突
            for i in range(1, r):
                if (Q[i] == j) or (Q[i] == j + r - i) or (Q[i] == j - r + i):
                    legal = False
                    break
            if legal:
                Q[r] = j # 放置皇后
                place_queens(Q, r + 1, n) # 递归处理下一行
                # 递归返回后,尝试当前行的下一个位置(隐式“移除”皇后)

算法分析

该算法会系统地探索所有可能的放置组合。递归树可能非常庞大,最坏情况下的运行时间约为 O(n^n),效率极低。然而,对于此类组合问题,这通常是已知的最佳方法之一。算法的正确性显而易见,因为它尝试了所有可能性。


双人完全信息游戏 🎮

上一节我们看了如何用回溯解决静态布局问题,本节中我们来看看如何将其应用于动态的双人游戏。

游戏树与必胜态/必败态

考虑一个简单的双人回合制游戏(如文中提到的“糖包游戏”或象棋)。游戏状态可以用一个位置和当前玩家来表示。我们可以构建一棵游戏树,根节点是初始状态,子节点是通过一步合法移动到达的状态。

我们的目标是判断在当前状态下,先手玩家是否存在必胜策略。我们递归定义:

  • 必胜态:存在至少一步移动,可以引导游戏进入一个对对手而言的必败态
  • 必败态:所有可能的移动,都会引导游戏进入一个对对手而言的必胜态

递归算法

基于此,我们可以设计一个递归的回溯算法来判断任意状态 X 对当前玩家 P 是必胜还是必败。

def is_good_position(state, player):
    # state: 当前游戏状态
    # player: 当前轮到行动的玩家(‘我’或‘对手’)
    if state is a winning state for player:
        return True
    if state is a losing state for player:
        return False

    possible_moves = generate_all_legal_moves(state, player)
    for move in possible_moves:
        new_state = apply_move(state, move)
        # 如果存在一步移动能使对手陷入必败态,则当前状态是必胜态
        if not is_good_position(new_state, opponent(player)):
            return True
    # 所有移动都导致对手进入必胜态,则当前状态是必败态
    return False

与N皇后问题的区别

注意,在这个游戏算法中,递归子问题(is_good_position(new_state, ...))的输入只依赖于当前状态,而不依赖于到达这个状态的历史路径。这与N皇后问题不同,在N皇后中我们需要记住之前所有皇后的位置。这种“无历史依赖”的特性在某些情况下是优化的关键。


字符串分解问题 🔤

现在,我们来看第三个例子:判断一个字符串是否能被分解为给定词典中的单词序列。这是一个更贴近实际的问题。

问题与递归分解

给定字符串 A[1..n] 和一个能判断子串是否为单词的函数 isWord(w),我们需要判断 A 是否能写成一系列单词的连接。

递归思路非常直接:对于当前字符串(或后缀),我们尝试所有可能的前缀。如果某个前缀是一个单词,我们就“切掉”这个前缀,然后递归地判断剩余的后缀是否也能分解为单词序列。

递归算法

以下是该算法的两种等价表述。第一种更直观,第二种更高效(通过索引操作避免复制字符串)。

表述一(直观):

def splitable(suffix):
    if suffix is empty:
        return True # 空字符串可以分解为0个单词
    for i in range(1, len(suffix) + 1):
        prefix = suffix[0:i]
        rest = suffix[i:]
        if isWord(prefix) and splitable(rest):
            return True
    return False
# 初始调用: splitable(original_string)

表述二(高效,使用索引):

# A 是全局的输入字符串数组
def splitable(i):
    # 判断后缀 A[i..n] 是否可分解
    if i > n:
        return True # 空后缀
    for j in range(i, n + 1):
        # 检查子串 A[i..j] 是否为单词
        if isWord(A, i, j) and splitable(j + 1):
            return True
    return False
# 初始调用: splitable(1)

指数爆炸与重复子问题

该算法在最坏情况下(例如,每个子串都是单词)会尝试所有 2^(n-1) 种分割方式,运行时间为指数级 O(2^n)

然而,观察函数 splitable(i),它的输入参数 i 只有 n+1 种可能的值(1n+1)。这意味着在整个庞大的递归树中,实际上只有 O(n)不同的子问题。指数级运行时间的根源在于,算法在递归过程中反复计算了相同的子问题

例如,在计算 splitable(7) 时,可能会通过不同路径多次调用 splitable(10)。每次调用,算法都会重新进行完整的计算。

优化的关键:记忆化

这引出了一个强大的优化思想:记忆化(Memoization)。我们可以在第一次计算出 splitable(i) 的结果时,将其保存起来(例如,存入一个数组 memo[i])。之后再次需要 splitable(i) 的结果时,直接查找 memo[i] 即可,无需重新计算。

这种方法能将算法的时间复杂度从指数级 O(2^n) 降低到多项式级 O(n^2)(因为最多有 O(n) 个子问题,每个子问题需要检查 O(n) 个前缀)。这,就是动态规划的核心思想之一。


总结

本节课中我们一起学习了回溯算法。我们通过三个例子深入理解了其核心模式:

  1. N皇后问题:通过递归尝试所有可能的放置,需要记录完整的历史决策。
  2. 双人游戏必胜策略:通过递归探索游戏树,定义必胜态和必败态,通常只需关注当前状态。
  3. 字符串分解问题:通过递归尝试所有可能的前缀分割,揭示了指数级递归中存在大量重复子问题。

回溯算法是一种“暴力但系统”的搜索方法,它确保我们能找到解(如果存在的话),但代价可能是极高的时间复杂度。然而,正如在字符串分解问题中看到的,识别并消除重复子问题的计算,是将其优化为高效算法(动态规划)的关键第一步。下节课,我们将正式进入动态规划的学习。

014:动态规划入门 🚀

在本节课中,我们将学习如何将递归回溯算法优化为多项式时间算法。我们将从经典的斐波那契数列问题入手,逐步介绍记忆化(Memoization)和动态规划(Dynamic Programming)的核心思想,并应用这些思想解决文本分割问题。


概述

上一节我们介绍了递归回溯算法,这类算法通常会导致指数级的时间复杂度。本节中,我们将学习一种称为“动态规划”的强大技术,它能将许多指数时间的递归算法优化为多项式时间。我们将通过两个具体例子——计算斐波那契数和文本分割问题——来详细阐述这一过程。

斐波那契数列:从递归到动态规划

斐波那契数列的定义如下:

  • F(0) = 0
  • F(1) = 1
  • F(n) = F(n-1) + F(n-2),当 n > 1

朴素的递归算法

最直接的实现方式是将其定义翻译成代码:

def rec_fibo(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    return rec_fibo(n-1) + rec_fibo(n-2)

这个算法虽然正确,但效率极低。其递归树会指数级膨胀,导致大量重复计算。例如,计算 F(5) 时,F(3) 会被计算多次。该算法的时间复杂度约为 O(φ^n),其中 φ ≈ 1.618,是指数时间。

记忆化递归(Memoization)

为了避免重复计算,我们可以在第一次计算出某个 F(k) 的值后,将其存储起来。之后需要时直接查找,而无需重新计算。这个过程称为“记忆化”。

我们需要一个数据结构来存储这些结果。由于输入是整数索引,最合适的数据结构是数组。

以下是记忆化递归的实现:

def memo_fibo(n, memo):
    if n in memo:          # 如果已经计算过,直接返回
        return memo[n]
    if n == 0:
        result = 0
    elif n == 1:
        result = 1
    else:
        result = memo_fibo(n-1, memo) + memo_fibo(n-2, memo)
    memo[n] = result       # 将计算结果存入数组
    return result

通过记忆化,每个子问题 F(k) 只被计算一次。递归树被大幅“修剪”,时间复杂度降低为 O(n)

迭代动态规划

观察记忆化递归填充数组的顺序,它实际上是从小到大(F(0), F(1), F(2)...)依次计算的。既然如此,我们可以直接使用循环,显式地按这个顺序填充数组,从而完全避免递归调用。这就是迭代动态规划。

def dp_fibo(n):
    if n == 0:
        return 0
    F = [0] * (n+1)      # 创建数组
    F[0] = 0
    F[1] = 1
    for i in range(2, n+1):
        F[i] = F[i-1] + F[i-2]  # 递推关系
    return F[n]

这个算法清晰地展示了动态规划的核心:定义状态(数组 F),找到状态转移方程(F[i] = F[i-1] + F[i-2]),并确定正确的计算顺序(从 i=2n。其时间复杂度和空间复杂度均为 O(n)

空间优化

进一步观察,计算 F[i] 时只需要前两个值 F[i-1]F[i-2]。因此,我们无需存储整个数组,只用几个变量滚动更新即可,将空间复杂度优化到 O(1)

def dp_fibo_optimized(n):
    if n == 0:
        return 0
    prev, curr = 0, 1  # F(0), F(1)
    for i in range(2, n+1):
        nxt = prev + curr
        prev, curr = curr, nxt
    return curr

文本分割问题:应用动态规划

上一节我们介绍了文本分割问题:给定一个字符串和一个词典函数 is_word(i, j),判断该字符串能否被分割成一系列词典中的单词。

递归回溯解法

我们定义函数 splittable(i),表示从字符串第 i 个字符开始的后缀能否被成功分割。其递归定义为:

  • 如果 i 超过字符串长度,返回 True(空字符串可分割)。
  • 否则,尝试所有可能的分割点 j(从 i 到字符串末尾)。如果子串 s[i:j] 是一个单词,并且后缀 s[j+1:] 也可分割(即 splittable(j+1) 为真),则整个字符串可分割。

这个递归算法的时间复杂度是指数级的 O(2^n)

动态规划解法

遵循我们总结的流程,我们来构建动态规划算法。

  1. 定义函数与状态:函数 splittable(i) 就是我们想要计算的状态。我们用一个布尔数组 SplitTable[i] 来存储这些结果。
  2. 写出递推关系
    • SplitTable[n+1] = True (基例:空后缀)
    • 对于 in 递减到 1
      SplitTable[i] = any( is_word(i, j) and SplitTable[j+1] for j in range(i, n+1) )
  3. 确定计算顺序:要计算 SplitTable[i],我们需要知道 SplitTable[j+1] 的值,其中 j >= i。这意味着我们需要先计算索引较大(即字符串更靠后)的状态。因此,计算顺序是从右向左in 递减到 1)。
  4. 实现迭代算法
def can_segment(s, is_word):
    n = len(s)
    SplitTable = [False] * (n + 2)  # 多一位存放 n+1 的基例
    SplitTable[n + 1] = True        # 空字符串可分割

    for i in range(n, 0, -1):       # 从右向左填充
        for j in range(i, n + 1):   # 尝试所有可能的分割点
            if is_word(i, j) and SplitTable[j + 1]:
                SplitTable[i] = True
                break               # 找到一个有效分割即可
    return SplitTable[1]
  1. 分析复杂度:算法包含两层循环,外层 n 次,内层平均 n/2 次。如果每次调用 is_word 是常数时间,那么总时间复杂度为 O(n^2)。空间复杂度为 O(n)

通过动态规划,我们将一个指数级问题优化成了多项式时间问题。


总结

本节课我们一起学习了动态规划的基本思想和方法。关键步骤如下:

  1. 将问题定义为递归函数。
  2. 识别并消除重复子问题,引入记忆化(通常使用数组)。
  3. 分析子问题间的依赖关系,确定正确的计算顺序(自底向上)。
  4. 将记忆化递归改写为迭代形式的动态规划算法,并分析其复杂度。

我们从斐波那契数列这个简单例子出发,理解了从指数递归到线性动态规划的完整优化路径。随后,我们将这套方法应用于更复杂的文本分割问题,成功将其从指数时间优化为平方时间。动态规划是一种强大的算法设计范式,在后续课程中我们还会遇到更多它的应用。

015:编辑距离

概述

在本节课中,我们将学习动态规划的一个经典应用:编辑距离问题。我们将遵循动态规划的标准开发流程,从递归回溯算法开始,逐步推导出高效的迭代解法。


课堂管理与资源提醒

近期实验室发生了一起学生因情绪失控而大声呵斥他人的事件。此类行为在课堂、实验室或答疑时间均不可接受。如果再次发生类似事件,助教将有权要求涉事学生立即离开,并可能上报学校进行进一步处理。

此外,我了解到有学生遭遇了性侵犯。根据联邦法律,我是强制性的Title IX报告人,这意味着我若得知此类事件,必须向校园Title IX办公室报告。同时,我也是计算机科学系CS Cares委员会的主席。该委员会旨在帮助经历或目睹计算机科学系及相关活动中不当行为的学生。我们提供倾听、引导资源(如学生冲突解决办公室、Title IX办公室)或协助沟通等服务。相关答疑时间可在计算机科学系日历上找到。如果你在本课程的任何环节遇到问题,也欢迎随时与我沟通。

期中考试已批改完成约85%-90%,成绩预计明天发布。周四我将公布分数分布及基于当前成绩的预测课程等级。周五我将全天安排额外的答疑时间,以帮助对课程表现或周五午夜截止的退课截止日期有疑问的同学。


动态规划开发流程回顾

上一节我们介绍了动态规划的基本思想。本节中,我们来看看开发动态规划算法的标准步骤。

第一步:设计递归回溯算法

首先,不要考虑效率。目标是获得一个对问题的递归表述。

以下是设计递归算法的关键点:

  • 确定子问题:通常涉及对输入序列(如后缀或前缀)做决策,并可能需要记住过去决策的某些信息(例如,在最长递增子序列问题中,需要记住最后选择的数字)。
  • 用自然语言描述递归子问题:这是至关重要的一步,应清晰说明函数参数、全局输入以及函数返回值的具体含义。避免使用“dp”这类无意义的函数名。
  • 推导递推关系(或回溯算法):形式化你的直觉。尝试所有可能的下一步选择,递归求解剩余子问题,并组合结果。不要试图提前做出“聪明”的选择,也不要通过递归参数来累积答案。

第二步:转化为迭代算法

在获得正确的递归解法后,下一步是使其高效。

以下是转化为迭代算法的步骤:

  • 选择记忆化数据结构:通常使用多维数组,而非哈希表。数组更高效且易于分析。
  • 确定计算顺序:分析递推关系中的依赖项,以确保在计算每个数组元素时,其所依赖的元素已被计算。
  • 分析运行时间和空间:运行时间通常正比于递推式等号右边出现的不同变量的所有可能组合数乘以计算每个状态所需的时间。空间复杂度通常由递推式等号左边的参数决定,即记忆化数组的维度。

掌握了这个流程后,我们来看一个新的例子。


编辑距离问题

编辑距离衡量的是将一个字符串转换为另一个字符串所需的最小操作次数。操作包括插入一个字符、删除一个字符或替换一个字符。这个问题在文本编辑器、生物信息学(如DNA序列比对)和音频识别等领域有广泛应用。

问题建模

我们可以将编辑操作序列可视化为一个两行的对齐表格:

  • 如果一列中上下字符不同,表示一次替换
  • 如果一列中只有上方有字符,表示一次删除
  • 如果一列中只有下方有字符,表示一次插入

我们的目标是找到总“成本”(操作次数)最小的对齐方式。

设计递归解法

我们尝试从右向左构建这个对齐表格。假设我们已经处理了两个字符串的右边部分,现在需要决定最左边新的一列是什么。

递归子问题的自然语言描述
Edit(i, j) 表示将字符串 A 的前 i 个字符转换为字符串 B 的前 j 个字符所需的最小编辑距离。

递推关系
对于一般情况(i > 0j > 0),我们考虑对最后一个字符的三种操作:

  1. 替换:将 A[i] 替换为 B[j]。成本为 Edit(i-1, j-1) + [A[i] != B[j]]。其中 [X] 是艾弗森括号,当条件 X 为真时值为1,否则为0。
  2. 删除:删除 A[i]。成本为 Edit(i-1, j) + 1
  3. 插入:在 A 中插入 B[j]。成本为 Edit(i, j-1) + 1

我们选择成本最小的操作:
Edit(i, j) = min( Edit(i-1, j-1) + [A[i] != B[j]], Edit(i-1, j) + 1, Edit(i, j-1) + 1 )

基础情况

  • 如果 i = 0,则需要将空字符串转换为 B 的前 j 个字符,只能进行 j 次插入:Edit(0, j) = j
  • 如果 j = 0,则需要将 A 的前 i 个字符转换为空字符串,只能进行 i 次删除:Edit(i, 0) = i

转化为迭代算法

根据递推关系,我们可以高效地计算 Edit(i, j)

记忆化数据结构
使用一个二维数组 E[0..m][0..n],其中 mn 分别是字符串 AB 的长度。E[i][j] 将存储 Edit(i, j) 的值。

计算顺序
E[i][j] 依赖于其左方 (E[i][j-1])、上方 (E[i-1][j]) 和左上方 (E[i-1][j-1]) 的元素。因此,我们可以按行主序(先 ij)或列主序(先 ji)遍历数组,只要确保在计算 E[i][j] 时,其依赖的三个值都已计算完毕。

伪代码

function EditDistance(A[1..m], B[1..n]):
    let E[0..m][0..n] be a new 2D array
    for i = 0 to m:
        E[i][0] = i  // 基础情况:删除所有字符
    for j = 0 to n:
        E[0][j] = j  // 基础情况:插入所有字符

    for i = 1 to m:
        for j = 1 to n:
            replaceCost = E[i-1][j-1] + (A[i] != B[j] ? 1 : 0)
            deleteCost = E[i-1][j] + 1
            insertCost = E[i][j-1] + 1
            E[i][j] = min(replaceCost, deleteCost, insertCost)

    return E[m][n]

复杂度分析

  • 时间复杂度:我们需要填充一个 (m+1) x (n+1) 的表格,每个单元格的计算是常数时间。因此,总时间复杂度为 O(m*n)
  • 空间复杂度:我们使用了一个 (m+1) x (n+1) 的二维数组,因此空间复杂度为 O(m*n)。可以进一步优化到 O(min(m, n)),但这不是本课重点。

总结

本节课我们一起学习了编辑距离问题。我们首先将其建模为寻找最小成本字符串对齐方式的问题。然后,我们遵循动态规划的开发流程:定义了清晰的递归子问题 Edit(i, j),推导出了包含替换、删除、插入三种操作的递推关系,并处理了基础情况。最后,我们将其转化为自底向上的迭代算法,使用二维数组进行记忆化,并分析了其 O(m*n) 的时间和空间复杂度。这个算法是许多序列比对和相似性度量应用的基础。

016:树形动态规划

在本节课中,我们将要学习一种新的动态规划模式——树形动态规划。我们将通过一个“木板切割”问题来理解这种模式,它涉及将一个大问题递归地分解为多个独立的子问题,其依赖关系图呈现出树形结构,而非简单的线性序列。

期中成绩概览

首先,我们简要回顾一下期中考试的成绩分布情况。我根据大家提交的作业和习题集(1-4次作业,1-5次习题集)估算了一个课程平均分,并将其与期中考试成绩进行了对比分析。

图表展示了每位学生的估算课程平均分(橙色曲线)和对应的期中考试成绩(蓝色散点)。成绩高于橙色曲线的同学,其作业平均分相对较低;成绩低于曲线的同学,其作业平均分相对较高。目前课程(作业+习题集)的中位数平均分为92%。

关于成绩需要说明以下几点:

  • 部分同学因组员未在GradeScope上确认身份而导致作业成绩缺失,请尽快联系组员处理。
  • 图表未考虑任何尚未处理的作业或考试的复议请求。
  • 冲突考试的分数因其中一题难度较高而略低,我将在期末计算总成绩时进行分数调整。
  • 不同助教在批阅同一大题的不同部分时,对评分标准的解读可能存在细微差异。
  • 成绩复议的截止日期将会延后。
  • 退课截止时间为明天晚上11:59。如果你对成绩感到担忧或考虑退课,请来办公室与我面谈。请注意,目前我只对少数有不及格风险的同学发出了邮件提醒。
  • 这个估算比较粗略,仅基于约30%的课程作业,且未考虑最低分剔除、延期等政策。从期中到期末,成绩变动一个甚至两个等级(上升或下降)都是有可能的。
  • 本次考试的平均分高于我过去三次教授此课程时的平均分,总体而言大家表现不错。

现在,让我们回到动态规划的主题。

回顾:编辑距离问题

上一节我们介绍了编辑距离问题及其动态规划解法。编辑距离定义为将一个字符串A转换为另一个字符串B所需的最少插入、删除或替换操作次数。

我们推导出了以下递推关系,其中 Edit(i, j) 表示字符串A的前 i 个字符与字符串B的前 j 个字符之间的编辑距离:

Edit(i, j) = min(
    Edit(i, j-1) + 1,                 // 插入
    Edit(i-1, j) + 1,                 // 删除
    Edit(i-1, j-1) + (A[i] != B[j])   // 替换(字符不同时成本为1)
)

基础情况是 Edit(0, j) = jEdit(i, 0) = i。最终答案是 Edit(m, n)

我们通过一个二维数组(m+1 行 x n+1 列)来存储(记忆化)所有 Edit(i, j) 的值。为了确保计算每个单元格时,其所依赖的左侧(Edit(i, j-1))、上方(Edit(i-1, j))和左上方(Edit(i-1, j-1))的单元格都已被计算,我们通常采用行主序(外层循环遍历行i从0到m,内层循环遍历列j从0到n)来填充数组。整个算法的时间复杂度为 O(mn)

关于决策方向,一个直观的技巧是:在构思递归时,如果我们从序列的末端(或右端)开始决策并递归处理前缀,那么最终实现动态规划时,循环顺序往往是正向的(索引递增)。反之亦然。两种方式都是可行的。

最后,在计算出包含最优成本(编辑距离)的表格后,我们可以通过回溯(从终点 Edit(m, n) 开始,根据递推关系逆向查找每一步的来源)来重建出具体的编辑操作序列。通常,课程中只要求计算最优成本。

引入:木板切割问题

现在,我们来看一个新的问题——木板切割问题,它将引导我们进入树形动态规划。

问题描述:你有一根长的原木板(plank),需要将其按指定位置切割成若干块较短的木板(boards)。你只能去朋友的车间切割,每次切割的费用等于被切割木板的当前长度。目标是找到一种切割顺序,使得总费用最小。

示例:假设原木板长10英尺,需要在距左端2、3、6英尺处进行切割(即得到长度分别为2、1、3、4英尺的木板)。

  • 如果从左到右顺序切割:第一次切10英尺板($10),得到2英尺板和8英尺板;第二次切8英尺板($8),得到1英尺板和7英尺板;第三次切7英尺板($7)。总费用 = $25。
  • 如果从右到左顺序切割:第一次切10英尺板($10),得到6英尺板和4英尺板;第二次切6英尺板($6),得到2英尺板和4英尺板(此处有误,应为得到3英尺板和3英尺板?我们以实际计算为准)。总费用可能更低。
  • 如果先切中间(3英尺标记处):第一次切10英尺板($10),得到一块3英尺板和一块7英尺板;然后分别切割这两块板。这展示了切割顺序的多样性。

关键在于,第一次切割将原板分成两块后,这两块板的后续切割是相互独立的子问题。这与之前编辑距离等问题的线性递归结构不同。

定义子问题与递推关系

为了形式化问题,我们定义输入:一个数组 L[1..n],其中 L[i] 表示第 i 块目标短木板的长度。同时,我们有 n+1 个切割点(包括两端)。我们定义子问题:

OptCost(i, k) 表示将位于切割点 i 和切割点 k 之间的这段木板(包含了短木板 i+1k)完全切割成目标小块所需的最小费用。注意,ik 是切割点的索引,而不是木板的索引。

我们最终要求的是 OptCost(0, n),即将整根原板(从切割点0到切割点n)切割好的最小费用。

现在推导递推关系。考虑一般情况(ik 不相邻):

  1. 首次切割成本:无论第一次在哪里切,切割这段木板本身的成本就是它的总长度,即 sum(L[j] for j from i+1 to k)
  2. 选择切割点:我们可以在 ik 之间的任意切割点 ji < j < k)进行第一次切割。
  3. 递归子问题:切割点 j 将当前木板分成了左右两段:[i, j][j, k]。这两段木板需要分别独立地、以最优方式完成切割,其成本分别为 OptCost(i, j)OptCost(j, k)
  4. 总成本:对于某个特定的 j,总成本为 首次切割成本 + OptCost(i, j) + OptCost(j, k)
  5. 最优选择:我们需要遍历所有可能的 j,选择总成本最小的那个。

因此,递推关系如下:

OptCost(i, k) = 0, 如果 k - i == 1  # 基础情况:只剩一块板,无需再切
OptCost(i, k) = min(
    (sum(L[j] for j from i+1 to k)) + OptCost(i, j) + OptCost(j, k)
    for all j where i < j < k
)

动态规划实现

这个递归函数有两个参数 ik,因此我们需要一个二维数组(或表格)来进行记忆化。数组的行对应 i,列对应 k,大小约为 (n+1) x (n+1)。我们只关心 i < k 的部分(矩阵的上三角部分)。

确定计算顺序:计算 OptCost(i, k) 时,它依赖于所有 OptCost(i, j)OptCost(j, k),其中 i < j < k。在二维表格中:

  • OptCost(i, j) 位于同一行 i,但列 j 在列 k 的左边。
  • OptCost(j, k) 位于同一列 k,但行 j 在行 i 的下方。

因此,为了确保计算每个单元格时,其依赖的所有左侧和下方的单元格都已就绪,我们可以按行从下往上、每行从左往右计算。或者,也可以按列从左往右、每列从下往上计算。另一种更直观的顺序是按子问题长度(即 k - i 的值)递增的顺序进行计算:先处理所有长度为1的基础情况(k = i+1),然后处理长度为2,长度为3,...,直到长度为 n(即 OptCost(0, n))。

算法复杂度:我们需要填充 O(n²) 个单元格。对于每个单元格 OptCost(i, k),我们需要:

  1. 计算 sum(L[i+1..k]),这可以在 O(n) 时间内完成,但通过前缀和预处理可以优化到 O(1)。
  2. 遍历所有可能的切割点 j (i < j < k),最多有 O(n) 个。
    因此,最直接实现的时间复杂度是 O(n³)。通过一些优化技巧(如利用四边形不等式),可以将此特定问题的复杂度降低到 O(n²)。甚至存在基于贪心选择(类似于霍夫曼编码)的 O(n log n) 算法,但这属于该问题的特殊性质,并非所有树形动态规划都适用。

总结

本节课中我们一起学习了树形动态规划。我们从编辑距离问题的回顾开始,巩固了基于前缀的线性动态规划思路。然后,我们引入了一个新的木板切割问题,它展示了动态规划的另一种常见模式:树形分解。在这个问题中,一个决策(第一次切割)会将原问题分解成两个独立的子问题,这些子问题可以继续递归分解,形成树状的依赖结构。

我们定义了子问题 OptCost(i, k),并推导出了相应的递推关系,其中包含了对中间切割点的遍历。我们讨论了如何用二维表格记忆化,并分析了以子问题长度递增的顺序进行填表的策略。最终,我们得到了一个时间复杂度为 O(n³) 的动态规划算法,并提及了可能的优化方向。

树形动态规划是解决许多区间划分、树形结构优化问题的强大工具,关键在于识别出问题如何被分解为独立的子问题,并正确定义状态和递推关系。

017:图的表示与遍历 🗺️

在本节课中,我们将要学习图的基本概念、两种核心的数据结构表示方法(邻接矩阵与邻接表),以及一种通用的图遍历算法——任意优先搜索。

图是算法设计中最为有用的数学抽象之一。它由顶点(Vertices)和连接顶点的(Edges)组成。一个图可以形式化地定义为 G = (V, E),其中 V 是一个非空的有限顶点集合,E 是边的集合。对于无向图,边是顶点的无序对 {u, v};对于有向图,边是顶点的有序对 (u, v)。重要的是,要将图本身与其图形化表示(即画出来的圆圈和连线)区分开来,后者只是图的一种可视化方式。

上一节我们介绍了图的基本定义,本节中我们来看看如何在计算机中存储和表示图。

图的表示方法

当我们在代码中处理图时,需要将其存储在具体的数据结构中。主要有两种标准的数据结构表示方法。

邻接矩阵

邻接矩阵使用一个二维布尔数组 A 来表示图。如果图有 n 个顶点,则矩阵大小为 n x n。矩阵元素 A[u][v] 的值为 1(或 True)表示顶点 uv 之间存在一条边,为 0(或 False)则表示没有边。

核心概念公式:
A[u][v] = 1 if (u, v) ∈ E else 0

对于无向图,邻接矩阵是对称的,即 A[u][v] = A[v][u]

优点:

  • 可以在常数时间 O(1) 内查询任意两个顶点间是否存在边。

缺点:

  • 无论图中有多少条边,它总是占用 O(V²) 的存储空间。
  • 列出某个顶点的所有邻居需要 O(V) 时间,即使该顶点的实际邻居很少。

邻接表

邻接表使用一个数组(或列表)来表示图,数组的每个索引对应一个顶点。每个数组元素存储一个链表(或其他动态容器),该链表包含了与该顶点相邻的所有其他顶点。

核心概念代码(Python风格描述):

# 假设顶点编号为 0 到 V-1
adjacency_list = [[] for _ in range(V)]
# 添加一条从 u 到 v 的边(无向图需添加两次)
adjacency_list[u].append(v)

优点:

  • 存储空间与顶点数 V 和边数 E 成正比,为 O(V + E),对于边数远少于 的稀疏图非常高效。
  • 可以高效地列出某个顶点 v 的所有邻居,时间复杂度为 O(degree(v)),即与该顶点相连的边数。

缺点:

  • 查询任意两个顶点 uv 之间是否存在边,需要 O(min(degree(u), degree(v))) 的时间,在最坏情况下可能达到 O(V)

在实际算法中,尤其是处理稀疏图时,邻接表通常是更优的选择,因为许多图算法(如遍历)的核心操作是“列出某个顶点的所有邻居”。

图的遍历:任意优先搜索

遍历图意味着系统地访问图中的每一个顶点。我们介绍一种通用的遍历框架——任意优先搜索。

以下是任意优先搜索的核心思想,它使用一个称为“袋子”的抽象数据结构:

  1. 将起始顶点放入“袋子”。
  2. 只要“袋子”非空,就从中取出一个顶点。
  3. 如果该顶点未被访问过(标记),则标记它已访问,并将其所有未访问的邻居放入“袋子”。
  4. 重复步骤2-3。

核心概念伪代码:

WhateverFirstSearch(s):
    mark all vertices as unvisited
    initialize an empty Bag
    put vertex s into the Bag
    while the Bag is not empty:
        take a vertex v from the Bag
        if v is unvisited:
            mark v as visited
            for each neighbor w of v:
                if w is unvisited:
                    put w into the Bag

这个算法的关键在于“袋子”这个抽象。通过选择“袋子”的具体实现,我们可以得到不同的遍历策略:

  • 如果“袋子”是一个(后进先出),那么这就是深度优先搜索
  • 如果“袋子”是一个队列(先进先出),那么这就是广度优先搜索
  • 它也可以是优先队列随机集合等。

无论“袋子”如何实现,该算法都能访问从起点 s 可达的所有顶点(即 s 所在的连通分量)。要遍历整个图(包括所有连通分量),只需在外层循环中依次以每个未访问的顶点作为起点调用此算法即可。

以下是分析该算法时间复杂度的一个关键点:

运行时间分析:

  • 每个顶点最多被放入和取出“袋子”各一次。
  • 对于每条边 (u, v),算法会从 u 检查 v 一次,并从 v 检查 u 一次(对于无向图)。因此,检查邻居的循环总共执行 O(E) 次。
  • 如果图使用邻接表表示,那么总时间复杂度为 O(V + E)

本节课中我们一起学习了图的基本表示方法——邻接矩阵和邻接表,以及一个强大而通用的图遍历框架——任意优先搜索。理解这些基础是学习后续更复杂图算法(如最短路径、连通性分析等)的关键。记住,选择正确的数据表示方式可以显著影响算法的效率。

018:深度优先搜索、有向无环图与强连通分量

在本节课中,我们将要学习图算法家族中的一个重要成员——通用搜索算法,并重点探讨其变体深度优先搜索。我们将了解如何使用深度优先搜索来检测有向图中的环,以及如何识别有向图的强连通分量结构。

通用搜索算法概述

上一节我们开始讨论图算法,并介绍了一个通用的图遍历算法家族,称为“通用搜索”。其核心思想是使用一个名为“袋子”的数据结构来管理待访问的顶点。

一个“袋子”可以执行三种操作:

  1. 将一个对象插入袋子。
  2. 从袋子中取出一个对象(具体取出哪个取决于袋子的具体实现)。
  3. 检查袋子是否为空。

以下是几种常见的“袋子”实现:

  • :后进先出。
  • 队列:先进先出。
  • 优先队列:根据优先级取出元素。

算法的基本流程如下:

  1. 将起始顶点 s 放入袋子。
  2. 当袋子不为空时:
    a. 从袋子中取出一个顶点 v
    b. 如果 v 未被标记过,则标记它。
    c. 将 v 的所有(未标记的)邻居放入袋子。

该算法会标记所有从起点 s 可达的顶点。其运行时间为 O(V + E),其中 V 是可达顶点的数量,E 是可达边的数量。这是因为每个顶点最多被标记一次,每条边最多被处理两次(从两端各考虑一次)。

生成树与父指针

通用搜索算法可以稍作修改,为每个被发现的顶点(除了起点 s)记录一个“父”顶点。具体做法是,在将邻居 w 放入袋子时,同时记录 vw 的父节点。

以下是修改后的算法伪代码:

WhateverFirstSearch(s):
    mark(s)
    parent(s) = null
    bag.insert((null, s))
    while bag is not empty:
        (p, v) = bag.take()
        if v is unmarked:
            mark(v)
            parent(v) = p
            for each neighbor w of v:
                bag.insert((v, w))

这些父指针定义了一个以 s 为根的生成树,它覆盖了所有从 s 可达的顶点。这棵树具有以下性质:

  • 恰好有 V-1 条父指针边。
  • 这些边中没有环,因为父节点总是比子节点更早被发现。

深度优先搜索与广度优先搜索

根据所使用的“袋子”类型,通用搜索会演变成不同的具体算法,并产生不同形态的生成树。

深度优先搜索使用作为袋子。它倾向于深入探索一条路径,直到尽头再回溯,因此生成的树通常又深又窄。

广度优先搜索使用队列作为袋子。它从起点开始一层层向外探索,因此生成的树通常又宽又浅。更重要的是,在无权图中,广度优先搜索树中从根 s 到任意顶点 v 的路径,就是两者之间的最短路径(边数最少)。

无向图的应用

在无向图中,通用搜索算法可以解决几个基本问题:

连通性:判断两个顶点 uv 是否连通(即是否存在路径)。只需从 u 开始运行搜索,检查 v 是否被标记即可。

连通分量:“连通”关系是一种等价关系。我们可以通过多次运行搜索来找出图的所有连通分量。算法如下:

CountAndLabel(G):
    count = 0
    for all vertices v:
        if v is unmarked:
            count = count + 1
            WhateverFirstSearch(v, count) // 标记该分量所有顶点为 count

该算法的总运行时间仍然是 O(V + E),因为每个顶点和每条边只被处理一次。

有向图:可达性与强连通性

将通用搜索算法应用于有向图时,流程基本相同,只是遍历的是有向边。算法会标记所有从起点 s 通过有向路径可达的顶点。

然而,在有向图中,“可达”关系不是对称的。s 可达 t 并不意味着 t 可达 s。因此,我们引入了强连通的概念:两个顶点 uv 是强连通的,当且仅当 u 可达 vv 可达 u

强连通关系是一种等价关系,其等价类称为强连通分量。一个有向图的强连通分量具有一个关键性质:如果将每个强连通分量收缩为一个“超顶点”,那么得到的分量图(或称凝聚图)是一个有向无环图。这意味着分量之间的连接没有循环依赖。

识别所有强连通分量及其结构可以在 O(V + E) 时间内完成(例如使用 Kosaraju 或 Tarjan 算法)。

深度优先搜索的细节:时间戳与环检测

现在,让我们更深入地看看深度优先搜索。我们可以为算法添加“预处理”、“访问前”和“访问后”的钩子函数,并记录时间戳。

以下是记录时间戳的 DFS 伪代码:

DFSAll(G):
    clock = 0
    for all vertices v:
        if v is unmarked:
            DFS(v)

DFS(v):
    previsit(v)        // 记录 pre(v) = ++clock
    mark(v)
    for each neighbor w of v:
        if w is unmarked:
            parent(w) = v
            DFS(w)
    postvisit(v)       // 记录 post(v) = ++clock
  • 前序编号 pre(v):记录第一次递归进入顶点 v 的时间。
  • 后序编号 post(v):记录完全处理完顶点 v 及其后代的时间。

顶点的后序编号有一个非常重要的应用:检测有向环

关键引理:一个有向图 G 包含环,当且仅当图中存在一条边 u -> v,使得 post(u) < post(v)

换句话说,如果存在一条从 uv 的边,但 u 的后序编号却比 v 小(即 uv 之后才结束处理),那么图中必然存在环。反之,如果一个有向图是无环的,那么按后序编号的逆序对顶点进行排序,得到的就是一个拓扑排序——所有边都从左指向右的线性顺序。

总结

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

  1. 通用搜索算法的核心框架及其运行时间分析 O(V + E)
  2. 通过记录父指针,搜索算法可以生成生成树
  3. 使用作为袋子得到深度优先搜索,使用队列得到广度优先搜索,后者可用于求解无权图最短路径。
  4. 在无向图中,搜索算法可用于判断连通性和找出所有连通分量
  5. 在有向图中,我们引入了强连通分量的概念,其分量图构成一个有向无环图
  6. 深度优先搜索中的后序编号是强大的工具,可用于检测有向环,并为无环图生成拓扑排序

理解这些基础的图遍历算法及其变体,是解决更复杂图论问题的基石。

019:拓扑排序、DAG动态规划与最短路径

在本节课中,我们将学习有向图中的深度优先搜索(DFS)及其应用,包括如何检测环、理解有向无环图(DAG)、进行拓扑排序,以及如何利用这些概念解决DAG上的动态规划问题(如最长路径问题)。最后,我们将介绍计算最短路径的通用框架和几种经典算法。

深度优先搜索与遍历顺序

上一节我们介绍了图的基本概念。本节中,我们来看看如何在有向图中进行深度优先搜索,并记录两种重要的遍历顺序。

深度优先搜索(DFS)是对树的前序和后序遍历的推广。在前序遍历中,我们首先访问节点,然后递归地探索其子树。在后序遍历中,我们先进行递归探索,最后再访问节点。

在有向图的DFS中,我们使用一个包装函数。该函数首先将所有顶点标记为未访问,并将“时钟”归零。然后,它按任意顺序遍历所有顶点。如果发现一个未标记的顶点,就从该点启动一次递归探索。

递归探索过程由一个子程序完成。它接收一个节点和当前时钟值。当首次访问节点时(前序时间),立即递增时钟并记录为 v.pre。然后,递归探索该节点的所有出边(即后继节点)。当所有出边探索完毕后(后序时间),再次递增时钟并记录为 v.post

这个算法会生成一个由父指针构成的DFS生成森林,其运行时间与顶点数加边数成正比,即 O(V + E)

后序编号与环检测

上一节我们介绍了如何通过DFS获取顶点的后序编号。本节中我们来看看这个编号的一个重要应用:检测有向图中的环。

引理:一个有向图包含环,当且仅当存在一条从顶点 v 指向顶点 w 的边,且满足 v.post < w.post。换句话说,存在一条边,其尾顶点的后序编号小于头顶点的后序编号。

这意味着,如果DFS在完成对 v 的探索之前就完成了对 w 的探索(即 v 的结束时间早于 w),并且存在一条 vw 的边,那么图中就存在环。

如果没有这个引理,检测环可能需要检查每条边 v->ww 是否能到达 v,这可能导致 O(E^2)O(VE) 的时间复杂度。而利用此引理,我们可以在 O(V + E) 时间内检测环:运行一次DFS,记录所有顶点的后序时间,然后暴力检查每条边是否满足上述不等式条件。

有向无环图与拓扑排序

上一节我们讨论了如何检测环。本节中我们来看看当图中没有环时会发生什么。

如果一个有向图运行DFS后,对于每条边 v->w,都满足 v.post > w.post(即每条边的尾顶点后序时间都大于头顶点的后序时间),那么这个图就没有有向环。这样的图称为有向无环图

在DAG中,至少存在一个源点(没有入边的顶点)和一个汇点(没有出边的顶点)。同一个顶点可以同时是源点和汇点。

根据之前的引理,如果图没有环,那么按后序编号对顶点排序后,每条边都从后序编号较大的顶点指向后序编号较小的顶点。如果我们反转这个顺序,就得到了一个拓扑序:所有边都从左指向右。计算DAG的拓扑序的过程称为拓扑排序

拓扑排序算法非常简单:运行DFS,在顶点完成探索(后序时刻)时,将其按顺序放入一个列表中。由于后序顺序是“反拓扑序”,我们需要将最终列表反转,或者在后序时刻将顶点从列表末尾向前放置。

核心建议:任何时候拿到一个DAG,第一件事就是对其进行拓扑排序。因为任何处理DAG的算法至少需要 O(V + E) 时间,而拓扑排序本身也是这个时间复杂度,所以它基本上是“免费”的预处理步骤。

DAG上的动态规划:最长路径问题

上一节我们学会了如何对DAG进行拓扑排序。本节中我们来看看如何利用拓扑序解决DAG上的动态规划问题,例如最长路径问题

给定一个带权DAG(权值可为正、负或零),我们希望找到总权值最大的路径。

首先,对DAG进行拓扑排序,使所有边从左指向右。为了简化问题,我们可以添加一个虚拟的汇点 t,并用权值为0的边将其连接到所有其他顶点。这样,寻找图内任意最长路径就等价于寻找以 t 为终点的最长路径。

我们定义函数 LLP(v):表示从顶点 v 到汇点 t 的最长路径长度。我们希望通过递归方式计算它。

递归关系(核心思想):考虑从 v 出发的第一条边。对于 v 的每条出边 v->w,如果最长路径从这条边开始,那么其总长度就是该边的权值加上从 wt 的最长路径长度。由于我们不知道哪条边是最优的第一条边,因此需要取最大值。

公式

LLP(v) = max { weight(v->w) + LLP(w) | for all edges v->w }

基本情况:当 v == t 时,从 tt 的路径是空路径,长度为0。因此 LLP(t) = 0

有了递归关系,我们可以通过记忆化搜索(在DFS中计算并存储 LLP(v))或迭代动态规划来计算。由于图是DAG且已拓扑排序,我们可以按逆拓扑序(即从右向左)迭代计算所有 LLP(v) 值,确保计算每个顶点时,其所有后继顶点的值都已计算完毕。

这个模式适用于许多序列型动态规划问题,其实质都是在隐式的依赖DAG中寻找最优路径。编辑距离等问题都可以看作在网格DAG中寻找最短/最长路径。

最短路径问题概述

上一节我们探讨了DAG上的最长路径。本节中我们转向更一般的最短路径问题

最常见的是单源最短路径问题:给定一个带权图(通常权值为正)和一个源点 s,找到从 s 到图中所有其他顶点的最短路径。

这些最短路径具有一个重要性质:它们构成一棵以 s 为根的树(最短路径树)。如果存在多条等长的最短路径,则存在一个包含最短路径的树形结构。

所有最短路径算法都维护两个信息:

  1. dist(v):从 sv 的当前最短距离估计值(总是高估或等于真实值)。
  2. pred(v):在 sv 的当前最短路径估计中,v 的前驱顶点。

初始化

  • dist(s) = 0, pred(s) = NULL
  • 对于所有其他顶点 vdist(v) = ∞, pred(v) = NULL

通用最短路径算法:松弛操作

所有最短路径算法的核心是一个称为松弛的简单操作。

定义:一条边 u->v紧张的,如果 dist(u) + weight(u->v) < dist(v)。这意味着通过 u 到达 v 比当前已知的到 v 的路径更短。

松弛操作:当边 u->v 紧张时,我们更新 v 的信息:

  1. dist(v) = dist(u) + weight(u->v)
  2. pred(v) = u

通用算法(Ford算法)

  1. 初始化所有 distpred
  2. 重复以下步骤,直到没有紧张的边:
    • 找到任意一条紧张的边 u->v
    • 对边 u->v 执行松弛操作。

如果最终没有紧张的边,那么算法正确计算出了所有最短路径。然而,松弛边的顺序至关重要,它决定了算法的效率和是否能够终止。

具体的最短路径算法

通用算法过于宽泛。以下是几种由不同“松弛顺序”产生的具体算法:

1. DAG最短路径算法

  • 适用条件:图是有向无环图。
  • 方法:对DAG进行拓扑排序,然后按拓扑顺序(从左到右)遍历顶点。对于每个顶点,松弛其所有出边。
  • 特点:能处理负权边,运行时间为 O(V + E)

2. 广度优先搜索(BFS)

  • 适用条件:所有边权相等(通常视为1)。
  • 方法:使用队列(FIFO)管理顶点。这实际上是通用算法的一种特例,松弛顺序由BFS的层次顺序决定。
  • 特点:运行时间为 O(V + E),能找到最少边数的路径。

3. Dijkstra算法

  • 适用条件:边权非负(经典版本)。更通用的版本能处理无负环的图,但效率可能降低。
  • 方法:使用优先队列(最小堆)管理顶点,优先级为当前的 dist 值。总是优先松弛距离估计最小的顶点。
  • 特点:当边权非负时,非常高效(使用二叉堆时为 O((V+E) log V))。如果存在负权边但数量很少,实践中仍常使用。

重要澄清:深度优先搜索(DFS)不适用于在一般图中寻找最短路径(除非是DAG)。DFS不探索所有可能路径,它只探索第一条到达每个顶点的路径。试图用DFS探索所有路径将导致指数级时间复杂度。

总结

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

  1. 有向图中深度优先搜索的后序编号及其在环检测中的应用。
  2. 有向无环图的特性,以及如何通过DFS在 O(V+E) 时间内进行拓扑排序
  3. 如何利用拓扑序解决DAG上的动态规划问题,例如最长路径问题,其本质是在依赖DAG中寻找最优路径。
  4. 单源最短路径问题的通用框架,其核心是松弛操作
  5. 几种具体的最短路径算法:适用于DAG的线性时间算法、适用于等权图的广度优先搜索,以及适用于非负权图的Dijkstra算法。每种算法都是通过控制松弛操作的顺序来实现的。

理解这些算法之间的联系——它们都是同一通用框架下的特例——对于掌握图算法至关重要。

020:Dijkstra 与 Bellman-Ford 算法详解 🧭

在本节课中,我们将深入学习两种计算单源最短路径的核心算法:Dijkstra 算法和 Bellman-Ford 算法。我们将探讨它们的工作原理、适用场景以及背后的动态规划思想。


概述

所有最短路径算法都遵循一个通用策略:寻找并“松弛”那些“紧张”的边。对于一个顶点,我们维护一个距离值和一个前驱指针。如果存在一条从顶点 u 到顶点 v 的边,使得 u.dist + w(u, v) < v.dist,我们就称这条边是“紧张”的。松弛操作会更新 v.dist 为更小的值,并将 v 的前驱设置为 u

通用策略的步骤如下:

  1. 初始化:源点 s 的距离为 0,其他所有顶点的距离为无穷大。
  2. 不断寻找并松弛紧张的边。
  3. 当没有紧张的边时,前驱指针就构成了一棵最短路径树。

对于无权图(所有边权重为1),广度优先搜索(BFS)是线性时间的解决方案。对于有向无环图(DAG),按拓扑顺序松弛边也能在线性时间内解决问题。然而,对于具有任意权重的通用图,我们需要更强大的算法。


Dijkstra 算法 🚀

上一节我们介绍了通用松弛策略,本节中我们来看看最著名的非负权重最短路径算法:Dijkstra 算法。它本质上是一种“最佳优先搜索”。

核心思想与数据结构

Dijkstra 算法使用一个优先队列(通常是最小堆)来管理顶点。每个顶点的优先级是其当前估计的距离。算法维护一个不变式:从优先队列中取出的顶点,其距离值就是最终的最短距离。

以下是算法依赖的优先队列操作:

  • Insert(v, priority): 将顶点 v 以指定优先级插入队列。
  • ExtractMin(): 取出并返回队列中优先级最小的顶点。
  • DecreaseKey(v, newPriority): 将已在队列中的顶点 v 的优先级降低为新值。

算法步骤

以下是 Dijkstra 算法的伪代码描述:

Dijkstra(G, s):
    for each vertex v in G:
        v.dist = INFINITY
        v.pred = NULL
    s.dist = 0
    Initialize PriorityQueue Q
    Q.Insert(s, s.dist)

    while Q is not empty:
        u = Q.ExtractMin()
        for each edge (u, v) in G.adjacentEdges(u):
            if u.dist + w(u, v) < v.dist: // 边 (u, v) 紧张
                v.dist = u.dist + w(u, v) // 松弛
                v.pred = u
                if v is in Q:
                    Q.DecreaseKey(v, v.dist)
                else:
                    Q.Insert(v, v.dist)

算法演示与正确性

让我们通过一个例子来理解算法的执行过程。假设源点 s 在底部。

  1. 初始时,只有 s(距离0)在优先队列中。取出 s,松弛其出边,更新邻居距离并加入队列。
  2. 接下来,取出队列中距离最小的顶点(例如距离3的顶点),松弛其出边。如果松弛成功,则更新目标顶点的距离(可能需要 DecreaseKeyInsert)。
  3. 重复此过程,直到队列为空。最终,所有顶点的距离值收敛为最短距离,前驱指针形成最短路径树。

Dijkstra 算法正确性的关键基于两个观察(在所有边权非负的前提下):

  1. 距离值单调不增:任何顶点的 v.dist 在整个算法过程中只会减小(当被松弛时),不会增加。
  2. 出队距离单调不减:从优先队列中取出的顶点的距离值是单调不减的。因为每次取出最小距离顶点 u 后,通过它松弛得到的任何新距离 v.dist 都满足 v.dist = u.dist + w(u, v) >= u.dist

这两个性质共同保证了每个顶点最多被从优先队列中取出一次。因为如果同一个顶点被取出两次,根据性质2,第二次的距离必须大于等于第一次;但根据性质1,它的距离只可能比第一次更小或相等,这产生了矛盾。

时间复杂度分析

基于上述“每个顶点最多出队一次”的结论:

  • ExtractMin 操作最多执行 V 次。
  • 每条边最多在其尾顶点被取出时检查并可能松弛一次,因此 Relax(及其内部的 DecreaseKeyInsert)操作最多执行 E 次。
  • 每个优先队列操作(Insert, ExtractMin, DecreaseKey)的时间复杂度为 O(log V)

因此,Dijkstra 算法的总时间复杂度为 O((V + E) log V)。对于连通图(E >= V-1),通常简写为 O(E log V)


处理负权边的情况 ⚠️

上一节我们分析了 Dijkstra 算法在非负权图上的优异表现,本节中我们来看看当图中存在负权边时会发生什么,并介绍能处理这种情况的 Bellman-Ford 算法。

Dijkstra 算法与负权边

如果图中存在负权边,Dijkstra 算法的核心正确性保证会被打破。那个“出队即确定最短距离”的不变式不再成立。因为通过一个当前距离较大的顶点,经由负权边,可能得到一条到达某个已出队顶点的更短路径。

在这种情况下,原始的、允许将顶点重新插入优先队列的 Dijkstra 算法(即通用松弛策略)仍然有效(只要没有负权环)。但最坏情况下,一个顶点可能被多次插入和取出,导致运行时间退化,甚至可能达到指数级。不过,如果负权边数量很少(例如常数个),算法效率仍然可以接受。

对于存在负权环的图,最短路径问题本身可能无解(可以无限绕环降低总距离),任何基于松弛的算法都可能陷入无限循环。

Bellman-Ford 算法 🛡️

为了系统性地、高效地处理具有任意权重(包括负权)且无负权环的图,我们使用 Bellman-Ford 算法。它的运行时间为 O(V * E),虽然比 Dijkstra 慢,但更具普适性。它也可以用于检测图中是否存在负权环。

动态规划视角

Bellman-Ford 算法可以从动态规划的角度优雅地推导出来。我们定义状态:
dist[i][v] = 从源点 s 到顶点 v最多使用 i 条边的最短路径长度。

我们有以下递推关系(Bellman 方程):

  • 基础情况dist[0][s] = 0;对于 v != sdist[0][v] = ∞(不使用边无法到达其他点)。
  • 递推关系:对于 i > 0
    dist[i][v] = min( dist[i-1][v], min_{(u, v) in E} ( dist[i-1][u] + w(u, v) ) )
    解释:到 v 最多用 i 条边的最短路径,要么根本没用满 i 条边(即 dist[i-1][v]),要么其最后一条边是 (u, v),那么路径就是 s -> ... -> u(最多 i-1 条边)加上边 (u, v)

由于最短路径不可能包含正环(可删除)或负环(无解),因此最多包含 V-1 条边。这意味着我们只需要计算 i 从 0 到 V-1 的情况,最终的 dist[V-1][v] 就是真正的最短距离。

算法简化与最终形式

根据上述 DP 思路,我们可以写出一个二维 DP 算法。但观察发现,计算 dist[i][v] 时只依赖于 dist[i-1][*]。因此,我们可以将二维数组压缩成两个一维数组,进一步地,我们可以只用一个一维数组 dist[v],并在每一轮 i 中,用上一轮的结果松弛所有边。

这直接导出了 Bellman-Ford 算法的经典形式:

BellmanFord(G, s):
    for each vertex v in G:
        v.dist = INFINITY
        v.pred = NULL
    s.dist = 0

    for i = 1 to |V| - 1:
        for each edge (u, v) in G.edges:
            if u.dist + w(u, v) < v.dist: // 边 (u, v) 紧张
                v.dist = u.dist + w(u, v) // 松弛
                v.pred = u

    // 检查负权环:如果还能松弛,则存在负权环
    for each edge (u, v) in G.edges:
        if u.dist + w(u, v) < v.dist:
            report "Graph contains a negative-weight cycle"

外层循环 V-1:这对应于动态规划中考虑路径边数从 1 增加到 V-1。
内层循环遍历所有边:在每一轮中,尝试松弛所有边。
负环检测:完成 V-1 轮松弛后,如果还能找到紧张的边,则说明图中存在负权环。

时间复杂度与理解

  • 时间复杂度很明显是 O(V * E)
  • 算法可以理解为:第一轮循环后,dist[v] 存储的是从 s 出发,最多经过 1 条边到达 v 的最短距离。第二轮后,存储的是最多经过 2 条边的最短距离…… 第 V-1 轮后,存储的就是最多经过 V-1 条边的最短距离,也就是全局最短距离。

总结

本节课中我们一起学习了两种最重要的单源最短路径算法:

  1. Dijkstra 算法:适用于边权非负的图。采用优先队列实现最佳优先搜索,时间复杂度为 O(E log V)。其正确性依赖于非负权重的假设,确保每个顶点只需处理一次。
  2. Bellman-Ford 算法:适用于任意权重(可正可负)且无负权环的图。基于动态规划思想,通过进行 V-1 轮全局边的松弛来逐步逼近最短路径,时间复杂度为 O(V * E)。它还能用于检测图中是否存在负权环。

选择算法的经验法则:

  • 边权非负 → 优先使用 Dijkstra
  • 边权有负,或需要检测负环 → 使用 Bellman-Ford
  • 图是 DAG → 使用基于拓扑排序的线性时间算法。
  • 图无权 → 使用 BFS

理解这些算法背后的松弛策略和动态规划思想,比记忆代码更为重要。

021:Bellman-Ford算法回顾与全对最短路径

概述

在本节课中,我们将回顾Bellman-Ford算法,并探讨如何将其思想扩展到解决全对最短路径问题。我们将学习不同的动态规划思路,并最终介绍著名的Floyd-Warshall算法。


期中考试信息

期中考试二将于下周一(11月6日)晚上7点到9点举行。考试范围涵盖作业5到9,以及相关的实验和讲座内容,主要包括递归(分治、回溯、动态规划)和图论(遍历、连通性、拓扑排序、强连通分量、最短路径)两大主题。

复习会议将在周四晚上、周六下午和周一上午举行。网站上提供了两份模拟试卷,建议在无干扰环境下限时完成以检验准备情况。考试地点已重新分配,请务必在课程网站上确认自己的考场。

关于成绩复议,大部分请求已处理完毕。如果对作业成绩有异议但尚未收到回复,请在本周末前联系助教。


单源最短路径问题回顾

单源最短路径问题的输入是一个有向图 G = (V, E)、一个边权函数 w(权值可为任意实数)和一个源顶点 s。输出是为每个顶点 v 计算两个值:

  • v.dist:从 sv 的最短路径长度。
  • v.pred:该最短路径上 v 的前驱顶点。

这些前驱共同定义了从源点 s 出发的最短路径树。对于不可达的顶点,其距离为无穷大,前驱为空。

根据图的不同特性,我们有以下算法:

  • 无权图:广度优先搜索(BFS),时间复杂度为 O(V + E)
  • 有向无环图(DAG):基于拓扑排序的算法,时间复杂度为 O(V + E)
  • 非负权图:Dijkstra算法,使用二叉堆时时间复杂度为 O(E log V)
  • 通用图(允许负权):Bellman-Ford算法,时间复杂度为 O(VE)

以下是Bellman-Ford算法的核心代码框架:

def BellmanFord(G, w, s):
    for v in V:
        v.dist = INFINITY
        v.pred = None
    s.dist = 0

    for i in range(1, len(V)):
        for edge (u, v) in E:
            if v.dist > u.dist + w(u, v):  # 边(u,v)是“紧绷”的
                v.dist = u.dist + w(u, v)  # 松弛操作
                v.pred = u

该算法的一个关键性质是:在第 i 次主循环迭代后,对于所有顶点 vv.dist 的值至多是从 sv最多使用 i 条边的最短路径长度。由于简单路径最多包含 V-1 条边,因此算法进行 V-1 次迭代即可。


Bellman-Ford算法的另一种视角:构造分层DAG

我们可以通过构建一个分层DAG来重新表述Bellman-Ford算法,这揭示了其与动态规划的紧密联系。

具体构造如下:

  • 新建图 G' 的顶点集为 V' = V × {0, 1, 2, ..., V-1},即每个原始顶点创建 V 个副本,分布在不同的“层”。
  • 对于原图中的每条边 (u, v) ∈ E 和每一层 i0 ≤ i < V-1),在 G' 中添加一条从 (u, i)(v, i+1) 的有向边,其权值等于原边权 w(u, v)

这个新图 G' 是一个DAG,因为每条边都严格地从第 i 层指向第 i+1 层。原图中一条从 sv、长度为 L 的路径,对应 G' 中一条从 (s, 0)(v, L) 的路径,且路径权值相同。

因此,要计算原图中从 sv 的最短路径,我们可以在DAG G' 上以 (s, 0) 为源点运行DAG最短路径算法。由于 G'O(V²) 个顶点和 O(VE) 条边,该算法的时间复杂度也是 O(VE)。这种“构造DAG”的思路与动态规划中定义状态和转移是完全等价的。


全对最短路径问题

全对最短路径问题的目标是计算图中每一对顶点 (u, v) 之间的最短路径距离 dist(u, v)(以及可选的前驱信息)。

一个直接的解法是对每个顶点都作为源点运行一次单源最短路径算法。根据图的性质,会得到不同的总时间复杂度:

  • 无权图:运行 V 次BFS,总时间 O(VE)
  • DAG:运行 V 次DAG算法,总时间 O(VE)
  • 非负权图:运行 V 次Dijkstra,总时间 O(VE log V)
  • 通用图:运行 V 次Bellman-Ford,总时间 O(V²E)

当图比较稠密(边数 E 接近 )时,O(V²E) 接近 O(V⁴),效率不高。接下来,我们探索更优的动态规划解法。


基于路径边数限制的动态规划

第一种思路是定义状态 dist(u, v, L),表示从 uv 最多使用 L 条边的最短路径长度。其递推关系与Bellman-Ford类似:

  1. 基态:dist(u, v, 0) = 0(若 u == v),否则为无穷大。
  2. 递归:dist(u, v, L) = min( dist(u, v, L-1), min_{(x,v)∈E} { dist(u, x, L-1) + w(x, v) } )

将其转化为动态规划算法,需要三层循环:外层遍历 L(从1到 V-1),内层两层遍历所有顶点对 (u, v)。对于每个 (u, v),还需要遍历 v 的所有入边邻居 x。总时间复杂度为 O(V * V * E) = O(V²E),这与运行 V 次Bellman-Ford是一致的。


基于路径分裂的动态规划

我们可以改变分解路径的方式。考虑将路径从中间断开:dist(u, v, L) = min_{x ∈ V} { dist(u, x, L/2) + dist(x, v, L/2) }。这里 L 被限制为2的幂次。

由于 L 只需取 log V 个2的幂次值,算法的时间复杂度变为 O(log V * V * V * V) = O(V³ log V)。在稠密图上,这比 O(V⁴) 要好。


Floyd-Warshall算法

最著名的全对最短路径算法是Floyd-Warshall算法,它采用了另一种巧妙的动态规划状态定义。

定义 dist(u, v, r) 为从 uv 的、所有中间顶点编号不超过 r 的最短路径长度(顶点编号假设为1到 V)。注意,uv 本身不被视为中间顶点。

其递推关系基于路径是否经过顶点 r

  1. 不经过 r:则路径上的中间顶点编号最多为 r-1,即 dist(u, v, r-1)
  2. 经过 r:则路径可分解为从 ur 和从 rv 的两段,且这两段的中间顶点编号也最多为 r-1,即 dist(u, r, r-1) + dist(r, v, r-1)

因此,递推式为:
dist(u, v, r) = min( dist(u, v, r-1), dist(u, r, r-1) + dist(r, v, r-1) )

这个递推式的美妙之处在于,右侧没有引入新的求和或最小化变量(只有 u, v, r)。对应的动态规划算法只需三层简单的循环:

def FloydWarshall(G, w):
    # 初始化: dist[u][v][0]
    for u in V:
        for v in V:
            if u == v:
                dist[u][v] = 0
            elif (u, v) in E:
                dist[u][v] = w(u, v)
            else:
                dist[u][v] = INFINITY

    # 动态规划
    for r in V: # 中间顶点编号
        for u in V:
            for v in V:
                if dist[u][r] + dist[r][v] < dist[u][v]:
                    dist[u][v] = dist[u][r] + dist[r][v]

该算法的时间复杂度为 O(V³),空间复杂度通过复用数组可优化至 O(V²)。这是目前已知的解决通用图上全对最短路径问题的最优算法之一(在稠密图上)。


总结

本节课我们一起深入探讨了最短路径算法。

  • 我们首先回顾了Bellman-Ford算法的原理及其基于分层DAG的另一种解释,这加深了我们对动态规划思想的理解。
  • 接着,我们将问题扩展到全对最短路径,分析了朴素的多次运行单源算法的方案及其复杂度。
  • 最后,我们介绍了Floyd-Warshall算法,它通过巧妙地定义状态 dist(u, v, r)(允许使用的中间顶点编号),用简洁的三重循环实现了 O(V³) 时间复杂度的全对最短路径计算,这是图论中的一个经典算法。

022:期中考试二练习题讲解

在本节课中,我们将一起复习期中考试二的练习题。我们将逐一分析每道题目的核心要求,并学习如何构建解决方案。请注意,本课程并非常规讲座,而是针对周一考试的复习课。


算法与计算模型:1:简答题

上一节我们介绍了课程的整体安排,本节中我们来看看第一部分:简答题。这部分包含几个需要快速解答的小问题。

以下是三个需要求解的递归式,它们形式相似但系数不同:

  • A. T(n) = 3T(n/2) + n²
  • B. T(n) = 7T(n/2) + n²
  • C. T(n) = 4T(n/2) + n²

我们可以使用递归树法来分析。对于递归式 T(n) = aT(n/b) + f(n)

  • 若每层工作量构成一个递减的几何级数(即 a f(n/b) < f(n)),则总运行时间由根节点决定:Θ(f(n))
  • 若构成一个递增的几何级数(即 a f(n/b) > f(n)),则总运行时间由叶子节点决定:Θ(n^(log_b a))
  • 若每层工作量相同,则总运行时间为层数乘以每层工作量:Θ(f(n) * log n)

应用此方法:

  • A. a=3, b=2, f(n)=n²。因为 3*(n/2)² = (3/4)n² < n²,是递减级数,所以 T(n) = Θ(n²)
  • B. a=7, b=2, f(n)=n²。因为 7*(n/2)² = (7/4)n² > n²,是递增级数,所以 T(n) = Θ(n^(log_2 7))
  • C. a=4, b=2, f(n)=n²。因为 4*(n/2)² = n² = f(n),每层工作量相同,所以 T(n) = Θ(n² log n)

算法与计算模型:2:图的构造题

上一节我们解决了递归式问题,本节中我们来看看两个需要动手画图的问题。

以下是第一个构造要求:

  • 画一个有向无环图(DAG)。
  • 顶点数最多10个。
  • 恰好有1个源点(入度为0)和1个汇点(出度为0)。
  • 该图存在多于一种拓扑排序顺序。

构造思路是引入分支,使得拓扑排序时存在选择。例如:

S -> A -> T
S -> B -> T
A -> B

这个图有多个拓扑序,如 S, A, B, TS, B, A, T(注意A指向B,所以B必须在A之后?实际上此例中 S, A, B, T 是有效的,S, B, A, T 因为A->B边而无效。一个更简单的有效例子是 S -> A -> TS -> B -> T,且A与B之间没有边,则拓扑序有 S, A, B, TS, B, A, T)。

以下是第二个构造要求:

  • 画一个有向图(可以带环)。
  • 顶点数最多10个。
  • 边权为正且互不相同。
  • 存在从某个源点 S 到某个汇点 T 的多于一条最短路径。

构造思路是创建两条从 ST 的路径,其总长度相等。例如:

S -1-> A -2-> T
S -2-> B -1-> T

ST 有两条路径:S->A->T 总权为3,S->B->T 总权也为3。它们都是最短路径。


算法与计算模型:3:动态规划表格

上一节我们进行了图的构造,本节中我们来看一个动态规划备忘录设计问题。

给定一个无具体含义的递归式:
H(i, k) = max{ H(i-1, k-2), H(i+2, k-1), H(i+1, k-2), H(i+2, k-1) } + A[i][k]
(注:原问题中递归式可能更清晰,此处根据视频内容概括。关键是分析依赖关系。)

我们需要描述:

  1. 合适的备忘录结构:由于 ik 都是整数,且范围可限定,我们使用一个二维数组 dp[0..n][0..n] 来存储 H(i, k) 的结果。
  2. 评估顺序:我们需要确定填充 dp 数组的顺序,确保计算每个 dp[i][k] 时,它所依赖的其他状态(即递归式右侧的 H 值)都已经被计算出来。通过分析递归式,依赖项通常位于当前行的下方或左侧。因此,一个安全的填充顺序是:外层循环 i 从大到小遍历,内层循环 k 从小到大遍历。也可以采用按对角线填充的顺序。
  3. 运行时间:数组大小为 (n+1) x (n+1),每个状态的计算是常数时间,因此总运行时间为 Θ(n²)

算法与计算模型:4:Elmo的卡牌游戏 🃏

上一节我们设计了动态规划表格,本节我们来分析一个有趣的游戏策略问题。

你和你的表弟Elmo玩一个卡牌游戏。一排 N 张卡牌,点数公开。你们轮流从这排卡牌的最左端最右端取走一张牌,直到取完。每张牌的点数可正可负。Elmo总是采取贪心策略:轮到他时,他总取当前左右两端点数较大的那张牌。你的目标是最大化自己获得的总点数。

第一部分:证明贪心策略并非最优

你需要构造一个例子,证明如果你也使用和Elmo一样的贪心策略,你无法赢得最大可能点数,而采用其他策略则可以。

构造思路:设置一个“陷阱”。例如,卡牌序列为 [10, 100, 5, 2]。Elmo先手。

  • 如果双方都贪心
    1. Elmo看到10和2,取10(左)。
    2. 你看到100和2,取100(左)。
    3. Elmo看到5和2,取5(左)。
    4. 你取最后一张2。
      你的总点数为 100 + 2 = 102
  • 如果你采用最优策略(第一轮不贪心)
    1. Elmo先手,依然取10(左)。
    2. 轮到你,现在序列是 [100, 5, 2]。贪心策略会取100。但最优策略是取2(右)!
    3. 接下来Elmo面对 [100, 5],他取100(左)。
    4. 你取最后一张5。
      你的总点数为 2 + 5 = 7
      这个例子中贪心反而更好?我们需要调整数字。一个经典的例子是 [1, 100, 5, 2],Elmo先手。关键在于,你要牺牲一个小点数,迫使Elmo在下一轮将一个中等点数的牌暴露在端点,而你能拿到后面的大点数牌。详细构造需要仔细计算,但核心思想是:存在某些局面,主动选择较小的牌,能在后续回合中获得更大的总收益

第二部分:设计算法计算最大得分

我们需要一个算法,给定初始卡牌序列 A[1..n],计算你所能获得的最大点数。

定义状态:这是一个双方交替、且对手策略固定的游戏。我们可以用动态规划来模拟所有可能的游戏进程。定义 dp[i][j] 为:当剩余卡牌是子数组 A[i..j] 时,且接下来轮到Elmo行动,在此后游戏中你所能获得的最大点数。

状态转移:考虑 dp[i][j]

  1. Elmo行动。他比较 A[i]A[j]
    • 如果 A[i] > A[j],Elmo取走 A[i]。剩余卡牌为 A[i+1..j],接下来轮到你行动。
    • 如果 A[i] <= A[j],Elmo取走 A[j]。剩余卡牌为 A[i..j-1],接下来轮到你行动。
  2. 轮到你行动。在剩余卡牌中,你可以选择左端或右端,目标是最大化你的点数:
    • 如果上一步Elmo取了左端(A[i]),那么现在牌堆是 A[i+1..j],你选择取 A[i+1]A[j],然后加上后续游戏中的最优得分。
    • 如果上一步Elmo取了右端(A[j]),那么现在牌堆是 A[i..j-1],你选择取 A[i]A[j-1],然后加上后续游戏中的最优得分。

因此,转移方程需要根据Elmo的选择分情况讨论,并在你的选择中取最大值。这会导致一个稍微复杂但清晰的递归关系。最终,dp[1][n] 就是答案。

初始化:当 i > j 时,没有牌,得分为0。

计算顺序与复杂度:状态 dp[i][j] 依赖于 dp[i+1][j]dp[i][j-1] 等更小区间的状态。因此,我们可以按区间长度从小到大的顺序填充二维 dp 表。总共有 O(n²) 个状态,每个状态计算需要常数时间,故总时间复杂度为 O(n²)


算法与计算模型:5:图中三色顶点的“偏远度” 🎨

上一节我们分析了一个游戏策略问题,本节我们来看一个图算法问题。

给定一个有向图 G,每个顶点被染成绿中的一种。边无权。顶点 v偏远度定义为以下三个最短路径长度的最大值

  1. v 到离它最近的红色顶点的最短路径长度。
  2. v 到离它最近的蓝色顶点的最短路径长度。
  3. v 到离它最近的绿色顶点的最短路径长度。
    如果对于某种颜色,v 无法到达任何该颜色的顶点,则该项为无穷大。目标是找到图中偏远度最小的顶点。

算法思路
问题的核心是为每个顶点 v 快速计算它到最近的红、蓝、绿色顶点的距离。一个朴素的方法是:分别从所有红色顶点运行BFS,对每个 v 取最小距离;对蓝、绿色顶点做同样操作。但这需要 O(|V| * (|V|+|E|)) 时间。

优化方法
我们可以通过增加超级源点来批量计算。

  1. 创建三个新顶点:source_red, source_blue, source_green
  2. source_red 向图中每个红色顶点添加一条有向边。
  3. source_bluesource_green 做类似操作。
  4. 在修改后的图 G' 中,分别从 source_red, source_blue, source_green 运行一次广度优先搜索(BFS)
    • source_red 开始的BFS,计算出的 dist[source_red][v] 其实就是 v 到某个红色顶点的最短路径长度加1(因为多了一条从超级源点到红色顶点的边)。
    • 因此,v 到最近红色顶点的真实距离为 dist[source_red][v] - 1。如果 v 本身就是红色,则距离为0,这与 dist[source_red][v]=1 然后减1的结果一致。
  5. 对每个顶点 v,计算其三个距离(分别减1后),取最大值,即为它的偏远度。
  6. 最后,扫描所有顶点,找出偏远度最小的那个。

复杂度分析:修改图需要 O(|V|) 时间。进行三次BFS,每次 O(|V|+|E|)。最后扫描顶点需要 O(|V|)。总时间复杂度为 O(|V|+|E|),非常高效。


算法与计算模型:6:寻找唯一奇偶性异常索引 🔍

上一节我们解决了图上的偏远度问题,本节我们处理一个数组搜索问题。

给定一个整数数组 A[1..n]。除了唯一的一个位置 i 外,数组中任意两个相邻整数的和都是偶数。也就是说,除了一个地方,整个数组的奇偶性(偶数/奇数)是交替出现的。这个异常位置 i 满足 A[i]A[i+1] 具有相同的奇偶性(即它们的和是偶数)。目标是找到这个唯一的索引 i

要求:设计一个比线性扫描更高效的算法。

算法思路:线性扫描需要 O(n) 时间。由于数组具有极强的规律性(仅一处异常),我们可以利用二分查找的思想在 O(log n) 时间内找到它。

关键观察:在正常的交替奇偶性序列中,下标 index 和其值 A[index] 的奇偶性之间存在固定关系。如果我们假设第一个元素是偶数(如果不是,可以将所有数字的奇偶性翻转,问题等价),那么:

  • 在正常部分,偶数索引对应偶数,奇数索引对应奇数。因此,index + A[index] 总是偶数
  • 在异常位置之后,由于奇偶性模式整体错位,index + A[index] 将总是奇数

因此,唯一的异常点 i 就是 index + A[index] 的奇偶性发生改变的位置

二分查找算法

  1. low = 1, high = n
  2. while (high - low > 1):
    • mid = (low + high) / 2
    • 检查 (mid + A[mid]) % 2 是否等于 (low + A[low]) % 2
      • 如果相等,说明从 lowmid 的奇偶性模式没有改变,异常点在右侧。令 low = mid
      • 如果不相等,说明异常点在 lowmid 之间(包含 mid)。令 high = mid
  3. 循环结束时,high = low + 1。异常位置就是 low(因为 A[low]A[high] 奇偶性相同)。

复杂度分析:每次迭代将搜索范围减半,共 O(log n) 次迭代,每次常数时间操作。总时间复杂度为 O(log n)


算法与计算模型:7:有向图中的最短“之字形”路径 ⚡

上一节我们通过二分查找解决了数组问题,本节我们探讨最后一个关于特殊路径的图论问题。

给定一个有向图,边权非负。定义一条从 st之字形路径为顶点序列,其中路径上的边方向交替变化:第一条边方向向前(从当前顶点指向下一个),第二条边方向向后(从下一个顶点指回前一个,或更准确地说,是沿着边的方向交替“顺向”和“逆向”使用边),依此类推。路径长度是边上权重的总和。目标是找到从 st最短之字形路径

算法思路
这是一个边权非负的最短路径问题,但带有额外的“方向交替”约束。我们可以通过状态扩展将原图转化为一个可以应用标准最短路径算法(如Dijkstra)的新图。

状态定义:为每个原图顶点 v 创建两个状态:

  • (v, forward):表示到达 v,且下一步需要沿正向边离开。
  • (v, backward):表示到达 v,且下一步需要沿反向边离开(即从 v 沿着一条原图中指向 v 的边,逆向走到其前驱)。

构建新图 G'

  • 节点:所有 (v, dir),其中 v 是原图顶点,dir ∈ {forward, backward}
  • 边:
    • 对于状态 (u, forward):查看原图中所有从 u 出发的正向边 u -> w,则在 G' 中添加一条从 (u, forward)(w, backward) 的边,权重与原边相同。这表示我们走了一条正向边,接下来一步必须走反向边。
    • 对于状态 (u, backward):查看原图中所有指向 u 的边 v -> u,则在 G' 中添加一条从 (u, backward)(v, forward) 的边,权重与原边 v->u 相同。这表示我们“逆着”走了一条边,接下来一步必须走正向边。

问题转化:在原图中寻找从 st 的最短之字形路径,等价于在 G' 中寻找从任意初始状态(s, forward)(s, backward),取决于路径希望的第一步方向)到任意结束状态(t, forward)(t, backward))的最短路径。因为之字形路径对第一步和最后一步的方向没有硬性要求,我们需要考虑四种可能的起止状态组合。

解决方案

  1. 构建新图 G',其节点数和边数均为原图的 O(|V|+|E|)
  2. G' 上,以 (s, forward)(s, backward) 为源点,分别运行Dijkstra算法(因为边权非负)。
  3. 检查从这两个源点到 (t, forward)(t, backward) 的最短距离。
  4. 取这四种距离中的最小值,即为最短之字形路径的长度。路径本身可以通过回溯得到。

复杂度分析:构建 G' 需要 O(|V|+|E|) 时间。在 G' 上运行Dijkstra算法,G' 的规模是原图的常数倍,因此时间复杂度与在原图上运行Dijkstra相同,即 O((|V|+|E|) log |V|)(使用优先队列)。


本节课中我们一起学习了期中考试二可能涉及的各类问题,包括递归式求解、图构造、动态规划设计、游戏策略分析、图上的BFS应用、二分查找变形以及通过状态扩展解决约束最短路径问题。希望这次复习能帮助大家更好地准备考试。祝大家好运!

023:第二次期中考试模拟题讲解

在本节课中,我们将一起学习并解答UIUC CSECE 374课程第二次期中考试的模拟题。我们将逐一分析五道题目,涵盖图论、动态规划、图算法和分治算法等核心概念,并提供详细的解题思路和步骤。


问题一:图结构识别

上一节我们介绍了本次模拟题的整体结构,本节中我们来看看第一个问题。这是一个关于有向图的短答题,要求我们在给定的图中识别出特定的结构。

我们需要在图中清晰地标出以下结构,如果不存在则写“none”:

  • 以顶点A为根的深度优先搜索树。
  • 以顶点C为根的广度优先搜索树。
  • 图的强连通分量(圈出每一个)。
  • 图的强连通分量图(单独绘制)。

以下是解题步骤:

  1. 深度优先搜索树:从顶点A开始,使用栈进行深度优先搜索。访问顺序为:A -> D -> B -> F -> E。树边为:A-D, D-B, B-F, F-E。
  2. 广度优先搜索树:从顶点C开始,使用队列进行广度优先搜索。访问顺序为:C -> A, F, I -> D, E, G, H -> B。树边为:C-A, C-F, C-I, A-D, F-E, I-G, I-H, D-B。
  3. 强连通分量:识别出三个强连通分量:
    • 分量1:顶点C(孤立顶点)。
    • 分量2:顶点A, B, D, E, F(它们通过有向环相互可达)。
    • 分量3:顶点G, H, I(它们构成一个有向环)。
  4. 强连通分量图:创建一个新图,其顶点是上述三个分量。根据原图的边,添加有向边:分量1 -> 分量2,分量1 -> 分量3,分量3 -> 分量2。

问题二:平方深度动态规划

上一节我们解决了图结构识别问题,本节中我们来看看一个动态规划问题。给定一个n位整数X,我们可以反复从数字的任一端移除一位数字,直到没有数字剩余。X的平方深度是在此过程中能看到的完全平方数的最大数量。

核心思路:这是一个典型的区间动态规划问题。我们定义子问题为计算子数组 X[i..j] 的平方深度。

状态定义
dp[i][j] 表示子数字串 X[i..j] 的平方深度(即最大完美平方数数量)。

状态转移方程

  • 如果子串 X[i..j] 本身是一个完全平方数,则至少能获得1个平方数。
  • 接下来,我们可以选择移除最左边的数字(i)或最右边的数字(j),并取两种选择中的最大值。
  • 因此,递推公式为:
    dp[i][j] = isSquare(X[i..j]) + max(dp[i+1][j], dp[i][j-1])
    其中 isSquare 是一个黑盒子函数,能在 O(k^2) 时间内判断一个k位数是否为完全平方数。

边界条件
i > j 时,dp[i][j] = 0(空字符串没有平方数)。

算法实现与复杂度

  • 我们需要填充一个 n x n 的二维DP表。
  • 填充顺序:外层循环 in-1 递减到 0,内层循环 ji 递增到 n-1,以确保子问题先被计算。
  • 每个状态 dp[i][j] 需要调用一次 isSquare 函数,其时间复杂度为 O((j-i+1)^2)
  • 总时间复杂度为 O(n^4)。虽然存在使用更快的乘法算法(如Karatsuba)优化 isSquare 的可能性,但在考试环境下,给出正确的 O(n^4) DP解法即可获得满分。

问题三:彩虹路径可达性

上一节我们分析了动态规划问题,本节中我们来看一个涉及图变换的问题。给定一个每条边都被染成红、绿或蓝中一种颜色的有向图G。一条彩虹路径是指其中不包含两条连续同色边的路径。我们需要找出从给定起点s出发,能通过彩虹路径到达的所有顶点。

核心思路:直接修改BFS/DFS算法来跟踪颜色比较困难且容易出错。更稳健的方法是构建一个新图,将“当前顶点”和“到达该顶点所用边的颜色”编码为新图的顶点状态。

图变换构建

  1. 创建新图 G‘
  2. G‘ 的顶点集为:V‘ = V × {R, G, B}。即,对于原图每个顶点v,在G‘中有三个副本:(v, R), (v, G), (v, B)。状态 (v, C) 表示“位于顶点v,且是通过一条颜色为C的边到达的”。
  3. G‘ 的边集构建:对于原图 G 中的每条边 u->v,假设其颜色为 C,则在 G‘ 中,从所有 颜色不是 Cu 的副本,向 (v, C) 添加一条有向边。这确保了路径中不会出现连续同色边。
    • 例如,对于一条红色边 u->v,在 G‘ 中添加边:(u, G) -> (v, R)(u, B) -> (v, R)

算法步骤

  1. 在变换后的图 G‘ 上,分别从 (s, R), (s, G), (s, B) 三个顶点开始,运行广度优先搜索(BFS)或深度优先搜索(DFS)。因为起点s没有前驱边,所以三种颜色状态都需要作为起点尝试。
  2. 搜索结束后,所有被标记访问过的 G‘ 顶点 (v, C),其对应的原图顶点 v 就是从 s 通过彩虹路径可达的顶点。

复杂度分析

  • G‘ 的顶点数是 3|V|,边数最多是 2|E|(每条原图边对应至多两条新边)。
  • 因此,在新图上运行BFS/DFS的时间复杂度为 O(|V| + |E|),即与原图规模呈线性关系。

问题四:合并K个有序数组

上一节我们通过图变换解决了彩虹路径问题,本节中我们来看一个经典的分治问题。给定K个长度均为N的有序数组,需要将它们合并成一个有序数组。

核心思路:此问题可以看作是归并排序(Merge Sort)中“合并”步骤的扩展。我们可以采用分治策略。

算法一(自底向上归并)

  1. 将K个数组两两配对,合并每对数组,得到约 K/2 个长度为 2N 的有序数组。
  2. 递归地将这 K/2 个数组继续两两合并。
  3. 重复此过程,直到只剩下一个有序数组。

算法二(自顶向下分治)

  1. 递归地将前 K/2 个数组合并成一个数组 B
  2. 递归地将后 K/2 个数组合并成一个数组 C
  3. 合并两个有序数组 BC,得到最终结果。

时间复杂度分析
两种算法本质相同。使用递归树法分析:

  • 递归树共有 O(log K) 层。
  • 每一层合并所有数组元素的总工作量都是 O(N * K)(因为每一层都需要处理所有 N*K 个元素)。
  • 因此,总时间复杂度为 O(N * K * log K)

对比朴素方法:如果简单地将数组逐个合并(先合并前两个,再将结果与第三个合并,以此类推),时间复杂度会达到 O(N * K^2)。分治法显著更优。


问题五:受限条件下的最高海拔路径

上一节我们使用分治法合并了有序数组,本节中我们来看最后一个图论问题。给定一个无向图G,每个顶点有海拔,每条边有正长度。给定起点s、终点t和最大路径长度L。需要找到一条从st、总长度不超过L的路径,使得该路径经过的最高海拔尽可能大。

核心洞察:目标不是找最短路径,而是找一条在长度限制内能到达最高点的路径。关键观察是:对于任何顶点v,如果存在一条从st且经过v、总长 ≤ L 的路径,那么用从sv的最短路径替换原路径的s->v部分,用从vt的最短路径替换原路径的v->t部分,得到的新路径总长只会更短(仍 ≤ L)。因此,v是“可到达的”当且仅当:
dist(s, v) + dist(v, t) ≤ L

算法步骤

  1. 使用Dijkstra算法,计算从起点s到图中所有其他顶点v的最短距离 dist(s, v)。因为边权为正,Dijkstra适用。
  2. 使用Dijkstra算法,计算从终点t到图中所有其他顶点v的最短距离 dist(t, v)。由于是无向图,dist(v, t) = dist(t, v)
  3. 遍历所有顶点v,检查条件 dist(s, v) + dist(t, v) ≤ L。在所有满足条件的顶点中,找出海拔最高的那个。其海拔值即为答案。

复杂度分析

  • 运行两次Dijkstra算法,时间复杂度为 O((|E| + |V|) log |V|)
  • 遍历所有顶点检查条件,时间复杂度为 O(|V|)
  • 总时间复杂度由Dijkstra算法主导,为 O((|E| + |V|) log |V|)

总结

本节课中我们一起学习了第二次期中考试的五道模拟题:

  1. 图结构识别:练习了DFS树、BFS树、强连通分量及其缩图的识别与绘制。
  2. 平方深度动态规划:定义 dp[i][j] 状态,利用 isSquare 子程序和选择移除左端或右端的决策,解决了区间DP问题。
  3. 彩虹路径可达性:通过构建状态图 (顶点, 颜色) 将原问题转化为新图上的标准可达性问题,并用BFS/DFS解决。
  4. 合并K个有序数组:运用分治思想,模仿归并排序的过程,在 O(NK log K) 时间内完成了合并。
  5. 受限条件下的最高海拔路径:巧妙地将问题转化为计算最短路径,利用 dist(s, v) + dist(t, v) ≤ L 的条件筛选顶点,并求最大海拔。

希望本次讲解能帮助你巩固相关算法知识,为考试做好准备。

024:多项式时间归约

在本节课中,我们将学习如何通过“归约”来证明计算问题的难度。我们将看到,如果一个问题可以快速解决,那么另一个问题也可以快速解决。我们将通过几个图论问题的例子来理解归约的概念和具体操作。


概述

到目前为止,我们主要关注如何设计高效的算法来解决问题。现在,我们将转向问题的“困难”部分。我们将探讨一类问题,对于这些问题,我们目前只知道指数时间的“暴力”解法。为了理解这些问题的内在难度,我们将学习“归约”技术。归约的核心思想是:如果我们能快速解决问题X,那么通过某种转换,我们也能快速解决问题Y。本节课将通过独立集、团、顶点覆盖以及哈密顿环等具体问题来演示归约。


从简单问题到困难问题

上一节我们介绍了算法设计中“简单”的部分。本节中,我们来看看那些被认为是“困难”的问题。在20世纪50年代,苏联数学家们开始研究所谓的“蛮力”问题,即那些最显而易见的算法也需要指数级运行时间的问题。

一个典型的例子是旅行商问题:在一个带权图中,寻找访问每个顶点恰好一次的最短环。长期以来,解决此问题的唯一已知方法是尝试所有顶点的排列,即n!种可能性,这是一个指数级的时间复杂度。

另一个例子是布尔可满足性问题:给定一个布尔公式,判断是否存在一组变量赋值使得公式为真。最直接的解法是尝试所有2^n种可能的变量赋值组合。

对于这些问题,尽管存在针对特定实际输入的启发式算法,但在最坏情况下,我们目前不知道是否存在多项式时间的算法。接下来,我们将形式化这种“困难性”。


归约的基本概念

归约是一种技术,用于证明如果一个问题可以快速解决,那么另一个问题也可以快速解决。其核心是设计一个算法,该算法将问题Y的实例转化为问题X的实例,然后利用一个能快速解决问题X的“黑盒子”子程序来解决问题Y。

以下是归约算法的一般结构:

  1. 将问题Y的输入实例转化为问题X的输入实例。
  2. 将转化后的实例输入给能解决问题X的黑盒子算法。
  3. 将黑盒子的输出转化回问题Y的解。

我们将通过几个图论问题来具体说明。


图问题之间的归约

我们将研究三个密切相关的问题:最大团、最大独立集和最小顶点覆盖。对于这些问题,已知的最佳算法运行时间都是指数级的。

最大团与最大独立集

首先,我们展示如何将最大团问题归约到最大独立集问题。

定义

  • 最大团:在图中寻找最大的顶点子集,使得该子集中任意两点之间都有边相连。
  • 最大独立集:在图中寻找最大的顶点子集,使得该子集中任意两点之间都没有边相连。

归约算法
给定一个图G,我们希望找到其最大团的大小。我们构造一个新图G‘,其顶点集与G相同,但边集是G的补集。也就是说,在G’中,两个顶点之间有边当且仅当在G中它们之间没有边。

公式描述

  • 设原图 G = (V, E)
  • 构造新图 G‘ = (V’, E‘),其中 V’ = V,E‘ =

然后,我们将G‘输入给能解决最大独立集问题的黑盒子。黑盒子返回G’中最大独立集的大小k。我们直接输出k作为G中最大团的大小。

正确性证明
需要证明:图G中的一个顶点子集A是一个团,当且仅当A是图G‘中的一个独立集。

  • 如果A是G中的一个团,则A中任意两点在G中都有边相连。根据G‘的定义,这些点在G’中都没有边相连,因此A是G‘中的一个独立集。
  • 如果A是G’中的一个独立集,则A中任意两点在G‘中都没有边相连。根据G’的定义,这些点在G中都有边相连,因此A是G中的一个团。

因此,G‘中最大独立集的大小就等于G中最大团的大小。如果最大独立集问题能在多项式时间内解决,那么通过这个归约,最大团问题也能在多项式时间内解决。


最大独立集与最小顶点覆盖

接下来,我们展示最大独立集问题与最小顶点覆盖问题之间的归约。

定义

  • 最小顶点覆盖:在图中寻找最小的顶点子集,使得图中的每条边都至少有一个端点在该子集中。

归约算法(从最大独立集到最小顶点覆盖)
给定一个图G,我们希望找到其最大独立集的大小。我们直接将图G输入给能解决最小顶点覆盖问题的黑盒子。黑盒子返回G中最小顶点覆盖的大小k。设图G的顶点总数为n,我们输出 n - k 作为G中最大独立集的大小。

正确性证明
需要证明:图G中的一个顶点子集S是一个独立集,当且仅当它的补集 V \ S 是一个顶点覆盖。

  • 如果S是一个独立集,那么S中任意两点之间没有边。这意味着图中的每一条边,都至少有一个端点不在S中(即在V \ S中)。因此,V \ S是一个顶点覆盖。
  • 如果C是一个顶点覆盖,那么每条边都至少有一个端点在C中。这意味着没有任何一条边的两个端点都在V \ C中。因此,V \ C是一个独立集。

因此,最大独立集的大小 = n - 最小顶点覆盖的大小。这个归约同样可以在相反方向进行。


哈密顿环问题的归约

哈密顿环问题是另一个经典的难题:给定一个图,判断是否存在一个经过每个顶点恰好一次并回到起点的环。

从无向图到有向图的归约

首先,我们展示如何将无向图的哈密顿环问题归约到有向图的哈密顿环问题。

归约算法
给定一个无向图G,我们构造一个有向图G‘。G’的顶点集与G相同。对于G中的每一条无向边(u, v),我们在G‘中添加两条方向相反的有向边:u → v 和 v → u。

正确性证明

  • 如果G有一个哈密顿环,那么按照环的顺序遍历顶点,在G‘中总可以选择对应方向的有向边来构造一个有向哈密顿环。
  • 如果G‘有一个有向哈密顿环,忽略所有边的方向,就得到了G中的一个无向哈密顿环。

因此,G有哈密顿环当且仅当G‘有哈密顿环。如果能在多项式时间内解决有向图哈密顿环问题,那么无向图版本也能解决。


从有向图到无向图的归约

现在,我们展示更复杂的反向归约:如何将有向图的哈密顿环问题归约到无向图的哈密顿环问题。

归约算法
给定一个有向图G,我们构造一个无向图G‘。对于G中的每一个顶点v,我们在G’中创建三个顶点:v_in, v_mid, v_out。我们在它们之间添加两条边,形成一个长度为2的路径:v_in — v_mid — v_out。
对于G中的每一条有向边 u → v,我们在G‘中添加一条无向边:u_out — v_in。

正确性证明思路

  • 正向(G有环 ⇒ G‘有环):如果G有一个有向哈密顿环 v1 → v2 → … → vn → v1,那么在G’中,我们可以构造环:v1_in — v1_mid — v1_out — v2_in — v2_mid — v2_out — … — vn_in — vn_mid — vn_out — v1_in。这个环访问了G‘中的每一个顶点。
  • 反向(G‘有环 ⇒ G有环):关键在于证明G’中的任何哈密顿环都必须以特定的“模式”遍历顶点。由于v_mid顶点只与v_in和v_out相连,任何哈密顿环访问v_mid时,必然紧接着访问v_in和v_out(或相反顺序)。进一步分析表明,环在离开一个v_out后,必须进入某个u_in,然后遍历u_in—u_mid—u_out。这种模式强制对应回G中的一个有向哈密顿环。

这个构造确保了归约的正确性。如果无向图哈密顿环问题能在多项式时间内解决,那么有向图版本也能解决。


总结

本节课中,我们一起学习了多项式时间归约的核心思想。归约是证明问题计算难度的强大工具。我们通过具体例子看到:

  1. 最大团、最大独立集和最小顶点覆盖这三个问题可以通过简单的图变换或取补集操作相互归约。
  2. 哈密顿环问题在无向图和有向图版本之间也可以相互归约,其中从有向图到无向图的归约需要更精巧的“构件”设计。

这些归约表明,如果其中任何一个问题存在多项式时间算法,那么其他所有问题也都将迎刃而解。这为理解NP完全性理论奠定了基础,我们将在后续课程中深入探讨。

025:P、NP、NP-难、SAT、3SAT、规约到最大独立集

在本节课中,我们将学习计算复杂性理论中的核心概念:P类、NP类、NP-难问题,以及如何通过规约证明一个问题是NP-难的。我们将以布尔可满足性问题(SAT)及其特例3SAT为例,并展示如何将3SAT规约到最大独立集问题。


课程概述

本节课我们将探讨计算复杂性理论的基础。我们将定义P类和NP类问题,理解它们之间的区别与联系。我们将介绍布尔可满足性问题(SAT)及其特例3SAT,并学习如何通过多项式时间规约,证明最大独立集问题是NP-难的。理解这些概念是学习算法理论中更高级主题的关键。


P类与NP类

上一节我们回顾了考试相关问题,本节中我们来看看计算复杂性理论的核心分类。

P类 是指所有能在多项式时间内被确定性图灵机解决的问题。简单来说,存在一个算法,其运行时间与输入规模的多项式成正比,可以准确解决问题。例如,判断一个电路是否能点亮灯泡(给定具体输入设置后),只需按拓扑顺序遍历电路即可在多项式时间内完成。

NP类 是指所有能在多项式时间内被非确定性图灵机解决的问题。更直观的理解是:如果问题的答案是“是”,那么存在一个简短的“证明”或“证书”,我们可以在多项式时间内验证这个证明的正确性。例如,对于布尔电路可满足性问题(SAT),如果答案是“是”,那么一个令灯泡点亮的输入设置就是一个证书,我们可以快速验证它。

非确定性可以理解为“幸运猜测”或“并行宇宙分支”。当算法面临选择时(例如,猜测一个变量的值),它会同时探索所有可能性。只要有一个分支最终成功,我们就认为问题可解。

P与NP的关系 是理论计算机科学的核心开放问题:P是否等于NP?即,所有容易验证解的问题,是否也都能容易地找到解?多数研究者认为P ≠ NP,但这尚未被证明。


布尔电路与SAT问题

上一节我们介绍了P和NP的定义,本节中我们来看看一个具体的NP问题:布尔可满足性问题。

一个布尔电路 由逻辑门(与门、或门、非门)和导线组成,计算布尔函数。输入是开关(代表布尔变量),输出是一个灯泡(代表布尔值)。电路可以表示为一个有向无环图。

电路可满足性问题(Circuit SAT) 是指:给定一个布尔电路的描述,是否存在一组输入设置(即各开关的开/关),使得输出灯泡点亮(即电路输出为真)。

  • 验证一个给定的输入设置是否能点亮灯泡是容易的(P问题):只需按拓扑顺序计算即可。
  • 但是,从头开始寻找这样一个设置(如果存在的话)被认为是困难的。最朴素的方法是尝试所有2^n种可能的输入组合。

3SAT问题

上一节我们讨论了一般的电路SAT问题,本节中我们来看看一个更标准、更常用的形式:3SAT。

3SAT 是布尔可满足性问题的一个特例。其输入是一个布尔公式,该公式必须是合取范式(CNF),且每个子句恰好包含3个文字

  • 文字:一个布尔变量或其否定(例如 x¬x)。
  • 子句:多个文字的析取(逻辑或)。在3SAT中,每个子句恰好包含三个文字(例如 (x ∨ ¬y ∨ z))。
  • 公式:多个子句的合取(逻辑与)。例如:
    (A ∨ B ∨ C) ∧ (¬B ∨ D ∨ ¬E) ∧ (¬A ∨ C ∨ F)

3SAT问题 是:给定这样一个3CNF公式,是否存在对各个变量的真值赋值,使得整个公式为真?即使得每一个子句中至少有一个文字为真。

重要说明:3SAT本身是一个NP-完全问题(我们将在后续课程中精确定义)。这意味着它是NP类中“最难”的问题之一。目前没有已知的多项式时间算法来解决它,并且如果任何一个NP-完全问题存在多项式时间算法,则P=NP。


NP-难与规约

上一节我们定义了3SAT问题,本节中我们来看看如何利用“规约”来证明其他问题的难度。

规约 是将一个问题(A)转化为另一个问题(B)的过程,使得如果我们可以解决B,那么我们也就能解决A。在复杂性理论中,我们使用多项式时间规约:转化过程本身必须在多项式时间内完成。

如果我们可以将一个已知的NP-难问题(如3SAT)多项式时间规约到问题X,那么我们就证明了X至少和3SAT一样难。因此,X也是NP-难的。这意味着,如果X存在多项式时间算法,那么3SAT(以及所有NP问题)也将存在多项式时间算法,即P=NP。由于我们相信P≠NP,因此X不可能存在多项式时间算法。

这是一种反证法思路:假设X有高效算法 → 通过规约,3SAT也有高效算法 → 这与“3SAT是难的”共识矛盾 → 故假设不成立,X没有高效算法。


从3SAT规约到最大独立集

上一节我们介绍了规约的概念,本节中我们来看一个具体的规约例子:将3SAT问题规约到最大独立集问题

最大独立集问题:给定一个无向图,找出一个最大的顶点集合,使得集合中任意两个顶点之间都没有边直接相连。这个集合的大小称为图的最大独立集大小。

以下是规约的步骤概要:

  1. 输入:一个3CNF公式 φ,假设它有 k 个子句。
  2. 构造图 G
    • 对于公式中的每一个子句,创建一个由3个顶点组成的“三角形子句构件”。这3个顶点分别标记为该子句中的3个文字。
    • 对于图中任意两个标记为互补文字(如 x¬x)的顶点,在它们之间添加一条边。这些边构成了“变量一致性构件”。
  3. 输出与判断:构造图G后,询问图G的最大独立集大小是否等于子句数量 k
    • 如果等于 k,则原始公式 φ 是可满足的。
    • 如果小于 k,则原始公式 φ 是不可满足的。

规约正确性思路

  • 如果φ可满足:取一个满足赋值。在每个子句中,至少有一个文字为真。从每个子句构件中,选取一个对应真文字的顶点。这些顶点来自不同的三角形,彼此间无边(三角形内顶点相连,但每个三角形只选一个)。同时,由于赋值一致,不会同时选取 x¬x,因此由红边连接的顶点也不会同时被选。这样我们就得到了一个大小为 k 的独立集。
  • 如果G有一个大小为k的独立集:由于每个三角形子句构件中至多能选一个顶点(否则它们之间有边),大小为k意味着每个三角形恰好贡献了一个顶点。我们可以将这些顶点对应的文字设为真,并据此给变量赋值(如果出现矛盾,由于红边的存在,矛盾的文字不会同时被选中)。这样每个子句都至少有一个文字为真,因此φ可满足。

这个构造过程显然是多项式时间的(遍历公式即可)。因此,如果我们能在多项式时间内解决最大独立集问题,我们就能在多项式时间内解决3SAT问题。由于3SAT是NP-难的,这证明了最大独立集问题也是NP-难的


课程总结

本节课我们一起学习了计算复杂性理论的核心概念。

  • 我们定义了P类(可在多项式时间内确定性地解决的问题)和NP类(可在多项式时间内验证“是”答案的问题)。
  • 我们介绍了布尔可满足性问题(SAT) 及其特例3SAT,这是经典的NP-完全问题。
  • 我们学习了规约的概念,它是证明问题计算难度的关键工具。
  • 最后,我们通过一个具体的例子,展示了如何将3SAT问题多项式时间规约到最大独立集问题,从而证明最大独立集是NP-难的。

理解这些基础是探索更广阔的计算复杂性世界和算法设计极限的第一步。

026:NP困难性进阶,从顶点覆盖到哈密顿环的规约

在本节课中,我们将要学习NP困难性证明的更多细节,特别是如何从一个已知的NP困难问题(顶点覆盖)规约到另一个问题(哈密顿环)。我们将详细探讨规约的构造过程、直觉以及证明思路。


概述

上一节我们介绍了NP完全问题的基本概念和库克-列文定理。本节中我们来看看如何利用已知的NP困难问题,通过构造性的规约,来证明其他问题的NP困难性。我们将以从顶点覆盖问题到有向哈密顿环问题的规约为例,展示这一过程。

NP困难性定义回顾

一个问题X是NP困难的,当且仅当在多项式时间内解决X意味着P = NP。从实践角度,这通常意味着存在一个从某个已知的NP完全问题(如3-SAT)到X的多项式时间规约。

公式:问题 X 是 NP-hard ⇔ 3-SAT ≤_p X

库克-列文定理表明,任何可以在多项式时间内验证证明的问题,都可以规约到3-SAT。因此,如果我们想证明一个新问题X是NP困难的,一种方法是展示一个从3-SAT到X的规约。然而,我们也可以利用已经证明是NP困难的问题作为起点。

规约的传递性

我们已经看到了一些问题的规约链。例如,我们证明了从3-SAT到最大独立集问题的规约,以及从最大独立集到顶点覆盖问题的规约。这意味着顶点覆盖问题也是NP困难的。

核心思想:如果问题A可以规约到问题B(A ≤_p B),并且问题B可以规约到问题C(B ≤_p C),那么问题A也可以规约到问题C(A ≤_p C)。因此,我们可以使用任何已知的NP困难问题作为新规约的起点,而不必总是从3-SAT开始。

以下是构建规约库的直观理解:

  • 我们积累了一系列已知的NP困难问题。
  • 当需要证明新问题Y是NP困难时,我们可以选择库中任何一个问题X,并构造一个从X到Y的规约。

从顶点覆盖到哈密顿环的规约

现在,我们来看一个更复杂的规约例子:从顶点覆盖的判定问题规约到有向哈密顿环的判定问题。这个规约的构造颇具技巧性,旨在展示如何将一种图结构(覆盖)编码到另一种完全不同的图结构(环)中。

问题定义

首先,明确两个问题的定义:

  1. 顶点覆盖(判定问题)

    • 输入:一个无向图 G 和一个整数 K。
    • 输出:True,如果 G 中存在一个大小不超过 K 的顶点覆盖(即一组顶点,使得每条边都至少有一个端点在该集合中);否则输出 False。
  2. 有向哈密顿环(判定问题)

    • 输入:一个有向图 H。
    • 输出:True,如果 H 中包含一个哈密顿环(即一个经过每个顶点恰好一次的有向环);否则输出 False。

规约构造的直觉

规约的目标是:给定一个顶点覆盖问题的实例 (G, K),我们构造一个有向图 H,使得 H 具有哈密顿环 当且仅当 G 具有一个大小不超过 K 的顶点覆盖。

构造的核心是设计两种“小工具”:

  • 顶点小工具:对应原图 G 中的每个顶点。
  • 边小工具:对应原图 G 中的每条边。

我们需要设计 H 的结构,使得 H 中哈密顿环的行为能够反映 G 中顶点覆盖的性质。具体来说,哈密顿环如何穿过一个边小工具,应该对应原图中该边的两个端点是否被选入顶点覆盖。

边小工具的设计

边小工具是一个有向子图,对应原图 G 中的一条边 (u, v)。经过精心设计,哈密顿环穿过该小工具的方式只有三种:

  1. 从“u端”进入并离开:这对应于在顶点覆盖中只选择了顶点 u。
  2. 从“v端”进入并离开:这对应于在顶点覆盖中只选择了顶点 v。
  3. 从两端都穿过:这对应于在顶点覆盖中同时选择了顶点 u 和 v。

小工具的内部结构确保了哈密顿环必须访问其内部所有顶点,并且上述三种模式是唯一可能的方式。

顶点小工具与覆盖顶点的连接

对于原图 G 中的每个顶点 u,我们在 H 中创建一条有向路径,称为“u的顶点路径”。这条路径会依次经过所有与 u 相关联的边小工具的对应端点(即“u端”的入口和出口)。

此外,我们引入 K 个特殊的“覆盖顶点”。每个覆盖顶点都有边指向每个顶点路径的起点,并且从每个顶点路径的终点也有边指回这些覆盖顶点。

构造的整合

最终的图 H 由以下部分组成:

  • K 个覆盖顶点。
  • 对于 G 中的每个顶点,一条对应的顶点路径。
  • 对于 G 中的每条边,一个对应的边小工具,其端点与相应顶点路径中的点连接。

规约的证明思路

证明分为两个方向:

方向一:如果 G 有大小不超过 K 的顶点覆盖 C,则 H 有哈密顿环。

  1. 从覆盖顶点1开始。
  2. 对于 C 中的每个顶点 u:
    • 从当前覆盖顶点进入 u 的顶点路径。
    • 沿着该路径前进。当到达关联边 (u, v) 的小工具时:
      • 如果 v 也在 C 中,则让哈密顿环直接穿过小工具的“u端”(模式3)。
      • 如果 v 不在 C 中,则让哈密顿环从“u端”进入,穿过小工具内部,从“v端”出来,再返回“u端”(模式1),从而“覆盖”该小工具的所有顶点。
    • 完成 u 的顶点路径后,前往下一个覆盖顶点。
  3. 最后,从最后一个覆盖顶点返回起始覆盖顶点,形成环。由于 C 覆盖了所有边,所有边小工具的顶点都已被哈密顿环访问。

方向二:如果 H 有哈密顿环,则 G 有大小不超过 K 的顶点覆盖。

  1. 观察 H 中的哈密顿环。它必须访问所有 K 个覆盖顶点。
  2. 分析环的结构可以发现,它必须被分解成 K 段,每段从一个覆盖顶点开始,进入并完整遍历恰好一条顶点路径,然后到达下一个覆盖顶点。
  3. 那些其顶点路径被哈密顿环遍历的顶点,构成了 G 的一个候选顶点集合 C。
  4. 由于哈密顿环必须访问每个边小工具的所有顶点,通过分析环穿过边小工具的方式(上述三种模式),可以证明每条边至少有一个端点属于 C。
  5. 因此,C 就是 G 的一个大小不超过 K 的顶点覆盖。

总结

本节课中我们一起学习了NP困难性证明中一个复杂的规约案例:从顶点覆盖问题到有向哈密顿环问题。我们看到了如何通过设计顶点小工具和边小工具,将图覆盖的结构性条件编码为图环存在的条件。虽然这个规约本身很复杂,但它阐释了规约证明的核心思想:在目标问题实例中精心构造结构,以模拟源问题实例的约束和解决方案。对于作业和考试,大家需要掌握的规约通常比这个例子更直接和简洁。

027:为何关注与如何选择归约起点

在本节课中,我们将探讨NP-hardness证明的高级策略,特别是如何选择合适的问题作为归约的起点。我们将通过几个例子来理解这一过程,并首先讨论为何要证明问题是NP-hard的。

为何要证明问题是NP-hard的?

证明一个问题是NP-hard的,在某种意义上可以让你从为所有可能输入寻找一个既高效又正确的算法的责任中解脱出来。因为如果问题是NP-hard的,这样的算法几乎肯定不存在。这意味着,在某种程度上,你试图解决的是一个错误的问题。

现在,可能的情况是,你实际上想解决你真正关心的问题,但你开发了一种攻击策略,并且你发现这种策略实际上意味着能为一个子问题、相关问题或更一般的问题提供高效算法。那么你就应该放下这种策略,尝试其他方法。

另一种方法是尝试特化问题。例如,你已经知道旅行商问题是NP-hard的,因此没有为所有边赋权图设计的、可证明高效且正确的算法。另一方面,如果你碰巧知道你的图是一个有向无环图,那么存在一个简单的动态规划算法可以找到该图中的最长路径。因此,如果你能根据应用中实际期望看到的输入类型,对输入做出限制性假设,通常可以将一个NP-hard问题特化为一个可以有效解决的特殊情况。

另一种可能性是,你并不一定需要这个问题的最佳解决方案,因为那会很困难。实际上,你的应用需要的不是最佳解决方案,而是一个足够好的解决方案。因此,存在一个专门研究近似算法的子领域。例如,对于旅行商问题,我无法在任意图中精确求解最优解,但相对容易得到一个在最优解两倍以内的答案。根据具体情况,也许你可以将近似因子驱动到任意接近1,只要你愿意花费更多时间。

还有一种方法是依赖启发式算法,这些算法在实践中有效。现在我们正从理论领域转向更实际的领域。例如,对于大多数来自真实地理数据的旅行商问题实例,存在在实践中有效的方法。一种方法是将其表述为整数线性规划问题,然后对于广泛的整数线性规划类别,存在可以快速求解到最优的技术。但当我提到“广泛类别”时,这不是一个定理,而是一种观察,即在实践中这些问题可以快速解决。

选择归约起点

现在,我们来讨论如何选择归约的起点。Cook-Levin定理表明3-SAT是NP-hard的。这意味着,如果存在从3-SAT到问题X的多项式时间归约,那么X就是NP-hard的。一旦我们证明了某个问题是NP-hard的,我们就可以用它作为新的归约起点来证明其他问题是NP-hard的。

因此,我们有一个已知的NP-hard问题列表,例如:3-SAT、电路可满足性、最大团、最大独立集、最小顶点覆盖、各种着色问题、哈密顿回路或路径、旅行商问题等。

那么,如何选择从哪个问题归约呢?虽然没有硬性规定,但有一些经验法则。

以下是选择归约起点的一些启发式方法:

  • 如果问题涉及二元选择:例如,决定哪些对象应该涂成红色,哪些涂成蓝色;或者选择一个具有某种性质的子集。这听起来可能与布尔可满足性有关,因此3-SAT可能是一个有用的归约起点。
  • 如果问题涉及寻找满足约束的最大/小子集:例如,寻找一个尽可能大的子集,但要求子集中没有三角形。那么最大独立集或最大团可能适合作为归约起点。类似地,如果寻找一个满足某些约束的小子集,那么最小顶点覆盖可能是正确的选择。
  • 如果问题涉及将集合划分为若干子集:例如,将房间里的所有人分成4组,并满足某些约束。这开始有点像四色问题(比三色问题更难)。或者你想知道将房间里的人分成团队的最小数量。当给图着色时,你实际上是将顶点划分成红色、黄色和蓝色集合,每个集合内部没有边。因此,着色问题可能是正确的选择。
  • 如果问题涉及寻找对象的顺序:例如,哈密顿回路是寻找一个经过所有顶点的回路,哈密顿路径是寻找一个经过所有顶点的路径。在某种意义上,这是顶点的一个排列,要求排列中相邻的顶点在图中有边相连。因此,如果你在寻找一堆对象的排序,那么也许可以从哈密顿问题归约。
  • 如果问题涉及平衡或调度:例如,负载均衡、调度,需要将具有成本的资源分配给多个代理或多个消费者。那么也许可以使用像划分这样的问题。划分问题是:给定一组带权重的物品,能否将它们分成两组,使得两组的权重和相等。

一个有趣的经验法则是:注意数字“3”。许多NP-hard问题,如3-SAT、3-着色、3-划分,当把“3”换成“2”时,问题就变得简单了;换成更大的数字,问题仍然困难。因此,如果你的问题看起来有“3”的特性,尝试从3-SAT归约通常很有效。

一个例子:半独立集问题

让我们通过一个例子来具体说明。定义:在无向图G中,一个顶点子集是半独立的,如果该子集中的每个顶点最多与该子集中的另一个顶点相邻。

我们要证明:在给定图中寻找最大半独立集的问题是NP-hard的。

第一步:选择归约起点。我们选择一个看起来尽可能像这个问题的已知NP-hard问题。这里我们选择标准的最大独立集问题

第二步:构造归约。我们有一个算法,输入图G,输出最大独立集的大小k。在中间,我们有一个解决最大半独立集问题的黑盒,输入图H,输出最大半独立集的大小l。我们需要找到一种方法将G转换为H,并将k转换为l。

构造H:从图G开始,为G中的每个顶点v添加一个新的“叶子”顶点u,并用一条边连接v和u。也就是说,给每个原始顶点附加一个长度为1的路径(一个叶子)。

证明思路

  • 正向(如果G有大小为k的独立集S,则H有大小至少为k+|V|的半独立集):取S,并包含所有新添加的叶子顶点。这个集合是半独立的:S中的原始顶点彼此不相邻(因为S是独立集),每个叶子顶点只与S中的一个顶点相邻(如果其父顶点在S中)。因此,我们得到了一个大小为k + |V|的半独立集。
  • 反向(如果H有大小为l的半独立集S‘,则G有大小至少为l - |V|的独立集):关键是要论证,我们可以修改S‘,使其包含所有叶子顶点,而不减少其大小。然后,从修改后的集合中移除所有叶子顶点,剩下的就是G中的一个独立集,其大小至少为l - |V|。修改过程涉及:对于每个叶子顶点,如果它不在S‘中但其父顶点在S’中,则将叶子加入S‘并移除父顶点;如果父顶点也不在S’中,则直接将叶子加入S‘。可以验证,经过这样的修改,我们仍然得到一个半独立集,并且它包含了所有叶子顶点。

因此,图G的最大独立集大小等于图H的最大半独立集大小减去|V|。这就证明了,如果我们能在多项式时间内解决最大半独立集问题,我们就能在多项式时间内解决最大独立集问题。由于最大独立集是NP-hard的,所以最大半独立集也是NP-hard的。

总结

本节课中,我们一起学习了证明NP-hardness的动机和策略。我们了解到,证明问题是NP-hard的可以指导我们放弃寻找通用高效精确算法的徒劳努力,转而寻求特化、近似或启发式方法。在证明技巧方面,我们学习了如何根据目标问题的特征(如二元选择、子集选取、划分、排序、平衡等)来选择合适的已知NP-hard问题作为归约起点。一个实用的启发式是注意“3”这个数字。最后,我们通过“半独立集”问题的归约例子,具体展示了如何构造和证明一个归约。掌握这些策略,将有助于你理解和应对计算中的困难问题。

028:不可判定性

在本节课中,我们将要学习计算理论中的一个核心概念:不可判定性。我们将了解什么是不可判定的问题,并通过一个经典的证明来理解为什么某些问题不存在任何算法可以解决它们。

课程概述与安排

上一节我们介绍了NP难问题,其核心是“没有快速算法”。本节中我们来看看一个更极端的概念:不可判定性,即“没有任何算法”。

首先,我们来看一下课程近期的安排。以下是需要大家注意的事项:

  • 引导式习题集:一份简短的习题集已发布,一周后截止,内容关于不可判定性。
  • 家庭作业12:仅供练习,同样关于不可判定性。
  • 期末考试:定于12月8日(周五)上午8点。若有时间冲突,请尽快填写冲突考试登记表。
  • 成绩复议:期中考试1和部分作业的成绩复议已处理。若对GradeScope上的复议结果不满意且复议窗口已关闭,可使用另一个专用表单提交后续请求。
  • 教学评估:请通过学校的ICES系统填写课程和讨论课评估。此外,我们还为助教(TA)和课程助理(CA)设置了单独的匿名反馈表。
  • 课程相关调查:关于合作学习、引导式习题集体验以及本学期延期政策的两份调查即将发布。若提交人数超过班级总人数的40%,全体同学将获得额外学分。
  • PrairieLearn开发团队招聘:春季学期招聘开发人员,申请表稍后发布。
  • 课程助理(CA)申请:春季学期374课程的CA申请表格将由授课教师稍后发布。

从NP难到不可判定

我们之前讨论的NP难问题,如3SAT或旅行商问题,存在算法,但最坏情况下需要指数时间。例如,对于有n个变量的3SAT问题,暴力尝试所有2^n种变量赋值组合的算法总能给出正确答案。

然而,存在另一类问题,它们没有任何算法能在有限时间内对任意输入给出是/否的答案。这就是不可判定问题。

一个例子:波斯特对应问题

为了直观理解,我们首先看一个称为波斯特对应问题的例子。

问题描述:给定n个卡片类型,每种类型有无限张相同的卡片。每张卡片上半部分(蓝色)有一个字符串,下半部分(红色)有一个字符串。目标是选择一系列卡片(每种类型可使用任意次),使得将这些卡片按顺序排列后,上半部分拼接成的字符串下半部分拼接成的字符串完全相同。

示例1(有解)
假设有三种卡片:

  1. 上半: 1, 下半: 101
  2. 上半: 10, 下半: 00
  3. 上半: 0, 下半: 011

可以按顺序 [1, 3, 2, 3] 选择卡片:

  • 顶部拼接:1 + 0 + 10 + 0 = 10100
  • 底部拼接:101 + 011 + 00 + 011 = 10100011
    此例无解。实际上,可以证明不存在任何算法能正确判定任意给定的波斯特对应问题实例是否有解。即使对于只有3种卡片类型的情况,我们也不知道它是否可判定。

不可判定性的核心:程序与数据

要理解不可判定性的深层原因,我们需要思考程序和数据的本质。

  • 程序(代码):一个可以执行计算的实体,例如一个编译后的可执行文件或一个物理的图灵机。
  • 程序描述(数据):描述该程序的字符串,例如源代码。任何程序的源代码本身就是一个文本文件,可以被其他程序(如编译器或解释器)读取和处理。

关键在于,程序描述可以作为数据输入给另一个程序(甚至自己)。编译器将源代码(数据)转换为可执行代码(程序)。通用图灵机(或Python解释器)读取一个程序的描述(数据),并模拟该程序的行为。

我们引入记号:

  • M:表示机器或程序本身。
  • <M>:表示机器M的描述(即其源代码)。

停机问题与“自拒绝”问题

一个著名的不可判定问题是停机问题:判断一个给定程序在特定输入上是否会无限运行(即“停机”)。我们通过一个更具体的问题来证明其不可判定性:“自拒绝”问题。

“自拒绝”问题定义:给定一个程序M的描述<M>,问:当M以自己的描述<M>作为输入运行时,它是否会在有限时间内拒绝这个输入(即输出“否”)?

证明(反证法)

  1. 假设存在一个程序SR,它能完美判定“自拒绝”问题。即,对于任何输入<M>
    • 如果M拒绝<M>,则SR(<M>)接受(输出“是”)。
    • 如果M不拒绝<M>(即接受或无限循环),则SR(<M>)拒绝(输出“否”)。
    • SR自身总是能在有限时间内停机。
  2. 现在考虑SR以自己的描述<SR>作为输入运行会发生什么
    • 情况1:假设SR(<SR>)接受。根据SR的定义,这意味着SR拒绝<SR>。矛盾。
    • 情况2:假设SR(<SR>)拒绝。根据SR的定义,这意味着SR不拒绝<SR>。由于SR总是停机,这意味着SR接受<SR>。矛盾。
  3. 无论哪种情况都导致矛盾。因此,最初的假设不成立。
  4. 结论:这样的程序SR不可能存在。“自拒绝”问题是不可判定的。

这个证明运用了对角化思想,类似于“非主席俱乐部”悖论:一个规定“会员不得是任何社团的主席”的俱乐部,其自身的主席人选将导致逻辑矛盾,因此这个俱乐部无法成立。

总结

本节课中我们一起学习了计算理论中的不可判定性概念。我们首先通过波斯特对应问题直观感受了不存在任何算法的问题。然后,我们深入探讨了程序与其描述的关系,并通过对角化方法,证明了“自拒绝”问题是不可判定的。这个证明的核心在于展示了假设存在判定程序会导致逻辑上的自相矛盾。在下一节中,我们将学习如何利用这种不可判定性,通过规约来证明其他问题的不可判定性。

029:不可判定性证明

概述

在本节课中,我们将学习如何证明某些计算问题是不可判定的,即不存在任何算法能在有限时间内解决该问题的所有实例。我们将从经典的停机问题入手,通过归约和反证法,理解不可判定性证明的核心思想。


停机问题与自停机问题

上一节我们介绍了不可判定问题的概念。本节中,我们来看看最经典的不可判定问题——停机问题。

停机问题的定义如下:给定一个程序 M 的源代码和一个输入字符串 x,判断程序 M 在输入 x 上是否会停机。形式化地,这是一个判定问题:

HALT(<M>, x) = 1 当且仅当 M(x) 停机

其中 <M> 表示程序 M 的源代码。

为了证明停机问题是不可判定的,我们首先考虑一个相关的、更简单的问题:自停机问题

自停机问题的定义是:给定一个程序 M 的源代码,判断当 M自身的源代码作为输入时,是否会停机。形式化地:

SELF_HALT(<M>) = 1 当且仅当 M(<M>) 停机

我们将通过反证法(对角线论证)来证明自停机问题是不可判定的。

自停机问题的不可判定性证明

假设存在一个程序 Bob 能够判定自停机问题。即,对于任意输入字符串 w(代表某个程序的源代码),Bob(w) 总能在有限时间内停机,并返回:

  • True,如果 w 所代表的程序在输入 w 时会停机。
  • False,如果 w 所代表的程序在输入 w 时会无限循环。

现在,我们利用 Bob 来构造一个新程序 AliceAlice 的行为描述如下(伪代码):

function Alice(string w):
    if Bob(w) == True:
        // 进入无限循环
        while True: pass
    else:
        // 停机并接受
        return True

Alice 的核心逻辑是:如果 Bob 判断 w 会自停机,那么 Alice 就无限循环;反之,如果 Bob 判断 w 不会自停机,那么 Alice 就停机并接受。

接下来,我们分析将 Alice 自身的源代码 <Alice> 作为输入时会发生什么。这会导致矛盾:

  1. 假设 Alice(<Alice>) 停机并接受。

    • 这意味着 Bob(<Alice>) 返回了 False(根据 Alice 的代码逻辑)。
    • Bob 返回 False 表示:程序 Alice 在输入 <Alice>不会停机
    • 这与我们“Alice(<Alice>) 停机”的假设直接矛盾。
  2. 假设 Alice(<Alice>) 无限循环。

    • 这意味着 Bob(<Alice>) 返回了 True(根据 Alice 的代码逻辑)。
    • Bob 返回 True 表示:程序 Alice 在输入 <Alice>会停机
    • 这又与我们“Alice(<Alice>) 无限循环”的假设直接矛盾。

因此,无论 Alice 在自身输入上的行为如何,都会导致矛盾。这个矛盾的根源在于我们最初假设了 Bob 的存在。所以,这样的程序 Bob 不可能存在。自停机问题是不可判定的。


从自停机问题归约到停机问题

既然自停机问题是不可判定的,我们可以利用它来证明更一般的停机问题也是不可判定的。证明方法是归约:如果我们能用一个假设的“停机问题判定器”来解决自停机问题,那么由于自停机问题不可判定,这个判定器也不可能存在。

假设存在一个程序 Halt_Decider 能够判定停机问题。我们可以构造一个解决自停机问题的程序 Self_Halt_Solver

function Self_Halt_Solver(string <M>):
    // 直接调用停机问题判定器,并将程序M的源代码同时作为程序和输入
    return Halt_Decider(<M>, <M>)

这个构造非常直接:要判断程序 M 在自身源代码上是否停机,等价于判断停机问题的一个特例——输入是 (<M>, <M>)

  • 如果 Halt_Decider 存在,那么 Self_Halt_Solver 就正确地解决了自停机问题。
  • 但我们已证明自停机问题是不可判定的,不存在这样的解决程序。
  • 因此,我们的假设错误,停机问题判定器 Halt_Decider 也不可能存在。所以,停机问题是不可判定的。

这个归约表明,如果一个问题的特例(自停机)是困难的,那么其一般形式(停机问题)至少同样困难。


证明更多问题的不可判定性:以“永不停机”为例

掌握了归约方法后,我们可以证明更多问题的不可判定性。让我们看一个例子:“永不停机”问题。

“永不停机”问题的定义是:给定一个程序 M 的源代码,判断 M 是否对所有可能的输入都无限循环(即永不停机)。形式化地:

NEVER_HALT(<M>) = 1 当且仅当 对于所有输入x, M(x) 都无限循环

我们将通过从停机问题归约来证明 NEVER_HALT 是不可判定的。

归约构造

假设存在一个程序 Bunny 能够判定 NEVER_HALT。我们将利用 Bunny 来构造一个解决停机问题 HALT 的程序 Halt_Decider,从而引出矛盾。

Halt_Decider 的输入是一个程序 M 的源代码和一个输入字符串 w。其构造如下:

  1. 构造一个新程序 Meow 的源代码Meow 被设计成忽略自己的输入,并模拟 M 在输入 w 上的运行。

    // 程序 Meow 的伪代码描述
    function Meow(string x):
        // 忽略输入x,直接模拟运行 M(w)
        simulate M(w)
        return // 如果 M(w) 停机,则 Meow 也停机
    

    关键点:Meow 的行为完全由 Mw 决定。对于任何输入 xMeow(x) 都会尝试运行 M(w)。因此:

    • 如果 M(w) 停机,则 Meow所有输入 x 都停机。
    • 如果 M(w) 无限循环,则 Meow所有输入 x 都无限循环。
  2. 利用 Bunny 进行判断:将我们刚构造出的程序 Meow 的源代码 <Meow> 作为输入,调用 Bunny

    function Halt_Decider(string <M>, string w):
        // 1. 根据 M 和 w 构造出程序 Meow 的源代码 <Meow>
        <Meow> = construct_meow_code(<M>, w)
    
        // 2. 询问 Bunny: Meow 是否永不停机?
        if Bunny(<Meow>) == True:
            // Bunny 说 Meow 永不停机
            return False // 这意味着 M(w) 无限循环
        else:
            // Bunny 说 Meow 并非永不停机(即存在某个输入使它停机)
            return True // 这意味着 M(w) 会停机
    

正确性分析

我们需要验证这个 Halt_Decider 是否正确解决了停机问题。

  • 情况一:M(w) 确实停机。

    • 那么,对于任何输入 xMeow(x) 都会运行 M(w) 然后停机。所以 Meow 是一个对任何输入都停机的程序。
    • 因此,Meow 并非“永不停机”。判定器 Bunny(<Meow>) 将返回 False
    • 根据 Halt_Decider 的代码,当 Bunny 返回 False 时,它返回 True
    • 结论:Halt_Decider(<M>, w) 正确地返回了 True(表示停机)。
  • 情况二:M(w) 无限循环。

    • 那么,对于任何输入 xMeow(x) 都会陷入运行 M(w) 的无限循环中。所以 Meow 是一个对任何输入都无限循环的程序。
    • 因此,Meow 满足“永不停机”的条件。判定器 Bunny(<Meow>) 将返回 True
    • 根据 Halt_Decider 的代码,当 Bunny 返回 True 时,它返回 False
    • 结论:Halt_Decider(<M>, w) 正确地返回了 False(表示无限循环)。

因此,如果 Bunny 存在,我们就能构造出正确的 Halt_Decider 来解决停机问题。但我们已知停机问题是不可判定的,这样的 Halt_Decider 不存在。所以,我们的初始假设错误——程序 Bunny 不可能存在。这证明了“永不停机”问题 NEVER_HALT 是不可判定的。

这个归约的典型模式是:为了判断原问题(HALT)的一个实例 (M, w),我们构造一个新程序Meow),使得这个新程序的某个整体性质(是否永不停机)恰好等价于原实例的答案。然后,利用目标问题(NEVER_HALT)的假设判定器对这个新程序进行判断,从而得出原实例的答案。


赖斯定理:一个强大的元定理

通过归约,我们可以逐个证明许多关于程序行为的问题是不可判定的。然而,存在一个强大的定理——赖斯定理——可以一次性证明一整类问题的不可判定性。

赖斯定理关注的是关于图灵机(或程序)可接受语言的“非平凡”性质。一个程序 M 的可接受语言 L(M) 是指所有能使 M 停机并接受的输入字符串的集合。

定理陈述

P 是图灵机可接受语言集合的一个非平凡性质。满足以下两个条件时,P 是非平凡的:

  1. 存在性:至少存在一个图灵机 Y,其可接受语言 L(Y) 满足性质 P
  2. 非普遍性:至少存在一个图灵机 N,其可接受语言 L(N) 满足性质 P

赖斯定理指出:判定一个给定的图灵机 M 的可接受语言 L(M) 是否满足非平凡性质 P,是一个不可判定的问题。

理解与应用

简单来说,任何关于程序“计算什么”(即它的可接受语言是什么样子)的、非平凡的、语义层面的问题,都是不可判定的。

什么是“平凡”性质?

  • 所有语言都满足的性质:例如,“是一个语言”。判定这个问题没有意义,答案总是“是”。
  • 没有语言满足的性质:例如,“接受字符串‘Discover信用卡’”。由于‘Discover信用卡’不是一个合法的输入字符串,任何图灵机都不会接受它,所以答案总是“否”。
    对于这两种平凡性质,判定问题是简单的(直接返回常数)。赖斯定理针对的是除此之外的所有性质。

应用示例:
以下是赖斯定理的一些直接推论(均不可判定):

  • 给定程序 ML(M) 是否为空集?
  • 给定程序 ML(M) 是否包含所有字符串(即 Σ*)?
  • 给定程序 ML(M) 是否只包含回文?
  • 给定程序 ML(M) 是否包含至少一个字符串?
  • 给定程序 ML(M) 是否为有限集?
  • 给定程序 ML(M) 是否为正则语言?

如何使用赖斯定理快速证明?
要证明“判定 L(M) 是否具有性质 P”是不可判定的,只需:

  1. 找到一个图灵机 Y,使得 L(Y) 满足 P。(通常,令 Y 为接受所有字符串的程序即可,因为许多性质都包含 Σ*)。
  2. 找到一个图灵机 N,使得 L(N) 不满足 P。(通常,令 N 为拒绝所有字符串的程序即可,因为 L(N) = ∅ 通常不满足目标性质)。
  3. 引用赖斯定理,得出结论。

例如,证明“判定 L(M) 是否包含空串 ε”是不可判定的:

  • 性质 P: L(M) 包含 ε
  • Y 为接受所有字符串的程序,则 L(Y) 包含 ε,满足 P
  • N 为拒绝所有字符串的程序,则 L(N) = ∅,不包含 ε,不满足 P
  • 根据赖斯定理,该判定问题不可判定。

总结

本节课中我们一起学习了不可判定性证明的核心方法:

  1. 直接对角线论证:通过程序对自身源代码的行为构造矛盾,证明了自停机问题的不可判定性。
  2. 归约法:将一个已知的不可判定问题(如自停机问题)归约到待证明的问题(如停机问题),从而证明后者也是不可判定的。我们详细分析了如何归约证明“永不停机”问题的不可判定性。
  3. 赖斯定理:这是一个强大的元定理,指出任何关于图灵机可接受语言的非平凡语义性质都是不可判定的。这为我们快速判断一大类问题的可判定性提供了有力工具。

理解这些证明的关键在于清晰区分“作为代码运行的程序”和“作为数据处理的数据(源代码)”,并掌握构造“自指”或“行为转换”程序来引发矛盾的技巧。

030:期末复习(第二部分)🎓

在本节课中,我们将继续讲解期末模拟考试中剩余的问题。我们将逐一分析每个问题,并提供清晰的解题思路和答案。课程内容涵盖动态规划、正则语言、NP完全性、图论以及有向无环图(DAG)等核心概念。


问题一:判断题 ✅❌

以下是判断题部分。对于每个陈述,如果该陈述在所有情况下都成立,请勾选“是”;否则,请勾选“否”,并给出简要解释。我们假设P ≠ NP。

1. 递归式 T(n) = 8T(n/2) + n² 的解是 T(n) = O(n²)

答案:否

解释:
我们可以通过递归树来分析。根节点的代价是 ,第一层有8个子问题,每个子问题的规模是 (n/2)² = n²/4,因此第一层的总代价是 8 * (n²/4) = 2n²。第二层有64个子问题,每个规模为 (n/4)² = n²/16,总代价为 4n²。这是一个递增的几何级数,总代价由叶子节点决定。叶子节点数量为 n^(log₂8) = n³,因此总时间复杂度为 O(n³),而不是 O(n²)

2. 递归式 T(n) = 2T(n/8) + n² 的解是 T(n) = O(n²)

答案:是

解释:
同样使用递归树分析。根节点代价为 ,第一层有2个子问题,每个代价为 (n/8)² = n²/64,总代价为 2 * (n²/64) = n²/32。这是一个快速衰减的几何级数,总代价由根节点主导,因此为 O(n²)

3. 每个有向无环图(DAG)都至少有一个汇点(sink)。

答案:是

解释:
汇点是指只有入边、没有出边的顶点。假设一个DAG中没有汇点,那么从任意顶点出发,总可以沿着出边走到另一个顶点。由于图是无环的,这条路径不能无限延伸,最终会到达一个没有出边的顶点,即汇点。因此,每个DAG都至少有一个汇点。

4. 对于任何无向图,我们都可以使用广度优先搜索(BFS)或深度优先搜索(DFS)在线性时间内计算出一棵生成树。

答案:否

解释:
生成树要求图是连通的。如果图不连通,BFS或DFS只能得到包含起始顶点的连通分量的生成树,而无法得到整个图的生成树。因此,该陈述不成立。

5. 对于动态规划递推式 dp[i][j] = dp[i][j-1] + dp[i-1][j] + dp[i+1][j+1],可以通过外层循环递增 i、内层循环递增 j 的顺序正确计算。

答案:否

解释:
该递推式存在循环依赖。为了计算 dp[i][j],需要 dp[i+1][j+1] 的值,但按照给定的顺序,dp[i+1][j+1] 会在 dp[i][j] 之后才被计算。实际上,依赖图中存在环,因此没有任何计算顺序可以正确计算该递推式。


问题二:语言与复杂性 🧠

对于以下每个陈述,判断是否存在至少一种语言 L 使其成立。

1. L* = L**

答案:是

解释:
例如,令 L = {1},则 L* = 1*,且 L** = (1*)* = 1*,两者相等。

2. L 是可判定的,但 L* 是不可判定的。

答案:否

解释:
如果 L 是可判定的,我们可以使用动态规划(如文本分割算法)在多项式时间内判定 L*。因此,L* 也是可判定的。

3. L 既不是正则语言,也不是NP难问题。

答案:是

解释:
例如,语言 L = {0ⁿ1ⁿ | n ≥ 0} 不是正则语言(使用泵引理可证),但可以在线性时间内判定,因此属于P类。假设P ≠ NP,则 L 不是NP难问题。

4. L ∈ PL 有无限泵引理。

答案:是

解释:
同样以 L = {0ⁿ1ⁿ | n ≥ 0} 为例,它属于P类,且不是正则语言,因此具有无限泵引理。

5. 图灵机编码的语言 {⟨M⟩ | L(M) 不可判定} 是不可判定的。

答案:是

解释:
根据莱斯定理,任何关于图灵机语义的非平凡性质都是不可判定的。这里询问的是图灵机接受的语言是否可判定,这是一个非平凡性质,因此不可判定。


问题三:有向图与NP完全性 🔗

考虑以下两个语言:

  • 有向哈密顿路径(DHP):包含有向哈密顿路径的有向图。
  • 无环图(DAG):不包含环的有向图。

1. DAG ∈ NP

答案:是

解释:
给定一个图,如果它是无环的,我们可以通过深度优先搜索在多项式时间内验证这一点。因此,DAG ∈ P ⊆ NP。

2. DAG ∩ DHP ∈ P

答案:是

解释:
要判断一个图是否同时是无环图且包含哈密顿路径,可以先使用DFS检查是否有环(多项式时间),如果是DAG,再使用动态规划求最长路径(多项式时间)。如果最长路径长度等于顶点数减一,则存在哈密顿路径。

3. DHP 是可判定的

答案:是

解释:
虽然DHP是NP难问题,但可以通过枚举所有顶点排列(共 n! 种)并在多项式时间内检查每条路径是否为哈密顿路径来判定。尽管该算法时间复杂度极高,但它确实是一个算法,因此DHP是可判定的。

4. 从一个问题到另一个问题的多项式时间归约意味着 P = NP

答案:仅当从 DHP 归约到 DAG 时成立

解释:

  • 如果存在从DHP到DAG的多项式时间归约,且DAG ∈ P,则DHP ∈ P,从而 P = NP。
  • 如果存在从DAG到DHP的多项式时间归约,这仅意味着我们有一个低效的算法解决DAG,不能推出 P = NP。

问题四:多项式时间归约 🔄

假设存在从语言 A 到语言 B 的多项式时间归约,且 P ≠ NP。判断以下陈述是否总是成立。

1. A ⊆ B

答案:否

解释:
归约不要求子集关系。例如,A = 0*B = 1*,通过将0替换为1的归约,A 可以归约到 B,但 A 不是 B 的子集。

2. 如果 B ∈ P,则 A ∈ P

答案:是

解释:
根据多项式时间归约的定义,如果 B 可以在多项式时间内解决,且存在从 AB 的多项式时间归约,则 A 也可以在多项式时间内解决。

3. 如果 B 是NP难问题,则 A 是NP难问题

答案:否

解释:
归约方向错误。正确的方向是:如果 A 是NP难问题,且存在从 AB 的多项式时间归约,则 B 是NP难问题。

4. 如果 B 是正则语言,则 A 是正则语言

答案:否

解释:
多项式时间归约可以执行复杂的计算,不一定保持正则性。例如,A 可以是非正则语言,但通过归约调用 B 的DFA。

5. 如果 B 是正则语言,则 A 是可判定的

答案:是

解释:
正则语言属于P类,因此 B ∈ P。根据归约性质,A ∈ P,从而 A 是可判定的。


问题五:骨牌覆盖问题 🀄

我们有一个 2 × n 的网格,每个格子有一个整数值。使用 2 × 1 的骨牌覆盖网格,垂直骨牌覆盖的格子值相加,水平骨牌覆盖的格子值相减,求最大总得分。

动态规划解法

定义 dp[i] 为覆盖第 i 列到第 n 列的最大得分。递推关系如下:

dp[i] = max(
    value[i][1] + value[i][2] + dp[i+1],  // 放置垂直骨牌
    -value[i][1] - value[i][2] - value[i+1][1] - value[i+1][2] + dp[i+2]  // 放置两个水平骨牌
)

边界条件:

  • 如果 i > ndp[i] = 0
  • 如果 i == n,只能放置垂直骨牌

从右向左计算 dp[1],时间复杂度为 O(n)


问题六:正则语言与泵引理 🔤

1. 设计DFA、NFA和正则表达式

语言 L:所有0后面都至少紧跟一个1的字符串。

  • 正则表达式(1 + 01)*
  • DFA
    • 状态 q0(起始状态,接受状态):读1留在 q0,读0转到 q1
    • 状态 q1:读1回到 q0,读0转到拒绝状态

2. 证明语言非正则

语言 LB:所有游程长度严格递增的字符串。

使用泵引理证明。考虑语言中只有两个游程的字符串子集,例如第一个游程是0,第二个游程是1。令 F = {0^i | i ≥ 1}。对于任意两个不同的字符串 0^i0^j(假设 i < j),取 z = 1^j。则 0^i 1^j ∈ LB,但 0^j 1^j ∉ LB。因此,F 是无限混淆集,LB 不是正则语言。


问题七:山地徒步问题 ⛰️

给定一个 n × n 的高度网格,每次只能向四个方向移动,且相邻格子高度差不超过 Δ。徒步路线必须先严格上升,后严格下降。求最长徒步路线长度。

解法:构建有向无环图(DAG)

  1. 顶点:为每个网格点 (i, j) 创建两个顶点 (i, j, up)(i, j, down),分别代表上升阶段和下降阶段。
    • 上升边:从 (i, j, up) 到邻居 (i', j', up),如果高度严格增加且差 ≤ Δ。
    • 下降边:从 (i, j, down) 到邻居 (i', j', down),如果高度严格减少且差 ≤ Δ。
    • 转换边:从 (i, j, up)(i, j, down),表示从上升转为下降。
  2. 权重:所有边权重为1,转换边权重为0。
  3. 最长路径:在DAG中,从 (s, up)(t, down) 的最长路径即为所求。使用动态规划求DAG最长路径,时间复杂度 O(V + E) = O(n²)

总结 📚

本节课我们一起完成了期末模拟考试的所有问题,涵盖了算法与计算模型的核心知识点:

  1. 递归式分析:通过递归树判断时间复杂度。
  2. 语言与复杂性:理解正则语言、可判定性、P与NP的关系。
  3. NP完全性:掌握多项式时间归约及其含义。
  4. 动态规划:解决骨牌覆盖等最优化问题。
  5. 正则语言:设计自动机并使用泵引理证明非正则性。
  6. 图论算法:将实际问题转化为DAG最长路径问题。

希望本教程能帮助你巩固知识,为期末考试做好充分准备。祝你考试顺利!

031:期末复习(第一部分)📚

在本节课中,我们将一起复习期末考试的核心内容,涵盖NP完全性证明、有穷自动机(DFA)语言判定以及算法设计等关键主题。课程内容基于一份模拟试卷,我们将详细解析其中的典型问题,帮助你为考试做好准备。


问题四:NP完全性证明 🔍

上一节我们介绍了期末考试的总体结构,本节中我们来看看一个典型的NP完全性证明问题。这类问题通常要求你将一个已知的NP难问题归约到目标问题。

问题描述

试卷中提供了两个NP难问题供选择证明。我们选择第二个问题进行详细分析:彩虹哈密顿环问题

彩虹哈密顿环问题:给定一个有向图,其每条边都被赋予一种颜色。问是否存在一个哈密顿环(即访问每个顶点恰好一次的环),且环中任意两条连续的边颜色不同(即一个“彩虹”环)。

我们的目标是证明该问题是NP难的。为此,我们需要从一个已知的NP难问题出发,构造一个多项式时间归约。

证明步骤

以下是证明彩虹哈密顿环问题是NP难的完整过程。

  1. 选择归约源问题:我们选择从有向图哈密顿环问题(Directed Hamiltonian Cycle)进行归约。这是一个经典的NP完全问题。

  2. 构造归约:给定一个有向图 G = (V, E) 作为有向哈密顿环问题的输入实例。我们构造彩虹哈密顿环问题的一个实例 H

    • H 的顶点集与 G 的顶点集 V 相同。
    • H 的边集与 G 的边集 E 相同。
    • 对于 H 中的每一条边,我们为其分配一种独一无二的颜色。也就是说,如果 Gm 条边,我们就使用 m 种不同的颜色,每条边一种。

    这个构造过程显然是多项式时间的(只需遍历所有边并分配颜色)。

  3. 证明等价性:我们需要证明 G 包含一个哈密顿环 当且仅当 H 包含一个彩虹哈密顿环。

    • (⇒)如果 G 有哈密顿环:设 CG 中的一个哈密顿环。由于 HG 具有相同的顶点和边,C 也是 H 中的一个哈密顿环。又因为 H 中每条边的颜色都不同,环 C 中任意两条连续边的颜色必然不同。因此,C 就是 H 中的一个彩虹哈密顿环。
    • (⇐)如果 H 有彩虹哈密顿环:设 RH 中的一个彩虹哈密顿环。忽略边的颜色,R 本身就是 H(因而也是 G)的一个哈密顿环。因此,G 包含一个哈密顿环。
  4. 结论:由于我们给出了一个从NP难问题(有向哈密顿环)到彩虹哈密顿环问题的多项式时间归约,并且证明了两个问题的答案等价,因此彩虹哈密顿环问题也是NP难的。

核心概念公式/代码描述
归约过程可以概念性地表示为:

def reduce_DHC_to_RainbowHC(G):
    H = copy(G)
    for each edge e in H.edges:
        assign_unique_color(e)
    return H
# 那么: G has Hamiltonian cycle ⇔ H has Rainbow Hamiltonian cycle


问题二:判定DFA语言的有限性 🔄

在理解了NP完全性证明后,我们转向另一个重要主题:有穷自动机。本节将探讨如何判定一个确定性有穷自动机(DFA)所接受的语言是有限的还是无限的。

问题描述

给定一个DFA,描述并分析一个算法,用于判断该DFA接受的语言是有限的(Finite)还是无限的(Infinite)。你可以假设输入字母表为 {0, 1}。提示:将DFA视为一个有向图。

算法思路与步骤

DFA可以自然地表示为一个有向图:状态是顶点,转移函数定义了带标签的有向边。一个DFA接受无限语言,当且仅当存在某些足够长的字符串能被接受。根据泵引理的思想,这等价于在DFA对应的图中存在一个特定的环。

关键观察:DFA M 接受无限字符串,当且仅当 在其状态转移图中存在一个环,且该环满足:

  1. 从起始状态 s 可以到达该环。
  2. 从该环中的某个状态可以到达某个接受状态。

以下是具体的算法步骤:

  1. 预处理图:首先,从图中删除所有从起始状态 s 无法到达的状态。这可以通过以 s 为起点运行一次图搜索算法(如BFS或DFS)实现,标记所有可达顶点,然后删除未标记的顶点及其关联边。

  2. 处理接受状态可达性:其次,我们需要知道哪些状态可以到达某个接受状态。高效的做法是:

    • 添加一个虚拟的“超级汇点” z
    • 对于每一个接受状态 q,添加一条从 q 指向 z 的有向边。
    • 反向图(将所有边反转)中,从 z 开始运行一次图搜索。这样,在原始图中能到达某个接受状态的状态,就是在反向图中能从 z 到达的状态。
    • 删除所有在反向图中无法从 z 到达的状态(即在原始图中无法到达任何接受状态的状态)。
  3. 检测环:经过前两步处理后,我们得到了一个简化后的图。在这个图中,所有剩余状态都满足:从 s 可达,且可以到达某个接受状态。

    • 如果这个简化后的图是一个有向无环图(DAG),则不存在满足条件的环,因此DFA的语言是有限的
    • 否则,图中存在环,该环必然满足上述两个条件,因此DFA的语言是无限的。我们可以通过DFS检测环的存在性。

复杂度分析

设DFA的状态数为 n。由于输入字母表是 {0,1},每个状态有恰好两条出边,因此图的边数 m = 2n
上述算法的每一步(图搜索、反向图搜索、环检测)都可以在 O(n + m) = O(n + 2n) = O(n) 时间内完成。因此,总时间复杂度是 O(n),即线性于DFA的状态数。

核心概念公式/代码描述
算法逻辑的伪代码表示:

def is_language_infinite(DFA M):
    G = graph_of(M)
    # 步骤1: 删除从起始状态s不可达的状态
    reachable_from_s = BFS(G, M.start_state)
    G1 = delete_vertices(G, not in reachable_from_s)

    # 步骤2: 删除无法到达任何接受状态的状态
    # 构建反向图并添加超级汇点z
    G_rev = reverse_graph(G1)
    add_vertex(G_rev, 'z')
    for accept_state in M.accept_states:
        add_edge(G_rev, accept_state, 'z') # 注意:这是在反向图中加边
    can_reach_accept = BFS(G_rev, 'z')
    # 在原始图G1中,能到达接受状态的状态,是反向图中能从z到达的状态
    G2 = delete_vertices(G1, not in can_reach_accept)

    # 步骤3: 检测剩余图中是否有环
    return contains_cycle(G2) # 使用DFS检测

总结 📝

本节课中我们一起学习了期末复习的两个核心部分:

  1. NP完全性证明:我们通过将“有向哈密顿环问题”归约到“彩虹哈密顿环问题”,展示了如何构造多项式时间归约并证明两个问题的等价性,从而证明目标问题是NP难的。
  2. DFA语言有限性判定:我们将DFA视为有向图,通过分析图中是否存在满足特定条件(从起点可达、可到达接受状态)的环,设计了一个线性时间算法来判断DFA接受的语言是有限的还是无限的。

掌握这些问题的解决思路,对于应对期末考试中类似的证明和算法设计题目至关重要。建议结合模拟试卷中的其他题目进行练习,以巩固理解。祝你复习顺利,考试成功!

032:第二次期末考试模拟题讲解

概述

在本教程中,我们将一起学习并解答UIUC《算法与计算模型》课程第二次期末考试模拟题。我们将涵盖多个核心主题,包括正则语言、动态规划、NP-完全性证明以及图论算法。本教程旨在通过清晰的步骤和解释,帮助初学者理解并掌握这些复杂概念。


问题一:正则语言与自动机

1.1:证明语言非正则性

上一节我们介绍了本教程的概述,本节中我们来看看第一个问题,它涉及正则语言的证明。

核心概念

  • Run(游程):在字符串中,一个“游程”是指由相同字符组成的、不能再向两侧扩展的最大子串。
  • 语言 LA:所有非空字符串的集合,其中第一个游程的长度等于字符串中游程的总数。

证明 LA 非正则
我们将使用“Fooling Set”论证法。考虑以下字符串集合:
[
F = { 0^{2i+1} \mid i \ge 0 }
]
即所有由奇数个零组成的字符串。

取集合中任意两个不同的字符串 ( x = 0^{2i+1} ) 和 ( y = 0^{2j+1} ),其中 ( i \ne j )。

现在,考虑后缀 ( z = (10)^i )。

  • 对于字符串 ( xz = 0{2i+1}(10)i ):
    • 第一个游程(零)的长度为 ( 2i+1 )。
    • 总游程数为:第一个零游程,加上 ( i ) 个“1”游程和 ( i ) 个“0”游程(交替),总共 ( 2i+1 ) 个游程。
    • 因此,( xz \in LA )。
  • 对于字符串 ( yz = 0{2j+1}(10)i ):
    • 第一个游程的长度为 ( 2j+1 )。
    • 总游程数为 ( 2i+1 )(因为后缀 ( z ) 贡献了 ( 2i ) 个游程,加上第一个零游程)。
    • 由于 ( i \ne j ),第一个游程长度 ( 2j+1 ) 不等于总游程数 ( 2i+1 )。
    • 因此,( yz \notin LA )。

由于我们找到了一个无限集合 ( F ),其中任意两个不同字符串都能被同一个后缀 ( z ) 区分(一个属于 LA,一个不属于),根据 Myhill-Nerode 定理或 Fooling Set 论证,语言 LA 不是正则语言。

1.2:构建 DFA 或 NFA 及正则表达式

上一节我们证明了语言 LA 的非正则性,本节中我们来看看语言 LB 的自动机构建。

核心概念

  • 语言 LB:所有包含偶数个长度为奇数的游程的字符串集合(来自字母表 {0,1}*)。

关键观察
一个字符串的总长度等于其所有游程长度之和。奇数个奇数之和为奇数,偶数个奇数之和为偶数。因此,LB 恰好是所有长度为偶数的字符串的集合

构建 DFA
基于上述观察,我们可以构建一个非常简单的 DFA。

以下是该 DFA 的状态转移描述(也可用状态图表示):

  • 状态:两个状态,Even(偶数长度,接受状态)和 Odd(奇数长度,拒绝状态)。
  • 初始状态Even(空字符串长度为0,是偶数)。
  • 转移:无论输入字符是 0 还是 1,都从当前状态切换到另一个状态。
  • 接受状态Even

正则表达式
描述所有长度为偶数的字符串的正则表达式很简单:
[
((0+1)(0+1))^*
]
或者更简单地,任何两个字符的重复。


问题二:合作游戏的可解性

上一节我们处理了自动机问题,本节中我们来看看一个涉及状态搜索的谜题。

问题描述
Aladdin 和 Bala Baur 各有一个长度为 n 的正整数数组(棋盘)。他们的令牌从最左方格开始。每回合,两人必须同时将自己的令牌向左或向右移动,移动的距离等于令牌当前所在方格的数字。任何令牌移出棋盘边界则游戏失败。目标是让两个令牌都到达最右方格。

算法思路
这不是一个动态规划问题,因为状态转移可能形成循环。更合适的方法是将其建模为图搜索问题

构建图 G

  • 顶点:每个顶点是一个二元组 (a, b),表示 Aladdin 的令牌在第 a 个方格,Bala Baur 的令牌在第 b 个方格(1 ≤ a, b ≤ n)。
  • 边(状态转移):从顶点 (a, b) 出发,有最多两条有向边:
    1. 向左移动:指向 (a - A[a], b - B[b]),前提是 a - A[a] ≥ 1b - B[b] ≥ 1
    2. 向右移动:指向 (a + A[a], b + B[b]),前提是 a + A[a] ≤ nb + B[b] ≤ n

问题转化
游戏有解当且仅当在构建的图 G 中,存在一条从起始顶点 (1, 1) 到目标顶点 (n, n) 的路径。

算法步骤

  1. 根据输入数组 AB 构建图 G(顶点数 (O(n^2)),每个顶点最多2条出边,总边数 (O(n^2)))。
  2. 从顶点 (1, 1) 开始,执行图遍历算法(如 BFS 或 DFS)。
  3. 如果在遍历过程中访问到顶点 (n, n),则返回 True(可解);否则返回 False

时间复杂度:构建和遍历图的时间为 (O(n^2))。


问题三:NP-完全性证明(选做一题)

上一节我们解决了一个图搜索问题,本节中我们来看看计算复杂性理论中的 NP-完全性证明。

3.A:证明“最大近似独立集”是 NP-难的

问题定义
给定无向图 (G),一个顶点子集 (S) 被称为“近似独立集”,如果 (S) 中超过一半的顶点在 (S) 内没有邻居。目标是找到最大的近似独立集。

证明思路
我们将从经典的最大独立集问题(已知 NP-难)进行归约。

归约构造
给定一个图 (G)(要求其最大独立集),我们构造一个新图 (H):

  1. 复制原图 (G)。
  2. 添加一个包含 (k) 个顶点的团(完全图)(K),其中 (k) 是 (G) 的顶点数。
  3. 不添加 (G) 和 (K) 之间的任何边。

声明
图 (G) 有一个大小为 (k) 的独立集,当且仅当 图 (H) 有一个大小为 (2k-1) 的近似独立集。

证明

  • (⇒)如果方向:假设 (G) 有独立集 (I),(|I| = k)。在 (H) 中,选取集合 (S = I \cup K'),其中 (K') 是 (K) 中任意 (k-1) 个顶点。则 (S) 的大小为 (2k-1)。在 (S) 中,来自 (I) 的 (k) 个顶点在 (S) 内没有邻居(因为 (I) 是独立集,且与 (K') 无边连接),而来自 (K') 的 (k-1) 个顶点在 (S) 内都有邻居(因为 (K) 是完全图)。因此,超过一半的顶点((k > k-1))在 (S) 内没有邻居,(S) 是近似独立集。
  • (⇐)仅当方向:假设 (H) 有大小为 (2k-1) 的近似独立集 (S)。我们可以通过“交换”论证,将 (S) 中在 (H) 内有邻居的顶点(“坏”顶点)从 (G) 的部分移动到 (K) 的部分,而不改变集合大小和“近似独立”的性质。最终,我们可以得到一个同样大小的近似独立集 (S'),其中所有来自 (G) 的顶点在 (S') 内都没有邻居,即构成了 (G) 的一个独立集 (I),并且 (I) 的大小至少为 (k)(因为 (S') 中至少一半以上的顶点来自 (G) 且是“好”的)。

因此,我们完成了从最大独立集问题到最大近似独立集问题的多项式时间归约,证明了后者是 NP-难的。


问题四:最长回文子序列

上一节我们探讨了 NP-完全性,本节中我们回到经典的动态规划问题。

4.1:最长回文子序列的长度

问题描述
给定一个字符串 T[1..n],找到其最长回文子序列的长度。

动态规划定义
定义 LPS[i][j] 为子字符串 T[i..j] 的最长回文子序列的长度。

递推关系

  • 基础情况
    • 如果 i > jLPS[i][j] = 0(空序列)。
    • 如果 i == jLPS[i][j] = 1(单个字符是回文)。
  • 递推情况
    • 如果 T[i] == T[j]
      [
      LPS[i][j] = \max(LPS[i+1][j],\ LPS[i][j-1],\ 2 + LPS[i+1][j-1])
      ]
    • 如果 T[i] != T[j]
      [
      LPS[i][j] = \max(LPS[i+1][j],\ LPS[i][j-1])
      ]

算法与复杂度
使用二维数组按 i 递减、j 递增的顺序计算 LPS[i][j]。共有 (O(n^2)) 个子问题,每个子问题计算需要 (O(1)) 时间。总时间复杂度为 (O(n^2)),空间复杂度为 (O(n^2))。

4.2:最长“双回文”子序列的长度

问题描述
“双回文”是两个非空回文的拼接。要求找到给定字符串的最长双回文子序列的长度。

算法思路
利用第一部分已计算的 LPS 表。一个双回文子序列必然在某个位置 k 将原序列分成前后两部分,每部分各自是一个回文子序列。并且,为了最大化总长度,前后两部分应分别取对应部分的最长回文子序列。

算法步骤

  1. 运行问题 4.1 的算法,得到完整的 LPS 表。
  2. 对于所有可能的分割点 k(从 1 到 n-1),计算:
    [
    \text{candidateLength} = LPS[1][k] + LPS[k+1][n]
    ]
  3. 返回所有 candidateLength 中的最大值。

时间复杂度
计算 LPS 表需要 (O(n^2))。第二步需要遍历 (O(n)) 个分割点,每个点计算为 (O(1))。总时间复杂度仍为 (O(n^2))。


问题五:钥匙与锁盒

上一节我们使用了动态规划,本节中我们来看一个可以用图论建模的有趣问题。

5.1:判断能否仅砸指定盒子而取出所有钥匙

问题描述
n 个上锁的盒子和 m 把钥匙。每把钥匙最多开一个盒子。钥匙被锁在盒子里(知道对应关系)。你必须用锤子砸开一个盒子来启动。弟弟选中了一个盒子。判断是否可能只砸弟弟选中的那个盒子,然后利用得到的钥匙开其他盒子,最终取出所有钥匙。

建模为有向图 G

  • 顶点:每个盒子是一个顶点。
  • :如果盒子 i 里面含有能打开盒子 j 的钥匙,则添加一条有向边 i -> j

关键观察
如果砸开盒子 x,那么所有能从顶点 x 通过有向边到达的盒子,最终都能被打开(因为打开一个盒子就能获得它内部钥匙对应的盒子的访问权)。

算法步骤

  1. 根据输入构建有向图 G
  2. 从弟弟选中的盒子(顶点 s)开始,执行图遍历(BFS/DFS),标记所有能到达的顶点(可打开的盒子)。
  3. 检查所有未被标记的盒子(无法打开的盒子):
    • 如果其中任何一个盒子非空(即含有任何钥匙),则返回 False(无法取出所有钥匙)。
    • 如果所有未被标记的盒子都是空的,则返回 True

时间复杂度:构建和遍历图的时间为 (O(n + m))。

5.2:计算取出所有钥匙必须砸的最小盒子数

问题重述(图论版本)
在问题 5.1 构建的有向图 G 中,每个顶点(盒子)至少有一条出边(因为非空盒子至少含有一把钥匙)。我们需要找到最小的顶点集合 S,使得从 S 中的某个顶点出发,可以到达图中的每一个顶点。

算法思路
这个问题等价于在 G强连通分量(SCC)缩点图 G' 中,寻找最小的顶点集合(对应原图的一组 SCC),使得从这些 SCC 出发可以到达 G' 中的所有 SCC。这进一步等价于在 DAG G' 中,寻找所有的源点(入度为 0 的 SCC)。

算法步骤

  1. 使用 Kosaraju 或 Tarjan 算法找出有向图 G 的所有强连通分量,并构建分量缩点图 G'(是一个 DAG)。
  2. G' 中,识别所有入度为 0 的强连通分量(源 SCC)。
  3. 答案就是这些源 SCC 的数量。为了构造解,从每个源 SCC 中任意选取一个盒子即可。

正确性

  • 必要性:必须砸开每个源 SCC 中的至少一个盒子,因为没有任何其他 SCC 的盒子能到达该源 SCC。
  • 充分性:砸开每个源 SCC 中的一个盒子后,由于 G' 是 DAG,从这些源点可以到达所有其他 SCC。而一旦进入一个 SCC,由于强连通性,可以打开该 SCC 中的所有盒子。

时间复杂度:求 SCC 和构建缩点图的时间为 (O(n + m))。


问题六:判断题

上一节我们解决了图论应用问题,本节中我们快速回顾一系列判断题,巩固基本概念。

以下是问题六中部分关键判断的答案与简要理由:

A. 关于语言的性质

  1. ( L^* = (L*)* ):。克林闭包的迭代不产生新字符串。
  2. 若 ( L ) 可判定,则 ( L^* ) 可判定:。可通过动态规划(如分词算法)判定。
  3. ( L ) 要么是正则的,要么是 NP-难的:。存在非正则但可在多项式时间判定的语言(如 ( {0n1n \mid n\ge0} ))。
  4. 若 ( L ) 不可判定,则 ( L ) 有无限 fooling set:。正则语言可判定,所以不可判定语言必非正则,而非正则语言有无限 fooling set。
  5. ( { \langle M \rangle \mid M \text{ 判定 } L } ) 对任意 ( L ) 都不可判定:。由莱斯定理可得。

B. 递推式与图论基础

  1. ( T(n) = 4T(n/4) + O(n) ) 的解为 ( O(n\log n) ):。递归树每层工作量和为 ( O(n) ),深度 ( \log_4 n )。
  2. ( T(n) = 4T(n/4) + O(n^2) ) 的解为 ( O(n^2 \log n) ):。解为 ( O(n^2) )(几何级数求和)。
  3. 每个有向无环图至多有一个源点和一个汇点:。反例:两个无入度的顶点。
  4. 深度优先搜索探索源点到其他各点的所有路径:。DFS 仅探索一条路径。
  5. 给定特定递推和评估顺序,能否在 ( O(n^2) ) 时间内完成记忆化:。所述评估顺序(外层 i 递增,内层 j 递增)不正确,会导致依赖项未计算。

C. 关于归约的正确性
(题目描述了一个从停机问题 HALT 到语言 MUGGLE 的归约。分析该归约可得出以下判断)

  1. 若 ( M ) 接受 ( w ),则 RubberDuck 拒绝 "magic"
  2. 若 ( M ) 在 ( w ) 上发散,则 RubberDuck 拒绝 "magic"(实际是发散)。
  3. 若 ( M ) 接受 ( w ),则 DecideMuggle 接受 RubberDuck
  4. 若 ( M ) 在 ( w ) 上发散,则 DecideMuggle 拒绝 RubberDuck
  5. DecideHalt 能判定 HALT(基于归约)。

D. 多项式时间归约的影响
假设存在从语言 ( A ) 到语言 ( B ) 的多项时归约,且 ( P \neq NP )。

  1. ( A \cap B ) 为空:。归约不保证交集性质。
  2. 存在算法将任意解 ( B ) 的 Python 程序转为解 ( A ) 的 Python 程序,且均多项式运行:。这是归约的定义。
  3. 若 ( B ) 是 NP-难的,则 ( A ) 是 NP-难的:。归约方向反了;应说“若 ( A ) 是 NP-难的,则 ( B ) 是 NP-难的”。
  4. 若 ( B ) 可判定,则 ( A ) 可判定:。利用归约和 ( B ) 的判定器可判定 ( A )。
  5. 若某图灵机接受 ( B ) 中所有串,则它也接受 ( A ) 中所有串:。归约改变输入,接受性不直接传递。

总结

在本教程中,我们一起学习了第二次期末考试模拟题的解答。我们涵盖了:

  1. 使用 Fooling Set 论证证明语言的非正则性,以及为简单语言设计 DFA 和正则表达式。
  2. 将双人合作游戏建模为图搜索问题,并通过 BFS/DFS 判断可解性。
  3. 通过从最大独立集问题归约,证明了一个新问题是 NP-难的。
  4. 应用动态规划求解最长回文子序列及其变体(双回文)问题。
  5. 将钥匙盒子问题抽象为有向图,利用可达性、强连通分量和 DAG 性质设计算法。
  6. 分析了一系列关于计算理论基础(语言类、递推、归约)的判断。

希望这份详细的逐步讲解能帮助你理解这些核心的算法与计算模型概念,并为考试做好充分准备。祝你学习顺利!

posted @ 2026-03-29 09:31  布客飞龙II  阅读(12)  评论(0)    收藏  举报