动态规划的三大特性
一个问题只有同时满足以下三个特性,才适合用动态规划进行求解
- 重叠子问题
- 最优子结构
- 无后效性
重复子问题:在计算的过程中,有一些问题会重复计算,必须记住结果。没有重复子问题的话,可以「分而治之」求解;
最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。
怎么才能推出?要求子问题之间必须互相独立
,否则无法推出。
无后效性:我们转移某个状态需要用到某个值,但是并不关心该值是如何而来的。
怎么样才满足最优子结构
「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。
那怎么样才算满足最优子结构呢?有没有一个范式去判断,有的,下面举例理解:
1、假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在要计算全校最高的成绩——符合最优子结构,每个班的最高成绩相互独立。
2、假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差。
——不符合最优子结构,因为你没办通过每个班的最大分数差
推出全校的最大分数差
,即:没办法通过子问题的最优值推出规模更大的问题的最优值
为什么推不出?
因为全校的最高分和最低分可能分布在两个班级,那么子问题就不独立了。
总结:想满足最优子结构,子问题之间必须互相独立
怎么判断是否有重叠子问题
简单粗暴的方式就是画图,把递归树画出来,看看有没有重复的节点,有重复节点就说明存在重叠子问题。
比如下面我以斐波那契数列为例,画出其递归树
斐波那契数列求解伪代码:
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
递归树:
可以看到上面存在大量的类似f(17)的重复节点,我们可以在第一个计算到f(17)时将其数值记录下来,下次再遇到时可以不在重复计算了,也就是剪枝思想。
动态规划问题具体该怎么求解呢
现在已经明确了什么样的问题适合用DP进行求解,下面说下我总结的DP求解的具体步骤:
1、明确「状态」
并确定 dp 数组
DP 的状态定义很大程度是靠经验去猜的
确定状态后,由状态确定数组维度:有几个状态,数组就是几维的
比如斐波那契数列问题中,状态只有一个,就是当前数值大小,所以可以定义dp数组为一维的,即dp[i]:表示数值i的斐波那契数列的数值。
2、明确「选择」,根据选择
写出状态转移方程
如果我们的状态定义猜对了,基本上「状态转移方程」就是呼之欲出,因此一定程度上,状态转移方程可以反过来验证我们状态定义猜得是否正确.
如果猜了一个状态定义,然后发现无法列出涵盖所有情况的状态转移方程,多半就是状态定义猜错了,赶紧换个思路,而不是去死磕状态转移方程。
比如斐波那契数列问题中,选择是规定好的,所以状态转移方程也是显而易见的
dp[i] = dp[i - 1] + dp[i - 2];
3、由状态转移方程
明确 base case
,初始化dp数组 。
base case就是一开始就已知的基本情况,比如斐波那契数列问题中就是初始的dp[0]=0,dp[1]=1
4、选择遍历方向,进行迭代求出最终结果
记住两点就行了:
- 遍历的过程中,当前状态所需的状态必须是已经计算出来的。
- 遍历的终点必须是存储最终结果的那个位置。
举例:
1、比如编辑距离这个经典的问题,我们通过对 dp
数组的定义,确定了 base case 是 dp[..][0]
和 dp[0][..]
,最终答案是 dp[m][n]
;而且我们通过状态转移方程知道 dp[i][j]
需要从 dp[i-1][j]
, dp[i][j-1]
, dp[i-1][j-1]
转移而来,如下图:
那么,参考刚才说的两条原则,你该怎么遍历 dp
数组?肯定是正向遍历:
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
// 通过 dp[i-1][j], dp[i][j - 1], dp[i-1][j-1]
// 计算 dp[i][j]
因为,这样每一步迭代的左边、上边、左上边
的位置都是之前计算过的,而且最终结束在我们想要的答案 dp[m][n]
。
2、再举一例,回文子序列问题,我们通过对 dp
数组的定义,确定了 base case 处在中间的对角线,dp[i][j]
需要从 dp[i+1][j]
, dp[i][j-1]
, dp[i+1][j-1]
转移而来,想要求的最终答案是 dp[0][n-1]
,如下图:
这种情况根据刚才的两个原则,就可以有两种正确的遍历方式:
要么从左至右斜着遍历,要么从下向上从左到右
遍历,这样才能保证每次 dp[i][j]
的左边、下边、左下边已经计算完毕,得到正确结果。
动态规划的两种形式
还是拿斐波拉契数列举例,该问题的求解实际有两种写法,一种自顶向下递归+剪枝,也叫记忆化搜索,一种自底向上迭代,通常我们将这种方式叫做动态规划。
这两种解法的思想是一样的,大部分情况下,效率也基本相同。可以说是DP的两种写法,只是我们通常狭义的只把第二种写法成为DP,而把第一种称为记忆化搜索、
补充:有没有发现记忆化搜索和回溯很像,没错就是很像,都是自顶向下递归,都面临着【做选择】,可以这样理解:记忆化搜索=回溯+剪枝,并且剪枝方式一定得是复用之前的结果从而减少重复计算,不然剪枝效果达不到DP的要求。
-
自顶向下递归-记忆化搜索
注意我上面在重叠子问题中画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说f(20),向下逐渐分解规模,直到f(1)和f(2)触底,然后逐层返回答案,这就叫「自顶向下」。 -
自底向上迭代-动态规划
反过来,我们直接从最底下,最简单,问题规模最小的f(1)和f(2)开始往上推,直到推到我们想要的答案f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
下面分别写出斐波拉契数列的这两种解法,方便大家进一步的理解DP
暴力递归
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
这样写代码十分低效,为了计算原问题 f(20)
,产生了大量的重复计算
,比如f(1)重复计算了很多很多次。
记忆化搜索
耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
class Solution {
int[]memo;
public int fib(int n) {
memo=new int[n+1];
return f(n);
}
private int f(int n) {
if (n < 2) {
return n;
}else if(memo[n]!=0){
return memo[n];
}
memo[n]=f(n - 1) + f(n - 2);
return memo[n];
}
}
动态规划
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」
画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table
下面根据我前面总结的dp求解步骤,一步一步进行求解:
1、确定状态
状态只有一个,就是当前数值大小,所以可以定义dp数组为一维的,即dp[i]:表示数值i的斐波那契数列的数值。
2、确定选择,选择是确定的,所以状态转移方程为:dp[i]=dp[i-1]+dp[i-2]
3、由状态转移方程明确 base case,初始化dp数组 。
base case就是一开始就已知的基本情况,比如斐波那契数列问题中就是初始的dp[0]=1,dp[1]=1
4、选择遍历方向,进行迭代求出最终结果
遍历方向从前往后即可
5、代码:
int fib(int N) {
if (N == 0) return 0;
if (N == 1) return 1;
int[] dp=new int[N+1];
// base case
dp[0]=0;
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2]; //从3往上计算 自底向上
return dp[N];
}
实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table
,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。
状态压缩优化:
当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化把空间复杂度降为 O(1)
int fib(int n) {
if (n == 2 || n == 1)
return 1;
int prev = 1, curr = 1;
for (int i = 3; i <= n; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}