From edcd1e5c104873771348e02e6a21ebfca0e80295 Mon Sep 17 00:00:00 2001 From: krahets Date: Sun, 16 Jul 2023 04:18:52 +0800 Subject: [PATCH] build --- chapter_appendix/contribution.md | 8 +- chapter_appendix/installation.md | 20 +- .../backtracking_algorithm.md | 14 +- chapter_backtracking/index.md | 2 +- chapter_backtracking/n_queens_problem.md | 4 +- chapter_backtracking/permutations_problem.md | 8 +- chapter_backtracking/subset_sum_problem.md | 8 +- chapter_backtracking/summary.md | 2 +- chapter_data_structure/summary.md | 4 +- .../build_binary_tree.md | 212 ++++++++++++++++++ .../divide_and_conquer.md | 99 ++++++++ chapter_divide_and_conquer/index.md | 6 + .../dp_problem_features.md | 6 +- .../dp_solution_pipeline.md | 12 +- .../edit_distance_problem.md | 57 ++++- chapter_dynamic_programming/index.md | 2 +- .../intro_to_dynamic_programming.md | 58 ++++- .../knapsack_problem.md | 8 +- chapter_dynamic_programming/summary.md | 2 +- .../unbounded_knapsack_problem.md | 8 +- chapter_sorting/bucket_sort.md | 2 +- 21 files changed, 475 insertions(+), 67 deletions(-) create mode 100644 chapter_divide_and_conquer/build_binary_tree.md create mode 100644 chapter_divide_and_conquer/divide_and_conquer.md create mode 100644 chapter_divide_and_conquer/index.md diff --git a/chapter_appendix/contribution.md b/chapter_appendix/contribution.md index 791ecf941..6b85c20b5 100644 --- a/chapter_appendix/contribution.md +++ b/chapter_appendix/contribution.md @@ -2,7 +2,7 @@ comments: true --- -# 14.2.   一起参与创作 +# 15.2.   一起参与创作 !!! success "开源的魅力" @@ -10,7 +10,7 @@ comments: true 由于作者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以帮助其他读者获得更优质的学习资源。所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)将在仓库和网站主页上展示,以感谢他们对开源社区的无私奉献! -## 14.2.1.   内容微调 +## 15.2.1.   内容微调 在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码: @@ -24,7 +24,7 @@ comments: true 由于图片无法直接修改,因此需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述图片问题,我们会尽快重新绘制并替换图片。 -## 14.2.2.   内容创作 +## 15.2.2.   内容创作 如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施 Pull Request 工作流程: @@ -34,7 +34,7 @@ comments: true 4. 将本地所做更改 Commit ,然后 Push 至远程仓库; 5. 刷新仓库网页,点击“Create pull request”按钮即可发起拉取请求; -## 14.2.3.   Docker 部署 +## 15.2.3.   Docker 部署 我们可以通过 Docker 来部署本项目。执行以下脚本,稍等片刻后,即可使用浏览器打开 `http://localhost:8000` 来访问本项目。 diff --git a/chapter_appendix/installation.md b/chapter_appendix/installation.md index 089390319..d3a3ebd18 100644 --- a/chapter_appendix/installation.md +++ b/chapter_appendix/installation.md @@ -2,52 +2,52 @@ comments: true --- -# 14.1.   编程环境安装 +# 15.1.   编程环境安装 -## 14.1.1.   VSCode +## 15.1.1.   VSCode 本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。 -## 14.1.2.   Java 环境 +## 15.1.2.   Java 环境 1. 下载并安装 [OpenJDK](https://jdk.java.net/18/)(版本需满足 > JDK 9)。 2. 在 VSCode 的插件市场中搜索 `java` ,安装 Extension Pack for Java 。 -## 14.1.3.   C/C++ 环境 +## 15.1.3.   C/C++ 环境 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 }` 。 -## 14.1.4.   Python 环境 +## 15.1.4.   Python 环境 1. 下载并安装 [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) 。 2. 在 VSCode 的插件市场中搜索 `python` ,安装 Python Extension Pack 。 3. (可选)在命令行输入 `pip install black` ,安装代码格式化工具。 -## 14.1.5.   Go 环境 +## 15.1.5.   Go 环境 1. 下载并安装 [go](https://go.dev/dl/) 。 2. 在 VSCode 的插件市场中搜索 `go` ,安装 Go 。 3. 快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。 -## 14.1.6.   JavaScript 环境 +## 15.1.6.   JavaScript 环境 1. 下载并安装 [node.js](https://nodejs.org/en/) 。 2. 在 VSCode 的插件市场中搜索 `javascript` ,安装 JavaScript (ES6) code snippets 。 3. (可选)在 VSCode 的插件市场中搜索 `Prettier` ,安装代码格式化工具。 -## 14.1.7.   C# 环境 +## 15.1.7.   C# 环境 1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download) ; 2. 在 VSCode 的插件市场中搜索 `c#` ,安装 c# 。 -## 14.1.8.   Swift 环境 +## 15.1.8.   Swift 环境 1. 下载并安装 [Swift](https://www.swift.org/download/); 2. 在 VSCode 的插件市场中搜索 `swift` ,安装 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)。 -## 14.1.9.   Rust 环境 +## 15.1.9.   Rust 环境 1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install); 2. 在 VSCode 的插件市场中搜索 `rust` ,安装 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)。 diff --git a/chapter_backtracking/backtracking_algorithm.md b/chapter_backtracking/backtracking_algorithm.md index ac9a3db43..2370c0a76 100644 --- a/chapter_backtracking/backtracking_algorithm.md +++ b/chapter_backtracking/backtracking_algorithm.md @@ -2,7 +2,7 @@ comments: true --- -# 12.1.   回溯算法 +# 13.1.   回溯算法 「回溯算法 Backtracking Algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。 @@ -169,7 +169,7 @@ comments: true

