文章目录
【注意】:字符串的极值问题十有八九是动态规划,而解决动态规划问题最重要的一个方法就是打表,然后找状态转移方程,打表的时候重点关注dp[i][j]与dp[i-1][j]、dp[i][j-1]、dp[i-1][j-1]四者之间的关系。
【注意】:一维的字符串要使用dp,一般先确定dp[i]或者dp[i][j]的意义,比如dp[i][j]可以表示区间,然后再找状态转移方程。
【注意】很多涉及到子串和子数组等问题也可以用动态规划解决。
一、分割等和子集(leetcode 416)
思路:使用动态规划,dp[i]表示是否可以从nums中找出一些数字,并且和为i,遍历每个数字num并加上i,如果dp[i]可达则dp[i + num]也是可达的。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2 == 1)
return false;
// 原问题转化为数组中是否可以选出若干个数使其和为sum/2
int target = sum / 2;
// dp[i]表示是否可以从nums中找出一些数字,并且和为i
vector<int> dp(target + 1, 0);
dp[0] = 1;
// 遍历每个数字
for (auto num : nums) {
// 遍历出加上每个数字可以组成的和。
// 如果加上该数字num超出target范围,是无效解,可以忽略。
// 因此此处for语句的起始条件为i = target - num
for (int i = target - num; i >= 0; --i) {
// 如果i是合法的和,那么i加上当前num可以组成一个新的和
if (dp[i]) {
// 将dp[i + num]设置为true
dp[i + num] = true;
// 如果已经计算出target为true,直接返回
if (dp[target])
return true;
}
}
}
return dp[target];
}
};
二、硬币找零(leetcode 518)
思路:和分割等和子集的思想差不多,使用dp[i]表示的是组成钱数i的方法数,name组成钱数i+coin的方法就来源于组成钱数为i的方法。
class Solution {
public:
int change(int amount, vector<int>& coins) {
// dp[i]表示的是组成钱数i的方法数
vector<int> dp(amount+1, 0);
dp[0] = 1;
for (auto coin : coins) {
for (int i = 0; i <= amount-coin; ++i)
// 组成钱数i+coin的方法来源于组成钱数为i的方法
dp[i+coin] += dp[i];
}
return dp[amount];
}
};
三、最长公共子序列(leetcode 1143)
思路:动态规划问题,一般先打表,横纵坐标分别是text1和text2,然后一行一行填数。
(图https://blog.csdn.net/hrn1216/article/details/51534607)
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
// 状态转移方程
if (text1[i-1] == text2[j-1])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
return dp[m][n];
}
};
四、最短编辑距离(leetcode 72)
思路:打表,找状态转移方程。
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 0; i <= m; ++i)
dp[i][0] = i;
for (int j = 0; j <= n; ++j)
dp[0][j] = j;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++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], dp[i][j-1])) + 1;
}
}
return dp[m][n];
}
};
五、最小路径和(leetcode 64)
思路:用dp[i][j] 表示到达当前位置的最小路径和。接下来找状态转移方程,因为到达当前位置 (i, j) 只有两种情况,要么从上方 (i-1, j) 过来,要么从左边 (i, j-1) 过来,我们选择 dp 值较小的那个路径,即比较 dp[i-1][j] 和 dp[i][j-1],将其中的较小值加上当前的数字 grid[i][j],就是当前位置的 dp 值了。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
dp[0][0] = grid[0][0];
for (int i = 1; i < m; ++i)
dp[i][0] = dp[i-1][0] + grid[i][0];
for (int j = 1; j < n; ++j)
dp[0][j] = dp[0][j-1] + grid[0][j];
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
// 状态转移方程
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
}
}
return dp[m-1][n-1];
}
};
六、最长回文子序列(leetcode 516)
思路:dp[i][j]表示[i,j]区间内的字符串的最长回文子序列,那么对于递推公式我们分析一下,如果s[i]==s[j],那么i和j就可以增加2个回文串的长度,我们知道中间dp[i + 1][j - 1]的值,那么其加上2就是dp[i][j]的值。如果s[i] != s[j],那么我们可以去掉i或j其中的一个字符,然后比较两种情况下所剩的字符串谁dp值大,就赋给dp[i][j]。注意:我们需要逆向遍历dp数组。
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n = s.size();
// dp[i][j]表示的是区间i到j的最长回文子序列的个数
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = n-1; i >= 0; --i) {
dp[i][i] = 1;
for (int j = i + 1; j <= n-1; ++j) {
// 状态转移方程
if (s[i] == s[j])
dp[i][j] = dp[i+1][j-1] + 2;
else
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
return dp[0][n-1];
}
};
七、最大子数组和(leetcode 53)
思路:dp[i]表示以下标为i作为结尾的最大连续和,然后检查连续最大和中是否包含当前值,两者取最大。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp;
dp.push_back(nums[0]);
for (int i = 1; i < nums.size(); ++i)
{
// 连续最大和中是否包含当前值,两者取最大的那个
dp.push_back(max(dp[i-1] + nums[i], nums[i]));
}
return *max_element(dp.begin(), dp.end());
}
};
八、解码方法(leetcode 91)
思路:
【状态的定义】:我们使用 dp [n] 表示长度为 n 的字符串的解码方式,dp 数组初始化为 0,其中 dp [0] 表示空字符串其值为 1,dp [1] 表示长度为 1 的字符串,不为 0 时其值为 1。
【状态转移过程】:
class Solution {
public:
int numDecodings(string s) {
int n = s.size();
vector<int> dp(n+1, 0);
dp[0] = 1;
dp[1] = (s[0] == '0') ? 0 : 1;
for (int i = 2; i <= n; ++i) {
if (s[i-1] != '0')
dp[i] += dp[i-1];
if (s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6'))
dp[i] += dp[i-2];
}
return dp[n];
}
};
九、三角形最小路径和(leetcode 120)
思路:使用dp[i][j]表示达到(i,j)位置的最小路径和,取决于dp[i-1][j-1], dp[i-1][j]的最小者再加上triangle[i][j]。
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
dp[0][0] = triangle[0][0];
for (int i = 1; i < n; ++i) {
for (int j = 0; j < triangle[i].size(); ++j) {
if (j == 0) {
dp[i][j] = dp[i-1][j] + triangle[i][j];
} else if (j == n-1) {
dp[i][j] = dp[i-1][j-1] + triangle[i][j];
} else {
dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j];
}
}
}
return *min_element(dp[n-1].begin(), dp[n-1].end());
}
};
十、单词分解(leetcode 139)
思路:
【状态的定义】:dp[i]表示范围[0, i)内的子串是否可以拆分;
【状态转移过程】:我们用j把 [0, i) 范围内的子串分为了两部分,[0, j) 和 [j, i),其中范围 [0, j) 就是 dp[j],范围 [j, i) 就是 s.substr(j, i-j),如果两个都是true则dp[i]为true。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
int n = s.size();
// 用一个一维的 dp 数组,其中 dp[i] 表示范围 [0, i) 内的子串是否可以拆分,注意这里 dp 数组的长度比s串的长度大1,
// 是因为我们要 handle 空串的情况,我们初始化 dp[0] 为 true,然后开始遍历。
vector<bool> dp(n+1, false);
dp[0] = true;
for (int i = 0; i < n + 1; ++i) {
for (int j = 0; j < i; ++j) {
// 我们用j把 [0, i) 范围内的子串分为了两部分,[0, j) 和 [j, i),其中范围 [0, j) 就是 dp[j],
// 范围 [j, i) 就是 s.substr(j, i-j),如果两个都是true则dp[i]为true
if (dp[j] && wordSet.count(s.substr(j, i-j))) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
};
十一、回文子串拆分-最小拆分次数(leetcode 132)
思路:
【状态的定义】:用dp[i]表示子串(0,i)的最小回文切割;
【状态转移过程】:对于任意大于1的i,如果s.substring(j,i+1)( 1 =< j <= i ,即遍历i之前的每个子串)是回文时,dp[i] = min(dp[i], dp[j-1]+1)。
class Solution {
public:
int minCut(string s) {
int n = s.size();
// dp
vector<int> dp(n);
for (int i = 0; i < n; ++i) {
// 初始化dp
if (checkPalindrome(s, 0, i))
dp[i] = 0;
else
dp[i] = i;
for (int j = 1; j <= i; ++j) {
// 状态转移过程
if (checkPalindrome(s, j, i))
dp[i] = min(dp[i], dp[j-1] + 1);
}
}
return dp[n-1];
}
// 检查区间[j, i]是否可以构成回文串
bool checkPalindrome(string s, int left, int right) {
while (left < right) {
if (s[left] != s[right])
return false;
else {
left++;
right--;
}
}
return true;
}
};
十二、跳跃游戏(leetcode 55)
思路:
【状态的定义】:使用dp[i]表示达到i位置时剩余的步数,如果到达last index的位置时dp[i]>=0,则返回true。
【状态转移过程】:当前位置的剩余步数(dp值)和当前位置的跳力中的较大那个数决定了当前能到的最远距离,而下一个位置的剩余步数(dp值)就等于当前的这个较大值减去1,因为需要花一个跳力到达下一个位置。
class Solution {
public:
bool canJump(vector<int>& nums) {
int n = nums.size();
// 使用dp[i]表示达到i位置时剩余的步数,如果到达last index的位置时dp[i]>=0,则返回true
vector<int> dp(n, 0);
for (int i = 1; i < n; ++i) {
// 状态转移方程
dp[i] = max(dp[i-1], nums[i-1]) - 1;
if (dp[i] < 0)
return false;
}
return true;
}
};