本篇分享动态规划题解,题目来自AI刷题题库。
在正文开始之前,我们首先了解一下什么是二维状态机dp:
二维状态机动态规划(Dynamic Programming, DP)是动态规划的一种应用,通常用于求解涉及两个维度状态的优化问题。常见的场景包括网格问题、区间问题、子序列问题等,主要特点是使用二维数组来记录状态,通过状态转移方程来逐步推导出最优解。
在二维状态机 DP 中,通常有两个维度的状态,而每个状态由两个(或多个)索引值确定。我们需要设计一个 DP 数组来保存中间结果,避免重复计算,并最终通过递推关系得到问题的最优解。
一般思路
-
定义状态:
- 需要明确问题的“状态”是什么。对于二维 DP,状态通常由两个变量来表示。例如,
dp[i][j]可能表示在某种条件下,第i行、第j列的最优解。
- 需要明确问题的“状态”是什么。对于二维 DP,状态通常由两个变量来表示。例如,
-
确定状态转移方程:
- 根据问题的特点,找出如何通过前一个状态(或者几个状态)来计算当前状态。状态转移方程是整个 DP 解法的核心,它决定了如何将子问题的解合并起来得到大问题的解。
-
初始化:
- 对于边界情况,需要初始化 DP 数组的起始状态,通常是数组的边缘或某个特殊位置。
-
边界条件:
- 需要根据问题的需求设定适当的边界条件。比如在某些问题中,可能需要将
dp[0][0]或dp[i][0]设定为特定的值。
- 需要根据问题的需求设定适当的边界条件。比如在某些问题中,可能需要将
-
返回结果:
- 根据最终的 DP 数组,得到问题的答案,通常是
dp[n][m]或dp[i][j]。
- 根据最终的 DP 数组,得到问题的答案,通常是
典型例子:最短路径问题
假设我们有一个二维网格,要求从左上角到右下角的最短路径,且每个格子有一个权重(可能为正数,也可能为负数)。我们可以使用二维 DP 来求解这个问题。
问题描述
- 给定一个
m x n的网格,要求从左上角出发,走到右下角,每次只能向下或向右走,路径的总和是路径上所有格子的权重之和。 - 目标是找到最短路径的权重和。
状态定义
定义 dp[i][j] 为从 (0, 0) 到 (i, j) 的最短路径的权重和。
状态转移方程
- 每个格子的路径和是其左边格子和上边格子的路径和的最小值加上当前格子的权重。
- 因此,
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j],其中grid[i][j]是当前格子的权重。
初始化
dp[0][0] = grid[0][0],即起始点的路径和是它本身的权重。- 对于第一行和第一列,只能从左边或从上边走来,因此需要初始化第一行和第一列。
边界条件
- 第一行:
dp[0][j] = dp[0][j-1] + grid[0][j],只能从左边过来。 - 第一列:
dp[i][0] = dp[i-1][0] + grid[i][0],只能从上边过来。
最终结果
dp[m-1][n-1]即为从左上角到右下角的最短路径的权重和。
另一个例子:背包问题
背包问题的二维 DP 版本通常涉及两个维度的状态:物品的索引和背包的容量。
问题描述
- 给定一系列物品,每个物品有重量和价值,给定一个背包的容量,求最大可以装入背包的总价值。
状态定义
定义 dp[i][j] 为前 i 个物品中,总重量不超过 j 的情况下,可以获得的最大价值。
状态转移方程
- 对于第
i个物品,可以选择不放入背包,或者选择放入背包。若放入背包,则剩余容量为j - weight[i],并且总价值为dp[i-1][j - weight[i]] + value[i]。 - 因此,状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
初始化
dp[0][j] = 0:表示如果没有物品,背包中的最大价值为 0。dp[i][0] = 0:表示如果背包容量为 0,最大价值也是 0。
最终结果
dp[n][W]即为最大总价值,其中n是物品的数量,W是背包的最大容量。
二维状态机 DP 的应用领域
二维状态机 DP 常用于以下几种类型的算法问题:
-
网格问题:
- 如最短路径问题、最小路径和、最大路径和等。
-
子序列问题:
- 如最长公共子序列、编辑距离等。这类问题通常涉及两个字符串的比较,可以通过 DP 的二维表格来实现。
-
区间问题:
- 如区间合并问题、区间覆盖问题等,可以通过二维 DP 来表示区间之间的状态和转移。
-
背包问题的扩展:
- 多维背包问题,例如多重背包问题、0-1背包问题的多种变种。
-
动态规划的其他变种:
- 比如矩阵链乘法、最大矩形面积等。
总结
二维状态机 DP 是动态规划的一种常见应用,通过定义一个二维数组来表示状态,利用状态转移方程逐步计算最优解。其关键在于:
- 明确问题中的“状态”;
- 设计合适的状态转移方程;
- 根据边界条件初始化,并通过递推获得最终答案。
这种方法通常能有效解决复杂的优化问题,避免了暴力算法中的重复计算。
回到题目本身:
根据题目大意,每个位置可选择a牌或者b牌,选择方案一共是 种情况,像这种选择问题有很明显的dp意味,因为大量的情况是可合并的。
考虑维护二维数组dp[i][j],表示前i张卡牌,形成的总和为j的方案数。
但是j会很大,实际上开出来空间为 ,空间复杂度不允许。
再观察到题目只需要求被三整除,所以只需要维护总和对3取模即可,也就是说:
dp[i][j]表示前i张卡牌中,总和模3为j的方案数。
同时空间复杂度降到了3n
现在考虑如何转移:
如果是原来的 前 i 张卡牌,形成的总和为 j 的方案数 若当前值为 x ,则应该从 sum-x 转移。但是由于我们存储的是总和模3,所以应该是模数 j ,从 做转移。同时这个值可能是负数,将其转移到0~3之间即可。
dp[i][j] += dp[i-1][((j-a[i])%3+3)%3];
dp[i][j] += dp[i-1][((j-b[i])%3+3)%3];
其中 ((x%3)+3)%3 可以将任意值转移到0~3之间。
其次是初始化,如果我们从n=1转移过来,从上面两行代码来看,只需要处理0处的值。0处有哪些情况呢:选择前0张卡牌就是没选,总和一定是0,且只有一种情况,但是总和没有其他情况,所以:
dp[0][0] = 1;
再把所有部分进行拼接即可,但是注意一般情况下在执行转移过程中一定要及时取模,毕竟还是 种情况,随随便便就会爆int,开long long也只能支持n不大于64的情况。不过给定的题目貌似不取模也能过,但是还是建议养成习惯。
以下是完整代码:
#include <iostream>
#include <vector>
using namespace std;
int solution(int n, std::vector<int> a, std::vector<int> b) {
vector<vector<int>>dp(n+5,vector<int>(3));
dp[0][0]=1;
for(int i=1;i<=n;i++){
int A = a[i-1];
int B = b[i-1];
for(int j=0;j<3;j++){
dp[i][j]+=dp[i-1][((j-A)%3+3)%3];
dp[i][j]+=dp[i-1][((j-B)%3+3)%3];
}
}
return dp[n][0];
}
int main() {
std::cout << (solution(3, {1, 2, 3}, {2, 3, 2}) == 3) << std::endl;
std::cout << (solution(4, {3, 1, 2, 4}, {1, 2, 3, 1}) == 6) << std::endl;
std::cout << (solution(5, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5}) == 32) << std::endl;
return 0;
}