feat: Revised the book (#978)

* Sync recent changes to the revised Word.

* Revised the preface chapter

* Revised the introduction chapter

* Revised the computation complexity chapter

* Revised the chapter data structure

* Revised the chapter array and linked list

* Revised the chapter stack and queue

* Revised the chapter hashing

* Revised the chapter tree

* Revised the chapter heap

* Revised the chapter graph

* Revised the chapter searching

* Reivised the sorting chapter

* Revised the divide and conquer chapter

* Revised the chapter backtacking

* Revised the DP chapter

* Revised the greedy chapter

* Revised the appendix chapter

* Revised the preface chapter doubly

* Revised the figures
This commit is contained in:
Yudong Jin
2023-12-02 06:21:34 +08:00
committed by GitHub
parent b824d149cb
commit e720aa2d24
404 changed files with 1537 additions and 1558 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,14 +1,14 @@
# 一起参与创作
由于者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。
由于者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、链接失效、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以给读者提供更优质的学习资源。
所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)的 GitHub ID 将被展示在本书仓库主页上,以感谢他们对开源社区的无私奉献。
所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)的 GitHub ID 将在本书仓库、网页版和 PDF 版的主页上进行展示,以感谢他们对开源社区的无私奉献。
!!! success "开源的魅力"
纸质书的两次印刷的间隔时间往往需要数年,内容更新非常不方便。
纸质书的两次印刷的间隔时间往往较久,内容更新非常不方便。
而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。
而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。
### 内容微调
@@ -26,7 +26,7 @@
如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施以下 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 至远程仓库。
@@ -34,13 +34,13 @@
### Docker 部署
`hello-algo` 根目录下,执行以下 Docker 脚本,即可在 `http://localhost:8000` 访问本项目
`hello-algo` 根目录下,执行以下 Docker 脚本,即可在 `http://localhost:8000` 访问本项目
```shell
docker-compose up -d
```
使用以下命令即可删除部署
使用以下命令即可删除部署
```shell
docker-compose down

View File

@@ -2,7 +2,7 @@
### VSCode
本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。
本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。
### Java 环境
@@ -11,7 +11,7 @@
### 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 }`
@@ -25,7 +25,7 @@
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` ,全部勾选并安装即可。
### JavaScript 环境

View File

@@ -2,113 +2,65 @@
下表列出了书中出现的重要术语。建议你同时记住它们的中英文叫法,以便阅读英文文献。
<p align="center"> 表 <id> &nbsp; 数据结构与算法重要名词 </p>
<p align="center"> 表 <id> &nbsp; 数据结构与算法重要名词 </p>
| 中文 | English |
| -------------- | ------------------------------ |
| 算法 | algorithm |
| 数据结构 | data structure |
| 渐近复杂度分析 | asymptotic complexity analysis |
| 时间复杂度 | time complexity |
| 空间复杂度 | space complexity |
| 迭代 | iteration |
| 递归 | recursion |
| 尾递归 | tail recursion |
| 递归树 | recursion tree |
| 大 $O$ 记号 | big-$O$ notation |
| 渐近上界 | asymptotic upper bound |
| 原码 | signmagnitude |
| 码 | 1's complement |
| 码 | 2's complement |
| 数组 | array |
| 索引 | index |
| 链表 | linked list |
| 链表节点 | linked list node, list node |
| 列表 | list |
| 动态数组 | dynamic array |
| 栈 | stack |
| 队列 | queue |
| 双向队列 | double-ended queue |
| 哈希表 | hash table |
| 桶 | bucket |
| 哈希函数 | hash function |
| 哈希冲突 | hash collision |
| 负载因子 | load factor |
| 链式地址 | separate chaining |
| 开放寻址 | open addressing |
| 线性探测 | linear probing |
| 懒删除 | lazy deletion |
| 二叉树 | binary tree |
| 树节点 | tree node |
| 左子节点 | left-child node |
| 右子节点 | right-child node |
| 父节点 | parent node |
| 左子树 | left subtree |
| 右子树 | right subtree |
| 节点 | root node |
| 叶节点 | leaf node |
| | edge |
| | level |
| | degree |
| 高度 | height |
| 深度 | depth |
| 完美二叉树 | perfect binary tree |
| 完全二叉树 | complete binary tree |
| 完满二叉树 | full binary tree |
| 平衡二叉树 | balanced binary tree |
| AVL 树 | AVL tree |
| 红黑树 | red-black tree |
| 层序遍历 | level-order traversal |
| 广度优先遍历 | breadth-first traversal |
| 深度优先遍历 | depth-first traversal |
| 二叉搜索树 | binary search tree |
| 平衡二叉搜索树 | balanced binary search tree |
| 平衡因子 | balance factor |
| 堆 | heap |
| 大顶堆 | max heap |
| 小顶堆 | min heap |
| 优先队列 | priority queue |
| 堆化 | heapify |
| 图 | graph |
| 顶点 | vertex |
| 无向图 | undirected graph |
| 有向图 | directed graph |
| 连通图 | connected graph |
| 非连通图 | disconnected graph |
| 有权图 | weighted graph |
| 邻接 | adjacency |
| 路径 | path |
| 入度 | in-degree |
| 出度 | out-degree |
| 邻接矩阵 | adjacency matrix |
| 邻接表 | adjacency list |
| 广度优先搜索 | breadth-first search |
| 深度优先搜索 | depth-first search |
| 二分查找 | binary search |
| 搜索算法 | searching algorithm |
| 排序算法 | sorting algorithm |
| 选择排序 | selection sort |
| 冒泡排序 | bubble sort |
| 插入排序 | insertion sort |
| 快速排序 | quick sort |
| 归并排序 | merge sort |
| 堆排序 | heap sort |
| 桶排序 | bucket sort |
| 计数排序 | counting sort |
| 基数排序 | radix sort |
| 分治 | divide and conquer |
| 汉诺塔问题 | hanota problem |
| 回溯算法 | backtracking algorithm |
| 约束 | constraint |
| 解 | solution |
| 状态 | state |
| 剪枝 | pruning |
| 全排列问题 | permutations problem |
| 子集和问题 | subset-sum problem |
| N 皇后问题 | N-queens problem |
| 动态规划 | dynamic programming |
| 初始状态 | initial state |
| 状态转移方程 | state-trasition equation |
| 背包问题 | knapsack problem |
| 编辑距离问题 | edit distance problem |
| 贪心算法 | greedy algorithm |
| 中文 | English | 中文 | English |
| -------------- | ------------------------------ | -------------- | --------------------------- |
| 算法 | algorithm | 层序遍历 | level-order traversal |
| 数据结构 | data structure | 广度优先遍历 | breadth-first traversal |
| 渐近复杂度分析 | asymptotic complexity analysis | 深度优先遍历 | depth-first traversal |
| 时间复杂度 | time complexity | 二叉搜索树 | binary search tree |
| 空间复杂度 | space complexity | 平衡二叉搜索树 | balanced binary search tree |
| 迭代 | iteration | 平衡因子 | balance factor |
| 递归 | recursion | 堆 | heap |
| 尾递归 | tail recursion | 大顶堆 | max heap |
| 递归树 | recursion tree | 小顶堆 | min heap |
| 大 | big- | 优先队列 | priority queue |
| 记号 | notation | | |
| 渐近上界 | asymptotic upper bound | 堆化 | heapify |
| 码 | signmagnitude | 图 | graph |
| 码 | 1s complement | 顶点 | vertex |
| 补码 | 2s complement | 无向图 | undirected graph |
| 数组 | array | 有向图 | directed graph |
| 索引 | index | 连通图 | connected graph |
| 链表 | linked list | 非连通图 | disconnected graph |
| 链表节点 | linked list node, list node | 有权图 | weighted graph |
| 列表 | list | 邻接 | adjacency |
| 动态数组 | dynamic array | 路径 | path |
| 硬盘 | hard disk | 入度 | in-degree |
| 内存 | random-access memory (RAM) | 出度 | out-degree |
| 缓存 | cache memory | 邻接矩阵 | adjacency matrix |
| 缓存未命中 | cache miss | 邻接表 | adjacency list |
| 缓存命中率 | cache hit rate | 广度优先搜索 | breadth-first search |
| | stack | 深度优先搜索 | depth-first search |
| 队列 | queue | 二分查找 | binary search |
| 双向队列 | double-ended queue | 搜索算法 | searching algorithm |
| 哈希表 | hash table | 排序算法 | sorting algorithm |
| | bucket | 选择排序 | selection sort |
| 哈希函数 | hash function | 冒泡排序 | bubble sort |
| 哈希冲突 | hash collision | 插入排序 | insertion sort |
| 负载因子 | load factor | 快速排序 | quick sort |
| 链式地址 | separate chaining | 归并排序 | merge sort |
| 开放寻址 | open addressing | 堆排序 | heap sort |
| 线性探测 | linear probing | 桶排序 | bucket sort |
| 懒删除 | lazy deletion | 计数排序 | counting sort |
| 二叉树 | binary tree | 基数排序 | radix sort |
| 节点 | tree node | 分治 | divide and conquer |
| 左子节点 | left-child node | 汉诺塔问题 | hanota problem |
| 右子节点 | right-child node | 回溯算法 | backtracking algorithm |
| 父节点 | parent node | 约束 | constraint |
| 左子树 | left subtree | 解 | solution |
| 右子树 | right subtree | 状态 | state |
| 根节点 | root node | 剪枝 | pruning |
| 叶节点 | leaf node | 全排列问题 | permutations problem |
| 边 | edge | 子集和问题 | subset-sum problem |
| 层 | level | N 皇后问题 | N-queens problem |
| 度 | degree | 动态规划 | dynamic programming |
| 高度 | height | 初始状态 | initial state |
| 深度 | depth | 状态转移方程 | state-trasition equation |
| 完美二叉树 | perfect binary tree | 背包问题 | knapsack problem |
| 完全二叉树 | complete binary tree | 编辑距离问题 | edit distance problem |
| 完满二叉树 | full binary tree | 贪心算法 | greedy algorithm |
| 平衡二叉树 | balanced binary tree | | |
| AVL 树 | AVL tree | | |
| 红黑树 | red-black tree | | |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,6 +1,6 @@
# 数组
「数组 array」是一种线性数据结构其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
「数组 array」是一种线性数据结构其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
![数组定义与存储方式](array.assets/array_definition.png)
@@ -8,7 +8,7 @@
### 初始化数组
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$
=== "Python"
@@ -115,13 +115,13 @@
### 访问元素
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,我们可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问元素。
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,我们可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问元素。
![数组元素的内存地址计算](array.assets/array_memory_location_calculation.png)
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 是合理的。
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 是合理的。
在数组中访问元素非常高效,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
在数组中访问元素非常高效,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
```src
[file]{array}-[class]{}-[func]{random_access}
@@ -129,11 +129,11 @@
### 插入元素
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
![数组插入元素示例](array.assets/array_insert_element.png)
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
```src
[file]{array}-[class]{}-[func]{insert}
@@ -141,7 +141,7 @@
### 删除元素
同理,如下图所示,若想删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
同理,如下图所示,若想删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
![数组删除元素示例](array.assets/array_remove_element.png)
@@ -155,11 +155,11 @@
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
- **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
### 遍历数组
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素
```src
[file]{array}-[class]{}-[func]{traverse}
@@ -179,32 +179,32 @@
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,**数组的长度是不可变的**。
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次拷贝到新数组。这是一个 $O(n)$ 的操作,在数组很大的情况下非常耗时
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次复制到新数组。这是一个 $O(n)$ 的操作,在数组很大的情况下非常耗时。代码如下所示:
```src
[file]{array}-[class]{}-[func]{extend}
```
## 数组优点与局限性
## 数组优点与局限性
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来优化数据结构的操作效率。
- **空间效率高**: 数组为数据分配了连续的内存块,无须额外的结构开销。
- **支持随机访问**: 数组允许在 $O(1)$ 时间内访问任何元素。
- **缓存局部性**: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
- **空间效率高**数组为数据分配了连续的内存块,无须额外的结构开销。
- **支持随机访问**数组允许在 $O(1)$ 时间内访问任何元素。
- **缓存局部性**当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
连续空间存储是一把双刃剑,其存在以下缺点
连续空间存储是一把双刃剑,其存在以下局限性
- **插入与删除效率低**:当数组中元素较多时,插入与删除操作需要移动大量的元素。
- **长度不可变**: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
- **空间浪费**: 如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
- **插入与删除效率低**当数组中元素较多时,插入与删除操作需要移动大量的元素。
- **长度不可变**数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
- **空间浪费**如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
## 数组典型应用
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
- **随机访问**:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽
- **随机访问**:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现随机抽
- **排序和搜索**:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
- **查找表**:当我们需要快速查找一个元素或者需要查找一个元素的对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
- **查找表**:当需要快速查找一个元素或对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
- **机器学习**:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
- **数据结构实现**:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。

View File

@@ -4,7 +4,7 @@
「链表 linked list」是一种线性数据结构其中的每个元素都是一个节点对象各个节点通过“引用”相连接。引用记录了下一个节点的内存地址通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。
![链表定义与存储方式](linked_list.assets/linkedlist_definition.png)
@@ -183,7 +183,7 @@
### 初始化链表
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建节点之间的引用关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
=== "Python"
@@ -195,7 +195,7 @@
n2 = ListNode(2)
n3 = ListNode(5)
n4 = ListNode(4)
# 构建引用指向
# 构建节点之间的引用
n0.next = n1
n1.next = n2
n2.next = n3
@@ -212,7 +212,7 @@
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
@@ -229,7 +229,7 @@
ListNode n2 = new ListNode(2);
ListNode n3 = new ListNode(5);
ListNode n4 = new ListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -246,7 +246,7 @@
ListNode n2 = new(2);
ListNode n3 = new(5);
ListNode n4 = new(4);
// 构建引用指向
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -263,7 +263,7 @@
n2 := NewListNode(2)
n3 := NewListNode(5)
n4 := NewListNode(4)
// 构建引用指向
// 构建节点之间的引用
n0.Next = n1
n1.Next = n2
n2.Next = n3
@@ -280,7 +280,7 @@
let n2 = ListNode(x: 2)
let n3 = ListNode(x: 5)
let n4 = ListNode(x: 4)
// 构建引用指向
// 构建节点之间的引用
n0.next = n1
n1.next = n2
n2.next = n3
@@ -297,7 +297,7 @@
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -314,7 +314,7 @@
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -331,7 +331,7 @@
ListNode n2 = ListNode(2);
ListNode n3 = ListNode(5);
ListNode n4 = ListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
@@ -349,7 +349,7 @@
let n3 = Rc::new(RefCell::new(ListNode { val: 5, next: None }));
let n4 = Rc::new(RefCell::new(ListNode { val: 4, next: None }));
// 构建引用指向
// 构建节点之间的引用
n0.borrow_mut().next = Some(n1.clone());
n1.borrow_mut().next = Some(n2.clone());
n2.borrow_mut().next = Some(n3.clone());
@@ -366,7 +366,7 @@
ListNode* n2 = newListNode(2);
ListNode* n3 = newListNode(5);
ListNode* n4 = newListNode(4);
// 构建引用指向
// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
@@ -383,18 +383,18 @@
var n2 = inc.ListNode(i32){.val = 2};
var n3 = inc.ListNode(i32){.val = 5};
var n4 = inc.ListNode(i32){.val = 4};
// 构建引用指向
// 构建节点之间的引用
n0.next = &n1;
n1.next = &n2;
n2.next = &n3;
n3.next = &n4;
```
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可记作链表 `n0` 。
### 插入节点
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 `n0` 和 `n1` 之间插入一个新节点 `P` **则只需改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 `n0` 和 `n1` 之间插入一个新节点 `P` **则只需改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
@@ -418,7 +418,7 @@
### 访问节点
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
```src
[file]{linked_list}-[class]{}-[func]{access}
@@ -426,15 +426,15 @@
### 查找节点
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于线性查找。
遍历链表,查找其中值为 `target` 的节点,输出节点在链表中的索引。此过程也属于线性查找。代码如下所示:
```src
[file]{linked_list}-[class]{}-[func]{find}
```
## 数组 VS 链表
## 数组 vs. 链表
下表总结对比了数组和链表的各项特点操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
下表总结了数组和链表的各项特点并对比了操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
<p align="center"> 表 <id> &nbsp; 数组与链表的效率对比 </p>
@@ -451,8 +451,8 @@
如下图所示,常见的链表类型包括三种。
- **单向链表**:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。
- **环形链表**:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
- **单向链表**:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。
- **环形链表**:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
- **双向链表**:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
=== "Python"
@@ -652,17 +652,17 @@
单向链表通常用于实现栈、队列、哈希表和图等数据结构。
- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
- **哈希表**:链地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
- **图**:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
- **哈希表**:链地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
- **图**:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
双向链表常用于需要快速查找前一个和一个元素的场景。
双向链表常用于需要快速查找前一个和一个元素的场景。
- **高级数据结构**比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
- **LRU 算法**:在缓存淘汰算法LRU我们需要快速找到最近最少使用的数据以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
- **LRU 算法**在缓存淘汰LRU算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
环链表常用于需要周期性操作的场景,比如操作系统的资源调度。
链表常用于需要周期性操作的场景,比如操作系统的资源调度。
- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法它需要对一组进程进行循环。每个进程被赋予一个时间片当时间片用完时CPU 将切换到下一个进程。这种循环操作可以通过环链表来实现。
- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用到循环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环链表,以便实现无缝播放。
- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法它需要对一组进程进行循环。每个进程被赋予一个时间片当时间片用完时CPU 将切换到下一个进程。这种循环操作可以通过环链表来实现。
- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环链表,以便实现无缝播放。

View File

