[LeetCode] 动态规划题型总结

写在前面

动态规划在互联网公司的笔试题中经常会使大题的压轴题,解动态规划题的关键是定义动态规划变量和写出状态转移方程,本篇博客主要探讨动态规划题型解法,最后也会介绍动态规划与其他算法知识结合的题。

152. 乘积最大子数组

给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

解题思路: 此题很容易受连续子数组最大和值 题的影响,以当前元素结尾序列的最大和值只取决于当前值、当前值+前一序列最大和,但是对于乘法,因为一个数a乘上数b,若数a是最大值,乘上数b后,若数b为正数,那么依然能保证是最大值,但是若数b为负数,反而会成为最小值,因此,我们会发现求当前位置的最大乘积值,要同时关心前一个位置的最大乘积值和最小乘积值,此题属于双动态规划题,定义动态规划变量mx[i]表示以nums[i]为结尾的最大乘积值,mn[i]表示以nums[i]为结尾的最小乘积值,状态转移方程如下:

mn[i] = min(nums[i], min(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
mx[i] = max(nums[i], max(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
// DP,以nums[i]为结尾的序列最大乘积由nums[i],MIN[i-1]*nums[i],MAX[i-1]*nums[i]
// 决定且取最大值,而最小乘积也有三者决定且取最小值
// T: O(n), space: O(n)
class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int n = nums.size();
        int res = nums[0];
        vector<int> mn(n), mx(n);
        mn[0] = nums[0];
        mx[0] = nums[0];
        for (int i = 1; i < n; ++i) {
            mn[i] = min(nums[i], min(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
            mx[i] = max(nums[i], max(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
            res = max(res, mx[i]);
        }
        return res;
    }
};

44. 通配符匹配

给定一个字符串 (s) 和一个字符模式 § ,实现一个支持 ‘?’ 和 ‘*’ 的通配符匹配。

'?' 可以匹配任何单个字符。
'*' 可以匹配任意字符串(包括空字符串)。

两个字符串完全匹配才算匹配成功。

说明:

  • s 可能为空,且只包含从 a-z 的小写字母。
  • p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。

原题链接

解题思路: 此题比较适合用动态规划解题,因为两个字符串匹配可以转化为两个字符串前缀子串匹配的问题(将问题化成子问题,典型的动态规划特征),由于要考虑空串,因此定义dp[i][j]为s前i个字符和p前j个字符是否匹配,而与状态(i,j)关联的有三个状态,即(i-1,j),(i-1,j-1),(i,j-1),因此,动态转移方程:

dp[i][j] = dp[i][j-1] || dp[i][j-1] || dp[i][j-1]

注:动态规划解题的关键是,找出与当前状态关联的其他状态(完备且互斥),然后思考与他们的关联(即状态转变)。

// DP, dp[i][j]表示s前i个字符与p前j个字符匹配
// dp[i][j]下面两项决定:
// 1. p[j-1]='*'时,dp[i-1][j] || dp[i][j-1]
// 2. s[i-1]==p[j-1] || p[j]=='?' || p[j] == '*'时,dp[i-1][j-1]
// time&space: O(n*m)
class Solution {
public:
    bool isMatch(string s, string p) {
        int n = s.size(), m = p.size();
        vector<vector<bool>> dp(n + 1, vector<bool>(m + 1, false));
        dp[0][0] = true;
        for (int i = 0; i < m; ++i) {
            if (p[i] == '*') dp[0][i + 1] = true;
            else break; 
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                if (p[j - 1] == '*') dp[i][j] = dp[i][j] || dp[i - 1][j] || dp[i][j - 1];
                if (s[i - 1] == p[j - 1] || p[j - 1] == '?' || p[j - 1] == '*') {
                    dp[i][j] = dp[i][j] || dp[i - 1][j - 1];
                }
            }
        }
        return dp[n][m];
    }
};

91. 解码方法

原题链接

解题思路: 分析题特征,若想知道整个字符串的解码个数,是不是可以考虑若知道某个前缀解码个数,然后每次解码选取的数只能在[1,26]范围内,因此要么选一个字符,要么选两个字符组合,选一个字符时要求不能为’0’,选两个字符组合时,要求形成的数在[10,26]范围内,这种若求一个状态可求另一个(子)状态的题最适合用Dynamic Programming解题,状态转移方程好写,dp[i]=dp[i-1]+dp[i-2]dp[i]表示前i个字符组成的字符串有多少种解码,对于长度为0的dp[0]我们选择初始化它为1,这里主要是考虑当s[0]和s[1]组合的值恰好在[10,26]范围内,那这个组合值算1种解法。此题的corner case应该是与0相关的测试点,0在前或者后。

// DP, dp[i]=dp[i-1]+dp[i-2],考虑与'0'相关的corner case
// T&S: O(n)
class Solution {
public:
    int numDecodings(string s) {
        if (s[0] == '0') return 0;
        int n = s.size();
        vector<int> dp(n + 1);
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] != '0') dp[i] = dp[i - 1];
            int num = 10 * (s[i - 2] - '0') + s[i - 1] - '0';
            if (num >= 10 && num <= 26) dp[i] += dp[i - 2];
        }
        return dp[n];
    }
};

639. 解码方法 2

原题链接

解题思路: 与题91不同的是,本题引入了'*',可以当做1-9,但是解题的本质与题91是相同的,增添的工作量仅仅是需要更多种情况的分类讨论,在DP基础上写好此题的关键是,假设当前位置是i,分类讨论s[i-1]s[i]是否为'*'的2*2种情况。OK,看下面代码。

class Solution {
public:
    int numDecodings(string s) {
        if (s[0] == '0') return 0;
        if (s.size() == 1) {
            if (s[0] == '*') return 9;
            else return 0;
        }
        int n = s.size();
        long M = 1e9+7;
        vector<long> dp(n + 1, 0);
        dp[0] = 1;
        dp[1] = s[0] == '*' ? 9 : 1;
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] == '*') {
                dp[i] = (dp[i] + dp[i - 1] * 9) % M;
                if (s[i - 2] != '*') {
                    for (int j = 1; j <= 9; ++j) {
                        int num = (s[i - 2] - '0') * 10 + j;
                        if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                    }
                } else {
                    for (int k = 1; k <= 9; ++k) {
                        for (int j = 1; j <= 9; ++j) {
                            int num = k * 10 + j;
                            if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                        }
                    }
                }
            } else {
                if (s[i - 2] == '*') {
                    if (s[i - 1] != '0') dp[i] = (dp[i] + dp[i - 1]) % M;
                    for (int j = 1; j <= 9; ++j) {
                        int num = j * 10 + s[i - 1] - '0';
                        if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                    }
                } else {
                    if (s[i - 1] != '0') dp[i] = (dp[i] + dp[i - 1]) % M;
                    int num = (s[i - 2] - '0') * 10 + s[i - 1] - '0';
                    if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                }
            }
            
         }
         return dp[n];
    }
};

