1.动态规划算法
动态规划(dp)
首先:动态规划问题一般的形式就是 求 最值
解 动态规划的核心问题 就是 穷举
然后:动态规划穷举有点特别,因为这类问题存在 “重叠子问题” ,如果暴力穷举,效率会低下,所以需要借助备忘录“ dp table”来优化穷举过程,避免不必要的计算
然后:动态规划 一般 会具备 “最优子结构” ,这样才通过子问题求最值
动态规划三要素
重叠子问题、最优子结构、状态转移方程
写状态转移方程 :
1.这个问题的最简单状况(初始值)是什么
2.这个问题有什么”状态“
3.对于每个“状态”,可以做出的“选择”是什么才能使 其“状态” 改变
4.如何定义 dp 数组 来表现“状态”和“选择”
说白了就是三点:状态、选择、dp数组的定义。。。。
例题讲解 1、斐波那契数列(LeetCode 509)
第一种方式: 暴力递归
int fib(int num){
if(num==0){
return 0;
}
if(num==1||num==2){
return 1;
}
return fib(num-1)+num-2;
}
代码简单易懂,但是十分的低效,低效在哪里?
假如 num=20; 画出递归树,如图
递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。
子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。
解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。
所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。
第二种方式:备忘录(动态规划)
明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
int fib(int N) {
if (N < 1) 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];
}
这次再次 画图递归树 如图 :
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。
递归算法的时间复杂度怎么算?****子问题个数乘以解决一个子问题需要的时间。
子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是f(1)
,f(2)
,f(3)
…f(20)
,数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。
解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。
所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。
第三种方式:dp数组的迭代解法
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!
int fib(int N) {
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];
}
这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式: