33-卡牌翻面求和问题,带你理解动态规划| 豆包MarsCode AI 刷题

56 阅读3分钟

问题描述

小M有 nn 张卡牌,每张卡牌的正反面分别写着不同的数字,正面是 aia_i ,背面是 bib_i。小M希望通过选择每张卡牌的一面,使得所有向上的数字之和可以被3整除。你需要告诉小M,一共有多少种不同的方案可以满足这个条件。由于可能的方案数量过大,结果需要对 109+710^9+7 取模。

例如:如果有3张卡牌,正反面数字分别为 (1,2)(2,3) 和 (3,2),你需要找到所有满足这3张卡牌正面或背面朝上的数字之和可以被3整除的组合数。

测试样例

样例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

问题理解

我们需要找到所有可能的卡牌组合,使得这些卡牌正面或背面朝上的数字之和可以被3整除。由于可能的方案数量过大,结果需要对 (109+7)(10^9+7) 取模。

题解

暴力枚举

我们可以将正面或者反面看作两种状态 01 ,即我们可以枚举每一种可能的情况,假设 n=2n=2,可以枚举 00,01,10,11四种情况并计算和是否为3的倍数。

但是,很明显的是,这种算法的时间复杂度非常高,是 O(2n)O(2^n) ,是必然要对其进行优化的。

动态规划

我们仔细思考枚举的过程,当枚举到01的和时,第一个数为0的和在枚举00时已经被计算过了,所以本来只需要计算一次的值,重复计算了很多次。

定义子问题

所以让我们来假设,我已经知道前 n1n-1 个数和为3的倍数的组合数。我现在想要求 nn 个数和为3的倍数的组合数,应该怎么求?

在这之前我们先需要思考一下,什么情况下和才是三的倍数?

对咯,我们需要看之前的和 %3 的余数情况,和当前的值进行计算,才能得到。所以我们需要记录所有余数的组合个数。

显而易见的,我们可以定义 dp[i][j]dp[i][j] 表示前 i 张卡牌中,数字之和 %3 等于 j 的组合数。

求解状态转移方程

状态转移方程是动态规划的核心,它描述了如何从前一个状态推导出当前状态。在这个问题中,状态转移方程如下:

1. 状态定义
  • dp[i][j]:表示前 i 张卡牌中,数字之和模3等于 j 的方案数。
  • i:表示当前考虑的卡牌数量。
  • j:表示当前数字之和模3的余数(0, 1, 2)。
2. 初始状态
  • dp[0][0] = 1:当没有卡牌时,数字之和为0的方案数为1。
  • dp[0][1] = 0 和 dp[0][2] = 0:当没有卡牌时,数字之和模3等于1或2的方案数为0。
3. 状态转移
  • 对于每一张卡牌 i(从1到n),我们需要考虑两种选择:选择卡牌的正面 a[i-1] 或背面 b[i-1]
  • 对于每一种选择,我们需要更新 dp[i][j] 的值。
选择正面 a[i-1]
  • 如果选择第 i 张卡牌的正面 a[i-1],那么前 i-1 张卡牌的数字之和模3应该等于 (j - a[i-1]) % 3
  • 因此,dp[i][j] 应该加上 dp[i-1][(j - a[i-1]) % 3]
选择背面 b[i-1]
  • 如果选择第 i 张卡牌的背面 b[i-1],那么前 i-1 张卡牌的数字之和模3应该等于 (j - b[i-1]) % 3
  • 因此,dp[i][j] 应该加上 dp[i-1][(j - b[i-1]) % 3]
合并两种选择
  • 最终,dp[i][j] 的值是上述两种选择的方案数之和,并对 MOD 取模以防止溢出。
dp[i][j]=dp[i1][(ja[i])mod3]+dp[i1][(jb[i])mod3]dp[i][j] = dp[i-1][(j-a[i])\mod 3] + dp[i-1][(j-b[i])\mod 3]

代码

def solution(n: int, a: list, b: list) -> int:
    MOD = 10**9 + 7
    
    # 初始化dp数组
    dp = [[0] * 3 for _ in range(n + 1)]
    dp[0][0] = 1  # 没有卡牌时,数字之和为0的方案数为1
    
    # 状态转移
    for i in range(1, n + 1):
        for j in range(3):
            dp[i][j] = (dp[i-1][(j - a[i-1]) % 3] + dp[i-1][(j - b[i-1]) % 3]) % MOD
    
    # 返回结果
    return dp[n][0]

if __name__ == '__main__':
    print(solution(n = 3, a = [1, 2, 3], b = [2, 3, 2]) == 3)
    print(solution(n = 4, a = [3, 1, 2, 4], b = [1, 2, 3, 1]) == 6)
    print(solution(n = 5, a = [1, 2, 3, 4, 5], b = [1, 2, 3, 4, 5]) == 32)

这个代码的时间复杂度是 O(n)O(n) , 绝对满足要求。

总结

动态规划(Dynamic Programming,简称 DP)是一种通过将复杂问题分解为更简单的子问题来解决问题的优化方法。它的核心思想是通过记录和复用子问题的解,避免重复计算,从而提高效率。动态规划的解决过程可以概括为以下几个关键步骤:

  1. 问题分解与子问题定义
    首先,需要分析问题的结构,找到能够被分解为多个重复子问题的特性。动态规划通常适用于具有重叠子问题和最优子结构性质的问题。

    • 重叠子问题:子问题会被多次计算,例如斐波那契数列。
    • 最优子结构:问题的最优解可以由其子问题的最优解推导出来。
  2. 状态定义
    在动态规划中,状态是问题在某一特定条件下的表示。例如,背包问题的状态可以定义为“当前考虑前 iii 个物品时,总重量不超过 jjj 的最大价值”。状态的定义需要清晰且覆盖问题的所有可能情况。

  3. 状态转移方程
    状态转移方程是动态规划的核心,描述了如何通过较小的子问题递推得到较大的问题的解。例如,斐波那契数列的状态转移方程是:

    f(n)=f(n−1)+f(n−2)

    状态转移方程需要结合问题的约束条件进行设计,是动态规划解题的逻辑依据。

  4. 初始化
    动态规划通常需要设置初始状态,为递推过程提供起点。例如,斐波那契数列中 f(0)=0f(0) = 0f(0)=0, f(1)=1f(1) = 1f(1)=1。

  5. 递推计算
    按照状态转移方程,从基础状态逐步递推,最终得到问题的解。递推可以通过自底向上(迭代)或自顶向下(记忆化搜索)实现。

  6. 结果获取与路径还原(可选)
    动态规划的最终结果可能是一个具体值,也可能需要还原出最优解对应的路径。例如,在最长公共子序列问题中,结果可以是子序列的长度,也可以进一步还原具体的子序列。

掌握了以上方法后,动态规划的问题看似复杂,但其实逻辑清晰,适用于各种优化问题,例如最短路径问题、背包问题、字符串匹配问题、博弈问题等

动态规划的关键在于明确问题的递归关系并将其显式化,通过状态定义、转移方程、递推计算,实现从简单子问题到复杂问题的解决。只要能正确拆解问题并设计好状态和转移方程,动态规划的思想就能让许多看似困难的问题迎刃而解!