diff --git a/chapter_appendix/contribution.assets/edit_markdown.png b/chapter_appendix/contribution.assets/edit_markdown.png index d1ac26fa6..c11dce4fb 100644 Binary files a/chapter_appendix/contribution.assets/edit_markdown.png and b/chapter_appendix/contribution.assets/edit_markdown.png differ diff --git a/chapter_appendix/contribution/index.html b/chapter_appendix/contribution/index.html index 4061ed120..92a4b4b74 100644 --- a/chapter_appendix/contribution/index.html +++ b/chapter_appendix/contribution/index.html @@ -3396,12 +3396,12 @@
由于作者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。
-所有撰稿人的 GitHub ID 将被展示在本书的仓库主页上,以感谢他们对开源社区的无私奉献。
+由于笔者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、链接失效、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。
+所有撰稿人的 GitHub ID 将在本书仓库、网页版和 PDF 版的主页上进行展示,以感谢他们对开源社区的无私奉献。
开源的魅力
-纸质书籍的两次印刷的间隔时间往往需要数年,内容更新非常不方便。
-然而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。
+纸质图书的两次印刷的间隔时间往往较久,内容更新非常不方便。
+而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。
如图 16-1 所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码。
@@ -3417,17 +3417,17 @@如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施以下 Pull Request 工作流程。
git clone 命令将仓库克隆至本地。在 hello-algo 根目录下,执行以下 Docker 脚本,即可在 http://localhost:8000 访问本项目。
在 hello-algo 根目录下,执行以下 Docker 脚本,即可在 http://localhost:8000 访问本项目:
使用以下命令即可删除部署。
+使用以下命令即可删除部署:
diff --git a/chapter_appendix/installation/index.html b/chapter_appendix/installation/index.html index 554f8ae2d..e40bd60cb 100644 --- a/chapter_appendix/installation/index.html +++ b/chapter_appendix/installation/index.html @@ -3495,7 +3495,7 @@本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 VSCode 。
+本书推荐使用开源、轻量的 VSCode 作为本地 IDE ,下载并安装 VSCode 。
c++ ,安装 C/C++ Extension Pack 。Clang_format_fallback Style 代码格式化选项,设置为 { BasedOnStyle: Microsoft, BreakBeforeBraces: Attach } 。go ,安装 Go 。Ctrl + Shift + P 呼出命令栏,输入 go ,选择 Go: Install/Update Tools ,全部勾选并安装即可。Ctrl + Shift + P 呼出命令栏,输入 go ,选择 Go: Install/Update Tools ,全部勾选并安装即可。表 16-1 列出了书中出现的重要术语。建议你同时记住它们的中英文叫法,以便阅读英文文献。
-表 16-1 数据结构与算法重要名词
+表 16-1 数据结构与算法的重要名词
| 中文 | English | +中文 | +English |
|---|---|---|---|
| 算法 | algorithm | -||
| 数据结构 | -data structure | -||
| 渐近复杂度分析 | -asymptotic complexity analysis | -||
| 时间复杂度 | -time complexity | -||
| 空间复杂度 | -space complexity | -||
| 迭代 | -iteration | -||
| 递归 | -recursion | -||
| 尾递归 | -tail recursion | -||
| 递归树 | -recursion tree | -||
| 大 \(O\) 记号 | -big-\(O\) notation | -||
| 渐近上界 | -asymptotic upper bound | -||
| 原码 | -sign–magnitude | -||
| 反码 | -1's complement | -||
| 补码 | -2's complement | -||
| 数组 | -array | -||
| 索引 | -index | -||
| 链表 | -linked list | -||
| 链表节点 | -linked list node, list node | -||
| 列表 | -list | -||
| 动态数组 | -dynamic array | -||
| 栈 | -stack | -||
| 队列 | -queue | -||
| 双向队列 | -double-ended queue | -||
| 哈希表 | -hash table | -||
| 桶 | -bucket | -||
| 哈希函数 | -hash function | -||
| 哈希冲突 | -hash collision | -||
| 负载因子 | -load factor | -||
| 链式地址 | -separate chaining | -||
| 开放寻址 | -open addressing | -||
| 线性探测 | -linear probing | -||
| 懒删除 | -lazy deletion | -||
| 二叉树 | -binary tree | -||
| 树节点 | -tree node | -||
| 左子节点 | -left-child node | -||
| 右子节点 | -right-child node | -||
| 父节点 | -parent node | -||
| 左子树 | -left subtree | -||
| 右子树 | -right subtree | -||
| 根节点 | -root node | -||
| 叶节点 | -leaf node | -||
| 边 | -edge | -||
| 层 | -level | -||
| 度 | -degree | -||
| 高度 | -height | -||
| 深度 | -depth | -||
| 完美二叉树 | -perfect binary tree | -||
| 完全二叉树 | -complete binary tree | -||
| 完满二叉树 | -full binary tree | -||
| 平衡二叉树 | -balanced binary tree | -||
| AVL 树 | -AVL tree | -||
| 红黑树 | -red-black tree | -||
| 层序遍历 | level-order traversal | ||
| 数据结构 | +data structure | 广度优先遍历 | breadth-first traversal |
| 渐近复杂度分析 | +asymptotic complexity analysis | 深度优先遍历 | depth-first traversal |
| 时间复杂度 | +time complexity | 二叉搜索树 | binary search tree |
| 空间复杂度 | +space complexity | 平衡二叉搜索树 | balanced binary search tree |
| 迭代 | +iteration | 平衡因子 | balance factor |
| 递归 | +recursion | 堆 | heap |
| 尾递归 | +tail recursion | 大顶堆 | max heap |
| 递归树 | +recursion tree | 小顶堆 | min heap |
| 大 | +big- | 优先队列 | priority queue |
| 记号 | +notation | ++ | + |
| 渐近上界 | +asymptotic upper bound | 堆化 | heapify |
| 原码 | +sign–magnitude | 图 | graph |
| 反码 | +1’s complement | 顶点 | vertex |
| 补码 | +2’s complement | 无向图 | undirected graph |
| 数组 | +array | 有向图 | directed graph |
| 索引 | +index | 连通图 | connected graph |
| 链表 | +linked list | 非连通图 | disconnected graph |
| 链表节点 | +linked list node, list node | 有权图 | weighted graph |
| 列表 | +list | 邻接 | adjacency |
| 动态数组 | +dynamic array | 路径 | path |
| 硬盘 | +hard disk | 入度 | in-degree |
| 内存 | +random-access memory (RAM) | 出度 | out-degree |
| 缓存 | +cache memory | 邻接矩阵 | adjacency matrix |
| 缓存未命中 | +cache miss | 邻接表 | adjacency list |
| 缓存命中率 | +cache hit rate | 广度优先搜索 | breadth-first search |
| 栈 | +stack | 深度优先搜索 | depth-first search |
| 队列 | +queue | 二分查找 | binary search |
| 双向队列 | +double-ended queue | 搜索算法 | searching algorithm |
| 哈希表 | +hash table | 排序算法 | sorting algorithm |
| 桶 | +bucket | 选择排序 | selection sort |
| 哈希函数 | +hash function | 冒泡排序 | bubble sort |
| 哈希冲突 | +hash collision | 插入排序 | insertion sort |
| 负载因子 | +load factor | 快速排序 | quick sort |
| 链式地址 | +separate chaining | 归并排序 | merge sort |
| 开放寻址 | +open addressing | 堆排序 | heap sort |
| 线性探测 | +linear probing | 桶排序 | bucket sort |
| 懒删除 | +lazy deletion | 计数排序 | counting sort |
| 二叉树 | +binary tree | 基数排序 | radix sort |
| 树节点 | +tree node | 分治 | divide and conquer |
| 左子节点 | +left-child node | 汉诺塔问题 | hanota problem |
| 右子节点 | +right-child node | 回溯算法 | backtracking algorithm |
| 父节点 | +parent node | 约束 | constraint |
| 左子树 | +left subtree | 解 | solution |
| 右子树 | +right subtree | 状态 | state |
| 根节点 | +root node | 剪枝 | pruning |
| 叶节点 | +leaf node | 全排列问题 | permutations problem |
| 边 | +edge | 子集和问题 | subset-sum problem |
| 层 | +level | N 皇后问题 | N-queens problem |
| 度 | +degree | 动态规划 | dynamic programming |
| 高度 | +height | 初始状态 | initial state |
| 深度 | +depth | 状态转移方程 | state-trasition equation |
| 完美二叉树 | +perfect binary tree | 背包问题 | knapsack problem |
| 完全二叉树 | +complete binary tree | 编辑距离问题 | edit distance problem |
| 完满二叉树 | +full binary tree | 贪心算法 | greedy algorithm |
| 平衡二叉树 | +balanced binary tree | ++ | + |
| AVL 树 | +AVL tree | ++ | + |
| 红黑树 | +red-black tree | ++ | + |
「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。图 4-1 展示了数组的主要术语和概念。
+「数组 array」是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。图 4-1 展示了数组的主要术语和概念。
图 4-1 数组定义与存储方式
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 \(0\) 。
+我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 \(0\) :
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(即首元素内存地址)和某个元素的索引,我们可以使用图 4-2 所示的公式计算得到该元素的内存地址,从而直接访问此元素。
+数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,我们可以使用图 4-2 所示的公式计算得到该元素的内存地址,从而直接访问该元素。
图 4-2 数组元素的内存地址计算
-观察图 4-2 ,我们发现数组首个元素的索引为 \(0\) ,这似乎有些反直觉,因为从 \(1\) 开始计数会更自然。但从地址计算公式的角度看,索引的含义本质上是内存地址的偏移量。首个元素的地址偏移量是 \(0\) ,因此它的索引为 \(0\) 也是合理的。
-在数组中访问元素是非常高效的,我们可以在 \(O(1)\) 时间内随机访问数组中的任意一个元素。
+观察图 4-2 ,我们发现数组首个元素的索引为 \(0\) ,这似乎有些反直觉,因为从 \(1\) 开始计数会更自然。但从地址计算公式的角度看,索引本质上是内存地址的偏移量。首个元素的地址偏移量是 \(0\) ,因此它的索引为 \(0\) 是合理的。
+在数组中访问元素非常高效,我们可以在 \(O(1)\) 时间内随机访问数组中的任意一个元素。
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
+数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
图 4-3 数组插入元素示例
-值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
+值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在“列表”章节中讨论。
同理,如图 4-4 所示,若想要删除索引 \(i\) 处的元素,则需要把索引 \(i\) 之后的元素都向前移动一位。
+同理,如图 4-4 所示,若想删除索引 \(i\) 处的元素,则需要把索引 \(i\) 之后的元素都向前移动一位。
图 4-4 数组删除元素示例
@@ -3902,14 +3902,14 @@// 删除索引 index 处元素
+array.zig// 删除索引 index 处的元素
fn remove(nums: []i32, index: usize) void {
// 把索引 index 之后的所有元素向前移动一位
var i = index;
@@ -4026,10 +4026,10 @@
- 时间复杂度高:数组的插入和删除的平均时间复杂度均为 \(O(n)\) ,其中 \(n\) 为数组长度。
- 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
-- 内存浪费:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做也会造成部分内存空间的浪费。
+- 内存浪费:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
5. 遍历数组¶
-在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素。
+在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素:
@@ -4361,7 +4361,7 @@
7. 扩容数组¶
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,数组的长度是不可变的。
-如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次拷贝到新数组。这是一个 \(O(n)\) 的操作,在数组很大的情况下是非常耗时的。
+如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次复制到新数组。这是一个 \(O(n)\) 的操作,在数组很大的情况下非常耗时。代码如下所示:
@@ -4451,7 +4451,7 @@
array.js/* 扩展数组长度 */
// 请注意,JavaScript 的 Array 是动态数组,可以直接扩展
-// 为了方便学习,本函数将 Array 看作是长度不可变的数组
+// 为了方便学习,本函数将 Array 看作长度不可变的数组
function extend(nums, enlarge) {
// 初始化一个扩展长度后的数组
const res = new Array(nums.length + enlarge).fill(0);
@@ -4467,7 +4467,7 @@
-4.1.2 数组优点与局限性¶
+4.1.2 数组的优点与局限性¶
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来优化数据结构的操作效率。
-- 空间效率高: 数组为数据分配了连续的内存块,无须额外的结构开销。
-- 支持随机访问: 数组允许在 \(O(1)\) 时间内访问任何元素。
-- 缓存局部性: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
+- 空间效率高:数组为数据分配了连续的内存块,无须额外的结构开销。
+- 支持随机访问:数组允许在 \(O(1)\) 时间内访问任何元素。
+- 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
-连续空间存储是一把双刃剑,其存在以下缺点。
+连续空间存储是一把双刃剑,其存在以下局限性。
-- 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
-- 长度不可变: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
-- 空间浪费: 如果数组分配的大小超过了实际所需,那么多余的空间就被浪费了。
+- 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
+- 长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
+- 空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
4.1.3 数组典型应用¶
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
-- 随机访问:如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
+- 随机访问:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现随机抽样。
- 排序和搜索:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
-- 查找表:当我们需要快速查找一个元素或者需要查找一个元素的对应关系时,可以使用数组作为查找表。假如我们想要实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
+- 查找表:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
- 机器学习:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
- 数据结构实现:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。
diff --git a/chapter_array_and_linkedlist/linked_list/index.html b/chapter_array_and_linkedlist/linked_list/index.html
index 1159563c7..2a2a3223c 100644
--- a/chapter_array_and_linkedlist/linked_list/index.html
+++ b/chapter_array_and_linkedlist/linked_list/index.html
@@ -1061,7 +1061,7 @@
-
- 4.2.2 数组 VS 链表
+ 4.2.2 数组 vs. 链表
@@ -3417,7 +3417,7 @@
-
- 4.2.2 数组 VS 链表
+ 4.2.2 数组 vs. 链表
@@ -3494,7 +3494,7 @@
4.2 链表¶
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
-链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。
+链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。
图 4-5 链表定义与存储方式
@@ -3650,7 +3650,7 @@
4.2.1 链表常用操作¶
1. 初始化链表¶
-建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 next 依次访问所有节点。
+建立链表分为两步,第一步是初始化各个节点对象,第二步是构建节点之间的引用关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 next 依次访问所有节点。
@@ -3661,7 +3661,7 @@
n2 = ListNode(2)
n3 = ListNode(5)
n4 = ListNode(4)
-# 构建引用指向
+# 构建节点之间的引用
n0.next = n1
n1.next = n2
n2.next = n3
@@ -3676,7 +3676,7 @@
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
@@ -3691,7 +3691,7 @@
ListNode n2 = new ListNode(2);
ListNode n3 = new ListNode(5);
ListNode n4 = new ListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -3706,7 +3706,7 @@
ListNode n2 = new(2);
ListNode n3 = new(5);
ListNode n4 = new(4);
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -3721,7 +3721,7 @@
n2 := NewListNode(2)
n3 := NewListNode(5)
n4 := NewListNode(4)
-// 构建引用指向
+// 构建节点之间的引用
n0.Next = n1
n1.Next = n2
n2.Next = n3
@@ -3736,7 +3736,7 @@
let n2 = ListNode(x: 2)
let n3 = ListNode(x: 5)
let n4 = ListNode(x: 4)
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1
n1.next = n2
n2.next = n3
@@ -3751,7 +3751,7 @@
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -3766,7 +3766,7 @@
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -3781,7 +3781,7 @@
ListNode n2 = ListNode(2);
ListNode n3 = ListNode(5);
ListNode n4 = ListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -3797,7 +3797,7 @@
let n3 = Rc::new(RefCell::new(ListNode { val: 5, next: None }));
let n4 = Rc::new(RefCell::new(ListNode { val: 4, next: None }));
-// 构建引用指向
+// 构建节点之间的引用
n0.borrow_mut().next = Some(n1.clone());
n1.borrow_mut().next = Some(n2.clone());
n2.borrow_mut().next = Some(n3.clone());
@@ -3812,7 +3812,7 @@
ListNode* n2 = newListNode(2);
ListNode* n3 = newListNode(5);
ListNode* n4 = newListNode(4);
-// 构建引用指向
+// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
@@ -3827,7 +3827,7 @@
var n2 = inc.ListNode(i32){.val = 2};
var n3 = inc.ListNode(i32){.val = 5};
var n4 = inc.ListNode(i32){.val = 4};
-// 构建引用指向
+// 构建节点之间的引用
n0.next = &n1;
n1.next = &n2;
n2.next = &n3;
@@ -3836,9 +3836,9 @@
-数组整体是一个变量,比如数组 nums 包含元素 nums[0] 和 nums[1] 等,而链表是由多个独立的节点对象组成的。我们通常将头节点当作链表的代称,比如以上代码中的链表可被记做链表 n0 。
+数组整体是一个变量,比如数组 nums 包含元素 nums[0] 和 nums[1] 等,而链表是由多个独立的节点对象组成的。我们通常将头节点当作链表的代称,比如以上代码中的链表可记作链表 n0 。
2. 插入节点¶
-在链表中插入节点非常容易。如图 4-6 所示,假设我们想在相邻的两个节点 n0 和 n1 之间插入一个新节点 P ,则只需要改变两个节点引用(指针)即可,时间复杂度为 \(O(1)\) 。
+在链表中插入节点非常容易。如图 4-6 所示,假设我们想在相邻的两个节点 n0 和 n1 之间插入一个新节点 P ,则只需改变两个节点引用(指针)即可,时间复杂度为 \(O(1)\) 。
相比之下,在数组中插入元素的时间复杂度为 \(O(n)\) ,在大数据量下的效率较低。
图 4-6 链表插入节点示例
@@ -4117,7 +4117,7 @@
4. 访问节点¶
-在链表访问节点的效率较低。如上节所述,我们可以在 \(O(1)\) 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 \(i\) 个节点需要循环 \(i - 1\) 轮,时间复杂度为 \(O(n)\) 。
+在链表中访问节点的效率较低。如上一节所述,我们可以在 \(O(1)\) 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 \(i\) 个节点需要循环 \(i - 1\) 轮,时间复杂度为 \(O(n)\) 。
@@ -4268,7 +4268,7 @@
5. 查找节点¶
-遍历链表,查找链表内值为 target 的节点,输出节点在链表中的索引。此过程也属于线性查找。
+遍历链表,查找其中值为 target 的节点,输出该节点在链表中的索引。此过程也属于线性查找。代码如下所示:
@@ -4442,8 +4442,8 @@
-4.2.2 数组 VS 链表¶
-表 4-1 总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
+4.2.2 数组 vs. 链表¶
+表 4-1 总结了数组和链表的各项特点并对比了操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
表 4-1 数组与链表的效率对比
@@ -4492,8 +4492,8 @@
4.2.3 常见链表类型¶
如图 4-8 所示,常见的链表类型包括三种。
-- 单向链表:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 \(\text{None}\) 。
-- 环形链表:如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
+- 单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 \(\text{None}\) 。
+- 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
- 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
@@ -4673,20 +4673,20 @@
4.2.4 链表典型应用¶
单向链表通常用于实现栈、队列、哈希表和图等数据结构。
-- 栈与队列:当插入和删除操作都在链表的一端进行时,它表现出先进后出的的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
-- 哈希表:链地址法是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
-- 图:邻接表是表示图的一种常用方式,在其中,图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
+- 栈与队列:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
+- 哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
+- 图:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
-双向链表常被用于需要快速查找前一个和下一个元素的场景。
+双向链表常用于需要快速查找前一个和后一个元素的场景。
- 高级数据结构:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
- 浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
-- LRU 算法:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。
+- LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
-循环链表常被用于需要周期性操作的场景,比如操作系统的资源调度。
+环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。
-- 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环的操作就可以通过循环链表来实现。
-- 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用到循环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个循环链表,以便实现无缝播放。
+- 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。
+- 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。
diff --git a/chapter_array_and_linkedlist/list/index.html b/chapter_array_and_linkedlist/list/index.html
index 5cf313fc3..c6f8af252 100644
--- a/chapter_array_and_linkedlist/list/index.html
+++ b/chapter_array_and_linkedlist/list/index.html
@@ -3478,17 +3478,17 @@
4.3 列表¶
-「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无需使用者考虑容量限制的问题。列表可以基于链表或数组实现。
+「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。
-- 链表天然可以被看作是一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
-- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作是一个具有长度限制的列表。
+- 链表天然可以被看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
+- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作一个具有长度限制的列表。
当使用数组实现列表时,长度不可变的性质会导致列表的实用性降低。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间的浪费。
为解决此问题,我们可以使用「动态数组 dynamic array」来实现列表。它继承了数组的各项优点,并且可以在程序运行过程中进行动态扩容。
实际上,许多编程语言中的标准库提供的列表都是基于动态数组实现的,例如 Python 中的 list 、Java 中的 ArrayList 、C++ 中的 vector 和 C# 中的 List 等。在接下来的讨论中,我们将把“列表”和“动态数组”视为等同的概念。
4.3.1 列表常用操作¶
1. 初始化列表¶
-我们通常使用“无初始值”和“有初始值”这两种初始化方法。
+我们通常使用“无初始值”和“有初始值”这两种初始化方法:
@@ -3692,14 +3692,14 @@
list.py# 清空列表
nums.clear()
-# 尾部添加元素
+# 在尾部添加元素
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
-# 中间插入元素
+# 在中间插入元素
nums.insert(3, 6) # 在索引 3 处插入数字 6
# 删除元素
@@ -3710,14 +3710,14 @@
list.cpp/* 清空列表 */
nums.clear();
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.push_back(1);
nums.push_back(3);
nums.push_back(2);
nums.push_back(5);
nums.push_back(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.insert(nums.begin() + 3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3728,14 +3728,14 @@
list.java/* 清空列表 */
nums.clear();
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.add(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3746,14 +3746,14 @@
list.cs/* 清空列表 */
nums.Clear();
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.Add(1);
nums.Add(3);
nums.Add(2);
nums.Add(5);
nums.Add(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.Insert(3, 6);
/* 删除元素 */
@@ -3764,14 +3764,14 @@
list_test.go/* 清空列表 */
nums = nil
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums = append(nums, 1)
nums = append(nums, 3)
nums = append(nums, 2)
nums = append(nums, 5)
nums = append(nums, 4)
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3782,14 +3782,14 @@
list.swift/* 清空列表 */
nums.removeAll()
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.insert(6, at: 3) // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3800,14 +3800,14 @@
list.js/* 清空列表 */
nums.length = 0;
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.splice(3, 0, 6);
/* 删除元素 */
@@ -3818,14 +3818,14 @@
list.ts/* 清空列表 */
nums.length = 0;
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.splice(3, 0, 6);
/* 删除元素 */
@@ -3836,14 +3836,14 @@
list.dart/* 清空列表 */
nums.clear();
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.insert(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3854,14 +3854,14 @@
list.rs/* 清空列表 */
nums.clear();
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
-/* 中间插入元素 */
+/* 在中间插入元素 */
nums.insert(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -3876,14 +3876,14 @@
5. 拼接列表¶
-给定一个新列表 nums1 ,我们可以将该列表拼接到原列表的尾部。
+给定一个新列表 nums1 ,我们可以将其拼接到原列表的尾部。
@@ -4133,7 +4133,7 @@
6. 排序列表¶
-完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。
+完成列表排序后,我们便可以使用在数组类算法题中经常考查的“二分查找”和“双指针”算法。
@@ -4198,28 +4198,28 @@
4.3.2 列表实现¶
-许多编程语言都提供内置的列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
+许多编程语言内置了列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
- 初始容量:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
- 数量记录:声明一个变量
size ,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
-- 扩容机制:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
+- 扩容机制:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
my_list.pyclass MyList:
- """列表类简易实现"""
+ """列表类"""
def __init__(self):
"""构造方法"""
self._capacity: int = 10 # 列表容量
self._arr: list[int] = [0] * self._capacity # 数组(存储列表元素)
- self._size: int = 0 # 列表长度(即当前元素数量)
+ self._size: int = 0 # 列表长度(当前元素数量)
self._extend_ratio: int = 2 # 每次列表扩容的倍数
def size(self) -> int:
- """获取列表长度(即当前元素数量)"""
+ """获取列表长度(当前元素数量)"""
return self._size
def capacity(self) -> int:
@@ -4240,7 +4240,7 @@
self._arr[index] = num
def add(self, num: int):
- """尾部添加元素"""
+ """在尾部添加元素"""
# 元素数量超出容量时,触发扩容机制
if self.size() == self.capacity():
self.extend_capacity()
@@ -4248,7 +4248,7 @@
self._size += 1
def insert(self, num: int, index: int):
- """中间插入元素"""
+ """在中间插入元素"""
if index < 0 or index >= self._size:
raise IndexError("索引越界")
# 元素数量超出容量时,触发扩容机制
@@ -4287,12 +4287,12 @@
-my_list.cpp/* 列表类简易实现 */
+my_list.cpp/* 列表类 */
class MyList {
private:
int *arr; // 数组(存储列表元素)
int arrCapacity = 10; // 列表容量
- int arrSize = 0; // 列表长度(即当前元素数量)
+ int arrSize = 0; // 列表长度(当前元素数量)
int extendRatio = 2; // 每次列表扩容的倍数
public:
@@ -4306,7 +4306,7 @@
delete[] arr;
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
int size() {
return arrSize;
}
@@ -4331,7 +4331,7 @@
arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
void add(int num) {
// 元素数量超出容量时,触发扩容机制
if (size() == capacity())
@@ -4341,7 +4341,7 @@
arrSize++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
void insert(int index, int num) {
if (index < 0 || index >= size())
throw out_of_range("索引越界");
@@ -4400,11 +4400,11 @@
-my_list.java/* 列表类简易实现 */
+my_list.java/* 列表类 */
class MyList {
private int[] arr; // 数组(存储列表元素)
private int capacity = 10; // 列表容量
- private int size = 0; // 列表长度(即当前元素数量)
+ private int size = 0; // 列表长度(当前元素数量)
private int extendRatio = 2; // 每次列表扩容的倍数
/* 构造方法 */
@@ -4412,7 +4412,7 @@
arr = new int[capacity];
}
- /* 获取列表长度(即当前元素数量) */
+ /* 获取列表长度(当前元素数量) */
public int size() {
return size;
}
@@ -4437,7 +4437,7 @@
arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
public void add(int num) {
// 元素数量超出容量时,触发扩容机制
if (size == capacity())
@@ -4447,7 +4447,7 @@
size++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
public void insert(int index, int num) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("索引越界");
@@ -4500,11 +4500,11 @@
-my_list.cs/* 列表类简易实现 */
+my_list.cs/* 列表类 */
class MyList {
private int[] arr; // 数组(存储列表元素)
private int arrCapacity = 10; // 列表容量
- private int arrSize = 0; // 列表长度(即当前元素数量)
+ private int arrSize = 0; // 列表长度(当前元素数量)
private readonly int extendRatio = 2; // 每次列表扩容的倍数
/* 构造方法 */
@@ -4512,7 +4512,7 @@
arr = new int[arrCapacity];
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
public int Size() {
return arrSize;
}
@@ -4537,7 +4537,7 @@
arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
public void Add(int num) {
// 元素数量超出容量时,触发扩容机制
if (arrSize == arrCapacity)
@@ -4547,7 +4547,7 @@
arrSize++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
public void Insert(int index, int num) {
if (index < 0 || index >= arrSize)
throw new IndexOutOfRangeException("索引越界");
@@ -4599,7 +4599,7 @@
-my_list.go/* 列表类简易实现 */
+my_list.go/* 列表类 */
type myList struct {
arrCapacity int
arr []int
@@ -4612,12 +4612,12 @@
return &myList{
arrCapacity: 10, // 列表容量
arr: make([]int, 10), // 数组(存储列表元素)
- arrSize: 0, // 列表长度(即当前元素数量)
+ arrSize: 0, // 列表长度(当前元素数量)
extendRatio: 2, // 每次列表扩容的倍数
}
}
-/* 获取列表长度(即当前元素数量) */
+/* 获取列表长度(当前元素数量) */
func (l *myList) size() int {
return l.arrSize
}
@@ -4644,7 +4644,7 @@
l.arr[index] = num
}
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
func (l *myList) add(num int) {
// 元素数量超出容量时,触发扩容机制
if l.arrSize == l.arrCapacity {
@@ -4655,7 +4655,7 @@
l.arrSize++
}
-/* 中间插入元素 */
+/* 在中间插入元素 */
func (l *myList) insert(num, index int) {
if index < 0 || index >= l.arrSize {
panic("索引越界")
@@ -4705,11 +4705,11 @@
-my_list.swift/* 列表类简易实现 */
+my_list.swift/* 列表类 */
class MyList {
private var arr: [Int] // 数组(存储列表元素)
private var _capacity = 10 // 列表容量
- private var _size = 0 // 列表长度(即当前元素数量)
+ private var _size = 0 // 列表长度(当前元素数量)
private let extendRatio = 2 // 每次列表扩容的倍数
/* 构造方法 */
@@ -4717,7 +4717,7 @@
arr = Array(repeating: 0, count: _capacity)
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
func size() -> Int {
_size
}
@@ -4744,7 +4744,7 @@
arr[index] = num
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
func add(num: Int) {
// 元素数量超出容量时,触发扩容机制
if _size == _capacity {
@@ -4755,7 +4755,7 @@
_size += 1
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
func insert(index: Int, num: Int) {
if index < 0 || index >= _size {
fatalError("索引越界")
@@ -4810,11 +4810,11 @@
-my_list.js/* 列表类简易实现 */
+my_list.js/* 列表类 */
class MyList {
#arr = new Array(); // 数组(存储列表元素)
#capacity = 10; // 列表容量
- #size = 0; // 列表长度(即当前元素数量)
+ #size = 0; // 列表长度(当前元素数量)
#extendRatio = 2; // 每次列表扩容的倍数
/* 构造方法 */
@@ -4822,7 +4822,7 @@
this.#arr = new Array(this.#capacity);
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
size() {
return this.#size;
}
@@ -4845,7 +4845,7 @@
this.#arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
add(num) {
// 如果长度等于容量,则需要扩容
if (this.#size === this.#capacity) {
@@ -4856,7 +4856,7 @@
this.#size++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
insert(index, num) {
if (index < 0 || index >= this.#size) throw new Error('索引越界');
// 元素数量超出容量时,触发扩容机制
@@ -4910,11 +4910,11 @@
-my_list.ts/* 列表类简易实现 */
+my_list.ts/* 列表类 */
class MyList {
private arr: Array<number>; // 数组(存储列表元素)
private _capacity: number = 10; // 列表容量
- private _size: number = 0; // 列表长度(即当前元素数量)
+ private _size: number = 0; // 列表长度(当前元素数量)
private extendRatio: number = 2; // 每次列表扩容的倍数
/* 构造方法 */
@@ -4922,7 +4922,7 @@
this.arr = new Array(this._capacity);
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
public size(): number {
return this._size;
}
@@ -4945,7 +4945,7 @@
this.arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
public add(num: number): void {
// 如果长度等于容量,则需要扩容
if (this._size === this._capacity) this.extendCapacity();
@@ -4954,7 +4954,7 @@
this._size++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
public insert(index: number, num: number): void {
if (index < 0 || index >= this._size) throw new Error('索引越界');
// 元素数量超出容量时,触发扩容机制
@@ -5008,11 +5008,11 @@
-my_list.dart/* 列表类简易实现 */
+my_list.dart/* 列表类 */
class MyList {
late List<int> _arr; // 数组(存储列表元素)
int _capacity = 10; // 列表容量
- int _size = 0; // 列表长度(即当前元素数量)
+ int _size = 0; // 列表长度(当前元素数量)
int _extendRatio = 2; // 每次列表扩容的倍数
/* 构造方法 */
@@ -5020,7 +5020,7 @@
_arr = List.filled(_capacity, 0);
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
int size() => _size;
/* 获取列表容量 */
@@ -5038,7 +5038,7 @@
_arr[index] = _num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
void add(int _num) {
// 元素数量超出容量时,触发扩容机制
if (_size == _capacity) extendCapacity();
@@ -5047,7 +5047,7 @@
_size++;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
void insert(int index, int _num) {
if (index >= _size) throw RangeError('索引越界');
// 元素数量超出容量时,触发扩容机制
@@ -5099,12 +5099,12 @@
-my_list.rs/* 列表类简易实现 */
+my_list.rs/* 列表类 */
#[allow(dead_code)]
struct MyList {
arr: Vec<i32>, // 数组(存储列表元素)
capacity: usize, // 列表容量
- size: usize, // 列表长度(即当前元素数量)
+ size: usize, // 列表长度(当前元素数量)
extend_ratio: usize, // 每次列表扩容的倍数
}
@@ -5122,7 +5122,7 @@
}
}
- /* 获取列表长度(即当前元素数量)*/
+ /* 获取列表长度(当前元素数量)*/
pub fn size(&self) -> usize {
return self.size;
}
@@ -5145,7 +5145,7 @@
self.arr[index] = num;
}
- /* 尾部添加元素 */
+ /* 在尾部添加元素 */
pub fn add(&mut self, num: i32) {
// 元素数量超出容量时,触发扩容机制
if self.size == self.capacity() {
@@ -5156,7 +5156,7 @@
self.size += 1;
}
- /* 中间插入元素 */
+ /* 在中间插入元素 */
pub fn insert(&mut self, index: usize, num: i32) {
if index >= self.size() {panic!("索引越界")};
// 元素数量超出容量时,触发扩容机制
@@ -5208,7 +5208,7 @@
-my_list.c/* 列表类简易实现 */
+my_list.c/* 列表类 */
typedef struct {
int *arr; // 数组(存储列表元素)
int capacity; // 列表容量
@@ -5254,7 +5254,7 @@
nums->arr[index] = num;
}
-/* 尾部添加元素 */
+/* 在尾部添加元素 */
void add(MyList *nums, int num) {
if (size(nums) == capacity(nums)) {
extendCapacity(nums); // 扩容
@@ -5263,7 +5263,7 @@
nums->size++;
}
-/* 中间插入元素 */
+/* 在中间插入元素 */
void insert(MyList *nums, int index, int num) {
assert(index >= 0 && index < size(nums));
// 元素数量超出容量时,触发扩容机制
@@ -5315,14 +5315,14 @@
-my_list.zig// 列表类简易实现
+my_list.zig// 列表类
fn MyList(comptime T: type) type {
return struct {
const Self = @This();
arr: []T = undefined, // 数组(存储列表元素)
arrCapacity: usize = 10, // 列表容量
- numSize: usize = 0, // 列表长度(即当前元素数量)
+ numSize: usize = 0, // 列表长度(当前元素数量)
extendRatio: usize = 2, // 每次列表扩容的倍数
mem_arena: ?std.heap.ArenaAllocator = null,
mem_allocator: std.mem.Allocator = undefined, // 内存分配器
@@ -5343,7 +5343,7 @@
self.mem_arena.?.deinit();
}
- // 获取列表长度(即当前元素数量)
+ // 获取列表长度(当前元素数量)
pub fn size(self: *Self) usize {
return self.numSize;
}
@@ -5367,7 +5367,7 @@
self.arr[index] = num;
}
- // 尾部添加元素
+ // 在尾部添加元素
pub fn add(self: *Self, num: T) !void {
// 元素数量超出容量时,触发扩容机制
if (self.size() == self.capacity()) try self.extendCapacity();
@@ -5376,7 +5376,7 @@
self.numSize += 1;
}
- // 中间插入元素
+ // 在中间插入元素
pub fn insert(self: *Self, index: usize, num: T) !void {
if (index < 0 or index >= self.size()) @panic("索引越界");
// 元素数量超出容量时,触发扩容机制
diff --git a/chapter_array_and_linkedlist/ram_and_cache.assets/computer_storage_devices.png b/chapter_array_and_linkedlist/ram_and_cache.assets/computer_storage_devices.png
index 85005ab95..0f2af92ee 100644
Binary files a/chapter_array_and_linkedlist/ram_and_cache.assets/computer_storage_devices.png and b/chapter_array_and_linkedlist/ram_and_cache.assets/computer_storage_devices.png differ
diff --git a/chapter_array_and_linkedlist/ram_and_cache.assets/storage_pyramid.png b/chapter_array_and_linkedlist/ram_and_cache.assets/storage_pyramid.png
index 0fdd39a76..4a14715f6 100644
Binary files a/chapter_array_and_linkedlist/ram_and_cache.assets/storage_pyramid.png and b/chapter_array_and_linkedlist/ram_and_cache.assets/storage_pyramid.png differ
diff --git a/chapter_array_and_linkedlist/ram_and_cache/index.html b/chapter_array_and_linkedlist/ram_and_cache/index.html
index 827b4785e..5cb73b9db 100644
--- a/chapter_array_and_linkedlist/ram_and_cache/index.html
+++ b/chapter_array_and_linkedlist/ram_and_cache/index.html
@@ -3407,7 +3407,7 @@
在本章的前两节中,我们探讨了数组和链表这两种基础且重要的数据结构,它们分别代表了“连续存储”和“分散存储”这两种不同的物理结构。
实际上,物理结构在很大程度上决定了程序对内存和缓存的使用效率,进而影响算法程序的整体性能。
4.4.1 计算机存储设备¶
-计算机中包括三种不同类型的存储设备:硬盘、内存、缓存。表 4-2 展示了它们在计算机系统中的不同角色和性能特点。
+计算机中包括三种不同类型的存储设备:「硬盘 hard disk」、「内存 random-access memory, RAM」、「缓存 cache memory」。表 4-2 展示了它们在计算机系统中的不同角色和性能特点。
表 4-2 计算机的存储设备
@@ -3415,9 +3415,9 @@
-硬盘 Hard Disk
-内存 RAM
-缓存 Cache
+硬盘
+内存
+缓存
@@ -3467,7 +3467,7 @@
计算机的存储层次结构体现了速度、容量和成本三者之间的精妙平衡。实际上,这种权衡普遍存在于所有工业领域,它要求我们在不同的优势和限制之间找到最佳的平衡点。
总的来说,硬盘用于长期存储大量数据,内存用于临时存储程序运行中正在处理的数据,而缓存则用于存储经常访问的数据和指令,以提高程序运行效率。这三者共同协作,确保计算机系统的高效运行。
-如图 4-10 所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作是 CPU 的一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
+如图 4-10 所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作 CPU 的一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
图 4-10 硬盘、内存和缓存之间的数据流通
diff --git a/chapter_array_and_linkedlist/summary/index.html b/chapter_array_and_linkedlist/summary/index.html
index 940859fb6..dc5d779ee 100644
--- a/chapter_array_and_linkedlist/summary/index.html
+++ b/chapter_array_and_linkedlist/summary/index.html
@@ -3387,7 +3387,7 @@
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。
-- 常见的链表类型包括单向链表、循环链表、双向链表,它们分别具有各自的应用场景。
+- 常见的链表类型包括单向链表、环形链表、双向链表,它们分别具有各自的应用场景。
- 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现,其保留了数组的优势,同时可以灵活调整长度。
- 列表的出现大幅地提高了数组的实用性,但可能导致部分内存空间浪费。
- 程序运行时,数据主要存储在内存中。数组提供更高的内存空间效率,而链表则在内存使用上更加灵活。
@@ -3407,22 +3407,22 @@
为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?
链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、string、object 等。
-相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,如果数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,那么此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
+相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
删除节点后,是否需要把 P.next 设为 \(\text{None}\) 呢?
-不修改 P.next 也可以。从该链表的角度看,从头节点遍历到尾节点已经遇不到 P 了。这意味着节点 P 已经从链表中删除了,此时节点 P 指向哪里都不会对这条链表产生影响了。
-从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收的语言来说,节点 P 是否被回收取决于是否仍存在指向它的引用,而不是 P.next 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
+不修改 P.next 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 P 了。这意味着节点 P 已经从链表中删除了,此时节点 P 指向哪里都不会对该链表产生影响。
+从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收机制的语言来说,节点 P 是否被回收取决于是否仍存在指向它的引用,而不是 P.next 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
-在链表中插入和删除操作的时间复杂度是 \(O(1)\) 。但是增删之前都需要 \(O(n)\) 查找元素,那为什么时间复杂度不是 \(O(n)\) 呢?
-如果是先查找元素、再删除元素,确实是 \(O(n)\) 。然而,链表的 \(O(1)\) 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 \(O(1)\) 。
+在链表中插入和删除操作的时间复杂度是 \(O(1)\) 。但是增删之前都需要 \(O(n)\) 的时间查找元素,那为什么时间复杂度不是 \(O(n)\) 呢?
+如果是先查找元素、再删除元素,时间复杂度确实是 \(O(n)\) 。然而,链表的 \(O(1)\) 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 \(O(1)\) 。
-图片“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?
-文中的示意图只是定性表示,定量表示需要根据具体情况进行分析。
+图“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?
+该示意图只是定性表示,定量表示需要根据具体情况进行分析。
- 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。
- 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。
@@ -3433,20 +3433,20 @@
如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 \(O(n)\) 。
-“列表的出现大大提升了数组的实用性,但副作用是会造成部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?
-这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多。另一方面,为了防止频繁扩容,扩容一般都会乘以一个系数,比如 \(\times 1.5\) 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
+“列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?
+这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多;另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,比如 \(\times 1.5\) 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
-在 Python 中初始化 n = [1, 2, 3] 后,这 3 个元素的地址是相连的,但是初始化 m = [2, 1, 3] 会发现它们每个元素的 id 并不是连续的,而是分别跟 n 中的相同。这些元素地址不连续,那么 m 还是数组吗?
-假如把列表元素换成链表节点 n = [n1, n2, n3, n4, n5] ,通常情况下这五个节点对象也是被分散存储在内存各处的。然而,给定一个列表索引,我们仍然可以在 \(O(1)\) 时间内获取到节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
-与许多语言不同的是,在 Python 中数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址是无须连续的。
+在 Python 中初始化 n = [1, 2, 3] 后,这 3 个元素的地址是相连的,但是初始化 m = [2, 1, 3] 会发现它们每个元素的 id 并不是连续的,而是分别跟 n 中的相同。这些元素的地址不连续,那么 m 还是数组吗?
+假如把列表元素换成链表节点 n = [n1, n2, n3, n4, n5] ,通常情况下这 5 个节点对象也分散存储在内存各处。然而,给定一个列表索引,我们仍然可以在 \(O(1)\) 时间内获取节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
+与许多语言不同,Python 中的数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续。
-C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法的书上都不怎么直接用这个,是不是有什么局限性呢?
-一方面,我们往往更青睐使用数组实现算法,而只有在必要时才使用链表,主要有两个原因。
+C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法书上不怎么直接使用它,是不是因为有什么局限性呢?
+一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。
- 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以
std::list 通常比 std::vector 更占用空间。
-- 缓存不友好:由于数据不是连续存放的,
std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。
+- 缓存不友好:由于数据不是连续存放的,因此
std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。
另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 stack 和 queue ,而非链表。
diff --git a/chapter_backtracking/backtracking_algorithm.assets/preorder_find_constrained_paths.png b/chapter_backtracking/backtracking_algorithm.assets/preorder_find_constrained_paths.png
index 988221345..55401dda7 100644
Binary files a/chapter_backtracking/backtracking_algorithm.assets/preorder_find_constrained_paths.png and b/chapter_backtracking/backtracking_algorithm.assets/preorder_find_constrained_paths.png differ
diff --git a/chapter_backtracking/backtracking_algorithm/index.html b/chapter_backtracking/backtracking_algorithm/index.html
index 6ff5ed1c8..0f73b7dd6 100644
--- a/chapter_backtracking/backtracking_algorithm/index.html
+++ b/chapter_backtracking/backtracking_algorithm/index.html
@@ -2638,7 +2638,7 @@
-
- 13.1.5 优势与局限性
+ 13.1.5 优点与局限性
@@ -3370,7 +3370,7 @@
-
- 13.1.5 优势与局限性
+ 13.1.5 优点与局限性
@@ -3439,12 +3439,12 @@
13.1 回溯算法¶
「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
-回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
+回溯算法通常采用“深度优先搜索”来遍历解空间。在“二叉树”章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
例题一
-给定一个二叉树,搜索并记录所有值为 \(7\) 的节点,请返回节点列表。
+给定一棵二叉树,搜索并记录所有值为 \(7\) 的节点,请返回节点列表。
-对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 \(7\) ,若是则将该节点的值加入到结果列表 res 之中。相关过程实现如图 13-1 和以下代码所示。
+对于此题,我们前序遍历这棵树,并判断当前节点的值是否为 \(7\) ,若是,则将该节点的值加入结果列表 res 之中。相关过程实现如图 13-1 和以下代码所示:
@@ -3628,7 +3628,7 @@
例题二
在二叉树中搜索所有值为 \(7\) 的节点,请返回根节点到这些节点的路径。
-在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 \(7\) 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。
+在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 \(7\) 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。代码如下所示:
@@ -3858,7 +3858,7 @@
在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从 path 中弹出,以恢复本次尝试之前的状态。
-观察图 13-2 所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作是互为逆向的。
+观察图 13-2 所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作互为逆向。
@@ -3904,7 +3904,7 @@
例题三
在二叉树中搜索所有值为 \(7\) 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 \(3\) 的节点。
-为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 \(3\) 的节点,则提前返回,停止继续搜索。
+为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 \(3\) 的节点,则提前返回,不再继续搜索。代码如下所示:
@@ -4143,13 +4143,13 @@
-剪枝是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
+“剪枝”是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
图 13-3 根据约束条件剪枝
13.1.3 框架代码¶
接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
-在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择。
+在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择:
@@ -4159,7 +4159,7 @@
if is_solution(state):
# 记录解
record_solution(state, res)
- # 停止继续搜索
+ # 不再继续搜索
return
# 遍历所有选择
for choice in choices:
@@ -4179,7 +4179,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4203,7 +4203,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4227,7 +4227,7 @@
if (IsSolution(state)) {
// 记录解
RecordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4251,7 +4251,7 @@
if isSolution(state) {
// 记录解
recordSolution(state, res)
- // 停止继续搜索
+ // 不再继续搜索
return
}
// 遍历所有选择
@@ -4275,7 +4275,7 @@
if isSolution(state: state) {
// 记录解
recordSolution(state: state, res: &res)
- // 停止继续搜索
+ // 不再继续搜索
return
}
// 遍历所有选择
@@ -4299,7 +4299,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4323,7 +4323,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4347,7 +4347,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4371,7 +4371,7 @@
if is_solution(state) {
// 记录解
record_solution(state, res);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4395,7 +4395,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res, numRes);
- // 停止继续搜索
+ // 不再继续搜索
return;
}
// 遍历所有选择
@@ -4418,7 +4418,7 @@
-接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表。
+接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表:
@@ -4977,9 +4977,9 @@
图 13-4 保留与删除 return 的搜索过程对比
-相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,许多回溯问题都可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。
+相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰唆,但通用性更好。实际上,许多回溯问题可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。
13.1.4 常用术语¶
-为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。
+为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例,如表 13-1 所示。
表 13-1 常见的回溯算法术语
@@ -4993,34 +4993,34 @@
-解 Solution
+解(solution)
解是满足问题特定条件的答案,可能有一个或多个
根节点到节点 \(7\) 的满足约束条件的所有路径
-约束条件 Constraint
+约束条件(constraint)
约束条件是问题中限制解的可行性的条件,通常用于剪枝
路径中不包含节点 \(3\)
-状态 State
+状态(state)
状态表示问题在某一时刻的情况,包括已经做出的选择
当前已访问的节点路径,即 path 节点列表
-尝试 Attempt
+尝试(attempt)
尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解
递归访问左(右)子节点,将节点添加进 path ,判断节点的值是否为 \(7\)
-回退 Backtracking
+回退(backtracking)
回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态
当越过叶节点、结束节点访问、遇到值为 \(3\) 的节点时终止搜索,函数返回
-剪枝 Pruning
+剪枝(pruning)
剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可提高搜索效率
-当遇到值为 \(3\) 的节点时,则终止继续搜索
+当遇到值为 \(3\) 的节点时,则不再继续搜索
@@ -5029,14 +5029,14 @@
Tip
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。
-13.1.5 优势与局限性¶
-回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
+13.1.5 优点与局限性¶
+回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
-即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何进行效率优化,常见的效率优化方法有两种。
+即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种。
- 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
@@ -5047,7 +5047,7 @@
- 全排列问题:给定一个集合,求出其所有可能的排列组合。
- 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
-- 汉诺塔问题:给定三个柱子和一系列大小不同的圆盘,要求将所有圆盘从一个柱子移动到另一个柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
+- 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
@@ -5061,11 +5061,11 @@
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
- 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
-请注意,对于许多组合优化问题,回溯都不是最优解决方案。
+请注意,对于许多组合优化问题,回溯不是最优解决方案。
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
-- 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决。
+- 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。
diff --git a/chapter_backtracking/n_queens_problem/index.html b/chapter_backtracking/n_queens_problem/index.html
index ab6ca8932..a70a9179e 100644
--- a/chapter_backtracking/n_queens_problem/index.html
+++ b/chapter_backtracking/n_queens_problem/index.html
@@ -3398,7 +3398,7 @@
13.4 N 皇后问题¶
Question
-根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 \(n\) 个皇后和一个 \(n \times n\) 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
+根据国际象棋的规则,皇后可以攻击与同处一行、一列或一条斜线上的棋子。给定 \(n\) 个皇后和一个 \(n \times n\) 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如图 13-15 所示,当 \(n = 4\) 时,共可以找到两个解。从回溯算法的角度看,\(n \times n\) 大小的棋盘共有 \(n^2\) 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state 。
@@ -3411,15 +3411,15 @@
1. 逐行放置策略¶
皇后的数量和棋盘的行数都为 \(n\) ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
-如图 13-17 所示,为 \(4\) 皇后问题的逐行放置过程。受画幅限制,图 13-17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
+图 13-17 所示为 \(4\) 皇后问题的逐行放置过程。受画幅限制,图 13-17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
图 13-17 逐行放置策略
-本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
+从本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
2. 列与对角线剪枝¶
为了满足列约束,我们可以利用一个长度为 \(n\) 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 \((row, col)\) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 \(row - col\) 为恒定值。
-也就是说,如果两个格子满足 \(row_1 - col_1 = row_2 - col_2\) ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 diags1 ,记录每条主对角线上是否有皇后。
+也就是说,如果两个格子满足 \(row_1 - col_1 = row_2 - col_2\) ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 diags1 记录每条主对角线上是否有皇后。
同理,次对角线上的所有格子的 \(row + col\) 是恒定值。我们同样也可以借助数组 diags2 来处理次对角线约束。
图 13-18 处理列约束和对角线约束
@@ -3448,7 +3448,7 @@
# 计算该格子对应的主对角线和副对角线
diag1 = row - col + n - 1
diag2 = row + col
- # 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ # 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if not cols[col] and not diags1[diag1] and not diags2[diag2]:
# 尝试:将皇后放置在该格子
state[row][col] = "Q"
@@ -3464,8 +3464,8 @@
# 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
state = [["#" for _ in range(n)] for _ in range(n)]
cols = [False] * n # 记录列是否有皇后
- diags1 = [False] * (2 * n - 1) # 记录主对角线是否有皇后
- diags2 = [False] * (2 * n - 1) # 记录副对角线是否有皇后
+ diags1 = [False] * (2 * n - 1) # 记录主对角线上是否有皇后
+ diags2 = [False] * (2 * n - 1) # 记录副对角线上是否有皇后
res = []
backtrack(0, n, state, res, cols, diags1, diags2)
@@ -3486,7 +3486,7 @@
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
@@ -3505,8 +3505,8 @@
// 初始化 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<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);
@@ -3533,7 +3533,7 @@
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state.get(row).set(col, "Q");
@@ -3559,8 +3559,8 @@
state.add(row);
}
boolean[] cols = new boolean[n]; // 记录列是否有皇后
- boolean[] diags1 = new boolean[2 * n - 1]; // 记录主对角线是否有皇后
- boolean[] diags2 = new boolean[2 * n - 1]; // 记录副对角线是否有皇后
+ boolean[] diags1 = new boolean[2 * n - 1]; // 记录主对角线上是否有皇后
+ boolean[] diags2 = new boolean[2 * n - 1]; // 记录副对角线上是否有皇后
List<List<List<String>>> res = new ArrayList<>();
backtrack(0, n, state, res, cols, diags1, diags2);
@@ -3587,7 +3587,7 @@
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
@@ -3613,8 +3613,8 @@
state.Add(row);
}
bool[] cols = new bool[n]; // 记录列是否有皇后
- bool[] diags1 = new bool[2 * n - 1]; // 记录主对角线是否有皇后
- bool[] diags2 = new bool[2 * n - 1]; // 记录副对角线是否有皇后
+ bool[] diags1 = new bool[2 * n - 1]; // 记录主对角线上是否有皇后
+ bool[] diags2 = new bool[2 * n - 1]; // 记录副对角线上是否有皇后
List<List<List<string>>> res = [];
Backtrack(0, n, state, res, cols, diags1, diags2);
@@ -3641,7 +3641,7 @@
// 计算该格子对应的主对角线和副对角线
diag1 := row - col + n - 1
diag2 := row + col
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if !(*cols)[col] && !(*diags1)[diag1] && !(*diags2)[diag2] {
// 尝试:将皇后放置在该格子
(*state)[row][col] = "Q"
@@ -3672,7 +3672,7 @@
// 计算该格子对应的主对角线和副对角线
diag1 := row - col + n - 1
diag2 := row + col
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if !(*cols)[col] && !(*diags1)[diag1] && !(*diags2)[diag2] {
// 尝试:将皇后放置在该格子
(*state)[row][col] = "Q"
@@ -3719,7 +3719,7 @@
// 计算该格子对应的主对角线和副对角线
let diag1 = row - col + n - 1
let diag2 = row + col
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if !cols[col] && !diags1[diag1] && !diags2[diag2] {
// 尝试:将皇后放置在该格子
state[row][col] = "Q"
@@ -3742,8 +3742,8 @@
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
var state = Array(repeating: Array(repeating: "#", count: n), count: n)
var cols = Array(repeating: false, count: n) // 记录列是否有皇后
- var diags1 = Array(repeating: false, count: 2 * n - 1) // 记录主对角线是否有皇后
- var diags2 = Array(repeating: false, count: 2 * n - 1) // 记录副对角线是否有皇后
+ var diags1 = Array(repeating: false, count: 2 * n - 1) // 记录主对角线上是否有皇后
+ var diags2 = Array(repeating: false, count: 2 * n - 1) // 记录副对角线上是否有皇后
var res: [[[String]]] = []
backtrack(row: 0, n: n, state: &state, res: &res, cols: &cols, diags1: &diags1, diags2: &diags2)
@@ -3765,7 +3765,7 @@
// 计算该格子对应的主对角线和副对角线
const diag1 = row - col + n - 1;
const diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = 'Q';
@@ -3784,8 +3784,8 @@
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
const state = Array.from({ length: n }, () => Array(n).fill('#'));
const cols = Array(n).fill(false); // 记录列是否有皇后
- const diags1 = Array(2 * n - 1).fill(false); // 记录主对角线是否有皇后
- const diags2 = Array(2 * n - 1).fill(false); // 记录副对角线是否有皇后
+ const diags1 = Array(2 * n - 1).fill(false); // 记录主对角线上是否有皇后
+ const diags2 = Array(2 * n - 1).fill(false); // 记录副对角线上是否有皇后
const res = [];
backtrack(0, n, state, res, cols, diags1, diags2);
@@ -3814,7 +3814,7 @@
// 计算该格子对应的主对角线和副对角线
const diag1 = row - col + n - 1;
const diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = 'Q';
@@ -3833,8 +3833,8 @@
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
const state = Array.from({ length: n }, () => Array(n).fill('#'));
const cols = Array(n).fill(false); // 记录列是否有皇后
- const diags1 = Array(2 * n - 1).fill(false); // 记录主对角线是否有皇后
- const diags2 = Array(2 * n - 1).fill(false); // 记录副对角线是否有皇后
+ const diags1 = Array(2 * n - 1).fill(false); // 记录主对角线上是否有皇后
+ const diags2 = Array(2 * n - 1).fill(false); // 记录副对角线上是否有皇后
const res: string[][][] = [];
backtrack(0, n, state, res, cols, diags1, diags2);
@@ -3867,7 +3867,7 @@
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
@@ -3890,8 +3890,8 @@
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
List<List<String>> state = List.generate(n, (index) => List.filled(n, "#"));
List<bool> cols = List.filled(n, false); // 记录列是否有皇后
- List<bool> diags1 = List.filled(2 * n - 1, false); // 记录主对角线是否有皇后
- List<bool> diags2 = List.filled(2 * n - 1, false); // 记录副对角线是否有皇后
+ List<bool> diags1 = List.filled(2 * n - 1, false); // 记录主对角线上是否有皇后
+ List<bool> diags2 = List.filled(2 * n - 1, false); // 记录副对角线上是否有皇后
List<List<List<String>>> res = [];
backtrack(0, n, state, res, cols, diags1, diags2);
@@ -3918,7 +3918,7 @@
// 计算该格子对应的主对角线和副对角线
let diag1 = row + n - 1 - col;
let diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if !cols[col] && !diags1[diag1] && !diags2[diag2] {
// 尝试:将皇后放置在该格子
state.get_mut(row).unwrap()[col] = "Q".into();
@@ -3944,8 +3944,8 @@
state.push(row);
}
let mut cols = vec![false; n]; // 记录列是否有皇后
- let mut diags1 = vec![false; 2 * n - 1]; // 记录主对角线是否有皇后
- let mut diags2 = vec![false; 2 * n - 1]; // 记录副对角线是否有皇后
+ let mut diags1 = vec![false; 2 * n - 1]; // 记录主对角线上是否有皇后
+ let mut diags2 = vec![false; 2 * n - 1]; // 记录副对角线上是否有皇后
let mut res: Vec<Vec<Vec<String>>> = Vec::new();
backtrack(0, n, &mut state, &mut res, &mut cols, &mut diags1, &mut diags2);
@@ -3973,7 +3973,7 @@
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
- // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
+ // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = 'Q';
@@ -3998,8 +3998,8 @@
state[i][n] = '\0';
}
bool cols[MAX_SIZE] = {false}; // 记录列是否有皇后
- bool diags1[2 * MAX_SIZE - 1] = {false}; // 记录主对角线是否有皇后
- bool diags2[2 * MAX_SIZE - 1] = {false}; // 记录副对角线是否有皇后
+ bool diags1[2 * MAX_SIZE - 1] = {false}; // 记录主对角线上是否有皇后
+ bool diags2[2 * MAX_SIZE - 1] = {false}; // 记录副对角线上是否有皇后
char ***res = (char ***)malloc(sizeof(char **) * MAX_SIZE);
*returnSize = 0;
@@ -4016,7 +4016,7 @@
-逐行放置 \(n\) 次,考虑列约束,则从第一行到最后一行分别有 \(n\)、\(n-1\)、\(\dots\)、\(2\)、\(1\) 个选择,因此时间复杂度为 \(O(n!)\) 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
+逐行放置 \(n\) 次,考虑列约束,则从第一行到最后一行分别有 \(n\)、\(n-1\)、\(\dots\)、\(2\)、\(1\) 个选择,因此时间复杂度为 \(O(n!)\) 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
数组 state 使用 \(O(n^2)\) 空间,数组 cols、diags1 和 diags2 皆使用 \(O(n)\) 空间。最大递归深度为 \(n\) ,使用 \(O(n)\) 栈帧空间。因此,空间复杂度为 \(O(n^2)\) 。
diff --git a/chapter_backtracking/permutations_problem/index.html b/chapter_backtracking/permutations_problem/index.html
index 88c022c19..f536fbe26 100644
--- a/chapter_backtracking/permutations_problem/index.html
+++ b/chapter_backtracking/permutations_problem/index.html
@@ -3476,7 +3476,7 @@
13.2 全排列问题¶
-全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。
+全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出其中元素的所有可能的排列。
表 13-2 列举了几个示例数据,包括输入数组和对应的所有排列。
表 13-2 全排列示例
@@ -3507,11 +3507,11 @@
13.2.1 无相等元素的情况¶
Question
-输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。
+输入一个整数数组,其中不包含重复元素,返回所有可能的排列。
-从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 \([1, 2, 3]\) ,如果我们先选择 \(1\)、再选择 \(3\)、最后选择 \(2\) ,则获得排列 \([1, 3, 2]\) 。回退表示撤销一个选择,之后继续尝试其他选择。
+从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 \([1, 2, 3]\) ,如果我们先选择 \(1\) ,再选择 \(3\) ,最后选择 \(2\) ,则获得排列 \([1, 3, 2]\) 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。
-如图 13-5 所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
+如图 13-5 所示,我们可以将搜索过程展开成一棵递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
图 13-5 全排列的递归树
@@ -3519,15 +3519,15 @@
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i] 是否已被选择,并基于它实现以下剪枝操作。
- 在做出选择
choice[i] 后,我们就将 selected[i] 赋值为 \(\text{True}\) ,代表它已被选择。
-- 遍历选择列表
choices 时,跳过所有已被选择过的节点,即剪枝。
+- 遍历选择列表
choices 时,跳过所有已被选择的节点,即剪枝。
如图 13-6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。
图 13-6 全排列剪枝示例
-观察图 13-6 发现,该剪枝操作将搜索空间大小从 \(O(n^n)\) 降低至 \(O(n!)\) 。
+观察图 13-6 发现,该剪枝操作将搜索空间大小从 \(O(n^n)\) 减小至 \(O(n!)\) 。
2. 代码实现¶
-想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将他们展开在 backtrack() 函数中。
+想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短整体代码,我们不单独实现框架代码中的各个函数,而是将它们展开在 backtrack() 函数中:
@@ -3930,20 +3930,20 @@
输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。
假设输入数组为 \([1, 1, 2]\) 。为了方便区分两个重复元素 \(1\) ,我们将第二个 \(1\) 记为 \(\hat{1}\) 。
-如图 13-7 所示,上述方法生成的排列有一半都是重复的。
+如图 13-7 所示,上述方法生成的排列有一半是重复的。
图 13-7 重复排列
-那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝,这样可以进一步提升算法效率。
+那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝,这样可以进一步提升算法效率。
1. 相等元素剪枝¶
-观察图 13-8 ,在第一轮中,选择 \(1\) 或选择 \(\hat{1}\) 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 \(\hat{1}\) 剪枝掉。
+观察图 13-8 ,在第一轮中,选择 \(1\) 或选择 \(\hat{1}\) 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 \(\hat{1}\) 剪枝。
同理,在第一轮选择 \(2\) 之后,第二轮选择中的 \(1\) 和 \(\hat{1}\) 也会产生重复分支,因此也应将第二轮的 \(\hat{1}\) 剪枝。
-本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。
+从本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。
图 13-8 重复排列剪枝
2. 代码实现¶
-在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。
+在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝:
@@ -4366,10 +4366,10 @@
假设元素两两之间互不相同,则 \(n\) 个元素共有 \(n!\) 种排列(阶乘);在记录结果时,需要复制长度为 \(n\) 的列表,使用 \(O(n)\) 时间。因此时间复杂度为 \(O(n!n)\) 。
最大递归深度为 \(n\) ,使用 \(O(n)\) 栈帧空间。selected 使用 \(O(n)\) 空间。同一时刻最多共有 \(n\) 个 duplicated ,使用 \(O(n^2)\) 空间。因此空间复杂度为 \(O(n^2)\) 。
3. 两种剪枝对比¶
-请注意,虽然 selected 和 duplicated 都用作剪枝,但两者的目标是不同的。
+请注意,虽然 selected 和 duplicated 都用于剪枝,但两者的目标不同。
-- 重复选择剪枝:整个搜索过程中只有一个
selected 。它记录的是当前状态中包含哪些元素,作用是防止 choices 中的任一元素在 state 中重复出现。
-- 相等元素剪枝:每轮选择(即每个调用的
backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(即 for 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。
+- 重复选择剪枝:整个搜索过程中只有一个
selected 。它记录的是当前状态中包含哪些元素,其作用是防止 choices 中的任一元素在 state 中重复出现。
+- 相等元素剪枝:每轮选择(每个调用的
backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(for 循环)中哪些元素已被选择过,其作用是保证相等的元素只被选择一次。
图 13-9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
diff --git a/chapter_backtracking/subset_sum_problem/index.html b/chapter_backtracking/subset_sum_problem/index.html
index c2d155cf3..8d2d2d255 100644
--- a/chapter_backtracking/subset_sum_problem/index.html
+++ b/chapter_backtracking/subset_sum_problem/index.html
@@ -3484,11 +3484,11 @@
例如,输入集合 \(\{3, 4, 5\}\) 和目标整数 \(9\) ,解为 \(\{3, 3, 3\}, \{4, 5\}\) 。需要注意以下两点。
- 输入集合中的元素可以被无限次重复选取。
-- 子集是不区分元素顺序的,比如 \(\{4, 5\}\) 和 \(\{5, 4\}\) 是同一个子集。
+- 子集不区分元素顺序,比如 \(\{4, 5\}\) 和 \(\{5, 4\}\) 是同一个子集。
1. 参考全排列解法¶
类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 target 时,就将子集记录至结果列表。
-而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码。
+而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码:
@@ -3877,7 +3877,7 @@
向以上代码输入数组 \([3, 4, 5]\) 和目标元素 \(9\) ,输出结果为 \([3, 3, 3], [4, 5], [5, 4]\) 。虽然成功找出了所有和为 \(9\) 的子集,但其中存在重复的子集 \([4, 5]\) 和 \([5, 4]\) 。
-这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13-10 所示,先选 \(4\) 后选 \(5\) 与先选 \(5\) 后选 \(4\) 是两个不同的分支,但两者对应同一个子集。
+这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13-10 所示,先选 \(4\) 后选 \(5\) 与先选 \(5\) 后选 \(4\) 是不同的分支,但对应同一个子集。
图 13-10 子集搜索与越界剪枝
@@ -3890,23 +3890,23 @@
我们考虑在搜索过程中通过剪枝进行去重。观察图 13-11 ,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。
- 当第一轮和第二轮分别选择 \(3\) 和 \(4\) 时,会生成包含这两个元素的所有子集,记为 \([3, 4, \dots]\) 。
-- 之后,当第一轮选择 \(4\) 时,则第二轮应该跳过 \(3\) ,因为该选择产生的子集 \([4, 3, \dots]\) 和
1. 中生成的子集完全重复。
+- 之后,当第一轮选择 \(4\) 时,则第二轮应该跳过 \(3\) ,因为该选择产生的子集 \([4, 3, \dots]\) 和第
1. 步中生成的子集完全重复。
-在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
+在搜索过程中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
- 前两轮选择 \(3\) 和 \(5\) ,生成子集 \([3, 5, \dots]\) 。
- 前两轮选择 \(4\) 和 \(5\) ,生成子集 \([4, 5, \dots]\) 。
-- 若第一轮选择 \(5\) ,则第二轮应该跳过 \(3\) 和 \(4\) ,因为子集 \([5, 3, \dots]\) 和 \([5, 4, \dots]\) 与第
1. 和 2. 步中描述的子集完全重复。
+- 若第一轮选择 \(5\) ,则第二轮应该跳过 \(3\) 和 \(4\) ,因为子集 \([5, 3, \dots]\) 和 \([5, 4, \dots]\) 与第
1. 步和第 2. 步中描述的子集完全重复。
图 13-11 不同选择顺序导致的重复子集
总结来看,给定输入数组 \([x_1, x_2, \dots, x_n]\) ,设搜索过程中的选择序列为 \([x_{i_1}, x_{i_2}, \dots, x_{i_m}]\) ,则该选择序列需要满足 \(i_1 \leq i_2 \leq \dots \leq i_m\) ,不满足该条件的选择序列都会造成重复,应当剪枝。
3. 代码实现¶
-为实现该剪枝,我们初始化变量 start ,用于指示遍历起点。当做出选择 \(x_{i}\) 后,设定下一轮从索引 \(i\) 开始遍历。这样做就可以让选择序列满足 \(i_1 \leq i_2 \leq \dots \leq i_m\) ,从而保证子集唯一。
+为实现该剪枝,我们初始化变量 start ,用于指示遍历起始点。当做出选择 \(x_{i}\) 后,设定下一轮从索引 \(i\) 开始遍历。这样做就可以让选择序列满足 \(i_1 \leq i_2 \leq \dots \leq i_m\) ,从而保证子集唯一。
除此之外,我们还对代码进行了以下两项优化。
-- 在开启搜索前,先将数组
nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为后边的元素更大,其子集和都一定会超过 target 。
+- 在开启搜索前,先将数组
nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为后边的元素更大,其子集和一定超过 target 。
- 省去元素和变量
total ,通过在 target 上执行减法来统计元素和,当 target 等于 \(0\) 时记录解。
@@ -4326,7 +4326,7 @@
-如图 13-12 所示,为将数组 \([3, 4, 5]\) 和目标元素 \(9\) 输入到以上代码后的整体回溯过程。
+图 13-12 所示为将数组 \([3, 4, 5]\) 和目标元素 \(9\) 输入以上代码后的整体回溯过程。
图 13-12 子集和 I 回溯过程
@@ -4341,8 +4341,8 @@
图 13-13 相等元素导致的重复子集
1. 相等元素剪枝¶
-为解决此问题,我们需要限制相等元素在每一轮中只被选择一次。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
-与此同时,本题规定数组中的每个元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当做出选择 \(x_{i}\) 后,设定下一轮从索引 \(i + 1\) 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。
+为解决此问题,我们需要限制相等元素在每一轮中只能被选择一次。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
+与此同时,本题规定每个数组元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当做出选择 \(x_{i}\) 后,设定下一轮从索引 \(i + 1\) 开始向后遍历。这样既能去除重复子集,也能避免重复选择元素。
2. 代码实现¶
diff --git a/chapter_backtracking/summary/index.html b/chapter_backtracking/summary/index.html
index d8a430c86..5ac1ad3fa 100644
--- a/chapter_backtracking/summary/index.html
+++ b/chapter_backtracking/summary/index.html
@@ -3387,13 +3387,13 @@
- 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满足条件的解则记录,直至找到所有解或遍历完成后结束。
- 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两个方向相反的操作。
- 回溯问题通常包含多个约束条件,它们可用于实现剪枝操作。剪枝可以提前结束不必要的搜索分支,大幅提升搜索效率。
-- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在更高效率或更好效果的解法。
-- 全排列问题旨在搜索给定集合的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪枝掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。
+- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在效率更高或效果更好的解法。
+- 全排列问题旨在搜索给定集合元素的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。
- 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每轮中只能被选择一次,这通常借助一个哈希表来实现。
-- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起点,从而将生成重复子集的搜索分支进行剪枝。
+- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起始点,从而将生成重复子集的搜索分支进行剪枝。
- 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。
-- \(n\) 皇后旨在寻找将 \(n\) 个皇后放置到 \(n \times n\) 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
-- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
+- \(n\) 皇后问题旨在寻找将 \(n\) 个皇后放置到 \(n \times n\) 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
+- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线上是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
2. Q & A¶
diff --git a/chapter_computational_complexity/iteration_and_recursion/index.html b/chapter_computational_complexity/iteration_and_recursion/index.html
index d46f0cf67..a4806194f 100644
--- a/chapter_computational_complexity/iteration_and_recursion/index.html
+++ b/chapter_computational_complexity/iteration_and_recursion/index.html
@@ -3508,8 +3508,8 @@
2.2.1 迭代¶
「迭代 iteration」是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
1. for 循环¶
-for 循环是最常见的迭代形式之一,适合预先知道迭代次数时使用。
-以下函数基于 for 循环实现了求和 \(1 + 2 + \dots + n\) ,求和结果使用变量 res 记录。需要注意的是,Python 中 range(a, b) 对应的区间是“左闭右开”的,对应的遍历范围为 \(a, a + 1, \dots, b-1\) 。
+for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。
+以下函数基于 for 循环实现了求和 \(1 + 2 + \dots + n\) ,求和结果使用变量 res 记录。需要注意的是,Python 中 range(a, b) 对应的区间是“左闭右开”的,对应的遍历范围为 \(a, a + 1, \dots, b-1\) :
@@ -3656,14 +3656,14 @@
-图 2-1 展示了该求和函数的流程框图。
+图 2-1 是该求和函数的流程框图。
图 2-1 求和函数的流程框图
此求和函数的操作数量与输入数据大小 \(n\) 成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。相关内容将会在下一节中详细介绍。
2. while 循环¶
-与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。
-下面,我们用 while 循环来实现求和 \(1 + 2 + \dots + n\) 。
+与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。
+下面我们用 while 循环来实现求和 \(1 + 2 + \dots + n\) :
@@ -3836,8 +3836,8 @@
-while 循环比 for 循环的自由度更高。在 while 循环中,我们可以自由设计条件变量的初始化和更新步骤。
-例如在以下代码中,条件变量 \(i\) 每轮进行了两次更新,这种情况就不太方便用 for 循环实现。
+while 循环比 for 循环的自由度更高。在 while 循环中,我们可以自由地设计条件变量的初始化和更新步骤。
+例如在以下代码中,条件变量 \(i\) 每轮进行两次更新,这种情况就不太方便用 for 循环实现:
@@ -3845,7 +3845,7 @@
"""while 循环(两次更新)"""
res = 0
i = 1 # 初始化条件变量
- # 循环求和 1, 4, ...
+ # 循环求和 1, 4, 10, ...
while i <= n:
res += i
# 更新条件变量
@@ -3859,7 +3859,7 @@
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -3875,7 +3875,7 @@
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -3908,7 +3908,7 @@
res := 0
// 初始化条件变量
i := 1
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
for i <= n {
res += i
// 更新条件变量
@@ -3924,7 +3924,7 @@
func whileLoopII(n: Int) -> Int {
var res = 0
var i = 1 // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while i <= n {
res += i
// 更新条件变量
@@ -3940,7 +3940,7 @@
function whileLoopII(n) {
let res = 0;
let i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -3956,7 +3956,7 @@
function whileLoopII(n: number): number {
let res = 0;
let i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -3972,7 +3972,7 @@
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -3988,7 +3988,7 @@
fn while_loop_ii(n: i32) -> i32 {
let mut res = 0;
let mut i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while i <= n {
res += i;
// 更新条件变量
@@ -4004,7 +4004,7 @@
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
@@ -4020,7 +4020,7 @@
fn whileLoopII(n: i32) i32 {
var res: i32 = 0;
var i: i32 = 1; // 初始化条件变量
- // 循环求和 1, 4, ...
+ // 循环求和 1, 4, 10, ...
while (i <= n) {
res += @intCast(i);
// 更新条件变量
@@ -4035,7 +4035,7 @@
总的来说,for 循环的代码更加紧凑,while 循环更加灵活,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。
3. 嵌套循环¶
-我们可以在一个循环结构内嵌套另一个循环结构,以 for 循环为例:
+我们可以在一个循环结构内嵌套另一个循环结构,下面以 for 循环为例:
@@ -4224,12 +4224,12 @@
-图 2-2 给出了该嵌套循环的流程框图。
+图 2-2 是该嵌套循环的流程框图。
图 2-2 嵌套循环的流程框图
在这种情况下,函数的操作数量与 \(n^2\) 成正比,或者说算法运行时间和输入数据大小 \(n\) 成“平方关系”。
-我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方关系”、以此类推。
+我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”,以此类推。
2.2.2 递归¶
「递归 recursion」是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
@@ -4412,7 +4412,7 @@
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
-以上述的求和函数为例,设问题 \(f(n) = 1 + 2 + \dots + n\) 。
+以上述求和函数为例,设问题 \(f(n) = 1 + 2 + \dots + n\) 。
- 迭代:在循环中模拟求和过程,从 \(1\) 遍历到 \(n\) ,每轮执行求和操作,即可求得 \(f(n)\) 。
- 递归:将问题分解为子问题 \(f(n) = n + f(n-1)\) ,不断(递归地)分解下去,直至基本情况 \(f(1) = 1\) 时终止。
@@ -4427,14 +4427,14 @@
图 2-4 递归调用深度
-在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。
+在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
2. 尾递归¶
有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
-- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。
+- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
-以计算 \(1 + 2 + \dots + n\) 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归。
+以计算 \(1 + 2 + \dots + n\) 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归:
@@ -4571,7 +4571,7 @@
-尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,求和操作的执行点是不同的。
+尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。
- 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
@@ -4581,7 +4581,7 @@
Tip
-请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,但仍然可能会遇到栈溢出问题。
+请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。
3. 递归树¶
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
@@ -4594,7 +4594,7 @@
- 数列的前两个数字为 \(f(1) = 0\) 和 \(f(2) = 1\) 。
- 数列中的每个数字是前两个数字的和,即 \(f(n) = f(n - 1) + f(n - 2)\) 。
-按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第 \(n\) 个数字。
+按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第 \(n\) 个数字:
@@ -4755,13 +4755,13 @@
-观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一个层数为 \(n\) 的「递归树 recursion tree」。
+观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 \(n\) 的「递归树 recursion tree」。
图 2-6 斐波那契数列的递归树
-本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略是至关重要的。
+从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
-- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略都直接或间接地应用这种思维方式。
+- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。
2.2.3 两者对比¶
diff --git a/chapter_computational_complexity/performance_evaluation/index.html b/chapter_computational_complexity/performance_evaluation/index.html
index 3c4f09d3a..65f605902 100644
--- a/chapter_computational_complexity/performance_evaluation/index.html
+++ b/chapter_computational_complexity/performance_evaluation/index.html
@@ -3384,7 +3384,7 @@
2.1 算法效率评估¶
在算法设计中,我们先后追求以下两个层面的目标。
-- 找到问题解法:算法需要在规定的输入范围内,可靠地求得问题的正确解。
+- 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。
@@ -3392,15 +3392,15 @@
- 时间效率:算法运行速度的快慢。
- 空间效率:算法占用内存空间的大小。
-简而言之,我们的目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,从而指导算法设计与优化过程。
+简而言之,我们的目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,进而指导算法设计与优化过程。
效率评估方法主要分为两种:实际测试、理论估算。
2.1.1 实际测试¶
-假设我们现在有算法 A 和算法 B ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大局限性。
-一方面,难以排除测试环境的干扰因素。硬件配置会影响算法的性能表现。比如在某台计算机中,算法 A 的运行时间比算法 B 短;但在另一台配置不同的计算机中,我们可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。
-另一方面,展开完整测试非常耗费资源。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 A 的运行时间比算法 B 更少;而输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。
+假设我们现在有算法 A 和算法 B ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大的局限性。
+一方面,难以排除测试环境的干扰因素。硬件配置会影响算法的性能。比如在某台计算机中,算法 A 的运行时间比算法 B 短;但在另一台配置不同的计算机中,可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。
+另一方面,展开完整测试非常耗费资源。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 A 的运行时间比算法 B 短;而在输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。
2.1.2 理论估算¶
-由于实际测试具有较大的局限性,我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」,简称「复杂度分析」。
-复杂度分析体现算法运行所需的时间(空间)资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。这个定义有些拗口,我们可以将其分为三个重点来理解。
+由于实际测试具有较大的局限性,因此我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」,简称「复杂度分析」。
+复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。这个定义有些拗口,我们可以将其分为三个重点来理解。
- “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。
- “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
diff --git a/chapter_computational_complexity/space_complexity/index.html b/chapter_computational_complexity/space_complexity/index.html
index 4bbed0f27..c56d1d3a7 100644
--- a/chapter_computational_complexity/space_complexity/index.html
+++ b/chapter_computational_complexity/space_complexity/index.html
@@ -3507,10 +3507,11 @@
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
-在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分。
+在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分,如图 2-15 所示。
图 2-15 算法使用的相关空间
+相关代码如下:
@@ -3787,8 +3788,8 @@
而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
-- 以最差输入数据为准:当 \(n < 10\) 时,空间复杂度为 \(O(1)\) ;但当 \(n > 10\) 时,初始化的数组
nums 占用 \(O(n)\) 空间;因此最差空间复杂度为 \(O(n)\) 。
-- 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 \(O(1)\) 空间;当初始化数组
nums 时,程序占用 \(O(n)\) 空间;因此最差空间复杂度为 \(O(n)\) 。
+- 以最差输入数据为准:当 \(n < 10\) 时,空间复杂度为 \(O(1)\) ;但当 \(n > 10\) 时,初始化的数组
nums 占用 \(O(n)\) 空间,因此最差空间复杂度为 \(O(n)\) 。
+- 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 \(O(1)\) 空间;当初始化数组
nums 时,程序占用 \(O(n)\) 空间,因此最差空间复杂度为 \(O(n)\) 。
@@ -3905,11 +3906,7 @@
-在递归函数中,需要注意统计栈帧空间。例如在以下代码中:
-
-- 函数
loop() 在循环中调用了 \(n\) 次 function() ,每轮中的 function() 都返回并释放了栈帧空间,因此空间复杂度仍为 \(O(1)\) 。
-- 递归函数
recur() 在运行过程中会同时存在 \(n\) 个未返回的 recur() ,从而占用 \(O(n)\) 的栈帧空间。
-
+在递归函数中,需要注意统计栈帧空间。观察以下代码:
@@ -4125,6 +4122,11 @@
+函数 loop() 和 recur() 的时间复杂度都为 \(O(n)\) ,但空间复杂度不同。
+
+- 函数
loop() 在循环中调用了 \(n\) 次 function() ,每轮中的 function() 都返回并释放了栈帧空间,因此空间复杂度仍为 \(O(1)\) 。
+- 递归函数
recur() 在运行过程中会同时存在 \(n\) 个未返回的 recur() ,从而占用 \(O(n)\) 的栈帧空间。
+
2.4.3 常见类型¶
设输入数据大小为 \(n\) ,图 2-16 展示了常见的空间复杂度类型(从低到高排列)。
\[
@@ -5301,12 +5303,12 @@ O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
图 2-19 满二叉树产生的指数阶空间复杂度
5. 对数阶 \(O(\log n)\)¶
-对数阶常见于分治算法。例如归并排序,输入长度为 \(n\) 的数组,每轮递归将数组从中点划分为两半,形成高度为 \(\log n\) 的递归树,使用 \(O(\log n)\) 栈帧空间。
+对数阶常见于分治算法。例如归并排序,输入长度为 \(n\) 的数组,每轮递归将数组从中点处划分为两半,形成高度为 \(\log n\) 的递归树,使用 \(O(\log n)\) 栈帧空间。
再例如将数字转化为字符串,输入一个正整数 \(n\) ,它的位数为 \(\log_{10} n + 1\) ,即对应字符串长度为 \(\log_{10} n + 1\) ,因此空间复杂度为 \(O(\log_{10} n + 1) = O(\log n)\) 。
2.4.4 权衡时间与空间¶
-理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。
+理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
-选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也是非常重要的。
+选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。
diff --git a/chapter_computational_complexity/summary/index.html b/chapter_computational_complexity/summary/index.html
index 0d0dc2eec..7c3b900c0 100644
--- a/chapter_computational_complexity/summary/index.html
+++ b/chapter_computational_complexity/summary/index.html
@@ -3387,42 +3387,42 @@
- 时间效率和空间效率是衡量算法优劣的两个主要评价指标。
- 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。
-- 复杂度分析可以克服实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
+- 复杂度分析可以消除实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
时间复杂度
- 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。
- 最差时间复杂度使用大 \(O\) 符号表示,对应函数渐近上界,反映当 \(n\) 趋向正无穷时,操作数量 \(T(n)\) 的增长级别。
- 推算时间复杂度分为两步,首先统计操作数量,然后判断渐近上界。
-- 常见时间复杂度从小到大排列有 \(O(1)\)、\(O(\log n)\)、\(O(n)\)、\(O(n \log n)\)、\(O(n^2)\)、\(O(2^n)\) 和 \(O(n!)\) 等。
+- 常见时间复杂度从低到高排列有 \(O(1)\)、\(O(\log n)\)、\(O(n)\)、\(O(n \log n)\)、\(O(n^2)\)、\(O(2^n)\) 和 \(O(n!)\) 等。
- 某些算法的时间复杂度非固定,而是与输入数据的分布有关。时间复杂度分为最差、最佳、平均时间复杂度,最佳时间复杂度几乎不用,因为输入数据一般需要满足严格条件才能达到最佳情况。
- 平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。计算平均时间复杂度需要统计输入数据分布以及综合后的数学期望。
空间复杂度
-- 空间复杂度的作用类似于时间复杂度,用于衡量算法占用空间随数据量增长的趋势。
-- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不计入空间复杂度计算。暂存空间可分为指令空间、数据空间、栈帧空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。
-- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时间点下的空间复杂度。
-- 常见空间复杂度从小到大排列有 \(O(1)\)、\(O(\log n)\)、\(O(n)\)、\(O(n^2)\) 和 \(O(2^n)\) 等。
+- 空间复杂度的作用类似于时间复杂度,用于衡量算法占用内存空间随数据量增长的趋势。
+- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不纳入空间复杂度计算。暂存空间可分为暂存数据、栈帧空间和指令空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。
+- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时刻下的空间复杂度。
+- 常见空间复杂度从低到高排列有 \(O(1)\)、\(O(\log n)\)、\(O(n)\)、\(O(n^2)\) 和 \(O(2^n)\) 等。
2. Q & A¶
尾递归的空间复杂度是 \(O(1)\) 吗?
-理论上,尾递归函数的空间复杂度可以被优化至 \(O(1)\) 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)都不支持自动优化尾递归,因此通常认为空间复杂度是 \(O(n)\) 。
+理论上,尾递归函数的空间复杂度可以优化至 \(O(1)\) 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)不支持自动优化尾递归,因此通常认为空间复杂度是 \(O(n)\) 。
函数和方法这两个术语的区别是什么?
-函数(function)可以被独立执行,所有参数都以显式传递。方法(method)与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。
-下面以几个常见的编程语言来说明。
+「函数 function」可以被独立执行,所有参数都以显式传递。「方法 method」与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。
+下面以几种常见的编程语言为例来说明。
-- C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他语言中的方法。
-- Java 和 C# 是面向对象的编程语言,代码块(方法)通常都是作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。
+- C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他编程语言中的方法。
+- Java 和 C# 是面向对象的编程语言,代码块(方法)通常作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。
- C++ 和 Python 既支持过程式编程(函数),也支持面向对象编程(方法)。
-图“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?
-不是,该图片展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。
+图解“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?
+不是,该图展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。
假设取 \(n = 8\) ,你可能会发现每条曲线的值与函数对应不上。这是因为每条曲线都包含一个常数项,用于将取值范围压缩到一个视觉舒适的范围内。
在实际中,因为我们通常不知道每个方法的“常数项”复杂度是多少,所以一般无法仅凭复杂度来选择 \(n = 8\) 之下的最优解法。但对于 \(n = 8^5\) 就很好选了,这时增长趋势已经占主导了。
diff --git a/chapter_computational_complexity/time_complexity/index.html b/chapter_computational_complexity/time_complexity/index.html
index fdc5b87e3..81a09f813 100644
--- a/chapter_computational_complexity/time_complexity/index.html
+++ b/chapter_computational_complexity/time_complexity/index.html
@@ -3574,7 +3574,7 @@
2.3 时间复杂度¶
-运行时间可以直观且准确地反映算法的效率。如果我们想要准确预估一段代码的运行时间,应该如何操作呢?
+运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
- 确定运行平台,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
- 评估各种计算操作所需的运行时间,例如加法操作
+ 需要 1 ns ,乘法操作 * 需要 10 ns ,打印操作 print() 需要 5 ns 等。
@@ -3739,14 +3739,14 @@
-根据以上方法,可以得到算法运行时间为 \((6n + 12)\) ns :
+根据以上方法,可以得到算法的运行时间为 \((6n + 12)\) ns :
\[
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
\]
但实际上,统计算法的运行时间既不合理也不现实。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
2.3.1 统计时间增长趋势¶
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
-“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 \(n\) ,给定三个算法函数 A、B 和 C :
+“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 \(n\) ,给定三个算法 A、B 和 C :
@@ -3987,10 +3987,10 @@
图 2-7 算法 A、B 和 C 的时间增长趋势
-相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
+相较于直接统计算法的运行时间,时间复杂度分析有哪些特点呢?
-- 时间复杂度能够有效评估算法效率。例如,算法
B 的运行时间呈线性增长,在 \(n > 1\) 时比算法 A 更慢,在 \(n > 1000000\) 时比算法 C 更慢。事实上,只要输入数据大小 \(n\) 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势所表达的含义。
-- 时间复杂度的推算方法更简便。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作的运行时间的统计”简化为“计算操作的数量的统计”,这样一来估算难度就大大降低了。
+- 时间复杂度能够有效评估算法效率。例如,算法
B 的运行时间呈线性增长,在 \(n > 1\) 时比算法 A 更慢,在 \(n > 1000000\) 时比算法 C 更慢。事实上,只要输入数据大小 \(n\) 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。
+- 时间复杂度的推算方法更简便。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- 时间复杂度也存在一定的局限性。例如,尽管算法
A 和 C 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 B 的时间复杂度比 C 高,但在输入数据大小 \(n\) 较小时,算法 B 明显优于算法 C 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
2.3.2 函数渐近上界¶
@@ -4142,13 +4142,13 @@
-设算法的操作数量是一个关于输入数据大小 \(n\) 的函数,记为 \(T(n)\) ,则以上函数的的操作数量为:
+设算法的操作数量是一个关于输入数据大小 \(n\) 的函数,记为 \(T(n)\) ,则以上函数的操作数量为:
\[
T(n) = 3 + 2n
\]
\(T(n)\) 是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 \(O(n)\) ,这个数学符号称为「大 \(O\) 记号 big-\(O\) notation」,表示函数 \(T(n)\) 的「渐近上界 asymptotic upper bound」。
-时间复杂度分析本质上是计算“操作数量函数 \(T(n)\)”的渐近上界,其具有明确的数学定义。
+时间复杂度分析本质上是计算“操作数量 \(T(n)\)”的渐近上界,它具有明确的数学定义。
函数渐近上界
若存在正实数 \(c\) 和实数 \(n_0\) ,使得对于所有的 \(n > n_0\) ,均有 \(T(n) \leq c \cdot f(n)\) ,则可认为 \(f(n)\) 给出了 \(T(n)\) 的一个渐近上界,记为 \(T(n) = O(f(n))\) 。
@@ -4158,16 +4158,16 @@ T(n) = 3 + 2n
图 2-8 函数的渐近上界
2.3.3 推算方法¶
-渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
+渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的实践中,就可以逐渐领悟其数学意义。
根据定义,确定 \(f(n)\) 之后,我们便可得到时间复杂度 \(O(f(n))\) 。那么如何确定渐近上界 \(f(n)\) 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
1. 第一步:统计操作数量¶
-针对代码,逐行从上到下计算即可。然而,由于上述 \(c \cdot f(n)\) 中的常数项 \(c\) 可以取任意大小,因此操作数量 \(T(n)\) 中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数简化技巧。
+针对代码,逐行从上到下计算即可。然而,由于上述 \(c \cdot f(n)\) 中的常数项 \(c\) 可以取任意大小,因此操作数量 \(T(n)\) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。
- 忽略 \(T(n)\) 中的常数项。因为它们都与 \(n\) 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 \(2n\) 次、\(5n + 1\) 次等,都可以简化记为 \(n\) 次,因为 \(n\) 前面的系数对时间复杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第
1. 点和第 2. 点的技巧。
-给定一个函数,我们可以用上述技巧来统计操作数量。
+给定一个函数,我们可以用上述技巧来统计操作数量:
@@ -4376,7 +4376,7 @@ T(n) = 3 + 2n
-以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 \(O(n^2)\) 。
+以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 \(O(n^2)\) 。
\[
\begin{aligned}
T(n) & = 2n(n + 1) + (5n + 1) + 2 & \text{完整统计 (-.-|||)} \newline
@@ -4385,7 +4385,7 @@ T(n) & = n^2 + n & \text{偷懒统计 (o.O)}
\end{aligned}
\]
2. 第二步:判断渐近上界¶
-时间复杂度由多项式 \(T(n)\) 中最高阶的项来决定。这是因为在 \(n\) 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
+时间复杂度由 \(T(n)\) 中最高阶的项来决定。这是因为在 \(n\) 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
表 2-2 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 \(n\) 趋于无穷大时,这些常数变得无足轻重。
表 2-2 不同操作数量对应的时间复杂度
@@ -4857,7 +4857,7 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!
值得注意的是,输入数据大小 \(n\) 需根据输入数据的类型来具体确定。比如在第一个示例中,变量 \(n\) 为输入数据大小;在第二个示例中,数组长度 \(n\) 为数据大小。
3. 平方阶 \(O(n^2)\)¶
-平方阶的操作数量相对于输入数据大小 \(n\) 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 \(O(n)\) ,因此总体为 \(O(n^2)\) :
+平方阶的操作数量相对于输入数据大小 \(n\) 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 \(O(n)\) ,因此总体的时间复杂度为 \(O(n^2)\) :
@@ -5033,7 +5033,7 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!
图 2-10 常数阶、线性阶和平方阶的时间复杂度
-以冒泡排序为例,外层循环执行 \(n - 1\) 次,内层循环执行 \(n-1\)、\(n-2\)、\(\dots\)、\(2\)、\(1\) 次,平均为 \(n / 2\) 次,因此时间复杂度为 \(O((n - 1) n / 2) = O(n^2)\) 。
+以冒泡排序为例,外层循环执行 \(n - 1\) 次,内层循环执行 \(n-1\)、\(n-2\)、\(\dots\)、\(2\)、\(1\) 次,平均为 \(n / 2\) 次,因此时间复杂度为 \(O((n - 1) n / 2) = O(n^2)\) :
@@ -5604,10 +5604,10 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!
-指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决。
+指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
5. 对数阶 \(O(\log n)\)¶
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 \(n\) ,由于每轮缩减到一半,因此循环次数是 \(\log_2 n\) ,即 \(2^n\) 的反函数。
-图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 \(O(\log_2 n)\) ,简记为 \(O(\log n)\) 。
+图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 \(O(\log_2 n)\) ,简记为 \(O(\log n)\) :
@@ -5760,7 +5760,7 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!
图 2-12 对数阶的时间复杂度
-与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 \(\log_2 n\) 的递归树:
+与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为 \(\log_2 n\) 的递归树:
@@ -5873,7 +5873,7 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
\(O(\log n)\) 的底数是多少?
-准确来说,“一分为 \(m\)”对应的时间复杂度是 \(O(\log_m n)\) 。而通过对数换底公式,我们可以得到具有不同底数的、相等的时间复杂度:
+准确来说,“一分为 \(m\)”对应的时间复杂度是 \(O(\log_m n)\) 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
\[
O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n)
\]
@@ -6556,12 +6556,12 @@ n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。
-从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 \(\Theta\) 记号来表示。
+从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 \(\Theta\) 记号来表示。
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 \(1\) 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 \(n / 2\) ,平均时间复杂度为 \(\Theta(n / 2) = \Theta(n)\) 。
-但对于较为复杂的算法,计算平均时间复杂度往往是比较困难的,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
+但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
为什么很少看到 \(\Theta\) 符号?
-可能由于 \(O\) 符号过于朗朗上口,我们常常使用它来表示平均时间复杂度。但从严格意义上看,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 \(O(n)\)”的表述,请将其直接理解为 \(\Theta(n)\) 。
+可能由于 \(O\) 符号过于朗朗上口,因此我们常常使用它来表示平均时间复杂度。但从严格意义上讲,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 \(O(n)\)”的表述,请将其直接理解为 \(\Theta(n)\) 。
diff --git a/chapter_data_structure/basic_data_types/index.html b/chapter_data_structure/basic_data_types/index.html
index 59bbc755c..8122c9995 100644
--- a/chapter_data_structure/basic_data_types/index.html
+++ b/chapter_data_structure/basic_data_types/index.html
@@ -3316,21 +3316,21 @@
3.2 基本数据类型¶
-谈及计算机中的数据,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。
-基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用,主要包括以下几种类型。
+当谈及计算机中的数据时,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。
+基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用,主要包括以下几种。
- 整数类型
byte、short、int、long 。
- 浮点数类型
float、double ,用于表示小数。
-- 字符类型
char ,用于表示各种语言的字母、标点符号、甚至表情符号等。
+- 字符类型
char ,用于表示各种语言的字母、标点符号甚至表情符号等。
- 布尔类型
bool ,用于表示“是”与“否”判断。
-基本数据类型以二进制的形式存储在计算机中。一个二进制位即为 \(1\) 比特。在绝大多数现代系统中,\(1\) 字节(byte)由 \(8\) 比特(bits)组成。
+基本数据类型以二进制的形式存储在计算机中。一个二进制位即为 \(1\) 比特。在绝大多数现代操作系统中,\(1\) 字节(byte)由 \(8\) 比特(bit)组成。
基本数据类型的取值范围取决于其占用的空间大小。下面以 Java 为例。
- 整数类型
byte 占用 \(1\) byte = \(8\) bits ,可以表示 \(2^{8}\) 个数字。
- 整数类型
int 占用 \(4\) bytes = \(32\) bits ,可以表示 \(2^{32}\) 个数字。
-表 3-1 列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。
+表 3-1 列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须死记硬背,大致理解即可,需要时可以通过查表来回忆。
表 3-1 基本数据类型的占用空间和取值范围
@@ -3384,7 +3384,7 @@
4 bytes
\(1.175 \times 10^{-38}\)
\(3.403 \times 10^{38}\)
-\(0.0f\)
+\(0.0\text{f}\)
@@ -3418,10 +3418,10 @@
- 在 Python 中,整数类型
int 可以是任意大小,只受限于可用内存;浮点数 float 是双精度 64 位;没有 char 类型,单个字符实际上是长度为 1 的字符串 str 。
- C 和 C++ 未明确规定基本数据类型大小,而因实现和平台各异。表 3-1 遵循 LP64 数据模型,其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。
- 字符
char 的大小在 C 和 C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。
-- 即使表示布尔量仅需 1 位(\(0\) 或 \(1\)),它在内存中通常被存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
+- 即使表示布尔量仅需 1 位(\(0\) 或 \(1\)),它在内存中通常存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
-那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。它的主语是“结构”而非“数据”。
-如果想要表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 int、小数 float 或是字符 char ,则与“数据结构”无关。
+那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。
+如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 int、小数 float 或是字符 char ,则与“数据结构”无关。
换句话说,基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”。例如以下代码,我们用相同的数据结构(数组)来存储与表示不同的基本数据类型,包括 int、float、char、bool 等。
@@ -3429,7 +3429,7 @@
# 使用多种基本数据类型来初始化数组
numbers: list[int] = [0] * 5
decimals: list[float] = [0.0] * 5
-# Python 的字符应被看作长度为一的字符串
+# Python 的字符实际上是长度为 1 的字符串
characters: list[str] = ['0'] * 5
bools: list[bool] = [False] * 5
# Python 的列表可以自由存储各种基本数据类型和对象引用
diff --git a/chapter_data_structure/character_encoding/index.html b/chapter_data_structure/character_encoding/index.html
index 2c5455225..6fa5f82d5 100644
--- a/chapter_data_structure/character_encoding/index.html
+++ b/chapter_data_structure/character_encoding/index.html
@@ -3426,67 +3426,67 @@
3.4 字符编码 *¶
在计算机中,所有数据都是以二进制数的形式存储的,字符 char 也不例外。为了表示字符,我们需要建立一套“字符集”,规定每个字符和二进制数之间的一一对应关系。有了字符集之后,计算机就可以通过查表完成二进制数到字符的转换。
3.4.1 ASCII 字符集¶
-「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。
+「ASCII 码」是最早出现的字符集,其全称为 American Standard Code for Information Interchange(美国标准信息交换代码)。它使用 7 位二进制数(一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。
图 3-6 ASCII 码
然而,ASCII 码仅能够表示英文。随着计算机的全球化,诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
在世界范围内,陆续出现了一批适用于不同地区的 EASCII 字符集。这些字符集的前 128 个字符统一为 ASCII 码,后 128 个字符定义不同,以适应不同语言的需求。
3.4.2 GBK 字符集¶
-后来人们发现,EASCII 码仍然无法满足许多语言的字符数量要求。比如汉字大约有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集,其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。
-然而,GB2312 无法处理部分的罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中,ASCII 字符使用一个字节表示,汉字使用两个字节表示。
+后来人们发现,EASCII 码仍然无法满足许多语言的字符数量要求。比如汉字有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集,其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。
+然而,GB2312 无法处理部分罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中,ASCII 字符使用一个字节表示,汉字使用两个字节表示。
3.4.3 Unicode 字符集¶
-随着计算机的蓬勃发展,字符集与编码标准百花齐放,而这带来了许多问题。一方面,这些字符集一般只定义了特定语言的字符,无法在多语言环境下正常工作。另一方面,同一种语言也存在多种字符集标准,如果两台电脑安装的是不同的编码标准,则在信息传递时就会出现乱码。
+随着计算机技术的蓬勃发展,字符集与编码标准百花齐放,而这带来了许多问题。一方面,这些字符集一般只定义了特定语言的字符,无法在多语言环境下正常工作。另一方面,同一种语言存在多种字符集标准,如果两台计算机使用的是不同的编码标准,则在信息传递时就会出现乱码。
那个时代的研究人员就在想:如果推出一个足够完整的字符集,将世界范围内的所有语言和符号都收录其中,不就可以解决跨语言环境和乱码问题了吗?在这种想法的驱动下,一个大而全的字符集 Unicode 应运而生。
-「Unicode」的全称为“统一字符编码”,理论上能容纳一百多万个字符。它致力于将全球范围内的字符纳入到统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。
-自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截止 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号、甚至是表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占 3 字节甚至 4 字节。
-Unicode 是一种字符集标准,本质上是给每个字符分配一个编号(称为“码点”),但它并没有规定在计算机中如何存储这些字符码点。我们不禁会问:当多种长度的 Unicode 码点同时出现在同一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符?
-对于以上问题,一种直接的解决方案是将所有字符存储为等长的编码。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。
+「Unicode」的中文名称为“统一码”,理论上能容纳 100 多万个字符。它致力于将全球范围内的字符纳入统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。
+自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截至 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号甚至表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占用 3 字节甚至 4 字节。
+Unicode 是一种通用字符集,本质上是给每个字符分配一个编号(称为“码点”),但它并没有规定在计算机中如何存储这些字符码点。我们不禁会问:当多种长度的 Unicode 码点同时出现在一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符?
+对于以上问题,一种直接的解决方案是将所有字符存储为等长的编码。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复这个短语的内容了。
图 3-7 Unicode 编码示例
-然而 ASCII 码已经向我们证明,编码英文只需要 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
+然而 ASCII 码已经向我们证明,编码英文只需 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
3.4.4 UTF-8 编码¶
-目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。它是一种可变长的编码,使用 1 到 4 个字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需要 1 个字节,拉丁字母和希腊字母需要 2 个字节,常用的中文字符需要 3 个字节,其他的一些生僻字符需要 4 个字节。
+目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。它是一种可变长度的编码,使用 1 到 4 字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需 1 字节,拉丁字母和希腊字母需要 2 字节,常用的中文字符需要 3 字节,其他的一些生僻字符需要 4 字节。
UTF-8 的编码规则并不复杂,分为以下两种情况。
-- 对于长度为 1 字节的字符,将最高位设置为 \(0\)、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,UTF-8 编码可以向下兼容 ASCII 码。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
-- 对于长度为 \(n\) 字节的字符(其中 \(n > 1\)),将首个字节的高 \(n\) 位都设置为 \(1\)、第 \(n + 1\) 位设置为 \(0\) ;从第二个字节开始,将每个字节的高 2 位都设置为 \(10\) ;其余所有位用于填充字符的 Unicode 码点。
+- 对于长度为 1 字节的字符,将最高位设置为 \(0\) ,其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,UTF-8 编码可以向下兼容 ASCII 码。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
+- 对于长度为 \(n\) 字节的字符(其中 \(n > 1\)),将首个字节的高 \(n\) 位都设置为 \(1\) ,第 \(n + 1\) 位设置为 \(0\) ;从第二个字节开始,将每个字节的高 2 位都设置为 \(10\) ;其余所有位用于填充字符的 Unicode 码点。
-图 3-8 展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 \(n\) 位都被设置为 \(1\) ,因此系统可以通过读取最高位 \(1\) 的个数来解析出字符的长度为 \(n\) 。
-但为什么要将其余所有字节的高 2 位都设置为 \(10\) 呢?实际上,这个 \(10\) 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 \(10\) 能够帮助系统快速的判断出异常。
+图 3-8 展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 \(n\) 位都设置为 \(1\) ,因此系统可以通过读取最高位 \(1\) 的个数来解析出字符的长度为 \(n\) 。
+但为什么要将其余所有字节的高 2 位都设置为 \(10\) 呢?实际上,这个 \(10\) 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 \(10\) 能够帮助系统快速判断出异常。
之所以将 \(10\) 当作校验符,是因为在 UTF-8 编码规则下,不可能有字符的最高两位是 \(10\) 。这个结论可以用反证法来证明:假设一个字符的最高两位是 \(10\) ,说明该字符的长度为 \(1\) ,对应 ASCII 码。而 ASCII 码的最高位应该是 \(0\) ,与假设矛盾。
图 3-8 UTF-8 编码示例
除了 UTF-8 之外,常见的编码方式还包括以下两种。
-- UTF-16 编码:使用 2 或 4 个字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 个字节表示;少数字符需要用到 4 个字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。
-- UTF-32 编码:每个字符都使用 4 个字节。这意味着 UTF-32 会比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。
+- UTF-16 编码:使用 2 或 4 字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 字节表示;少数字符需要用到 4 字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。
+- UTF-32 编码:每个字符都使用 4 字节。这意味着 UTF-32 比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。
-从存储空间的角度看,使用 UTF-8 表示英文字符非常高效,因为它仅需 1 个字节;使用 UTF-16 编码某些非英文字符(例如中文)会更加高效,因为它只需要 2 个字节,而 UTF-8 可能需要 3 个字节。
-从兼容性的角度看,UTF-8 的通用性最佳,许多工具和库都优先支持 UTF-8 。
+从存储空间占用的角度看,使用 UTF-8 表示英文字符非常高效,因为它仅需 1 字节;使用 UTF-16 编码某些非英文字符(例如中文)会更加高效,因为它仅需 2 字节,而 UTF-8 可能需要 3 字节。
+从兼容性的角度看,UTF-8 的通用性最佳,许多工具和库优先支持 UTF-8 。
3.4.5 编程语言的字符编码¶
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长的编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。
-- 随机访问: UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要找到第 \(i\) 个字符,我们需要从字符串的开始处遍历到第 \(i\) 个字符,这需要 \(O(n)\) 的时间。
-- 字符计数: 与随机访问类似,计算 UTF-16 字符串的长度也是 \(O(1)\) 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
-- 字符串操作: 在 UTF-16 编码的字符串中,很多字符串操作(如分割、连接、插入、删除等)都更容易进行。在 UTF-8 编码的字符串上进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。
+- 随机访问:UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要想找到第 \(i\) 个字符,我们需要从字符串的开始处遍历到第 \(i\) 个字符,这需要 \(O(n)\) 的时间。
+- 字符计数:与随机访问类似,计算 UTF-16 编码的字符串的长度也是 \(O(1)\) 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
+- 字符串操作:在 UTF-16 编码的字符串上,很多字符串操作(如分割、连接、插入、删除等)更容易进行。在 UTF-8 编码的字符串上,进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。
-实际上,编程语言的字符编码方案设计是一个很有趣的话题,其涉及到许多因素。
+实际上,编程语言的字符编码方案设计是一个很有趣的话题,涉及许多因素。
- Java 的
String 类型使用 UTF-16 编码,每个字符占用 2 字节。这是因为 Java 语言设计之初,人们认为 16 位足以表示所有可能的字符。然而,这是一个不正确的判断。后来 Unicode 规范扩展到了超过 16 位,所以 Java 中的字符现在可能由一对 16 位的值(称为“代理对”)表示。
-- JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 JavaScript 语言在 1995 年被 Netscape 公司首次引入时,Unicode 还处于相对早期的阶段,那时候使用 16 位的编码就足够表示所有的 Unicode 字符了。
-- C# 使用 UTF-16 编码,主要因为 .NET 平台是由 Microsoft 设计的,而 Microsoft 的很多技术,包括 Windows 操作系统,都广泛地使用 UTF-16 编码。
+- JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 1995 年 Netscape 公司首次推出 JavaScript 语言时,Unicode 还处于发展早期,那时候使用 16 位的编码就足以表示所有的 Unicode 字符了。
+- C# 使用 UTF-16 编码,主要是因为 .NET 平台是由 Microsoft 设计的,而 Microsoft 的很多技术(包括 Windows 操作系统)都广泛使用 UTF-16 编码。
-由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这增加了编程的复杂性和 Debug 难度。
+由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这提高了编程的复杂性和调试难度。
出于以上原因,部分编程语言提出了一些不同的编码方案。
- Python 中的
str 使用 Unicode 编码,并采用一种灵活的字符串表示,存储的字符长度取决于字符串中最大的 Unicode 码点。若字符串中全部是 ASCII 字符,则每个字符占用 1 个字节;如果有字符超出了 ASCII 范围,但全部在基本多语言平面(BMP)内,则每个字符占用 2 个字节;如果有超出 BMP 的字符,则每个字符占用 4 个字节。
- Go 语言的
string 类型在内部使用 UTF-8 编码。Go 语言还提供了 rune 类型,它用于表示单个 Unicode 码点。
- Rust 语言的 str 和 String 类型在内部使用 UTF-8 编码。Rust 也提供了
char 类型,用于表示单个 Unicode 码点。
-需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,这和字符串如何在文件中存储或在网络中传输是两个不同的问题。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。
+需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,这和字符串如何在文件中存储或在网络中传输是不同的问题。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。
diff --git a/chapter_data_structure/classification_of_data_structure/index.html b/chapter_data_structure/classification_of_data_structure/index.html
index b3491079f..5c18bc805 100644
--- a/chapter_data_structure/classification_of_data_structure/index.html
+++ b/chapter_data_structure/classification_of_data_structure/index.html
@@ -3384,16 +3384,16 @@
3.1 数据结构分类¶
常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。
3.1.1 逻辑结构:线性与非线性¶
-逻辑结构揭示了数据元素之间的逻辑关系。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
-如图 3-1 所示,逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
+逻辑结构揭示了数据元素之间的逻辑关系。在数组和链表中,数据按照一定顺序排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出“祖先”与“后代”之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
+如图 3-1 所示,逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
- 线性数据结构:数组、链表、栈、队列、哈希表。
- 非线性数据结构:树、堆、图、哈希表。
-
- 图 3-1 线性与非线性数据结构
+
+ 图 3-1 线性数据结构与非线性数据结构
-非线性数据结构可以进一步被划分为树形结构和网状结构。
+非线性数据结构可以进一步划分为树形结构和网状结构。
- 线性结构:数组、链表、队列、栈、哈希表,元素之间是一对一的顺序关系。
- 树形结构:树、堆、哈希表,元素之间是一对多的关系。
@@ -3405,12 +3405,12 @@
图 3-2 内存条、内存空间、内存地址
-
-Note
+
+Tip
值得说明的是,将内存比作 Excel 表格是一个简化的类比,实际内存的工作机制比较复杂,涉及到地址空间、内存管理、缓存机制、虚拟和物理内存等概念。
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。
-如图 3-3 所示,物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出互补的特点。
+如图 3-3 所示,物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,两种物理结构在时间效率和空间效率方面呈现出互补的特点。
图 3-3 连续空间存储与分散空间存储
@@ -3419,10 +3419,10 @@
- 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 \(\geq 3\) 的数组)等。
- 基于链表可实现:栈、队列、哈希表、树、堆、图等。
-基于数组实现的数据结构也被称为“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构被称为“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
+基于数组实现的数据结构也称“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构称“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
Tip
-如果你感觉物理结构理解起来有困难,建议先阅读下一章“数组与链表”,然后再回顾本节内容。
+如果你感觉物理结构理解起来有困难,建议先阅读下一章,然后再回顾本节内容。
diff --git a/chapter_data_structure/index.html b/chapter_data_structure/index.html
index 7c7686f3a..4c5ea88b8 100644
--- a/chapter_data_structure/index.html
+++ b/chapter_data_structure/index.html
@@ -3327,7 +3327,7 @@
Abstract
数据结构如同一副稳固而多样的框架。
-它为数据的有序组织提供了蓝图,使算法得以在此基础上生动起来。
+它为数据的有序组织提供了蓝图,算法得以在此基础上生动起来。
本章内容¶
diff --git a/chapter_data_structure/number_encoding/index.html b/chapter_data_structure/number_encoding/index.html
index fece376b9..6e6498735 100644
--- a/chapter_data_structure/number_encoding/index.html
+++ b/chapter_data_structure/number_encoding/index.html
@@ -3384,11 +3384,11 @@
3.3 数字编码 *¶
Note
-在本书中,标题带有的 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。
+在本书中,标题带有 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。
3.3.1 整数编码¶
-在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 byte 的取值范围是 \([-128, 127]\) 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。
-首先需要指出,数字是以“补码”的形式存储在计算机中的。在分析这样做的原因之前,我们首先给出三者的定义。
+在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 byte 的取值范围是 \([-128, 127]\) 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。
+首先需要指出,数字是以“补码”的形式存储在计算机中的。在分析这样做的原因之前,首先给出三者的定义。
- 原码:我们将数字的二进制表示的最高位视为符号位,其中 \(0\) 表示正数,\(1\) 表示负数,其余位表示数字的值。
- 反码:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
@@ -3407,7 +3407,7 @@
& \rightarrow -3
\end{aligned}
\]
-为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码,并在反码下计算 \(1 + (-2)\) ,最后将结果从反码转化回原码,则可得到正确结果 \(-1\) 。
+为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码,并在反码下计算 \(1 + (-2)\) ,最后将结果从反码转换回原码,则可得到正确结果 \(-1\) 。
\[
\begin{aligned}
& 1 + (-2) \newline
@@ -3418,7 +3418,7 @@
& \rightarrow -1
\end{aligned}
\]
-另一方面,数字零的原码有 \(+0\) 和 \(-0\) 两种表示方式。这意味着数字零对应着两个不同的二进制编码,其可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想要处理正零和负零歧义,则需要引入额外的判断操作,其可能会降低计算机的运算效率。
+另一方面,数字零的原码有 \(+0\) 和 \(-0\) 两种表示方式。这意味着数字零对应两个不同的二进制编码,这可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想处理正零和负零歧义,则需要引入额外的判断操作,这可能会降低计算机的运算效率。
\[
\begin{aligned}
+0 & \rightarrow 0000 \; 0000 \newline
@@ -3434,7 +3434,7 @@
\end{aligned}
\]
在负零的反码基础上加 \(1\) 会产生进位,但 byte 类型的长度只有 8 位,因此溢出到第 9 位的 \(1\) 会被舍弃。也就是说,负零的补码为 \(0000 \; 0000\) ,与正零的补码相同。这意味着在补码表示中只存在一个零,正负零歧义从而得到解决。
-还剩余最后一个疑惑:byte 类型的取值范围是 \([-128, 127]\) ,多出来的一个负数 \(-128\) 是如何得到的呢?我们注意到,区间 \([-127, +127]\) 内的所有整数都有对应的原码、反码和补码,并且原码和补码之间是可以互相转换的。
+还剩最后一个疑惑:byte 类型的取值范围是 \([-128, 127]\) ,多出来的一个负数 \(-128\) 是如何得到的呢?我们注意到,区间 \([-127, +127]\) 内的所有整数都有对应的原码、反码和补码,并且原码和补码之间可以互相转换。
然而,补码 \(1000 \; 0000\) 是一个例外,它并没有对应的原码。根据转换方法,我们得到该补码的原码为 \(0000 \; 0000\) 。这显然是矛盾的,因为该原码表示数字 \(0\) ,它的补码应该是自身。计算机规定这个特殊的补码 \(1000 \; 0000\) 代表 \(-128\) 。实际上,\((-1) + (-127)\) 在补码下的计算结果就是 \(-128\) 。
\[
\begin{aligned}
@@ -3446,10 +3446,10 @@
& \rightarrow -128
\end{aligned}
\]
-你可能已经发现,上述的所有计算都是加法运算。这暗示着一个重要事实:计算机内部的硬件电路主要是基于加法运算设计的。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。
+你可能已经发现了,上述所有计算都是加法运算。这暗示着一个重要事实:计算机内部的硬件电路主要是基于加法运算设计的。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。
请注意,这并不意味着计算机只能做加法。通过将加法与一些基本逻辑运算结合,计算机能够实现各种其他的数学运算。例如,计算减法 \(a - b\) 可以转换为计算加法 \(a + (-b)\) ;计算乘法和除法可以转换为计算多次加法或减法。
现在我们可以总结出计算机使用补码的原因:基于补码表示,计算机可以用同样的电路和操作来处理正数和负数的加法,不需要设计特殊的硬件电路来处理减法,并且无须特别处理正负零的歧义问题。这大大简化了硬件设计,提高了运算效率。
-补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深度了解。
+补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深入了解。
3.3.2 浮点数编码¶
细心的你可能会发现:int 和 float 长度相同,都是 4 bytes ,但为什么 float 的取值范围远大于 int ?这非常反直觉,因为按理说 float 需要表示小数,取值范围应该变小才对。
实际上,这是因为浮点数 float 采用了不同的表示方式。记一个 32-bit 长度的二进制数为:
@@ -3462,15 +3462,15 @@ b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0
- 指数位 \(\mathrm{E}\) :占 8 bits ,对应 \(b_{30} b_{29} \ldots b_{23}\) 。
- 分数位 \(\mathrm{N}\) :占 23 bits ,对应 \(b_{22} b_{21} \ldots b_0\) 。
-二进制数 float 对应的值的计算方法:
+二进制数 float 对应值的计算方法为:
\[
\text {val} = (-1)^{b_{31}} \times 2^{\left(b_{30} b_{29} \ldots b_{23}\right)_2-127} \times\left(1 . b_{22} b_{21} \ldots b_0\right)_2
\]
-转化到十进制下的计算公式:
+转化到十进制下的计算公式为:
\[
\text {val}=(-1)^{\mathrm{S}} \times 2^{\mathrm{E} -127} \times (1 + \mathrm{N})
\]
-其中各项的取值范围:
+其中各项的取值范围为:
\[
\begin{aligned}
\mathrm{S} \in & \{ 0, 1\}, \quad \mathrm{E} \in \{ 1, 2, \dots, 254 \} \newline
@@ -3522,7 +3522,7 @@ b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0
值得说明的是,次正规数显著提升了浮点数的精度。最小正正规数为 \(2^{-126}\) ,最小正次正规数为 \(2^{-126} \times 2^{-23}\) 。
-双精度 double 也采用类似 float 的表示方法,在此不做赘述。
+双精度 double 也采用类似于 float 的表示方法,在此不做赘述。
diff --git a/chapter_data_structure/summary/index.html b/chapter_data_structure/summary/index.html
index 997b44c57..a3a52940c 100644
--- a/chapter_data_structure/summary/index.html
+++ b/chapter_data_structure/summary/index.html
@@ -3385,13 +3385,13 @@
1. 重点回顾¶
- 数据结构可以从逻辑结构和物理结构两个角度进行分类。逻辑结构描述了数据元素之间的逻辑关系,而物理结构描述了数据在计算机内存中的存储方式。
-- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性和非线性结构。
+- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性数据结构和非线性数据结构。
- 当程序运行时,数据被存储在计算机内存中。每个内存空间都拥有对应的内存地址,程序通过这些内存地址访问数据。
- 物理结构主要分为连续空间存储(数组)和分散空间存储(链表)。所有数据结构都是由数组、链表或两者的组合实现的。
- 计算机中的基本数据类型包括整数
byte、short、int、long ,浮点数 float、double ,字符 char 和布尔 boolean 。它们的取值范围取决于占用空间大小和表示方式。
-- 原码、反码和补码是在计算机中编码数字的三种方法,它们之间是可以相互转换的。整数的原码的最高位是符号位,其余位是数字的值。
+- 原码、反码和补码是在计算机中编码数字的三种方法,它们之间可以相互转换。整数的原码的最高位是符号位,其余位是数字的值。
- 整数在计算机中是以补码的形式存储的。在补码表示下,计算机可以对正数和负数的加法一视同仁,不需要为减法操作单独设计特殊的硬件电路,并且不存在正负零歧义的问题。
-- 浮点数的编码由 1 位符号位、8 位指数位和 23 位分数位构成。由于存在指数位,浮点数的取值范围远大于整数,代价是牺牲了精度。
+- 浮点数的编码由 1 位符号位、8 位指数位和 23 位分数位构成。由于存在指数位,因此浮点数的取值范围远大于整数,代价是牺牲了精度。
- ASCII 码是最早出现的英文字符集,长度为 1 字节,共收录 127 个字符。GBK 字符集是常用的中文字符集,共收录两万多个汉字。Unicode 致力于提供一个完整的字符集标准,收录世界内各种语言的字符,从而解决由于字符编码方法不一致而导致的乱码问题。
- UTF-8 是最受欢迎的 Unicode 编码方法,通用性非常好。它是一种变长的编码方法,具有很好的扩展性,有效提升了存储空间的使用效率。UTF-16 和 UTF-32 是等长的编码方法。在编码中文时,UTF-16 比 UTF-8 的占用空间更小。Java 和 C# 等编程语言默认使用 UTF-16 编码。
@@ -3403,7 +3403,7 @@
char 类型的长度是 1 byte 吗?
-char 类型的长度由编程语言采用的编码方法决定。例如,Java、JS、TS、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。
+char 类型的长度由编程语言采用的编码方法决定。例如,Java、JavaScript、TypeScript、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。
基于数组实现的数据结构也被称为“静态数据结构” 是否有歧义?因为栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。
diff --git a/chapter_divide_and_conquer/binary_search_recur/index.html b/chapter_divide_and_conquer/binary_search_recur/index.html
index ff58990d3..fb6e90313 100644
--- a/chapter_divide_and_conquer/binary_search_recur/index.html
+++ b/chapter_divide_and_conquer/binary_search_recur/index.html
@@ -2461,7 +2461,7 @@
-
- 1. 基于分治实现二分
+ 1. 基于分治实现二分查找
@@ -3307,7 +3307,7 @@
-
- 1. 基于分治实现二分
+ 1. 基于分治实现二分查找
@@ -3371,38 +3371,38 @@
我们已经学过,搜索算法分为两大类。
- 暴力搜索:它通过遍历数据结构实现,时间复杂度为 \(O(n)\) 。
-- 自适应搜索:它利用特有的数据组织形式或先验信息,可达到 \(O(\log n)\) 甚至 \(O(1)\) 的时间复杂度。
+- 自适应搜索:它利用特有的数据组织形式或先验信息,时间复杂度可达到 \(O(\log n)\) 甚至 \(O(1)\) 。
-实际上,时间复杂度为 \(O(\log n)\) 的搜索算法通常都是基于分治策略实现的,例如二分查找和树。
+实际上,时间复杂度为 \(O(\log n)\) 的搜索算法通常是基于分治策略实现的,例如二分查找和树。
- 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。
-- 树是分治关系的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 \(O(\log n)\) 。
+- 树是分治思想的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 \(O(\log n)\) 。
二分查找的分治策略如下所示。
-- 问题可以被分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
-- 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受另外子问题的影响。
+- 问题可以分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
+- 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
- 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项。
-1. 基于分治实现二分¶
+1. 基于分治实现二分查找¶
在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。
Question
-给定一个长度为 \(n\) 的有序数组 nums ,数组中所有元素都是唯一的,请查找元素 target 。
+给定一个长度为 \(n\) 的有序数组 nums ,其中所有元素都是唯一的,请查找元素 target 。
从分治角度,我们将搜索区间 \([i, j]\) 对应的子问题记为 \(f(i, j)\) 。
-从原问题 \(f(0, n-1)\) 为起始点,通过以下步骤进行二分查找。
+以原问题 \(f(0, n-1)\) 为起始点,通过以下步骤进行二分查找。
- 计算搜索区间 \([i, j]\) 的中点 \(m\) ,根据它排除一半搜索区间。
- 递归求解规模减小一半的子问题,可能为 \(f(i, m-1)\) 或 \(f(m+1, j)\) 。
-- 循环第
1. 和 2. 步,直至找到 target 或区间为空时返回。
+- 循环第
1. 步和第 2. 步,直至找到 target 或区间为空时返回。
图 12-4 展示了在数组中二分查找元素 \(6\) 的分治过程。
图 12-4 二分查找的分治过程
-在实现代码中,我们声明一个递归函数 dfs() 来求解问题 \(f(i, j)\) 。
+在实现代码中,我们声明一个递归函数 dfs() 来求解问题 \(f(i, j)\) :
diff --git a/chapter_divide_and_conquer/build_binary_tree_problem/index.html b/chapter_divide_and_conquer/build_binary_tree_problem/index.html
index e8e285753..d76afa9f0 100644
--- a/chapter_divide_and_conquer/build_binary_tree_problem/index.html
+++ b/chapter_divide_and_conquer/build_binary_tree_problem/index.html
@@ -3412,21 +3412,21 @@
12.3 构建二叉树问题¶
Question
-给定一个二叉树的前序遍历 preorder 和中序遍历 inorder ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
+给定一棵二叉树的前序遍历 preorder 和中序遍历 inorder ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
图 12-5 构建二叉树的示例数据
1. 判断是否为分治问题¶
-原问题定义为从 preorder 和 inorder 构建二叉树,其是一个典型的分治问题。
+原问题定义为从 preorder 和 inorder 构建二叉树,是一个典型的分治问题。
-- 问题可以被分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
-- 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
+- 问题可以分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
+- 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
- 子问题的解可以合并:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。
2. 如何划分子树¶
-根据以上分析,这道题是可以使用分治来求解的,但如何通过前序遍历 preorder 和中序遍历 inorder 来划分左子树和右子树呢?
-根据定义,preorder 和 inorder 都可以被划分为三个部分。
+根据以上分析,这道题可以使用分治来求解,但如何通过前序遍历 preorder 和中序遍历 inorder 来划分左子树和右子树呢?
+根据定义,preorder 和 inorder 都可以划分为三个部分。
- 前序遍历:
[ 根节点 | 左子树 | 右子树 ] ,例如图 12-5 的树对应 [ 3 | 9 | 2 1 7 ] 。
- 中序遍历:
[ 左子树 | 根节点 | 右子树 ] ,例如图 12-5 的树对应 [ 9 | 3 | 1 2 7 ] 。
@@ -3437,8 +3437,8 @@
- 查找根节点 3 在
inorder 中的索引,利用该索引可将 inorder 划分为 [ 9 | 3 | 1 2 7 ] 。
- 根据
inorder 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 preorder 划分为 [ 3 | 9 | 2 1 7 ] 。
-
- 图 12-6 在前序和中序遍历中划分子树
+
+ 图 12-6 在前序遍历和中序遍历中划分子树
3. 基于变量描述子树区间¶
根据以上划分方法,我们已经得到根节点、左子树、右子树在 preorder 和 inorder 中的索引区间。而为了描述这些索引区间,我们需要借助几个指针变量。
@@ -3448,7 +3448,7 @@
- 将当前树在
inorder 中的索引区间记为 \([l, r]\) 。
如表 12-1 所示,通过以上变量即可表示根节点在 preorder 中的索引,以及子树在 inorder 中的索引区间。
- 表 12-1 根节点和子树在前序和中序遍历中的索引
+ 表 12-1 根节点和子树在前序遍历和中序遍历中的索引
@@ -3483,7 +3483,7 @@
图 12-7 根节点和左右子树的索引区间表示
4. 代码实现¶
-为了提升查询 \(m\) 的效率,我们借助一个哈希表 hmap 来存储数组 inorder 中元素到索引的映射。
+为了提升查询 \(m\) 的效率,我们借助一个哈希表 hmap 来存储数组 inorder 中元素到索引的映射:
@@ -3838,7 +3838,7 @@
-图 12-8 展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(即引用)是在向上“归”的过程中建立的。
+图 12-8 展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。
@@ -3877,7 +3877,7 @@
图 12-9 每个递归函数中的划分结果
设树的节点数量为 \(n\) ,初始化每一个节点(执行一个递归函数 dfs() )使用 \(O(1)\) 时间。因此总体时间复杂度为 \(O(n)\) 。
-哈希表存储 inorder 元素到索引的映射,空间复杂度为 \(O(n)\) 。最差情况下,即二叉树退化为链表时,递归深度达到 \(n\) ,使用 \(O(n)\) 的栈帧空间。因此总体空间复杂度为 \(O(n)\) 。
+哈希表存储 inorder 元素到索引的映射,空间复杂度为 \(O(n)\) 。在最差情况下,即二叉树退化为链表时,递归深度达到 \(n\) ,使用 \(O(n)\) 的栈帧空间。因此总体空间复杂度为 \(O(n)\) 。
diff --git a/chapter_divide_and_conquer/divide_and_conquer/index.html b/chapter_divide_and_conquer/divide_and_conquer/index.html
index 28e444dd9..1c83fb4cc 100644
--- a/chapter_divide_and_conquer/divide_and_conquer/index.html
+++ b/chapter_divide_and_conquer/divide_and_conquer/index.html
@@ -3452,21 +3452,21 @@
12.1.1 如何判断分治问题¶
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
-- 问题可以被分解:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
-- 子问题是独立的:子问题之间是没有重叠的,互相没有依赖,可以被独立解决。
-- 子问题的解可以被合并:原问题的解通过合并子问题的解得来。
+- 问题可以分解:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
+- 子问题是独立的:子问题之间没有重叠,互不依赖,可以独立解决。
+- 子问题的解可以合并:原问题的解通过合并子问题的解得来。
-显然,归并排序是满足以上三条判断依据的。
+显然,归并排序满足以上三条判断依据。
-- 问题可以被分解:递归地将数组(原问题)划分为两个子数组(子问题)。
+- 问题可以分解:递归地将数组(原问题)划分为两个子数组(子问题)。
- 子问题是独立的:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
-- 子问题的解可以被合并:两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)。
+- 子问题的解可以合并:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。
12.1.2 通过分治提升效率¶
-分治不仅可以有效地解决算法问题,往往还可以带来算法效率的提升。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
+分治不仅可以有效地解决算法问题,往往还可以提升算法效率。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
那么,我们不禁发问:为什么分治可以提升算法效率,其底层逻辑是什么?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。
1. 操作数量优化¶
-以“冒泡排序”为例,其处理一个长度为 \(n\) 的数组需要 \(O(n^2)\) 时间。假设我们按照图 12-2 所示的方式,将数组从中点分为两个子数组,则划分需要 \(O(n)\) 时间,排序每个子数组需要 \(O((n / 2)^2)\) 时间,合并两个子数组需要 \(O(n)\) 时间,总体时间复杂度为:
+以“冒泡排序”为例,其处理一个长度为 \(n\) 的数组需要 \(O(n^2)\) 时间。假设我们按照图 12-2 所示的方式,将数组从中点处分为两个子数组,则划分需要 \(O(n)\) 时间,排序每个子数组需要 \(O((n / 2)^2)\) 时间,合并两个子数组需要 \(O(n)\) 时间,总体时间复杂度为:
\[
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
\]
@@ -3482,33 +3482,33 @@ n(n - 4) & > 0
\end{aligned}
\]
这意味着当 \(n > 4\) 时,划分后的操作数量更少,排序效率应该更高。请注意,划分后的时间复杂度仍然是平方阶 \(O(n^2)\) ,只是复杂度中的常数项变小了。
-进一步想,如果我们把子数组不断地再从中点划分为两个子数组,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 \(O(n \log n)\) 。
+进一步想,如果我们把子数组不断地再从中点处划分为两个子数组,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 \(O(n \log n)\) 。
再思考,如果我们多设置几个划分点,将原数组平均划分为 \(k\) 个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 \(O(n + k)\) 。
2. 并行计算优化¶
我们知道,分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化。
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
-比如在图 12-3 所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
+比如在图 12-3 所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再合并结果。
图 12-3 桶排序的并行计算
12.1.3 分治常见应用¶
一方面,分治可以用来解决许多经典算法问题。
-- 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对。
-- 大整数乘法:例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法。
-- 矩阵乘法:例如 Strassen 算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法。
-- 汉诺塔问题:汉诺塔问题可以视为典型的分治策略,通过递归解决。
-- 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。
+- 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
+- 大整数乘法:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
+- 矩阵乘法:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
+- 汉诺塔问题:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
+- 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。
另一方面,分治在算法和数据结构的设计中应用非常广泛。
-- 二分查找:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,然后在剩余区间执行相同的二分操作。
-- 归并排序:文章开头已介绍,不再赘述。
-- 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,然后再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
+- 二分查找:二分查找是将有序数组从中点索引处分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,并在剩余区间执行相同的二分操作。
+- 归并排序:本节开头已介绍,不再赘述。
+- 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
- 桶排序:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
-- 树:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治的应用。
+- 树:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治策略的应用。
- 堆:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
-- 哈希表:虽然哈希表来并不直接应用分治,但某些哈希冲突解决策略间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
+- 哈希表:虽然哈希表来并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,分治是一种“润物细无声”的算法思想,隐含在各种算法与数据结构之中。
diff --git a/chapter_divide_and_conquer/hanota_problem/index.html b/chapter_divide_and_conquer/hanota_problem/index.html
index f9661754f..f17e1b592 100644
--- a/chapter_divide_and_conquer/hanota_problem/index.html
+++ b/chapter_divide_and_conquer/hanota_problem/index.html
@@ -3399,9 +3399,9 @@
在归并排序和构建二叉树中,我们都是将原问题分解为两个规模为原问题一半的子问题。然而对于汉诺塔问题,我们采用不同的分解策略。
Question
-给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 \(n\) 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 \(n\) 个圆盘移到柱子 C 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则。
+给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 \(n\) 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 \(n\) 个圆盘移到柱子 C 上,并保持它们的原有顺序不变(如图 12-10 所示)。在移动圆盘的过程中,需要遵守以下规则。
-- 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入。
+- 圆盘只能从一根柱子顶部拿出,从另一根柱子顶部放入。
- 每次只能移动一个圆盘。
- 小圆盘必须时刻位于大圆盘之上。
@@ -3409,7 +3409,7 @@
图 12-10 汉诺塔问题示例
-我们将规模为 \(i\) 的汉诺塔问题记做 \(f(i)\) 。例如 \(f(3)\) 代表将 \(3\) 个圆盘从 A 移动至 C 的汉诺塔问题。
+我们将规模为 \(i\) 的汉诺塔问题记作 \(f(i)\) 。例如 \(f(3)\) 代表将 \(3\) 个圆盘从 A 移动至 C 的汉诺塔问题。
1. 考虑基本情况¶
如图 12-11 所示,对于问题 \(f(1)\) ,即当只有一个圆盘时,我们将它直接从 A 移动至 C 即可。
@@ -3451,11 +3451,11 @@
解决问题 \(f(2)\) 的过程可总结为:将两个圆盘借助 B 从 A 移至 C 。其中,C 称为目标柱、B 称为缓冲柱。
2. 子问题分解¶
对于问题 \(f(3)\) ,即当有三个圆盘时,情况变得稍微复杂了一些。
-因为已知 \(f(1)\) 和 \(f(2)\) 的解,所以我们可从分治角度思考,将 A 顶部的两个圆盘看做一个整体,执行图 12-13 所示的步骤。这样三个圆盘就被顺利地从 A 移动至 C 了。
+因为已知 \(f(1)\) 和 \(f(2)\) 的解,所以我们可从分治角度思考,将 A 顶部的两个圆盘看作一个整体,执行图 12-13 所示的步骤。这样三个圆盘就被顺利地从 A 移至 C 了。
-- 令
B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移动至 B 。
+- 令
B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移至 B 。
- 将
A 中剩余的一个圆盘从 A 直接移动至 C 。
-- 令
C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移动至 C 。
+- 令
C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移至 C 。
@@ -3475,19 +3475,19 @@
图 12-13 规模为 3 问题的解
-本质上看,我们将问题 \(f(3)\) 划分为两个子问题 \(f(2)\) 和子问题 \(f(1)\) 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。
-至此,我们可总结出图 12-14 所示的汉诺塔问题的分治策略:将原问题 \(f(n)\) 划分为两个子问题 \(f(n-1)\) 和一个子问题 \(f(1)\) ,并按照以下顺序解决这三个子问题。
+从本质上看,我们将问题 \(f(3)\) 划分为两个子问题 \(f(2)\) 和子问题 \(f(1)\) 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
+至此,我们可总结出图 12-14 所示的解决汉诺塔问题的分治策略:将原问题 \(f(n)\) 划分为两个子问题 \(f(n-1)\) 和一个子问题 \(f(1)\) ,并按照以下顺序解决这三个子问题。
- 将 \(n-1\) 个圆盘借助
C 从 A 移至 B 。
- 将剩余 \(1\) 个圆盘从
A 直接移至 C 。
- 将 \(n-1\) 个圆盘借助
A 从 B 移至 C 。
对于这两个子问题 \(f(n-1)\) ,可以通过相同的方式进行递归划分,直至达到最小子问题 \(f(1)\) 。而 \(f(1)\) 的解是已知的,只需一次移动操作即可。
-
- 图 12-14 汉诺塔问题的分治策略
+
+ 图 12-14 解决汉诺塔问题的分治策略
3. 代码实现¶
-在代码中,我们声明一个递归函数 dfs(i, src, buf, tar) ,它的作用是将柱 src 顶部的 \(i\) 个圆盘借助缓冲柱 buf 移动至目标柱 tar 。
+在代码中,我们声明一个递归函数 dfs(i, src, buf, tar) ,它的作用是将柱 src 顶部的 \(i\) 个圆盘借助缓冲柱 buf 移动至目标柱 tar :
@@ -3499,7 +3499,7 @@
tar.append(pan)
def dfs(i: int, src: list[int], buf: list[int], tar: list[int]):
- """求解汉诺塔:问题 f(i)"""
+ """求解汉诺塔问题 f(i)"""
# 若 src 只剩下一个圆盘,则直接将其移到 tar
if i == 1:
move(src, tar)
@@ -3512,7 +3512,7 @@
dfs(i - 1, buf, src, tar)
def solve_hanota(A: list[int], B: list[int], C: list[int]):
- """求解汉诺塔"""
+ """求解汉诺塔问题"""
n = len(A)
# 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(n, A, B, C)
@@ -3528,7 +3528,7 @@
tar.push_back(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
@@ -3543,7 +3543,7 @@
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
@@ -3560,7 +3560,7 @@
tar.add(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
void dfs(int i, List<Integer> src, List<Integer> buf, List<Integer> tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
@@ -3575,7 +3575,7 @@
dfs(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
void solveHanota(List<Integer> A, List<Integer> B, List<Integer> C) {
int n = A.size();
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3593,7 +3593,7 @@
tar.Add(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
void DFS(int i, List<int> src, List<int> buf, List<int> tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
@@ -3608,7 +3608,7 @@
DFS(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
void SolveHanota(List<int> A, List<int> B, List<int> C) {
int n = A.Count;
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3627,7 +3627,7 @@
src.Remove(pan)
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
func dfsHanota(i int, src, buf, tar *list.List) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if i == 1 {
@@ -3642,7 +3642,7 @@
dfsHanota(i-1, buf, src, tar)
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
func solveHanota(A, B, C *list.List) {
n := A.Len()
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3659,7 +3659,7 @@
tar.append(pan)
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
func dfs(i: Int, src: inout [Int], buf: inout [Int], tar: inout [Int]) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if i == 1 {
@@ -3674,7 +3674,7 @@
dfs(i: i - 1, src: &buf, buf: &src, tar: &tar)
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
func solveHanota(A: inout [Int], B: inout [Int], C: inout [Int]) {
let n = A.count
// 列表尾部是柱子顶部
@@ -3692,7 +3692,7 @@
tar.push(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
function dfs(i, src, buf, tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i === 1) {
@@ -3707,7 +3707,7 @@
dfs(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
function solveHanota(A, B, C) {
const n = A.length;
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3724,7 +3724,7 @@
tar.push(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
function dfs(i: number, src: number[], buf: number[], tar: number[]): void {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i === 1) {
@@ -3739,7 +3739,7 @@
dfs(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
function solveHanota(A: number[], B: number[], C: number[]): void {
const n = A.length;
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3756,7 +3756,7 @@
tar.add(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
void dfs(int i, List<int> src, List<int> buf, List<int> tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
@@ -3771,7 +3771,7 @@
dfs(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
void solveHanota(List<int> A, List<int> B, List<int> C) {
int n = A.length;
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3788,7 +3788,7 @@
tar.push(pan);
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
fn dfs(i: i32, src: &mut Vec<i32>, buf: &mut Vec<i32>, tar: &mut Vec<i32>) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if i == 1 {
@@ -3803,7 +3803,7 @@
dfs(i - 1, buf, src, tar);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
fn solve_hanota(A: &mut Vec<i32>, B: &mut Vec<i32>, C: &mut Vec<i32>) {
let n = A.len() as i32;
// 将 A 顶部 n 个圆盘借助 B 移到 C
@@ -3823,7 +3823,7 @@
(*tarSize)++;
}
-/* 求解汉诺塔:问题 f(i) */
+/* 求解汉诺塔问题 f(i) */
void dfs(int i, int *src, int *srcSize, int *buf, int *bufSize, int *tar, int *tarSize) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
@@ -3838,7 +3838,7 @@
dfs(i - 1, buf, bufSize, src, srcSize, tar, tarSize);
}
-/* 求解汉诺塔 */
+/* 求解汉诺塔问题 */
void solveHanota(int *A, int *ASize, int *B, int *BSize, int *C, int *CSize) {
// 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(*ASize, A, ASize, B, BSize, C, CSize);
@@ -3855,13 +3855,13 @@
-如图 12-15 所示,汉诺塔问题形成一个高度为 \(n\) 的递归树,每个节点代表一个子问题、对应一个开启的 dfs() 函数,因此时间复杂度为 \(O(2^n)\) ,空间复杂度为 \(O(n)\) 。
+如图 12-15 所示,汉诺塔问题形成一棵高度为 \(n\) 的递归树,每个节点代表一个子问题,对应一个开启的 dfs() 函数,因此时间复杂度为 \(O(2^n)\) ,空间复杂度为 \(O(n)\) 。
图 12-15 汉诺塔问题的递归树
Quote
-汉诺塔问题源自一种古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 \(64\) 个大小不一的金圆盘。僧侣们不断地移动原盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
+汉诺塔问题源自一个古老的传说。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 \(64\) 个大小不一的金圆盘。僧侣们不断地移动圆盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
然而,即使僧侣们每秒钟移动一次,总共需要大约 \(2^{64} \approx 1.84×10^{19}\) 秒,合约 \(5850\) 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。
diff --git a/chapter_divide_and_conquer/summary/index.html b/chapter_divide_and_conquer/summary/index.html
index f155a774c..09e744614 100644
--- a/chapter_divide_and_conquer/summary/index.html
+++ b/chapter_divide_and_conquer/summary/index.html
@@ -3317,15 +3317,15 @@
12.5 小结¶
-- 分治算法是一种常见的算法设计策略,包括分(划分)和治(合并)两个阶段,通常基于递归实现。
-- 判断是否是分治算法问题的依据包括:问题能否被分解、子问题是否独立、子问题是否可以被合并。
+- 分治是一种常见的算法设计策略,包括分(划分)和治(合并)两个阶段,通常基于递归实现。
+- 判断是否是分治算法问题的依据包括:问题能否分解、子问题是否独立、子问题能否合并。
- 归并排序是分治策略的典型应用,其递归地将数组划分为等长的两个子数组,直到只剩一个元素时开始逐层合并,从而完成排序。
-- 引入分治策略往往可以带来算法效率的提升。一方面,分治策略减少了操作数量;另一方面,分治后有利于系统的并行优化。
+- 引入分治策略往往可以提升算法效率。一方面,分治策略减少了操作数量;另一方面,分治后有利于系统的并行优化。
- 分治既可以解决许多算法问题,也广泛应用于数据结构与算法设计中,处处可见其身影。
-- 相较于暴力搜索,自适应搜索效率更高。时间复杂度为 \(O(\log n)\) 的搜索算法通常都是基于分治策略实现的。
+- 相较于暴力搜索,自适应搜索效率更高。时间复杂度为 \(O(\log n)\) 的搜索算法通常是基于分治策略实现的。
- 二分查找是分治策略的另一个典型应用,它不包含将子问题的解进行合并的步骤。我们可以通过递归分治实现二分查找。
-- 在构建二叉树问题中,构建树(原问题)可以被划分为构建左子树和右子树(子问题),其可以通过划分前序遍历和中序遍历的索引区间来实现。
-- 在汉诺塔问题中,一个规模为 \(n\) 的问题可以被划分为两个规模为 \(n-1\) 的子问题和一个规模为 \(1\) 的子问题。按顺序解决这三个子问题后,原问题随之得到解决。
+- 在构建二叉树的问题中,构建树(原问题)可以划分为构建左子树和右子树(子问题),这可以通过划分前序遍历和中序遍历的索引区间来实现。
+- 在汉诺塔问题中,一个规模为 \(n\) 的问题可以划分为两个规模为 \(n-1\) 的子问题和一个规模为 \(1\) 的子问题。按顺序解决这三个子问题后,原问题随之得到解决。
diff --git a/chapter_dynamic_programming/dp_problem_features/index.html b/chapter_dynamic_programming/dp_problem_features/index.html
index fd93dbbe3..f8007160d 100644
--- a/chapter_dynamic_programming/dp_problem_features/index.html
+++ b/chapter_dynamic_programming/dp_problem_features/index.html
@@ -3382,18 +3382,18 @@
14.2 动态规划问题特性¶
-在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
+在上一节中,我们学习了动态规划是如何通过子问题分解来求解原问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
-- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。
+- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
14.2.1 最优子结构¶
我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
爬楼梯最小代价
-给定一个楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 \(cost\) ,其中 \(cost[i]\) 表示在第 \(i\) 个台阶需要付出的代价,\(cost[0]\) 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
+给定一个楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 \(cost\) ,其中 \(cost[i]\) 表示在第 \(i\) 个台阶需要付出的代价,\(cost[0]\) 为地面(起始点)。请计算最少需要付出多少代价才能到达顶部?
如图 14-6 所示,若第 \(1\)、\(2\)、\(3\) 阶的代价分别为 \(1\)、\(10\)、\(1\) ,则从地面爬到第 \(3\) 阶的最小代价为 \(2\) 。
@@ -3405,8 +3405,8 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
\]
这便可以引出最优子结构的含义:原问题的最优解是从子问题的最优解构建得来的。
本题显然具有最优子结构:我们从两个子问题最优解 \(dp[i-1]\) 和 \(dp[i-2]\) 中挑选出较优的那一个,并用它构建出原问题 \(dp[i]\) 的最优解。
-那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 \(n\) 阶最大方案数量等于第 \(n-1\) 阶和第 \(n-2\) 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
-根据状态转移方程,以及初始状态 \(dp[1] = cost[1]\) 和 \(dp[2] = cost[2]\) ,我们就可以得到动态规划代码。
+那么,上一节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 \(n\) 阶最大方案数量等于第 \(n-1\) 阶和第 \(n-2\) 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
+根据状态转移方程,以及初始状态 \(dp[1] = cost[1]\) 和 \(dp[2] = cost[2]\) ,我们就可以得到动态规划代码:
@@ -3652,7 +3652,7 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
图 14-7 爬楼梯最小代价的动态规划过程
-本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
+本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 \(O(n)\) 降至 \(O(1)\) :
@@ -3859,20 +3859,20 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
14.2.2 无后效性¶
-无后效性是动态规划能够有效解决问题的重要特性之一,定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关。
+无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。
以爬楼梯问题为例,给定状态 \(i\) ,它会发展出状态 \(i+1\) 和状态 \(i+2\) ,分别对应跳 \(1\) 步和跳 \(2\) 步。在做出这两种选择时,我们无须考虑状态 \(i\) 之前的状态,它们对状态 \(i\) 的未来没有影响。
-然而,如果我们向爬楼梯问题添加一个约束,情况就不一样了。
+然而,如果我们给爬楼梯问题添加一个约束,情况就不一样了。
带约束爬楼梯
-给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,但不能连续两轮跳 \(1\) 阶,请问有多少种方案可以爬到楼顶。
+给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,但不能连续两轮跳 \(1\) 阶,请问有多少种方案可以爬到楼顶?
-例如图 14-8 ,爬上第 \(3\) 阶仅剩 \(2\) 种可行方案,其中连续三次跳 \(1\) 阶的方案不满足约束条件,因此被舍弃。
+如图 14-8 所示,爬上第 \(3\) 阶仅剩 \(2\) 种可行方案,其中连续三次跳 \(1\) 阶的方案不满足约束条件,因此被舍弃。
图 14-8 带约束爬到第 3 阶的方案数量
-在该问题中,如果上一轮是跳 \(1\) 阶上来的,那么下一轮就必须跳 \(2\) 阶。这意味着,下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关。
-不难发现,此问题已不满足无后效性,状态转移方程 \(dp[i] = dp[i-1] + dp[i-2]\) 也失效了,因为 \(dp[i-1]\) 代表本轮跳 \(1\) 阶,但其中包含了许多“上一轮跳 \(1\) 阶上来的”方案,而为了满足约束,我们就不能将 \(dp[i-1]\) 直接计入 \(dp[i]\) 中。
-为此,我们需要扩展状态定义:状态 \([i, j]\) 表示处在第 \(i\) 阶、并且上一轮跳了 \(j\) 阶,其中 \(j \in \{1, 2\}\) 。此状态定义有效地区分了上一轮跳了 \(1\) 阶还是 \(2\) 阶,我们可以据此来判断当前状态是从何而来的。
+在该问题中,如果上一轮是跳 \(1\) 阶上来的,那么下一轮就必须跳 \(2\) 阶。这意味着,下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上轮所在楼梯阶数)有关。
+不难发现,此问题已不满足无后效性,状态转移方程 \(dp[i] = dp[i-1] + dp[i-2]\) 也失效了,因为 \(dp[i-1]\) 代表本轮跳 \(1\) 阶,但其中包含了许多“上一轮是跳 \(1\) 阶上来的”方案,而为了满足约束,我们就不能将 \(dp[i-1]\) 直接计入 \(dp[i]\) 中。
+为此,我们需要扩展状态定义:状态 \([i, j]\) 表示处在第 \(i\) 阶并且上一轮跳了 \(j\) 阶,其中 \(j \in \{1, 2\}\) 。此状态定义有效地区分了上一轮跳了 \(1\) 阶还是 \(2\) 阶,我们可以据此来判断当前状态是从何而来的。
- 当上一轮跳了 \(1\) 阶时,上上一轮只能选择跳 \(2\) 阶,即 \(dp[i, 1]\) 只能从 \(dp[i-1, 2]\) 转移过来。
- 当上一轮跳了 \(2\) 阶时,上上一轮可选择跳 \(1\) 阶或跳 \(2\) 阶,即 \(dp[i, 2]\) 可以从 \(dp[i-2, 1]\) 或 \(dp[i-2, 2]\) 转移过来。
@@ -3887,7 +3887,7 @@ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
图 14-9 考虑约束下的递推关系
-最终,返回 \(dp[n, 1] + dp[n, 2]\) 即可,两者之和代表爬到第 \(n\) 阶的方案总数。
+最终,返回 \(dp[n, 1] + dp[n, 2]\) 即可,两者之和代表爬到第 \(n\) 阶的方案总数:
@@ -4158,13 +4158,13 @@ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
-在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
+在上面的案例中,由于仅需多考虑前面一个状态,因此我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
爬楼梯与障碍生成
-给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶。规定当爬到第 \(i\) 阶时,系统自动会给第 \(2i\) 阶上放上障碍物,之后所有轮都不允许跳到第 \(2i\) 阶上。例如,前两轮分别跳到了第 \(2\)、\(3\) 阶上,则之后就不能跳到第 \(4\)、\(6\) 阶上。请问有多少种方案可以爬到楼顶。
+给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶。规定当爬到第 \(i\) 阶时,系统自动会在第 \(2i\) 阶上放上障碍物,之后所有轮都不允许跳到第 \(2i\) 阶上。例如,前两轮分别跳到了第 \(2\)、\(3\) 阶上,则之后就不能跳到第 \(4\)、\(6\) 阶上。请问有多少种方案可以爬到楼顶?
-在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
-实际上,许多复杂的组合优化问题(例如旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
+在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
+实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
diff --git a/chapter_dynamic_programming/dp_solution_pipeline/index.html b/chapter_dynamic_programming/dp_solution_pipeline/index.html
index f42b08e87..612107029 100644
--- a/chapter_dynamic_programming/dp_solution_pipeline/index.html
+++ b/chapter_dynamic_programming/dp_solution_pipeline/index.html
@@ -3456,7 +3456,7 @@
- 求解动态规划问题该从何处入手,完整步骤是什么?
14.3.1 问题判断¶
-总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解。然而,我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
+总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述中直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
在此基础上,动态规划问题还有一些判断的“加分项”。
@@ -3482,7 +3482,7 @@
图 14-10 最小路径和示例数据
第一步:思考每轮的决策,定义状态,从而得到 \(dp\) 表
-本题的每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为 \([i, j]\) ,则向下或向右走一步后,索引变为 \([i+1, j]\) 或 \([i, j+1]\) 。因此,状态应包含行索引和列索引两个变量,记为 \([i, j]\) 。
+本题的每一轮的决策就是从当前格子向下或向右走一步。设当前格子的行列索引为 \([i, j]\) ,则向下或向右走一步后,索引变为 \([i+1, j]\) 或 \([i, j+1]\) 。因此,状态应包含行索引和列索引两个变量,记为 \([i, j]\) 。
状态 \([i, j]\) 对应的子问题为:从起始点 \([0, 0]\) 走到 \([i, j]\) 的最小路径和,解记为 \(dp[i, j]\) 。
至此,我们就得到了图 14-11 所示的二维 \(dp\) 矩阵,其尺寸与输入网格 \(grid\) 相同。
@@ -3490,11 +3490,11 @@
Note
-动态规划和回溯过程可以被描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
-每个状态都对应一个子问题,我们会定义一个 \(dp\) 表来存储所有子问题的解,状态的每个独立变量都是 \(dp\) 表的一个维度。本质上看,\(dp\) 表是状态和子问题的解之间的映射。
+动态规划和回溯过程可以描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
+每个状态都对应一个子问题,我们会定义一个 \(dp\) 表来存储所有子问题的解,状态的每个独立变量都是 \(dp\) 表的一个维度。从本质上看,\(dp\) 表是状态和子问题的解之间的映射。
第二步:找出最优子结构,进而推导出状态转移方程
-对于状态 \([i, j]\) ,它只能从上边格子 \([i-1, j]\) 和左边格子 \([i, j-1]\) 转移而来。因此最优子结构为:到达 \([i, j]\) 的最小路径和由 \([i, j-1]\) 的最小路径和与 \([i-1, j]\) 的最小路径和,这两者较小的那一个决定。
+对于状态 \([i, j]\) ,它只能从上边格子 \([i-1, j]\) 和左边格子 \([i, j-1]\) 转移而来。因此最优子结构为:到达 \([i, j]\) 的最小路径和由 \([i, j-1]\) 的最小路径和与 \([i-1, j]\) 的最小路径和中较小的那一个决定。
根据以上分析,可推出图 14-12 所示的状态转移方程:
\[
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
@@ -3508,8 +3508,8 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
一旦我们找到了最优子结构,就可以使用它来构建出状态转移方程。
第三步:确定边界条件和状态转移顺序
-在本题中,首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行 \(i = 0\) 和首列 \(j = 0\) 是边界条件。
-如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
+在本题中,处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行 \(i = 0\) 和首列 \(j = 0\) 是边界条件。
+如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵,外循环遍历各行,内循环遍历各列。
图 14-13 边界条件与状态转移顺序
@@ -3527,6 +3527,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
- 终止条件:当 \(i = 0\) 且 \(j = 0\) 时,返回代价 \(grid[0, 0]\) 。
- 剪枝:当 \(i < 0\) 时或 \(j < 0\) 时索引越界,此时返回代价 \(+\infty\) ,代表不可行。
+实现代码如下:
@@ -3762,13 +3763,13 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
图 14-14 给出了以 \(dp[2, 1]\) 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。
-本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。
+从本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。
图 14-14 暴力搜索递归树
-每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
+每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择,因此实际的路径数量会少一些。
2. 方法二:记忆化搜索¶
-我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝。
+我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝:
@@ -4071,7 +4072,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
图 14-15 记忆化搜索递归树
3. 方法三:动态规划¶
-基于迭代实现动态规划解法。
+基于迭代实现动态规划解法,代码如下所示:
@@ -4087,7 +4088,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
# 状态转移:首列
for i in range(1, n):
dp[i][0] = dp[i - 1][0] + grid[i][0]
- # 状态转移:其余行列
+ # 状态转移:其余行和列
for i in range(1, n):
for j in range(1, m):
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]
@@ -4109,7 +4110,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, 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];
@@ -4134,7 +4135,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, 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] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4159,7 +4160,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, 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] = Math.Min(dp[i, j - 1], dp[i - 1, j]) + grid[i][j];
@@ -4187,7 +4188,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for i := 1; i < n; i++ {
dp[i][0] = dp[i-1][0] + grid[i][0]
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i := 1; i < n; i++ {
for j := 1; j < m; j++ {
dp[i][j] = int(math.Min(float64(dp[i][j-1]), float64(dp[i-1][j]))) + grid[i][j]
@@ -4213,7 +4214,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for i in stride(from: 1, to: n, by: 1) {
dp[i][0] = dp[i - 1][0] + grid[i][0]
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in stride(from: 1, to: n, by: 1) {
for j in stride(from: 1, to: m, by: 1) {
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]
@@ -4241,7 +4242,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for (let i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i < n; i++) {
for (let j = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4269,7 +4270,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for (let i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i < n; i++) {
for (let j: number = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4294,7 +4295,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, 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];
@@ -4319,7 +4320,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for i in 1..n {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in 1..n {
for j in 1..m {
dp[i][j] = std::cmp::min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4346,7 +4347,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, 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] = myMin(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4377,7 +4378,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for (1..n) |i| {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (1..n) |i| {
for (1..m) |j| {
dp[i][j] = @min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
@@ -4435,7 +4436,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
4. 空间优化¶
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 \(dp\) 表。
-请注意,因为数组 dp 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行中更新它。
+请注意,因为数组 dp 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行时更新它:
@@ -4541,7 +4542,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
for j := 1; j < m; j++ {
dp[j] = dp[j-1] + grid[0][j]
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i := 1; i < n; i++ {
// 状态转移:首列
dp[0] = dp[0] + grid[i][0]
diff --git a/chapter_dynamic_programming/edit_distance_problem/index.html b/chapter_dynamic_programming/edit_distance_problem/index.html
index d961b23eb..61a0404ad 100644
--- a/chapter_dynamic_programming/edit_distance_problem/index.html
+++ b/chapter_dynamic_programming/edit_distance_problem/index.html
@@ -3396,11 +3396,11 @@
14.6 编辑距离问题¶
-编辑距离,也被称为 Levenshtein 距离,指两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
+编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
Question
输入两个字符串 \(s\) 和 \(t\) ,返回将 \(s\) 转换为 \(t\) 所需的最少编辑步数。
-你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。
+你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、将字符替换为任意一个字符。
如图 14-27 所示,将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。
@@ -3420,7 +3420,7 @@
- 若 \(s[n-1]\) 和 \(t[m-1]\) 相同,我们可以跳过它们,直接考虑 \(s[n-2]\) 和 \(t[m-2]\) 。
- 若 \(s[n-1]\) 和 \(t[m-1]\) 不同,我们需要对 \(s\) 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
-也就是说,我们在字符串 \(s\) 中进行的每一轮决策(编辑操作),都会使得 \(s\) 和 \(t\) 中剩余的待匹配字符发生变化。因此,状态为当前在 \(s\) 和 \(t\) 中考虑的第 \(i\) 和 \(j\) 个字符,记为 \([i, j]\) 。
+也就是说,我们在字符串 \(s\) 中进行的每一轮决策(编辑操作),都会使得 \(s\) 和 \(t\) 中剩余的待匹配字符发生变化。因此,状态为当前在 \(s\) 和 \(t\) 中考虑的第 \(i\) 和第 \(j\) 个字符,记为 \([i, j]\) 。
状态 \([i, j]\) 对应的子问题:将 \(s\) 的前 \(i\) 个字符更改为 \(t\) 的前 \(j\) 个字符所需的最少编辑步数。
至此,得到一个尺寸为 \((i+1) \times (j+1)\) 的二维 \(dp\) 表。
第二步:找出最优子结构,进而推导出状态转移方程
@@ -3442,7 +3442,7 @@ dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1
dp[i, j] = dp[i-1, j-1]
\]
第三步:确定边界条件和状态转移顺序
-当两字符串都为空时,编辑步数为 \(0\) ,即 \(dp[0, 0] = 0\) 。当 \(s\) 为空但 \(t\) 不为空时,最少编辑步数等于 \(t\) 的长度,即首行 \(dp[0, j] = j\) 。当 \(s\) 不为空但 \(t\) 为空时,等于 \(s\) 的长度,即首列 \(dp[i, 0] = i\) 。
+当两字符串都为空时,编辑步数为 \(0\) ,即 \(dp[0, 0] = 0\) 。当 \(s\) 为空但 \(t\) 不为空时,最少编辑步数等于 \(t\) 的长度,即首行 \(dp[0, j] = j\) 。当 \(s\) 不为空但 \(t\) 为空时,最少编辑步数等于 \(s\) 的长度,即首列 \(dp[i, 0] = i\) 。
观察状态转移方程,解 \(dp[i, j]\) 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 \(dp\) 表即可。
2. 代码实现¶
@@ -3457,7 +3457,7 @@ dp[i, j] = dp[i-1, j-1]
dp[i][0] = i
for j in range(1, m + 1):
dp[0][j] = j
- # 状态转移:其余行列
+ # 状态转移:其余行和列
for i in range(1, n + 1):
for j in range(1, m + 1):
if s[i - 1] == t[j - 1]:
@@ -3481,7 +3481,7 @@ dp[i, j] = dp[i-1, j-1]
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]) {
@@ -3509,7 +3509,7 @@ dp[i, j] = dp[i-1, j-1]
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.charAt(i - 1) == t.charAt(j - 1)) {
@@ -3537,7 +3537,7 @@ dp[i, j] = dp[i-1, j-1]
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]) {
@@ -3569,7 +3569,7 @@ dp[i, j] = dp[i-1, j-1]
for j := 1; j <= m; j++ {
dp[0][j] = j
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if s[i-1] == t[j-1] {
@@ -3598,7 +3598,7 @@ dp[i, j] = dp[i-1, j-1]
for j in stride(from: 1, through: m, by: 1) {
dp[0][j] = j
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in stride(from: 1, through: n, by: 1) {
for j in stride(from: 1, through: m, by: 1) {
if s.utf8CString[i - 1] == t.utf8CString[j - 1] {
@@ -3627,7 +3627,7 @@ dp[i, j] = dp[i-1, j-1]
for (let j = 1; j <= m; j++) {
dp[0][j] = j;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s.charAt(i - 1) === t.charAt(j - 1)) {
@@ -3659,7 +3659,7 @@ dp[i, j] = dp[i-1, j-1]
for (let j = 1; j <= m; j++) {
dp[0][j] = j;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s.charAt(i - 1) === t.charAt(j - 1)) {
@@ -3688,7 +3688,7 @@ dp[i, j] = dp[i-1, j-1]
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]) {
@@ -3716,7 +3716,7 @@ dp[i, j] = dp[i-1, j-1]
for j in 1..m {
dp[0][j] = j as i32;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in 1..=n {
for j in 1..=m {
if s.chars().nth(i - 1) == t.chars().nth(j - 1) {
@@ -3746,7 +3746,7 @@ dp[i, j] = dp[i-1, j-1]
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]) {
@@ -3780,7 +3780,7 @@ dp[i, j] = dp[i-1, j-1]
for (1..m + 1) |j| {
dp[0][j] = @intCast(j);
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (1..n + 1) |i| {
for (1..m + 1) |j| {
if (s[i - 1] == t[j - 1]) {
@@ -3798,7 +3798,7 @@ dp[i, j] = dp[i-1, j-1]
-如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。
+如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作填写一个二维网格的过程。
@@ -3851,8 +3851,8 @@ dp[i, j] = dp[i-1, j-1]
图 14-30 编辑距离的动态规划过程
3. 空间优化¶
-由于 \(dp[i,j]\) 是由上方 \(dp[i-1, j]\)、左方 \(dp[i, j-1]\)、左上方状态 \(dp[i-1, j-1]\) 转移而来,而正序遍历会丢失左上方 \(dp[i-1, j-1]\) ,倒序遍历无法提前构建 \(dp[i, j-1]\) ,因此两种遍历顺序都不可取。
-为此,我们可以使用一个变量 leftup 来暂存左上方的解 \(dp[i-1, j-1]\) ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。
+由于 \(dp[i,j]\) 是由上方 \(dp[i-1, j]\)、左方 \(dp[i, j-1]\)、左上方 \(dp[i-1, j-1]\) 转移而来的,而正序遍历会丢失左上方 \(dp[i-1, j-1]\) ,倒序遍历无法提前构建 \(dp[i, j-1]\) ,因此两种遍历顺序都不可取。
+为此,我们可以使用一个变量 leftup 来暂存左上方的解 \(dp[i-1, j-1]\) ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。代码如下所示:
diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
index b2633041b..b02efa9c4 100644
--- a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
+++ b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
@@ -3414,13 +3414,13 @@
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。
爬楼梯
-给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,请问有多少种方案可以爬到楼顶。
+给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,请问有多少种方案可以爬到楼顶?
如图 14-1 所示,对于一个 \(3\) 阶楼梯,共有 \(3\) 种方案可以爬到楼顶。
图 14-1 爬到第 3 阶的方案数量
-本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 \(1\) 阶或 \(2\) 阶,每当到达楼梯顶部时就将方案数量加 \(1\) ,当越过楼梯顶部时就将其剪枝。
+本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 \(1\) 阶或 \(2\) 阶,每当到达楼梯顶部时就将方案数量加 \(1\) ,当越过楼梯顶部时就将其剪枝。代码如下所示:
@@ -3440,7 +3440,7 @@
def climbing_stairs_backtrack(n: int) -> int:
"""爬楼梯:回溯"""
- choices = [1, 2] # 可选择向上爬 1 或 2 阶
+ choices = [1, 2] # 可选择向上爬 1 阶或 2 阶
state = 0 # 从第 0 阶开始爬
res = [0] # 使用 res[0] 记录方案数量
backtrack(choices, state, n, res)
@@ -3466,7 +3466,7 @@
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
- vector<int> choices = {1, 2}; // 可选择向上爬 1 或 2 阶
+ vector<int> choices = {1, 2}; // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
vector<int> res = {0}; // 使用 res[0] 记录方案数量
backtrack(choices, state, n, res);
@@ -3493,7 +3493,7 @@
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
- List<Integer> choices = Arrays.asList(1, 2); // 可选择向上爬 1 或 2 阶
+ List<Integer> choices = Arrays.asList(1, 2); // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
List<Integer> res = new ArrayList<>();
res.add(0); // 使用 res[0] 记录方案数量
@@ -3521,7 +3521,7 @@
/* 爬楼梯:回溯 */
int ClimbingStairsBacktrack(int n) {
- List<int> choices = [1, 2]; // 可选择向上爬 1 或 2 阶
+ List<int> choices = [1, 2]; // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
List<int> res = [0]; // 使用 res[0] 记录方案数量
Backtrack(choices, state, n, res);
@@ -3550,7 +3550,7 @@
/* 爬楼梯:回溯 */
func climbingStairsBacktrack(n int) int {
- // 可选择向上爬 1 或 2 阶
+ // 可选择向上爬 1 阶或 2 阶
choices := []int{1, 2}
// 从第 0 阶开始爬
state := 0
@@ -3581,7 +3581,7 @@
/* 爬楼梯:回溯 */
func climbingStairsBacktrack(n: Int) -> Int {
- let choices = [1, 2] // 可选择向上爬 1 或 2 阶
+ let choices = [1, 2] // 可选择向上爬 1 阶或 2 阶
let state = 0 // 从第 0 阶开始爬
var res: [Int] = []
res.append(0) // 使用 res[0] 记录方案数量
@@ -3607,7 +3607,7 @@
/* 爬楼梯:回溯 */
function climbingStairsBacktrack(n) {
- const choices = [1, 2]; // 可选择向上爬 1 或 2 阶
+ const choices = [1, 2]; // 可选择向上爬 1 阶或 2 阶
const state = 0; // 从第 0 阶开始爬
const res = new Map();
res.set(0, 0); // 使用 res[0] 记录方案数量
@@ -3638,7 +3638,7 @@
/* 爬楼梯:回溯 */
function climbingStairsBacktrack(n: number): number {
- const choices = [1, 2]; // 可选择向上爬 1 或 2 阶
+ const choices = [1, 2]; // 可选择向上爬 1 阶或 2 阶
const state = 0; // 从第 0 阶开始爬
const res = new Map();
res.set(0, 0); // 使用 res[0] 记录方案数量
@@ -3666,7 +3666,7 @@
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
- List<int> choices = [1, 2]; // 可选择向上爬 1 或 2 阶
+ List<int> choices = [1, 2]; // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
List<int> res = [];
res.add(0); // 使用 res[0] 记录方案数量
@@ -3692,7 +3692,7 @@
/* 爬楼梯:回溯 */
fn climbing_stairs_backtrack(n: usize) -> i32 {
- let choices = vec![ 1, 2 ]; // 可选择向上爬 1 或 2 阶
+ let choices = vec![ 1, 2 ]; // 可选择向上爬 1 阶或 2 阶
let state = 0; // 从第 0 阶开始爬
let mut res = Vec::new();
res.push(0); // 使用 res[0] 记录方案数量
@@ -3721,7 +3721,7 @@
/* 爬楼梯:回溯 */
int climbingStairsBacktrack(int n) {
- int choices[2] = {1, 2}; // 可选择向上爬 1 或 2 阶
+ int choices[2] = {1, 2}; // 可选择向上爬 1 阶或 2 阶
int state = 0; // 从第 0 阶开始爬
int *res = (int *)malloc(sizeof(int));
*res = 0; // 使用 res[0] 记录方案数量
@@ -3754,7 +3754,7 @@
// 爬楼梯:回溯
fn climbingStairsBacktrack(n: usize) !i32 {
- var choices = [_]i32{ 1, 2 }; // 可选择向上爬 1 或 2 阶
+ var choices = [_]i32{ 1, 2 }; // 可选择向上爬 1 阶或 2 阶
var state: i32 = 0; // 从第 0 阶开始爬
var res = std.ArrayList(i32).init(std.heap.page_allocator);
defer res.deinit();
@@ -3767,12 +3767,12 @@
14.1.1 方法一:暴力搜索¶
-回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
-我们可以尝试从问题分解的角度分析这道题。设爬到第 \(i\) 阶共有 \(dp[i]\) 种方案,那么 \(dp[i]\) 就是原问题,其子问题包括:
+回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
+我们可以尝试从问题分解的角度分析这道题。设爬到第 \(i\) 阶共有 \(dp[i]\) 种方案,那么 \(dp[i]\) 就是原问题,其子问题包括:
\[
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
\]
-由于每轮只能上 \(1\) 阶或 \(2\) 阶,因此当我们站在第 \(i\) 阶楼梯上时,上一轮只可能站在第 \(i - 1\) 阶或第 \(i - 2\) 阶上。换句话说,我们只能从第 \(i -1\) 阶或第 \(i - 2\) 阶前往第 \(i\) 阶。
+由于每轮只能上 \(1\) 阶或 \(2\) 阶,因此当我们站在第 \(i\) 阶楼梯上时,上一轮只可能站在第 \(i - 1\) 阶或第 \(i - 2\) 阶上。换句话说,我们只能从第 \(i -1\) 阶或第 \(i - 2\) 阶迈向第 \(i\) 阶。
由此便可得出一个重要推论:爬到第 \(i - 1\) 阶的方案数加上爬到第 \(i - 2\) 阶的方案数就等于爬到第 \(i\) 阶的方案数。公式如下:
\[
dp[i] = dp[i-1] + dp[i-2]
@@ -3782,7 +3782,7 @@ dp[i] = dp[i-1] + dp[i-2]
图 14-2 方案数量递推关系
我们可以根据递推公式得到暴力搜索解法。以 \(dp[n]\) 为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 \(dp[1]\) 和 \(dp[2]\) 时返回。其中,最小子问题的解是已知的,即 \(dp[1] = 1\)、\(dp[2] = 2\) ,表示爬到第 \(1\)、\(2\) 阶分别有 \(1\)、\(2\) 种方案。
-观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
+观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁:
@@ -3992,7 +3992,7 @@ dp[i] = dp[i-1] + dp[i-2]
图 14-3 爬楼梯对应递归树
-观察图 14-3 ,指数阶的时间复杂度是由于“重叠子问题”导致的。例如 \(dp[9]\) 被分解为 \(dp[8]\) 和 \(dp[7]\) ,\(dp[8]\) 被分解为 \(dp[7]\) 和 \(dp[6]\) ,两者都包含子问题 \(dp[7]\) 。
+观察图 14-3 ,指数阶的时间复杂度是“重叠子问题”导致的。例如 \(dp[9]\) 被分解为 \(dp[8]\) 和 \(dp[7]\) ,\(dp[8]\) 被分解为 \(dp[7]\) 和 \(dp[6]\) ,两者都包含子问题 \(dp[7]\) 。
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。
14.1.2 方法二:记忆化搜索¶
为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
@@ -4000,6 +4000,7 @@ dp[i] = dp[i-1] + dp[i-2]
- 当首次计算 \(dp[i]\) 时,我们将其记录至
mem[i] ,以便之后使用。
- 当再次需要计算 \(dp[i]\) 时,我们便可直接从
mem[i] 中获取结果,从而避免重复计算该子问题。
+代码如下所示:
@@ -4298,14 +4299,14 @@ dp[i] = dp[i-1] + dp[i-2]
-观察图 14-4 ,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。
+观察图 14-4 ,经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 \(O(n)\) ,这是一个巨大的飞跃。
图 14-4 记忆化搜索对应递归树
14.1.3 方法三:动态规划¶
-记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。
+记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。
与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。
-由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了记忆化搜索中数组 mem 相同的记录作用。
+由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了与记忆化搜索中数组 mem 相同的记录作用:
@@ -4530,15 +4531,15 @@ dp[i] = dp[i-1] + dp[i-2]
图 14-5 爬楼梯的动态规划过程
-与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 \(i\) 。
+与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 \(i\) 。
根据以上内容,我们可以总结出动态规划的常用术语。
- 将数组
dp 称为「\(dp\) 表」,\(dp[i]\) 表示状态 \(i\) 对应子问题的解。
-- 将最小子问题对应的状态(即第 \(1\) 和 \(2\) 阶楼梯)称为「初始状态」。
+- 将最小子问题对应的状态(第 \(1\) 阶和第 \(2\) 阶楼梯)称为「初始状态」。
- 将递推公式 \(dp[i] = dp[i-1] + dp[i-2]\) 称为「状态转移方程」。
14.1.4 空间优化¶
-细心的你可能发现,由于 \(dp[i]\) 只与 \(dp[i-1]\) 和 \(dp[i-2]\) 有关,因此我们无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可。
+细心的读者可能发现了,由于 \(dp[i]\) 只与 \(dp[i-1]\) 和 \(dp[i-2]\) 有关,因此我们无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可。代码如下所示:
@@ -4719,7 +4720,7 @@ dp[i] = dp[i-1] + dp[i-2]
-观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
+观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 \(O(n)\) 降至 \(O(1)\) 。
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”。
diff --git a/chapter_dynamic_programming/knapsack_problem/index.html b/chapter_dynamic_programming/knapsack_problem/index.html
index 62d708a42..26caf5a68 100644
--- a/chapter_dynamic_programming/knapsack_problem/index.html
+++ b/chapter_dynamic_programming/knapsack_problem/index.html
@@ -3414,14 +3414,14 @@
在本节中,我们先来求解最常见的 0-1 背包问题。
Question
-给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
+给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。
观察图 14-17 ,由于物品编号 \(i\) 从 \(1\) 开始计数,数组索引从 \(0\) 开始计数,因此物品 \(i\) 对应重量 \(wgt[i-1]\) 和价值 \(val[i-1]\) 。
图 14-17 0-1 背包的示例数据
-我们可以将 0-1 背包问题看作是一个由 \(n\) 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
-该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。
+我们可以将 0-1 背包问题看作一个由 \(n\) 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。
+该问题的目标是求解“在限定背包容量下能放入物品的最大价值”,因此较大概率是一个动态规划问题。
第一步:思考每轮的决策,定义状态,从而得到 \(dp\) 表
对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 \(i\) 和剩余背包容量 \(c\) ,记为 \([i, c]\) 。
状态 \([i, c]\) 对应的子问题为:前 \(i\) 个物品在剩余容量为 \(c\) 的背包中的最大价值,记为 \(dp[i, c]\) 。
@@ -3430,9 +3430,9 @@
当我们做出物品 \(i\) 的决策后,剩余的是前 \(i-1\) 个物品的决策,可分为以下两种情况。
- 不放入物品 \(i\) :背包容量不变,状态变化为 \([i-1, c]\) 。
-- 放入物品 \(i\) :背包容量减小 \(wgt[i-1]\) ,价值增加 \(val[i-1]\) ,状态变化为 \([i-1, c-wgt[i-1]]\) 。
+- 放入物品 \(i\) :背包容量减少 \(wgt[i-1]\) ,价值增加 \(val[i-1]\) ,状态变化为 \([i-1, c-wgt[i-1]]\) 。
-上述分析向我们揭示了本题的最优子结构:最大价值 \(dp[i, c]\) 等于不放入物品 \(i\) 和放入物品 \(i\) 两种方案中的价值更大的那一个。由此可推出状态转移方程:
+上述分析向我们揭示了本题的最优子结构:最大价值 \(dp[i, c]\) 等于不放入物品 \(i\) 和放入物品 \(i\) 两种方案中价值更大的那一个。由此可推导出状态转移方程:
\[
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
\]
@@ -3447,17 +3447,17 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
- 递归参数:状态 \([i, c]\) 。
- 返回值:子问题的解 \(dp[i, c]\) 。
- 终止条件:当物品编号越界 \(i = 0\) 或背包剩余容量为 \(0\) 时,终止递归并返回价值 \(0\) 。
-- 剪枝:若当前物品重量超出背包剩余容量,则只能不放入背包。
+- 剪枝:若当前物品重量超出背包剩余容量,则只能选择不放入背包。
knapsack.pydef knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int:
"""0-1 背包:暴力搜索"""
- # 若已选完所有物品或背包无容量,则返回价值 0
+ # 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 or c == 0:
return 0
- # 若超过背包容量,则只能不放入背包
+ # 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c:
return knapsack_dfs(wgt, val, i - 1, c)
# 计算不放入和放入物品 i 的最大价值
@@ -3470,11 +3470,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.cpp/* 0-1 背包:暴力搜索 */
int knapsackDFS(vector<int> &wgt, vector<int> &val, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3489,11 +3489,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.java/* 0-1 背包:暴力搜索 */
int knapsackDFS(int[] wgt, int[] val, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3508,11 +3508,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.cs/* 0-1 背包:暴力搜索 */
int KnapsackDFS(int[] weight, int[] val, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (weight[i - 1] > c) {
return KnapsackDFS(weight, val, i - 1, c);
}
@@ -3527,11 +3527,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.go/* 0-1 背包:暴力搜索 */
func knapsackDFS(wgt, val []int, i, c int) int {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i-1] > c {
return knapsackDFS(wgt, val, i-1, c)
}
@@ -3546,11 +3546,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.swift/* 0-1 背包:暴力搜索 */
func knapsackDFS(wgt: [Int], val: [Int], i: Int, c: Int) -> Int {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c {
return knapsackDFS(wgt: wgt, val: val, i: i - 1, c: c)
}
@@ -3565,11 +3565,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.js/* 0-1 背包:暴力搜索 */
function knapsackDFS(wgt, val, i, c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i === 0 || c === 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3589,11 +3589,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
i: number,
c: number
): number {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i === 0 || c === 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3608,11 +3608,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.dart/* 0-1 背包:暴力搜索 */
int knapsackDFS(List<int> wgt, List<int> val, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3627,11 +3627,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.rs/* 0-1 背包:暴力搜索 */
fn knapsack_dfs(wgt: &[i32], val: &[i32], i: usize, c: usize) -> i32 {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c as i32 {
return knapsack_dfs(wgt, val, i - 1, c);
}
@@ -3646,11 +3646,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.c/* 0-1 背包:暴力搜索 */
int knapsackDFS(int wgt[], int val[], int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3665,11 +3665,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.zig// 0-1 背包:暴力搜索
fn knapsackDFS(wgt: []i32, val: []i32, i: usize, c: usize) i32 {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 or c == 0) {
return 0;
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
@@ -3685,12 +3685,12 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
如图 14-18 所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 \(O(2^n)\) 。
观察递归树,容易发现其中存在重叠子问题,例如 \(dp[1, 10]\) 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。
-
- 图 14-18 0-1 背包的暴力搜索递归树
+
+ 图 14-18 0-1 背包问题的暴力搜索递归树
2. 方法二:记忆化搜索¶
为了保证重叠子问题只被计算一次,我们借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 \(dp[i, c]\) 。
-引入记忆化之后,时间复杂度取决于子问题数量,也就是 \(O(n \times cap)\) 。
+引入记忆化之后,时间复杂度取决于子问题数量,也就是 \(O(n \times cap)\) 。实现代码如下:
@@ -3698,13 +3698,13 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int
) -> int:
"""0-1 背包:记忆化搜索"""
- # 若已选完所有物品或背包无容量,则返回价值 0
+ # 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 or c == 0:
return 0
# 若已有记录,则直接返回
if mem[i][c] != -1:
return mem[i][c]
- # 若超过背包容量,则只能不放入背包
+ # 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c:
return knapsack_dfs_mem(wgt, val, mem, i - 1, c)
# 计算不放入和放入物品 i 的最大价值
@@ -3718,7 +3718,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.cpp/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(vector<int> &wgt, vector<int> &val, vector<vector<int>> &mem, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
@@ -3726,7 +3726,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3742,7 +3742,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.java/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
@@ -3750,7 +3750,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3766,7 +3766,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.cs/* 0-1 背包:记忆化搜索 */
int KnapsackDFSMem(int[] weight, int[] val, int[][] mem, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
@@ -3774,7 +3774,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (weight[i - 1] > c) {
return KnapsackDFSMem(weight, val, mem, i - 1, c);
}
@@ -3790,7 +3790,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.go/* 0-1 背包:记忆化搜索 */
func knapsackDFSMem(wgt, val []int, mem [][]int, i, c int) int {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0
}
@@ -3798,7 +3798,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if mem[i][c] != -1 {
return mem[i][c]
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i-1] > c {
return knapsackDFSMem(wgt, val, mem, i-1, c)
}
@@ -3814,7 +3814,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.swift/* 0-1 背包:记忆化搜索 */
func knapsackDFSMem(wgt: [Int], val: [Int], mem: inout [[Int]], i: Int, c: Int) -> Int {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0
}
@@ -3822,7 +3822,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if mem[i][c] != -1 {
return mem[i][c]
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c {
return knapsackDFSMem(wgt: wgt, val: val, mem: &mem, i: i - 1, c: c)
}
@@ -3838,7 +3838,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.js/* 0-1 背包:记忆化搜索 */
function knapsackDFSMem(wgt, val, mem, i, c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i === 0 || c === 0) {
return 0;
}
@@ -3846,7 +3846,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] !== -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3869,7 +3869,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
i: number,
c: number
): number {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i === 0 || c === 0) {
return 0;
}
@@ -3877,7 +3877,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] !== -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3900,7 +3900,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
int i,
int c,
) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
@@ -3908,7 +3908,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3924,7 +3924,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.rs/* 0-1 背包:记忆化搜索 */
fn knapsack_dfs_mem(wgt: &[i32], val: &[i32], mem: &mut Vec<Vec<i32>>, i: usize, c: usize) -> i32 {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if i == 0 || c == 0 {
return 0;
}
@@ -3932,7 +3932,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if mem[i][c] != -1 {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if wgt[i - 1] > c as i32 {
return knapsack_dfs_mem(wgt, val, mem, i - 1, c);
}
@@ -3948,7 +3948,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.c/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(int wgt[], int val[], int memCols, int **mem, int i, int c) {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 || c == 0) {
return 0;
}
@@ -3956,7 +3956,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, memCols, mem, i - 1, c);
}
@@ -3972,7 +3972,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
knapsack.zig// 0-1 背包:记忆化搜索
fn knapsackDFSMem(wgt: []i32, val: []i32, mem: anytype, i: usize, c: usize) i32 {
- // 若已选完所有物品或背包无容量,则返回价值 0
+ // 若已选完所有物品或背包无剩余容量,则返回价值 0
if (i == 0 or c == 0) {
return 0;
}
@@ -3980,7 +3980,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
if (mem[i][c] != -1) {
return mem[i][c];
}
- // 若超过背包容量,则只能不放入背包
+ // 若超过背包容量,则只能选择不放入背包
if (wgt[i - 1] > c) {
return knapsackDFSMem(wgt, val, mem, i - 1, c);
}
@@ -3995,12 +3995,12 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
-图 14-19 展示了在记忆化递归中被剪掉的搜索分支。
-
- 图 14-19 0-1 背包的记忆化搜索递归树
+图 14-19 展示了在记忆化搜索中被剪掉的搜索分支。
+
+ 图 14-19 0-1 背包问题的记忆化搜索递归树
3. 方法三:动态规划¶
-动态规划实质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示。
+动态规划实质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示:
@@ -4294,7 +4294,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
- 图 14-20 0-1 背包的动态规划过程
+ 图 14-20 0-1 背包问题的动态规划过程
4. 空间优化¶
-由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\) 。
-进一步思考,我们是否可以仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 \(i\) 行时,该数组存储的仍然是第 \(i-1\) 行的状态。
+由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 降至 \(O(n)\) 。
+进一步思考,我们能否仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 \(i\) 行时,该数组存储的仍然是第 \(i-1\) 行的状态。
- 如果采取正序遍历,那么遍历到 \(dp[i, j]\) 时,左上方 \(dp[i-1, 1]\) ~ \(dp[i-1, j-1]\) 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
- 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
@@ -4371,7 +4371,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
图 14-21 0-1 背包的空间优化后的动态规划过程
-在代码实现中,我们仅需将数组 dp 的第一维 \(i\) 直接删除,并且把内循环更改为倒序遍历即可。
+在代码实现中,我们仅需将数组 dp 的第一维 \(i\) 直接删除,并且把内循环更改为倒序遍历即可:
diff --git a/chapter_dynamic_programming/summary/index.html b/chapter_dynamic_programming/summary/index.html
index c96ca0963..a18c5d3fb 100644
--- a/chapter_dynamic_programming/summary/index.html
+++ b/chapter_dynamic_programming/summary/index.html
@@ -3317,27 +3317,27 @@
14.7 小结¶
-- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,实现高效的计算效率。
+- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,提高 计算效率。
- 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。
-- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖于某些局部状态,因此我们可以消除 \(dp\) 表的一个维度,从而降低空间复杂度。
+- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖某些局部状态,因此我们可以消除 \(dp\) 表的一个维度,从而降低空间复杂度。
- 子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。
-- 动态规划问题的三大特性:重叠子问题、最优子结构、无后效性。
+- 动态规划问题有三大特性:重叠子问题、最优子结构、无后效性。
- 如果原问题的最优解可以从子问题的最优解构建得来,则它就具有最优子结构。
-- 无后效性指对于一个状态,其未来发展只与该状态有关,与其所经历的过去的所有状态无关。许多组合优化问题都不具有无后效性,无法使用动态规划快速求解。
+- 无后效性指对于一个状态,其未来发展只与该状态有关,而与过去经历的所有状态无关。许多组合优化问题不具有无后效性,无法使用动态规划快速求解。
背包问题
-- 背包问题是最典型的动态规划题目,具有 0-1 背包、完全背包、多重背包等变种问题。
+- 背包问题是最典型的动态规划问题之一,具有 0-1 背包、完全背包、多重背包等变种。
- 0-1 背包的状态定义为前 \(i\) 个物品在剩余容量为 \(c\) 的背包中的最大价值。根据不放入背包和放入背包两种决策,可得到最优子结构,并构建出状态转移方程。在空间优化中,由于每个状态依赖正上方和左上方的状态,因此需要倒序遍历列表,避免左上方状态被覆盖。
-- 完全背包的每种物品的选取数量无限制,因此选择放入物品的状态转移与 0-1 背包不同。由于状态依赖于正上方和正左方的状态,因此在空间优化中应当正序遍历。
-- 零钱兑换问题是完全背包的一个变种。它从求“最大”价值变为求“最小”硬币数量,因此状态转移方程中的 \(\max()\) 应改为 \(\min()\) 。从求“不超过”背包容量到求“恰好”凑出目标金额,因此使用 \(amt + 1\) 来表示“无法凑出目标金额”的无效解。
+- 完全背包问题的每种物品的选取数量无限制,因此选择放入物品的状态转移与 0-1 背包问题不同。由于状态依赖正上方和正左方的状态,因此在空间优化中应当正序遍历。
+- 零钱兑换问题是完全背包问题的一个变种。它从求“最大”价值变为求“最小”硬币数量,因此状态转移方程中的 \(\max()\) 应改为 \(\min()\) 。从追求“不超过”背包容量到追求“恰好”凑出目标金额,因此使用 \(amt + 1\) 来表示“无法凑出目标金额”的无效解。
- 零钱兑换 II 问题从求“最少硬币数量”改为求“硬币组合数量”,状态转移方程相应地从 \(\min()\) 改为求和运算符。
编辑距离问题
-- 编辑距离(Levenshtein 距离)用于衡量两个字符串之间的相似度,其定义为从一个字符串到另一个字符串的最小编辑步数,编辑操作包括添加、删除、替换。
+- 编辑距离(Levenshtein 距离)用于衡量两个字符串之间的相似度,其定义为从一个字符串到另一个字符串的最少编辑步数,编辑操作包括添加、删除、替换。
- 编辑距离问题的状态定义为将 \(s\) 的前 \(i\) 个字符更改为 \(t\) 的前 \(j\) 个字符所需的最少编辑步数。当 \(s[i] \ne t[j]\) 时,具有三种决策:添加、删除、替换,它们都有相应的剩余子问题。据此便可以找出最优子结构与构建状态转移方程。而当 \(s[i] = t[j]\) 时,无须编辑当前字符。
-- 在编辑距离中,状态依赖于其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包等价的情况,可以在空间优化后进行正序遍历。
+- 在编辑距离中,状态依赖其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包问题等价的情况,可以在空间优化后进行正序遍历。
diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem/index.html b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
index 2522d689d..fb39e6734 100644
--- a/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
+++ b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
@@ -3562,28 +3562,28 @@
14.5.1 完全背包¶
Question
-给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品可以重复选取,问在不超过背包容量下能放入物品的最大价值。
+给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品可以重复选取,问在限定背包容量下能放入物品的最大价值。示例如图 14-22 所示。
图 14-22 完全背包问题的示例数据
1. 动态规划思路¶
-完全背包和 0-1 背包问题非常相似,区别仅在于不限制物品的选择次数。
+完全背包问题和 0-1 背包问题非常相似,区别仅在于不限制物品的选择次数。
-- 在 0-1 背包中,每个物品只有一个,因此将物品 \(i\) 放入背包后,只能从前 \(i-1\) 个物品中选择。
-- 在完全背包中,每个物品有无数个,因此将物品 \(i\) 放入背包后,仍可以从前 \(i\) 个物品中选择。
+- 在 0-1 背包问题中,每种物品只有一个,因此将物品 \(i\) 放入背包后,只能从前 \(i-1\) 个物品中选择。
+- 在完全背包问题中,每种物品的数量是无限的,因此将物品 \(i\) 放入背包后,仍可以从前 \(i\) 个物品中选择。
-在完全背包的规定下,状态 \([i, c]\) 的变化分为两种情况。
+在完全背包问题的规定下,状态 \([i, c]\) 的变化分为两种情况。
-- 不放入物品 \(i\) :与 0-1 背包相同,转移至 \([i-1, c]\) 。
-- 放入物品 \(i\) :与 0-1 背包不同,转移至 \([i, c-wgt[i-1]]\) 。
+- 不放入物品 \(i\) :与 0-1 背包问题相同,转移至 \([i-1, c]\) 。
+- 放入物品 \(i\) :与 0-1 背包问题不同,转移至 \([i, c-wgt[i-1]]\) 。
从而状态转移方程变为:
\[
dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
\]
2. 代码实现¶
-对比两道题目的代码,状态转移中有一处从 \(i-1\) 变为 \(i\) ,其余完全一致。
+对比两道题目的代码,状态转移中有一处从 \(i-1\) 变为 \(i\) ,其余完全一致:
@@ -3874,12 +3874,12 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
3. 空间优化¶
-由于当前状态是从左边和上边的状态转移而来,因此空间优化后应该对 \(dp\) 表中的每一行采取正序遍历。
+由于当前状态是从左边和上边的状态转移而来的,因此空间优化后应该对 \(dp\) 表中的每一行进行正序遍历。
这个遍历顺序与 0-1 背包正好相反。请借助图 14-23 来理解两者的区别。
- 图 14-23 完全背包的空间优化后的动态规划过程
+ 图 14-23 完全背包问题在空间优化后的动态规划过程
-代码实现比较简单,仅需将数组 dp 的第一维删除。
+代码实现比较简单,仅需将数组 dp 的第一维删除:
@@ -4174,26 +4174,26 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
14.5.2 零钱兑换问题¶
-背包问题是一大类动态规划问题的代表,其拥有很多的变种,例如零钱兑换问题。
+背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。
Question
-给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 \(-1\) 。
+给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 \(-1\) 。示例如图 14-24 所示。
图 14-24 零钱兑换问题的示例数据
1. 动态规划思路¶
-零钱兑换可以看作是完全背包的一种特殊情况,两者具有以下联系与不同点。
+零钱兑换可以看作完全背包问题的一种特殊情况,两者具有以下联系与不同点。
-- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。
-- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
-- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
+- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
+- 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
+- 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
第一步:思考每轮的决策,定义状态,从而得到 \(dp\) 表
-状态 \([i, a]\) 对应的子问题为:前 \(i\) 种硬币能够凑出金额 \(a\) 的最少硬币个数,记为 \(dp[i, a]\) 。
+状态 \([i, a]\) 对应的子问题为:前 \(i\) 种硬币能够凑出金额 \(a\) 的最少硬币数量,记为 \(dp[i, a]\) 。
二维 \(dp\) 表的尺寸为 \((n+1) \times (amt+1)\) 。
第二步:找出最优子结构,进而推导出状态转移方程
-本题与完全背包的状态转移方程存在以下两个差异。
+本题与完全背包问题的状态转移方程存在以下两点差异。
- 本题要求最小值,因此需将运算符 \(\max()\) 更改为 \(\min()\) 。
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 \(+1\) 即可。
@@ -4202,12 +4202,11 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
\]
第三步:确定边界条件和状态转移顺序
-当目标金额为 \(0\) 时,凑出它的最少硬币个数为 \(0\) ,即首列所有 \(dp[i, 0]\) 都等于 \(0\) 。
+当目标金额为 \(0\) 时,凑出它的最少硬币数量为 \(0\) ,即首列所有 \(dp[i, 0]\) 都等于 \(0\) 。
当无硬币时,无法凑出任意 \(> 0\) 的目标金额,即是无效解。为使状态转移方程中的 \(\min()\) 函数能够识别并过滤无效解,我们考虑使用 \(+ \infty\) 来表示它们,即令首行所有 \(dp[0, a]\) 都等于 \(+ \infty\) 。
2. 代码实现¶
大多数编程语言并未提供 \(+ \infty\) 变量,只能使用整型 int 的最大值来代替。而这又会导致大数越界:状态转移方程中的 \(+ 1\) 操作可能发生溢出。
-为此,我们采用数字 \(amt + 1\) 来表示无效解,因为凑出 \(amt\) 的硬币个数最多为 \(amt\) 个。
-最后返回前,判断 \(dp[n, amt]\) 是否等于 \(amt + 1\) ,若是则返回 \(-1\) ,代表无法凑出目标金额。
+为此,我们采用数字 \(amt + 1\) 来表示无效解,因为凑出 \(amt\) 的硬币数量最多为 \(amt\) 。最后返回前,判断 \(dp[n, amt]\) 是否等于 \(amt + 1\) ,若是则返回 \(-1\) ,代表无法凑出目标金额。代码如下所示:
@@ -4220,7 +4219,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
# 状态转移:首行首列
for a in range(1, amt + 1):
dp[0][a] = MAX
- # 状态转移:其余行列
+ # 状态转移:其余行和列
for i in range(1, n + 1):
for a in range(1, amt + 1):
if coins[i - 1] > a:
@@ -4243,7 +4242,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
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) {
@@ -4270,7 +4269,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
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) {
@@ -4297,7 +4296,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
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) {
@@ -4327,7 +4326,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for a := 1; a <= amt; a++ {
dp[0][a] = max
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i := 1; i <= n; i++ {
for a := 1; a <= amt; a++ {
if coins[i-1] > a {
@@ -4357,7 +4356,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for a in stride(from: 1, through: amt, by: 1) {
dp[0][a] = MAX
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in stride(from: 1, through: n, by: 1) {
for a in stride(from: 1, through: amt, by: 1) {
if coins[i - 1] > a {
@@ -4386,7 +4385,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for (let a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i <= n; i++) {
for (let a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -4415,7 +4414,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for (let a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (let i = 1; i <= n; i++) {
for (let a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -4442,7 +4441,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
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) {
@@ -4469,7 +4468,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for a in 1..= amt {
dp[0][a] = max;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i in 1..=n {
for a in 1..=amt {
if coins[i - 1] > a as i32 {
@@ -4499,7 +4498,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
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) {
@@ -4532,7 +4531,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
for (1..amt + 1) |a| {
dp[0][a] = max;
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for (1..n + 1) |i| {
for (1..amt + 1) |a| {
if (coins[i - 1] > @as(i32, @intCast(a))) {
@@ -4554,7 +4553,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
-图 14-25 展示了零钱兑换的动态规划过程,和完全背包非常相似。
+图 14-25 展示了零钱兑换的动态规划过程,和完全背包问题非常相似。
@@ -4607,7 +4606,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
图 14-25 零钱兑换问题的动态规划过程
3. 空间优化¶
-零钱兑换的空间优化的处理方式和完全背包一致。
+零钱兑换的空间优化的处理方式和完全背包问题一致:
@@ -4917,13 +4916,13 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
14.5.3 零钱兑换问题 II¶
Question
-给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问在凑出目标金额的硬币组合数量。
+给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问凑出目标金额的硬币组合数量。示例如图 14-26 所示。
图 14-26 零钱兑换问题 II 的示例数据
1. 动态规划思路¶
-相比于上一题,本题目标是组合数量,因此子问题变为:前 \(i\) 种硬币能够凑出金额 \(a\) 的组合数量。而 \(dp\) 表仍然是尺寸为 \((n+1) \times (amt + 1)\) 的二维矩阵。
+相比于上一题,本题目标是求组合数量,因此子问题变为:前 \(i\) 种硬币能够凑出金额 \(a\) 的组合数量。而 \(dp\) 表仍然是尺寸为 \((n+1) \times (amt + 1)\) 的二维矩阵。
当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:
\[
dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
@@ -5044,7 +5043,7 @@ dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
for i := 0; i <= n; i++ {
dp[i][0] = 1
}
- // 状态转移:其余行列
+ // 状态转移:其余行和列
for i := 1; i <= n; i++ {
for a := 1; a <= amt; a++ {
if coins[i-1] > a {
@@ -5258,7 +5257,7 @@ dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
3. 空间优化¶
-空间优化处理方式相同,删除硬币维度即可。
+空间优化处理方式相同,删除硬币维度即可:
diff --git a/chapter_graph/graph.assets/adjacency_matrix.png b/chapter_graph/graph.assets/adjacency_matrix.png
index db81eb47b..e5e88c1b7 100644
Binary files a/chapter_graph/graph.assets/adjacency_matrix.png and b/chapter_graph/graph.assets/adjacency_matrix.png differ
diff --git a/chapter_graph/graph/index.html b/chapter_graph/graph/index.html
index c7bfdd497..a6cf16085 100644
--- a/chapter_graph/graph/index.html
+++ b/chapter_graph/graph/index.html
@@ -3444,12 +3444,12 @@ E & = \{ (1,2), (1,3), (1,5), (2,3), (2,4), (2,5), (4,5) \} \newline
G & = \{ V, E \} \newline
\end{aligned}
\]
-如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如图 9-1 所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂。
+如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如图 9-1 所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,因而更为复杂。
图 9-1 链表、树、图之间的关系
9.1.1 图常见类型与术语¶
-根据边是否具有方向,可分为图 9-2 所示的「无向图 undirected graph」和「有向图 directed graph」。
+根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」,如图 9-2 所示。
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
- 在有向图中,边具有方向性,即 \(A \rightarrow B\) 和 \(A \leftarrow B\) 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
@@ -3457,7 +3457,7 @@ G & = \{ V, E \} \newline
图 9-2 有向图与无向图
-根据所有顶点是否连通,可分为图 9-3 所示的「连通图 connected graph」和「非连通图 disconnected graph」。
+根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」,如图 9-3 所示。
- 对于连通图,从某个顶点出发,可以到达其余任意顶点。
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。
@@ -3465,7 +3465,7 @@ G & = \{ V, E \} \newline
图 9-3 连通图与非连通图
-我们还可以为边添加“权重”变量,从而得到图 9-4 所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
+我们还可以为边添加“权重”变量,从而得到如图 9-4 所示的「有权图 weighted graph」。例如在“王者荣耀”等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
图 9-4 有权图与无权图
@@ -3489,16 +3489,16 @@ G & = \{ V, E \} \newline
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
- 将邻接矩阵的元素从 \(1\) 和 \(0\) 替换为权重,则可表示有权图。
-使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 \(O(1)\) 。然而,矩阵的空间复杂度为 \(O(n^2)\) ,内存占用较多。
+使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查改操作的效率很高,时间复杂度均为 \(O(1)\) 。然而,矩阵的空间复杂度为 \(O(n^2)\) ,内存占用较多。
2. 邻接表¶
-「邻接表 adjacency list」使用 \(n\) 个链表来表示图,链表节点表示顶点。第 \(i\) 条链表对应顶点 \(i\) ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。
+「邻接表 adjacency list」使用 \(n\) 个链表来表示图,链表节点表示顶点。第 \(i\) 个链表对应顶点 \(i\) ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。
图 9-6 图的邻接表表示
邻接表仅存储实际存在的边,而边的总数通常远小于 \(n^2\) ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
-观察图 9-6 ,邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 \(O(n)\) 优化至 \(O(\log n)\) ;还可以把链表转换为哈希表,从而将时间复杂度降低至 \(O(1)\) 。
+观察图 9-6 ,邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 \(O(n)\) 优化至 \(O(\log n)\) ;还可以把链表转换为哈希表,从而将时间复杂度降至 \(O(1)\) 。
9.1.3 图常见应用¶
-如表 9-1 所示,许多现实系统都可以用图来建模,相应的问题也可以约化为图计算问题。
+如表 9-1 所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。
表 9-1 现实生活中常见的图
diff --git a/chapter_graph/graph_operations/index.html b/chapter_graph/graph_operations/index.html
index 38c501722..867240a17 100644
--- a/chapter_graph/graph_operations/index.html
+++ b/chapter_graph/graph_operations/index.html
@@ -3426,7 +3426,7 @@
图 9-7 邻接矩阵的初始化、增删边、增删顶点
-以下是基于邻接矩阵表示图的实现代码。
+以下是基于邻接矩阵表示图的实现代码:
@@ -3481,7 +3481,7 @@
# 索引越界与相等处理
if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j:
raise IndexError()
- # 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ # 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
self.adj_mat[i][j] = 1
self.adj_mat[j][i] = 1
@@ -3561,7 +3561,7 @@
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw out_of_range("顶点不存在");
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
@@ -3650,7 +3650,7 @@
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j)
throw new IndexOutOfBoundsException();
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat.get(i).set(j, 1);
adjMat.get(j).set(i, 1);
}
@@ -3738,7 +3738,7 @@
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= Size() || j >= Size() || i == j)
throw new IndexOutOfRangeException();
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
@@ -3834,7 +3834,7 @@
if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j {
fmt.Errorf("%s", "Index Out Of Bounds Exception")
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
g.adjMat[i][j] = 1
g.adjMat[j][i] = 1
}
@@ -3922,7 +3922,7 @@
if i < 0 || j < 0 || i >= size() || j >= size() || i == j {
fatalError("越界")
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1
adjMat[j][i] = 1
}
@@ -4014,7 +4014,7 @@
if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) {
throw new RangeError('Index Out Of Bounds Exception');
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) === (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) === (j, i)
this.adjMat[i][j] = 1;
this.adjMat[j][i] = 1;
}
@@ -4104,7 +4104,7 @@
if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) {
throw new RangeError('Index Out Of Bounds Exception');
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) === (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) === (j, i)
this.adjMat[i][j] = 1;
this.adjMat[j][i] = 1;
}
@@ -4190,7 +4190,7 @@
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw IndexError;
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
@@ -4284,7 +4284,7 @@
if i >= self.size() || j >= self.size() || i == j {
panic!("index error")
}
- // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
+ // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
self.adj_mat[i][j] = 1;
self.adj_mat[j][i] = 1;
}
@@ -4462,7 +4462,7 @@
def __init__(self, edges: list[list[Vertex]]):
"""构造方法"""
- # 邻接表,key: 顶点,value:该顶点的所有邻接顶点
+ # 邻接表,key:顶点,value:该顶点的所有邻接顶点
self.adj_list = dict[Vertex, list[Vertex]]()
# 添加所有顶点和边
for edge in edges:
@@ -4520,7 +4520,7 @@
graph_adjacency_list.cpp/* 基于邻接表实现的无向图类 */
class GraphAdjList {
public:
- // 邻接表,key: 顶点,value:该顶点的所有邻接顶点
+ // 邻接表,key:顶点,value:该顶点的所有邻接顶点
unordered_map<Vertex *, vector<Vertex *>> adjList;
/* 在 vector 中删除指定节点 */
@@ -4602,7 +4602,7 @@
graph_adjacency_list.java/* 基于邻接表实现的无向图类 */
class GraphAdjList {
- // 邻接表,key: 顶点,value:该顶点的所有邻接顶点
+ // 邻接表,key:顶点,value:该顶点的所有邻接顶点
Map<Vertex, List<Vertex>> adjList;
/* 构造方法 */
@@ -4675,7 +4675,7 @@
graph_adjacency_list.cs/* 基于邻接表实现的无向图类 */
class GraphAdjList {
- // 邻接表,key: 顶点,value:该顶点的所有邻接顶点
+ // 邻接表,key:顶点,value:该顶点的所有邻接顶点
public Dictionary<Vertex, List<Vertex>> adjList;
/* 构造函数 */
@@ -4748,7 +4748,7 @@