@@ -1,9 +1,9 @@
# 列表
「列表 list」是一个抽象的数据结构概念它表示元素的有序集合支持元素访问、修改、添加、删除和遍历等操作使用者考虑容量限制的问题。列表可以基于链表或数组实现。
「列表 list」是一个抽象的数据结构概念它表示元素的有序集合支持元素访问、修改、添加、删除和遍历等操作使用者考虑容量限制的问题。列表可以基于链表或数组实现。
- 链表天然可以被看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作一个具有长度限制的列表。
- 链表天然可以被看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作一个具有长度限制的列表。
当使用数组实现列表时,**长度不可变的性质会导致列表的实用性降低**。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间的浪费。
@@ -15,7 +15,7 @@
### 初始化列表
我们通常使用“无初始值”和“有初始值”这两种初始化方法
我们通常使用“无初始值”和“有初始值”这两种初始化方法
=== "Python"
@@ -264,14 +264,14 @@
# 清空列表
nums.clear()
# 尾部添加元素
# 尾部添加元素
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
# 中间插入元素
# 中间插入元素
nums.insert(3, 6) # 在索引 3 处插入数字 6
# 删除元素
@@ -284,14 +284,14 @@
/* 清空列表 */
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
/* 删除元素 */
@@ -304,14 +304,14 @@
/* 清空列表 */
nums.clear();
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.add(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -324,14 +324,14 @@
/* 清空列表 */
nums.Clear();
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.Add(1);
nums.Add(3);
nums.Add(2);
nums.Add(5);
nums.Add(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.Insert(3, 6);
/* 删除元素 */
@@ -344,14 +344,14 @@
/* 清空列表 */
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
/* 删除元素 */
@@ -364,14 +364,14 @@
/* 清空列表 */
nums.removeAll()
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
/* 中间插入元素 */
/* 中间插入元素 */
nums.insert(6, at: 3) // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -384,14 +384,14 @@
/* 清空列表 */
nums.length = 0;
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.splice(3, 0, 6);
/* 删除元素 */
@@ -404,14 +404,14 @@
/* 清空列表 */
nums.length = 0;
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.splice(3, 0, 6);
/* 删除元素 */
@@ -424,14 +424,14 @@
/* 清空列表 */
nums.clear();
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.insert(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -444,14 +444,14 @@
/* 清空列表 */
nums.clear();
/* 尾部添加元素 */
/* 尾部添加元素 */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* 中间插入元素 */
/* 中间插入元素 */
nums.insert(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
@@ -470,14 +470,14 @@
// 清空列表
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
// 删除元素
@@ -669,7 +669,7 @@
### 拼接列表
给定一个新列表 `nums1` ,我们可以将该列表拼接到原列表的尾部。
给定一个新列表 `nums1` ,我们可以将拼接到原列表的尾部。
=== "Python"
@@ -770,7 +770,7 @@
### 排序列表
完成列表排序后,我们便可以使用在数组类算法题中经常考的“二分查找”和“双指针”算法。
完成列表排序后,我们便可以使用在数组类算法题中经常考的“二分查找”和“双指针”算法。
=== "Python"
@@ -857,13 +857,13 @@
## 列表实现
许多编程语言都提供内置列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
许多编程语言内置列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
- **数量记录**:声明一个变量 `size` ,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
```src
[file]{my_list}-[class]{my_list}-[func]{}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -6,11 +6,11 @@
## 计算机存储设备
计算机中包括三种不同类型的存储设备:硬盘、内存、缓存。下表展示了它们在计算机系统中的不同角色和性能特点。
计算机中包括三种不同类型的存储设备:硬盘 hard disk」、「内存 random-access memory, RAM」、「缓存 cache memory」。下表展示了它们在计算机系统中的不同角色和性能特点。
<p align="center"> 表 <id> &nbsp; 计算机的存储设备 </p>
| | 硬盘 Hard Disk | 内存 RAM | 缓存 Cache |
| | 硬盘 | 内存 | 缓存 |
| ------ | ---------------------------------------- | -------------------------------------- | ------------------------------------------------- |
| 用途 | 长期存储数据,包括操作系统、程序、文件等 | 临时存储当前运行的程序和正在处理的数据 | 存储经常访问的数据和指令,减少 CPU 访问内存的次数 |
| 易失性 | 断电后数据不会丢失 | 断电后数据会丢失 | 断电后数据会丢失 |
@@ -31,7 +31,7 @@
总的来说,**硬盘用于长期存储大量数据,内存用于临时存储程序运行中正在处理的数据,而缓存则用于存储经常访问的数据和指令**,以提高程序运行效率。这三者共同协作,确保计算机系统的高效运行。
如下图所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作 CPU 的一部分,**它通过智能地从内存加载数据**,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
如下图所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作 CPU 的一部分,**它通过智能地从内存加载数据**,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
![硬盘、内存和缓存之间的数据流通](ram_and_cache.assets/computer_storage_devices.png)

View File

@@ -5,7 +5,7 @@
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。
- 常见的链表类型包括单向链表、环链表、双向链表,它们分别具有各自的应用场景。
- 常见的链表类型包括单向链表、环链表、双向链表,它们分别具有各自的应用场景。
- 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现,其保留了数组的优势,同时可以灵活调整长度。
- 列表的出现大幅地提高了数组的实用性,但可能导致部分内存空间浪费。
- 程序运行时,数据主要存储在内存中。数组提供更高的内存空间效率,而链表则在内存使用上更加灵活。
@@ -26,7 +26,7 @@
链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、string、object 等。
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,如果数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes 那么此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
```shell
# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引
@@ -34,17 +34,17 @@
!!! question "删除节点后,是否需要把 `P.next` 设为 $\text{None}$ 呢?"
不修改 `P.next` 也可以。从该链表的角度看,从头节点遍历到尾节点已经不到 `P` 了。这意味着节点 `P` 已经从链表中删除了,此时节点 `P` 指向哪里都不会对这条链表产生影响
不修改 `P.next` 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 `P` 了。这意味着节点 `P` 已经从链表中删除了,此时节点 `P` 指向哪里都不会对链表产生影响。
从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收的语言来说,节点 `P` 是否被回收取决于是否仍存在指向它的引用,而不是 `P.next` 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收机制的语言来说,节点 `P` 是否被回收取决于是否仍存在指向它的引用,而不是 `P.next` 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
!!! question "在链表中插入和删除操作的时间复杂度是 $O(1)$ 。但是增删之前都需要 $O(n)$ 查找元素,那为什么时间复杂度不是 $O(n)$ 呢?"
!!! question "在链表中插入和删除操作的时间复杂度是 $O(1)$ 。但是增删之前都需要 $O(n)$ 的时间查找元素,那为什么时间复杂度不是 $O(n)$ 呢?"
如果是先查找元素、再删除元素,确实是 $O(n)$ 。然而,链表的 $O(1)$ 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 $O(1)$ 。
如果是先查找元素、再删除元素,时间复杂度确实是 $O(n)$ 。然而,链表的 $O(1)$ 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 $O(1)$ 。
!!! question "图“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?"
!!! question "图“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?"
文中的示意图只是定性表示,定量表示需要根据具体情况进行分析。
示意图只是定性表示,定量表示需要根据具体情况进行分析。
- 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。
- 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。
@@ -53,22 +53,22 @@
如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 $O(n)$ 。
!!! question "“列表的出现大大提升了数组的实用性,但副作用是会造成部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?"
!!! question "“列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?"
这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,比如 $\times 1.5$ 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,比如 $\times 1.5$ 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
!!! question "在 Python 中初始化 `n = [1, 2, 3]` 后,这 3 个元素的地址是相连的,但是初始化 `m = [2, 1, 3]` 会发现它们每个元素的 id 并不是连续的,而是分别跟 `n` 中的相同。这些元素地址不连续,那么 `m` 还是数组吗?"
!!! question "在 Python 中初始化 `n = [1, 2, 3]` 后,这 3 个元素的地址是相连的,但是初始化 `m = [2, 1, 3]` 会发现它们每个元素的 id 并不是连续的,而是分别跟 `n` 中的相同。这些元素地址不连续,那么 `m` 还是数组吗?"
假如把列表元素换成链表节点 `n = [n1, n2, n3, n4, n5]` ,通常情况下这个节点对象也是被分散存储在内存各处。然而,给定一个列表索引,我们仍然可以在 $O(1)$ 时间内获取节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
假如把列表元素换成链表节点 `n = [n1, n2, n3, n4, n5]` ,通常情况下这 5 个节点对象也分散存储在内存各处。然而,给定一个列表索引,我们仍然可以在 $O(1)$ 时间内获取节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
与许多语言不同的是,在 Python 中数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续
与许多语言不同Python 中数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续。
!!! question "C++ STL 里面的 `std::list` 已经实现了双向链表,但好像一些算法书上不怎么直接用这个,是不是有什么局限性呢?"
!!! question "C++ STL 里面的 `std::list` 已经实现了双向链表,但好像一些算法书上不怎么直接使用它,是不是因为有什么局限性呢"
一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。
一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。
- 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 `std::list` 通常比 `std::vector` 更占用空间。
- 缓存不友好:由于数据不是连续存放的,`std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
- 缓存不友好:由于数据不是连续存放的,因此 `std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 `stack` 和 `queue` ,而非链表。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -2,13 +2,13 @@
「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法它的核心思想是从一个初始状态出发暴力搜索所有可能的解决方案当遇到正确的解则将其记录直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
!!! question "例题一"
给定一二叉树,搜索并记录所有值为 $7$ 的节点,请返回节点列表。
给定一二叉树,搜索并记录所有值为 $7$ 的节点,请返回节点列表。
对于此题,我们前序遍历这树,并判断当前节点的值是否为 $7$ ,若是则将该节点的值加入结果列表 `res` 之中。相关过程实现如下图和以下代码所示
对于此题,我们前序遍历这树,并判断当前节点的值是否为 $7$ ,若是则将该节点的值加入结果列表 `res` 之中。相关过程实现如下图和以下代码所示
```src
[file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order}
@@ -28,7 +28,7 @@
在二叉树中搜索所有值为 $7$ 的节点,**请返回根节点到这些节点的路径**。
在例题一代码的基础上,我们需要借助一个列表 `path` 记录访问过的节点路径。当访问到值为 $7$ 的节点时,则复制 `path` 并添加进结果列表 `res` 。遍历完成后,`res` 中保存的就是所有的解。
在例题一代码的基础上,我们需要借助一个列表 `path` 记录访问过的节点路径。当访问到值为 $7$ 的节点时,则复制 `path` 并添加进结果列表 `res` 。遍历完成后,`res` 中保存的就是所有的解。代码如下所示:
```src
[file]{preorder_traversal_ii_compact}-[class]{}-[func]{pre_order}
@@ -36,7 +36,7 @@
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。
观察下图所示的过程,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作互为逆向
观察下图所示的过程,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作互为逆向。
=== "<1>"
![尝试与回退](backtracking_algorithm.assets/preorder_find_paths_step1.png)
@@ -79,13 +79,13 @@
在二叉树中搜索所有值为 $7$ 的节点,请返回根节点到这些节点的路径,**并要求路径中不包含值为 $3$ 的节点**。
为了满足以上约束条件,**我们需要添加剪枝操作**:在搜索过程中,若遇到值为 $3$ 的节点,则提前返回,停止继续搜索。
为了满足以上约束条件,**我们需要添加剪枝操作**:在搜索过程中,若遇到值为 $3$ 的节点,则提前返回,不再继续搜索。代码如下所示:
```src
[file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order}
```
剪枝是一个非常形象的名词。如下图所示,在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提高了搜索效率。
剪枝是一个非常形象的名词。如下图所示,在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提高了搜索效率。
![根据约束条件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
@@ -93,7 +93,7 @@
接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
在以下框架代码中,`state` 表示问题的当前状态,`choices` 表示当前状态下可以做出的选择
在以下框架代码中,`state` 表示问题的当前状态,`choices` 表示当前状态下可以做出的选择
=== "Python"
@@ -104,7 +104,7 @@
if is_solution(state):
# 记录解
record_solution(state, res)
# 停止继续搜索
# 不再继续搜索
return
# 遍历所有选择
for choice in choices:
@@ -126,7 +126,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -152,7 +152,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -178,7 +178,7 @@
if (IsSolution(state)) {
// 记录解
RecordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -204,7 +204,7 @@
if isSolution(state) {
// 记录解
recordSolution(state, res)
// 停止继续搜索
// 不再继续搜索
return
}
// 遍历所有选择
@@ -230,7 +230,7 @@
if isSolution(state: state) {
// 记录解
recordSolution(state: state, res: &res)
// 停止继续搜索
// 不再继续搜索
return
}
// 遍历所有选择
@@ -256,7 +256,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -282,7 +282,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -308,7 +308,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -334,7 +334,7 @@
if is_solution(state) {
// 记录解
record_solution(state, res);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -360,7 +360,7 @@
if (isSolution(state)) {
// 记录解
recordSolution(state, res, numRes);
// 停止继续搜索
// 不再继续搜索
return;
}
// 遍历所有选择
@@ -383,7 +383,7 @@
```
接下来,我们基于框架代码来解决例题三。状态 `state` 为节点遍历路径,选择 `choices` 为当前节点的左子节点和右子节点,结果 `res` 是路径列表
接下来,我们基于框架代码来解决例题三。状态 `state` 为节点遍历路径,选择 `choices` 为当前节点的左子节点和右子节点,结果 `res` 是路径列表
```src
[file]{preorder_traversal_iii_template}-[class]{}-[func]{backtrack}
@@ -393,37 +393,37 @@
![保留与删除 return 的搜索过程对比](backtracking_algorithm.assets/backtrack_remove_return_or_not.png)
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰,但通用性更好。实际上,**许多回溯问题可以在该框架下解决**。我们只需根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法即可。
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰,但通用性更好。实际上,**许多回溯问题可以在该框架下解决**。我们只需根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法即可。
## 常用术语
为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。
为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例,如下表所示
<p align="center"> 表 <id> &nbsp; 常见的回溯算法术语 </p>
| 名词 | 定义 | 例题三 |
| ------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 解 Solution | 解是满足问题特定条件的答案,可能有一个或多个 | 根节点到节点 $7$ 的满足约束条件的所有路径 |
| 约束条件 Constraint | 约束条件是问题中限制解的可行性的条件,通常用于剪枝 | 路径中不包含节点 $3$ |
| 状态 State | 状态表示问题在某一时刻的情况,包括已经做出的选择 | 当前已访问的节点路径,即 `path` 节点列表 |
| 尝试 Attempt | 尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加进 `path` ,判断节点的值是否为 $7$ |
| 回退 Backtracking | 回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态 | 当越过叶节点、结束节点访问、遇到值为 $3$ 的节点时终止搜索,函数返回 |
| 剪枝 Pruning | 剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可提高搜索效率 | 当遇到值为 $3$ 的节点时,则终止继续搜索 |
| 名词 | 定义 | 例题三 |
| ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 解solution | 解是满足问题特定条件的答案,可能有一个或多个 | 根节点到节点 $7$ 的满足约束条件的所有路径 |
| 约束条件constraint | 约束条件是问题中限制解的可行性的条件,通常用于剪枝 | 路径中不包含节点 $3$ |
| 状态state | 状态表示问题在某一时刻的情况,包括已经做出的选择 | 当前已访问的节点路径,即 `path` 节点列表 |
| 尝试attempt | 尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加进 `path` ,判断节点的值是否为 $7$ |
| 回退backtracking | 回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态 | 当越过叶节点、结束节点访问、遇到值为 $3$ 的节点时终止搜索,函数返回 |
| 剪枝pruning | 剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可提高搜索效率 | 当遇到值为 $3$ 的节点时,则不再继续搜索 |
!!! tip
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。
## 优与局限性
## 优与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,**回溯算法的运行效率可能难以接受**。
- **时间**:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- **空间**:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此,**回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案**。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,**关键是如何进行效率优化**,常见的效率优化方法有两种。
即便如此,**回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案**。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,**关键是如何优化效率**,常见的效率优化方法有两种。
- **剪枝**:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
- **启发式搜索**:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
@@ -436,7 +436,7 @@
- 全排列问题:给定一个集合,求出其所有可能的排列组合。
- 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
- 汉诺塔问题:给定三柱子和一系列大小不同的圆盘,要求将所有圆盘从一柱子移动到另一柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
- 汉诺塔问题:给定三柱子和一系列大小不同的圆盘,要求将所有圆盘从一柱子移动到另一柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
**约束满足问题**:这类问题的目标是找到满足所有约束条件的解。
@@ -450,8 +450,8 @@
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
- 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
请注意,对于许多组合优化问题,回溯不是最优解决方案。
请注意,对于许多组合优化问题,回溯不是最优解决方案。
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
- 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决。
- 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。

View File

@@ -2,7 +2,7 @@
!!! question
根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或一斜线上的棋子。给定 $n$ 个皇后和一个 $n \times n$ 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
根据国际象棋的规则,皇后可以攻击与同处一行、一列或一斜线上的棋子。给定 $n$ 个皇后和一个 $n \times n$ 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如下图所示,当 $n = 4$ 时,共可以找到两个解。从回溯算法的角度看,$n \times n$ 大小的棋盘共有 $n^2$ 个格子,给出了所有的选择 `choices` 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 `state`
@@ -18,11 +18,11 @@
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
下图所示为 $4$ 皇后问题的逐行放置过程。受画幅限制,下图仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
下图所示为 $4$ 皇后问题的逐行放置过程。受画幅限制,下图仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
![逐行放置策略](n_queens_problem.assets/n_queens_placing.png)
本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。
本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。
### 列与对角线剪枝
@@ -30,7 +30,7 @@
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 $(row, col)$ ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,**即对角线上所有格子的 $row - col$ 为恒定值**。
也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助下图所示的数组 `diags1` 记录每条主对角线上是否有皇后。
也就是说,如果两个格子满足 $row_1 - col_1 = row_2 - col_2$ ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助下图所示的数组 `diags1` 记录每条主对角线上是否有皇后。
同理,**次对角线上的所有格子的 $row + col$ 是恒定值**。我们同样也可以借助数组 `diags2` 来处理次对角线约束。
@@ -44,6 +44,6 @@
[file]{n_queens}-[class]{}-[func]{n_queens}
```
逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n$、$n-1$、$\dots$、$2$、$1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n$、$n-1$、$\dots$、$2$、$1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
数组 `state` 使用 $O(n^2)$ 空间,数组 `cols``diags1``diags2` 皆使用 $O(n)$ 空间。最大递归深度为 $n$ ,使用 $O(n)$ 栈帧空间。因此,**空间复杂度为 $O(n^2)$** 。

View File

@@ -1,6 +1,6 @@
# 全排列问题
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出中元素的所有可能的排列。
下表列举了几个示例数据,包括输入数组和对应的所有排列。
@@ -16,13 +16,13 @@
!!! question
输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。
输入一个整数数组,中不包含重复元素,返回所有可能的排列。
从回溯算法的角度看,**我们可以把生成排列的过程想象成一系列选择的结果**。假设输入数组为 $[1, 2, 3]$ ,如果我们先选择 $1$再选择 $3$最后选择 $2$ ,则获得排列 $[1, 3, 2]$ 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯算法的角度看,**我们可以把生成排列的过程想象成一系列选择的结果**。假设输入数组为 $[1, 2, 3]$ ,如果我们先选择 $1$ 再选择 $3$ 最后选择 $2$ ,则获得排列 $[1, 3, 2]$ 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 `choices` 是输入数组中的所有元素,状态 `state` 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,**因此 `state` 中的所有元素都应该是唯一的**。
如下图所示,我们可以将搜索过程展开成一递归树,树中的每个节点代表当前状态 `state` 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
如下图所示,我们可以将搜索过程展开成一递归树,树中的每个节点代表当前状态 `state` 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
![全排列的递归树](permutations_problem.assets/permutations_i.png)
@@ -31,17 +31,17 @@
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 `selected` ,其中 `selected[i]` 表示 `choices[i]` 是否已被选择,并基于它实现以下剪枝操作。
- 在做出选择 `choice[i]` 后,我们就将 `selected[i]` 赋值为 $\text{True}$ ,代表它已被选择。
- 遍历选择列表 `choices` 时,跳过所有已被选择的节点,即剪枝。
- 遍历选择列表 `choices` 时,跳过所有已被选择的节点,即剪枝。
如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。
![全排列剪枝示例](permutations_problem.assets/permutations_i_pruning.png)
观察上图发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。
观察上图发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 减小至 $O(n!)$ 。
### 代码实现
想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将们展开在 `backtrack()` 函数中
想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短整体代码,我们不单独实现框架代码中的各个函数,而是将们展开在 `backtrack()` 函数中
```src
[file]{permutations_i}-[class]{}-[func]{permutations_i}
@@ -55,25 +55,25 @@
假设输入数组为 $[1, 1, 2]$ 。为了方便区分两个重复元素 $1$ ,我们将第二个 $1$ 记为 $\hat{1}$ 。
如下图所示,上述方法生成的排列有一半是重复的。
如下图所示,上述方法生成的排列有一半是重复的。
![重复排列](permutations_problem.assets/permutations_ii.png)
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝**,这样可以进一步提升算法效率。
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝**,这样可以进一步提升算法效率。
### 相等元素剪枝
观察下图,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝
观察下图,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝。
同理,在第一轮选择 $2$ 之后,第二轮选择中的 $1$ 和 $\hat{1}$ 也会产生重复分支,因此也应将第二轮的 $\hat{1}$ 剪枝。
本质上看,**我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次**。
本质上看,**我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次**。
![重复排列剪枝](permutations_problem.assets/permutations_ii_pruning.png)
### 代码实现
在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝
在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝
```src
[file]{permutations_ii}-[class]{}-[func]{permutations_ii}
@@ -85,10 +85,10 @@
### 两种剪枝对比
请注意,虽然 `selected``duplicated` 都用剪枝,但两者的目标不同
请注意,虽然 `selected``duplicated` 都用剪枝,但两者的目标不同。
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是防止 `choices` 中的任一元素在 `state` 中重复出现。
- **相等元素剪枝**:每轮选择(每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(`for` 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是防止 `choices` 中的任一元素在 `state` 中重复出现。
- **相等元素剪枝**:每轮选择(每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(`for` 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。
下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。

View File

@@ -9,13 +9,13 @@
例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,解为 $\{3, 3, 3\}, \{4, 5\}$ 。需要注意以下两点。
- 输入集合中的元素可以被无限次重复选取。
- 子集不区分元素顺序,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。
- 子集不区分元素顺序,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。
### 参考全排列解法
类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 `target` 时,就将子集记录至结果列表。
而与全排列问题不同的是,**本题集合中的元素可以被无限次选取**,因此无须借助 `selected` 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码
而与全排列问题不同的是,**本题集合中的元素可以被无限次选取**,因此无须借助 `selected` 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码
```src
[file]{subset_sum_i_naive}-[class]{}-[func]{subset_sum_i_naive}
@@ -23,7 +23,7 @@
向以上代码输入数组 $[3, 4, 5]$ 和目标元素 $9$ ,输出结果为 $[3, 3, 3], [4, 5], [5, 4]$ 。**虽然成功找出了所有和为 $9$ 的子集,但其中存在重复的子集 $[4, 5]$ 和 $[5, 4]$** 。
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如下图所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是两个不同的分支,但两者对应同一个子集。
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如下图所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是不同的分支,但对应同一个子集。
![子集搜索与越界剪枝](subset_sum_problem.assets/subset_sum_i_naive.png)
@@ -37,13 +37,13 @@
**我们考虑在搜索过程中通过剪枝进行去重**。观察下图,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。
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.` 步中描述的子集完全重复。
![不同选择顺序导致的重复子集](subset_sum_problem.assets/subset_sum_i_pruning.png)
@@ -51,18 +51,18 @@
### 代码实现
为实现该剪枝,我们初始化变量 `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$ 时记录解。
```src
[file]{subset_sum_i}-[class]{}-[func]{subset_sum_i}
```
下图所示为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入以上代码后的整体回溯过程。
下图所示为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入以上代码后的整体回溯过程。
![子集和 I 回溯过程](subset_sum_problem.assets/subset_sum_i.png)
@@ -80,9 +80,9 @@
### 相等元素剪枝
为解决此问题,**我们需要限制相等元素在每一轮中只被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
为解决此问题,**我们需要限制相等元素在每一轮中只被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
与此同时,**本题规定数组中的每个元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样能去除重复子集,也能避免重复选择元素。
与此同时,**本题规定每个数组元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样能去除重复子集,也能避免重复选择元素。
### 代码实现

View File

@@ -5,13 +5,13 @@
- 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满足条件的解则记录,直至找到所有解或遍历完成后结束。
- 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两个方向相反的操作。
- 回溯问题通常包含多个约束条件,它们可用于实现剪枝操作。剪枝可以提前结束不必要的搜索分支,大幅提升搜索效率。
- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在更高效率或更好效果的解法。
- 全排列问题旨在搜索给定集合的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。
- 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在效率更高或效果更好的解法。
- 全排列问题旨在搜索给定集合元素的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。
- 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每轮中只能被选择一次,这通常借助一个哈希表来实现。
- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起点,从而将生成重复子集的搜索分支进行剪枝。
- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起点,从而将生成重复子集的搜索分支进行剪枝。
- 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。
- $n$ 皇后旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
- $n$ 皇后问题旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
### Q & A

View File

@@ -8,15 +8,15 @@
### 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$
```src
[file]{iteration}-[class]{}-[func]{for_loop}
```
下图展示了该求和函数的流程框图。
下图该求和函数的流程框图。
![求和函数的流程框图](iteration_and_recursion.assets/iteration.png)
@@ -24,17 +24,17 @@
### while 循环
`for` 循环类似,`while` 循环也是一种实现迭代的方法。在 `while` 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。
`for` 循环类似,`while` 循环也是一种实现迭代的方法。在 `while` 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。
下面我们用 `while` 循环来实现求和 $1 + 2 + \dots + n$
下面我们用 `while` 循环来实现求和 $1 + 2 + \dots + n$
```src
[file]{iteration}-[class]{}-[func]{while_loop}
```
**`while` 循环比 `for` 循环的自由度更高**。在 `while` 循环中,我们可以自由设计条件变量的初始化和更新步骤。
**`while` 循环比 `for` 循环的自由度更高**。在 `while` 循环中,我们可以自由设计条件变量的初始化和更新步骤。
例如在以下代码中,条件变量 $i$ 每轮进行两次更新,这种情况就不太方便用 `for` 循环实现
例如在以下代码中,条件变量 $i$ 每轮进行两次更新,这种情况就不太方便用 `for` 循环实现
```src
[file]{iteration}-[class]{}-[func]{while_loop_ii}
@@ -44,19 +44,19 @@
### 嵌套循环
我们可以在一个循环结构内嵌套另一个循环结构,以 `for` 循环为例:
我们可以在一个循环结构内嵌套另一个循环结构,下面`for` 循环为例:
```src
[file]{iteration}-[class]{}-[func]{nested_for_loop}
```
下图给出了该嵌套循环的流程框图。
下图该嵌套循环的流程框图。
![嵌套循环的流程框图](iteration_and_recursion.assets/nested_iteration.png)
在这种情况下,函数的操作数量与 $n^2$ 成正比,或者说算法运行时间和输入数据大小 $n$ 成“平方关系”。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”以此类推。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”以此类推。
## 递归
@@ -86,7 +86,7 @@
- **迭代**:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- **递归**:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。
以上述求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。
- **迭代**:在循环中模拟求和过程,从 $1$ 遍历到 $n$ ,每轮执行求和操作,即可求得 $f(n)$ 。
- **递归**:将问题分解为子问题 $f(n) = n + f(n-1)$ ,不断(递归地)分解下去,直至基本情况 $f(1) = 1$ 时终止。
@@ -102,22 +102,22 @@
![递归调用深度](iteration_and_recursion.assets/recursion_sum_depth.png)
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错。
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错
### 尾递归
有趣的是,**如果函数在返回前的最后一步才进行递归调用**,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。
- **普通递归**:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无继续执行其他操作,因此系统无保存上一层函数的上下文。
- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无继续执行其他操作,因此系统无保存上一层函数的上下文。
以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归
以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归
```src
[file]{recursion}-[class]{}-[func]{tail_recur}
```
尾递归的执行过程如下图所示。对比普通递归和尾递归,求和操作的执行点是不同的。
尾递归的执行过程如下图所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。
- **普通递归**:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- **尾递归**:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
@@ -126,7 +126,7 @@
!!! tip
请注意许多编译器或解释器并不支持尾递归优化。例如Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。
请注意许多编译器或解释器并不支持尾递归优化。例如Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。
### 递归树
@@ -141,19 +141,19 @@
- 数列的前两个数字为 $f(1) = 0$ 和 $f(2) = 1$ 。
- 数列中的每个数字是前两个数字的和,即 $f(n) = f(n - 1) + f(n - 2)$ 。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字
```src
[file]{recursion}-[class]{}-[func]{fib}
```
观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如下图所示,这样不断递归调用下去,最终将产生一层数为 $n$ 的「递归树 recursion tree」。
观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如下图所示,这样不断递归调用下去,最终将产生一层数为 $n$ 的「递归树 recursion tree」。
![斐波那契数列的递归树](iteration_and_recursion.assets/recursion_tree.png)
本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略至关重要
本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用这种思维方式。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。
## 两者对比

View File

@@ -2,7 +2,7 @@
在算法设计中,我们先后追求以下两个层面的目标。
1. **找到问题解法**:算法需要在规定的输入范围内可靠地求得问题的正确解。
1. **找到问题解法**:算法需要在规定的输入范围内可靠地求得问题的正确解。
2. **寻求最优解法**:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。
@@ -10,23 +10,23 @@
- **时间效率**:算法运行速度的快慢。
- **空间效率**:算法占用内存空间的大小。
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,而指导算法设计与优化过程。
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,而指导算法设计与优化过程。
效率评估方法主要分为两种:实际测试、理论估算。
## 实际测试
假设我们现在有算法 `A` 和算法 `B` ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大局限性。
假设我们现在有算法 `A` 和算法 `B` ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大局限性。
一方面,**难以排除测试环境的干扰因素**。硬件配置会影响算法的性能表现。比如在某台计算机中,算法 `A` 的运行时间比算法 `B` 短;但在另一台配置不同的计算机中,我们可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。
一方面,**难以排除测试环境的干扰因素**。硬件配置会影响算法的性能。比如在某台计算机中,算法 `A` 的运行时间比算法 `B` 短;但在另一台配置不同的计算机中,可能得到相反的测试结果。这意味着我们需要在各种机器上进行测试,统计平均效率,而这是不现实的。
另一方面,**展开完整测试非常耗费资源**。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 `A` 的运行时间比算法 `B` 更少;而输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。
另一方面,**展开完整测试非常耗费资源**。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 `A` 的运行时间比算法 `B` ;而输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,我们需要测试各种规模的输入数据,而这需要耗费大量的计算资源。
## 理论估算
由于实际测试具有较大的局限性,我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」简称「复杂度分析」。
由于实际测试具有较大的局限性,因此我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」简称「复杂度分析」。
复杂度分析体现算法运行所需的时间空间资源与输入数据大小之间的关系。**它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。
复杂度分析能够体现算法运行所需的时间空间资源与输入数据大小之间的关系。**它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。
- “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。
- “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。

View File

@@ -18,10 +18,12 @@
- **栈帧空间**:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- **指令空间**:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的空间复杂度时,**我们通常统计暂存数据、栈帧空间和输出数据三部分**。
在分析一段程序的空间复杂度时,**我们通常统计暂存数据、栈帧空间和输出数据三部分**,如下图所示
![算法使用的相关空间](space_complexity.assets/space_types.png)
相关代码如下:
=== "Python"
```python title=""
@@ -321,8 +323,8 @@
观察以下代码,最差空间复杂度中的“最差”有两层含义。
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"
@@ -459,10 +461,7 @@
```
**在递归函数中,需要注意统计栈帧空间**。例如在以下代码
- 函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。
- 递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。
**在递归函数中,需要注意统计栈帧空间**。观察以下代码:
=== "Python"
@@ -699,6 +698,11 @@
```
函数 `loop()` 和 `recur()` 的时间复杂度都为 $O(n)$ ,但空间复杂度不同。
- 函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。
- 递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。
## 常见类型
设输入数据大小为 $n$ ,下图展示了常见的空间复杂度类型(从低到高排列)。
@@ -766,14 +770,14 @@ $$
### 对数阶 $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)$ 。
## 权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。
**降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然**。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。

View File

@@ -6,43 +6,43 @@
- 时间效率和空间效率是衡量算法优劣的两个主要评价指标。
- 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。
- 复杂度分析可以克服实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
- 复杂度分析可以消除实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
**时间复杂度**
- 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。
- 最差时间复杂度使用大 $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)$ 等。
### 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$ ,你可能会发现每条曲线的值与函数对应不上。这是因为每条曲线都包含一个常数项,用于将取值范围压缩到一个视觉舒适的范围内。

View File

@@ -1,6 +1,6 @@
# 时间复杂度
运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
1. **确定运行平台**,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
2. **评估各种计算操作所需的运行时间**,例如加法操作 `+` 需要 1 ns ,乘法操作 `*` 需要 10 ns ,打印操作 `print()` 需要 5 ns 等。
@@ -186,7 +186,7 @@
}
```
根据以上方法,可以得到算法运行时间为 $(6n + 12)$ ns
根据以上方法,可以得到算法运行时间为 $(6n + 12)$ ns
$$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
@@ -198,7 +198,7 @@ $$
时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法函数 `A`、`B` 和 `C`
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法 `A`、`B` 和 `C`
=== "Python"
@@ -460,10 +460,10 @@ $$
![算法 A、B 和 C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势所表达的含义。
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- **时间复杂度也存在一定的局限性**。例如,尽管算法 `A` 和 `C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
## 函数渐近上界
@@ -637,7 +637,7 @@ $$
}
```
设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的操作数量为:
设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的操作数量为:
$$
T(n) = 3 + 2n
@@ -647,7 +647,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 big-$O$ notation」表示函数 $T(n)$ 的「渐近上界 asymptotic upper bound」。
时间复杂度分析本质上是计算“操作数量函数 $T(n)$”的渐近上界,具有明确的数学定义。
时间复杂度分析本质上是计算“操作数量 $T(n)$”的渐近上界,具有明确的数学定义。
!!! abstract "函数渐近上界"
@@ -659,19 +659,19 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
## 推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的实践中,就可以逐渐领悟其数学意义
根据定义,确定 $f(n)$ 之后,我们便可得到时间复杂度 $O(f(n))$ 。那么如何确定渐近上界 $f(n)$ 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
### 第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 $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"
@@ -901,7 +901,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
}
```
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 $O(n^2)$ 。
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 $O(n^2)$ 。
$$
\begin{aligned}
@@ -913,7 +913,7 @@ $$
### 第二步:判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
**时间复杂度由 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。
@@ -968,7 +968,7 @@ $$
### 平方阶 $O(n^2)$
平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$
平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 $O(n)$ ,因此总体的时间复杂度为 $O(n^2)$
```src
[file]{time_complexity}-[class]{}-[func]{quadratic}
@@ -978,7 +978,7 @@ $$
![常数阶、线性阶和平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
以冒泡排序为例,外层循环执行 $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)$
```src
[file]{time_complexity}-[class]{}-[func]{bubble_sort}
@@ -1002,13 +1002,13 @@ $$
[file]{time_complexity}-[class]{}-[func]{exp_recur}
```
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法来解决。
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法来解决。
### 对数阶 $O(\log n)$
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。
下图和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$
下图和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$
```src
[file]{time_complexity}-[class]{}-[func]{logarithmic}
@@ -1016,7 +1016,7 @@ $$
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一高度为 $\log_2 n$ 的递归树:
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一高度为 $\log_2 n$ 的递归树:
```src
[file]{time_complexity}-[class]{}-[func]{log_recur}
@@ -1026,7 +1026,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)
@@ -1081,12 +1081,12 @@ $$
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。
从上述示例可以看出,最差最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 $n / 2$ ,平均时间复杂度为 $\Theta(n / 2) = \Theta(n)$ 。
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
!!! question "为什么很少看到 $\Theta$ 符号?"
可能由于 $O$ 符号过于朗朗上口,我们常常使用它来表示平均时间复杂度。但从严格意义上,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。
可能由于 $O$ 符号过于朗朗上口,因此我们常常使用它来表示平均时间复杂度。但从严格意义上,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。

View File

@@ -1,22 +1,22 @@
# 基本数据类型
谈及计算机中的数据我们会想到文本、图片、视频、语音、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}$ 个数字。
下表列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。
下表列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须死记硬背,大致理解即可,需要时可以通过查表来回忆。
<p align="center"> 表 <id> &nbsp; 基本数据类型的占用空间和取值范围 </p>
@@ -26,7 +26,7 @@
| | `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}$ |
@@ -36,11 +36,11 @@
- 在 Python 中,整数类型 `int` 可以是任意大小,只受限于可用内存;浮点数 `float` 是双精度 64 位;没有 `char` 类型,单个字符实际上是长度为 1 的字符串 `str`
- C 和 C++ 未明确规定基本数据类型大小,而因实现和平台各异。上表遵循 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` 等。
@@ -50,7 +50,7 @@
# 使用多种基本数据类型来初始化数组
numbers: list[int] = [0] * 5
decimals: list[float] = [0.0] * 5
# Python 的字符应被看作长度为一的字符串
# Python 的字符实际上是长度为 1 的字符串
characters: list[str] = ['0'] * 5
bools: list[bool] = [False] * 5
# Python 的列表可以自由存储各种基本数据类型和对象引用

View File

@@ -4,7 +4,7 @@
## ASCII 字符集
「ASCII 码」是最早出现的字符集,全称为美国标准信息交换代码。它使用 7 位二进制数(一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如下图所示ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号以及一些控制字符如换行符和制表符
「ASCII 码」是最早出现的字符集,全称为 American Standard Code for Information Interchange美国标准信息交换代码。它使用 7 位二进制数(一个字节的低 7 位)表示一个字符,最多能够表示 128 个不同的字符。如下图所示ASCII 码包括英文字母的大小写、数字 0 ~ 9、一些标点符号以及一些控制字符如换行符和制表符
![ASCII 码](character_encoding.assets/ascii_table.png)
@@ -14,40 +14,40 @@
## GBK 字符集
后来人们发现,**EASCII 码仍然无法满足许多语言的字符数量要求**。比如汉字大约有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。
后来人们发现,**EASCII 码仍然无法满足许多语言的字符数量要求**。比如汉字有近十万个,光日常使用的就有几千个。中国国家标准总局于 1980 年发布了「GB2312」字符集其收录了 6763 个汉字,基本满足了汉字的计算机处理需要。
然而GB2312 无法处理部分罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中ASCII 字符使用一个字节表示,汉字使用两个字节表示。
然而GB2312 无法处理部分罕见字和繁体字。「GBK」字符集是在 GB2312 的基础上扩展得到的,它共收录了 21886 个汉字。在 GBK 的编码方案中ASCII 字符使用一个字节表示,汉字使用两个字节表示。
## 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 字节的字符?
对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如下图所示“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复这个短语的内容了。
对于以上问题,**一种直接的解决方案是将所有字符存储为等长的编码**。如下图所示“Hello”中的每个字符占用 1 字节,“算法”中的每个字符占用 2 字节。我们可以通过高位填 0 将“Hello 算法”中的所有字符都编码为 2 字节长度。这样系统就可以每隔 2 字节解析一个字符,恢复这个短语的内容了。
![Unicode 编码示例](character_encoding.assets/unicode_hello_algo.png)
然而 ASCII 码已经向我们证明,编码英文只需 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
然而 ASCII 码已经向我们证明,编码英文只需 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
## 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 码点。
下图展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。
下图展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。
但为什么要将其余所有字节的高 2 位都设置为 $10$ 呢?实际上,这个 $10$ 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 $10$ 能够帮助系统快速判断出异常。
但为什么要将其余所有字节的高 2 位都设置为 $10$ 呢?实际上,这个 $10$ 能够起到校验符的作用。假设系统从一个错误的字节开始解析文本,字节头部的 $10$ 能够帮助系统快速判断出异常。
之所以将 $10$ 当作校验符,是因为在 UTF-8 编码规则下,不可能有字符的最高两位是 $10$ 。这个结论可以用反证法来证明:假设一个字符的最高两位是 $10$ ,说明该字符的长度为 $1$ ,对应 ASCII 码。而 ASCII 码的最高位应该是 $0$ ,与假设矛盾。
@@ -55,28 +55,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 。
## 编程语言的字符编码
对于以往的大多数编程语言,程序运行中的字符串都采用 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 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这提高了编程的复杂性和调试难度。
出于以上原因,部分编程语言提出了一些不同的编码方案。
@@ -84,4 +84,4 @@ UTF-8 的编码规则并不复杂,分为以下两种情况。
- Go 语言的 `string` 类型在内部使用 UTF-8 编码。Go 语言还提供了 `rune` 类型,它用于表示单个 Unicode 码点。
- Rust 语言的 str 和 String 类型在内部使用 UTF-8 编码。Rust 也提供了 `char` 类型,用于表示单个 Unicode 码点。
需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,**这和字符串如何在文件中存储或在网络中传输是两个不同的问题**。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。
需要注意的是,以上讨论的都是字符串在编程语言中的存储方式,**这和字符串如何在文件中存储或在网络中传输是不同的问题**。在文件存储或网络传输中,我们通常会将字符串编码为 UTF-8 格式,以达到最优的兼容性和空间效率。

View File

@@ -4,16 +4,16 @@
## 逻辑结构:线性与非线性
**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照一定顺序排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先”与“后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
如下图所示,逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
如下图所示,逻辑结构可分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
- **线性数据结构**:数组、链表、栈、队列、哈希表。
- **非线性数据结构**:树、堆、图、哈希表。
![线性与非线性数据结构](classification_of_data_structure.assets/classification_logic_structure.png)
![线性数据结构与非线性数据结构](classification_of_data_structure.assets/classification_logic_structure.png)
非线性数据结构可以进一步划分为树形结构和网状结构。
非线性数据结构可以进一步划分为树形结构和网状结构。
- **线性结构**:数组、链表、队列、栈、哈希表,元素之间是一对一的顺序关系。
- **树形结构**:树、堆、哈希表,元素之间是一对多的关系。
@@ -27,13 +27,13 @@
![内存条、内存空间、内存地址](classification_of_data_structure.assets/computer_memory_location.png)
!!! note
!!! tip
值得说明的是,将内存比作 Excel 表格是一个简化的类比,实际内存的工作机制比较复杂,涉及到地址空间、内存管理、缓存机制、虚拟和物理内存等概念。
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。**因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素**。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内。
如下图所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出互补的特点。
如下图所示,**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,两种物理结构在时间效率和空间效率方面呈现出互补的特点。
![连续空间存储与分散空间存储](classification_of_data_structure.assets/classification_phisical_structure.png)
@@ -42,8 +42,8 @@
- **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等。
- **基于链表可实现**:栈、队列、哈希表、树、堆、图等。
基于数组实现的数据结构也被称为“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构被称为“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
基于数组实现的数据结构也“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
!!! tip
如果你感觉物理结构理解起来有困难,建议先阅读下一章“数组与链表”,然后再回顾本节内容。
如果你感觉物理结构理解起来有困难,建议先阅读下一章,然后再回顾本节内容。

View File

@@ -10,4 +10,4 @@
数据结构如同一副稳固而多样的框架。
它为数据的有序组织提供了蓝图,使算法得以在此基础上生动起来。
它为数据的有序组织提供了蓝图,算法得以在此基础上生动起来。

View File

@@ -2,13 +2,13 @@
!!! note
在本书中,标题带有 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。
在本书中,标题带有 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。
## 整数编码
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。
首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,我们首先给出三者的定义。
首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,首先给出三者的定义。
- **原码**:我们将数字的二进制表示的最高位视为符号位,其中 $0$ 表示正数,$1$ 表示负数,其余位表示数字的值。
- **反码**:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
@@ -29,7 +29,7 @@ $$
\end{aligned}
$$
为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码并在反码下计算 $1 + (-2)$ ,最后将结果从反码转回原码,则可得到正确结果 $-1$ 。
为了解决此问题,计算机引入了「反码 1's complement」。如果我们先将原码转换为反码并在反码下计算 $1 + (-2)$ ,最后将结果从反码转回原码,则可得到正确结果 $-1$ 。
$$
\begin{aligned}
@@ -42,7 +42,7 @@ $$
\end{aligned}
$$
另一方面,**数字零的原码有 $+0$ 和 $-0$ 两种表示方式**。这意味着数字零对应两个不同的二进制编码,可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想处理正零和负零歧义,则需要引入额外的判断操作,可能会降低计算机的运算效率。
另一方面,**数字零的原码有 $+0$ 和 $-0$ 两种表示方式**。这意味着数字零对应两个不同的二进制编码,可能会带来歧义。比如在条件判断中,如果没有区分正零和负零,则可能会导致判断结果出错。而如果我们想处理正零和负零歧义,则需要引入额外的判断操作,可能会降低计算机的运算效率。
$$
\begin{aligned}
@@ -63,7 +63,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$ 。
@@ -78,13 +78,13 @@ $$
\end{aligned}
$$
你可能已经发现,上述所有计算都是加法运算。这暗示着一个重要事实:**计算机内部的硬件电路主要是基于加法运算设计的**。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。
你可能已经发现,上述所有计算都是加法运算。这暗示着一个重要事实:**计算机内部的硬件电路主要是基于加法运算设计的**。这是因为加法运算相对于其他运算(比如乘法、除法和减法)来说,硬件实现起来更简单,更容易进行并行化处理,运算速度更快。
请注意,这并不意味着计算机只能做加法。**通过将加法与一些基本逻辑运算结合,计算机能够实现各种其他的数学运算**。例如,计算减法 $a - b$ 可以转换为计算加法 $a + (-b)$ ;计算乘法和除法可以转换为计算多次加法或减法。
现在我们可以总结出计算机使用补码的原因:基于补码表示,计算机可以用同样的电路和操作来处理正数和负数的加法,不需要设计特殊的硬件电路来处理减法,并且无须特别处理正负零的歧义问题。这大大简化了硬件设计,提高了运算效率。
补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深了解。
补码的设计非常精妙,因篇幅关系我们就先介绍到这里,建议有兴趣的读者进一步深了解。
## 浮点数编码
@@ -102,19 +102,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}
@@ -147,4 +147,4 @@ $$
值得说明的是,次正规数显著提升了浮点数的精度。最小正正规数为 $2^{-126}$ ,最小正次正规数为 $2^{-126} \times 2^{-23}$ 。
双精度 `double` 也采用类似 `float` 的表示方法,在此不做赘述。
双精度 `double` 也采用类似 `float` 的表示方法,在此不做赘述。

View File

@@ -3,13 +3,13 @@
### 重点回顾
- 数据结构可以从逻辑结构和物理结构两个角度进行分类。逻辑结构描述了数据元素之间的逻辑关系,而物理结构描述了数据在计算机内存中的存储方式。
- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性和非线性结构。
- 常见的逻辑结构包括线性、树状和网状等。通常我们根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。哈希表的实现可能同时包含线性数据结构和非线性数据结构。
- 当程序运行时,数据被存储在计算机内存中。每个内存空间都拥有对应的内存地址,程序通过这些内存地址访问数据。
- 物理结构主要分为连续空间存储(数组)和分散空间存储(链表)。所有数据结构都是由数组、链表或两者的组合实现的。
- 计算机中的基本数据类型包括整数 `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 编码。
@@ -22,7 +22,7 @@
!!! 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 "基于数组实现的数据结构也被称为“静态数据结构” 是否有歧义?因为栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。"

View File

@@ -3,42 +3,42 @@
我们已经学过,搜索算法分为两大类。
- **暴力搜索**:它通过遍历数据结构实现,时间复杂度为 $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)$ 。
二分查找的分治策略如下所示。
- **问题可以分解**:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
- **子问题是独立的**:在二分查找中,每轮只处理一个子问题,它不受另外子问题的影响。
- **问题可以分解**:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
- **子问题是独立的**:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
- **子问题的解无须合并**:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,**而分治搜索每轮可以排除一半选项**。
### 基于分治实现二分
### 基于分治实现二分查找
在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。
!!! 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` 或区间为空时返回。
下图展示了在数组中二分查找元素 $6$ 的分治过程。
![二分查找的分治过程](binary_search_recur.assets/binary_search_recur.png)
在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$
在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$
```src
[file]{binary_search_recur}-[class]{}-[func]{binary_search}

View File

@@ -2,23 +2,23 @@
!!! question
给定一二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
给定一二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
![构建二叉树的示例数据](build_binary_tree_problem.assets/build_tree_example.png)
### 判断是否为分治问题
原问题定义为从 `preorder``inorder` 构建二叉树,是一个典型的分治问题。
原问题定义为从 `preorder``inorder` 构建二叉树,是一个典型的分治问题。
- **问题可以分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
- **问题可以分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
- **子问题的解可以合并**:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。
### 如何划分子树
根据以上分析,这道题可以使用分治来求解**但如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**
根据以上分析,这道题可以使用分治来求解,**但如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**
根据定义,`preorder``inorder` 都可以划分为三个部分。
根据定义,`preorder``inorder` 都可以划分为三个部分。
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图的树对应 `[ 3 | 9 | 2 1 7 ]`
- 中序遍历:`[ 左子树 | 根节点 右子树 ]` ,例如上图的树对应 `[ 9 | 3 | 1 2 7 ]`
@@ -29,7 +29,7 @@
2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 1 2 7 ]`
3. 根据 `inorder` 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]`
![在前序和中序遍历中划分子树](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png)
![在前序遍历和中序遍历中划分子树](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png)
### 基于变量描述子树区间
@@ -41,7 +41,7 @@
如下表所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。
<p align="center"> 表 <id> &nbsp; 根节点和子树在前序和中序遍历中的索引 </p>
<p align="center"> 表 <id> &nbsp; 根节点和子树在前序遍历和中序遍历中的索引 </p>
| | 根节点在 `preorder` 中的索引 | 子树在 `inorder` 中的索引区间 |
| ------ | ---------------------------- | ----------------------------- |
@@ -55,13 +55,13 @@
### 代码实现
为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储数组 `inorder` 中元素到索引的映射
为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储数组 `inorder` 中元素到索引的映射
```src
[file]{build_tree}-[class]{}-[func]{build_tree}
```
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。
=== "<1>"
![构建二叉树的递归过程](build_binary_tree_problem.assets/built_tree_step1.png)
@@ -96,4 +96,4 @@
设树的节点数量为 $n$ ,初始化每一个节点(执行一个递归函数 `dfs()` )使用 $O(1)$ 时间。**因此总体时间复杂度为 $O(n)$** 。
哈希表存储 `inorder` 元素到索引的映射,空间复杂度为 $O(n)$ 。最差情况下,即二叉树退化为链表时,递归深度达到 $n$ ,使用 $O(n)$ 的栈帧空间。**因此总体空间复杂度为 $O(n)$** 。
哈希表存储 `inorder` 元素到索引的映射,空间复杂度为 $O(n)$ 。最差情况下,即二叉树退化为链表时,递归深度达到 $n$ ,使用 $O(n)$ 的栈帧空间。**因此总体空间复杂度为 $O(n)$** 。

View File

@@ -16,25 +16,25 @@
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
1. **问题可以分解**:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
2. **子问题是独立的**:子问题之间没有重叠,互相没有依赖,可以独立解决。
3. **子问题的解可以合并**:原问题的解通过合并子问题的解得来。
1. **问题可以分解**:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
2. **子问题是独立的**:子问题之间没有重叠,互依赖,可以独立解决。
3. **子问题的解可以合并**:原问题的解通过合并子问题的解得来。
显然,归并排序满足以上三条判断依据
显然,归并排序满足以上三条判断依据。
1. **问题可以分解**:递归地将数组(原问题)划分为两个子数组(子问题)。
1. **问题可以分解**:递归地将数组(原问题)划分为两个子数组(子问题)。
2. **子问题是独立的**:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
3. **子问题的解可以合并**:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。
3. **子问题的解可以合并**:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。
## 通过分治提升效率
分治不仅可以有效地解决算法问题,**往往还可以带来算法效率的提升**。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
**分治不仅可以有效地解决算法问题,往往还可以提升算法效率**。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
那么,我们不禁发问:**为什么分治可以提升算法效率,其底层逻辑是什么**?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。
### 操作数量优化
以“冒泡排序”为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们按照下图所示的方式,将数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((n / 2)^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为:
以“冒泡排序”为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们按照下图所示的方式,将数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((n / 2)^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为:
$$
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
@@ -54,7 +54,7 @@ $$
**这意味着当 $n > 4$ 时,划分后的操作数量更少,排序效率应该更高**。请注意,划分后的时间复杂度仍然是平方阶 $O(n^2)$ ,只是复杂度中的常数项变小了。
进一步想,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 $O(n \log n)$ 。
进一步想,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 $O(n \log n)$ 。
再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。
@@ -64,7 +64,7 @@ $$
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
比如在下图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
比如在下图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再合并结果
![桶排序的并行计算](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png)
@@ -72,20 +72,20 @@ $$
一方面,分治可以用来解决许多经典算法问题。
- **寻找最近点对**:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
- **大整数乘法**:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
- **矩阵乘法**:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
- **汉诺塔问题**:汉诺塔问题可以视为典型的分治策略,通过递归解决。
- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。
- **寻找最近点对**:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
- **大整数乘法**:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
- **矩阵乘法**:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
- **汉诺塔问题**:汉诺塔问题可以通过递归解决,这是典型的分治策略应用
- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。
另一方面,分治在算法和数据结构的设计中应用非常广泛。
- **二分查找**:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,然后在剩余区间执行相同的二分操作。
- **归并排序**文章开头已介绍,不再赘述。
- **快速排序**:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,然后再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
- **二分查找**:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,在剩余区间执行相同的二分操作。
- **归并排序**本节开头已介绍,不再赘述。
- **快速排序**:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
- **桶排序**:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
- **树**例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治的应用。
- **树**例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治策略的应用。
- **堆**:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
- **哈希表**:虽然哈希表来并不直接应用分治,但某些哈希冲突解决策略间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
- **哈希表**:虽然哈希表来并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,**分治是一种“润物细无声”的算法思想**,隐含在各种算法与数据结构之中。

View File

@@ -4,15 +4,15 @@
!!! question
给定三根柱子,记为 `A``B``C` 。起始状态下,柱子 `A` 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 `C` 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则。
给定三根柱子,记为 `A``B``C` 。起始状态下,柱子 `A` 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 `C` 上,并保持它们的原有顺序不变(如下图所示)。在移动圆盘的过程中,需要遵守以下规则。
1. 圆盘只能从一柱子顶部拿出,从另一柱子顶部放入。
1. 圆盘只能从一柱子顶部拿出,从另一柱子顶部放入。
2. 每次只能移动一个圆盘。
3. 小圆盘必须时刻位于大圆盘之上。
![汉诺塔问题示例](hanota_problem.assets/hanota_example.png)
**我们将规模为 $i$ 的汉诺塔问题记 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。
**我们将规模为 $i$ 的汉诺塔问题记 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。
### 考虑基本情况
@@ -48,11 +48,11 @@
对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。
因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看一个整体**,执行下图所示的步骤。这样三个圆盘就被顺利地从 `A``C` 了。
因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,**将 `A` 顶部的两个圆盘看一个整体**,执行下图所示的步骤。这样三个圆盘就被顺利地从 `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>"
![规模为 3 问题的解](hanota_problem.assets/hanota_f3_step1.png)
@@ -66,9 +66,9 @@
=== "<4>"
![hanota_f3_step4](hanota_problem.assets/hanota_f3_step4.png)
本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并
本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
至此,我们可总结出下图所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。
至此,我们可总结出下图所示的解决汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。
1. 将 $n-1$ 个圆盘借助 `C``A` 移至 `B`
2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C`
@@ -76,22 +76,22 @@
对于这两个子问题 $f(n-1)$ **可以通过相同的方式进行递归划分**,直至达到最小子问题 $f(1)$ 。而 $f(1)$ 的解是已知的,只需一次移动操作即可。
![汉诺塔问题的分治策略](hanota_problem.assets/hanota_divide_and_conquer.png)
![解决汉诺塔问题的分治策略](hanota_problem.assets/hanota_divide_and_conquer.png)
### 代码实现
在代码中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar`
在代码中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar`
```src
[file]{hanota}-[class]{}-[func]{solve_hanota}
```
如下图所示,汉诺塔问题形成一高度为 $n$ 的递归树,每个节点代表一个子问题对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。
如下图所示,汉诺塔问题形成一高度为 $n$ 的递归树,每个节点代表一个子问题对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。
![汉诺塔问题的递归树](hanota_problem.assets/hanota_recursive_tree.png)
!!! quote
汉诺塔问题源自一古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 $64$ 个大小不一的金圆盘。僧侣们不断地移动盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
汉诺塔问题源自一古老的传说。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 $64$ 个大小不一的金圆盘。僧侣们不断地移动盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
然而,即使僧侣们每秒钟移动一次,总共需要大约 $2^{64} \approx 1.84×10^{19}$ 秒,合约 $5850$ 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。

View File

@@ -1,11 +1,11 @@
# 小结
- 分治算法是一种常见的算法设计策略,包括分(划分)和治(合并)两个阶段,通常基于递归实现。
- 判断是否是分治算法问题的依据包括:问题能否分解、子问题是否独立、子问题是否可以被合并。
- 分治是一种常见的算法设计策略,包括分(划分)和治(合并)两个阶段,通常基于递归实现。
- 判断是否是分治算法问题的依据包括:问题能否分解、子问题是否独立、子问题能否合并。
- 归并排序是分治策略的典型应用,其递归地将数组划分为等长的两个子数组,直到只剩一个元素时开始逐层合并,从而完成排序。
- 引入分治策略往往可以带来算法效率的提升。一方面,分治策略减少了操作数量;另一方面,分治后有利于系统的并行优化。
- 引入分治策略往往可以提升算法效率。一方面,分治策略减少了操作数量;另一方面,分治后有利于系统的并行优化。
- 分治既可以解决许多算法问题,也广泛应用于数据结构与算法设计中,处处可见其身影。
- 相较于暴力搜索,自适应搜索效率更高。时间复杂度为 $O(\log n)$ 的搜索算法通常是基于分治策略实现的。
- 相较于暴力搜索,自适应搜索效率更高。时间复杂度为 $O(\log n)$ 的搜索算法通常是基于分治策略实现的。
- 二分查找是分治策略的另一个典型应用,它不包含将子问题的解进行合并的步骤。我们可以通过递归分治实现二分查找。
- 在构建二叉树问题中,构建树(原问题)可以划分为构建左子树和右子树(子问题),可以通过划分前序遍历和中序遍历的索引区间来实现。
- 在汉诺塔问题中,一个规模为 $n$ 的问题可以划分为两个规模为 $n-1$ 的子问题和一个规模为 $1$ 的子问题。按顺序解决这三个子问题后,原问题随之得到解决。
- 在构建二叉树问题中,构建树(原问题)可以划分为构建左子树和右子树(子问题),可以通过划分前序遍历和中序遍历的索引区间来实现。
- 在汉诺塔问题中,一个规模为 $n$ 的问题可以划分为两个规模为 $n-1$ 的子问题和一个规模为 $1$ 的子问题。按顺序解决这三个子问题后,原问题随之得到解决。

View File

@@ -1,10 +1,10 @@
# 动态规划问题特性
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
@@ -14,7 +14,7 @@
!!! question "爬楼梯最小代价"
给定一个楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 个台阶需要付出的代价,$cost[0]$ 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
给定一个楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 个台阶需要付出的代价,$cost[0]$ 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
如下图所示,若第 $1$、$2$、$3$ 阶的代价分别为 $1$、$10$、$1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。
@@ -30,9 +30,9 @@ $$
本题显然具有最优子结构:我们从两个子问题最优解 $dp[i-1]$ 和 $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。
那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
根据状态转移方程,以及初始状态 $dp[1] = cost[1]$ 和 $dp[2] = cost[2]$ ,我们就可以得到动态规划代码
根据状态转移方程,以及初始状态 $dp[1] = cost[1]$ 和 $dp[2] = cost[2]$ ,我们就可以得到动态规划代码
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp}
@@ -42,7 +42,7 @@ $$
![爬楼梯最小代价的动态规划过程](dp_problem_features.assets/min_cost_cs_dp.png)
本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降至 $O(1)$
本题也可以进行空间优化,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降至 $O(1)$
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
@@ -50,25 +50,25 @@ $$
## 无后效性
无后效性是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去经历的所有状态无关**。
无后效性是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关**。
以爬楼梯问题为例,给定状态 $i$ ,它会发展出状态 $i+1$ 和状态 $i+2$ ,分别对应跳 $1$ 步和跳 $2$ 步。在做出这两种选择时,我们无须考虑状态 $i$ 之前的状态,它们对状态 $i$ 的未来没有影响。
然而,如果我们爬楼梯问题添加一个约束,情况就不一样了。
然而,如果我们爬楼梯问题添加一个约束,情况就不一样了。
!!! question "带约束爬楼梯"
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,**但不能连续两轮跳 $1$ 阶**,请问有多少种方案可以爬到楼顶
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,**但不能连续两轮跳 $1$ 阶**,请问有多少种方案可以爬到楼顶
如下图,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。
如下图所示,爬上第 $3$ 阶仅剩 $2$ 种可行方案,其中连续三次跳 $1$ 阶的方案不满足约束条件,因此被舍弃。
![带约束爬到第 3 阶的方案数量](dp_problem_features.assets/climbing_stairs_constraint_example.png)
在该问题中,如果上一轮是跳 $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]$ 转移过来。
@@ -84,18 +84,18 @@ $$
![考虑约束下的递推关系](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png)
最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数
最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数
```src
[file]{climbing_stairs_constraint_dp}-[class]{}-[func]{climbing_stairs_constraint_dp}
```
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
在上面的案例中,由于仅需多考虑前面一个状态,因此我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
!!! question "爬楼梯与障碍生成"
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2$、$3$ 阶上,则之后就不能跳到第 $4$、$6$ 阶上。请问有多少种方案可以爬到楼顶
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2$、$3$ 阶上,则之后就不能跳到第 $4$、$6$ 阶上。请问有多少种方案可以爬到楼顶
在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。

View File

@@ -7,7 +7,7 @@
## 问题判断
总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。
总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。
**适合用回溯解决的问题通常满足“决策树模型”**,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。
@@ -41,7 +41,7 @@
**第一步:思考每轮的决策,定义状态,从而得到 $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]$ 。
@@ -51,13 +51,13 @@
!!! 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]$ 的最小路径和较小的那一个决定。
根据以上分析,可推出下图所示的状态转移方程:
@@ -75,9 +75,9 @@ $$
**第三步:确定边界条件和状态转移顺序**
在本题中,首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行 $i = 0$ 和首列 $j = 0$ 是边界条件。
在本题中,处在首行的状态只能从其左边的状态得来,处在首列的状态只能从其上边的状态得来,因此首行 $i = 0$ 和首列 $j = 0$ 是边界条件。
如下图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行内循环遍历各列。
如下图所示,由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵,外循环遍历各行内循环遍历各列。
![边界条件与状态转移顺序](dp_solution_pipeline.assets/min_path_sum_solution_step3.png)
@@ -98,21 +98,23 @@ $$
- **终止条件**:当 $i = 0$ 且 $j = 0$ 时,返回代价 $grid[0, 0]$ 。
- **剪枝**:当 $i < 0$ 时或 $j < 0$ 时索引越界,此时返回代价 $+\infty$ ,代表不可行。
实现代码如下:
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
```
下图给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。
本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。
本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。
![暴力搜索递归树](dp_solution_pipeline.assets/min_path_sum_dfs.png)
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择因此实际的路径数量会少一些。
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择因此实际的路径数量会少一些。
### 方法二:记忆化搜索
我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,并将重叠子问题进行剪枝
我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,并将重叠子问题进行剪枝
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
@@ -124,7 +126,7 @@ $$
### 方法三:动态规划
基于迭代实现动态规划解法
基于迭代实现动态规划解法,代码如下所示:
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
@@ -174,7 +176,7 @@ $$
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 $dp$ 表。
请注意,因为数组 `dp` 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行更新它
请注意,因为数组 `dp` 只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行更新它
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}

View File

@@ -1,12 +1,12 @@
# 编辑距离问题
编辑距离,也被称为 Levenshtein 距离,指两个字符串之间互相转换的最修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
编辑距离,也 Levenshtein 距离,指两个字符串之间互相转换的最修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
!!! question
输入两个字符串 $s$ 和 $t$ ,返回将 $s$ 转换为 $t$ 所需的最少编辑步数。
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、将字符替换为任意一个字符。
如下图所示,将 `kitten` 转换为 `sitting` 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 `hello` 转换为 `algo` 需要 3 步,包括 2 次替换操作和 1 次删除操作。
@@ -31,7 +31,7 @@
- 若 $s[n-1]$ 和 $t[m-1]$ 相同,我们可以跳过它们,直接考虑 $s[n-2]$ 和 $t[m-2]$ 。
- 若 $s[n-1]$ 和 $t[m-1]$ 不同,我们需要对 $s$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ 和 $t$ 中考虑的第 $i$ 和 $j$ 个字符,记为 $[i, j]$ 。
也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ 和 $t$ 中考虑的第 $i$ 和 $j$ 个字符,记为 $[i, j]$ 。
状态 $[i, j]$ 对应的子问题:**将 $s$ 的前 $i$ 个字符更改为 $t$ 的前 $j$ 个字符所需的最少编辑步数**。
@@ -61,7 +61,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$ 表即可。
@@ -71,7 +71,7 @@ $$
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp}
```
如下图所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作填写一个二维网格的过程。
如下图所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作填写一个二维网格的过程。
=== "<1>"
![编辑距离的动态规划过程](edit_distance_problem.assets/edit_distance_dp_step1.png)
@@ -120,9 +120,9 @@ $$
### 空间优化
由于 $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]$ ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。代码如下所示:
```src
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp_comp}

View File

@@ -6,13 +6,13 @@
!!! question "爬楼梯"
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,请问有多少种方案可以爬到楼顶
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,请问有多少种方案可以爬到楼顶
如下图所示,对于一个 $3$ 阶楼梯,共有 $3$ 种方案可以爬到楼顶。
![爬到第 3 阶的方案数量](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。
本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。代码如下所示:
```src
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
@@ -20,15 +20,15 @@
## 方法一:暴力搜索
回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
我们可以尝试从问题分解的角度分析这道题。设爬到第 $i$ 阶共有 $dp[i]$ 种方案,那么 $dp[i]$ 就是原问题,其子问题包括:
我们可以尝试从问题分解的角度分析这道题。设爬到第 $i$ 阶共有 $dp[i]$ 种方案,那么 $dp[i]$ 就是原问题,其子问题包括
$$
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
$$
由于每轮只能上 $1$ 阶或 $2$ 阶,因此当我们站在第 $i$ 阶楼梯上时,上一轮只可能站在第 $i - 1$ 阶或第 $i - 2$ 阶上。换句话说,我们只能从第 $i -1$ 阶或第 $i - 2$ 阶前往第 $i$ 阶。
由于每轮只能上 $1$ 阶或 $2$ 阶,因此当我们站在第 $i$ 阶楼梯上时,上一轮只可能站在第 $i - 1$ 阶或第 $i - 2$ 阶上。换句话说,我们只能从第 $i -1$ 阶或第 $i - 2$ 阶迈向第 $i$ 阶。
由此便可得出一个重要推论:**爬到第 $i - 1$ 阶的方案数加上爬到第 $i - 2$ 阶的方案数就等于爬到第 $i$ 阶的方案数**。公式如下:
@@ -42,7 +42,7 @@ $$
我们可以根据递推公式得到暴力搜索解法。以 $dp[n]$ 为起始点,**递归地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即 $dp[1] = 1$、$dp[2] = 2$ ,表示爬到第 $1$、$2$ 阶分别有 $1$、$2$ 种方案。
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁
```src
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
@@ -52,7 +52,7 @@ $$
![爬楼梯对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
观察上图,**指数阶的时间复杂度是由于“重叠子问题”导致的**。例如 $dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ $dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
观察上图,**指数阶的时间复杂度是“重叠子问题”导致的**。例如 $dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ $dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。
@@ -63,21 +63,23 @@ $$
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用。
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而避免重复计算该子问题。
代码如下所示:
```src
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
```
观察下图,**经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 $O(n)$** ,这是一个巨大的飞跃。
观察下图,**经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 $O(n)$** ,这是一个巨大的飞跃。
![记忆化搜索对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png)
## 方法三:动态规划
**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯子问题的解逐层收集,构建出原问题的解。
**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。
与之相反,**动态规划是一种“从底至顶”的方法**:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。
由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 `dp` 来存储子问题的解,它起到了记忆化搜索中数组 `mem` 相同的记录作用
由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 `dp` 来存储子问题的解,它起到了记忆化搜索中数组 `mem` 相同的记录作用
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
@@ -87,22 +89,22 @@ $$
![爬楼梯的动态规划过程](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
根据以上内容,我们可以总结出动态规划的常用术语。
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解。
- 将最小子问题对应的状态(第 $1$ $2$ 阶楼梯)称为「初始状态」。
- 将最小子问题对应的状态(第 $1$ 阶和第 $2$ 阶楼梯)称为「初始状态」。
- 将递推公式 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」。
## 空间优化
细心的可能发现,**由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无须使用一个数组 `dp` 来存储所有子问题的解**,而只需两个变量滚动前进即可。
细心的读者可能发现**由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无须使用一个数组 `dp` 来存储所有子问题的解**,而只需两个变量滚动前进即可。代码如下所示:
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
```
观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降至 $O(1)$ 。
观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降至 $O(1)$ 。
在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。**这种空间优化技巧被称为“滚动变量”或“滚动数组”**。

