build
This commit is contained in:
parent
0c9bf14e20
commit
92a0853ab8
@ -16,7 +16,7 @@ comments: true
|
||||
|
||||
## 16.2.1 内容微调
|
||||
|
||||
如下图所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码:
|
||||
如图 16-1 所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码:
|
||||
|
||||
1. 点击“编辑图标”,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。
|
||||
2. 修改 Markdown 源文件内容,检查内容的正确性,并尽量保持排版格式的统一。
|
||||
@ -24,7 +24,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:页面编辑按键 </p>
|
||||
<p align="center"> 图 16-1 页面编辑按键 </p>
|
||||
|
||||
图片无法直接修改,需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述问题,我们会尽快重新绘制并替换图片。
|
||||
|
||||
|
||||
@ -4,11 +4,11 @@ comments: true
|
||||
|
||||
# 4.1 数组
|
||||
|
||||
「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
|
||||
「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。图 4-1 展示了数组的主要术语和概念。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数组定义与存储方式 </p>
|
||||
<p align="center"> 图 4-1 数组定义与存储方式 </p>
|
||||
|
||||
## 4.1.1 数组常用操作
|
||||
|
||||
@ -129,9 +129,9 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数组元素的内存地址计算 </p>
|
||||
<p align="center"> 图 4-2 数组元素的内存地址计算 </p>
|
||||
|
||||
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 也是合理的。
|
||||
观察图 4-2 ,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 也是合理的。
|
||||
|
||||
在数组中访问元素是非常高效的,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
|
||||
|
||||
@ -293,11 +293,11 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
|
||||
### 3. 插入元素
|
||||
|
||||
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
|
||||
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数组插入元素示例 </p>
|
||||
<p align="center"> 图 4-3 数组插入元素示例 </p>
|
||||
|
||||
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
|
||||
|
||||
@ -470,11 +470,11 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
|
||||
### 4. 删除元素
|
||||
|
||||
同理,如下图所示,若想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
|
||||
同理,如图 4-4 所示,若想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数组删除元素示例 </p>
|
||||
<p align="center"> 图 4-4 数组删除元素示例 </p>
|
||||
|
||||
请注意,删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无须特意去修改它。
|
||||
|
||||
|
||||
@ -12,9 +12,9 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:链表定义与存储方式 </p>
|
||||
<p align="center"> 图 4-5 链表定义与存储方式 </p>
|
||||
|
||||
观察上图,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。
|
||||
观察图 4-5 ,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。
|
||||
|
||||
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
|
||||
- 尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。
|
||||
@ -405,13 +405,13 @@ comments: true
|
||||
|
||||
### 2. 插入节点
|
||||
|
||||
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,**则只需要改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
|
||||
在链表中插入节点非常容易。如图 4-6 所示,假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,**则只需要改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
|
||||
|
||||
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:链表插入节点示例 </p>
|
||||
<p align="center"> 图 4-6 链表插入节点示例 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -547,13 +547,13 @@ comments: true
|
||||
|
||||
### 3. 删除节点
|
||||
|
||||
如下图所示,在链表中删除节点也非常方便,**只需改变一个节点的引用(指针)即可**。
|
||||
如图 4-7 所示,在链表中删除节点也非常方便,**只需改变一个节点的引用(指针)即可**。
|
||||
|
||||
请注意,尽管在删除操作完成后节点 `P` 仍然指向 `n1` ,但实际上遍历此链表已经无法访问到 `P` ,这意味着 `P` 已经不再属于该链表了。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:链表删除节点 </p>
|
||||
<p align="center"> 图 4-7 链表删除节点 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -1102,9 +1102,9 @@ comments: true
|
||||
|
||||
## 4.2.2 数组 VS 链表
|
||||
|
||||
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
表 4-1 总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
|
||||
<p align="center"> 表:数组与链表的效率对比 </p>
|
||||
<p align="center"> 表 4-1 数组与链表的效率对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -1122,7 +1122,7 @@ comments: true
|
||||
|
||||
## 4.2.3 常见链表类型
|
||||
|
||||
如下图所示,常见的链表类型包括三种。
|
||||
如图 4-8 所示,常见的链表类型包括三种。
|
||||
|
||||
- **单向链表**:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
- **环形链表**:如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
@ -1327,7 +1327,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:常见链表种类 </p>
|
||||
<p align="center"> 图 4-8 常见链表种类 </p>
|
||||
|
||||
## 4.2.4 链表典型应用
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ comments: true
|
||||
|
||||
给定一个二叉树,搜索并记录所有值为 $7$ 的节点,请返回节点列表。
|
||||
|
||||
对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 $7$ ,若是则将该节点的值加入到结果列表 `res` 之中。相关过程实现如下图和以下代码所示。
|
||||
对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 $7$ ,若是则将该节点的值加入到结果列表 `res` 之中。相关过程实现如图 13-1 和以下代码所示。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -197,7 +197,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在前序遍历中搜索节点 </p>
|
||||
<p align="center"> 图 13-1 在前序遍历中搜索节点 </p>
|
||||
|
||||
## 13.1.1 尝试与回退
|
||||
|
||||
@ -466,7 +466,7 @@ comments: true
|
||||
|
||||
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。
|
||||
|
||||
观察下图所示的过程,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为逆向的。
|
||||
观察图 13-2 所示的过程,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为逆向的。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -501,7 +501,7 @@ comments: true
|
||||
=== "<11>"
|
||||

