详解动态规划——从暴力递归到动态规划

以下内容参照了https://zhuanlan.zhihu.com/p/78220312。
动态规划遵循一套固定的流程:
递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法
下面以斐波那契数列为例,揭开动态规划的面纱:

斐波那契数列

一、暴力递归法

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

但是使用递归算法求解该问题往往是十分低效的,不仅存在占用内存大,且存在大量重复计算。如下图,当我们计算 f ( 20 ) f(20) f(20),可看到每个分支都存在大量重复计算,如果我们能够把这些计算过的值先保存下来,是不是就可以避免了重复计算,这就是带有备忘录的递归解法,这也是动态规划的第一个性质:重叠子问题。在讲这个方法前,我们先来分析一下递归算法的时间复杂度,子问题的个数乘以解决一个子问题的时间。图中的结点即是一个子问题,解决一个子问题的时间,即 f ( n − 1 ) + f ( n − 2 ) f(n-1)+f(n-2) f(n1)+f(n2)显然为 O ( 1 ) O(1) O(1),所以递归算法的时间复杂度与结点数相同为 O ( 2 n ) O(2^n) O(2n)
在这里插入图片描述

二、带备忘录的递归解法

在上一个问题中,我们得出结论,如果能够保留此前计算出的值,即增加一个备忘录,就能提高递归解法的效率。下面的代码实际上做到了,如果备忘录中无值则进行相应计算,有值则直接返回,大大减少了计算所需要的的时间。

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 ) O(n) O(n)
至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

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

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

三、动态规划

动态规划的难点在于找到一个状态转移方程。对于斐波那契数列来说,这个很好找,即 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n1)+f(n2)。而且,有了上一步备忘录的启发,我们可以将备忘录独立成一张表,即DP Table

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];
}

在这里插入图片描述
但对于上面的代码,我们还可以继续优化,我们需要保存的只有当前状态和之前的两个状态,所以可以将时间复杂度降为 O ( 1 ) O(1) O(1)

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

斐波那契数列的例子严格来说不算动态规划,以上旨在演示算法设计螺旋上升的过程。当问题中要求求一个最优解或在代码中看到循环和 max、min 等函数时,十有八九,需要动态规划大显身手。

凑零钱

题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,再给一个总金额 n,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,则回答 -1 。
比如说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少需要 3 枚硬币,即 11 = 5 + 5 + 1 。下面走流程。

一、暴力解法

第一步,写出状态转移方程:
f ( n ) f(n) f(n)是对于凑出零钱 n n n,所需要的最小次数。当 n = 0 n=0 n=0,返回0,其他情况时,返回 1 + m i n ( f ( n − c 1 ) ) 1+min(f(n-c_1)) 1+min(f(nc1)),此处加1是最后一次凑零钱的操作。
在这里插入图片描述
其实,这个方程就用到了最优子结构性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。

记住,要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高… 当然,最终就是你每门课都是满分,这就是最高的总成绩。

得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。

回到凑零钱问题,显然子问题之间没有相互制约,而是互相独立的。所以这个状态转移方程是可以得到正确答案的。

然后按照方差写出暴力递归算法即可:

int coinChange(vector<int>& coins, int amount) {
    if (amount == 0) return 0;
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = coinChange(coins, amount - coin);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    return ans == INT_MAX ? -1 : ans;
}

在这里插入图片描述
时间复杂度分析:子问题总数 x 每个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是 O ( n k ) O(n^k) O(nk),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O ( k ) O(k) O(k)。所以总时间复杂度为 O ( k ∗ n k ) O(k*n^k) O(knk),指数级别。
接下来,我们引入备忘录机制:
二、带备忘录的递归算法

int coinChange(vector<int>& coins, int amount) {
    // 备忘录初始化为 -2
    vector<int> memo(amount + 1, -2);
    return helper(coins, amount, memo);
}

int helper(vector<int>& coins, int amount, vector<int>& memo) {
    if (amount == 0) return 0;
    if (memo[amount] != -2) return memo[amount];
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = helper(coins, amount - coin, memo);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    // 记录本轮答案
    //*********************************************
    memo[amount] = (ans == INT_MAX) ? -1 : ans;
    //*********************************************
    return memo[amount];
}

三、动态规划
有了状态转移方程之后,该问题的代码就不难写了,构建动态规划表:

int coinChange(vector<int>& coins, int amount) {
    vector<int> dp(amount + 1, amount + 1);
    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];
}

在这里插入图片描述

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值