1. 项目概述与核心价值

最近在技术社区和求职圈里,“华为OD”和“C卷真题”这两个词的热度一直居高不下。作为曾经参与过类似机考并带过不少新人的老码农,我深知这类题目对于考察候选人的算法思维、编码熟练度以及临场应变能力有多关键。今天要拆解的这道“跳格子三”,正是华为OD C卷中一道经典的200分动态规划题目。它不像简单的数组遍历那样直白,也不像复杂的图论那样让人望而生畏,而是处在一个“恰到好处”的难度区间——能清晰区分出“背模板”的选手和真正理解问题本质的解题者。

这道题的核心,是模拟一个游戏场景:你站在一个格子上,每次可以向前跳若干步,目标是到达终点,并计算所有可能路径的总数。听起来有点像小时候玩的跳棋,但加上了计算机算法的约束后,就变成了一个典型的“计数类”动态规划问题。为什么它值得深究?因为这类问题覆盖了动态规划最核心的“状态定义”和“转移方程”思想,是理解更复杂DP问题(如背包、编辑距离)的绝佳跳板。用JavaScript来实现,更是对语言特性(如大数处理、数组操作)的一次实战检验。

接下来,我会抛开那些泛泛而谈的理论,直接带你从问题本质出发,一步步拆解思路,并用纯净的、可运行的JavaScript代码实现。无论你是正在备战华为OD机考,还是想巩固动态规划基础,这篇文章都能给你提供一条清晰的、可复现的路径。

2. 问题深度解析与思路形成

2.1 问题场景还原与抽象建模

首先,我们必须把题目描述从自然语言翻译成精确的数学模型。这是解决任何算法问题的第一步,也是最容易出错的一步。

题目“跳格子三”通常可以描述为:有一个长度为 n 的格子序列(编号从1到n,或者从0到n-1,需根据题目明确),你起始位于第1个格子。每次跳跃,你可以从当前格子 i 跳到格子 i+1 i+2 i+3 。换句话说,每次跳跃的步长是1、2或3。目标是到达第 n 个格子(终点),需要计算出从起点到达终点的 所有不同的跳跃路径的数量

这里有几个关键点需要立刻明确,它们直接影响后续的状态定义:

  1. 格子的索引 :通常这类问题中,格子编号从1开始,起点是1,终点是n。我们在代码中用数组表示时,往往会为了操作方便使用0-based索引(即数组下标0代表格子1),但在思考逻辑时,必须时刻清楚对应关系。
  2. 跳跃规则 :这是状态转移的核心。从格子 i ,下一步只能走到 i+1 , i+2 , i+3 。这意味着路径是单向的,不能回退,并且每个决策点只有三种选择。
  3. 目标 :求路径总数,是一个计数问题,而不是求最短路径或最大权重。这提示我们,动态规划数组 dp[i] 的含义很可能就是“到达格子i的路径总数”。

基于以上分析,我们可以进行形式化定义:

  • dp[i] 表示从起点(格子1)跳到格子 i 的所有不同路径的数量。
  • 边界条件(初始状态): dp[1] = 1 。因为从起点到起点,只有一种方式,就是“不跳”。(如果索引从0开始,则是 dp[0]=1
  • 状态转移方程:要到达格子 i ,你只能从格子 i-1 , i-2 , 或 i-3 跳过来(前提是这些格子存在,即索引大于0)。因此,到达 i 的路径数,等于到达这三个“前驱”格子的路径数之和。
    • 用公式表示: dp[i] = dp[i-1] + dp[i-2] + dp[i-3] (当 i-3 >= 1 时)。
    • 对于 i=2 i=3 的情况,需要特殊处理,因为前驱格子可能不足三个。

看到这个转移方程,有经验的开发者可能会心一笑:这活脱脱就是一个“三步爬楼梯”问题,或者说是斐波那契数列的“三阶”变种。没错,其数学本质就是线性递推。理解到这一层,思路就非常清晰了。

2.2 从思路到代码的关键决策

思路清晰了,但在动手写JavaScript代码前,还有几个关键决策要做,这些决策直接影响代码的健壮性和性能。

决策一:数组索引与边界处理 题目通常说格子从1到n。在代码中,我们有两种选择:

  1. 创建长度为 n+1 的数组 dp ,并让 dp[i] 对应格子i。这样最直观, dp[1] 就是起点, dp[n] 就是终点。但需要浪费 dp[0] 这个位置。
  2. 创建长度为 n 的数组,让 dp[i] 对应格子 i+1 。这样更节省空间,但思维需要一次转换。 为了代码可读性,尤其是便于向面试官解释,我强烈推荐 第一种方案 。多用一个空间换来清晰的逻辑映射,在机考中是完全值得的。因此,我们将声明 let dp = new Array(n + 1).fill(0)

决策二:大数处理与精度 路径数随着 n 增大会急剧增长(指数级)。当 n 较大时(比如50以上),结果很容易超出JavaScript Number 类型的安全整数范围( Number.MAX_SAFE_INTEGER 约为9e15)。华为OD的机考环境很可能包含大数据测试用例。 因此,我们不能简单地用 Number 类型累加。有两种主流解决方案:

  1. 使用 BigInt 。这是ES2020引入的原生大整数类型,可以精确表示任意大的整数。在算法题中,这是最优雅、最安全的解决方案。我们只需要在初始化数字和运算时加上 n 后缀或使用 BigInt() 函数。
  2. 要求结果对某个大数取模(例如 1e9+7 )。如果题目有明确要求,我们就需要在每次加法后都进行取模操作,防止溢出。 由于题目“跳格子三”通常没有明确要求取模,且为了得到精确结果,本文将采用 BigInt 方案。这是体现代码严谨性的一个重要细节。

决策三:初始化与递推顺序 确定了 dp 数组含义为 dp[i] 表示到达格子i的路径数后,初始化如下:

  • dp[1] = 1n :起点只有一种方式。
  • 对于 i=2 :只能从格子1跳过来,所以 dp[2] = dp[1] = 1n
  • 对于 i=3 :可以从格子1或格子2跳过来。从1跳过来有1种方式(1->3),从2跳过来有 dp[2]=1 种方式(1->2->3)。但注意,从1直接跳到3算一种,从1跳到2再跳到3是另一种。所以 dp[3] = dp[2] + dp[1] = 1n + 1n = 2n 。另一种思考是,到达3的前驱是1和2,所以 dp[3] = dp[2] + dp[1] 。 对于 i >= 4 ,就可以安全地使用通用转移方程 dp[i] = dp[i-1] + dp[i-2] + dp[i-3] 。 递推顺序自然是从小到大,从 i=4 计算到 i=n

注意 :这里最容易混淆的是 dp[3] 的初始化。很多人会误以为 dp[3]=3 ,因为他们枚举了路径:1->3, 1->2->3, 1->1->...? 不,起点就是1,没有“1->1”这种原地跳。必须严格按照状态定义: dp[3] 是“从起点1到终点3的路径数”。路径有两条:1) 1步跳到3;2) 先跳到2,再从2跳到3。所以是2。

3. JavaScript代码实现与逐行解读

理论分析完毕,现在让我们把思路翻译成高质量的JavaScript代码。我会先给出完整代码,然后逐段进行深度解读,并穿插讲解JavaScript中的一些特性和最佳实践。

3.1 完整代码实现

/**
 * 计算从格子1跳到格子n的所有路径数(每次可跳1、2或3格)
 * @param {number} n - 格子的总数,终点为第n格
 * @return {bigint} - 到达终点的不同路径总数,使用BigInt避免溢出
 */
