AbdulBari-算法导论笔记-全-
AbdulBari 算法导论笔记(全)
001:算法简介 🚀



在本节课中,我们将要学*算法的基本概念,了解算法是什么、为什么重要,以及它与程序的区别。我们将从最基础的定义开始,逐步深入,确保初学者能够轻松理解。




什么是算法?🤔




算法是一个用于解决计算问题的逐步过程。这是一个普遍的定义。程序同样也是解决问题的逐步过程。



那么,算法和程序之间有什么区别呢?通过对比两者,我们可以更好地理解算法的意义和重要性。











算法与程序的对比 📊

以下是算法与程序之间的主要区别:





1. 开发阶段不同
在软件开发的生命周期中,有两个重要阶段:设计阶段和实现阶段。算法是在设计阶段编写的,用于规划解决方案的逻辑。程序则是在实现阶段编写的,是将设计转化为计算机可执行代码的过程。



2. 编写者不同
算法的编写者需要具备领域知识,即对所要解决问题的深刻理解。这个人可能是程序员,也可能是该领域的专家(如会计师、医生)。程序的编写者通常是程序员,他们负责将算法用特定的编程语言实现。






3. 使用的语言不同
算法可以使用任何易于理解的语言来描述,例如英语、伪代码或数学符号。其核心是清晰表达逻辑,不依赖于特定语法。程序则必须使用特定的编程语言来编写,例如 C、C++、Java 或 Python。




4. 平台依赖性不同
算法是独立于硬件和操作系统的。它描述的是逻辑,不关心在什么机器上运行。程序则是依赖于硬件和操作系统的。编写程序时,需要考虑目标运行环境(如 Windows 或 Linux)。


5. 分析方式不同
在编写算法后,我们需要分析算法,以评估其正确性和效率(包括时间和空间复杂度)。对于程序,我们通常直接运行和测试它,以验证其功能。






为什么使用类C语言描述算法?💻




在实际教学中,虽然算法可以用任何语言描述,但我们常常使用类C语言的语法来书写算法。这是因为C语言被广泛教授和理解,从大学到中学都有涉及。使用大家熟悉的语法,可以避免混淆,让所有人都能轻松理解算法逻辑。




例如,一个简单的加法算法可以这样描述:
int add(int a, int b) {
int sum = a + b;
return sum;
}








总结 📝




本节课中,我们一起学*了算法的核心定义及其与程序的关键区别。我们了解到,算法是解决问题前的设计蓝图,它独立于具体技术实现,注重逻辑和效率。理解这些基本概念,是深入学*各类复杂算法和提升编程能力的重要第一步。


在接下来的课程中,我们将开始学*如何设计和分析具体的算法。
002:先验分析与后验测试 🔍


在本节课中,我们将学*算法分析中的两个核心概念:先验分析与后验测试。我们将探讨它们的定义、区别以及各自的应用场景。

先验分析 📊

上一节我们介绍了算法分析的重要性,本节中我们来看看什么是先验分析。


先验分析是针对算法本身进行的。这意味着我们将通过深入研究算法的工作原理来进行分析,并得出一些结果。
分析的结果是,我们可以找出算法所消耗的时间和空间。


以下是先验分析的特点:
- 分析的对象是算法。
- 算法是语言无关的,不依赖于特定编程语言。
- 算法是硬件无关的,不针对特定硬件设计。
- 分析得出的结果不是具体的分钟或秒数,而是时间函数和空间函数。

后验测试 ⚙️

了解了理论上的先验分析后,我们来看看与之对应的实践方法——后验测试。

测试是针对程序进行的。我们运行并执行程序,通过检查来了解它实际花费了多少时间(例如多少秒、毫秒),以及消耗了多少内存(以字节为单位)。
以下是后验测试的特点:
- 测试的对象是程序。
- 程序是高度特定的,依赖于具体的编程语言。
- 程序也依赖于硬件、操作系统和运行环境。
- 测试得出的是程序在特定环境下运行所消耗的具体时间和具体空间。



总结 📝

本节课中我们一起学*了算法分析的两个基本方法。


先验分析是在算法层面进行的、独立于语言和硬件的理论分析,其结果表示为时间与空间的函数。而后验测试是在程序层面进行的、依赖于具体语言和运行环境的实践测试,其结果表现为具体的运行时间和内存占用。理解这两者的区别是进行有效算法设计与评估的基础。
003:算法的特性 📝





在本节课中,我们将要学*算法的核心特性。理解这些特性是判断一个过程能否被称为“算法”的关键,也是我们设计和分析算法的基础。


上一节我们介绍了算法的定义,本节中我们来看看一个合格的算法必须具备哪些具体特征。
输入 (Input)





算法必须接收输入。这里的输入可以是零个或多个。例如,一个生成随机数的算法可能不需要任何外部输入,而一个排序算法则需要一个待排序的列表作为输入。

核心概念:算法可以表示为 Algorithm(Input) -> Output,其中输入集合可以为空。


输出 (Output)


算法必须产生至少一个输出。没有输出的算法是毫无意义的。你可以将算法想象成一个函数:它被调用后,必须返回一个结果。即使在编程中函数可能声明为 void 类型,它也会通过修改外部状态等方式产生某种“效果”,这同样可视为输出。
核心概念:算法执行后,必须有 Output ≠ ∅(输出非空)。


明确性 (Definiteness)

算法的每一步都必须清晰、明确,且只有单一、确切的含义。不能存在任何模棱两可或无法执行的语句。例如,你不能在算法中使用一个未定义或虚构的值(如虚数单位 i 在实数运算中),因为计算机无法理解或处理它。



核心概念:算法中的每个步骤都必须是确定性的和可执行的。

有限性 (Finiteness)


算法必须在有限的步骤后终止。它不能无限循环下去。一个算法可能只有10行代码,也可能有上万行,但其语句集合必须是有限的,并且执行过程会结束。这与一些持续运行的服务程序(如数据库服务器、Web服务器)有本质区别,后者是“过程”而非“算法”。


核心概念:算法的执行时间必须是有限的,即 Time(Algorithm) < ∞。



有效性 (Effectiveness)

算法中的每一个操作都必须是基本的、可行的,并且对达成最终目标有贡献。不应包含任何冗余或无意义的步骤。这类似于做化学实验时,你不会去加热一个与最终溶液无关的化学品;或者做菜时,你不会准备一道根本用不上的食材。


核心概念:算法中的所有操作都应是必要的和可实现的。





为了更清晰地总结,以下是算法的五大关键特性列表:
- 输入:算法有零个或多个输入。
- 输出:算法至少产生一个输出。
- 明确性:每条指令清晰无歧义。
- 有限性:算法在执行有限步骤后自动结束。
- 有效性:每条指令都基本、可行且必要。






本节课中我们一起学*了算法的五个基本特性:输入、输出、明确性、有限性和有效性。这些特性共同定义了一个合格的算法,将其与一般性的“过程”或“程序”区分开来。理解这些特性,是后续我们设计、分析和评估算法优劣的基石。在下一节中,我们将探讨如何编写和表示算法。
004:如何编写与分析算法 📝




在本节课中,我们将学*如何编写一个算法,并了解分析算法性能的主要标准。我们将通过一个简单的例子来演示编写算法的过程,并详细讲解如何从时间和空间两个维度对算法进行分析。





如何编写算法 ✍️




上一节我们讨论了算法的定义,本节中我们来看看如何具体地编写一个算法。编写算法时,我们关注的是解决问题的步骤,而不是特定编程语言的语法细节。

以下是一个交换两个数字的算法示例:



算法:交换两个数字
输入:两个数字 a 和 b
输出:交换后的 a 和 b


步骤:
1. 声明一个临时变量 t。
2. 将 a 的值赋给 t。
3. 将 b 的值赋给 a。
4. 将 t 的值赋给 b。

与C语言程序相比,算法编写有以下特点:
- 不声明参数的数据类型。数据类型是在后续编程阶段才决定的。
- 不直接声明临时变量。在算法中,我们默认可以直接使用变量,无需像在程序中那样先声明。
- 可以使用多种符号。例如,赋值操作可以用
=,也可以用:=(源自Pascal等旧语言),甚至可以用箭头符号←来表示“将值存储到此处”。



编写算法的核心目标是清晰、易懂。只要参与项目的人员能够理解其逻辑,你可以使用任何你认为合适的语法,算法没有固定的语法格式。




如何分析算法 🔍



我们已经学会了如何编写算法,接下来我们探讨如何分析一个算法的优劣。分析算法主要是为了评估其效率,主要标准有以下几点:


以下是分析算法时需要考虑的主要标准:



- 时间效率 ⏱️:算法执行所需的时间。我们希望算法能快速解决问题。分析后,我们通常得到一个时间函数
f(n),它描述了时间消耗如何随输入规模n变化。 - 空间效率 💾:算法运行需要消耗的内存空间。算法最终会转化为程序在机器上运行,因此了解其内存占用至关重要。
- 其他标准:根据具体应用场景,可能还需要考虑:
- 网络数据传输量:对于基于互联网或云的应用,算法产生的数据传输量是一个重要指标。
- 功耗:对于移动设备(如笔记本电脑、平板电脑),算法的功耗是需要考虑的因素。
- CPU寄存器占用:在进行系统级编程或设备驱动开发时,算法占用的CPU寄存器数量也是一个考量因素。


具体分析哪些标准,取决于你的项目需求和目标。



算法分析实例 📊
让我们通过分析上面“交换两个数字”的算法,来具体看看如何进行时间和空间分析。



时间分析


为了分析时间,我们假设算法中的每一个简单语句(非嵌套的独立语句)执行都需要 1个单位时间。



对于我们的交换算法:
t = a(赋值) -> 1个单位时间a = b(赋值) -> 1个单位时间b = t(赋值) -> 1个单位时间



因此,总时间 f(n) = 3。这是一个常数,不随输入规模 n 变化。

注意:即使某个语句很复杂(例如 x = 5*a + 6*b),在基础分析中,我们通常也将其视为消耗1个单位时间。当然,如果你需要进行极其详细的分析(例如分析到机器指令级别),可以拆解得更细,但这通常不是必要的。分析的详细程度取决于你的需求。


空间分析

空间分析主要计算算法中使用了多少变量。

对于我们的交换算法:
- 输入参数:
a,b-> 2个变量 - 局部变量:
t-> 1个变量




总共使用了 3个变量。我们通常说它占用了“3个字”的空间(“字”是一个与具体数据类型无关的抽象单位)。这同样是一个常数。

在算法复杂度分析中,我们通常用 O(1)(读作“大O1”)来表示这种常数级别的时间和空间复杂度。无论是3、30还是3000,只要是固定值,我们都记作 O(1)。





总结 📚




本节课中我们一起学*了:
- 编写算法:算法关注逻辑步骤,不拘泥于编程语言语法,目标是清晰易懂。
- 分析算法:主要从时间效率和空间效率两个核心维度进行评估,也可根据需求考虑网络、功耗等其他指标。
- 分析方法:通过简单的“交换数字”算法实例,我们实践了基础分析:假设每个语句耗时1单位,每个变量占用1字空间,并得出其时间复杂度和空间复杂度均为 O(1)(常数阶)。



掌握如何编写和分析算法,是设计和选择高效解决方案的基础。
005:频率计数法 📊


在本节课中,我们将学*一种分析算法时间复杂度的核心方法——频率计数法。我们将通过分析几个具体的算法示例,来掌握如何应用此方法计算其时间与空间复杂度。


概述:什么是频率计数法?

频率计数法的核心思想是:为算法中的每一条语句分配一个单位时间。如果某条语句重复执行多次,则计算其执行的频率。将所有语句的执行时间(频率)相加,即可得到该算法的总时间函数。

上一节我们介绍了算法分析的重要性,本节中我们来看看如何具体地为一个算法“计数”。
示例一:数组求和算法

首先,我们分析一个计算数组所有元素之和的简单算法。假设数组 A 的大小为 n。

以下是该算法的核心代码逻辑:
sum = 0;
for (i = 0; i < n; i++) {
sum = sum + A[i];
}


逐步时间分析
我们使用频率计数法为每条语句计算执行次数。
sum = 0;- 此语句执行1次。


for (i = 0; i < n; i++)中的循环条件i < n- 此条件会被检查
n + 1次。当i从0到n-1时,条件为真,共n次。当i等于n时,条件为假,检查第n+1次后循环停止。
- 此条件会被检查

- 循环体内的
sum = sum + A[i];- 此语句在循环内,会执行
n次。
- 此语句在循环内,会执行


- 最后的输出或返回语句
- 我们假设有一条输出
sum的语句,它执行1次。
- 我们假设有一条输出

计算时间函数

将以上所有语句的执行次数(频率)相加,我们得到时间函数 f(n):
f(n) = 1 + (n+1) + n + 1 = 2n + 3

确定时间复杂度

我们关注当 n 很大时,函数中增长最快的项(最高次项)。2n + 3 的最高次项是 n,其阶数为 1。
因此,该算法的时间复杂度为 O(n)。

分析空间复杂度

空间复杂度分析算法运行所需的额外内存空间。


以下是该算法使用的变量:
A: 一个大小为n的数组。sum: 一个存储结果的变量。i: 一个循环计数器。

假设每个变量占用1个单位空间(sum 和 i 各占1,数组 A 占 n),则总空间函数为 n + 2。
其最高次项同样是 n,因此空间复杂度也为 O(n)。



示例二:矩阵加法算法

接下来,我们分析一个更复杂的算法:将两个 n x n 的矩阵 A 和 B 相加,结果存入矩阵 C。

算法使用两层嵌套循环:
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
C[i][j] = A[i][j] + B[i][j];
}
}


逐步时间分析
- 外层循环
for (i = 0; i < n; i++)的条件- 执行
n + 1次。
- 执行


- 内层循环
for (j = 0; j < n; j++)的条件- 对于外层循环的每一次迭代,内层循环条件都会执行
n + 1次。由于外层循环执行n次,所以该条件总共执行n * (n + 1)次。
- 对于外层循环的每一次迭代,内层循环条件都会执行

- 最内层的赋值语句
C[i][j] = A[i][j] + B[i][j];- 该语句位于两层循环内部,总共执行
n * n = n²次。
- 该语句位于两层循环内部,总共执行


计算时间函数

简化并合并所有执行次数,我们得到主要的时间函数项:
f(n) ≈ n² + ... (其他低阶项)
其中,增长最快的项是 n²。

确定时间复杂度

因此,该矩阵加法算法的时间复杂度为 O(n²)。


分析空间复杂度



以下是该算法使用的变量:
A,B,C: 三个n x n的二维数组,各占n²空间。i,j: 两个循环计数器,各占1空间。


总空间函数约为 3n² + 2。其最高次项是 n²,因此空间复杂度也为 O(n²)。



示例三:矩阵乘法算法
最后,我们分析一个三层嵌套循环的算法:计算两个 n x n 矩阵 A 和 B 的乘积,结果存入矩阵 C。
算法核心结构如下:
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
C[i][j] = 0;
for (k = 0; k < n; k++) {
C[i][j] = C[i][j] + A[i][k] * B[k][j];
}
}
}

逐步时间分析


- 最内层循环
for (k = 0; k < n; k++)及其内部语句- 该循环条件执行
n + 1次,内部赋值语句执行n次。但这对每一对(i, j)都会发生。
- 该循环条件执行

- 中层和内层循环
- 对于外层循环的每个
i,中层循环运行n次。 - 对于中层的每个
j,最内层循环运行n次。 - 因此,最内层的核心乘法语句
C[i][j] = C[i][j] + A[i][k] * B[k][j];总共执行n * n * n = n³次。
- 对于外层循环的每个


计算时间函数

忽略低阶项和常数系数,时间函数主要由 n³ 项主导。
f(n) ≈ 2n³ + 3n² + 2n + 1

确定时间复杂度

最高次项是 n³,因此该矩阵乘法算法的时间复杂度为 O(n³)。



分析空间复杂度


算法使用的变量:
A,B,C: 三个n x n矩阵,占3n²空间。i,j,k: 三个循环计数器,占3空间。


总空间函数约为 3n² + 3。其最高次项是 n²,因此空间复杂度为 O(n²)。注意,虽然时间开销是立方级,但空间开销仍是平方级。




总结 📝

本节课中我们一起学*了频率计数法,这是一种通过统计语句执行次数来分析算法效率的基础方法。

我们通过三个由简到繁的例子实践了该方法:
- 数组求和:时间复杂度
O(n),空间复杂度O(n)。 - 矩阵加法:时间复杂度
O(n²),空间复杂度O(n²)。 - 矩阵乘法:时间复杂度
O(n³),空间复杂度O(n²)。

关键要点在于:
- 为每条语句分配单位时间并计算其执行频率。
- 将频率相加得到时间函数
f(n)。 - 取
f(n)中最高次项(忽略系数和低阶项)来确定算法的时间复杂度。 - 类似地,统计算法使用的所有变量所占空间来确定空间复杂度。

掌握这一方法,你就能对许多简单算法的性能进行量化分析和比较了。在接下来的课程中,我们将看到更多样的算法及其分析方法。
算法导论:1.5.1:时间复杂度分析 #1
在本节课中,我们将学*如何分析代码片段的时间复杂度。我们将通过几个具体的循环示例,展示如何计算语句的执行次数,并推导出其大O表示法。



简单循环分析

首先,我们来看一个简单的 for 循环。我们只关注循环体内的语句执行了多少次。

for (i=0; i<n; i++) {
// 某个语句
}

我们知道,循环条件 i<n 会检查 n+1 次,循环体语句会执行 n 次。在分析时间复杂度时,我们主要关注循环体语句的执行次数,因为它决定了多项式的最高次项。因此,这个循环的时间复杂度是 O(n)。




递减循环

上一节我们分析了递增循环,本节中我们来看看递减循环。循环的方向(递增或递减)不影响其执行次数。


for (i=n; i>0; i--) {
// 某个语句
}

无论是从 1 数到 n,还是从 n 数到 1,都需要 n 步。因此,这个循环的时间复杂度同样是 O(n)。


步长大于1的循环

接下来,我们分析步长不为1的循环。这会影响循环体执行的次数。

for (i=1; i<n; i=i+2) {
// 某个语句
}


在这个循环中,变量 i 每次增加 2。因此,循环体执行的次数大约是 n/2 次。即使步长是其他常数(例如 i=i+5),执行次数也只是 n/5 次。在计算大O复杂度时,常数因子会被忽略。所以,无论步长是多少,只要它是常数,时间复杂度就仍然是 O(n)。




嵌套循环


现在,我们来看一个经典的嵌套循环结构。


for (i=0; i<n; i++) {
for (j=0; j<n; j++) {
// 某个语句
}
}


外层循环执行 n 次。对于外层循环的每一次迭代,内层循环也执行 n 次。因此,内层循环的语句总共执行了 n * n = n² 次。所以,这个嵌套循环的时间复杂度是 O(n²)。


内层循环次数变化的嵌套循环



上一个例子中内层循环次数是固定的。现在我们分析一个内层循环次数会随外层循环变化的例子。
for (i=0; i<n; i++) {
for (j=0; j<i; j++) {
// 某个语句
}
}
为了分析它,我们可以通过追踪变量值来计算语句执行的总次数。
以下是执行过程的追踪:
- 当
i = 0时,内层循环条件j<0不成立,语句执行 0 次。 - 当
i = 1时,内层循环j取值0,语句执行 1 次。 - 当
i = 2时,内层循环j取值0, 1,语句执行 2 次。 - 当
i = 3时,内层循环j取值0, 1, 2,语句执行 3 次。 - ...
- 当
i = n-1时,语句执行 n-1 次。
因此,语句执行的总次数是 0 + 1 + 2 + 3 + ... + (n-1)。


这是一个等差数列求和,其和为:
公式: (n-1)*n / 2 = (n² - n) / 2
这个多项式的最高次项是 n²。所以,尽管这个循环的执行模式与上一个不同,但其时间复杂度仍然是 O(n²)。


条件依赖于累加和的循环


最后,我们分析一个更复杂的循环,其终止条件不是简单的计数器。
p = 0;
for (i=1; p <= n; i++) {
p = p + i;
}
这个循环的终止条件是 p <= n,而 p 是每次迭代中 i 的累加和。我们无法直接看出它执行了多少次。
我们假设循环执行了 k 次后停止。那么:
- 第一次迭代后,
p = 1 - 第二次迭代后,
p = 1 + 2 - 第三次迭代后,
p = 1 + 2 + 3 - ...
- 第
k次迭代后,p = 1 + 2 + 3 + ... + k


p 的值是前 k 个自然数的和:
公式: p = k*(k+1) / 2



循环会在 p > n 时停止。因此,我们可以建立不等式:
公式: k*(k+1) / 2 > n


解这个不等式,k² 是主导项,所以 k 的数量级大约是 √n。因此,这个循环的时间复杂度是 O(√n)。


总结


本节课中我们一起学*了如何分析几种常见循环结构的时间复杂度:
- 简单的单层循环,时间复杂度为 O(n)。
- 步长为常数的循环,时间复杂度仍为 O(n)。
- 两层循环次数固定的嵌套循环,时间复杂度为 O(n²)。
- 内层循环次数依赖于外层的嵌套循环,通过求和公式分析,时间复杂度也为 O(n²)。
- 终止条件依赖于累加值的循环,需要通过建立方程求解执行次数,本例中得到的时间复杂度为 O(√n)。

核心方法是计算关键语句的执行次数,并找出其关于输入规模 n 的函数表达式中增长最快的项(最高次项)。在下一个视频中,我们将看到更多不同类型的代码分析示例。
007:时间复杂度示例 #2 🔍




在本节课中,我们将学*如何分析几种不同循环结构的时间复杂度。我们将重点分析循环变量以乘法方式(如乘以2)或除法方式(如除以2)变化的循环,并与传统的线性递增循环进行对比。理解这些模式是掌握算法分析的关键。



循环变量倍增的循环分析 🚀


上一节我们介绍了线性递增循环的时间复杂度。本节中,我们来看看一个不同的循环结构。在这个代码片段中,循环变量 i 从1开始,每次迭代后乘以2,直到 i 大于等于 n。




for (i = 1; i < n; i = i * 2) {
// 循环体语句
}

我们不能直接说这个循环会执行 n 次,因为 i 的增长是指数级的。我们需要分析它实际执行的次数。




以下是循环变量 i 的变化过程:
- 第一次迭代后:
i = 1 * 2 = 2 - 第二次迭代后:
i = 2 * 2 = 4 - 第三次迭代后:
i = 4 * 2 = 8 - 第
k次迭代后:i = 2^k



循环终止的条件是 i >= n。我们假设在第 k 次迭代后,i 首次大于等于 n。因此,我们可以建立方程:
2^k >= n

为了求解 k,我们取对数:
k = log₂(n)



因此,循环体语句将执行 O(log₂ n) 次。这表明,当循环变量以固定倍数(如2、3等)增长时,时间复杂度是对数级的。


与线性递增循环的对比 📈



为了加深理解,我们可以将倍增循环与标准的线性递增循环进行对比。




对于线性递增循环:
for (i = 1; i <= n; i++) {
// 循环体语句
}
循环变量 i 从1增加到 n,每次增加1。执行次数 k 等于 n,因此时间复杂度是 O(n)。





对于倍增循环:
循环变量 i 从1开始,每次乘以2。经过 k 次乘法后,i 的值变为 2^k。当 2^k >= n 时循环停止,因此 k = log₂ n,时间复杂度是 O(log₂ n)。


这个对比清晰地展示了循环变量的更新方式如何从根本上影响算法的执行次数。





对数时间的取整问题 🔢


由于 log₂ n 可能不是整数(例如 log₂ 10 ≈ 3.32),我们需要确定在实际计算循环次数时,应该向上取整还是向下取整。


让我们通过两个例子来验证:
- 当
n = 8时,i的变化为:1, 2, 4, 8。循环执行了3次。log₂ 8 = 3,恰好是整数。 - 当
n = 10时,i的变化为:1, 2, 4, 8, 16。循环执行了4次。log₂ 10 ≈ 3.32,而实际执行次数是4。
从例子可以看出,为了保证循环能正确终止,我们需要对 log₂ n 的结果进行向上取整 (ceil)。在时间复杂度的大O表示法中,我们通常直接写作 O(log n),隐含了取整操作。
循环变量倍减的循环分析 📉



与倍增循环类似,如果循环变量每次迭代被除以一个常数,时间复杂度同样是对数级的。





考虑以下代码:
for (i = n; i >= 1; i = i / 2) {
// 循环体语句
}



以下是循环变量 i 的变化过程:
- 第一次迭代后:
i = n / 2 - 第二次迭代后:
i = n / 4 - 第
k次迭代后:i = n / (2^k)




循环终止的条件是 i < 1。假设在第 k 次迭代后,i 首次小于1。我们可以建立方程:
n / (2^k) < 1 => 2^k > n => k > log₂ n



因此,这个循环的时间复杂度也是 O(log₂ n)。无论是倍增还是倍减,只要变化是指数级的,时间复杂度就是对数级。



其他循环模式示例 🧩




现在,让我们快速分析几种其他常见的循环模式,以巩固理解。



示例1:循环条件为 i * i <= n
for (i = 0; i * i <= n; i++) {
// 循环体语句
}
循环终止条件是 i² > n,因此 i 大约增长到 √n。时间复杂度为 O(√n)。




示例2:多个独立顺序循环
for (i = 0; i < n; i++) { // 循环A
// 语句
}
for (j = 0; j < n; j++) { // 循环B
// 语句
}
循环A和循环B是顺序执行的,不是嵌套。总时间复杂度为 O(n) + O(n) = O(n)。




示例3:嵌套循环(外层线性,内层对数)
for (i = 0; i < n; i++) { // 外层:O(n)
for (j = 1; j < n; j = j * 2) { // 内层:O(log n)
// 语句
}
}
外层循环执行 n 次,每次内层循环执行 log n 次。总时间复杂度为 O(n log n)。





时间复杂度速查总结 📋




根据以上分析,我们可以总结出一些常见循环模式的时间复杂度公式:





以下是基于循环更新方式的复杂度速查:
- 线性更新(递增或递减):
for(i=0; i<n; i++)或for(i=n; i>0; i--)-> 时间复杂度为 O(n)。- 即使步长不是1(如
i+=2或i-=5),复杂度仍然是 O(n),因为常数倍不影响渐*趋势。
- 即使步长不是1(如
- 指数倍增更新:
for(i=1; i<n; i=i*2)-> 时间复杂度为 O(log n)。- 如果乘以常数
c(如i=i*3),复杂度为 O(log_c n),在大O记法中仍为 O(log n)。
- 如果乘以常数
- 指数倍减更新:
for(i=n; i>1; i=i/2)-> 时间复杂度为 O(log n)。 - 平方根边界:
for(i=0; i*i<n; i++)-> 时间复杂度为 O(√n)。 - 嵌套循环:需要将各层循环的复杂度相乘。
- 例如,外层O(n),内层O(log n),则总复杂度为 O(n log n)。






总结 🎯



本节课中我们一起学*了如何分析非标准循环的时间复杂度。核心在于识别循环变量的更新模式:线性更新导致线性时间 O(n),而几何级数(乘以或除以常数)的更新则导致对数时间 O(log n)。我们还学*了如何处理对数结果的取整问题,并总结了多种常见循环模式的复杂度结论。掌握这些模式是进行算法效率分析的基础。
008:While循环与If语句的时间复杂度分析 🧮













在本节课中,我们将学*如何分析包含 while 循环和 if 条件语句的算法的时间复杂度。我们将通过多个代码示例,理解如何推导这些控制结构的执行次数,并将其转化为大O表示法。




循环结构概述 🔄




在C语言中,主要有三种循环:for、while 和 do-while。do-while 循环至少会执行一次,而 for 和 while 循环在功能上是等价的。任何用 for 循环能实现的功能,用 while 循环也能实现,反之亦然。





然而,在一些早期的编程语言(如Pascal)中,for 循环的语法有所不同,它通常用于线性递增或递减的迭代。例如,在Pascal中,一个从1到N的循环语法是:
for i := 1 to n do
// 一些语句
这种循环的步长通常是1。如果想步长为2,则需要指定 step 2。






While循环与For循环的等价性 ⚖️



在C语言中,for 循环和 while 循环可以相互转换。分析一个简单的 while 循环时,我们需要研究其终止条件,以确定它迭代的次数。








以下是一个简单的 while 循环示例:
i = 0;
while (i < n) {
// 一些语句
i++;
}
这个循环将执行 n 次(当 i 从0到n-1时)。条件检查 i < n 会执行 n+1 次(最后一次为假时退出)。因此,其时间复杂度为 O(n)。






同样的逻辑可以用 for 循环重写:
for (i = 0; i < n; i++) {
// 一些语句
}
其分析结果和时间复杂度与 while 循环版本完全相同。




分析复杂的While循环 🔍






并非所有 while 循环都像计数器那样简单。我们需要根据循环变量的更新规律来推导执行次数。


示例1:指数增长循环
考虑以下循环:
a = 1;
while (a < b) {
// 一些语句
a = a * 2;
}
循环变量 a 每次乘以2。假设循环执行了 k 次后停止,此时 a 的值是 2^k。循环终止的条件是 2^k >= b。解这个不等式,我们得到 k >= log₂(b)。因此,循环执行次数约为 log₂(b)。如果我们把 b 看作输入规模 n,那么时间复杂度就是 O(log n)。





示例2:平方根复杂度循环
分析以下代码:
i = 1;
k = 1;
while (k < n) {
k = k + i;
i++;
}
让我们追踪 k 的值:它依次为 1, 1+2, 1+2+3, ...,即自然数的累加和。执行 m 次后,k 的值约为 m(m+1)/2。循环在 k >= n 时停止,所以我们有 m(m+1)/2 ≈ n,解得 m ≈ √n。因此,这个循环的时间复杂度是 O(√n)。




示例3:最大公约数(GCD)算法
以下是求最大公约数的一个简单算法:
while (m != n) {
if (m > n)
m = m - n;
else
n = n - m;
}
这个循环的执行次数取决于输入 m 和 n 的值。
- 最好情况:如果
m等于n,循环一次也不执行,时间复杂度为 O(1)。 - 最坏情况:例如
m远大于n(如m=16, n=2),循环需要执行约m/2次。因此,最坏情况时间复杂度为 O(n)。








这表明,对于包含条件分支的循环,我们需要考虑最好和最坏情况。




条件语句(If)的时间复杂度分析 ⚡








if 语句本身的时间复杂度通常是 O(1),因为它只进行一次条件判断。然而,它控制着不同时间复杂度的代码块的执行,从而影响了算法的整体时间复杂度分类。






考虑以下示例:
void test(int n) {
if (n < 5) {
printf(“%d”, n); // O(1)
} else {
for (int i = 0; i < n; i++) { // O(n)
// 一些语句
}
}
}
- 当
n < 5时,算法执行 O(1) 的操作。 - 当
n >= 5时,算法执行 O(n) 的操作。








因此,这个算法有:
- 最好情况时间复杂度:O(1)(当
n < 5) - 最坏情况时间复杂度:O(n)(当
n >= 5)







需要注意的是,并非所有 if 语句都会导致不同的复杂度情况。如果 if 和 else 分支的代码块具有相同的时间复杂度等级,那么 if 语句不会引入新的复杂度分类。




总结 📝







本节课我们一起学*了 while 循环和 if 条件语句的时间复杂度分析方法。



- 循环分析核心:分析
while循环的关键是找出循环变量更新的规律,并建立其与终止条件之间的关系式,从而解出循环次数。 - 循环等价性:在C语言中,
for循环和while循环可以相互转换,它们的分析方法本质上是相同的。 - 条件语句的影响:
if语句可能根据条件执行不同时间复杂度的代码块,从而引出了算法的最好情况、最坏情况(以及平均情况)时间复杂度的概念。 - 复杂度表示:无论推导出的具体公式是什么(如
3n+2,log n,√n),我们最终都关注其渐*上界,用大O表示法(如 O(n), O(log n), O(√n))来描述算法的增长率。







掌握这些控制结构的分析方法,是准确评估算法效率的基础。
009:函数复杂度类别 🧮
在本节课中,我们将学*算法分析中常见的几种时间复杂度函数类别。理解这些类别有助于我们快速判断和比较不同算法的效率。


上一节我们介绍了如何用大O记法表示算法的时间复杂度。本节中,我们来看看这些时间复杂度具体对应哪些类型的函数。

常数时间
首先,我们得到的时间复杂度可能是 O(1)。
O(1)


这意味着算法的运行时间是一个常数。无论输入规模 n 如何变化,执行时间都保持不变。例如,时间复杂度是 1、5 或 5000,都属于常数时间。
核心概念:算法的运行时间不依赖于输入规模 n。

对数时间

接下来,我们得到的时间复杂度可能是 O(log n)。

O(\log n)

这被称为对数时间复杂度。无论对数的底数是 2、3 还是其他值,我们都将其归类为对数级。

线性时间
然后,我们得到的时间复杂度可能是 O(n)。

O(n)


这被称为线性时间复杂度。即使函数的具体形式是 f(n) = 2n + 3、f(n) = 500n + 700 或 f(n) = n/5000 + 6,其时间复杂度仍然是 O(n)。
核心概念:我们关注的是多项式函数的最高次项。当最高次项是 n 的一次方时,我们就写作 O(n),而不写出具体的系数或低阶项。

平方时间与立方时间

对于某些算法,我们得到的时间复杂度可能是 O(n²)。

O(n^2)

这被称为平方时间复杂度。例如,矩阵乘法算法的时间复杂度就是 O(n³),即立方时间复杂度。
指数时间


对于另一些算法,你可能会得到像 O(2ⁿ) 这样的时间复杂度。
O(2^n)
这被称为指数时间复杂度。它可能是 2ⁿ、3ⁿ 或 nⁿ 等形式。我们目前尚未详细讨论这类算法,但在后续课程中可能会遇到。

函数类别总结
综上所述,所有的时间复杂度函数都可以归入不同的类别或“函数类”。
以下是常见的时间复杂度类别,从最优到最差排列:
- 常数类:
O(1) - 对数类:
O(log n) - 线性类:
O(n) - 平方类:
O(n²) - 立方类:
O(n³) - 指数类:
O(2ⁿ)


本节课中我们一起学*了算法时间复杂度的主要类别,包括常数时间、对数时间、线性时间、平方时间、立方时间和指数时间。理解这些类别是分析和比较算法效率的基础。
010:函数类比较 📈




在本节课中,我们将学*不同函数增长率的比较。我们将按照增长率从低到高的顺序,排列常见的函数类,并通过具体的数值例子来验证和理解这种排序关系。这对于分析算法的时间复杂度至关重要。




上一节我们介绍了算法分析的基本概念,本节中我们来看看如何比较不同函数的增长速度。




函数类的增长率排序 📊




以下是按照增长率(或称为“阶”)从低到高排列的常见函数类:




- 常数:
1 - 对数:
log n - 线性:
n - 线性对数:
n log n - 多项式:
n^2,n^3, ...,n^k(k为常数) - 指数:
2^n,3^n, ...

这意味着,对于足够大的 n 值,以下不等式关系成立:
1 < log n < n < n log n < n^2 < n^3 < ... < 2^n < 3^n < ...




通过数值验证排序 🔍



为了证明上述排序关系,我们选取几个代表性的函数进行数值比较:log n、n、n^2 和 2^n。

以下是不同 n 值下这些函数的计算结果:




- 当
n = 1时:log 1 = 0n = 1n^2 = 12^n = 2
- 当
n = 2时:log 2 ≈ 1n = 2n^2 = 42^n = 4
- 当
n = 4时:log 4 = 2n = 4n^2 = 162^n = 16
- 当
n = 8时:log 8 = 3n = 8n^2 = 642^n = 256


从这些数值中,我们可以观察到增长率的显著差异。在 n=8 时,2^n 的值出现了爆发式增长,远远超过了其他函数。这表明,指数函数 2^n 的增长率最终会超过任何多项式函数 n^k。


理解排序的关键点 💡




需要理解几个关键点:



- “最终”成立:这种排序关系是针对“足够大”的
n而言的。在n值较小时,可能会出现n^2等于甚至小于2^n的情况(例如n=2或n=4)。但从某个临界点开始,2^n将永远以更快的速度增长。 - 图像表示:如果将这些函数绘制成图,
log n的曲线最平缓,n是一条斜线,n^2是抛物线,而2^n则是一条急速上升的曲线。指数函数的图像最终会超越所有多项式函数。 - 核心结论:任何多项式函数
n^k(k为常数)的增长率都小于任何指数函数a^n(a>1)。这是算法分析中一个非常重要的不等式。


总结 📝




本节课中我们一起学*了函数增长率的比较。我们按照从慢到快的顺序排列了常见的函数类:常数 < 对数 < 线性 < 线性对数 < 多项式 < 指数。我们通过具体的数值计算验证了这一排序,并理解了指数函数 2^n 具有“压倒性”的增长率,最终会超过任何多项式函数 n^k。掌握这个函数阶的层次关系,是分析和比较不同算法效率的基础。
011:渐进符号(大O、Ω、Θ)详解 🧮
在本节课中,我们将要学*算法分析中一个至关重要的数学工具——渐进符号。这些符号用于简洁地表示函数的增长级别,帮助我们理解和比较不同算法的时间复杂度,而无需每次都写出复杂的函数表达式。
什么是渐进符号?📈



上一节我们提到了用函数表示时间复杂度。本节中我们来看看如何用更简洁的方式描述这些函数。渐进符号来源于数学,用于表示函数的“类别”或“级别”。它可以帮助我们比较函数的增长速率,并以一种简单、可交流的形式呈现算法的复杂度。




以下是三种核心的渐进符号:


- 大O符号 (Big O Notation): 这表示函数的上界。
- Ω符号 (Omega Notation): 这表示函数的下界。
- Θ符号 (Theta Notation): 这表示函数的紧确界(平均界)。


任何函数都可以用其上界、下界或紧确界来表示。其中,Θ符号最为精确和有用。当我们无法精确确定一个函数的Θ表示时,可以使用大O(上界)或Ω(下界)来描述它。
大O符号 (Big O) - 上界 🎯




大O符号用于描述函数增长速率的上限。其定义如下:




定义:函数 f(n) = O(g(n)),当且仅当存在正常数 c 和 n0,使得对于所有 n ≥ n0,都有:
f(n) ≤ c * g(n)


这意味着,当 n 足够大时,f(n) 的增长不会超过 g(n) 乘以某个常数因子。

理解与应用


让我们通过一个例子来理解。假设有一个函数 f(n) = 2n + 3。




我们需要找到一个更简单的函数 g(n) 和一个常数 c,使得 2n + 3 ≤ c * g(n) 对于足够大的 n 成立。


一个简单的方法是:将 f(n) 中的所有项都转换为 n 的倍数。这里,2n + 3 可以放大为 2n + 3n = 5n。因此:
2n + 3 ≤ 5n 对于所有 n ≥ 1 成立。

在这里,c = 5,g(n) = n。所以我们可以说:f(n) = O(n)。


值得注意的是,上界不是唯一的。对于同一个 f(n),我们也可以说:
2n + 3 ≤ 5n² 对于所有 n ≥ 1 成立。
因此,f(n) = O(n²) 同样正确。



以下是关于大O符号的一些要点:



f(n) = O(n)和f(n) = O(n²)都正确,因为n和n²都是2n+3的上界。- 我们应尽量使用最“紧”的上界。
O(n)比O(n²)更精确地描述了f(n)的增长。 - 不能使用比原函数增长更慢的函数作为上界。例如,
f(n) = O(log n)是错误的,因为对于大的n,2n+3最终会超过任何常数倍的log n。

Ω符号 (Omega) - 下界 ⚖️




Ω符号用于描述函数增长速率的下限。其定义与大O符号对称:


定义:函数 f(n) = Ω(g(n)),当且仅当存在正常数 c 和 n0,使得对于所有 n ≥ n0,都有:
f(n) ≥ c * g(n)


这意味着,当 n 足够大时,f(n) 的增长至少和 g(n) 乘以某个常数因子一样快。


理解与应用


我们继续使用函数 f(n) = 2n + 3。



我们需要找到一个函数 g(n) 和一个常数 c,使得 2n + 3 ≥ c * g(n)。一个简单的方法是直接取 g(n) = n,并令 c = 1。那么:
2n + 3 ≥ 1 * n 对于所有 n ≥ 0 成立。
因此,f(n) = Ω(n)。
同样,下界也不是唯一的。因为 2n+3 的增长比对数函数快,所以我们也可以说 f(n) = Ω(log n),这也是正确的。



以下是关于Ω符号的要点:


f(n) = Ω(n)和f(n) = Ω(log n)都正确,因为n和log n都是2n+3的下界。- 我们应尽量使用最“紧”的下界。
Ω(n)比Ω(log n)更精确。 - 不能使用比原函数增长更快的函数作为下界。例如,
f(n) = Ω(n²)是错误的,因为2n+3无法达到n²的增长级别。

Θ符号 (Theta) - 紧确界 ⚡

Θ符号结合了上界和下界,用于精确地描述函数的增长级别。当一个函数既是 O(g(n)) 又是 Ω(g(n)) 时,我们就可以用 Θ(g(n)) 来表示。





定义:函数 f(n) = Θ(g(n)),当且仅当存在正常数 c1, c2 和 n0,使得对于所有 n ≥ n0,都有:
c1 * g(n) ≤ f(n) ≤ c2 * g(n)



这意味着,f(n) 被夹在两个常数倍的 g(n) 之间,因此它与 g(n) 具有相同的增长阶数。

理解与应用


再次使用 f(n) = 2n + 3。
我们已经找到:
- 下界:
1 * n ≤ 2n + 3(c1 = 1,g(n) = n) - 上界:
2n + 3 ≤ 5 * n(c2 = 5,g(n) = n)



将两者结合,得到:1*n ≤ 2n+3 ≤ 5*n 对于足够大的 n 成立。
因此,f(n) = Θ(n)。


对于Θ符号,有严格的限制:


g(n)必须是同一个函数。上界和下界必须使用相同的g(n)(此处为n)。- 表示是唯一的。对于
f(n)=2n+3,我们只能说Θ(n)。说Θ(n²)或Θ(log n)都是错误的,因为它们无法同时满足上下界的定义。 - 这是最理想的表示,因为它精确地刻画了函数的增长行为。在算法分析中,应尽可能使用Θ表示法。




重要注意事项 ⚠️


最后,必须澄清一个常见的误解:
渐进符号(大O、Ω、Θ)与算法的最好情况、最坏情况分析没有直接关系。



很多人错误地认为:
- 大O表示最坏情况。
- Ω表示最好情况。
- Θ表示平均情况。

这是不正确的。 这些符号仅仅是用来描述函数边界的数学工具。你可以用大O来描述一个算法最好情况时间复杂度的上界,也可以用Ω来描述最坏情况时间复杂度的下界。案例(最好、最坏、平均)分析与边界(上界、下界、紧确界)分析是两个不同的维度,不应混淆。



本节课中我们一起学*了三种核心的渐进符号:大O(上界)、Ω(下界)和Θ(紧确界)。我们理解了它们的数学定义,并通过例子 f(n)=2n+3 掌握了如何应用它们来简化函数的表示。记住,Θ符号是最精确的,而大O和Ω在无法确定紧确界时也很有用。正确理解这些符号是进行算法复杂度分析的基础。
算法导论:1.8.2:渐进符号(大O、Ω、Θ)示例详解 🧮






在本节课中,我们将通过具体的函数示例,深入学*如何应用大O、Ω和Θ这三种渐进符号来分析算法的时间复杂度。我们将看到如何为不同的函数找到其上界、下界和紧确界。



上一节我们介绍了渐进符号的基本定义,本节中我们来看看如何将这些定义应用到具体的函数上。






示例一:多项式函数




考虑函数 f(n) = 2n² + 3n + 4。

为了找到其上界(大O表示法),我们可以将其与一个更大的函数进行比较。例如:
- 2n² + 3n + 4 ≤ 2n² + 3n² + 4n² (当 n ≥ 1 时)
- 即 2n² + 3n + 4 ≤ 9n²




因此,存在常数 c = 9 和 g(n) = n²,使得对于所有足够大的 n,都有 f(n) ≤ c * g(n)。所以,我们可以说:
f(n) = O(n²)


对于同一个函数,我们也可以找到其下界(Ω表示法):
- 2n² + 3n + 4 ≥ 1 * n² (当 n ≥ 1 时)


因此,存在常数 c = 1 和 g(n) = n²,使得对于所有足够大的 n,都有 f(n) ≥ c * g(n)。所以,我们可以说:
f(n) = Ω(n²)




由于我们同时找到了相同的上界和下界函数(n²),这意味着该函数的增长率被 n² 紧密地限定。因此,我们可以得到其紧确界(Θ表示法):
f(n) = Θ(n²)





示例二:对数与多项式组合函数


考虑函数 f(n) = n² log n + n。



以下是分析步骤:
- 首先,n² log n + n ≤ 10 * n² log n (当 n 足够大时)。所以,f(n) = O(n² log n)。
- 其次,n² log n + n ≥ 1 * n² log n (当 n 足够大时)。所以,f(n) = Ω(n² log n)。
- 由于上下界函数相同,我们可以得出:f(n) = Θ(n² log n)。



值得注意的是,n² log n 的增长率介于 n² 和 n³ 之间。对于此类函数,我们可以在渐进阶的图表中为其指定一个明确的位置。




示例三:阶乘函数




考虑函数 f(n) = n!。

阶乘函数可以展开为:n! = n * (n-1) * (n-2) * ... * 3 * 2 * 1。




为了分析其渐进性,我们尝试寻找边界:
- 一个非常宽松的下界是:n! ≥ 1 * 1 * 1 * ... * 1 = 1。所以,f(n) = Ω(1)。
- 一个非常宽松的上界是:n! ≤ n * n * n * ... * n = nⁿ。所以,f(n) = O(nⁿ)。



这里的关键问题是,我们无法找到一个像 n² 或 n³ 这样的“简单”函数 g(n),使得 n! 既能被 c₁ * g(n) 从下方限定,又能被 c₂ * g(n) 从上方限定(即找到 Θ 界)。n! 的增长速度极快,对于较小的 n 值,它可能接*下界,但对于较大的 n,它会迅速趋*于 nⁿ 这样的上界。



因此,对于 n!,我们无法定义一个紧确的 Θ 界。我们只能给出其非常宽泛的上界和下界。






示例四:阶乘的对数
考虑函数 f(n) = log(n!)。



利用对数的性质,我们可以进行分析:
- log(n!) = log(1 * 2 * 3 * ... * n) = log 1 + log 2 + ... + log n。
- 一个宽松的上界是:log(n!) ≤ log(n * n * n * ... * n) = log(nⁿ) = n log n。所以,f(n) = O(n log n)。
- 一个宽松的下界是:log(n!) ≥ log(1 * 1 * 1 * ... * 1) = 0,但通常我们写作 ≥ 1。所以,f(n) = Ω(1)。
与 n! 类似,log(n!) 也无法找到一个紧确的 Θ 界。
如何选择使用哪种符号?





以下是关于何时使用大O、Ω和Θ符号的指导原则:

- 优先使用 Θ:如果你能为一个函数找到 Θ 界,这通常是最好的选择。因为它精确地描述了函数的增长率,是一个“紧确界”。这就像有人问手机的价格,你回答“大约2万元”,给出了最接*的估计。
- 使用大O(上界)时:应尽量给出一个“紧”的上界。不要随意给出一个过大、不具参考价值的上界(例如,对于 n² 的函数,说它是 O(n¹⁰) 虽然正确但无意义)。这就像回答手机最高价格时说“最高20万”,虽然正确,但对提问者没有帮助。
- 使用 Ω(下界)时:同样,应尽量给出一个“紧”的下界。避免给出过小、不具代表性的下界。


大O 和 Ω 表示法在无法确定紧确界时非常有用(如阶乘函数)。它们表示“最多不会超过”或“至少需要”的量级。而 Θ 则保证了“就是这么多”的精确性。



本节课中我们一起学*了如何为不同类型的函数(多项式、对数组合、阶乘)应用大O、Ω和Θ符号进行分析。我们了解到,Θ 是描述算法时间复杂度的最精确方式,应优先寻找。当无法找到 Θ 时,大O 和 Ω 分别提供了有用的上界和下界信息,但应尽可能使其“紧确”以提供有意义的参考。理解这些符号的应用是分析算法效率的基础。在接下来的视频中,我们将学*渐进符号的一些重要性质。
013:渐*符号的性质 📊






在本节课中,我们将学*渐*符号(Big O, Omega, Theta)的几个重要数学性质。理解这些性质有助于我们更严谨地分析和比较算法的复杂度。



一般性质



上一节我们介绍了渐*符号的基本概念,本节中我们来看看它们的一些通用性质。


常数乘法性质



如果一个函数 f(n) 是 O(g(n)),那么将该函数乘以一个正常数 c 后,其渐*复杂度不变。
公式:如果 f(n) = O(g(n)),那么 c * f(n) = O(g(n)),其中 c > 0。



例如,假设 f(n) = 2n² + 5,并且已知 f(n) = O(n²)。那么 7 * f(n) = 7*(2n²+5) = 14n² + 35 也同样是 O(n²)。因为函数的阶数(最高次项)没有改变,所以其渐*复杂度不变。



这个性质不仅适用于大O符号,也适用于Omega和Theta符号。



公式:
- 如果
f(n) = Ω(g(n)),那么c * f(n) = Ω(g(n))。 - 如果
f(n) = Θ(g(n)),那么c * f(n) = Θ(g(n))。



自反性
一个函数是其自身的上界(对于大O符号)和下界(对于Omega符号)。

公式:f(n) = O(f(n)) 且 f(n) = Ω(f(n))。


这意味着,对于任意函数 f(n),它本身就是一个有效的上界(大于或等于自身)和下界(小于或等于自身)。例如,n² = O(n²) 是成立的。


传递性



传递性是指,如果函数 f 的上界是 g,而 g 的上界是 h,那么 h 也是 f 的上界。



公式:如果 f(n) = O(g(n)) 且 g(n) = O(h(n)),那么 f(n) = O(h(n))。



让我们通过一个例子来理解:
- 设
f(n) = n - 设
g(n) = n² - 设
h(n) = n³


我们知道 n = O(n²) 且 n² = O(n³)。根据传递性,可以得出 n = O(n³)。这个性质同样适用于Omega和Theta符号。
对称性



对称性特指Theta符号。如果两个函数互为Theta关系,那么这种关系是对称的。



公式:如果 f(n) = Θ(g(n)),那么 g(n) = Θ(f(n))。



例如,如果 f(n) = n² 且 g(n) = n²,那么 f(n) = Θ(n²) 且 g(n) = Θ(n²),两者互为Theta关系。这个性质在大O和Omega符号中不成立,它们具有的是转置对称性。
转置对称性

转置对称性描述了大O和Omega符号之间的关系:如果一个函数是另一个函数的上界,那么反过来,后者就是前者的下界。




公式:如果 f(n) = O(g(n)),那么 g(n) = Ω(f(n))。



例如:
- 设
f(n) = n - 设
g(n) = n²
因为 n = O(n²)(n是n²的上界),所以反过来,n² = Ω(n)(n²是n的下界)。



夹逼定理与Theta关系

如果一个函数同时被同一个函数 g(n) 从上方(大O)和下方(Omega)界定,那么该函数就是 g(n) 的Theta。

公式:如果 f(n) = O(g(n)) 且 f(n) = Ω(g(n)),那么 f(n) = Θ(g(n))。



这直观地说明了Theta符号的含义:它描述了两个函数增长速率“相等”的情况,即一个函数被另一个函数紧密地夹在中间。

函数相加的性质


以下是两个函数相加时的渐*复杂度规则。

如果已知 f(n) = O(g(n)) 且 d(n) = O(e(n)),那么两个函数之和的复杂度由其中增长更快的项决定。




公式:f(n) + d(n) = O( max( g(n), e(n) ) )


让我们看一个例子:
- 设
f(n) = n,则f(n) = O(n) - 设
d(n) = n²,则d(n) = O(n²) - 那么
f(n) + d(n) = n + n²
由于 n² 是增长更快的项,所以 n + n² = O(n²),即 O( max(n, n²) )。


函数相乘的性质


以下是两个函数相乘时的渐*复杂度规则。

如果 f(n) = O(g(n)) 且 d(n) = O(e(n)),那么两个函数之积的复杂度是它们各自复杂度的乘积。



公式:f(n) * d(n) = O( g(n) * e(n) )

例如:
- 设
f(n) = n,则f(n) = O(n) - 设
d(n) = n²,则d(n) = O(n²) - 那么
f(n) * d(n) = n * n² = n³ - 根据性质,
n * n² = O(n * n²) = O(n³),与直接计算结果一致。


总结 🎯


本节课中我们一起学*了渐*符号的七个核心性质:
- 常数乘法性质:乘以常数不改变渐*阶。
- 自反性:函数是其自身的上界和下界。
- 传递性:上界关系可以传递。
- 对称性:Theta关系是对称的。
- 转置对称性:大O和Omega符号互为转置。
- 夹逼定理:被同一函数同时从上和下界定,则构成Theta关系。
- 和与积的性质:和的复杂度取最大项,积的复杂度为复杂度相乘。



掌握这些性质是进行严谨算法复杂度分析的基础,它们帮助我们简化比较过程并得出可靠的结论。
014:函数比较方法 #1 📊



在本节课中,我们将学*如何比较两个函数。这些函数可能是描述算法时间复杂度的函数,也可能是描述空间复杂度的函数。比较它们的大小,有助于我们确定哪个函数是上界,哪个是下界。




函数比较的基本概念



函数是数学函数。假设我们有两个函数,一个是 n²,另一个是 n³。我们想比较它们,以确定哪个更大,哪个更小,从而判断哪个是上界,哪个是下界。



对于这个简单的例子,我们无需计算就能知道 n² 小于 n³。但为了系统地比较,我们需要掌握一些方法。



方法一:采样值比较法




第一种比较方法是采样一些具体的输入值(n),计算两个函数在这些点上的值,然后进行比较。




以下是具体步骤:
- 选择几个n值(例如2, 3, 4)。
- 分别计算两个函数在这些n值下的结果。
- 观察并总结大小关系。



例如,比较 f(n) = n² 和 g(n) = n³:
- 当 n=2 时,f(2)=4,g(2)=8。4 < 8。
- 当 n=3 时,f(3)=9,g(3)=27。9 < 27。
- 当 n=4 时,f(4)=16,g(4)=64。16 < 64。


通过采样三个值,我们可以观察到 n² 始终小于 n³。因此,n² 是更小的函数。



这是一种通过采样具体数值来比较两个函数大小的方法。





方法二:对数比较法



第二种方法是对等式两边同时取对数,然后比较对数结果的大小。这对于比较复杂的函数特别有用。




我们继续使用 n² 和 n³ 的例子来演示这个方法:
- 对两个函数取对数:
log(n²)和log(n³)。 - 应用对数公式进行化简:
log(n²) = 2 * log(n),log(n³) = 3 * log(n)。 - 比较化简后的结果:
2 * log(n)和3 * log(n)。显然,对于任意 n>1,3 * log(n)都大于2 * log(n)。



因此,我们再次得出结论:n³ 大于 n²。



为了熟练运用对数比较法,我们需要掌握一些基本的对数公式。
必备对数公式






以下是进行比较时常用的对数公式:
log(a * b) = log(a) + log(b)log(a / b) = log(a) - log(b)log(aᵇ) = b * log(a)- 换底公式:
logₐ(b) = logₓ(b) / logₓ(a) - 若
aᵇ = n,则b = logₐ(n)


掌握这些公式将有助于我们解决涉及对数的比较问题。



综合应用示例



现在,让我们运用这些方法来比较一些更复杂的函数。

示例一:比较 f(n) = n² * log(n) 和 g(n) = n * (log n)¹⁰



仅凭观察难以判断。我们使用对数比较法:
- 对两个函数取对数:
log(f(n)) = log(n² * log(n))log(g(n)) = log(n * (log n)¹⁰)
- 应用对数公式展开:
log(f(n)) = log(n²) + log(log(n)) = 2*log(n) + log(log(n))log(g(n)) = log(n) + log((log n)¹⁰) = log(n) + 10*log(log(n))
- 比较主要项:
2*log(n)与log(n)相比,前者更大;log(log(n))与10*log(log(n))相比,后者更大。但log(n)的增长速度远快于log(log(n)),因此起主导作用的是包含log(n)的项。由于2*log(n) > log(n),我们可以判断log(f(n)) > log(g(n)),所以 f(n) > g(n)。


示例二:比较 f(n) = 3^√n 和 g(n) = 2^(√n * log n)



这次我们不直接取对数,而是利用对数公式进行变换:
- 注意到
g(n) = 2^(√n * log n)。根据公式a^(logₐ b) = b,我们可以将n写作2^(log₂ n)。但这需要更巧妙的变换。一个有效方法是直接比较log(f(n))和log(g(n)):log(f(n)) = log(3^√n) = √n * log 3log(g(n)) = log(2^(√n * log n)) = √n * log n * log 2 = √n * log n * 1(因为log₂2=1)
- 现在比较
√n * log 3和√n * log n。当 n 很大时,log n会超过常数log 3。因此,对于足够大的 n,有log(g(n)) > log(f(n)),所以 g(n) > f(n)。


示例三:理解“渐进相等”





考虑 f(n) = 2n² 和 g(n) = n²。谁更大?
- 从具体数值看,
2n²总是n²的两倍,所以2n²更大。 - 但从渐进分析(Asymptotic Analysis)的角度看,我们关注的是函数的增长阶(Order)。
2n²和n²的增长阶都是 n²。在讨论大O表示法(例如 O(n²))时,常数系数(如2)被忽略,我们认为它们渐进相等。
总结


本节课我们一起学*了比较函数的两种核心方法:
- 采样值比较法:通过计算函数在具体输入点的值进行直观比较,适用于简单或验证场景。
- 对数比较法:对函数同时取对数并利用对数公式化简后再比较,是处理复杂函数比较的强有力工具。

我们还回顾了关键的对数运算公式,并通过实例演示了如何运用这些方法判断函数的大小关系,以及理解了在渐进分析中“系数可忽略”的概念。掌握这些基础,对于后续分析算法复杂度至关重要。
015:函数比较方法 #2 🔍


在本节课中,我们将学*如何比较两个函数的增长速率,这是算法分析中的核心技能。我们将通过具体的例子,掌握使用对数变换来简化比较过程的方法,并理解其中的关键注意事项。



概述 📋

上一节我们介绍了函数比较的基本概念。本节中,我们来看看如何通过应用对数(log)来比较复杂的函数,特别是当直接比较有困难时。我们将学*一个实用的技巧:对函数两边同时取对数,以简化表达式,从而更容易判断哪个函数增长更快。
函数比较与对数应用

现在,我们来比较这两个函数。为了判断哪个更大,我将对两边应用对数。


原始函数是:
f(n) = log(n)g(n) = sqrt(log(n))(即(log n)^(1/2))

对两边取对数:
log(f(n)) = log(log n)log(g(n)) = log( (log n)^(1/2) ) = (1/2) * log(log n)(应用对数幂法则log(a^b) = b * log a)



现在,判断哪个更小。log(log n) 比 log n 小。是的,log(log n) 比 log n 小。
通过一次取对数,如果我们仍然无法判断,可以再次应用对数。


这里的要点是,我们需要判断哪个函数更小,哪个更大。为了简化,我们应用对数。这不是一个固定的规则或公式,只是一个技巧:应用对数来简化表达式,以便更容易比较。
在这个例子中,应用一次对数后我们仍无法判断,所以需要再应用一次。
再次取对数后,我们得到 log(log(log n)) 和 log((1/2) * log(log n))。显然,log n 的增长速度远大于 log(log n),因此可以判断原函数 f(n) = log n 的增长速度大于 g(n) = sqrt(log n)。





比较中的关键注意事项 ⚠️

接下来,我们看另一个比较,并对两边应用对数。


比较函数:
f1(n) = nf2(n) = 2n


对两边取对数:
log(f1(n)) = log nlog(f2(n)) = log(2n) = log 2 + log n = 1 + log n(因为log2 2 = 1)




现在,哪个更大?注意,不要说它们相等。我们已经应用了对数进行变换。现在是为了比较。
在原始函数中,你可以忽略常数系数(即 n 和 2n 在渐进意义下相同)。但在应用对数之后,为了比较目的,你不能直接去掉系数。
所以,1 + log n 大于 log n。因此,f2(n) 大于 f1(n)。从这里可以清楚地看出。


我举这个例子是为了让你明白:在对函数取对数之后,不要去掉系数。在之前的例子中我那样做过,但那时我没有应用对数。现在应用对数后,我改变了函数形式。如果此时去掉系数,会得到不同的(错误的)答案。直接从原始函数可以去掉系数进行比较,但在对数变换后的表达式中不行。



处理分段函数 📊


让我们比较两个分段函数,g1(n) 和 g2(n),看看哪个更大。



函数定义如下:
g1(n) = n^3(当n < 100),g1(n) = n^2(当n >= 100)g2(n) = n^2(当n < 10000),g2(n) = n^3(当n >= 10000)



这里不是单一的表达式。对于不同的 n 值,函数行为不同。
如果我们取 n 的值,从 0 到 10000 来看:
- 在
n < 100时,g1是n^3,g2是n^2,所以g1更大。 - 在
100 <= n < 10000时,g1是n^2,g2也是n^2,所以两者相等(g1 = g2)。 - 在
n >= 10000时,g1保持为n^2,而g2变为n^3,所以g2更大。
那么,哪个函数总体上更大?g2 并不总是更大。在 n < 100 时,g1 更大。但从 10000 开始,g2 总是更大。在 100 到 10000 之间,它们相等。



请记住这一点:当我们说某个函数大于另一个函数时,我们不需要考虑 n 很小时的值。它可能从非常大的 n 值开始才更大,但从那个点开始,如果它一直更大,那么我们就认为那个函数更大。
我们不会仅仅检查较小的 n 值就为所有 n 值下结论。“为所有 n 值决定”意味着直到无穷。从 10000 到无穷大,g2 总是大于 g1。

渐进符号判断题 ✅❌


这里给出了一些涉及渐进符号的陈述和比较,我们需要判断这些陈述是对是错。


以下是判断过程:




(n + k)^m = Θ(n^m)是否正确?
正确。展开(n+k)^m(二项式定理),最高阶项是n^m。例如,(n+3)^2的最高阶项是n^2。所以它是Θ(n^m)。


2^(n+1) = O(2^n)是否正确?
正确。2^(n+1) = 2 * 2^n。常数系数2在渐进分析中可以忽略,它属于O(2^n),也属于Θ(2^n)和Ω(2^n)。


-
2^(2n) = O(2^n)是否正确?
错误。2^(2n) = (2^2)^n = 4^n。4^n的增长速度远快于2^n。2^n不可能是4^n的上界。这应该是O(4^n),而不是O(2^n)。 -
log(log n) = O(log n)是否正确?
正确。对两边取对数:log(log(log n))和log(log n)。显然log(log n)增长更慢,所以log(log n)是O(log n)。



(log n)^(log n) = O(n)是否正确?
正确。对两边取对数:log( (log n)^(log n) ) = log n * log(log n),右边是log n。log n * log(log n)的增长速度慢于n,所以原式成立。
总结 🎯



本节课中,我们一起学*了通过取对数来比较函数增长速率的高级技巧。我们掌握了几个关键点:
- 当直接比较困难时,对函数两边取对数是一个有效的简化方法。
- 在对数变换后的表达式中进行比较时,不能忽略常数系数,这与直接比较原始渐进函数不同。
- 在判断函数
f(n)是否大于g(n)时,我们关注的是当n趋向于无穷大时的趋势,而不是某个有限区间内的行为。 - 我们通过一系列例题和判断题巩固了对渐进符号(
O,Θ,Ω)的理解和应用。

通过这些练*,你应该对如何解决函数比较和渐进符号相关问题有了清晰的思路。如果多加练*,你将能够解决任何类型的相关问题。



至此,我们完成了算法课程的第一部分——算法基础、分析与渐进符号。在接下来的章节中,我们将开始学*算法设计策略,例如分治法和贪心算法。
016:最好、最坏与平均情况分析 📊



在本节课中,我们将学*算法的最好情况、最坏情况和平均情况分析。我们将通过线性搜索和二叉搜索树这两个具体例子,来理解这些概念的含义、如何计算它们的时间复杂度,以及如何用渐*记号来描述它们。


线性搜索算法 🔍
上一节我们介绍了课程概述,本节中我们来看看第一个例子:线性搜索。

线性搜索是一种基础的搜索方法。当给定一组元素列表时,如果我们要在该列表中搜索某个特定元素(称为“键”),线性搜索会从列表左侧开始,逐个元素地进行比较。


以下是线性搜索的工作原理:
- 假设我们有一个包含10个元素的列表:
[8, 3, 6, 1, 7, 9, 4, 2, 18, 5]。 - 如果要搜索键
7,算法会从第一个元素8开始检查。 - 检查
8是否等于7?否,则移动到下一个元素3。 - 重复此过程,直到在索引5(从0开始计数)找到
7。总共进行了6次比较。 - 如果要搜索的键(例如
20)不在列表中,算法会检查完所有n个元素后,宣告搜索失败。


因此,线性搜索的结果可能是成功的(找到元素),也可能是不成功的(未找到元素)。



线性搜索的情况分析 📈



理解了线性搜索的基本操作后,我们来分析它在不同情况下的性能。





最好情况与最好情况时间



最好情况是指算法执行时间最短的场景。对于线性搜索,最好情况发生在要搜索的键元素恰好位于列表的第一个索引位置时。


例如,在上述列表中搜索第一个元素 8。算法只需进行一次比较就能找到它。




因此,对于 n 个元素的列表,线性搜索的最好情况时间是常数时间。我们可以用渐*记号表示为:
最好情况时间:B(n) = O(1)


最坏情况与最坏情况时间


最坏情况是指算法执行时间最长的场景。对于线性搜索,最坏情况发生在以下两种情形:
- 要搜索的键元素位于列表的最后一个索引位置。
- 要搜索的键元素根本不在列表中。
例如,搜索最后一个元素 18 或不在列表中的 20。这两种情况都需要检查全部 n 个元素。





因此,对于 n 个元素的列表,线性搜索的最坏情况时间与元素数量 n 成正比。我们可以用渐*记号表示为:
最坏情况时间:W(n) = O(n)
平均情况分析

平均情况分析考虑了所有可能出现的输入,并计算其平均执行时间。其定义为:
平均情况时间 = (所有可能情况的时间总和) / (情况总数)

对于线性搜索,可能的情况是:键元素出现在索引0、索引1、索引2……直到索引 n-1(共n种成功情况)。每种情况所需的比较次数分别为1, 2, 3, ..., n。



计算平均情况时间:
- 时间总和:
1 + 2 + 3 + ... + n = n(n+1)/2 - 情况总数:
n - 平均情况时间:
[n(n+1)/2] / n = (n+1)/2



这个结果 (n+1)/2 仍然属于线性阶。因此,线性搜索的平均情况时间也可以表示为 O(n)。



平均情况分析通常比较复杂,并非对所有算法都可行。由于平均情况时间往往与最坏情况时间同阶,因此我们通常更关注算法的最坏情况时间复杂度。




渐*记号与情况分析的关系 🔗



我们之前已经学*了渐*记号(Big O, Ω, Θ),现在需要明确它们与情况分析的关系。这是一个常见的误解。



核心概念是:渐*记号用于描述函数的增长阶,而情况分析(最好、最坏、平均)是算法分析的类型。两者没有固定的绑定关系。

对于线性搜索:
- 最好情况时间函数是常数
f(n) = 1。我们可以用多种记号描述它:O(1)(这是一个上界,当然也成立)Ω(1)(这是一个下界)Θ(1)(这是精确的阶)
- 最坏情况时间函数是线性
f(n) = n。我们同样可以用多种记号描述它:O(n)Ω(n)Θ(n)
不要错误地认为 Big O 只能用于最坏情况,Ω 只能用于最好情况。 你可以使用任何一种渐*记号来描述任何一种情况分析的结果,只要它正确地刻画了该情况下时间函数的增长阶。



二叉搜索树的情况分析 🌳




接下来,我们通过第二个例子——二叉搜索树(BST)——来巩固情况分析的概念。



二叉搜索树是一种用于高效搜索的数据结构。其特性是:对于任意节点,其左子树中的所有元素都小于该节点,右子树中的所有元素都大于该节点。搜索时,从根节点开始,根据比较结果决定向左或向右子树移动。





假设我们有一个高度平衡的BST(元素:5, 10, 15, 20, 25, 30, 40),其高度约为 log n。搜索元素 15 大约需要 log n 次比较。




最好情况与最坏情况





对于二叉搜索树的搜索操作:
- 最好情况:要搜索的键元素正好是根节点。此时只需一次比较。
- 最好情况时间:
O(1)
- 最好情况时间:
- 最坏情况:要搜索的键元素位于某个叶子节点。此时需要从根节点走到叶子节点。
- 最坏情况时间:这取决于树的高度
h。
- 最坏情况时间:这取决于树的高度



理解最坏情况时间的范围



这是关键的一点:二叉搜索树的最坏情况时间取决于树的高度 h,而 h 本身是有范围的。

考虑两种极端的BST,它们包含相同的元素:
- 高度平衡的BST:树的高度最小,约为
h_min ≈ log n。 - 左倾(或右倾)的BST(退化成一个链表):树的高度最大,为
h_max = n。




因此,对于二叉搜索树的搜索操作:
- 最小化的最坏情况时间(当树平衡时):
O(log n) - 最大化的最坏情况时间(当树退化成链表时):
O(n)


所以,当我们说二叉搜索树搜索的“最坏情况时间”时,需要明确树的结构。它可以在 O(log n) 到 O(n) 之间变化。这解释了为什么保持二叉搜索树的平衡(例如使用AVL树、红黑树)如此重要——目的是将最坏情况时间控制在 O(log n)。




与线性搜索一样,我们也可以用任意的渐*记号(O, Ω, Θ)来描述二叉搜索树在各种情况下的时间复杂度。

总结 🎯




本节课中我们一起学*了算法分析中三个核心概念:
- 最好情况分析:关注算法在最优输入下的最小运行时间。例如,线性搜索中键在首位,BST搜索中键在根节点。
- 最坏情况分析:关注算法在最差输入下的最大运行时间。这为我们提供了算法性能的保证,是最常用的分析指标。例如,线性搜索中键在末尾或不存在,BST搜索中键在叶子节点。
- 平均情况分析:考虑所有可能输入的平均性能。计算较为复杂,且通常与最坏情况同阶。
我们明确了渐*记号(Big O, Ω, Θ)与情况分析没有固定对应关系。它们分别描述函数增长阶和分析类型,可以灵活组合。


最后,我们通过二叉搜索树的例子了解到,某些算法(尤其是依赖数据结构的算法)的最坏情况时间可能在一个范围内变化(如BST的 O(log n) 到 O(n)),这取决于数据的具体组织方式。
017:不相交集合数据结构 - 加权合并与折叠查找 🔗





在本节课中,我们将要学*一种重要的数据结构——不相交集合。我们将了解它的基本概念、核心操作,以及如何利用它来检测无向图中的环。我们还将学*两种高效的优化方法:加权合并与折叠查找。



什么是“不相交集合”?🤔





不相交集合与数学中的集合概念相似,但为了在算法中应用而有所调整。它最著名的应用是Kruskal算法,用于检测图中的环。

不相交集合是指多个集合,其中任意两个集合之间没有公共元素。例如,在一个非连通的无向图中,每个连通分量可以看作一个独立的集合。如果两个集合的交集为空,则它们是“不相交”的。


核心操作:查找与合并 🔍


不相交集合主要支持两种操作:查找 和 合并。



查找操作
查找操作的目的是确定一个元素属于哪个集合。例如,给定元素 x,find(x) 返回 x 所属集合的代表元素(或根节点)。






合并操作
合并操作的目的是将两个不同的集合合并为一个。当我们向图中添加一条连接两个不同集合中顶点的边时,就需要执行合并操作。如果两个顶点已经属于同一个集合,那么添加这条边就会形成一个环。







应用:检测无向图中的环 🚴


不相交集合是检测无向图中环的有效工具。其核心思想如下:
- 初始时,将图中的每个顶点视为一个独立的集合。
- 按顺序考虑图中的每条边
(u, v)。 - 对每条边,执行
find(u)和find(v)。- 如果
find(u) != find(v),说明u和v属于不同集合,添加这条边不会形成环。此时执行union(u, v)合并这两个集合。 - 如果
find(u) == find(v),说明u和v已经属于同一个集合,添加这条边会形成环。
- 如果


通过这种方式,我们可以在构建最小生成树(如Kruskal算法)时,有效地避免环的产生。








图形化表示与数组实现 📊


为了在计算机中实现不相交集合,我们通常使用数组来表示。

数组表示法
我们使用一个名为 parent 的数组。数组的索引代表顶点编号,数组的值代表该顶点的父节点。
- 初始时,每个顶点都是独立的集合,即自己是自己的根。我们通常用
-1或自身的负值(表示集合大小)来表示根节点。 - 例如,
parent[i] = -1表示顶点i是一个集合的根,且该集合大小为1。


操作示例
假设有顶点 1 和 2:
find(1):查看parent[1]。若为负值,则1是根。find(2):查看parent[2]。若为负值,则2是根。- 由于
find(1) != find(2),执行union(1, 2)。我们可以选择让1作为新集合的根,并令parent[2] = 1。同时,更新根1的值为-2,表示集合大小变为2。


通过这种方式,我们可以高效地追踪所有顶点所属的集合关系。





高效优化:加权合并与折叠查找 ⚡




基础的查找与合并操作在最坏情况下可能效率不高。以下是两种关键的优化策略。



加权合并
在执行合并操作时,我们总是将元素较少的集合合并到元素较多的集合的根下。这可以防止树变得过高,从而保证查找操作的效率。
在数组实现中,我们通过根节点存储的负值大小来判断集合的权重。

折叠查找
在查找某个元素的根节点时,我们遍历从该元素到根节点的路径。折叠查找优化在于,在找到根之后,我们将路径上所有经过的节点直接指向根节点。
这样,下次查找这些节点时,就只需要常数时间。

以下是这两种优化的核心思想:





加权合并伪代码:
union(x, y):
rootX = find(x)
rootY = find(y)
if rootX == rootY: return
// 比较权重(集合大小)
if size[rootX] < size[rootY]:
parent[rootX] = rootY
size[rootY] += size[rootX]
else:
parent[rootY] = rootX
size[rootX] += size[rootY]


折叠查找伪代码:
find(x):
if parent[x] < 0: // x是根节点
return x
else:
// 递归查找根,并折叠路径
parent[x] = find(parent[x])
return parent[x]




结合加权合并与折叠查找,不相交集合的每个操作平均时间接*常数复杂度,效率非常高。




总结 📝


本节课我们一起学*了不相交集合数据结构。
- 我们首先了解了不相交集合的概念及其两个核心操作:查找与合并。
- 接着,我们探讨了如何利用不相交集合来检测无向图中的环,这是Kruskal等算法的基础。
- 然后,我们学*了如何使用数组来高效表示和操作不相交集合。
- 最后,我们介绍了两种重要的优化技术:加权合并与折叠查找。加权合并通过总是将小树合并到大树下来保持树的平衡;折叠查找则在查找过程中压缩路径,将节点直接链接到根,从而大幅提升后续查找的效率。




掌握不相交集合及其优化,对于理解许多图论算法至关重要。
018:分治策略 🧩
在本节课中,我们将要学*算法设计中的一个核心策略——分治法。我们将了解它的基本思想、适用条件以及通用步骤。
概述

分治法是解决计算问题的一种策略。它通过将一个大问题分解为多个相同类型的小问题,分别解决这些小问题,然后将它们的解合并起来,从而得到原问题的解。这种方法本质上是递归的。


什么是分治策略?

策略是解决问题的一种方法或设计。对于任何计算问题,如果该策略适合,你就可以采用它来解决问题。通过实践,你可以了解哪些问题适用分治法,虽然没有绝对的公式,但存在一些指导原则。




分治法的核心思想


如果给定一个规模为 n 的问题 P,并且这个问题规模很大,那么你可以将其分解为 K 个更小的子问题:P1, P2, ..., Pk。


原问题 P (规模 n)
↓ (分解)
子问题 P1, P2, ..., Pk (规模更小)


这些子问题被分别解决,得到各自的解:S1, S2, ..., Sk。
子问题 P1, P2, ..., Pk
↓ (分别求解)
解 S1, S2, ..., Sk

最后,将这些子问题的解合并,得到原问题 P 的解 S。

解 S1, S2, ..., Sk
↓ (合并)
原问题的解 S

所以,分治法的核心流程是:分解 -> 解决 -> 合并。如果一个子问题本身也很大,可以递归地对其再次应用分治法。



分治法的重要特征

关于分治法,有两个至关重要的特征:
- 子问题与原问题同质:分解出的子问题必须与原问题是同一类型的问题。例如,如果原问题是排序,那么子问题也必须是排序问题。它不能转换成其他类型的问题。
- 必须能合并解:必须存在一种方法,能够将子问题的解有效地合并成原问题的解。如果无法合并,则不能采用此策略。

分治法的通用步骤


以下是分治策略的通用方法描述,可以用伪代码表示:


函数 DivideAndConquer(P):
如果 问题P的规模足够小:
则 直接求解P并返回结果
否则:
1. 将问题P分解为子问题 P1, P2, ..., Pk
2. 对于每个子问题 Pi:
调用 DivideAndConquer(Pi) 递归求解
3. 将子问题的解合并为原问题P的解
返回合并后的解
这个过程清晰地展示了其递归属性,因此分治算法通常是递归实现的。


适用分治法的问题示例

上一节我们介绍了分治法的通用框架,本节中我们来看看哪些经典算法问题适用于这种策略。


以下是部分可以使用分治法解决的经典问题:
- 二分查找
- 寻找最大值与最小值
- 归并排序
- 快速排序
- 斯特拉森矩阵乘法


我们将在后续的不同视频中详细探讨这些问题。


递归与递推关系

由于分治策略本质上是递归的,我们需要知道如何编写递归算法、递归函数,以及如何分析它们的时间复杂度。为此,我们使用递推关系。


在接下来的视频中,我们将深入讲解递推关系的解法,展示各种类型的递推式及其求解方法,以便你能全面掌握分析递归式复杂度的技巧。
总结


本节课中我们一起学*了分治策略。我们了解到,分治法通过“分解、解决、合并”的步骤处理大规模问题,其关键在于子问题必须与原问题同质,且必须能合并子问题的解。这是一种强大的、递归的算法设计范式,为许多高效算法(如排序和查找)奠定了基础。
019:递推关系 (T(n) = T(n-1) + 1) 🧮


在本节课中,我们将学*如何追踪递归函数的执行过程,如何为一个递归函数建立递推关系式,以及如何求解这个递推关系式。我们将通过一个具体的C语言函数示例来逐步讲解这些概念。




递归函数示例与追踪 🌳


首先,我们来看一个用C语言编写的递归函数。这个函数名为 test,它接收一个整数 n 作为输入。


void test(int n) {
if (n > 0) {
printf("%d", n);
test(n - 1);
}
}



函数的工作原理是:如果输入 n 大于0,则打印 n 的值,然后调用自身,参数变为 n-1。

为了理解它的执行流程,我们以 test(3) 为例进行追踪。





当调用 test(3) 时:
n=3大于0,所以打印3。- 然后调用
test(2)。

当调用 test(2) 时:
n=2大于0,所以打印2。- 然后调用
test(1)。



当调用 test(1) 时:
n=1大于0,所以打印1。- 然后调用
test(0)。



当调用 test(0) 时:
n=0不大于0,条件不满足,函数直接返回,不再进行递归调用。
整个执行过程可以用一个递归树或追踪树来表示,它清晰地展示了函数调用的层次和顺序。



时间复杂度分析 ⏱️


上一节我们通过追踪树直观地看到了函数的执行过程。现在我们来分析这个函数的时间复杂度。

观察函数,其主要操作是执行 printf 语句。我们可以假设每次打印操作消耗1个单位时间。



以下是 test(3) 的执行时间分析:
test(3):打印一次,耗时1。test(2):打印一次,耗时1。test(1):打印一次,耗时1。test(0):没有打印,耗时0(或常数时间)。



因此,对于输入 n=3,总时间约为 3 个单位。函数总共发生了 4 次调用(3次有效打印 + 1次终止调用)。


推广到一般情况,对于输入 n:
- 打印操作执行了 n 次。
- 函数总共调用了 n+1 次。




如果我们*似地认为每次调用都执行打印(忽略最后一次无打印的调用),那么总时间 T(n) 与调用次数成正比,即 T(n) = n + 1。

在渐*时间复杂度表示法中,我们关注最高阶项。因此,这个函数的时间复杂度是 O(n),属于线性复杂度。我们也可以表示为 Θ(n) 或 Ω(n)。



建立递推关系式 📝


通过递归树我们直接得到了时间复杂度。另一种更通用的方法是先为递归函数建立递推关系式,然后求解它。

我们使用 T(n) 来表示处理规模为 n 的问题所需的时间。



分析 test(n) 函数:
- 它首先进行一个条件判断(常数时间)。
- 然后执行一次打印操作(常数时间,记为1)。
- 最后递归调用
test(n-1),其时间消耗为 T(n-1)。




因此,总时间 T(n) 可以表示为递归调用时间加上本次操作的常数时间。我们将条件判断和打印的常数时间合并为一个常数 1。



于是,我们得到递推关系式:
T(n) = T(n-1) + 1, 当 n > 0 时。

对于递归的基准情况,即 n=0 时,函数直接返回,执行常数时间操作。我们同样将其记为常数 1。
T(0) = 1。




求解递推关系式(代入法)🔍



上一节我们建立了递推关系 T(n) = T(n-1) + 1 和 T(0) = 1。现在,我们使用反向代入法来求解它。


我们从递推式开始逐步展开:
T(n) = T(n-1) + 1

我们不知道 T(n-1) 的具体值,但可以用同样的递推式表示它:
T(n-1) = T(n-2) + 1



将 T(n-1) 的表达式代入原式:
T(n) = [T(n-2) + 1] + 1 = T(n-2) + 2




我们继续对 T(n-2) 进行展开:
T(n-2) = T(n-3) + 1




再次代入:
T(n) = [T(n-3) + 1] + 2 = T(n-3) + 3


观察这个模式,如果我们重复这个过程 k 次,可以得到:
T(n) = T(n-k) + k


我们的目标是展开到已知的基准情况 T(0)。也就是说,要让 n - k = 0,从而得到 k = n。





将 k = n 代入上式:
T(n) = T(n-n) + n = T(0) + n



已知 T(0) = 1,所以:
T(n) = 1 + n


求解完毕。我们得到该递归函数的精确时间函数为 T(n) = n + 1,与之前通过递归树分析得到的结果一致。其渐*时间复杂度为 Θ(n)。



总结 🎯


本节课我们一起学*了递归算法分析的完整流程:
- 追踪递归函数:通过绘制递归树,直观理解函数的执行流程和调用次数。
- 建立递推关系:将递归函数的时间消耗表示为自身较小规模的函数值加上当前操作的成本,并确定基准情况。
- 求解递推式:使用代入法等数学方法,从递推关系中推导出时间复杂度的闭合形式解。



我们以 T(n) = T(n-1) + 1 这个简单的线性递推为例,掌握了分析的基本方法。这是理解更复杂递归算法(如二分、分治)时间复杂度分析的重要基础。在后续课程中,我们将遇到更多形式的递推关系并学*相应的求解技巧。
020:递推关系 (T(n) = T(n-1) + n) 求解 🧮





在本节课中,我们将学*如何为一个给定的递归算法建立递推关系,并运用两种不同的方法——递归树法和回代法(或称代入法)——来求解该递推关系,最终得出算法的时间复杂度。



建立递推关系




上一节我们介绍了递推关系的概念,本节中我们来看看如何从一个具体的递归函数中推导出递推关系。





考虑一个递归函数,当传入参数 n 时,它会调用自身处理 n-1,并且在函数内部包含一个循环。

为了分析其时间复杂度,我们设函数的总时间为 T(n)。


以下是函数内部各部分的耗时分析:
- 递归调用
T(n-1)的时间为T(n-1)。 - 循环条件判断执行
n+1次。 - 循环体内部语句执行
n次。



因此,我们可以将总时间 T(n) 表示为:
T(n) = T(n-1) + (n+1) + n = T(n-1) + 2n + 2

为了简化求解,我们通常使用渐*记号来*似表示非递归部分的时间。由于 2n+2 属于线性阶,我们可以将其简化为 O(n) 或 Θ(n)。于是,得到一个更简洁的递推关系:
T(n) = T(n-1) + n, 当 n > 0。

对于基准情况,当 n = 0 时,函数不执行任何操作,我们可以记其时间为一个常数,例如:
T(0) = 1。



至此,我们就完成了从算法到递推关系的建立。

方法一:递归树法求解 🌳


现在,我们使用递归树法来求解递推关系 T(n) = T(n-1) + n。




递归树的根节点代表 T(n),其代价为 n(来自公式中的 + n 部分),并产生一个代价为 T(n-1) 的子节点。




子节点 T(n-1) 又可以展开为代价 n-1 和子节点 T(n-2)。

这个过程持续进行,每次递归调用的代价递减 1。

最终,递归会到达基准情况 T(0),其代价为 1。



以下是递归树展开的代价序列:
T(n)层代价:nT(n-1)层代价:n-1T(n-2)层代价:n-2- ...
T(1)层代价:1T(0)层代价:1






将所有层的代价相加,得到总时间 T(n):
T(n) = n + (n-1) + (n-2) + ... + 2 + 1 + 1 = (n(n+1)/2) + 1




这个和式是前 n 个自然数之和再加上 T(0) 的常数 1。前 n 项和公式为 n(n+1)/2。

因此,T(n) = n(n+1)/2 + 1。其最高阶项为 n²/2,所以算法的时间复杂度为 Θ(n²)。



方法二:回代法(代入法)求解 🔄


接下来,我们使用回代法来求解同一个递推关系。这种方法通过反复将递推式右侧的 T 项用其定义进行替换来求解。






我们从递推式开始:
T(n) = T(n-1) + n



第一次回代:为了消去右边的 T(n-1),我们需要知道 T(n-1) 的表达式。根据原递推式,将 n 替换为 n-1,得到:
T(n-1) = T(n-2) + (n-1)


将 T(n-1) 的表达式代入原式:
T(n) = [T(n-2) + (n-1)] + n = T(n-2) + (n-1) + n





第二次回代:现在,对 T(n-2) 进行同样的操作:
T(n-2) = T(n-3) + (n-2)




代入上式:
T(n) = [T(n-3) + (n-2)] + (n-1) + n = T(n-3) + (n-2) + (n-1) + n




观察模式:继续进行 k 次回代后,我们可以观察到规律:
T(n) = T(n-k) + (n-(k-1)) + (n-(k-2)) + ... + (n-1) + n



到达基准情况:当 n-k = 0,即 k = n 时,我们到达了基准情况 T(0) = 1。




将 k = n 代入上述通式:
T(n) = T(0) + (n-(n-1)) + (n-(n-2)) + ... + (n-1) + n
T(n) = 1 + 1 + 2 + ... + (n-1) + n



求和:括号内的和同样是前 n 个自然数之和:
T(n) = 1 + n(n+1)/2



结论:因此,我们再次得到 T(n) = n(n+1)/2 + 1,时间复杂度为 Θ(n²)。这与递归树法得到的结果一致。

总结 📝



本节课中我们一起学*了如何为一个递归算法建立递推关系 T(n) = T(n-1) + n,并运用递归树法和回代法两种方法成功求解。两种方法均得出算法的时间复杂度为 Θ(n²)。理解这些求解步骤对于深入分析算法性能至关重要,尤其是在需要详细推导的理论考试中。在接下来的课程中,我们将学*更快速判断递归式时间复杂度的方法,以应对客观题型。
021:递推关系 (T(n) = T(n-1) + log n) 求解 🧮


在本节课中,我们将学*如何分析一个特定算法的运行时间,该算法的运行时间由递推关系 T(n) = T(n-1) + log n 描述。我们将通过两种方法(递归树法和代入法)来求解这个递推式,并最终得出其时间复杂度。此外,我们还将总结一类递推关系的快速求解规律。


算法分析与递推式建立
我们首先分析一个给定的算法或函数,目标是找出它的运行时间。




该算法包含一个循环。观察循环,变量 i 从1开始,每次迭代乘以2。根据时间复杂度的示例知识,我们可以直接判断这个循环语句的执行次数。

这个语句将执行 log n 次。
为了简化分析,我们专注于算法中主要的打印操作。打印操作本身消耗的时间我们考虑为 log n。


算法剩余部分是一个递归调用 T(n-1)。


因此,这个算法的总运行时间 T(n) 是递归调用时间加上本次操作的时间,即:



T(n) = T(n-1) + log n

同时,我们知道基准情况:当 n = 0 时,T(0) = 1。
所以,完整的递推关系是:
- T(n) = 1,当 n = 0。
- T(n) = T(n-1) + log n,当 n > 0。

接下来,我们将使用两种方法求解这个递推式。


方法一:递归树法 🌳
我们使用递归树法来展开递推关系。


递推式 T(n) = T(n-1) + log n 表示,解决规模为 n 的问题需要 log n 的时间,再加上解决规模为 n-1 的子问题的时间。
我们可以将其逐层展开:
- 第一层:代价为 log n,并产生一个规模为
n-1的子问题。 - 第二层:代价为 log(n-1),并产生一个规模为
n-2的子问题。 - 以此类推,直到规模为
2时,代价为 log 2,产生规模为1的子问题。 - 规模为
1时,代价为 log 1,产生规模为0的子问题。 - 规模为
0时,根据基准情况,代价为 1。


现在,将每一层的代价相加,得到总时间:


T(n) = log n + log(n-1) + log(n-2) + ... + log 2 + log 1 + 1
我们可以忽略最后的常数1,因为它不影响渐*复杂度。于是得到:

T(n) = log n + log(n-1) + log(n-2) + ... + log 2 + log 1

根据对数运算性质,对数相加等于真数相乘后取对数:


T(n) = log( n * (n-1) * (n-2) * ... * 2 * 1 ) = log(n!)


因此,运行时间是 log(n!)。



我们知道阶乘 n! 的一个上界是 n^n。所以,log(n!) 的上界是 log(n^n) = n log n。



由此,我们得出该算法的时间复杂度为 O(n log n)。


方法二:代入法(归纳法) 🔄


上一节我们用递归树法得到了结果,本节我们使用代入法来验证。


我们从递推式开始:T(n) = T(n-1) + log n
我们反复将等式右边的 T(n-1) 用其自身的递推式展开:



- 第一次展开:
T(n) = [T(n-2) + log(n-1)] + log n = T(n-2) + log(n-1) + log n

-
第二次展开:
T(n) = [T(n-3) + log(n-2)] + log(n-1) + log n = T(n-3) + log(n-2) + log(n-1) + log n -
继续展开
k次后,我们得到:
T(n) = T(n-k) + log(n-(k-1)) + ... + log(n-1) + log n


当 k = n 时,T(n-k) = T(0) = 1。此时等式变为:


T(n) = 1 + log 1 + log 2 + ... + log(n-1) + log n



同样,我们得到:


T(n) = 1 + log(n!)
因此,时间复杂度仍然是 O(log(n!)),即 O(n log n)。



代入法得到了与递归树法一致的结论。


规律总结与快速求解 🚀


前面我们通过两种方法详细求解了递推式。现在,让我们回顾一类特定递推关系的求解规律,以便未来能快速判断。

以下是几个递推关系及其解的例子:
- T(n) = T(n-1) + 1 的解是 O(n)。
- T(n) = T(n-1) + n 的解是 O(n²)。
- T(n) = T(n-1) + log n 的解是 O(n log n)。

观察这些例子,可以发现一个模式:当递推式形如 T(n) = T(n-1) + f(n),且没有前项系数时,其解大致是 f(n) * n。

这是因为 f(n) 这个操作会随着递归重复执行大约 n 次。


基于这个观察,我们可以快速推断:
- 如果 T(n) = T(n-1) + n²,那么解大约是 n² * n = O(n³)。
- 即使递推式是 T(n) = T(n-100) + n,对于很大的
n值,它仍然会执行大约n/100次n的操作,结果仍然是 O(n²)。常数因子在渐*分析中被忽略。


以下是快速判断的核心要点:
对于形式为 T(n) = T(n - c) + f(n) (其中 c 是常数)的递推式,其时间复杂度通常为 O(n * f(n))。


但是,这个规律有一个重要的前提:递推式中 T(n-1) 前面没有系数。如果递推式形如 T(n) = 2T(n-1) + 1,情况就不同了,这将在后续课程中讨论。
课程总结 📝



本节课中,我们一起学*了如何分析由 T(n) = T(n-1) + log n 描述的算法。
- 我们首先建立了算法的递推关系式。
- 接着,使用递归树法展开递推式,求和后得到时间复杂度为 O(log(n!)),进而简化为 O(n log n)。
- 然后,使用代入法进行了验证,得到了相同的结果。
- 最后,我们总结了一类递推关系 T(n) = T(n-c) + f(n) 的快速求解规律:其时间复杂度通常为 O(n * f(n)),但需注意该规律仅在递归前项系数为1时适用。


掌握这些方法,能帮助你更高效地分析递归算法的时间复杂度。
022:递推关系 T(n)=2T(n-1)+1 求解 🧮

在本节课中,我们将学*如何求解一个特定的递推关系:T(n) = 2T(n-1) + 1。我们将通过两种方法——递归树法和反向代入法——来推导其时间复杂度,并最终理解其属于指数级复杂度 O(2^n)。

从算法到递推关系

首先,我们从一个示例算法开始,并推导出其时间复杂度的递推关系。


考虑一个函数,它执行一次打印操作(耗时1个单位时间),然后递归调用自身两次,每次调用的输入规模减1。

因此,该算法的时间函数 T(n) 可以表示为:
T(n) = 2T(n-1) + 1

其完整的递推关系定义如下:
- T(n) = 2T(n-1) + 1,当 n > 0 时。
- T(0) = 1,这是递归的基本情况(常数时间)。
方法一:递归树法求解 🌳
上一节我们得到了递推关系,本节中我们使用递归树法来直观地求解它。

递归树展示了函数调用层层展开的过程。对于 T(n) = 2T(n-1) + 1:
- 根节点代价为 1(对应公式中的
+1)。 - 它产生两个子问题,每个子问题的规模是
n-1,即T(n-1)。

以下是展开步骤:
- 第一层(根):代价为 1。
- 第二层:有 2 个节点,每个节点代价为 1,总代价为 2 * 1 = 2。
- 第三层:有 4 个节点,每个节点代价为 1,总代价为 4 * 1 = 4。
- 以此类推,直到第 k 层,问题规模变为 0 (
T(0))。

当 n - k = 0,即 k = n 时,递归停止。此时,树的总层数为 n+1(从第0层到第n层)。

现在,我们来计算整棵树的总代价。总代价是每一层代价之和。
以下是各层代价的序列:
- 第0层(根):1 = 2^0
- 第1层:2 = 2^1
- 第2层:4 = 2^2
- ...
- 第n层:2^n
因此,总时间 T(n) 是一个等比数列(Geometric Progression, GP)的和:
T(n) = 1 + 2 + 2^2 + 2^3 + ... + 2^n

等比数列求和公式为:S = a * (r^(k+1) - 1) / (r - 1),其中 a 是首项,r 是公比,k 是最大指数。
在此数列中,a = 1,r = 2,k = n。代入公式:
T(n) = 1 * (2^(n+1) - 1) / (2 - 1) = 2^(n+1) - 1

所以,T(n) = 2^(n+1) - 1。当 n 很大时,主导项是 2^(n+1)。因此,时间复杂度为 O(2^n)。



方法二:反向代入法求解 🔄

除了递归树法,我们还可以使用反向代入法来求解这个递推关系。这种方法通过反复将递推式右侧的 T 项用其定义进行展开。



我们从原始递推式开始:
(1) T(n) = 2T(n-1) + 1

第一次代入:将等式右边的 T(n-1) 用 T(n-2) 表示(即 T(n-1) = 2T(n-2) + 1)。
T(n) = 2 * [2T(n-2) + 1] + 1 = 2^2 * T(n-2) + 2 + 1



第二次代入:将 T(n-2) 用 T(n-3) 表示。
T(n) = 2^2 * [2T(n-3) + 1] + 2 + 1 = 2^3 * T(n-3) + 2^2 + 2 + 1


观察规律,在进行第 k 次代入后,我们得到:
(4) T(n) = 2^k * T(n-k) + (2^{k-1} + 2^{k-2} + ... + 2^2 + 2 + 1)
我们一直代入,直到递归到达基本情况,即 T(0)。此时 n - k = 0,所以 k = n。同时,我们知道 T(0) = 1。




将 k = n 和 T(0) = 1 代入公式 (4):
T(n) = 2^n * T(0) + (2^{n-1} + 2^{n-2} + ... + 2 + 1)
T(n) = 2^n * 1 + (2^{n-1} + 2^{n-2} + ... + 2 + 1)


括号内是一个等比数列求和(首项1,公比2,共n项),其和为 2^n - 1。
因此:
T(n) = 2^n + (2^n - 1) = 2^{n+1} - 1
结论与方法一完全一致:T(n) = 2^{n+1} - 1,时间复杂度为 O(2^n)。

总结 📝

本节课中我们一起学*了如何求解递推关系 T(n) = 2T(n-1) + 1。
- 我们首先从算法逻辑推导出该递推式。
- 接着,使用递归树法直观地展示了递归展开过程,并通过等比数列求和得到解
T(n) = 2^{n+1} - 1。 - 然后,使用反向代入法进行了严谨的代数推导,得到了相同的结果。
- 最终,我们确定该算法的时间复杂度属于指数阶 O(2^n)。这意味着随着输入规模
n的增大,运行时间将变得非常长,效率很低。


在接下来的课程中,我们将学*用于解决递减函数递推关系的主定理(Master Theorem for Decreasing Functions),它提供了一种更快捷的判定方法。
023:递减函数的主定理 📉



在本节课中,我们将要学*用于求解递减函数形式递归式的主定理。我们将通过观察一系列已解决的例子,归纳出定理的一般形式,并学*如何应用它来快速求解特定类型的递归问题。


上一节我们介绍了递归式的基本概念,本节中我们来看看针对递减函数形式(即 T(n) = aT(n-b) + f(n))的通用解法。




观察与归纳



首先,回顾我们在之前视频中已经解决过的一些递减函数递归式的例子及其答案:

以下是已解决的递归式及其渐进时间复杂度:
T(n) = T(n-1) + 1的解是 O(n)。T(n) = T(n-1) + n的解是 O(n²)。T(n) = T(n-1) + log n的解是 O(n log n)。T(n) = 2T(n-1) + 1的解是 O(2ⁿ)。T(n) = 3T(n-1) + 1的解是 O(3ⁿ)。T(n) = 2T(n-1) + n的解是 O(n · 2ⁿ)。

从这些观察中,我们可以归纳出这类递归式的一般形式。
主定理的一般形式




基于上述观察,我们定义递减函数递归式的一般形式为:



T(n) = aT(n - b) + f(n)



其中:
- a > 0, b > 0。
- f(n) 是一个渐进正函数,通常我们假设它具有 O(nᵏ) 的形式,其中 k ≥ 0。


这个形式概括了我们之前看到的所有例子。接下来,我们将根据系数 a 的不同取值,分情况讨论递归式的解。



主定理的三种情况

根据一般形式中系数 a 的值,解可以分为以下三种情况。


情况一:当 a < 1 时




如果递归式中的系数 a 小于 1(例如 0.5),那么递归式的解主要由 f(n) 决定。

公式:T(n) = O(f(n)) 或 T(n) = O(nᵏ)



这意味着递归式的渐进时间复杂度与 f(n) 同阶。



情况二:当 a = 1 时


如果系数 a 等于 1,那么递归式的解是 f(n) 乘以 n。


公式:T(n) = O(n · f(n)) 或 T(n) = O(nᵏ⁺¹)



从例子中可以看到:f(n)=1 时解为 O(n),f(n)=n 时解为 O(n²),f(n)=log n 时解为 O(n log n)。



情况三:当 a > 1 时



如果系数 a 大于 1(例如 2, 3),那么递归式的解是 f(n) 乘以 a 的幂(形式为 aⁿ 或 aⁿ/ᵇ)。



公式:T(n) = O(f(n) · aⁿ) 或更精确地 T(n) = O(f(n) · aⁿ/ᵇ)

从例子中可以看到:a=2, f(n)=1 时解为 O(2ⁿ),a=3, f(n)=1 时解为 O(3ⁿ),a=2, f(n)=n 时解为 O(n · 2ⁿ)。



总结与应用

本节课中我们一起学*了用于递减函数形式递归式的主定理。我们首先通过具体例子进行观察,然后归纳出递归式的一般形式 T(n) = aT(n-b) + f(n),并最终得到了根据 a 的值快速判断解的三条规则:


- 若 a < 1,则
T(n) = O(f(n))。 - 若 a = 1,则
T(n) = O(n · f(n))。 - 若 a > 1,则
T(n) = O(f(n) · aⁿ)(或O(f(n) · aⁿ/ᵇ))。

要掌握这个定理,关键在于记忆这三种情况。建议你花些时间亲自推导和练*这些例子,而不仅仅是观看,这样才能牢固掌握并熟练运用此定理来解决符合该形式的任何递归问题。
024:分治函数的递归关系 T(n)=T(n/2)+1 📚
在本节课中,我们将学*一种称为“分治函数”的递归关系。具体来说,我们将详细分析递归式 T(n) = T(n/2) + 1,并使用两种不同的方法(递归树法和代入法)来求解其时间复杂度。通过本课,你将理解当递归函数每次将问题规模减半时,其运行时间是如何计算的。




什么是分治函数?🔍
上一节我们介绍了递减函数,本节中我们来看看分治函数。分治函数之所以得名,是因为在算法或函数中,它调用自身时传入的参数是 n/2。也就是说,如果传入的参数是 n,那么函数会将 n 除以 2。
一个算法接收参数 n 后,可以通过多种方式减小 n 的值:
n-1:递减。n/2:除以 2(分治)。√n:开平方根。

对于这些不同的减小方式,递归函数会有不同类型的递归关系。我们已经学*过递减类型的函数,现在我们来分析分治类型的函数。

建立递归关系 📝


让我们来求解一个具体的分治递归函数。假设一个算法的时间函数为 T(n)。该算法内部包含两个部分:
- 一个耗时 1 个时间单位的操作。
- 一个递归调用
T(n/2)。
因此,我们可以建立以下递归方程:
T(n) = T(n/2) + 1

同时,我们需要一个基准条件。当 n = 1 时,函数直接返回,我们可以认为其耗时是一个常数,例如 1。所以完整的递归关系是:
- T(n) = T(n/2) + 1,当
n > 1时。 - T(1) = 1。

接下来,我们将使用两种方法来求解这个递归式。
方法一:递归树法 🌳
递归树法通过将递归展开成一棵树来直观地计算总时间。

步骤如下:


-
展开第一层:
T(n)的耗时等于 1(当前操作)加上T(n/2)的耗时。T(n) = 1 + T(n/2)
-
展开第二层:
T(n/2)又可以展开为 1 加上T(n/4)。T(n) = 1 + [1 + T(n/4)] = 2 + T(n/4)


- 继续展开:这个过程会持续进行。假设我们展开
k次,那么递归调用会变成T(n / 2^k),而前面累计的常数项之和为k。T(n) = k + T(n / 2^k)

- 确定终止条件:递归会一直进行,直到问题规模变为 1,即
n / 2^k = 1。由此我们可以解出k:n / 2^k = 1 => n = 2^k => k = log₂n

- 计算总时间:将
k = log₂n和T(1) = 1代入公式:T(n) = log₂n + T(1) = log₂n + 1
因此,该递归式的时间复杂度为 O(log n)。




方法二:代入法(归纳法) 🔄

上一节我们用递归树法得到了结果,本节我们用代入法来验证。代入法通过不断将递归式右侧的项用其定义进行替换来求解。

步骤如下:

- 写出原始方程:
T(n) = T(n/2) + 1 ... (1)



- 第一次代入:为了替换右边的
T(n/2),我们写出T(n/2)的表达式:T(n/2) = T(n/4) + 1
将上式代入方程(1):
T(n) = [T(n/4) + 1] + 1 = T(n/4) + 2 ... (2)

- 第二次代入:继续替换
T(n/4):T(n/4) = T(n/8) + 1
代入方程(2):
T(n) = [T(n/8) + 1] + 2 = T(n/8) + 3 ... (3)


- 观察规律:经过
k次代入后,模式变得清晰:T(n) = T(n / 2^k) + k ... (4)

- 应用基准条件:当
n / 2^k = 1,即n = 2^k时,递归到达终点T(1)。此时k = log₂n。
将k和T(1) = 1代入方程(4):T(n) = T(1) + log₂n = 1 + log₂n

同样,我们得出时间复杂度为 O(log n)。





总结 📖


本节课中我们一起学*了分治类型的递归关系 T(n) = T(n/2) + 1。
我们通过两种方法进行了求解:
- 递归树法:通过将递归可视化成一棵树,累加每一层的代价,最终得出时间复杂度为 O(log n)。
- 代入法:通过反复代入展开递归式,归纳出通用公式,同样验证了时间复杂度为 O(log n)。

这个结果非常典型:当一个递归算法每次将问题规模减半,并执行常数时间操作时,其时间复杂度通常是对数级的 O(log n)。理解这个推导过程是掌握更复杂递归分析(如主定理)的重要基础。在后续课程中,我们将看到更多递归式的例子并学*主定理。
025:分治递推关系求解 - T(n)=T(n/2)+n 🔍



在本节课中,我们将学*如何使用两种方法求解分治类型的递推关系:T(n) = T(n/2) + n。我们将详细讲解递归树法和代入法,并通过几何图形直观理解求和过程。最终,我们将得出该递推关系的时间复杂度为 O(n)。

递归树法求解 🧮
上一节我们介绍了递推关系的基本概念,本节中我们来看看如何使用递归树法进行求解。递归树法通过将递归调用过程可视化为一棵树,并计算每一层的工作量来求解总时间。
首先,我们写出递推关系:T(n) = T(n/2) + n。等号右边的 n 代表当前步骤花费的时间,T(n/2) 代表递归调用子问题所花费的时间。


以下是构建递归树的步骤:
- 根节点代表 T(n),其成本为 n。
- 根节点产生一个子节点,代表 T(n/2),其成本为 n/2。
- 子节点 T(n/2) 继续产生一个子节点 T(n/4),成本为 n/4。
- 此过程持续进行,直到问题规模缩小到 1,即 T(1)。
假设递归进行 k 次后问题规模变为 1,即 n / 2^k = 1。由此可得 k = log₂ n。

现在,我们将递归树每一层的成本相加:
- 第 1 层(根节点)成本:n
- 第 2 层成本:n/2
- 第 3 层成本:n/4
- ...
- 第 k 层成本:n / 2^(k-1)


总成本 T(n) 是所有这些成本之和:
T(n) = n + n/2 + n/4 + ... + n / 2^(k-1)



我们可以提取公因子 n:
T(n) = n * (1 + 1/2 + 1/4 + ... + 1/2^(k-1))

括号内是一个等比数列求和。当项数趋*于无穷时,该和收敛于 2。在我们的有限项求和中,它小于 2。因此,我们可以得出:
T(n) ≈ n * 2 = O(n)


为了更直观地理解这个求和,可以想象一个完整的圆代表数字 1。将其一半涂色(1/2),再将剩下部分的一半涂色(1/4),如此继续。即使无限进行下去,所有涂色部分的总和也永远不会超过整个圆(1)。这说明了该级数的和有界性。

代入法求解 🔁


上一节我们使用递归树法得到了结果,本节中我们使用代入法进行验证。代入法通过反复将递推式右侧的 T() 项用其定义进行展开来求解。

我们从原始递推式开始:
T(n) = T(n/2) + n
将等式右边的 T(n/2) 用其自身定义展开:
T(n/2) = T(n/4) + n/2


将其代回原式:
T(n) = [T(n/4) + n/2] + n = T(n/4) + n/2 + n


继续展开 T(n/4):
T(n/4) = T(n/8) + n/4

再次代回:
T(n) = T(n/8) + n/4 + n/2 + n


重复此过程 k 次后,我们得到:
T(n) = T(n / 2^k) + n/2^(k-1) + n/2^(k-2) + ... + n/2 + n



与递归树法一样,当 n / 2^k = 1,即 k = log₂ n 时,递归停止。此时 T(n / 2^k) = T(1),这是一个常数,记为 c。


因此:
T(n) = c + n * (1 + 1/2 + 1/4 + ... + 1/2^(k-1))


同样,括号内的级数和小于 2。所以:
T(n) ≤ c + 2n

忽略常数项和系数,我们得到 T(n) = O(n)。


总结 📝


本节课中我们一起学*了求解递推关系 T(n) = T(n/2) + n 的两种核心方法。
- 使用递归树法,我们通过将递归过程可视化并求和每一层的成本,得出总时间复杂度为 O(n)。
- 使用代入法,我们通过反复展开递推式并求和,同样验证了结果为 O(n)。



两种方法都揭示了关键点:虽然递归深度是对数级的(log n),但每一层的工作量(n, n/2, n/4...)构成一个收敛的等比数列,其总和与 n 同阶。因此,该算法的时间复杂度是线性的。
026:递归关系 T(n) = 2T(n/2) + n 的求解 🧮




在本节课中,我们将学*如何为一个特定的算法推导并求解其递归关系。我们将重点分析递归式 T(n) = 2T(n/2) + n,并使用两种不同的方法(递归树法和回代法)来求解其时间复杂度。这个递归式在分析分治算法(如归并排序)时非常常见。


算法与递归关系的推导



我编写了一个算法。让我们找出它的递归关系。



该算法包含一个函数,函数内部有一个循环。循环内的语句执行时间为 n。函数递归地调用自身两次,每次处理规模为 n/2 的问题。


因此,总时间 T(n) 等于两次递归调用的时间加上循环的时间。这给出了递归关系:
T(n) = 2T(n/2) + n,当 n > 1 时。

当 n = 1 时,问题规模最小,执行时间为常数,我们通常取 T(1) = 1。这里选择 n=1 作为基准情况,是因为在分治算法中,问题不断对半划分,最小的规模就是 1。如果基准情况是 0,求解可能会更复杂,因此我们通常设为 1。

这个递归关系的求解非常重要,因为它代表了许多分治算法的时间复杂度。

方法一:递归树法求解 🌳




上一节我们推导出了递归关系,本节中我们来看看如何使用递归树法来求解它。


递归树法通过将递归调用展开成一棵树来直观地计算总时间。以下是构建递归树的步骤:


- 树的根节点:代表 T(n),其代价为 n(即公式中的 “+ n” 部分)。
- 第一层子节点:根节点产生两个子节点,分别代表 T(n/2)。每个子节点的代价是 n/2。因此第一层的总代价是 2 * (n/2) = n。
- 继续分解:每个 T(n/2) 节点再产生两个代价为 n/4 的子节点。第二层的总代价是 4 * (n/4) = n。
- 模式总结:每一层的总代价都是 n。
- 树的高度:问题规模每次除以 2,直到变为 1。设树的高度为 k,则有 n / 2^k = 1。解得 2^k = n,即 k = log₂ n。
- 总时间计算:总时间 = 层数 × 每层代价 = k * n = n log₂ n。



因此,使用递归树法得到的时间复杂度为 O(n log n)。




方法二:回代法求解 🔄

除了递归树法,我们还可以使用回代法来求解这个递归关系。回代法通过反复将递归式右侧的 T() 项替换为其自身的展开式来求解。


我们从递归式开始:
T(n) = 2T(n/2) + n ………… (1)


我们的目标是将其展开,直到出现基准情况 T(1)。


第一次回代:为了展开 T(n/2),我们将公式 (1) 中的 n 替换为 n/2:
T(n/2) = 2T(n/4) + n/2




将上式代入公式 (1):
T(n) = 2 * [ 2T(n/4) + n/2 ] + n = 4T(n/4) + n + n = 4T(n/4) + 2n ………… (2)


第二次回代:现在展开 T(n/4)。将公式 (1) 中的 n 替换为 n/4:
T(n/4) = 2T(n/8) + n/4

将上式代入公式 (2):
T(n) = 4 * [ 2T(n/8) + n/4 ] + 2n = 8T(n/8) + n + 2n = 8T(n/8) + 3n ………… (3)




观察规律,在进行 k 次回代后,我们得到:
T(n) = 2^k T(n / 2^k) + k * n


当 n / 2^k = 1,即 2^k = n,k = log₂ n 时,递归到达基准情况。此时 T(n / 2^k) = T(1) = 1。



代入得:
T(n) = 2^(log₂ n) * 1 + (log₂ n) * n = n + n log₂ n




在时间复杂度分析中,我们关注最高阶项,因此 T(n) = O(n log n)。




总结与预告 📚

本节课中我们一起学*了如何求解递归关系 T(n) = 2T(n/2) + n。


我们首先从一个示例算法推导出了这个递归式。接着,我们使用了两种经典方法进行求解:
- 递归树法:通过将递归调用可视化成一棵每层代价为
n、高度为log n的树,得出总时间为 O(n log n)。 - 回代法:通过代数替换,将递归式展开,最终同样得到结果为 O(n log n)。



这个结果 O(n log n) 是一个非常重要的结论,它是许多高效分治算法(如归并排序、快速排序的平均情况)的时间复杂度基础。


直接求解递归关系有时比较繁琐。在下节课中,我们将介绍主定理(Master Theorem),它是一个强大的工具,可以让我们直接根据递归式的形式,快速得出其时间复杂度解。这对于分析分治算法将非常便捷。
027:分治函数的主定理(Master Theorem)🧮




在本节课中,我们将学*用于分析分治算法时间复杂度的核心工具——主定理。我们将通过一系列简单的例子,理解其三种主要情况及其应用。



概述



主定理提供了一种快速求解特定形式递归关系渐*解的方法。这种递归关系通常出现在分治算法中,其一般形式为:



T(n) = a T(n/b) + f(n)



其中:
- a ≥ 1,表示子问题的数量。
- b > 1,表示问题规模缩小的因子。
- f(n) 是将问题分解和合并子问题解所花费的时间。



我们假设 f(n) 具有以下形式:




f(n) = Θ( n^k * (log n)^p )


接下来,我们需要计算两个关键值:
- log_b(a):以 b 为底 a 的对数。
- k:函数 f(n) 中 n 的幂次。




通过比较这两个值,我们可以将递归式的解归入以下三种情况之一。



主定理的三种情况

以下是主定理的三种核心情况及其判定条件。



情况一:递归工作量占主导

如果 log_b(a) > k,那么递归树中叶子层的代价占主导。此时,递归式的解为:


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



情况二:各层工作量平衡


如果 log_b(a) = k,那么递归树每一层的工作量大致相同。此时,解的形式取决于对数因子 p 的值。以下是三个子情况:
- 子情况 2.1:如果 p > -1,则解为 T(n) = Θ( n^k * (log n)^{p+1} )。
- 子情况 2.2:如果 p = -1,则解为 T(n) = Θ( n^k * log(log n) )。
- 子情况 2.3:如果 p < -1,则解为 T(n) = Θ( n^k )。



情况三:合并工作量占主导


如果 log_b(a) < k,那么递归树根节点合并子问题的工作量占主导。此时,解的形式也取决于 p 的值:
- 如果 p ≥ 0,则解为 T(n) = Θ( f(n) ) = Θ( n^k * (log n)^p )。
- 如果 p < 0,则解为 T(n) = Θ( n^k ),忽略对数项。

应用示例

上一节我们介绍了主定理的三种情况,本节中我们通过具体例子来看看如何应用。


情况一示例

以下是满足情况一条件的几个递归式。




- 例1:T(n) = 2T(n/2) + 1
- a=2, b=2, f(n)=1 => k=0, p=0。
- log_2(2) = 1 > 0 (k)。
- 解:T(n) = Θ(n^1) = Θ(n)。

- 例2:T(n) = 4T(n/2) + n
- a=4, b=2, f(n)=n => k=1, p=0。
- log_2(4) = 2 > 1 (k)。
- 解:T(n) = Θ(n^2)。

- 例3:T(n) = 8T(n/2) + n^2
- a=8, b=2, f(n)=n^2 => k=2, p=0。
- log_2(8) = 3 > 2 (k)。
- 解:T(n) = Θ(n^3)。

情况二示例

现在,我们来看满足情况二条件的例子,即 log_b(a) 等于 k。

- 例1:T(n) = 2T(n/2) + n
- a=2, b=2, f(n)=n => k=1, p=0。
- log_2(2) = 1 = 1 (k),且 p=0 > -1。
- 解:T(n) = Θ(n^1 * (log n)^{0+1}) = Θ(n log n)。


-
例2:T(n) = 4T(n/2) + n^2
- a=4, b=2, f(n)=n^2 => k=2, p=0。
- log_2(4) = 2 = 2 (k),且 p=0 > -1。
- 解:T(n) = Θ(n^2 * (log n)^{0+1}) = Θ(n^2 log n)。
-
例3:T(n) = 2T(n/2) + n / log n
- a=2, b=2, f(n)=n / log n => 可视为 k=1, p=-1。
- log_2(2) = 1 = 1 (k),且 p = -1。
- 解:T(n) = Θ(n^1 * log(log n)) = Θ(n log(log n))。



情况三示例

最后,我们看看情况三的例子,即函数 f(n) 的增长速度超过了递归部分。




- 例1:T(n) = T(n/2) + n^2
- a=1, b=2, f(n)=n^2 => k=2, p=0。
- log_2(1) = 0 < 2 (k),且 p=0 ≥ 0。
- 解:T(n) = Θ(f(n)) = Θ(n^2)。



- 例2:T(n) = 2T(n/2) + n^3
- a=2, b=2, f(n)=n^3 => k=3, p=0。
- log_2(2) = 1 < 3 (k),且 p=0 ≥ 0。
- 解:T(n) = Θ(n^3)。


- 例3:T(n) = 4T(n/2) + n^3 / log n
- a=4, b=2, f(n)=n^3 / log n => 可视为 k=3, p=-1。
- log_2(4) = 2 < 3 (k),且 p=-1 < 0。
- 解:T(n) = Θ(n^3)(忽略分母的 log n 项)。


总结
本节课中,我们一起学*了用于求解分治算法递归式的主定理。我们首先介绍了其一般形式 T(n) = a T(n/b) + f(n),并定义了关键参数 log_b(a) 和 k。然后,我们详细讲解了三种核心情况:
- 当 log_b(a) > k 时,解由递归部分主导。
- 当 log_b(a) = k 时,解需要额外考虑对数因子 p。
- 当 log_b(a) < k 时,解由合并函数 f(n) 主导。

通过多个简单的示例,我们实践了如何根据递归式的系数快速判断其所属情况并得出时间复杂度。掌握主定理能极大地简化对许多经典分治算法(如归并排序、快速排序、二分查找)的时间复杂度分析。
算法导论精讲:2.4.2:主定理案例二示例详解 🧮






在本节课中,我们将通过一系列具体的例子,深入学*主定理(Master Theorem)的第二种情况。我们将逐一分析不同形式的递归关系式,并应用主定理的规则来确定其时间复杂度。通过实例练*,你将能更熟练地判断和计算递归算法的复杂度。







上一节我们介绍了主定理的基本形式和三种情况。本节中,我们来看看如何应用主定理的案例二来解决实际问题。案例二的核心特征是递归式中的 f(n) 与 n^log_b(a) 具有相同的增长率,即两者“旗鼓相当”。



以下是几个案例二的典型示例,我们将逐一计算其时间复杂度。


- 示例 1:
T(n) = 2T(n/2) + 1- 这里,
a = 2,b = 2,f(n) = 1。 - 计算
log_b(a) = log_2(2) = 1,因此n^log_b(a) = n^1。 f(n) = 1可以视为n^0。由于1 > 0,n^1的增长速度远快于n^0,这实际上属于案例一。因此,时间复杂度为 Θ(n)。
- 这里,





- 示例 2:
T(n) = 2T(n/2) + n- 这里,
a = 2,b = 2,f(n) = n。 - 计算
log_b(a) = log_2(2) = 1,因此n^log_b(a) = n^1。 f(n) = n也是n^1。两者增长率相同,属于案例二。- 根据案例二规则,时间复杂度为 Θ(n * log n)。
- 这里,




- 示例 3:
T(n) = 2T(n/2) + n log n- 这里,
a = 2,b = 2,f(n) = n log n。 - 计算
log_b(a) = log_2(2) = 1,因此n^log_b(a) = n^1。 f(n) = n log n可以看作n^1乘以一个log n因子。在案例二中,当f(n)与n^log_b(a)同阶但多出一个log^k n因子时,结果需额外乘以log^(k+1) n。此处k = 1。- 因此,时间复杂度为 Θ(n * log² n)。
- 这里,


- 示例 4:
T(n) = 4T(n/2) + n²- 这里,
a = 4,b = 2,f(n) = n²。 - 计算
log_b(a) = log_2(4) = 2,因此n^log_b(a) = n²。 f(n) = n²,两者增长率相同,属于案例二。- 因此,时间复杂度为 Θ(n² * log n)。
- 这里,





- 示例 5:
T(n) = 4T(n/2) + n² log² n- 这里,
a = 4,b = 2,f(n) = n² log² n。 - 计算
log_b(a) = log_2(4) = 2,因此n^log_b(a) = n²。 f(n)是n²乘以log² n,属于案例二中f(n)带log^k n因子的情况,此处k = 2。- 因此,时间复杂度为 Θ(n² * log³ n)。
- 这里,






特殊情况讨论




在案例二中,如果 f(n) 的分母包含对数项,处理方式略有不同。



- 示例 6:
T(n) = 2T(n/2) + n / log n- 这里,
a = 2,b = 2,f(n) = n / log n。 n^log_b(a) = n¹。f(n)可以看作n¹除以log n,其增长率略低于n¹。严格来说,这需要检查正则性条件,但一个简单的记忆法是:当分母有log n时,最终结果中的log n因子会减少。- 可以*似认为时间复杂度为 Θ(n * log log n)(这是一个更精确的分析结果,此处作为了解)。核心在于,它不会得到
Θ(n log n)。
- 这里,



- 示例 7:
T(n) = 2T(n/2) + n / log² n- 同理,
f(n)的增长比n / log n更慢。 - 其时间复杂度*似为 Θ(n)(同样,这是更深入分析的结果)。分母的对数项使得
f(n)足够小,以至于结果中不产生额外的log n因子。
- 同理,







本节课中我们一起学*了主定理案例二的应用。我们通过多个例子看到,当 f(n) 与 n^log_b(a) 同阶时,时间复杂度需要在 f(n) 的基础上额外乘以一个 log n。我们还特别讨论了 f(n) 分母包含对数项时的处理方法。掌握这些例子,能帮助你快速判断符合案例二的递归式的时间复杂度。
029:根函数递归关系求解教程 📚


在本节课中,我们将学*如何求解一种特殊的递归关系,其中递归调用的参数是输入值 n 的平方根。我们将通过一个具体的例子,使用回代法来逐步推导其时间复杂度。


上一节我们介绍了处理递减和分治递归关系的方法。本节中,我们来看看当递归调用涉及根函数(即参数为 √n)时,应如何求解。


首先,我们有一个算法,其递归关系如下。可以看到,函数通过传递 n 的平方根值来调用自身。

递归关系公式:
T(n) = T(√n) + 1, 当 n > 2
这里需要注意,n 的取值必须大于 2。如果取 1 或 0,可能无法顺利求解此递归关系。因此,在处理根函数时,最小的 n 值应大于或等于 2。
现在,让我们开始求解这个递归式。我们直接使用回代法。




第一步回代:
将 n 替换为 √n 代入原式。
T(n) = T(n^(1/2)) + 1 // 方程 (1)


第二步回代:
将方程 (1) 中的 T(n^(1/2)) 再次展开。
T(n) = [T((n^(1/2))^(1/2)) + 1] + 1 = T(n^(1/4)) + 2 // 方程 (2)



第三步回代:
继续此过程。
T(n) = T(n^(1/8)) + 3 // 方程 (3)



以下是经过 k 次回代后的通用形式:
T(n) = T(n^(1/(2^k))) + k
为了进一步求解,我们需要进行一个关键假设。我们假设 n 是 2 的幂次方,即 n = 2^m。这个假设在分析递归时很常见,能简化计算。


将 n = 2^m 代入通用形式:
T(2^m) = T(2^(m / 2^k)) + k




接下来,我们假设经过 k 次开方后,参数最终变为一个常数,这里我们取 T(2)。这意味着:
2^(m / 2^k) = 2^1


由此可得:
m / 2^k = 1
=> m = 2^k
=> k = log₂ m

现在,我们需要将结果用原始的 n 来表示。因为 n = 2^m,所以 m = log₂ n。


将 m 代入 k 的表达式:
k = log₂ m = log₂ (log₂ n)

因此,我们得到递归式的解为 Θ(log log n),即以 2 为底的对数的对数。


本节课中我们一起学*了如何求解参数为根函数的递归关系。我们使用了回代法,并借助 n 是 2 的幂次方这一假设,最终推导出其时间复杂度为 Θ(log log n)。这种方法也适用于主定理中处理类似分治函数的情况。
030:二分查找迭代法 🔍








在本节课中,我们将要学*一种高效的查找算法——二分查找。这是一种基于分治策略的搜索技术,适用于已排序的列表。我们将通过具体的例子来理解其工作原理,并学*如何用迭代的方式实现它。







二分查找概述





二分查找是一种搜索技术。搜索方法主要有两种:一种是线性查找,另一种就是二分查找。二分查找遵循分治策略。






在分治策略中,如果一个问题规模很大,我们会将其分解为若干子问题。当所有子问题都得到解决后,再将它们的解合并,从而得到原问题的解。





二分查找的前提条件





执行二分查找的首要条件是,列表必须是已排序的。所有元素必须事先按顺序排列,这是二分查找的先决条件。




二分查找的工作原理





为了执行二分查找,我们需要两个索引指针:一个是 low(起始点),另一个是 high(结束点)。



假设我们要在一个包含15个元素(1到15)的已排序列表中搜索一个关键元素 42。以下是查找步骤:





- 初始化
low = 1,high = 15。 - 计算中间索引
mid,公式为mid = floor((low + high) / 2)。这里mid = floor((1+15)/2) = 8。 - 检查
a[mid]的值是否为42。此时a[8] = 29,不是42。 - 因为
42 > 29,且列表已排序,所以目标元素必然在列表的右半部分。因此,我们忽略左半部分,将搜索范围缩小到右半部分:更新low = mid + 1 = 9,high保持不变为15。 - 在新的搜索范围(9到15)内,重新计算
mid = floor((9+15)/2) = 12。 - 检查
a[12] = 47,不是42。因为42 < 47,所以目标元素在左半部分。更新high = mid - 1 = 11,low保持为9。 - 在新的搜索范围(9到11)内,计算
mid = floor((9+11)/2) = 10。 - 检查
a[10] = 36,不是42。因为42 > 36,所以目标元素在右半部分。更新low = mid + 1 = 11,high保持为11。 - 此时
low和high相等。计算mid = floor((11+11)/2) = 11。 - 检查
a[11] = 42,这正是我们要找的元素。搜索成功。




通过这个过程,我们仅用了4次比较就找到了位于索引11的元素。如果使用线性查找,则需要11次比较。这表明二分查找比线性查找更快。






更多示例




为了加深理解,我们来看几个其他例子。





示例一:查找元素 12





low=1,high=15,mid=8。a[8]=29,12 < 29,向左查找。high = mid - 1 = 7。mid = floor((1+7)/2) = 4。a[4] = 12,找到目标。仅用2次比较。








示例二:查找不存在的元素 30







low=1,high=15,mid=8。a[8]=29,30 > 29,向右查找。low = mid + 1 = 9。mid = floor((9+15)/2) = 12。a[12]=47,30 < 47,向左查找。high = mid - 1 = 11。mid = floor((9+11)/2) = 10。a[10]=36,30 < 36,向左查找。high = mid - 1 = 9。mid = floor((9+9)/2) = 9。a[9]=31,30 < 31,向左查找。high = mid - 1 = 8。此时low=9大于high=8,搜索范围无效,说明元素不存在。






关键点:当 low 变得大于 high 时,意味着搜索范围已为空,可以断定元素不在列表中。





二分查找算法(迭代法)





基于以上步骤,我们可以写出二分查找的迭代算法。该函数接收一个数组 A、数组大小 n 和要查找的关键字 key,并返回找到元素的索引(若未找到则返回0)。





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






Function BinarySearch(A, n, key):
low = 1
high = n
while low <= high:
mid = floor((low + high) / 2)
if key == A[mid]:
return mid // 找到元素
else if key < A[mid]:
high = mid - 1 // 在左半部分查找
else:
low = mid + 1 // 在右半部分查找
return 0 // 未找到元素




算法分析与时间复杂度


我们可以将二分查找的过程可视化为一棵二叉树。树的根节点是第一次的 mid,其左右子树分别对应搜索范围的左半部分和右半部分。

- 最好情况:要查找的元素正好在第一次的中间位置(即
mid)。此时时间复杂度为 O(1)。 - 最坏情况:要查找的元素在树的叶子节点位置。对于有
n个元素的列表,二分查找树的最大高度约为 log₂n。因此,最坏情况下的时间复杂度为 O(log n)。 - 平均情况:经过分析,平均时间复杂度同样为 O(log n)。


对于不成功的搜索,其比较次数也最多为树的高度,即 O(log n)。


总结


本节课我们一起学*了二分查找迭代法。我们了解到二分查找是一种高效的搜索算法,其前提是数据必须已排序。它的核心思想是不断将搜索区间对半分割,通过比较中间元素来排除一半的无效数据。


我们详细走查了查找成功和失败的例子,并推导出了其迭代实现的算法。最后,我们分析了算法的时间复杂度,得出其最好情况为 O(1),最坏和平均情况均为 O(log n),这远优于线性查找的 O(n)。掌握二分查找是理解更复杂分治算法的重要基础。
031:递归二分查找法 🔍






在本节课中,我们将学*如何为二分查找编写一个递归算法。二分查找遵循分治策略,我们将详细解析其递归实现、核心逻辑以及时间复杂度分析。

算法概述与递归思想



上一节我们介绍了二分查找的基本概念。本节中,我们来看看如何用递归的方式实现它。分治策略的核心是:如果问题规模较大,就将其分解为子问题来解决。因此,我们首先需要定义什么是“小问题”。

当数组中只剩下一个元素时,我们认为这是一个小问题。对于小问题,处理方式很简单:检查这个元素是否就是我们要找的键值。




递归算法步骤

以下是递归二分查找算法的详细步骤。

1. 定义函数与参数
算法名为 BinarySearch,它接受三个参数:low(搜索区间下限索引)、high(搜索区间上限索引)和 key(要查找的目标值)。




2. 处理基本情况(小问题)
如果 low == high,说明当前搜索区间只有一个元素。这是一个小问题。
- 检查该元素
A[low]是否等于key。 - 如果相等,查找成功,返回索引
low。 - 如果不相等,查找失败,返回
0(或一个表示未找到的特定值)。


代码描述:
if low == high
if A[low] == key
return low
else
return 0



3. 处理递归情况(大问题)
如果问题规模较大(即 low < high),则进行以下步骤:
- 计算中间索引:
mid = (low + high) / 2。 - 比较
key与中间元素A[mid]:- 如果
key == A[mid],查找成功,返回mid。 - 如果
key < A[mid],说明目标值只可能存在于左半部分。递归调用BinarySearch,参数为(low, mid-1, key)。 - 如果
key > A[mid],说明目标值只可能存在于右半部分。递归调用BinarySearch,参数为(mid+1, high, key)。
- 如果




代码描述:
mid = floor((low + high) / 2)
if key == A[mid]
return mid
else if key < A[mid]
return BinarySearch(low, mid-1, key)
else
return BinarySearch(mid+1, high, key)



时间复杂度分析



现在我们已经编写了基于分治策略的递归算法。分治算法的时间复杂度通常使用递归关系式来求解,让我们为此算法建立一个递归式。


设算法处理 n 个元素的时间为 T(n)。
- 对于小问题(n=1):只需进行一次比较,因此
T(1) = 1。 - 对于大问题(n>1):算法会计算中间值并进行常数次比较(例如2-3次),我们将其计为常数时间
1。然后,算法会在左半部分或右半部分(二者选一)递归调用自身,问题的规模减半。因此,递归关系为:
T(n) = T(n/2) + 1(当 n>1 时)


这个递归式是标准的。通过主定理(Master Theorem)或展开求解,我们可以得到其时间复杂度。
应用主定理:将 T(n) = T(n/2) + 1 与标准形式 T(n) = aT(n/b) + f(n) 对比。
a = 1b = 2f(n) = 1 = Θ(n^0)


这里,log_b(a) = log_2(1) = 0。由于 f(n) = Θ(n^0),属于主定理的第二种情况,解为 T(n) = Θ(n^{log_b(a)} * log n) = Θ(log n)。


结论: 递归二分查找算法的时间复杂度为 O(log n)。



总结




本节课中我们一起学*了二分查找的递归实现方法。我们首先定义了算法的基本情况和递归情况,然后逐步构建了完整的递归算法。最后,我们通过建立和求解递归关系式,证明了该算法具有 O(log n) 的优秀时间复杂度,这与迭代版本的二分查找效率一致。递归实现清晰地体现了分治策略的思想:将大问题不断分解,直至可以直接解决的小问题。
032:堆 📚
在本节课中,我们将要学*堆这一数据结构。堆是一种特殊的完全二叉树,常用于实现高效的排序算法(堆排序)和优先级队列。我们将从堆的基本表示方法开始,逐步学*堆的插入、删除操作,以及堆排序和堆化算法,最后了解堆在优先级队列中的应用。
堆的数组表示 🌳
上一节我们介绍了堆的基本概念,本节中我们来看看如何在计算机中表示堆。通常,我们使用数组来存储一个堆,因为这样可以高效地利用内存并快速访问父子节点。
对于一个存储在数组中的完全二叉树,如果任意节点的索引为 i,那么其父子节点的位置可以通过以下公式确定:
- 左子节点索引:
2 * i - 右子节点索引:
2 * i + 1 - 父节点索引:
floor(i / 2)
以下是使用数组表示二叉树的示例:
- 数组
[A, B, C, D, E, F, G]对应一个完全二叉树。 - 节点
B在索引 2,其左子节点D在索引2*2=4,右子节点E在索引2*2+1=5。 - 节点
F在索引 6,其父节点索引为floor(6/2)=3,即节点C。
为了自动满足这些公式,我们在将二叉树存入数组时,必须按层级顺序填充。如果某个位置没有节点,则必须留空。例如,一个缺少左子节点的二叉树,在数组中对应的位置应为空。
完全二叉树与满二叉树 🌲
在深入学*堆之前,我们需要明确两种特殊的二叉树:满二叉树和完全二叉树。
- 满二叉树:一棵高度为
h的二叉树,如果拥有最大可能数量的节点(即2^(h+1)-1个节点),则称为满二叉树。没有空间再添加任何新节点。 - 完全二叉树:一棵二叉树,如果其所有层级(除了最后一层)都被完全填满,并且最后一层的节点都尽可能靠左排列,则称为完全二叉树。在数组表示中,这意味着从第一个元素到最后一个元素之间没有任何空位。
重要结论:
- 所有满二叉树都是完全二叉树。
- 完全二叉树的高度是
log n,这是可能的最小高度。 - 堆必须是一棵完全二叉树。
堆的定义与类型 ⛰️


现在,让我们正式定义堆。堆是一种满足特定堆序性质的完全二叉树。根据堆序性质的不同,堆分为两种:
- 最大堆:树中任意节点的值都大于或等于其所有后代节点的值。因此,根节点存储着最大值。
- 示例:根节点为50,其子节点30和20均小于50;30的子节点15和10也小于30。
- 最小堆:树中任意节点的值都小于或等于其所有后代节点的值。因此,根节点存储着最小值。
- 示例:根节点为10,其子节点30和20均大于10;30的子节点35和40也大于30。
在后续学*中,我们将以最大堆为例,其原理同样适用于最小堆。
堆的插入操作 ⬆️
上一节我们了解了堆的结构,本节中我们来看看如何向一个最大堆中插入新元素。插入操作的目标是在添加新元素后,仍然保持堆的性质。
插入步骤如下:
- 将新元素添加到堆的末尾(即数组的下一个空闲位置)。这保证了树仍然是完全二叉树。
- 由于新元素可能破坏堆序性质,需要将其与父节点进行比较。
- 如果新元素大于其父节点,则交换它们的位置。
- 重复步骤3,将新元素不断与新的父节点比较并上浮,直到它不大于其父节点,或者到达根节点。
时间复杂度分析:在最坏情况下,新元素需要从叶子节点一直上浮到根节点,移动次数等于树的高度。由于完全二叉树的高度为 O(log n),因此插入操作的最坏时间复杂度为 O(log n)。最好情况下(新元素很小,无需上浮),时间复杂度为 O(1)。
核心要点:插入操作中,元素的调整方向是自底向上(从叶子到根)。
堆的删除操作 ⬇️
在堆中,我们通常只删除根节点的元素,因为根节点存储着最大(或最小)值,这是堆最有用的特性。
删除步骤如下:
- 移除根节点元素。
- 将堆中最后一个元素移动到根节点的位置。这保证了树仍然是完全二叉树。
- 新的根元素可能破坏堆序性质,需要将其与子节点进行调整。
- 将根节点与其较大的子节点进行比较。
- 如果根节点小于该子节点,则交换它们的位置。
- 重复步骤4和5,将元素不断下沉,直到它大于等于其所有子节点,或者到达叶子节点。
时间复杂度分析:在最坏情况下,元素需要从根节点一直下沉到叶子节点,移动次数等于树的高度,即 O(log n)。
核心要点:删除操作中,元素的调整方向是自顶向下(从根到叶子)。这与插入操作的方向相反。
堆排序算法 🔄
堆排序是一种基于堆数据结构的比较排序算法。它的思想非常巧妙:利用堆的根节点总是最大(或最小)值的特性,反复取出根节点,从而得到一个有序序列。
堆排序分为两个主要阶段:
第一阶段:建堆
将给定的无序数组构造成一个最大堆。
- 方法:可以视为从第一个元素开始,反复执行
n-1次插入操作。 - 时间复杂度:每次插入是
O(log n),n次插入总时间为O(n log n)。
第二阶段:排序
- 此时,堆的根节点(数组第一个位置)是最大元素。
- 将根节点与堆的最后一个元素交换。此时,最大元素被放到了数组末尾的正确位置,并且它不再属于堆(堆大小减1)。
- 对新的根节点执行一次“删除调整”(即下沉操作),使剩余元素重新构成最大堆。
- 重复步骤2和3,直到堆中只剩下一个元素。此时,数组已完全有序。
整体时间复杂度:建堆 O(n log n) + 排序 O(n log n) = O(n log n)。
空间复杂度:O(1)(原地排序)。
堆化算法 ⚡
上一节我们通过反复插入来建堆,时间复杂度是 O(n log n)。本节介绍一种更高效的建堆方法——堆化,它可以在 O(n) 时间内完成。
堆化算法的核心思想是“自底向上”地构建堆,但调整方向是“自顶向下”(下沉)。它从最后一个非叶子节点开始,向前遍历到根节点,对每个节点执行下沉操作,确保以该节点为根的子树满足堆性质。
以下是堆化步骤:
- 找到最后一个非叶子节点的索引(对于大小为
n的堆,索引为floor(n/2))。 - 从该节点开始,递减索引直到根节点(索引1):
- 对当前节点调用“下沉”操作,确保以其为根的子树是堆。
- 当遍历到根节点并完成下沉后,整个数组就构成了一个堆。
为什么更快? 堆化算法利用了子树高度较小的特点。大部分节点位于底层,它们下沉的代价很小。经过数学推导,其总时间复杂度为 O(n),优于反复插入的 O(n log n)。
优先级队列 🎯
优先级队列是一种抽象数据类型,每个元素都有一个“优先级”。出队时,总是先删除优先级最高(或最低)的元素。堆是实现优先级队列的理想数据结构。
堆如何实现优先级队列?
- 插入:相当于堆的插入操作 (
O(log n))。 - 删除最高优先级元素:相当于删除堆的根节点 (
O(log n))。
两种配置:
- 若数值越小优先级越高,则使用最小堆。
- 若数值越大优先级越高,则使用最大堆。
与使用普通有序或无序数组实现相比,堆在插入和删除操作上都提供了更优的 O(log n) 时间复杂度,使其成为实现高效优先级队列的首选。
总结 📝
本节课中我们一起学*了堆这一重要的数据结构。
- 我们首先学*了如何使用数组表示完全二叉树,以及满二叉树和完全二叉树的区别。
- 接着,我们定义了最大堆和最小堆,并详细讲解了堆的插入(上浮)和删除(下沉)操作,其时间复杂度均为
O(log n)。 - 基于删除操作总是取出最大元素的特性,我们推导出了堆排序算法,其时间复杂度为
O(n log n)。 - 然后,我们介绍了更高效的
O(n)堆化建堆算法。 - 最后,我们探讨了堆在实现优先级队列中的应用,展示了其在高频插入删除场景下的性能优势。

掌握堆及其相关操作,是理解许多高级算法(如图算法、调度算法)的基础。
033:归并排序 - 迭代法 📚


在本节课中,我们将要学*归并排序。为了理解归并排序,我们首先需要了解什么是“归并”。




什么是归并?🤔




归并是将两个已排序的列表合并成一个新的、已排序的列表的过程。
假设我们有两个列表 A 和 B,它们各自包含一些元素,并且都是已排序的。



列表 A: [3, 9, 18]
列表 B: [2, 8, 15, 17]

为了合并它们,我们需要准备第三个列表 C。我们使用三个指针:i 指向列表 A 的当前元素,j 指向列表 B 的当前元素,k 指向列表 C 的下一个空位。


以下是合并过程的步骤:
- 比较
A[i]和B[j]。 - 将较小的元素复制到
C[k]。 - 移动较小元素所在列表的指针(
i或j)以及k。 - 重复步骤 1-3,直到其中一个列表的所有元素都被处理完。
- 将另一个列表中剩余的所有元素直接复制到列表 C 的末尾。






通过这个过程,我们最终得到一个合并后的有序列表 C。



这个过程的特性是:如果输入的两个列表已经有序,那么合并后的结果也是有序的。





归并的时间复杂度 ⏱️





假设列表 A 有 m 个元素,列表 B 有 n 个元素。合并过程需要将总共 m + n 个元素从原列表复制到新列表,并进行最多 m + n - 1 次比较。因此,归并过程的时间复杂度是 Θ(m + n)。



归并算法 📝

上一节我们介绍了归并的直观过程,本节中我们来看看它的算法描述。




以下是归并算法的伪代码:


Algorithm Merge(A, B, m, n)
i = 1, j = 1, k = 1 // 初始化指针
while i <= m and j <= n do
if A[i] < B[j] then
C[k] = A[i]
i = i + 1
else
C[k] = B[j]
j = j + 1
end if
k = k + 1
end while
// 复制列表A中剩余的元素
while i <= m do
C[k] = A[i]
i = i + 1
k = k + 1
end while
// 复制列表B中剩余的元素
while j <= n do
C[k] = B[j]
j = j + 1
k = k + 1
end while
这个算法清晰地展示了我们之前描述的步骤:比较、复制、处理剩余元素。

多路归并与二路归并 🔄



理解了如何合并两个列表后,我们来看看如何合并多个列表。




理论上,我们可以一次合并多个列表(例如四个),这被称为 M路归并。在M路归并中,我们比较所有列表的第一个元素,选择最小的复制到结果列表,然后移动该列表的指针。


然而,更常见且简单的方法是 二路归并,即每次只合并两个列表。当有多个列表需要合并时,我们可以通过不同的配对模式,多次应用二路归并,最终将它们合并成一个列表。




例如,有四个列表 A, B, C, D,我们可以:
- 先合并 A 和 B 得到列表 L1,合并 C 和 D 得到列表 L2。
- 最后合并 L1 和 L2 得到最终列表。


二路归并排序 🧩




现在,让我们应用二路归并的思想来进行排序,这就是二路归并排序。



假设我们有一个包含8个无序元素的数组:[9, 3, 7, 5, 6, 4, 8, 2]。



二路归并排序的迭代法工作流程如下:


核心思想:将数组中的每个元素都视为一个已排序的(单个元素的)列表。然后,反复进行二路归并,直到所有元素合并成一个有序列表。




第一趟(Pass 1):
- 将相邻的“单元素列表”两两合并。
[9]和[3]合并为[3, 9][7]和[5]合并为[5, 7][6]和[4]合并为[4, 6][8]和[2]合并为[2, 8]- 此时,我们得到4个已排序的列表,每个列表包含2个元素。




第二趟(Pass 2):
- 继续将相邻的列表两两合并。
[3, 9]和[5, 7]合并为[3, 5, 7, 9][4, 6]和[2, 8]合并为[2, 4, 6, 8]- 此时,我们得到2个已排序的列表,每个列表包含4个元素。



第三趟(Pass 3):
- 合并最后两个列表。
[3, 5, 7, 9]和[2, 4, 6, 8]合并为最终的排序数组[2, 3, 4, 5, 6, 7, 8, 9]。
注意:在实际编程中,合并操作通常需要一个额外的数组(B)来存放中间结果,完成一趟合并后,再将结果复制回原数组(A)。
算法分析 📊





我们来分析一下二路归并排序(迭代法)的时间复杂度。




- 每趟的工作量:在每一趟归并中,我们需要处理数组中的所有
n个元素(进行比较和复制)。因此,单趟时间复杂度为 Θ(n)。 - 需要的趟数:从
n个单元素列表开始,每合并一次,列表数量大约减半。直到合并成一个列表为止。这个过程所需的次数是 log₂ n(以2为底n的对数)。- 例如,n=8时,列表数量变化为:8 -> 4 -> 2 -> 1,共需要3趟,而 log₂ 8 = 3。




因此,总的时间复杂度是:
每趟时间复杂度 Θ(n) × 趟数 log n = Θ(n log n)



总结 ✨





本节课中我们一起学*了归并排序的迭代方法——二路归并排序。





我们首先理解了归并的核心概念,即合并两个有序列表为一个新的有序列表,其时间复杂度为 Θ(m+n)。接着,我们探讨了如何通过反复应用二路归并,将初始无序数组(视为多个单元素有序列表)逐步合并,最终完成整个数组的排序。





二路归并排序的迭代过程清晰展示了 “分治” 思想中 “治” 的环节,其时间复杂度稳定在优秀的 Θ(n log n) 级别。在下一节中,我们将会学*归并排序的另一种实现方式——递归法。
034:归并排序算法 🧩







在本节课中,我们将要学*归并排序算法。这是一种基于“分而治之”策略的递归排序算法。我们将了解其工作原理、算法步骤、执行过程,并分析其时间复杂度。







概述





我们已经了解了什么是合并操作,以及如何将多个列表合并在一起,例如两路合并或多路合并。如果存在多个列表,我们也看到了可以按照不同的模式进行合并。现在,让我们来看看归并排序,这是一个递归过程。





分而治之策略



归并排序遵循分而治之的策略。该策略指出,如果一个问题规模很大,就将其分解为子问题。当问题变得足够小时,直接解决它,然后将这些小问题或子问题的解决方案组合起来,从而得到主问题的解决方案。







在这里,问题是对一组元素进行排序。我们认为列表非常大。那么什么是“小”问题呢?基本上,如果只有一个元素,我们就认为它足够小。单个元素本身已经是有序的,无需排序。因此,在归并排序中,如果只有一个元素,我们就不需要排序它。否则,即使只有两个元素,我们也将其视为一个较大的问题。






归并排序算法




由于归并排序遵循分而治之,因此它是一个递归算法。以下是其算法描述。





算法:归并排序
它接受两个参数,即列表的起始位置和结束位置。





MergeSort(A, low, high)
if low < high
mid = (low + high) / 2
MergeSort(A, low, mid)
MergeSort(A, mid+1, high)
Merge(A, low, mid, high)



low和high是列表的起始和结束索引。- 我们检查
low是否小于high。如果low等于high,意味着只有一个元素。low小于high意味着至少有两个元素,因此列表规模较大。 - 如果
low小于high,我们找出中间位置mid,即(low + high) / 2。 - 我们找到
mid是为了将这个大规模问题分解为两个子问题。 - 然后,我们对左半部分递归执行归并排序(从
low到mid)。 - 同样,对右半部分递归执行归并排序(从
mid+1到high)。 - 通过对左半部分和右半部分分别排序,我们将列表分成了两个已排序的列表,然后可以将它们合并在一起。因此,最后执行合并操作
Merge。





合并操作 Merge 的过程我们已经学*过。这里的关键点是,当给定一个单独的数组进行排序时,我们将其分解为两个子数组,并称其为第一个列表和第二个列表。因此,单个数组可以包含多个列表,我们需要合并它们。



算法执行过程




让我们通过一个例子来追踪算法的执行过程。假设我们有以下元素:[9, 3, 7, 5, 6, 4, 8, 2]。


初始时,我们传入 low=1 和 high=8(假设索引从1开始)。算法会找到中间点,并将列表分成两半,然后递归地对每一半进行排序和合并。


以下是执行步骤的简化描述:





- 将整个列表
[9,3,7,5,6,4,8,2]分成左半部分[9,3,7,5]和右半部分[6,4,8,2]。 - 对左半部分
[9,3,7,5]递归调用归并排序:- 分成
[9,3]和[7,5]。 - 对
[9,3]排序:分成[9]和[3],合并为[3,9]。 - 对
[7,5]排序:分成[7]和[5],合并为[5,7]。 - 合并
[3,9]和[5,7]得到[3,5,7,9]。
- 分成
- 对右半部分
[6,4,8,2]递归调用归并排序:- 分成
[6,4]和[8,2]。 - 对
[6,4]排序:分成[6]和[4],合并为[4,6]。 - 对
[8,2]排序:分成[8]和[2],合并为[2,8]。 - 合并
[4,6]和[2,8]得到[2,4,6,8]。
- 分成
- 最后,合并已排序的左半部分
[3,5,7,9]和右半部分[2,4,6,8],得到最终排序结果[2,3,4,5,6,7,8,9]。


这个过程就像一棵二叉树的后续遍历:先深入处理所有左子树,然后处理右子树,最后在根节点进行合并操作。


时间复杂度分析






现在,让我们找出这个算法的时间复杂度。我们可以通过建立递归关系式来分析。




假设算法处理 n 个元素的时间为 T(n)。在递归步骤中,算法将问题分成两个大小为 n/2 的子问题,并分别解决它们(各耗时 T(n/2)),然后合并结果(耗时与 n 成线性关系,记为 n)。此外,还有一些常数时间的操作(如计算中点)。






因此,递归关系式为:
T(n) = 2T(n/2) + n,当 n > 1 时。
并且,T(1) = 1(或某个常数)。






我们可以使用主定理来求解这个递归式。这里,a = 2,b = 2,f(n) = n。
计算 log_b(a) = log_2(2) = 1。
由于 f(n) = n^1,且 log_b(a) = 1,两者相等,符合主定理的情况二。



情况二的结果是时间复杂度为:
T(n) = Θ(n log n)。




从递归树的角度也可以理解:合并操作在每一层上都处理了所有 n 个元素,而递归树的高度是 log₂ n。因此,总时间为 n 乘以层数,即 O(n log n)。





总结




本节课中,我们一起学*了归并排序算法。它是一种高效、稳定的排序算法,基于分而治之的递归思想。其核心步骤是分解、递归排序、合并。我们详细分析了算法流程,并通过实例追踪了其执行过程。最后,我们推导出归并排序的时间复杂度为 Θ(n log n),这使其在处理大规模数据时非常有效。它与我们之前了解的两路归并思想相似,但通过递归实现了系统性的分解与合并。
035:归并排序深度分析 📊



在本节课中,我们将深入探讨归并排序的优缺点。我们将了解它为何适用于处理大规模数据,以及它在特定场景下的局限性。

归并排序的优点 ✅


上一节我们介绍了归并排序的基本原理,本节中我们来看看它的具体优势。




适用于大规模列表 📈
归并排序非常适合处理规模非常大的列表。如果你有数百万个数字或记录,可以使用归并排序进行排序。其他排序算法通常难以支持如此大规模的数据。




适用于链表 🔗
归并排序非常适合链表数据结构。以下是其工作原理:
- 归并排序的核心是合并操作。
- 对于两个已排序的链表,合并时无需创建第三个链表。
- 通过直接改变节点间的链接,即可将两个链表合并成一个有序链表。

例如,合并链表 [2, 3, 8] 和 [3, 9, 11, 12]:
- 比较
2和3,2较小,作为新链表的头。 - 比较
3和3,取第一个链表的3。 - 比较
8和3,取第二个链表的3。 - 以此类推,最终通过改变链接得到有序链表
[2, 3, 3, 8, 9, 11, 12]。





因此,可以在链表上执行归并排序,而无需创建额外的链表。


支持外部排序 💾
合并过程支持外部排序,因此归并排序也支持。外部排序是指当数据量过大,无法全部加载到内存(主存)中进行排序时使用的技术。



假设硬盘上有两个非常大的文件(例如5GB和10GB),而内存只有4GB。无法将整个文件读入内存进行合并。解决方法如下:
- 将大文件分成多个块(或称为“段”)。
- 每次从每个文件中读取一小块数据到内存中。
- 在内存中对这些小块进行合并。
- 将合并结果写回硬盘。
- 重复此过程,直到所有数据块处理完毕。


通过这种方式,归并排序可以利用外部合并技术对海量数据进行排序。


稳定的排序算法 ⚖️
归并排序是稳定的排序算法。稳定性是指如果排序前存在相等的元素,排序后它们的相对顺序保持不变。

例如,有一个学生记录列表,初始按姓名排序。现在需要按分数排序。如果有两个学生分数相同(例如都是8分),稳定的排序能保证在按分数排序后,原先姓名排在前的学生记录仍然排在前面。这对于需要保持多重排序顺序的场景非常重要。

归并排序的缺点 ❌



了解了归并排序的优点后,我们再来看看它的一些不足之处。




需要额外的存储空间 🧱
归并排序不是原地排序算法。原地排序是指算法仅使用原始数据结构的固定额外空间进行排序。



对于数组,归并排序在合并两个已排序子数组时,无法将结果直接存回原数组的同一位置而不覆盖未处理的数据。因此,它需要一个与原数组同样大小的辅助数组来存放合并结果。其空间复杂度为 O(n)。



注意:对于链表,归并排序可以通过改变链接实现,不需要额外的存储空间,因此此缺点主要针对数组结构。



对小规模问题效率较低 🐌
归并排序采用分治和递归策略。它没有明确界定“小问题”的规模,递归会一直分解到单个元素为止。


观察发现,对于元素数量很少的列表(例如少于或等于15个),归并排序由于递归调用的开销,其实际运行速度可能比一些简单排序算法(如插入排序)更慢。
因此,一种常见的优化策略是混合排序:当待排序的子数组规模小于某个阈值时,改用插入排序进行处理,然后再用归并排序合并结果。



递归调用占用栈空间 🥞
归并排序是递归算法,递归调用需要使用系统栈来保存每次函数调用的状态(激活记录)。




递归树的高度决定了栈空间的最大使用量。对于包含 n 个元素的数组,归并排序递归树的高度是 log₂ n。因此,算法需要的栈空间复杂度为 O(log n)。

综合来看,归并排序的总空间消耗包括 O(n) 的辅助数组空间和 O(log n) 的栈空间。


总结 📝




本节课中我们一起学*了归并排序的优缺点。


优点:
- 适用于大规模数据列表。
- 非常适合链表数据结构,可原地排序。
- 支持外部排序,能处理远超内存容量的大型数据集。
- 是稳定的排序算法,能保持相等元素的原始相对顺序。




缺点:
- 对数组排序时,需要 O(n) 的额外辅助空间,不是原地排序。
- 对于小规模列表,递归开销可能导致其效率不如插入排序等简单算法。
- 递归实现需要 O(log n) 的栈空间。

在实际应用中,常采用优化策略,如对小规模子数组使用插入排序,以提升整体性能。理解这些特性有助于我们在不同场景下选择合适的排序算法。
036:变量与数据类型 🐍
在本节课中,我们将要学*Python编程中最基础也是最重要的两个概念:变量和数据类型。理解它们是编写任何Python程序的第一步。
什么是变量? 📦
变量是计算机内存中用于存储数据的“盒子”或“标签”。你可以把数据放进去,也可以取出来使用。在Python中,创建一个变量并给它赋值非常简单。
上一节我们介绍了课程概述,本节中我们来看看如何创建和使用变量。
变量的命名与赋值
在Python中,使用等号 = 来为变量赋值。等号左边是变量名,右边是你要存储的值。
以下是变量赋值的基本语法:
变量名 = 值
例如:
name = "小明"
age = 20
score = 95.5
is_student = True
在这个例子中,我们创建了四个变量:
name存储了一个文本(字符串)。age存储了一个整数。score存储了一个带小数点的数字(浮点数)。is_student存储了一个表示“是”或“否”的值(布尔值)。
变量名可以包含字母、数字和下划线,但不能以数字开头,并且区分大小写(例如 Age 和 age 是两个不同的变量)。
认识数据类型 🏷️
Python中的数据有不同的“类型”,就像现实世界中有书籍、水果、电器等分类一样。了解数据的类型很重要,因为它决定了你能对这个数据做什么操作。
上一节我们学会了如何把数据放进变量,本节中我们来看看Python中主要有哪些数据类型。
Python有几种内置的核心数据类型,最常见的有以下几种:
1. 整数 (int)
整数是不带小数点的数字,可以是正数、负数或零。
my_int = 42
temperature = -10
count = 0
2. 浮点数 (float)
浮点数是带有小数点的数字,用于表示更精确的数值。
pi = 3.14159
price = 19.99
average = 85.5
3. 字符串 (str)
字符串是由一系列字符组成的文本数据,需要用单引号 ‘ ’ 或双引号 “ ” 括起来。
greeting = “你好,世界!”
username = ‘Python新手’
4. 布尔值 (bool)
布尔值只有两种:True(真)和 False(假)。它们通常用于逻辑判断。
is_raining = False
has_passed = True
如何查看数据类型?
你可以使用Python内置的 type() 函数来查看任何变量或值的数据类型。
以下是使用 type() 函数的示例:
print(type(10)) # 输出:<class ‘int’>
print(type(3.14)) # 输出:<class ‘float’>
print(type(“Hello”)) # 输出:<class ‘str’>
print(type(True)) # 输出:<class ‘bool’>
x = 100
print(type(x)) # 输出:<class ‘int’>
总结 📝
本节课中我们一起学*了Python编程的基石:变量和数据类型。
- 变量是存储数据的命名容器,通过
=赋值。 - 数据类型定义了数据的种类和可执行的操作,我们主要接触了四种基本类型:
- 整数 (int):用于表示整数值。
- 浮点数 (float):用于表示带小数的数值。
- 字符串 (str):用于表示文本信息。
- 布尔值 (bool):用于表示逻辑真值(True/False)。
- 可以使用
type()函数来检查任何数据的数据类型。
理解这些概念是后续学*更复杂的操作(如数学运算、文本处理、条件判断)的基础。请尝试创建一些自己的变量,并为它们赋予不同类型的数据,然后使用 print() 和 type() 函数来观察结果。
037:快速排序分析 ⚡


在本节课中,我们将要学*快速排序算法的复杂度分析。我们将探讨其最佳情况、最坏情况的时间复杂度,并了解如何通过选择枢轴元素来优化算法性能。






快速排序的递归与分治策略


快速排序是一个递归算法,遵循分治策略。它使用一个分区算法来找出枢轴元素的最终位置,并以此将列表划分为两个子列表。


假设我们有一个包含15个元素的列表。


[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]


如果我们调用快速排序,传入第一个索引1和最后一个索引15,并且假设分区点正好在列表中间(索引8),那么我们将得到:


- 左侧子列表:元素1到7。
- 右侧子列表:元素9到15。



最佳情况分析

现在,让我们分析其时间复杂度。一个重要前提是,我们假设每次分区都恰好发生在列表的中间。
如果分区始终发生在中间,那么递归过程会形成一个平衡的二叉树。在每一层递归中,分区算法都需要遍历当前处理的整个(子)列表。对于一个包含 n 个元素的初始列表:



- 第一层:处理
n个元素。 - 第二层:两个子列表共处理约
n个元素。 - 第三层:四个子列表共处理约
n个元素。 - … 以此类推。




那么,树的高度是多少?由于每次都将列表对半划分,直到子列表只剩一个元素。设划分次数为 k,则有 n / 2^k = 1,推导出 k = log₂ n。因此,树有 log n 层。



在每一层,处理时间约为 n。所以,总的时间复杂度为:
T(n) = n * log n = O(n log n)


这个结果基于“分区点始终在中间”的假设,也就是快速排序的最佳情况。


中位数与最佳情况的可行性


中位数是排序后列表的中间元素。例如,列表 [1,2,3,4,5,6,7] 的中位数是 4。
快速排序要达到 O(n log n) 的最佳情况,要求每次选择的枢轴元素都恰好是当前子列表的中位数。然而,在排序完成之前,我们无法预知中位数。虽然随机选择有可能恰好选中中位数,但我们无法保证这一点。因此,刻意实现最佳情况通常是不可能的。


最坏情况分析


什么是最坏情况?考虑一个已经按升序排好的列表:[2, 4, 8, 10, 16, 18, 17]。



如果快速排序总是选择第一个元素作为枢轴(这是常见实现),那么对于已排序的列表:
- 第一次分区后,枢轴
2位于起始位置,右侧是剩余n-1个元素的子列表。 - 第二次对右侧子列表分区,枢轴
4又位于其子列表的起始位置。 - 这个过程一直持续,每次分区只减少一个元素。

此时,递归树退化成一条高度为 n 的链。每次分区的比较次数为:n, n-1, n-2, ..., 2, 1。


总比较次数是等差数列求和:
T(n) = n + (n-1) + ... + 1 = n(n+1)/2 = O(n²)



因此,快速排序的最坏情况时间复杂度是 O(n²),当输入列表已经有序(或逆序)且选择首(或尾)元素为枢轴时会发生。

优化建议:枢轴选择策略

最坏情况 O(n²) 是个问题,尤其当我们需要对可能已有序的数据反复排序时。为了缓解这个问题,我们可以改变枢轴的选择策略:


以下是两种改进建议:

- 选择中间元素作为枢轴:如果列表已排序,选择中间元素能立刻将列表均分,将最坏情况转变为最佳情况
O(n log n)。 - 随机选择一个元素作为枢轴:随机化可以避免因固定模式(如已排序)导致的持续最坏性能。虽然理论上仍有可能出现
O(n²),但概率极低,平均时间复杂度仍为O(n log n)。

空间复杂度分析


快速排序是递归算法,它本身不需要额外的数组空间(原地排序),但需要使用调用栈来保存递归信息。

- 最佳情况:递归树平衡,高度为
log n,因此栈的最大深度为O(log n)。 - 最坏情况:递归树退化为链,高度为
n,因此栈的最大深度为O(n)。

所以,快速排序的空间复杂度在 O(log n) 到 O(n) 之间,取决于递归的深度。


总结


本节课中我们一起学*了快速排序的复杂度分析:
- 最佳情况时间复杂度为 O(n log n),当每次分区都能将列表均匀划分时达到,但这通常难以保证。
- 最坏情况时间复杂度为 O(n²),当输入列表已排序且总是选择边缘元素作为枢轴时发生。
- 平均情况时间复杂度为 O(n log n),这是其高效的原因。
- 通过优化枢轴选择策略(如选择中位数或随机选择),可以显著降低遇到最坏情况的概率。
- 其空间复杂度取决于递归深度,范围在 O(log n) 到 O(n) 之间。



理解这些情况有助于我们在实际应用中更好地使用和优化快速排序算法。
038:Strassen矩阵乘法 🧮

在本节课中,我们将要学*Strassen矩阵乘法。这个主题属于分治策略的范畴。Strassen矩阵乘法遵循分治策略。


首先,我们将了解矩阵乘法通常是如何进行的。其次,我们将看到分治策略如何应用于解决矩阵乘法问题。最后,我们将探讨Strassen如何通过分治策略改进矩阵乘法。

常规矩阵乘法
让我们从矩阵乘法开始。这里有两个矩阵A和B,它们的阶数都是2x2。当两个矩阵相乘时,只有当第一个矩阵的列数等于第二个矩阵的行数时,它们才能相乘。结果矩阵C的维度将是2x2。



现在,我们需要将这两个矩阵相乘得到第三个矩阵C。如何相乘呢?这是一个简单的问题。矩阵C中的任何元素C[i][j]可以通过以下公式获得:
C[i][j] = Σ (A[i][k] * B[k][j]),其中k的取值范围是从1到n(这里n是矩阵的维度)。对于任意维度的矩阵相乘,我们这里以2x2矩阵为例。


如果我们使用通用公式来编写矩阵乘法的算法或代码片段,让我们看看代码是什么样子的。



为了计算矩阵C,我们可以使用三层循环:
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
C[i][j] = 0;
for (int k = 0; k < n; k++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
访问矩阵的所有元素需要两个循环。为了计算乘积,每个元素C[i][j]是通过将A的一行元素与B的一列元素相乘得到的,因此我们需要第三个循环。这就是为什么它需要三个for循环。
如果有三个for循环,每个循环运行n次,那么时间复杂度将是O(n³)。这是矩阵乘法的简单方法。

应用分治策略

现在,让我们尝试采用分治策略来解决这个问题。首先,我们需要观察如何应用分治策略。


在分治策略中,如果问题规模很大,我们将问题分解为子问题,解决这些子问题,然后合并子问题的解以获得主问题的解。当问题规模很大时,我们也需要定义什么是小问题。如果问题规模小,我们将直接解决它。



首先,我们定义一个小问题。在分治策略中,我们应该定义多大的矩阵是小的。我们定义2x2矩阵是小的。如果矩阵是2x2,我们将直接相乘。如果矩阵的维度大于2x2,例如4x4或8x8,我们将应用分治策略,即将问题分解为子问题。



为了了解如何解决小问题,我们以两个2x2矩阵为例。我们需要在相乘后得到结果矩阵C。如何得到C矩阵的各项呢?让我们来计算它们。


要得到c11,可以通过a11 * b11 + a12 * b21计算。
要得到c12,可以通过a11 * b12 + a12 * b22计算。
要得到c21,可以通过a21 * b11 + a22 * b21计算。
要得到c22,可以通过a21 * b12 + a22 * b22计算。






对于2x2矩阵,我们可以直接使用这四个公式相乘,而不需要使用三层循环。如果我们计算这里花费的时间,每个语句可以视为一个单位时间,总时间是常数。这里有8次乘法,这也是常数。总之,这四个公式是常数操作。这被定义为一个“小问题”。
如果你想进一步减小规模,如果是1x1矩阵,那么只有一个元素,只需一次乘法。所以,如果维度小于或等于2,就是一个小问题。如果是2x2,使用这四个公式;如果是1x1,直接相乘。




如果矩阵大于2x2呢?那么我们需要将问题分解为子问题并解决它们。为此,我们假设矩阵的维度是2的幂次方,例如2x2、4x4、8x8、16x16等。这样,当我们不断分割时,最终会达到2x2的大小。如果不是2的幂次方,可以填充零使其成为2的幂次方的方阵。



现在,让我展示如果是4x4矩阵,我们将如何应用分治策略。




这里有两个4x4矩阵。我们说这个问题很大,需要将问题分解为子问题,解决子问题,然后合并解。如何做到呢?这是4x4矩阵,行和列都是从1到4。我们说这个维度必须除以2,所以这个矩阵被分割成四个2x2矩阵。同样,另一个矩阵也被分割成四个2x2矩阵。




现在我们得到四个2x2矩阵。我们可以将每个2x2矩阵视为一个元素。例如,将左上角的2x2矩阵称为A11,右上角的称为A12,左下角的称为A21,右下角的称为A22。同样,对于B矩阵,称为B11、B12、B21、B22。现在,如果我将这些矩阵视为单个元素,那么整个4x4矩阵乘法就变成了一个2x2“矩阵”的乘法问题,其中每个“元素”本身是一个2x2矩阵。







我们可以直接解决这个2x2“矩阵”的乘法吗?可以。我们遵循相同的公式,例如,C11 = A11 * B11 + A12 * B21,但这里的乘法和加法是矩阵的乘法和加法。



分治算法
现在,让我们看看如何编写一个分治算法来相乘两个矩阵。我将算法命名为MatrixMultiplication(A, B, n),它接受矩阵A、B和维度n作为参数。




这不是一个可以直接转换为程序的详细代码,而是一个算法描述。

Algorithm MatrixMultiplication(A, B, n):
if n <= 2 then
// 直接使用四个公式计算C
C[1][1] = A[1][1]*B[1][1] + A[1][2]*B[2][1]
C[1][2] = A[1][1]*B[1][2] + A[1][2]*B[2][2]
C[2][1] = A[2][1]*B[1][1] + A[2][2]*B[2][1]
C[2][2] = A[2][1]*B[1][2] + A[2][2]*B[2][2]
else
// 分割矩阵
mid = n / 2
// 将A和B分割为四个子矩阵 A11, A12, A21, A22, B11, B12, B21, B22
// 递归计算C的子部分
C11 = MatrixMultiplication(A11, B11, mid) + MatrixMultiplication(A12, B21, mid)
C12 = MatrixMultiplication(A11, B12, mid) + MatrixMultiplication(A12, B22, mid)
C21 = MatrixMultiplication(A21, B11, mid) + MatrixMultiplication(A22, B21, mid)
C22 = MatrixMultiplication(A21, B12, mid) + MatrixMultiplication(A22, B22, mid)
// 将C11, C12, C21, C22组合成最终的C矩阵
end if
return C
这里我写了一个递归代码。它递归地调用自身来处理更小尺寸的矩阵。我在这里应用了四个公式,但这里的加法是矩阵加法,不是普通的标量加法。




分治算法的时间复杂度
现在让我们分析这个算法执行需要多少时间。这个算法使用分治策略,是一个递归算法。你可以看到它递归调用了自身8次。让我们为此建立一个递归关系式。


递归式将是:
T(n) = 8 * T(n/2) + O(n²),当 n > 2 时。
当 n <= 2 时,T(n) = O(1)。
这里,8 * T(n/2) 是因为算法在大小为 n/2 的子问题上递归调用了8次。O(n²) 是合并步骤中矩阵加法所花费的时间。



如果我们应用主定理,a=8,b=2,f(n)=n²。计算 log_b(a) = log_2(8) = 3。f(n) = n^k,其中 k=2。因为 3 > 2,这属于主定理的情况一。因此,这个递归式的时间复杂度是 O(n^(log_b(a))) = O(n^3)。

分治算法的时间复杂度也是O(n³)。无论你是使用三层循环的简单算法,还是应用分治策略,都是O(n³)。但是分治算法是递归的,因此内部使用了栈,会消耗额外的空间。所以在空间方面,仅使用三层循环的算法比分治算法更好。



Strassen的改进




由于这个算法的时间复杂度是O(n³),而我们看到的大多数算法时间复杂度通常是n²或logn,那么有没有可能减少这个算法的时间呢?这是一个研究课题。如果你能找到任何更快的方法来相乘两个矩阵,那将对人类非常有益。


Strassen做了什么?让我们来看看。我们已经看到了用于直接相乘两个矩阵的四个公式(有8次乘法),我在递归函数中使用了这些公式。在这里,你可以看到总共有8次乘法。


Strassen的主要工作在于,矩阵乘法的主要开销是乘法运算。如果我们能减少乘法的次数,就能使算法更快。这就是他采取的方法。他没有使用那四个需要8次乘法的公式,而是给出了不同的公式,只需要7次乘法。这将使算法的时间复杂度略低于n³。

让我们看看他给出了什么。以下是Strassen的公式。这些是用于直接相乘两个矩阵以获得第三个矩阵C的四个公式,但需要8次乘法。这些可能是简单元素,也可能是矩阵。我们已经看到了分治方法。

现在,Strassen减少了乘法的次数。另外,加法和减法也增加了,但加法和减法比乘法花费的时间少。Strassen的公式减少了乘法次数,这是无疑的。

以下是他的公式。他说找出这些公式P、Q、R、S、T、U、V,它们涉及A和B的元素。如果你看乘法,这里有7次乘法。



他给出了7个公式,命名为P到V,包含7次乘法。这些公式被用来计算C的元素,即C11、C12、C21、C22。因此,代替原来的四个公式,我们现在有这些公式。


毫无疑问,公式的数量增加了,加法和减法的次数也增加了,但乘法的次数减少了。如果应用分治策略,算法将调用自身多次,但总共只调用7次,而不是8次。



那么,这个算法的递归关系式将是:
T(n) = 7 * T(n/2) + O(n²),当 n > 2 时。
当 n <= 2 时,T(n) = O(1)。

这个递归关系式的阶数改变了。log_2(7) 约等于2.81,而k=2。因为2.81 > 2,根据主定理,时间复杂度是 O(n^(log_2(7))) ≈ O(n^2.81)。



因此,Strassen将时间复杂度从O(n³)降低到了O(n^2.81),略低于n³。这就是Strassen矩阵乘法。



总结


在本节课中,我们一起学*了矩阵乘法。我们首先回顾了常规的三层循环方法,其时间复杂度为O(n³)。接着,我们探讨了如何应用分治策略来解决矩阵乘法问题,并发现其时间复杂度同样为O(n³),且由于递归会占用更多空间。最后,我们介绍了Strassen的改进算法,他通过设计一组仅需7次乘法的公式,成功将时间复杂度降低到了约O(n^2.81),这是矩阵乘法领域的一个重要突破。
039:贪心算法简介 🎯


在本节课中,我们将要学*一种重要的算法设计策略——贪心算法。我们将了解它的基本思想、适用场景以及它如何通过一系列局部最优选择来尝试解决全局优化问题。






什么是贪心算法?🤔

贪心算法是解决问题的一种策略,与分治法等其他策略类似。它是一种可以用于解决特定类型问题的通用方法或设计模式。如果一个问题符合贪心算法的适用条件,你就可以用这种方法来解决它。





优化问题与核心概念 📈

贪心算法主要用于解决优化问题。优化问题是指那些要求得到最小结果或最大结果的问题。




为了理解贪心算法,我们需要先明确几个核心概念。让我们通过一个例子来解释。



假设有一个问题 P:我想从地点 A 旅行到地点 B。





对于这个问题,可能存在多种解决方案:
- 方案1:步行。
- 方案2:骑自行车。
- 方案3:开车。
- 方案4:乘坐火车。
- 方案5:乘坐飞机。
- 可能还有其他方案。

但是,问题中通常存在约束条件。例如,我必须在2小时内完成这段旅程。那么,步行或开车可能无法满足这个条件,只有乘坐火车或飞机才可行。



现在,我们引入几个关键定义:

- 可行解:一个满足问题给定约束条件的解决方案,称为可行解。一个问题可能有多个可行解。
- 最优解:在所有可行解中,能够实现问题目标(成本最低或收益最高)的那个解,称为最优解。对于任何问题,最优解通常是唯一的(例如,最低成本只有一个)。
- 优化问题:一个要求得到最小或最大结果的问题,就称为优化问题。





简单总结:
- 可行解:满足约束的解。
- 最优解:满足约束且实现目标(最小/最大)的解。
- 优化问题:寻求最优解的问题。

贪心算法的方法论 ⚙️


贪心算法是解决优化问题的策略之一。它的核心思想是:问题应该分阶段解决,在每一个阶段,都做出一个在当前看来最优的(贪心的)选择。
以下是贪心算法的一般性描述(伪代码):



Algorithm Greedy(a, n)
// a[1:n] 包含了 n 个输入
{
solution = ∅; // 初始化解集为空
for i = 1 to n do
{
x = Select(a); // 从输入中选择一个元素 x
if Feasible(solution, x) then // 如果 x 加入当前解集后仍是可行的
solution = Union(solution, x); // 将 x 并入解集
}
return solution;
}



算法流程说明:
- 将问题分解为多个阶段。
- 在每一阶段,从给定的输入中选择一个元素
x。 - 检查将
x加入当前的部分解后,是否仍然满足问题的约束(即可行)。 - 如果可行,就将
x包含进最终的解中。 - 重复此过程,直到处理完所有输入。


通过这种方式,将所有可行的输入包含进来,期望得到的就是一个最优解。


贪心算法的现实类比 🚗💼


为了更好地理解贪心思想,让我们看两个生活中的例子。
例子1:购买最好的汽车
假设你要买一辆“最好”的汽车(这里“最好”指特性最优,可能意味着价格最高)。一种方法是检查市场上所有品牌和型号的汽车,然后选出最好的。但这非常耗时。
实际上,我们通常这样做:首先,根据品牌声誉筛选(例如,只考虑丰田或现代)。然后,在这些品牌中筛选高端车型。接着,在高端车型中选择最新发布或口碑最好的。最终,你选定了一款车,并认为它是最好的。
你并没有检查所有车辆,而是依据一系列快速的、局部的筛选标准(品牌 -> 车型 -> 新款/口碑)做出了选择。你相信按照这个“贪心”的筛选流程得到的结果就是最优的。这就是一种贪心策略。


例子2:公司招聘
假设公司要招聘一名员工,有上千人应聘。你不会对所有人都进行完整的多轮面试(笔试、技术面试、HR面试),因为那成本极高。
通常的流程是:先进行笔试筛选掉一部分人,再进行小组讨论或技术面试筛选,最后进行HR面试。最终被选中的人,是根据你设定的这一系列筛选流程得出的“最优”人选。

虽然可能有落选者认为自己更优秀,但根据公司设定的(贪心的)选拔流程,被选中者就是该流程下的最优解。我们不会为了寻找“绝对最优”而去对所有人进行全套深度考核。
总结 📚

本节课我们一起学*了贪心算法的基本概念:
- 贪心算法是一种用于解决优化问题(求最小或最大)的策略。
- 它通过分阶段处理问题,在每一阶段都做出一个局部最优选择,并希望这些局部最优能最终导向全局最优解。
- 其核心流程可以概括为:选择 -> 检查可行性 -> 加入解集。
- 贪心策略在现实中很常见,它的优势在于高效、直接,但需要注意的是,并非所有问题都适用贪心算法,它不一定总能得到真正的全局最优解。

在接下来的课程中,我们将通过具体的问题实例,来深入探讨如何应用贪心算法,并分析其适用场景与局限性。
040:贪心算法解决背包问题 🎒



在本节课中,我们将学*一个经典问题——背包问题。我们将了解问题是什么,如何解决它,并理解贪心算法如何在此处应用。

背包问题是一个著名的优化问题。想象一下,我们有一些物品,每个物品都有其对应的利润和重量。同时,我们有一个容量有限的背包。我们的目标是在不超过背包容量的前提下,选择物品放入背包,使得总利润最大化。这是一个典型的“容器装载”问题,在日常生活中很常见。
问题定义与约束
首先,让我们明确问题的组成部分。我们有若干物品,每个物品 i 都有利润 p_i 和重量 w_i。背包有一个最大容量 M(在本例中为15公斤)。
问题的目标是:
- 最大化 总利润。
- 满足约束 总重量不能超过背包容量
M。


用数学公式表示,即:
- 目标函数:
Maximize Σ (x_i * p_i) - 约束条件:
Σ (x_i * w_i) <= M



其中,x_i 表示我们选取第 i 个物品的比例,其取值范围是 0 <= x_i <= 1。这意味着我们允许取物品的一部分(即可分割物品),例如蔬菜、谷物等,而不是必须取整个不可分割的物品(如一台洗衣机)。


贪心策略的选择


面对多个物品,我们如何决定先放哪个后放哪个呢?可能有不同的想法:
- 想法一:优先放入利润最高的物品。
- 想法二:优先放入重量最轻的物品,以便放入更多物品。

然而,这两种策略单独来看都不是最优的。正确的贪心策略是:优先选择“单位重量利润”(即利润/重量比)最高的物品。


我们计算每个物品的单位重量利润 p_i / w_i,并按照这个比值从高到低对物品进行排序。这个比值代表了物品的“性价比”,优先选择性价比高的物品,是获得最大总利润的有效方法。




应用贪心算法解决问题
上一节我们确定了选择物品的策略——按单位重量利润降序选择。本节中,我们来看看如何一步步应用这个策略来装满背包。




以下是解决可分割背包问题的贪心算法步骤:



- 计算与排序:计算每个物品的单位重量利润
(p_i / w_i),并按照该值从高到低对物品进行排序。 - 迭代选择:按排序后的顺序依次考虑每个物品。
- 如果背包剩余容量能容纳整个当前物品,则将其全部放入
(x_i = 1),并更新剩余容量。 - 如果背包剩余容量不足以容纳整个当前物品,则只放入剩余容量所能容纳的部分
(x_i = 剩余容量 / w_i),然后背包被装满,算法结束。
- 如果背包剩余容量能容纳整个当前物品,则将其全部放入
- 计算总收益:根据最终的
x_i取值,计算总利润Σ (x_i * p_i)。


让我们用示例数据来演练这个过程。我们有7个物品,背包容量 M = 15。




首先,计算并排序单位重量利润:

| 物品 | 利润 (p) | 重量 (w) | p/w | 排序后顺序 |
|---|---|---|---|---|
| 1 | 10 | 2 | 5 | 2 |
| 2 | 5 | 3 | 1.67 | 5 |
| 3 | 15 | 5 | 3 | 3 |
| 4 | 7 | 7 | 1 | 6 |
| 5 | 6 | 1 | 6 | 1 |
| 6 | 18 | 4 | 4.5 | 4 |
| 7 | 3 | 1 | 3 | 3 (并列) |



说明:物品5和7的p/w值都是3,排序时可以任意决定先后,不影响最终的最优解。



现在,开始迭代选择:
- 选择物品5 (p/w=6): 重量1,全部放入。
x5 = 1,剩余容量 = 15 - 1 = 14。 - 选择物品1 (p/w=5): 重量2,全部放入。
x1 = 1,剩余容量 = 14 - 2 = 12。 - 选择物品6 (p/w=4.5): 重量4,全部放入。
x6 = 1,剩余容量 = 12 - 4 = 8。 - 选择物品3 (p/w=3): 重量5,全部放入。
x3 = 1,剩余容量 = 8 - 5 = 3。 - 选择物品7 (p/w=3): 重量1,全部放入。
x7 = 1,剩余容量 = 3 - 1 = 2。 - 选择物品2 (p/w≈1.67): 重量3,但剩余容量只有2。因此只放入
2/3。x2 = 2/3,剩余容量 = 0。 - 物品4未被选中。
x4 = 0。



验证解决方案




我们已经通过贪心算法得到了一组 x_i 的值。现在需要验证这个解决方案是否满足约束,并计算总利润。




验证总重量约束:
总重量 = (1*2) + (2/3*3) + (1*5) + (0*7) + (1*1) + (1*4) + (1*1) = 2 + 2 + 5 + 0 + 1 + 4 + 1 = 15。等于背包容量,约束满足。




计算总利润:
总利润 = (1*10) + (2/3*5) + (1*15) + (0*7) + (1*6) + (1*18) + (1*3) = 10 + 10/3 + 15 + 0 + 6 + 18 + 3 = 54 + 10/3 ≈ 54 + 3.33 = 57.33。




注意:视频中演示的计算过程(54.6)可能存在笔误或使用了不同的排序处理(如先处理了p/w=3的物品7再处理物品3)。但核心方法和结论不变:按单位重量利润降序的贪心策略,对于可分割的背包问题,能得到最优解。

总结与延伸




本节课中,我们一起学*了如何使用贪心算法解决可分割背包问题(Fractional Knapsack Problem)。

我们首先定义了问题的目标(利润最大化)和约束(重量限制)。然后,我们确定了贪心选择的标准:单位重量利润最高者优先。接着,我们一步步演示了算法过程,并验证了最终解的有效性。

需要强调的是,贪心算法能完美解决可分割背包问题。但还存在另一种变体——0/1背包问题,其中物品不可分割,要么整个放入 (x_i=1),要么不放入 (x_i=0)。对于0/1背包问题,贪心算法(按p/w排序)通常无法保证得到最优解,需要更复杂的动态规划等算法来解决。我们将在后续课程中探讨。
041:带截止时间的作业调度问题 🕒





在本节课中,我们将学*如何使用贪心算法解决“带截止时间的作业调度问题”。这是一个典型的优化问题,目标是在满足每个作业截止时间的前提下,选择一组作业以获得最大总利润。


问题定义


假设有 n 个作业,每个作业 i 都有:
- 一个利润
p_i - 一个截止时间
d_i


所有作业都需要在一台机器上处理,每个作业的处理时间恰好为 1 个单位时间。机器从时间 0 开始工作。目标是选择一个作业子集,使得子集中的每个作业都能在其截止时间前完成,并且所有被选作业的总利润最大。





问题示例与理解




我们通过一个例子来理解问题。假设有 5 个作业,其利润和截止时间如下:



| 作业编号 | 利润 | 截止时间 |
|---|---|---|
| J1 | 20 | 2 |
| J2 | 15 | 2 |
| J3 | 10 | 1 |
| J4 | 5 | 3 |
| J5 | 1 | 3 |


情景比喻:想象一个打字员(机器)在早上 9 点开门,有 5 位顾客(作业)等待服务。每位顾客都愿意支付一定费用(利润),并且只愿意等待到某个特定时间(截止时间)。打字员每小时(1 个单位时间)只能完成一份文件。目标是选择服务哪些顾客,以使总收入最高。



观察发现,最大截止时间是 3,因此我们最多有 3 个时间槽位:(0-1), (1-2), (2-3)。这意味着最多只能完成 3 个作业。


贪心算法策略

这是一个最大化问题,适合使用贪心算法。我们的策略是:
按利润降序考虑作业,并为每个作业尽可能晚地安排一个空闲的时间槽位。




上一节我们定义了问题,本节中我们来看看具体的解决步骤。



以下是应用贪心算法解决问题的详细步骤:





- 排序:将所有作业按利润从高到低排序。
- 初始化时间槽:找出所有作业中的最大截止时间
max_deadline。创建max_deadline个时间槽(通常编号为 1 到max_deadline),初始均为空。 - 迭代安排:按顺序考虑每个作业(已排序):
a. 对于当前作业,查看其截止时间d对应的槽位。
b. 如果该槽位空闲,则将作业安排在此槽位。
c. 如果该槽位已被占用,则向前(向更早的时间)查找,直到找到一个空闲槽位。如果找不到任何空闲槽位(截止时间之前的所有槽位都满了),则放弃此作业。 - 计算总利润:将所有被安排作业的利润相加,得到最大总利润。




算法演示:例1




我们使用上述例子进行演示。作业已按利润降序排列。





- 考虑 J1 (利润20, 截止2):查看槽位2。空闲,安排 J1 到槽位2。利润 = 20。
- 考虑 J2 (利润15, 截止2):查看槽位2,已被 J1 占用。向前查找,槽位1空闲。安排 J2 到槽位1。利润 = 20 + 15 = 35。
- 考虑 J3 (利润10, 截止1):查看槽位1,已被 J2 占用。向前查找,无更早槽位。放弃 J3。
- 考虑 J4 (利润5, 截止3):查看槽位3。空闲,安排 J4 到槽位3。利润 = 35 + 5 = 40。
- 考虑 J5 (利润1, 截止3):查看槽位3,已被 J4 占用。向前查找,槽位2和1均被占用。放弃 J5。




最终安排:槽位1: J2, 槽位2: J1, 槽位3: J4。总利润 = 40。





算法演示:例2






再看一个例子,巩固理解。作业如下:



| 作业 | 利润 | 截止时间 |
|---|---|---|
| J1 | 35 | 3 |
| J2 | 30 | 4 |
| J3 | 25 | 4 |
| J4 | 20 | 2 |
| J5 | 15 | 3 |
| J6 | 12 | 1 |
| J7 | 5 | 2 |




最大截止时间为4,因此有4个槽位:1, 2, 3, 4。



- J1 (35, d=3):槽位3空闲,安排。利润=35。
- J2 (30, d=4):槽位4空闲,安排。利润=65。
- J3 (25, d=4):槽位4已被占,向前查找。槽位2空闲,安排。利润=90。
- J4 (20, d=2):槽位2已被占,向前查找。槽位1空闲,安排。利润=110。
- J5 (15, d=3):槽位3已被占,向前查找。槽位1和2均被占,放弃。
- J6, J7:所有槽位已满,放弃。



最终安排:槽位1: J4, 槽位2: J3, 槽位3: J1, 槽位4: J2。总利润 = 110。



总结




本节课中我们一起学*了如何使用贪心算法解决“带截止时间的作业调度问题”。核心步骤是:按利润降序选择作业,并为每个作业在其截止时间之前尽可能晚地安排一个空闲时间槽。这个方法简单直观,能有效地在满足约束的条件下找到总利润较高的解。理解每个作业处理时间为1单位以及“尽可能晚安排”以保留早期槽位给其他作业这两个关键点,是掌握本算法的重点。
042:最优归并模式 - 贪心法 🧩

在本节课中,我们将要学*最优归并模式问题。这是一个可以使用贪心法解决的经典问题。理解这个问题,将帮助我们掌握哈夫曼编码的一半原理。


理解归并操作

在了解最优归并模式之前,首先需要理解什么是归并。

假设我们有两个已排序的列表 A 和 B。归并操作的目标是将它们合并,生成一个新的、完整的已排序列表。


归并过程:
- 比较两个列表的第一个元素。
- 将较小的元素写入新列表,并从其原列表中移除。
- 重复步骤1和2,直到两个列表的所有元素都被处理。

时间复杂度:如果列表 A 有 m 个元素,列表 B 有 n 个元素,那么归并的总时间成本为 m + n。这表示需要进行的元素比较和移动的总次数。
从两个列表到多个列表

上一节我们介绍了两个列表的归并。本节中我们来看看,当有超过两个列表需要归并时,情况会如何变化。

假设我们有四个列表,其大小(元素数量)分别为:6, 5, 2, 3。我们规定每次只能归并两个列表(称为两路归并),最终需要将它们合并成一个列表。
有多种归并顺序。以下是三种可能的模式:



模式一:
- 先归并大小为6和5的列表,得到大小为11的新列表。
- 再归并大小为11和2的列表,得到大小为13的新列表。
- 最后归并大小为13和3的列表,得到最终的大小为16的列表。
总成本 = 11 + 13 + 16 = 40。

模式二:
- 先归并大小为2和3的列表,得到大小为5的新列表。
- 再归并大小为6和5的列表,得到大小为11的新列表。
- 最后归并大小为11和5的列表,得到最终的大小为16的列表。
总成本 = 5 + 11 + 16 = 32。



模式三:
- 先归并大小为2和3的列表,得到大小为5的新列表。
- 再归并大小为5和5的列表,得到大小为10的新列表。
- 最后归并大小为10和6的列表,得到最终的大小为16的列表。
总成本 = 5 + 10 + 16 = 31。



通过观察可以发现,模式三的总成本最低。它的策略是:每次都选择当前所有列表中,大小最小的两个进行归并。
贪心策略与问题定义

从上述观察中,我们得出了解决最优归并模式的贪心方法:在每一步,总是选择并归并当前最小的两个列表。

最优归并模式问题可以正式定义为:给定 n 个已排序列表及其大小,找到一种两两归并的顺序,使得总的归并成本(即所有中间归并产生的新列表大小之和)最小。
实例演练
让我们通过一个具体例子来应用贪心策略。假设有五个文件,大小分别为:20, 30, 10, 5, 30。


以下是解决步骤:

首先,将所有文件按大小升序排列:5, 10, 20, 30, 30。



第一步:选择最小的两个文件(5和10)进行归并。
- 新文件大小 = 5 + 10 = 15。
- 当前文件列表变为:15, 20, 30, 30。
第二步:从当前列表中选择最小的两个文件(15和20)进行归并。
- 新文件大小 = 15 + 20 = 35。
- 当前文件列表变为:30, 30, 35。
第三步:选择最小的两个文件(30和30)进行归并。
- 新文件大小 = 30 + 30 = 60。
- 当前文件列表变为:35, 60。



第四步:归并最后两个文件(35和60)。
- 最终文件大小 = 35 + 60 = 95。


现在,计算总归并成本。总成本等于所有中间生成的新列表大小之和:
总成本 = 15(第一步)+ 35(第二步)+ 60(第三步)+ 95(第四步)= 205。
我们可以用一棵二叉树来表示这个归并过程,其中叶子节点是原始文件大小,内部节点是归并产生的新文件大小。总成本就是所有内部节点值之和。



另一种计算总成本的方法是考虑每个原始文件被复制的次数。在树中,一个文件的路径长度(从根到该叶子节点的边数)决定了它参与归并的次数。总成本等于每个文件大小乘以其路径长度,然后求和。
- 文件5:路径长度=3,贡献 5 * 3 = 15
- 文件10:路径长度=3,贡献 10 * 3 = 30
- 文件20:路径长度=2,贡献 20 * 2 = 40
- 文件30(第一个):路径长度=2,贡献 30 * 2 = 60
- 文件30(第二个):路径长度=2,贡献 30 * 2 = 60
总成本 = 15 + 30 + 40 + 60 + 60 = 205。
这可以用公式表示:
总成本 = Σ (文件大小_i × 路径长度_i)


总结

本节课中我们一起学*了最优归并模式问题。我们了解到,通过一个简单的贪心策略——每次归并当前最小的两个列表——就可以有效地最小化总归并成本。我们通过实例演练了该策略的步骤,并学会了两种计算总成本的方法:一是累加所有内部节点(归并结果)的值;二是计算每个原始文件大小与其在归并树中路径长度的乘积之和。这个方法为接下来学*哈夫曼编码奠定了重要基础。
043:霍夫曼编码 - 贪心算法 🧮
在本节课中,我们将要学*一种重要的数据压缩技术——霍夫曼编码。这是一种基于贪心算法的无损压缩方法,通过为出现频率高的字符分配较短的编码,为出现频率低的字符分配较长的编码,从而有效地减少数据存储或传输所需的总比特数。
问题背景:消息的存储与传输成本
假设我们有一条消息需要存储到文件中,或者通过网络发送。消息的成本或大小通常以比特为单位来衡量。让我们先看一个例子。


考虑以下消息:
BCCBBBDDDAACCCCCBEEE
首先,这条消息包含20个字母。如果使用标准的ASCII码(每个字符8比特)来发送,那么总比特数为:
8 bits/字符 * 20 字符 = 160 bits

因此,未经编码的消息大小为160比特。
固定长度编码



上一节我们看到了使用标准ASCII码的成本。本节中我们来看看能否使用自定义的编码来减少大小。



在我们的消息中,只使用了A、B、C、D、E这五个字符。我们不需要用8比特来仅仅表示5个字符。以下是每个字符在消息中出现的次数(频率):


- A: 3次
- B: 5次
- C: 6次
- D: 4次
- E: 2次




要表示5个不同的字符,至少需要3比特(因为2比特只能表示4种组合)。我们可以分配如下固定长度的3比特编码:


- A:
000 - B:
001 - C:
010 - D:
011 - E:
100


使用此编码,消息的总比特数为:
3 bits/字符 * 20 字符 = 60 bits

然而,解码器需要知道我们使用的编码表才能正确还原消息。因此,我们必须将编码表(字符及其对应的3比特码)与编码后的消息一起发送或存储。
以下是计算总成本的方法:
- 编码后的消息:60 bits
- 编码表:
- 存储5个字符的ASCII码:
5 字符 * 8 bits/ASCII码 = 40 bits - 存储5个对应的3比特码:
5 码 * 3 bits/码 = 15 bits - 编码表总大小:
40 + 15 = 55 bits
- 存储5个字符的ASCII码:


总大小:60 bits (消息) + 55 bits (表) = 115 bits



虽然从160比特减少到了115比特(约减少28%),但我们还可以做得更好。

霍夫曼编码(可变长度编码)
霍夫曼编码的核心思想是:为高频字符分配短码,为低频字符分配长码,从而进一步降低整体消息的比特数。它遵循最优合并模式(Optimal Merge Pattern),这是一种贪心算法。


以下是构建霍夫曼编码树的步骤:
- 列出节点:将每个字符及其频率视为一个独立的节点。
- 重复合并:始终选择频率最低的两个节点,将它们合并成一个新节点,新节点的频率是这两个节点频率之和。
- 构建二叉树:重复步骤2,直到所有节点合并成一棵完整的二叉树。
- 分配编码:从树根到每个字符的路径即为该字符的编码。约定左分支代表
0,右分支代表1。
让我们应用此算法到我们的例子中。


初始节点(按频率排序):
E(2), A(3), D(4), B(5), C(6)


以下是合并过程:
- 合并最小的 E(2) 和 A(3) → 新节点 N1(5)。剩余节点:
D(4), B(5), N1(5), C(6) - 合并最小的 D(4) 和 B(5) → 新节点 N2(9)。剩余节点:
N1(5), C(6), N2(9) - 合并最小的 N1(5) 和 C(6) → 新节点 N3(11)。剩余节点:
N2(9), N3(11) - 合并最后的 N2(9) 和 N3(11) → 根节点 Root(20)


根据生成的树分配编码(左0右1):
- A:
001 - B:
10 - C:
11 - D:
01 - E:
000

现在,让我们计算使用霍夫曼编码后消息的大小。我们需要将每个字符的编码长度乘以其出现次数:


- A (出现3次,码长3 bits):
3 * 3 = 9 bits - B (出现5次,码长2 bits):
5 * 2 = 10 bits - C (出现6次,码长2 bits):
6 * 2 = 12 bits - D (出现4次,码长2 bits):
4 * 2 = 8 bits - E (出现2次,码长3 bits):
2 * 3 = 6 bits


编码后消息总大小:9 + 10 + 12 + 8 + 6 = 45 bits



同样,我们需要考虑编码表的开销:
- 存储5个字符的ASCII码:
5 * 8 = 40 bits - 存储5个可变长度霍夫曼码。我们需要存储码本身以及长度信息。简单估算,若存储每个码(平均约2-3比特)及其长度,总开销约为
~12 bits(此处视频估算为52 bits,是另一种计算方式)。 - 假设表总开销为
52 bits(根据视频计算)。

使用霍夫曼编码的总成本:45 bits (消息) + 52 bits (表) ≈ 97 bits

与固定长度编码的115比特相比,霍夫曼编码进一步减少了数据大小。



解码过程
解码需要编码树或编码表。过程如下:
- 从树根开始。
- 读取编码比特流,遇到
0则走到左子节点,遇到1则走到右子节点。 - 当到达叶子节点时,输出对应的字符,然后重新回到树根,继续解码下一个字符。
例如,对于比特流 10 11 11 10 ...:
- 从根开始,读
1→右,读0→左,到达叶子节点B。输出B,回到根。 - 读
1→右,读1→右,到达叶子节点C。输出C,回到根。 - 以此类推。

总结


本节课中我们一起学*了霍夫曼编码。
- 我们首先了解了数据压缩的需求,以及使用标准ASCII码和自定义固定长度编码的优缺点。
- 然后,我们深入探讨了霍夫曼编码这一可变长度编码方法。其核心贪心策略是优先合并频率最低的节点,从而构建一棵最优二叉树。
- 我们通过实例逐步演示了如何构建霍夫曼树、分配编码,并计算了编码后消息及附带编码表的总成本,验证了其相对于固定长度编码的压缩效率优势。
- 最后,我们简要说明了如何使用霍夫曼树进行解码。

霍夫曼编码是许多现代压缩算法(如ZIP、GZIP、JPEG等)的基础组成部分,理解其原理对学*数据压缩和贪心算法设计至关重要。
044:Prim算法与Kruskal算法 - 贪心法 🧩
在本节课中,我们将要学*最小生成树的概念,以及两种用于寻找最小生成树的经典贪心算法:Prim算法和Kruskal算法。我们会从生成树的定义开始,逐步理解这两种算法的工作原理和步骤。


生成树是什么?🌳



首先,我们需要理解什么是生成树。这里有一个示例图。



一个图可以表示为 G(V, E),其中 V 是顶点集合,E 是边集合。例如,顶点集合 V = {1, 2, 3, 4, 5, 6},边集合 E 包含了连接这些顶点的边。


生成树 是原图的一个子图。这意味着,我们需要取原图边集的一个子集。


顶点必须全部保留。所以,我需要取所有的6个顶点。
生成树必须包含所有顶点。那么需要多少条边呢?假设顶点数为 n(这里是6),那么生成树需要的边数就是 n - 1(即5条边)。




因此,生成树是包含原图所有顶点,但只有 n-1 条边的子图。从原图的边中,我们只选择5条。


例如,我没有选择边 (1, 6)。这样就构成了一棵生成树。
注意,树中不能有环。这里确实没有环。如果存在环,从一个顶点出发可以回到自身。但这里没有环。


我也可以选择边 (1, 6) 而不选择边 (5, 6)。这同样也是一棵生成树。


选择哪些边构成生成树,是有选择余地的。



我们可以这样形式化地定义:生成树 S 是图 G 的一个子图,其中 S 的顶点集与 G 的顶点集相同,而边集是 G 的边集的一个子集,且边数恰好为 |V| - 1。

生成树的数量 📊

对于一个给定的图,可以生成多少棵不同的生成树呢?


假设图有 m 条边,我们需要从中选出 n-1 条边来构成生成树。可能的组合数是 C(m, n-1)。

但是,这只是一个上界。因为选出的边集不能包含环。如果某些边组合会形成环,那么这些组合就不是有效的生成树。因此,实际可能的生成树数量是 C(m, n-1) 减去那些会形成环的组合数。
例如,在一个有7条边的图中,需要选出5条边。可能的组合是 C(7, 5)。但其中有些组合会形成环(比如构成小圈的部分),所以实际数量需要减去这些形成环的情况。
最小生成树问题 ⚖️





现在,我们考虑带权图(即每条边都有一个权重或成本)。

这是一个带权图。我想知道所有可能的生成树中,哪一棵的总权重(成本)最小。

让我画出几棵可能的生成树。假设有顶点1, 2, 3, 4。我需要选择3条边(因为4-1=3)。

- 选择边 (1,2), (2,3), (3,4)。权重分别是5, 3, 6。这棵生成树的成本是 5 + 3 + 6 = 14。
- 选择边 (1,2), (1,4), (3,4)。权重是4, 6, 3。成本是 4 + 6 + 3 = 13。
- 选择边 (1,2), (2,4), (3,4)。权重是4, 2, 3。成本是 4 + 2 + 3 = 9。



从这个例子可以观察到:对于一个带权图,不同的生成树可能有不同的总成本。


那么问题来了:我能否找到成本最小的那棵生成树(即最小生成树)?




当然可以。一种方法是尝试所有可能的生成树,然后从中选出成本最小的。但这会非常耗时。


有没有更高效的方法?有的,这就是贪心算法。对于寻找最小生成树,有两种著名的贪心算法:Prim算法 和 Kruskal算法。
Prim算法 🚀


Prim算法是一种用于寻找最小生成树的贪心算法。让我们通过一个例子来看它是如何工作的。
假设我们有一个带权图,需要利用Prim算法找到其最小生成树。
Prim算法的步骤如下:
- 初始化:从图中选择一条成本最小的边。
- 迭代扩展:在后续的每一步中,总是从图中选择一条成本最小的边,但这条边必须连接到当前已选中的顶点集合上。确保它不会与已选边形成环(因为总是连接到现有树,所以自然无环)。
- 终止:重复步骤2,直到选中了 n-1 条边(即所有顶点都已连接)。




以下是具体过程:
- 第一步:全图中成本最小的边是 (1, 6),成本为10。选中它。
- 第二步:现在已选顶点集合是 {1, 6}。连接到这些顶点的边中,成本最小的是 (1, 2),成本为25。选中它。
- 第三步:已选顶点集合是 {1, 6, 2}。连接到这些顶点的边中,成本最小的是 (2, 3),成本为28。选中它。
- 第四步:已选顶点集合是 {1, 6, 2, 3}。连接到这些顶点的边中,成本最小的是 (3, 4),成本为22。选中它。
- 第五步:已选顶点集合是 {1, 6, 2, 3, 4}。连接到这些顶点的边中,成本最小的是 (2, 5),成本为12。选中它。
- 第六步:已选顶点集合是 {1, 6, 2, 3, 4, 5}。连接到这些顶点的边中,成本最小的是 (5, 7),成本为16。选中它。

现在,我们选中了6条边,连接了7个顶点,得到了一棵最小生成树。总成本是 10 + 25 + 28 + 22 + 12 + 16 = 113。



Prim算法的核心:从一条最小边开始,始终选择连接已选顶点集和未选顶点集的最小权重边来扩展树。
关于非连通图的说明




如果一个图不是连通的(即由多个独立部分组成),那么对于整个图来说,不存在生成树,因为生成树要求所有顶点连通。此时,Prim算法如果从某个顶点开始,只能找到它所在连通分量的生成树。


Kruskal算法 🔗
Kruskal算法是另一种寻找最小生成树的贪心算法。它也采用贪心策略,但思路与Prim不同。
Kruskal算法的步骤如下:
- 排序:将图中所有边按照权重从小到大排序。
- 初始化:创建一个空的边集合,用于存放生成树的边。
- 迭代选择:按权重从小到大的顺序考虑每条边。
- 如果加入当前边不会与已选边集合形成环,则选中这条边,将其加入集合。
- 如果加入当前边会形成环,则丢弃这条边。
- 终止:重复步骤3,直到选中了 n-1 条边。




让我们用同一个例子演示:
- 所有边按权重排序后,顺序大致为:10, 14, 16, 18, 22, 24, 25, 28, ...
- 第一步:选中最小边 (1, 6),成本10。
- 第二步:选中下一个最小边 (3, 4),成本14?(根据描述,这里例子中第二小似乎是2,但图示和后续说明以14为例,我们遵循算法逻辑:选次小且无环边)。假设次小是 (2, 7) 成本14,选中。
- 第三步:选中下一个最小边 (2, 3),成本16,选中。
- 第四步:下一个最小边是 (4, 7),成本18。如果加入它,会与已选边 (2,3), (3,4), (2,7) 形成环(2-3-4-7-2)。因此丢弃它。
- 第五步:选中下一个最小边 (4, 5),成本22。
- 第六步:下一个最小边是 (5, 7),成本24。加入它会形成环(4-5-7-4? 需要看已选边),因此丢弃。
- 第七步:选中下一个最小边 (5, 6),成本25。





此时,我们选中了5条边?等等,我们需要 n-1=6 条边。让我们检查已选边: (1,6), (2,7), (2,3), (4,5), (5,6)。这只有5条,且顶点集合为 {1,2,3,4,5,6,7},但顶点7似乎只通过(2,7)连接,而(4,7)被丢弃了。实际上,根据算法描述,在选中 (2,3), (3,4), (2,7) 后,下一个被选中的应该是 (4,5) 成本22,然后是 (5,6) 成本25。这样选中了:10, 14, 16, 22, 25。这只有5条边,还缺一条。下一个可选的、不构成环的最小边可能是 (1,2) 成本28 或 (3,4) 成本?(注意,在最初的描述中,边(3,4)权重是2,但被跳过了,可能是因为它连接了已连通的3和4?这里例子数据有些混淆)。关键在于算法逻辑:按全局权重顺序选边,只要不形成环就加入。

最终,Kruskal算法也会选出一组边,其总成本与Prim算法结果相同(在这个特定例子中都是99,但根据我们Prim的推算是113,这里存在例子数据不一致,理解算法逻辑为主)。
Kruskal算法的核心:全局地按权重排序边,然后依次尝试加入,仅当不形成环时才采纳。




时间复杂度与优化

Kruskal算法的基本实现需要反复从边集中选取最小边。如果有 E 条边,我们需要选出 V-1 条边。简单查找最小边的时间复杂度可能是 O(V * E),在最坏情况下约为 O(V²)。
但是,我们可以通过使用最小堆(Min Heap) 来优化。将所有的边放入最小堆中,每次取出堆顶元素(即当前最小权重的边)只需要 O(log E) 的时间。我们需要进行 V-1 次这样的取出操作。因此,使用最小堆后,Kruskal算法的时间复杂度可以优化到 O(E log V)(因为 E 通常大于 V,且 log E 和 log V 同阶)。
关于非连通图的说明
对于非连通图,Kruskal算法仍然可以运行。它会试图选出 V-1 条边,但由于图不连通,这可能无法实现。实际上,算法最终会为每个连通分量分别生成一棵最小生成树(森林),而不是为整个图生成一棵树。
应用示例与思考题 💡
让我们看一个具体问题。一个图的部分边权重未知,但已知仅用某些已知权重的边就能构成一棵生成树。
问题是:那些未知边权重的最小可能值是多少?
推理过程基于Kruskal算法:
- 已知边被选中构成生成树,意味着在算法执行时,它们按权重顺序被选中且不构成环。
- 未被选中的边(未知权重的边)一定是因为在算法考虑它时,它会与已选边形成环。
- 因此,这条未被选中的边的权重,必须大于或等于在它之前被选中、并导致环形成的那些边中的最大权重。否则,如果它的权重更小,它就会先被选中。

例如,如果一条未知边在算法最后未被选中,而最后一步选中的边权重是4,那么这条未知边的权重至少是5。
最小生成树的唯一性 ❓


最小生成树的总成本(最小权重和)通常是唯一的。但是,构成最小生成树的边集可能不唯一。当图中存在多条权重相同的边时,可能会产生多个不同的边集,但它们的总成本相同,都是最小值。


例如,在一个图中,两条不同的边权重都是4,在构建生成树时,选择其中任意一条都能得到总成本相同的最小生成树。



本节课中我们一起学*了生成树和最小生成树的概念,并详细探讨了寻找最小生成树的两种贪心算法:Prim算法和Kruskal算法。Prim算法通过逐步扩展树来工作,而Kruskal算法通过全局排序并避免环来工作。两者都能高效地解决最小生成树问题,并且可以通过数据结构(如优先队列)进行优化。我们还了解了算法在非连通图上的行为以及一些相关的应用问题。
045:Dijkstra算法 - 单源最短路径 - 贪心法 🚀



在本节课中,我们将要学*Dijkstra算法。这是一种用于解决单源最短路径问题的贪心算法。我们将了解其工作原理、步骤、时间复杂性,并探讨其局限性。
概述 📋

单源最短路径问题是指,给定一个带权图,我们需要找出从一个指定的源顶点到图中所有其他顶点的最短路径。这是一个最小化问题,属于优化问题范畴,因此可以使用贪心法来解决。贪心法的核心思想是分阶段解决问题,每一步只考虑一个输入,以期望获得全局最优解。


Dijkstra算法的基本思想 💡



上一节我们介绍了问题的定义,本节中我们来看看Dijkstra算法的核心思想。该算法通过逐步选择当前距离源点最*的顶点,并“松弛”其相邻顶点的距离来工作。

核心概念:松弛操作




松弛是Dijkstra算法的关键操作。假设我们有一个顶点 u(其当前最短距离已知)和一个相邻顶点 v。松弛操作检查是否通过 u 到达 v 的路径比 v 当前已知的最短路径更短。



其判断公式如下:
如果 distance[u] + cost(u, v) < distance[v]
则 distance[v] = distance[u] + cost(u, v)




这个操作会不断更新顶点的最短距离估计值。




算法步骤详解 🛠️


让我们通过一个具体例子,一步步理解Dijkstra算法是如何执行的。



初始化




首先,我们需要进行初始化设置。对于源顶点,其距离设为0。对于其他所有顶点,距离初始化为无穷大(∞),表示暂时没有已知路径。

以下是初始化步骤的伪代码表示:
初始化距离数组 dist[]
dist[source] = 0
对于所有其他顶点 v:
dist[v] = ∞
创建一个集合 S,用于存放已找到最短路径的顶点(初始为空)




迭代过程


接下来,我们进入算法的核心迭代循环。这个过程会重复执行,直到所有顶点都被处理完毕。


以下是每一步需要执行的操作列表:
- 选择顶点:从尚未处理的顶点集合中,选出当前
dist值最小的顶点 u。这保证了我们总是先处理离源点“最*”的顶点。 - 标记处理:将顶点 u 加入已处理集合 S。这意味着从源点到 u 的最短路径已经最终确定。
- 松弛相邻顶点:检查 u 的所有未被最终确定的邻居顶点 v。对每个邻居 v,尝试进行松弛操作:如果
dist[u] + weight(u, v) < dist[v],则更新dist[v] = dist[u] + weight(u, v)。

这个过程会一直重复,直到所有顶点都加入了集合 S。最终,dist 数组中存储的就是从源点到每个顶点的最短路径长度。

算法实例演示 📝





让我们通过一个具体的图例来演示上述步骤。假设我们有如下带权图,并选择顶点1作为源点。



(此处原视频有图示,教程中可描述:图中有6个顶点,边及其权重为:1-2(2), 1-3(4), 2-3(1), 2-4(7), 3-5(3), 4-5(2), 4-6(5), 5-6(1))





我们将通过表格来追踪算法的执行过程。


| 步骤 | 已选顶点 (S) | 距离 (dist) | 说明 |
|---|---|---|---|
| 初始化 | [0, 2, 4, ∞, ∞, ∞] | 源点1距离为0,直接邻居2和3的距离更新为2和4,其余为∞。 | |
| 1 | [0, 2, 3, 9, ∞, ∞] | 选择最小dist顶点2。松弛其邻居:到3:2+1=3 < 4,更新;到4:2+7=9 < ∞,更新。 | |
| 2 | [0, 2, 3, 9, 6, ∞] | 选择最小dist顶点3。松弛其邻居5:3+3=6 < ∞,更新。 | |
| 3 | [0, 2, 3, 8, 6, 11] | 选择最小dist顶点5。松弛其邻居:到4:6+2=8 < 9,更新;到6:6+5=11 < ∞,更新。 | |
| 4 | [0, 2, 3, 8, 6, 9] | 选择最小dist顶点4。松弛其邻居6:8+1=9 < 11,更新。 | |
| 5 | [0, 2, 3, 8, 6, 9] | 选择最后一个顶点6。无未处理邻居,算法结束。 |




最终,我们得到了从顶点1到所有顶点的最短距离:1->2:2, 1->3:3, 1->4:8, 1->5:6, 1->6:9。

时间复杂性分析 ⏱️




上一节我们走完了算法的流程,本节我们来分析一下它的效率。Dijkstra算法的时间复杂度取决于如何实现“选择最小距离顶点”和“更新距离”这两个操作。
- 最简单的实现方式是使用数组来存储距离。每次选择最小顶点需要 O(V) 时间(V为顶点数),总共需要选择V次。每次松弛操作需要 O(1) 时间,但总共可能需要对所有边进行松弛(E为边数)。
- 因此,使用数组的简单实现总时间复杂度为 O(V² + E)。在稠密图(E ≈ V²)中,这*似于 O(V²)。
- 更高效的实现是使用最小堆(优先队列)来存储顶点。每次提取最小顶点和更新键值的操作可以优化到 O(log V)。这样,总时间复杂度可以降低到 O((V+E) log V),对于稀疏图更为高效。
我们可以总结其时间复杂度为:
简单数组实现:O(V²)
二叉堆优先队列实现:O((V + E) log V)
斐波那契堆实现:O(E + V log V) (理论最优)


算法的适用范围与局限性 ⚠️

Dijkstra算法功能强大,但并非适用于所有场景。了解其局限性至关重要。





适用范围
- 图类型:适用于有向图和无向图。对于无向图,每条边可以视为两条方向相反的有向边。
- 边权:要求图中所有边的权重为非负值。这是保证算法正确性的关键前提。


局限性:负权边问题




Dijkstra算法最大的局限性在于无法处理包含负权边的图。原因在于其贪心选择策略:一旦一个顶点被标记为已处理(加入集合S),算法就认为已经找到了到达该顶点的最终最短路径,之后不会再对其进行更新。




考虑一个包含负权边的简单图:顶点A(0) -> B(5), A->C(3), C->B(-10)。从A到B:
- 初始化:dist[B]=5, dist[C]=3。
- 选择C(dist=3),标记为已处理。通过C松弛B:3 + (-10) = -7,这比当前的5更短。
- 然而,由于C已被标记为“已处理”,算法不会再考虑通过C去更新其他顶点(如B)的可能性。
- 最终,算法错误地输出A到B的最短路径为5,而实际正确的最短路径是-7。



结论:当图中存在负权边时,Dijkstra算法可能无法得出正确结果。对于包含负权边的单源最短路径问题,需要使用其他算法,例如Bellman-Ford算法(将在动态规划部分介绍)。


总结 🎯

本节课中我们一起学*了Dijkstra算法。我们首先明确了它要解决的单源最短路径问题。接着,我们深入探讨了其基于贪心策略的核心思想,即通过不断选择当前最短路径顶点并松弛其邻居来逐步逼*最优解。我们通过详细的步骤演示和实例,清晰地展示了算法的工作流程。随后,我们分析了算法的时间复杂性,并指出了其最重要的局限性:无法处理带有负权边的图。理解Dijkstra算法的原理、实现及其适用边界,是学*图论算法的重要基础。对于存在负权边的场景,我们将在后续课程中学*Bellman-Ford算法。
046:动态规划入门 🚀


在本节课中,我们将要学*动态规划的基本概念。我们将了解动态规划与贪心算法的区别,并通过一个详细的例子来展示记忆化与制表法之间的不同。

概述
动态规划是一种用于解决优化问题的算法设计范式。它通过将复杂问题分解为更简单的子问题,并存储这些子问题的解来避免重复计算,从而高效地找到最优解。
动态规划与贪心算法
上一节我们介绍了动态规划的基本目标,本节中我们来看看它与另一种常用方法——贪心算法的区别。

贪心算法和动态规划都用于解决优化问题。它们采用不同的策略,但目的相同,即寻找问题的最优解(最小值或最大值)。


以下是两种方法的核心区别:


- 贪心算法:遵循一个预定义的、已知为最优的步骤来获取结果。例如,在寻找最小生成树时,总是选择当前最小成本的边。
- 动态规划:尝试找出所有可能的解决方案,然后从中挑选出最优解。这种方法通常更耗时,但能保证找到全局最优解。


动态规划问题大多通过递归公式解决。虽然我们通常使用迭代而非递归编程,但其公式本质是递归的。动态规划遵循最优性原理,该原理指出,可以通过做出一系列决策来获得问题的最优解。与贪心算法一次性做出决策不同,在动态规划中,我们在每个阶段都可能做出决策。
记忆化与制表法



理解了动态规划的基本思想后,我们来看看实现它的两种主要技术:记忆化和制表法。我们将通过斐波那契数列的例子来详细说明。


斐波那契数列的定义是递归的:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n>=2)。一个直接的递归函数实现如下:

def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)

计算 fib(5) 时,函数调用会形成一棵递归树。许多子问题(如 fib(2)、fib(3))被重复计算了多次。这导致其时间复杂度为 O(2^n),是指数级的。

记忆化(自顶向下)


为了优化,我们可以使用记忆化技术。其核心思想是保存已计算过的子问题的结果,避免重复计算。
以下是记忆化的实现思路:
- 初始化一个全局数组(例如
memo),用特殊值(如-1)填充,表示尚未计算。 - 在递归函数中,先检查
memo[n]是否有值。如果有,直接返回。 - 如果没有,则进行计算,并将结果存入
memo[n]后再返回。



应用记忆化后,计算 fib(5) 时,每个子问题只计算一次。总调用次数约为 n+1 次,时间复杂度降低到 O(n),从指数级优化到了线性级。这种方法从原问题(顶部)开始,递归向下分解,并存储结果,因此被称为自顶向下的方法。

制表法(自底向上)

动态规划更常用的实现方式是制表法,它采用迭代而非递归。

以下是制表法的实现方式:

- 创建一个数组(表)来存储所有子问题的解。
- 从最小的子问题开始(即
F(0)和F(1)),逐步计算并填充表格,直到得到原问题的解。





对应的迭代函数如下:


def fib_tabulation(n):
if n <= 1:
return n
dp = [0] * (n+1)
dp[0] = 0
dp[1] = 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
这种方法从基础情况开始,逐步构建到最终解,因此被称为自底向上的方法。它同样具有 O(n) 的时间复杂度,但通常比递归的记忆化方法具有更小的常数开销和避免递归栈溢出的风险。
总结

本节课中我们一起学*了动态规划的核心内容。
- 我们明确了动态规划与贪心算法的区别:贪心算法每一步做局部最优选择,而动态规划会考虑所有子问题以找到全局最优。
- 我们深入探讨了实现动态规划的两种技术:记忆化(自顶向下) 和 制表法(自底向上),并通过斐波那契数列的例子看到它们如何将时间复杂度从指数级 O(2^n) 优化到线性级 O(n)。
- 动态规划的关键在于找到问题的最优子结构和定义正确的状态转移方程。掌握如何将问题分解并构建解决方案的思路,是应用动态规划解决更复杂问题的基础。
047:多阶段图问题 - 动态规划 🧮




在本节课中,我们将学*如何使用动态规划策略来解决多阶段图问题。我们将首先理解什么是多阶段图,然后探讨动态规划为何及如何适用于此问题,最后通过前向法来求解一个具体实例。
概述:什么是多阶段图? 🗺️

多阶段图是一种有向加权图。它的顶点被划分为多个阶段,并且图中的边仅连接相邻阶段的顶点。通常,第一个阶段和最后一个阶段都只有一个顶点,分别代表图的起点(源点)和终点(汇点)。


这种图常用于表示资源分配等问题。我们的目标是:在所有从源点到汇点的路径中,找到一条总成本最小的路径。因此,这是一个最小化优化问题。



动态规划的适用性 ✅




动态规划基于最优性原理工作。该原理要求问题可以通过一系列决策来解决。在多阶段图中,我们从源点出发,在每个阶段都需要选择一个顶点前往下一个阶段。每次选择时,都必须确保当前的选择能导向全局最优解。因此,多阶段图问题满足最优性原理,可以应用动态规划。




求解过程:前向法 📈



我们将通过一个具体的图例,从后向前(即从汇点向源点)计算每个顶点的最小成本。这种方法被称为“前向法”,因为最终的决策序列是从源点向前推进的。




首先,我们定义公式。设 cost(i, j) 表示从第 i 阶段第 j 个顶点到汇点的最小成本。其计算公式为:
cost(i, j) = min { c(j, l) + cost(i+1, l) }
其中,l 是下一阶段 (i+1) 中所有与顶点 j 相连的顶点,c(j, l) 是边 (j, l) 的权重。



对于汇点(最后一个顶点),其成本显然为0:
cost(最后一个阶段, 汇点) = 0


步骤一:初始化表格



我们创建一个表格,记录每个阶段每个顶点的最小成本 (cost) 以及做出该选择时前往的下一个顶点 (d)。



以下是计算步骤的分解:
步骤二:从最后阶段开始计算


阶段 5 (汇点)
- 顶点 12:
cost(5, 12) = 0




阶段 4
计算从本阶段各顶点到汇点 12 的成本(只有一条边):
- 顶点 9:
cost(4, 9) = c(9,12) = 4,d=12 - 顶点 10:
cost(4, 10) = c(10,12) = 2,d=12 - 顶点 11:
cost(4, 11) = c(11,12) = 5,d=12


阶段 3
现在,我们需要利用阶段4的结果来计算阶段3的顶点成本。公式要求我们考虑所有可能的下一阶段顶点,并取最小值。
以下是计算过程:
- 顶点 6:有边通往顶点9和10。
cost(3, 6) = min{ c(6,9)+cost(4,9), c(6,10)+cost(4,10) } = min{ 6+4, 5+2 } = min{10, 7} = 7
最小值来自顶点10,因此d=10。 - 顶点 7:有边通往顶点9和10。
cost(3, 7) = min{ c(7,9)+cost(4,9), c(7,10)+cost(4,10) } = min{ 4+4, 3+2 } = min{8, 5} = 5
最小值来自顶点10,因此d=10。 - 顶点 8:有边通往顶点10和11。
cost(3, 8) = min{ c(8,10)+cost(4,10), c(8,11)+cost(4,11) } = min{ 5+2, 6+5 } = min{7, 11} = 7
最小值来自顶点10,因此d=10。


阶段 2
同理,利用阶段3的结果计算:
- 顶点 2:有边通往顶点6、7、8。
cost(2, 2) = min{ c(2,6)+cost(3,6), c(2,7)+cost(3,7), c(2,8)+cost(3,8) } = min{ 4+7, 2+5, 1+7 } = min{11, 7, 8} = 7
最小值来自顶点7,因此d=7。 - 顶点 3:有边通往顶点6。
cost(2, 3) = c(3,6)+cost(3,6) = 2+7 = 9,d=6。 - 顶点 4:有边通往顶点8。
cost(2, 4) = c(4,8)+cost(3,8) = 11+7 = 18,d=8。 - 顶点 5:有边通往顶点7和8。
cost(2, 5) = min{ c(5,7)+cost(3,7), c(5,8)+cost(3,8) } = min{ 11+5, 8+7 } = min{16, 15} = 15
最小值来自顶点8,因此d=8。
阶段 1 (源点)
最后,计算源点的成本,这将给出全局最小成本:
- 顶点 1:有边通往顶点2、3、4、5。
cost(1, 1) = min{ c(1,2)+cost(2,2), c(1,3)+cost(2,3), c(1,4)+cost(2,4), c(1,5)+cost(2,5) } = min{ 9+7, 7+9, 3+18, 2+15 } = min{16, 16, 21, 17} = 16
最小值来自顶点2和顶点3,因此d可以是2或3。


至此,我们完成了整个成本表的填充。
步骤三:根据表格做出决策序列 🧭


动态规划的核心在于利用已计算好的数据(表格)做出最优决策序列。我们从源点(阶段1,顶点1)开始,根据 d 值向前推进:
- 从 顶点1 出发,
d值为 2 或 3。我们选择一条路径,例如先选d=2。 - 到达 阶段2,顶点2,其
d=7。 - 到达 阶段3,顶点7,其
d=10。 - 到达 阶段4,顶点10,其
d=12。 - 到达 阶段5,顶点12 (汇点)。


于是,我们得到一条最短路径:1 -> 2 -> 7 -> 10 -> 12,总成本为16。
如果从顶点1选择 d=3,则会得到另一条成本同样为16的最短路径:1 -> 3 -> 6 -> 10 -> 12。


总结 📝
本节课中,我们一起学*了多阶段图问题及其动态规划解法。
- 多阶段图是一种特殊的有向加权图,顶点分阶段连接,常用于建模多步决策过程。
- 该问题满足最优性原理,因此可以使用动态规划高效求解。
- 我们采用了前向法,其核心是递推公式:
cost(i, j) = min { c(j, l) + cost(i+1, l) }。 - 求解过程分为两步:首先从后向前填表,计算每个顶点到汇点的最小成本;然后从前向后追踪
d值,得到具体的最优路径。 - 动态规划的优势在于通过存储子问题的解(填表),避免了重复计算,这正是“重叠子问题”特性的体现。


通过这个方法,我们能够系统地找到从源点到汇点的最小成本路径。
048:多阶段图(程序实现)📊

在本节课中,我们将学*如何使用动态规划解决多阶段图问题,并详细解释其C++程序实现。我们将从理解问题开始,逐步分析程序的数据结构、核心算法逻辑,并最终计算出最短路径及其时间复杂度。

概述

多阶段图问题旨在找到从源点到汇点的最短路径,其中图被划分为多个阶段,边仅从较早阶段指向较晚阶段。我们将使用动态规划中的前向法来解决此问题,并通过一个具体的C++程序示例来演示如何实现算法、追踪计算过程并找出最短路径。
数据结构与初始化


首先,我们需要定义合适的数据结构来表示图并存储计算过程中的中间结果。以下是程序中使用的主要数据结构:
- 邻接矩阵
c: 用于存储图中各边的权值(成本)。由于顶点编号从1开始,而数组索引从0开始,我们声明大小为9(索引0-8),并忽略0行和0列以方便使用。 - 成本数组
cost: 用于存储从每个顶点到汇点(顶点8)的最短成本。 - 决策数组
d: 用于记录在计算cost[i]时,使得总成本最小的下一个顶点k。 - 路径数组
p: 用于最终重构从源点到汇点的最短路径。


在程序中,图的邻接矩阵数据被直接初始化。例如,c[1][2] = 2 表示从顶点1到顶点2的边成本为2。

// 示例:成本邻接矩阵的部分初始化
int c[9][9] = {
{0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 2, 1, 3, 0, 0, 0, 0}, // 顶点1到2,3,4的成本
{0, 0, 0, 0, 0, 2, 3, 0, 0}, // 顶点2到5,6的成本
// ... 其他行以此类推
};
int cost[9], d[9], p[9];
int n = 8; // 顶点总数
int stages = 4; // 阶段总数



核心算法:动态规划计算
上一节我们介绍了所需的数据结构,本节中我们来看看动态规划的核心计算过程。算法从汇点开始,逆向计算每个顶点到汇点的最短成本。
核心公式如下,对于顶点 i,其到汇点的最小成本 cost[i] 是:

cost[i] = min { c[i][k] + cost[k] }, 其中 k 取遍 i 的所有后继顶点。

程序实现如下:
- 初始化: 汇点(顶点n)的成本为0,即
cost[n] = 0。 - 逆向计算: 使用一个循环,从顶点
n-1递减到1,计算每个顶点i的成本。- 对于每个顶点
i,遍历其所有可能的后继顶点k(从i+1到n)。 - 如果存在边
c[i][k](即权值不为0),则计算c[i][k] + cost[k]。 - 在所有计算结果中,取最小值作为
cost[i],并将对应的k存入d[i]。
- 对于每个顶点
以下是算法关键部分的代码逻辑:

cost[n] = 0; // 汇点成本为0

// 从顶点n-1逆向计算到顶点1
for (int i = n-1; i >= 1; i--) {
int minCost = INT_MAX;
int minVertex = -1;
// 遍历顶点i的所有后继顶点
for (int k = i+1; k <= n; k++) {
if (c[i][k] != 0) {
int currentCost = c[i][k] + cost[k];
if (currentCost < minCost) {
minCost = currentCost;
minVertex = k;
}
}
}
cost[i] = minCost;
d[i] = minVertex;
}
计算过程示例: 以顶点4为例。
- 其后继顶点有5、6、7、8(但4到8无边,故忽略)。
- 计算
c[4][5] + cost[5] = 6 + 6 = 12 - 计算
c[4][6] + cost[6] = 8 + 4 = 12 - 计算
c[4][7] + cost[7] = 9 + 5 = 14 - 最小值是12,因此
cost[4] = 12,d[4] = 5(假设第一次找到的最小值来自顶点5)。

按此方式完成所有计算后,我们得到 cost[1] = 9,即从源点1到汇点8的最短路径总成本为9。

重构最短路径

计算出成本数组 cost 和决策数组 d 后,我们需要找出具体的最短路径。路径将存储于数组 p 中。
重构路径的步骤如下:
- 路径的起点是源点,终点是汇点。因此
p[1] = 1,p[stages] = n。 - 对于中间阶段(第2阶段到第
stages-1阶段),路径顶点由决策数组d递推得出:p[i] = d[ p[i-1] ]。

以下是路径重构的代码:
p[1] = 1; // 起点
p[stages] = n; // 终点
// 填充中间阶段的顶点
for (int i = 2; i < stages; i++) {
p[i] = d[ p[i-1] ];
}


路径重构示例:
- 已知
d[1] = 2,所以p[2] = d[p[1]] = d[1] = 2。 - 接着,
p[3] = d[p[2]] = d[2] = 6。 - 最终路径为:1 → 2 → 6 → 8。


时间复杂度分析
现在我们已经理解了算法的计算和路径重构过程,最后来分析其效率。该算法的时间复杂度主要由嵌套循环决定。
- 外层循环运行
n-1次(从n-1到1)。 - 内层循环的次数取决于当前顶点
i。当i较小时,内层循环需要检查的后继顶点较多。具体来说,对于顶点i,内层循环大约运行n-i次。 - 因此,总操作次数大致为
(n-1) + (n-2) + ... + 1 = n(n-1)/2。


结论: 多阶段图动态规划算法的时间复杂度为 O(n²),其中 n 是图的顶点数。
总结



本节课中我们一起学*了多阶段图问题的动态规划解法及其程序实现。我们从定义数据结构开始,逐步分析了如何逆向计算每个顶点的最小成本,并利用决策数组重构出最终的最短路径。最后,我们分析了算法的时间复杂度为 O(n²)。通过这个具体的程序示例,我们不仅掌握了算法的理论公式,也了解了如何将其转化为实际的代码逻辑。
049:全对最短路径(Floyd-Warshall)🚀

在本节课中,我们将学*一个图论中的经典问题:全对最短路径。我们将探讨如何使用动态规划方法来解决这个问题,推导出核心公式,并展示其代码实现。


问题概述
全对最短路径问题的目标是:在一个带权有向图中,找出每一对顶点之间的最短路径长度。
例如,对于一个有4个顶点的图,我们需要计算:
- 从顶点1到顶点2、3、4的最短路径。
- 从顶点2到顶点1、3、4的最短路径。
- 从顶点3到顶点1、2、4的最短路径。
- 从顶点4到顶点1、2、3的最短路径。
上一节我们介绍了单源最短路径算法(如Dijkstra算法)。本节中我们来看看如何将其扩展来解决所有顶点对的问题。
一种直接的方法是:对图中的每一个顶点,都运行一次Dijkstra算法。如果图有 n 个顶点,Dijkstra算法的时间复杂度为 O(n²),那么总时间复杂度将是 O(n³)。
然而,我们将采用一种更系统化的动态规划方法来解决此问题,这就是Floyd-Warshall算法。



动态规划思路
动态规划要求我们将问题分解为一系列的阶段决策。在全对最短路径问题中,我们的决策是:是否通过某个中间顶点来获得更短的路径。

具体来说,我们将按顺序考虑每一个顶点作为可能的“中转站”。在每一阶段(即考虑一个新的中转顶点时),我们检查对于所有顶点对 (i, j),从 i 到 j 的路径如果经过这个新的中转顶点,是否会比当前已知的最短路径更短。
算法步骤详解


我们通过一个例子来演示算法的执行过程。假设我们有一个4个顶点的图,其初始邻接矩阵 A⁰ 如下(其中 ∞ 表示没有直接边,0 表示顶点到自身的距离):


A⁰:
1 2 3 4
1 | 0 3 ∞ 7
2 | 8 0 2 ∞
3 | 5 ∞ 0 1
4 | 2 ∞ ∞ 0
阶段1:允许通过顶点1作为中转
我们生成新矩阵 A¹,其中 A¹[i][j] 表示在允许通过顶点1作为中转的情况下,从 i 到 j 的最短路径。
规则是:对于每一对 (i, j),其最短路径要么是原来的直接路径 A⁰[i][j],要么是通过顶点1的路径 A⁰[i][1] + A⁰[1][j]。我们取两者中的较小值。


以下是计算过程:
- 当考虑顶点1作为中转时,矩阵的第1行和第1列保持不变,因为涉及顶点1的路径无需通过自身中转。
- 我们计算其他值。例如,对于
A¹[2][4]:- 原路径
A⁰[2][4] = ∞。 - 通过顶点1的路径:
A⁰[2][1] + A⁰[1][4] = 8 + 7 = 15。 15 < ∞,因此更新A¹[2][4] = 15。这意味着从顶点2到4,现在有一条经由顶点1的路径。
- 原路径


最终得到的 A¹ 矩阵为:
A¹:
1 2 3 4
1 | 0 3 ∞ 7
2 | 8 0 2 15
3 | 5 8 0 1
4 | 2 5 ∞ 0
阶段2:允许通过顶点1和2作为中转
接下来,我们基于 A¹ 生成 A²,此时允许使用顶点1和顶点2作为中转。

规则相同:A²[i][j] = min( A¹[i][j], A¹[i][2] + A¹[2][j] )。
例如,计算 A²[1][3]:
- 原路径
A¹[1][3] = ∞。 - 通过顶点2的路径:
A¹[1][2] + A¹[2][3] = 3 + 2 = 5。 5 < ∞,因此更新A²[1][3] = 5。这表示找到了从1到3经由顶点2的路径。


A² 矩阵如下:
A²:
1 2 3 4
1 | 0 3 5 7
2 | 8 0 2 15
3 | 5 8 0 1
4 | 2 5 7 0

后续阶段
我们继续这个过程:
- 阶段3:基于
A²生成A³,允许通过顶点 {1, 2, 3} 中转。规则:A³[i][j] = min( A²[i][j], A²[i][3] + A²[3][j] )。 - 阶段4:基于
A³生成A⁴,允许通过所有顶点 {1, 2, 3, 4} 中转。规则:A⁴[i][j] = min( A³[i][j], A³[i][4] + A³[4][j] )。

最终的 A⁴ 矩阵就包含了所有顶点对之间的最短路径长度。



核心递推公式

从上述步骤中,我们可以总结出Floyd-Warshall算法的核心递推公式。设 Aᵏ[i][j] 表示在允许使用前 k 个顶点(即顶点1到k)作为中转站时,从顶点 i 到 j 的最短路径长度。
那么,递推关系为:
Aᵏ[i][j] = min( Aᵏ⁻¹[i][j], Aᵏ⁻¹[i][k] + Aᵏ⁻¹[k][j] )
其中:
Aᵏ⁻¹[i][j]是不使用第k个顶点作为中转的当前最短路径。Aᵏ⁻¹[i][k] + Aᵏ⁻¹[k][j]是使用第k个顶点作为中转的新路径长度。- 我们取两者中的最小值作为新的最短路径估计。
初始条件 A⁰ 就是图的邻接矩阵。
代码实现


理解了公式之后,代码实现就非常直观了。我们只需要三重循环来更新这个矩阵。
以下是算法的伪代码/核心结构:
// 假设 dist 是 n x n 的邻接矩阵,dist[i][i] = 0,无边则为 INF
for (int k = 0; k < n; k++) { // 枚举中转顶点
for (int i = 0; i < n; i++) { // 枚举起点
for (int j = 0; j < n; j++) { // 枚举终点
if (dist[i][k] != INF && dist[k][j] != INF) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
// 循环结束后,dist[i][j] 即为 i 到 j 的最短路径长度



代码解读:
- 最外层的
k循环对应动态规划的“阶段”,表示我们允许使用前k+1个顶点作为中转。 - 内层的
i和j循环用于遍历所有顶点对,并应用递推公式进行更新。 - 由于算法直接在原矩阵上更新,空间复杂度为
O(n²)。 - 三重嵌套循环决定了其时间复杂度为 O(n³)。

总结

本节课中我们一起学*了全对最短路径问题及其经典的动态规划解决方案——Floyd-Warshall算法。
我们首先明确了问题的定义:求解图中所有顶点对之间的最短距离。接着,我们通过一个具体的例子,一步步演示了算法如何通过引入“中转顶点”来逐步优化最短路径矩阵。从这个过程中,我们提炼出了核心的递推公式 Aᵏ[i][j] = min( Aᵏ⁻¹[i][j], Aᵏ⁻¹[i][k] + Aᵏ⁻¹[k][j] )。最后,我们看到了该公式如何转化为简洁的三重循环代码,其时间复杂度为 O(n³)。

Floyd-Warshall算法的优点在于实现极其简洁,并且能够处理带有负权边的图(但不能处理包含负权环的图)。它是理解动态规划在图论中应用的绝佳范例。
050:矩阵链乘法问题与动态规划解法 🧮

在本节课中,我们将要学*矩阵链乘法问题。这是一个经典的动态规划问题,其核心目标是找到一种最优的矩阵相乘顺序,使得完成整个链式乘法所需的总标量乘法次数最少。我们将从理解矩阵乘法的基础开始,逐步推导出动态规划的递推公式,并通过一个完整的例子演示如何填充表格来找到最优解。


矩阵乘法基础
首先,我们需要理解两个矩阵相乘的条件和成本。


两个矩阵A和B可以相乘的条件是:第一个矩阵A的列数必须等于第二个矩阵B的行数。如果A的维度是 p × q,B的维度是 q × r,那么它们可以相乘,结果矩阵C的维度将是 p × r。
计算乘积矩阵C中每个元素 C[i][j] 的公式是:
C[i][j] = Σ (A[i][k] * B[k][j]), 其中 k 从 1 到 q
这意味着计算C的每个元素需要进行 q 次乘法。由于C有 p * r 个元素,因此两个矩阵相乘的总乘法次数为:
总乘法次数 = p × q × r
这个数值也被称为这次矩阵乘法的“成本”。
什么是矩阵链乘法问题?




现在,假设我们有一个矩阵链 A1, A2, A3, ..., An,并且我们知道每个矩阵的维度(例如A1是 d0 × d1,A2是 d1 × d2,以此类推)。由于相邻矩阵的维度匹配,整个链理论上可以相乘。



然而,矩阵乘法满足结合律,我们可以通过添加括号来改变相乘的顺序。例如,对于四个矩阵 A1 A2 A3 A4,可能的相乘顺序有:
((A1 A2) A3) A4(A1 (A2 A3)) A4(A1 A2) (A3 A4)A1 ((A2 A3) A4)A1 (A2 (A3 A4))


矩阵链乘法问题就是:从所有可能的括号化方案中,找出一种使得完成整个链乘法所需总成本(即总标量乘法次数)最小的方案。


对于 n 个矩阵,可能的括号化方案总数是一个卡特兰数 C(n-1)。当 n 较大时,暴力枚举所有方案是不可行的,因此我们需要更高效的算法——动态规划。

动态规划解法

动态规划通过将大问题分解为重叠子问题,并存储子问题的解来避免重复计算,从而高效地解决问题。对于矩阵链乘法,我们采用自底向上的填表法。



定义子问题与表格


我们定义子问题:M[i][j] 表示计算矩阵链 Ai ... Aj(即从第 i 个到第 j 个矩阵)所需的最小标量乘法次数。


我们还需要一个辅助表格 S[i][j],用于记录在得到 M[i][j] 这个最优解时,我们在哪个位置 k 进行了分割(即括号的位置),使得 (Ai ... Ak) 和 (A(k+1) ... Aj) 先分别计算,然后再相乘。


假设矩阵链的维度信息存储在一个数组 d 中,其中矩阵 Ai 的维度为 d[i-1] × d[i]。对于 n 个矩阵,数组 d 的长度为 n+1。


递推公式
对于子问题 M[i][j],我们需要考虑所有可能的分割点 k(i ≤ k < j)。如果我们选择在 k 处分割,那么:
- 先计算左边子链
Ai ... Ak的最小成本:M[i][k] - 再计算右边子链
A(k+1) ... Aj的最小成本:M[k+1][j] - 最后将这两个结果矩阵相乘的成本:
d[i-1] * d[k] * d[j](左边结果矩阵维度为d[i-1] × d[k],右边为d[k] × d[j])


因此,在 k 处分割的总成本是这三部分之和。我们需要遍历所有可能的 k,找到使总成本最小的那个。递推公式如下:
如果 i == j,则 M[i][j] = 0 (单个矩阵无需相乘)
否则:
M[i][j] = min { M[i][k] + M[k+1][j] + d[i-1]*d[k]*d[j] }, 其中 i ≤ k < j
S[i][j] = 使上述表达式取最小值的 k


填表示例

让我们通过一个具体例子来演示填表过程。假设有4个矩阵:
- A1: 5 × 4
- A2: 4 × 6
- A3: 6 × 2
- A4: 2 × 7



那么维度数组 d = [5, 4, 6, 2, 7]。



我们初始化表格 M 和 S。首先,对角线 M[i][i] = 0,因为单个矩阵没有乘法成本。


第一步:计算长度为2的链(即相邻矩阵对)
M[1][2]:计算 A1 * A2。成本 = 5 * 4 * 6 = 120。S[1][2] = 1(在第一个矩阵后分割,但长度为2时只有一种可能)。M[2][3]:计算 A2 * A3。成本 = 4 * 6 * 2 = 48。S[2][3] = 2。M[3][4]:计算 A3 * A4。成本 = 6 * 2 * 7 = 84。S[3][4] = 3。


第二步:计算长度为3的链
M[1][3]:计算 A1 A2 A3。有两种括号化方式:(A1 A2) A3:成本 =M[1][1] + M[2][3] + 5*4*2 = 0 + 48 + 40 = 88A1 (A2 A3):成本 =M[1][2] + M[3][3] + 5*6*2 = 120 + 0 + 60 = 180
取最小值 88,所以M[1][3] = 88,S[1][3] = 1(在k=1处分割,即(A1)(A2 A3)这种形式,但结合顺序是(A1 (A2 A3))?注意:S[1][3]=1表示分割为(A1)和(A2 A3),然后组合成(A1 (A2 A3)))。
M[2][4]:计算 A2 A3 A4。有两种方式:(A2 A3) A4:成本 =M[2][2] + M[3][4] + 4*6*7 = 0 + 84 + 168 = 252A2 (A3 A4):成本 =M[2][3] + M[4][4] + 4*2*7 = 48 + 0 + 56 = 104
取最小值 104,所以M[2][4] = 104,S[2][4] = 3(在k=3处分割,即(A2 A3)(A4),组合成((A2 A3) A4)?这里S[2][4]=3表示分割为(A2 A3)和(A4),即((A2 A3) A4))。

第三步:计算长度为4的整个链
M[1][4]:计算 A1 A2 A3 A4。有三种分割方式:- k=1:
(A1)(A2 A3 A4)-> 成本 =M[1][1] + M[2][4] + 5*4*7 = 0 + 104 + 140 = 244 - k=2:
(A1 A2)(A3 A4)-> 成本 =M[1][2] + M[3][4] + 5*6*7 = 120 + 84 + 210 = 414 - k=3:
(A1 A2 A3)(A4)-> 成本 =M[1][3] + M[4][4] + 5*2*7 = 88 + 0 + 70 = 158
取最小值 158,所以M[1][4] = 158,S[1][4] = 3。
- k=1:



因此,最小乘法总成本为158。根据 S 表可以重构最优括号化方案:
S[1][4] = 3,表示在3处分割:(A1 A2 A3) (A4)。- 再看
S[1][3] = 1,表示(A1 A2 A3)这个子链在1处分割:(A1) (A2 A3)。 - 而
S[2][3] = 2,表示(A2 A3)就是(A2)(A3)。


所以最终的最优计算顺序是:(A1 (A2 A3)) A4。即先计算 A2 * A3(成本48),再计算其结果与 A1 相乘(成本 5*6*2=60,加上子成本总88),最后再与 A4 相乘(成本 5*2*7=70,总成本88+70=158)。
时间复杂度分析
我们需要填充 M 和 S 表格的上三角部分(因为 i ≤ j)。对于 n 个矩阵,表格大约有 n²/2 个元素需要计算。





在计算每个 M[i][j] 时,我们需要遍历 i 到 j-1 的所有 k 值,在最坏情况下(即计算 M[1][n] 时)需要 O(n) 次比较。



因此,算法的总时间复杂度为 O(n³)。虽然仍然是多项式时间,但比暴力枚举所有卡特兰数种方案要高效得多。
总结



本节课中我们一起学*了矩阵链乘法问题及其动态规划解法。

我们首先回顾了矩阵乘法的基本规则和成本计算方式。然后,我们定义了矩阵链乘法问题:寻找最优的矩阵相乘顺序以最小化总标量乘法次数。

为了解决这个问题,我们引入了动态规划方法:
- 定义了子问题
M[i][j]和辅助表S[i][j]。 - 推导出了核心的递推公式,通过考虑所有可能的分割点来找到最小成本。
- 通过一个详细的例子,演示了如何自底向上地填充动态规划表格,并从中读出最小成本和最优的括号化方案。
- 最后,我们分析了算法的时间复杂度为
O(n³)。



动态规划通过巧妙地组织子问题并避免重复计算,为我们提供了一种高效解决矩阵链乘法及其他具有最优子结构问题的强大工具。
051:矩阵链乘法问题 🧮







在本节课中,我们将学*如何使用动态规划解决矩阵链乘法问题。我们将从矩阵乘法的基本规则开始,逐步推导出动态规划公式,并通过一个完整的例子演示如何应用该公式来找到最优的括号化方案,以最小化乘法运算的总次数。








矩阵乘法基础 📐





首先,我们需要理解两个矩阵相乘的基本规则。





两个矩阵 A 和 B 可以相乘的条件是:A 的列数必须等于 B 的行数。






假设矩阵 A 的维度是 p × q,矩阵 B 的维度是 q × r。那么:
- 它们可以相乘。
- 结果矩阵 C 的维度将是
p × r。 - 计算 C 所需的标量乘法总数为
p × q × r。





例如,一个 2×3 的矩阵与一个 3×2 的矩阵相乘:
- 条件满足(3 = 3)。
- 结果矩阵维度为
2×2。 - 总乘法次数为
2 × 3 × 2 = 12。


对于两个矩阵,我们通常用三个不同的维度来描述:p、q、r。其中 q 是共用的维度。










什么是矩阵链乘法问题?⛓️


上一节我们介绍了两个矩阵的乘法。本节中我们来看看当有多个矩阵(形成一个链)需要相乘时,会出现什么问题。





矩阵乘法满足结合律,这意味着对于链 A1 × A2 × A3 × ... × An,我们可以通过不同的方式添加括号来改变乘法的顺序。虽然最终的计算结果相同,但不同的计算顺序(括号化方案)会导致所需的标量乘法总次数截然不同。

矩阵链乘法问题的目标是:给定一个矩阵链(每个矩阵的维度已知),确定一个最优的括号化方案,使得计算该链乘积所需的总标量乘法次数最少。

考虑三个矩阵 A1(2×3), A2(3×4), A3(4×2):
- 方案一:
(A1 × A2) × A3- 计算
A1 × A2:2×3×4 = 24次乘法,得到(2×4)的中间矩阵。 - 计算中间矩阵
× A3:2×4×2 = 16次乘法。 - 总次数:24 + 16 = 40
- 计算
- 方案二:
A1 × (A2 × A3)- 计算
A2 × A3:3×4×2 = 24次乘法,得到(3×2)的中间矩阵。 - 计算
A1 ×中间矩阵:2×3×2 = 12次乘法。 - 总次数:24 + 12 = 36
- 计算







可以看到,第二种方案更优。当矩阵数量很大时,不同方案间的效率差异会非常显著。暴力枚举所有括号化方案(其数量是卡特兰数)是不可行的,因此我们需要更聪明的方法——动态规划。
















动态规划公式推导 🧠






动态规划的核心思想是将大问题分解为重叠的子问题,并存储子问题的解以避免重复计算。对于矩阵链 A_i ... A_j,我们尝试在所有可能的位置 k (i ≤ k < j) 将其分割为两部分 (A_i ... A_k) 和 (A_{k+1} ... A_j)。





设:
d[i]表示矩阵A_i的行数(i从 0 开始)。因此矩阵A_i的维度为d[i-1] × d[i]。m[i][j]表示计算子链A_i ... A_j所需的最少标量乘法次数。





对于链 A_i ... A_j,如果我们选择在 A_k 和 A_{k+1} 之间分割,那么:
- 计算左边子链
A_i ... A_k的最小代价为m[i][k]。 - 计算右边子链
A_{k+1} ... A_j的最小代价为m[k+1][j]。 - 将这两个结果矩阵相乘的代价为
d[i-1] × d[k] × d[j](左边结果矩阵维度为d[i-1] × d[k],右边为d[k] × d[j])。








因此,对于每个 k,总代价为:m[i][k] + m[k+1][j] + d[i-1] * d[k] * d[j]。
我们需要遍历所有可能的 k,并取其中的最小值作为 m[i][j] 的最优解。





由此得到动态规划递推公式:







当 i < j 时:
m[i][j] = min_{i ≤ k < j} ( m[i][k] + m[k+1][j] + d[i-1] * d[k] * d[j] )




边界条件:
m[i][i] = 0 (单个矩阵不需要乘法)




同时,我们需要另一个表 s[i][j] 来记录得到 m[i][j] 最优值时的分割点 k,以便最后构造出最优的括号化方案。














动态规划求解步骤 📊




上一节我们推导出了核心公式。本节中我们来看看如何系统地填表求解。







我们以包含4个矩阵的链为例,其维度数组为:d = [3, 2, 4, 2, 5]。这意味着:
A1: 3×2A2: 2×4A3: 4×2A4: 2×5






我们需要填充两个 n×n 的表格(n=4):m(代价表)和 s(分割点表)。







以下是具体的填表过程:




- 初始化:将对角线
m[i][i]设为 0,因为单个矩阵的乘法代价为0。m[1][1] = 0 m[2][2] = 0 m[3][3] = 0 m[4][4] = 0







- 计算链长为2的子问题(即
j-i = 1):m[1][2]:只有一种分割方式(k=1)。
= m[1][1] + m[2][2] + d0*d1*d2 = 0 + 0 + 3*2*4 = 24,s[1][2]=1m[2][3]:k=2。
= 0 + 0 + d1*d2*d3 = 2*4*2 = 16,s[2][3]=2m[3][4]:k=3。
= 0 + 0 + d2*d3*d4 = 4*2*5 = 40,s[3][4]=3







- 计算链长为3的子问题(即
j-i = 2):m[1][3]:k可以是1或2。- k=1:
m[1][1]+m[2][3] + d0*d1*d3 = 0+16 + 3*2*2 = 16+12=28 - k=2:
m[1][2]+m[3][3] + d0*d2*d3 = 24+0 + 3*4*2=24+24=48 - 最小值是28,所以
m[1][3]=28,s[1][3]=1
- k=1:
m[2][4]:k可以是2或3。- k=2:
m[2][2]+m[3][4] + d1*d2*d4 = 0+40 + 2*4*5=40+40=80 - k=3:
m[2][3]+m[4][4] + d1*d3*d4 = 16+0 + 2*2*5=16+20=36 - 最小值是36,所以
m[2][4]=36,s[2][4]=3
- k=2:









- 计算链长为4的子问题(即整个链,
j-i = 3):m[1][4]:k可以是1, 2, 3。- k=1:
m[1][1]+m[2][4] + d0*d1*d4 = 0+36 + 3*2*5=36+30=66 - k=2:
m[1][2]+m[3][4] + d0*d2*d4 = 24+40 + 3*4*5=64+60=124 - k=3:
m[1][3]+m[4][4] + d0*d3*d4 = 28+0 + 3*2*5=28+30=58 - 最小值是58,所以
m[1][4]=58,s[1][4]=3
- k=1:







最终,我们得到最小乘法总次数为 58。s 表记录了最优分割点。














构造最优括号化方案 🏗️









现在我们已经得到了最小代价和分割点表 s。本节中我们利用 s 表来重建最优的括号化方案。







递归地从 s[1][4] 开始:
s[1][4] = 3,表示最优分割在矩阵A3之后。因此整体结构为:(A1 A2 A3) (A4)。- 对于左子链
(1, 3),查s[1][3] = 1,表示在A1之后分割。结构变为:((A1) (A2 A3)) (A4)。 - 对于子链
(2, 3),查s[2][3] = 2,表示在A2之后分割(即A2和A3直接相乘)。最终结构为:((A1) ((A2)(A3))) (A4)。







去掉不必要的单矩阵括号,最优的乘法顺序是:(A1 × (A2 × A3)) × A4。






这意味着计算步骤是:
- 先计算
A2 × A3(代价16)。 - 再将结果与
A1相乘(代价3*2*2=12?注意维度变化:A1(3×2)乘(A2A3)(2×2)代价为3*2*2=12,总代价此时为28)。 - 最后将结果
(3×2)与A4(2×5)相乘(代价3*2*5=30)。
总验证代价:16 + 12 + 30 = 58,与动态规划结果一致。











算法复杂度与总结 🎯


本节课中我们一起学*了如何使用动态规划解决矩阵链乘法问题。









算法复杂度分析:
- 我们需要填充一个
n×n的m表,这大约有O(n²)个子问题。 - 对于每个子问题
m[i][j],我们需要检查最多j-i种分割可能,在最坏情况下是O(n)。 - 因此,动态规划算法的总时间复杂度为 O(n³)。这比暴力枚举所有卡特兰数数量的括号化方案要高效得多。
- 空间复杂度为 O(n²),用于存储
m表和s表。





核心要点总结:
- 问题定义:寻找矩阵链相乘的最优括号化顺序,以最小化标量乘法次数。
- 动态规划思路:将大链分解为两个子链,合并子问题的最优解。
- 关键公式:
m[i][j] = min_{i≤k<j} ( m[i][k] + m[k+1][j] + d[i-1]*d[k]*d[j] ) - 求解步骤:按链长自底向上填表,记录分割点。
- 方案重建:利用分割点表递归地构造出最优括号化方案。




通过掌握这个经典的动态规划问题,你不仅学会了如何优化矩阵乘法顺序,更重要的是理解了如何将复杂问题分解为重叠子问题并高效求解的通用范式。
052:矩阵链乘法(程序实现)⚙️

在本节课中,我们将学*如何使用动态规划方法,通过编写程序来解决矩阵链乘法问题。我们将重点关注程序的实现逻辑,并逐步追踪其执行过程,以理解如何计算最小乘法成本并确定最优的括号化方案。
上一节我们介绍了矩阵链乘法的动态规划原理和公式推导,本节中我们来看看如何将这些理论转化为实际的程序代码。

程序结构与初始化
首先,让我们查看程序的基本结构。为了简化,所有逻辑都写在了 main 函数中。这足以展示算法的核心思想,未来可以将其封装成独立的函数。
以下是程序初始化的关键部分:

int p[] = {5, 4, 6, 2, 7}; // 矩阵的维度数组
int n = 5; // 数组长度,代表有 n-1 个矩阵
int m[n][n]; // 存储最小代价的二维数组
int s[n][n]; // 存储最优分割点 k 的二维数组
我们有一个包含4个矩阵的实例,其维度分别为 5x4, 4x6, 6x2, 2x7。数组 p 存储了这些维度。我们创建了两个 n x n 的二维数组 m 和 s,其中 n 为维度数组的长度。m[i][j] 用于存储计算矩阵 A_i 到 A_j 所需的最小标量乘法次数,s[i][j] 则记录达到该最小值时的最优分割点 k。
程序开始时,先将 m 数组的主对角线(即 i == j 的情况)初始化为0,因为单个矩阵无需乘法。


核心算法:填充动态规划表
程序的核心是三层嵌套循环,用于按对角线顺序填充 m 和 s 表。

上一节我们初始化了表格,本节中我们来看看填充表格的具体循环逻辑。


外层循环控制子链的长度 L(在代码中常表示为 d 或 len),它从 2 开始,直到 n-1(即矩阵个数)。对于长度为 L 的子链,中层循环确定子链的起始点 i,内层循环则遍历所有可能的分割点 k,并应用动态规划递推公式计算最小代价。
以下是填充表格的核心代码逻辑:
for (int L = 2; L < n; L++) { // L是链的长度
for (int i = 1; i < n - L + 1; i++) {
int j = i + L - 1;
m[i][j] = INT_MAX; // 初始化为一个很大的数
for (int k = i; k <= j-1; k++) {
// 应用递推公式
int cost = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
if (cost < m[i][j]) {
m[i][j] = cost;
s[i][j] = k;
}
}
}
}
递推公式如下:
m[i][j] = min_{i≤k<j} ( m[i][k] + m[k+1][j] + p_{i-1} * p_k * p_j )
这个公式的含义是:为了计算 A_i 到 A_j 的最小代价,我们尝试在所有可能的位置 k 将其分割成两部分 (A_i ... A_k) 和 (A_{k+1} ... A_j)。总代价等于左半部分的代价、右半部分的代价,以及合并这两部分结果矩阵的代价之和。我们选择所有 k 中总代价最小的那个。
实例追踪与计算
现在,我们以具体的矩阵链为例,手动追踪程序如何填充表格。
假设我们有四个矩阵,维度数组为 p = {5, 4, 6, 2, 7}。这意味着:
A1: 5 x 4A2: 4 x 6A3: 6 x 2A4: 2 x 7
程序将按以下顺序计算:
-
长度为2的子链 (
L=2):
以下是计算示例:m[1][2]:计算A1 * A2。代价 =5*4*6 = 120。m[2][3]:计算A2 * A3。代价 =4*6*2 = 48。m[3][4]:计算A3 * A4。代价 =6*2*7 = 84。
-
长度为3的子链 (
L=3):
以下是计算m[1][3]的过程,即计算A1 A2 A3的最小代价:- 分割点
k=1:(A1)(A2 A3)。代价 =m[1][1] + m[2][3] + 5*4*2 = 0 + 48 + 40 = 88。 - 分割点
k=2:(A1 A2)(A3)。代价 =m[1][2] + m[3][3] + 5*6*2 = 120 + 0 + 60 = 180。 - 最小值为
88,所以m[1][3] = 88,s[1][3] = 1。
同理可计算m[2][4]。
- 分割点
-
长度为4的子链 (
L=4):
以下是计算整个链m[1][4]的过程:- 尝试所有分割点
k=1,2,3,使用上述公式和已计算的m[][]值,找出最小代价。最终会得到m[1][4] = 158,最优分割点s[1][4] = 3。
- 尝试所有分割点
通过这个过程,我们最终得到 m[1][4] = 158,这就是链乘的最小标量乘法次数。同时,s 表记录了每一步的最优分割点。
重构最优解
计算出最小代价后,我们需要根据 s 表重构出具体的括号化方案。
重构过程是一个递归或迭代的过程,根据 s[i][j] 记录的分割点 k,将问题分解为左子链 (i, k) 和右子链 (k+1, j),然后分别对子链进行同样的操作,直到子链只包含一个矩阵。这本质上是在构建一棵最优二叉树,对其进行中序遍历即可得到带括号的表达式。
例如,根据我们追踪得到的 s 表:
s[1][4] = 3,表示在矩阵3和4之间分割:(A1 A2 A3) (A4)。- 对于左子链
(1,3),s[1][3] = 1,表示在矩阵1和2之间分割:(A1) (A2 A3)。 - 因此,最优的括号化顺序是:
((A1 (A2 A3)) A4)。这个顺序对应的计算代价最小,为158。
总结
本节课中我们一起学*了矩阵链乘法问题的程序实现。我们首先了解了程序的数据结构初始化,然后深入分析了用于填充动态规划表的三层循环核心算法,并手动追踪了一个实例的计算过程。最后,我们学*了如何利用存储分割点的表格来重构出最优的括号化方案。

这个程序清晰地展示了如何将动态规划的递推公式转化为迭代代码,是理解动态规划“自底向上”求解思想的经典案例。你可以尝试修改矩阵的维度和数量,运行程序来验证不同情况下的结果。
053:贝尔曼-福特算法(单源最短路径 - 动态规划)🚀
在本节课中,我们将学*贝尔曼-福特算法。这是一个用于解决单源最短路径问题的算法,特别适用于图中包含负权边的情况。我们将了解算法的工作原理、步骤、时间复杂度,以及它的一个重要限制。
概述
单源最短路径问题是指:在一个有向加权图中,我们选择一个顶点作为源点,然后找出从该源点到图中所有其他顶点的最短路径。


例如,如果我们选择顶点1作为源点,就需要找出从1到所有其他顶点的最短距离。

迪杰斯特拉算法的局限性

我们已经有一个算法可以解决最短路径问题,那就是迪杰斯特拉算法。但是,迪杰斯特拉算法在图中存在负权边时无法保证正确工作。它可能给出正确结果,也可能不会。因此,我们不能在有负权边的图中放心地使用迪杰斯特拉算法。

我们需要一个能确保答案正确的算法,这就是贝尔曼-福特算法。它遵循动态规划策略,即尝试所有可能的解决方案,并从中选出最优解。


算法核心思想

贝尔曼-福特算法的核心思想是:反复松弛所有边。
松弛是算法中的关键操作。对于任意两个顶点 u 和 v,如果存在一条从 u 到 v 的边,则检查:
如果 distance[u] + cost(u, v) < distance[v],那么就将 distance[v] 更新为 distance[u] + cost(u, v)。
用公式表示就是:
if d[u] + w(u, v) < d[v]:
d[v] = d[u] + w(u, v)


算法要求我们重复这个过程 V-1 次,其中 V 是图中顶点的数量。为什么是 V-1 次?因为从源点到任意顶点的最长简单路径最多包含 V-1 条边。通过 V-1 轮松弛,足以确保找到所有可能的最短路径。

算法步骤详解
让我们通过一个例子来一步步理解算法。假设我们有以下有向加权图(图例见视频),选择顶点1为源点。

以下是算法的具体步骤:
-
初始化距离:
- 将源点(顶点1)的距离设为
0。 - 将所有其他顶点的距离初始化为
∞(无穷大)。
- 将源点(顶点1)的距离设为
-
进行 V-1 轮松弛:
- 对于图中的每一条边,按顺序应用松弛操作。
- 重复此过程 V-1 轮。
第一轮松弛

我们按顺序处理每条边,检查并更新距离:

- 边 (1,2):
d[1] + 6 = 0 + 6 = 6,小于d[2] = ∞。更新d[2] = 6。 - 边 (1,3):
0 + 5 = 5,小于∞。更新d[3] = 5。 - 边 (1,4):
0 + 5 = 5,小于∞。更新d[4] = 5。 - 边 (2,5):
d[2] + (-1) = 6 - 1 = 5,小于d[5] = ∞。更新d[5] = 5。 - 边 (3,2):
d[3] + (-2) = 5 - 2 = 3,小于d[2] = 6。更新d[2] = 3。 - 边 (3,5):
d[3] + 1 = 5 + 1 = 6,不小于d[5] = 5。不更新。 - 边 (4,3):
d[4] + (-2) = 5 - 2 = 3,小于d[3] = 5。更新d[3] = 3。 - 边 (4,6):
d[4] + (-1) = 5 - 1 = 4,小于∞。更新d[6] = 4。 - 边 (5,7):
d[5] + 3 = 5 + 3 = 8,小于∞。更新d[7] = 8。 - 边 (6,7):
d[6] + 3 = 4 + 3 = 7,小于d[7] = 8。更新d[7] = 7。



第一轮结束后,各点距离为:d[1]=0, d[2]=3, d[3]=3, d[4]=5, d[5]=5, d[6]=4, d[7]=7。
后续轮次松弛

我们继续重复上述过程,进行第二轮、第三轮...直到完成 V-1 轮(本例中 V=7,所以需要6轮)。
在后续轮次中,我们继续对所有边进行松弛操作。你会发现,有些距离会进一步更新(例如 d[5] 从5变为0,d[7] 从7变为3),而有些则保持不变。
关键点:在第三轮松弛之后,本例中的所有距离值就不再发生变化了。这意味着我们已经找到了最短路径。但在编写算法时,它仍然会执行完 V-1 轮。有些图可能一轮就找到答案,有些图可能需要到最后一轮。
最终,我们得到从源点1到各点的最短距离为:
d[1] = 0d[2] = 1d[3] = 3d[4] = 5d[5] = 0d[6] = 4d[7] = 3
算法复杂度分析

贝尔曼-福特算法的时间复杂度很容易计算:
- 我们需要进行 V-1 轮松弛。
- 在每一轮中,我们遍历所有的 E 条边。
- 因此,总时间复杂度为 O(V * E)。
在稠密图(完全图)中,边的数量 E 约为 V²,所以此时算法的时间复杂度接* O(V³)。在稀疏图中,则接* O(V²)。
处理负权边与负权环 🔄
上一节我们看到了贝尔曼-福特算法在普通含负权边图上的工作流程。现在,让我们通过另一个例子来展示它如何处理负权边,并揭示其最重要的限制。
考虑一个更小的图,我们同样使用贝尔曼-福特算法。经过 V-1 轮松弛后,算法成功计算出了包含负权边的最短路径,证明了它在负权边下的有效性。


然而,贝尔曼-福特算法有一个致命的缺点:它无法处理图中存在负权环的情况。
什么是负权环?
负权环是指一个环路上所有边的权重之和为负数。例如,一个由边 (2,4)=5, (4,3)=3, (3,2)=-10 组成的环,总权重为 5+3+(-10) = -2。
负权环导致的问题
如果图中存在负权环,且该环可以从源点到达,那么“最短路径”的概念就失去了意义。因为你可以无限次地绕着这个负权环走,每走一圈路径总成本就会减少,从而不存在一个有限的“最短”路径。
在算法执行中,这意味着即使在完成规定的 V-1 轮松弛之后,如果我们再进行一轮额外的松弛检查,某些顶点的距离值仍然会发生变化。而这在不存在负权环的正常图中是不会发生的。
算法的额外功能:检测负权环
这正是贝尔曼-福特算法的一个强大之处:它不仅可以求最短路径,还可以检测图中是否存在从源点可达的负权环。
检测方法非常简单:
- 正常执行 V-1 轮松弛。
- 再额外执行一轮松弛。
- 如果在这一轮中,有任何顶点的距离值被更新(变短),那么就说明图中存在从源点可达的负权环。

总结

本节课我们一起学*了贝尔曼-福特算法。让我们回顾一下重点:
- 目的:解决有向加权图的单源最短路径问题,尤其适用于含有负权边的图。
- 核心操作:松弛。反复对图中所有边执行 V-1 轮松弛操作。
- 时间复杂度:O(V * E),在稠密图中可能达到 O(V³)。
- 与迪杰斯特拉算法的区别:迪杰斯特拉不能处理负权边,而贝尔曼-福特可以。
- 重要限制:无法处理存在从源点可达的负权环的图。因为这种情况下最短路径可能不存在(无限递减)。
- 额外功能:通过执行第 V 轮松弛检查,可以检测出图中是否存在负权环。

贝尔曼-福特算法虽然比迪杰斯特拉算法慢,但其对负权边的处理能力和负权环检测功能,使其在特定场景下是不可或缺的工具。
054:0/1背包问题 - 两种动态规划方法 🎒
在本节课中,我们将要学*0/1背包问题,并掌握两种使用动态规划解决该问题的方法:表格法和集合法。我们将从理解问题定义开始,逐步学*如何应用动态规划思想,并最终得到最优解。


问题定义


0/1背包问题是一个经典的优化问题。假设我们有一个容量为 M 的背包和 n 个物品。每个物品 i 有其自身的利润 pᵢ 和重量 wᵢ。我们的目标是选择物品的一个子集放入背包,使得在总重量不超过背包容量 M 的前提下,总利润最大化。
问题的核心约束是:每个物品要么整个放入(用 xᵢ = 1 表示),要么完全不放入(用 xᵢ = 0 表示),不能只放入物品的一部分。这就是“0/1”名称的由来。


用数学公式描述如下:
- 目标:最大化 ∑ pᵢ * xᵢ (i=1 to n)
- 约束:∑ wᵢ * xᵢ ≤ M (i=1 to n)
- 其中:xᵢ ∈ {0, 1}


动态规划方法概述

上一节我们明确了问题,本节中我们来看看动态规划如何解决它。动态规划适用于解决此类优化问题,其核心思想是:
- 序列决策:问题可以分解为一系列决策(对每个物品决定放或不放)。
- 尝试所有可能:理论上需要检查所有 2ⁿ 种物品组合(子集),但直接检查的复杂度是指数级的 O(2ⁿ),效率太低。
- 避免重复计算:动态规划通过存储子问题的解(记忆化),避免重复计算,从而将指数复杂度降低为多项式复杂度。
接下来,我们将通过一个具体例子来学*两种实现动态规划思想的方法。

示例数据:
假设背包容量 M = 8,有4个物品,其利润和重量如下:
| 物品 (i) | 利润 (pᵢ) | 重量 (wᵢ) |
|---|---|---|
| 1 | 1 | 2 |
| 2 | 2 | 3 |
| 3 | 5 | 4 |
| 4 | 6 | 5 |


方法一:表格法(自底向上)




表格法是动态规划最直观的实现方式。我们创建一个二维表格 V,其中 V[i][w] 表示:当只考虑前 i 个物品,且背包容量为 w 时,能获得的最大利润。


以下是构建动态规划表格的步骤:


首先,初始化表格。行 i 代表考虑前 i 个物品(i=0 表示不考虑任何物品),列 w 代表背包当前容量(从 0 到 M)。
- 当
i=0(没有物品)或w=0(没有容量)时,最大利润显然为0。

现在,我们开始填充表格。对于每个 i > 0 和 w > 0,我们面临一个选择:是否将第 i 个物品放入背包。
- 选择1:不放入物品 i。那么最大利润就等于只考虑前
i-1个物品、容量为w时的最大利润,即V[i-1][w]。 - 选择2:放入物品 i(前提是物品 i 的重量
wᵢ ≤ w)。放入后,背包剩余容量为w - wᵢ,我们需要在这个剩余容量下,从前i-1个物品中寻找最优解。此时总利润为V[i-1][w - wᵢ] + pᵢ。





我们的目标是利润最大化,因此 V[i][w] 的值就是上述两种选择中的较大值。



这可以总结为以下递推公式:
如果 wᵢ > w:
V[i][w] = V[i-1][w]
否则:
V[i][w] = max( V[i-1][w], V[i-1][w - wᵢ] + pᵢ )



让我们用示例数据来填充这个表格:



| i\w | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 (1,2) | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 (2,3) | 0 | 0 | 1 | 2 | 2 | 3 | 3 | 3 | 3 |
| 3 (5,4) | 0 | 0 | 1 | 2 | 5 | 5 | 6 | 7 | 7 |
| 4 (6,5) | 0 | 0 | 1 | 2 | 5 | 6 | 6 | 7 | 8 |


表格解读:
V[4][8] = 8就是我们的最终答案:在容量为8的背包中,能获得的最大利润是8。- 填充过程遵循上述公式。例如
V[4][5] = max(V[3][5]=5, V[3][0]+6=6) = 6。

回溯找出最优解



表格填完后,我们知道了最大利润,但还不知道具体选择了哪些物品。我们需要从表格右下角 (V[n][M]) 开始回溯。




以下是回溯步骤:
- 从
i=n,w=M开始。 - 比较
V[i][w]和V[i-1][w]。- 如果
V[i][w] != V[i-1][w],说明第i个物品被放入了最优解。记录xᵢ = 1,然后将w减去wᵢ,i减1。 - 如果
V[i][w] == V[i-1][w],说明第i个物品没有放入。记录xᵢ = 0,仅将i减1。
- 如果
- 重复步骤2,直到
i=0。




对我们的例子进行回溯:
V[4][8]=8,V[3][8]=7,两者不等 →x₄=1。更新:w = 8-5=3,i=3。V[3][3]=2,V[2][3]=2,两者相等 →x₃=0。更新:i=2。V[2][3]=2,V[1][3]=1,两者不等 →x₂=1。更新:w = 3-3=0,i=1。V[1][0]=0,V[0][0]=0,两者相等 →x₁=0。更新:i=0,结束。




因此,最优解为 (x₁, x₂, x₃, x₄) = (0, 1, 0, 1),即放入物品2和物品4,总利润为8,总重量为3+5=8。
方法二:集合法(生成所有可能子集)


集合法更直接地体现了“尝试所有可能组合”的思想,但通过巧妙的合并与支配规则来剪枝,避免显式枚举所有 2ⁿ 个子集。




该方法的核心是维护一个有序对 (利润, 重量) 的集合 S^i,表示考虑前 i 个物品时,所有可行的(重量不超限)、且互不支配的解决方案。
一个方案 (p_a, w_a) 支配 另一个方案 (p_b, w_b),如果 p_a ≥ p_b 且 w_a ≤ w_b(即前者利润更高或相等,但重量更轻或相等)。被支配的方案可以从集合中移除,因为它不可能是最优解。



以下是构建集合的步骤:
- 初始化:
S⁰ = {(0, 0)},表示不考虑任何物品。 - 迭代:对于每个物品
i(利润pᵢ, 重量wᵢ):
a. 生成新集合:Temp = {(p + pᵢ, w + wᵢ) | (p, w) ∈ S^(i-1)}。这相当于将当前物品加入S^(i-1)中的每个方案。
b. 合并与剪枝:S^i = MergeAndPurge(S^(i-1), Temp)。
* 合并:将S^(i-1)和Temp合并。
* 排序:按利润(或重量)排序。
* 剪枝:
* 移除任何总重量w > M的方案(不可行)。
* 应用支配规则:遍历集合,如果后一个方案的利润不高于前一个,但重量更重,则移除后一个被支配的方案。



让我们用示例数据演示:




S⁰ = {(0,0)}- 考虑物品1 (1,2):
Temp = {(0+1, 0+2)} = {(1,2)}S¹ = MergeAndPurge({(0,0)}, {(1,2)}) = {(0,0), (1,2)}
- 考虑物品2 (2,3):
Temp = {(0+2,0+3), (1+2,2+3)} = {(2,3), (3,5)}S² = MergeAndPurge({(0,0),(1,2)}, {(2,3),(3,5)}) = {(0,0), (1,2), (2,3), (3,5)}
- 考虑物品3 (5,4):
Temp = {(0+5,0+4), (1+5,2+4), (2+5,3+4), (3+5,5+4)} = {(5,4), (6,6), (7,7), (8,9)}- 合并后为
{(0,0),(1,2),(2,3),(3,5),(5,4),(6,6),(7,7),(8,9)} - 剪枝:
- 移除
(8,9)(重量9>M=8)。 - 应用支配规则:
(5,4)支配(3,5)(利润5>3,重量4<5),所以移除(3,5)。
- 移除
S³ = {(0,0), (1,2), (2,3), (5,4), (6,6), (7,7)}
- 考虑物品4 (6,5):
Temp = {(0+6,0+5), (1+6,2+5), (2+6,3+5), (5+6,4+5), (6+6,6+5), (7+6,7+5)} = {(6,5), (7,7), (8,8), (11,9), (12,11), (13,12)}- 合并
S³和Temp。 - 剪枝:
- 移除重量超限的
(11,9),(12,11),(13,12)。 - 应用支配规则:
(6,5)支配(5,4)(利润6>5,重量5>4?注意,这里(6,5)利润更高但重量也更高,两者互不支配。但(7,7)和(7,7)重复,保留一个)。检查集合顺序,(6,6)和(6,5)利润相同,后者重量更轻,所以(6,5)支配(6,6),移除(6,6)。
- 移除重量超限的
S⁴ = {(0,0), (1,2), (2,3), (5,4), (6,5), (7,7), (8,8)}




最终集合 S⁴ 中的最后一个有序对 (8,8) 给出了最大利润8。


从集合回溯最优解
回溯过程与表格法类似,但基于集合操作:
- 从最终解
(p, w) = (8,8)和i=4开始。 - 检查
(p, w)是否存在于S^(i-1)(即S³)中。- 如果不存在,则第
i个物品被选中 (xᵢ=1)。更新:p = p - pᵢ,w = w - wᵢ。 - 如果存在,则第
i个物品未被选中 (xᵢ=0)。
- 如果不存在,则第
i减1,重复步骤2,直到i=0。





对我们的例子进行回溯:
(8,8)在S⁴中,但不在S³中 →x₄=1。更新:(p,w) = (8-6, 8-5) = (2,3),i=3。(2,3)在S³中,也在S²中 →x₃=0。更新:i=2。(2,3)在S²中,但不在S¹中 →x₂=1。更新:(p,w) = (2-2, 3-3) = (0,0),i=1。(0,0)在S¹中,也在S⁰中 →x₁=0。更新:i=0,结束。
得到相同的最优解 (0, 1, 0, 1)。


总结
本节课中我们一起学*了0/1背包问题及其两种动态规划解法:
- 表格法:通过构建一个二维DP表,利用递推公式自底向上计算所有子问题的最优值,最后回溯得到具体方案。这种方法结构清晰,是动态规划的经典实现。
- 集合法:通过维护和更新一个“利润-重量”有序对集合,显式地生成并剪枝所有可能的解决方案。这种方法更直观地展示了状态空间的演进,并通过支配规则有效减少了状态数量。



两种方法都能在伪多项式时间复杂度内(与物品数量 n 和背包容量 M 相关)解决问题,避免了暴力枚举的指数级复杂度。理解这两种方法有助于深化对动态规划“最优子结构”和“重叠子问题”两个核心性质的认识。
055:0/1背包问题(程序实现)⚙️

在本节课中,我们将学*如何使用动态规划方法解决0/1背包问题,并详细讲解其程序实现。我们将通过一个具体的例子,逐步追踪代码执行过程,以理解如何编写解决此类问题的程序。


问题概述


0/1背包问题描述如下:给定一个背包,其容量有限。我们需要选择一些物品放入背包中,使得在不超过背包容量的前提下,所获总价值最大。每个物品要么完整放入,要么不放入。
程序结构与数据

以下是解决该问题所需的初始数据。我们定义了一个利润数组、一个重量数组、物品数量以及背包的总容量。




int p[] = {0, 1, 2, 5, 6}; // 利润数组,索引0占位
int w[] = {0, 2, 3, 4, 5}; // 重量数组,索引0占位
int n = 4; // 物品数量
int m = 8; // 背包容量
注意,数组大小设为5(索引0到4),这是为了从索引1开始表示第一个物品,索引0留空以简化边界条件处理。
动态规划表


为了解决此问题,我们需要一个动态规划表。该表的大小应为 (n+1) x (m+1),即5行(对应0到4号物品)和9列(对应0到8的容量)。
int k[5][9]; // 动态规划表
核心算法逻辑

程序的核心逻辑是使用嵌套循环来填充动态规划表。外层循环 i 遍历物品(行),内层循环 w 遍历背包容量(列)。
for(int i=0; i<=n; i++) {
for(int w=0; w<=m; w++) {
if(i==0 || w==0) {
k[i][w] = 0;
} else if(w[i] <= w) {
k[i][w] = max(p[i] + k[i-1][w-w[i]], k[i-1][w]);
} else {
k[i][w] = k[i-1][w];
}
}
}

上一节我们介绍了程序的基本结构和动态规划表,本节中我们来看看这个核心逻辑是如何一步步执行的。
算法执行过程追踪
让我们逐步追踪代码,观察动态规划表是如何被填充的。
- 初始化阶段:当
i=0(没有物品)或w=0(容量为0)时,最大利润为0。因此,表的第一行和第一列全部填充为0。 - 处理第一个物品(i=1):
- 当背包容量
w小于物品1的重量(2)时,无法放入,最大利润继承自上一行相同列的值(即k[0][w])。 - 当
w >= 2时,我们有两种选择:放入物品1或不放。我们取两者中的最大值:- 放入:利润 =
p[1] + k[0][w-2] - 不放入:利润 =
k[0][w]
例如,k[1][2] = max(1 + k[0][0], k[0][2]) = max(1, 0) = 1。
- 放入:利润 =
- 当背包容量
- 处理后续物品:对于每个物品
i和每个容量w,决策逻辑相同:- 如果物品重量
w[i]大于当前容量w,则不能放入,k[i][w] = k[i-1][w]。 - 否则,比较放入和不放入的利润:
k[i][w] = max(p[i] + k[i-1][w-w[i]], k[i-1][w])。
- 如果物品重量
通过这种方式,表格被逐行填充。最终,k[4][8] 的值就是背包容量为8时能获得的最大利润。
构造最优解
填充完动态规划表后,我们得到了最大利润。但我们也需要知道具体选择了哪些物品。以下是回溯找出最优解组成的逻辑:
int i = n, j = m;
while(i > 0 && j > 0) {
if(k[i][j] == k[i-1][j]) {
// 物品i没有被选中
i--;
} else {
// 物品i被选中
cout << "物品 " << i << " 被选中" << endl;
j = j - w[i]; // 减去物品i的重量
i--;
}
}
回溯从表的右下角 k[n][m] 开始:
- 如果
k[i][j]的值等于k[i-1][j],说明物品i没有被放入最优解,我们只需向上移动一行(i--)。 - 如果不相等,说明物品
i被放入了最优解。我们输出物品编号,然后向上移动一行(i--),同时向左移动w[i]列(j = j - w[i]),表示这部分容量已被占用。 - 重复此过程直到
i或j为0。
总结
本节课中我们一起学*了0/1背包问题的动态规划程序实现。我们首先定义了问题所需的数据结构,然后详细讲解了填充动态规划表的核心算法逻辑,并通过追踪执行过程加深理解。最后,我们学*了如何通过回溯动态规划表来构造出获得最大利润的具体物品选择方案。这个程序框架清晰地展示了动态规划“填表”和“查表”的核心思想,是解决许多优化问题的基础。
056:最优二叉搜索树(仅成功搜索)🔍
在本节课中,我们将学*最优二叉搜索树问题。我们将首先理解什么是二叉搜索树,然后探讨“最优”的含义。接着,我们将通过一个具体例子,详细讲解如何使用动态规划方法来解决这个问题。我们的重点在于展示动态规划如何在此问题上工作,而不仅仅是展示如何快速填表得到答案。
什么是二叉搜索树?🌲


假设我们有一组给定的键值,目的是在这些键值中进行搜索。除了线性搜索和二分搜索等方法,我们还可以使用一种名为“二叉树”的数据结构。那么,能否利用二叉树进行搜索呢?



我们可以将这些元素排列成一棵二叉树。例如,以40为根节点,20作为其左子节点,10作为20的左子节点,30作为20的右子节点。60作为40的右子节点,50作为60的左子节点,70作为60的右子节点。

可以看到,这棵树的排列方式使得:对于任意节点,其左侧的所有元素都小于它,其右侧的所有元素都大于它。例如,对于40,其右侧的所有元素(60, 50, 70)都更大。对于60,其左侧的50更小,右侧的70更大。这种排列方式的优点是便于搜索。


例如,要搜索键值50,我们可以从根节点40开始比较。50大于40,所以转向右子节点60。50小于60,所以转向左子节点50。找到了目标键值。

搜索一个键值所需的最大时间取决于元素在树中的位置。如果元素在叶子节点,所需时间最多,这对应于树的高度。一棵平衡二叉搜索树的高度约为 log n,因此搜索时间复杂度为 O(log n)。

此外,搜索可能成功,也可能失败。例如,搜索键值45,它会沿着路径40 -> 60 -> 50,最终在50处终止,因为45小于50且50没有左子节点,所以搜索失败。


二叉搜索树的数量与成本💰




对于给定的一组元素,可以构造出许多不同的二叉搜索树。以三个键值(10, 20, 30)为例,可以构造出5种不同的二叉搜索树。




搜索成本取决于搜索一个键值所需的比较次数。在这5棵树中,平均比较次数可能不同。例如,某棵树的平均比较次数为 (1+2+2)/3 ≈ 1.67,而另一棵则为 (1+2+3)/3 = 2。平均比较次数较少的那棵树是一棵高度平衡的二叉搜索树,其高度较低。





最优二叉搜索树问题🎯

现在,我们进入核心问题:最优二叉搜索树。在这个问题中,我们不仅考虑键值本身,还考虑每个键值被搜索的频率。


假设键值10、20、30的搜索频率分别是3、2、5。这意味着并非所有键值都以相同频率被搜索,有些键值更常用。我们需要找出,在给定频率的情况下,哪种树形结构能使总的搜索成本最小。



计算每棵树的总成本(频率 × 比较次数)后,我们发现成本各不相同。其中成本最小的树(总成本为17)就是针对这组频率的最优二叉搜索树。值得注意的是,这棵最优树不一定是高度最平衡的树,但其基于频率计算出的总成本是最低的。



因此,问题的定义是:给定键值及其搜索频率,找出总搜索成本最小的二叉搜索树结构。


动态规划求解方法🧩


接下来,我们通过一个具体例子,使用动态规划方法求解。假设我们有四个键值:10, 20, 30, 40,其对应频率为:4, 2, 6, 3。



我们创建一个动态规划表 c[i][j],用于存储从第 i 个键值到第 j 个键值(按升序排列)构成的最优二叉搜索树的最小成本。同时,我们用一个平行表 root[i][j] 记录对应子树根节点的键值索引。



初始化:首先,填充对角线,即 j - i = 0 的情况(只包含一个键值)。此时,成本就是该键值本身的频率。
c[0][0] = 0(虚拟节点)c[1][1] = 4(键值10)c[2][2] = 2(键值20)c[3][3] = 6(键值30)c[4][4] = 3(键值40)

递推求解:我们按子问题规模(j - i 的值)从小到大进行填充。



- 考虑两个键值 (
j - i = 1):c[0][1]:键值10。成本 = 频率4。c[1][2]:键值20。成本 = 频率2。c[2][3]:键值30。成本 = 频率6。c[3][4]:键值40。成本 = 频率3。

-
考虑三个键值 (
j - i = 2):
以c[0][2]为例,它包含键值10和20。可能的树有两种:以10为根或以20为根。- 若以10为根:左子树为空(
c[0][0]=0),右子树为20(c[1][2]=2)。总成本 =0 + 2 + (4+2) = 8。 - 若以20为根:左子树为10(
c[0][1]=4),右子树为空(c[2][2]=0)。总成本 =4 + 0 + (4+2) = 10。
最小成本为8,根节点为10。因此,c[0][2]=8,root[0][2]=1。
同理计算c[1][3](键值20,30)和c[2][4](键值30,40)。
- 若以10为根:左子树为空(
-
考虑四个键值 (
j - i = 3):
以c[0][3]为例,包含键值10,20,30。我们需要尝试每个键值作为根节点。- 以10为根:成本 =
c[0][0] + c[1][3] + (4+2+6) = 0 + 10 + 12 = 22 - 以20为根:成本 =
c[0][1] + c[2][3] + 12 = 4 + 6 + 12 = 22 - 以30为根:成本 =
c[0][2] + c[3][3] + 12 = 8 + 0 + 12 = 20
最小成本为20,根节点为30。因此,c[0][3]=20,root[0][3]=3。
同理计算c[1][4](键值20,30,40)。
- 以10为根:成本 =
-
考虑所有键值 (
j - i = 4):
计算c[0][4],包含所有四个键值。- 以10为根:成本 =
c[0][0] + c[1][4] + (4+2+6+3) = 0 + 16 + 15 = 31 - 以20为根:成本 =
c[0][1] + c[2][4] + 15 = 4 + 12 + 15 = 31 - 以30为根:成本 =
c[0][2] + c[3][4] + 15 = 8 + 3 + 15 = 26 - 以40为根:成本 =
c[0][3] + c[4][4] + 15 = 20 + 0 + 15 = 35
最小成本为26,根节点为30。因此,c[0][4]=26,root[0][4]=3。
- 以10为根:成本 =






构建最优树🌳
根据 root 表,我们可以重建最优二叉搜索树的结构。
- 整体树 (
root[0][4]) 的根是键值30。 - 30的左子树包含键值10,20 (
root[0][2]),其根是键值10。 - 10的右子树是键值20 (
root[1][2])。 - 30的右子树是键值40 (
root[3][4])。


因此,最终的最优二叉搜索树结构为:30为根,10为其左子节点,20为10的右子节点,40为30的右子节点。

核心公式📝



上述动态规划过程遵循以下递推公式:




对于 i 和 j(0 <= i < j <= n,其中n为键值数量),c[i][j] 表示键值 ki+1, ..., kj 构成的最优二叉搜索树的最小成本,w[i][j] 表示这些键值的频率之和。


递推公式为:
c[i][j] = min { c[i][k-1] + c[k][j] } + w[i][j] (对于所有 i < k <= j)
其中,k 是当前考虑的根节点的索引。c[i][k-1] 是左子树的最优成本,c[k][j] 是右子树的最优成本。w[i][j] 是当前子树中所有键值的频率总和,在公式中作为附加项,确保当子树深度增加时,其所有节点的比较次数都正确增加一次。




总结✨



本节课我们一起学*了最优二叉搜索树问题。我们从二叉搜索树的基本概念出发,理解了搜索成本与频率的关系。然后,我们深入探讨了如何使用动态规划方法来解决这个问题,通过填表的方式自底向上地计算了所有子问题的最优解,并最终构建出了总搜索成本最小的树形结构。关键点在于将问题分解为更小的子树问题,并利用递推公式 c[i][j] = min { c[i][k-1] + c[k][j] } + w[i][j] 来组合最优解。
057:最优二叉搜索树(含成功与失败概率)🎯

在本节课中,我们将要学*最优二叉搜索树。我们将从二叉搜索树的基本概念讲起,解释搜索成本的含义,并引入成功与失败搜索概率。接着,我们将探讨什么是最优二叉搜索树,并学*如何使用动态规划方法来构建它。我们将推导出动态规划的公式,并通过一个具体问题来演示如何应用该方法。最后,我们将看到如何利用动态规划的结果来构造出最优二叉搜索树。



二叉搜索树与搜索成本 🔍
上一节我们介绍了课程概述,本节中我们来看看什么是二叉搜索树以及如何进行搜索。
二叉搜索树是一种二叉树,其中每个节点包含一个键值。这些键值的排列遵循一个规则:对于任意节点,其左子树中的所有键值都小于该节点的键值,其右子树中的所有键值都大于该节点的键值。





这种结构为搜索提供了便利。例如,要搜索键值30,我们从根节点开始比较。如果30小于当前节点键值,则转向左子树;如果大于,则转向右子树。如此反复,直到找到目标或到达空节点。
搜索一个元素所需的比较次数取决于该元素在树中的层级。例如,如果根节点是第一层,那么搜索根节点只需一次比较,搜索第二层的节点需要两次比较,依此类推。树的最大层级(即高度)决定了最坏情况下的搜索比较次数。

搜索有两种可能的结果:
- 成功搜索:在树中找到了目标键值。
- 失败搜索:在树中未找到目标键值。


在二叉搜索树的可视化中,圆形节点代表实际的键值(成功搜索的目标),而方形节点(通常画得较小)代表“虚拟节点”或“外部节点”。这些方形节点不代表具体的键值,而是代表所有可能导致搜索失败的键值范围。例如,一个方形节点可能代表所有小于10的键值(即从负无穷到9),另一个可能代表所有在10到20之间的键值,等等。对于有 n 个键值的树,会有 n+1 个这样的虚拟节点。

搜索概率与成本计算 ⚖️



上一节我们了解了二叉搜索树的结构,本节中我们来看看如何计算搜索的成本,这涉及到搜索的概率。

在实际应用中(例如商店的商品检索),不同键值(商品)被搜索的频率是不同的。有些商品很热门,经常被查找;有些则很少被需要。同样,用户也可能搜索一些根本不存在的商品。因此,我们需要为每个键值分配一个成功搜索概率,并为每个键值区间(由虚拟节点代表)分配一个失败搜索概率。



假设我们有 n 个键值,记为 a1, a2, ..., an。它们的成功搜索概率分别为 p1, p2, ..., pn。同时,我们有 n+1 个失败搜索概率,记为 q0, q1, ..., qn,其中 qi 代表搜索键值落在区间 (ai, ai+1) 内的概率(a0 视为负无穷,an+1 视为正无穷)。所有概率之和应为1。

对于一个给定的二叉搜索树,其搜索成本的计算公式如下:
成本 = Σ (成功搜索概率 pi × 键值 ai 的层级) + Σ (失败搜索概率 qi × (对应虚拟节点的层级 - 1))

其中,键值的层级从根节点的1开始计算。对于虚拟节点,我们取其父节点的层级(即搜索终止时的比较次数)。
什么是最优二叉搜索树? 🌳
上一节我们学会了如何计算给定树的搜索成本,本节中我们来看看什么是最优二叉搜索树。






对于同一组键值,可以构造出许多不同的、都符合二叉搜索树定义的树。例如,对于3个键值,有5种可能的二叉搜索树;对于 n 个键值,可能的树的数量由卡特兰数给出:(2n)! / ((n+1)! * n!)。




这些不同的树具有不同的结构(高度不同、键值分布在不同层级),因此它们的搜索成本也不同。最优二叉搜索树问题就是:在给定所有键值的成功搜索概率 {p1...pn} 和失败搜索概率 {q0...qn} 的前提下,构造出一棵二叉搜索树,使得上述计算出的搜索成本最小。
一种朴素的方法是枚举所有可能的二叉搜索树,分别计算其成本,然后选择成本最小的那棵。但当键值数量 n 较大时,可能的树的数量是巨大的,这种方法完全不现实。因此,我们需要一种更聪明的方法——动态规划。


动态规划方法推导 🧠


上一节我们定义了最优二叉搜索树问题,本节中我们来看看如何使用动态规划来解决它。


动态规划的核心思想是将大问题分解为重叠的子问题,先解决子问题,并存储结果(通常用表格),然后利用子问题的解来构建原问题的解。
我们定义子问题:C[i, j] 表示由键值 a(i+1), a(i+2), ..., a(j) 构成的最优二叉搜索树的最小搜索成本。其中 0 <= i <= j <= n。注意,当 i = j 时,表示子树只包含虚拟节点(即没有实际键值),此时 C[i, i] = 0。

我们还定义 W[i, j] 为从 i 到 j 这个子问题中所有概率(包括成功和失败)的总和:
W[i, j] = q_i + Σ (p_k + q_k) 对于 k 从 i+1 到 j

W[i, j] 可以递归计算:W[i, j] = W[i, j-1] + p_j + q_j,且 W[i, i] = q_i。

现在,考虑如何构建 C[i, j]。我们需要从 a(i+1)...a(j) 中选择一个根节点 a_k。如果选择 a_k 作为根,那么:
- 左子树包含键值
a(i+1)...a(k-1),其最优成本为C[i, k-1]。 - 右子树包含键值
a(k+1)...a(j),其最优成本为C[k, j]。 - 根节点
a_k本身处于第一层,但当我们把左右子树连接到根节点时,左右子树中每个节点的深度都增加了1。这意味着整个子树的成本增加了左右子树中所有节点的概率之和,恰好就是W[i, j]。

因此,如果我们选择 a_k 作为根,那么以它为根的这棵子树的总成本为:C[i, k-1] + C[k, j] + W[i, j]。
为了得到 C[i, j] 的最小值,我们需要遍历所有可能的 k(从 i+1 到 j),选择使上述表达式最小的那个 k。由此得到动态规划递推公式:




C[i, j] = min { C[i, k-1] + C[k, j] } + W[i, j],其中 k 从 i+1 到 j 遍历








同时,我们用一个辅助表 R[i, j] 来记录取得最小值时所选的根节点 k,以便最后构造出最优树。


我们按子问题规模(即 j-i 的值)从小到大的顺序来计算 C[i, j] 和 R[i, j]。





实例求解 📝

上一节我们推导了动态规划的公式,本节中我们通过一个具体例子来应用它。



假设我们有4个键值:10, 20, 30, 40。它们的成功和失败搜索概率如下(为方便计算,已将概率乘以16化为整数):
- 成功概率 p: [3, 3, 1, 1]
- 失败概率 q: [2, 3, 1, 1, 1]
(验证:总和 = 3+3+1+1 + 2+3+1+1+1 = 16)


我们需要计算 C[0, 4](即包含所有键值的最优成本)和 R[0, 4](即整棵树的根节点)。

以下是计算过程表格。我们按 j-i 的差值(子问题规模)递增的顺序填充。



1. 初始化 (j-i = 0)
当 i=j 时,树为空(只有虚拟节点)。
W[i,i] = q_iC[i,i] = 0R[i,i] = 0(无根)
| i | j | W | C | R |
|---|---|---|---|---|
| 0 | 0 | 2 | 0 | 0 |
| 1 | 1 | 3 | 0 | 0 |
| 2 | 2 | 1 | 0 | 0 |
| 3 | 3 | 1 | 0 | 0 |
| 4 | 4 | 1 | 0 | 0 |
2. 计算规模为1的子问题 (j-i = 1)
例如 C[0,1]:可能的根 k=1。
C[0,1] = min{ C[0,0] + C[1,1] } + W[0,1] = min{0+0} + (W[0,0]+p1+q1)= 0 + (2+3+3)= 8,R[0,1]=1。
W[i,j] 可用递推公式快速计算:W[0,1]=W[0,0]+p1+q1=2+3+3=8。
| i | j | W | C | R |
|---|---|---|---|---|
| 0 | 1 | 8 | 8 | 1 |
| 1 | 2 | 7 | 7 | 2 |
| 2 | 3 | 3 | 3 | 3 |
| 3 | 4 | 3 | 3 | 4 |
3. 计算规模为2的子问题 (j-i = 2)
例如 C[0,2]:可能的根 k=1, 2。
k=1:C[0,0] + C[1,2] = 0 + 7 = 7k=2:C[0,1] + C[2,2] = 8 + 0 = 8
最小值是7,对应k=1。
C[0,2] = 7 + W[0,2]。W[0,2]=W[0,1]+p2+q2=8+3+1=12。
所以C[0,2]=7+12=19,R[0,2]=1。
| i | j | W | C | R |
|---|---|---|---|---|
| 0 | 2 | 12 | 19 | 1 |
| 1 | 3 | 9 | 12 | 2 |
| 2 | 4 | 5 | 8 | 3 |
4. 计算规模为3的子问题 (j-i = 3)
例如 C[0,3]:可能的根 k=1, 2, 3。
k=1:C[0,0] + C[1,3] = 0 + 12 = 12k=2:C[0,1] + C[2,3] = 8 + 3 = 11k=3:C[0,2] + C[3,3] = 19 + 0 = 19
最小值是11,对应k=2。
W[0,3]=W[0,2]+p3+q3=12+1+1=14。
C[0,3]=11+14=25,R[0,3]=2。
| i | j | W | C | R |
|---|---|---|---|---|
| 0 | 3 | 14 | 25 | 2 |
| 1 | 4 | 11 | 19 | 2 |
5. 计算最终规模为4的子问题 (j-i = 4)
C[0,4]:可能的根 k=1, 2, 3, 4。
k=1:C[0,0] + C[1,4] = 0 + 19 = 19k=2:C[0,1] + C[2,4] = 8 + 8 = 16k=3:C[0,2] + C[3,4] = 19 + 3 = 22k=4:C[0,3] + C[4,4] = 25 + 0 = 25
最小值是16,对应k=2。
W[0,4]=W[0,3]+p4+q4=14+1+1=16。
C[0,4]=16+16=32,R[0,4]=2。

| i | j | W | C | R |
|---|---|---|---|---|
| 0 | 4 | 16 | 32 | 2 |
最终,我们得到最小搜索成本 C[0,4] = 32。由于我们的概率和是16,实际平均成本为 32/16 = 2。同时,R[0,4]=2 告诉我们,整棵最优二叉搜索树的根节点是第二个键值,即 20。
构造最优二叉搜索树 🛠️
上一节我们通过动态规划求出了最小成本和根节点,本节中我们利用 R[][] 表来构造出这棵最优树。
我们从最大的子问题 [0,4] 开始,其根节点 R[0,4]=2(键值20)。
- 根节点20的左子树对应子问题
[0,1](键值10)。查表得R[0,1]=1,所以左子树的根是键值10。 - 根节点20的右子树对应子问题
[2,4](键值30,40)。查表得R[2,4]=3,所以右子树的根是第三个键值30。 - 对于右子树根30,其左子树对应
[2,2](为空),右子树对应[3,4]。查表R[3,4]=4,所以30的右孩子是第四个键值40。
由此,我们构造出的最优二叉搜索树结构如下:
20
/ \
10 30
\
40
(注:为简洁,图中未画出所有虚拟节点,但根据定义它们存在于每个空指针的位置)。
这棵树就是在给定成功与失败搜索概率下,使得期望搜索成本最小的二叉搜索树。
总结 📚
本节课中我们一起学*了最优二叉搜索树的动态规划解法。
我们首先回顾了二叉搜索树的基本概念和搜索成本的计算方法,引入了成功与失败搜索概率。然后,我们定义了最优二叉搜索树问题:给定概率,构造成本最小的树。
接着,我们重点学*了动态规划的解决思路:
- 定义子问题
C[i,j]和概率和W[i,j]。 - 推导出关键递推公式:
C[i,j] = min_{i<k<=j}{ C[i,k-1] + C[k,j] } + W[i,j]。 - 通过一个实例,演示了如何自底向上地填充动态规划表格,计算最小成本和记录根节点选择。
- 最后,利用记录的根节点信息,递归地构造出最优二叉搜索树的结构。
这种方法避免了枚举所有可能的树,通过子问题重叠和最优子结构的特性,高效地找到了全局最优解,是动态规划解决复杂优化问题的经典案例。
058:旅行商问题 - 动态规划公式法 🧳






在本节课中,我们将学*如何使用动态规划公式来解决旅行商问题。我们将通过一个具体的例子,逐步解析公式的含义和应用过程,最终计算出访问所有城市并返回起点的最短路径成本。



旅行商问题描述如下:给定一个有向加权图,我们需要从某个顶点出发,访问所有其他顶点恰好一次,最后返回起点,并且要求总旅行成本最小。



问题定义与公式引入


上一节我们介绍了问题的基本描述。本节中,我们来看看解决该问题的核心动态规划公式。
对于一个有n个顶点的图,设起点为1。定义函数 g(i, S) 表示从顶点 i 出发,访问集合 S 中所有顶点恰好一次,最后回到起点1的最小成本。其中 S 是尚未访问的顶点集合。


核心递推公式如下:

g(i, S) = min{ C[i][j] + g(j, S - {j}) },其中 j 属于集合 S。

这个公式的含义是:从当前顶点 i 出发,要访问集合 S 中的所有顶点。我们可以选择先走到 S 中的任意一个顶点 j,成本是 C[i][j],然后问题就转化为从 j 出发,访问剩余顶点 S - {j} 的子问题。





公式应用与递归树展开

理解了公式后,我们将其应用到一个具体的4顶点图例上。该图的成本邻接矩阵如下:



| 1 | 2 | 3 | 4 | |
|---|---|---|---|---|
| 1 | 0 | 10 | 15 | 20 |
| 2 | 5 | 0 | 9 | 10 |
| 3 | 6 | 13 | 0 | 12 |
| 4 | 8 | 8 | 9 | 0 |


我们的目标是计算 g(1, {2,3,4}),即从顶点1出发,访问顶点2、3、4后回到1的最小成本。



根据公式,我们展开第一层递归:







g(1, {2,3,4}) = min { C[1][2] + g(2, {3,4}), C[1][3] + g(3, {2,4}), C[1][4] + g(4, {2,3}) }

代入已知的边成本:C[1][2]=10, C[1][3]=15, C[1][4]=20。公式变为:






g(1, {2,3,4}) = min { 10 + g(2, {3,4}), 15 + g(3, {2,4}), 20 + g(4, {2,3}) }


为了求出最小值,我们需要继续计算 g(2, {3,4})、g(3, {2,4}) 和 g(4, {2,3}) 的值。这构成了一个递归树。




自底向上的动态规划表解法




理论上,我们可以通过递归树求解,但这样会产生大量重复计算,效率低下。动态规划的精髓在于自底向上填表,避免重复。



我们从小规模子问题开始计算,逐步构建出大规模问题的解。以下是计算步骤:


步骤1:计算基础情况
当集合 S 为空时,意味着所有顶点都已访问,只需从当前顶点 i 直接返回起点1。
以下是基础情况的值:
g(2, {}) = C[2][1] = 5g(3, {}) = C[3][1] = 6g(4, {}) = C[4][1] = 8





步骤2:计算集合 S 包含1个顶点的情况
现在计算剩余一个顶点需要访问时的最小成本。
以下是计算公式和结果:
g(2, {3}) = C[2][3] + g(3, {}) = 9 + 6 = 15g(2, {4}) = C[2][4] + g(4, {}) = 10 + 8 = 18g(3, {2}) = C[3][2] + g(2, {}) = 13 + 5 = 18g(3, {4}) = C[3][4] + g(4, {}) = 12 + 8 = 20g(4, {2}) = C[4][2] + g(2, {}) = 8 + 5 = 13g(4, {3}) = C[4][3] + g(3, {}) = 9 + 6 = 15


步骤3:计算集合 S 包含2个顶点的情况
接着计算剩余两个顶点需要访问时的最小成本。
以下是计算公式和结果:
g(2, {3,4}) = min { C[2][3] + g(3,{4}), C[2][4] + g(4,{3}) } = min { 9+20, 10+15 } = min {29, 25} = 25g(3, {2,4}) = min { C[3][2] + g(2,{4}), C[3][4] + g(4,{2}) } = min { 13+18, 12+13 } = min {31, 25} = 25g(4, {2,3}) = min { C[4][2] + g(2,{3}), C[4][3] + g(3,{2}) } = min { 8+15, 9+18 } = min {23, 27} = 23


步骤4:计算最终答案
最后,我们回到最初的问题,计算从顶点1出发访问所有顶点的最小成本。
g(1, {2,3,4}) = min { 10 + g(2,{3,4}), 15 + g(3,{2,4}), 20 + g(4,{2,3}) } = min { 10+25, 15+25, 20+23 } = min {35, 40, 43} = 35




总结





本节课中我们一起学*了如何使用动态规划公式解决旅行商问题。我们首先理解了定义状态 g(i, S) 的核心递推公式,然后通过一个4顶点的具体例子,演示了如何自底向上地填充动态规划表,从最简单的基础情况(集合S为空)开始,逐步计算出包含更多顶点的子问题解,最终得到了从起点出发访问所有顶点的最小成本为 35。这种方法避免了递归带来的重复计算,是解决此类组合优化问题的有效策略。
059:可靠性设计问题(动态规划)🔧

在本节课中,我们将学*一个经典的动态规划问题——可靠性设计。我们将理解问题的定义,并通过一个具体的例子,一步步学*如何使用类似0/1背包问题的集合方法来解决它。



问题概述 📋


可靠性设计问题的核心是:我们需要用多个设备(例如摄像头、灯光、白板笔)来搭建一个系统。每个设备都有其自身的成本和可靠性(即正常工作的概率)。系统由这些设备串联而成,因此整个系统的可靠性是各个设备可靠性的乘积。
如果每个设备的可靠性不高,整个系统的可靠性会非常低。为了提高系统可靠性,我们可以为每个设备购买多个副本,并将这些副本并联起来作为一个“阶段”。这样,只要该阶段中至少有一个副本正常工作,该阶段就能正常工作。



我们的目标是:在给定的总预算 C 内,决定为每个设备购买多少个副本,使得整个系统的可靠性最大化。



核心概念与公式 📝

在深入解题前,我们需要明确几个核心计算:




- 单个设备的可靠性:记为 r。
- 一个阶段(包含m个并联副本)的可靠性:如果单个设备的可靠性是 r,那么使用 m 个并联副本后,该阶段的可靠性 R 计算公式为:
R = 1 - (1 - r)^m
这个公式的意思是:1减去“所有m个副本都失效”的概率。 - 整个系统的可靠性:系统由n个阶段(设备)串联而成,若第i个阶段的可靠性为 R_i,则系统总可靠性 R_total 为:
R_total = R_1 * R_2 * ... * R_n - 成本计算:若第i个设备的单价为 cost_i,购买了 m_i 个,则在该设备上的总花费为
m_i * cost_i。系统总成本为所有设备花费之和。




问题示例与求解步骤 🧮
上一节我们介绍了问题的定义和核心公式,本节我们来看一个具体的例子,并学*如何分步求解。


假设我们有3个设备(D1, D2, D3),其成本和可靠性如下:
- D1: 成本 = $30, 可靠性 = 0.9
- D2: 成本 = $15, 可靠性 = 0.8
- D3: 成本 = $20, 可靠性 = 0.5
我们的总预算 C = $105。



步骤1:计算每个设备可购买副本数的上限





首先,系统必须至少包含每个设备的一个副本。这部分基础成本为:30 + 15 + 20 = $65。
剩余预算为:105 - 65 = $40。
在剩余的$40中,我们可以决定为哪个设备购买额外的副本。对于每个设备i,我们计算在花光所有剩余预算的情况下,最多能买多少个额外副本(包括必须买的那一个):
上限 m_i = floor(剩余预算 / cost_i) + 1




以下是每个设备的上限计算:
- D1: floor(40 / 30) + 1 = 1 + 1 = 2 (即可买1或2个)
- D2: floor(40 / 15) + 1 = 2 + 1 = 3 (即可买1,2或3个)
- D3: floor(40 / 20) + 1 = 2 + 1 = 3 (即可买1,2或3个)

这意味着我们的搜索空间是:为D1选择1或2个副本,为D2选择1、2或3个副本,为D3选择1、2或3个副本,同时满足总成本 ≤ $105。

步骤2:使用动态规划集合方法求解
我们将使用类似0/1背包问题的“有序对集合”方法。每个有序对 (可靠性, 成本) 代表达到当前状态的一种方案。


初始化集合 S0
我们从空系统开始,可靠性为1(乘法的单位元),成本为0。
S0 = { (1, 0) }




考虑第一个设备 (D1)
我们基于S0生成新的集合S1,代表考虑了D1后的所有可能方案。
- 购买1个D1: 阶段可靠性 = 0.9, 成本 = 30。
与S0中的(1,0)组合:新可靠性 = 1 * 0.9 = 0.9, 新成本 = 0 + 30 = 30。得到(0.9, 30)。 - 购买2个D1: 阶段可靠性 = 1 - (1-0.9)^2 = 0.99, 成本 = 2*30=60。
与S0中的(1,0)组合:新可靠性 = 1 * 0.99 = 0.99, 新成本 = 0 + 60 = 60。得到(0.99, 60)。


合并并去除明显劣解(支配规则:成本更高但可靠性更低或相等的方案被剔除)。目前两个方案成本和可靠性都递增,保留。
S1 = { (0.9, 30), (0.99, 60) }

考虑第二个设备 (D2)
我们基于S1生成新的集合S2,代表考虑了D1和D2后的所有可能方案。我们需要将D2的每种购买选择(1,2,3个)与S1中的每个有序对进行组合。


首先计算D2在不同副本数下的阶段可靠性:
- 1个副本: R = 0.8, 成本 = 15
- 2个副本: R = 1 - (1-0.8)^2 = 0.96, 成本 = 30
- 3个副本: R = 1 - (1-0.8)^3 = 0.992, 成本 = 45




然后进行组合计算。注意:在组合时,我们先检查成本是否超预算($105),若超支则无需计算可靠性。


以下是组合S1与D2各选项后得到的有序对:
- (0.9,30) + 1个D2 -> (0.9*0.8=0.72, 30+15=45)
- (0.9,30) + 2个D2 -> (0.9*0.96=0.864, 30+30=60)
- (0.9,30) + 3个D2 -> (0.9*0.992=0.8928, 30+45=75)
- (0.99,60) + 1个D2 -> (0.99*0.8=0.792, 60+15=75)
- (0.99,60) + 2个D2 -> (0.99*0.96=0.9504, 60+30=90) -> 成本90,剩余15<20,无法购买后续D3,剔除
- (0.99,60) + 3个D2 -> (0.99*0.992≈0.982, 60+45=105) -> 成本已用尽,但还需购买D3,剔除


得到初步集合: { (0.72,45), (0.864,60), (0.8928,75), (0.792,75) }
应用支配规则:比较(0.792,75)和(0.8928,75),两者成本相同,但后者可靠性更高,因此剔除(0.792,75)。
最终 S2 = { (0.72,45), (0.864,60), (0.8928,75) }

考虑第三个设备 (D3)
我们基于S2生成最终集合S3。首先计算D3在不同副本数下的阶段可靠性:
- 1个副本: R = 0.5, 成本 = 20
- 2个副本: R = 1 - (1-0.5)^2 = 0.75, 成本 = 40
- 3个副本: R = 1 - (1-0.5)^3 = 0.875, 成本 = 60
将S2中的每个有序对与D3的选项组合,并剔除成本超支的方案:
- (0.72,45) + 1个D3 -> (0.72*0.5=0.36, 45+20=65)
- (0.72,45) + 2个D3 -> (0.72*0.75=0.54, 45+40=85)
- (0.72,45) + 3个D3 -> (0.72*0.875=0.63, 45+60=105)
- (0.864,60) + 1个D3 -> (0.864*0.5=0.432, 60+20=80)
- (0.864,60) + 2个D3 -> (0.864*0.75=0.648, 60+40=100)
- (0.864,60) + 3个D3 -> (0.864*0.875=0.756, 60+60=120) -> 超支,剔除
- (0.8928,75) + 1个D3 -> (0.8928*0.5=0.4464, 75+20=95)
- (0.8928,75) + 2个D3 -> (0.8928*0.75=0.6696, 75+40=115) -> 超支,剔除
- (0.8928,75) + 3个D3 -> 超支,剔除



得到集合: { (0.36,65), (0.54,85), (0.63,105), (0.432,80), (0.648,100), (0.4464,95) }
应用支配规则进行筛选:
- 按成本排序并比较相邻项,保留可靠性随成本递增的方案。
- 例如,(0.54,85)和(0.63,105)是合理的。
- 若出现成本增加但可靠性反而下降的项,则剔除。


最终,在预算$105内,可靠性最高的有序对是 (0.648, 100)。




步骤3:回溯确定设备购买数量


最优解 (0.648, 100) 来自 S2 中的 (0.864, 60) 加上 2个D3副本。
(0.864, 60)来自 S1 中的(0.9, 30)加上 2个D2副本。(0.9, 30)来自 S0 中的(1, 0)加上 1个D1副本。
因此,最优购买策略是:
- D1:购买 1 个副本
- D2:购买 2 个副本
- D3:购买 2 个副本
总成本 = 130 + 215 + 2*20 = $100。
系统最大可靠性 = 0.648。



总结 🎯
本节课中,我们一起学*了动态规划中的可靠性设计问题。

- 问题核心:在有限预算下,通过为串联系统中的设备购买并联副本,最大化系统整体可靠性。
- 关键公式:
- 并联m个副本的阶段可靠性:
R = 1 - (1 - r)^m - 串联系统总可靠性:各阶段可靠性相乘。
- 并联m个副本的阶段可靠性:
- 求解方法:采用类似0/1背包问题的有序对集合法,逐步添加设备,记录所有可能的(可靠性,成本)状态,并利用支配规则和预算约束剪枝,最终找到最优解。
- 最终输出:不仅得到最大可靠性值,还能通过回溯确定每个设备的具体购买数量。

这个方法虽然计算步骤较多,但通过系统地生成和筛选状态,能确保我们找到全局最优解。
060:最长公共子序列 (LCS) - 递归与动态编程 🧬





在本节课中,我们将要学*最长公共子序列问题,简称 LCS。我们将从理解问题定义开始,然后使用递归方法解决它。接着,我们会看到递归方法效率较低,因此引入记忆化技术来优化。最后,我们将学*使用动态规划这一更高效、更标准的方法来解决 LCS 问题。
问题定义与理解 🔍

LCS 问题是指:给定两个字符串,我们需要找出它们共有的、最长的子序列。这里的“子序列”意味着字符不必在原始字符串中连续出现,但必须保持原有的先后顺序。


例如,对于字符串 "ABCDGH" 和 "AEDFHR",一个公共子序列是 "ADH"。但最长的公共子序列是 "ADH" 吗?我们需要通过算法来寻找。






理解的关键点:
- 我们有两个独立的字符串,并非在一个字符串中搜索另一个。
- 目标是找到一组在两个字符串中都出现的字符,且这组字符在两个字符串中的顺序一致。
- 可能存在多个长度相同的最长公共子序列。

递归解法 📉





上一节我们理解了问题,本节中我们来看看如何使用递归思想来解决它。递归的核心思路是将大问题分解为更小的子问题。
递归算法的伪代码如下:
function LCS(i, j):
if i == 0 or j == 0:
return 0
else if string1[i] == string2[j]:
return 1 + LCS(i-1, j-1)
else:
return max(LCS(i-1, j), LCS(i, j-1))
参数说明:
i和j分别是两个字符串当前比较位置的索引。- 如果任一索引为0(通常代表字符串起始前),则公共长度为0。
- 如果当前字符匹配,则长度加1,并同时向前移动两个字符串的索引(
i-1, j-1)。 - 如果当前字符不匹配,则有两种选择:移动第一个字符串的索引(
i-1, j)或移动第二个字符串的索引(i, j-1),取两者结果的最大值。



我们通过一个简单例子 ("BD", "ABC") 来手动模拟递归树。你会发现,即使对于很小的输入,递归调用也会产生大量重复计算(重叠子问题),导致时间复杂度是指数级的 O(2^(m+n))。


记忆化优化 ⚡
上一节我们介绍了朴素的递归方法,它存在大量重复计算。本节中我们来看看如何通过记忆化来优化它。


记忆化的思想很简单:用一个表格(通常是二维数组)来存储已经计算过的子问题的结果。在递归函数开始时,先检查表格中是否已有结果,如果有则直接返回,避免重复计算;如果没有,则进行计算,并将结果存入表格后再返回。
以下是记忆化优化的关键步骤:
- 创建一个大小为
(m+1) x (n+1)的表格memo,初始值设为-1(表示未计算)。 - 修改递归函数,在计算
LCS(i, j)前,先检查memo[i][j]。 - 如果
memo[i][j] != -1,直接返回该值。 - 否则,正常进行计算,并将结果存入
memo[i][j]后再返回。





通过记忆化,我们确保了每个子问题(对应表格中的一个单元格)只被计算一次。因此,时间复杂度从指数级降低到了多项式级 O(m * n),其中 m 和 n 是两个字符串的长度。空间复杂度也是 O(m * n),用于存储记忆表格。




动态规划解法 🚀




上一节我们通过记忆化优化了递归,这本质上是一种“自顶向下”的带备忘的解法。本节中我们来看看更经典的“自底向上”的动态规划解法。
动态规划直接使用迭代的方式填充一个二维表格 dp,从而避免递归调用开销。dp[i][j] 的定义与递归函数相同:表示字符串 A[0..i-1] 和 B[0..j-1] 的 LCS 长度。


动态规划的状态转移方程如下:
如果 i == 0 或 j == 0:
dp[i][j] = 0
否则如果 A[i-1] == B[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
否则:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])


以下是填充动态规划表的步骤:
- 初始化一个
(m+1) x (n+1)的表格dp,并将第一行和第一列全部设为0。 - 从
i = 1到m,j = 1到n遍历表格:- 如果当前字符相等(
A[i-1] == B[j-1]),则dp[i][j] = dp[i-1][j-1] + 1。 - 否则,
dp[i][j] = max(dp[i-1][j], dp[i][j-1])。
- 如果当前字符相等(
- 表格右下角
dp[m][n]的值即为 LCS 的长度。


要重构出 LCS 本身,可以从 dp[m][n] 开始反向追踪:
- 如果
A[i-1] == B[j-1],则该字符属于 LCS,将其加入结果,并移动到dp[i-1][j-1]。 - 否则,比较
dp[i-1][j]和dp[i][j-1],向值较大的方向移动(如果相等,任选一个方向,通常选择向上)。 - 重复此过程直到
i或j为 0。


动态规划解法的时间复杂度和空间复杂度均为 O(m * n)。空间复杂度可以通过使用滚动数组优化到 O(min(m, n))。

总结 📚
本节课中我们一起学*了最长公共子序列问题。
- 我们首先理解了 LCS 问题的定义:寻找两个序列中顺序相同的最长子序列。
- 然后,我们探索了递归解法,虽然直观但效率低下,时间复杂度为指数级。
- 接着,我们引入了记忆化技术,通过存储子问题解来优化递归,将复杂度降至
O(m*n)。 - 最后,我们学*了标准的动态规划解法,它采用自底向上的迭代方式填充表格,同样具有
O(m*n)的复杂度,并且是解决此类问题最常用的方法。





动态规划是解决许多优化问题的强大工具,LCS 是理解其思想的经典入门案例。
061:图的遍历 - 广度优先搜索与深度优先搜索 🧭

在本节课中,我们将要学*图的两种基本遍历方法:广度优先搜索和深度优先搜索。我们将通过多个例子来理解它们的工作原理、区别以及实现步骤。
概述

图遍历是指系统地访问图中的每一个顶点。广度优先搜索 和 深度优先搜索 是两种最核心的遍历策略。理解它们的关键在于区分“访问顶点”和“探索顶点”这两个概念:访问意味着到达该顶点;探索意味着访问该顶点的所有相邻顶点。



上一节我们介绍了图遍历的基本概念,本节中我们来看看这两种方法的具体过程。

核心概念与快速比较
为了快速理解两者的区别,我们首先观察一个简单的图(实际上是一棵树)。

广度优先搜索示例
- 选择顶点1作为起始顶点。
- 访问 顶点1。
- 开始探索顶点1,即访问其所有相邻顶点(2和4)。访问顺序可以是任意的,例如先2后4。
- 接着,选择下一个待探索的顶点。在已访问的顶点(1, 2, 4)中,按照访问顺序,选择顶点2进行探索。
- 探索顶点2,访问其所有未访问的相邻顶点(3, 6, 7)。
- 以此类推,直到所有顶点都被访问。



遍历顺序可能是:1 -> 2 -> 4 -> 3 -> 6 -> 7。





深度优先搜索示例

- 同样从顶点1开始,访问顶点1。
- 开始探索顶点1,首先访问相邻顶点2。
- 关键规则:一旦访问了一个新顶点(2),就暂停当前顶点(1)的探索,立即开始探索这个新顶点(2)。
- 探索顶点2,访问其一个相邻顶点3。
- 再次应用规则:暂停顶点2的探索,开始探索新顶点3。
- 顶点3没有未访问的相邻顶点,因此顶点3探索完成。回溯到上一个顶点(2),继续探索它的其他相邻顶点(6, 7)。
- 此过程持续进行,直到所有顶点都被探索。

遍历顺序可能是:1 -> 2 -> 3 -> 6 -> 7 -> 4。





核心区别

- 广度优先搜索:探索一个顶点时,会先访问其所有相邻顶点,然后再选择下一个顶点进行探索。这类似于树的层序遍历。
- 深度优先搜索:探索一个顶点时,访问一个相邻顶点后,会立即深入探索这个新顶点,直到无路可走再回溯。这类似于树的先序遍历。

下面,我们通过一个更复杂的图来详细学*这两种算法的正式步骤。
广度优先搜索详解 🟦


我们将使用一个队列来辅助实现BFS。




算法步骤


以下是BFS的步骤分解:


- 初始化:选择任意一个顶点作为起点(例如顶点1)。将其标记为已访问,加入结果列表,并入队。
Q = [1],Result = [1]




- 重复以下步骤,直到队列为空:
a. 出队一个顶点(记为current)。
b. 探索顶点current:遍历它的每一个相邻顶点。
c. 对于每个相邻顶点,如果它未被访问过,则:
* 标记为已访问。
* 加入结果列表。
* 入队。

分步演示
假设我们从顶点1开始。
- 初始:
Q = [1],Result = [1] - 迭代1:出队
1。探索1,相邻顶点为4和2。两者均未访问,按顺序访问4,然后2。将4和2入队。
Q = [4, 2],Result = [1, 4, 2]
![]()
- 迭代2:出队
4。探索4,相邻顶点为3。访问3并入队。
Q = [2, 3],Result = [1, 4, 2, 3]
![]()
- 迭代3:出队
2。探索2,相邻顶点为3, 5, 7, 8。3已访问,依次访问5, 8, 7并入队。
Q = [3, 5, 8, 7],Result = [1, 4, 2, 3, 5, 8, 7]
![]()
![]()
- 迭代4:出队
3。探索3,相邻顶点为2, 8, 9, 10。2和8已访问,访问10和9并入队。
Q = [5, 8, 7, 10, 9],Result = [1, 4, 2, 3, 5, 8, 7, 10, 9]
![]()
- 迭代5:出队
5。探索5,相邻顶点为6, 7, 8。7和8已访问,访问6并入队。
Q = [8, 7, 10, 9, 6],Result = [1, 4, 2, 3, 5, 8, 7, 10, 9, 6]
![]()
- 后续迭代中,出队8, 7, 10, 9, 6,但它们的相邻顶点均已访问,不再有新顶点入队。队列最终为空,算法结束。


最终遍历顺序为:1, 4, 2, 3, 5, 8, 7, 10, 9, 6。



BFS要点总结

- 起点自由:可以从任何顶点开始。
- 访问顺序自由:探索一个顶点时,其相邻顶点的访问顺序可以任意。
- 核心规则:必须完全探索完一个顶点(访问其所有相邻顶点)后,才能从队列中取出下一个顶点进行探索。
- 数据结构:使用队列管理待探索的顶点。
- 生成树:BFS遍历过程中经过的边构成一棵BFS生成树,未包含的边称为交叉边。
- 多解性:由于起点和访问顺序的自由度,一个图可以有多个有效的BFS序列。
- 例如:
1, 2, 4, 8, 5, 7, 3, 6, 10, 9 - 例如:
5, 8, 7, 6, 2, 3, 1, 4, 10, 9
![]()
![]()
- 例如:
深度优先搜索详解 🟩
我们将使用一个栈来辅助实现DFS(递归本质也是栈)。


算法步骤

以下是DFS的步骤分解:

-
初始化:选择任意一个顶点作为起点(例如顶点1)。将其标记为已访问,加入结果列表,并压栈。
Stack = [1],Result = [1] -
重复以下步骤,直到栈为空:
a. 查看栈顶顶点(记为current),但不弹出。
b. 从current的相邻顶点中,找到一个未被访问的顶点(记为next)。
c. 如果找到next:
* 标记next为已访问,加入结果列表。
* 将next压栈。(此时next成为新的current,算法将在下一步探索它)。
d. 如果没找到(即current的所有相邻顶点都已访问):
* 将current弹出栈。算法将回溯到上一个顶点继续探索。
分步演示(思路)


假设我们从顶点1开始。
- 访问1,压栈。
Stack = [1],Result = [1] - 栈顶是1。找到未访问的相邻顶点4。访问4,压栈。
Stack = [1, 4],Result = [1, 4] - 栈顶是4。找到未访问的相邻顶点3。访问3,压栈。
Stack = [1, 4, 3],Result = [1, 4, 3] - 栈顶是3。找到未访问的相邻顶点10。访问10,压栈。
Stack = [1, 4, 3, 10],Result = [1, 4, 3, 10] - 栈顶是10。没有未访问的相邻顶点。弹出10。
Stack = [1, 4, 3] - 栈顶回到3。找到下一个未访问的相邻顶点9。访问9,压栈... 此过程持续进行,不断深入,直到无法继续则回溯。



最终,一个可能的DFS遍历顺序为:1, 4, 3, 10, 9, 2, 8, 7, 5, 6。




DFS要点总结



- 起点自由:可以从任何顶点开始。
- 深入优先:一旦访问一个新顶点,就立即暂停当前路径,转而深入探索这个新顶点。
- 核心规则:使用栈来记录访问路径,以便回溯。
- 生成树:DFS遍历过程中经过的边构成一棵DFS生成树,未包含的边称为回边。
- 多解性:由于起点和每次选择下一个顶点的自由度,一个图可以有多个有效的DFS序列。
- 例如:
1, 2, 8, 7, 5, 6, 3, 9, 10, 4 - 例如:
3, 4, 1, 2, 5, 6, 7, 8, 10, 9
![]()
![]()
- 例如:

总结

本节课中我们一起学*了图的两种基础遍历算法:
- 广度优先搜索:采用“广撒网”的策略,使用队列,按层次遍历顶点。其时间复杂度为 O(V)。
- 深度优先搜索:采用“一条路走到底”的策略,使用栈(或递归),沿着分支深入探索。其时间复杂度也为 O(V)。
两者都是系统访问图中所有顶点的方法,BFS常用于寻找最短路径,DFS则更适合拓扑排序、检测环等场景。理解它们的工作机制是学*更复杂图算法的重要基础。


062:关节点与双连通分量 🔗


在本节课中,我们将学*图论中的两个重要概念:关节点和双连通分量。我们将了解什么是关节点,为什么它在网络设计中不受欢迎,以及如何通过算法找到并消除图中的关节点。
什么是关节点?🎯

在一个图中,如果移除某个顶点会导致图被分割成多个互不连通的组件,那么这个顶点就被称为关节点。

例如,在下图中,顶点3就是一个关节点。如果移除顶点3,图会被分割成两个部分:顶点1、4、2构成一个组件,顶点5、6构成另一个组件。


如果图代表一个网络(如道路网或计算机网络),我们通常不希望存在关节点。因为如果关节点出现问题,整个网络就会分裂成多个部分,影响连通性。




理想情况下,即使网络中某些部分发生故障,网络也应尽可能保持为一个完整的连通组件。因此,我们需要识别并处理关节点。
如何消除关节点?🔧
解决关节点问题的一个直接方法是:在由关节点连接的不同组件之间添加一条边。
以上图为例,如果在顶点4和顶点5之间添加一条边,那么即使移除顶点3,顶点5和6仍然可以通过顶点4与图的其余部分保持连通。这样,顶点3就不再是关节点了。


因此,消除关节点的核心思路是:找到关节点,然后在它连接的不同组件之间添加额外的边。
寻找关节点的算法 🧠
仅仅通过观察来识别关节点对于小图是可行的,但我们需要一个系统性的算法让计算机也能完成这个任务。以下是寻找关节点的算法步骤。
上一节我们介绍了关节点的概念和消除思路,本节中我们来看看如何通过算法自动找到它们。
第一步:生成深度优先搜索生成树

首先,为给定的图生成一棵深度优先搜索生成树。我们从一个起始顶点(例如顶点1)开始进行深度优先遍历,只记录遍历过程中首次访问边所构成的树形结构,忽略那些指向已访问顶点的边(回边)。


以下是从顶点1开始进行DFS可能得到的一棵生成树:
- 从1访问4。
- 从4访问3。
- 从3访问5。
- 从5访问6。
- 从6有一条边指向3(但3已被访问,这是一条回边,不计入树中)。
- 回溯到3,访问2。
- 从2有一条边指向1(回边,不计入树中)。
最终得到的DFS生成树如下图所示:



第二步:记录发现时间


为DFS生成树中的每个顶点标记一个发现时间,也称为DFS编号。这个编号就是顶点在深度优先遍历中被访问的顺序。


以下是各顶点的发现时间:
d[1] = 1d[4] = 2d[3] = 3d[5] = 4d[6] = 5d[2] = 6



第三步:计算最低可到达编号


对于每个顶点 v,计算 low[v]。low[v] 表示从顶点 v 出发,通过树边向下走,然后最多使用一条回边向上,所能到达的顶点的最小发现时间。
以下是计算每个顶点 low 值的过程:
- low[1]:从1出发,最低能回到自己,所以
low[1] = 1。 - low[2]:从2出发,可以通过回边(2,1)直接到达1,所以
low[2] = 1。 - low[3]:从3出发,可以走到2再通过回边到1,所以
low[3] = 1。 - low[4]:从4出发,可以走到3->2->1,所以
low[4] = 1。 - low[5]:从5出发,可以走到6,然后通过回边(6,3)到达3,所以
low[5] = d[3] = 3。 - low[6]:从6出发,可以通过回边(6,3)直接到达3,所以
low[6] = 3。


计算结果总结如下:

第四步:应用关节点判定条件
现在,我们可以利用发现时间 d 和最低可到达编号 low 来判断关节点。判定规则针对DFS生成树中的非根节点:

对于树中的一条边 (u, v),其中 u 是 v 的父节点。如果满足以下条件,则 u 是一个关节点:
low[v] >= d[u]

这个条件的含义是:顶点 v 及其后代无法通过一条回边绕过 u 到达 u 的祖先。因此,如果移除 u,v 所在的子树就会与图的其余部分断开。
让我们应用这个规则检查图中的顶点:
- 检查边
(4, 3):low[3] = 1,d[4] = 2。1 >= 2为假,所以4不是关节点。 - 检查边
(3, 5):low[5] = 3,d[3] = 3。3 >= 3为真,所以顶点3是关节点。


这与我们之前直观观察的结果一致。
第五步:根节点的特殊处理
上述规则不适用于DFS树的根节点。判断根节点是否为关节点有一个更简单的条件:如果根节点在DFS树中有两个或更多的子节点,那么它就是关节点。因为移除根节点会使其各个子树彼此分离。



算法总结与回顾 📝

本节课中我们一起学*了关节点与双连通分量的核心知识。
以下是寻找并消除关节点的完整流程总结:
- 生成DFS树:对图进行深度优先搜索,得到一棵生成树。
- 标记发现时间:记录每个顶点在DFS中被访问的顺序
d[u]。 - 计算Low值:计算每个顶点通过树边和最多一条回边能到达的最小发现时间
low[u]。 - 判定关节点:
- 对于非根节点
u,如果存在一个子节点v满足low[v] >= d[u],则u是关节点。 - 对于根节点,如果它拥有两个或更多子节点,则它是关节点。
- 对于非根节点
- 消除关节点:一旦找到关节点,可以在由该关节点连接的不同连通分量之间添加边,从而使图变为双连通图(没有关节点的图)。
通过这个算法,我们就能系统地分析任何图的脆弱点,并通过增加冗余连接来增强网络的鲁棒性。


063:回溯法入门与暴力法
在本节课中,我们将要学*一种名为“回溯法”的问题解决策略。这是一种基于“暴力法”的方法,用于寻找问题的所有可能解。
回溯法遵循暴力法的核心思想:对于给定的问题,尝试所有可能的解决方案,然后从中挑选出符合要求的解。这与动态规划有相似之处,但动态规划主要用于解决优化问题,而回溯法则用于处理存在多个解且需要找出所有解的情况。



为了清晰地解释回溯法及其如何遵循暴力法,我们将通过一个简单的例子来阐述这一策略。


状态空间树与暴力枚举
假设我们有三个学生:两个男孩(B1, B2)和一个女孩(G1)。有三把椅子,我们需要找出所有可能的座位安排方式。
由于学生人数为3,总的排列方式为 3! = 6 种。为了系统地找出所有排列,我们可以将解决方案表示为一棵树,这棵树被称为状态空间树或解空间树。



让我们从根节点开始生成这棵树:
- 第一层代表第一把椅子的选择。
- 第二层代表在第一把椅子选定后,第二把椅子的选择。
- 第三层代表在前两把椅子选定后,第三把椅子的选择。

以下是生成所有排列的过程:


- 从根节点开始。
- 第一把椅子可以选择 B1。
- 第二把椅子可以选择 B2。
- 第三把椅子只能选择 G1。得到一种排列:
(B1, B2, G1)。
- 第三把椅子只能选择 G1。得到一种排列:
- 回溯到第二把椅子,选择 G1。
- 第三把椅子只能选择 B2。得到第二种排列:
(B1, G1, B2)。
- 第三把椅子只能选择 B2。得到第二种排列:
- 第二把椅子可以选择 B2。
- 回溯到第一把椅子,选择 B2。
- 第二把椅子可以选择 B1。
- 第三把椅子只能选择 G1。得到第三种排列:
(B2, B1, G1)。
- 第三把椅子只能选择 G1。得到第三种排列:
- 回溯到第二把椅子,选择 G1。
- 第三把椅子只能选择 B1。得到第四种排列:
(B2, G1, B1)。
- 第三把椅子只能选择 B1。得到第四种排列:
- 第二把椅子可以选择 B1。
- 回溯到第一把椅子,选择 G1。
- 第二把椅子可以选择 B1。
- 第三把椅子只能选择 B2。得到第五种排列:
(G1, B1, B2)。
- 第三把椅子只能选择 B2。得到第五种排列:
- 回溯到第二把椅子,选择 B2。
- 第三把椅子只能选择 B1。得到第六种排列:
(G1, B2, B1)。
- 第三把椅子只能选择 B1。得到第六种排列:
- 第二把椅子可以选择 B1。


通过遍历从根节点到每个叶节点的路径,我们得到了全部6种可能的座位安排。这就是暴力枚举所有可能性的过程。

约束条件与界限函数
在实际使用回溯法解决的问题中,通常存在一些约束条件。我们的目标是找到满足所有约束的解,而不是所有可能的解。

现在,让我们在之前的例子中增加一个约束:女孩不能坐在中间的椅子上。
我们再次使用状态空间树来寻找解,但这次会在生成树的过程中应用约束进行检查:

- 选择 B1 坐第一把椅子。
- 选择 B2 坐第二把椅子(允许)。
- 选择 G1 坐第三把椅子。得到有效解:
(B1, B2, G1)。
- 选择 G1 坐第三把椅子。得到有效解:
- 选择 G1 坐第二把椅子(违反约束)。停止从该节点继续探索。
- 选择 B2 坐第二把椅子(允许)。
- 选择 B2 坐第一把椅子。
- 选择 B1 坐第二把椅子(允许)。
- 选择 G1 坐第三把椅子。得到有效解:
(B2, B1, G1)。
- 选择 G1 坐第三把椅子。得到有效解:
- 选择 G1 坐第二把椅子(违反约束)。停止从该节点继续探索。
- 选择 B1 坐第二把椅子(允许)。
- 选择 G1 坐第一把椅子。
- 选择 B1 坐第二把椅子(允许)。
- 选择 B2 坐第三把椅子。得到有效解:
(G1, B1, B2)。
- 选择 B2 坐第三把椅子。得到有效解:
- 选择 B2 坐第二把椅子(允许)。
- 选择 B1 坐第三把椅子。得到有效解:
(G1, B2, B1)。
- 选择 B1 坐第三把椅子。得到有效解:
- 选择 B1 坐第二把椅子(允许)。
应用约束后,我们得到了4个有效解。在搜索过程中,一旦发现某个部分选择违反了约束(例如女孩坐在了中间),我们就立即停止从该节点向下继续生成子树。这种用于检查约束并“杀死”无效节点的条件,被称为界限函数。
界限函数的作用是剪枝,它避免了在不可能得到有效解的分支上浪费计算资源,从而提升了搜索效率。
回溯法与分支限界法的区别

回溯法所遵循的暴力搜索策略,也被另一种称为分支限界法的策略所采用。两者都生成状态空间树并使用界限函数进行剪枝,但它们的搜索顺序有根本区别。



上一节我们介绍了回溯法生成状态空间树的方式,本节中我们来看看它与分支限界法的不同。
- 回溯法采用深度优先搜索策略。
- 它沿着一条路径一直向下探索到叶节点(或遇到界限函数剪枝),然后回溯到上一个分支点,尝试另一条路径。
- 正如我们在座位安排例子中一步步深入再回溯所做的那样。



- 分支限界法通常采用广度优先搜索或最佳优先搜索策略。
- 它会先生成当前层的所有可能节点,然后再进入下一层。
- 以座位安排为例(不应用约束),分支限界法会先生成第一层所有节点(B1, B2, G1),然后针对第一层的每个节点,生成第二层的所有可能节点,以此类推。
简单来说,主要区别在于遍历状态空间树的方式:回溯法是“一条路走到黑再回头”,而分支限界法是“一层一层地扫”。分支限界法通常用于求解优化问题(如找最短路径、最小成本),这将在其他课程中详细讨论。


回溯法的典型应用

回溯法是解决许多经典算法问题的有力工具,以下是其中一些例子:



- N皇后问题:在N×N的棋盘上放置N个皇后,使得它们互不攻击。
- 子集和问题:给定一个集合,找出所有和为特定值的子集。
- 图着色问题:用最少的颜色给图的顶点着色,确保相邻顶点颜色不同。
- 排列与组合问题:生成所有可能的排列或组合。
- 迷宫求解问题:找出从起点到终点的所有路径。

总结

本节课中我们一起学*了回溯法的核心概念。我们了解到回溯法是一种基于深度优先搜索的暴力问题解决策略,它通过系统地探索状态空间树来寻找问题的所有解。关键在于,它使用界限函数来检查约束条件,并及时剪枝,避免无效搜索,从而在枚举所有可能性的同时提高了效率。我们还简单对比了回溯法与分支限界法在搜索顺序上的区别。在接下来的课程中,我们将运用回溯法解决N皇后等具体问题。
064:N皇后问题详解 👑


在本节课中,我们将学*如何使用回溯算法解决经典的N皇后问题。我们将从理解问题本身开始,然后逐步构建解决方案,并最终通过一个4x4棋盘的具体例子来演示回溯过程。
问题定义


N皇后问题是在一个N×N的棋盘上放置N个皇后,使得它们彼此之间不能相互攻击。在国际象棋中,皇后可以攻击同一行、同一列或同一对角线上的任何棋子。因此,问题的目标是找到所有满足此条件的皇后摆放方式。


问题简化与建模


为了减少问题的复杂度,我们首先进行简化。我们规定每个皇后必须放在不同的行上。这样,问题就从在N×N个格子中选择N个,简化为为每一行的皇后选择一个列号。



因此,一个解决方案可以表示为一个长度为N的数组 x,其中 x[i] 表示第 i 行(从0或1开始计数)的皇后所在的列号。




核心公式:一个解可以表示为数组 x = [x1, x2, ..., xn],其中 xi 是第 i 行皇后的列号。





约束条件(限界函数)


为了确保皇后之间不相互攻击,我们必须满足以下三个条件,这些条件构成了回溯算法中的“限界函数”:
- 不能有两个皇后在同一行。(已通过我们的建模自动满足)
- 不能有两个皇后在同一列。即对于任意
i != j,需满足x[i] != x[j]。 - 不能有两个皇后在同一对角线。这分为两种:
- 主对角线(左上到右下):行号与列号的差相等。即对于任意
i != j,需满足i - x[i] != j - x[j]。 - 副对角线(右上到左下):行号与列号的和相等。即对于任意
i != j,需满足i + x[i] != j + x[j]。
- 主对角线(左上到右下):行号与列号的差相等。即对于任意



核心代码(检查函数):
def is_safe(board, row, col):
# 检查同一列
for i in range(row):
if board[i] == col:
return False
# 检查主对角线
if i - board[i] == row - col:
return False
# 检查副对角线
if i + board[i] == row + col:
return False
return True



状态空间树与回溯过程



我们可以将寻找解的过程想象成一棵“状态空间树”。树的根节点代表空棋盘。第一层节点代表在第一行放置皇后的所有可能列(1到N)。每个第一层节点下,又展开第二层节点,代表在满足约束条件下,在第二行放置皇后的所有可能列,以此类推。


回溯法的核心是深度优先搜索这棵树:
- 从根节点开始,尝试在第一行放置皇后。
- 移动到下一行,尝试放置皇后,并立即用“限界函数”检查当前位置是否安全。
- 如果安全,则继续向下一行探索。
- 如果不安全,则“剪枝”——放弃该分支,回溯到上一行,尝试下一个可能的列。
- 重复步骤2-4,直到找到所有行都成功放置皇后的完整解,或者所有可能性都被探索完毕。



4皇后问题实例分析


让我们以4皇后问题(4x4棋盘)为例,手动模拟回溯过程。



上一节我们介绍了状态空间树和回溯的概念,本节中我们来看看如何将其应用到4皇后问题中。




初始状态:我们从空棋盘开始,尝试将第一个皇后放在第一行。



步骤分解:
- 将Q1放在第1列。
x[0] = 1。 - 尝试放置Q2(第二行):
- 放第1列?违反同列约束,剪枝。
- 放第2列?与Q1在同一主对角线(1-1 = 2-2),剪枝。
- 放第3列?安全。
x[1] = 3。
- 尝试放置Q3(第三行):
- 放第1列?安全。
x[2] = 1。
- 放第1列?安全。
- 尝试放置Q4(第四行):
- 放第1列?同列冲突。
- 放第2列?与Q3在同一副对角线(3+1 = 4+2),冲突。
- 放第3列?同列冲突。
- 放第4列?与Q1在同一副对角线(1+1 = 4+4),冲突。
- Q4无处可放,回溯到Q3。
- Q3尝试下一个位置:第2列?与Q1在同一副对角线(1+1 = 3+2),冲突。第4列?与Q2在同一主对角线(2-3 = 3-4),冲突。
- Q3也无处可放,回溯到Q2。
- Q2尝试下一个位置:第4列。
x[1] = 4。 - 尝试放置Q3(第三行):
- 放第1列?安全。
x[2] = 1。
- 放第1列?安全。
- 尝试放置Q4(第四行):
- 放第1列?同列冲突。
- 放第2列?与Q1在同一主对角线(1-1 = 4-2),冲突。
- 放第3列?安全!
x[3] = 3。 - 找到一个解!
x = [1, 4, 1, 3]?等等,这里Q1和Q3同在第1列,违反了条件。让我们仔细检查:当Q2=4,Q3=1时,Q4尝试第3列,它与Q2在同一副对角线(2+4 = 4+3)吗?7=7,是的,冲突。所以此路不通,继续回溯...(注:视频演示中此处有口误或笔误,实际正确找到一个解的过程如下)




正确的解发现过程(对应视频后半部分):
- 回溯到最初,尝试将Q1放在第2列。
x[0] = 2。 - Q2尝试第4列安全。
x[1] = 4。 - Q3尝试第1列安全。
x[2] = 1。 - Q4尝试第3列安全。
x[3] = 3。 - 找到第一个有效解:
[2, 4, 1, 3]。- 对应棋盘位置:(行,列) -> (1,2), (2,4), (3,1), (4,3)
- 算法继续回溯搜索,会找到另一个对称解:
[3, 1, 4, 2]。




算法复杂度





对于N皇后问题,最坏情况下需要探索的状态空间树节点数量非常大。在仅避免同行同列的简化模型中,节点总数公式为:





核心公式:总节点数 ≈ 1 + Σ ( Π (N - j) ),其中外层求和 i 从 0 到 N-1,内层连乘 j 从 0 到 i。




对于N=4,计算可得最多生成约65个节点。通过使用限界函数(检查对角线)进行剪枝,实际探索的节点数远小于此值,这正是回溯法高效的关键。对于标准的8皇后问题,解的数量为92个。



总结




本节课中我们一起学*了如何使用回溯算法解决N皇后问题。我们首先定义了问题并建立了使用列号数组的简化模型。然后,我们明确了皇后互不攻击的三个约束条件,它们共同构成了回溯中的限界函数。通过构建和遍历状态空间树,并结合深度优先搜索与剪枝策略,我们能够有效地找出所有可行解。最后,我们通过一个4皇后的实例,一步步演示了回溯、探索、冲突检查和剪枝的全过程,并得到了最终的两个解。回溯法是解决此类约束满足和组合优化问题的强大工具。
065:子集和问题与回溯法 🧩



在本节课中,我们将学*一个经典的组合优化问题——子集和问题。我们将理解问题的定义,并重点学*如何使用回溯法来解决它。回溯法是一种通过系统地探索所有可能候选解来寻找问题解的算法策略,当发现当前路径不可能得到有效解时,它会“回溯”并尝试其他路径。


问题定义
首先,我们来解释子集和问题。给定一组数字(或权重),我们需要找到一个子集,使得该子集中所有数字的总和恰好等于一个给定的目标值 M。
让我们通过一个具体例子来理解。假设有 n = 6 个权重值:5, 10, 12, 13, 15, 18。我们的目标总和 M = 30。如果将所有数字相加,总和为73,远大于30。因此,我们需要找到一个子集,其总和正好是30。





为了表示一个解,我们可以为每个权重定义一个变量 X,其值为0或1。X = 1 表示该权重被包含在子集中,X = 0 则表示不被包含。



暴力解法与状态空间树
如何解决这个问题呢?选择哪些权重可以有很多种方式。一种最直接的方法是尝试所有可能的组合,然后找出总和为30的组合。我们可以为这个问题画一棵“状态空间树”或“解空间树”。


这棵树从根节点开始,每个层级对应一个权重的决策。第一层决定第一个权重(5)是否被包含,第二层决定第二个权重(10)是否被包含,依此类推。由于有6个权重,这棵树将有7层(包括根节点),总共有 2^6 = 64 个叶子节点,每个叶子节点代表一种完整的权重选择方案。




如果权重数量是 n,那么总路径数就是 2^n。这是一个指数级的时间复杂度,非常耗时。

回溯法与约束函数


当我们应用回溯法时,目标是减少需要探索的节点数量。我们通过定义“约束函数”来实现这一点。约束函数用于判断当前的部分解是否有可能扩展为有效解。如果不可能,我们就“剪枝”(即停止探索该分支),从而避免无效的搜索。
接下来,我们将展示如何使用回溯法解决这个例子,并定义相应的约束函数。


回溯求解过程演示

我们开始生成状态空间树。在根节点,我们记录两个值:当前已选权重的总和(初始为0)和剩余可用权重的总和(初始为所有权重之和73)。

我们采用深度优先搜索(DFS)策略,优先探索“包含”当前权重的分支。
-
包含第一个权重(5):当前总和变为5,剩余总和变为 73 - 5 = 68。
-
包含第二个权重(10):当前总和变为 5 + 10 = 15,剩余总和变为 68 - 10 = 58。
-
包含第三个权重(12):当前总和变为 15 + 12 = 27,剩余总和变为 58 - 12 = 46。
-
尝试包含第四个权重(13):如果包含,当前总和将变为 27 + 13 = 40。这已经超过了目标值30。因此,我们应用第一个约束函数:
约束函数1(可行性):已选权重之和加上下一个待选权重,必须小于等于目标值 M。
sum_included + weight[next] <= M因为 40 > 30,这个节点被剪枝。我们回溯,尝试不包含第四个权重。
-
不包含第四个权重(13):当前总和保持27,剩余总和变为 46 - 13 = 33(因为13被永久排除了)。
-
尝试包含第五个权重(15):如果包含,总和将变为 27 + 15 = 42 > 30。再次违反约束函数1,剪枝。
-
尝试不包含第五个权重(15):当前总和保持27,剩余总和变为 33 - 15 = 18。
-
尝试包含第六个权重(18):总和将变为 27 + 18 = 45 > 30,剪枝。
-
尝试不包含第六个权重(18):此时当前总和为27,剩余总和为0。我们应用第二个约束函数:
约束函数2(充分性):已选权重之和加上所有剩余权重的总和,必须大于等于目标值 M。否则,即使包含所有剩余权重也无法达到目标。
sum_included + sum_remaining >= M这里 27 + 0 = 27 < 30,因此这个节点也被剪枝。这条路径探索完毕,没有解。



现在,我们回溯到更早的节点(在包含第三个权重之后),尝试不包含第三个权重。
- 不包含第三个权重(12):当前总和回退到15,剩余总和变为 46(之前剩余46,加上刚排除的12,实际上是58-12=46?这里需要厘清:在决定不包含12时,剩余总和是之前未包含12时的剩余值,计算方式需保持一致。在回溯算法中,通常记录“从当前索引开始往后所有未处理权重的和”。我们继续按演示逻辑进行)。
- 包含第四个权重(13):总和变为 15 + 13 = 28,剩余总和相应减少。
- 包含第五个权重(15):总和变为 28 + 15 = 43 > 30,剪枝。
- 不包含第五个权重(15):总和保持28。
- 包含第六个权重(18):总和变为 28 + 18 = 46 > 30,剪枝。
- 不包含第六个权重(18):总和为28,检查约束函数2,发现即使包含所有剩余权重(已无剩余)也无法达到30,剪枝。
继续回溯,尝试在未包含第三个权重后,也不包含第四个权重... 最终,我们会找到一条有效路径。
找到的一个解:探索过程最终会找到一条路径:包含权重5, 包含权重10, 不包含权重12, 不包含权重13, 包含权重15, 不包含权重18。
- 计算总和:5 + 10 + 15 = 30。
因此,解向量可以表示为:[X1=1, X2=1, X3=0, X4=0, X5=1, X6=0]。

实际上,这个问题可能有多个解,例如权重 12 + 18 = 30 或 5 + 12 + 13 = 30。回溯法会继续探索其他分支,找到所有满足条件的解。


算法核心与总结




本节课我们一起学*了子集和问题以及如何使用回溯法求解。关键在于两个约束函数,它们能显著减少需要搜索的状态空间:
- 可行性约束:确保部分解不会超过目标值。
sum_included + weight[next] <= M - 充分性约束:确保部分解还有可能通过添加剩余元素达到目标值。
sum_included + sum_remaining >= M



回溯法通常以递归的深度优先搜索形式实现,系统地遍历解空间树,并利用约束函数进行剪枝,从而高效地找到所有可行解。虽然最坏情况复杂度仍可能是指数级的,但对于许多实际问题,回溯法能通过剪枝大大提升效率。
066:图着色问题与回溯法 🎨


在本节课中,我们将学*图着色问题,并了解如何使用回溯法来解决它。图着色问题是一个经典的组合优化问题,其核心目标是为一个图中的顶点分配颜色,使得任何两个相邻的顶点颜色不同。

什么是图着色问题? 🤔


首先,让我们明确问题是什么。给定一个图和一些颜色,例如红、绿、蓝三种颜色。问题的要求是:为图中的所有顶点着色,使得任何两个相邻的顶点不能拥有相同的颜色。
例如,考虑下图:

我们尝试用三种颜色为其着色。从顶点1开始,赋予红色。顶点2与顶点1相邻,因此不能是红色,我们赋予绿色。顶点3不与顶点1相邻,可以赋予红色。顶点4与顶点2相邻,不能是绿色,但可以赋予红色。顶点5与顶点3和4相邻,不能是红色或绿色,因此赋予蓝色。
最终着色方案为:红、绿、红、绿、蓝。如下图所示:





这并非唯一解,存在多种可能的着色方案。我们的目标是找出所有满足条件的方案,这类似于尝试所有排列组合,并筛选出满足“相邻顶点颜色不同”这一条件的方案。这类问题非常适合用回溯法解决。

图着色问题的类型 📊

根据目标不同,图着色问题主要分为三类:
- M着色判定问题:给定一个图和M种颜色,判断是否能用这M种颜色为图着色。我们不需要具体的着色方案,只需回答“是”或“否”。
![]()

- M着色优化问题:给定一个图,找出为其着色所需的最少颜色数量。这个问题也称为色数问题。
![]()



- 找出所有着色方案:给定一个图和M种颜色,找出所有可能的着色方案。


在开始着色前,我们通常需要知道最少需要多少种颜色(优化问题),或者判断给定的颜色是否足够(判定问题)。



使用回溯法解决图着色问题 🔄



回溯法通过系统地探索所有可能的解空间来寻找问题的解。对于图着色,我们构建一个状态空间树。
假设有一个简单的图和三种颜色(红、绿、蓝)。我们首先构建不考虑任何约束(即允许相邻顶点同色)的完整状态空间树。

- 根节点代表起点。
- 第一层代表为顶点1(x1)选择颜色:红、绿、蓝。
- 第二层代表在顶点1的每种选择下,为顶点2(x2)选择颜色:红、绿、蓝。
- 以此类推,为顶点3(x3)、顶点4(x4)构建选择分支。

对于一个有n个顶点、C种颜色的问题,如果不加约束,生成的节点总数*似为 C^(n+1)。这是一个指数级复杂度问题。






引入约束(边界函数)进行剪枝 ✂️




回溯法的核心在于引入约束条件(边界函数),在构建树的过程中提前剪掉不可能产生有效解的分支,从而大幅减少搜索量。

我们以深度优先搜索的方式构建树,并在为每个顶点尝试颜色时,立即检查该颜色是否与已着色的相邻顶点冲突。



回溯过程示例:


- 为x1选择红色。
- 为x2选择颜色。尝试红色:与x1冲突,剪枝。尝试绿色:有效,选择绿色。
- 为x3选择颜色。尝试红色:与x2不冲突,有效,选择红色。
- 为x4选择颜色。
- 尝试红色:与x2冲突,剪枝。
- 尝试绿色:与x2和x3不冲突,有效。找到第一个解:[红, 绿, 红, 绿]。
- 尝试蓝色:与x2和x3不冲突,有效。找到第二个解:[红, 绿, 红, 蓝]。
- 回溯到x3,尝试下一种颜色(绿色)。与x2冲突,剪枝。再尝试蓝色,有效。
- 继续为x4尝试颜色... 如此反复,直到探索完所有可能分支。


通过这种方式,我们只生成了可能通向有效解的部分节点,但最坏情况下的时间复杂度仍然是 O(C^n),其中C是颜色数,n是顶点数。




图着色问题的实际应用 🗺️


图着色问题一个著名的历史应用是地图着色。




假设有一张包含多个区域的地图,需要为每个区域着色,使得有共同边界的区域颜色不同。在早期印刷技术中,每种颜色都需要单独制版和印刷。如果能为地图找到一种使用最少颜色的着色方案,就能极大减少印刷次数和成本。


如何将地图问题转化为图着色问题?
- 将地图中的每个区域视为图中的一个顶点。
- 如果两个区域有共同边界,则在对应的顶点间连一条边。

这样,地图着色问题就转化为了对所得图的顶点着色问题。解决这个图着色问题,得到的着色方案直接对应地图的着色方案。

尽管现代印刷技术已能一次性完成多色印刷,但图着色问题的思想仍广泛应用于课程安排、频谱分配、寄存器分配等需要避免冲突的调度和资源分配问题中。



总结 📝
本节课我们一起学*了图着色问题及其解法。
- 我们了解了图着色问题的基本定义:为图中顶点着色,使得相邻顶点颜色不同。
- 我们区分了问题的三种类型:判定问题、优化问题和寻找所有解的问题。
- 我们重点学*了如何使用回溯法解决该问题,通过构建状态空间树并利用边界函数进行剪枝,来系统地搜索所有有效着色方案。
- 最后,我们探讨了图着色问题的一个经典应用——地图着色,理解了如何将实际问题抽象为图模型并求解。



图着色问题是展示回溯法威力的一个完美例子,它教会我们如何通过智能搜索和剪枝,来高效处理指数级复杂度的组合问题。
067:哈密顿回路 - 回溯法 🧩


在本节课中,我们将要学*什么是哈密顿回路,并了解如何使用回溯法来寻找图中的所有哈密顿回路。我们将从一个简单的定义开始,逐步深入到算法的工作原理和实现细节。

什么是哈密顿回路? 🔄




首先,让我们理解什么是哈密顿回路。给定一个图,我们需要从某个起始顶点出发,访问图中的所有顶点恰好一次,并最终返回到起始顶点。这样形成的闭合路径就是一个哈密顿回路。




因此,我们的问题是判断一个图中是否存在哈密顿回路。如果存在,找出所有可能的回路。这个问题与旅行商问题相似,但旅行商问题要求找到成本最低的路径,而哈密顿回路问题要求找出所有可能的路径。


关于哈密顿回路,有几个重要事实需要了解:
- 图可以是有向的或无向的,但必须是连通的。如果图不连通,则不可能存在哈密顿回路。
- 这是一个NP难问题,意味着其时间复杂度是指数级的,没有简单的方法能快速判断图中是否存在哈密顿回路。




手动寻找哈密顿回路示例 ✍️

为了更直观地理解,让我们手动尝试在一些图中寻找哈密顿回路。





示例图1:
假设我们有一个包含多个顶点的图。从顶点1开始,我们可以尝试路径 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 1,这是一个哈密顿回路。我们还可以找到其他回路,例如 1 -> 2 -> 6 -> 5 -> 4 -> 3 -> 1。

需要注意的是,如果仅仅改变起始顶点(例如从顶点2开始:2 -> 3 -> 4 -> 5 -> 6 -> 1 -> 2),得到的回路顺序与之前相同,这被视为同一个回路。因此,在算法中需要避免重复计数。


示例图2:
考虑另一个图。我们可以找到回路 1 -> 2 -> 3 -> 5 -> 4 -> 1 和 1 -> 2 -> 4 -> 5 -> 3 -> 1,说明存在多个哈密顿回路。

示例图3:
再来看一个图。尝试路径 1 -> 2 -> 3 -> 7 -> 6 后,我们发现无法在不重复访问顶点的情况下到达顶点4或5并最终返回起点。因此,这个图中不存在哈密顿回路。
通过观察,我们总结出两个阻碍哈密顿回路存在的条件:
- 关节点:如果图中存在关节点(移除该顶点会使图变得不连通),则不可能存在哈密顿回路。
- 悬挂顶点:如果图中存在度为1的悬挂顶点,则也不可能存在哈密顿回路。
使用回溯法寻找哈密顿回路 🧠
上一节我们介绍了哈密顿回路的概念和一些特性,本节中我们来看看如何使用回溯法系统地解决这个问题。回溯法的工作方式类似于深度优先搜索,它会在解空间树中探索所有可能的路径,并利用约束条件(界限函数)来剪除无效的分支,从而高效地找到所有解。
我们将通过一个具体的图和算法来演示这个过程。
给定图及其邻接矩阵:
假设我们有一个图,其邻接矩阵如下(1表示有边,0表示无边):
1 2 3 4 5
1: 0 1 0 1 1
2: 1 0 1 1 1
3: 0 1 0 1 0
4: 1 1 1 0 1
5: 1 1 0 1 0
算法思路:
我们使用一个数组 x[] 来存储当前尝试的哈密顿回路路径。为了不重复计算循环排列,我们固定起始顶点为1,即 x[1] = 1。然后从第二个位置(k=2)开始,尝试为每个位置 x[k] 选择符合条件的顶点。
界限函数(约束条件):
在尝试为 x[k] 选择顶点时,必须满足以下三个条件,否则该分支将被剪除:
- 非重复性:选择的顶点
v不能已经在路径x[1..k-1]中出现过。 - 连通性:选择的顶点
v必须与路径中的前一个顶点x[k-1]有边相连。即邻接矩阵中graph[x[k-1]][v] != 0。 - 回路闭合性(仅当
k是最后一个顶点时检查):如果k等于顶点总数n,那么选择的顶点v还必须与起始顶点x[1]有边相连,以形成闭合回路。
算法步骤(伪代码描述):
算法 Hamiltonian(k)
// 为路径的第k个位置寻找合适的顶点
循环 v 从 1 到 n // 尝试所有顶点
如果 NextValue(v) 为真 // 检查顶点v是否满足约束条件
那么
x[k] = v // 将v加入路径
如果 k == n // 如果路径已满
那么 打印路径 x[1..n] // 找到一个哈密顿回路
否则
Hamiltonian(k+1) // 递归地为下一个位置寻找顶点

函数 NextValue(v)
// 判断顶点v是否能作为x[k]的候选
如果 graph[x[k-1]][v] == 0 // 条件2:与前一顶点无边
返回 假
循环 i 从 1 到 k-1
如果 x[i] == v // 条件1:顶点已重复
返回 假
如果 k == n // 条件3:若是最后一个位置,需与起点相连
返回 (graph[v][x[1]] != 0)
否则
返回 真
回溯过程示例:
让我们基于上述图和算法,手动模拟部分回溯过程,构建状态空间树。
- 固定
x[1] = 1。 - 为
x[2]尝试顶点:- 尝试
v=1:违反非重复性,剪除。 - 尝试
v=2:满足所有条件(与1有边,未重复),设置x[2]=2,递归调用Hamiltonian(3)。
- 尝试
- 在
x[2]=2的分支下,为x[3]尝试顶点:- 尝试
v=1,2:重复,剪除。 - 尝试
v=3:满足条件(与2有边),设置x[3]=3,递归调用Hamiltonian(4)。
- 尝试
- 继续此过程... 最终可能找到一条路径如
x[] = {1, 2, 3, 4, 5}。在设置x[5]=5后,检查发现k=5等于总顶点数5,且graph[5][1] != 0,因此打印该回路1-2-3-4-5-1。 - 算法会回溯,尝试
x[4]的其他可能值(如尝试v=5代替4),或者回溯到更早的位置,继续探索其他分支,从而找到所有可能的哈密顿回路,例如1-2-5-4-3-1。
算法复杂度:
该算法本质上尝试了 (n-1)! 种顶点排列(因为起点固定),其时间复杂度为 O(n!),这等价于 O(n^n),属于指数级复杂度,印证了哈密顿回路问题是NP难的事实。
总结 📚
本节课中我们一起学*了哈密顿回路问题。我们首先定义了哈密顿回路,并了解了其存在的一些必要条件(如图的连通性、无关节顶点和悬挂顶点)。然后,我们重点学*了如何使用回溯法来解决这个问题。回溯法通过系统性地探索解空间,并利用界限函数(检查顶点非重复性、边存在性和回路闭合性)来剪枝,从而有效地找出图中所有的哈密顿回路。虽然该算法的时间复杂度是指数级的,但对于理解NP难问题的求解以及回溯法的应用是一个很好的范例。
068:分支限界法入门 🧠
在本节课中,我们将要学*一种新的问题解决策略——分支限界法。这是一种用于解决优化问题(特别是最小化问题)的算法,它通过构建和探索状态空间树来寻找最优解。
概述





分支限界法是一种类似于回溯法的算法,两者都使用状态空间树来表示问题的解。然而,分支限界法主要用于解决优化问题,并且通常针对最小化问题。虽然它不直接处理最大化问题,但我们可以通过转换(例如,将最大化利润转换为最小化成本)来应用它。
上一节我们介绍了回溯法的基本概念,本节中我们来看看分支限界法有何不同。



状态空间树的生成方法


分支限界法的核心是生成并探索一棵状态空间树。根据解的表达方式,生成树的方法主要有两种。
方法一:子集法(可变大小解)


在这种方法中,一个解被表示为一个作业的子集。例如,解 {作业1, 作业4} 表示执行作业1和作业4。这种表示法得到的解大小是可变的,可能包含不同数量的作业。
以下是生成状态空间树的过程:
- 从根节点(不包含任何作业)开始。
- 第一层,依次考虑是否加入第一个作业、第二个作业等,生成多个子节点。
- 与回溯法使用深度优先搜索不同,分支限界法采用广度优先搜索。这意味着我们完成一层的所有扩展后,再进入下一层。
- 继续该过程,为每个节点生成其后续可能的作业选择,直到所有可能性都被探索。


这种方法系统地生成了所有可能的作业组合(子集)。
方法二:序列法(固定大小解)



在这种方法中,解用一个固定长度的序列表示,每个位置对应一个作业,用1(执行)或0(不执行)表示。例如,对于四个作业,解 [1, 0, 0, 1] 表示执行作业1和作业4。


生成树的过程逻辑与子集法类似,但每个节点的分支代表对下一个作业位置选择0或1,最终所有叶子节点都代表一个长度为n(作业总数)的序列。



节点探索策略:FIFO vs LIFO vs LC


在广度优先搜索的框架下,我们如何选择下一个要扩展的节点?这引出了分支限界法的几种变体。


上一节我们看到了如何生成树,本节中我们来看看如何智能地选择节点进行探索以提升效率。

FIFO 分支限界法


如果我们使用队列(Queue) 来存储待探索的节点,并总是从队列前端取出节点进行扩展,这就是先进先出策略。
- 代码表示:
next_node = queue.pop(0) - 这种策略严格按照节点生成的顺序进行广度优先探索。


LIFO 分支限界法

如果我们使用栈(Stack) 来存储待探索的节点,并总是从栈顶取出节点进行扩展,这就是后进先出策略。
- 代码表示:
next_node = stack.pop() - 这种策略会导致探索顺序更接*深度优先,但在分支限界法中,它仍然是在广度优先的层面上选择最后一个生成的节点进行扩展。


LC(最小成本)分支限界法
这是最有效的一种策略。它要求我们为每个节点计算一个“成本”估计值(对于最小化问题,成本越低越好)。


以下是其工作步骤:
- 为每个生成的节点计算一个成本值。这个值通常是一个下界,表示从该节点出发可能达到的最佳解至少会有多好。
- 在所有活节点(未扩展的节点)中,总是选择成本最小的节点进行扩展。
- 重复此过程。


公式表示:选择下一个节点 NextNode = argmin(Cost(Node)),其中 Node 属于所有活节点。

优势:通过始终朝着当前看来最有希望(成本最低)的方向搜索,LC分支限界法能更快地找到高质量的解,并可能避免探索大量无希望的子树,从而比FIFO和LIFO方法更快。

总结
本节课中我们一起学*了分支限界法的基础知识:
- 它是一种利用状态空间树和广度优先搜索解决最小化优化问题的方法。
- 解可以通过子集法(可变大小)或序列法(固定大小)来表示。
- 节点探索策略主要有三种:FIFO(队列)、LIFO(栈)和高效的 LC(最小成本)。
- LC分支限界法通过为节点计算成本并优先扩展成本最低的节点,能够显著提高搜索效率。

在接下来的课程中,我们将通过具体问题来实践分支限界法的应用。
069:带截止时间的作业调度问题 - 分支限界法 🧩
在本节课中,我们将学*如何使用分支限界法来解决“带截止时间的作业调度问题”。这是一个经典的优化问题,通常目标是最大化总利润。为了适应分支限界法擅长解决最小化问题的特点,我们将问题转化为最小化总惩罚(即未完成作业的利润损失)。通过这种方法,最小化惩罚等价于最大化利润。


问题定义与转化


带截止时间的作业调度问题通常是一个最大化问题,每个作业有对应的利润和截止时间。分支限界法通常用于解决最小化问题。因此,我们需要将问题转化。

我们将每个作业的利润视为“惩罚”——如果该作业未被完成,我们就需要支付(损失)这笔惩罚。这样,问题的目标就变成了:选择一组作业,在各自的截止时间内完成它们,使得总惩罚最小化。


核心公式:
- 设作业集合为
J = {J1, J2, ..., Jn} - 每个作业
Ji的利润为Pi, 截止时间为Di, 处理时间为Ti - 定义惩罚
Penalty_i = Pi(即未完成作业Ji的损失) - 目标:最小化总惩罚
Minimize Σ Penalty_i (对于所有未完成的作业Ji)

分支限界法核心概念
我们将通过生成状态空间树来解决问题。树中的每个节点代表一个部分解(即已选择或已拒绝的作业组合)。对于每个节点,我们需要计算两个关键值:成本 和 上界。

- 成本:从根节点到当前节点,所有已被拒绝的作业的惩罚之和。它代表了当前部分解已经确定要支付的惩罚。
- 上界:在当前部分解的基础上,对剩余所有未被考虑的作业的惩罚求和。它代表了完成最终解可能需要支付的最小额外惩罚的一个乐观估计。

核心定义:
- 设
S为当前节点已选择要完成的作业集合。 - 设
R为从根节点到当前节点路径上已明确拒绝的作业集合。 - 设
U为尚未做出决定的剩余作业集合。 - 则:
- 成本 Cost = Σ Penalty_j (对于所有 j ∈ R)
- 上界 Upper Bound = Σ Penalty_k (对于所有 k ∈ U)

我们还会维护一个全局变量 upper,记录当前找到的所有可行解的最小成本。在搜索过程中,如果一个节点的成本已经超过 upper,或者其上界大于 upper,则该节点可以被“剪枝”(即不再继续扩展),因为它不可能产生比当前已知解更好的结果。

实例分步详解


上一节我们介绍了分支限界法的核心概念,本节我们通过一个具体例子来看如何应用。假设有4个作业,其数据如下:

| 作业 | 处理时间 | 截止时间 | 利润(惩罚) |
|---|---|---|---|
| J1 | 1 | 1 | 5 |
| J2 | 2 | 3 | 10 |
| J3 | 1 | 2 | 6 |
| J4 | 1 | 1 | 3 |


注意:此例中作业的处理时间并不相同,这增加了问题的复杂性。


步骤1:初始化根节点

我们从根节点开始,此时没有做出任何决定。
- 成本 = 0 (尚未拒绝任何作业)
- 上界 = ∞ (初始设为无穷大)
- 全局
upper= ∞



步骤2:扩展根节点
从根节点出发,我们依次考虑是否选择第一个作业 J1。这会产生分支。

以下是第一个分支(选择J1)的计算过程:
- 节点1:选择 J1。
- 已拒绝作业集合
R= {} (因为选择了J1,并未拒绝它) - 未决定作业集合
U= - 成本 = Σ Penalty of R = 0
- 上界 = Σ Penalty of U = 10 + 6 + 3 = 19
- 由于上界 19 < 当前全局
upper(∞),更新upper = 19
- 已拒绝作业集合



步骤3:继续扩展与剪枝
接着,我们考虑根节点的其他分支。
以下是根节点的其他分支:
- 节点2:不选 J1, 选择 J2。
R= {J1} (拒绝了J1)U=- 成本 = Penalty(J1) = 5
- 上界 = Penalty(J3) + Penalty(J4) = 6 + 3 = 9
- 上界 9 < 当前
upper(19), 更新upper = 9
- 节点3:不选 J1, 不选 J2, 选择 J3。
R=U=- 成本 = 5 + 10 = 15
- 成本 15 > 当前
upper(9), 剪枝此节点。无需计算上界。
- 节点4:不选 J1, J2, J3, 选择 J4。
R=U= {}- 成本 = 5 + 10 + 6 = 21
- 成本 21 > 当前
upper(9), 剪枝此节点。





此时,存活的节点是节点1和节点2。


步骤4:扩展存活节点并检查可行性


我们选择上界更小的节点2进行扩展(因为它更有希望)。扩展节点2意味着在“已选择J2”的基础上,继续决定J3和J4。

在扩展之前,我们必须检查时间可行性。已选择J2(处理时间2,截止时间3)。现在考虑加入J3(处理时间1,截止时间2):
- 尝试调度:J3截止时间更早,先安排J3在时间0-1执行,再安排J2在时间1-3执行。总处理时间=1+2=3,满足J2的截止时间3和J3的截止时间2。可行。

以下是节点2的分支:
- 节点5:选择 J2 和 J3。
R= {J1} (只拒绝了J1)U=- 成本 = Penalty(J1) = 5
- 上界 = Penalty(J4) = 3
- 上界 3 < 当前
upper(9), 更新upper = 3
- 节点6:选择 J2 和 J4。
- 检查可行性:J2(时间2,截止3), J4(时间1,截止1)。无法在截止时间前完成这两个作业(例如,先做J4,则J2无法在截止时间3前完成2个单位的工作)。不可行,直接剪枝。


步骤5:进一步探索与得到解

现在,存活的节点是节点1和节点5。节点5的上界(3)更小,我们扩展节点5。在“已选择J2和J3”的基础上,考虑J4。


检查可行性:已选择J2和J3。
- 一种调度方式:时间0-1执行J3,时间1-3执行J2。所有时间片已占满。
- 加入J4(处理时间1):没有空闲时间片,无法在截止时间前完成。不可行。
因此,节点5无法再产生新的可行子节点。它是一个叶子节点,代表一个完整解:{J2, J3}。


步骤6:回溯与确认最优解
我们回溯到另一个存活节点1进行扩展。在“已选择J1”的基础上,尝试加入J2。
- 检查可行性:J1(时间1,截止1), J2(时间2,截止3)。可以安排(先J1后J2),可行。
- 计算新节点成本=0,上界=Penalty(J3)+Penalty(J4)=9。其上界9大于当前全局最优解的成本3,因此即使扩展下去,最好的结果(上界)也不会优于当前解。我们可以对此节点进行剪枝。
所有节点探索完毕。我们找到的最优解在节点5,其成本为5。
结果解读


最优解是完成作业 J2 和 J3。
- 完成的作业利润:10 + 6 = 16
- 未完成的作业惩罚(成本):5 + 3 = 8
- 净收益(相当于最大化问题中的利润):16 - 8 = 8, 或者说,最小化惩罚为 8。
关键点:在分支限界过程中,我们通过不断计算成本和上界,并与全局最优值 upper 比较,有效地剪掉了大量不可能产生更优解的分支,从而高效地搜索到了最优解。


总结

本节课中,我们一起学*了如何使用分支限界法解决带截止时间的作业调度问题。我们首先将最大化利润问题转化为最小化惩罚问题以适应算法。然后,我们深入探讨了状态空间树中每个节点的成本与上界的计算方法,以及如何利用它们和全局变量 upper 进行剪枝。通过一个分步实例,我们演示了从初始化、扩展节点、可行性检查到剪枝和确定最优解的完整流程。分支限界法的威力在于它通过智能的界限计算,避免了穷举所有可能性,从而在解空间很大的问题上也能高效找到最优解。
070:使用分支限界法解决0/1背包问题 🎒
在本节课中,我们将学*如何使用分支限界法来解决经典的0/1背包问题。我们将通过一个具体的例子,一步步地构建状态空间树,计算每个节点的代价和上界,并利用最小代价优先的策略来高效地找到最优解。

问题定义



抽象问题描述如下:给定若干物品,每个物品有其利润和重量。同时,有一个容量为15的背包。问题的目标是将物品装入背包,使得装入物品的总重量不超过背包容量(即15),并且总利润最大化。




一个重要的约束是:对于任何物品,要么完整地放入背包,要么完全不放入。不能只放入物品的一部分(例如,不能从一个4公斤的物品中只取2公斤)。因此,这是一个最大化问题。
解决方法概述
此问题可以用贪心算法、动态规划甚至回溯法解决。但分支限界法通常用于解决最小化问题。为了应用分支限界法,我们可以将利润值取负,从而将最大化问题转化为最小化问题。求解后,再将结果转换回正值即可。
我们将使用LC(最小代价)分支限界法,这意味着在探索状态空间树时,总是优先探索代价最小的节点。
对于状态空间树中的每个节点,我们需要计算两个值:
- 上界:在不允许分割物品的情况下,从该节点出发可能获得的最大利润(取负值)。
- 代价:在允许分割物品(仅用于计算,最终解不允许分割)的情况下,从该节点出发可能获得的最大利润(取负值)。
公式定义:
设总利润为 P,总重量为 W,背包容量为 C。
- 上界计算:
上界 = - (已选物品利润和 + 后续物品在不超重情况下的完整利润和) - 代价计算:
代价 = - (已选物品利润和 + 后续物品在不超重情况下的最大利润和,允许取分数物品填满剩余容量)


构建状态空间树

我们采用定长解来表示方案,即用一个长度为物品数量的0/1序列来表示每个物品是否被选中。例如,[1, 0, 1, 0] 表示第一个和第三个物品被选中。




初始时,设置一个全局变量 upper 为 -∞(因为我们已经将利润取负,所以初始上界是负无穷大,代表最差情况)。
第一步:根节点


首先计算根节点的代价和上界。根节点代表尚未做出任何选择。
计算代价:
- 按单位重量利润降序考虑物品(假设已排序):物品4(18/9=2), 物品1(10/2=5), 物品2(10/4=2.5), 物品3(12/6=2)。
- 先装物品1(利润10,重量2),剩余容量13。
- 再装物品4(利润18,重量9),剩余容量4。
- 接着装物品2(利润10,重量4),恰好装满,剩余容量0。
- 因此,允许分割时的最大利润为
10 + 18 + 10 = 38。 - 代价为
-38。
计算上界:
- 同样顺序考虑物品,但不允许分割。
- 装物品1(利润10,重量2),剩余容量13。
- 装物品4(利润18,重量9),剩余容量4。
- 尝试装物品2(重量4,可装入),利润10。
- 因此,不允许分割时的最大利润为
10 + 18 + 10 = 38。 - 上界为
-38。

由于 -38 优于(小于)当前的 upper(-∞),我们更新 upper = -38。





第二步:探索第一层节点

从根节点分支,产生两个子节点:包含物品1和不包含物品1。

节点A:包含物品1
- 已选利润:10,已用重量:2。
- 代价:
- (10 + 后续最大利润)。后续过程类似根节点,从剩余物品中贪心选择(允许分割),最终代价为-38。 - 上界:
- (10 + 后续最大利润)。后续过程不允许分割,计算得上界为-38。 - 该节点代价等于当前全局上界
upper(-38),保留。


节点B:不包含物品1
- 已选利润:0,已用重量:0。
- 代价:从物品4开始贪心选择(允许分割)。装物品4(利润18,重9),剩余6;装物品2(利润10,重4),剩余2;剩余容量2可装物品3的1/3(利润
12 * (2/6) = 4)。总利润18+10+4=32。代价为-32。 - 上界:不允许分割。装物品4(利润18,重9),剩余6;装物品2(利润10,重4),剩余2;物品3重量6 > 2,无法完整装入。总利润
18+10=28。上界为-28。 - 该节点代价
-32大于当前全局上界upper(-38)吗?-32 > -38为 False(在负数比较中,-32 不比 -38 “大”,即其代表的利润32小于38),所以节点保留。注意:这里比较的是代价和上界,目的是剪枝。如果节点的代价已经比当前找到的最好上界“差”(即代价的绝对值更小,代表利润更小),则可以剪枝。-32(利润32)比-38(利润38)差,但通常我们直接比较代价值,若cost > upper则剪枝。-32 > -38成立,所以理论上此节点可考虑剪枝,但教程中保留了它,可能是为了演示或计算细节有差异,我们遵循教程流程。教程中表述为“is it greater than that no”,我们按此继续。


第三步:LC策略选择下一个节点
当前活节点有:节点A(代价-38)、节点B(代价-32)。
根据LC(最小代价优先)策略,选择代价最小的节点,即节点A(代价-38)进行扩展。



第四步:扩展节点A(包含物品1)
从节点A分支,产生两个子节点:在已包含物品1的基础上,选择包含或不包含物品2。
节点A1:包含物品1和2
- 已选利润:20,已用重量:6。
- 代价:剩余容量9。贪心加物品4(利润18,重9),恰好装满。代价
- (20+18) = -38。 - 上界:同样操作,上界
-38。 - 代价等于当前
upper(-38),保留。

节点A2:包含物品1,不包含物品2
- 已选利润:10,已用重量:2。
- 代价:剩余容量13。贪心加物品4(利润18,重9),剩余4;剩余容量4可加物品3的2/3(利润
12*(4/6)=8)。总利润10+18+8=36。代价-36。 - 上界:不允许分割。加物品4(利润18,重9),剩余4;物品3重6>4,无法完整装入;物品2被排除。总利润
10+18=28。上界-28。 - 代价
-36与当前upper(-38)比较?-36 > -38成立,但教程中再次选择保留。我们继续。



第五步:继续LC策略选择

当前活节点:节点B(-32)、节点A1(-38)、节点A2(-36)。
最小代价节点是节点A1(-38)。扩展它。


第六步:扩展节点A1(包含物品1和2)

从节点A1分支,产生两个子节点:在已包含物品1和2的基础上,选择包含或不包含物品3。
- 已选利润:20,已用重量:6。
节点A1a:包含物品1,2,3
- 总重量:6+6=12,剩余容量3。
- 代价:剩余容量3无法完整装入物品4(重9)。可以装入物品4的 3/9(利润
18*(3/9)=6)。总利润20+12+6=38。代价-38。 - 上界:不允许分割。物品4无法完整装入,故总利润
20+12=32。上界-32。 - 该节点代价
-38,等于当前upper。
节点A1b:包含物品1,2,不包含物品3
- 总重量:6,剩余容量9。
- 代价:剩余容量9恰好完整装入物品4(利润18)。总利润
20+18=38。代价-38。 - 上界:同样操作,上界
-38。 - 我们发现节点A1b的上界是-38,比当前全局上界
upper(-38)更优吗?相等。但注意,这是一个可行解(所有物品决策已定,且总重量15恰好装满)。我们将upper更新为这个更具体的上界值-38。



第七步:剪枝


更新全局上界 upper 为 -38 后,检查其他活节点:
- 节点B:代价
-32。比较-32 > upper (-38)?-32 > -38为 True。这意味着节点B所能达到的最好可能利润(32)已经低于当前找到的可行解利润(38)。因此,剪枝节点B。 - 节点A2:代价
-36。比较-36 > upper (-38)?-36 > -38为 True。同理,剪枝节点A2。 - 节点A1a:上界
-32,-32 > -38为 True,剪枝。




第八步:探索剩余节点并得出结论
现在剩下的活节点是 节点A1b。它已经是一个叶子节点(所有物品决策已定:物品1选,物品2选,物品3不选,物品4选)。我们将其标记为答案节点。



因此,最优解是:
- 物品1:放入(利润10,重量2)
- 物品2:放入(利润10,重量4)
- 物品3:不放入(利润0,重量0)
- 物品4:放入(利润18,重量9)

总利润:10 + 10 + 0 + 18 = 38
总重量:2 + 4 + 0 + 9 = 15(等于背包容量)





总结 📝



本节课中,我们一起学*了如何使用LC(最小代价)分支限界法来解决0/1背包问题。我们首先将最大化问题通过利润取负转化为最小化问题。然后,通过构建状态空间树,为每个节点计算代价(允许分数物品的乐观估计)和上界(不允许分数物品的乐观估计)。利用全局上界和LC优先探索策略,我们有效地剪去了大量不可能产生更优解的子树,最终找到了最优解:放入物品1、2和4,获得最大利润38。分支限界法通过智能的界限计算和搜索顺序,避免了穷举所有可能性,在解决组合优化问题时非常高效。


071:旅行商问题 - 分支限界法 🧳➡️


在本节课中,我们将学*如何使用分支限界法解决旅行商问题。我们将首先理解问题本身和分支限界法的基本思路,然后通过一个具体的例子,一步步演示如何应用该方法来找到访问所有城市并返回起点的最短路径。
问题概述
旅行商问题描述如下:给定一个带权重的图,我们需要找到一条从起点出发,访问图中所有顶点恰好一次,最后返回起点的最短路径。通常,我们将起点设为顶点1。



例如,对于一个有5个顶点的图,我们需要找到一条访问顶点2、3、4、5并返回顶点1的最短路径。问题的输入通常是一个邻接矩阵,表示顶点之间的距离或成本。



分支限界法 vs. 回溯法



在深入分支限界法之前,我们先理解它与回溯法的区别。两者都使用状态空间树来探索所有可能的解,但策略不同。





回溯法采用深度优先搜索生成完整的状态空间树,然后比较所有完整路径的成本以找到最优解。这种方法会探索所有可能性,对于优化问题(如寻找最小成本)可能效率较低。


分支限界法则采用广度优先或最小成本优先的策略。它在生成树节点的同时,计算每个节点的成本下界。如果某个节点的成本下界已经超过当前已知的最优解(上界),则“剪枝”——不再探索该节点的子树。这样可以避免生成大量无希望的节点,从而更高效地找到最优解。

简单来说,回溯法是“先全部找出来,再挑最好的”;分支限界法是“边找边淘汰,直奔最好的”。

核心步骤与公式







分支限界法解决旅行商问题的核心在于归约矩阵和成本计算。




- 归约矩阵:通过归约操作,我们可以得到一个矩阵,其每一行和每一列都至少有一个0。归约成本代表了路径成本的一个下界。
- 行归约:对于每一行,减去该行的最小值。行归约成本
R为所有行最小值之和。- 公式:
R = Σ(第i行的最小值)
- 公式:
- 列归约:对于归约后的矩阵,检查每一列,减去该列的最小值(如果最小值不是0)。列归约成本
C为所有列最小值之和。- 公式:
C = Σ(第j列的最小值)
- 公式:
- 总归约成本:
lower_bound = R + C。这个值代表了从当前状态出发,完成整个回路所需成本的最小可能值。
- 行归约:对于每一行,减去该行的最小值。行归约成本




- 节点成本计算:在状态空间树中,每个节点代表一条部分路径。其成本计算如下:
- 成本 = 父节点成本 + 选择的边的实际权重 + 新矩阵的归约成本(如果有)。
- 公式:
cost(node) = cost(parent) + weight(i, j) + reduction_cost(new_matrix) - 其中,
weight(i, j)是从顶点i到顶点j的边的权重。

实例详解




让我们通过一个具体的例子来演示整个过程。假设我们有如下4个顶点的图的邻接矩阵(本例中实际演示了5个顶点的计算,但原理完全相同):

原始矩阵(示例):
1 2 3 4
1 ∞ 10 17 15
2 10 ∞ 20 25
3 17 20 ∞ 30
4 15 25 30 ∞
(注:为简化,教程正文使用4顶点示例说明逻辑,但下方详细计算遵循原视频的5顶点数据步骤。)
步骤1:初始归约





首先,我们对初始矩阵进行归约,得到初始下界。
- 行归约:每行减去最小值(例如第1行最小值10),得到行归约成本。
- 列归约:在行归约后的矩阵中,每列减去最小值(如果非零),得到列归约成本。
- 初始下界 = 行归约成本 + 列归约成本。假设我们计算得到初始下界为 25。这意味着任何完整回路的成本至少为25。
步骤2:构建状态空间树



根节点代表起点(顶点1),其成本为初始下界25。
从根节点出发,我们可以选择下一个访问的顶点(2, 3, 4, 5)。这就产生了第一层子节点。






以下是计算每个子节点成本的方法:
- 选择一条边,例如
1->2。 - 创建新矩阵:
- 将矩阵的第1行和第2列所有元素设为∞(因为已经离开1并到达2,不能再从1出发或直接进入2)。
- 将
2->1也设为∞(防止直接返回起点形成小圈)。
- 检查新矩阵是否需要归约(即是否每行每列都有0)。如果需要,则进行归约,并计算归约成本。
- 计算节点成本:
cost(1->2) = 父节点成本(25) + 边权(weight(1,2)) + 新矩阵归约成本(0或某个值)。- 假设我们计算出:
cost(1->2)=35,cost(1->3)=53,cost(1->4)=31,cost(1->5)=50。
- 假设我们计算出:
步骤3:选择与扩展节点


在分支限界法中,我们通常选择成本最低的活节点进行扩展(最小成本优先策略)。目前成本最低的节点是 1->4(成本31)。
我们扩展这个节点,考虑从顶点4出发,可以访问的剩余顶点(2, 3, 5)。为每个选择创建新的子节点(例如 1->4->2, 1->4->3, 1->4->5),并按照上述方法计算它们的成本。





步骤4:重复与剪枝




重复以下过程:
- 从所有活节点中选出成本最低的节点。
- 扩展它,生成其子节点并计算成本。
- 如果扩展后到达了叶子节点(即访问了所有顶点),我们就得到了一个完整的回路成本。此时,我们更新当前最优解(上界)。例如,第一个找到的完整回路成本是28。
- 关键剪枝:比较所有活节点的成本下界与当前上界。如果某个节点的成本下界 >= 当前上界,则说明从该节点继续搜索不可能得到比当前已知更好的解,因此将该节点“杀死”(剪枝),不再扩展。



步骤5:得到最优解



最终,当所有活节点都被扩展或剪枝后,当前的上界对应的路径就是全局最优解。




在我们的例子中,算法最终找到的最优路径是 1 -> 4 -> 2 -> 5 -> 3 -> 1,总成本为 28。所有其他节点的成本都大于或等于28,因此都被剪枝,验证了28是最优解。
总结


本节课我们一起学*了如何使用分支限界法解决旅行商问题。

我们首先理解了旅行商问题是寻找访问所有点并返回起点的最短回路。接着,我们对比了回溯法与分支限界法的区别,认识到分支限界法通过“边计算成本边剪枝”的策略,在解决优化问题上更具效率。


核心在于归约矩阵来获得成本下界,并通过状态空间树系统地探索路径。在探索过程中,我们始终优先扩展当前成本最低的节点,并利用不断更新的最优解上界来剪掉不可能更优的分支,从而高效地定位到全局最优解。
通过具体的计算示例,我们一步步演练了归约、成本计算、节点扩展和剪枝的全过程。最终,分支限界法帮助我们找到了总成本为28的最短回路。这种方法虽然步骤稍多,但能有效避免组合爆炸,是解决NP难问题的一种实用策略。
072:NP难与NP完全问题概述





在本节课中,我们将要学*计算机科学中一个核心且常令人困惑的主题:NP难与NP完全问题。我们将从算法的时间复杂度分类入手,逐步理解为何需要研究这些问题,并解释非确定性算法、P与NP类、归约以及NP完全等核心概念。课程的目标是让初学者能够清晰地把握这个复杂领域的整体框架和基本思想。

算法的时间复杂度分类


我们已知的算法可以根据其运行时间大致分为两类。
以下是多项式时间算法,其运行时间可以表示为输入规模n的多项式函数,例如 O(n)、O(n log n) 或 O(n²)。
- 搜索算法:如线性搜索
O(n)和二分搜索O(log n)。 - 排序算法:如冒泡排序
O(n²)和归并排序O(n log n)。 - 图算法:如单源最短路径(Dijkstra算法)、最小生成树(Prim或Kruskal算法)。
- 其他:如最优合并模式、哈夫曼编码等。
以下是指数时间算法,其运行时间随输入规模n呈指数级增长,例如 O(2ⁿ) 或 O(n!)。
- 0/1背包问题
- 旅行商问题
- 图着色问题
- 哈密顿回路问题


指数时间算法即使对于中等规模的输入也极为耗时。因此,一个核心的研究目标是:为这些目前只有指数时间算法的问题,寻找多项式时间的解决方案。即使对于已有的多项式时间算法,我们也希望找到更快的版本(例如,将排序从 O(n log n) 优化到 O(n))。
然而,迄今为止,尚未有人成功为上述指数时间问题找到多项式时间算法。当直接求解遇到困难时,研究者们建立了一个框架来指导相关研究,这个框架的核心就是 NP难 和 NP完全 的概念。


研究框架的两个核心目标





这个研究框架主要围绕两个目标展开:
- 建立问题间的联系:当我们无法单独解决每个问题时,至少可以证明这些问题在本质上相似。这样,如果其中一个问题被多项式时间算法解决,其他相关问题也能迎刃而解。我们无需对每个问题单独研究。
- 撰写非确定性算法:当我们无法写出确定性的多项式时间算法时,可以尝试先写出非确定性的多项式时间算法,为未来的研究保留思路和基础。




接下来,我们将首先深入探讨第二个目标:什么是非确定性算法。

理解非确定性算法与P/NP类
我们通常编写的算法是确定性的,即每条语句的执行方式和结果都是明确可知的。而非确定性算法则包含一些我们目前“不知道如何高效实现”的步骤,这些步骤被暂时留白,并假设它们能在常数时间内“神奇地”给出答案。




以下是一个非确定性搜索算法的伪代码示例,用于在数组 A[1..n] 中查找关键字 key:
// 非确定性搜索算法
j = choice(A) // 非确定性语句:假设能瞬间“猜中”key所在的索引j
if key == A[j] then
success // 搜索成功
else
fail // 搜索失败(key不在数组中)
在这个算法中,choice、success、fail 被视为非确定性语句,并假设它们仅消耗 O(1) 时间。因此,整个算法的时间复杂度是 O(1),这比我们已知最快的确定性搜索算法(二分搜索 O(log n))还要快。这里的“魔法”在于 choice 语句,它未经任何比较就直接给出了正确答案的位置。今天它是魔法,未来一旦我们发现了实现它的具体逻辑,它就会变成一个确定性的 O(1) 算法。撰写非确定性算法的意义在于保存研究进展,让后续研究者可以尝试将非确定性的部分转化为确定性的。


基于算法是确定性还是非确定性,以及其时间复杂度是否为多项式,我们可以定义两个重要的复杂度类:
-
P类 (Polynomial Time):所有能在多项式时间内被确定性算法解决的问题的集合。例如:线性搜索、归并排序、最短路径问题。
- 公式表示:
P = { L | L 可以被确定性图灵机在多项式时间内判定 }
- 公式表示:
-
NP类 (Nondeterministic Polynomial Time):所有能在多项式时间内被非确定性算法解决的问题的集合。注意,NP不是“非多项式”,而是“非确定性多项式”。
- 公式表示:
NP = { L | L 可以被非确定性图灵机在多项式时间内判定 }
- 公式表示:




显然,所有P类问题(确定性多项式)都可以通过一个“不去使用非确定性选择”的非确定性算法来解决,因此 P ⊆ NP。一个重要的未解问题是:P 是否等于 NP? 即,所有非确定性多项式时间可解的问题,是否实际上也存在确定性的多项式时间算法?这被认为是计算机科学中最重大的开放性问题之一。


上一节我们介绍了通过撰写非确定性算法来推进研究。本节中,我们来看看如何通过建立问题间的联系来集体攻关。


问题归约与NP难问题


为了建立指数时间问题之间的联系,我们需要一个“基准”问题。这个被选中的问题是布尔可满足性问题。


布尔可满足性问题:给定一个合取范式布尔公式,判断是否存在一组对其变量的真值赋值,使得整个公式的值为真。
- 示例公式:
F = (x1 ∨ x2) ∧ (¬x1 ∨ x2 ∨ ¬x3) - 问题:是否存在对
x1, x2, x3的赋值(每个变量为真或假),使得F = true? - 暴力解法:尝试所有
2ⁿ种赋值组合,是指数时间算法。


SAT问题本身也是一个指数时间问题。关键思想在于:如果我们能证明某个难题L可以被SAT问题“归约”,并且这个归约过程本身是多项式时间的,那么只要SAT能用多项式时间解决,L也就能用多项式时间解决。

归约:将问题A的任意实例,在多项式时间内转化为问题B的一个实例,并且两个实例的答案(是/否)保持一致。
- 表示为:
A ≤ₚ B(A可多项式时间归约到B)。 - 意义:如果B存在多项式时间算法,那么A也存在多项式时间算法(先归约,再用B的算法解)。


我们以0/1背包问题和SAT为例,直观理解它们的联系。两者在寻找解时,都需要在 2ⁿ 种可能性(变量赋值组合 / 物品选择组合)构成的状态空间树中搜索。虽然检查每个“叶子”(即一个候选解)的标准不同(SAT检查公式是否满足,背包检查利润是否最大且重量不超限),但搜索的空间结构是相似的。这种结构上的相似性暗示了它们内在的难度可能是相通的。






通过严谨的归约技术(而不仅仅是结构类比),我们可以证明SAT可以归约到0/1背包问题。如果这一点被证明,那么:
- 只要SAT有多项式时间算法,0/1背包问题也就有了多项式时间算法。
- 由于SAT本身是极难的问题,任何能被SAT归约的问题,其难度至少和SAT一样“难”。这类问题被称为 NP难问题。




NP难问题:如果问题H满足 SAT ≤ₚ H(或者任何已知的NP难问题 ≤ₚ H),那么H就是一个NP难问题。NP难问题至少和SAT一样难,但不一定属于NP类(即不一定有非确定性多项式算法)。


NP完全问题:NP难与NP的交集


我们已经知道SAT是NP难的。此外,SAT也存在非确定性多项式时间算法(可以非确定性地“猜”出一组赋值然后验证),因此 SAT ∈ NP。


既属于NP类(有非确定性多项式算法),又是NP难的问题,被称为 NP完全问题。SAT是第一个被证明的NP完全问题。



NP完全问题:一个问题L是NP完全的,当且仅当:
L ∈ NPL ∈ NP-Hard(即,对于任意A ∈ NP,都有A ≤ₚ L)


NP完全问题是NP类中“最难”的问题。如果任何一个NP完全问题被证明存在多项式时间的确定性算法,那么 所有NP问题 都将存在多项式时间确定性算法,即 P = NP。



总结与展望
本节课中我们一起学*了NP难与NP完全问题的核心概念。让我们总结一下关键点:
- 研究动机:为指数时间问题寻找多项式时间算法。
- 两个工具:撰写非确定性算法保存思路;通过多项式时间归约建立问题间的联系。
- 复杂度类:
- P:确定性多项式时间可解。
- NP:非确定性多项式时间可解。P ⊆ NP。
- NP难:至少和SAT一样难的问题(通过归约定义)。
- NP完全:既属于NP,又是NP难的问题。它们是NP类中最难的问题。
- 核心关系:如果任何一个NP完全问题被证明属于P,则 P = NP。这就是著名的库克定理所阐述的内容。

对于研究者而言,要证明一个新问题是“困难的”,标准流程是:
- 第一步:证明它是NP难的(通常通过将一个已知的NP完全问题,如SAT,归约到该问题)。
- 第二步:证明它属于NP(为其构造一个非确定性多项式时间算法,或证明其解能在多项式时间内被验证)。
如果两步都完成,则该问题就是NP完全的。



理解NP完全性理论的意义在于,当你遇到一个看似非常困难的问题时,可以尝试证明它是NP完全的。如果证明成功,那么你就不必再徒劳地寻找多项式时间精确算法,转而可以寻求*似算法、启发式方法或针对特殊情况的算法。这就是这个理论框架对算法研究和实践的核心指导价值。
073:NP难图问题 - 团判定问题 🧩


在本节课中,我们将要学*一个重要的NP难图问题——团判定问题。我们将从基本概念入手,理解什么是团,然后区分判定问题和优化问题。接着,我们将探讨NP难问题的含义,并学*如何证明一个问题是NP难的。最后,我们将通过一个具体的例子,证明团判定问题本身是NP难的。

什么是团?🔍


上一节我们介绍了课程目标,本节中我们来看看第一个核心概念:团。

首先,我们需要理解什么是完全图。如果一个图有 n 个顶点,并且每个顶点都与其他所有顶点相连,那么这个图就是一个完全图。一个具有 n 个顶点的完全图,其边的数量为 n * (n-1) / 2。


公式: 完全图的边数 = n * (n-1) / 2
现在,我们来看团的定义。团是一个图的子图,并且这个子图本身是一个完全图。换句话说,团是原图中一个顶点集合,这个集合中任意两个顶点之间都有边相连。
判定问题 vs. 优化问题 ⚖️


理解了团的概念后,我们来区分两类不同的问题:判定问题和优化问题。




- 判定问题:这类问题的答案通常是“是”或“否”。例如,“图中是否存在一个大小为 K 的团?”。
- 优化问题:这类问题要求找出一个最优值(最大或最小)。例如,“图中最大的团是多大?”。



代码描述:
# 判定问题示例
def is_clique_of_size_k(graph, k):
# 返回 True 或 False
pass


# 优化问题示例
def find_maximum_clique_size(graph):
# 返回一个整数(最大团的大小)
pass
团判定问题(Clique Decision Problem, CDP)就是一个典型的判定问题。

什么是NP难问题?🤔


我们已经明确了要研究的问题类型。那么,为什么说团判定问题是一个“NP难”的图问题呢?
简单来说,NP难问题是指那些至少和NP中最难的问题一样难的问题。目前没有已知的多项式时间算法可以解决所有NP难问题的实例。如果一个判定问题是NP难的,那么其对应的优化问题通常也是NP难的。

如何证明一个问题是NP难的?🔨


要证明一个新问题(记作 L2)是NP难的,我们需要使用“规约”技术。以下是证明步骤:
- 选择一个已知的NP难问题(记作 L1)。
- 展示如何将 L1 的任意实例,在多项式时间内,转化为 L2 的一个实例。
- 证明这个转化是有效的:即 L1 实例的答案为“是”,当且仅当对应的 L2 实例的答案也为“是”。



这意味着,如果我们能在多项式时间内解决 L2,那么我们也能在多项式时间内解决 L1。由于 L1 是NP难的(公认没有多项式解法),因此 L2 也必须是NP难的。

我们将使用已知的NP难问题——布尔可满足性问题(SAT问题)——来证明团判定问题是NP难的。

证明:SAT问题可规约到团判定问题 🧠

现在,让我们进入核心环节,证明SAT问题可以规约到团判定问题,从而证明后者是NP难的。
我们将从一个具体的SAT公式例子开始,展示如何构造一个对应的图。
给定一个SAT公式:
假设我们有一个合取范式公式 F,包含三个变量(x1, x2, x3)和三个子句(K=3):
F = (x1 ∨ x2) ∧ (¬x1 ∨ ¬x2) ∧ (x1 ∨ x3)

构造对应的图 G:
以下是构造规则:
- 顶点:为公式 F 中每个子句里的每个文字(变量或其否定)创建一个顶点。顶点标记为
(文字, 子句号)。在我们的例子中,顶点为:(x1,1),(x2,1),(¬x1,2),(¬x2,2),(x1,3),(x3,3)。 - 边:连接不同子句的顶点,但有一个例外。
- 规则:连接顶点
(a, i)和(b, j),当且仅当满足以下两个条件:i ≠ j(顶点来自不同的子句)。b ≠ ¬a(两个文字不互为否定)。
- 规则:连接顶点


根据以上规则,我们可以画出图 G。可以验证,在这个构造出的图 G 中,存在一个大小为 3(等于子句数 K)的团,例如顶点 {(x2,1), (¬x1,2), (x3,3)}。
为什么这能证明规约有效?
- 如果 F 可满足:则每个子句中至少有一个文字为真。从每个子句中选取一个为真的文字对应的顶点,这些顶点之间必然有边相连(因为它们来自不同子句且不互为否定),从而构成一个大小为 K 的团。
- 如果 G 有一个大小为 K 的团:因为这个团中任意两个顶点都相连,根据边的构造规则,它们必然来自不同的子句且代表的文字不互为否定。因此,我们可以将团中顶点对应的文字赋值为“真”,这个赋值会使每个子句为真,从而使整个公式 F 为真。






我们成功地将一个SAT问题实例转化为了一个团判定问题实例,并且两个问题的答案一致性得到了保证。这个转化过程(构造图)显然可以在多项式时间内完成。因此,SAT问题可以规约到团判定问题。

总结 📚

本节课中我们一起学*了:
- 团的定义:一个图中完全的子图。
- 判定问题(回答是/否)与优化问题(寻找最优值)的区别。
- NP难问题的基本概念:目前没有已知高效通用解法的问题类别。
- 证明问题为NP难的通用方法:通过多项式时间规约从一个已知NP难问题转化而来。
- 团判定问题是NP难的证明:我们演示了如何将 布尔可满足性问题(SAT) 的实例,在多项式时间内规约为团判定问题的实例,从而证明了后者是NP难的。

要证明团判定问题是NP完全的,还需要证明它本身也在NP类中(即存在一个非确定性图灵机可以在多项式时间内验证其解),这通常通过构造一个验证算法来完成。但可以确定的是,团判定问题是一个经典的NP难(也是NP完全)问题。
074:KMP字符串匹配算法 🧵



在本节课中,我们将要学*KMP(Knuth-Morris-Pratt)字符串匹配算法。该算法用于解决一个核心问题:给定一个文本字符串和一个模式字符串,判断模式是否存在于文本中,如果存在则找出其起始位置。我们将从最基础的朴素算法开始,理解其局限性,然后逐步深入KMP算法的核心思想、关键概念和具体工作流程。


朴素算法及其局限性 🔍


上一节我们介绍了字符串匹配的基本问题,本节中我们来看看最直观的解决方法——朴素算法。


朴素算法的工作方式非常简单:将模式字符串与文本字符串的起始位置对齐,逐个字符进行比较。如果所有字符都匹配,则找到模式;如果出现不匹配,则将模式向右移动一位,然后从文本的下一个位置重新开始比较。


以下是朴素算法的一个简单示例:
文本: A B C D E F G
模式: D E F
- 从文本位置1开始比较:A与D不匹配。
- 模式右移一位,从文本位置2开始比较:B与D不匹配。
- 模式继续右移,直到在文本位置4找到完全匹配。

然而,朴素算法存在一个明显的缺点。考虑以下情况:
文本: A B A B A B A B C
模式: A B A B C
当比较到第5个字符(文本的‘A’与模式的‘C’)出现不匹配时,朴素算法会将模式右移一位,并将文本的指针I回溯到上一次起始位置的下一位(即从‘B’重新开始)。这意味着之前已经比较过的字符(如开头的‘A B’)会被重复比较,导致效率低下。




在最坏情况下(例如文本为“AAAAAAAB”,模式为“AAAAB”),朴素算法的时间复杂度为 O(m * n),其中m是模式长度,n是文本长度。这种回溯文本指针I的行为是主要的性能瓶颈。
KMP算法的核心思想 💡
上一节我们看到了朴素算法因文本指针回溯而效率低下,本节中我们来看看KMP算法如何通过“智能”地移动模式指针来避免回溯。



KMP算法的核心思想是:当出现不匹配时,利用模式字符串本身的信息,决定下一次匹配应该从模式的哪个位置开始,而无需回溯文本指针。它通过预先计算一个“部分匹配表”(也称为LPS数组或π表)来获得这个信息。


该表的关键在于寻找模式的前缀和后缀的最长公共长度。
- 前缀:指从模式开头开始的连续子串(例如“A”, “AB”, “ABC”)。
- 后缀:指以模式结尾为结束的连续子串(例如“C”, “BC”, “ABC”)。

对于模式中的每个位置i,LPS[i]的值表示模式串从开头到i的这个子串中,最长的、相等的真前缀和真后缀的长度。

以下是构建LPS表的几个例子:
模式: A B A B C
LPS: [0, 0, 1, 2, 0]
解释:
位置1(‘A’): 子串“A”,无真前缀/后缀,长度为0。
位置2(‘B’): 子串“AB”,前缀{A},后缀{B},无公共,长度为0。
位置3(‘A’): 子串“ABA”,前缀{A, AB},后缀{A, BA},公共最长是“A”,长度为1。
位置4(‘B’): 子串“ABAB”,前缀{A, AB, ABA},后缀{B, AB, BAB},公共最长是“AB”,长度为2。
位置5(‘C’): 子串“ABABC”,无公共真前缀/后缀,长度为0。

模式: A A A A B
LPS: [0, 1, 2, 3, 0]
解释:
位置1: “A” -> 0
位置2: “AA” -> 公共“A”,长度1
位置3: “AAA” -> 公共“AA”,长度2
位置4: “AAAA” -> 公共“AAA”,长度3
位置5: “AAAAB” -> 0

这个LPS表告诉我们,当在某个位置匹配失败时,模式串的前面有多少个字符是已经“自我匹配”好的,我们可以直接将模式指针J移动到LPS[J-1]的位置继续比较,而文本指针I保持不动。

KMP算法工作流程 🚀
上一节我们介绍了LPS表的概念,本节中我们结合一个具体例子,来看看KMP算法是如何利用这张表进行匹配的。


假设我们有:
文本(T): A B A B A B A B C A B A B A B C
模式(P): A B A B C
LPS: [0, 0, 1, 2, 0]
我们使用两个指针:i(指向文本当前字符)和j(指向模式当前待匹配字符,初始为0)。
以下是匹配过程的步骤分解:
- 初始化:
i = 0,j = 0。比较T[0](‘A’) 和P[0](‘A’),匹配。 - 匹配时:
i和j同时加1。继续比较T[1](‘B’) 和P[1](‘B’),匹配。 - 继续匹配:
i=2, j=2,T[2](‘A’) 和P[2](‘A’) 匹配。i=3, j=3,T[3](‘B’) 和P[3](‘B’) 匹配。 - 出现不匹配:
i=4, j=4,T[4](‘A’) 和P[4](‘C’) 不匹配。- 朴素算法会将
i回溯到1,j重置为0。 - KMP算法查询
LPS表:LPS[j-1] = LPS[3] = 2。这意味着模式串前4个字符“ABAB”中,有长度为2的前缀“AB”也是后缀。因此,我们可以将j设置为2,表示模式串开头的“AB”已经和文本i指针前的“AB”匹配好了,接下来直接从模式的第3个字符(P[2],即‘A’)开始与T[4]比较。 j更新为2,i保持为4。
- 朴素算法会将
- 继续比较:比较
T[4](‘A’) 和P[2](‘A’),匹配。i和j加1。 - 再次不匹配:
i=5, j=3,T[5](‘B’) 和P[3](‘B’) 匹配。i=6, j=4,T[6](‘A’) 和P[4](‘C’) 不匹配。- 再次查询
LPS表:LPS[3] = 2。将j更新为2。
- 再次查询
- 继续流程:重复此过程。最终,当
j等于模式长度m时,表示找到了完全匹配。
在整个过程中,文本指针i从未回溯,它只从前往后扫描了一遍文本。模式指针j根据LPS表在模式串上“跳跃”。


复杂度分析与总结 📊




本节课中我们一起学*了KMP字符串匹配算法。我们来总结一下它的核心优势和时间复杂度。




KMP算法通过预先计算模式串的LPS(最长公共前后缀)表,在匹配失败时避免了文本指针的回溯。其工作流程可以概括为:
- 预处理:构建模式的
LPS数组,时间复杂度为 O(m)。 - 搜索:遍历文本字符串一次,在每次失配时根据
LPS表调整模式指针。由于文本指针i只增不减,此过程时间复杂度为 O(n)。

因此,KMP算法的总时间复杂度为 O(m + n),这比朴素算法的 O(m * n) 要高效得多,尤其是在处理长文本和具有重复部分的模式时优势明显。



总而言之,KMP算法的精髓在于利用已经匹配过的信息,通过模式本身的特性来指导后续的匹配,从而实现了线性时间的字符串搜索。
075:Rabin-Karp 字符串匹配算法 🔍




在本节课中,我们将要学* Rabin-Karp 算法。这是一种用于在文本中查找特定模式(子串)的模式匹配或字符串匹配算法。我们将从基本思想开始,探讨其最坏情况,学*如何改进,并最终了解其实际算法。


基本思想 💡


假设我们有一段给定的文本和一个给定的模式。我们的任务是找出这个模式是否存在于文本中。


有多种字符串匹配算法,Rabin-Karp 是其中之一。它的核心思想是避免逐个字符地比较模式与文本的每一个可能位置。
上一节我们介绍了问题的定义,本节中我们来看看 Rabin-Karp 算法的核心策略。

我们不想逐个比较字母。对于一个包含多个字母的模式,我们能否将其转换为一个单一的数值来进行快速比较呢?答案是肯定的,我们可以通过一个哈希函数来实现。
首先,我们为字母分配数值代码。为了简化理解,我们可以自定义一套简单的映射(在实际应用中,通常会使用 ASCII 码)。
以下是字母到数值的映射示例:
- A -> 1
- B -> 2
- C -> 3
- D -> 4
- ... (以此类推,例如到 J -> 10)
对于一个模式,例如 “AAB”,我们将其字母代码相加:1 + 1 + 2 = 4。我们称这个结果值为模式的哈希值,计算这个值的函数称为哈希函数。


为了在文本中查找模式,我们以滑动窗口的方式,计算文本中每个与模式等长的子串的哈希值。


以下是查找过程的步骤:
- 计算模式 “AAB” 的哈希值,得到
hash_pattern = 4。 - 从文本开头取前 3 个字符 “AAA”,计算其哈希值:
1 + 1 + 1 = 3。 - 比较
3和4,不匹配。说明 “AAA” 不是 “AAB”。 - 将窗口向右滑动一位,计算新窗口 “AAB” 的哈希值。我们可以用“滚动哈希”技巧高效计算:从旧哈希值
3中减去滑出字符 ‘A’ 的代码1,再加上滑入字符 ‘B’ 的代码2,得到新哈希值3 - 1 + 2 = 4。 - 比较
4和4,哈希值匹配!此时,我们需要进行最终确认,逐个字符比较窗口 “AAB” 和模式 “AAB”,确认完全匹配。至此,我们在文本中找到了模式。

这种“滚动哈希”功能允许我们在常数时间内高效计算下一个子串的哈希值,这是 Rabin-Karp 算法高效的关键。

简单哈希函数的缺陷与伪命中 🚨
上一节我们看到了 Rabin-Karp 算法在理想情况下的工作流程。然而,我们之前使用的简单相加哈希函数存在一个严重缺陷。


考虑文本 “D C A C A A B A A B A” 和模式 “D B A”。其代码和为 4 + 2 + 1 = 7。
当我们用滑动窗口计算文本子串的哈希值时,可能会发现多个子串(如 “C A C”, “A C A”, “C A A”)的哈希值也恰好是 7。每次哈希值匹配时,我们都需要进行耗时的逐字符验证,但最终发现它们都不是真正的模式。


这些哈希值匹配但实际内容不匹配的情况被称为伪命中。在简单哈希函数下,伪命中可能非常频繁。
在最坏情况下,如果每个文本子串都产生伪命中,算法将不得不对每个位置都进行逐字符比较,导致时间复杂度退化到 O(m * n),其中 m 是模式长度,n 是文本长度。这远非我们期望的平均情况 O(n - m + 1)。
伪命中的根源在于哈希函数过于简单,冲突(不同字符串产生相同哈希值)概率太高。为了减少伪命中,我们需要一个更强、更独特的哈希函数。
Rabin-Karp 指纹哈希函数 🔑
为了克服伪命中的问题,Rabin 和 Karp 提出了一种更强大的哈希函数,通常称为Rabin 指纹。


其核心思想是将字符串视为一个某进制下的数字。假设字母表大小为 d(例如,如果我们只考虑 A-J,则 d=10;考虑所有小写字母,则 d=26;考虑所有 ASCII 码,则 d=127)。
对于一个长度为 m 的模式 P[1..m],其哈希值计算公式为:
hash(P) = (P[1] * d^(m-1) + P[2] * d^(m-2) + ... + P[m] * d^0) mod q
其中,P[i] 是字符的数值代码,q 是一个选定的模数(通常是一个大素数),用于控制哈希值的范围,防止溢出。
让我们用之前的缺陷例子来演示。模式 “D B A”,设 d=10,代码 D=4, B=2, A=1。
hash(“DBA”) = (4 * 10^2) + (2 * 10^1) + (1 * 10^0) = 400 + 20 + 1 = 421

现在计算文本 “D C A ...” 的第一个窗口 “D C A” 的哈希值 (D=4, C=3, A=1):
hash(“DCA”) = (4*10^2) + (3*10^1) + (1*10^0) = 400 + 30 + 1 = 431
431 不等于 421,没有命中。无需验证。
关键在于,滚动哈希的计算依然高效。假设旧窗口 “T[i...i+m-1]” 的哈希值为 H_old,那么新窗口 “T[i+1...i+m]” 的哈希值 H_new 可以通过以下公式计算:
H_new = [d * (H_old - T[i] * d^(m-1)) + T[i+m]] mod q
这个计算可以在常数时间内完成。
通过使用这种进制表示的哈希函数,不同字符串产生相同哈希值(冲突)的概率大大降低,从而显著减少了伪命中的发生,使得算法在平均情况下能够保持接* O(n) 的性能。
算法总结与注意事项 📝





本节课中我们一起学*了 Rabin-Karp 字符串匹配算法。

我们首先了解了算法的基本思想:通过比较哈希值来快速过滤掉不可能匹配的文本位置,仅在哈希值匹配时才进行精确的字符比较。





接着,我们指出了使用简单哈希函数(如字符代码和)会导致大量的伪命中,从而使算法在最坏情况下变慢。最后,我们介绍了 Rabin-Karp 算法采用的Rabin 指纹哈希函数,它将字符串视为一个多进制数,并结合滚动哈希技巧,能够在保持高效计算的同时,极大降低哈希冲突的概率,使算法在平均情况下非常高效。

在实际编程实现中,有两点需要注意:
- 模运算:直接计算
d^(m-1)等项可能导致数值巨大而溢出。因此,通常会在计算过程中引入一个模数q(如一个大素数),将所有运算限制在固定范围内。哈希函数变为:hash = ( ... ) % q。滚动哈希公式也需相应调整。 - 哈希冲突:即使使用 Rabin 指纹,理论上仍然存在冲突(伪命中)的可能。因此,当哈希值匹配时,必须进行最终的逐字符比较来确认。不过,在精心选择
d和q的情况下,冲突概率极低。



Rabin-Karp 算法的优势在于其概念简单,易于实现,并且在处理多个模式匹配或二维模式匹配等扩展问题时非常灵活。它的核心启示是:利用数字指纹进行快速预筛选,是优化字符串比较的有效策略。
076:AVL树 - 插入与旋转 🎯
在本节课中,我们将要学*AVL树。AVL树是一种高度平衡的二叉搜索树,它通过旋转操作来维持树的平衡,从而保证搜索、插入和删除等操作的时间复杂度为O(log n)。我们将从二叉搜索树的基础开始,逐步理解其缺点、平衡因子的概念,以及四种核心的旋转操作,最后学*如何通过插入操作构建一个AVL树。

什么是二叉搜索树? 🌲
二叉搜索树是一种特殊的二叉树结构。对于树中的任意一个节点,其左子树中的所有节点值都小于该节点的值,而其右子树中的所有节点值都大于该节点的值。
公式:对于任意节点 node,满足 node.left.value < node.value < node.right.value。



这种结构使得二叉搜索树非常适用于搜索操作。搜索过程从根节点开始,根据比较结果决定向左子树还是右子树移动。


二叉搜索树的搜索示例

假设我们有一个二叉搜索树,其节点值为 [20, 10, 30, 5, 15, 25, 35]。
- 搜索成功:查找值
30。- 从根节点
20开始,30 > 20,转向右子树。 - 到达节点
30,匹配成功。共进行了2次比较。
- 从根节点
- 搜索失败:查找值
32。- 从根节点
20开始,32 > 20,转向右子树。 - 到达节点
30,32 > 30,转向右子树。 - 右子树为空,搜索失败。共进行了2次比较。
- 从根节点


搜索所需的最大比较次数取决于树的高度。因此,二叉搜索树的搜索时间复杂度与树的高度直接相关。

二叉搜索树的缺点 ⚠️

上一节我们介绍了二叉搜索树的搜索效率取决于其高度。本节中我们来看看它的主要问题。
对于同一组数据,根据插入顺序的不同,可能形成高度差异巨大的二叉搜索树。树的高度最小可以是 log₂ n,最大可以是 n。


公式:二叉搜索树的高度 h 满足 log₂ n ≤ h ≤ n。

这意味着,在最坏情况下(例如,按升序或降序插入数据),二叉搜索树会退化成一条链表,搜索时间复杂度变为 O(n),与线性搜索无异,失去了其高效搜索的优势。




创建二叉搜索树的示例

考虑两组相同但顺序不同的键值 [30, 40, 10, 50, 20, 5, 35]。
- 顺序1:
[30, 40, 10, 50, 20, 5, 35]- 形成的树比较平衡,高度约为
log₂ 7 ≈ 3。
- 形成的树比较平衡,高度约为
- 顺序2:
[50, 40, 35, 30, 20, 10, 5]- 形成的树退化成右倾链表,高度为
7。
- 形成的树退化成右倾链表,高度为
这个问题的根源在于,二叉搜索树的构建过程(逐个插入)无法控制最终树的高度。
如何改进二叉搜索树? 💡

既然问题的核心在于树的高度不平衡,那么改进的思路就是在插入或删除过程中,动态调整树的结构,使其保持平衡。这就是平衡二叉搜索树的思想,AVL树是其中最早被发明的一种。


核心思想是旋转。通过围绕特定节点进行旋转,可以将一个不平衡的子树调整为平衡的子树。


旋转的基本思想
考虑三个节点 A, B, C。假设由于插入操作,形成了 A(B(C)) 这种左左倾斜的不平衡结构(C 是 B 的左孩子,B 是 A 的左孩子)。


我们可以通过一次右旋操作来平衡它:
- 将
B提升为新的子树的根节点。 A变为B的右孩子。B原来的右孩子(如果有)变为A的左孩子。


旋转后,结构变为 B(C, A),树的高度得以降低并恢复平衡。对于右右倾斜的情况,则进行对称的左旋操作。
什么是AVL树? ⚖️



AVL树(以发明者Adelson-Velsky和Landis命名)是一种高度平衡的二叉搜索树。它通过为每个节点维护一个平衡因子来确保树的平衡性。


平衡因子定义为:一个节点的左子树高度减去其右子树高度。


公式:平衡因子(BF) = 左子树高度(h_left) - 右子树高度(h_right)


在AVL树中,要求每个节点的平衡因子只能是 -1、0 或 1。如果任何节点的平衡因子绝对值大于1,则该节点不平衡,需要通过旋转来调整。

公式:对于AVL树中任意节点 node,满足 |BF(node)| ≤ 1。
平衡因子计算示例






以下是如何计算节点平衡因子的方法(高度通常定义为从节点到最深叶子节点的边数):
- 叶子节点:左右子树高度均为0,平衡因子为
0。 - 只有一个左孩子的节点:左子树高度为1,右子树高度为0,平衡因子为
1。 - 左子树高度为2,右子树高度为1的节点:平衡因子为
2 - 1 = 1。 - 左子树高度为3,右子树高度为1的节点:平衡因子为
3 - 1 = 2(不平衡!)。





AVL树的旋转操作 🔄




当插入或删除一个节点导致某个节点的平衡因子变为 +2 或 -2 时,就需要通过旋转来恢复平衡。根据不平衡的形态,有四种基本的旋转类型。
1. LL旋转(右单旋)



触发条件:在节点 A 的左孩子(B)的左子树上插入了新节点,导致 A 的平衡因子变为 +2。这是一种“左左”不平衡。
操作:以 A 为支点,进行一次向右的顺时针旋转。
B成为新的子树根节点。A成为B的右孩子。B原来的右孩子BR成为A的左孩子。



代码/图示逻辑:
不平衡状态:
A (BF=+2)
/
B (BF=+1)
/
C

执行LL旋转(右旋)后:
B
/ \
C A



2. RR旋转(左单旋)

触发条件:在节点 A 的右孩子(B)的右子树上插入了新节点,导致 A 的平衡因子变为 -2。这是一种“右右”不平衡。
操作:以 A 为支点,进行一次向左的逆时针旋转。与LL旋转对称。
B成为新的子树根节点。A成为B的左孩子。B原来的左孩子BL成为A的右孩子。



代码/图示逻辑:
不平衡状态:
A (BF=-2)
\
B (BF=-1)
\
C




执行RR旋转(左旋)后:
B
/ \
A C

3. LR旋转(左右双旋)




触发条件:在节点 A 的左孩子(B)的右子树上插入了新节点,导致 A 的平衡因子变为 +2。这是一种“左右”不平衡。

操作:需要两次旋转。
- 先对
A的左孩子B进行一次 RR旋转(左旋),将情况转化为LL形态。 - 再对
A进行一次 LL旋转(右旋)。


也可以理解为:将 B 的右孩子 C 提升为新的根节点。

代码/图示逻辑:
不平衡状态:
A (BF=+2)
/
B (BF=-1)
\
C
第一步:对B进行RR旋转(左旋)
A
/
C
/
B



第二步:对A进行LL旋转(右旋)
C
/ \
B A



4. RL旋转(右左双旋)

触发条件:在节点 A 的右孩子(B)的左子树上插入了新节点,导致 A 的平衡因子变为 -2。这是一种“右左”不平衡。
操作:与LR旋转对称。
- 先对
A的右孩子B进行一次 LL旋转(右旋),将情况转化为RR形态。 - 再对
A进行一次 RR旋转(左旋)。




也可以理解为:将 B 的左孩子 C 提升为新的根节点。
代码/图示逻辑:
不平衡状态:
A (BF=-2)
\
B (BF=+1)
/
C
第一步:对B进行LL旋转(右旋)
A
\
C
\
B

第二步:对A进行RR旋转(左旋)
C
/ \
A B
总结四种旋转:
- LL 和 RR 是单旋。
- LR 和 RL 是双旋,由两个单旋组合而成。
- 核心:无论子树多复杂,旋转操作始终只涉及三个关键节点(失衡节点、它的孩子、它的孙子),并按照上述模式重组。

构建AVL树:逐步插入示例 🏗️




现在,我们通过一个完整的例子,看看如何通过插入操作构建一棵AVL树。插入规则是:先像普通二叉搜索树一样插入,然后从插入点向上回溯,找到第一个不平衡的祖先节点,对其进行相应的旋转。

我们按顺序插入键值:[40, 20, 10, 25, 30, 22, 50]。
- 插入40:树为空,直接作为根节点。平衡。
- 插入20:作为40的左孩子。检查40的BF:左高1,右高0,BF=1。平衡。
40(BF=1) / 20(BF=0) - 插入10:作为20的左孩子。
- 更新BF:20的BF变为1。40的左子树高度变为2,BF=2。节点40不平衡!
- 插入位置在40的左孩子的左子树,属于 LL型。
- 对40执行 LL旋转(右旋)。
- 结果:20成为根,10在左,40在右。所有节点BF为0。
20(BF=0) / \ 10 40(BF=0) - 插入25:作为40的左孩子。树保持平衡。
20(BF=-1) / \ 10 40(BF=1) / 25(BF=0) - 插入30:作为25的右孩子。
- 更新BF:25的BF=-1。40的左子树高度为2,右子树高度为0,BF=2。节点40不平衡!
- 插入位置在40的左孩子的右子树,属于 LR型。
- 对40执行 LR旋转。将30提升。
- 结果:30成为局部根,25在左,40在右。20的右孩子更新为30。
20(BF=-1) / \ 10 30(BF=0) / \ 25 40(BF=0) - 插入22:作为25的左孩子。
- 更新BF:25的BF=1。30的左子树高度为2,右子树高度为1,BF=1。40的BF=0。节点30不平衡!(它是第一个不平衡的祖先)
- 插入位置在30的左孩子的左子树?不,我们需要看导致30不平衡的“三个节点”:30(失衡点)、25(孩子)、22(孙子)。22在25的左边,所以对于30-25-22这条路径,是 左左(LL)型吗?仔细看:新节点22插在25的左边,而25是30的左孩子,所以这是“左孩子的左子树”,属于 LL型。
- 对30执行 LL旋转(右旋)。将25提升。
- 结果:25成为20的右孩子,22是25的左孩子,30是25的右孩子。
20(BF=-1) / \ 10 25(BF=0) / \ 22 30(BF=0) \ 40(BF=0) // 注意40现在是30的右孩子- 检查:现在所有节点BF绝对值均<=1,树平衡。
- 插入50:作为40的右孩子。
- 更新BF:40的BF=-1。30的BF=-1。25的右子树高度增加,BF变为-1。20的右子树高度增加,BF变为-2。节点20不平衡!
- 导致20不平衡的三个节点是:20(失衡点)、25(孩子)、30(孙子)。新节点50插在30的子树中,但我们需要看20-25-30这条路径。25是20的右孩子,30是25的右孩子,所以这是“右孩子的右子树”,属于 RR型。
- 对20执行 RR旋转(左旋)。将25提升为新的根。
- 结果:25成为整棵树的根,20在左,30在右。20的右孩子变为22(原25的左孩子),30的左孩子保持不变(是空或某个值),右孩子是40(包含新插入的50)。
25(BF=0) / \ 20(BF=0) 30(BF=-1) / \ \ 10 22 40(BF=-1) \ 50(BF=0)- 最终,我们得到了一棵平衡的AVL树。

关键点:
- 插入后必须从插入点向上回溯检查平衡因子。
- 只对第一个找到的不平衡祖先节点进行旋转。
- 一次正确的旋转可以使该子树恢复平衡,并可能同时解决上层的不平衡问题。


总结 📚
本节课中我们一起学*了AVL树,一种自平衡的二叉搜索树。

- 回顾了二叉搜索树:其搜索效率取决于树的高度,但在最坏情况下会退化成链表(高度为n)。
- 引入了平衡的需求:为了保持O(log n)的搜索效率,需要树的高度平衡。
- 定义了AVL树:通过平衡因子(
|左子树高 - 右子树高| ≤ 1)来严格定义平衡。 - 掌握了四种旋转操作:这是维持AVL树平衡的核心技术。
- LL / 右单旋:处理“左左”不平衡。
- RR / 左单旋:处理“右右”不平衡。
- LR / 左右双旋:处理“左右”不平衡。
- RL / 右左双旋:处理“右左”不平衡。
- 实践了AVL树的构建:通过逐步插入并即时旋转调整,可以构建出一棵高度平衡的AVL树。其高度h始终满足
h = O(log n),具体约为h < 1.44 * log₂(n+2),从而保证了所有操作的高效性。

AVL树通过严格的平衡条件,提供了最坏情况下的性能保证。与之相比,另一种常见的平衡树——红黑树——使用稍宽松的平衡规则,减少了旋转次数,在实际应用中(如C++ STL的map/set)也非常流行。理解AVL树是学*所有平衡树结构的重要基础。
077:B树与B+树详解 🗂️
在本节课中,我们将要学*B树和B+树。这是一个重要的主题,也是学生理解起来比较困难的主题。它们在数据库中非常有用。为了透彻理解B树和B+树,我们需要了解一系列相关概念。
以下是理解B树和B+树所需掌握的主题列表。如果你理解了所有这些内容,那么你就能明白B树和B+树是什么。很多人试图在不了解全部背景的情况下,仅仅学*插入和删除的操作步骤。因此,本教程将涵盖所有与B树和B+树相关的主题,帮助你完美地理解B树和B+树。
让我们从磁盘结构开始。

磁盘结构 💽

让我们看看磁盘结构。磁盘就像一个盘片,上面有同心圆,这些是逻辑上的圆圈,并非物理上的。
这些圆圈被称为磁道。假设这是磁道0、磁道1、磁道2和磁道3。这些被划分的部分被称为扇区。这是扇区0、扇区1、扇区2、扇区3、扇区4和扇区5。磁盘被划分为许多磁道和扇区。

磁道和扇区的交叉部分,例如这个或这个,所有这些交叉位置都被称为块。因此,磁盘上的任何位置都可以通过磁道号和扇区号来寻址。


所以,磁盘上的地址,即块地址,是以磁道号和扇区号的形式表示的。例如,如果我取这个特定的块,那么它是磁道1,扇区0。这个块是磁道2,扇区1。因此,每个块都可以用磁道号和扇区号的形式表示其地址。

通常,块大小我们取为512字节。为了学*目的,我们假设它为512字节。它可以是任何大小,取决于磁盘制造商。我们假设每个块是512字节。当我们从磁盘读取或写入时,我们总是以块为单位进行读写。
现在,如果我取一个512字节的块,起始地址(第一个字节)是0,最后一个字节是511,那么每个字节都有自己的地址,这称为偏移量。让我们看这是第10个字节,我们可以通过块地址来引用磁盘上的任何一个字节,然后在那个块内,我们取偏移量。这就是该特定字节的地址。
我们假设每个块的地址从0开始,最后一个地址是511,因为总共512字节。任何字节我们都可以取其地址,这个地址被称为偏移量。要访问磁盘上的一个特定字节,我们需要知道磁道号、扇区号和偏移量。

假设在这个特定的块中,我想读取这个特定的点,这是一个字节。为了读取那个字节,我需要知道这三样东西。
然后,让我们再理解一件事。磁盘像这样安装在主轴上,也会有磁头臂。通过旋转,扇区会改变;通过磁头臂的移动,磁道会改变。因此,我们可以通过移动这个磁头臂(即读写数据的磁头)或旋转磁盘,将磁头定位在特定的磁道和特定的扇区。通过旋转改变扇区,通过移动磁头改变磁道。这就是关于磁盘的内容。
现在,磁盘上的数据是以块为单位存储的。还有一件事。
让我们把这个当作主内存。通常我们称之为RAM,RAM是一种技术,这种内存类型是主内存。我们在主内存中运行程序,程序会访问数据。假设它们从磁盘访问数据,那么数据必须从磁盘加载到主内存中,然后才能被访问。如果得到任何结果,结果会再次写回磁盘。
因此,数据不能直接在磁盘上处理,它必须被带入主内存然后访问。这是需要理解的一个重要点。

我再强调一次,在主内存内部组织数据,这些数据直接被程序使用,这就是数据结构。


而在磁盘上高效地组织数据,以便可以轻松利用,这就是数据库管理系统。

所以,研究或用于在磁盘上以高效方式存储数据的方法或途径,都是关于数据库管理系统的设计,组织一切。这里,研究数据结构。


让我们继续下一件事:数据如何存储在磁盘上。

数据库中的数据组织 📊


现在,让我们了解数据如何以数据库的形式组织在磁盘上。我这里有一个示例数据库表,包含一些列和一些行。假设有100行。
数据如何以块的形式存储在磁盘上?我们已经研究过,并假设块大小为512字节。


现在,这个表的结构在这里给出。有更多的字段,我没有全部显示,但这里我写下了所有。总共有五个字段,这些是它们的大小。员工ID大小为10字节,这个字段是50字节,部门是10字节,等等。所以总共占用128字节。

这意味着每行大小是128字节。每一行是128字节。

现在,当我们想将该表存储在数据库上时,在每个块中可以存储多少行?也就是可以存储多少条记录。所以,每个块的记录数。


块大小是512,记录大小是128,所以是4。这意味着我们可以在一个块中存储这个表的四行。
这意味着这四行将进入一个块。而这四行将进入下一个块。
每四行存储在一个块中。所以一个块只能包含四行。因为我们这里有100行,也就是100条记录。那么需要多少个块?
然后,100条记录。每个块只能包含四条记录,所以是25。因此,存储这个数据库表总共需要25个块。

在磁盘上。现在,当这个表占用25个块时,想象一下,如果你想搜索任何特定的记录,你写了一个查询,例如从员工表中选择,我想找员工ID为5或员工名为Smith的记录。如果我进行搜索,那么需要多少时间?时间取决于你访问的块数。要访问整个表,我们必须访问25个块。

所以块访问次数是25。因为记录可能在任何地方,我们不知道记录在哪里。所以我们必须搜索整个表。为此,我们最多需要访问25个块。我们可以逐个块访问并检查我们正在寻找的特定记录。但最多需要访问25个块。我们能减少这个时间吗?可以。现在我们将为这个数据库表准备索引。
索引与密集索引 🔍
索引。在索引中,我们将存储什么?在索引中,我们将存储一个键,即员工ID,以及指向记录的指针。假设员工ID 1,它在这里。员工ID 2,它在这里。员工ID 3,它在这里。实际上,我们将有一个指向磁盘上块内表的指针。所以我们将有一个指向记录的指针。我们称之为记录指针。对于每条记录,我们将在索引中有一个条目。这被称为密集索引。
接下来,索引存储在哪里?我们也将索引存储在磁盘上。
假设在这个块中,我们存储一个索引。这是用于索引的。这是用于数据库表的,还有更多记录。因为有更多块,因为它占用了25个块。索引也保存在磁盘上。
现在的问题是,这个索引在磁盘上会占用多少空间?让我们分析一下,员工ID是10字节。那么这个记录指针呢,我们在这里取的指针?记录指针。我们假设它占用6字节。所以每个条目大小是多少字节?16字节。每个条目占用16字节。那么这里有多少个条目?有100个条目。
现在的问题是,这需要多少个块?条目大小是16字节,块大小是512,所以这将是32。这意味着我们可以在一个特定的块中拥有32个条目。那么总共有多少个条目?100个条目。所以对于100个条目,100除以32。所以大约是3.2。假设超过3。所以这意味着存储这个索引需要3个以上的块。假设存储这个索引需要4个块。
现在,每当我们想要搜索时,我们访问索引。所以最多需要多少个块?4个块。4个索引块,一旦我们得到一个特定的键,假设我正在寻找这个,使用那个地址我可以访问记录。所以为了访问记录,我必须访问一个块。1个块。所以最多4个索引块和一个数据库表块。首先在索引中搜索,最多需要多少时间?4个块。然后一旦你得到索引条目,就去那个特定的数据库块。那又是一个块。所以总共需要4加1个块来访问任何数据库记录。访问任何记录,我们必须访问4加1,也就是5个块,而不是25个块,因为我们不必查看整个表。这就是索引的好处。
接下来我将讨论多级索引。

多级索引 📈
如果索引大小也在增长怎么办?现在我有1000条记录,1000条记录对应50个块。现在,这里我们需要4个块。现在我们需要40个块,因为会有1000个条目,我只是加了个0。当行数是100时,我们需要4个块。现在,1000条记录,假设需要40个块。只是假设,我不做所有计算。
那么在索引本身的搜索就变大了。我们可以在索引之上再有一个索引吗?可以。现在你如何为这里的每个条目准备这个索引?我们会在下一级索引、高级索引中为每个条目设置一个条目吗?不,是为每个块。所以在这个块中可能有多少个条目?32个条目。所以我们将有一个指向这个特定索引的32个条目的指针。
所以假设起始键是1,下一个键将是33,它将指向下一个块。所以这是一个稀疏索引。它不会为这里的每个条目都有一个条目。它不会拥有相同数量的条目。它将为索引的每个块设置一个条目。现在,正如我所说,有40个块。现在,同样,这个的大小是多少?只有16字节。所以一个块中可能有多少个?32个可能。那么对于这个特定的索引,需要多少个块?只需要一个多一点,假设两个。所以需要两个块。所以这个高级索引可以只存储在两个块中。因此,数据库表占50个块,第一级索引占40个块,然后下一个高级索引只占两个块。所以在两个块中搜索,一旦我们得到一个条目,访问这里的块。然后一旦我们得到一个条目,访问这里的块。所以总共多少个块?这边2个块,这边1个块,这边1个块。通过这种方式,通过增加一级索引,我们减少了块访问次数。


这就是B树和B+树的基本思想起源。我将绘制多级索引,然后向你展示它的样子。让我们再看一次多级索引。
假设这是主索引。

现在关于这个,我们需要一个更多的索引,假设这个太大了,那么它将有一个指向索引中一组条目的指针,然后一个指向索引中另一组条目的指针。我只是随机取,没有按计数绘制。所以这个有指向这些的指针。假设这个也很大。这个也很大,那么你可以再有一个索引,它将有一个指向一组条目的指针和一个指向另一组条目的指针。


这样我们可以继续添加多级索引,如果数据库表在增长。假设这里是一个数据库表。如果大小在增长,这个索引会增长,我们将添加一个更多的索引,再一个索引。添加多级索引将减少块访问次数。
所以如果我把它翻转过来看,像这样。
下一级索引。然后,再下一级索引。如果我翻转过来看这个,这个多级索引看起来像这样。现在,多级索引的要求是什么?我们应该手动添加索引吗?我们应该检查数据库大小是否在增长,然后我们应该逐个添加索引,即高级索引吗?不,我们希望高级索引能够自动添加和删除。如果这个在增加,表大小在增加,这个索引在增加,那么你添加一个更多的索引。如果你正在删除记录,那么索引大小会减少,然后你移除高级索引。我们希望自我管理的高级索引,即多级索引。



所以最终的事情是我们想要自我管理的多级索引。这就是B树和B+树的想法的来源。

现在你可以看到,这些多级索引看起来像一棵树。如果我把它们侧过来,那么它看起来像这样。B树和B+树实际上起源于M路搜索树。接下来我将讨论什么是M路搜索树。最后,我们将进入B树和B+树。


M路搜索树 🌳
让我们谈谈M路搜索树。要了解M路搜索树,让我们回顾一下二叉搜索树,它用于搜索目的。为了搜索任何键值,我们从根开始。如果键值较小,我们走左边;如果键值较大,我们走右边。像这样的二叉搜索树。如果我搜索15,那么15大于这个,我走这边。15小于这个,所以我走这边,然后找到了。我们知道较小的键排列在左侧,较大的键排列在右侧。这就是二叉搜索树中所做的。
在二叉搜索树中,每个节点可以有多少个键?只有一个键。每个节点可以有多少个子节点?最多两个子节点。每个节点最多可以有两个子节点。所以这就是为什么它被称为二叉搜索树,两个子节点。
那么,我们能否有一棵拥有超过两个子节点或超过一个键的树?让我们检查20和50。这是两个键。现在这边我将有小于20的键,即10和15。这里我将有介于这两个键之间的键,即30、35。这里我将有大于这个的键,即60和90。所以这里我们也可以有一个像这样的搜索树。这是一个遵循与二叉搜索树相同思想的搜索树。

看,键的排列使得它们是递增顺序的。所以如果你看这里的键,第一个键小于第二个键,第二个键小于第三个键。如果你有更多的键,它们将像这样排列。如果我搜索30,那么我从这里开始。30大于20。30小于50,所以它必须在这个子节点中,来这里。30找到了。

所以毫无疑问,搜索所花费的时间比二叉搜索树多一点,但搜索方法是相同的。所以这些树被称为M路搜索树。那么M是什么?看它有多少个键?在这个例子中,它有两个键。那么它有多少个子节点?每个节点最多可以有3个子节点。所以这是一个三路搜索树。这被称为三路搜索树。
这样,M路搜索树意味着每个节点最多可以有M个子节点的搜索树。所以每个节点最多可以有M个子节点。那么有多少个键?M-1个键。

所以M基于子节点的数量,即节点的度。M路搜索树是二叉搜索树的扩展,或者你可以说二叉搜索树是度为2的M路搜索树的一种类型。

所以你也可以有度为3、度为5、度为10、度为1000的树。这意味着你可以有100个键在这里,每个节点可以有100个键和101个子节点。所以你可以有任何度的搜索树。这就是关于M路搜索树的内容。
让我们看看M路搜索树的节点。对于二叉搜索树,我们如何准备一个节点?我们将有数据在这里和两个子节点。这里你需要更多的子节点。让我为这个画一个节点结构。如果M路搜索树是四路搜索树,意味着每个节点可以有四个子节点和三个键。让我画一个节点结构。
这应该是一个子节点指针。指向子节点。这是键1。再次,这是指向一个子节点的指针。这是键2。这是指向一个子节点的指针。这是键3。这是指向一个子节点的指针。所以四个子节点指针。所以这是四路。当它是四路时,那么可以有三个键。
也就是说,一个节点可以有M-1个键。所以就像这样。这是一个图示表示。如果你正在制作一个数据结构,那么节点结构看起来像这样。所以如果有M个子节点,那么需要M-1个键空间。


将M路搜索树用于索引

接下来,我们能否使用这些搜索树,这些节点结构来准备索引?让我们看看。所以我将尝试使用这个来制作索引。让我们检查一下我们如何为四路搜索树做到这一点。

可以有四个子节点,所以这是一个子节点指针。好的,现在这是键1。这是下一个子节点指针。然后,这是键2。然后这是一个下一个子节点指针。然后这是键3,和第四个孩子指针,所以子指针1,2,3,4。然后这是什么?我在每个键之后添加了一个指针。这不过是,如果你这里有键,并且你利用M路搜索树作为索引的好处,那么除了子指针,你还应该有一个指向记录的指针。那就是记录指针。所以,是的,我们也会有一个记录指针。所以这是一个记录指针。

但是一个指针被指出了。所以我们可以利用M路搜索树作为索引,即多级索引,就像树会有多个级别一样。所以为了制作多级索引,我们可以利用M路搜索树,但节点结构应该像这样。所以记住这个节点结构:键在那里,子指针,所以度为4,所以四个子指针,三个键,所以三个记录指针,每个键都有一个指向数据库记录的指针。

现在,如果我们简单地使用M路搜索树,会有什么问题?我们如何克服这个问题并引入B树?现在我们将走向B树。到目前为止,我们已经讨论了M路搜索树。现在我带你进入B树。在B树中,我们将使用相同的节点结构。再次记住这个。我可能不会向你展示。这是B树中遵循的节点结构。
但首先,让我们知道M路搜索树有什么问题,然后B树与M路搜索树有何不同。让我们看看在M路搜索树中如何完成插入,以及它的问题是什么。然后我们将转向B树。


为了让你意识到这个问题,我取了一个10路搜索树。度为10意味着每个节点可以有10个子节点和九个键。假设我必须插入这些键,让我们看看如何插入。
最初,什么都没有。所以我会创建一个节点,并插入。我还能在这里插入多少个键?还能插入8个键。但是对于插入20,我将创建一个新节点作为子节点并插入20,然后插入30。30更大,所以30在右边。所以我不会插入这里,但我会来到这里。20和30大于这个?或者我会在这里插入30。
所以我可以像这样插入,但这是错误的。

我应该先填满这个节点,然后才考虑创建下一个节点,但在M路搜索树中没有控制。你可以随意插入。
所以这意味着如果我有任何键,那么M路搜索树的高度会是多少?这将消耗太多时间。它变得类似于线性搜索。
这就是M路搜索树的问题。所以M路搜索树的问题是什么?创建过程不受任何控制。对于创建过程没有指导原则,你可以随意创建。如果你像这样创建,那就是浪费时间。

搜索将花费大量时间。那么必须有一些规则或指导原则来创建M路搜索树。是的,那些指导原则被称为B树。


B树规则与创建 🧮

所以B树无非是带有一些规则的M路搜索树。


让我们看看那些规则是什么。让我解释一下,让我们看看什么是B树。B树无非是带有一些规则的M路搜索树。


那么规则是什么?让我们看看前三条。
每个节点必须至少填充一半,一半什么?如果度是M,那么必须有M/2个子节点。所以取M/2的值,不是向下取整。M/2意味着如果度是10,那么一个节点必须有5个子节点。每个节点必须有五个子节点。只有这样你才能考虑创建新节点。这是为了控制M路搜索树的高度。这是一条规则。
你只有在当前节点至少填充了一半子节点时才能创建下一个节点。这是一条。并且必须有M/2个子节点。然后,第二件事。
我们如何对根节点施加这个条件?我们不能对根节点施加这个条件。如果我们施加,那么我们就不能插入第一个值。所以根节点可以有最少2个子节点。所以根节点意味着第一个节点,所以第一个节点最少可以有2个子节点,对吧?所以这意味着它也可以只有一个键。没问题。但是所有其他节点呢?它们必须至少有半数的子节点。这是条件,两个条件,不是第三个条件。然后第三条规则是所有叶节点必须在同一层,所有叶节点在同一层。就是这样。
如果你遵循这三条规则来创建M路搜索树,那么我们实际上是在创建B树。这里还有一件重要的事情是创建过程是自底向上的。
现在我将取一些键,并向你展示如何自底向上生成一个B树。为此,我不会取10路搜索树。我将取一个小的度,比如3,并向你展示如何创建B树,换句话说,如何插入。我们将创建一个度为4的B树,意味着每个节点可以有四个子节点和三个键。我在这里取了一些键。然后我将插入越来越多的节点。所以我之后会添加更多的键。让我们从第一个开始。
对于B树,最初没有节点,我正在插入10。第一个键,所以它可以有多少个子节点?两个子节点。根据多级索引,当我们说时,它可以有一个记录指针。下一个20。20将插入在这里。20更大,所以插入在这里。下一个是50。没有空间给50。所有三个键都在这里,度是4,所以我们不能插入50。没有空间。那么我们应该做什么?分裂这个节点并创建一个新节点。所以从10、20、40和50中,两个键,我将保留在这边,一个键,我将发送到根节点。那么这是左子节点。这是右子节点。就这样。
所以当节点被填满,没有空闲空间时,为了插入一个键,我们分裂了一个节点,并将其中一个键发送到了根节点。从4个键中,包括50,这边两个,那边一个,40上去。
这就是在创建B树时如何进行分裂。你可以看到树在向上生长。所以就是这样。它是自底向上创建的。
我告诉过你,B树对于实现多级索引很有用。那么如果你说这是一级索引,那么上面就是自动创建的第二级索引。如果你在这里添加键,这个会自动创建。
现在让我们看看,我将在这里添加更多的键,然后你可以再次看到这个在增长。所以,让我们继续。我将插入更多的键。假设60。所以60来到这里。然后70,70来到这里。然后80,80,这里没有空间了。80应该来这里。没有空间,那么再次分裂。
那么我应该做什么?包括80,50,60,70和80。所以80应该去这边,70应该在根节点。检查一下。现在,树看起来像这样。然后。接下来,我将插入30。30,30将来到这里。然后我将插入35。这里没有空间给35。那么分裂。所以因为没有空间,我将分裂这边。所以从10,20,30和35中,35,这个应该去根节点,10和20在这边,35在那边,右边,35在这边。那么谁将去根节点?30。所以我会重画它:30、40和70。我将移除这个。是的,看。这是子节点,这个子节点,这个子节点。这就是现在看起来的样子。看,每当节点被填满时,我们就分裂它,并将其中一个键发送到父节点。
现在尝试在这里填充更多的键,让我们看看。我想插入5。那么5会出现在哪里?5会出现在开头。所以5、10和20。现在,我想插入15,15,没有空间了。所以5、10、15和20,没有空间给15。然后按顺序排列它们。然后没有空间了,分裂它。所以为了分裂,我将在这边创建一个新节点,5和10在这边,20在那边。现在,15应该上去这里。这个应该上去。对,一个关键点。你可能会想为什么我要发送第三个键?我想发送第二个键。好的,那是你的选择。所以你可以让它要么左偏要么右偏。所以无论哪种方式,你都可以做到。左偏意味着左边有更多的键。所以无论你想要哪一个,你都可以取。所以15和20在那边,5和10在这边。现在,这里也没有空间了。所以这里,你也分裂它。所以从根节点的键中,15、30、40和70。所以15和30在这边,70在那边。并且40在父节点。所以这个将在这边。这个将在这边。现在,所有这些链接也会改变,因为我们在分裂它。所以这个在这边。这个在这边,这个,我会画得离这个远一点,70。然后70的左边和70的右边。
现在你可以看到有三个层级,1、2、3,所以创建了三级索引,这就是通过插入键生成B树的方式。
B树的高度在增加,这与多级索引相同。
所以这就是创建过程。所以它是在控制之下的。并且所有叶节点都在同一层。所以它们看起来更像索引。并且在同一层。这形成了索引,上面一层也是索引。所以我向你展示了如何插入以及如何创建B树。
B树在数据库中的应用 🗃️
现在,让我们看看它如何对数据库有用。只是一些非常关键和重要的要点,我向你展示。
正如我们刚才讨论的,每个节点都将有一个指向子节点的指针以及一个记录指针。这个20将有一个记录指针,5将有一个记录指针,10将有一个指针,15将有一个记录指针,30将有一个记录指针。每个键都将有一个记录指针,也指向数据库。所以这对于实现多级索引很有用,除了子指针,还有一个记录指针。所以一个B树节点可以有什么?键、子指针(即块指针)和这个记录指针。就是这样。这就是关于B树的内容。

我没有展示删除。这取决于你从这个中学到了多少,你只需回复我,留下评论,如果需要,我也会准备删除的部分。
好的,实际上,我想向你展示B树和B+树的应用。所以我从索引开始我的主题,并带你走到这一点,以便你能理解与数据库和B树的关系。否则,大多数人只学*B树和B+树,而不了解索引和数据库。所以现在你可以将其与数据库联系起来。

B+树 🌲
现在最后一件事。什么是B+树?这是一个非常简单的事情,让我快速向你展示。我将修改这个。什么是B+树?在B+树中,我们不会从每个节点都有记录指针。我们只从叶节点有记录指针。只有叶节点我们有记录指针。那么这些键呢?每个键在叶节点中都会有一个副本。所以意味着这里谁缺失了?15这里缺失了。所以15也会在这里。20也会在这里。所以15也会在这里有一个副本。20也会在这里有一个副本。然后30这里也会有一个30的副本和35。那么40呢?40也会在这里有一个副本。所以40和50和60。所以三个记录指针。所以70也会出现在这个中。所以树中的每个键肯定都存在于叶节点中,并且这些叶节点像这样连接,像一个链表。


所以这变成了一个密集索引。现在,这完全像多级索引。所以这是B+树。


所以从B树到B+树的区别是什么,让我快速告诉你:所有的键都存在于叶节点中,在非叶节点中,可能存在它们的副本。
每个键必须存在于叶节点中,并且没有来自非叶节点的记录指针。记录指针将只来自叶节点。



叶节点,所有叶节点都在同一层。它们将形成一个链表。这些是要点。然后这变成了一个密集索引。这变成了一个密集索引。然后这是一个稀疏索引,下一级是稀疏索引。所以与B树相比,B+树更像多级索引。
所以这个的“加”是什么,我已经向你展示了。所以如果你理解了B树,理解B+树并不困难。
总结 📝

本节课中,我们一起学*了B树和B+树。我们从磁盘结构和数据组织开始,了解了数据库表如何以块的形式存储在磁盘上,以及直接搜索的低效性。接着,我们引入了索引的概念,特别是密集索引,它通过存储键和记录指针来加速搜索。当索引本身变大时,我们引入了多级索引(稀疏索引)来进一步减少磁盘访问次数。

多级索引的自管理需求引出了M路搜索树的概念。我们回顾了二叉搜索树,并将其扩展到每个节点可拥有最多M个子节点和M-1个键的M路搜索树。然而,M路搜索树的创建缺乏控制,可能导致树过高,搜索效率低下。



为了解决这个问题,我们引入了B树,它是对M路搜索树施加了三条规则的版本:
- 除根节点外,每个节点必须至少有
ceil(M/2)个子节点。 - 所有叶节点必须位于同一层。
- 树的生长是自底向上的,通过节点分裂来实现。

我们通过一个插入示例演示了B树的创建过程,并说明了其节点结构包含键、子节点指针和记录指针,非常适合实现数据库的多级索引。


最后,我们介绍了B+树,它是B树的一种变体,主要区别在于:
- 所有键的副本都存在于叶节点中,并形成有序链表。
- 非叶节点仅包含键的副本用于路由,不包含记录指针。
- 记录指针仅存在于叶节点中。


这使得B+树的叶节点层形成一个密集索引,而非叶节点层形成稀疏索引,更贴*于理想的多级索引结构,并且在范围查询等操作上更具优势。

通过本节课的学*,你应该能够理解B树和B+树不仅是重要的数据结构,更是数据库系统中实现高效索引的核心技术,它们的设计深刻地考虑了磁盘存储的特性(如块访问),以最小化I/O操作,提升数据库性能。
078:渐进符号详解 📈








在本节课中,我们将要学*算法分析中的核心概念——渐进符号。我们将了解如何用它来表示算法的时间复杂度,并通过具体的例子来理解上界、下界和紧确界的含义。
概述




渐进符号用于表示算法的时间复杂度。当我们分析算法时,通过研究并找出其语句的执行频率,我们可能会得到各种形式的时间复杂度,例如常数、对数、线性、平方或指数级等。



如何分析算法时间复杂度





上一节我们介绍了渐进符号的用途,本节中我们来看看如何具体分析一个算法的时间复杂度。方法是通过计算算法中每条语句的执行频率。




以下是分析时间复杂度的基本步骤:




- 识别算法中的每一条语句。
- 计算每条语句被执行的次数。
- 将所有语句的执行次数相加,得到一个关于输入规模
n的多项式函数f(n)。 - 该多项式的最高次项决定了算法的时间复杂度级别。



示例分析




让我们通过几个具体的算法来实践这个方法。



示例 1:常数时间算法
Algorithm A()
x = 10
- 这条语句只执行一次,所以
f(n) = 1。时间复杂度是常数级。





示例 2:线性时间算法
Algorithm B(n)
for i = 1 to n
print i
for循环条件判断执行n+1次。print i语句执行n次。- 所以
f(n) = (n+1) + n = 2n + 1。最高次项是n,因此时间复杂度是线性级。






示例 3:平方时间算法
Algorithm C(n)
for i = 1 to n
for j = 1 to n
print i, j
- 外层循环执行
n+1次。 - 内层循环的
print语句执行n * n = n²次。 - 所以
f(n) = 2n² + 2n + 1。最高次项是n²,因此时间复杂度是平方级。



示例 4:对数时间算法(二分查找)
Algorithm BinarySearch(A, n, key)
low = 1
high = n
while low <= high
mid = (low + high) / 2
if key == A[mid]
return mid
else if key < A[mid]
high = mid - 1
else
low = mid + 1
return -1
- 在
while循环中,搜索范围每次减半。 - 循环内的语句大约执行
log₂n次。 - 所以
f(n) ≈ 3log₂n + 2。时间复杂度是对数级。





示例 5:指数时间算法(递归计算)
Algorithm D(n)
if n <= 1
return 1
return D(n-1) + D(n-1)
- 设
T(n)为执行时间,可以得到递归关系式:T(n) = 2 * T(n-1) + 1。 - 解这个递归式,得到
T(n) = O(2ⁿ)。时间复杂度是指数级。





理解渐进符号:上界、下界与紧确界





通过前面的分析,我们得到了一个表示算法运行时间的多项式 f(n)。渐进符号的作用就是描述这个函数 f(n) 的增长级别。



假设我们分析一个算法,得到 f(n) = 2n² + 3n + 2。这个多项式的最高次项是 n²。




我们可以将常见的时间复杂度按增长速率从小到大排列:
1 < log n < n < n log n < n² < n³ < 2ⁿ



基于这个顺序,我们可以定义三种界限:


- 上界 (Big-O):所有大于或等于
n²的复杂度级别(如n²,n³,2ⁿ)都可以作为f(n)的上界。这意味着算法的运行时间不会比这些级别增长得更快。- 公式定义:
f(n) = O(g(n)),当且仅当存在正常数c和n₀,使得对于所有n ≥ n₀,都有f(n) ≤ c * g(n)。 - 对于
f(n) = 2n² + 3n + 2,我们可以找到c=6,使得2n² + 3n + 2 ≤ 6n²在n足够大时成立。因此,f(n) = O(n²)成立。同样,f(n) = O(n³)和f(n) = O(2ⁿ)也成立。
- 公式定义:




- 下界 (Big-Ω):所有小于或等于
n²的复杂度级别(如1,log n,n,n²)都可以作为f(n)的下界。这意味着算法的运行时间至少以这些级别增长。- 公式定义:
f(n) = Ω(g(n)),当且仅当存在正常数c和n₀,使得对于所有n ≥ n₀,都有f(n) ≥ c * g(n)。 - 对于同一个
f(n),我们可以找到c=1,使得2n² + 3n + 2 ≥ n²在n足够大时成立。因此,f(n) = Ω(n²)成立。同样,f(n) = Ω(n)和f(n) = Ω(log n)也成立。
- 公式定义:


- 紧确界 (Big-Θ):如果一个函数
f(n)既是O(g(n))又是Ω(g(n)),那么g(n)就是f(n)的紧确界。它精确地描述了函数的增长速率。- 因为
f(n) = 2n² + 3n + 2同时满足f(n) = O(n²)和f(n) = Ω(n²),所以我们可以说f(n) = Θ(n²)。这是对算法时间复杂度最精确的描述。
- 因为






特殊情况:没有紧确界的函数



并非所有函数都有紧确界。有些函数我们只能找到其上界或下界,但无法找到一个函数同时作为其紧确的上界和下界。



考虑函数 f(n) = n!(n的阶乘)。





- 寻找上界:
n! = n * (n-1) * ... * 2 * 1。如果我们把每一项都放大到n,则n! ≤ n * n * ... * n = nⁿ。因此,n! = O(nⁿ)是一个有效的上界。 - 寻找下界:
- 如果我们把每一项都缩小到
1,则n! ≥ 1。因此,n! = Ω(1)成立。 - 如果我们把每一项都缩小到
log n,则n! ≥ (log n) * (log n) * ... * (log n) = (log n)ⁿ。但(log n)ⁿ的增长比n log n慢吗?实际上,n!的增长远快于n log n。一个更紧的下界是n! = Ω(n log n)甚至n! = Ω(nⁿ/₂),但这不是我们讨论的重点。
- 如果我们把每一项都缩小到
- 关键点:对于
n!,我们找不到一个函数g(n),使得n! = O(g(n))和n! = Ω(g(n))同时成立,且g(n)是像n²这样简单的函数。它的上界nⁿ和下界1(或n log n)相差甚远。因此,n!没有紧确界 (Θ),我们只能分别描述它的上界和下界。





总结



本节课中我们一起学*了算法分析中的核心工具——渐进符号。




- 我们首先学*了如何通过计算语句执行频率来分析算法的时间复杂度,并得到了常数、线性、平方、对数和指数等不同级别的复杂度。
- 接着,我们深入理解了三种渐进符号:
- Big-O 符号 (O) 描述了函数增长率的上界,即算法运行时间的最坏情况估计。
- Big-Omega 符号 (Ω) 描述了函数增长率的下界,即算法运行时间的最好情况估计。
- Big-Theta 符号 (Θ) 描述了函数增长率的紧确界,当上界和下界相同时,它给出了算法运行时间的精确增长率。
- 最后,我们了解到像阶乘
n!这样的函数可能没有紧确界,这说明了渐进符号应用的边界。





掌握这些概念,能够帮助我们以简洁、规范的方式交流和比较不同算法的效率。
079:哈希技术简介 🗺️




在本节课中,我们将要学*一种用于搜索目的的重要技术——哈希技术。我们将从回顾已知的搜索方法开始,逐步理解哈希技术如何实现常数时间的搜索,并探讨其核心概念、面临的挑战以及解决方案。

已知的搜索技术回顾

在深入哈希技术之前,让我们快速回顾一下两种常见的搜索技术。

以下是两种基本的搜索方法:


- 线性搜索:在这种方法中,元素可以随机地、无序地排列在一个数组中。搜索任何元素时,我们从数组的左侧开始,向右逐个查找。其时间复杂度为 O(n)。
- 二分搜索:在这种方法中,元素必须以升序(排序后)的形式存储在数组或列表中。我们总是从列表的中间开始查找元素。如果目标元素更大,则转向右侧;如果更小,则转向左侧。其时间复杂度为 O(log n)。


线性搜索中元素的排列可以是随机的,但二分搜索要求元素必须是有序的。线性搜索的时间复杂度是 O(n),而二分搜索改进了搜索方法,使其更快,达到 O(log n)。但二分搜索的一个必要前提是元素必须排序,这意味着我们需要花费额外的时间来排序元素。

哈希技术的引入


现在,我们想要一种搜索方法,它只需要常数时间。也就是说,我们希望从 O(n) 到 O(log n),再从 O(log n) 进步到 O(1)。


正是这个需求引入了哈希技术。


哈希的基本思想
在哈希技术中,如果给定一个元素列表和一个用于存储这些元素的数组空间,我们根据元素自身的值作为索引来存储它。
例如,元素 8 存储在索引 8 的位置,元素 3 存储在索引 3 的位置,依此类推。

现在,当你想搜索任何关键元素时,比如 10,我们直接去索引 10 查看元素是否存在。如果想搜索关键元素 22,我们直接去索引 22 查看元素是否存在。因此,搜索时间是 O(1),因为我们直接去那个索引查找元素。


但问题是,如果我还有另一个元素 50,那么我们必须将它存储在索引 50 的位置,这可能非常遥远。为了存储这个 50,数组中必须有索引 50。这种方法会浪费大量空间。


如果我们想减少时间,就可能需要浪费大量内存。仅仅为了少数几个元素,我们就需要一个非常大的数组,其中大部分空间是空置的。
哈希的数学模型
为了改进这个基本思想,我们采用一个数学模型。我们为哈希设计一个基于函数的数学模型。
让我们看看如何用数学方式建模。


我们拥有的元素列表称为键空间,而我们必须存储它们的地方称为哈希表。我们假设存在一个函数,比如 h(x) = x,它将这些元素从键空间映射到哈希表。所以 h(8) = 8,这个 8 将存储在索引 8。同样,3 存储在索引 3。h(13) = 13,所以它存储在索引 13。


我们说 h(x) = x 是一个哈希函数,它将键空间中的元素映射到哈希表。我们将其视为一个理想的哈希函数。这与我们刚才看到的情况相同,只是用数学形式表达了出来。

在这个模型中,如果我有索引 50,并且有一个关键元素 50,那么我的数组中必须有索引 50。因此,我仍然需要一个非常长的哈希表,同样存在浪费大量空间的问题。



哈希函数与冲突

如果我们观察函数的性质,有两种类型的函数:


- 一对一函数
- 多对一函数


这是函数遵循的两种基数类型。h(x) = x 是一个一对一函数。如果你采用除 h(x) = x 之外的任何其他函数,它可能是多对一函数。但这也取决于键或哈希表的大小,不过大多数情况下它会是多对一函数。
我们说,这里的函数负责将元素映射到它们的索引,因此我们应该修改函数以有效利用哈希表中的空间。



接下来,我们将尝试修改函数。这里我们取哈希函数 H(x) = x mod size,其中 size 是哈希表的大小。以 size = 10 为例。

现在,让我们映射元素 8。8 mod 10 得到 8。所以 8 存储在这里。3 mod 10 得到 3,所以存储在这里。13 mod 10 也得到 3。因此,发生了冲突。


现在的问题是,当你改变哈希函数时,有可能两个或更多元素被映射到同一个索引,这就称为冲突。我们必须解决这个冲突。
解决冲突的方法


有不同的方法来避免或解决冲突。

以下是解决冲突的不同方法:
- 开放哈希法:其下是链地址法。
- 封闭哈希法:其下是线性探测法和二次探测法。

让我们逐一看看它们。

方法一:链地址法


解决冲突的第一个方法是链地址法。


在这种方法中,当我们使用相同的哈希函数映射任何键时,它将被映射到某个索引(例如 8)。我们不会将元素存储在哈希表本身中,而是在该索引处添加一个新的链,并在该链中插入元素。链本质上是一个链表。


例如,3 被映射到这里,所以我们将添加一个节点到链中,并插入 3。13 也被映射到这里,所以我们将添加一个新节点,并在那里插入 13。因此,当发生冲突时,我们将在同一索引处的链中插入关键元素。所有元素都被插入到其索引对应的链中。
现在,当我想搜索任何关键元素时,比如 13,我应该使用哈希函数。h(13) = 13 mod size,即 3。3 mod 10 得到 3。所以去索引 3,并在该特定索引处的链中搜索那个关键元素。可能有一个以上的元素,所以我们必须搜索整个链(整个链表)。因此,哈希的时间复杂度将不是 O(1),它会大于 1,但会比 O(log n) 更好、更快。
方法二:线性探测法
让我们看看下一个方法,线性探测法。这里哈希函数是 h(x) = x mod size(我采用相同的哈希函数)。8 被映射到索引 8。3 被映射到索引 3。13 也被映射到相同的位置,但发生了冲突,所以我们将其存储在下一个位置(索引 4)。

在这种方法中,我们简单地找到下一个空闲空间,并将元素存储在那里。我们应该用数学方式给出这种方法的解释。

我们的实际哈希函数是 x mod size。我们定义一个新的哈希函数 H'(x),即 (h(x) + f(i)) mod size,并且对于线性探测,f(i) = i,其中 i 取值 0, 1, 2 等。
当我们使用 13 并需要插入 13 时,我们说 (h(13) + f(0)) mod 10。这得到 3,但存在冲突。接下来,我们对 i 取不同的值使用相同的哈希函数,计算 (h(13) + f(1)) mod 10。我们得到答案为 4,这就是值存储在位置 4(索引 4)的方式。因此,我们使用这个 H'(x) 作为哈希函数来存储元素。如果发生冲突,那么我们取 i 的下一个值;如果再次发生冲突,则继续取 i 的下一个值,依此类推。


现在,当必须搜索任何关键元素时,比如搜索 4,我应该使用哈希函数 h(x) = x mod size,去那个位置(索引 4),但 4 不在那里。然后我应该查看下一个位置(索引 5),是的,它在那里找到了。所以它在索引 5 被找到。

这意味着,通过使用哈希函数,我应该去那个索引,并开始寻找元素。如果在连续的位置找到元素,或者遇到空位,则停止。这是搜索的方法。



同样,时间复杂度不会是 O(1)。它会大于 1,但会比 O(log n) 更好。

方法三:二次探测法
现在,线性探测中的一个问题是它会导致元素的聚集。一堆元素会集中在单个地方的连续位置,它们会被连续分配。这可能导致搜索元素花费大量时间。


为了避免元素聚集,我们引入二次探测法。

这与线性探测法相同。如果我们取哈希函数,那么对于二次探测,我们也定义一个新函数 H'(x),即 (h(x) + f(i)) mod size,但 f(i) = i²。i 取值 0, 1, 2 等。所以 f(i) 不等于 i,而是等于 i²。这就是线性探测和二次探测之间的区别:在线性探测中,我们取 f(i) = i;在二次探测中,我们取 f(i) = i²。

当我们尝试映射关键元素时,8 存储在这里,3 存储在相同位置。对于 i=0,有冲突,所以对于 i=1,它将存储在下一个位置。以元素 23 为例。让我们计算 23 的位置。h(23) + f(0) mod 10 得到 3,在位置 3 有冲突。然后计算 h(23) + f(1) mod 10,即 3+1 mod 10 = 4,在索引 4 也有冲突。接着计算 h(23) + f(2) mod 10,即 3+4 mod 10 = 7。所以这个 23 存储在索引 7。


现在你可以看到,元素被放置在几个位置之外,它并不非常靠*与 23 发生冲突的那些元素。因此,元素之间有间隔,这将避免元素聚集,并改善搜索时间。

总结



本节课中,我们一起学*了哈希技术。我们从线性搜索和二分搜索的回顾开始,理解了追求常数时间搜索的需求如何引出了哈希技术。我们探讨了哈希的基本思想、数学模型,以及核心的哈希函数概念。重点在于,当哈希函数导致多个元素映射到同一位置(即冲突)时,我们学*了三种主要的解决方法:链地址法、线性探测法和二次探测法。每种方法都在时间复杂度和空间利用率之间有不同的权衡,并解决了如元素聚集等问题。哈希技术通过巧妙的映射,极大地提升了搜索效率。
080:概述与问题定义 🗺️
在本节课中,我们将要学*两种重要的最短路径算法:Dijkstra算法和Bellman-Ford算法。最短路径问题是图论中的一个核心问题,目标是找到从一个指定的起点到图中所有其他顶点的最短路径。这些路径可能由单条边构成,也可能需要经过多个顶点。
最短路径算法:第2章:核心概念与松弛操作 ⚙️
上一节我们介绍了最短路径问题的基本定义,本节中我们来看看解决该问题的核心思想。
为了找到最短路径,我们需要一个关键的操作:松弛。松弛操作是检查并更新当前已知最短距离的过程。
假设我们有一个起点 s,对于图中的每个顶点 v,我们维护一个距离值 dist[v],表示从 s 到 v 的当前已知最短距离。初始时,dist[s] = 0,对于其他所有顶点 v,dist[v] = ∞。
松弛操作针对图中的每一条边 (u, v) 进行,其权重为 w。操作如下:
公式: if dist[u] + w < dist[v] then dist[v] = dist[u] + w
这个操作的含义是:如果我们发现一条通过顶点 u 到达 v 的新路径,其总距离(dist[u] + w)比当前记录的 dist[v] 更短,那么我们就用这个更短的距离更新 dist[v]。
最短路径算法:第3章:Dijkstra算法详解 🎯
理解了松弛操作后,我们来看第一个算法:Dijkstra算法。该算法适用于所有边权重都为非负值的图。
Dijkstra算法采用贪心策略。它维护两个集合:已确定最短路径的顶点集合 S 和未确定的顶点集合。算法反复从未确定的集合中选取距离起点最*的顶点,将其加入 S,然后对其所有出边进行松弛操作。
以下是Dijkstra算法的步骤概述:
- 初始化所有顶点的距离:起点为0,其他为无穷大(∞)。
- 创建一个包含所有顶点的集合
Q。 - 当
Q不为空时:
a. 从Q中取出距离值最小的顶点u。
b. 将u从Q中移除(意味着u的最短路径已确定)。
c. 对于u的每个邻居顶点v,进行松弛操作:如果dist[u] + weight(u, v) < dist[v],则更新dist[v]。
让我们通过一个简单例子来理解这个过程。假设我们有下图,起点为 P:
(此处应有图,描述为:P到Q权重1,P到S权重6,P到E权重7;Q到R权重1,Q到S权重4;R到U权重1,R到S权重2;S到T权重3)
- 初始化:
dist[P]=0,dist[Q]=1,dist[S]=6,dist[E]=7,dist[R]=∞,dist[U]=∞,dist[T]=∞。 - 选择最小距离顶点
Q(dist=1),松弛其边:Q->R: 1+1=2 < ∞,更新dist[R]=2Q->S: 1+4=5 < 6,更新dist[S]=5
- 选择下一个最小距离顶点
R(dist=2),松弛其边:R->U: 2+1=3 < ∞,更新dist[U]=3R->S: 2+2=4 < 5,更新dist[S]=4
- 继续此过程,依次选择
U(3),S(4),E(7),T(7)。最终得到从P到所有顶点的最短距离。
时间复杂度分析:
在最简单的实现中,每次需要遍历所有顶点来找到距离最小的那个,并对它的边进行松弛。对于有 V 个顶点的图,时间复杂度为 O(V²)。使用优先队列(如二叉堆)可以优化到 O((V+E) log V),其中 E 是边数。
最短路径算法:第4章:负权边与Dijkstra的局限性 ⚠️
上一节我们介绍了Dijkstra算法,本节中我们来看看当图中存在负权边时会发生什么。
Dijkstra算法基于一个假设:一旦一个顶点被标记为“已确定”,从起点到它的最短距离就不会再被改变。这个假设在边权非负时成立,因为任何后续路径都会增加非负的权重,不可能更短。
然而,当存在负权边时,这个假设就被打破了。考虑下图:
(此处应有图,描述为:顶点1到4权重1,顶点4到3权重-6,顶点1到2权重2,顶点2到3权重3)
- 起点为1,初始化:
dist[1]=0,dist[2]=2,dist[3]=∞,dist[4]=1。 - 选择最小距离顶点
4(dist=1),将其确定。根据Dijkstra,到顶点4的最短路径就是1。 - 选择下一个最小距离顶点
2(dist=2),松弛2->3:2+3=5,更新dist[3]=5。 - 选择下一个顶点
3(dist=5),松弛3->4:5+(-6) = -1。这比当前dist[4]=1更短!
问题出现了:顶点 4 已经被“确定”并移出集合,但我们现在发现了一条更短的路径(1->2->3->4,总成本-1)。Dijkstra算法无法处理这种情况,因为它不会回头去更新已确定的顶点。因此,Dijkstra算法不能用于包含负权边的图。
最短路径算法:第5章:Bellman-Ford算法详解 🔄
由于Dijkstra算法无法处理负权边,我们需要一个更通用的算法:Bellman-Ford算法。该算法的核心思想是:反复对所有边进行松弛操作,直到没有更新发生。
与Dijkstra一次确定一个顶点不同,Bellman-Ford算法“暴力”地重复松弛过程。它不关心顶点被处理的顺序。
以下是Bellman-Ford算法的步骤:
- 初始化:起点距离为0,其他所有顶点距离为∞。
- 对图中的所有边,重复进行 V-1 次松弛操作(V是顶点数)。
- (可选)再额外进行一次全边松弛。如果这次还有距离被更新,则说明图中存在负权环。
为什么是 V-1 次?在一条没有环的最短路径上,最多包含 V-1 条边。经过 V-1 轮全局松弛,足以让最短路径信息从起点传播到任何可达的顶点。
让我们看一个包含负权边的例子:
(此处应有图,描述为:A到B权重1,A到C权重2,C到D权重3,D到B权重-6)
- 初始化:
dist[A]=0,dist[B]=∞,dist[C]=∞,dist[D]=∞。 - 列出所有边:
(D,B,-6),(C,D,3),(A,C,2),(A,B,1)。 - 进行第一轮松弛(按列表顺序):
D->B: ∞ -6 = ∞,无变化。C->D: ∞ +3 = ∞,无变化。A->C: 0+2=2 < ∞,更新dist[C]=2。A->B: 0+1=1 < ∞,更新dist[B]=1。
- 进行第二轮松弛:
D->B: ∞ -6 = ∞,无变化。C->D: 2+3=5 < ∞,更新dist[D]=5。A->C,A->B无变化。
- 进行第三轮松弛(V-1=3次):
D->B: 5 + (-6) = -1 < 1,更新dist[B]=-1。- 其他边无变化。
- 最终得到正确的最短距离:A->B: -1, A->C: 2, A->D: 5。
时间复杂度分析:
算法需要进行 V-1 轮松弛,每轮需要遍历所有 E 条边。因此,时间复杂度为 O(V * E)。在稠密图(完全图)中,E ≈ V²,所以时间复杂度可达 O(V³)。
最短路径算法:第6章:负权环与算法总结 ⛔
上一节我们介绍了Bellman-Ford算法,本节中我们来看看它的局限性并总结两个算法。
Bellman-Ford算法能处理负权边,但它有一个无法逾越的障碍:负权环。负权环是指一个环上所有边的权重之和为负数。
为什么这是问题?因为如果存在一个从起点可达的负权环,你可以绕着这个环走无限多圈,每走一圈总路径成本就会减少,从而不存在“最短”路径(路径长度可以趋向负无穷)。
Bellman-Ford算法在完成 V-1 轮松弛后,如果再进行第 V 轮松弛,仍然有距离被更新,那么就可以断定图中存在负权环。Bellman-Ford算法可以检测负权环,但无法给出包含负权环的图的最短路径。
算法对比总结:
以下是两种算法的核心区别:
- 适用条件:
- Dijkstra:仅适用于边权非负的图。
- Bellman-Ford:适用于所有图(可含负权边),并能检测负权环。
- 核心策略:
- Dijkstra:贪心算法,每次处理当前距离最小的未确定顶点。
- Bellman-Ford:动态规划思想,反复对所有边进行松弛。
- 时间复杂度:
- Dijkstra(朴素):O(V²);使用优先队列:O((V+E) log V)。
- Bellman-Ford:O(V * E)。
- 主要缺点:
- Dijkstra:不能处理负权边。
- Bellman-Ford:时间效率通常低于Dijkstra;无法解决含负权环的图的最短路径问题。


本节课中我们一起学*了最短路径问题的两种经典解法:Dijkstra算法和Bellman-Ford算法。我们理解了松弛操作的核心作用,掌握了每个算法的步骤、原理、时间复杂度和各自的适用场景。记住关键点:Dijkstra高效但怕负权边,Bellman-Ford通用但怕负权环。在实际应用中,需要根据图的具体特性(边权是否可能为负)来选择合适的算法。
081:图的广度优先与深度优先搜索


在本节课中,我们将学*图的两种基本遍历方法:广度优先搜索和深度优先搜索。遍历意味着访问图中的所有顶点。我们将通过简单的例子和清晰的步骤来理解这两种方法的工作原理。
核心概念


在讨论遍历方法之前,需要明确两个术语:
- 访问一个顶点:意味着到达并处理该顶点。
- 探索一个顶点:意味着访问给定顶点的所有相邻顶点。例如,对于顶点4,其相邻顶点是1、3和5。探索顶点4就是去访问顶点1、3和5。
我们将使用这两个术语来研究广度优先搜索和深度优先搜索。
广度优先搜索

广度优先搜索按照“层次”的顺序访问顶点。它从一个起始顶点开始,先访问其所有相邻顶点,然后再访问这些相邻顶点的相邻顶点,依此类推。这个过程使用队列作为核心数据结构。
以下是BFS的工作流程:
- 选择任意一个顶点作为起始顶点,访问它并将其放入队列。
- 从队列前端取出一个顶点进行“探索”(即访问其所有未访问过的相邻顶点)。
- 每访问一个新的相邻顶点,就将其放入队列末尾。
- 重复步骤2和3,直到队列为空,这意味着所有可达顶点都已被访问。
让我们通过一个例子来演示。假设我们有下图(此处为逻辑描述,请参考视频中的图示):
- 顶点1连接2和4。
- 顶点2连接1和3。
- 顶点3连接2、4、8、9。
- 顶点4连接1、3、5。
- 顶点5连接4、6、7。
- 顶点6连接5。
- 顶点7连接5。
- 顶点8连接3。
- 顶点9连接3。
从顶点1开始的BFS遍历顺序可能是:1, 2, 4, 3, 5, 8, 9, 6, 7。
关键点:
- 新访问的顶点总是加入队列。
- 下一个要探索的顶点总是从队列前端选取。
- 忽略已访问过的顶点,避免重复访问。
- 通过这种方式,BFS会生成图的一棵广度优先搜索生成树。
如何验证BFS序列
判断一个给定的顶点序列是否为有效的BFS结果,关键是检查它是否遵循“队列”的先进先出原则。例如,序列 1, 4, 2, 3, 5, 8, 9, 6, 7 是有效的,因为从队列中取顶点的顺序符合探索逻辑。而序列 1, 2, 3, 4, 5, 6, 7, 8, 9 可能是无效的,因为在探索顶点2之后,应该先探索顶点4(它与1相邻且已访问),然后才能探索顶点3的更深层邻居(如8、9),这个顺序违反了队列的规则。

上一节我们介绍了广度优先搜索,它像“层层推进”一样访问顶点。本节中我们来看看深度优先搜索,它的策略是“一条路走到黑,再回头”。
深度优先搜索
深度优先搜索会沿着一条路径尽可能深入地访问顶点,直到无法继续,然后回溯到上一个分叉点,选择另一条路径继续深入。这个过程使用栈(或递归)作为核心数据结构。
以下是DFS的工作流程:

- 选择任意一个顶点作为起始顶点,访问它并将其放入栈。
- 取出栈顶顶点进行“探索”。
- 访问它的第一个未访问的相邻顶点,将这个新顶点放入栈,并立即暂停当前顶点的探索,转而开始探索这个新顶点(即“深入”)。
- 重复步骤3,直到当前顶点没有未访问的相邻顶点(称为“死胡同”)。
- 此时,从栈中弹出该顶点(回溯),回到上一个顶点,并继续探索该顶点剩下的未访问相邻顶点。
- 重复步骤2-5,直到栈为空,这意味着所有可达顶点都已被访问。
使用同一个图,从顶点1开始的一个可能的DFS遍历顺序是:1, 2, 3, 4, 5, 6, 7, 8, 9。
- 路径可能是:1 -> 2 -> 3 -> 4 -> 5 -> 6 (回溯) -> 5 -> 7 (回溯) -> 5 -> 4 -> 3 -> 8 (回溯) -> 3 -> 9。
关键点:
- 新访问的顶点被压入栈,并立即成为新的当前探索顶点。
- 当一条路径走到尽头时,通过弹出栈顶元素进行回溯。
- 通过这种方式,DFS会生成图的一棵深度优先搜索生成树。
如何验证DFS序列

判断一个序列是否为有效的DFS结果,关键是检查它是否遵循“深度优先”和“回溯”的机制。一个有效的DFS序列中,当你访问一个新顶点时,接下来应该立即开始探索这个新顶点(即访问它的一个邻居),而不是回到之前某个顶点的其他邻居。例如,序列 1, 2, 3, 8, 9, 4, 5, 6, 7 是有效的。而序列 1, 2, 3, 5, ... 可能是无效的,因为从顶点3,你应该先去访问它的一个未访问邻居(如4、8、9),而不应该直接跳到并非其直接邻居的顶点5。
总结
本节课中我们一起学*了图的两种基础遍历算法:
- 广度优先搜索:使用队列数据结构,按照离起始点由*及远的顺序访问所有顶点。其核心模式是:
访问 -> 入队 -> 取队首探索。 - 深度优先搜索:使用栈数据结构(或递归),沿着一条路径不断深入,直到尽头再回溯。其核心模式是:
访问 -> 压栈 -> 深入 -> 回溯弹栈。

理解这两种方法对于解决许多图论问题至关重要,例如查找最短路径(BFS)、拓扑排序、检测环(DFS)等。通过练*识别有效的BFS/DFS序列,可以加深对它们核心机制的理解。
082:汉诺塔问题详解 🗼
在本节课中,我们将学*汉诺塔问题的递归解法。我们将从简单例子开始,逐步推导出通用算法,并通过追踪执行过程来理解其工作原理。
概述
汉诺塔问题是一个经典的递归问题。问题描述如下:有三根柱子(A、B、C),其中一根柱子(A)上堆叠着若干大小不一的圆盘,从上到下尺寸递增。目标是将所有圆盘从柱子A移动到柱子C。移动过程中,任何时刻都不能将较大的圆盘放在较小的圆盘之上,并且可以借助第三根柱子(B)作为辅助。
从简单例子开始
为了理解问题,我们先从最简单的例子入手。
单个圆盘的情况
如果只有一个圆盘,解决方案非常简单。
步骤:
- 将圆盘从柱子A移动到柱子C。
两个圆盘的情况
如果有两个圆盘(编号1和2,1较小),则需要三步。
步骤:
- 将圆盘1从柱子A移动到柱子B(借助C)。
- 将圆盘2从柱子A移动到柱子C。
- 将圆盘1从柱子B移动到柱子C(借助A)。
通过以上两个简单例子,我们理解了基本移动规则。接下来,我们看看更复杂的情况。
三个圆盘的情况
当有三个圆盘时,问题变得稍微复杂。解决思路可以分解为三个核心步骤。
步骤:
- 将上面的两个圆盘从A移动到B(借助C)。这是一个递归步骤,其过程与解决两个圆盘的问题完全相同。
- 将最大的圆盘从A移动到C。
- 将B柱上的两个圆盘移动到C(借助A)。这同样是解决两个圆盘问题的递归步骤。
这三个步骤是设计递归算法的关键。
推导通用递归算法
基于三个圆盘的解决思路,我们可以将其推广到n个圆盘的情况。通用步骤可以表述为:
递归步骤:
- 将
n-1个圆盘从源柱(A)移动到辅助柱(B),借助目标柱(C)。 - 将第
n个(最大的)圆盘从源柱(A)移动到目标柱(C)。 - 将
n-1个圆盘从辅助柱(B)移动到目标柱(C),借助源柱(A)。
这直接转化为了递归算法。以下是算法的伪代码描述:
void towerOfHanoi(int n, char source, char destination, char auxiliary) {
if (n > 0) {
// 步骤1:移动n-1个圆盘从source到auxiliary,借助destination
towerOfHanoi(n-1, source, auxiliary, destination);
// 步骤2:移动第n个圆盘从source到destination
printf("Move disk from %c to %c\n", source, destination);
// 步骤3:移动n-1个圆盘从auxiliary到destination,借助source
towerOfHanoi(n-1, auxiliary, destination, source);
}
}
在函数调用中,参数角色(源、目标、辅助)会根据当前递归步骤而动态变化。
算法执行过程追踪
为了更清晰地理解,我们来追踪n=3时的算法执行过程。初始调用为:towerOfHanoi(3, ‘A’, ‘C’, ‘B’)。
以下是递归展开和移动指令生成的过程概要:
-
处理3个盘(A->C,借助B):
- 步骤1:递归处理2个盘(A->B,借助C)。
- 步骤2:打印移动:
A -> C。 - 步骤3:递归处理2个盘(B->C,借助A)。
-
处理2个盘(A->B,借助C)时:
- 步骤1:递归处理1个盘(A->C,借助B)。打印:
A -> C。 - 步骤2:打印移动:
A -> B。 - 步骤3:递归处理1个盘(C->B,借助A)。打印:
C -> B。
- 步骤1:递归处理1个盘(A->C,借助B)。打印:
-
处理2个盘(B->C,借助A)时:
- 步骤1:递归处理1个盘(B->A,借助C)。打印:
B -> A。 - 步骤2:打印移动:
B -> C。 - 步骤3:递归处理1个盘(A->C,借助B)。打印:
A -> C。
- 步骤1:递归处理1个盘(B->A,借助C)。打印:
将所有打印指令按顺序收集,得到完整的移动序列:
移动序列:
- A -> C
- A -> B
- C -> B
- A -> C
- B -> A
- B -> C
- A -> C
按照这个序列操作,即可成功将三个圆盘从柱子A移动到柱子C,且符合所有规则。
总结
本节课我们一起学*了汉诺塔问题的递归解法。我们从单个和两个圆盘的简单情况入手,归纳出三个圆盘时的核心解决步骤,并由此推导出适用于n个圆盘的通用递归算法 towerOfHanoi。最后,我们通过追踪三个圆盘时算法的执行过程,验证了移动序列的正确性。这个算法清晰地展示了如何将复杂问题分解为相似的子问题,是理解递归思想的绝佳范例。
083:行优先与列优先映射 📚

在本节课中,我们将学*编译器如何将数组(无论是一维、二维还是三维)映射到内存中。我们将探讨行优先和列优先这两种主要的映射方式,并理解编译器如何生成访问数组元素的机器代码公式。
一维数组的地址计算
首先,我们来看一维数组。当我们在程序中声明一个数组时,例如 int A[5],运行时会在内存中分配一块连续的空间。数组的起始地址称为基地址(Base Address),记作 L₀。
当我们在程序中访问数组元素,例如 A[2] = 15 时,编译器需要知道元素 A[2] 在内存中的确切地址。这个地址不是简单的数字2,而是通过一个公式计算出来的。
以下是计算一维数组元素地址的通用公式:
地址(A[i]) = L₀ + i × w
其中:
- L₀ 是数组的基地址。
- i 是元素的索引。
- w 是字长(Word Size),即数据类型的字节大小(例如,
int通常是2或4字节,char是1字节)。
示例:假设 L₀ = 100,w = 2(整数大小),那么 A[2] 的地址就是 100 + 2 × 2 = 104。
不同起始索引的公式
在C语言之前,有些语言的数组索引可以从1开始。对于这种情况,地址计算公式需要调整:
地址(A[i]) = L₀ + (i - 1) × w
如果编译器允许索引从任意数字开始(例如 A[-3..5]),那么公式会变得更加通用:
地址(A[i]) = L₀ + (i - 下界) × w

C语言及之后的大多数语言强制索引从0开始,就是为了消除公式中的减法操作,从而提高效率。

二维数组的内存布局

在内存中,所有数据都是线性存储的。因此,一个二维数组(例如 int A[3][4])在物理内存中实际上被存储为一个一维的连续空间。

编译器为我们提供了以二维形式访问这个线性空间的接口。有两种主要方式将二维逻辑结构映射到一维物理内存:行优先映射和列优先映射。
行优先映射
在行优先映射中,数组元素按行存储。首先存储第一行的所有元素,接着是第二行,依此类推。

假设有一个 3×4 的数组 A,其行优先存储顺序如下:
A[0][0], A[0][1], A[0][2], A[0][3], A[1][0], A[1][1], A[1][2], A[1][3], A[2][0], A[2][1], A[2][2], A[2][3]

行优先地址计算公式
要访问元素 A[i][j],编译器使用的公式是:
地址(A[i][j]) = L₀ + (i × n + j) × w
其中:
- L₀ 是基地址。
- i 是行索引(从0开始)。
- j 是列索引(从0开始)。
- n 是数组的列数(即每行的元素个数)。
- w 是字长。
示例:假设 L₀ = 500, n = 4, w = 2,计算 A[2][1] 的地址。
地址 = 500 + (2 × 4 + 1) × 2 = 500 + (8 + 1) × 2 = 500 + 18 = 518
公式 (i × n) 计算了跳过前 i 行所需跨越的元素总数,+ j 则是在当前行内再移动 j 个元素。

如果索引从1开始,公式则变为:
地址(A[i][j]) = L₀ + [(i-1) × n + (j-1)] × w

列优先映射
在列优先映射中,数组元素按列存储。首先存储第一列的所有元素,接着是第二列,依此类推。

对于同一个 3×4 的数组 A,其列优先存储顺序如下:
A[0][0], A[1][0], A[2][0], A[0][1], A[1][1], A[2][1], A[0][2], A[1][2], A[2][2], A[0][3], A[1][3], A[2][3]

列优先地址计算公式

要访问元素 A[i][j],编译器使用的公式是:
地址(A[i][j]) = L₀ + (j × m + i) × w
其中:
- L₀ 是基地址。
- j 是列索引(从0开始)。
- i 是行索引(从0开始)。
- m 是数组的行数(即每列的元素个数)。
- w 是字长。
示例:假设 L₀ = 500, m = 3, w = 2,计算 A[1][2] 的地址。
地址 = 500 + (2 × 3 + 1) × 2 = 500 + (6 + 1) × 2 = 500 + 14 = 514
公式 (j × m) 计算了跳过前 j 列所需跨越的元素总数,+ i 则是在当前列内再移动 i 个元素。
如果索引从1开始,公式则变为:
地址(A[i][j]) = L₀ + [(j-1) × m + (i-1)] × w
总结与比较

本节课我们一起学*了数组在内存中的映射方式。
- 对于一维数组,地址计算公式为
L₀ + i × w。索引从0开始可以优化计算效率。 - 对于二维数组,有两种映射方式:
- 行优先映射:公式为
L₀ + (i × n + j) × w。元素按行存储。 - 列优先映射:公式为
L₀ + (j × m + i) × w。元素按列存储。
- 行优先映射:公式为
从计算复杂度看,两种映射方式的公式都包含两次乘法和两次加法,效率是相等的。因此,设计编译器或新语言时可以选择任意一种。在C语言及其家族语言(如C++、Java、C#)中,默认采用的是行优先映射。

理解这些底层映射原理,有助于我们更好地理解程序的内存访问模式,并在某些情况下优化代码性能。
084:归并排序算法详解 🧩




在本节课中,我们将要学*归并排序算法。归并排序是一种基于“分治法”的高效排序算法,其核心思想是将一个大问题分解为若干个小问题,分别解决后再将结果合并。本节课将详细讲解归并排序的两个核心过程:合并与递归分解,并通过示例和伪代码帮助你彻底理解其工作原理。



概述

归并排序的过程可以概括为两个主要步骤:首先,将待排序的列表递归地分成两半,直到每个子列表只包含一个元素(一个元素的列表自然是有序的)。然后,将这些有序的子列表两两合并,最终得到一个完整的有序列表。理解“合并”过程是掌握归并排序的关键。



合并两个有序列表 🤝



上一节我们概述了归并排序的流程,本节中我们来看看其最核心的操作:合并。合并是指将两个已经分别排好序的列表,组合成一个新的有序列表。

假设我们有两个有序列表 A 和 B。合并过程需要三个指针:
- i:指向列表 A 的当前待比较元素。
- j:指向列表 B 的当前待比较元素。
- k:指向合并结果列表 C 的当前位置。





以下是合并过程的步骤:
- 比较
A[i]和B[j]。 - 将较小的那个元素复制到
C[k]。 - 移动被复制元素所在列表的指针(i 或 j)以及结果列表的指针 k。
- 重复步骤 1-3,直到其中一个列表的所有元素都被复制完毕。
- 将另一个列表中剩余的所有元素直接复制到结果列表 C 的末尾。



这个过程可以用以下伪代码来描述:




// 假设列表 A 的范围是 A[low...mid],列表 B 的范围是 A[mid+1...high]
// 结果将存回原数组 A[low...high] 或另一个数组 C
i = low
j = mid + 1
k = low // 如果使用新数组 C,则 k=0

while (i <= mid && j <= high)
if (A[i] < A[j])
C[k] = A[i]
i++
else
C[k] = A[j]
j++
k++



// 复制剩余元素
while (i <= mid)
C[k] = A[i]
i++
k++



while (j <= high)
C[k] = A[j]
j++
k++





递归分解与合并流程 🔄



理解了如何合并两个有序列表后,我们来看看归并排序如何利用递归来分解问题。对于一个包含 n 个元素的数组,归并排序的递归过程如下:


- 分解:找到数组的中间点
mid,将数组逻辑上分成左右两个子数组。- 公式:
mid = (low + high) / 2
- 公式:
- 递归排序:对左半部分
A[low...mid]递归调用归并排序,使其有序。 - 递归排序:对右半部分
A[mid+1...high]递归调用归并排序,使其有序。 - 合并:调用上面介绍的合并过程,将两个已排序的子数组合并为一个完整的有序数组。



这个递归过程会一直进行,直到子数组的长度为 1(此时 low == high),因为单个元素的数组被视为已排序。然后递归开始“返回”,逐层合并这些小数组。


完整示例演示 📊

让我们通过一个具体例子 [2, 8, 5, 3, 9, 4, 1, 7] 来可视化整个归并排序过程。



分解阶段(递归向下):
- 初始数组:
[2, 8, 5, 3, 9, 4, 1, 7] - 第一次分解: 左
[2, 8, 5, 3], 右[9, 4, 1, 7] - 继续分解左半部分:
[2, 8]和[5, 3] - 继续分解,直到每个子数组只剩一个元素:
[2],[8],[5],[3],[9],[4],[1],[7]


合并阶段(递归返回):
- 合并
[2]和[8]得到[2, 8] - 合并
[5]和[3]得到[3, 5] - 合并
[2, 8]和[3, 5]得到[2, 3, 5, 8] - 同理,右半部分合并后得到
[1, 4, 7, 9] - 最后合并左右两个大数组
[2, 3, 5, 8]和[1, 4, 7, 9],得到最终结果[1, 2, 3, 4, 5, 7, 8, 9]

整个过程的模式是:先不断二分分解,再逐层两两合并。







总结




本节课中我们一起学*了归并排序算法。我们首先明确了其“分而治之”的核心思想,然后深入剖析了将两个有序列表合并为一个有序列表的关键步骤。接着,我们理解了算法如何通过递归调用将原始问题分解到最小单元(单个元素),再通过合并操作自底向上地构建出最终的有序序列。归并排序的时间复杂度为 O(n log n),是一种稳定且高效的通用排序算法。掌握合并过程与递归分解的配合,是理解归并排序的精髓。

浙公网安备 33010602011771号