97. 交错字符串

原题链接

解题思路: 此题起初的想法是,若s1和s2中不存在相同的字符串题目就好解了,直接双指针移动即可,而使此题成为hard的题的关键是,若将s3与s1和s2对应位比对时,此时s1和s2对应位字符相同,那该由哪一个匹配s3对应位就不好抉择了,因为这会影响到后续的比对,想到着,我想出的第一个算法是,对于当前位选与不选的问题,至少有两个方法可以提供使用,即回溯算法和动态规划的01背包,于是先选择用回溯法解,但是回溯算法若不剪枝的复杂度太高,OJ果然TLE了,然后想到用动态规划解,分析一下,一个状态有三个变量决定,即s1s2s3三个对应位置i,j,k决定,而k=i+j,因此可以进一步简化一下,由i和j决定,因此定义状态dp[i][j]为s1前i个字符和s2前j个字符能交错成长度为(i+j)的s3,而s3的(i+j-1)位置上的字符要么来自s1要么来自s2,OK状态转移方程有了:

dp[i][j] = (dp[i - 1][j] && s1[i - 1] == s3[i + j - 1]) ||  (dp[i][j - 1] && s2[j - 1] == s3[i + j - 1]);

完美解决动态规划问题由三步组成,即定义状态+状态转移+状态初始化和更新。因此接下来就要做状态初始化和更新的分析,这一步,我们要保证在推进到状态(i,j)之前,状态(i-1,j)和(i-1,j)都被初始化和更新,在这里,因为i和j都是沿着递增方向推进的,因此不会出现前置状态未更新。详细的分析推荐看这篇博客,博主总结的一条规律,感觉不错,引用如下。

只要是遇到字符串的子序列或是匹配问题直接就上动态规划 Dynamic Programming,其他的都不要考虑,什么递归呀的都是浮云(当然带记忆数组的递归写法除外,因为这也可以算是 DP 的一种),千辛万苦的写了递归结果拿到 OJ 上妥妥 Time Limit Exceeded

