青训营X豆包MarsCode 技术训练营第五篇——拨号器(题解与总结) | 豆包MarsCode AI 刷题

95 阅读9分钟

学习方法与心得:动态规划在算法中的应用(拨号器)

在学习算法时,动态规划(DP)是一个非常重要的工具,特别是面对状态转移问题时。在这道“骑士跳跃电话号码”问题中,我们利用动态规划来处理多种可能的跳跃路径,并通过逐步积累结果来求解最终的答案。这不仅是对算法思想的深入理解,也是对动态规划技巧的实际应用。

题目描述

小F正在使用一个骑士跳跃方式的拨号器。这个拨号器是一个类似电话键盘的 3x4 矩阵,每个数字对应一个单元格,骑士只能站在蓝色数字单元格上进行跳跃(数字 1 到 9 和 0)。骑士的移动方式和国际象棋中的马相同:它可以垂直移动两个单元格并水平移动一个单元格,或水平移动两个单元格并垂直移动一个单元格,形成 "L" 形。

123

456

789

*0#

给定一个整数 n,你需要帮助小F计算骑士可以拨出的所有长度为 n 的不同电话号码的数量。骑士可以从任何数字开始,并在 n-1 次有效跳跃后得到一个有效号码。答案可能非常大,因此你需要返回对 10^9 + 7 取模的结果。


测试样例

样例1:

输入:n = 1 输出:10

样例2:

输入:n = 2 输出:20

样例3:

输入:n = 3 输出:46

样例4:

输入:n = 4 输出:104

题目分析与思路

本题的核心是模拟骑士(马)在数字键盘上的跳跃路径,每个数字都可以通过骑士跳跃规则跳到其他数字。我们需要通过动态规划来记录长度为 ( n ) 的所有可能电话号码,最终求出结果。

  1. 跳跃规则映射:首先需要明确骑士从每个数字跳到哪些数字。我们可以利用字典或映射数组来表示这些跳跃规则。

  2. 动态规划表设计:我们使用一个二维数组 dp[i][j] 来表示长度为 ( i+1 ) 的电话号码,以数字 j 结尾的数量。动态规划的状态转移非常直观,每次根据当前数字的跳跃规则更新下一步的电话号码数量。

  3. 最终结果:通过遍历所有可能的终点,得到长度为 ( n ) 的电话号码数量。

完整代码如下

def solution(n: int) -> int:
    MOD = 10**9 + 7
    
    # 定义骑士可以跳跃的规则
    jumps = {
        1: [6, 8],
        2: [7, 9],
        3: [4, 8],
        4: [3, 9, 0],
        5: [],  # 5 不能跳到任何其他数字
        6: [1, 7, 0],
        7: [2, 6],
        8: [1, 3],
        9: [2, 4],
        0: [4, 6]
    }
    
    # 初始化 dp 数组
    dp = [[0] * 10 for _ in range(n)]
    
    # 初始状态:第 0 次跳跃时,每个数字都可以作为起点
    for i in range(10):
        dp[0][i] = 1
    
    # 状态转移
    for i in range(n - 1):
        for j in range(10):
            if dp[i][j] > 0:
                for next_digit in jumps[j]:
                    dp[i + 1][next_digit] = (dp[i + 1][next_digit] + dp[i][j]) % MOD
    
    # 计算最终结果
    result = sum(dp[n - 1]) % MOD
    return result

if __name__ == '__main__':
    print(solution(1) == 10)
    print(solution(2) == 20)
    print(solution(3) == 46)
    print(solution(4) == 104)

代码详解

1. 跳跃规则映射

jumps = {
    1: [6, 8],
    2: [7, 9],
    3: [4, 8],
    4: [3, 9, 0],
    5: [],  # 5 不能跳到任何其他数字
    6: [1, 7, 0],
    7: [2, 6],
    8: [1, 3],
    9: [2, 4],
    0: [4, 6]
}

这个字典定义了每个数字的跳跃目标。例如,数字 1 可以跳跃到 68,而数字 5 没有有效的跳跃路径。

2. 动态规划数组

dp = [[0] * 10 for _ in range(n)]

我们初始化一个二维数组 dpdp[i][j] 表示长度为 ( i+1 ) 的电话号码以数字 j 结尾的数量。最初,所有的值为 0。

3. 初始化状态

for i in range(10):
    dp[0][i] = 1

长度为 1 的电话号码,每个数字都可以作为独立的电话号码。因此,我们初始化第一行,所有数字的数量为 1。

4. 状态转移

for i in range(n - 1):
    for j in range(10):
        if dp[i][j] > 0:
            for next_digit in jumps[j]:
                dp[i + 1][next_digit] = (dp[i + 1][next_digit] + dp[i][j]) % MOD

这里的状态转移是核心。对于每个长度为 ( i+1 ) 的电话号码,以数字 j 结尾,我们通过跳跃规则将结果转移到长度为 ( i+2 ) 的电话号码中。每次转移时,我们都考虑当前 dp[i][j] 的数量,并加到 dp[i + 1][next_digit] 中。

5. 最终结果

result = sum(dp[n - 1]) % MOD

最终,我们通过累加 dp[n-1] 中所有数字的值,得到长度为 ( n ) 的电话号码数量。

  • 动态规划的关键是明确如何定义状态和状态转移关系,并通过递推逐步求解问题。
  • 本题展示了如何结合数字跳跃规则来处理状态转移,通过动态规划高效地计算电话号码的数量。

关于本题的时间复杂度与优化

  • 时间复杂度为 ( O(30n) ),因为每个 dp[i][j] 的状态转移最多涉及到 3 个跳跃目标(数字 j 的跳跃集合的最大长度)。因此,总体时间复杂度为 ( O(30n) ),可以认为是 ( O(n) ) 级别,非常高效。

  • 空间复杂度是 ( O(10n) ),即 ( O(n) ),这对于大多数问题规模来说是非常可接受的。

知识总结与学习建议

在这道题目中,几个关键点和技巧是:

1. 动态规划(Dynamic Programming, DP)

  • 定义:动态规划是一种通过将问题分解为子问题来求解的算法设计方法,适用于具有重叠子问题和最优子结构性质的问题。
  • 核心思想:通过保存子问题的解(通常是存储在数组或表格中),避免重复计算,从而提高效率。

在此题中的应用

  • 使用 dp[i][j] 表示长度为 ( i+1 ) 的电话号码以数字 j 结尾的数量。
  • 基于跳跃规则,状态从前一个数字的状态转移到当前数字的状态。
  • 通过逐步积累结果,最终得到所求的电话号码数量。

2. 状态转移与递推关系

  • 状态定义:我们定义 dp[i][j] 为长度为 ( i+1 ) 的电话号码以数字 j 结尾的数量。
  • 转移方程:通过当前数字的跳跃规则更新到下一状态。例如,从数字 j 跳跃到其所有可能的目标数字,将 dp[i][j] 的数量加到 dp[i+1][next_digit] 中。
  • 初始化:初始化 dp[0][i] = 1,表示长度为 1 的电话号码可以直接由任何数字开始。

关键点

  • 边界条件:初始化时,长度为 1 的电话号码可以是任意数字(0-9),因此 dp[0][i] = 1
  • 递推关系:通过跳跃规则,将上一状态的结果累加到当前状态。

3. 跳跃规则

  • 本题中,跳跃规则是固定的,每个数字可以跳跃到其他特定数字。比如:
    • 数字 1 可以跳跃到数字 6 和 8,数字 2 可以跳跃到数字 7 和 9,等等。
  • 这要求我们在动态规划转移时,考虑每个数字的跳跃目标,逐步扩展电话号码的可能性。

4. 时间和空间复杂度

  • 时间复杂度:由于每个 dp[i][j] 的状态转移最多涉及到 3 个跳跃目标(每个数字的跳跃目标数量最多为 3),因此总体时间复杂度是 ( O(30n) ),可以认为是 ( O(n) ),适合处理较大规模的问题。
  • 空间复杂度:使用一个二维数组 dp 存储所有长度为 ( n ) 的电话号码的中间结果。空间复杂度为 ( O(10n) ),即 ( O(n) ),其中 10 是数字的个数。

5. 模运算(Modulo Operation)

  • 目的:由于电话号码数量可能非常大,需要对结果进行模运算,避免溢出。
  • 操作:在每次更新 dp[i + 1][next_digit] 时,使用模运算:dp[i + 1][next_digit] = (dp[i + 1][next_digit] + dp[i][j]) % MOD,确保计算结果保持在一个合理的范围内。

6. 动态规划的优化

  • 空间优化:本题的状态转移只依赖于前一行,因此可以用两个一维数组代替二维数组,节省空间。
  • 技巧:如果题目允许,可以尝试将原本的二维数组 dp 进一步优化为滚动数组(rolling array)结构,减少空间复杂度。

高效学习方法

在刷题时,可以结合以下方法提高学习效率:

  1. 逐步练习:先从简单的 DP 问题入手,逐渐增加题目的难度。理解每个题目的核心思想,尤其是如何设计状态转移方程和递推公式。
  2. 总结归纳:每次解决一个 DP 问题后,尝试总结出适用的模板或常见的状态转移模式,帮助自己在类似题目中快速应用。
  3. 错题重练:对于做错的题目,花时间理解错因,并通过回顾题目分析加深理解。可以利用错题本或在线平台的错题集进行针对性复习。

工具运用

结合豆包MarsCode AI 刷题的功能,可以帮助我们:

  1. 生成多样化的题目:通过AI生成不同难度的动态规划题目,并逐步提高难度,帮助我们在掌握基础后更好地挑战复杂问题。
  2. 错误反馈与优化:AI能够自动检测我们的错误并给出反馈,帮助我们发现漏洞或不合理的设计,从而加速学习进程。
  3. 与其他资源结合:通过与在线教程、博客、书籍等资源结合,进一步拓展对动态规划的理解,不仅限于题目本身。

这道题目是通过动态规划解决路径问题的经典例子,它能够帮助我们提高解题思路和技巧,特别是对于复杂的状态转移问题。在学习过程中,我们可以针对此类题进行重点研究。