|
||||
|
||||
<p align="center"> 图:尝试与回退 </p>
|
||||
<p align="center"> 图 13-2 尝试与回退 </p>
|
||||
|
||||
## 13.1.2 剪枝
|
||||
|
||||
@ -795,11 +795,11 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
剪枝是一个非常形象的名词。如下图所示,在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提高了搜索效率。
|
||||
剪枝是一个非常形象的名词。如图 13-3 所示,在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提高了搜索效率。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:根据约束条件剪枝 </p>
|
||||
<p align="center"> 图 13-3 根据约束条件剪枝 </p>
|
||||
|
||||
## 13.1.3 框架代码
|
||||
|
||||
@ -1655,11 +1655,11 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
根据题意,我们在找到值为 7 的节点后应该继续搜索,**因此需要将记录解之后的 `return` 语句删除**。下图对比了保留或删除 `return` 语句的搜索过程。
|
||||
根据题意,我们在找到值为 7 的节点后应该继续搜索,**因此需要将记录解之后的 `return` 语句删除**。图 13-4 对比了保留或删除 `return` 语句的搜索过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:保留与删除 return 的搜索过程对比 </p>
|
||||
<p align="center"> 图 13-4 保留与删除 return 的搜索过程对比 </p>
|
||||
|
||||
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,**许多回溯问题都可以在该框架下解决**。我们只需根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法即可。
|
||||
|
||||
|
||||
@ -8,17 +8,17 @@ comments: true
|
||||
|
||||
根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 $n$ 个皇后和一个 $n \times n$ 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
|
||||
|
||||
如下图所示,当 $n = 4$ 时,共可以找到两个解。从回溯算法的角度看,$n \times n$ 大小的棋盘共有 $n^2$ 个格子,给出了所有的选择 `choices` 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 `state` 。
|
||||
如图 13-15 所示,当 $n = 4$ 时,共可以找到两个解。从回溯算法的角度看,$n \times n$ 大小的棋盘共有 $n^2$ 个格子,给出了所有的选择 `choices` 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 `state` 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:4 皇后问题的解 </p>
|
||||
<p align="center"> 图 13-15 4 皇后问题的解 </p>
|
||||
|
||||
下图展示了本题的三个约束条件:**多个皇后不能在同一行、同一列、同一对角线**。值得注意的是,对角线分为主对角线 `\` 和次对角线 `/` 两种。
|
||||
图 13-16 展示了本题的三个约束条件:**多个皇后不能在同一行、同一列、同一对角线**。值得注意的是,对角线分为主对角线 `\` 和次对角线 `/` 两种。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:n 皇后问题的约束条件 </p>
|
||||
<p align="center"> 图 13-16 n 皇后问题的约束条件 </p>
|
||||
|
||||
### 1. 逐行放置策略
|
||||
|
||||
@ -26,11 +26,11 @@ comments: true
|
||||
|
||||
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
|
||||
|
||||
如下图所示,为 $4$ 皇后问题的逐行放置过程。受画幅限制,下图仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
|
||||
如图 13-17 所示,为 $4$ 皇后问题的逐行放置过程。受画幅限制,图 13-17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:逐行放置策略 </p>
|
||||
<p align="center"> 图 13-17 逐行放置策略 </p>
|
||||
|
||||
本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。
|
||||
|
||||
@ -40,13 +40,13 @@ comments: true
|
||||
|
||||
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 $(row, col)$ ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,**即对角线上所有格子的 $row - col$ 为恒定值**。
|
||||
|
||||
也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助下图所示的数组 `diag1` ,记录每条主对角线上是否有皇后。
|
||||
也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 `diag1` ,记录每条主对角线上是否有皇后。
|
||||
|
||||
同理,**次对角线上的所有格子的 $row + col$ 是恒定值**。我们同样也可以借助数组 `diag2` 来处理次对角线约束。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:处理列约束和对角线约束 </p>
|
||||
<p align="center"> 图 13-18 处理列约束和对角线约束 </p>
|
||||
|
||||
### 3. 代码实现
|
||||
|
||||
|
||||
@ -6,9 +6,9 @@ comments: true
|
||||
|
||||
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。
|
||||
|
||||
下表列举了几个示例数据,包括输入数组和对应的所有排列。
|
||||
表 13-1 列举了几个示例数据,包括输入数组和对应的所有排列。
|
||||
|
||||
<p align="center"> 表:数组与链表的效率对比 </p>
|
||||
<p align="center"> 表 13-1 数组与链表的效率对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -30,11 +30,11 @@ comments: true
|
||||
|
||||
从回溯代码的角度看,候选集合 `choices` 是输入数组中的所有元素,状态 `state` 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,**因此 `state` 中的所有元素都应该是唯一的**。
|
||||
|
||||
如下图所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 `state` 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
|
||||
如图 13-5 所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 `state` 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:全排列的递归树 </p>
|
||||
<p align="center"> 图 13-5 全排列的递归树 </p>
|
||||
|
||||
### 1. 重复选择剪枝
|
||||
|
||||
@ -43,13 +43,13 @@ comments: true
|
||||
- 在做出选择 `choice[i]` 后,我们就将 `selected[i]` 赋值为 $\text{True}$ ,代表它已被选择。
|
||||
- 遍历选择列表 `choices` 时,跳过所有已被选择过的节点,即剪枝。
|
||||
|
||||
如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。
|
||||
如图 13-6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:全排列剪枝示例 </p>
|
||||
<p align="center"> 图 13-6 全排列剪枝示例 </p>
|
||||
|
||||
观察上图发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。
|
||||
观察图 13-6 发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。
|
||||
|
||||
### 2. 代码实现
|
||||
|
||||
@ -481,17 +481,17 @@ comments: true
|
||||
|
||||
假设输入数组为 $[1, 1, 2]$ 。为了方便区分两个重复元素 $1$ ,我们将第二个 $1$ 记为 $\hat{1}$ 。
|
||||
|
||||
如下图所示,上述方法生成的排列有一半都是重复的。
|
||||
如图 13-7 所示,上述方法生成的排列有一半都是重复的。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:重复排列 </p>
|
||||
<p align="center"> 图 13-7 重复排列 </p>
|
||||
|
||||
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝**,这样可以进一步提升算法效率。
|
||||
|
||||
### 1. 相等元素剪枝
|
||||
|
||||
观察下图,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝掉。
|
||||
观察图 13-8 ,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝掉。
|
||||
|
||||
同理,在第一轮选择 $2$ 之后,第二轮选择中的 $1$ 和 $\hat{1}$ 也会产生重复分支,因此也应将第二轮的 $\hat{1}$ 剪枝。
|
||||
|
||||
@ -499,7 +499,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:重复排列剪枝 </p>
|
||||
<p align="center"> 图 13-8 重复排列剪枝 </p>
|
||||
|
||||
### 2. 代码实现
|
||||
|
||||
@ -912,8 +912,8 @@ comments: true
|
||||
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 `state` 中重复出现。
|
||||
- **相等元素剪枝**:每轮选择(即每个开启的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在遍历中哪些元素已被选择过,作用是保证相等元素只被选择一次。
|
||||
|
||||
下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
|
||||
图 13-9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:两种剪枝条件的作用范围 </p>
|
||||
<p align="center"> 图 13-9 两种剪枝条件的作用范围 </p>
|
||||
|
||||
@ -434,11 +434,11 @@ comments: true
|
||||
|
||||
向以上代码输入数组 $[3, 4, 5]$ 和目标元素 $9$ ,输出结果为 $[3, 3, 3], [4, 5], [5, 4]$ 。**虽然成功找出了所有和为 $9$ 的子集,但其中存在重复的子集 $[4, 5]$ 和 $[5, 4]$** 。
|
||||
|
||||
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如下图所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是两个不同的分支,但两者对应同一个子集。
|
||||
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13-10 所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是两个不同的分支,但两者对应同一个子集。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:子集搜索与越界剪枝 </p>
|
||||
<p align="center"> 图 13-10 子集搜索与越界剪枝 </p>
|
||||
|
||||
为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,因为:
|
||||
|
||||
@ -447,12 +447,12 @@ comments: true
|
||||
|
||||
### 2. 重复子集剪枝
|
||||
|
||||
**我们考虑在搜索过程中通过剪枝进行去重**。观察下图,重复子集是在以不同顺序选择数组元素时产生的,具体来看:
|
||||
**我们考虑在搜索过程中通过剪枝进行去重**。观察图 13-11 ,重复子集是在以不同顺序选择数组元素时产生的,具体来看:
|
||||
|
||||
1. 第一轮和第二轮分别选择 $3$ , $4$ ,会生成包含这两个元素的所有子集,记为 $[3, 4, \dots]$ 。
|
||||
2. 若第一轮选择 $4$ ,**则第二轮应该跳过 $3$** ,因为该选择产生的子集 $[4, 3, \dots]$ 和 `1.` 中生成的子集完全重复。
|
||||
|
||||
如下图所示,每一层的选择都是从左到右被逐个尝试的,因此越靠右剪枝越多。
|
||||
如图 13-11 所示,每一层的选择都是从左到右被逐个尝试的,因此越靠右剪枝越多。
|
||||
|
||||
1. 前两轮选择 $3$ , $5$ ,生成子集 $[3, 5, \dots]$ 。
|
||||
2. 前两轮选择 $4$ , $5$ ,生成子集 $[4, 5, \dots]$ 。
|
||||
@ -460,7 +460,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:不同选择顺序导致的重复子集 </p>
|
||||
<p align="center"> 图 13-11 不同选择顺序导致的重复子集 </p>
|
||||
|
||||
总结来看,给定输入数组 $[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$ ,**不满足该条件的选择序列都会造成重复,应当剪枝**。
|
||||
|
||||
@ -912,11 +912,11 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入到以上代码后的整体回溯过程。
|
||||
如图 13-12 所示,为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入到以上代码后的整体回溯过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:子集和 I 回溯过程 </p>
|
||||
<p align="center"> 图 13-12 子集和 I 回溯过程 </p>
|
||||
|
||||
## 13.3.2 考虑重复元素的情况
|
||||
|
||||
@ -926,11 +926,11 @@ comments: true
|
||||
|
||||
相比于上题,**本题的输入数组可能包含重复元素**,这引入了新的问题。例如,给定数组 $[4, \hat{4}, 5]$ 和目标元素 $9$ ,则现有代码的输出结果为 $[4, 5], [\hat{4}, 5]$ ,出现了重复子集。
|
||||
|
||||
**造成这种重复的原因是相等元素在某轮中被多次选择**。在下图中,第一轮共有三个选择,其中两个都为 $4$ ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 $4$ 也会产生重复子集。
|
||||
**造成这种重复的原因是相等元素在某轮中被多次选择**。在图 13-13 中,第一轮共有三个选择,其中两个都为 $4$ ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 $4$ 也会产生重复子集。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:相等元素导致的重复子集 </p>
|
||||
<p align="center"> 图 13-13 相等元素导致的重复子集 </p>
|
||||
|
||||
### 1. 相等元素剪枝
|
||||
|
||||
@ -1434,8 +1434,8 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了数组 $[4, 4, 5]$ 和目标元素 $9$ 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。
|
||||
图 13-14 展示了数组 $[4, 4, 5]$ 和目标元素 $9$ 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:子集和 II 回溯过程 </p>
|
||||
<p align="center"> 图 13-14 子集和 II 回溯过程 </p>
|
||||
|
||||
@ -26,7 +26,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:算法使用的相关空间 </p>
|
||||
<p align="center"> 图 2-9 算法使用的相关空间 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -658,7 +658,7 @@ comments: true
|
||||
|
||||
## 2.3.3 常见类型
|
||||
|
||||
设输入数据大小为 $n$ ,下图展示了常见的空间复杂度类型(从低到高排列)。
|
||||
设输入数据大小为 $n$ ,图 2-10 展示了常见的空间复杂度类型(从低到高排列)。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@ -669,7 +669,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:常见的空间复杂度类型 </p>
|
||||
<p align="center"> 图 2-10 常见的空间复杂度类型 </p>
|
||||
|
||||
!!! tip
|
||||
|
||||
@ -1274,7 +1274,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 `linear_recur()` 函数,使用 $O(n)$ 大小的栈帧空间:
|
||||
如图 2-11 所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 `linear_recur()` 函数,使用 $O(n)$ 大小的栈帧空间:
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -1417,7 +1417,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:递归函数产生的线性阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-11 递归函数产生的线性阶空间复杂度 </p>
|
||||
|
||||
### 3. 平方阶 $O(n^2)$
|
||||
|
||||
@ -1635,7 +1635,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间:
|
||||
如图 2-12 所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间:
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -1796,11 +1796,11 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:递归函数产生的平方阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-12 递归函数产生的平方阶空间复杂度 </p>
|
||||
|
||||
### 4. 指数阶 $O(2^n)$
|
||||
|
||||
指数阶常见于二叉树。观察下图,高度为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
|
||||
指数阶常见于二叉树。观察图 2-13 ,高度为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -1968,7 +1968,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:满二叉树产生的指数阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-13 满二叉树产生的指数阶空间复杂度 </p>
|
||||
|
||||
### 5. 对数阶 $O(\log n)$
|
||||
|
||||
|
||||
@ -430,7 +430,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了以上三个算法函数的时间复杂度。
|
||||
图 2-1 展示了以上三个算法函数的时间复杂度。
|
||||
|
||||
- 算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为“常数阶”。
|
||||
- 算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。
|
||||
@ -438,7 +438,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:算法 A 、B 和 C 的时间增长趋势 </p>
|
||||
<p align="center"> 图 2-1 算法 A 、B 和 C 的时间增长趋势 </p>
|
||||
|
||||
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
|
||||
|
||||
@ -632,11 +632,11 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
|
||||
T(n) = O(f(n))
|
||||
$$
|
||||
|
||||
如下图所示,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
|
||||
如图 2-2 所示,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:函数的渐近上界 </p>
|
||||
<p align="center"> 图 2-2 函数的渐近上界 </p>
|
||||
|
||||
## 2.2.3 推算方法
|
||||
|
||||
@ -879,9 +879,9 @@ $$
|
||||
|
||||
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
|
||||
|
||||
下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。
|
||||
表 2-1 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。
|
||||
|
||||
<p align="center"> 表:不同操作数量对应的时间复杂度 </p>
|
||||
<p align="center"> 表 2-1 不同操作数量对应的时间复杂度 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -897,7 +897,7 @@ $$
|
||||
|
||||
## 2.2.4 常见类型
|
||||
|
||||
设输入数据大小为 $n$ ,常见的时间复杂度类型如下图所示(按照从低到高的顺序排列)。
|
||||
设输入数据大小为 $n$ ,常见的时间复杂度类型如图 2-3 所示(按照从低到高的顺序排列)。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@ -908,7 +908,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:常见的时间复杂度类型 </p>
|
||||
<p align="center"> 图 2-3 常见的时间复杂度类型 </p>
|
||||
|
||||
!!! tip
|
||||
|
||||
@ -1600,11 +1600,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图对比了常数阶、线性阶和平方阶三种时间复杂度。
|
||||
图 2-4 对比了常数阶、线性阶和平方阶三种时间复杂度。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:常数阶、线性阶和平方阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-4 常数阶、线性阶和平方阶的时间复杂度 </p>
|
||||
|
||||
以冒泡排序为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1, n-2, \dots, 2, 1$ 次,平均为 $n / 2$ 次,因此时间复杂度为 $O((n - 1) n / 2) = O(n^2)$ 。
|
||||
|
||||
@ -1884,7 +1884,7 @@ $$
|
||||
|
||||
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 $1$ 个细胞,分裂一轮后变为 $2$ 个,分裂两轮后变为 $4$ 个,以此类推,分裂 $n$ 轮后有 $2^n$ 个细胞。
|
||||
|
||||
以下代码和图模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ 。
|
||||
图 2-5 和以下代码模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -2110,7 +2110,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:指数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-5 指数阶的时间复杂度 </p>
|
||||
|
||||
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 $n$ 次分裂后停止:
|
||||
|
||||
@ -2249,7 +2249,7 @@ $$
|
||||
|
||||
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。
|
||||
|
||||
以下代码和图模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ 。
|
||||
图 2-6 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -2422,7 +2422,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:对数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-6 对数阶的时间复杂度 </p>
|
||||
|
||||
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 $\log_2 n$ 的递归树:
|
||||
|
||||
@ -2753,11 +2753,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 $n$ ,树共有 $\log_2 n + 1$ 层,因此时间复杂度为 $O(n \log n)$ 。
|
||||
图 2-7 展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 $n$ ,树共有 $\log_2 n + 1$ 层,因此时间复杂度为 $O(n \log n)$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:线性对数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-7 线性对数阶的时间复杂度 </p>
|
||||
|
||||
主流排序算法的时间复杂度通常为 $O(n \log n)$ ,例如快速排序、归并排序、堆排序等。
|
||||
|
||||
@ -2769,7 +2769,7 @@ $$
|
||||
n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1
|
||||
$$
|
||||
|
||||
阶乘通常使用递归实现。如下图和以下代码所示,第一层分裂出 $n$ 个,第二层分裂出 $n - 1$ 个,以此类推,直至第 $n$ 层时停止分裂:
|
||||
阶乘通常使用递归实现。如图 2-8 和以下代码所示,第一层分裂出 $n$ 个,第二层分裂出 $n - 1$ 个,以此类推,直至第 $n$ 层时停止分裂:
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -2961,7 +2961,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:阶乘阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-8 阶乘阶的时间复杂度 </p>
|
||||
|
||||
请注意,因为当 $n \geq 4$ 时恒有 $n! > 2^n$ ,所以阶乘阶比指数阶增长得更快,在 $n$ 较大时也是不可接受的。
|
||||
|
||||
|
||||
@ -20,9 +20,9 @@ comments: true
|
||||
- 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个数字。
|
||||
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字。
|
||||
|
||||
下表列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。
|
||||
表 3-1 列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。
|
||||
|
||||
<p align="center"> 表:基本数据类型的占用空间和取值范围 </p>
|
||||
<p align="center"> 表 3-1 基本数据类型的占用空间和取值范围 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -39,9 +39,9 @@ comments: true
|
||||
|
||||
</div>
|
||||
|
||||
对于上表,需要注意以下几点:
|
||||
对于表 3-1 ,需要注意以下几点:
|
||||
|
||||
- C, C++ 未明确规定基本数据类型大小,而因实现和平台各异。上表遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于 Unix 64 位操作系统(例如 Linux , macOS)。
|
||||
- C, C++ 未明确规定基本数据类型大小,而因实现和平台各异。表 3-1 遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于 Unix 64 位操作系统(例如 Linux , macOS)。
|
||||
- 字符 `char` 的大小在 C, C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。
|
||||
- 即使表示布尔量仅需 1 位($0$ 或 $1$),它在内存中通常被存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
|
||||
|
||||
|
||||
@ -8,11 +8,11 @@ comments: true
|
||||
|
||||
## 3.4.1 ASCII 字符集
|
||||
|
||||
「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如下图所示,ASCII 码包括英文字母的大小写、数字 0-9 、一些标点符号,以及一些控制字符(如换行符和制表符)。
|
||||
「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0-9 、一些标点符号,以及一些控制字符(如换行符和制表符)。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:ASCII 码 </p>
|
||||
<p align="center"> 图 3-6 ASCII 码 </p>
|
||||
|
||||
然而,**ASCII 码仅能够表示英文**。随着计算机的全球化,诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
|
||||
|
||||
@ -36,11 +36,11 @@ comments: true
|
||||
|
||||
Unicode 是一种字符集标准,本质上是给每个字符分配一个编号(称为“码点”),**但它并没有规定在计算机中如何存储这些字符码点**。我们不禁会问:当多种长度的 Unicode 码点同时出现在同一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符?
|
||||
|
||||
对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如下图所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。
|
||||
对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:Unicode 编码示例 </p>
|
||||
<p align="center"> 图 3-7 Unicode 编码示例 </p>
|
||||
|
||||
然而 ASCII 码已经向我们证明,编码英文只需要 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
|
||||
|
||||
@ -53,7 +53,7 @@ UTF-8 的编码规则并不复杂,分为两种情况:
|
||||
1. 对于长度为 1 字节的字符,将最高位设置为 $0$ 、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,**UTF-8 编码可以向下兼容 ASCII 码**。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
|
||||
2. 对于长度为 $n$ 字节的字符(其中 $n > 1$),将首个字节的高 $n$ 位都设置为 $1$ 、第 $n + 1$ 位设置为 $0$ ;从第二个字节开始,将每个字节的高 2 位都设置为 $10$ ;其余所有位用于填充字符的 Unicode 码点。
|
||||
|
||||
下图展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都被设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。
|
||||
图 3-8 展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都被设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。
|
||||
|
||||
但为什么要将其余所有字节的高 2 位都设置为 $10$ 呢?实际上,这个 $10$ 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 $10$ 能够帮助系统快速的判断出异常。
|
||||
|
||||
@ -61,7 +61,7 @@ UTF-8 的编码规则并不复杂,分为两种情况:
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:UTF-8 编码示例 </p>
|
||||
<p align="center"> 图 3-8 UTF-8 编码示例 </p>
|
||||
|
||||
除了 UTF-8 之外,常见的编码方式还包括:
|
||||
|
||||
|
||||
@ -10,14 +10,14 @@ comments: true
|
||||
|
||||
**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
|
||||
|
||||
如下图所示,逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
|
||||
如图 3-1 所示,逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
|
||||
|
||||
- **线性数据结构**:数组、链表、栈、队列、哈希表。
|
||||
- **非线性数据结构**:树、堆、图、哈希表。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:线性与非线性数据结构 </p>
|
||||
<p align="center"> 图 3-1 线性与非线性数据结构 </p>
|
||||
|
||||
非线性数据结构可以进一步被划分为树形结构和网状结构。
|
||||
|
||||
@ -29,21 +29,21 @@ comments: true
|
||||
|
||||
在计算机中,内存和硬盘是两种主要的存储硬件设备。硬盘主要用于长期存储数据,容量较大(通常可达到 TB 级别)、速度较慢。内存用于运行程序时暂存数据,速度较快,但容量较小(通常为 GB 级别)。
|
||||
|
||||
**在算法运行过程中,相关数据都存储在内存中**。下图展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据,在算法运行时,所有数据都被存储在这些单元格中。
|
||||
**在算法运行过程中,相关数据都存储在内存中**。图 3-2 展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据,在算法运行时,所有数据都被存储在这些单元格中。
|
||||
|
||||
**系统通过内存地址来访问目标位置的数据**。如下图所示,计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。
|
||||
**系统通过内存地址来访问目标位置的数据**。如图 3-2 所示,计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:内存条、内存空间、内存地址 </p>
|
||||
<p align="center"> 图 3-2 内存条、内存空间、内存地址 </p>
|
||||
|
||||
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。**因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素**。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在离散的内存空间内。
|
||||
|
||||
如下图所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。
|
||||
如图 3-3 所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:连续空间存储与离散空间存储 </p>
|
||||
<p align="center"> 图 3-3 连续空间存储与离散空间存储 </p>
|
||||
|
||||
值得说明的是,**所有数据结构都是基于数组、链表或二者的组合实现的**。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。
|
||||
|
||||
|
||||
@ -18,11 +18,11 @@ comments: true
|
||||
- **反码**:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
|
||||
- **补码**:正数的补码与其原码相同,负数的补码是在其反码的基础上加 $1$ 。
|
||||
|
||||
下图展示了原吗、反码和补码之间的转换方法。
|
||||
图 3-4 展示了原吗、反码和补码之间的转换方法。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:原码、反码与补码之间的相互转换 </p>
|
||||
<p align="center"> 图 3-4 原码、反码与补码之间的相互转换 </p>
|
||||
|
||||
「原码 true form」虽然最直观,但存在一些局限性。一方面,**负数的原码不能直接用于运算**。例如在原码下计算 $1 + (-2)$ ,得到的结果是 $-3$ ,这显然是不对的。
|
||||
|
||||
@ -131,9 +131,9 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:IEEE 754 标准下的 float 的计算示例 </p>
|
||||
<p align="center"> 图 3-5 IEEE 754 标准下的 float 的计算示例 </p>
|
||||
|
||||
观察上图,给定一个示例数据 $\mathrm{S} = 0$ , $\mathrm{E} = 124$ ,$\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$ ,则有:
|
||||
观察图 3-5 ,给定一个示例数据 $\mathrm{S} = 0$ , $\mathrm{E} = 124$ ,$\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$ ,则有:
|
||||
|
||||
$$
|
||||
\text { val } = (-1)^0 \times 2^{124 - 127} \times (1 + 0.375) = 0.171875
|
||||
@ -143,9 +143,9 @@ $$
|
||||
|
||||
**尽管浮点数 `float` 扩展了取值范围,但其副作用是牺牲了精度**。整数类型 `int` 将全部 32 位用于表示数字,数字是均匀分布的;而由于指数位的存在,浮点数 `float` 的数值越大,相邻两个数字之间的差值就会趋向越大。
|
||||
|
||||
如下表所示,指数位 $E = 0$ 和 $E = 255$ 具有特殊含义,**用于表示零、无穷大、$\mathrm{NaN}$ 等**。
|
||||
如表 3-2 所示,指数位 $E = 0$ 和 $E = 255$ 具有特殊含义,**用于表示零、无穷大、$\mathrm{NaN}$ 等**。
|
||||
|
||||
<p align="center"> 表:指数位含义 </p>
|
||||
<p align="center"> 表 3-2 指数位含义 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
|
||||
@ -39,11 +39,11 @@ status: new
|
||||
2. 递归求解规模减小一半的子问题,可能为 $f(i, m-1)$ 或 $f(m+1, j)$ 。
|
||||
3. 循环第 `1.` , `2.` 步,直至找到 `target` 或区间为空时返回。
|
||||
|
||||
下图展示了在数组中二分查找元素 $6$ 的分治过程。
|
||||
图 12-4 展示了在数组中二分查找元素 $6$ 的分治过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二分查找的分治过程 </p>
|
||||
<p align="center"> 图 12-4 二分查找的分治过程 </p>
|
||||
|
||||
在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$ 。
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:构建二叉树的示例数据 </p>
|
||||
<p align="center"> 图 12-5 构建二叉树的示例数据 </p>
|
||||
|
||||
### 1. 判断是否为分治问题
|
||||
|
||||
@ -27,10 +27,10 @@ status: new
|
||||
|
||||
根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分:
|
||||
|
||||
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图 `[ 3 | 9 | 2 1 7 ]` 。
|
||||
- 中序遍历:`[ 左子树 | 根节点 | 右子树 ]` ,例如上图 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如图 12-5 的树对应 `[ 3 | 9 | 2 1 7 ]` 。
|
||||
- 中序遍历:`[ 左子树 | 根节点 | 右子树 ]` ,例如图 12-5 的树对应 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
|
||||
以上图数据为例,我们可以通过下图所示的步骤得到划分结果:
|
||||
以上图数据为例,我们可以通过图 12-6 所示的步骤得到划分结果:
|
||||
|
||||
1. 前序遍历的首元素 3 是根节点的值。
|
||||
2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
@ -38,7 +38,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在前序和中序遍历中划分子树 </p>
|
||||
<p align="center"> 图 12-6 在前序和中序遍历中划分子树 </p>
|
||||
|
||||
### 3. 基于变量描述子树区间
|
||||
|
||||
@ -48,9 +48,9 @@ status: new
|
||||
- 将当前树的根节点在 `inorder` 中的索引记为 $m$ 。
|
||||
- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$ 。
|
||||
|
||||
如下表所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。
|
||||
如表 12-1 所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。
|
||||
|
||||
<p align="center"> 表:根节点和子树在前序和中序遍历中的索引 </p>
|
||||
<p align="center"> 表 12-1 根节点和子树在前序和中序遍历中的索引 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -62,11 +62,11 @@ status: new
|
||||
|
||||
</div>
|
||||
|
||||
请注意,右子树根节点索引中的 $(m-l)$ 的含义是“左子树的节点数量”,建议配合下图理解。
|
||||
请注意,右子树根节点索引中的 $(m-l)$ 的含义是“左子树的节点数量”,建议配合图 12-7 理解。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:根节点和左右子树的索引区间表示 </p>
|
||||
<p align="center"> 图 12-7 根节点和左右子树的索引区间表示 </p>
|
||||
|
||||
### 4. 代码实现
|
||||
|
||||
@ -400,7 +400,7 @@ status: new
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(即引用)是在向上“归”的过程中建立的。
|
||||
图 12-8 展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(即引用)是在向上“归”的过程中建立的。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -432,7 +432,7 @@ status: new
|
||||
=== "<10>"
|
||||

|
||||
|
||||
<p align="center"> 图:构建二叉树的递归过程 </p>
|
||||
<p align="center"> 图 12-8 构建二叉树的递归过程 </p>
|
||||
|
||||
设树的节点数量为 $n$ ,初始化每一个节点(执行一个递归函数 `dfs()` )使用 $O(1)$ 时间。**因此总体时间复杂度为 $O(n)$** 。
|
||||
|
||||
|
||||
@ -10,14 +10,14 @@ status: new
|
||||
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
|
||||
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
|
||||
|
||||
如下图所示,“归并排序”是分治策略的典型应用之一,其算法原理为:
|
||||
如图 12-1 所示,“归并排序”是分治策略的典型应用之一,其算法原理为:
|
||||
|
||||
1. **分**:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
|
||||
2. **治**:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:归并排序的分治策略 </p>
|
||||
<p align="center"> 图 12-1 归并排序的分治策略 </p>
|
||||
|
||||
## 12.1.1 如何判断分治问题
|
||||
|
||||
@ -41,7 +41,7 @@ status: new
|
||||
|
||||
### 1. 操作数量优化
|
||||
|
||||
以“冒泡排序”为例,其处理一个长度为 $n$ 的数组需要 $O(n^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)
|
||||
@ -49,7 +49,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:划分数组前后的冒泡排序 </p>
|
||||
<p align="center"> 图 12-2 划分数组前后的冒泡排序 </p>
|
||||
|
||||
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
|
||||
|
||||
@ -73,11 +73,11 @@ $$
|
||||
|
||||
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
|
||||
|
||||
比如在下图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
|
||||
比如在图 12-3 所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:桶排序的并行计算 </p>
|
||||
<p align="center"> 图 12-3 桶排序的并行计算 </p>
|
||||
|
||||
## 12.1.3 分治常见应用
|
||||
|
||||
|
||||
@ -17,13 +17,13 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:汉诺塔问题示例 </p>
|
||||
<p align="center"> 图 12-9 汉诺塔问题示例 </p>
|
||||
|
||||
**我们将规模为 $i$ 的汉诺塔问题记做 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。
|
||||
|
||||
### 1. 考虑基本情况
|
||||
|
||||
如下图所示,对于问题 $f(1)$ ,即当只有一个圆盘时,我们将它直接从 `A` 移动至 `C` 即可。
|
||||
如图 12-10 所示,对于问题 $f(1)$ ,即当只有一个圆盘时,我们将它直接从 `A` 移动至 `C` 即可。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -31,9 +31,9 @@ status: new
|
||||
=== "<2>"
|
||||

|
||||
|
||||
<p align="center"> 图:规模为 1 问题的解 </p>
|
||||
<p align="center"> 图 12-10 规模为 1 问题的解 </p>
|
||||
|
||||
如下图所示,对于问题 $f(2)$ ,即当有两个圆盘时,**由于要时刻满足小圆盘在大圆盘之上,因此需要借助 `B` 来完成移动**。
|
||||
如图 12-11 所示,对于问题 $f(2)$ ,即当有两个圆盘时,**由于要时刻满足小圆盘在大圆盘之上,因此需要借助 `B` 来完成移动**。
|
||||
|
||||
1. 先将上面的小圆盘从 `A` 移至 `B` 。
|
||||
2. 再将大圆盘从 `A` 移至 `C` 。
|
||||
@ -51,7 +51,7 @@ status: new
|
||||
=== "<4>"
|
||||

|
||||
|
||||
<p align="center"> 图:规模为 2 问题的解 </p>
|
||||
<p align="center"> 图 12-11 规模为 2 问题的解 </p>
|
||||
|
||||
解决问题 $f(2)$ 的过程可总结为:**将两个圆盘借助 `B` 从 `A` 移至 `C`** 。其中,`C` 称为目标柱、`B` 称为缓冲柱。
|
||||
|
||||
@ -59,13 +59,12 @@ status: new
|
||||
|
||||
对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。
|
||||
|
||||
因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行下图所示的步骤。这样三个圆盘就被顺利地从 `A` 移动至 `C` 了。
|
||||
因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行图 12-12 所示的步骤。这样三个圆盘就被顺利地从 `A` 移动至 `C` 了。
|
||||
|
||||
1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移动至 `B` 。
|
||||
2. 将 `A` 中剩余的一个圆盘从 `A` 直接移动至 `C` 。
|
||||
3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移动至 `C` 。
|
||||
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
@ -78,11 +77,11 @@ status: new
|
||||
=== "<4>"
|
||||

|
||||
|
||||
<p align="center"> 图:规模为 3 问题的解 </p>
|
||||
<p align="center"> 图 12-12 规模为 3 问题的解 </p>
|
||||
|
||||
本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。
|
||||
|
||||
至此,我们可总结出下图所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ 。子问题的解决顺序为:
|
||||
至此,我们可总结出图 12-13 所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ 。子问题的解决顺序为:
|
||||
|
||||
1. 将 $n-1$ 个圆盘借助 `C` 从 `A` 移至 `B` 。
|
||||
2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C` 。
|
||||
@ -92,7 +91,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:汉诺塔问题的分治策略 </p>
|
||||
<p align="center"> 图 12-13 汉诺塔问题的分治策略 </p>
|
||||
|
||||
### 3. 代码实现
|
||||
|
||||
@ -434,11 +433,11 @@ status: new
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,汉诺塔问题形成一个高度为 $n$ 的递归树,每个节点代表一个子问题、对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。
|
||||
如图 12-14 所示,汉诺塔问题形成一个高度为 $n$ 的递归树,每个节点代表一个子问题、对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:汉诺塔问题的递归树 </p>
|
||||
<p align="center"> 图 12-14 汉诺塔问题的递归树 </p>
|
||||
|
||||
!!! quote
|
||||
|
||||
|
||||
@ -21,11 +21,11 @@ status: new
|
||||
|
||||
给定一个楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 个台阶需要付出的代价,$cost[0]$ 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
|
||||
|
||||
如下图所示,若第 $1$ , $2$ , $3$ 阶的代价分别为 $1$ , $10$ , $1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。
|
||||
如图 14-6 所示,若第 $1$ , $2$ , $3$ 阶的代价分别为 $1$ , $10$ , $1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:爬到第 3 阶的最小代价 </p>
|
||||
<p align="center"> 图 14-6 爬到第 3 阶的最小代价 </p>
|
||||
|
||||
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个,即:
|
||||
|
||||
@ -246,11 +246,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了以上代码的动态规划过程。
|
||||
图 14-7 展示了以上代码的动态规划过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:爬楼梯最小代价的动态规划过程 </p>
|
||||
<p align="center"> 图 14-7 爬楼梯最小代价的动态规划过程 </p>
|
||||
|
||||
本题也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降低至 $O(1)$ 。
|
||||
|
||||
@ -445,11 +445,11 @@ $$
|
||||
|
||||
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,**但不能连续两轮跳 $1$ 阶**,请问有多少种方案可以爬到楼顶。
|
||||
|
||||
例如下图,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。
|
||||
例如图 14-8 ,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:带约束爬到第 3 阶的方案数量 </p>
|
||||
<p align="center"> 图 14-8 带约束爬到第 3 阶的方案数量 </p>
|
||||
|
||||
在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关**。
|
||||
|
||||
@ -460,7 +460,7 @@ $$
|
||||
- 当 $j$ 等于 $1$ ,即上一轮跳了 $1$ 阶时,这一轮只能选择跳 $2$ 阶。
|
||||
- 当 $j$ 等于 $2$ ,即上一轮跳了 $2$ 阶时,这一轮可选择跳 $1$ 阶或跳 $2$ 阶。
|
||||
|
||||
如下图所示,在该定义下,$dp[i, j]$ 表示状态 $[i, j]$ 对应的方案数。此时状态转移方程为:
|
||||
如图 14-9 所示,在该定义下,$dp[i, j]$ 表示状态 $[i, j]$ 对应的方案数。此时状态转移方程为:
|
||||
|
||||
$$
|
||||
\begin{cases}
|
||||
@ -471,7 +471,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:考虑约束下的递推关系 </p>
|
||||
<p align="center"> 图 14-9 考虑约束下的递推关系 </p>
|
||||
|
||||
最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数。
|
||||
|
||||
|
||||
@ -40,11 +40,11 @@ status: new
|
||||
|
||||
给定一个 $n \times m$ 的二维网格 `grid` ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。
|
||||
|
||||
下图展示了一个例子,给定网格的最小路径和为 $13$ 。
|
||||
图 14-10 展示了一个例子,给定网格的最小路径和为 $13$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最小路径和示例数据 </p>
|
||||
<p align="center"> 图 14-10 最小路径和示例数据 </p>
|
||||
|
||||
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
|
||||
|
||||
@ -52,11 +52,11 @@ status: new
|
||||
|
||||
状态 $[i, j]$ 对应的子问题为:从起始点 $[0, 0]$ 走到 $[i, j]$ 的最小路径和,解记为 $dp[i, j]$ 。
|
||||
|
||||
至此,我们就得到了下图所示的二维 $dp$ 矩阵,其尺寸与输入网格 $grid$ 相同。
|
||||
至此,我们就得到了图 14-11 所示的二维 $dp$ 矩阵,其尺寸与输入网格 $grid$ 相同。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:状态定义与 dp 表 </p>
|
||||
<p align="center"> 图 14-11 状态定义与 dp 表 </p>
|
||||
|
||||
!!! note
|
||||
|
||||
@ -68,7 +68,7 @@ status: new
|
||||
|
||||
对于状态 $[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]
|
||||
@ -76,7 +76,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最优子结构与状态转移方程 </p>
|
||||
<p align="center"> 图 14-12 最优子结构与状态转移方程 </p>
|
||||
|
||||
!!! note
|
||||
|
||||
@ -88,11 +88,11 @@ $$
|
||||
|
||||
在本题中,处在首行的状态只能向右转移,首列状态只能向下转移,因此首行 $i = 0$ 和首列 $j = 0$ 是边界条件。
|
||||
|
||||
如下图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
|
||||
如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:边界条件与状态转移顺序 </p>
|
||||
<p align="center"> 图 14-13 边界条件与状态转移顺序 </p>
|
||||
|
||||
!!! note
|
||||
|
||||
@ -316,13 +316,13 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。
|
||||
图 14-14 给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。
|
||||
|
||||
本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:暴力搜索递归树 </p>
|
||||
<p align="center"> 图 14-14 暴力搜索递归树 </p>
|
||||
|
||||
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
|
||||
|
||||
@ -582,11 +582,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 $O(nm)$ 。
|
||||
如图 14-15 所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 $O(nm)$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:记忆化搜索递归树 </p>
|
||||
<p align="center"> 图 14-15 记忆化搜索递归树 </p>
|
||||
|
||||
### 3. 方法三:动态规划
|
||||
|
||||
@ -853,7 +853,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了最小路径和的状态转移过程,其遍历了整个网格,**因此时间复杂度为 $O(nm)$** 。
|
||||
图 14-16 展示了最小路径和的状态转移过程,其遍历了整个网格,**因此时间复杂度为 $O(nm)$** 。
|
||||
|
||||
数组 `dp` 大小为 $n \times m$ ,**因此空间复杂度为 $O(nm)$** 。
|
||||
|
||||
@ -893,7 +893,7 @@ $$
|
||||
=== "<12>"
|
||||

|
||||
|
||||
<p align="center"> 图:最小路径和的动态规划过程 </p>
|
||||
<p align="center"> 图 14-16 最小路径和的动态规划过程 </p>
|
||||
|
||||
### 4. 状态压缩
|
||||
|
||||
|
||||
@ -13,21 +13,21 @@ status: new
|
||||
|
||||
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。
|
||||
|
||||
如下图所示,将 `kitten` 转换为 `sitting` 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 `hello` 转换为 `algo` 需要 3 步,包括 2 次替换操作和 1 次删除操作。
|
||||
如图 14-27 所示,将 `kitten` 转换为 `sitting` 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 `hello` 转换为 `algo` 需要 3 步,包括 2 次替换操作和 1 次删除操作。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:编辑距离的示例数据 </p>
|
||||
<p align="center"> 图 14-27 编辑距离的示例数据 </p>
|
||||
|
||||
**编辑距离问题可以很自然地用决策树模型来解释**。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。
|
||||
|
||||
如下图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 `hello` 转换到 `algo` 有许多种可能的路径。
|
||||
如图 14-28 所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 `hello` 转换到 `algo` 有许多种可能的路径。
|
||||
|
||||
从决策树的角度看,本题的目标是求解节点 `hello` 和节点 `algo` 之间的最短路径。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:基于决策树模型表示编辑距离问题 </p>
|
||||
<p align="center"> 图 14-28 基于决策树模型表示编辑距离问题 </p>
|
||||
|
||||
### 1. 动态规划思路
|
||||
|
||||
@ -48,7 +48,7 @@ status: new
|
||||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为下图所示的三种情况:
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为图 14-29 所示的三种情况:
|
||||
|
||||
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ 。
|
||||
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$ 。
|
||||
@ -56,7 +56,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:编辑距离的状态转移 </p>
|
||||
<p align="center"> 图 14-29 编辑距离的状态转移 </p>
|
||||
|
||||
根据以上分析,可得最优子结构:$dp[i, j]$ 的最少编辑步数等于 $dp[i, j-1]$ , $dp[i-1, j]$ , $dp[i-1, j-1]$ 三者中的最少编辑步数,再加上本次的编辑步数 $1$ 。对应的状态转移方程为:
|
||||
|
||||
@ -366,7 +366,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。
|
||||
如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -413,7 +413,7 @@ $$
|
||||
=== "<15>"
|
||||

|
||||
|
||||
<p align="center"> 图:编辑距离的动态规划过程 </p>
|
||||
<p align="center"> 图 14-30 编辑距离的动态规划过程 </p>
|
||||
|
||||
### 3. 状态压缩
|
||||
|
||||
|
||||
@ -13,11 +13,11 @@ status: new
|
||||
|
||||
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,请问有多少种方案可以爬到楼顶。
|
||||
|
||||
如下图所示,对于一个 $3$ 阶楼梯,共有 $3$ 种方案可以爬到楼顶。
|
||||
如图 14-1 所示,对于一个 $3$ 阶楼梯,共有 $3$ 种方案可以爬到楼顶。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:爬到第 3 阶的方案数量 </p>
|
||||
<p align="center"> 图 14-1 爬到第 3 阶的方案数量 </p>
|
||||
|
||||
本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。
|
||||
|
||||
@ -378,11 +378,11 @@ $$
|
||||
dp[i] = dp[i-1] + dp[i-2]
|
||||
$$
|
||||
|
||||
这意味着在爬楼梯问题中,各个子问题之间存在递推关系,**原问题的解可以由子问题的解构建得来**。下图展示了该递推关系。
|
||||
这意味着在爬楼梯问题中,各个子问题之间存在递推关系,**原问题的解可以由子问题的解构建得来**。图 14-2 展示了该递推关系。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:方案数量递推关系 </p>
|
||||
<p align="center"> 图 14-2 方案数量递推关系 </p>
|
||||
|
||||
我们可以根据递推公式得到暴力搜索解法:
|
||||
|
||||
@ -605,13 +605,13 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了暴力搜索形成的递归树。对于问题 $dp[n]$ ,其递归树的深度为 $n$ ,时间复杂度为 $O(2^n)$ 。指数阶属于爆炸式增长,如果我们输入一个比较大的 $n$ ,则会陷入漫长的等待之中。
|
||||
图 14-3 展示了暴力搜索形成的递归树。对于问题 $dp[n]$ ,其递归树的深度为 $n$ ,时间复杂度为 $O(2^n)$ 。指数阶属于爆炸式增长,如果我们输入一个比较大的 $n$ ,则会陷入漫长的等待之中。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:爬楼梯对应递归树 </p>
|
||||
<p align="center"> 图 14-3 爬楼梯对应递归树 </p>
|
||||
|
||||
观察上图发现,**指数阶的时间复杂度是由于“重叠子问题”导致的**。例如:$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]$ 。
|
||||
|
||||
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。
|
||||
|
||||
@ -917,11 +917,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
观察下图,**经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 $O(n)$** ,这是一个巨大的飞跃。
|
||||
观察图 14-4 ,**经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 $O(n)$** ,这是一个巨大的飞跃。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:记忆化搜索对应递归树 </p>
|
||||
<p align="center"> 图 14-4 记忆化搜索对应递归树 </p>
|
||||
|
||||
## 14.1.3 方法三:动态规划
|
||||
|
||||
@ -1155,11 +1155,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图模拟了以上代码的执行过程。
|
||||
图 14-5 模拟了以上代码的执行过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:爬楼梯的动态规划过程 </p>
|
||||
<p align="center"> 图 14-5 爬楼梯的动态规划过程 </p>
|
||||
|
||||
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
|
||||
|
||||
|
||||
@ -13,11 +13,11 @@ status: new
|
||||
|
||||
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$ 、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
|
||||
|
||||
观察下图,由于物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
|
||||
观察图 14-17 ,由于物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:0-1 背包的示例数据 </p>
|
||||
<p align="center"> 图 14-17 0-1 背包的示例数据 </p>
|
||||
|
||||
我们可以将 0-1 背包问题看作是一个由 $n$ 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
|
||||
|
||||
@ -267,13 +267,13 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 $O(2^n)$ 。
|
||||
如图 14-18 所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 $O(2^n)$ 。
|
||||
|
||||
观察递归树,容易发现其中存在重叠子问题,例如 $dp[1, 10]$ 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:0-1 背包的暴力搜索递归树 </p>
|
||||
<p align="center"> 图 14-18 0-1 背包的暴力搜索递归树 </p>
|
||||
|
||||
### 2. 方法二:记忆化搜索
|
||||
|
||||
@ -537,11 +537,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了在记忆化递归中被剪掉的搜索分支。
|
||||
图 14-19 展示了在记忆化递归中被剪掉的搜索分支。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:0-1 背包的记忆化搜索递归树 </p>
|
||||
<p align="center"> 图 14-19 0-1 背包的记忆化搜索递归树 </p>
|
||||
|
||||
### 3. 方法三:动态规划
|
||||
|
||||
@ -780,7 +780,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。
|
||||
如图 14-20 所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -824,7 +824,7 @@ $$
|
||||
=== "<14>"
|
||||

|
||||
|
||||
<p align="center"> 图:0-1 背包的动态规划过程 </p>
|
||||
<p align="center"> 图 14-20 0-1 背包的动态规划过程 </p>
|
||||
|
||||
### 4. 状态压缩
|
||||
|
||||
@ -835,7 +835,7 @@ $$
|
||||
- 如果采取正序遍历,那么遍历到 $dp[i, j]$ 时,左上方 $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
|
||||
- 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
|
||||
|
||||
下图展示了在单个数组下从第 $i = 1$ 行转换至第 $i = 2$ 行的过程。请思考正序遍历和倒序遍历的区别。
|
||||
图 14-21 展示了在单个数组下从第 $i = 1$ 行转换至第 $i = 2$ 行的过程。请思考正序遍历和倒序遍历的区别。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -855,7 +855,7 @@ $$
|
||||
=== "<6>"
|
||||

|
||||
|
||||
<p align="center"> 图:0-1 背包的状态压缩后的动态规划过程 </p>
|
||||
<p align="center"> 图 14-21 0-1 背包的状态压缩后的动态规划过程 </p>
|
||||
|
||||
在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可。
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完全背包问题的示例数据 </p>
|
||||
<p align="center"> 图 14-22 完全背包问题的示例数据 </p>
|
||||
|
||||
### 1. 动态规划思路
|
||||
|
||||
@ -276,7 +276,7 @@ $$
|
||||
|
||||
由于当前状态是从左边和上边的状态转移而来,**因此状态压缩后应该对 $dp$ 表中的每一行采取正序遍历**。
|
||||
|
||||
这个遍历顺序与 0-1 背包正好相反。请借助下图来理解两者的区别。
|
||||
这个遍历顺序与 0-1 背包正好相反。请借助图 14-23 来理解两者的区别。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -296,7 +296,7 @@ $$
|
||||
=== "<6>"
|
||||

|
||||
|
||||
<p align="center"> 图:完全背包的状态压缩后的动态规划过程 </p>
|
||||
<p align="center"> 图 14-23 完全背包的状态压缩后的动态规划过程 </p>
|
||||
|
||||
代码实现比较简单,仅需将数组 `dp` 的第一维删除。
|
||||
|
||||
@ -541,7 +541,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:零钱兑换问题的示例数据 </p>
|
||||
<p align="center"> 图 14-24 零钱兑换问题的示例数据 </p>
|
||||
|
||||
### 1. 动态规划思路
|
||||
|
||||
@ -866,7 +866,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
下图展示了零钱兑换的动态规划过程,和完全背包非常相似。
|
||||
图 14-25 展示了零钱兑换的动态规划过程,和完全背包非常相似。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -913,7 +913,7 @@ $$
|
||||
=== "<15>"
|
||||

|
||||
|
||||
<p align="center"> 图:零钱兑换问题的动态规划过程 </p>
|
||||
<p align="center"> 图 14-25 零钱兑换问题的动态规划过程 </p>
|
||||
|
||||
### 3. 状态压缩
|
||||
|
||||
@ -1190,7 +1190,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:零钱兑换问题 II 的示例数据 </p>
|
||||
<p align="center"> 图 14-26 零钱兑换问题 II 的示例数据 </p>
|
||||
|
||||
### 1. 动态规划思路
|
||||
|
||||
|
||||
@ -14,42 +14,42 @@ G & = \{ V, E \} \newline
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如下图所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**,从而更为复杂。
|
||||
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如图 9-1 所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**,从而更为复杂。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:链表、树、图之间的关系 </p>
|
||||
<p align="center"> 图 9-1 链表、树、图之间的关系 </p>
|
||||
|
||||
## 9.1.1 图常见类型与术语
|
||||
|
||||
根据边是否具有方向,可分为下图所示的「无向图 undirected graph」和「有向图 directed graph」。
|
||||
根据边是否具有方向,可分为图 9-2 所示的「无向图 undirected graph」和「有向图 directed graph」。
|
||||
|
||||
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
|
||||
- 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:有向图与无向图 </p>
|
||||
<p align="center"> 图 9-2 有向图与无向图 </p>
|
||||
|
||||
根据所有顶点是否连通,可分为下图所示的「连通图 connected graph」和「非连通图 disconnected graph」。
|
||||
根据所有顶点是否连通,可分为图 9-3 所示的「连通图 connected graph」和「非连通图 disconnected graph」。
|
||||
|
||||
- 对于连通图,从某个顶点出发,可以到达其余任意顶点。
|
||||
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:连通图与非连通图 </p>
|
||||
<p align="center"> 图 9-3 连通图与非连通图 </p>
|
||||
|
||||
我们还可以为边添加“权重”变量,从而得到下图所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
|
||||
我们还可以为边添加“权重”变量,从而得到图 9-4 所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:有权图与无权图 </p>
|
||||
<p align="center"> 图 9-4 有权图与无权图 </p>
|
||||
|
||||
图的常用术语包括:
|
||||
|
||||
- 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。
|
||||
- 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
|
||||
- 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在图 9-4 中,顶点 1 的邻接顶点为顶点 2、3、5。
|
||||
- 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在图 9-4 中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
|
||||
- 「度 degree」:一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点,「出度 Out-Degree」表示有多少条边从该顶点指出。
|
||||
|
||||
## 9.1.2 图的表示
|
||||
@ -60,11 +60,11 @@ $$
|
||||
|
||||
设图的顶点数量为 $n$ ,「邻接矩阵 adjacency matrix」使用一个 $n \times n$ 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间是否存在边。
|
||||
|
||||
如下图所示,设邻接矩阵为 $M$ 、顶点列表为 $V$ ,那么矩阵元素 $M[i, j] = 1$ 表示顶点 $V[i]$ 到顶点 $V[j]$ 之间存在边,反之 $M[i, j] = 0$ 表示两顶点之间无边。
|
||||
如图 9-5 所示,设邻接矩阵为 $M$ 、顶点列表为 $V$ ,那么矩阵元素 $M[i, j] = 1$ 表示顶点 $V[i]$ 到顶点 $V[j]$ 之间存在边,反之 $M[i, j] = 0$ 表示两顶点之间无边。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:图的邻接矩阵表示 </p>
|
||||
<p align="center"> 图 9-5 图的邻接矩阵表示 </p>
|
||||
|
||||
邻接矩阵具有以下特性:
|
||||
|
||||
@ -76,21 +76,21 @@ $$
|
||||
|
||||
### 2. 邻接表
|
||||
|
||||
「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例。
|
||||
「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:图的邻接表表示 </p>
|
||||
<p align="center"> 图 9-6 图的邻接表表示 </p>
|
||||
|
||||
邻接表仅存储实际存在的边,而边的总数通常远小于 $n^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
|
||||
|
||||
观察上图,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降低至 $O(1)$ 。
|
||||
观察图 9-6 ,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降低至 $O(1)$ 。
|
||||
|
||||
## 9.1.3 图常见应用
|
||||
|
||||
如下图所示,许多现实系统都可以用图来建模,相应的问题也可以约化为图计算问题。
|
||||
如表 9-1 所示,许多现实系统都可以用图来建模,相应的问题也可以约化为图计算问题。
|
||||
|
||||
<p align="center"> 表:现实生活中常见的图 </p>
|
||||
<p align="center"> 表 9-1 现实生活中常见的图 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ comments: true
|
||||
|
||||
## 9.2.1 基于邻接矩阵的实现
|
||||
|
||||
给定一个顶点数量为 $n$ 的无向图,则各种操作的实现方式如下图所示。
|
||||
给定一个顶点数量为 $n$ 的无向图,则各种操作的实现方式如图 9-7 所示。
|
||||
|
||||
- **添加或删除边**:直接在邻接矩阵中修改指定的边即可,使用 $O(1)$ 时间。而由于是无向图,因此需要同时更新两个方向的边。
|
||||
- **添加顶点**:在邻接矩阵的尾部添加一行一列,并全部填 $0$ 即可,使用 $O(n)$ 时间。
|
||||
@ -30,7 +30,7 @@ comments: true
|
||||
=== "删除顶点"
|
||||

|
||||
|
||||
<p align="center"> 图:邻接矩阵的初始化、增删边、增删顶点 </p>
|
||||
<p align="center"> 图 9-7 邻接矩阵的初始化、增删边、增删顶点 </p>
|
||||
|
||||
以下是基于邻接矩阵表示图的实现代码。
|
||||
|
||||
@ -1126,7 +1126,7 @@ comments: true
|
||||
|
||||
## 9.2.2 基于邻接表的实现
|
||||
|
||||
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则可根据下图所示的方法实现各种操作。
|
||||
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则可根据图 9-8 所示的方法实现各种操作。
|
||||
|
||||
- **添加边**:在顶点对应链表的末尾添加边即可,使用 $O(1)$ 时间。因为是无向图,所以需要同时添加两个方向的边。
|
||||
- **删除边**:在顶点对应链表中查找并删除指定边,使用 $O(m)$ 时间。在无向图中,需要同时删除两个方向的边。
|
||||
@ -1149,7 +1149,7 @@ comments: true
|
||||
=== "删除顶点"
|
||||

|
||||
|
||||
<p align="center"> 图:邻接表的初始化、增删边、增删顶点 </p>
|
||||
<p align="center"> 图 9-8 邻接表的初始化、增删边、增删顶点 </p>
|
||||
|
||||
以下是基于邻接表实现图的代码示例。细心的同学可能注意到,**我们在邻接表中使用 `Vertex` 节点类来表示顶点**,这样做的原因有:
|
||||
|
||||
@ -2114,9 +2114,9 @@ comments: true
|
||||
|
||||
## 9.2.3 效率对比
|
||||
|
||||
设图中共有 $n$ 个顶点和 $m$ 条边,下表对比了邻接矩阵和邻接表的时间和空间效率。
|
||||
设图中共有 $n$ 个顶点和 $m$ 条边,表 9-2 对比了邻接矩阵和邻接表的时间和空间效率。
|
||||
|
||||
<p align="center"> 表:邻接矩阵与邻接表对比 </p>
|
||||
<p align="center"> 表 9-2 邻接矩阵与邻接表对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -2131,4 +2131,4 @@ comments: true
|
||||
|
||||
</div>
|
||||
|
||||
观察上表,似乎邻接表(哈希表)的时间与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需要一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
|
||||
观察表 9-2 ,似乎邻接表(哈希表)的时间与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需要一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
|
||||
|
||||
@ -14,11 +14,11 @@ comments: true
|
||||
|
||||
## 9.3.1 广度优先遍历
|
||||
|
||||
**广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张**。如下图所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
|
||||
**广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张**。如图 9-9 所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:图的广度优先遍历 </p>
|
||||
<p align="center"> 图 9-9 图的广度优先遍历 </p>
|
||||
|
||||
### 1. 算法实现
|
||||
|
||||
@ -392,7 +392,7 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
||||
}
|
||||
```
|
||||
|
||||
代码相对抽象,建议对照下图来加深理解。
|
||||
代码相对抽象,建议对照图 9-10 来加深理解。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -427,11 +427,11 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
||||
=== "<11>"
|
||||

|
||||
|
||||
<p align="center"> 图:图的广度优先遍历步骤 </p>
|
||||
<p align="center"> 图 9-10 图的广度优先遍历步骤 </p>
|
||||
|
||||
!!! question "广度优先遍历的序列是否唯一?"
|
||||
|
||||
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序是允许被任意打乱的**。以上图为例,顶点 $1$ , $3$ 的访问顺序可以交换、顶点 $2$ , $4$ , $6$ 的访问顺序也可以任意交换。
|
||||
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序是允许被任意打乱的**。以图 9-10 为例,顶点 $1$ , $3$ 的访问顺序可以交换、顶点 $2$ , $4$ , $6$ 的访问顺序也可以任意交换。
|
||||
|
||||
### 2. 复杂度分析
|
||||
|
||||
@ -441,11 +441,11 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
||||
|
||||
## 9.3.2 深度优先遍历
|
||||
|
||||
**深度优先遍历是一种优先走到底、无路可走再回头的遍历方式**。如下图所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
|
||||
**深度优先遍历是一种优先走到底、无路可走再回头的遍历方式**。如图 9-11 所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:图的深度优先遍历 </p>
|
||||
<p align="center"> 图 9-11 图的深度优先遍历 </p>
|
||||
|
||||
### 1. 算法实现
|
||||
|
||||
@ -797,7 +797,7 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
||||
}
|
||||
```
|
||||
|
||||
深度优先遍历的算法流程如下图所示,其中:
|
||||
深度优先遍历的算法流程如图 9-12 所示,其中:
|
||||
|
||||
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点。
|
||||
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此递归方法的位置。
|
||||
@ -837,7 +837,7 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
||||
=== "<11>"
|
||||

|
||||
|
||||
<p align="center"> 图:图的深度优先遍历步骤 </p>
|
||||
<p align="center"> 图 9-12 图的深度优先遍历步骤 </p>
|
||||
|
||||
!!! question "深度优先遍历的序列是否唯一?"
|
||||
|
||||
|
||||
@ -11,22 +11,22 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:分数背包问题的示例数据 </p>
|
||||
<p align="center"> 图 15-3 分数背包问题的示例数据 </p>
|
||||
|
||||
分数背包和 0-1 背包整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求不超过背包容量下的最大价值。
|
||||
|
||||
不同点在于,本题允许只选择物品的一部分。如下图所示,**我们可以对物品任意地进行切分,并按照重量比例来计算物品价值**。
|
||||
不同点在于,本题允许只选择物品的一部分。如图 15-4 所示,**我们可以对物品任意地进行切分,并按照重量比例来计算物品价值**。
|
||||
|
||||
1. 对于物品 $i$ ,它在单位重量下的价值为 $val[i-1] / wgt[i-1]$ ,简称为单位价值。
|
||||
2. 假设放入一部分物品 $i$ ,重量为 $w$ ,则背包增加的价值为 $w \times val[i-1] / wgt[i-1]$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:物品在单位重量下的价值 </p>
|
||||
<p align="center"> 图 15-4 物品在单位重量下的价值 </p>
|
||||
|
||||
### 1. 贪心策略确定
|
||||
|
||||
最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出下图所示的贪心策略:
|
||||
最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出图 15-5 所示的贪心策略:
|
||||
|
||||
1. 将物品按照单位价值从高到低进行排序。
|
||||
2. 遍历所有物品,**每轮贪心地选择单位价值最高的物品**。
|
||||
@ -34,7 +34,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:分数背包的贪心策略 </p>
|
||||
<p align="center"> 图 15-5 分数背包的贪心策略 </p>
|
||||
|
||||
### 2. 代码实现
|
||||
|
||||
@ -365,8 +365,8 @@ status: new
|
||||
|
||||
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,**单位价值更大的物品总是更优选择**,这说明贪心策略是有效的。
|
||||
|
||||
如下图所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可被转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
|
||||
如图 15-6 所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可被转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:分数背包问题的几何表示 </p>
|
||||
<p align="center"> 图 15-6 分数背包问题的几何表示 </p>
|
||||
|
||||
@ -18,11 +18,11 @@ status: new
|
||||
|
||||
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 $-1$ 。
|
||||
|
||||
本题的贪心策略如下图所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。
|
||||
本题的贪心策略如图 15-1 所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:零钱兑换的贪心策略 </p>
|
||||
<p align="center"> 图 15-1 零钱兑换的贪心策略 </p>
|
||||
|
||||
实现代码如下所示。你可能会不由地发出感叹:So Clean !贪心算法仅用十行代码就解决了零钱兑换问题。
|
||||
|
||||
@ -225,7 +225,7 @@ status: new
|
||||
|
||||
**贪心算法不仅操作直接、实现简单,而且通常效率也很高**。在以上代码中,记硬币最小面值为 $\min(coins)$ ,则贪心选择最多循环 $amt / \min(coins)$ 次,时间复杂度为 $O(amt / \min(coins))$ 。这比动态规划解法的时间复杂度 $O(n \times amt)$ 提升了一个数量级。
|
||||
|
||||
然而,**对于某些硬币面值组合,贪心算法并不能找到最优解**。下图给出了两个示例。
|
||||
然而,**对于某些硬币面值组合,贪心算法并不能找到最优解**。图 15-2 给出了两个示例。
|
||||
|
||||
- **正例 $coins = [1, 5, 10, 20, 50, 100]$**:在该硬币组合下,给定任意 $amt$ ,贪心算法都可以找出最优解。
|
||||
- **反例 $coins = [1, 20, 50]$**:假设 $amt = 60$ ,贪心算法只能找到 $50 + 1 \times 10$ 的兑换组合,共计 $11$ 枚硬币,但动态规划可以找到最优解 $20 + 20 + 20$ ,仅需 $3$ 枚硬币。
|
||||
@ -233,7 +233,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:贪心无法找出最优解的示例 </p>
|
||||
<p align="center"> 图 15-2 贪心无法找出最优解的示例 </p>
|
||||
|
||||
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最大容量问题的示例数据 </p>
|
||||
<p align="center"> 图 15-7 最大容量问题的示例数据 </p>
|
||||
|
||||
容器由任意两个隔板围成,**因此本题的状态为两个隔板的索引,记为 $[i, j]$** 。
|
||||
|
||||
@ -29,30 +29,30 @@ $$
|
||||
|
||||
### 1. 贪心策略确定
|
||||
|
||||
这道题还有更高效率的解法。如下图所示,现选取一个状态 $[i, j]$ ,其满足索引 $i < j$ 且高度 $ht[i] < ht[j]$ ,即 $i$ 为短板、 $j$ 为长板。
|
||||
这道题还有更高效率的解法。如图 15-8 所示,现选取一个状态 $[i, j]$ ,其满足索引 $i < j$ 且高度 $ht[i] < ht[j]$ ,即 $i$ 为短板、 $j$ 为长板。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:初始状态 </p>
|
||||
<p align="center"> 图 15-8 初始状态 </p>
|
||||
|
||||
如下图所示,**若此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。这是因为在移动长板 $j$ 后:
|
||||
如图 15-9 所示,**若此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。这是因为在移动长板 $j$ 后:
|
||||
|
||||
- 宽度 $j-i$ 肯定变小。
|
||||
- 高度由短板决定,因此高度只可能不变( $i$ 仍为短板)或变小(移动后的 $j$ 成为短板)。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:向内移动长板后的状态 </p>
|
||||
<p align="center"> 图 15-9 向内移动长板后的状态 </p>
|
||||
|
||||
反向思考,**我们只有向内收缩短板 $i$ ,才有可能使容量变大**。因为虽然宽度一定变小,**但高度可能会变大**(移动后的短板 $i$ 可能会变长)。例如在下图中,移动短板后面积变大。
|
||||
反向思考,**我们只有向内收缩短板 $i$ ,才有可能使容量变大**。因为虽然宽度一定变小,**但高度可能会变大**(移动后的短板 $i$ 可能会变长)。例如在图 15-10 中,移动短板后面积变大。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:向内移动短板后的状态 </p>
|
||||
<p align="center"> 图 15-10 向内移动短板后的状态 </p>
|
||||
|
||||
由此便可推出本题的贪心策略:初始化两指针分裂容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
|
||||
|
||||
下图展示了贪心策略的执行过程。
|
||||
图 15-11 展示了贪心策略的执行过程。
|
||||
|
||||
1. 初始状态下,指针 $i$ , $j$ 分列与数组两端。
|
||||
2. 计算当前状态的容量 $cap[i, j]$ ,并更新最大容量。
|
||||
@ -86,7 +86,7 @@ $$
|
||||
=== "<9>"
|
||||

