当前位置: 移动技术网 > IT编程>开发语言>Java > 动态规划 Dynamic programming

动态规划 Dynamic programming

2020年09月01日  | 移动技术网IT编程  | 我要评论
文章目录一、前言二、优点三、引申1. [coin-change](https://leetcode.com/problems/coin-change/solution/) 换零钱问题2. [Minimum Path Sum](https://leetcode.com/problems/minimum-path-sum/) 最小路径问题3. [Partition Equal Subset Sum](https://leetcode.com/problems/partition-equal-subset-sum

一、前言

动态规划(英語:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

动态规划常常适用于有重叠子问题最优子结构性质的问题,相对与递归解法的自顶向下,动态规划的自底而上由循环迭代完成计算,其所耗时间往往远少于传统递归。

动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。

所以,动态规划问题的一般形式就是求最值。既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。

其主要可以分为这么几大类:

  • 树形DP:01背包问题
  • 线性DP:最长公共子串,最长公共子序列
  • 区间DP:矩阵最大值
  • 数位DP:数字游戏
  • 状态压缩DP:旅行商

二、优点

我们在做Fibonacci Number计算时,常根据公式进行暴力算法:

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

我们也知道这样写代码虽然简洁易懂,但是十分低效,低效在哪里?假设 n = 20,请画出递归树(图片来源:labuladong):

在这里插入图片描述
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效 (复杂度达到了 O ( 2 n ) O(2^n) O(2n)) 。

这就是动态规划问题的第一个性质:重叠子问题。我们前面说过,动态规划就是空间换时间,暴力穷举,但如果仅仅暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

使用经典的空间换时间的动态规划,先穷举,然后查询:

int fib(int N) {
    if(N < 2)
        return N;
    int memo[N+1];
    memo[0] = 0;
    memo[1] = 1;
    for(int i=2; i<=N; i++)
        memo[i] = memo[i-1] + memo[i-2];
    return memo[N];
}
  • Time Complexity - O(N)
  • Space Complexity - O(N)

当然,我们在这里观察也得到,并不是每一次的结果都需要存储起来,如果我们只需要最后的结果,那么我们只需要存储其上两次的值,这能为我们节约许多空间。

int fib(int N) {
    if(N < 2) 
        return N;
	int a = 0, b = 1, c = 0;
    for(int i = 1; i < N; i++)
    {
        c = a + b;
        a = b;
        b = c;
    }
    return c;
}
  • Time Complexity - O(N)
  • Space Complexity - O(1)

在这里插入图片描述
这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,上述例子就相当于把DP table 的大小从 n 缩小到 2。一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。

有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示得到最优解法逐步求精的过程。

三、引申

动态规划的诀窍就在与找到状态转移方程进行枚举,当然Fibonacci Number状态转移方程就是其公式,其他的就需要我们思考,过程就是观察其是否可以拆成子问题,找到父子问题之间的关联。

在这里插入图片描述
让我们多看一些DP的例子加深印象。

1. coin-change 换零钱问题

给你 k 种面值的硬币,面值分别为 c 1 , c 2 . . . c k c_1, c_2 ... c_k c1,c2...ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。

在这里插入图片描述
递归版本:

在这里插入图片描述
迭代版本:

在这里插入图片描述
代码:

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 就相当于初始化为正无穷,便于后续取最小值。

2. Minimum Path Sum 最小路径问题

给定一个m × n的网格,其中填充了非负数,请找到一条从左上到右下的路径,该路径将沿其路径的所有数字的总和最小化。

注意:您只能在任何时间点向下或向右移动。
在这里插入图片描述
例如输入:
[
    [1,3,1],
    [1,5,1],
    [4,2,1]
]
输出: 7
说明:因为路径1→3→1→1→1使总和最小。

这是一个典型的DP问题。假设到达点的最小路径总和(i, j)为S[i][j],则状态方程为

  • S[i][j] = min(S[i - 1][j], S[i][j - 1]) + grid[i][j]。

好吧,需要处理一些边界条件。边界条件发生在最上面的行(S[i - 1][j]不存在)和最左边的列(S[i][j - 1]不存在)上。假设grid就像[1, 1, 1, 1],那么到达每个点的最小和只是前一个点的累加,结果是[1, 2, 3, 4]。

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size(); 
        vector<vector<int> > sum(m, vector<int>(n, grid[0][0]));
        for (int i = 1; i < m; i++)
            sum[i][0] = sum[i - 1][0] + grid[i][0];
        for (int j = 1; j < n; j++)
            sum[0][j] = sum[0][j - 1] + grid[0][j];
        for (int i = 1; i < m; i++)
            for (int j = 1; j < n; j++)
                sum[i][j]  = min(sum[i - 1][j], sum[i][j - 1]) + grid[i][j];
        return sum[m - 1][n - 1];
    }
};

可以看出,每次更新时sum[i][j],我们只需要sum[i - 1][j](在当前列)和sum[i][j - 1](在左列)。因此,我们不需要维护完整的m*n矩阵。维护两列就足够了。

3. Partition Equal Subset Sum 背包问题

给定一个仅包含正整数的非空数组,请确定该数组是否可以划分为两个子集,以使两个子集中的元素之和相等。

范例:
输入:[1、5、11、5]
输出:true
说明:数组可以划分为[1、5、5] 和 [11]。

按照背包问题的套路,可以给出如下定义:

dp[i][j] = x表示,对于前i个物品,当前背包的容量为j( j=sum/2) 时,若x为true,则说明可以恰好将背包装满,若x为false,则说明不能恰好将背包装满。

比如说,如果dp[4][9] = true,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。

根据这个定义,我们想求的最终答案就是dp[N][sum/2],base case 就是dp[..][0] = true 和 dp[0][..] = false,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。

回想刚才的dp数组含义,可以根据「选择」对 dp[i][j] 得到以下状态转移:

  • 背包已经装满:如果不把nums[i]算入子集,或者说你不把这第i个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态dp[i-1][j],继承之前的结果。
  • 背包还未装满:如果把nums[i]算入子集,或者说你把这第i个物品装入了背包,那么是否能够恰好装满背包,取决于状态dp[i - 1][j-nums[i-1]]

首先,由于i是从 1 开始的,而数组索引是从 0 开始的,所以第i个物品的重量应该是nums[i-1],这一点不要搞混。

dp[i - 1][j-nums[i-1]]也很好理解:你如果装了第i个物品,就要看背包的剩余重量j - nums[i-1]限制下是否能够被恰好装满。

换句话说,如果j - nums[i-1]的重量可以被恰好装满,那么只要把第i个物品装进去,也可恰好装满j的重量;否则的话,重量j肯定是装不满的。

coding:

bool canPartition(vector<int>& nums) {
    int sum = 0;
    for (int num : nums) sum += num;
    // 和为奇数时,不可能划分成两个和相等的集合
    if (sum % 2 != 0) return false;
    int n = nums.size();
    sum = sum / 2;
    
    vector<vector<bool>> dp(n + 1, vector<bool>(sum + 1, false));
    // base case
    for (int i = 0; i <= n; i++)
        dp[i][0] = true;

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= sum; j++) {
            if (j - nums[i - 1] < 0) {
               // 背包容量不足,不能装入第 i 个物品
                dp[i][j] = dp[i - 1][j]; 
            } else {
                // 装入或不装入背包
                dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
            }
        }
    }
    return dp[n][sum];
}



参考文章:

本文地址:https://blog.csdn.net/weixin_40539125/article/details/108558643

如您对本文有疑问或者有任何想说的,请点击进行留言回复,万千网友为您解惑!

相关文章:

验证码:
移动技术网