class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        int n = s1.size(), m = s2.size(), r = s3.size();
        if (n + m != r) return false;
        vector<vector<bool>> dp(n + 1, vector<bool>(m + 1, false));
        dp[0][0] = true;
        for (int i = 1; i <= n; ++i) {
            dp[i][0] = dp[i - 1][0] && s1[i - 1] == s3[i - 1];
        }
        for (int j = 1; j <= m; ++j) {
            dp[0][j] = dp[0][j - 1] && s2[j - 1] == s3[j - 1];
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                dp[i][j] = (dp[i - 1][j] && s1[i - 1] == s3[i + j - 1]) ||
                            (dp[i][j - 1] && s2[j - 1] == s3[i + j - 1]);
            }
        }
        return dp[n][m];
    }
};

32. 最长有效括号

原题链接

解题思路: 此题解起来还是有一定难度的,我没解出来,参考了这篇博客思路,但是仅根据博客思路,虽然代码能AC,但是还是不能证明为什么这个解法是对的(可能是我太笨啦╮(╯▽╰)╭),这里记录一下我对这题的分析以证明前面解法的合理性。考察配对的问题,比如括号、运算符等等,最常规的是用来解题,这个在《数据结构》书上一般都会举出这个例子,而真正觉得此题为hard的是字符串中间若插入了不能配对的(或者)这个字符串就不能取,但是用栈解题的基本框架我们可以先搭起来,然后在遇到)时,处理字符串长度,我们用start记录可能合理的字符串起始位置,我们可以分析下面一组序列,因为1-3字符串出现3因此不能与后序字符串作为一个合理字符串计算长度,因此start从下一个位置开始计算合理字符串,OK,我们从4开始往后分析,若在遍历到位置10时,若我们发现栈不为空,即6,我们展示还无法确定11是否能与6匹配(i.e.,字符串会不会因为6无法匹配导致不能从start开始合理),因此当前位置我们只能上一个元素,即6,作为计算当前合理字符串的起点(注意这里不能以9作为起点因为会漏掉7和8,这个在LT水池题中出现过),当然,若我们遍历到11位置,发现出栈后栈为空,那说明从start位置开始,所有元素均完成配对,所以可以肯定从start至当前位置字符串是合理的,于是可以用start位置计算,那么,我们为什么在出栈后要判断栈是否空呢?这是因为我们在是否用start做计算,i.e.,是否认定从start开始到当前位置字符串合理拿捏不准,因此,以出栈后栈是否为空来做最长合理串的尽力计算(贪婪思想)。
在这里插入图片描述

class Solution {
public:
    int longestValidParentheses(string s) {
        stack<int> st;
        int start = 0, res = 0;
        for (int i = 0; i < s.size(); ++i) {
            if (s[i] == '(') st.push(i);
            else {
                if (st.empty()) start = i + 1;
                else {
                    st.pop();
                    if (st.empty()) res = max(res, i - start + 1);
                    else res = max(res, i - st.top());
                }
            }
        }
        return res;
    }
};

标签里提到动态规划解题,此题属于字符串子串问题,也应该要想到是否能用动态规划题。此题我动态规划调试了半个多小时,AC后感慨了一会,果然是编写思维不严谨,调试耗时数万倍,所以我们在写代码的时候,对所有代码的位置编排、条件的并排或嵌套,都要能明确的思路说服自己为什么这么写,只有这样写出来的代码才能bug-free。OK,看此题动态规划解题思路,所有的括号关系我们可以归纳为要么嵌套(i.e.,包含)要么并排,在判断括号字符串是否有效之前,首先要考察自身是否能合理,然后在尽量利用括号关系往内部(i.e.,嵌套)或者向左边(i.e.,并排)贪婪计算尽可能长的长度。设dp[i]表示包含第i-1个字符的字符串前i个字符能组成的最长合理括号串的长度,只有结尾是)的字符串dp值才非零,那么dp[i]前的dp[i-1]若非零,则表示dp[i]前面有长度为dp[i-1]是合理字符串,OK,那我们往前推进dp[i-1]+1长度即可找到s[i - dp[i-1] - 2],看它是否为(,i.e.,判断dp[i]是否配对,若不配对,实际上就不用再考虑嵌套和并排的关系了,直接为0,在配对的情况下,考虑嵌套关系,则可在dp[i-1]长度基础上再增加两个配对元素,i.e., dp[i-1]+2,再考虑并排关系,再向前推一个元素,若s[i - dp[i-1] - 3])或者说dp[i - dp[i-1] - 2]非零,则可将前面并排的合理字符串也并入其中。

