跳到主要内容
约 11 分钟3 张卡

动态规划

动态规划(DP)保存重复子问题的答案,再按依赖顺序组合成更大问题的答案。解题时,先定义一个状态究竟表示什么,再决定怎样存进 dp 数组。本文从股票问题入手,讲解状态、转移和初始化,再用字符串与背包问题说明计算顺序和空间压缩。

设计 DP 时,可以按这五个问题依次检查状态与转移:

  1. 哪些信息会影响后续选择,需要保存在状态中?
  2. 当前状态能由哪些合法操作得到?
  3. 空输入和不可达状态怎样初始化?
  4. 按什么顺序计算,才能读到需要的旧结果?
  5. 压缩空间后,怎样恢复具体方案?

1. 面试核心​

先记住这几条,再读正文
  • 状态完整性。 DP 状态必须包含决定后续合法选择的全部信息。只有后续选择与目标值都相同的子问题,才能复用同一状态结果。
  • 编辑距离。 编辑距离用两个字符串的前缀长度定义状态,末尾字符不同时比较插入、删除和替换的成本。卡 · 编辑距离
  • 子序列与子串。 最长公共子序列允许跳过字符,而公共子串要求字符在原字符串中连续出现。卡 · 最长公共子序列
  • 状态压缩。 背包 DP 压缩成一维数组后,0/1 背包按容量倒序更新,完全背包则按容量正序更新。卡 · 计算顺序
  • 初始值与不可达状态。 计数 DP 若把空方案视为一种合法方案,就将对应初值设为 1,并用不同标记区分不可达状态与合法零值。

2. 适用信号​

动态规划通过复用子问题结果减少重复计算。看到以下信号时,可以尝试用它建模:

  • 问题要求最优值、方案数或可达性。
  • 大问题能拆成规模更小的同类问题。
  • 递归搜索反复遇到相同的参数组合,也就是重叠子问题。
  • 当前选择对未来的影响能用有限的状态信息概括。

最优化问题还需要最优子结构,也就是原问题的最优解能由子问题的最优解组合得到。如果子问题几乎不重复,保存结果所能减少的计算就有限。

枚举全部方案时,仍需付出生成这些结果的成本,见回溯。

若局部选择经证明始终安全,可以用更简单的贪心方法。

3. 状态设计​

状态设计要回答:哪些不同经历可以共用同一个子问题答案?可以先用一句话定义它:

dp[i][s] 表示处理到位置 i、处于条件 s 时的最优值或方案数。

这句话需要写清处理范围、约束和结果含义。比如,股票问题只记录日期和最大利润会遗漏持仓情况。持股时可以卖出,未持股时才可以买入,所以两者的后续动作不同。

本文例子允许多次交易、至多持有一股,且无手续费或冷冻期:

cash[i] = 第 i 天结束时,未持股的最大利润
hold[i] = 第 i 天结束时,持有一股的最大利润

交易次数受限时,状态还需表达剩余次数。如果存在冷冻期,就要区分当前是否允许买入;如果存在手续费,则需要调整对应的买卖转移。状态只保留这些影响未来的条件,让不同历史可以合并到同一个子问题。

4. 状态转移​

状态转移说明当前答案怎样由已有答案得到。推导时,可以从“到达当前状态的最后一个动作”入手,列出所有合法来源。股票例子的转移如下:

cash[i] = max(cash[i-1], hold[i-1] + price[i])
hold[i] = max(hold[i-1], cash[i-1] - price[i])

今天未持股,可以是昨天就未持股,也可以是昨天持股、今天卖出。取两种结果的最大值,就得到 cash。今天持股同样有两种来源:继续持有昨日的股票,或用昨日未持股的利润买入。

这样按最后一步分类,就能检查是否漏掉合法选择。每条转移还需符合题目规则,避免把互相冲突的动作组合起来。

4.1 编辑距离​

编辑距离求一个字符串变成另一个字符串所需的最少操作数。这里允许插入、删除和替换,每次成本都为 1。

设 dp[i][j] 为 A 的前 i 个字符变成 B 的前 j 个字符的最少操作数。前缀就是从字符串开头截取的连续部分。两串需要使用同一种字符单位,转移比较的是两个前缀的末尾字符:

若 A[i-1] == B[j-1]:dp[i][j] = dp[i-1][j-1]
否则:dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
dp[0][j] = j,dp[i][0] = i

末尾相等时,保留这个字符即可,操作数来自左上角的更短前缀。末尾不同时,比较以下三种来源,取最小值再加一次操作:

  • 删除 A 的末尾字符,来自 dp[i-1][j]。
  • 在 A 的末尾插入 B 的末尾字符,来自 dp[i][j-1]。
  • 将 A 的末尾字符替换成 B 的末尾字符,来自 dp[i-1][j-1]。