View File

@@ -6,15 +6,15 @@
!!! question
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在限定背包容量下能放入物品的最大价值。
观察下图,由于物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
![0-1 背包的示例数据](knapsack_problem.assets/knapsack_example.png)
我们可以将 0-1 背包问题看作一个由 $n$ 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题满足决策树模型
我们可以将 0-1 背包问题看作一个由 $n$ 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。
该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。
该问题的目标是求解“在限定背包容量下能放入物品的最大价值”,因此较大概率是个动态规划问题。
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
@@ -29,9 +29,9 @@
当我们做出物品 $i$ 的决策后,剩余的是前 $i-1$ 个物品的决策,可分为以下两种情况。
- **不放入物品 $i$** :背包容量不变,状态变化为 $[i-1, c]$ 。
- **放入物品 $i$** :背包容量减 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态变化为 $[i-1, c-wgt[i-1]]$ 。
- **放入物品 $i$** :背包容量减 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态变化为 $[i-1, c-wgt[i-1]]$ 。
上述分析向我们揭示了本题的最优子结构:**最大价值 $dp[i, c]$ 等于不放入物品 $i$ 和放入物品 $i$ 两种方案中价值更大的那一个**。由此可推出状态转移方程:
上述分析向我们揭示了本题的最优子结构:**最大价值 $dp[i, c]$ 等于不放入物品 $i$ 和放入物品 $i$ 两种方案中价值更大的那一个**。由此可推出状态转移方程:
$$
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
@@ -54,7 +54,7 @@ $$
- **递归参数**:状态 $[i, c]$ 。
- **返回值**:子问题的解 $dp[i, c]$ 。
- **终止条件**:当物品编号越界 $i = 0$ 或背包剩余容量为 $0$ 时,终止递归并返回价值 $0$ 。
- **剪枝**:若当前物品重量超出背包剩余容量,则只能不放入背包。
- **剪枝**:若当前物品重量超出背包剩余容量,则只能选择不放入背包。
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
@@ -64,25 +64,25 @@ $$
观察递归树,容易发现其中存在重叠子问题,例如 $dp[1, 10]$ 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。
![0-1 背包的暴力搜索递归树](knapsack_problem.assets/knapsack_dfs.png)
![0-1 背包问题的暴力搜索递归树](knapsack_problem.assets/knapsack_dfs.png)
### 方法二:记忆化搜索
为了保证重叠子问题只被计算一次,我们借助记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应 $dp[i, c]$ 。
引入记忆化之后,**时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。
引入记忆化之后,**时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。实现代码如下:
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
```
下图展示了在记忆化递归中被剪掉的搜索分支。
下图展示了在记忆化搜索中被剪掉的搜索分支。
![0-1 背包的记忆化搜索递归树](knapsack_problem.assets/knapsack_dfs_mem.png)
![0-1 背包问题的记忆化搜索递归树](knapsack_problem.assets/knapsack_dfs_mem.png)
### 方法三:动态规划
动态规划实质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示
动态规划实质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
@@ -91,7 +91,7 @@ $$
如下图所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。
=== "<1>"
![0-1 背包的动态规划过程](knapsack_problem.assets/knapsack_dp_step1.png)
![0-1 背包问题的动态规划过程](knapsack_problem.assets/knapsack_dp_step1.png)
=== "<2>"
![knapsack_dp_step2](knapsack_problem.assets/knapsack_dp_step2.png)
@@ -134,9 +134,9 @@ $$
### 空间优化
由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $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]$ 值可能已经被覆盖,此时就无法得到正确的状态转移结果。
- 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
@@ -161,7 +161,7 @@ $$
=== "<6>"
![knapsack_dp_comp_step6](knapsack_problem.assets/knapsack_dp_comp_step6.png)
在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可
在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}

View File

@@ -1,23 +1,23 @@
# 小结
- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,实现高效的计算效率。
- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,提高 计算效率。
- 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。
- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖某些局部状态,因此我们可以消除 $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]$ 时,无须编辑当前字符。
- 在编辑距离中,状态依赖其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包等价的情况,可以在空间优化后进行正序遍历。
- 在编辑距离中,状态依赖其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包问题等价的情况,可以在空间优化后进行正序遍历。

View File

@@ -6,21 +6,21 @@
!!! question
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在不超过背包容量下能放入物品的最大价值。
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在限定背包容量下能放入物品的最大价值。示例如下图所示。
![完全背包问题的示例数据](unbounded_knapsack_problem.assets/unbounded_knapsack_example.png)
### 动态规划思路
完全背包和 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]]$ 。
从而状态转移方程变为:
@@ -30,7 +30,7 @@ $$
### 代码实现
对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致
对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致
```src
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}
@@ -38,12 +38,12 @@ $$
### 空间优化
由于当前状态是从左边和上边的状态转移而来,**因此空间优化后应该对 $dp$ 表中的每一行采取正序遍历**。
由于当前状态是从左边和上边的状态转移而来**因此空间优化后应该对 $dp$ 表中的每一行进行正序遍历**。
这个遍历顺序与 0-1 背包正好相反。请借助下图来理解两者的区别。
=== "<1>"
![完全背包空间优化后的动态规划过程](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png)
![完全背包问题在空间优化后的动态规划过程](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png)
=== "<2>"
![unbounded_knapsack_dp_comp_step2](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step2.png)
@@ -60,7 +60,7 @@ $$
=== "<6>"
![unbounded_knapsack_dp_comp_step6](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step6.png)
代码实现比较简单,仅需将数组 `dp` 的第一维删除
代码实现比较简单,仅需将数组 `dp` 的第一维删除
```src
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}
@@ -68,31 +68,31 @@ $$
## 零钱兑换问题
背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。
背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。
!!! question
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ **每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ **每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。示例如下图所示。
![零钱兑换问题的示例数据](unbounded_knapsack_problem.assets/coin_change_example.png)
### 动态规划思路
**零钱兑换可以看作完全背包的一种特殊情况**,两者具有以下联系与不同点。
**零钱兑换可以看作完全背包问题的一种特殊情况**,两者具有以下联系与不同点。
- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
- 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
- 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数**,记为 $dp[i, a]$ 。
状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数**,记为 $dp[i, a]$ 。
二维 $dp$ 表的尺寸为 $(n+1) \times (amt+1)$ 。
**第二步:找出最优子结构,进而推导出状态转移方程**
本题与完全背包的状态转移方程存在以下两差异。
本题与完全背包问题的状态转移方程存在以下两差异。
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。
@@ -103,7 +103,7 @@ $$
**第三步:确定边界条件和状态转移顺序**
当目标金额为 $0$ 时,凑出它的最少硬币数为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。
当目标金额为 $0$ 时,凑出它的最少硬币数为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。
当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。
@@ -111,15 +111,13 @@ $$
大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 `int` 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。
为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数最多为 $amt$
最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。
为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数最多为 $amt$ 。最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。代码如下所示:
```src
[file]{coin_change}-[class]{}-[func]{coin_change_dp}
```
下图展示了零钱兑换的动态规划过程,和完全背包非常相似。
下图展示了零钱兑换的动态规划过程,和完全背包问题非常相似。
=== "<1>"
![零钱兑换问题的动态规划过程](unbounded_knapsack_problem.assets/coin_change_dp_step1.png)
@@ -168,7 +166,7 @@ $$
### 空间优化
零钱兑换的空间优化的处理方式和完全背包一致
零钱兑换的空间优化的处理方式和完全背包问题一致
```src
[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}
@@ -178,13 +176,13 @@ $$
!!! question
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。示例如下图所示。
![零钱兑换问题 II 的示例数据](unbounded_knapsack_problem.assets/coin_change_ii_example.png)
### 动态规划思路
相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。
相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。
当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:
@@ -202,7 +200,7 @@ $$
### 空间优化
空间优化处理方式相同,删除硬币维度即可
空间优化处理方式相同,删除硬币维度即可
```src
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -10,27 +10,27 @@ G & = \{ V, E \} \newline
\end{aligned}
$$
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如下图所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**而更为复杂。
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数据结构。如下图所示,**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高**而更为复杂。
![链表、树、图之间的关系](graph.assets/linkedlist_tree_graph.png)
## 图常见类型与术语
根据边是否具有方向,可分为下图所示的「无向图 undirected graph」和「有向图 directed graph」。
根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」,如下图所示
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
- 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
![有向图与无向图](graph.assets/directed_graph.png)
根据所有顶点是否连通,可分为下图所示的「连通图 connected graph」和「非连通图 disconnected graph」。
根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」,如下图所示
- 对于连通图,从某个顶点出发,可以到达其余任意顶点。
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。
![连通图与非连通图](graph.assets/connected_graph.png)
我们还可以为边添加“权重”变量,从而得到下图所示的「有权图 weighted graph」。例如在王者荣耀等手游中系统会根据共同游戏时间来计算玩家之间的“亲密度”这种亲密度网络就可以用有权图来表示。
我们还可以为边添加“权重”变量,从而得到下图所示的「有权图 weighted graph」。例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
![有权图与无权图](graph.assets/weighted_graph.png)
@@ -58,21 +58,21 @@ $$
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
- 将邻接矩阵的元素从 $1$ 和 $0$ 替换为权重,则可表示有权图。
使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较多。
使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较多。
### 邻接表
「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例。
「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例。
![图的邻接表表示](graph.assets/adjacency_list.png)
邻接表仅存储实际存在的边,而边的总数通常远小于 $n^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
观察上图,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降至 $O(1)$ 。
观察上图,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降至 $O(1)$ 。
## 图常见应用
如下表所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。
如下表所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。
<p align="center"> 表 <id> &nbsp; 现实生活中常见的图 </p>

View File

@@ -26,7 +26,7 @@
=== "删除顶点"
![adjacency_matrix_remove_vertex](graph_operations.assets/adjacency_matrix_remove_vertex.png)
以下是基于邻接矩阵表示图的实现代码
以下是基于邻接矩阵表示图的实现代码
```src
[file]{graph_adjacency_matrix}-[class]{graph_adj_mat}-[func]{}
@@ -70,7 +70,7 @@
## 效率对比
设图中共有 $n$ 个顶点和 $m$ 条边,下表对比了邻接矩阵和邻接表的时间和空间效率。
设图中共有 $n$ 个顶点和 $m$ 条边,下表对比了邻接矩阵和邻接表的时间效率和空间效率。
<p align="center"> 表 <id> &nbsp; 邻接矩阵与邻接表对比 </p>
@@ -83,4 +83,4 @@
| 删除顶点 | $O(n^2)$ | $O(n + m)$ | $O(n)$ |
| 内存空间占用 | $O(n^2)$ | $O(n + m)$ | $O(n + m)$ |
观察上表,似乎邻接表(哈希表)的时间与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。
观察上表,似乎邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。

View File

@@ -1,22 +1,22 @@
# 图的遍历
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作图的一种特例。显然,**树的遍历操作也是图的遍历操作的一种特例**。
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作图的一种特例。显然,**树的遍历操作也是图的遍历操作的一种特例**。
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式可分为两种:「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也常被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」简称 BFS 和 DFS 。
## 广度优先遍历
**广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张**。如下图所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
**广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张**。如下图所示,从左上角顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
![图的广度优先遍历](graph_traversal.assets/graph_bfs.png)
### 算法实现
BFS 通常借助队列来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
BFS 通常借助队列来实现,代码如下所示。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
1. 将遍历起始顶点 `startVet` 加入队列,并开启循环。
2. 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。
3. 循环步骤 `2.` ,直到所有顶点被访问完后结束。
3. 循环步骤 `2.` ,直到所有顶点被访问完后结束。
为了防止重复遍历顶点,我们需要借助一个哈希表 `visited` 来记录哪些节点已被访问。
@@ -61,13 +61,13 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
!!! question "广度优先遍历的序列是否唯一?"
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序允许被任意打乱**。以上图为例,顶点 $1$、$3$ 的访问顺序可以交换顶点 $2$、$4$、$6$ 的访问顺序也可以任意交换。
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序允许被任意打乱**。以上图为例,顶点 $1$、$3$ 的访问顺序可以交换顶点 $2$、$4$、$6$ 的访问顺序也可以任意交换。
### 复杂度分析
**时间复杂度** 所有顶点都会入队并出队一次,使用 $O(|V|)$ 时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。
**时间复杂度**所有顶点都会入队并出队一次,使用 $O(|V|)$ 时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。
**空间复杂度** 列表 `res` ,哈希表 `visited` ,队列 `que` 中的顶点数量最多为 $|V|$ ,使用 $O(|V|)$ 空间。
**空间复杂度**列表 `res` ,哈希表 `visited` ,队列 `que` 中的顶点数量最多为 $|V|$ ,使用 $O(|V|)$ 空间。
## 深度优先遍历
@@ -77,7 +77,7 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
### 算法实现
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中我们也需要借助一个哈希表 `visited` 来记录已被访问的顶点,以避免重复访问顶点。
这种“走到尽头再返回”的算法范式通常基于递归来实现。与广度优先遍历类似,在深度优先遍历中我们也需要借助一个哈希表 `visited` 来记录已被访问的顶点,以避免重复访问顶点。
```src
[file]{graph_dfs}-[class]{}-[func]{graph_dfs}
@@ -86,9 +86,9 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
深度优先遍历的算法流程如下图所示。
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点。
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此递归方法的位置。
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此方法的位置。
为了加深理解,建议将图与代码结合起来,在脑中(或者用笔画下来)模拟整个 DFS 过程,包括每个递归方法何时开启、何时返回。
为了加深理解,建议将图与代码结合起来,在脑中模拟(或者用笔画下来)整个 DFS 过程,包括每个递归方法何时开启、何时返回。
=== "<1>"
![图的深度优先遍历步骤](graph_traversal.assets/graph_dfs_step1.png)
@@ -127,10 +127,10 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
与广度优先遍历类似,深度优先遍历序列的顺序也不是唯一的。给定某顶点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。
以树的遍历为例,“根 $\rightarrow$ 左 $\rightarrow$ 右”“左 $\rightarrow$ 根 $\rightarrow$ 右”“左 $\rightarrow$ 右 $\rightarrow$ 根”分别对应前序、中序、后序遍历,它们展示了三种不同的遍历优先级,然而这三者都属于深度优先遍历。
以树的遍历为例,“根 $\rightarrow$ 左 $\rightarrow$ 右”“左 $\rightarrow$ 根 $\rightarrow$ 右”“左 $\rightarrow$ 右 $\rightarrow$ 根”分别对应前序、中序、后序遍历,它们展示了三种遍历优先级,然而这三者都属于深度优先遍历。
### 复杂度分析
**时间复杂度** 所有顶点都会被访问 $1$ 次,使用 $O(|V|)$ 时间;所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。
**时间复杂度**所有顶点都会被访问 $1$ 次,使用 $O(|V|)$ 时间;所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。
**空间复杂度** 列表 `res` ,哈希表 `visited` 顶点数量最多为 $|V|$ ,递归深度最大为 $|V|$ ,因此使用 $O(|V|)$ 空间。
**空间复杂度**列表 `res` ,哈希表 `visited` 顶点数量最多为 $|V|$ ,递归深度最大为 $|V|$ ,因此使用 $O(|V|)$ 空间。

View File

@@ -8,6 +8,6 @@
!!! abstract
在生命旅途中,我们就像是个节点,被无数看不见的边相连。
在生命旅途中,我们就像是一个个节点,被无数看不见的边相连。
每一次的相识与相离,都在这张巨大的网络图中留下独特的印记。

View File

@@ -2,13 +2,13 @@
### 重点回顾
- 图由顶点和边组成,可以表示为一组顶点和一组边构成的集合。
- 图由顶点和边组成,可以表示为一组顶点和一组边构成的集合。
- 相较于线性关系(链表)和分治关系(树),网络关系(图)具有更高的自由度,因而更为复杂。
- 有向图的边具有方向性,连通图中的任意顶点均可达,有权图的每条边都包含权重变量。
- 邻接矩阵利用矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间有边或无边。邻接矩阵在增删查操作上效率很高,但空间占用较多。
- 邻接表使用多个链表来表示图,第 $i$ 链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点。邻接表相对于邻接矩阵更加节省空间,但由于需要遍历链表来查找边,时间效率较低。
- 邻接矩阵利用矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间有边或无边。邻接矩阵在增删查操作上效率很高,但空间占用较多。
- 邻接表使用多个链表来表示图,第 $i$ 链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点。邻接表相对于邻接矩阵更加节省空间,但由于需要遍历链表来查找边,因此时间效率较低。
- 当邻接表中的链表过长时,可以将其转换为红黑树或哈希表,从而提升查询效率。
- 从算法思想角度分析,邻接矩阵体现“以空间换时间”,邻接表体现“以时间换空间”。
- 从算法思想角度分析,邻接矩阵体现“以空间换时间”,邻接表体现“以时间换空间”。
- 图可用于建模各类现实系统,如社交网络、地铁线路等。
- 树是图的一种特例,树的遍历也是图的遍历的一种特例。
- 图的广度优先遍历是一种由近及远、层层扩张的搜索方式,通常借助队列实现。
@@ -19,12 +19,12 @@
!!! question "路径的定义是顶点序列还是边序列?"
维基百科上不同语言版本的定义不一致英文版是“路径是一个边序列”而中文版是“路径是一个顶点序列”。以下是英文版原文In graph theory, a path in a graph is a finite or infinite sequence of edges which joins a sequence of vertices.
在本文中,路径被认为是一个边序列,而不是一个顶点序列。这是因为两个顶点之间可能存在多条边连接,此时每条边都对应一条路径。
在本文中,路径被视为一个边序列,而不是一个顶点序列。这是因为两个顶点之间可能存在多条边连接,此时每条边都对应一条路径。
!!! question "非连通图中是否会有无法遍历到的点?"
!!! question "非连通图中是否会有无法遍历到的点?"
在非连通图中,从某个顶点出发,至少有一个顶点无法到达。遍历非连通图需要设置多个起点,以遍历到图的所有连通分量。
!!! question "在邻接表中,“与该顶点相连的所有顶点”的顶点顺序是否有要求?"
可以是任意顺序。但在实际应用中,可能需要按照指定规则来排序,比如按照顶点添加的次序或者按照顶点值大小的顺序等,这样可以有助于快速查找“带有某种极值”的顶点。
可以是任意顺序。但在实际应用中,可能需要按照指定规则来排序,比如按照顶点添加的次序或者按照顶点值大小的顺序等,这样有助于快速查找“带有某种极值”的顶点。

View File

@@ -2,38 +2,38 @@
!!! question
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,**但可以选择物品的一部分,价值根据选择的重量比例计算**,问在不超过背包容量下背包中物品的最大价值。
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,**但可以选择物品的一部分,价值根据选择的重量比例计算**,问在限定背包容量下背包中物品的最大价值。示例如下图所示。
![分数背包问题的示例数据](fractional_knapsack_problem.assets/fractional_knapsack_example.png)
分数背包和 0-1 背包整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求不超过背包容量下的最大价值。
分数背包问题和 0-1 背包问题整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求限定背包容量下的最大价值。
不同点在于,本题允许只选择物品的一部分。如下图所示,**我们可以对物品任意地进行切分,并按照重量比例来计算物品价值**。
不同点在于,本题允许只选择物品的一部分。如下图所示,**我们可以对物品任意地进行切分,并按照重量比例来计算相应价值**。
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]$ 。
![物品在单位重量下的价值](fractional_knapsack_problem.assets/fractional_knapsack_unit_value.png)
### 贪心策略确定
最大化背包内物品总价值,**本质上是最大化单位重量下的物品价值**。由此便可推出下图所示的贪心策略。
最大化背包内物品总价值,**本质上是最大化单位重量下的物品价值**。由此便可推出下图所示的贪心策略。
1. 将物品按照单位价值从高到低进行排序。
2. 遍历所有物品,**每轮贪心地选择单位价值最高的物品**。
3. 若剩余背包容量不足,则使用当前物品的一部分填满背包即可
3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。
![分数背包的贪心策略](fractional_knapsack_problem.assets/fractional_knapsack_greedy_strategy.png)
![分数背包问题的贪心策略](fractional_knapsack_problem.assets/fractional_knapsack_greedy_strategy.png)
### 代码实现
我们建立了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解
我们建立了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解
```src
[file]{fractional_knapsack}-[class]{}-[func]{fractional_knapsack}
```
最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。
最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。
由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。
@@ -45,6 +45,6 @@
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,**单位价值更大的物品总是更优选择**,这说明贪心策略是有效的。
如下图所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
如下图所示,如果将物品重量和物品单位价值分别看作一张二维图表的横轴和纵轴,则分数背包问题可转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
![分数背包问题的几何表示](fractional_knapsack_problem.assets/fractional_knapsack_area_chart.png)

View File

@@ -1,35 +1,35 @@
# 贪心算法
「贪心算法 greedy algorithm」是一种常见的解决优化问题的算法其基本思想是在问题的每个决策阶段都选择当前看起来最优的选择即贪心地做出局部最优的决策以期获得全局最优解。贪心算法简洁且高效,在许多实际问题中有着广泛的应用。
「贪心算法 greedy algorithm」是一种常见的解决优化问题的算法其基本思想是在问题的每个决策阶段都选择当前看起来最优的选择即贪心地做出局部最优的决策以期获得全局最优解。贪心算法简洁且高效在许多实际问题中有着广泛的应用。
贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同
贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同。
- 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
- 贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。
- 贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。
我们先通过例题“零钱兑换”了解贪心算法的工作原理。这道题已经在动态规划章节中介绍过,相信你对它并不陌生。
我们先通过例题“零钱兑换”了解贪心算法的工作原理。这道题已经在“完全背包问题”章节中介绍过,相信你对它并不陌生。
!!! question
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。
本题的贪心策略如下图所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。
本题采取的贪心策略如下图所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。
![零钱兑换的贪心策略](greedy_algorithm.assets/coin_change_greedy_strategy.png)
实现代码如下所示。你可能会不由地发出感叹So Clean !贪心算法仅用十行代码就解决了零钱兑换问题
实现代码如下所示。你可能会不由地发出感叹So clean !贪心算法仅用十行代码就解决了零钱兑换问题
```src
[file]{coin_change_greedy}-[class]{}-[func]{coin_change_greedy}
```
## 贪心优点与局限性
## 贪心优点与局限性
**贪心算法不仅操作直接、实现简单,而且通常效率也很高**。在以上代码中,记硬币最小面值为 $\min(coins)$ ,则贪心选择最多循环 $amt / \min(coins)$ 次,时间复杂度为 $O(amt / \min(coins))$ 。这比动态规划解法的时间复杂度 $O(n \times amt)$ 提升了一个数量级。
然而,**对于某些硬币面值组合,贪心算法并不能找到最优解**。下图给出了两个示例。
- **正例 $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$ 枚硬币。
@@ -37,10 +37,10 @@
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
一般情况下,贪心算法适用于以下两类问题
一般情况下,贪心算法适用情况分以下两种
1. **可以保证找到最优解**:贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效。
2. **可以找到近似最优解**:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解非常困难,能以较高效率找到次优解也是非常不错的。
2. **可以找到近似最优解**:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解非常困难,能以较高效率找到次优解也是非常不错的。
## 贪心算法特性
@@ -51,15 +51,15 @@
- **贪心选择性质**:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
- **最优子结构**:原问题的最优解包含子问题的最优解。
最优子结构已经在动态规划章节中介绍过,不再赘述。值得注意的是,一些问题的最优子结构并不明显,但仍然可使用贪心算法解决。
最优子结构已经在动态规划章节中介绍过,这里不再赘述。值得注意的是,一些问题的最优子结构并不明显,但仍然可使用贪心算法解决。
我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单,**但实际上对于许多问题,证明贪心选择性质不是一件易事**。
我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单,**但实际上对于许多问题,证明贪心选择性质并非易事**。
例如零钱兑换问题,我们虽然能够容易地举出反例,对贪心选择性质进行证伪,但证实的难度较大。如果问:**满足什么条件的硬币组合可以使用贪心算法求解**?我们往往只能凭借直觉或举例子来给出一个模棱两可的答案,而难以给出严谨的数学证明。
!!! 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.
@@ -68,17 +68,17 @@
贪心问题的解决流程大体可分为以下三步。
1. **问题分析**:梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。
2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终解决整个问题。
3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要使用到数学证明,例如归纳法或反证法等。
2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终解决整个问题。
3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要用到数学证明,例如归纳法或反证法等。
确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要包含以下原因。
确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要以下原因。
- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。
- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是个典型案例。
- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。
- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是个典型案例。
为了保证正确性,我们应该对贪心策略进行严谨的数学证明,**通常需要用到反证法或数学归纳法**。
然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行 Debug ,一步步修改与验证贪心策略。
然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行代码调试,一步步修改与验证贪心策略。
## 贪心典型例题
@@ -88,5 +88,5 @@
- **区间调度问题**:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
- **分数背包问题**:给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解。
- **股票买卖问题**:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
- **霍夫曼编码**:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
- **霍夫曼编码**:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
- **Dijkstra 算法**:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

View File

@@ -8,6 +8,6 @@
!!! abstract
向日葵朝着太阳转动,时刻都在追求自身成长的最大可能。
向日葵朝着太阳转动,时刻追求自身成长的最大可能。
贪心策略在一轮轮的简单选择中,逐步导向最佳答案。
贪心策略在一轮轮的简单选择中,逐步导向最佳答案。

View File

@@ -2,23 +2,23 @@
!!! question
输入一个数组 $ht$ 数组中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。
输入一个数组 $ht$ 中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。
容器的容量等于高度和宽度的乘积(面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。
容器的容量等于高度和宽度的乘积(面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。
请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。
请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。示例如下图所示。
![最大容量问题的示例数据](max_capacity_problem.assets/max_capacity_example.png)
容器由任意两个隔板围成,**因此本题的状态为两个隔板的索引,记为 $[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)$ 。
### 贪心策略确定
@@ -36,14 +36,14 @@ $$
![向内移动短板后的状态](max_capacity_problem.assets/max_capacity_moving_short_board.png)
由此便可推出本题的贪心策略:初始化两指针分容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
由此便可推出本题的贪心策略:初始化两指针分容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
下图展示了贪心策略的执行过程。
1. 初始状态下,指针 $i$ 和 $j$ 分列与数组两端。
2. 计算当前状态的容量 $cap[i, j]$ ,并更新最大容量。
3. 比较板 $i$ 和 板 $j$ 的高度,并将短板向内移动一格。
4. 循环执行第 `2.` `3.` 步,直至 $i$ 和 $j$ 相遇时结束。
4. 循环执行第 `2.` 步和第 `3.` 步,直至 $i$ 和 $j$ 相遇时结束。
=== "<1>"
![最大容量问题的贪心过程](max_capacity_problem.assets/max_capacity_greedy_step1.png)
@@ -76,7 +76,7 @@ $$
代码循环最多 $n$ 轮,**因此时间复杂度为 $O(n)$** 。
变量 $i$、$j$、$res$ 使用常数大小额外空间,**因此空间复杂度为 $O(1)$** 。
变量 $i$、$j$、$res$ 使用常数大小额外空间,**因此空间复杂度为 $O(1)$** 。
```src
[file]{max_capacity}-[class]{}-[func]{max_capacity}
@@ -94,6 +94,6 @@ $$
![移动短板导致被跳过的状态](max_capacity_problem.assets/max_capacity_skipped_states.png)
观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。
观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。前面我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。
以上分析说明,**移动短板的操作是“安全”的**,贪心策略是有效的。
以上分析说明,移动短板的操作是“安全”的,贪心策略是有效的。