|
||||
|
||||
<p align="center"> 图:最大容量问题的贪心过程 </p>
|
||||
<p align="center"> 图 15-11 最大容量问题的贪心过程 </p>
|
||||
|
||||
### 2. 代码实现
|
||||
|
||||
@ -301,7 +301,7 @@ $$
|
||||
|
||||
之所以贪心比穷举更快,是因为每轮的贪心选择都会“跳过”一些状态。
|
||||
|
||||
比如在状态 $cap[i, j]$ 下,$i$ 为短板、$j$ 为长板。若贪心地将短板 $i$ 向内移动一格,会导致下图所示的状态被“跳过”。**这意味着之后无法验证这些状态的容量大小**。
|
||||
比如在状态 $cap[i, j]$ 下,$i$ 为短板、$j$ 为长板。若贪心地将短板 $i$ 向内移动一格,会导致图 15-12 所示的状态被“跳过”。**这意味着之后无法验证这些状态的容量大小**。
|
||||
|
||||
$$
|
||||
cap[i, i+1], cap[i, i+2], \dots, cap[i, j-2], cap[i, j-1]
|
||||
@ -309,7 +309,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:移动短板导致被跳过的状态 </p>
|
||||
<p align="center"> 图 15-12 移动短板导致被跳过的状态 </p>
|
||||
|
||||
观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最大切分乘积的问题定义 </p>
|
||||
<p align="center"> 图 15-13 最大切分乘积的问题定义 </p>
|
||||
|
||||
假设我们将 $n$ 切分为 $m$ 个整数因子,其中第 $i$ 个因子记为 $n_i$ ,即
|
||||
|
||||
@ -39,23 +39,23 @@ n & \geq 4
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
如下图所示,当 $n \geq 4$ 时,切分出一个 $2$ 后乘积会变大,**这说明大于等于 $4$ 的整数都应该被切分**。
|
||||
如图 15-14 所示,当 $n \geq 4$ 时,切分出一个 $2$ 后乘积会变大,**这说明大于等于 $4$ 的整数都应该被切分**。
|
||||
|
||||
**贪心策略一**:如果切分方案中包含 $\geq 4$ 的因子,那么它就应该被继续切分。最终的切分方案只应出现 $1$ , $2$ , $3$ 这三种因子。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:切分导致乘积变大 </p>
|
||||
<p align="center"> 图 15-14 切分导致乘积变大 </p>
|
||||
|
||||
接下来思考哪个因子是最优的。在 $1$ , $2$ , $3$ 这三个因子中,显然 $1$ 是最差的,因为 $1 \times (n-1) < n$ 恒成立,即切分出 $1$ 反而会导致乘积减小。
|
||||
|
||||
如下图所示,当 $n = 6$ 时,有 $3 \times 3 > 2 \times 2 \times 2$ 。**这意味着切分出 $3$ 比切分出 $2$ 更优**。
|
||||
如图 15-15 所示,当 $n = 6$ 时,有 $3 \times 3 > 2 \times 2 \times 2$ 。**这意味着切分出 $3$ 比切分出 $2$ 更优**。
|
||||
|
||||
**贪心策略二**:在切分方案中,最多只应存在两个 $2$ 。因为三个 $2$ 总是可以被替换为两个 $3$ ,从而获得更大乘积。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最优切分因子 </p>
|
||||
<p align="center"> 图 15-15 最优切分因子 </p>
|
||||
|
||||
总结以上,可推出贪心策略:
|
||||
|
||||
@ -66,7 +66,7 @@ $$
|
||||
|
||||
### 2. 代码实现
|
||||
|
||||
如下图所示,我们无须通过循环来切分整数,而可以利用向下整除运算得到 $3$ 的个数 $a$ ,用取模运算得到余数 $b$ ,此时有:
|
||||
如图 15-16 所示,我们无须通过循环来切分整数,而可以利用向下整除运算得到 $3$ 的个数 $a$ ,用取模运算得到余数 $b$ ,此时有:
|
||||
|
||||
$$
|
||||
n = 3 a + b
|
||||
@ -276,7 +276,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:最大切分乘积的计算方法 </p>
|
||||
<p align="center"> 图 15-16 最大切分乘积的计算方法 </p>
|
||||
|
||||
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有:
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ comments: true
|
||||
|
||||
在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,**它们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生**。
|
||||
|
||||
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
|
||||
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图 6-7 所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:哈希冲突的最佳与最差情况 </p>
|
||||
<p align="center"> 图 6-7 哈希冲突的最佳与最差情况 </p>
|
||||
|
||||
**键值对的分布情况由哈希函数决定**。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
|
||||
|
||||
|
||||
@ -15,11 +15,11 @@ comments: true
|
||||
|
||||
## 6.2.1 链式地址
|
||||
|
||||
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。下图展示了一个链式地址哈希表的例子。
|
||||
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。图 6-5 展示了一个链式地址哈希表的例子。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:链式地址哈希表 </p>
|
||||
<p align="center"> 图 6-5 链式地址哈希表 </p>
|
||||
|
||||
链式地址下,哈希表的操作方法包括:
|
||||
|
||||
@ -1163,11 +1163,11 @@ comments: true
|
||||
- **插入元素**:通过哈希函数计算数组索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 $1$ ),直至找到空位,将元素插入其中。
|
||||
- **查找元素**:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空位,说明目标键值对不在哈希表中,返回 $\text{None}$ 。
|
||||
|
||||
下图展示了一个在开放寻址(线性探测)下工作的哈希表。
|
||||
图 6-6 展示了一个在开放寻址(线性探测)下工作的哈希表。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:开放寻址和线性探测 </p>
|
||||
<p align="center"> 图 6-6 开放寻址和线性探测 </p>
|
||||
|
||||
然而,线性探测存在以下缺陷:
|
||||
|
||||
|
||||
@ -6,19 +6,19 @@ comments: true
|
||||
|
||||
「哈希表 hash table」,又称「散列表」,其通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value` 。
|
||||
|
||||
如下图所示,给定 $n$ 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用哈希表来实现。
|
||||
如图 6-1 所示,给定 $n$ 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用图 6-1 所示的哈希表来实现。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:哈希表的抽象表示 </p>
|
||||
<p align="center"> 图 6-1 哈希表的抽象表示 </p>
|
||||
|
||||
除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如下表所示。
|
||||
除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如表 6-1 所示。
|
||||
|
||||
- **添加元素**:仅需将元素添加至数组(链表)的尾部即可,使用 $O(1)$ 时间。
|
||||
- **查询元素**:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 $O(n)$ 时间。
|
||||
- **删除元素**:需要先查询到元素,再从数组(链表)中删除,使用 $O(n)$ 时间。
|
||||
|
||||
<p align="center"> 表:元素查询效率对比 </p>
|
||||
<p align="center"> 表 6-1 元素查询效率对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -462,11 +462,11 @@ index = hash(key) % capacity
|
||||
|
||||
随后,我们就可以利用 `index` 在哈希表中访问对应的桶,从而获取 `value` 。
|
||||
|
||||
设数组长度 `capacity = 100` 、哈希算法 `hash(key) = key` ,易得哈希函数为 `key % 100` 。下图以 `key` 学号和 `value` 姓名为例,展示了哈希函数的工作原理。
|
||||
设数组长度 `capacity = 100` 、哈希算法 `hash(key) = key` ,易得哈希函数为 `key % 100` 。图 6-2 以 `key` 学号和 `value` 姓名为例,展示了哈希函数的工作原理。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:哈希函数工作原理 </p>
|
||||
<p align="center"> 图 6-2 哈希函数工作原理 </p>
|
||||
|
||||
以下代码实现了一个简单哈希表。其中,我们将 `key` 和 `value` 封装成一个类 `Pair` ,以表示键值对。
|
||||
|
||||
@ -1499,19 +1499,19 @@ index = hash(key) % capacity
|
||||
20336 % 100 = 36
|
||||
```
|
||||
|
||||
如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 hash collision」。
|
||||
如图 6-3 所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 hash collision」。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:哈希冲突示例 </p>
|
||||
<p align="center"> 图 6-3 哈希冲突示例 </p>
|
||||
|
||||
容易想到,哈希表容量 $n$ 越大,多个 `key` 被分配到同一个桶中的概率就越低,冲突就越少。因此,**我们可以通过扩容哈希表来减少哈希冲突**。
|
||||
|
||||
如下图所示,扩容前键值对 `(136, A)` 和 `(236, D)` 发生冲突,扩容后冲突消失。
|
||||
如图 6-4 所示,扩容前键值对 `(136, A)` 和 `(236, D)` 发生冲突,扩容后冲突消失。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:哈希表扩容 </p>
|
||||
<p align="center"> 图 6-4 哈希表扩容 </p>
|
||||
|
||||
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
|
||||
|
||||
|
||||
@ -206,9 +206,9 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完美二叉树的各层节点数量 </p>
|
||||
<p align="center"> 图 8-5 完美二叉树的各层节点数量 </p>
|
||||
|
||||
如上图所示,**节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”**。因此,我们可以将各层的“节点数量 $\times$ 节点高度”求和,**从而得到所有节点的堆化迭代次数的总和**。
|
||||
如图 8-5 所示,**节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”**。因此,我们可以将各层的“节点数量 $\times$ 节点高度”求和,**从而得到所有节点的堆化迭代次数的总和**。
|
||||
|
||||
$$
|
||||
T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1
|
||||
|
||||
@ -4,14 +4,14 @@ comments: true
|
||||
|
||||
# 8.1 堆
|
||||
|
||||
「堆 heap」是一种满足特定条件的完全二叉树,主要可分为下图所示的两种类型:
|
||||
「堆 heap」是一种满足特定条件的完全二叉树,主要可分为图 8-1 所示的两种类型:
|
||||
|
||||
- 「大顶堆 max heap」:任意节点的值 $\geq$ 其子节点的值。
|
||||
- 「小顶堆 min heap」:任意节点的值 $\leq$ 其子节点的值。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:小顶堆与大顶堆 </p>
|
||||
<p align="center"> 图 8-1 小顶堆与大顶堆 </p>
|
||||
|
||||
堆作为完全二叉树的一个特例,具有以下特性:
|
||||
|
||||
@ -25,9 +25,9 @@ comments: true
|
||||
|
||||
实际上,**堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一使用“堆“来命名。
|
||||
|
||||
堆的常用操作见下表,方法名需要根据编程语言来确定。
|
||||
堆的常用操作见表 8-1 ,方法名需要根据编程语言来确定。
|
||||
|
||||
<p align="center"> 表:堆的操作效率 </p>
|
||||
<p align="center"> 表 8-1 堆的操作效率 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -68,11 +68,11 @@ comments: true
|
||||
|
||||
/* 堆顶元素出堆 */
|
||||
// 出堆元素会形成一个从大到小的序列
|
||||
peek = heap.poll(); // 5
|
||||
peek = heap.poll(); // 4
|
||||
peek = heap.poll(); // 3
|
||||
peek = heap.poll(); // 2
|
||||
peek = heap.poll(); // 1
|
||||
peek = maxHeap.poll(); // 5
|
||||
peek = maxHeap.poll(); // 4
|
||||
peek = maxHeap.poll(); // 3
|
||||
peek = maxHeap.poll(); // 2
|
||||
peek = maxHeap.poll(); // 1
|
||||
|
||||
/* 获取堆大小 */
|
||||
int size = maxHeap.size();
|
||||
@ -331,11 +331,11 @@ comments: true
|
||||
|
||||
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。**节点指针通过索引映射公式来实现**。
|
||||
|
||||
如下图所示,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下取整)。当索引越界时,表示空节点或节点不存在。
|
||||
如图 8-2 所示,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下取整)。当索引越界时,表示空节点或节点不存在。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:堆的表示与存储 </p>
|
||||
<p align="center"> 图 8-2 堆的表示与存储 </p>
|
||||
|
||||
我们可以将索引映射公式封装成函数,方便后续使用。
|
||||
|
||||
@ -681,7 +681,7 @@ comments: true
|
||||
|
||||
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
|
||||
|
||||
考虑从入堆节点开始,**从底至顶执行堆化**。如下图所示,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
|
||||
考虑从入堆节点开始,**从底至顶执行堆化**。如图 8-3 所示,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -710,7 +710,7 @@ comments: true
|
||||
=== "<9>"
|
||||

|
||||
|
||||
<p align="center"> 图:元素入堆步骤 </p>
|
||||
<p align="center"> 图 8-3 元素入堆步骤 </p>
|
||||
|
||||
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ ,**元素入堆操作的时间复杂度为 $O(\log n)$** 。
|
||||
|
||||
@ -1058,7 +1058,7 @@ comments: true
|
||||
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素)。
|
||||
3. 从根节点开始,**从顶至底执行堆化**。
|
||||
|
||||
如下图所示,**“从顶至底堆化”的操作方向与“从底至顶堆化”相反**,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换。然后循环执行此操作,直到越过叶节点或遇到无须交换的节点时结束。
|
||||
如图 8-4 所示,**“从顶至底堆化”的操作方向与“从底至顶堆化”相反**,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换。然后循环执行此操作,直到越过叶节点或遇到无须交换的节点时结束。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -1090,7 +1090,7 @@ comments: true
|
||||
=== "<10>"
|
||||

|
||||
|
||||
<p align="center"> 图:堆顶元素出堆步骤 </p>
|
||||
<p align="center"> 图 8-4 堆顶元素出堆步骤 </p>
|
||||
|
||||
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 $O(\log n)$ 。
|
||||
|
||||
|
||||
@ -12,13 +12,13 @@ comments: true
|
||||
|
||||
## 8.3.1 方法一:遍历选择
|
||||
|
||||
我们可以进行下图所示的 $k$ 轮遍历,分别在每轮中提取第 $1$ , $2$ , $\dots$ , $k$ 大的元素,时间复杂度为 $O(nk)$ 。
|
||||
我们可以进行图 8-6 所示的 $k$ 轮遍历,分别在每轮中提取第 $1$ , $2$ , $\dots$ , $k$ 大的元素,时间复杂度为 $O(nk)$ 。
|
||||
|
||||
此方法只适用于 $k \ll n$ 的情况,因为当 $k$ 与 $n$ 比较接近时,其时间复杂度趋向于 $O(n^2)$ ,非常耗时。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:遍历寻找最大的 k 个元素 </p>
|
||||
<p align="center"> 图 8-6 遍历寻找最大的 k 个元素 </p>
|
||||
|
||||
!!! tip
|
||||
|
||||
@ -26,17 +26,17 @@ comments: true
|
||||
|
||||
## 8.3.2 方法二:排序
|
||||
|
||||
如下图所示,我们可以先对数组 `nums` 进行排序,再返回最右边的 $k$ 个元素,时间复杂度为 $O(n \log n)$ 。
|
||||
如图 8-7 所示,我们可以先对数组 `nums` 进行排序,再返回最右边的 $k$ 个元素,时间复杂度为 $O(n \log n)$ 。
|
||||
|
||||
显然,该方法“超额”完成任务了,因为我们只需要找出最大的 $k$ 个元素即可,而不需要排序其他元素。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:排序寻找最大的 k 个元素 </p>
|
||||
<p align="center"> 图 8-7 排序寻找最大的 k 个元素 </p>
|
||||
|
||||
## 8.3.3 方法三:堆
|
||||
|
||||
我们可以基于堆更加高效地解决 Top-K 问题,流程如下图所示。
|
||||
我们可以基于堆更加高效地解决 Top-K 问题,流程如图 8-8 所示。
|
||||
|
||||
1. 初始化一个小顶堆,其堆顶元素最小。
|
||||
2. 先将数组的前 $k$ 个元素依次入堆。
|
||||
@ -70,7 +70,7 @@ comments: true
|
||||
=== "<9>"
|
||||

