Unify punctuation.

pull/655/head
krahets 1 year ago
parent 35973068a7
commit 63a0e73df0

@ -48,9 +48,9 @@
我们正在加速更新本书,欢迎您通过提交 Pull Request 来[参与本项目](https://www.hello-algo.com/chapter_appendix/contribution/),以帮助其他读者获取更优质的学习内容。
- 若您发现语法错误、内容缺失、文字歧义、无效链接、解释不清晰等问题,请协助修正或在评论区指出
- 期待您参与 C++, Python, Go, JavaScript, TypeScript, C, C#, Swift, Zig, Rust, Dart 等语言的[代码翻译](https://github.com/krahets/hello-algo/issues/15)
- 欢迎您为本书内容提出宝贵意见和建议,如有任何问题请提交 Issues 或微信联系 krahets-jyd
- 若您发现语法错误、内容缺失、文字歧义、无效链接、解释不清晰等问题,请协助修正或在评论区指出
- 期待您参与 C++, Python, Go, JavaScript, TypeScript, C, C#, Swift, Zig, Rust, Dart 等语言的[代码翻译](https://github.com/krahets/hello-algo/issues/15)
- 欢迎您为本书内容提出宝贵意见和建议,如有任何问题请提交 Issues 或微信联系 krahets-jyd
感谢本开源书的每一位撰稿人,是他们的无私奉献让这本书变得更好,他们是:

@ -10,23 +10,23 @@
在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码:
1. 点击编辑按钮,如果遇到“需要 Fork 此仓库”的提示,请同意该操作
2. 修改 Markdown 源文件内容,并确保内容正确,同时尽量保持排版格式的统一
1. 点击编辑按钮,如果遇到“需要 Fork 此仓库”的提示,请同意该操作
2. 修改 Markdown 源文件内容,并确保内容正确,同时尽量保持排版格式的统一
3. 在页面底部填写修改说明然后点击“Propose file change”按钮页面跳转后点击“Create pull request”按钮即可发起拉取请求。
![页面编辑按键](contribution.assets/edit_markdown.png)
由于图片无法直接修改,因此需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述图片问题,我们会尽快重新绘制并替换图片。
由于图片无法直接修改,因此需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述问题,我们会尽快重新绘制并替换图片。
## 内容创作
如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施 Pull Request 工作流程:
1. 登录 GitHub ,将[本仓库](https://github.com/krahets/hello-algo) Fork 到个人账号下
2. 进入您的 Fork 仓库网页,使用 git clone 命令将仓库克隆至本地
3. 在本地进行内容创作,并通过运行测试以验证代码的正确性
4. 将本地所做更改 Commit ,然后 Push 至远程仓库
5. 刷新仓库网页点击“Create pull request”按钮即可发起拉取请求
1. 登录 GitHub ,将[本仓库](https://github.com/krahets/hello-algo) Fork 到个人账号下
2. 进入您的 Fork 仓库网页,使用 git clone 命令将仓库克隆至本地
3. 在本地进行内容创作,并通过运行测试以验证代码的正确性
4. 将本地所做更改 Commit ,然后 Push 至远程仓库
5. 刷新仓库网页点击“Create pull request”按钮即可发起拉取请求
## Docker 部署

@ -35,15 +35,15 @@
## C# 环境
1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download)
1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download)
2. 在 VSCode 的插件市场中搜索 `c#` ,安装 c# 。
## Swift 环境
1. 下载并安装 [Swift](https://www.swift.org/download/)
1. 下载并安装 [Swift](https://www.swift.org/download/)
2. 在 VSCode 的插件市场中搜索 `swift` ,安装 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)。
## Rust 环境
1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install)
1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install)
2. 在 VSCode 的插件市场中搜索 `rust` ,安装 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)。

@ -30,9 +30,9 @@
栈内存分配由编译器自动完成,而堆内存由程序员在代码中分配(注意,这里的栈和堆和数据结构中的栈和堆不是同一概念)。
1. 栈不灵活,分配的内存大小不可更改;堆相对灵活,可以动态分配内存
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中
1. 栈不灵活,分配的内存大小不可更改;堆相对灵活,可以动态分配内存
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中
!!! question "为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?"

@ -782,6 +782,6 @@
请注意,对于许多组合优化问题,回溯都不是最优解决方案,例如:
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等
- 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等
- 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决

@ -125,8 +125,8 @@
分支越靠右,需要排除的分支也越多,例如:
1. 前两轮选择 $3$ , $5$ ,生成子集 $[3, 5, \cdots]$
2. 前两轮选择 $4$ , $5$ ,生成子集 $[4, 5, \cdots]$
1. 前两轮选择 $3$ , $5$ ,生成子集 $[3, 5, \cdots]$
2. 前两轮选择 $4$ , $5$ ,生成子集 $[4, 5, \cdots]$
3. 若第一轮选择 $5$ **则第二轮应该跳过 $3$ 和 $4$** ,因为子集 $[5, 3, \cdots]$ 和子集 $[5, 4, \cdots]$ 和 `1.` , `2.` 中生成的子集完全重复。
![不同选择顺序导致的重复子集](subset_sum_problem.assets/subset_sum_i_pruning.png)

