目录
[简单多状态 dp 问题](#简单多状态 dp 问题)
[两个数组的 dp 问题](#两个数组的 dp 问题)
使用动态规划解题的一般思路
1、确定状态表示(最重要的一步)
用一个一维数组或者二维数组充当 dp(dynamic programming) 表 ,想办法把 dp 表填满,表格的某个数据就是答案,要根据题目要求确定合适的 dp 表大小,确保最终答案在 dp 表内。把 dp 表的某个数据就当作状态表示(dp[i] 表示什么含义,粗略的理解)。状态表示通常有两个思考方向:1、dp[i] 表示 i 位置为结尾(终点),从起点到 i 位置表示的什么 2、dp[i] 表示以 i 位置为开始(起点),从 i 位置到终点表示的什么。怎么得来:
如果 起点----> A ----> B -----> 终点,
- 如果 A 点影响 B 点(更新 B 点的状态需要用到 A 点),那么状态表示最好是 dp[i] 表示 i 位置为结尾(终点),从起点到 i 位置表示的什么,
- 如果 B 点影响 A 点(更新 A 点的状态需要用到 B 点),那么状态表示最好是 dp[i] 表示 i 位置为开始(起点),从 i 位置到终点表示的什么
如果从题目中分析出一个位置可能有多个状态,可以先定义一个粗略的状态(一维数组),再将这个粗略的状态进行细分,即增加一维,下标表示某种状态。
2、确定状态转移方程(最难的一步)
说白了就是想办法得到 dp[i] 等于什么,比如斐波那契的 dp[i] = dp[i-1] + dp[i-2]。如果始终得不到状态转移方程,可能是状态定义的问题,需要重新定义状态或者细分状态。
3、初始化
这一步是为了保证填表的时候不会有越界问题。比如斐波那契的 dp[i] = dp[i-1] + dp[i-2]。为了不越界,i 必须 >= 2,也就是说 dp[0] 和 dp[1] 必须初始化为某些值,并且初始化的这些值要保证后面的填表是正确的。
4、确定填表顺序
这一步是为了保证在填表的时候,所需要的状态已经被填过了。这一步要做到确定 1、从哪里开始填 2、填表的方向。比如斐波那契的 dp[i] = dp[i-1] + dp[i-2],如果从 dp[2] 开始填,从左往右填,就可以保证填写 dp[i] 时已经知道 dp[i-1] 和 dp[i-2]。
一般而言,如果dp[i] 表示 i 位置为结尾(终点),从起点到 i 位置表示的什么,那么填表顺序一般是从起点到终点,因为这样 dp[n](n < i) 才能先确定,才能影响到 dp[i] ,同理,如果dp[i] 表示 i 位置为开始(起点),那么填表顺序一般是从终点到起点。
5、确定返回值
根据题目要求和状态表示,确定返回值。比如斐波那契要求返回第 n 个斐波那契数,确定的状态表示是:dp[i] 表示第 i 个斐波那契数,只需返回 dp[i] 就可以了。

解析:确定状态表示: dp[i] 表示从 0 阶台阶(地面)到 i 阶台阶所有的方式数,确定状态转移方程:只有 i - 1 阶台阶、i - 2 阶台阶、i - 3阶台阶可以通过一步到达 i 阶台阶,到 i 阶台阶所有的方式数可以是从地面到 i - 1 阶台阶、i - 2 阶台阶、i - 3阶台阶的方式数之和,即:dp[i] = dp[i-1] + dp[i-2] + dp[i-3]。初始化: 将 dp[0]、dp[1]、dp[2] 分别初始化为 1、1、2(原因显而易见)。确定填表顺序: dp[i] 依赖它前面的值,所以应该从左往右填。**确定返回值:**既然dp[i] 表示从 0 阶台阶(地面)到 i 阶台阶所有的方式数,那么 dp[n] 表示从 0 阶台阶(地面)到 n 阶台阶所有的方式数,即题目所求的值。
cpp
class Solution {
public:
int MOD = 1e9 + 7;
int waysToStep(int n) {
if(n == 0 || n == 1) return 1;
if(n == 2) return 2;
vector<int> dp(n + 1);
dp[0] = 1;
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n; i++)
{
dp[i] = ((dp[i-1] + dp[i-2]) % MOD + dp[i-3]) % MOD;
}
return dp[n];
}
};
每做一次加法,都要取一下模,模 1e9 + 7 后的数一定小于 1e9 + 7,在计算过程中两个数之和一定小于 2e9 + 14,int 存得下,但是三个数就不一定了,所以每做一次加法,都要取一下模。

解析:dp[i] 表示不管用什么方式,从下标为 0 或 1 位置开始,到达 i 位置的最小花费。比如 dp[0] = 0,含义就是到达 0 位置的最小花费是 0(可以从 0 位置开始,不需要到达),dp[1] = 0 同理,dp[2] 表示不管用什么方式,从下标为 0 或 1 位置开始,到达 2 位置的最小花费,那 dp[2] = min(dp[0] + cost[0],dp[1] + cost[1]),可以推出状态转移方程
cpp
class Solution {
public:
// 使用 dfs 暴力求解,会超时
/*int ret = INT_MAX;
int minCostClimbingStairs(vector<int>& cost) {
dfs(cost,0,0);
dfs(cost,1,0);
return ret;
}
void dfs(vector<int>& cost, int pos, int fee)
{
if(pos >= cost.size())
{
if(fee < ret) ret = fee;
return;
}
dfs(cost,pos + 1,fee + cost[pos]);
dfs(cost,pos + 2,fee + cost[pos]);
}*/
// 动态规划
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> dp(n);
dp[0] = dp[1] = 0;
for(int i = 2; i < n; i++)
{
dp[i] = min(dp[i - 1] + cost[i - 1],dp[i - 2] + cost[i - 2]);
}
int n1 = dp[n - 1] + cost[n - 1];
int n2 = dp[n - 2] + cost[n - 2];
return n1 < n2 ? n1 : n2;
}
};

解析:dp[i] 表示从 s 下标为 0 位置开始,到下标为 i 位置(包括下标为 i 位置)的所有解码方法总数,如果 i 位置的数字可以单独解码,那么 dp[i] = dp[i - 1],(相当于在所有 i - 1 位置的情况后面加上 s[i] 所对应的字母),如果 i 位置的数字可以与前面的数字结合解码,那么 dp[i] 再加上 dp[i-2],(相当于在所有 i - 1 位置的情况后面加上 s[i-1]s[i] 所对应的字母)。
cpp
class Solution {
public:
int numDecodings(string s) {
if(s[0] == '0') return 0;
int n = s.size();
vector<int> dp(n,0);
dp[0] = 1;
if(n > 1)
{
int num = getnum(s[0],s[1]);
if(s[1] == '0')
{
if(num >= 10 && num <= 26) dp[1] = 1;
else dp[1] = 0;
}
else
{
if(num >= 11 && num <= 26) dp[1] = 2;
else dp[1] = 1;
}
}
for(int i = 2; i < n; i++)
{
if(s[i] != '0') dp[i] = dp[i - 1];
int num = getnum(s[i - 1],s[i]);
if(num >= 10 && num <= 26)
{
dp[i] += dp[i - 2];
}
}
return dp[n - 1];
}
int getnum(char c1,char c2)
{
return (c1 - '0') * 10 + c2 - '0';
}
};
处理边界问题和初始化的技巧
在原 dp 表的前面加上一个虚拟的"结点",建立新旧 dp 表的映射关系。可以简化代码

对于二维 dp 表,可以在原 dp 表的基础增加一行和一列(这不是固定的,要根据题目)

注意事项:
虚拟节点里面的值,要保证后面的填表是正确的
下标的映射关系
使用动态规划解决路径问题
题目中给出起点和终点,要求算出从起点到终点有多少条不同路径,或者是某条特殊路径(比如路径上的值的和最大或最小),可以使用动态规划解决这类问题。这类问题通常要判断(有的题目显而易见,不用判断)dp[i] 表示 i 位置为结尾(终点),从起点到 i 位置表示的什么 还是 dp[i] 表示以 i 位置为开始(起点),从 i 位置到终点表示的什么

