#动态规划入门:从“爬楼梯”问题理解核心思想

引言

在算法学习的过程中,"动态规划"(Dynamic Programming,简称 DP)往往是一道令初学者既兴奋又困惑的关卡。它不像排序、查找那样有固定的模板,而更像是一种解决问题的"思想"或"策略"。动态规划擅长处理那些可以分解为重叠子问题的问题,通过记录子问题的解来避免重复计算,从而高效地得到最终答案。

LeetCode 上的第 70 题"爬楼梯"是动态规划领域最经典、最简单的入门题目之一。它看似只是一个计数问题,但背后蕴含了 DP 的核心要素:状态定义、状态转移方程、边界条件和最优子结构。本文将首先介绍动态规划的基本概念,然后以爬楼梯问题为例进行深度剖析,最后给出 JavaScript 语言的实现代码。

一、什么是动态规划?

动态规划是一种通过把原问题分解为相对简单的子问题的方式来解决复杂问题的方法。它通常用于求解最优化问题 (如最短路径、最大价值等)或计数问题 (如爬楼梯的不同方法数)。其核心思想可以概括为四个字: "记忆化搜索" ------ 如果我们发现一个大问题可以拆分成若干个小问题,而且这些小问题会被反复用到,那么我们就把小问题的答案保存下来,下次直接取用,避免重复计算。

1.1 动态规划的两个关键特征

  • 最优子结构(Optimal Substructure) :如果一个问题的解可以通过其子问题的最优解组合得到,那么该问题就具有最优子结构。对于爬楼梯问题,爬到第 n 阶的方法数可以通过爬到第 n-1 阶和第 n-2 阶的方法数推导出来,这正是最优子结构的体现。
  • 重叠子问题(Overlapping Subproblems) :在递归求解的过程中,相同的子问题会被多次计算。例如,计算 f(5) 时需要 f(4) 和 f(3),而计算 f(4) 时又需要 f(3) 和 f(2),这里的 f(3) 就被重复计算了。动态规划正是通过记录已计算的子问题结果来消除这种重复。

1.2 动态规划的常见实现方式

动态规划通常有两种实现方式:

  1. 自顶向下(Top-Down) :即带备忘录的递归。我们仍然按照递归的思路去分解问题,但在每次计算子问题之前,先检查是否已经计算过,如果计算过就直接返回保存的结果。这种方法比较符合人的直觉思维,但递归深度较大时可能引起栈溢出。
  2. 自底向上(Bottom-Up) :从最小的子问题开始,逐步递推求出更大问题的解。通常使用循环迭代,空间复杂度可以进一步优化。爬楼梯问题的常见解法就是自底向上。

1.3 动态规划的一般步骤

解决一个动态规划问题,通常可以遵循以下四个步骤:

  • 步骤一:定义状态
    状态就是子问题的解。例如在爬楼梯问题中,可以定义 dp[i] 为"爬到第 i 阶楼梯的不同方法数"。状态的选择要能够准确描述问题的当前情况,并且能够通过状态转移推导出最终答案。
  • 步骤二:找出状态转移方程
    状态转移方程描述了如何从较小的子问题的解推导出较大子问题的解。这是 DP 的核心。例如对于爬楼梯,有 dp[i] = dp[i-1] + dp[i-2],因为最后一步要么从 i-1 阶跨 1 步上来,要么从 i-2 阶跨 2 步上来。
  • 步骤三:确定边界条件
    最小的子问题(初始状态)的值必须直接给出,不能通过转移方程计算。例如 dp[1] = 1dp[2] = 2(或者 dp[0]=1 的另一种定义方式)。
  • 步骤四:确定计算顺序
    通常是按照子问题规模从小到大的顺序计算,保证每个状态在计算时所依赖的状态已经计算完毕。

二、爬楼梯问题详细分析

2.1 问题重述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。问有多少种不同的方法可以爬到楼顶?

2.2 手动模拟找规律