class Solution {
public:
    int longestValidParentheses(string s) {
        int n = s.size(), res = 0;
        vector<int> dp(n + 1, 0);
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] == ')') {
                int j = i - dp[i - 1] - 2;
                if (j >= 0 && s[j] == '(') {
                    dp[i] = dp[i - 1] + 2;
                    dp[i] += dp[j];    
                }       
            }
            res = max(res, dp[i]);
        }
        return res;
    }
};

87. 扰乱字符串

原题链接

解题思路: 看到字符串子串问题,我们优先想到的是动态规划,但在用动态规划解题之前先介绍递归解法。我们先分析一下题意,题目定义将字符串写成二叉树,将二叉树的节点的左右子节点交换,得到的新字符串为扰乱字符串,题眼即左右子节点交换,而反应到字符串上,相邻两端子串交换,在转化一下,给定字符串s,将s从中间某点断开,形成字符串s1s2,交换两半段,得新字符串s2s1,为扰乱字符串。OK,剖析至此,基本明朗了,假设字符串s1和s2,假设断开段的长度一个是L和LEN-L,当对s1左->右断开L处,那么s2既可以左->右断开L处,也可以右->左断开L处,然后交叉判断对应的子串是否能是scrambe,若是,则s1和s2是scramble,当然判断之间可以先判断s1和s2中对应的字符及其个数是否相同,最简单的方法是排序然后比较字符串是否相等。
在这里插入图片描述

class Solution {
public:
    bool isScramble(string s1, string s2) {
        if (s1 == s2) return true;
        string ts1 = s1, ts2 = s2;
        sort(ts1.begin(), ts1.end());
        sort(ts2.begin(), ts2.end());
        if (ts1 != ts2) return false;
        int n = s1.size();
        for (int i = 1; i <= n - 1; ++i) {
            string s11 = s1.substr(0, i);
            string s12 = s1.substr(i);
            string s21 = s2.substr(0, i);
            string s22 = s2.substr(i);
            if ((isScramble(s11, s21) && isScramble(s12, s22)))
                return true;
            s22 = s2.substr(0, n - i);
            s21 = s2.substr(n - i);
            if ((isScramble(s11, s21) && isScramble(s12, s22)))
                return true;
        }
        return false;
    }
};

动态规划解题,直觉告诉至少要两个变量定义一个状态,即s1位置和s2位置,若简单的对整个字符串切的话,大长度的子串在更新状态时,无法保证小长度的子串已经更新了,这个在很多DP题中都碰到过,而常规的做法是,以长度作为遍历,而不是以位置作为遍历,这样在更新大长度的状态时,小长度的状态肯定就已经更新过了。OK,为了方便更新,需要引入第三个变量即,子串长度,因此状态定义为dp[i][j][l],其中i表示s1中子串起始的位置,j表示s2中子串起始的位置,l表示子串的长度,状态转移方程怎么找呢,想一下题意,自然需要对长度为l做切开,切的方式在递归解法中已经讲过,因此状态转移方程为:

dp[i][j][l] = dp[i][j][l] || (dp[i][j][k] && dp[i + k][j + k][l - k]) || (dp[i][j + l - k][k] && dp[i + k][j][l - k])
class Solution {
public:
    bool isScramble(string s1, string s2) {
        int n = s1.size();
        vector<vector<vector<bool>>> dp(n, vector<vector<bool>>(n, vector<bool>(n + 1, false)));
        for (int l = 1; l <= n; ++l) {
            for (int i = 0; i + l <= n; ++i) {
                for (int j = 0; j + l <= n; ++j) {
                    if (l == 1) {
                        dp[i][j][1] = (s1[i] == s2[j]);
                    } else {
                        for (int k = 1; k < l; ++k) {
                            if ((dp[i][j][k] && dp[i + k][j + k][l - k]) || (dp[i][j + l - k][k] && dp[i + k][j][l - k])) {
                                dp[i][j][l] = true;
                                break;
                            }
                        }
                    }
                }
            }
        }
        return dp[0][0][n];
    }
};

85. 最大矩形

原题链接

解题思路: 本题如果用暴力解法解的话,大致思路是,先确定矩阵的左上叫,然后确定矩阵的长和宽,再check这个矩阵是否全’1’,不难发现此算法复杂度为 O ( n 4 ) O(n^4) O(n4),估计OJ会TLE。其实若是第一次做此题,还挺难解的,这题实际上是在题84基础上解题,将此题矩阵逐行应用题84解法,具体的,对矩阵每一行,向上看,把它看成直方图,求取这个直方图后,利用题84解法解出最大矩阵,题84解法非常灵活,本题用的是单调栈解法。