@ -30,9 +30,9 @@
**复杂度分析评估的是算法运行效率随着输入数据量增多时的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解:
- “算法运行效率”可分为“运行时间”和“占用空间”,因此我们可以将复杂度分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」
- “随着输入数据量增多时”表示复杂度与输入数据量有关,反映了算法运行效率与输入数据量之间的关系
- “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间
- “算法运行效率”可分为“运行时间”和“占用空间”,因此我们可以将复杂度分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」
- “随着输入数据量增多时”表示复杂度与输入数据量有关,反映了算法运行效率与输入数据量之间的关系
- “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,因此分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。

@ -6,9 +6,9 @@
算法运行过程中使用的内存空间主要包括以下几种:
- 「输入空间」用于存储算法的输入数据
- 「暂存空间」用于存储算法运行过程中的变量、对象、函数上下文等数据
- 「输出空间」用于存储算法的输出数据
- 「输入空间」用于存储算法的输入数据
- 「暂存空间」用于存储算法运行过程中的变量、对象、函数上下文等数据
- 「输出空间」用于存储算法的输出数据
通常情况下,空间复杂度统计范围是「暂存空间」+「输出空间」。
@ -286,8 +286,8 @@
**最差空间复杂度中的“最差”有两层含义**,分别是输入数据的最差分布和算法运行过程中的最差时间点。
- **以最差输入数据为准**。当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但当 $n > 10$ 时,初始化的数组 `nums` 占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$
- **以算法运行过程中的峰值内存为准**。例如,程序在执行最后一行之前,占用 $O(1)$ 空间;当初始化数组 `nums` 时,程序占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$
- **以最差输入数据为准**。当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但当 $n > 10$ 时,初始化的数组 `nums` 占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$
- **以算法运行过程中的峰值内存为准**。例如,程序在执行最后一行之前,占用 $O(1)$ 空间;当初始化数组 `nums` 时,程序占用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$
=== "Java"

@ -1632,8 +1632,8 @@ $$
**某些算法的时间复杂度不是固定的,而是与输入数据的分布有关**。例如,假设输入一个长度为 $n$ 的数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,但元素顺序是随机打乱的;算法的任务是返回元素 $1$ 的索引。我们可以得出以下结论:
- 当 `nums = [?, ?, ..., 1]` ,即当末尾元素是 $1$ 时,需要完整遍历数组,此时达到 **最差时间复杂度 $O(n)$**
- 当 `nums = [1, ?, ?, ...]` ,即当首个数字为 $1$ 时,无论数组多长都不需要继续遍历,此时达到 **最佳时间复杂度 $\Omega(1)$**
- 当 `nums = [?, ?, ..., 1]` ,即当末尾元素是 $1$ 时,需要完整遍历数组,此时达到 **最差时间复杂度 $O(n)$**
- 当 `nums = [1, ?, ?, ...]` ,即当首个数字为 $1$ 时,无论数组多长都不需要继续遍历,此时达到 **最佳时间复杂度 $\Omega(1)$**
“函数渐近上界”使用大 $O$ 记号表示,代表「最差时间复杂度」。相应地,“函数渐近下界”用 $\Omega$ 记号来表示,代表「最佳时间复杂度」。

@ -4,15 +4,15 @@
**基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用**。它包括:
- 整数类型 `byte` , `short` , `int` , `long`
- 浮点数类型 `float` , `double` ,用于表示小数
- 字符类型 `char` ,用于表示各种语言的字母、标点符号、甚至表情符号等
- 布尔类型 `bool` ,用于表示“是”与“否”判断
- 整数类型 `byte` , `short` , `int` , `long`
- 浮点数类型 `float` , `double` ,用于表示小数
- 字符类型 `char` ,用于表示各种语言的字母、标点符号、甚至表情符号等
- 布尔类型 `bool` ,用于表示“是”与“否”判断
**所有基本数据类型都以二进制的形式存储在计算机中**。在计算机中,我们将 $1$ 个二进制位称为 $1$ 比特,并规定 $1$ 字节byte由 $8$ 比特bits组成。基本数据类型的取值范围取决于其占用的空间大小例如
- 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个不同的数字
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字
- 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个不同的数字
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字
下表列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无需硬背,大致理解即可,需要时可以通过查表来回忆。

@ -8,16 +8,16 @@
逻辑结构通常分为“线性”和“非线性”两类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
- **线性数据结构**:数组、链表、栈、队列、哈希表
- **非线性数据结构**:树、堆、图、哈希表
- **线性数据结构**:数组、链表、栈、队列、哈希表
- **非线性数据结构**:树、堆、图、哈希表
![线性与非线性数据结构](classification_of_data_structure.assets/classification_logic_structure.png)
非线性数据结构可以进一步被划分为树形结构和网状结构。
- **线性结构**:数组、链表、队列、栈、哈希表,元素存在一对一的顺序关系
- **树形结构**:树、堆、哈希表,元素存在一对多的关系
- **网状结构**:图,元素存在多对多的关系
- **线性结构**:数组、链表、队列、栈、哈希表,元素存在一对一的顺序关系
- **树形结构**:树、堆、哈希表,元素存在一对多的关系
- **网状结构**:图,元素存在多对多的关系
## 物理结构:连续与离散
@ -37,8 +37,8 @@
**所有数据结构都是基于数组、链表或二者的组合实现的**。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。
- **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等
- **基于链表可实现**:栈、队列、哈希表、树、堆、图等
- **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等
- **基于链表可实现**:栈、队列、哈希表、树、堆、图等
基于数组实现的数据结构也被称为“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构被称为“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。

