算法是什么
算法定义
算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性。
‧ 问题是明确的,包含清晰的输入和输出定义。
‧ 具有可行性,能够在有限步骤、时间和内存空间下完成。
‧ 各步骤都有确定的含义,在相同的输入和运行条件下,输出始终相同。
数据结构定义
数据结构(data structure)是计算机中组织和存储数据的方式,具有以下设计目标。
‧ 空间占用尽量少,以节省计算机内存
‧ 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
‧ 提供简洁的数据表示和逻辑信息,以便算法高效运行。
数据结构设计是一个充满权衡的过程。如果想在某方面取得提升,往往需要在另一方面作出妥协。下面举两
个例子。
‧ 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度。
‧ 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间
数据结构与算法的关系
数据结构与算法高度相关、紧密结合,具体表现在以下三个方面。
‧ 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及操作数据的方法。
‧ 算法是数据结构发挥作用的舞台。数据结构本身仅存储数据信息,结合算法才能解决特定问题。
‧ 算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,选择合适的数据结构是关键
约定俗成的简称
在实际讨论时,我们通常会将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题
目,实际上同时考查数据结构和算法两方面的知识。
复杂度分析
算法效率评估
在算法设计中,我们先后追求以下两个层面的目标。
- 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维
度。
‧ 时间效率:算法运行速度的快慢。
‧ 空间效率:算法占用内存空间的大小。
简而言之,我们的目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有
这样,我们才能将各种算法进行对比,进而指导算法设计与优化过程。
效率评估方法主要分为两种:实际测试、理论估算。
理论估算
复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大
小的增加,算法执行所需时间和空间的增长趋势。这个定义有些拗口,我们可以将其分为三个重点来理解。
‧“时间和空间资源”分别对应时间复杂度(time complexity)和空间复杂度(space complexity)。
‧“随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
‧“时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间
增长的“快慢”。
复杂度分析克服了实际测试方法的弊端,体现在以下两个方面。
‧ 它独立于测试环境,分析结果适用于所有运行平台。
‧ 它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
复杂度分析为我们提供了一把评估算法效率的“标尺”,使我们可以衡量执行某个算法所需的时间和空间资
源,对比不同算法之间的效率。
复杂度是个数学概念,对于初学者可能比较抽象,学习难度相对较高。从这个角度看,复杂度分析可能不太
适合作为最先介绍的内容。然而,当我们讨论某个数据结构或算法的特点时,难以避免要分析其运行速度和
空间使用情况。
综上所述,建议你在深入学习数据结构与算法之前,先对复杂度分析建立初步的了解,以便能够完成简单算
法的复杂度分析。
迭代与递归
在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。因此,在介绍时间复杂度和空间复杂
度之前,我们先来了解如何在程序中实现重复执行任务,即两种基本的程序控制结构:迭代、递归。
迭代
迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段
代码,直到这个条件不再满足。
for **循环的代码更加紧凑,**while 循环更加灵活,两者都可以实现迭代结构。选择使用哪一个应该
根据特定问题的需求来决定。
我们可以在一个循环结构内嵌套另一个循环结构,下面以 for 循环为例:
// === File: iteration.cpp ===
/* 双层 for 循环 */
string nestedForLoop(int n) {
ostringstream res;
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
在这种情况下,函数的操作数量与 𝑛 2 成正比,或者说算法运行时间和输入数据大小 𝑛 成“平方关系”。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方
关系”,以此类推。
递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
- 终止条件:用于决定什么时候由“递”转“归”。
- 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
- 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
观察以下代码,我们只需调用函数 recur(n) ,就可以完成 1 + 2 + ⋯ + 𝑛 的计算:
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范
式。
‧ 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
‧ 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。
接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述求和函数为例,设问题 𝑓(𝑛) = 1 + 2 + ⋯ + 𝑛 。
‧ 迭代:在循环中模拟求和过程,从 1 遍历到 𝑛 ,每轮执行求和操作,即可求得 𝑓(𝑛) 。
‧ 递归:将问题分解为子问题 𝑓(𝑛) = 𝑛+𝑓(𝑛−1) ,不断(递归地)分解下去,直至基本情况 𝑓(1) = 1
时终止。
1. 调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。
这将导致两方面的结果。
‧ 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归
通常比迭代更加耗费内存空间。
‧ 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
在触发终止条件前,同时存在 𝑛 个未返回的递归函数,递归深度为 𝑛 。在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
2. 尾递归
有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空
间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
‧ 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下
文。
‧ 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他
操作,因此系统无须保存上一层函数的上下文。
以计算 1 + 2 + ⋯ + 𝑛 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归:
// === File: recursion.cpp ===
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
对比普通递归和尾递归,两者的求和操作的执行点是不同的。
‧ 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
‧ 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即
使函数是尾递归形式,仍然可能会遇到栈溢出问题。
3. 递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”
为例。
Question
给定一个斐波那契数列 0, 1, 1, 2, 3, 5, 8, 13, … ,求该数列的第 𝑛 个数字。
设斐波那契数列的第 𝑛 个数字为 𝑓(𝑛) ,易得两个结论。
‧ 数列的前两个数字为 𝑓(1) = 0 和 𝑓(2) = 1 。
‧ 数列中的每个数字是前两个数字的和,即 𝑓(𝑛) = 𝑓(𝑛 − 1) + 𝑓(𝑛 − 2) 。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那
契数列的第 𝑛 个数字:
// === File: recursion.cpp ===
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2‑6 所
示,这样不断递归调用下去,最终将产生一棵层数为 𝑛 的递归树(recursion tree)。
从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
‧ 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维
方式。
‧ 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分
析。
两者对比
迭代和递归具有什么内在联系呢?以上述递归函数为例,求和操作在递归的“归”阶段进行。这意味
着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工。
事实上,“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。
- 递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、
返回地址等数据。
- 归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。
因此,我们可以使用一个显式的栈来模拟调用栈的行为,从而将递归转化为迭代形式
// === File: recursion.cpp ===
/* 使用迭代模拟递归 */
int forLoopRecur(int n) {
// 使用一个显式的栈来模拟系统调用栈
stack<int> stack;
int res = 0;
// 递:递归调用
for (int i = n; i > 0; i--) {
// 通过“入栈操作”模拟“递”
stack.push(i);
}
// 归:返回结果
while (!stack.empty()) {
// 通过“出栈操作”模拟“归”
res += stack.top();
stack.pop();
}
// res = 1+2+3+...+n
return res;
}
观察以上代码,当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,
但不一定值得这样做,有以下两点原因。
‧ 转化后的代码可能更加难以理解,可读性更差。
‧ 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。
总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情境选择合适的方
法至关重要。
时间复杂度
统计时间增长趋势
函数渐近上界
时间复杂度分析本质上是计算“操作数量 𝑇(𝑛)”的渐近上界,它具有明确的数学定义。
函数渐近上界
若存在正实数 𝑐 和实数 𝑛0 ,使得对于所有的 𝑛 > 𝑛0 ,均有 𝑇(𝑛) ≤ 𝑐 ⋅ 𝑓(𝑛) ,则可认为 𝑓(𝑛) 给
出了 𝑇(𝑛) 的一个渐近上界,记为 𝑇(𝑛) = 𝑂(𝑓(𝑛)) 。
计算渐近上界就是寻找一个函数 𝑓(𝑛) ,使得当 𝑛 趋向于无穷大时,𝑇(𝑛) 和 𝑓(𝑛) 处于相同
的增长级别,仅相差一个常数项 𝑐 的倍数。
推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的
实践中,就可以逐渐领悟其数学意义。
根据定义,确定 𝑓(𝑛) 之后,我们便可得到时间复杂度 𝑂(𝑓(𝑛)) 。那么如何确定渐近上界 𝑓(𝑛) 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
1. 第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 𝑐 ⋅ 𝑓(𝑛) 中的常数项 𝑐 可以取任意大小,因此操作数量
𝑇(𝑛) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。
- 忽略 𝑇(𝑛) 中的常数项。因为它们都与 𝑛 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 2𝑛 次、5𝑛 + 1 次等,都可以简化记为 𝑛 次,因为 𝑛 前面的系数对时间复
杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 1. 点和第 2. 点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量:
// 算法 A 的时间复杂度:常数阶
void algorithm_A(int n) {
cout << 0 << endl;
}
// 算法 B 的时间复杂度:线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 算法 C 的时间复杂度:常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 𝑂(𝑛2 ) 。
𝑇(𝑛) = 2𝑛(𝑛 + 1) + (5𝑛 + 1) + 2 完整统计 (‑.‑|||)
= 2𝑛2 + 7𝑛 + 3
𝑇(𝑛) = 𝑛2 + 𝑛 偷懒统计 (o.O)
第二步:判断渐近上界
时间复杂度由 𝑇(𝑛) 中最高阶的项来决定。这是因为在 𝑛 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
常见类型
设输入数据大小为 𝑛 ,常见的时间复杂度类型如图 2‑9 所示(按照从低到高的顺序排列)。
𝑂(1) < 𝑂(log 𝑛) < 𝑂(𝑛) < 𝑂(𝑛 log 𝑛) < 𝑂(𝑛2 ) < 𝑂(2𝑛) < 𝑂(𝑛!)
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
以冒泡排序为例,外层循环执行 𝑛 − 1 次,内层循环执行 𝑛 − 1、𝑛 − 2、…、2、1 次,平均为 𝑛/2 次,因
此时间复杂度为 𝑂((𝑛 − 1)𝑛/2) = 𝑂(𝑛2 ) :
/* 平方阶(冒泡排序) */
int bubbleSort(vector<int> &nums) {
int count = 0; // 计数器
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交换包含 3 个单元操作
}
}
}
return count;
}
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 𝑛 次分裂后停止:
/* 指数阶(递归实现) */
int expRecur(int n) {
if (n == 1)
return 1;
return expRecur(n - 1) + expRecur(n - 1) + 1;
}
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 𝑛 ,由于每轮缩减到一半,因此循环次数是 log2 𝑛 ,即 2 𝑛 的反函数。
/* 对数阶(循环实现) */
int logarithmic(int n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为 log2 𝑛 的递归树
/* 对数阶(递归实现) */
int logRecur(int n) {
if (n <= 1)
return 0;
return logRecur(n / 2) + 1;
}
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
𝑂(log 𝑛) 的底数是多少?
准确来说,“一分为 𝑚”对应的时间复杂度是 𝑂(log𝑚 𝑛) 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
𝑂(log𝑚 𝑛) = 𝑂(log𝑘 𝑛/ log𝑘 𝑚) = 𝑂(log𝑘 𝑛)
也就是说,底数 𝑚 可以在不影响复杂度的前提下转换。因此我们通常会省略底数 𝑚 ,将对数阶直接记为 𝑂(log 𝑛) 。
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 𝑂(log 𝑛) 和 𝑂(𝑛) 。相关代码如下
/* 线性对数阶 */
int linearLogRecur(int n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (int i = 0; i < n; i++) {
count++;
}
return count;
}
二叉树的每一层的操作总数都为 𝑛 ,树共有 log2 𝑛 + 1 层,因此时间复杂度为 𝑂(𝑛 log 𝑛)
主流排序算法的时间复杂度通常为 𝑂(𝑛 log 𝑛) ,例如快速排序、归并排序、堆排序等。
阶乘阶对应数学上的“全排列”问题。给定 𝑛 个互不重复的元素,求其所有可能的排列方案,方案数量为:
𝑛! = 𝑛 × (𝑛 − 1) × (𝑛 − 2) × ⋯ × 2 × 1
阶乘通常使用递归实现。如图 2‑14 和以下代码所示,第一层分裂出 𝑛 个,第二层分裂出 𝑛 − 1 个,以此类
推,直至第 𝑛 层时停止分裂:
/* 阶乘阶(递归实现) */
int factorialRecur(int n) {
if (n == 0)
return 1;
int count = 0;
// 从 1 个分裂出 n 个
for (int i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
最差、最佳、平均时间复杂度
我们通常使用最差时间复杂度作为算法效率的评判标准
空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
‧ 输入空间:用于存储算法的输入数据。
‧ 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据
‧ 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
暂存空间可以进一步划分为三个部分。
‧ 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
‧ 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数
返回后,栈帧空间会被释放。
‧ 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分
推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复
杂度和空间复杂度通常非常困难。
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的
思路称为“以空间换时间”;反之,则称为“以时间换空间”。
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通
常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。
数据结构
数据结构分类
常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。
逻辑结构:线性与非线性
逻辑结构揭示了数据元素之间的逻辑关系。在数组和链表中,数据按照一定顺序排列,体现了数据之间的线
性关系;而在树中,数据从顶部向下按层次排列,表现出“祖先”与“后代”之间的派生关系;图则由节点
和边构成,反映了复杂的网络关系。
如图 3‑1 所示,逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈
线性排列;非线性结构则相反,呈非线性排列。
‧ 线性数据结构:数组、链表、栈、队列、哈希表,元素之间是一对一的顺序关系。
‧ 非线性数据结构:树、堆、图、哈希表。
非线性数据结构可以进一步划分为树形结构和网状结构。
‧ 树形结构:树、堆、哈希表,元素之间是一对多的关系。
‧ 网状结构:图,元素之间是多对多的关系。
物理结构:连续与分散
当算法程序运行时,正在处理的数据主要存储在内存中。图 3‑2 展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据。
系统通过内存地址来访问目标位置的数据。如图 3‑2 所示,计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。因此在数据结****构与算法的设计中,内存资源是一个重要的考虑因素。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。
如图 3‑3 所示,物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,两种物理结构在时间效率和空间效率方面呈现出互补的特点。
值得说明的是,==所有数据结构都是基于数组、链表或二者的组合实现的。==例如,栈和队列既可以使用数组实
现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。
‧ 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 ≥ 3 的数组)等。
‧ 基于链表可实现:栈、队列、哈希表、树、堆、图等。
链表在初始化后,仍可以在程序运行过程中对其长度进行调整,因此也称“动态数据结构”。数组在初始化后长度不可变,因此也称“静态数据结构”。值得注意的是,数组可通过重新分配内存实现长度变化,从而具备一定的“动态性”。
数组与链表
数组
数组(array)是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的
位置称为该元素的索引(index)。图 4‑1 展示了数组的主要概念和存储方式。
链表
链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。
引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。
链表的组成单位是节点(node)对象。每个节点都包含两项数据:节点的“值”和指向下一节
点的“引用”。
‧ 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
‧ 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为 null、nullptr 和 None 。
‧ 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。
如以下代码所示,链表节点 ListNode 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,链****表比数组占用更多的内存空间。
常见链表类型
单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 None 。
‧ 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
‧ 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
/* 双向链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向后继节点的指针
ListNode *prev; // 指向前驱节点的指针
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
};
列表(动态数组)
列表(list)是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。
‧ 链表天然可以看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
‧ 数组也支持元素增删查改,但由于其长度不可变,因此只能看作一个具有长度限制的列表。
当使用数组实现列表时,长度不可变的性质会导致列表的实用性降低。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间浪费。
为解决此问题,我们可以使用动态数组(dynamic array)来实现列表。它继承了数组的各项优点,并且可以在程序运行过程中进行动态扩容。
实际上,许多编程语言中的标准库提供的列表是基于动态数组实现的,例如 Python 中的 list 、Java 中的ArrayList 、C++ 中的 vector 和 C# 中的 List 等。在接下来的讨论中,我们将把“列表”和“动态数组”视为等同的概念。
列表实现
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
‧ 初始容量:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
‧ 数量记录:声明一个变量 size ,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据
此变量,我们可以定位列表尾部,以及判断是否需要扩容。
‧ 扩容机制:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将
当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
/* 列表类 */
class MyList {
private:
int *arr; // 数组(存储列表元素)
int arrCapacity = 10; // 列表容量
int arrSize = 0; // 列表长度(当前元素数量)
int extendRatio = 2; // 每次列表扩容的倍数
public:
/* 构造方法 */
MyList() {
arr = new int[arrCapacity];
}
/* 析构方法 */
~MyList() {
delete[] arr;
}
/* 获取列表长度(当前元素数量)*/
int size() {
return arrSize;
}
/* 获取列表容量 */
int capacity() {
return arrCapacity;
}
/* 访问元素 */
int get(int index) {
// 索引如果越界,则抛出异常,下同
if (index < 0 || index >= size())
throw out_of_range("索引越界");
return arr[index];
}
/* 更新元素 */
void set(int index, int num) {
if (index < 0 || index >= size())
throw out_of_range("索引越界");
arr[index] = num;
}
/* 在尾部添加元素 */
void add(int num) {
// 元素数量超出容量时,触发扩容机制
if (size() == capacity())
extendCapacity();
arr[size()] = num;
// 更新元素数量
arrSize++;
}
/* 在中间插入元素 */
void insert(int index, int num) {
if (index < 0 || index >= size())
throw out_of_range("索引越界");
// 元素数量超出容量时,触发扩容机制
if (size() == capacity())
extendCapacity();
// 将索引 index 以及之后的元素都向后移动一位
for (int j = size() - 1; j >= index; j--) {
arr[j + 1] = arr[j];
}
arr[index] = num;
// 更新元素数量
arrSize++;
}
/* 删除元素 */
int remove(int index) {
if (index < 0 || index >= size())
throw out_of_range("索引越界");
int num = arr[index];
// 将索引 index 之后的元素都向前移动一位
for (int j = index; j < size() - 1; j++) {
arr[j] = arr[j + 1];
}
// 更新元素数量
arrSize--;
// 返回被删除的元素
return num;
}
/* 列表扩容 */
void extendCapacity() {
// 新建一个长度为原数组 extendRatio 倍的新数组
int newCapacity = capacity() * extendRatio;
int *tmp = arr;
arr = new int[newCapacity];
// 将原数组中的所有元素复制到新数组
for (int i = 0; i < size(); i++) {
arr[i] = tmp[i];
}
// 释放内存
delete[] tmp;
arrCapacity = newCapacity;
}
/* 将列表转换为 Vector 用于打印 */
vector<int> toVector() {
// 仅转换有效长度范围内的列表元素
vector<int> vec(size());
for (int i = 0; i < size(); i++) {
vec[i] = arr[i];
}
return vec;
}
};
栈与队列
栈
栈(stack)是一种遵循先入后出逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。
如图 5‑1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。
/* 初始化栈 */
stack<int> stack;
/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
/* 访问栈顶元素 */
int top = stack.top();
/* 元素出栈 */
stack.pop(); // 无返回值
/* 获取栈的长度 */
int size = stack.size();
/* 判断是否为空 */
bool empty = stack.empty();
栈的实现
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。换句话说,我们可以“屏蔽”数组或链表的部分无关
操作,使其对外表现的逻辑符合栈的特性。
1. 基于链表的实现
使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
如图 5‑2 所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
/* 基于链表实现的栈 */
class LinkedListStack {
private:
ListNode *stackTop; // 将头节点作为栈顶
int stkSize; // 栈的长度
public:
LinkedListStack() {
stackTop = nullptr;
stkSize = 0;
}
~LinkedListStack() {
// 遍历链表删除节点,释放内存
freeMemoryLinkedList(stackTop);
}
/* 获取栈的长度 */
int size() {
return stkSize;
}
/* 判断栈是否为空 */
bool isEmpty() {
return size() == 0;
}
/* 入栈 */
void push(int num) {
ListNode *node = new ListNode(num);
node->next = stackTop;
stackTop = node;
stkSize++;
}
/* 出栈 */
int pop() {
int num = top();
ListNode *tmp = stackTop;
stackTop = stackTop->next;
// 释放内存
delete tmp;
stkSize--;
return num;
}
/* 访问栈顶元素 */
int top() {
if (isEmpty())
throw out_of_range("栈为空");
return stackTop->val;
}
/* 将 List 转化为 Array 并返回 */
vector<int> toVector() {
ListNode *node = stackTop;
vector<int> res(size());
for (int i = res.size() - 1; i >= 0; i--) {
res[i] = node->val;
node = node->next;
}
return res;
}
};
2. 基于数组的实现
使用数组实现栈时,我们可以将数组的尾部作为栈顶。如图 5‑3 所示,入栈与出栈操作分别对应在数组尾部
添加元素与删除元素,时间复杂度都为 𝑂(1) 。
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。
以下为示例代码:
/* 基于数组实现的栈 */
class ArrayStack {
private:
vector<int> stack;
public:
/* 获取栈的长度 */
int size() {
return stack.size();
}
/* 判断栈是否为空 */
bool isEmpty() {
return stack.size() == 0;
}
/* 入栈 */
void push(int num) {
stack.push_back(num);
}
/* 出栈 */
int pop() {
int num = top();
stack.pop_back();
return num;
}
/* 访问栈顶元素 */
int top() {
if (isEmpty())
throw out_of_range("栈为空");
return stack.back();
}
/* 返回 Vector */
vector<int> toVector() {
return stack;
}
};
栈的典型应用
浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执
行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支
持后退和前进,那么需要两个栈来配合实现。
‧ 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归
函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。
队列
队列(queue)是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断
加入队列尾部,而位于队列头部的人逐个离开。
如图 5‑4 所示,我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删
除队首元素的操作称为“出队”。
队列典型应用
淘宝订单。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期
间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
‧ 各类待办事项。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等,
队列在这些场景中可以有效地维护处理顺序。
双向队列
在队列中,我们仅能删除头部元素或在尾部添加元素。如图 5‑7 所示,双向队列(double‑ended queue)提
供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
/* 初始化双向队列 */
deque<int> deque;
/* 元素入队 */
deque.push_back(2); // 添加至队尾
deque.push_back(5);
deque.push_back(4);
deque.push_front(3); // 添加至队首
deque.push_front(1);
/* 访问元素 */
int front = deque.front(); // 队首元素
int back = deque.back(); // 队尾元素
/* 元素出队 */
deque.pop_front(); // 队首元素出队
deque.pop_back(); // 队尾元素出队
/* 获取双向队列的长度 */
int size = deque.size();
/* 判断双向队列是否为空 */
bool empty = deque.empty();
哈希表
哈希表(hash table),又称散列表,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具体而
言,我们向哈希表中输入一个键 key ,则可以在 𝑂(1) 时间内获取对应的值 value 。
除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如表 6‑1 所示。
‧ 添加元素:仅需将元素添加至数组(链表)的尾部即可,使用 𝑂(1) 时间。
‧ 查询元素:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 𝑂(𝑛) 时间。
‧ 删除元素:需要先查询到元素,再从数组(链表)中删除,使用 𝑂(𝑛) 时间。
哈希表常用操作
/* 初始化哈希表 */
unordered_map<int, string> map;
/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈";
map[15937] = "小啰";
map[16750] = "小算";
map[13276] = "小法";
map[10583] = "小鸭";
/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
string name = map[15937];
/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.erase(10583);
哈希表有三种常用的遍历方式:遍历键值对、遍历键和遍历值。示例代码如下:
/* 遍历哈希表 */
// 遍历键值对 key->value
for (auto kv: map) {
cout << kv.first << " -> " << kv.second << endl;
}
// 使用迭代器遍历 key->value
for (auto iter = map.begin(); iter != map.end(); iter++) {
cout << iter->first << "->" << iter->second << endl;
}
哈希表简单实现
我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为桶
(bucket),每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value 。
那么,如何基于 key 定位对应的桶呢?这是通过哈希函数(hash function)实现的。哈希函数的作用是将一个
较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key ,输出空间是所有桶(数组
索引)。换句话说,输入一个 key ,我们可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置。
输入一个 key ,哈希函数的计算过程分为以下两步。
- 通过某种哈希算法 hash() 计算得到哈希值。
- 将哈希值对桶数量(数组长度)capacity 取模,从而获取该 key 对应的数组索引 index 。
index = hash(key) % capacity
随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value 。
设数组长度 capacity = 100、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。图 6‑2 以 key 学号
和 value 姓名为例,展示了哈希函数的工作原理。
/* 键值对 */
struct Pair {
public:
int key;
string val;
Pair(int key, string val) {
this->key = key;
this->val = val;
}
};
/* 基于数组实现的哈希表 */
class ArrayHashMap {
private:
vector<Pair *> buckets;
public:
ArrayHashMap() {
// 初始化数组,包含 100 个桶
buckets = vector<Pair *>(100);
}
~ArrayHashMap() {
// 释放内存
for (const auto &bucket : buckets) {
delete bucket;
}
buckets.clear();
}
/* 哈希函数 */
int hashFunc(int key) {
int index = key % 100;
return index;
}
/* 查询操作 */
string get(int key) {
int index = hashFunc(key);
Pair *pair = buckets[index];
if (pair == nullptr)
return "";
return pair->val;
}
/* 添加操作 */
void put(int key, string val) {
Pair *pair = new Pair(key, val);
int index = hashFunc(key);
buckets[index] = pair;
}
/* 删除操作 */
void remove(int key) {
int index = hashFunc(key);
// 释放内存并置为 nullptr
delete buckets[index];
buckets[index] = nullptr;
}
/* 获取所有键值对 */
vector<Pair *> pairSet() {
vector<Pair *> pairSet;
for (Pair *pair : buckets) {
if (pair != nullptr) {
pairSet.push_back(pair);
}
}
return pairSet;
}
/* 获取所有键 */
vector<int> keySet() {
vector<int> keySet;
for (Pair *pair : buckets) {
if (pair != nullptr) {
keySet.push_back(pair->key);
}
}
return keySet;
}
/* 获取所有值 */
vector<string> valueSet() {
vector<string> valueSet;
for (Pair *pair : buckets) {
if (pair != nullptr) {
valueSet.push_back(pair->val);
}
}
return valueSet;
}
/* 打印哈希表 */
void print() {
for (Pair *kv : pairSet()) {
cout << kv->key << " -> " << kv->val << endl;
}
}
};
哈希冲突与扩容
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就
进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量
的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
- 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。
- 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址
常见哈希算法
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和
异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,
并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA‑1、SHA‑2 和 SHA‑3 等。它们可以将任意长
度的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一
部分研究人员和黑客则致力于寻找哈希算法的安全性问题。表 6‑2 展示了在实际应用中常见的哈希算法。
‧ MD5 和 SHA‑1 已多次被成功攻击,因此它们被各类安全应用弃用。
‧ SHA‑2 系列中的 SHA‑256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全
应用与协议中。
‧ SHA‑3 相较 SHA‑2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA‑2 系列。
树
二叉树
二叉树(binary tree)是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”
的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用
/* 二叉树节点结构体 */
struct TreeNode {
int val; // 节点值
TreeNode *left; // 左子节点指针
TreeNode *right; // 右子节点指针
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
二叉树的常用术语如图 7‑2 所示。
‧ 根节点(root node):位于二叉树顶层的节点,没有父节点。
‧ 叶节点(leaf node):没有子节点的节点,其两个指针均指向 None
‧ 边(edge):连接两个节点的线段,即节点引用(指针)。
‧ 节点所在的层(level):从顶至底递增,根节点所在层为 1 。
‧ 节点的度(degree):节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
‧ 二叉树的高度(height):从根节点到最远叶节点所经过的边的数量。
‧ 节点的深度(depth):从根节点到该节点所经过的边的数量。
‧ 节点的高度(height):从距离该节点最远的叶节点到该节点所经过的边的数量。
二叉树遍历
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,
树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。
二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等。
层序遍历
如图 7‑9 所示,层序遍历(level‑order traversal)从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的
顺序访问节点。
层序遍历本质上属于广度优先遍历(breadth‑first traversal),也称广度优先搜索(breadth‑first search, BFS),
它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
1.代码实现
广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。实现代码如下:
/* 层序遍历 */
vector<int> levelOrder(TreeNode *root) {
// 初始化队列,加入根节点
queue<TreeNode *> queue;
queue.push(root);
// 初始化一个列表,用于保存遍历序列
vector<int> vec;
while (!queue.empty()) {
TreeNode *node = queue.front();
queue.pop(); // 队列出队
vec.push_back(node->val); // 保存节点值
if (node->left != nullptr)
queue.push(node->left); // 左子节点入队
if (node->right != nullptr)
queue.push(node->right); // 右子节点入队
}
return vec;
}
2. 复杂度分析
‧ 时间复杂度为 𝑂(𝑛) :所有节点被访问一次,使用 𝑂(𝑛) 时间,其中 𝑛 为节点数量。
‧ 空间复杂度为 𝑂(𝑛) :在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在
(𝑛 + 1)/2 个节点,占用 𝑂(𝑛) 空间。
前序、中序、后序遍历(深度优先遍历)
相应地,前序、中序和后序遍历都属于深度优先遍历(depth‑first traversal),也称深度优先搜索(depth‑first
search, DFS),它体现了一种“先走到尽头,再回溯继续”的遍历方式。
图 7‑10 展示了对二叉树进行深度优先遍历的工作原理。深度优先遍历就像是绕着整棵二叉树的外围“走”一
圈,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
1.代码实现
深度优先搜索通常基于递归实现:
/* 前序遍历 */
void preOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:根节点 -> 左子树 -> 右子树
vec.push_back(root->val);
preOrder(root->left);
preOrder(root->right);
}
/* 中序遍历 */
void inOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(root->left);
vec.push_back(root->val);
inOrder(root->right);
}
/* 后序遍历 */
void postOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(root->left);
postOrder(root->right);
vec.push_back(root->val);
}
前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分。
1.“递”表示开启新方法,程序在此过程中访问下一个节点。
2.“归”表示函数返回,代表当前节点已经访问完毕。
二叉搜索树
二叉搜索树满足以下条件
- 对于根节点,左子树中所有节点的值 < 根节点的值 < 右子树中所有节点的值。
- 任意节点的左、右子树也是二叉搜索树,即同样满足条件 1. 。
堆
堆
堆(heap)是一种满足特定条件的完全二叉树,主要可分为两种类型,如图 8‑1 所示。
‧ 小顶堆(min heap):任意节点的值 ≤ 其子节点的值。
‧ 大顶堆(max heap):任意节点的值 ≥ 其子节点的值。
堆作为完全二叉树的一个特例,具有以下特性。
‧ 最底层节点靠左填充,其他层的节点都被填满。
‧ 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
‧ 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。
堆的常用操作
需要指出的是,许多编程语言提供的是优先队列(priority queue),这是一种抽象的数据结构,定义为具有优
先级排序的队列。
实际上,堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。从使用角度来看,
我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一称作“堆”。
在实际应用中,我们可以直接使用编程语言提供的堆类(或优先队列类)。
类似于排序算法中的“从小到大排列”和“从大到小排列”,我们可以通过设置一个 flag 或修改 Comparator
实现“小顶堆”与“大顶堆”之间的转换。代码如下所示:
/* 初始化堆 */
// 初始化小顶堆
priority_queue<int, vector<int>, greater<int>> minHeap;
// 初始化大顶堆
priority_queue<int, vector<int>, less<int>> maxHeap;
/* 元素入堆 */
maxHeap.push(1);
maxHeap.push(3);
maxHeap.push(2);
maxHeap.push(5);
maxHeap.push(4);
/* 获取堆顶元素 */
int peek = maxHeap.top(); // 5
/* 堆顶元素出堆 */
// 出堆元素会形成一个从大到小的序列
maxHeap.pop(); // 5
maxHeap.pop(); // 4
maxHeap.pop(); // 3
maxHeap.pop(); // 2
maxHeap.pop(); // 1
/* 获取堆大小 */
int size = maxHeap.size();
/* 判断堆是否为空 */
bool isEmpty = maxHeap.empty();
/* 输入列表并建堆 */
vector<int> input{1, 3, 2, 5, 4};
priority_queue<int, vector<int>, greater<int>> minHeap(input.begin(), input.end());
堆的常见应用
‧ 优先队列:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 𝑂(log 𝑛)
,而建队操作为 𝑂(𝑛) ,这些操作都非常高效。
‧ 堆排序:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数
据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
‧ 获取最大的 𝑘 个元素:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻
作为微博热搜,选取销量前 10 的商品等。
Top‑k 问题
Question
给定一个长度为 𝑛 的无序数组 nums ,请返回数组中最大的 𝑘 个元素。
我们可以基于堆更加高效地解决 Top‑k 问题,流程如图 8‑8 所示。
- 初始化一个小顶堆,其堆顶元素最小。
- 先将数组的前 𝑘 个元素依次入堆。
- 从第 𝑘 + 1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
- 遍历完成后,堆中保存的就是最大的 𝑘 个元素。
/* 基于堆查找数组中最大的 k 个元素 */
priority_queue<int, vector<int>, greater<int>> topKHeap(vector<int> &nums, int k) {
// 初始化小顶堆
priority_queue<int, vector<int>, greater<int>> heap;
// 将数组的前 k 个元素入堆
for (int i = 0; i < k; i++) {
heap.push(nums[i]);
}
// 从第 k+1 个元素开始,保持堆的长度为 k
for (int i = k; i < nums.size(); i++) {
// 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
if (nums[i] > heap.top()) {
heap.pop();
heap.push(nums[i]);
}
}
return heap;
}
总共执行了 𝑛 轮入堆和出堆,堆的最大长度为 𝑘 ,因此时间复杂度为 𝑂(𝑛 log 𝑘) 。该方法的效率很高,当
𝑘 较小时,时间复杂度趋向 𝑂(𝑛) ;当 𝑘 较大时,时间复杂度不会超过 𝑂(𝑛 log 𝑛) 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现
最大的 𝑘 个元素的动态更新。
图
图
图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。我们可以将图 𝐺 抽象地表示为一
组顶点 𝑉 和一组边 𝐸 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。
𝑉 = {1, 2, 3, 4, 5}
𝐸 = {(1, 2), (1, 3), (1, 5), (2, 3), (2, 4), (2, 5), (4, 5)}
𝐺 = {𝑉 , 𝐸}
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数
据结构。如图 9‑1 所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,因而更
为复杂。
图的表示
1.邻接矩阵
设图的顶点数量为 𝑛 ,邻接矩阵(adjacency matrix)使用一个 𝑛 × 𝑛 大小的矩阵来表示图,每一行(列)代
表一个顶点,矩阵元素代表边,用 1 或 0 表示两个顶点之间是否存在边。
邻接矩阵具有以下特性。
‧ 顶点不能与自身相连,因此邻接矩阵主对角线元素没有意义。
‧ 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
‧ 将邻接矩阵的元素从 1 和 0 替换为权重,则可表示有权图。
使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查改操作的效率很高,时间复杂度
均为 𝑂(1) 。然而,矩阵的空间复杂度为 𝑂(𝑛2 ) ,内存占用较多。
2. 邻接表
邻接表(adjacency list)使用 𝑛 个链表来表示图,链表节点表示顶点。第 𝑖 个链表对应顶点 𝑖 ,其中存储了该
顶点的所有邻接顶点(与该顶点相连的顶点)。
邻接表仅存储实际存在的边,而边的总数通常远小于 𝑛 2 ,因此它更加节省空间。然而,在邻接表中需要通
过遍历链表来查找边,因此其时间效率不如邻接矩阵。
观察图 9‑6 ,邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。
比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 𝑂(𝑛) 优化至 𝑂(log 𝑛) ;还可
以把链表转换为哈希表,从而将时间复杂度降至 𝑂(1) 。
图的常见应用
基于邻接矩阵的实现
/* 基于邻接矩阵实现的无向图类 */
class GraphAdjMat {
vector<int> vertices; // 顶点列表,元素代表“顶点值”,索引代表“顶点索引”
vector<vector<int>> adjMat; // 邻接矩阵,行列索引对应“顶点索引”
public:
/* 构造方法 */
GraphAdjMat(const vector<int> &vertices, const vector<vector<int>> &edges) {
// 添加顶点
for (int val : vertices) {
addVertex(val);
}
// 添加边
// 请注意,edges 元素代表顶点索引,即对应 vertices 元素索引
for (const vector<int> &edge : edges) {
addEdge(edge[0], edge[1]);
}
}
/* 获取顶点数量 */
int size() const {
return vertices.size();
}
/* 添加顶点 */
void addVertex(int val) {
int n = size();
// 向顶点列表中添加新顶点的值
vertices.push_back(val);
// 在邻接矩阵中添加一行
adjMat.emplace_back(vector<int>(n, 0));
// 在邻接矩阵中添加一列
for (vector<int> &row : adjMat) {
row.push_back(0);
}
}
/* 删除顶点 */
void removeVertex(int index) {
if (index >= size()) {
throw out_of_range("顶点不存在");
}
// 在顶点列表中移除索引 index 的顶点
vertices.erase(vertices.begin() + index);
// 在邻接矩阵中删除索引 index 的行
adjMat.erase(adjMat.begin() + index);
// 在邻接矩阵中删除索引 index 的列
for (vector<int> &row : adjMat) {
row.erase(row.begin() + index);
}
}
/* 添加边 */
// 参数 i, j 对应 vertices 元素索引
void addEdge(int i, int j) {
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw out_of_range("顶点不存在");
}
// 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
/* 删除边 */
// 参数 i, j 对应 vertices 元素索引
void removeEdge(int i, int j) {
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw out_of_range("顶点不存在");
}
adjMat[i][j] = 0;
adjMat[j][i] = 0;
}
/* 打印邻接矩阵 */
void print() {
cout << "顶点列表 = ";
printVector(vertices);
cout << "邻接矩阵 =" << endl;
printVectorMatrix(adjMat);
}
};
基于邻接表的实现
设无向图的顶点总数为 𝑛、边总数为 𝑚 ,
‧ 添加边:在顶点对应链表的末尾添加边即可,使用 𝑂(1) 时间。因为是无向图,所以需要同时添加两个
方向的边。
‧ 删除边:在顶点对应链表中查找并删除指定边,使用 𝑂(𝑚) 时间。在无向图中,需要同时删除两个方
向的边。
‧ 添加顶点:在邻接表中添加一个链表,并将新增顶点作为链表头节点,使用 𝑂(1) 时间。
‧ 删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用 𝑂(𝑛 + 𝑚) 时间。
‧ 初始化:在邻接表中创建 𝑛 个顶点和 2𝑚 条边,使用 𝑂(𝑛 + 𝑚) 时间。
以下是邻接表的代码实现。对比图 9‑8 ,实际代码有以下不同。
‧ 为了方便添加与删除顶点,以及简化代码,我们使用列表(动态数组)来代替链表。
‧ 使用哈希表来存储邻接表,key 为顶点实例,value 为该顶点的邻接顶点列表(链表)。
另外,我们在邻接表中使用 Vertex 类来表示顶点,这样做的原因是:如果与邻接矩阵一样,用列表索引来区
分不同顶点,那么假设要删除索引为 𝑖 的顶点,则需遍历整个邻接表,将所有大于 𝑖 的索引全部减 1 ,效率
很低。而如果每个顶点都是唯一的 Vertex 实例,删除某一顶点之后就无须改动其他顶点了。
/* 基于邻接表实现的无向图类 */
class GraphAdjList {
public:
// 邻接表,key:顶点,value:该顶点的所有邻接顶点
unordered_map<Vertex *, vector<Vertex *>> adjList;
/* 在 vector 中删除指定节点 */
void remove(vector<Vertex *> &vec, Vertex *vet) {
for (int i = 0; i < vec.size(); i++) {
if (vec[i] == vet) {
vec.erase(vec.begin() + i);
break;
}
}
}
/* 构造方法 */
GraphAdjList(const vector<vector<Vertex *>> &edges) {
// 添加所有顶点和边
for (const vector<Vertex *> &edge : edges) {
addVertex(edge[0]);
addVertex(edge[1]);
addEdge(edge[0], edge[1]);
}
}
/* 获取顶点数量 */
int size() {
return adjList.size();
}
/* 添加边 */
void addEdge(Vertex *vet1, Vertex *vet2) {
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
throw invalid_argument("不存在顶点");
// 添加边 vet1 - vet2
adjList[vet1].push_back(vet2);
adjList[vet2].push_back(vet1);
}
/* 删除边 */
void removeEdge(Vertex *vet1, Vertex *vet2) {
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
throw invalid_argument("不存在顶点");
// 删除边 vet1 - vet2
remove(adjList[vet1], vet2);
remove(adjList[vet2], vet1);
}
/* 添加顶点 */
void addVertex(Vertex *vet) {
if (adjList.count(vet))
return;
// 在邻接表中添加一个新链表
adjList[vet] = vector<Vertex *>();
}
/* 删除顶点 */
void removeVertex(Vertex *vet) {
if (!adjList.count(vet))
throw invalid_argument("不存在顶点");
// 在邻接表中删除顶点 vet 对应的链表
adjList.erase(vet);
// 遍历其他顶点的链表,删除所有包含 vet 的边
for (auto &adj : adjList) {
remove(adj.second, vet);
}
}
/* 打印邻接表 */
void print() {
cout << "邻接表 =" << endl;
for (auto &adj : adjList) {
const auto &key = adj.first;
const auto &vec = adj.second;
cout << key->val << ": ";
printVector(vetsToVals(vec));
}
}
};
似乎邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更
高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体
现了“以时间换空间”的原则。
图的遍历
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以
把树看作图的一种特例。显然,树的遍历操作也是图的遍历操作的一种特例。
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:广度优先遍历和深度优先遍历
广度优先遍历
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外
扩张。
1. 算法实现
BFS 通常借助队列来实现,代码如下所示。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想
异曲同工。
- 将遍历起始顶点 startVet 加入队列,并开启循环。
- 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。
- 循环步骤 2. ,直到所有顶点被访问完毕后结束。
为了防止重复遍历顶点,我们需要借助一个哈希表 visited 来记录哪些节点已被访问
/* 广度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
vector<Vertex *> graphBFS(GraphAdjList &graph, Vertex *startVet) {
// 顶点遍历序列
vector<Vertex *> res;
// 哈希集合,用于记录已被访问过的顶点
unordered_set<Vertex *> visited = {startVet};
// 队列用于实现 BFS
queue<Vertex *> que;
que.push(startVet);
// 以顶点 vet 为起点,循环直至访问完所有顶点
while (!que.empty()) {
Vertex *vet = que.front();
que.pop(); // 队首顶点出队
res.push_back(vet); // 记录访问顶点
// 遍历该顶点的所有邻接顶点
for (auto adjVet : graph.adjList[vet]) {
if (visited.count(adjVet))
continue; // 跳过已被访问的顶点
que.push(adjVet); // 只入队未访问的顶点
visited.emplace(adjVet); // 标记该顶点已被访问
}
}
// 返回顶点遍历序列
return res;
}
2. 复杂度分析
时间复杂度:所有顶点都会入队并出队一次,使用 𝑂(|𝑉 |) 时间;在遍历邻接顶点的过程中,由于是无向图,
因此所有边都会被访问 2 次,使用 𝑂(2|𝐸|) 时间;总体使用 𝑂(|𝑉 | + |𝐸|) 时间。
空间复杂度:列表 res ,哈希表 visited ,队列 que 中的顶点数量最多为 |𝑉 | ,使用 𝑂(|𝑉 |) 空间。
深度优先遍历
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如图 9‑11 所示,从左上角顶点出发,访问
当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完
成。
1. 算法实现
/* 深度优先遍历辅助函数 */
void dfs(GraphAdjList &graph, unordered_set<Vertex *> &visited, vector<Vertex *> &res, Vertex *vet) {
res.push_back(vet); // 记录访问顶点
visited.emplace(vet); // 标记该顶点已被访问
// 遍历该顶点的所有邻接顶点
for (Vertex *adjVet : graph.adjList[vet]) {
if (visited.count(adjVet))
continue; // 跳过已被访问的顶点
// 递归访问邻接顶点
dfs(graph, visited, res, adjVet);
}
}
/* 深度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
vector<Vertex *> graphDFS(GraphAdjList &graph, Vertex *startVet) {
// 顶点遍历序列
vector<Vertex *> res;
// 哈希集合,用于记录已被访问过的顶点
unordered_set<Vertex *> visited;
dfs(graph, visited, res, startVet);
return res;
}
深度优先遍历的算法流程如图 9‑12 所示。
‧ 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
‧ 曲虚线代表向上回溯,表示此递归方法已经返回,回溯到了开启此方法的位置。
为了加深理解,建议将图 9‑12 与代码结合起来,在脑中模拟(或者用笔画下来)整个 DFS 过程,包括每个
递归方法何时开启、何时返回。
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中,我们
也需要借助一个哈希表 visited 来记录已被访问的顶点,以避免重复访问顶点。
2. 复杂度分析
时间复杂度:所有顶点都会被访问 1 次,使用 𝑂(|𝑉 |) 时间;所有边都会被访问 2 次,使用 𝑂(2|𝐸|) 时间;
总体使用 𝑂(|𝑉 | + |𝐸|) 时间。
空间复杂度:列表 res ,哈希表 visited 顶点数量最多为 |𝑉 | ,递归深度最大为 |𝑉 | ,因此使用 𝑂(|𝑉 |) 空
间。
搜索
搜索是一场未知的冒险,我们或许需要走遍神秘空间的每个角落,又或许可以快速锁定目标。
在这场寻觅之旅中,每一次探索都可能得到一个未曾料想的答案。
二分查找
二分查找(binary search)是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范
围,直至找到目标元素或搜索区间为空为止。
Question
给定一个长度为 𝑛 的数组 nums ,元素按从小到大的顺序排列且不重复(前提)。请查找并返回元素 target 在
该数组中的索引。若数组不包含该元素,则返回 −1 。示例如图 10‑1 所示。
如图 10‑2 所示,我们先初始化指针 𝑖 = 0 和 𝑗 = 𝑛 − 1 ,分别指向数组首元素和尾元素,代表搜索区间
[0, 𝑛 − 1] 。请注意,中括号表示闭区间,其包含边界值本身。
接下来,循环执行以下两步。
- 计算中点索引 𝑚 = ⌊(𝑖 + 𝑗)/2⌋ ,其中 ⌊ ⌋ 表示向下取整操作。
- 判断 nums[m] 和 target 的大小关系,分为以下三种情况。
- 当 nums[m] < target 时,说明 target 在区间 [𝑚 + 1, 𝑗] 中,因此执行 𝑖 = 𝑚 + 1 。
- 当 nums[m] > target 时,说明 target 在区间 [𝑖, 𝑚 − 1] 中,因此执行 𝑗 = 𝑚 − 1 。
- 当 nums[m] = target 时,说明找到 target ,因此返回索引 𝑚 。
若数组不包含目标元素,搜索区间最终会缩小为空。此时返回 −1 。
值得注意的是,由于 𝑖 和 𝑗 都是 int 类型,因此 𝑖 + 𝑗 可能会超出 int 类型的取值范围。为了避免大数越界,
我们通常采用公式 𝑚 = ⌊𝑖 + (𝑗 − 𝑖)/2⌋ 来计算中点。
/* 二分查找(双闭区间) */
int binarySearch(vector<int> &nums, int target) {
// 初始化双闭区间 [0, n-1] ,即 i, j 分别指向数组首元素、尾元素
int i = 0, j = nums.size() - 1;
// 循环,当搜索区间为空时跳出(当 i > j 时为空)
while (i <= j) {
int m = i + (j - i) / 2; // 计算中点索引 m
if (nums[m] < target) // 此情况说明 target 在区间 [m+1, j] 中
i = m + 1;
else if (nums[m] > target) // 此情况说明 target 在区间 [i, m-1] 中
j = m - 1;
else // 找到目标元素,返回其索引
return m;
}
// 未找到目标元素,返回 -1
return -1;
}
时间复杂度为 𝑂(log 𝑛) :在二分循环中,区间每轮缩小一半,因此循环次数为 log2 𝑛 。
空间复杂度为 𝑂(1) :指针 𝑖 和 𝑗 使用常数大小空间
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此通过指针 𝑖 和指针 𝑗 缩小区间的操作也是对称
的。这样更不容易出错,因此一般建议采用“双闭区间”的写法
优点与局限性
二分查找在时间和空间方面都有较好的性能。
‧ 二分查找的时间效率高。在大数据量下,对数阶的时间复杂度具有显著优势。例如,当数据大小 𝑛 = 220
时,线性查找需要 2 20 = 1048576 轮循环,而二分查找仅需 log2 2 20 = 20 轮循环。
‧ 二分查找无须额外空间。相较于需要借助额外空间的搜索算法(例如哈希查找),二分查找更加节省空
间。
然而,二分查找并非适用于所有情况,主要有以下原因。
‧ 二分查找仅适用于有序数据。若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为
排序算法的时间复杂度通常为 𝑂(𝑛 log 𝑛) ,比线性查找和二分查找都更高。对于频繁插入元素的场景,
为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 𝑂(𝑛) ,也是非常昂贵的。
‧ 二分查找仅适用于数组。二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效
率较低,因此不适合应用在链表或基于链表实现的数据结构。
‧ 小数据量下,线性查找性能更佳。在线性查找中,每轮只需 1 次判断操作;而在二分查找中,需要 1 次
加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 𝑛 较小时,
线性查找反而比二分查找更快。
二分查找插入点、
二分查找不仅可用于搜索目标元素,还可用于解决许多变种问题,比如搜索目标元素的插入位置。
无重复元素的情况
Question
给定一个长度为 𝑛 的有序数组 nums 和一个元素 target ,数组不存在重复元素。现将 target 插入数
组 nums 中,并保持其有序性。若数组中已存在元素 target ,则插入到其左方。请返回插入后 target
在数组中的索引。示例如图 10‑4 所示。
/* 二分查找插入点(无重复元素) */
int binarySearchInsertionSimple(vector<int> &nums, int target) {
int i = 0, j = nums.size() - 1; // 初始化双闭区间 [0, n-1]
while (i <= j) {
int m = i + (j - i) / 2; // 计算中点索引 m
if (nums[m] < target) {
i = m + 1; // target 在区间 [m+1, j] 中
} else if (nums[m] > target) {
j = m - 1; // target 在区间 [i, m-1] 中
} else {
return m; // 找到 target ,返回插入点 m
}
}
// 未找到 target ,返回插入点 i
return i;
}
存在重复元素的情况
Question
在上一题的基础上,规定数组可能包含重复元素,其余不变。
假设数组中存在多个 target ,则普通二分查找只能返回其中一个 target 的索引,而无法确定该元素的左边
和右边还有多少 target。
题目要求将目标元素插入到最左边,所以我们需要查找数组中最左一个 target 的索引。初步考虑通过图 10‑5
所示的步骤实现。
- 执行二分查找,得到任意一个 target 的索引,记为 𝑘 。
- 从索引 𝑘 开始,向左进行线性遍历,当找到最左边的 target 时返回。
此方法虽然可用,但其包含线性查找,因此时间复杂度为 𝑂(𝑛) 。当数组中存在很多重复的 target 时,该方
法效率很低。
现考虑拓展二分查找代码。如图 10‑6 所示,整体流程保持不变,每轮先计算中点索引 𝑚 ,再判断 target 和
nums[m] 的大小关系,分为以下几种情况。
‧ 当 nums[m] < target 或 nums[m] > target 时,说明还没有找到 target ,因此采用普通二分查找的缩
小区间操作,从而使指针 𝑖 和 𝑗 向 target 靠近。
‧ 当 nums[m] == target 时,说明小于 target 的元素在区间 [𝑖, 𝑚 − 1] 中,因此采用 𝑗 = 𝑚 − 1 来缩
小区间,从而使指针 𝑗 向小于 target 的元素靠近。
循环完成后,𝑖 指向最左边的 target ,𝑗 指向首个小于 target 的元素,因此索引 𝑖 就是插入点。
观察以下代码,判断分支 nums[m] > target 和 nums[m] == target 的操作相同,因此两者可以合并。
即便如此,我们仍然可以将判断条件保持展开,因为其逻辑更加清晰、可读性更好
/* 二分查找插入点(存在重复元素) */
int binarySearchInsertion(vector<int> &nums, int target) {
int i = 0, j = nums.size() - 1; // 初始化双闭区间 [0, n-1]
while (i <= j) {
int m = i + (j - i) / 2; // 计算中点索引 m
if (nums[m] < target) {
i = m + 1; // target 在区间 [m+1, j] 中
} else if (nums[m] > target) {
j = m - 1; // target 在区间 [i, m-1] 中
} else {
j = m - 1; // 首个小于 target 的元素在区间 [i, m-1] 中
}
}
// 返回插入点 i
return i;
}
总的来看,二分查找无非就是给指针 𝑖 和 𝑗 分别设定搜索目标,目标可能是一个具体的元素(例如 target ),
也可能是一个元素范围(例如小于 target 的元素)。
在不断的循环二分中,指针 𝑖 和 𝑗 都逐渐逼近预先设定的目标。最终,它们或是成功找到答案,或是越过边
界后停止。
二分查找边界
查找左边界
Question
给定一个长度为 𝑛 的有序数组 nums ,其中可能包含重复元素。请返回数组中最左一个元素 target 的
索引。若数组中不包含该元素,则返回 −1 。
回忆二分查找插入点的方法,搜索完成后 𝑖 指向最左一个 target ,因此查找插入点本质上是在查找最左一个
target 的索引。
考虑通过查找插入点的函数实现查找左边界。请注意,数组中可能不包含 target ,这种情况可能导致以下两
种结果。
‧ 插入点的索引 𝑖 越界。
‧ 元素 nums[i] 与 target 不相等。
当遇到以上两种情况时,直接返回 −1 即可。代码如下所示:
/* 二分查找最左一个 target */
int binarySearchLeftEdge(vector<int> &nums, int target) {
// 等价于查找 target 的插入点
int i = binarySearchInsertion(nums, target);
// 未找到 target ,返回 -1
if (i == nums.size() || nums[i] != target) {
return -1;
}
// 找到 target ,返回索引 i
return i;
}
查找右边界
那么如何查找最右一个 target 呢?最直接的方式是修改代码,替换在 nums[m] == target 情况下的指针收缩
操作。代码在此省略,有兴趣的读者可以自行实现。
下面我们介绍两种更加取巧的方法。
1. 复用查找左边界
实际上,我们可以利用查找最左元素的函数来查找最右元素,具体方法为:将查找最右一个 target 转化为查
找最左一个 target + 1。
如图 10‑7 所示,查找完成后,指针 𝑖 指向最左一个 target + 1(如果存在),而 𝑗 指向最右一个 target ,因
此返回 𝑗 即可。
请注意,返回的插入点是 𝑖 ,因此需要将其减 1 ,从而获得 ?
/* 二分查找最右一个 target */
int binarySearchRightEdge(vector<int> &nums, int target) {
// 转化为查找最左一个 target + 1
int i = binarySearchInsertion(nums, target + 1);
// j 指向最右一个 target ,i 指向首个大于 target 的元素
int j = i - 1;
// 未找到 target ,返回 -1
if (j == -1 || nums[j] != target) {
return -1;
}
// 找到 target ,返回索引 j
return j;
}
哈希优化策略
我们常通过将线性查找替换为哈希查找来降低算法的时间复杂度。我们借助一个算法题来加深理解。
Question
给定一个整数数组 nums 和一个目标元素 target ,请在数组中搜索“和”为 target 的两个元素,并返
回它们的数组索引。返回任意一个解即可。
线性查找:以时间换空间
/* 方法一:暴力枚举 */
vector<int> twoSumBruteForce(vector<int> &nums, int target) {
int size = nums.size();
// 两层循环,时间复杂度为 O(n^2)
for (int i = 0; i < size - 1; i++) {
for (int j = i + 1; j < size; j++) {
if (nums[i] + nums[j] == target)
return {i, j};
}
}
return {};
}
此方法的时间复杂度为 𝑂(𝑛2 ) ,空间复杂度为 𝑂(1) ,在大数据量下非常耗时
哈希查找:以空间换时间
考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行图 10‑10 所示的步骤。
- 判断数字 target - nums[i] 是否在哈希表中,若是,则直接返回这两个元素的索引。
- 将键值对 nums[i] 和索引 i 添加进哈希表。
/* 方法二:辅助哈希表 */
vector<int> twoSumHashTable(vector<int> &nums, int target) {
int size = nums.size();
// 辅助哈希表,空间复杂度为 O(n)
unordered_map<int, int> dic;
// 单层循环,时间复杂度为 O(n)
for (int i = 0; i < size; i++) {
if (dic.find(target - nums[i]) != dic.end()) {
return {dic[target - nums[i]], i};
}
dic.emplace(nums[i], i);
}
return {};
}
此方法通过哈希查找将时间复杂度从 𝑂(𝑛2 ) 降至 𝑂(𝑛) ,大幅提升运行效率。
由于需要维护一个额外的哈希表,因此空间复杂度为 𝑂(𝑛) 。尽管如此,该方法的整体时空效率更为均衡,
因此它是本题的最优解法。
重识搜索算法
搜索算法(searching algorithm)用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条
件的元素。
搜索算法可根据实现思路分为以下两类。
‧ 通过遍历数据结构来定位目标元素,例如数组、链表、树和图的遍历等。
‧ 利用数据组织结构或数据包含的先验信息,实现高效元素查找,例如二分查找、哈希查找和二叉搜索树
查找等。
不难发现,这些知识点都已在前面的章节中介绍过,因此搜索算法对于我们来说并不陌生。在本节中,我们
将从更加系统的视角切入,重新审视搜索算法。
暴力搜索
暴力搜索通过遍历数据结构的每个元素来定位目标元素。
‧“线性搜索”适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到
目标元素或到达另一端仍没有找到目标元素为止。
‧“广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜
索,由近及远地访问各个节点。深度优先搜索从初始节点开始,沿着一条路径走到头,再回溯并尝试其
他路径,直到遍历完整个数据结构。
暴力搜索的优点是简单且通用性好,无须对数据做预处理和借助额外的数据结构。
然而,此类算法的时间复杂度为 𝑂(𝑛) ,其中 𝑛 为元素数量,因此在数据量较大的情况下性能较差
自适应搜索
自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。
‧“二分查找”利用数据的有序性实现高效查找,仅适用于数组。
‧“哈希查找”利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。
‧“树查找”在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。
此类算法的优点是效率高,时间复杂度可达到 𝑂(log 𝑛) 甚至 𝑂(1) 。
然而,使用这些算法往往需要对数据进行预处理。例如,二分查找需要预先对数组进行排序,哈希查找和树
查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开销
自适应搜索算法常被称为查找算法,主要用于在特定数据结构中快速检索目标元素。
搜索方法选取
给定大小为 𝑛 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法从中搜索目标
元素。各个方法的工作原理如图 10‑11 所示
搜索算法的选择还取决于数据体量、搜索性能要求、数据查询与更新频率等。
线性搜索
‧ 通用性较好,无须任何数据预处理操作。假如我们仅需查询一次数据,那么其他三种方法的数据预处理
的时间比线性搜索的时间还要更长。
‧ 适用于体量较小的数据,此情况下时间复杂度对效率影响较小。
‧ 适用于数据更新频率较高的场景,因为该方法不需要对数据进行任何额外维护。
二分查找
‧ 适用于大数据量的情况,效率表现稳定,最差时间复杂度为 𝑂(log 𝑛) 。
‧ 数据量不能过大,因为存储数组需要连续的内存空间。
‧ 不适用于高频增删数据的场景,因为维护有序数组的开销较大。
哈希查找
‧ 适合对查询性能要求很高的场景,平均时间复杂度为 𝑂(1) 。
‧ 不适合需要有序数据或范围查找的场景,因为哈希表无法维护数据的有序性。
‧ 对哈希函数和哈希冲突处理策略的依赖性较高,具有较大的性能劣化风险。
‧ 不适合数据量过大的情况,因为哈希表需要额外空间来最大程度地减少冲突,从而提供良好的查询性
能。
树查找
‧ 适用于海量数据,因为树节点在内存中是分散存储的。
‧ 适合需要维护有序数据或范围查找的场景。
‧ 在持续增删节点的过程中,二叉搜索树可能产生倾斜,时间复杂度劣化至 𝑂(𝑛) 。
‧ 若使用 AVL 树或红黑树,则各项操作可在 𝑂(log 𝑛) 效率下稳定运行,但维护树平衡的操作会增加额
外的开销。
排序
排序犹如一把将混乱变为秩序的魔法钥匙,使我们能以更高效的方式理解与处理数据。
无论是简单的升序,还是复杂的分类排列,排序都向我们展示了数据的和谐美感。
排序算法
排序算法(sorting algorithm)用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更高效地查找、分析和处理。
排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求
设定,如数字大小、字符 ASCII 码顺序或自定义规则。
评价维度
运行效率:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对
于大数据量的情况,运行效率显得尤为重要。
就地性:顾名思义,原地排序通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。
通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
==稳定性:==稳定排序在完成排序后,相等元素在数组中的相对顺序不发生改变。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,非稳定排序可能导致输入数据的有序性丧失:
# 输入数据是按照姓名排序好的
# (name, age)
('A', 19)
('B', 18)
('C', 21)
('D', 19)
('E', 23)
# 假设使用非稳定排序算法按年龄排序列表,
# 结果中 ('D', 19) 和 ('A', 19) 的相对位置改变,
# 输入数据按姓名排序的性质丢失
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)
自适应性:自适应排序的时间复杂度会受输入数据的影响,即最佳时间复杂度、最差时间复杂度、平均时间复杂度并不完全相等。自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
是否基于比较:基于比较的排序依赖比较运算符(<、=、>)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 𝑂(𝑛 log 𝑛) 。而非比较排序不使用比较运算符,时间复杂度可达 𝑂(𝑛) ,但其通用性相对较差。
选择排序
选择排序(selection sort)的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放
到已排序区间的末尾
设数组的长度为 𝑛 ,选择排序的算法流程如图 11‑2 所示。
- 初始状态下,所有元素未排序,即未排序(索引)区间为 [0, 𝑛 − 1] 。
- 选取区间 [0, 𝑛 − 1] 中的最小元素,将其与索引 0 处的元素交换。完成后,数组前 1 个元素已排序。
- 选取区间 [1, 𝑛 − 1] 中的最小元素,将其与索引 1 处的元素交换。完成后,数组前 2 个元素已排序。
- 以此类推。经过 𝑛 − 1 轮选择与交换后,数组前 𝑛 − 1 个元素已排序。
- 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
在代码中,我们用 𝑘 来记录未排序区间内的最小元素
/* 选择排序 */
void selectionSort(vector<int> &nums) {
int n = nums.size();
// 外循环:未排序区间为 [i, n-1]
for (int i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素
int k = i;
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[k])
k = j; // 记录最小元素的索引
}
// 将该最小元素与未排序区间的首个元素交换
swap(nums[i], nums[k]);
}
}
算法特性
‧ 时间复杂度为 𝑂(𝑛2 )、非自适应排序:外循环共 𝑛 − 1 轮,第一轮的未排序区间长度为 𝑛 ,最后一轮
的未排序区间长度为 2 ,即各轮外循环分别包含 𝑛、𝑛 − 1、…、3、2 轮内循环,求和为 (𝑛−1)(𝑛+2)/2
‧ 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
‧ 非稳定排序:如图 11‑3 所示,元素 nums[i] 有可能被交换至与其相等的元素的右边,导致两者的相对
顺序发生改变。
冒泡排序
冒泡排序(bubble sort)通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,
因此得名冒泡排序。
如图 11‑4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大
小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。
算法流程
设数组的长度为 𝑛 ,冒泡排序的步骤如图 11‑5 所示。
- 首先,对 𝑛 个元素执行“冒泡”,将数组的最大元素交换至正确位置。
- 接下来,对剩余 𝑛 − 1 个元素执行“冒泡”,将第二大元素交换至正确位置。
- 以此类推,经过 𝑛 − 1 轮“冒泡”后,前 𝑛 − 1 大的元素都被交换至正确位置。
- 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
/* 冒泡排序 */
void bubbleSort(vector<int> &nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
// 这里使用了 std::swap() 函数
swap(nums[j], nums[j + 1]);
}
}
}
}
效率优化
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可
以增加一个标志位 flag 来监测这种情况,一旦出现就立即返回。
经过优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为 𝑂(𝑛2 ) ;但当输入数组完全有序时,可达到最佳时间复杂度 𝑂(𝑛) 。
/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(vector<int> &nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
bool flag = false; // 初始化标志位
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
// 这里使用了 std::swap() 函数
swap(nums[j], nums[j + 1]);
flag = true; // 记录交换元素
}
}
if (!flag)
break; // 此轮“冒泡”未交换任何元素,直接跳出
}
}
算法特性
‧ 时间复杂度为 𝑂(𝑛2 )、自适应排序:各轮“冒泡”遍历的数组长度依次为 𝑛 − 1、𝑛 − 2、…、2、1 ,
总和为 (𝑛 − 1)𝑛/2 。在引入 flag 优化后,最佳时间复杂度可达到 𝑂(𝑛) 。
‧ 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
‧ 稳定排序:由于在“冒泡”中遇到相等元素不交换。
插入排序
插入排序(insertion sort)是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将
该元素插入到正确的位置。
图 11‑6 展示了数组插入元素的操作流程。设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元
素向右移动一位,然后将 base 赋值给目标索引。
算法流程
插入排序的整体流程如图 11‑7 所示。
- 初始状态下,数组的第 1 个元素已完成排序。
- 选取数组的第 2 个元素作为 base ,将其插入到正确位置后,数组的前 2 个元素已排序。
- 选取第 3 个元素作为 base ,将其插入到正确位置后,数组的前 3 个元素已排序。
- 以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序。
/* 插入排序 */
void insertionSort(vector<int> &nums) {
// 外循环:已排序区间为 [0, i-1]
for (int i = 1; i < nums.size(); i++) {
int base = nums[i], j = i - 1;
// 内循环:将 base 插入到已排序区间 [0, i-1] 中的正确位置
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
j--;
}
nums[j + 1] = base; // 将 base 赋值到正确位置
}
}
算法特性
‧ 时间复杂度为 𝑂(𝑛2 )、自适应排序:在最差情况下,每次插入操作分别需要循环 𝑛 − 1、𝑛 − 2、…、
2、1 次,求和得到 (𝑛 − 1)𝑛/2 ,因此时间复杂度为 𝑂(𝑛2 ) 。在遇到有序数据时,插入操作会提前终
止。当输入数组完全有序时,插入排序达到最佳时间复杂度 𝑂(𝑛) 。
‧ 空间复杂度为 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
‧ 稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
插入排序的优势
插入排序的时间复杂度为 𝑂(𝑛2 ) ,而我们即将学习的快速排序的时间复杂度为 𝑂(𝑛 log 𝑛) 。尽管插入排序
的时间复杂度更高,但在数据量较小的情况下,插入排序通常更快。
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 𝑂(𝑛 log 𝑛) 的算法属于基于分治策
略的排序算法,往往包含更多单元计算操作。而在数据量较小时,𝑛 2 和 𝑛 log 𝑛 的数值比较接近,复杂度不
占主导地位,每轮中的单元操作数量起到决定性作用。
实际上,许多编程语言(例如 Java)的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于
分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 𝑂(𝑛2 ) ,但在实际情况中,插入排序的使用频率显
著高于冒泡排序和选择排序,主要有以下原因。
‧ 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及 3 个单元操作;插入排序基于元素赋值实
现,仅需 1 个单元操作。因此,冒泡排序的计算开销通常比插入排序更高。
‧ 选择排序在任何情况下的时间复杂度都为 𝑂(𝑛2 ) 。如果给定一组部分有序的数据,插入排序通常比选
择排序效率更高。
‧ 选择排序不稳定,无法应用于多级排序。
快速排序
快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数
的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如图 11‑8 所示。
- 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
- 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
- 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 ≤ 基
准数 ≤ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
快速排序的分治策略
哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题
/* 元素交换 */
void swap(vector<int> &nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
算法流程
快速排序的整体流程如图 11‑9 所示。
- 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
- 然后,对左子数组和右子数组分别递归执行“哨兵划分”。
- 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
/* 快速排序 */
void quickSort(vector<int> &nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right)
return;
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
算法特性
‧ 时间复杂度为 𝑂(𝑛 log 𝑛)、自适应排序:在平均情况下,哨兵划分的递归层数为 log 𝑛 ,每层中的总循
环数为 𝑛 ,总体使用 𝑂(𝑛 log 𝑛) 时间。在最差情况下,每轮哨兵划分操作都将长度为 𝑛 的数组划分为
长度为 0 和 𝑛−1 的两个子数组,此时递归层数达到 𝑛 ,每层中的循环数为 𝑛 ,总体使用 𝑂(𝑛2 ) 时间。
‧ 空间复杂度为 𝑂(𝑛)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 𝑛 ,使用 𝑂(𝑛) 栈
帧空间。排序操作是在原数组上进行的,未借助额外数组。
‧ 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
快速排序为什么快
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排
序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
‧ 出现最差情况的概率很低:虽然快速排序的最差时间复杂度为 𝑂(𝑛2 ) ,没有归并排序稳定,但在绝大
多数情况下,快速排序能在 𝑂(𝑛 log 𝑛) 的时间复杂度下运行。
‧ 缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较
高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
‧ 复杂度的常数系数小:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与
“插入排序”比“冒泡排序”更快的原因类似。
基准数优化
快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择
最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 𝑛 − 1、
右子数组长度为 0 。如此递归下去,每轮哨兵划分后都有一个子数组的长度为 0 ,分治策略失效,快速排序
退化为“冒泡排序”的近似形式。
为了尽量避免这种情况发生,我们可以优化哨兵划分中的基准数的选取策略。例如,我们可以随机选取一个
元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。
需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,
那么快速排序的效率仍然可能劣化。
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选
元素的中位数作为基准数。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以
选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 𝑂(𝑛2 ) 的概率大大
降低。
/* 选取三个候选元素的中位数 */
int medianThree(vector<int> &nums, int left, int mid, int right) {
int l = nums[left], m = nums[mid], r = nums[right];
if ((l <= m && m <= r) || (r <= m && m <= l))
return mid; // m 在 l 和 r 之间
if ((m <= l && l <= r) || (r <= l && l <= m))
return left; // l 在 m 和 r 之间
return right;
}
/* 哨兵划分(三数取中值) */
int partition(vector<int> &nums, int left, int right) {
// 选取三个候选元素的中位数
int med = medianThree(nums, left, (left + right) / 2, right);
// 将中位数交换至数组最左端
swap(nums, left, med);
// 以 nums[left] 为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
尾递归优化
在某些输入下,快速排序可能占用空间较多。以完全有序的输入数组为例,设递归中的子数组长度为 m ,每轮哨兵划分操作都将产生长度为 0 的左子数组和长度为 m−1 的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到 n−1 ,此时需要占用 O(n) 大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过 n/2 ,因此这种方法能确保递归深度不超过 logn ,从而将最差空间复杂度优化至 O(logn) 。代码如下所示:
/* 快速排序(尾递归优化) */
void quickSort(vector<int> &nums, int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = partition(nums, left, right);
// 对两个子数组中较短的那个执行快速排序
if (pivot - left < right - pivot) {
quickSort(nums, left, pivot - 1); // 递归排序左子数组
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
} else {
quickSort(nums, pivot + 1, right); // 递归排序右子数组
right = pivot - 1; // 剩余未排序区间为 [left, pivot - 1]
}
}
}
归并排序
归并排序(merge sort)是一种基于分治策略的排序算法,包含图 11‑10 所示的“划分”和“合并”阶段。
- 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
- 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较
长的有序数组,直至结束。
算法流程
“划分阶段”从顶至底递归地将数组从中点切分为两个子数组。
- 计算数组中点 mid ,递归划分左子数组(区间 [left, mid] )和右子数组(区间 [mid + 1, right] )。
- 递归执行步骤 1. ,直至子数组区间长度为 1 时终止。
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开
始合并,合并阶段中的每个子数组都是有序的。
观察发现,归并排序与二叉树后序遍历的递归顺序是一致的。
‧ 后序遍历:先递归左子树,再递归右子树,最后处理根节点。
‧ 归并排序:先递归左子数组,再递归右子数组,最后处理合并。
归并排序的实现如以下代码所示。请注意,nums 的待合并区间为 [left, right] ,而 tmp 的对应区间为[0, right - left] 。
/* 合并左子数组和右子数组 */
void merge(vector<int> &nums, int left, int mid, int right) {
// 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
// 创建一个临时数组 tmp ,用于存放合并后的结果
vector<int> tmp(right - left + 1);
// 初始化左子数组和右子数组的起始索引
int i = left, j = mid + 1, k = 0;
// 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
while (i <= mid && j <= right) {
if (nums[i] <= nums[j])
tmp[k++] = nums[i++];
else
tmp[k++] = nums[j++];
}
// 将左子数组和右子数组的剩余元素复制到临时数组中
while (i <= mid) {
tmp[k++] = nums[i++];
}
while (j <= right) {
tmp[k++] = nums[j++];
}
// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
for (k = 0; k < tmp.size(); k++) {
nums[left + k] = tmp[k];
}
}
/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
// 终止条件
if (left >= right)
return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = left + (right - left) / 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}
算法特性
‧ 时间复杂度为 𝑂(𝑛 log 𝑛)、非自适应排序:划分产生高度为 log 𝑛 的递归树,每层合并的总操作数量
为 𝑛 ,因此总体时间复杂度为 𝑂(𝑛 log 𝑛) 。
‧ 空间复杂度为 𝑂(𝑛)、非原地排序:递归深度为 log 𝑛 ,使用 𝑂(log 𝑛) 大小的栈帧空间。合并操作需
要借助辅助数组实现,使用 𝑂(𝑛) 大小的额外空间。
‧ 稳定排序:在合并过程中,相等元素的次序保持不变。
堆排序
堆排序(heap sort)是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和
“元素出堆操作”实现堆排序。
- 输入数组并建立小顶堆,此时最小元素位于堆顶。
- 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。
以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。在实际中,我们通常使用
一种更加优雅的实现方式。
算法流程
设数组的长度为 𝑛 ,堆排序的流程如图 11‑12 所示。
- 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
- 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 1 ,已排序元
素数量加 1 。
- 从堆顶元素开始,从顶到底执行堆化操作(sift down)。完成堆化后,堆的性质得到修复。
- 循环执行第 2. 步和第 3. 步。循环 𝑛 − 1 轮后,即可完成数组排序。
在代码实现中,我们使用了与“堆”章节相同的从顶至底堆化 sift_down() 函数。值得注意的是,由于堆的
长度会随着提取最大元素而减小,因此我们需要给 sift_down() 函数添加一个长度参数 𝑛 ,用于指定堆的当
前有效长度。代码如下所示:
/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
int l = 2 * i + 1;
int r = 2 * i + 2;
int ma = i;
if (l < n && nums[l] > nums[ma])
ma = l;
if (r < n && nums[r] > nums[ma])
ma = r;
// 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if (ma == i) {
break;
}
// 交换两节点
swap(nums[i], nums[ma]);
// 循环向下堆化
i = ma;
}
}
/* 堆排序 */
void heapSort(vector<int> &nums) {
// 建堆操作:堆化除叶节点以外的其他所有节点
for (int i = nums.size() / 2 - 1; i >= 0; --i) {
siftDown(nums, nums.size(), i);
}
// 从堆中提取最大元素,循环 n-1 轮
for (int i = nums.size() - 1; i > 0; --i) {
// 交换根节点与最右叶节点(交换首元素与尾元素)
swap(nums[0], nums[i]);
// 以根节点为起点,从顶至底进行堆化
siftDown(nums, i, 0);
}
}
算法特性
‧ 时间复杂度为 𝑂(𝑛 log 𝑛)、非自适应排序:建堆操作使用 𝑂(𝑛) 时间。从堆中提取最大元素的时间复
杂度为 𝑂(log 𝑛) ,共循环 𝑛 − 1 轮。
‧ 空间复杂度为 𝑂(1)、原地排序:几个指针变量使用 𝑂(1) 空间。元素交换和堆化操作都是在原数组上
进行的。
‧ 非稳定排序:在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。
桶排序
前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的
时间复杂度无法超越 𝑂(𝑛 log 𝑛) 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达
到线性阶。
桶排序(bucket sort)是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数
据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合
并。
算法流程
考虑一个长度为 𝑛 的数组,其元素是范围 [0, 1) 内的浮点数。桶排序的流程如图 11‑13 所示。
- 初始化 𝑘 个桶,将 𝑛 个元素分配到 𝑘 个桶中。
- 对每个桶分别执行排序(这里采用编程语言的内置排序函数)。
- 按照桶从小到大的顺序合并结果。
/* 桶排序 */
void bucketSort(vector<float> &nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.size() / 2;
vector<vector<float>> buckets(k);
// 1. 将数组元素分配到各个桶中
for (float num : nums) {
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = num * k;
// 将 num 添加进桶 bucket_idx
buckets[i].push_back(num);
}
// 2. 对各个桶执行排序
for (vector<float> &bucket : buckets) {
// 使用内置排序函数,也可以替换成其他排序算法
sort(bucket.begin(), bucket.end());
}
// 3. 遍历桶合并结果
int i = 0;
for (vector<float> &bucket : buckets) {
for (float num : bucket) {
nums[i++] = num;
}
}
}
计数排序
基数排序
小结
分治
分治算法
分治(divide and conquer),全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包
括“分”和“治”两个步骤。
- 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
- 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题
的解。
如图 12‑1 所示,“归并排序”是分治策略的典型应用之一。
- 分:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
- 治:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。
如何判断分治问题
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
- 问题可以分解:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
- 子问题是独立的:子问题之间没有重叠,互不依赖,可以独立解决。
- 子问题的解可以合并:原问题的解通过合并子问题的解得来。
显然,归并排序满足以上三个判断依据。
- 问题可以分解:递归地将数组(原问题)划分为两个子数组(子问题)。
- 子问题是独立的:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
- 子问题的解可以合并:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。
通过分治提升效率
分治不仅可以有效地解决算法问题,往往还可以提升算法效率。在排序算法中,快速排序、归并排序、堆排
序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
那么,我们不禁发问:为什么分治可以提升算法效率,其底层逻辑是什么?换句话说,将大问题分解为多个
子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?
这个问题可以从操作数量和并行计算两方面来讨论。
分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的
时间复杂度,还有利于操作系统的并行优化。
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资
源,从而显著减少总体的运行时间。
比如在图 12‑3 所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到
各个计算单元,完成后再合并结果。
一方面,分治可以用来解决许多经典算法问题。
‧ 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部
分的最近点对。
‧ 大整数乘法:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
‧ 矩阵乘法:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
‧ 汉诺塔问题:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
‧ 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解
逆序对问题可以利用分治的思想,借助归并排序进行求解。
另一方面,分治在算法和数据结构的设计中应用得非常广泛。
‧ 二分查找:二分查找是将有序数组从中点索引处分为两部分,然后根据目标值与中间元素值比较结果,
决定排除哪一半区间,并在剩余区间执行相同的二分操作。
‧ 归并排序:本节开头已介绍,不再赘述。
‧ 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,
另一子数组的元素比基准值大,再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
‧ 桶排序:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的
元素依次取出,从而得到一个有序数组。
‧ 树:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为
分治策略的应用。
‧ 堆:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
‧ 哈希表:虽然哈希表并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地
址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,分治是一种“润物细无声”的算法思想,隐含在各种算法与数据结构之中。
分治搜索策略
我们已经学过,搜索算法分为两大类。
‧ 暴力搜索:它通过遍历数据结构实现,时间复杂度为 𝑂(𝑛) 。
‧ 自适应搜索:它利用特有的数据组织形式或先验信息,时间复杂度可达到 𝑂(log 𝑛) 甚至 𝑂(1) 。
实际上,时间复杂度为 𝑂(log 𝑛) 的搜索算法通常是基于分治策略实现的,例如二分查找和树。
‧ 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元
素),这个过程一直持续到数组为空或找到目标元素为止。
‧ 树是分治思想的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 𝑂(log 𝑛)
。
二分查找的分治策略如下所示。
‧ 问题可以分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查
找),这是通过比较中间元素和目标元素来实现的。
‧ 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
‧ 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题
得到解决时,原问题也会同时得到解决。
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选****项
1. 基于分治实现二分查找
在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。
**Question**
给定一个长度为 𝑛 的有序数组 nums ,其中所有元素都是唯一的,请查找元素 target 。
从分治角度,我们将搜索区间 [𝑖, 𝑗] 对应的子问题记为 𝑓(𝑖, 𝑗) 。
以原问题 𝑓(0, 𝑛 − 1) 为起始点,通过以下步骤进行二分查找。
- 计算搜索区间 [𝑖, 𝑗] 的中点 𝑚 ,根据它排除一半搜索区间。
- 递归求解规模减小一半的子问题,可能为 𝑓(𝑖, 𝑚 − 1) 或 𝑓(𝑚 + 1, 𝑗) 。
- 循环第 1. 步和第 2. 步,直至找到 target 或区间为空时返回。
二分查找的分治过程
/* 二分查找:问题 f(i, j) */
int dfs(vector<int> &nums, int target, int i, int j) {
// 若区间为空,代表无目标元素,则返回 -1
if (i > j) {
return -1;
}
// 计算中点索引 m
int m = (i + j) / 2;
if (nums[m] < target) {
// 递归子问题 f(m+1, j)
return dfs(nums, target, m + 1, j);
} else if (nums[m] > target) {
// 递归子问题 f(i, m-1)
return dfs(nums, target, i, m - 1);
} else {
// 找到目标元素,返回其索引
return m;
}
}
/* 二分查找 */
int binarySearch(vector<int> &nums, int target) {
int n = nums.size();
// 求解问题 f(0, n-1)
return dfs(nums, target, 0, n - 1);
}
构建二叉树问题
Question
给定一棵二叉树的前序遍历 preorder 和中序遍历 inorder ,请从中构建二叉树,返回二叉树的根节
点。假设二叉树中没有值重复的节点(如图 12‑5 所示)。
1. 判断是否为分治问题
原问题定义为从 preorder 和 inorder 构建二叉树,是一个典型的分治问题。
‧ 问题可以分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,
加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划
分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
‧ 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注
中序遍历和前序遍历中与左子树对应的部分。右子树同理。
‧ 子问题的解可以合并:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,
得到原问题的解。
2. 如何划分子树
根据以上分析,这道题可以使用分治来求解,但如何通过前序遍历 preorder 和中序遍历 inorder 来划分左子
树和右子树呢?
根据定义,preorder 和 inorder 都可以划分为三个部分。
‧ 前序遍历:[ 根节点 | 左子树 | 右子树 ] ,例如图 12‑5 的树对应 [ 3 | 9 | 2 1 7 ] 。
‧ 中序遍历:[ 左子树 | 根节点 | 右子树 ] ,例如图 12‑5 的树对应 [ 9 | 3 | 1 2 7 ] 。
以上图数据为例,我们可以通过图 12‑6 所示的步骤得到划分结果。
- 前序遍历的首元素 3 是根节点的值。
- 查找根节点 3 在 inorder 中的索引,利用该索引可将 inorder 划分为 [ 9 | 3 | 1 2 7 ] 。
- 根据 inorder 的划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 preorder 划分为
[ 3 | 9 | 2 1 7 ] 。
3. 基于变量描述子树区间
根据以上划分方法,我们已经得到根节点、左子树、右子树在 preorder 和 inorder 中的索引区间。而为了描
述这些索引区间,我们需要借助几个指针变量。
‧ 将当前树的根节点在 preorder 中的索引记为 𝑖 。
‧ 将当前树的根节点在 inorder 中的索引记为 𝑚 。
‧ 将当前树在 inorder 中的索引区间记为 [𝑙, 𝑟] 。
如表 12‑1 所示,通过以上变量即可表示根节点在 preorder 中的索引,以及子树在 inorder 中的索引区间。
为了提升查询 𝑚 的效率,我们借助一个哈希表 hmap 来存储数组 inorder 中元素到索引的映射:
/* 构建二叉树:分治 */
TreeNode *dfs(vector<int> &preorder, unordered_map<int, int> &inorderMap, int i, int l, int r) {
// 子树区间为空时终止
if (r - l < 0)
return NULL;
// 初始化根节点
TreeNode *root = new TreeNode(preorder[i]);
// 查询 m ,从而划分左右子树
int m = inorderMap[preorder[i]];
// 子问题:构建左子树
root->left = dfs(preorder, inorderMap, i + 1, l, m - 1);
// 子问题:构建右子树
root->right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r);
// 返回根节点
return root;
}
/* 构建二叉树 */
TreeNode *buildTree(vector<int> &preorder, vector<int> &inorder) {
// 初始化哈希表,存储 inorder 元素到索引的映射
unordered_map<int, int> inorderMap;
for (int i = 0; i < inorder.size(); i++) {
inorderMap[inorder[i]] = i;
}
TreeNode *root = dfs(preorder, inorderMap, 0, 0, inorder.size() - 1);
return root;
}
汉诺塔问题
在归并排序和构建二叉树中,我们都是将原问题分解为两个规模为原问题一半的子问题。然而对于汉诺塔问
题,我们采用不同的分解策略
Question
给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 𝑛 个圆盘,它们从上到下按照从小到大的
顺序排列。我们的任务是要把这 𝑛 个圆盘移到柱子 C 上,并保持它们的原有顺序不变(如图 12‑10 所
示)。在移动圆盘的过程中,需要遵守以下规则。
1. 圆盘只能从一根柱子顶部拿出,从另一根柱子顶部放入。
2. 每次只能移动一个圆盘。
3. 小圆盘必须时刻位于大圆盘之上。
我们将规模为 𝑖 的汉诺塔问题记作 𝑓(𝑖) 。例如 𝑓(3) 代表将 3 个圆盘从 A 移动至 C 的汉诺塔问题。
1. 考虑基本情况
如图 12‑11 所示,对于问题 𝑓(1) ,即当只有一个圆盘时,我们将它直接从 A 移动至 C 即可。
如图 12‑12 所示,对于问题 𝑓(2) ,即当有两个圆盘时,由于要时刻满足小圆盘在大圆盘之上,因此需要借助
B 来完成移动。
- 先将上面的小圆盘从 A 移至 B 。
- 再将大圆盘从 A 移至 C 。
- 最后将小圆盘从 B 移至 C 。
解决问题 𝑓(2) 的过程可总结为:将两个圆盘借助 B 从 A 移至 C 。其中,C 称为目标柱、B 称为缓冲柱。
2. 子问题分解
对于问题 𝑓(3) ,即当有三个圆盘时,情况变得稍微复杂了一些。
因为已知 𝑓(1) 和 𝑓(2) 的解,所以我们可从分治角度思考,将 A 顶部的两个圆盘看作一个整体,执行图 12‑13
所示的步骤。这样三个圆盘就被顺利地从 A 移至 C 了。
- 令 B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移至 B 。
- 将 A 中剩余的一个圆盘从 A 直接移动至 C 。
- 令 C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移至 C 。
从本质上看,我们将问题 𝑓(3) 划分为两个子问题 𝑓(2) 和一个子问题 𝑓(1) 。按顺序解决这三个子问题之后,
原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
至此,我们可总结出图 12‑14 所示的解决汉诺塔问题的分治策略:将原问题 𝑓(𝑛) 划分为两个子问题 𝑓(𝑛−1)
和一个子问题 𝑓(1) ,并按照以下顺序解决这三个子问题。
- 将 𝑛 − 1 个圆盘借助 C 从 A 移至 B 。
- 将剩余 1 个圆盘从 A 直接移至 C 。
- 将 𝑛 − 1 个圆盘借助 A 从 B 移至 C 。
对于这两个子问题 𝑓(𝑛 − 1) ,可以通过相同的方式进行递归划分,直至达到最小子问题 𝑓(1) 。而 𝑓(1) 的
解是已知的,只需一次移动操作即可。
3. 代码实现
在代码中,我们声明一个递归函数 dfs(i, src, buf, tar) ,它的作用是将柱 src 顶部的 𝑖 个圆盘借助缓冲
柱 buf 移动至目标柱 tar :
/* 移动一个圆盘 */
void move(vector<int> &src, vector<int> &tar) {
// 从 src 顶部拿出一个圆盘
int pan = src.back();
src.pop_back();
// 将圆盘放入 tar 顶部
tar.push_back(pan);
}
/* 求解汉诺塔问题 f(i) */
void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
move(src, tar);
return;
}
// 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
dfs(i - 1, src, tar, buf);
// 子问题 f(1) :将 src 剩余一个圆盘移到 tar
move(src, tar);
// 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
dfs(i - 1, buf, src, tar);
}
/* 求解汉诺塔问题 */
void solveHanota(vector<int> &A, vector<int> &B, vector<int> &C) {
int n = A.size();
// 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(n, A, B, C);
}
回溯
我们如同迷宫中的探索者,在前进的道路上可能会遇到困难。
回溯的力量让我们能够重新开始,不断尝试,最终找到通往光明的出口。
回溯算法
回溯算法(backtracking algorithm)是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出
发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都
无法找到解为止。
回溯算法通常采用“深度优先搜索”来遍历解空间。在“二叉树”章节中,我们提到前序、中序和后序遍历
都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理
例题一
给定一棵二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。
/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
if (root->val == 7) {
// 记录解
res.push_back(root);
}
preOrder(root->left);
preOrder(root->right);
}
尝试与回退
之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过
程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,
并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展
例题二
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径
在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则
复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。代码如下所示:
/* 前序遍历:例题二 */
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从
path 中弹出,以恢复本次尝试之前的状态。
我们可以将尝试和回退理解为“前进”与“撤销”,两个操作互为逆向。
剪枝
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。
例题三
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的
节点
为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回,不再
继续搜索。代码如下所示:
/* 前序遍历:例题三 */
void preOrder(TreeNode *root) {
// 剪枝
if (root == nullptr || root->val == 3) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
“剪枝”是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
框架代码
接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择:
/* 回溯算法框架 */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 不再继续搜索
return;
}
// 遍历所有选择
for (Choice choice : choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
接下来,我们基于框架代码来解决例题三。状态 state
为节点遍历路径,选择 choices
为当前节点的左子节点和右子节点,结果 res
是路径列表:
/* 判断当前状态是否为解 */
bool isSolution(vector<TreeNode *> &state) {
return !state.empty() && state.back()->val == 7;
}
/* 记录解 */
void recordSolution(vector<TreeNode *> &state, vector<vector<TreeNode *>> &res) {
res.push_back(state);
}
/* 判断在当前状态下,该选择是否合法 */
bool isValid(vector<TreeNode *> &state, TreeNode *choice) {
return choice != nullptr && choice->val != 3;
}
/* 更新状态 */
void makeChoice(vector<TreeNode *> &state, TreeNode *choice) {
state.push_back(choice);
}
/* 恢复状态 */
void undoChoice(vector<TreeNode *> &state, TreeNode *choice) {
state.pop_back();
}
/* 回溯算法:例题三 */
void backtrack(vector<TreeNode *> &state, vector<TreeNode *> &choices, vector<vector<TreeNode *>> &res) {
// 检查是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
}
// 遍历所有选择
for (TreeNode *choice : choices) {
// 剪枝:检查选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
// 进行下一轮选择
vector<TreeNode *> nextChoices{choice->left, choice->right};
backtrack(state, nextChoices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
常用术语
优点与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的
优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
‧ 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
‧ 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间
需求可能会变得很大。
即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪
些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,
常见的效率优化方法有两种。
‧ 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
‧ 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
回溯典型例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
搜索问题:这类问题的目标是找到满足特定条件的解决方案。
‧ 全排列问题:给定一个集合,求出其所有可能的排列组合。
‧ 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
‧ 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,
每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
‧ 𝑛 皇后:在 𝑛 × 𝑛 的棋盘上放置 𝑛 个皇后,使得它们互不攻击。
‧ 数独:在 9 × 9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3 × 3 子网格中的数字不重复。
‧ 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。
组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。
‧ 0‑1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选
择物品使得总价值最大。
‧ 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
‧ 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
请注意,对于许多组合优化问题,回溯不是最优解决方案。
‧ 0‑1 背包问题通常使用动态规划解决,以达到更高的时间效率。
‧ 旅行商是一个著名的 NP‑Hard 问题,常用解法有遗传算法和蚁群算法等。
‧ 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。
全排列问题
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找
出其中元素的所有可能的排列。
表 13‑2 列举了几个示例数据,包括输入数组和对应的所有排列。
无相等元素的情况
Question
输入一个整数数组,其中不包含重复元素,返回所有可能的排列。
从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 [1, 2, 3] ,如
果我们先选择 1 ,再选择 3 ,最后选择 2 ,则获得排列 [1, 3, 2] 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。
请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。
如图 13‑5 所示,我们可以将搜索过程展开成一棵递归树,树中的每个节点代表当前状态 state 。从根节点开
始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
1. 重复选择剪枝
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i]
是否已被选择,并基于它实现以下剪枝操作。
‧ 在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 True ,代表它已被选择。
‧ 遍历选择列表 choices 时,跳过所有已被选择的节点,即剪枝。
如图 13‑6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分
支,在第三轮剪掉元素 1 和元素 3 的分支。
该剪枝操作将搜索空间大小从 𝑂(𝑛𝑛) 减小至 𝑂(𝑛!) 。
2. 代码实现
想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短整体代码,我们不单独实现框
架代码中的各个函数,而是将它们展开在 backtrack() 函数中
/* 回溯算法:全排列 I */
void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
// 当状态长度等于元素数量时,记录解
if (state.size() == choices.size()) {
res.push_back(state);
return;
}
// 遍历所有选择
for (int i = 0; i < choices.size(); i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素
if (!selected[i]) {
// 尝试:做出选择,更新状态
selected[i] = true;
state.push_back(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.pop_back();
}
}
}
/* 全排列 I */
vector<vector<int>> permutationsI(vector<int> nums) {
vector<int> state;
vector<bool> selected(nums.size(), false);
vector<vector<int>> res;
backtrack(state, nums, selected, res);
return res;
}
考虑相等元素的情况
Question
输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。
假设输入数组为 [1, 1, 2] 。为了方便区分两个重复元素 1 ,我们将第二个 1 记为 1 。
如图 13‑7 所示,上述方法生成的排列有一半是重复的。
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够
优雅,因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝,这样可以进一步提升算法效率。
1. 相等元素剪枝
观察图 13‑8 ,在第一轮中,选择 1 或选择 1 是等价的,在这两个选择之下生成的所有排列都是重复的。因此
应该把 1 剪枝。
同理,在第一轮选择 2 之后,第二轮选择中的 1 和 1 也会产生重复分支,因此也应将第二轮的 1 剪枝。
从本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。
2. 代码实现
在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试
过的元素,并将重复元素剪枝:
/* 回溯算法:全排列 II */
void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
// 当状态长度等于元素数量时,记录解
if (state.size() == choices.size()) {
res.push_back(state);
return;
}
// 遍历所有选择
unordered_set<int> duplicated;
for (int i = 0; i < choices.size(); i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
if (!selected[i] && duplicated.find(choice) == duplicated.end()) {
// 尝试:做出选择,更新状态
duplicated.emplace(choice); // 记录选择过的元素值
selected[i] = true;
state.push_back(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.pop_back();
}
}
}
/* 全排列 II */
vector<vector<int>> permutationsII(vector<int> nums) {
vector<int> state;
vector<bool> selected(nums.size(), false);
vector<vector<int>> res;
backtrack(state, nums, selected, res);
return res;
}
假设元素两两之间互不相同,则 𝑛 个元素共有 𝑛! 种排列(阶乘);在记录结果时,需要复制长度为 𝑛 的列
表,使用 𝑂(𝑛) 时间。因此时间复杂度为 𝑂(𝑛!𝑛) 。
最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。selected 使用 𝑂(𝑛) 空间。同一时刻最多共有 𝑛 个 duplicated ,
使用 𝑂(𝑛2 ) 空间。因此空间复杂度为 𝑂(𝑛2 )
3. 两种剪枝对比
请注意,虽然 selected 和 duplicated 都用于剪枝,但两者的目标不同。
‧ 重复选择剪枝:整个搜索过程中只有一个 selected 。它记录的是当前状态中包含哪些元素,其作用是
避免某个元素在 state 中重复出现。
‧ 相等元素剪枝:每轮选择(每个调用的 backtrack 函数)都包含一个 duplicated 。它记录的是在本轮
遍历(for 循环)中哪些元素已被选择过,其作用是保证相等元素只被选择一次。
图 13‑9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上
的各个节点构成一个排列。
子集和问题
无重复元素的情况
Question
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和
等于 target 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中
不应包含重复组合。
例如,输入集合 {3, 4, 5} 和目标整数 9 ,解为 {3, 3, 3}, {4, 5} 。需要注意以下两点。
‧ 输入集合中的元素可以被无限次重复选取。
‧ 子集不区分元素顺序,比如 {4, 5} 和 {5, 4} 是同一个子集。
1. 参考全排列解法
类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素
和”,当元素和等于 target 时,就将子集记录至结果列表。
而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元
素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码:
/* 回溯算法:子集和 I */
void backtrack(vector<int> &state, int target, int total, vector<int> &choices, vector<vector<int>> &res) {
// 子集和等于 target 时,记录解
if (total == target) {
res.push_back(state);
return;
}
// 遍历所有选择
for (size_t i = 0; i < choices.size(); i++) {
// 剪枝:若子集和超过 target ,则跳过该选择
if (total + choices[i] > target) {
continue;
}
// 尝试:做出选择,更新元素和 total
state.push_back(choices[i]);
// 进行下一轮选择
backtrack(state, target, total + choices[i], choices, res);
// 回退:撤销选择,恢复到之前的状态
state.pop_back();
}
}
/* 求解子集和 I(包含重复子集) */
vector<vector<int>> subsetSumINaive(vector<int> &nums, int target) {
vector<int> state; // 状态(子集)
int total = 0; // 子集和
vector<vector<int>> res; // 结果列表(子集列表)
backtrack(state, target, total, nums, res);
return res;
}
向以上代码输入数组 [3, 4, 5] 和目标元素 9 ,输出结果为 [3, 3, 3], [4, 5], [5, 4] 。虽然成功找出了所有和为
9 的子集,但其中存在重复的子集 [4, 5] 和 [5, 4] 。
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13‑10 所示,先选 4 后选 5 与先选 5
后选 4 是不同的分支,但对应同一个子集。
为了去除重复子集,一种直接的思路是对结果列表进行去重。但这个方法效率很低,有两方面原因。
‧ 当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。
‧ 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
2. 重复子集剪枝
我们考虑在搜索过程中通过剪枝进行去重。观察图 13‑11 ,重复子集是在以不同顺序选择数组元素时产生的,
例如以下情况。
- 当第一轮和第二轮分别选择 3 和 4 时,会生成包含这两个元素的所有子集,记为 [3, 4, … ] 。
- 之后,当第一轮选择 4 时,则第二轮应该跳过 3 ,因为该选择产生的子集 [4, 3, … ] 和第 1. 步中生成
的子集完全重复。
在搜索过程中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
- 前两轮选择 3 和 5 ,生成子集 [3, 5, … ] 。
- 前两轮选择 4 和 5 ,生成子集 [4, 5, … ] 。
- 若第一轮选择 5 ,则第二轮应该跳过 3 和 4 ,因为子集 [5, 3, … ] 和 [5, 4, … ] 与第 1. 步和第 2. 步中描述的子集完全重复。
总结来看,给定输入数组 [𝑥1 , 𝑥2 , … , 𝑥𝑛] ,设搜索过程中的选择序列为 [𝑥𝑖1 , 𝑥𝑖2 , … , 𝑥𝑖𝑚 ] ,则该选择序列
需要满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,不满足该条件的选择序列都会造成重复,应当剪枝。
3. 代码实现
为实现该剪枝,我们初始化变量 start ,用于指示遍历起始点。当做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 开始
遍历。这样做就可以让选择序列满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,从而保证子集唯一。
除此之外,我们还对代码进行了以下两项优化。
‧ 在开启搜索前,先将数组 nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为
后边的元素更大,其子集和一定超过 target 。
‧ 省去元素和变量 total ,通过在 target 上执行减法来统计元素和,当 target 等于 0 时记录解。
/* 回溯算法:子集和 I */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.push_back(state);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (int i = start; i < choices.size(); i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 尝试:做出选择,更新 target, start
state.push_back(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i, res);
// 回退:撤销选择,恢复到之前的状态
state.pop_back();
}
}
/* 求解子集和 I */
vector<vector<int>> subsetSumI(vector<int> &nums, int target) {
vector<int> state; // 状态(子集)
sort(nums.begin(), nums.end()); // 对 nums 进行排序
int start = 0; // 遍历起始点
vector<vector<int>> res; // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
所示为将数组 [3, 4, 5] 和目标元素 9 输入以上代码后的整体回溯过程。
考虑重复元素的情况
Question
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和
等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,
列表中不应包含重复组合。
相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。例如,给定数组 [4, 4, 5] 和目标元素
9 ,则现有代码的输出结果为 [4, 5], [4, 5] ,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择。在图 13‑13 中,第一轮共有三个选择,其中两个都为
4 ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 4 也会产生重复子集。
1. 相等元素剪枝
为解决此问题,我们需要限制相等元素在每一轮中只能被选择一次。实现方式比较巧妙:由于数组是已排序
的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选
择过,因此直接跳过当前元素。
与此同时,本题规定每个数组元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当
做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 + 1 开始向后遍历。这样既能去除重复子集,也能避免重复选择元素
2. 代码实现
/* 回溯算法:子集和 II */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.push_back(state);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
// 剪枝三:从 start 开始遍历,避免重复选择同一元素
for (int i = start; i < choices.size(); i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i > start && choices[i] == choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state.push_back(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i + 1, res);
// 回退:撤销选择,恢复到之前的状态
state.pop_back();
}
}
/* 求解子集和 II */
vector<vector<int>> subsetSumII(vector<int> &nums, int target) {
vector<int> state; // 状态(子集)
sort(nums.begin(), nums.end()); // 对 nums 进行排序
int start = 0; // 遍历起始点
vector<vector<int>> res; // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
n 皇后问题
Question
根据国际象棋的规则,皇后可以攻击与同处一行、一列或一条斜线上的棋子。给定 𝑛 个皇后和一个
𝑛 × 𝑛 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如图 13‑15 所示,当 𝑛 = 4 时,共可以找到两个解。从回溯算法的角度看,𝑛 × 𝑛 大小的棋盘共有 𝑛 2 个格
子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是
状态 state 。
4皇后问题的解
图 13‑16 展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一条对角线上。值得注意的是,对
角线分为主对角线 \ 和次对角线 / 两种。
1. 逐行放置策略
皇后的数量和棋盘的行数都为 𝑛 ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
图 13‑17 所示为 4 皇后问题的逐行放置过程。受画幅限制,图 13‑17 仅展开了第一行的其中一个搜索分支,
并且将不满足列约束和对角线约束的方案都进行了剪枝。
从本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支
2. 列与对角线剪枝
为了满足列约束,我们可以利用一个长度为 𝑛 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置
前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 (𝑟𝑜𝑤, 𝑐𝑜𝑙) ,选定矩阵中的某条主对角线,
我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 𝑟𝑜𝑤 − 𝑐𝑜𝑙 为恒定值。
也就是说,如果两个格子满足 𝑟𝑜𝑤1 − 𝑐𝑜𝑙1 = 𝑟𝑜𝑤2 − 𝑐𝑜𝑙2 ,则它们一定处在同一条主对角线上。利用该
规律,我们可以借助图 13‑18 所示的数组 diags1 记录每条主对角线上是否有皇后。
同理,次对角线上的所有格子的 𝑟𝑜𝑤 + 𝑐𝑜𝑙 是恒定值。我们同样也可以借助数组 diags2 来处理次对角线约
束。
3. 代码实现
请注意,𝑛 维方阵中 𝑟𝑜𝑤 − 𝑐𝑜𝑙 的范围是 [−𝑛 + 1, 𝑛 − 1] ,𝑟𝑜𝑤 + 𝑐𝑜𝑙 的范围是 [0, 2𝑛 − 2] ,所以主对
角线和次对角线的数量都为 2𝑛 − 1 ,即数组 diags1 和 diags2 的长度都为 2𝑛 − 1 。
/* 回溯算法:n 皇后 */
void backtrack(int row, int n, vector<vector<string>> &state, vector<vector<vector<string>>> &res, vector<bool> &cols,
vector<bool> &diags1, vector<bool> &diags2) {
// 当放置完所有行时,记录解
if (row == n) {
res.push_back(state);
return;
}
// 遍历所有列
for (int col = 0; col < n; col++) {
// 计算该格子对应的主对角线和次对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
// 剪枝:不允许该格子所在列、主对角线、次对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
cols[col] = diags1[diag1] = diags2[diag2] = true;
// 放置下一行
backtrack(row + 1, n, state, res, cols, diags1, diags2);
// 回退:将该格子恢复为空位
state[row][col] = "#";
cols[col] = diags1[diag1] = diags2[diag2] = false;
}
}
}
/* 求解 n 皇后 */
vector<vector<vector<string>>> nQueens(int n) {
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
vector<vector<string>> state(n, vector<string>(n, "#"));
vector<bool> cols(n, false); // 记录列是否有皇后
vector<bool> diags1(2 * n - 1, false); // 记录主对角线上是否有皇后
vector<bool> diags2(2 * n - 1, false); // 记录次对角线上是否有皇后
vector<vector<vector<string>>> res;
backtrack(0, n, state, res, cols, diags1, diags2);
return res;
}
逐行放置 𝑛 次,考虑列约束,则从第一行到最后一行分别有 𝑛、𝑛 − 1、…、2、1 个选择,使用 𝑂(𝑛!) 时
间。当记录解时,需要复制矩阵 state 并添加进 res ,复制操作使用 𝑂(𝑛2 ) 时间。因此,总体时间复杂度为
𝑂(𝑛! ⋅ 𝑛2 ) 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复
杂度。
数组 state 使用 𝑂(𝑛2 ) 空间,数组 cols、diags1 和 diags2 皆使用 𝑂(𝑛) 空间。最大递归深度为 𝑛 ,使用
𝑂(𝑛) 栈帧空间。因此,空间复杂度为 𝑂(𝑛2 ) 。
Q&A
Q:怎么理解回溯和递归的关系?
总的来看,回溯是一种“算法策略”,而递归更像是一个“工具”。
‧ 回溯算法通常基于递归实现。然而,回溯是递归的应用场景之一,是递归在搜索问题中的应用。
‧ 递归的结构体现了“子问题分解”的解题范式,常用于解决分治、回溯、动态规划(记忆化递归)等问题
动态规划
小溪汇入河流,江河汇入大海。
动态规划将小问题的解汇集成大问题的答案,一步步引领我们走向解决问题的彼岸
初探动态规划
动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并
通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出
更高效的动态规划解法
爬楼梯
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶?
对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶
本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多
轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1 ,当越过楼梯
顶部时就将其剪枝。代码如下所示
/* 回溯 */
void backtrack(vector<int> &choices, int state, int n, vector<int> &res) {
// 当爬到第 n 阶时,方案数量加 1
if (state == n)
res[0]++;
// 遍历所有选择
for (auto &choice : choices) {
// 剪枝:不允许越过第 n 阶
if (state + choice > n)
continue;
// 尝试:做出选择,更新状态
backtrack(choices, state + choice, n, res);
// 回退
}
}
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
vector<int> choices = {1, 2}; // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
vector<int> res = {0}; // 使用 res[0] 记录方案数量
backtrack(choices, state, n, res);
return res[0];
}
方法一:暴力搜索
回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所
有可能的解。
我们可以尝试从问题分解的角度分析这道题。设爬到第 𝑖 阶共有 𝑑𝑝[𝑖] 种方案,那么 𝑑𝑝[𝑖] 就是原问题,其
子问题包括:
𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2], … , 𝑑𝑝[2], 𝑑𝑝[1]
由于每轮只能上 1 阶或 2 阶,因此当我们站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖 − 1 阶或第 𝑖 − 2 阶
上。换句话说,我们只能从第 𝑖 − 1 阶或第 𝑖 − 2 阶迈向第 𝑖 阶。
由此便可得出一个重要推论:爬到第 𝑖 − 1 阶的方案数加上爬到第 𝑖 − 2 阶的方案数就等于爬到第 𝑖 阶的方
案数。公式如下:
𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2]
这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。图 14‑2
展示了该递推关系。
我们可以根据递推公式得到暴力搜索解法。以 𝑑𝑝[𝑛] 为起始点,递归地将一个较大问题拆解为两个较小问
题的和,直至到达最小子问题 𝑑𝑝[1] 和 𝑑𝑝[2] 时返回。其中,最小子问题的解是已知的,即 𝑑𝑝[1] = 1、
𝑑𝑝[2] = 2 ,表示爬到第 1、2 阶分别有 1、2 种方案。
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁:
/* 搜索 */
int dfs(int i) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
/* 爬楼梯:搜索 */
int climbingStairsDFS(int n) {
return dfs(n);
}
图 14‑3 展示了暴力搜索形成的递归树。对于问题 𝑑𝑝[𝑛] ,其递归树的深度为 𝑛 ,时间复杂度为 𝑂(2𝑛) 。指
数阶属于爆炸式增长,如果我们输入一个比较大的 𝑛 ,则会陷入漫长的等待之中。
观察图 14‑3 ,指数阶的时间复杂度是“重叠子问题”导致的。例如 𝑑𝑝[9] 被分解为 𝑑𝑝[8] 和 𝑑𝑝[7] ,𝑑𝑝[8]
被分解为 𝑑𝑝[7] 和 𝑑𝑝[6] ,两者都包含子问题 𝑑𝑝[7] 。
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的子
问题上
方法二:记忆化搜索
为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem 来记录每个
子问题的解,并在搜索过程中将重叠子问题剪枝。
- 当首次计算 𝑑𝑝[𝑖] 时,我们将其记录至 mem[i] ,以便之后使用。
- 当再次需要计算 𝑑𝑝[𝑖] 时,我们便可直接从 mem[i] 中获取结果,从而避免重复计算该子问题。
/* 记忆化搜索 */
int dfs(int i, vector<int> &mem) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// 若存在记录 dp[i] ,则直接返回之
if (mem[i] != -1)
return mem[i];
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1, mem) + dfs(i - 2, mem);
// 记录 dp[i]
mem[i] = count;
return count;
}
/* 爬楼梯:记忆化搜索 */
int climbingStairsDFSMem(int n) {
// mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
vector<int> mem(n + 1, -1);
return dfs(n, mem);
}
观察图 14‑4 ,经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 𝑂(𝑛) ,这是一个巨
大的飞跃。
方法三:动态规划
记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子
问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。
与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直
至得到原问题的解。
由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一
个数组 dp 来存储子问题的解,它起到了与记忆化搜索中数组 mem 相同的记录作用
/* 爬楼梯:动态规划 */
int climbingStairsDP(int n) {
if (n == 1 || n == 2)
return n;
// 初始化 dp 表,用于存储子问题的解
vector<int> dp(n + 1);
// 初始状态:预设最小子问题的解
dp[1] = 1;
dp[2] = 2;
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以
及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 𝑖 。
根据以上内容,我们可以总结出动态规划的常用术语。
‧ 将数组 dp 称为 dp 表,𝑑𝑝[𝑖] 表示状态 𝑖 对应子问题的解。
‧ 将最小子问题对应的状态(第 1 阶和第 2 阶楼梯)称为初始状态。
‧ 将递推公式 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 称为状态转移方程。
空间优化
细心的读者可能发现了,由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖 − 1] 和 𝑑𝑝[𝑖 − 2] 有关,因此我们无须使用一个数组 dp 来存
储所有子问题的解,而只需两个变量滚动前进即可。代码如下所示:
/* 爬楼梯:空间优化后的动态规划 */
int climbingStairsDPComp(int n) {
if (n == 1 || n == 2)
return n;
int a = 1, b = 2;
for (int i = 3; i <= n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return b;
}
观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 𝑂(𝑛) 降至 𝑂(1) 。
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”
来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”。
动态规划问题特性
,我们学习了动态规划是如何通过子问题分解来求解原问题的。实际上,子问题分解是一种通用
的算法思路,在分治、动态规划、回溯中的侧重点不同。
‧ 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的
解,最终得到原问题的解。
‧ 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在
分解过程中会出现许多重叠子问题。
‧ 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系
列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无
后效性。
最优子结构
爬楼梯最小代价
给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶
所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需要付出的代价,
𝑐𝑜𝑠𝑡[0] 为地面(起始点)。请计算最少需要付出多少代价才能到达顶部?
若第 1、2、3 阶的代价分别为 1、10、1 ,则从地面爬到第 3 阶的最小代价为 2 。
设 𝑑𝑝[𝑖] 为爬到第 𝑖 阶累计付出的代价,由于第 𝑖 阶只可能从 𝑖 − 1 阶或 𝑖 − 2 阶走来,因此 𝑑𝑝[𝑖] 只可能等
于 𝑑𝑝[𝑖−1]+𝑐𝑜𝑠𝑡[𝑖] 或 𝑑𝑝[𝑖−2]+𝑐𝑜𝑠𝑡[𝑖] 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
𝑑𝑝[𝑖] = min(𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2]) + 𝑐𝑜𝑠𝑡[𝑖]
这便可以引出最优子结构的含义:原问题的最优解是从子问题的最优解构建得来的。
本题显然具有最优子结构:我们从两个子问题最优解 𝑑𝑝[𝑖 − 1] 和 𝑑𝑝[𝑖 − 2] 中挑选出较优的那一个,并用
它构建出原问题 𝑑𝑝[𝑖] 的最优解。
根据状态转移方程,以及初始状态 𝑑𝑝[1] = 𝑐𝑜𝑠𝑡[1] 和 𝑑𝑝[2] = 𝑐𝑜𝑠𝑡[2] ,我们就可以得到动态规划代码:
/* 爬楼梯最小代价:动态规划 */
int minCostClimbingStairsDP(vector<int> &cost) {
int n = cost.size() - 1;
if (n == 1 || n == 2)
return cost[n];
// 初始化 dp 表,用于存储子问题的解
vector<int> dp(n + 1);
// 初始状态:预设最小子问题的解
dp[1] = cost[1];
dp[2] = cost[2];
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
}
return dp[n];
}
本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 𝑂(𝑛) 降至 𝑂(1)
无后效性
无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:给定一个确定的状态,它的未来发展只
与当前状态有关,而与过去经历的所有状态无关。
以爬楼梯问题为例,给定状态 𝑖 ,它会发展出状态 𝑖 + 1 和状态 𝑖 + 2 ,分别对应跳 1 步和跳 2 步。在做出
这两种选择时,我们无须考虑状态 𝑖 之前的状态,它们对状态 𝑖 的未来没有影响。
然而,如果我们给爬楼梯问题添加一个约束,情况就不一样了。
带约束爬楼梯**
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,**但不能连续两轮跳** 1 **阶**,请问有多少种方案
可以爬到楼顶?
爬上第 3 阶仅剩 2 种可行方案,其中连续三次跳 1 阶的方案不满足约束条件,因此被舍弃。
爬楼梯与障碍生成
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶。规定当爬到第 𝑖 阶时,系统自动会在第
阶上放上障碍物,之后所有轮都不允许跳到第 2𝑖
2𝑖
阶上。例如,前两轮分别跳到了第 2、3 阶上,则之
后就不能跳到第 4、6 阶上。请问有多少种方案可以爬到楼顶?
在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来
的跳跃。对于这类问题,动态规划往往难以解决。
实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使
用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
问题求解步骤
动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立𝑑𝑝 表,推导状态转移方程,
为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。
Question
给定一个 𝑛 × 𝑚 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。
机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回
从左上角到右下角的最小路径和。
展示了一个例子,给定网格的最小路径和为 13
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
本题的每一轮的决策就是从当前格子向下或向右走一步。设当前格子的行列索引为 [𝑖, 𝑗] ,则向下或向右走
一步后,索引变为 [𝑖 + 1, 𝑗] 或 [𝑖, 𝑗 + 1] 。因此,状态应包含行索引和列索引两个变量,记为 [𝑖, 𝑗] 。
状态 [𝑖, 𝑗] 对应的子问题为:从起始点 [0, 0] 走到 [𝑖, 𝑗] 的最小路径和,解记为 𝑑𝑝[𝑖, 𝑗] 。
至此,我们就得到了图 14‑11 所示的二维 𝑑𝑝 矩阵,其尺寸与输入网格 𝑔𝑟𝑖𝑑 相同。
动态规划和回溯过程可以描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进
度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
每个状态都对应一个子问题,我们会定义一个 𝑑𝑝 表来存储所有子问题的解,状态的每个独立变量都
是 𝑑𝑝 表的一个维度。从本质上看,𝑑𝑝 表是状态和子问题的解之间的映射。
第二步:找出最优子结构,进而推导出状态转移方程
对于状态 [𝑖, 𝑗] ,它只能从上边格子 [𝑖 − 1, 𝑗]
和左边格子 [𝑖, 𝑗 − 1] 转移而来。因此最优子结构为:到达[𝑖, 𝑗] 的最小路径和由 [𝑖, 𝑗 − 1] 的最小路径和与 [𝑖 − 1, 𝑗]
的最小路径和中较小的那一个决定。
根据以上分析,可推出图 14‑12 所示的状态转移方程
𝑑𝑝[𝑖, 𝑗] = min(𝑑𝑝[𝑖 − 1, 𝑗], 𝑑𝑝[𝑖, 𝑗 − 1]) + 𝑔𝑟𝑖𝑑[𝑖, 𝑗]
第三步:确定边界条件和状态转移顺序
在本题中,处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行𝑖 = 0 和首列 𝑗 = 0 是边界条件。
如图 14‑13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵,外循
环遍历各行,内循环遍历各列。
Note
边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。
状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经被正确地计算出来。
根据以上分析,我们已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照
“暴力搜索 → 记忆化搜索 → 动态规划”的顺序实现更加符合思维习惯
基于迭代实现动态规划解法,代码如下所示:
/* 最小路径和:动态规划 */
int minPathSumDP(vector<vector<int>> &grid) {
int n = grid.size(), m = grid[0].size();
// 初始化 dp 表
vector<vector<int>> dp(n, vector<int>(m));
dp[0][0] = grid[0][0];
// 状态转移:首行
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 状态转移:首列
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 状态转移:其余行和列
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[n - 1][m - 1];
}
展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 𝑂(𝑛𝑚) 。
数组 dp 大小为 𝑛 × 𝑚 ,因此空间复杂度为 𝑂(𝑛𝑚)
4. 空间优化
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 𝑑𝑝 表。
请注意,因为数组 dp 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行时更新它:
/* 最小路径和:空间优化后的动态规划 */
int minPathSumDPComp(vector<vector<int>> &grid) {
int n = grid.size(), m = grid[0].size();
// 初始化 dp 表
vector<int> dp(m);
// 状态转移:首行
dp[0] = grid[0][0];
for (int j = 1; j < m; j++) {
dp[j] = dp[j - 1] + grid[0][j];
}
// 状态转移:其余行
for (int i = 1; i < n; i++) {
// 状态转移:首列
dp[0] = dp[0] + grid[i][0];
// 状态转移:其余列
for (int j = 1; j < m; j++) {
dp[j] = min(dp[j - 1], dp[j]) + grid[i][j];
}
}
return dp[m - 1];
}
0‑1 背包问题
背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0‑1
背包问题、完全背包问题、多重背包问题等。
在本节中,我们先来求解最常见的 0‑1 背包问题。
Question
给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的背包。每
个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。
,由于物品编号 𝑖 从 1 开始计数,数组索引从 0 开始计数,因此物品 𝑖 对应重量 𝑤𝑔𝑡[𝑖 − 1] 和价值 𝑣𝑎𝑙[𝑖 − 1]
我们可以将 0‑1 背包问题看作一个由 𝑛 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此
该问题满足决策树模型。
该问题的目标是求解“在限定背包容量下能放入物品的最大价值”,因此较大概率是一个动态规划问题
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品
编号 𝑖 和背包容量 𝑐 ,记为 [𝑖, 𝑐] 。
状态 [𝑖, 𝑐] 对应的子问题为:前 𝑖 个物品在容量为 𝑐 的背包中的最大价值,记为 𝑑𝑝[𝑖, 𝑐] 。
待求解的是 𝑑𝑝[𝑛, 𝑐𝑎𝑝] ,因此需要一个尺寸为 (𝑛 + 1) × (𝑐𝑎𝑝 + 1) 的二维 𝑑𝑝 表。
第二步:找出最优子结构,进而推导出状态转移方程
当我们做出物品 𝑖 的决策后,剩余的是前 𝑖 − 1 个物品决策的子问题,可分为以下两种情况
不放入物品 𝑖 :背包容量不变,状态变化为 [𝑖 − 1, 𝑐] 。
‧ 放入物品 𝑖 :背包容量减少 𝑤𝑔𝑡[𝑖 − 1] ,价值增加 𝑣𝑎𝑙[𝑖 − 1] ,状态变化为 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]]
上述分析向我们揭示了本题的最优子结构:最大价值 𝑑𝑝[𝑖, 𝑐] 等于不放入物品 𝑖 和放入物品 𝑖 两种方案中价
值更大的那一个。由此可推导出状态转移方程:
𝑑𝑝[𝑖, 𝑐] = max(𝑑𝑝[𝑖 − 1, 𝑐], 𝑑𝑝[𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] + 𝑣𝑎𝑙[𝑖 − 1])
需要注意的是,若当前物品重量 𝑤𝑔𝑡[𝑖 − 1] 超出剩余背包容量 𝑐 ,则只能选择不放入背包。
第三步:确定边界条件和状态转移顺序
当无物品或背包容量为 0 时最大价值为 0 ,即首列 𝑑𝑝[𝑖, 0] 和首行 𝑑𝑝[0, 𝑐] 都等于 0 。
当前状态 [𝑖, 𝑐] 从上方的状态 [𝑖 − 1, 𝑐] 和左上方的状态 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 转移而来,因此通过两层循
环正序遍历整个 𝑑𝑝 表即可。
根据以上分析,我们接下来按顺序实现暴力搜索、记忆化搜索、动态规划解法。
动态规划实质上就是在状态转移中填充 𝑑𝑝 表的过程,代码如下所示:
/* 0-1 背包:动态规划 */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
4. 空间优化
由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 𝑂(𝑛2 )
降至 𝑂(𝑛) 。
进一步思考,我们能否仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转
移过来的。假设只有一个数组,当开始遍历第 𝑖 行时,该数组存储的仍然是第 𝑖 − 1 行的状态。
‧ 如果采取正序遍历,那么遍历到 𝑑𝑝[𝑖, 𝑗] 时,左上方 𝑑𝑝[𝑖 − 1, 1] ~ 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 值可能已经被覆
盖,此时就无法得到正确的状态转移结果。
‧ 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
在代码实现中,我们仅需将数组 dp 的第一维 𝑖 直接删除,并且把内循环更改为倒序遍历即可:
/* 0-1 背包:空间优化后的动态规划 */
int knapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<int> dp(cap + 1, 0);
// 状态转移
for (int i = 1; i <= n; i++) {
// 倒序遍历
for (int c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 不选和选物品 i 这两种方案的较大值
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
完全背包问题
我们先求解另一个常见的背包问题:完全背包,再了解它的一种特例:零钱兑换。
Question
给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝
的背包。每个物品可以重复选取,问在限定背包容量下能放入物品的最大价值。示例如图 14‑22 所示。
1. 动态规划思路
完全背包问题和 0‑1 背包问题非常相似,区别仅在于不限制物品的选择次数。
‧ 在 0‑1 背包问题中,每种物品只有一个,因此将物品 𝑖 放入背包后,只能从前 𝑖 − 1 个物品中选择。
‧ 在完全背包问题中,每种物品的数量是无限的,因此将物品 𝑖 放入背包后,仍可以从前 𝑖 个物品中选择。
在完全背包问题的规定下,状态 [𝑖, 𝑐] 的变化分为两种情况。
‧ 不放入物品 𝑖 :与 0‑1 背包问题相同,转移至[𝑖 − 1, 𝑐]
‧ 放入物品 𝑖 :与 0‑1 背包问题不同,转移至 [𝑖, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 。
从而状态转移方程变为:
𝑑𝑝[𝑖, 𝑐] = max(𝑑𝑝[𝑖 − 1, 𝑐], 𝑑𝑝[𝑖, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] + 𝑣𝑎𝑙[𝑖 − 1])
2. 代码实现
对比两道题目的代码,状态转移中有一处从 𝑖 − 1 变为 𝑖 ,其余完全一致:
/* 完全背包:动态规划 */
int unboundedKnapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
3. 空间优化
由于当前状态是从左边和上边的状态转移而来的,因此空间优化后应该对 𝑑𝑝 表中的每一行进行正序遍历。
这个遍历顺序与 0‑1 背包正好相反。
代码实现比较简单,仅需将数组 dp
的第一维删除:
/* 完全背包:空间优化后的动态规划 */
int unboundedKnapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<int> dp(cap + 1, 0);
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[c] = dp[c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
零钱兑换问题
背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题
Question
给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问
能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 −1 。示例如图 14‑24 所示。
1. 动态规划思路
零钱兑换可以看作完全背包问题的一种特殊情况,两者具有以下联系与不同点。
‧ 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
‧ 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
‧ 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
状态 [𝑖, 𝑎] 对应的子问题为:前 𝑖 种硬币能够凑出金额 𝑎 的最少硬币数量,记为 𝑑𝑝[𝑖, 𝑎] 。
二维 𝑑𝑝 表的尺寸为 (𝑛 + 1) × (𝑎𝑚𝑡 + 1)
第二步:找出最优子结构,进而推导出状态转移方程
本题与完全背包问题的状态转移方程存在以下两点差异。
‧ 本题要求最小值,因此需将运算符 max() 更改为 min() 。
‧ 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 +1 即可。
𝑑𝑝[𝑖, 𝑎] = min(𝑑𝑝[𝑖 − 1, 𝑎], 𝑑𝑝[𝑖, 𝑎 − 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1]] + 1)
第三步:确定边界条件和状态转移顺序
当目标金额为 0 时,凑出它的最少硬币数量为 0 ,即首列所有 𝑑𝑝[𝑖, 0] 都等于 0 。
当无硬币时,无法凑出任意 > 0 的目标金额,即是无效解。为使状态转移方程中的 min() 函数能够识别并过滤无效解,我们考虑使用 +∞ 来表示它们,即令首行所有 𝑑𝑝[0, 𝑎] 都等于 +∞ 。
2. 代码实现
大多数编程语言并未提供 +∞ 变量,只能使用整型 int 的最大值来代替。而这又会导致大数越界:状态转移方程中的 +1 操作可能发生溢出。
为此,我们采用数字 𝑎𝑚𝑡 + 1 来表示无效解,因为凑出 𝑎𝑚𝑡 的硬币数量最多为 𝑎𝑚𝑡 。最后返回前,判断
𝑑𝑝[𝑛, 𝑎𝑚𝑡] 是否等于 𝑎𝑚𝑡 + 1 ,若是则返回 −1 ,代表无法凑出目标金额。代码如下所示:
/* 零钱兑换:动态规划 */
int coinChangeDP(vector<int> &coins, int amt) {
int n = coins.size();
int MAX = amt + 1;
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(amt + 1, 0));
// 状态转移:首行首列
for (int a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行和列
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
// 若超过目标金额,则不选硬币 i
dp[i][a] = dp[i - 1][a];
} else {
// 不选和选硬币 i 这两种方案的较小值
dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1);
}
}
}
return dp[n][amt] != MAX ? dp[n][amt] : -1;
}
空间优化¶
零钱兑换的空间优化的处理方式和完全背包问题一致:
/* 零钱兑换:空间优化后的动态规划 */
int coinChangeDPComp(vector<int> &coins, int amt) {
int n = coins.size();
int MAX = amt + 1;
// 初始化 dp 表
vector<int> dp(amt + 1, MAX);
dp[0] = 0;
// 状态转移
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
// 若超过目标金额,则不选硬币 i
dp[a] = dp[a];
} else {
// 不选和选硬币 i 这两种方案的较小值
dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1);
}
}
}
return dp[amt] != MAX ? dp[amt] : -1;
}
零钱兑换问题 II
Question
给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问凑出目标金额的硬币组合数量*。示例如图 14‑26 所示。
1. 动态规划思路
相比于上一题,本题目标是求组合数量,因此子问题变为:前 𝑖 种硬币能够凑出金额 𝑎 的组合数量。而 𝑑𝑝
表仍然是尺寸为 (𝑛 + 1) × (𝑎𝑚𝑡 + 1) 的二维矩阵。
当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:
𝑑𝑝[𝑖, 𝑎] = 𝑑𝑝[𝑖 − 1, 𝑎] + 𝑑𝑝[𝑖, 𝑎 − 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1]]
当目标金额为 0 时,无须选择任何硬币即可凑出目标金额,因此应将首列所有 𝑑𝑝[𝑖, 0] 都初始化为 1 。当无
硬币时,无法凑出任何 > 0 的目标金额,因此首行所有 𝑑𝑝[0, 𝑎] 都等于 0 。
2. 代码实现
/* 零钱兑换 II:动态规划 */
int coinChangeIIDP(vector<int> &coins, int amt) {
int n = coins.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(amt + 1, 0));
// 初始化首列
for (int i = 0; i <= n; i++) {
dp[i][0] = 1;
}
// 状态转移
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
// 若超过目标金额,则不选硬币 i
dp[i][a] = dp[i - 1][a];
} else {
// 不选和选硬币 i 这两种方案之和
dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]];
}
}
}
return dp[n][amt];
}
3. 空间优化
空间优化处理方式相同,删除硬币维度即可
/* 零钱兑换 II:空间优化后的动态规划 */
int coinChangeIIDPComp(vector<int> &coins, int amt) {
int n = coins.size();
// 初始化 dp 表
vector<int> dp(amt + 1, 0);
dp[0] = 1;
// 状态转移
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
// 若超过目标金额,则不选硬币 i
dp[a] = dp[a];
} else {
// 不选和选硬币 i 这两种方案之和
dp[a] = dp[a] + dp[a - coins[i - 1]];
}
}
}
return dp[amt];
}
编辑距离问题
编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自
然语言处理中度量两个序列的相似度。
**Question**
输入两个字符串 𝑠 和 𝑡 ,返回将 𝑠 转换为 𝑡 所需的最少编辑步数。你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、将字符替换为任意一个字符。
如图 14‑27 所示,将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转
换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。
编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的
一条边。如图 14‑28 所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味
着从 hello 转换到 algo 有许多种可能的路径。
从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。
1. 动态规划思路
第一步:思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
每一轮的决策是对字符串 𝑠 进行一次编辑操作。我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 𝑠 和 𝑡 的长度分别为
𝑛 和 𝑚 ,我们先考虑两字符串尾部的字符 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 。
‧ 若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 相同,我们可以跳过它们,直接考虑 𝑠[𝑛 − 2] 和 𝑡[𝑚 − 2] 。
‧ 若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 不同,我们需要对 𝑠 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
也就是说,我们在字符串 𝑠 中进行的每一轮决策(编辑操作),都会使得 𝑠 和 𝑡 中剩余的待匹配字符发生变化。因此,状态为当前在 𝑠 和 𝑡 中考虑的第 𝑖 和第 𝑗 个字符,记为 [𝑖, 𝑗] 。状态 [𝑖, 𝑗] 对应的子问题:
将 𝑠 的前 𝑖 个字符更改为 𝑡 的前 𝑗 个字符所需的最少编辑步数。至此,得到一个尺寸为 (𝑖 + 1) × (𝑗 + 1) 的二维 𝑑𝑝 表
第二步:找出最优子结构,进而推导出状态转移方程
考虑子问题 𝑑𝑝[𝑖, 𝑗] ,其对应的两个字符串的尾部字符为 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] ,可根据不同编辑操作分为图
14‑29 所示的三种情况。
-
在 𝑠[𝑖 − 1] 之后添加 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖, 𝑗 − 1] 。
-
删除 𝑠[𝑖 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗]
-
将 𝑠[𝑖 − 1] 替换为 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 。
根据以上分析,可得最优子结构:𝑑𝑝[𝑖, 𝑗] 的最少编辑步数等于
𝑑𝑝[𝑖, 𝑗 − 1]、𝑑𝑝[𝑖 − 1, 𝑗]、𝑑𝑝[𝑖 − 1, 𝑗 − 1]三者中的最少编辑步数,再加上本次的编辑步数 1 。对应的状态转移方程为:
𝑑𝑝[𝑖, 𝑗] = min(𝑑𝑝[𝑖, 𝑗 − 1], 𝑑𝑝[𝑖 − 1, 𝑗], 𝑑𝑝[𝑖 − 1, 𝑗 − 1]) + 1
请注意,当 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] 相同时,无须编辑当前字符,这种情况下的状态转移方程为:
𝑑𝑝[𝑖, 𝑗] = 𝑑𝑝[𝑖 − 1, 𝑗 − 1]
第三步:确定边界条件和状态转移顺序
当两字符串都为空时,编辑步数为 0 ,即 𝑑𝑝[0, 0] = 0 。当 𝑠 为空但 𝑡 不为空时,最少编辑步数等于 𝑡 的长度,即首行 𝑑𝑝[0, 𝑗] = 𝑗 。当 𝑠 不为空但 𝑡
为空时,最少编辑步数等于 𝑠 的长度,即首列 𝑑𝑝[𝑖, 0] = 𝑖 。
观察状态转移方程,解 𝑑𝑝[𝑖, 𝑗] 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。
2. 代码实现
/* 编辑距离:动态规划 */
int editDistanceDP(string s, string t) {
int n = s.length(), m = t.length();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
// 状态转移:首行首列
for (int i = 1; i <= n; i++) {
dp[i][0] = i;
}
for (int j = 1; j <= m; j++) {
dp[0][j] = j;
}
// 状态转移:其余行和列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s[i - 1] == t[j - 1]) {
// 若两字符相等,则直接跳过此两字符
dp[i][j] = dp[i - 1][j - 1];
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[n][m];
}