View File

@@ -2,7 +2,7 @@
!!! question
给定一个正整数 $n$ ,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少。
给定一个正整数 $n$ ,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少,如下图所示
![最大切分乘积的问题定义](max_product_cutting_problem.assets/max_product_cutting_definition.png)
@@ -12,7 +12,7 @@ $$
n = \sum_{i=1}^{m}n_i
$$
本题目标是求得所有整数因子的最大乘积,即
本题目标是求得所有整数因子的最大乘积,即
$$
\max(\prod_{i=1}^{m}n_i)
@@ -42,11 +42,11 @@ $$
如下图所示,当 $n = 6$ 时,有 $3 \times 3 > 2 \times 2 \times 2$ 。**这意味着切分出 $3$ 比切分出 $2$ 更优**。
**贪心策略二**:在切分方案中,最多只应存在两个 $2$ 。因为三个 $2$ 总是可以替换为两个 $3$ ,从而获得更大乘积。
**贪心策略二**:在切分方案中,最多只应存在两个 $2$ 。因为三个 $2$ 总是可以替换为两个 $3$ ,从而获得更大乘积。
![最优切分因子](max_product_cutting_problem.assets/max_product_cutting_greedy_infer2.png)
总结以上,可推出以下贪心策略。
综上所述,可推出以下贪心策略。
1. 输入整数 $n$ ,从其不断地切分出因子 $3$ ,直至余数为 $0$、$1$、$2$ 。
2. 当余数为 $0$ 时,代表 $n$ 是 $3$ 的倍数,因此不做任何处理。
@@ -81,5 +81,5 @@ $$
使用反证法,只分析 $n \geq 3$ 的情况。
1. **所有因子 $\leq 3$** :假设最优切分方案中存在 $\geq 4$ 的因子 $x$ ,那么一定可以将其继续划分为 $2(x-2)$ ,从而获得更大的乘积。这与假设矛盾。
2. **切分方案不包含 $1$** :假设最优切分方案中存在一个因子 $1$ ,那么它一定可以合并入另外一个因子中,以获更大乘积。这与假设矛盾。
2. **切分方案不包含 $1$** :假设最优切分方案中存在一个因子 $1$ ,那么它一定可以合并入另外一个因子中,以获更大乘积。这与假设矛盾。
3. **切分方案最多包含两个 $2$** :假设最优切分方案中包含三个 $2$ ,那么一定可以替换为两个 $3$ ,乘积更大。这与假设矛盾。

View File

@@ -1,12 +1,12 @@
# 小结
- 贪心算法通常用于解决最优化问题,其原理是在每个决策阶段都做出局部最优的决策,以期获得全局最优解。
- 贪心算法通常用于解决最优化问题,其原理是在每个决策阶段都做出局部最优的决策,以期获得全局最优解。
- 贪心算法会迭代地做出一个又一个的贪心选择,每轮都将问题转化成一个规模更小的子问题,直到问题被解决。
- 贪心算法不仅实现简单,还具有很高的解题效率。相比于动态规划,贪心算法的时间复杂度通常更低。
- 在零钱兑换问题中,对于某些硬币组合,贪心算法可以保证找到最优解;对于另外一些硬币组合则不然,贪心算法可能找到很差的解。
- 适合用贪心算法求解的问题具有两大性质:贪心选择性质和最优子结构。贪心选择性质代表贪心策略的有效性。
- 对于某些复杂问题,贪心选择性质的证明并不简单。相对来说,证伪更加容易,例如零钱兑换问题。
- 求解贪心问题主要分为三步:问题分析、贪心策略确定、正确性证明。其中,贪心策略确定是核心步骤,正确性证明往往是难点。
- 求解贪心问题主要分为三步:问题分析、确定贪心策略、正确性证明。其中,确定贪心策略是核心步骤,正确性证明往往是难点。
- 分数背包问题在 0-1 背包的基础上,允许选择物品的一部分,因此可使用贪心算法求解。贪心策略的正确性可以使用反证法来证明。
- 最大容量问题可使用穷举法求解,时间复杂度为 $O(n^2)$ 。通过设计贪心策略,每轮向内移动短板,可将时间复杂度优化至 $O(n)$ 。
- 在最大切分乘积问题中,我们先后推理出两个贪心策略:$\geq 4$ 的整数都应该继续切分、最优切分因子为 $3$ 。代码中包含幂运算,时间复杂度取决于幂运算实现方法,通常为 $O(1)$ 或 $O(\log n)$ 。

View File

@@ -1,10 +1,10 @@
# 哈希算法
在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址**它们只能保证哈希表可以在发生冲突时正常工作,无法减少哈希冲突的发生**。
前两节介绍了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址,**它们只能保证哈希表可以在发生冲突时正常工作,无法减少哈希冲突的发生**。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
![哈希冲突的最佳与最差情况](hash_algorithm.assets/hash_collision_best_worst_condition.png)
![哈希冲突的最佳情况与最差情况](hash_algorithm.assets/hash_collision_best_worst_condition.png)
**键值对的分布情况由哈希函数决定**。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
@@ -14,25 +14,25 @@ index = hash(key) % capacity
观察以上公式,当哈希表容量 `capacity` 固定时,**哈希算法 `hash()` 决定了输出值**,进而决定了键值对在哈希表中的分布情况。
这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。
这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。
## 哈希算法的目标
为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。
为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点。
- **确定性**:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
- **效率高**:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
- **均匀分布**:哈希算法应使得键值对均分布在哈希表中。分布越均,哈希冲突的概率就越低。
- **均匀分布**:哈希算法应使得键值对均分布在哈希表中。分布越均,哈希冲突的概率就越低。
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
- **密码存储**:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整
- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。
- **单向性**:无法通过哈希值反推出关于输入数据的任何信息。
- **抗碰撞性**:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
- **抗碰撞性**:应当极难找到两个不同的输入,使得它们的哈希值相同。
- **雪崩效应**:输入的微小变化应当导致输出的显著且不可预测的变化。
请注意,**“均匀分布”与“抗碰撞性”是两个独立的概念**,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 `key` 下,哈希函数 `key % 100` 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 `key` 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 `key` ,从而破解密码。
@@ -42,7 +42,7 @@ index = hash(key) % capacity
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
- **加法哈希**:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
- **乘法哈希**:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
- **乘法哈希**:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
- **异或哈希**:将输入数据的每个元素通过异或操作累积到一个哈希值中。
- **旋转哈希**:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
@@ -52,9 +52,9 @@ index = hash(key) % capacity
观察发现,每种哈希算法的最后一步都是对大质数 $1000000007$ 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
先抛出结论:**当我们使用大质数作为模数,可以最大化地保证哈希值的均匀分布**。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
先抛出结论:**使用大质数作为模数,可以最大化地保证哈希值的均匀分布**。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。
举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。
$$
\begin{aligned}
@@ -64,7 +64,7 @@ $$
\end{aligned}
$$
如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key``modulus` 之间不存在公约数,输出的哈希值的均匀性会明显提升。
如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key``modulus` 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。
$$
\begin{aligned}
@@ -74,7 +74,7 @@ $$
\end{aligned}
$$
值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。
@@ -82,12 +82,12 @@ $$
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下表展示了在实际应用中常见的哈希算法。
- MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用。
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。
- SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列。
<p align="center"> 表 <id> &nbsp; 常见的哈希算法 </p>
@@ -95,7 +95,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 |
@@ -105,7 +105,7 @@ $$
我们知道,哈希表的 `key` 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 `hash()` 函数来计算各种数据类型的哈希值。
- 整数和布尔量的哈希值就是其本身。
- 浮点数和字符串的哈希值计算较为复杂,有兴趣的同学请自行学习。
- 浮点数和字符串的哈希值计算较为复杂,有兴趣的读者请自行学习。
- 元组的哈希值是对其中每一个元素进行哈希,然后将这些哈希值组合起来,得到单一的哈希值。
- 对象的哈希值基于其内存地址生成。通过重写对象的哈希方法,可实现基于内容生成哈希值。
@@ -130,7 +130,7 @@ $$
str = "Hello 算法"
hash_str = hash(str)
# 字符串 Hello 算法 的哈希值为 4617003410720528961
# 字符串Hello 算法的哈希值为 4617003410720528961
tup = (12836, "小哈")
hash_tup = hash(tup)
@@ -158,7 +158,7 @@ $$
string str = "Hello 算法";
size_t hashStr = hash<string>()(str);
// 字符串 Hello 算法 的哈希值为 15466937326284535026
// 字符串Hello 算法的哈希值为 15466937326284535026
// 在 C++ 中,内置 std:hash() 仅提供基本数据类型的哈希值计算
// 数组、对象的哈希值计算需要自行实现
@@ -181,7 +181,7 @@ $$
String str = "Hello 算法";
int hashStr = str.hashCode();
// 字符串 Hello 算法 的哈希值为 -727081396
// 字符串Hello 算法的哈希值为 -727081396
Object[] arr = { 12836, "小哈" };
int hashTup = Arrays.hashCode(arr);
@@ -209,7 +209,7 @@ $$
string str = "Hello 算法";
int hashStr = str.GetHashCode();
// 字符串 Hello 算法 的哈希值为 -586107568;
// 字符串Hello 算法的哈希值为 -586107568;
object[] arr = [12836, "小哈"];
int hashTup = arr.GetHashCode();
@@ -243,7 +243,7 @@ $$
let str = "Hello 算法"
let hashStr = str.hashValue
// 字符串 Hello 算法 的哈希值为 -7850626797806988787
// 字符串Hello 算法的哈希值为 -7850626797806988787
let arr = [AnyHashable(12836), AnyHashable("小哈")]
let hashTup = arr.hashValue
@@ -283,7 +283,7 @@ $$
String str = "Hello 算法";
int hashStr = str.hashCode;
// 字符串 Hello 算法 的哈希值为 468167534
// 字符串Hello 算法的哈希值为 468167534
List arr = [12836, "小哈"];
int hashArr = arr.hashCode;
@@ -323,7 +323,7 @@ $$
let mut str_hasher = DefaultHasher::new();
str.hash(&mut str_hasher);
let hash_str = str_hasher.finish();
// 字符串 Hello 算法 的哈希值为 16092673739211250988
// 字符串Hello 算法的哈希值为 16092673739211250988
let arr = (&12836, &"小哈");
let mut tup_hasher = DefaultHasher::new();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,10 +1,10 @@
# 哈希冲突
上节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
1. 改良哈希表数据结构,**使得哈希表可以在存在哈希冲突时正常工作**。
1. 改良哈希表数据结构,**使得哈希表可以在出现哈希冲突时正常工作**。
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
@@ -18,8 +18,8 @@
基于链式地址实现的哈希表的操作方法发生了以下变化。
- **查询元素**:输入 `key` ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 `key` 以查找目标键值对。
- **添加元素**:先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
- **删除元素**:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
- **添加元素**先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
- **删除元素**:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
链式地址存在以下局限性。
@@ -29,7 +29,7 @@
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
- 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
- 以下实现包含哈希表扩容方法。当负载因子超过 $\frac{2}{3}$ 时,我们将哈希表扩容至 $2$ 倍。
- 以下实现包含哈希表扩容方法。当负载因子超过 $\frac{2}{3}$ 时,我们将哈希表扩容至原先的 $2$ 倍。
```src
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
@@ -41,7 +41,7 @@
「开放寻址 open addressing」不引入额外的数据结构而是通过“多次探测”来处理哈希冲突探测方式主要包括线性探测、平方探测、多次哈希等。
下面将主要以线性探测为例,介绍开放寻址哈希表的工作机制与代码实现
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
### 线性探测
@@ -66,7 +66,7 @@
为此,考虑在线性探测中记录遇到的首个 `TOMBSTONE` 的索引,并将搜索到的目标元素与该 `TOMBSTONE` 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
```src
[file]{hash_map_open_addressing}-[class]{hash_map_open_addressing}-[func]{}
@@ -88,12 +88,12 @@
### 多次哈希
多次哈希使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
顾名思义,多次哈希方法使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空桶后插入元素。
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;或当遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$ 。
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$ 。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
!!! tip
@@ -103,6 +103,6 @@
各个编程语言采取了不同的哈希表实现策略,以下举几个例子。
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会被转换为红黑树以提升查找性能。
- Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
- Golang 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。

View File

@@ -24,7 +24,7 @@
## 哈希表常用操作
哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等
哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等,示例代码如下:
=== "Python"
@@ -273,7 +273,7 @@
```
哈希表有三种常用遍历方式:遍历键值对、遍历键和遍历值。
哈希表有三种常用遍历方式:遍历键值对、遍历键和遍历值。示例代码如下:
=== "Python"
@@ -468,7 +468,7 @@
我们先考虑最简单的情况,**仅用一个数组来实现哈希表**。在哈希表中,我们将数组中的每个空位称为「桶 bucket」每个桶可存储一个键值对。因此查询操作就是找到 `key` 对应的桶,并在桶中获取 `value` 。
那么,如何基于 `key` 定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` **我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
那么,如何基于 `key` 定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` **我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
输入一个 `key` ,哈希函数的计算过程分为以下两步。
@@ -493,7 +493,7 @@ index = hash(key) % capacity
## 哈希冲突与扩容
本质上看,哈希函数的作用是将所有 `key` 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,**理论上一定存在“多个输入对应相同输出”的情况**。
本质上看,哈希函数的作用是将所有 `key` 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,**理论上一定存在“多个输入对应相同输出”的情况**。
对于上述示例中的哈希函数,当输入的 `key` 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到:
@@ -512,6 +512,6 @@ index = hash(key) % capacity
![哈希表扩容](hash_map.assets/hash_table_reshash.png)
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
「负载因子 load factor」是哈希表的一个重要概念其定义为哈希表的元素数量除以桶数量用于衡量哈希冲突的严重程度**也常作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表容量扩展为原先的 $2$ 倍。
「负载因子 load factor」是哈希表的一个重要概念其定义为哈希表的元素数量除以桶数量用于衡量哈希冲突的严重程度**也常作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表扩容至原先的 $2$ 倍。

View File

@@ -8,6 +8,6 @@
!!! abstract
在计算机世界中,哈希表如同一位智能的图书管理员。
在计算机世界中,哈希表如同一位聪慧的图书管理员。
他知道如何计算索书号,从而可以快速找到目标书
他知道如何计算索书号,从而可以快速找到目标书。

View File

@@ -8,35 +8,35 @@
- 两个不同的 `key` 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈希冲突。
- 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类似,哈希表扩容操作的开销很大。
- 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容的条件。
- 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以进一步将链表转换为红黑树来提高效率。
- 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以通过进一步将链表转换为红黑树来提高效率。
- 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算量。
- 不同编程语言采取了不同的哈希表实现。例如Java 的 `HashMap` 使用链式地址,而 Python 的 `Dict` 采用开放寻址。
- 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该具备抗碰撞性和雪崩效应。
- 哈希算法通常采用大质数作为模数,以最大化地保证哈希值均匀分布,减少哈希冲突。
- 常见的哈希算法包括 MD5、SHA-1、SHA-2 和 SHA3 等。MD5 常用于校验文件完整性SHA-2 常用于安全应用与协议。
- 哈希算法通常采用大质数作为模数,以最大化地保证哈希值均匀分布,减少哈希冲突。
- 常见的哈希算法包括 MD5、SHA-1、SHA-2 和 SHA-3 等。MD5 常用于校验文件完整性SHA-2 常用于安全应用与协议。
- 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变对象是可哈希的。
### Q & A
!!! question "哈希表的时间复杂度为什么不是 $O(n)$ "
当哈希冲突比较严重时,哈希表的时间复杂度会退化至 $O(n)$ 。当哈希函数设计比较好、容量设置比较合理、冲突比较平均时,时间复杂度是 $O(1)$ 。我们使用编程语言内置的哈希表时,通常认为时间复杂度是 $O(1)$ 。
当哈希冲突比较严重时,哈希表的时间复杂度会退化至 $O(n)$ 。当哈希函数设计比较好、容量设置比较合理、冲突比较平均时,时间复杂度是 $O(1)$ 。我们使用编程语言内置的哈希表时,通常认为时间复杂度是 $O(1)$ 。
!!! question "为什么不使用哈希函数 $f(x) = x$ 呢?这样就不会有冲突了"
在 $f(x) = x$ 哈希函数下,每个元素对应唯一的桶索引,这与数组等价。然而,输入空间通常远大于输出空间(数组长度),因此哈希函数的最后一步往往是对数组长度取模。换句话说,哈希表的目标是将一个较大的状态空间映射到一个较小的空间,并提供 $O(1)$ 的查询效率。
!!! question "哈希表底层实现是数组、链表、二叉树,但为什么效率可以比们更高呢?"
!!! question "哈希表底层实现是数组、链表、二叉树,但为什么效率可以比们更高呢?"
首先,哈希表的时间效率变高,但空间效率变低了。哈希表有相当一部分内存未使用的,
首先,哈希表的时间效率变高,但空间效率变低了。哈希表有相当一部分内存未使用
其次,只是在特定使用场景下时间效率变高了。如果一个功能能够在相同的时间复杂度下使用数组或链表实现,那么通常比哈希表更快。这是因为哈希函数计算需要开销,时间复杂度的常数项更大。
最后,哈希表的时间复杂度可能发生劣化。例如在链式地址中,我们采取在链表或红黑树中执行查找操作,仍然有退化至 $O(n)$ 时间的风险。
!!! question "多次哈希有不能直接删除元素的缺陷吗?对于标记已删除的空间,这个空间还能再次使用吗?"
!!! question "多次哈希有不能直接删除元素的缺陷吗?标记已删除的空间还能再次使用吗?"
多次哈希是开放寻址的一种,开放寻址法都有不能直接删除元素的缺陷,需要通过标记删除。标记为已删除的空间可以再次使用。当将新元素插入哈希表,并且通过哈希函数找到了被标记为已删除的位置时,该位置可以被新元素使用。这样做既能保持哈希表的探测序列不变,又能保证哈希表的空间使用率。
多次哈希是开放寻址的一种,开放寻址法都有不能直接删除元素的缺陷,需要通过标记删除。标记为已删除的空间可以再次使用。当将新元素插入哈希表,并且通过哈希函数找到标记为已删除的位置时,该位置可以被新元素使用。这样做既能保持哈希表的探测序列不变,又能保证哈希表的空间使用率。
!!! question "为什么在线性探测中,查找元素的时候会出现哈希冲突呢?"
@@ -44,4 +44,4 @@
!!! question "为什么哈希表扩容能够缓解哈希冲突?"
哈希函数的最后一步往往是对数组长度 $n$ 取余,让输出值落在数组索引范围;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
哈希函数的最后一步往往是对数组长度 $n$ 取余,让输出值落在数组索引范围;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。

View File

@@ -6,7 +6,7 @@
我们首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为 $n$ ,每个元素的入堆操作使用 $O(\log{n})$ 时间,因此该建堆方法的时间复杂度为 $O(n \log n)$ 。
@@ -17,11 +17,11 @@
1. 将列表所有元素原封不动添加到堆中,此时堆的性质尚未得到满足。
2. 倒序遍历堆(即层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。
**每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆**。而由于是倒序遍历,因此堆是“自下而上”地被构建的。
**每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆**。而由于是倒序遍历,因此堆是“自下而上”构建的。
之所以选择倒序遍历,是因为这样能够保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的。
值得说明的是,**叶节点没有子节点,天然就是合法的子堆,因此无堆化**。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化。
值得说明的是,**叶节点没有子节点,天然就是合法的子堆,因此无堆化**。如以下代码所示,最后一个非叶节点是最后一个节点的父节点,我们从它开始倒序遍历并执行堆化。
```src
[file]{my_heap}-[class]{max_heap}-[func]{__init__}
@@ -36,11 +36,11 @@
将上述两者相乘,可得到建堆过程的时间复杂度为 $O(n \log n)$ 。**但这个估算结果并不准确,因为我们没有考虑到二叉树底层节点数量远多于顶层节点的性质**。
接下来我们来进行更为准确的计算。为了减小计算难度,假设给定一个节点数量为 $n$ 高度为 $h$ 的“完美二叉树”,该假设不会影响计算结果的正确性。
接下来我们来进行更为准确的计算。为了降低计算难度,假设给定一个节点数量为 $n$ 高度为 $h$ 的“完美二叉树”,该假设不会影响计算结果的正确性。
![完美二叉树的各层节点数量](build_heap.assets/heapify_operations_count.png)
如上图所示,节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,我们可以各层的“节点数量 $\times$ 节点高度”求和,**从而得到所有节点的堆化迭代次数的总和**。
如上图所示,节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,我们可以各层的“节点数量 $\times$ 节点高度”求和,**得到所有节点的堆化迭代次数的总和**。
$$
T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1

