动态规划专题

分享:
算法动态规划DP

动态规划专题

📖 学习路径: 01 树遍历 → 02 DFS/BFS 进阶 → 03 动态规划

一、核心概念

1. 什么是动态规划

动态规划的核心:将大问题分解为重叠子问题,记录子问题的解,避免重复计算。

要素 含义 举例(斐波那契)
最优子结构 大问题的最优解包含子问题的最优解 F(n) = F(n-1) + F(n-2)
重叠子问题 子问题被反复计算 F(5) 依赖 F(4)F(3)F(4) 又依赖 F(3)
无后效性 某阶段状态确定后,不受之后决策影响 dp[i] 只依赖 dp[i-1]dp[i-2]

2. DP vs 回溯 vs 贪心

算法 特点 适用场景
回溯(DFS) 穷举所有可能 需要所有解(全排列、子集)
贪心 每步选局部最优 局部最优 = 全局最优
DP 记录子问题,自底向上 有重叠子问题,求最优解/方案数

3. DP 解题五步法

  1. 定义 dp 含义dp[i] / dp[i][j] 代表什么?
  2. 推导状态转移方程 — 当前状态如何由之前的状态得出?
  3. 确定初始条件/边界dp[0]dp[1] 等特殊值
  4. 确定遍历顺序 — 正序?倒序?外层循环是谁?
  5. 举例推导验证 — 用小例子手动跑一遍

约定: 本专题所有代码使用统一风格。

  • 变量名:一维用 dp,二维用 dp,滚动变量用 prev1/prev2
  • 先展示基础 DP 数组写法(方便理解),再给空间优化版(方便面试)
  • 类型注解完整

二、代码框架(先基础写法,再优化)

框架一:一维线性 DP

核心: dp[i] 只依赖前面几个状态,正向递推。

const linearDP = (nums: number[]): number => {
    const n = nums.length;
    // ① 定义:dp[i] 表示...(根据题目确定)
    const dp: number[] = new Array(n).fill(0);
    // ② 初始化
    dp[0] = nums[0];
    // ③ 递推
    for (let i = 1; i < n; i++) {
        dp[i] = Math.max(dp[i - 1], dp[i - 1] + nums[i]);  // 状态转移
    }
    // ④ 返回
    return dp[n - 1];
};

💡 优化:如果 dp[i] 只依赖 dp[i-1]dp[i-2],可以用两个变量替代整个数组(见例题)。


框架二:01 背包

核心: 每件物品只能选 0 或 1 次。dp[i][j] = 前 i 件物品,容量 j 时的最大价值。

const knapsack01 = (weights: number[], values: number[], capacity: number): number => {
    const n = weights.length;
    // ① 定义:dp[i][j] = 前 i 件物品,容量 j 时的最大价值
    const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0));

    for (let i = 1; i <= n; i++) {
        for (let j = 0; j <= capacity; j++) {
            if (j < weights[i - 1]) {
                // 装不下当前物品 → 只能不选
                dp[i][j] = dp[i - 1][j];
            } else {
                // 装得下 → 选或不选取最大值
                dp[i][j] = Math.max(
                    dp[i - 1][j],                              // 不选
                    dp[i - 1][j - weights[i - 1]] + values[i - 1]  // 选
                );
            }
        }
    }
    return dp[n][capacity];
};

空间优化版(一维数组,倒序遍历):

