# 动态规划方法论

30 秒速记

  • 学 DP 别从概念入手,先做一道具体题,做完再回头对名词
  • DP 说白了是「把算过的子问题记下来,别再算一遍」
  • 三件套:状态是什么、怎么从小状态推到大状态、起点(边界)是什么
  • 刷完题一定要复盘:这道题的状态为什么这么定义
  • 题目是教具,换个说法还能写出来才算会

动态规划说白了就是:大问题能拆成小问题,小问题会被反复用到,那就算一次记下来。 刚开始学别急着啃「最优子结构」「状态转移方程」这些词,从抽象到抽象很难懂。我建议先拿爬楼梯这种题手推一遍,自己体会「到第 n 阶的走法只和前两阶有关」,再回头看这些名词就知道它们在说什么。做完题别急着走,花两分钟想清楚状态为什么这么定义,换一道题才迁移得过去。

动态规划是算法面试中的一个“大IP”,同时也是很多同学的心头痛。本节致力于用舒服的姿势帮助大家克服这块心病,因此开篇不能急于怼知识点,要先讲讲方法。

在笔者看来,对于动态规划的学习,最重要的是找到一个正确的学习切入点:如果你是一个对相关理论一无所知的初学者,自然不能急于一上来就生吞“模型”、“状态转移方程”等高端概念——大家谨记,动态规划是一种思想,所谓思想,就是非常好用,好用到爆的套路。我们学习一种思想,重要的是建立起对它的感性认知,而不是反复咀嚼那些对现在的你来说还非常生硬的文字概念——从抽象去理解抽象是意淫,从具体去理解抽象才是学习。

首先带大家一起解决一个实际的问题,然后逐步复盘问题的解决方案,最后从解决方案中提取出动态规划相关的概念、模型和技巧,实现对号入座。

从前面一系列章节的学习反馈中,笔者观察到一部分同学的阅读习惯非常“薄情”——打开小册只为做题,做完就溜,讲解部分基本是不看的。

这里想要提醒大家的是,题目本身不仅仅是命题点,更是素材、是教具,大家最终要关注到的还是题目背后的思想和方法。因此希望同学们能多给自己一点时间、多一些耐心去反刍和吸收知识。

面试官追问

追问 1一句话解释动态规划,你会怎么说?
参考回答

记住已经算过的小问题答案,用它们拼出大问题的答案。比如斐波那契,f(5) 要 f(4) 和 f(3),f(4) 又要 f(3),f(3) 算一次存起来就行。

追问 2能背出「最优子结构、重叠子问题」,但换道题就不会定义状态,问题出在哪?
参考回答

只记了名词,没练过「从题目里抽状态」。拿一道新题,先问自己:最后一步是什么?最后一步之前的局面要用哪些量才能描述?这个量就是状态。

追问 3动态规划和贪心的区别?
参考回答

贪心每一步只挑当前最好的,不回头;DP 会把所有可能的上一步都比一遍再取最优。硬币 [1, 3, 4] 凑 6,贪心拿 4+1+1 三枚,DP 能找到 3+3 两枚。

追问 4动态规划题刷到多少道才算入门?
参考回答

数量不是关键。爬楼梯、硬币、背包、最长上升子序列、编辑距离这几类,每类能独立写出状态和转移,就够应付大部分前端面试了。

# 从“爬楼梯”问题说起

30 秒速记

  • 状态:f(i) = 爬到第 i 阶的走法数
  • 按最后一步分类:从 i-1 跨一步,或从 i-2 跨两步 → f(i) = f(i-1) + f(i-2)
  • 两类走法不重复、不遗漏,所以能直接相加
  • 边界 f(1) = 1、f(2) = 2;时间 O(n),滚动两个变量空间 O(1)
  • n 很大时结果会超出 Number.MAX_SAFE_INTEGER,题目一般要求取模,或者用 BigInt

到第 n 阶的走法数等于到第 n-1 阶和第 n-2 阶的走法数之和,因为最后一步只能是跨 1 阶或跨 2 阶。 这两类走法最后一步不同,肯定不重复,又覆盖了所有情况,所以可以直接加。别一上来就说「这就是斐波那契」,面试官要听的是这个分类论证,规则一改(比如能跨 3 阶)你才能马上改出转移。写代码只要两个变量滚动:let a = 1, b = 2,循环里 [a, b] = [b, a + b]。

回答参考:“我先定义状态而不是直接背斐波那契。按最后一步分类,两类路径互斥且覆盖全部答案,所以得到递推式。”

面试官追问

追问 1你说这是斐波那契,为什么?
参考回答

不是因为长得像,而是按最后一步分类:要么从 n-1 上来,要么从 n-2 上来,两类互斥且完整,所以 f(n) = f(n-1) + f(n-2)。这个论证才是重点。

追问 2规则改成每次可以走 1、2、3 阶呢?
参考回答

最后一步多一种来源:f(n) = f(n-1) + f(n-2) + f(n-3),边界补成 f(1) = 1, f(2) = 2, f(3) = 4,或者统一用 f(0) = 1 起步。

