动态规划之多状态 dp 问题
题型概述
什么时候该用多状态解决动态规划问题
有些情况下,用一个 dp 表就足以充分表示每一个位置上代表的含义,也可以保证在每一种情况下,都能用 dp 表求出正确答案。
但有些情况下,一个 dp 表只能表示一种情况下的答案,并不能涵盖所有可能性,这时就需要借助多个 dp 表以确保答案正确。
例题
力扣 面试题 17.16 按摩师
在这道题中,如果按照以往的习惯,dp[i] 表示到 i 位置时,最长的预约时间,那么还有一个信息无法表示----该位置的前一个位置有没有被加到总的预约时间里?
所以,针对这个问题,可以采用两个 dp 表,分别用来表示某一个位置被加入到了总的预约时间,以及没有被加入到总的预约时间时,到该位置时可以达到的最长预约时间。假设两个 dp 表分别为 f 和 g。
解题步骤:
创建 dp 表以及确定 dp 表中所要填写位置的含义:
首先,根据写题经验,先确定出这道题应该使用的解题思路是 “以某一个位置为结尾进行分析”。
f[i] 表示,到 i 位置时,并且将该位置加入到总的预约时间中,这时可以达到的最长预约时间。
g[i] 表示,到 i 位置时,并且不将该位置加入到总的预约时间中,这时可以达到的最长预约时间。
确定状态转移方程:
对于 f[i],由于该位置要被加入到总的预约时间,所以需要保证前一个位置一定不能加入总的时间中,所以 f[i] = g[i - 1] + nums[i]。
对于 g[i],由于该位置没有加入到总的预约时间,所以前一个位置可以加入到总的预约时间,也可以不加入,所以取它们的最大值即可,即 g[i] = max(g[i - 1], f[i - 1])。
初始化:
只需要将 f[0] = nums[0] 即可,表示第一个位置被加入到总的预约时间的情况。
确定填表顺序:
根据状态转移方程可以看出应该从从左到右依次填入数据。
确定返回值:
该题要求返回针对整个数组,可以达到的最长的总的预约时间,最长预约时间可能包含最后一个位置,也可能不包含,所以应该返回 f 和 g 表中最后一个位置的最大值。
代码:
class Solution
{
public:int massage(vector<int>& nums) {// 创建 dp 表int n = nums.size();if (n == 0) return 0;if (n == 1) return nums[0];if (n == 2) return max(nums[0], nums[1]);vector<int> f(n), g(n);// 初始化f[0] = nums[0];// 填表for (int i = 1; i < n; i++){f[i] = g[i - 1] + nums[i];g[i] = max(g[i - 1], f[i - 1]);}return max(f[n - 1], g[n - 1]);}
};
练习题
力扣 213. 打家劫舍(二)
力扣 213. 打家劫舍(二)
这道题只需要针对第一个和最后一个位置是否被占用,分成两种情况考虑即可。
如果第一个房子被占用,则最后一个房子不能被占用。
所以,可以针对第一个房子到倒数第二个房子,以及第二个房子到最后一个房子,分别做一次类似于上一道题的操作即可。
最后应该返回两种情况得到的最大值,具体原理不再赘述。
代码:
class Solution
{
public:int Rob(vector<int>& nums, int left, int right){if (left > right) return 0;int n = nums.size();vector<int> f(n), g(n);f[left] = nums[left];for (int i = left + 1; i <= right; i++){f[i] = g[i - 1] + nums[i];g[i] = max(g[i - 1], f[i - 1]);}return max(f[right], g[right]);}int rob(vector<int>& nums) {int n = nums.size();if (n == 0) return 0;if (n == 1) return nums[0];return max(Rob(nums, 0, n - 2), Rob(nums, 1, n - 1));}
};
力扣 740. 删除并获得点数
力扣 740. 删除并获得点数
这道题求的是满足条件的最大数字和, 其实跟打家劫舍问题很像。
只是需要把这道题中的数组转化为一个更接近打家劫舍的数组以供操作。
所以考虑创建一个数组arr, arr[i] 表示 nums[i] 中所有出现的数字 i 的总和。
所以该问题就转化为 "选了一个数之后, 不能再选择与它相邻的数", 标准的打家劫舍问题 。
class Solution
{
public:int deleteAndEarn(vector<int>& nums) {vector<int> arr(10001);for (int num : nums) arr[num] += num;vector<int> f(10001), g(10001);for (int i = 1; i <= 10000; i++){f[i] = g[i - 1] + arr[i];g[i] = max(g[i - 1], f[i - 1]);}return max(f[10000], g[10000]);}
};
力扣 91. 粉刷房子
力扣 91. 粉刷房子
这道题可以创建 3 个 dp 表,也可以直接创建一个二维的 dp 表,在其中包含每个房子分别粉刷成 3 种颜色的情况。
我这里为了不用自己初始化第一个房子,直接用 costs 数组创建一个 dp 表。
dp 表中的每一行有 3 个数据,分别对应 3 种颜色,针对每一种颜色,只需要在 dp 表原有的基础上, 再加上上一行中另外两列的最小值即可(dp 表中每一个位置已经有每一种颜色本身的花费了,因为是由 costs 初始化而来的,如果这个位置选了某一个颜色,则上一个位置一定不是这个颜色,所以在另外两列找最小值)
class Solution
{
public:int minCost(vector<vector<int>>& costs) {int n = costs.size();vector<vector<int>> dp(costs);for (int i = 1; i < n; i++){dp[i][0] += min(dp[i - 1][1], dp[i - 1][2]);dp[i][1] += min(dp[i - 1][0], dp[i - 1][2]);dp[i][2] += min(dp[i - 1][0], dp[i - 1][1]);}return min(dp[n - 1][0], min(dp[n - 1][1], dp[n - 1][2]));}
};
力扣 309. 买卖股票的最佳时期含冷冻期
力扣 309. 买卖股票的最佳时期含冷冻期
这道题针对每一天可以细分为三种状态:
买入(在当天买入股票) 可交易(当天没有股票, 可以买入) 冷冻期(当天没有股票, 也不能买入)。
创建一个 dp 表, dp[i][0] dp[i][1] dp[i][2] 分别表示第 i 天结束之后, 上述三种状态下的最大利润。
针对这三种状态分别分析, 确定每一种状态能不能以及怎么做才能转化为另外两种状态。
买入: 前一天买入股票, 这一天什么都不干, 可以继续处于买入状态。
前一天买入股票, 这一天卖出股票, 可以转化为冷冻期。
前一天买入股票, 今天不能转化为可交易状态, 因为需要提前卖出股票, 还要经过冷冻期。
可交易状态: 前一天没有股票, 今天买入, 就可以转化为买入状态。
前一天没有股票, 今天什么都不干, 依然是可交易状态。
前一天没有股票, 无法到达冷冻期, 因为需要经过买入和卖出。
冷冻期: 前一天冷冻期, 今天不可能还是冷冻期。
前一天冷冻期, 今天什么都不干, 就转化为了可交易状态。
前一天冷冻期, 今天不能买入股票, 无法转化为买入状态。
class Solution
{
public:int maxProfit(vector<int>& prices) {int n = prices.size();vector<vector<int>> dp(n, vector<int>(3));dp[0][0] = -prices[0];for (int i = 1; i <n; i++){dp[i][0] = max(dp[i - 1][1] - prices[i], dp[i - 1][0]);dp[i][1] = max(dp[i - 1][1], dp[i - 1][2]);dp[i][2] = dp[i - 1][0] + prices[i];}return max(dp[n - 1][1], dp[n - 1][2]);}
};
力扣 714. 买卖股票的最佳时期含手续费
力扣 714. 买卖股票的最佳时期含手续费
没有冷冻期, 每天要么是买入(手里有股票), 要么是卖出(手里没有股票)。
并且这两种状态都可以相互转换, 也都可以保持该状态不变。
代码:
class Solution
{
public:int maxProfit(vector<int>& prices, int fee) {int n = prices.size();vector<int> f(n), g(n);f[0] = -prices[0];for (int i = 1; i < n; i++){f[i] = max(f[i - 1], g[i - 1] - prices[i]);g[i] = max(g[i - 1], f[i - 1] + prices[i] - fee);}return g[n - 1];}
};
力扣 123. 买卖股票的最佳时机(三)
力扣 123. 买卖股票的最佳时机(三)
这道题不同之处在于, 它限制了交易的次数。
所以, 除了要记录每一天是买入还是卖出状态之外, 还要记录当前是第几次交易。
f[i][j] 表示第 i 天处于买入状态下, 且已经完成了 j 笔交易时, 最大获利。
g[i][j] 表示第 i 天处于卖出状态下, 且已经完成了 j 笔交易时, 最大获利。
代码:
class Solution
{const int INF = -0x3f3f3f3f;
public:int maxProfit(vector<int>& prices) {int n = prices.size();vector<vector<int>> f(n, vector<int>(3, INF)), 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 >= 0) g[i][j] = max(g[i][j], f[i - 1][j - 1] + prices[i]);}}return *max_element(g[n - 1].begin(), g[n - 1].end());}
};
力扣 188. 买卖股票的最佳时机(四)
力扣 188. 买卖股票的最佳时机(四)
这道题跟上面的一模一样,只是需要把第二层循环中的 j 控制为 <= k 即可。原理不再赘述。
代码:
class Solution
{const int INF = -0x3f3f3f3f;
public:int maxProfit(int k, vector<int>& prices) {int n = prices.size();vector<vector<int>> f(n, vector<int>(k + 1, INF)), g(n, vector<int>(k + 1, INF));f[0][0] = -prices[0], g[0][0] = 0;for (int i = 1; i < n; i++){for (int j = 0; j <= k; 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 >= 0) g[i][j] = max(g[i][j], f[i - 1][j - 1] + prices[i]);}}return *max_element(g[n - 1].begin(), g[n - 1].end());}
};
总结
多状态 dp 问题要注意以下几点:
-
创建 dp 表的个数取决于,需要多少个 dp 表才能完全表示出所需要的每一种状态。
-
对 dp 表的初始化要确保不会影响后面填表的正确性。
-
对于解题思路中的每一种状态,要分析它经过某种操作是否能到达其他状态或保持不变,这是推导状态转移方程的关键所在。
-
最后的返回值一定要再次分析题目,确定返回的是符合题目要求的答案。