动态规划解题套路框架

参考: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之后,面值12价值并没有变

#那么,既然知道了这是个动态规划问题,就要思考如何列出正确的
#状态转移方程:
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 就是在追求“如何聪明地穷举”。用空间换时间的思
路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花
活?
之后我们会有一章专门讲解动态规划问题,如果有任何问题都可以随
时回来重读本文,希望读者在阅读每个题目和解法时,多往「状态」
和「选择」上靠,才能对这套框架产生自己的理解,运用自如。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值