掌握动态规划(DP)是没有捷径的,咱们唯一能做的,就是投入时间猛猛刷题。好比学数学,只看书看视频而不做习题,是不能说学会的。
我能做的,是帮你节省找题的时间,并把这些题分类整理好。有着相同套路的题,一起做效率会更高,也更能领悟到 DP 的精髓。每个小节的题目已按照难度分排序(右侧数字为难度分)。

记忆化搜索是新手村神器(甚至可以用到游戏后期),推荐先看 动态规划入门:从记忆化搜索到递推。
但记忆化搜索并不是万能的,某些题目只有写成递推,才能结合数据结构等来优化时间复杂度,多数题目还可以优化空间复杂度。所以尽量在写完记忆化搜索后,把递推的代码也写一下。熟练之后直接写递推也可以。
问:在 1:1 翻译的过程中,如何根据记忆化搜索,确定递推数组(DP 数组)的大小?为什么有时候要开 大小的数组,有时候要开 大小的数组?
答:看记忆化搜索的参数的范围(最小值和最大值)。例如 最小是 (递归边界也算),最大是 (递归入口),那么一共有 个不同的 ,就需要开 大小的 DP 数组。如果 最小是 ,最大是 ,一共有 个不同的 ,就需要开 大小的 DP 数组。
思维扩展:
有两种做法:
思维扩展:
完成本章后,请思考:什么时候要返回 ,什么时候要返回 ?
对于一些二维 DP(例如背包、最长公共子序列),如果把 DP 矩阵画出来,其实状态转移可以视作在网格图上的移动。所以在学习相对更抽象的二维 DP 之前,做一些形象的网格图 DP 会让后续的学习更轻松(比如 0-1 背包的空间优化写法为什么要倒序遍历)。
思维扩展:
每个物品只能选一次,即要么选,要么不选。
所以 0-1 背包是「选或不选」的代表。关于「枚举选哪个」的代表,见本题单的「§4.2 最长递增子序列」。
进阶:
物品可以重复选,无个数限制。
思维扩展:
物品可以重复选,有个数限制。
求方案数
注意求方案数的题目不能用二进制优化。比如从 个相同物品中选 个,只有一种选法。但按照二进制优化,会把 分解为 ,有 和 两种选法。
如果要优化,可以考虑用 同余前缀和 优化。
二进制优化
同一组内的物品至多/恰好选一个。
也叫树上背包、依赖背包等。
注:目前力扣只有无依赖的背包,时间复杂度为 。如果有依赖,可以优化到 。
一般定义 表示对 的求解结果。
思维扩展:
思考题:
115 题的扩展。给定字符串 和 ,你可以在 的任意位置插入一个字母,插入后, 最多有多少个子序列等于 ?
思路和代码见 评论。
做法有很多:
思维扩展:
思考题:
给定整数 ,构造一个数组 ,使得 恰好有 个最长递增子序列。
一般定义 表示长为 的前缀 能否划分。
枚举最后一个子数组的左端点 ,从 转移到 ,并考虑 是否满足要求。
计算最少(最多)可以划分出多少段、最优划分得分等。
一般定义 表示长为 的前缀 在题目约束下,分割出的最少(最多)子数组个数(或者定义成分割方案数)。
枚举最后一个子数组的左端点 ,从 转移到 ,并考虑 对最优解的影响。
将数组分成(恰好/至多) 个连续子数组,计算与这些子数组有关的最优值。
一般定义 表示将长为 的前缀 分成 个连续子数组所得到的最优解。
枚举最后一个子数组的左端点 ,从 转移到 ,并考虑 对最优解的影响。
注:对于恰好型划分 DP,可以通过控制内层循环的上下界,把时间复杂度从 优化至 。例如 3473 题。
一般定义 表示前缀 在状态 下的最优值。 一般很小。
发生在前缀/后缀之间的转移,例如从 转移到 ,或者从 转移到 。
思维扩展:
计算合法子序列的最长长度、个数、元素和等。
一般定义 表示以元素 结尾的合法子序列的最长长度/个数/元素和,从子序列的倒数第二个数转移过来。
注意这里的 不是下标,是元素值。如果 不是整数,或者值域范围很大,可以用哈希表代替数组。
思维扩展:
不会设计状态?那你可要好好刷这一节了。
思维扩展:
从数组的左右两端不断缩短,求解关于某段下标区间的最优值。
一般定义 表示下标区间 的最优值。
对于类似合法括号字符串(RBS)的消除问题,通常根据题意,会有如下性质:
满足上述性质的题目(例如 3563 题),可以用区间 DP 解决。
定义 表示消除 到 的最优值。
边界:,即空串。
答案:。
学习指南:
暴力做法是枚举所有排列,对每个排列计算和题目有关的值,时间复杂度(通常来说)是 。可以解决 的问题。
状压 DP 可以把时间复杂度(通常来说)优化至 。可以解决 的问题。
一般有两种定义方式:
注:部分题目由于暴搜+剪枝也能过,难度分仅供参考。
一般定义 表示未选(或者已选)的集合为 ,且上一个填的元素(下标)为 时,和题目有关的最优值。通过枚举当前位置要填的元素(下标)来转移。
时间复杂度(通常来说)是 。
本质上就是排列型 ②。
相似问题:
一般定义 表示未选(或者已选)的集合为 时,和题目有关的最优值。通过枚举 (或者 的补集 )的子集来转移。
时间复杂度(通常来说)是 ,证明:
对于大小为 的集合,它的大小为 的子集有 个,每个子集又有 个子集。根据二项式定理,,所以「枚举子集的子集」的总体时间复杂度为 。
值得注意的是,枚举子集的子集还可以用「选或不选」来做,对于存在无效状态的情况,可以做到更优的时间复杂度。具体见 1349 题解 最后的写法。
相关问题:
子集和 DP(Sum Over Subsets DP,SOS DP),国内算法竞赛圈一般叫高维前缀和。
模板(从子集转移过来):
# 设 w 为 a[i] 的二进制最大长度
# 返回一个长为 2^w 的数组 f,其中 f[S] 表示 a 中是 S 的子集的元素个数(把二进制数视作集合)
# 时间复杂度 O(n + U log U),其中 U = max(a)
def sos_dp(a: List[int]) -> List[int]:
mx = max(a)
w = mx.bit_length() # 二进制长度上限
u = 1 << w
f = [0] * u
for x in a:
f[x] += 1 # 初始值
for i in range(w):
bit = 1 << i
s = 0
while s < u:
s |= bit # 优化:快速跳到 i 位是 1 的 s
f[s] += f[s ^ bit]
s += 1
return f模板(从超集转移过来):
# 设 w 为 a[i] 的二进制最大长度
# 返回一个长为 2^w 的数组 f,其中 f[S] 表示 a 中是 S 的超集的元素个数(把二进制数视作集合)
# 时间复杂度 O(n + U log U),其中 U = max(a)
def sos_dp(a: List[int]) -> List[int]:
mx = max(a)
w = mx.bit_length() # 二进制长度上限
u = 1 << w
f = [0] * u
for x in a:
f[x] += 1 # 初始值
for i in range(w):
bit = 1 << i
s = 0
while s < u:
s |= bit # 优化:快速跳到 i 位是 1 的 s
f[s ^ bit] += f[s]
s += 1
return f数位 DP v2.0 模板讲解(上下界数位 DP)
下面是数位 DP v2.1 模板。相比 v2.0,不需要写 参数。
注:只有上界约束的题目,相当于 或者 。
# 代码示例:返回 [low, high] 中的恰好包含 target 个 0 的数字个数
# 比如 digitDP(0, 10, 1) == 2
# 要点:我们统计的是 0 的个数,需要区分【前导零】和【数字中的零】,前导零不能计入,而数字中的零需要计入
def digitDP(low: int, high: int, target: int) -> int:
low_s = list(map(int, str(low))) # 避免在 dfs 中频繁调用 int()
high_s = list(map(int, str(high)))
n = len(high_s)
diff_lh = n - len(low_s)
@cache
def dfs(i: int, cnt0: int, limit_low: bool, limit_high: bool) -> int:
if cnt0 > target:
return 0 # 不合法
if i == n:
return 1 if cnt0 == target else 0
lo = low_s[i - diff_lh] if limit_low and i >= diff_lh else 0
hi = high_s[i] if limit_high else 9
res = 0
start = lo
# 通过 limit_low 和 i 可以判断能否不填数字,无需 is_num 参数
# 如果前导零不影响答案,去掉这个 if block
if limit_low and i < diff_lh:
# 不填数字,上界不受约束
res = dfs(i + 1, 0, True, False)
start = 1
for d in range(start, hi + 1):
res += dfs(i + 1,
cnt0 + (1 if d == 0 else 0), # 统计 0 的个数
limit_low and d == lo,
limit_high and d == hi)
# res %= MOD
return res
return dfs(0, 0, True, True)从低到高:
每个元素 都有一个相应的价值 ,计算 中的满足题目约束的元素的价值总和。
例如 是计算满足题目约束的元素和, 是计算满足题目约束的元素的数位和。
模板(数位和):
# 计算在 [low, high] 中的整数 x 的数位和,满足 x 中的不同数字个数不超过 k
def digitDPContribution(low: int, high: int, k: int) -> int:
low_s = list(map(int, str(low))) # 避免在 dfs 中频繁调用 int()
high_s = list(map(int, str(high)))
n = len(high_s)
diff_lh = n - len(low_s)
# dfs 返回两个数:子树合法数字个数,子树数位总和
@cache
def dfs(i: int, mask: int, limit_low: bool, limit_high: bool) -> Tuple[int, int]:
if i == n:
# 如果没有特殊约束,那么能递归到终点的都是合法数字
return 1, 0
lo = low_s[i - diff_lh] if limit_low and i >= diff_lh else 0
hi = high_s[i] if limit_high else 9
cnt = res = 0
start = lo
# 如果前导零不影响答案,去掉这个 if block
if limit_low and i < diff_lh:
# 不填数字,上界不受约束
cnt, res = dfs(i + 1, 0, True, False)
start = 1
for d in range(start, hi + 1):
new_mask = mask | 1 << d
if new_mask.bit_count() > k: # 不满足要求
continue
sub_cnt, sub_sum = dfs(i + 1,
new_mask,
limit_low and d == lo,
limit_high and d == hi)
cnt += sub_cnt # 累加子树的合法数字个数
res += sub_sum # 累加子树的数位总和
res += d * sub_cnt # d 会出现在 sub_cnt 个数中(贡献法)
# cnt %= MOD; res %= MOD
return cnt, res
return dfs(0, 0, True, True)[1]前置题单:单调栈(矩形系列/字典序最小/贡献法)
一般用来维护一段转移来源的最值。
有两种类型的矩阵快速幂优化 DP:
时间复杂度一般为 。
进阶做法:先用 Berlekamp-Massey 算法找规律,得到线性递推式,然后用 Kitamasa 算法(或者 Bostan-Mori 算法)优化。
请看我的知乎科普文章:
Berlekamp-Massey 算法:如何预测数列的下一项?
具体例子见 3700 题 我的题解的方法二。
时间复杂度一般为 。
也叫凸包优化/凸壳优化(CHT,Convex Hull Trick)。
把最多选 个物品的问题(时间复杂度高)转换成选任意个物品的问题(时间复杂度低)。
一般时间复杂度为 或者 。
此外「§5.3 约束划分个数」的部分题目也可以用 WQS 二分优化。
关于倍增算法,见 题单 的「§3.8 最近公共祖先(LCA)、倍增算法」。
注:可能有同学觉得树形 DP 没有重复访问同一个状态(重叠子问题),并不能算作 DP,而是算作普通的递归。这么说也有一定道理,不过考虑到思维方式和 DP 是一样的自底向上,所以仍然叫做树形 DP。此外,如果是自顶向下的递归做法,是存在重叠子问题的,一般要结合记忆化搜索实现。
注:求直径也有两次 DFS 的做法。
讲解:树形 DP:监控二叉树【基础算法精讲 25】,包含 968 的变形题。
也叫二次扫描法。
注:前后缀分解,可以视作一条链上的换根 DP。
另见【题单】图论算法 中的「全源最短路:Floyd」,本质是多维 DP。
注意这些题目和回溯的区别,某些回溯题目要求输出所有方案,这里只要求输出一个。
部分题目也可以用状态机 DP 解决。
如果涉及到的只是若干元素,而不是前缀/后缀这样的一段元素,也可以用「枚举右,维护左」思考,详见数据结构题单。
补充题目:
部分题目也可以用 BFS 解决。
另见 图论题单 中的 Dijkstra 算法,例如:
欢迎关注 B站@灵茶山艾府
如果你发现有题目可以补充进来,欢迎评论反馈。