diff --git a/docs/chapter_appendix/contribution.md b/docs/chapter_appendix/contribution.md index 206dcdaa9..c840fa2b6 100644 --- a/docs/chapter_appendix/contribution.md +++ b/docs/chapter_appendix/contribution.md @@ -4,15 +4,15 @@ comments: true # 16.2 一起参与创作 -由于作者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。 +由于笔者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、链接失效、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。 -所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)的 GitHub ID 将被展示在本书的仓库主页上,以感谢他们对开源社区的无私奉献。 +所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)的 GitHub ID 将在本书仓库、网页版和 PDF 版的主页上进行展示,以感谢他们对开源社区的无私奉献。 !!! success "开源的魅力" - 纸质书籍的两次印刷的间隔时间往往需要数年,内容更新非常不方便。 + 纸质图书的两次印刷的间隔时间往往较久,内容更新非常不方便。 - 然而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。 + 而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。 ### 1. 内容微调 @@ -32,7 +32,7 @@ comments: true 如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施以下 Pull Request 工作流程。 -1. 登录 GitHub ,将[本仓库](https://github.com/krahets/hello-algo) Fork 到个人账号下。 +1. 登录 GitHub ,将本书的[代码仓库](https://github.com/krahets/hello-algo) Fork 到个人账号下。 2. 进入您的 Fork 仓库网页,使用 `git clone` 命令将仓库克隆至本地。 3. 在本地进行内容创作,并进行完整测试,验证代码的正确性。 4. 将本地所做更改 Commit ,然后 Push 至远程仓库。 @@ -40,13 +40,13 @@ comments: true ### 3. Docker 部署 -在 `hello-algo` 根目录下,执行以下 Docker 脚本,即可在 `http://localhost:8000` 访问本项目。 +在 `hello-algo` 根目录下,执行以下 Docker 脚本,即可在 `http://localhost:8000` 访问本项目: ```shell docker-compose up -d ``` -使用以下命令即可删除部署。 +使用以下命令即可删除部署: ```shell docker-compose down diff --git a/docs/chapter_appendix/installation.md b/docs/chapter_appendix/installation.md index 5a1e8b520..9c73db9dc 100644 --- a/docs/chapter_appendix/installation.md +++ b/docs/chapter_appendix/installation.md @@ -6,7 +6,7 @@ comments: true ### 1. VSCode -本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。 +本书推荐使用开源、轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。 ### 2. Java 环境 @@ -15,7 +15,7 @@ comments: true ### 3. C/C++ 环境 -1. Windows 系统需要安装 [MinGW](https://sourceforge.net/projects/mingw-w64/files/)([配置教程](https://blog.csdn.net/qq_33698226/article/details/129031241)),MacOS 自带 Clang 无须安装。 +1. Windows 系统需要安装 [MinGW](https://sourceforge.net/projects/mingw-w64/files/)([配置教程](https://blog.csdn.net/qq_33698226/article/details/129031241));MacOS 自带 Clang ,无须安装。 2. 在 VSCode 的插件市场中搜索 `c++` ,安装 C/C++ Extension Pack 。 3. (可选)打开 Settings 页面,搜索 `Clang_format_fallback Style` 代码格式化选项,设置为 `{ BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }` 。 @@ -29,7 +29,7 @@ comments: true 1. 下载并安装 [go](https://go.dev/dl/) 。 2. 在 VSCode 的插件市场中搜索 `go` ,安装 Go 。 -3. 快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。 +3. 按快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。 ### 6. JavaScript 环境 diff --git a/docs/chapter_appendix/terminology.md b/docs/chapter_appendix/terminology.md index 3ec1aabfb..ab4f29cd1 100644 --- a/docs/chapter_appendix/terminology.md +++ b/docs/chapter_appendix/terminology.md @@ -7,117 +7,69 @@ status: new 表 16-1 列出了书中出现的重要术语。建议你同时记住它们的中英文叫法,以便阅读英文文献。 -
表 16-1 数据结构与算法重要名词
+表 16-1 数据结构与算法的重要名词
图 4-2 数组元素的内存地址计算
-观察图 4-2 ,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 也是合理的。 +观察图 4-2 ,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 是合理的。 -在数组中访问元素是非常高效的,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。 +在数组中访问元素非常高效,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。 === "Python" @@ -289,13 +289,13 @@ comments: true ### 3. 插入元素 -数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。 +数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如图 4-3 所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。 { class="animation-figure" }图 4-3 数组插入元素示例
-值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。 +值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在“列表”章节中讨论。 === "Python" @@ -305,7 +305,7 @@ comments: true # 把索引 index 以及之后的所有元素向后移动一位 for i in range(len(nums) - 1, index, -1): nums[i] = nums[i - 1] - # 将 num 赋给 index 处元素 + # 将 num 赋给 index 处的元素 nums[index] = num ``` @@ -318,7 +318,7 @@ comments: true for (int i = size - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -332,7 +332,7 @@ comments: true for (int i = nums.length - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -346,7 +346,7 @@ comments: true for (int i = nums.Length - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -360,7 +360,7 @@ comments: true for i := len(nums) - 1; i > index; i-- { nums[i] = nums[i-1] } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num } ``` @@ -374,7 +374,7 @@ comments: true for i in nums.indices.dropFirst(index).reversed() { nums[i] = nums[i - 1] } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num } ``` @@ -388,7 +388,7 @@ comments: true for (let i = nums.length - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -402,7 +402,7 @@ comments: true for (let i = nums.length - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -430,7 +430,7 @@ comments: true for i in (index + 1..nums.len()).rev() { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -444,7 +444,7 @@ comments: true for (int i = size - 1; i > index; i--) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` @@ -459,14 +459,14 @@ comments: true while (i > index) : (i -= 1) { nums[i] = nums[i - 1]; } - // 将 num 赋给 index 处元素 + // 将 num 赋给 index 处的元素 nums[index] = num; } ``` ### 4. 删除元素 -同理,如图 4-4 所示,若想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。 +同理,如图 4-4 所示,若想删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。 { class="animation-figure" } @@ -478,7 +478,7 @@ comments: true ```python title="array.py" def remove(nums: list[int], index: int): - """删除索引 index 处元素""" + """删除索引 index 处的元素""" # 把索引 index 之后的所有元素向前移动一位 for i in range(index, len(nums) - 1): nums[i] = nums[i + 1] @@ -487,7 +487,7 @@ comments: true === "C++" ```cpp title="array.cpp" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ void remove(int *nums, int size, int index) { // 把索引 index 之后的所有元素向前移动一位 for (int i = index; i < size - 1; i++) { @@ -499,7 +499,7 @@ comments: true === "Java" ```java title="array.java" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ void remove(int[] nums, int index) { // 把索引 index 之后的所有元素向前移动一位 for (int i = index; i < nums.length - 1; i++) { @@ -511,7 +511,7 @@ comments: true === "C#" ```csharp title="array.cs" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ void Remove(int[] nums, int index) { // 把索引 index 之后的所有元素向前移动一位 for (int i = index; i < nums.Length - 1; i++) { @@ -523,7 +523,7 @@ comments: true === "Go" ```go title="array.go" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ func remove(nums []int, index int) { // 把索引 index 之后的所有元素向前移动一位 for i := index; i < len(nums)-1; i++ { @@ -535,7 +535,7 @@ comments: true === "Swift" ```swift title="array.swift" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ func remove(nums: inout [Int], index: Int) { // 把索引 index 之后的所有元素向前移动一位 for i in nums.indices.dropFirst(index).dropLast() { @@ -547,7 +547,7 @@ comments: true === "JS" ```javascript title="array.js" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ function remove(nums, index) { // 把索引 index 之后的所有元素向前移动一位 for (let i = index; i < nums.length - 1; i++) { @@ -559,7 +559,7 @@ comments: true === "TS" ```typescript title="array.ts" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ function remove(nums: number[], index: number): void { // 把索引 index 之后的所有元素向前移动一位 for (let i = index; i < nums.length - 1; i++) { @@ -571,7 +571,7 @@ comments: true === "Dart" ```dart title="array.dart" - /* 删除索引 index 处元素 */ + /* 删除索引 index 处的元素 */ void remove(List表 4-1 数组与链表的效率对比
@@ -1118,8 +1118,8 @@ comments: true 如图 4-8 所示,常见的链表类型包括三种。 -- **单向链表**:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。 -- **环形链表**:如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。 +- **单向链表**:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。 +- **环形链表**:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。 - **双向链表**:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。 === "Python" @@ -1321,17 +1321,17 @@ comments: true 单向链表通常用于实现栈、队列、哈希表和图等数据结构。 -- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。 -- **哈希表**:链地址法是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。 -- **图**:邻接表是表示图的一种常用方式,在其中,图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。 +- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。 +- **哈希表**:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。 +- **图**:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。 -双向链表常被用于需要快速查找前一个和下一个元素的场景。 +双向链表常用于需要快速查找前一个和后一个元素的场景。 - **高级数据结构**:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。 - **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。 -- **LRU 算法**:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。 +- **LRU 算法**:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。 -循环链表常被用于需要周期性操作的场景,比如操作系统的资源调度。 +环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。 -- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环的操作就可以通过循环链表来实现。 -- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用到循环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个循环链表,以便实现无缝播放。 +- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。 +- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。 diff --git a/docs/chapter_array_and_linkedlist/list.md b/docs/chapter_array_and_linkedlist/list.md index 64e21451c..abc0d2599 100755 --- a/docs/chapter_array_and_linkedlist/list.md +++ b/docs/chapter_array_and_linkedlist/list.md @@ -4,10 +4,10 @@ comments: true # 4.3 列表 -「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无需使用者考虑容量限制的问题。列表可以基于链表或数组实现。 +「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。 -- 链表天然可以被看作是一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。 -- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作是一个具有长度限制的列表。 +- 链表天然可以被看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。 +- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作一个具有长度限制的列表。 当使用数组实现列表时,**长度不可变的性质会导致列表的实用性降低**。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间的浪费。 @@ -19,7 +19,7 @@ comments: true ### 1. 初始化列表 -我们通常使用“无初始值”和“有初始值”这两种初始化方法。 +我们通常使用“无初始值”和“有初始值”这两种初始化方法: === "Python" @@ -268,14 +268,14 @@ comments: true # 清空列表 nums.clear() - # 尾部添加元素 + # 在尾部添加元素 nums.append(1) nums.append(3) nums.append(2) nums.append(5) nums.append(4) - # 中间插入元素 + # 在中间插入元素 nums.insert(3, 6) # 在索引 3 处插入数字 6 # 删除元素 @@ -288,14 +288,14 @@ comments: true /* 清空列表 */ nums.clear(); - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.push_back(1); nums.push_back(3); nums.push_back(2); nums.push_back(5); nums.push_back(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.insert(nums.begin() + 3, 6); // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -308,14 +308,14 @@ comments: true /* 清空列表 */ nums.clear(); - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.add(1); nums.add(3); nums.add(2); nums.add(5); nums.add(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.add(3, 6); // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -328,14 +328,14 @@ comments: true /* 清空列表 */ nums.Clear(); - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.Add(1); nums.Add(3); nums.Add(2); nums.Add(5); nums.Add(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.Insert(3, 6); /* 删除元素 */ @@ -348,14 +348,14 @@ comments: true /* 清空列表 */ nums = nil - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums = append(nums, 1) nums = append(nums, 3) nums = append(nums, 2) nums = append(nums, 5) nums = append(nums, 4) - /* 中间插入元素 */ + /* 在中间插入元素 */ nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -368,14 +368,14 @@ comments: true /* 清空列表 */ nums.removeAll() - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.append(1) nums.append(3) nums.append(2) nums.append(5) nums.append(4) - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.insert(6, at: 3) // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -388,14 +388,14 @@ comments: true /* 清空列表 */ nums.length = 0; - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.push(1); nums.push(3); nums.push(2); nums.push(5); nums.push(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.splice(3, 0, 6); /* 删除元素 */ @@ -408,14 +408,14 @@ comments: true /* 清空列表 */ nums.length = 0; - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.push(1); nums.push(3); nums.push(2); nums.push(5); nums.push(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.splice(3, 0, 6); /* 删除元素 */ @@ -428,14 +428,14 @@ comments: true /* 清空列表 */ nums.clear(); - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.add(1); nums.add(3); nums.add(2); nums.add(5); nums.add(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.insert(3, 6); // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -448,14 +448,14 @@ comments: true /* 清空列表 */ nums.clear(); - /* 尾部添加元素 */ + /* 在尾部添加元素 */ nums.push(1); nums.push(3); nums.push(2); nums.push(5); nums.push(4); - /* 中间插入元素 */ + /* 在中间插入元素 */ nums.insert(3, 6); // 在索引 3 处插入数字 6 /* 删除元素 */ @@ -474,14 +474,14 @@ comments: true // 清空列表 nums.clearRetainingCapacity(); - // 尾部添加元素 + // 在尾部添加元素 try nums.append(1); try nums.append(3); try nums.append(2); try nums.append(5); try nums.append(4); - // 中间插入元素 + // 在中间插入元素 try nums.insert(3, 6); // 在索引 3 处插入数字 6 // 删除元素 @@ -673,7 +673,7 @@ comments: true ### 5. 拼接列表 -给定一个新列表 `nums1` ,我们可以将该列表拼接到原列表的尾部。 +给定一个新列表 `nums1` ,我们可以将其拼接到原列表的尾部。 === "Python" @@ -774,7 +774,7 @@ comments: true ### 6. 排序列表 -完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。 +完成列表排序后,我们便可以使用在数组类算法题中经常考查的“二分查找”和“双指针”算法。 === "Python" @@ -861,29 +861,29 @@ comments: true ## 4.3.2 列表实现 -许多编程语言都提供内置的列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。 +许多编程语言内置了列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。 为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。 - **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。 - **数量记录**:声明一个变量 `size` ,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。 -- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。 +- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。 === "Python" ```python title="my_list.py" class MyList: - """列表类简易实现""" + """列表类""" def __init__(self): """构造方法""" self._capacity: int = 10 # 列表容量 self._arr: list[int] = [0] * self._capacity # 数组(存储列表元素) - self._size: int = 0 # 列表长度(即当前元素数量) + self._size: int = 0 # 列表长度(当前元素数量) self._extend_ratio: int = 2 # 每次列表扩容的倍数 def size(self) -> int: - """获取列表长度(即当前元素数量)""" + """获取列表长度(当前元素数量)""" return self._size def capacity(self) -> int: @@ -904,7 +904,7 @@ comments: true self._arr[index] = num def add(self, num: int): - """尾部添加元素""" + """在尾部添加元素""" # 元素数量超出容量时,触发扩容机制 if self.size() == self.capacity(): self.extend_capacity() @@ -912,7 +912,7 @@ comments: true self._size += 1 def insert(self, num: int, index: int): - """中间插入元素""" + """在中间插入元素""" if index < 0 or index >= self._size: raise IndexError("索引越界") # 元素数量超出容量时,触发扩容机制 @@ -953,12 +953,12 @@ comments: true === "C++" ```cpp title="my_list.cpp" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { private: int *arr; // 数组(存储列表元素) int arrCapacity = 10; // 列表容量 - int arrSize = 0; // 列表长度(即当前元素数量) + int arrSize = 0; // 列表长度(当前元素数量) int extendRatio = 2; // 每次列表扩容的倍数 public: @@ -972,7 +972,7 @@ comments: true delete[] arr; } - /* 获取列表长度(即当前元素数量)*/ + /* 获取列表长度(当前元素数量)*/ int size() { return arrSize; } @@ -997,7 +997,7 @@ comments: true arr[index] = num; } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ void add(int num) { // 元素数量超出容量时,触发扩容机制 if (size() == capacity()) @@ -1007,7 +1007,7 @@ comments: true arrSize++; } - /* 中间插入元素 */ + /* 在中间插入元素 */ void insert(int index, int num) { if (index < 0 || index >= size()) throw out_of_range("索引越界"); @@ -1068,11 +1068,11 @@ comments: true === "Java" ```java title="my_list.java" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { private int[] arr; // 数组(存储列表元素) private int capacity = 10; // 列表容量 - private int size = 0; // 列表长度(即当前元素数量) + private int size = 0; // 列表长度(当前元素数量) private int extendRatio = 2; // 每次列表扩容的倍数 /* 构造方法 */ @@ -1080,7 +1080,7 @@ comments: true arr = new int[capacity]; } - /* 获取列表长度(即当前元素数量) */ + /* 获取列表长度(当前元素数量) */ public int size() { return size; } @@ -1105,7 +1105,7 @@ comments: true arr[index] = num; } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ public void add(int num) { // 元素数量超出容量时,触发扩容机制 if (size == capacity()) @@ -1115,7 +1115,7 @@ comments: true size++; } - /* 中间插入元素 */ + /* 在中间插入元素 */ public void insert(int index, int num) { if (index < 0 || index >= size) throw new IndexOutOfBoundsException("索引越界"); @@ -1170,11 +1170,11 @@ comments: true === "C#" ```csharp title="my_list.cs" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { private int[] arr; // 数组(存储列表元素) private int arrCapacity = 10; // 列表容量 - private int arrSize = 0; // 列表长度(即当前元素数量) + private int arrSize = 0; // 列表长度(当前元素数量) private readonly int extendRatio = 2; // 每次列表扩容的倍数 /* 构造方法 */ @@ -1182,7 +1182,7 @@ comments: true arr = new int[arrCapacity]; } - /* 获取列表长度(即当前元素数量)*/ + /* 获取列表长度(当前元素数量)*/ public int Size() { return arrSize; } @@ -1207,7 +1207,7 @@ comments: true arr[index] = num; } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ public void Add(int num) { // 元素数量超出容量时,触发扩容机制 if (arrSize == arrCapacity) @@ -1217,7 +1217,7 @@ comments: true arrSize++; } - /* 中间插入元素 */ + /* 在中间插入元素 */ public void Insert(int index, int num) { if (index < 0 || index >= arrSize) throw new IndexOutOfRangeException("索引越界"); @@ -1271,7 +1271,7 @@ comments: true === "Go" ```go title="my_list.go" - /* 列表类简易实现 */ + /* 列表类 */ type myList struct { arrCapacity int arr []int @@ -1284,12 +1284,12 @@ comments: true return &myList{ arrCapacity: 10, // 列表容量 arr: make([]int, 10), // 数组(存储列表元素) - arrSize: 0, // 列表长度(即当前元素数量) + arrSize: 0, // 列表长度(当前元素数量) extendRatio: 2, // 每次列表扩容的倍数 } } - /* 获取列表长度(即当前元素数量) */ + /* 获取列表长度(当前元素数量) */ func (l *myList) size() int { return l.arrSize } @@ -1316,7 +1316,7 @@ comments: true l.arr[index] = num } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ func (l *myList) add(num int) { // 元素数量超出容量时,触发扩容机制 if l.arrSize == l.arrCapacity { @@ -1327,7 +1327,7 @@ comments: true l.arrSize++ } - /* 中间插入元素 */ + /* 在中间插入元素 */ func (l *myList) insert(num, index int) { if index < 0 || index >= l.arrSize { panic("索引越界") @@ -1379,11 +1379,11 @@ comments: true === "Swift" ```swift title="my_list.swift" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { private var arr: [Int] // 数组(存储列表元素) private var _capacity = 10 // 列表容量 - private var _size = 0 // 列表长度(即当前元素数量) + private var _size = 0 // 列表长度(当前元素数量) private let extendRatio = 2 // 每次列表扩容的倍数 /* 构造方法 */ @@ -1391,7 +1391,7 @@ comments: true arr = Array(repeating: 0, count: _capacity) } - /* 获取列表长度(即当前元素数量)*/ + /* 获取列表长度(当前元素数量)*/ func size() -> Int { _size } @@ -1418,7 +1418,7 @@ comments: true arr[index] = num } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ func add(num: Int) { // 元素数量超出容量时,触发扩容机制 if _size == _capacity { @@ -1429,7 +1429,7 @@ comments: true _size += 1 } - /* 中间插入元素 */ + /* 在中间插入元素 */ func insert(index: Int, num: Int) { if index < 0 || index >= _size { fatalError("索引越界") @@ -1486,11 +1486,11 @@ comments: true === "JS" ```javascript title="my_list.js" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { #arr = new Array(); // 数组(存储列表元素) #capacity = 10; // 列表容量 - #size = 0; // 列表长度(即当前元素数量) + #size = 0; // 列表长度(当前元素数量) #extendRatio = 2; // 每次列表扩容的倍数 /* 构造方法 */ @@ -1498,7 +1498,7 @@ comments: true this.#arr = new Array(this.#capacity); } - /* 获取列表长度(即当前元素数量)*/ + /* 获取列表长度(当前元素数量)*/ size() { return this.#size; } @@ -1521,7 +1521,7 @@ comments: true this.#arr[index] = num; } - /* 尾部添加元素 */ + /* 在尾部添加元素 */ add(num) { // 如果长度等于容量,则需要扩容 if (this.#size === this.#capacity) { @@ -1532,7 +1532,7 @@ comments: true this.#size++; } - /* 中间插入元素 */ + /* 在中间插入元素 */ insert(index, num) { if (index < 0 || index >= this.#size) throw new Error('索引越界'); // 元素数量超出容量时,触发扩容机制 @@ -1588,11 +1588,11 @@ comments: true === "TS" ```typescript title="my_list.ts" - /* 列表类简易实现 */ + /* 列表类 */ class MyList { private arr: Array表 4-2 计算机的存储设备
图 13-4 保留与删除 return 的搜索过程对比
-相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,**许多回溯问题都可以在该框架下解决**。我们只需根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法即可。 +相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰唆,但通用性更好。实际上,**许多回溯问题可以在该框架下解决**。我们只需根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法即可。 ## 13.1.4 常用术语 -为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。 +为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例,如表 13-1 所示。表 13-1 常见的回溯算法术语
图 13-17 逐行放置策略
-本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。 +从本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。 ### 2. 列与对角线剪枝 @@ -40,7 +40,7 @@ comments: true 那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 $(row, col)$ ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,**即对角线上所有格子的 $row - col$ 为恒定值**。 -也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 `diags1` ,记录每条主对角线上是否有皇后。 +也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 `diags1` 记录每条主对角线上是否有皇后。 同理,**次对角线上的所有格子的 $row + col$ 是恒定值**。我们同样也可以借助数组 `diags2` 来处理次对角线约束。 @@ -74,7 +74,7 @@ comments: true # 计算该格子对应的主对角线和副对角线 diag1 = row - col + n - 1 diag2 = row + col - # 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后 + # 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后 if not cols[col] and not diags1[diag1] and not diags2[diag2]: # 尝试:将皇后放置在该格子 state[row][col] = "Q" @@ -90,8 +90,8 @@ comments: true # 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位 state = [["#" for _ in range(n)] for _ in range(n)] cols = [False] * n # 记录列是否有皇后 - diags1 = [False] * (2 * n - 1) # 记录主对角线是否有皇后 - diags2 = [False] * (2 * n - 1) # 记录副对角线是否有皇后 + diags1 = [False] * (2 * n - 1) # 记录主对角线上是否有皇后 + diags2 = [False] * (2 * n - 1) # 记录副对角线上是否有皇后 res = [] backtrack(0, n, state, res, cols, diags1, diags2) @@ -114,7 +114,7 @@ comments: true // 计算该格子对应的主对角线和副对角线 int diag1 = row - col + n - 1; int diag2 = row + col; - // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后 + // 剪枝:不允许该格子所在列、主对角线、副对角线上存在皇后 if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { // 尝试:将皇后放置在该格子 state[row][col] = "Q"; @@ -133,8 +133,8 @@ comments: true // 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位 vector图 13-6 全排列剪枝示例
-观察图 13-6 发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。 +观察图 13-6 发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 减小至 $O(n!)$ 。 ### 2. 代码实现 -想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将他们展开在 `backtrack()` 函数中。 +想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短整体代码,我们不单独实现框架代码中的各个函数,而是将它们展开在 `backtrack()` 函数中: === "Python" @@ -479,21 +479,21 @@ comments: true 假设输入数组为 $[1, 1, 2]$ 。为了方便区分两个重复元素 $1$ ,我们将第二个 $1$ 记为 $\hat{1}$ 。 -如图 13-7 所示,上述方法生成的排列有一半都是重复的。 +如图 13-7 所示,上述方法生成的排列有一半是重复的。 { class="animation-figure" }图 13-7 重复排列
-那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝**,这样可以进一步提升算法效率。 +那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝**,这样可以进一步提升算法效率。 ### 1. 相等元素剪枝 -观察图 13-8 ,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝掉。 +观察图 13-8 ,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝。 同理,在第一轮选择 $2$ 之后,第二轮选择中的 $1$ 和 $\hat{1}$ 也会产生重复分支,因此也应将第二轮的 $\hat{1}$ 剪枝。 -本质上看,**我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次**。 +从本质上看,**我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次**。 { class="animation-figure" } @@ -501,7 +501,7 @@ comments: true ### 2. 代码实现 -在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。 +在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝: === "Python" @@ -948,10 +948,10 @@ comments: true ### 3. 两种剪枝对比 -请注意,虽然 `selected` 和 `duplicated` 都用作剪枝,但两者的目标是不同的。 +请注意,虽然 `selected` 和 `duplicated` 都用于剪枝,但两者的目标不同。 -- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是防止 `choices` 中的任一元素在 `state` 中重复出现。 -- **相等元素剪枝**:每轮选择(即每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(即 `for` 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。 +- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,其作用是防止 `choices` 中的任一元素在 `state` 中重复出现。 +- **相等元素剪枝**:每轮选择(每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(`for` 循环)中哪些元素已被选择过,其作用是保证相等的元素只被选择一次。 图 13-9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。 diff --git a/docs/chapter_backtracking/subset_sum_problem.md b/docs/chapter_backtracking/subset_sum_problem.md index 48930b9d3..7d63532a3 100644 --- a/docs/chapter_backtracking/subset_sum_problem.md +++ b/docs/chapter_backtracking/subset_sum_problem.md @@ -13,13 +13,13 @@ comments: true 例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,解为 $\{3, 3, 3\}, \{4, 5\}$ 。需要注意以下两点。 - 输入集合中的元素可以被无限次重复选取。 -- 子集是不区分元素顺序的,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。 +- 子集不区分元素顺序,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。 ### 1. 参考全排列解法 类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 `target` 时,就将子集记录至结果列表。 -而与全排列问题不同的是,**本题集合中的元素可以被无限次选取**,因此无须借助 `selected` 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码。 +而与全排列问题不同的是,**本题集合中的元素可以被无限次选取**,因此无须借助 `selected` 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码: === "Python" @@ -430,7 +430,7 @@ comments: true 向以上代码输入数组 $[3, 4, 5]$ 和目标元素 $9$ ,输出结果为 $[3, 3, 3], [4, 5], [5, 4]$ 。**虽然成功找出了所有和为 $9$ 的子集,但其中存在重复的子集 $[4, 5]$ 和 $[5, 4]$** 。 -这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13-10 所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是两个不同的分支,但两者对应同一个子集。 +这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13-10 所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是不同的分支,但对应同一个子集。 { class="animation-figure" } @@ -446,13 +446,13 @@ comments: true **我们考虑在搜索过程中通过剪枝进行去重**。观察图 13-11 ,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。 1. 当第一轮和第二轮分别选择 $3$ 和 $4$ 时,会生成包含这两个元素的所有子集,记为 $[3, 4, \dots]$ 。 -2. 之后,当第一轮选择 $4$ 时,**则第二轮应该跳过 $3$** ,因为该选择产生的子集 $[4, 3, \dots]$ 和 `1.` 中生成的子集完全重复。 +2. 之后,当第一轮选择 $4$ 时,**则第二轮应该跳过 $3$** ,因为该选择产生的子集 $[4, 3, \dots]$ 和第 `1.` 步中生成的子集完全重复。 -在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。 +在搜索过程中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。 1. 前两轮选择 $3$ 和 $5$ ,生成子集 $[3, 5, \dots]$ 。 2. 前两轮选择 $4$ 和 $5$ ,生成子集 $[4, 5, \dots]$ 。 -3. 若第一轮选择 $5$ ,**则第二轮应该跳过 $3$ 和 $4$** ,因为子集 $[5, 3, \dots]$ 和 $[5, 4, \dots]$ 与第 `1.` 和 `2.` 步中描述的子集完全重复。 +3. 若第一轮选择 $5$ ,**则第二轮应该跳过 $3$ 和 $4$** ,因为子集 $[5, 3, \dots]$ 和 $[5, 4, \dots]$ 与第 `1.` 步和第 `2.` 步中描述的子集完全重复。 { class="animation-figure" } @@ -462,11 +462,11 @@ comments: true ### 3. 代码实现 -为实现该剪枝,我们初始化变量 `start` ,用于指示遍历起点。**当做出选择 $x_{i}$ 后,设定下一轮从索引 $i$ 开始遍历**。这样做就可以让选择序列满足 $i_1 \leq i_2 \leq \dots \leq i_m$ ,从而保证子集唯一。 +为实现该剪枝,我们初始化变量 `start` ,用于指示遍历起始点。**当做出选择 $x_{i}$ 后,设定下一轮从索引 $i$ 开始遍历**。这样做就可以让选择序列满足 $i_1 \leq i_2 \leq \dots \leq i_m$ ,从而保证子集唯一。 除此之外,我们还对代码进行了以下两项优化。 -- 在开启搜索前,先将数组 `nums` 排序。在遍历所有选择时,**当子集和超过 `target` 时直接结束循环**,因为后边的元素更大,其子集和都一定会超过 `target` 。 +- 在开启搜索前,先将数组 `nums` 排序。在遍历所有选择时,**当子集和超过 `target` 时直接结束循环**,因为后边的元素更大,其子集和一定超过 `target` 。 - 省去元素和变量 `total` ,**通过在 `target` 上执行减法来统计元素和**,当 `target` 等于 $0$ 时记录解。 === "Python" @@ -906,7 +906,7 @@ comments: true [class]{}-[func]{subsetSumI} ``` -如图 13-12 所示,为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入到以上代码后的整体回溯过程。 +图 13-12 所示为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入以上代码后的整体回溯过程。 { class="animation-figure" } @@ -928,9 +928,9 @@ comments: true ### 1. 相等元素剪枝 -为解决此问题,**我们需要限制相等元素在每一轮中只被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。 +为解决此问题,**我们需要限制相等元素在每一轮中只能被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。 -与此同时,**本题规定数组中的每个元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。 +与此同时,**本题规定每个数组元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样既能去除重复子集,也能避免重复选择元素。 ### 2. 代码实现 diff --git a/docs/chapter_backtracking/summary.md b/docs/chapter_backtracking/summary.md index 6260753bf..6a4a04470 100644 --- a/docs/chapter_backtracking/summary.md +++ b/docs/chapter_backtracking/summary.md @@ -9,13 +9,13 @@ comments: true - 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满足条件的解则记录,直至找到所有解或遍历完成后结束。 - 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两个方向相反的操作。 - 回溯问题通常包含多个约束条件,它们可用于实现剪枝操作。剪枝可以提前结束不必要的搜索分支,大幅提升搜索效率。 -- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在更高效率或更好效果的解法。 -- 全排列问题旨在搜索给定集合的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪枝掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。 +- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在效率更高或效果更好的解法。 +- 全排列问题旨在搜索给定集合元素的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。 - 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每轮中只能被选择一次,这通常借助一个哈希表来实现。 -- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起点,从而将生成重复子集的搜索分支进行剪枝。 +- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起始点,从而将生成重复子集的搜索分支进行剪枝。 - 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。 -- $n$ 皇后旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。 -- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。 +- $n$ 皇后问题旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。 +- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线上是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。 ### 2. Q & A diff --git a/docs/chapter_computational_complexity/iteration_and_recursion.md b/docs/chapter_computational_complexity/iteration_and_recursion.md index c2a1e8732..fb859d5c4 100644 --- a/docs/chapter_computational_complexity/iteration_and_recursion.md +++ b/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -12,9 +12,9 @@ comments: true ### 1. for 循环 -`for` 循环是最常见的迭代形式之一,**适合预先知道迭代次数时使用**。 +`for` 循环是最常见的迭代形式之一,**适合在预先知道迭代次数时使用**。 -以下函数基于 `for` 循环实现了求和 $1 + 2 + \dots + n$ ,求和结果使用变量 `res` 记录。需要注意的是,Python 中 `range(a, b)` 对应的区间是“左闭右开”的,对应的遍历范围为 $a, a + 1, \dots, b-1$ 。 +以下函数基于 `for` 循环实现了求和 $1 + 2 + \dots + n$ ,求和结果使用变量 `res` 记录。需要注意的是,Python 中 `range(a, b)` 对应的区间是“左闭右开”的,对应的遍历范围为 $a, a + 1, \dots, b-1$ : === "Python" @@ -182,7 +182,7 @@ comments: true } ``` -图 2-1 展示了该求和函数的流程框图。 +图 2-1 是该求和函数的流程框图。 { class="animation-figure" } @@ -192,9 +192,9 @@ comments: true ### 2. while 循环 -与 `for` 循环类似,`while` 循环也是一种实现迭代的方法。在 `while` 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。 +与 `for` 循环类似,`while` 循环也是一种实现迭代的方法。在 `while` 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。 -下面,我们用 `while` 循环来实现求和 $1 + 2 + \dots + n$ 。 +下面我们用 `while` 循环来实现求和 $1 + 2 + \dots + n$ : === "Python" @@ -388,9 +388,9 @@ comments: true } ``` -**`while` 循环比 `for` 循环的自由度更高**。在 `while` 循环中,我们可以自由设计条件变量的初始化和更新步骤。 +**`while` 循环比 `for` 循环的自由度更高**。在 `while` 循环中,我们可以自由地设计条件变量的初始化和更新步骤。 -例如在以下代码中,条件变量 $i$ 每轮进行了两次更新,这种情况就不太方便用 `for` 循环实现。 +例如在以下代码中,条件变量 $i$ 每轮进行两次更新,这种情况就不太方便用 `for` 循环实现: === "Python" @@ -399,7 +399,7 @@ comments: true """while 循环(两次更新)""" res = 0 i = 1 # 初始化条件变量 - # 循环求和 1, 4, ... + # 循环求和 1, 4, 10, ... while i <= n: res += i # 更新条件变量 @@ -415,7 +415,7 @@ comments: true int whileLoopII(int n) { int res = 0; int i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -433,7 +433,7 @@ comments: true int whileLoopII(int n) { int res = 0; int i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -470,7 +470,7 @@ comments: true res := 0 // 初始化条件变量 i := 1 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... for i <= n { res += i // 更新条件变量 @@ -488,7 +488,7 @@ comments: true func whileLoopII(n: Int) -> Int { var res = 0 var i = 1 // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while i <= n { res += i // 更新条件变量 @@ -506,7 +506,7 @@ comments: true function whileLoopII(n) { let res = 0; let i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -524,7 +524,7 @@ comments: true function whileLoopII(n: number): number { let res = 0; let i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -542,7 +542,7 @@ comments: true int whileLoopII(int n) { int res = 0; int i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -560,7 +560,7 @@ comments: true fn while_loop_ii(n: i32) -> i32 { let mut res = 0; let mut i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while i <= n { res += i; // 更新条件变量 @@ -578,7 +578,7 @@ comments: true int whileLoopII(int n) { int res = 0; int i = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += i; // 更新条件变量 @@ -596,7 +596,7 @@ comments: true fn whileLoopII(n: i32) i32 { var res: i32 = 0; var i: i32 = 1; // 初始化条件变量 - // 循环求和 1, 4, ... + // 循环求和 1, 4, 10, ... while (i <= n) { res += @intCast(i); // 更新条件变量 @@ -611,7 +611,7 @@ comments: true ### 3. 嵌套循环 -我们可以在一个循环结构内嵌套另一个循环结构,以 `for` 循环为例: +我们可以在一个循环结构内嵌套另一个循环结构,下面以 `for` 循环为例: === "Python" @@ -821,7 +821,7 @@ comments: true } ``` -图 2-2 给出了该嵌套循环的流程框图。 +图 2-2 是该嵌套循环的流程框图。 { class="animation-figure" } @@ -829,7 +829,7 @@ comments: true 在这种情况下,函数的操作数量与 $n^2$ 成正比,或者说算法运行时间和输入数据大小 $n$ 成“平方关系”。 -我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方关系”、以此类推。 +我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”,以此类推。 ## 2.2.2 递归 @@ -1037,7 +1037,7 @@ comments: true - **迭代**:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。 - **递归**:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。 -以上述的求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。 +以上述求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。 - **迭代**:在循环中模拟求和过程,从 $1$ 遍历到 $n$ ,每轮执行求和操作,即可求得 $f(n)$ 。 - **递归**:将问题分解为子问题 $f(n) = n + f(n-1)$ ,不断(递归地)分解下去,直至基本情况 $f(1) = 1$ 时终止。 @@ -1055,16 +1055,16 @@ comments: true图 2-4 递归调用深度
-在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。 +在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。 ### 2. 尾递归 有趣的是,**如果函数在返回前的最后一步才进行递归调用**,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。 - **普通递归**:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。 -- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。 +- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。 -以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归。 +以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归: === "Python" @@ -1222,7 +1222,7 @@ comments: true } ``` -尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,求和操作的执行点是不同的。 +尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。 - **普通递归**:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。 - **尾递归**:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。 @@ -1233,7 +1233,7 @@ comments: true !!! tip - 请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,但仍然可能会遇到栈溢出问题。 + 请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。 ### 3. 递归树 @@ -1248,7 +1248,7 @@ comments: true - 数列的前两个数字为 $f(1) = 0$ 和 $f(2) = 1$ 。 - 数列中的每个数字是前两个数字的和,即 $f(n) = f(n - 1) + f(n - 2)$ 。 -按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字。 +按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字: === "Python" @@ -1430,15 +1430,15 @@ comments: true } ``` -观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如图 2-6 所示,这样不断递归调用下去,最终将产生一个层数为 $n$ 的「递归树 recursion tree」。 +观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 $n$ 的「递归树 recursion tree」。 { class="animation-figure" }图 2-6 斐波那契数列的递归树
-本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略是至关重要的。 +从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。 -- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略都直接或间接地应用这种思维方式。 +- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。 - 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。 ## 2.2.3 两者对比 diff --git a/docs/chapter_computational_complexity/performance_evaluation.md b/docs/chapter_computational_complexity/performance_evaluation.md index 9d2b964a8..576e4f8d3 100644 --- a/docs/chapter_computational_complexity/performance_evaluation.md +++ b/docs/chapter_computational_complexity/performance_evaluation.md @@ -6,7 +6,7 @@ comments: true 在算法设计中,我们先后追求以下两个层面的目标。 -1. **找到问题解法**:算法需要在规定的输入范围内,可靠地求得问题的正确解。 +1. **找到问题解法**:算法需要在规定的输入范围内可靠地求得问题的正确解。 2. **寻求最优解法**:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。 也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。 @@ -14,23 +14,23 @@ comments: true - **时间效率**:算法运行速度的快慢。 - **空间效率**:算法占用内存空间的大小。 -简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,从而指导算法设计与优化过程。 +简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,进而指导算法设计与优化过程。 效率评估方法主要分为两种:实际测试、理论估算。 ## 2.1.1 实际测试 -假设我们现在有算法 `A` 和算法 `B` ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大局限性。 +假设我们现在有算法 `A` 和算法 `B` ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大的局限性。 -一方面,**难以排除测试环境的干扰因素**。硬件配置会影响算法的性能表现。比如在某台计算机中,算法 `A` 的运行时间比算法 `B` 短;但在另一台配置不同的计算机中,我们可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。 +一方面,**难以排除测试环境的干扰因素**。硬件配置会影响算法的性能。比如在某台计算机中,算法 `A` 的运行时间比算法 `B` 短;但在另一台配置不同的计算机中,可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。 -另一方面,**展开完整测试非常耗费资源**。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 `A` 的运行时间比算法 `B` 更少;而输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。 +另一方面,**展开完整测试非常耗费资源**。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 `A` 的运行时间比算法 `B` 短;而在输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。 ## 2.1.2 理论估算 -由于实际测试具有较大的局限性,我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」,简称「复杂度分析」。 +由于实际测试具有较大的局限性,因此我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」,简称「复杂度分析」。 -复杂度分析体现算法运行所需的时间(空间)资源与输入数据大小之间的关系。**它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。 +复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。**它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。 - “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。 - “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。 diff --git a/docs/chapter_computational_complexity/space_complexity.md b/docs/chapter_computational_complexity/space_complexity.md index b745a4fcf..40e78675b 100755 --- a/docs/chapter_computational_complexity/space_complexity.md +++ b/docs/chapter_computational_complexity/space_complexity.md @@ -22,12 +22,14 @@ comments: true - **栈帧空间**:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。 - **指令空间**:用于保存编译后的程序指令,在实际统计中通常忽略不计。 -在分析一段程序的空间复杂度时,**我们通常统计暂存数据、栈帧空间和输出数据三部分**。 +在分析一段程序的空间复杂度时,**我们通常统计暂存数据、栈帧空间和输出数据三部分**,如图 2-15 所示。 { class="animation-figure" }图 2-15 算法使用的相关空间
+相关代码如下: + === "Python" ```python title="" @@ -327,8 +329,8 @@ comments: true 观察以下代码,最差空间复杂度中的“最差”有两层含义。 -1. **以最差输入数据为准**:当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但当 $n > 10$ 时,初始化的数组 `nums` 占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$ 。 -2. **以算法运行中的峰值内存为准**:例如,程序在执行最后一行之前,占用 $O(1)$ 空间;当初始化数组 `nums` 时,程序占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$ 。 +1. **以最差输入数据为准**:当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但当 $n > 10$ 时,初始化的数组 `nums` 占用 $O(n)$ 空间,因此最差空间复杂度为 $O(n)$ 。 +2. **以算法运行中的峰值内存为准**:例如,程序在执行最后一行之前,占用 $O(1)$ 空间;当初始化数组 `nums` 时,程序占用 $O(n)$ 空间,因此最差空间复杂度为 $O(n)$ 。 === "Python" @@ -465,10 +467,7 @@ comments: true ``` -**在递归函数中,需要注意统计栈帧空间**。例如在以下代码中: - -- 函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。 -- 递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。 +**在递归函数中,需要注意统计栈帧空间**。观察以下代码: === "Python" @@ -705,6 +704,11 @@ comments: true ``` +函数 `loop()` 和 `recur()` 的时间复杂度都为 $O(n)$ ,但空间复杂度不同。 + +- 函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。 +- 递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。 + ## 2.4.3 常见类型 设输入数据大小为 $n$ ,图 2-16 展示了常见的空间复杂度类型(从低到高排列)。 @@ -2020,14 +2024,14 @@ $$ ### 5. 对数阶 $O(\log n)$ -对数阶常见于分治算法。例如归并排序,输入长度为 $n$ 的数组,每轮递归将数组从中点划分为两半,形成高度为 $\log n$ 的递归树,使用 $O(\log n)$ 栈帧空间。 +对数阶常见于分治算法。例如归并排序,输入长度为 $n$ 的数组,每轮递归将数组从中点处划分为两半,形成高度为 $\log n$ 的递归树,使用 $O(\log n)$ 栈帧空间。 再例如将数字转化为字符串,输入一个正整数 $n$ ,它的位数为 $\log_{10} n + 1$ ,即对应字符串长度为 $\log_{10} n + 1$ ,因此空间复杂度为 $O(\log_{10} n + 1) = O(\log n)$ 。 ## 2.4.4 权衡时间与空间 -理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。 +理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。 **降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然**。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。 -选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也是非常重要的。 +选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。 diff --git a/docs/chapter_computational_complexity/summary.md b/docs/chapter_computational_complexity/summary.md index 22bb110f8..012ad1afe 100644 --- a/docs/chapter_computational_complexity/summary.md +++ b/docs/chapter_computational_complexity/summary.md @@ -10,43 +10,43 @@ comments: true - 时间效率和空间效率是衡量算法优劣的两个主要评价指标。 - 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。 -- 复杂度分析可以克服实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。 +- 复杂度分析可以消除实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。 **时间复杂度** - 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。 - 最差时间复杂度使用大 $O$ 符号表示,对应函数渐近上界,反映当 $n$ 趋向正无穷时,操作数量 $T(n)$ 的增长级别。 - 推算时间复杂度分为两步,首先统计操作数量,然后判断渐近上界。 -- 常见时间复杂度从小到大排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$ 和 $O(n!)$ 等。 +- 常见时间复杂度从低到高排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$ 和 $O(n!)$ 等。 - 某些算法的时间复杂度非固定,而是与输入数据的分布有关。时间复杂度分为最差、最佳、平均时间复杂度,最佳时间复杂度几乎不用,因为输入数据一般需要满足严格条件才能达到最佳情况。 - 平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。计算平均时间复杂度需要统计输入数据分布以及综合后的数学期望。 **空间复杂度** -- 空间复杂度的作用类似于时间复杂度,用于衡量算法占用空间随数据量增长的趋势。 -- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不计入空间复杂度计算。暂存空间可分为指令空间、数据空间、栈帧空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。 -- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时间点下的空间复杂度。 -- 常见空间复杂度从小到大排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$ 和 $O(2^n)$ 等。 +- 空间复杂度的作用类似于时间复杂度,用于衡量算法占用内存空间随数据量增长的趋势。 +- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不纳入空间复杂度计算。暂存空间可分为暂存数据、栈帧空间和指令空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。 +- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时刻下的空间复杂度。 +- 常见空间复杂度从低到高排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$ 和 $O(2^n)$ 等。 ### 2. Q & A !!! question "尾递归的空间复杂度是 $O(1)$ 吗?" - 理论上,尾递归函数的空间复杂度可以被优化至 $O(1)$ 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)都不支持自动优化尾递归,因此通常认为空间复杂度是 $O(n)$ 。 + 理论上,尾递归函数的空间复杂度可以优化至 $O(1)$ 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)不支持自动优化尾递归,因此通常认为空间复杂度是 $O(n)$ 。 !!! question "函数和方法这两个术语的区别是什么?" - 函数(function)可以被独立执行,所有参数都以显式传递。方法(method)与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。 + 「函数 function」可以被独立执行,所有参数都以显式传递。「方法 method」与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。 - 下面以几个常见的编程语言来说明。 + 下面以几种常见的编程语言为例来说明。 - - C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他语言中的方法。 - - Java 和 C# 是面向对象的编程语言,代码块(方法)通常都是作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。 + - C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他编程语言中的方法。 + - Java 和 C# 是面向对象的编程语言,代码块(方法)通常作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。 - C++ 和 Python 既支持过程式编程(函数),也支持面向对象编程(方法)。 -!!! question "图“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?" +!!! question "图解“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?" - 不是,该图片展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。 + 不是,该图展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。 假设取 $n = 8$ ,你可能会发现每条曲线的值与函数对应不上。这是因为每条曲线都包含一个常数项,用于将取值范围压缩到一个视觉舒适的范围内。 diff --git a/docs/chapter_computational_complexity/time_complexity.md b/docs/chapter_computational_complexity/time_complexity.md index c6a0d1ec2..f1041f509 100755 --- a/docs/chapter_computational_complexity/time_complexity.md +++ b/docs/chapter_computational_complexity/time_complexity.md @@ -4,7 +4,7 @@ comments: true # 2.3 时间复杂度 -运行时间可以直观且准确地反映算法的效率。如果我们想要准确预估一段代码的运行时间,应该如何操作呢? +运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢? 1. **确定运行平台**,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。 2. **评估各种计算操作所需的运行时间**,例如加法操作 `+` 需要 1 ns ,乘法操作 `*` 需要 10 ns ,打印操作 `print()` 需要 5 ns 等。 @@ -190,7 +190,7 @@ comments: true } ``` -根据以上方法,可以得到算法运行时间为 $(6n + 12)$ ns : +根据以上方法,可以得到算法的运行时间为 $(6n + 12)$ ns : $$ 1 + 1 + 10 + (1 + 5) \times n = 6n + 12 @@ -202,7 +202,7 @@ $$ 时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。 -“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法函数 `A`、`B` 和 `C` : +“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法 `A`、`B` 和 `C` : === "Python" @@ -466,10 +466,10 @@ $$图 2-7 算法 A、B 和 C 的时间增长趋势
-相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢? +相较于直接统计算法的运行时间,时间复杂度分析有哪些特点呢? -- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势所表达的含义。 -- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作的运行时间的统计”简化为“计算操作的数量的统计”,这样一来估算难度就大大降低了。 +- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。 +- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。 - **时间复杂度也存在一定的局限性**。例如,尽管算法 `A` 和 `C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。 ## 2.3.2 函数渐近上界 @@ -643,7 +643,7 @@ $$ } ``` -设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的的操作数量为: +设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的操作数量为: $$ T(n) = 3 + 2n @@ -653,7 +653,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因 我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 big-$O$ notation」,表示函数 $T(n)$ 的「渐近上界 asymptotic upper bound」。 -时间复杂度分析本质上是计算“操作数量函数 $T(n)$”的渐近上界,其具有明确的数学定义。 +时间复杂度分析本质上是计算“操作数量 $T(n)$”的渐近上界,它具有明确的数学定义。 !!! abstract "函数渐近上界" @@ -667,19 +667,19 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因 ## 2.3.3 推算方法 -渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。 +渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的实践中,就可以逐渐领悟其数学意义。 根据定义,确定 $f(n)$ 之后,我们便可得到时间复杂度 $O(f(n))$ 。那么如何确定渐近上界 $f(n)$ 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。 ### 1. 第一步:统计操作数量 -针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以被忽略**。根据此原则,可以总结出以下计数简化技巧。 +针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以忽略**。根据此原则,可以总结出以下计数简化技巧。 1. **忽略 $T(n)$ 中的常数项**。因为它们都与 $n$ 无关,所以对时间复杂度不产生影响。 2. **省略所有系数**。例如,循环 $2n$ 次、$5n + 1$ 次等,都可以简化记为 $n$ 次,因为 $n$ 前面的系数对时间复杂度没有影响。 3. **循环嵌套时使用乘法**。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 `1.` 点和第 `2.` 点的技巧。 -给定一个函数,我们可以用上述技巧来统计操作数量。 +给定一个函数,我们可以用上述技巧来统计操作数量: === "Python" @@ -909,7 +909,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因 } ``` -以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 $O(n^2)$ 。 +以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 $O(n^2)$ 。 $$ \begin{aligned} @@ -921,7 +921,7 @@ $$ ### 2. 第二步:判断渐近上界 -**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。 +**时间复杂度由 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。 表 2-2 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。 @@ -1447,7 +1447,7 @@ $$ ### 3. 平方阶 $O(n^2)$ -平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ : +平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 $O(n)$ ,因此总体的时间复杂度为 $O(n^2)$ : === "Python" @@ -1646,7 +1646,7 @@ $$图 2-10 常数阶、线性阶和平方阶的时间复杂度
-以冒泡排序为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1$、$n-2$、$\dots$、$2$、$1$ 次,平均为 $n / 2$ 次,因此时间复杂度为 $O((n - 1) n / 2) = O(n^2)$ 。 +以冒泡排序为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1$、$n-2$、$\dots$、$2$、$1$ 次,平均为 $n / 2$ 次,因此时间复杂度为 $O((n - 1) n / 2) = O(n^2)$ : === "Python" @@ -2283,13 +2283,13 @@ $$ } ``` -指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决。 +指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。 ### 5. 对数阶 $O(\log n)$ 与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。 -图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ 。 +图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ : === "Python" @@ -2464,7 +2464,7 @@ $$图 2-12 对数阶的时间复杂度
-与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 $\log_2 n$ 的递归树: +与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为 $\log_2 n$ 的递归树: === "Python" @@ -2599,7 +2599,7 @@ $$ !!! tip "$O(\log n)$ 的底数是多少?" - 准确来说,“一分为 $m$”对应的时间复杂度是 $O(\log_m n)$ 。而通过对数换底公式,我们可以得到具有不同底数的、相等的时间复杂度: + 准确来说,“一分为 $m$”对应的时间复杂度是 $O(\log_m n)$ 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度: $$ O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n) @@ -3358,12 +3358,12 @@ $$ 值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。 -从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。 +从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。 对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 $n / 2$ ,平均时间复杂度为 $\Theta(n / 2) = \Theta(n)$ 。 -但对于较为复杂的算法,计算平均时间复杂度往往是比较困难的,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。 +但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。 !!! question "为什么很少看到 $\Theta$ 符号?" - 可能由于 $O$ 符号过于朗朗上口,我们常常使用它来表示平均时间复杂度。但从严格意义上看,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。 + 可能由于 $O$ 符号过于朗朗上口,因此我们常常使用它来表示平均时间复杂度。但从严格意义上讲,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。 diff --git a/docs/chapter_data_structure/basic_data_types.md b/docs/chapter_data_structure/basic_data_types.md index 4ccc38476..fff30a55b 100644 --- a/docs/chapter_data_structure/basic_data_types.md +++ b/docs/chapter_data_structure/basic_data_types.md @@ -4,23 +4,23 @@ comments: true # 3.2 基本数据类型 -谈及计算机中的数据,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。 +当谈及计算机中的数据时,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。 -**基本数据类型是 CPU 可以直接进行运算的类型**,在算法中直接被使用,主要包括以下几种类型。 +**基本数据类型是 CPU 可以直接进行运算的类型**,在算法中直接被使用,主要包括以下几种。 - 整数类型 `byte`、`short`、`int`、`long` 。 - 浮点数类型 `float`、`double` ,用于表示小数。 -- 字符类型 `char` ,用于表示各种语言的字母、标点符号、甚至表情符号等。 +- 字符类型 `char` ,用于表示各种语言的字母、标点符号甚至表情符号等。 - 布尔类型 `bool` ,用于表示“是”与“否”判断。 -**基本数据类型以二进制的形式存储在计算机中**。一个二进制位即为 $1$ 比特。在绝大多数现代系统中,$1$ 字节(byte)由 $8$ 比特(bits)组成。 +**基本数据类型以二进制的形式存储在计算机中**。一个二进制位即为 $1$ 比特。在绝大多数现代操作系统中,$1$ 字节(byte)由 $8$ 比特(bit)组成。 基本数据类型的取值范围取决于其占用的空间大小。下面以 Java 为例。 - 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个数字。 - 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字。 -表 3-1 列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。 +表 3-1 列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须死记硬背,大致理解即可,需要时可以通过查表来回忆。表 3-1 基本数据类型的占用空间和取值范围
@@ -32,7 +32,7 @@ comments: true | | `short` | 2 bytes | $-2^{15}$ | $2^{15} - 1$ | $0$ | | | `int` | 4 bytes | $-2^{31}$ | $2^{31} - 1$ | $0$ | | | `long` | 8 bytes | $-2^{63}$ | $2^{63} - 1$ | $0$ | -| 浮点数 | `float` | 4 bytes | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0f$ | +| 浮点数 | `float` | 4 bytes | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ | | | `double` | 8 bytes | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | $0.0$ | | 字符 | `char` | 2 bytes | $0$ | $2^{16} - 1$ | $0$ | | 布尔 | `bool` | 1 byte | $\text{false}$ | $\text{true}$ | $\text{false}$ | @@ -44,11 +44,11 @@ comments: true - 在 Python 中,整数类型 `int` 可以是任意大小,只受限于可用内存;浮点数 `float` 是双精度 64 位;没有 `char` 类型,单个字符实际上是长度为 1 的字符串 `str` 。 - C 和 C++ 未明确规定基本数据类型大小,而因实现和平台各异。表 3-1 遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。 - 字符 `char` 的大小在 C 和 C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。 -- 即使表示布尔量仅需 1 位($0$ 或 $1$),它在内存中通常被存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。 +- 即使表示布尔量仅需 1 位($0$ 或 $1$),它在内存中通常存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。 -那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。它的主语是“结构”而非“数据”。 +那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。 -如果想要表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 `int`、小数 `float` 或是字符 `char` ,则与“数据结构”无关。 +如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 `int`、小数 `float` 或是字符 `char` ,则与“数据结构”无关。 换句话说,**基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”**。例如以下代码,我们用相同的数据结构(数组)来存储与表示不同的基本数据类型,包括 `int`、`float`、`char`、`bool` 等。 @@ -58,7 +58,7 @@ comments: true # 使用多种基本数据类型来初始化数组 numbers: list[int] = [0] * 5 decimals: list[float] = [0.0] * 5 - # Python 的字符应被看作长度为一的字符串 + # Python 的字符实际上是长度为 1 的字符串 characters: list[str] = ['0'] * 5 bools: list[bool] = [False] * 5 # Python 的列表可以自由存储各种基本数据类型和对象引用 diff --git a/docs/chapter_data_structure/character_encoding.md b/docs/chapter_data_structure/character_encoding.md index 27cf14ad3..e144f0491 100644 --- a/docs/chapter_data_structure/character_encoding.md +++ b/docs/chapter_data_structure/character_encoding.md @@ -8,7 +8,7 @@ comments: true ## 3.4.1 ASCII 字符集 -「ASCII 码」是最早出现的字符集,全称为“美国标准信息交换代码”。它使用 7 位二进制数(即一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。 +「ASCII 码」是最早出现的字符集,其全称为 American Standard Code for Information Interchange(美国标准信息交换代码)。它使用 7 位二进制数(一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如图 3-6 所示,ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号,以及一些控制字符(如换行符和制表符)。 { class="animation-figure" } @@ -20,42 +20,42 @@ comments: true ## 3.4.2 GBK 字符集 -后来人们发现,**EASCII 码仍然无法满足许多语言的字符数量要求**。比如汉字大约有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集,其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。 +后来人们发现,**EASCII 码仍然无法满足许多语言的字符数量要求**。比如汉字有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集,其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。 -然而,GB2312 无法处理部分的罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中,ASCII 字符使用一个字节表示,汉字使用两个字节表示。 +然而,GB2312 无法处理部分罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中,ASCII 字符使用一个字节表示,汉字使用两个字节表示。 ## 3.4.3 Unicode 字符集 -随着计算机的蓬勃发展,字符集与编码标准百花齐放,而这带来了许多问题。一方面,这些字符集一般只定义了特定语言的字符,无法在多语言环境下正常工作。另一方面,同一种语言也存在多种字符集标准,如果两台电脑安装的是不同的编码标准,则在信息传递时就会出现乱码。 +随着计算机技术的蓬勃发展,字符集与编码标准百花齐放,而这带来了许多问题。一方面,这些字符集一般只定义了特定语言的字符,无法在多语言环境下正常工作。另一方面,同一种语言存在多种字符集标准,如果两台计算机使用的是不同的编码标准,则在信息传递时就会出现乱码。 那个时代的研究人员就在想:**如果推出一个足够完整的字符集,将世界范围内的所有语言和符号都收录其中,不就可以解决跨语言环境和乱码问题了吗**?在这种想法的驱动下,一个大而全的字符集 Unicode 应运而生。 -「Unicode」的全称为“统一字符编码”,理论上能容纳一百多万个字符。它致力于将全球范围内的字符纳入到统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。 +「Unicode」的中文名称为“统一码”,理论上能容纳 100 多万个字符。它致力于将全球范围内的字符纳入统一的字符集之中,提供一种通用的字符集来处理和显示各种语言文字,减少因为编码标准不同而产生的乱码问题。 -自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截止 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号、甚至是表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占 3 字节甚至 4 字节。 +自 1991 年发布以来,Unicode 不断扩充新的语言与字符。截至 2022 年 9 月,Unicode 已经包含 149186 个字符,包括各种语言的字符、符号甚至表情符号等。在庞大的 Unicode 字符集中,常用的字符占用 2 字节,有些生僻的字符占用 3 字节甚至 4 字节。 -Unicode 是一种字符集标准,本质上是给每个字符分配一个编号(称为“码点”),**但它并没有规定在计算机中如何存储这些字符码点**。我们不禁会问:当多种长度的 Unicode 码点同时出现在同一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符? +Unicode 是一种通用字符集,本质上是给每个字符分配一个编号(称为“码点”),**但它并没有规定在计算机中如何存储这些字符码点**。我们不禁会问:当多种长度的 Unicode 码点同时出现在一个文本中时,系统如何解析字符?例如给定一个长度为 2 字节的编码,系统如何确认它是一个 2 字节的字符还是两个 1 字节的字符? -对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 ,将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复出这个短语的内容了。 +对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如图 3-7 所示,“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复这个短语的内容了。 { class="animation-figure" }图 3-7 Unicode 编码示例
-然而 ASCII 码已经向我们证明,编码英文只需要 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。 +然而 ASCII 码已经向我们证明,编码英文只需 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。 ## 3.4.4 UTF-8 编码 -目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。**它是一种可变长的编码**,使用 1 到 4 个字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需要 1 个字节,拉丁字母和希腊字母需要 2 个字节,常用的中文字符需要 3 个字节,其他的一些生僻字符需要 4 个字节。 +目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。**它是一种可变长度的编码**,使用 1 到 4 字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需 1 字节,拉丁字母和希腊字母需要 2 字节,常用的中文字符需要 3 字节,其他的一些生僻字符需要 4 字节。 UTF-8 的编码规则并不复杂,分为以下两种情况。 -- 对于长度为 1 字节的字符,将最高位设置为 $0$、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,**UTF-8 编码可以向下兼容 ASCII 码**。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。 -- 对于长度为 $n$ 字节的字符(其中 $n > 1$),将首个字节的高 $n$ 位都设置为 $1$、第 $n + 1$ 位设置为 $0$ ;从第二个字节开始,将每个字节的高 2 位都设置为 $10$ ;其余所有位用于填充字符的 Unicode 码点。 +- 对于长度为 1 字节的字符,将最高位设置为 $0$ ,其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,**UTF-8 编码可以向下兼容 ASCII 码**。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。 +- 对于长度为 $n$ 字节的字符(其中 $n > 1$),将首个字节的高 $n$ 位都设置为 $1$ ,第 $n + 1$ 位设置为 $0$ ;从第二个字节开始,将每个字节的高 2 位都设置为 $10$ ;其余所有位用于填充字符的 Unicode 码点。 -图 3-8 展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都被设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。 +图 3-8 展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。 -但为什么要将其余所有字节的高 2 位都设置为 $10$ 呢?实际上,这个 $10$ 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 $10$ 能够帮助系统快速的判断出异常。 +但为什么要将其余所有字节的高 2 位都设置为 $10$ 呢?实际上,这个 $10$ 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 $10$ 能够帮助系统快速判断出异常。 之所以将 $10$ 当作校验符,是因为在 UTF-8 编码规则下,不可能有字符的最高两位是 $10$ 。这个结论可以用反证法来证明:假设一个字符的最高两位是 $10$ ,说明该字符的长度为 $1$ ,对应 ASCII 码。而 ASCII 码的最高位应该是 $0$ ,与假设矛盾。 @@ -65,28 +65,28 @@ UTF-8 的编码规则并不复杂,分为以下两种情况。 除了 UTF-8 之外,常见的编码方式还包括以下两种。 -- **UTF-16 编码**:使用 2 或 4 个字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 个字节表示;少数字符需要用到 4 个字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。 -- **UTF-32 编码**:每个字符都使用 4 个字节。这意味着 UTF-32 会比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。 +- **UTF-16 编码**:使用 2 或 4 字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 字节表示;少数字符需要用到 4 字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。 +- **UTF-32 编码**:每个字符都使用 4 字节。这意味着 UTF-32 比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。 -从存储空间的角度看,使用 UTF-8 表示英文字符非常高效,因为它仅需 1 个字节;使用 UTF-16 编码某些非英文字符(例如中文)会更加高效,因为它只需要 2 个字节,而 UTF-8 可能需要 3 个字节。 +从存储空间占用的角度看,使用 UTF-8 表示英文字符非常高效,因为它仅需 1 字节;使用 UTF-16 编码某些非英文字符(例如中文)会更加高效,因为它仅需 2 字节,而 UTF-8 可能需要 3 字节。 -从兼容性的角度看,UTF-8 的通用性最佳,许多工具和库都优先支持 UTF-8 。 +从兼容性的角度看,UTF-8 的通用性最佳,许多工具和库优先支持 UTF-8 。 ## 3.4.5 编程语言的字符编码 对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长的编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。 -- **随机访问**: UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要找到第 $i$ 个字符,我们需要从字符串的开始处遍历到第 $i$ 个字符,这需要 $O(n)$ 的时间。 -- **字符计数**: 与随机访问类似,计算 UTF-16 字符串的长度也是 $O(1)$ 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。 -- **字符串操作**: 在 UTF-16 编码的字符串中,很多字符串操作(如分割、连接、插入、删除等)都更容易进行。在 UTF-8 编码的字符串上进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。 +- **随机访问**:UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要想找到第 $i$ 个字符,我们需要从字符串的开始处遍历到第 $i$ 个字符,这需要 $O(n)$ 的时间。 +- **字符计数**:与随机访问类似,计算 UTF-16 编码的字符串的长度也是 $O(1)$ 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。 +- **字符串操作**:在 UTF-16 编码的字符串上,很多字符串操作(如分割、连接、插入、删除等)更容易进行。在 UTF-8 编码的字符串上,进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。 -实际上,编程语言的字符编码方案设计是一个很有趣的话题,其涉及到许多因素。 +实际上,编程语言的字符编码方案设计是一个很有趣的话题,涉及许多因素。 - Java 的 `String` 类型使用 UTF-16 编码,每个字符占用 2 字节。这是因为 Java 语言设计之初,人们认为 16 位足以表示所有可能的字符。然而,这是一个不正确的判断。后来 Unicode 规范扩展到了超过 16 位,所以 Java 中的字符现在可能由一对 16 位的值(称为“代理对”)表示。 -- JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 JavaScript 语言在 1995 年被 Netscape 公司首次引入时,Unicode 还处于相对早期的阶段,那时候使用 16 位的编码就足够表示所有的 Unicode 字符了。 -- C# 使用 UTF-16 编码,主要因为 .NET 平台是由 Microsoft 设计的,而 Microsoft 的很多技术,包括 Windows 操作系统,都广泛地使用 UTF-16 编码。 +- JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 1995 年 Netscape 公司首次推出 JavaScript 语言时,Unicode 还处于发展早期,那时候使用 16 位的编码就足以表示所有的 Unicode 字符了。 +- C# 使用 UTF-16 编码,主要是因为 .NET 平台是由 Microsoft 设计的,而 Microsoft 的很多技术(包括 Windows 操作系统)都广泛使用 UTF-16 编码。 -由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这增加了编程的复杂性和 Debug 难度。 +由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这提高了编程的复杂性和调试难度。 出于以上原因,部分编程语言提出了一些不同的编码方案。 @@ -94,4 +94,4 @@ UTF-8 的编码规则并不复杂,分为以下两种情况。 - Go 语言的 `string` 类型在内部使用 UTF-8 编码。Go 语言还提供了 `rune` 类型,它用于表示单个 Unicode 码点。 - Rust 语言的 str 和 String 类型在内部使用 UTF-8 编码。Rust 也提供了 `char` 类型,用于表示单个 Unicode 码点。 -需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,**这和字符串如何在文件中存储或在网络中传输是两个不同的问题**。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。 +需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,**这和字符串如何在文件中存储或在网络中传输是不同的问题**。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。 diff --git a/docs/chapter_data_structure/classification_of_data_structure.md b/docs/chapter_data_structure/classification_of_data_structure.md index 953689b81..9d8f9599b 100644 --- a/docs/chapter_data_structure/classification_of_data_structure.md +++ b/docs/chapter_data_structure/classification_of_data_structure.md @@ -8,18 +8,18 @@ comments: true ## 3.1.1 逻辑结构:线性与非线性 -**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。 +**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照一定顺序排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出“祖先”与“后代”之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。 -如图 3-1 所示,逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。 +如图 3-1 所示,逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。 - **线性数据结构**:数组、链表、栈、队列、哈希表。 - **非线性数据结构**:树、堆、图、哈希表。 -{ class="animation-figure" } +{ class="animation-figure" } -图 3-1 线性与非线性数据结构
+图 3-1 线性数据结构与非线性数据结构
-非线性数据结构可以进一步被划分为树形结构和网状结构。 +非线性数据结构可以进一步划分为树形结构和网状结构。 - **线性结构**:数组、链表、队列、栈、哈希表,元素之间是一对一的顺序关系。 - **树形结构**:树、堆、哈希表,元素之间是一对多的关系。 @@ -35,13 +35,13 @@ comments: true图 3-2 内存条、内存空间、内存地址
-!!! note +!!! tip 值得说明的是,将内存比作 Excel 表格是一个简化的类比,实际内存的工作机制比较复杂,涉及到地址空间、内存管理、缓存机制、虚拟和物理内存等概念。 内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。**因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素**。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。 -如图 3-3 所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出互补的特点。 +如图 3-3 所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,两种物理结构在时间效率和空间效率方面呈现出互补的特点。 { class="animation-figure" } @@ -52,8 +52,8 @@ comments: true - **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等。 - **基于链表可实现**:栈、队列、哈希表、树、堆、图等。 -基于数组实现的数据结构也被称为“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构被称为“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。 +基于数组实现的数据结构也称“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构称“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。 !!! tip - 如果你感觉物理结构理解起来有困难,建议先阅读下一章“数组与链表”,然后再回顾本节内容。 + 如果你感觉物理结构理解起来有困难,建议先阅读下一章,然后再回顾本节内容。 diff --git a/docs/chapter_data_structure/index.md b/docs/chapter_data_structure/index.md index 30e69e750..79382f37c 100644 --- a/docs/chapter_data_structure/index.md +++ b/docs/chapter_data_structure/index.md @@ -15,7 +15,7 @@ icon: material/shape-outline 数据结构如同一副稳固而多样的框架。 - 它为数据的有序组织提供了蓝图,使算法得以在此基础上生动起来。 + 它为数据的有序组织提供了蓝图,算法得以在此基础上生动起来。 ## 本章内容 diff --git a/docs/chapter_data_structure/number_encoding.md b/docs/chapter_data_structure/number_encoding.md index bdbca117b..03f5488b0 100644 --- a/docs/chapter_data_structure/number_encoding.md +++ b/docs/chapter_data_structure/number_encoding.md @@ -6,13 +6,13 @@ comments: true !!! note - 在本书中,标题带有的 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。 + 在本书中,标题带有 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。 ## 3.3.1 整数编码 -在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。 +在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。 -首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,我们首先给出三者的定义。 +首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,首先给出三者的定义。 - **原码**:我们将数字的二进制表示的最高位视为符号位,其中 $0$ 表示正数,$1$ 表示负数,其余位表示数字的值。 - **反码**:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。 @@ -35,7 +35,7 @@ $$ \end{aligned} $$ -为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码,并在反码下计算 $1 + (-2)$ ,最后将结果从反码转化回原码,则可得到正确结果 $-1$ 。 +为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码,并在反码下计算 $1 + (-2)$ ,最后将结果从反码转换回原码,则可得到正确结果 $-1$ 。 $$ \begin{aligned} @@ -48,7 +48,7 @@ $$ \end{aligned} $$ -另一方面,**数字零的原码有 $+0$ 和 $-0$ 两种表示方式**。这意味着数字零对应着两个不同的二进制编码,其可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想要处理正零和负零歧义,则需要引入额外的判断操作,其可能会降低计算机的运算效率。 +另一方面,**数字零的原码有 $+0$ 和 $-0$ 两种表示方式**。这意味着数字零对应两个不同的二进制编码,这可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想处理正零和负零歧义,则需要引入额外的判断操作,这可能会降低计算机的运算效率。 $$ \begin{aligned} @@ -69,7 +69,7 @@ $$ 在负零的反码基础上加 $1$ 会产生进位,但 `byte` 类型的长度只有 8 位,因此溢出到第 9 位的 $1$ 会被舍弃。也就是说,**负零的补码为 $0000 \; 0000$ ,与正零的补码相同**。这意味着在补码表示中只存在一个零,正负零歧义从而得到解决。 -还剩余最后一个疑惑:`byte` 类型的取值范围是 $[-128, 127]$ ,多出来的一个负数 $-128$ 是如何得到的呢?我们注意到,区间 $[-127, +127]$ 内的所有整数都有对应的原码、反码和补码,并且原码和补码之间是可以互相转换的。 +还剩最后一个疑惑:`byte` 类型的取值范围是 $[-128, 127]$ ,多出来的一个负数 $-128$ 是如何得到的呢?我们注意到,区间 $[-127, +127]$ 内的所有整数都有对应的原码、反码和补码,并且原码和补码之间可以互相转换。 然而,**补码 $1000 \; 0000$ 是一个例外,它并没有对应的原码**。根据转换方法,我们得到该补码的原码为 $0000 \; 0000$ 。这显然是矛盾的,因为该原码表示数字 $0$ ,它的补码应该是自身。计算机规定这个特殊的补码 $1000 \; 0000$ 代表 $-128$ 。实际上,$(-1) + (-127)$ 在补码下的计算结果就是 $-128$ 。 @@ -84,13 +84,13 @@ $$ \end{aligned} $$ -你可能已经发现,上述的所有计算都是加法运算。这暗示着一个重要事实:**计算机内部的硬件电路主要是基于加法运算设计的**。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。 +你可能已经发现了,上述所有计算都是加法运算。这暗示着一个重要事实:**计算机内部的硬件电路主要是基于加法运算设计的**。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。 请注意,这并不意味着计算机只能做加法。**通过将加法与一些基本逻辑运算结合,计算机能够实现各种其他的数学运算**。例如,计算减法 $a - b$ 可以转换为计算加法 $a + (-b)$ ;计算乘法和除法可以转换为计算多次加法或减法。 现在我们可以总结出计算机使用补码的原因:基于补码表示,计算机可以用同样的电路和操作来处理正数和负数的加法,不需要设计特殊的硬件电路来处理减法,并且无须特别处理正负零的歧义问题。这大大简化了硬件设计,提高了运算效率。 -补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深度了解。 +补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深入了解。 ## 3.3.2 浮点数编码 @@ -108,19 +108,19 @@ $$ - 指数位 $\mathrm{E}$ :占 8 bits ,对应 $b_{30} b_{29} \ldots b_{23}$ 。 - 分数位 $\mathrm{N}$ :占 23 bits ,对应 $b_{22} b_{21} \ldots b_0$ 。 -二进制数 `float` 对应的值的计算方法: +二进制数 `float` 对应值的计算方法为: $$ \text {val} = (-1)^{b_{31}} \times 2^{\left(b_{30} b_{29} \ldots b_{23}\right)_2-127} \times\left(1 . b_{22} b_{21} \ldots b_0\right)_2 $$ -转化到十进制下的计算公式: +转化到十进制下的计算公式为: $$ \text {val}=(-1)^{\mathrm{S}} \times 2^{\mathrm{E} -127} \times (1 + \mathrm{N}) $$ -其中各项的取值范围: +其中各项的取值范围为: $$ \begin{aligned} @@ -159,4 +159,4 @@ $$ 值得说明的是,次正规数显著提升了浮点数的精度。最小正正规数为 $2^{-126}$ ,最小正次正规数为 $2^{-126} \times 2^{-23}$ 。 -双精度 `double` 也采用类似 `float` 的表示方法,在此不做赘述。 +双精度 `double` 也采用类似于 `float` 的表示方法,在此不做赘述。 diff --git a/docs/chapter_data_structure/summary.md b/docs/chapter_data_structure/summary.md index 37a9d327e..abaeb8261 100644 --- a/docs/chapter_data_structure/summary.md +++ b/docs/chapter_data_structure/summary.md @@ -7,13 +7,13 @@ comments: true ### 1. 重点回顾 - 数据结构可以从逻辑结构和物理结构两个角度进行分类。逻辑结构描述了数据元素之间的逻辑关系,而物理结构描述了数据在计算机内存中的存储方式。 -- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性和非线性结构。 +- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性数据结构和非线性数据结构。 - 当程序运行时,数据被存储在计算机内存中。每个内存空间都拥有对应的内存地址,程序通过这些内存地址访问数据。 - 物理结构主要分为连续空间存储(数组)和分散空间存储(链表)。所有数据结构都是由数组、链表或两者的组合实现的。 - 计算机中的基本数据类型包括整数 `byte`、`short`、`int`、`long` ,浮点数 `float`、`double` ,字符 `char` 和布尔 `boolean` 。它们的取值范围取决于占用空间大小和表示方式。 -- 原码、反码和补码是在计算机中编码数字的三种方法,它们之间是可以相互转换的。整数的原码的最高位是符号位,其余位是数字的值。 +- 原码、反码和补码是在计算机中编码数字的三种方法,它们之间可以相互转换。整数的原码的最高位是符号位,其余位是数字的值。 - 整数在计算机中是以补码的形式存储的。在补码表示下,计算机可以对正数和负数的加法一视同仁,不需要为减法操作单独设计特殊的硬件电路,并且不存在正负零歧义的问题。 -- 浮点数的编码由 1 位符号位、8 位指数位和 23 位分数位构成。由于存在指数位,浮点数的取值范围远大于整数,代价是牺牲了精度。 +- 浮点数的编码由 1 位符号位、8 位指数位和 23 位分数位构成。由于存在指数位,因此浮点数的取值范围远大于整数,代价是牺牲了精度。 - ASCII 码是最早出现的英文字符集,长度为 1 字节,共收录 127 个字符。GBK 字符集是常用的中文字符集,共收录两万多个汉字。Unicode 致力于提供一个完整的字符集标准,收录世界内各种语言的字符,从而解决由于字符编码方法不一致而导致的乱码问题。 - UTF-8 是最受欢迎的 Unicode 编码方法,通用性非常好。它是一种变长的编码方法,具有很好的扩展性,有效提升了存储空间的使用效率。UTF-16 和 UTF-32 是等长的编码方法。在编码中文时,UTF-16 比 UTF-8 的占用空间更小。Java 和 C# 等编程语言默认使用 UTF-16 编码。 @@ -26,7 +26,7 @@ comments: true !!! question "`char` 类型的长度是 1 byte 吗?" - `char` 类型的长度由编程语言采用的编码方法决定。例如,Java、JS、TS、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。 + `char` 类型的长度由编程语言采用的编码方法决定。例如,Java、JavaScript、TypeScript、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。 !!! question "基于数组实现的数据结构也被称为“静态数据结构” 是否有歧义?因为栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。" diff --git a/docs/chapter_divide_and_conquer/binary_search_recur.md b/docs/chapter_divide_and_conquer/binary_search_recur.md index d1b898363..5d9847bf4 100644 --- a/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -7,36 +7,36 @@ comments: true 我们已经学过,搜索算法分为两大类。 - **暴力搜索**:它通过遍历数据结构实现,时间复杂度为 $O(n)$ 。 -- **自适应搜索**:它利用特有的数据组织形式或先验信息,可达到 $O(\log n)$ 甚至 $O(1)$ 的时间复杂度。 +- **自适应搜索**:它利用特有的数据组织形式或先验信息,时间复杂度可达到 $O(\log n)$ 甚至 $O(1)$ 。 -实际上,**时间复杂度为 $O(\log n)$ 的搜索算法通常都是基于分治策略实现的**,例如二分查找和树。 +实际上,**时间复杂度为 $O(\log n)$ 的搜索算法通常是基于分治策略实现的**,例如二分查找和树。 - 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。 -- 树是分治关系的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 $O(\log n)$ 。 +- 树是分治思想的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 $O(\log n)$ 。 二分查找的分治策略如下所示。 -- **问题可以被分解**:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。 -- **子问题是独立的**:在二分查找中,每轮只处理一个子问题,它不受另外子问题的影响。 +- **问题可以分解**:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。 +- **子问题是独立的**:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。 - **子问题的解无须合并**:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。 分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,**而分治搜索每轮可以排除一半选项**。 -### 1. 基于分治实现二分 +### 1. 基于分治实现二分查找 在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。 !!! question - 给定一个长度为 $n$ 的有序数组 `nums` ,数组中所有元素都是唯一的,请查找元素 `target` 。 + 给定一个长度为 $n$ 的有序数组 `nums` ,其中所有元素都是唯一的,请查找元素 `target` 。 从分治角度,我们将搜索区间 $[i, j]$ 对应的子问题记为 $f(i, j)$ 。 -从原问题 $f(0, n-1)$ 为起始点,通过以下步骤进行二分查找。 +以原问题 $f(0, n-1)$ 为起始点,通过以下步骤进行二分查找。 1. 计算搜索区间 $[i, j]$ 的中点 $m$ ,根据它排除一半搜索区间。 2. 递归求解规模减小一半的子问题,可能为 $f(i, m-1)$ 或 $f(m+1, j)$ 。 -3. 循环第 `1.` 和 `2.` 步,直至找到 `target` 或区间为空时返回。 +3. 循环第 `1.` 步和第 `2.` 步,直至找到 `target` 或区间为空时返回。 图 12-4 展示了在数组中二分查找元素 $6$ 的分治过程。 @@ -44,7 +44,7 @@ comments: true图 12-4 二分查找的分治过程
-在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$ 。 +在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$ : === "Python" diff --git a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index 2f84a9b6a..cc366a9ac 100644 --- a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -6,7 +6,7 @@ comments: true !!! question - 给定一个二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。 + 给定一棵二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。 { class="animation-figure" } @@ -14,17 +14,17 @@ comments: true ### 1. 判断是否为分治问题 -原问题定义为从 `preorder` 和 `inorder` 构建二叉树,其是一个典型的分治问题。 +原问题定义为从 `preorder` 和 `inorder` 构建二叉树,是一个典型的分治问题。 -- **问题可以被分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。 -- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。 +- **问题可以分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。 +- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。 - **子问题的解可以合并**:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。 ### 2. 如何划分子树 -根据以上分析,这道题是可以使用分治来求解的,**但如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**? +根据以上分析,这道题可以使用分治来求解,**但如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**? -根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分。 +根据定义,`preorder` 和 `inorder` 都可以划分为三个部分。 - 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如图 12-5 的树对应 `[ 3 | 9 | 2 1 7 ]` 。 - 中序遍历:`[ 左子树 | 根节点 | 右子树 ]` ,例如图 12-5 的树对应 `[ 9 | 3 | 1 2 7 ]` 。 @@ -35,9 +35,9 @@ comments: true 2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 | 1 2 7 ]` 。 3. 根据 `inorder` 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]` 。 -{ class="animation-figure" } +{ class="animation-figure" } -图 12-6 在前序和中序遍历中划分子树
+图 12-6 在前序遍历和中序遍历中划分子树
### 3. 基于变量描述子树区间 @@ -49,7 +49,7 @@ comments: true 如表 12-1 所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。 -表 12-1 根节点和子树在前序和中序遍历中的索引
+表 12-1 根节点和子树在前序遍历和中序遍历中的索引
图 12-10 汉诺塔问题示例
-**我们将规模为 $i$ 的汉诺塔问题记做 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。 +**我们将规模为 $i$ 的汉诺塔问题记作 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。 ### 1. 考虑基本情况 @@ -58,11 +58,11 @@ comments: true 对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。 -因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行图 12-13 所示的步骤。这样三个圆盘就被顺利地从 `A` 移动至 `C` 了。 +因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看作一个整体**,执行图 12-13 所示的步骤。这样三个圆盘就被顺利地从 `A` 移至 `C` 了。 -1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移动至 `B` 。 +1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移至 `B` 。 2. 将 `A` 中剩余的一个圆盘从 `A` 直接移动至 `C` 。 -3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移动至 `C` 。 +3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移至 `C` 。 === "<1>" { class="animation-figure" } @@ -78,9 +78,9 @@ comments: true图 12-13 规模为 3 问题的解
-本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。 +从本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。 -至此,我们可总结出图 12-14 所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。 +至此,我们可总结出图 12-14 所示的解决汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。 1. 将 $n-1$ 个圆盘借助 `C` 从 `A` 移至 `B` 。 2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C` 。 @@ -88,13 +88,13 @@ comments: true 对于这两个子问题 $f(n-1)$ ,**可以通过相同的方式进行递归划分**,直至达到最小子问题 $f(1)$ 。而 $f(1)$ 的解是已知的,只需一次移动操作即可。 -{ class="animation-figure" } +{ class="animation-figure" } -图 12-14 汉诺塔问题的分治策略
+图 12-14 解决汉诺塔问题的分治策略
### 3. 代码实现 -在代码中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar` 。 +在代码中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar` : === "Python" @@ -107,7 +107,7 @@ comments: true tar.append(pan) def dfs(i: int, src: list[int], buf: list[int], tar: list[int]): - """求解汉诺塔:问题 f(i)""" + """求解汉诺塔问题 f(i)""" # 若 src 只剩下一个圆盘,则直接将其移到 tar if i == 1: move(src, tar) @@ -120,7 +120,7 @@ comments: true dfs(i - 1, buf, src, tar) def solve_hanota(A: list[int], B: list[int], C: list[int]): - """求解汉诺塔""" + """求解汉诺塔问题""" n = len(A) # 将 A 顶部 n 个圆盘借助 B 移到 C dfs(n, A, B, C) @@ -138,7 +138,7 @@ comments: true tar.push_back(pan); } - /* 求解汉诺塔:问题 f(i) */ + /* 求解汉诺塔问题 f(i) */ void dfs(int i, vector图 14-7 爬楼梯最小代价的动态规划过程
-本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降低至 $O(1)$ 。 +本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降至 $O(1)$ : === "Python" @@ -536,27 +536,27 @@ $$ ## 14.2.2 无后效性 -无后效性是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。 +无后效性是动态规划能够有效解决问题的重要特性之一,其定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关**。 以爬楼梯问题为例,给定状态 $i$ ,它会发展出状态 $i+1$ 和状态 $i+2$ ,分别对应跳 $1$ 步和跳 $2$ 步。在做出这两种选择时,我们无须考虑状态 $i$ 之前的状态,它们对状态 $i$ 的未来没有影响。 -然而,如果我们向爬楼梯问题添加一个约束,情况就不一样了。 +然而,如果我们给爬楼梯问题添加一个约束,情况就不一样了。 !!! question "带约束爬楼梯" - 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,**但不能连续两轮跳 $1$ 阶**,请问有多少种方案可以爬到楼顶。 + 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,**但不能连续两轮跳 $1$ 阶**,请问有多少种方案可以爬到楼顶? -例如图 14-8 ,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。 +如图 14-8 所示,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。 { class="animation-figure" }图 14-8 带约束爬到第 3 阶的方案数量
-在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关**。 +在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上轮所在楼梯阶数)有关**。 -不难发现,此问题已不满足无后效性,状态转移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因为 $dp[i-1]$ 代表本轮跳 $1$ 阶,但其中包含了许多“上一轮跳 $1$ 阶上来的”方案,而为了满足约束,我们就不能将 $dp[i-1]$ 直接计入 $dp[i]$ 中。 +不难发现,此问题已不满足无后效性,状态转移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因为 $dp[i-1]$ 代表本轮跳 $1$ 阶,但其中包含了许多“上一轮是跳 $1$ 阶上来的”方案,而为了满足约束,我们就不能将 $dp[i-1]$ 直接计入 $dp[i]$ 中。 -为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来判断当前状态是从何而来的。 +为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来判断当前状态是从何而来的。 - 当上一轮跳了 $1$ 阶时,上上一轮只能选择跳 $2$ 阶,即 $dp[i, 1]$ 只能从 $dp[i-1, 2]$ 转移过来。 - 当上一轮跳了 $2$ 阶时,上上一轮可选择跳 $1$ 阶或跳 $2$ 阶,即 $dp[i, 2]$ 可以从 $dp[i-2, 1]$ 或 $dp[i-2, 2]$ 转移过来。 @@ -574,7 +574,7 @@ $$图 14-9 考虑约束下的递推关系
-最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数。 +最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数: === "Python" @@ -866,12 +866,12 @@ $$ } ``` -在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。 +在上面的案例中,由于仅需多考虑前面一个状态,因此我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。 !!! question "爬楼梯与障碍生成" - 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会给第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2$、$3$ 阶上,则之后就不能跳到第 $4$、$6$ 阶上。请问有多少种方案可以爬到楼顶。 + 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会在第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2$、$3$ 阶上,则之后就不能跳到第 $4$、$6$ 阶上。请问有多少种方案可以爬到楼顶? -在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。 +在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。 -实际上,许多复杂的组合优化问题(例如旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。 +实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。 diff --git a/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/docs/chapter_dynamic_programming/dp_solution_pipeline.md index 675cc917a..50cbb595a 100644 --- a/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -11,7 +11,7 @@ comments: true ## 14.3.1 问题判断 -总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解。然而,我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。 +总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述中直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。 **适合用回溯解决的问题通常满足“决策树模型”**,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。 @@ -47,7 +47,7 @@ comments: true **第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表** -本题的每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为 $[i, j]$ ,则向下或向右走一步后,索引变为 $[i+1, j]$ 或 $[i, j+1]$ 。因此,状态应包含行索引和列索引两个变量,记为 $[i, j]$ 。 +本题的每一轮的决策就是从当前格子向下或向右走一步。设当前格子的行列索引为 $[i, j]$ ,则向下或向右走一步后,索引变为 $[i+1, j]$ 或 $[i, j+1]$ 。因此,状态应包含行索引和列索引两个变量,记为 $[i, j]$ 。 状态 $[i, j]$ 对应的子问题为:从起始点 $[0, 0]$ 走到 $[i, j]$ 的最小路径和,解记为 $dp[i, j]$ 。 @@ -59,13 +59,13 @@ comments: true !!! note - 动态规划和回溯过程可以被描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。 + 动态规划和回溯过程可以描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。 - 每个状态都对应一个子问题,我们会定义一个 $dp$ 表来存储所有子问题的解,状态的每个独立变量都是 $dp$ 表的一个维度。本质上看,$dp$ 表是状态和子问题的解之间的映射。 + 每个状态都对应一个子问题,我们会定义一个 $dp$ 表来存储所有子问题的解,状态的每个独立变量都是 $dp$ 表的一个维度。从本质上看,$dp$ 表是状态和子问题的解之间的映射。 **第二步:找出最优子结构,进而推导出状态转移方程** -对于状态 $[i, j]$ ,它只能从上边格子 $[i-1, j]$ 和左边格子 $[i, j-1]$ 转移而来。因此最优子结构为:到达 $[i, j]$ 的最小路径和由 $[i, j-1]$ 的最小路径和与 $[i-1, j]$ 的最小路径和,这两者较小的那一个决定。 +对于状态 $[i, j]$ ,它只能从上边格子 $[i-1, j]$ 和左边格子 $[i, j-1]$ 转移而来。因此最优子结构为:到达 $[i, j]$ 的最小路径和由 $[i, j-1]$ 的最小路径和与 $[i-1, j]$ 的最小路径和中较小的那一个决定。 根据以上分析,可推出图 14-12 所示的状态转移方程: @@ -85,9 +85,9 @@ $$ **第三步:确定边界条件和状态转移顺序** -在本题中,首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行 $i = 0$ 和首列 $j = 0$ 是边界条件。 +在本题中,处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行 $i = 0$ 和首列 $j = 0$ 是边界条件。 -如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。 +如图 14-13 所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵,外循环遍历各行,内循环遍历各列。 { class="animation-figure" } @@ -110,6 +110,8 @@ $$ - **终止条件**:当 $i = 0$ 且 $j = 0$ 时,返回代价 $grid[0, 0]$ 。 - **剪枝**:当 $i < 0$ 时或 $j < 0$ 时索引越界,此时返回代价 $+\infty$ ,代表不可行。 +实现代码如下: + === "Python" ```python title="min_path_sum.py" @@ -366,17 +368,17 @@ $$ 图 14-14 给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。 -本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。 +从本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。 { class="animation-figure" }图 14-14 暴力搜索递归树
-每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。 +每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择,因此实际的路径数量会少一些。 ### 2. 方法二:记忆化搜索 -我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,并将重叠子问题进行剪枝。 +我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,并将重叠子问题进行剪枝: === "Python" @@ -703,7 +705,7 @@ $$ ### 3. 方法三:动态规划 -基于迭代实现动态规划解法。 +基于迭代实现动态规划解法,代码如下所示: === "Python" @@ -720,7 +722,7 @@ $$ # 状态转移:首列 for i in range(1, n): dp[i][0] = dp[i - 1][0] + grid[i][0] - # 状态转移:其余行列 + # 状态转移:其余行和列 for i in range(1, n): for j in range(1, m): dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j] @@ -744,7 +746,7 @@ $$ for (int i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i < n; i++) { for (int j = 1; j < m; j++) { dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -771,7 +773,7 @@ $$ for (int i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i < n; i++) { for (int j = 1; j < m; j++) { dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -798,7 +800,7 @@ $$ for (int i = 1; i < n; i++) { dp[i, 0] = dp[i - 1, 0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i < n; i++) { for (int j = 1; j < m; j++) { dp[i, j] = Math.Min(dp[i, j - 1], dp[i - 1, j]) + grid[i][j]; @@ -828,7 +830,7 @@ $$ for i := 1; i < n; i++ { dp[i][0] = dp[i-1][0] + grid[i][0] } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i := 1; i < n; i++ { for j := 1; j < m; j++ { dp[i][j] = int(math.Min(float64(dp[i][j-1]), float64(dp[i-1][j]))) + grid[i][j] @@ -856,7 +858,7 @@ $$ for i in stride(from: 1, to: n, by: 1) { dp[i][0] = dp[i - 1][0] + grid[i][0] } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in stride(from: 1, to: n, by: 1) { for j in stride(from: 1, to: m, by: 1) { dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j] @@ -886,7 +888,7 @@ $$ for (let i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i < n; i++) { for (let j = 1; j < m; j++) { dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -916,7 +918,7 @@ $$ for (let i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i < n; i++) { for (let j: number = 1; j < m; j++) { dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -943,7 +945,7 @@ $$ for (int i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i < n; i++) { for (int j = 1; j < m; j++) { dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -970,7 +972,7 @@ $$ for i in 1..n { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in 1..n { for j in 1..m { dp[i][j] = std::cmp::min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -999,7 +1001,7 @@ $$ for (int i = 1; i < n; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i < n; i++) { for (int j = 1; j < m; j++) { dp[i][j] = myMin(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -1032,7 +1034,7 @@ $$ for (1..n) |i| { dp[i][0] = dp[i - 1][0] + grid[i][0]; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (1..n) |i| { for (1..m) |j| { dp[i][j] = @min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; @@ -1088,7 +1090,7 @@ $$ 由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 $dp$ 表。 -请注意,因为数组 `dp` 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行中更新它。 +请注意,因为数组 `dp` 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行时更新它: === "Python" @@ -1203,7 +1205,7 @@ $$ for j := 1; j < m; j++ { dp[j] = dp[j-1] + grid[0][j] } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i := 1; i < n; i++ { // 状态转移:首列 dp[0] = dp[0] + grid[i][0] diff --git a/docs/chapter_dynamic_programming/edit_distance_problem.md b/docs/chapter_dynamic_programming/edit_distance_problem.md index 5a13df283..5f320bbdf 100644 --- a/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -4,13 +4,13 @@ comments: true # 14.6 编辑距离问题 -编辑距离,也被称为 Levenshtein 距离,指两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。 +编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。 !!! question 输入两个字符串 $s$ 和 $t$ ,返回将 $s$ 转换为 $t$ 所需的最少编辑步数。 - 你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。 + 你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、将字符替换为任意一个字符。 如图 14-27 所示,将 `kitten` 转换为 `sitting` 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 `hello` 转换为 `algo` 需要 3 步,包括 2 次替换操作和 1 次删除操作。 @@ -39,7 +39,7 @@ comments: true - 若 $s[n-1]$ 和 $t[m-1]$ 相同,我们可以跳过它们,直接考虑 $s[n-2]$ 和 $t[m-2]$ 。 - 若 $s[n-1]$ 和 $t[m-1]$ 不同,我们需要对 $s$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。 -也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ 和 $t$ 中考虑的第 $i$ 和 $j$ 个字符,记为 $[i, j]$ 。 +也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ 和 $t$ 中考虑的第 $i$ 和第 $j$ 个字符,记为 $[i, j]$ 。 状态 $[i, j]$ 对应的子问题:**将 $s$ 的前 $i$ 个字符更改为 $t$ 的前 $j$ 个字符所需的最少编辑步数**。 @@ -71,7 +71,7 @@ $$ **第三步:确定边界条件和状态转移顺序** -当两字符串都为空时,编辑步数为 $0$ ,即 $dp[0, 0] = 0$ 。当 $s$ 为空但 $t$ 不为空时,最少编辑步数等于 $t$ 的长度,即首行 $dp[0, j] = j$ 。当 $s$ 不为空但 $t$ 为空时,等于 $s$ 的长度,即首列 $dp[i, 0] = i$ 。 +当两字符串都为空时,编辑步数为 $0$ ,即 $dp[0, 0] = 0$ 。当 $s$ 为空但 $t$ 不为空时,最少编辑步数等于 $t$ 的长度,即首行 $dp[0, j] = j$ 。当 $s$ 不为空但 $t$ 为空时,最少编辑步数等于 $s$ 的长度,即首列 $dp[i, 0] = i$ 。 观察状态转移方程,解 $dp[i, j]$ 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 $dp$ 表即可。 @@ -89,7 +89,7 @@ $$ dp[i][0] = i for j in range(1, m + 1): dp[0][j] = j - # 状态转移:其余行列 + # 状态转移:其余行和列 for i in range(1, n + 1): for j in range(1, m + 1): if s[i - 1] == t[j - 1]: @@ -115,7 +115,7 @@ $$ for (int j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (s[i - 1] == t[j - 1]) { @@ -145,7 +145,7 @@ $$ for (int j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (s.charAt(i - 1) == t.charAt(j - 1)) { @@ -175,7 +175,7 @@ $$ for (int j = 1; j <= m; j++) { dp[0, j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (s[i - 1] == t[j - 1]) { @@ -209,7 +209,7 @@ $$ for j := 1; j <= m; j++ { dp[0][j] = j } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i := 1; i <= n; i++ { for j := 1; j <= m; j++ { if s[i-1] == t[j-1] { @@ -240,7 +240,7 @@ $$ for j in stride(from: 1, through: m, by: 1) { dp[0][j] = j } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in stride(from: 1, through: n, by: 1) { for j in stride(from: 1, through: m, by: 1) { if s.utf8CString[i - 1] == t.utf8CString[j - 1] { @@ -271,7 +271,7 @@ $$ for (let j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { if (s.charAt(i - 1) === t.charAt(j - 1)) { @@ -305,7 +305,7 @@ $$ for (let j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { if (s.charAt(i - 1) === t.charAt(j - 1)) { @@ -336,7 +336,7 @@ $$ for (int j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (s[i - 1] == t[j - 1]) { @@ -366,7 +366,7 @@ $$ for j in 1..m { dp[0][j] = j as i32; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in 1..=n { for j in 1..=m { if s.chars().nth(i - 1) == t.chars().nth(j - 1) { @@ -398,7 +398,7 @@ $$ for (int j = 1; j <= m; j++) { dp[0][j] = j; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (s[i - 1] == t[j - 1]) { @@ -434,7 +434,7 @@ $$ for (1..m + 1) |j| { dp[0][j] = @intCast(j); } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (1..n + 1) |i| { for (1..m + 1) |j| { if (s[i - 1] == t[j - 1]) { @@ -450,7 +450,7 @@ $$ } ``` -如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。 +如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作填写一个二维网格的过程。 === "<1>" { class="animation-figure" } @@ -501,9 +501,9 @@ $$ ### 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]$ ,因此两种遍历顺序都不可取。 +由于 $dp[i,j]$ 是由上方 $dp[i-1, j]$、左方 $dp[i, j-1]$、左上方 $dp[i-1, j-1]$ 转移而来的,而正序遍历会丢失左上方 $dp[i-1, j-1]$ ,倒序遍历无法提前构建 $dp[i, j-1]$ ,因此两种遍历顺序都不可取。 -为此,我们可以使用一个变量 `leftup` 来暂存左上方的解 $dp[i-1, j-1]$ ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。 +为此,我们可以使用一个变量 `leftup` 来暂存左上方的解 $dp[i-1, j-1]$ ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。代码如下所示: === "Python" diff --git a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index ff4868ae3..8ecbd6f13 100644 --- a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -10,7 +10,7 @@ comments: true !!! question "爬楼梯" - 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,请问有多少种方案可以爬到楼顶。 + 给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,请问有多少种方案可以爬到楼顶? 如图 14-1 所示,对于一个 $3$ 阶楼梯,共有 $3$ 种方案可以爬到楼顶。 @@ -18,7 +18,7 @@ comments: true图 14-1 爬到第 3 阶的方案数量
-本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。 +本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。代码如下所示: === "Python" @@ -39,7 +39,7 @@ comments: true def climbing_stairs_backtrack(n: int) -> int: """爬楼梯:回溯""" - choices = [1, 2] # 可选择向上爬 1 或 2 阶 + choices = [1, 2] # 可选择向上爬 1 阶或 2 阶 state = 0 # 从第 0 阶开始爬 res = [0] # 使用 res[0] 记录方案数量 backtrack(choices, state, n, res) @@ -67,7 +67,7 @@ comments: true /* 爬楼梯:回溯 */ int climbingStairsBacktrack(int n) { - vector图 14-3 爬楼梯对应递归树
-观察图 14-3 ,**指数阶的时间复杂度是由于“重叠子问题”导致的**。例如 $dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ ,$dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。 +观察图 14-3 ,**指数阶的时间复杂度是“重叠子问题”导致的**。例如 $dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ ,$dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。 以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。 @@ -655,6 +655,8 @@ $$ 1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用。 2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而避免重复计算该子问题。 +代码如下所示: + === "Python" ```python title="climbing_stairs_dfs_mem.py" @@ -973,7 +975,7 @@ $$ } ``` -观察图 14-4 ,**经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 $O(n)$** ,这是一个巨大的飞跃。 +观察图 14-4 ,**经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 $O(n)$** ,这是一个巨大的飞跃。 { class="animation-figure" } @@ -981,11 +983,11 @@ $$ ## 14.1.3 方法三:动态规划 -**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。 +**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。 与之相反,**动态规划是一种“从底至顶”的方法**:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。 -由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 `dp` 来存储子问题的解,它起到了记忆化搜索中数组 `mem` 相同的记录作用。 +由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 `dp` 来存储子问题的解,它起到了与记忆化搜索中数组 `mem` 相同的记录作用: === "Python" @@ -1233,17 +1235,17 @@ $$图 14-5 爬楼梯的动态规划过程
-与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。 +与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。 根据以上内容,我们可以总结出动态规划的常用术语。 - 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解。 -- 将最小子问题对应的状态(即第 $1$ 和 $2$ 阶楼梯)称为「初始状态」。 +- 将最小子问题对应的状态(第 $1$ 阶和第 $2$ 阶楼梯)称为「初始状态」。 - 将递推公式 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」。 ## 14.1.4 空间优化 -细心的你可能发现,**由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无须使用一个数组 `dp` 来存储所有子问题的解**,而只需两个变量滚动前进即可。 +细心的读者可能发现了,**由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无须使用一个数组 `dp` 来存储所有子问题的解**,而只需两个变量滚动前进即可。代码如下所示: === "Python" @@ -1445,6 +1447,6 @@ $$ } ``` -观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降低至 $O(1)$ 。 +观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降至 $O(1)$ 。 在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。**这种空间优化技巧被称为“滚动变量”或“滚动数组”**。 diff --git a/docs/chapter_dynamic_programming/knapsack_problem.md b/docs/chapter_dynamic_programming/knapsack_problem.md index b1f27fc3a..fb83e1390 100644 --- a/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/docs/chapter_dynamic_programming/knapsack_problem.md @@ -10,7 +10,7 @@ comments: true !!! question - 给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。 + 给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。 观察图 14-17 ,由于物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。 @@ -18,9 +18,9 @@ comments: true图 14-17 0-1 背包的示例数据
-我们可以将 0-1 背包问题看作是一个由 $n$ 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。 +我们可以将 0-1 背包问题看作一个由 $n$ 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。 -该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。 +该问题的目标是求解“在限定背包容量下能放入物品的最大价值”,因此较大概率是一个动态规划问题。 **第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表** @@ -35,9 +35,9 @@ comments: true 当我们做出物品 $i$ 的决策后,剩余的是前 $i-1$ 个物品的决策,可分为以下两种情况。 - **不放入物品 $i$** :背包容量不变,状态变化为 $[i-1, c]$ 。 -- **放入物品 $i$** :背包容量减小 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态变化为 $[i-1, c-wgt[i-1]]$ 。 +- **放入物品 $i$** :背包容量减少 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态变化为 $[i-1, c-wgt[i-1]]$ 。 -上述分析向我们揭示了本题的最优子结构:**最大价值 $dp[i, c]$ 等于不放入物品 $i$ 和放入物品 $i$ 两种方案中的价值更大的那一个**。由此可推出状态转移方程: +上述分析向我们揭示了本题的最优子结构:**最大价值 $dp[i, c]$ 等于不放入物品 $i$ 和放入物品 $i$ 两种方案中价值更大的那一个**。由此可推导出状态转移方程: $$ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) @@ -60,17 +60,17 @@ $$ - **递归参数**:状态 $[i, c]$ 。 - **返回值**:子问题的解 $dp[i, c]$ 。 - **终止条件**:当物品编号越界 $i = 0$ 或背包剩余容量为 $0$ 时,终止递归并返回价值 $0$ 。 -- **剪枝**:若当前物品重量超出背包剩余容量,则只能不放入背包。 +- **剪枝**:若当前物品重量超出背包剩余容量,则只能选择不放入背包。 === "Python" ```python title="knapsack.py" def knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int: """0-1 背包:暴力搜索""" - # 若已选完所有物品或背包无容量,则返回价值 0 + # 若已选完所有物品或背包无剩余容量,则返回价值 0 if i == 0 or c == 0: return 0 - # 若超过背包容量,则只能不放入背包 + # 若超过背包容量,则只能选择不放入背包 if wgt[i - 1] > c: return knapsack_dfs(wgt, val, i - 1, c) # 计算不放入和放入物品 i 的最大价值 @@ -85,11 +85,11 @@ $$ ```cpp title="knapsack.cpp" /* 0-1 背包:暴力搜索 */ int knapsackDFS(vector图 14-18 0-1 背包的暴力搜索递归树
+图 14-18 0-1 背包问题的暴力搜索递归树
### 2. 方法二:记忆化搜索 为了保证重叠子问题只被计算一次,我们借助记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应 $dp[i, c]$ 。 -引入记忆化之后,**时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。 +引入记忆化之后,**时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。实现代码如下: === "Python" @@ -337,13 +337,13 @@ $$ wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int ) -> int: """0-1 背包:记忆化搜索""" - # 若已选完所有物品或背包无容量,则返回价值 0 + # 若已选完所有物品或背包无剩余容量,则返回价值 0 if i == 0 or c == 0: return 0 # 若已有记录,则直接返回 if mem[i][c] != -1: return mem[i][c] - # 若超过背包容量,则只能不放入背包 + # 若超过背包容量,则只能选择不放入背包 if wgt[i - 1] > c: return knapsack_dfs_mem(wgt, val, mem, i - 1, c) # 计算不放入和放入物品 i 的最大价值 @@ -359,7 +359,7 @@ $$ ```cpp title="knapsack.cpp" /* 0-1 背包:记忆化搜索 */ int knapsackDFSMem(vector图 14-19 0-1 背包的记忆化搜索递归树
+图 14-19 0-1 背包问题的记忆化搜索递归树
### 3. 方法三:动态规划 -动态规划实质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示。 +动态规划实质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示: === "Python" @@ -976,7 +976,7 @@ $$ 如图 14-20 所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。 === "<1>" - { class="animation-figure" } + { class="animation-figure" } === "<2>" { class="animation-figure" } @@ -1017,13 +1017,13 @@ $$ === "<14>" { class="animation-figure" } -图 14-20 0-1 背包的动态规划过程
+图 14-20 0-1 背包问题的动态规划过程
### 4. 空间优化 -由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。 +由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 降至 $O(n)$ 。 -进一步思考,我们是否可以仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态。 +进一步思考,我们能否仅用一个数组实现空间优化呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态。 - 如果采取正序遍历,那么遍历到 $dp[i, j]$ 时,左上方 $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ 值可能已经被覆盖,此时就无法得到正确的状态转移结果。 - 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。 @@ -1050,7 +1050,7 @@ $$图 14-21 0-1 背包的空间优化后的动态规划过程
-在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可。 +在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可: === "Python" diff --git a/docs/chapter_dynamic_programming/summary.md b/docs/chapter_dynamic_programming/summary.md index 59b7e89d7..0890fd2e5 100644 --- a/docs/chapter_dynamic_programming/summary.md +++ b/docs/chapter_dynamic_programming/summary.md @@ -4,24 +4,24 @@ comments: true # 14.7 小结 -- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,实现高效的计算效率。 +- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,提高 计算效率。 - 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。 -- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖于某些局部状态,因此我们可以消除 $dp$ 表的一个维度,从而降低空间复杂度。 +- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖某些局部状态,因此我们可以消除 $dp$ 表的一个维度,从而降低空间复杂度。 - 子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。 -- 动态规划问题的三大特性:重叠子问题、最优子结构、无后效性。 +- 动态规划问题有三大特性:重叠子问题、最优子结构、无后效性。 - 如果原问题的最优解可以从子问题的最优解构建得来,则它就具有最优子结构。 -- 无后效性指对于一个状态,其未来发展只与该状态有关,与其所经历的过去的所有状态无关。许多组合优化问题都不具有无后效性,无法使用动态规划快速求解。 +- 无后效性指对于一个状态,其未来发展只与该状态有关,而与过去经历的所有状态无关。许多组合优化问题不具有无后效性,无法使用动态规划快速求解。 **背包问题** -- 背包问题是最典型的动态规划题目,具有 0-1 背包、完全背包、多重背包等变种问题。 +- 背包问题是最典型的动态规划问题之一,具有 0-1 背包、完全背包、多重背包等变种。 - 0-1 背包的状态定义为前 $i$ 个物品在剩余容量为 $c$ 的背包中的最大价值。根据不放入背包和放入背包两种决策,可得到最优子结构,并构建出状态转移方程。在空间优化中,由于每个状态依赖正上方和左上方的状态,因此需要倒序遍历列表,避免左上方状态被覆盖。 -- 完全背包的每种物品的选取数量无限制,因此选择放入物品的状态转移与 0-1 背包不同。由于状态依赖于正上方和正左方的状态,因此在空间优化中应当正序遍历。 -- 零钱兑换问题是完全背包的一个变种。它从求“最大”价值变为求“最小”硬币数量,因此状态转移方程中的 $\max()$ 应改为 $\min()$ 。从求“不超过”背包容量到求“恰好”凑出目标金额,因此使用 $amt + 1$ 来表示“无法凑出目标金额”的无效解。 +- 完全背包问题的每种物品的选取数量无限制,因此选择放入物品的状态转移与 0-1 背包问题不同。由于状态依赖正上方和正左方的状态,因此在空间优化中应当正序遍历。 +- 零钱兑换问题是完全背包问题的一个变种。它从求“最大”价值变为求“最小”硬币数量,因此状态转移方程中的 $\max()$ 应改为 $\min()$ 。从追求“不超过”背包容量到追求“恰好”凑出目标金额,因此使用 $amt + 1$ 来表示“无法凑出目标金额”的无效解。 - 零钱兑换 II 问题从求“最少硬币数量”改为求“硬币组合数量”,状态转移方程相应地从 $\min()$ 改为求和运算符。 **编辑距离问题** -- 编辑距离(Levenshtein 距离)用于衡量两个字符串之间的相似度,其定义为从一个字符串到另一个字符串的最小编辑步数,编辑操作包括添加、删除、替换。 +- 编辑距离(Levenshtein 距离)用于衡量两个字符串之间的相似度,其定义为从一个字符串到另一个字符串的最少编辑步数,编辑操作包括添加、删除、替换。 - 编辑距离问题的状态定义为将 $s$ 的前 $i$ 个字符更改为 $t$ 的前 $j$ 个字符所需的最少编辑步数。当 $s[i] \ne t[j]$ 时,具有三种决策:添加、删除、替换,它们都有相应的剩余子问题。据此便可以找出最优子结构与构建状态转移方程。而当 $s[i] = t[j]$ 时,无须编辑当前字符。 -- 在编辑距离中,状态依赖于其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包等价的情况,可以在空间优化后进行正序遍历。 +- 在编辑距离中,状态依赖其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包问题等价的情况,可以在空间优化后进行正序遍历。 diff --git a/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index 411f6df43..1f05c2dac 100644 --- a/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -10,7 +10,7 @@ comments: true !!! question - 给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在不超过背包容量下能放入物品的最大价值。 + 给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在限定背包容量下能放入物品的最大价值。示例如图 14-22 所示。 { class="animation-figure" } @@ -18,15 +18,15 @@ comments: true ### 1. 动态规划思路 -完全背包和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。 +完全背包问题和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。 -- 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。 -- 在完全背包中,每个物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。 +- 在 0-1 背包问题中,每种物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。 +- 在完全背包问题中,每种物品的数量是无限的,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。 -在完全背包的规定下,状态 $[i, c]$ 的变化分为两种情况。 +在完全背包问题的规定下,状态 $[i, c]$ 的变化分为两种情况。 -- **不放入物品 $i$** :与 0-1 背包相同,转移至 $[i-1, c]$ 。 -- **放入物品 $i$** :与 0-1 背包不同,转移至 $[i, c-wgt[i-1]]$ 。 +- **不放入物品 $i$** :与 0-1 背包问题相同,转移至 $[i-1, c]$ 。 +- **放入物品 $i$** :与 0-1 背包问题不同,转移至 $[i, c-wgt[i-1]]$ 。 从而状态转移方程变为: @@ -36,7 +36,7 @@ $$ ### 2. 代码实现 -对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致。 +对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致: === "Python" @@ -349,12 +349,12 @@ $$ ### 3. 空间优化 -由于当前状态是从左边和上边的状态转移而来,**因此空间优化后应该对 $dp$ 表中的每一行采取正序遍历**。 +由于当前状态是从左边和上边的状态转移而来的,**因此空间优化后应该对 $dp$ 表中的每一行进行正序遍历**。 这个遍历顺序与 0-1 背包正好相反。请借助图 14-23 来理解两者的区别。 === "<1>" - { class="animation-figure" } + { class="animation-figure" } === "<2>" { class="animation-figure" } @@ -371,9 +371,9 @@ $$ === "<6>" { class="animation-figure" } -图 14-23 完全背包的空间优化后的动态规划过程
+图 14-23 完全背包问题在空间优化后的动态规划过程
-代码实现比较简单,仅需将数组 `dp` 的第一维删除。 +代码实现比较简单,仅需将数组 `dp` 的第一维删除: === "Python" @@ -669,11 +669,11 @@ $$ ## 14.5.2 零钱兑换问题 -背包问题是一大类动态规划问题的代表,其拥有很多的变种,例如零钱兑换问题。 +背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。 !!! question - 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,**每种硬币可以重复选取**,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 $-1$ 。 + 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,**每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 $-1$ 。示例如图 14-24 所示。 { class="animation-figure" } @@ -681,21 +681,21 @@ $$ ### 1. 动态规划思路 -**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点。 +**零钱兑换可以看作完全背包问题的一种特殊情况**,两者具有以下联系与不同点。 -- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。 -- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。 -- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。 +- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。 +- 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。 +- 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。 **第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表** -状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币个数**,记为 $dp[i, a]$ 。 +状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数量**,记为 $dp[i, a]$ 。 二维 $dp$ 表的尺寸为 $(n+1) \times (amt+1)$ 。 **第二步:找出最优子结构,进而推导出状态转移方程** -本题与完全背包的状态转移方程存在以下两个差异。 +本题与完全背包问题的状态转移方程存在以下两点差异。 - 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。 - 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。 @@ -706,7 +706,7 @@ $$ **第三步:确定边界条件和状态转移顺序** -当目标金额为 $0$ 时,凑出它的最少硬币个数为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。 +当目标金额为 $0$ 时,凑出它的最少硬币数量为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。 当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。 @@ -714,9 +714,7 @@ $$ 大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 `int` 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。 -为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币个数最多为 $amt$ 个。 - -最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。 +为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数量最多为 $amt$ 。最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。代码如下所示: === "Python" @@ -730,7 +728,7 @@ $$ # 状态转移:首行首列 for a in range(1, amt + 1): dp[0][a] = MAX - # 状态转移:其余行列 + # 状态转移:其余行和列 for i in range(1, n + 1): for a in range(1, amt + 1): if coins[i - 1] > a: @@ -755,7 +753,7 @@ $$ for (int a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -784,7 +782,7 @@ $$ for (int a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -813,7 +811,7 @@ $$ for (int a = 1; a <= amt; a++) { dp[0, a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -845,7 +843,7 @@ $$ for a := 1; a <= amt; a++ { dp[0][a] = max } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i := 1; i <= n; i++ { for a := 1; a <= amt; a++ { if coins[i-1] > a { @@ -877,7 +875,7 @@ $$ for a in stride(from: 1, through: amt, by: 1) { dp[0][a] = MAX } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in stride(from: 1, through: n, by: 1) { for a in stride(from: 1, through: amt, by: 1) { if coins[i - 1] > a { @@ -908,7 +906,7 @@ $$ for (let a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i <= n; i++) { for (let a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -939,7 +937,7 @@ $$ for (let a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (let i = 1; i <= n; i++) { for (let a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -968,7 +966,7 @@ $$ for (int a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -997,7 +995,7 @@ $$ for a in 1..= amt { dp[0][a] = max; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i in 1..=n { for a in 1..=amt { if coins[i - 1] > a as i32 { @@ -1029,7 +1027,7 @@ $$ for (int a = 1; a <= amt; a++) { dp[0][a] = MAX; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (int i = 1; i <= n; i++) { for (int a = 1; a <= amt; a++) { if (coins[i - 1] > a) { @@ -1064,7 +1062,7 @@ $$ for (1..amt + 1) |a| { dp[0][a] = max; } - // 状态转移:其余行列 + // 状态转移:其余行和列 for (1..n + 1) |i| { for (1..amt + 1) |a| { if (coins[i - 1] > @as(i32, @intCast(a))) { @@ -1084,7 +1082,7 @@ $$ } ``` -图 14-25 展示了零钱兑换的动态规划过程,和完全背包非常相似。 +图 14-25 展示了零钱兑换的动态规划过程,和完全背包问题非常相似。 === "<1>" { class="animation-figure" } @@ -1135,7 +1133,7 @@ $$ ### 3. 空间优化 -零钱兑换的空间优化的处理方式和完全背包一致。 +零钱兑换的空间优化的处理方式和完全背包问题一致: === "Python" @@ -1467,7 +1465,7 @@ $$ !!! question - 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问在凑出目标金额的硬币组合数量**。 + 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。示例如图 14-26 所示。 { class="animation-figure" } @@ -1475,7 +1473,7 @@ $$ ### 1. 动态规划思路 -相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。 +相比于上一题,本题目标是求组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。 当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为: @@ -1609,7 +1607,7 @@ $$ for i := 0; i <= n; i++ { dp[i][0] = 1 } - // 状态转移:其余行列 + // 状态转移:其余行和列 for i := 1; i <= n; i++ { for a := 1; a <= amt; a++ { if coins[i-1] > a { @@ -1836,7 +1834,7 @@ $$ ### 3. 空间优化 -空间优化处理方式相同,删除硬币维度即可。 +空间优化处理方式相同,删除硬币维度即可: === "Python" diff --git a/docs/chapter_graph/graph.md b/docs/chapter_graph/graph.md index 821462e97..e6109330c 100644 --- a/docs/chapter_graph/graph.md +++ b/docs/chapter_graph/graph.md @@ -14,7 +14,7 @@ G & = \{ V, E \} \newline \end{aligned} $$ -如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作是一种从链表拓展而来的数据结构。如图 9-1 所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**,从而更为复杂。 +如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如图 9-1 所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**,因而更为复杂。 { class="animation-figure" } @@ -22,7 +22,7 @@ $$ ## 9.1.1 图常见类型与术语 -根据边是否具有方向,可分为图 9-2 所示的「无向图 undirected graph」和「有向图 directed graph」。 +根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」,如图 9-2 所示。 - 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。 - 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。 @@ -31,7 +31,7 @@ $$图 9-2 有向图与无向图
-根据所有顶点是否连通,可分为图 9-3 所示的「连通图 connected graph」和「非连通图 disconnected graph」。 +根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」,如图 9-3 所示。 - 对于连通图,从某个顶点出发,可以到达其余任意顶点。 - 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。 @@ -40,7 +40,7 @@ $$图 9-3 连通图与非连通图
-我们还可以为边添加“权重”变量,从而得到图 9-4 所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。 +我们还可以为边添加“权重”变量,从而得到如图 9-4 所示的「有权图 weighted graph」。例如在“王者荣耀”等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。 { class="animation-figure" } @@ -72,11 +72,11 @@ $$ - 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。 - 将邻接矩阵的元素从 $1$ 和 $0$ 替换为权重,则可表示有权图。 -使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较多。 +使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查改操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较多。 ### 2. 邻接表 -「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。 +「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 个链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。图 9-6 展示了一个使用邻接表存储的图的示例。 { class="animation-figure" } @@ -84,11 +84,11 @@ $$ 邻接表仅存储实际存在的边,而边的总数通常远小于 $n^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。 -观察图 9-6 ,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降低至 $O(1)$ 。 +观察图 9-6 ,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降至 $O(1)$ 。 ## 9.1.3 图常见应用 -如表 9-1 所示,许多现实系统都可以用图来建模,相应的问题也可以约化为图计算问题。 +如表 9-1 所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。表 9-1 现实生活中常见的图
diff --git a/docs/chapter_graph/graph_operations.md b/docs/chapter_graph/graph_operations.md index b3be43b8b..c84f9fbf9 100644 --- a/docs/chapter_graph/graph_operations.md +++ b/docs/chapter_graph/graph_operations.md @@ -32,7 +32,7 @@ comments: true图 9-7 邻接矩阵的初始化、增删边、增删顶点
-以下是基于邻接矩阵表示图的实现代码。 +以下是基于邻接矩阵表示图的实现代码: === "Python" @@ -88,7 +88,7 @@ comments: true # 索引越界与相等处理 if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: raise IndexError() - # 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + # 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) self.adj_mat[i][j] = 1 self.adj_mat[j][i] = 1 @@ -170,7 +170,7 @@ comments: true if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { throw out_of_range("顶点不存在"); } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) adjMat[i][j] = 1; adjMat[j][i] = 1; } @@ -261,7 +261,7 @@ comments: true // 索引越界与相等处理 if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) throw new IndexOutOfBoundsException(); - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) adjMat.get(i).set(j, 1); adjMat.get(j).set(i, 1); } @@ -351,7 +351,7 @@ comments: true // 索引越界与相等处理 if (i < 0 || j < 0 || i >= Size() || j >= Size() || i == j) throw new IndexOutOfRangeException(); - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) adjMat[i][j] = 1; adjMat[j][i] = 1; } @@ -449,7 +449,7 @@ comments: true if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j { fmt.Errorf("%s", "Index Out Of Bounds Exception") } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) g.adjMat[i][j] = 1 g.adjMat[j][i] = 1 } @@ -539,7 +539,7 @@ comments: true if i < 0 || j < 0 || i >= size() || j >= size() || i == j { fatalError("越界") } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) adjMat[i][j] = 1 adjMat[j][i] = 1 } @@ -633,7 +633,7 @@ comments: true if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { throw new RangeError('Index Out Of Bounds Exception'); } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) === (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) === (j, i) this.adjMat[i][j] = 1; this.adjMat[j][i] = 1; } @@ -725,7 +725,7 @@ comments: true if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { throw new RangeError('Index Out Of Bounds Exception'); } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) === (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) === (j, i) this.adjMat[i][j] = 1; this.adjMat[j][i] = 1; } @@ -813,7 +813,7 @@ comments: true if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { throw IndexError; } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) adjMat[i][j] = 1; adjMat[j][i] = 1; } @@ -909,7 +909,7 @@ comments: true if i >= self.size() || j >= self.size() || i == j { panic!("index error") } - // 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i) + // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i) self.adj_mat[i][j] = 1; self.adj_mat[j][i] = 1; } @@ -1087,7 +1087,7 @@ comments: true def __init__(self, edges: list[list[Vertex]]): """构造方法""" - # 邻接表,key: 顶点,value:该顶点的所有邻接顶点 + # 邻接表,key:顶点,value:该顶点的所有邻接顶点 self.adj_list = dict[Vertex, list[Vertex]]() # 添加所有顶点和边 for edge in edges: @@ -1147,7 +1147,7 @@ comments: true /* 基于邻接表实现的无向图类 */ class GraphAdjList { public: - // 邻接表,key: 顶点,value:该顶点的所有邻接顶点 + // 邻接表,key:顶点,value:该顶点的所有邻接顶点 unordered_map表 9-2 邻接矩阵与邻接表对比
@@ -2084,4 +2084,4 @@ comments: true图 15-3 分数背包问题的示例数据
-分数背包和 0-1 背包整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求不超过背包容量下的最大价值。 +分数背包问题和 0-1 背包问题整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求限定背包容量下的最大价值。 -不同点在于,本题允许只选择物品的一部分。如图 15-4 所示,**我们可以对物品任意地进行切分,并按照重量比例来计算物品价值**。 +不同点在于,本题允许只选择物品的一部分。如图 15-4 所示,**我们可以对物品任意地进行切分,并按照重量比例来计算相应价值**。 -1. 对于物品 $i$ ,它在单位重量下的价值为 $val[i-1] / wgt[i-1]$ ,简称为单位价值。 +1. 对于物品 $i$ ,它在单位重量下的价值为 $val[i-1] / wgt[i-1]$ ,简称单位价值。 2. 假设放入一部分物品 $i$ ,重量为 $w$ ,则背包增加的价值为 $w \times val[i-1] / wgt[i-1]$ 。 { class="animation-figure" } @@ -25,19 +25,19 @@ comments: true ### 1. 贪心策略确定 -最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出图 15-5 所示的贪心策略。 +最大化背包内物品总价值,**本质上是最大化单位重量下的物品价值**。由此便可推理出图 15-5 所示的贪心策略。 1. 将物品按照单位价值从高到低进行排序。 2. 遍历所有物品,**每轮贪心地选择单位价值最高的物品**。 -3. 若剩余背包容量不足,则使用当前物品的一部分填满背包即可。 +3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。 -{ class="animation-figure" } +{ class="animation-figure" } -图 15-5 分数背包的贪心策略
+图 15-5 分数背包问题的贪心策略
### 2. 代码实现 -我们建立了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解。 +我们建立了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解: === "Python" @@ -464,7 +464,7 @@ comments: true [class]{}-[func]{fractionalKnapsack} ``` -最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。 +在最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。 由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。 @@ -476,7 +476,7 @@ comments: true 对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,**单位价值更大的物品总是更优选择**,这说明贪心策略是有效的。 -如图 15-6 所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可被转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。 +如图 15-6 所示,如果将物品重量和物品单位价值分别看作一张二维图表的横轴和纵轴,则分数背包问题可转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。 { class="animation-figure" } diff --git a/docs/chapter_greedy/greedy_algorithm.md b/docs/chapter_greedy/greedy_algorithm.md index 224ff58cf..ecdba46c0 100644 --- a/docs/chapter_greedy/greedy_algorithm.md +++ b/docs/chapter_greedy/greedy_algorithm.md @@ -4,26 +4,26 @@ comments: true # 15.1 贪心算法 -「贪心算法 greedy algorithm」是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解。贪心算法简洁且高效,在许多实际问题中都有着广泛的应用。 +「贪心算法 greedy algorithm」是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。贪心算法简洁且高效,在许多实际问题中有着广泛的应用。 -贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理是不同的。 +贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同。 - 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。 -- 贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。 +- 贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。 -我们先通过例题“零钱兑换”了解贪心算法的工作原理。这道题已经在动态规划章节中介绍过,相信你对它并不陌生。 +我们先通过例题“零钱兑换”了解贪心算法的工作原理。这道题已经在“完全背包问题”章节中介绍过,相信你对它并不陌生。 !!! question - 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 $-1$ 。 + 给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额则返回 $-1$ 。 -本题的贪心策略如图 15-1 所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。 +本题采取的贪心策略如图 15-1 所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。 { class="animation-figure" }图 15-1 零钱兑换的贪心策略
-实现代码如下所示。你可能会不由地发出感叹:So Clean !贪心算法仅用十行代码就解决了零钱兑换问题。 +实现代码如下所示。你可能会不由地发出感叹:So clean !贪心算法仅用约十行代码就解决了零钱兑换问题: === "Python" @@ -289,13 +289,13 @@ comments: true [class]{}-[func]{coinChangeGreedy} ``` -## 15.1.1 贪心优点与局限性 +## 15.1.1 贪心的优点与局限性 **贪心算法不仅操作直接、实现简单,而且通常效率也很高**。在以上代码中,记硬币最小面值为 $\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, 5, 10, 20, 50, 100]$**:在该硬币组合下,给定任意 $amt$ ,贪心算法都可以找到最优解。 - **反例 $coins = [1, 20, 50]$**:假设 $amt = 60$ ,贪心算法只能找到 $50 + 1 \times 10$ 的兑换组合,共计 $11$ 枚硬币,但动态规划可以找到最优解 $20 + 20 + 20$ ,仅需 $3$ 枚硬币。 - **反例 $coins = [1, 49, 50]$**:假设 $amt = 98$ ,贪心算法只能找到 $50 + 1 \times 48$ 的兑换组合,共计 $49$ 枚硬币,但动态规划可以找到最优解 $49 + 49$ ,仅需 $2$ 枚硬币。 @@ -305,10 +305,10 @@ comments: true 也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。 -一般情况下,贪心算法适用于以下两类问题。 +一般情况下,贪心算法的适用情况分以下两种。 1. **可以保证找到最优解**:贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效。 -2. **可以找到近似最优解**:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解是非常困难的,能以较高效率找到次优解也是非常不错的。 +2. **可以找到近似最优解**:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解非常困难,能以较高效率找到次优解也是非常不错的。 ## 15.1.2 贪心算法特性 @@ -319,15 +319,15 @@ comments: true - **贪心选择性质**:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。 - **最优子结构**:原问题的最优解包含子问题的最优解。 -最优子结构已经在动态规划章节中介绍过,不再赘述。值得注意的是,一些问题的最优子结构并不明显,但仍然可使用贪心算法解决。 +最优子结构已经在“动态规划”章节中介绍过,这里不再赘述。值得注意的是,一些问题的最优子结构并不明显,但仍然可使用贪心算法解决。 -我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单,**但实际上对于许多问题,证明贪心选择性质不是一件易事**。 +我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单,**但实际上对于许多问题,证明贪心选择性质并非易事**。 例如零钱兑换问题,我们虽然能够容易地举出反例,对贪心选择性质进行证伪,但证实的难度较大。如果问:**满足什么条件的硬币组合可以使用贪心算法求解**?我们往往只能凭借直觉或举例子来给出一个模棱两可的答案,而难以给出严谨的数学证明。 !!! quote - 有一篇论文给出了一个 $O(n^3)$ 时间复杂度的算法,用于判断一个硬币组合是否可以使用贪心算法找出任何金额的最优解。 + 有一篇论文给出了一个 $O(n^3)$ 时间复杂度的算法,用于判断一个硬币组合能否使用贪心算法找出任意金额的最优解。 Pearson, David. A polynomial-time algorithm for the change-making problem. Operations Research Letters 33.3 (2005): 231-234. @@ -336,17 +336,17 @@ comments: true 贪心问题的解决流程大体可分为以下三步。 1. **问题分析**:梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。 -2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终能解决整个问题。 -3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要使用到数学证明,例如归纳法或反证法等。 +2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终解决整个问题。 +3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要用到数学证明,例如归纳法或反证法等。 -确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要包含以下原因。 +确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要有以下原因。 -- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略都比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。 -- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是个典型案例。 +- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。 +- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是一个典型案例。 为了保证正确性,我们应该对贪心策略进行严谨的数学证明,**通常需要用到反证法或数学归纳法**。 -然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行 Debug ,一步步修改与验证贪心策略。 +然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行代码调试,一步步修改与验证贪心策略。 ## 15.1.4 贪心典型例题 @@ -356,5 +356,5 @@ comments: true - **区间调度问题**:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。 - **分数背包问题**:给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解。 - **股票买卖问题**:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。 -- **霍夫曼编码**:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最小的两个节点合并,最后得到的霍夫曼树的带权路径长度(即编码长度)最小。 +- **霍夫曼编码**:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。 - **Dijkstra 算法**:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。 diff --git a/docs/chapter_greedy/index.md b/docs/chapter_greedy/index.md index 92cde33cb..aa81269c7 100644 --- a/docs/chapter_greedy/index.md +++ b/docs/chapter_greedy/index.md @@ -13,9 +13,9 @@ icon: material/head-heart-outline !!! abstract - 向日葵朝着太阳转动,时刻都在追求自身成长的最大可能。 + 向日葵朝着太阳转动,时刻追求自身成长的最大可能。 - 贪心策略在一轮轮的简单选择中,逐步导向最佳的答案。 + 贪心策略在一轮轮的简单选择中,逐步导向最佳答案。 ## 本章内容 diff --git a/docs/chapter_greedy/max_capacity_problem.md b/docs/chapter_greedy/max_capacity_problem.md index 3ba674dc9..cba48c319 100644 --- a/docs/chapter_greedy/max_capacity_problem.md +++ b/docs/chapter_greedy/max_capacity_problem.md @@ -6,11 +6,11 @@ comments: true !!! question - 输入一个数组 $ht$ ,数组中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。 + 输入一个数组 $ht$ ,其中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。 - 容器的容量等于高度和宽度的乘积(即面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。 + 容器的容量等于高度和宽度的乘积(面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。 - 请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。 + 请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。示例如图 15-7 所示。 { class="animation-figure" } @@ -18,13 +18,13 @@ comments: true 容器由任意两个隔板围成,**因此本题的状态为两个隔板的索引,记为 $[i, j]$** 。 -根据题意,容量等于高度乘以宽度,其中高度由短板决定,宽度是两隔板的索引之差。设容量为 $cap[i, j]$ ,则可得计算公式: +根据题意,容量等于高度乘以宽度,其中高度由短板决定,宽度是两隔板的数组索引之差。设容量为 $cap[i, j]$ ,则可得计算公式: $$ cap[i, j] = \min(ht[i], ht[j]) \times (j - i) $$ -设数组长度为 $n$ ,两个隔板的组合数量(即状态总数)为 $C_n^2 = \frac{n(n - 1)}{2}$ 个。最直接地,**我们可以穷举所有状态**,从而求得最大容量,时间复杂度为 $O(n^2)$ 。 +设数组长度为 $n$ ,两个隔板的组合数量(状态总数)为 $C_n^2 = \frac{n(n - 1)}{2}$ 个。最直接地,**我们可以穷举所有状态**,从而求得最大容量,时间复杂度为 $O(n^2)$ 。 ### 1. 贪心策略确定 @@ -48,14 +48,14 @@ $$图 15-10 向内移动短板后的状态
-由此便可推出本题的贪心策略:初始化两指针分裂容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。 +由此便可推出本题的贪心策略:初始化两指针分列容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。 图 15-11 展示了贪心策略的执行过程。 1. 初始状态下,指针 $i$ 和 $j$ 分列与数组两端。 2. 计算当前状态的容量 $cap[i, j]$ ,并更新最大容量。 3. 比较板 $i$ 和 板 $j$ 的高度,并将短板向内移动一格。 -4. 循环执行第 `2.` 和 `3.` 步,直至 $i$ 和 $j$ 相遇时结束。 +4. 循环执行第 `2.` 步和第 `3.` 步,直至 $i$ 和 $j$ 相遇时结束。 === "<1>" { class="animation-figure" } @@ -90,7 +90,7 @@ $$ 代码循环最多 $n$ 轮,**因此时间复杂度为 $O(n)$** 。 -变量 $i$、$j$、$res$ 使用常数大小额外空间,**因此空间复杂度为 $O(1)$** 。 +变量 $i$、$j$、$res$ 使用常数大小的额外空间,**因此空间复杂度为 $O(1)$** 。 === "Python" @@ -388,6 +388,6 @@ $$图 15-12 移动短板导致被跳过的状态
-观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。 +观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。前面我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。 -以上的分析说明,**移动短板的操作是“安全”的**,贪心策略是有效的。 +以上分析说明,移动短板的操作是“安全”的,贪心策略是有效的。 diff --git a/docs/chapter_greedy/max_product_cutting_problem.md b/docs/chapter_greedy/max_product_cutting_problem.md index db63e28a8..e4896dabf 100644 --- a/docs/chapter_greedy/max_product_cutting_problem.md +++ b/docs/chapter_greedy/max_product_cutting_problem.md @@ -6,7 +6,7 @@ comments: true !!! question - 给定一个正整数 $n$ ,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少。 + 给定一个正整数 $n$ ,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少,如图 15-13 所示。 { class="animation-figure" } @@ -18,7 +18,7 @@ $$ n = \sum_{i=1}^{m}n_i $$ -本题目标是求得所有整数因子的最大乘积,即 +本题的目标是求得所有整数因子的最大乘积,即 $$ \max(\prod_{i=1}^{m}n_i) @@ -50,13 +50,13 @@ $$ 如图 15-15 所示,当 $n = 6$ 时,有 $3 \times 3 > 2 \times 2 \times 2$ 。**这意味着切分出 $3$ 比切分出 $2$ 更优**。 -**贪心策略二**:在切分方案中,最多只应存在两个 $2$ 。因为三个 $2$ 总是可以被替换为两个 $3$ ,从而获得更大乘积。 +**贪心策略二**:在切分方案中,最多只应存在两个 $2$ 。因为三个 $2$ 总是可以替换为两个 $3$ ,从而获得更大的乘积。 { class="animation-figure" }图 15-15 最优切分因子
-总结以上,可推出以下贪心策略。 +综上所述,可推理出以下贪心策略。 1. 输入整数 $n$ ,从其不断地切分出因子 $3$ ,直至余数为 $0$、$1$、$2$ 。 2. 当余数为 $0$ 时,代表 $n$ 是 $3$ 的倍数,因此不做任何处理。 @@ -365,5 +365,5 @@ $$ 使用反证法,只分析 $n \geq 3$ 的情况。 1. **所有因子 $\leq 3$** :假设最优切分方案中存在 $\geq 4$ 的因子 $x$ ,那么一定可以将其继续划分为 $2(x-2)$ ,从而获得更大的乘积。这与假设矛盾。 -2. **切分方案不包含 $1$** :假设最优切分方案中存在一个因子 $1$ ,那么它一定可以合并入另外一个因子中,以获取更大乘积。这与假设矛盾。 +2. **切分方案不包含 $1$** :假设最优切分方案中存在一个因子 $1$ ,那么它一定可以合并入另外一个因子中,以获得更大的乘积。这与假设矛盾。 3. **切分方案最多包含两个 $2$** :假设最优切分方案中包含三个 $2$ ,那么一定可以替换为两个 $3$ ,乘积更大。这与假设矛盾。 diff --git a/docs/chapter_greedy/summary.md b/docs/chapter_greedy/summary.md index 61597f46a..04a766d44 100644 --- a/docs/chapter_greedy/summary.md +++ b/docs/chapter_greedy/summary.md @@ -4,13 +4,13 @@ comments: true # 15.5 小结 -- 贪心算法通常用于解决最优化问题,其原理是在每个决策阶段都做出局部最优的决策,以期望获得全局最优解。 +- 贪心算法通常用于解决最优化问题,其原理是在每个决策阶段都做出局部最优的决策,以期获得全局最优解。 - 贪心算法会迭代地做出一个又一个的贪心选择,每轮都将问题转化成一个规模更小的子问题,直到问题被解决。 - 贪心算法不仅实现简单,还具有很高的解题效率。相比于动态规划,贪心算法的时间复杂度通常更低。 - 在零钱兑换问题中,对于某些硬币组合,贪心算法可以保证找到最优解;对于另外一些硬币组合则不然,贪心算法可能找到很差的解。 - 适合用贪心算法求解的问题具有两大性质:贪心选择性质和最优子结构。贪心选择性质代表贪心策略的有效性。 - 对于某些复杂问题,贪心选择性质的证明并不简单。相对来说,证伪更加容易,例如零钱兑换问题。 -- 求解贪心问题主要分为三步:问题分析、贪心策略确定、正确性证明。其中,贪心策略确定是核心步骤,正确性证明往往是难点。 +- 求解贪心问题主要分为三步:问题分析、确定贪心策略、正确性证明。其中,确定贪心策略是核心步骤,正确性证明往往是难点。 - 分数背包问题在 0-1 背包的基础上,允许选择物品的一部分,因此可使用贪心算法求解。贪心策略的正确性可以使用反证法来证明。 - 最大容量问题可使用穷举法求解,时间复杂度为 $O(n^2)$ 。通过设计贪心策略,每轮向内移动短板,可将时间复杂度优化至 $O(n)$ 。 - 在最大切分乘积问题中,我们先后推理出两个贪心策略:$\geq 4$ 的整数都应该继续切分、最优切分因子为 $3$ 。代码中包含幂运算,时间复杂度取决于幂运算实现方法,通常为 $O(1)$ 或 $O(\log n)$ 。 diff --git a/docs/chapter_hashing/hash_algorithm.md b/docs/chapter_hashing/hash_algorithm.md index 6032590ce..6e6bc3126 100644 --- a/docs/chapter_hashing/hash_algorithm.md +++ b/docs/chapter_hashing/hash_algorithm.md @@ -4,13 +4,13 @@ comments: true # 6.3 哈希算法 -在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,**它们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生**。 +前两节介绍了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链式地址,**它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生**。 -如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图 6-8 所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 $O(n)$ 。 +如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图 6-8 所示,对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 $O(n)$ 。 -{ class="animation-figure" } +{ class="animation-figure" } -图 6-8 哈希冲突的最佳与最差情况
+图 6-8 哈希冲突的最佳情况与最差情况
**键值对的分布情况由哈希函数决定**。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模: @@ -20,25 +20,25 @@ index = hash(key) % capacity 观察以上公式,当哈希表容量 `capacity` 固定时,**哈希算法 `hash()` 决定了输出值**,进而决定了键值对在哈希表中的分布情况。 -这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。 +这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。 ## 6.3.1 哈希算法的目标 -为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。 +为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点。 - **确定性**:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。 - **效率高**:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。 -- **均匀分布**:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。 +- **均匀分布**:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。 实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。 - **密码存储**:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。 -- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。 +- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。 对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。 - **单向性**:无法通过哈希值反推出关于输入数据的任何信息。 -- **抗碰撞性**:应当极其困难找到两个不同的输入,使得它们的哈希值相同。 +- **抗碰撞性**:应当极难找到两个不同的输入,使得它们的哈希值相同。 - **雪崩效应**:输入的微小变化应当导致输出的显著且不可预测的变化。 请注意,**“均匀分布”与“抗碰撞性”是两个独立的概念**,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 `key` 下,哈希函数 `key % 100` 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 `key` 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 `key` ,从而破解密码。 @@ -48,7 +48,7 @@ index = hash(key) % capacity 哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。 - **加法哈希**:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。 -- **乘法哈希**:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。 +- **乘法哈希**:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。 - **异或哈希**:将输入数据的每个元素通过异或操作累积到一个哈希值中。 - **旋转哈希**:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。 @@ -568,9 +568,9 @@ index = hash(key) % capacity 观察发现,每种哈希算法的最后一步都是对大质数 $1000000007$ 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。 -先抛出结论:**当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布**。因为质数不会与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。 +先抛出结论:**使用大质数作为模数,可以最大化地保证哈希值的均匀分布**。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。 -举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除。那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。 +举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除,那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。 $$ \begin{aligned} @@ -580,7 +580,7 @@ $$ \end{aligned} $$ -如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key` 和 `modulus` 之间不存在公约数,输出的哈希值的均匀性会明显提升。 +如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key` 和 `modulus` 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。 $$ \begin{aligned} @@ -590,7 +590,7 @@ $$ \end{aligned} $$ -值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。 +值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。 总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。 @@ -598,12 +598,12 @@ $$ 不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。 -在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。 +在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。 近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。表 6-2 展示了在实际应用中常见的哈希算法。 - MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用。 -- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安全应用与协议中。 +- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。 - SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列。表 6-2 常见的哈希算法
@@ -613,7 +613,7 @@ $$ | | MD5 | SHA-1 | SHA-2 | SHA-3 | | -------- | ------------------------------ | ---------------- | ---------------------------- | -------------------- | | 推出时间 | 1992 | 1995 | 2002 | 2008 | -| 输出长度 | 128 bits | 160 bits | 256 / 512 bits | 224/256/384/512 bits | +| 输出长度 | 128 bits | 160 bits | 256/512 bits | 224/256/384/512 bits | | 哈希冲突 | 较多 | 较多 | 很少 | 很少 | | 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 | | 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 | @@ -625,7 +625,7 @@ $$ 我们知道,哈希表的 `key` 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 `hash()` 函数来计算各种数据类型的哈希值。 - 整数和布尔量的哈希值就是其本身。 -- 浮点数和字符串的哈希值计算较为复杂,有兴趣的同学请自行学习。 +- 浮点数和字符串的哈希值计算较为复杂,有兴趣的读者请自行学习。 - 元组的哈希值是对其中每一个元素进行哈希,然后将这些哈希值组合起来,得到单一的哈希值。 - 对象的哈希值基于其内存地址生成。通过重写对象的哈希方法,可实现基于内容生成哈希值。 @@ -650,7 +650,7 @@ $$ str = "Hello 算法" hash_str = hash(str) - # 字符串 Hello 算法 的哈希值为 4617003410720528961 + # 字符串“Hello 算法”的哈希值为 4617003410720528961 tup = (12836, "小哈") hash_tup = hash(tup) @@ -678,7 +678,7 @@ $$ string str = "Hello 算法"; size_t hashStr = hash图 5-8 基于链表实现双向队列的入队出队操作
-实现代码如下所示。 +实现代码如下所示: === "Python" @@ -415,7 +417,7 @@ comments: true def push(self, num: int, is_front: bool): """入队操作""" node = ListNode(num) - # 若链表为空,则令 front, rear 都指向 node + # 若链表为空,则令 front 和 rear 都指向 node if self.is_empty(): self._front = self._rear = node # 队首入队操作 @@ -542,7 +544,7 @@ comments: true /* 入队操作 */ void push(int num, bool isFront) { DoublyListNode *node = new DoublyListNode(num); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (isEmpty()) front = rear = node; // 队首入队操作 @@ -677,7 +679,7 @@ comments: true /* 入队操作 */ private void push(int num, boolean isFront) { ListNode node = new ListNode(num); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (isEmpty()) front = rear = node; // 队首入队操作 @@ -806,7 +808,7 @@ comments: true /* 入队操作 */ void Push(int num, bool isFront) { ListNode node = new(num); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (IsEmpty()) { front = node; rear = node; @@ -1026,7 +1028,7 @@ comments: true /* 入队操作 */ private func push(num: Int, isFront: Bool) { let node = ListNode(val: num) - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if isEmpty() { front = node rear = node @@ -1154,7 +1156,7 @@ comments: true /* 队尾入队操作 */ pushLast(val) { const node = new ListNode(val); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (this.#queSize === 0) { this.#front = node; this.#rear = node; @@ -1170,7 +1172,7 @@ comments: true /* 队首入队操作 */ pushFirst(val) { const node = new ListNode(val); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (this.#queSize === 0) { this.#front = node; this.#rear = node; @@ -1281,7 +1283,7 @@ comments: true /* 队尾入队操作 */ pushLast(val: number): void { const node: ListNode = new ListNode(val); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (this.queSize === 0) { this.front = node; this.rear = node; @@ -1297,7 +1299,7 @@ comments: true /* 队首入队操作 */ pushFirst(val: number): void { const node: ListNode = new ListNode(val); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (this.queSize === 0) { this.front = node; this.rear = node; @@ -1560,7 +1562,7 @@ comments: true // 队首入队操作 if is_front { match self.front.take() { - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node None => { self.rear = Some(node.clone()); self.front = Some(node); @@ -1576,7 +1578,7 @@ comments: true // 队尾入队操作 else { match self.rear.take() { - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node None => { self.front = Some(node.clone()); self.rear = Some(node); @@ -1739,7 +1741,7 @@ comments: true /* 入队 */ void push(LinkedListDeque *deque, int num, bool isFront) { DoublyListNode *node = newDoublyListNode(num); - // 若链表为空,则令 front, rear 都指向node + // 若链表为空,则令 front 和 rear 都指向node if (empty(deque)) { deque->front = deque->rear = node; } @@ -1901,7 +1903,7 @@ comments: true pub fn push(self: *Self, num: T, is_front: bool) !void { var node = try self.mem_allocator.create(ListNode(T)); node.init(num); - // 若链表为空,则令 front, rear 都指向 node + // 若链表为空,则令 front 和 rear 都指向 node if (self.isEmpty()) { self.front = node; self.rear = node; @@ -2019,7 +2021,7 @@ comments: true图 5-9 基于数组实现双向队列的入队出队操作
-在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。 +在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法: === "Python" @@ -3225,4 +3227,4 @@ comments: true 双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。 -我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 $50$ 步)。当栈的长度超过 $50$ 时,软件需要在栈底(即队首)执行删除操作。**但栈无法实现该功能,此时就需要使用双向队列来替代栈**。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。 +我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 $50$ 步)。当栈的长度超过 $50$ 时,软件需要在栈底(队首)执行删除操作。**但栈无法实现该功能,此时就需要使用双向队列来替代栈**。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。 diff --git a/docs/chapter_stack_and_queue/index.md b/docs/chapter_stack_and_queue/index.md index 542c41137..11ca906d2 100644 --- a/docs/chapter_stack_and_queue/index.md +++ b/docs/chapter_stack_and_queue/index.md @@ -15,7 +15,7 @@ icon: material/stack-overflow 栈如同叠猫猫,而队列就像猫猫排队。 - 两者分别代表着先入后出和先入先出的逻辑关系。 + 两者分别代表先入后出和先入先出的逻辑关系。 ## 本章内容 diff --git a/docs/chapter_stack_and_queue/queue.md b/docs/chapter_stack_and_queue/queue.md index 519a33985..7dbb98b36 100755 --- a/docs/chapter_stack_and_queue/queue.md +++ b/docs/chapter_stack_and_queue/queue.md @@ -4,9 +4,9 @@ comments: true # 5.2 队列 -「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。 +「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列尾部,而位于队列头部的人逐个离开。 -如图 5-4 所示,我们将队列的头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。 +如图 5-4 所示,我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。 { class="animation-figure" } @@ -28,15 +28,17 @@ comments: true -我们可以直接使用编程语言中现成的队列类。 +我们可以直接使用编程语言中现成的队列类: === "Python" ```python title="queue.py" + from collections import deque + # 初始化队列 - # 在 Python 中,我们一般将双向队列类 deque 看作队列使用 - # 虽然 queue.Queue() 是纯正的队列类,但不太好用,因此不建议 - que: deque[int] = collections.deque() + # 在 Python 中,我们一般将双向队列类 deque 当作队列使用 + # 虽然 queue.Queue() 是纯正的队列类,但不太好用,因此不推荐 + que: deque[int] = deque() # 元素入队 que.append(1) @@ -318,7 +320,7 @@ comments: true ## 5.2.2 队列实现 -为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。 +为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。链表和数组都符合要求。 ### 1. 基于链表的实现 @@ -335,7 +337,7 @@ comments: true图 5-5 基于链表实现队列的入队出队操作
-以下是用链表实现队列的代码。 +以下是用链表实现队列的代码: === "Python" @@ -1205,7 +1207,7 @@ comments: true ### 2. 基于数组的实现 -由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。 +在数组中删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。 我们可以使用一个变量 `front` 指向队首元素的索引,并维护一个变量 `size` 用于记录队列长度。定义 `rear = front + size` ,这个公式计算出的 `rear` 指向队尾元素之后的下一个位置。 @@ -1227,9 +1229,9 @@ comments: true图 5-6 基于数组实现队列的入队出队操作
-你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。 +你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为了解决此问题,我们可以将数组视为首尾相接的“环形数组”。 -对于环形数组,我们需要让 `front` 或 `rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示。 +对于环形数组,我们需要让 `front` 或 `rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示: === "Python" @@ -2112,11 +2114,11 @@ comments: true } ``` -以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。 +以上实现的队列仍然具有局限性:其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的读者可以尝试自行实现。 两种实现的对比结论与栈一致,在此不再赘述。 ## 5.2.3 队列典型应用 -- **淘宝订单**。购物者下单后,订单将加入队列中,系统随后会根据顺序依次处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。 -- **各类待办事项**。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等。队列在这些场景中可以有效地维护处理顺序。 +- **淘宝订单**。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。 +- **各类待办事项**。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等,队列在这些场景中可以有效地维护处理顺序。 diff --git a/docs/chapter_stack_and_queue/stack.md b/docs/chapter_stack_and_queue/stack.md index 2c39987ef..bef6dc66a 100755 --- a/docs/chapter_stack_and_queue/stack.md +++ b/docs/chapter_stack_and_queue/stack.md @@ -6,9 +6,9 @@ comments: true 「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。 -我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。 +我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。 -如图 5-1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,删除栈顶元素的操作叫做“出栈”。 +如图 5-1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。 { class="animation-figure" } @@ -30,7 +30,7 @@ comments: true -通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”视作栈来使用,并在程序逻辑上忽略与栈无关的操作。 +通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”当作栈来使用,并在程序逻辑上忽略与栈无关的操作。 === "Python" @@ -316,11 +316,11 @@ comments: true 为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。 -栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以被视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。 +栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。 ### 1. 基于链表的实现 -使用链表来实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。 +使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。 如图 5-2 所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。 @@ -335,7 +335,7 @@ comments: true图 5-2 基于链表实现栈的入栈出栈操作
-以下是基于链表实现栈的示例代码。 +以下是基于链表实现栈的示例代码: === "Python" @@ -1099,7 +1099,7 @@ comments: true图 5-3 基于数组实现栈的入栈出栈操作
-由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码。 +由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码: === "Python" @@ -1701,9 +1701,9 @@ comments: true **时间效率** -在基于数组的实现中,入栈和出栈操作都是在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。 +在基于数组的实现中,入栈和出栈操作都在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。 -在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。 +在基于链表的实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。 综上所述,当入栈与出栈操作的元素是基本数据类型时,例如 `int` 或 `double` ,我们可以得出以下结论。 @@ -1712,7 +1712,7 @@ comments: true **空间效率** -在初始化列表时,系统会为列表分配“初始容量”,该容量可能超过实际需求。并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。 +在初始化列表时,系统会为列表分配“初始容量”,该容量可能超出实际需求;并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容的,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。 然而,由于链表节点需要额外存储指针,**因此链表节点占用的空间相对较大**。 @@ -1720,5 +1720,5 @@ comments: true ## 5.1.4 栈典型应用 -- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。 -- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。 +- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。 +- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。 diff --git a/docs/chapter_stack_and_queue/summary.md b/docs/chapter_stack_and_queue/summary.md index f1b0ddc91..5fda37738 100644 --- a/docs/chapter_stack_and_queue/summary.md +++ b/docs/chapter_stack_and_queue/summary.md @@ -7,7 +7,7 @@ comments: true ### 1. 重点回顾 - 栈是一种遵循先入后出原则的数据结构,可通过数组或链表来实现。 -- 从时间效率角度看,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会劣化至 $O(n)$ 。相比之下,基于链表实现的栈具有更为稳定的效率表现。 +- 在时间效率方面,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会劣化至 $O(n)$ 。相比之下,栈的链表实现具有更为稳定的效率表现。 - 在空间效率方面,栈的数组实现可能导致一定程度的空间浪费。但需要注意的是,链表节点所占用的内存空间比数组元素更大。 - 队列是一种遵循先入先出原则的数据结构,同样可以通过数组或链表来实现。在时间效率和空间效率的对比上,队列的结论与前述栈的结论相似。 - 双向队列是一种具有更高自由度的队列,它允许在两端进行元素的添加和删除操作。 @@ -16,7 +16,7 @@ comments: true !!! question "浏览器的前进后退是否是双向链表实现?" - 浏览器的前进后退功能本质上是“栈”的体现。当用户访问一个新页面时,该页面会被添加到栈顶;当用户点击后退按钮时,该页面会从栈顶弹出。使用双向队列可以方便实现一些额外操作,这个在双向队列章节有提到。 + 浏览器的前进后退功能本质上是“栈”的体现。当用户访问一个新页面时,该页面会被添加到栈顶;当用户点击后退按钮时,该页面会从栈顶弹出。使用双向队列可以方便地实现一些额外操作,这个在“双向队列”章节有提到。 !!! question "在出栈后,是否需要释放出栈节点的内存?" @@ -24,7 +24,7 @@ comments: true !!! question "双向队列像是两个栈拼接在了一起,它的用途是什么?" - 双向队列就像是栈和队列的组合,或者是两个栈拼在了一起。它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。 + 双向队列就像是栈和队列的组合,或两个栈拼在了一起。它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。 !!! question "撤销(undo)和反撤销(redo)具体是如何实现的?" diff --git a/docs/chapter_tree/array_representation_of_tree.md b/docs/chapter_tree/array_representation_of_tree.md index 859cfe0c7..f45d6045f 100644 --- a/docs/chapter_tree/array_representation_of_tree.md +++ b/docs/chapter_tree/array_representation_of_tree.md @@ -4,15 +4,15 @@ comments: true # 7.3 二叉树数组表示 -在链表表示下,二叉树的存储单元为节点 `TreeNode` ,节点之间通过指针相连接。在上节中,我们学习了在链表表示下的二叉树的各项基本操作。 +在链表表示下,二叉树的存储单元为节点 `TreeNode` ,节点之间通过指针相连接。上一节介绍了链表表示下的二叉树的各项基本操作。 那么,我们能否用数组来表示二叉树呢?答案是肯定的。 ## 7.3.1 表示完美二叉树 -先分析一个简单案例。给定一个完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。 +先分析一个简单案例。给定一棵完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。 -根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。图 7-12 展示了各个节点索引之间的映射关系。 +根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若某节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。图 7-12 展示了各个节点索引之间的映射关系。 { class="animation-figure" } @@ -24,13 +24,13 @@ comments: true 完美二叉树是一个特例,在二叉树的中间层通常存在许多 $\text{None}$ 。由于层序遍历序列并不包含这些 $\text{None}$ ,因此我们无法仅凭该序列来推测 $\text{None}$ 的数量和分布位置。**这意味着存在多种二叉树结构都符合该层序遍历序列**。 -如图 7-13 所示,给定一个非完美二叉树,上述的数组表示方法已经失效。 +如图 7-13 所示,给定一棵非完美二叉树,上述数组表示方法已经失效。 { class="animation-figure" }图 7-13 层序遍历序列对应多种二叉树可能性
-为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如图 7-14 所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。 +为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如图 7-14 所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。示例代码如下: === "Python" @@ -138,7 +138,7 @@ comments: true图 7-15 完全二叉树的数组表示
-以下代码实现了一个基于数组表示的二叉树,包括以下几种操作。 +以下代码实现了一棵基于数组表示的二叉树,包括以下几种操作。 - 给定某节点,获取它的值、左(右)子节点、父节点。 - 获取前序遍历、中序遍历、后序遍历、层序遍历序列。 @@ -727,7 +727,7 @@ comments: true /* 获取索引为 i 节点的父节点的索引 */ parent(i) { - return Math.floor((i - 1) / 2); // 向下取整 + return Math.floor((i - 1) / 2); // 向下整除 } /* 层序遍历 */ @@ -813,7 +813,7 @@ comments: true /* 获取索引为 i 节点的父节点的索引 */ parent(i: number): number { - return Math.floor((i - 1) / 2); // 向下取整 + return Math.floor((i - 1) / 2); // 向下整除 } /* 层序遍历 */ @@ -1161,7 +1161,7 @@ comments: true [class]{ArrayBinaryTree}-[func]{} ``` -## 7.3.3 优势与局限性 +## 7.3.3 优点与局限性 二叉树的数组表示主要有以下优点。 diff --git a/docs/chapter_tree/avl_tree.md b/docs/chapter_tree/avl_tree.md index d6963a427..ecac354ca 100644 --- a/docs/chapter_tree/avl_tree.md +++ b/docs/chapter_tree/avl_tree.md @@ -4,21 +4,21 @@ comments: true # 7.5 AVL 树 * -在二叉搜索树章节中,我们提到了在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$ 。 +在“二叉搜索树”章节中,我们提到,在多次插入和删除操作后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$ 。 -如图 7-24 所示,经过两次删除节点操作,这个二叉搜索树便会退化为链表。 +如图 7-24 所示,经过两次删除节点操作,这棵二叉搜索树便会退化为链表。 { class="animation-figure" }图 7-24 AVL 树在删除节点后发生退化
-再例如,在图 7-25 的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。 +再例如,在图 7-25 所示的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。 { class="animation-figure" }图 7-25 AVL 树在插入节点后发生退化
-G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。 +1962 年 G. M. Adelson-Velsky 和 E. M. Landis 在论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。 ## 7.5.1 AVL 树常见术语 @@ -26,7 +26,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉 ### 1. 节点高度 -由于 AVL 树的相关操作需要获取节点高度,因此我们需要为节点类添加 `height` 变量。 +由于 AVL 树的相关操作需要获取节点高度,因此我们需要为节点类添加 `height` 变量: === "Python" @@ -214,7 +214,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉 ``` -“节点高度”是指从该节点到其最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。 +“节点高度”是指从该节点到其最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 $0$ ,而空节点的高度为 $-1$ 。我们将创建两个工具函数,分别用于获取和更新节点的高度: === "Python" @@ -438,7 +438,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉 ### 2. 节点平衡因子 -节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。 +节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 $0$ 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用: === "Python" @@ -602,11 +602,11 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉 AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”**。 -我们将平衡因子绝对值 $> 1$ 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。 +我们将平衡因子绝对值 $> 1$ 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面详细介绍这些旋转操作。 ### 1. 右旋 -如图 7-26 所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。 +如图 7-26 所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树恢复平衡,并且仍然保持二叉搜索树的性质。 === "<1>" { class="animation-figure" } @@ -628,7 +628,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中图 7-27 有 grandChild 的右旋操作
-“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示。 +“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示: === "Python" @@ -853,7 +853,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中 ### 2. 左旋 -相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行图 7-28 所示的“左旋”操作。 +相应地,如果考虑上述失衡二叉树的“镜像”,则需要执行图 7-28 所示的“左旋”操作。 { class="animation-figure" } @@ -865,7 +865,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中图 7-29 有 grandChild 的左旋操作
-可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码。 +可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码: === "Python" @@ -1098,7 +1098,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中 ### 4. 先右旋后左旋 -如图 7-31 所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,然后对 `node` 执行“左旋”。 +如图 7-31 所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,再对 `node` 执行“左旋”。 { class="animation-figure" } @@ -1106,7 +1106,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中 ### 5. 旋转的选择 -图 7-32 展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。 +图 7-32 展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、先左旋后右旋、先右旋后左旋、左旋的操作。 { class="animation-figure" } @@ -1120,14 +1120,14 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中 | 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 | | ------------------- | ---------------- | ---------------- | -| $> 1$ (即左偏树) | $\geq 0$ | 右旋 | -| $> 1$ (即左偏树) | $<0$ | 先左旋后右旋 | -| $< -1$ (即右偏树) | $\leq 0$ | 左旋 | -| $< -1$ (即右偏树) | $>0$ | 先右旋后左旋 | +| $> 1$ (左偏树) | $\geq 0$ | 右旋 | +| $> 1$ (左偏树) | $<0$ | 先左旋后右旋 | +| $< -1$ (右偏树) | $\leq 0$ | 左旋 | +| $< -1$ (右偏树) | $>0$ | 先右旋后左旋 | -为了便于使用,我们将旋转操作封装成一个函数。**有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡**。 +为了便于使用,我们将旋转操作封装成一个函数。**有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡**。代码如下所示: === "Python" @@ -1542,7 +1542,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中 ### 1. 插入节点 -AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。 +AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。代码如下所示: === "Python" @@ -1894,7 +1894,7 @@ AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区 ### 2. 删除节点 -类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。 +类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶执行旋转操作,使所有失衡节点恢复平衡。代码如下所示: === "Python" diff --git a/docs/chapter_tree/binary_search_tree.md b/docs/chapter_tree/binary_search_tree.md index 2716b233e..38ac308e1 100755 --- a/docs/chapter_tree/binary_search_tree.md +++ b/docs/chapter_tree/binary_search_tree.md @@ -39,7 +39,7 @@ comments: true图 7-17 二叉搜索树查找节点示例
-二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。 +二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。示例代码如下: === "Python" @@ -745,11 +745,11 @@ comments: true ### 3. 删除节点 -先在二叉树中查找到目标节点,再将其从二叉树中删除。 +先在二叉树中查找到目标节点,再将其删除。 与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。 -因此,我们需要根据目标节点的子节点数量,共分为 0、1 和 2 这三种情况,执行对应的删除节点操作。 +因此,我们根据目标节点的子节点数量,分 0、1 和 2 三种情况,执行对应的删除节点操作。 如图 7-19 所示,当待删除节点的度为 $0$ 时,表示该节点是叶节点,可以直接删除。 @@ -763,12 +763,12 @@ comments: true图 7-20 在二叉搜索树中删除节点(度为 1 )
-当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。 +当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左子树 $<$ 根节点 $<$ 右子树”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。 -假设我们选择右子树的最小节点(即中序遍历的下一个节点),则删除操作流程如图 7-21 所示。 +假设我们选择右子树的最小节点(中序遍历的下一个节点),则删除操作流程如图 7-21 所示。 1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp` 。 -2. 将 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp` 。 +2. 用 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp` 。 === "<1>" { class="animation-figure" } @@ -784,7 +784,7 @@ comments: true图 7-21 在二叉搜索树中删除节点(度为 2 )
-删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。 +删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。示例代码如下: === "Python" @@ -1470,7 +1470,7 @@ comments: true ## 7.4.2 二叉搜索树的效率 -给定一组数据,我们考虑使用数组或二叉搜索树存储。观察表 7-2 ,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。 +给定一组数据,我们考虑使用数组或二叉搜索树存储。观察表 7-2 ,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能。只有在高频添加、低频查找删除数据的场景下,数组比二叉搜索树的效率更高。表 7-2 数组与搜索树的效率对比
@@ -1488,9 +1488,9 @@ comments: true 然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为图 7-23 所示的链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。 -{ class="animation-figure" } +{ class="animation-figure" } -图 7-23 二叉搜索树的退化
+图 7-23 二叉搜索树退化
## 7.4.3 二叉搜索树常见应用 diff --git a/docs/chapter_tree/binary_tree.md b/docs/chapter_tree/binary_tree.md index f9679b34e..f9da65092 100644 --- a/docs/chapter_tree/binary_tree.md +++ b/docs/chapter_tree/binary_tree.md @@ -4,7 +4,7 @@ comments: true # 7.1 二叉树 -「二叉树 binary tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含:值、左子节点引用、右子节点引用。 +「二叉树 binary tree」是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用。 === "Python" @@ -213,7 +213,7 @@ comments: true !!! tip - 请注意,我们通常将“高度”和“深度”定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。 + 请注意,我们通常将“高度”和“深度”定义为“经过的边的数量”,但有些题目或教材可能会将其定义为“经过的节点的数量”。在这种情况下,高度和深度都需要加 1 。 ## 7.1.2 二叉树基本操作 @@ -231,7 +231,7 @@ comments: true n3 = TreeNode(val=3) n4 = TreeNode(val=4) n5 = TreeNode(val=5) - # 构建引用指向(即指针) + # 构建节点之间的引用(指针) n1.left = n2 n1.right = n3 n2.left = n4 @@ -248,7 +248,7 @@ comments: true TreeNode* n3 = new TreeNode(3); TreeNode* n4 = new TreeNode(4); TreeNode* n5 = new TreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1->left = n2; n1->right = n3; n2->left = n4; @@ -264,7 +264,7 @@ comments: true TreeNode n3 = new TreeNode(3); TreeNode n4 = new TreeNode(4); TreeNode n5 = new TreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; @@ -281,7 +281,7 @@ comments: true TreeNode n3 = new(3); TreeNode n4 = new(4); TreeNode n5 = new(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; @@ -298,7 +298,7 @@ comments: true n3 := NewTreeNode(3) n4 := NewTreeNode(4) n5 := NewTreeNode(5) - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.Left = n2 n1.Right = n3 n2.Left = n4 @@ -314,7 +314,7 @@ comments: true let n3 = TreeNode(x: 3) let n4 = TreeNode(x: 4) let n5 = TreeNode(x: 5) - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2 n1.right = n3 n2.left = n4 @@ -331,7 +331,7 @@ comments: true n3 = new TreeNode(3), n4 = new TreeNode(4), n5 = new TreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; @@ -348,7 +348,7 @@ comments: true n3 = new TreeNode(3), n4 = new TreeNode(4), n5 = new TreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; @@ -365,7 +365,7 @@ comments: true TreeNode n3 = new TreeNode(3); TreeNode n4 = new TreeNode(4); TreeNode n5 = new TreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; @@ -381,7 +381,7 @@ comments: true let n3 = TreeNode::new(3); let n4 = TreeNode::new(4); let n5 = TreeNode::new(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1.borrow_mut().left = Some(n2.clone()); n1.borrow_mut().right = Some(n3); n2.borrow_mut().left = Some(n4); @@ -398,7 +398,7 @@ comments: true TreeNode *n3 = newTreeNode(3); TreeNode *n4 = newTreeNode(4); TreeNode *n5 = newTreeNode(5); - // 构建引用指向(即指针) + // 构建节点之间的引用(指针) n1->left = n2; n1->right = n3; n2->left = n4; @@ -556,13 +556,13 @@ comments: true !!! note - 需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。 + 需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除通常是由一套操作配合完成的,以实现有实际意义的操作。 ## 7.1.3 常见二叉树类型 ### 1. 完美二叉树 -「完美二叉树 perfect binary tree」所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。 +如图 7-4 所示,「完美二叉树 perfect binary tree」所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树的高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。 !!! tip @@ -598,26 +598,26 @@ comments: true ## 7.1.4 二叉树的退化 -图 7-8 展示了二叉树的理想与退化状态。当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。 +图 7-8 展示了二叉树的理想结构与退化结构。当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。 - 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。 - 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ 。 -{ class="animation-figure" } +{ class="animation-figure" } -图 7-8 二叉树的最佳与最差结构
+图 7-8 二叉树的最佳结构与最差结构
-如表 7-1 所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。 +如表 7-1 所示,在最佳结构和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大值或极小值。 -表 7-1 二叉树的最佳与最差情况
+表 7-1 二叉树的最佳结构与最差结构
图 7-10 二叉搜索树的前、中、后序遍历
+图 7-10 二叉搜索树的前序、中序、后序遍历
### 1. 代码实现 @@ -754,9 +754,9 @@ comments: true } ``` -!!! note +!!! tip - 深度优先搜索也可以基于迭代实现,有兴趣的同学可以自行研究。 + 深度优先搜索也可以基于迭代实现,有兴趣的读者可以自行研究。 图 7-11 展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分。 diff --git a/docs/chapter_tree/index.md b/docs/chapter_tree/index.md index 5044703b0..0ef1ac0b6 100644 --- a/docs/chapter_tree/index.md +++ b/docs/chapter_tree/index.md @@ -13,7 +13,7 @@ icon: material/graph-outline !!! abstract - 参天大树充满生命力,其根深叶茂,分枝扶疏。 + 参天大树充满生命力,根深叶茂,分枝扶疏。 它为我们展现了数据分治的生动形态。 diff --git a/docs/chapter_tree/summary.md b/docs/chapter_tree/summary.md index b18094a44..84340f790 100644 --- a/docs/chapter_tree/summary.md +++ b/docs/chapter_tree/summary.md @@ -12,25 +12,25 @@ comments: true - 二叉树的初始化、节点插入和节点删除操作与链表操作方法类似。 - 常见的二叉树类型有完美二叉树、完全二叉树、完满二叉树和平衡二叉树。完美二叉树是最理想的状态,而链表是退化后的最差状态。 - 二叉树可以用数组表示,方法是将节点值和空位按层序遍历顺序排列,并根据父节点与子节点之间的索引映射关系来实现指针。 -- 二叉树的层序遍历是一种广度优先搜索方法,它体现了“一圈一圈向外”的分层遍历方式,通常通过队列来实现。 -- 前序、中序、后序遍历皆属于深度优先搜索,它们体现了“走到尽头,再回头继续”的回溯遍历方式,通常使用递归来实现。 +- 二叉树的层序遍历是一种广度优先搜索方法,它体现了“一圈一圈向外扩展”的逐层遍历方式,通常通过队列来实现。 +- 前序、中序、后序遍历皆属于深度优先搜索,它们体现了“先走到尽头,再回溯继续”的遍历方式,通常使用递归来实现。 - 二叉搜索树是一种高效的元素查找数据结构,其查找、插入和删除操作的时间复杂度均为 $O(\log n)$ 。当二叉搜索树退化为链表时,各项时间复杂度会劣化至 $O(n)$ 。 -- AVL 树,也称为平衡二叉搜索树,它通过旋转操作,确保在不断插入和删除节点后,树仍然保持平衡。 +- AVL 树,也称平衡二叉搜索树,它通过旋转操作确保在不断插入和删除节点后树仍然保持平衡。 - AVL 树的旋转操作包括右旋、左旋、先右旋再左旋、先左旋再右旋。在插入或删除节点后,AVL 树会从底向顶执行旋转操作,使树重新恢复平衡。 ### 2. Q & A !!! question "对于只有一个节点的二叉树,树的高度和根节点的深度都是 $0$ 吗?" - 是的,因为高度和深度通常定义为“走过边的数量”。 + 是的,因为高度和深度通常定义为“经过的边的数量”。 -!!! question "二叉树中的插入与删除一般都是由一套操作配合完成的,这里的“一套操作”指什么呢?可以理解为资源的子节点的资源释放吗?" +!!! question "二叉树中的插入与删除一般由一套操作配合完成,这里的“一套操作”指什么呢?可以理解为资源的子节点的资源释放吗?" - 拿二叉搜索树来举例,删除节点操作要分为三种情况处理,其中每种情况都需要进行多个步骤的节点操作。 + 拿二叉搜索树来举例,删除节点操作要分三种情况处理,其中每种情况都需要进行多个步骤的节点操作。 !!! question "为什么 DFS 遍历二叉树有前、中、后三种顺序,分别有什么用呢?" - DFS 的前、中、后序遍历和访问数组的顺序类似,是遍历二叉树的基本方法,利用这三种遍历方法,我们可以得到一个特定顺序的遍历结果。例如在二叉搜索树中,由于节点大小满足 `左子节点值 < 根节点值 < 右子节点值` ,因此我们只要按照 `左->根->右` 的优先级遍历树,就可以获得有序的节点序列。 + 与顺序和逆序遍历数组类似,前序、中序、后序遍历是三种二叉树遍历方法,我们可以使用它们得到一个特定顺序的遍历结果。例如在二叉搜索树中,由于节点大小满足 `左子节点值 < 根节点值 < 右子节点值` ,因此我们只要按照 `左 $\rightarrow$ 根 $\rightarrow$ 右` 的优先级遍历树,就可以获得有序的节点序列。 !!! question "右旋操作是处理失衡节点 `node`、`child`、`grand_child` 之间的关系,那 `node` 的父节点和 `node` 原来的连接不需要维护吗?右旋操作后岂不是断掉了?" @@ -40,9 +40,9 @@ comments: true 主要看方法的使用范围,如果方法只在类内部使用,那么就设计为 `private` 。例如,用户单独调用 `updateHeight()` 是没有意义的,它只是插入、删除操作中的一步。而 `height()` 是访问节点高度,类似于 `vector.size()` ,因此设置成 `public` 以便使用。 -!!! question "请问如何从一组输入数据构建一个二叉搜索树?根节点的选择是不是很重要?" +!!! question "如何从一组输入数据构建一棵二叉搜索树?根节点的选择是不是很重要?" - 是的,构建树的方法已在二叉搜索树代码中的 `build_tree()` 方法中给出。至于根节点的选择,我们通常会将输入数据排序,然后用中点元素作为根节点,再递归地构建左右子树。这样做可以最大程度保证树的平衡性。 + 是的,构建树的方法已在二叉搜索树代码中的 `build_tree()` 方法中给出。至于根节点的选择,我们通常会将输入数据排序,然后将中点元素作为根节点,再递归地构建左右子树。这样做可以最大程度保证树的平衡性。 !!! question "在 Java 中,字符串对比是否一定要用 `equals()` 方法?" @@ -51,7 +51,7 @@ comments: true - `==` :用来比较两个变量是否指向同一个对象,即它们在内存中的位置是否相同。 - `equals()`:用来对比两个对象的值是否相等。 - 因此如果要对比值,我们通常会用 `equals()` 。然而,通过 `String a = "hi"; String b = "hi";` 初始化的字符串都存储在字符串常量池中,它们指向同一个对象,因此也可以用 `a == b` 来比较两个字符串的内容。 + 因此,如果要对比值,我们应该使用 `equals()` 。然而,通过 `String a = "hi"; String b = "hi";` 初始化的字符串都存储在字符串常量池中,它们指向同一个对象,因此也可以用 `a == b` 来比较两个字符串的内容。 !!! question "广度优先遍历到最底层之前,队列中的节点数量是 $2^h$ 吗?" diff --git a/docs/index.md b/docs/index.md index c5d77e9bc..50f22273f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ hide: !!! quote - “一本通俗易懂的数据结构与算法入门书,引导读者手脑并用地学习,强烈推荐算法初学者阅读。” + “一本通俗易懂的数据结构与算法入门书,引导读者手脑并用地学习,强烈推荐算法初学者阅读!” **—— 邓俊辉,清华大学计算机系教授** @@ -105,13 +105,13 @@ hide: