diff --git a/chapter_appendix/contribution/index.html b/chapter_appendix/contribution/index.html index fd46916b4..082894761 100644 --- a/chapter_appendix/contribution/index.html +++ b/chapter_appendix/contribution/index.html @@ -3434,14 +3434,14 @@

然而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。

16.2.1   内容微调

-

如下图所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码:

+

如图 16-1 所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码:

  1. 点击“编辑图标”,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。
  2. 修改 Markdown 源文件内容,检查内容的正确性,并尽量保持排版格式的统一。
  3. 在页面底部填写修改说明,然后点击“Propose file change”按钮。页面跳转后,点击“Create pull request”按钮即可发起拉取请求。

页面编辑按键

-

图:页面编辑按键

+

图 16-1   页面编辑按键

图片无法直接修改,需要通过新建 Issue 或评论留言来描述问题,我们会尽快重新绘制并替换图片。

16.2.2   内容创作

diff --git a/chapter_array_and_linkedlist/array/index.html b/chapter_array_and_linkedlist/array/index.html index 4c262281d..8dc37d076 100644 --- a/chapter_array_and_linkedlist/array/index.html +++ b/chapter_array_and_linkedlist/array/index.html @@ -3536,9 +3536,9 @@

4.1   数组

-

「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。

+

「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。图 4-1 展示了数组的主要术语和概念。

数组定义与存储方式

-

图:数组定义与存储方式

+

图 4-1   数组定义与存储方式

4.1.1   数组常用操作

1.   初始化数组

@@ -3631,9 +3631,9 @@ elementAddr = firtstElementAddr + elementLength * elementIndex

数组元素的内存地址计算

-

图:数组元素的内存地址计算

+

图 4-2   数组元素的内存地址计算

-

观察上图,我们发现数组首个元素的索引为 \(0\) ,这似乎有些反直觉,因为从 \(1\) 开始计数会更自然。但从地址计算公式的角度看,索引的含义本质上是内存地址的偏移量。首个元素的地址偏移量是 \(0\) ,因此它的索引为 \(0\) 也是合理的。

+

观察图 4-2 ,我们发现数组首个元素的索引为 \(0\) ,这似乎有些反直觉,因为从 \(1\) 开始计数会更自然。但从地址计算公式的角度看,索引的含义本质上是内存地址的偏移量。首个元素的地址偏移量是 \(0\) ,因此它的索引为 \(0\) 也是合理的。

在数组中访问元素是非常高效的,我们可以在 \(O(1)\) 时间内随机访问数组中的任意一个元素。

@@ -3772,9 +3772,9 @@

3.   插入元素

-

数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。

+

数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。

数组插入元素示例

-

图:数组插入元素示例

+

图 4-3   数组插入元素示例

值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。

@@ -3925,9 +3925,9 @@

4.   删除元素

-

同理,如下图所示,若想要删除索引 \(i\) 处的元素,则需要把索引 \(i\) 之后的元素都向前移动一位。

+

同理,如图 4-4 所示,若想要删除索引 \(i\) 处的元素,则需要把索引 \(i\) 之后的元素都向前移动一位。

数组删除元素示例

-

图:数组删除元素示例

+

图 4-4   数组删除元素示例

请注意,删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无须特意去修改它。

diff --git a/chapter_array_and_linkedlist/linked_list/index.html b/chapter_array_and_linkedlist/linked_list/index.html index 1a2050f70..957343380 100644 --- a/chapter_array_and_linkedlist/linked_list/index.html +++ b/chapter_array_and_linkedlist/linked_list/index.html @@ -3526,9 +3526,9 @@

「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。

链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。

链表定义与存储方式

-

图:链表定义与存储方式

+

图 4-5   链表定义与存储方式

-

观察上图,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。

+

观察图 4-5 ,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。

数组整体是一个变量,比如数组 nums 包含元素 nums[0] , nums[1] 等,而链表是由多个独立的节点对象组成的。我们通常将头节点当作链表的代称,比如以上代码中的链表可被记做链表 n0

