动态规划 Dynamic programming

一、前言

动态规划(英語: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];
}

3. Longest Palindromic Substring 最长回文子串

给定一个字符串s,返回 最长的回文子在s。

示例:
Input: s = “babad” Output: “bab” Note: “aba” is also a valid answer.
Input: s = “cbbd” Output: “bb”
Input: s = “a” Output: “a”

问题分解:
设状态变量state(s, e)表示 str[s, e] 是否是回文

for s = e, "a" 是回文
for s + 1 = e, "aa" 是回文 (如果 str[s] = str[e])
for s + 2 = e, "aba" 是回文 (如果 "b" 是回文 且 str[s] = str[e] )
for s + 3 = e, "abba"是回文 (如果 "bb"是回文 且 str[s] = str[e] )
for s + 4 = e, "abcba"是回文 (如果 "c"是回文 且 “bcb” 是回文..... 且 str[s + 1, e - 1]  是回文 且 str[s] = str[e] )
··· 递归 ···

我们意识到:

for s + dist = e, str[s, e] will be palindromic (if str[s] == str[e] and str[s + 1, e - 1] is palindromic)


S为回文起点,dist为回文长度,e为回文终点

列出状态转换方程:

state(s, e) is true:
for s = e, 
for s + 1 = e,  if str[s] == str[e]
for s + 2 <= e, if str[s] == str[e] && state(s + 1, e - 1) is true

注意:state(s + 1, e - 1)之前应该计算state(s, e)。也就是说,在 bottop-up dp 实现期间 s 正在减少,而dists 和 e 之间正在增加,这就是为什么

 for (int s = len - 1; s >= 0; s--) {
            for (int dist = 1; dist < len - i; dist++) {

实现:

class Solution {
public:
    string longestPalindrome(string s) {
        if (s.length() <= 1) {
            return s;
        }

        int len = s.length();

        // state[i][j] true if s[i, j] is palindrome.
        vector<vector<bool>> state(len, vector<bool>(len));
    
        // Base cases.
        for (int i = 0; i < len; i++) { 
            state[i][i] = true; // dist = 0.
        }

        // to compute state[i][j] we need state[i + 1][j - 1]
        // so the i - outer loop needs to go from higher to lower
        // and the j - inner loop needs to go from lower to higher
        int maxLen = 1, maxSta = 0;
        for (int i = len; i >= 0; i--) {
            // dist of 0 - already covered by initialization
            for (int dist = 1; dist < len - i; dist++) {
                int j = i + dist;
                // we are ready to compute dist [i] [j]
                if (dist == 1){
                    state[i][j] = (s.at(i) == s.at(j));
                } else {
                    state[i][j] = (s.at(i) == s.at(j)) && state[i + 1][j - 1];
                }
                // state[i][j] = (dist == 1) ? s.at(i) == s.at(j) : (s.at(i) == s.at(j)) && state[i + 1][j - 1];
                // if true
                if (state[i][j] && (j - i + 1) > maxLen) {
                    maxLen = j - i + 1;
                    maxSta = i;
                }
            }
        }       
        return s.substr(maxSta, maxLen);
    }
};

优化流程:

class Solution{
public:
    string longestPalindrome(string s) {
        int slength = s.length();

        if (slength <= 1)
            return s;

        vector<vector<bool>> dp(slength, vector<bool>(slength));
        // bool (*dp)[slength] = (bool(*)[slength])malloc(sizeof(bool) * slength * slength);

        for (int i = 0; i < slength; i++)
            dp[i][i] = true;
        
        int longestPalindromeStart = 0, longestPalindromeLength = 1;
        for (int start = slength - 1; start >= 0; start--) {
            for (int end = start + 1; end < slength; end++) {
                if (s.at(start) == s.at(end)) {
                    if (end - start == 1 || dp[start + 1][end - 1]) {
                        dp[start][end] = true;
                        if (longestPalindromeLength < end - start + 1) {
                            longestPalindromeStart = start;
                            longestPalindromeLength = end - start + 1;
                        }
                    }
                }

            }
        }

        // free(dp);
        return s.substr(longestPalindromeStart, longestPalindromeLength);
    }
};

int main()
{
    Solution sol;
    string str;
    std::cin >> str;
    std::cout << sol.longestPalindrome(str) << std::endl;
    return 0;
}

可进行空间优化:

class Solution{
public:
    string longestPalindrome(string s) {
        if(s.empty() || s.size() == 1) return s;
            int len = s.size();
            bool dp[len];
            int left, length;
            left = 0;
            length = 1;
            for(int j = 1; j < len; j++) {
                for(int i = 0; i < j; i++) {
                    if(j - i <= 2) {
                  	    dp[i] = s[i] == s[j];
                    } else {
                        dp[i] = (s[i] == s[j]) && dp[i + 1];
                    }

                    if(dp[i] && (j - i + 1) > length) {
                        length = j - i + 1;
                        left = i;
                    }
                }
            }
            return s.substr(left, length);
    }
};

int main()
{
    Solution sol;
    string str;
    std::cin >> str;
    std::cout << sol.longestPalindrome(str) << std::endl;
    return 0;
}



参考文章:

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值