我们先来看几个小规模的情况:

  • n = 1 时:只有一种方法,即 [1]。
  • n = 2 时:有两种方法,[1,1] 和 [2]。
  • n = 3 时:有三种方法,[1,1,1]、[1,2]、[2,1]。
  • n = 4 时:我们试着推算一下。要爬到第 4 阶,最后一步可能是从第 3 阶跨 1 步,或者从第 2 阶跨 2 步。到达第 3 阶有 3 种方法,到达第 2 阶有 2 种方法,所以总共 3+2=5 种。具体为:[1,1,1,1]、[1,1,2]、[1,2,1]、[2,1,1]、[2,2]。

得到数列:1, 2, 3, 5, ... 这正是著名的斐波那契数列(从第 1 项开始:1,2,3,5,8...,注意通常斐波那契是 1,1,2,3,5...,这里稍有偏移)。

2.3 状态定义与转移方程

我们定义 dp[i] 为"爬到第 i 阶楼梯的方法总数"。那么:

  • 要到达第 i 阶,最后一步只能有两种情况:

    1. 从第 i-1 阶爬 1 个台阶上来;
    2. 从第 i-2 阶爬 2 个台阶上来。
  • 并且,这两种情况覆盖了所有可能,而且没有重叠(因为最后一步的台阶数不同)。因此:

    text

    css 复制代码
    dp[i] = dp[i-1] + dp[i-2]

这个方程成立的前提是 i >= 3。对于 i=1i=2,我们需要直接给出边界值:

  • dp[1] = 1
  • dp[2] = 2

2.4 最优子结构与重叠子问题的体现

  • 最优子结构 :问题的最优解(方法总数)包含了子问题的最优解。因为 dp[i] 完全由 dp[i-1]dp[i-2] 构成,而这两个子问题本身也是"爬到对应阶数的最多方法数"。
  • 重叠子问题 :在计算 dp[5] 时,需要 dp[4]dp[3];计算 dp[4] 时又需要 dp[3]dp[2]dp[3] 被多次用到。动态规划通过从小到大计算并存储每个 dp[i] 来避免重复。

2.5 空间优化

观察状态转移方程,dp[i] 只依赖 dp[i-1]dp[i-2],因此我们并不需要用一个数组存储所有状态,只需要维护两个变量即可。这样空间复杂度从 O(n) 降为 O(1)。

2.6 时间复杂度

无论是用数组还是只用两个变量,都需要从 3 循环到 n,因此时间复杂度为 O(n)。对于题目给定的 n ≤ 45,这种解法非常快。

三、JavaScript 代码实现

下面将您原先的 C++ 代码转换成等价的 JavaScript 代码。原 C++ 代码逻辑正确:使用两个变量 ab 分别表示 dp[i-2]dp[i-1],然后循环更新。注意边界情况 n=1 或 n=2 直接返回 n。

javascript

ini 复制代码
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    if (n <= 2) {
        return n;
    }
    let a = 1; // 相当于 dp[i-2],初始为 dp[1]
    let b = 2; // 相当于 dp[i-1],初始为 dp[2]
    let ans = 0;
    for (let i = 3; i <= n; i++) {
        ans = a + b;
        a = b;
        b = ans;
    }
    return ans;
};

测试一下:

javascript

arduino 复制代码
console.log(climbStairs(2)); // 输出 2
console.log(climbStairs(3)); // 输出 3
console.log(climbStairs(4)); // 输出 5
console.log(climbStairs(5)); // 输出 8

可以看到输出完全符合预期。这个版本的代码简洁高效,空间复杂度 O(1),时间复杂度 O(n)。

补充一种更易读的数组写法(适合教学)

如果为了更清晰地展示 DP 的状态转移过程,也可以使用数组,虽然浪费了一点空间,但逻辑更直观:

javascript

ini 复制代码
var climbStairs = function(n) {
    if (n <= 2) return n;
    const dp = new Array(n + 1);
    dp[1] = 1;
    dp[2] = 2;
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
};