const knapsack01Optimized = (weights: number[], values: number[], capacity: number): number => {
    const dp: number[] = new Array(capacity + 1).fill(0);

    for (let i = 0; i < weights.length; i++) {
        for (let j = capacity; j >= weights[i]; j--) {  // ⚠️ 倒序!防止重复使用
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
};

💡 为什么倒序?因为 dp[j - weights[i]] 是上一轮(i-1)的值,如果正序会被本轮覆盖。


框架三:完全背包

核心: 每件物品可用无限次。与 01 背包的唯一区别:内层正序遍历

const knapsackComplete = (weights: number[], values: number[], capacity: number): number => {
    const dp: number[] = new Array(capacity + 1).fill(0);

    for (let i = 0; i < weights.length; i++) {
        for (let j = weights[i]; j <= capacity; j++) {  // ⚠️ 正序!允许重复使用
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
};

01背包 vs 完全背包对比:

01 背包 完全背包
内层遍历 j = capacity; j >= w; j--(倒序) j = w; j <= capacity; j++(正序)
物品使用 每件最多 1 次 每件无限次
原因 倒序保证 dp[j-w] 是上一轮的值 正序允许本轮更新覆盖

框架四:二维网格 DP

核心: dp[i][j] 表示从起点到 (i,j) 的最优值,只能向右/向下移动。

const gridDP = (grid: number[][]): number => {
    const m = grid.length, n = grid[0].length;
    // ① 定义:dp[i][j] = 从 (0,0) 到 (i,j) 的最小路径和
    const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(0));

    // ② 初始化:起点 + 第一行 + 第一列
    dp[0][0] = grid[0][0];
    for (let j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
    for (let i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];

    // ③ 递推:当前位置 = min(上, 左) + 当前格
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
        }
    }
    return dp[m - 1][n - 1];
};

框架五:双串 DP(LCS 模板)

核心: dp[i][j] = 字符串 A 的前 i 个字符 与 字符串 B 的前 j 个字符 的关系。

const LCS = (text1: string, text2: string): number => {
    const m = text1.length, n = text2.length;
    // ① 定义:dp[i][j] = text1[0..i-1] 与 text2[0..j-1] 的 LCS 长度
    const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

    // ② 递推:遍历两个字符串
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] === text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;     // 字符相等:↖ + 1
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);  // 不等:max(←, ↑)
            }
        }
    }
    return dp[m][n];
};

框架六:区间 DP

核心: dp[i][j] 表示区间 [i, j] 的状态,按区间长度递增遍历。

const intervalDP = (s: string): number => {
    const n = s.length;
    // dp[i][j] = 区间 [i, j] 是否为回文
    const dp: boolean[][] = Array.from({ length: n }, () => new Array(n).fill(false));

    // 初始化:单个字符
    for (let i = 0; i < n; i++) dp[i][i] = true;

    // 按长度递增遍历
    for (let len = 2; len <= n; len++) {
        for (let i = 0; i + len - 1 < n; i++) {
            const j = i + len - 1;
            if (s[i] === s[j]) {
                dp[i][j] = len === 2 || dp[i + 1][j - 1];
            }
        }
    }
    return dp[0][n - 1];  // 或其他结果
};

三、典型例题(由简到难)

🟢 线性 DP 入门

1. 爬楼梯(LeetCode 70)

问题: 每次爬 1 或 2 阶,爬到第 n 阶有多少种方法?

思路:

  • dp[i] = 爬到第 i 阶的方法数
  • 最后一步走 1 阶:dp[i-1];最后一步走 2 阶:dp[i-2]
  • 状态转移:dp[i] = dp[i-1] + dp[i-2]
  • 初始:dp[1] = 1, dp[2] = 2

基础版(DP 数组):

const climbStairs = (n: number): number => {
    if (n <= 2) return n;
    const dp: number[] = 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];
};

空间优化版(滚动变量):

const climbStairsOptimized = (n: number): number => {
    if (n <= 2) return n;
    let prev2 = 1;   // dp[i-2]
    let prev1 = 2;   // dp[i-1]

    for (let i = 3; i <= n; i++) {
        const cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
};

💡 dp[i] 只依赖 dp[i-1]dp[i-2],所以两个变量就够了。


2. 斐波那契数(LeetCode 509)

问题: 计算 F(n),其中 F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)

// 基础版
const fib = (n: number): number => {
    if (n <= 1) return n;
    const dp: number[] = new Array(n + 1);
    dp[0] = 0;
    dp[1] = 1;

    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
};