View File

@@ -1,6 +1,6 @@
# 堆
「堆 heap」是一种满足特定条件的完全二叉树主要可分为下图所示的两种类型。
「堆 heap」是一种满足特定条件的完全二叉树主要可分为两种类型,如下图所示
- 「大顶堆 max heap」任意节点的值 $\geq$ 其子节点的值。
- 「小顶堆 min heap」任意节点的值 $\leq$ 其子节点的值。
@@ -11,13 +11,13 @@
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值分别是最大(最小)的。
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值分别是最大(最小)的。
## 堆常用操作
需要指出的是,许多编程语言提供的是「优先队列 priority queue」这是一种抽象数据结构定义为具有优先级排序的队列。
实际上,**堆通常用实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一使用“堆“来命名
实际上,**堆通常用实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一称作“堆”
堆的常用操作见下表,方法名需要根据编程语言来确定。
@@ -33,9 +33,7 @@
在实际应用中,我们可以直接使用编程语言提供的堆类(或优先队列类)。
!!! tip
类似于排序算法中的“从小到大排列”和“从大到小排列”,我们可以通过修改 Comparator 来实现“小顶堆”与“大顶堆”之间的转换。
类似于排序算法中的“从小到大排列”和“从大到小排列”,我们可以通过设置一个 `flag` 或修改 `Comparator` 实现“小顶堆”与“大顶堆”之间的转换。代码如下所示:
=== "Python"
@@ -351,15 +349,15 @@
### 堆的存储与表示
我们在二叉树章节中学习到,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,**我们将采用数组来存储堆**。
二叉树章节讲过,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,**因此我们将采用数组来存储堆**。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。**节点指针通过索引映射公式来实现**。
如下图所示,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下整)。当索引越界时,表示空节点或节点不存在。
如下图所示,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下整)。当索引越界时,表示空节点或节点不存在。
![堆的表示与存储](heap.assets/representation_of_heap.png)
我们可以将索引映射公式封装成函数,方便后续使用
我们可以将索引映射公式封装成函数,方便后续使用
```src
[file]{my_heap}-[class]{max_heap}-[func]{parent}
@@ -367,7 +365,7 @@
### 访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素
```src
[file]{my_heap}-[class]{max_heap}-[func]{peek}
@@ -375,7 +373,7 @@
### 元素入堆
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,**因此需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
考虑从入堆节点开始,**从底至顶执行堆化**。如下图所示,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
@@ -406,7 +404,7 @@
=== "<9>"
![heap_push_step9](heap.assets/heap_push_step9.png)
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ **元素入堆操作的时间复杂度为 $O(\log n)$** 。
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ **元素入堆操作的时间复杂度为 $O(\log n)$** 。代码如下所示:
```src
[file]{my_heap}-[class]{max_heap}-[func]{sift_up}
@@ -414,10 +412,10 @@
### 堆顶元素出堆
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤。
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。为了尽量减少元素索引的变动,我们采用以下操作步骤。
1. 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素)。
1. 交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
3. 从根节点开始,**从顶至底执行堆化**。
如下图所示,**“从顶至底堆化”的操作方向与“从底至顶堆化”相反**,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换。然后循环执行此操作,直到越过叶节点或遇到无须交换的节点时结束。
@@ -452,7 +450,7 @@
=== "<10>"
![heap_pop_step10](heap.assets/heap_pop_step10.png)
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 $O(\log n)$ 。
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 $O(\log n)$ 。代码如下所示:
```src
[file]{my_heap}-[class]{max_heap}-[func]{sift_down}
@@ -461,5 +459,5 @@
## 堆常见应用
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。
- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见后续的堆排序章节。
- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见堆排序章节。
- **获取最大的 $k$ 个元素**:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。

View File

@@ -8,6 +8,6 @@
!!! abstract
堆就像是山川的峰峦,它们层叠起伏、形态各异。
堆就像是山峰峦,层叠起伏、形态各异。
每一座山峰都有其高低之分,而最高的山峰总是最先映入眼帘。
座山峰高低错落,而最高的山峰总是最先映入眼帘。

View File

@@ -14,4 +14,4 @@
!!! question "数据结构的“堆”与内存管理的“堆”是同一个概念吗?"
两者不是同一个概念,只是碰巧都叫堆。计算机系统内存中的堆是动态内存分配的一部分,程序在运行时可以使用它来存储数据。程序可以请求一定量的堆内存,用于存储如对象和数组等复杂结构。当这些数据不再需要时,程序需要释放这些内存,以防止内存泄。相较于栈内存,堆内存的管理和使用需要更谨慎,不恰当的使用可能会导致内存泄和野指针等问题。
两者不是同一个概念,只是碰巧都叫堆。计算机系统内存中的堆是动态内存分配的一部分,程序在运行时可以使用它来存储数据。程序可以请求一定量的堆内存,用于存储如对象和数组等复杂结构。当这些数据不再需要时,程序需要释放这些内存,以防止内存泄。相较于栈内存,堆内存的管理和使用需要更谨慎,使用不当可能会导致内存泄和野指针等问题。

View File

@@ -2,7 +2,7 @@
!!! question
给定一个长度为 $n$ 无序数组 `nums` ,请返回数组中前 $k$ 大的元素。
给定一个长度为 $n$ 无序数组 `nums` ,请返回数组中前 $k$ 大的元素。
对于该问题,我们先介绍两种思路比较直接的解法,再介绍效率更高的堆解法。
@@ -22,7 +22,7 @@
如下图所示,我们可以先对数组 `nums` 进行排序,再返回最右边的 $k$ 个元素,时间复杂度为 $O(n \log n)$ 。
显然,该方法“超额”完成任务了,因为我们只需找出最大的 $k$ 个元素即可,而不需要排序其他元素。
显然,该方法“超额”完成任务了,因为我们只需找出最大的 $k$ 个元素即可,而不需要排序其他元素。
![排序寻找最大的 k 个元素](top_k.assets/top_k_sorting.png)
@@ -62,10 +62,12 @@
=== "<9>"
![top_k_heap_step9](top_k.assets/top_k_heap_step9.png)
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大 $k$ 个元素的动态更新。
示例代码如下:
```src
[file]{top_k}-[class]{}-[func]{top_k_heap}
```
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大 $k$ 个元素的动态更新。

View File

@@ -1,8 +1,8 @@
# 算法无处不在
当我们听到“算法”这个词时,很自然地会想到数学。然而实际上,许多算法并不涉及复杂数学,而是更多地依赖基本逻辑,这些逻辑在我们的日常生活中处处可见。
当我们听到“算法”这个词时,很自然地会想到数学。然而实际上,许多算法并不涉及复杂数学,而是更多地依赖基本逻辑,这些逻辑在我们的日常生活中处处可见。
在正式探讨算法之前,有一个有趣的事实值得分享:**你已经在不知不觉中学会了许多算法,并习惯将它们应用到日常生活中了**。下面我将举几个具体例子来证实这一点。
在正式探讨算法之前,有一个有趣的事实值得分享:**你已经在不知不觉中学会了许多算法,并习惯将它们应用到日常生活中了**。下面我将举几个具体例子来证实这一点。
**例一:查阅字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会按照下图所示的方式实现。
@@ -25,9 +25,9 @@
=== "<5>"
![binary_search_dictionary_step5](algorithms_are_everywhere.assets/binary_search_dictionary_step5.png)
字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的“数组”;从算法的角度,我们可以将上述查字典的一系列操作看作“二分查找”。
查字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的“数组”;从算法的角度,我们可以将上述查字典的一系列操作看作“二分查找”。
**例二:整理扑克**。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下图所示。
**例二:整理扑克**。我们在打牌时,每局都需要整理手中的扑克牌,使其从小到大排列,实现流程如下图所示。
1. 将扑克牌划分为“有序”和“无序”两部分,并假设初始状态下最左 1 张扑克牌已经有序。
2. 在无序部分抽出一张扑克牌,插入至有序部分的正确位置;完成后最左 2 张扑克已经有序。
@@ -35,7 +35,7 @@
![扑克排序步骤](algorithms_are_everywhere.assets/playing_cards_sorting.png)
上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都插入排序的身影。
**例三:货币找零**。假设我们在超市购买了 $69$ 元的商品,给了收银员 $100$ 元,则收银员需要找我们 $31$ 元。他会很自然地完成如下图所示的思考。
@@ -49,8 +49,8 @@
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是“贪心”算法。
小到烹饪一道菜,大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现使我们能够通过编程将数据结构存储在内存中,同时编写代码调用 CPU 和 GPU 执行算法。这样一来,我们就能把生活中的问题转移到计算机上,以更高效的方式解决各种复杂问题。
小到烹饪一道菜,大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现使我们能够通过编程将数据结构存储在内存中,同时编写代码调用 CPU 和 GPU 执行算法。这样一来,我们就能把生活中的问题转移到计算机上,以更高效的方式解决各种复杂问题。
!!! tip
如果你对数据结构、算法、数组和二分查找等概念仍感到一知半解,请继续往下阅读,本书将引导你迈入数据结构与算法的知识殿堂。
如果你对数据结构、算法、数组和二分查找等概念仍感到一知半解,请继续往下阅读,本书将引导你迈入数据结构与算法的知识殿堂。

View File

@@ -3,7 +3,7 @@
- 算法在日常生活中无处不在,并不是遥不可及的高深知识。实际上,我们已经在不知不觉中学会了许多算法,用以解决生活中的大小问题。
- 查阅字典的原理与二分查找算法相一致。二分查找算法体现了分而治之的重要算法思想。
- 整理扑克的过程与插入排序算法非常类似。插入排序算法适合排序小型数据集。
- 货币找零的步骤本质上是贪心算法,每一步都采取当前看来最好的选择。
- 货币找零的步骤本质上是贪心算法,每一步都采取当前看来最好的选择。
- 算法是在有限时间内解决特定问题的一组指令或操作步骤,而数据结构是计算机中组织和存储数据的方式。
- 数据结构与算法紧密相连。数据结构是算法的基石,而算法则是发挥数据结构作用的舞台。
- 我们可以将数据结构与算法类比为拼装积木,积木代表数据,积木的形状和连接方式代表数据结构,拼装积木的步骤则对应算法。
- 数据结构与算法紧密相连。数据结构是算法的基石,而算法数据结构发挥作用的舞台。
- 我们可以将数据结构与算法类比为拼装积木,积木代表数据,积木的形状和连接方式代表数据结构,拼装积木的步骤则对应算法。

View File

@@ -6,28 +6,28 @@
- 问题是明确的,包含清晰的输入和输出定义。
- 具有可行性,能够在有限步骤、时间和内存空间下完成。
- 各步骤都有确定的含义,相同的输入和运行条件下,输出始终相同。
- 各步骤都有确定的含义,相同的输入和运行条件下,输出始终相同。
## 数据结构定义
「数据结构 data structure」是计算机中组织和存储数据的方式具有以下设计目标。
- 空间占用尽量少,节省计算机内存。
- 空间占用尽量少,节省计算机内存。
- 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
- 提供简洁的数据表示和逻辑信息,以便使得算法高效运行。
- 提供简洁的数据表示和逻辑信息,以便算法高效运行。
**数据结构设计是一个充满权衡的过程**。如果想在某方面取得提升,往往需要在另一方面作出妥协。下面举两个例子。
**数据结构设计是一个充满权衡的过程**。如果想在某方面取得提升,往往需要在另一方面作出妥协。下面举两个例子。
- 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度。
- 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间。
## 数据结构与算法的关系
如下图所示,数据结构与算法高度相关、紧密结合,具体表现以下三个方面。
如下图所示,数据结构与算法高度相关、紧密结合,具体表现以下三个方面。
- 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及用于操作数据的方法。
- 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及操作数据的方法。
- 算法是数据结构发挥作用的舞台。数据结构本身仅存储数据信息,结合算法才能解决特定问题。
- 算法通常可以基于不同的数据结构进行实现,但执行效率可能相差很大,选择合适的数据结构是关键。
- 算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,选择合适的数据结构是关键。
![数据结构与算法的关系](what_is_dsa.assets/relationship_between_data_structure_and_algorithm.png)
@@ -37,7 +37,7 @@
两者的详细对应关系如下表所示。
<p align="center"> 表 <id> &nbsp; 将数据结构与算法类比为积木 </p>
<p align="center"> 表 <id> &nbsp; 将数据结构与算法类比为拼装积木 </p>
| 数据结构与算法 | 拼装积木 |
| -------------- | ---------------------------------------- |
@@ -46,8 +46,8 @@
| 算法 | 把积木拼成目标形态的一系列操作步骤 |
| 输出数据 | 积木模型 |
值得说明的是,数据结构与算法是独立于编程语言的。正因如此,本书得以提供多种编程语言的实现。
值得说明的是,数据结构与算法是独立于编程语言的。正因如此,本书得以提供基于多种编程语言的实现。
!!! tip "约定俗成的简称"
在实际讨论时,我们通常会将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。
在实际讨论时,我们通常会将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题目,实际上同时考数据结构和算法两方面的知识。

View File