@ -90,9 +90,9 @@ $$
实际上,这是因为浮点数 `float` 采用了不同的表示方式。根据 IEEE 754 标准32-bit 长度的 `float` 由以下部分构成:
- 符号位 $\mathrm{S}$ :占 1 bit
- 指数位 $\mathrm{E}$ :占 8 bits
- 分数位 $\mathrm{N}$ :占 24 bits ,其中 23 位显式存储
- 符号位 $\mathrm{S}$ :占 1 bit
- 指数位 $\mathrm{E}$ :占 8 bits
- 分数位 $\mathrm{N}$ :占 24 bits ,其中 23 位显式存储
设 32-bit 二进制数的第 $i$ 位为 $b_i$ ,则 `float` 值的计算方法定义为:
@ -141,7 +141,7 @@ $$
特别地,次正规数显著提升了浮点数的精度,这是因为:
- 最小正正规数为 $2^{-126} \approx 1.18 \times 10^{-38}$
- 最小正次正规数为 $2^{-126} \times 2^{-23} \approx 1.4 \times 10^{-45}$
- 最小正正规数为 $2^{-126} \approx 1.18 \times 10^{-38}$
- 最小正次正规数为 $2^{-126} \times 2^{-23} \approx 1.4 \times 10^{-45}$
双精度 `double` 也采用类似 `float` 的表示方法,此处不再详述。

@ -30,8 +30,8 @@
从原问题 $f(0, n-1)$ 为起始点,二分查找的分治步骤为:
1. 计算搜索区间 $[i, j]$ 的中点 $m$ ,根据它排除一半搜索区间
2. 递归求解规模减小一半的子问题,可能为 $f(i, m-1)$ 或 $f(m+1, j)$
1. 计算搜索区间 $[i, j]$ 的中点 $m$ ,根据它排除一半搜索区间
2. 递归求解规模减小一半的子问题,可能为 $f(i, m-1)$ 或 $f(m+1, j)$
3. 循环第 `1.` , `2.` 步,直至找到 `target` 或区间为空时返回。
下图展示了在数组中二分查找元素 $6$ 的分治过程。