2.   插入节点

-

在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 n0 , n1 之间插入一个新节点 P则只需要改变两个节点引用(指针)即可,时间复杂度为 \(O(1)\)

+

在链表中插入节点非常容易。如图 4-6 所示,假设我们想在相邻的两个节点 n0 , n1 之间插入一个新节点 P则只需要改变两个节点引用(指针)即可,时间复杂度为 \(O(1)\)

相比之下,在数组中插入元素的时间复杂度为 \(O(n)\) ,在大数据量下的效率较低。

链表插入节点示例

-

图:链表插入节点示例

+

图 4-6   链表插入节点示例

@@ -3991,10 +3991,10 @@

3.   删除节点

-

如下图所示,在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可

+

如图 4-7 所示,在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可

请注意,尽管在删除操作完成后节点 P 仍然指向 n1 ,但实际上遍历此链表已经无法访问到 P ,这意味着 P 已经不再属于该链表了。

链表删除节点

-

图:链表删除节点

+

图 4-7   链表删除节点

@@ -4478,8 +4478,8 @@

4.2.2   数组 VS 链表

-

下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。

-

表:数组与链表的效率对比

+

表 4-1 总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。

+

表 4-1   数组与链表的效率对比

@@ -4530,7 +4530,7 @@

4.2.3   常见链表类型

-

如下图所示,常见的链表类型包括三种。

+

如图 4-8 所示,常见的链表类型包括三种。

-

下表列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。

-

表:基本数据类型的占用空间和取值范围

+

表 3-1 列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。

+

表 3-1   基本数据类型的占用空间和取值范围

@@ -3443,9 +3443,9 @@
-

对于上表,需要注意以下几点:

+

对于表 3-1 ,需要注意以下几点:

diff --git a/chapter_data_structure/character_encoding/index.html b/chapter_data_structure/character_encoding/index.html index d4be2efff..ab3a559cd 100644 --- a/chapter_data_structure/character_encoding/index.html +++ b/chapter_data_structure/character_encoding/index.html @@ -3456,9 +3456,9 @@

3.4   字符编码 *

在计算机中,所有数据都是以二进制数的形式存储的,字符 char 也不例外。为了表示字符,我们需要建立一套“字符集”,规定每个字符和二进制数之间的一一对应关系。有了字符集之后,计算机就可以通过查表完成二进制数到字符的转换。

3.4.1   ASCII 字符集

-

「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如下图所示,ASCII 码包括英文字母的大小写、数字 0-9 、一些标点符号,以及一些控制字符(如换行符和制表符)。

+

「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0-9 、一些标点符号,以及一些控制字符(如换行符和制表符)。

ASCII 码

-

图:ASCII 码

+

图 3-6   ASCII 码

然而,ASCII 码仅能够表示英文。随着计算机的全球化,诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。

在世界范围内,陆续出现了一批适用于不同地区的 EASCII 字符集。这些字符集的前 128 个字符统一为 ASCII 码,后 128 个字符定义不同,以适应不同语言的需求。

@@ -3471,9 +3471,9 @@

「Unicode」的全称为“统一字符编码”,理论上能容纳一百多万个字符。它致力于将全球范围内的字符纳入到统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。

自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截止 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号、甚至是表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占 3 字节甚至 4 字节。

Unicode 是一种字符集标准,本质上是给每个字符分配一个编号(称为“码点”),但它并没有规定在计算机中如何存储这些字符码点。我们不禁会问:当多种长度的 Unicode 码点同时出现在同一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符?

-

对于以上问题,一种直接的解决方案是将所有字符存储为等长的编码。如下图所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。

+

对于以上问题,一种直接的解决方案是将所有字符存储为等长的编码。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。

Unicode 编码示例

-

图:Unicode 编码示例

+

图 3-7   Unicode 编码示例

然而 ASCII 码已经向我们证明,编码英文只需要 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。

3.4.4   UTF-8 编码

@@ -3483,11 +3483,11 @@
  • 对于长度为 1 字节的字符,将最高位设置为 \(0\) 、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,UTF-8 编码可以向下兼容 ASCII 码。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
  • 对于长度为 \(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\) 能够帮助系统快速的判断出异常。

    之所以将 \(10\) 当作校验符,是因为在 UTF-8 编码规则下,不可能有字符的最高两位是 \(10\) 。这个结论可以用反证法来证明:假设一个字符的最高两位是 \(10\) ,说明该字符的长度为 \(1\) ,对应 ASCII 码。而 ASCII 码的最高位应该是 \(0\) ,与假设矛盾。

    UTF-8 编码示例

    -

    图:UTF-8 编码示例

    +

    图 3-8   UTF-8 编码示例

    除了 UTF-8 之外,常见的编码方式还包括:

    -

    如下图所示,在该定义下,\(dp[i, j]\) 表示状态 \([i, j]\) 对应的方案数。此时状态转移方程为:

    +

    如图 14-9 所示,在该定义下,\(dp[i, j]\) 表示状态 \([i, j]\) 对应的方案数。此时状态转移方程为:

    \[ \begin{cases} dp[i, 1] = dp[i-1, 2] \\ @@ -3821,7 +3821,7 @@ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2] \end{cases} \]

    考虑约束下的递推关系

    -

    图:考虑约束下的递推关系

    +

    图 14-9   考虑约束下的递推关系

    最终,返回 \(dp[n, 1] + dp[n, 2]\) 即可,两者之和代表爬到第 \(n\) 阶的方案总数。

    diff --git a/chapter_dynamic_programming/dp_solution_pipeline/index.html b/chapter_dynamic_programming/dp_solution_pipeline/index.html index 9ab2154b1..ad5e7d90b 100644 --- a/chapter_dynamic_programming/dp_solution_pipeline/index.html +++ b/chapter_dynamic_programming/dp_solution_pipeline/index.html @@ -3515,16 +3515,16 @@

    Question

    给定一个 \(n \times m\) 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。

    -

    下图展示了一个例子,给定网格的最小路径和为 \(13\)

    +

    图 14-10 展示了一个例子,给定网格的最小路径和为 \(13\)

    最小路径和示例数据

    -

    图:最小路径和示例数据

    +

    图 14-10   最小路径和示例数据

    第一步:思考每轮的决策,定义状态,从而得到 \(dp\)

    本题的每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为 \([i, j]\) ,则向下或向右走一步后,索引变为 \([i+1, j]\)\([i, j+1]\) 。因此,状态应包含行索引和列索引两个变量,记为 \([i, j]\)

    状态 \([i, j]\) 对应的子问题为:从起始点 \([0, 0]\) 走到 \([i, j]\) 的最小路径和,解记为 \(dp[i, j]\)

    -

    至此,我们就得到了下图所示的二维 \(dp\) 矩阵,其尺寸与输入网格 \(grid\) 相同。

    +

    至此,我们就得到了图 14-11 所示的二维 \(dp\) 矩阵,其尺寸与输入网格 \(grid\) 相同。

    状态定义与 dp 表

    -

    图:状态定义与 dp 表

    +

    图 14-11   状态定义与 dp 表

    Note

    @@ -3533,12 +3533,12 @@

    第二步:找出最优子结构,进而推导出状态转移方程

    对于状态 \([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] \]

    最优子结构与状态转移方程

    -

    图:最优子结构与状态转移方程

    +

    图 14-12   最优子结构与状态转移方程

    Note

    @@ -3547,9 +3547,9 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]

    第三步:确定边界条件和状态转移顺序

    在本题中,处在首行的状态只能向右转移,首列状态只能向下转移,因此首行 \(i = 0\) 和首列 \(j = 0\) 是边界条件。

    -

    如下图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。

    +

    如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。

    边界条件与状态转移顺序

    -

    图:边界条件与状态转移顺序

    +

    图 14-13   边界条件与状态转移顺序

    Note

    @@ -3750,10 +3750,10 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
    -

    下图给出了以 \(dp[2, 1]\) 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。

    +

    图 14-14 给出了以 \(dp[2, 1]\) 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid 的尺寸变大而急剧增多。

    本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格

    暴力搜索递归树

    -

    图:暴力搜索递归树

    +

    图 14-14   暴力搜索递归树

    每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。

    2.   方法二:记忆化搜索

    @@ -3990,9 +3990,9 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j] -

    如下图所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 \(O(nm)\)

    +

    如图 14-15 所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 \(O(nm)\)

    记忆化搜索递归树

    -

    图:记忆化搜索递归树

    +

    图 14-15   记忆化搜索递归树

    3.   方法三:动态规划

    基于迭代实现动态规划解法。

    @@ -4237,7 +4237,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j] -

    下图展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 \(O(nm)\)

    +

    图 14-16 展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 \(O(nm)\)

    数组 dp 大小为 \(n \times m\)因此空间复杂度为 \(O(nm)\)

    @@ -4279,7 +4279,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
    -

    图:最小路径和的动态规划过程

    +

    图 14-16   最小路径和的动态规划过程

    4.   状态压缩

    由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 \(dp\) 表。

    diff --git a/chapter_dynamic_programming/edit_distance_problem/index.html b/chapter_dynamic_programming/edit_distance_problem/index.html index 1b151a073..c93151b42 100644 --- a/chapter_dynamic_programming/edit_distance_problem/index.html +++ b/chapter_dynamic_programming/edit_distance_problem/index.html @@ -3440,15 +3440,15 @@

    输入两个字符串 \(s\)\(t\) ,返回将 \(s\) 转换为 \(t\) 所需的最少编辑步数。

    你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。

    -

    如下图所示,将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。

    +

    如图 14-27 所示,将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。

    编辑距离的示例数据

    -

    图:编辑距离的示例数据

    +

    图 14-27   编辑距离的示例数据

    编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。

    -

    如下图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。

    +

    如图 14-28 所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。

    从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。

    基于决策树模型表示编辑距离问题

    -

    图:基于决策树模型表示编辑距离问题

    +

    图 14-28   基于决策树模型表示编辑距离问题

    1.   动态规划思路

    第一步:思考每轮的决策,定义状态,从而得到 \(dp\)

    @@ -3462,14 +3462,14 @@

    状态 \([i, j]\) 对应的子问题:\(s\) 的前 \(i\) 个字符更改为 \(t\) 的前 \(j\) 个字符所需的最少编辑步数

    至此,得到一个尺寸为 \((i+1) \times (j+1)\) 的二维 \(dp\) 表。

    第二步:找出最优子结构,进而推导出状态转移方程

    -

    考虑子问题 \(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]\)
    3. \(s[i-1]\) 替换为 \(t[j-1]\) ,则剩余子问题 \(dp[i-1, j-1]\)

    编辑距离的状态转移

    -

    图:编辑距离的状态转移

    +

    图 14-29   编辑距离的状态转移

    根据以上分析,可得最优子结构:\(dp[i, j]\) 的最少编辑步数等于 \(dp[i, j-1]\) , \(dp[i-1, j]\) , \(dp[i-1, j-1]\) 三者中的最少编辑步数,再加上本次的编辑步数 \(1\) 。对应的状态转移方程为:

    \[ @@ -3751,7 +3751,7 @@ dp[i, j] = dp[i-1, j-1]
    -

    如下图所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。

    +

    如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。

    @@ -3801,7 +3801,7 @@ dp[i, j] = dp[i-1, j-1]
    -

    图:编辑距离的动态规划过程

    +

    图 14-30   编辑距离的动态规划过程

    3.   状态压缩

    由于 \(dp[i,j]\) 是由上方 \(dp[i-1, j]\) 、左方 \(dp[i, j-1]\) 、左上方状态 \(dp[i-1, j-1]\) 转移而来,而正序遍历会丢失左上方 \(dp[i-1, j-1]\) ,倒序遍历无法提前构建 \(dp[i, j-1]\) ,因此两种遍历顺序都不可取。

    diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html index df9494d4e..b025d33bf 100644 --- a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html +++ b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html @@ -3454,9 +3454,9 @@

    爬楼梯

    给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶,请问有多少种方案可以爬到楼顶。

    -

    如下图所示,对于一个 \(3\) 阶楼梯,共有 \(3\) 种方案可以爬到楼顶。

    +

    如图 14-1 所示,对于一个 \(3\) 阶楼梯,共有 \(3\) 种方案可以爬到楼顶。

    爬到第 3 阶的方案数量

    -

    图:爬到第 3 阶的方案数量

    +

    图 14-1   爬到第 3 阶的方案数量

    本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 \(1\) 阶或 \(2\) 阶,每当到达楼梯顶部时就将方案数量加 \(1\) ,当越过楼梯顶部时就将其剪枝。

    @@ -3789,9 +3789,9 @@ dp[i-1] , dp[i-2] , \dots , dp[2] , dp[1]
    \[ dp[i] = dp[i-1] + dp[i-2] \]
    -

    这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。下图展示了该递推关系。

    +

    这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。图 14-2 展示了该递推关系。

    方案数量递推关系

    -

    图:方案数量递推关系

    +

    图 14-2   方案数量递推关系

    我们可以根据递推公式得到暴力搜索解法:

    -

    下图展示了暴力搜索形成的递归树。对于问题 \(dp[n]\) ,其递归树的深度为 \(n\) ,时间复杂度为 \(O(2^n)\) 。指数阶属于爆炸式增长,如果我们输入一个比较大的 \(n\) ,则会陷入漫长的等待之中。

    +

    图 14-3 展示了暴力搜索形成的递归树。对于问题 \(dp[n]\) ,其递归树的深度为 \(n\) ,时间复杂度为 \(O(2^n)\) 。指数阶属于爆炸式增长,如果我们输入一个比较大的 \(n\) ,则会陷入漫长的等待之中。

    爬楼梯对应递归树

    -

    图:爬楼梯对应递归树

    +

    图 14-3   爬楼梯对应递归树

    -

    观察上图发现,指数阶的时间复杂度是由于“重叠子问题”导致的。例如:\(dp[9]\) 被分解为 \(dp[8]\)\(dp[7]\)\(dp[8]\) 被分解为 \(dp[7]\)\(dp[6]\) ,两者都包含子问题 \(dp[7]\)

    +

    观察图 14-3 ,指数阶的时间复杂度是由于“重叠子问题”导致的。例如 \(dp[9]\) 被分解为 \(dp[8]\)\(dp[7]\)\(dp[8]\) 被分解为 \(dp[7]\)\(dp[6]\) ,两者都包含子问题 \(dp[7]\)

    以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。

    14.1.2   方法二:记忆化搜索

    为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem 来记录每个子问题的解,并在搜索过程中这样做:

    @@ -4280,9 +4280,9 @@ dp[i] = dp[i-1] + dp[i-2] -

    观察下图,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。

    +

    观察图 14-4 ,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。

    记忆化搜索对应递归树

    -

    图:记忆化搜索对应递归树

    +

    图 14-4   记忆化搜索对应递归树

    14.1.3   方法三:动态规划

    记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。

    @@ -4492,9 +4492,9 @@ dp[i] = dp[i-1] + dp[i-2] -

    下图模拟了以上代码的执行过程。

    +

    图 14-5 模拟了以上代码的执行过程。

    爬楼梯的动态规划过程

    -

    图:爬楼梯的动态规划过程

    +

    图 14-5   爬楼梯的动态规划过程

    与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 \(i\)

    总结以上,动态规划的常用术语包括:

    diff --git a/chapter_dynamic_programming/knapsack_problem/index.html b/chapter_dynamic_programming/knapsack_problem/index.html index 4c24d285f..f6dca9e9a 100644 --- a/chapter_dynamic_programming/knapsack_problem/index.html +++ b/chapter_dynamic_programming/knapsack_problem/index.html @@ -3454,9 +3454,9 @@

    Question

    给定 \(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]\)

    0-1 背包的示例数据

    -

    图:0-1 背包的示例数据

    +

    图 14-17   0-1 背包的示例数据

    我们可以将 0-1 背包问题看作是一个由 \(n\) 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。

    该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。

    @@ -3671,10 +3671,10 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) -

    如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 \(O(2^n)\)

    +

    如图 14-18 所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 \(O(2^n)\)

    观察递归树,容易发现其中存在重叠子问题,例如 \(dp[1, 10]\) 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。

    0-1 背包的暴力搜索递归树

    -

    图:0-1 背包的暴力搜索递归树

    +

    图 14-18   0-1 背包的暴力搜索递归树

    2.   方法二:记忆化搜索

    为了保证重叠子问题只被计算一次,我们借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 \(dp[i, c]\)

    @@ -3915,9 +3915,9 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) -

    下图展示了在记忆化递归中被剪掉的搜索分支。

    +

    图 14-19 展示了在记忆化递归中被剪掉的搜索分支。

    0-1 背包的记忆化搜索递归树

    -

    图:0-1 背包的记忆化搜索递归树

    +

    图 14-19   0-1 背包的记忆化搜索递归树

    3.   方法三:动态规划

    动态规划实质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示。

    @@ -4134,7 +4134,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) -

    如下图所示,时间复杂度和空间复杂度都由数组 dp 大小决定,即 \(O(n \times cap)\)

    +

    如图 14-20 所示,时间复杂度和空间复杂度都由数组 dp 大小决定,即 \(O(n \times cap)\)

    @@ -4181,7 +4181,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
    -

    图:0-1 背包的动态规划过程

    +

    图 14-20   0-1 背包的动态规划过程

    4.   状态压缩

    由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\)

    @@ -4190,7 +4190,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
  • 如果采取正序遍历,那么遍历到 \(dp[i, j]\) 时,左上方 \(dp[i-1, 1]\) ~ \(dp[i-1, j-1]\) 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
  • 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
  • -

    下图展示了在单个数组下从第 \(i = 1\) 行转换至第 \(i = 2\) 行的过程。请思考正序遍历和倒序遍历的区别。

    +

    图 14-21 展示了在单个数组下从第 \(i = 1\) 行转换至第 \(i = 2\) 行的过程。请思考正序遍历和倒序遍历的区别。

    @@ -4213,7 +4213,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
    -

    图:0-1 背包的状态压缩后的动态规划过程

    +

    图 14-21   0-1 背包的状态压缩后的动态规划过程

    在代码实现中,我们仅需将数组 dp 的第一维 \(i\) 直接删除,并且把内循环更改为倒序遍历即可。

    diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem/index.html b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html index 764975c3a..081bab642 100644 --- a/chapter_dynamic_programming/unbounded_knapsack_problem/index.html +++ b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html @@ -3603,7 +3603,7 @@

    给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\) 、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品可以重复选取,问在不超过背包容量下能放入物品的最大价值。

    完全背包问题的示例数据

    -

    图:完全背包问题的示例数据

    +

    图 14-22   完全背包问题的示例数据

    1.   动态规划思路

    完全背包和 0-1 背包问题非常相似,区别仅在于不限制物品的选择次数

    @@ -3837,7 +3837,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])

    3.   状态压缩

    由于当前状态是从左边和上边的状态转移而来,因此状态压缩后应该对 \(dp\) 表中的每一行采取正序遍历

    -

    这个遍历顺序与 0-1 背包正好相反。请借助下图来理解两者的区别。

    +

    这个遍历顺序与 0-1 背包正好相反。请借助图 14-23 来理解两者的区别。

    @@ -3860,7 +3860,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
    -

    图:完全背包的状态压缩后的动态规划过程

    +

    图 14-23   完全背包的状态压缩后的动态规划过程

    代码实现比较简单,仅需将数组 dp 的第一维删除。

    @@ -4081,7 +4081,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])

    给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\)每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 \(-1\)

    零钱兑换问题的示例数据

    -

    图:零钱兑换问题的示例数据

    +

    图 14-24   零钱兑换问题的示例数据

    1.   动态规划思路

    零钱兑换可以看作是完全背包的一种特殊情况,两者具有以下联系与不同点:

    @@ -4373,7 +4373,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1) -

    下图展示了零钱兑换的动态规划过程,和完全背包非常相似。

    +

    图 14-25 展示了零钱兑换的动态规划过程,和完全背包非常相似。

    @@ -4423,7 +4423,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
    -

    图:零钱兑换问题的动态规划过程

    +

    图 14-25   零钱兑换问题的动态规划过程

    3.   状态压缩

    零钱兑换的状态压缩的处理方式和完全背包一致。

    @@ -4676,7 +4676,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)

    给定 \(n\) 种硬币,第 \(i\) 种硬币的面值为 \(coins[i - 1]\) ,目标金额为 \(amt\) ,每种硬币可以重复选取,问在凑出目标金额的硬币组合数量

    零钱兑换问题 II 的示例数据

    -

    图:零钱兑换问题 II 的示例数据

    +

    图 14-26   零钱兑换问题 II 的示例数据

    1.   动态规划思路

    相比于上一题,本题目标是组合数量,因此子问题变为:\(i\) 种硬币能够凑出金额 \(a\) 的组合数量。而 \(dp\) 表仍然是尺寸为 \((n+1) \times (amt + 1)\) 的二维矩阵。

    diff --git a/chapter_graph/graph/index.html b/chapter_graph/graph/index.html index d32c1b534..741fc3808 100644 --- a/chapter_graph/graph/index.html +++ b/chapter_graph/graph/index.html @@ -3474,44 +3474,44 @@ E & = \{ (1,2), (1,3), (1,5), (2,3), (2,4), (2,5), (4,5) \} \newline G & = \{ V, E \} \newline \end{aligned} \] -

    如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如下图所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂。

    +

    如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如图 9-1 所示,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂。

    链表、树、图之间的关系

    -

    图:链表、树、图之间的关系

    +

    图 9-1   链表、树、图之间的关系

    9.1.1   图常见类型与术语

    -

    根据边是否具有方向,可分为下图所示的「无向图 undirected graph」和「有向图 directed graph」。

    +

    根据边是否具有方向,可分为图 9-2 所示的「无向图 undirected graph」和「有向图 directed graph」。

    有向图与无向图

    -

    图:有向图与无向图

    +

    图 9-2   有向图与无向图

    -

    根据所有顶点是否连通,可分为下图所示的「连通图 connected graph」和「非连通图 disconnected graph」。

    +

    根据所有顶点是否连通,可分为图 9-3 所示的「连通图 connected graph」和「非连通图 disconnected graph」。

    连通图与非连通图

    -

    图:连通图与非连通图

    +

    图 9-3   连通图与非连通图

    -

    我们还可以为边添加“权重”变量,从而得到下图所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。

    +

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

    有权图与无权图

    -

    图:有权图与无权图

    +

    图 9-4   有权图与无权图

    图的常用术语包括:

    9.1.2   图的表示

    图的常用表示方式包括“邻接矩阵”和“邻接表”。以下使用无向图进行举例。

    1.   邻接矩阵

    设图的顶点数量为 \(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\) 表示两顶点之间无边。

    图的邻接矩阵表示

    -

    图:图的邻接矩阵表示

    +

    图 9-5   图的邻接矩阵表示

    邻接矩阵具有以下特性:

    使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 \(O(1)\) 。然而,矩阵的空间复杂度为 \(O(n^2)\) ,内存占用较多。

    2.   邻接表

    -

    「邻接表 adjacency list」使用 \(n\) 个链表来表示图,链表节点表示顶点。第 \(i\) 条链表对应顶点 \(i\) ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例。

    +

    「邻接表 adjacency list」使用 \(n\) 个链表来表示图,链表节点表示顶点。第 \(i\) 条链表对应顶点 \(i\) ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。

    图的邻接表表示

    -

    图:图的邻接表表示

    +

    图 9-6   图的邻接表表示

    邻接表仅存储实际存在的边,而边的总数通常远小于 \(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 所示,许多现实系统都可以用图来建模,相应的问题也可以约化为图计算问题。

    +

    表 9-1   现实生活中常见的图

    diff --git a/chapter_graph/graph_operations/index.html b/chapter_graph/graph_operations/index.html index 20a66f465..807c96d17 100644 --- a/chapter_graph/graph_operations/index.html +++ b/chapter_graph/graph_operations/index.html @@ -3428,7 +3428,7 @@

    9.2   图基础操作

    图的基础操作可分为对“边”的操作和对“顶点”的操作。在“邻接矩阵”和“邻接表”两种表示方法下,实现方式有所不同。

    9.2.1   基于邻接矩阵的实现

    -

    给定一个顶点数量为 \(n\) 的无向图,则各种操作的实现方式如下图所示。

    +

    给定一个顶点数量为 \(n\) 的无向图,则各种操作的实现方式如图 9-7 所示。

    @@ -5552,7 +5552,7 @@
    -

    观察上表,似乎邻接表(哈希表)的时间与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需要一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。

    +

    观察表 9-2 ,似乎邻接表(哈希表)的时间与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需要一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。

    diff --git a/chapter_graph/graph_traversal/index.html b/chapter_graph/graph_traversal/index.html index e42f75fe7..73d1e9bf1 100644 --- a/chapter_graph/graph_traversal/index.html +++ b/chapter_graph/graph_traversal/index.html @@ -3499,9 +3499,9 @@

    图和树都是非线性数据结构,都需要使用搜索算法来实现遍历操作。

    与树类似,图的遍历方式也可分为两种,即「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」,简称 BFS 和 DFS 。

    9.3.1   广度优先遍历

    -

    广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。如下图所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。

    +

    广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。如图 9-9 所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。

    图的广度优先遍历

    -

    图:图的广度优先遍历

    +

    图 9-9   图的广度优先遍历

    1.   算法实现

    BFS 通常借助队列来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。

    @@ -3853,7 +3853,7 @@ -

    代码相对抽象,建议对照下图来加深理解。

    +

    代码相对抽象,建议对照图 9-10 来加深理解。

    @@ -3891,19 +3891,19 @@
    -

    图:图的广度优先遍历步骤

    +

    图 9-10   图的广度优先遍历步骤

    广度优先遍历的序列是否唯一?

    -

    不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序是允许被任意打乱的。以上图为例,顶点 \(1\) , \(3\) 的访问顺序可以交换、顶点 \(2\) , \(4\) , \(6\) 的访问顺序也可以任意交换。

    +

    不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序是允许被任意打乱的。以图 9-10 为例,顶点 \(1\) , \(3\) 的访问顺序可以交换、顶点 \(2\) , \(4\) , \(6\) 的访问顺序也可以任意交换。

    2.   复杂度分析

    时间复杂度: 所有顶点都会入队并出队一次,使用 \(O(|V|)\) 时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问 \(2\) 次,使用 \(O(2|E|)\) 时间;总体使用 \(O(|V| + |E|)\) 时间。

    空间复杂度: 列表 res ,哈希表 visited ,队列 que 中的顶点数量最多为 \(|V|\) ,使用 \(O(|V|)\) 空间。

    9.3.2   深度优先遍历

    -

    深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如下图所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。

    +

    深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。如图 9-11 所示,从左上角顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。

    图的深度优先遍历

    -

    图:图的深度优先遍历

    +

    图 9-11   图的深度优先遍历

    1.   算法实现

    这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中我们也需要借助一个哈希表 visited 来记录已被访问的顶点,以避免重复访问顶点。

    @@ -4233,7 +4233,7 @@ -

    深度优先遍历的算法流程如下图所示,其中:

    +

    深度优先遍历的算法流程如图 9-12 所示,其中: