参考:labuladong
一、斐波那契数列
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,
比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的
这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,
还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。
这就是动态规划问题的第一个性质:重叠子问题。下面,
我们想办法解决这个问题
# 1.暴力递归
int fib(int N){
if(N==1 || N==2){
return fib(N-1)+fib(N-2);
}
}
# 2.带备忘录的递归解法
// 每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;
// 每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已
// 经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了
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];
}
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通
过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题
(即递归图中节点)的个数。
# 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++){
# 类似在算法图解那本书上进行table的填值
# 先把底层的值填好,然后自底向上推算
dp[i]=dp[i-1]+dp[i-2];
}
return dp[N];
}
千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力
解,即状态转移方程。只要写出暴力解,优化方法无非是用备忘录或
者 DP table,再无奥妙可言
细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只
和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存
储所有的状态,只要想办法存储之前的两个状态就行了
这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需
要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table
的大小
二、凑零钱问题
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种
硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币
凑出这个金额,如果不可能凑出,算法返回 -1 。
比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少
需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
# 1、暴力递归
这个问题是动态规划问题,因为它具有「最优子结构」的。要符合
「最优子结构」,子问题间必须互相独立。这里我是借助算法图解
来进行理解的:
将菲尔铁塔加入‘背包’,卢浮宫将更‘便宜’:只要1天时间,不需要
1.5天,这两个子问题就不是独立的;
选择面值5之后,面值1、2价值并没有变
#那么,既然知道了这是个动态规划问题,就要思考如何列出正确的
#状态转移方程:
1.确定base case:这个很简单,显然目标金额 amount 为 0 时算
法返回 0,因为不需要任何硬币就已经凑出目标金额了
2.确定「状态」,也就是原问题和子问题中会变化的变量:
由于硬币数量无限,硬币的面额也是题目给定,只有目标金额会不断
地向 base case 靠近,所以唯一的「状态」就是目标金额 amount
3.确定「选择」,也就是导致「状态」产生变化的行为。目标金额为
什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少
了目标金额。所以说所有硬币的面值,就是你的「选择」。
4.明确 dp 函数/数组的定义。
我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,
一般来说函数的参数:就是状态转移中会变化的量,也就是上面说到的「状态」:目标的金额;
函数的返回值:就是题目要求我们计算的量:最少的硬币数量
搞清楚上面这几个关键点,解法的伪码就可以写出来了:
# 伪码框架
def coinChange(coins: List[int], amount:int){
# 定义:要凑出金额 n,至少要 dp(n) 个硬币,n是状态
def dp(n):
# 做选择,选择需要硬币最少的那个结果
for coin in coins:
res = min(res, 1+dp(n-coin))
return res
# 题目要求的最终结果是 dp(amount)
return dp(amount)
}
# 根据伪码,假设base case即可得到最终答案:
# 显然目标金额为 0 时,所需硬币数量为 0
# 当目标金额小于 0 时,无解,返回 -1
def coinChange(coins: List[int], amount: int):
def dp(n):
# base case
if n==0: return 0;
if n<0: return -1;
# 求最小值,所以初始化为正无穷
res=float('INF')
for coin in coins:
subproblem = dp(n-coin)
# 子问题无解,跳过
if subproblem == -1:continue
res= min(res, 1+subprobele)
return res if res!=float('INF') else -1
return dp(amount)
# 2.带备忘录的递归
def coinChange(coins: List[int], amount: int):
# 备忘录
memo = dict()
def dp(n):
# 查备忘录,避免重复计算
if n in memo: return memo[n]
# base case
if n==0: return 0
if n<-1: return -1
res = float('INF')
for coin in coins:
subproblem = dp(n-coin)
if subproblem == -1: continue
res = min(res, 1+subproblem)
# 记入备忘录
memo[n] = res if res!= float('INF') else -1
return memo[n]
return dp(amount)
# 3.dp数组的迭代解法
# 也可以自底向上使用 dp table 来消除重叠子问题
# 关于「状态」「选择」和 base case 与之前没有区别
# dp 数组的定义和刚才 dp 函数类似,也是把「状态」,也就是目标金额作为变量
# 不过 dp 函数体现在函数参数,而 dp 数组体现在数组索引
# 自底向上,就是数组索引了
int coinChange(vector<int>& coins, int amount){
// 数组大小为 amount + 1,初始值也为 amount + 1
vector<int> dp(amount+1, amount+1);
// base case
dp[0]=0;
// 外层 for 循环在遍历所有状态的所有取值:状态就是目标金额
for (int i = 0; i < dp.size(); i++){
// 内层 for 循环在求所有选择的最小值
for(int coin:coins){
// 子问题无解,跳过
if(i-coin<0) continue;
dp[i]=min(dp[i], 1+dp[i-coin]);
}
}
return (dp[amount]==amount+1)? -1:dp[amount];
}
# PS:为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount
# 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬
# 币),所以初始化为 amount + 1 就相当于初始化为正无穷,便
# 于后续取最小值。
三、最后总结
第一个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」的方法来优化递归树,
并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。
第二个凑零钱的问题,展示了如何流程化确定「状态转移方程」,只
要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消
除重叠子问题而已
计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷
举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再
追求“如何聪明地穷举”。
列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,
一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复
杂,不那么容易穷举完整。
备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思
路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花
活?
之后我们会有一章专门讲解动态规划问题,如果有任何问题都可以随
时回来重读本文,希望读者在阅读每个题目和解法时,多往「状态」
和「选择」上靠,才能对这套框架产生自己的理解,运用自如。