// 空间优化版
const fibOptimized = (n: number): number => {
    if (n <= 1) return n;
    let prev2 = 0, prev1 = 1;
    for (let i = 2; i <= n; i++) {
        const cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
};

3. 使用最小花费爬楼梯(LeetCode 746)

问题: 每阶有花费 cost[i],可从第 0 或第 1 阶开始,每次爬 1 或 2 阶,求到顶部的最小总花费。

思路:

  • dp[i] = 到达第 i 阶的最小花费
  • 可以从 i-1 爬 1 阶:dp[i-1] + cost[i-1]
  • 可以从 i-2 爬 2 阶:dp[i-2] + cost[i-2]
  • 状态转移:dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
  • 初始:dp[0] = 0, dp[1] = 0(起点不花费)
// 基础版
const minCostClimbingStairs = (cost: number[]): number => {
    const n = cost.length;
    const dp: number[] = new Array(n + 1);
    dp[0] = 0;  // 从第0阶开始,不花费
    dp[1] = 0;  // 从第1阶开始,不花费

    for (let i = 2; i <= n; i++) {
        dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
    }
    return dp[n];
};

// 空间优化版
const minCostClimbingStairsOptimized = (cost: number[]): number => {
    const n = cost.length;
    let prev2 = 0, prev1 = 0;

    for (let i = 2; i <= n; i++) {
        const cur = Math.min(prev1 + cost[i - 1], prev2 + cost[i - 2]);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
};

4. 打家劫舍(LeetCode 198)

问题: 不能偷相邻的房屋,求能偷到的最大金额。

思路:

  • dp[i] = 偷到第 i 间时的最大金额
  • 偷当前:dp[i-2] + nums[i]
  • 不偷当前:dp[i-1]
  • 状态转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
// 基础版
const rob = (nums: number[]): number => {
    const n = nums.length;
    if (n === 0) return 0;
    if (n === 1) return nums[0];

    const dp: number[] = new Array(n);
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);

    for (let i = 2; i < n; i++) {
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    return dp[n - 1];
};

// 空间优化版
const robOptimized = (nums: number[]): number => {
    if (nums.length === 0) return 0;
    if (nums.length === 1) return nums[0];

    let prev2 = nums[0];
    let prev1 = Math.max(nums[0], nums[1]);

    for (let i = 2; i < nums.length; i++) {
        const cur = Math.max(prev1, prev2 + nums[i]);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
};

执行过程(nums=[2,7,9,3,1]):

dp[0]=2       偷[2] → 2
dp[1]=7       偷[2,7] → max(2,7)=7
dp[2]=11      偷[2,7,9] → max(7, 2+9)=11
dp[3]=11      偷[2,7,9,3] → max(11, 7+3)=11
dp[4]=12      偷[2,7,9,3,1] → max(11, 11+1)=12

答案:12(偷 2→9→1 或 7→3→1)

🟡 二维网格 DP

5. 不同路径(LeetCode 62)

问题: m × n 网格,从左上角到右下角,只能向右/向下,有多少条不同路径?

思路:

  • dp[i][j] = 从 (0,0)(i,j) 的路径数
  • 只能从上方或左方来:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  • 第一行和第一列都只有 1 条路径
// 基础版(二维数组)
const uniquePaths = (m: number, n: number): number => {
    const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(1));

    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[m - 1][n - 1];
};

// 空间优化版(一维数组)
const uniquePathsOptimized = (m: number, n: number): number => {
    const dp: number[] = new Array(n).fill(1);

    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[j] = dp[j] + dp[j - 1];  // dp[j] = 上方值, dp[j-1] = 左方值
        }
    }
    return dp[n - 1];
};

6. 最小路径和(LeetCode 64)

问题: m × n 非负网格,从左上到右下,只能向右/向下,求最小路径和。

思路:

  • dp[i][j] = 到 (i,j) 的最小路径和
  • 状态转移:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
// 基础版(二维数组)
const minPathSum = (grid: number[][]): number => {
    const m = grid.length, n = grid[0].length;
    const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(0));

    // 初始化
    dp[0][0] = grid[0][0];
    for (let j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
    for (let i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];

    // 递推
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
        }
    }
    return dp[m - 1][n - 1];
};

// 空间优化版(一维数组)
const minPathSumOptimized = (grid: number[][]): number => {
    const m = grid.length, n = grid[0].length;
    const dp: number[] = new Array(n);

    dp[0] = grid[0][0];
    for (let j = 1; j < n; j++) dp[j] = dp[j - 1] + grid[0][j];

    for (let i = 1; i < m; i++) {
        dp[0] += grid[i][0];
        for (let j = 1; j < n; j++) {
            dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j];
        }
    }
    return dp[n - 1];
};

执行过程(grid=[[1,3,1],[1,5,1],[4,2,1]]):

网格:         DP表:
1 3 1        1  4  5
1 5 1   →    2  7  6
4 2 1        6  8  7
                      ↑ 答案=7
