DP的思想
DP的由来是因为可以将一个问题由子问题得出:完整的问题可以由正方向得出(符合人的逻辑,自底向上),也可以由反方向得出(回溯,自顶向下),一般来说推荐正难则反,即推荐反向思考。
所以dp问题最经典的解题思路四个步骤就是:问题能否从结果往前递归回溯(即由上一个问题得到当前问题解)?
-->回溯+记忆化搜索(优化时间)
-->1:1翻译成递推,即dp形式
-->优化空间
回溯的方式更加容易且贴合dp的子问题递归,因此这里讲的是以反向方式来解决。(不去关心到达怎么解决,只关心当前状态是按什么方程由上一个状态得来的)
记忆化搜索:通过dfs的参数来决定创建的是几维数组,它也决定可了递推式中dp的维度
- 线性DP:一般从前缀/后缀上进行转移
- 区间DP:从小区间转移到大区间
1 线性DP
线性DP,一般从前缀/后缀上进行转移。 ### 1.1 背包问题 本质还是选与不选 #### 0-1背包 0-1背包:有n个物品,第i个物品的体积为w[i]
,价值为v[i]
,每个物品至多选一个,求体积和不超过capacity
时的最大价值和。
思路:
背包问题是dp的一个经典题型,涉及到一个当前元素选与不选: - 选:在剩余容量为c-w[i]
时,从前i-1个物品得到的最大价值和 - 不选:在剩余容量c
时,从前i-1
个物品得到的最大价值和 - 取两者较大值。
边界问题: - i<0
或者c<0
时,需要返回0,次数代表没有其他商品可选,或者没有容量可以装了
记忆化搜索:通过dfs的参数来决定创建的是几维数组,它也决定可了递推式中dp的维度
回溯+记忆化搜索
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int maxValue(vector<int>&w,vector<int>&v,int capacity){ int n = w.size(); vector<vector<int>> cache(n,vector<int>(capacity+1,-1)); function<int(int,int)> dfs = [&](int i,int c){ if(i < 0||c < 0) return 0; int& res = cache[i][c]; if(res!=-1) return res; if(c>=w[i]){ res = dfs(i-1,c-w[i])+v[i]; } res = max(res,dfs(i-1,c)); return res; }; return dfs(n-1,capacity); }
|
递推
讲上述的“回溯+记忆化搜索”
进行1:1翻译可以得到递推,注意翻译过程中的边界条件如何实现(一般通过增加1个开额外的dp数组即可)
上述中,用dp数组得到的递推式如下: dp[i][c] = max(dp[i-1][c],dp[i-1][c-w[i]]+v[i])
1 2 3 4 5 6 7 8 9 10 11
| int maxValue(vector<int>&w,vector<int>&v,int capacity){ int n = w.size(); vector<vector<int>> dp(n+1,vector<int>(capacity+1,0)); for(int i = 0; i < n; ++i){ for(int j = 1; j <= capacity; ++j){ if(j>=w[i]) dp[i+1][j] = dp[i][j-w[i]]+v[i]; dp[i+1][j] = max(dp[i+1][j],dp[i][j]); } } return dp[n][capacity]; }
|
变体题目:
494.目标和
完全背包
完全背包与01背包的不同点,只在于可以重复选择:
回溯+记忆化搜索
只需要更改res = dfs(i-1,c-w[i])+v[i];
为res = dfs(i,c-w[i])+v[i];
即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int maxValue(vector<int>&w,vector<int>&v,int capacity){ int n = w.size(); vector<vector<int>> cache(n,vector<int>(capacity+1,-1)); function<int(int,int)> dfs = [&](int i,int c){ if(i < 0||c < 0) return 0; int& res = cache[i][c]; if(res!=-1) return res; if(c>=w[i]){ res = dfs(i,c-w[i])+v[i]; } res = max(res,dfs(i-1,c)); return res; }; return dfs(n-1,capacity); }
|
变体题目: 332.零钱兑换
1.2 股票问题(状态DP)
leetcode内由许多股票问题,这类成为状态机DP 121.买卖股票的最佳时机 122.买卖股票的最佳时机Ⅱ 123.买卖股票的最佳时机Ⅲ 188.买卖股票的最佳时机Ⅳ 714.买卖股票的最佳时机含手续费 309.买卖股票的最佳时机含冷冻期
上面的题目均可由回溯+记忆化搜索的一个模板解决。
买卖股票的最佳时机Ⅱ
其题意就是就是至少交易0次,即可以交易无穷次。
回溯+记忆化搜索
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int maxProfit(vector<int>& prices) { int n = prices.size(); vector<vector<int>>cache(n,vector<int>(2,-1)); function<int(int,bool)>dfs = [&](int i,bool hold){ if(i<0) return hold?INT_MIN/2:0; int& res = cache[i][hold]; if(res!=-1) return res; if(hold) res = max(dfs(i-1,true),dfs(i-1,false)-prices[i]); else res = max(dfs(i-1,false),dfs(i-1,true)+prices[i]); cache[i][hold] = res; return res; }; return dfs(n-1,false); }
|
递推
将回溯+记忆化搜索
进行1:1翻译成递推(自底向上),则递推式:dp(i,0)
表示前i个股票中当前未持有股票时的最大利润,dp(i,1)`表示前i个股票中当前持有股票时的最大利润,有:
dp(i,0) = max(dp(i-1,0),dp(i-1,1)+prices[i])
dp(i,1) = max(dp(i-1,1),dp(i-1,0)-prices[i])
边界条件-->即递推的初始状态: 只需要将if(i<0) return hold?INT_MIN/2:0;
这句话转换,因此我们为dp多开辟一个空间作为出发临界状态,即dp大小为dp[n+1][2]
dp[0][0] = 0
dp[0][1] = INT_MIN/2
1 2 3 4 5 6 7 8 9 10 11
| int maxProfit(vector<int>& prices) { int n = prices.size(); vector<vector<int>> dp(n+1,vector<int>(2,-1)); dp[0][0] = 0; dp[0][1] = INT_MIN/2; for(int i = 0;i < n; ++i){ dp[i+1][0] = max(dp[i][0],dp[i][1]+prices[i]); dp[i+1][1] = max(dp[i][1],dp[i][0] - prices[i]); } return dp[n][0]; }
|
买卖股票的最佳时机Ⅳ
最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。只需要将上面的代码dfs添加一个参数j即可 ##### 回溯+记忆化搜索 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| int maxProfit(int k, vector<int>& prices) { int n = prices.size(); int cache[n][k+1][2]; memset(cache,-1,sizeof(cache)); function<int(int,int,bool)> dfs = [&](int i,int j,bool hold){ if(j<0) return INT_MIN/2; if(i<0) return hold?INT_MIN/2:0; int& res = cache[i][j][hold]; if(res!=-1) return res; if(hold) res = max(dfs(i-1,j,true),dfs(i-1,j,false)-prices[i]); else res = max(dfs(i-1,j,false),dfs(i-1,j-1,true)+prices[i]); return res; }; return dfs(n-1,k,false); }
|
递推
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| int maxProfit(int k, vector<int>& prices) { int n = prices.size(); int dp[n+1][k+2][2]; memset(dp,INT_MIN+100000,sizeof(dp)); for(int j = 1;j<=k+1;++j){ dp[0][j][0] = 0; } for(int i = 0; i < n; ++i){ for(int j = 1; j <= k+1; ++j){ dp[i+1][j][1] = max(dp[i][j][1],dp[i][j][0]-prices[i]); dp[i+1][j][0] = max(dp[i][j][0],dp[i][j-1][1]+prices[i]); } } return dp[n][k+1][0]; }
|
思考:恰好k次交易如何做,那就需要保证初始状态正好是从恰好0次交易出发,而不会由其他状态,因此只有dp[0][1][0]=0
,其他均为INT_MIN+100000
买卖股票的最佳时机含冷冻期
直接秒 1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int maxProfit(vector<int>& prices) { int n =prices.size(); vector<vector<int>> cache(n,vector<int>(2,-1)); function<int(int,bool)> dfs = [&](int i,bool hold){ if(i<0) return hold?INT_MIN/2:0; int& res = cache[i][hold]; if(res!=-1) return res; if(hold) res = max(dfs(i-1,true),dfs(i-2,false)-prices[i]); else res = max(dfs(i-1,false),dfs(i-1,true)+prices[i]); return res; }; return dfs(n-1,false); }
|
1.3 最长公共子序列
从后缀转移进行转移,即:
回溯+记忆搜索
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class Solution { public: int longestCommonSubsequence(string text1, string text2) { int n = text1.size(),m = text2.size(); vector<vector<int>> cache(n,vector<int>(m,-1)); function<int(int,int)> dfs = [&](int i,int j){ if(i<0) return 0; if(j<0) return 0; int& res = cache[i][j]; if(res!=-1) return res; if(text1[i]==text2[j]) res = dfs(i-1,j-1)+1; res = max(res,max(dfs(i-1,j),dfs(i,j-1))); return res; }; return dfs(n-1,m-1); } };
|
递推
将上面的回溯+记忆搜索进行1:1翻译, 1 2 3 4 5 6 7 8 9 10 11 12
| 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=0;i<m;++i){ for(int j=0;j<n;++j){ if(text1[i]==text2[j]) dp[i+1][j+1] = dp[i][j]+1; else dp[i+1][j+1]=max(dp[i][j+1],dp[i+1][j]); } } return dp[m][n]; }
|
2 区间DP
- 区间DP:从小区间转移到大区间,可为两类
- 选与不选:从两侧向内缩小文体规模--->如最长回文子序列(回文串系列)
- 枚举选哪个:分割成多个规模更小的子问题--->如多边形三角部分的最低得分
2. 动态规划
我们遇到的问题中,有很大一部分可以用动态规划Dynamic Programming
来解。 解决这类问题可以很大地提升你的能力与技巧,我会试着帮助你理解如何使用DP来解题