问题描述
小M有 n 张卡牌,每张卡牌的正反面分别写着不同的数字,正面是 ai,背面是 bi。小M希望通过选择每张卡牌的一面,使得所有向上的数字之和可以被3整除。你需要告诉小M,一共有多少种不同的方案可以满足这个条件。由于可能的方案数量过大,结果需要对 10^9 + 7 取模。
测试样例
- 样例1:
- 输入:n = 3, a = [1, 2, 3], b = [2, 3, 2]
- 输出:3
- 样例2:
- 输入:n = 4, a = [3, 1, 2, 4], b = [1, 2, 3, 1]
- 输出:6
- 样例3:
- 输入:n = 5, a = [1, 2, 3, 4, 5], b = [1, 2, 3, 4, 5]
- 输出:32
方法思路
动态规划 (DP)
- 状态定义:
-
dp[i][j] 表示前 i 张卡牌中,当前和为 j 的方案数。
-
j 的取值范围是 [0, 2],因为我们需要计算和对3取模的结果。
-
状态转移:
-
对于第 i 张卡牌,有两种选择:使用正面 a[i-1] 或反面 b[i-1]。
-
dp[i][j] = (dp[i-1][(j - a[i-1] % 3 + 3) % 3] + dp[i-1][(j - b[i-1] % 3 + 3) % 3]) % MOD。
-
初始化:
-
dp[0][0] = 1,表示没有卡牌时,和为0的方案数为1。
-
最终结果:
-
dp[n][0],表示前 n 张卡牌中,和为0(即能被3整除)的方案数。
递归加记忆化搜索
- 递归:
- 使用递归来遍历每张卡牌,并决定是使用正面还是反面的数字。
- 记忆化搜索:
- 为了避免重复计算,使用一个 Map 来存储已经计算过的状态(即当前处理到的卡牌索引和当前的数字和)。
- 状态表示:
- 用一个字符串 key 来表示当前的状态,key 由当前卡牌的索引和当前的数字和组成。
- 递归终止条件:
- 当处理完所有卡牌时(即 index == n),检查当前的数字和是否是3的倍数。如果是,则返回1,否则返回0。
- 状态转移:
- 对于每张卡牌,有两种选择:使用正面的数字或反面的数字。递归地计算这两种选择的结果,并将结果相加。
代码实现
动态规划 (DP)
public class Main {
private static final int MOD = 1000000007;
private static int solution(int n, int[] a, int[] b) {
int[][] dp = new int[n + 1][3];
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
int front = a[i - 1] % 3;
int back = b[i - 1] % 3;
for (int j = 0; j < 3; j++) {
dp[i][j] = (dp[i - 1][(j - front + 3) % 3] + dp[i - 1][(j - back + 3) % 3]) % MOD;
}
}
return dp[n][0];
}
public static void main(String[] args) {
System.out.println(solution(3, new int[]{1, 2, 3}, new int[]{2, 3, 2}) == 3);
System.out.println(solution(4, new int[]{3, 1, 2, 4}, new int[]{1, 2, 3, 1}) == 6);
System.out.println(solution(5, new int[]{1, 2, 3, 4, 5}, new int[]{1, 2, 3, 4, 5}) == 32);
}
}
递归加记忆化搜索
import java.util.HashMap;
import java.util.Map;
public class Main {
private static final int MOD = 1000000007;
public static void main(String[] args) {
System.out.println(solution(3, new int[]{1, 2, 3}, new int[]{2, 3, 2}) == 3);
System.out.println(solution(4, new int[]{3, 1, 2, 4}, new int[]{1, 2, 3, 1}) == 6);
System.out.println(solution(5, new int[]{1, 2, 3, 4, 5}, new int[]{1, 2, 3, 4, 5}) == 32);
}
private static int solution(int n, int[] a, int[] b) {
Map<String, Integer> memo = new HashMap<>();
return helper(n, 0, 0, a, b, memo);
}
private static int helper(int n, int index, int currentSum, int[] a, int[] b, Map<String, Integer> memo) {
if (index == n) {
return currentSum % 3 == 0 ? 1 : 0;
}
String key = index + "," + currentSum;
if (memo.containsKey(key)) {
return memo.get(key);
}
int front = a[index] % 3;
int back = b[index] % 3;
int result = (helper(n, index + 1, (currentSum + front) % 3, a, b, memo) +
helper(n, index + 1, (currentSum + back) % 3, a, b, memo)) % MOD;
memo.put(key, result);
return result;
}
}
方法对比
时间复杂度
- 动态规划 (DP):O(n * 3) = O(n)。因为我们需要遍历 n 个元素,并且每个元素有 3 种状态。
- 递归加记忆化搜索:O(n * 3) = O(n)。helper 函数每次调用两个子 helper 函数,看起来像是一个二叉树的结构,可能会让人误以为时间复杂度是O(2 ^ n) ,但实际上,由于我们使用了记忆化搜索,许多子问题会被重复计算并存储在哈希表中,从而避免了重复计算。实际上每个子问题只会被计算一次,递归树的深度为 n,每个节点有 3 种状态。
空间复杂度
- 动态规划 (DP):O(n * 3) = O(n)。因为我们使用了一个大小为 (n + 1) * 3 的二维数组 dp。
- 递归加记忆化搜索:O(n * 3) = O(n)。因为我们需要存储每个子问题的结果,最坏情况下会有 n * 3 个不同的子问题。
实现难易
- 动态规划 (DP):中等。需要正确初始化 dp 数组,并且在循环中正确更新状态。
- 递归加记忆化搜索:较低。需要正确处理递归调用和记忆化存储。
理解难易
- 动态规划 (DP):中等。需要理解状态转移的过程和如何通过当前状态推导出下一个状态。
- 递归加记忆化搜索:较高。需要理解递归的思想和记忆化的机制。
综合评价
| 维度 | 动态规划 (DP) | 递归加记忆化搜索 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(n) | O(n) |
| 实现难易 | 中等 | 较低 |
| 理解难易 | 中等 | 较高 |
总结
动态规划:
- 优点:代码结构清晰,动态规划递推逻辑直观。
- 缺点:实现相对复杂,需要手动管理状态数组,可能会有一些额外的代码开销。
递归加记忆化搜索:
- 优点:实现相对简单,递归的写法并不复杂。
- 缺点:需要处理递归调用和记忆化存储,理解上可能有一定难度。