动态规划是运筹学中用于求解决策过程中的最优化数学方法。每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
1、重叠子问题均是最优解
2、子问题不影响覆盖问题的后续过程,即子问题可作为单独最优解问题。
3、子问题会被覆盖问题多次使用。
关于重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
动态规划求解的是存在重叠子问题的最优解,主打利用空间换时间,较大的覆盖问题可以利用其一个或多个子问题的最优解来求取(状态转移方程),存储这些子问题的最优解的空间即为DP数组。
同上整数拆分,先遍历从小到大的数组,再依次遍历根节点为不同值的情况并进行相加。
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
dp数组:
dp[i]表示当前背包容量为i时最大价值
状态转移方程:
max(放入当前物品,不放入当前物品)的value
dp数组:
dp[i]表示背包容量为i时可以放入的最大价值
状态转移方程:
dp[j] = max(dp[j] , dp[j-nums[i]+value[i]);
背包解决排列组合问题!
不考虑nums[i]的情况下,填满容量为j - nums[i]的背包,有dp[j - nums[i]]种方法。
那么只要搞到nums[i]的话,凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]。
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]。
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 dp[5]
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
背包解决组合问题的常见状态转移方程如下:
for (int i = 0; i < nums.size(); i++) {
for (int j = bagSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
求组和类问题,回溯与DP都可以解决,回溯的时间复杂度一般 2 n 2^n 2n,DP为 m ∗ n m*n m∗n
dp[i]表示当前容量为i时,存放物品组合总和
//初始化,容量为0时,方法为1(什么都不取)
dp[0]=1;
//组合类状态转移方程:不取当前物品组合总和与取了当前物品组合总和
dp[j] += dp[j - nums[i]];
vector<vector<int>>dp(m + 1, vector<int> (n + 1, 0));
//遍历顺序:先遍历物品,再遍历背包容量需求m,再遍历背包容量需求n
for (string str : strs) { // 遍历物品
int oneNum = 0, zeroNum = 0;
for (char c : str) {
if (c == '0') zeroNum++;
else oneNum++;
}
for (int i = m; i >= zeroNum; i--) { // 遍历背包容量需求m
for (int j = n; j >= oneNum; j--) { //遍历背包容量需求n
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
完全背包理论基础
虽然状态转移方程相同dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);,但是含义完全不同!!!
//01背包的遍历
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i]);//二维数组
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);//转为滚动数组
}
}
//物品数量无限后的完全背包-[朴素]
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
for(int k = 0; k<=j/weight[i] ;k++){ //遍历物品数量
dp[j] = max(dp[j], dp[j - k*weight[i]] + k*value[i]);
}
}
}
//物品数量无限后的完全背包-[改进]
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]]+value[i]);//二维数组
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);//转为滚动数组
}
}
注意:
//背包问题中当前物品不取的状态:
dp[i-1][j]
//01背包的状态转移方程:
//当前物品选取时状态:当前物品数量为0时,背包容量正好放下第1个当前物品重量的value
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
//完全背包的状态转移方程:
//当前物品选取时状态:当前物品数量为k-1时,背包容量正好放下第k个当前物品重量的value
dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i]);
状态转移方程:
简单来说:求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];
复杂来说:
举个例子,nums = [1, 2, 3],target = 35.
假设用1,2,3拼凑出35的总组合个数为y。我们可以考虑三种情况:
(1)有效组合的末尾数字为1,这类组合的个数为 x1。我们把所有该类组合的末尾1去掉,那么不难发现,我们找到了一个子问题,x1即为在[1,2,3]中凑出35 - 1 = 34的总组合个数。因为我如果得到了和为34的所有组合,我只要在所有组合的最后面,拼接一个1,就得到了和为35且最后一个数字为1的组合个数了。
(2)有效组合的末尾数字为2,这类组合的个数为 x2。我们把所有该类组合的末尾2去掉,那么不难发现,我们找到了一个子问题,x2即为在[1,2,3]中凑出35 - 2 = 33的总组合个数。因为我如果得到了和为33的所有组合,我只要在所有组合的最后面,拼接一个2,就得到了和为35且最后一个数字为2的组合个数了。
(3)有效组合的末尾数字为3,这类组合的个数为 x3。我们把所有该类组合的末尾3去掉,那么不难发现,我们找到了一个子问题,x3即为在[1,2,3]中凑出35 - 3 = 32的总组合个数。因为我如果得到了和为32的所有组合,我只要在所有组合的最后面,拼接一个3,就得到了和为35且最后一个数字为3的组合个数了。
这样就简单了,y = x1 + x2 + x3。而x1,x2,x3又可以用同样的办法从子问题得到。状态转移方程get!
dp[i] += dp[i - nums[j]];
//dp[i]为容量为i时,背包不包含当前物品的序列组合的个数。
//dp[i-nums[j]]为容量为i-nums[j]时即最后一个物品为当前物品,序列组合的个数。
遍历顺序:
多重背包问题:有N种物品和一个容量为V 的背包。第 i 种物品最多有 Mi 件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像,每件物品最多有 Mi 件可用,把 Mi 件摊开,其实就是一个01背包问题了。
//法一
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
//法二
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
// 以上为01背包,然后加一个遍历个数
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
}
一定是要后序遍历,因为通过递归函数的返回值来做下一步计算。
两种状态:
五种状态:
五种状态:
五种状态:
1.持有股票
1.1 买入
1.2 已买入
2.不持有股票
2.1 非冷冻期
2.1.1 卖出的当前
2.1.2 卖出的第三天及以后
2.2 冷冻期
两种状态:
法一:动态规划
dp[i]的定义:
dp[i]表示从前向后以nums[i]结尾最长上升子序列(一定要包含nums[i])的长度。
注意:dp[i]一定要是以nums[i]作为末尾,也就是最大值的上升子序列,只有这样,nums[i+1],nums[i+2],nums[i+3]…才可以通过与nums[i]比较继承i之前的上升子序列的数据。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
vector<int> dp(nums.size(), 1);
int result = 0;
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
}
if (dp[i] > result) result = dp[i]; // 取长的子序列
}
return result;
}
};
法二:动态规划+二分查找
优化法一的内存循环,将内存循环改为二分查找,时间复杂度降为 O ( n l o g n ) O(nlogn) O(nlogn)。但是二分查找前提是有序序列,所以增加一个数组tails[]记录已有上升子序列。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
//创建数组存储递增子序列
vector<int> tails(nums.size(),0);
tails[0] = nums[0];
int index=0; //记录上升子序列长度
for(int i=1 ; i<nums.size() ; i++){
//通过遍历nums与当前递增子序列进行比较(添加或替换)
if(nums[i]>tails[index]){ //大于则添加
tails[++index] = nums[i];
}
else if(nums[i]==tails[index]){ //等于则跳过
continue;
}
else{ //小于则替换
//找到tails中第一个大于或等于nums[i]的元素并替换
//二分查找
int left = 0, right = index;
while(left<=right){
int mid = (left+right)/2;
if(nums[i]>tails[mid]){
left = mid+1;
}else{
right = mid-1;
}
}
tails[left] = nums[i];
}
}
return index+1;
}
};
最长连续递增序列与LIS区别在于:
连续递增序列只需要将nums[i]与nums[i-1]比较;
递增子序列需要
//dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。
dp[i][j] = dp[i - 1][j - 1] + 1;
//转滚动数组
dp[j] = dp[j - 1] + 1;
区别在于这里不要求是连续的了,但要有相对顺序
确定递推公式
主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同
int longestCommonSubsequence(string text1, string text2) {
vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
string s;
for (int i = 1; i <= text1.size(); i++) {
for (int j = 1; j <= text2.size(); j++) {
if (text1[i - 1] == text2[j - 1]) {
s += text1[i - 1];
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
cout << s << endl;//输出一个最长子序列
return dp[text1.size()][text2.size()];
}
不相交的线,等于子序列问题LCS
本质还是求取最长公共子序列!
给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。
本质还是求取两字符串的最长公共子序列!
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
dp[i][j]//表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。
if (word1[i - 1] == word2[j - 1])
不操作
if (word1[i - 1] != word2[j - 1])
增
删
换
双指针(极优)
题目:给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
注意初始化问题,初始化时由状态转移矩阵dp[]的含义进行初始化。
字符串中最长的回文子串。
由于dp[i][j]需要用到dp[i+1][j-1]的结果进行分析进行分析,所以i从最大值开始递减,j从(j-1>=i+1)处开始递增。
string longestPalindrome(string s) {
// dp[i][j] 表示 s[i..j] 是否是回文串
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int maxLen = 1;//记录长度
int begin = 0; //记录首位
for (int i = s.size() - 1; i >= 0; i--){
for (int j = i; j < s.size(); j++){
//左右两侧相同
if (s[i] == s[j]){
if (j - i + 1 <= 2){ //短串
dp[i][j] = true;
if (j - i + 1 > maxLen) {
begin = i;
maxLen = j - i + 1;
}
}
else{ //长串
if (dp[i + 1][j - 1]){ //判断内串是否为回文子串
dp[i][j] = true;
if (j - i + 1 > maxLen) {
begin = i;
maxLen = j - i + 1;
}
}
}
}
}
}
return s.substr(begin, maxLen);
}
返回字符串中 回文子串 的数目。同上,最长回文子串
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
int result = 0;
for (int i = s.size() - 1; i >= 0; i--) { //
for (int j = i; j < s.size(); j++) {
if (s[i] == s[j]) {
if (j - i <= 1) { // 短串
result++;
dp[i][j] = true;
}
else if (dp[i + 1][j - 1]) { // 长串
result++;
dp[i][j] = true;
}
}
}
}
return result;
}
字符串中最长的 回文子序列 的长度。
int longestPalindromeSubseq(string s) {
//dp[i][j]为[i,j]内最长回文子序列的长度,子序列不需要考虑内部具体情况
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.size(); 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][s.size() - 1];
}
关于正则表达式,用于查找匹配
状态:dp[i][j]表示s的前i个能否被p的前j个匹配
转移方程:
1 s[i]==p[j]: dp[i][j]=dp[i-1][j-1];
2 p[j]=='.' : dp[i][j]=dp[i-1][j-1];
3 p[j]=='*' : 分情况讨论
* 的含义是 匹配零个或多个前面的那一个元素,所以要考虑他前面的元素 p[j-1]。p[j-1]可以匹配>=0次
* 跟着前一个字符走,前一个能匹配上 s[i],* 才能有用,前一个都不能匹配上 s[i],* 也无能为力,只能让前一个字符消失,也就是匹配 00 次前一个字符。
3.1 p[j-1] != s[i] : dp[i][j] = dp[i][j-2]
前一个字符p[j-1]不匹配s[i],此时dp[i][j]的状态由前前个字符p[i-2]与s[i]匹配的结果决定
3.2 p[j-1] == s[i] or p[j-1] == ".":
3.2.1 dp[i][j] = dp[i-1][j] // 多个字符匹配的情况
3.2.2 dp[i][j] = dp[i][j-1] // 单个字符匹配的情况
3.2.3 dp[i][j] = dp[i][j-2] // 没有匹配的情况
bool isMatch(string s, string p) {
s = " " + s;//防止该案例:""\n"c*"
p = " " + p;
int m = s.size(), n = p.size();
vector<vector<bool>>dp(m+1,vector<bool>(n+1,false));
dp[0][0] = true;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s[i - 1] == p[j - 1] || p[j - 1] == '.') { //匹配
dp[i][j] = dp[i - 1][j - 1];
}
else if (p[j - 1] == '*') { //讨论p[j-1]与s[i]
if (s[i - 1] != p[j - 2] && p[j - 2] != '.')
dp[i][j] = dp[i][j - 2];
else {
dp[i][j] = dp[i][j - 1] || dp[i][j - 2] || dp[i - 1][j];
}
}
}
}
return dp[m][n];
}
给你一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
方法1:动态规划
两方法:动态规划、栈。
方法一:动态规划
从221. 最大正方形演变而来。
最大正方形中用 d p [ i ] [ j ] dp[i][j] dp[i][j]来表示,以 i , j i,j i,j为右下角元素的正方形。
状态转移:
演变为求取最大矩形时,使用正方形的状态转移方程已经不能满足。
建立辅助矩阵: r e s : ( r i g h t [ j ] − l e f t [ j ] ) ∗ h e i g h t [ j ] res:(right[j]-left[j])*height[j] res:(right[j]−left[j])∗height[j]
h e i g h t [ ] height[] height[]:从上到下的高度。
10100 1 0 1 0 0 10100
20211 2 0 2 1 1 20211
31322 3 1 3 2 2 31322
40030 4 0 0 3 0 40030
l e f t [ ] left[] left[]:从左向右,出现连续‘1’的string的第一个坐标。
00200 0 0 2 0 0 00200
00222 0 0 2 2 2 00222
00000 0 0 0 0 0 00000
00030 0 0 0 3 0 00030
r i g h t [ ] right[] right[]:从右向左,出现连续‘1’的string的最后一个坐标。
15355 1 5 3 5 5 15355
15355 1 5 3 5 5 15355
15355 1 5 3 5 5 15355
15545 1 5 5 4 5 15545
int maximalRectangle(vector<vector<char>>& matrix) {
int result = 0;
int m = matrix.size(), n = matrix[0].size();
vector<int>height(n, 0);
vector<int>left(n, 0);
vector<int>right(n, n);
for (int i = 0; i < m; i++) { //从上向下逐行遍历
int curLeft = 0, curRight = n;
for (int j = 0; j < n; j++) { //记录高度
if (matrix[i][j] == '1') height[j]++;
else height[j] = 0;
}
for (int j = 0; j < n; j++) { //记录左下标
if (matrix[i][j] == '1') {
left[j] = max(curLeft, left[j]);//左下标取靠右值
}
else {
left[j] = 0;
curLeft = j + 1;
}
}
for (int j = n - 1; j >= 0; j--) { //记录右下标
if (matrix[i][j] == '1') {
right[j] = min(curRight, right[j]);//右下标取靠左值
}
else {
right[j] = n;
curRight = j;
}
}
for (int j = 0; j < n; j++) { //遍历求取当前i行的最大矩阵面积
result = max(result, (right[j] - left[j]) * height[j]);
}
}
return result;
}
方法二:栈
从84. 柱状图中最大的矩形演变而来。
1、先使用动态规划计算每一层的柱子的高度
2、单调栈计算每一层的最大矩阵面积(与柱状图中最大矩形一样解法, 必须先理解 84题解)
int maximalRectangle(vector<vector<char>>& matrix) {
//dp[i][j]:i*j范围的最大矩阵面积
int result = 0;
int m = matrix.size(), n = matrix[0].size();
//求取每层的高度柱状图
vector<vector<int>>heights(m + 1, vector<int>(n + 2, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (matrix[i - 1][j - 1] == '1') heights[i][j] = heights[i - 1][j] + 1;
else heights[i][j] = 0;
}
}
//开始求取逐层的最大面积,同84题
for (int i = 1; i <= m; i++) {
stack<int>st;
for (int j = 0; j <= n + 1; j++) {
while (!st.empty() && heights[i][j] < heights[i][st.top()]) {
int curHeight = heights[i][st.top()];
st.pop();
result = max(result, curHeight * (j - st.top() - 1));
}
st.push(j);
}
}
return result;
}
设计二叉树,首先考虑如何遍历;接着考虑操作节点和遍历节点如何处理。
class Solution {
public:
int maxPathSum(TreeNode* root) {
//二叉树首先思考如何遍历
//需要先了解子节点,再判断父节点是否选择连接,选择后序遍历
maxSum(root);
return result;
}
private:
int maxSum(TreeNode* root){
if(root==nullptr) return 0;
//判断子节点的value值,小于0就舍弃取0
int leftVal = max(maxSum(root->left),0);
int rightVal = max(maxSum(root->right),0);
//不可以连接父节点的情况,左中右(右中左)
int soloVal = leftVal + rightVal + root->val;
result = max(result , soloVal);
//可以连接父节点的情况
int conVal = root->val+max(leftVal , rightVal);
return conVal;
}
int result=INT_MIN;
};
找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
注意:当前位置的最优解未必是由前一个位置的最优解转移得到的,例如[-2,1,-4]。
int maxProduct(vector<int>& nums) {
//遍历观察数据变化
int maxn = nums[0], minn = nums[0], ans = nums[0];
for (int i = 1; i < nums.size(); i++) {
int mx = maxn, mn = minn;
//状态转移方程:当前位置最优值为
//1、i-1的连续子数组乘积最大值 X 当前位置i的值
//2、i-1的连续子数组乘积最小值 X 当前位置i的值
//3、当前位置i的值
maxn = max(nums[i], max(mx * nums[i], mn * nums[i]));
minn = min(nums[i], min(mx * nums[i], mn * nums[i]));
ans = max(maxn, ans);
}
return ans;
}
状态:当前气球 k 是 [i,j] 区间内最后一个待戳破气球
dp[i][j] 表示开区间 ( i , j ) 能拿到的的金币,k 是这个区间 最后一个 被戳爆的气球,枚举 i 和 j ,遍历所有区间,i - j 能获得的最大数量的金币等于 戳破当前的气球获得的金钱加上之前 i - k 、k - j 区间中已经获得的金币
参考博客:
代码随想录
力扣HOT100
力扣剑指offer
CodeTop