diff --git a/chapter_appendix/contribution/index.html b/chapter_appendix/contribution/index.html index fe63734c3..5bcf815e8 100644 --- a/chapter_appendix/contribution/index.html +++ b/chapter_appendix/contribution/index.html @@ -3380,7 +3380,7 @@
图 16-1 页面编辑按键
图片无法直接修改,需要通过新建 Issue 或评论留言来描述问题,我们会尽快重新绘制并替换图片。
diff --git a/chapter_appendix/index.html b/chapter_appendix/index.html index e4ded75ba..28f998f04 100644 --- a/chapter_appendix/index.html +++ b/chapter_appendix/index.html @@ -3292,7 +3292,7 @@「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。图 4-1 展示了数组的主要术语和概念。
- +图 4-1 数组定义与存储方式
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(即首元素内存地址)和某个元素的索引,我们可以使用图 4-2 所示的公式计算得到该元素的内存地址,从而直接访问此元素。
- +图 4-2 数组元素的内存地址计算
观察图 4-2 ,我们发现数组首个元素的索引为 \(0\) ,这似乎有些反直觉,因为从 \(1\) 开始计数会更自然。但从地址计算公式的角度看,索引的含义本质上是内存地址的偏移量。首个元素的地址偏移量是 \(0\) ,因此它的索引为 \(0\) 也是合理的。
@@ -3711,7 +3711,7 @@数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
- +图 4-3 数组插入元素示例
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
@@ -3864,7 +3864,7 @@同理,如图 4-4 所示,若想要删除索引 \(i\) 处的元素,则需要把索引 \(i\) 之后的元素都向前移动一位。
- +图 4-4 数组删除元素示例
请注意,删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无须特意去修改它。
diff --git a/chapter_array_and_linkedlist/index.html b/chapter_array_and_linkedlist/index.html index 28a386b60..c82a0b620 100644 --- a/chapter_array_and_linkedlist/index.html +++ b/chapter_array_and_linkedlist/index.html @@ -3291,9 +3291,7 @@Abstract
数据结构的世界如同一堵厚实的砖墙。
diff --git a/chapter_array_and_linkedlist/linked_list/index.html b/chapter_array_and_linkedlist/linked_list/index.html index bfd4f7250..7117ae359 100644 --- a/chapter_array_and_linkedlist/linked_list/index.html +++ b/chapter_array_and_linkedlist/linked_list/index.html @@ -3465,7 +3465,7 @@内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。
- +图 4-5 链表定义与存储方式
观察图 4-5 ,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。
@@ -3811,7 +3811,7 @@在链表中插入节点非常容易。如图 4-6 所示,假设我们想在相邻的两个节点 n0 和 n1 之间插入一个新节点 P ,则只需要改变两个节点引用(指针)即可,时间复杂度为 \(O(1)\) 。
相比之下,在数组中插入元素的时间复杂度为 \(O(n)\) ,在大数据量下的效率较低。
- +图 4-6 链表插入节点示例
如图 4-7 所示,在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可。
请注意,尽管在删除操作完成后节点 P 仍然指向 n1 ,但实际上遍历此链表已经无法访问到 P ,这意味着 P 已经不再属于该链表了。
图 4-7 链表删除节点
图 4-8 常见链表种类
图 13-1 在前序遍历中搜索节点
剪枝是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
- +图 13-3 根据约束条件剪枝
根据题意,我们在找到值为 \(7\) 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。图 13-4 对比了保留或删除 return 语句的搜索过程。
图 13-4 保留与删除 return 的搜索过程对比
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,许多回溯问题都可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。
Abstract
diff --git a/chapter_backtracking/n_queens_problem/index.html b/chapter_backtracking/n_queens_problem/index.html index e03a86a67..ce8cf7af4 100644 --- a/chapter_backtracking/n_queens_problem/index.html +++ b/chapter_backtracking/n_queens_problem/index.html @@ -3371,18 +3371,18 @@根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 \(n\) 个皇后和一个 \(n \times n\) 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如图 13-15 所示,当 \(n = 4\) 时,共可以找到两个解。从回溯算法的角度看,\(n \times n\) 大小的棋盘共有 \(n^2\) 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state 。
图 13-15 4 皇后问题的解
图 13-16 展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一对角线。值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种。
图 13-16 n 皇后问题的约束条件
皇后的数量和棋盘的行数都为 \(n\) ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
如图 13-17 所示,为 \(4\) 皇后问题的逐行放置过程。受画幅限制,图 13-17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
- +图 13-17 逐行放置策略
本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
@@ -3391,7 +3391,7 @@那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 \((row, col)\) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 \(row - col\) 为恒定值。
也就是说,如果两个格子满足 \(row_1 - col_1 = row_2 - col_2\) ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 diags1 ,记录每条主对角线上是否有皇后。
同理,次对角线上的所有格子的 \(row + col\) 是恒定值。我们同样也可以借助数组 diags2 来处理次对角线约束。
图 13-18 处理列约束和对角线约束
从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 \([1, 2, 3]\) ,如果我们先选择 \(1\)、再选择 \(3\)、最后选择 \(2\) ,则获得排列 \([1, 3, 2]\) 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。
如图 13-5 所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
图 13-5 全排列的递归树
choices 时,跳过所有已被选择过的节点,即剪枝。如图 13-6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。
- +图 13-6 全排列剪枝示例
观察图 13-6 发现,该剪枝操作将搜索空间大小从 \(O(n^n)\) 降低至 \(O(n!)\) 。
@@ -3901,7 +3901,7 @@假设输入数组为 \([1, 1, 2]\) 。为了方便区分两个重复元素 \(1\) ,我们将第二个 \(1\) 记为 \(\hat{1}\) 。
如图 13-7 所示,上述方法生成的排列有一半都是重复的。
- +图 13-7 重复排列
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝,这样可以进一步提升算法效率。
@@ -3909,7 +3909,7 @@观察图 13-8 ,在第一轮中,选择 \(1\) 或选择 \(\hat{1}\) 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 \(\hat{1}\) 剪枝掉。
同理,在第一轮选择 \(2\) 之后,第二轮选择中的 \(1\) 和 \(\hat{1}\) 也会产生重复分支,因此也应将第二轮的 \(\hat{1}\) 剪枝。
本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。
- +图 13-8 重复排列剪枝
backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(即 for 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。图 13-9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
- +图 13-9 两种剪枝条件的作用范围
diff --git a/chapter_backtracking/subset_sum_problem/index.html b/chapter_backtracking/subset_sum_problem/index.html index ab84ad526..751a85b56 100644 --- a/chapter_backtracking/subset_sum_problem/index.html +++ b/chapter_backtracking/subset_sum_problem/index.html @@ -3848,7 +3848,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 子集搜索与越界剪枝
为了去除重复子集,一种直接的思路是对结果列表进行去重。但这个方法效率很低,有两方面原因。
@@ -3868,7 +3868,7 @@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\) ,不满足该条件的选择序列都会造成重复,应当剪枝。
@@ -4297,7 +4297,7 @@如图 13-12 所示,为将数组 \([3, 4, 5]\) 和目标元素 \(9\) 输入到以上代码后的整体回溯过程。
- +图 13-12 子集和 I 回溯过程
相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。例如,给定数组 \([4, \hat{4}, 5]\) 和目标元素 \(9\) ,则现有代码的输出结果为 \([4, 5], [\hat{4}, 5]\) ,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择。在图 13-13 中,第一轮共有三个选择,其中两个都为 \(4\) ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 \(4\) 也会产生重复子集。
- +图 13-13 相等元素导致的重复子集
图 13-14 展示了数组 \([4, 4, 5]\) 和目标元素 \(9\) 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。
- +图 13-14 子集和 II 回溯过程
diff --git a/chapter_computational_complexity/index.html b/chapter_computational_complexity/index.html index c81f23728..2ec29a5a9 100644 --- a/chapter_computational_complexity/index.html +++ b/chapter_computational_complexity/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_computational_complexity/iteration_and_recursion/index.html b/chapter_computational_complexity/iteration_and_recursion/index.html index bcc5f5dd7..bf6171ece 100644 --- a/chapter_computational_complexity/iteration_and_recursion/index.html +++ b/chapter_computational_complexity/iteration_and_recursion/index.html @@ -3627,7 +3627,7 @@图 2-1 展示了该求和函数的流程框图。
- +图 2-1 求和函数的流程框图
此求和函数的操作数量与输入数据大小 \(n\) 成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。相关内容将会在下一节中详细介绍。
@@ -4195,7 +4195,7 @@图 2-2 给出了该嵌套循环的流程框图。
- +图 2-2 嵌套循环的流程框图
在这种情况下,函数的操作数量与 \(n^2\) 成正比,或者说算法运行时间和输入数据大小 \(n\) 成“平方关系”。
@@ -4374,7 +4374,7 @@图 2-3 展示了该函数的递归过程。
- +图 2-3 求和函数的递归过程
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式。
@@ -4394,7 +4394,7 @@如图 2-4 所示,在触发终止条件前,同时存在 \(n\) 个未返回的递归函数,递归深度为 \(n\) 。
- +图 2-4 递归调用深度
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。
@@ -4546,7 +4546,7 @@图 2-5 尾递归过程
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一个层数为 \(n\) 的「递归树 recursion tree」。
- +图 2-6 斐波那契数列的递归树
本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略是至关重要的。
diff --git a/chapter_computational_complexity/space_complexity/index.html b/chapter_computational_complexity/space_complexity/index.html index cade05e4f..6f0a6fe39 100644 --- a/chapter_computational_complexity/space_complexity/index.html +++ b/chapter_computational_complexity/space_complexity/index.html @@ -3478,7 +3478,7 @@在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分。
- +图 2-15 算法使用的相关空间
图 2-16 常见的空间复杂度类型
图 2-17 递归函数产生的线性阶空间复杂度
图 2-18 递归函数产生的平方阶空间复杂度
图 2-19 满二叉树产生的指数阶空间复杂度
B 中的打印操作需要循环 \(n\) 次,算法运行时间随着 \(n\) 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。C 中的打印操作需要循环 \(1000000\) 次,虽然运行时间很长,但它与输入数据大小 \(n\) 无关。因此 C 的时间复杂度和 A 相同,仍为“常数阶”。图 2-7 算法 A、B 和 C 的时间增长趋势
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
@@ -4124,7 +4124,7 @@ T(n) = 3 + 2n若存在正实数 \(c\) 和实数 \(n_0\) ,使得对于所有的 \(n > n_0\) ,均有 \(T(n) \leq c \cdot f(n)\) ,则可认为 \(f(n)\) 给出了 \(T(n)\) 的一个渐近上界,记为 \(T(n) = O(f(n))\) 。
如图 2-8 所示,计算渐近上界就是寻找一个函数 \(f(n)\) ,使得当 \(n\) 趋向于无穷大时,\(T(n)\) 和 \(f(n)\) 处于相同的增长级别,仅相差一个常数项 \(c\) 的倍数。
- +图 2-8 函数的渐近上界
图 2-9 常见的时间复杂度类型
图 2-10 对比了常数阶、线性阶和平方阶三种时间复杂度。
- +图 2-10 常数阶、线性阶和平方阶的时间复杂度
以冒泡排序为例,外层循环执行 \(n - 1\) 次,内层循环执行 \(n-1\)、\(n-2\)、\(\dots\)、\(2\)、\(1\) 次,平均为 \(n / 2\) 次,因此时间复杂度为 \(O((n - 1) n / 2) = O(n^2)\) 。
@@ -5461,7 +5461,7 @@ O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n! - +图 2-11 指数阶的时间复杂度
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 \(n\) 次分裂后停止:
@@ -5727,7 +5727,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\) 的递归树:
@@ -6009,7 +6009,7 @@ O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n)图 2-13 展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 \(n\) ,树共有 \(\log_2 n + 1\) 层,因此时间复杂度为 \(O(n \log n)\) 。
- +图 2-13 线性对数阶的时间复杂度
主流排序算法的时间复杂度通常为 \(O(n \log n)\) ,例如快速排序、归并排序、堆排序等。
@@ -6187,7 +6187,7 @@ n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1 - +图 2-14 阶乘阶的时间复杂度
请注意,因为当 \(n \geq 4\) 时恒有 \(n! > 2^n\) ,所以阶乘阶比指数阶增长得更快,在 \(n\) 较大时也是不可接受的。
diff --git a/chapter_data_structure/character_encoding/index.html b/chapter_data_structure/character_encoding/index.html index e6b95b59e..c0745265d 100644 --- a/chapter_data_structure/character_encoding/index.html +++ b/chapter_data_structure/character_encoding/index.html @@ -3397,7 +3397,7 @@在计算机中,所有数据都是以二进制数的形式存储的,字符 char 也不例外。为了表示字符,我们需要建立一套“字符集”,规定每个字符和二进制数之间的一一对应关系。有了字符集之后,计算机就可以通过查表完成二进制数到字符的转换。
「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。
- +图 3-6 ASCII 码
然而,ASCII 码仅能够表示英文。随着计算机的全球化,诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
@@ -3412,7 +3412,7 @@自 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 编码方法。
@@ -3426,7 +3426,7 @@图 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 之外,常见的编码方式还包括以下两种。
diff --git a/chapter_data_structure/classification_of_data_structure/index.html b/chapter_data_structure/classification_of_data_structure/index.html index 90446e56a..1c5b9f2a3 100644 --- a/chapter_data_structure/classification_of_data_structure/index.html +++ b/chapter_data_structure/classification_of_data_structure/index.html @@ -3360,7 +3360,7 @@图 3-1 线性与非线性数据结构
非线性数据结构可以进一步被划分为树形结构和网状结构。
@@ -3373,12 +3373,12 @@在计算机中,内存和硬盘是两种主要的存储硬件设备。硬盘主要用于长期存储数据,容量较大(通常可达到 TB 级别)、速度较慢。内存用于运行程序时暂存数据,速度较快,但容量较小(通常为 GB 级别)。
在算法运行过程中,相关数据都存储在内存中。图 3-2 展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据,在算法运行时,所有数据都被存储在这些单元格中。
系统通过内存地址来访问目标位置的数据。如图 3-2 所示,计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。
- +图 3-2 内存条、内存空间、内存地址
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。
如图 3-3 所示,物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。
- +图 3-3 连续空间存储与分散空间存储
值得说明的是,所有数据结构都是基于数组、链表或二者的组合实现的。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。
diff --git a/chapter_data_structure/index.html b/chapter_data_structure/index.html index 6003a2db9..f93868f71 100644 --- a/chapter_data_structure/index.html +++ b/chapter_data_structure/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_data_structure/number_encoding/index.html b/chapter_data_structure/number_encoding/index.html index f47fada79..4235da6b6 100644 --- a/chapter_data_structure/number_encoding/index.html +++ b/chapter_data_structure/number_encoding/index.html @@ -3365,7 +3365,7 @@图 3-4 展示了原码、反码和补码之间的转换方法。
- +图 3-4 原码、反码与补码之间的相互转换
「原码 true form」虽然最直观,但存在一些局限性。一方面,负数的原码不能直接用于运算。例如在原码下计算 \(1 + (-2)\) ,得到的结果是 \(-3\) ,这显然是不对的。
@@ -3447,7 +3447,7 @@ b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0 (1 + \mathrm{N}) = & (1 + \sum_{i=1}^{23} b_{23-i} 2^{-i}) \subset [1, 2 - 2^{-23}] \end{aligned} \]图 3-5 IEEE 754 标准下的 float 的计算示例
观察图 3-5 ,给定一个示例数据 \(\mathrm{S} = 0\) , \(\mathrm{E} = 124\) ,\(\mathrm{N} = 2^{-2} + 2^{-3} = 0.375\) ,则有:
diff --git a/chapter_divide_and_conquer/binary_search_recur/index.html b/chapter_divide_and_conquer/binary_search_recur/index.html index 89dd94ba2..6bf1ce4e4 100644 --- a/chapter_divide_and_conquer/binary_search_recur/index.html +++ b/chapter_divide_and_conquer/binary_search_recur/index.html @@ -3369,7 +3369,7 @@1. 和 2. 步,直至找到 target 或区间为空时返回。图 12-4 展示了在数组中二分查找元素 \(6\) 的分治过程。
- +图 12-4 二分查找的分治过程
在实现代码中,我们声明一个递归函数 dfs() 来求解问题 \(f(i, j)\) 。
Question
给定一个二叉树的前序遍历 preorder 和中序遍历 inorder ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
图 12-5 构建二叉树的示例数据
inorder 中的索引,利用该索引可将 inorder 划分为 [ 9 | 3 | 1 2 7 ] 。inorder 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 preorder 划分为 [ 3 | 9 | 2 1 7 ] 。图 12-6 在前序和中序遍历中划分子树
请注意,右子树根节点索引中的 \((m-l)\) 的含义是“左子树的节点数量”,建议配合图 12-7 理解。
- +图 12-7 根节点和左右子树的索引区间表示
图 12-8 构建二叉树的递归过程
每个递归函数内的前序遍历 preorder 和中序遍历 inorder 的划分结果如图 12-9 所示。
图 12-9 每个递归函数中的划分结果
设树的节点数量为 \(n\) ,初始化每一个节点(执行一个递归函数 dfs() )使用 \(O(1)\) 时间。因此总体时间复杂度为 \(O(n)\) 。
图 12-1 归并排序的分治策略
图 12-2 划分数组前后的冒泡排序
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
@@ -3458,7 +3458,7 @@ n(n - 4) & > 0我们知道,分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化。
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
比如在图 12-3 所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
- +图 12-3 桶排序的并行计算
图 12-10 汉诺塔问题示例
我们将规模为 \(i\) 的汉诺塔问题记做 \(f(i)\) 。例如 \(f(3)\) 代表将 \(3\) 个圆盘从 A 移动至 C 的汉诺塔问题。
A 从 B 移至 C 。对于这两个子问题 \(f(n-1)\) ,可以通过相同的方式进行递归划分,直至达到最小子问题 \(f(1)\) 。而 \(f(1)\) 的解是已知的,只需一次移动操作即可。
- +图 12-14 汉诺塔问题的分治策略
如图 12-15 所示,汉诺塔问题形成一个高度为 \(n\) 的递归树,每个节点代表一个子问题、对应一个开启的 dfs() 函数,因此时间复杂度为 \(O(2^n)\) ,空间复杂度为 \(O(n)\) 。
图 12-15 汉诺塔问题的递归树
Abstract
diff --git a/chapter_dynamic_programming/dp_problem_features/index.html b/chapter_dynamic_programming/dp_problem_features/index.html index 3399e060c..fedc2aa3c 100644 --- a/chapter_dynamic_programming/dp_problem_features/index.html +++ b/chapter_dynamic_programming/dp_problem_features/index.html @@ -3366,7 +3366,7 @@给定一个楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 \(cost\) ,其中 \(cost[i]\) 表示在第 \(i\) 个台阶需要付出的代价,\(cost[0]\) 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
如图 14-6 所示,若第 \(1\)、\(2\)、\(3\) 阶的代价分别为 \(1\)、\(10\)、\(1\) ,则从地面爬到第 \(3\) 阶的最小代价为 \(2\) 。
- +图 14-6 爬到第 3 阶的最小代价
设 \(dp[i]\) 为爬到第 \(i\) 阶累计付出的代价,由于第 \(i\) 阶只可能从 \(i - 1\) 阶或 \(i - 2\) 阶走来,因此 \(dp[i]\) 只可能等于 \(dp[i - 1] + cost[i]\) 或 \(dp[i - 2] + cost[i]\) 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
@@ -3616,7 +3616,7 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]图 14-7 展示了以上代码的动态规划过程。
- +图 14-7 爬楼梯最小代价的动态规划过程
本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
@@ -3834,7 +3834,7 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,但不能连续两轮跳 \(1\) 阶,请问有多少种方案可以爬到楼顶。
例如图 14-8 ,爬上第 \(3\) 阶仅剩 \(2\) 种可行方案,其中连续三次跳 \(1\) 阶的方案不满足约束条件,因此被舍弃。
- +图 14-8 带约束爬到第 3 阶的方案数量
在该问题中,如果上一轮是跳 \(1\) 阶上来的,那么下一轮就必须跳 \(2\) 阶。这意味着,下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关。
@@ -3851,7 +3851,7 @@ dp[i, 1] = dp[i-1, 2] \\ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2] \end{cases} \] - +图 14-9 考虑约束下的递推关系
最终,返回 \(dp[n, 1] + dp[n, 2]\) 即可,两者之和代表爬到第 \(n\) 阶的方案总数。
diff --git a/chapter_dynamic_programming/dp_solution_pipeline/index.html b/chapter_dynamic_programming/dp_solution_pipeline/index.html index 42ccc106f..efdaa5772 100644 --- a/chapter_dynamic_programming/dp_solution_pipeline/index.html +++ b/chapter_dynamic_programming/dp_solution_pipeline/index.html @@ -3448,14 +3448,14 @@给定一个 \(n \times m\) 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。
图 14-10 展示了一个例子,给定网格的最小路径和为 \(13\) 。
- +图 14-10 最小路径和示例数据
第一步:思考每轮的决策,定义状态,从而得到 \(dp\) 表
本题的每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为 \([i, j]\) ,则向下或向右走一步后,索引变为 \([i+1, j]\) 或 \([i, j+1]\) 。因此,状态应包含行索引和列索引两个变量,记为 \([i, j]\) 。
状态 \([i, j]\) 对应的子问题为:从起始点 \([0, 0]\) 走到 \([i, j]\) 的最小路径和,解记为 \(dp[i, j]\) 。
至此,我们就得到了图 14-11 所示的二维 \(dp\) 矩阵,其尺寸与输入网格 \(grid\) 相同。
- +图 14-11 状态定义与 dp 表
图 14-12 最优子结构与状态转移方程
第三步:确定边界条件和状态转移顺序
在本题中,首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行 \(i = 0\) 和首列 \(j = 0\) 是边界条件。
如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
- +图 14-13 边界条件与状态转移顺序
图 14-14 给出了以 \(dp[2, 1]\) 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。
本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。
- +图 14-14 暴力搜索递归树
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
@@ -4037,7 +4037,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]如图 14-15 所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 \(O(nm)\) 。
- +图 14-15 记忆化搜索递归树
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。
如图 14-27 所示,将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。
图 14-27 编辑距离的示例数据
编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。
如图 14-28 所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。
从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。
图 14-28 基于决策树模型表示编辑距离问题
图 14-29 编辑距离的状态转移
根据以上分析,可得最优子结构:\(dp[i, j]\) 的最少编辑步数等于 \(dp[i, j-1]\)、\(dp[i-1, j]\)、\(dp[i-1, j-1]\) 三者中的最少编辑步数,再加上本次的编辑步数 \(1\) 。对应的状态转移方程为:
@@ -3765,49 +3765,49 @@ dp[i, j] = dp[i-1, j-1] diff --git a/chapter_dynamic_programming/index.html b/chapter_dynamic_programming/index.html index 093468114..57eb9d76b 100644 --- a/chapter_dynamic_programming/index.html +++ b/chapter_dynamic_programming/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html index b50e669fa..938d7fcfd 100644 --- a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html +++ b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html @@ -3387,7 +3387,7 @@给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,请问有多少种方案可以爬到楼顶。
如图 14-1 所示,对于一个 \(3\) 阶楼梯,共有 \(3\) 种方案可以爬到楼顶。
- +图 14-1 爬到第 3 阶的方案数量
本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 \(1\) 阶或 \(2\) 阶,每当到达楼梯顶部时就将方案数量加 \(1\) ,当越过楼梯顶部时就将其剪枝。
@@ -3748,7 +3748,7 @@ dp[i-1], dp[i-2], \dots, dp[2], dp[1] dp[i] = dp[i-1] + dp[i-2] \]这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。图 14-2 展示了该递推关系。
- +图 14-2 方案数量递推关系
我们可以根据递推公式得到暴力搜索解法。以 \(dp[n]\) 为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 \(dp[1]\) 和 \(dp[2]\) 时返回。其中,最小子问题的解是已知的,即 \(dp[1] = 1\)、\(dp[2] = 2\) ,表示爬到第 \(1\)、\(2\) 阶分别有 \(1\)、\(2\) 种方案。
@@ -3959,7 +3959,7 @@ dp[i] = dp[i-1] + dp[i-2]图 14-3 展示了暴力搜索形成的递归树。对于问题 \(dp[n]\) ,其递归树的深度为 \(n\) ,时间复杂度为 \(O(2^n)\) 。指数阶属于爆炸式增长,如果我们输入一个比较大的 \(n\) ,则会陷入漫长的等待之中。
- +图 14-3 爬楼梯对应递归树
观察图 14-3 ,指数阶的时间复杂度是由于“重叠子问题”导致的。例如 \(dp[9]\) 被分解为 \(dp[8]\) 和 \(dp[7]\) ,\(dp[8]\) 被分解为 \(dp[7]\) 和 \(dp[6]\) ,两者都包含子问题 \(dp[7]\) 。
@@ -4269,7 +4269,7 @@ dp[i] = dp[i-1] + dp[i-2]观察图 14-4 ,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。
- +图 14-4 记忆化搜索对应递归树
图 14-5 模拟了以上代码的执行过程。
- +图 14-5 爬楼梯的动态规划过程
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 \(i\) 。
diff --git a/chapter_dynamic_programming/knapsack_problem/index.html b/chapter_dynamic_programming/knapsack_problem/index.html index 209fff468..6647ff675 100644 --- a/chapter_dynamic_programming/knapsack_problem/index.html +++ b/chapter_dynamic_programming/knapsack_problem/index.html @@ -3387,7 +3387,7 @@给定 \(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\) 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
@@ -3655,7 +3655,7 @@ 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-19 展示了在记忆化递归中被剪掉的搜索分支。
- +图 14-19 0-1 背包的记忆化搜索递归树
Question
给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品可以重复选取,问在不超过背包容量下能放入物品的最大价值。
- +图 14-22 完全背包问题的示例数据
Question
给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 \(-1\) 。
- +图 14-24 零钱兑换问题的示例数据
Question
给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问在凑出目标金额的硬币组合数量。
- +图 14-26 零钱兑换问题 II 的示例数据
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如图 9-1 所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂。
- +图 9-1 链表、树、图之间的关系
图 9-2 有向图与无向图
根据所有顶点是否连通,可分为图 9-3 所示的「连通图 connected graph」和「非连通图 disconnected graph」。
@@ -3432,11 +3432,11 @@ G & = \{ V, E \} \newline图 9-3 连通图与非连通图
我们还可以为边添加“权重”变量,从而得到图 9-4 所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
- +图 9-4 有权图与无权图
图数据结构包含以下常用术语。
@@ -3450,7 +3450,7 @@ G & = \{ V, E \} \newline设图的顶点数量为 \(n\) ,「邻接矩阵 adjacency matrix」使用一个 \(n \times n\) 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 \(1\) 或 \(0\) 表示两个顶点之间是否存在边。
如图 9-5 所示,设邻接矩阵为 \(M\)、顶点列表为 \(V\) ,那么矩阵元素 \(M[i, j] = 1\) 表示顶点 \(V[i]\) 到顶点 \(V[j]\) 之间存在边,反之 \(M[i, j] = 0\) 表示两顶点之间无边。
- +图 9-5 图的邻接矩阵表示
邻接矩阵具有以下特性。
@@ -3462,7 +3462,7 @@ G & = \{ V, E \} \newline使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 \(O(1)\) 。然而,矩阵的空间复杂度为 \(O(n^2)\) ,内存占用较多。
「邻接表 adjacency list」使用 \(n\) 个链表来表示图,链表节点表示顶点。第 \(i\) 条链表对应顶点 \(i\) ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。
- +图 9-6 图的邻接表表示
邻接表仅存储实际存在的边,而边的总数通常远小于 \(n^2\) ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
diff --git a/chapter_graph/graph_operations/index.html b/chapter_graph/graph_operations/index.html index 6d06b143d..549143a56 100644 --- a/chapter_graph/graph_operations/index.html +++ b/chapter_graph/graph_operations/index.html @@ -3378,19 +3378,19 @@ @@ -4400,19 +4400,19 @@ diff --git a/chapter_graph/graph_traversal/index.html b/chapter_graph/graph_traversal/index.html index b4d115cc5..2f7ffd3a7 100644 --- a/chapter_graph/graph_traversal/index.html +++ b/chapter_graph/graph_traversal/index.html @@ -3436,7 +3436,7 @@图和树都需要应用搜索算法来实现遍历操作。图的遍历方式可分为两种:「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也常被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」,简称 BFS 和 DFS 。
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。如图 9-9 所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
- +图 9-9 图的广度优先遍历
空间复杂度: 列表 res ,哈希表 visited ,队列 que 中的顶点数量最多为 \(|V|\) ,使用 \(O(|V|)\) 空间。
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如图 9-11 所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
- +图 9-11 图的深度优先遍历
Abstract
diff --git a/chapter_greedy/fractional_knapsack_problem/index.html b/chapter_greedy/fractional_knapsack_problem/index.html index 6984838c5..4532ca862 100644 --- a/chapter_greedy/fractional_knapsack_problem/index.html +++ b/chapter_greedy/fractional_knapsack_problem/index.html @@ -3370,7 +3370,7 @@Question
给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\)、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算,问在不超过背包容量下背包中物品的最大价值。
图 15-3 分数背包问题的示例数据
分数背包和 0-1 背包整体上非常相似,状态包含当前物品 \(i\) 和容量 \(c\) ,目标是求不超过背包容量下的最大价值。
@@ -3379,7 +3379,7 @@图 15-4 物品在单位重量下的价值
图 15-5 分数背包的贪心策略
现在从背包中拿出单位重量的任意物品,并替换为单位重量的物品 \(x\) 。由于物品 \(x\) 的单位价值最高,因此替换后的总价值一定大于 res 。这与 res 是最优解矛盾,说明最优解中必须包含物品 \(x\) 。
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,单位价值更大的物品总是更优选择,这说明贪心策略是有效的。
如图 15-6 所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可被转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
- +图 15-6 分数背包问题的几何表示
diff --git a/chapter_greedy/greedy_algorithm/index.html b/chapter_greedy/greedy_algorithm/index.html index 623a01fea..2b824a3ac 100644 --- a/chapter_greedy/greedy_algorithm/index.html +++ b/chapter_greedy/greedy_algorithm/index.html @@ -3392,7 +3392,7 @@给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 \(-1\) 。
本题的贪心策略如图 15-1 所示。给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步骤,直至凑出目标金额为止。
- +图 15-1 零钱兑换的贪心策略
实现代码如下所示。你可能会不由地发出感叹:So Clean !贪心算法仅用十行代码就解决了零钱兑换问题。
@@ -3648,7 +3648,7 @@图 15-2 贪心无法找出最优解的示例
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
diff --git a/chapter_greedy/index.html b/chapter_greedy/index.html index b5b3bf58b..6d161fb46 100644 --- a/chapter_greedy/index.html +++ b/chapter_greedy/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_greedy/max_capacity_problem/index.html b/chapter_greedy/max_capacity_problem/index.html index 6667e7b94..fb73ddcaa 100644 --- a/chapter_greedy/max_capacity_problem/index.html +++ b/chapter_greedy/max_capacity_problem/index.html @@ -3372,7 +3372,7 @@容器的容量等于高度和宽度的乘积(即面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。
请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。
图 15-7 最大容量问题的示例数据
容器由任意两个隔板围成,因此本题的状态为两个隔板的索引,记为 \([i, j]\) 。
@@ -3383,16 +3383,16 @@ cap[i, j] = \min(ht[i], ht[j]) \times (j - i)设数组长度为 \(n\) ,两个隔板的组合数量(即状态总数)为 \(C_n^2 = \frac{n(n - 1)}{2}\) 个。最直接地,我们可以穷举所有状态,从而求得最大容量,时间复杂度为 \(O(n^2)\) 。
这道题还有更高效率的解法。如图 15-8 所示,现选取一个状态 \([i, j]\) ,其满足索引 \(i < j\) 且高度 \(ht[i] < ht[j]\) ,即 \(i\) 为短板、\(j\) 为长板。
- +图 15-8 初始状态
如图 15-9 所示,若此时将长板 \(j\) 向短板 \(i\) 靠近,则容量一定变小。
这是因为在移动长板 \(j\) 后,宽度 \(j-i\) 肯定变小;而高度由短板决定,因此高度只可能不变( \(i\) 仍为短板)或变小(移动后的 \(j\) 成为短板)。
- +图 15-9 向内移动长板后的状态
反向思考,我们只有向内收缩短板 \(i\) ,才有可能使容量变大。因为虽然宽度一定变小,但高度可能会变大(移动后的短板 \(i\) 可能会变长)。例如在图 15-10 中,移动短板后面积变大。
- +图 15-10 向内移动短板后的状态
由此便可推出本题的贪心策略:初始化两指针分裂容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
@@ -3406,31 +3406,31 @@ cap[i, j] = \min(ht[i], ht[j]) \times (j - i) @@ -3707,7 +3707,7 @@ cap[i, j] = \min(ht[i], ht[j]) \times (j - i)图 15-12 移动短板导致被跳过的状态
观察发现,这些被跳过的状态实际上就是将长板 \(j\) 向内移动的所有状态。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,跳过它们不会导致错过最优解。
diff --git a/chapter_greedy/max_product_cutting_problem/index.html b/chapter_greedy/max_product_cutting_problem/index.html index 3e8afa912..524eedcc9 100644 --- a/chapter_greedy/max_product_cutting_problem/index.html +++ b/chapter_greedy/max_product_cutting_problem/index.html @@ -3370,7 +3370,7 @@Question
给定一个正整数 \(n\) ,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少。
- +图 15-13 最大切分乘积的问题定义
假设我们将 \(n\) 切分为 \(m\) 个整数因子,其中第 \(i\) 个因子记为 \(n_i\) ,即
@@ -3393,13 +3393,13 @@ n & \geq 4 \]如图 15-14 所示,当 \(n \geq 4\) 时,切分出一个 \(2\) 后乘积会变大,这说明大于等于 \(4\) 的整数都应该被切分。
贪心策略一:如果切分方案中包含 \(\geq 4\) 的因子,那么它就应该被继续切分。最终的切分方案只应出现 \(1\)、\(2\)、\(3\) 这三种因子。
- +图 15-14 切分导致乘积变大
接下来思考哪个因子是最优的。在 \(1\)、\(2\)、\(3\) 这三个因子中,显然 \(1\) 是最差的,因为 \(1 \times (n-1) < n\) 恒成立,即切分出 \(1\) 反而会导致乘积减小。
如图 15-15 所示,当 \(n = 6\) 时,有 \(3 \times 3 > 2 \times 2 \times 2\) 。这意味着切分出 \(3\) 比切分出 \(2\) 更优。
贪心策略二:在切分方案中,最多只应存在两个 \(2\) 。因为三个 \(2\) 总是可以被替换为两个 \(3\) ,从而获得更大乘积。
- +图 15-15 最优切分因子
总结以上,可推出以下贪心策略。
@@ -3671,7 +3671,7 @@ n = 3 a + b - +图 15-16 最大切分乘积的计算方法
时间复杂度取决于编程语言的幂运算的实现方法。以 Python 为例,常用的幂计算函数有三种。
diff --git a/chapter_hashing/hash_algorithm/index.html b/chapter_hashing/hash_algorithm/index.html index 8584134a0..2c7f3739a 100644 --- a/chapter_hashing/hash_algorithm/index.html +++ b/chapter_hashing/hash_algorithm/index.html @@ -3382,7 +3382,7 @@在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,它们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图 6-8 所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 \(O(n)\) 。
- +图 6-8 哈希冲突的最佳与最差情况
键值对的分布情况由哈希函数决定。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
diff --git a/chapter_hashing/hash_collision/index.html b/chapter_hashing/hash_collision/index.html index e477ca335..8791cfa7e 100644 --- a/chapter_hashing/hash_collision/index.html +++ b/chapter_hashing/hash_collision/index.html @@ -3429,7 +3429,7 @@哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。图 6-5 展示了一个链式地址哈希表的例子。
- +图 6-5 链式地址哈希表
基于链式地址实现的哈希表的操作方法发生了以下变化。
@@ -4718,12 +4718,12 @@value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 \(\text{None}\) 。图 6-6 展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。
图 6-6 开放寻址和线性探测
然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 \(\text{None}\) ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
- +图 6-7 在开放寻址中删除元素导致的查询问题
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,\(\text{None}\) 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。
「哈希表 hash table」,又称「散列表」,其通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 key ,则可以在 \(O(1)\) 时间内获取对应的值 value 。
如图 6-1 所示,给定 \(n\) 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用图 6-1 所示的哈希表来实现。
- +图 6-1 哈希表的抽象表示
除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如表 6-1 所示。
@@ -3823,7 +3823,7 @@随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value 。
设数组长度 capacity = 100、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。图 6-2 以 key 学号和 value 姓名为例,展示了哈希函数的工作原理。
图 6-2 哈希函数工作原理
以下代码实现了一个简单哈希表。其中,我们将 key 和 value 封装成一个类 Pair ,以表示键值对。
如图 6-3 所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 hash collision」。
- +图 6-3 哈希冲突示例
容易想到,哈希表容量 \(n\) 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。
如图 6-4 所示,扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失。
图 6-4 哈希表扩容
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
Abstract
diff --git a/chapter_heap/build_heap/index.html b/chapter_heap/build_heap/index.html index 662a91bba..6526ffe11 100644 --- a/chapter_heap/build_heap/index.html +++ b/chapter_heap/build_heap/index.html @@ -3543,7 +3543,7 @@将上述两者相乘,可得到建堆过程的时间复杂度为 \(O(n \log n)\) 。但这个估算结果并不准确,因为我们没有考虑到二叉树底层节点数量远多于顶层节点的性质。
接下来我们来进行更为准确的计算。为了减小计算难度,假设给定一个节点数量为 \(n\) ,高度为 \(h\) 的“完美二叉树”,该假设不会影响计算结果的正确性。
- +图 8-5 完美二叉树的各层节点数量
如图 8-5 所示,节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,我们可以将各层的“节点数量 \(\times\) 节点高度”求和,从而得到所有节点的堆化迭代次数的总和。
diff --git a/chapter_heap/heap/index.html b/chapter_heap/heap/index.html index 76bc50192..25a31ef8c 100644 --- a/chapter_heap/heap/index.html +++ b/chapter_heap/heap/index.html @@ -3439,7 +3439,7 @@图 8-1 小顶堆与大顶堆
堆作为完全二叉树的一个特例,具有以下特性。
@@ -3791,7 +3791,7 @@我们在二叉树章节中学习到,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,我们将采用数组来存储堆。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。
如图 8-2 所示,给定索引 \(i\) ,其左子节点索引为 \(2i + 1\) ,右子节点索引为 \(2i + 2\) ,父节点索引为 \((i - 1) / 2\)(向下取整)。当索引越界时,表示空节点或节点不存在。
- +图 8-2 堆的表示与存储
我们可以将索引映射公式封装成函数,方便后续使用。
@@ -4097,31 +4097,31 @@ @@ -4455,34 +4455,34 @@ diff --git a/chapter_heap/index.html b/chapter_heap/index.html index 29559d8a4..0008af111 100644 --- a/chapter_heap/index.html +++ b/chapter_heap/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_heap/top_k/index.html b/chapter_heap/top_k/index.html index 2852559e7..400eedcad 100644 --- a/chapter_heap/top_k/index.html +++ b/chapter_heap/top_k/index.html @@ -3374,7 +3374,7 @@我们可以进行图 8-6 所示的 \(k\) 轮遍历,分别在每轮中提取第 \(1\)、\(2\)、\(\dots\)、\(k\) 大的元素,时间复杂度为 \(O(nk)\) 。
此方法只适用于 \(k \ll n\) 的情况,因为当 \(k\) 与 \(n\) 比较接近时,其时间复杂度趋向于 \(O(n^2)\) ,非常耗时。
- +图 8-6 遍历寻找最大的 k 个元素
如图 8-7 所示,我们可以先对数组 nums 进行排序,再返回最右边的 \(k\) 个元素,时间复杂度为 \(O(n \log n)\) 。
显然,该方法“超额”完成任务了,因为我们只需要找出最大的 \(k\) 个元素即可,而不需要排序其他元素。
- +图 8-7 排序寻找最大的 k 个元素
2. ,每一轮将一张扑克牌从无序部分插入至有序部分,直至所有扑克牌都有序。图 1-2 扑克排序步骤
上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
@@ -3334,7 +3334,7 @@图 1-3 货币找零过程
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是“贪心”算法。
diff --git a/chapter_introduction/index.html b/chapter_introduction/index.html index 42b77e040..011fabe83 100644 --- a/chapter_introduction/index.html +++ b/chapter_introduction/index.html @@ -3292,7 +3292,7 @@Abstract
diff --git a/chapter_introduction/what_is_dsa/index.html b/chapter_introduction/what_is_dsa/index.html index 9366f2713..b6b8656c5 100644 --- a/chapter_introduction/what_is_dsa/index.html +++ b/chapter_introduction/what_is_dsa/index.html @@ -3392,11 +3392,11 @@图 1-4 数据结构与算法的关系
数据结构与算法犹如图 1-5 所示的拼装积木。一套积木,除了包含许多零件之外,还附有详细的组装说明书。我们按照说明书一步步操作,就能组装出精美的积木模型。
- +图 1-5 拼装积木
两者的详细对应关系如表 1-1 所示。
diff --git a/chapter_preface/about_the_book/index.html b/chapter_preface/about_the_book/index.html index d857a3102..de355d41b 100644 --- a/chapter_preface/about_the_book/index.html +++ b/chapter_preface/about_the_book/index.html @@ -3387,7 +3387,7 @@图 0-1 Hello 算法内容结构
Abstract
diff --git a/chapter_preface/suggestions/index.html b/chapter_preface/suggestions/index.html index 6c6662e1e..9905c0d94 100644 --- a/chapter_preface/suggestions/index.html +++ b/chapter_preface/suggestions/index.html @@ -3544,14 +3544,14 @@相较于文字,视频和图片具有更高的信息密度和结构化程度,更易于理解。在本书中,重点和难点知识将主要通过动画和图解形式展示,而文字则作为动画和图片的解释与补充。
如果你在阅读本书时,发现某段内容提供了图 0-2 所示的动画或图解,请以图为主、以文字为辅,综合两者来理解内容。
- +图 0-2 动画图解示例
本书的配套代码被托管在 GitHub 仓库。如图 0-3 所示,源代码附有测试样例,可一键运行。
如果时间允许,建议你参照代码自行敲一遍。如果学习时间有限,请至少通读并运行所有代码。
与阅读代码相比,编写代码的过程往往能带来更多收获。动手学,才是真的学。
- +图 0-3 运行代码示例
运行代码的前置工作主要分为三步。
@@ -3560,17 +3560,17 @@当然,你也可以在图 0-4 所示的位置,点击“Download ZIP”直接下载代码压缩包,然后在本地解压即可。
- +图 0-4 克隆仓库与下载代码
第三步:运行源代码。如图 0-5 所示,对于顶部标有文件名称的代码块,我们可以在仓库的 codes 文件夹内找到对应的源代码文件。源代码文件可一键运行,将帮助你节省不必要的调试时间,让你能够专注于学习内容。
图 0-5 代码块与对应的源代码文件
在阅读本书时,请不要轻易跳过那些没学明白的知识点。欢迎在评论区提出你的问题,我和小伙伴们将竭诚为你解答,一般情况下可在两天内回复。
如图 0-6 所示,每篇文章的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享您的见解,帮助他人进步。
- +图 0-6 评论区示例
如图 0-7 所示,本书内容主要涵盖“第一阶段”,旨在帮助你更高效地展开第二和第三阶段的学习。
- +图 0-7 算法学习路线
diff --git a/chapter_searching/binary_search/index.html b/chapter_searching/binary_search/index.html index 9a85974ef..ae24af8d9 100644 --- a/chapter_searching/binary_search/index.html +++ b/chapter_searching/binary_search/index.html @@ -3357,7 +3357,7 @@Question
给定一个长度为 \(n\) 的数组 nums ,元素按从小到大的顺序排列,数组不包含重复元素。请查找并返回元素 target 在该数组中的索引。若数组不包含该元素,则返回 \(-1\) 。
图 10-1 二分查找示例数据
如图 10-2 所示,我们先初始化指针 \(i = 0\) 和 \(j = n - 1\) ,分别指向数组首元素和尾元素,代表搜索区间 \([0, n - 1]\) 。请注意,中括号表示闭区间,其包含边界值本身。
@@ -3375,25 +3375,25 @@ @@ -3929,7 +3929,7 @@如图 10-3 所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此指针 \(i\) 和 \(j\) 缩小区间操作也是对称的。这样更不容易出错,因此一般建议采用“双闭区间”的写法。
- +图 10-3 两种区间定义
实际上,我们可以利用查找最左元素的函数来查找最右元素,具体方法为:将查找最右一个 target 转化为查找最左一个 target + 1。
如图 10-7 所示,查找完成后,指针 \(i\) 指向最左一个 target + 1(如果存在),而 \(j\) 指向最右一个 target ,因此返回 \(j\) 即可。
图 10-7 将查找右边界转化为查找左边界
请注意,返回的插入点是 \(i\) ,因此需要将其减 \(1\) ,从而获得 \(j\) 。
@@ -3763,7 +3763,7 @@target :可以转化为查找 target - 0.5 ,并返回指针 \(i\) 。target :可以转化为查找 target + 0.5 ,并返回指针 \(j\) 。图 10-8 将查找边界转化为查找元素
代码在此省略,值得注意以下两点。
diff --git a/chapter_searching/binary_search_insertion/index.html b/chapter_searching/binary_search_insertion/index.html index c35566cb6..580cd8e54 100644 --- a/chapter_searching/binary_search_insertion/index.html +++ b/chapter_searching/binary_search_insertion/index.html @@ -3358,7 +3358,7 @@Question
给定一个长度为 \(n\) 的有序数组 nums 和一个元素 target ,数组不存在重复元素。现将 target 插入到数组 nums 中,并保持其有序性。若数组中已存在元素 target ,则插入到其左方。请返回插入后 target 在数组中的索引。
图 10-4 二分查找插入点示例数据
如果想要复用上节的二分查找代码,则需要回答以下两个问题。
@@ -3602,7 +3602,7 @@target 的索引,记为 \(k\) 。target 时返回。图 10-5 线性查找重复元素的插入点
此方法虽然可用,但其包含线性查找,因此时间复杂度为 \(O(n)\) 。当数组中存在很多重复的 target 时,该方法效率很低。
Abstract
diff --git a/chapter_searching/replace_linear_by_hashing/index.html b/chapter_searching/replace_linear_by_hashing/index.html index 239aeaea0..5b111812d 100644 --- a/chapter_searching/replace_linear_by_hashing/index.html +++ b/chapter_searching/replace_linear_by_hashing/index.html @@ -3359,7 +3359,7 @@考虑直接遍历所有可能的组合。如图 10-9 所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 target ,若是则返回它们的索引。
图 10-9 线性查找求解两数之和
给定大小为 \(n\) 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法在该数据中搜索目标元素。各个方法的工作原理如图 10-11 所示。
- +图 10-11 多种搜索策略
上述几种方法的操作效率与特性如表 10-1 所示。
diff --git a/chapter_sorting/bubble_sort/index.html b/chapter_sorting/bubble_sort/index.html index 2e6b151ba..1aea9af61 100644 --- a/chapter_sorting/bubble_sort/index.html +++ b/chapter_sorting/bubble_sort/index.html @@ -3371,25 +3371,25 @@ @@ -3403,7 +3403,7 @@图 11-5 冒泡排序流程
图 11-13 桶排序算法流程
桶排序的时间复杂度理论上可以达到 \(O(n)\) ,关键在于将元素均匀分配到各个桶中,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。
为实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等。
如图 11-14 所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
- +图 11-14 递归划分桶
如果我们提前知道商品价格的概率分布,则可以根据数据概率分布设置每个桶的价格分界线。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。
如图 11-15 所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。
- +图 11-15 根据概率分布划分桶
diff --git a/chapter_sorting/counting_sort/index.html b/chapter_sorting/counting_sort/index.html index 328fd3129..533ae6588 100644 --- a/chapter_sorting/counting_sort/index.html +++ b/chapter_sorting/counting_sort/index.html @@ -3388,7 +3388,7 @@counter 统计 nums 中各数字的出现次数,其中 counter[num] 对应数字 num 的出现次数。统计方法很简单,只需遍历 nums(设当前数字为 num),每轮将 counter[num] 增加 \(1\) 即可。counter 的各个索引天然有序,因此相当于所有数字已经被排序好了。接下来,我们遍历 counter ,根据各数字的出现次数,将它们按从小到大的顺序填入 nums 即可。图 11-16 计数排序流程
Abstract
diff --git a/chapter_sorting/insertion_sort/index.html b/chapter_sorting/insertion_sort/index.html index 9b457c0ce..17e2f0969 100644 --- a/chapter_sorting/insertion_sort/index.html +++ b/chapter_sorting/insertion_sort/index.html @@ -3369,7 +3369,7 @@「插入排序 insertion sort」是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
图 11-6 展示了数组插入元素的操作流程。设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元素向右移动一位,然后再将 base 赋值给目标索引。
图 11-6 单次插入操作
base ,将其插入到正确位置后,数组的前 3 个元素已排序。base ,将其插入到正确位置后,所有元素均已排序。图 11-7 插入排序流程
图 11-10 归并排序的划分与合并阶段
图 11-9 快速排序流程
2. 继续迭代,直到所有位都排序完成后结束。图 11-18 基数排序算法流程
下面来剖析代码实现。对于一个 \(d\) 进制的数字 \(x\) ,要获取其第 \(k\) 位 \(x_k\) ,可以使用以下计算公式:
diff --git a/chapter_sorting/selection_sort/index.html b/chapter_sorting/selection_sort/index.html index df305e782..266e6d2f5 100644 --- a/chapter_sorting/selection_sort/index.html +++ b/chapter_sorting/selection_sort/index.html @@ -3350,37 +3350,37 @@ @@ -3606,7 +3606,7 @@nums[i] 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。图 11-3 选择排序非稳定示例
diff --git a/chapter_sorting/sorting_algorithm/index.html b/chapter_sorting/sorting_algorithm/index.html index 38b1ce0a7..59df5e29a 100644 --- a/chapter_sorting/sorting_algorithm/index.html +++ b/chapter_sorting/sorting_algorithm/index.html @@ -3354,7 +3354,7 @@「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。
如图 11-1 所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
- +图 11-1 数据类型和判断规则示例
图 11-19 排序算法对比
在队列中,我们仅能在头部删除或在尾部添加元素。如图 5-7 所示,「双向队列 double-ended queue」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
- +图 5-7 双向队列的操作
Abstract
diff --git a/chapter_stack_and_queue/queue/index.html b/chapter_stack_and_queue/queue/index.html index d8c3de241..baedfd779 100644 --- a/chapter_stack_and_queue/queue/index.html +++ b/chapter_stack_and_queue/queue/index.html @@ -3408,7 +3408,7 @@「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。
如图 5-4 所示,我们将队列的头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
- +图 5-4 队列的先入先出规则
「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
如图 5-1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,删除栈顶元素的操作叫做“出栈”。
- +图 5-1 栈的先入后出规则
先分析一个简单案例。给定一个完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:若节点的索引为 \(i\) ,则该节点的左子节点索引为 \(2i + 1\) ,右子节点索引为 \(2i + 2\) 。图 7-12 展示了各个节点索引之间的映射关系。
- +图 7-12 完美二叉树的数组表示
映射公式的角色相当于链表中的指针。给定数组中的任意一个节点,我们都可以通过映射公式来访问它的左(右)子节点。
完美二叉树是一个特例,在二叉树的中间层通常存在许多 \(\text{None}\) 。由于层序遍历序列并不包含这些 \(\text{None}\) ,因此我们无法仅凭该序列来推测 \(\text{None}\) 的数量和分布位置。这意味着存在多种二叉树结构都符合该层序遍历序列。
如图 7-13 所示,给定一个非完美二叉树,上述的数组表示方法已经失效。
- +图 7-13 层序遍历序列对应多种二叉树可能性
为了解决此问题,我们可以考虑在层序遍历序列中显式地写出所有 \(\text{None}\) 。如图 7-14 所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
@@ -3456,12 +3456,12 @@图 7-14 任意类型二叉树的数组表示
值得说明的是,完全二叉树非常适合使用数组来表示。回顾完全二叉树的定义,\(\text{None}\) 只出现在最底层且靠右的位置,因此所有 \(\text{None}\) 一定出现在层序遍历序列的末尾。
这意味着使用数组表示完全二叉树时,可以省略存储所有 \(\text{None}\) ,非常方便。图 7-15 给出了一个例子。
- +图 7-15 完全二叉树的数组表示
以下代码实现了一个基于数组表示的二叉树,包括以下几种操作。
diff --git a/chapter_tree/avl_tree/index.html b/chapter_tree/avl_tree/index.html index cd074b5c9..86a29d4b9 100644 --- a/chapter_tree/avl_tree/index.html +++ b/chapter_tree/avl_tree/index.html @@ -3558,11 +3558,11 @@在二叉搜索树章节中,我们提到了在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 \(O(\log n)\) 恶化为 \(O(n)\) 。
如图 7-24 所示,经过两次删除节点操作,这个二叉搜索树便会退化为链表。
- +图 7-24 AVL 树在删除节点后发生退化
再例如,在图 7-25 的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
- +图 7-25 AVL 树在插入节点后发生退化
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 \(O(\log n)\) 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
@@ -4086,23 +4086,23 @@图 7-26 右旋操作步骤
如图 7-27 所示,当节点 child 有右子节点(记为 grandChild )时,需要在右旋中添加一步:将 grandChild 作为 node 的左子节点。
图 7-27 有 grandChild 的右旋操作
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示。
@@ -4309,11 +4309,11 @@相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行图 7-28 所示的“左旋”操作。
- +图 7-28 左旋操作
同理,如图 7-29 所示,当节点 child 有左子节点(记为 grandChild )时,需要在左旋中添加一步:将 grandChild 作为 node 的右子节点。
图 7-29 有 grandChild 的左旋操作
可以观察到,右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的。基于对称性,我们只需将右旋的实现代码中的所有的 left 替换为 right ,将所有的 right 替换为 left ,即可得到左旋的实现代码。
对于图 7-30 中的失衡节点 3 ,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先对 child 执行“左旋”,再对 node 执行“右旋”。
图 7-30 先左旋后右旋
如图 7-31 所示,对于上述失衡二叉树的镜像情况,需要先对 child 执行“右旋”,然后对 node 执行“左旋”。
图 7-31 先右旋后左旋
图 7-32 展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。
- +图 7-32 AVL 树的四种旋转情况
如下表所示,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于图 7-32 中的哪种情况。
diff --git a/chapter_tree/binary_search_tree/index.html b/chapter_tree/binary_search_tree/index.html index cae28ee2d..47718ac86 100644 --- a/chapter_tree/binary_search_tree/index.html +++ b/chapter_tree/binary_search_tree/index.html @@ -3439,7 +3439,7 @@1. 。图 7-16 二叉搜索树
num 的大小关系循环向下搜索,直到越过叶节点(遍历至 \(\text{None}\) )时跳出循环。num ,将该节点置于 \(\text{None}\) 的位置。图 7-18 在二叉搜索树中插入节点
在代码实现中,需要注意以下两点。
@@ -4134,11 +4134,11 @@与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。
因此,我们需要根据目标节点的子节点数量,共分为 0、1 和 2 这三种情况,执行对应的删除节点操作。
如图 7-19 所示,当待删除节点的度为 \(0\) 时,表示该节点是叶节点,可以直接删除。
- +图 7-19 在二叉搜索树中删除节点(度为 0 )
如图 7-20 所示,当待删除节点的度为 \(1\) 时,将待删除节点替换为其子节点即可。
- +图 7-20 在二叉搜索树中删除节点(度为 1 )
当待删除节点的度为 \(2\) 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 \(<\) 根 \(<\) 右”的性质,因此这个节点可以是右子树的最小节点或左子树的最大节点。
@@ -4150,16 +4150,16 @@ @@ -4820,7 +4820,7 @@如图 7-22 所示,二叉树的中序遍历遵循“左 \(\rightarrow\) 根 \(\rightarrow\) 右”的遍历顺序,而二叉搜索树满足“左子节点 \(<\) 根节点 \(<\) 右子节点”的大小关系。
这意味着在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:二叉搜索树的中序遍历序列是升序的。
利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 \(O(n)\) 时间,无须进行额外的排序操作,非常高效。
- +图 7-22 二叉搜索树的中序遍历序列
在理想情况下,二叉搜索树是“平衡”的,这样就可以在 \(\log n\) 轮循环内查找任意节点。
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为图 7-23 所示的链表,这时各种操作的时间复杂度也会退化为 \(O(n)\) 。
- +图 7-23 二叉搜索树的退化
每个节点都有两个引用(指针),分别指向「左子节点 left-child node」和「右子节点 right-child node」,该节点被称为这两个子节点的「父节点 parent node」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树 left subtree」,同理可得「右子树 right subtree」。
在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树。如图 7-1 所示,如果将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
- +图 7-1 父节点、子节点、子树
图 7-2 二叉树的常用术语
与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。图 7-3 给出了一个示例。
- +图 7-3 在二叉树中插入与删除节点
Tip
请注意,在中文社区中,完美二叉树常被称为「满二叉树」。
图 7-4 完美二叉树
如图 7-5 所示,「完全二叉树 complete binary tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。
- +图 7-5 完全二叉树
如图 7-6 所示,「完满二叉树 full binary tree」除了叶节点之外,其余所有节点都有两个子节点。
- +图 7-6 完满二叉树
如图 7-7 所示,「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
- +图 7-7 平衡二叉树
图 7-8 二叉树的最佳与最差结构
如表 7-1 所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
diff --git a/chapter_tree/binary_tree_traversal/index.html b/chapter_tree/binary_tree_traversal/index.html index 216c44744..39e60b4ae 100644 --- a/chapter_tree/binary_tree_traversal/index.html +++ b/chapter_tree/binary_tree_traversal/index.html @@ -3437,7 +3437,7 @@如图 7-9 所示,「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
层序遍历本质上属于「广度优先遍历 breadth-first traversal」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
- +图 7-9 二叉树的层序遍历
相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
图 7-10 展示了对二叉树进行深度优先遍历的工作原理。深度优先遍历就像是绕着整个二叉树的外围“走”一圈,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
- +图 7-10 二叉搜索树的前、中、后序遍历
Abstract
diff --git a/index.html b/index.html index e8ab255e9..530beb338 100644 --- a/index.html +++ b/index.html @@ -3258,8 +3258,8 @@
-
-
+
+