本文参考动态规划套路详解
一、斐波那契数列(Fabonacci)
1、暴力递归
斐波那契数列的数学形式就是递归:
int fi(int n)
{
if(n==1 || n==2) return 1;
return fi(n-1)+fi(n-2);
}
如剑指offer题斐波那契数列、跳台阶、矩形覆盖。
若n=20,我们可以画出递归树
这个递归树怎么理解?就是说想要计算原问题 f(20),我就得先计算出子问题 f(19) 和 f(18),然后要计算 f(19),我就要先算出子问题 f(18) 和 f(17),以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果,递归树不再向下生长了----------------- 自顶向下
递归算法的时间复杂度怎么计算?
子问题个数乘以解决一个子问题需要的时间。
子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。
二、备忘录递归解法
即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
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];
}
我们可以看到「备忘录」memo[n]记录了每个子问题的结果
递归算法的时间复杂度怎么计算?
子问题个数乘以解决一个子问题需要的时间。
子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) … f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。
解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。
所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。
实际上,这种解法和迭代的动态规划已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
3、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];
}
这里,引出 状态转移方程这个名词,实际上就是描述问题结构的数学形式:
为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。
千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。
二、动态规划问题-凑零钱
Leetcode322. 零钱兑换
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
可以写出状态转移方程:
1、先确定「状态」,也就是原问题和子问题中变化的变量。由于零钱数量无限,所以唯一的状态就是目标金额 amount。
2、然后确定 dp 函数的定义:当前的目标金额是 n,至少需要 dp(n) 个零钱凑出该金额。
3、然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当前的目标金额是多少,选择就是从面额列表 coins 中选择一个零钱,然后目标金额就会减少:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//初始化较大的数,以便于后面min函数计算
vector<int> dp(amount+1,amount+1);
dp[0]=0;
for(int i=1;i<dp.size();i++)
{
for(auto coin:coins)
{
//子问题无解,跳过
if(i-coin<0) continue;
dp[i]=min(dp[i],dp[i-coin]+1);
}
}
return (dp[amount]==amount+1) ? -1:dp[amount];
}
};
PS:为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount 金额的数最多只可能等于 amount(全用 1 元面值的),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值。
三、动态规划问题-凑零钱II
Leetcode518. 零钱兑换 II
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount+1,0);
dp[0]=1;
for(auto coin:coins)
{
for(int i=1;i<dp.size();i++)
{
if(i-coin<0) continue;
dp[i]+=dp[i-coin];
}
}
return dp[amount];
}
};
相较于二、动态规划问题-凑零钱里解法,为何双层循环的顺序相反???这就涉及到c++背包九讲之01背包和c++背包九讲之完全背包,大家可以看一下。
四、动态规划问题-硬币
Leetcode面试题 08.11. 硬币
硬币。给定数量不限的硬币,币值为25分、10分、5分和1分,编写代码计算n分有几种表示法。(结果可能会很大,你需要将结果模上1000000007)
class Solution {
public:
int waysToChange(int n) {
vector<int> dp(n+1,0);
dp[0]=1;
int coins[4]={1,5,10,25};
for(auto coin:coins)
{
for(int i=1;i<dp.size();i++)
{
if(i-coin<0) continue;
dp[i]+=dp[i-coin];
dp[i]=dp[i]%1000000007;
}
}
return dp[n];
}
};