欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

动态规划 Dynamic programming

程序员文章站 2022-03-27 17:10:14
文章目录一、前言二、优点三、引申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):

动态规划 Dynamic programming
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 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)

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

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

三、引申

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

动态规划 Dynamic programming
让我们多看一些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。

动态规划 Dynamic programming
递归版本:

动态规划 Dynamic programming
迭代版本:

动态规划 Dynamic programming
代码:

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的网格,其中填充了非负数,请找到一条从左上到右下的路径,该路径将沿其路径的所有数字的总和最小化。

注意:您只能在任何时间点向下或向右移动。
动态规划 Dynamic programming
例如输入:
[
    [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

相关标签: 数据结构与算法