算法分析总结

本文深入探讨了回溯算法的实现框架,详细解释了路径、选择列表和结束条件的概念,并通过代码展示了如何在递归过程中做出选择和撤销选择。接着,文章转向动态规划,阐述了其核心是穷举并优化重叠子问题,强调了最优子结构、状态转移方程的重要性。通过斐波那契数列和凑零钱问题的实例,展示了动态规划的暴力递归、备忘录优化和dp数组迭代解法。最后,讨论了如何通过备忘录和dp数组消除递归树中的冗余,优化算法效率。
摘要由CSDN通过智能技术生成

回溯算法

代码⽅⾯,回溯算法的框架:

result = []
def backtrack(路径, 选择列表):
	if 满⾜结束条件:
		result.add(路径)
		return
	for 选择 in 选择列表:
		做选择
		backtrack(路径, 选择列表)
		撤销选择

其核⼼就是 for 循环⾥⾯的递归,在递归调⽤之前「做选择」,在递归调⽤
之后「撤销选择」
步骤解释:
1、路径:也就是已经做出的选择。记录你已经做过的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,⽆法再做选择的条件。
在这里插入图片描述

我们定义的 backtrack 函数其实就像⼀个指针,在这棵树上游⾛,同时要
正确维护每个节点的属性,每当⾛到树的底层,其「路径」就是⼀个全排

在这里插入图片描述
前序遍历的代码在进⼊某⼀个节点之前的那个时间点执⾏,后序遍历代码在
离开某个节点之后的那个时间点执⾏

回想我们刚才说的,「路径」和「选择」是每个节点的属性,函数在树上游
⾛要正确维护节点的属性,那么就要在这两个特殊时间点搞点动作:

在这里插入图片描述
for 选择 in 选择列表:

# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加⼊选择列表

我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到
每个节点的选择列表和路径。

动态规划

求解动态规划的核⼼问题是穷举。因为要求最值,肯定要把所有可⾏的答案穷举出来,然后在其中找最值呗
动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果暴⼒穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算
动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得到原问题的最值。

以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。
状态转移⽅程:
思维框架
明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case
实例:
1.斐波那契数列
2.凑零钱问题
前者主要是让你明⽩什么是重叠⼦问题(斐波那契数列严格来说不是动态规划问题),后者主要举集中于如何列出状态转移⽅程。

斐波那契数列

1.暴⼒递归

int fib(int N) {
	if (N == 1 || N == 2) return 1;
	return fib(N - 1) + fib(N - 2);
}

原始状态转移方程直接进行递归称为暴力递归,比较费时,存在大量重复计算。

PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨⼤帮助。

递归算法的时间复杂度怎么计算?⼦问题个数乘以解决⼀个⼦问题需要的时间
⼦问题个数,即递归树中节点的总数。显然⼆叉树节点总数为指数级别,所以⼦问题个数为 O(2^n)。解决⼀个⼦问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) ⼀个加法操作,时间为O(1)。所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。

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++)
		dp[i] = dp[i - 1] + dp[i - 2];
	return dp[N];
}

在这里插入图片描述
实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,⼤部分情况下,效率也基本相同。
在这里插入图片描述
你把 f(n) 想做⼀个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移⽽来,这就叫状态转移,很容易发现,其实状态转移⽅程直接代表着暴⼒解法。

细节优化

int fib(int n) {
	if (n == 2 || n == 1)
		return 1;
	int prev = 1, curr = 1;
	for (int i = 3; i <= n; i++) {
		int sum = prev + curr;
		prev = curr;
		curr = sum;
	}
	return curr;
}

凑零钱问题

先看下题⽬:给你 k 种⾯值的硬币,⾯值分别为 c1, c2 … ck ,每种硬币的数量⽆限,再给⼀个总⾦额 amount ,问你最少需要⼏枚硬币凑出这个⾦额,如果不可能凑出,算法返回 -1 。

1、暴⼒递归
⾸先,这个问题是动态规划问题,因为它具有「最优⼦结构」的。要符合「最优⼦结构」,⼦问题间必须互相独⽴。

那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移⽅程?

先确定「状态」,也就是原问题和⼦问题中变化的变量。由于硬币数量⽆
限,所以唯⼀的状态就是⽬标⾦额 amount 。

然后确定 dp 函数的定义:当前的⽬标⾦额是 n ,⾄少需要 dp(n) 个硬
币凑出该⾦额。

然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当
前状态。具体到这个问题,⽆论当的⽬标⾦额是多少,选择就是从⾯额列表
coins 中选择⼀个硬币,然后⽬标⾦额就会减少:

# 伪码框架
def coinChange(coins: List[int], amount: int):
	# 定义:要凑出⾦额 n,⾄少要 dp(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 + subproblem)
			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]
		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 + subproblem)
		# 记⼊备忘录
		memo[n] = res if res != float('INF') else -1
		return memo[n]
	return dp(amount)

3、dp 数组的迭代解法
当然,我们也可以⾃底向上使⽤ dp table 来消除重叠⼦问题, dp 数组的定义和刚才 dp 函数类似,定义也是⼀样的:

dp[i] = x 表⽰,当⽬标⾦额为 i 时,⾄少需要 x 枚硬币。
这里可以建立一个字典存取数组dp,x相当于剩下需要的最大面额。

int coinChange(vector<int>& coins, int amount) {
	// 数组⼤⼩为 amount + 1,初始值也为 amount + 1
	vector<int> dp(amount + 1, amount + 1);
	// base case
	dp[0] = 0;
	for (int i = 0; i < dp.size(); i++) {
		// 内层 for 在求所有⼦问题 + 1 的最⼩值
		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」的⽅法来优化递归树,并且明确了这两种⽅法本质上是⼀样的,只是⾃顶向下和⾃底向上的不同⽽已。第⼆个凑零钱的问题,展⽰了如何流程化确定「状态转移⽅程」,只要通过状态转移⽅程写出暴⼒递归解,剩下的也就是优化递归树,消除重叠⼦问题⽽已。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值