Fig. 在前序遍历中搜索节点

-## 12.1.1.   尝试与回退 +## 13.1.1.   尝试与回退 **之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略**。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。 @@ -405,7 +405,7 @@ comments: true === "<11>" ![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png) -## 12.1.2.   剪枝 +## 13.1.2.   剪枝 复杂的回溯问题通常包含一个或多个约束条件,**约束条件通常可用于“剪枝”**。 @@ -616,7 +616,7 @@ comments: true

Fig. 根据约束条件剪枝

-## 12.1.3.   常用术语 +## 13.1.3.   常用术语 为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。 @@ -633,7 +633,7 @@ comments: true 解、状态、约束条件等术语是通用的,适用于回溯算法、动态规划、贪心算法等。 -## 12.1.4.   框架代码 +## 13.1.4.   框架代码 回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。为提升代码通用性,我们希望将回溯算法的“尝试、回退、剪枝”的主体框架提炼出来。 @@ -1350,7 +1350,7 @@ comments: true 相较于基于前序遍历的实现代码,基于回溯算法框架的实现代码虽然显得啰嗦,但通用性更好。实际上,**所有回溯问题都可以在该框架下解决**。我们需要根据具体问题来定义 `state` 和 `choices` ,并实现框架中的各个方法。 -## 12.1.5.   优势与局限性 +## 13.1.5.   优势与局限性 回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。 @@ -1364,7 +1364,7 @@ comments: true - 上文介绍过的剪枝是一种常用的优化方法。它可以避免搜索那些肯定不会产生有效解的路径,从而节省时间和空间。 - 另一个常用的优化方法是加入「启发式搜索 Heuristic Search」策略,它在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。 -## 12.1.6.   典型例题 +## 13.1.6.   典型例题 **搜索问题**:这类问题的目标是找到满足特定条件的解决方案。 diff --git a/chapter_backtracking/index.md b/chapter_backtracking/index.md index c1c8be927..43645b419 100644 --- a/chapter_backtracking/index.md +++ b/chapter_backtracking/index.md @@ -2,7 +2,7 @@ comments: true --- -# 12.   回溯 +# 13.   回溯
diff --git a/chapter_backtracking/n_queens_problem.md b/chapter_backtracking/n_queens_problem.md index 94f4200d1..398d4f2ba 100644 --- a/chapter_backtracking/n_queens_problem.md +++ b/chapter_backtracking/n_queens_problem.md @@ -2,7 +2,7 @@ comments: true --- -# 12.4.   N 皇后问题 +# 13.4.   N 皇后问题 !!! question @@ -496,7 +496,7 @@ comments: true [class]{}-[func]{nQueens} ``` -## 12.4.1.   复杂度分析 +## 13.4.1.   复杂度分析 逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n, n-1, \cdots, 2, 1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。 diff --git a/chapter_backtracking/permutations_problem.md b/chapter_backtracking/permutations_problem.md index 0e8602c32..3cd0e2e06 100644 --- a/chapter_backtracking/permutations_problem.md +++ b/chapter_backtracking/permutations_problem.md @@ -2,7 +2,7 @@ comments: true --- -# 12.2.   全排列问题 +# 13.2.   全排列问题 全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。 @@ -18,7 +18,7 @@ comments: true
-## 12.2.1.   无重复的情况 +## 13.2.1.   无重复的情况 !!! question @@ -352,7 +352,7 @@ comments: true

Fig. 全排列剪枝示例

-## 12.2.2.   考虑重复的情况 +## 13.2.2.   考虑重复的情况 !!! question @@ -710,7 +710,7 @@ comments: true

Fig. 两种剪枝条件的作用范围

-## 12.2.3.   复杂度分析 +## 13.2.3.   复杂度分析 假设元素两两之间互不相同,则 $n$ 个元素共有 $n!$ 种排列(阶乘);在记录结果时,需要复制长度为 $n$ 的列表,使用 $O(n)$ 时间。因此,**时间复杂度为 $O(n!n)$** 。 diff --git a/chapter_backtracking/subset_sum_problem.md b/chapter_backtracking/subset_sum_problem.md index 4d6717e96..cbb788776 100644 --- a/chapter_backtracking/subset_sum_problem.md +++ b/chapter_backtracking/subset_sum_problem.md @@ -2,7 +2,7 @@ comments: true --- -# 12.3.   子集和问题 +# 13.3.   子集和问题 !!! question @@ -10,7 +10,7 @@ comments: true 例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,由于集合中的数字可以被重复选取,因此解为 $\{3, 3, 3\}, \{4, 5\}$ 。请注意,子集是不区分元素顺序的,例如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。 -## 12.3.1.   从全排列引出解法 +## 13.3.1.   从全排列引出解法 类似于上节全排列问题的解法,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 `target` 时,就将子集记录至结果列表。 @@ -274,7 +274,7 @@ comments: true

Fig. 子集搜索与越界剪枝

-## 12.3.2.   重复子集剪枝 +## 13.3.2.   重复子集剪枝 为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,因为: @@ -569,7 +569,7 @@ comments: true

Fig. 子集和 I 回溯过程

-## 12.3.3.   相等元素剪枝 +## 13.3.3.   相等元素剪枝 !!! question diff --git a/chapter_backtracking/summary.md b/chapter_backtracking/summary.md index 66e1e5615..f0b774164 100644 --- a/chapter_backtracking/summary.md +++ b/chapter_backtracking/summary.md @@ -2,7 +2,7 @@ comments: true --- -# 12.5.   小结 +# 13.5.   小结 - 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满足条件的解则记录,直至找到所有解或遍历完成后结束。 - 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两个方向相反的操作。 diff --git a/chapter_data_structure/summary.md b/chapter_data_structure/summary.md index 85fa71eab..3d8aa6926 100644 --- a/chapter_data_structure/summary.md +++ b/chapter_data_structure/summary.md @@ -26,6 +26,6 @@ comments: true 哈希表底层是数组,而为了解决哈希冲突,我们可能会使用“拉链法”(后续散列表章节会讲)。在拉链法中,数组中每个地址(桶)指向一个链表;当这个链表长度超过一定阈值时,又可能被转化为树(通常为红黑树)。因此,哈希表可能同时包含线性(数组、链表)和非线性(树)数据结构。 -!!! question "char 类型的长度是 1 bytes 吗?" +!!! question "char 类型的长度是 1 byte 吗?" - 这个与编程语言采用的编码方法有关。例如,Java, JS, TS, C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。 + char 类型的长度由编程语言采用的编码方法决定。例如,Java, JS, TS, C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes 。 diff --git a/chapter_divide_and_conquer/build_binary_tree.md b/chapter_divide_and_conquer/build_binary_tree.md new file mode 100644 index 000000000..751f192e8 --- /dev/null +++ b/chapter_divide_and_conquer/build_binary_tree.md @@ -0,0 +1,212 @@ +--- +comments: true +--- + +# 12.2.   构建二叉树问题 + +!!! question + + 给定一个二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。 + +![构建二叉树的示例数据](build_binary_tree.assets/build_tree_example.png) + +

Fig. 构建二叉树的示例数据

+ +原问题定义为从 `preorder` 和 `inorder` 构建二叉树。我们首先从分治的角度分析这道题: + +- **问题可以被分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。 +- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历或后序遍历中与左子树对应的部分。右子树同理。 +- **子问题的解可以合并**:一旦我们得到了左子树和右子树,我们可以将它们链接到根节点上,从而得到原问题的解。 + +根据以上分析,这道题是可以使用分治来求解的,但问题是:**如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**? + +根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分: + +- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图 `[ 3 | 9 | 2 1 7 ]` ; +- 中序遍历:`[ 左子树 | 根节点 | 右子树 ]` ,例如上图 `[ 9 | 3 | 1 2 7 ]` ; + +以上图数据为例,我们可以通过以下三步得到上述的划分结果: + +1. 前序遍历的首元素 3 为根节点的值; +2. 查找根节点在 `inorder` 中的索引,基于该索引可将 `inorder` 划分为 `[ 9 | 3 | 1 2 7 ]` ; +3. 根据 `inorder` 划分结果,可得左子树和右子树分别有 1 个和 3 个节点,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]` ; + +![在前序和中序遍历中划分子树](build_binary_tree.assets/build_tree_preorder_inorder_division.png) + +

Fig. 在前序和中序遍历中划分子树

+ +至此,**我们已经推导出根节点、左子树、右子树在 `preorder` 和 `inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量: + +- 将当前树的根节点在 `preorder` 中的索引记为 $i$ ; +- 将当前树的根节点在 `inorder` 中的索引记为 $m$ ; +- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$ ; + +下表整理了根节点、左子树和右子树的索引区间在这些变量下的具体表示。 + +
+ +| | 子树根节点在 `preorder` 中的索引 | 子树在 `inorder` 中的索引区间 | +| ------ | -------------------------------- | ----------------------------- | +| 当前树 | $i$ | $[l, r]$ | +| 左子树 | $i + 1$ | $[l, m-1]$ | +| 右子树 | $i + 1 + (m - l)$ | $[m+1, r]$ | + +
+ +请注意,右子树根节点索引中的 $(m-l)$ 的含义是“左子树的节点数量”,建议配合下图理解。 + +![根节点和左右子树的索引区间表示](build_binary_tree.assets/build_tree_division_pointers.png) + +

Fig. 根节点和左右子树的索引区间表示

+ +接下来就可以实现代码了。为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储 `inorder` 列表元素到索引的映射。 + +=== "Java" + + ```java title="build_tree.java" + [class]{build_tree}-[func]{dfs} + + [class]{build_tree}-[func]{buildTree} + ``` + +=== "C++" + + ```cpp title="build_tree.cpp" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "Python" + + ```python title="build_tree.py" + def dfs( + preorder: list[int], + inorder: list[int], + hmap: dict[int, int], + i: int, + l: int, + r: int, + ) -> TreeNode | None: + """构建二叉树 DFS""" + # 子树区间为空时终止 + if r - l < 0: + return None + # 初始化根节点 + root = TreeNode(preorder[i]) + # 查询 m ,从而划分左右子树 + m = hmap[preorder[i]] + # 递归构建左子树 + root.left = dfs(preorder, inorder, hmap, i + 1, l, m - 1) + # 递归构建右子树 + root.right = dfs(preorder, inorder, hmap, i + 1 + m - l, m + 1, r) + # 返回根节点 + return root + + def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None: + """构建二叉树""" + # 初始化哈希表,存储 inorder 元素到索引的映射 + hmap = {val: i for i, val in enumerate(inorder)} + root = dfs(preorder, inorder, hmap, 0, 0, len(inorder) - 1) + return root + ``` + +=== "Go" + + ```go title="build_tree.go" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "JavaScript" + + ```javascript title="build_tree.js" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "TypeScript" + + ```typescript title="build_tree.ts" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "C" + + ```c title="build_tree.c" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "C#" + + ```csharp title="build_tree.cs" + [class]{build_tree}-[func]{dfs} + + [class]{build_tree}-[func]{buildTree} + ``` + +=== "Swift" + + ```swift title="build_tree.swift" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "Zig" + + ```zig title="build_tree.zig" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +=== "Dart" + + ```dart title="build_tree.dart" + [class]{}-[func]{dfs} + + [class]{}-[func]{buildTree} + ``` + +下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边是在向上“归”的过程中建立的。 + +=== "<1>" + ![built_tree_step1](build_binary_tree.assets/built_tree_step1.png) + +=== "<2>" + ![built_tree_step2](build_binary_tree.assets/built_tree_step2.png) + +=== "<3>" + ![built_tree_step3](build_binary_tree.assets/built_tree_step3.png) + +=== "<4>" + ![built_tree_step4](build_binary_tree.assets/built_tree_step4.png) + +=== "<5>" + ![built_tree_step5](build_binary_tree.assets/built_tree_step5.png) + +=== "<6>" + ![built_tree_step6](build_binary_tree.assets/built_tree_step6.png) + +=== "<7>" + ![built_tree_step7](build_binary_tree.assets/built_tree_step7.png) + +=== "<8>" + ![built_tree_step8](build_binary_tree.assets/built_tree_step8.png) + +=== "<9>" + ![built_tree_step9](build_binary_tree.assets/built_tree_step9.png) + +=== "<10>" + ![built_tree_step10](build_binary_tree.assets/built_tree_step10.png) + +设树的节点数量为 $n$ ,初始化每一个节点(执行一个递归函数 `dfs()` )使用 $O(1)$ 时间。**因此总体时间复杂度为 $O(n)$** 。 + +哈希表存储 `inorder` 元素到索引的映射,空间复杂度为 $O(n)$ 。最差情况下,即二叉树退化为链表时,递归深度达到 $n$ ,使用 $O(n)$ 的栈帧空间。**因此总体空间复杂度为 $O(n)$** 。 diff --git a/chapter_divide_and_conquer/divide_and_conquer.md b/chapter_divide_and_conquer/divide_and_conquer.md new file mode 100644 index 000000000..9ca8d298d --- /dev/null +++ b/chapter_divide_and_conquer/divide_and_conquer.md @@ -0,0 +1,99 @@ +--- +comments: true +--- + +# 12.1.   分治 + +「分治 Divide and Conquer」,全称分而治之,是一种非常重要的算法策略。分治通常基于递归实现,包括“分”和“治”两部分,主要步骤如下: + +1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止; +2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解; + +之前学过的「归并排序」是分治策略的典型应用之一,对于该算法: + +1. **分**:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。 +2. **治**:从底至顶地将有序的子数组进行合并,从而得到有序的原数组。 + +![归并排序的分治策略](divide_and_conquer.assets/divide_and_conquer_merge_sort.png) + +

Fig. 归并排序的分治策略

+ +一个问题是否适合使用分治解决,通常可以参考以下几个判断依据: + +1. **问题可以被分解**:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。 +2. **子问题是独立的**:子问题之间是没有重叠的,互相没有依赖,可以被独立解决。 +3. **子问题的解可以被合并**:原问题的解可以通过合并子问题的解得来。 + +归并排序显然是满足以上三条判断依据的: + +1. 递归地将数组(原问题)划分为两个子数组(子问题),当子数组只有一个元素时天然有序; +2. 每个子数组都可以独立地进行排序,因此子问题是独立的; +3. 两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解); + +## 12.1.1.   通过分治提升效率 + +分治不仅可以有效地解决算法问题,**往往还可以提升算法效率**。在排序算法中,归并排序相较于选择、冒泡、插入排序更快,就是因为其应用了分治策略。 + +那么,我们不禁发问:**为什么分治可以提升算法效率,其底层逻辑是什么**?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这三步为什么比直接解决原问题效率更高?这个问题可以从操作数量和并行计算两方面来讨论。 + +### 操作数量优化 + +以「冒泡排序」为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们把数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((n/2)^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为: + +$$ +O(n + (n/2)^2 \times 2 + n) = O(n^2 / 2 + 2n) +$$ + +![划分数组前后的冒泡排序](divide_and_conquer.assets/divide_and_conquer_bubble_sort.png) + +

Fig. 划分数组前后的冒泡排序

+ +接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数: + +$$ +\begin{aligned} +n^2 & > \frac{n^2}{2} + 2n \newline +n^2 - \frac{n^2}{2} - 2n & > 0 \newline +n(n - 4) & > 0 +\end{aligned} +$$ + +**这意味着当 $n > 4$ 时,划分后的操作数量更少,排序效率可能更高**。当然,划分后的时间复杂度仍然是平方阶 $O(n^2)$ ,即复杂度并没有降低,只是其中的常数项变小了。 + +那么,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这就达到了「归并排序」的情况,时间复杂度为 $O(n \log n)$ 。 + +再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况就与「桶排序」非常类似了,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。 + +### 并行计算优化 + +我们知道,分治生成的子问题是相互独立的,**因此通常可以并行解决**。也就是说,分治不仅可以降低算法的时间复杂度,**还有利于操作系统的并行优化**。 + +并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。 + +例如在桶排序中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。 + +![桶排序的并行计算](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png) + +

Fig. 桶排序的并行计算

+ +## 12.1.2.   分治常见应用 + +一方面,分治可以用来解决许多经典算法问题: + +- **寻找最近点对**:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对。 +- **大整数乘法**:例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法。 +- **矩阵乘法**:例如 Strassen 算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法。 +- **汉诺塔问题**:汉诺塔问题可以视为典型的分治策略,通过递归解决。 +- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。 + +另一方面,**分治在算法和数据结构的设计中应用非常广泛**,举几个已经学过的例子: + +- **二分查找**:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,然后在剩余区间执行相同的二分操作。 +- **归并排序**:文章开头已介绍,不再赘述。 +- **快速排序**:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,然后再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。 +- **桶排序**:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。 +- **树**:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治的应用。 +- **堆**:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。 +- **哈希表**:虽然哈希表来并不直接应用分治,但某些哈希冲突解决策略间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。 + +可以看出,**分治是一种“润物细无声”的算法思想**,隐含在各种算法与数据结构之中。 diff --git a/chapter_divide_and_conquer/index.md b/chapter_divide_and_conquer/index.md new file mode 100644 index 000000000..905d35c54 --- /dev/null +++ b/chapter_divide_and_conquer/index.md @@ -0,0 +1,6 @@ +--- +comments: true +--- + +# 12.   分治 + diff --git a/chapter_dynamic_programming/dp_problem_features.md b/chapter_dynamic_programming/dp_problem_features.md index 45cf52b83..104e45b07 100644 --- a/chapter_dynamic_programming/dp_problem_features.md +++ b/chapter_dynamic_programming/dp_problem_features.md @@ -2,13 +2,13 @@ comments: true --- -# 13.2.   动态规划问题特性 +# 14.2.   动态规划问题特性 在上节中,我们学习了动态规划问题的暴力解法,从递归树中观察到海量的重叠子问题,以及了解到动态规划是如何通过记录解来优化时间复杂度的。 实际上,动态规划最常用来求解最优方案问题,例如寻找最短路径、最大利润、最少时间等。**这类问题不仅包含重叠子问题,往往还具有另外两大特性:最优子结构、无后效性**。 -## 13.2.1.   最优子结构 +## 14.2.1.   最优子结构 我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。 @@ -273,7 +273,7 @@ $$ [class]{}-[func]{minCostClimbingStairsDPComp} ``` -## 13.2.2.   无后效性 +## 14.2.2.   无后效性 「无后效性」是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。 diff --git a/chapter_dynamic_programming/dp_solution_pipeline.md b/chapter_dynamic_programming/dp_solution_pipeline.md index 1e83b344c..ac03ded7f 100644 --- a/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/chapter_dynamic_programming/dp_solution_pipeline.md @@ -2,14 +2,14 @@ comments: true --- -# 13.3.   动态规划解题思路 +# 14.3.   动态规划解题思路 上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题: 1. 如何判断一个问题是不是动态规划问题? 2. 求解动态规划问题该从何处入手,完整步骤是什么? -## 13.3.1.   问题判断 +## 14.3.1.   问题判断 总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解,但我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。 @@ -29,7 +29,7 @@ comments: true 如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并尝试求解它。 -## 13.3.2.   问题求解 +## 14.3.2.   问题求解 动态规划的解题流程可能会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 $dp$ 表,推导状态转移方程,确定边界条件等。 @@ -99,7 +99,7 @@ $$ 接下来,我们就可以实现动态规划代码了。然而,由于子问题分解是一种从顶至底的思想,因此按照“暴力搜索 $\rightarrow$ 记忆化搜索 $\rightarrow$ 动态规划”的顺序实现更加符合思维习惯。 -## 13.3.3.   方法一:暴力搜索 +## 14.3.3.   方法一:暴力搜索 从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素: @@ -240,7 +240,7 @@ $$ 每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。 -## 13.3.4.   方法二:记忆化搜索 +## 14.3.4.   方法二:记忆化搜索 为了避免重复计算重叠子问题,我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,提升搜索效率。 @@ -394,7 +394,7 @@ $$

Fig. 记忆化搜索递归树

-## 13.3.5.   方法三:动态规划 +## 14.3.5.   方法三:动态规划 动态规划代码是从底至顶的,仅需循环即可实现。 diff --git a/chapter_dynamic_programming/edit_distance_problem.md b/chapter_dynamic_programming/edit_distance_problem.md index 5ee238e9a..6141d20f6 100644 --- a/chapter_dynamic_programming/edit_distance_problem.md +++ b/chapter_dynamic_programming/edit_distance_problem.md @@ -2,7 +2,7 @@ comments: true --- -# 13.6.   编辑距离问题 +# 14.6.   编辑距离问题 编辑距离,也被称为 Levenshtein 距离,是两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。 @@ -182,7 +182,31 @@ $$ === "C#" ```csharp title="edit_distance.cs" - [class]{edit_distance}-[func]{editDistanceDP} + /* 编辑距离:动态规划 */ + int editDistanceDP(string s, string t) { + int n = s.Length, m = t.Length; + int[,] dp = new int[n + 1, m + 1]; + // 状态转移:首行首列 + for (int i = 1; i <= n; i++) { + dp[i, 0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0, j] = j; + } + // 状态转移:其余行列 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s[i - 1] == t[j - 1]) { + // 若两字符相等,则直接跳过此两字符 + dp[i, j] = dp[i - 1, j - 1]; + } else { + // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1 + dp[i, j] = Math.Min(Math.Min(dp[i, j - 1], dp[i - 1, j]), dp[i - 1, j - 1]) + 1; + } + } + } + return dp[n, m]; + } ``` === "Swift" @@ -375,7 +399,34 @@ $$ === "C#" ```csharp title="edit_distance.cs" - [class]{edit_distance}-[func]{editDistanceDPComp} + /* 编辑距离:状态压缩后的动态规划 */ + int editDistanceDPComp(string s, string t) { + int n = s.Length, m = t.Length; + int[] dp = new int[m + 1]; + // 状态转移:首行 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 状态转移:其余行 + for (int i = 1; i <= n; i++) { + // 状态转移:首列 + int leftup = dp[0]; // 暂存 dp[i-1, j-1] + dp[0] = i; + // 状态转移:其余列 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s[i - 1] == t[j - 1]) { + // 若两字符相等,则直接跳过此两字符 + dp[j] = leftup; + } else { + // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1 + dp[j] = Math.Min(Math.Min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 更新为下一轮的 dp[i-1, j-1] + } + } + return dp[m]; + } ``` === "Swift" diff --git a/chapter_dynamic_programming/index.md b/chapter_dynamic_programming/index.md index d64e2c983..9adbad847 100644 --- a/chapter_dynamic_programming/index.md +++ b/chapter_dynamic_programming/index.md @@ -2,7 +2,7 @@ comments: true --- -# 13.   动态规划 +# 14.   动态规划
diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming.md b/chapter_dynamic_programming/intro_to_dynamic_programming.md index 2eaba21b2..9dfdf1358 100644 --- a/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -2,7 +2,7 @@ comments: true --- -# 13.1.   初探动态规划 +# 14.1.   初探动态规划 「动态规划 Dynamic Programming」是一种通过将复杂问题分解为更简单的子问题的方式来求解问题的方法。它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。 @@ -177,7 +177,23 @@ comments: true === "Zig" ```zig title="climbing_stairs_backtrack.zig" - [class]{}-[func]{backtrack} + // 回溯 + fn backtrack(choices: []i32, state: i32, n: i32, res: std.ArrayList(i32)) void { + // 当爬到第 n 阶时,方案数量加 1 + if (state == n) { + res.items[0] = res.items[0] + 1; + } + // 遍历所有选择 + for (choices) |choice| { + // 剪枝:不允许越过第 n 阶 + if (state + choice > n) { + break; + } + // 尝试:做出选择,更新状态 + backtrack(choices, state + choice, n, res); + // 回退 + } + } [class]{}-[func]{climbingStairsBacktrack} ``` @@ -190,7 +206,7 @@ comments: true [class]{}-[func]{climbingStairsBacktrack} ``` -## 13.1.1.   方法一:暴力搜索 +## 14.1.1.   方法一:暴力搜索 回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。 @@ -335,7 +351,16 @@ $$ === "Zig" ```zig title="climbing_stairs_dfs.zig" - [class]{}-[func]{dfs} + // 搜索 + fn dfs(i: usize) i32 { + // 已知 dp[1] 和 dp[2] ,返回之 + if (i == 1 or i == 2) { + return @intCast(i); + } + // dp[i] = dp[i-1] + dp[i-2] + var count = dfs(i - 1) + dfs(i - 2); + return count; + } [class]{}-[func]{climbingStairsDFS} ``` @@ -356,7 +381,7 @@ $$ 实际上,**指数阶的时间复杂度是由于「重叠子问题」导致的**。例如,问题 $dp[9]$ 被分解为子问题 $dp[8]$ 和 $dp[7]$ ,问题 $dp[8]$ 被分解为子问题 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ ,而子问题中又包含更小的重叠子问题,子子孙孙无穷尽也,绝大部分计算资源都浪费在这些重叠的问题上。 -## 13.1.2.   方法二:记忆化搜索 +## 14.1.2.   方法二:记忆化搜索 为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。具体来说,考虑借助一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做: @@ -510,7 +535,22 @@ $$ === "Zig" ```zig title="climbing_stairs_dfs_mem.zig" - [class]{}-[func]{dfs} + // 记忆化搜索 + fn dfs(i: usize, mem: []i32) i32 { + // 已知 dp[1] 和 dp[2] ,返回之 + if (i == 1 or i == 2) { + return @intCast(i); + } + // 若存在记录 dp[i] ,则直接返回之 + if (mem[i] != -1) { + return mem[i]; + } + // dp[i] = dp[i-1] + dp[i-2] + var count = dfs(i - 1, mem) + dfs(i - 2, mem); + // 记录 dp[i] + mem[i] = count; + return count; + } [class]{}-[func]{climbingStairsDFSMem} ``` @@ -529,7 +569,7 @@ $$

Fig. 记忆化搜索对应递归树

-## 13.1.3.   方法三:动态规划 +## 14.1.3.   方法三:动态规划 **记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点);最终通过回溯将子问题的解逐层收集,得到原问题的解。 @@ -779,5 +819,5 @@ $$ 总的看来,**子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点**: - 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。例如,归并排序将长数组不断划分为两个短子数组,再将排序好的子数组合并为排序好的长数组。 -- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。因此,动态规划通常会引入记忆化,保存已经解决的子问题的解,避免重复计算。 -- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之后的剩余问题看作为一个子问题。 +- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。 +- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。 diff --git a/chapter_dynamic_programming/knapsack_problem.md b/chapter_dynamic_programming/knapsack_problem.md index 848d5847a..d742e2cf2 100644 --- a/chapter_dynamic_programming/knapsack_problem.md +++ b/chapter_dynamic_programming/knapsack_problem.md @@ -2,7 +2,7 @@ comments: true --- -# 13.4.   0-1 背包问题 +# 14.4.   0-1 背包问题 背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。 @@ -55,7 +55,7 @@ $$ 完成以上三步后,我们可以直接实现从底至顶的动态规划解法。而为了展示本题包含的重叠子问题,本文也同时给出从顶至底的暴力搜索和记忆化搜索解法。 -## 13.4.1.   方法一:暴力搜索 +## 14.4.1.   方法一:暴力搜索 搜索代码包含以下要素: @@ -194,7 +194,7 @@ $$

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

-## 13.4.2.   方法二:记忆化搜索 +## 14.4.2.   方法二:记忆化搜索 为了防止重复求解重叠子问题,我们借助一个记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应解 $dp[i, c]$ 。 @@ -348,7 +348,7 @@ $$

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

-## 13.4.3.   方法三:动态规划 +## 14.4.3.   方法三:动态规划 动态规划解法本质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示。 diff --git a/chapter_dynamic_programming/summary.md b/chapter_dynamic_programming/summary.md index 33a58abc1..73827e24c 100644 --- a/chapter_dynamic_programming/summary.md +++ b/chapter_dynamic_programming/summary.md @@ -2,7 +2,7 @@ comments: true --- -# 13.7.   小结 +# 14.7.   小结 - 动态规划通过将原问题分解为子问题来求解问题,并通过存储子问题的解来规避重复计算,实现高效的计算效率。子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。 - 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。 diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem.md b/chapter_dynamic_programming/unbounded_knapsack_problem.md index 73b5ca822..f464cd2f4 100644 --- a/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -2,11 +2,11 @@ comments: true --- -# 13.5.   完全背包问题 +# 14.5.   完全背包问题 在本节,我们先求解 0-1 背包的一个变种问题:完全背包问题;再了解完全背包的一种特例问题:零钱兑换。 -## 13.5.1.   完全背包问题 +## 14.5.1.   完全背包问题 !!! question @@ -325,7 +325,7 @@ $$ [class]{}-[func]{unboundedKnapsackDPComp} ``` -## 13.5.2.   零钱兑换问题 +## 14.5.2.   零钱兑换问题 背包问题是一大类动态规划问题的代表,其拥有很多的变种,例如零钱兑换问题。 @@ -719,7 +719,7 @@ $$ [class]{}-[func]{coinChangeDPComp} ``` -## 13.5.3.   零钱兑换问题 II +## 14.5.3.   零钱兑换问题 II !!! question diff --git a/chapter_sorting/bucket_sort.md b/chapter_sorting/bucket_sort.md index 3506e2c29..df866b158 100644 --- a/chapter_sorting/bucket_sort.md +++ b/chapter_sorting/bucket_sort.md @@ -367,7 +367,7 @@ comments: true ## 11.8.2.   算法特性 -- **时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历 $n$ 个桶,花费 $O(k)$ 时间。 +- **时间复杂度 $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 + k)$ 、非原地排序** :需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。 - 桶排序是否稳定取决于排序桶内元素的算法是否稳定。