|
|
|
@ -1,12 +1,8 @@
|
|
|
|
|
# 初探动态规划
|
|
|
|
|
|
|
|
|
|
动态规划(Dynamic Programming)是一种用于解决复杂问题的优化算法,它把一个问题分解为一系列更小的子问题,并把子问题的解存储起来以供后续使用,从而避免了重复计算,提升了解题效率。
|
|
|
|
|
「动态规划 Dynamic Programming」是一种用于解决复杂问题的优化算法,它把一个问题分解为一系列更小的子问题,并把子问题的解存储起来以供后续使用,从而避免了重复计算,提升了解题效率。
|
|
|
|
|
|
|
|
|
|
在本节中,我们先从一个动态规划经典例题入手,学习动态规划是如何高效地求解问题的,包括:
|
|
|
|
|
|
|
|
|
|
1. 如何暴力求解动态规划问题,什么是重叠子问题。
|
|
|
|
|
2. 如何向暴力搜索引入记忆化处理,从而优化时间复杂度。
|
|
|
|
|
3. 从递归解法引出动态规划解法,以及如何优化空间复杂度。
|
|
|
|
|
在本节中,我们先从一个动态规划经典例题入手,了解动态规划是如何高效地求解问题的。
|
|
|
|
|
|
|
|
|
|
!!! question "爬楼梯"
|
|
|
|
|
|
|
|
|
@ -16,9 +12,7 @@
|
|
|
|
|
|
|
|
|
|
![爬到第 3 阶的方案数量](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
|
|
|
|
|
|
|
|
|
|
**不考虑效率的前提下,动态规划问题理论上都可以使用回溯算法解决**,因为回溯算法本质上就是穷举,它能够遍历决策树的所有可能的状态,并从中记录需要的解。
|
|
|
|
|
|
|
|
|
|
对于本题,我们可以将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ 。
|
|
|
|
|
本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。
|
|
|
|
|
|
|
|
|
|
=== "Java"
|
|
|
|
|
|
|
|
|
@ -110,9 +104,9 @@
|
|
|
|
|
|
|
|
|
|
## 方法一:暴力搜索
|
|
|
|
|
|
|
|
|
|
然而,爬楼梯并不是典型的回溯问题,更适合从分治的角度进行解析。在分治算法中,原问题被分解为较小的子问题,通过组合子问题的解得到原问题的解。例如,归并排序将一个长数组从顶至底地划分为两个短数组,再从底至顶地将已排序的短数组进行排序。
|
|
|
|
|
回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
|
|
|
|
|
|
|
|
|
|
对于本题,设爬到第 $i$ 阶共有 $dp[i]$ 种方案,那么 $dp[i]$ 就是原问题,其子问题包括:
|
|
|
|
|
对于本题,我们可以尝试将问题拆解为更小的子问题。设爬到第 $i$ 阶共有 $dp[i]$ 种方案,那么 $dp[i]$ 就是原问题,其子问题包括:
|
|
|
|
|
|
|
|
|
|
$$
|
|
|
|
|
dp[i-1] , dp[i-2] , \cdots , dp[2] , dp[1]
|
|
|
|
@ -124,11 +118,13 @@ $$
|
|
|
|
|
dp[i] = dp[i-1] + dp[i-2]
|
|
|
|
|
$$
|
|
|
|
|
|
|
|
|
|
![方案数量递推公式](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
|
|
|
|
|
![方案数量递推关系](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
|
|
|
|
|
|
|
|
|
|
基于此递推公式,我们可以写出递归代码:以 $dp[n]$ 为起始点,**从顶至底地将一个较大问题拆解为两个较小问题**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。
|
|
|
|
|
也就是说,在爬楼梯问题中,**各个子问题之间不是相互独立的,原问题的解可以由子问题的解构成**。
|
|
|
|
|
|
|
|
|
|
以下代码与回溯解法一样,都属于深度优先搜索,但它比回溯算法更加简洁,这体现了从分治角度考虑这道题的优势。
|
|
|
|
|
我们可以基于此递推公式写出暴力搜索代码:以 $dp[n]$ 为起始点,**从顶至底地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。
|
|
|
|
|
|
|
|
|
|
观察以下代码,它与回溯解法都属于深度优先搜索,但比回溯算法更加简洁。
|
|
|
|
|
|
|
|
|
|
=== "Java"
|
|
|
|
|
|
|
|
|
@ -329,7 +325,7 @@ $$
|
|
|
|
|
|
|
|
|
|
**我们也可以直接“从底至顶”进行求解**,得到标准的动态规划解法:从最小子问题开始,迭代地求解较大子问题,直至得到原问题的解。
|
|
|
|
|
|
|
|
|
|
由于没有回溯过程,动态规划可以直接基于循环实现。我们初始化一个数组 `dp` 来存储子问题的解,从最小子问题开始,逐步求解较大子问题。在以下代码中,数组 `dp` 起到了记忆化搜索中数组 `mem` 相同的记录作用。
|
|
|
|
|
由于动态规划不包含回溯过程,因此无需使用递归,而可以直接基于递推实现。我们初始化一个数组 `dp` 来存储子问题的解,从最小子问题开始,逐步求解较大子问题。在以下代码中,数组 `dp` 起到了记忆化搜索中数组 `mem` 相同的记录作用。
|
|
|
|
|
|
|
|
|
|
=== "Java"
|
|
|
|
|
|
|
|
|
@ -474,3 +470,5 @@ $$
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**我们将这种空间优化技巧称为「状态压缩」**。在许多动态规划问题中,当前状态仅与前面有限个状态有关,不必保存所有的历史状态,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。
|
|
|
|
|
|
|
|
|
|
实际上,所有动态规划问题都可以使用回溯算法解决,因为回溯算法本质上就是穷举,它能够遍历决策树的所有可能的状态,并从中记录需要的解。
|