Skip to content

入门:最优子结构、重叠子问题与三要素

基于通用算法概念 · 核于 2026-07

速查

  • DP 是什么:一套「把大问题拆成语义相同的小问题、合并小问题解得到大问题解」的方法论——不是具体算法,是思想框架,核心抓住两个特征:最优子结构 + 重叠子问题
  • 最优子结构:大问题的最优解由子问题的最优解构成(如「爬到第 n 阶的方案数 = 爬到第 n-1 阶 + 爬到第 n-2 阶」)——是 DP / 分治能用的前提。
  • 重叠子问题:不同的递归分支会反复求解同一个子问题(如 fib(5) 会算 fib(3) 两次、fib(2) 三次)——是 DP 区别于分治的关键:分治的子问题不重叠(归并排序),DP 的子问题重叠,所以记下来避免重复算
  • 三要素:①状态定义dp[i] 代表什么);②状态转移方程dp[i] 怎么由更小的子问题推出);③边界条件(最小的、不依赖转移的那几个 dp 值)。
  • 两种实现自顶向下 = 记忆化递归(从大问题出发递归 + 用数组/哈希记已算结果,更贴近思考过程);自底向上 = 递推填表(从边界往大算,迭代填 dp 数组,常数更小、无递归栈)。两者等价,复杂度相同。
  • 与分治的区别:分治的子问题独立不重叠(归并排序左右两半各算各的),不需要记忆化;DP 的子问题重叠,必须记下来才省时间——这是本质分水岭。
  • 与贪心的区别:贪心每步选「当前最优」不回头,需严格证明局部最优能推出全局最优;DP 枚举所有子问题选择取最优,适用面更广但常数更大。能用贪心优先贪心(更快),不行再 DP。
  • 斐波那契是 DP 的 Hello Worldfib(n) = fib(n-1) + fib(n-2),暴力递归 O(2ⁿ),记忆化 / 递推后 O(n)——完美演示「重叠子问题 + 记忆化」的威力。
  • DP 不是万能:要求「无后效性」(当前状态之后的过程不会影响之前的状态),不满足(如带「历史路径」约束)就要扩展状态维度。
  • 进阶顺序背包问题与零钱兑换DP 设计方法与常见模型参考

一、核心思想:从暴力递归到 DP

理解 DP 最好的入口是斐波那契数列fib(1)=1, fib(2)=1, fib(n)=fib(n-1)+fib(n-2)。最朴素的写法是直接翻译递推式:

js
function fib(n) {
  if (n <= 2) return 1;             // 边界
  return fib(n - 1) + fib(n - 2);   // 递归
}

这个写法正确但慢得离谱fib(5) 会算 fib(4)+fib(3)fib(4) 又算 fib(3)+fib(2)——fib(3) 被算了两次,越往下重叠越多。整体复杂度是 O(2ⁿ)fib(50) 就算到天荒地老。

为什么慢?因为同一个子问题被反复求解。这就是 DP 抓住的第一个特征:重叠子问题

记忆化:记下来别重复算

既然子问题会重叠,那就把算过的结果存起来,下次直接查:

js
const memo = new Map();              // 记忆化表
function fib(n) {
  if (n <= 2) return 1;
  if (memo.has(n)) return memo.get(n);  // 算过就直接拿
  const r = fib(n - 1) + fib(n - 2);
  memo.set(n, r);                       // 记下来
  return r;
}

每个 fib(i) 只真正计算一次,共 n 个子问题,每个 O(1)——复杂度从 O(2ⁿ) 降到 O(n)。这就是「自顶向下记忆化」。

递推:从边界往大算

换个角度,直接从已知边界(fib(1)=1, fib(2)=1)往大填表:

js
function fib(n) {
  const dp = new Array(n + 1);
  dp[1] = 1; dp[2] = 1;              // 边界
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];   // 转移
  }
  return dp[n];
}

这就是「自底向上递推」——不需要递归栈,常数更小,还能滚动数组优化到 O(1) 空间。记忆化和递推是同一件事的两种写法,复杂度相同,选哪个看习惯和题目。

二、两个核心特征

DP 能用,必须同时满足两个特征:

  1. 最优子结构:大问题的(最优)解可以由子问题的(最优)解组合而成。爬楼梯:到第 n 阶的方案 = 到第 n-1 阶的方案 + 到第 n-2 阶的方案。背包:容量 w 下前 i 件物品的最大价值 = max(不装第 i 件,装第 i 件)。没有这个性质,DP 无从下手。
  2. 重叠子问题:不同的求解路径会反复遇到同一个子问题。斐波那契的 fib(3) 被多次调用;背包的「前 i 件、容量 w」会被不同物品组合反复查询。有了重叠,记忆化 / 递推才有意义——分治的子问题不重叠,记下来也没用。

一句话区分:有最优子结构 → 可以用 DP / 分治;再加上重叠子问题 → 用 DP(记忆化)才有收益

三、三要素:状态、转移、边界

任何 DP 题都可以拆成三件事:

  • 状态定义dp[...] 代表什么。这是最难也最关键的一步——状态定义错了,转移方程再漂亮也白搭。如斐波那契 dp[i] = 第 i 个斐波那契数;爬楼梯 dp[i] = 爬到第 i 阶的方案数。
  • 状态转移方程dp[i] 怎么由更小的子问题推出。这是 DP 的「灵魂」。fib(i) = fib(i-1) + fib(i-2)dp[i] = dp[i-1] + dp[i-2]
  • 边界条件:最小的、不依赖转移的那几个初始值。fib(1)=1, fib(2)=1dp[1]=1, dp[2]=2漏边界或边界写错是 DP 最常见的 bug

记住解题流程:先想暴力递归 → 观察重叠子问题 → 加记忆化 → (可选)改递推 → (可选)空间优化

四、记忆化 vs 递推:怎么选

维度自顶向下(记忆化递归)自底向上(递推填表)
思考方向从大问题出发,递归到边界从边界出发,迭代填到大
代码形态递归函数 + memofor 循环 + dp 数组
贴近直觉✅ 更贴近「人脑推导转移」需先想清计算顺序
子问题覆盖只算「真正用到」的子问题把所有子问题都算一遍(可能有些用不到)
常数 / 栈有递归栈开销,可能栈溢出无栈,常数更小
空间优化难滚动优化✅ 易做滚动数组 / 一维化

经验法则:新手先用记忆化把思路理顺(更不容易写错转移),再改递推拿常数优势;状态空间稀疏(很多子问题用不到)时用记忆化省时间;要空间优化时用递推

五、与分治、贪心的区别

范式子问题关系是否记忆化典型
分治独立、不重叠不需要归并排序、快排
DP重叠必须记斐波那契、背包、LCS
贪心不显式划分子问题,每步选当前最优不需要活动选择、Dijkstra
  • DP vs 分治:都「分而治之」,分水岭是子问题是否重叠。归并排序分两半,两半各算各的、互不重叠,所以 O(n log n) 就够,不需要记忆化;斐波那契两半严重重叠,不记就 O(2ⁿ)。
  • DP vs 贪心:贪心不回头,每步选局部最优,需严格证明「局部最优 ⇒ 全局最优」(如区间调度按结束时间排序)。DP 穷举所有子选择取最优,适用面更广但常数更大。能用贪心优先贪心(更快、代码更短),证不出贪心正确性就用 DP 兜底。

六、一个隐藏前提:无后效性

DP 还有个常被忽略的前提:无后效性——「当前状态之后的过程不会影响之前的状态」,即未来不依赖「怎么到达当前状态」的路径,只依赖当前状态本身。

  • 爬楼梯满足:dp[i] 只看 dp[i-1]dp[i-2],不关心你是 1 步还是 2 步爬到 i-1 的。
  • 反例:「从左上到右下,要求路径上恰好经过 k 个障碍」——光知道当前坐标不够,还得记「已经经过几个障碍」,状态要加一维 dp[i][j][k]。这就是「状态定义要包含所有影响未来的信息」。

不满足无后效性时,扩展状态维度把「历史信息」吸收进状态里,往往就能继续用 DP。

下一步

理解了 DP 的核心思想与三要素后,下一步是 DP 最经典的「Hello World」家族——背包问题与零钱兑换,它们把「选或不选」的状态转移演绎到极致,并演示二维压一维的空间优化技巧,见背包问题与零钱兑换