路径:1→3→1→1→1 = 7

🟠 背包问题

7. 分割等和子集(LeetCode 416)— 01 背包

问题: 能否将数组分成两个和相等的子集?

转化: 能否选出一些数,和为 sum/2?→ 01 背包(布尔版)

const canPartition = (nums: number[]): boolean => {
    const sum = nums.reduce((a, b) => a + b, 0);
    if (sum % 2 !== 0) return false;  // 奇数无法平分

    const target = sum / 2;
    // dp[j] = 能否选出和为 j 的子集
    const dp: boolean[] = new Array(target + 1).fill(false);
    dp[0] = true;  // 和为 0 总是可以(不选任何数)

    for (const num of nums) {
        for (let j = target; j >= num; j--) {  // 01背包 → 倒序
            dp[j] = dp[j] || dp[j - num];
        }
    }
    return dp[target];
};

执行过程(nums=[1,5,11,5], sum=22, target=11):

初始化: dp[0]=true, 其余 false

处理 num=1:
  j=11→1: dp[1]=true(选了1)

处理 num=5:
  j=11→5: dp[6]=true(1+5), dp[5]=true

处理 num=11:
  j=11→11: dp[11]=true ✅ 找到!

8. 零钱兑换(LeetCode 322)— 完全背包

问题: 每种硬币无限用,凑出 amount 的最少硬币数。

思路: 完全背包(最小值版),正序遍历。

const coinChange = (coins: number[], amount: number): number => {
    // dp[j] = 凑出金额 j 所需的最少硬币数
    const dp: number[] = new Array(amount + 1).fill(Infinity);
    dp[0] = 0;  // 凑出 0 元需要 0 个硬币

    for (const coin of coins) {
        for (let j = coin; j <= amount; j++) {  // 完全背包 → 正序
            dp[j] = Math.min(dp[j], dp[j - coin] + 1);
        }
    }
    return dp[amount] === Infinity ? -1 : dp[amount];
};

执行过程(coins=[1,2,5], amount=11):

初始化: dp[0]=0, 其余=∞

处理 coin=1(遍历 j=1→11):
  dp[1]=1, dp[2]=2, dp[3]=3, ..., dp[11]=11

处理 coin=2(遍历 j=2→11):
  dp[2]=min(2, dp[0]+1=1)=1
  dp[3]=min(3, dp[1]+1=2)=2
  ...

处理 coin=5(遍历 j=5→11):
  dp[5]=min(5, dp[0]+1=1)=1
  dp[6]=min(6, dp[1]+1=2)=2
  ...
  dp[11]=min(11, dp[6]+1=3)=3

答案:3(5+5+1 或 5+2+2+2)

9. 零钱兑换 II(LeetCode 518)— 组合数

问题: 每种硬币无限用,凑出 amount 的组合数(顺序无关)。

思路: 完全背包(计数版)。⚠️ 外层遍历硬币保证是组合数,而非排列数。

const change = (amount: number, coins: number[]): number => {
    // dp[j] = 凑出金额 j 的组合数
    const dp: number[] = new Array(amount + 1).fill(0);
    dp[0] = 1;  // 凑出 0 元有 1 种方式(不选)

    for (const coin of coins) {              // 外层:硬币
        for (let j = coin; j <= amount; j++) {  // 内层:金额(正序)
            dp[j] += dp[j - coin];
        }
    }
    return dp[amount];
};

💡 为什么外层是硬币? 保证硬币按顺序使用(1→2→5),避免 [1,2][2,1] 被重复计数。


🔴 子序列 DP

10. 最长递增子序列 LIS(LeetCode 300)

问题: 求数组中最长严格递增子序列的长度(不要求连续)。

思路:

  • dp[i] = 以 nums[i] 结尾的最长递增子序列长度
  • 对于每个 j < i,如果 nums[i] > nums[j],则可以接在 j 后面
  • 状态转移:dp[i] = max(dp[j] + 1) 对所有满足条件的 j
