动态规划小结
- 一. 动态规划简介
- 二. 动态规划步骤
- 三. 常见力扣习题分析
- 1.爬楼梯
- 2.Leetcode 198 打家劫舍
- 3.找子数组或子序列
- 4.Leetcode 64 最小路径和
- 5.Leetcode 221 最大正方形
- 6.Leetcode 279 完全平方数
- 9.Leetcode 416 分割等和子集
- 9.Leetcode 338 比特位计数
- 10.HJ 32 密码截取
- 11. 回文子串
- 12. JZ46 把数字翻译成字符串
- 13.Leetcode 650 只有两个键的键盘
- 14.股票问题
- 14.Leetcode 322 找零钱
- 15. Leetcode 22 括号生成
- 16. 不同路径
- 17. Leetcode 96 不同的二叉搜索树
- 18. Leetcode 97 交错字符串
- 总结
一. 动态规划简介
动态规划是将一个大问题分解成连续的小问题,通过一步一步在上一个小问题的基础上解决下一个小问题,并将小问题的解连续存储,最终得到最终结果的方法。
一个大问题能够采用动态规划解决的前提是:动态规划智能应用于最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解。
二. 动态规划步骤
1.将问题拆解成各个小问题
2.建立小问题的状态转移方程
3.考虑状态转移方程不能cover的初始情况
4.用递归按顺序求解各个子问题
5.输出最终结果
三. 常见力扣习题分析
1.爬楼梯
1.1 Leetcode 70 爬楼梯
思路:
1.子问题是爬到第i阶有几种方法;
2.爬到第i阶的方法是【从第i-1阶往上爬1阶】或【从第i-2阶往上爬2阶】;
3.一般来说,这个问题得从第3阶开始,要初始化第1阶和第2阶。但是我们可以补充一个第0阶,从第0阶爬到第0阶只有一种办法 ,即不动,那么这个问题可以从第2阶开始递归,初始化第1阶。
4.从第2阶开始;
5.输出第n阶的结果
代码如下:
int climbStairs(int n) {
if (n == 1) return 1; //初始化第1阶
int p1 = 1, p2 = 1, temp = 0;
for (int i = 2; i <= n; ++i) { // 从第2阶开始
temp = p1 + p2; // 状态转移方程
p1 = p2;
p2 = temp;
}
return p2; // 输出第n阶的结果
}
也可以用vector来存储每一步的结果。
int climbStairs(int n) {
if (n <= 3) return n;
vector<int> dp(n, 0);
dp[0] = 1, dp[1] = 2;
for (int i = 2; i < n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n - 1];
}
1.2 剑指 Offer II 088. 爬楼梯的最少成本
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
if (n == 1) return cost[0];
if (n == 2) return min(cost[0], cost[1]);
vector<int> dp(n + 1, INT_MAX - 1);
for (int i = 2; i <= n; ++i) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]); // 站到本级台阶的花费不包括本级的cost,本级的cost是爬到下一层的cost。
}
return dp[n];
}
2.Leetcode 198 打家劫舍
思路:
1.子问题是偷到第i家有几种方法;
2.偷到第i家的方法是【偷到第i-1家】或【偷到第i-2家+第i家】的最大值;
3.一般来说,这个问题得从第3家开始,要初始化第1家和第2家。但是我们可以补充一个第0家,偷到第0家即偷了0个单位的现金,即不偷,那么这个问题可以从第2家开始递归,初始化第1家。
4.从第2家开始;
5.输出第n家的结果
代码如下:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 1) return nums[0];
int p1 = 0, p2 = nums[0], temp = 0;
for (int i = 1; i < n; ++i) {
temp = max(p1 + nums[i], p2);
p1 = p2;
p2 = temp;
}
return p2;
}
3.找子数组或子序列
3.1 或Leetcode 413 等差数列划分
思路:
0.让我们重新陈述一下这个问题:以其中【各个数】为结尾的等差数列一共有多少个;
1.子问题是【以第i个数为结尾的等差数列一共有多少个】;
2.当i 和i-1的差等于i-1和i-2的差时,【以第i个数为结尾的等差数列总和】是【以第i个数为结尾的等差数列总和】+1,否则为0;
3.这个问题得从第3个开始,要初始化第1家和第2家。
4.从第3个开始;
5.计算到第n个
6.计算这个数列之和
代码如下:
int numberOfArithmeticSlices(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 0); // 初始化第1家和第2家为0
for (int i = 2; i < n; ++i) {
if (nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2]){
dp[i] = dp[i - 1] + 1; // 当i 和i-1的差等于i-1和i-2的差时,【以第i个数为结尾的等差数列总和】是【以第i个数为结尾的等差数列总和】+1
}
}
return accumulate(dp.begin(), dp.end(), 0); // 计算这个数列之和
}
3.2 Leetcode 300 最长递增子序列
思路:
1.子问题是【到i的最长递增子序列的长度】;
2.【到i的最长递增子序列的长度】是各个【比i小的位置的最长递增子序列的长度】+1;
3.这个问题得从第2个开始,因为1就是1。
4.计算到最后1个。
代码如下:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if (n == 1) return 1;
vector<int> dp(n, 1);
int ans = 1;
for (int i = 1; i < n; ++i) { // 从第2个开始
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1); // 【到i的最长递增子序列的长度】是各个【比i小的位置的最长递增子序列的长度】+1;
ans = max(ans, dp[i]);
}
}
}
return ans;
}
4.Leetcode 64 最小路径和
思路:
1.子问题是【到grid[i][j]的最小路径和是多少】;
2.【到grid[i][j]的最小路径和】是【到grid[i-1][j]的最小路径和】与【到grid[i][j-1]的最小路径和】的最小值+当前位置的值;
3.一般来说,这个问题得从第1行第1列开始,要初始化第0行和第0列。但是这样代码复杂了,直接从第0行第0列开始。
4.计算到最后1个
代码如下:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<int> dp(n, 0);
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (i + j == 0) {
dp[0] = grid[0][0];
}
else if (i == 0) dp[j] = dp[j - 1] + grid[i][j]; //第0行
else if (j == 0) dp[j] = dp[j] + grid[i][j]; //第0列
else dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]; 从第1行第1列开始
}
}
return dp[n - 1];
}
5.Leetcode 221 最大正方形
思路:
1.子问题是【到grid[i][j]的最大正方形是多少】;
2.【到grid[i][j]的最大正方形】是【到grid[i-1][j]的最大正方形】与【到grid[i][j-1]的最大正方形】与【到grid[i][j-1]的最大正方形】的最小值+1;
3.一般来说,这个问题得从第1行第1列开始,要初始化第0行和第0列。但是这样代码复杂了,我们补充为0的一行一列,放在这个matrix的左上角,这样状态转移关系可以普遍适用。
4.计算到最后1个
代码如下:
int maximalSquare(vector<vector<char>>& matrix) {
int m = matrix.size(), n = matrix[0].size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
int ans = 0;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (matrix[i -1][j - 1] == '0') dp[i][j] = 0;
else {
dp[i][j] = min(dp[i - 1][j - 1], min(dp[i - 1][j], dp[i][j - 1])) + 1;
ans = max(ans, dp[i][j]);
}
}
}
return ans*ans;
}
6.Leetcode 279 完全平方数
思路:
1.子问题是【到i可以有几个完全平方数组成】;
2.【到i可以有几个完全平方数组成】是【到i-1可以有几个完全平方数组成】与【到i-4可以有几个完全平方数组成】与【到i-9可以有几个完全平方数组成】······的最小值+1;
3.这个问题得从第2个开始,因为1就是1。为了方便,我们多补充一个0,0不需要完全平方数组成。
4.计算到最后1个。
代码如下:
int numSquares(int n) {
if (n == 1) return 1;
vector<int> dp(n + 1, 100);
dp[0] = 0, dp[1] = 1;
for (int i = 2; i <= n; ++i) {
for (int j = 1; j*j <= i; ++j) {
dp[i] = min(dp[i], dp[i - j*j] + 1);
}
}
return dp[n];
}
9.Leetcode 416 分割等和子集
思路:
0.令sum为vector所有元素之和;
0.5.问题重述:【到最后1个元素时,是否能让部分和为sum/2】
1.子问题是【到第i个元素时,是否能让部分和为j】;
2.当第i个数大于j时,【到第i个元素时,是否能让部分和为j】=【到第i-1个元素时,是否能让部分和为j】,因为此时第i个数由于太大,不能放进去;当第i个数小于等于j时,【到第i个元素时,是否能让部分和为j】=【到第i-1个元素时,是否能让部分和为j】||【到第i-1个元素时,是否能让部分和为j-nums[i]】;
3.一般来说这个问题得从第1个开始。但是我们多补充一个0行0列,0行表示没有元素放进去,0列表示不需要放入元素,因此0列所有值先默认为true;
4.计算到最后1个。
代码如下:
bool canPartition(vector<int>& nums) {
int n = nums.size(), sum = accumulate(nums.begin(), nums.end(), 0), me = *max_element(nums.begin(), nums.end());
if (sum % 2) return false; // 若数组之和为奇数,肯定不行
if (2 * me > sum) return false; // 若数组最大数的2倍超过总和,那么肯定也不行
vector<vector<bool>> dp(n + 1, vector<bool>(sum / 2 + 1, false));
dp[0][0] = true;
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= sum / 2; ++j) {
if (j < nums[i - 1]) {
dp[i][j] = dp[i - 1][j]; // 当第i个数大于j时,【到第i个元素时,是否能让部分和为j】=【到第i-1个元素时,是否能让部分和为j】
}
else {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]]; // 当第i个数小于等于j时,【到第i个元素时,是否能让部分和为j】=【到第i-1个元素时,是否能让部分和为j】||【到第i-1个元素时,是否能让部分和为j-nums[i]】
}
}
}
return dp[n][sum / 2];
}
9.Leetcode 338 比特位计数
思路:
1.子问题是【到i的为1的比特位有多少个】;
2.若i的二进制最后一位为1,则【到i的为1的比特位】是【到i的为1的比特位】+1;若i的二进制最后一位为0,则【到i的为1的比特位】是【到i>>1的为1的比特位】;
3.这个问题得从1个开始。
4.计算到最后1个。
代码如下:
if (n == 0) return vector<int>{0};
vector<int> ans(n + 1, 0);
for (int i = 0; i <= n; ++i) {
if (i & 1 == 1) {
ans[i] = 1 + ans[i - 1];
}
else {
ans[i] = ans[i>>1];
}
}
return ans;
10.HJ 32 密码截取
思路:
1.子问题是【从i到j最长的对称字符串的长度】;
2.若第i个字符==第j个字符,且从第i+1个字符到第j-1个字符构成的字符串是对称的,那么【从i到j最长的对称字符串的长度】=【从i+1到j-1最长的对称字符串的长度】+2,否则【从i到j最长的对称字符串的长度】=max(【从i到j-1最长的对称字符串的长度】,【从i+1到j最长的对称字符串的长度】);
3.这个问题得从(0,0)个开始。
4.将整个二维数组遍历完成。
代码如下:
int main() {
string s;
cin >> s;
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = 0; i < n; ++i) {
int r = 0, c = i;
if (i == 0) {
for (int j = 0; j < n; ++j) {
dp[j][j] = 1;
}
} else {
while (c < n) {
if (s[r] == s[c] && dp[r + 1][c - 1] == c - r - 1) dp[r][c] = dp[r + 1][c - 1] + 2;
else dp[r][c] = max(dp[r][c - 1], dp[r + 1][c]);
r++; c++;
}
}
}
cout << dp[0][n - 1];
return 0;
}
11. 回文子串
11.1 Leetcode 647 回文子串
子问题是【从i到j是不是回文子串】。
int n = s.size();
if (n == 1) return 1;
vector<vector<bool>> dp(n, vector<bool>(n, false));
int cnt = 0;
for (int i = 0; i < n; ++i) {
dp[i][i] = true;
cnt++;
}
for (int i = 1; i < n; ++i) {
int l = 0. r = i;
while (r < n) {
if (s[l] == s[r] && (r - l == 1 || dp[l + 1][r - 1])) {
dp[l][r] = true;
cnt++
}
}
l++;r++;
}
return cnt;
11.2 Leetcode 5 最长回文子串
string longestPalindrome(string s) {
int n = s.size(), maxl = 0, maxlen = 1;
vector<vector<bool>> dp(n, vector<bool>(n, false));
for (int i = 0; i < n; ++i) {
for (int j = 0; i + j < n; ++j) {
if (i == 0) dp[j][j] = true;
else {
if (s[j] == s[i + j]) {
if (i == 1) {
dp[j][i + j] = true;
}
else dp[j][i + j] = dp[j + 1][i + j - 1];
if (dp[j][i + j] && maxlen < i + 1) {
maxl = j;
maxlen = i + 1;
}
}
}
}
}
return s.substr(maxl, maxlen);
}
12. JZ46 把数字翻译成字符串
设字符串长度为n,子问题是【从0到n-1或n-2有几种翻译方法】。
int solve(string nums) {
// write code here
int n = nums.size();
if (n == 0) return 1;
if (n == 1) {
if (nums[0] == '0') return 0;
else return 1;
}
if (nums[n - 1] == '0') { // 必须要要和前面的结合,没有别的可能
if (nums[n - 2] >= '3') return 0; // 结合的话前面是3-9,则说明超出范围了,返回0
return solve(nums.substr(0, n - 2));
} else {
if (nums[n - 2] == '1' || nums[n - 2] == '2' && nums[n - 1] <= '6') { // 可以和前面一个数组组成新的可能
return solve(nums.substr(0, n - 1)) + solve(nums.substr(0, n - 2));
} else { // 不能和前面的组合,只能作为一个新的字母
return solve(nums.substr(0, n - 1));
}
}
}
13.Leetcode 650 只有两个键的键盘
这一题的状态空间方程是dp[i] = dp[j] + dp[i/j],但是其实不需要用循环去求解每一个dp的值,只需要求需要的就可以了,所以可以用递归来做,如下:
int minSteps(int n) {
if (n == 1) return 0;
if (n <= 4) return n;
for (int i = 2; i <n; ++i) {
if (n % i == 0) return minSteps(n / i) + minSteps(i);
}
return n;
}
14.股票问题
14.1 Leetcode 121 买卖股票的最佳时机
int maxProfit(vector<int>& prices) {
int n = prices.size(), minp = prices[0], ans = 0;
vector<int> dp(n, 0);
for (int i = 1; i < n; ++i) {
dp[i] = prices[i] - minp;
ans = max(dp[i], ans);
minp = min(minp, prices[i]);
}
return ans;
}
14.2 Leetcode 122 买卖股票的最佳时机II
这个变种是可以多次买卖
int maxProfit(vector<int>& prices) {
int ans = 0;
for (int i = 1; i < prices.size(); ++i) {
if (prices[i] > prices[i - 1]) ans += prices[i] - prices[i - 1];
}
return ans;
}
14.2 Leetcode 309 含冷却时间的最佳买卖股票时机
这一题要计算每天处于卖出状态时至当天的收益 和 每天处于持有状态时至当天的收益。
今天处于卖出状态时至今天的收益 = max(昨天处于卖出状态时至昨天的收益, 昨天处于持有状态时至昨天的收益+今天卖出的收益)
今天处于持有状态时至今天的收益 = max(昨天处于持有状态时至昨天的收益, 前天处于卖出状态时至前天的收益+今天买入的收益)
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2, 0));
// 第一列是处于卖出状态时至当天的收益
// 第二列是处于持有状态时至当天的收益
dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
// 今天处于卖出状态时至今天的收益 = max(昨天处于卖出状态时至昨天的收益, 昨天处于持有状态时至昨天的收益+今天卖出的收益)
dp[i][1] = max(dp[i - 1][1], dp[max(0, i - 2)][0] - prices[i]);
// 今天处于持有状态时至今天的收益 = max(昨天处于持有状态时至昨天的收益, 前天处于卖出状态时至前天的收益+今天买入的收益)
}
return dp[n - 1][0]; // 题目条件是一定卖出了,所以返回第一列的值
}
14.Leetcode 322 找零钱
二维动态规划。
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
int n = coins.size();
vector<vector<int>> dp(amount + 1, vector<int>(n + 1, amount + 1));
for (int i = 0; i <= amount; ++i) {
for (int j = 1; j <= n; ++j) {
if (i == coins[j - 1]) dp[i][j] = 1; // 如果coins[j - 1]正好放在包里,一定是最小的方法,那么就放这1个
else if (i < coins[j - 1]) dp[i][j] = dp[i][j - 1]; // 如果这个coin太大,就不放它
else dp[i][j] = min(1 + dp[i- coins[j - 1]][j], dp[i][j - 1]); // 如果这个coin比价小,可以试试放进去,也可以不放进去
}
}
if (dp[amount][n] == amount + 1) return -1;
return dp[amount][n];
}
15. Leetcode 22 括号生成
它的通项是如下的式子:
“(” + 【i=p时所有括号的排列组合】 + “)” + 【i=q时所有括号的排列组合】
vector<string> generateParenthesis(int n) {
if (n == 0) return vector<string>{""};
if (n == 1) return vector<string>{"()"};
if (n == 2) return vector<string>{"()()", "(())"};
vector<string> ans;
for (int i = 0; i <= n - 1; ++i) {
auto vs1 = generateParenthesis(i), vs2 = generateParenthesis(n - 1 - i);
int n1 = vs1.size(), n2 = vs2.size();
for (int j = 0; j < n1; ++j) {
for (int k = 0; k < n2; ++k) {
ans.push_back("(" + vs1[j] + ")" + vs2[k]);
}
}
}
return ans;
16. 不同路径
16.1 Leetcode 62 不同路径
int uniquePaths(int m, int n) {
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 (i + j == 2) dp[i][j] = 1;
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m][n];
}
16.2 Leetcode 63 不同路径II
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(), n = obstacleGrid[0].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 (obstacleGrid[i - 1][j - 1]) continue;
if (i + j == 2) dp[i][j] = 1;
else {
if (i >= 2 && !obstacleGrid[i - 2][j - 1]) dp[i][j] += dp[i - 1][j]; // 要考虑障碍物
if (j >= 2 && !obstacleGrid[i - 1][j - 2]) dp[i][j] += dp[i][j - 1]; // 要考虑障碍物
}
}
}
return dp[m][n];
}
17. Leetcode 96 不同的二叉搜索树
int numTrees(int n) {
if (n == 0) return 1;
if (n <= 2) return n;
int ans = 0;
vector<int> dp(n + 1, 0);
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; ++i) {
for (int j = 1; j <= i; ++j) dp[i] += dp[j - 1] * dp[i - j];
}
return dp[n];
}
18. Leetcode 97 交错字符串
bool isInterleave(string s1, string s2, string s3) {
int n1 = s1.size(), n2 = s2.size(), n3 = s3.size();
if (n1 + n2 != n3) return false;
vector<vector<bool>> dp(n1 + 1, vector<bool>(n2 + 1, false));
for (int i = 0; i <= n1; ++i) {
for (int j = 0; j <= n2; ++j) {
if (i == 0 && j == 0) {
dp[0][0] = true;
continue;
}
if (i > 0) dp[i][j] = dp[i][j] || (s3[i + j - 1] == s1[i - 1]) && dp[i - 1][j]; // 试试s1[i-1]和s3[i+j-1]相不相等
if (j > 0) dp[i][j] = dp[i][j] || (s3[i + j - 1] == s2[j - 1]) && dp[i][j - 1];
}
}
return dp[n1][n2];
}
总结
刷完这些题之后,回过头来看看动态规划和分治法的区别:分治法将问题也拆分成了子问题,但是子问题之间互不影响,而动态规划的子问题具有连续性,若前面的不解决,后面的子问题也无法解决,子问题之前通过状态转移关系联系起来。