解析:将 dp[i][j] 定义为从起点到 (i,j)位置一共有多少条不同路径,只有(i-1,j)或(i,j-1)位置可以到达(i,j)位置,也就是说到 (i,j)位置一共有 dp[i-1][j] + dp[i][j-1] 条不同路径,即:dp[i]j[j] = dp[i-1][j] + dp[i][j-1];
cpp
class Solution {
public:
// 也可以使用 dfs 解决
//int memo[101][101];
int uniquePaths(int m, int n) {
//memset(memo,-1,sizeof(memo));
//return dfs(m,n);
vector<vector<int>> dp(m + 1,vector<int>(n + 1,0));
dp[0][1] = 1;
for(int i = 1; i <= m; i++)
{
for(int j = 1; j <= n; j++)
{
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m][n];
}
/*int dfs(int m, int n)
{
if(memo[m][n] != -1) return memo[m][n];
if(m == 1 || n == 1)
{
memo[m][n] = 1;
return 1;
}
memo[m][n] = dfs(m - 1, n) + dfs(m,n - 1);
return memo[m][n];
}*/
};

解析:这道题最重要的是想到 dp[i] 表示以 i 位置为开始(起点),从 i 位置到终点所需最少血量
cpp
class Solution {
public:
// dp[i] 表示 i 位置为结尾(终点),从起点到 i 位置所需最少血量 失败
/*typedef pair<long long , long long> pll;
int calculateMinimumHP(vector<vector<int>>& dungeon) {
int row = dungeon.size();
int col = dungeon[0].size();
vector<vector<pll>> dp(row + 1,vector<pll>(col + 1,{INT_MAX,INT_MAX}));
if(dungeon[0][0] <= 0) dp[1][1] = {-dungeon[0][0] + 1,1};
else dp[1][1] = {1,1 + dungeon[0][0]};
for(int i = 1; i <= row; i++)
{
for(int j = 1; j <= col; j++)
{
if(i == 1 && j == 1) continue;
pll ret1 = func(dp[i-1][j],dungeon[i-1][j-1]);
pll ret2 = func(dp[i][j-1],dungeon[i-1][j-1]);
if(ret1.first > ret2.first) dp[i][j] = ret2;
else if(ret1.first < ret2.first) dp[i][j] = ret1;
else
{
if(ret1.second >= ret2.second) dp[i][j] = ret1;
else dp[i][j] = ret2;
}
}
}
//if(col > 1) cout << dp[1][2].first << ' ' << dp[1][2].second << endl;
for(int i = 1; i <= row; i++)
{
for(int j = 1; j <= col; j++)
{
cout << '{' << dp[i][j].first << ' ' << dp[i][j].second << '}' << ' ';
}
cout << endl;
}
return dp[row][col].first;
}
pll func(pll info,int num)
{
long long blood = info.second + num;
if(blood >= 1) return {info.first,blood};
else return {info.first + (-blood) + 1,1};
}*/
// dp[i] 表示以 i 位置为开始(起点),从 i 位置到终点所需最少血量 成功
int calculateMinimumHP(vector<vector<int>>& dungeon) {
int row = dungeon.size();
int col = dungeon[0].size();
vector<vector<int>> dp(row + 1,vector<int>(col + 1, INT_MAX));
dp[row-1][col] = 1;
for(int i = row - 1; i >= 0; i--)
{
for(int j = col - 1; j >= 0; j--)
{
if(dp[i][j+1] >= dp[i+1][j])
{
int blood = dp[i+1][j] - dungeon[i][j];
if(blood > 0) dp[i][j] = blood;
else dp[i][j] = 1;
}
else
{
int blood = dp[i][j + 1] - dungeon[i][j];
if(blood > 0) dp[i][j] = blood;
else dp[i][j] = 1;
}
}
}
return dp[0][0];
}
};
简单多状态 dp 问题
之前的 dp 问题,每个位置的状态表示只有一个 dp[i],现在尝试每个位置的状态表示有多个,可以用 f[i] 和 g[i] 表示。

解析:用 f[i] 表示:如果这个位置接,目前最长服务时长,g[i] 表示:如果这个位置不接,目前最长服务时长,先推导 f[i] 的状态转移方程:如果这个位置接,那么上个位置一定没有接,目前最长服务时长就是 g[i - 1] + nums[i] ,再推导 g[i] 的状态转移方程:如果这个位置不接,那么上个位置可能接也可能不接,目前最长服务时长就是 max(f[i-1],g[i-1)。最后的结果就是:max(f[n-1],g[n-1])
这道题可以作为一个模板,应到其他题:只要出现"跳着选",可以联想到这道题
cpp
class Solution {
public:
// dfs 超时
/*int ret = 0;
int massage(vector<int>& nums) {
dfs(nums,0,0);
return ret;
}
void dfs(vector<int>& nums,int pos,int sum)
{
if(pos >= nums.size())
{
if(sum > ret) ret = sum;
return;
}
dfs(nums,pos + 2,sum + nums[pos]);
dfs(nums,pos + 1,sum);
}
// 用大根堆存储 i - 1 位置之前的最长服务时长,更直观
int massage(vector<int>& nums) {
if(nums.size() == 0) return 0;
int n = nums.size();
priority_queue<int> q;
vector<int> dp(n);
dp[0] = nums[0];
if(n == 1) return dp[0];
dp[1] = max(nums[0],nums[1]);
q.push(dp[0]);
for(int i = 2; i < n; i++)
{
dp[i] = nums[i] + q.top();
q.push(dp[i - 1]);
}
int max = 0;
for(auto i : dp)
{
if(i > max) max = i;
}
return max(dp[n-1],dp[n-2]);
}*/
// 多状态 dp
int massage(vector<int>& nums) {
if(nums.size() == 0) return 0;
int n = nums.size();
if(n == 1) return nums[0];
vector<int> f(n);
vector<int> g(n);
f[0] = nums[0];
g[0] = 0;
for(int i = 1; i < n; i++)
{
f[i] = g[i-1] + nums[i];
g[i] = max(f[i - 1],g[i - 1]);
}
return max(f[n - 1],g[n - 1]);
}
};

解析:这道题可以复用上一道题的思路,对第一个位置分类讨论即可
cpp
class Solution {
public:
int rob(vector<int>& nums) {
// 第一个位置偷
int ret1 = _rob(nums,2,nums.size() - 2) + nums[0];
// 第一个位置不偷
int ret2 = _rob(nums,1,nums.size() - 1);
return max(ret1,ret2);
}
int _rob(vector<int>& nums,int start,int end)
{
int n = end - start + 1;
if(n <= 0) return 0;
if(n == 1) return nums[start];
vector<int> f(n);
vector<int> g(n);
f[0] = nums[start];
g[0] = 0;
for(int i = 1; i < n; i++)
{
f[i] = g[i-1] + nums[start + i];
g[i] = max(f[i - 1],g[i - 1]);
}
return max(f[n - 1],g[n - 1]);
}
};

解析:这道题可以使用"按摩师"的思路,开辟一个大小为 10001 的数组 arr,arr[i] 表示 nums 数组中 i 数字的总数,只要对 arr 数组使用"按摩师"的思路即可。
cpp
class Solution {
public:
int deleteAndEarn(vector<int>& nums) {
int arr[10001] = {0};
for(auto i : nums) arr[i] += i;
int f[10001] = {0};
int g[10001] = {0};
f[0] = arr[0];
g[0] = 0;
for(int i = 1; i < 10001; i++)
{
f[i] = g[i - 1] + arr[i];
g[i] = max(f[i - 1], g[i - 1]);
}
return max(f[10000],g[10000]);
}
};

解析:确定状态表示:如果用 0 表示红色,1 表示蓝色,2 表示绿色,dp[i][0] 表示 i 位置房子涂红色,此时的最小花费。确定状态转移方程:dp[i][0] = min(dp[i-1][1],dp[i-1][2]) + costs[i-1][0];
cpp
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
int n = costs.size();
vector<vector<int>> dp(n+1,vector<int>(3,0));
for(int i = 1; i < n + 1; i++)
{
dp[i][0] = min(dp[i-1][1],dp[i-1][2]) + costs[i-1][0];
dp[i][1] = min(dp[i-1][0],dp[i-1][2]) + costs[i-1][1];
dp[i][2] = min(dp[i-1][1],dp[i-1][0]) + costs[i-1][2];
}
return min(min(dp[n][0],dp[n][1]),dp[n][2]);
}
};
买卖股票的最佳时机

解析:dp[i][0] 表示第 i 天结束后手里没有股票,dp[i][1] 表示第 i 天结束后手里有一支股票。
考虑 dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i−1][0],或者前一天结束的时候手里持有一支股票,即 dp[i−1][1],这时候我们要将其卖出,并获得 prices[i] 的收益。因此为了收益最大化,我们列出如下的转移方程:
dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]}
再来考虑 dp[i][1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即 dp[i−1][1],或者前一天结束时还没有股票,即 dp[i−1][0],这时候我们要将其买入,并减少 prices[i] 的收益。可以列出如下的转移方程:
dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]}
对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候
dp[0][0]=0,dp[0[1]=−prices[0]。
来源:力扣(LeetCode)
cpp
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size() == 1) return 0;
int n = prices.size();
vector<vector<int>> dp(n,vector<int>(2));
dp[0][0] = 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]);
dp[i][1] = max(dp[i - 1][1],dp[i - 1][0] - prices[i]);
}
return max(dp[n-1][0],dp[n-1][1]);
}
};

解析:f[i][j] 表示:第 i 天结束后,完成了 j 次交易,手里有一支股票,此时的最大利润。g[i][j] 表示:第 i 天结束后,完成了 j 次交易,手里没有股票,此时的最大利润。
考虑 g[i][j] 的状态转移方程:
如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 g[i−1][j],或者前一天结束的时候手里持有一支股票,即 f[i−1][j],这时候我们要将其卖出,并获得 prices[i] 的收益,并且交易次数加 1。因此为了收益最大化,我们列出如下的转移方程:
g[i][j]=max{g[i−1][j],f[i−1][j-1]+prices[i]}
注意循环的 j 是从 0 开始的,而 g[i][0]=max{g[i−1][0],f[i−1][-1]+prices[i]},g[i−1][0] 状态一定存在,f[i−1][-1] 状态不存在,所以在求 g[i][j] 时,先把 g[i−1][j] 赋值给 g[i][j],再判断 j -1 是否 >= 1,再考虑 f[i−1][j-1];
考虑 f[i][j] 的状态转移方程:
按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即 f[i−1][j],或者前一天结束时还没有股票,即 g[i−1][j],这时候我们要将其买入,并减少 prices[i] 的收益,注意交易次数没有增加。可以列出如下的转移方程:
f[i][j]=max{f[i−1][j],g[i−1][j]−prices[i]}
考虑如何初始化:观察状态转移方程,知道要初始化 f[0] 和 g[0] 这两行,但是由于只能进行两次交易,所以要珍惜交易次数,不能当天买当天买的情况,所以 f[1] 和 g[1] 没有意义,应该初始化为 -INT_MAX ,但是 -INT_MAX 可能会参与计算发生溢出,所以应该初始化为 -0x3f3f3f3f.
最大利润可能是进行了 0 次、1 次、或 2 次交易后,手里没有股票的情况,因此要返回 g 最后一行的最大值。
cpp
class Solution {
public:
const int INF = 0x3f3f3f3f;
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> f(n,vector<int>(3,-INF));
vector<vector<int>> g(n,vector<int>(3,-INF));
f[0][0] = -prices[0];
g[0][0] = 0;
for(int i = 1; i < n; i++)
{
for(int j = 0; j < 3; j++)
{
f[i][j] = max(f[i-1][j],g[i-1][j] - prices[i]);
g[i][j] = g[i-1][j];
if(j >= 1) g[i][j] = max(g[i][j],f[i-1][j-1] + prices[i]);
}
}
int ret = 0;
for(int i = 0; i < 3; i++) ret = max(ret,g[n-1][i]);
return ret;
}
};
如果题目要求只能进行 K 次交易,那么只需要将 f、g 数组的第二维的大小设置为 k + 1,代表进行了 0、1、2...... k 次交易,第二个 for 循环改为 for(int j = 0; j <= k; j++)。
子数组问题
运用动态规划解决求数组的某个特定子数组。

解析:dp[i] 定义为:以 i 位置为结尾的最大连续子数组的和。考虑状态转移方程:如果 i 位置的数加上 以 i - 1 位置为结尾的最大连续子数组的和还没有以 i 位置单独作为子数组的和要大,那么 dp[i] = nums[i],否则 dp[i] = dp[i-1] + nums[i],即:dp[i] = max(dp[i-1] + nums[i],nums[i])
cpp
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n);
dp[0] = nums[0];
int ret = nums[0];
for(int i = 1; i < n; i++)
{
dp[i] = max(nums[i],nums[i] + dp[i-1]);
if(dp[i] > ret) ret = dp[i];
}
return ret;
}
};

解析:把环形数组就看成是一个普通数组,最终答案无非两种情况:1、刚好在数组内部 2、数组的头部和尾部

如果是刚好在数组内部,问题就转化成上道题的思路,如果是数组的头部和尾部,那么未被选择的元素组成的子数组就是所有连续子数组中和最小的子数组
细节问题:如果数组元素全为负,sum - min == 0,最终结果就是 0,不符合题意,所以如果 min == sum 时,不更新结果。
cpp
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int n = nums.size();
if(n == 1) return nums[0];
int sum = 0;
for(auto i : nums) sum += i;
vector<int> dp(n);
// 最大子数组和
dp[0] = nums[0];
int ret1 = nums[0];
for(int i = 1; i < n; i++)
{
dp[i] = max(nums[i],dp[i-1] + nums[i]);
if(dp[i] > ret1) ret1 = dp[i];
}
// 最小子数组和
int ret2 = sum - nums[0];
for(int i = 1; i < n; i++)
{
dp[i] = min(nums[i],dp[i-1] + nums[i]);
if(sum != dp[i] && sum - dp[i] > ret2) ret2 = sum - dp[i];
}
return max(ret1,ret2);
}
};

解析:dp[i] 定义为 s 的 [0,i] 子串是否可以由字典的单词拼接而成,分析状态转移方程:要知道 s 的 [0,i] 子串是否可以由字典的单词拼接而成,只需要知道 s 的 [0,j - 1] 子串是否可以由字典的单词拼接而成(dp[j - 1]) 和 s 的 [j,i] 子串构成的单词是否在字典,在 j 取 [0,i] 过程中,如果两个条件一旦都为真,那么 dp[i] 为真,否则为假。
初始化的技巧:由于 j 取 0 时不存在 dp[-1] 状态,所以可以在原字符串 s 之前加上空格 ' ' ,使原字符串所有字符的下标统一加 1,dp 数组的大小为 s 长度 + 1,
cpp
class Solution {
public:
// 递归超时
//string tmp;
//bool done = false;
//int n;
bool wordBreak(string s, vector<string>& wordDict) {
int n = s.size();
unordered_set<string> hash;
for(auto& s : wordDict) hash.insert(s);
//_wordBreak(s,wordDict);
vector<bool> dp(n + 1,false);
dp[0] = true;
s = ' ' + s;
for(int i = 1; i <= n; i++)
{
for(int j = i; j >= 1; j--)
{
if(dp[j-1] && hash.count(s.substr(j,i-j+1)))
{
dp[i] = true;
break;
}
}
}
return dp[n];
}
/*void _wordBreak(string& s, vector<string>& wordDict)
{
if(tmp.size() == s.size())
{
done = true;
return;
}
for(int i = 0; i < n; i++)
{
tmp += wordDict[i];
if(s.substr(0,tmp.size()) != tmp)
{
int cnt = wordDict[i].size();
while(cnt--) tmp.pop_back();
}
else
{
_wordBreak(s,wordDict);
if(done) return;
else
{
int cnt = wordDict[i].size();
while(cnt--) tmp.pop_back();
}
}
}
}*/
};
子序列问题
子序列 VS 子数组
子序列: 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序所构成的序列。例如,
[3,6,2,7]是数组[0,3,1,6,2,2,7]的子序列**子数组:**数组中一段连续的序列。所有子数组都是子序列,子数组是子序列的一种特殊情况。

解析:dp[i] 表示:以 i 位置为结尾的最长递增子序列的长度。分析状态转移方程:i 位置要么单独结尾,要么与前面的子序列结合,如果要与前面的子序列结合,必须满足:1、num[i] > num[j] 2、与前面最长的递增子序列结合,以 i 位置为结尾的递增子序列才是最长的。
cpp
class Solution {
public:
// 记忆化搜索
//int memo[2500];
//int n;
int lengthOfLIS(vector<int>& nums) {
/*vector<int> memo(2500,-1);
//memset(memo,-1,sizeof(memo));
n = nums.size();
int ret = 0;
for(int i = 0; i < n; i++)
{
if(memo[i] == -1) ret = max(dfs(nums,i,memo),ret);
}
return ret;*/
int n = nums.size();
vector<int> dp(n,1);
for(int i = 1; i < n; i++)
{
int m = 0;
for(int j = i-1; j >= 0; j--)
{
if(nums[j] < nums[i]) m = max(m,dp[j]);
}
dp[i] += m;
}
int ret = 1;
for(auto i : dp) ret = max(i,ret);
return ret;
}
/*int dfs(vector<int>& nums,int pos,vector<int>& memo)
{
if(pos == n - 1) return 1;
if(memo[pos] != -1) return memo[pos];
memo[pos] = 1;
for(int i = pos + 1; i < n; i++)
{
if(nums[i] > nums[pos])
{
memo[pos] = max(memo[pos],dfs(nums,i,memo) + 1);
}
}
return memo[pos];
}*/
};

解析:如果定义状态表示为:dp[i] 为以 i 位置为结尾的最长斐波那契子序列的长度,则推导不出状态转移方程,应该这样定义状态表示:dp[i][j] 表示以 i 位置和 j 位置(规定 i < j)为结尾的最长斐波那契子序列的长度,考虑状态转移方程:在 i 位置之前找出 aim == arr[j] - arr[i] ,如果存在 aim,并且 aim 所在位置的下标 < i,那么 j 位置就可以跟在 aim 和 i 位置之后 dp[i][j] = dp[aim所在位置下标][i] + 1。如果 aim 不存在或者aim 所在位置的下标大于 i 而小于 j,不合法,dp[i][j] = 2。优化:预先把 arr 数组的值和它的下标绑定,存储在 hash 表,可以快速查询 aim 是否存在和获取 aim 的下标。
cpp
class Solution {
public:
int lenLongestFibSubseq(vector<int>& arr) {
int n = arr.size();
map<int,int> hash;
for(int i = 0; i < n; i++) hash.insert({arr[i],i});
vector<vector<int>> dp(n,vector<int>(n,2));
for(int i = 0; i < n - 1; i++)
{
for(int j = i + 1; j < n; j++)
{
int aim = arr[j] - arr[i];
if(hash.count(aim) && aim < arr[i]) dp[i][j] = dp[hash[aim]][i] + 1;
}
}
int ret = 0;
for(int i = 0; i < n - 1; i++)
for(int j = i + 1; j < n; j++)
ret = max(ret,dp[i][j]);
if(ret < 3) return 0;
return ret;
}
};
回文串问题
运用动态规划解决回文串问题时,一般的思路是:选取一段区间来研究问题。选取[i,j]区间(这时 i 就默认 <= j 了),讨论 i 位置和 j 位置的字符是否相同来划分问题。通常要对 i == j 和 i + 1 == j 做特殊处理。要注意回文子字符串和回文子序列的区别。

解析:dp[i][j] (i <= j )表示:以 i 位置为开头和以 j 位置为结尾的字符串,是否是回文串。推导状态转移方程:如果 i 位置的字符 != j 位置的字符,那么 dp[i][j] = false,否则,看看 dp[i+1][j-1] 是否是回文串,如果是,那么 dp[i][j] 就是 true。为了防止越界,对 i == j 和 i + 1 == j 进行特殊判断。
cpp
class Solution {
public:
int countSubstrings(string s) {
int n = s.size();
vector<vector<bool>> dp(n,vector<bool>(n));
for(int i = n - 1; i >= 0; i--)
{
for(int j = i; j < n; j++)
{
if(s[i] != s[j]) dp[i][j] = false;
else
{
if(i == j || i + 1 == j) dp[i][j] = true;
else dp[i][j] = dp[i+1][j-1];
}
}
}
int ret = 0;
for(int i = 0; i < n; i++)
for(int j = i; j < n; j++)
if(dp[i][j]) ret++;
return ret;
}
};

解析:(这道题和上面的"单词拆分"题目链接有异曲同工之妙)dp[i] 定义为:从 0 位置到 i 位置的子串的最少分割次数。状态转移方程推导:如果 0 位置到 i 位置的子串是回文串,那么 dp[i] = 0;如果不是回文串,就看看从 j 到 i 位置子串(0 < j <= i) 是否是回文串,如果是,那么 dp[i] = dp[j - i] + 1,取所有情况的最小值。
cpp
class Solution {
public:
int minCut(string s) {
int n = s.size();
vector<vector<bool>> dp1(n,vector<bool>(n));
for(int i = n - 1; i >= 0; i--)
{
for(int j = i; j < n; j++)
{
if(s[i] != s[j]) dp1[i][j] = false;
else if(i == j || i + 1 == j) dp1[i][j] = true;
else dp1[i][j] = dp1[i + 1][j - 1];
}
}
vector<int> dp2(n,INT_MAX);
for(int i = 0; i < n; i++)
{
if(dp1[0][i]) dp2[i] = 0;
else
{
for(int j = 1; j <= i; j++)
{
if(dp1[j][i]) dp2[i] = min(dp2[i],dp2[j-1] + 1);
}
}
}
return dp2[n-1];
}
};

解析:用 dp[i] 表示以某个位置为结尾的经验在本题失效。受"回文串在两侧添加相同字符后仍是回文串"启发,可以这样定义状态表示:dp[i][j] 表示在 [i ,j] 区间内所有子序列中,最长回文子序列的长度。推导状态转移方程:

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

解析:dp[i][j] 表示:以 i 开头以 j 结尾的字符串,成为回文串的最少插入次数。推导状态转移方程:如果 s[i] == s[j] 对 i == j 和 i + 1 == j 特殊处理后,dp[i][j] 就等于 dp[i+1][j] 。如果 s[i] != s[j] 可以在 i 位置之前插入 j 位置的字符,也可以在 j 位置后插入 i 位置的字符。

两个数组的 dp 问题

解析:dp[i][j] 定义为:test1 的 [0,i] 区间和 test2 的 [0,j] 区间的所有子序列中,最长公共子序列的长度。推导状态转移方程:以 test1[i] 和 test2[j] 的字符是否相同来讨论:如果相同,那么 test1 的 [0,i] 区间和 test2 的 [0,j] 区间公共子序列一定可以以该字符为结尾,此时最长公共子序列的长度就是 dp[i-1][j-1] 的长度再 + 1;如果不相同,那么现在的最长公共子序列一定不能同时以 i 位置和 j 位置为结尾,可能是 i 位置或 j 位置之前的某个相同字符为结尾,只需要看看 dp[i-1][j] 和 dp[i][j-1] 谁比较大,谁就是 dp[i][j]。为了方便初始化,引入空字符串概念,空字符串与任何字符串的最长公共字符串长度都为 0。把 dp 表再增加一行和一列,第 1 行和第 1 列分别表示如果 test1 或 test2 是空字符串的情况,显然第 1 行和第 1 列的值都是 0。把原 test 字符串的开头都加上空格代表空字符串,此时从原字符串映射到 dp 表的映射关系仍然不变。
cpp
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size();
int n = text2.size();
vector<vector<int>> dp(m + 1,vector<int>(n + 1,0));
text1 = ' ' + text1;
text2 = ' ' + text2;
for(int i = 1; i <= m; i++)
{
for(int j = 1; j <= n; j++)
{
if(text1[i] == text2[j]) 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];
}
};

解析:dp[i][j] 表示:s 字符串的 [0,i] 字串是否与 p 字符串的 [0,j] 字串匹配。推导状态转移方程:以 p 字符串的 j 位置是什么字符来分类讨论,如果是普通字符,那就先看看是否与 s 字符串的 i 位置字符是否相同,如果不同,dpp[i][j] 就为 false,如果相同,那还要看看前面的字符是否匹配,即 dp[i-1][j-1];如果是 '?',把 ?与 s 字符串的 i 位置字符匹配后,看看前面的字符是否匹配,即 dp[i-1][j-1]。如果是 '*',再分情况讨论:如果 * 匹配空字符串,那么 dp[i][j] = dp[i][j-1],如果匹配一个字符,那么dp[i][j] = dp[i-1][j-1],如果匹配两个字符,那么dp[i][j] = dp[i-2][j-1],如果匹配三个字符,那么dp[i][j] = dp[i-3][j-1]........ 如果用循环依次匹配直到 * 匹配全部字符,那么时间复杂度将是 O(N^3),考虑优化,优化有两种方法:
1、"错位相减法"

2、