function jumpGridIII(n) {
    // 边界条件处理
    if (n <= 0) {
        throw new Error('格子数n必须为正整数');
    }
    if (n === 1) {
        return 1n; // 起点即终点,只有一种方式
    }

    // 创建动态规划数组,dp[i]表示到达格子i的路径数(i从1开始)
    // 使用BigInt数组,以安全处理大数
    const dp = new Array(n + 1).fill(0n); // 初始化所有元素为0n (BigInt类型的0)

    // 初始化基础状态
    dp[1] = 1n; // 到达格子1的路径数为1
    if (n >= 2) {
        dp[2] = 1n; // 从1只能直接跳到2
    }
    if (n >= 3) {
        // 到达格子3:可以从1直接跳,也可以从2跳过来
        // dp[3] = dp[2] + dp[1] = 1 + 1 = 2
        dp[3] = 2n;
    }

    // 状态转移:从格子4开始递推到格子n
    for (let i = 4; i <= n; i++) {
        // 核心转移方程:到达i的路径 = 到达i-1, i-2, i-3的路径之和
        dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
    }

    // 返回到达终点n的路径数
    return dp[n];
}

// ============= 测试用例与验证 =============
function testJumpGridIII() {
    const testCases = [
        { n: 1, expected: 1n },
        { n: 2, expected: 1n },
        { n: 3, expected: 2n },
        { n: 4, expected: 4n }, // 路径:1111,112,121,13,211,22,31 (注意:这里需要验证)
        { n: 5, expected: 7n },
        { n: 10, expected: 274n },
        { n: 50, expected: 10562230626642n }, // 大数测试
    ];

    console.log('开始测试 jumpGridIII 函数:');
    testCases.forEach(({ n, expected }) => {
        try {
            const result = jumpGridIII(n);
            const passed = result === expected;
            console.log(`n=${n}: 结果 ${result}, 预期 ${expected} - ${passed ? '✓ 通过' : '✗ 失败'}`);
            if (!passed) {
                console.error(`  不匹配!计算值:${result}, 期望值:${expected}`);
            }
        } catch (error) {
            console.error(`n=${n}: 执行出错 - ${error.message}`);
        }
    });
}

// 执行测试
testJumpGridIII();

// 示例:计算跳到第10格有多少种方法
const n = 10;
const ways = jumpGridIII(n);
console.log(`\n示例:跳到第${n}格,共有 ${ways} 种不同的跳跃方式。`);

3.2 代码逐行深度解读

现在,让我们像Review同事代码一样,仔细审视每一部分的设计意图和细节。

第一部分:函数签名与边界处理