空串变成长度 j 的前缀,需要 j 次插入;长度 i 的前缀变成空串,需要 i 次删除。比如,cat 变成 cut 只需替换 a,距离为 1。

两串长度为 m、n 时,时间与二维空间都为 O(mn)。只求距离时,当前格只依赖上一行和当前行左侧,可保留前后两行,将空间降为 O(n)。

4.2 最长公共子序列​

最长公共子序列允许跳过字符,但保留原来的先后顺序。设 dp[i][j] 为 A 的前 i 个字符与 B 的前 j 个字符的最长公共子序列长度:

若 A[i-1] == B[j-1]:dp[i][j] = dp[i-1][j-1] + 1
否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
dp[0][j] = dp[i][0] = 0

两个末尾字符相等时,可以把它接到更短前缀的公共子序列后,长度加 1。不同时,公共子序列至少要跳过一个末尾字符,所以比较上方和左方,取较大值。任一前缀为空时,共同长度为 0。

比如,abc 与 ac 的结果为 2,因为跳过 b 后得到 ac。时间和二维空间都为 O(mn),m、n 分别是两串长度。

最长公共子串要求连续,因此状态要记录以两个当前位置结尾的连续匹配长度。末尾不相等时,这段连续匹配就中断,状态归零。最长匹配可以在任意位置结束,所以答案取全表最大值。

5. 初始化​

初始化定义最小问题的答案,后续转移才能有起点。初值要由状态含义推导,常见的边界情况如下:

  • 计数问题若存在一个空方案,初值就是 1。
  • 恰好装满背包时,只有容量 0 初始可达,其余容量先标记为不可达。
  • 容量只要求不超过上限、收益非负时,可以用“不选物品”的收益 0 初始化。
  • 网格路径要处理第一行、第一列的前驱边界,也可在表外加一圈辅助位置来统一转移。

不可达标记常被称为哨兵值,用来区分合法答案与尚无方案的状态。使用它之前应先判断可达性,再参与加减。比如,用 Long.MIN_VALUE 表示负无穷时,减去正数会下溢。加正数虽然未溢出,也会生成一个伪造的有限值,破坏不可达标记。

也可以在证明运算范围后,用 Long.MIN_VALUE / 4 这样的有限负值预留算术余量。但仍需保证它与合法收益区分,并让不可达转移保持无效。预留余量只解决数值范围问题。

股票例子可以直接按首日情况初始化:未持股利润为 0,持股利润为买入价的负值。这样就避开了哨兵运算:

static long maxProfit(int[] prices) {
if (prices.length == 0) return 0;
long cash = 0;
long hold = -(long) prices[0];
for (int i = 1; i < prices.length; i++) {
long previousCash = cash;
cash = Math.max(cash, hold + prices[i]);
hold = Math.max(hold, previousCash - prices[i]);
}
return cash;
}

输入价格要求非负。[3,1,4] 返回 3,对应在价格 1 时买入、价格 4 时卖出。空数组直接返回 0。每个交易日只更新两个状态,所以时间为 O(n),辅助空间为 O(1)。

6. 计算顺序​

计算顺序要保证:读取某个状态时,它已经算好,而且仍是所需的那一轮结果。若 dp[i] 依赖 dp[i-1],就按 i 递增计算。二维网格依赖左方与上方时,也可以按行列递增。

0/1 背包规定每件物品最多用一次。压成一维数组后,转移需读取“尚未使用当前物品”的上一轮结果:

for (int i = 0; i < weights.length; i++) {
for (int c = limit; c >= weights[i]; c--) {
dp[c] = Math.max(dp[c], dp[c - weights[i]] + values[i]);
}
}

这里 weights 为正整数,values 为非负收益。dp[c] 表示容量最多为 c 时的最大收益,初值为 0。容量倒序更新时,较小容量还未被当前物品更新。所以,从 dp[c-weights[i]] 转移时,当前物品只会被加入一次。

比如,只有重量 2、收益 3 的一件物品,容量为 4。倒序更新的答案为 3。正序会先写 dp[2]=3,再读取这个新值,得到 dp[4]=6,相当于重复使用了该物品。完全背包允许同一物品重复使用,才采用这种正序转移。循环方向由状态依赖决定,需要连同状态含义一起解释。

7. 自顶向下与自底向上​

同一组状态和转移,可以用递归求值,也可以按顺序填表。两种方式的区别在于从哪些状态开始,以及怎样安排计算。

7.1 记忆化搜索​

记忆化搜索从目标递归,只计算实际到达的状态,并保存结果供再次访问时复用。它容易从暴力搜索改造,但要同时考虑递归深度。

