动态规划的前世(背景)——递归


前言

先介绍一下分治算法:

  • 分治策略:
    将原问题分解为若干个规模较小但类似于原问题的子问题,递归的求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
    因为在求解大问题时,需要递归的求小问题,因此一般用递归的方法实现,即自顶向下(下面解释)

  • 动态规划:
    动态规划其实和分治策略是类似的,也是将一个原问题分解为若干个规模较小的子问题,递归的求解这些子问题,然后合并子问题的解得到原问题的解。

区别在于这些子问题会有重叠,一个子问题在求解后,可能会再次求解,于是我们想到将这些子问题的解存储起来,当下次再次求解这个子问题时,直接拿过来就是
其实就是说,动态规划所解决的问题是分治策略所解决问题的一个子集,只是这个子集更适合用动态规划来解决从而得到更小的运行时间。

即用动态规划能解决的问题分治策略肯定能解决,只是运行时间长了。因此,分治策略一般用来解决子问题相互对立的问题,称为标准分治,而动态规划用来解决子问题重叠的问题。

根据例题解释,如何从递归(自顶向下)到动态规划(自底向上),并解决子问题重叠。

1.以斐波那契问题为例

递归算法:
int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

在这里插入图片描述
这个算法的时间复杂度为 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);
    // 初始化最简情况
    memo[1] = memo[2] = 1;
    return helper(memo, N);
}

int helper(vector<int>& memo, int n) {
    // 未被计算过
    if (n > 0 && memo[n] == 0) 
        memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。

动态规划

至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。

啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

int fib(int N) {
    vector<int> dp(N + 1, 0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}
总结

对比递归代码和动态规划里的循环,return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],你会发现, 若把f(n)当作一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来。这一过程就叫状态转移

这恰恰是解决问题的核心。

2.以爬楼梯为例

问题描述:

一个人爬楼梯,每次只能爬1个或2个台阶,假设有n个台阶,那么这个人有多少种不同的爬楼梯方法

问题分析:

在这里,可以思考一下:可以根据第一步的走法把所有走法分为两类:

① 第一类是第一步走了 1 个台阶

② 第二类是第一步走了 2 个台阶

所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 ,然后加上先走 2 阶后,n-2 个台阶的走法。

用公式表示就是:

f(n) = f(n-1)+f(n-2)
递归代码

有了递推公式,递归代码基本上就完成了一半。那么接下来考虑递归终止条件。

当有一个台阶时,我们不需要再继续递归,就只有一种走法。

所以f(1)=1

通过用n = 2,n = 3这样比较小的数试验一下后发现这个递归终止条件还不足够。

n = 2时,f(2) = f(1) + f(0)。如果递归终止条件只有一个f(1) = 1,那 f(2) 就无法求解,递归无法结束。
所以除了 f(1) = 1 这一个递归终止条件外,还要有 f(0) = 1,表示走 0 个台阶有一种走法,从思维上以及动图上来看,这显得的有点不符合逻辑。所以为了便于理解,把 f(2) = 2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。

总结如下:

① 假设只有一个台阶,那么只有一种走法,那就是爬 1 个台阶

② 假设有两个个台阶,那么有两种走法,一步走完或者分两步来走

通过递归条件:

f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)

很容易推导出递归代码:

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

如果以递归的方式解决的话,那么这种方法的时间复杂度为O(2^n).

动态规划

在这里插入图片描述
通过图片可以发现一个现象,我们是 自顶向下 的进行递归运算,比如:f(n) 是f(n-1)与f(n-2)相加,f(n-1) 是f(n-2)与f(n-3)相加。

思考一下:如果反过来,采取自底向上,用迭代的方式进行推导会怎么样了?

表格的第一行代表了楼梯台阶的数目,第二行代表了若干台阶对应的走法数。
其中f(1) = 1 和 f(2) = 2是前面明确的结果。

第一次迭代,如果台阶数为 3 ,那么走法数为 3 ,通过 f(3) = f(2) + f(1)得来。

第二次迭代,如果台阶数为 4 ,那么走法数为 5 ,通过 f(4) = f(3) + f(2)得来。

由此可见,每一次迭代过程中,只需要保留之前的两个状态,就可以推到出新的状态。
解决了子问题重叠问题。

int f(int n) {
    if (n == 1) return 1;
    if (n == 2) return 2;
    // a 保存倒数第二个子状态数据,b 保存倒数第一个子状态数据, temp 保存当前状态的数据
    int a = 1, b = 2;
    int temp = a + b;
    for (int i = 3; i <= n; i++) {
        temp = a + b;
        a = b;
        b = temp; 
    }
    return temp; 
}

程序从 i = 3 开始迭代,一直到 i = n 结束。每一次迭代,都会计算出多一级台阶的走法数量。迭代过程中只需保留两个临时变量 a 和 b ,分别代表了上一次和上上次迭代的结果。为了便于理解,引入了temp变量。temp代表了当前迭代的结果值。

看一看出,事实上并没有增加太多的代码,只是简单的进行了优化,时间复杂度便就降为O(n),而空间复杂度也变为O(1),这,就是「动态规划」的强大!

总结

动态规划的在于三个重要概念:
在「 爬台阶问题 」中:


f(10) = f(9) + f(8) //是【最优子结构】  
f(1)f(2)// 是【边界】  
f(n) = f(n-1) + f(n-2) //【状态转移公式】

总结

动态规划在递归思想的基础上:

1.动态规划解决了子问题重叠。
2.动态规划采用自底向上递推。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Strive_LiJiaLe

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值