如果你刷过一段时间的算法题,一定遇到过这样的场景:一道题用暴力枚举写出来,小数据几毫秒出结果,数据规模一上来,直接超时到怀疑人生。旁边的同学轻飘飘一句“这题动态规划一下”,剩下你对着题目发呆,想问“状态是什么”“转移怎么写”却不好意思开口。动态规划,是整个算法学习里绕不过去的一座山,也是算法工程师面试中出镜率最高的一类题。这篇内容我想用自己做过几年算法题、也面过别人的视角,把动态规划从“背模板”里解放出来,讲清楚它到底在解决什么问题、怎么从一个会超时的暴力递归一步步改成DP、实操时最容易踩哪些坑,以及顺着它怎么够到状态压缩、树形DP这些进阶模型。无论你是刚接触数据结构与算法的新手,还是正在洛谷题单上吭哧吭哧刷题的人,这篇文章应该都能给你一些新东西。
1. 动态规划的本质理解:它不是模板,是决策过程的建模
1.1 动态规划到底解决了什么问题
先看最朴素的定义:动态规划(Dynamic Programming,简称DP)是一种把原问题拆成子问题,通过解决子问题并记录结果来避免重复计算的方法。但这句话太空了。我更愿意把它理解成:当一个决策问题存在“重叠子问题”和“无后效性”两个条件时,我们可以用一张表把已经算过的答案存下来,用递推的方式从最简单的状态一路推到目标状态。这张表就是DP表,这套从简单到复杂的推法,就是动态规划。
斐波那契数列是最经典的例子。暴力递归写法是:
def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2)如果你画一下递归调用树,fib(5) 会调用 fib(4) 和 fib(3),fib(4) 又调用 fib(3) 和 fib(2),同一个 fib(3) 被重复算了好几次。n=40 的时候,递归次数已经呈指数增长,肉眼可见地卡顿。但如果开一个数组,把每个 fib(i) 都存下来,整体就变成一层线性循环。这个“避免重复计算”的价值,就是DP最直接的收益。
不过要注意,不是所有能拆分的问题都适合DP。分治算法(比如归并排序)也是拆子问题,但子问题之间基本不重叠,每个子问题独立解决完再合并,不存在重复计算。动态规划面对的则是子问题大量重叠的场景,所以才有“记录下来”的必要。另外,DP还要求决策关系是单向的、无环的,后面的状态只依赖前面阶段的结果,不会反过来影响之前的决策,这就是所谓的“无后效性”。这两个条件,是判断一道题能不能DP的第一个筛子。
1.2 从暴力枚举到DP的思维递进
很多人一开始学DP觉得难,是因为题目看起来像搜索、又像贪心,最后才想到DP。这里我建议你把“从暴力枚举到DP”的转变过程完整走一遍,而不是直接背状态。
暴力枚举的本质是“把所有可能都试一遍”。拿爬楼梯举例:一次可以爬1级或2级,问爬到第n级有多少种方法。暴力枚举就是把所有走法列出来,然后数一数。可爬到30级时,走法数量本身就已经爆炸了。这时你观察会发现,爬到第n级的方法数,只和“到第n-1级的方法数”“到第n-2级的方法数”有关,因为最后一步不是跨1级就是跨2级。于是:
f[n] = f[n-1] + f[n-2]
这就是“从最后一步反推”的思维方式,也是DP最常见的建模切入点:想想我要得到答案,最后一步有哪些可能,每一种可能把问题变成了哪个更小的子问题。
这个思路能套到大量题型里:最长公共子序列想最后两个字符是否相等;0-1背包想最后一个物品装不装;区间DP想最后合并的是哪两块。牢牢记住“从最后一步切入”这个思路,比记住任何一个具体的例题都重要。它本质上是在做一件事:把一棵庞大的递归树裁剪成一条有依赖顺序的计算链,每个节点只算一次。
1.3 动态规划三大要素:状态、转移、边界
接触过DP的人都听过“状态”“转移方程”“边界条件”这三个词。我按自己的理解拆开说一下:
- 状态:DP表里每一个格子代表什么含义。比如 f[i] 表示“爬到第i级楼梯的方案数”。状态是整个DP的灵魂,状态定错了,后面全废。
- 转移方程:相邻状态之间的关系。它描述一步决策如何把规模更小的子问题组合成当前问题的答案。转移方程必须覆盖所有情况,不能漏,也不能因为状态定义不清导致重复计算。
- 边界条件:递推起点的值。比如爬楼梯 f[1]=1、f[2]=2,或者干脆约定 f[0]=1。边界错了,整个递推从起点就歪了。
我常用一个生活化类比来理解DP:动态规划就像读一本有章节指引的故事书。你先读完最简单的第一章(边界),每读完一章就把结论记在笔记本上(状态存储),下一章只需要参考笔记本上几个之前章节的结论就能推出(状态转移)。如果没有笔记本,每章都从第一页重新读起,那就是暴力枚举;有了笔记本,每一章只读一遍,就能顺着往后推进,这就是动态规划。
2. 从暴力递归到DP的四步转换法:手把手把“递归超时”改造成“递推通过”
2.1 第一步:先用暴力递归把问题写出来
很多教程一上来就教递推,忽略了更自然的起点:先把递归写出来。递归的代码结构和题目描述最贴近,能帮你快速明确“子问题到底是什么”。我写DP题的习惯是:先按题目要求直接写一个递归函数,参数就是状态里需要区分的信息,返回值就是答案。
拿爬楼梯来说:
def climb(n): if n <= 2: return n return climb(n - 1) + climb(n - 2)递归版本虽然会超时,但它暴露出两个重要信息:参数 n 就是状态,两个递归分支就是转移的雏形。把递归函数里那些“重复计算的调用”变成“查表”,就是DP的本质改造。这一步不用想优化,只要能跑通小数据、确认逻辑正确就行。
写递归时有几个小技巧:参数尽量少,越少越容易看清状态依赖关系;递归出口一定要写在最前面;如果返回值同时受多个参数影响,别害羞,把所有必要参数都写进函数签名里。信息缺失比参数多更可怕,因为转移会无从下手。
2.2 第二步:加备忘录,改成记忆化搜索
递归慢是因为重复计算。最简单粗暴的优化就是缓存:算过的不再算,直接查。这就是记忆化搜索。把爬楼梯改成带缓存的递归:
from functools import lru_cache @lru_cache(None) def climb(n): if n <= 2: return n return climb(n - 1) + climb(n - 2)或者手动开一个 memo 数组,算之前看一眼,算完存一下。记忆化搜索是“自顶向下”的DP:从大问题出发,递归到小问题,再回溯组合答案。它的优势是思维负担小,递归骨架不用变,只加缓存。但是递归调用本身有栈开销,极端大数据下可能爆栈,而且自顶向下的写法通常不方便做空间优化。所以记忆化搜索适合“快速验证思路”和“状态转移方向难确定”的题,真正要追求效率时,还是要落到底部递推。
2.3 第三步:改成自底向上的递推
自底向上的思路是:既然大问题依赖小问题,那就从最小的边界开始,一层层往上算。还是爬楼梯:
n = int(input()) f = [0] * (n + 1) f[1] = 1 f[2] = 2 for i in range(3, n + 1): f[i] = f[i - 1] + f[i - 2] print(f[n])对比递归版本你会发现:递归里的 climb(n-1) 变成了数组里的 f[i-1];递归出口变成了数组初值;递归调用顺序变成了 for 循环从3到 n 的递推。整个过程,就是把“函数调用”换成“查表、填表”。
这里最容易犯错的是填表顺序。核心原则是:算 f[i] 时用到的所有 f[j] 都必须已经算好。大多数一维题从小到大循环就行,但有的题不是简单从小到大,比如区间DP要按“区间长度”从小到大的顺序;树形DP要先递归子树再回溯更新。所以不要机械背循环方向,要看清状态依赖方向。这一步走通了,你已经写出标准DP了。
2.4 第四步:空间优化——滚动数组与降维
动态规划的空间复杂度经常还能再砍。如果一个状态只依赖前面有限个状态,就不必保存整张表。爬楼梯只需要两个变量滚动:
a, b = 1, 2 for i in range(3, n + 1): a, b = b, a + b滚动数组的意义不只是省几个字节,有时还能帮你发现时间优化空间。比如后面要讲的0-1背包一维化,就是在滚动数组基础上通过逆序遍历区分“旧值”和“新值”,避免覆盖污染。我一直觉得,“能不能把二维滚成一维”是判断一个人DP理解是否到位的好问题。你在面试里能主动讲清楚为什么从二维降到一维、为什么遍历方向变了,会给面试官留下非常扎实的印象。
3. 核心细节解析:状态设计、转移推导与初始化的实操要点
3.1 状态设计:先把维度定下来
状态设计没有万能公式,但有可循的路径。我一般按“问题里有几个关键变量”来定维度:
- 一个变量,比如“前i个物品”“长度为i的序列”——一维DP,f[i] 通常表示前i个元素的最优值或方案数。
- 两个变量,比如“前i个物品里选,背包容量为j”——二维DP,f[i][j]。
- 如果还有额外限制,比如“必须恰好选k个”,就得加维度,变成 f[i][j][k]。
每个维度都要有明确的含义和取值范围。常见的坑是维度定义太模糊,导致转移时不知道从哪些状态来。我建议状态写好之后,先自己翻译一句人话,比如:“f[i][j] 表示处理完前i个物品、背包容量为j时能获得的最大价值”。如果这句话说不通,状态十有八九有问题。状态设计的本质是“只保留做最后一步决策所需要的最小信息集合”。比如最长递增子序列为什么必须定义成“以第i个元素结尾”而不是“前i个元素”?因为如果不记录结尾元素,就不知道后续能否继续接着增长。这就是状态里的信息必要性。
3.2 转移方程:如何确保不漏不重
写转移方程的核心技巧,我总结成三步:
- 把当前状态 f[i] 看成“还没做最后一步决策时的各种可能集合”,思考最后一步有哪些选择。
- 对每一种选择,把问题退化成一个或多个规模更小的子问题。
- 根据题目要求取 max、min 或求和,拼出转移方程。
以最长递增子序列为例:f[i] 表示以第i个元素结尾的最长递增子序列长度。最后一步是“上一个元素是谁”,可以是前面任意一个值比 a[i] 小的 j,于是:
f[i] = max(f[j] + 1),其中 j < i 且 a[j] < a[i]
这个转移不会漏,因为所有可能的 j 都枚举了;不会重,因为最大值重复不影响结果。如果题目要求“方案数”,就要小心是否把同一种序列算了多遍,这通常涉及状态语义的精细化。
还有一个点是“合法状态”与“非法状态”的区分。比如背包求“最多能装多少”和“恰好装满”的区别,本质就是初始化和非法状态的不同。求最多能装时 f 数组全初始化为0,装不下的状态天然是0;求恰好装满时 f[0]=0,其他初始化为负无穷,表示“恰好装满这些容量目前不可行”。这个细节我后面在背包部分还会重点强调,因为太多人在这里翻车。
3.3 边界与初始化:事故高发区
我见过无数人转移方程写得没问题,代码却跑不出正确答案,最后发现是初始化错了。初始化不是随便填一排0,而是在定义“子问题的基准答案”。
几个常见规则:
- 求方案数:边界通常置为1,比如 f[0] = 1 表示“空方案也是一种方案”,然后从f[0]开始累加。
- 求最大值:边界通常置为0,非法状态置为负无穷(-INF,比如 -1e9),避免被 max 选中。
- 求最小值:边界通常置为0,非法状态置为正无穷(INF,比如 0x3f3f3f3f),避免被 min 选中。
- 注意下标偏移:如果状态里出现 i>=1 访问 f[i-1],循环得从合适的位置开始;如果 f[0] 是“空状态”,它的含义要想清楚。
用 C++ 的话,memset 的字节值有讲究,0x3f 是一个很好用的“较大值”,两个 0x3f3f3f3f 相加也不会溢出 int。但它不等于“无穷大”,处理负无穷时要小心。用 Python 的话,初始化列表时多确认一次长度够不够,不要因为疏忽把 dp 数组长度设成 n-1。
4. 经典模型实战拆解:线性DP、背包DP与区间DP
4.1 线性DP:从最长递增子序列看状态与二分解法
LIS 应该是一维DP里最经典的题目,也是洛谷动态规划题单里必有的入门题。朴素DP上面写过,再贴一次完整可运行的写法:
n = int(input()) a = list(map(int, input().split())) f = [1] * n for i in range(n): for j in range(i): if a[j] < a[i]: f[i] = max(f[i], f[j] + 1) print(max(f))这是 O(n^2) 的解,n=5000 以内都能跑。如果 n=1e5,就得换思路:维护一个 tails 数组,tails[k] 表示长度为 k+1 的递增子序列中,最小的结尾元素。遍历每个数时,用二分查找找到第一个大于等于它的位置并覆盖。这个优化的核心理解是:对于长度相同的递增子序列,结尾元素越小越好,这样后续越容易接上更长的序列。这个思想非常实用,很多看起来像LIS的题都能这样转化。
实际操作中,我建议初学者先写朴素版,把 f 数组打印出来看几遍,确认自己理解了“以第i个元素结尾”这个状态的含义,再去碰二分优化。直接背二分版代码很容易出细节错误,比如二分边界是“大于等于”还是“大于”,差一个等号,最终答案就可能算错。
4.2 背包DP:0-1背包的一维化与遍历顺序
背包问题是动态规划最重要的模型之一,“0-1背包”在面试和竞赛里出现频率极高。先上二维版本:
- 状态:f[i][j] 表示前i个物品,总重量不超过j时能获得的最大价值。
- 转移:最后一个物品拿或不拿。不拿就是 f[i-1][j];拿就是 f[i-1][j-w[i]] + v[i]。
核心代码:
for i in range(1, n + 1): for j in range(1, W + 1): if j >= w[i]: f[i][j] = max(f[i - 1][j], f[i - 1][j - w[i]] + v[i]) else: f[i][j] = f[i - 1][j]一维优化版:
f = [0] * (W + 1) for i in range(1, n + 1): for j in range(W, w[i] - 1, -1): f[j] = max(f[j], f[j - w[i]] + v[i])关键点在于 j 必须从大到小遍历。为什么?因为 f[j - w[i]] 这里需要用“上一轮(i-1)的结果”。如果正序从小到大更新,那么 f[j - w[i]] 可能已经被本轮(i)更新过了,相当于同一个物品被拿了好几次,0-1背包就会悄悄变成完全背包。逆序则保证每次查到的都是更新前的旧值。
如果题目是每个物品可以无限拿的完全背包,遍历顺序就要反过来,从小到大。还有多重背包、分组背包等变体,都是在这些逻辑上做更进一层。但不管怎么变,“先枚举物品,再枚举容量,容量是逆序还是正序”永远是核心判断题。
4.3 区间DP:从石子合并看区间划分
区间DP的典型特征是:问题是在一个区间上做合并或划分,状态常常设计成 f[i][j] 表示区间 [i, j] 的最优解。石子合并是经典题:有n堆石子排成一排,每次只能合并相邻两堆,合并代价为两堆石子数之和,问最小总代价。
状态定义为 f[i][j] 把第i堆到第j堆合并成一堆的最小代价。最后一步一定是在某个位置 k 断开,把 [i, k] 和 [k+1, j] 先分别合并成两堆,再合并这两堆:
f[i][j] = min(f[i][k] + f[k+1][j] + sum(i, j))
这里最大的坑是循环顺序。如果按 i 从小到大、j 从小到大去枚举,会出现计算 f[i][j] 时 f[k+1][j] 还没算完的情况。正确做法是外层枚举区间长度 len,内层枚举左端点 i,保证每个更短的区间都已经算出来。我第一次学区间DP时也卡在这里,后来才理解这不是玄学,而是依赖关系决定的:区间越大,它依赖的小区间必须先算完。环形版本的变体通常是把原数组翻倍再按线性处理,本质上还是区间DP。
5. 完整实操实录:零钱兑换从暴力到AC全过程
5.1 题目分析与暴力递归初探
理论说再多,不如完整跑一遍。零钱兑换(LeetCode 322)是我认为最适合用来演示全过程的题,因为它能清楚展示“暴力递归 → 记忆化 → 自底向上 → 优化”的完整演进,而且它就是面试高频题。
题目大意:给定不同面额的硬币 coins 和一个总金额 amount,计算凑成总金额所需的最少硬币个数。如果没有组合能组成,返回 -1。每种硬币数量无限。
先写成暴力递归:定义 f(amount) 为凑出 amount 的最小硬币数。最后一步是“选择一枚硬币 c”,问题就变成 f(amount - c),所以:
f(amount) = min( f(amount - c) for c in coins if amount >= c ) + 1
边界:f(0) = 0。直接递归会大量重复计算 amount 的子问题,小金额可能还好,amount 一大立刻超时。我建议写算法题都先走这一步,你会在递归函数里清晰地看到状态参数、分支条件和边界,后面改写成DP表会非常顺。
5.2 状态定义与DP表填充
把递归改成自底向上的DP表:
def coinChange(coins, amount): INF = float('inf') dp = [INF] * (amount + 1) dp[0] = 0 for i in range(1, amount + 1): for c in coins: if i >= c: dp[i] = min(dp[i], dp[i - c] + 1) return dp[amount] if dp[amount] != INF else -1逐行看这个代码:
- dp[i] 初始化为 INF,表示“凑出 i 元目前还没找到有效方案”。
- dp[0] = 0 是整个递推的地基。
- 外层遍历所有金额从 1 到 amount,内层遍历每个硬币。因为硬币数量无限,这里金额从小到大正序更新,等价于完全背包。
- 最后如果 dp[amount] 仍是 INF,返回 -1。
这个代码已经是完整可提交的AC解法了。时间 O(amount * len(coins)),空间 O(amount)。能优化空间吗?这个维度只有一个,已经是线性空间,不太能再压了。但如果你写的是二维常规版,可以从二维滚到一维,这种经验值得积累。
5.3 调试技巧与小样例推演
拿到代码别急着交,先手工推一遍小数据,比如 coins = [1, 2, 5], amount = 11。
dp[1] = min(dp[1], dp[0] + 1) = 1,用1元。 dp[2] = min(dp[1] + 1 = 2, dp[0] + 1 = 1) = 1,用2元。 dp[3] = min(dp[2] + 1 = 2, dp[1] + 1 = 2) = 2,2+1。 ……推到 dp[11] = 3,即 5+5+1。
我调试DP时最常用的一招:在循环里 print 中间 dp 数组,或者只打印几个关键位置的转移过程。如果答案不对,先查边界,再手推几个小用例,最后怀疑初始化是否污染。这个排查顺序能解决九成的问题。其实很多所谓“没想到”的DP细节错误,本质上都是小样例没推透。
5.4 变体扩展:方案数与组合、排列的区别
零钱兑换有很多变体,最常见的是“返回方案总数”。求方案总数时初始化 dp[0] = 1,其他 dp 为0,转移用加法:
dp[0] = 1 for i in range(1, amount + 1): for c in coins: if i >= c: dp[i] += dp[i - c]但这里有一个经典的顺序问题:如果认为 [1, 2] 和 [2, 1] 是两种方案,上面的写法会包含两者,因为金额自外层、硬币自内层,相当于统计的是“排列”。如果想统计“组合”,即认为 [1, 2] 和 [2, 1] 是同一种方案,就要把硬币放在外层、金额放在内层:
dp[0] = 1 for c in coins: for i in range(c, amount + 1): dp[i] += dp[i - c]我建议你亲手跑一遍对比结果,这个差异一旦自己踩过,就永远记住了。理解它需要回到“状态更新顺序”的本质:外层循环决定你每次新增的“决策维度”,内层循环则枚举了在这个维度下的所有金额状态。
6. 刷DP必踩的坑:常见问题与排查技巧实录
6.1 状态定义不清晰,转移无从下手
症状:拿到题想了半天,不知道 f[i] 表示什么;或者状态定得太宽,比如直接 f[i] 表示“前i个元素的最优解”,结果转移时发现信息不够。这是初学者最容易遇到的问题。
对策:状态定义必须包含“做最后一步决策时所需的最小信息集合”。LIS 必须以第 i 个元素结尾,因为不记录结尾元素就无法判断后续能不能继续接。做题时如果转移卡住,先回头想想:做最后一步时,我需要知道哪些信息才能决定下一步?把它们全部加进状态里。宁可多一个维度和一点空间,也不要让状态信息缺失。
6.2 边界与非法状态处理失误
症状:最小值问题初始化为0,导致答案永远都是0;或者方案数问题少了 dp[0]=1,答案整体差一截。
对策:把“边界”和“非法状态”分开想清楚。dp[0] 往往是专门为递推服务的“空状态”,要单独确认语义;非法状态用正负无穷表示,保证不会被更新选中。我处理 min/max 时有个习惯:先在注释里写清“dp[i] = INF 表示目前不可达”,再写代码,防止自己手滑。边界条件不是靠记的,是靠“代入递推的第一圈”验证的,最稳妥。
6.3 数组越界与下标偏移
症状:循环里访问 dp[i - c] 时 i - c 是负数,或者 f[i-1][j-w] 数组越界。
对策:一种做法是在访问前判断 i >= c;另一种是把 dp 表开大一点,比如多开几个哨兵位置,然后用合法的初始化兜底。遇到“前i个”和“第i个”混用,下标经常差1,建议全程统一语义并在注释里固定下来。用 C++ 写算法题时尤其要注意,下标越界不一定立刻崩溃,可能悄悄把相邻内存污染了,最后输出一个非常诡异的大数,排查起来很痛苦。
6.4 无后效性问题
症状:状态在转移之后又会被后面的状态影响,导致无论怎么调整循环顺序都对不上,这种题目往往存在“循环依赖”。
对策:DP要求决策关系是有向无环的。如果发现需要依赖“后面的值”,要么换状态定义,要么用拓扑排序、搜索等其他算法。比如树上的动态规划,本质上就是借助树的天然层级关系,先算子树再回溯更新。这也是为什么我说动态规划不是万能的,它只适合无后效性的问题。面试时能主动识别出“这题不是DP”也是一种很强的能力。
6.5 空间优化带来的覆盖污染
症状:一维背包写了正序遍历,结果同一种物品被用了多次;滚动数组时两个状态互相覆盖,输出结果错误。
对策:每次做空间优化前,先想清楚当前代码需要使用“上一轮”还是“本轮”的值。需要上一轮时,要么逆序更新,要么用临时变量暂存。经验是:一维滚动后结果不对,不要靠猜,先在纸上画几轮更新过程,确认覆盖顺序。我把常见问题整理成一张速查表,方便你快速定位:
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 答案偏大 | 最大值问题初始化用了0,非法状态被选中 | 非法状态改负无穷 |
| 答案偏小 | 最小值问题初始化用了0,非法状态没排除 | 非法状态改正无穷 |
| 背包数量超限 | 0-1背包正序遍历容量 | 容量逆序 |
| 完全背包数量不够 | 完全背包逆序遍历容量 | 容量正序 |
| 数组输出神秘大数 | 数组越界 / 下标偏移 / 未初始化 | 检查下标范围 |
| 答案固定为某个值 | dp[0] 意义不清或漏初始化 | 复核边界语义 |
7. 动态规划的进阶方向与实际应用
7.1 面试中的DP考察
算法工程师面试里,DP几乎是必考板块。面试官通常会观察你:拿到题能不能快速识别模型;状态定义讨论得是否清楚;有没有空间优化意识。不会有人要求你三秒内给出最优解,但很看重你把三个要素逐步分析出来的过程。
我的建议是面试时先大声说思路:先确认能不能暴力枚举,再分析有没有重叠子问题,给出状态定义并解释为什么这么定,最后写转移方程时把“最后一步”的逻辑讲给面试官听。即使一时没有最优解,这个思维过程本身就很加分。相反,如果你上来就沉默着埋头写代码,即使写对了,面试官也难判断你是真的理解还是碰巧见过原题。
7.2 四个扩展方向:状态压缩、树形、数位、图论
想进阶的话,把线性DP、背包、区间DP吃透后,可以按这几个方向扩展:
- 状态压缩DP:当状态是集合时,用二进制位表示“哪些元素已经用过”,典型代表是旅行商问题。n 一般在20以内才适合,复杂度 2^n * n。
- 树形DP:在树上做DP,先递归处理子树,再回溯合并,比如树上背包、树的直径。关键是理解“自底向上的递归顺序”和“父子关系怎么贡献答案”。
- 数位DP:统计区间内满足特定数位性质的数,状态通常是 pos、limit、lead_zero 等几个维度,本质是带约束的数字枚举。
- 动态规划与图论结合:DAG上的最长路径、Bellman-Ford 思想,以及强化学习里的价值迭代,本质上都是“状态 + 转移 + 最优值”这个框架的外延,只是转移方式可能有概率参与。
我经常建议想系统化刷题的人,去找一份结构清晰的动态规划题单,按照线性、背包、区间、树形、状态压缩的板块顺序刷。不用贪多,每类刷透10道,比一天刷50道简单题有用得多。刷的过程也别只盯着AC,把每道题的“最后一步是什么”写在笔记里,年底复习时会发现知识网络自然连成了片。
7.3 动态规划在现实业务中怎么用
很多人觉得DP只是面试题,现实业务用不上,其实不然。最典型的是路径规划里的最短路径类问题:从起点到终点经过若干阶段,每阶段有若干选择,目标是最小化总代价,这就是一个天然的DP模型。资源分配问题,比如给几个项目分配预算,每个项目投入不同、回报不同,本质就是分组背包。热词里的“车辆动态规划问题”,比如如何排班、分配路线使总成本最小,很多时候也是先建立状态空间,再做基于DP或强化学习的策略优化。实际业务里问题规模大、约束多,往往要结合剪枝、贪心近似、强化学习等手段,但“状态、转移、边界”这套建模思想始终贯穿其中。就算你以后不专门做算法,这套“拆解问题、定义状态、建立依赖”的思考方式,对系统设计同样有启发。
我个人体会是,动态规划不太像“背题型”能学会的技能,更像一种“拆解问题”的思维习惯。每当我拿到一道新题,第一反应就是问自己“最后一步是什么”,然后顺着这个思路去定义状态。刷题刷得久了你会发现,很多看起来毫无关联的题,最后都会落进同一个模型。你若刚接触DP,别怕慢,哪怕一天只吃透一题,把状态、转移、边界写在纸上,都比囫囵吞枣刷十题走得远。如果你已经有基础了,建议挑一个之前没解出来的DP题,用“递归超时 → 记忆化 → 自底向上 → 空间优化”完整过一遍,那种通透感值得你亲身体验一次。