这两种方法在 LeetCode 上都能通过所有测试用例。

四、从爬楼梯看动态规划的普适思想

爬楼梯问题虽小,但它折射出了动态规划的通用模式。许多看似复杂的问题,例如"最大子序和"、"打家劫舍"、"不同路径"等,都可以通过类似的状态定义和状态转移来求解。初学者往往容易陷入递归的直觉(比如直接写 return climbStairs(n-1) + climbStairs(n-2)),但由于没有保存中间结果,指数级的重复计算会导致超时。而动态规划正是通过"空间换时间"的思路,将指数级复杂度降为线性。

此外,爬楼梯问题还有一个容易混淆的点:有些同学会问,为什么要定义 dp[1]=1, dp[2]=2 而不是 dp[0]=1, dp[1]=1?实际上两种定义都是正确的,只要转移方程和边界条件自洽。例如,如果定义 dp[0] = 1(表示爬到第 0 阶有一种方法,即什么都不做),那么 dp[1] = dp[0] + dp[-1]?这不合理,所以常见的另一种定义是 dp[0]=1, dp[1]=1,然后转移方程 dp[i] = dp[i-1] + dp[i-2] 对 i>=2 生效,此时 dp[2] = dp[1] + dp[0] = 1+1=2,结果仍然正确。这种定义在数学上更优雅,因为此时 dp[i] 直接对应斐波那契数列的标准形式。不过对于初学者,直接使用 dp[1]=1, dp[2]=2 更符合直观的"第 1 阶、第 2 阶"思考方式。

五、总结

本文从动态规划的基本概念入手,详细讲解了最优子结构、重叠子问题、状态定义与转移方程等核心思想,并以 LeetCode 70 题"爬楼梯"为实战案例,一步步分析了解题思路,最后给出了 JavaScript 语言的两种实现方式。希望读者能够通过这道简单而经典的题目,建立起对动态规划的初步认识,并能够举一反三,尝试解决更多相关的 DP 问题。

动态规划的学习需要大量的练习和思考,但每一道题目中"定义状态"和"写出转移方程"这两个步骤一旦突破,剩下的实现就水到渠成了。记住:不要试图去递归地思考整个过程,而是相信子问题已经解决了,你只需要关注最后一步。这才是动态规划的精髓所在。

现在,你可以打开 LeetCode,尝试自己用 JavaScript 写下 climbStairs 函数,并体会一下从暴力递归到带备忘录的递归,再到自底向上迭代的优化过程。祝你学习愉快!

相关推荐
Yunzenn10 小时前
深度解析字节最新研究-Cola DLM 第 06 章:分块因果 DiT 先验 —— 在隐空间里做 Flow Matching
人工智能·算法·架构
HjhIron10 小时前
数组去重:从零开始,写一个靠谱的工具函数
算法·面试
黎阳之光10 小时前
技术赋能智慧新能源|黎阳之光风电叶片光栅载荷+声纹AI智能监测技术落地应用
大数据·人工智能·物联网·算法·安全
逻辑君10 小时前
物理生物学研究报告【20260016】
人工智能·算法·物理
Brilliantwxx10 小时前
【C++】 手撕 AVLTree :从零实现自平衡二叉搜索树
开发语言·c++·笔记·算法
机器学习之心10 小时前
顶刊《KBS》算法应用,PIMO-Transformer-LSTM-ABKDE:投影迭代优化算法概率区间预测,报告+代码
算法·lstm·transformer·投影迭代优化算法
XWalnut10 小时前
LeetCode刷题 day20
java·算法·leetcode
努力努力再努力wz10 小时前
【Redis入门系列】:从 hashtable到 listpack:深入理解 Hash 底层编码、字段级过期、核心命令与缓存应用
开发语言·数据结构·数据库·c++·redis·算法·缓存
Zxc_10 小时前
模拟退火算法:从固体退火到Rastrigin与TSP,手写一个完整的退火求解器
算法