|
||||
|
||||
<p align="center"> 图:基于堆寻找最大的 k 个元素 </p>
|
||||
<p align="center"> 图 8-8 基于堆寻找最大的 k 个元素 </p>
|
||||
|
||||
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ comments: true
|
||||
|
||||
在正式探讨算法之前,有一个有趣的事实值得分享:**你已经在不知不觉中学会了许多算法,并习惯将它们应用到日常生活中了**。下面,我将举几个具体例子来证实这一点。
|
||||
|
||||
**例一:查阅字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会按照下图所示的方式实现。
|
||||
**例一:查阅字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会按照图 1-1 所示的方式实现。
|
||||
|
||||
1. 翻开字典约一半的页数,查看该页的首字母是什么,假设首字母为 $m$ 。
|
||||
2. 由于在拼音字母表中 $r$ 位于 $m$ 之后,所以排除字典前半部分,查找范围缩小到后半部分。
|
||||
@ -29,11 +29,11 @@ comments: true
|
||||
=== "<5>"
|
||||

|
||||
|
||||
<p align="center"> 图:查字典步骤 </p>
|
||||
<p align="center"> 图 1-1 查字典步骤 </p>
|
||||
|
||||
查阅字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的“数组”;从算法的角度,我们可以将上述查字典的一系列操作看作是“二分查找”。
|
||||
|
||||
**例二:整理扑克**。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下图所示。
|
||||
**例二:整理扑克**。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如图 1-2 所示。
|
||||
|
||||
1. 将扑克牌划分为“有序”和“无序”两部分,并假设初始状态下最左 1 张扑克牌已经有序。
|
||||
2. 在无序部分抽出一张扑克牌,插入至有序部分的正确位置;完成后最左 2 张扑克已经有序。
|
||||
@ -41,11 +41,11 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:扑克排序步骤 </p>
|
||||
<p align="center"> 图 1-2 扑克排序步骤 </p>
|
||||
|
||||
上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
|
||||
|
||||
**例三:货币找零**。假设我们在超市购买了 $69$ 元的商品,给收银员付了 $100$ 元,则收银员需要找我们 $31$ 元。他会很自然地完成下图所示的思考。
|
||||
**例三:货币找零**。假设我们在超市购买了 $69$ 元的商品,给收银员付了 $100$ 元,则收银员需要找我们 $31$ 元。他会很自然地完成图 1-3 所示的思考。
|
||||
|
||||
1. 可选项是比 $31$ 元面值更小的货币,包括 $1$ 元、$5$ 元、$10$ 元、$20$ 元。
|
||||
2. 从可选项中拿出最大的 $20$ 元,剩余 $31 - 20 = 11$ 元。
|
||||
@ -55,7 +55,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:货币找零过程 </p>
|
||||
<p align="center"> 图 1-3 货币找零过程 </p>
|
||||
|
||||
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是“贪心”算法。
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ comments: true
|
||||
|
||||
## 1.2.3 数据结构与算法的关系
|
||||
|
||||
数据结构与算法高度相关、紧密结合,具体表现在下图所示的几个方面。
|
||||
数据结构与算法高度相关、紧密结合,具体表现在图 1-4 所示的几个方面。
|
||||
|
||||
- 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及用于操作数据的方法。
|
||||
- 算法是数据结构发挥作用的舞台。数据结构本身仅存储数据信息,结合算法才能解决特定问题。
|
||||
@ -35,17 +35,17 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数据结构与算法的关系 </p>
|
||||
<p align="center"> 图 1-4 数据结构与算法的关系 </p>
|
||||
|
||||
数据结构与算法犹如下图所示的拼装积木。一套积木,除了包含许多零件之外,还附有详细的组装说明书。我们按照说明书一步步操作,就能组装出精美的积木模型。
|
||||
数据结构与算法犹如图 1-5 所示的拼装积木。一套积木,除了包含许多零件之外,还附有详细的组装说明书。我们按照说明书一步步操作,就能组装出精美的积木模型。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:拼装积木 </p>
|
||||
<p align="center"> 图 1-5 拼装积木 </p>
|
||||
|
||||
两者的详细对应关系如下表所示。
|
||||
两者的详细对应关系如表 1-1 所示。
|
||||
|
||||
<p align="center"> 表:将数据结构与算法类比为积木 </p>
|
||||
<p align="center"> 表 1-1 将数据结构与算法类比为积木 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ comments: true
|
||||
|
||||
## 0.1.2 内容结构
|
||||
|
||||
本书主要内容如下图所示。
|
||||
本书主要内容如图 0-1 所示。
|
||||
|
||||
- **复杂度分析**:数据结构和算法的评价维度与方法。时间复杂度、空间复杂度的推算方法、常见类型、示例等。
|
||||
- **数据结构**:基本数据类型,数据结构的分类方法。数组、链表、栈、队列、哈希表、树、堆、图等数据结构的定义、优缺点、常用操作、常见类型、典型应用、实现方法等。
|
||||
@ -32,7 +32,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:Hello 算法内容结构 </p>
|
||||
<p align="center"> 图 0-1 Hello 算法内容结构 </p>
|
||||
|
||||
## 0.1.3 致谢
|
||||
|
||||
|
||||
@ -168,15 +168,15 @@ comments: true
|
||||
|
||||
相较于文字,视频和图片具有更高的信息密度和结构化程度,更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
|
||||
|
||||
如果你在阅读本书时,发现某段内容提供了下图所示的动画或图解,**请以图为主、以文字为辅**,综合两者来理解内容。
|
||||
如果你在阅读本书时,发现某段内容提供了图 0-2 所示的动画或图解,**请以图为主、以文字为辅**,综合两者来理解内容。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:动画图解示例 </p>
|
||||
<p align="center"> 图 0-2 动画图解示例 </p>
|
||||
|
||||
## 0.2.3 在代码实践中加深理解
|
||||
|
||||
本书的配套代码被托管在 [GitHub 仓库](https://github.com/krahets/hello-algo)。如下图所示,**源代码附有测试样例,可一键运行**。
|
||||
本书的配套代码被托管在 [GitHub 仓库](https://github.com/krahets/hello-algo)。如图 0-3 所示,**源代码附有测试样例,可一键运行**。
|
||||
|
||||
如果时间允许,**建议你参照代码自行敲一遍**。如果学习时间有限,请至少通读并运行所有代码。
|
||||
|
||||
@ -184,7 +184,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:运行代码示例 </p>
|
||||
<p align="center"> 图 0-3 运行代码示例 </p>
|
||||
|
||||
运行代码的前置工作主要分为三步。
|
||||
|
||||
@ -196,27 +196,27 @@ comments: true
|
||||
git clone https://github.com/krahets/hello-algo.git
|
||||
```
|
||||
|
||||
当然,你也可以点击“Download ZIP”直接下载代码压缩包,然后在本地解压即可。
|
||||
当然,你也可以在图 0-4 所示的位置,点击“Download ZIP”直接下载代码压缩包,然后在本地解压即可。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:克隆仓库与下载代码 </p>
|
||||
<p align="center"> 图 0-4 克隆仓库与下载代码 </p>
|
||||
|
||||
**第三步:运行源代码**。如果代码块顶部标有文件名称,则可以在仓库的 `codes` 文件夹中找到相应的源代码文件。源代码文件将帮助你节省不必要的调试时间,让你能够专注于学习内容。
|
||||
**第三步:运行源代码**。如图 0-5 所示,对于顶部标有文件名称的代码块,我们可以在仓库的 `codes` 文件夹内找到对应的源代码文件。源代码文件可一键运行,将帮助你节省不必要的调试时间,让你能够专注于学习内容。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:代码块与对应的源代码文件 </p>
|
||||
<p align="center"> 图 0-5 代码块与对应的源代码文件 </p>
|
||||
|
||||
## 0.2.4 在提问讨论中共同成长
|
||||
|
||||
在阅读本书时,请不要轻易跳过那些没学明白的知识点。**欢迎在评论区提出你的问题**,我和小伙伴们将竭诚为你解答,一般情况下可在两天内回复。
|
||||
|
||||
同时,也希望你能在评论区多花些时间。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享您的见解,帮助他人进步。
|
||||
如图 0-6 所示,每篇文章的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享您的见解,帮助他人进步。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:评论区示例 </p>
|
||||
<p align="center"> 图 0-6 评论区示例 </p>
|
||||
|
||||
## 0.2.5 算法学习路线
|
||||
|
||||
@ -226,8 +226,8 @@ git clone https://github.com/krahets/hello-algo.git
|
||||
2. **刷算法题**。建议从热门题目开刷,如[剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)和[LeetCode Hot 100](https://leetcode.cn/problem-list/2cktkvj/),先积累至少 100 道题目,熟悉主流的算法问题。初次刷题时,“知识遗忘”可能是一个挑战,但请放心,这是很正常的。我们可以按照“艾宾浩斯遗忘曲线”来复习题目,通常在进行 3-5 轮的重复后,就能将其牢记在心。
|
||||
3. **搭建知识体系**。在学习方面,我们可以阅读算法专栏文章、解题框架和算法教材,以不断丰富知识体系。在刷题方面,可以尝试采用进阶刷题策略,如按专题分类、一题多解、一解多题等,相关的刷题心得可以在各个社区找到。
|
||||
|
||||
如下图所示,本书内容主要涵盖“第一阶段”,旨在帮助你更高效地展开第二和第三阶段的学习。
|
||||
如图 0-7 所示,本书内容主要涵盖“第一阶段”,旨在帮助你更高效地展开第二和第三阶段的学习。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:算法学习路线 </p>
|
||||
<p align="center"> 图 0-7 算法学习路线 </p>
|
||||
|
||||
@ -12,9 +12,9 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二分查找示例数据 </p>
|
||||
<p align="center"> 图 10-1 二分查找示例数据 </p>
|
||||
|
||||
如下图所示,我们先初始化指针 $i = 0$ 和 $j = n - 1$ ,分别指向数组首元素和尾元素,代表搜索区间 $[0, n - 1]$ 。请注意,中括号表示闭区间,其包含边界值本身。
|
||||
如图 10-2 所示,我们先初始化指针 $i = 0$ 和 $j = n - 1$ ,分别指向数组首元素和尾元素,代表搜索区间 $[0, n - 1]$ 。请注意,中括号表示闭区间,其包含边界值本身。
|
||||
|
||||
接下来,循环执行以下两个步骤:
|
||||
|
||||
@ -47,7 +47,7 @@ comments: true
|
||||
=== "<7>"
|
||||

|
||||
|
||||
<p align="center"> 图:binary_search_step1 </p>
|
||||
<p align="center"> 图 10-2 binary_search_step1 </p>
|
||||
|
||||
值得注意的是,由于 $i$ 和 $j$ 都是 `int` 类型,**因此 $i + j$ 可能会超出 `int` 类型的取值范围**。为了避免大数越界,我们通常采用公式 $m = \lfloor {i + (j - i) / 2} \rfloor$ 来计算中点。
|
||||
|
||||
@ -623,13 +623,13 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
|
||||
如图 10-3 所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
|
||||
|
||||
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错,**因此一般建议采用“双闭区间”的写法**。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:两种区间定义 </p>
|
||||
<p align="center"> 图 10-3 两种区间定义 </p>
|
||||
|
||||
## 10.1.2 优点与局限性
|
||||
|
||||
|
||||
@ -160,11 +160,11 @@ status: new
|
||||
|
||||
实际上,我们可以利用查找最左元素的函数来查找最右元素,具体方法为:**将查找最右一个 `target` 转化为查找最左一个 `target + 1`**。
|
||||
|
||||
如下图所示,查找完成后,指针 $i$ 指向最左一个 `target + 1`(如果存在),而 $j$ 指向最右一个 `target` ,**因此返回 $j$ 即可**。
|
||||
如图 10-7 所示,查找完成后,指针 $i$ 指向最左一个 `target + 1`(如果存在),而 $j$ 指向最右一个 `target` ,**因此返回 $j$ 即可**。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:将查找右边界转化为查找左边界 </p>
|
||||
<p align="center"> 图 10-7 将查找右边界转化为查找左边界 </p>
|
||||
|
||||
请注意,返回的插入点是 $i$ ,因此需要将其减 $1$ ,从而获得 $j$ 。
|
||||
|
||||
@ -314,14 +314,14 @@ status: new
|
||||
|
||||
我们知道,当数组不包含 `target` 时,最后 $i$ , $j$ 会分别指向首个大于、小于 `target` 的元素。
|
||||
|
||||
根据上述结论,我们可以构造一个数组中不存在的元素,用于查找左右边界,如下图所示。
|
||||
根据上述结论,我们可以构造一个数组中不存在的元素,用于查找左右边界,如图 10-8 所示。
|
||||
|
||||
- 查找最左一个 `target` :可以转化为查找 `target - 0.5` ,并返回指针 $i$ 。
|
||||
- 查找最右一个 `target` :可以转化为查找 `target + 0.5` ,并返回指针 $j$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:将查找边界转化为查找元素 </p>
|
||||
<p align="center"> 图 10-8 将查找边界转化为查找元素 </p>
|
||||
|
||||
代码在此省略,值得注意的有:
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ status: new
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二分查找插入点示例数据 </p>
|
||||
<p align="center"> 图 10-4 二分查找插入点示例数据 </p>
|
||||
|
||||
如果想要复用上节的二分查找代码,则需要回答以下两个问题。
|
||||
|
||||
@ -196,18 +196,18 @@ status: new
|
||||
|
||||
假设数组中存在多个 `target` ,则普通二分查找只能返回其中一个 `target` 的索引,**而无法确定该元素的左边和右边还有多少 `target`**。
|
||||
|
||||
题目要求将目标元素插入到最左边,**所以我们需要查找数组中最左一个 `target` 的索引**。初步考虑通过下图所示的步骤实现。
|
||||
题目要求将目标元素插入到最左边,**所以我们需要查找数组中最左一个 `target` 的索引**。初步考虑通过图 10-5 所示的步骤实现。
|
||||
|
||||
1. 执行二分查找,得到任意一个 `target` 的索引,记为 $k$ 。
|
||||
2. 从索引 $k$ 开始,向左进行线性遍历,当找到最左边的 `target` 时返回。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:线性查找重复元素的插入点 </p>
|
||||
<p align="center"> 图 10-5 线性查找重复元素的插入点 </p>
|
||||
|
||||
此方法虽然可用,但其包含线性查找,因此时间复杂度为 $O(n)$ 。当数组中存在很多重复的 `target` 时,该方法效率很低。
|
||||
|
||||
现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target` 和 `nums[m]` 大小关系:
|
||||
现考虑拓展二分查找代码。如图 10-6 所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target` 和 `nums[m]` 大小关系:
|
||||
|
||||
1. 当 `nums[m] < target` 或 `nums[m] > target` 时,说明还没有找到 `target` ,因此采用普通二分查找的缩小区间操作,**从而使指针 $i$ 和 $j$ 向 `target` 靠近**。
|
||||
2. 当 `nums[m] == target` 时,说明小于 `target` 的元素在区间 $[i, m - 1]$ 中,因此采用 $j = m - 1$ 来缩小区间,**从而使指针 $j$ 向小于 `target` 的元素靠近**。
|
||||
@ -238,7 +238,7 @@ status: new
|
||||
=== "<8>"
|
||||

|
||||
|
||||
<p align="center"> 图:二分查找重复元素的插入点的步骤 </p>
|
||||
<p align="center"> 图 10-6 二分查找重复元素的插入点的步骤 </p>
|
||||
|
||||
观察以下代码,判断分支 `nums[m] > target` 和 `nums[m] == target` 的操作相同,因此两者可以合并。
|
||||
|
||||
|
||||
@ -12,11 +12,11 @@ comments: true
|
||||
|
||||
## 10.4.1 线性查找:以时间换空间
|
||||
|
||||
考虑直接遍历所有可能的组合。如下图所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 `target` ,若是则返回它们的索引。
|
||||
考虑直接遍历所有可能的组合。如图 10-9 所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 `target` ,若是则返回它们的索引。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:线性查找求解两数之和 </p>
|
||||
<p align="center"> 图 10-9 线性查找求解两数之和 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -231,7 +231,7 @@ comments: true
|
||||
|
||||
## 10.4.2 哈希查找:以空间换时间
|
||||
|
||||
考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行下图所示的步骤。
|
||||
考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行图 10-10 所示的步骤。
|
||||
|
||||
1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引。
|
||||
2. 将键值对 `nums[i]` 和索引 `i` 添加进哈希表。
|
||||
@ -245,7 +245,7 @@ comments: true
|
||||
=== "<3>"
|
||||

|
||||
|
||||
<p align="center"> 图:辅助哈希表求解两数之和 </p>
|
||||
<p align="center"> 图 10-10 辅助哈希表求解两数之和 </p>
|
||||
|
||||
实现代码如下所示,仅需单层循环即可。
|
||||
|
||||
|
||||
@ -42,15 +42,15 @@ comments: true
|
||||
|
||||
## 10.5.3 搜索方法选取
|
||||
|
||||
给定大小为 $n$ 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法在该数据中搜索目标元素。各个方法的工作原理如下图所示。
|
||||
给定大小为 $n$ 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法在该数据中搜索目标元素。各个方法的工作原理如图 10-11 所示。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:多种搜索策略 </p>
|
||||
<p align="center"> 图 10-11 多种搜索策略 </p>
|
||||
|
||||
上述几种方法的操作效率与特性如下表所示。
|
||||
上述几种方法的操作效率与特性如表 10-1 所示。
|
||||
|
||||
<p align="center"> 表:查找算法效率对比 </p>
|
||||
<p align="center"> 表 10-1 查找算法效率对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -65,7 +65,7 @@ comments: true
|
||||
|
||||
</div>
|
||||
|
||||
除了以上表格内容,搜索算法的选择还取决于数据体量、搜索性能要求、数据查询与更新频率等。
|
||||
搜索算法的选择还取决于数据体量、搜索性能要求、数据查询与更新频率等。
|
||||
|
||||
**线性搜索**
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ comments: true
|
||||
|
||||
「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。
|
||||
|
||||
如下图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。
|
||||
如图 11-4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -29,11 +29,11 @@ comments: true
|
||||
=== "<7>"
|
||||

|
||||
|
||||
<p align="center"> 图:利用元素交换操作模拟冒泡 </p>
|
||||
<p align="center"> 图 11-4 利用元素交换操作模拟冒泡 </p>
|
||||
|
||||
## 11.3.1 算法流程
|
||||
|
||||
设数组的长度为 $n$ ,冒泡排序的步骤如下图所示。
|
||||
设数组的长度为 $n$ ,冒泡排序的步骤如图 11-5 所示。
|
||||
|
||||
1. 首先,对 $n$ 个元素执行“冒泡”,**将数组的最大元素交换至正确位置**,
|
||||
2. 接下来,对剩余 $n - 1$ 个元素执行“冒泡”,**将第二大元素交换至正确位置**。
|
||||
@ -42,7 +42,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:冒泡排序流程 </p>
|
||||
<p align="center"> 图 11-5 冒泡排序流程 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ comments: true
|
||||
|
||||
## 11.8.1 算法流程
|
||||
|
||||
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下图所示。
|
||||
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如图 11-13 所示。
|
||||
|
||||
1. 初始化 $k$ 个桶,将 $n$ 个元素分配到 $k$ 个桶中。
|
||||
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)。
|
||||
@ -18,7 +18,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:桶排序算法流程 </p>
|
||||
<p align="center"> 图 11-13 桶排序算法流程 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -409,16 +409,16 @@ comments: true
|
||||
|
||||
为实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。
|
||||
|
||||
如下图所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
|
||||
如图 11-14 所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:递归划分桶 </p>
|
||||
<p align="center"> 图 11-14 递归划分桶 </p>
|
||||
|
||||
如果我们提前知道商品价格的概率分布,**则可以根据数据概率分布设置每个桶的价格分界线**。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。
|
||||
|
||||
如下图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。
|
||||
如图 11-15 所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:根据概率分布划分桶 </p>
|
||||
<p align="center"> 图 11-15 根据概率分布划分桶 </p>
|
||||
|
||||
@ -8,7 +8,7 @@ comments: true
|
||||
|
||||
## 11.9.1 简单实现
|
||||
|
||||
先来看一个简单的例子。给定一个长度为 $n$ 的数组 `nums` ,其中的元素都是“非负整数”,计数排序的整体流程如下图所示。
|
||||
先来看一个简单的例子。给定一个长度为 $n$ 的数组 `nums` ,其中的元素都是“非负整数”,计数排序的整体流程如图 11-16 所示。
|
||||
|
||||
1. 遍历数组,找出数组中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter` 。
|
||||
2. **借助 `counter` 统计 `nums` 中各数字的出现次数**,其中 `counter[num]` 对应数字 `num` 的出现次数。统计方法很简单,只需遍历 `nums`(设当前数字为 `num`),每轮将 `counter[num]` 增加 $1$ 即可。
|
||||
@ -16,7 +16,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:计数排序流程 </p>
|
||||
<p align="center"> 图 11-16 计数排序流程 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -336,7 +336,7 @@ $$
|
||||
1. 将 `num` 填入数组 `res` 的索引 `prefix[num] - 1` 处。
|
||||
2. 令前缀和 `prefix[num]` 减小 $1$ ,从而得到下次放置 `num` 的索引。
|
||||
|
||||
遍历完成后,数组 `res` 中就是排序好的结果,最后使用 `res` 覆盖原数组 `nums` 即可。下图展示了完整的计数排序流程。
|
||||
遍历完成后,数组 `res` 中就是排序好的结果,最后使用 `res` 覆盖原数组 `nums` 即可。图 11-17 展示了完整的计数排序流程。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -362,7 +362,7 @@ $$
|
||||
=== "<8>"
|
||||

|
||||
|
||||
<p align="center"> 图:计数排序步骤 </p>
|
||||
<p align="center"> 图 11-17 计数排序步骤 </p>
|
||||
|
||||
计数排序的实现代码如下所示。
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ comments: true
|
||||
|
||||
## 11.7.1 算法流程
|
||||
|
||||
设数组的长度为 $n$ ,堆排序的流程如下图所示。
|
||||
设数组的长度为 $n$ ,堆排序的流程如图 11-12 所示。
|
||||
|
||||
1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
|
||||
2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 $1$ ,已排序元素数量加 $1$ 。
|
||||
@ -64,7 +64,7 @@ comments: true
|
||||
=== "<12>"
|
||||

|
||||
|
||||
<p align="center"> 图:堆排序步骤 </p>
|
||||
<p align="center"> 图 11-12 堆排序步骤 </p>
|
||||
|
||||
在代码实现中,我们使用了与堆章节相同的从顶至底堆化 `sift_down()` 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 `sift_down()` 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。
|
||||
|
||||
|
||||
@ -8,15 +8,15 @@ comments: true
|
||||
|
||||
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
|
||||
|
||||
下图展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后再将 `base` 赋值给目标索引。
|
||||
图 11-6 展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后再将 `base` 赋值给目标索引。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:单次插入操作 </p>
|
||||
<p align="center"> 图 11-6 单次插入操作 </p>
|
||||
|
||||
## 11.4.1 算法流程
|
||||
|
||||
插入排序的整体流程如下图所示。
|
||||
插入排序的整体流程如图 11-7 所示。
|
||||
|
||||
1. 初始状态下,数组的第 1 个元素已完成排序。
|
||||
2. 选取数组的第 2 个元素作为 `base` ,将其插入到正确位置后,**数组的前 2 个元素已排序**。
|
||||
@ -25,7 +25,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:插入排序流程 </p>
|
||||
<p align="center"> 图 11-7 插入排序流程 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
||||
@ -4,18 +4,18 @@ comments: true
|
||||
|
||||
# 11.6 归并排序
|
||||
|
||||
「归并排序 merge sort」是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段:
|
||||
「归并排序 merge sort」是一种基于分治策略的排序算法,包含图 11-10 所示的“划分”和“合并”阶段:
|
||||
|
||||
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
|
||||
2. **合并阶段**:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:归并排序的划分与合并阶段 </p>
|
||||
<p align="center"> 图 11-10 归并排序的划分与合并阶段 </p>
|
||||
|
||||
## 11.6.1 算法流程
|
||||
|
||||
如下图所示,“划分阶段”从顶至底递归地将数组从中点切为两个子数组:
|
||||
如图 11-11 所示,“划分阶段”从顶至底递归地将数组从中点切为两个子数组:
|
||||
|
||||
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]` )。
|
||||
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分。
|
||||
@ -52,7 +52,7 @@ comments: true
|
||||
=== "<10>"
|
||||

|
||||
|
||||
<p align="center"> 图:归并排序步骤 </p>
|
||||
<p align="center"> 图 11-11 归并排序步骤 </p>
|
||||
|
||||
观察发现,归并排序的递归顺序与二叉树的后序遍历相同,对比来看:
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ comments: true
|
||||
|
||||
「快速排序 quick sort」是一种基于分治策略的排序算法,运行高效,应用广泛。
|
||||
|
||||
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如下图所示。
|
||||
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如图 11-8 所示。
|
||||
|
||||
1. 选取数组最左端元素作为基准数,初始化两个指针 `i` 和 `j` 分别指向数组的两端。
|
||||
2. 设置一个循环,在每轮中使用 `i`(`j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
|
||||
@ -39,7 +39,7 @@ comments: true
|
||||
=== "<9>"
|
||||

|
||||
|
||||
<p align="center"> 图:哨兵划分步骤 </p>
|
||||
<p align="center"> 图 11-8 哨兵划分步骤 </p>
|
||||
|
||||
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 $\leq$ 基准数 $\leq$ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
|
||||
|
||||
@ -362,7 +362,7 @@ comments: true
|
||||
|
||||
## 11.5.1 算法流程
|
||||
|
||||
快速排序的整体流程如下图所示。
|
||||
快速排序的整体流程如图 11-9 所示。
|
||||
|
||||
1. 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
|
||||
2. 然后,对左子数组和右子数组分别递归执行“哨兵划分”。
|
||||
@ -370,7 +370,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:快速排序流程 </p>
|
||||
<p align="center"> 图 11-9 快速排序流程 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ comments: true
|
||||
|
||||
## 11.10.1 算法流程
|
||||
|
||||
以学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的流程如下图所示。
|
||||
以学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的流程如图 11-18 所示。
|
||||
|
||||
1. 初始化位数 $k = 1$ 。
|
||||
2. 对学号的第 $k$ 位执行“计数排序”。完成后,数据会根据第 $k$ 位从小到大排序。
|
||||
@ -18,7 +18,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:基数排序算法流程 </p>
|
||||
<p align="center"> 图 11-18 基数排序算法流程 </p>
|
||||
|
||||
下面来剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ comments: true
|
||||
|
||||
「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
|
||||
|
||||
设数组的长度为 $n$ ,选择排序的算法流程如下图所示。
|
||||
设数组的长度为 $n$ ,选择排序的算法流程如图 11-2 所示。
|
||||
|
||||
1. 初始状态下,所有元素未排序,即未排序(索引)区间为 $[0, n-1]$ 。
|
||||
2. 选取区间 $[0, n-1]$ 中的最小元素,将其与索引 $0$ 处元素交换。完成后,数组前 1 个元素已排序。
|
||||
@ -47,7 +47,7 @@ comments: true
|
||||
=== "<11>"
|
||||

|
||||
|
||||
<p align="center"> 图:选择排序步骤 </p>
|
||||
<p align="center"> 图 11-2 选择排序步骤 </p>
|
||||
|
||||
在代码中,我们用 $k$ 来记录未排序区间内的最小元素。
|
||||
|
||||
@ -288,8 +288,8 @@ comments: true
|
||||
|
||||
- **时间复杂度为 $O(n^2)$ 、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$ , $n - 1$ , $\dots$ , $2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。
|
||||
- **空间复杂度 $O(1)$ 、原地排序**:指针 $i$ , $j$ 使用常数大小的额外空间。
|
||||
- **非稳定排序**:如下图所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
|
||||
- **非稳定排序**:如图 11-3 所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:选择排序非稳定示例 </p>
|
||||
<p align="center"> 图 11-3 选择排序非稳定示例 </p>
|
||||
|
||||
@ -6,11 +6,11 @@ comments: true
|
||||
|
||||
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。
|
||||
|
||||
如下图所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
|
||||
如图 11-1 所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:数据类型和判断规则示例 </p>
|
||||
<p align="center"> 图 11-1 数据类型和判断规则示例 </p>
|
||||
|
||||
## 11.1.1 评价维度
|
||||
|
||||
|
||||
@ -12,11 +12,11 @@ comments: true
|
||||
- 计数排序是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。
|
||||
- 基数排序通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。
|
||||
- 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。
|
||||
- 下图对比了主流排序算法的效率、稳定性、就地性和自适应性等。
|
||||
- 图 11-19 对比了主流排序算法的效率、稳定性、就地性和自适应性等。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:排序算法对比 </p>
|
||||
<p align="center"> 图 11-19 排序算法对比 </p>
|
||||
|
||||
## 11.11.1 Q & A
|
||||
|
||||
|
||||
@ -4,17 +4,17 @@ comments: true
|
||||
|
||||
# 5.3 双向队列
|
||||
|
||||
在队列中,我们仅能在头部删除或在尾部添加元素。如下图所示,「双向队列 deque」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
|
||||
在队列中,我们仅能在头部删除或在尾部添加元素。如图 5-7 所示,「双向队列 deque」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:双向队列的操作 </p>
|
||||
<p align="center"> 图 5-7 双向队列的操作 </p>
|
||||
|
||||
## 5.3.1 双向队列常用操作
|
||||
|
||||
双向队列的常用操作如下表所示,具体的方法名称需要根据所使用的编程语言来确定。
|
||||
双向队列的常用操作如表 5-3 所示,具体的方法名称需要根据所使用的编程语言来确定。
|
||||
|
||||
<p align="center"> 表:双向队列操作效率 </p>
|
||||
<p align="center"> 表 5-3 双向队列操作效率 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -336,7 +336,7 @@ comments: true
|
||||
|
||||
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。
|
||||
|
||||
如下图所示,我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
|
||||
如图 5-8 所示,我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
|
||||
|
||||
=== "LinkedListDeque"
|
||||

|
||||
@ -353,7 +353,7 @@ comments: true
|
||||
=== "popFirst()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于链表实现双向队列的入队出队操作 </p>
|
||||
<p align="center"> 图 5-8 基于链表实现双向队列的入队出队操作 </p>
|
||||
|
||||
实现代码如下所示。
|
||||
|
||||
@ -1975,7 +1975,7 @@ comments: true
|
||||
|
||||
### 2. 基于数组的实现
|
||||
|
||||
如下图所示,与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
|
||||
如图 5-9 所示,与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
|
||||
|
||||
=== "ArrayDeque"
|
||||

|
||||
@ -1992,7 +1992,7 @@ comments: true
|
||||
=== "popFirst()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于数组实现双向队列的入队出队操作 </p>
|
||||
<p align="center"> 图 5-9 基于数组实现双向队列的入队出队操作 </p>
|
||||
|
||||
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
|
||||
|
||||
|
||||
@ -6,17 +6,17 @@ comments: true
|
||||
|
||||
「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。
|
||||
|
||||
如下图所示,我们将队列的头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
|
||||
如图 5-4 所示,我们将队列的头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:队列的先入先出规则 </p>
|
||||
<p align="center"> 图 5-4 队列的先入先出规则 </p>
|
||||
|
||||
## 5.2.1 队列常用操作
|
||||
|
||||
队列的常见操作如下表所示。需要注意的是,不同编程语言的方法名称可能会有所不同。我们在此采用与栈相同的方法命名。
|
||||
队列的常见操作如表 5-2 所示。需要注意的是,不同编程语言的方法名称可能会有所不同。我们在此采用与栈相同的方法命名。
|
||||
|
||||
<p align="center"> 表:队列操作效率 </p>
|
||||
<p align="center"> 表 5-2 队列操作效率 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -299,7 +299,7 @@ comments: true
|
||||
|
||||
### 1. 基于链表的实现
|
||||
|
||||
如下图所示,我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
|
||||
如图 5-5 所示,我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
|
||||
|
||||
=== "LinkedListQueue"
|
||||

|
||||
@ -310,7 +310,7 @@ comments: true
|
||||
=== "pop()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于链表实现队列的入队出队操作 </p>
|
||||
<p align="center"> 图 5-5 基于链表实现队列的入队出队操作 </p>
|
||||
|
||||
以下是用链表实现队列的代码。
|
||||
|
||||
@ -1188,7 +1188,7 @@ comments: true
|
||||
|
||||
我们可以使用一个变量 `front` 指向队首元素的索引,并维护一个变量 `queSize` 用于记录队列长度。定义 `rear = front + queSize` ,这个公式计算出的 `rear` 指向队尾元素之后的下一个位置。
|
||||
|
||||
基于此设计,**数组中包含元素的有效区间为 `[front, rear - 1]`**,各种操作的实现方法如下图所示。
|
||||
基于此设计,**数组中包含元素的有效区间为 `[front, rear - 1]`**,各种操作的实现方法如图 5-6 所示。
|
||||
|
||||
- 入队操作:将输入元素赋值给 `rear` 索引处,并将 `queSize` 增加 1 。
|
||||
- 出队操作:只需将 `front` 增加 1 ,并将 `queSize` 减少 1 。
|
||||
@ -1204,7 +1204,7 @@ comments: true
|
||||
=== "pop()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于数组实现队列的入队出队操作 </p>
|
||||
<p align="center"> 图 5-6 基于数组实现队列的入队出队操作 </p>
|
||||
|
||||
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。
|
||||
|
||||
|
||||
@ -8,17 +8,17 @@ comments: true
|
||||
|
||||
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
|
||||
|
||||
如下图所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,删除栈顶元素的操作叫做“出栈”。
|
||||
如图 5-1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,删除栈顶元素的操作叫做“出栈”。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:栈的先入后出规则 </p>
|
||||
<p align="center"> 图 5-1 栈的先入后出规则 </p>
|
||||
|
||||
## 5.1.1 栈常用操作
|
||||
|
||||
栈的常用操作如下表所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 `push()` , `pop()` , `peek()` 命名为例。
|
||||
栈的常用操作如表 5-1 所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 `push()` , `pop()` , `peek()` 命名为例。
|
||||
|
||||
<p align="center"> 表:栈的操作效率 </p>
|
||||
<p align="center"> 表 5-1 栈的操作效率 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -301,7 +301,7 @@ comments: true
|
||||
|
||||
使用链表来实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
|
||||
|
||||
如下图所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
|
||||
如图 5-2 所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
|
||||
|
||||
=== "LinkedListStack"
|
||||

|
||||
@ -312,7 +312,7 @@ comments: true
|
||||
=== "pop()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于链表实现栈的入栈出栈操作 </p>
|
||||
<p align="center"> 图 5-2 基于链表实现栈的入栈出栈操作 </p>
|
||||
|
||||
以下是基于链表实现栈的示例代码。
|
||||
|
||||
@ -1070,7 +1070,7 @@ comments: true
|
||||
|
||||
### 2. 基于数组的实现
|
||||
|
||||
使用数组实现栈时,我们可以将数组的尾部作为栈顶。如下图所示,入栈与出栈操作分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
|
||||
使用数组实现栈时,我们可以将数组的尾部作为栈顶。如图 5-3 所示,入栈与出栈操作分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
|
||||
|
||||
=== "ArrayStack"
|
||||

|
||||
@ -1081,7 +1081,7 @@ comments: true
|
||||
=== "pop()"
|
||||

|
||||
|
||||
<p align="center"> 图:基于数组实现栈的入栈出栈操作 </p>
|
||||
<p align="center"> 图 5-3 基于数组实现栈的入栈出栈操作 </p>
|
||||
|
||||
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码。
|
||||
|
||||
|
||||
@ -12,11 +12,11 @@ comments: true
|
||||
|
||||
先分析一个简单案例。给定一个完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。
|
||||
|
||||
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。下图展示了各个节点索引之间的映射关系。
|
||||
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。图 7-12 展示了各个节点索引之间的映射关系。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完美二叉树的数组表示 </p>
|
||||
<p align="center"> 图 7-12 完美二叉树的数组表示 </p>
|
||||
|
||||
**映射公式的角色相当于链表中的指针**。给定数组中的任意一个节点,我们都可以通过映射公式来访问它的左(右)子节点。
|
||||
|
||||
@ -24,13 +24,13 @@ comments: true
|
||||
|
||||
完美二叉树是一个特例,在二叉树的中间层通常存在许多 $\text{None}$ 。由于层序遍历序列并不包含这些 $\text{None}$ ,因此我们无法仅凭该序列来推测 $\text{None}$ 的数量和分布位置。**这意味着存在多种二叉树结构都符合该层序遍历序列**。
|
||||
|
||||
如下图所示,给定一个非完美二叉树,上述的数组表示方法已经失效。
|
||||
如图 7-13 所示,给定一个非完美二叉树,上述的数组表示方法已经失效。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:层序遍历序列对应多种二叉树可能性 </p>
|
||||
<p align="center"> 图 7-13 层序遍历序列对应多种二叉树可能性 </p>
|
||||
|
||||
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
|
||||
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如图 7-14 所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -126,15 +126,15 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:任意类型二叉树的数组表示 </p>
|
||||
<p align="center"> 图 7-14 任意类型二叉树的数组表示 </p>
|
||||
|
||||
值得说明的是,**完全二叉树非常适合使用数组来表示**。回顾完全二叉树的定义,$\text{None}$ 只出现在最底层且靠右的位置,**因此所有 $\text{None}$ 一定出现在层序遍历序列的末尾**。
|
||||
|
||||
这意味着使用数组表示完全二叉树时,可以省略存储所有 $\text{None}$ ,非常方便。下图给出了一个例子。
|
||||
这意味着使用数组表示完全二叉树时,可以省略存储所有 $\text{None}$ ,非常方便。图 7-15 给出了一个例子。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完全二叉树的数组表示 </p>
|
||||
<p align="center"> 图 7-15 完全二叉树的数组表示 </p>
|
||||
|
||||
如下代码给出了数组表示下的二叉树的简单实现,包括以下操作:
|
||||
|
||||
|
||||
@ -6,17 +6,17 @@ comments: true
|
||||
|
||||
在二叉搜索树章节中,我们提到了在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$。
|
||||
|
||||
如下图所示,经过两次删除节点操作,这个二叉搜索树便会退化为链表。
|
||||
如图 7-24 所示,经过两次删除节点操作,这个二叉搜索树便会退化为链表。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:AVL 树在删除节点后发生退化 </p>
|
||||
<p align="center"> 图 7-24 AVL 树在删除节点后发生退化 </p>
|
||||
|
||||
再例如,在下图的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
|
||||
再例如,在图 7-25 的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:AVL 树在插入节点后发生退化 </p>
|
||||
<p align="center"> 图 7-25 AVL 树在插入节点后发生退化 </p>
|
||||
|
||||
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
|
||||
|
||||
@ -588,7 +588,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
|
||||
### 1. 右旋
|
||||
|
||||
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。
|
||||
如图 7-26 所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@ -602,13 +602,13 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
=== "<4>"
|
||||

|
||||
|
||||
<p align="center"> 图:右旋操作步骤 </p>
|
||||
<p align="center"> 图 7-26 右旋操作步骤 </p>
|
||||
|
||||
如下图所示,当节点 `child` 有右子节点(记为 `grandChild` )时,需要在右旋中添加一步:将 `grandChild` 作为 `node` 的左子节点。
|
||||
如图 7-27 所示,当节点 `child` 有右子节点(记为 `grandChild` )时,需要在右旋中添加一步:将 `grandChild` 作为 `node` 的左子节点。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:有 grandChild 的右旋操作 </p>
|
||||
<p align="center"> 图 7-27 有 grandChild 的右旋操作 </p>
|
||||
|
||||
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示。
|
||||
|
||||
@ -835,17 +835,17 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
|
||||
### 2. 左旋
|
||||
|
||||
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行下图所示的“左旋”操作。
|
||||
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行图 7-28 所示的“左旋”操作。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:左旋操作 </p>
|
||||
<p align="center"> 图 7-28 左旋操作 </p>
|
||||
|
||||
同理,如下图所示,当节点 `child` 有左子节点(记为 `grandChild` )时,需要在左旋中添加一步:将 `grandChild` 作为 `node` 的右子节点。
|
||||
同理,如图 7-29 所示,当节点 `child` 有左子节点(记为 `grandChild` )时,需要在左旋中添加一步:将 `grandChild` 作为 `node` 的右子节点。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:有 grandChild 的左旋操作 </p>
|
||||
<p align="center"> 图 7-29 有 grandChild 的左旋操作 </p>
|
||||
|
||||
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码。
|
||||
|
||||
@ -1072,31 +1072,31 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
|
||||
### 3. 先左旋后右旋
|
||||
|
||||
对于下图中的失衡节点 3 ,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先对 `child` 执行“左旋”,再对 `node` 执行“右旋”。
|
||||
对于图 7-30 中的失衡节点 3 ,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先对 `child` 执行“左旋”,再对 `node` 执行“右旋”。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:先左旋后右旋 </p>
|
||||
<p align="center"> 图 7-30 先左旋后右旋 </p>
|
||||
|
||||
### 4. 先右旋后左旋
|
||||
|
||||
如下图所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,然后对 `node` 执行“左旋”。
|
||||
如图 7-31 所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,然后对 `node` 执行“左旋”。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:先右旋后左旋 </p>
|
||||
<p align="center"> 图 7-31 先右旋后左旋 </p>
|
||||
|
||||
### 5. 旋转的选择
|
||||
|
||||
下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。
|
||||
图 7-32 展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:AVL 树的四种旋转情况 </p>
|
||||
<p align="center"> 图 7-32 AVL 树的四种旋转情况 </p>
|
||||
|
||||
如下表所示,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图中的哪种情况。
|
||||
如下表所示,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于图 7-32 中的哪种情况。
|
||||
|
||||
<p align="center"> 表:四种旋转情况的选择条件 </p>
|
||||
<p align="center"> 表 7-3 四种旋转情况的选择条件 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
|
||||
@ -4,14 +4,14 @@ comments: true
|
||||
|
||||
# 7.4 二叉搜索树
|
||||
|
||||
如下图所示,「二叉搜索树 binary search tree」满足以下条件:
|
||||
如图 7-16 所示,「二叉搜索树 binary search tree」满足以下条件:
|
||||
|
||||
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值。
|
||||
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.` 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树 </p>
|
||||
<p align="center"> 图 7-16 二叉搜索树 </p>
|
||||
|
||||
## 7.4.1 二叉搜索树的操作
|
||||
|
||||
@ -19,7 +19,7 @@ comments: true
|
||||
|
||||
### 1. 查找节点
|
||||
|
||||
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。如下图所示,我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val` 和 `num` 之间的大小关系:
|
||||
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。如图 7-17 所示,我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val` 和 `num` 之间的大小关系:
|
||||
|
||||
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right` 。
|
||||
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left` 。
|
||||
@ -37,7 +37,7 @@ comments: true
|
||||
=== "<4>"
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树查找节点示例 </p>
|
||||
<p align="center"> 图 7-17 二叉搜索树查找节点示例 </p>
|
||||
|
||||
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
|
||||
|
||||
@ -321,14 +321,14 @@ comments: true
|
||||
|
||||
### 2. 插入节点
|
||||
|
||||
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作流程如下图所示。
|
||||
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作流程如图 7-18 所示。
|
||||
|
||||
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 $\text{None}$ )时跳出循环。
|
||||
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 $\text{None}$ 的位置。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在二叉搜索树中插入节点 </p>
|
||||
<p align="center"> 图 7-18 在二叉搜索树中插入节点 </p>
|
||||
|
||||
在代码实现中,需要注意以下两点:
|
||||
|
||||
@ -733,21 +733,21 @@ comments: true
|
||||
|
||||
与插入节点类似,我们需要在删除操作后维持二叉搜索树的“左子树 < 根节点 < 右子树”的性质。首先,我们需要在二叉树中执行查找操作,获取待删除节点。接下来,根据待删除节点的子节点数量,删除操作需分为三种情况:
|
||||
|
||||
如下图所示,当待删除节点的度为 $0$ 时,表示待删除节点是叶节点,可以直接删除。
|
||||
如图 7-19 所示,当待删除节点的度为 $0$ 时,表示待删除节点是叶节点,可以直接删除。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在二叉搜索树中删除节点(度为 0) </p>
|
||||
<p align="center"> 图 7-19 在二叉搜索树中删除节点(度为 0) </p>
|
||||
|
||||
如下图所示,当待删除节点的度为 $1$ 时,将待删除节点替换为其子节点即可。
|
||||
如图 7-20 所示,当待删除节点的度为 $1$ 时,将待删除节点替换为其子节点即可。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在二叉搜索树中删除节点(度为 1) </p>
|
||||
<p align="center"> 图 7-20 在二叉搜索树中删除节点(度为 1) </p>
|
||||
|
||||
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。
|
||||
|
||||
假设我们选择右子树的最小节点(即中序遍历的下一个节点),则删除操作如下图所示。
|
||||
假设我们选择右子树的最小节点(即中序遍历的下一个节点),则删除操作如图 7-21 所示。
|
||||
|
||||
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp` 。
|
||||
2. 将 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp` 。
|
||||
@ -764,7 +764,7 @@ comments: true
|
||||
=== "<4>"
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树删除节点示例 </p>
|
||||
<p align="center"> 图 7-21 二叉搜索树删除节点示例 </p>
|
||||
|
||||
删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。
|
||||
|
||||
@ -1477,7 +1477,7 @@ comments: true
|
||||
|
||||
### 4. 中序遍历性质
|
||||
|
||||
如下图所示,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。
|
||||
如图 7-22 所示,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。
|
||||
|
||||
这意味着在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
||||
|
||||
@ -1485,15 +1485,15 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树的中序遍历序列 </p>
|
||||
<p align="center"> 图 7-22 二叉搜索树的中序遍历序列 </p>
|
||||
|
||||
## 7.4.2 二叉搜索树的效率
|
||||
|
||||
给定一组数据,我们考虑使用数组或二叉搜索树存储。
|
||||
|
||||
观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
|
||||
观察表 7-2 ,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
|
||||
|
||||
<p align="center"> 表:数组与搜索树的效率对比 </p>
|
||||
<p align="center"> 表 7-2 数组与搜索树的效率对比 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
@ -1507,11 +1507,11 @@ comments: true
|
||||
|
||||
在理想情况下,二叉搜索树是“平衡”的,这样就可以在 $\log n$ 轮循环内查找任意节点。
|
||||
|
||||
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
|
||||
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为图 7-23 所示的链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树的平衡与退化 </p>
|
||||
<p align="center"> 图 7-23 二叉搜索树的平衡与退化 </p>
|
||||
|
||||
## 7.4.3 二叉搜索树常见应用
|
||||
|
||||
|
||||
@ -167,15 +167,15 @@ comments: true
|
||||
|
||||
每个节点都有两个引用(指针),分别指向「左子节点 left-child node」和「右子节点 right-child node」,该节点被称为这两个子节点的「父节点 parent node」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树 left subtree」,同理可得「右子树 right subtree」。
|
||||
|
||||
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。如下图所示,如果将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
|
||||
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。如图 7-1 所示,如果将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:父节点、子节点、子树 </p>
|
||||
<p align="center"> 图 7-1 父节点、子节点、子树 </p>
|
||||
|
||||
## 7.1.1 二叉树常见术语
|
||||
|
||||
二叉树的常用术语如下图所示。
|
||||
二叉树的常用术语如图 7-2 所示。
|
||||
|
||||
- 「根节点 root node」:位于二叉树顶层的节点,没有父节点。
|
||||
- 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 $\text{None}$ 。
|
||||
@ -188,7 +188,7 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉树的常用术语 </p>
|
||||
<p align="center"> 图 7-2 二叉树的常用术语 </p>
|
||||
|
||||
!!! tip "高度与深度的定义"
|
||||
|
||||
@ -382,11 +382,11 @@ comments: true
|
||||
|
||||
### 2. 插入与删除节点
|
||||
|
||||
与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。下图给出了一个示例。
|
||||
与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。图 7-3 给出了一个示例。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:在二叉树中插入与删除节点 </p>
|
||||
<p align="center"> 图 7-3 在二叉树中插入与删除节点 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -534,31 +534,31 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完美二叉树 </p>
|
||||
<p align="center"> 图 7-4 完美二叉树 </p>
|
||||
|
||||
### 2. 完全二叉树
|
||||
|
||||
如下图所示,「完全二叉树 complete binary tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。
|
||||
如图 7-5 所示,「完全二叉树 complete binary tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完全二叉树 </p>
|
||||
<p align="center"> 图 7-5 完全二叉树 </p>
|
||||
|
||||
### 3. 完满二叉树
|
||||
|
||||
如下图所示,「完满二叉树 full binary tree」除了叶节点之外,其余所有节点都有两个子节点。
|
||||
如图 7-6 所示,「完满二叉树 full binary tree」除了叶节点之外,其余所有节点都有两个子节点。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:完满二叉树 </p>
|
||||
<p align="center"> 图 7-6 完满二叉树 </p>
|
||||
|
||||
### 4. 平衡二叉树
|
||||
|
||||
如下图所示,「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
|
||||
如图 7-7 所示,「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:平衡二叉树 </p>
|
||||
<p align="center"> 图 7-7 平衡二叉树 </p>
|
||||
|
||||
## 7.1.4 二叉树的退化
|
||||
|
||||
@ -569,11 +569,11 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉树的最佳与最差结构 </p>
|
||||
<p align="center"> 图 7-8 二叉树的最佳与最差结构 </p>
|
||||
|
||||
如下表所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
|
||||
如表 7-1 所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
|
||||
|
||||
<p align="center"> 表:二叉树的最佳与最差情况 </p>
|
||||
<p align="center"> 表 7-1 二叉树的最佳与最差情况 </p>
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
|
||||
@ -10,13 +10,13 @@ comments: true
|
||||
|
||||
## 7.2.1 层序遍历
|
||||
|
||||
如下图所示,「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
|
||||
如图 7-9 所示,「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
|
||||
|
||||
层序遍历本质上属于「广度优先遍历 breadth-first traversal」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉树的层序遍历 </p>
|
||||
<p align="center"> 图 7-9 二叉树的层序遍历 </p>
|
||||
|
||||
广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。
|
||||
|
||||
@ -330,11 +330,11 @@ comments: true
|
||||
|
||||
相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
|
||||
|
||||
下图展示了对二叉树进行深度优先遍历的工作原理。**深度优先遍历就像是绕着整个二叉树的外围“走”一圈**,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
|
||||
图 7-10 展示了对二叉树进行深度优先遍历的工作原理。**深度优先遍历就像是绕着整个二叉树的外围“走”一圈**,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图:二叉搜索树的前、中、后序遍历 </p>
|
||||
<p align="center"> 图 7-10 二叉搜索树的前、中、后序遍历 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -755,7 +755,7 @@ comments: true
|
||||
|
||||
我们也可以不使用递归,仅基于迭代实现前、中、后序遍历,有兴趣的同学可以自行实现。
|
||||
|
||||
下图展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分:
|
||||
图 7-11 展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分:
|
||||
|
||||
1. “递”表示开启新方法,程序在此过程中访问下一个节点。
|
||||
2. “归”表示函数返回,代表当前节点已经访问完毕。
|
||||
@ -793,4 +793,4 @@ comments: true
|
||||
=== "<11>"
|
||||

|
||||
|
||||
<p align="center"> 图:前序遍历的递归过程 </p>
|
||||
<p align="center"> 图 7-11 前序遍历的递归过程 </p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user