diff --git a/chapter_appendix/contribution.md b/chapter_appendix/contribution.md index 35349488e..791ecf941 100644 --- a/chapter_appendix/contribution.md +++ b/chapter_appendix/contribution.md @@ -2,7 +2,7 @@ comments: true --- -# 13.2. 一起参与创作 +# 14.2. 一起参与创作 !!! success "开源的魅力" @@ -10,7 +10,7 @@ comments: true 由于作者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以帮助其他读者获得更优质的学习资源。所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)将在仓库和网站主页上展示,以感谢他们对开源社区的无私奉献! -## 13.2.1. 内容微调 +## 14.2.1. 内容微调 在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码: @@ -24,7 +24,7 @@ comments: true 由于图片无法直接修改,因此需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述图片问题,我们会尽快重新绘制并替换图片。 -## 13.2.2. 内容创作 +## 14.2.2. 内容创作 如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施 Pull Request 工作流程: @@ -34,7 +34,7 @@ comments: true 4. 将本地所做更改 Commit ,然后 Push 至远程仓库; 5. 刷新仓库网页,点击“Create pull request”按钮即可发起拉取请求; -## 13.2.3. Docker 部署 +## 14.2.3. Docker 部署 我们可以通过 Docker 来部署本项目。执行以下脚本,稍等片刻后,即可使用浏览器打开 `http://localhost:8000` 来访问本项目。 diff --git a/chapter_appendix/installation.md b/chapter_appendix/installation.md index c19463a41..089390319 100644 --- a/chapter_appendix/installation.md +++ b/chapter_appendix/installation.md @@ -2,52 +2,52 @@ comments: true --- -# 13.1. 编程环境安装 +# 14.1. 编程环境安装 -## 13.1.1. VSCode +## 14.1.1. VSCode 本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。 -## 13.1.2. Java 环境 +## 14.1.2. Java 环境 1. 下载并安装 [OpenJDK](https://jdk.java.net/18/)(版本需满足 > JDK 9)。 2. 在 VSCode 的插件市场中搜索 `java` ,安装 Extension Pack for Java 。 -## 13.1.3. C/C++ 环境 +## 14.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 }` 。 -## 13.1.4. Python 环境 +## 14.1.4. Python 环境 1. 下载并安装 [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) 。 2. 在 VSCode 的插件市场中搜索 `python` ,安装 Python Extension Pack 。 3. (可选)在命令行输入 `pip install black` ,安装代码格式化工具。 -## 13.1.5. Go 环境 +## 14.1.5. Go 环境 1. 下载并安装 [go](https://go.dev/dl/) 。 2. 在 VSCode 的插件市场中搜索 `go` ,安装 Go 。 3. 快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。 -## 13.1.6. JavaScript 环境 +## 14.1.6. JavaScript 环境 1. 下载并安装 [node.js](https://nodejs.org/en/) 。 2. 在 VSCode 的插件市场中搜索 `javascript` ,安装 JavaScript (ES6) code snippets 。 3. (可选)在 VSCode 的插件市场中搜索 `Prettier` ,安装代码格式化工具。 -## 13.1.7. C# 环境 +## 14.1.7. C# 环境 1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download) ; 2. 在 VSCode 的插件市场中搜索 `c#` ,安装 c# 。 -## 13.1.8. Swift 环境 +## 14.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)。 -## 13.1.9. Rust 环境 +## 14.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_computational_complexity/space_complexity.md b/chapter_computational_complexity/space_complexity.md index 0d4fff06f..30d622e17 100755 --- a/chapter_computational_complexity/space_complexity.md +++ b/chapter_computational_complexity/space_complexity.md @@ -324,7 +324,7 @@ comments: true ```python title="" def algorithm(n: int) -> None: - a = 0 # O(1) + a = 0 # O(1) b = [0] * 10000 # O(1) if n > 10: nums = [0] * n # O(n) diff --git a/chapter_dynamic_programming/index.md b/chapter_dynamic_programming/index.md new file mode 100644 index 000000000..d64e2c983 --- /dev/null +++ b/chapter_dynamic_programming/index.md @@ -0,0 +1,11 @@ +--- +comments: true +--- + +# 13. 动态规划 + +
Fig. 爬到第 3 阶的方案数量
+ +**不考虑效率的前提下,动态规划问题理论上都可以使用回溯算法解决**,因为回溯算法本质上就是穷举,它能够遍历决策树的所有可能的状态,并从中记录需要的解。 + +对于本题,我们可以将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ 。 + +=== "Java" + + ```java title="climbing_stairs_backtrack.java" + /* 回溯 */ + void backtrack(ListFig. 方案数量递推公式
+ +基于此递推公式,我们可以写出递归代码:以 $dp[n]$ 为起始点,**从顶至底地将一个较大问题拆解为两个较小问题**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。 + +虽然以下代码也属于深度优先搜索,但比标准回溯算法代码简洁很多,这体现了从分治角度考虑这道题的优势。 + +=== "Java" + + ```java title="climbing_stairs_dfs.java" + /* 搜索 */ + int dfs(int i) { + // 已知 dp[1] 和 dp[2] ,返回之 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 爬楼梯:搜索 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs.cpp" + /* 搜索 */ + int dfs(int i) { + // 已知 dp[1] 和 dp[2] ,返回之 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 爬楼梯:搜索 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "Python" + + ```python title="climbing_stairs_dfs.py" + def dfs(i: int) -> int: + """搜索""" + # 已知 dp[1] 和 dp[2] ,返回之 + if i == 1 or i == 2: + return i + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1) + dfs(i - 2) + return count + + def climbing_stairs_dfs(n: int) -> int: + """爬楼梯:搜索""" + return dfs(n) + ``` + +=== "Go" + + ```go title="climbing_stairs_dfs.go" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "JavaScript" + + ```javascript title="climbing_stairs_dfs.js" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "TypeScript" + + ```typescript title="climbing_stairs_dfs.ts" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "C" + + ```c title="climbing_stairs_dfs.c" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dfs.cs" + [class]{climbing_stairs_dfs}-[func]{dfs} + + [class]{climbing_stairs_dfs}-[func]{climbingStairsDFS} + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dfs.swift" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Zig" + + ```zig title="climbing_stairs_dfs.zig" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dfs.dart" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +下图展示了该方法形成的递归树。对于问题 $dp[n]$ ,递归树的深度为 $n$ ,时间复杂度为 $O(2^n)$ 。指数阶的运行时间增长地非常快,如果我们输入一个比较大的 $n$ ,则会陷入漫长的等待之中。 + +![爬楼梯对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png) + +Fig. 爬楼梯对应递归树
+ +实际上,**指数阶的时间复杂度是由于「重叠子问题」导致的**。例如,问题 $dp[9]$ 被分解为子问题 $dp[8]$ 和 $dp[7]$ ,问题 $dp[8]$ 被分解为子问题 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ ,而子问题中又包含更小的重叠子问题,子子孙孙无穷尽也,绝大部分计算资源都浪费在这些重叠的问题上。 + +### 方法二:记忆化搜索 + +为了提升算法效率,**我们希望所有的重叠子问题只被计算一次**。具体来说,考虑借助一个数组 `mem` 来记录每个子问题的解。当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ;当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而实现将重叠子问题剪枝。这种方法被称为“记忆化搜索”。 + +=== "Java" + + ```java title="climbing_stairs_dfs_mem.java" + /* 记忆化搜索 */ + int dfs(int i, int[] mem) { + // 已知 dp[1] 和 dp[2] ,返回之 + if (i == 1 || i == 2) + return i; + // 若存在记录 dp[i] ,则直接返回之 + if (mem[i] != -1) + return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1, mem) + dfs(i - 2, mem); + // 记录 dp[i] + mem[i] = count; + return count; + } + + /* 爬楼梯:记忆化搜索 */ + int climbingStairsDFSMem(int n) { + // mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录 + int[] mem = new int[n + 1]; + Arrays.fill(mem, -1); + return dfs(n, mem); + } + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs_mem.cpp" + /* 记忆化搜索 */ + int dfs(int i, vectorFig. 记忆化搜索对应递归树
+ +### 方法三:动态规划 + +**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点);最终通过回溯将子问题的解逐层收集,得到原问题的解。 + +**我们也可以直接“从底至顶”进行求解**:从最小子问题开始,迭代地求解较大子问题,直至得到原问题的解。这便是动态规划。 + +由于没有回溯过程,动态规划可以直接基于循环实现。我们初始化一个数组 `dp` 来存储子问题的解,从最小子问题开始,逐步求解较大子问题。在以下代码中,数组 `dp` 起到了记忆化搜索中数组 `mem` 相同的记录作用。 + +=== "Java" + + ```java title="climbing_stairs_dp.java" + /* 爬楼梯:动态规划 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 初始化 dp 列表,用于存储子问题的解 + int[] dp = new int[n + 1]; + // 初始状态:预设最小子问题的解 + dp[1] = 1; + dp[2] = 2; + // 状态转移:从较小子问题逐步求解较大子问题 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 爬楼梯:动态规划 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 初始化 dp 列表,用于存储子问题的解 + vectorFig. 爬楼梯的动态规划过程
+ +在动态规划中,我们通常将 $dp$ 数组称为「状态列表」,将最小子问题对应的状态(即 $dp[1]$ , $dp[2]$ )称为「初始状态」,将 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」。这些名词出现频率很高,请你务必理解并记住。 + +细心的你可能发现,由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无需使用一个数组 `dp` 来存储所有状态,只需两个变量滚动前进即可。如以下代码所示,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降低至 $O(1)$ 。 + +=== "Java" + + ```java title="climbing_stairs_dp.java" + /* 爬楼梯:状态压缩后的动态规划 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 爬楼梯:状态压缩后的动态规划 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Python" + + ```python title="climbing_stairs_dp.py" + def climbing_stairs_dp_comp(n: int) -> int: + """爬楼梯:状态压缩后的动态规划""" + if n == 1 or n == 2: + return n + a, b = 1, 2 + for _ in range(3, n + 1): + a, b = b, a + b + return b + ``` + +=== "Go" + + ```go title="climbing_stairs_dp.go" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "JavaScript" + + ```javascript title="climbing_stairs_dp.js" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "TypeScript" + + ```typescript title="climbing_stairs_dp.ts" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "C" + + ```c title="climbing_stairs_dp.c" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dp.cs" + [class]{climbing_stairs_dp}-[func]{climbingStairsDPComp} + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dp.swift" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Zig" + + ```zig title="climbing_stairs_dp.zig" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dp.dart" + [class]{}-[func]{climbingStairsDPComp} + ``` + +**这种做法被称为「状态压缩」**。在许多动态规划问题中,当前状态仅与前面有限个状态有关,不必保存所有的历史状态,这时我们可以通过状态压缩的技巧,只保留必要的状态,通过“降维”来节省内存空间。 + +## 13.1.2. 最优子结构 + +爬楼梯问题很好地展示了重叠子问题。接下来,我们对题目稍作改动,使之更加适合展示最优子结构概念。 + +!!! question "爬楼梯最小代价" + + 给定一个楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 个台阶需要付出的代价,$cost[0]$ 为地面起始点。请计算最少需要付出多少代价才能到达顶部? + +如下图所示,若第 $1$ , $2$ , $3$ 阶的代价分别为 $1$ , $10$ , $1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。 + +![爬到第 3 阶的最小代价](intro_to_dynamic_programming.assets/min_cost_cs_example.png) + +Fig. 爬到第 3 阶的最小代价
+ +设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个,即: + +$$ +dp[i] = \min(dp[i-1], dp[i-2]) + cost[i] +$$ + +这便可以引出「最优子结构」的含义:**原问题的最优解是从子问题的最优解构建得来的**。对于本题,我们从两个子问题最优解 $dp[i-1]$ , $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。 + +那么,上道爬楼梯题目有没有最优子结构呢?它要求解的是方案数量,看似是一个计数问题,但如果换一种问法:求解最大方案数量。我们惊喜地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的是一个比较宽泛的概念,在不同问题中会有不同的含义。 + +根据以上状态转移方程,以及初始状态 $dp[1] = cost[1]$ , $dp[2] = cost[2]$ ,我们可以得出动态规划解题代码。 + +=== "Java" + + ```java title="min_cost_climbing_stairs_dp.java" + /* 爬楼梯最小代价:动态规划 */ + int minCostClimbingStairsDP(int[] cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) + return cost[n]; + // 初始化 dp 列表,用于存储子问题的解 + int[] dp = new int[n + 1]; + // 初始状态:预设最小子问题的解 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 状态转移:从较小子问题逐步求解较大子问题 + for (int i = 3; i <= n; i++) { + dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 爬楼梯最小代价:动态规划 */ + int minCostClimbingStairsDP(vectorFig. 爬楼梯最小代价的动态规划过程
+ +这道题同样也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降低至 $O(1)$ 。 + +=== "Java" + + ```java title="min_cost_climbing_stairs_dp.java" + /* 爬楼梯最小代价:状态压缩后的动态规划 */ + int minCostClimbingStairsDPComp(int[] cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) + return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = Math.min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 爬楼梯最小代价:状态压缩后的动态规划 */ + int minCostClimbingStairsDPComp(vector