const lengthOfLIS = (nums: number[]): number => {
    const n = nums.length;
    // dp[i] = 以 nums[i] 结尾的 LIS 长度
    const dp: number[] = new Array(n).fill(1);  // 每个元素自身长度为1
    let maxLen = 1;

    for (let i = 1; i < n; i++) {
        for (let j = 0; j < i; j++) {
            if (nums[i] > nums[j]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        maxLen = Math.max(maxLen, dp[i]);
    }
    return maxLen;
};

执行过程(nums=[10,9,2,5,3,7,101,18]):

i=0: nums[0]=10, dp[0]=1
i=1: nums[1]=9,  9<10 → dp[1]=1
i=2: nums[2]=2,  2<9,2<10 → dp[2]=1
i=3: nums[3]=5,  5>2 → dp[3]=dp[2]+1=2
i=4: nums[4]=3,  3>2 → dp[4]=dp[2]+1=2
i=5: nums[5]=7,  7>2→2, 7>5→3, 7>3→3 → dp[5]=3
i=6: nums[6]=101, 101>all → dp[6]=max(dp[0..5])+1=4
i=7: nums[7]=18, 18>... → dp[7]=max(dp[0..6]中<18的)+1=4

答案:4(子序列 [2,5,7,101] 或 [2,3,7,101])

11. 最长公共子序列 LCS(LeetCode 1143)

问题: 求两个字符串的最长公共子序列长度(不要求连续,保持原顺序)。

思路:

  • dp[i][j] = text1[0..i-1]text2[0..j-1] 的 LCS 长度
  • 字符相等:dp[i][j] = dp[i-1][j-1] + 1(↖ + 1)
  • 字符不等:dp[i][j] = max(dp[i-1][j], dp[i][j-1])(← 或 ↑ 取最大)
const longestCommonSubsequence = (text1: string, text2: string): number => {
    const m = text1.length, n = text2.length;
    const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] === text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;  // 字符相等:↖ + 1
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);  // 不等:取最大
            }
        }
    }
    return dp[m][n];
};

DP 表演示(text1=“abcde”, text2=“ace”):

      "" a  c  e
  ""  0  0  0  0
  a   0  1  1  1    ← a==a: 1+0=1
  b   0  1  1  1    ← b≠a,b≠c,b≠e → max(1,1)=1
  c   0  1  2  2    ← c==c: 1+1=2
  d   0  1  2  2
  e   0  1  2  3    ← e==e: 1+2=3
                      ↑ 答案=3 (子序列 "ace")

12. 最长回文子串(LeetCode 5)

问题: 求字符串中最长的回文子串。

思路: 区间 DP。dp[i][j] = s[i..j] 是否为回文。

  • 按子串长度递增遍历
  • s[i]==s[j] 且 (len==2dp[i+1][j-1]==true)
const longestPalindrome = (s: string): string => {
    const n = s.length;
    // dp[i][j] = s[i..j] 是否为回文
    const dp: boolean[][] = Array.from({ length: n }, () => new Array(n).fill(false));
    let start = 0, maxLen = 1;

    // 初始化:所有单个字符都是回文
    for (let i = 0; i < n; i++) {
        dp[i][i] = true;
    }

    // 按子串长度递增遍历
    for (let len = 2; len <= n; len++) {
        for (let i = 0; i + len - 1 < n; i++) {
            const j = i + len - 1;

            if (s[i] === s[j]) {
                if (len === 2) {
                    dp[i][j] = true;           // 两个相同字符 → 回文
                } else {
                    dp[i][j] = dp[i + 1][j - 1];  // 依赖内部子串
                }
            }

            if (dp[i][j] && len > maxLen) {
                start = i;
                maxLen = len;
            }
        }
    }
    return s.substring(start, start + maxLen);
};

执行过程(s=“babad”):

len=1: b,a,b,a,d → 全部 true
len=2: ba(false), ab(false), ba(false), ad(false) → 无
len=3: bab: s[0]=s[2]='b' && dp[1][1]=true → true ✅ (len=3)
       aba: s[1]=s[3]='a' && dp[2][2]=true → true ✅ (len=3)
len=4: baba: s[0]=s[3]='a'='a'? → s[0]='b'≠'a' → false
len=5: babad: s[0]='b'≠'d' → false

答案:"bab"(或 "aba")

13. 编辑距离(LeetCode 72)

问题: 求将 word1 转换为 word2 的最少操作数(插入、删除、替换各算 1 次)。