追问 3n = 100 的时候结果对不上,怎么回事?
参考回答

f(100) 大约是 5.7 × 10^20,早就超过 2^53 了,Number 精度丢了。要么按题目要求每步 % 1000000007,要么用 BigInt:let a = 1n, b = 2n。

追问 4空间能不能从 O(n) 降到 O(1)?
参考回答

能,f(i) 只依赖前两项,用两个变量滚动就行,不用开数组。

题目描述:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2

输出: 2
@前端进阶之旅: 代码已经复制到剪贴板

解释: 有两种方法可以爬到楼顶。

1 阶 + 1 阶
2 阶
@前端进阶之旅: 代码已经复制到剪贴板

示例 2:

输入: 3
输出: 3
@前端进阶之旅: 代码已经复制到剪贴板

解释: 有三种方法可以爬到楼顶。

1 阶 + 1 阶 + 1 阶
1 阶 + 2 阶
2 阶 + 1 阶
@前端进阶之旅: 代码已经复制到剪贴板

# 思路分析与编码实现

30 秒速记

  • 能用 DP 的信号:问「有多少种」「最多 / 最少」,且不要求列出每条具体路径
  • 思路:先倒着想(终点从哪来)写出递归 → 发现重复计算 → 加缓存 → 改成循环
  • 裸递归是 O(2^n),f(n-2) 会被算很多遍,n = 45 就要好几秒
  • 记忆化搜索 = 自顶向下 + 缓存;DP = 自底向上填表,两者都是 O(n)
  • 要输出所有具体路径时就不是计数 DP 了,得回溯枚举

这题我会先写递归,再看出重复计算,加个缓存,最后改成从小往大的循环。 递归版本很直观:climbStairs(n) = climbStairs(n-1) + climbStairs(n-2),但画一下递归树就会发现 f(n-2) 被算了两次、f(n-3) 被算了三次,整体是指数级,OJ 直接超时。加一个 memo 数组存算过的结果,每个状态只算一次,就变成 O(n)。再进一步把方向反过来,从 f(1)、f(2) 往上推,就是标准的动态规划,还省掉了递归栈。

这道题目有两个关键的特征:

  • 要求你给出达成某个目的的解法个数
  • 不要求你给出每一种解法对应的具体路径

这样的问题,往往可以用动态规划进行求解(这个结论大家先记下来,后面我们会有很多验证它的机会)。

Step1:递归思想分析问题 基于动态规划的思想来做题,我们首先要想到的思维工具就是“倒着分析问题”。“倒着分析问题”分两步走:

定位到问题的终点

站在终点这个视角,思考后退的可能性 在这道题里,“问题的终点”指的就是走到第 n 阶楼梯这个目标对应的路径数,我们把它记为 f(n)。

那么站在第 n 阶楼梯这个视角, 有哪些后退的可能性呢?按照题目中的要求,一次只能后退 1 步或者 2 步。因此可以定位到从第 n 阶楼梯只能后退到第 n-1 或者第 n-2 阶。我们把抵达第 n-1 阶楼梯对应的路径数记为f(n-1),把抵达第 n-2 阶楼梯对应的路径数记为 f(n-2),不难得出以下关系:

f(n) = f(n-1) + f(n-2)
@前端进阶之旅: 代码已经复制到剪贴板

这个关系用树形结构表示会更加形象

现在我们不难看出,要想求出 f(n),必须求出f(n-1) 和f(n-2)(我们假设 n 是一个大于 5 的数字)。

接下来站在第 n-1 阶台阶上,思考后退的姿势,也无非只能是退到 n-1-1层台阶 或 n-1-2层台阶上,所以f(n-1) 和 f(n-2)、f(n-3)间同样具有以下关系:

f(n-1) = f(n-2) + f(n-3)
@前端进阶之旅: 代码已经复制到剪贴板

同理, f(n-2)也可以按照同样的规则进行拆分:

f(n-2) = f(n-3) + f(n-4)
@前端进阶之旅: 代码已经复制到剪贴板

现在我们的树结构渐渐丰满起来了:

随着拆分的进行,一定会有一个时刻,求解到了 f(1) 或 f(2)。按照题设规则,第 1 阶楼梯只能走 1 步抵达,第 2 阶楼梯可以走 1 步或者走 2 步抵达,因此我们不难得出 f(1) 和 f(2) 的值:

f(1) = 1
f(2) = 2
@前端进阶之旅: 代码已经复制到剪贴板

我们在学习递归与回溯思想的时候,曾经给大家强调过,遇到“树形思维模型”,就要想办法往递归上靠。这道题明显用到了树形思维模型,有着明确的重复内容(不断地按照 f(n) = f(n-1) + f(n-2)的规则拆分),同时有着明确的边界条件(遇到f(1)或f(2)就可以返回了),因此我们不难写出其对应的递归解法代码:

/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
    // 处理递归边界
    if(n === 1) {
        return 1
    }
    if(n === 2){
        return 2
    }
    // 递归计算
    return climbStairs(n-1) + climbStairs(n-2)
};