From d517b5317089ff760193d3f9cdbb241827104220 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Tue, 9 Sep 2025 18:05:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/06_graph/06_01_graph_basic.md | 4 +- .../07_algorithm/07_02_recursive_algorithm.md | 2 +- .../08_01_dynamic_programming_basic.md | 254 +++++++----------- .../08_02_memoization_search.md | 143 +++++----- 4 files changed, 171 insertions(+), 232 deletions(-) diff --git a/docs/06_graph/06_01_graph_basic.md b/docs/06_graph/06_01_graph_basic.md index ead8e32e..9266a825 100644 --- a/docs/06_graph/06_01_graph_basic.md +++ b/docs/06_graph/06_01_graph_basic.md @@ -53,8 +53,8 @@ 根据图中是否存在环,可以将图分为「环形图」和「无环图」: -- **环形图(Circular Graph)**:如果图中至少存在一条环,则称为环形图。 -- **无环图(Acyclic Graph)**:如果图中不存在任何环,则称为无环图。 +- **环形图(Cyclic Graph)**:如果图中至少存在一条环(Cycle),则称该图为环形图或含环图。 +- **无环图(Acyclic Graph)**:如果图中不存在任何环,则称该图为无环图。 对于有向图,如果不存在环,则称为「有向无环图(Directed Acyclic Graph, DAG)」。DAG 结构在动态规划、最短路径、数据压缩等算法中有着广泛应用。 diff --git a/docs/07_algorithm/07_02_recursive_algorithm.md b/docs/07_algorithm/07_02_recursive_algorithm.md index 2f18770d..866ef5d1 100644 --- a/docs/07_algorithm/07_02_recursive_algorithm.md +++ b/docs/07_algorithm/07_02_recursive_algorithm.md @@ -4,7 +4,7 @@ 以阶乘为例,数学定义如下: -$fact(n) = \begin{cases} 1 & \text{n = 0} \cr n \times fact(n - 1) & \text{n> 0} \end{cases}$ +$$fact(n) = \begin{cases} 1 & \text{n = 0} \cr n \times fact(n - 1) & \text{n> 0} \end{cases}$$ 我们可以直接用调用函数自身的方式实现阶乘函数 $fact(n),ドル代码如下: diff --git a/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md b/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md index 4d4e4402..23f48991 100644 --- a/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md +++ b/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md @@ -2,136 +2,136 @@ ### 1.1 动态规划的定义 -> **动态规划(Dynamic Programming)**:简称 **DP**,是一种求解多阶段决策过程最优化问题的方法。在动态规划中,通过把原问题分解为相对简单的子问题,先求解子问题,再由子问题的解而得到原问题的解。 +> **动态规划(Dynamic Programming,DP)**:是一种通过将复杂问题分解为若干相互重叠的子问题,逐步求解并保存子问题结果,最终获得原问题最优解的方法。动态规划强调阶段性决策和结果复用,避免重复计算,从而高效解决多阶段最优化问题。 -动态规划最早由理查德 · 贝尔曼于 1957 年在其著作「动态规划(Dynamic Programming)」一书中提出。这里的 Programming 并不是编程的意思,而是指一种「表格处理方法」,即将每一步计算的结果存储在表格中,供随后的计算查询使用。 +动态规划由理查德·贝尔曼于 1957 年提出。这里的 Programming 指的是「规划」或「表格法」,即把每一步的计算结果记录下来,避免重复计算。 ### 1.2 动态规划的核心思想 > **动态规划的核心思想**: > -> 1. 把「原问题」分解为「若干个重叠的子问题」,每个子问题的求解过程都构成一个 **「阶段」**。在完成一个阶段的计算之后,动态规划方法才会执行下一个阶段的计算。 -> 2. 在求解子问题的过程中,按照「自顶向下的记忆化搜索方法」或者「自底向上的递推方法」求解出「子问题的解」,把结果存储在表格中,当需要再次求解此子问题时,直接从表格中查询该子问题的解,从而避免了大量的重复计算。 +> 1. 将复杂的原问题拆解为若干个相互重叠的子问题,每个子问题的求解过程可视为一个 **「阶段」**。每完成一个阶段的计算,才进入下一个阶段。 +> 2. 在求解子问题时,采用「自顶向下的记忆化搜索」或「自底向上的递推」方式,将每个子问题的解保存到表格(数组 / 哈希表)中。这样,当后续需要用到某个子问题的解时,可以直接查表,避免重复计算。 -这看起来很像是分治算法,但动态规划与分治算法的不同点在于: +动态规划与分治算法类似,都采用「分而治之」的思想,但两者的区别在于: -1. 适用于动态规划求解的问题,在分解之后得到的子问题往往是相互联系的,会出现若干个重叠子问题。 -2. 使用动态规划方法会将这些重叠子问题的解保存到表格里,供随后的计算查询使用,从而避免大量的重复计算。 +1. 动态规划适用于子问题之间存在重叠的场景,即分解后得到的子问题并非完全独立,而是相互关联、会被多次重复求解。 +2. 动态规划通过将这些重叠子问题的解存储下来,后续直接复用,极大减少了重复计算,提高了效率。 ### 1.3 动态规划的简单例子 -下面我们先来通过一个简单的例子来介绍一下什么是动态规划算法,然后再来讲解动态规划中的各种术语。 +我们先通过一个经典例子直观理解动态规划的思想,再进一步介绍相关术语。 -> **斐波那契数列**:数列由 $f(0) = 1, f(1) = 2$ 开始,后面的每一项数字都是前面两项数字的和。也就是: +> **斐波那契数列**:该数列以 $f(0) = 0, f(1) = 1$ 为起点,每一项均为前两项之和,即: > -> $f(n) = \begin{cases} 0 & n = 0 \cr 1 & n = 1 \cr f(n - 2) + f(n - 1) & n> 1 \end{cases}$ +> $f(n) = \begin{cases} 0 & n = 0 \\ 1 & n = 1 \\ f(n - 2) + f(n - 1) & n> 1 \end{cases}$ -通过公式 $f(n) = f(n - 2) + f(n - 1),ドル我们可以将原问题 $f(n)$ 递归地划分为 $f(n - 2)$ 和 $f(n - 1)$ 这两个子问题。其对应的递归过程如下图所示: +根据递推公式 $f(n) = f(n-2) + f(n-1),ドル我们可以将原问题 $f(n)$ 递归的拆解为两个子问题 $f(n-2)$ 和 $f(n-1)$。如下图所示: ![斐波那契数列的重复计算项](https://qcdn.itcharge.cn/images/20230307164107.png) -从图中可以看出:如果使用传统递归算法计算 $f(5),ドル需要先计算 $f(3)$ 和 $f(4),ドル而在计算 $f(4)$ 时还需要计算 $f(3),ドル这样 $f(3)$ 就进行了多次计算。同理 $f(0)$、$f(1)$、$f(2)$ 都进行了多次计算,从而导致了重复计算问题。 +从图中可以看到,如果直接递归计算 $f(5),ドル会多次重复计算 $f(3)$、$f(2)$ 等子问题,导致效率低下。 -为了避免重复计算,我们可以使用动态规划中的「表格处理方法」来处理。 +为避免重复计算,动态规划采用「表格法」:将每个子问题的结果记录下来,后续直接查表即可。 -这里我们使用「自底向上的递推方法」求解出子问题 $f(n - 2)$ 和 $f(n - 1)$ 的解,然后把结果存储在表格中,供随后的计算查询使用。具体过程如下: +以「自底向上」的递推方式为例,步骤如下: -1. 定义一个数组 $dp,ドル用于记录斐波那契数列中的值。 +1. 定义数组 $dp,ドル用于存储斐波那契数列的各项值。 2. 初始化 $dp[0] = 0, dp[1] = 1$。 -3. 根据斐波那契数列的递推公式 $f(n) = f(n - 1) + f(n - 2),ドル从 $dp(2)$ 开始递推计算斐波那契数列的每个数,直到计算出 $dp(n)$。 -4. 最后返回 $dp(n)$ 即可得到第 $n$ 项斐波那契数。 +3. 按递推公式 $f(n) = f(n-1) + f(n-2),ドル从 $dp[2]$ 开始依次计算,直到 $dp[n]$。 +4. 返回 $dp[n],ドル即为第 $n$ 项斐波那契数。 具体代码如下: ```python class Solution: def fib(self, n: int) -> int: + # 边界情况:n 为 0 时,斐波那契数为 0 if n == 0: return 0 + # 边界情况:n 为 1 时,斐波那契数为 1 if n == 1: return 1 + # 初始化dp数组,dp[i] 表示第 i 个斐波那契数 dp = [0 for _ in range(n + 1)] - dp[0] = 0 - dp[1] = 1 + dp[0] = 0 # 第 0 项 + dp[1] = 1 # 第 1 项 + # 自底向上递推,依次计算每一项 for i in range(2, n + 1): + # 状态转移方程:第 i 项等于前两项之和 dp[i] = dp[i - 2] + dp[i - 1] + # 返回第 n 项斐波那契数 return dp[n] ``` -这种使用缓存(哈希表、集合或数组)保存计算结果,从而避免子问题重复计算的方法,就是「动态规划算法」。 +这种通过缓存(如哈希表、集合或数组)存储已计算结果,避免对子问题的重复求解的方法,就是「动态规划」的核心思想。 -## 2. 动态规划的特征 +## 2. 动态规划的三大特征 -究竟什么样的问题才可以使用动态规划算法解决呢? +什么样的问题适合用动态规划解决?本质上,只有同时满足以下三个特征的问题,才适合用动态规划方法高效求解: -首先,能够使用动态规划方法解决的问题必须满足以下三个特征: - -1. **最优子结构性质** -2. **重叠子问题性质** +1. **最优子结构** +2. **重叠子问题** 3. **无后效性** -### 2.1 最优子结构性质 - -> **最优子结构**:指的是一个问题的最优解包含其子问题的最优解。 +### 2.1 最优子结构 -举个例子,如下图所示,原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace,ドル在 $a_1$ 步我们选出一个当前最优解之后,问题就转换为求解子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$。如果原问题 $S$ 的最优解可以由「第 $a_1$ 步得到的局部最优解」和「 $S_{\text{子问题}}$ 的最优解」构成,则说明该问题满足最优子结构性质。 +> **最优子结构**:即一个问题的最优解可以由其子问题的最优解推导得到。 -也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。 +通俗来说,如果我们能把原问题分解为若干子问题,并且原问题的最优解一定包含这些子问题的最优解,那么该问题就具备最优子结构。例如,设原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace,ドル在第 $a_1$ 步选出当前最优后,剩下的子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$ 的最优解也必须包含在整体最优解中。 ![最优子结构性质](https://qcdn.itcharge.cn/images/20240513163310.png) -### 2.2 重叠子问题性质 +### 2.2 重叠子问题 -> **重叠子问题性质**:指的是在求解子问题的过程中,有大量的子问题是重复的,一个子问题在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对其求解一次,然后用表格将结果存储下来,以后使用时可以直接查询,不需要再次求解。 +> **重叠子问题**:即在递归求解过程中,会反复遇到相同的子问题。 -![重叠子问题性质](https://qcdn.itcharge.cn/images/20230307164107.png) +动态规划与分治法的最大区别在于,动态规划适用于子问题会被多次重复计算的场景。比如斐波那契数列,$f(2)$、$f(3)$ 等子问题会被多次递归调用。动态规划通过将子问题的解存储下来(如用数组或哈希表),避免了重复计算,大大提升了效率。 -之前我们提到的「斐波那契数列」例子中,$f(0)$、$f(1)$、$f(2)$、$f(3)$ 都进行了多次重复计算。动态规划算法利用了子问题重叠的性质,在第一次计算 $f(0)$、$f(1)$、$f(2)$、$f(3)$ 时就将其结果存入表格,当再次使用时可以直接查询,无需再次求解,从而提升效率。 +![重叠子问题性质](https://qcdn.itcharge.cn/images/20230307164107.png) ### 2.3 无后效性 -> **无后效性**:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。 +> **无后效性**:指某一阶段的状态一旦确定,不会被后续阶段的决策所影响。 -也就是说,**一旦某一个子问题的求解结果确定以后,就不会再被修改**。 +也就是说,**子问题的解一旦确定,就不会再被修改**。例如,在有向无环图中求最短路径时,假设已知 $A$ 到 $D$ 的最短路径为 9ドル,ドル那么无论后续如何选择路径,都不会影响 $A$ 到 $D$ 的最短路径长度。这种前面的决策不会被后面的决策反悔就是「无后效性」。 -举个例子,下图是一个有向无环带权图,我们在求解从 $A$ 点到 $F$ 点的最短路径问题时,假设当前已知从 $A$ 点到 $D$ 点的最短路径(2ドル + 7 = 9$)。那么无论之后的路径如何选择,都不会影响之前从 $A$ 点到 $D$ 点的最短路径长度。这就是「无后效性」。 - -而如果一个问题具有「后效性」,则可能需要先将其转化或者逆向求解来消除后效性,然后才可以使用动态规划算法。 +如果问题存在「后效性」,则需要先消除后效性(如逆序建模),否则无法直接用动态规划求解。 ![无后效性](https://qcdn.itcharge.cn/images/20240514110127.png) ## 3. 动态规划的基本思路 -如下图所示,我们在使用动态规划方法解决某些最优化问题时,可以将解决问题的过程按照一定顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。然后按照顺序对每一个阶段做出「决策」,这个决策既决定了本阶段的效益,也决定了下一阶段的初始状态。依次做完每个阶段的决策之后,就得到了一个整个问题的决策序列。 +如下图所示,动态规划在解决最优化问题时,通常会将整个求解过程按照一定的顺序(如时间、空间等)划分为若干相互关联的「阶段」。在每个阶段,我们需要做出一个「决策」,该决策不仅影响当前阶段的收益,还会决定下一阶段的初始状态。依次完成每个阶段的决策后,就形成了从起点到终点的完整决策序列。 -这样就将一个原问题分解为了一系列的子问题,再通过逐步求解从而获得最终结果。 +通过这种方式,原问题被拆解为一系列相互联系的子问题,逐步递推求解,最终获得整体问题的最优解。 ![动态规划方法](https://qcdn.itcharge.cn/images/20240514110154.png) -这种前后关联、具有链状结构的多阶段进行决策的问题也叫做「多阶段决策问题」。 +这种具有前后依赖关系、链式结构的多阶段决策问题,通常被称为「多阶段决策问题」。 + +动态规划的基本步骤如下: -通常我们使用动态规划方法来解决问题的基本思路如下: +1. **划分阶段**:将原问题按照某种顺序(如时间、空间等)分解为若干有序的阶段。每个阶段对应一个子问题,阶段之间存在明确的先后关系。 +2. **定义状态**:用适当的变量(如位置、数量、容量等)描述每个阶段的状态。状态的设计需满足无后效性,即一旦确定不会被后续决策影响。 +3. **状态转移方程**:根据前一阶段的状态和当前可做的决策,推导出当前阶段的状态。即通过状态转移方程描述各阶段状态之间的递推关系。 +4. **初始条件与边界条件**:结合问题描述、状态定义和状态转移方程,明确初始状态和边界情况,为递推提供基础。 +5. **确定最终结果**:根据递推过程和问题目标,提取或计算出最终所需的答案。 -1. **划分阶段**:将原问题按顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。划分后的阶段一定是有序或可排序的,否则问题无法求解。 - - 这里的「阶段」指的是子问题的求解过程。每个子问题的求解过程都构成一个「阶段」,在完成前一阶段的求解后才会进行后一阶段的求解。 -2. **定义状态**:将和子问题相关的某些变量(位置、数量、体积、空间等等)作为一个「状态」表示出来。状态的选择要满足无后效性。 - - 一个「状态」对应一个或多个子问题,所谓某个「状态」下的值,指的就是这个「状态」所对应的子问题的解。 -3. **状态转移**:根据「上一阶段的状态」和「该状态下所能做出的决策」,推导出「下一阶段的状态」。或者说根据相邻两个阶段各个状态之间的关系,确定决策,然后推导出状态间的相互转移方式(即「状态转移方程」)。 -4. **初始条件和边界条件**:根据问题描述、状态定义和状态转移方程,确定初始条件和边界条件。 -5. **最终结果**:确定问题的求解目标,然后按照一定顺序求解每一个阶段的问题。最后根据状态转移方程的递推结果,确定最终结果。 +通过上述步骤,可以系统性地将原问题拆解、建模并高效求解。 ## 4. 动态规划的应用 -动态规划相关的问题往往灵活多变,思维难度大,没有特别明显的套路,并且经常会在各类算法竞赛和面试中出现。 +动态规划问题通常灵活多变,思维难度较高,缺乏固定的解题模板,因此在各类算法竞赛和面试中频繁出现。 -动态规划问题的关键点在于「如何状态设计」和「推导状态转移条件」,还有各种各样的「优化方法」。这类问题一定要多练习、多总结,只有接触的题型多了,才能熟练掌握动态规划思想。 +解决动态规划问题的核心在于「状态的设计」与「状态转移方程的推导」,同时还需掌握多种优化技巧。要想真正掌握动态规划,必须通过大量练习和总结,积累不同题型的经验,才能灵活运用动态规划思想。 -下面来介绍几道关于动态规划的基础题目。 +下面将介绍几道动态规划的基础题目,帮助大家理解和掌握基本方法。 -### 4.1 斐波那契数 +### 4.1 经典例题:斐波那契数 #### 4.1.1 题目链接 @@ -172,47 +172,50 @@ class Solution: ###### 1. 划分阶段 -我们可以按照整数顺序进行阶段划分,将其划分为整数 0ドル \sim n$。 +将问题按整数顺序分为 0ドル$ 到 $n$ 共 $n + 1$ 个阶段,每个阶段对应一个斐波那契数的下标。 ###### 2. 定义状态 -定义状态 $dp[i]$ 为:第 $i$ 个斐波那契数。 +令 $dp[i]$ 表示第 $i$ 个斐波那契数的值。 ###### 3. 状态转移方程 -根据题目中所给的斐波那契数列的定义 $f(n) = f(n - 1) + f(n - 2),ドル则直接得出状态转移方程为 $dp[i] = dp[i - 1] + dp[i - 2]$。 +依据斐波那契数列的递推关系 $f(n) = f(n - 1) + f(n - 2),ドル可得状态转移方程:$dp[i] = dp[i - 1] + dp[i - 2]$。 ###### 4. 初始条件 -根据题目中所给的初始条件 $f(0) = 0, f(1) = 1$ 确定动态规划的初始条件,即 $dp[0] = 0, dp[1] = 1$。 +根据定义,$dp[0] = 0,ドル$dp[1] = 1$。 ###### 5. 最终结果 -根据状态定义,最终结果为 $dp[n],ドル即第 $n$ 个斐波那契数为 $dp[n]$。 +最终答案为 $dp[n],ドル即第 $n$ 个斐波那契数。 #### 4.1.4 代码 ```python class Solution: def fib(self, n: int) -> int: + # 边界情况,n 为 0 或 1 时直接返回 n if n <= 1: return n + # 初始化 dp 数组,长度为 n+1,dp[i] 表示第 i 个斐波那契数 dp = [0 for _ in range(n + 1)] - dp[0] = 0 - dp[1] = 1 + dp[0] = 0 # 第 0 个斐波那契数为 0 + dp[1] = 1 # 第 1 个斐波那契数为 1 + # 状态转移,从第 2 项开始递推 for i in range(2, n + 1): - dp[i] = dp[i - 2] + dp[i - 1] + dp[i] = dp[i - 2] + dp[i - 1] # 状态转移方程 - return dp[n] + return dp[n] # 返回第 n 个斐波那契数 ``` #### 4.1.5 复杂度分析 -- **时间复杂度**:$O(n)$。一重循环遍历的时间复杂度为 $O(n)$。 -- **空间复杂度**:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为 $O(n)$。 +- **时间复杂度**:$O(n)$。仅需一次循环遍历所有状态,整体时间复杂度为 $O(n)$。 +- **空间复杂度**:$O(n)$。由于使用了一维数组 $dp$ 存储每个阶段的状态,因此空间复杂度为 $O(n)$。 -### 4.2 爬楼梯 +### 4.2 经典例题:爬楼梯 #### 4.2.1 题目链接 @@ -255,134 +258,57 @@ class Solution: ###### 1. 划分阶段 -我们按照台阶的阶层划分阶段,将其划分为 0ドル \sim n$ 阶。 +将问题按台阶数分阶段,阶段范围为 0ドル \sim n$ 阶。 ###### 2. 定义状态 -定义状态 $dp[i]$ 为:爬到第 $i$ 阶台阶的方案数。 +设 $dp[i]$ 表示到达第 $i$ 阶台阶的不同方法数。 ###### 3. 状态转移方程 -根据题目大意,每次只能爬 1ドル$ 或 2ドル$ 个台阶。则第 $i$ 阶楼梯只能从第 $i - 1$ 阶向上爬 1ドル$ 阶上来,或者从第 $i - 2$ 阶向上爬 2ドル$ 阶上来。所以可以推出状态转移方程为 $dp[i] = dp[i - 1] + dp[i - 2]$。 +每次只能爬 1ドル$ 或 2ドル$ 个台阶,因此到达第 $i$ 阶的方法可以从第 $i - 1$ 阶爬 1ドル$ 阶,或从第 $i - 2$ 阶爬 2ドル$ 阶而来。状态转移方程为:$dp[i] = dp[i - 1] + dp[i - 2]$。 ###### 4. 初始条件 -- 第 0ドル$ 层台阶方案数:可以看做 1ドル$ 种方法(从 0ドル$ 阶向上爬 0ドル$ 阶),即 $dp[1] = 1$。 -- 第 1ドル$ 层台阶方案数:1ドル$ 种方法(从 0ドル$ 阶向上爬 1ドル$ 阶),即 $dp[1] = 1$。 -- 第 2ドル$ 层台阶方案数:2ドル$ 中方法(从 0ドル$ 阶向上爬 2ドル$ 阶,或者从 1ドル$ 阶向上爬 1ドル$ 阶)。 +- $dp[0] = 1$:从地面(第 0ドル$ 阶)出发,有 1ドル$ 种方式(不动)。 +- $dp[1] = 1$:只爬 1ドル$ 阶,有 1ドル$ 种方式。 +- $dp[2] = 2$:可以一次爬 2ドル$ 阶,或两次各爬 1ドル$ 阶,共 2ドル$ 种方式。 ###### 5. 最终结果 -根据状态定义,最终结果为 $dp[n],ドル即爬到第 $n$ 阶台阶(即楼顶)的方案数为 $dp[n]$。 +最终答案为 $dp[n],ドル即到达第 $n$ 阶(楼顶)的方案数。 -虽然这道题跟上一道题的状态转移方程都是 $dp[i] = dp[i - 1] + dp[i - 2],ドル但是两道题的考察方式并不相同,一定程度上也可以看出来动态规划相关题目的灵活多变。 +虽然本题与斐波那契数列的状态转移方程相同($dp[i] = dp[i - 1] + dp[i - 2]$),但考察的实际场景和建模方式有所不同,体现了动态规划在不同问题中的灵活应用。 #### 4.2.4 代码 ```python class Solution: def climbStairs(self, n: int) -> int: + # 初始化 dp 数组,dp[i] 表示到达第 i 阶的方法数 dp = [0 for _ in range(n + 1)] - dp[0] = 1 - dp[1] = 1 + dp[0] = 1 # 到达第 0 阶只有 1 种方式(不动) + dp[1] = 1 # 到达第 1 阶只有 1 种方式(一步到达) + # 从第 2 阶开始,依次计算每一阶的方案数 for i in range(2, n + 1): + # 状态转移:到达第 i 阶的方法数 = 到达第 i - 1 阶的方法数 + 到达第 i - 2 阶的方法数 dp[i] = dp[i - 1] + dp[i - 2] - return dp[n] + return dp[n] # 返回到达第 n 阶的方法总数 ``` #### 4.2.5 复杂度分析 -- **时间复杂度**:$O(n)$。一重循环遍历的时间复杂度为 $O(n)$。 -- **空间复杂度**:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为 $O(n)$。因为 $dp[i]$ 的状态只依赖于 $dp[i - 1]$ 和 $dp[i - 2],ドル所以可以使用 3ドル$ 个变量来分别表示 $dp[i]$、$dp[i - 1]$、$dp[i - 2],ドル从而将空间复杂度优化到 $O(1)$。 - -### 4.3 不同路径 - -#### 4.3.1 题目链接 - -- [62. 不同路径 - 力扣](https://leetcode.cn/problems/unique-paths/) - -#### 4.3.2 题目大意 - -**描述**:给定两个整数 $m$ 和 $n,ドル代表大小为 $m \times n$ 的棋盘, 一个机器人位于棋盘左上角的位置,机器人每次只能向右、或者向下移动一步。 +- **时间复杂度**:为 $O(n),ドル因为只需一重循环遍历 $n$ 次即可完成计算。 +- **空间复杂度**:为 $O(n),ドル由于需要一个长度为 $n + 1$ 的一维数组来保存每一级台阶的状态。不过,由于每次状态转移只依赖于前两项 $dp[i - 1]$ 和 $dp[i - 2],ドル因此可以进一步优化,只用三个变量分别记录当前和前两步的状态,将空间复杂度降为 $O(1)$。 -**要求**:计算出机器人从棋盘左上角到达棋盘右下角一共有多少条不同的路径。 +## 5. 总结 -**说明**: - -- 1ドル \le m, n \le 100$。 -- 题目数据保证答案小于等于 2ドル \times 10^9$。 - -**示例**: - -- 示例 1: - -```python -输入:m = 3, n = 7 -输出:28 -``` - -- 示例 2: - -```python -输入:m = 3, n = 2 -输出:3 -解释: -从左上角开始,总共有 3 条路径可以到达右下角。 -1. 向右 -> 向下 -> 向下 -2. 向下 -> 向下 -> 向右 -3. 向下 -> 向右 -> 向下 -``` - -![](https://assets.leetcode.com/uploads/2018/10/22/robot_maze.png) - -#### 4.3.3 解题思路 - -###### 1. 划分阶段 - -按照路径的结尾位置(行位置、列位置组成的二维坐标)进行阶段划分。 - -###### 2. 定义状态 - -定义状态 $dp[i][j]$ 为:从左上角到达 $(i, j)$ 位置的路径数量。 - -###### 3. 状态转移方程 - -因为我们每次只能向右、或者向下移动一步,因此想要走到 $(i, j),ドル只能从 $(i - 1, j)$ 向下走一步走过来;或者从 $(i, j - 1)$ 向右走一步走过来。所以可以写出状态转移方程为:$dp[i][j] = dp[i - 1][j] + dp[i][j - 1],ドル此时 $i> 0, j> 0$。 - -###### 4. 初始条件 - -- 从左上角走到 $(0, 0)$ 只有一种方法,即 $dp[0][0] = 1$。 -- 第一行元素只有一条路径(即只能通过前一个元素向右走得到),所以 $dp[0][j] = 1$。 -- 同理,第一列元素只有一条路径(即只能通过前一个元素向下走得到),所以 $dp[i][0] = 1$。 - -###### 5. 最终结果 - -根据状态定义,最终结果为 $dp[m - 1][n - 1],ドル即从左上角到达右下角 $(m - 1, n - 1)$ 位置的路径数量为 $dp[m - 1][n - 1]$。 - -#### 4.3.4 代码 - -```python -class Solution: - def uniquePaths(self, m: int, n: int) -> int: - dp = [[0 for _ in range(n)] for _ in range(m)] - - for j in range(n): - dp[0][j] = 1 - for i in range(m): - dp[i][0] = 1 - - for i in range(1, m): - for j in range(1, n): - dp[i][j] = dp[i - 1][j] + dp[i][j - 1] - - return dp[m - 1][n - 1] -``` +动态规划通过「阶段 — 状态 — 转移」的框架,将含有重叠子问题的最优化任务转化为可递推求解的表格计算。它适用于同时满足最优子结构、重叠子问题与无后效性的场景,核心在于合理设计状态与状态转移方程,并明确初始与边界条件。 -#### 4.3.5 复杂度分析 +实际解题时,先判断是否符合三大特征,再按步骤完成:划分阶段、定义状态、写出转移、设定初始 / 边界、提取答案。实现可采用自顶向下(记忆化)或自底向上(迭代)两种方式,并视依赖关系进行空间优化。 -- **时间复杂度**:$O(m \times n)$。初始条件赋值的时间复杂度为 $O(m + n),ドル两重循环遍历的时间复杂度为 $O(m \times n),ドル所以总体时间复杂度为 $O(m \times n)$。 -- **空间复杂度**:$O(m \times n)$。用到了二维数组保存状态,所以总体空间复杂度为 $O(m \times n)$。因为 $dp[i][j]$ 的状态只依赖于上方值 $dp[i - 1][j]$ 和左侧值 $dp[i][j - 1],ドル而我们在进行遍历时的顺序刚好是从上至下、从左到右。所以我们可以使用长度为 $n$ 的一维数组来保存状态,从而将空间复杂度优化到 $O(n)$。 +本文以斐波那契与爬楼梯为例,展示了从建模到实现的完整路径。理解这些基本范式后,多练习与归纳是提升动态规划能力的关键,尤其要学会在不同问题背景下复用"状态设计+转移关系"的思维模板。 ## 题目练习 diff --git a/docs/08_dynamic_programming/08_02_memoization_search.md b/docs/08_dynamic_programming/08_02_memoization_search.md index 7f8936a4..b4a2a086 100644 --- a/docs/08_dynamic_programming/08_02_memoization_search.md +++ b/docs/08_dynamic_programming/08_02_memoization_search.md @@ -1,79 +1,79 @@ ## 1. 记忆化搜索简介 ->**记忆化搜索(Memoization Search)**:是一种通过存储已经遍历过的状态信息,从而避免对同一状态重复遍历的搜索算法。 +> **记忆化搜索(Memoization Search)**:是一种通过记录已访问状态的结果,避免对同一状态进行重复计算的高效搜索方法。 -记忆化搜索是动态规划的一种实现方式。在记忆化搜索中,当算法需要计算某个子问题的结果时,它首先检查是否已经计算过该问题。如果已经计算过,则直接返回已经存储的结果;否则,计算该问题,并将结果存储下来以备将来使用。 +记忆化搜索本质上是动态规划的一种实现方式。其核心思想是在递归求解子问题时,先判断该子问题是否已经被计算过——如果有,则直接返回缓存结果;如果没有,则递归计算,并将结果存入缓存,供后续复用。 -举个例子,比如「斐波那契数列」的定义是:$f(0) = 0, f(1) = 1, f(n) = f(n - 1) + f(n - 2)$。如果我们使用递归算法求解第 $n$ 个斐波那契数,则对应的递推过程如下: +以「斐波那契数列」为例,其定义为:$f(0) = 0, f(1) = 1, f(n) = f(n - 1) + f(n - 2)$。如果直接递归求解第 $n$ 项,递归树会出现大量重复计算: ![记忆化搜索](https://qcdn.itcharge.cn/images/20240514110503.png) -从图中可以看出:如果使用普通递归算法,想要计算 $f(5),ドル需要先计算 $f(3)$ 和 $f(4),ドル而在计算 $f(4)$ 时还需要计算 $f(3)$。这样 $f(3)$ 就进行了多次计算,同理 $f(0)$、$f(1)$、$f(2)$ 都进行了多次计算,从而导致了重复计算问题。 +如上图所示,普通递归在计算 $f(5)$ 时,会多次计算 $f(3)$、$f(2)$ 等子问题,导致效率低下。 -为了避免重复计算,在递归的同时,我们可以使用一个缓存(数组或哈希表)来保存已经求解过的 $f(k)$ 的结果。如上图所示,当递归调用用到 $f(k)$ 时,先查看一下之前是否已经计算过结果,如果已经计算过,则直接从缓存中取值返回,而不用再递推下去,这样就避免了重复计算问题。 +为避免这种重复,记忆化搜索在递归过程中引入缓存(如数组或哈希表),每当递归到 $f(k)$ 时,先检查缓存中是否已有结果——若有则直接返回,无需再次递归。这样即可显著减少重复计算,提高效率。 -使用「记忆化搜索」方法解决斐波那契数列的代码如下: +下面是用记忆化搜索实现斐波那契数列的示例代码: ```python class Solution: def fib(self, n: int) -> int: - # 使用数组保存已经求解过的 f(k) 的结果 + # 初始化备忘录数组,memo[i] 用于存储 f(i) 的计算结果,避免重复递归 memo = [0 for _ in range(n + 1)] return self.my_fib(n, memo) def my_fib(self, n: int, memo: List[int]) -> int: + # 边界情况:n 为 0 时,斐波那契数为 0 if n == 0: return 0 + # 边界情况:n 为 1 时,斐波那契数为 1 if n == 1: return 1 - # 已经计算过结果 + # 如果 f(n) 已经计算过,直接返回缓存结果,避免重复计算 if memo[n] != 0: return memo[n] - # 没有计算过结果 + # 递归计算 f(n - 1) 和 f(n - 2),并将结果存入备忘录 memo[n] = self.my_fib(n - 1, memo) + self.my_fib(n - 2, memo) return memo[n] ``` -## 2. 记忆化搜索与递推区别 +## 2. 记忆化搜索与递推的区别 -「记忆化搜索」与「递推」都是动态规划的实现方式,但是两者之间有一些区别。 +「记忆化搜索」和「递推」都是动态规划的常用实现方式,但两者在思路和实现上存在明显差异: -> **记忆化搜索**:「自顶向下」的解决问题,采用自然的递归方式编写过程,在过程中会保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。 +> **记忆化搜索**:采用「自顶向下」的递归方式解决问题。每当遇到一个子问题时,先判断其结果是否已被计算并缓存(通常用数组或哈希表存储),如果已缓存则直接返回,否则递归计算并缓存结果,避免重复计算。 > -> - 优点:代码清晰易懂,可以有效的处理一些复杂的状态转移方程。有些状态转移方程是非常复杂的,使用记忆化搜索可以将复杂的状态转移方程拆分成多个子问题,通过递归调用来解决。 -> - 缺点:可能会因为递归深度过大而导致栈溢出问题。 -> -> **递推**:「自底向上」的解决问题,采用循环的方式编写过程,在过程中通过保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。 -> -> - 优点:避免了深度过大问题,不存在栈溢出问题。计算顺序比较明确,易于实现。 -> - 缺点:无法处理一些复杂的状态转移方程。有些状态转移方程非常复杂,如果使用递推方法来计算,就会导致代码实现变得非常困难。 - -根据记忆化搜索和递推的优缺点,我们可以在不同场景下使用这两种方法。 +> - **优点**:代码结构直观,易于理解,尤其适合处理状态转移方程复杂、难以直接递推出解的问题。通过递归自然地分解子问题,便于实现。 +> - **缺点**:递归层数过深时,可能导致栈溢出,空间消耗较大。 -适合使用「记忆化搜索」的场景: +> **递推**:采用「自底向上」的循环方式解决问题。通过预先确定的顺序,逐步计算并保存每个子问题的解,最终得到原问题的答案。 +> +> - **优点**:避免了递归带来的栈溢出风险,计算顺序清晰,通常空间和时间效率更高。 +> - **缺点**:对于状态转移方程复杂、依赖关系不明确的问题,递推实现较为困难,代码可读性和灵活性较差。 -1. 问题的状态转移方程比较复杂,递推关系不是很明确。 -2. 问题适合转换为递归形式,并且递归深度不会太深。 +**选择建议**: -适合使用「递推」的场景: +- 适合使用记忆化搜索的场景: + 1. 状态转移方程复杂,难以直接递推出解。 + 2. 问题本身适合递归建模,且递归深度可控。 -1. 问题的状态转移方程比较简单,递归关系比较明确。 -2. 问题不太适合转换为递归形式,或者递归深度过大容易导致栈溢出。 +- 适合使用递推的场景: + 1. 状态转移方程简单明了,递推关系清晰。 + 2. 递归深度过大,担心栈溢出,或希望提升空间效率。 -## 3. 记忆化搜索解题步骤 +## 3. 记忆化搜索的解题步骤 -我们在使用记忆化搜索解决问题的时候,其基本步骤如下: +使用记忆化搜索时,通常遵循以下步骤: -1. 写出问题的动态规划「状态」和「状态转移方程」。 -2. 定义一个缓存(数组或哈希表),用于保存子问题的解。 -3. 定义一个递归函数,用于解决问题。在递归函数中,首先检查缓存中是否已经存在需要计算的结果,如果存在则直接返回结果,否则进行计算,并将结果存储到缓存中,再返回结果。 -4. 在主函数中,调用递归函数并返回结果。 +1. 明确问题的动态规划「状态」以及「状态转移方程」。 +2. 定义一个缓存结构(如数组或哈希表),用于存储已计算过的子问题结果,避免重复计算。 +3. 编写递归函数:每次递归时,先判断当前子问题的解是否已在缓存中,若有则直接返回,否则递归计算,并将结果存入缓存后返回。 +4. 在主函数中初始化缓存,并调用递归函数获取最终答案。 ## 4. 记忆化搜索的应用 -### 4.1 目标和 +### 4.1 经典例题:目标和 #### 4.1.1 题目链接 @@ -118,17 +118,20 @@ class Solution: ##### 思路 1:深度优先搜索(超时) -使用深度优先搜索对每位数字进行 `+` 或者 `-`,具体步骤如下: +采用深度优先搜索(DFS)的方法,对每个数字尝试添加 `+` 或 `-`,具体步骤如下: + +1. 定义递归函数 `dfs(i, cur_sum)`,表示从下标 $i$ 开始,当前累加和为 $cur\_sum$ 时,能够得到目标和 $target$ 的方案数。 +2. 初始调用为 `dfs(0, 0)`,即从第一个数字、当前和为 0ドル$ 开始递归。 +3. 递归终止条件:当 $i$ 等于数组长度 $size$ 时,说明已经处理完所有数字。 + - 如果此时 $cur\_sum$ 等于 $target,ドル返回 1ドル,ドル表示找到一种可行方案。 + - 否则返回 0ドル,ドル表示当前路径不可行。 +4. 对于每个位置 $i,ドル递归地分别尝试加上 $nums[i]$ 和减去 $nums[i],ドル即: + - 递归调用 `dfs(i + 1, cur_sum + nums[i])` + - 递归调用 `dfs(i + 1, cur_sum - nums[i])` +5. 将上述两种选择的返回值相加,得到当前位置 $i$、当前和 $cur\_sum$ 下的总方案数,并返回。 +6. 最终答案即为 `dfs(0, 0)` 的返回值。 -1. 定义从位置 0ドル$、和为 0ドル$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 -2. 下面从位置 0ドル$、和为 0ドル$ 开始,以深度优先搜索遍历每个位置。 -3. 如果当前位置 $i$ 到达最后一个位置 $size$: - 1. 如果和 $cur\_sum$ 等于目标和 $target,ドル则返回方案数 1ドル$。 - 2. 如果和 $cur\_sum$ 不等于目标和 $target,ドル则返回方案数 0ドル$。 -4. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 -5. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 -6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,返回该方案数。 -7. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 +这种方法会遍历所有可能的加减组合,统计满足条件的方案数。 ##### 思路 1:代码 @@ -156,21 +159,21 @@ class Solution: ##### 思路 2:记忆化搜索 -在思路 1 中我们单独使用深度优先搜索对每位数字进行 `+` 或者 `-` 的方法超时了。所以我们考虑使用记忆化搜索的方式,避免进行重复搜索。 +在思路 1 中,单纯使用深度优先搜索(DFS)对每个数字尝试加号或减号,导致大量重复计算,最终超时。为此,我们引入记忆化搜索,通过缓存中间结果来避免重复递归。 + +具体做法如下: -这里我们使用哈希表 $table$ 记录遍历过的位置 $i$ 及所得到的的当前和$cur\_sum$ 下的方案数,来避免重复搜索。具体步骤如下: +1. 使用哈希表 $table$ 记录每个状态 $(i, cur\_sum)$ 下的方案数,$i$ 表示当前处理到的下标,$cur\_sum$ 表示当前累加和。 +2. 定义递归函数 `dfs(i, cur_sum)`,表示从下标 $i$ 开始,当前累加和为 $cur\_sum$ 时,能够得到目标和 $target$ 的方案数。初始调用为 `dfs(0, 0)`。 +3. 递归终止条件:当 $i$ 等于数组长度时,如果 $cur\_sum = target,ドル返回 1ドル,ドル否则返回 0ドル$。 +4. 如果状态 $(i, cur\_sum)$ 已经在 $table$ 中,直接返回缓存的结果,避免重复计算。 +5. 否则,递归计算两种选择的方案数: + - 选择 $nums[i]$ 加号:`dfs(i + 1, cur_sum + nums[i])` + - 选择 $nums[i]$ 减号:`dfs(i + 1, cur_sum - nums[i])` + 将两者结果相加,得到当前状态的总方案数,并存入 $table$。 +6. 最终返回 `dfs(0, 0)` 作为答案。 -1. 定义从位置 0ドル$、和为 0ドル$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 -2. 下面从位置 0ドル$、和为 0ドル$ 开始,以深度优先搜索遍历每个位置。 -3. 如果当前位置 $i$ 遍历完所有位置: - 1. 如果和 $cur\_sum$ 等于目标和 $target,ドル则返回方案数 1ドル$。 - 2. 如果和 $cur\_sum$ 不等于目标和 $target,ドル则返回方案数 0ドル$。 -4. 如果当前位置 $i$、和为 $cur\_sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 -5. 如果当前位置 $i$、和为 $cur\_sum$ 之前没有记录过,则: - 1. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 - 2. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 - 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 -6. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 +通过记忆化搜索,大幅减少了重复子问题的计算,提高了效率。 ##### 思路 2:代码 @@ -202,7 +205,7 @@ class Solution: - **时间复杂度**:$O(2^n)$。其中 $n$ 为数组 $nums$ 的长度。 - **空间复杂度**:$O(n)$。递归调用的栈空间深度不超过 $n$。 -### 4.2 第 N 个泰波那契数 +### 4.2 经典例题:第 N 个泰波那契数 #### 4.2.1 题目链接 @@ -243,13 +246,12 @@ T_4 = 1 +たす 1 +たす 2 = 4 ##### 思路 1:记忆化搜索 -1. 问题的状态定义为:第 $n$ 个泰波那契数。其状态转移方程为:$T_0 = 0, T_1 = 1, T_2 = 1,ドル且在 $n>= 0$ 的条件下,$T_{n + 3} = T_{n} + T_{n+1} + T_{n+2}$。 -2. 定义一个长度为 $n + 1$ 数组 $memo$ 用于保存一斤个计算过的泰波那契数。 -3. 定义递归函数 `my_tribonacci(n, memo)`。 - 1. 当 $n = 0$ 或者 $n = 1,ドル或者 $n = 2$ 时直接返回结果。 - 2. 当 $n> 2$ 时,首先检查是否计算过 $T(n),ドル即判断 $memo[n]$ 是否等于 0ドル$。 - 1. 如果 $memo[n] \ne 0,ドル说明已经计算过 $T(n),ドル直接返回 $memo[n]$。 - 2. 如果 $memo[n] = 0,ドル说明没有计算过 $T(n),ドル则递归调用 `my_tribonacci(n - 3, memo)`、`my_tribonacci(n - 2, memo)`、`my_tribonacci(n - 1, memo)`,并将计算结果存入 $memo[n]$ 中,并返回 $memo[n]$。 +1. 状态定义:用 $T_n$ 表示第 $n$ 个泰波那契数。状态转移方程为:$T_0 = 0, T_1 = 1, T_2 = 1,ドル当 $n \ge 3$ 时,$T_n = T_{n-1} + T_{n-2} + T_{n-3}$。 +2. 使用一个长度为 $n + 1$ 的数组 $memo,ドル用于记录已经计算过的泰波那契数,避免重复计算。 +3. 定义递归函数 `my_tribonacci(n, memo)`,实现如下: + 1. 如果 $n = 0,ドル返回 0ドル$;若 $n = 1$ 或 $n = 2,ドル返回 1ドル$。 + 2. 如果 $memo[n]$ 已经被赋值(即 $memo[n] \ne 0$),直接返回 $memo[n]$。 + 3. 否则递归计算 $my\_tribonacci(n - 1, memo)$、$my\_tribonacci(n - 2, memo)$ 和 $my\_tribonacci(n-3, memo),ドル将三者之和赋值给 $memo[n],ドル并返回 $memo[n]$。 ##### 思路 1:代码 @@ -277,6 +279,17 @@ class Solution: - **时间复杂度**:$O(n)$。 - **空间复杂度**:$O(n)$。 +## 5. 总结 + +记忆化搜索是动态规划的一种重要实现方式,通过缓存已计算过的子问题结果来避免重复计算,从而显著提高算法效率。其核心思想是在递归求解过程中,先检查当前状态是否已被计算过,如果有则直接返回缓存结果,否则递归计算并将结果存入缓存。 + +记忆化搜索采用自顶向下的递归方式,代码结构直观易懂,特别适合处理状态转移方程复杂、难以直接递推的问题。通过递归自然地分解子问题,使得复杂问题的建模变得相对简单。然而,递归深度过深时可能导致栈溢出,且空间消耗相对较大。 + +在实际应用中,记忆化搜索广泛应用于各种动态规划问题,如斐波那契数列、目标和问题、泰波那契数等。通过合理使用记忆化技术,可以将原本指数级的时间复杂度降低到多项式级别,大大提升算法性能。 + +选择记忆化搜索还是递推方式,需要根据具体问题的特点来决定。对于状态转移方程复杂、递归深度可控的问题,记忆化搜索是很好的选择;而对于状态转移简单、需要优化空间效率的问题,递推方式可能更为合适。 + + ## 题目练习 - [0494. 目标和](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/target-sum.md)

AltStyle によって変換されたページ (->オリジナル) /