思路:

  • dp[i][j] = word1[0..i-1] 转换为 word2[0..j-1] 的最小操作数
  • 字符相等:dp[i][j] = dp[i-1][j-1](无需操作)
  • 字符不等:三种操作取最小 + 1
    • 删除 word1[i-1]dp[i-1][j] + 1
    • 插入 word2[j-1]dp[i][j-1] + 1
    • 替换:dp[i-1][j-1] + 1
const minDistance = (word1: string, word2: string): number => {
    const m = word1.length, n = word2.length;
    // dp[i][j] = word1[0..i-1] → word2[0..j-1] 的最少操作数
    const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

    // 初始化:空字符串转换的边界
    for (let i = 0; i <= m; i++) dp[i][0] = i;  // 删除 i 次
    for (let j = 0; j <= n; j++) dp[0][j] = j;  // 插入 j 次

    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (word1[i - 1] === word2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];  // 相等,无需操作
            } else {
                dp[i][j] = 1 + Math.min(
                    dp[i - 1][j],      // 删除 word1[i-1]
                    dp[i][j - 1],      // 插入 word2[j-1]
                    dp[i - 1][j - 1]   // 替换
                );
            }
        }
    }
    return dp[m][n];
};

执行过程(word1=“horse”, word2=“ros”):

      "" r  o  s
  ""  0  1  2  3
  h   1  1  2  3   ← h≠r → min(删h, 插r, 替h→r)=1
  o   2  2  1  2   ← o==o → dp[1][1]=1
  r   3  2  2  2   ← r==r → dp[2][1]=2
  s   4  3  3  2   ← s≠o → min(删,插,替)=2
  e   5  4  4  3   ← e≠s → min=3
                      ↑ 答案=3
操作:horse → rorse(替h→r) → rose(删r) → ros(删e)

四、总结

题目速查表

# 题目 DP 类型 状态转移
1 爬楼梯 线性 DP dp[i] = dp[i-1] + dp[i-2]
2 斐波那契 线性 DP dp[i] = dp[i-1] + dp[i-2]
3 最小花费爬楼梯 线性 DP dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
4 打家劫舍 线性 DP dp[i] = max(dp[i-1], dp[i-2]+nums[i])
5 不同路径 二维网格 DP dp[i][j] = dp[i-1][j] + dp[i][j-1]
6 最小路径和 二维网格 DP dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
7 分割等和子集 01 背包 dp[j] = dp[j] || dp[j-num](倒序)
8 零钱兑换 完全背包 dp[j] = min(dp[j], dp[j-coin]+1)(正序)
9 零钱兑换 II 完全背包 dp[j] += dp[j-coin](正序,外层硬币)
10 最长递增子序列 子序列 DP dp[i] = max(dp[j]+1)(对所有 j<i, nums[j]<nums[i]
11 最长公共子序列 双串 DP dp[i][j] = (相等?↖+1 : max(←,↑))
12 最长回文子串 区间 DP dp[i][j] = s[i]==s[j] && dp[i+1][j-1]
13 编辑距离 双串 DP dp[i][j] = min(删,插,替) + 1

统一框架回顾

DP 类型 特征 遍历方式
线性 DP dp[i] 依赖前几个 正向遍历
01 背包 每件最多 1 次 外层物品,内层容量倒序
完全背包 每件无限次 外层物品,内层容量正序
二维网格 dp[i][j],只向右/下 i 从 0→m, j 从 0→n
双串 DP 两个字符串/数组 双层循环,i+1j+1
区间 DP dp[i][j] 表示区间 区间长度递增遍历

背包问题速记

01 背包 完全背包
内层遍历 for (j=cap; j>=w; j--) for (j=w; j<=cap; j++)
核心公式 dp[j] = max(dp[j], dp[j-w] + v) 同左,但正序
求组合数 外层物品,内层金额 外层物品,内层金额
求排列数 外层金额,内层物品

记忆口诀

  • 线性递推:看前两三个状态
  • 01 背包倒着走,完全背包正着走
  • 二维网格:上 + 左,取 min/max + 当前
  • 双串 DP:相等 ↖+1,不等 max(←,↑)
  • 区间 DP:按长度递增,dp[i][j] 依赖 dp[i+1][j-1]
  • 组合外层是物品,排列外层是金额

关联题库

以下题目与本文知识点相关,可以跳转到题库练习: