动态规划完全指南:从入门到面试 10 道经典题型

动态规划(DP)是算法面试里出现频率最高的家族,也是新手最容易"看懂题解但自己想不出来"的题型。原因不在难,而在大家学的是"题目 + 答案",而不是从一个朴素递归一步步推导到 DP的过程。这篇文章给你这套推导框架,然后用它打通从入门到 LeetCode 中等/困难的常见题型。

DP 的本质:有重叠子问题的递归 + 记忆化

动态规划只解决一类问题:原问题可以拆成更小的同类子问题,且这些子问题会被重复计算。"重复计算"是关键,不重复就用不到 DP,只是普通分治。

最经典的入门例子是斐波那契:

// 朴素递归:指数级时间复杂度
function fib(n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}
// fib(40) 已经要算几秒,fib(50) 几乎跑不出来

慢的原因:fib(38) 被算了很多次。把每个 n 的结果缓存起来,瞬间从指数变线性:

// 自顶向下:递归 + 记忆化
function fibMemo(n, memo = {}) {
    if (n <= 1) return n;
    if (memo[n] !== undefined) return memo[n];
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}

// 自底向上:迭代,用数组存所有子问题答案
function fibDP(n) {
    if (n <= 1) return n;
    const dp = [0, 1];
    for (let i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
    return dp[n];
}

// 滚动数组:只用两个变量
function fibBest(n) {
    if (n <= 1) return n;
    let a = 0, b = 1;
    for (let i = 2; i <= n; i++) [a, b] = [b, a + b];
    return b;
}

"朴素递归 → 加记忆化 → 改迭代 → 压缩空间"是 DP 题的标准演进路径。会写朴素递归是第一道闸,后面三步都是机械优化。

解 DP 题的固定四步法

  1. 定义状态:dp[i] / dp[i][j] 表示什么?这是最关键的一步,直接决定能不能做出来。
  2. 写状态转移方程:dp[i] 怎么从 dp[更小的 i] 算出来?
  3. 确定初始值和遍历顺序:边界 dp[0] 是什么?是按 i 从小到大还是从大到小?
  4. (可选)优化空间:如果只用到前 k 个状态,用滚动数组。

一维 DP:爬楼梯、打家劫舍、最大子数组和

爬楼梯

每次能爬 1 或 2 阶,爬到 n 阶有多少种方法?

// 状态:dp[i] = 爬到第 i 阶的方法数
// 转移:dp[i] = dp[i-1] + dp[i-2]
//      (从 i-1 阶迈 1 步 或 从 i-2 阶迈 2 步)
// 初始:dp[0]=1, dp[1]=1
function climbStairs(n) {
    if (n <= 1) return 1;
    let a = 1, b = 1;
    for (let i = 2; i <= n; i++) [a, b] = [b, a + b];
    return b;
}

打家劫舍

一排房子,nums[i] 是第 i 间的钱,不能偷相邻两间,问最多能偷多少。

// 状态:dp[i] = 考虑前 i 间房,能偷到的最大金额
// 转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
//        不偷第 i 间        偷第 i 间(那么 i-1 不能偷)
function rob(nums) {
    let prev = 0, curr = 0;
    for (const x of nums) {
        [prev, curr] = [curr, Math.max(curr, prev + x)];
    }
    return curr;
}

最大子数组和(Kadane 算法)

// 状态:dp[i] = 以 nums[i] 结尾的最大子数组和
// 注意"以 i 结尾"是关键技巧 —— 让相邻状态有清晰联系
// 转移:dp[i] = max(nums[i], dp[i-1] + nums[i])
//             单独自己      接到前面的子数组
// 答案:max(dp)
function maxSubArray(nums) {
    let curr = nums[0], best = nums[0];
    for (let i = 1; i < nums.length; i++) {
        curr = Math.max(nums[i], curr + nums[i]);
        best = Math.max(best, curr);
    }
    return best;
}

二维 DP:网格路径、最长公共子序列

不同路径

m×n 网格,从左上走到右下,只能向右或向下,有多少种路径?

// 状态:dp[i][j] = 到 (i,j) 的路径数
// 转移:dp[i][j] = dp[i-1][j] + dp[i][j-1]
// 初始:第一行第一列都是 1
function uniquePaths(m, n) {
    const dp = Array(n).fill(1);   // 第一行
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[j] += dp[j - 1];    // dp[j] 现在是上一行,dp[j-1] 是同一行
        }
    }
    return dp[n - 1];
}

最长公共子序列 LCS

给两个字符串,找最长的公共子序列(不必连续)的长度。这是字符串 DP 的祖师爷。

// 状态:dp[i][j] = s1 前 i 个字符 和 s2 前 j 个字符 的 LCS 长度
// 转移:
//   s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] + 1
//   否则:               dp[i][j] = max(dp[i-1][j], dp[i][j-1])
function lcs(s1, s2) {
    const m = s1.length, n = s2.length;
    const dp = 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 (s1[i - 1] === s2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
    return dp[m][n];
}
console.log(lcs('abcde', 'ace'));   // 3 ('ace')

LCS 是模板题:编辑距离、最长公共子串、最短编辑路径都是它的变种。

编辑距离(Levenshtein 距离)

把 word1 变成 word2 最少需要多少次操作(插入/删除/替换)?

// 状态:dp[i][j] = word1 前 i 个变成 word2 前 j 个的最少操作数
// 转移:
//   word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1]
//   否则:dp[i][j] = 1 + min(
//     dp[i-1][j],     // 删除 word1[i-1]
//     dp[i][j-1],     // 在 word1 末尾插入 word2[j-1]
//     dp[i-1][j-1]    // 把 word1[i-1] 替换成 word2[j-1]
//   )
function editDistance(w1, w2) {
    const m = w1.length, n = w2.length;
    const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
    for (let i = 0; i <= m; i++) dp[i][0] = i;
    for (let j = 0; j <= n; j++) dp[0][j] = j;
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (w1[i - 1] === w2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
            else dp[i][j] = 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
        }
    }
    return dp[m][n];
}

背包问题:DP 里最重要的家族

0-1 背包和完全背包是几乎所有"选/不选"类问题的母题。

0-1 背包

有 n 个物品,第 i 个重量 w[i]、价值 v[i],背包容量 C,每个物品只能选一次,求最大总价值。

// 状态:dp[i][c] = 考虑前 i 个物品、容量 c 时的最大价值
// 转移:
//   不选第 i 个:        dp[i][c] = dp[i-1][c]
//   选(且装得下):      dp[i][c] = dp[i-1][c - w[i-1]] + v[i-1]
// 取两者较大
function knapsack(w, v, C) {
    const n = w.length;
    const dp = Array.from({ length: n + 1 }, () => new Array(C + 1).fill(0));
    for (let i = 1; i <= n; i++) {
        for (let c = 0; c <= C; c++) {
            dp[i][c] = dp[i - 1][c];
            if (c >= w[i - 1]) {
                dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - w[i - 1]] + v[i - 1]);
            }
        }
    }
    return dp[n][C];
}

// 空间压缩到一维:注意 c 必须倒序遍历(否则会重复使用同一物品)
function knapsack1D(w, v, C) {
    const dp = new Array(C + 1).fill(0);
    for (let i = 0; i < w.length; i++) {
        for (let c = C; c >= w[i]; c--) {     // 倒序!
            dp[c] = Math.max(dp[c], dp[c - w[i]] + v[i]);
        }
    }
    return dp[C];
}

完全背包(每个物品无限选)

// 唯一改动:内层 c 改成"正序"遍历,允许重复使用同一物品
function completeKnapsack(w, v, C) {
    const dp = new Array(C + 1).fill(0);
    for (let i = 0; i < w.length; i++) {
        for (let c = w[i]; c <= C; c++) {     // 正序!
            dp[c] = Math.max(dp[c], dp[c - w[i]] + v[i]);
        }
    }
    return dp[C];
}

"0-1 倒序、完全正序"是背包问题最值得记住的一句口诀。

零钱兑换(完全背包变形)

给定面额 coins,凑成 amount 所需的最少硬币数。

function coinChange(coins, amount) {
    const dp = new Array(amount + 1).fill(Infinity);
    dp[0] = 0;
    for (const c of coins) {
        for (let i = c; i <= amount; i++) {
            dp[i] = Math.min(dp[i], dp[i - c] + 1);
        }
    }
    return dp[amount] === Infinity ? -1 : dp[amount];
}

区间 DP:最长回文子序列

区间 DP 的状态通常是 dp[i][j],表示"区间 [i,j] 的某个最优值",转移依赖更小的区间。

// 最长回文子序列
// 状态:dp[i][j] = s 在 [i,j] 内的最长回文子序列长度
// 转移:
//   s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2
//   否则:        dp[i][j] = max(dp[i+1][j], dp[i][j-1])
// 注意:i 倒序、j 正序,因为依赖更小区间
function longestPalindromeSubseq(s) {
    const n = s.length;
    const dp = Array.from({ length: n }, () => new Array(n).fill(0));
    for (let i = n - 1; i >= 0; i--) {
        dp[i][i] = 1;
        for (let j = i + 1; j < n; j++) {
            if (s[i] === s[j]) dp[i][j] = dp[i + 1][j - 1] + 2;
            else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
    return dp[0][n - 1];
}

状态机 DP:股票买卖系列

"买卖股票的最佳时机 IV"是状态机 DP 的代表 —— 用一个二维状态(第几天,当前持/不持)精确刻画行为。

// 一次买卖(只能交易一次)
function maxProfit(prices) {
    let minPrice = Infinity, best = 0;
    for (const p of prices) {
        minPrice = Math.min(minPrice, p);
        best = Math.max(best, p - minPrice);
    }
    return best;
}

// 不限交易次数(但同一天不能买卖,且需要先卖才能买)
function maxProfitUnlimited(prices) {
    let hold = -Infinity, cash = 0;
    for (const p of prices) {
        const newHold = Math.max(hold, cash - p);    // 继续持有 or 今天买入
        const newCash = Math.max(cash, hold + p);    // 继续空仓 or 今天卖出
        [hold, cash] = [newHold, newCash];
    }
    return cash;
}

怎么判断一道题是不是 DP

面试时拿到题,问自己三个问题:

  1. 题目要"最值/计数/可行性"吗?(最长、最短、最大、有多少种、能不能)
  2. 能不能分解成"前 i 个的最优解 + 第 i+1 个的决策"?
  3. 同一个子问题会被多次需要吗?

三个都是"是",八九不离十是 DP。如果是"找一条路径""组合数学的精确公式""贪心局部最优能推出全局",那可能是别的算法。

记忆化递归:从朴素递归更平滑地过渡到 DP

有时候自顶向下的记忆化递归比自底向上的 DP 更好理解,尤其是当状态空间稀疏(很多子问题用不到)时它还更高效。

// 单词拆分:给定字典,问 s 能否被字典里的单词拼接成
function wordBreak(s, wordDict) {
    const set = new Set(wordDict);
    const memo = new Map();
    function can(i) {
        if (i === s.length) return true;
        if (memo.has(i)) return memo.get(i);
        for (let j = i + 1; j <= s.length; j++) {
            if (set.has(s.slice(i, j)) && can(j)) {
                memo.set(i, true);
                return true;
            }
        }
        memo.set(i, false);
        return false;
    }
    return can(0);
}

这种"先写朴素递归,然后给入口函数加一个 memo Map"的模式,几乎所有 DP 题都适用。它的好处是写法贴近你解决问题的思路,缺点是有递归调用栈开销。

树形 DP 入门:打家劫舍 III

把房子从一排变成一棵二叉树,相邻节点(父子)不能同时偷,问最多偷多少。

// 状态:每个节点返回 [不偷该节点的最大值, 偷该节点的最大值]
// 转移:
//   不偷:左右子树都可偷可不偷,各取较大
//   偷:  左右子树必须都不偷,加上当前节点的值
function rob3(root) {
    function dfs(node) {
        if (!node) return [0, 0];
        const [ln, lp] = dfs(node.left);
        const [rn, rp] = dfs(node.right);
        const notRob = Math.max(ln, lp) + Math.max(rn, rp);
        const rob = ln + rn + node.val;
        return [notRob, rob];
    }
    return Math.max(...dfs(root));
}

树形 DP 的精髓:把"每个节点对外提供的状态"想清楚,然后递归收集左右子树的状态,合成自己对外提供的状态。一旦能写出"返回什么、依赖什么",代码自然就出来了。

常见易错点

错 1:状态定义忘了"以 i 结尾"。 "最长上升子序列"如果定义成"前 i 个的最长上升子序列长度",转移就写不出来 —— 因为 dp[i] 不一定包含 nums[i]。改成"以 nums[i] 结尾的最长上升子序列长度",转移立刻清晰。

错 2:背包问题维度混淆。 0-1 背包压一维必须倒序,完全背包必须正序 —— 顺序错了 AC 不了。记口诀比死记原理更可靠。

错 3:初始值边界考虑不全。 编辑距离的 dp[i][0] = idp[0][j] = j,如果都默认 0,转移方程立刻崩。每写完一个 DP,都手动跑一遍 i=0、j=0 的边界,确保不依赖未初始化的状态。

写在最后

动态规划没有"灵光一现"。每一道你看着惊艳的 DP 题解,背后都是"定义状态 → 写转移方程 → 确定边界和顺序 → 必要时压缩空间"这套机械流程。卡住的几乎永远是第一步 —— 状态定义错了,转移方程就没法写。多练几道一维二维的入门题,把状态定义那种"以 i 结尾""考虑前 i 个""[i,j] 区间内"的常用模板熟透,后面的题就只是模板的组合。

给的练习路径:爬楼梯 → 打家劫舍 → 最大子数组和 → 不同路径 → 最长公共子序列 → 编辑距离 → 0-1 背包 → 零钱兑换 → 最长回文子序列 → 股票买卖系列。这十道刷完且不看题解能独立写出,大部分 DP 中等题你都能拿下。困难题再加上"区间 DP""树形 DP""数位 DP"这几个专题就够了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

React Hooks 原理与陷阱:从 useState 到自定义 Hook

2026-5-15 10:55:54

技术教程

Linux 进程与信号全解:fork、exec、wait 与信号处理的实战指南

2026-5-15 11:04:01

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索