LeetCode · Dynamic Programming
完整教程 · 单文件离线 · 先讲状态,再写转移
0%
INTERVIEW PREPARATION / DYNAMIC PROGRAMMING

动态规划
从状态定义,到独立解题。

13 道 LeetCode 题,串起递推基础、一维最优、网格模型、划分与子序列、双序列 DP。每题包含完整 Python 3 答案、关键语句解释、手算推演、复杂度与易错点。

13 道题5 个阶段30 份完整代码离线复习 · 进度保存

先用一句完整的话定义 dp,再写公式。每次看到 dp[i],都应能回答:它考虑了哪些元素?是否必须选择最后一个?保存数量、可行性还是最优值?

学习路线 · 先建模,再优化

阶段题目(建议顺序)过关标准
01 · 基础知识 · 从递归到递推509 斐波那契数 → 70 爬楼梯 → 118 杨辉三角从重复计算出发,学会给状态命名、设置边界并按依赖顺序填表。
02 · 核心模板 · 一维最优决策198 打家劫舍 → 53 最大子数组和 → 152 乘积最大子数组区分前缀最优与结尾最优,理解取 max、维护全局答案与保留双极值的原因。
03 · 典型应用 · 网格动态规划62 不同路径 → 63 不同路径 II → 64 最小路径和同一张依赖图,可以累计路径数、处理障碍,也可以选择最小代价。
04 · 状态扩展 · 划分与子序列139 单词拆分 → 300 最长递增子序列从固定前驱走向枚举前驱,并学习用贪心不变量和二分改造状态。
05 · 进阶算法 · 双序列动态规划1143 最长公共子序列 → 72 编辑距离使用两个前缀长度定义二维状态,比较匹配、跳过与编辑三种转移,再压缩空间。

先理解 DP:把重复的子问题只算一次

动态规划适合能够拆为相互依赖的较小状态的问题。重叠子问题意味着多条推导路径会遇到相同状态;求最优解时还要有最优子结构:当前最优值能由适当子问题的最优值构成。状态必须包含影响后续选择所需的信息;乘积题保留最大值还不够,还需要最小值。

把状态看作节点、把依赖看作箭头,就得到一张无环有向图。记忆化按需访问节点,递推则按拓扑顺序填表;两者都是 DP。不要为了“套 DP”给状态附加没有作用的维度。

F(i−2)F(i−1)F(i)先确定依赖,再决定遍历顺序滚动更新:a, b = b, a + b
箭头表示旧状态对新状态的依赖;这张图是一个无环有向图。
五步法必须说清楚的问题本手册中的例子
1 · 定义状态下标表示位置还是前缀长度?是否必须以它结尾?53 必须结尾;198 只考虑前 i 间
2 · 找转移按最后一步或最后一次选择分类,如何覆盖全部情况?70 两种步长相加;198 偷或不偷取 max
3 · 设边界最小合法状态是什么?不可达应设为什么?139 空前缀 True;64 越界为 +∞
4 · 定顺序转移读取的状态是否已经算出?网格按行从左到右;杨辉单行更新从右到左
5 · 定答案与优化最终答案在哪?能否丢弃不再使用的旧状态?300 取 max(dp);1143 保存一行与旧对角线

计数、可行性、最优化,聚合方式不同。计数常用加法,存在性常用 OR,最大值用 max,最小值用 min。初始化与状态含义一起确定,不能仅凭习惯把整张表设为 0。

问题类型常见初值本专题例子
路径计数不可达为 0;起点空方案为 170、62、63
判断可行不可行为 False;空前缀 True139
求非空最大值以首个合法元素初始化53、152
求最小代价不可达为 +∞;合法起点写真实代价64
两个前缀的比较显式增设空前缀行列1143 为 0;72 为长度

复杂度与空间压缩:不要只数循环层数

时间通常是“状态数 × 每个状态枚举的候选数 × 单次操作成本”。Python 的字符串切片会复制字符,不能忽略;哈希表查找的期望常数时间也不等于新字符串哈希免费。空间要区分辅助状态、递归栈和必须返回的输出;杨辉三角的全部输出本来就占 O(r²)。除特别说明外,整数运算按常数时间估算,适用于本题规模。

滚动数组优化的是存储,不改变状态定义。写一维版本之前,先在二维依赖图上标明哪些值来自旧行、哪些来自新行。只求数值时可压缩;需要重建具体方案时通常要保留更多信息。

OFFLINE DP LAB

亲手改一个障碍,看状态如何传播

3×3 网格,左上出发、右下结束,只能向右或下。点击任意格子切换障碍,包括起点与终点。数字表示到达该格的路径数;键盘 Tab 与 Enter 也可操作。

计算规则:先检查障碍并归零;可走的起点赋 1;其他格子累加上方与左方。中心有障碍时答案为 2,清空障碍后为 6。

STAGE 01基础知识 · 从递归到递推

从重复计算出发,学会给状态命名、设置边界并按依赖顺序填表。

01LeetCode 509斐波那契数

LeetCode 原题 ↗

难度:简单 | 核心:重叠子问题 / 记忆化 / 滚动变量

题意与输入边界

输入整数 n(0 ≤ n ≤ 30),返回第 n 个斐波那契数;第 0 项为 0,第 1 项为 1。

解题思路 · 状态、转移与正确性

状态定义:dp[i] 表示 F(i),即第 i 项的数值;它不是“方案数”的通用模板。

F(0)=0, F(1)=1;F(i)=F(i−1)+F(i−2)

直接递归会反复求 F(3)、F(2) 等同一子问题。记忆化保证每个状态只求一次;表格按下标递增计算,转移时两个前驱已知。只依赖前两项,因此还能压缩成两个变量。

解法一 · 记忆化搜索

按定义递归,使用缓存保存每个 k 的结果;这是从问题向下拆解的自顶向下 DP。

Python 3 · 可独立提交
from functools import lru_cache

class Solution:
    def fib(self, n: int) -> int:
        @lru_cache(None)
        def dfs(k):
            if k < 2:
                return k
            return dfs(k - 1) + dfs(k - 2)
        return dfs(n)
关键代码解释
  • 缓存的键是 k,不要让不同状态共用一个结果。
  • 递归函数定义在方法内部,每次调用获得独立缓存;n=0、1 直接触发边界。

时间复杂度:O(n):n+1 个状态,每个状态一次加法。

空间复杂度:O(n):缓存与最深 O(n) 递归栈。

解法二 · 数组递推

显式存下从 F(0) 到 F(n) 的所有状态,便于观察和调试。

Python 3 · 可独立提交
class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        dp = [0] * (n + 1)
        dp[1] = 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        return dp[n]
关键代码解释
  • 先返回 n<2,避免长度为 1 时访问 dp[1]。
  • range 的右边界必须是 n+1,才能计算第 n 项。

时间复杂度:O(n)。

空间复杂度:O(n):状态数组。

解法三 · 滚动变量(推荐提交)

每轮开始 a=F(i−2)、b=F(i−1),计算后整体向前移动。

Python 3 · 可独立提交
class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        a, b = 0, 1
        for i in range(2, n + 1):
            a, b = b, a + b
        return b
关键代码解释
  • Python 同时赋值先计算右侧两个旧值,再更新左侧。
  • 若先写 a=b,再写 b=a+b,就丢失了旧 a。

时间复杂度:O(n)。

空间复杂度:O(1):两个滚动变量。

示例推演 · 手算状态

n=5 → 5;边界 n=0 → 0,n=1 → 1。

iF(i−2)F(i−1)F(i)
2011
3112
4123
5235

解法比较与面试表达

记忆化最接近数学定义,但有栈开销;数组适合展示全部状态;滚动变量最省空间。这里按面试常用的算术操作计数分析,题目范围内整数大小有限。

易错点与边界复查

  • 无缓存递归时间指数增长,不是 O(n)。
  • 不要把 F(0) 写成 1;它与爬楼梯中的空方案含义不同。

02LeetCode 70爬楼梯

LeetCode 原题 ↗

难度:简单 | 核心:计数 DP / 最后一步分类 / 空方案

题意与输入边界

输入 n 阶楼梯(1 ≤ n ≤ 45)。每次上 1 或 2 阶,求恰好到楼顶的不同走法数;先 1 后 2 与先 2 后 1 是不同走法。

解题思路 · 状态、转移与正确性

状态定义:dp[i] 表示恰好到达第 i 阶的方法数。dp[0]=1 代表从地面出发、不走任何一步的唯一空方案。

dp[0]=1, dp[1]=1;dp[i]=dp[i−1]+dp[i−2]

按最后一步分成“从 i−1 走 1 阶”和“从 i−2 走 2 阶”。两类互斥,覆盖所有路径,所以相加。不要枚举所有完整走法,数量会快速增长。

解法一 · 计数数组

从地面初始化,依次求每一阶的方案数。

Python 3 · 可独立提交
class Solution:
    def climbStairs(self, n: int) -> int:
        dp = [0] * (n + 1)
        dp[0] = 1
        for i in range(1, n + 1):
            dp[i] = dp[i - 1]
            if i >= 2:
                dp[i] += dp[i - 2]
        return dp[n]
关键代码解释
  • i=1 时只有来自第 0 阶的一种走法,不能读取 dp[-1]。
  • 把“求个数”写成求 max,会漏掉另一类路径。

时间复杂度:O(n)。

空间复杂度:O(n)。

解法二 · 滚动计数(推荐)

从 dp[0]=dp[1]=1 出发保留两项,递推到 n。

Python 3 · 可独立提交
class Solution:
    def climbStairs(self, n: int) -> int:
        a, b = 1, 1
        for i in range(2, n + 1):
            a, b = b, a + b
        return b
关键代码解释
  • n=1 时循环为空,直接返回 1。
  • a、b 对应前两阶的方案数,而不是当前台阶高度。

时间复杂度:O(n)。

空间复杂度:O(1)。

示例推演 · 手算状态

n=4 → 5:1111、112、121、211、22;n=1 → 1,n=2 → 2。

阶数来自前一阶来自前两阶总走法
0——1
1101
2112
3213
4325

解法比较与面试表达

先写数组解释计数来源,再压缩空间。两种方法结果一致;扩展为允许步长集合时,应对每个合法前驱累加,不能盲用两变量。

易错点与边界复查

  • 爬楼梯的答案等于 F(n+1),不是 F(n)。
  • 顺序不同算不同方法;这不是忽略排列顺序的硬币组合题。

03LeetCode 118杨辉三角

LeetCode 原题 ↗

难度:简单 | 核心:二维递推 / 行边界 / 输出空间

题意与输入边界

返回杨辉三角前 numRows 行(1 ≤ numRows ≤ 30)。每行首尾为 1,中间位置等于上一行相邻两个数之和。

解题思路 · 状态、转移与正确性

状态定义:第 i 行使用 0 起始下标,长度为 i+1;row[j] 表示该行第 j 个数。

P[i][0]=P[i][i]=1;P[i][j]=P[i−1][j−1]+P[i−1][j]

一行完全由上一行确定。两侧边界没有两个父项,直接设为 1。递推按行进行,即保证上一行先完成。整个三角形必须返回,因此输出本身就有 Θ(r²) 个元素。

解法一 · 逐行新建(推荐)

每行先填 1,只计算内部位置;结果列表兼作历史状态表。

Python 3 · 可独立提交
from typing import List

class Solution:
    def generate(self, numRows: int) -> List[List[int]]:
        ans = []
        for i in range(numRows):
            row = [1] * (i + 1)
            for j in range(1, i):
                row[j] = ans[i - 1][j - 1] + ans[i - 1][j]
            ans.append(row)
        return ans
关键代码解释
  • range(1,i) 自动跳过两端;前两行没有内部项。
  • 每一轮创建新的 row,避免所有行引用同一列表。

时间复杂度:O(r²),r=numRows;输出元素数决定下界。

空间复杂度:总空间 O(r²) 含输出;除结果及当前行外为 O(1)。当前行最终进入输出。

解法二 · 单行从右向左更新

保留一行,在末尾补 1,再从右往左更新中间项;每次把快照加入答案。

Python 3 · 可独立提交
from typing import List

class Solution:
    def generate(self, numRows: int) -> List[List[int]]:
        ans, row = [], []
        for i in range(numRows):
            row.append(1)
            for j in range(i - 1, 0, -1):
                row[j] += row[j - 1]
            ans.append(row[:])
        return ans
关键代码解释
  • 逆序保证 row[j−1] 仍是上一行的值。
  • row[:] 生成快照;直接 ans.append(row) 会使所有行随同一个列表改变。

时间复杂度:O(r²):更新及逐行复制。

空间复杂度:辅助 O(r),输出 O(r²);返回完整三角形不能宣称总空间 O(r)。

示例推演 · 手算状态

numRows=5 → [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]];numRows=1 → [[1]]。

行下标 i边界内部推导该行
01无[1]
11,1无[1,1]
21,…,11+1=2[1,2,1]
31,…,11+2=3;2+1=3[1,3,3,1]
41,…,11+3=4;3+3=6[1,4,6,4,1]

解法比较与面试表达

逐行新建更清楚且自然返回所有行;单行方法用于理解逆序更新,但本题仍须保存完整输出,没有降低输出空间。

易错点与边界复查

  • 不要使用 [[1]*r]*r 创建多行,它们共享引用。
  • 一维更新若从左往右,当前行新值会污染还需要的旧值。

阶段验收:从重复计算出发,学会给状态命名、设置边界并按依赖顺序填表。 隐去代码,独立写出本阶段各题的状态、初值、递推式,再用一个最小输入和一个反例验证。

STAGE 02核心模板 · 一维最优决策

区分前缀最优与结尾最优,理解取 max、维护全局答案与保留双极值的原因。

04LeetCode 198打家劫舍

LeetCode 原题 ↗

难度:中等 | 核心:前缀最优 / 选或不选 / 间隔约束

题意与输入边界

给出一排房屋的非负金额 nums,不能同时选择相邻房屋,求可获得的最大金额。至少有一间房,允许不偷。

解题思路 · 状态、转移与正确性

状态定义:dp[i] 表示只考虑前 i 间房的最大收益,当前房屋金额为 nums[i−1]。它不要求必须偷第 i 间。

dp[0]=0;dp[1]=nums[0];dp[i]=max(dp[i−1], dp[i−2]+nums[i−1])

最优方案只有两种:不偷当前房屋,收益来自前 i−1 间;偷当前房屋,前一间不能偷,收益来自前 i−2 间。两类取最大即得到最优解。仅比较奇数位与偶数位金额不够,例如 [2,1,1,2] 可以偷首尾,收益为 4。

解法一 · 前缀数组

以“前多少间”为下标,空前缀收益为 0。

Python 3 · 可独立提交
from typing import List

class Solution:
    def rob(self, nums: List[int]) -> int:
        n = len(nums)
        dp = [0] * (n + 1)
        dp[1] = nums[0]
        for i in range(2, n + 1):
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])
        return dp[n]
关键代码解释
  • dp 下标是数量,nums 下标是位置,两者相差 1。
  • dp[i−1] 可以继承,不应强制包含当前房屋。

时间复杂度:O(n)。

空间复杂度:O(n)。

解法二 · 滚动状态(推荐)

每处理一个金额 x,用前两轮的最优收益决定新收益。

Python 3 · 可独立提交
from typing import List

class Solution:
    def rob(self, nums: List[int]) -> int:
        prev2 = prev1 = 0
        for x in nums:
            prev2, prev1 = prev1, max(prev1, prev2 + x)
        return prev1
关键代码解释
  • 循环前两个 0 分别表示尚未选择房屋时的基准收益。
  • 同时赋值确保 prev2+x 使用旧 prev2。

时间复杂度:O(n)。

空间复杂度:O(1)。

示例推演 · 手算状态

nums=[2,7,9,3,1] → 12,选择 2、9、1;[0] → 0;[2,1,1,2] → 4。

前 i 间当前金额不偷 / 偷dp[i]
0——0
120 / 22
272 / 77
397 / 2+911
4311 / 7+311
5111 / 11+112

解法比较与面试表达

数组可用于回溯选择方案;滚动变量适合只返回最大收益。回溯时比较 dp[i−1] 与 dp[i−2]+nums[i−1],相等时可能有多种最优方案。

易错点与边界复查

  • 相邻房屋有约束,不能简单累加所有正数。
  • 本题是一排房屋;首尾并不相邻,不要误用环形版本边界。

05LeetCode 53最大子数组和

LeetCode 原题 ↗

难度:中等 | 核心:以当前位置结尾 / Kadane / 分治合并

题意与输入边界

输入非空整数数组,返回非空连续子数组的最大和。元素可以为负数,子数组必须连续。

解题思路 · 状态、转移与正确性

状态定义:end[i] 表示必须以 nums[i] 结尾的最大子数组和;全局答案为所有 end[i] 的最大值。

end[0]=nums[0];end[i]=max(nums[i], end[i−1]+nums[i]);ans=max(end)

以 i 结尾的连续子数组要么只含 nums[i],要么把以 i−1 结尾的子数组接上 nums[i],没有第三种情况。旧和为负时重新开始更好;但答案不能从 0 初始化,因为空子数组不合法。

解法一 · 显式 DP 数组

写清每个下标的结尾最优,再从所有状态中取最大。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        dp = [0] * len(nums)
        dp[0] = nums[0]
        for i in range(1, len(nums)):
            dp[i] = max(nums[i], dp[i - 1] + nums[i])
        return max(dp)
关键代码解释
  • dp[-1] 只代表最后一个位置结尾,不能直接作为全局答案。
  • 非空保证 nums[0] 存在;单元素数组直接返回该元素。

时间复杂度:O(n)。

空间复杂度:O(n)。

解法二 · Kadane 滚动变量(推荐)

用 end 保存当前结尾最优,用 ans 累积全局最优。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        end = ans = nums[0]
        for i in range(1, len(nums)):
            end = max(nums[i], end + nums[i])
            ans = max(ans, end)
        return ans
关键代码解释
  • 先更新 end,再用本轮 end 更新 ans。
  • 以 nums[0] 初始化两个变量,能正确处理全负数组。

时间复杂度:O(n)。

空间复杂度:O(1)。

解法三 · 四元组分治(进阶)

每个区间返回总和、最大前缀和、最大后缀和、最大子数组和。左右各自递归后 O(1) 合并。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        def solve(l, r):
            if l == r:
                x = nums[l]
                return x, x, x, x
            mid = (l + r) // 2
            lt, lp, ls, lb = solve(l, mid)
            rt, rp, rs, rb = solve(mid + 1, r)
            total = lt + rt
            prefix = max(lp, lt + rp)
            suffix = max(rs, rt + ls)
            best = max(lb, rb, ls + rp)
            return total, prefix, suffix, best
        return solve(0, len(nums) - 1)[3]
关键代码解释
  • 跨越中点的最佳子数组必为左区间后缀 + 右区间前缀。
  • 每层不用重新扫描区间,也不用切片,所以 T(n)=2T(n/2)+O(1)=O(n)。

时间复杂度:O(n):每个递归节点只做常数次合并。

空间复杂度:O(log n):平衡递归栈。

示例推演 · 手算状态

[-2,1,-3,4,-1,2,1,-5,4] → 6,对应 [4,-1,2,1];[-5,-2,-7] → −2。

i / 值以此结尾 end全局 ans决定
0−2−2初始化
111从 1 重启
2−211−3
344从 4 重启
4344−1
555接上 2
666接上 1
716接上 −5
856接上 4

解法比较与面试表达

数组版最便于看状态;Kadane 最简洁;四元组法展示“保留足够信息才能合并”,也可迁移到线段树。若要返回区间,还需记录重启位置和全局左右边界。

易错点与边界复查

  • 不能跳过中间负数拼接两个不相邻片段。
  • ans=0 会把全负数组错误地变成 0。
  • 分治若每次线性扫描跨界区间,复杂度为 O(n log n);这里的四元组版本为 O(n)。

06LeetCode 152乘积最大子数组

LeetCode 原题 ↗

难度:中等 | 核心:双状态 / 负负得正 / 旧值保护

题意与输入边界

输入非空整数数组,返回非空连续子数组的最大乘积。数值可为负数或 0;题目保证任意子数组乘积在 32 位整数范围内。

解题思路 · 状态、转移与正确性

状态定义:hi[i]、lo[i] 分别表示以 i 结尾的最大与最小乘积。负数乘法会交换大小,所以两端极值都必须保存。

hi=max(x, old_hi·x, old_lo·x);lo=min(x, old_hi·x, old_lo·x)

把 x 接在一个旧子数组后面时:x 为正数保持大小顺序,x 为负数反转顺序,x=0 则两个乘积都为 0。加上“只选择 x”,三类候选覆盖所有结尾子数组。

解法一 · 最大最小数组

分别保存每个位置的两种极值,最后在 hi 中找答案。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxProduct(self, nums: List[int]) -> int:
        n = len(nums)
        hi, lo = [0] * n, [0] * n
        hi[0] = lo[0] = nums[0]
        for i in range(1, n):
            x = nums[i]
            hi[i] = max(x, hi[i - 1] * x, lo[i - 1] * x)
            lo[i] = min(x, hi[i - 1] * x, lo[i - 1] * x)
        return max(hi)
关键代码解释
  • lo 可能是很小的负数,遇到下一次负数时变成最大正数。
  • 只读前一项,因此更新 hi[i] 不会破坏 lo[i] 的计算。

时间复杂度:O(n)。

空间复杂度:O(n)。

解法二 · 两个滚动极值(推荐)

显式存下旧 hi、lo,再同时推导本轮两个极值。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxProduct(self, nums: List[int]) -> int:
        hi = lo = ans = nums[0]
        for i in range(1, len(nums)):
            x = nums[i]
            old_hi, old_lo = hi, lo
            hi = max(x, old_hi * x, old_lo * x)
            lo = min(x, old_hi * x, old_lo * x)
            ans = max(ans, hi)
        return ans
关键代码解释
  • old_hi、old_lo 同属上一轮;不能用更新后的 hi 求 lo。
  • 候选 x 让新片段可从当前位置开始,0 之后也能正常重启。

时间复杂度:O(n)。

空间复杂度:O(1)。

示例推演 · 手算状态

[-2,3,-4,0,-1] → 24;[2,3,-2,4] → 6;[-2,0,-1] → 0;[-2] → −2。

x候选:x / old_hi×x / old_lo×x新 hi / loans
-2初始化−2 / −2-2
33 / −6 / −63 / −63
-4−4 / −12 / 2424 / −1224
00 / 0 / 00 / 024
-1−1 / 0 / 00 / −124

解法比较与面试表达

两种解法使用相同转移。数组版便于推演;滚动版减少空间。也可在 x<0 时交换 hi、lo 后计算,但显式旧值版本更便于避免更新顺序错误。

易错点与边界复查

  • 不能套用最大和的单一状态;[-2,3,-4] 正说明最小负积有用。
  • 最大乘积可能出现在中途,必须保存全局 ans。
  • 把初值设为 1 会允许空子数组,单个负数时错误。

阶段验收:区分前缀最优与结尾最优,理解取 max、维护全局答案与保留双极值的原因。 隐去代码,独立写出本阶段各题的状态、初值、递推式,再用一个最小输入和一个反例验证。

STAGE 03典型应用 · 网格动态规划

同一张依赖图,可以累计路径数、处理障碍,也可以选择最小代价。

07LeetCode 62不同路径

LeetCode 原题 ↗

难度:中等 | 核心:网格计数 / 上与左 / 滚动行

题意与输入边界

m 行 n 列网格,从左上角走到右下角,每次只向右或向下移动一步,求路径总数。m、n 均为正整数。

解题思路 · 状态、转移与正确性

状态定义:dp[i][j] 表示从起点到格子 (i,j) 的路径数,包含到达当前格子的完整路径。

dp[0][j]=dp[i][0]=1;dp[i][j]=dp[i−1][j]+dp[i][j−1]

最后一步只能来自上方或左方,两类路径互斥,因此相加。首行只能一直向右,首列只能一直向下,均只有 1 条路径。按行从左到右计算可保证依赖已就绪。

解法一 · 二维表

整张表先填 1,再求内部格子。

Python 3 · 可独立提交
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[1] * n for _ in range(m)]
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        return dp[m - 1][n - 1]
关键代码解释
  • 列表推导式为每一行创建独立对象。
  • 只有一行或一列时内部循环为空,答案保留为 1。

时间复杂度:O(mn)。

空间复杂度:O(mn)。

解法二 · 一维滚动行(推荐)

更新 dp[j] 之前它是上一行的路径数;dp[j−1] 已是当前行左边的路径数。

Python 3 · 可独立提交
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [1] * n
        for i in range(1, m):
            for j in range(1, n):
                dp[j] += dp[j - 1]
        return dp[-1]
关键代码解释
  • 左到右更新正是为了让左邻居使用本行新值。
  • 若利用网格对称性让 n 为较小维度,可压缩至 O(min(m,n));此代码原样为 O(n)。

时间复杂度:O(mn)。

空间复杂度:O(n):一行。

解法三 · 组合计数(进阶对照)

每条路径固定包含 m−1 次向下、n−1 次向右。只要选择向下动作在总步数中的位置,就唯一确定一条路径,答案是组合数。下面逐项精确计算,不使用浮点数。

Python 3 · 可独立提交
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        total = m + n - 2
        k = min(m - 1, n - 1)
        ans = 1
        for i in range(1, k + 1):
            ans = ans * (total - k + i) // i
        return ans
关键代码解释
  • 第 i 轮结束,ans=C(total−k+i,i),乘除递推保持整数。
  • 必须先乘后整除;提前除可能截断。k=0 表示只有一行或一列,空循环返回 1。

时间复杂度:O(k) 次整数乘除,k=min(m−1,n−1);按算术操作模型计数。

空间复杂度:O(1) 个变量;若计入大整数位数,则需要 O(log C(total,k)) 位存储,运算成本也随位数增长。

示例推演 · 手算状态

m=3,n=3 → 6;m=3,n=7 → 28;m=1,n=5 → 1;m=1,n=1 → 1。

处理的行第 0 列第 1 列第 2 列
第 0 行111
第 1 行11+1=21+2=3
第 2 行12+1=33+3=6

解法比较与面试表达

二维表最直观,一维版只保留必需的历史;组合计数利用固定步数通常更快。带障碍或带权网格破坏了任意步序都合法的条件,应回到 63、64 的 DP。

易错点与边界复查

  • 二维数组不要写成 [[1]*n]*m。
  • 一维版从右向左更新会让左方仍来自上一行,含义不符。

08LeetCode 63不同路径 II

LeetCode 原题 ↗

难度:中等 | 核心:障碍归零 / 边界传播 / 可达性

题意与输入边界

给出障碍网格 obstacleGrid,0 可走、1 为障碍。从左上到右下只向右或下,求不穿过障碍的路径数。起点或终点也可能被堵住。

解题思路 · 状态、转移与正确性

状态定义:dp[i][j] 仍表示到达当前格子的路径数。任何障碍格子的状态都必须为 0。

有障碍:dp[i][j]=0;否则为上方+左方;起点可走时 dp[0][0]=1

与 62 的转移一致,但边界不再整行整列恒为 1。首行遇到障碍后,后续格子都无法从左边抵达;通用转移会自然传播这个 0。必须先判断障碍,再处理起点。

解法一 · 二维表与统一边界

越界方向视为没有路径;起点单独设为 1。

Python 3 · 可独立提交
from typing import List

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        m, n = len(obstacleGrid), len(obstacleGrid[0])
        dp = [[0] * n for _ in range(m)]
        for i in range(m):
            for j in range(n):
                if obstacleGrid[i][j] == 1:
                    continue
                if i == 0 and j == 0:
                    dp[i][j] = 1
                else:
                    up = dp[i - 1][j] if i > 0 else 0
                    left = dp[i][j - 1] if j > 0 else 0
                    dp[i][j] = up + left
        return dp[-1][-1]
关键代码解释
  • continue 保留障碍格的初值 0,也会让障碍起点保持 0。
  • 不能直接套用无障碍题的首行全 1 初始化。

时间复杂度:O(mn)。

空间复杂度:O(mn)。

解法二 · 一维行内归零(推荐)

初始 dp[0]=1 代表起点的种子。扫描到障碍时,把从上方继承的旧路径数清除。

Python 3 · 可独立提交
from typing import List

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        n = len(obstacleGrid[0])
        dp = [0] * n
        dp[0] = 1
        for row in obstacleGrid:
            for j in range(n):
                if row[j] == 1:
                    dp[j] = 0
                elif j > 0:
                    dp[j] += dp[j - 1]
        return dp[-1]
关键代码解释
  • j=0 时不加 dp[-1];首列路径数直接继承上一行,遇障碍则清零。
  • 起点有障碍时第一次扫描就会令 dp[0]=0。

时间复杂度:O(mn)。

空间复杂度:O(n)。

示例推演 · 手算状态

[[0,0,0],[0,1,0],[0,0,0]] → 2;[[1]] → 0;[[0]] → 1;[[0,1,0]] → 0。

行障碍布局滚动数组更新后解释
00 0 0[1,1,1]首行只有向右
10 1 0[1,0,1]中心归零,右侧从上继承
20 0 0[1,1,2]终点由上方 1 + 左方 1

解法比较与面试表达

二维版便于对照障碍和状态;一维版空间更低。该题可视为 DAG 上的路径计数:被禁用的节点没有路径贡献。

易错点与边界复查

  • 障碍不能只是跳过更新一维状态,否则上一行的旧值仍留在 dp[j]。
  • 不要假设起终点一定可走。
  • 障碍之后的首行格子可能不可达,不要无条件赋 1。

09LeetCode 64最小路径和

LeetCode 原题 ↗

难度:中等 | 核心:网格最优 / 不可达设无穷 / 起点代价

题意与输入边界

非负整数网格中,从左上到右下,每次只能向右或向下,求路径上所有格子(含起点终点)的最小总和。

解题思路 · 状态、转移与正确性

状态定义:dp[i][j] 表示到达 (i,j) 的最小累计和。与路径计数不同,这里从两个前驱选择代价较小者。

dp[0][0]=grid[0][0];dp[i][j]=min(上方,左方)+grid[i][j]

任意最优路径最后一步来自上或左。若它在该前驱之前并非最优,就可以替换为更便宜的前缀,得到更优整条路径,产生矛盾。这就是最优子结构。越界方向不存在,应视为 +∞,不能视为 0。

解法一 · 二维最短代价表

显式初始化起点,然后统一处理其余格子。

Python 3 · 可独立提交
from typing import List

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        dp = [[float("inf")] * n for _ in range(m)]
        dp[0][0] = grid[0][0]
        for i in range(m):
            for j in range(n):
                if i == 0 and j == 0:
                    continue
                up = dp[i - 1][j] if i > 0 else float("inf")
                left = dp[i][j - 1] if j > 0 else float("inf")
                dp[i][j] = min(up, left) + grid[i][j]
        return dp[-1][-1]
关键代码解释
  • 起点只计入一次;其余格子把本格代价加在前驱最优值之后。
  • 此实现不修改原 grid;保存整表便于回溯路径。

时间复杂度:O(mn)。

空间复杂度:O(mn)。

解法二 · 一维滚动最小值(推荐)

用 dp[0]=0 为起点提供一个虚拟前驱;第一次处理后它就变成首格代价。

Python 3 · 可独立提交
from typing import List

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        n = len(grid[0])
        dp = [float("inf")] * n
        dp[0] = 0
        for row in grid:
            for j, x in enumerate(row):
                left = dp[j - 1] if j > 0 else float("inf")
                dp[j] = min(dp[j], left) + x
        return dp[-1]
关键代码解释
  • 旧 dp[j] 是上方,新 dp[j−1] 是左方。
  • 首列只能从上方来;虚拟种子仅在初始时为 0,不会每行重置。

时间复杂度:O(mn)。

空间复杂度:O(n)。

示例推演 · 手算状态

[[1,3,1],[1,5,1],[4,2,1]] → 7,路径值 1→3→1→1→1;[[0]] → 0;[[1,2,3]] → 6。

行原网格该行最小累计和关键计算
0[1,3,1][1,4,5]首行累计
1[1,5,1][2,7,6]中间 min(4,2)+5=7
2[4,2,1][6,8,7]终点 min(6,8)+1=7

解法比较与面试表达

二维与滚动版都不修改输入;若直接在 grid 上累加,额外空间可为 O(1),代价是输入被改变。只返回最小和时滚动版已足够。

易错点与边界复查

  • 不能贪心地每次选右、下中较小值,局部小不保证后续代价小。
  • min 型问题的不可达状态用 +∞,计数型不可达才用 0。
  • 有的原地解会修改 grid;复制输入或明确接受修改后再使用。

阶段验收:同一张依赖图,可以累计路径数、处理障碍,也可以选择最小代价。 隐去代码,独立写出本阶段各题的状态、初值、递推式,再用一个最小输入和一个反例验证。

STAGE 04状态扩展 · 划分与子序列

从固定前驱走向枚举前驱,并学习用贪心不变量和二分改造状态。

10LeetCode 139单词拆分

LeetCode 原题 ↗

难度:中等 | 核心:前缀可行性 / 分割点 / 字符串成本

题意与输入边界

给定字符串 s 和非空单词字典 wordDict,判断能否用字典词完整拼出 s。词可重复使用,可以不使用部分词;不能重排 s 中字符。

解题思路 · 状态、转移与正确性

状态定义:dp[i] 表示长度为 i 的前缀 s[:i] 能否被合法拆分;dp[0]=True 是连接首个单词所需的空前缀。

dp[i]=OR(dp[j] AND s[j:i]∈words),0≤j<i

枚举最后一个单词的起点 j。前 j 个字符可拆且最后一段属于字典,就得到一个合法拆分;反过来任何合法方案也必存在这样的最后分割点。只需要一个可行方案,命中后可提前停止。

解法一 · 枚举全部分割点

字典转 set 以便查找;短路逻辑只在 dp[j] 为真时创建候选切片。

Python 3 · 可独立提交
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        words = set(wordDict)
        n = len(s)
        dp = [False] * (n + 1)
        dp[0] = True
        for i in range(1, n + 1):
            for j in range(i):
                if dp[j] and s[j:i] in words:
                    dp[i] = True
                    break
        return dp[n]
关键代码解释
  • dp[j] 是前缀长度 j,最后一段是左闭右开 s[j:i]。
  • set 查找期望 O(1) 不包含构造新切片与首次计算字符串哈希的 O(i−j)。

时间复杂度:Python 严格上界 O(n³+S):O(n²) 候选,每个切片及哈希最坏 O(n);S 为字典总字符数。

空间复杂度:O(n+S):DP、词集合及临时候选;保守计入字典存储。

解法二 · 限制最大词长(推荐)

令 L 为最大词长。最后一个词不可能超过 L,只枚举 i−L 到 i−1 的分割点。

Python 3 · 可独立提交
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        words = set(wordDict)
        limit = max(map(len, wordDict), default=0)
        n = len(s)
        dp = [False] * (n + 1)
        dp[0] = True
        for i in range(1, n + 1):
            for j in range(max(0, i - limit), i):
                if dp[j] and s[j:i] in words:
                    dp[i] = True
                    break
        return dp[n]
关键代码解释
  • 截断范围只移除不可能是字典词的过长段,不会漏掉有效方案。
  • 设 K=min(n,L),每个位置至多 K 个候选、每次切片至多 K 个字符。

时间复杂度:期望上界 O(nK²+S),K=min(n,L),含切片和哈希成本;抽象忽略字符串成本时为 O(nK)。

空间复杂度:O(n+S):状态、词集合;临时切片 O(K) 不改变总上界。

解法三 · 后缀记忆化搜索

改从当前位置向右选择一个词。dfs(start) 表示后缀 s[start:] 能否完整拆分;不同拆分历史到达相同 start 时,共用缓存结果。

Python 3 · 可独立提交
from functools import lru_cache
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        words = set(wordDict)
        limit = max(map(len, wordDict), default=0)

        @lru_cache(None)
        def dfs(start):
            if start == len(s):
                return True
            stop = min(len(s), start + limit)
            for end in range(start + 1, stop + 1):
                if s[start:end] in words and dfs(end):
                    return True
            return False

        return dfs(0)
关键代码解释
  • 缓存键是整数 start,不需要把整个剩余子串作为键反复复制。
  • 先匹配当前词再递归。到达字符串末尾表示所有字符恰好被消费。
  • 失败状态同样要缓存,否则大量不同路径会重复尝试同一段不可拆后缀。

时间复杂度:期望上界 O(nK²+S),K=min(n,L),包含切片与哈希;可能因按需搜索而访问更少状态。

空间复杂度:O(n+S):缓存、词集合和最深 O(n) 递归栈;本题 n≤300,迭代版更适合更长字符串。

示例推演 · 手算状态

s="leetcode", wordDict=["leet","code"] → True;"applepenapple",["apple","pen"] → True;"catsandog",["cats","dog","sand","and","cat"] → False。

i前缀可行分割dp[i]
0空串空方案True
1–3l / le / lee没有字典词结尾False
4leetdp[0] 且 leet 在字典True
5–7leetc / leetco / leetcod均不成立False
8leetcodedp[4] 且 code 在字典True

解法比较与面试表达

全分割版便于推导;最大词长限制减少无效枚举;记忆化搜索按需访问可达后缀,但有递归栈开销。三者都可重复用词。更大字典还可考虑 Trie,沿原字符串走边来避免反复切片。

易错点与边界复查

  • dp[0] 若为 False,任何首个词都接不上。
  • 不要贪心取最长词;例如 s="cars",词为 ["car","ca","rs"],应选 ca+rs。
  • 能重复用词不等于能任意排列字符;必须匹配当前位置的连续片段。

11LeetCode 300最长递增子序列

LeetCode 原题 ↗

难度:中等 | 核心:结尾最优 / 严格递增 / 贪心与二分

题意与输入边界

给出非空数组,返回最长严格递增子序列的长度。可删除元素但不能改变相对顺序;不要求连续,相等值不能延长序列。

解题思路 · 状态、转移与正确性

状态定义:基础状态 dp[i] 是必须以 nums[i] 作为最后元素的最长递增子序列长度。答案为 max(dp),不是 dp[-1]。

dp[i]=1+max(dp[j]:j<i 且 nums[j]<nums[i]);无合法 j 时为 1

以 i 结尾的序列若长度超过 1,其前一项必位于某个 j

解法一 · O(n²) 结尾 DP

每个位置枚举全部较早的下标。

Python 3 · 可独立提交
from typing import List

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        dp = [1] * len(nums)
        for i in range(len(nums)):
            for j in range(i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)
        return max(dp)
关键代码解释
  • 只有严格小于才能接上;等号会变成最长非递减子序列。
  • 内层用 max 聚合所有前驱,不能只保存最后一个合法 j。

时间复杂度:O(n²)。

空间复杂度:O(n)。

解法二 · 最小结尾 + 二分(推荐进阶)

tails[k] 表示已扫描前缀中,长度为 k+1 的递增子序列的最小可能结尾。找到首个 ≥x 的位置替换;不存在则追加。

Python 3 · 可独立提交
from bisect import bisect_left
from typing import List

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tails = []
        for x in nums:
            k = bisect_left(tails, x)
            if k == len(tails):
                tails.append(x)
            else:
                tails[k] = x
        return len(tails)
关键代码解释
  • tails 严格递增,因此可以二分。用较小的 x 替换结尾,不会损失已有长度,还会利于未来扩展。
  • bisect_left 找首个 ≥x;重复值替换原位,不会增加长度。
  • tails 各位置可能来自不同子序列,整个列表不保证是原数组的一条合法子序列;本题只求长度。

时间复杂度:O(n log n):每个元素一次二分,末尾追加均摊 O(1)。

空间复杂度:O(n):tails 至多 n 个元素。

示例推演 · 手算状态

[10,9,2,5,3,7,101,18] → 4;[7,7,7] → 1;[3,2,1] → 1;[1] → 1。

扫描 xtails结尾 DP[i]当前 LIS 长度
10[10]11
9[9]11
2[2]11
5[2,5]22
3[2,3]22
7[2,3,7]33
101[2,3,7,101]44
18[2,3,7,18]44

解法比较与面试表达

O(n²) 版本易证明,也能配合前驱数组重建路径;二分版把“每个结尾求长度”改为“每个长度求最小结尾”。重建实际 LIS 需另外维护原数组下标和前驱链。

易错点与边界复查

  • 子序列可跳跃,子数组必须连续;不要混用最大子数组的递推。
  • bisect_right 会让相等元素延长序列,改变题意。
  • 二分方案是在原位替换 tails[k],不要 insert 导致 O(n) 移动和错误含义。

阶段验收:从固定前驱走向枚举前驱,并学习用贪心不变量和二分改造状态。 隐去代码,独立写出本阶段各题的状态、初值、递推式,再用一个最小输入和一个反例验证。

STAGE 05进阶算法 · 双序列动态规划

使用两个前缀长度定义二维状态,比较匹配、跳过与编辑三种转移,再压缩空间。

旧左上 diag旧上方 up新左方 dp[j−1]当前 dp[j]
一维压缩后,上方和左上角仍需来自旧行;左方来自当前新行。

12LeetCode 1143最长公共子序列

LeetCode 原题 ↗

难度:中等 | 核心:双字符串前缀 / 匹配与跳过 / 旧左上角

题意与输入边界

给出 text1、text2,返回两者最长公共子序列长度。保持相对顺序即可,不要求连续;没有公共字符时返回 0。

解题思路 · 状态、转移与正确性

状态定义:dp[i][j] 表示 text1[:i] 与 text2[:j] 的 LCS 长度;i、j 是前缀长度,对应末字符下标 i−1、j−1。

相等:dp[i][j]=dp[i−1][j−1]+1;不等:max(dp[i−1][j],dp[i][j−1])

末字符相同时,可以在两个较短前缀的公共子序列后接上这个字符,获得最优匹配。末字符不同时不可能同时匹配它们,至少跳过其中一个,分别从上方或左方取最大。任一前缀为空时公共长度为 0。

解法一 · 二维前缀表(先掌握)

增加空前缀行与列,字符匹配时无需特殊判断边界。

Python 3 · 可独立提交
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        return dp[m][n]
关键代码解释
  • 字符下标减 1,dp 下标不减 1,两者含义不同。
  • 相等时取左上角加 1;不等时不能无条件加 1。

时间复杂度:O(mn),m、n 为两串长度。

空间复杂度:O(mn)。

解法二 · 滚动行与对角线(推荐复习)

让短字符串作为列,保存更新前的 dp[j],并用 diag 保留旧左上角。

Python 3 · 可独立提交
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        if len(text1) < len(text2):
            text1, text2 = text2, text1
        dp = [0] * (len(text2) + 1)
        for a in text1:
            diag = 0
            for j, b in enumerate(text2, 1):
                up = dp[j]
                if a == b:
                    dp[j] = diag + 1
                else:
                    dp[j] = max(up, dp[j - 1])
                diag = up
        return dp[-1]
关键代码解释
  • up 是旧上方,dp[j−1] 是新左方,diag 是旧左上方。
  • 每行 diag 重置为 0,因为空前缀列恒为 0。
  • diag=up 必须在本格更新之后执行,为下一列保存正确旧值。

时间复杂度:O(mn)。

空间复杂度:O(min(m,n)),含空前缀的一格;严格可写 O(min(m,n)+1)。

示例推演 · 手算状态

text1="abcde", text2="ace" → 3,公共子序列为 ace;"abc","def" → 0;"abc","abc" → 3;扩展空串边界 "","a" → 0。

text1 前缀 / text2 前缀空aacace
空0000
a0111
ab0111
abc0122
abcd0122
abcde0123

解法比较与面试表达

二维表最便于证明、画图和回溯;滚动版适合只求长度。与 LIS 相比,LCS 有两个独立前缀维度,不能直接照搬单数组二分。

易错点与边界复查

  • 最长公共子串要求连续,失配时清零;LCS 失配应继承 max。
  • 一维版若不保存旧左上角,匹配同一字符可能被重复计数。
  • 返回长度与返回字符串是不同任务;重建实际 LCS 通常保留二维表回溯。

13LeetCode 72编辑距离

LeetCode 原题 ↗

难度:中等 | 核心:双串前缀 / 插删替换 / 最小操作数

题意与输入边界

给出 word1、word2,允许对 word1 插入、删除或替换一个字符,每次操作代价为 1,返回转成 word2 的最少次数。空串也是合法输入。

解题思路 · 状态、转移与正确性

状态定义:dp[i][j] 表示把 word1[:i] 变成 word2[:j] 的最少操作数;空串变成 j 个字符需要 j 次插入。

dp[i][0]=i, dp[0][j]=j;相等取左上;不等取 1+min(上,左,左上)

末字符相等时可以保留,沿用两个较短前缀的代价。末字符不等时,最后一次操作分别可能是删除源串末字符、插入目标串末字符、替换源串末字符。三类操作穷尽可能,取最小值再加本次代价。

解法一 · 二维操作表(先掌握)

初始化空前缀行列,逐格比较两个末字符。

Python 3 · 可独立提交
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m + 1):
            dp[i][0] = i
        for j in range(n + 1):
            dp[0][j] = j
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = 1 + min(dp[i - 1][j],
                                       dp[i][j - 1],
                                       dp[i - 1][j - 1])
        return dp[m][n]
关键代码解释
  • 上方 dp[i−1][j]+1:删掉 word1 当前末字符。
  • 左方 dp[i][j−1]+1:先得到目标较短前缀,再插入目标末字符。
  • 左上 dp[i−1][j−1]+1:替换末字符;相等时无需 +1。

时间复杂度:O((m+1)(n+1)),非空时通常记 O(mn);含空串时初始化也需 O(m+n)。

空间复杂度:O((m+1)(n+1)),通常简记 O(mn)。

解法二 · 滚动行(推荐复习)

短串作列,dp 初始为 0…n;每行先保存旧 dp[0],再把当前空目标代价改为 i。

Python 3 · 可独立提交
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        if len(word1) < len(word2):
            word1, word2 = word2, word1
        dp = list(range(len(word2) + 1))
        for i, a in enumerate(word1, 1):
            diag = dp[0]
            dp[0] = i
            for j, b in enumerate(word2, 1):
                up = dp[j]
                if a == b:
                    dp[j] = diag
                else:
                    dp[j] = 1 + min(up, dp[j - 1], diag)
                diag = up
        return dp[-1]
关键代码解释
  • 与 LCS 不同,空前缀列是 i,不是恒定 0;先保存 diag 再覆盖。
  • 单位代价的插入与删除对称,因此可交换两串优化列宽。非对称加权版本不能直接交换。

时间复杂度:O(mn+m+n),包含空串情况;非空时为 O(mn)。

空间复杂度:O(min(m,n)+1)。

示例推演 · 手算状态

word1="horse", word2="ros" → 3:horse→rorse(替换 h)→rose(删第 3 个字符 r)→ros(删 e)。"","abc" → 3;"","" → 0;"a","a" → 0。

word1 前缀 / word2 前缀空rroros
空0123
h1123
ho2212
hor3222
hors4332
horse5443

解法比较与面试表达

二维版可沿最优前驱回溯出操作序列;滚动版只保留距离。面试中先说明插、删、替换分别对应哪个前驱,再考虑压缩空间。

易错点与边界复查

  • 初始化若全为 0,会让插入/删除空前缀错误地免费。
  • 不要使用 len(a)+len(b)−2×LCS 代替答案;该公式仅适用于只插入删除。例 a→b:编辑距离 1,公式为 2。
  • 编辑距离不允许交换字符作为一步;那是另一种距离定义。

阶段验收:使用两个前缀长度定义二维状态,比较匹配、跳过与编辑三种转移,再压缩空间。 隐去代码,独立写出本阶段各题的状态、初值、递推式,再用一个最小输入和一个反例验证。

核心模板 · 按状态含义建立题目之间的联系

模板 / 代表题状态与转移骨架最重要的边界
相邻递推 · 509 / 70当前 = 前一项 + 前两项F(0)=0;爬楼梯 dp[0]=1
选或不选 · 198前缀最优 = max(不选当前,隔一项最优+当前)前缀长度与数组下标差 1
结尾最优 · 53 / 152重新开始,或接在旧结尾后面非空初始化;乘积还需最小负积
网格计数与最优 · 62 / 63 / 64聚合上方、左方,再结合本格约束/代价障碍清零;最小值越界为 +∞
前缀划分 · 139枚举最后一个分割点,OR(前缀可行且尾段合法)空前缀 True;计入切片成本
枚举序列前驱 · 300枚举 j每个元素自身长度 1;全局 max
双前缀 · 1143 / 72比较末字符,读取上、左、左上空串行列不同;压缩需保存旧对角线

相似外观,不同语义

对照前者后者
最大子数组和 vs 打家劫舍53 要连续,且状态必须以 i 结尾198 可以跳选,状态不要求选当前
LIS vs LCS300:一个序列,求严格递增1143:两个序列,求共同且保序
LCS vs 编辑距离1143:相等时 +1,不等取 max72:相等代价不增,不等取 min+1
杨辉单行 vs 网格单行118 依赖上一行左邻:逆序保护旧值62 依赖本行左邻:正序使用新值
子数组 vs 子序列子数组要求连续子序列可删除中间元素,但保持原序

面试回答顺序

  1. 先用小例子澄清题意:是否非空、是否连续、是否严格递增,求长度还是具体方案。
  2. 口述状态含义,给出初值;用“最后一步/最后一次选择”推出递推。
  3. 说明为什么分类完整、为什么可以使用子问题的最优值。
  4. 先写易验证的版本,手算最小边界,再解释空间优化与遍历方向。
  5. 按实际代码估算复杂度,说明是否修改输入、是否包含输出与递归栈。

掌握程度自测 · 能解释,才算掌握

题目“已掌握”与下方能力自测分别记录。建议在闭卷写对、讲清转移并通过边界后再勾选。

7 天学习计划 · 每天完成一个可检验目标

天数练习内容本日交付
Day 1509 → 70 → 118(第一阶段)画依赖图;解释 0 与 1 的初始化;复现三种 Fibonacci 写法
Day 2198 → 53(第二阶段前半)对比前缀最优与结尾最优;验证全负数组与不相邻选择
Day 3152 + 复写第二阶段手算一组含负数与 0 的乘积;闭卷写双状态更新
Day 462 → 63 → 64(第三阶段)画二维表再压缩;测试一行、一列、障碍起点
Day 5139 → 300(第四阶段)解释前缀划分与枚举前驱;掌握 LIS 二分不变量
Day 61143 → 72(第五阶段)手填两张 DP 表;独立实现空串边界和对角线保护
Day 713 题混合闭卷复习每题先讲五步法;从 53、63、139、300、72 抽题编码,记录错误原因

复习节奏:首次做对后第 1、3、7 天各闭卷复现一次。若写不出,不急着抄答案,先判断问题来自状态定义、转移遗漏、边界、更新顺序还是语言细节。每次只针对错误原因补练。

动态规划的核心习惯:保存足够的信息,让下一次决策只依赖已经明确的状态。公式应当是状态含义的结果,空间优化应当是依赖关系的结果。