多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

爬楼梯与动态规划:从递归到滚动数组的完整优化路径

爬楼梯与动态规划:从递归到滚动数组的完整优化路径 1. 为什么一道“简单题”能稳坐Hot 100前70名爬楼梯LeetCode 70在Hot 100榜单里几乎是必刷的存在。题目描述极其朴素每次可以爬1级或2级台阶问爬到第n级有多少种不同的方法。很多第一次刷到的人会觉得这题太简单了小学奥数都讲过斐波那契。但它在面试里出现的频率一直居高不下因为这道题考察的不是你背没背过递推公式而是你对递归、记忆化搜索、动态规划、空间优化、数学推导这一整套算法思维链条的掌握程度。先说题目本身。假设你站在地面上要上到第n级台阶。你每一步只能跨1级或者2级。问总共有多少种不同的走法。这个问题为什么是斐波那契逻辑其实很简单要到达第n级台阶你最后一步只可能是从第n-1级跨1级上来或者从第n-2级跨2级上来。那么到达第n级的方法数 到达第n-1级的方法数 到达第n-2级的方法数。边界条件n1时只有1种走法跨1级n2时有2种走法11或直接跨2级。所以 f(n) f(n-1) f(n-2)f(1)1f(2)2。这就是斐波那契数列的原型只是初值从1,1变成了1,2。Hot 100把它放在比较靠前的位置我推测是因为它既是动态规划入门的最佳标本又隐藏着多种优化路径。从最原始的递归到带备忘录的递归再到自底向上的动态规划再到滚动数组压缩空间再到通项公式和矩阵快速幂这道题可以循序渐进地串起至少五层解法。而且每一层解法都对应着面试中可能被追问的问题——你还能优化吗时间复杂度是多少空间复杂度呢这些追问在真实面试里几乎一定会出现。跟很多“背模板”的题目不同爬楼梯的每一种解法都有清晰的推导路径不存在“这个状态定义我看不懂”这类问题。它适合作为动态规划的第一个完整案例来研究。2. 从递归到动态规划一条完整的算法进阶链路这一节我想把爬楼梯的五种典型解法全部过一遍。重点不是让你记住代码而是理解每一步的动机为什么一开始要写递归递归为什么慢备忘录解决了什么动态规划又改进了什么2.1 纯递归最直观但最糟糕的写法如果完全按照递推公式翻译写出来的代码非常简洁def climbStairs(n: int) - int: if n 2: return n return climbStairs(n - 1) climbStairs(n - 2)这段代码能跑但n一大就彻底卡死。n45时在普通机器上可能要跑几十秒甚至更久。原因在于它把同一个子问题重复计算了无数次。举个例子计算f(5)需要计算f(4)和f(3)计算f(4)又需要计算f(3)和f(2)。也就是说f(3)被重复计算了两次f(2)被重复计算了更多次。如果画一棵递归树这棵树上有大量重叠的子树而每一棵子树都被完全重算。f(5)这棵递归树的节点数大约是15个f(10)递归树的节点数大约是177个f(20)大约是21891个。这个增长速度是指数级的准确说是 O(2^n)。在LeetCode上提交纯递归版本n稍微大一点就会超时这几乎是必然的。注意n45已经是斐波那契的一个临界点了再往上数值会超过32位整数范围。在LeetCode题目默认约束里n最大就到45也是为了避免在纯算法层面引入大数处理的问题。但现实中很多题目会要求对10^97取模那n就可以给到很大。2.2 备忘录递归用空间消灭重复计算既然重复计算是罪魁祸首那最简单的优化思路就是把已经算过的结果存起来下次直接用。这就是备忘录递归Memoization。def climbStairs(n: int) - int: memo {} def dfs(x: int) - int: if x 2: return x if x in memo: return memo[x] memo[x] dfs(x - 1) dfs(x - 2) return memo[x] return dfs(n)也可以用数组当备忘录因为n是连续的整数用列表比用字典更节省开销def climbStairs(n: int) - int: memo [0] * (n 1) memo[1], memo[2] 1, 2 def dfs(x: int) - int: if memo[x] ! 0: return memo[x] memo[x] dfs(x - 1) dfs(x - 2) return memo[x] return dfs(n)这里有一个容易踩的小坑memo数组初始化长度为n1因为我们需要访问下标n。memo[0]在这个题目里用不到但数组占位时给它留一个位置是标准做法。如果你把数组长度写成n那么当n恰好等于1或2时memo[n]就会越界。备忘录递归把时间复杂度从O(2^n)降到了O(n)。因为每个子问题只计算一次计算f(n)最多需要计算f(1)到f(n)共n个子问题。空间复杂度也是O(n)用来存memo数组。这里有必要解释一个关键点递归是“自顶向下”的。我们从f(n)出发不断向下分解到f(1)、f(2)然后再把结果一层层返回上来。备忘录只是保证了过程中不重复计算但递归调用栈本身仍然存在最深的调用深度是n。2.3 自底向上DP从基态出发一步一个脚印备忘录递归虽然已经不超时了但递归函数调用本身有开销而且在极端情况下还有爆栈风险n10000时递归深度就可能出问题不过这道题n只有45。更工程化的写法是自底向上的动态规划。def climbStairs(n: int) - int: if n 2: return n dp [0] * (n 1) dp[1] 1 dp[2] 2 for i in range(3, n 1): dp[i] dp[i - 1] dp[i - 2] return dp[n]理解自底向上DP的核心在于状态定义和转移方程状态定义dp[i]表示爬到第i级台阶有多少种方法。转移方程dp[i] dp[i-1] dp[i-2]。初始化dp[1]1dp[2]2。为什么需要这两行的初始化因为递推公式在i1和i2时没有前驱项。dp[1]只有“跨1级”这一条路径dp[2]有“11”和“直接跨2级”两条路径这些是人工推出来的基态也是一切递推的起点。从实现角度讲这个版本的时间复杂度仍是O(n)但省掉了递归的函数调用开销而且是迭代执行不会爆栈。空间复杂度还是O(n)因为dp数组把所有中间结果都存了。2.4 复杂度对比一次看清每层优化解法时间复杂度空间复杂度关键点纯递归O(2^n)O(n)递归栈重叠子问题大量重复计算备忘录递归O(n)O(n)用缓存消除重复仍保留递归栈自底向上DPO(n)O(n)迭代计算无递归栈开销滚动数组O(n)O(1)只保留前两个状态通项公式O(log n)或O(1)按浮点算O(1)数学降维但有精度问题矩阵快速幂O(log n)O(1)泛化能力强适合大n场景在实际面试中从纯递归开始说起然后一路优化到滚动数组这是一条非常标准的“展示你懂动态规划”的路径。面试官想看的不是你直接背出最优解而是你有没有能力一层层发现问题、提出改进。3. 滚动数组的奇妙之处其实根本不需要记住所有台阶从自底向上DP的代码可以看到当我们在计算dp[i]时真正用到的只有dp[i-1]和dp[i-2]。dp[0]、dp[3]、dp[4]这些比i-2更早的值在后续计算中永远不会再被访问。这就引出了一个显而易见的空间优化思路用两个变量滚动维护前两个状态即可没必要开一个长度为n1的数组。def climbStairs(n: int) - int: if n 2: return n prev2 1 # dp[1] prev1 2 # dp[2] for i in range(3, n 1): cur prev1 prev2 prev2 prev1 prev1 cur return prev1这个版本的执行过程是这样的初始prev21表示第1级的方法数prev12表示第2级的方法数。计算第3级cur 2 1 3。更新prev22prev13。计算第4级cur 3 2 5。更新prev23prev15。以此类推计算完第n级后prev1就是答案。代码细节上要注意变量的更新顺序必须先让prev2接收旧的prev1再让prev1接收cur。如果顺序反过来prev2会被写成cur那下一步计算时prev2就不再是dp[i-2]了。这个细节是滚动数组最容易写错的地方我就见过很多人面试时当场翻车。在Python里其实还有一种更优雅的写法def climbStairs(n: int) - int: a, b 1, 2 for _ in range(2, n): a, b b, a b return b if n 1 else a这段代码利用了Python的元组赋值特性右边的b和ab都是在赋值前先取值所以不会出现上面说的更新顺序问题。但如果你用的是Java或C就得老老实实按变量交换的顺序写。为什么滚动数组在这里成立深层原因是这个DP的状态转移只依赖前两个状态它是一个“阶数固定为2”的递推。如果把问题改成“每次可以爬1到k阶”那计算dp[i]就需要依赖前k个状态滚动数组就得开一个长度为k的环形缓冲而不是只用两个变量。把这道题的滚动数组写法理解透彻之后你会发现它对后续很多DP题都有启发——比如“不同路径II”里的二维矩阵逐行滚动本质上也是同一个思路当前状态只依赖邻近的若干个历史状态其他的一概不需要留。4. 从数学视角降维打击通项公式和矩阵快速幂如果说滚动数组是把空间压到了极致那通项公式和矩阵快速幂就是在时间复杂度上做文章。这两种方法在n较小的时候体现不出优势甚至代码更复杂、更容易出错但在n极大比如10^18的竞赛场景下它们是唯一能跑的方案。4.1 斐波那契通项公式的实战价值爬楼梯的递推式和斐波那契几乎一样只是初值不同。标准斐波那契是F(0)0F(1)1爬楼梯是f(1)1f(2)2。如果平移一下下标可以证明 f(n) F(n1)其中F是标准斐波那契数列。因此爬楼梯的通项公式可以直接从斐波那契通项公式导出f(n) (1 / sqrt(5)) * [((1 sqrt(5)) / 2)^(n1) - ((1 - sqrt(5)) / 2)^(n1)]用代码实现import math def climbStairs(n: int) - int: sqrt5 math.sqrt(5) phi (1 sqrt5) / 2 psi (1 - sqrt5) / 2 return int(round((phi ** (n 1) - psi ** (n 1)) / sqrt5))这个方法在理论上时间复杂度是O(log n)——因为幂运算可以用快速幂——空间O(1)。但实际使用时要非常小心浮点精度问题。当n较大时phi^n和psi^n都是极大的浮点数相减之后除以sqrt5再四舍五入可能会因为浮点误差得到错误的整数结果。LeetCode上这道题的n最大到45浮点精度还够用但如果n到70以上这个公式就可能翻车。所以我的建议是通项公式知道原理即可笔试面试中不要主动用。它属于看起来优雅、用起来扎手的方案。除非题目明确要求O(log n)且n极大否则滚动数组才是最优选择。4.2 矩阵快速幂把一个递推改写成一个幂运算如果说通项公式是碰巧斐波那契才有封闭解那矩阵快速幂就是一套普适性更强的通用方法。它适用于任何线性递推关系在n巨大的时候依然可以在O(log n)时间内求解。思路是把递推关系表示成矩阵形式[f(n) ] [1 1] [f(n-1)] [f(n-1)] [1 0] [f(n-2)]也就是说[f(n) ] [1 1]^(n-2) [f(2)] [f(n-1)] [1 0] [f(1)]然后问题转化为计算矩阵的n-2次幂。矩阵幂运算可以用快速幂二进制拆分做到O(log n)。def climbStairs(n: int) - int: if n 2: return n def mat_mul(a, b): return [ [a[0][0] * b[0][0] a[0][1] * b[1][0], a[0][0] * b[0][1] a[0][1] * b[1][1]], [a[1][0] * b[0][0] a[1][1] * b[1][0], a[1][0] * b[0][1] a[1][1] * b[1][1]] ] def mat_pow(mat, power): result [[1, 0], [0, 1]] # 单位矩阵 while power 0: if power 1: result mat_mul(result, mat) mat mat_mul(mat, mat) power 1 return result base [[1, 1], [1, 0]] result mat_pow(base, n - 2) # 乘以初始向量 [f(2), f(1)] [2, 1] return result[0][0] * 2 result[0][1] * 1矩阵快速幂在实际面试中算是加分项。大多数候选人能说到滚动数组就已经很好了如果你能主动补充“如果n达到10^18滚动数组也扛不住可以用矩阵快速幂或者通项公式”面试官会认为你对复杂度的理解是系统的而不是只会背模板。这道题最经典的面试走位就是先答滚动数组O(n)/O(1)再补一句矩阵快速幂可以到O(log n)配合追问给出推导。不过要提醒一句面试中如果你决定写矩阵快速幂必须确保矩阵乘法函数完全正确。这类代码有一堆下标细节非常容易写错。我自己在练习时至少写错过三次都是因为a[0][0]*b[0][0]这类交叉项弄混。4.3 到底该记哪些解法一个务实的取舍我不建议把五种解法全部背下来。真正值得反复手写的是自底向上DP理解状态定义和转移滚动数组面试中最优的时空平衡矩阵快速幂作为知识储备能推导能讲清即可纯粹递归是理解用的不是让你写在卷子上的。通项公式知道存在即可面试主动提它反而可能被追问到精度问题得不偿失。5. 面试官通常怎么围绕这道题做文章爬楼梯在面试中的表现有点特殊。它难吗不难。但正因为它不难面试官可以在这道题上做大量扩展和追问考察你的思维深度。常见的追问路径我都遇到过总结下来有这么几类。5.1 递推公式的推导过程很多候选人上来就背“dp[i] dp[i-1] dp[i-2]”但被问“为什么”就卡住了。其实推导只需要一句话到第i级的最后一步要么从第i-1级跨1级要么从第i-2级跨2级这两种情况互不重叠所以相加。这个“最后一步分类”的思想是动态规划领域最核心的建模方式。几乎所有一维DP都可以套这个模板最后一步有哪些选择每种选择对应哪种前序状态把选择对应的方案数加起来。5.2 边界情况面试官会问n0时是多少n1呢n2呢题目本身默认n是正整数所以n0不需要处理。但如果你自己把代码写成“dp[0]1, dp[1]1”也能得到正确答案因为这样本质上就是从斐波那契数列F(1)1, F(2)2换了一种初始化方式。这不算错但解释起来比较绕。如果你采用dp[0]1的写法必须能清楚解释dp[0]代表什么——很多人的解释是“站在原地有一种方法就是不动”这个解释在数学上是自洽的但在直觉上比较牵强。所以我个人还是推荐用dp[1]1, dp[2]2这种边界设置语义更直观。5.3 大数取模变种如果n被放大到10^18所有O(n)的解法都会超时必须用矩阵快速幂。同时结果要对10^97取模。这种变体在笔试里经常出现但LeetCode原题不会。刷题时建议自己练一遍取模版矩阵快速幂因为取模运算如果放错了位置非常容易产生负数和溢出。5.4 扩展问题“每次可以跨1或2或3阶呢”如果允许一次跨1、2、3阶递推式就变成f(n) f(n-1) f(n-2) f(n-3)初值也要相应扩展到f(1)1, f(2)2, f(3)4。这个扩展考察的不是新的算法而是你能不能举一反三把状态转移方程的维度从2改成3。再往外扩一层如果“每次最多可以跨k阶”就需要维护一个长度为k的滑动窗口和。这一步已经不是单纯套公式能解决的了而是要分析区间和的前缀优化。这一道题可以一直延伸到面试结束根本用不着面试官再出新题。6. 爬楼梯可不是孤立的它是一整个DP入门家族的核心很多人刷完爬楼梯就急着往后面的中等题、困难题冲我觉得有点浪费。爬楼梯这一题延伸出去的变种其实是动态规划入门阶段性价比最高的一组训练。我自己带过几个新人都是让他们把这组题吃透再往后走效果比盲目刷题好得多。6.1 最小花费爬楼梯爬楼梯的Cost版剑指Offer 10-II和LeetCode 746都涉及“最小花费爬楼梯”。题目变成每一级台阶都有一个cost[i]你可以从第0级或第1级出发跨1级或2级问到达楼顶的最小花费。这题的建模思路和爬楼梯完全一致只不过状态从“方案数”变成了“最小花费”转移也从加法变成了取mindp[i] cost[i] min(dp[i-1], dp[i-2])最后答案是min(dp[n-1], dp[n-2])——因为可以从倒数第一级或倒数第二级跨到楼顶。它和爬楼梯放在一起刷能让你清晰感受到DP的两大基本问题类型计数型和最值型。计数型用加法因为每个方案都是独立的最值型用min或者max因为只需要保留最优路径。6.2 不同路径从一维到二维的跨越LeetCode 62“不同路径”是爬楼梯的二维版。一个机器人在m x n网格的左上角每次只能向下或向右走一步问到达右下角有多少条路径。状态定义从dp[i]变成dp[i][j]转移方程从dp[i] dp[i-1] dp[i-2]变成dp[i][j] dp[i-1][j] dp[i][j-1]。没有本质区别只是多了一个维度。但很多人第一次接触二维DP时会觉得变了个物种其实不然。建议在刷完爬楼梯之后立刻刷这一题你会感觉一切都很顺。6.3 爬楼梯的k阶推广滑动窗口优化如果题目改成“每次可以爬1到k阶”那么f(n) f(n-1) f(n-2) ... f(n-k)直接计算的话时间复杂度是O(nk)。如果k和n都很大比如n10^6k10^5就需要用前缀和或者滑动窗口来优化。设sum[i] f(1) f(2) ... f(i)那么f(n) sum[n-1] - sum[n-k-1]。这样每个f(n)都可以O(1)算出来整体降到O(n)。这是从爬楼梯这题延伸出去的一个不错的进阶点难度刚刚好不会让人劝退但又确实需要动点脑筋。6.4 三步问题面试题库里的孪生兄弟还有一个高频变体叫“三步问题”也就是一次可以跨1、2、3阶n可能到10^6要求对10^97取模。这个题比LeetCode原版更接近笔试实战因为涉及了模运算。它的核心坑有两个取模后相加可能要取两次模f(n) (f(n-1) f(n-2) f(n-3)) % MOD取模不能改变中间态的精度所以每一步都要模踩过几次之后你会形成一个条件反射看到“结果可能很大”立刻想到模运算看到n的范围大过10^7立刻想到矩阵快速幂或者O(1)数学解。7. 我把这题的所有坑都踩了一遍给你整理一份避坑清单7.1 语言差异导致的溢出陷阱LeetCode原题n最大45结果在32位有符号整数范围内2^31-1约21亿而f(45)是1836311903刚好没爆。但如果你用的是C的intf(46)就已经溢出变成负数了。所以很多C选手刷题时会把返回值类型写成long long这个习惯是好的因为题目一旦扩展int根本扛不住。Java的int是32位long是64位但很多国内笔试场景为了防止溢出会要求输出对10^97取模。如果你没有养成取模的习惯那就得格外小心输入范围。python不存在这个问题Python的int是任意精度的这算是刷题时一个隐性优势但也容易让人忽视溢出这个考点——面试问Java/C溢出问题时就会露馅。7.2 循环边界range(3, n1)还是range(2, n)滚动数组写法里循环边界特别容易出错。我用的是for i in range(3, n 1):当n3时循环会执行一次算出f(3)。当n4时循环会执行两次先算f(3)再算f(4)。这个边界写对了答案就是对的。把n1写成n的话n3就算不到f(3)直接返回错误的prev12。如果要精简成a, b 1, 2 for _ in range(2, n): a, b b, a b return b if n 1 else a当n3时range(2, 3)只循环一次此时a2, b3返回b3正确。当n2时循环不执行b2返回2正确。这种写法可以少写一个if分支但前提是你要理解range的终点是开区间。7.3 递归的返回值陷阱如果你写备忘录递归而且要处理n0的边界那么if n 2: return n这个条件的n2包含了n0返回0。但如果你把条件写成n1或n2分别return那就必须在函数入口先处理n0否则递归到n0时memo[0]可能没初始化或者直接越界。7.4 面试中的“由浅入深”节奏最后说一个比较玄学但很重要的点面试时怎么讲这道题决定了面试官对你的印象。我建议的答题节奏是四步走先确认题目边界问清楚n的范围和是否要求取模。说出最直观的递归思路并主动指出它的指数级复杂度问题。紧接着给出DP解法并解释状态转移是怎么从“最后一步”推导出来的。最后说“这个状态转移只依赖前两项所以可以用两个变量滚动优化到O(1)空间”然后直接写下最终代码。这套流程下来面试官能看到你完整的思维链路而不是一个只会默写答案的刷题机器。如果你还有余力可以补充一句“如果n极大可以用矩阵快速幂优化到O(log n)”这就是锦上添花。我个人在刷这题时最大的体会是好题的评判标准不在于难而在于能不能用最简单的外壳装下整个算法体系的骨架。爬楼梯显然就是这样的题。你在这题上花两个小时把每一层解法的来龙去脉都弄明白收益远大于草草刷三四个不同题型的题。对了还有一个很容易被忽略的小细节LeetCode的题解区很多人喜欢写“完全背包解法”把爬楼梯看成无限物品的完全背包。这个视角本身没问题但是对于刚入门DP的新手我建议先不要把思路搞复杂。先把一维DP的基本模型吃透背包的视角等做到完全背包专题的时候再回头来看你会发现原来这些题在更高维度上是相通的。
返回列表