动态规划(Dynamic Programming)
运筹学的一种最优化方法,常用于计算机应用,如求最长递增子序列,最小编辑距离。
1. 问题形式
求最值
核心方法: 穷举
- 列出所有可行解
- 找最值
2. 问题特点
状态、选择、dp数组定义
- 问题存在”重叠子问题“
暴力穷举效率低下,需要通过”备忘录“或者”DP table“优化穷举。 - 问题具备”最优子结构“
通过子问题最值得到原问题最值 - 问题的”状态转移方程“
第一:问题最简单的情况是什么?
第二:问题有哪些”状态“?
第三:对于每个”状态“,有哪些”选择“使得
”状态“发生改变?
第四:如何定义dp数组/函数来表现”状态“和”选择“?
3. 案例
3.1重叠子问题(斐波那契数列)
3.1.1 暴力递归(提出问题)
斐波那契数列数学形式:递归
代码:
int fib(int N){
if(N==0){
return 0;
}
if(N==1||N==2){
return 1;
}
return fib(N-1)+fib(N-2);
}
上述代码:
优点:简单易懂
缺点:低效
分析:假设n=20时,它的递归树为:
求原问题fib(20)值:
(1)先计算子问题fib(19)和fib(18)
(2)然后计算fib(19)
需计算fib(18)和fib(17)
…
以此类推
计算fib(1)和fib(2)
(3)返回结果,递归树不向下生长。
递归算法时间复杂度计算:
子问题个数 * 一个子问题需要的时间
- 子问题个数=递归树中节点的总数
因为二叉树节点总数为指数级别,所以子问题个数的时间复杂度为O(2n)。 - 一个子问题需要的时间
因为无循环,只有fib(n-1)和fib(n-2)的加法操作,所以时间复杂度为O(1)。
综上:时间复杂度O(2n)(即两者之积)
通过观察递归树,算法低效的原因:大量重复计算。如fib(18)被计算两次。fib(18)为根的递归树体量巨大,多算一遍消耗大量时间,同时不止fib(18)这一个节点被重复计算。
上述重复计算的过程被称为重叠子问题
3.1.2 带备忘录的递归解法(解决问题)
通过分析上述提出的重叠子问题,知道造成耗时的原因是重复计算,那么我们可以造一个”备忘录“,每次算出某个子问题的答案后先不要返回,而是先将其记到”备忘录“里再返回;每次遇到一个子问题先去”备忘录“里查一查,如果发现之前解决过这个问题,可以直接引用这个答案,不需要重复计算了。
”备忘录“:数组或者哈希表(字典)
代码:
int fib(int N){
if(N==0){
return 0;
}
//将备忘录全部初始化为0
vector<int>memo(N+1,0);
//进行备忘录的递归
return helper(memo,N);
}
int helper(vector<int>&memo,int n){
//base case最简单的情况
if(n==1||n==2){
return 1;
}
//已经计算过的情况
if(memo[n]!=0){
return memo[n];
}
memo[n]=helper(memo,n-1)+helper(memo,n-2);
return memo[n];
}
分析:假设n=20时,“备忘录”的递归树:
通过“备忘录”上述递归树进行了”剪枝“,改造成了一幅不存在冗余的递归图(其中为了方便书写fib()函数写成f()函数):
递归算法时间复杂度计算:
- 子问题个数=图中节点的总数
因为不存在冗余计算,子问题就是f(1)、f(2)、f(3)…f(20),数量和输入规模N=20成正比,所以子问题个数为O(N)。 - 一个子问题需要的时间
因为无循环,只有f(n-1)和f(n-2)的加法操作,所以时间复杂度为O(1)。
综上:时间复杂度O(N)(即两者之积)
与暴力算法相比,效率提高了许多
总结1:
带“备忘录”的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和迭代的动态规划已经差不多了。
带备忘录的递归解法解法:“自顶向下”
动态规划:“自底向上”的。
啥叫“自顶向下”?
我们刚才画的递归树(或者说图),是从上向下延伸的,都是从一个规模较大的原问题,比如f(20),向下逐渐分解规模,直到f(1)和f(2)这两个base case, 然后逐层返回答案, 这就叫“自顶向下”。
啥叫“自底向上”?
与上述相反,我们直接从最下面、最简单、问题规模最小的f(1)和f(2)开始往上推,直到推到我们想要的答案f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算的关键所在。
3.1.3 dp数组的迭代解法(解决问题)
有了上一步“备忘录”的启发,我们可以把这个“备忘录”独立出来成为一张表,就叫作DP table, 在这张表上完成“自底向上”的推算!
int fib(int N){
if(N==0){
return 0;
}
if(N==1||N==2){
return 1;
}
vector<int>dp(N+1,0);
//base case
dp[1]=dp[2]=1;
for(int i=3;i<=N;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[N];
}
DP table表如下:
DP table特别像之前那个“剪枝”后的结果,只不过反过来了。实际上带备忘录的递归解法中的”备忘录“最终就是这个DP table, 所以说这两种解法其实是差不多的, 在大部分情况下, 效率也基本相同。
这里引出了“状态转移方程”这个名词,实际上就是描述问题结构的数学形式:
为哈叫“状态转移方程”?
把f(n)想作一个状态n,这个状态n是由状态n-1和状态n-2相加转移而来,这就叫状态转移。
总结2:
上面的几种解法中的所有操作, 例如语句return f(n-1) +f(n-2) ,dp[i] =dp[i 1-1] +dp[i-2], 以及对“备忘录”或DP table的初始化操作, 都是围绕这个方程式的不同表现形式,由此可见列出“状态转移方程”的重要性,它是解决问题的核心。而且很容易发现,其实状态转移方程直接代表着暴力解法。
动态规划问题最困难的就是写出暴力解法,即状态转移方程。只要写出暴力解法, 优化方法无非是用“备忘录"或者DP table。
在这个例子的最后,有一个细节的优化。根据斐波那契数列的状态转移方程, 当前状态只和之前的两个状态有关, 其实并不需要那么长的一个DP table来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为O(1);
int fib(int N){
if(N==0){
return 0;
}
if(N==1||N==2){
return 1;
}
int pre=1,curr=1;
for(int i=3;i<=N;i++){
int sum=pre+curr;
pre=curr;
curr=sum;
}
return curr;
}
这个技巧就是所谓的“状态压缩”, 如果我们发现每次状态转移只需要DP table中的一部分, 那么可以尝试用状态压缩来缩小DP table的大小, 只记录必要的数据, 上述例子就相当于把DP table的大小从N缩小到2。 一般来说是把一个二维的DP table压缩成一维, 即把空间复杂度从O(N2) 压缩到O(N)。
关于涉及动态规划的另一个重要特性“最优子结构”?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示为得到最优解法而逐步求精的过程。