@@ -1,45 +1,46 @@
# 关于本书
本项目旨在创建一本开源免费、新手友好的数据结构与算法入门教程。
本项目旨在创建一本开源免费、新手友好的数据结构与算法入门教程。
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂、学习曲线平滑。
- 算法源代码皆可一键运行,支持 Python、C++、Java、C#、Go、Swift、JS、TS、Dart、Rust、C、Zig 等语言。
- 算法源代码皆可一键运行,支持 Python、C++、Java、C#、Go、Swift、JavaScript、TypeScript、Dart、Rust、C、Zig 等语言。
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论通常可在两日内得到回复。
## 读者对象
是算法初学者,从未接触过算法,或者已经有一些刷题经验,对数据结构与算法有模糊的认识,在会与不会之间反复横跳,那么本书正是为量身定制!
是算法初学者,从未接触过算法,或者已经有一些刷题经验,对数据结构与算法有模糊的认识,在会与不会之间反复横跳,那么本书正是为量身定制
如果已经积累一定刷题量,熟悉大部分题型,那么本书可助回顾与梳理算法知识体系,仓库源代码可以当作“刷题工具库”或“算法字典”来使用。
如果已经积累一定刷题量,熟悉大部分题型,那么本书可助回顾与梳理算法知识体系,仓库源代码可以当作“刷题工具库”或“算法字典”来使用。
是算法大神,我们期待收到的宝贵建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
是算法大神,我们期待收到的宝贵建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
!!! success "前置条件"
需要至少具备任一语言的编程基础,能够阅读和编写简单代码。
需要至少具备任一语言的编程基础,能够阅读和编写简单代码。
## 内容结构
本书主要内容如下图所示。
本书主要内容如下图所示。
- **复杂度分析**:数据结构和算法的评价维度与方法。时间复杂度、空间复杂度的推算方法、常见类型、示例等。
- **数据结构**:基本数据类型,数据结构的分类方法。数组、链表、栈、队列、哈希表、树、堆、图等数据结构的定义、优缺点、常用操作、常见类型、典型应用、实现方法等。
- **算法**:搜索、排序、分治、回溯、动态规划、贪心等算法的定义、优缺点、效率、应用场景、解题步骤示例题目等。
- **算法**:搜索、排序、分治、回溯、动态规划、贪心等算法的定义、优缺点、效率、应用场景、解题步骤示例题目等。
![Hello 算法内容结构](about_the_book.assets/hello_algo_mindmap.jpg)
![本书主要内容](about_the_book.assets/hello_algo_mindmap.jpg)
## 致谢
在本书的创作过程中,我得到了许多人的帮助,包括但不限于:
- 感谢我在公司的导师李汐博士,在一次畅谈中鼓励我“快行动起来”,坚定了我写这本书的决心
- 感谢我的女朋友泡泡作为本书的首位读者,从算法小白的角度提出许多宝贵建议,使得本书更适合新手阅读
- 感谢腾宝、琦宝、飞宝为本书起了一个富有创意的名字,唤起大家写下第一行代码 "Hello World!" 的美好回忆
- 感谢苏潼为本书设计了精美的封面和 LOGO ,并在我的强迫症下多次耐心修改
- 感谢我在公司的导师李汐博士,在一次畅谈中鼓励我“快行动起来”,坚定了我写这本书的决心
- 感谢我的女朋友泡泡作为本书的首位读者,从算法小白的角度提出许多宝贵建议,使得本书更适合新手阅读
- 感谢腾宝、琦宝、飞宝为本书起了一个富有创意的名字,唤起大家写下第一行代码 "Hello World!" 的美好回忆
- 感谢校铨在知识产权方面提供的帮助,你的专业指导对这本开源书籍的发展起到了至关重要的作用
- 感谢苏潼为本书设计了精美的封面和 logo ,并在我的强迫症下多次耐心修改;
- 感谢 @squidfunk 提供的写作排版建议,以及他开发的开源文档主题 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) 。
在写作过程中,我阅读了许多关于数据结构与算法的教材和文章。这些作品为本书提供了优秀的范本,确保了本书内容的准确性与品质。在此感谢所有老师和前辈们的杰出贡献!
本书倡导手脑并用的学习方式,在这一点上深受[《动手学深度学习》](https://github.com/d2l-ai/d2l-zh)的启发。在此向各位读者强烈推荐这本优秀著作。
本书倡导手脑并用的学习方式,在这一点上深受[《动手学深度学习》](https://github.com/d2l-ai/d2l-zh)的启发。在此向各位读者强烈推荐这本优秀著作。
衷心感谢我的父母,正是们一直以来的支持与鼓励,让我有机会做这件富有趣味的事。
衷心感谢我的父母,正是们一直以来的支持与鼓励,让我有机会做这件富有趣味的事。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -2,15 +2,15 @@
!!! tip
为了获得最佳的阅读体验,建议通读本节内容。
为了获得最佳的阅读体验,建议通读本节内容。
## 行文风格约定
- 标题后标注 `*` 的是选读章节,内容相对困难。如果你的时间有限,建议可以先跳过。
- 专有名词和有特指含义的词句会使用 `“双引号”` 标注,以避免歧义。
- 标题后标注 `*` 的是选读章节,内容相对困难。如果你的时间有限,可以先跳过。
- 重要专有名词及其英文翻译会用 `「 」` 括号标注,例如 `「数组 array」` 。建议记住它们,以便阅读文献。
- **加粗的文字** 表示重点内容或总结性语句,这类文字值得特别关注
- 当涉及到编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 $\text{None}$ 来表示“空”
- 专有名词和有特指含义的词句会使用 `“引号”` 标注,以避免歧义
- 重要名词、重点内容和总结性语句会被 **加粗** ,这类文字值得特别关注
- 当涉及编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 $\text{None}$ 来表示“空”。
- 本书部分放弃了编程语言的注释规范,以换取更加紧凑的内容排版。注释主要分为三种类型:标题注释、内容注释、多行注释。
=== "Python"
@@ -171,13 +171,13 @@
相较于文字,视频和图片具有更高的信息密度和结构化程度,更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
如果你在阅读本书时,发现某段内容提供了下图所示的动画或图解,**请以图为主、以文字为辅**,综合两者来理解内容。
如果你在阅读本书时,发现某段内容提供了下图所示的动画或图解,**请以图为主、以文字为辅**,综合两者来理解内容。
![动画图解示例](../index.assets/animation.gif)
## 在代码实践中加深理解
本书的配套代码托管在 [GitHub 仓库](https://github.com/krahets/hello-algo)。如下图所示,**源代码附有测试样例,可一键运行**。
本书的配套代码托管在 [GitHub 仓库](https://github.com/krahets/hello-algo)。如下图所示,**源代码附有测试样例,可一键运行**。
如果时间允许,**建议你参照代码自行敲一遍**。如果学习时间有限,请至少通读并运行所有代码。
@@ -187,15 +187,17 @@
运行代码的前置工作主要分为三步。
**第一步:安装本地编程环境**。请参照[附录教程](https://www.hello-algo.com/chapter_appendix/installation/)进行安装,如果已安装则可跳过此步骤。
**第一步:安装本地编程环境**。请参照附录所示的[教程](https://www.hello-algo.com/chapter_appendix/installation/)进行安装,如果已安装则可跳过此步骤。
**第二步:克隆或下载代码仓**。如果已经安装 [Git](https://git-scm.com/downloads) ,可以通过以下命令克隆本仓库
**第二步:克隆或下载代码仓**。前往 [GitHub 仓库](https://github.com/krahets/hello-algo)
如果已经安装 [Git](https://git-scm.com/downloads) ,可以通过以下命令克隆本仓库:
```shell
git clone https://github.com/krahets/hello-algo.git
```
当然你也可以在下图所示的位置点击“Download ZIP”直接下载代码压缩包然后在本地解压即可。
当然你也可以在下图所示的位置点击“Download ZIP”按钮直接下载代码压缩包,然后在本地解压即可。
![克隆仓库与下载代码](suggestions.assets/download_code.png)
@@ -207,7 +209,7 @@ git clone https://github.com/krahets/hello-algo.git
在阅读本书时,请不要轻易跳过那些没学明白的知识点。**欢迎在评论区提出你的问题**,我和小伙伴们将竭诚为你解答,一般情况下可在两天内回复。
如下图所示,每篇文章的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享的见解,帮助他人进步。
如下图所示,每个章节的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享的见解,帮助他人进步。
![评论区示例](../index.assets/comment.gif)
@@ -215,10 +217,10 @@ git clone https://github.com/krahets/hello-algo.git
从总体上看,我们可以将学习数据结构与算法的过程划分为三个阶段。
1. **算法入门**。我们需要熟悉各种数据结构的特点和用法,学习不同算法的原理、流程、用途和效率等方面内容。
2. **刷算法题**。建议从热门题目开刷,如[剑指 Offer](https://leetcode.cn/studyplan/coding-interviews/)[LeetCode Hot 100](https://leetcode.cn/studyplan/top-100-liked/),先积累至少 100 道题目,熟悉主流的算法问题。初次刷题时,“知识遗忘”可能是一个挑战,但请放心,这是很正常的。我们可以按照“艾宾浩斯遗忘曲线”来复习题目,通常在进行 3-5 轮的重复后,就能将其牢记在心。
3. **搭建知识体系**。在学习方面,我们可以阅读算法专栏文章、解题框架和算法教材,以不断丰富知识体系。在刷题方面,可以尝试采用进阶刷题策略,如按专题分类、一题多解、一解多题等,相关的刷题心得可以在各个社区找到。
1. **阶段一:算法入门**。我们需要熟悉各种数据结构的特点和用法,学习不同算法的原理、流程、用途和效率等方面内容。
2. **阶段二:刷算法题**。建议从热门题目开刷,如[剑指 Offer](https://leetcode.cn/studyplan/coding-interviews/)”和“[LeetCode Hot 100](https://leetcode.cn/studyplan/top-100-liked/),先积累至少 100 道题目,熟悉主流的算法问题。初次刷题时,“知识遗忘”可能是一个挑战,但请放心,这是很正常的。我们可以按照“艾宾浩斯遗忘曲线”来复习题目,通常在进行 35 轮的重复后,就能将其牢记在心。
3. **阶段三:搭建知识体系**。在学习方面,我们可以阅读算法专栏文章、解题框架和算法教材,以不断丰富知识体系。在刷题方面,可以尝试采用进阶刷题策略,如按专题分类、一题多解、一解多题等,相关的刷题心得可以在各个社区找到。
如下图所示,本书内容主要涵盖“第一阶段”,旨在帮助你更高效地展开第二和第三阶段的学习。
如下图所示,本书内容主要涵盖“阶段”,旨在帮助你更高效地展开阶段二和阶段的学习。
![算法学习路线](suggestions.assets/learning_route.png)

View File

@@ -1,8 +1,8 @@
# 小结
- 本书的主要受众是算法初学者。如果已有一定基础,本书能帮助系统回顾算法知识,书源代码也可作为“刷题工具库”使用。
- 本书的主要受众是算法初学者。如果已有一定基础,本书能帮助系统回顾算法知识,书源代码也可作为“刷题工具库”使用。
- 书中内容主要包括复杂度分析、数据结构、算法三部分,涵盖了该领域的大部分主题。
- 对于算法新手,在初学阶段阅读一本入门书至关重要,可以少走许多弯路。
-的动画和图解通常用于介绍重点和难点知识。阅读本书时,应给予这些内容更多关注。
- 实践乃学习编程之最佳途径。强烈建议运行源代码并亲自敲代码。
- 对于算法新手,在初学阶段阅读一本入门书至关重要,可以少走许多弯路。
-的动画和图解通常用于介绍重点和难点知识。阅读本书时,应给予这些内容更多关注。
- 实践乃学习编程之最佳途径。强烈建议运行源代码并亲自敲代码。
- 本书网页版的每个章节都设有讨论区,欢迎随时分享你的疑惑与见解。

View File

@@ -1,10 +1,10 @@
# 二分查找
「二分查找 binary search」是一种基于分治策略的高效搜索算法。它利用数据的有序性每轮减少一半搜索范围,直至找到目标元素或搜索区间为空为止。
「二分查找 binary search」是一种基于分治策略的高效搜索算法。它利用数据的有序性每轮缩小一半搜索范围,直至找到目标元素或搜索区间为空为止。
!!! question
给定一个长度为 $n$ 的数组 `nums` ,元素按从小到大的顺序排列,数组不包含重复元素。请查找并返回元素 `target` 在该数组中的索引。若数组不包含该元素,则返回 $-1$ 。
给定一个长度为 $n$ 的数组 `nums` ,元素按从小到大的顺序排列且不重复。请查找并返回元素 `target` 在该数组中的索引。若数组不包含该元素,则返回 $-1$ 。示例如下图所示。
![二分查找示例数据](binary_search.assets/binary_search_example.png)
@@ -43,6 +43,8 @@
值得注意的是,由于 $i$ 和 $j$ 都是 `int` 类型,**因此 $i + j$ 可能会超出 `int` 类型的取值范围**。为了避免大数越界,我们通常采用公式 $m = \lfloor {i + (j - i) / 2} \rfloor$ 来计算中点。
代码如下所示:
```src
[file]{binary_search}-[class]{}-[func]{binary_search}
```
@@ -53,9 +55,9 @@
## 区间表示方法
除了上述双闭区间外,常见的区间表示还有“左闭右开”区间,定义为 $[0, n)$ ,即左边界包含自身,右边界不包含自身。在该表示下,区间 $[i, j]$ 在 $i = j$ 时为空。
除了上述双闭区间外,常见的区间表示还有“左闭右开”区间,定义为 $[0, n)$ ,即左边界包含自身,右边界不包含自身。在该表示下,区间 $[i, j]$ 在 $i = j$ 时为空。
我们可以基于该表示实现具有相同功能的二分查找算法
我们可以基于该表示实现具有相同功能的二分查找算法
```src
[file]{binary_search}-[class]{}-[func]{binary_search_lcro}
@@ -63,7 +65,7 @@
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错,**因此一般建议采用“双闭区间”的写法**。
由于“双闭区间”表示中的左右边界都被定义为闭区间,因此通过指针 $i$ 和指针 $j$ 缩小区间操作也是对称的。这样更不容易出错,**因此一般建议采用“双闭区间”的写法**。
![两种区间定义](binary_search.assets/binary_search_ranges.png)
@@ -78,4 +80,4 @@
- 二分查找仅适用于有序数据。若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 $O(n \log n)$ ,比线性查找和二分查找都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
- 二分查找仅适用于数组。二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
- 小数据量下,线性查找性能更佳。在线性查找中,每轮只需 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 $n$ 较小时,线性查找反而比二分查找更快。
- 小数据量下,线性查找性能更佳。在线性查找中,每轮只需 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 $n$ 较小时,线性查找反而比二分查找更快。

View File

@@ -4,7 +4,7 @@
!!! question
给定一个长度为 $n$ 的有序数组 `nums` 数组可能包含重复元素。请返回数组中最左一个元素 `target` 的索引。若数组中不包含该元素,则返回 $-1$ 。
给定一个长度为 $n$ 的有序数组 `nums` 其中可能包含重复元素。请返回数组中最左一个元素 `target` 的索引。若数组中不包含该元素,则返回 $-1$ 。
回忆二分查找插入点的方法,搜索完成后 $i$ 指向最左一个 `target` **因此查找插入点本质上是在查找最左一个 `target` 的索引**。
@@ -13,7 +13,7 @@
- 插入点的索引 $i$ 越界。
- 元素 `nums[i]``target` 不相等。
当遇到以上两种情况时,直接返回 $-1$ 即可。
当遇到以上两种情况时,直接返回 $-1$ 即可。代码如下所示:
```src
[file]{binary_search_edge}-[class]{}-[func]{binary_search_left_edge}
@@ -21,7 +21,7 @@
## 查找右边界
那么如何查找最右一个 `target` 呢?最直接的方式是修改代码,替换在 `nums[m] == target` 情况下的指针收缩操作。代码在此省略,有兴趣的同学可以自行实现。
那么如何查找最右一个 `target` 呢?最直接的方式是修改代码,替换在 `nums[m] == target` 情况下的指针收缩操作。代码在此省略,有兴趣的读者可以自行实现。
下面我们介绍两种更加取巧的方法。
@@ -33,7 +33,7 @@
![将查找右边界转化为查找左边界](binary_search_edge.assets/binary_search_right_edge_by_left_edge.png)
请注意,返回的插入点是 $i$ ,因此需要将其减 $1$ ,从而获得 $j$
请注意,返回的插入点是 $i$ ,因此需要将其减 $1$ ,从而获得 $j$
```src
[file]{binary_search_edge}-[class]{}-[func]{binary_search_right_edge}
@@ -50,7 +50,7 @@
![将查找边界转化为查找元素](binary_search_edge.assets/binary_search_edge_by_element.png)
代码在此省略,值得注意以下两点。
代码在此省略,以下两点值得注意
- 给定数组不包含小数,这意味着我们无须关心如何处理相等的情况。
- 因为该方法引入了小数,所以需要将函数中的变量 `target` 改为浮点数类型。

View File

@@ -1,16 +1,16 @@
# 二分查找插入点
二分查找不仅可用于搜索目标元素,还具有许多变种问题,比如搜索目标元素的插入位置。
二分查找不仅可用于搜索目标元素,还可用于解决许多变种问题,比如搜索目标元素的插入位置。
## 无重复元素的情况
!!! question
给定一个长度为 $n$ 的有序数组 `nums` 和一个元素 `target` ,数组不存在重复元素。现将 `target` 插入数组 `nums` 中,并保持其有序性。若数组中已存在元素 `target` ,则插入到其左方。请返回插入后 `target` 在数组中的索引。
给定一个长度为 $n$ 的有序数组 `nums` 和一个元素 `target` ,数组不存在重复元素。现将 `target` 插入数组 `nums` 中,并保持其有序性。若数组中已存在元素 `target` ,则插入到其左方。请返回插入后 `target` 在数组中的索引。
![二分查找插入点示例数据](binary_search_insertion.assets/binary_search_insertion_example.png)
如果想复用上节的二分查找代码,则需要回答以下两个问题。
如果想复用上节的二分查找代码,则需要回答以下两个问题。
**问题一**:当数组中包含 `target` 时,插入点的索引是否是该元素的索引?
@@ -20,7 +20,7 @@
进一步思考二分查找过程:当 `nums[m] < target` 时 $i$ 移动,这意味着指针 $i$ 在向大于等于 `target` 的元素靠近。同理,指针 $j$ 始终在向小于等于 `target` 的元素靠近。
因此二分结束时一定有:$i$ 指向首个大于 `target` 的元素,$j$ 指向首个小于 `target` 的元素。**易得当数组不包含 `target` 时,插入索引为 $i$** 。
因此二分结束时一定有:$i$ 指向首个大于 `target` 的元素,$j$ 指向首个小于 `target` 的元素。**易得当数组不包含 `target` 时,插入索引为 $i$** 。代码如下所示:
```src
[file]{binary_search_insertion}-[class]{}-[func]{binary_search_insertion_simple}
@@ -43,7 +43,7 @@
此方法虽然可用,但其包含线性查找,因此时间复杂度为 $O(n)$ 。当数组中存在很多重复的 `target` 时,该方法效率很低。
现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target``nums[m]` 大小关系,分为以下几种情况。
现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target``nums[m]` 大小关系,分为以下几种情况。
-`nums[m] < target``nums[m] > target` 时,说明还没有找到 `target` ,因此采用普通二分查找的缩小区间操作,**从而使指针 $i$ 和 $j$ 向 `target` 靠近**。
-`nums[m] == target` 时,说明小于 `target` 的元素在区间 $[i, m - 1]$ 中,因此采用 $j = m - 1$ 来缩小区间,**从而使指针 $j$ 向小于 `target` 的元素靠近**。

View File

@@ -8,10 +8,12 @@
## 线性查找:以时间换空间
考虑直接遍历所有可能的组合。如下图所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 `target` ,若是则返回它们的索引。
考虑直接遍历所有可能的组合。如下图所示,我们开启一个两层循环,在每轮中判断两个整数的和是否为 `target` ,若是则返回它们的索引。
![线性查找求解两数之和](replace_linear_by_hashing.assets/two_sum_brute_force.png)
代码如下所示:
```src
[file]{two_sum}-[class]{}-[func]{two_sum_brute_force}
```
@@ -22,7 +24,7 @@
考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行下图所示的步骤。
1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引。
1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引。
2. 将键值对 `nums[i]` 和索引 `i` 添加进哈希表。
=== "<1>"
@@ -34,12 +36,12 @@
=== "<3>"
![two_sum_hashtable_step3](replace_linear_by_hashing.assets/two_sum_hashtable_step3.png)
实现代码如下所示,仅需单层循环即可
实现代码如下所示,仅需单层循环即可
```src
[file]{two_sum}-[class]{}-[func]{two_sum_hash_table}
```
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降至 $O(n)$ ,大幅提升运行效率。
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降至 $O(n)$ ,大幅提升运行效率。
由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。

View File

@@ -14,7 +14,7 @@
暴力搜索通过遍历数据结构的每个元素来定位目标元素。
- “线性搜索”适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
- “广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。
- “广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索从初始节点开始,沿着一条路径走到头,再回溯并尝试其他路径,直到遍历完整个数据结构。
暴力搜索的优点是简单且通用性好,**无须对数据做预处理和借助额外的数据结构**。
@@ -30,15 +30,15 @@
此类算法的优点是效率高,**时间复杂度可达到 $O(\log n)$ 甚至 $O(1)$** 。
然而,**使用这些算法往往需要对数据进行预处理**。例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开
然而,**使用这些算法往往需要对数据进行预处理**。例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开
!!! note
!!! tip
自适应搜索算法常被称为查找算法,**主要关注在特定数据结构中快速检索目标元素**。
自适应搜索算法常被称为查找算法,**主要用于在特定数据结构中快速检索目标元素**。
## 搜索方法选取
给定大小为 $n$ 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法在该数据中搜索目标元素。各个方法的工作原理如下图所示。
给定大小为 $n$ 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法中搜索目标元素。各个方法的工作原理如下图所示。
![多种搜索策略](searching_algorithm_revisited.assets/searching_algorithms.png)

View File

@@ -1,8 +1,8 @@
# 小结
- 二分查找依赖数据的有序性,通过循环逐步缩减一半搜索区间来实现查找。它要求输入数据有序,且仅适用于数组或基于数组实现的数据结构。
- 暴力搜索通过遍历数据结构来定位数据。线性搜索适用于数组和链表,广度优先搜索和深度优先搜索适用于图和树。此类算法通用性好,无须对数据预处理,但时间复杂度 $O(n)$ 较高。
- 二分查找依赖数据的有序性,通过循环逐步缩减一半搜索区间来进行查找。它要求输入数据有序,且仅适用于数组或基于数组实现的数据结构。
- 暴力搜索通过遍历数据结构来定位数据。线性搜索适用于数组和链表,广度优先搜索和深度优先搜索适用于图和树。此类算法通用性好,无须对数据进行预处理,但时间复杂度 $O(n)$ 较高。
- 哈希查找、树查找和二分查找属于高效搜索方法,可在特定数据结构中快速定位目标元素。此类算法效率高,时间复杂度可达 $O(\log n)$ 甚至 $O(1)$ ,但通常需要借助额外数据结构。
- 实际中,我们需要对数据体量、搜索性能要求、数据查询和更新频率等因素进行具体分析,从而选择合适的搜索方法。
- 线性搜索适用于小型或频繁更新的数据;二分查找适用于大型、排序的数据;哈希查找适对查询效率要求较高且无须范围查询的数据;树查找适用于需要维护顺序和支持范围查询的大型动态数据。
- 用哈希查找替换线性查找是一种常用的优化运行时间的策略,可将时间复杂度从 $O(n)$ 降至 $O(1)$ 。
- 线性搜索适用于小型或频繁更新的数据;二分查找适用于大型、排序的数据;哈希查找适用于对查询效率要求较高且无须范围查询的数据;树查找适用于需要维护顺序和支持范围查询的大型动态数据。
- 用哈希查找替换线性查找是一种常用的优化运行时间的策略,可将时间复杂度从 $O(n)$ 降至 $O(1)$ 。

View File

@@ -2,7 +2,7 @@
「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样因此得名冒泡排序。
如下图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。
如下图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。
=== "<1>"
![利用元素交换操作模拟冒泡](bubble_sort.assets/bubble_operation_step1.png)
@@ -36,6 +36,8 @@
![冒泡排序流程](bubble_sort.assets/bubble_sort_overview.png)
示例代码如下:
```src
[file]{bubble_sort}-[class]{}-[func]{bubble_sort}
```
@@ -44,7 +46,7 @@
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
经过优化,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;但当输入数组完全有序时,可达到最佳时间复杂度 $O(n)$ 。
经过优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为 $O(n^2)$ ;但当输入数组完全有序时,可达到最佳时间复杂度 $O(n)$ 。
```src
[file]{bubble_sort}-[class]{}-[func]{bubble_sort_with_flag}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,19 +1,21 @@
# 桶排序
前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
「桶排序 bucket sort」是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶每个桶对应一个数据范围将数据平均分配到各个桶中然后在每个桶内部分别执行排序最终按照桶的顺序将所有数据合并。
## 算法流程
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下图所示。
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下图所示。
1. 初始化 $k$ 个桶,将 $n$ 个元素分配到 $k$ 个桶中。
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)。
3. 按照桶从小到大的顺序合并结果。
2. 对每个桶分别执行排序(这里采用编程语言的内置排序函数)。
3. 按照桶从小到大的顺序合并结果。
![桶排序算法流程](bucket_sort.assets/bucket_sort_overview.png)
代码如下所示:
```src
[file]{bucket_sort}-[class]{}-[func]{bucket_sort}
```
@@ -23,17 +25,17 @@
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
- **时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历所有桶和元素,花费 $O(n + k)$ 时间。
- **自适应排序**:在最情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。
- **自适应排序**:在最情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。
- **空间复杂度 $O(n + k)$、非原地排序**:需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。
- 桶排序是否稳定取决于排序桶内元素的算法是否稳定。
## 如何实现平均分配
桶排序的时间复杂度理论上可以达到 $O(n)$ **关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 ,各个桶中的商品数量差距会非常大。
桶排序的时间复杂度理论上可以达到 $O(n)$ **关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 ,各个桶中的商品数量差距会非常大。
为实现平均分配,我们可以先设定一大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。
为实现平均分配,我们可以先设定一大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。
如下图所示,这种方法本质上是创建一递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
如下图所示,这种方法本质上是创建一递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
![递归划分桶](bucket_sort.assets/scatter_in_buckets_recursively.png)

View File

@@ -6,23 +6,25 @@
先来看一个简单的例子。给定一个长度为 $n$ 的数组 `nums` ,其中的元素都是“非负整数”,计数排序的整体流程如下图所示。
1. 遍历数组,找出数组中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter`
1. 遍历数组,找出中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter`
2. **借助 `counter` 统计 `nums` 中各数字的出现次数**,其中 `counter[num]` 对应数字 `num` 的出现次数。统计方法很简单,只需遍历 `nums`(设当前数字为 `num`),每轮将 `counter[num]` 增加 $1$ 即可。
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经排序好了**。接下来,我们遍历 `counter` ,根据各数字出现次数,将它们按从小到大的顺序填入 `nums` 即可。
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经排序好了**。接下来,我们遍历 `counter` ,根据各数字出现次数从小到大的顺序填入 `nums` 即可。
![计数排序流程](counting_sort.assets/counting_sort_overview.png)
代码如下所示:
```src
[file]{counting_sort}-[class]{}-[func]{counting_sort_naive}
```
!!! note "计数排序与桶排序的联系"
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
## 完整实现
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。假设输入数据是商品对象,我们想按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
细心的读者可能发现**如果输入数据是对象,上述步骤 `3.` 就失效了**。假设输入数据是商品对象,我们想按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的“前缀和”。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和:
@@ -61,7 +63,7 @@ $$
=== "<8>"
![counting_sort_step8](counting_sort.assets/counting_sort_step8.png)
计数排序的实现代码如下所示
计数排序的实现代码如下所示
```src
[file]{counting_sort}-[class]{}-[func]{counting_sort}
@@ -75,8 +77,8 @@ $$
## 局限性
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格。
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序。然而,使用计数排序的前置条件相对较为严格。
**计数排序只适用于非负整数**。若想将其用于其他类型的数据,需要确保这些数据可以转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可
**计数排序只适用于非负整数**。若想将其用于其他类型的数据,需要确保这些数据可以转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去。
**计数排序适用于数据量大但数据范围较小的情况**。比如,在上述示例中 $m$ 不能太大,否则会占用过多空间。而当 $n \ll m$ 时,计数排序使用 $O(m)$ 时间,可能比 $O(n \log n)$ 的排序算法还要慢。

View File

@@ -18,11 +18,11 @@
1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 $1$ ,已排序元素数量加 $1$ 。
3. 从堆顶元素开始从顶到底执行堆化操作Sift Down。完成堆化后堆的性质得到修复。
4. 循环执行第 `2.` `3.` 步。循环 $n - 1$ 轮后,即可完成数组排序。
4. 循环执行第 `2.` 步和第 `3.` 步。循环 $n - 1$ 轮后,即可完成数组排序。
!!! tip
实际上,元素出堆操作中也包含第 `2.` `3.` 步,只是多了一个弹出元素的步骤。
实际上,元素出堆操作中也包含第 `2.` 步和第 `3.` 步,只是多了一个弹出元素的步骤。
=== "<1>"
![堆排序步骤](heap_sort.assets/heap_sort_step1.png)
@@ -60,7 +60,7 @@
=== "<12>"
![heap_sort_step12](heap_sort.assets/heap_sort_step12.png)
在代码实现中,我们使用了与章节相同的从顶至底堆化 `sift_down()` 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 `sift_down()` 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。
在代码实现中,我们使用了与“堆”章节相同的从顶至底堆化 `sift_down()` 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 `sift_down()` 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。代码如下所示:
```src
[file]{heap_sort}-[class]{}-[func]{heap_sort}

View File

@@ -4,7 +4,7 @@
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
下图展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后`base` 赋值给目标索引。
下图展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后将 `base` 赋值给目标索引。
![单次插入操作](insertion_sort.assets/insertion_operation.png)
@@ -19,23 +19,25 @@
![插入排序流程](insertion_sort.assets/insertion_sort_overview.png)
示例代码如下:
```src
[file]{insertion_sort}-[class]{}-[func]{insertion_sort}
```
## 算法特性
- **时间复杂度 $O(n^2)$、自适应排序**:最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
- **时间复杂度 $O(n^2)$、自适应排序**最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
- **稳定排序**:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
## 插入排序优势
插入排序的时间复杂度为 $O(n^2)$ ,而我们即将学习的快速排序的时间复杂度为 $O(n \log n)$ 。尽管插入排序的时间复杂度相比快速排序更高,**但在数据量较小的情况下,插入排序通常更快**。
插入排序的时间复杂度为 $O(n^2)$ ,而我们即将学习的快速排序的时间复杂度为 $O(n \log n)$ 。尽管插入排序的时间复杂度更高,**但在数据量较小的情况下,插入排序通常更快**。
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 $O(n \log n)$ 的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时,$n^2$ 和 $n \log n$ 的数值比较接近,复杂度不占主导作用;每轮中的单元操作数量起到决定性因素
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 $O(n \log n)$ 的算法属于基于分治策略的排序算法,往往包含更多单元计算操作。而在数据量较小时,$n^2$ 和 $n \log n$ 的数值比较接近,复杂度不占主导地位;每轮中的单元操作数量起到决定性作用
实际上,许多编程语言(例如 Java的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
实际上,许多编程语言(例如 Java的内置排序函数采用了插入排序大致思路为对于长数组采用基于分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 $O(n^2)$ ,但在实际情况中,**插入排序的使用频率显著高于冒泡排序和选择排序**,主要有以下原因。

View File

@@ -12,7 +12,7 @@
如下图所示,“划分阶段”从顶至底递归地将数组从中点切分为两个子数组。
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]` )。
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时终止递归划分
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时终止。
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
@@ -51,23 +51,23 @@
- **后序遍历**:先递归左子树,再递归右子树,最后处理根节点。
- **归并排序**:先递归左子数组,再递归右子数组,最后处理合并。
归并排序的实现如以下代码所示。请注意,`nums` 的待合并区间为 `[left, right]` ,而 `tmp` 的对应区间为 `[0, right - left]`
```src
[file]{merge_sort}-[class]{}-[func]{merge_sort}
```
值得注意的是,`nums` 的待合并区间为 `[left, right]` ,而 `tmp` 的对应区间为 `[0, right - left]`
## 算法特性
- **时间复杂度 $O(n \log n)$、非自适应排序**:划分产生高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,因此总体时间复杂度为 $O(n \log n)$ 。
- **空间复杂度 $O(n)$、非原地排序**:递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 $O(n)$ 大小的额外空间。
- **稳定排序**:在合并过程中,相等元素的次序保持不变。
## 链表排序 *
## 链表排序
对于链表,归并排序相较于其他排序算法具有显著优势,**可以将链表排序任务的空间复杂度优化至 $O(1)$** 。
- **划分阶段**:可以通过使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
- **划分阶段**:可以使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
- **合并阶段**:在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建额外链表。
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。
具体实现细节比较复杂,有兴趣的读者可以查阅相关资料进行学习。

View File

@@ -61,17 +61,17 @@
## 算法特性
- **时间复杂度 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ ,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
- **时间复杂度 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ ,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
- **空间复杂度 $O(n)$、原地排序**:在输入数组完全倒序的情况下,达到最差递归深度 $n$ ,使用 $O(n)$ 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
- **非稳定排序**:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
## 快为什么快
## 快速排序为什么快
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行。
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
- **复杂度的常数系数**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
- **复杂度的常数系数**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
## 基准数优化
@@ -83,6 +83,8 @@
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),**并将这三个候选元素的中位数作为基准数**。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 $O(n^2)$ 的概率大大降低。
示例代码如下:
```src
[file]{quick_sort}-[class]{quick_sort_median}-[func]{partition}
```
@@ -91,7 +93,7 @@
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,设递归中的子数组长度为 $m$ ,每轮哨兵划分操作都将产生长度为 $0$ 的左子数组和长度为 $m - 1$ 的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $n / 2$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $n / 2$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。代码如下所示:
```src
[file]{quick_sort}-[class]{quick_sort_tail_call}-[func]{quick_sort}

View File

@@ -1,6 +1,6 @@
# 基数排序
上一节我们介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
上一节介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
「基数排序 radix sort」的核心思想与计数排序一致也通过统计个数来实现排序。在此基础上基数排序利用数字各位之间的递进关系依次对每一位进行排序从而得到最终的排序结果。
@@ -14,7 +14,7 @@
![基数排序算法流程](radix_sort.assets/radix_sort_overview.png)
下面剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
下面剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
$$
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d
@@ -22,7 +22,7 @@ $$
其中 $\lfloor a \rfloor$ 表示对浮点数 $a$ 向下取整,而 $\bmod \: d$ 表示对 $d$ 取余。对于学号数据,$d = 10$ 且 $k \in [1, 8]$ 。
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序
```src
[file]{radix_sort}-[class]{}-[func]{radix_sort}
@@ -30,7 +30,7 @@ $$
!!! question "为什么从最低位开始排序?"
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,因此应该先排序低位再排序高位。
## 算法特性

View File

@@ -1,12 +1,12 @@
# 选择排序
「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
「选择排序 selection sort」的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
设数组的长度为 $n$ ,选择排序的算法流程如下图所示。
1. 初始状态下,所有元素未排序,即未排序(索引)区间为 $[0, n-1]$ 。
2. 选取区间 $[0, n-1]$ 中的最小元素,将其与索引 $0$ 处元素交换。完成后,数组前 1 个元素已排序。
3. 选取区间 $[1, n-1]$ 中的最小元素,将其与索引 $1$ 处元素交换。完成后,数组前 2 个元素已排序。
2. 选取区间 $[0, n-1]$ 中的最小元素,将其与索引 $0$ 处元素交换。完成后,数组前 1 个元素已排序。
3. 选取区间 $[1, n-1]$ 中的最小元素,将其与索引 $1$ 处元素交换。完成后,数组前 2 个元素已排序。
4. 以此类推。经过 $n - 1$ 轮选择与交换后,数组前 $n - 1$ 个元素已排序。
5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
@@ -43,7 +43,7 @@
=== "<11>"
![selection_sort_step11](selection_sort.assets/selection_sort_step11.png)
在代码中,我们用 $k$ 来记录未排序区间内的最小元素
在代码中,我们用 $k$ 来记录未排序区间内的最小元素
```src
[file]{selection_sort}-[class]{}-[func]{selection_sort}
@@ -53,6 +53,6 @@
- **时间复杂度为 $O(n^2)$、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$、$n - 1$、$\dots$、$3$、$2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
- **非稳定排序**:如下图所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
- **非稳定排序**:如下图所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
![选择排序非稳定示例](selection_sort.assets/selection_sort_instability.png)

View File

@@ -1,6 +1,6 @@
# 排序算法
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用因为有序数据通常能够被更效地查找、分析和处理。
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用因为有序数据通常能够被更效地查找、分析和处理。
如下图所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
@@ -8,13 +8,13 @@
## 评价维度
**运行效率**:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。
**运行效率**:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对于大数据量情况,运行效率显得尤为重要。
**就地性**:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失
```shell
# 输入数据是按照姓名排序好的
@@ -35,11 +35,11 @@
('E', 23)
```
**自适应性**:「自适应排序」的时间复杂度会受输入数据的影响,即最佳、最差、平均时间复杂度并不完全相等。
**自适应性**:「自适应排序」的时间复杂度会受输入数据的影响,即最佳时间复杂度、最差时间复杂度、平均时间复杂度并不完全相等。
自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
**是否基于比较**:「基于比较的排序」依赖比较运算符($<$、$=$、$>$)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。而「非比较排序」不使用比较运算符,时间复杂度可达 $O(n)$ ,但其通用性相对较差。
**是否基于比较**:「基于比较的排序」依赖比较运算符($<$、$=$、$>$)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。而「非比较排序」不使用比较运算符,时间复杂度可达 $O(n)$ ,但其通用性相对较差。
## 理想排序算法

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -3,7 +3,7 @@
### 重点回顾
- 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 $O(n)$ 。
- 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,在小数据量的排序任务中非常受欢迎。
- 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,因此在小数据量的排序任务中非常受欢迎。
- 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 $O(n^2)$ 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到 $O(\log n)$ 。
- 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 $O(n)$ ;然而排序链表的空间复杂度可以优化至 $O(1)$ 。
- 桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。
@@ -16,11 +16,11 @@
### Q & A
!!! question "排序算法稳定性在什么情况下是必的?"
!!! question "排序算法稳定性在什么情况下是必的?"
在现实中,我们有可能是对象的某个属性进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序/
在现实中,我们有可能是基于对象的某个属性进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序
先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` 接下来对身高进行排序。由于排序算法不稳定,我们可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)`
先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` 对身高进行排序。由于排序算法不稳定,因此可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)`
可以发现,学生 D 和 C 的位置发生了交换,姓名的有序性被破坏了,而这是我们不希望看到的。
@@ -36,13 +36,13 @@
!!! question "关于尾递归优化,为什么选短的数组能保证递归深度不超过 $\log n$ "
递归深度就是当前未返回的递归方法的数量。每轮哨兵划分我们将原数组划分为两个子数组。在尾递归优化后,向下递归的子数组长度最大为原数组的一半长度。假设最差情况,一直为一半长度,那么最终的递归深度就是 $\log n$ 。
递归深度就是当前未返回的递归方法的数量。每轮哨兵划分我们将原数组划分为两个子数组。在尾递归优化后,向下递归的子数组长度最大为原数组长度的一半。假设最差情况,一直为一半长度,那么最终的递归深度就是 $\log n$ 。
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为 $n$、$n - 1$、$\dots$、$2$、$1$ ,递归深度为 $n$ 。尾递归优化可以避免这种情况出现。
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为 $n$、$n - 1$、$\dots$、$2$、$1$ ,递归深度为 $n$ 。尾递归优化可以避免这种情况出现。
!!! question "当数组中所有元素都相等时,快速排序的时间复杂度是 $O(n^2)$ 吗?该如何处理这种退化情况?"
是的。这种情况可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。仅向下递归小于和大于的两部分。在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可完成排序。
是的。对于这种情况可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。仅向下递归小于和大于的两部分。在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可完成排序。
!!! question "桶排序的最差时间复杂度为什么是 $O(n^2)$ "

View File

@@ -1,6 +1,6 @@
# 双向队列
在队列中,我们仅能在头部删除或在尾部添加元素。如下图所示,「双向队列 double-ended queue」提供了更高的灵活性允许在头部和尾部执行元素的添加或删除操作。
在队列中,我们仅能删除头部元素或在尾部添加元素。如下图所示,「双向队列 double-ended queue」提供了更高的灵活性允许在头部和尾部执行元素的添加或删除操作。
![双向队列的操作](deque.assets/deque_operations.png)
@@ -19,13 +19,15 @@
| peekFirst() | 访问队首元素 | $O(1)$ |
| peekLast() | 访问队尾元素 | $O(1)$ |
同样地,我们可以直接使用编程语言中已实现的双向队列类
同样地,我们可以直接使用编程语言中已实现的双向队列类
=== "Python"
```python title="deque.py"
from collections import deque
# 初始化双向队列
deque: deque[int] = collections.deque()
deque: deque[int] = deque()
# 元素入队
deque.append(2) # 添加至队尾
@@ -369,7 +371,7 @@
=== "popFirst()"
![linkedlist_deque_pop_first](deque.assets/linkedlist_deque_pop_first.png)
实现代码如下所示
实现代码如下所示
```src
[file]{linkedlist_deque}-[class]{linked_list_deque}-[func]{}
@@ -394,7 +396,7 @@
=== "popFirst()"
![array_deque_pop_first](deque.assets/array_deque_pop_first.png)
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法
```src
[file]{array_deque}-[class]{array_deque}-[func]{}
@@ -404,4 +406,4 @@
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 $50$ 步)。当栈的长度超过 $50$ 时,软件需要在栈底(队首)执行删除操作。**但栈无法实现该功能,此时就需要使用双向队列来替代栈**。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 $50$ 步)。当栈的长度超过 $50$ 时,软件需要在栈底(队首)执行删除操作。**但栈无法实现该功能,此时就需要使用双向队列来替代栈**。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。

View File

@@ -10,4 +10,4 @@
栈如同叠猫猫,而队列就像猫猫排队。
两者分别代表先入后出和先入先出的逻辑关系。
两者分别代表先入后出和先入先出的逻辑关系。

View File

@@ -1,8 +1,8 @@
# 队列
「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义队列模拟了排队现象即新来的人不断加入队列尾部,而位于队列头部的人逐个离开。
「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义队列模拟了排队现象即新来的人不断加入队列尾部而位于队列头部的人逐个离开。
如下图所示,我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
如下图所示,我们将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
![队列的先入先出规则](queue.assets/queue_operations.png)
@@ -18,15 +18,17 @@
| pop() | 队首元素出队 | $O(1)$ |
| peek() | 访问队首元素 | $O(1)$ |
我们可以直接使用编程语言中现成的队列类
我们可以直接使用编程语言中现成的队列类
=== "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)
@@ -308,7 +310,7 @@
## 队列实现
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。链表和数组都符合要求
### 基于链表的实现
@@ -323,7 +325,7 @@
=== "pop()"
![linkedlist_queue_pop](queue.assets/linkedlist_queue_pop.png)
以下是用链表实现队列的代码
以下是用链表实现队列的代码
```src
[file]{linkedlist_queue}-[class]{linked_list_queue}-[func]{}
@@ -331,7 +333,7 @@
### 基于数组的实现
由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
我们可以使用一个变量 `front` 指向队首元素的索引,并维护一个变量 `size` 用于记录队列长度。定义 `rear = front + size` ,这个公式计算出的 `rear` 指向队尾元素之后的下一个位置。
@@ -351,19 +353,19 @@
=== "pop()"
![array_queue_pop](queue.assets/array_queue_pop.png)
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。
对于环形数组,我们需要让 `front` 或 `rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示
对于环形数组,我们需要让 `front` 或 `rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示
```src
[file]{array_queue}-[class]{array_queue}-[func]{}
```
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
以上实现的队列仍然具有局限性其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的读者可以尝试自行实现。
两种实现的对比结论与栈一致,在此不再赘述。
## 队列典型应用
- **淘宝订单**。购物者下单后,订单将加入队列中,系统随后会根据顺序依次处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
- **各类待办事项**。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等队列在这些场景中可以有效地维护处理顺序。
- **淘宝订单**。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
- **各类待办事项**。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等队列在这些场景中可以有效地维护处理顺序。

View File

@@ -2,9 +2,9 @@
「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。
如下图所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫“入栈”,删除栈顶元素的操作叫“出栈”。
如下图所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫“入栈”,删除栈顶元素的操作叫“出栈”。
![栈的先入后出规则](stack.assets/stack_operations.png)
@@ -20,7 +20,7 @@
| pop() | 栈顶元素出栈 | $O(1)$ |
| peek() | 访问栈顶元素 | $O(1)$ |
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”作栈来使用,并在程序逻辑上忽略与栈无关的操作。
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”作栈来使用,并在程序逻辑上忽略与栈无关的操作。
=== "Python"
@@ -306,11 +306,11 @@
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。
### 基于链表的实现
使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
如下图所示,对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
@@ -323,7 +323,7 @@
=== "pop()"
![linkedlist_stack_pop](stack.assets/linkedlist_stack_pop.png)
以下是基于链表实现栈的示例代码
以下是基于链表实现栈的示例代码
```src
[file]{linkedlist_stack}-[class]{linked_list_stack}-[func]{}
@@ -342,7 +342,7 @@
=== "pop()"
![array_stack_pop](stack.assets/array_stack_pop.png)
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码
```src
[file]{array_stack}-[class]{array_stack}-[func]{}
@@ -356,9 +356,9 @@
**时间效率**
在基于数组的实现中,入栈和出栈操作都在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。
在基于数组的实现中,入栈和出栈操作都在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。
在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
基于链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
综上所述,当入栈与出栈操作的元素是基本数据类型时,例如 `int` 或 `double` ,我们可以得出以下结论。
@@ -367,7 +367,7 @@
**空间效率**
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超实际需求并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超实际需求并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。
然而,由于链表节点需要额外存储指针,**因此链表节点占用的空间相对较大**。
@@ -375,5 +375,5 @@
## 栈典型应用
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会上一个网页执行入栈,这样我们就可以通过后退操作回到上一页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。

View File

@@ -3,7 +3,7 @@
### 重点回顾
- 栈是一种遵循先入后出原则的数据结构,可通过数组或链表来实现。
- 时间效率角度看,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会劣化至 $O(n)$ 。相比之下,基于链表实现的栈具有更为稳定的效率表现。
- 时间效率方面,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会劣化至 $O(n)$ 。相比之下,栈的链表实现具有更为稳定的效率表现。
- 在空间效率方面,栈的数组实现可能导致一定程度的空间浪费。但需要注意的是,链表节点所占用的内存空间比数组元素更大。
- 队列是一种遵循先入先出原则的数据结构,同样可以通过数组或链表来实现。在时间效率和空间效率的对比上,队列的结论与前述栈的结论相似。
- 双向队列是一种具有更高自由度的队列,它允许在两端进行元素的添加和删除操作。
@@ -12,7 +12,7 @@
!!! question "浏览器的前进后退是否是双向链表实现?"
浏览器的前进后退功能本质上是“栈”的体现。当用户访问一个新页面时,该页面会被添加到栈顶;当用户点击后退按钮时,该页面会从栈顶弹出。使用双向队列可以方便实现一些额外操作,这个在双向队列章节有提到。
浏览器的前进后退功能本质上是“栈”的体现。当用户访问一个新页面时,该页面会被添加到栈顶;当用户点击后退按钮时,该页面会从栈顶弹出。使用双向队列可以方便实现一些额外操作,这个在双向队列章节有提到。
!!! question "在出栈后,是否需要释放出栈节点的内存?"
@@ -20,7 +20,7 @@
!!! question "双向队列像是两个栈拼接在了一起,它的用途是什么?"
双向队列就像是栈和队列的组合,或者是两个栈拼在了一起。它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。
双向队列就像是栈和队列的组合,或两个栈拼在了一起。它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。
!!! question "撤销undo和反撤销redo具体是如何实现的"

View File

@@ -1,14 +1,14 @@
# 二叉树数组表示
在链表表示下,二叉树的存储单元为节点 `TreeNode` ,节点之间通过指针相连接。在上节中,我们学习了在链表表示下的二叉树的各项基本操作。
在链表表示下,二叉树的存储单元为节点 `TreeNode` ,节点之间通过指针相连接。上一节介绍了链表表示下的二叉树的各项基本操作。
那么,我们能否用数组来表示二叉树呢?答案是肯定的。
## 表示完美二叉树
先分析一个简单案例。给定一完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。
先分析一个简单案例。给定一完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。下图展示了各个节点索引之间的映射关系。
根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。下图展示了各个节点索引之间的映射关系。
![完美二叉树的数组表示](array_representation_of_tree.assets/array_representation_binary_tree.png)
@@ -18,11 +18,11 @@
完美二叉树是一个特例,在二叉树的中间层通常存在许多 $\text{None}$ 。由于层序遍历序列并不包含这些 $\text{None}$ ,因此我们无法仅凭该序列来推测 $\text{None}$ 的数量和分布位置。**这意味着存在多种二叉树结构都符合该层序遍历序列**。
如下图所示,给定一非完美二叉树,上述数组表示方法已经失效。
如下图所示,给定一非完美二叉树,上述数组表示方法已经失效。
![层序遍历序列对应多种二叉树可能性](array_representation_of_tree.assets/array_representation_without_empty.png)
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。示例代码如下:
=== "Python"
@@ -126,7 +126,7 @@
![完全二叉树的数组表示](array_representation_of_tree.assets/array_representation_complete_binary_tree.png)
以下代码实现了一基于数组表示的二叉树,包括以下几种操作。
以下代码实现了一基于数组表示的二叉树,包括以下几种操作。
- 给定某节点,获取它的值、左(右)子节点、父节点。
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列。
@@ -135,7 +135,7 @@
[file]{array_binary_tree}-[class]{array_binary_tree}-[func]{}
```
## 优与局限性
## 优与局限性
二叉树的数组表示主要有以下优点。

View File

@@ -1,16 +1,16 @@
# AVL 树 *
在二叉搜索树章节中,我们提到在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$ 。
二叉搜索树章节中,我们提到在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$ 。
如下图所示,经过两次删除节点操作,这二叉搜索树便会退化为链表。
如下图所示,经过两次删除节点操作,这二叉搜索树便会退化为链表。
![AVL 树在删除节点后发生退化](avl_tree.assets/avltree_degradation_from_removing_node.png)
再例如,在下图的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
再例如,在下图所示的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
![AVL 树在插入节点后发生退化](avl_tree.assets/avltree_degradation_from_inserting_node.png)
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 树能始终保持高效的数据操作性能,具有很好的应用价值。
## AVL 树常见术语
@@ -18,7 +18,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉
### 节点高度
由于 AVL 树的相关操作需要获取节点高度,因此我们需要为节点类添加 `height` 变量
由于 AVL 树的相关操作需要获取节点高度,因此我们需要为节点类添加 `height` 变量
=== "Python"
@@ -206,7 +206,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉
```
“节点高度”是指从该节点到其最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度
“节点高度”是指从该节点到其最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 $0$ ,而空节点的高度为 $-1$ 。我们将创建两个工具函数,分别用于获取和更新节点的高度
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{update_height}
@@ -214,7 +214,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉
### 节点平衡因子
节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用
节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 $0$ 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{balance_factor}
@@ -228,11 +228,11 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉
AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”**。
我们将平衡因子绝对值 $> 1$ 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。
我们将平衡因子绝对值 $> 1$ 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面详细介绍这些旋转操作。
### 右旋
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的性。
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树恢复平衡,并且仍然保持二叉搜索树的性
=== "<1>"
![右旋操作步骤](avl_tree.assets/avltree_right_rotate_step1.png)
@@ -250,7 +250,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
![有 grandChild 的右旋操作](avl_tree.assets/avltree_right_rotate_with_grandchild.png)
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{right_rotate}
@@ -258,7 +258,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
### 左旋
相应,如果考虑上述失衡二叉树的“镜像”,则需要执行下图所示的“左旋”操作。
相应,如果考虑上述失衡二叉树的“镜像”,则需要执行下图所示的“左旋”操作。
![左旋操作](avl_tree.assets/avltree_left_rotate.png)
@@ -266,7 +266,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
![有 grandChild 的左旋操作](avl_tree.assets/avltree_left_rotate_with_grandchild.png)
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{left_rotate}
@@ -280,13 +280,13 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
### 先右旋后左旋
如下图所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,然后对 `node` 执行“左旋”。
如下图所示,对于上述失衡二叉树的镜像情况,需要先对 `child` 执行“右旋”,对 `node` 执行“左旋”。
![先右旋后左旋](avl_tree.assets/avltree_right_left_rotate.png)
### 旋转的选择
下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。
下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋后右旋、先右后左旋、左旋的操作。
![AVL 树的四种旋转情况](avl_tree.assets/avltree_rotation_cases.png)
@@ -296,12 +296,12 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
| ------------------- | ---------------- | ---------------- |
| $> 1$ 左偏树) | $\geq 0$ | 右旋 |
| $> 1$ 左偏树) | $<0$ | 先左旋后右旋 |
| $< -1$ 右偏树) | $\leq 0$ | 左旋 |
| $< -1$ 右偏树) | $>0$ | 先右旋后左旋 |
| $> 1$ (左偏树) | $\geq 0$ | 右旋 |
| $> 1$ (左偏树) | $<0$ | 先左旋后右旋 |
| $< -1$ (右偏树) | $\leq 0$ | 左旋 |
| $< -1$ (右偏树) | $>0$ | 先右旋后左旋 |
为了便于使用,我们将旋转操作封装成一个函数。**有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡**。
为了便于使用,我们将旋转操作封装成一个函数。**有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡**。代码如下所示:
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{rotate}
@@ -311,7 +311,7 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
### 插入节点
AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。
AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。代码如下所示:
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{insert_helper}
@@ -319,7 +319,7 @@ AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区
### 删除节点
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶执行旋转操作,使所有失衡节点恢复平衡。
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶执行旋转操作,使所有失衡节点恢复平衡。代码如下所示:
```src
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{remove_helper}

View File

@@ -31,7 +31,7 @@
=== "<4>"
![bst_search_step4](binary_search_tree.assets/bst_search_step4.png)
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。示例代码如下:
```src
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{search}
@@ -59,11 +59,11 @@
### 删除节点
先在二叉树中查找到目标节点,再将其从二叉树中删除。
先在二叉树中查找到目标节点,再将其删除。
与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。
因此,我们需要根据目标节点的子节点数量,共分为 0、1 和 2 三种情况,执行对应的删除节点操作。
因此,我们根据目标节点的子节点数量, 0、1 和 2 三种情况,执行对应的删除节点操作。
如下图所示,当待删除节点的度为 $0$ 时,表示该节点是叶节点,可以直接删除。
@@ -73,12 +73,12 @@
![在二叉搜索树中删除节点(度为 1 ](binary_search_tree.assets/bst_remove_case2.png)
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左子树 $<$ 根节点 $<$ 右子树”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。
假设我们选择右子树的最小节点(中序遍历的下一个节点),则删除操作流程如下图所示。
假设我们选择右子树的最小节点(中序遍历的下一个节点),则删除操作流程如下图所示。
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp`
2. `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp`
2. `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp`
=== "<1>"
![在二叉搜索树中删除节点(度为 2 ](binary_search_tree.assets/bst_remove_case3_step1.png)
@@ -92,7 +92,7 @@
=== "<4>"
![bst_remove_case3_step4](binary_search_tree.assets/bst_remove_case3_step4.png)
删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。
删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。示例代码如下:
```src
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{remove}
@@ -110,7 +110,7 @@
## 二叉搜索树的效率
给定一组数据,我们考虑使用数组或二叉搜索树存储。观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除数据适用场景下,数组比二叉搜索树的效率更高。
给定一组数据,我们考虑使用数组或二叉搜索树存储。观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能。只有在高频添加、低频查找删除数据场景下,数组比二叉搜索树的效率更高。
<p align="center"> 表 <id> &nbsp; 数组与搜索树的效率对比 </p>
@@ -124,7 +124,7 @@
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
![二叉搜索树退化](binary_search_tree.assets/bst_degradation.png)
![二叉搜索树退化](binary_search_tree.assets/bst_degradation.png)
## 二叉搜索树常见应用

View File

@@ -1,6 +1,6 @@
# 二叉树
「二叉树 binary tree」是一种非线性数据结构代表祖先后代之间的派生关系,体现“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用右子节点引用。
「二叉树 binary tree」是一种非线性数据结构代表祖先”与“后代之间的派生关系,体现“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用右子节点引用。
=== "Python"
@@ -205,7 +205,7 @@
!!! tip
请注意,我们通常将“高度”和“深度”定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。
请注意,我们通常将“高度”和“深度”定义为“经过的边的数量”,但有些题目或教材可能会将其定义为“经过的节点的数量”。在这种情况下,高度和深度都需要加 1 。
## 二叉树基本操作
@@ -223,7 +223,7 @@
n3 = TreeNode(val=3)
n4 = TreeNode(val=4)
n5 = TreeNode(val=5)
# 构建引用指向(即指针)
# 构建节点之间的引用(指针)
n1.left = n2
n1.right = n3
n2.left = n4
@@ -240,7 +240,7 @@
TreeNode* n3 = new TreeNode(3);
TreeNode* n4 = new TreeNode(4);
TreeNode* n5 = new TreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1->left = n2;
n1->right = n3;
n2->left = n4;
@@ -256,7 +256,7 @@
TreeNode n3 = new TreeNode(3);
TreeNode n4 = new TreeNode(4);
TreeNode n5 = new TreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2;
n1.right = n3;
n2.left = n4;
@@ -273,7 +273,7 @@
TreeNode n3 = new(3);
TreeNode n4 = new(4);
TreeNode n5 = new(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2;
n1.right = n3;
n2.left = n4;
@@ -290,7 +290,7 @@
n3 := NewTreeNode(3)
n4 := NewTreeNode(4)
n5 := NewTreeNode(5)
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.Left = n2
n1.Right = n3
n2.Left = n4
@@ -306,7 +306,7 @@
let n3 = TreeNode(x: 3)
let n4 = TreeNode(x: 4)
let n5 = TreeNode(x: 5)
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2
n1.right = n3
n2.left = n4
@@ -323,7 +323,7 @@
n3 = new TreeNode(3),
n4 = new TreeNode(4),
n5 = new TreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2;
n1.right = n3;
n2.left = n4;
@@ -340,7 +340,7 @@
n3 = new TreeNode(3),
n4 = new TreeNode(4),
n5 = new TreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2;
n1.right = n3;
n2.left = n4;
@@ -357,7 +357,7 @@
TreeNode n3 = new TreeNode(3);
TreeNode n4 = new TreeNode(4);
TreeNode n5 = new TreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1.left = n2;
n1.right = n3;
n2.left = n4;
@@ -373,7 +373,7 @@
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);
@@ -390,7 +390,7 @@
TreeNode *n3 = newTreeNode(3);
TreeNode *n4 = newTreeNode(4);
TreeNode *n5 = newTreeNode(5);
// 构建引用指向(即指针)
// 构建节点之间的引用(指针)
n1->left = n2;
n1->right = n3;
n2->left = n4;
@@ -546,13 +546,13 @@
!!! note
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除通常是由一套操作配合完成的,以实现有实际意义的操作。
## 常见二叉树类型
### 完美二叉树
「完美二叉树 perfect binary tree」所有层的节点都被完全填满。在完美二叉树中叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
如下图所示,「完美二叉树 perfect binary tree」所有层的节点都被完全填满。在完美二叉树中叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
!!! tip
@@ -580,20 +580,20 @@
## 二叉树的退化
下图展示了二叉树的理想与退化状态。当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。
下图展示了二叉树的理想结构与退化结构。当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ 。
![二叉树的最佳与最差结构](binary_tree.assets/binary_tree_best_worst_cases.png)
![二叉树的最佳结构与最差结构](binary_tree.assets/binary_tree_best_worst_cases.png)
如下表所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
如下表所示,在最佳结构和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
<p align="center"> 表 <id> &nbsp; 二叉树的最佳与最差情况 </p>
<p align="center"> 表 <id> &nbsp; 二叉树的最佳结构与最差结构 </p>
| | 完美二叉树 | 链表 |
| ----------------------- | ------------------ | ------- |
| 第 $i$ 层的节点数量 | $2^{i-1}$ | $1$ |
| 高度 $h$ 树的叶节点数量 | $2^h$ | $1$ |
| 高度 $h$ 树的节点总数 | $2^{h+1} - 1$ | $h + 1$ |
| 节点总数 $n$ 树的高度 | $\log_2 (n+1) - 1$ | $n - 1$ |
| | 完美二叉树 | 链表 |
| --------------------------- | ------------------ | ------- |
| 第 $i$ 层的节点数量 | $2^{i-1}$ | $1$ |
| 高度 $h$ 树的叶节点数量 | $2^h$ | $1$ |
| 高度 $h$ 树的节点总数 | $2^{h+1} - 1$ | $h + 1$ |
| 节点总数 $n$ 树的高度 | $\log_2 (n+1) - 1$ | $n - 1$ |

Some files were not shown because too many files have changed in this diff Show More