底层慢慢“爬楼梯”:从递归崩溃到动规巅峰的深度进阶指南

54 阅读1分钟

在算法面试的江湖里, “爬楼梯”(Climbing Stairs) 就像是武侠小说里的《太极长拳》——招式简单,却是内功心法的基础。很多初学者能一眼看出它是斐波那契数列的变种,但面试官真正考察的,是你能否从“暴力拆解”进化到“优雅优化”的思维全过程。

今天,我们就把这道题揉碎了、讲透了,带你从底层逻辑出发,一步步攀登算法的高峰。


一、 问题建模:为什么是树形结构?

首先,我们要理解问题的本质。假设你面前有 nn 阶台阶,你每次只能跨 1 步2 步

1. 逆向思维(自顶向下)

        f(10)
    f(9)      f(8)
 f(8) f(7)  f(7)  f(6)

我们站在最高处(第 nn 阶)回望:

  • 发现是一个树状结构,因此得出使用递归解决问题
    • 如果你最后一步跨的是 1 步,说明你之前已经站在了第 n1n-1 阶;
    • 如果你最后一步跨的是 2 步,说明你之前已经站在了第 n2n-2 阶。

所以,到达第 nn 阶的总方法数 f(n)f(n),必然等于到达 n1n-1 阶的方法数与到达 n2n-2 阶的方法数之和。这就是大名鼎鼎的公式:

f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2)

2. 树形分解

为了求出 f(10)f(10),你需要先知道 f(9)f(9)f(8)f(8);为了知道 f(9)f(9),你又要知道 f(8)f(8)f(7)f(7)。这种层层嵌套的关系,在逻辑上形成了一棵巨大的二叉树


二、 方案一:暴力递归(最纯粹但也最脆弱)

程序员的第一反应往往是:既然公式都出来了,直接写个函数调自己不就行了?

JavaScript

function climbStairs(n) {
    // 【退出条件】递归的灵魂。没有它,程序会掉入深渊。
    // 第1阶只有1种爬法(走1步);第2阶有2种(1+1 或 直接跨2步)
    if(n === 1) return 1;
    if(n === 2) return 2;

    // 【递归调用】自顶向下不断分裂。
    // 看起来很优雅,但背后却隐藏着恐怖的计算量。
    return climbStairs(n-1) + climbStairs(n-2);
}

为什么这个方案会被面试官“嫌弃”?

  • 重复计算: 观察上面的树形图,你会发现 f(8)f(8) 被算了好多次,f(7)f(7) 被算的次数更多。这种重叠子问题会导致计算量呈指数级增长。
  • 调用栈溢出(Stack Overflow): 每一个递归函数都需要在内存中开辟一个栈帧。当 nn 很大时,成千上万个函数还没跑完就堆在一起,内存直接爆表。
  • 时间复杂度: O(2n)O(2^n)。这是一个让任何系统都会瞬间瘫痪的数字。

三、 方案二:记忆化搜索(空间换时间)

既然“重复计算”是罪魁祸首,那我们能不能把算过的结果存起来?这就是空间换时间的策略。

JavaScript

// 使用立即执行函数(IIFE)创建一个闭包,保护 memo 变量
const climbStairs2 = (function() {
    // 【闭包容器】memo 就像一个记事本,用来存储已经算出来的答案
    const memo = {}; 

    return function (n) {
        if(n === 1) return 1;
        if(n === 2) return 2;

        // 【关键点:查表】在向下递归之前,先翻翻记事本。
        // 如果 memo[n] 已经有值了,说明之前算过,直接拿走,效率瞬间起飞!
        if(memo[n]) return memo[n];

        // 【关键点:存表】如果没算过,就算一次,然后赶紧记在 memo 里。
        memo[n] = climbStairs2(n-1) + climbStairs2(n-2);
        
        return memo[n];
    };
})();

在这里使用了闭包加上 立即执行(IIFE) 保护了内部数据memo是一个常用且方便的小技巧

进阶评价

这一版代码将时间复杂度降到了 O(n)O(n)。虽然引入了 O(n)O(n) 的空间复杂度来维护 memo 对象,但对比起性能的提升,这笔买卖稳赚不赔!这种方法在算法中被称为带备忘录的自顶向下法


四、 方案三:自底向上(从递归走向动态规划)

面试官可能会继续追问:“能不能不使用递归,直接从第 1 阶推导到第 nn 阶?”

这就是自底向上的思想。递归是“由大化小”,而动态规划(Dynamic Programming) 则是“由小筑大”。

1. 逻辑重构

我们不再从天而降,而是从地基开始盖楼:

  • f(1)=1f(1) = 1
  • f(2)=2f(2) = 2
  • f(3)=f(1)+f(2)=3f(3) = f(1) + f(2) = 3
  • f(4)=f(2)+f(3)=5f(4) = f(2) + f(3) = 5

2. 极致的代码实现

为了把空间利用到极致,我们甚至不需要一个长长的数组,只需要三个变量来记录当前和前两次的状态。

JavaScript

function climbStairs3(n) {
    // 处理基础边界情况
    if(n === 1) return 1;
    if(n === 2) return 2;

    // 【初始状态】这就是我们的“地基”
    // prevPrev 代表前前一个阶梯的方法数 f(i-2)
    // prev 代表前一个阶梯的方法数 f(i-1)
    let prevPrev = 1; 
    let prev = 2;     
    let current;

    // 【自底向上迭代】从第3阶开始,一步步往上爬到第 n 阶
    for(let i = 3; i <= n; i++) {
        // 【核心方程】当前状态 = 前两个状态之和
        current = prevPrev + prev;

        // 【滚动更新】超级关键点!
        // 这一步是动态规划的精髓:我们要把窗口向右滑动。
        // 原来的 prev 变成了下一次计算中的 prevPrev
        // 原来的 current 变成了下一次计算中的 prev
        prevPrev = prev;
        prev = current;
    }

    return current;
}

五、 技术复盘:深度解析动态规划

通过爬楼梯这道题,我们其实已经完整走过了动态规划的三大支柱:

1. 状态定义

在这道题中,状态就是 f(i),它代表的是“到达第 ii 阶台阶的方法总数”。面试时,第一步一定要清晰地定义状态。

2. 状态转移方程

也就是 f(i)=f(i1)+f(i2)f(i) = f(i-1) + f(i-2)。这是 DP 的核心逻辑,决定了你是如何从子问题的解推导出原问题的解。

3. 边界条件与初始值

f(1)=1f(1)=1f(2)=2f(2)=2 是所有推导的起点。没有地基,再漂亮的方程也无从谈起。


六、 给面试者的特别建议

一定要记住:代码只是载体,思维才是灵魂。

在面试中,如果你能按照这个流程讲解:

  1. 先给最差解:展示你对问题的直观理解(递归)。
  2. 分析痛点:指出重复计算和内存开销(复杂度分析)。
  3. 给出改进方案:引入记忆化搜索(备忘录)。
  4. 升华最优解:通过滚动变量实现 O(1)O(1) 空间复杂度的动态规划(自底向上)。

面试官对你的评价将不仅仅是“会写代码”,而是“具备深厚的底层算法素养和优化思维”。


七、 互动与课后思考

爬楼梯问题其实是动态规划中最简单的一维 DP

思考题:如果题目改成了“你每次可以跨 1步、2步 或 3步”,你的状态转移方程该如何修改?代码中的初始状态需要增加吗?

欢迎在评论区留下你的代码实现,我们一起交流进步!