第五章 动态规划
第四十一节 DP基础与状态转移
401 斐波那契动态规划
斐波那契是动态规划的“Hello World”,一个简单的序列,却展示了记住过往结果的强大力量。我们不再反复重新计算子问题,而是将它们存储起来。结果如何?时间复杂度从指数级飞跃到线性级。
我们要解决什么问题?
斐波那契数列定义如下:
$$ F(0) = 0, \quad F(1) = 1, \quad F(n) = F(n-1) + F(n-2) $$
一个朴素的递归版本会多次重复计算相同的值。例如,计算 F(5) 会调用 F(4) 和 F(3),但 F(4) 也会再次调用 F(3),这是浪费的重复计算。
我们的目标是通过缓存结果来避免重复计算。这就是动态规划的精髓。
它是如何工作的(通俗解释)?
把斐波那契想象成一个梯子。你只有知道到达 n-1 和 n-2 级台阶的方法数,才能爬到第 n 级台阶。与其每次都重新计算这些台阶,不如记录一次,然后复用。
动态规划主要有两种实现方式:
| 方法 | 描述 | 示例 |
|---|---|---|
| 自顶向下(记忆化) | 递归 + 缓存 | 将结果存储在数组中 |
| 自底向上(制表法) | 从基础情况开始迭代 | 从 0 开始构建数组 |
让我们可视化状态填充过程:
| n | F(n-2) | F(n-1) | F(n) = F(n-1) + F(n-2) |
|---|---|---|---|
| 0 | - | - | 0 |
| 1 | - | - | 1 |
| 2 | 0 | 1 | 1 |
| 3 | 1 | 1 | 2 |
| 4 | 1 | 2 | 3 |
| 5 | 2 | 3 | 5 |
| 6 | 3 | 5 | 8 |
每个新值都复用了两个旧值,没有冗余工作。
微型代码(简易版本)
C 语言(自底向上斐波那契)
#include <stdio.h>
int main(void) {
int n;
printf("请输入 n: ");
scanf("%d", &n);
if (n == 0) { printf("0\n"); return 0; }
if (n == 1) { printf("1\n"); return 0; }
long long dp[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
printf("斐波那契(%d) = %lld\n", n, dp[n]);
return 0;
}Python(记忆化斐波那契)
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
n = int(input("请输入 n: "))
print("斐波那契(", n, ") =", fib(n))为何重要
- 演示了状态定义:F(n) = F(n-1) + F(n-2)
- 引入了重叠子问题和最优子结构的概念
- 是掌握动态规划直觉的第一步
- 将时间复杂度从指数级 O(2ⁿ) 降低到线性级 O(n)
你学到的是:解决一次并记住,胜过解决一百次。
逐步示例
| 步骤 | 计算 | 记忆表(部分) |
|---|---|---|
| 基础 | F(0)=0, F(1)=1 | [0, 1, _, _, _, _] |
| F(2) | F(1)+F(0)=1 | [0, 1, 1, _, _, _] |
| F(3) | F(2)+F(1)=2 | [0, 1, 1, 2, _, _] |
| F(4) | F(3)+F(2)=3 | [0, 1, 1, 2, 3, _] |
| F(5) | F(4)+F(3)=5 | [0, 1, 1, 2, 3, 5] |
动手尝试
- 编写不使用记忆化的递归斐波那契函数。测量调用次数。
- 添加一个字典或数组用于记忆化,比较速度。
- 将递归版本转换为迭代版本(制表法)。
- 优化空间:只存储两个变量,而不是整个数组。
- 打印所有计算值的完整表格。
测试用例
| n | 预期输出 | 备注 |
|---|---|---|
| 0 | 0 | 基础情况 |
| 1 | 1 | 基础情况 |
| 2 | 1 | 1 + 0 |
| 5 | 5 | 序列:0,1,1,2,3,5 |
| 10 | 55 | 检查平滑增长 |
复杂度
- 时间复杂度:对于记忆化和制表法都是 O(n)
- 空间复杂度:使用表格时为 O(n);优化后可为 O(1)
斐波那契动态规划是最简单的证明,它告诉我们记住结果是有回报的。这是动态规划的起点,也是效率诞生的地方。
402 爬楼梯
爬楼梯问题是斐波那契数列的友好近亲,递推关系相同,故事背景不同。你站在一个有 n 级台阶的楼梯底部。你一次可以爬 1 级或 2 级台阶。有多少种不同的方式可以到达顶部?
这是进入动态规划最直观的入门问题之一:定义状态,递归地关联它们,并重用过去的计算结果。
我们要解决什么问题?
我们想要到达第 n 级台阶的不同方式的数量。
你可以通过以下方式到达第 n 级台阶:
- 从第
n-1级走 1 步,或者 - 从第
n-2级走 2 步。
因此递推关系是: $$ dp[n] = dp[n-1] + dp[n-2] $$
初始条件为: $$ dp[0] = 1, \quad dp[1] = 1 $$
这在结构上与斐波那契数列完全相同,但具有组合解释。
它是如何工作的(通俗解释)?
将每一级台阶视为一个检查点。要到达第 n 级,你必须来自前两个检查点之一。如果你已经知道到达那两个检查点有多少种方式,只需将它们相加。
让我们用一个表格来说明:
| 台阶 (n) | 到达方式数 | 解释 |
|---|---|---|
| 0 | 1 | 停留在原地 |
| 1 | 1 | 单步走 |
| 2 | 2 | (1+1), (2) |
| 3 | 3 | (1+1+1), (1+2), (2+1) |
| 4 | 5 | (1+1+1+1), (2+1+1), (1+2+1), (1+1+2), (2+2) |
| 5 | 8 | 前两个数之和:5 = 3+2 |
每个新值都是前两个值的和。
精简代码(简单版本)
C 语言(自底向上爬楼梯)
#include <stdio.h>
int main(void) {
int n;
printf("请输入台阶数: ");
scanf("%d", &n);
if (n == 0 || n == 1) {
printf("方式数: 1\n");
return 0;
}
long long dp[n + 1];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
printf("爬 %d 级台阶的方式数: %lld\n", n, dp[n]);
return 0;
}Python(空间优化版)
n = int(input("请输入台阶数: "))
if n == 0 or n == 1:
print(1)
else:
a, b = 1, 1
for _ in range(2, n + 1):
a, b = b, a + b
print("爬", n, "级台阶的方式数:", b)为什么它重要
- 演示状态转移:
dp[i]依赖于dp[i-1]和dp[i-2] - 教授自底向上的思维方式和初始条件的设置
- 展示递推关系如何转化为计数问题
- 将组合数学与动态规划直觉联系起来
爬楼梯问题是在纯数学(递推关系)和应用推理(路径计数)之间架起的一座绝佳思维桥梁。
逐步示例
让我们追踪 n = 5 的情况:
| i | dp[i-2] | dp[i-1] | dp[i] = dp[i-1] + dp[i-2] |
|---|---|---|---|
| 0 | - | - | 1 |
| 1 | - | - | 1 |
| 2 | 1 | 1 | 2 |
| 3 | 1 | 2 | 3 |
| 4 | 2 | 3 | 5 |
| 5 | 3 | 5 | 8 |
所以,爬 5 级台阶有 8 种方式。
自己动手试试
- 修改代码,允许一次走 1、2 或 3 步。
- 为较小的
n打印整个dp表。 - 比较递归与迭代解决方案。
- 尝试推导一个公式,注意斐波那契模式。
- 如果每级台阶都有成本呢?将其改编为最小成本爬楼梯问题。
测试用例
| n | 预期输出 | 方式 |
|---|---|---|
| 0 | 1 | 什么都不做 |
| 1 | 1 | [1] |
| 2 | 2 | [1+1], [2] |
| 3 | 3 | [1+1+1], [1+2], [2+1] |
| 4 | 5 | 所有组合 |
| 5 | 8 | 像斐波那契数列一样增长 |
复杂度
- 时间复杂度:O(n)
- 空间复杂度:O(n),使用两个变量可降至 O(1)
爬楼梯问题表明,动态规划不仅仅是数学,更是关于识别运动、增长和记忆中的模式。
403 网格路径
网格路径问题是进入二维动态规划的一个温和步骤,其中状态不仅依赖于前一个元素,还依赖于相邻元素。想象一下,你站在网格的左上角,只能向右或向下移动,试图计算有多少条路径可以到达右下角。
每个单元格的值由从上方或从左方到达它的路径决定,这完美地比喻了动态规划如何逐层构建解决方案。
我们要解决什么问题?
给定一个 m × n 的网格,当你只能进行以下移动时,找出从 (0, 0) 到 (m-1, n-1) 的不同路径数量:
- 向右
(x, y+1) - 向下
(x+1, y)
递推关系: $$ dp[i][j] = dp[i-1][j] + dp[i][j-1] $$ 基础情况: $$ dp[0][j] = 1, \quad dp[i][0] = 1 $$ (因为沿着第一行或第一列只有一种方式)
它是如何工作的(通俗解释)?
把网格想象成城市地图,每个交叉路口 (i, j) 都可以从北边 (i-1, j) 或西边 (i, j-1) 到达。所以总路线数 = 从北边来的路线数 + 从西边来的路线数。
让我们可视化一个 3×3 的网格(0索引):
| 单元格 (i,j) | 路径数 | 解释 |
|---|---|---|
| (0,0) | 1 | 起点 |
| (0,1) | 1 | 只能从左方来 |
| (0,2) | 1 | 只能从左方来 |
| (1,0) | 1 | 只能从上方来 |
| (1,1) | 2 | (0,1)+(1,0)=2 |
| (1,2) | 3 | (0,2)+(1,1)=3 |
| (2,0) | 1 | 只能从上方来 |
| (2,1) | 3 | (1,1)+(2,0)=3 |
| (2,2) | 6 | (1,2)+(2,1)=6 |
所以 dp[2][2] = 6 → 6 条不同的路径。
微型代码(简单版本)
C 语言(二维 DP 表)
#include <stdio.h>
int main(void) {
int m, n;
printf("输入行数和列数: ");
scanf("%d %d", &m, &n);
long long dp[m][n];
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
printf("唯一路径数: %lld\n", dp[m-1][n-1]);
return 0;
}Python(空间优化版)
m, n = map(int, input("输入行数和列数: ").split())
dp = [1] * n
for _ in range(1, m):
for j in range(1, n):
dp[j] += dp[j - 1]
print("唯一路径数:", dp[-1])为什么它很重要
- 教授二维动态规划网格
- 为格子、矩阵、网格上的问题建立直觉
- 为最小成本路径、迷宫遍历、机器人运动奠定基础
- 鼓励从二维到一维的空间优化
从计数路径到优化路径,这个网格就是你的动态规划画布。
逐步示例
对于一个 3×3 的网格:
| i\j | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 1 | 1 | 1 |
| 1 | 1 | 2 | 3 |
| 2 | 1 | 3 | 6 |
dp[2][2] = 6 → 六条唯一路线。
自己动手试试
- 修改代码以处理障碍物(
0= 阻塞,1= 可通过)。 - 打印 DP 表。
- 使用递归 + 记忆化实现。
- 添加向右、向下和对角线移动的条件。
- 与组合公式进行比较:$\binom{m+n-2}{m-1}$。
测试用例
| 网格大小 | 预期路径数 | 备注 |
|---|---|---|
| 1×1 | 1 | 只有起始单元格 |
| 2×2 | 2 | 右→下,下→右 |
| 3×3 | 6 | 经典情况 |
| 3×4 | 10 | 组合数学检查 |
| 4×4 | 20 | 帕斯卡三角形模式 |
复杂度
- 时间复杂度:O(m×n)
- 空间复杂度:O(m×n),可优化至 O(n)
网格路径揭示了动态规划的本质,每个位置都依赖于更简单的位置。从这里开始,你将学会有目的地最小化、最大化和遍历。
404 最小成本路径
最小成本路径问题是计数与优化的交汇点。我们不再问“有多少种方式可以到达终点?”,而是问“到达那里的最便宜方式是什么?”。你正在一个网格上逐格移动,每个格子都有一个成本,你的目标是在最小化总成本的同时到达右下角。
这是动态规划中最基本的路径优化问题之一。
我们要解决什么问题?
给定一个矩阵 cost[m][n],其中每个单元格代表一个非负成本,找到从 (0, 0) 到 (m-1, n-1) 的最小总成本路径,只能向右、向下或(可选地)向右下对角线移动。
递推关系: $$ dp[i][j] = cost[i][j] + \min(dp[i-1][j], dp[i][j-1]) $$ 如果允许对角线移动: $$ dp[i][j] = cost[i][j] + \min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) $$
基本情况: $$ dp[0][0] = cost[0][0] $$
它是如何工作的(通俗解释)?
想象一下徒步穿越一个地形网格,每个单元格都有一个能量成本。你的每一次移动都会增加你的总成本。你总是希望选择能让你当前总成本尽可能小的路径。
DP 表记录了到达每个单元格的最小成本,从左上角构建到右下角。
让我们看一个示例网格:
| 单元格 | 成本 |
|---|---|
| (0,0) | 1 |
| (0,1) | 3 |
| (0,2) | 5 |
| (1,0) | 2 |
| (1,1) | 1 |
| (1,2) | 2 |
| (2,0) | 4 |
| (2,1) | 3 |
| (2,2) | 1 |
我们填充 dp[i][j] = 到达 (i,j) 的成本:
| i\j | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 1 | 4 | 9 |
| 1 | 3 | 2 | 4 |
| 2 | 7 | 5 | 5 |
最小成本 = 5
精简代码(简易版本)
C 语言(自底向上 DP)
#include <stdio.h>
#define MIN(a,b) ((a)<(b)?(a):(b))
int main(void) {
int m, n;
printf("输入行数和列数: ");
scanf("%d %d", &m, &n);
int cost[m][n];
printf("输入成本矩阵:\n");
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
scanf("%d", &cost[i][j]);
int dp[m][n];
dp[0][0] = cost[0][0];
for (int i = 1; i < m; i++) dp[i][0] = dp[i-1][0] + cost[i][0];
for (int j = 1; j < n; j++) dp[0][j] = dp[0][j-1] + cost[0][j];
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
dp[i][j] = cost[i][j] + MIN(dp[i-1][j], dp[i][j-1]);
printf("最小成本: %d\n", dp[m-1][n-1]);
return 0;
}Python(可选对角线移动)
def min_cost_path(cost):
m, n = len(cost), len(cost[0])
dp = [[0]*n for _ in range(m)]
dp[0][0] = cost[0][0]
for i in range(1, m):
dp[i][0] = dp[i-1][0] + cost[i][0]
for j in range(1, n):
dp[0][j] = dp[0][j-1] + cost[0][j]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = cost[i][j] + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
return dp[-1][-1]
cost = [
[1, 3, 5],
[2, 1, 2],
[4, 3, 1]
$$
print("最小成本路径:", min_cost_path(cost))为什么它很重要
- 从计数路径过渡到优化路径
- 引入了最小化递推关系
- 是许多网格问题的基础(例如迷宫求解、图像遍历、最短路径)
- 为加权 DP 建立直觉
这个问题是通向图中 Dijkstra(迪杰斯特拉)和 Bellman-Ford(贝尔曼-福特)算法的垫脚石。
逐步示例
| 单元格 | 从上方来 | 从左方来 | 最小值 | 总成本 |
|---|---|---|---|---|
| (0,0) | - | - | - | 1 |
| (0,1) | - | 1+3 | 4 | 4 |
| (1,0) | 1+2 | - | 3 | 3 |
| (1,1) | 4+1 | 3+1 | 4 | 4 |
| (2,2) | ... | ... | ... | 5 |
答案:5
亲自尝试
- 添加对角线移动,比较结果。
- 添加阻塞单元格(无限成本)。
- 修改为寻找最大成本路径。
- 使用
parent表重建路径。 - 对于非网格图,使用优先队列(Dijkstra)。
测试用例
| 网格 | 预期最小成本 | 备注 |
|---|---|---|
| [[1]] | 1 | 单个单元格 |
| [[1,2],[3,4]] | 7 | 1→2→4 |
| [[1,3,5],[2,1,2],[4,3,1]] | 5 | 最优路线 |
| [[5,9],[4,2]] | 11 | 5→4→2 |
复杂度
- 时间复杂度:O(m×n)
- 空间复杂度:O(m×n),可减少到 O(n)
最小成本路径将网格转化为决策地图,每个单元格都在问:“到达我的最便宜方式是什么?”,而 DP 表则以冷静的精确度给出答案。
405 零钱兑换(计算方式数)
你有一些硬币面额和一个总金额。如果每种硬币可以无限使用,那么有多少种不同的方式可以凑出该金额?我们计算的是顺序无关的组合数。例如,对于硬币 [1, 2, 5],凑出金额 5 有 4 种方式: 5、2+2+1、2+1+1+1、1+1+1+1+1。
我们要解决什么问题?
给定一个数组 coins[] 和一个整数 amount,计算使用每种硬币的无限副本凑成 amount 的组合数。
状态和递推关系:
$$ dp[x] = \text{凑出总和 } x \text{ 的方式数} $$ $$ dp[0] = 1 $$ $$ \text{对于每个硬币 } c:\quad \text{对于 } x \text{ 从 } c \text{ 到 } \text{amount}:\quad dp[x] \mathrel{+=} dp[x - c] $$
为什么是这个顺序:在外层循环遍历硬币确保了每个组合只被计数一次。如果你在外层循环金额,内层循环硬币,那么你计数的将是排列数。
它是如何工作的(通俗解释)?
想象从左到右构建总金额。对于每个硬币面值 c,你问:如果我必须至少使用一次 c,那么凑出剩余金额 x - c 还有多少种方式?把这些方式数加到已知的方式数上。向前移动,增加 x,然后对下一个硬币重复此过程。表格的填充就像一个滚动累加。
以硬币 [1, 2, 5] 和 amount = 5 为例:
| 处理完 | dp[0] | dp[1] | dp[2] | dp[3] | dp[4] | dp[5] | 解释 |
|---|---|---|---|---|---|---|---|
| 初始化 | 1 | 0 | 0 | 0 | 0 | 0 | 凑出 0 的一种方式:什么都不选 |
| 硬币 1 | 1 | 1 | 1 | 1 | 1 | 1 | 仅使用 1 元硬币 |
| 硬币 2 | 1 | 1 | 2 | 2 | 3 | 3 | 加上以 2 结尾的方式数 |
| 硬币 5 | 1 | 1 | 2 | 2 | 3 | 4 | 加上以 5 结尾的方式数 |
答案是 dp[5] = 4。
精简代码(简单版本)
C 语言 (1D DP, 组合数)
#include <stdio.h>
int main(void) {
int n, amount;
printf("输入硬币种类数和总金额: ");
scanf("%d %d", &n, &amount);
int coins[n];
printf("输入硬币面值: ");
for (int i = 0; i < n; i++) scanf("%d", &coins[i]);
// 使用 long long 避免大数计数时溢出
long long dp[amount + 1];
for (int x = 0; x <= amount; x++) dp[x] = 0;
dp[0] = 1;
for (int i = 0; i < n; i++) {
int c = coins[i];
for (int x = c; x <= amount; x++) {
dp[x] += dp[x - c];
}
}
printf("方式数: %lld\n", dp[amount]);
return 0;
}Python (1D DP, 组合数)
coins = list(map(int, input("输入硬币面值: ").split()))
amount = int(input("输入总金额: "))
dp = [0] * (amount + 1)
dp[0] = 1
for c in coins:
for x in range(c, amount + 1):
dp[x] += dp[x - c]
print("方式数:", dp[amount])为什么它重要
- 引入了无界背包计数的概念
- 展示了循环顺序如何控制你计算的是组合数还是排列数
- 为许多计数类动态规划问题(如整数划分、骰子点数和计数)奠定了基础
- 鼓励使用一维数组进行空间优化
分步示例
硬币 [1, 3, 4],金额 6:
| 步骤 | 更新操作 | dp 数组快照(索引为金额) |
|---|---|---|
| 初始化 | dp[0]=1 | [1, 0, 0, 0, 0, 0, 0] |
| 硬币 1 | 填充 x=1..6 | [1, 1, 1, 1, 1, 1, 1] |
| 硬币 3 | x=3..6 加上 dp[x-3] | [1, 1, 1, 2, 2, 2, 3] |
| 硬币 4 | x=4..6 加上 dp[x-4] | [1, 1, 1, 2, 3, 3, 4] |
答案:dp[6] = 4。
动手尝试
- 切换到排列数计数:外层循环
x,内层循环coins。比较结果。 - 为每种硬币类型添加数量上限,转换为有界版本。
- 对硬币排序,并使用父指针数组打印出一种有效的组合。
- 使用模运算避免溢出:例如
10^9+7。 - 扩展到计算从
0到amount每个x的方式数,并打印完整的表格。
测试用例
| 硬币 | 金额 | 预期方式数 | 备注 |
|---|---|---|---|
| [] | 0 | 1 | 一种空方案 |
| [] | 5 | 0 | 没有硬币无法凑出正数和 |
| [1] | 4 | 1 | 只有 1+1+1+1 |
| [2] | 3 | 0 | 奇数无法用偶数面额凑出 |
| [1,2,5] | 5 | 4 | 经典示例 |
| [2,3,7] | 12 | 4 | 仅计算组合数 |
复杂度
- 时间复杂度:$O(n \times \text{amount})$,其中
n是硬币种类数 - 空间复杂度:$O(\text{amount})$(使用 1D DP)
零钱兑换计数问题教你理解状态顺序和循环顺序如何塑造动态规划的含义。一旦你掌握了这个模式,许多计数问题就会变得简单明了。
406 零钱兑换(最少硬币数)
现在我们从计算方式数转向寻找最少硬币数。给定一个目标金额和硬币面额,我们如何使用最少数量的硬币来凑出这个金额?这个版本的零钱兑换问题将计数转化为优化,一个小小的转变却带来巨大的影响。
我们解决的是什么问题?
给定 coins[] 和一个总额 amount,找出凑成该金额所需的最少硬币数量。 如果不可能,则返回 -1。
我们定义: $$ dp[x] = \text{凑出总和 } x \text{ 所需的最少硬币数} $$ 基础情况: $$ dp[0] = 0 $$ 递推关系: $$ dp[x] = \min_{c \in coins,\ c \le x} (dp[x - c] + 1) $$
每个 dp[x] 都在问:"如果我取一枚面额为 c 的硬币,对于剩余金额我能做到的最好结果是什么?"
它是如何工作的(通俗解释)?
你从 0 开始向上累加,为每个金额构建最便宜的凑法。 对于每个 x,你尝试所有满足 c ≤ x 的硬币 c。如果你能凑出 x-c,那么加上一枚硬币,看看这是否比你当前的最佳方案更好。
这就像使用较小的步幅选择到达目的地的最短路线。
示例:coins = [1, 3, 4], amount = 6
| 金额 (x) | dp[x] | 解释 |
|---|---|---|
| 0 | 0 | 基础情况 |
| 1 | 1 | 1×1 |
| 2 | 2 | 1+1 |
| 3 | 1 | 3 |
| 4 | 1 | 4 |
| 5 | 2 | 1+4 |
| 6 | 2 | 3+3 |
所以最少硬币数 = 2。
精简代码(简易版本)
C (自底向上动态规划)
#include <stdio.h>
#define INF 1000000
#define MIN(a,b) ((a)<(b)?(a):(b))
int main(void) {
int n, amount;
printf("输入硬币数量和总额: ");
scanf("%d %d", &n, &amount);
int coins[n];
printf("输入硬币面值: ");
for (int i = 0; i < n; i++) scanf("%d", &coins[i]);
int dp[amount + 1];
for (int x = 1; x <= amount; x++) dp[x] = INF;
dp[0] = 0;
for (int x = 1; x <= amount; x++) {
for (int i = 0; i < n; i++) {
int c = coins[i];
if (x - c >= 0) dp[x] = MIN(dp[x], dp[x - c] + 1);
}
}
if (dp[amount] == INF)
printf("不可能\n");
else
printf("最少硬币数: %d\n", dp[amount]);
return 0;
}Python (直接动态规划)
coins = list(map(int, input("输入硬币面值: ").split()))
amount = int(input("输入总额: "))
INF = float('inf')
dp = [INF] * (amount + 1)
dp[0] = 0
for x in range(1, amount + 1):
for c in coins:
if x - c >= 0:
dp[x] = min(dp[x], dp[x - c] + 1)
print("最少硬币数:" if dp[amount] != INF else "不可能", end=" ")
print(dp[amount] if dp[amount] != INF else "")为何重要
- 核心的无界优化动态规划问题
- 展示了使用基础值无穷大的最小化递推关系
- 说明了子问题依赖性:
dp[x]依赖于更小的金额 - 直接关联到背包问题、最短路径问题以及动态规划与广度优先搜索的混合问题
这个版本教你如何将贪心直觉与动态规划的正确性结合起来。
逐步示例
硬币 [1, 3, 4], 总额 = 6
| x | 尝试 1 | 尝试 3 | 尝试 4 | dp[x] |
|---|---|---|---|---|
| 0 | - | - | - | 0 |
| 1 | dp[0]+1=1 | - | - | 1 |
| 2 | dp[1]+1=2 | - | - | 2 |
| 3 | dp[2]+1=3 | dp[0]+1=1 | - | 1 |
| 4 | dp[3]+1=2 | dp[1]+1=2 | dp[0]+1=1 | 1 |
| 5 | dp[4]+1=2 | dp[2]+1=3 | dp[1]+1=2 | 2 |
| 6 | dp[5]+1=3 | dp[3]+1=2 | dp[2]+1=3 | 2 |
答案 = dp[6] = 2
动手尝试
- 添加代码来重构实际的硬币组合。
- 对于硬币
[1, 3, 4], 总额 = 6,比较贪心算法与动态规划。 - 修改代码以处理有限的硬币供应量。
- 使用递归 + 记忆化并比较运行时间。
- 尝试边界情况:总额小于最小硬币面额。
测试用例
| 硬币 | 总额 | 预期输出 | 备注 |
|---|---|---|---|
| [1] | 3 | 3 | 只有面额1 |
| [2] | 3 | -1 | 不可能 |
| [1,3,4] | 6 | 2 | 3+3 或 4+1+1 |
| [1,2,5] | 11 | 3 | 5+5+1 |
| [2,5,10] | 0 | 0 | 不需要硬币 |
复杂度
- 时间复杂度:O(n×amount)
- 空间复杂度:O(amount)
零钱兑换(最少硬币数)是关于最小化思维的经典课程,每个子问题都是通往最有效路径的一个小决策。
407 0/1 背包问题
0/1 背包问题是动态规划的皇冠明珠之一。给定一个容量有限的背包和一组物品,每个物品都有重量和价值。你必须决定装入哪些物品,使得在不超出重量限制的情况下总价值最大化。你可以选择拿取一个物品(1)或者不拿(0),因此得名。
我们要解决什么问题?
给定:
n个物品,每个物品有weight[i]和value[i]- 容量
W
找出你能携带的最大总价值: $$ dp[i][w] = \text{使用前 i 个物品在容量 w 下的最大价值} $$
递推关系: $$ dp[i][w] = \max( dp[i-1][w], \quad value[i-1] + dp[i-1][w - weight[i-1]] ) $$ 如果 weight[i-1] <= w,否则 dp[i][w] = dp[i-1][w]。
基础情况: $$ dp[0][w] = 0, \quad dp[i][0] = 0 $$
它是如何工作的(通俗解释)?
把你的背包想象成一个空间预算。每个物品都是一个权衡:
- 包含它 → 获得其价值但损失容量
- 排除它 → 为其他物品保留容量
你对每个物品和每个可能的容量都要做出这个决定。
我们构建一个表格,其中每个单元格 dp[i][w] 存储了使用前 i 个物品和总容量 w 所能达到的最佳价值。
示例: 物品 = [(w=1,v=1), (w=3,v=4), (w=4,v=5), (w=5,v=7)], W = 7
| $i/w$ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | 0 | 1 | 1 | 4 | 5 | 5 | 5 | 5 |
| 3 | 0 | 1 | 1 | 4 | 5 | 6 | 6 | 9 |
| 4 | 0 | 1 | 1 | 4 | 5 | 7 | 8 | 9 |
答案 = 9 (物品 2 + 3)
精简代码(简易版本)
C 语言 (2D DP 表)
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n, W;
printf("请输入物品数量和容量: ");
scanf("%d %d", &n, &W);
int wt[n], val[n];
printf("请输入重量: ");
for (int i = 0; i < n; i++) scanf("%d", &wt[i]);
printf("请输入价值: ");
for (int i = 0; i < n; i++) scanf("%d", &val[i]);
int dp[n + 1][W + 1];
for (int i = 0; i <= n; i++) {
for (int w = 0; w <= W; w++) {
if (i == 0 || w == 0) dp[i][w] = 0;
else if (wt[i-1] <= w)
dp[i][w] = MAX(val[i-1] + dp[i-1][w - wt[i-1]], dp[i-1][w]);
else
dp[i][w] = dp[i-1][w];
}
}
printf("最大价值: %d\n", dp[n][W]);
return 0;
}Python (空间优化版)
weights = list(map(int, input("请输入重量: ").split()))
values = list(map(int, input("请输入价值: ").split()))
W = int(input("请输入容量: "))
n = len(weights)
dp = [0] * (W + 1)
for i in range(n):
for w in range(W, weights[i] - 1, -1):
dp[w] = max(dp[w], values[i] + dp[w - weights[i]])
print("最大价值:", dp[W])为什么它很重要
- 教授基于选择的动态规划:包含或排除
- 资源分配、子集选择、预算问题的核心
- 高级动态规划的基础(子集和、划分、调度)
- 引入了 2D → 1D 的空间优化
这个问题体现了动态规划中决策的本质:拿还是不拿。
分步示例
物品: (w,v) = (1,1), (3,4), (4,5), (5,7), W=7
| 步骤 | 容量 | 操作 | dp |
|---|---|---|---|
| i=1 | w≥1 | 拿取 (1,1) | +1 价值 |
| i=2 | w≥3 | 拿取 (3,4) | 替换低价值组合 |
| i=3 | w≥4 | 组合 (3+4) = 9 | 找到最大值 |
| i=4 | w=7 | 无法超过 9 | 完成 |
亲自尝试
- 使用回溯表打印选中的物品。
- 比较 2D 和 1D 版本。
- 添加精确重量匹配的约束。
- 尝试变体:最大化重量、最小化数量等。
- 修改为分数重量 → 贪心分数背包问题。
测试用例
| 重量 | 价值 | 容量 | 期望值 | 备注 |
|---|---|---|---|---|
| [1,2,3] | [10,15,40] | 6 | 65 | 全部拿取 |
| [2,3,4,5] | [3,4,5,6] | 5 | 7 | (2,3) |
| [1,3,4,5] | [1,4,5,7] | 7 | 9 | (3,4) |
| [2,5] | [5,10] | 3 | 5 | 只有第一个能装下 |
复杂度
- 时间: O(n×W)
- 空间: O(n×W) → O(W) 优化后
0/1 背包问题是动态规划的原型,它关乎于在选项、约束和回报之间取得平衡。
408 完全背包问题
完全背包问题是背包问题的“无限续杯”版本。你仍然希望在容量限制下最大化价值,但现在每种物品都可以被选择多次。这就像打包零食,只要袋子装得下,你想拿多少就拿多少。
我们要解决什么问题?
给定:
n件物品,每件物品有weight[i]和value[i]- 容量
W - 每种物品有无限个副本
求在不超出容量 W 的情况下,可以达到的最大价值。
状态定义: $$ dp[w] = \text{容量为 } w \text{ 时的最大价值} $$
状态转移方程: $$ dp[w] = \max_{i: weight[i] \le w} (dp[w - weight[i]] + value[i]) $$
基础情况: $$ dp[0] = 0 $$
注意,这与 0/1 背包问题类似,但这里我们可以重复使用物品。区别在于迭代的顺序。
它是如何工作的(通俗解释)?
把容量 w 想象成一个预算。对于每个容量,你检查所有物品,如果某个物品能装下,你就看看重复使用它会怎样。与 0/1 背包问题(每种物品在每个组合中只能使用一次)不同,完全背包允许多次选择。
| 容量 (w) | 最佳价值 | 解释 |
|---|---|---|
| 0 | 0 | 空 |
| 1 | 15 | 1 个物品(1,15) |
| 2 | 30 | 2 个副本 |
| 3 | 45 | 3 个副本 |
| 4 | 60 | 4 个副本 |
(如果所有物品的价值重量比相同,你会用最好的那个装满。)
示例: 物品:(w,v) = (2,4), (3,7), (4,9), W = 7
- dp[2] = 4
- dp[3] = 7
- dp[4] = 9
- dp[5] = max(dp[3]+4, dp[2]+7) = 11
- dp[6] = max(dp[4]+4, dp[3]+7, dp[2]+9) = 14
- dp[7] = max(dp[5]+4, dp[4]+7, dp[3]+9) = 16
答案 = 16
精简代码(简易版本)
C (1D DP, 完全背包)
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n, W;
printf("请输入物品数量和容量: ");
scanf("%d %d", &n, &W);
int wt[n], val[n];
printf("请输入重量: ");
for (int i = 0; i < n; i++) scanf("%d", &wt[i]);
printf("请输入价值: ");
for (int i = 0; i < n; i++) scanf("%d", &val[i]);
int dp[W + 1];
for (int w = 0; w <= W; w++) dp[w] = 0;
for (int i = 0; i < n; i++) {
for (int w = wt[i]; w <= W; w++) {
dp[w] = MAX(dp[w], val[i] + dp[w - wt[i]]);
}
}
printf("最大价值: %d\n", dp[W]);
return 0;
}Python (简单的自底向上)
weights = list(map(int, input("请输入重量: ").split()))
values = list(map(int, input("请输入价值: ").split()))
W = int(input("请输入容量: "))
dp = [0] * (W + 1)
for i in range(len(weights)):
for w in range(weights[i], W + 1):
dp[w] = max(dp[w], values[i] + dp[w - weights[i]])
print("最大价值:", dp[W])为什么它很重要
- 展示了元素的无限次使用
- 是零钱兑换(最少硬币数)、钢条切割、整数拆分等问题的基础
- 强调了动态规划中迭代顺序的重要性
- 将计数问题(有多少种方式)与优化问题(最佳方式)联系起来
这是组合爆炸变得可控的地方。
逐步示例
物品: (2,4), (3,7), (4,9), W = 7
| w | dp[w] | 最佳选择 |
|---|---|---|
| 0 | 0 | - |
| 1 | 0 | 没有合适的 |
| 2 | 4 | (2) |
| 3 | 7 | (3) |
| 4 | 9 | (4) |
| 5 | 11 | (2+3) |
| 6 | 14 | (3+3) |
| 7 | 16 | (3+4) |
答案: 16
动手尝试
- 打印出使用的物品(存储父选择)。
- 比较 0/1 背包和完全背包的输出。
- 添加副本数量限制,实现混合背包。
- 改变目标:最小化物品数量。
- 应用到钢条切割问题。
测试用例
| 重量 | 价值 | W | 预期结果 | 备注 |
|---|---|---|---|---|
| [2,3,4] | [4,7,9] | 7 | 16 | 3+4 |
| [1,2,3] | [10,15,40] | 6 | 90 | 六个 1 |
| [5,10,20] | [10,30,50] | 20 | 100 | 四个 5 或一个 20 |
| [2,5] | [5,10] | 3 | 5 | 只能一个 2 |
复杂度
- 时间复杂度:O(n×W)
- 空间复杂度:O(W)
完全背包问题是你第一次在约束条件下体验无限选择,这是一个贯穿许多动态规划设计的强大思想。
409 最长递增子序列(动态规划)
最长递增子序列(LIS)问题是一个经典问题,其核心在于找到一组严格递增的数字所构成的最长链条。这些数字不必在原始数组中连续,只需保持顺序即可。这是一个基础的动态规划问题,完美融合了状态定义、状态转移和比较。
我们要解决什么问题?
给定一个长度为 n 的数组 arr[],找出最长递增子序列的长度。一个递增子序列由一组索引 i₁ < i₂ < ... < iₖ 组成,满足: $$ arr[i₁] < arr[i₂] < \cdots < arr[iₖ] $$ 我们要求的是最大长度。
状态转移方程: $$ dp[i] = 1 + \max(dp[j]) \quad \text{对于所有满足 } j < i \text{ 且 } arr[j] < arr[i] \text{ 的 } j $$ 否则: $$ dp[i] = 1 $$
基础情况: $$ dp[0] = 1 $$
答案: $$ \max_i dp[i] $$
它是如何工作的(通俗解释)?
你依次观察每个数字并问自己:“我能接在某个在我之前结束的递增序列后面吗?” 如果可以,就选择所有符合条件的序列中最长的那一个,然后把自己接在后面,使长度加一。
这就像搭积木塔,每个数字都试图堆叠在一个比它小的数字之上,从而尽可能高地延伸已有的塔。
示例:arr = [10, 22, 9, 33, 21, 50, 41, 60]
| i | arr[i] | dp[i] | 原因 |
|---|---|---|---|
| 0 | 10 | 1 | 起始 |
| 1 | 22 | 2 | 10→22 |
| 2 | 9 | 1 | 前面没有更小的数 |
| 3 | 33 | 3 | 10→22→33 |
| 4 | 21 | 2 | 10→21 |
| 5 | 50 | 4 | 10→22→33→50 |
| 6 | 41 | 4 | 10→22→33→41 |
| 7 | 60 | 5 | 10→22→33→50→60 |
答案 = 5
精简代码(简单版本)
C 语言 (O(n²) DP)
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n;
printf("输入元素个数: ");
scanf("%d", &n);
int arr[n];
printf("输入数组: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int dp[n];
for (int i = 0; i < n; i++) dp[i] = 1;
int ans = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i])
dp[i] = MAX(dp[i], dp[j] + 1);
}
ans = MAX(ans, dp[i]);
}
printf("最长递增子序列长度: %d\n", ans);
return 0;
}Python (简单 DP)
arr = list(map(int, input("输入数组: ").split()))
n = len(arr)
dp = [1] * n
for i in range(n):
for j in range(i):
if arr[j] < arr[i]:
dp[i] = max(dp[i], dp[j] + 1)
print("最长递增子序列长度:", max(dp))为什么它很重要
- 是序列 DP 的核心,涉及元素对比较和追踪最佳链条
- 展示了 O(n²) 动态规划的思路
- 是学习 LCS(最长公共子序列)、编辑距离、耐心排序(O(n log n))的基础
- 应用于股票分析、基因组序列和链条类问题
这个问题教授了“回溯并扩展”这一关键的动态规划直觉。
分步示例
arr = [3, 10, 2, 1, 20]
| i | arr[i] | dp[i] | 最佳链条 |
|---|---|---|---|
| 0 | 3 | 1 | [3] |
| 1 | 10 | 2 | [3,10] |
| 2 | 2 | 1 | [2] |
| 3 | 1 | 1 | [1] |
| 4 | 20 | 3 | [3,10,20] |
答案 = 3
动手尝试
- 使用
parent数组打印出实际的 LIS。 - 转换为非递减 LIS(使用 ≤ 而不是 <)。
- 与 O(n log n) 的二分查找版本进行比较。
- 改编为最长递减子序列。
- 应用到二维点对问题(俄罗斯套娃信封问题)。
测试用例
| arr | 预期结果 | 备注 |
|---|---|---|
| [1,2,3,4,5] | 5 | 已经是递增的 |
| [5,4,3,2,1] | 1 | 只有一个元素 |
| [3,10,2,1,20] | 3 | [3,10,20] |
| [10,22,9,33,21,50,41,60] | 5 | 经典示例 |
| [2,2,2,2] | 1 | 严格递增 |
复杂度分析
- 时间复杂度:O(n²)
- 空间复杂度:O(n)
LIS 是动态规划的旋律,每个元素都倾听其前驱,找到和谐之处,并将乐曲延伸至其最长的可能长度。
410 编辑距离(莱文斯坦距离)
编辑距离(或莱文斯坦距离)问题通过计算将一个字符串转换为另一个字符串所需的最少操作次数,来衡量两个字符串的差异程度。允许的操作包括:
- 插入
- 删除
- 替换
它是拼写检查器、DNA序列比对和模糊搜索的基础,适用于任何需要衡量两个序列“有多接近”的场景。
我们要解决什么问题?
给定两个字符串 A 和 B,找出将 A 转换为 B 所需的最少操作次数。
设:
A的长度为mB的长度为n
状态定义: $$ dp[i][j] = \text{将 } A[0..i-1] \text{ 转换为 } B[0..j-1] \text{ 所需的最小编辑次数} $$
递推关系:
- 如果
A[i-1] == B[j-1]: $$ dp[i][j] = dp[i-1][j-1] $$ - 否则,取三种操作的最小值: $$ dp[i][j] = 1 + \min( dp[i-1][j], \text{ (删除)} dp[i][j-1], \text{ (插入)} dp[i-1][j-1] \text{ (替换)} ) $$
基础情况: $$ dp[0][j] = j,\quad dp[i][0] = i $$ (空字符串转换)
它是如何工作的(通俗解释)?
想象一下逐个字符编辑一个单词。在每一步,比较当前字符:
- 如果它们匹配 → 无成本,沿对角线移动。
- 如果它们不同 → 选择成本最低的修复方式(插入、删除、替换)。
动态规划表构建了从短字符串到完整字符串的所有前缀转换。
示例: A = "kitten", B = "sitting"
| 步骤 | 操作 | 结果 |
|---|---|---|
| 替换 k → s | sitten | |
| 替换 e → i | sittin | |
| 插入 g | sitting |
答案 = 3
精简代码(简易版本)
C (2D DP)
#include <stdio.h>
#define MIN3(a,b,c) ((a<b?a:b)<c?(a<b?a:b):c)
int main(void) {
char A[100], B[100];
printf("输入字符串 A: ");
scanf("%s", A);
printf("输入字符串 B: ");
scanf("%s", B);
int m = 0, n = 0;
while (A[m]) m++;
while (B[n]) n++;
int dp[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (A[i-1] == B[j-1]) dp[i][j] = dp[i-1][j-1];
else dp[i][j] = 1 + MIN3(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
}
}
printf("编辑距离: %d\n", dp[m][n]);
return 0;
}Python (简单版本)
A = input("输入字符串 A: ")
B = input("输入字符串 B: ")
m, n = len(A), len(B)
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 A[i-1] == B[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])
print("编辑距离:", dp[m][n])为什么它很重要
- 展示了字符串上的二维动态规划
- 引入了转换问题
- 构成了拼写纠正、DNA比对、差异工具的核心
- 完美地捕捉了“状态 = 前缀长度”的模式
编辑距离是“逐步转换”的词典定义。
逐步示例
A = "intention", B = "execution"
| $A/B$ | "" | e | x | e | c | u | t | i | o | n |
|---|---|---|---|---|---|---|---|---|---|---|
| "" | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| i | 1 | 1 | 2 | 3 | 4 | 5 | 6 | 6 | 7 | 8 |
| n | 2 | 2 | 2 | 3 | 4 | 5 | 6 | 7 | 7 | 7 |
| t | 3 | 3 | 3 | 3 | 4 | 5 | 5 | 6 | 7 | 8 |
| e | 4 | 3 | 4 | 3 | 4 | 5 | 6 | 6 | 7 | 8 |
| n | 5 | 4 | 4 | 4 | 4 | 5 | 6 | 7 | 7 | 7 |
| t | 6 | 5 | 5 | 5 | 5 | 5 | 5 | 6 | 7 | 8 |
| i | 7 | 6 | 6 | 6 | 6 | 6 | 6 | 5 | 6 | 7 |
| o | 8 | 7 | 7 | 7 | 7 | 7 | 7 | 6 | 5 | 6 |
| n | 9 | 8 | 8 | 8 | 8 | 8 | 8 | 7 | 6 | 5 |
答案 = 5 次编辑
自己动手试试
- 打印实际的编辑序列(回溯)。
- 添加成本:为插入/删除/替换分配不同的权重。
- 尝试不区分大小写的变体。
- 与最长公共子序列进行比较。
- 实现递归 + 记忆化版本。
测试用例
| A | B | 预期结果 | 备注 |
|---|---|---|---|
| "kitten" | "sitting" | 3 | 经典案例 |
| "horse" | "ros" | 3 | leetcode 案例 |
| "flaw" | "lawn" | 2 | 替换 + 插入 |
| "abc" | "yabd" | 2 | 插入 + 替换 |
| "" | "abc" | 3 | 全部插入 |
复杂度
- 时间复杂度:O(m×n)
- 空间复杂度:O(m×n),可优化至 O(n)
编辑距离教授了动态规划的精确性:每个单元格意味着“修复此前缀的最小改动”。它是一个字母一个字母的校正语言。
第 42 章 经典问题
411 0/1 背包问题
0/1 背包问题是动态规划中最具代表性的问题之一。它是在约束条件下进行决策的完美范例:每个物品要么被拿走,要么被留下,但不能分割或重复使用。目标是在固定容量内最大化总价值。
这个版本侧重于理解选择、容量和最优子结构——动态规划的三大支柱。
我们要解决什么问题?
给定:
n个物品,每个物品有重量w[i]和价值v[i]- 一个容量为
W的背包
我们希望: $$ \text{最大化总价值} \quad \sum v[i] $$ 约束条件为: $$ \sum w[i] \le W $$ 且每个物品最多只能使用一次。
状态定义: $$ dp[i][w] = \text{使用前 } i \text{ 个物品在容量 } w \text{ 下的最大价值} $$
递推关系: $$ dp[i][w] = \begin{cases} dp[i-1][w], & \text{if } w_{i-1} > w,\ \max\big(dp[i-1][w],\ dp[i-1][w - w_{i-1}] + v_{i-1}\big), & \text{otherwise.} \end{cases} $$
基本情况: $$ dp[0][w] = 0, \quad dp[i][0] = 0 $$
答案: $$ dp[n][W] $$
它是如何工作的(通俗解释)?
在每一步,你都在问: “我应该拿走这个物品还是留下它?” 如果它能装下,就比较:
- 不拿它 → 沿用之前的最佳值 (
dp[i-1][w]) - 拿它 → 加上它的价值,再加上剩余容量的最佳价值 (
dp[i-1][w - weight[i]] + value[i])
DP 表存储了对于每个物品子集,在每个子容量下的最佳可能价值。
| 物品 | 重量 | 价值 |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 3 | 4 |
| 3 | 4 | 5 |
| 4 | 5 | 7 |
容量 = 7 → 答案 = 9(物品 2 + 3)
精简代码(简易版本)
C 语言(2D DP 表)
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n, W;
printf("输入物品数量和容量: ");
scanf("%d %d", &n, &W);
int wt[n], val[n];
printf("输入重量: ");
for (int i = 0; i < n; i++) scanf("%d", &wt[i]);
printf("输入价值: ");
for (int i = 0; i < n; i++) scanf("%d", &val[i]);
int dp[n + 1][W + 1];
for (int i = 0; i <= n; i++) {
for (int w = 0; w <= W; w++) {
if (i == 0 || w == 0) dp[i][w] = 0;
else if (wt[i-1] <= w)
dp[i][w] = MAX(val[i-1] + dp[i-1][w - wt[i-1]], dp[i-1][w]);
else
dp[i][w] = dp[i-1][w];
}
}
printf("最大价值: %d\n", dp[n][W]);
return 0;
}Python(1D 优化版本)
weights = list(map(int, input("输入重量: ").split()))
values = list(map(int, input("输入价值: ").split()))
W = int(input("输入容量: "))
n = len(weights)
dp = [0] * (W + 1)
for i in range(n):
for w in range(W, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
print("最大价值:", dp[W])为什么它很重要
- 引入了基于决策的 DP:拿或不拿
- 建立在递推直觉(状态转移)之上
- 构成了子集和、等分划分和资源分配问题的基础
- 教授了依赖于容量的状态
这是你第一次感受到贪婪欲望与受限现实之间的张力,一个经典的 DP 问题。
逐步示例
物品:(w,v) = (1,1), (3,4), (4,5), (5,7), W = 7
| $i/w$ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 2 | 0 | 1 | 1 | 4 | 5 | 5 | 5 | 5 |
| 3 | 0 | 1 | 1 | 4 | 5 | 6 | 6 | 9 |
| 4 | 0 | 1 | 1 | 4 | 5 | 7 | 8 | 9 |
答案 = 9
亲自尝试
- 添加代码来重构被选中的物品。
- 比较 2D 和 1D DP 的输出。
- 修改代码,在给定价值下最小化重量。
- 为小输入可视化表的转移过程。
- 用大重量进行实验,测试性能。
测试用例
| 重量 | 价值 | W | 预期结果 | 备注 |
|---|---|---|---|---|
| [1,2,3] | [10,15,40] | 6 | 65 | 所有物品都能装下 |
| [2,3,4,5] | [3,4,5,6] | 5 | 7 | (2,3) |
| [1,3,4,5] | [1,4,5,7] | 7 | 9 | (3,4) |
| [2,5] | [5,10] | 3 | 5 | 只有第一个能装下 |
复杂度
- 时间:O(n×W)
- 空间:O(n×W) → O(W)(优化后)
0/1 背包问题是 DP 的核心,每个决策都呼应着基本的权衡:拿还是不拿。
412 子集和问题
子集和问题是布尔动态规划的一个基本示例。我们不是要最大化或最小化,而是简单地询问“是否可能?”——我们能否挑选一个数字子集,使其总和等于给定的目标值?
这个问题构成了许多组合动态规划问题的基础,例如等和分割、子集计数、目标和,甚至背包问题本身。
我们要解决什么问题?
给定:
- 一个包含
n个正整数的数组arr[] - 一个目标和
S
判断是否存在 arr[] 的一个子集,其元素之和恰好等于 S。
我们定义: $$ dp[i][s] = \text{如果前 } i \text{ 个元素的子集可以构成和 } s \text{,则为真} $$
递推关系:
- 如果
arr[i-1] > s: [ dp[i][s] = dp[i-1][s] ] - 否则: [ dp[i][s] = dp[i-1][s] \lor dp[i-1][s - arr[i-1]] ]
基本情况: $$ dp[0][0] = \text{true}, \quad dp[0][s>0] = \text{false} $$
答案: $$ dp[n][S] $$
它是如何工作的(通俗解释)?
可以把它看作一个是/否表格:
- 行 → 物品
- 列 → 和
每个单元格询问:“我能否使用前 i 个物品构成和 s?” 答案来自于跳过或包含当前物品。
示例:arr = [2, 3, 7, 8, 10], S = 11
| $i/和$ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | T | F | F | F | F | F | F | F | F | F | F | F |
| 1 (2) | T | F | T | F | F | F | F | F | F | F | F | F |
| 2 (3) | T | F | T | T | F | T | F | F | F | F | F | F |
| 3 (7) | T | F | T | T | F | T | F | T | T | F | T | T |
dp[5][11] = True → [3, 8] 是一个有效的子集。
精简代码(简易版本)
C 语言(二维布尔表)
#include <stdio.h>
int main(void) {
int n, S;
printf("输入元素个数和目标总和: ");
scanf("%d %d", &n, &S);
int arr[n];
printf("输入元素: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int dp[n + 1][S + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int s = 1; s <= S; s++) dp[0][s] = 0;
for (int i = 1; i <= n; i++) {
for (int s = 1; s <= S; s++) {
if (arr[i-1] > s)
dp[i][s] = dp[i-1][s];
else
dp[i][s] = dp[i-1][s] || dp[i-1][s - arr[i-1]];
}
}
printf("子集和 %s 可能\n", dp[n][S] ? "是" : "不是");
return 0;
}Python(一维优化)
arr = list(map(int, input("输入元素: ").split()))
S = int(input("输入目标总和: "))
dp = [False] * (S + 1)
dp[0] = True
for num in arr:
for s in range(S, num - 1, -1):
dp[s] = dp[s] or dp[s - num]
print("子集和可能" if dp[S] else "不可能")为什么它重要
- 引入了布尔动态规划(真/假状态)
- 是等和分割、目标和以及子集计数等问题的基础
- 与 0/1 背包问题密切相关,但没有价值项
- 是学习状态依赖性的绝佳练习
这个问题抓住了可行性逻辑的精髓:“如果之前我能构成 s - arr[i],那么现在我也能构成 s。”
分步示例
数组 = [2, 3, 7, 8, 10], S = 11
| 步骤 | 考虑 | 新出现的真和 |
|---|---|---|
| 2 | [0,2] | {2} |
| 3 | [0,2,3,5] | {3,5} |
| 7 | [0,2,3,5,7,9,10,12] | {7,9,10} |
| 8 | [0,2,3,5,7,8,9,10,11,12,13,15] | {11} |
找到 11。
亲自尝试
- 使用父指针表打印一个有效的子集。
- 计算有效子集的总数(转换为计数动态规划)。
- 尝试处理重复元素,这会改变什么吗?
- 修改以检查总和是否能被
k整除。 - 添加负数(使用偏移量变换)。
测试用例
| arr | S | 预期结果 | 备注 |
|---|---|---|---|
| [2,3,7,8,10] | 11 | True | 3+8 |
| [1,2,3] | 5 | True | 2+3 |
| [1,2,5] | 4 | False | 无子集 |
| [1,1,1,1] | 2 | True | 1+1 |
| [5,2,6,4] | 13 | True | 5+4+4 |
复杂度
- 时间:O(n×S)
- 空间:O(n×S) → O(S)(优化后)
子集和问题是动态规划的基石,它是背包问题的是/否版本,教会了我们逻辑如何通过状态流动,一次一个和。
413 等和划分
等和划分问题提出了一个自然的问题:我们能否将一组数字分成两个和相等的子集? 这是子集和问题的直接应用,重新表述为一个划分挑战。 如果总和是偶数,我们检查是否存在一个子集的和为一半,这确保了另一个子集的和相同。
我们解决的是什么问题?
给定:
- 一个包含
n个正整数的数组arr[]
判断它是否能被划分成两个和相等的子集。
设总和为 S。 我们需要检查:
- 如果
S是奇数 → 不可能 - 如果
S是偶数 → 检查是否存在和为S/2的子集
因此,问题简化为:
$$ \text{是否存在 } arr[] \text{ 的一个子集,其和 } = S/2? $$
我们使用与子集和问题相同的递推关系:
$$ dp[i][s] = dp[i-1][s] \lor dp[i-1][s - arr[i-1]] $$
基础情况: $$ dp[0][0] = \text{true} $$
答案: $$ dp[n][S/2] $$
它是如何工作的(通俗解释)?
- 计算总和
S。 - 如果
S是奇数 → 无法平均分割。 - 否则,使用子集和动态规划检查是否能达到
S/2。 如果可以,一个子集构成S/2,剩余的数字自动构成另一半。
示例:arr = [1, 5, 11, 5]
- 总和 = 22
- 目标 = 11
- 我们能凑出 11 吗? 能 →
[11]和[1,5,5]是两部分。
精简代码(简易版本)
C (2D DP 表)
#include <stdio.h>
int main(void) {
int n;
printf("输入元素个数: ");
scanf("%d", &n);
int arr[n];
printf("输入元素: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int sum = 0;
for (int i = 0; i < n; i++) sum += arr[i];
if (sum % 2 != 0) {
printf("无法划分成和相等的子集\n");
return 0;
}
int target = sum / 2;
int dp[n + 1][target + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int s = 1; s <= target; s++) dp[0][s] = 0;
for (int i = 1; i <= n; i++) {
for (int s = 1; s <= target; s++) {
if (arr[i-1] > s)
dp[i][s] = dp[i-1][s];
else
dp[i][s] = dp[i-1][s] || dp[i-1][s - arr[i-1]];
}
}
printf("等和划分 %s 可能的\n", dp[n][target] ? "是" : "不是");
return 0;
}Python (1D 空间优化)
arr = list(map(int, input("输入元素: ").split()))
S = sum(arr)
if S % 2 != 0:
print("无法划分成和相等的子集")
else:
target = S // 2
dp = [False] * (target + 1)
dp[0] = True
for num in arr:
for s in range(target, num - 1, -1):
dp[s] = dp[s] or dp[s - num]
print("等和划分是可能的" if dp[target] else "不可能")为什么它重要
- 直接建立在子集和问题之上
- 展示了动态规划中的问题归约
- 对于平衡划分、负载均衡和公平分配很有用
- 教授从状态可行性的角度思考
等和划分展示了是/否型动态规划如何用简单的逻辑解决看似复杂的问题。
逐步示例
arr = [1, 5, 11, 5]
S = 22→S/2 = 11- 使用子集和动态规划检查是否能凑出
11 - True → 子集
[11]和[1,5,5]
另一个例子:arr = [1, 2, 3, 5]
S = 11(奇数) → 无法划分。
自己动手试试
- 打印实际的子集(回溯表)。
- 尝试包含重复元素的数组。
- 与总和为奇数的情况进行比较。
- 添加约束:每个子集必须至少使用一个元素。
- 为小数组可视化 dp 表。
测试用例
| arr | 总和 | 预期结果 | 备注 |
|---|---|---|---|
| [1,5,11,5] | 22 | True | 11 和 11 |
| [1,2,3,5] | 11 | False | 总和为奇数 |
| [3,3,3,3] | 12 | True | 平均分割 |
| [2,2,2,2,2] | 10 | True | 5+5 |
| [1,1,3,4,7] | 16 | True | 8+8 |
复杂度
- 时间:O(n × S/2)
- 空间:O(S/2) (优化后)
等和划分是动态规划中归约思想的第一个真正体验,将一个更大的问题,表达为子集和问题,并用相同的机制解决。
414 和为特定值的子集数量
“和为特定值的子集数量”问题扩展了“子集和”问题的思路。我们不再问“能否构成这个和?”,而是问“有多少种方式可以构成它?”。 这将布尔型动态规划(DP)转化为计数型动态规划,其中每个状态累积了能产生给定和的组合数量。
我们要解决什么问题?
给定:
- 一个包含
n个正整数的数组arr[] - 一个目标和
S
我们想要元素和恰好等于 S 的子集的数量。
我们定义状态:
$$ dp[i][s] = \text{使用前 } i \text{ 个元素构成和 } s \text{ 的方法数} $$
递推关系:
$$ dp[i][s] = \begin{cases} dp[i-1][s], & \text{如果 } arr[i-1] > s,\ dp[i-1][s] + dp[i-1][s - arr[i-1]], & \text{否则}. \end{cases} $$
基础情况:
$$ dp[0][0] = 1, \quad dp[0][s>0] = 0 $$
最终答案:
$$ dp[n][S] $$
它是如何工作的(通俗解释)
每个元素提供两条路径:包含它或不包含它。 如果包含它,你就计算之前所有能构成 s - arr[i-1] 的子集数量。 如果不包含它,你就继承所有已经能构成 s 的子集。 因此,每个单元格累积了来自两个分支的组合总数。
示例:arr = [2, 3, 5, 6, 8, 10], S = 10
构成 10 的方法:
{10}{2, 8}{2, 3, 5}
答案 = 3
精简代码(简易版本)
C 语言(二维 DP 表)
#include <stdio.h>
int main(void) {
int n, S;
printf("输入元素个数和目标总和: ");
scanf("%d %d", &n, &S);
int arr[n];
printf("输入元素: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int dp[n + 1][S + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int s = 1; s <= S; s++) dp[0][s] = 0;
for (int i = 1; i <= n; i++) {
for (int s = 0; s <= S; s++) {
if (arr[i-1] > s)
dp[i][s] = dp[i-1][s];
else
dp[i][s] = dp[i-1][s] + dp[i-1][s - arr[i-1]];
}
}
printf("子集数量: %d\n", dp[n][S]);
return 0;
}Python(一维空间优化)
arr = list(map(int, input("输入元素: ").split()))
S = int(input("输入目标和: "))
dp = [0] * (S + 1)
dp[0] = 1
for num in arr:
for s in range(S, num - 1, -1):
dp[s] += dp[s - num]
print("子集数量:", dp[S])为什么它重要
- 将子集和问题从可行性扩展到计数
- 是“目标和”、“等和划分计数”以及组合计数 DP 的基础
- 展示了递推关系的一个微小变化如何改变问题含义
- 演示了累积(加法)而非布尔或(OR)操作
这是动态规划从逻辑转向组合计数,从“我能吗?”转向“有多少种方式?”的节点。
逐步示例
arr = [2, 3, 5, 6, 8, 10], S = 10
| i | arr[i] | 构成 10 的方法数 | 解释 |
|---|---|---|---|
| 1 | 2 | 0 | 尚无法达到 10 |
| 2 | 3 | 0 | 仅 2+3=5 |
| 3 | 5 | 1 | {5} |
| 4 | 6 | 1 | {10} |
| 5 | 8 | 2 | {2,8}, {10} |
| 6 | 10 | 3 | {10}, {2,8}, {2,3,5} |
答案 = 3
一个温和的证明(为什么它有效)
我们使用包含-排除原理构建 dp[i][s]:
要使用前 i 项构成和 s,存在两种可能性:
- 排除
arr[i-1]:所有能构成s的子集仍然有效 $$ dp[i-1][s] $$ - 包含
arr[i-1]:每个之前能构成s - arr[i-1]的子集现在能构成s$$ dp[i-1][s - arr[i-1]] $$
因此:
$$ dp[i][s] = dp[i-1][s] + dp[i-1][s - arr[i-1]] $$
不会发生重复计数,因为每个元素只被处理一次,每个子问题只贡献到一个分支。 通过逐层构建,dp[n][S] 累积了所有和等于 S 的有效子集组合。
亲自尝试
- 使用递归回溯打印所有有效子集。
- 修改 DP 以计算和 ≤ 目标的子集数量。
- 添加重复元素并比较结果。
- 应用模 $10^9 + 7$ 来处理大计数。
- 扩展到计算和之差等于 D 的子集数量。
测试用例
| arr | S | 预期结果 | 备注 |
|---|---|---|---|
| [2,3,5,6,8,10] | 10 | 3 | {10}, {2,8}, {2,3,5} |
| [1,1,1,1] | 2 | 6 | 任意选择 2 个 1 |
| [1,2,3] | 3 | 2 | {3}, {1,2} |
| [1,2,5] | 4 | 0 | 无子集 |
| [2,4,6,10] | 16 | 2 | {6,10}, {2,4,10} |
复杂度
- 时间:$O(n \times S)$
- 空间:$O(S)$
“和为特定值的子集数量”问题完美地阐释了动态规划如何从可行性问题演变为枚举问题,计算每一条通向成功的路径。
415 目标和问题
目标和问题结合了子集和与符号分配,不是选择元素,而是为每个元素分配 + 或 − 号,使得它们的总和等于目标值。这是一个绝佳的例子,展示了动态规划如何将代数约束转化为组合计数。
我们要解决什么问题?
给定:
- 一个包含
n个非负整数的数组arr[] - 一个目标值
T
计算为元素分配 + 或 − 号的方法数,使得:
$$ a_1 \pm a_2 \pm a_3 \pm \ldots \pm a_n = T $$
每个元素必须出现一次,且带有正号或负号。
我们定义:
- 令总和为 $S = \sum arr[i]$
如果我们将其分成两个子集 $P$(正号)和 $N$(负号),则有:
$$ \begin{cases} P + N = S \ P - N = T \end{cases} $$
解这些方程:
$$ P = \frac{S + T}{2} $$
因此,问题转化为计算和等于 (S + T)/2 的子集数量。
如果 $(S + T)$ 是奇数或 $T > S$,答案 = 0(不可能)。
核心思想
将符号分配问题转化为子集计数问题:
$$ \text{计算和为 } P = \frac{S + T}{2} \text{ 的子集数量} $$
然后使用"和为特定值的子集计数"中的递推关系:
$$ dp[i][p] = \begin{cases} dp[i-1][p], & \text{如果 } arr[i-1] > p,\ dp[i-1][p] + dp[i-1][p - arr[i-1]], & \text{否则.} \end{cases} $$
基本情况:
$$ dp[0][0] = 1 $$
答案:
$$ dp[n][P] $$
工作原理(通俗解释)
将每个元素视为被放置在两侧之一:正号侧或负号侧。 与其直接模拟符号分配,我们计算有多少个子集的和等于 $(S + T)/2$。 该子集代表所有被分配 + 号的数字;其余数字则隐式地成为 − 号。
示例: arr = [1, 1, 2, 3], T = 1 总和 S = 7 → $P = (7 + 1)/2 = 4$
因此我们计算和为 4 的子集:
{1, 3}{1, 1, 2}答案 = 2
精简代码(简易版本)
C 语言(二维 DP 表)
#include <stdio.h>
int main(void) {
int n, T;
printf("输入元素个数和目标值: ");
scanf("%d %d", &n, &T);
int arr[n];
printf("输入元素: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int S = 0;
for (int i = 0; i < n; i++) S += arr[i];
if ((S + T) % 2 != 0 || T > S) {
printf("无解\n");
return 0;
}
int P = (S + T) / 2;
int dp[n + 1][P + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int j = 1; j <= P; j++) dp[0][j] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= P; j++) {
if (arr[i-1] > j)
dp[i][j] = dp[i-1][j];
else
dp[i][j] = dp[i-1][j] + dp[i-1][j - arr[i-1]];
}
}
printf("方法数: %d\n", dp[n][P]);
return 0;
}Python(一维空间优化)
arr = list(map(int, input("输入元素: ").split()))
T = int(input("输入目标值: "))
S = sum(arr)
if (S + T) % 2 != 0 or T > S:
print("无解")
else:
P = (S + T) // 2
dp = [0] * (P + 1)
dp[0] = 1
for num in arr:
for s in range(P, num - 1, -1):
dp[s] += dp[s - num]
print("方法数:", dp[P])为什么重要
- 将符号分配问题转化为子集计数问题
- 强化了动态规划中的代数变换技巧
- 为表达式求值、划分问题和概率求和奠定了基础
- 展示了数学重构如何简化状态设计
这是一个强有力的例子,将一个棘手的 ± 求和问题转化为熟悉的计数型动态规划问题。
逐步示例
arr = [1, 1, 2, 3], T = 1 $S = 7$ → $P = 4$
和为 4 的子集:
{1, 3}{1, 1, 2}
答案 = 2
一个温和的证明(为什么有效)
令正号子集的和为 $P$,负号子集的和为 $N$。 我们有:
$$ P - N = T \quad \text{且} \quad P + N = S $$
两式相加: $$ 2P = S + T \implies P = \frac{S + T}{2} $$
因此,任何有效的符号分配恰好对应于一个和为 $P$ 的子集。 每个和为 $P$ 的子集定义了一个唯一的符号配置:
- $P$ 中的数字 → 正号
- 不在 $P$ 中的数字 → 负号
所以,计算和为 $P$ 的子集数量等价于计算所有有效的符号分配方法数。
动手尝试
- 处理零元素(它们会使计数加倍)。
- 返回所有可能的符号配置。
- 用负的
T值测试(具有相同的对称性)。 - 比较暴力枚举与动态规划的结果。
- 修改以满足诸如"至少 k 个正数"之类的约束。
测试用例
| arr | T | 预期结果 | 备注 |
|---|---|---|---|
| [1,1,2,3] | 1 | 2 | {1,3}, {1,1,2} |
| [1,2,3] | 0 | 2 | {1,2,3} 和 {-1,-2,-3} |
| [2,2,2,2] | 4 | 6 | 多种方式 |
| [1,1,1,1] | 0 | 6 | 对称划分 |
| [5,3,2,1] | 5 | 2 | {2,3}, {5} |
复杂度
- 时间复杂度:$O(n \times P)$
- 空间复杂度:$O(P)$
目标和问题展示了代数与动态规划如何相遇:通过将符号分配重新解释为子集选择,你将一个关于加号和减号的谜题转化为了一个清晰的组合计数问题。
416 完全背包问题
完全背包问题是经典 0/1 背包问题的无限制版本。在这里,每个物品可以被选择多次,只要总重量不超过背包容量。它是动态规划中可重用状态最优雅的示例之一。
我们要解决什么问题?
给定:
n个物品,每个物品有weight[i]和value[i]- 一个容量为
W的背包
求在不超出容量 W 的前提下,可以达成的最大总价值。 每个物品可以使用任意多次。
我们定义状态:
$$ dp[w] = \text{容量为 } w \text{ 时的最大价值} $$
递推关系:
$$ dp[w] = \max_{i: , weight[i] \le w} \big( dp[w - weight[i]] + value[i] \big) $$
基础情况:
$$ dp[0] = 0 $$
最终答案:
$$ dp[W] $$
与 0/1 背包的关键区别在于迭代顺序 —— 对于完全背包,我们按重量正向遍历,这样物品就可以被重复使用。
它是如何工作的(通俗解释)
把容量想象成一个梯子。在每一个横档 w 处,你检查每一个物品:
- 如果它能放进去,你就问:“如果我拿这个物品,用剩下的空间我能做到的最好情况是什么?”
- 由于物品可以重复使用,你之后可以再次添加它。
这样,每一个 dp[w] 都从更小的容量构建而来,每个都可能再次使用同一个物品。
示例: 物品 = (重量, 价值): (2,4), (3,7), (4,9) W = 7
| 容量 | dp[w] | 解释 |
|---|---|---|
| 0 | 0 | 基础情况 |
| 1 | 0 | 没有物品能放下 |
| 2 | 4 | 一个 (2,4) |
| 3 | 7 | 一个 (3,7) |
| 4 | 9 | 一个 (4,9) |
| 5 | 11 | (2,4)+(3,7) |
| 6 | 14 | (3,7)+(3,7) |
| 7 | 16 | (3,7)+(4,9) |
答案 = 16
精简代码(简易版本)
C 语言(自底向上 1D DP)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int main(void) {
int n, W;
printf("输入物品数量和背包容量: ");
scanf("%d %d", &n, &W);
int wt[n], val[n];
printf("输入物品重量: ");
for (int i = 0; i < n; i++) scanf("%d", &wt[i]);
printf("输入物品价值: ");
for (int i = 0; i < n; i++) scanf("%d", &val[i]);
int dp[W + 1];
for (int w = 0; w <= W; w++) dp[w] = 0;
for (int i = 0; i < n; i++) {
for (int w = wt[i]; w <= W; w++) {
dp[w] = MAX(dp[w], val[i] + dp[w - wt[i]]);
}
}
printf("最大价值: %d\n", dp[W]);
return 0;
}Python(迭代版本)
weights = list(map(int, input("输入物品重量: ").split()))
values = list(map(int, input("输入物品价值: ").split()))
W = int(input("输入背包容量: "))
dp = [0] * (W + 1)
for i in range(len(weights)):
for w in range(weights[i], W + 1):
dp[w] = max(dp[w], values[i] + dp[w - weights[i]])
print("最大价值:", dp[W])为什么它很重要
- 展示了可重用的子问题,物品可以出现多次
- 与零钱兑换(最少硬币数)和钢条切割问题相关联
- 是整数划分、资源分配以及有界-无界混合问题的基础
- 教授正向迭代的逻辑
完全背包问题是展示带重复的 DP 的完美范例。
分步示例
物品: (2,4), (3,7), (4,9), W = 7
| w | dp[w] | 最佳选择 |
|---|---|---|
| 0 | 0 | 基础情况 |
| 1 | 0 | 无 |
| 2 | 4 | (2) |
| 3 | 7 | (3) |
| 4 | 9 | (4) |
| 5 | 11 | (2,3) |
| 6 | 14 | (3,3) |
| 7 | 16 | (3,4) |
答案 = 16
一个温和的证明(为什么它有效)
对于每个容量 w,我们考虑每一个满足 weight[i] ≤ w 的物品 i。 如果我们选择物品 i,我们获得它的价值加上剩余空间 w - weight[i] 所能达到的最佳价值:
$$ dp[w] = \max_i \big( value[i] + dp[w - weight[i]] \big) $$
与 0/1 背包(必须避免重用)不同,这个递推关系允许重用,因为 dp[w - weight[i]] 是在同一轮遍历中先于 dp[w] 计算的,这意味着物品 i 可以贡献多次。
通过从 0 到 W 填充数组,每个容量的最佳价值都来自于更小容量的最优子结构。
自己动手试试
- 通过追踪前驱来打印被选中的物品。
- 与 0/1 背包的结果进行比较。
- 添加约束:每个物品最多 k 个副本。
- 应用于钢条切割问题:
weight = length,value = price。 - 尝试分数重量(这里贪心算法会失败)。
测试用例
| 重量 | 价值 | W | 期望值 | 备注 |
|---|---|---|---|---|
| [2,3,4] | [4,7,9] | 7 | 16 | (3,4) |
| [5,10,15] | [10,30,50] | 20 | 100 | 四个 5 或两个 10 |
| [1,2,3] | [10,15,40] | 6 | 90 | 六个 1 |
| [2,5] | [5,10] | 3 | 5 | 一个 2 |
| [1,3,4,5] | [10,40,50,70] | 8 | 160 | 多个 1 和 3 的组合 |
复杂度
- 时间复杂度:$O(n \times W)$
- 空间复杂度:$O(W)$
完全背包问题抓住了可重用 DP 状态的精髓,每一步都建立在更小的、自相似的子问题上,一点一点地叠加价值,直到容量被填满。
417 分数背包问题
分数背包问题是 0/1 背包问题的近亲,但有一个转折。在这里,你可以将物品分割成部分,取部分重量以最大化总价值。这个问题不是用动态规划(DP)解决的;它是一个贪心算法,作为一个对比,展示了在哪些情况下不需要使用动态规划。
我们要解决什么问题?
给定:
n个物品,每个物品有weight[i]和value[i]- 一个容量为
W的背包
通过可能取物品的一部分,找出可实现的最大总价值。
我们定义:
- 价值密度(比率): $$ \text{ratio}[i] = \frac{value[i]}{weight[i]} $$
为了最大化价值:
- 按比率降序对物品排序。
- 尽可能多地取完整的物品。
- 取下一个物品的一部分以填满剩余容量。
答案是所选(完整 + 部分)价值的总和。
它是如何工作的(通俗解释)
如果每个物品都可以分割,最好的方法是先取单位重量价值最高的物品。 这就像用金粉装满你的袋子,从最富有的金粉开始,然后转向价值较低的种类。
示例: 物品:
| 物品 | 价值 | 重量 | 比率 |
|---|---|---|---|
| 1 | 60 | 10 | 6.0 |
| 2 | 100 | 20 | 5.0 |
| 3 | 120 | 30 | 4.0 |
容量 = 50
- 取物品 1 的全部 → 重量 10,价值 60
- 取物品 2 的全部 → 重量 20,价值 100
- 取物品 3 的 20/30 = 2/3 → 重量 20,价值 80
总计 = 60 + 100 + 80 = 240
精简代码(简单版本)
C 语言(贪心算法)
#include <stdio.h>
struct Item {
int value, weight;
};
int compare(const void *a, const void *b) {
double r1 = (double)((struct Item *)a)->value / ((struct Item *)a)->weight;
double r2 = (double)((struct Item *)b)->value / ((struct Item *)b)->weight;
return (r1 < r2) ? 1 : -1;
}
int main(void) {
int n, W;
printf("输入物品数量和容量: ");
scanf("%d %d", &n, &W);
struct Item arr[n];
printf("输入价值和重量:\n");
for (int i = 0; i < n; i++)
scanf("%d %d", &arr[i].value, &arr[i].weight);
qsort(arr, n, sizeof(struct Item), compare);
double totalValue = 0.0;
int curWeight = 0;
for (int i = 0; i < n; i++) {
if (curWeight + arr[i].weight <= W) {
curWeight += arr[i].weight;
totalValue += arr[i].value;
} else {
int remain = W - curWeight;
totalValue += arr[i].value * ((double)remain / arr[i].weight);
break;
}
}
printf("最大价值: %.2f\n", totalValue);
return 0;
}Python(贪心算法实现)
items = []
n = int(input("输入物品数量: "))
W = int(input("输入容量: "))
for _ in range(n):
v, w = map(int, input("输入价值和重量: ").split())
items.append((v, w, v / w))
items.sort(key=lambda x: x[2], reverse=True)
total_value = 0.0
cur_weight = 0
for v, w, r in items:
if cur_weight + w <= W:
cur_weight += w
total_value += v
else:
remain = W - cur_weight
total_value += v * (remain / w)
break
print("最大价值:", round(total_value, 2))为什么它很重要
- 展示了不需要动态规划的情况,即贪心选择性质
- 与 0/1 背包问题(需要动态规划)形成对比
- 建立基于比率优化的直觉
- 出现在资源分配、金融、优化等领域
分数背包问题是“连续”版本,你不是选择取或不取,而是不断倒入最好的部分,直到空间用完。
逐步示例
| 物品 | 价值 | 重量 | 比率 | 取用情况 |
|---|---|---|---|---|
| 1 | 60 | 10 | 6.0 | 全部 |
| 2 | 100 | 20 | 5.0 | 全部 |
| 3 | 120 | 30 | 4.0 | 2/3 |
总计 = 240
一个温和的证明(为什么它有效)
如果所有物品都可以任意分割,最优策略总是先取价值密度最高的物品。 证明概要:
- 假设一个最优解跳过一个更高比率的物品,而取了一个更低比率的物品。
- 用更高比率的物品替换部分更低比率的物品,会严格增加总价值。
- 矛盾,因此,按比率排序是最优的。
这个性质被称为贪心选择性质。 因为该问题同时满足最优子结构和贪心选择性质,所以贪心算法就足够了。
自己动手试试
- 对相同物品,比较结果与 0/1 背包问题的结果。
- 添加更多具有相同比率的物品。
- 手动实现排序并测试正确性。
- 检查当容量 < 最小重量时的行为。
- 使用比率图可视化部分填充。
测试用例
| 价值列表 | 重量列表 | W | 预期结果 | 备注 |
|---|---|---|---|---|
| [60,100,120] | [10,20,30] | 50 | 240 | 经典示例 |
| [10,5,15,7,6,18,3] | [2,3,5,7,1,4,1] | 15 | 55.33 | 贪心混合 |
| [25,50,75] | [5,10,15] | 10 | 50 | 完整物品 |
| [5,10,15] | [1,2,3] | 3 | 15 | 全部取用 |
| [1,2,3] | [3,2,1] | 3 | 5 | 最高比率优先 |
复杂度
- 时间:$O(n \log n)$(用于排序)
- 空间:$O(1)$
分数背包问题展示了贪心推理的力量,有时,局部最优的思考确实能带来全局最优。
418 零钱兑换(最少硬币数)
零钱兑换(最少硬币数)问题是关于找出凑成给定金额所需的最少硬币数量。与计算所有组合的计数版本不同,这个版本专注于最小化,即达到目标金额的最短路径。
这是一个经典的无限制动态规划问题,其中每种硬币可以多次使用。
我们要解决什么问题?
给定:
- 一个硬币列表
coins[] - 一个目标金额
A
找出凑成金额 A 所需的最少硬币数量。 如果不可能,返回 -1。
我们定义状态:
$$ dp[x] = \text{凑成金额 } x \text{ 所需的最少硬币数量} $$
递推关系:
$$ dp[x] = \min_{c \in coins,; c \le x} \big( dp[x - c] + 1 \big) $$
基础情况:
$$ dp[0] = 0 $$
最终答案:
$$ dp[A] $$
如果没有可能的组合,dp[A] 将保持为无穷大(或一个大的哨兵值)。
它是如何工作的(通俗解释)
想象一下逐步构建金额。 对于每个值 x,尝试所有合适的硬币 c,看看哪个能导致总硬币数最少。 每个状态 dp[x] 代表了从 0 到 x 的最短链条。
这就像爬楼梯到目标楼层,每枚硬币是一个步长,你想要步数最少的路径。
示例: coins = [1, 3, 4], A = 6
| 金额 | dp[x] | 选择 |
|---|---|---|
| 0 | 0 | 基础 |
| 1 | 1 | 1 |
| 2 | 2 | 1+1 |
| 3 | 1 | 3 |
| 4 | 1 | 4 |
| 5 | 2 | 3+2 |
| 6 | 2 | 3+3 |
答案 = 2 (3 + 3)
精简代码(简单版本)
C (自底向上动态规划)
#include <stdio.h>
#define INF 1000000
#define MIN(a,b) ((a) < (b) ? (a) : (b))
int main(void) {
int n, A;
printf("输入硬币数量和金额: ");
scanf("%d %d", &n, &A);
int coins[n];
printf("输入硬币面值: ");
for (int i = 0; i < n; i++) scanf("%d", &coins[i]);
int dp[A + 1];
for (int i = 1; i <= A; i++) dp[i] = INF;
dp[0] = 0;
for (int x = 1; x <= A; x++) {
for (int i = 0; i < n; i++) {
int c = coins[i];
if (x - c >= 0)
dp[x] = MIN(dp[x], dp[x - c] + 1);
}
}
if (dp[A] == INF) printf("不可能\n");
else printf("最少硬币数: %d\n", dp[A]);
return 0;
}Python (简单迭代版本)
coins = list(map(int, input("输入硬币面值: ").split()))
A = int(input("输入金额: "))
INF = float('inf')
dp = [INF] * (A + 1)
dp[0] = 0
for x in range(1, A + 1):
for c in coins:
if x - c >= 0:
dp[x] = min(dp[x], dp[x - c] + 1)
print("最少硬币数:" if dp[A] != INF else "不可能", end=" ")
print(dp[A] if dp[A] != INF else "")为什么它重要
- 经典的无限制最小化动态规划
- 许多资源优化问题的核心
- 图最短路径、最少步数、编辑操作的基础
- 与计数版本形成对比(相同的递推关系,不同的聚合方式)
这个问题展示了在动态规划中如何用最小值替换求和,从而从"有多少种"转变为"最少需要多少"。
逐步示例
Coins = [1, 3, 4], A = 6
| x | 选择 | dp[x] |
|---|---|---|
| 0 | - | 0 |
| 1 | dp[0]+1 | 1 |
| 2 | dp[1]+1 | 2 |
| 3 | dp[0]+1 | 1 |
| 4 | dp[0]+1 | 1 |
| 5 | min(dp[2]+1, dp[1]+1) | 2 |
| 6 | min(dp[3]+1, dp[2]+1) | 2 |
答案 = 2 (3+3)
一个温和的证明(为什么它有效)
递推关系从小金额向上构建。 对于每个金额 x,每枚硬币 c 提供了一条从 x - c → x 的路径,增加 1 步。
通过归纳法:
- 基础情况:
dp[0] = 0(凑成 0 不需要硬币)。 - 归纳步骤:假设对于所有
< x的金额都存在最优解。 那么,所有dp[x - c] + 1中的最小值就是凑成x的最少硬币数。
由于每个 x 都重用了最优子解,dp[A] 是全局最优的。
自己尝试
- 打印出选择的硬币(回溯
dp[x])。 - 添加一个永远没有帮助的硬币(例如
[1, 3, 10],A=6)。 - 与贪心算法比较
[1,3,4](会失败)。 - 扩展到有限硬币(有界背包问题)。
- 尝试更大的
A来观察性能。
测试用例
| 硬币 | 金额 | 预期结果 | 备注 |
|---|---|---|---|
| [1,3,4] | 6 | 2 | 3+3 |
| [2,5] | 3 | -1 | 不可能 |
| [1,2,5] | 11 | 3 | 5+5+1 |
| [9,6,5,1] | 11 | 2 | 6+5 |
| [2,3,7] | 12 | 3 | 3+3+6 |
复杂度
- 时间复杂度:$O(n \times A)$
- 空间复杂度:$O(A)$
零钱兑换(最少硬币数)问题是无限制动态规划与优化的交汇点,通过简单、重复的决策构建到达目标的最小路径。
419 零钱兑换(计算方式)
零钱兑换(计算方式)问题关注的是使用给定的硬币有多少种不同的方法可以凑出指定的金额。与最小化硬币数量的版本不同,这里每一种组合都算数,顺序无关紧要。
这是一个无界组合动态规划的完美示例,其中每种硬币可以使用多次,但排列顺序无关。
我们要解决什么问题?
给定:
- 一个硬币列表
coins[] - 一个目标金额
A
找出和为 A 的不同组合(无序)的数量。
我们定义状态:
$$ dp[x] = \text{凑出金额 } x \text{ 的方法数} $$
递推关系:
$$ dp[x] = \sum_{c \in coins,; c \le x} dp[x - c] $$
基础情况:
$$ dp[0] = 1 $$
为了避免多次计算相同的组合(例如 [1,2] 和 [2,1]),我们首先遍历硬币,然后遍历金额。
它是如何工作的(通俗解释)
我们计算的是组合,而不是排列。 这意味着 {1,2} 和 {2,1} 被视为同一种方式。 因此,我们固定每种硬币的顺序,当处理一种硬币时,我们允许它被重复使用,但不能与后续的硬币重新排序。
示例: coins = [1, 2, 5], A = 5
方式:
- 1+1+1+1+1
- 1+1+1+2
- 1+2+2
- 5
答案 = 4
精简代码(简单版本)
C 语言(自底向上动态规划)
#include <stdio.h>
int main(void) {
int n, A;
printf("输入硬币数量和金额: ");
scanf("%d %d", &n, &A);
int coins[n];
printf("输入硬币面值: ");
for (int i = 0; i < n; i++) scanf("%d", &coins[i]);
long long dp[A + 1];
for (int i = 0; i <= A; i++) dp[i] = 0;
dp[0] = 1;
for (int i = 0; i < n; i++) {
for (int x = coins[i]; x <= A; x++) {
dp[x] += dp[x - coins[i]];
}
}
printf("方式数量: %lld\n", dp[A]);
return 0;
}Python(迭代组合)
coins = list(map(int, input("输入硬币面值: ").split()))
A = int(input("输入金额: "))
dp = [0] * (A + 1)
dp[0] = 1
for c in coins:
for x in range(c, A + 1):
dp[x] += dp[x - c]
print("方式数量:", dp[A])为什么它重要
- 组合动态规划的基础
- 划分计数、组合和以及概率动态规划的基石
- 强调了循环顺序的重要性,改变顺序会计算排列而不是组合
- 与数论中的整数划分问题相关联
它教会你,你计算的是什么(顺序 vs 组合)取决于你如何迭代。
逐步示例
Coins = [1, 2, 5], A = 5
初始化 dp = [1, 0, 0, 0, 0, 0]
| 硬币 | 状态 | dp 数组(处理完成后) |
|---|---|---|
| 1 | 所有 | [1, 1, 1, 1, 1, 1] |
| 2 | 添加使用 2 的组合 | [1, 1, 2, 2, 3, 3] |
| 5 | 添加使用 5 的组合 | [1, 1, 2, 2, 3, 4] |
答案 = 4
一个温和的证明(为什么它有效)
我们通过累加每种硬币 c 的贡献来填充 dp[x]: 每次我们使用硬币 c,我们就从子问题 x - c 转移到 x。
$$ dp[x] = \sum_{c \in coins} dp[x - c] $$
但我们必须固定硬币的迭代顺序以确保组合的唯一性。 首先迭代硬币确保了每个组合都以一种规范顺序形成:
1在2之前,2在5之前 所以{1,2}只出现一次,而不是两次。
通过归纳法:
- 基础:
dp[0] = 1(一种方式:不使用任何硬币) - 步骤:每个
dp[x]通过扩展较小的和来计算有效的组合。
自己尝试
- 交换循环顺序 → 计算排列。
- 添加硬币
3并比较增长情况。 - 通过递归打印所有组合。
- 为大的结果添加模 $10^9 + 7$。
- 与最小硬币数问题(相同硬币,不同目标)进行比较。
测试用例
| 硬币 | 金额 | 预期结果 | 说明 |
|---|---|---|---|
| [1,2,5] | 5 | 4 | 经典示例 |
| [2,3,5,6] | 10 | 5 | 多种组合 |
| [1] | 3 | 1 | 只有一种方式 |
| [2] | 3 | 0 | 不可能 |
| [1,2,3] | 4 | 4 | (1+1+1+1), (1+1+2), (2+2), (1+3) |
复杂度
- 时间:$O(n \times A)$
- 空间:$O(A)$
零钱兑换(计算方式)问题抓住了动态规划的组合核心,一个简单的递推关系,但通过顺序感知的迭代魔法,将计数从混乱变为清晰。
420 多维背包问题
多维背包问题(也称为多约束背包问题)将经典的 0/1 背包问题扩展到了一个更丰富、更现实的世界。在这里,每个物品消耗多种类型的资源(重量、体积、成本等),我们必须同时满足所有约束。
这是从一维的简单性让位于多维复杂性的地方。
我们要解决什么问题?
给定:
n个物品- 每个物品
i具有:- 价值
v[i] - 在
m个维度上的重量w[i][1..m]
- 价值
- 每个维度的容量
C[1..m]
选择一个物品子集,在满足以下条件的前提下最大化总价值:
$$ \forall j \in [1, m]: \sum_{i \in S} w[i][j] \le C[j] $$
状态定义:
$$ dp[c_1][c_2] \ldots [c_m] = \text{在容量为 } (c_1, \ldots, c_m) \text{ 时可获得的最大价值} $$
递推关系:
$$ dp[\vec{c}] = \max \big( dp[\vec{c}],; dp[\vec{c} - \vec{w_i}] + v[i] \big) $$
其中 $\vec{c} - \vec{w_i}$ 表示按分量减去所有权重。
它是如何工作的(通俗解释)
这就像为一艘有多个限制(重量、体积、燃料消耗)的宇宙飞船打包,每个物品在不同维度上的消耗都不同。 你不能只填满一个限制;每个物品的成本会同时影响所有维度。
现在的 DP 网格是多维的:你必须遍历容量的每一种组合,并决定是否包含每个物品。
示例(2D 情况): 物品:
| 物品 | 价值 | 重量 | 体积 |
|---|---|---|---|
| 1 | 60 | 2 | 3 |
| 2 | 100 | 3 | 4 |
| 3 | 120 | 4 | 5 |
容量:(W=5, V=7)
答案:160(物品 1 + 2)
简短代码(2D DP 示例)
C 语言(2D 容量,0/1 版本)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int main(void) {
int n, W, V;
printf("输入物品数量、重量容量、体积容量: ");
scanf("%d %d %d", &n, &W, &V);
int w[n], vol[n], val[n];
printf("输入重量、体积、价值:\n");
for (int i = 0; i < n; i++)
scanf("%d %d %d", &w[i], &vol[i], &val[i]);
int dp[W + 1][V + 1];
for (int i = 0; i <= W; i++)
for (int j = 0; j <= V; j++)
dp[i][j] = 0;
for (int i = 0; i < n; i++) {
for (int wi = W; wi >= w[i]; wi--) {
for (int vi = V; vi >= vol[i]; vi--) {
dp[wi][vi] = MAX(dp[wi][vi],
dp[wi - w[i]][vi - vol[i]] + val[i]);
}
}
}
printf("最大价值: %d\n", dp[W][V]);
return 0;
}Python(2D DP)
items = [(2,3,60), (3,4,100), (4,5,120)]
W, V = 5, 7
dp = [[0]*(V+1) for _ in range(W+1)]
for w, v, val in items:
for wi in range(W, w-1, -1):
for vi in range(V, v-1, -1):
dp[wi][vi] = max(dp[wi][vi], dp[wi-w][vi-v] + val)
print("最大价值:", dp[W][V])为什么它重要
- 模拟现实世界的约束,多种资源
- 运筹学、资源分配、物流、多资源调度的核心
- 说明了 DP 维度如何随着复杂性增长
- 迫使仔细设计状态和迭代顺序
当一维不够用时,这种推广捕捉了跨越多维的权衡。
逐步示例(2D)
容量:W=5, V=7 物品:
- (2,3,60)
- (3,4,100)
- (4,5,120)
我们探索子集:
- {1} → (2,3), 价值=60
- {2} → (3,4), 价值=100
- {3} → (4,5), 价值=120
- {1,2} → (5,7), 价值=160 ✅ 最优
- {1,3} → (6,8) ❌ 超出
- {2,3} → (7,9) ❌ 超出
答案 = 160
一个温和的证明(为什么它有效)
通过对物品索引和容量进行归纳:
令 $dp[i][c_1][c_2] \ldots [c_m]$ 为使用前 i 个物品和容量向量 $(c_1, c_2, \ldots, c_m)$ 所能获得的最佳价值。
每个物品有两种选择:
- 排除 → 保持 $dp[i-1][\vec{c}]$
- 包含 → $dp[i-1][\vec{c} - \vec{w_i}] + v[i]$(如果可行)
取最大值。
由于所有转移只依赖于更小的容量,并且每个子问题都是最优的,因此整个 DP 收敛到全局最优解。
自己动手试试
- 添加第三个维度(例如,"时间")。
- 与贪心算法比较(会失败)。
- 可视化 2D 的 DP 表。
- 使用回溯追踪选择的物品。
- 添加无界变体(可重复使用物品)。
测试用例
| 物品 (W,V,Val) | 容量 (W,V) | 预期结果 | 备注 |
|---|---|---|---|
| [(2,3,60),(3,4,100),(4,5,120)] | (5,7) | 160 | (1+2) |
| [(1,2,10),(2,3,20),(3,3,40)] | (3,4) | 40 | 单个最佳 |
| [(2,2,8),(2,3,9),(3,4,14)] | (4,5) | 17 | (1+2) |
| [(3,2,10),(2,4,12),(4,3,18)] | (5,6) | 22 | (1+2) |
复杂度
- 时间:$O(n \times C_1 \times C_2 \times \ldots \times C_m)$
- 空间:$O(C_1 \times C_2 \times \ldots \times C_m)$
多维背包问题提醒我们,每一种额外的资源都会为你的推理和 DP 表增加一个新的维度。
第 43 节. 序列问题
421 最长递增子序列 (O(n^2) DP)
最长递增子序列 (LIS) 问题要求找出一个严格递增的子序列的最大长度。元素不需要连续,只需保持顺序。
我们正在解决什么问题?
给定一个数组 arr[0..n-1],找出最大的 k,使得存在索引 0 ≤ i1 < i2 < ... < ik < n 满足 $$ arr[i_1] < arr[i_2] < \cdots < arr[i_k]. $$
定义状态 $$ dp[i] = \text{以索引 } i \text{ 结尾的 LIS 的长度}. $$
递推关系 $$ dp[i] = 1 + \max_{;0 \le j < i,; arr[j] < arr[i]} dp[j], \quad \text{如果不存在这样的 } j \text{,则 } dp[i] \leftarrow 1. $$
答案 $$ \max_{0 \le i < n} dp[i]. $$
它是如何工作的(通俗解释)
对于每个位置 i,回顾所有更早的位置 j < i 且值更小的位置。任何以 j 结尾的递增子序列都可以通过 arr[i] 来扩展。从中选择最好的一个并加一。如果没有更小的元素,则在 i 处开始一个新的长度为 1 的子序列。
示例:arr = [10, 22, 9, 33, 21, 50, 41, 60]
| i | arr[i] | dp[i] | 解释 |
|---|---|---|---|
| 0 | 10 | 1 | 开始 |
| 1 | 22 | 2 | 10 → 22 |
| 2 | 9 | 1 | 从 9 重新开始 |
| 3 | 33 | 3 | 10 → 22 → 33 |
| 4 | 21 | 2 | 10 → 21 |
| 5 | 50 | 4 | 10 → 22 → 33 → 50 |
| 6 | 41 | 4 | 10 → 22 → 33 → 41 |
| 7 | 60 | 5 | 10 → 22 → 33 → 50 → 60 |
答案是 5。
精简代码(简单版本)
C (O(n^2))
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n;
printf("输入 n: ");
scanf("%d", &n);
int arr[n];
printf("输入数组: ");
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
int dp[n];
for (int i = 0; i < n; i++) dp[i] = 1;
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i]) dp[i] = MAX(dp[i], dp[j] + 1);
}
ans = MAX(ans, dp[i]);
}
printf("LIS 长度: %d\n", ans);
return 0;
}Python (O(n^2))
arr = list(map(int, input("输入数组: ").split()))
n = len(arr)
dp = [1] * n
for i in range(n):
for j in range(i):
if arr[j] < arr[i]:
dp[i] = max(dp[i], dp[j] + 1)
print("LIS 长度:", max(dp) if dp else 0)为什么它重要
- 典型的序列 DP:在前缀上定义状态并通过选择进行扩展。
- 是 LCS(最长公共子序列)、编辑距离、O(n log n) 耐心排序 LIS 以及像俄罗斯套娃信封这样的 2D 变种问题的基础。
- 在排名、时间序列平滑和链式调度中很有用。
逐步示例
arr = [3, 10, 2, 1, 20]
| i | arr[i] | 候选 (dp[j]+1) 且满足 (arr[j] < arr[i]) | dp[i] |
|---|---|---|---|
| 0 | 3 | , | 1 |
| 1 | 10 | dp[0]+1 = 2 | 2 |
| 2 | 2 | , | 1 |
| 3 | 1 | , | 1 |
| 4 | 20 | max( dp[0]+1=2, dp[1]+1=3, dp[2]+1=2, dp[3]+1=2 ) | 3 |
答案是 3。
一个温和的证明(为什么它有效)
令 OPT(i) 表示恰好以索引 i 结尾的 LIS 长度。任何以 i 结尾的 LIS 要么是长度为 1 的 [arr[i]],要么是扩展了某个 j < i 处的严格更小元素。因此 $$ OPT(i) = \max\left( 1,; 1 + \max_{j<i,;arr[j]<arr[i]} OPT(j) \right). $$ 这仅依赖于较小索引的最优值,因此动态规划适用。整体的 LIS 是所有结束位置的最大值: $$ \text{LIS} = \max_i OPT(i). $$ 通过对 i 进行归纳,递推关系正确地计算了 OPT(i),因此最终的最大值是最优的。
自己动手试试
- 恢复一个实际的 LIS:保留一个
parent[i]指向给出最佳转移的j。 - 改为非递减子序列:将
<替换为<=。 - 与 O(n log n) 耐心排序方法进行比较,并验证两者的长度是否匹配。
- 使用并行的
cnt[i]计算最大长度的 LIS 的数量。 - 扩展到 2D 对
(a,b):按a排序,并在b上运行 LIS,注意处理相等的情况。
测试用例
| 数组 | 期望值 |
|---|---|
| [1,2,3,4,5] | 5 |
| [5,4,3,2,1] | 1 |
| [3,10,2,1,20] | 3 |
| [10,22,9,33,21,50,41,60] | 5 |
| [2,2,2,2] | 1 |
复杂度
- 时间复杂度: (O(n^2))
- 空间复杂度: (O(n))
这个 O(n^2) 的 DP 是通往 LIS 最清晰的路径:定义结尾,回顾更小的结尾,并增长最长的链。
422 最长递增子序列(耐心排序)—— O(n log n) 优化
最长递增子序列(LIS)问题可以通过借鉴耐心排序的巧妙思想,获得比经典 O(n²) 动态规划更快的解法。我们不是构建所有可能的序列,而是维护一个最小尾部数组,其中每个元素代表给定长度的递增子序列可能的最小尾部值。
我们要解决什么问题?
给定一个数组 arr[0..n-1],在 O(n log n) 时间内找出最长严格递增子序列的长度。
我们要求的是 $$ \text{LIS 长度} = \max k \text{ 使得 } \exists i_1 < i_2 < \cdots < i_k,; arr[i_1] < arr[i_2] < \cdots < arr[i_k]. $$
核心思想
维护一个数组 tails[],其中 tails[len] = 任何长度为 len+1 的递增子序列的最小尾部值。
对于数组中的每个元素 x:
- 在
tails中使用二分查找找到第一个满足tails[pos] ≥ x的位置pos。 - 用
x替换tails[pos](我们找到了一个更好的尾部)。 - 如果
x大于所有尾部值,则将其追加到数组末尾,子序列长度增加。
最终,len(tails) 就是 LIS 的长度。
工作原理(通俗解释)
想象将数字放到一堆堆牌上(就像耐心纸牌游戏):
- 每堆牌最上面的牌,是结束该长度递增子序列的最小可能数字。
- 当一个新的数字到来时,将其放在最左边那堆顶部数字 ≥ 该数字的牌堆上。
- 如果不存在这样的牌堆,则开始一个新堆。
牌堆的数量就等于 LIS 的长度。
示例: arr = [10, 22, 9, 33, 21, 50, 41, 60]
处理过程:
| x | tails (处理 x 后) |
|---|---|
| 10 | [10] |
| 22 | [10, 22] |
| 9 | [9, 22] |
| 33 | [9, 22, 33] |
| 21 | [9, 21, 33] |
| 50 | [9, 21, 33, 50] |
| 41 | [9, 21, 33, 41] |
| 60 | [9, 21, 33, 41, 60] |
答案 = 5
简洁代码(简易版本)
C 语言(使用二分查找)
#include <stdio.h>
int lower_bound(int arr[], int len, int x) {
int l = 0, r = len;
while (l < r) {
int mid = (l + r) / 2;
if (arr[mid] < x) l = mid + 1;
else r = mid;
}
return l;
}
int main(void) {
int n;
printf("输入 n: ");
scanf("%d", &n);
int a[n];
printf("输入数组: ");
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
int tails[n], len = 0;
for (int i = 0; i < n; i++) {
int pos = lower_bound(tails, len, a[i]);
tails[pos] = a[i];
if (pos == len) len++;
}
printf("LIS 长度: %d\n", len);
return 0;
}Python(使用 bisect 模块)
import bisect
arr = list(map(int, input("输入数组: ").split()))
tails = []
for x in arr:
pos = bisect.bisect_left(tails, x)
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
print("LIS 长度:", len(tails))为何重要
- 将 LIS 问题的时间复杂度从 $O(n^2)$ 降低到 $O(n \log n)$。
- 引入了在动态规划状态转移中使用二分查找的思想。
- 展示了状态压缩,我们只跟踪尾部值,而非所有子问题。
- 是 LIS 重构、LDS(最长递减子序列)、最长双调子序列以及二维 LIS 等问题的基础。
这项技术展示了数学洞察力如何能将一个动态规划表压缩成一个最小化的结构。
逐步示例
arr = [3, 10, 2, 1, 20]
| x | tails |
|---|---|
| 3 | [3] |
| 10 | [3,10] |
| 2 | [2,10] |
| 1 | [1,10] |
| 20 | [1,10,20] |
答案 = 3
一个温和的证明(为何有效)
不变式:
tails[k]= 任何长度为k+1的递增子序列的最小可能尾部值。
当我们放置 x 时:
- 替换一个尾部值保持了子序列的有效性(更短或相等的尾部 → 更大的扩展机会)。
- 追加
x使长度增加一。
通过归纳法:
- 每个
tails[k]随着长度的增加是非递减的。 tails的最终大小等于 LIS 的长度,因为每一堆都代表一个不同的子序列长度。
动手尝试
- 跟踪前驱节点以重构一个 LIS。
- 修改为使用
bisect_right来求解非递减子序列。 - 与 O(n²) 版本的计数进行比较。
- 可视化每次插入后的牌堆状态。
- 在二维排序对
(a,b)上使用此方法解决包络问题。
测试用例
| arr | 预期结果 | 备注 |
|---|---|---|
| [10,22,9,33,21,50,41,60] | 5 | 经典示例 |
| [3,10,2,1,20] | 3 | {3,10,20} |
| [1,2,3,4,5] | 5 | 已递增 |
| [5,4,3,2,1] | 1 | 递减 |
| [2,2,2,2] | 1 | 常数序列 |
复杂度分析
- 时间复杂度:$O(n \log n)$(每个元素一次二分查找)
- 空间复杂度:$O(n)$
基于耐心排序的 LIS 算法将二次方的动态规划转化为优雅的对数方法,这是用空间换取洞察力的典范。
423 最长公共子序列(LCS)
最长公共子序列(LCS)问题旨在找出在两个字符串中以相同相对顺序(不一定是连续的)出现的最长序列。它是最基础的双序列动态规划问题之一,也是 diff、编辑距离和 DNA 序列比对等算法的基础。
我们要解决什么问题?
给定两个序列 $$ X = x_1, x_2, \dots, x_m,\quad Y = y_1, y_2, \dots, y_n $$ 找出同时是两者子序列的最长序列的长度。
定义状态:
$$ dp[i][j] = \text{前缀 } X[0..i-1],, Y[0..j-1] \text{ 的 LCS 长度} $$
递推关系:
$$ dp[i][j] = \begin{cases} 0, & \text{如果 } i = 0 \text{ 或 } j = 0,\ dp[i-1][j-1] + 1, & \text{如果 } x_{i-1} = y_{j-1},\ \max\big(dp[i-1][j],, dp[i][j-1]\big), & \text{如果 } x_{i-1} \ne y_{j-1}. \end{cases} $$
答案:
$$ dp[m][n] $$
它是如何工作的(通俗解释)
我们构建一个网格,其中每个单元格 dp[i][j] 代表 X 的前 i 个字符和 Y 的前 j 个字符的 LCS。
- 如果字符匹配,则沿对角线方向扩展子序列。
- 如果不匹配,则跳过一个字符(从
X或Y中),并取更好的结果。
可以将其想象为逐步对齐两个字符串,并保持最长的匹配顺序。
示例: X = "ABCBDAB", Y = "BDCAB"
最长公共子序列是 "BCAB",长度为 4。
精简代码(简易版本)
C 语言(经典二维 DP)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int main(void) {
char X[100], Y[100];
printf("输入第一个字符串: ");
scanf("%s", X);
printf("输入第二个字符串: ");
scanf("%s", Y);
int m = 0, n = 0;
while (X[m]) m++;
while (Y[n]) n++;
int dp[m + 1][n + 1];
for (int i = 0; i <= m; i++)
for (int j = 0; j <= n; j++)
dp[i][j] = 0;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i - 1] == Y[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = MAX(dp[i - 1][j], dp[i][j - 1]);
}
}
printf("LCS 长度: %d\n", dp[m][n]);
return 0;
}Python(二维 DP)
X = input("输入第一个字符串: ")
Y = input("输入第二个字符串: ")
m, n = len(X), len(Y)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if X[i-1] == Y[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
print("LCS 长度:", dp[m][n])为什么它很重要
- 经典的二维动态规划模板
- 编辑距离、序列比对、Diff 工具的核心
- 展示了通过重叠前缀实现子问题重用
- 有助于理解表格填充和回溯重构
LCS 是动态规划与字符串相似性相遇的地方。
逐步示例
X = "ABCBDAB", Y = "BDCAB"
| i | j | X[i-1], Y[j-1] | dp[i][j] | 解释 |
|---|---|---|---|---|
| 1 | 1 | A, B | 0 | 不匹配 |
| 2 | 1 | B, B | 1 | 匹配 |
| 3 | 2 | C, D | 1 | 传递最大值 |
| 4 | 3 | B, C | 1 | 传递最大值 |
| 5 | 4 | D, A | 2 | 后续匹配 |
| 7 | 5 | B, B | 4 | 完整子序列 |
答案 = 4 ("BCAB")
一个温和的证明(为什么它有效)
通过对 i 和 j 进行归纳:
- 基础情况:$dp[0][j] = dp[i][0] = 0$(空前缀)
- 如果 $x_{i-1} = y_{j-1}$,那么
X[0..i-2]和Y[0..j-2]的每个公共子序列都可以通过这个匹配进行扩展。 - 如果不相等,最长子序列必须排除一个字符,因此取左侧和上方单元格的
max。
由于每个子问题只依赖于更小的前缀,逐行填充表格确保了所有依赖项都已就绪。
自己动手试试
- 重构实际的 LCS(存储方向或回溯)。
- 修改以处理不区分大小写的匹配。
- 与编辑距离公式进行比较。
- 可视化表格中的对角线匹配。
- 用它来查找两行文本之间的差异。
测试用例
| X | Y | 预期结果 | 备注 |
|---|---|---|---|
| "ABCBDAB" | "BDCAB" | 4 | "BCAB" |
| "AGGTAB" | "GXTXAYB" | 4 | "GTAB" |
| "AAAA" | "AA" | 2 | 子集 |
| "ABC" | "DEF" | 0 | 无 |
| "" | "ABC" | 0 | 基础情况 |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$,使用滚动数组可优化至 $O(\min(m,n))$
最长公共子序列教会你如何逐个字符地比对两个世界,从共享的顺序(而非邻近性)中构建相似性。
424 编辑距离(莱文斯坦距离)
编辑距离(或称莱文斯坦距离)问题通过计算将一个字符串转换为另一个字符串所需的最少操作次数来衡量两个字符串的差异程度。允许的操作通常是插入、删除和替换。
这是最优雅的二维动态规划问题之一,它一步步地捕捉了序列之间的转换代价。
我们要解决什么问题?
给定两个字符串 $$ X = x_1, x_2, \dots, x_m,\quad Y = y_1, y_2, \dots, y_n $$ 找到将 X 转换为 Y 所需的最少操作次数,操作包括:
- 插入一个字符
- 删除一个字符
- 替换一个字符
我们定义状态:
$$ dp[i][j] = \text{将 } X[0..i-1] \text{ 转换为 } Y[0..j-1] \text{ 所需的最少编辑次数} $$
递推关系:
$$ dp[i][j] = \begin{cases} i, & \text{如果 } j = 0,\ j, & \text{如果 } i = 0,\ dp[i-1][j-1], & \text{如果 } x_{i-1} = y_{j-1},\ 1 + \min\big(dp[i-1][j],\ dp[i][j-1],\ dp[i-1][j-1]\big), & \text{如果 } x_{i-1} \ne y_{j-1}. \end{cases} $$
- $dp[i-1][j] + 1$ → 删除
- $dp[i][j-1] + 1$ → 插入
- $dp[i-1][j-1] + 1$ → 替换
答案:
$$ dp[m][n] $$
它是如何工作的(通俗解释)
我们构建一个二维网格,比较两个字符串的前缀。
每个单元格回答的问题是:"让 X[:i] 变得像 Y[:j] 的最廉价方式是什么?"
- 如果字符匹配,则继承对角线上的值。
- 如果字符不同,则在插入、删除或替换的代价中选择最小的。
可以想象成打字校正:每一次操作都让你更接近目标。
示例: X = "kitten", Y = "sitting" 操作:
- 替换
k→s - 替换
e→i - 插入
g
答案 = 3
精简代码(简易版本)
C 语言(二维 DP 表)
#include <stdio.h>
#define MIN(a,b) ((a)<(b)?(a):(b))
int min3(int a, int b, int c) {
int m = (a < b) ? a : b;
return (m < c) ? m : c;
}
int main(void) {
char X[100], Y[100];
printf("输入第一个字符串: ");
scanf("%s", X);
printf("输入第二个字符串: ");
scanf("%s", Y);
int m = 0, n = 0;
while (X[m]) m++;
while (Y[n]) n++;
int dp[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + min3(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
}
}
printf("编辑距离: %d\n", dp[m][n]);
return 0;
}Python(紧凑版本)
X = input("输入第一个字符串: ")
Y = input("输入第二个字符串: ")
m, n = len(X), len(Y)
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 X[i-1] == Y[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])
print("编辑距离:", dp[m][n])为什么它重要
- 是拼写检查、DNA序列比对、模糊匹配和差异比较工具的基础
- 展示了多选项递推(每个状态有3种选择)
- 是加权编辑距离(每个操作有不同代价)的基础
- 展示了如何将序列转换编码为动态规划问题
这是动态规划揭示最短转换路径的最清晰的例子之一。
逐步示例
X = "kitten", Y = "sitting"
| i | j | X[:i] | Y[:j] | dp[i][j] | 解释 |
|---|---|---|---|---|---|
| 0 | 0 | "" | "" | 0 | 基础情况 |
| 1 | 1 | "k" | "s" | 1 | 替换 |
| 2 | 2 | "ki" | "si" | 1 | 继承(匹配) |
| 3 | 3 | "kit" | "sit" | 1 | 继承(匹配) |
| 4 | 4 | "kitt" | "sitt" | 1 | 继承(匹配) |
| 5 | 5 | "kitte" | "sitti" | 2 | 替换 |
| 6 | 6 | "kitten" | "sittin" | 2 | 替换 |
| 6 | 7 | "kitten" | "sitting" | 3 | 插入 |
答案 = 3
一个温和的证明(为什么它有效)
对于每个前缀对 (i, j):
如果最后一个字符匹配:没有新代价,继承
dp[i-1][j-1]。否则:
- 删除
X[i-1]→dp[i-1][j] + 1 - 插入
Y[j-1]→dp[i][j-1] + 1 - 将
X[i-1]替换为Y[j-1]→dp[i-1][j-1] + 1
- 删除
我们选择代价最小的选项。 通过对 (i, j) 进行归纳,每个 dp[i][j] 都是最优的,因为它使用了来自更小前缀的最优子解。
自己动手试试
- 打印操作序列(从
dp[m][n]回溯)。 - 改变代价:使替换代价 = 2,其他代价 = 1。
- 与最长公共子序列(LCS)比较:
编辑距离 = m + n - 2 × LCS。 - 只处理插入/删除(将其变为 LCS 的变体)。
- 尝试使用像
"intention"→"execution"这样的单词。
测试用例
| X | Y | 期望值 | 备注 |
|---|---|---|---|
| "kitten" | "sitting" | 3 | 替换、替换、插入 |
| "flaw" | "lawn" | 2 | 替换、插入 |
| "abc" | "abc" | 0 | 相同 |
| "abc" | "yabd" | 2 | 替换、插入 |
| "" | "abc" | 3 | 插入 |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$,或使用滚动数组时为 $O(\min(m,n))$
编辑距离抓住了转换的本质,即如何一次一个精心操作地将一种结构重塑为另一种结构。
425 最长回文子序列
最长回文子序列(LPS)问题旨在找到一个字符串中最长的子序列,该子序列正读和反读相同,但不必是连续的。 这是一个经典的二维动态规划(DP)问题,并且是最长公共子序列(LCS)问题的镜像版本,不过这里我们比较的是字符串与其反转后的字符串。
我们要解决什么问题?
给定一个字符串 $$ S = s_1, s_2, \dots, s_n $$ 找到最长的回文子序列的长度。
定义状态:
$$ dp[i][j] = \text{子串 } S[i..j] \text{ 中的 LPS 长度} $$
递推关系:
$$ dp[i][j] = \begin{cases} 1, & \text{如果 } i = j,\ 2 + dp[i+1][j-1], & \text{如果 } s_i = s_j,\ \max\big(dp[i+1][j],, dp[i][j-1]\big), & \text{如果 } s_i \ne s_j. \end{cases} $$
基本情况: $$ dp[i][i] = 1 $$
最终答案: $$ dp[0][n-1] $$
它是如何工作的(通俗解释)
我们在两个索引 i 和 j 之间向外扩展:
- 如果字符匹配,它们可以包裹一个更小的内部回文子序列。
- 如果不匹配,则跳过一个字符(开头或结尾)再试。
可以将其想象为将字符串折叠到自身上,每次匹配一对字符。
示例: S = "bbbab"
LPS = "bbbb"(长度为 4)
精简代码(简易版本)
C 语言(自底向上二维 DP)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int main(void) {
char S[100];
printf("输入字符串: ");
scanf("%s", S);
int n = 0;
while (S[n]) n++;
int dp[n][n];
for (int i = 0; i < n; i++) dp[i][i] = 1;
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (S[i] == S[j] && len == 2)
dp[i][j] = 2;
else if (S[i] == S[j])
dp[i][j] = 2 + dp[i+1][j-1];
else
dp[i][j] = MAX(dp[i+1][j], dp[i][j-1]);
}
}
printf("LPS 长度: %d\n", dp[0][n-1]);
return 0;
}Python(简洁 DP 版本)
S = input("输入字符串: ")
n = len(S)
dp = [[0]*n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n+1):
for i in range(n - length + 1):
j = i + length - 1
if S[i] == S[j]:
dp[i][j] = 2 + (dp[i+1][j-1] if length > 2 else 0)
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
print("LPS 长度:", dp[0][n-1])为什么它重要
- 子串上的核心 DP(区间 DP)
- 与 LCS 相关联: [ \text{LPS}(S) = \text{LCS}(S, \text{reverse}(S)) ]
- 是回文划分、字符串重构和 DNA 对称问题的基础
- 教授双指针 DP 的直觉
LPS 展示了对称性和子结构是如何交织在一起的。
逐步示例
S = "bbbab"
| i | j | S[i..j] | dp[i][j] | 原因 |
|---|---|---|---|---|
| 0 | 0 | b | 1 | 单个字符 |
| 1 | 1 | b | 1 | 单个字符 |
| 2 | 2 | b | 1 | 单个字符 |
| 3 | 3 | a | 1 | 单个字符 |
| 4 | 4 | b | 1 | 单个字符 |
| 2 | 4 | "bab" | 3 | b + a + b |
| 1 | 4 | "bbab" | 3 | 包裹 b's |
| 0 | 4 | "bbbab" | 4 | b + (bb a b) + b |
答案 = 4 ("bbbb")
一个温和的证明(为什么它有效)
对于子串 S[i..j]:
如果
s_i == s_j: 两端都可以贡献给一个更长的回文序列,在dp[i+1][j-1]的基础上加 2。如果
s_i != s_j: 其中一个字符不能出现在回文序列中,跳过i或j并取最大值。
通过填充递增的子串长度,每个子问题都在需要之前被解决。
亲自尝试
- 使用回溯法重构一个最长回文子序列。
- 与
LCS(S, reverse(S))的结果进行比较。 - 在
"cbbd"、"agbdba"上尝试。 - 修改代码以计算不同回文子序列的数量。
- 可视化表格的对角线(自底向上的增长)。
测试用例
| S | 预期结果 | 备注 |
|---|---|---|
| "bbbab" | 4 | "bbbb" |
| "cbbd" | 2 | "bb" |
| "agbdba" | 5 | "abdba" |
| "abcd" | 1 | 任意单个字符 |
| "aaa" | 3 | 整个字符串 |
复杂度
- 时间复杂度:(O(n^2))
- 空间复杂度:(O(n^2)),可使用滚动数组优化
最长回文子序列就像一面镜子,映照出你的字符串中隐藏的对称性。
426 最短公共超序列 (SCS)
最短公共超序列 (SCS) 问题要求找出一个最短的字符串,该字符串包含两个给定的字符串作为子序列。这就像在不破坏顺序的情况下将两个序列合并在一起,平衡重叠和包含关系。
这个问题与 LCS(最长公共子序列)密切相关,事实上,它的长度可以直接用最长公共子序列的长度来表示。
我们要解决什么问题?
给定两个字符串 $$ X = x_1, x_2, \dots, x_m,\quad Y = y_1, y_2, \dots, y_n $$ 找出包含两者作为子序列的最短字符串的长度。
定义状态:
$$ dp[i][j] = \text{前缀 } X[0..i-1] \text{ 和 } Y[0..j-1] \text{ 的 SCS 长度} $$
递推关系:
$$ dp[i][j] = \begin{cases} i, & \text{如果 } j = 0,\ j, & \text{如果 } i = 0,\ 1 + dp[i-1][j-1], & \text{如果 } x_{i-1} = y_{j-1},\ 1 + \min\big(dp[i-1][j],\ dp[i][j-1]\big), & \text{如果 } x_{i-1} \ne y_{j-1}. \end{cases} $$
答案: $$ dp[m][n] $$
替代公式: $$ \text{SCS 长度} = m + n - \text{LCS 长度} $$
它是如何工作的(通俗解释)
如果两个字符匹配,你只需包含它一次,然后沿对角线移动。 如果它们不同,则包含其中一个字符,并向更小的子问题方向移动(跳过一侧)。 你正在构建一个保留两个顺序的最短合并字符串。
可以把它看作是用最小的冗余将两个序列缝合在一起。
示例: X = "AGGTAB", Y = "GXTXAYB"
LCS = "GTAB" (长度 4)
因此: $$ \text{SCS 长度} = 6 + 7 - 4 = 9 $$
SCS = "AGXGTXAYB"
精简代码(简易版本)
C (DP 表)
#include <stdio.h>
#define MIN(a,b) ((a) < (b) ? (a) : (b))
int main(void) {
char X[100], Y[100];
printf("输入第一个字符串: ");
scanf("%s", X);
printf("输入第二个字符串: ");
scanf("%s", Y);
int m = 0, n = 0;
while (X[m]) m++;
while (Y[n]) n++;
int dp[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1])
dp[i][j] = 1 + dp[i-1][j-1];
else
dp[i][j] = 1 + MIN(dp[i-1][j], dp[i][j-1]);
}
}
printf("SCS 长度: %d\n", dp[m][n]);
return 0;
}Python (直接 DP)
X = input("输入第一个字符串: ")
Y = input("输入第二个字符串: ")
m, n = len(X), len(Y)
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 X[i-1] == Y[j-1]:
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1])
print("SCS 长度:", dp[m][n])为什么它很重要
- 展示了在保留顺序的情况下合并序列
- 通过公式直接关联到 LCS
- 在文件合并、版本控制和序列比对中很有用
- 展示了在两个 DP 之上的最小超结构
它是 LCS 的“交集”对应的“并集”。
分步示例
X = "AGGTAB", Y = "GXTXAYB"
| 步骤 | 操作 | 结果 |
|---|---|---|
| 比较 A,G | 不同 | 选择 A |
| 比较 G,G | 匹配 | 添加 G |
| 比较 G,X | 不同 | 添加 X |
| 比较 G,T | 不同 | 添加 T |
| 比较 T,X | 不同 | 添加 X |
| 比较 A,A | 匹配 | 添加 A |
| 比较 B,B | 匹配 | 添加 B |
SCS = "AGXGTXAYB"
长度 = 9
一个温和的证明(为什么它有效)
每个 SCS 都必须按顺序包含两个字符串的所有字符。
- 如果最后一个字符匹配:只需追加一次 →
1 + dp[i-1][j-1] - 否则,最短的选择来自于跳过其中一个字符串的一个字符。
通过对 (i, j) 的归纳,由于子问题解决的是严格更小的前缀,我们得到了最优长度。
等价关系 $$ |SCS| = m + n - |LCS| $$ 成立是因为当求和长度时,重叠的 LCS 字符被计算了两次,必须减去一次。
自己动手试试
- 重建实际的 SCS 字符串(从
dp[m][n]回溯)。 - 验证
|SCS| = |X| + |Y| - |LCS|。 - 比较 SCS 与连接
X + Y。 - 应用于没有重叠的序列。
- 用相同的字符串测试(SCS = 同一个字符串)。
测试用例
| X | Y | 预期长度 | 备注 |
|---|---|---|---|
| "AGGTAB" | "GXTXAYB" | 9 | 重叠 GTAB |
| "ABCBDAB" | "BDCAB" | 9 | 共享 BCAB |
| "HELLO" | "GEEK" | 8 | 无大重叠 |
| "AB" | "AB" | 2 | 相同 |
| "AB" | "CD" | 4 | 不相交 |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$,如果只求长度则为 $O(\min(m,n))$
最短公共超序列将两个字符串编织成一个,是将两个故事结合在一起的最紧密的线索。
427 最长重复子序列
字符串的最长重复子序列(Longest Repeated Subsequence, LRS)是指在该字符串中至少出现两次、且不使用相同索引位置的最长子序列。它类似于字符串与自身的最长公共子序列(LCS),但增加了一个额外的约束,即不允许字符与自身匹配。
我们要解决什么问题?
给定一个字符串 $$ S = s_1, s_2, \dots, s_n $$ 找出在 (S) 中至少出现两次、且索引位置互不相交的最长子序列的长度。
通过将字符串与自身进行比较来定义状态:
$$ dp[i][j] = \text{子串 } S[1..i] \text{ 和 } S[1..j] \text{ 的 LRS 长度} $$
递推关系:
$$ dp[i][j] = \begin{cases} 0, & \text{如果 } i = 0 \text{ 或 } j = 0,\ 1 + dp[i-1][j-1], & \text{如果 } s_i = s_j \text{ 且 } i \ne j,\ \max\big(dp[i-1][j],, dp[i][j-1]\big), & \text{否则}. \end{cases} $$
答案: $$ dp[n][n] $$
与 LCS 的关键区别在于约束条件 $i \ne j$,以防止匹配字符的同一出现位置。
它是如何工作的(通俗解释)
想象将字符串与自身对齐。你在寻找公共子序列,但不允许将字符匹配到其相同的位置。当字符在不同位置匹配时,你就扩展了重复子序列。当它们不匹配或处于相同位置时,你选择跳过一侧的最佳结果。
示例: S = "aabebcdd" 一个 LRS 是 "abd",长度为 3。
精简代码(简易版本)
C 语言(二维 DP)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int main(void) {
char S[1005];
printf("输入字符串: ");
scanf("%s", S);
int n = 0; while (S[n]) n++;
int dp[n + 1][n + 1];
for (int i = 0; i <= n; i++)
for (int j = 0; j <= n; j++)
dp[i][j] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (S[i-1] == S[j-1] && i != j)
dp[i][j] = 1 + dp[i-1][j-1];
else
dp[i][j] = MAX(dp[i-1][j], dp[i][j-1]);
}
}
printf("LRS 长度: %d\n", dp[n][n]);
return 0;
}Python(直接 DP)
S = input("输入字符串: ")
n = len(S)
dp = [[0]*(n+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, n+1):
if S[i-1] == S[j-1] and i != j:
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
print("LRS 长度:", dp[n][n])为什么它重要
- 阐释了如何将一个带有简单约束的问题转化为同一字符串上的 LCS 问题。
- 有助于检测重复模式和压缩信号。
- 为自对齐动态规划和索引约束建立直觉。
逐步示例
S = "aabebcdd" 让我们看看对齐的思路:
- 在不同索引处匹配的字符对:
- 位置 1 和 2 的
a可以贡献,但不能与相同索引匹配。 - 位置 3 和 6 的
b。 - 位置 7 和 8 的
d。
- 位置 1 和 2 的
一个有效的重复子序列是 "abd",使用索引 (1,3,7) 和 (2,6,8)。 长度 (= 3)。
一个温和的证明(为什么它有效)
考虑 (S) 与自身的最长公共子序列(LCS):
- 如果允许在相同索引处匹配,你会平凡地匹配每个字符与其自身,得到 (n)。
- 通过禁止 (i = j) 时的匹配,任何字符只有在另一个不同索引处存在相同字符时才会贡献。
- 递推关系与 LCS 相似,但强制了 (i \ne j)。
- 通过对 (i, j) 进行归纳,表格精确地累积了重复子序列的长度,并且 (dp[n][n]) 处的最大值就是 LRS 的长度。
亲自尝试
- 通过从 (dp[n][n]) 回溯并遵守 (i \ne j) 约束,重构一个 LRS。
- 修改代码以计算最大长度的不同 LRS 的数量。
- 比较同一字符串上的 LRS 和最长回文子序列(LPS),观察结构差异。
- 在重构时处理平局情况,以获得字典序最小的 LRS。
- 在所有字符都唯一的字符串上测试其行为。
测试用例
| S | 期望的 LRS 长度 | 一个 LRS |
|---|---|---|
| "aabebcdd" | 3 | "abd" |
| "axxxy" | 2 | "xx" |
| "aaaa" | 3 | "aaa" |
| "abc" | 0 | "" |
| "aaba" | 2 | "aa" |
复杂度
- 时间复杂度:(O(n^2))
- 空间复杂度:(O(n^2))
最长重复子序列是向内的 LCS。将字符串与自身比较,禁止相同位置,重复的模式就会显现出来。
428 字符串交织
字符串交织问题询问一个字符串 $S$ 是否可以通过交织(或编织在一起)另外两个字符串 $X$ 和 $Y$ 来形成,同时保持每个字符串中字符的相对顺序。
这是一个动态规划问题,优雅地捕捉了顺序约束下的序列合并,其精神类似于合并两个有序列表。
我们要解决什么问题?
给定三个字符串 $X$、$Y$ 和 $S$,判断 $S$ 是否是 $X$ 和 $Y$ 的一个有效交织。
我们定义状态:
$$ dp[i][j] = \text{如果 } S[0..i+j-1] \text{ 可以通过交织 } X[0..i-1] \text{ 和 } Y[0..j-1] \text{ 形成,则为 True} $$
递推关系:
$$ dp[i][j] = \begin{cases} dp[i-1][j], & \text{如果 } X[i-1] = S[i+j-1] \text{ 且 } dp[i-1][j],\ dp[i][j-1], & \text{如果 } Y[j-1] = S[i+j-1] \text{ 且 } dp[i][j-1],\ dp[i-1][j] \lor dp[i][j-1], & \text{如果两个条件都成立。} \end{cases} $$
基础条件:
$$ dp[0][0] = \text{True} $$
$$ dp[i][0] = dp[i-1][0] \land (X[i-1] = S[i-1]) $$
$$ dp[0][j] = dp[0][j-1] \land (Y[j-1] = S[j-1]) $$
答案:
$$ dp[m][n] $$
其中 $m = \vert X\vert $, $n = \vert Y\vert $。
它是如何工作的(通俗解释)
你有两个输入字符串 $X$ 和 $Y$,问题是能否按顺序合并它们以得到 $S$。
每一步,决定 $S$ 中的下一个字符应该来自 $X$ 还是 $Y$,只要不破坏任一字符串内部的顺序。
想象一下从两条字符带中读取,你可以在它们之间切换,但绝不能重新排列单条带内的字符。
示例: $X = \text{"abc"}$, $Y = \text{"def"}$, $S = \text{"adbcef"}$
有效交织:$a$(来自 $X$),$d$(来自 $Y$),$b$(来自 $X$),$c$(来自 $X$),$e$(来自 $Y$),$f$(来自 $Y$)
微型代码(简单版本)
C 语言(二维布尔 DP)
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
bool isInterleave(char *X, char *Y, char *S) {
int m = strlen(X), n = strlen(Y);
if (m + n != strlen(S)) return false;
bool dp[m+1][n+1];
dp[0][0] = true;
for (int i = 1; i <= m; i++)
dp[i][0] = dp[i-1][0] && X[i-1] == S[i-1];
for (int j = 1; j <= n; j++)
dp[0][j] = dp[0][j-1] && Y[j-1] == S[j-1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
dp[i][j] = false;
if (X[i-1] == S[i+j-1]) dp[i][j] |= dp[i-1][j];
if (Y[j-1] == S[i+j-1]) dp[i][j] |= dp[i][j-1];
}
}
return dp[m][n];
}
int main(void) {
char X[100], Y[100], S[200];
printf("输入 X: "); scanf("%s", X);
printf("输入 Y: "); scanf("%s", Y);
printf("输入 S: "); scanf("%s", S);
if (isInterleave(X, Y, S))
printf("是的,S 是 X 和 Y 的交织。\n");
else
printf("不,S 无法形成。\n");
return 0;
}Python(简单 DP 表)
X = input("输入 X: ")
Y = input("输入 Y: ")
S = input("输入 S: ")
m, n = len(X), len(Y)
if len(S) != m + n:
print("否")
else:
dp = [[False]*(n+1) for _ in range(m+1)]
dp[0][0] = True
for i in range(1, m+1):
dp[i][0] = dp[i-1][0] and X[i-1] == S[i-1]
for j in range(1, n+1):
dp[0][j] = dp[0][j-1] and Y[j-1] == S[j-1]
for i in range(1, m+1):
for j in range(1, n+1):
dp[i][j] = (X[i-1] == S[i+j-1] and dp[i-1][j]) or \
(Y[j-1] == S[i+j-1] and dp[i][j-1])
print("是" if dp[m][n] else "否")为什么它重要
- 展示了顺序约束下的双序列合并
- 是路径交织、合并调度和字符串编织等问题的核心思想
- 是学习涉及二维网格 DP 和字符串约束问题的良好垫脚石
逐步示例
$X = \text{"abc"}$, $Y = \text{"def"}$, $S = \text{"adbcef"}$
| i | j | X[:i] | Y[:j] | dp[i][j] | 解释 |
|---|---|---|---|---|---|
| 0 | 0 | "" | "" | T | 基础条件 |
| 1 | 0 | a | "" | T | a 来自 X |
| 1 | 1 | a | d | T | d 来自 Y |
| 2 | 1 | ab | d | T | b 来自 X |
| 3 | 1 | abc | d | F | 无法匹配下一个字符 |
| 3 | 2 | abc | de | T | e 来自 Y |
| 3 | 3 | abc | def | T | f 来自 Y |
答案:True
一个温和的证明(为什么它有效)
在任何时刻,我们已经使用了 $S$ 中的 $i + j$ 个字符:
- 如果最后一个字符来自 $X$:$X[i-1] = S[i+j-1]$ 且 $dp[i-1][j]$ 为 True
- 如果最后一个字符来自 $Y$:$Y[j-1] = S[i+j-1]$ 且 $dp[i][j-1]$ 为 True
通过从左到右、从上到下填充表格,每个前缀在组合之前都经过了验证。 归纳推理确保了所有前缀的正确性。
自己动手试试
- 打印一个有效的交织路径
- 修改以计算交织总数
- 小心处理具有重复字符的字符串
- 在 $X$ 和 $Y$ 有共同前缀的示例上尝试
- 扩展到三个字符串的交织
测试用例
| X | Y | S | 预期结果 |
|---|---|---|---|
| "abc" | "def" | "adbcef" | True |
| "ab" | "cd" | "abcd" | True |
| "ab" | "cd" | "acbd" | True |
| "ab" | "cd" | "acdb" | False |
| "aa" | "ab" | "aaba" | True |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$,可减少到 $O(n)$
字符串交织问题是关于和谐的,将两个序列一个字母一个字母地、按完美顺序编织在一起。
429 序列比对(生物信息学)
序列比对问题探讨如何最优地对齐两个序列(通常是 DNA、RNA 或蛋白质)以衡量它们的相似性,允许存在空位和错配。它构成了生物信息学、字符串相似性和基于编辑的评分系统的基础。
与编辑距离不同,序列比对会对匹配、错配和空位分配分数,并寻求最大分数,而不是最小编辑次数。
我们要解决什么问题?
给定两个序列 $$ X = x_1, x_2, \dots, x_m, \quad Y = y_1, y_2, \dots, y_n $$ 以及评分规则:
- 匹配得 $+1$ 分
- 错配得 $-1$ 分
- 空位(插入/删除)得 $-2$ 分
我们想要找到 $X$ 和 $Y$ 的一个比对,使得总分数最大化。
我们定义状态:
$$ dp[i][j] = \text{$X[0..i-1]$ 和 $Y[0..j-1]$ 之间的最大比对分数} $$
递推关系:
$$ dp[i][j] = \max \begin{cases} dp[i-1][j-1] + \text{score}(x_{i-1}, y_{j-1}) \ dp[i-1][j] + \text{空位罚分} \ dp[i][j-1] + \text{空位罚分} \end{cases} $$
基础情况:
$$ dp[i][0] = i \times \text{空位罚分}, \quad dp[0][j] = j \times \text{空位罚分} $$
答案:$dp[m][n]$
它是如何工作的(通俗解释)
我们填充一个网格,其中每个单元格 $(i, j)$ 表示将 $X$ 的前 $i$ 个字符与 $Y$ 的前 $j$ 个字符对齐所能获得的最佳分数。
在每一步,我们决定:
- 匹配/错配(将 $x_{i-1}$ 与 $y_{j-1}$ 对齐)
- 在 $Y$ 中插入一个空位(跳过 $X$ 中的一个字符)
- 在 $X$ 中插入一个空位(跳过 $Y$ 中的一个字符)
最终的单元格保存了最优的比对分数。 回溯过程可以揭示对齐后的字符串,其中破折号代表空位。
示例:
$X = \text{"GATTACA"}$ $Y = \text{"GCATGCU"}$
一种比对方式:
G A T T A C A -
| | | | |
G - C A T G C U微型代码(简易版本)
C 语言(全局比对 / Needleman–Wunsch)
#include <stdio.h>
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int score(char a, char b) {
return a == b ? 1 : -1;
}
int main(void) {
char X[100], Y[100];
printf("输入 X: "); scanf("%s", X);
printf("输入 Y: "); scanf("%s", Y);
int m = 0, n = 0;
while (X[m]) m++;
while (Y[n]) n++;
int gap = -2;
int dp[m+1][n+1];
for (int i = 0; i <= m; i++) dp[i][0] = i * gap;
for (int j = 0; j <= n; j++) dp[0][j] = j * gap;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
int match = dp[i-1][j-1] + score(X[i-1], Y[j-1]);
int delete = dp[i-1][j] + gap;
int insert = dp[i][j-1] + gap;
dp[i][j] = MAX(match, MAX(delete, insert));
}
}
printf("最大比对分数: %d\n", dp[m][n]);
return 0;
}Python(简洁版本)
X = input("输入 X: ")
Y = input("输入 Y: ")
m, n = len(X), len(Y)
match, mismatch, gap = 1, -1, -2
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
dp[i][0] = i * gap
for j in range(1, n+1):
dp[0][j] = j * gap
for i in range(1, m+1):
for j in range(1, n+1):
score = match if X[i-1] == Y[j-1] else mismatch
dp[i][j] = max(
dp[i-1][j-1] + score,
dp[i-1][j] + gap,
dp[i][j-1] + gap
)
print("最大比对分数:", dp[m][n])为什么它很重要
- 生物信息学的基础(DNA、RNA、蛋白质比较)
- 用于拼写纠正、抄袭检测、文本相似性
- 展示了带有分数的加权动态规划,而不仅仅是计数
- 演示了具有多重决策的路径重建
这是编辑距离到带分数比对的推广。
逐步示例
设 $X = \text{"AGT"}$, $Y = \text{"GTT"}$ 匹配 $= +1$, 错配 $= -1$, 空位 $= -2$
| i | j | $X[0..i]$ | $Y[0..j]$ | $dp[i][j]$ | 选择 |
|---|---|---|---|---|---|
| 1 | 1 | A, G | G | -1 | 错配 |
| 2 | 2 | AG, GT | GT | +0 | 对齐 G |
| 3 | 3 | AGT, GTT | GTT | +1 | 对齐 T |
答案 = +1
一个温和的证明(为什么它有效)
每个 $dp[i][j]$ 表示对齐 $X[0..i-1]$ 和 $Y[0..j-1]$ 所能达到的最佳可能分数。 归纳法确保了正确性:
- 基础:$dp[0][j], dp[i][0]$ 处理前导空位
- 步骤:在每个 $(i, j)$ 处,你考虑所有有效的转移(匹配/错配、插入、删除),并取最大值。 因此 $dp[m][n]$ 是全局最优的。
亲自尝试
- 回溯以打印带空位的比对结果
- 尝试不同的评分系统
- 比较全局比对(Needleman–Wunsch)与局部比对(Smith–Waterman)
- 处理仿射空位(空位起始 + 延伸)
- 将网格路径可视化为比对
测试用例
| X | Y | 预期值 | 备注 |
|---|---|---|---|
| "AGT" | "GTT" | 1 | 一次匹配 |
| "GATTACA" | "GCATGCU" | 取决于评分系统 | 经典示例 |
| "ABC" | "ABC" | 3 | 全部匹配 |
| "ABC" | "DEF" | -3 | 全部错配 |
| "A" | "AAA" | -2 | 添加空位 |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$(可以通过行滚动减少)
序列比对问题告诉我们,相似性不仅仅是关于匹配,它关乎在比对、错配和空位之间取得平衡,以找到两个序列之间最佳的对应关系。
430 Diff 算法 (Myers / 动态规划)
Diff 算法比较两个序列,并找出它们的最短编辑脚本(SES),即一个序列转换为另一个序列所需的最少插入和删除操作序列。 它是 git diff 和 diff 等工具的核心,提供了人类可读的变更摘要。
Myers 算法是最著名的线性空间实现,但其动态规划(DP)公式建立在编辑距离和最长公共子序列(LCS)的直观理解之上。
我们要解决什么问题?
给定两个字符串 $$ X = x_1, x_2, \dots, x_m, \quad Y = y_1, y_2, \dots, y_n $$ 找到一个最小的编辑操作序列(插入和删除),将 $X$ 转换为 $Y$。
每次编辑都将一个序列向另一个序列转换,匹配的字符保持不变。
最小编辑次数等于:
$$ \text{SES 长度} = m + n - 2 \times \text{LCS 长度} $$
我们也可以显式地追踪路径来恢复 diff 结果。
递推关系(动态规划公式)
令 $dp[i][j]$ 为将 $X[0..i-1]$ 转换为 $Y[0..j-1]$ 所需的最小编辑次数。
那么:
$$ dp[i][j] = \begin{cases} i, & \text{如果 } j = 0,\ j, & \text{如果 } i = 0,\ dp[i-1][j-1], & \text{如果 } x_{i-1} = y_{j-1},\ 1 + \min\big(dp[i-1][j],, dp[i][j-1]\big), & \text{如果 } x_{i-1} \ne y_{j-1}. \end{cases} $$
答案: $$ dp[m][n] $$
回溯可以重建操作序列:保留、删除或插入字符以将 $X$ 转换为 $Y$。
它是如何工作的(通俗解释)
想象一下将两个序列逐行对齐。 当字符匹配时,沿对角线移动(无成本)。 如果字符不同,你必须从 $X$ 中删除一个字符,或者从 $Y$ 中插入一个字符。
通过遍历所有前缀对构成的网格,你可以找到最短的编辑路径,这与 git diff 的逻辑相同。
示例: $X = \text{"ABCABBA"}$ $Y = \text{"CBABAC"}$
一个最小 diff 如下:
- A
B
C
+ B
A
B
- B
+ A
C微型代码(简易版本)
C 语言(用于 Diff 的动态规划回溯)
#include <stdio.h>
#define MIN(a,b) ((a)<(b)?(a):(b))
int main(void) {
char X[100], Y[100];
printf("输入 X: "); scanf("%s", X);
printf("输入 Y: "); scanf("%s", Y);
int m = 0, n = 0;
while (X[m]) m++;
while (Y[n]) n++;
int dp[m+1][n+1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + MIN(dp[i-1][j], dp[i][j-1]);
}
}
printf("编辑距离: %d\n", dp[m][n]);
// 回溯
int i = m, j = n;
printf("Diff:\n");
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && X[i-1] == Y[j-1]) {
printf(" %c\n", X[i-1]);
i--; j--;
} else if (i > 0 && dp[i][j] == dp[i-1][j] + 1) {
printf("- %c\n", X[i-1]);
i--;
} else {
printf("+ %c\n", Y[j-1]);
j--;
}
}
return 0;
}Python(简易 Diff 重建)
X = input("输入 X: ")
Y = input("输入 Y: ")
m, n = len(X), len(Y)
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 X[i-1] == Y[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1])
i, j = m, n
ops = []
while i > 0 or j > 0:
if i > 0 and j > 0 and X[i-1] == Y[j-1]:
ops.append(f" {X[i-1]}")
i -= 1; j -= 1
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
ops.append(f"- {X[i-1]}")
i -= 1
else:
ops.append(f"+ {Y[j-1]}")
j -= 1
print("编辑距离:", dp[m][n])
print("Diff:")
for op in reversed(ops):
print(op)为什么它很重要
- 版本控制系统的基础(
git diff,patch) - 最小化转换所需的编辑操作
- 简化合并冲突解决
- 建立在 LCS 和编辑距离概念之上
- 演示了基于回溯的重建
Diff 是动态规划人性化的一面,它将表格转化为洞察力。
逐步示例
$X = \text{"ABCABBA"}$, $Y = \text{"CBABAC"}$
| 操作 | 解释 |
|---|---|
| - A | X 以 'A' 开头,不在 Y 的前缀中 |
| B | 匹配 |
| C | 匹配 |
| + B | 插入 |
| A | 匹配 |
| B | 匹配 |
| - B | 删除 |
| + A | 插入 |
| C | 匹配 |
总编辑次数 = 4(最小)
一个温和的证明(为什么它有效)
- 如果 $x_{i-1} = y_{j-1}$,跳过两者(无成本)。
- 否则,最佳路径必须添加一个新操作(插入/删除)。
- $dp[i][j]$ 存储前缀的最小编辑次数。 通过归纳法,$dp[m][n]$ 是最小的 SES 长度。
亲自尝试
- 在对齐视图中打印 diff 符号(+, -, 空格)
- 比较 diff 路径与 LCS 路径
- 用真实单词尝试:
"kitten"→"sitting" - 修改成本:插入/删除的权重
- 使用颜色或缩进来可视化输出
测试用例
| X | Y | 编辑次数 | 备注 |
|---|---|---|---|
| "ABCABBA" | "CBABAC" | 4 | 经典示例 |
| "abc" | "abc" | 0 | 相同 |
| "abc" | "def" | 6 | 全部替换 |
| "kitten" | "sitting" | 3 | 经典示例 |
| "" | "xyz" | 3 | 全部插入 |
复杂度
- 时间:$O(m \times n)$
- 空间:$O(m \times n)$(回溯需要完整的表格)
Diff 算法将比较转化为故事讲述,精确地展示一个序列如何通过一次次的编辑演变成另一个序列。
第 44 节 矩阵与链式问题
431 矩阵链乘法
矩阵链乘法问题旨在寻找一种最高效的方式对矩阵乘积进行括号化,使得标量乘法的总次数最小化。矩阵乘法满足结合律,因此乘法顺序可以显著改变计算成本,尽管最终结果的形状相同。
我们解决的是什么问题?
给定一个矩阵链 $$ A_1A_2\cdots A_n $$ 其维度为 $$ A_i \text{ 是 } p_{i-1}\times p_i \quad (i=1..n), $$ 选择一种括号化方式,使得标量乘法次数最小。
定义状态:
$$ dp[i][j] = \text{计算 } A_iA_{i+1}\cdots A_j \text{ 的最小成本} $$
递推关系:
$$ dp[i][i] = 0 $$ $$ dp[i][j] = \min_{i\le k<j}\bigl(dp[i][k] + dp[k+1][j] + p_{i-1}p_kp_j\bigr) $$
最后一项是在分割点 (k) 处将得到的两个矩阵相乘的成本。
答案:
$$ dp[1][n] $$
可选地,保留一个分割表 (split[i][j]) 来存储达到最小值的 (k),以便重建最优括号化方案。
它是如何工作的(通俗解释)
矩阵链乘法动态规划
递推关系 $$ \begin{aligned} m[i,i] &= 0,\ m[i,j] &= \min_{i \le k < j}\Big(m[i,k] + m[k+1,j] + p_{i-1},p_k,p_j\Big)\qquad (i<j), \end{aligned} $$ 其中矩阵 $A_i$ 的大小为 $p_{i-1}\times p_i$。
维度示例 $$ p=[10,,30,,5,,60],\quad A_1:10\times30,\ A_2:30\times5,\ A_3:5\times60. $$
两种括号化方式:
$(A_1A_2)A_3$
成本 $$ (10\cdot 30\cdot 5) + (10\cdot 5\cdot 60) = 1500 + 3000 = 4500. $$$A_1(A_2A_3)$
成本 $$ (30\cdot 5\cdot 60) + (10\cdot 30\cdot 60) = 9000 + 18000 = 27000. $$
最小成本是 $4500$,通过 $(A_1A_2)A_3$ 实现,分割点 $k=1$。
简短的代码(简易版本)
C 语言(带重建的动态规划)
#include <stdio.h>
#include <limits.h>
void print_optimal(int i, int j, int split[105][105]) {
if (i == j) { printf("A%d", i); return; }
printf("(");
int k = split[i][j];
print_optimal(i, k, split);
printf(" x ");
print_optimal(k+1, j, split);
printf(")");
}
int main(void) {
int n;
printf("输入矩阵数量: ");
scanf("%d", &n);
int p[n+1];
printf("输入维度 p0..pn: ");
for (int i = 0; i <= n; i++) scanf("%d", &p[i]);
long long dp[105][105];
int split[105][105];
for (int i = 1; i <= n; i++) dp[i][i] = 0;
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
dp[i][j] = LLONG_MAX;
for (int k = i; k < j; k++) {
long long cost = dp[i][k] + dp[k+1][j] + 1LL*p[i-1]*p[k]*p[j];
if (cost < dp[i][j]) {
dp[i][j] = cost;
split[i][j] = k;
}
}
}
}
printf("最小标量乘法次数: %lld\n", dp[1][n]);
printf("最优括号化方案: ");
print_optimal(1, n, split);
printf("\n");
return 0;
}Python(带重建的动态规划)
def matrix_chain_order(p):
n = len(p) - 1
dp = [[0]*(n+1) for _ in range(n+1)]
split = [[0]*(n+1) for _ in range(n+1)]
for length in range(2, n+1):
for i in range(1, n - length + 2):
j = i + length - 1
dp[i][j] = float('inf')
for k in range(i, j):
cost = dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]
if cost < dp[i][j]:
dp[i][j] = cost
split[i][j] = k
return dp, split
def build_solution(split, i, j):
if i == j:
return f"A{i}"
k = split[i][j]
return "(" + build_solution(split, i, k) + " x " + build_solution(split, k+1, j) + ")"
p = list(map(int, input("输入 p0..pn: ").split()))
dp, split = matrix_chain_order(p)
n = len(p) - 1
print("最小标量乘法次数:", dp[1][n])
print("最优括号化方案:", build_solution(split, 1, n))为什么它很重要
- 区间动态规划和最优二叉划分的经典示例
- 展示了结合律如何允许具有不同成本的多种求值顺序
- 出现在查询计划优化、自动微分调度、图形和编译器优化中
逐步示例
对于 (p = [5, 10, 3, 12, 5, 50, 6]) 且 (n=6):
- 为每个子链长度尝试所有分割点
- 动态规划最终得出 (dp[1][6] = 2010) 和一个最优结构,例如 (((A_1(A_2A_3))((A_4A_5)A_6))) 确切的括号化在平局情况下可能有所不同,但此处最小成本是唯一的。
一个温和的证明(为什么它有效)
令 (OPT(i,j)) 为计算 $A_i\cdots A_j$ 的最优成本。在任何最优解中,最后一次乘法在某个 (k) 处分割链,其中 $i\le k<j$。两侧本身必须是最优的,否则用更好的解替换其中一侧会改善总成本,这与最优性矛盾。因此 $$ OPT(i,j) = \min_{i\le k<j}\bigl(OPT(i,k)+OPT(k+1,j)+p_{i-1}p_kp_j\bigr), $$ 且 (OPT(i,i)=0)。由于每个子问题使用严格更短的链,按长度递增的顺序填充可以在使用之前计算出所有需要的值。
亲自尝试
- 当多个 (k) 平局时,不仅打印一个,而是打印所有最优括号化方案。
- 在最小化成本后,添加第二个目标,例如最小化深度。
- 在随机实例上比较贪心选择与动态规划。
- 扩展到包含加法成本或缓存重用的成本模型。
- 可视化动态规划表和沿对角线的分割点。
测试用例
| p (p0..pn) | n | 预期最小成本 | 一个最优括号化方案 |
|---|---|---|---|
| [10,30,5,60] | 3 | 4500 | (A1 x A2) x A3 |
| [5,10,3,12,5,50,6] | 6 | 2010 | 一个最优结构 |
| [40,20,30,10,30] | 4 | 26000 | ((A1 x (A2 x A3)) x A4) 或平局变体 |
| [10,20,30] | 2 | 6000 | A1 x A2 |
| [2,3,4,5] | 3 | 64 | (A1 x A2) x A3 |
复杂度
- 时间:由于 (i,j,k) 的三重循环,为 (O(n^3))
- 空间:用于动态规划和分割表,为 (O(n^2))
矩阵链乘法是区间动态规划的教科书模式:选择一个分割点,组合最优子链,并计算边界乘法的成本。
432 布尔表达式加括号问题
布尔表达式加括号问题(也称为布尔表达式求值问题)提出:给定一个由 T(真)、F(假)和运算符(&、|、^)组成的布尔表达式,我们有多少种方式为其加括号,使其计算结果为 True?
这是一个经典的区间动态规划问题,我们探索运算符之间所有可能的分割点,并根据逻辑规则组合子结果。
我们要解决什么问题?
给定一个布尔表达式字符串,例如 T|F&T^T,计算有多少种方式为其加括号,使其计算结果为 True。
我们必须同时考虑子表达式的 True 和 False 计数。
设:
- $dpT[i][j]$ = $expr[i..j]$ 计算结果为 True 的方式数
- $dpF[i][j]$ = $expr[i..j]$ 计算结果为 False 的方式数
如果表达式长度为 $n$,那么我们只考虑偶数索引处的操作数和奇数索引处的运算符。
递推关系
对于 $i$ 和 $j$ 之间运算符 $k$ 处的每个分割点:
令 $op = expr[k]$
计算:
$$ \text{TotalTrue} = \begin{cases} dpT[i][k-1]\cdot dpT[k+1][j], & \text{如果 } op=\land,\ dpT[i][k-1]\cdot dpT[k+1][j];+;dpT[i][k-1]\cdot dpF[k+1][j];+;dpF[i][k-1]\cdot dpT[k+1][j], & \text{如果 } op=\lor,\ dpT[i][k-1]\cdot dpF[k+1][j];+;dpF[i][k-1]\cdot dpT[k+1][j], & \text{如果 } op=\oplus. \end{cases} $$
类似地,使用互补逻辑计算 $dpF[i][j]$。
基本情况:
$$ dpT[i][i] = 1 \text{ 如果 expr[i] = 'T' 否则 } 0 $$ $$ dpF[i][i] = 1 \text{ 如果 expr[i] = 'F' 否则 } 0 $$
答案 = $dpT[0][n-1]$
它是如何工作的(通俗解释)
我们在每个运算符处切割表达式,并根据布尔逻辑组合左右两边的真值计数。
对于每个子表达式,我们记录:
- 有多少种加括号方式使其为 True
- 有多少种加括号方式使其为 False
然后我们组合更小的子问题来得到更大的子问题,就像矩阵链乘法一样,但使用的是逻辑规则。
示例: 表达式 = T|F&T
我们可以分组为:
(T|F)&T→ TrueT|(F&T)→ True
答案 = 2
精简代码(简易版本)
C 语言(自底向上动态规划)
#include <stdio.h>
#include <string.h>
#define MAX 105
int dpT[MAX][MAX], dpF[MAX][MAX];
int main(void) {
char expr[105];
printf("输入表达式 (T/F 与 &|^): ");
scanf("%s", expr);
int n = strlen(expr);
for (int i = 0; i < n; i += 2) {
dpT[i][i] = (expr[i] == 'T');
dpF[i][i] = (expr[i] == 'F');
}
for (int len = 3; len <= n; len += 2) {
for (int i = 0; i + len - 1 < n; i += 2) {
int j = i + len - 1;
dpT[i][j] = dpF[i][j] = 0;
for (int k = i + 1; k < j; k += 2) {
char op = expr[k];
int lT = dpT[i][k-1], lF = dpF[i][k-1];
int rT = dpT[k+1][j], rF = dpF[k+1][j];
if (op == '&') {
dpT[i][j] += lT * rT;
dpF[i][j] += lT * rF + lF * rT + lF * rF;
} else if (op == '|') {
dpT[i][j] += lT * rT + lT * rF + lF * rT;
dpF[i][j] += lF * rF;
} else if (op == '^') {
dpT[i][j] += lT * rF + lF * rT;
dpF[i][j] += lT * rT + lF * rF;
}
}
}
}
printf("结果为 True 的方式数: %d\n", dpT[0][n-1]);
return 0;
}Python(易读的动态规划)
expr = input("输入表达式 (T/F 与 &|^): ")
n = len(expr)
dpT = [[0]*n for _ in range(n)]
dpF = [[0]*n for _ in range(n)]
for i in range(0, n, 2):
dpT[i][i] = 1 if expr[i] == 'T' else 0
dpF[i][i] = 1 if expr[i] == 'F' else 0
for length in range(3, n+1, 2):
for i in range(0, n-length+1, 2):
j = i + length - 1
for k in range(i+1, j, 2):
op = expr[k]
lT, lF = dpT[i][k-1], dpF[i][k-1]
rT, rF = dpT[k+1][j], dpF[k+1][j]
if op == '&':
dpT[i][j] += lT * rT
dpF[i][j] += lT * rF + lF * rT + lF * rF
elif op == '|':
dpT[i][j] += lT * rT + lT * rF + lF * rT
dpF[i][j] += lF * rF
else: # '^'
dpT[i][j] += lT * rF + lF * rT
dpF[i][j] += lT * rT + lF * rF
print("计算结果为 True 的方式数:", dpT[0][n-1])为什么它很重要
- 经典的区间动态规划模式,带有逻辑组合
- 展示了状态分割如何应用于算术之外
- 是布尔电路优化和表达式计数问题的基础
- 强化了按运算符分割的技术
逐步示例
表达式 = T|F&T
子问题:
| 子表达式 | True 方式数 | False 方式数 | |
|---|---|---|---|
| T | 1 | 0 | |
| F | 0 | 1 | |
| T | 1 | 0 | |
| T | F | 1 | 0 |
| F&T | 0 | 1 | |
| T | F&T | 2 | 0 |
答案 = 2
一个温和的证明(为什么它有效)
每个子表达式都可以在运算符 $op_k$ 处分割。整个表达式的真值计数仅取决于其部分的真值计数和运算符的真值表。通过递归地组合所有可能的 $k$,我们计算了所有有效的加括号方式。在评估相同子字符串时会出现重叠子问题,因此记忆化或自底向上填充确保了效率。
自己动手试试
- 扩展以同时计算 False 结果。
- 为大量计数添加模 $10^9+7$。
- 打印一种有效的加括号方式。
- 在像
T^T^F或T|F&T^T这样的表达式上尝试。 - 为自定义逻辑系统修改规则。
测试用例
| 表达式 | 预期 True 计数 | ||
|---|---|---|---|
| T | F&T | 2 | |
| T^T^F | 0 | ||
| T^F | F | 2 | |
| T&F | T | 2 | |
| T | T&F | F | 5 |
复杂度
- 时间:$O(n^3)$(为每个运算符分割)
- 空间:$O(n^2)$(2 个 DP 表)
布尔表达式加括号问题是矩阵链乘法的逻辑镜像,不是最小化成本,而是通过组合结构来计数真值。
433 戳气球
戳气球问题是一个经典的区间动态规划挑战。给定一排气球,每个气球上有一个代表硬币数量的数字。当你戳破一个气球时,你将获得等于该气球数字与其左右相邻气球数字乘积的硬币。任务是通过选择最优的戳破顺序来确定你能收集到的最大硬币数量。
我们要解决什么问题?
给定一个长度为 n 的数组 nums,当你戳破气球 i 时,你将获得 $$ \text{coins} = nums[i-1] \times nums[i] \times nums[i+1] $$ 其中 nums[i-1] 和 nums[i+1] 是仍然未被戳破的相邻气球。
戳破 i 后,它从序列中移除,从而改变了邻居关系。
我们希望通过选择最佳的戳破顺序来最大化总硬币数。
为了简化边界条件,在数组两端填充 1: $$ val = [1] + nums + [1] $$
定义动态规划状态: $$ dp[i][j] = \text{戳破 } i \text{ 和 } j \text{ 之间所有气球可获得的最大硬币数} $$
递推关系: $$ dp[i][j] = \max_{k \in (i, j)} \Big( dp[i][k] + dp[k][j] + val[i] \cdot val[k] \cdot val[j] \Big) $$
答案: $$ dp[0][n+1] $$
它是如何工作的(通俗解释)
与其思考“接下来戳破哪个气球?”,不如思考在两个边界之间“最后戳破哪个气球?”。
通过固定 i 和 j 之间的最后一个气球 k,可以保证在那一刻它的邻居是 i 和 j,因此获得的硬币数很容易计算: val[i] * val[k] * val[j]。
然后我们解决更小的子问题:
dp[i][k]:戳破i和k之间气球的最佳硬币数dp[k][j]:戳破k和j之间气球的最佳硬币数
组合起来并选择最佳的分割点。
这与直观的“先戳破”方法相反,使得子问题相互独立。
精简代码(简易版本)
C (自底向上动态规划)
#include <stdio.h>
#include <string.h>
#define MAX 305
#define max(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n;
printf("请输入气球数量: ");
scanf("%d", &n);
int nums[MAX], val[MAX];
printf("请输入气球数值: ");
for (int i = 1; i <= n; i++) scanf("%d", &nums[i]);
val[0] = val[n+1] = 1;
for (int i = 1; i <= n; i++) val[i] = nums[i];
int dp[MAX][MAX];
memset(dp, 0, sizeof(dp));
for (int len = 2; len <= n + 1; len++) {
for (int i = 0; i + len <= n + 1; i++) {
int j = i + len;
for (int k = i + 1; k < j; k++) {
int cost = val[i] * val[k] * val[j] + dp[i][k] + dp[k][j];
dp[i][j] = max(dp[i][j], cost);
}
}
}
printf("最大硬币数: %d\n", dp[0][n+1]);
return 0;
}Python (区间动态规划)
nums = list(map(int, input("请输入气球数值: ").split()))
val = [1] + nums + [1]
n = len(nums)
dp = [[0]*(n+2) for _ in range(n+2)]
for length in range(2, n+2):
for i in range(0, n+2-length):
j = i + length
for k in range(i+1, j):
dp[i][j] = max(dp[i][j], val[i]*val[k]*val[j] + dp[i][k] + dp[k][j])
print("最大硬币数:", dp[0][n+1])为什么它重要
- 体现了区间动态规划的结构:选择一个气球作为“最后”的动作
- 展示了逆向推理如何简化状态的独立性
- 出现在链、树、括号和游戏的优化问题中
- 是多边形三角剖分和矩阵乘法变体的基础
逐步示例
示例:nums = [3, 1, 5, 8]
填充 → val = [1, 3, 1, 5, 8, 1]
为递增的区间计算 dp[i][j]:
- 区间 (1,4):选择
k=2或3,比较成本 - 逐渐扩展到完整区间 (0,5): 最优解 = 167 枚硬币
一种最优顺序: 戳破 1 → 5 → 3 → 8 → 1
一个温和的证明(为什么它有效)
每个区间 (i,j) 只能依赖于更小的区间 (i,k) 和 (k,j),因为最后一个气球 k 干净利落地分割了链。通过固定 k 为最后一个,我们确保两边是独立的,它们没有共享未戳破的气球。由于每个子问题都更小,自底向上的动态规划可以无环地填充状态。因此,最优子结构和重叠子问题保证了正确性。
自己动手试试
- 实现一个带递归的自顶向下记忆化版本。
- 将动态规划表可视化为显示最佳分割的三角形。
- 添加重构功能以打印戳破顺序。
- 尝试
[1,2,3]、[1,5]、[7,9,8]来检验直觉。 - 比较
n=300时的性能。
测试用例
| nums | 最大硬币数 |
|---|---|
| [3,1,5,8] | 167 |
| [1,5] | 10 |
| [2,2,2] | 12 |
| [1,2,3] | 12 |
| [9] | 9 |
复杂度
- 时间复杂度:$O(n^3)$,用于所有子区间和分割点
- 空间复杂度:$O(n^2)$,用于动态规划表
戳气球问题抓住了区间动态规划的精髓:选择最后一个动作,在两侧构建子问题,让结构指导最优顺序。
434 最优二叉搜索树
最优二叉搜索树问题要求构建一棵 BST,使得在给定访问频率的情况下,期望搜索成本最小。尽管所有 BST 都按顺序存储相同的键,但不同的树形结构可能导致平均查找深度差异巨大。
我们要解决什么问题?
我们有一组已排序的键 $$K_1<K_2<\dots<K_n,$$ 以及成功搜索的概率 $(p_1,\dots,p_n)$。可选地,我们还可以包含键之间间隙(即不成功搜索)的概率 $(q_0,\dots,q_n)$(经典表述)。
目标:在这些键上构建一棵 BST,以最小化期望比较次数。
两种标准的动态规划模型:
- 包含间隙 $(p_i,q_i)$ 的完整模型。这是教科书版本。
- 仅包含 $(p_i)$ 的简化模型。当你只关心成功命中时很有用。
动态规划:包含间隙的完整模型
在键的区间 $(K_i,\dots,K_j)$ 上定义动态规划,区间两侧包含间隙。
权重(区间内的总概率质量,包括间隙): $$ w[i][j]= \begin{cases} q_{i-1}, & i>j,\ w[i][j-1] + p_j + q_j, & i\le j. \end{cases} $$
期望成本(包括内部节点的比较): $$ e[i][j]= \begin{cases} q_{i-1}, & i>j,\ \displaystyle \min_{r=i}^{j}\big( e[i][r-1] + e[r+1][j] + w[i][j] \big), & i\le j. \end{cases} $$
答案:$e[1][n]$。 如果你还存储了记录最小化 $r$ 的 $root[i][j]$,就可以重建这棵树。
动态规划:简化模型(仅成功搜索)
有时你只有命中频率 $f_i$。让成本以深度计数,每次比较加 1。定义 $$ dp[i][j] = \text{当根节点为其下方的每个键贡献 1 时,键 } K_i,\ldots,K_j \text{ 的最小总加权深度。} $$
一个方便的表述使用前缀和 $$ S[k]=\sum_{t=1}^{k} f_t, \qquad W(i,j)=S[j]-S[i-1]. $$
递推关系: $$ dp[i][j]= \begin{cases} 0, & i>j,\[4pt] \displaystyle \min_{r=i}^{j}\big(dp[i][r-1]+dp[r+1][j]\big)+W(i,j), & i\le j. \end{cases} $$
答案:$dp[1][n]$。 额外的 $W(i,j)$ 解释了选择任何根节点都会使其子树中所有键的深度增加 1 这一事实。
工作原理(通俗解释)
为一个键的区间选择根节点。每个未被选为根节点的键都位于更深的一层,因此其成本增加。动态规划尝试每个候选根节点,并加上:
- 左子树的最优成本
- 右子树的最优成本
- 将所有非根键向下推一层所带来的惩罚(它们的总频率)
为每个区间选择能使这个总和最小化的根节点。
精简代码(简易版本)
C(包含间隙 $(p_i,q_i)$ 的完整模型)
#include <stdio.h>
#include <float.h>
#define MAXN 205
#define MIN(a,b) ((a)<(b)?(a):(b))
double e[MAXN][MAXN], w[MAXN][MAXN];
int rootIdx[MAXN][MAXN];
int main(void) {
int n;
printf("输入 n: ");
scanf("%d", &n);
double p[MAXN], q[MAXN];
printf("输入 p1..pn: ");
for (int i = 1; i <= n; i++) scanf("%lf", &p[i]);
printf("输入 q0..qn: ");
for (int i = 0; i <= n; i++) scanf("%lf", &q[i]);
for (int i = 1; i <= n+1; i++) {
e[i][i-1] = q[i-1];
w[i][i-1] = q[i-1];
}
for (int len = 1; len <= n; len++) {
for (int i = 1; i+len-1 <= n; i++) {
int j = i + len - 1;
w[i][j] = w[i][j-1] + p[j] + q[j];
e[i][j] = DBL_MAX;
for (int r = i; r <= j; r++) {
double cost = e[i][r-1] + e[r+1][j] + w[i][j];
if (cost < e[i][j]) {
e[i][j] = cost;
rootIdx[i][j] = r;
}
}
}
}
printf("最优期望成本: %.6f\n", e[1][n]);
// rootIdx[i][j] 保存了用于重建的选定根节点
return 0;
}Python(包含命中频率 $(f_i)$ 的简化模型)
f = list(map(float, input("输入 f1..fn: ").split()))
n = len(f)
# 为了方便,使用 1-索引
f = [0.0] + f
S = [0.0]*(n+1)
for i in range(1, n+1):
S[i] = S[i-1] + f[i]
def W(i, j):
return 0.0 if i > j else S[j] - S[i-1]
dp = [[0.0]*(n+2) for _ in range(n+2)]
root = [[0]*(n+2) for _ in range(n+2)]
for length in range(1, n+1):
for i in range(1, n - length + 2):
j = i + length - 1
best, arg = float('inf'), -1
for r in range(i, j+1):
cost = dp[i][r-1] + dp[r+1][j] + W(i, j)
if cost < best:
best, arg = cost, r
dp[i][j], root[i][j] = best, arg
print("最优加权成本:", round(dp[1][n], 6))
# root[i][j] 给出了用于重建的根节点选择为什么它很重要
- 模拟了某些键远比其他键更受欢迎的偏态查询
- 经典的区间动态规划,包含分割和每个区间的附加惩罚
- 是查询计划优化、自动补全字典树和决策树塑形的基础
- 当条件满足时,可引出像 Knuth 优化这样的高级加速技术
逐步示例
简化模型,$(f = [0.3, 0.2, 0.5])$ 对应 $(K_1<K_2<K_3)$。
- 对于长度为 1 的区间:$(dp[i][i] = f_i)$
- 对于区间 $([1,2])$:尝试根节点 1 或 2
- $(r=1:\ dp[1][0]+dp[2][2]+(f_1+f_2)=0+0.2+0.5=0.7)$
- $(r=2:\ dp[1][1]+dp[3][2]+0.5=0.3+0+0.5=0.8)$ 选择 $(r=1)$。
- 对于 $([1,3])$:尝试 $(r=1,2,3)$,惩罚为 $(W(1,3)=1.0)$ 计算并选择最小值。动态规划返回最佳树形和成本。
一个温和的证明(为什么它有效)
考虑键 $([i,j])$ 的一棵最优树,其根节点为 $(r)$。除了 $(K_r)$ 之外的所有键都向下移动一层,这正好给它们的累积成本增加了 $(W(i,j)-p_r)$。在 $(r)$ 处分割将问题实例分成两个独立的子问题 $([i,r-1])$ 和 $([r+1,j])$。如果任何一个子树不是最优的,用一个更好的子树替换它会降低总成本,这与最优性矛盾。因此,扫描所有根节点并加上区间权重的递推关系是正确的。间隙模型将 $(q)$-概率加到区间权重 $(w[i][j])$ 上,并产生了经典公式。
自己动手试试
- 使用存储的
root表重建树,并按先序遍历打印它。 - 在相同数据上比较完整模型 $((p,q))$ 和简化模型。
- 归一化频率,使得 $(\sum p_i + \sum q_i = 1)$,并将 $(e[1][n])$ 解释为期望比较次数。
- 当四边形不等式成立时,尝试使用 Knuth 优化将时间减少到 $(O(n^2))$。
- 使用一个键占主导地位的偏态分布进行压力测试。
测试用例
| 键数 | 模型 | 参数 | 预期行为 |
|---|---|---|---|
| 3 个键 | 简化 | $(f=[0.3,0.2,0.5])$ | 根节点倾向于键 3 或 1,取决于分割成本 |
| 4 个键 | 简化 | $(f=[1,1,1,1])$ | 更平衡的树胜出 |
| 3 个键 | 完整 $((p,q))$ | $(p=[0.3,0.2,0.4],\ q=[0.02,0.02,0.03,0.03])$ | 间隙会改变最优根节点的选择 |
| 1 个键 | 任意 | 单个频率 | 成本等于 $(p_1)$ 或 $(q_0+ p_1 + q_1)$ |
复杂度
- 时间复杂度:$(O(n^3))$,使用朴素的 $(i,j,r)$ 三重循环
- 空间复杂度:$(O(n^2))$,用于存储动态规划和根节点表
最优二叉搜索树动态规划捕捉了一个通用模式:选择一个分割点,加上反映深度增加的每个区间的惩罚,然后组合最优子树以获得最小的期望搜索成本。
435 多边形三角剖分
多边形三角剖分问题是一个基础的几何动态规划挑战。给定一个凸多边形,任务是通过绘制不相交的对角线将其划分为互不重叠的三角形,并最小化总权重——通常是三角形面积或边长的总和。它在结构上与矩阵链乘法相似,都涉及区间、分割和可加性代价。
我们要解决什么问题?
给定一个顶点为 $(V_0, V_1, \dots, V_{n-1})$ 的凸多边形,我们希望对其进行三角剖分(使用对角线将其划分为三角形),使得总代价最小。
定义代价为: $$ \text{cost}(i,j,k) = \text{三角形 }(V_i, V_j, V_k) \text{ 的权重} $$ 其中权重可以是:
- 面积
- 周长
- 边长的平方和(为了通用性)
我们定义 DP 状态: $$ dp[i][j] = V_i \text{ 和 } V_j \text{ 之间的最小三角剖分代价} $$
递推关系: $$ dp[i][j] = \min_{i<k<j}\Big(dp[i][k] + dp[k][j] + \text{cost}(i,j,k)\Big) $$
基本情况: $$ dp[i][i+1] = 0 $$
最终答案:$dp[0][n-1]$
它是如何工作的(通俗解释)
你在 $i$ 和 $j$ 之间选择一个顶点 $k$ 来形成一个三角形 $(V_i, V_k, V_j)$。 这个三角形将多边形分割成两个更小的多边形:
- 一个从 $V_i$ 到 $V_k$
- 另一个从 $V_k$ 到 $V_j$
我们递归地找到它们的最优三角剖分,并加上当前三角形的代价。
这是几何上的分治策略。 每一个对角线的选择都对应着动态规划中的一个分割点。
精简代码(简化版本)
C 语言(使用面积作为代价)
#include <stdio.h>
#include <math.h>
#include <float.h>
#define MAX 105
#define min(a,b) ((a)<(b)?(a):(b))
typedef struct { double x, y; } Point;
double dist(Point a, Point b) {
double dx = a.x - b.x, dy = a.y - b.y;
return sqrt(dx*dx + dy*dy);
}
double triangle_cost(Point a, Point b, Point c) {
double s = (dist(a,b) + dist(b,c) + dist(c,a)) / 2.0;
double area = sqrt(s * (s - dist(a,b)) * (s - dist(b,c)) * (s - dist(c,a)));
return area;
}
int main(void) {
int n;
printf("输入顶点数: ");
scanf("%d", &n);
Point v[MAX];
for (int i = 0; i < n; i++)
scanf("%lf %lf", &v[i].x, &v[i].y);
double dp[MAX][MAX];
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
dp[i][j] = 0.0;
for (int len = 2; len < n; len++) {
for (int i = 0; i + len < n; i++) {
int j = i + len;
dp[i][j] = DBL_MAX;
for (int k = i + 1; k < j; k++) {
double cost = dp[i][k] + dp[k][j] + triangle_cost(v[i], v[j], v[k]);
dp[i][j] = min(dp[i][j], cost);
}
}
}
printf("最小三角剖分代价: %.4f\n", dp[0][n-1]);
return 0;
}Python(使用周长作为代价)
import math
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def triangle_cost(a, b, c):
return dist(a,b) + dist(b,c) + dist(c,a)
n = int(input("输入顶点数: "))
v = [tuple(map(float, input().split())) for _ in range(n)]
dp = [[0]*n for _ in range(n)]
for length in range(2, n):
for i in range(n - length):
j = i + length
dp[i][j] = float('inf')
for k in range(i+1, j):
dp[i][j] = min(dp[i][j],
dp[i][k] + dp[k][j] + triangle_cost(v[i], v[k], v[j]))
print("最小三角剖分代价:", round(dp[0][n-1], 4))为什么它重要
- 使用区间和三元分割的经典几何动态规划问题
- 是图形学、网格划分、计算几何和 3D 建模的基础
- 展示了矩阵链乘法和多边形三角剖分共享相同的结构模板
- 强化了空间推理如何映射到递推公式的构建
逐步示例
考虑一个四边形,其顶点为: $$ V_0=(0,0),; V_1=(1,0),; V_2=(1,1),; V_3=(0,1) $$
两种三角剖分方案:
- 对角线 $V_0V_2$:三角形 $(V_0,V_1,V_2)$ 和 $(V_0,V_2,V_3)$
- 对角线 $V_1V_3$:三角形 $(V_0,V_1,V_3)$ 和 $(V_1,V_2,V_3)$
两者给出相同的面积(=1)。 动态规划会计算两者并取最小值。
一个温和的证明(为什么它有效)
每个三角剖分必须恰好包含 $n-3$ 条对角线。 固定一个使用对角线 $V_iV_j$ 的三角形 $(V_i, V_k, V_j)$,会将多边形分割成两个更小的凸多边形。 由于子问题除了边界外互不重叠,它们的最优解组合起来就是全局最优解。 通过评估 $i$ 和 $j$ 之间所有的 $k$,我们保证能找到最优的分割点。 这个递推关系隐式地枚举了所有可能的三角剖分。
动手尝试
- 将代价函数从面积改为周长。
- 通过存储分割点来打印所选择的三角形序列。
- 在二维平面上可视化三角剖分的顺序。
- 比较其复杂度与暴力枚举的差异。
- 用记忆化递归风格实现。
测试用例
| 顶点坐标 | 预期最小代价(面积) |
|---|---|
| 正方形 (0,0),(1,0),(1,1),(0,1) | 1.0 |
| 三角形 (0,0),(1,0),(0,1) | 0.5 |
| 五边形 (0,0),(1,0),(2,1),(1,2),(0,1) | 随形状变化 |
| (0,0),(2,0),(2,2),(0,2) | 4.0 |
复杂度
- 时间复杂度:$O(n^3)$(三重嵌套循环)
- 空间复杂度:$O(n^2)$(DP 表)
多边形三角剖分是矩阵链优化的几何孪生兄弟,相同的递推关系,新的含义。
436 矩阵路径和
矩阵路径和问题要求寻找一条从网格左上角到右下角的路径,以优化某个得分,通常是最小化访问单元格的总和,且只能向右或向下移动。
我们要解决什么问题?
给定一个 $m\times n$ 的整数矩阵 $A$,找到从 $(0,0)$ 到 $(m-1,n-1)$ 的最小成本,移动方式仅限于 ${,\text{右},\text{下},}$。
状态: $$ dp[i][j]=\text{到达单元格 }(i,j) \text{ 的最小路径和} $$
递推关系: $$ dp[i][j]=A[i][j]+\min\big(dp[i-1][j],,dp[i][j-1]\big) $$
边界条件: $$ dp[0][0]=A[0][0],\quad dp[i][0]=A[i][0]+dp[i-1][0],\quad dp[0][j]=A[0][j]+dp[0][j-1] $$
答案: $$ dp[m-1][n-1] $$
空间优化:只保留一行 $$ \text{row}[j]=A[i][j]+\min(\text{row}[j],,\text{row}[j-1]) $$
它是如何工作的(通俗解释)
每个单元格的最佳成本等于其值加上两种可能到达方式(从上方或从左方)中较好的那个。逐行构建表格,直到填满目标单元格。
示例: $$ A= \begin{bmatrix} 1&3&1
1&5&1
4&2&1 \end{bmatrix} $$ 最优和为 $1+1+3+1+1=7$,路径为 $(0,0)\to(1,0)\to(1,1)\to(1,2)\to(2,2)$。
精简代码(简易版本)
C 语言(二维 DP)
#include <stdio.h>
#define MIN(a,b) ((a)<(b)?(a):(b))
int main(void) {
int m, n;
scanf("%d %d", &m, &n);
int A[m][n], dp[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
scanf("%d", &A[i][j]);
dp[0][0] = A[0][0];
for (int i = 1; i < m; i++) dp[i][0] = A[i][0] + dp[i-1][0];
for (int j = 1; j < n; j++) dp[0][j] = A[0][j] + dp[0][j-1];
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
dp[i][j] = A[i][j] + MIN(dp[i-1][j], dp[i][j-1]);
printf("%d\n", dp[m-1][n-1]);
return 0;
}Python(一维空间优化)
m, n = map(int, input().split())
A = [list(map(int, input().split())) for _ in range(m)]
row = [0]*n
row[0] = A[0][0]
for j in range(1, n):
row[j] = row[j-1] + A[0][j]
for i in range(1, m):
row[0] += A[i][0]
for j in range(1, n):
row[j] = A[i][j] + min(row[j], row[j-1])
print(row[-1])为什么它很重要
- 网格动态规划和有向无环图最短路径的核心模板
- 图像接缝裁剪、网格机器人运动、电子表格成本流的基础
- 展示了经典的动态规划模式:表格填充、边界初始化、空间滚动
逐步示例
对于 $$ A= \begin{bmatrix} 5&1&3
2&8&1
4&2&1 \end{bmatrix} $$ 逐行计算:
第一行 $dp$:$[5,6,9]$
第一列 $dp$:$[5,7,11]$
填充内部:
- $dp[1][1]=8+\min(6,7)=14$
- $dp[1][2]=1+\min(9,14)=10$
- $dp[2][1]=2+\min(14,11)=13$
- $dp[2][2]=1+\min(10,13)=11$
答案 $=11$。
一个温和的证明(为什么它有效)
任何到达 $(i,j)$ 的最优路径必须来自 $(i-1,j)$ 或 $(i,j-1)$,因为移动仅限于向右或向下。如果存在一条更便宜的路线不经过这两个中较便宜的那个,那么用更便宜的子路径替换前缀将减少总成本,这与最优性矛盾。因此,在拓扑顺序填充时,使用顶部和左侧最小值进行局部递推可以得到全局最优解。
自己动手试试
- 重建路径:为每个 $(i,j)$ 保留一个指向顶部或左侧最小值的父指针。
- 将 $\min$ 改为 $\max$,以最大化总和而非最小化。
- 添加障碍物:用 $+\infty$ 标记被阻挡的单元格并跳过它们。
- 允许向右、向下和向右下对角线移动。将递推关系扩展到三个前驱。
- 使用大型矩阵,比较二维 DP 与一维滚动性能。
测试用例
| 矩阵 | 期望值 |
|---|---|
| [[1,3,1],[1,5,1],[4,2,1]] | 7 |
| [[5]] | 5 |
| [[1,2,3],[4,5,6]] | 12 |
| [[1,1,1],[1,1,1],[1,1,1]] | 5 |
| [[5,1,3],[2,8,1],[4,2,1]] | 11 |
复杂度
- 时间:$O(mn)$
- 空间:使用完整表格为 $O(mn)$,使用行滚动为 $O(n)$
矩阵路径和是处理网格成本问题的首选模式:初始化边界,扫描表格,每个单元格的值加上最佳到达方式。
437 最大正方形子矩阵
最大正方形子矩阵问题要求找出二进制矩阵中由 1 构成的最大正方形的边长。这是一个经典的二维动态规划问题,每个单元格的值表示以该位置为右下角的正方形最大可能边长。
我们要解决什么问题?
给定一个大小为 $m\times n$ 的二进制矩阵 $A$,找出其中全为 1 的最大正方形的边长。
我们定义 $$ dp[i][j] = \text{以 } (i,j) \text{ 为右下角的最大正方形的边长}. $$
递推关系: $$ dp[i][j]= \begin{cases} A[i][j], & i=0 \text{ 或 } j=0,\ 0, & A[i][j]=0,\ \min{dp[i-1][j],, dp[i][j-1],, dp[i-1][j-1]}+1, & A[i][j]=1. \end{cases} $$
答案: $$ \max_{i,j} dp[i][j]. $$
它是如何工作的(通俗解释)
每个单元格表示:"以我为右下角,能构成多大的 1 正方形?" 如果它是 1,我们查看上方、左方和左上方的邻居,这三个值中的最小值决定了我们能将正方形扩展到多大。
直观理解:
- 一个正方形只有在它的所有边界都能扩展时才能增长。
- 因此,位置 ((i,j)) 处的 1 可以构成一个边长为
1 + min(三个邻居)的正方形。
示例:
1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0最大正方形的边长为 3。
简洁代码(简易版本)
C
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
#define MIN(a,b) ((a)<(b)?(a):(b))
int main(void) {
int m, n;
scanf("%d %d", &m, &n);
int A[m][n], dp[m][n];
for (int i=0; i<m; i++)
for (int j=0; j<n; j++)
scanf("%d", &A[i][j]);
int max_side = 0;
for (int i=0; i<m; i++) {
for (int j=0; j<n; j++) {
if (A[i][j] == 0) dp[i][j] = 0;
else if (i==0 || j==0) dp[i][j] = 1;
else dp[i][j] = MIN(MIN(dp[i-1][j], dp[i][j-1]), dp[i-1][j-1]) + 1;
if (dp[i][j] > max_side) max_side = dp[i][j];
}
}
printf("%d\n", max_side);
return 0;
}Python
m, n = map(int, input().split())
A = [list(map(int, input().split())) for _ in range(m)]
dp = [[0]*n for _ in range(m)]
max_side = 0
for i in range(m):
for j in range(n):
if A[i][j] == 1:
if i == 0 or j == 0:
dp[i][j] = 1
else:
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
max_side = max(max_side, dp[i][j])
print(max_side)为什么它重要
- 是二维动态规划问题的基础(基于邻居的局部递推)。
- 可以直接扩展到矩形、直方图、障碍物或带权重的单元格问题。
- 应用于图像处理、模式识别、生物信息学网格分析。
- 展示了如何通过重叠子问题捕捉空间结构。
逐步示例
矩阵: $$ A = \begin{bmatrix} 1 & 1 & 1 \ 1 & 1 & 1 \ 0 & 1 & 1 \end{bmatrix} $$
填充 DP:
| A | DP |
|---|---|
| 1 | 1 |
| 1 | 1 |
| 1 | 1 |
第 2 行:
- $dp[1][1] = \min(1,1,1) + 1 = 2$
- $dp[1][2] = \min(1,1,1) + 1 = 2$
第 3 行:
- $dp[2][2] = \min(2,2,1) + 1 = 2$
$\text{最大值} = 2 \Rightarrow \text{正方形边长} = 2$
一个温和的证明(为什么它有效)
一个单元格 ((i,j)) 能够成为边长为 (k) 的正方形的右下角,当且仅当:
- 正上方、正左方和左上对角线方向的单元格各自都能构成边长为 (k-1) 的正方形。 因此,(dp[i][j]) 是满足这些条件的最大 (k)。 自顶向下填充确保了每个需要的邻居都已就绪,而取最小值则保证了正方形的对齐。
自己动手试试
- 修改代码以返回面积而非边长。
- 处理障碍物(值为 -1 的单元格)作为阻塞。
- 调整为寻找最大 1 矩形(提示:每行使用直方图动态规划)。
- 输出最大正方形的左上角单元格坐标。
- 比较完整二维动态规划与一维滚动数组动态规划的时间。
测试用例
| 矩阵 | 结果 |
|---|---|
| [[1,1,1],[1,1,1],[1,1,1]] | 3 |
| [[0,1],[1,1]] | 2 |
| [[1,0,1],[1,1,1],[1,1,0]] | 2 |
| [[1]] | 1 |
| [[0,0],[0,0]] | 0 |
复杂度
- 时间复杂度:(O(mn))
- 空间复杂度:(O(mn)) 或使用滚动行时为 (O(n))
这个问题清晰地展示了局部动态规划传播,每个单元格根据其三个邻居的最佳正方形信息来增长。
438 二进制矩阵中的最大矩形
二进制矩阵中的最大矩形问题要求找出一个二进制矩阵中仅包含 1 的最大矩形的面积。它是二维动态规划和基于栈的直方图算法的强大结合:每一行都被视为一个直方图的基底,我们在此计算最大矩形。
我们要解决什么问题?
给定一个大小为 $m \times n$ 的二进制矩阵 $A$,找出完全由 1 组成的最大矩形面积。
将每一行解释为一个高度直方图,并逐行更新: $$ \text{height}[j]= \begin{cases} \text{height}[j]+1, & A[i][j]=1,\ 0, & A[i][j]=0. \end{cases} $$ 在每一行,对 $\text{height}$ 计算"直方图中的最大矩形"。
等价的高度二维递推关系: $$ \text{height}[i][j]= \begin{cases} A[i][j]+\text{height}[i-1][j], & A[i][j]=1,\ 0, & A[i][j]=0. \end{cases} $$
答案: $$ \max_i \operatorname{LargestRectangle}\big(\text{height}[i]\big). $$
它是如何工作的(通俗解释)
将矩阵视为堆叠的直方图:
- 每一行都建立在上一行之上。
1会延伸高度;0会将其重置。- 对于每一行,我们问:"如果这一行是底部,最大的矩形是什么?"
这将一个二维问题转化为 (m) 个直方图问题。
示例:
1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0由 1 组成的最大矩形面积为 6。
精简代码(简易版本)
C 语言(使用栈处理直方图)
#include <stdio.h>
#define MAX 205
#define MAX2(a,b) ((a)>(b)?(a):(b))
int largest_histogram(int *h, int n) {
int stack[MAX], top = -1, maxA = 0;
for (int i = 0; i <= n; i++) {
int cur = (i == n ? 0 : h[i]);
while (top >= 0 && h[stack[top]] >= cur) {
int height = h[stack[top--]];
int width = (top < 0 ? i : i - stack[top] - 1);
int area = height * width;
if (area > maxA) maxA = area;
}
stack[++top] = i;
}
return maxA;
}
int main(void) {
int m, n;
scanf("%d %d", &m, &n);
int A[MAX][MAX];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
scanf("%d", &A[i][j]);
int height[MAX] = {0}, maxArea = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++)
height[j] = A[i][j] ? height[j] + 1 : 0;
int area = largest_histogram(height, n);
if (area > maxArea) maxArea = area;
}
printf("%d\n", maxArea);
return 0;
}Python(基于栈)
def largest_histogram(h):
stack, maxA = [], 0
h.append(0)
for i, x in enumerate(h):
while stack and h[stack[-1]] >= x:
height = h[stack.pop()]
width = i if not stack else i - stack[-1] - 1
maxA = max(maxA, height * width)
stack.append(i)
h.pop()
return maxA
m, n = map(int, input().split())
A = [list(map(int, input().split())) for _ in range(m)]
height = [0]*n
maxA = 0
for i in range(m):
for j in range(n):
height[j] = height[j] + 1 if A[i][j] else 0
maxA = max(maxA, largest_histogram(height))
print(maxA)为什么它很重要
- 二维最大面积问题的核心
- 连接了动态规划(高度传播)和栈算法(直方图)
- 用于图像分割、模式识别、二值掩码
- 是约束条件下最大子矩阵问题的模板
逐步示例
对于: $$ A= \begin{bmatrix} 1 & 1 & 1 \ 1 & 1 & 1 \ 1 & 0 & 1 \end{bmatrix} $$
第 1 行:[1,1,1] → 最大直方图面积 = 3 第 2 行:[2,2,2] → 最大直方图面积 = 6 第 3 行:[3,0,3] → 最大直方图面积 = 3 最大值 = 6
一个温和的证明(为什么它有效)
矩阵中的每个矩形都可以通过其底边行和列范围来标识。 第 (i) 行的高度数组恰好编码了每列(包括第 (i) 行)上方连续 1 的数量。 因此,每个最大矩形的底边行都会被考虑一次,而最大直方图算法确保对于每种高度组合,都能找到最大面积。 所以,遍历所有行就能得到全局最优解。
动手尝试
- 修改代码以返回矩形的左上角和右下角坐标。
- 通过翻转位来扩展到 0 的最大矩形。
- 与最大正方形子矩阵比较,思路相同,递推关系不同。
- 使用滚动数组来减少内存占用。
- 逐行可视化直方图的增长。
测试用例
| 矩阵 | 期望最大面积 |
|---|---|
| [[1,0,1,0,0],[1,0,1,1,1],[1,1,1,1,1],[1,0,0,1,0]] | 6 |
| [[1,1,1],[1,1,1],[1,1,1]] | 9 |
| [[0,0],[0,0]] | 0 |
| [[1]] | 1 |
| [[1,0,1],[1,1,1],[1,1,0]] | 4 |
复杂度
- 时间复杂度:$O(mn)$(每个单元格在所有行中最多被压入/弹出一次)
- 空间复杂度:$O(n)$(用于存储直方图和栈)
这个问题优雅地结合了逐行动态规划和直方图优化,是解决二维网格中最大矩形问题的通用方法。
439 子矩阵求和查询
子矩阵求和查询问题要求计算二维数组中许多矩形区域内所有元素的和。借助二维前缀和动态规划表,在 $O(mn)$ 预处理后,每个查询可以在 $O(1)$ 时间内得到答案。
我们要解决什么问题?
给定一个 $m\times n$ 的矩阵 $A$ 和许多形式为 $(r_1,c_1,r_2,c_2)$ 的查询,其中 $0\le r_1\le r_2<m$ 且 $0\le c_1\le c_2<n$,计算:
$$ \text{Sum}(r_1,c_1,r_2,c_2)=\sum_{i=r_1}^{r_2}\sum_{j=c_1}^{c_2}A[i][j] $$
使用基于 1 的索引定义二维前缀和 $P$:
$$ P[i][j]=\sum_{x=1}^{i}\sum_{y=1}^{j}A[x-1][y-1], \quad 1\le i\le m,\ 1\le j\le n $$
其中 $P[0][]=P[][0]=0$。
那么任意子矩阵的和为:
$$ S=P[r_2+1][c_2+1]-P[r_1][c_2+1]-P[r_2+1][c_1]+P[r_1][c_1] $$
它是如何工作的(通俗解释)
预先计算从左上角开始的累积和。 那么一个矩形的和就等于到达其右下角的大前缀和,减去上方和左侧的两个前缀和,再加上你重复减去的重叠部分。 这只是二维的容斥原理。
示例:
$$ A = \begin{bmatrix} 1 & 2 & 3 \ 4 & 5 & 6 \ 7 & 8 & 9 \end{bmatrix} $$
查询 $(r_1,c_1,r_2,c_2)=(1,1,2,2)$,作用于 $$ \begin{bmatrix} 5 & 6 \ 8 & 9 \end{bmatrix} $$ 得到 $5+6+8+9=28$。
精简代码(简易版本)
C 语言(二维前缀和,每个查询 O(1))
#include <stdio.h>
int main(void) {
int m, n, q;
scanf("%d %d", &m, &n);
long long A[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
scanf("%lld", &A[i][j]);
long long P[m+1][n+1];
for (int i = 0; i <= m; i++) P[i][0] = 0;
for (int j = 0; j <= n; j++) P[0][j] = 0;
for (int i = 1; i <= m; i++) {
long long rowsum = 0;
for (int j = 1; j <= n; j++) {
rowsum += A[i-1][j-1];
P[i][j] = P[i-1][j] + rowsum;
}
}
scanf("%d", &q);
while (q--) {
int r1, c1, r2, c2;
scanf("%d %d %d %d", &r1, &c1, &r2, &c2);
long long S = P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1];
printf("%lld\n", S);
}
return 0;
}Python(二维前缀和)
m, n = map(int, input().split())
A = [list(map(int, input().split())) for _ in range(m)]
P = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
rowsum = 0
for j in range(1, n+1):
rowsum += A[i-1][j-1]
P[i][j] = P[i-1][j] + rowsum
q = int(input())
for _ in range(q):
r1, c1, r2, c2 = map(int, input().split())
S = P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1]
print(S)为什么它很重要
- 在 $O(mn)$ 预处理后,将许多二维区间和查询转化为每个 $O(1)$ 时间
- 是积分图像、热力图、地形高程图和数据分析的基础
- 是最大子矩阵和、区间平均值和密度查询的构建模块
逐步示例
令 $$ A = \begin{bmatrix} 2 & -1 & 3 \ 0 & 4 & 5 \ 7 & 2 & -6 \end{bmatrix} $$
查询 $(0,1,2,2)$ 覆盖子矩阵 $$ \begin{bmatrix} -1 & 3 \ 4 & 5 \ 2 & -6 \end{bmatrix} $$ 其和为 $$ -1 + 3 + 4 + 5 + 2 - 6 = 7. $$
用公式验证:
$$ S = P[3][3]-P[0][3]-P[3][1]+P[0][1] = 12-4-7+6 = 7 $$
一个温和的证明(为什么它有效)
根据定义, $P[i][j]=\sum_{x\le i}\sum_{y\le j}A[x-1][y-1]$。
对于矩形 $[r_1..r_2]\times[c_1..c_2]$:
- $P[r_2+1][c_2+1]$:直到右下角的总和
- 减去 $P[r_1][c_2+1]$:移除上方的行
- 减去 $P[r_2+1][c_1]$:移除左侧的列
- 加上 $P[r_1][c_1]$:恢复重叠部分
因此,容斥恒等式成立。
亲自尝试
- 扩展到三维前缀和以处理长方体查询
- 支持区间平均值(将和除以面积)
- 为大数和添加模运算
- 使用二维 Fenwick 树处理稀疏更新
- 为概率图或热分布预计算前缀和
测试用例
| 矩阵 | 查询 $(r_1,c_1,r_2,c_2)$ | 期望值 |
|---|---|---|
| [[1,2],[3,4]] | (0,0,1,1) | 10 |
| [[1,2,3],[4,5,6],[7,8,9]] | (1,1,2,2) | 28 |
| [[2,-1,3],[0,4,5],[7,2,-6]] | (0,1,2,2) | 7 |
| [[5]] | (0,0,0,0) | 5 |
| [[1,0,1],[0,1,0],[1,0,1]] | (0,0,2,2) | 5 |
复杂度
- 预处理:$O(mn)$
- 查询:$O(1)$
- 空间:$O(mn)$
二维前缀和是一个基础的动态规划工具:预处理一次,然后每个子矩阵和都能即时得到。
440 回文串分割
回文串分割(Palindrome Partitioning)问题要求你将一个字符串分割成尽可能少的子串,使得每个子串都是回文串。这是一个典型的区间动态规划(interval DP)问题,我们探索所有分割点,并使用预先计算好的回文检查来加速递推过程。
我们要解决什么问题?
给定一个长度为 $n$ 的字符串 $s$,找出所需的最少切割次数,使得每个子串都是回文串。
例如: $s=\text{"aab"}$ 最佳分割是 "aa" | "b",需要 1 次切割。
我们定义:
- $dp[i]$ = 子串 $s[0..i]$ 所需的最少切割次数
- $pal[i][j] = 1$ 如果 $s[i..j]$ 是回文串,否则为 $0$
递推关系: $$ dp[i] = \begin{cases} 0, & \text{if } pal[0][i] = 1,\ \min_{0 \le j < i,\ pal[j+1][i] = 1} (dp[j] + 1), & \text{otherwise.} \end{cases} $$
使用以下公式预先计算 $pal[i][j]$: $$ pal[i][j] = (s[i]=s[j]) \land (j-i<2 \lor pal[i+1][j-1]) $$
答案:$dp[n-1]$
它是如何工作的(通俗解释)
我们希望在右子串是回文串的位置切割字符串。 对于每个索引 $i$,我们找到所有 $j<i$ 使得 $s[j+1..i]$ 是回文串,并取 $dp[j]+1$ 的最小值。
为了避免 $O(n^3)$ 的复杂度,我们首先以 $O(n^2)$ 的时间预先计算 $pal[i][j]$。
示例:
s = "aab"
pal =
a a b
1 1 0
1 0
1切割:
- $dp[0]=0$ (
a) - $dp[1]=0$ (
aa) - $dp[2]=1$ (
aa|b) → 答案 = 1
精简代码(简易版本)
C 语言(自底向上动态规划)
#include <stdio.h>
#include <string.h>
#include <limits.h>
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAXN 1005
int main(void) {
char s[MAXN];
scanf("%s", s);
int n = strlen(s);
int pal[MAXN][MAXN] = {0};
int dp[MAXN];
for (int i = 0; i < n; i++) pal[i][i] = 1;
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (s[i] == s[j]) {
if (len == 2 || pal[i+1][j-1]) pal[i][j] = 1;
}
}
}
for (int i = 0; i < n; i++) {
if (pal[0][i]) { dp[i] = 0; continue; }
dp[i] = INT_MAX;
for (int j = 0; j < i; j++) {
if (pal[j+1][i] && dp[j] + 1 < dp[i])
dp[i] = dp[j] + 1;
}
}
printf("%d\n", dp[n-1]);
return 0;
}Python(可读性版本)
s = input().strip()
n = len(s)
pal = [[False]*n for _ in range(n)]
dp = [0]*n
for i in range(n):
pal[i][i] = True
for length in range(2, n+1):
for i in range(n-length+1):
j = i + length - 1
if s[i] == s[j] and (length == 2 or pal[i+1][j-1]):
pal[i][j] = True
for i in range(n):
if pal[0][i]:
dp[i] = 0
else:
dp[i] = min(dp[j]+1 for j in range(i) if pal[j+1][i])
print(dp[-1])为什么它重要
- 教授基于字符串状态的区间动态规划
- 应用于文本分割、DNA 序列分析、代码解析
- 为分割问题以及预计算的协同作用建立直觉
逐步示例
$s = \text{"banana"}$
回文子串:
- 单个字母
"ana"位于位置 (1–3), (3–5)"anana"位于 (1–5)
计算 $dp$:
- $dp[0]=0$
- $dp[1]=1$ (
b|a) - $dp[2]=1$ (
ba|n) - $dp[3]=1$ (
b|ana) - $dp[4]=2$ (
b|an|an) - $dp[5]=1$ (
b|anana) → 答案 = 1
一个温和的证明(为什么它有效)
一个有效的分割结束于某个位置 $i$。 如果 $s[j+1..i]$ 是回文串,那么分割到 $i$ 的代价是 $dp[j]+1$。 最优解必须选择最佳的 $j$。 通过预先计算所有回文子串,每个 $dp[i]$ 仅依赖于更小的索引,满足了最优性原理。
自己动手试试
- 使用父数组返回实际的分割方案。
- 修改为计算所有分割方案的数量,而不是最小化。
- 适配到回文子序列问题(不同的结构)。
- 并排可视化动态规划表和回文表。
- 对朴素方法与预计算回文方法进行基准测试。
测试用例
| s | 预期切割次数 | 示例分割 | ||
|---|---|---|---|---|
| "aab" | 1 | "aa" | "b" | |
| "racecar" | 0 | "racecar" | ||
| "banana" | 1 | "b" | "anana" | |
| "abc" | 2 | "a" | "b" | "c" |
| "a" | 0 | "a" |
复杂度
- 时间:$O(n^2)$
- 空间:$O(n^2)$(可以减少回文表)
回文串分割是动态规划与预计算结合的一个典范例子,揭示了结构(回文串)如何实现高效的分割。
第 45 节. 状态压缩动态规划与旅行商问题
441 旅行商问题 (TSP),状态压缩动态规划 (Held–Karp)
旅行商问题要求找到一条最短的环路,该环路恰好访问每个城市一次并返回起点。通过基于子集的动态规划,我们可以在 $O(n^2 2^n)$ 的时间内解决它,这对于精确的指数级算法来说,在多项式因子内是最优的。
我们要解决什么问题?
给定 $n$ 个城市和一个距离矩阵 $dist[i][j]$,找到一条从城市 $0$ 出发,访问所有城市一次并返回 $0$ 的最小环路长度。
状态定义 (Held–Karp):
- 令 $dp[mask][i]$ 表示从城市 $0$ 出发,恰好访问位掩码 $mask$ 所代表的城市集合(其中位 $k$ 被置位表示城市 $k$ 已被访问),并且最终停留在城市 $i$ 的最小成本。
- 基础情况:$dp[1\ll 0][0]=0$
状态转移:
$$ dp[mask][i] = \min_{j \in mask,, j\ne i}; (dp[mask\setminus{i}][j] + dist[j][i]) $$
答案:
$$ \min_{i\ne 0}; dp[(1\ll n)-1][i] + dist[i][0] $$
路径重建:为每个 $(mask,i)$ 存储前驱节点。
它是如何工作的(通俗解释)
我们通过逐步扩大已访问城市的集合来构建环路。对于每个子集和最后一个城市 $i$,我们问:
"在不访问 $i$ 的情况下访问这个子集的最佳方式是什么,然后从 $j$ 跳到 $i$?"
这利用了较小的子问题来解决较大的子问题,直到所有城市都被访问。
精简代码(简易版本)
C 语言(带路径重建的状态压缩动态规划)
#include <stdio.h>
#include <limits.h>
#define MAXN 20
#define INF 1000000000
int dist[MAXN][MAXN];
int prevCity[1<<MAXN][MAXN];
int dp[1<<MAXN][MAXN];
int main(void) {
int n;
scanf("%d", &n);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
scanf("%d", &dist[i][j]);
int N = 1 << n;
for (int m = 0; m < N; m++)
for (int i = 0; i < n; i++)
dp[m][i] = INF, prevCity[m][i] = -1;
dp[1<<0][0] = 0;
for (int mask = 0; mask < N; mask++) {
for (int i = 0; i < n; i++) if (mask & (1<<i)) {
int pmask = mask ^ (1<<i);
if (pmask == 0) continue;
for (int j = 0; j < n; j++) if (pmask & (1<<j)) {
int val = dp[pmask][j] + dist[j][i];
if (val < dp[mask][i]) {
dp[mask][i] = val;
prevCity[mask][i] = j;
}
}
}
}
int all = N - 1, best = INF, last = -1;
for (int i = 1; i < n; i++) {
int val = dp[all][i] + dist[i][0];
if (val < best) best = val, last = i;
}
printf("Min tour cost: %d\n", best);
int path[MAXN+1], cnt = n;
int mask = all, cur = last;
path[--cnt] = cur;
while (cur != 0) {
int p = prevCity[mask][cur];
mask ^= (1<<cur);
cur = p;
path[--cnt] = cur;
}
printf("Tour: ");
for (int i = 0; i < n; i++) printf("%d ", path[i]);
printf("0\n");
return 0;
}Python(紧凑版本)
import sys
INF = 1015
n = int(sys.stdin.readline())
dist = [list(map(int, sys.stdin.readline().split())) for _ in range(n)]
N = 1 << n
dp = [[INF]*n for _ in range(N)]
dp[1][0] = 0
for mask in range(N):
if (mask & 1) == 0:
continue
for i in range(n):
if (mask & (1<<i)) == 0:
continue
pmask = mask ^ (1<<i)
if pmask == 0:
continue
for j in range(n):
if (pmask & (1<<j)) == 0:
continue
dp[mask][i] = min(dp[mask][i], dp[pmask][j] + dist[j][i])
allmask = N - 1
ans = min(dp[allmask][i] + dist[i][0] for i in range(1, n))
print(ans)为什么它很重要
- 经典的状态压缩动态规划示例
- 对于一般 TSP 问题,具有已知最佳时间复杂度的精确解法
- 基于子集状态的动态规划问题的模板:分配、路由、路径覆盖、斯坦纳树
分步示例
假设
$$ dist = \begin{bmatrix} 0 & 10 & 15 & 20 \ 10 & 0 & 35 & 25 \ 15 & 35 & 0 & 30 \ 20 & 25 & 30 & 0 \end{bmatrix} $$
最优环路是 $0 \to 1 \to 3 \to 2 \to 0$,成本为 $10+25+30+15=80$。
一个温和的证明(为什么它有效)
对于任何包含 $0$ 且以 $i\in S$ 为终点的子集 $S$,访问 $S$ 并以 $i$ 结束的最优路径必然来自某个 $j\in S\setminus{i}$,该路径最优地访问了 $S\setminus{i}$,然后经过边 $j\to i$。
因此:
$$ dp[S][i] = \min_{j\in S\setminus{i}}(dp[S\setminus{i}][j] + dist[j][i]) $$
通过按子集大小递增的顺序处理,依赖项在使用前总是准备就绪。
动手尝试
- 重建环路路径
- 添加必须访问或禁止访问的城市
- 在 $n=15$ 的情况下运行并观察规模变化
- 适应非对称 TSP($dist[i][j]\ne dist[j][i]$)
- 与暴力枚举 $O(n!)$ 进行比较
测试用例
| n | dist | 期望值 |
|---|---|---|
| 2 | [[0,5],[5,0]] | 10 |
| 3 | [[0,1,10],[1,0,2],[10,2,0]] | 13 |
| 4 | [[0,10,15,20],[10,0,35,25],[15,35,0,30],[20,25,30,0]] | 80 |
| 4 | [[0,3,4,2],[3,0,1,5],[4,1,0,6],[2,5,6,0]] | 12 |
复杂度
- 时间复杂度:$O(n^2 2^n)$
- 空间复杂度:$O(n 2^n)$
Held–Karp 动态规划是基于子集的指数级时间优化的基础,它连接了组合搜索和动态规划。
442 子集动态规划(基于状态子集)
子集动态规划是一种强大的模式,其中每个动态规划状态代表一个元素子集。当问题依赖于物品组合、掩码或已访问集合时,会使用这种模式。你基于更小的子集定义状态转移,逐步构建到更大的子集。
我们要解决什么问题?
我们想要在大小为 $n$ 的全集的所有子集 $S$ 上计算某个函数 $dp[S]$(其中 $S$ 用一个位掩码表示)。
每个 $dp[S]$ 依赖于 $S$ 的更小子集,通常是通过每次添加或移除一个元素来实现。
常见形式:
- 子集和:$dp[S] = \sum_{T \subset S} f[T]$
- 子集上的最大值:$dp[S] = \max_{T \subset S} f[T]$
- 计数配置:$dp[S] = \sum_{i \in S} dp[S \setminus {i}]$
关键思想:使用位运算并高效地遍历子掩码。
它是如何工作的(通俗解释)
将每个子集视为一个状态。 例如,如果 $n=3$,子集如下:
| 掩码 | 二进制 | 子集 |
|---|---|---|
| 0 | 000 | ∅ |
| 1 | 001 | {0} |
| 2 | 010 | {1} |
| 3 | 011 | {0,1} |
| 4 | 100 | {2} |
| 5 | 101 | {0,2} |
| 6 | 110 | {1,2} |
| 7 | 111 | {0,1,2} |
状态转移取决于问题的结构:
- 加法型(子掩码求和)
- 组合型(合并结果)
- 逐步型(添加/移除一个比特位)
示例 1:子集求和(SOS DP)
我们想要 $F[S] = \sum_{T \subseteq S} A[T]$。 朴素方法时间复杂度为 $O(3^n)$,但 SOS DP 可以在 $O(n2^n)$ 内完成。
算法:
for (int i = 0; i < n; i++)
for (int mask = 0; mask < (1<<n); mask++)
if (mask & (1<<i))
F[mask] += F[mask ^ (1<<i)];每次迭代都会添加缺少比特位 $i$ 的子集的贡献。
示例 2:子集上的路径计数
假设我们计算子集上的哈密顿路径数量: $$ dp[S][i] = \sum_{j \in S, j \ne i} dp[S\setminus{i}][j] $$ 基础情况为 $dp[{i}][i]=1$。
遍历所有子集,对于每个子集和终点,对可能的前驱节点求和。
微型代码(简易版本)
C 语言(子集求和示例)
#include <stdio.h>
int main(void) {
int n = 3;
int A[8] = {1, 2, 3, 4, 5, 6, 7, 8}; // 任意基础值
int F[8];
for (int mask = 0; mask < (1<<n); mask++) F[mask] = A[mask];
for (int i = 0; i < n; i++)
for (int mask = 0; mask < (1<<n); mask++)
if (mask & (1<<i))
F[mask] += F[mask ^ (1<<i)];
for (int mask = 0; mask < (1<<n); mask++)
printf("F[%d] = %d\n", mask, F[mask]);
return 0;
}Python(子集求和)
n = 3
A = [1,2,3,4,5,6,7,8]
F = A[:]
for i in range(n):
for mask in range(1<<n):
if mask & (1<<i):
F[mask] += F[mask ^ (1<<i)]
print(F)为什么它很重要
- 是位掩码组合学的基础
- 加速子集卷积、容斥原理和快速 Zeta 变换
- 在斯坦纳树动态规划、位掩码背包问题和旅行商问题变体中至关重要
逐步示例
令 $A = [1,2,3,4,5,6,7,8]$,$n=3$。
我们想要 $F[S]=\sum_{T\subseteq S}A[T]$。
- $F[0]=A[0]=1$
- $F[1]=A[1]+A[0]=3$
- $F[3]=A[3]+A[2]+A[1]+A[0]=10$
最终: $F = [1,3,4,10,5,12,13,36]$
一个温和的证明(为什么它有效)
每个掩码代表一个子集 $S$。 当我们遍历比特位 $i$ 时,如果 $i\in S$,我们将 $F[S\setminus{i}]$ 加到 $F[S]$ 上。 这会将值从更小的子集传播到更大的子集,累积所有子掩码的贡献。 循环顺序确保了每个子掩码都在包含它的超集之前被处理。
亲自尝试
- 通过翻转条件来实现超集求和(SOS Superset)。
- 计算 $\max_{T\subseteq S} A[T]$ 而不是求和。
- 使用子集动态规划来计算用给定子集覆盖一个集合的方法数。
- 将子集动态规划与 bitcount(mask) 结合,以处理按大小的状态转移。
- 将子集格可视化为超立方体遍历。
测试用例
| A | 期望的 F(子集求和) |
|---|---|
| [1,2,3,4,5,6,7,8] | [1,3,4,10,5,12,13,36] |
| [0,1,0,1,0,1,0,1] | [0,1,0,2,0,2,0,4] |
| [1,0,0,0,0,0,0,0] | [1,1,1,1,1,1,1,1] |
复杂度
- 时间复杂度:$O(n2^n)$
- 空间复杂度:$O(2^n)$
子集动态规划是处理集合问题的统一模式,一旦你看到位掩码,就想到“基于子集的动态规划”。
443 哈密顿路径动态规划(状态压缩)
哈密顿路径动态规划问题要求找到一条恰好访问每个顶点一次的最短路径,且无需返回起点。它是旅行商问题(TSP)的近亲,但没有最后的返回边。使用位掩码动态规划,我们可以在 $O(n^2 2^n)$ 的时间内解决它。
我们要解决什么问题?
给定一个具有 $n$ 个顶点和成本矩阵 $dist[i][j]$ 的完全加权有向图,找到一条恰好访问所有顶点一次且成本最小的路径。
我们不需要返回到起始节点(这与 TSP 不同)。
定义动态规划状态:
- $dp[mask][i]$:恰好访问掩码 $mask$ 所表示的顶点集,并以顶点 $i$ 结束的最小成本。
基础情况:
$$ dp[1<<i][i] = 0 \quad \forall i $$
状态转移:
$$ dp[mask][i] = \min_{j \in mask,\ j \ne i}\big(dp[mask \setminus {i}][j] + dist[j][i]\big) $$
答案:
$$ \min_{i} dp[(1<<n)-1][i] $$
它是如何工作的(通俗解释)
想象我们一步一步地构建路径:
- 每个掩码代表我们已经访问了哪些顶点。
- 每个终点 $i$ 意味着我们以顶点 $i$ 结束路径。
- 我们通过添加最后一个顶点 $i$,从较小的子集构建 $dp[mask][i]$。
在每一步,我们检查所有可能作为路径中前一个顶点的 $j$。
不需要加上 $dist[i][start]$,因为我们不返回,这是一条路径,而不是一个环。
精简代码(简易版本)
C 语言(哈密顿路径动态规划)
#include <stdio.h>
#include <limits.h>
#define MAXN 20
#define INF 1000000000
int dist[MAXN][MAXN];
int dp[1<<MAXN][MAXN];
int main(void) {
int n;
scanf("%d", &n);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
scanf("%d", &dist[i][j]);
int N = 1 << n;
for (int mask = 0; mask < N; mask++)
for (int i = 0; i < n; i++)
dp[mask][i] = INF;
for (int i = 0; i < n; i++)
dp[1<<i][i] = 0;
for (int mask = 0; mask < N; mask++) {
for (int i = 0; i < n; i++) {
if (!(mask & (1<<i))) continue;
int pmask = mask ^ (1<<i);
if (pmask == 0) continue;
for (int j = 0; j < n; j++) {
if (!(pmask & (1<<j))) continue;
int cost = dp[pmask][j] + dist[j][i];
if (cost < dp[mask][i]) dp[mask][i] = cost;
}
}
}
int all = N - 1;
int best = INF;
for (int i = 0; i < n; i++)
if (dp[all][i] < best)
best = dp[all][i];
printf("Minimum Hamiltonian path cost: %d\n", best);
return 0;
}Python(紧凑版本)
INF = 1015
n = int(input())
dist = [list(map(int, input().split())) for _ in range(n)]
N = 1 << n
dp = [[INF]*n for _ in range(N)]
for i in range(n):
dp[1<<i][i] = 0
for mask in range(N):
for i in range(n):
if not (mask & (1<<i)): continue
pmask = mask ^ (1<<i)
if pmask == 0: continue
for j in range(n):
if not (pmask & (1<<j)): continue
dp[mask][i] = min(dp[mask][i], dp[pmask][j] + dist[j][i])
ans = min(dp[N-1][i] for i in range(n))
print(ans)为什么它很重要
- 基本的状态压缩动态规划模式
- 当问题涉及恰好访问所有节点一次时非常有用
- 是路径规划、顺序约束和位掩码搜索的核心
逐步示例
令
$$ dist = \begin{bmatrix} 0 & 1 & 4 \ 1 & 0 & 2 \ 4 & 2 & 0 \end{bmatrix} $$
路径:
- $0 \to 1 \to 2$:$1 + 2 = 3$
- $0 \to 2 \to 1$:$4 + 2 = 6$
- $1 \to 0 \to 2$:$1 + 4 = 5$
所以最小路径成本 = 3。 $dp[(1<<3)-1] = dp[7] = [5,3,5]$,$\min=3$。
一个温和的证明(为什么它有效)
我们应用最优子结构:
对于每个子集 $S$ 和终点 $i$, 到达 $(S,i)$ 的最优哈密顿路径必须通过一条边 $j\to i$ 扩展到达 $(S\setminus{i}, j)$ 的最优路径。 这个递推关系确保了没有城市被重复访问,并且所有城市都被恰好包含一次。
每个 $dp[mask][i]$ 只依赖于更小的掩码,因此可以自底向上构建。
自己动手试试
- 添加起始节点约束(固定路径必须从 0 开始)。
- 使用
parent数组恢复实际路径。 - 修改为求最大路径(将
min替换为max)。 - 适应具有不对称成本的有向图。
- 使用位技巧,如
mask & -mask来高效地迭代位。
测试用例
| n | dist | 预期结果 |
|---|---|---|
| 3 | [[0,1,4],[1,0,2],[4,2,0]] | 3 |
| 4 | [[0,3,1,5],[3,0,6,7],[1,6,0,2],[5,7,2,0]] | 6 |
| 2 | [[0,5],[5,0]] | 5 |
| 3 | [[0,9,9],[9,0,1],[9,1,0]] | 10 |
复杂度
- 时间:$O(n^2 2^n)$
- 空间:$O(n 2^n)$
哈密顿路径动态规划是解决涉及恰好遍历所有节点一次的问题的核心结构,简单、强大,并且是无数变体的模板。
444 任务分配问题 DP(基于任务掩码)
任务分配问题要求找出将 $n$ 个工人分配到 $n$ 个任务的最小总成本,每个工人恰好执行一个任务,每个任务恰好由一个工人完成。除了匈牙利算法,对于较小的 $n$,一个简洁的解决方案是基于任务子集的位掩码动态规划。
我们要解决什么问题?
给定一个成本矩阵 $C$,其中 $C[i][j]$ 是工人 $i$ 执行任务 $j$ 的成本,找出工人和任务之间的最小成本完美匹配。
状态定义:
- 令 $mask$ 编码哪些任务已被占用。
- 令 $i=\text{popcount}(mask)$ 表示已分配的工人数量,意味着我们即将分配工人 $i$。
DP 状态:
$$ dp[mask]=\text{将前 } \text{popcount}(mask)\text{ 个工人分配给 }mask\text{ 中任务集合的最小总成本} $$
状态转移:
$$ dp[mask\cup{j}]=\min\big(dp[mask]+C[i][j]\big)\quad\text{对于所有任务 }j\notin mask,\ i=\text{popcount}(mask) $$
初始状态与答案:
$$ dp[0]=0,\qquad \text{答案}=dp[(1\ll n)-1] $$
它是如何工作的(通俗解释)
我们按顺序 $0,1,\dots,n-1$ 处理工人。位掩码指示哪些任务已被使用。对于下一个工人 $i$,尝试分配任何空闲任务 $j$,加上其成本,并保留最优值。这会逐步构建所有部分匹配,直到所有任务都被占用。
简洁代码(简易版本)
C (位掩码 DP)
#include <stdio.h>
#include <limits.h>
#define MAXN 20
#define INF 1000000000
int C[MAXN][MAXN];
int dp[1<<MAXN];
int main(void) {
int n;
scanf("%d", &n);
for (int i=0;i<n;i++)
for (int j=0;j<n;j++)
scanf("%d",&C[i][j]);
int N=1<<n;
for (int m=0;m<N;m++) dp[m]=INF;
dp[0]=0;
for (int mask=0;mask<N;mask++) {
int i = __builtin_popcount(mask);
if (i>=n || dp[mask]==INF) continue;
for (int j=0;j<n;j++) if (!(mask&(1<<j))) {
int nmask = mask|(1<<j);
if (dp[mask]+C[i][j] < dp[nmask])
dp[nmask] = dp[mask]+C[i][j];
}
}
printf("%d\n", dp[N-1]);
return 0;
}Python (紧凑版)
import sys
INF = 1015
n = int(sys.stdin.readline())
C = [list(map(int, sys.stdin.readline().split())) for _ in range(n)]
N = 1 << n
dp = [INF]*N
dp[0] = 0
for mask in range(N):
i = bin(mask).count("1")
if i >= n or dp[mask] == INF:
continue
for j in range(n):
if (mask >> j) & 1:
continue
dp[mask | (1 << j)] = min(dp[mask | (1 << j)], dp[mask] + C[i][j])
print(dp[N-1])为什么它很重要
- 基于子集的状态压缩 DP 的经典示例
- 当 $n\le 20$ 左右时,简单可靠
- 与匈牙利算法和最小费用流进行比较的基准
- 适用于更复杂约束的模板:禁止配对、奖励、先决条件
逐步示例
令
$$ C= \begin{bmatrix} 9&2&7\ 6&4&3\ 5&8&1 \end{bmatrix} $$
一个最优分配是工人 $0\to1$ (2), $1\to2$ (3), $2\to0$ (5),总成本为 $2+3+5=10$。DP 探索所有掩码,总是为下一个工人扩展一个空闲任务。
一个温和的证明(为什么它有效)
令 $S$ 为一个任务集合,且 $i=\vert S\vert $。将前 $i$ 个工人分配给 $S$ 的任何最优部分分配,其最后一步必然是分配工人 $i-1$ 给某个 $j\in S$。移除任务 $j$ 会得到一个对于 $(S\setminus{j}, i-1)$ 的最优解加上 $C[i-1][j]$。反过来,这给出了从 $dp[mask]$ 到 $dp[mask\cup{j}]$ 的前向转移,其中 $i=\text{popcount}(mask)$。由于每次转移都会增加掩码,按递增的 popcount 顺序填充掩码在拓扑上符合依赖关系。
自己动手尝试
- 通过为每个 $mask$ 存储一个父任务来重构分配方案。
- 通过跳过那些 $(i,j)$ 对来添加禁止配对。
- 添加一个奖励矩阵 $B$,并最小化 $\sum(C[i][j]-B[i][j])$。
- 通过用零虚拟成本填充较小的一侧来处理矩形 $n\times m$ 的情况。
- 在随机实例上比较运行时间与匈牙利算法。
测试用例
| $C$ | 期望最小值 |
|---|---|
| $\begin{bmatrix}1 & 2 \ 2 & 1\end{bmatrix}$ | $2$ |
| $\begin{bmatrix}9 & 2 & 7 \ 6 & 4 & 3 \ 5 & 8 & 1\end{bmatrix}$ | $10$ |
| $\begin{bmatrix}4 & 1 & 3 \ 2 & 0 & 5 \ 3 & 2 & 2\end{bmatrix}$ | $5$ |
| $\begin{bmatrix}10\end{bmatrix}$ | $10$ |
复杂度
- 时间:$O(n^2 2^n)$
- 空间:$O(2^n)$
任务分配问题的位掩码 DP 是一个简洁的蓝图:迭代掩码,将下一个工人分配给一个空闲任务,并保持最便宜的扩展,直到所有任务都被占用。
445 划分为两个集合(平衡负载)
划分问题(Partition Problem)询问一个给定的数字集合能否被分割成两个和相等的子集。在其优化形式中,我们的目标是最小化两个子集和之间的差异。这是一个经典的子集动态规划(DP)示例,用于建模平衡工作负载、资源分配和负载均衡。
我们要解决什么问题?
给定一个数组 $A[0..n-1]$,将其划分为两个子集 $S_1$ 和 $S_2$,使得它们的和之差最小化:
$$ \text{最小化 } |sum(S_1) - sum(S_2)| $$
等价地,找到一个子集,其和尽可能接近总和的一半:
$$ \text{目标值} = \left\lfloor \frac{\sum A}{2} \right\rfloor $$
我们使用动态规划来找出所有可达的和,直到 target。
定义布尔型动态规划:
$$ dp[i][s] = 1 \text{ 表示前 } i \text{ 个元素的某个子集的和为 } s $$
状态转移:
$$ dp[i][s] = dp[i-1][s] \lor dp[i-1][s-A[i-1]] \quad \text{如果 } s\ge A[i-1] $$
答案:
$$ \text{找到最大的 } s\le target \text{ 使得 } dp[n][s]=1,\ \text{差值} = total - 2s $$
它是如何工作的(通俗解释)
可以将其想象成填充一个容量为 target 的背包。 每个物品要么放入子集,要么不放。 我们尝试所有可能的和,直到总和的一半;超过这个值,第二个子集的和就是其镜像。 最接近 target 的和会产生最小的差值。
精简代码(简易版本)
C 语言(表格法)
#include <stdio.h>
#include <string.h>
#define MAXN 100
#define MAXSUM 10000
int main(void) {
int n, A[MAXN];
scanf("%d", &n);
int total = 0;
for (int i = 0; i < n; i++) {
scanf("%d", &A[i]);
total += A[i];
}
int target = total / 2;
int dp[MAXSUM + 1] = {0};
dp[0] = 1;
for (int i = 0; i < n; i++) {
for (int s = target; s >= A[i]; s--) {
if (dp[s - A[i]]) dp[s] = 1;
}
}
int best = 0;
for (int s = target; s >= 0; s--) {
if (dp[s]) { best = s; break; }
}
int diff = total - 2 * best;
printf("最小差值: %d\n", diff);
return 0;
}Python(简洁版本)
A = list(map(int, input().split()))
total = sum(A)
target = total // 2
dp = [False]*(target+1)
dp[0] = True
for x in A:
for s in range(target, x-1, -1):
if dp[s-x]:
dp[s] = True
for s in range(target, -1, -1):
if dp[s]:
print("最小差值:", total - 2*s)
break为什么它很重要
- 为工作负载、内存或资源的平衡划分建模
- 是子集和(Subset Sum)与背包问题(Knapsack)的基础
- 引入了基于和的布尔型动态规划,是组合搜索的关键构建模块
分步示例
令 $A=[1,6,11,5]$。 总和 $=23$,目标值 $=11$。
可达的和: ${0,1,5,6,7,11,12,16,17,18,23}$
最佳 $s=11$,最小差值 $23-2\cdot11=1$。
划分:$[11]$ 和 $[1,5,6]$。
一个温和的证明(为什么它有效)
每个元素要么属于子集 $S_1$,要么属于 $S_2$。 设 $s_1$ 为 $S_1$ 的和,$s_2=total-s_1$。 我们希望最小化 $\vert s_1-s_2\vert $ → 即最小化 $\vert total-2s_1\vert $。 通过探索所有可达的和 $s_1\le total/2$,我们找到最接近一半总和的 $s_1$。 布尔型动态规划使用包含-排除的状态转移来跟踪可达性。
自己动手试试
- 计算平衡划分的数量(用整数型动态规划替换布尔型)。
- 添加约束“每个子集必须至少有 $k$ 个元素”。
- 扩展到多集合划分(3个或更多子集)。
- 将可达和可视化为布尔数组的转移过程。
- 与暴力枚举子集的方法进行比较。
测试用例
| A | 最小差值 | 划分 |
|---|---|---|
| [1,6,11,5] | 1 | [11] [1,5,6] |
| [3,1,4,2,2] | 0 | [3,2] [4,2] |
| [1,2,7] | 4 | [7] [1,2] |
| [2,2,2,2] | 0 | [2,2] [2,2] |
| [10,20,15,5] | 0 | [10,15] [20,5] |
复杂度
- 时间复杂度:$O(n\cdot sum)$
- 空间复杂度:$O(sum)$
划分动态规划是从子集和问题到平衡优化问题的一座温和桥梁,它展示了组合结构如何指导数值状态转移。
446 统计哈密顿回路(位掩码枚举)
哈密顿回路计数问题要求:给定一个图,存在多少条不同的哈密顿回路(恰好访问每个顶点一次的闭合环游)? 与最短路径变体不同,此版本侧重于使用位掩码动态规划来计数所有可能的回路。
我们正在解决什么问题?
给定一个图 $G=(V,E)$,其中 $\vert V\vert =n$,统计从顶点 $0$ 开始并结束,恰好访问每个顶点一次的不同哈密顿回路的数量。
我们定义:
- $dp[mask][i]$ = 从 $0$ 开始,访问 $mask$ 中的所有顶点,并在 $i$ 结束的哈密顿路径的数量
- 基础情况:$dp[1<<0][0]=1$
状态转移:
$$ dp[mask][i] = \sum_{j\in mask,, j\ne i,,(j,i)\in E} dp[mask\setminus{i}][j] $$
答案(回路数量):
$$ \sum_{i=1}^{n-1} dp[(1<<n)-1][i] \text{ 如果 } (i,0)\in E $$
每个有效的终点顶点 $i$ 必须连接回 $0$ 以完成回路。
它是如何工作的(通俗解释)
我们构建所有从顶点 $0$ 开始的可能路径,这些路径覆盖顶点的某个子集并在某个 $i$ 结束。 在每一步,我们通过添加一个新的终点 $i$ 来扩展较短的路径。 当所有顶点都被访问时($mask=(1<<n)-1$),我们检查哪些终点连接回 $0$。 将这些求和就得到哈密顿回路的总数。
这类似于 Held–Karp 动态规划,但操作是加法(计数)而不是最小化。
简洁代码(简易版本)
C (位掩码计数动态规划)
#include <stdio.h>
#include <string.h>
#define MAXN 20
long long dp[1<<MAXN][MAXN];
int adj[MAXN][MAXN];
int main(void) {
int n, m;
scanf("%d %d", &n, &m);
memset(adj, 0, sizeof(adj));
for (int e = 0; e < m; e++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u][v] = adj[v][u] = 1;
}
dp[1][0] = 1; // 从顶点 0 开始
for (int mask = 1; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if (!(mask & (1 << i))) continue;
for (int j = 0; j < n; j++) {
if (i == j || !(mask & (1 << j))) continue;
if (adj[j][i])
dp[mask][i] += dp[mask ^ (1 << i)][j];
}
}
}
long long total = 0;
int full = (1 << n) - 1;
for (int i = 1; i < n; i++) {
if (adj[i][0]) total += dp[full][i];
}
printf("%lld\n", total / 2); // 对于无向图除以 2
return 0;
}Python (紧凑版本)
n, m = map(int, input().split())
adj = [[0]*n for _ in range(n)]
for _ in range(m):
u, v = map(int, input().split())
adj[u][v] = adj[v][u] = 1
N = 1 << n
dp = [[0]*n for _ in range(N)]
dp[1][0] = 1
for mask in range(N):
for i in range(n):
if not (mask & (1<<i)): continue
for j in range(n):
if i==j or not (mask & (1<<j)): continue
if adj[j][i]:
dp[mask][i] += dp[mask ^ (1<<i)][j]
full = N-1
total = sum(dp[full][i] for i in range(1,n) if adj[i][0])
print(total // 2)为什么重要
- 演示了基于子集的动态规划计数
- 是计数哈密顿路径、回路覆盖和环游的基础
- 出现在图枚举、组合设计和旅行商计数中
逐步示例
假设图是一个正方形(4-环):
顶点:$0,1,2,3$
边:$(0,1),(1,2),(2,3),(3,0)$ 和 $(0,3),(3,2),(2,1),(1,0)$
所有哈密顿回路:
- $0\to1\to2\to3\to0$
- $0\to3\to2\to1\to0$
所以总数 = 2。
动态规划通过每次添加一个顶点来增量地构建这些路径。
一个温和的证明(为什么它有效)
每个动态规划状态 $(mask,i)$ 对应于访问 $mask$ 并在 $i$ 结束的唯一部分路径。 要形成在 $i$ 结束的路径,我们必须来自 $j\in mask\setminus{i}$ 且存在边 $(j,i)$。 这确保了每条路径被恰好计数一次。
在完整掩码下,我们拥有所有从 $0$ 开始并在 $i$ 结束的哈密顿路径;连接 $i\to0$ 就闭合了回路。
对于无向图除以 2,因为每个回路被计数两次(一次顺时针,一次逆时针)。
亲自尝试
- 对于有向图,移除除以 2 的操作。
- 统计哈密顿路径(无需返回边)。
- 修改代码,使用父节点数组跟踪路径序列。
- 对于较大的 $n$,与容斥原理计数进行比较。
- 实现记忆化递归(自顶向下版本)。
测试用例
| 图 | 预期结果 |
|---|---|
| 三角形 $(0-1-2-0)$ | 1 |
| 正方形 $(0-1-2-3-0)$ | 2 |
| 线 $(0-1-2)$ | 0 |
| 完全图 $K_4$ | $(4-1)!/2 = 3$ |
| $K_5$ | $(5-1)!/2 = 12$ |
复杂度
- 时间:$O(n^2 2^n)$
- 空间:$O(n 2^n)$
通过动态规划计数哈密顿回路,优雅地将子集枚举与路径计数相结合,为小型图提供了精确的组合计数。
447 Steiner Tree DP
Steiner Tree(斯坦纳树)问题要求在一个加权图中,找到一个最小代价的子图,该子图连接一组给定的终端节点。如果能够降低总代价,你可以使用额外的非终端顶点(Steiner 节点)。对于终端数量较少的情况,经典的精确动态规划(DP)算法是 Dreyfus–Wagner 子集 DP。
我们要解决什么问题?
输入:一个具有非负边权的无向连通图,以及一个终端集合 $T={t_1,\dots,t_k}$。
目标:找到一个连接 $T$ 中所有顶点的最小权重树。
我们使用一个基于终端子集和一个根顶点的 DP:
- 预计算所有顶点对之间的最短路径 $dist[u][v]$。
- DP 状态:$dp[S][v]=$ 连接子集 $S\subseteq T$ 中所有终端,并且树以顶点 $v$ 为根的最小代价。
初始化: $$ dp[{t_i}][v]=dist[v][t_i] $$
在同一根顶点处合并子集: $$ dp[S][v]=\min_{\emptyset\ne A\subset S}\big(dp[A][v]+dp[S\setminus A][v]\big) $$
然后允许根顶点通过最短路径移动: $$ dp[S][v]=\min_{u}\big(dp[S][u]+dist[u][v]\big) $$
答案: $$ \min_{v}dp[T][v] $$
在实践中,我们交替进行固定 $v$ 的子集合并和针对每个 $S$ 的多源最短路径松弛。
它是如何工作的(通俗解释)
想象一下,通过在一个汇合顶点 $v$ 处将针对较小终端子集的最优树“粘合”在一起来构建斯坦纳树。合并之后,你可以使用最短路径将这个汇合点滑动到图中的任何位置。对所有子集重复此过程,直到覆盖所有终端。
精简代码(简易版本,Python)
使用 Floyd–Warshall 计算 $dist$ 并基于 Dijkstra 对每个子集进行松弛的 Dreyfus–Wagner 算法。适用于较小的 $k$。
import heapq
INF = 1015
def floyd_warshall(n, w):
dist = [row[:] for row in w]
for i in range(n):
dist[i][i] = min(dist[i][i], 0)
for k in range(n):
for i in range(n):
dik = dist[i][k]
if dik == INF: continue
rowi = dist[i]
rowk = dist[k]
for j in range(n):
nd = dik + rowk[j]
if nd < rowi[j]: rowi[j] = nd
return dist
def steiner_tree(n, edges, terminals):
# 构建稠密权重矩阵
w = [[INF]*n for _ in range(n)]
for u in range(n):
w[u][u] = 0
for u, v, c in edges:
if c < w[u][v]:
w[u][v] = w[v][u] = c
dist = floyd_warshall(n, w)
k = len(terminals)
term_index = {t:i for i,t in enumerate(terminals)}
# dp[mask][v]
dp = [[INF]*n for _ in range(1<<k)]
for t in terminals:
m = 1 << term_index[t]
for v in range(n):
dp[m][v] = dist[v][t]
# 子集 DP
for mask in range(1, 1<<k):
# 在同一根顶点 v 处合并非空真子集 A
sub = (mask-1) & mask
while sub:
other = mask ^ sub
if other:
for v in range(n):
val = dp[sub][v] + dp[other][v]
if val < dp[mask][v]:
dp[mask][v] = val
sub = (sub-1) & mask
# 基于 Dijkstra 的根移动松弛,使用 dist 度量的完全图
# 这等价于:dp[mask] = 从源点出发、以 dp[mask] 为势能的度量闭包最短路径
# 使用初始代价 dp[mask][*] 运行一次 Dijkstra
pq = [(dp[mask][v], v) for v in range(n)]
heapq.heapify(pq)
seen = [False]*n
while pq:
d,v = heapq.heappop(pq)
if seen[v]: continue
seen[v] = True
if d > dp[mask][v]: continue
row = dist[v]
for u in range(n):
nd = d + row[u]
if nd < dp[mask][u]:
dp[mask][u] = nd
heapq.heappush(pq, (nd, u))
full = (1<<k) - 1
return min(dp[full])
# 使用示例:
# n = 5
# edges = [(0,1,1),(1,2,1),(2,3,1),(3,4,1),(0,4,10),(1,4,2)]
# terminals = [0,3,4]
# print(steiner_tree(n, edges, terminals))注意:
- 对于稀疏图,你可以跳过 Floyd–Warshall,在松弛步骤中使用原始邻接关系运行 Dijkstra。对于非负权重,使用上述度量闭包方法简单且正确。
为什么它很重要
- 当终端数量 $k$ 较小时,是斯坦纳树的精确算法
- 是 VLSI 布线、网络设计和系统发育学中的标准方法
- 教授了一个强大的模式:在根顶点处合并子集加上最短路径松弛
逐步示例
假设有一个 5 个顶点的路径 $0$–$1$–$2$–$3$–$4$,边权均为 1,终端 $T={0,3,4}$。
- 单元素集:$dp[{0}][v]=dist[v][0]$,等等。
- 在 $v=3$ 或 $v=4$ 处合并 ${3}$ 和 ${4}$,然后沿着路径松弛。
- 最后与 ${0}$ 合并;最优树是边 $(0,1),(1,2),(2,3),(3,4)$,总代价为 $4$。
一个温和的证明(为什么它有效)
设 $S\subseteq T$ 且 $v$ 是 $S$ 的最优斯坦纳树的一个汇合顶点。该树可以分解为两个子树,其终端集合划分 $S$,并且两个子树在 $v$ 处汇合。因此 $$ dp[S][v]\le dp[A][v]+dp[S\setminus A][v]. $$ 反之,由于度量闭包带来的三角不等式,在 $v$ 处的任何组合加上将 $v$ 重新定位到另一个顶点的最短路径,都不会比显式地用边连接更差。对 $\vert S\vert $ 进行归纳即可得出最优性。
动手尝试
- 将 Dijkstra 松弛替换为 Bellman–Ford,以允许零边和紧致的势能。
- 提取实际的斯坦纳树:在松弛过程中存储最佳分割和前驱顶点。
- 比较度量闭包方法与在原始稀疏图上进行松弛。
- 与终端上的最小生成树(MST)进行基准测试,以查看 Steiner 节点的优势。
- 添加约束,禁止某些顶点作为 Steiner 节点。
测试用例
| 图 | 终端 | 期望的斯坦纳树代价 |
|---|---|---|
| 边权为 1 的路径 0–1–2–3–4 | {0,3,4} | 4 |
| 边权为 1 的三角形 0–1–2 | {0,2} | 1 |
| 边权为 1 的正方形 0–1–2–3,对角线 1–3 代价为 1 | {0,2,3} | 2 |
| 星形图,中心 0 到 1..4 的边权均为 1 | {1,2,3,4} | 4 |
复杂度
- 使用度量闭包和子集合并:典型复杂度为 $O(3^k\cdot n + 2^k\cdot n\log n)$
- 内存:$O(2^k\cdot n)$
当 $k$ 较小时,Dreyfus–Wagner DP 是首选的精确方法:在根顶点处合并子集,然后通过最短路径进行松弛,从而让 Steiner 节点自动出现。
448 SOS DP(子集和动态规划)
SOS DP(子集和动态规划)是一种巧妙的位掩码动态规划技术,用于高效计算每个掩码所有子集上的聚合值,而无需显式枚举所有子集对。
我们要解决什么问题?
假设你有一个为所有长度为 n 的位掩码定义的数组 f[mask],并且你想计算:
$$ g[mask] = \sum_{sub \subseteq mask} f[sub] $$
朴素的方法是遍历每个掩码的所有子集,这需要 $O(3^n)$ 的时间。 使用 SOS DP,我们可以在 $O(n2^n)$ 的时间内计算出所有 $g[mask]$。
它是如何工作的(通俗解释)
将掩码中的每一位看作一个“维度”。 对于每个位位置 i,如果该位在掩码中被设置(为1),我们可以继承该位未设置(为0)的版本中的贡献。 我们通过迭代每个位维度并将较小的子集向上“折叠”来逐步构建和。
状态转移公式
令 dp[mask] 初始等于 f[mask]。 然后对于每个位 i 从 0 到 n-1:
如果掩码的第 i 位被设置:
dp[mask] += dp[mask ^ (1 << i)]处理完所有位后,dp[mask] 就保存了 mask 的所有子集的和。
示例
令 $n=3$,掩码从 000 到 111。
如果对所有掩码 $f[mask]=1$,那么:
| 掩码 | 子集 | g[mask] |
|---|---|---|
| 000 | {000} | 1 |
| 001 | {000,001} | 2 |
| 010 | {000,010} | 2 |
| 011 | {000,001,010,011} | 4 |
| 100 | {000,100} | 2 |
| 111 | 所有 8 个子集 | 8 |
DP 会逐位“折叠”这些结果。
简短的代码(简易版本)
C
#include <stdio.h>
#define MAXN (1<<20)
long long f[MAXN], dp[MAXN];
int main(void) {
int n;
scanf("%d", &n);
int N = 1 << n;
for (int mask = 0; mask < N; mask++) {
scanf("%lld", &f[mask]);
dp[mask] = f[mask];
}
for (int i = 0; i < n; i++) {
for (int mask = 0; mask < N; mask++) {
if (mask & (1 << i)) {
dp[mask] += dp[mask ^ (1 << i)];
}
}
}
for (int mask = 0; mask < N; mask++)
printf("%lld ", dp[mask]);
printf("\n");
}Python
n = int(input("n: "))
N = 1 << n
f = list(map(int, input().split()))
dp = f[:]
for i in range(n):
for mask in range(N):
if mask & (1 << i):
dp[mask] += dp[mask ^ (1 << i)]
print(dp)为什么它很重要
- 是位掩码卷积、子集变换、异或卷积和莫比乌斯反演中的核心技巧。
- 将 $O(3^n)$ 的子集循环减少到 $O(n2^n)$。
- 常用于子集和问题、概率 DP、状态计数和博弈论问题。
一个温和的证明(为什么它有效)
我们可以将每个掩码表示为一个 $n$ 位的二进制向量。 每个位维度 $i$ 会添加第 $i$ 位未设置的子集。 通过归纳法,在处理完位 $i$ 之后,每个掩码已经累积了所有仅在 $\le i$ 位上不同的子集的贡献。 最终,mask 的每个子集都被恰好包含了一次。
形式上,对于每个子集 $sub\subseteq mask$,都存在一个从 $sub$ 到 $mask$ 的位添加序列,确保了它的被包含。
自己动手试试
- 反转这个过程,改为计算超集和。
- 修改以计算子集上的乘积。
- 应用于计算满足奇偶性条件的子集数量。
- 在进行子集卷积之前,使用 SOS DP 预计算子集和。
- 结合容斥原理进行更快的组合计数。
测试用例
| n | f (输入) | 期望的 dp (输出) |
|---|---|---|
| 2 | 1 1 1 1 | 1 2 2 4 |
| 2 | 1 2 3 4 | 1 3 4 10 |
| 3 | 全为 1 | 1 2 2 4 2 4 4 8 |
复杂度
- 时间复杂度:$O(n2^n)$
- 空间复杂度:$O(2^n)$
SOS DP 是位掩码动态规划的基石,它揭示了子集间的结构,而无需显式枚举它们。
449 位掩码背包问题(状态压缩)
位掩码背包技术使用位掩码对物品子集进行编码,从而能够紧凑地表示选择、转移和约束。它是子集枚举与动态规划之间的桥梁,尤其适用于物品数量较少(例如 $n \le 20$)但价值/重量范围较大的情况。
我们要解决什么问题?
给定 $n$ 件物品,每件物品有重量 $w_i$ 和价值 $v_i$,以及容量 $W$,选择一个总重量 ≤ $W$ 的子集,以最大化总价值。
我们可以通过位掩码枚举子集,而不是用容量来索引 DP:
$$ best = \max_{\text{子集}} \sum_{i \in \text{子集}} v_i \quad \text{满足条件 } \sum_{i \in \text{子集}} w_i \le W $$
每个子集对应一个整数掩码,其中第 $i$ 位表示是否包含物品 $i$。
它是如何工作的(通俗解释)
位掩码是哪些物品被选取的快照。你预先计算每个子集的总重量和总价值。 然后简单地遍历所有掩码,根据容量进行筛选,并保留最佳值。
它是指数级的($2^n$),但当 $n$ 较小且重量很大时有效,此时基于重量的经典动态规划不可行。
转移公式
对于每个掩码:
- 计算 $$ total_w = \sum_{i:mask_i=1} w_i $$ $$ total_v = \sum_{i:mask_i=1} v_i $$
- 如果 $total_w \le W$,更新答案: $$ best = \max(best, total_v) $$
或者增量计算:
$$ dp[mask] = \sum_{i:mask_i=1} v_i $$ $$ weight[mask] = \sum_{i:mask_i=1} w_i $$
简短代码(简易版本)
C
#include <stdio.h>
#define MAXN 20
#define MAXMASK (1<<MAXN)
int main(void) {
int n, W;
scanf("%d %d", &n, &W);
int w[n], v[n];
for (int i = 0; i < n; i++) scanf("%d %d", &w[i], &v[i]);
int N = 1 << n;
int best = 0;
for (int mask = 0; mask < N; mask++) {
int total_w = 0, total_v = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
total_w += w[i];
total_v += v[i];
}
}
if (total_w <= W && total_v > best)
best = total_v;
}
printf("%d\n", best);
}Python
n, W = map(int, input().split())
w, v = [], []
for _ in range(n):
wi, vi = map(int, input().split())
w.append(wi)
v.append(vi)
best = 0
for mask in range(1 << n):
total_w = total_v = 0
for i in range(n):
if mask & (1 << i):
total_w += w[i]
total_v += v[i]
if total_w <= W:
best = max(best, total_v)
print(best)为什么它很重要
- 当 $n$ 较小(例如 $n \le 20$)但重量/价值很大时效果很好
- 非常适合中途相遇法和子集枚举
- 简化了关于组合、约束和转移的推理
- 出现在旅行商问题变体、集合包装和团队选择问题中
逐步示例
物品:
| i | w | v |
|---|---|---|
| 0 | 3 | 4 |
| 1 | 4 | 5 |
| 2 | 2 | 3 |
容量 $W=6$。
子集:
| 掩码 | 物品 | 重量 | 价值 | 是否可行 |
|---|---|---|---|---|
| 000 | {} | 0 | 0 | ✓ |
| 001 | {0} | 3 | 4 | ✓ |
| 010 | {1} | 4 | 5 | ✓ |
| 011 | {0,1} | 7 | 9 | ✗ |
| 100 | {2} | 2 | 3 | ✓ |
| 101 | {0,2} | 5 | 7 | ✓ |
| 110 | {1,2} | 6 | 8 | ✓ |
| 111 | {0,1,2} | 9 | 12 | ✗ |
最佳可行方案 = 掩码 110 → 价值 8。
一个温和的证明(为什么它有效)
每个子集都是物品的一个独特组合。枚举所有 $2^n$ 个子集保证了完整性。 通过总重量 ≤ $W$ 来检查可行性,确保无效子集不会产生影响。 在所有可行子集上取最大值,返回全局最优解。
没有重叠子问题,因此不需要记忆化。整个搜索空间是有限的并且被探索了。
自己动手试试
- 打印所有可行子集及其总价值。
- 结合位计数来限制子集大小。
- 使用中途相遇法:将物品分成两半,枚举每一半,然后合并结果。
- 扩展到多维容量 $(W_1,W_2,...)$。
- 调整为针对目标价值最小化重量。
测试用例
| 物品 | W | 预期结果 |
|---|---|---|
| (3,4),(4,5),(2,3) | 6 | 8 |
| (2,3),(3,4),(4,5) | 5 | 7 |
| (1,1),(2,2),(3,3) | 3 | 3 |
复杂度
- 时间:$O(n2^n)$
- 空间:$O(1)$(不需要 DP 表)
位掩码背包是一种带压缩的暴力动态规划,是小 $n$ 情况下的首选技术,在经典的基于容量索引的动态规划会爆炸时提供了完全的灵活性。
450 位掩码独立集(图子集优化)
位掩码独立集动态规划枚举图中所有顶点子集,以找到最大权独立集,即任意一对顶点之间都没有边的顶点集合。这是一种经典的指数级动态规划,适用于小图($n \le 20$),使用位运算来处理邻接关系和可行性检查。
我们要解决什么问题?
给定一个具有顶点权重 $w_i$ 的无向图 $G=(V,E)$,找到:
$$ \max \sum_{i \in S} w_i \quad \text{满足条件:} \forall (u,v) \in E,\ u,v \notin S $$
也就是说,选择一个顶点子集 $S$,其中没有相邻的顶点对,并最大化总权重。
我们用一个位掩码表示每个子集 $S$,其中位 $i=1$ 表示包含顶点 $i$。
它是如何工作的(通俗解释)
我们遍历所有 $2^n$ 个子集。 对于每个子集,我们通过确保没有边连接两个被选中的顶点来检查它是否构成一个独立集。 如果它是有效的,就计算其顶点权重之和并更新最佳值。
预先计算邻接掩码以便快速测试有效性。
状态转移 / 检查
对于每个掩码:
有效性
一个子集是独立的,当且仅当对于所有包含的顶点 $i$,它不包含任何邻居: $$ (,adj[i] \mathbin{&} mask,) = 0 $$ 其中 $adj[i]$ 是顶点 $i$ 的邻居位掩码。值 $$ value(mask) = \sum_{i:,mask_i=1} w_i $$
最佳值 $$ best = \max!\bigl(best,\ value(mask)\bigr) $$
简洁代码(简易版本)
C
#include <stdio.h>
#define MAXN 20
int main(void) {
int n, m;
scanf("%d %d", &n, &m);
int w[n];
for (int i = 0; i < n; i++) scanf("%d", &w[i]);
int adj[n];
for (int i = 0; i < n; i++) adj[i] = 0;
for (int e = 0; e < m; e++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u] |= 1 << v;
adj[v] |= 1 << u;
}
int N = 1 << n;
int best = 0;
for (int mask = 0; mask < N; mask++) {
int ok = 1, val = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
if (adj[i] & mask) { ok = 0; break; }
val += w[i];
}
}
if (ok && val > best) best = val;
}
printf("%d\n", best);
}Python
n, m = map(int, input().split())
w = list(map(int, input().split()))
adj = [0]*n
for _ in range(m):
u, v = map(int, input().split())
adj[u] |= 1 << v
adj[v] |= 1 << u
best = 0
for mask in range(1 << n):
ok = True
val = 0
for i in range(n):
if mask & (1 << i):
if adj[i] & mask:
ok = False
break
val += w[i]
if ok:
best = max(best, val)
print(best)为什么它很重要
- 解决小图上的最大独立集(MIS)问题
- 在约束问题、基于树宽的动态规划或位掩码搜索中用于精确求解
- 是图着色、最大团、支配集和子集优化问题的基础构建块
- 可适用于无权(计数)或有权(求和)版本
逐步示例
图:4 个顶点,边 $(0,1), (1,2), (2,3)$ 权重:$[3, 2, 4, 1]$
独立集:
| 掩码 | 集合 | 有效 | 值 |
|---|---|---|---|
| 0000 | ∅ | ✓ | 0 |
| 0001 | {0} | ✓ | 3 |
| 0010 | {1} | ✓ | 2 |
| 0100 | {2} | ✓ | 4 |
| 1000 | {3} | ✓ | 1 |
| 0101 | {0,2} | ✗ | , |
| 1001 | {0,3} | ✓ | 4 |
| 1100 | {2,3} | ✗ | , |
| 1010 | {1,3} | ✓ | 3 |
最佳值 = 4(对应 {2} 或 {0,3})
一个温和的证明(为什么它有效)
每个子集代表一个候选解。 一个子集是可行的,当且仅当它不包含相邻的顶点对,这通过邻接掩码检查来保证。 由于测试了所有子集,该算法通过枚举找到了全局最优解。
位掩码可行性检查 $(adj[i] \mathbin{&} mask) == 0$ 确保了对每个顶点进行常数时间的验证,从而保持了紧凑的复杂度。
亲自尝试
- 修改代码以计算所有独立集的数量。
- 将搜索限制在恰好大小为 $k$ 的子集。
- 添加记忆化以尽早剪枝无效掩码。
- 结合中途相遇法处理 $n=40$ 的情况。
- 翻转边以在补图中寻找最大团。
测试用例
| 图 | 权重 | 期望值 |
|---|---|---|
| 链 0–1–2–3 | [3,2,4,1] | 4 |
| 星形图,中心 0 | [5,1,1,1,1] | 5 |
| 三角形 | [1,2,3] | 3 |
| 空图 | [2,2,2] | 6 |
复杂度
- 时间:$O(n2^n)$
- 空间:$O(n)$
位掩码独立集动态规划系统地探索所有子集,当图规模小但权重或约束复杂时非常完美。
第 46 节 数位动态规划与 SOS 动态规划
451 统计满足属性的数字(数位动态规划)
数位动态规划是一种用于统计在给定范围内满足数位级约束的数字的方法,例如没有前导零、数位和、特定数字或禁止模式。该算法通过逐个处理数位,同时维护前缀约束和当前属性的状态来构建结果。
我们要解决什么问题?
给定一个整数 $N$ 和一个属性 $P$(例如“数位和为偶数”),统计在 $[0, N]$ 区间内有多少个整数满足 $P$。
示例:统计 $\le 327$ 的数位和为偶数的数字个数。
我们从最高有效位到最低有效位处理每个数位,维护:
- pos:当前数位索引
- sum:累积的属性(例如数位和模 2)
- tight:我们是否受到 $N$ 的前缀限制(0 = 自由,1 = 仍然受限)
- leading:我们是否只看到了前导零(可选)
状态: $$ dp[pos][sum][tight] $$
对下一个数字 d (0..limit) 进行状态转移:
- 更新
next_sum = (sum + d) % 2 - 如果
tight=1,limit = $N$ 在 pos 位置的数字,否则为 9 - 移动到下一个位置
答案是所有满足属性的有效结束状态的总和。
它是如何工作的(通俗解释)
数位动态规划的工作原理类似于有意识的计数: 在每一步,你选择下一个数字,更新你所知道的信息(例如当前和),同时遵守上界限制。 当你处理完所有数位时,你就已经统计了所有有效的数字,无需迭代到 $N$!
状态转移公式
令 $S$ 为 $N$ 的数字字符串。对于每个状态:
$$ dp[pos][sum][tight] = \sum_{d=0}^{limit} dp[pos+1][(sum+d)\bmod M][tight \land (d==limit)] $$
基础情况: $$ dp[len][sum][tight] = 1 \text{ 如果属性成立,否则 } 0 $$
示例:属性 = 数位和为偶数 → $sum%2=0$
精简代码(简易版本)
Python(统计 ≤ N 且数位和为偶数的数字个数)
from functools import lru_cache
def count_even_sum(n):
digits = list(map(int, str(n)))
m = len(digits)
@lru_cache(None)
def dp(pos, sum_mod2, tight):
if pos == m:
return 1 if sum_mod2 == 0 else 0
limit = digits[pos] if tight else 9
total = 0
for d in range(limit + 1):
total += dp(pos + 1, (sum_mod2 + d) % 2, tight and d == limit)
return total
return dp(0, 0, True)
N = int(input("输入 N: "))
print(count_even_sum(N))C(递归记忆化动态规划)
#include <stdio.h>
#include <string.h>
int digits[20];
long long memo[20][2][2];
int len;
long long dp(int pos, int sum_mod2, int tight) {
if (pos == len) return sum_mod2 == 0;
if (memo[pos][sum_mod2][tight] != -1) return memo[pos][sum_mod2][tight];
int limit = tight ? digits[pos] : 9;
long long res = 0;
for (int d = 0; d <= limit; d++) {
res += dp(pos + 1, (sum_mod2 + d) % 2, tight && (d == limit));
}
return memo[pos][sum_mod2][tight] = res;
}
long long solve(long long n) {
len = 0;
while (n > 0) {
digits[len++] = n % 10;
n /= 10;
}
for (int i = 0; i < len / 2; i++) {
int tmp = digits[i];
digits[i] = digits[len - 1 - i];
digits[len - 1 - i] = tmp;
}
memset(memo, -1, sizeof(memo));
return dp(0, 0, 1);
}
int main(void) {
long long n;
scanf("%lld", &n);
printf("%lld\n", solve(n));
}为什么它很重要
是基于数位的计数问题的基础
高效处理数位、和、模、奇偶性、禁止模式等约束
避免遍历大范围(在 $O(\text{len} \times \text{state})$ 内工作)
是统计具有以下属性数字的核心思想:
- 数位和为偶数
- 特定数字(例如没有 4)
- 数字递增/递减
- 模 M 余数条件
逐步示例
统计 ≤ 327 且数位和为偶数的数字个数。
我们追踪数位和模 2:
开始:pos=0, sum=0, tight=1
第一个数字:选择 0..3
- 如果选择 3 → sum=1, next tight=1
- 否则 → 自由 (tight=0)
继续直到最后一个数字
在结束时,统计 sum=0(偶数)的情况
结果:164 个数字。
一个温和的证明(为什么它有效)
在每个位置,tight 确保我们永远不会超过 N,并且对数字的递归分支确保了所有有效前缀的覆盖。 通过缓存相同的子问题(相同的 pos, sum, tight),我们避免了重复计算。 因此,总状态数 = $O(len \times property_space \times 2)$。
自己动手试试
- 统计 ≤ N 且数位和能被 3 整除的数字个数。
- 统计没有连续相等数字的数字个数。
- 统计最多有 k 个非零数字的数字个数。
- 统计数位乘积 < M 的数字个数。
- 通过
solve(R) - solve(L-1)来适应范围 [L, R]。
测试用例
| N | 属性 | 预期结果 |
|---|---|---|
| 9 | 数位和为偶数 | 5 |
| 20 | 数位和为偶数 | 10 |
| 327 | 数位和为偶数 | 164 |
复杂度
- 时间复杂度:$O(len \times M \times 2)$
- 空间复杂度:$O(len \times M \times 2)$
数位动态规划将组合计数转化为基于状态的逐位推理,是数论动态规划的基础技巧。
452 统计无相邻重复数字的数
这道数位 DP 问题用于统计某个范围内不包含相邻相同数字的数的数量,这是一个经典的例子,其状态必须记住前一个数字以强制执行相邻规则。
我们要解决什么问题?
给定一个整数 $N$,统计在区间 $[0, N]$ 内有多少个整数没有两个连续相等的数字。
例如,对于 $N = 1234$:
- 有效:1203(无重复)
- 无效:1224(两个 2 相邻)
我们将使用数位 DP 来探索所有不超过 $N$ 的数字序列,确保没有相邻重复。
DP 状态
设 $N$ 的数字字符串为 S,长度为 len。
状态: $$ dp[pos][prev][tight][leading] $$
其中:
pos:当前位置索引(从 0 开始)prev:前一个数字(0–9,如果还没有则为 10)tight:前缀是否与 $N$ 到目前为止相等(1 = 受限制,0 = 自由)leading:是否只看到了前导零(1 = 是)
每个状态统计从位置 pos 开始往后的有效完成数。
状态转移
在位置 pos:
- 选择下一个数字
d,范围从 0 到limit(其中limit = S[pos]如果tight = 1,否则为 9) - 如果
d == prev且不是leading状态,则跳过(不允许相邻重复) - 下一个状态:
next_prev = d如果不是leading状态,否则为 10next_tight = tight and (d == limit)next_leading = leading and (d == 0)
对所有有效转移求和。
基础情况: 当 pos == len(数字结束)时, $$ dp[len][prev][tight][leading] = 1 $$
工作原理(通俗解释)
我们逐位构建数字:
tight确保我们在界限内。prev记住最后选择的数字,以防止重复。leading帮助跳过前导零(前导零不计为重复)。
通过缓存所有组合,我们精确地统计每个有效数字一次。
精简代码(简易版本)
Python
from functools import lru_cache
def count_no_adjacent(N):
digits = list(map(int, str(N)))
m = len(digits)
@lru_cache(None)
def dp(pos, prev, tight, leading):
if pos == m:
return 1 # 有效的数字
limit = digits[pos] if tight else 9
total = 0
for d in range(limit + 1):
if not leading and d == prev:
continue # 不允许相邻重复
total += dp(
pos + 1,
d if not leading else 10,
tight and d == limit,
leading and d == 0
)
return total
return dp(0, 10, True, True)
N = int(input("输入 N: "))
print(count_no_adjacent(N))C
#include <stdio.h>
#include <string.h>
int digits[20];
long long memo[20][11][2][2];
int len;
long long dp(int pos, int prev, int tight, int leading) {
if (pos == len) return 1;
if (memo[pos][prev][tight][leading] != -1) return memo[pos][prev][tight][leading];
int limit = tight ? digits[pos] : 9;
long long res = 0;
for (int d = 0; d <= limit; d++) {
if (!leading && d == prev) continue;
int next_prev = leading && d == 0 ? 10 : d;
int next_tight = tight && (d == limit);
int next_leading = leading && (d == 0);
res += dp(pos + 1, next_prev, next_tight, next_leading);
}
return memo[pos][prev][tight][leading] = res;
}
long long solve(long long n) {
len = 0;
while (n > 0) {
digits[len++] = n % 10;
n /= 10;
}
for (int i = 0; i < len / 2; i++) {
int tmp = digits[i];
digits[i] = digits[len - 1 - i];
digits[len - 1 - i] = tmp;
}
memset(memo, -1, sizeof(memo));
return dp(0, 10, 1, 1);
}
int main(void) {
long long n;
scanf("%lld", &n);
printf("%lld\n", solve(n));
}为什么重要?
- 经典的带有“前一个数字”状态的数位 DP
- 能够实现如下约束:
- 无相邻重复
- 无递增/递减序列
- 无禁止的数字对
- 在模式计数、PIN 码生成、车牌验证中很有用
逐步示例
统计 ≤ 120 且无相邻重复数字的数的数量:
- 开始时允许前导零
- 例如
101✓,110✗ - DP 检查每一位数字:
1?0(pos=0, prev=10)- 对于下一位数字:跳过与前一位相等的数字
- 有效总数 = 91
一个温和的证明(为什么它有效)
对于每个位置,状态转移确保:
- 如果
tight=1,则只选择 ≤ 界限的数字 - 通过
d != prev避免连续重复 - 前导零被特殊处理(在重复检查中被忽略)
通过遍历所有有效数字并记忆化结果,每个子问题(前缀约束 + 前一个数字)只被解决一次,确保了完整性和正确性。
亲自尝试
- 统计 ≤ N 且无相等相邻数字且数字和为偶数的数的数量。
- 统计 ≤ N 且数字严格递增的数的数量。
- 修改为仅禁止相邻的 0。
- 与模约束(数字和模 M)结合。
- 扩展以处理恰好 k 个相等数字对的情况。
测试用例
| N | 期望值 | 说明 |
|---|---|---|
| 9 | 10 | 0–9 全部有效 |
| 11 | 10 | 10 无效 |
| 100 | 91 | 只有 9 个无效 |
| 1234 | 820 | 近似结果 |
复杂度
- 时间复杂度:$O(len \times 11 \times 2 \times 2 \times 10)$
- 空间复杂度:$O(len \times 11 \times 2 \times 2)$
数位 DP 通过记忆前一个数字,优雅地强制执行了局部数字约束(如相邻性),从而能够在大范围内进行快速计数。
453 区间数字和
这道数位 DP(动态规划)问题计算给定范围 $[0, N]$ 内所有数字的各位数字之和。 我们不是枚举数字,而是逐位累加数字的贡献,同时考虑上界限制。
我们要解决什么问题?
给定一个数字 $N$,计算:
$$ S(N) = \sum_{x=0}^{N} \text{sum_of_digits}(x) $$
例如:
- $S(13) = 1+0 + 1+1 + 1+2 + 1+3 = 55$
目标是以 $O(\text{len} \times 2 \times M)$ 的效率计算 $S(N)$,而不是 $O(N)$。
你也可以处理区间: $$ S(L,R) = S(R) - S(L-1) $$
DP 状态
令 $S$ = $N$ 的数字列表。
我们定义一个递归函数: $$ dp[pos][tight][sum] $$
但不仅仅是计数数字,我们还累加总的数字和贡献。
所以函数返回一个对 (count, total_sum):
count:有效数字的数量sum:所有有效数字的数字和
状态:
pos:当前位置 (0..len-1)tight:是否仍受前缀限制leading:是否到目前为止只有前导零
状态转移
在每个位置,选择数字 d,范围在 [0, limit] 内 (如果 tight = 1,则 limit = 当前位置的数字,否则为 9)
令 (cnt_next, sum_next) = 从下一个位置得到的结果。
我们加上当前数字的贡献: $$ total_sum += sum_next + d \times cnt_next $$
如果 leading 为真且 d=0,那么我们不计入该数字作为“真正的”前导数字。
基本情况: $$ dp[len][tight][leading] = (1, 0) $$
(一个有效数字,和为 0)
工作原理(通俗解释)
每个递归调用计算从当前前缀开始可能有多少个数字,以及它们产生多少总数字和。
当你选择一个数字 d 时:
d对该分支中的所有数字贡献d * cnt_next- 再加上其余数字递归产生的贡献
通过缓存每个状态的 (count, sum),我们可以重用重复前缀的计算。
精简代码(简易版本)
Python
from functools import lru_cache
def sum_of_digits_upto(N):
digits = list(map(int, str(N)))
m = len(digits)
@lru_cache(None)
def dp(pos, tight, leading):
if pos == m:
return (1, 0) # (count, sum)
limit = digits[pos] if tight else 9
total_count, total_sum = 0, 0
for d in range(limit + 1):
cnt_next, sum_next = dp(
pos + 1,
tight and (d == limit),
leading and d == 0
)
total_count += cnt_next
total_sum += sum_next + (0 if leading and d == 0 else d * cnt_next)
return (total_count, total_sum)
return dp(0, True, True)[1]
def sum_of_digits_range(L, R):
return sum_of_digits_upto(R) - sum_of_digits_upto(L - 1)
# 示例
L, R = map(int, input("输入 L R: ").split())
print(sum_of_digits_range(L, R))C (通过结构体返回递归对)
#include <stdio.h>
#include <string.h>
typedef struct { long long count, sum; } Pair;
int digits[20];
Pair memo[20][2][2];
int vis[20][2][2];
int len;
Pair dp(int pos, int tight, int leading) {
if (pos == len) return (Pair){1, 0};
if (vis[pos][tight][leading]) return memo[pos][tight][leading];
vis[pos][tight][leading] = 1;
int limit = tight ? digits[pos] : 9;
long long total_count = 0, total_sum = 0;
for (int d = 0; d <= limit; d++) {
Pair next = dp(pos + 1, tight && (d == limit), leading && (d == 0));
total_count += next.count;
total_sum += next.sum + (leading && d == 0 ? 0 : (long long)d * next.count);
}
return memo[pos][tight][leading] = (Pair){total_count, total_sum};
}
long long solve(long long n) {
if (n < 0) return 0;
len = 0;
while (n > 0) {
digits[len++] = n % 10;
n /= 10;
}
for (int i = 0; i < len / 2; i++) {
int tmp = digits[i];
digits[i] = digits[len - 1 - i];
digits[len - 1 - i] = tmp;
}
memset(vis, 0, sizeof(vis));
return dp(0, 1, 1).sum;
}
int main(void) {
long long L, R;
scanf("%lld %lld", &L, &R);
printf("%lld\n", solve(R) - solve(L - 1));
}为什么重要
在对数时间内计算巨大区间上的数字和
是许多数字聚合问题的基础(如 1 的个数、数字和模 M 等)
可扩展至:
- 计数偶数/奇数位数字
- 加权数字和(如 $d \times 10^{pos}$)
- 基于属性的聚合(例如,平方和)
逐步示例
计算所有 ≤ 13 的数字的数字和:
| 数字 | 和 |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 6 |
| 7 | 7 |
| 8 | 8 |
| 9 | 9 |
| 10 | 1 |
| 11 | 2 |
| 12 | 3 |
| 13 | 4 |
总计 = 55 ✅
DP 通过数字位构建此结果:
- 十位数字 → 重复 10 次
- 个位数字 → 重复贡献 0–9
一个温和的证明(为什么有效)
每个位置贡献其数字值乘以剩余位置的组合数。 数位 DP 递归地捕获了这一点: 如果一个数字 d 固定在位置 pos,那么后面位置的每个完成都包含该数字一次,因此贡献为 d * count_of_suffix。对所有位置的所有数字求和就得到总数字和。
亲自尝试
- 仅计算偶数位数字的和。
- 计算数字和模 M。
- 计算数字的平方和。
- 计算区间内书写的数字总数。
- 计算加权和(如
d * 10^pos贡献)。
测试用例
| 区间 | 期望值 |
|---|---|
| 0–9 | 45 |
| 0–13 | 55 |
| 10–99 | 855 |
| 1–1000 | 13501 |
复杂度
- 时间:$O(\text{len} \times 2 \times 2 \times 10)$
- 空间:$O(\text{len} \times 2 \times 2)$
数位 DP 可以在大规模区间上聚合数字级别的属性,这个求和版本是其经典的“计数 + 累加”模板。
454 带模条件的计数(数位和模 M)
统计在某个范围内,其数位和满足模条件的数字个数。标准模式是在处理上界的同时,追踪数位和模 $M$ 的值。
我们要解决什么问题?
给定整数 $N, M, K$,统计有多少个 $x \in [0, N]$ 满足: $$ \big(\text{sum_digits}(x)\big) \bmod M = K $$ 对于一般范围 $[L, R]$,使用 $f(R) - f(L-1)$。
DP 状态
设 $N$ 的十进制字符串为 $S$,长度为 $m$。
状态: $$ dp[pos][mod][tight][leading] $$
- $pos$:在 $S$ 中的索引(从 0 开始,从左到右)
- $mod$:当前数位和模 $M$ 的值
- $tight \in {0, 1}$:是否仍等于 $S$ 的前缀(1 表示是,0 表示已小于)
- $leading \in {0, 1}$:是否仍在放置前导零(1 表示是)
目标:统计最终 $mod = K$ 的完成方案数。
基础情况: $$ dp[m][mod][tight][leading] = \begin{cases} 1, & mod = K,\ 0, & \text{否则。} \end{cases} $$
状态转移(选择下一个数字 $d$):
- $limit = S[pos]$ 如果 $tight = 1$,否则 $limit = 9$
- 下一个状态:
- $next_tight = tight \land (d = limit)$
- $next_leading = leading \land (d = 0)$
$$ next_mod = \begin{cases} mod, & \text{如果 } next_leading = 1,\ (mod + d) \bmod M, & \text{否则。} \end{cases} $$
那么 $$ dp[pos][mod][tight][leading] = \sum_{d=0}^{limit} dp[pos+1][next_mod][next_tight][next_leading]. $$
精简代码(简易版本)
Python
from functools import lru_cache
def count_mod_sum_upto(N, M, K):
S = list(map(int, str(N)))
m = len(S)
@lru_cache(None)
def dp(pos, mod, tight, leading):
if pos == m:
return 1 if mod == K else 0
limit = S[pos] if tight else 9
total = 0
for d in range(limit + 1):
ntight = tight and (d == limit)
nleading = leading and (d == 0)
nmod = mod if nleading else (mod + d) % M
total += dp(pos + 1, nmod, ntight, nleading)
return total
return dp(0, 0, True, True)
def count_mod_sum_range(L, R, M, K):
if L <= 0:
return count_mod_sum_upto(R, M, K)
return count_mod_sum_upto(R, M, K) - count_mod_sum_upto(L - 1, M, K)
# 示例:
# print(count_mod_sum_range(0, 327, 7, 3))C
#include <stdio.h>
#include <string.h>
long long memo[20][200][2][2];
char vis[20][200][2][2];
int digits[20], mlen, M, K;
long long solve_dp(int pos, int mod, int tight, int leading) {
if (pos == mlen) return mod == K;
if (vis[pos][mod][tight][leading]) return memo[pos][mod][tight][leading];
vis[pos][mod][tight][leading] = 1;
int limit = tight ? digits[pos] : 9;
long long total = 0;
for (int d = 0; d <= limit; d++) {
int ntight = tight && (d == limit);
int nleading = leading && (d == 0);
int nmod = nleading ? mod : (mod + d) % M;
total += solve_dp(pos + 1, nmod, ntight, nleading);
}
return memo[pos][mod][tight][leading] = total;
}
long long count_upto(long long N, int m, int k) {
if (N < 0) return 0;
M = m; K = k;
int tmp[20], len = 0;
while (N > 0) { tmp[len++] = (int)(N % 10); N /= 10; }
if (len == 0) tmp[len++] = 0;
for (int i = 0; i < len; i++) digits[i] = tmp[len - 1 - i];
mlen = len;
memset(vis, 0, sizeof(vis));
return solve_dp(0, 0, 1, 1);
}
long long count_range(long long L, long long R, int m, int k) {
return count_upto(R, m, k) - count_upto(L - 1, m, k);
}
// 在 main 函数中使用的示例:
// int main(){ long long L=0,R=327; int M=7,K=3; printf("%lld\n", count_range(L,R,M,K)); }注意:
- 数组
mod的边界200假设了 $M \le 200$。如果需要,可以增加。
为什么这很重要
- 这是处理数论风格约束的核心数位 DP
- 通过将携带的统计量改为模形式,可以处理许多变体
- 是解决以下问题的构建模块:
- 统计数位和等于 $S$ 的数(设置足够大的 $M$ 并以 $K=S$ 为目标,注意状态处理)
- 统计数位和在某个集合中的数(对多个 $K$ 求和)
- 多条件状态(例如,数位和模 $M$ 和奇偶性)
逐步示例
统计 $x \in [0, 99]$ 中,数位和模 $3$ 等于 $0$ 的个数。
- DP 携带 $mod \in {0, 1, 2}$。
- 在每个位置,根据 $d \in [0..9]$ 进行分支,
tight状态持续到超过上界 99。 - 答案是 $34$。
(这个小例子也可以通过组合数学验证:大约三分之一的带前导零的两位数。)
一个温和的证明(为什么它有效)
每个数字都对应一条数字选择路径。 状态 $(pos, mod, tight, leading)$ 唯一地捕获了所有影响未来可行性和最终条件 $mod=K$ 的信息。 由于状态转移仅依赖于当前状态和选择的数字,记忆化这些状态就产生了搜索空间的一个完整且无重叠的划分。
自己动手试试
- 通过将 $K \in S$ 的答案相加,来统计数位和模 $M$ 在集合 $S$ 中的数字个数。
- 通过将 $mod$ 替换为有界和状态并限制在 $S$,来统计数位和恰好为 $S$ 的数字个数。
- 通过向状态添加一个
prev(前一个数字)来结合"无相邻重复数字"的条件。 - 计算数字的各位数字平方和模 $M$ 等于 $K$ 的数字个数。
- 通过将限制从 9 改为 $B-1$,扩展到 $B$ 进制。
测试用例
| $N$ | $M$ | $K$ | 预期思路 |
|---|---|---|---|
| 9 | 3 | 0 | 4 个数字 (0, 3, 6, 9) |
| 20 | 2 | 0 | 大约 0..20 的一半 |
| 99 | 3 | 0 | 34 |
| 327 | 7 | 3 | 通过代码计算 |
复杂度
- 时间复杂度:$O(len \cdot M \cdot 2 \cdot 2 \cdot 10)$
- 空间复杂度:$O(len \cdot M \cdot 2 \cdot 2)$
这是标准的携带模信息的数位 DP:通过数字以模 $M$ 的形式传递属性,用 tight 尊重边界,并清晰地处理前导零。
455 递增数字的计数
我们想要统计一个范围内所有数字严格递增的整数,即每一位数字都比前一位大。例如,123、149 和 7 符合条件,但 133、321 或 224 不符合。
我们要解决什么问题?
给定一个上界 $N$,统计区间 $[0, N]$ 中满足以下条件的整数 $x$ 的数量:
$$ \text{digits}(x) = [d_0, d_1, \dots, d_k] \implies d_0 < d_1 < \dots < d_k $$
示例:对于 $N=500$,有效的数字包括 1, 2, …, 9, 12, 13, …, 49, 123, 134, …, 489 等。
我们可以使用数位 DP 来建模,跟踪前一个数字以确保递增条件。
DP 状态
令 $S$ = $N$ 的数字列表。
状态: $$ dp[pos][prev][tight][leading] $$
其中:
pos:当前数字的索引(从 0 开始)prev:最后选择的数字(0–9,或 10 表示“尚未选择”)tight:我们是否仍然与前缀 $N$ 相等leading:我们是否只放置了前导零(到目前为止没有真正的数字)
状态转移
在每个位置:
确定
limit = S[pos]如果tight=1,否则为 9。循环
d从 0 到limit。如果
leading为假,则跳过d <= prev(必须严格递增)。更新:
next_tight = tight and (d == limit)next_leading = leading and (d == 0)next_prev = prev if next_leading else d
对递归调用的结果求和。
基础情况: $$ dp[len][prev][tight][leading] = 1 $$ 因为一个有效的数字已经形成。
工作原理(通俗解释)
我们一次构建数字的一位:
- 如果我们已经开始(非前导),每个新数字必须大于前一个数字。
- 如果我们仍然处于前导状态,任何零都可以。
tight确保我们永远不会超过 $N$ 的前缀。 通过在这些规则下探索所有可能的数字,我们统计了所有 ≤ $N$ 的严格递增数字。
简洁代码(简单版本)
Python
from functools import lru_cache
def count_increasing_digits(N):
S = list(map(int, str(N)))
m = len(S)
@lru_cache(None)
def dp(pos, prev, tight, leading):
if pos == m:
return 1 # 到达末尾,有效数字
limit = S[pos] if tight else 9
total = 0
for d in range(limit + 1):
if not leading and d <= prev:
continue # 必须严格递增
ntight = tight and (d == limit)
nleading = leading and (d == 0)
nprev = prev if nleading else d
total += dp(pos + 1, nprev, ntight, nleading)
return total
return dp(0, 10, True, True)
# 示例
N = int(input("输入 N: "))
print(count_increasing_digits(N))C
#include <stdio.h>
#include <string.h>
long long memo[20][11][2][2];
char vis[20][11][2][2];
int digits[20], len;
long long dp(int pos, int prev, int tight, int leading) {
if (pos == len) return 1;
if (vis[pos][prev][tight][leading]) return memo[pos][prev][tight][leading];
vis[pos][prev][tight][leading] = 1;
int limit = tight ? digits[pos] : 9;
long long res = 0;
for (int d = 0; d <= limit; d++) {
if (!leading && d <= prev) continue;
int ntight = tight && (d == limit);
int nleading = leading && (d == 0);
int nprev = nleading ? prev : d;
res += dp(pos + 1, nprev, ntight, nleading);
}
return memo[pos][prev][tight][leading] = res;
}
long long solve(long long n) {
if (n < 0) return 0;
len = 0;
while (n > 0) {
digits[len++] = n % 10;
n /= 10;
}
if (len == 0) digits[len++] = 0;
for (int i = 0; i < len / 2; i++) {
int t = digits[i];
digits[i] = digits[len - 1 - i];
digits[len - 1 - i] = t;
}
memset(vis, 0, sizeof(vis));
return dp(0, 10, 1, 1);
}
int main(void) {
long long N;
scanf("%lld", &N);
printf("%lld\n", solve(N));
}为什么这很重要
建立对单调数字约束的理解
用于计数的模板:
- 严格递增数字
- 严格递减数字
- 非递减数字(只需将条件改为
d < prev)
出现在组合枚举和数字排序问题中
逐步示例
对于 $N=130$:
有效数字包括:
0–9
12, 13
23
...
123无效示例:
11(数字相等)21(递减)
DP 根据 d > prev 规则自动过滤这些情况。
一个温和的证明(为什么它有效)
每条路径对应一个唯一的 ≤ $N$ 的数字。 规则 d > prev 强制了严格递增的顺序。 DP 确保没有重复计数,因为每个前缀 (pos, prev, tight, leading) 完全决定了未来的选择。
自己动手试试
- 修改以统计严格递减的数字(
d < prev)。 - 统计非递减数字(
d >= prev)。 - 通过
len_used参数强制精确长度为 $k$。 - 添加模条件(和模 M)。
- 计算所有递增数字的和而不是数量。
测试用例
| N | 预期结果 | 说明 |
|---|---|---|
| 9 | 10 | 0–9 有效 |
| 12 | 12 | 10 个数字 0–9,加上 12 和 13 |
| 99 | 45 | 所有 1-2 位递增数字 |
| 321 | 84 | 由 DP 推导得出 |
复杂度
- 时间:$O(len \times 11 \times 2 \times 2 \times 10)$
- 空间:$O(len \times 11 \times 2 \times 2)$
具有单调数字约束的数位 DP 将排序问题转化为状态空间计数,这是组合数字分析的一项基本技术。
456 禁止数字计数
计算在一个区间内有多少个整数不包含给定的禁止数字集合。这是一个基本的数位 DP(数位动态规划),其状态记录了当前是否仍然紧贴上界,以及是否只放置了前导零。
我们要解决什么问题?
给定 $N$ 和一个禁止数字集合 $F\subseteq{0,1,\dots,9}$,统计区间 $x\in[0,N]$ 内那些标准十进制表示中不包含 $F$ 中任何数字的整数 $x$ 的数量。
约定:在 DP 过程中允许前导零,但它们不计为真正的数字,因此即使 $0\in F$,前导零也总是被允许的。
对于一个通用区间 $[L,R]$,返回 $f(R)-f(L-1)$。
DP 状态
令 $S$ 为 $N$ 的数字列表,长度为 $m$。
状态: $$ dp[pos][tight][leading] $$
- $pos$:索引,范围在 $[0,m)$
- $tight\in{0,1}$:如果前缀到目前为止等于 $N$,则为 1
- $leading\in{0,1}$:如果所有已选择的数字都是前导零,则为 1
状态转移
在位置 $pos$ 选择数字 $d\in[0,\text{limit}]$,其中如果 $tight=1$ 则 $\text{limit}=S[pos]$,否则为 $9$。
如果 $d$ 是一个被禁止的真实数字,则拒绝:
- 如果 $leading=1$ 且 $d=0$,则无论 $F$ 如何都接受。
- 否则要求 $d\notin F$。
下一个状态:
- $next_tight = tight\land(d=\text{limit})$
- $next_leading = leading\land(d=0)$
递推关系: $$ dp[pos][tight][leading] = \sum_{d=0}^{\text{limit}} \mathbf{1}\big(\text{allowed}(d,leading)\big)\cdot dp[pos+1][next_tight][next_leading] $$
基础情况: $$ dp[m][tight][leading]=1 $$
答案是 $dp[0][1][1]$。
它是如何工作的(通俗解释)
我们从左到右构建数字。如果我们还没有超过 $N$,那么下一个数字会受到 $N$ 在该位置数字的限制。前导零是虚拟填充,不会触发禁止检查。DP 统计从每个前缀开始的所有有效完成情况。
精简代码(简易版本)
Python
from functools import lru_cache
def count_without_forbidden(N, forbidden):
S = list(map(int, str(N)))
m = len(S)
F = set(forbidden)
@lru_cache(None)
def dp(pos, tight, leading):
if pos == m:
return 1
limit = S[pos] if tight else 9
total = 0
for d in range(limit + 1):
ntight = tight and (d == limit)
nleading = leading and (d == 0)
# 允许前导零,无论 F 如何
if not nleading and d in F:
continue
total += dp(pos + 1, ntight, nleading)
return total
return dp(0, True, True)
def count_range_without_forbidden(L, R, forbidden):
if L <= 0:
return count_without_forbidden(R, forbidden)
return count_without_forbidden(R, forbidden) - count_without_forbidden(L - 1, forbidden)
# 示例:
# print(count_range_without_forbidden(0, 327, {3,4}))C
#include <stdio.h>
#include <string.h>
long long memo[20][2][2];
char vis[20][2][2];
int digits[20], len;
int forbid[10];
long long dp(int pos, int tight, int leading) {
if (pos == len) return 1;
if (vis[pos][tight][leading]) return memo[pos][tight][leading];
vis[pos][tight][leading] = 1;
int limit = tight ? digits[pos] : 9;
long long total = 0;
for (int d = 0; d <= limit; d++) {
int ntight = tight && (d == limit);
int nleading = leading && (d == 0);
if (!nleading && forbid[d]) continue; // 真实数字不能是被禁止的
total += dp(pos + 1, ntight, nleading);
}
return memo[pos][tight][leading] = total;
}
long long solve_upto(long long N) {
if (N < 0) return 0;
len = 0;
if (N == 0) digits[len++] = 0;
while (N > 0) { digits[len++] = (int)(N % 10); N /= 10; }
for (int i = 0; i < len/2; i++) {
int t = digits[i]; digits[i] = digits[len-1-i]; digits[len-1-i] = t;
}
memset(vis, 0, sizeof(vis));
return dp(0, 1, 1);
}
// 示例 main 函数
// int main(void){
// long long L,R; int k,x;
// scanf("%lld %lld %d",&L,&R,&k);
// memset(forbid,0,sizeof(forbid));
// for(int i=0;i<k;i++){ scanf("%d",&x); forbid[x]=1; }
// long long ans = solve_upto(R) - solve_upto(L-1);
// printf("%lld\n", ans);
// }为什么它重要
- 这是通过局部约束过滤数字的经典数位 DP
- 可以建模具有数字黑名单、键盘规则、车牌格式或数制限制的问题
- 作为基础,可以与数字和或相邻约束等附加状态结合
逐步示例
令 $F={3,4}$ 且 $N=327$。
- 在每个位置,数字 ${3,4}$ 是不允许的,除非我们仍处于前导零状态。
- DP 探索所有以 $327$ 为界的数字前缀,并累加有效的完成情况。
- 使用 Python 代码片段计算确切的数量。
一个温和的证明(为什么它有效)
$[0,N]$ 中的每个数字都对应一条唯一的数字选择路径。谓词 allowed$(d,leading)$ 确保一旦放置了真实数字,它就不是被禁止的。对 $(pos,tight)$ 确保我们不会超过 $N$。由于子问题仅依赖于这三个参数,记忆化会精确地统计每个前缀等价类一次。
亲自尝试
- 禁止多个数字,例如 $F={1,3,7}$。
- 禁止一个包含 $0$ 的集合,并验证前导零是否仍然通过。
- 通过添加一个 $mod$ 状态,与取模条件结合。
- 通过添加一个 $prev$ 状态,与禁止相邻重复数字的条件结合。
- 通过将限制从 $9$ 改为 $B-1$,切换到 $B$ 进制。
测试用例
| $N$ | $F$ | 预期思路 |
|---|---|---|
| 99 | ${9}$ | 使用数字 0..8 的带前导零的两位数数量 |
| 327 | ${3,4}$ | 由代码计算 |
| 1000 | ${1}$ | 所有不包含数字 1 的数字 |
| 0 | 任意 $F$ | 1(数字 0) |
复杂度
- 时间复杂度:$O(len\cdot 2\cdot 2\cdot 10)$
- 空间复杂度:$O(len\cdot 2\cdot 2)$
这种模式是最简单的数位 DP 守卫:在正确处理边界和前导零的同时,根据黑名单筛选数字。
457 SOS DP 子集和
子集和(SOS)DP 是一种强大的位掩码技术,用于预处理给定掩码所有子集上的值。其核心应用之一是处理位掩码上的子集和问题,即高效地为所有 $S$ 计算 $f(S) = \sum_{T \subseteq S} g(T)$。
我们要解决什么问题?
给定一个大小为 $2^n$ 的数组 g,其索引为位掩码,计算一个新数组 f,使得:
$$ f[S] = \sum_{T \subseteq S} g[T] $$
一种朴素的方法是为每个 $S$ 遍历其所有子集,时间复杂度为 $O(3^n)$。SOS DP 将其降低到 $O(n \cdot 2^n)$,使得在 $n \le 20$ 时变得可行。
它是如何工作的(通俗解释)
我们将每个比特位视为一个维度。对于每个比特位 $i$(从 0 到 $n-1$):
对于每个掩码 $S$:
- 如果 $S$ 的第 $i$ 位被置位,则将 $S$ 在第 $i$ 位清零后的掩码的贡献值累加进来。
这样,每次处理一个比特位,逐步累积所有子集的和。
递推关系
令 f 初始等于 g。然后:
$$ \begin{aligned} &\text{for } i = 0,\dots,n-1:\ &\quad \text{for } S = 0,\dots,\ \texttt{(1<<n)}-1:\ &\quad\quad \text{if } \bigl(S \mathbin{&} \texttt{(1<<i)}\bigr) \ne 0:\quad f[S] \mathrel{+}= f!\left[S^{\text{without } i}\right] \end{aligned} $$
其中 $S^{\text{without } i} = S \oplus \texttt{(1<<i)}$ 表示移除第 $i$ 位后的掩码。
处理完所有比特位后,f[S] 就保存了 $S$ 的所有子集的和。
精简代码(简易版本)
Python
def sos_subset_sum(g, n):
f = g[:] # 复制
for i in range(n):
for S in range(1 << n):
if S & (1 << i):
f[S] += f[S ^ (1 << i)]
return f
# 示例
n = 3
g = [1,2,3,4,5,6,7,8] # g[mask]
f = sos_subset_sum(g, n)
print(f)C
#include <stdio.h>
void sos_subset_sum(long long f[], int n) {
for (int i = 0; i < n; i++) {
for (int S = 0; S < (1 << n); S++) {
if (S & (1 << i)) {
f[S] += f[S ^ (1 << i)];
}
}
}
}
int main() {
int n = 3;
long long f[1 << 3] = {1,2,3,4,5,6,7,8};
sos_subset_sum(f, n);
for (int i = 0; i < (1 << n); i++) printf("%lld ", f[i]);
return 0;
}为什么它很重要
- 是位掩码 DP 变换的基础(例如子集卷积、容斥原理)。
- 支持快速枚举子集属性(和、计数等)。
- 在概率 DP、多项式变换和博弈 DP 中是可复用的构建模块。
逐步示例
令 $n=2$,掩码为 $00,01,10,11$,且 $g=[1,2,3,4]$:
初始化 $f=g$。
比特位 $i=0$:
- $S=01$:$f[01]+=f[00] \implies 2+1=3$
- $S=11$:$f[11]+=f[10] \implies 4+3=7$
$f=[1,3,3,7]$
比特位 $i=1$:
- $S=10$:$f[10]+=f[00] \implies 3+1=4$
- $S=11$:$f[11]+=f[01] \implies 7+3=10$
最终 $f=[1,3,4,10]$
验证:
- $f[11] = g[00]+g[01]+g[10]+g[11] = 1+2+3+4=10$ ✓
一个温和的证明(为什么它有效)
每个比特位被独立处理。 在第 $i$ 次迭代中,每个掩码 $S$ 累积了所有仅在比特位 $i$ 上与其不同的子集的贡献。 处理完所有比特位后,每个子集 $T\subseteq S$ 恰好被访问一次。
通过归纳法:
- 基础:$i=0$ 时,$f[S]$ 包含 $g[S]$。
- 步骤:加上 $f[S^{\text{without }i}]$ 确保了包含缺少第 $i$ 位的子集。
因此,经过 $n$ 轮处理后,$f[S]$ 对所有子集求和。
动手尝试
- 将求和改为求积(如果 $f[S]*=f[S^{\text{without }i}]$)。
- 计算 $f[S]=\sum_{T\supseteq S} g[T]$(参见超集 DP)。
- 将 SOS DP 与容斥原理结合以计数有效子集。
- 应用于子集卷积问题。
- 对大数值使用模运算。
测试用例
| n | g (输入) | f (输出) |
|---|---|---|
| 2 | [1,2,3,4] | [1,3,4,10] |
| 3 | [1,1,1,1,1,1,1,1] | [1,2,2,4,2,4,4,8] |
| 3 | [0,1,2,3,4,5,6,7] | 由代码计算 |
复杂度
- 时间:$O(n \cdot 2^n)$
- 空间:$O(2^n)$
SOS DP 将指数级的子集和枚举转化为跨维度的结构化线性遍历,使得基于子集的计算在 $n$ 较小时变得易于处理。
458 SOS DP 超集求和
超集求和(Sum Over Supersets)为每个掩码计算其所有超集的聚合值。它是对通常作用于子集的 SOS DP 的补充。
我们要解决什么问题?
给定一个大小为 $2^n$、由位掩码索引的数组 g,计算 h 使得 $$ h[S]=\sum_{T\supseteq S}g[T]. $$ 朴素实现是 $O(3^n)$。使用 SOS 超集 DP 可以做到 $O(n\cdot 2^n)$。
它是如何工作的(通俗解释)
逐位处理。对于每一位 $i$,如果掩码 $S$ 的第 $i$ 位未设置,那么所有将此位打开的每个超集都具有 $S\cup{i}=S\oplus(1<<i)$ 的形式。因此我们可以从那个邻居向上累加。
递推关系
初始化 $h = g$。对于每一位 $i = 0..n-1$:
对于每个掩码 $S$:
如果 $(S \mathbin{&} (1 << i)) == 0$,则 $$ h[S] += h[S \oplus (1 << i)]. $$ 在所有位处理完后,$h[S]$ 等于 $S$ 的所有超集之和。
精简代码
Python
def sos_superset_sum(g, n):
h = g[:] # 复制
for i in range(n):
for S in range(1 << n):
if (S & (1 << i)) == 0:
h[S] += h[S | (1 << i)]
return h
# 示例
n = 3
g = [1,2,3,4,5,6,7,8] # g[mask]
h = sos_superset_sum(g, n)
print(h)C
#include <stdio.h>
void sos_superset_sum(long long h[], int n){
for(int i=0;i<n;i++){
for(int S=0;S<(1<<n);S++){
if((S&(1<<i))==0){
h[S]+=h[S|(1<<i)];
}
}
}
}
int main(){
int n=3;
long long h[1<<3]={1,2,3,4,5,6,7,8};
sos_superset_sum(h,n);
for(int i=0;i<(1<<n);i++) printf("%lld ", h[i]);
return 0;
}为什么它很重要
- 是子集 SOS DP 的对偶形式。
- 是子集格上 zeta 变换和 Möbius 变换的核心。
- 对于以下类型的查询很有用:对于每个特征集 $S$,聚合所有包含 $S$ 的超集的值。
分步示例
令 $n=2$,掩码 $00,01,10,11$ 且 $g=[1,2,3,4]$。
处理位 $i=0$:
- $S=00$:$h[00]+=h[01]\Rightarrow 1+2=3$
- $S=10$:$h[10]+=h[11]\Rightarrow 3+4=7$
现在 $h=[3,2,7,4]$。
处理位 $i=1$:
- $S=00$:$h[00]+=h[10]\Rightarrow 3+7=10$
- $S=01$:$h[01]+=h[11]\Rightarrow 2+4=6$
最终 $h=[10,6,7,4]$,这符合
- $h[00]=g[00]+g[01]+g[10]+g[11]=10$
- $h[01]=g[01]+g[11]=6$
- $h[10]=g[10]+g[11]=7$
- $h[11]=g[11]=4$。
一个温和的证明(为什么它有效)
固定一个位顺序。当处理位 $i$ 时,对于任何第 $i$ 位未设置的 $S$,$S$ 的每个超集要么保持第 $i$ 位关闭,要么将其打开。在处理 $i$ 之前,$h[S]$ 累积了第 $i$ 位关闭的超集。加上 $h[S\cup{i}]$ 则引入了所有第 $i$ 位打开的超集。对位进行归纳,可以得出结论:所有超集都被恰好包含了一次。
动手尝试
- 通过将加法替换为 max,将其转换为计算超集上的最大值。
- 与子集 SOS 结合,为快速子集-超集查询预计算两个方向。
- 应用模运算以防止溢出。
- 在超集上实现 Möbius 反演以反转变换。
- 扩展到聚合依赖于位计数的位运算场景。
测试用例
| n | g 输入 | h 输出 |
|---|---|---|
| 2 | [1,2,3,4] | [10,6,7,4] |
| 3 | 全为 1 | [8,4,4,2,4,2,2,1] |
| 3 | [0,1,2,3,4,5,6,7] | 由代码计算 |
复杂度
- 时间:$O(n\cdot 2^n)$
- 空间:$O(2^n)$
SOS 超集 DP 是子集 SOS 的自然镜像。当查询要求聚合所有包含给定掩码的集合时,请使用它。
459 异或基动态规划
异或基动态规划技术有助于高效地计数或生成一组数字的所有可能的异或值。它在 GF(2) 上构建线性基,并能够解决诸如计数不同异或值、寻找最小/最大异或值,以及与数位动态规划或状态压缩结合等问题。
我们要解决什么问题?
给定一个数字列表 $A = [a_1, a_2, \dots, a_n]$,我们希望:
- 找出从 $A$ 的子集可以形成多少个不同的异或值。
- 或者找出可能的最大/最小异或值。
- 或者回答关于可能的异或组合的查询。
异或运算在 $\mathbb{F}_2$ 上构成一个向量空间,每个数字贡献一个向量。异或基提供了所有子集异或值的紧凑表示。
核心思想
维护一个表示独立比特向量的数组 basis。将每个数字插入到基中(在 GF(2) 上进行高斯消元):
- 对于从高位到低位的每一位,如果该位被置位且尚未被表示,则存储该数字。
- 如果该位已被表示,则与当前基向量进行异或以消减它。
最后,独立向量的数量就是秩 $r$,不同异或值的数量是 $2^r$。
动态规划视角
状态表示从数组前缀构建出的基。 你可以定义:
$$ dp[i] = \text{处理完前 } i \text{ 个元素后的异或基} $$
要计算处理完所有元素后的不同异或值数量:
$$ \text{数量} = 2^{\text{秩}} $$
如果你需要构建组合(例如,计数异或值 < M),可以将基的构建与数位动态规划的约束条件结合起来。
微型代码(简易版本)
Python
def xor_basis(arr):
basis = []
for x in arr:
for b in basis:
x = min(x, x ^ b)
if x:
basis.append(x)
return basis
def count_distinct_xors(arr):
basis = xor_basis(arr)
return 1 << len(basis) # 2^rank
# 示例
A = [3, 10, 5]
basis = xor_basis(A)
print("基:", basis)
print("不同异或值数量:", count_distinct_xors(A))C
#include <stdio.h>
int insert_basis(int basis[], int *sz, int x) {
for (int i = 0; i < *sz; i++) {
if ((x ^ basis[i]) < x) x ^= basis[i];
}
if (x == 0) return 0;
basis[(*sz)++] = x;
return 1;
}
int main() {
int arr[] = {3, 10, 5};
int n = 3, basis[32], sz = 0;
for (int i = 0; i < n; i++)
insert_basis(basis, &sz, arr[i]);
printf("秩: %d\n不同异或值数量: %d\n", sz, 1 << sz);
}为什么它很重要
- 构成了子集异或问题的基础。
- 用于:
- 计数不同的异或值
- 最大异或子集
- 受异或约束的数位动态规划
- $\mathbb{F}_2$ 上的高斯消元
它是线性代数的比特位模拟,在 GF(2) 上求解问题。
逐步示例
令 $A = [3, 10, 5]$:
- 二进制:$3=011$,$10=1010$,$5=0101$
- 插入 3 → 基 = {3}
- 插入 10 → 独立,基 = {3,10}
- 插入 5 → 可以被消减:$5⊕3=6$,$6⊕10=12$ → 独立,基 = {3,10,5}
秩 $r=3$,不同异或值数量 = $2^3=8$。
所有子集异或值:
0, 3, 5, 6, 10, 11, 12, 15一个温和的证明(为什么它有效)
每个基向量代表一个新的独立比特维度。 每个子集异或值对应于基在 $\mathbb{F}_2$ 上的一个线性组合。 如果有 $r$ 个独立向量,就有 $2^r$ 种可能的线性组合(子集),因此有 $2^r$ 个不同的异或值。
亲自尝试
- 修改代码以找到最大异或子集(从最高有效位开始贪心地异或)。
- 与数位动态规划结合,计数满足异或约束($< M$)的数字数量。
- 跟踪重构:哪个子集能形成目标异或值。
- 应用于树中的路径异或查询(通过前缀基合并)。
- 扩展到多重集基或在线更新。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,10,5] | 8 | 3 个独立向量 |
| [1,2,3] | 4 | 秩 = 2 |
| [1,1,1] | 2 | 秩 = 1 |
复杂度
- 时间复杂度:$O(n \cdot \text{比特宽度})$
- 空间复杂度:$O(\text{比特宽度})$
异或基动态规划是集合在异或运算下的数字几何,每个数字是一个向量,每个子集是一个线性组合,每个问题都是穿越二进制空间的一条路径。
460 回文数的数位 DP
回文数的数位 DP 用于统计给定范围内所有回文数的数量,即正读反读都相同的数字。这是一种对称的 DP 方法,它同时从两端构造数字的每一位,并遵守原始数字的严格边界约束。
我们要解决什么问题?
给定两个整数 $L$ 和 $R$,统计 $[L, R]$ 区间内的回文数数量。
示例:在 $[1, 200]$ 中,回文数有 $1,2,\dots,9,11,22,\dots,99,101,111,\dots,191$,总共 28 个。
朴素地遍历并检查每个数字是 $O(N)$ 的复杂度,对于大的 $N$ 来说太慢了。 我们希望使用具有对称性的数位 DP,实现 $O(d \times 10^{d/2})$ 的方法。
核心思想
我们可以从外向内逐位构造一个数字,并确保每一步构造的数字都保持回文性质。
对于一个给定的长度 $len$,我们只需要选择前半部分的数字;后半部分由前半部分决定。
边界约束确保我们构造的数字 $\le R$(对于包含区间,则是 $\le L-1$)。
DP 定义
令 $S$ = 数字 $N$ 的各位数字,长度为 $len$。
状态: $$ dp[pos][tight][leading] $$
其中:
pos是当前从左半部分开始的索引($0 \le pos < \frac{len}{2}$)tight表示前缀到目前为止等于 $N$ 的前缀leading表示我们目前只放置了前导零
递归过程在 pos 处放置一个数字,将其镜像到 len-1-pos 位置,然后向内递归。
状态转移
对于 $[0, limit]$ 中的每个 digit:
- 如果
leading为真且digit==0,我们可以将其视为非有效数字而跳过计数。 - 将数字镜像到对称位置。
- 如果
digit == limit,则更新 tight 状态。 - 向内递归直到中点。
当到达中点时,计数 1 个有效的回文数。
算法概述
为了统计 ≤ $N$ 的回文数:
- 将 $N$ 转换为字符串
S - 运行
dfs(pos=0, tight=True, leading=True) - 如果构造的是完整的回文数(而非仅一半),检查镜像结构
在 $[L, R]$ 区间内的计数公式为: $$ f(R) - f(L-1) $$
精简代码(简易版本)
Python
from functools import lru_cache
def count_palindromes_upto(N):
S = list(map(int, str(N)))
n = len(S)
@lru_cache(None)
def dfs(pos, tight, leading, half):
if pos == (n + 1) // 2:
return 1 # 成功构造一个回文数
limit = S[pos] if tight else 9
total = 0
for d in range(limit + 1):
if leading and d == 0:
total += dfs(pos + 1, tight and d == limit, True, half + [0])
else:
total += dfs(pos + 1, tight and d == limit, False, half + [d])
return total
return dfs(0, True, True, ())
def count_palindromes(L, R):
return count_palindromes_upto(R) - count_palindromes_upto(L - 1)
# 示例
print(count_palindromes(1, 200))C (半构造法)
#include <stdio.h>
#include <string.h>
long long count_palindromes_upto(long long N) {
if (N < 0) return 0;
char s[20];
sprintf(s, "%lld", N);
int len = strlen(s);
int half = (len + 1) / 2;
long long count = 0;
// 构造前半部分并镜像
for (int mask = 0; mask < (1 << (half * 4)); mask++) {
// 仅为概念示意,实际代码需使用递归或十进制枚举
}
// 更简单的方法:遍历前半部分,构造完整回文数,检查是否 ≤ N
long long start = 1;
for (int i = 1; i <= len; i++) {
int half_len = (i + 1) / 2;
long long base = 1;
for (int j = 1; j < half_len; j++) base *= 10;
for (long long x = base; x < base * 10; x++) {
long long y = x;
if (i % 2) y /= 10;
long long z = x;
while (y > 0) {
z = z * 10 + (y % 10);
y /= 10;
}
if (z <= N) count++;
}
}
return count;
}
int main(void) {
long long L = 1, R = 200;
printf("%lld\n", count_palindromes_upto(R) - count_palindromes_upto(L - 1));
}为何重要
回文数计数出现在以下场景:
- 数位约束(例如,特殊数字集合)
- 对称数字组合学
- 模式化序列生成
有助于建立对双向 DP(数字镜像)的直觉。
分步示例
令 $N = 200$:
- 长度 3 → 半长 = 2
- 选择前半部分 (00–19),镜像 →
00–99→0,11,22,...,191 - 应用边界:仅 ≤200 → 计数 = 28
一个温和的证明(为何有效)
每个回文数都由其前半部分唯一确定。 对于每个满足上界约束的有效前半部分,恰好存在一个镜像数。 Tight 状态确保不会超过 $N$。 前导零处理确保排除像 00100 这样的数字。
动手尝试
- 仅统计偶数长度的回文数。
- 统计具有固定数位和的回文数。
- 修改代码,通过添加素性检查来统计回文素数。
- 结合数位约束(例如,不能有数字 3)。
- 统计经过反转操作后能变成回文数的数字。
测试用例
| L | R | 输出 | 备注 |
|---|---|---|---|
| 1 | 9 | 9 | 一位数回文数 |
| 10 | 99 | 9 | 11,22,...,99 |
| 1 | 200 | 28 | 直到 191 |
| 100 | 999 | 90 | 所有三位数回文数 |
复杂度
- 时间复杂度:$O(d \times 10^{d/2})$
- 空间复杂度:$O(d)$
回文数的数位 DP 连接了算术与对称性,在边界约束下逐位构造镜像结构。
第 47 节 DP 优化
461 分治 DP(单调优化)
当 DP 状态转移是如下形式的 1D 卷积 $dp[i][j]=\min\limits_{k<j}\big(dp[i-1][k]+C(k,j)\big)$, 并且最优决策点具有单调性($opt[i][j]\le opt[i][j+1]$)时,你可以使用分治法在 $O(n)$ 次分割内计算整个 $dp[i][*]$ 层,而不是 $O(n^2)$。根据实现方式不同,典型的总复杂度为 $O(K,n\log n)$ 或 $O(K,n)$。
我们要解决什么问题?
加速具有如下转移方程的 DP 层 $$ dp[j]=\min_{k<j}\big(prev[k]+C(k,j)\big), $$ 其中 $j$ 在某个区间内,且最优索引 $opt[j]\in[\text{optL},\text{optR}]$ 满足 $$ opt[j]\le opt[j+1]\quad\text{(决策单调性).} $$
这种结构出现在以下场景中:
- 具有凸分段成本的数组 $K$ 分割
- 具有凸惩罚的一维设施放置和断行问题
- 具有凸弧成本的 DAG 层上的某些最短路径问题
它是如何工作的(通俗解释)
通过求解中点 $M$,并在 $[optL,optR]$ 范围内搜索其最佳 $k$,来计算当前 DP 层在区间 $[L,R]$ 上的值。最优索引 $opt[M]$ 将问题分割开来:
- 左半部分 $[L,M-1]$ 只需要在 $[optL,opt[M]]$ 中寻找候选
- 右半部分 $[M+1,R]$ 只需要在 $[opt[M],optR]$ 中寻找候选
递归重复此过程,直到区间大小为 1。单调性保证了这些候选范围的有效性。
前提条件清单
如果满足以下条件,你可以使用分治 DP:
- 状态转移是 $dp[j]=\min_{k<j}(prev[k]+C(k,j))$。
- 最优索引关于 $j$ 是单调的。一个充分条件是 $C$ 满足四边形不等式或 Monge 性质: $$ C(a,c)+C(b,d)\le C(a,d)+C(b,c)\quad\text{对于 }a\le b\le c\le d. $$
微型代码(模板)
C++ 风格伪代码(稍作修改即可用于 C 语言)
// 给定 dp_prev 和成本函数 C(k,j),计算一层 dp_cur[lo..hi]。
// 假设最优索引是单调的。
void compute(int lo, int hi, int optL, int optR,
const vector<long long>& dp_prev,
vector<long long>& dp_cur,
auto&& cost) {
if (lo > hi) return;
int mid = (lo + hi) >> 1;
long long best = LLONG_MAX;
int best_k = -1;
int start = optL, end = min(optR, mid - 1);
for (int k = start; k <= end; ++k) {
long long val = dp_prev[k] + cost(k, mid);
if (val < best) {
best = val;
best_k = k;
}
}
dp_cur[mid] = best;
// 使用缩小后的 opt 范围进行递归
compute(lo, mid - 1, optL, best_k, dp_prev, dp_cur, cost);
compute(mid + 1, hi, best_k, optR, dp_prev, dp_cur, cost);
}Python(清晰简洁)
INF = 10**18
def compute(lo, hi, optL, optR, dp_prev, dp_cur, cost):
if lo > hi:
return
mid = (lo + hi) // 2
best_val, best_k = INF, -1
end = min(optR, mid - 1)
for k in range(optL, end + 1):
v = dp_prev[k] + cost(k, mid)
if v < best_val:
best_val, best_k = v, k
dp_cur[mid] = best_val
compute(lo, mid - 1, optL, best_k, dp_prev, dp_cur, cost)
compute(mid + 1, hi, best_k, optR, dp_prev, dp_cur, cost)使用方法 对于 $i=1..K$:调用 compute(1,n,0,n-1, dp_prev, dp_cur, cost),然后交换层。索引范围取决于你的基本情况。
示例:具有凸分段成本的 K 分割
给定数组 $a[1..n]$,令前缀和 $S[j]=\sum_{t=1}^j a[t]$。假设分段成本为 $$ C(k,j)=\big(S[j]-S[k]\big)^2, $$ 它是凸的并且满足四边形不等式。DP 方程 $$ dp[i][j]=\min_{k<j}\big(dp[i-1][k]+C(k,j)\big) $$ 具有单调的最优决策点,因此可以使用模板计算一层。总复杂度大约为 $O(K,n\log n)$。
为什么这很重要
- 将二次方的 DP 层计算降低到接近线性
- 与更高级的技巧相比,实现简单
- 与用于计算 $C(k,j)$ 的前缀和配合良好
- 是编辑器断行、一维聚类、直方图平滑等问题的核心技术
在一个小实例上逐步演示
令 $a=[1,3,2,4]$, $K=2$, $C(k,j)=(S[j]-S[k])^2$。
- 初始化 $dp[0][0]=0$, $dp[0][j>0]=+\infty$。
- 层 $i=1$:通过扫描 $k<j$ 计算 $dp[1][j]$。最优决策点是非递减的。
- 层 $i=2$:调用
compute(1,n,0,n-1, ...)。递归将目标区间对半分割,并根据单调性缩小候选 $k$ 的范围。
一个温和的证明(为什么它有效)
令 $opt[j]$ 是 $dp[j]$ 的一个最小化解。如果 $C$ 是 Monge 的,那么对于 $x<y$ 和 $u<v$: $$ C(u,x)+C(v,y)\le C(u,y)+C(v,x). $$ 假设 $opt[x]>opt[y]$ 导致矛盾。利用这些索引的最优性和上述不等式,会得出矛盾。因此 $opt$ 是非递减的。
递归使用 $[optL,optR]$ 中的候选来计算 $dp[mid]$。找到的 $opt[mid]$ 分割了可行的候选:
- 任何 $j<mid$ 的最优索引都在 $[optL,opt[mid]]$ 中
- 任何 $j>mid$ 的最优索引都在 $[opt[mid],optR]$ 中 对区间进行归纳,可以证明每个 $dp[j]$ 都是用恰好所需的候选集计算的,并且没有排除任何必要的候选。
亲自尝试
- 将 $C(k,j)$ 替换为 $\alpha,(S[j]-S[k])^2+\beta,(j-k)$,并验证单调性是否仍然成立。
- 使用模板来加速具有不整齐度惩罚的断行问题。
- 在随机凸成本上,对朴素 $O(n^2)$ 算法和分治算法进行基准测试。
- 通过仅保留两层来结合空间优化。
- 与 Knuth 优化和凸包技巧进行对比,并决定哪种方法适用于你的成本函数。
测试用例
- 小型凸成本:
- $a=[2,1,3], K=2$, $C(k,j)=(S[j]-S[k])^2$
- 比较朴素算法和分治算法的输出,它们必须匹配。
- 线性加凸混合:
- $a=[1,1,1,1], K=3$, $C(k,j)=(S[j]-S[k])^2+(j-k)$
- 边界情况:
- $n=1$ 任意 $K\ge1$
- 全零数组,任意 $K$
复杂度
- 使用分治法计算一层:在简单形式下需要 $O(n\log n)$ 次 $C$ 的计算,通常写作 $O(n)$ 次分割,递归深度为对数级。
- 完整 DP:时间复杂度 $O(K,n\log n)$,空间复杂度 $O(n)$ 或 $O(K,n)$,取决于是否存储所有层。
当最优决策点随着 $j$ 的增长而向右滑动时,分治 DP 是你的首选。它代码量小,提速效果显著。
462 Knuth 优化
Knuth 优化是一种针对特定 DP 状态转移的特殊加速方法,适用于满足四边形不等式和最优决策单调性的情况。它将 $O(n^2)$ 的动态规划算法优化为每层 $O(n)$,常用于最优二叉搜索树、矩阵链乘和区间划分等问题。
我们要解决什么问题?
我们希望优化以下形式的 DP 递推式: $$ dp[i][j] = \min_{k \in [i, j-1]} \big(dp[i][k] + dp[k+1][j] + C(i, j)\big) $$ 其中 $1 \le i \le j \le n$,且 $C(i,j)$ 是满足四边形不等式的代价函数。
朴素解法的时间复杂度为 $O(n^3)$。通过 Knuth 优化,我们利用最优分割点的单调性来限制搜索范围,从而将复杂度降低到 $O(n^2)$。
关键条件
Knuth 优化适用于以下条件:
- 四边形不等式: $$ C(a, c) + C(b, d) \le C(a, d) + C(b, c), \quad \forall a \le b \le c \le d $$
- 最优决策点的单调性: $$ opt[i][j-1] \le opt[i][j] \le opt[i+1][j] $$ 这些条件确保了区间 $[i,j]$ 的最优分割点 $k$ 会随着区间的滑动而向右移动。
工作原理(通俗解释)
在计算 $dp[i][j]$ 时,最佳分割点 $k$ 位于 $opt[i][j-1]$ 和 $opt[i+1][j]$ 之间。 因此,我们无需扫描整个 $[i, j-1]$ 区间,而是将搜索限制在一个狭窄的窗口内。 这使工作量从 $O(n^3)$ 减少到 $O(n^2)$。
我们按区间长度递增的顺序填充 DP 表,并维护和重用 opt[i][j]。
逐步递推
对于所有 $i$: $$ dp[i][i] = 0 $$
然后对于长度 $len = 2..n$:
for i in 1..n-len+1:
j = i + len - 1
dp[i][j] = ∞
for k in opt[i][j-1]..opt[i+1][j]:
val = dp[i][k] + dp[k+1][j] + C(i,j)
if val < dp[i][j]:
dp[i][j] = val
opt[i][j] = k简化代码(简易版本)
Python
INF = 1018
def knuth_optimization(n, cost):
dp = [[0]*n for _ in range(n)]
opt = [[0]*n for _ in range(n)]
for i in range(n):
opt[i][i] = i
for length in range(2, n+1):
for i in range(0, n-length+1):
j = i + length - 1
dp[i][j] = INF
start = opt[i][j-1]
end = opt[i+1][j] if i+1 <= j else j-1
for k in range(start, end+1):
val = dp[i][k] + dp[k+1][j] + cost(i, j)
if val < dp[i][j]:
dp[i][j] = val
opt[i][j] = k
return dp[0][n-1]C
#include <stdio.h>
#include <limits.h>
#define INF 1000000000000000LL
#define N 505
long long dp[N][N], opt[N][N];
long long cost(int i, int j); // 用户定义的代价函数
long long knuth(int n) {
for (int i = 0; i < n; i++) {
dp[i][i] = 0;
opt[i][i] = i;
}
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = INF;
int start = opt[i][j-1];
int end = opt[i+1][j];
if (end == 0) end = j - 1;
if (start > end) { int tmp = start; start = end; end = tmp; }
for (int k = start; k <= end; k++) {
long long val = dp[i][k] + dp[k+1][j] + cost(i, j);
if (val < dp[i][j]) {
dp[i][j] = val;
opt[i][j] = k;
}
}
}
}
return dp[0][n-1];
}为何重要
Knuth 优化将三次方的 DP 算法精确地优化为二次方,无需近似。它特别适用于:
- 最优二叉搜索树
- 矩阵链乘
- 石子合并 / 文件合并
- 括号解析 / 区间划分
这是一种基于 Monge 数组和凸性的精确代数优化。
逐步示例
考虑合并大小为 $[10,20,30]$ 的文件。 $C(i,j)=\text{从 i 到 j 的文件大小之和}$。 我们用合并区间 $[i..j]$ 的最小总代价来填充 $dp[i][j]$。 最优决策点单调移动,因此 Knuth 优化适用。
简要证明(为何有效)
如果 $C$ 满足四边形不等式: $$ C(a, c) + C(b, d) \le C(a, d) + C(b, c), $$ 那么合并两个相邻的子问题永远不会导致最优分割点向左移动。 因此,$opt[i][j-1] \le opt[i][j] \le opt[i+1][j]$。 所以,限制搜索范围在保持正确性的同时,减少了冗余检查。
动手尝试
- 将 Knuth 优化应用于最优二叉搜索树: $$ dp[i][j]=\min_{k\in[i,j]}(dp[i][k-1]+dp[k+1][j]+w[i][j]) $$
- 在石子合并问题(基于和的合并代价)中使用它。
- 与分治 DP 进行比较,两者都需要单调性,但 Knuth 优化具有固定的二次方结构。
- 通过打印 $opt[i][j]$ 来验证单调性。
- 证明 $C(i,j)=\text{prefix}[j]-\text{prefix}[i-1]$ 满足条件。
测试用例
| 用例 | 描述 | 预期复杂度 |
|---|---|---|
| 文件合并 | [10, 20, 30] | $O(n^2)$ |
| 最优二叉搜索树 | 排序的键及其频率 | $O(n^2)$ |
| 石子合并 | 等权重 | 单调的 $opt$ |
复杂度
- 时间复杂度:$O(n^2)$
- 空间复杂度:$O(n^2)$
Knuth 优化是完整 DP 和凸性技巧之间的优雅折中,精确、可预测,并且在代价函数满足 Monge 结构时总是最优的。
463 凸包技巧(CHT)
凸包技巧(Convex Hull Trick)可以加速以下形式的 DP 状态转移: $$ dp[i] = \min_{k<i}\big(dp[k] + m_k \cdot x_i + b_k\big) $$ 当斜率 $m_k$ 是单调的(递增或递减)且查询点 $x_i$ 也是单调时。 它使用动态凸包,将 $O(n^2)$ 的扫描复杂度降低到 $O(n)$ 的摊还时间或 $O(\log n)$ 的查询时间。
我们在解决什么问题?
我们有一个类似这样的 DP 递推式: $$ dp[i] = \min_{k < i}\big(dp[k] + m_k \cdot x_i + b_k\big) $$ 其中:
- $m_k$(斜率)和 $b_k$(截距)定义了直线,
- $x_i$ 是查询坐标,
- 我们希望得到所有 $k$ 中的最小值(或最大值)。
这出现在以下场景中:
- 线性 DP(例如,分段线性成本),
- 分治 DP(凸包变体),
- 带有线性惩罚项的类 Knuth DP,
- 针对非单调情况的 Aliens 技巧和李超线段树。
适用条件
- 状态转移符合 $dp[i] = \min_k(dp[k] + m_k x_i + b_k)$ 的形式
- $m_k$ 是单调的(非递减或非递增)
- $x_i$ 查询是排序好的(非递减)
那么你可以使用基于双端队列的 CHT,实现每次插入/查询 $O(1)$ 的摊还复杂度。
如果斜率或查询不是单调的,请使用李超线段树(下一个算法)。
工作原理(通俗解释)
每个 $k$ 定义了一条直线 $y = m_k x + b_k$。 DP 询问的是:对于当前的 $x_i$,哪条之前的直线给出的值最小? 所有直线共同形成了一个下包络线,即一个分段的最小值曲线。 我们增量地维护这个凸包,并高效地查询最小值。
如果斜率是排序好的,每条新直线与之前的凸包在一个点相交, 并且旧直线在它们的交点之后变得无用。
精简代码(简易版本)
Python(单调 CHT)
class CHT:
def __init__(self):
self.lines = [] # (m, b)
def bad(self, l1, l2, l3):
# 检查 l2 在 l1 和 l3 之间是否是不必要的
return (l3[1] - l1[1]) * (l1[0] - l2[0]) <= (l2[1] - l1[1]) * (l1[0] - l3[0])
def add(self, m, b):
self.lines.append((m, b))
while len(self.lines) >= 3 and self.bad(self.lines[-3], self.lines[-2], self.lines[-1]):
self.lines.pop(-2)
def query(self, x):
# 按递增顺序查询 x
while len(self.lines) >= 2 and \
self.lines[0][0]*x + self.lines[0][1] >= self.lines[1][0]*x + self.lines[1][1]:
self.lines.pop(0)
m, b = self.lines[0]
return m*x + b
# 示例:dp[i] = min(dp[k] + m[k]*x[i] + b[k])
dp = [0]*5
x = [1, 2, 3, 4, 5]
m = [2, 1, 3, 4, 5]
b = [5, 4, 3, 2, 1]
cht = CHT()
cht.add(m[0], b[0])
for i in range(1, 5):
dp[i] = cht.query(x[i-1])
cht.add(m[i], dp[i] + b[i])C(双端队列实现)
#include <stdio.h>
typedef struct { long long m, b; } Line;
Line hull[100005];
int sz = 0, ptr = 0;
double intersect(Line a, Line b) {
return (double)(b.b - a.b) / (a.m - b.m);
}
int bad(Line a, Line b, Line c) {
return (c.b - a.b)*(a.m - b.m) <= (b.b - a.b)*(a.m - c.m);
}
void add_line(long long m, long long b) {
Line L = {m, b};
while (sz >= 2 && bad(hull[sz-2], hull[sz-1], L)) sz--;
hull[sz++] = L;
}
long long query(long long x) {
while (ptr + 1 < sz && hull[ptr+1].m * x + hull[ptr+1].b <= hull[ptr].m * x + hull[ptr].b)
ptr++;
return hull[ptr].m * x + hull[ptr].b;
}为什么重要
将具有线性转移的 $O(n^2)$ DP 降低到 $O(n)$ 或 $O(n\log n)$。
在优化任务中极其常见:
- 凸成本划分
- 斜率技巧扩展
- 带有线性惩罚的动态规划
是李超线段树和斜率技巧的基础。
逐步示例
假设 $$ dp[i] = \min_{k<i}\big(dp[k] + a_k \cdot b_i + c_k\big) $$ 给定:
- $a = [2,4,6]$
- $b = [1,2,3]$
- $c = [5,4,2]$
每一步:
- 添加直线 $y = m_k x + b_k = a_k x + (dp[k] + c_k)$
- 在 $x_i = b[i]$ 处查询以获得最小值。
CHT 只保留构成下包络线的有用直线。
一个温和的证明(为什么有效)
对于单调的斜率,交点是有序的。 一旦一条直线在某个 $x$ 处变得比下一条差, 对于更大的 $x$,它将永远不会再是最优的。 因此,我们可以将其从双端队列中弹出 —— 每条直线进入和离开各一次 → $O(n)$ 摊还复杂度。
亲自尝试
- 调整为查询最大值(翻转符号)。
- 与 DP 结合:$dp[i] = \min_k(dp[k] + m_k x_i + b_k)$。
- 添加李超线段树以处理未排序的斜率/查询。
- 可视化下包络线的交点。
- 与斜率技巧(分段线性势能)进行比较。
测试用例
| m | b | x | 预期最小值 |
|---|---|---|---|
| [1,2,3] | [0,1,3] | 1 | 1 |
| [2,1] | [5,4] | 2 | 6 |
| [1,3,5] | [2,2,2] | 4 | 14 |
复杂度
- 时间:$O(n)$ 摊还(单调查询)或 $O(n\log n)$(李超线段树)
- 空间:$O(n)$
凸包技巧是几何与动态规划之间的桥梁,每条直线是一个子问题,每个包络线是一个最小化前沿。
464 Li Chao 树
Li Chao 树是一种动态数据结构,用于维护一组直线,并高效查询任意给定 $x$ 处的最小(或最大)值。与凸包技巧不同,即使斜率和查询点是任意且无序的,它也能正常工作。
我们要解决什么问题?
我们希望处理以下形式的 DP 递推式(或成本函数):
$$ dp[i] = \min_{j < i} (m_j \cdot x_i + b_j) $$
每个 $j$ 贡献一条直线 $y = m_jx + b_j$。 我们必须在给定的 $x_i$ 处,找到所有已添加直线中的最小值。
与凸包技巧(需要 $x$ 或 $m$ 单调)不同, Li Chao 树可以处理任何插入或查询顺序,无需排序。
适用场景
Li Chao 树适用于以下情况:
- 你需要随时间添加任意直线($m_j, b_j$)
- 你必须以任意顺序查询任意 $x$
- 你希望高效地进行最小或最大查询
- 斜率和查询不是单调的
这使得它成为以下情况的理想选择:
- 具有任意斜率的 DP
- 在线查询
- 涉及下包络的几何问题
- 计算几何中的直线容器查询
工作原理(通俗解释)
Li Chao 树将 $x$ 轴划分为若干段。 每个节点代表一个区间,存储一条当前在该区间的一部分(或全部)范围内最优的直线。
当添加一条新直线时:
- 将其与区间中当前的直线进行比较
- 如果它在区间中点处更优,则交换
- 递归地插入到一个子节点(左/右)中,缩小范围
当查询时:
- 使用 $x$ 值向下遍历树
- 合并遇到的直线的值
- 返回最小值(或最大值)
这使得每次插入和查询的时间复杂度为 $O(\log X)$, 其中 $X$ 是 $x$ 值的范围(必要时进行离散化)。
精简代码(简易版本)
Python(最小值版本)
INF = 1018
class Line:
def __init__(self, m, b):
self.m = m
self.b = b
def value(self, x):
return self.m * x + self.b
class Node:
def __init__(self, l, r):
self.l = l
self.r = r
self.line = None
self.left = None
self.right = None
class LiChaoTree:
def __init__(self, l, r):
self.root = Node(l, r)
def _add(self, node, new_line):
l, r = node.l, node.r
m = (l + r) // 2
if node.line is None:
node.line = new_line
return
left_better = new_line.value(l) < node.line.value(l)
mid_better = new_line.value(m) < node.line.value(m)
if mid_better:
node.line, new_line = new_line, node.line
if r - l == 0:
return
if left_better != mid_better:
if not node.left:
node.left = Node(l, m)
self._add(node.left, new_line)
else:
if not node.right:
node.right = Node(m + 1, r)
self._add(node.right, new_line)
def add_line(self, m, b):
self._add(self.root, Line(m, b))
def _query(self, node, x):
if node is None:
return INF
res = node.line.value(x) if node.line else INF
m = (node.l + node.r) // 2
if x <= m:
return min(res, self._query(node.left, x))
else:
return min(res, self._query(node.right, x))
def query(self, x):
return self._query(self.root, x)
# 使用示例
tree = LiChaoTree(0, 100)
tree.add_line(2, 3)
tree.add_line(-1, 10)
print(tree.query(5)) # 在 x=5 处所有直线的最小值为什么它很重要
- 处理任意斜率和查询
- 对在线 DP 和几何优化高效
- 推广了 CHT(无需单调性约束即可工作)
- 可用于最小和最大查询(只需翻转不等式)
分步示例
假设我们插入直线:
- $y = 2x + 3$
- $y = -x + 10$
然后在 $x = 5$ 处查询:
- 第一条直线:$2(5) + 3 = 13$
- 第二条直线:$-5 + 10 = 5$ → 答案为 5
该树确保每次查询都返回最优直线,而无需暴力计算。
一个温和的证明(为什么它有效)
每个区间存储一条在某个中点处局部最优的直线。 如果一条新直线在某个端点处更优,那么它最终必然会超越现有直线,因此交点位于那一半区间内。
通过对半区间递归,我们确保为每个 $x$ 选择了正确的直线。
树的高度为 $\log X$, 并且每次插入最多影响 $\log X$ 个节点。
因此: $$ T(n) = O(n \log X) $$
动手尝试
- 实现用于最大查询的 Li Chao 树(反转比较)。
- 添加 $n$ 条随机直线并查询随机 $x$。
- 将其应用于具有线性成本的 DP: $$ dp[i] = \min_{j < i}(dp[j] + a_jx_i + b_j) $$
- 可视化区间划分和存储的直线。
- 在单调测试用例上与凸包技巧进行比较。
测试用例
| 直线数 | 查询数 | 范围 | 时间复杂度 | 是否工作 |
|---|---|---|---|---|
| 10 | 10 | [0, 100] | O(n log X) | ✓ |
| 1e5 | 1e5 | [0, 1e9] | O(n log X) | ✓ |
复杂度
- 时间:每次插入/查询 $O(\log X)$
- 空间:每条直线 $O(\log X)$(树节点)
Li Chao 树是你的直线预言家,无论你的斜率和查询变得多么混乱,它总能准备好给出最优的直线。
465 斜率技巧
斜率技巧是一种用于处理分段线性凸函数的动态规划优化技术。它让你能够高效地维护和更新凸成本函数的形状,尤其是在状态转移涉及添加绝对值、移动最小值或合并凸形状等操作时。
我们解决什么问题?
许多动态规划问题涉及最小化一个随时间改变形状的成本函数,例如:
$$ dp[i] = \min_x (dpi-1 + |x - a_i|) $$
在这里,$dp[i]$ 不是一个单一的值,而是关于 $x$ 的一个函数。 斜率技巧就是我们如何高效地将这个函数维护为一系列线性段的方法。
这出现在以下情况:
- 你需要添加 |x - a| 项
- 你需要将整个函数向左或向右平移
- 你需要添加常数或合并最小值
我们不是存储完整的函数,而是存储关键的“断点”并以对数时间进行更新。
适用场景
斜率技巧适用于以下情况:
- 成本函数是凸的且分段线性
- 每次状态转移具有以下形式之一:
- $f(x) + \vert x - a\vert $
- $f(x + c)$ 或 $f(x - c)$
- $\min_x(f(x)) + c$
- 你需要在变化的选项中追踪最小成本
常见于:
- 中位数动态规划
- 路径对齐
- 凸平滑
- 最小化绝对差之和
- 成本平衡问题
工作原理(通俗解释)
与其每次都重新计算整个成本函数, 我们维护两个优先队列(堆)来追踪斜率发生变化的位置。
将成本函数想象成由直线构成的山脉:
- 添加 $\vert x - a\vert $ 意味着在 $a$ 处放置一个“帐篷”
- 将 $x$ 向左/右移动会平移山脉
- 最小值点可以移动,但保持易于找到
我们使用堆来追踪斜率的左侧和右侧断点:
- 左侧堆(最大堆):存储最小值左侧的斜率
- 右侧堆(最小堆):存储最小值右侧的斜率
每个操作以 $O(\log n)$ 的时间更新这些堆。
示例问题
最小化: $$ dp[i] = \min_x(dpi-1 + |x - a_i|) $$
我们希望得到到所有 $a_1, a_2, ..., a_i$ 的最小总距离。
最优的 $x$ 是目前所见所有 $a$ 的中位数。
斜率技巧高效地维护这个函数。
精简代码(简易版本)
Python
import heapq
class SlopeTrick:
def __init__(self):
self.left = [] # 最大堆(存储负值)
self.right = [] # 最小堆
self.min_cost = 0
def add_abs(self, a):
if not self.left:
heapq.heappush(self.left, -a)
heapq.heappush(self.right, a)
return
if a < -self.left[0]:
heapq.heappush(self.left, -a)
val = -heapq.heappop(self.left)
heapq.heappush(self.right, val)
self.min_cost += -self.left[0] - a
else:
heapq.heappush(self.right, a)
val = heapq.heappop(self.right)
heapq.heappush(self.left, -val)
self.min_cost += a - val
def get_min(self):
return self.min_cost
# 示例
st = SlopeTrick()
for a in [3, 1, 4, 1, 5]:
st.add_abs(a)
print("最小成本:", st.get_min())这个结构高效地追踪了最小化绝对差之和的成本。
为什么重要
- 将基于函数的动态规划简化为堆更新
- 为凸最小化问题提供了优雅的解决方案
- 以 $O(\log n)$ 的时间处理 |x - a|、平移和常数加法
- 避免了对连续 $x$ 的离散化
逐步示例
假设我们按顺序添加点:$a = [3, 1, 4]$
- 添加 $\vert x - 3\vert $:最小值在 $x = 3$
- 添加 $\vert x - 1\vert $:最小值移动到 $x = 2$
- 添加 $\vert x - 4\vert $:最小值移动到 $x = 3$
堆动态地追踪这些平衡点。 总成本是最小平移距离之和。
一个温和的证明(为什么有效)
添加 $\vert x - a\vert $ 会改变斜率:
- 对于 $x < a$,斜率增加 $-1$
- 对于 $x > a$,斜率增加 $+1$
因此,函数保持凸性。 堆存储斜率穿过零(即最小值)的位置。
平衡堆使得斜率相等,确保最小值位于中位数。 每个操作都正确地维护了凸性并更新了成本。
动手尝试
- 实现
add_shift(c)来水平平移函数。 - 求解: $$ dp[i] = \min_x(dpi-1 + |x - a_i|) $$ 对于给定的 $a_i$ 列表。
- 添加
add_constant(c)用于垂直平移。 - 使用堆追踪运行中位数。
- 可视化斜率的演变,它应该总是形成一个“V”形。
测试用例
| 输入 | 预期最小值 |
|---|---|
| [3] | 0 |
| [3, 1] | 2 |
| [3, 1, 4] | 3 |
| [3, 1, 4, 1] | 5 |
复杂度
- 时间复杂度:$O(n\log n)$
- 空间复杂度:$O(n)$
斜率技巧就像是动态规划的折纸艺术,你折叠和平移凸函数,一次一段地塑造出最小路径的形状。
466 单调队列优化
单调队列优化是一种动态规划加速技术,适用于涉及滑动窗口或范围受限最小值的递推关系。它用一个单调双端队列取代了朴素的扫描($O(nk)$),从而在 $O(n)$ 时间内找到最优状态。
我们要解决什么问题?
我们希望优化以下形式的 DP:
$$ dp[i] = \min_{j \in [i-k,, i-1]} (dp[j] + cost(j, i)) $$
或者更简单的情况,当 $cost(j, i)$ 是单调的或可分离的,例如 $w_i$ 或 $c(i-j)$ 时,我们可以维护一个候选 $j$ 的窗口。
这种模式出现在:
- 滑动窗口 DP
- 具有窗口约束的 DAG 中的最短路径问题
- 队列调度问题
- 带约束的子序列或分段 DP
何时适用
当满足以下条件时,可以应用单调队列优化:
- 状态转移使用了连续的 $j$ 值范围(像一个窗口)
- 代价函数是单调的,允许剪枝掉不好的状态
- 你想高效地找到滑动窗口上的 $\min$ 或 $\max$
常见形式:
- $dp[i] = \min_{j \in [i-k, i]} (dp[j] + c[j])$
- $dp[i] = \max_{j \in [i-k, i]} (dp[j] + w[i])$
这个技巧不要求凸性,只要求转移范围内的单调顺序。
它是如何工作的(通俗解释)
与其为每个 $i$ 检查所有 $k$ 个前驱状态, 我们维护一个双端队列,其中存放着仍然可能最优的索引。
在每个步骤中:
- 移除旧的索引(超出窗口的)
- 弹出更差的状态(其值比新状态差)
- 双端队列的队首给出当前 $i$ 的最佳 $j$
这确保了双端队列是单调的(根据求最小或最大,递增或递减)。
示例递推式
$$ dp[i] = \min_{j \in [i-k, i-1]} (dp[j] + w_i) $$
由于 $w_i$ 不依赖于 $j$,我们只需要过去 $k$ 个索引中的 $\min dp[j]$。
微型代码(简易版本)
Python
from collections import deque
def min_sliding_window_dp(arr, k):
n = len(arr)
dp = [0] * n
dq = deque()
for i in range(n):
# 移除窗口外的元素
while dq and dq[0] < i - k:
dq.popleft()
# 弹出更差的元素
while dq and dp[dq[-1]] >= dp[i - 1] if i > 0 else False:
dq.pop()
# 压入当前元素
dq.append(i)
# 计算 dp
dp[i] = arr[i] + (dp[dq[0]] if dq else 0)
return dpC
#include <stdio.h>
#define N 100000
#define INF 1000000000
int dp[N], a[N], q[N];
int main() {
int n = 5, k = 2;
int front = 0, back = 0;
int arr[5] = {3, 1, 4, 1, 5};
dp[0] = arr[0];
q[back++] = 0;
for (int i = 1; i < n; i++) {
while (front < back && q[front] < i - k) front++;
dp[i] = dp[q[front]] + arr[i];
while (front < back && dp[q[back - 1]] >= dp[i]) back--;
q[back++] = i;
}
for (int i = 0; i < n; i++) printf("%d ", dp[i]);
}为什么它很重要
- 将范围最小 DP 从 $O(nk)$ 转换为 $O(n)$
- 对于具有窗口约束的问题至关重要
- 避免了堆的开销(常数时间更新)
- 极其简单且鲁棒
逐步示例
设 $arr = [3,1,4,1,5]$, $k = 2$
在 $i = 2$ 时:
- 候选:$j \in [0, 1]$
- dp[0]=3, dp[1]=4
- 剪枝掉更差的值后,dq = [1]
- dp[2] = arr[2] + dp[1] = 4 + 4 = 8
随着窗口滑动,双端队列移动,始终保存着潜在的最小值。
一个温和的证明(为什么它有效)
在每个 $i$ 处:
- 移除索引 $< i-k$(超出范围)
- 在双端队列中维护 dp 值的单调顺序
- 队首始终给出窗口中最小的 $dp[j]$
- 因为每个元素只被压入和弹出一次,总操作数 = $O(n)$
因此,总体复杂度是线性的。
亲自尝试
- 通过反转比较来实现求最大值的版本。
- 将其应用于 $dp[i] = \min_{j \in [i-k, i]} (dp[j] + c_i)$
- 可视化每一步双端队列的演变。
- 解决具有有限跳跃大小的约束路径问题。
- 与朴素的 $O(nk)$ 方法比较运行时间。
测试用例
| 输入 | k | 预期结果 |
|---|---|---|
| [3,1,4,1,5] | 2 | 快速最小 DP |
| [10,9,8,7,6] | 3 | 递减 |
| [1,2,3,4,5] | 1 | 简单 |
复杂度
- 时间:$O(n)$
- 空间:$O(k)$
单调队列优化是你的滑动窗口预言家,只保留合适的候选者,并毫不留恋地抛弃其余。
467 位集动态规划
位集动态规划是一种性能优化技术,它利用位级并行性来加速动态规划,特别是在状态转移涉及大范围的布尔操作时。通过将状态表示为位,可以使用快速的位运算符同时处理多个状态转移。
我们要解决什么问题?
我们想要优化如下形式的动态规划:
$$ dp[i] = \text{考虑前 } i \text{ 个元素后可到达的状态} $$
在子集和、背包问题、路径存在性或掩码传播等问题中,我们经常处理这样的状态:
- 每个状态为真/假
- 状态转移是移位或组合位
例如,在子集和问题中: $$ dp[i][s] = dp[i-1][s] \lor dp[i-1][s - a_i] $$ 我们可以将其压缩为位集移位操作。
适用场景
当满足以下条件时,可以使用位集动态规划:
- 状态是布尔值(真/假)
- 状态转移是基于移位或加法操作
- 状态空间密集且有界
常见用例:
- 子集和问题($O(nS / w)$)
- 有界背包问题
- 图可达性
- 回文子串动态规划
- 使用位掩码计数
其中,$w$ 是字长(例如 64),可提供高达 64 倍的加速。
工作原理(通俗解释)
将每个动态规划层表示为一个位集,每一位表示一个状态是否可达。
对于子集和问题:
初始状态:
dp[0] = 1(和 0 可达)对于每个数字
a:- 左移
a位 → 新的可达和 - 合并:
dp |= dp << a
- 左移
示例: 将 3 加到集合 {0, 2, 5} 意味着左移 3 位 → {3, 5, 8}。
所有这些只需一条 CPU 指令!
示例递推关系
$$ dp[s] = dp[s] \lor dp[s - a] $$
位集形式:
$$ dp = dp \lor (dp \ll a) $$
简单代码示例
Python(使用整数位集)
def subset_sum(nums, target):
dp = 1 # 位 0 = 和 0 可达
for a in nums:
dp |= dp << a
return (dp >> target) & 1 # 检查目标位是否被设置
print(subset_sum([3, 2, 7], 5)) # True (3+2)
print(subset_sum([3, 2, 7], 6)) # FalseC(使用位集)
#include <stdio.h>
#include <string.h>
#define MAXS 10000
#define W 64
unsigned long long dp[MAXS / W + 1];
void setbit(int i) { dp[i / W] |= 1ULL << (i % W); }
int getbit(int i) { return (dp[i / W] >> (i % W)) & 1; }
int main() {
memset(dp, 0, sizeof(dp));
setbit(0); // 和 0 可达
int a[] = {3, 2, 7}, n = 3;
for (int i = 0; i < n; i++) {
int v = a[i];
for (int j = MAXS / W; j >= 0; j--) {
unsigned long long shifted = dp[j] << v;
if (j + v / W + 1 <= MAXS / W)
dp[j + v / W + 1] |= dp[j] >> (W - (v % W));
dp[j] |= shifted;
}
}
printf("和 5 可达? %d\n", getbit(5));
}为什么重要
- 利用硬件并行性
- 适用于密集布尔动态规划
- 可用于子集和、范围转移、图掩码等问题
- 通过简单操作实现巨大加速
逐步示例
假设 nums = [2, 3], target = 5
开始:dp = 1 → {0}
处理 2 后: dp << 2 = {2} dp |= dp << 2 = {0, 2}
处理 3 后: dp << 3 = {3, 5} dp |= dp << 3 = {0, 2, 3, 5}
位 5 被设置 → 和 5 可达 ✅
简单证明(为什么有效)
每次移位对应于将一个元素添加到子集和中。 按位或操作合并了可达的和。 没有重叠冲突,每个位唯一对应一个和。 经过所有移位操作后,所有由子集形成的和都被表示出来。
每次移位-或操作在 $O(S / w)$ 时间内运行, 其中 $S$ = 目标和,$w$ = 字长。
动手尝试
- 通过重复的移位-或操作实现有界背包问题。
- 计算不同的子集和数量(dp 的 popcount)。
- 应用于回文动态规划:$dp[i][j] = s[i] == s[j] \land dp[i+1][j-1]$。
- 可视化每一步后的位模式。
- 在大 $S$ 情况下与普通动态规划进行基准测试。
测试用例
| nums | target | 结果 |
|---|---|---|
| [3, 2, 7] | 5 | True |
| [3, 2, 7] | 6 | False |
| [1, 2, 3, 4] | 10 | True |
复杂度
- 时间复杂度:$O(nS / w)$
- 空间复杂度:$O(S / w)$
位集动态规划是你的布尔加速器,将缓慢的循环转变为极快的位操作。
468 离线动态规划查询
离线动态规划查询是一种通过重新排序或批量处理查询,以高效计算动态规划状态空间上查询的策略。我们不是在查询到达时立即回答(在线),而是在排序或分组后处理它们,从而实现更快的状态转移或范围更新。
我们解决什么问题?
你可能有一个随时间演变的动态规划或递推关系,以及一组询问特定状态或区间值的查询,例如:
- "在所有更新之后,$dp[x]$ 是多少?"
- "索引在 [L, R] 范围内的最小代价是多少?"
- "有多少可达状态满足条件 C?"
如果查询出现时就立即回答,会导致重复计算。 通过离线处理它们,我们可以利用排序、前缀累积或数据结构重用。
适用场景
离线动态规划查询方法适用于以下情况:
- 查询可以排序(按时间、索引或键)
- 状态转移或状态单调演变
- 可以批量更新并重用结果
常见案例:
- 范围动态规划查询:[L, R] 区间上的 $dp[i]$
- 单调状态动态规划(如凸包或线段动态规划)
- 在动态规划状态上应用莫队算法
- 增量动态规划,其中 $dp[i]$ 在查询前已确定
工作原理(通俗解释)
我们不按顺序即时回答,而是:
- 收集所有查询
- 按相关维度(如时间或索引)排序
- 增量处理动态规划转移
- 在所需状态可用时回答查询
可以将其视为“一次性向前推进”,并回答沿途遇到的所有问题。
通过将查询顺序与输入顺序解耦, 你可以避免重复计算,并利用动态规划的单调进展。
示例问题
你被问到 $q$ 个查询:
对于每个 $x_i$,在所有 $j \le x_i$ 中,$dp[j] + cost(j, x_i)$ 的最小值是多少?
朴素方法:$O(nq)$。 离线方法:按 $x_i$ 对查询排序, 处理 $j = 1 \ldots n$, 并维护当前的动态规划结构(如线段树或凸包)。
简单代码(简易版本)
Python(使用运行中的动态规划处理排序后的查询)
def offline_dp(arr, queries):
# arr 定义了动态规划转移
# queries = [(x, idx)]
n = len(arr)
dp = [0] * (n + 1)
res = [0] * len(queries)
queries.sort() # 按 x 排序
ptr = 0
for i in range(1, n + 1):
dp[i] = dp[i-1] + arr[i-1]
# 处理 x == i 的查询
while ptr < len(queries) and queries[ptr][0] == i:
_, idx = queries[ptr]
res[idx] = dp[i]
ptr += 1
return res
arr = [3, 1, 4, 1, 5]
queries = [(3, 0), (5, 1)]
print(offline_dp(arr, queries)) # [前 3 个元素的和, 前 5 个元素的和]C(排序查询)
#include <stdio.h>
#include <stdlib.h>
typedef struct { int x, idx; } Query;
int cmp(const void *a, const void *b) {
return ((Query*)a)->x - ((Query*)b)->x;
}
int main() {
int arr[] = {3, 1, 4, 1, 5}, n = 5;
Query q[] = {{3,0},{5,1}};
int dp[6] = {0}, res[2];
qsort(q, 2, sizeof(Query), cmp);
int ptr = 0;
for (int i = 1; i <= n; i++) {
dp[i] = dp[i-1] + arr[i-1];
while (ptr < 2 && q[ptr].x == i) {
res[q[ptr].idx] = dp[i];
ptr++;
}
}
printf("%d %d\n", res[0], res[1]); // dp[3], dp[5]
}为什么重要
- 将重复的查询更新转换为一次前向遍历
- 支持范围优化(线段树、凸包技巧等)
- 将复杂度从 $O(nq)$ 降低到 $O(n + q \log n)$ 或更好
- 对于混合查询和动态规划更新的问题至关重要
分步示例
假设我们计算累积和 $dp[i] = dp[i-1] + a[i]$ 并且查询随机询问 $dp[x]$:
朴素方法:
- 为每个查询重新计算:$O(qn)$
离线方法:
- 按 $x$ 对查询排序
- 单次遍历 $O(n + q)$
如果查询依赖于单调索引,同样的原理也适用于复杂的动态规划。
一个温和的证明(为什么有效)
如果动态规划状态在某个维度(索引或时间)上单调演变, 那么在计算完 $dp[1]$ 到 $dp[x]$ 之后, 所有边界 ≤ $x$ 的查询的答案就确定了。
排序确保我们永远不会重新计算旧状态, 并且每个查询恰好看到它需要的内容,不多不少。
因此,每个动态规划转移和查询都只被处理一次, 从而产生 $O(n + q)$ 的总复杂度。
动手尝试
- 为前缀和实现离线查询
- 与凸包技巧结合处理排序后的 $x_i$
- 使用线段树处理范围最小值动态规划查询
- 实现离线子集和查询(按和 ≤ X)
- 与在线查询比较性能
测试用例
| arr | queries | Output |
|---|---|---|
| [3,1,4,1,5] | [3,5] | [8,14] |
| [2,2,2] | [1,2,3] | [2,4,6] |
复杂度
- 时间:$O(n + q \log q)$
- 空间:$O(n + q)$
离线动态规划查询就像是你的时间旅行者,通过将未来的问题重新安排成一次对过去的高效扫描来回答它们。
469 DP + 线段树
DP + 线段树是一种混合优化模式,它将动态规划与线段树(或树状数组)相结合,以高效处理涉及区间查询(最小值、最大值、求和)的状态转移。当每个 DP 状态依赖于一个区间的前置状态,而非单个索引时,这种方法尤其有用。
我们要解决什么问题?
我们希望计算:
$$ dp[i] = \min_{l_i \le j \le r_i}(dp[j] + cost[j]) $$
或者更一般地,
$$ dp[i] = \text{在区间 [L(i), R(i)] 上对 } dp[j] \text{ 的某个函数进行聚合} $$
当状态转移跨越区间时,朴素地对每个区间进行迭代的复杂度是 $O(n^2)$。 线段树通过支持区间查询和点更新,可以将复杂度降低到 $O(n \log n)$。
适用场景
在以下情况使用 DP + 线段树:
- 状态转移依赖于区间或范围
- DP 递推式在索引顺序上是单调的
- 你需要对子集进行快速的最小值、最大值或求和查询
典型问题:
- 基于区间的背包问题变体
- 具有区间成本的序列划分
- 区间调度 DP
- 带权的最长递增子序列
- 具有分段边界的路径查找
工作原理(通俗解释)
与其遍历所有 $j$ 来寻找最佳的前置状态,不如:
- 将 $dp[j]$ 值存储在线段树中
- 查询区间 $[L(i), R(i)]$ 的最小值/最大值
- 加上转移成本,并将 $dp[i]$ 存回
这样,每一步:
- 查询 = $O(\log n)$
- 更新 = $O(\log n)$ 总计 = $O(n \log n)$
示例递推式
$$ dp[i] = \min_{j < i,, a_j < a_i}(dp[j]) + cost(i) $$
如果 $a_i$ 的值可以排序或压缩, 我们可以高效地查询线段树以获取所有满足 $a_j < a_i$ 的值。
微型代码(简易版本)
Python
INF = 1018
class SegmentTree:
def __init__(self, n):
self.N = 1
while self.N < n:
self.N *= 2
self.data = [INF] * (2 * self.N)
def update(self, i, val):
i += self.N
self.data[i] = val
while i > 1:
i //= 2
self.data[i] = min(self.data[2*i], self.data[2*i+1])
def query(self, l, r):
l += self.N
r += self.N
res = INF
while l < r:
if l % 2:
res = min(res, self.data[l])
l += 1
if r % 2:
r -= 1
res = min(res, self.data[r])
l //= 2
r //= 2
return res
def dp_segment_tree(arr):
n = len(arr)
dp = [INF] * n
seg = SegmentTree(n)
dp[0] = arr[0]
seg.update(0, dp[0])
for i in range(1, n):
best = seg.query(max(0, i - 2), i) # 例如,范围 [i-2, i-1]
dp[i] = arr[i] + best
seg.update(i, dp[i])
return dp
arr = [3, 1, 4, 1, 5]
print(dp_segment_tree(arr))C
#include <stdio.h>
#define INF 1000000000
#define N 100005
int seg[4*N], dp[N], arr[N];
int min(int a, int b) { return a < b ? a : b; }
void update(int idx, int val, int id, int l, int r) {
if (l == r) { seg[id] = val; return; }
int mid = (l + r) / 2;
if (idx <= mid) update(idx, val, 2*id, l, mid);
else update(idx, val, 2*id+1, mid+1, r);
seg[id] = min(seg[2*id], seg[2*id+1]);
}
int query(int ql, int qr, int id, int l, int r) {
if (qr < l || r < ql) return INF;
if (ql <= l && r <= qr) return seg[id];
int mid = (l + r) / 2;
return min(query(ql, qr, 2*id, l, mid),
query(ql, qr, 2*id+1, mid+1, r));
}
int main() {
int arr[] = {3,1,4,1,5}, n = 5;
for (int i = 0; i < 4*N; i++) seg[i] = INF;
dp[0] = arr[0];
update(0, dp[0], 1, 0, n-1);
for (int i = 1; i < n; i++) {
int best = query(i-2 >= 0 ? i-2 : 0, i-1, 1, 0, n-1);
dp[i] = arr[i] + best;
update(i, dp[i], 1, 0, n-1);
}
for (int i = 0; i < n; i++) printf("%d ", dp[i]);
}为何重要
- 高效处理区间转移
- 将二次方的 DP 复杂度降低到 $O(n \log n)$
- 适用于最小值和最大值递推式
- 可与坐标压缩结合处理复杂区间
逐步示例
令 $arr = [3, 1, 4, 1, 5]$,且 $$ dp[i] = arr[i] + \min_{j \in [i-2, i-1]} dp[j] $$
- $i=0$: $dp[0]=3$
- $i=1$: 查询 $[0,0]$,$dp[1]=1+3=4$
- $i=2$: 查询 $[0,1]$,$dp[2]=4+1=5$
- $i=3$: 查询 $[1,2]$,$dp[3]=1+4=5$
- $i=4$: 查询 $[2,3]$,$dp[4]=5+4=9$
一个温和的证明(为何有效)
线段树存储区间最小值。 每个 DP 状态仅依赖于先前已确定的值。 随着 $i$ 向前移动,你查询和更新的是不相交的区间。 因此总复杂度为:
$$ O(n \log n) \text{ (n 次查询 + n 次更新)} $$
无需重新计算,每次转移都通过树在对数时间内解决。
动手尝试
- 将递推式改为最大值并调整线段树。
- 解决带权最长递增子序列问题:$dp[i] = w_i + \max_{a_j < a_i} dp[j]$。
- 与坐标压缩结合,处理任意的 $a_i$。
- 可视化迭代过程中线段树的内容。
- 应用于具有重叠窗口的区间调度问题。
测试用例
| arr | 区间 | 输出 |
|---|---|---|
| [3,1,4,1,5] | [i-2,i-1] | [3,4,5,5,9] |
| [2,2,2,2] | [i-1,i-1] | [2,4,6,8] |
复杂度
- 时间:$O(n \log n)$
- 空间:$O(n)$
线段树 + DP 是你的区间预言家,无需扫描整个过去,就能回答每一个区间依赖问题。
470 分治背包
分治背包是一种优化方法,它通过递归地拆分物品集合并合并结果来加速大容量背包问题的动态规划,而不是构建完整的 $O(nW)$ DP 表。当您需要重建解决方案或处理跨子集的查询时,它尤其强大。
我们要解决什么问题?
经典的 0/1 背包问题是:
$$ dp[i][w] = \max(dp[i-1][w],, dp[i-1][w - w_i] + v_i) $$
其中 $w_i$ 是物品的重量,$v_i$ 是其价值。
这种标准 DP 在时间和空间上的成本为 $O(nW)$,当 $n$ 或 $W$ 很大时,这变得不可行。
分治背包通过将物品分成两半并递归地解决子问题来解决这个问题,这种策略类似于中间相遇法,但适用于 DP。
适用场景
在以下情况下使用分治背包:
- 您有很多物品 ($n > 1000$)
- 容量 $W$ 很大,但可以通过组合管理
- 您需要部分解决方案重建
- 您想处理批量查询(例如,每个容量范围的最佳价值)
常见场景:
- 大的 $n$,适中的 $W$(跨子集拆分)
- 枚举可行状态
- 物品集的离线处理
- 递归解决方案生成(用于决策树或子集枚举)
它是如何工作的(通俗解释)
与其构建一个巨大的 DP 表, 不如将物品列表分成两半:
- 解决左半部分 → 获取所有可实现的 $(重量, 价值)$ 对
- 解决右半部分 → 同上
- 高效地合并结果(如卷积或扫描)
通过递归地组合子问题, 您可以减少总的重计算,并实现可行子集的并行合并。
如果 $n = 2^k$,递归深度 = $O(\log n)$,每次合并成本为 $O(2^{n/2})$,当 $W$ 很大时,比 $O(nW)$ 快得多。
示例递推关系
让 solve(l, r) 计算物品 $[l, r)$ 的所有可行对:
如果 r - l == 1:
返回 {(0,0), (w_l, v_l)}
否则:
mid = (l + r) / 2
left = solve(l, mid)
right = solve(mid, r)
返回 combine(left, right)combine 合并来自左半部分和右半部分的对(类似于合并排序列表,只保留帕累托最优对)。
简化代码(简易版本)
Python
def combine(left, right, W):
res = []
for w1, v1 in left:
for w2, v2 in right:
w = w1 + w2
if w <= W:
res.append((w, v1 + v2))
# 只保留每个重量下的最佳价值(帕累托前沿)
res.sort()
best = []
cur = -1
for w, v in res:
if v > cur:
best.append((w, v))
cur = v
return best
def solve(items, W):
n = len(items)
if n == 1:
w, v = items[0]
return [(0, 0), (w, v)] if w <= W else [(0, 0)]
mid = n // 2
left = solve(items[:mid], W)
right = solve(items[mid:], W)
return combine(left, right, W)
items = [(3, 4), (4, 5), (7, 10), (8, 11)]
W = 10
print(solve(items, W)) # [(0,0),(3,4),(4,5),(7,10),(8,11),(10,14)]为什么它很重要
- 当 $W$ 很大时,避免了完整的 $O(nW)$ DP
- 支持离线合并和解决方案重建
- 在中间相遇法优化中很有用
- 可以通过重新组合子集来处理动态约束
分步示例
物品: $(3,4), (4,5), (7,10), (8,11)$, $W = 10$
拆分:
- 左半部分:$(3,4), (4,5)$ → 可行集 = {(0,0),(3,4),(4,5),(7,9)}
- 右半部分:$(7,10), (8,11)$ → {(0,0),(7,10),(8,11),(15,21)}
合并所有 $(w_L + w_R, v_L + v_R)$ ≤ 10:
- (0,0), (3,4), (4,5), (7,9), (7,10), (8,11), (10,14)
帕累托最优:
- (0,0), (3,4), (4,5), (7,10), (10,14)
$W=10$ 时的最大价值:14
一个温和的证明(为什么它有效)
通过递归拆分:
- 每个子集的组合在 $O(2^{n/2})$ 内被枚举
- 合并步骤确保只有非支配状态被传递下去
- 递归恰好覆盖所有子集一次
因此,总成本 ≈ $O(2^{n/2})$ 而不是 $O(nW)$。
对于适中的 $n$ (≤40),这要快得多。
对于有约束(有界重量)的大 $n$,合并减少到每层 $O(n \log n)$。
亲自尝试
- 实现仅价值背包(最大价值 ≤ W)
- 在每次合并后可视化帕累托前沿
- 使用递归树打印中间 DP 状态
- 与标准 $O(nW)$ DP 结果进行比较
- 扩展到多维重量
测试用例
| 物品 | W | 结果 |
|---|---|---|
| [(3,4),(4,5),(7,10),(8,11)] | 10 | 14 |
| [(1,1),(2,2),(3,3)] | 3 | 3 |
| [(2,3),(3,4),(4,5)] | 5 | 7 |
复杂度
- 时间:$O(2^{n/2} \cdot n)$(中间相遇法)
- 空间:$O(2^{n/2})$
分治背包是您的递归工匠,通过组合两半而不是填充表格来构建最优子集。
第 48 节. 树形 DP 与换根法
471 子树和动态规划
子树和动态规划是树形动态规划中最基础的模式之一。它通过简单的后序遍历计算每个节点子树中的值之和。一旦掌握了如何对子树进行聚合,你就可以将同样的思路扩展到处理子树大小、深度、计数或任何具有结合律的性质。
我们要解决什么问题?
给定一棵有根树,其中每个节点都有一个值,计算每个节点其子树(包括自身)中所有值的和。
对于一个节点 $u$,其子节点为 $v_1, v_2, \dots, v_k$,子树和的计算公式为:
$$ dp[u] = value[u] + \sum_{v \in children(u)} dp[v] $$
这个思路可以推广到多种聚合形式,例如计算节点数量、求子树大小或计算子树乘积。
它是如何工作的(通俗解释)
把每个节点想象成一个小计算器。 当一个节点完成其所有子节点和的计算后,它将这些和加起来,再加上自身的值。 这就是后序遍历,从叶子节点向上计算。
逐步示例
考虑以下树:
1(5)
/ \
2(3) 3(2)
/ \
4(1) 5(4)节点值:
- 节点 1 → 5
- 节点 2 → 3
- 节点 3 → 2
- 节点 4 → 1
- 节点 5 → 4
我们自底向上计算:
- $dp[4] = 1$
- $dp[5] = 4$
- $dp[2] = 3 + 1 + 4 = 8$
- $dp[3] = 2$
- $dp[1] = 5 + 8 + 2 = 15$
因此,子树和如下:
| 节点 | 子树和 |
|---|---|
| 1 | 15 |
| 2 | 8 |
| 3 | 2 |
| 4 | 1 |
| 5 | 4 |
精简代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int value[MAXN];
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN];
int dfs(int u, int parent) {
dp[u] = value[u];
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == parent) continue;
dp[u] += dfs(v, u);
}
return dp[u];
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
int vals[] = {0,5,3,2,1,4};
for (int i = 1; i <= n; i++) value[i] = vals[i];
dfs(1, -1);
for (int i = 1; i <= n; i++) printf("dp[%d] = %d\n", i, dp[i]);
}Python
from collections import defaultdict
n = 5
edges = [(1,2),(1,3),(2,4),(2,5)]
value = {1:5, 2:3, 3:2, 4:1, 5:4}
g = defaultdict(list)
for u,v in edges:
g[u].append(v)
g[v].append(u)
dp = {}
def dfs(u, p):
dp[u] = value[u]
for v in g[u]:
if v == p: continue
dfs(v, u)
dp[u] += dp[v]
dfs(1, -1)
print(dp)为什么它很重要
- 核心的树形 DP 模式:许多问题都可以归结为对子树进行聚合。
- 构成了换根 DP、树直径和重心分解的基础。
- 用于计算子树大小、子树异或和、深度和、子树计数等。
一旦掌握了子树 DP,你就可以将其推广到:
- 子树最大值/最小值
- 统计经过节点的路径数量
- 动态换根转移
一个温和的证明(为什么它有效)
基于树深度的归纳法:
- 基本情况:对于叶子节点 $u$,$dp[u] = value[u]$,根据定义这是正确的。
- 归纳步骤:假设所有子节点 $v$ 的 $dp[v]$ 都是正确的。 那么 $dp[u] = value[u] + \sum dp[v]$ 正确地累加了 $u$ 子树中的所有值。
由于每个节点被访问一次,每条边被访问两次,总时间复杂度为 $O(n)$。
动手尝试
- 修改代码以计算子树大小而非和。
- 追踪每个子树中的最大值。
- 扩展以计算每个子树的深度和。
- 添加换根功能,计算以每个节点为根的子树和。
- 使用输入解析器来构建任意树。
测试用例
| 树结构 | 节点值 | 子树和 |
|---|---|---|
| 1–2–3 | {1:1,2:2,3:3} | {1:6, 2:5, 3:3} |
| 1–2, 1–3 | {1:5,2:2,3:1} | {1:8, 2:2, 3:1} |
复杂度分析
- 时间复杂度:$O(n)$(每个节点访问一次)
- 空间复杂度:$O(n)$ 递归 + 邻接表
子树和动态规划是你接触树形动态规划的第一步,一次遍历,全局洞察。
472 直径动态规划
直径动态规划(Diameter DP)用于计算树中的最长路径,即直径。与最短路径不同,直径是指任意两个节点之间的最大距离,这条路径不一定经过根节点。通过使用动态规划,我们可以在一次深度优先搜索遍历中,通过组合每个节点的两条最深子路径来推导出直径。
我们要解决什么问题?
给定一棵具有 $n$ 个节点的树(无权重或有权重),找到其直径,即任意两个节点之间最长简单路径的长度。
对于无权重树,长度以边数或节点数衡量;对于有权重树,长度以边权重之和衡量。
我们定义一个动态规划递推关系:
$$ dp[u] = \text{从节点 } u \text{ 出发的最长向下路径的长度} $$
在每个节点处,直径的候选值是其两条最长子路径之和:
$$ diameter = \max(diameter, top1 + top2) $$
它是如何工作的(通俗解释)
将每个节点视为连接其子节点路径的枢纽。 经过一个节点的最长路径,是通过选取其两条最深的子路径并将它们连接起来形成的。 我们在执行后序深度优先搜索的过程中收集这个值。
最终,所有节点中的全局最大值就是树的直径。
逐步示例
树结构:
1
/ \
2 3
/ \
4 5每条边的权重为 1。
计算最长向下路径:
- 叶子节点 (4, 5, 3): $dp=0$
- 节点 2: $dp[2] = 1 + \max(dp[4], dp[5]) = 1 + 0 = 1$
- 节点 1: $dp[1] = 1 + \max(dp[2], dp[3]) = 1 + 1 = 2$
现在计算直径:
- 在节点 2 处: top1=0, top2=0 → 局部直径=0
- 在节点 1 处: top1=1 (来自节点 2), top2=1 (来自节点 3) → 局部直径=2
因此,树的直径 = 2 条边 (路径 4–2–1–3)
简洁代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int diameter = 0;
int dfs(int u, int p) {
int top1 = 0, top2 = 0;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
int depth = 1 + dfs(v, u);
if (depth > top1) {
top2 = top1;
top1 = depth;
} else if (depth > top2) {
top2 = depth;
}
}
if (top1 + top2 > diameter) diameter = top1 + top2;
return top1;
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs(1, -1);
printf("树的直径: %d 条边\n", diameter);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
diameter = 0
def dfs(u, p):
global diameter
top1 = top2 = 0
for v in g[u]:
if v == p: continue
depth = 1 + dfs(v, u)
if depth > top1:
top2 = top1
top1 = depth
elif depth > top2:
top2 = depth
diameter = max(diameter, top1 + top2)
return top1
dfs(1, -1)
print("树的直径:", diameter)为什么它很重要
- 是树分析、网络半径、中心点查找的核心构建模块
- 用于涉及最长路径、树高、重心分解的问题
- 是换根或重心算法中的关键步骤
一个温和的证明(为什么它有效)
我们用归纳法来证明其正确性:
- 基本情况:叶子节点的 $dp[u] = 0$,除了自身外没有贡献。
- 归纳步骤:对于每个内部节点 $u$,如果其所有子节点 $v$ 都正确计算了它们的最长向下路径 $dp[v]$,那么组合其中最大的两条就得到了经过 $u$ 的最长路径。 由于树中的每条路径都经过某个最低公共祖先 $u$,我们的深度优先搜索会在全局找到真正的最大值。
动手尝试
- 修改代码以处理带权边
- 返回直径路径的两个端点
- 与两次 BFS 方法(两次选取最远节点)进行比较
- 扩展以同时计算每个子树的高度
- 用局部直径可视化递归树
测试用例
| 树结构 | 直径 |
|---|---|
| 链状: 1–2–3–4 | 3 条边 |
| 星状: 1–{2,3,4,5} | 2 条边 |
| 平衡二叉树 (深度 2) | 4 条边 |
复杂度
- 时间复杂度: $O(n)$
- 空间复杂度: $O(n)$ 递归栈
直径动态规划是揭示树之最长气息的透镜,一次扫描,尽览全貌。
473 独立集动态规划
独立集动态规划用于寻找树中最大的节点集合,使得任意两个被选中的节点都不相邻。这是一个经典的树形动态规划问题,展示了在每个节点上包含与排除之间的基本权衡。
我们要解决什么问题?
给定一棵有 $n$ 个节点的树(每个节点可能有权重),找到最大权重的独立集,即一个节点的子集,使得没有两个相连的节点被选中。
对于每个节点 $u$,我们维护两个 DP 状态:
- $dp[u][0]$:当 $u$ 未被选中时,其子树中的最大值
- $dp[u][1]$:当 $u$ 被选中时,其子树中的最大值
递推关系:
$$ dp[u][0] = \sum_{v \in children(u)} \max(dp[v][0], dp[v][1]) $$
$$ dp[u][1] = value[u] + \sum_{v \in children(u)} dp[v][0] $$
答案是 $\max(dp[root][0], dp[root][1])$。
它是如何工作的(通俗解释)
每个节点做出决定:
- 如果它包含自己,则排除它的子节点
- 如果它排除自己,则可以为每个子节点选择最佳结果
这种“取或舍”的策略自底向上地从叶子节点流向根节点。
逐步示例
树:
1(3)
/ \
2(2) 3(1)
/
4(4)括号内为节点值。
从底部开始计算 DP:
节点 4: $dp[4][0]=0$, $dp[4][1]=4$
节点 2: $dp[2][0]=\max(dp[4][0],dp[4][1])=4$ $dp[2][1]=2+dp[4][0]=2+0=2$
节点 3: $dp[3][0]=0$, $dp[3][1]=1$
节点 1: $dp[1][0]=\max(dp[2][0],dp[2][1])+\max(dp[3][0],dp[3][1])=4+1=5$ $dp[1][1]=3+dp[2][0]+dp[3][0]=3+4+0=7$
答案:$\max(5,7)=7$
最佳集合 = {1,4}
简洁代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int value[MAXN];
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN][2];
int visited[MAXN];
void dfs(int u, int p) {
dp[u][0] = 0;
dp[u][1] = value[u];
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dfs(v, u);
dp[u][0] += (dp[v][0] > dp[v][1] ? dp[v][0] : dp[v][1]);
dp[u][1] += dp[v][0];
}
}
int main() {
n = 4;
int edges[][2] = {{1,2},{1,3},{2,4}};
for (int i = 0; i < 3; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
int vals[] = {0,3,2,1,4};
for (int i = 1; i <= n; i++) value[i] = vals[i];
dfs(1, -1);
int ans = dp[1][0] > dp[1][1] ? dp[1][0] : dp[1][1];
printf("最大独立集和: %d\n", ans);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
value = {1:3, 2:2, 3:1, 4:4}
dp = {}
def dfs(u, p):
include = value[u]
exclude = 0
for v in g[u]:
if v == p: continue
dfs(v, u)
include += dp[v][0]
exclude += max(dp[v][0], dp[v][1])
dp[u] = (exclude, include)
dfs(1, -1)
print("最大独立集和:", max(dp[1]))为什么它很重要
- 是基于树的约束问题的基础
- 用于网络稳定性、资源分配、调度
- 可扩展到加权图、森林、范围约束
由此衍生的模式:
- 顶点覆盖(补集)
- 树上的“打家劫舍”
- 动态包含-排除状态
一个温和的证明(为什么它有效)
我们通过归纳法证明:
- 基本情况:叶子节点 $u$ 有 $dp[u][1]=value[u]$, $dp[u][0]=0$,正确。
- 归纳步骤:对于任何节点 $u$,如果所有子树都计算出了最优值, 包含 $u$ 会加上它的值并排除其子节点($dp[v][0]$), 排除 $u$ 则允许为每个子节点选择最佳值($\max(dp[v][0], dp[v][1])$)。 因此每个子树都是最优的,从而保证了全局最优性。
动手尝试
- 扩展到加权边(每条边有成本)
- 修改以重构被选中的节点
- 为森林(多棵树)实现
- 与顶点覆盖动态规划进行比较
- 应用于“打家劫舍 III”(Leetcode 337)
测试用例
| 树 | 值 | 答案 |
|---|---|---|
| 1–2–3 | {1:3,2:2,3:1} | {1,3} 和=4 |
| 星形 1–{2,3,4} | {1:5, 其他:3} | {2,3,4} 和=9 |
| 链 1–2–3–4 | {1:1,2:4,3:5,4:4} | {2,4} 和=8 |
复杂度
- 时间:$O(n)$
- 空间:$O(n)$ 递归
独立集动态规划捕捉了树的静谧平衡,每个节点的选择都在其分支中回响。
474 顶点覆盖 DP
顶点覆盖 DP 解决了一个经典的树优化问题:选择最小的节点集合,使得树中的每条边至少有一个端点被选中。这是对独立集 DP 的补充,两者共同构成了组合优化中的一对对偶问题。
我们正在解决什么问题?
给定一棵有 $n$ 个节点的树,找到最小顶点覆盖,即一个最小的节点子集,使得每条边 $(u,v)$ 都有 $u$ 或 $v$ 在集合中。
我们为每个节点 $u$ 定义两种状态:
- $dp[u][0]$:当 $u$ 不被包含时,以 $u$ 为根的子树中的最小顶点覆盖大小
- $dp[u][1]$:当 $u$ 被包含时,以 $u$ 为根的子树中的最小顶点覆盖大小
递推关系:
$$ dp[u][0] = \sum_{v \in children(u)} dp[v][1] $$
$$ dp[u][1] = 1 + \sum_{v \in children(u)} \min(dp[v][0], dp[v][1]) $$
如果 $u$ 不在覆盖集中,为了覆盖边 $(u,v)$,它的所有子节点 $v$ 必须被包含。 如果 $u$ 被包含,每个子节点可以选择是否加入覆盖集。
答案:$\min(dp[root][0], dp[root][1])$
它是如何工作的(通俗解释)
每个节点决定是自己承担覆盖边的责任,还是将责任委托给其子节点。 这是一个互斥约束:
- 如果 $u$ 被排除,则其子节点必须被包含。
- 如果 $u$ 被包含,则每个子节点可以自由选择。
逐步示例
树:
1
/ \
2 3
/ \
4 5我们自底向上计算:
叶子节点 4, 5: $dp[4][0]=0$, $dp[4][1]=1$ $dp[5][0]=0$, $dp[5][1]=1$
节点 2: $dp[2][0]=dp[4][1]+dp[5][1]=2$ $dp[2][1]=1+\min(dp[4][0],dp[4][1])+\min(dp[5][0],dp[5][1])=1+0+0=1$
节点 3: $dp[3][0]=0$, $dp[3][1]=1$
节点 1: $dp[1][0]=dp[2][1]+dp[3][1]=1+1=2$ $dp[1][1]=1+\min(dp[2][0],dp[2][1])+\min(dp[3][0],dp[3][1])=1+1+0=2$
结果:$\min(2,2)=2$
最小顶点覆盖大小 = 2
精简代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN][2];
int visited[MAXN];
void dfs(int u, int p) {
dp[u][0] = 0;
dp[u][1] = 1;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dfs(v, u);
dp[u][0] += dp[v][1];
dp[u][1] += (dp[v][0] < dp[v][1] ? dp[v][0] : dp[v][1]);
}
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs(1, -1);
int ans = dp[1][0] < dp[1][1] ? dp[1][0] : dp[1][1];
printf("Minimum vertex cover: %d\n", ans);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
dp = {}
def dfs(u, p):
include = 1
exclude = 0
for v in g[u]:
if v == p: continue
dfs(v, u)
exclude += dp[v][1]
include += min(dp[v][0], dp[v][1])
dp[u] = (exclude, include)
dfs(1, -1)
print("Minimum vertex cover:", min(dp[1]))为什么它很重要
- 是树上约束满足问题的基础
- 与独立集 DP 对偶(通过补集关系)
- 用于网络设计、任务监控、传感器布置
许多(树上的)图算法都依赖于这种“覆盖或跳过”的二分法,包括:
- 支配集
- 守卫问题
- 二叉树中的最小摄像头(Leetcode 968)
一个温和的证明(为什么它有效)
通过归纳法证明:
基本情况:叶子节点 $u$: $dp[u][0]=0$(如果不被覆盖,边必须由父节点覆盖),$dp[u][1]=1$(包含自身)。
归纳步骤: 如果所有子节点都有最优覆盖,
- 排除 $u$ 强制包含所有 $v$(确保边 $(u,v)$ 被覆盖)。
- 包含 $u$ 允许为子节点提供灵活的最优选择。 由于树没有环,每个节点的局部决策产生了全局最优性。
自己动手试试
- 打印实际的覆盖集(从 DP 回溯)
- 扩展到带权顶点覆盖(将计数替换为权重和)
- 与独立集 DP 比较,显示补集大小
- 使用后序遍历实现迭代版本
- 应用于二叉树中的最小摄像头问题
测试用例
| 树 | 结果 | 覆盖集 |
|---|---|---|
| 1–2–3 | 1–2–3 | {2} |
| 星型 1–{2,3,4,5} | {1} | |
| 链 1–2–3–4 | {2,4} 或 {1,3} |
复杂度
- 时间复杂度:$O(n)$
- 空间复杂度:$O(n)$
顶点覆盖 DP 展示了包含一个节点如何保护其关联的边,在整个树中平衡了经济性和完备性。
475 路径计数动态规划
路径计数动态规划是进入树组合数学的一个温和切入点,它帮助你计算在特定条件下存在多少条不同的路径,例如从根到叶、节点对之间或具有特定约束的路径。它培养了一种基于结构而非暴力枚举的计数直觉。
我们要解决什么问题?
给定一棵有 $n$ 个节点的树,我们想要计算满足某种性质的路径数量。最简单的形式是计算从根到叶的路径数量,但我们可以推广到:
- 所有节点对之间的路径总数
- 具有权重约束的路径
- 具有特定节点属性的路径
我们将从最基本的形式——根到叶路径——开始。
定义:
$$ dp[u] = \text{从节点 } u \text{ 开始的路径数量} $$
对于一棵有根树,每个节点的路径数等于其子节点路径数之和。如果 $u$ 是叶子节点,它贡献 $1$ 条路径(即它自身):
$$ dp[u] = \begin{cases} 1, & \text{如果 } u \text{ 是叶子节点},\ \displaystyle\sum_{v \in \text{children}(u)} dp[v], & \text{否则}. \end{cases} $$
根到叶路径的总数 = $dp[root]$。
它是如何工作的(通俗解释)
从叶子节点开始,每个叶子节点就是一条完整的路径。 每个父节点累加其子节点的路径: “从我孩子出发的每条路径,也构成从我出发的一条路径。” 这个过程向上传播,直到根节点持有总计数。
逐步示例
树结构:
1
/ \
2 3
/ \
4 5自底向上计算 $dp$:
- $dp[4] = 1$, $dp[5] = 1$, $dp[3] = 1$
- $dp[2] = dp[4] + dp[5] = 2$
- $dp[1] = dp[2] + dp[3] = 3$
因此,有 3 条根到叶路径:
- 1–2–4
- 1–2–5
- 1–3
简短代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN];
int dfs(int u, int p) {
int count = 0;
int isLeaf = 1;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
isLeaf = 0;
count += dfs(v, u);
}
if (isLeaf) return dp[u] = 1;
return dp[u] = count;
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs(1, -1);
printf("Root-to-leaf paths: %d\n", dp[1]);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
dp = {}
def dfs(u, p):
is_leaf = True
count = 0
for v in g[u]:
if v == p: continue
is_leaf = False
count += dfs(v, u)
dp[u] = 1 if is_leaf else count
return dp[u]
dfs(1, -1)
print("Root-to-leaf paths:", dp[1])为什么它重要
- 是树上计数问题的基础
- 构成了路径和动态规划、树动态规划换根和组合枚举的基础
- 在概率模型和决策树中至关重要
- 对概率传播和分支过程模拟很有用
一个温和的证明(为什么它有效)
我们可以用归纳法证明:
- 基本情况:叶子节点 $u$ 恰好有一条路径,即它自身。 所以 $dp[u]=1$。
- 归纳步骤:假设所有子节点 $v$ 都计算出了正确的计数 $dp[v]$。 那么 $dp[u] = \sum dp[v]$ 就计算了所有经过 $u$ 的不同根到叶路径。
由于每条路径都由其第一个分支决策唯一标识,我们永远不会重复计数。
亲自尝试
- 修改代码以计算所有简单路径(节点对 $(u,v)$)。
- 添加边权重,计算总和不大于 $K$ 的路径。
- 使用递归栈跟踪并打印所有根到叶路径。
- 扩展到有向无环图。
- 结合换根法计算经过每个节点的路径数。
测试用例
| 树结构 | 路径数 |
|---|---|
| 1–2–3 | 1 条路径 |
| 星形 (1–{2,3,4}) | 3 条路径 |
| 深度为 2 的二叉树 | 3 条路径 |
复杂度
- 时间复杂度:$O(n)$(每个节点访问一次)
- 空间复杂度:$O(n)$ 递归
路径计数动态规划展示了结构如何转化为数字,一次遍历,所有路径尽在掌握。
476 有根树上的动态规划
有根树上的动态规划是树形动态规划最通用的模式,它教你如何在层次结构上思考状态。每个子树贡献部分答案,父节点将它们组合起来。这几乎是所有基于树的动态规划的基础构建块:求和、计数、距离、约束等等。
我们要解决什么问题?
我们希望基于每个节点的子树计算其属性,例如:
- 子树和
- 子树大小
- 最大深度
- 路径计数
- 模乘积
- 组合计数
给定一棵有根树,我们定义一个动态规划函数,递归地从每个子节点收集结果并聚合它们。
通用形式:
$$ dp[u] = f(u, {dp[v] : v \in children(u)}) $$
你需要定义:
- 基本情况(通常针对叶子节点)
- 转移函数(组合子节点的结果)
- 合并操作(求和、最大值、最小值、乘法等)
它是如何工作的(通俗解释)
你从叶子节点向上思考。 每个节点:
- 从其子节点收集结果。
- 应用一个组合函数。
- 存储最终值。
这种自底向上的推理方式类似于后序遍历,先解决子节点,再解决父节点。
其强大之处在于 $f$ 可以表示任何操作:求和、最小值、最大值,甚至是位掩码合并。
逐步示例
让我们为每个节点计算子树大小(其子树中的节点数):
递推关系:
$$ dp[u] = 1 + \sum_{v \in children(u)} dp[v] $$
树结构:
1
/ \
2 3
/ \
4 5自底向上计算:
- $dp[4]=1$, $dp[5]=1$
- $dp[2]=1+1+1=3$
- $dp[3]=1$
- $dp[1]=1+3+1=5$
因此:
- $dp[1]=5$
- $dp[2]=3$
- $dp[3]=1$
- $dp[4]=1$
- $dp[5]=1$
微型代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN];
int dfs(int u, int p) {
dp[u] = 1; // 计算自身
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dp[u] += dfs(v, u);
}
return dp[u];
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs(1, -1);
for (int i = 1; i <= n; i++)
printf("dp[%d] = %d\n", i, dp[i]);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
dp = {}
def dfs(u, p):
dp[u] = 1
for v in g[u]:
if v == p: continue
dp[u] += dfs(v, u)
return dp[u]
dfs(1, -1)
print(dp)为什么它很重要
- 是任何树形动态规划问题的核心模板
- 为子树求和、深度计数、模乘积聚合、路径计数和换根等算法提供动力
- 是高级换根动态规划的基础,其中答案依赖于父节点和兄弟节点的状态
一旦掌握了这个模式,你就可以:
- 改变递推关系 → 改变问题
- 添加约束 → 引入多个动态规划状态
- 扩展到具有 DAG 结构的图
一个温和的证明(为什么它有效)
对树的高度进行归纳:
- 基本情况:叶子节点 $u$ → $dp[u]$ 初始化为基值(例如 1 或 value[u])。
- 归纳步骤:假设所有子节点 $v$ 都计算出了正确的 $dp[v]$。 那么 $f(u, {dp[v]})$ 就能正确地聚合子树结果。
由于树是无环的,后序遍历保证了子节点先被处理,因此正确性得以保证。
自己动手试试
- 改变递推关系以计算子树值的和。
- 计算最大子树深度。
- 跟踪每个子树中叶子的数量。
- 扩展到双状态动态规划,例如包含/排除逻辑。
- 结合换根技术,为每个根节点计算值。
测试用例
| 树结构 | 子树大小 |
|---|---|
| 1–2–3 | {1:3, 2:2, 3:1} |
| 星形 1–{2,3,4,5} | {1:5, others:1} |
| 链式 1–2–3–4 | {1:4,2:3,3:2,4:1} |
复杂度
- 时间复杂度:$O(n)$
- 空间复杂度:$O(n)$ 递归
有根树上的动态规划是你的画布,定义 $f$,并将结构的逻辑绘制在其分支上。
477 换根技术
换根技术是树形动态规划中一种强大的模式,它允许你在线性时间内计算以每个节点为根时的结果。我们不是为每个根节点从头重新计算,而是通过重用已计算的子树结果来高效地“换根”。
我们要解决什么问题?
假设我们已经计算了以某个固定节点(例如节点 $1$)为根时的某个属性(如子树和、子树大小、距离和)。 现在我们想计算以所有节点为根时的相同属性,例如:
- 从每个节点到所有其他节点的距离之和
- 以不同节点为根时,每个子树的大小或代价
- 依赖于根节点位置的路径数量或贡献计数
我们定义:
$$ dp[u] = f({dp[v]}_{v \in children(u)}) $$
但我们还想要 $res[u]$,即以 $u$ 为根时树的答案。
通过换根,我们可以沿着边转移结果,移动根节点并仅更新局部贡献。
它是如何工作的(通俗解释)
我们首先执行一次后序 DFS,为每个节点计算 $dp[u]$,就好像根是固定的(例如节点 1)。
然后,第二次先序 DFS 将结果“向外推”——当根从父节点 $u$ 移动到子节点 $v$ 时,我们调整贡献:
- 从父节点中移除子节点的部分
- 将父节点的部分添加到子节点
每个换根步骤都以常数或很小的时间从 $res[u]$ 更新 $res[v]$。
这种两遍遍历的结构是换根 DP 的标志:
- 向下遍历:收集子树结果
- 向上遍历:传播父节点贡献
逐步示例:距离之和
目标:为每个节点计算到所有其他节点的距离之和。
树:
1
/ \
2 3
/ \
4 5步骤 1:后序遍历(子树和 + 大小)
对于每个节点:
- $subtree_size[u]$ = $u$ 子树中的节点数
- $dp[u]$ = 从 $u$ 到其子树中所有节点的距离之和
递推关系:
$$ subtree_size[u] = 1 + \sum subtree_size[v] $$
$$ dp[u] = \sum (dp[v] + subtree_size[v]) $$
在根节点 (1) 处,$dp[1]=8$(从节点 1 到所有节点的距离之和)。
步骤 2:先序遍历(换根)
当根从 $u$ 换到 $v$ 时:
- 将根移离 $v$ 会增加 $(n - subtree_size[v])$
- 将根移向 $v$ 会减去 $subtree_size[v]$
因此: $$ dp[v] = dp[u] + (n - 2 \times subtree_size[v]) $$
现在每个节点都在 $O(n)$ 时间内得到了其距离和。
微型代码(距离之和)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int dp[MAXN], subtree[MAXN], res[MAXN];
void dfs1(int u, int p) {
subtree[u] = 1;
dp[u] = 0;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dfs1(v, u);
subtree[u] += subtree[v];
dp[u] += dp[v] + subtree[v];
}
}
void dfs2(int u, int p) {
res[u] = dp[u];
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
int pu = dp[u], pv = dp[v];
int su = subtree[u], sv = subtree[v];
// 将根从 u 移动到 v
dp[u] -= dp[v] + subtree[v];
subtree[u] -= subtree[v];
dp[v] += dp[u] + subtree[u];
subtree[v] += subtree[u];
dfs2(v, u);
// 恢复
dp[v] = pv;
dp[u] = pu;
subtree[v] = sv;
subtree[u] = su;
}
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs1(1, -1);
dfs2(1, -1);
for (int i = 1; i <= n; i++)
printf("res[%d] = %d\n", i, res[i]);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
n = 5
dp = {i:0 for i in range(1,n+1)}
sub = {i:1 for i in range(1,n+1)}
res = {}
def dfs1(u,p):
sub[u]=1
dp[u]=0
for v in g[u]:
if v==p: continue
dfs1(v,u)
sub[u]+=sub[v]
dp[u]+=dp[v]+sub[v]
def dfs2(u,p):
res[u]=dp[u]
for v in g[u]:
if v==p: continue
pu,pv=dp[u],dp[v]
su,sv=sub[u],sub[v]
dp[u]-=dp[v]+sub[v]
sub[u]-=sub[v]
dp[v]+=dp[u]+sub[u]
sub[v]+=sub[u]
dfs2(v,u)
dp[u],dp[v]=pu,pv
sub[u],sub[v]=su,sv
dfs1(1,-1)
dfs2(1,-1)
print(res)为什么它很重要
- 在 $O(n)$ 时间内计算所有节点的答案
- 在距离和、换根子树查询、基于重心的算法中至关重要
- 是 AtCoder、Codeforces、Leetcode 上树形换根 DP 问题的核心模式
换根将单根逻辑转化为全根知识。
一个温和的证明(为什么它有效)
- 第一遍确保每个节点知道其子树的贡献。
- 第二遍应用常数时间更新来移动根:
- 移除子节点的贡献
- 添加父节点的补集
由于每条边被遍历两次,总成本是线性的。
亲自尝试
- 计算每个可能根节点下的子树节点数。
- 计算每个根节点下的深度之和。
- 修改递推关系以计算子树值的乘积。
- 应用于树平衡:最小化总距离。
- 扩展到带权树。
测试用例
| 树 | 根节点 | 距离和 |
|---|---|---|
| 1–2–3 | 1 | 3 |
| 1–2–3 | 2 | 2 |
| 1–2–3 | 3 | 3 |
复杂度
- 时间:$O(n)$
- 空间:$O(n)$
换根 DP 是你的算法万花筒,旋转根节点,即可看到所有视角。
478 距离和重定根
距离和重定根是重定根技术的一个经典而优雅的应用。它能在 O(n) 时间内,为树中的每个节点计算到所有其他节点的距离之和。这是展示重定根如何将局部子树数据转化为全局洞察的最简洁的例子之一。
我们要解决什么问题?
给定一棵有 $n$ 个节点的树,对于每个节点 $u$,计算:
$$ res[u] = \sum_{v=1}^{n} \text{dist}(u, v) $$
一种朴素的方法(从每个节点运行 BFS)需要 $O(n^2)$ 时间。 我们将使用重定根动态规划,通过两次 DFS 遍历来完成。
它是如何工作的(通俗解释)
第一次遍历(后序): 将树任意定根在一个节点(例如节点 1)。 计算:
- $subtree[u]$:以 $u$ 为根的子树的大小
- $dp[u]$:从 $u$ 到其子树中所有节点的距离之和
递推关系:
$$ subtree[u] = 1 + \sum_{v \in children(u)} subtree[v] $$
$$ dp[u] = \sum_{v \in children(u)} (dp[v] + subtree[v]) $$
第二次遍历(先序): 使用重定根从 $res[u]$ 计算 $res[v]$:
$$ res[v] = res[u] + (n - 2 \times subtree[v]) $$
- 将根从 $u$ 移动到 $v$ 时:
- 在 $v$ 子树内的节点距离减少 1
- 在 $v$ 子树外的节点距离增加 1
- 因此净变化 = $-subtree[v] + (n - subtree[v]) = n - 2 \times subtree[v]$
- 将根从 $u$ 移动到 $v$ 时:
$res[1] = dp[1]$(根节点的初始结果)
此后,每个节点都得到了其距离之和,整个过程是线性时间。
逐步示例
树:
1
/ \
2 3
/ \
4 5步骤 1(后序)
- $dp[4]=0,\ dp[5]=0,\ dp[3]=0$
- $subtree[4]=1,\ subtree[5]=1,\ subtree[3]=1$
- $subtree[2]=1+1+1=3,\ dp[2]=dp[4]+dp[5]+subtree[4]+subtree[5]=0+0+1+1=2$
- $subtree[1]=1+3+1=5,\ dp[1]=dp[2]+dp[3]+subtree[2]+subtree[3]=2+0+3+1=6$
所以 $res[1]=6$。
步骤 2(重定根)
- $res[2]=res[1]+(5-2\times3)=6-1=5$
- $res[3]=res[1]+(5-2\times1)=6+3=9$
- $res[4]=res[2]+(5-2\times1)=5+3=8$
- $res[5]=res[2]+(5-2\times1)=5+3=8$
✅ 最终结果:
- $res[1]=6$, $res[2]=5$, $res[3]=9$, $res[4]=8$, $res[5]=8$
简洁代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
int n;
int adj[MAXN][MAXN], deg[MAXN];
int subtree[MAXN], dp[MAXN], res[MAXN];
void dfs1(int u, int p) {
subtree[u] = 1;
dp[u] = 0;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dfs1(v, u);
subtree[u] += subtree[v];
dp[u] += dp[v] + subtree[v];
}
}
void dfs2(int u, int p) {
res[u] = dp[u];
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dp[v] = dp[u] + (n - 2 * subtree[v]);
dfs2(v, u);
}
}
int main() {
n = 5;
int edges[][2] = {{1,2},{1,3},{2,4},{2,5}};
for (int i = 0; i < 4; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs1(1, -1);
dfs2(1, -1);
for (int i = 1; i <= n; i++)
printf("从节点 %d 出发的距离和 = %d\n", i, res[i]);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2),(1,3),(2,4),(2,5)]
for u,v in edges:
g[u].append(v)
g[v].append(u)
n = 5
dp = {i:0 for i in range(1,n+1)}
sub = {i:1 for i in range(1,n+1)}
res = {}
def dfs1(u,p):
sub[u]=1
dp[u]=0
for v in g[u]:
if v==p: continue
dfs1(v,u)
sub[u]+=sub[v]
dp[u]+=dp[v]+sub[v]
def dfs2(u,p):
res[u]=dp[u]
for v in g[u]:
if v==p: continue
dp[v]=dp[u]+(n-2*sub[v])
dfs2(v,u)
dfs1(1,-1)
dfs2(1,-1)
print(res)为什么它很重要
- 在线性时间内计算每个节点的距离和
- 是一个基础的重定根例子,适用于许多其他度量(和、积、最小/最大值)
- 可扩展到带权边、有向树和重心分解
- 在图分析、网络延迟、树平衡、动态中心计算中很有用
一个温和的证明(为什么它有效)
每个重定根步骤通过考虑变得更近或更远的节点来调整距离和:
- 在新根的子树内的节点($subtree[v]$):距离减少 1
- 其他节点($n - subtree[v]$):距离增加 1
所以:
$$ res[v] = res[u] + (n - 2 \times subtree[v]) $$
通过对边进行归纳,每个节点都得到了正确的总距离。
自己动手试试
- 扩展到带权边。
- 计算每个节点的平均距离。
- 与重心查找(最小化 $res[u]$ 的节点)结合。
- 可视化根滑动时 $res$ 的变化。
- 适用于有向根树。
测试用例
| 树 | 节点 | 结果 |
|---|---|---|
| 1–2–3 | 1 | 3 |
| 1–2–3 | 2 | 2 |
| 1–2–3 | 3 | 3 |
复杂度
- 时间:$O(n)$
- 空间:$O(n)$
距离和重定根展示了树中对称性的美妙之处:移动根,更新世界,保持一切平衡。
479 树着色动态规划
树着色动态规划是一种通用的模式,用于解决在局部约束下对树进行着色和标记的问题,例如,计算当相邻节点不能共享颜色时有多少种有效着色方案,或在邻接限制下最小化成本。它结合了状态定义和子节点聚合,形成了算法竞赛中最常见的模板之一。
我们正在解决什么问题?
给定一棵有 $n$ 个节点的树,我们希望用 $k$ 种颜色之一为每个节点着色,使得任意两个相邻节点颜色不同,并计算有效着色方案的数量。
形式化地说,找到分配 $color[u] \in {1,2,\dots,k}$ 的方法数,使得对于每条边 $(u,v)$,都有 $color[u] \ne color[v]$。
我们还可以进行泛化:
- 加权版本(每种颜色的成本)
- 受限版本(预着色节点)
- 模数计数($\bmod\ 10^9+7$)
这里,我们将解决基本的未加权计数版本。
它是如何工作的(通俗解释)
我们进行有根动态规划,其中每个节点决定其颜色,并将其子节点的有效组合相乘。
如果一个节点 $u$ 被着色为 $c$,那么它的每个子节点 $v$ 可以选择除 $c$ 之外的任何颜色。
因此,对于每个节点:
$$ dp[u][c] = \prod_{v \in children(u)} \sum_{\substack{c' = 1 \ c' \ne c}}^{k} dp[v][c'] $$
最后,总计数为:
$$ \text{答案} = \sum_{c=1}^{k} dp[root][c] $$
因为树是无环的,我们可以安全地组合子树而不会重复计数。
逐步示例
树:
1
/ \
2 3$k=3$ 种颜色 (1,2,3)
从叶子节点开始:
- 对于叶子节点 $v$,所有 $c \in {1,2,3}$ 都有 $dp[v][c] = 1$(任何颜色都有效)
现在节点 2 和 3:
- $dp[2] = dp[3] = [1,1,1]$
在节点 1 处:
- $dp[1][1] = \prod_{child} \sum_{c' \ne 1} dp[child][c'] = (1+1)*(1+1)=4$
- $dp[1][2] = (1+1)*(1+1)=4$
- $dp[1][3] = (1+1)*(1+1)=4$
总计 = $4+4+4 = 12$ 种有效着色方案
手动检查:节点 1 有 3 种颜色选择 × 每个子节点有 2 种选择 × 2 个子节点 = 12 ✅
精简代码(简易版本)
C
#include <stdio.h>
#define MAXN 100
#define MAXK 10
#define MOD 1000000007
int n, k;
int adj[MAXN][MAXN], deg[MAXN];
long long dp[MAXN][MAXK+1];
void dfs(int u, int p) {
for (int c = 1; c <= k; c++) dp[u][c] = 1;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p) continue;
dfs(v, u);
for (int c = 1; c <= k; c++) {
long long sum = 0;
for (int c2 = 1; c2 <= k; c2++) {
if (c2 == c) continue;
sum = (sum + dp[v][c2]) % MOD;
}
dp[u][c] = (dp[u][c] * sum) % MOD;
}
}
}
int main() {
n = 3; k = 3;
int edges[][2] = {{1,2},{1,3}};
for (int i = 0; i < 2; i++) {
int a = edges[i][0], b = edges[i][1];
adj[a][deg[a]++] = b;
adj[b][deg[b]++] = a;
}
dfs(1, -1);
long long ans = 0;
for (int c = 1; c <= k; c++) ans = (ans + dp[1][c]) % MOD;
printf("总着色方案数: %lld\n", ans);
}Python
from collections import defaultdict
MOD = 109 + 7
n, k = 3, 3
edges = [(1,2),(1,3)]
g = defaultdict(list)
for u,v in edges:
g[u].append(v)
g[v].append(u)
dp = {}
def dfs(u, p):
dp[u] = [1]*(k+1)
for v in g[u]:
if v == p: continue
dfs(v, u)
new = [0]*(k+1)
for c in range(1, k+1):
total = 0
for c2 in range(1, k+1):
if c2 == c: continue
total = (total + dp[v][c2]) % MOD
new[c] = (dp[u][c] * total) % MOD
dp[u] = new
dfs(1, -1)
ans = sum(dp[1][1:]) % MOD
print("总着色方案数:", ans)为什么它很重要
- 解决树上的着色、标记和分配问题
- 约束满足动态规划的基础
- 可扩展为加权、模数和部分预着色版本
- 应用于图论、组合数学和树结构概率模型
通过小的调整,它可以变为:
- 最小成本着色(用
min替换+) - 约束着色(剪除无效颜色)
- 模数计数(用于组合数学)
一个温和的证明(为什么它有效)
通过对树的高度进行归纳:
- 基本情况:叶子节点 $u$:$dp[u][c]=1$(可以取任何颜色)
- 归纳步骤:假设每个子节点 $v$ 已经计算出正确的 $dp[v][c']$。 对于着色为 $c$ 的节点 $u$,所有子节点 $v$ 必须选择颜色 $c' \ne c$。 求和和相乘确保我们计算了所有有效组合。
不会出现重叠或遗漏,因为树没有环。
自己动手试试
- 添加模数约束(例如 $k=10^5$)。
- 扩展到预着色节点:固定某些 $dp[u][c] = 0/1$。
- 修改递推关系以处理加权着色(每种颜色的成本)。
- 对于大的 $k$,使用前缀-后缀乘积进行优化。
- 应用于具有奇偶约束的二叉树着色。
测试用例
| 树 | n | k | 结果 |
|---|---|---|---|
| 1–2 | 2 | 2 | 2 |
| 1–2–3 | 3 | 3 | 12 |
| 星形 1–{2,3,4} | 4 | 3 | 24 |
复杂度
- 时间:$O(n \times k^2)$
- 空间:$O(n \times k)$
树着色动态规划是你的组合画笔,定义局部规则,遍历一次,用逻辑为整片森林着色。
480 树形动态规划上的二分查找
树形动态规划上的二分查找是一种混合策略,它将树形动态规划与在答案空间上的二分查找相结合。当某个条件的可行性是单调的时,这种方法尤其有用,例如,当询问“在阈值 $T$ 下,是否存在满足约束 X 的子树/路径?”并且答案随着 $T$ 的增加从假变为真时。
我们要解决什么问题?
给定一棵在节点或边上带有权重或值的树,我们希望找到一个最小(或最大)阈值 $T$,使得某个属性成立,例如:
- 所有边权重 ≤ $T$ 的最长路径
- 存在和 ≥ $S$ 的子树的最小 $T$
- 使得某个有效的动态规划状态可达到的最小限制
我们在 $T$ 上进行二分查找,对于每个猜测值,我们在树上运行动态规划来检查条件是否满足。
它是如何工作的(通俗解释)
- 识别一个单调属性,即一旦为真,则保持为真(或一旦为假,则保持为假)。
- 使用树形动态规划定义一个 check(T) 函数,返回该属性是否成立。
- 在 $T$ 上应用二分查找,以找到满足条件的最小(或最大)值。
示例:限制下的最长路径
我们得到一棵带边权重 $w(u,v)$ 的加权树。 找到满足所有边 ≤ T 的最大路径长度。 我们希望找到使得路径长度 ≥ L 的最小 T。
步骤:
- 在 $T$ 上进行二分查找
- 对于每个 $T$,构建边 ≤ $T$ 的子图
- 在树上运行动态规划(例如直径动态规划)来检查是否存在长度 ≥ L 的路径
动态规划设计
我们使用基于深度优先搜索的动态规划,为每个节点计算:
$$ dp[u] = \text{在 } T \text{ 限制下向下的最长路径长度} $$
并组合两个最佳子节点路径来检查直径是否 ≥ L。
微型代码(可行性检查)
C
#include <stdio.h>
#include <string.h>
#define MAXN 100
#define INF 1000000000
int n, L;
int adj[MAXN][MAXN], w[MAXN][MAXN], deg[MAXN];
int best;
int dfs(int u, int p, int T) {
int top1 = 0, top2 = 0;
for (int i = 0; i < deg[u]; i++) {
int v = adj[u][i];
if (v == p || w[u][v] > T) continue;
int len = dfs(v, u, T) + 1;
if (len > top1) { top2 = top1; top1 = len; }
else if (len > top2) top2 = len;
}
if (top1 + top2 >= L) best = 1;
return top1;
}
int check(int T) {
best = 0;
dfs(1, -1, T);
return best;
}
int main() {
n = 4; L = 3;
int edges[3][3] = {{1,2,3},{2,3,5},{3,4,7}};
for (int i = 0; i < 3; i++) {
int a=edges[i][0], b=edges[i][1], c=edges[i][2];
adj[a][deg[a]] = b; w[a][b] = c; deg[a]++;
adj[b][deg[b]] = a; w[b][a] = c; deg[b]++;
}
int lo = 0, hi = 10, ans = -1;
while (lo <= hi) {
int mid = (lo + hi)/2;
if (check(mid)) { ans = mid; hi = mid - 1; }
else lo = mid + 1;
}
printf("Minimum T: %d\n", ans);
}Python
from collections import defaultdict
g = defaultdict(list)
edges = [(1,2,3),(2,3,5),(3,4,7)]
n, L = 4, 3
for u,v,w in edges:
g[u].append((v,w))
g[v].append((u,w))
def dfs(u, p, T):
top1 = top2 = 0
global ok
for v,w in g[u]:
if v == p or w > T: continue
length = dfs(v, u, T) + 1
if length > top1:
top2 = top1
top1 = length
elif length > top2:
top2 = length
if top1 + top2 >= L:
ok = True
return top1
def check(T):
global ok
ok = False
dfs(1, -1, T)
return ok
lo, hi = 0, 10
ans = -1
while lo <= hi:
mid = (lo + hi)//2
if check(mid):
ans = mid
hi = mid - 1
else:
lo = mid + 1
print("Minimum T:", ans)为什么它很重要
- 许多阈值优化问题依赖于二分查找 + 动态规划
- 当成本/限制与基于树的结构相互作用时,这是理想的方法
- 在网络设计、路径约束、树查询、博弈论中很有用
示例:
- 实现连通性的最小边权重
- 满足子树属性的最小节点成本
- 资源限制下的路径可行性
一个温和的证明(为什么它有效)
如果属性是单调的,二分查找保证了正确性:
- 如果条件在 $T$ 处成立,那么它在所有 $T' > T$ 处也成立
- 因此,我们可以搜索满足条件的最小 $T$
树形动态规划正确地检查了可行性,因为它枚举了阈值 $T$ 下所有从根到叶以及子节点到子节点的路径。
亲自尝试
- 修改为最大化值(反转单调性)。
- 将边约束替换为节点值 ≤ T。
- 使用动态规划来计数路径,而不仅仅是检查存在性。
- 应用于有界条件下的最大子树和。
- 扩展到 k 色约束(在成本上进行二分查找)。
测试用例
| n | L | 边 | 输出 |
|---|---|---|---|
| 4 | 3 | (1-2:3, 2-3:5, 3-4:7) | 5 |
| 3 | 2 | (1-2:1, 2-3:2) | 2 |
复杂度
- 每次检查的动态规划:$O(n)$
- 二分查找:$\log(\text{范围})$
- 总计:$O(n\log C)$,其中 $C$ 是最大边权重
树形动态规划上的二分查找连接了可行性逻辑和优化,每当遇到单调阈值和树状态相结合时,就可以使用它。
第 49 节 动态规划的重建与回溯
481 重构 LCS
重构最长公共子序列(LCS)不仅意味着计算其长度,还意味着追溯两个字符串按顺序共享的实际序列。这一步将抽象的 DP 表转化为具体的答案,这是生物信息学、文本差异比较和对齐任务中的常见需求。
我们要解决什么问题?
给定两个序列 $A$(长度为 $n$)和 $B$(长度为 $m$),找到两者共有的最长子序列(不一定是连续的)。
我们首先为 LCS 长度构建一个 DP 表:
$$ dp[i][j] = \begin{cases} 0, & \text{如果 } i = 0 \text{ 或 } j = 0,\ dp[i-1][j-1] + 1, & \text{如果 } A[i-1] = B[j-1],\ \max(dp[i-1][j],\ dp[i][j-1]), & \text{否则.} \end{cases} $$
然后我们从 $dp[n][m]$ 开始回溯以重构序列。
它是如何工作的(通俗解释)
- 使用标准 DP 计算 LCS 长度表。
- 从右下角 ($dp[n][m]$) 开始。
- 回溯:
- 如果 $A[i-1] == B[j-1]$:添加该字符并向对角线方向移动 ($i-1, j-1$)
- 否则向 dp 值更大的方向移动
- 反转收集到的序列。
示例
设 $A = \text{"ABCBDAB"}$, $B = \text{"BDCABA"}$
DP 长度表得到结果 "BCBA"。
精简代码
C
#include <stdio.h>
#include <string.h>
#define MAX 100
int dp[MAX][MAX];
char A[MAX], B[MAX];
char lcs[MAX];
int main() {
scanf("%s %s", A, B);
int n = strlen(A), m = strlen(B);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (A[i-1] == B[j-1])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = dp[i-1][j] > dp[i][j-1] ? dp[i-1][j] : dp[i][j-1];
// 重构
int i = n, j = m, k = dp[n][m];
lcs[k] = '\0';
while (i > 0 && j > 0) {
if (A[i-1] == B[j-1]) {
lcs[--k] = A[i-1];
i--; j--;
} else if (dp[i-1][j] >= dp[i][j-1])
i--;
else
j--;
}
printf("LCS: %s\n", lcs);
}Python
def reconstruct_lcs(A, B):
n, m = len(A), len(B)
dp = [[0]*(m+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, m+1):
if A[i-1] == B[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
i, j = n, m
res = []
while i > 0 and j > 0:
if A[i-1] == B[j-1]:
res.append(A[i-1])
i -= 1
j -= 1
elif dp[i-1][j] >= dp[i][j-1]:
i -= 1
else:
j -= 1
return ''.join(reversed(res))
A = input("A: ")
B = input("B: ")
print("LCS:", reconstruct_lcs(A, B))为什么它很重要
- 是差异比较、合并和 DNA 对齐工具的核心
- 展示了 DP 解决方案如何重构实际解,而不仅仅是计数
- 是许多 DP 问题中回溯技术的基础
一个温和的证明(为什么它有效)
在每一步:
- 如果 $A[i-1] = B[j-1]$,该字符必定属于 LCS,因此我们将其包含并沿对角线移动。
- 否则,更长的 LCS 位于 dp 值更大的方向,因此我们沿着那条路径前进。 通过从 $dp[n][m]$ 开始并向后移动,我们保证每个包含的字符至少是一个最优解的一部分。
由于我们是按相反顺序收集的,反转后得到正确的序列。
亲自尝试
- 手动追踪 "ABCBDAB" 和 "BDCABA" 的 LCS。
- 修改代码以找到所有可能 LCS 中的一个(处理平局情况)。
- 扩展以进行不区分大小写的比较。
- 调整代码以返回匹配字符的索引。
- 在 DP 表中可视化路径箭头。
测试用例
| A | B | LCS |
|---|---|---|
| "ABCBDAB" | "BDCABA" | "BCBA" |
| "AGGTAB" | "GXTXAYB" | "GTAB" |
| "AXYT" | "AYZX" | "AY" |
复杂度
- 时间:$O(nm)$
- 空间:$O(nm)$(仅计算长度可优化至 $O(\min(n,m))$)
重构 LCS 是你从数字表格走向实际解的第一步,连接了推理与现实。
482 重构最长递增子序列
重构最长递增子序列(LIS)不仅意味着找出最长递增序列的长度,还要找出实际的子序列。这是超越计算动态规划(DP)值的经典步骤,它关乎于追溯我们如何到达那里。
我们要解决什么问题?
给定一个数字序列 $A = [a_1, a_2, \dots, a_n]$,我们想要找到最长的严格递增子序列。DP 版本以 $O(n^2)$ 的时间复杂度计算 LIS 长度,但这里我们专注于重构。
我们定义:
$$ dp[i] = \text{以 } i \text{ 结尾的 LIS 的长度} $$
以及一个用于追踪前驱元素的父数组:
$$ parent[i] = \text{在以 } i \text{ 结尾的 LIS 中,前一个元素的索引} $$
最后,我们从最大 $dp[i]$ 的索引开始回溯,以恢复出序列。
它是如何工作的(通俗解释)
- 计算 dp[i]:以 $A[i]$ 结尾的最长 LIS。
- 追踪 parent[i]:这个序列来自哪里。
- 找到最大长度的索引,称之为
best。 - 使用
parent数组进行回溯。 - 反转重构出的列表。
示例
对于 $A = [10, 22, 9, 33, 21, 50, 41, 60]$
我们得到:
dp = [1, 2, 1, 3, 2, 4, 4, 5]- LIS 长度 = 5
- 序列 =
[10, 22, 33, 50, 60]
微型代码
C
#include <stdio.h>
int main() {
int A[] = {10, 22, 9, 33, 21, 50, 41, 60};
int n = sizeof(A) / sizeof(A[0]);
int dp[n], parent[n];
for (int i = 0; i < n; i++) {
dp[i] = 1;
parent[i] = -1;
for (int j = 0; j < i; j++) {
if (A[j] < A[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
parent[i] = j;
}
}
}
// 找到 LIS 的结束索引
int best = 0;
for (int i = 1; i < n; i++)
if (dp[i] > dp[best]) best = i;
// 重构 LIS
int lis[100], len = 0;
for (int i = best; i != -1; i = parent[i])
lis[len++] = A[i];
printf("LIS: ");
for (int i = len - 1; i >= 0; i--)
printf("%d ", lis[i]);
printf("\n");
}Python
def reconstruct_lis(A):
n = len(A)
dp = [1]*n
parent = [-1]*n
for i in range(n):
for j in range(i):
if A[j] < A[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
parent[i] = j
best = max(range(n), key=lambda i: dp[i])
res = []
while best != -1:
res.append(A[best])
best = parent[best]
return res[::-1]
A = [10, 22, 9, 33, 21, 50, 41, 60]
print("LIS:", reconstruct_lis(A))为什么这很重要
- 将抽象的 DP 转化为实际的序列输出
- 在调度、股票分析、子序列模式识别中很有用
- 教授了通过父节点追踪进行回溯的技术,该技术可复用于许多问题
一个温和的证明(为什么它有效)
根据定义,$dp[i]$ 记录了以 $i$ 结尾的 LIS 长度。每当我们更新 $dp[i] = dp[j] + 1$ 时,我们就扩展了以 $j$ 结尾的最佳 LIS。记录 parent[i] = j 让我们能够重构那条路径。
具有最大 $dp[i]$ 的元素必须结束一个 LIS,通过回溯父节点,我们恰好追踪到了一个达到最大长度的有效递增子序列。
亲自尝试
- 为 $[3, 10, 2, 1, 20]$ 追踪 LIS 重构过程。
- 修改代码以返回所有 LIS 序列(处理长度相等的情况)。
- 调整代码以适应非递减 LIS。
- 结合二分查找 LIS 算法,实现 $O(n \log n)$ 时间复杂度 + 父节点追踪。
- 将
parent链接可视化为索引之间的箭头。
测试用例
| 输入 | LIS | 长度 |
|---|---|---|
| [10, 22, 9, 33, 21, 50, 41, 60] | [10, 22, 33, 50, 60] | 5 |
| [3, 10, 2, 1, 20] | [3, 10, 20] | 3 |
| [50, 3, 10, 7, 40, 80] | [3, 7, 40, 80] | 4 |
复杂度
- 时间:$O(n^2)$
- 空间:$O(n)$
重构 LIS 是从计算一个数字到理解其背后故事的温和桥梁,每个元素都通过 DP 表追溯其谱系。
483 重建背包解
重建背包解意味着识别哪些物品构成了最优价值,而不仅仅是知道最大价值。这是理解可能是什么与选择什么之间的区别。
我们解决的是什么问题?
给定:
- $n$ 个物品,其价值为 $v[i]$,重量为 $w[i]$
- 容量 $W$
我们想要:
- 在不超出 $W$ 的情况下最大化总价值
- 恢复被选中的物品
0/1 背包问题的 DP 表定义如下:
$$ dp[i][w] = \begin{cases} 0, & \text{如果 } i = 0 \text{ 或 } w = 0,\ dp[i-1][w], & \text{如果 } w_i > w,\ \max(dp[i-1][w],\ dp[i-1][w - w_i] + v_i), & \text{否则}. \end{cases} $$
为了重建解,我们从 $dp[n][W]$ 开始回溯:
- 如果 $dp[i][w] \neq dp[i-1][w]$,那么物品 $i$ 被包含在内
- 减去其重量,移动到 $i-1$
它是如何工作的(通俗解释)
- 构建标准的 0/1 背包 DP 表。
- 从右下角 $(n, W)$ 开始。
- 比较 $dp[i][w]$ 与 $dp[i-1][w]$:
- 如果不同,则包含物品 $i$,更新 $w -= w_i$。
- 继续直到 $i=0$。
- 反转选中的物品以获得正确的顺序。
示例
设:
| 物品 | 价值 | 重量 |
|---|---|---|
| 1 | 60 | 10 |
| 2 | 100 | 20 |
| 3 | 120 | 30 |
容量 $W = 50$
最优价值 = 220 选中的物品 = {2, 3}
微型代码
C
#include <stdio.h>
#define N 4
#define W 50
int main() {
int val[] = {0, 60, 100, 120};
int wt[] = {0, 10, 20, 30};
int dp[N][W+1];
for (int i = 0; i < N; i++)
for (int w = 0; w <= W; w++)
dp[i][w] = 0;
for (int i = 1; i < N; i++) {
for (int w = 1; w <= W; w++) {
if (wt[i] <= w) {
int include = val[i] + dp[i-1][w-wt[i]];
int exclude = dp[i-1][w];
dp[i][w] = include > exclude ? include : exclude;
} else dp[i][w] = dp[i-1][w];
}
}
printf("最大价值: %d\n", dp[N-1][W]);
printf("选中的物品: ");
int w = W;
for (int i = N-1; i > 0; i--) {
if (dp[i][w] != dp[i-1][w]) {
printf("%d ", i);
w -= wt[i];
}
}
printf("\n");
}Python
def reconstruct_knapsack(values, weights, W):
n = len(values)
dp = [[0]*(W+1) for _ in range(n+1)]
for i in range(1, n+1):
for w in range(W+1):
if weights[i-1] <= w:
dp[i][w] = max(dp[i-1][w],
values[i-1] + dp[i-1][w - weights[i-1]])
else:
dp[i][w] = dp[i-1][w]
# 重建解
w = W
chosen = []
for i in range(n, 0, -1):
if dp[i][w] != dp[i-1][w]:
chosen.append(i-1)
w -= weights[i-1]
chosen.reverse()
return dp[n][W], chosen
values = [60, 100, 120]
weights = [10, 20, 30]
W = 50
value, items = reconstruct_knapsack(values, weights, W)
print("最大价值:", value)
print("物品:", items)为什么这很重要
- 将价值表转化为可操作的决策
- 在优化问题(资源分配、预算编制)中至关重要
- 展示了从 DP 矩阵回溯的逻辑
一个温和的证明(为什么它有效)
每个 $dp[i][w]$ 表示在前 $i$ 个物品中,容量为 $w$ 时的最佳价值。 如果 $dp[i][w] \neq dp[i-1][w]$,物品 $i$ 对于提升价值至关重要,因此必须被包含在内。 通过减去其重量并向上移动来重复相同的逻辑,从而追踪出一个最优解。
自己动手试试
- 修改以处理多个最优解(存储父路径)。
- 实现空间优化的 DP 并使用回溯信息重建解。
- 适配无界背包问题(物品可重复使用)。
- 添加总重量输出。
- 可视化从 $dp[n][W]$ 出发的重建箭头。
测试用例
| 价值值 | 重量值 | W | 最大价值 | 物品 |
|---|---|---|---|---|
| [60, 100, 120] | [10, 20, 30] | 50 | 220 | [1, 2] |
| [10, 20, 30] | [1, 1, 1] | 2 | 50 | [1, 2] |
| [5, 4, 6, 3] | [2, 3, 4, 5] | 5 | 7 | [0, 1] |
复杂度
- 时间:$O(nW)$
- 空间:$O(nW)$(如果只关心价值,可以降低到 $O(W)$)
重建解将背包问题从一个数学结果转变为现实世界中的选择列表,揭示了哪些物品使得最优成为可能。
484 编辑距离对齐
编辑距离告诉我们两个字符串有多不同,对齐重建则精确地展示它们在哪里不同。通过追踪操作(插入、删除、替换)的路径,我们可以可视化完整的转换过程。
我们要解决什么问题?
给定两个字符串 $A$(长度为 $n$)和 $B$(长度为 $m$),不仅要计算编辑距离,还要计算使用最少操作次数将 $A$ 转换为 $B$ 的对齐方式。
我们定义:
$$ dp[i][j] = \begin{cases} 0, & \text{如果 } i = 0 \text{ 且 } j = 0,\ i, & \text{如果 } j = 0,\ j, & \text{如果 } i = 0,\[6pt] \displaystyle \min!\begin{cases} dp[i-1][j] + 1, & \text{删除},\ dp[i][j-1] + 1, & \text{插入},\ dp[i-1][j-1] + \text{cost}(A[i-1], B[j-1]), & \text{替换或匹配。} \end{cases} \end{cases} $$
然后,我们从 $dp[n][m]$ 回溯,以逆序列出操作。
它是如何工作的(通俗解释)
- 构建标准的莱文斯坦(Levenshtein)动态规划表。
- 从 $dp[n][m]$ 开始。
- 移动:
- 对角线方向:匹配或替换
- 向上:删除
- 向左:插入
- 记录每次移动时的操作。
- 反转序列以获得最终对齐。
示例
令 $A=\text{"kitten"}$, $B=\text{"sitting"}$。
操作:
| 步骤 | 操作 | 结果 |
|---|---|---|
| k → k | 匹配 | kitten / sitting |
| i → i | 匹配 | kitten / sitting |
| t → t | 匹配 | kitten / sitting |
| t → t | 匹配 | kitten / sitting |
| e → i | 替换 | kitti n |
| 插入 g | 插入 | kitting |
编辑距离 = 3(替换 e→i,插入 g,插入 n)
精简代码
C
#include <stdio.h>
#include <string.h>
#define MAX 100
int dp[MAX][MAX];
int min3(int a, int b, int c) {
return a < b ? (a < c ? a : c) : (b < c ? b : c);
}
int main() {
char A[MAX], B[MAX];
scanf("%s %s", A, B);
int n = strlen(A), m = strlen(B);
for (int i = 0; i <= n; i++) dp[i][0] = i;
for (int j = 0; j <= m; j++) dp[0][j] = j;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
int cost = (A[i-1] == B[j-1]) ? 0 : 1;
dp[i][j] = min3(dp[i-1][j] + 1,
dp[i][j-1] + 1,
dp[i-1][j-1] + cost);
}
}
printf("编辑距离: %d\n", dp[n][m]);
printf("操作:\n");
int i = n, j = m;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && dp[i][j] == dp[i-1][j-1] && A[i-1] == B[j-1]) {
printf("匹配 %c\n", A[i-1]);
i--; j--;
}
else if (i > 0 && j > 0 && dp[i][j] == dp[i-1][j-1] + 1) {
printf("替换 %c -> %c\n", A[i-1], B[j-1]);
i--; j--;
}
else if (i > 0 && dp[i][j] == dp[i-1][j] + 1) {
printf("删除 %c\n", A[i-1]);
i--;
}
else {
printf("插入 %c\n", B[j-1]);
j--;
}
}
}Python
def edit_distance_alignment(A, B):
n, m = len(A), len(B)
dp = [[0]*(m+1) for _ in range(n+1)]
for i in range(n+1):
dp[i][0] = i
for j in range(m+1):
dp[0][j] = j
for i in range(1, n+1):
for j in range(1, m+1):
cost = 0 if A[i-1] == B[j-1] else 1
dp[i][j] = min(dp[i-1][j] + 1,
dp[i][j-1] + 1,
dp[i-1][j-1] + cost)
i, j = n, m
ops = []
while i > 0 or j > 0:
if i > 0 and j > 0 and A[i-1] == B[j-1]:
ops.append(f"匹配 {A[i-1]}")
i -= 1; j -= 1
elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1:
ops.append(f"替换 {A[i-1]} -> {B[j-1]}")
i -= 1; j -= 1
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
ops.append(f"删除 {A[i-1]}")
i -= 1
else:
ops.append(f"插入 {B[j-1]}")
j -= 1
return dp[n][m], list(reversed(ops))
dist, ops = edit_distance_alignment("kitten", "sitting")
print("编辑距离:", dist)
print("对齐:")
for op in ops:
print(op)为什么它很重要
- 支撑了 diff 工具、拼写检查器和 DNA 序列对齐
- 对于文本转换可视化至关重要
- 巩固了从动态规划表回溯的逻辑
一个温和的证明(为什么它有效)
每个 $dp[i][j]$ 都是以下三种可能性的最小值:
- 插入:添加 $B[j-1]$
- 删除:移除 $A[i-1]$
- 替换:将 $A[i-1]$ 更改为 $B[j-1]$
通过向左、向上或对角线移动穿过动态规划网格的最优路径,精确地记录了最小编辑序列。反转回溯路径就得到了转换过程。
亲自尝试
- 计算 "intention" → "execution" 的对齐
- 添加操作计数摘要(插入、删除、替换次数)
- 用箭头(↑, ←, ↖)可视化网格
- 修改成本:替换 = 2,插入/删除 = 1
- 返回对齐字符串和操作列表
测试用例
| A | B | 距离 | 对齐(操作) |
|---|---|---|---|
| kitten | sitting | 3 | 替换 e→i, 插入 n, 插入 g |
| sunday | saturday | 3 | 插入 a, 插入 t, 替换 n→r |
| horse | ros | 3 | 删除 h, 替换 o→r, 删除 e |
复杂度
- 时间:$O(nm)$
- 空间:$O(nm)$(在不进行重建的情况下可减少到 $O(\min(n,m))$)
编辑距离对齐将一个距离度量转换成一个逐步展开的故事,精确地展示了一个单词如何变成另一个。
485 矩阵链括号化
矩阵链乘法告诉我们最小的乘法次数,但重构(reconstruction)告诉我们如何加括号,即实现该代价的乘法顺序。没有这一步,我们只知道代价,却不知道具体方案。
我们解决的是什么问题?
给定一个矩阵序列 $A_1, A_2, \dots, A_n$,其维度分别为 $p_0 \times p_1, p_1 \times p_2, \dots, p_{n-1} \times p_n$, 我们希望确定最优的括号化方案,以最小化标量乘法次数。
代价的动态规划(DP)公式为:
$$ dp[i][j] = \begin{cases} 0 & \text{如果 } i = j \ \min_{i \le k < j} (dp[i][k] + dp[k+1][j] + p_{i-1} \cdot p_k \cdot p_j) \end{cases} $$
为了重构解决方案,我们维护一个分割表 $split[i][j]$,用于指示最优分割发生的位置索引 $k$。
它是如何工作的(通俗解释)
- 使用自底向上的动态规划计算代价表。
- 在每个子问题中追踪分割点 $k$。
- 递归重构:
- 基本情况:如果 $i==j$,返回 $A_i$
- 否则:
(+ 求解($i$, $k$) + 求解($k+1$, $j$) +)
这样就得到了确切的括号化方案。
示例
矩阵维度:$[40, 20, 30, 10, 30]$
共有 4 个矩阵:
- $A_1: 40\times20$
- $A_2: 20\times30$
- $A_3: 30\times10$
- $A_4: 10\times30$
最优顺序: $((A_1(A_2A_3))A_4)$ 最小代价:26000
精简代码
C
#include <stdio.h>
#include <limits.h>
#define N 5
int dp[N][N];
int split[N][N];
int min(int a, int b) { return a < b ? a : b; }
void print_paren(int i, int j) {
if (i == j) {
printf("A%d", i);
return;
}
printf("(");
print_paren(i, split[i][j]);
print_paren(split[i][j] + 1, j);
printf(")");
}
int main() {
int p[] = {40, 20, 30, 10, 30};
int n = 4;
for (int i = 1; i <= n; i++) dp[i][i] = 0;
for (int len = 2; len <= n; len++) {
for (int i = 1; i <= n - len + 1; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j];
if (cost < dp[i][j]) {
dp[i][j] = cost;
split[i][j] = k;
}
}
}
}
printf("最小代价: %d\n", dp[1][n]);
printf("最优顺序: ");
print_paren(1, n);
printf("\n");
}Python
def matrix_chain_order(p):
n = len(p) - 1
dp = [[0]* (n+1) for _ in range(n+1)]
split = [[0]* (n+1) for _ in range(n+1)]
for l in range(2, n+1):
for i in range(1, n-l+2):
j = i + l - 1
dp[i][j] = float('inf')
for k in range(i, j):
cost = dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]
if cost < dp[i][j]:
dp[i][j] = cost
split[i][j] = k
def build(i, j):
if i == j: return f"A{i}"
k = split[i][j]
return f"({build(i, k)}{build(k+1, j)})"
return dp[1][n], build(1, n)
p = [40, 20, 30, 10, 30]
cost, order = matrix_chain_order(p)
print("最小代价:", cost)
print("顺序:", order)为何重要
- 将抽象的代价表转化为具体的计划
- 是查询优化、编译器表达式解析的基础
- 展示了如何通过追踪分割点来生成人类可读的结构
一个温和的证明(为何有效)
在每个子链 $(i, j)$ 处,动态规划尝试所有 $k$ 分割。 选择的使代价最小化的 $k$ 被存储在 split[i][j] 中。 通过递归应用这些存储的分割点,我们遵循了生成最小代价的同一决策树。 因此,重构产生了确切的乘法序列。
动手尝试
- 尝试 $p=[10, 20, 30, 40, 30]$ 并验证顺序。
- 为每对 $(i,j)$ 添加子问题代价的打印。
- 修改代码以返回树结构而非字符串。
- 用嵌套括号树进行可视化。
- 扩展代码以显示每一步的中间矩阵维度。
测试用例
| 维度 | 代价 | 括号化方案 |
|---|---|---|
| [40,20,30,10,30] | 26000 | ((A1(A2A3))A4) |
| [10,20,30] | 6000 | (A1A2A3) |
| [10,20,30,40,30] | 30000 | ((A1A2)(A3A4)) |
复杂度
- 时间复杂度:$O(n^3)$
- 空间复杂度:$O(n^2)$
矩阵链括号化将代价最小化转化为具体策略,不仅展示了代价多少,还展示了具体如何实现。
486 硬币找零重构
在硬币找零问题中,我们通常计算最少硬币数或总方法数。然而,重构问题问的是:具体是哪些硬币组成了解决方案? 这弥合了数字答案与实际组合之间的差距。
我们正在解决什么问题?
给定:
- 一组硬币面额 $coins = [c_1, c_2, \dots, c_n]$
- 一个目标总和 $S$
我们想要:
- 计算所需的最少硬币数量(经典动态规划)
- 重构出实现 $S$ 的一种最优硬币组合
我们定义:
$$ dp[x] = \begin{cases} 0, & \text{if } x = 0,\ 1 + \displaystyle\min_{c \le x}\bigl(dp[x - c]\bigr), & \text{if } x > 0. \end{cases} $$
并记录是哪个硬币给出了最佳解:
$$ choice[x] = c \text{ 使得 } dp[x-c] \text{ 最小化} $$
它是如何工作的(通俗解释)
- 从 $0$ 到 $S$ 构建 DP 数组。
- 对于每个金额 $x$,尝试每种硬币 $c$。
- 跟踪记录:
- 最少硬币数 (
dp[x]) - 使用的硬币 (
choice[x])
- 最少硬币数 (
- 填充完成后,从 $S$ 开始回溯: 重复减去
choice[x]直到达到 0。
示例
硬币 = [1, 3, 4], 目标 $S = 6$
DP 步骤:
| x | dp[x] | choice[x] |
|---|---|---|
| 0 | 0 | - |
| 1 | 1 | 1 |
| 2 | 2 | 1 |
| 3 | 1 | 3 |
| 4 | 1 | 4 |
| 5 | 2 | 1 |
| 6 | 2 | 3 |
最优组合:[3, 3]
微型代码
C
#include <stdio.h>
#include <limits.h>
#define MAX 100
int main() {
int coins[] = {1, 3, 4};
int n = 3;
int S = 6;
int dp[MAX], choice[MAX];
dp[0] = 0;
choice[0] = -1;
for (int i = 1; i <= S; i++) {
dp[i] = INT_MAX;
choice[i] = -1;
for (int j = 0; j < n; j++) {
int c = coins[j];
if (c <= i && dp[i-c] + 1 < dp[i]) {
dp[i] = dp[i-c] + 1;
choice[i] = c;
}
}
}
printf("最少硬币数: %d\n", dp[S]);
printf("组合: ");
int x = S;
while (x > 0) {
printf("%d ", choice[x]);
x -= choice[x];
}
printf("\n");
}Python
def coin_change_reconstruct(coins, S):
dp = [float('inf')] * (S + 1)
choice = [-1] * (S + 1)
dp[0] = 0
for x in range(1, S + 1):
for c in coins:
if c <= x and dp[x - c] + 1 < dp[x]:
dp[x] = dp[x - c] + 1
choice[x] = c
if dp[S] == float('inf'):
return None, []
comb = []
while S > 0:
comb.append(choice[S])
S -= choice[S]
return dp[-1], comb
coins = [1, 3, 4]
S = 6
count, comb = coin_change_reconstruct(coins, S)
print("最少硬币数:", count)
print("组合:", comb)为什么它很重要
- 将抽象的 DP 结果转化为实际的计划
- 在金融、自动售货系统、资源分配中至关重要
- 强化了线性 DP 问题的回溯技术
一个温和的证明(为什么它有效)
根据定义,对于最优的 $c$,有 $dp[x] = 1 + dp[x-c]$。 因此,$x$ 的最优最后一步必须使用硬币 $choice[x] = c$。 重复减去这个 $c$ 会得到一个以 $0$ 结尾的有效序列。 每一步都减少了问题规模,同时保持了最优性(通过 DP 的贪心性质)。
亲自尝试
- 尝试 $coins=[1,3,4]$, $S=10$
- 修改以返回所有最优组合(如果有多个)
- 扩展到有限硬币数量的情况
- 可视化表格 $(x, dp[x], choice[x])$
- 适应非规范系统(如 [1, 3, 5, 7])
测试用例
| 硬币面额 | S | 最少硬币数 | 组合 |
|---|---|---|---|
| [1, 3, 4] | 6 | 2 | [3, 3] |
| [1, 2, 5] | 11 | 3 | [5, 5, 1] |
| [2, 5, 10] | 7 | ∞ | [] |
复杂度
- 时间:$O(S \times n)$
- 空间:$O(S)$
硬币找零重构将“多少个”转化为“哪些个”,不仅构建了一个答案,还构建了一条通往答案的清晰路径。
487 路径重建 DP
DP 中的路径重建是一门艺术,它通过成本或距离表回溯你的步骤,以找到导致最优答案的确切路线。仅仅知道有多远是不够的,你还想知道你是如何到达那里的。
我们要解决什么问题?
给定一个每个单元格都有成本的网格(或图),我们只允许向右或向下移动,计算从起始单元格 $(0,0)$ 到目标单元格 $(n-1, m-1)$ 的最小路径成本。 现在,我们不仅要报告最小成本,还要重建路径。
我们定义:
$$ dp[i][j] = \begin{cases} grid[0][0], & \text{如果 } i = 0 \text{ 且 } j = 0,\ grid[i][j] + \min\bigl(dp[i-1][j],\ dp[i][j-1]\bigr), & \text{其他情况}. \end{cases} $$
我们同时维护一个父节点表 parent[i][j] 来记住我们是从上方还是左方到达的。
它是如何工作的(通俗解释)
- 用到达每个单元格的最小成本填充 dp[i][j]。
- 追踪导致此成本的移动:
- 如果 $dp[i][j]$ 来自 $dp[i-1][j]$,则 parent = "up"
- 否则 parent = "left"
- 从目标单元格 $(n-1,m-1)$ 开始,使用
parent进行回溯。 - 反转重建的列表以获得正确的顺序。
示例
网格:
| 1 | 3 | 1 |
|---|---|---|
| 1 | 5 | 1 |
| 4 | 2 | 1 |
最小路径和:7 路径:$(0,0)\rightarrow(0,1)\rightarrow(0,2)\rightarrow(1,2)\rightarrow(2,2)$
微型代码
C
#include <stdio.h>
#include <limits.h>
#define N 3
#define M 3
int grid[N][M] = {
{1, 3, 1},
{1, 5, 1},
{4, 2, 1}
};
int dp[N][M];
char parent[N][M]; // 'U' = 上, 'L' = 左
int min(int a, int b) { return a < b ? a : b; }
int main() {
dp[0][0] = grid[0][0];
// 第一行
for (int j = 1; j < M; j++) {
dp[0][j] = dp[0][j-1] + grid[0][j];
parent[0][j] = 'L';
}
// 第一列
for (int i = 1; i < N; i++) {
dp[i][0] = dp[i-1][0] + grid[i][0];
parent[i][0] = 'U';
}
// 填充其余部分
for (int i = 1; i < N; i++) {
for (int j = 1; j < M; j++) {
if (dp[i-1][j] < dp[i][j-1]) {
dp[i][j] = dp[i-1][j] + grid[i][j];
parent[i][j] = 'U';
} else {
dp[i][j] = dp[i][j-1] + grid[i][j];
parent[i][j] = 'L';
}
}
}
printf("最小路径和: %d\n", dp[N-1][M-1]);
// 回溯
int i = N - 1, j = M - 1;
int path[100][2], len = 0;
while (!(i == 0 && j == 0)) {
path[len][0] = i;
path[len][1] = j;
len++;
if (parent[i][j] == 'U') i--;
else j--;
}
path[len][0] = 0; path[len][1] = 0;
len++;
printf("路径: ");
for (int k = len - 1; k >= 0; k--)
printf("(%d,%d) ", path[k][0], path[k][1]);
printf("\n");
}Python
def min_path_sum_path(grid):
n, m = len(grid), len(grid[0])
dp = [[0]*m for _ in range(n)]
parent = [['']*m for _ in range(n)]
dp[0][0] = grid[0][0]
for j in range(1, m):
dp[0][j] = dp[0][j-1] + grid[0][j]
parent[0][j] = 'L'
for i in range(1, n):
dp[i][0] = dp[i-1][0] + grid[i][0]
parent[i][0] = 'U'
for i in range(1, n):
for j in range(1, m):
if dp[i-1][j] < dp[i][j-1]:
dp[i][j] = dp[i-1][j] + grid[i][j]
parent[i][j] = 'U'
else:
dp[i][j] = dp[i][j-1] + grid[i][j]
parent[i][j] = 'L'
# 回溯
path = []
i, j = n-1, m-1
while not (i == 0 and j == 0):
path.append((i, j))
if parent[i][j] == 'U':
i -= 1
else:
j -= 1
path.append((0, 0))
path.reverse()
return dp[-1][-1], path
grid = [[1,3,1],[1,5,1],[4,2,1]]
cost, path = min_path_sum_path(grid)
print("最小成本:", cost)
print("路径:", path)为什么它很重要
- 将数值 DP 转化为可导航的路线
- 在寻路、机器人导航、路线规划中至关重要
- 展示了二维网格的父指针技术
一个温和的证明(为什么它有效)
根据构造,$dp[i][j]$ 存储了到达 $(i,j)$ 的最小成本。 由于每个单元格仅依赖于上方和左方,将更好的来源存储为 parent[i][j] 确保了每一步回溯都指向最优路径的一个有效前缀。 跟随父节点可以重建一条这样的最优路径。
亲自尝试
- 在一个 $4\times4$ 的随机成本网格上尝试。
- 修改以允许对角线移动。
- 扩展到最大路径和(将 min 改为 max)。
- 可视化路径箭头(↑, ←)。
- 适用于具有邻接矩阵的图最短路径。
测试用例
| 网格 | 结果 | 路径 |
|---|---|---|
| [[1,3,1],[1,5,1],[4,2,1]] | 7 | [(0,0),(0,1),(0,2),(1,2),(2,2)] |
| [[1,2,3],[4,5,6]] | 12 | [(0,0),(0,1),(0,2),(1,2)] |
复杂度
- 时间:$O(nm)$
- 空间:$O(nm)$
路径重建 DP 将最短路径转化为可见的旅程,展示了构建最优解的每一个选择。
488 序列重建
序列重建是指从部分或隐含信息(通常来自动态规划表、前缀关系或成对约束)中恢复完整序列的过程。它是连接问题求解与将答案解释为序列之间的桥梁。
我们正在解决什么问题?
你经常解决那些计算或评估可能序列的动态规划问题,但如果你需要恢复一个有效序列(甚至所有序列)呢? 例如:
- 给定最长递增子序列的长度,重建一个最长递增子序列。
- 给定偏序关系,重建一个满足这些关系的序列。
- 给定前缀和,重建原始数组。
在这里,我们将探讨一种通用模式:利用动态规划过程中追踪的父状态或前驱状态来重建序列。
示例:重建最长递增子序列
给定一个数组 arr,我们首先计算 dp[i] = 以 i 结尾的最长递增子序列的长度。 然后,我们使用 parent[i] 追踪前驱节点以重建实际的子序列。
递推关系
$$ dp[i] = 1 + \max_{j<i,\ arr[j]<arr[i]} dp[j] $$
以及 $$ parent[i] = \arg\max_{j<i,\ arr[j]<arr[i]} dp[j] $$
计算完 dp 后,我们找到 max(dp) 的索引,然后使用 parent 进行回溯。
工作原理(通俗解释)
- 像往常一样运行最长递增子序列的动态规划。
- 每当更新
dp[i]时,存储是哪个先前索引带来了这次改进。 - 完成后,找到最佳最长递增子序列的结束索引。
- 使用
parent向后遍历直到-1。 - 反转收集到的索引,那就是你的最长递增子序列。
精简代码
Python
def reconstruct_lis(arr):
n = len(arr)
dp = [1] * n
parent = [-1] * n
for i in range(n):
for j in range(i):
if arr[j] < arr[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
parent[i] = j
length = max(dp)
idx = dp.index(length)
# 回溯
lis = []
while idx != -1:
lis.append(arr[idx])
idx = parent[idx]
lis.reverse()
return lis
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(reconstruct_lis(arr)) # [2, 3, 7, 18]C 语言版本
#include <stdio.h>
int main() {
int arr[] = {10, 9, 2, 5, 3, 7, 101, 18};
int n = sizeof(arr)/sizeof(arr[0]);
int dp[n], parent[n];
for (int i = 0; i < n; i++) {
dp[i] = 1;
parent[i] = -1;
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
parent[i] = j;
}
}
}
// 找到最大长度对应的索引
int max_len = 0, idx = 0;
for (int i = 0; i < n; i++) {
if (dp[i] > max_len) {
max_len = dp[i];
idx = i;
}
}
// 重建序列
int lis[n], len = 0;
while (idx != -1) {
lis[len++] = arr[idx];
idx = parent[idx];
}
printf("LIS: ");
for (int i = len - 1; i >= 0; i--) printf("%d ", lis[i]);
printf("\n");
}为什么这很重要
- 展示了动态规划表如何包含完整的结构信息,而不仅仅是数值。
- 在生物信息学、差异比较工具、编辑追踪、序列比对中非常有用。
- 构成了回溯算法的基础。
一个温和的证明(为什么它有效)
基于索引 i 进行归纳:
- 基本情况:第一个元素,最长递增子序列 =
[arr[i]]。 - 归纳步骤:每个
parent[i]都指向能给出最大长度的前一个最长递增子序列端点。 因此,从最大元素开始跟随父指针可以重建一个有效的最长递增子序列,反转后得到正向顺序。
自己动手试试
- 将条件改为
arr[j] > arr[i]→ 最长递减子序列。 - 修改以追踪所有最长递增子序列。
- 打印索引而不是值。
- 扩展到二维(嵌套信封问题)。
- 与二分查找最长递增子序列结合,实现 $O(n \log n)$ 的重建。
测试用例
| 输入 | 最长递增子序列 |
|---|---|
| [10,9,2,5,3,7,101,18] | [2,3,7,18] |
| [3,10,2,1,20] | [3,10,20] |
| [50,3,10,7,40,80] | [3,7,40,80] |
复杂度
- 时间复杂度:$O(n^2)$
- 空间复杂度:$O(n)$
序列重建将数值答案转化为有故事的序列,揭示了每个元素如何融入最优解的整体叙事中。
489 多选重构
多选重构是指在动态规划(DP)问题中,当每个状态允许多种选择时(例如从类别、组别或配置中进行选择),如何回溯这些选择。它将简单的父节点追踪扩展到多维或多决策的DP中,重构出导致最优答案的完整选择组合。
我们正在解决什么问题?
一些DP问题涉及从多个类别中选择一个选项,例如:
- 多选背包问题:每组有多个物品;每组最多只能选择一个。
- 课程调度:为每个科目选择一个时间段,以最大化空闲时间。
- 机器分配:为每个作业选择一台机器,以最小化成本。
我们不仅需要计算最优值,还需要重构在所有类别中具体做出了哪些选择。
示例:多选背包问题
给定 G 个组,每组包含多个物品 (重量, 价值),要求从每组中恰好选择一个物品,使得总重量 ≤ W 且总价值最大。
状态定义
令 $dp[g][w]$ = 使用前 $g$ 个组且总重量为 $w$ 时的最大价值。 我们将追踪每组中哪个物品贡献了这个价值。
递推关系
$$ dp[g][w] = \max_{(w_i, v_i) \in group[g]} \big(dp[g-1][w - w_i] + v_i\big) $$
为了重构,我们存储:
$$ choice[g][w] = i \quad \text{使得} \quad dp[g][w] \text{ 由物品 } i \text{ 实现} $$
工作原理(通俗解释)
- 对于每个组,对于每个容量,尝试该组中的每个物品。
- 选择能给出最高价值的那个物品。
- 将给出该最佳值的物品索引存储在
choice中。 - 填完表后,从
(G, W)开始,利用choice回溯以重建所选的物品。
微型代码
Python
def multi_choice_knapsack(groups, W):
G = len(groups)
dp = [[0] * (W + 1) for _ in range(G + 1)]
choice = [[-1] * (W + 1) for _ in range(G + 1)]
for g in range(1, G + 1):
for w in range(W + 1):
for idx, (wt, val) in enumerate(groups[g - 1]):
if wt <= w and dp[g - 1][w - wt] + val > dp[g][w]:
dp[g][w] = dp[g - 1][w - wt] + val
choice[g][w] = idx
# 回溯
w = W
selected = []
for g in range(G, 0, -1):
idx = choice[g][w]
if idx != -1:
wt, val = groups[g - 1][idx]
selected.append((g - 1, idx, wt, val))
w -= wt
selected.reverse()
return dp[G][W], selected
groups = [
[(3, 5), (2, 3)],
[(4, 6), (1, 2), (3, 4)],
[(2, 4), (1, 1)]
]
print(multi_choice_knapsack(groups, 7))输出:
(13, [(0, 0, 3, 5), (1, 1, 1, 2), (2, 0, 2, 4)])为何重要
- 许多优化问题涉及多个嵌套决策。
- 在资源分配、调度和多约束规划中很有用。
- 重构有助于解释DP为何做出每个选择,这对于调试和解释至关重要。
一个温和的证明(为何有效)
我们对 g(组数)进行归纳:
- 基础情况:$g=1$,在容量 $w$ 下选择最佳物品。
- 归纳步骤:假设到第 $g-1$ 组为止的所有最优选择都是正确的。 对于第 $g$ 组,每个
dp[g][w]由dp[g-1][w-w_i] + v_i构建而来,存储索引i确保可以从 $(G,W)$ 向后重构出一条有效的、最优的选择链。
因此,每个回溯出的选择序列都对应一个最优解。
动手尝试
- 添加对所选组总数的限制。
- 修改为每组可选择多个物品。
- 打印组名而非索引。
- 扩展到3D DP(组 × 容量 × 预算)。
- 通过跳过某个选择来重构次优解。
测试用例
| 组数据 | W | 输出 |
|---|---|---|
[[(3,5),(2,3)], [(4,6),(1,2),(3,4)], [(2,4),(1,1)]] | 7 | 价值=13, 选择=(3,5),(1,2),(2,4) |
[[(2,3)], [(2,2),(3,5)]] | 5 | 价值=8 |
[[(1,1),(2,4)], [(2,2),(3,5)]] | 4 | 价值=6 |
复杂度
- 时间复杂度:$O(G \cdot W \cdot K)$,其中 $K$ = 最大组大小
- 空间复杂度:$O(G \cdot W)$
多选重构将分层决策的DP转化为可理解的序列,精确地揭示了选择了什么以及为何如此选择。
490 回溯可视化
回溯可视化旨在展示动态规划(DP)算法如何重构其答案,将不可见的状态转移转化为清晰的决策路径。它将 DP 表转化为一系列操作的叙述,展示每个最优解是如何一步步形成的。
我们要解决什么问题?
大多数 DP 问题计算的是最优值,但隐藏了这些值是如何达到的。 回溯可视化帮助我们回答:
- 采取了哪些转移?
- 我们如何从基本情况到达解决方案?
- DP 在其表中遵循什么模式?
你并不是在改变算法,而是在揭示它的故事。
常见示例:
- 最长公共子序列(LCS):追踪匹配的箭头。
- 编辑距离:对角线表示匹配,向上表示删除,向左表示插入。
- 矩阵路径问题:显示最小路径和的箭头。
- 背包问题:高亮显示选中的单元格。
它是如何工作的(通俗解释)
我们以可视化的方式重构 DP 解:
- 像往常一样计算
dp表。 - 从最终状态(例如
dp[n][m])开始。 - 沿着产生最优值的转移向后移动。
- 记录每一步(箭头、方向或解释)。
- 绘制路径或打印回溯信息。
每个单元格的转移揭示了它被选中的原因,无论是满足最小、最大还是匹配条件。
示例:编辑距离可视化
给定字符串 A = "kitten", B = "sitting", 我们计算 $dp[i][j]$ = 前缀 $A[0..i)$ 和 $B[0..j)$ 之间的最小编辑距离。
然后我们回溯:
如果 $A[i-1] = B[j-1]$:对角线(匹配)
否则:
- 如果 $dp[i][j] = dp[i-1][j-1] + 1$:替换
- 如果 $dp[i][j] = dp[i-1][j] + 1$:删除
- 如果 $dp[i][j] = dp[i][j-1] + 1$:插入
回溯路径:右下角 → 左上角
微型代码
Python
def edit_distance_trace(a, b):
n, m = len(a), len(b)
dp = [[0]*(m+1) for _ in range(n+1)]
for i in range(n+1):
dp[i][0] = i
for j in range(m+1):
dp[0][j] = j
for i in range(1, n+1):
for j in range(1, m+1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
# 回溯
i, j = n, m
trace = []
while i > 0 or j > 0:
if i > 0 and j > 0 and a[i-1] == b[j-1]:
trace.append(f"匹配 {a[i-1]}")
i -= 1; j -= 1
elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1:
trace.append(f"替换 {a[i-1]} -> {b[j-1]}")
i -= 1; j -= 1
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
trace.append(f"删除 {a[i-1]}")
i -= 1
else:
trace.append(f"插入 {b[j-1]}")
j -= 1
trace.reverse()
return dp[n][m], trace
dist, steps = edit_distance_trace("kitten", "sitting")
print("距离:", dist)
print("\n".join(steps))输出:
距离: 3
替换 k -> s
匹配 i
匹配 t
匹配 t
替换 e -> i
匹配 n
插入 g为什么它很重要
- 将抽象的数字转化为具体的推理过程。
- 非常适合教学和调试 DP。
- 连接计算和人类理解。
- 有助于验证最优路径或检测错误的转移。
一个温和的证明(为什么它有效)
根据构造,每个 $dp[i][j]$ 都是可实现的最小成本。 在重构时,我们选择任何满足递推等式的先驱状态 $(i', j')$:
$$ dp[i][j] = f(dp[i'][j']) + cost $$
因此,反向遵循这些等式会形成一条有效的最优路径。 由于所有 $dp$ 状态都是自底向上填充的,每个先驱状态都更早被计算,确保了前后一致性。
亲自尝试
- 可视化 LCS:为匹配和方向绘制箭头。
- 可视化网格中的最小路径和:打印箭头或坐标。
- 在回溯中添加 ASCII 箭头(↖ ↑ ←)。
- 生成带彩色路径的热图。
- 展示决策解释(例如 "在位置 6 插入 g")。
测试用例
| 输入 | 输出回溯 |
|---|---|
| ("abc", "abc") | 仅匹配 |
| ("kitten", "sitting") | 替换、插入步骤 |
| ("intention", "execution") | 混合编辑操作 |
| ("horse", "ros") | 删除 + 替换路径 |
复杂度
- 时间:与 DP 相同,$O(nm)$
- 空间:$O(nm)$ 用于表 + 路径
回溯可视化将最终的 DP 值转化为一条生动的路径,让学习者看到算法的推理如何在网格中展开。
第 50 节. 元 DP 与优化模板
491 状态压缩模板
状态压缩是一种强大的技术,通过将多个子状态变量编码为单个整数掩码,来降低动态规划问题的维度。这使得我们能够利用位运算高效地处理依赖于子集、访问状态或布尔配置的问题。
我们要解决什么问题?
许多动态规划问题依赖于状态的组合:
- 哪些元素已被使用?
- 哪些节点已被访问?
- 当前活跃的子集是什么?
直接表示这些状态可能导致复杂度爆炸式增长。 相反,我们将状态压缩成一个整数位掩码,其中每一位表示一个元素的包含情况或状态。
例如:
- 旅行商问题:掩码跟踪已访问的城市。
- 子集和问题:掩码跟踪子集可能达到的和。
- 图动态规划:掩码编码选定的顶点。
它是如何工作的(通俗解释)
我们用位掩码表示子集或多变量状态:
- $1$ 位 → 包含,$0$ 位 → 排除。
- 对于 $n$ 个元素,我们有 $2^n$ 种可能的掩码。
每个动态规划状态看起来像:
$$ dp[mask] = \text{由 } mask \text{ 表示的子集的最佳结果} $$
状态转移遍历掩码中设置/未设置的位,更新依赖的状态。
关键操作:
mask | (1 << i)→ 包含元素 imask & (1 << i)→ 检查是否包含 imask ^ (1 << i)→ 切换包含状态mask & -mask→ 提取最低有效设置位
示例:子集动态规划模板
$$ dp[mask] = \min_{i \in mask} \big( dp[mask \setminus {i}] + cost[i] \big) $$
这里,每个 mask 表示一组物品的组合,我们通过每次添加一个元素来逐步构建解。
微型代码
C
#include <stdio.h>
#include <limits.h>
int min(int a, int b) { return a < b ? a : b; }
int main(void) {
int n = 3;
int cost[] = {3, 2, 5};
int dp[1 << 3];
for (int mask = 0; mask < (1 << n); mask++)
dp[mask] = INT_MAX / 2;
dp[0] = 0;
for (int mask = 1; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
int prev = mask ^ (1 << i);
dp[mask] = min(dp[mask], dp[prev] + cost[i]);
}
}
}
printf("Minimum total cost: %d\n", dp[(1 << n) - 1]);
}Python
from math import inf
n = 3
cost = [3, 2, 5]
dp = [inf] * (1 << n)
dp[0] = 0
for mask in range(1, 1 << n):
for i in range(n):
if mask & (1 << i):
dp[mask] = min(dp[mask], dp[mask ^ (1 << i)] + cost[i])
print("Minimum total cost:", dp[(1 << n) - 1])为什么它很重要
- 将指数级的状态压缩为可管理的整数掩码。
- 为组合问题提供了优雅的解决方案。
- 对于旅行商问题、分配问题、子集动态规划和位掩码背包问题至关重要。
- 与迭代动态规划循环完美契合。
一个温和的证明(为什么它有效)
如果 $dp[S]$ 存储了子集 $S$ 的最优结果, 并且每次状态转移都是通过添加一个元素从较小的子集移动到较大的子集:
$$ dp[S] = \min_{i \in S} \big( dp[S \setminus {i}] + cost[i] \big) $$
那么通过归纳法:
- 基本情况:$dp[\emptyset]$ 已知(通常为 0)。
- 归纳步骤:每个子集 $S$ 都建立在更小的子集之上。 所有子集都按大小递增的顺序处理,确保了正确性。
动手试试
- 实现子集和动态规划(SOS DP)。
- 使用状态压缩解决旅行商问题。
- 将其适配到分配问题($n!$ → $2^n n$ 个状态)。
- 使用掩码奇偶性(偶/奇位)来处理组合约束。
- 以二进制形式打印掩码以可视化状态转移。
测试用例
| 输入 | 描述 | 输出 |
|---|---|---|
| cost = [3, 2, 5] | 选择所有 3 个元素 | 10 |
| cost = [1, 2] | 2 个元素 | 3 |
| cost = [5] | 单个物品 | 5 |
复杂度
- 时间:$O(n \cdot 2^n)$
- 空间:$O(2^n)$
状态压缩动态规划是你进入子集推理的敲门砖,它紧凑、强大,并且是解决具有结构的指数级组合空间问题的基础。
492 转移优化模板
转移优化是一种通过预计算或结构化递推更新来提高 DP 转移效率的核心技术。许多 DP 递推式涉及嵌套循环或重复计算,这些可以通过数学性质、单调性或辅助数据结构来简化。
我们要解决什么问题?
在许多 DP 问题中,每个状态依赖于一个范围或一组先前状态:
$$ dp[i] = \min_{j < i} \big( dp[j] + cost(j, i) \big) $$
朴素实现需要 $O(n^2)$ 时间。 但如果 $cost(j, i)$ 具有特殊结构(单调性、凸性、四边形不等式),我们可以使用优化转移将其减少到 $O(n \log n)$ 甚至 $O(n)$。
转移优化旨在寻找模式或数据结构来加速这些计算。
它是如何工作的(通俗解释)
当你注意到像下面这样的重复转移时:
for (int i = 1; i <= n; i++)
for (int j = 0; j < i; j++)
dp[i] = min(dp[i], dp[j] + cost(j, i));……你付出了 $O(n^2)$ 的代价。 但通常,cost(j, i) 遵循某种模式(例如线性、凸性或单调性),因此我们可以进行优化:
- 单调队列优化:用于滑动窗口最小值。
- 分治 DP:当最优的 j 单调移动时。
- 凸包技巧:当 $cost(j, i) = m_j \cdot x_i + b_j$ 是线性时。
- Knuth 优化:当四边形不等式成立时。
每种方法都通过预计算或缩小转移范围来实现优化。
示例转移(通用形式)
$$ dp[i] = \min_{j < i} \big( dp[j] + f(j, i) \big) $$
如果 $f$ 满足 Monge 性质或四边形不等式,我们可以确定最优的 $j$ 只朝一个方向移动(单调性)。 这意味着我们可以使用分治或指针技巧来高效地找到它。
微型代码
C 语言(朴素转移)
for (int i = 1; i <= n; i++) {
dp[i] = INF;
for (int j = 0; j < i; j++) {
int candidate = dp[j] + cost(j, i);
if (candidate < dp[i])
dp[i] = candidate;
}
}C 语言(使用单调指针优化)
int ptr = 0;
for (int i = 1; i <= n; i++) {
while (ptr + 1 < i && better(ptr + 1, ptr, i))
ptr++;
dp[i] = dp[ptr] + cost(ptr, i);
}这里的 better(a, b, i) 检查对于 dp[i],a 是否比 b 给出更小的代价。
Python(滑动窗口优化)
from collections import deque
dp = [0] * (n + 1)
q = deque([0])
for i in range(1, n + 1):
while len(q) >= 2 and better(q[1], q[0], i):
q.popleft()
j = q[0]
dp[i] = dp[j] + cost(j, i)
while len(q) >= 2 and cross(q[-2], q[-1], i):
q.pop()
q.append(i)这种结构出现在凸包技巧和单调队列优化中。
为什么它很重要
- 将 $O(n^2)$ 的转移减少到 $O(n \log n)$ 或 $O(n)$。
- 利用了 DP 代价函数中的结构(单调性、凸性)。
- 支撑了主要的优化技术:
- Knuth 优化
- 分治 DP
- 凸包技巧
- 斜率技巧
- 单调队列 DP
一个温和的证明(为什么它有效)
如果递推式满足最优转移索引的单调性,即:
$$ opt[i] \le opt[i+1] $$
那么最优的转移索引 $j$ 是非递减移动的。 这意味着我们可以使用以下任一方法在一次遍历中找到所有 $i$ 的最优 $j$:
- 双指针遍历(单调队列)
- 分治递归(Knuth 或分治 DP)
- 直线容器(凸包技巧)
通过利用这种结构,我们避免了重复计算。
亲自尝试
- 识别一个 DP 问题,其中每个状态依赖于一个范围的先前状态。
- 检查
cost(j, i)是否满足单调性或凸性性质。 - 应用分治优化来减少 $O(n^2)$ 的复杂度。
- 针对线性代价形式实现凸包技巧。
- 使用基于双端队列的单调队列来处理滑动范围 DP。
测试用例
| 案例 | 递推式 | 优化技术 |
|---|---|---|
| $dp[i] = \min_{j<i}(dp[j]+c(i-j))$ | $c$ 凸函数 | 凸包技巧 |
| $dp[i] = \min_{j<i}(dp[j]+w(j,i))$ | 最优转移索引单调 | 分治 |
| $dp[i] = \min_{j<i}(dp[j]) + a_i$ | 滑动窗口 | 单调队列 |
复杂度
- 时间复杂度:$O(n)$ 到 $O(n \log n)$(取决于方法)
- 空间复杂度:$O(n)$
转移优化是一门在代价中观察结构的艺术。一旦你发现了单调性或凸性,你的 DP 就会变得更快、更简洁、更智能。
493 空间优化模板
空间优化是一门艺术,通过认识到每一步只需要前一个状态的有限子集,从而裁剪掉动态规划表中未使用的维度。许多从大型 $O(n^2)$ 或 $O(nm)$ 表开始的经典动态规划问题,都可以简化为滚动数组或单行更新,从而大幅减少内存使用。
我们要解决什么问题?
动态规划通常使用多维数组:
$$ dp[i][j] = \text{使用前 } i \text{ 个物品,容量为 } j \text{ 时的答案} $$
但并非所有维度都是必需的。 如果每个状态 $dp[i]$ 只依赖于前一行 $dp[i-1]$,我们就可以重用内存,只保留两行(甚至一行)。
空间优化让我们可以在不改变逻辑的情况下,将空间复杂度从 $O(nm)$ 降低到 $O(m)$,或者从 2D → 1D,或者 3D → 2D。
它是如何工作的(通俗解释)
动态规划的更新来源于之前的状态,而不是所有状态。
例如,在 0/1 背包问题中:
$$ dp[i][w] = \max(dp[i-1][w], dp[i-1][w - wt[i]] + val[i]) $$
在计算 dp[i][*] 时,只需要 dp[i-1][*]。 因此,我们可以将动态规划表压缩成一个一维数组 dp[w],并以相反的顺序更新它(以避免覆盖我们仍然需要的状态)。
如果状态转移依赖于当前行或前一行,请仔细选择更新方向:
- 0/1 背包 → 反向循环
- 完全背包 → 正向循环
转换示例
之前(2D DP)
int dp[n+1][W+1];
for (int i = 1; i <= n; i++)
for (int w = 0; w <= W; w++)
dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt[i]] + val[i]);之后(1D DP)
int dp[W+1] = {0};
for (int i = 1; i <= n; i++)
for (int w = W; w >= wt[i]; w--)
dp[w] = max(dp[w], dp[w-wt[i]] + val[i]);微型代码
C 语言(滚动数组示例)
#include <stdio.h>
#define max(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n = 3, W = 5;
int wt[] = {0, 2, 3, 4};
int val[] = {0, 4, 5, 7};
int dp[6] = {0};
for (int i = 1; i <= n; i++)
for (int w = W; w >= wt[i]; w--)
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
printf("最大价值: %d\n", dp[W]);
}Python(1D 滚动)
n, W = 3, 5
wt = [2, 3, 4]
val = [4, 5, 7]
dp = [0] * (W + 1)
for i in range(n):
for w in range(W, wt[i] - 1, -1):
dp[w] = max(dp[w], dp[w - wt[i]] + val[i])
print("最大价值:", dp[W])为什么这很重要
- 将内存从 $O(nm)$ 减少到 $O(m)$。
- 使得大型动态规划问题在内存限制下变得可行。
- 揭示了状态转移中的依赖结构。
- 构成了迭代自底向上优化的基础。
空间优化对于以下问题至关重要:
- 背包问题,最长公共子序列(LCS),最长递增子序列(LIS)
- 网格路径计数
- 划分问题
- 数位动态规划(进位压缩)
一个温和的证明(为什么它有效)
让我们定义 $dp[i][j]$ 仅依赖于 $dp[i-1][*]$。 由于每一新行仅根据前一行计算:
$$ dp[i][j] = f(dp[i-1][j], dp[i-1][j-w_i]) $$
所以在迭代 $i$ 时,一旦 dp[i][*] 计算完成,dp[i-1][*] 就再也不会被使用了。 通过反向更新(以保留依赖关系),二维表可以滚动压缩成一维。
形式上,空间复杂度可以从 $O(nm)$ 降低到 $O(m)$,当且仅当:
- 每个 $dp[i]$ 依赖于 $dp[i-1]$,而不是 $dp[i]$ 本身。
- 转移方向确保之前的状态保持不变。
亲自尝试
- 将你的 0/1 背包问题转换为 1D DP。
- 对 LCS 表进行空间优化(2D → 2 行)。
- 应用于“爬楼梯”问题($dp[i]$ 只需要最后两个值)。
- 对于完全背包问题,尝试正向更新。
- 比较优化前后的内存使用情况。
测试用例
| 问题 | 原始空间复杂度 | 优化后空间复杂度 |
|---|---|---|
| 0/1 背包问题 | $O(nW)$ | $O(W)$ |
| LCS | $O(nm)$ | $O(2m)$ |
| 斐波那契数列 | $O(n)$ | $O(1)$ |
复杂度分析
- 时间复杂度:不变
- 空间复杂度:减少一个维度
- 权衡:迭代方向很重要
空间优化是一场静悄悄的革命:通过识别层之间的独立性,我们一次一个维度地,将算法从不必要的内存中解放出来。
494 多维动态规划模板
多维动态规划(Multi-Dimensional DP)将经典的一维或二维动态规划扩展到更高维的状态空间,用于解决多个独立变量共同演化的问题。每个维度对应一个决策轴、时间、位置、容量或某种离散属性,从而能够表达丰富的组合或结构关系。
我们正在解决什么问题?
有些问题需要追踪多个演化参数:
- 具有两种容量的背包问题 → $dp[i][w_1][w_2]$
- 字符串交错 → $dp[i][j][k]$
- 骰子和计数 → $dp[i][sum][count]$
- 带钥匙的网格 → $dp[x][y][mask]$
当多个独立因素驱动状态转移时,单索引动态规划无法捕捉它们。多维动态规划显式地编码了状态的联合演化。
它是如何工作的(通俗解释)
我们定义一个动态规划表,其中每个轴追踪一个属性:
$$ dp[a][b][c] = \text{参数为 } (a, b, c) \text{ 时的最佳结果} $$
状态转移沿着一个或多个维度更新:
$$ dp[a][b][c] = \min/\max(\text{来自相邻状态的转移}) $$
可以将其视为遍历一个状态网格,其中每次移动都会修改多个参数。关键思想是基于遵循依赖顺序的拓扑或嵌套循环,系统地填充表格。
示例递推关系
多维结构通常如下所示:
$$ dp[i][j][k] = f(dp[i-1][j'][k'], \text{cost}(i, j, k)) $$
示例(二维背包问题):
$$ dp[i][w_1][w_2] = \max(dp[i-1][w_1][w_2],\ dp[i-1][w_1-wt_1[i]][w_2-wt_2[i]] + val[i]) $$
微型代码
C 语言(二维背包问题)
#include <stdio.h>
#define max(a,b) ((a)>(b)?(a):(b))
int main(void) {
int n = 3, W1 = 5, W2 = 5;
int wt1[] = {0, 2, 3, 4};
int wt2[] = {0, 1, 2, 3};
int val[] = {0, 4, 5, 6};
int dp[4][6][6] = {0};
for (int i = 1; i <= n; i++) {
for (int w1 = 0; w1 <= W1; w1++) {
for (int w2 = 0; w2 <= W2; w2++) {
dp[i][w1][w2] = dp[i-1][w1][w2];
if (w1 >= wt1[i] && w2 >= wt2[i]) {
dp[i][w1][w2] = max(dp[i][w1][w2],
dp[i-1][w1 - wt1[i]][w2 - wt2[i]] + val[i]);
}
}
}
}
printf("最大价值: %d\n", dp[n][W1][W2]);
}Python(三维示例:字符串交错)
s1, s2, s3 = "ab", "cd", "acbd"
n1, n2, n3 = len(s1), len(s2), len(s3)
dp = [[[False]*(n3+1) for _ in range(n2+1)] for _ in range(n1+1)]
dp[0][0][0] = True
for i in range(n1+1):
for j in range(n2+1):
for k in range(n3+1):
if k == 0: continue
if i > 0 and s1[i-1] == s3[k-1] and dp[i-1][j][k-1]:
dp[i][j][k] = True
if j > 0 and s2[j-1] == s3[k-1] and dp[i][j-1][k-1]:
dp[i][j][k] = True
print("交错可能:", dp[n1][n2][n3])为什么它很重要
- 优雅地捕捉多因素问题
- 处理耦合约束(容量、索引、和)
- 在维度减少时支持状态压缩
- 常见于:
- 多资源分配
- 交错 / 序列合并
- 多背包 / 有界子集
- 具有额外属性的网格导航
多维动态规划构成了广义搜索空间的基础,其中每个变量都增加了一个推理维度。
一个温和的证明(为什么它有效)
通过对最外层维度进行归纳:
如果 $dp[i][][]$ 仅依赖于 $dp[i-1][][]$, 并且每次转移都从较小的索引移动到较大的索引, 那么动态规划会按拓扑顺序填充,确保了正确性。
每个额外的维度都会使状态空间成倍增加,但不会改变依赖关系的方向。 因此,只要我们尊重维度顺序并正确初始化基本情况,正确性就得以保持。
亲自尝试
- 解决具有双重容量的二维背包问题。
- 使用三维动态规划实现字符串交错检查。
- 使用 $dp[x][y][z]$ 为三维网格中的最短路径建模。
- 添加位掩码维度以跟踪子集。
- 使用滚动数组或压缩技术优化内存。
测试用例
| 问题 | 维度 | 示例状态 | 输出 |
|---|---|---|---|
| 二维背包问题 | 3D (物品, w1, w2) | $dp[i][w1][w2]$ | 最大价值 |
| 字符串交错 | 3D | $dp[i][j][k]$ | 真/假 |
| 带钥匙的网格 | 3D | $dp[x][y][mask]$ | 最小步数 |
复杂度
- 时间复杂度:$O(\text{各维度大小的乘积})$
- 空间复杂度:同阶;可通过滚动数组压缩
- 权衡:更丰富的状态空间与可行性
当问题无法在一维中解决时,多维动态规划就是你进行多约束推理的工具,让你的动态规划多长出一个轴。
495 决策单调性
决策单调性是动态规划递推中的一种结构性性质,它允许我们优化转移搜索。当 $dp[i]$ 的最优决策索引随着 $i$ 的增加而朝一个方向(非递减)移动时,我们可以使用分治或双指针技术,将朴素的 $O(n^2)$ 动态规划优化到 $O(n \log n)$ 甚至 $O(n)$。
我们正在解决什么问题?
在许多动态规划问题中,每个状态 $dp[i]$ 通过选择一个最佳转移点 $j < i$ 来计算:
$$ dp[i] = \min_{0 \le j < i} \big( dp[j] + cost(j, i) \big) $$
这种朴素的递推需要对每个 $i$ 尝试所有先前的状态,导致 $O(n^2)$ 的时间复杂度。 但是,如果最优 $j$ 的索引(称为 $opt[i]$)满足:
$$ opt[i] \le opt[i+1] $$
那么决策索引单调移动,我们可以通过分治动态规划或滑动指针优化进行高效搜索。
它是如何工作的(通俗解释)
如果随着 $i$ 增加,最优的 $j$ 从不向后移动,那么我们可以为每个后续状态重用或缩小搜索范围。
换句话说:
- 对于 $i=10$ 的“最佳分割点”将位于 $i=9$ 的最佳分割点处或之后。
- 无需重新检查更小的 $j$。
- 你可以向前扫描 $j$ 或递归地限制范围。
当 $cost(j, i)$ 满足某些四边形不等式或凸性条件时,就会出现这种性质。
示例递推式
$$ dp[i] = \min_{j < i} \big( dp[j] + (i-j)^2 \big) $$
这里,随着 $i$ 增长,更大的 $j$ 变得更有利,因为 $(i-j)^2$ 会惩罚小的间隔。因此,$opt[i]$ 单调递增。
另一个例子: $$ dp[i] = \min_{j < i} \big( dp[j] + c[j] \cdot a[i] \big) $$ 其中 $a[i]$ 递增,凸包技巧适用,且最优直线按递增顺序出现。
微型代码(双指针单调搜索)
C
int n = ...;
int dp[MAXN];
int opt[MAXN];
for (int i = 1; i <= n; i++) {
dp[i] = INF;
int start = opt[i-1];
for (int j = start; j <= i; j++) {
int val = dp[j] + cost(j, i);
if (val < dp[i]) {
dp[i] = val;
opt[i] = j;
}
}
}每个 $opt[i]$ 从 $opt[i-1]$ 开始搜索,减少了冗余检查。
Python(分治优化)
def solve(l, r, optL, optR):
if l > r: return
mid = (l + r) // 2
best = (float('inf'), -1)
for j in range(optL, min(optR, mid) + 1):
val = dp[j] + cost(j, mid)
if val < best[0]:
best = (val, j)
dp[mid] = best[0]
opt = best[1]
solve(l, mid - 1, optL, opt)
solve(mid + 1, r, opt, optR)
solve(1, n, 0, n-1)为什么它很重要
- 将复杂度从 $O(n^2)$ 降低到 $O(n \log n)$ 或 $O(n)$
- 实现了分治动态规划、Knuth 优化和凸包技巧
- 为结构化成本函数奠定了基础
- 有助于识别调度、分区或链式动态规划中的单调转移
一个温和的证明(为什么它有效)
如果 $opt[i] \le opt[i+1]$,那么 $dp[i]$ 的最优转移来自不早于 $opt[i-1]$ 的位置。 因此,我们可以安全地限制搜索区间:
$$ dp[i] = \min_{j \in [opt[i-1], i-1]} f(j, i) $$
证明遵循四边形不等式:
$$ f(a, c) + f(b, d) \le f(a, d) + f(b, c) $$
这确保了类凸结构和单调决策。
通过归纳法:
- 基础:$opt[1]$ 已知。
- 步骤:如果 $opt[i] \le opt[i+1]$,那么递推保持顺序。
亲自尝试
- 实现一个带有 $opt$ 跟踪的分治动态规划。
- 针对一个示例成本,通过实验验证 $opt[i]$ 的单调性。
- 将其应用于分区问题,例如将数组划分为 K 段。
- 比较 $O(n^2)$ 与优化后的 $O(n \log n)$ 性能。
- 检查你的成本是否满足四边形不等式。
测试用例
| 递推式 | 性质 | 优化方法 |
|---|---|---|
| $dp[i] = \min_{j<i}(dp[j]+(i-j)^2)$ | 凸性 | 单调最优决策 |
| $dp[i] = \min_{j<i}(dp[j]+a[i]\cdot b[j])$ | $a[i]$ 递增 | 凸包 |
| 分段动态规划 | $cost(l,r)$ Monge | 分治 |
复杂度
- 时间复杂度:$O(n \log n)$(分治)或 $O(n)$(双指针)
- 空间复杂度:$O(n)$
决策单调性是动态规划中隐藏的几何结构,一旦你发现“最佳索引”只向前移动,你的算法速度就会显著提升。
496 Monge 数组优化
Monge 数组优化是一种强大的工具,当代价矩阵满足一种称为 Monge 性质的特殊不等式时,它可以加速动态规划。它保证了每行的 argmin 在列之间单调移动,允许我们使用分治 DP 或 SMAWK 算法进行次二次优化。
我们要解决什么问题?
考虑形式如下的 DP:
$$ dp[i][j] = \min_{k < j} \big(dp[i-1][k] + cost[k][j]\big) $$
如果代价矩阵 $cost[k][j]$ 满足 Monge 性质,我们可以在每层以 $O(n \log n)$ 或 $O(n)$ 的时间复杂度计算所有 $dp[i][j]$,而不是朴素的 $O(n^2)$。
这种模式出现在:
- 划分 DP(将序列分割成段)
- 矩阵链乘法 / Knuth DP
- 最优合并 / 分割
它是如何工作的(通俗解释)
Monge 性质指出,对于所有 $a < b$ 和 $c < d$:
$$ cost[a][c] + cost[b][d] \le cost[a][d] + cost[b][c] $$
这意味着代价的差异在对角线上是一致的,暗示了二维上的凸性。 因此,最优分割点单调移动:
$$ opt[i][j] \le opt[i][j+1] $$
因此,我们可以使用分治优化来限制对 $dp[i][j]$ 的搜索范围。
示例递推关系
对于分段划分:
$$ dp[i][j] = \min_{k < j} \big( dp[i-1][k] + cost[k][j] \big) $$
如果 $cost[k][j]$ 是 Monge 的,那么 $opt[i][j] \le opt[i][j+1]$。 因此,在计算 $dp[i][j]$ 时,我们只需要在 $[opt[i][j-1], opt[i][j+1]]$ 范围内搜索 $k$。
微型代码(Monge 矩阵上的分治)
C (模板)
void compute(int i, int l, int r, int optL, int optR) {
if (l > r) return;
int mid = (l + r) / 2;
int best_k = -1;
long long best_val = LLONG_MAX;
for (int k = optL; k <= optR && k < mid; k++) {
long long val = dp_prev[k] + cost[k][mid];
if (val < best_val) {
best_val = val;
best_k = k;
}
}
dp[mid] = best_val;
compute(i, l, mid - 1, optL, best_k);
compute(i, mid + 1, r, best_k, optR);
}每次递归调用计算一个区间的中点,并根据单调性递归地缩小搜索范围。
Python (Monge DP 骨架)
def compute(i, l, r, optL, optR):
if l > r:
return
mid = (l + r) // 2
best = (float('inf'), -1)
for k in range(optL, min(optR, mid) + 1):
val = dp_prev[k] + cost[k][mid]
if val < best[0]:
best = (val, k)
dp[mid] = best[0]
opt[mid] = best[1]
compute(i, l, mid - 1, optL, best[1])
compute(i, mid + 1, r, best[1], optR)为什么它很重要
- 利用 Monge 性质跳过冗余的状态转移
- 将二维 DP 每层复杂度降低到 $O(n \log n)$ 甚至 $O(n)$
- 支持以下优化:
- 分治 DP
- Knuth 优化(特殊的 Monge 情况)
- SMAWK 算法(Monge 数组中的行最小值)
应用于:
- 序列分割
- 矩阵链乘法
- 最优二叉搜索树
- 库存 / 调度模型
一个温和的证明(为什么它有效)
如果 $cost$ 满足 Monge 不等式:
$$ cost[a][c] + cost[b][d] \le cost[a][d] + cost[b][c] $$
那么:
$$ opt[j] \le opt[j+1] $$
也就是说,随着 $j$ 增加,最优的 $k$(分割点)不能向后移动。 因此,在计算 $dp[j]$ 时,我们可以使用之前的最优索引来复用或缩小搜索区间。
这种 argmin 的单调性是分治加速的关键。
亲自尝试
- 验证你的代价函数是否满足 Monge 性质。
- 实现分治 DP 模板。
- 在具有凸分段代价的划分 DP 上测试。
- 比较 $O(n^2)$ 与优化后的 $O(n \log n)$ 运行时间。
- 探索 SMAWK 算法在 Monge 矩阵中求行最小值。
测试用例
| 代价函数 | Monge? | 优化 | ||
|---|---|---|---|---|
| $cost[a][b] = (sum[b]-sum[a])^2$ | ✅ | 是 | ||
| $cost[a][b] = (b-a)^2$ | ✅ | 是 | ||
| $cost[a][b] = \vert b-a \vert $ | ❌ | 否 |
复杂度
- 时间复杂度:每层 $O(n \log n)$
- 空间复杂度:$O(n)$
- 层数:如果是多阶段 DP,乘以 $k$
Monge 数组优化将朴素的 DP 表转化为结构化的图景,一旦你的代价符合条件,状态转移就会以对数的优雅方式整齐地就位。
497 分治模板
分治动态规划是一种优化动态规划状态转移的技术,适用于最优转移索引具有单调性的情况。通过递归地划分问题,并为每个中点仅在有限范围内搜索,我们可以将每层的复杂度从 $O(n^2)$ 降低到 $O(n \log n)$,甚至 $O(n)$。
我们要解决什么问题?
许多动态规划的状态转移方程具有如下形式:
$$ dp[i] = \min_{j < i} \big( dp[j] + cost(j, i) \big) $$
如果 $opt[i] \le opt[i+1]$,即最优转移索引单调向前移动,那么我们可以使用分治法来高效地找到最优的 $j$,而不是扫描所有 $j < i$。
这种结构常见于:
- 划分型动态规划(将数组划分为 $k$ 段)
- 满足 Monge 性质或凸代价函数的问题
- 具有单调最优参数(argmin)的基于区间的递推式
它是如何工作的?(通俗解释)
我们递归地划分范围 $[L, R]$,使用一个受限区间 $[optL, optR]$ 内的最优转移来计算 $dp[mid]$,然后:
- 左半部分 $[L, mid-1]$ 在 $[optL, opt[mid]]$ 中搜索
- 右半部分 $[mid+1, R]$ 在 $[opt[mid], optR]$ 中搜索
通过维持单调的搜索边界,我们确保了正确性并避免了冗余检查。
可以把它看作是在结构保证下,对动态规划索引进行的一种有指导的二分搜索。
示例递推式
$$ dp[i] = \min_{j < i} \big( dp[j] + cost(j, i) \big) $$
如果代价函数 $cost$ 满足四边形不等式或 Monge 性质,那么:
$$ opt[i] \le opt[i+1] $$
因此,我们可以递归地在子区间上计算 $dp$。
微型代码(C)
#include <stdio.h>
#include <limits.h>
#define INF 1000000000
#define min(a,b) ((a)<(b)?(a):(b))
int n;
int dp[10005], prev_dp[10005];
// 示例代价函数(前缀和)
int prefix[10005];
int cost(int j, int i) {
int sum = prefix[i] - prefix[j];
return sum * sum;
}
void compute(int l, int r, int optL, int optR) {
if (l > r) return;
int mid = (l + r) / 2;
int best_k = -1;
int best_val = INF;
for (int k = optL; k <= optR && k < mid; k++) {
int val = prev_dp[k] + cost(k, mid);
if (val < best_val) {
best_val = val;
best_k = k;
}
}
dp[mid] = best_val;
// 递归处理左半部分和右半部分
compute(l, mid - 1, optL, best_k);
compute(mid + 1, r, best_k, optR);
}
int main(void) {
n = 5;
int arr[] = {0, 1, 2, 3, 4, 5};
for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + arr[i];
for (int i = 0; i <= n; i++) prev_dp[i] = i*i;
compute(1, n, 0, n-1);
for (int i = 1; i <= n; i++) printf("dp[%d] = %d\n", i, dp[i]);
}Python
def cost(j, i):
s = prefix[i] - prefix[j]
return s * s
def compute(l, r, optL, optR):
if l > r:
return
mid = (l + r) // 2
best = (float('inf'), -1)
for k in range(optL, min(optR, mid) + 1):
val = prev_dp[k] + cost(k, mid)
if val < best[0]:
best = (val, k)
dp[mid], opt[mid] = best
compute(l, mid - 1, optL, best[1])
compute(mid + 1, r, best[1], optR)
n = 5
arr = [0, 1, 2, 3, 4, 5]
prefix = [0]
for x in arr: prefix.append(prefix[-1] + x)
prev_dp = [i*i for i in range(len(arr))]
dp = [0]*(n+1)
opt = [0]*(n+1)
compute(1, n, 0, n-1)
print(dp[1:])为什么它很重要
- 显著降低复杂度:每层 $O(n \log n)$
- 适用于具有单调 $opt[i]$ 的结构化递推式
- 构成了以下优化技术的基础:
- Knuth 优化
- Monge 数组动态规划
- 区间划分动态规划
你可以把它看作是“动态规划转移的二分搜索”。
一个温和的证明(为什么它有效)
如果 $opt[i] \le opt[i+1]$,那么每个 $dp[mid]$ 的最优索引 $k^*$ 都位于 $optL$ 和 $optR$ 之间。 当划分区间时:
- 左子区间($L, mid-1$)在 $[optL, k^*]$ 中搜索
- 右子区间($mid+1, R$)在 $[k^*, optR]$ 中搜索
通过归纳法,每个子区间只探索有效的转移。 由于每个 $k$ 被访问 $O(\log n)$ 次,总时间复杂度为 $O(n \log n)$。
动手试试
- 实现具有凸区间代价的划分型动态规划。
- 数值验证 $opt[i]$ 的单调性。
- 比较 $O(n^2)$ 与优化后 $O(n \log n)$ 的性能。
- 结合空间优化(滚动数组)。
- 扩展到多层动态规划(例如,k-划分)。
测试用例
| 递推式 | 性质 | 是否可优化 | ||
|---|---|---|---|---|
| $dp[i]=\min_{j<i}(dp[j]+(sum[i]-sum[j])^2)$ | 凸代价 | 是 | ||
| $dp[i]=\min_{j<i}(dp[j]+ \vert i-j \vert )$ | 线性 | 是 | ||
| $dp[i]=\min_{j<i}(dp[j]+cost[j][i])$ | Monge | 是 |
复杂度
- 时间复杂度:每层 $O(n \log n)$
- 空间复杂度:$O(n)$
- 层数:对于多阶段动态规划,需乘以 $k$
分治动态规划是你处理二次复杂度动态规划的“手术刀”,一旦发现单调性,你就能在每一层干净利落地将复杂度减半。
498 换根模板
换根动态规划是一种强大的树形动态规划模式,它允许你高效地计算以每个节点为根时的结果,通过重用父子转移中的计算。这就像将树的根节点依次旋转到所有节点上,而无需从头开始重新计算所有内容。
我们要解决什么问题?
给定一棵树,我们通常希望计算以每个节点为根时的某个属性。例如:
- 到所有节点的距离之和
- 子树的大小或基于子节点的值
- 以每个节点为根时的有效着色方案数
朴素的做法是为每个节点重新运行动态规划,时间复杂度为 $O(n^2)$,但换根法通过巧妙地重用部分结果,可以将复杂度降低到 $O(n)$ 或 $O(n \log n)$。
它是如何工作的(通俗解释)
- 第一遍(后序遍历):自底向上计算固定根节点(通常是节点 1)的动态规划值。
- 第二遍(前序遍历):自顶向下传播结果,沿着每条边进行换根,并组合来自父节点的贡献。
当根节点从 u 移动到 v 时:
- 从
u的动态规划值中移除v的贡献。 - 将
u的贡献(不包括v)加入到v的动态规划值中。
这样,每个节点在一次遍历中就能继承一个正确的换根后的动态规划值。
示例问题
计算从每个节点到所有其他节点的距离之和。
设:
- $dp[u]$ = 从节点 $u$ 到其子树中所有节点的距离之和
- $sz[u]$ = 节点 $u$ 的子树大小
我们可以使用以下公式进行换根: $$ dp[v] = dp[u] - sz[v] + (n - sz[v]) $$ 当根节点从父节点 $u$ 移动到子节点 $v$ 时。
微型代码(C)
#include <stdio.h>
#include <vector>
#define MAXN 100005
using namespace std;
vector<int> adj[MAXN];
int n;
int sz[MAXN];
long long dp[MAXN];
long long ans[MAXN];
void dfs1(int u, int p) {
sz[u] = 1;
dp[u] = 0;
for (int v : adj[u]) if (v != p) {
dfs1(v, u);
sz[u] += sz[v];
dp[u] += dp[v] + sz[v];
}
}
void dfs2(int u, int p) {
ans[u] = dp[u];
for (int v : adj[u]) if (v != p) {
long long dp_u = dp[u], dp_v = dp[v];
int sz_u = sz[u], sz_v = sz[v];
// 将根节点从 u 移动到 v
dp[u] -= dp[v] + sz[v];
sz[u] -= sz[v];
dp[v] += dp[u] + sz[u];
sz[v] += sz[u];
dfs2(v, u);
// 恢复
dp[u] = dp_u; dp[v] = dp_v;
sz[u] = sz_u; sz[v] = sz_v;
}
}
int main(void) {
scanf("%d", &n);
for (int i = 0; i < n-1; i++) {
int u, v;
scanf("%d%d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
dfs1(1, -1);
dfs2(1, -1);
for (int i = 1; i <= n; i++)
printf("从节点 %d 出发的距离之和: %lld\n", i, ans[i]);
}Python
from collections import defaultdict
n = 5
adj = defaultdict(list)
edges = [(1,2),(1,3),(3,4),(3,5)]
for u,v in edges:
adj[u].append(v)
adj[v].append(u)
sz = [0]*(n+1)
dp = [0]*(n+1)
ans = [0]*(n+1)
def dfs1(u,p):
sz[u] = 1
dp[u] = 0
for v in adj[u]:
if v == p: continue
dfs1(v,u)
sz[u] += sz[v]
dp[u] += dp[v] + sz[v]
def dfs2(u,p):
ans[u] = dp[u]
for v in adj[u]:
if v == p: continue
dp_u, dp_v = dp[u], dp[v]
sz_u, sz_v = sz[u], sz[v]
dp[u] -= dp[v] + sz[v]
sz[u] -= sz[v]
dp[v] += dp[u] + sz[u]
sz[v] += sz[u]
dfs2(v,u)
dp[u], dp[v] = dp_u, dp_v
sz[u], sz[v] = sz_u, sz_v
dfs1(1,-1)
dfs2(1,-1)
for i in range(1,n+1):
print(f"从节点 {i} 出发的距离之和: {ans[i]}")为什么它很重要
能够以 $O(n)$ 的时间复杂度计算每个节点的动态规划值。
通过可逆的转移重用子节点和父节点的信息。
对以下问题至关重要:
- 距离之和
- 子树聚合
- 着色和约束传播
一旦你知道如何移动贡献,就可以对任何树进行换根。
一个温和的证明(为什么它有效)
换根关系确保了:
- 当以 $u$ 为根时,$dp[u]$ 存储了全树的值。
- 当换根到 $v$ 时,先从 $u$ 中减去 $v$ 的贡献,然后将 $u$ 的贡献加到 $v$ 上。
因为每条边被遍历两次,所以总复杂度是 $O(n)$。
这是动态规划在树分解下可重用性的直接应用。
自己动手试试
- 计算子树和,并通过换根得到距离 ≤ k 的节点值之和。
- 应用换根法计算经过每个节点的路径数量。
- 修改转移方程以处理树着色或重心评分问题。
- 可视化父子节点之间的贡献流动。
测试用例
| 树结构 | 查询类型 | 输出结果 |
|---|---|---|
| 链状 (1–2–3–4) | 距离之和 | 6, 4, 4, 6 |
| 星状 (1–2,1–3,1–4) | 距离之和 | 3, 5, 5, 5 |
| 平衡树 | 聚合 | 对称 |
复杂度
- 时间复杂度:$O(n)$
- 空间复杂度:$O(n)$
换根动态规划是你的“遍历树”技巧:一次自底向上的遍历,一次自顶向下的遍历,你就能知道如果每个节点站在根的位置上会看到什么。
499 迭代动态规划模式
迭代(自底向上)动态规划是计算基于状态的解决方案最系统、最有效的方法。我们不再使用递归和记忆化,而是按照依赖关系的递增顺序显式地构建表格,将递推关系转化为简单的循环。
我们解决的是什么问题?
当你有一个像这样的递推关系时:
$$ dp[i] = f(dp[i-1], dp[i-2], \ldots) $$
你不需要递归,可以从基础情况迭代到目标状态。 这种方法避免了调用栈的开销,确保了可预测的内存访问,并简化了调试。
迭代动态规划适用于:
- 计数问题(例如斐波那契数列、爬楼梯)
- 路径最小化(例如最短路径、背包问题)
- 序列比对(例如最长公共子序列、编辑距离)
它是如何工作的(通俗解释)
- 定义状态 $dp[i]$:它代表什么?
- 确定基础情况(例如 $dp[0]$, $dp[1]$)。
- 使用更小的状态建立状态转移方程。
- 从最小索引迭代到最大索引,确保在使用前依赖项已被计算。
- 提取结果(例如 $dp[n]$ 或 $\max_i dp[i]$)。
迭代顺序必须与依赖方向匹配。
示例:爬楼梯
每次可以爬 1 级或 2 级台阶。 到达第 $n$ 级台阶的方法数:
$$ dp[i] = dp[i-1] + dp[i-2] $$
基础情况为 $dp[0] = 1$, $dp[1] = 1$。
微型代码(C)
#include <stdio.h>
int main() {
int n = 5;
int dp[6];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++)
dp[i] = dp[i-1] + dp[i-2];
printf("Ways to climb %d stairs: %d\n", n, dp[n]);
}Python
n = 5
dp = [0]*(n+1)
dp[0] = dp[1] = 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
print(f"Ways to climb {n} stairs: {dp[n]}")为什么它很重要
- 性能:迭代消除了递归开销。
- 清晰性:每个状态以已知的顺序计算一次。
- 内存优化:当只需要最近的状态时,可以减少空间(滚动数组)。
- 基础:所有高级动态规划(背包问题、编辑距离、最长递增子序列)都可以用迭代方式编写。
一个温和的证明(为什么它有效)
如果 $dp[i]$ 只依赖于更小的索引,那么按顺序填充 $dp[0 \ldots n]$ 就能保证正确性。
通过归纳法:
- 基础情况根据定义成立。
- 假设 $dp[0..i-1]$ 正确,那么 $dp[i] = f(dp[0..i-1])$ 会产生正确的结果。
没有状态会在计算之前被使用。
自己动手试试
- 用常数空间实现迭代的斐波那契数列。
- 将递归的背包问题转换为迭代的表格形式。
- 为两个字符串编写自底向上的最长公共子序列算法。
- 尝试为网格路径编写二维迭代动态规划。
测试用例
| 输入 | 期望输出 |
|---|---|
| $n=0$ | 1 |
| $n=1$ | 1 |
| $n=5$ | 8 |
| $n=10$ | 89 |
复杂度
- 时间复杂度:$O(n)$
- 空间复杂度:$O(n)$(使用滚动数组则为 $O(1)$)
迭代动态规划是规范形式,是将递归展开为循环的最简单、最直接的思考方式。
500 记忆化模板
记忆化是动态规划的自顶向下形式,你递归地解决问题,但存储答案,从而避免重复计算相同的状态。它是纯递归与迭代动态规划之间的自然桥梁。
我们解决的是什么问题?
许多递归问题会多次重新访问相同的子问题。 例如,斐波那契递归:
$$ F(n) = F(n-1) + F(n-2) $$
会多次重新计算 $F(k)$。记忆化通过在第一次计算后缓存结果来避免这种情况。
每当你的递归树出现重叠时,记忆化就能将指数级时间转换为多项式时间。
它是如何工作的(通俗解释)
- 定义状态:哪些参数描述了你的子问题?
- 检查缓存:如果已经解决过,则返回记忆化的值。
- 递归:使用更小的状态进行计算。
- 存储结果:在返回前存储结果。
- 返回缓存值:下次需要时直接返回。
记忆化非常适合:
- 递归定义(斐波那契、背包问题、最长公共子序列)
- 具有重叠子问题的组合计数
- 具有重复子路径的树/图遍历
示例:带记忆化的斐波那契
$$ F(n) = \begin{cases} 1, & n \le 1,\ F(n-1) + F(n-2), & \text{否则。} \end{cases} $$
我们在第一次计算时存储每个 $F(k)$。
精简代码(C)
#include <stdio.h>
int memo[100];
int fib(int n) {
if (n <= 1) return 1;
if (memo[n] != 0) return memo[n];
return memo[n] = fib(n-1) + fib(n-2);
}
int main() {
int n = 10;
printf("Fib(%d) = %d\n", n, fib(n));
}Python
memo = {}
def fib(n):
if n <= 1:
return 1
if n in memo:
return memo[n]
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
print(fib(10))为什么它很重要
- 连接递归与迭代:你保留了递归的优雅,同时获得了动态规划的性能。
- 更快的原型设计:非常适合快速构建正确的解决方案。
- 更容易推理:你只需要定义递推关系,而不需要考虑填充顺序。
- 过渡步骤:有助于后续推导出对应的自底向上解法。
一个温和的证明(为什么它有效)
我们通过归纳法证明其正确性:
- 基本情况:$dp[0]$ 和 $dp[1]$ 直接定义。
- 归纳步骤:对 $f(n)$ 的每次调用只使用更小的参数 $f(k)$,根据归纳假设,这些 $f(k)$ 是正确的。
- 缓存:确保每个 $f(k)$ 只被计算一次,从而保证总共 $O(n)$ 次调用。
因此,记忆化在保持递归语义的同时,实现了最优的时间复杂度。
动手试一试
- 编写带记忆化的背包问题,函数签名为
solve(i, w) - 记忆化子集和问题(
solve(i, sum)) - 以
(i, j)为状态递归构建最长公共子序列 - 比较记忆化版本和自底向上版本的运行时间
测试用例
| 输入 | 期望输出 |
|---|---|
| $fib(0)$ | 1 |
| $fib(1)$ | 1 |
| $fib(5)$ | 8 |
| $fib(10)$ | 89 |
复杂度
- 时间复杂度:$O(n)$(每个状态计算一次)
- 空间复杂度:$O(n)$(递归栈 + 缓存)
记忆化是动态规划的核心概念,它揭示了子问题如何重叠,并为你构建迭代解决方案做好准备。