class Solution {
public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        if (matrix.empty() || matrix[0].empty()) return 0;
        int n = matrix.size(), m = matrix[0].size();
        vector<int> heights(m, 0);
        int res = 0;
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < m; ++j) {
                if (matrix[i][j] == '1') ++heights[j];
                else heights[j] = 0;
            }
            res = max(res, helper(heights));
        }
        return res;
    }
    int helper(vector<int> &heights) {
        stack<int> st;
        int res = 0;
        heights.push_back(0);
        for (int i = 0; i < heights.size(); ++i) {
            if (st.empty() || heights[st.top()] <= heights[i]) st.push(i);
            else {
                int idx = st.top(); st.pop();
                int square = (st.empty() ? i * heights[idx] : (i - st.top() - 1) * heights[idx]);
                res = max(res, square);
                --i;
            }
        }
        return res;
    }
};

72. 编辑距离

原题链接

解题思路: 转述一下此题题意,若想知道word1和word2转换情况,可以先知道它们前缀(子串)的转换情况,OK,这一转换,题目有归为字符串子串问题,而子串问题我们会像膝跳反射一样想到动态规划。OK,因为涉及到两个子串,确切的说是描述两个子串的位置,那么我们需要两个变量来维护状态,定义dp[i][j]为word1前i个字符子串转换到word2前j个字符子串需要的最少操作次数,若i-1和j-1位置字符不相等,则需要借助题目提到三个操作以触发状态间的转换,若当前状态为(i,j),那么前一个状态为:1)删除,(i-1,j),2)替换,(i-1,j-1),3)插入,(i,j-1)。状态转移方程为:

if w1[i-1] == w2[j-1]  then  dp[i][j] = dp[i - 1][j - 1]
else dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1))
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size(), m = word2.size();
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        for (int i = 0; i <= n; ++i) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= m; ++j) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
                else {
                    dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1));
                }
            }
        }
        return dp[n][m];
    }
};

132. 分割回文串 II

原题链接

解题思路: 这题是题131的进阶题,题131要求求出所有的情况,而此题要求求出情况的个数,这实际上是LT题中最常规的搭配了,求出所有的情况对应的是回溯+剪枝解法,而求出情况的个数如果按照前面情形解,如果找不到好的剪枝方法,势必会TLE,最终还是得回归到动态规划解法,因此总结一个结论,求所有情况用回溯剪枝,而求情况的个数用动态规划。本题应该是我解动态规划题画的最长时间的一题了,着重记一笔,后面得详细review此题。起初用的是由题131改进的回溯法,但是剪枝如何优化都没能逃过OJ的TLE,遂放弃,然后选择动态规划,进而要选择如何定义状态以及找转移方程,定义dp[i][0,i]最小分割次数,那么我们对[0,i]内每个元素切一次,然后取所有切法的最小分割数做为dp[i],状态转移方程如下:

dp[i]=MIN(dp[j]+1) && Palindrome(s[j,i]), 0<j<i

求回文我们常规的做法是双指针,但是本题要求多次回文,因此双指针会在原算法基础上多加一层O(n)时间复杂度,导致整个算法时间复杂度太高,有点极端case无法AC,因此,对回文判断要做优化,而多次判断回文采取的优化策略用到了题647用动态规划。本题本来解起来非常简单,但是由于对时间非常苛刻,对细节的把控就比较难想到了,比如回文用动态规划来优化,对于我一贯用双指针来判断,实在想不到了,或许多做点题,可能融会贯通,激发出一点思维火花,hhh,因此本质上此题考查了两个动态规划,不愧为hard╮(╯▽╰)╭.

class Solution {
public:
    int minCut(string s) {
        int n = s.size();
        vector<int> dp(n);
        vector<vector<bool>> p(n, vector<bool>(n));
        for (int i = 0; i < n; ++i) {
            dp[i] = i;
            p[i][i] = true;
            if (i >= 1 && s[i] == s[i - 1]) p[i - 1][i] = true;
        }
        for (int len = 3; len <= n; ++len) {
            for (int i = 0; i + len <= n; ++i) {
                int j = i + len - 1;
                if (s[i] == s[j] && p[i + 1][j - 1]) p[i][j] = true;
            }
        }
        for (int i = 1; i < n; ++i) {
            if (p[0][i]) dp[i] = 0;
            else {
                for (int k = 1; k <= i; ++k) {
                    if (p[k][i]) {
                        dp[i] = min(dp[i], dp[k - 1] + 1);
                    }
                }
            }
        }
        return dp[n - 1];
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值