@ -20,14 +20,14 @@
根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分:
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图 `[ 3 | 9 | 2 1 7 ]`
- 中序遍历:`[ 左子树 | 根节点 右子树 ]` ,例如上图 `[ 9 | 3 | 1 2 7 ]`
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图 `[ 3 | 9 | 2 1 7 ]`
- 中序遍历:`[ 左子树 | 根节点 右子树 ]` ,例如上图 `[ 9 | 3 | 1 2 7 ]`
以上图数据为例,我们可以通过以下步骤得到上述的划分结果:
1. 前序遍历的首元素 3 是根节点的值
2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 1 2 7 ]`
3. 根据 `inorder` 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]`
1. 前序遍历的首元素 3 是根节点的值
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)
@ -35,9 +35,9 @@
根据以上划分方法,**我们已经得到根节点、左子树、右子树在 `preorder``inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量:
- 将当前树的根节点在 `preorder` 中的索引记为 $i$
- 将当前树的根节点在 `inorder` 中的索引记为 $m$
- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$
- 将当前树的根节点在 `preorder` 中的索引记为 $i$
- 将当前树的根节点在 `inorder` 中的索引记为 $m$
- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$
如下表所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。

@ -2,8 +2,8 @@
「分治 Divide and Conquer」全称分而治之是一种非常重要且常见的算法策略。分治通常基于递归实现包括“分”和“治”两步
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解
已介绍过的「归并排序」是分治策略的典型应用之一,它的分治策略为:
@ -22,9 +22,9 @@
显然归并排序,满足以上三条判断依据:
1. 递归地将数组(原问题)划分为两个子数组(子问题)
2. 每个子数组都可以独立地进行排序(子问题可以独立进行求解)
3. 两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)
1. 递归地将数组(原问题)划分为两个子数组(子问题)
2. 每个子数组都可以独立地进行排序(子问题可以独立进行求解)
3. 两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)
## 通过分治提升效率

@ -6,9 +6,9 @@
给定三根柱子,记为 `A` , `B` , `C` 。起始状态下,柱子 `A` 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 `C` 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则:
1. 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入
2. 每次只能移动一个圆盘
3. 小圆盘必须时刻位于大圆盘之上
1. 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入
2. 每次只能移动一个圆盘
3. 小圆盘必须时刻位于大圆盘之上
![汉诺塔问题示例](hanota_problem.assets/hanota_example.png)
@ -26,9 +26,9 @@
对于问题 $f(2)$ ,即当有两个圆盘时,**由于要时刻满足小圆盘在大圆盘之上,因此需要借助 `B` 来完成移动**,包括三步:
1. 先将上面的小圆盘从 `A` 移至 `B`
2. 再将大圆盘从 `A` 移至 `C`
3. 最后将小圆盘从 `B` 移至 `C`
1. 先将上面的小圆盘从 `A` 移至 `B`
2. 再将大圆盘从 `A` 移至 `C`
3. 最后将小圆盘从 `B` 移至 `C`
解决问题 $f(2)$ 的过程可总结为:**将两个圆盘借助 `B``A` 移至 `C`** 。其中,`C` 称为目标柱、`B` 称为缓冲柱。
@ -48,9 +48,9 @@
对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。由于已知 $f(1)$ 和 $f(2)$ 的解,因此可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行以下步骤:
1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移动至 `B`
2. 将 `A` 中剩余的一个圆盘从 `A` 直接移动至 `C`
3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移动至 `C`
1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移动至 `B`
2. 将 `A` 中剩余的一个圆盘从 `A` 直接移动至 `C`
3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移动至 `C`
这样三个圆盘就被顺利地从 `A` 移动至 `C` 了。
@ -70,9 +70,9 @@
至此,我们可总结出汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ 。子问题的解决顺序为:
1. 将 $n-1$ 个圆盘借助 `C``A` 移至 `B`
2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C`
3. 将 $n-1$ 个圆盘借助 `A``B` 移至 `C`
1. 将 $n-1$ 个圆盘借助 `C``A` 移至 `B`
2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C`
3. 将 $n-1$ 个圆盘借助 `A``B` 移至 `C`
对于这两个子问题 $f(n-1)$ **可以通过相同的方式进行递归划分**,直至达到最小子问题 $f(1)$ 。而 $f(1)$ 的解是已知的,只需一次移动操作即可。

@ -192,8 +192,8 @@ $$
为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来决定下一步该怎么跳:
- 当 $j$ 等于 $1$ ,即上一轮跳了 $1$ 阶时,这一轮只能选择跳 $2$ 阶
- 当 $j$ 等于 $2$ ,即上一轮跳了 $2$ 阶时,这一轮可选择跳 $1$ 阶或跳 $2$ 阶
- 当 $j$ 等于 $1$ ,即上一轮跳了 $1$ 阶时,这一轮只能选择跳 $2$ 阶
- 当 $j$ 等于 $2$ ,即上一轮跳了 $2$ 阶时,这一轮可选择跳 $1$ 阶或跳 $2$ 阶
在该定义下,$dp[i, j]$ 表示状态 $[i, j]$ 对应的方案数。在该定义下的状态转移方程为:

@ -93,10 +93,10 @@ $$
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素:
- **递归参数**:状态 $[i, j]$
- **返回值**:从 $[0, 0]$ 到 $[i, j]$ 的最小路径和 $dp[i, j]$
- **终止条件**:当 $i = 0$ 且 $j = 0$ 时,返回代价 $grid[0, 0]$
- **剪枝**:当 $i < 0$ 时或 $j < 0$ 时索引越界,此时返回代价 $+\infty$ ,代表不可行
- **递归参数**:状态 $[i, j]$
- **返回值**:从 $[0, 0]$ 到 $[i, j]$ 的最小路径和 $dp[i, j]$
- **终止条件**:当 $i = 0$ 且 $j = 0$ 时,返回代价 $grid[0, 0]$
- **剪枝**:当 $i < 0$ 时或 $j < 0$ 时索引越界,此时返回代价 $+\infty$ ,代表不可行
=== "Java"

@ -26,8 +26,8 @@
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 $s$ 和 $t$ 的长度分别为 $n$ 和 $m$ ,我们先考虑两字符串尾部的字符 $s[n-1]$ 和 $t[m-1]$
- 若 $s[n-1]$ 和 $t[m-1]$ 相同,我们可以跳过它们,直接考虑 $s[n-2]$ 和 $t[m-2]$ ;
- 若 $s[n-1]$ 和 $t[m-1]$ 不同,我们需要对 $s$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题
- 若 $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]$ 。
@ -39,9 +39,9 @@
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为三种情况:
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$
3. 将 $s[i-1]$ 替换为 $t[j-1]$ ,则剩余子问题 $dp[i-1, j-1]$
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$
3. 将 $s[i-1]$ 替换为 $t[j-1]$ ,则剩余子问题 $dp[i-1, j-1]$
![编辑距离的状态转移](edit_distance_problem.assets/edit_distance_state_transfer.png)

@ -231,8 +231,8 @@ $$
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做:
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而将重叠子问题剪枝
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而将重叠子问题剪枝
=== "Java"
@ -404,9 +404,9 @@ $$
总结以上,动态规划的常用术语包括:
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解
- 将最小子问题对应的状态(即第 $1$ , $2$ 阶楼梯)称为「初始状态」
- 将递推公式 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解
- 将最小子问题对应的状态(即第 $1$ , $2$ 阶楼梯)称为「初始状态」
- 将递推公式 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」
![爬楼梯的动态规划过程](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)

@ -28,8 +28,8 @@
当我们做出物品 $i$ 的决策后,剩余的是前 $i-1$ 个物品的决策。因此,状态转移分为两种情况:
- **不放入物品 $i$** :背包容量不变,状态转移至 $[i-1, c]$
- **放入物品 $i$** :背包容量减小 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态转移至 $[i-1, c-wgt[i-1]]$
- **不放入物品 $i$** :背包容量不变,状态转移至 $[i-1, c]$
- **放入物品 $i$** :背包容量减小 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态转移至 $[i-1, c-wgt[i-1]]$
上述的状态转移向我们揭示了本题的最优子结构:**最大价值 $dp[i, c]$ 等于不放入物品 $i$ 和放入物品 $i$ 两种方案中的价值更大的那一个**。由此可推出状态转移方程:
@ -51,10 +51,10 @@ $$
搜索代码包含以下要素:
- **递归参数**:状态 $[i, c]$
- **返回值**:子问题的解 $dp[i, c]$
- **终止条件**:当物品编号越界 $i = 0$ 或背包剩余容量为 $0$ 时,终止递归并返回价值 $0$
- **剪枝**:若当前物品重量超出背包剩余容量,则只能不放入背包
- **递归参数**:状态 $[i, c]$
- **返回值**:子问题的解 $dp[i, c]$
- **终止条件**:当物品编号越界 $i = 0$ 或背包剩余容量为 $0$ 时,终止递归并返回价值 $0$
- **剪枝**:若当前物品重量超出背包剩余容量,则只能不放入背包
=== "Java"

@ -12,13 +12,13 @@
完全背包和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。
- 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择
- 在完全背包中,每个物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**
- 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择
- 在完全背包中,每个物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**
这就导致了状态转移的变化,对于状态 $[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]]$
从而状态转移方程变为:
@ -200,9 +200,9 @@ $$
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点:
- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量
- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解
- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量
- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
@ -214,8 +214,8 @@ $$
与完全背包的状态转移方程基本相同,不同点在于:
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可
$$
dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)

@ -18,15 +18,15 @@ $$
根据边是否具有方向,可分为「无向图 Undirected Graph」和「有向图 Directed Graph」。
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”
- 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”
- 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系
![有向图与无向图](graph.assets/directed_graph.png)
根据所有顶点是否连通,可分为「连通图 Connected Graph」和「非连通图 Disconnected Graph」。
- 对于连通图,从某个顶点出发,可以到达其余任意顶点
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达
- 对于连通图,从某个顶点出发,可以到达其余任意顶点
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达
![连通图与非连通图](graph.assets/connected_graph.png)

@ -18,9 +18,9 @@
BFS 通常借助「队列」来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
1. 将遍历起始顶点 `startVet` 加入队列,并开启循环
2. 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部
3. 循环步骤 `2.` ,直到所有顶点被访问完成后结束
1. 将遍历起始顶点 `startVet` 加入队列,并开启循环
2. 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部
3. 循环步骤 `2.` ,直到所有顶点被访问完成后结束
为了防止重复遍历顶点,我们需要借助一个哈希表 `visited` 来记录哪些节点已被访问。
@ -235,8 +235,8 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
深度优先遍历的算法流程如下图所示,其中:
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此递归方法的位置
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此递归方法的位置
为了加深理解,建议将图示与代码结合起来,在脑中(或者用笔画下来)模拟整个 DFS 过程,包括每个递归方法何时开启、何时返回。

@ -12,8 +12,8 @@
不同点在于,本题允许只选择物品的一部分,**这意味着可以对物品任意地进行切分,并按照重量比例来计算物品价值**,因此有:
1. 对于物品 $i$ ,它在单位重量下的价值为 $val[i-1] / wgt[i-1]$ ,简称为单位价值
2. 假设放入一部分物品 $i$ ,重量为 $w$ ,则背包增加的价值为 $w \times 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)

@ -28,8 +28,8 @@ $$
我们发现,**如果此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。这是因为在移动长板 $j$ 后:
- 宽度 $j-i$ 肯定变小
- 高度由短板决定,因此高度只可能不变( $i$ 仍为短板)或变小(移动后的 $j$ 成为短板)
- 宽度 $j-i$ 肯定变小
- 高度由短板决定,因此高度只可能不变( $i$ 仍为短板)或变小(移动后的 $j$ 成为短板)
![向内移动长板后的状态](max_capacity_problem.assets/max_capacity_moving_long_board.png)

@ -133,7 +133,7 @@ $$
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有三种:
- 运算符 `**` 和函数 `pow()` 的时间复杂度均为 $O(\log a)$
- 运算符 `**` 和函数 `pow()` 的时间复杂度均为 $O(\log a)$
- 函数 `math.pow()` 内部调用 C 语言库的 `pow()` 函数,其执行浮点取幂,时间复杂度为 $O(1)$ 。
变量 $a$ , $b$ 使用常数大小的额外空间,**因此空间复杂度为 $O(1)$** 。

@ -8,9 +8,9 @@
除哈希表外,我们还可以使用数组或链表实现查询功能。若将学生数据看作数组(链表)元素,则有:
- **添加元素**:仅需将元素添加至数组(链表)的尾部即可,使用 $O(1)$ 时间
- **查询元素**:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 $O(n)$ 时间
- **删除元素**:需要先查询到元素,再从数组中删除,使用 $O(n)$ 时间
- **添加元素**:仅需将元素添加至数组(链表)的尾部即可,使用 $O(1)$ 时间
- **查询元素**:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 $O(n)$ 时间
- **删除元素**:需要先查询到元素,再从数组中删除,使用 $O(n)$ 时间
<div class="center-table" markdown>
@ -433,8 +433,8 @@
输入一个 `key` ,哈希函数的计算过程分为两步:
1. 通过某种哈希算法 `hash()` 计算得到哈希值
2. 将哈希值对桶数量(数组长度)`capacity` 取模,从而获取该 `key` 对应的数组索引 `index`
1. 通过某种哈希算法 `hash()` 计算得到哈希值
2. 将哈希值对桶数量(数组长度)`capacity` 取模,从而获取该 `key` 对应的数组索引 `index`
```shell
index = hash(key) % capacity

@ -82,8 +82,8 @@
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。
- 完全二叉树中,设节点总数为 $n$ ,则叶节点数量为 $(n + 1) / 2$ ,其中 $/$ 为向下整除。因此,在排除叶节点后,需要堆化的节点数量为 $(n - 1)/2$ ,复杂度为 $O(n)$
- 在从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度 $O(\log n)$
- 完全二叉树中,设节点总数为 $n$ ,则叶节点数量为 $(n + 1) / 2$ ,其中 $/$ 为向下整除。因此,在排除叶节点后,需要堆化的节点数量为 $(n - 1)/2$ ,复杂度为 $O(n)$
- 在从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度 $O(\log n)$
将上述两者相乘,可得到建堆过程的时间复杂度为 $O(n \log n)$ 。**然而,这个估算结果并不准确,因为我们没有考虑到二叉树底层节点数量远多于顶层节点的特性**。

@ -2,8 +2,8 @@
「堆 Heap」是一种满足特定条件的完全二叉树可分为两种类型
- 「大顶堆 Max Heap」任意节点的值 $\geq$ 其子节点的值
- 「小顶堆 Min Heap」任意节点的值 $\leq$ 其子节点的值
- 「大顶堆 Max Heap」任意节点的值 $\geq$ 其子节点的值
- 「小顶堆 Min Heap」任意节点的值 $\leq$ 其子节点的值
![小顶堆与大顶堆](heap.assets/min_heap_and_max_heap.png)
@ -630,9 +630,9 @@
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
1. 交换堆顶元素与堆底元素(即交换根节点与最右叶节点)
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素)
3. 从根节点开始,**从顶至底执行堆化**
1. 交换堆顶元素与堆底元素(即交换根节点与最右叶节点)
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素)
3. 从根节点开始,**从顶至底执行堆化**
顾名思义,**从顶至底堆化的操作方向与从底至顶堆化相反**,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换;然后循环执行此操作,直到越过叶节点或遇到无需交换的节点时结束。

@ -30,10 +30,10 @@
我们可以基于堆更加高效地解决 Top-K 问题,流程如下:
1. 初始化一个小顶堆,其堆顶元素最小
2. 先将数组的前 $k$ 个元素依次入堆
3. 从第 $k + 1$ 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆
4. 遍历完成后,堆中保存的就是最大的 $k$ 个元素
1. 初始化一个小顶堆,其堆顶元素最小
2. 先将数组的前 $k$ 个元素依次入堆
3. 从第 $k + 1$ 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆
4. 遍历完成后,堆中保存的就是最大的 $k$ 个元素
=== "<1>"
![基于堆寻找最大的 k 个元素](top_k.assets/top_k_heap_step1.png)

@ -2,9 +2,9 @@
本项目旨在创建一本开源免费、新手友好的数据结构与算法入门教程。
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂、学习曲线平滑
- 算法源代码皆可一键运行,支持 Java, C++, Python, Go, JS, TS, C#, Swift, Zig 等语言
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论通常可在两日内得到回复
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂、学习曲线平滑
- 算法源代码皆可一键运行,支持 Java, C++, Python, Go, JS, TS, C#, Swift, Zig 等语言
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论通常可在两日内得到回复
## 读者对象

@ -12,9 +12,9 @@
1. 计算中点索引 $m = \lfloor {(i + j) / 2} \rfloor$ ,其中 $\lfloor \space \rfloor$ 表示向下取整操作。
2. 判断 `nums[m]``target` 的大小关系,分为三种情况:
1. 当 `nums[m] < target` 时,说明 `target` 在区间 $[m + 1, j]$ 中,因此执行 $i = m + 1$
2. 当 `nums[m] > target` 时,说明 `target` 在区间 $[i, m - 1]$ 中,因此执行 $j = m - 1$
3. 当 `nums[m] = target` 时,说明找到 `target` ,因此返回索引 $m$
1. 当 `nums[m] < target` 时,说明 `target` 在区间 $[m + 1, j]$ 中,因此执行 $i = m + 1$
2. 当 `nums[m] > target` 时,说明 `target` 在区间 $[i, m - 1]$ 中,因此执行 $j = m - 1$
3. 当 `nums[m] = target` 时,说明找到 `target` ,因此返回索引 $m$
若数组不包含目标元素,搜索区间最终会缩小为空。此时返回 $-1$ 。

@ -10,7 +10,7 @@
为了查找数组中最左边的 `target` ,我们可以分为两步:
1. 进行二分查找,定位到任意一个 `target` 的索引,记为 $k$
1. 进行二分查找,定位到任意一个 `target` 的索引,记为 $k$
2. 以索引 $k$ 为起始点,向左进行线性遍历,找到最左边的 `target` 返回即可。
![线性查找最左边的元素](binary_search_edge.assets/binary_search_left_edge_naive.png)

@ -84,8 +84,8 @@
考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行:
1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引
2. 将键值对 `nums[i]` 和索引 `i` 添加进哈希表
1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引
2. 将键值对 `nums[i]` 和索引 `i` 添加进哈希表
=== "<1>"
![辅助哈希表求解两数之和](replace_linear_by_hashing.assets/two_sum_hashtable_step1.png)

@ -8,9 +8,9 @@
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下:
1. 初始化 $k$ 个桶,将 $n$ 个元素分配到 $k$ 个桶中
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)
3. 按照桶的从小到大的顺序,合并结果
1. 初始化 $k$ 个桶,将 $n$ 个元素分配到 $k$ 个桶中
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)
3. 按照桶的从小到大的顺序,合并结果
![桶排序算法流程](bucket_sort.assets/bucket_sort_overview.png)

@ -6,7 +6,7 @@
先来看一个简单的例子。给定一个长度为 $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` 即可。
@ -94,8 +94,8 @@ $$
**前缀和具有明确的意义,`prefix[num] - 1` 代表元素 `num` 在结果数组 `res` 中最后一次出现的索引**。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 `nums` 的每个元素 `num` ,在每轮迭代中执行:
1. 将 `num` 填入数组 `res` 的索引 `prefix[num] - 1`
2. 令前缀和 `prefix[num]` 减小 $1$ ,从而得到下次放置 `num` 的索引
1. 将 `num` 填入数组 `res` 的索引 `prefix[num] - 1`
2. 令前缀和 `prefix[num]` 减小 $1$ ,从而得到下次放置 `num` 的索引
遍历完成后,数组 `res` 中就是排序好的结果,最后使用 `res` 覆盖原数组 `nums` 即可。

@ -2,17 +2,17 @@
「归并排序 Merge Sort」基于分治思想实现排序包含“划分”和“合并”两个阶段
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题
2. **合并阶段**:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题
2. **合并阶段**:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束
![归并排序的划分与合并阶段](merge_sort.assets/merge_sort_overview.png)
## 算法流程
“划分阶段”从顶至底递归地将数组从中点切为两个子数组,直至长度为 1
“划分阶段”从顶至底递归地将数组从中点切为两个子数组
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]`
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]`
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
@ -155,6 +155,6 @@
归并排序在排序链表时具有显著优势,空间复杂度可以优化至 $O(1)$ ,原因如下:
- 由于链表仅需改变指针就可实现节点的增删操作,因此合并阶段(将两个短有序链表合并为一个长有序链表)无需创建辅助链表。
- 通过使用“迭代划分”替代“递归划分”,可省去递归使用的栈帧空间
- 通过使用“迭代划分”替代“递归划分”,可省去递归使用的栈帧空间
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。

@ -4,9 +4,9 @@
快速排序的核心操作是「哨兵划分」,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:
1. 选取数组最左端元素作为基准数,初始化两个指针 `i``j` 分别指向数组的两端
2. 设置一个循环,在每轮中使用 `i``j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素
3. 循环执行步骤 `2.` ,直到 `i``j` 相遇时停止,最后将基准数交换至两个子数组的分界线
1. 选取数组最左端元素作为基准数,初始化两个指针 `i``j` 分别指向数组的两端
2. 设置一个循环,在每轮中使用 `i``j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素
3. 循环执行步骤 `2.` ,直到 `i``j` 相遇时停止,最后将基准数交换至两个子数组的分界线
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 $\leq$ 基准数 $\leq$ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
@ -127,9 +127,9 @@
## 算法流程
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组
2. 然后,对左子数组和右子数组分别递归执行「哨兵划分」
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组
2. 然后,对左子数组和右子数组分别递归执行「哨兵划分」
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序
![快速排序流程](quick_sort.assets/quick_sort_overview.png)

@ -8,9 +8,9 @@
以学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的步骤如下:
1. 初始化位数 $k = 1$
2. 对学号的第 $k$ 位执行「计数排序」。完成后,数据会根据第 $k$ 位从小到大排序
3. 将 $k$ 增加 $1$ ,然后返回步骤 `2.` 继续迭代,直到所有位都排序完成后结束
1. 初始化位数 $k = 1$
2. 对学号的第 $k$ 位执行「计数排序」。完成后,数据会根据第 $k$ 位从小到大排序
3. 将 $k$ 增加 $1$ ,然后返回步骤 `2.` 继续迭代,直到所有位都排序完成后结束
![基数排序算法流程](radix_sort.assets/radix_sort_overview.png)

@ -372,8 +372,8 @@
基于此设计,**数组中包含元素的有效区间为 [front, rear - 1]**,进而:
- 对于入队操作,将输入元素赋值给 `rear` 索引处,并将 `queSize` 增加 1
- 对于出队操作,只需将 `front` 增加 1 ,并将 `queSize` 减少 1
- 对于入队操作,将输入元素赋值给 `rear` 索引处,并将 `queSize` 增加 1
- 对于出队操作,只需将 `front` 增加 1 ,并将 `queSize` 减少 1
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为 $O(1)$ 。

@ -461,8 +461,8 @@
综上所述,当入栈与出栈操作的元素是基本数据类型(如 `int` , `double` )时,我们可以得出以下结论:
- 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高
- 基于链表实现的栈可以提供更加稳定的效率表现
- 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高
- 基于链表实现的栈可以提供更加稳定的效率表现
### 空间效率

@ -116,8 +116,8 @@
如下代码给出了数组表示下的二叉树的简单实现,包括以下操作:
- 给定某节点,获取它的值、左(右)子节点、父节点
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列
- 给定某节点,获取它的值、左(右)子节点、父节点
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列
=== "Java"
@ -189,12 +189,12 @@
二叉树的数组表示的优点包括:
- 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快
- 不需要存储指针,比较节省空间
- 允许随机访问节点
- 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快
- 不需要存储指针,比较节省空间
- 允许随机访问节点
然而,数组表示也具有一些局限性:
- 数组存储需要连续内存空间,因此不适合存储数据量过大的树
- 增删节点需要通过数组插入与删除操作实现,效率较低
- 当二叉树中存在大量 $\text{None}$ 时,数组中包含的节点数据比重较低,空间利用率较低
- 数组存储需要连续内存空间,因此不适合存储数据量过大的树
- 增删节点需要通过数组插入与删除操作实现,效率较低
- 当二叉树中存在大量 $\text{None}$ 时,数组中包含的节点数据比重较低,空间利用率较低

@ -809,8 +809,8 @@ AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。
## AVL 树典型应用
- 组织和存储大型数据,适用于高频查找、低频增删的场景
- 用于构建数据库中的索引系统
- 组织和存储大型数据,适用于高频查找、低频增删的场景
- 用于构建数据库中的索引系统
!!! question "为什么红黑树比 AVL 树更受欢迎?"

@ -2,8 +2,8 @@
「二叉搜索树 Binary Search Tree」满足以下条件
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.`
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.`
![二叉搜索树](binary_search_tree.assets/binary_search_tree.png)
@ -13,9 +13,9 @@
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val``num` 之间的大小关系
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right`
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left`
- 若 `cur.val = num` ,说明找到目标节点,跳出循环并返回该节点
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right`
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left`
- 若 `cur.val = num` ,说明找到目标节点,跳出循环并返回该节点
=== "<1>"
![二叉搜索树查找节点示例](binary_search_tree.assets/bst_search_step1.png)
@ -101,8 +101,8 @@
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步:
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 $\text{None}$ )时跳出循环
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 $\text{None}$ 的位置
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 $\text{None}$ )时跳出循环
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 $\text{None}$ 的位置
二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回。
@ -192,8 +192,8 @@
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,因此这个节点可以是右子树的最小节点或左子树的最大节点。假设我们选择右子树的最小节点(或者称为中序遍历的下个节点),则删除操作为:
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp`
2. 将 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp`
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp`
2. 将 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp`
=== "<1>"
![二叉搜索树删除节点示例](binary_search_tree.assets/bst_remove_case3_step1.png)

@ -165,14 +165,14 @@
二叉树涉及的术语较多,建议尽量理解并记住。
- 「根节点 Root Node」位于二叉树顶层的节点没有父节点
- 「叶节点 Leaf Node」没有子节点的节点其两个指针均指向 $\text{None}$
- 节点的「层 Level」从顶至底递增根节点所在层为 1
- 节点的「度 Degree」节点的子节点的数量。在二叉树中度的范围是 0, 1, 2
- 「边 Edge」连接两个节点的线段即节点指针
- 二叉树的「高度」:从根节点到最远叶节点所经过的边的数量
- 节点的「深度 Depth」 :从根节点到该节点所经过的边的数量
- 节点的「高度 Height」从最远叶节点到该节点所经过的边的数量
- 「根节点 Root Node」位于二叉树顶层的节点没有父节点
- 「叶节点 Leaf Node」没有子节点的节点其两个指针均指向 $\text{None}$
- 节点的「层 Level」从顶至底递增根节点所在层为 1
- 节点的「度 Degree」节点的子节点的数量。在二叉树中度的范围是 0, 1, 2
- 「边 Edge」连接两个节点的线段即节点指针
- 二叉树的「高度」:从根节点到最远叶节点所经过的边的数量
- 节点的「深度 Depth」 :从根节点到该节点所经过的边的数量
- 节点的「高度 Height」从最远叶节点到该节点所经过的边的数量
![二叉树的常用术语](binary_tree.assets/binary_tree_terminology.png)
@ -524,8 +524,8 @@
当二叉树的每层节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一侧时,二叉树退化为「链表」。
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$
![二叉树的最佳与最差结构](binary_tree.assets/binary_tree_best_worst_cases.png)

Loading…
Cancel
Save