缓存键需包含完整状态,“未计算”标记也需区别于合法答案,具体见递归。

7.2 表格递推​

表格递推按依赖顺序迭代填表,省去递归调用栈,也便于复用数组。即使最终使用表格,也可以先写递归关系,检查状态与来源是否完整。

两种方式在每个状态只求一次时,时间都等于全部转移工作的总和。若每条转移为 O(1),可以用状态数乘每个状态的转移数估算。

8. 空间压缩​

空间压缩通过覆盖已用完的结果,减少同时保留的状态。压缩前,先写清完整状态,再确认每个旧值最后在哪里使用。

当前行只依赖上一行时,可复用两行数组。如果只依赖几个标量,就可以用变量滚动更新。最长公共子序列(LCS)若压成一行,还需保存即将被覆盖的左上角旧值。背包则通过循环方向,保证读到所需的上一轮或本轮结果。

股票例子保存 previousCash,表示昨日未持股的利润。在本文无手续费、无冷冻期的模型中,改用新 cash 也会得到相同数值。因为当天先卖再买回,只会回到原来的持股利润。保留旧值则能让代码直接对应递推式,交易规则变化后也便于核对。

压缩会让状态的读取顺序更难辨认,也会覆盖恢复方案所需的前驱信息。若要返回具体选择,可以另存决策,或保留原表逆推;也可以按需重新计算。面试时应先解释清楚完整状态,再说明哪些结果已经用完、可以覆盖。

9. 转移完整性​

转移式写出后,还要检查它是否覆盖所有合法方案。可以逐项核对以下五个条件:

  1. 状态覆盖:每个合法解都能由某个终态表达。
  2. 来源完整:到达当前状态的最后一步,都落在列出的转移中。
  3. 来源合法:每条转移都满足题目约束。
  4. 无后效性:状态保留的信息足以决定未来,省略的历史不再影响后续选择。
  5. 计算有序:前驱已经计算完成,读取的是转移要求的那一轮结果。

若其中一项无法解释,应回到状态定义和转移重新推导。小规模穷举还可以作为基准,检查转移是否遗漏或多算了方案。

10. 常见问题​

下面补充状态数量、方案恢复与数值范围的判断,并说明 DP 和贪心的关系。

10.1 动态规划与贪心​

DP 保留多个状态并比较合法来源,贪心则在每一步保留一个局部选择,需证明舍弃其他选择是安全的。一个问题可以同时有 DP 和贪心解法,具体证明方法见贪心。

10.2 状态维度​

10.3 方案恢复​

10.4 数值范围​

11 · 面试题卡片​

11.1 编辑距离的状态和转移如何定义?​

出现公司:字节跳动

考察重点:解释两个前缀怎样定义子问题,并推导三类操作的前驱、空串边界与空间压缩条件。

相关内容:编辑距离。

参考回答

定义 dp[i][j] 为 A 的前 i 个字符变成 B 的前 j 个字符的最少操作数。

末尾相等时,保留该字符,取 dp[i-1][j-1]。末尾不同时,比较以下三个前驱,取最小值再加 1:

  • 删除来自 dp[i-1][j]。
  • 插入来自 dp[i][j-1]。
  • 替换来自 dp[i-1][j-1]。

空串变成长度 j 的前缀需 j 次插入,长度 i 的前缀变成空串需 i 次删除。时间与二维空间都为 O(mn)。只求距离时,可保留上一行和当前行,将空间压成 O(n)。

11.2 最长公共子序列的状态为何不同于子串?​

出现公司:美团

考察重点:说明子序列为何能跳过字符,以及连续匹配要求怎样改变状态和答案的更新方式。

相关内容:最长公共子序列。

参考回答

子序列允许跳过字符,因此状态记录两个前缀的最优长度;子串要求连续,需要记录以当前位置结尾的匹配长度。

LCS 的末尾字符相等时,可以接在更短前缀的公共子序列后,得到 dp[i-1][j-1]+1。不同时,至少跳过一个末尾字符,所以取 dp[i-1][j] 与 dp[i][j-1] 的最大值。

子串遇到不匹配时,连续匹配就中断,状态归零。最长子串可以在任意位置结束,因此答案要取全表最大值。

11.3 0/1 背包压成一维后为何容量倒序?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释倒序怎样保留上一轮结果,并用反例说明正序为何会重复使用当前物品。

相关内容:计算顺序。

参考回答

容量倒序更新时,较小容量仍保存上一轮结果,所以当前物品只会被加入一次。

比如,一件物品重量 2、收益 3,背包容量 4,正确答案是 3。若正序更新,先得到 dp[2]=3,再读这个新值计算 dp[4]=6,同一物品就用了两次。完全背包允许重复使用,才使用这种正序转移。

12. 参考资料​