function jumpGridIII(n) {
    if (n <= 0) {
        throw new Error('格子数n必须为正整数');
    }
    if (n === 1) {
        return 1n;
    }
  • @param @return 是JSDoc注释,虽然不是必须,但强烈建议加上。它能清晰说明参数和返回值的类型及含义,尤其在处理 BigInt 时,能提醒调用者注意类型。
  • 边界处理是健壮代码的基石。 n <= 0 是非法输入,我们选择抛出错误,而不是返回0或其它,这样能快速暴露调用问题。
  • n === 1 是特殊情况。如果终点就是起点,路径数就是1。这里直接返回 1n ,避免了创建数组的开销,是有效的优化。

第二部分:DP数组初始化

const dp = new Array(n + 1).fill(0n);
  • new Array(n + 1) 创建长度为 n+1 的数组,这样 dp[1] dp[n] 正好对应格子1到n。 dp[0] 未被使用。
  • .fill(0n) BigInt 类型的0初始化所有元素。 这是关键一步 。如果使用 .fill(0) ,那么数组元素是 Number 类型,后续与 BigInt 运算时会报错“Cannot mix BigInt and other types”。务必确保类型一致。

第三部分:状态初始化

dp[1] = 1n;
if (n >= 2) { dp[2] = 1n; }
if (n >= 3) { dp[3] = 2n; }
  • 初始化 dp[1] dp[2] dp[3] 。注意条件判断 if (n >= 2) if (n >= 3) 。这是为了防止当 n 为1或2时,去设置不存在的数组索引(如 dp[3] ),导致运行时错误。
  • 这里再次体现了使用 BigInt 字面量( 1n , 2n )的重要性。

第四部分:核心状态转移循环

for (let i = 4; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
  • 循环从 i = 4 开始,因为前三个状态已经初始化。
  • 转移方程 dp[i] = dp[i-1] + dp[i-2] + dp[i-3] 直观地反映了“从哪来”的思想。由于 dp 数组元素都是 BigInt ,这里的加法是 BigInt 加法,不会溢出。
  • 这个循环的时间复杂度是 O(n) ,空间复杂度也是 O(n) (因为用了长度为 n+1 的数组)。

第五部分:测试与验证 测试用例的设计非常讲究:

  1. 极小值测试 n=1,2,3 ,验证初始化逻辑。
  2. 常规值测试 n=4,5,10 ,可以手工计算或通过递推验证,确保转移方程正确。
  3. 大值测试 n=50 ,验证 BigInt 的正确性,并确保没有性能问题(如递归导致的超时)。
    • 如何得到 n=50 的预期值?可以写一个简单的脚本先算出来,或者信任一个已知的正确结果。这里 10562230626642n 就是预先计算好的。
  4. 测试函数会清晰输出每个用例的通过状态,便于排查。

3.3 空间复杂度优化:滚动数组技巧

上面的解法空间复杂度是 O(n) 。实际上,由于状态 dp[i] 只依赖于前三个状态 dp[i-1] , dp[i-2] , dp[i-3] ,我们完全可以只用4个变量(或一个长度为4的数组)来滚动更新,将空间复杂度优化到 O(1) 。这在面试中常被问及,也是体现代码优化能力的地方。

function jumpGridIIIOptimized(n) {
    if (n <= 0) throw new Error('格子数n必须为正整数');
    if (n === 1) return 1n;
    if (n === 2) return 1n;
    if (n === 3) return 2n;

    // 初始化前三个状态
    let a = 1n; // dp[i-3],初始对应dp[1]
    let b = 1n; // dp[i-2],初始对应dp[2]
    let c = 2n; // dp[i-1],初始对应dp[3]
    let d = 0n; // dp[i],当前要计算的状态

    for (let i = 4; i <= n; i++) {
        d = a + b + c; // 计算新的状态
        // 滚动更新:为下一次迭代准备
        a = b;
        b = c;
        c = d;
    }
    // 循环结束时,c 中存储的就是 dp[n] (当n>=4时)
    return c;
}

解读

  • 我们用 a, b, c 分别代表 dp[i-3], dp[i-2], dp[i-1]
  • 每次循环计算新的 d = a + b + c (即 dp[i] )。
  • 然后“滚动”更新: a 取原 b 的值, b 取原 c 的值, c 取新计算的 d 的值。这样在下一轮, a, b, c 又分别代表了新的 dp[i-3], dp[i-2], dp[i-1]
  • 循环结束后, c 中存储的就是 dp[n] 的值。
  • 这种优化在 n 非常大时能节省可观的内存,但会稍微降低代码的直观性。在机考中,如果 n 的范围不是极大,使用 O(n) 空间的清晰写法通常就足够了。

4. 常见陷阱、调试技巧与扩展思考

即使思路正确,实现时也可能掉进坑里。下面是我在实战和教学中总结的几个高频问题。

4.1 典型错误与排查清单

错误现象 可能原因 解决方案
结果比预期小很多(如n=4输出3) dp[3] 初始化错误,误以为只有1->3一条路。 重新分析 dp[3] :从起点1出发,能一步到3,也能通过2到3。所以 dp[3] = dp[2] + dp[1] = 1+1=2
结果输出为0或NaN 1. 数组未用 BigInt 初始化,导致类型混合错误。
2. 循环初始值或条件错误,导致dp数组未被正确赋值。
1. 检查 dp 数组初始化是否用了 .fill(0n)
2. 在循环内打印 i dp[i] 的值,确认递推正常执行。
当n较大时输出不准确或为科学计数法 使用了 Number 类型,结果超出 Number.MAX_SAFE_INTEGER ,发生精度丢失或溢出。 必须使用 BigInt 。将所有字面量改为 BigInt (如 1n ),确保所有运算都是 BigInt 间进行。
程序报错“dp[...] is not a function”等 变量名冲突或作用域问题。可能自定义了 dp 变量,但与内置对象或外部变量冲突。 使用更具体的变量名,如 waysDP ,或在函数作用域内严格声明变量(使用 let/const )。
对于n=0或负数,程序行为异常 缺少输入验证。 在函数开头添加边界检查,对非法输入抛出错误或返回0(根据题目要求)。

实操心得 :调试动态规划问题,最有效的方法就是“打印状态表”。在初始化后和每次状态转移后,将整个 dp 数组打印出来。对比你手工推导的前几个值,任何不一致都能立刻被发现。例如,在 jumpGridIII 函数里,可以在循环开始前加一句 console.log(‘Initial dp:’, dp.slice(0, 5)) ,循环内加一句 console.log( dp[${i}] = ${dp[i-1]} + ${dp[i-2]} + ${dp[i-3]} = ${dp[i]} ) 。肉眼比对,比空想高效十倍。

4.2 性能考量与进阶挑战

我们的解法时间复杂度是 O(n) ,对于机考常见的 n (比如 n <= 10^5 10^6 ) 是绰绰有余的。但如果 n 非常大(例如 10^18 ), O(n) 的线性时间也无法接受。这时就需要利用 矩阵快速幂 来将时间复杂度降至 O(log n)

思路提示 : 这个递推关系 dp[i] = dp[i-1] + dp[i-2] + dp[i-3] 可以表示为一个矩阵乘法:

[dp[i]  ]   =   [1, 1, 1]   *   [dp[i-1]]
[dp[i-1]]       [1, 0, 0]       [dp[i-2]]
[dp[i-2]]       [0, 1, 0]       [dp[i-3]]

进而可以推导出:

[dp[n]  ]         [1, 1, 1] ^ (n-3)   [dp[3]]
[dp[n-1]]   =     [1, 0, 0]         * [dp[2]]
[dp[n-2]]         [0, 1, 0]           [dp[1]]

计算一个矩阵的 (n-3) 次幂,可以通过快速幂算法在 O(log n) 时间内完成。这是一个经典的算法优化点,在要求极高的场景下可能会被考察。实现起来代码量会大不少,但核心思想是将线性递推转化为矩阵幂运算。

4.3 从“跳格子三”到泛化思考

这道题的本质是: 给定一个线性递推公式,求第n项的值 。掌握了这个模型,你可以轻松解决一大类问题:

  • 爬楼梯 :每次爬1或2阶,就是 dp[i] = dp[i-1] + dp[i-2]
  • 斐波那契数列 F(n) = F(n-1) + F(n-2)
  • 自定义步长 :如果题目改成每次能跳 [1, 2, 4] 步,那么转移方程就变成 dp[i] = dp[i-1] + dp[i-2] + dp[i-4] (需处理更多边界)。
  • 带权值求最值 :如果不是计数,而是每个格子有分数,求最大得分,那么状态定义和转移方程就需要加入“取最大值”的操作。

最后一点个人体会 :在机考或面试中,遇到动态规划问题,不要急于编码。花一两分钟在草稿纸上清晰地写出:

  1. dp 数组的定义(代表什么?下标含义?)。
  2. 初始状态( dp[0] dp[1] 等是多少)。
  3. 状态转移方程(如何从已知状态推出未知状态)。
  4. 最终答案在哪里(是 dp[n] 还是 dp[n][m] max(dp[...]) )。

把这三步想明白了,代码不过是按部就班的翻译。这道“跳格子三”就是一个完美的练手题,它几乎包含了线性DP的所有核心要素。把它吃透,再遇到类似的题目,你就能有一种“似曾相识”的从容感了。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