初始化需要注意的地方:
1、引入空字符串,给原始 dp 表在增加一行和一列,第一行表示 s 字符串为空的情况,如果 s 为空,那要么 p 为空,或者 p 全是 * 才可以完全匹配,所以要特殊处理 dp 表的第一行:遍历 p 字符串,如果 i 位置(p没有事先加空格)为 *,那么 dp[0][i+1] = ture,否则之后全为 false。
cpp
class Solution {
public:
bool isMatch(string s, string p) {
int m = s.size();
int n = p.size();
vector<vector<int>> dp(m + 1,vector<int>(n + 1,false));
dp[0][0] = true;
for(int i = 0; i < n; i++)
{
if(p[i] == '*') dp[0][i+1] = true;
else break;
}
s = ' ' + s;
p = ' ' + p;
for(int i = 1; i <= m; i++)
{
for(int j = 1; j <= n; j++)
{
if(p[j] == '?') dp[i][j] = dp[i-1][j-1];
else if(p[j] == '*') dp[i][j] = dp[i][j-1] || dp[i-1][j];
else if(p[j] == s[i] && dp[i-1][j-1]) dp[i][j] = true;
}
}
return dp[m][n];
}
};
背包问题
背包问题 (Knapsack problem) 是⼀种组合优化的 NP完全问题。
问题可以描述为:给定⼀组物品,每种物品都有⾃⼰的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最⾼。
根据物品的个数,可以分为如下⼏类:
• 01 背包问题:每个物品只有⼀个
• 完全背包问题:每个物品有⽆限多个
• 多重背包问题:每件物品最多有 si 个
• 混合背包问题:每个物品会有上⾯三种情况......
• 分组背包问题:物品有 n 组,每组物品⾥有若⼲个,每组⾥最多选⼀个物品
其中上述分类⾥⾯,根据背包是否装满,⼜分为两类(可以与上面的问题组合):
• 不⼀定装满背包
• 背包⼀定装满
因此,背包问题种类⾮常繁多,题型⾮常丰富,难度也是⾮常难以捉摸。但是,尽管种类⾮常多,都是从 01 背包问题演化过来的。所以,⼀定要把 01 背包问题学好。
01背包模板
你有一个背包,最大容量为 V。现有 n 件物品,第 i 件物品的体积为 vi,价值为 wi。研究人员提出以下两种装填方案:
1. 不要求装满背包,求能获得的最大总价值;
2. 要求最终恰好装满背包,求能获得的最大总价值。若不存在使背包恰好装满的装法,则答案记为 0。
解析:第一问:dp[i][j] 定义为从第一个物品到第 i 个物品,背包容量为 j,此时的最大总价值。推导状态转移方程:如果不选择第 i 个物品,现在要求 dp[i][j],那就在第一个物品到第 i - 1 个物品中求背包容量为 j,此时的最大总价值,即 dp[i-1][j],这种情况一定存在,所以先让 dp[i][j] = dp[i-1][j]。如果选择第 i 个物品,那么就在第一个物品到第 i - 1 个物品中求背包容量为 j - vi,此时的最大总价值,前提条件是 j - vi >= 0,即 dp[i-1][j-vi] + wi,如果 j - vi >= 0 成立,那 dp[i][j] 就等于 dp[i-1][j] 和 dp[i-1][j-vi] + wi 的较大值。
第二问:dp[i][j] 定义为从第一个物品到第 i 个物品,背包容量为 j,如果恰好装满背包,此时的最大总价值,如果不能恰好装满背包,则为 -1。推导状态转移方程:如果不选择第 i 个物品,现在要求 dp[i][j],那就在第一个物品到第 i - 1 个物品中求背包容量为 j,如果恰好装满背包,此时的最大总价值,即 dp[i-1][j],虽然 dp[i-1][j] 可能不存在(等于 -1)但仍可以先让 dp[i][j] = dp[i-1][j]。如果选择第 i 个物品,那么就在第一个物品到第 i - 1 个物品中求背包容量为 j - vi,如果恰好装满背包,此时的最大总价值,即 dp[i-1][j-vi],如果 dp[i-1][j-vi] 存在,dp[i][j] 就等于 dp[i-1][j] 和 dp[i-1][j-vi] + wi 的较大值。如果不存在 dp[i][j] 仍然等于 dp[i-1][j]。
初始化:为了在填写 dp 表时不越界,在原始 dp 表的基础上增加一行和一列,第一行表示没有物品的情况,第一列表示背包容量为 0 的情况,根据题目要求和实际情况初始化。
填表顺序:可以从 dp[1][1] 开始,从左往右,从上往下的顺序填表(下一行从 dp[2][1] 开始填表,以此类推)。也可以从 dp[1][0] 开始,从左往右,从上往下的顺序填表(下一行从 dp[2][0] 开始填表,以此类推),为什么可以这样初始化呢,因为如果判断 j - vi >= 0 为真,从 dp[1][0] 开始填表,此时 j == 0,那么 vi 也为 0,dp[1][0] 需要使用状态 dp[0][0],而不是右上方的某个状态。
利用滚动数组做空间上的优化:

在填 dp 表的第 i 行时,只有 i - 1 行是有用的,第 0 行到第 i - 2 行是没有用的,所以我们可以用两个一维数组交替充当第 i -1 行和第 i 行的角色。
还可以只用一个一维数组:

在更新圆圈要用到三角的位置,易知应该从右往左填表。
cpp
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n = 0;
int max_v = 0;
cin >> n >> max_v;
vector<int> v(n+1);
vector<int> w(n+1);
for(int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<vector<int>> dp1(n+1,vector<int>(max_v+1,0));
vector<vector<int>> dp2(n+1,vector<int>(max_v+1,0));
for(int i = 1; i <= max_v; i++) dp2[0][i] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= max_v; j++)
{
dp1[i][j] = dp1[i-1][j];
dp2[i][j] = dp2[i-1][j];
if(j - v[i] >= 0)
{
dp1[i][j] = max(dp1[i][j],dp1[i-1][j - v[i]] + w[i]);
if(dp2[i-1][j - v[i]] != -1)
dp2[i][j] = max(dp2[i][j],dp2[i-1][j - v[i]] + w[i]);
}
}
}
int ret1 = dp1[n][max_v];
int ret2 = dp2[n][max_v];
cout << ret1 << endl;
if(ret2 == -1) cout << 0;
else cout << ret2;
return 0;
}
利用滚动数组做空间上的优化:
cpp
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n = 0;
int max_v = 0;
cin >> n >> max_v;
vector<int> v(n+1);
vector<int> w(n+1);
for(int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<int> dp(max_v+1,0);
for(int i = 1; i <= n; i++)
{
for(int j = max_v; j >= 0; j--)
{
if(j - v[i] >= 0)
{
dp[j] = max(dp[j],dp[j - v[i]] + w[i]);
}
}
}
cout << dp[max_v] << endl;
dp[0] = 0;
for(int i = 1; i <= max_v; i++) dp[i] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = max_v; j >= 0; j--)
{
if(j - v[i] >= 0 && dp[j - v[i]] != -1)
{
dp[j] = max(dp[j],dp[j - v[i]] + w[i]);
}
}
}
if(dp[max_v] == -1) cout << 0;
else cout << dp[max_v];
return 0;
}
01 背包应用 题目链接

解析:把 nums 数组作为物品的体积,所有物品的体积之和为 sum,有一个背包的的体积为 sum / 2,如果可以恰好把该背包装满,那原数组就可以分成两个子序列,使得两个子序列的元素和相等。易知如果 sum 为奇数时,一定无解。当 sum 为偶数时:dp[i][j] 定义为第一个物品到第 i 个物品,背包容量为 j,是否可以恰好装满背包。推导状态转移方程:如果不选 i 物品,就在第一个物品到第 i - 1 个物品中检查是否可以恰好装满容量为 j 的背包,即 dp[i-1][j]。如果选 i 物品,就在第一个物品到第 i - 1 个物品中检查是否可以恰好装满容量为 j-vi 的背包,即 dp[i-1][j-vi],前提是 j -vi >= 0。综上,状态转移方程是:dp[i][j] = dp[i-1][j] || ((j - nums[i-1] >= 0) && dp[i-1][j - nums[i-1]]);
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for(auto i : nums) sum += i;
if(sum % 2) return false;
int max_v = sum / 2;
vector<vector<bool>> dp(n+1,vector<bool>(max_v+1,false));
dp[0][0] = true;
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= max_v; j++)
{
dp[i][j] = dp[i-1][j] || ((j - nums[i-1] >= 0) && dp[i-1][j - nums[i-1]]);
}
}
return dp[n][max_v];
}
};
完全背包模板
你有一个背包,最多能容纳的体积是V。
现在有n种物品,每种物品有任意多个,第i种物品的体积为 vi ,价值为 wi。
(1)求这个背包至多能装多大价值的物品?
(2)若背包恰好装满,求至多能装多大价值的物品?
解析:第一问:在 01 背包的基础之上,可以定义这样的状态表示:dp[i][j] 表示:从前 i 种物品中选,背包最大容量为 j,此时的最大总价值。推导状态转移方程:01 背包每个物品有选或不选两个选项,完全背包有很多选项了:不选该物品、选 1 个该物品、选 2 个该物品、选 3 个该物品......,在所有选法中取最大值,可以使用一层循环遍历所有合理选法,也可以做优化:

先写出 dp[i][j] 的状态转移方程,假设最多可以选 k 个该种物品,即 j - kv[i] >= 0,j - (k+1)v[i] < 0,再写出 dp[i][j - v[i]] 的状态转移方程(把 dp[i][j] 的状态转移方程的所有 j 替换成 j - v[i]) ,假设最多可以选择 x 个物品,那么 x 一定等于 k,因为 j 和 v[i] 没有变,要使 j - xv[i] 最接近 0,那么 x 就与 k 相同。dp[i][j] 的状态转移方程的划线部分与 dp[i][j - v[i]] 的状态转移方程的划线部分刚好相差 w[i],可以等价替换,即 dp[i][j] = max(dp[i-1][j],dp[i][j-v[i]] + w[i])
第二问:与 01 背包的思路相同。
初始化与填表顺序与 01 背包相同。
利用滚动数组优化:
可以不使用二维 dp 表,而只用一个一维的数组,填表的顺序是从左往右。
cpp
#include <iostream>
#include <string.h>
using namespace std;
const int N = 1010;
int v[N];
int w[N];
int n;
int V;
int dp[N][N];
int main() {
cin >> n >> V;
for(int i = 1; i <= n; i++)
{
cin >> v[i] >> w[i];
}
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= V; j++)
{
dp[i][j] = dp[i-1][j];
if(j - v[i] >= 0) dp[i][j] = max(dp[i][j],dp[i][j-v[i]] + w[i]);
}
}
cout << dp[n][V] << endl;
memset(dp,0,sizeof(dp));
for(int i = 1; i <= V; i++) dp[0][i] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = 0; j <= V; j++)
{
dp[i][j] = dp[i-1][j];
if(j - v[i] >= 0 && dp[i][j-v[i]] != -1)
dp[i][j] = max(dp[i][j],dp[i][j-v[i]] + w[i]);
}
}
if(dp[n][V] == -1) cout << 0;
else cout << dp[n][V];
return 0;
}
完全背包应用:题目链接

解析:定义状态:dp[i][j] 表示:从前 i 种银币中选择,是否可以刚好凑成 j ,如果可以,在所有的选法中,银币最少的个数;如果不能,则为 -1。推导状态转移方程:由完全背包模板,可以推导出状态转移方程:dp[i][j] = min(dp[i][j],dp[i][j-coins[i-1]] + 1);,但要注意:如果 dp[i][j] 为 -1,要特殊判断
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
vector<vector<int>> dp(n+1,vector<int>(amount+1,0));
for(int i = 1; i <= amount; i++) dp[0][i] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= amount; j++)
{
dp[i][j] = dp[i-1][j];
if(j - coins[i-1] >= 0 && dp[i][j - coins[i-1]] != -1)
{
if(dp[i][j] == -1) dp[i][j] = dp[i][j-coins[i-1]] + 1;
else dp[i][j] = min(dp[i][j],dp[i][j-coins[i-1]] + 1);
}
}
}
return dp[n][amount];
}
};
二维费用的背包问题
什么是二维费用的背包问题?在 01 背包中,只有一个限制条件,即背包装的物品体积不能超过背包体积,二维费用的背包问题即有两个限定条件:背包装的物品体积和重量不能超过背包体积和最大载重。

解析:可以仿照 01 背包的思考过程解决,只是 dp 表增加了一个维度。dp[i][j][k] 表示:从前 i 个字符串中选择,0 不超过 j 个,1 不超过 k 个,在所有选法中的最大子集数。
cpp
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int x = strs.size();
vector<vector<vector<int>>> dp(x+1,vector<vector<int>>(m+1,vector<int>(n+1,0)));
for(int i = 1; i <= x; i++)
{
int a = 0;
int b = 0;
int cur = 0;
while(cur < strs[i-1].size())
{
if(strs[i-1][cur] == '0') a++;
else b++;
cur++;
}
for(int j = 0; j <= m; j++)
{
for(int k = 0; k <= n; k++)
{
dp[i][j][k] = dp[i-1][j][k];
if(j >= a && k >= b) dp[i][j][k] = max(dp[i][j][k],dp[i-1][j-a][k-b] + 1);
}
}
}
return dp[x][m][n];
}
};