
动态规划法这几个字科班出身的人大学里都听过面试前也总在恶补但真正能把它讲清楚、用明白的说实话不多。不少人背了一堆题换个问法就懵根源在于没搞懂动态规划法到底在做什么——它不是一个固定的模板而是一套“怎么把大问题拆成小问题、再从小问题的答案拼出大问题答案”的思维方式。这篇文章我就用实际做题的思路把动态规划法从头到尾拆一遍包括状态怎么定义、转移方程怎么写、初始化为什么那么重要、遍历顺序搞反了会出什么错以及最容易踩的坑。适合刚接触DP的初学者也适合刷了很多题但总觉得差点意思的进阶者看完能对这套方法论有一个更清晰的地图。1. 动态规划法到底在解决什么问题1.1 什么时候该想到用动态规划先记住一件事动态规划法不是用来解决所有优化问题的它有自己明确的适用场景。通常需要满足三个条件——最优子结构、重叠子问题、无后效性。这三个词听着抽象我用实际例子拆开讲。最优子结构的意思是整个问题的最优解里包含着子问题的最优解。拿最短路径来说从北京到广州的最短路线如果经过武汉那么北京到武汉这段也一定是北京到武汉的所有路线里最短的否则换成更短的那段整体路线就更短了矛盾。这个性质决定了你可以先解决子问题再把子问题的答案组装起来。重叠子问题更关键它是动态规划法比暴力递归高效的根本原因。斐波那契数列是教科书级例子递归计算 fib(10) 会反复计算 fib(3) 好几十次这些重复计算完全可以用一个数组存下来用到了直接取。动态规划法本质就是“用空间换时间”把已经算过的子问题答案缓存起来避免重复劳动。无后效性这个条件最容易忽略也最容易出问题。简单说就是某个阶段的状态一旦确定之后怎么走只跟当前状态有关跟它是怎么走到这个状态的无关。流水线生产里当前工序的产出只取决于上一道工序给我的半成品状态不关心这批料之前经历了多少次返工。如果一个问题里“历史路径”会影响“未来选择”那就不适合用经典动态规划法而需要考虑状态压缩或其他方法。这三个条件判断清楚了才能确定动态规划法是不是正确工具。工具用错了后面的努力全白费。1.2 动态规划法和分治、贪心到底差在哪很多人把分治、贪心、动态规划法混在一起面试时让你对比差异就支支吾吾。我用一个生活化的场景来区分假设你要从一层爬到十层可以选择爬楼梯或者坐电梯。分治像是把“从1层到10层”拆成“1层到5层”和“5层到10层”两边各走各的互不干扰最后把答案拼起来。典型如归并排序、快速排序子问题之间是独立的不需要共享中间结果。贪心像是在每一层的楼梯口选一条看起来最好的路选了就不回头。每一步做局部最优决策希望最后全局也最优。经典如找零钱问题在某些币值下可以用贪心因为每一步选最大面额不会让结果变差。但贪心不保证全局最优它依赖问题本身的特殊性质这就是为什么很多需要用动态规划法的题目贪心会得出错误答案。动态规划法则是把路都走一遍但聪明的走法记录下每个楼层到达的最小代价后续计算时直接利用已经得到的最优值。它允许决策之间相互影响通过枚举所有可能的状态转移保证拿到的是全局最优。再补一个很容易混淆的概念记忆化搜索。记忆化搜索就是“递归缓存”从大问题开始向下递归边算边存本质上和动态规划法计算的内容完全一样只是方向上是一个自顶向下、一个自底向上。我个人的经验是初学者先用记忆化搜索理解状态转移逻辑逻辑理顺了再改成自底向上的迭代写法正确率高很多。2. 动态规划法的三个核心环节2.1 状态定义——dp数组里存的东西决定了题目难度的一半动态规划法的第一步永远是定义状态也就是想清楚“dp[i] 代表什么”。这一层想明白了后面基本水到渠成想不明白写出来的转移方程一定是绕的。我见过太多人一上来就套模板根本不管 dp[i] 的实际含义结果边界条件和转移全靠猜。状态定义有两个硬性要求一是能够完整描述一个子问题的所有关键信息二是这个状态的维度要尽可能少否则复杂度爆炸。用经典的最长递增子序列举例。给定数组 [10, 9, 2, 5, 3, 7, 101, 18]要求最长的递增子序列长度。一个自然的状态定义是 dp[i] 以 nums[i] 结尾的最长递增子序列长度。为什么是“以 nums[i] 结尾”而不是“前 i 个元素里的最长递增子序列长度”因为前 i 个元素的状态不足以判断第 i1 个元素能不能接上——你必须知道当前递增子序列的最后一个值是多少才能决定后续元素能否扩展。而“以 nums[i] 结尾”就把最后一个元素值这个关键信息固化进了状态里这就是状态定义的学问。一句话总结状态定义里要包含所有“影响未来决策的关键信息”。未来的元素能不能接取决于当前子序列末尾的值所以末尾元素必须进状态。2.2 状态转移方程——从旧状态推新状态的那一步状态转移方程是动态规划法的心脏它描述了“一个状态如何从之前的一个或多个状态推导出来”。通俗地理解它就是“递推关系式”但它是带着约束条件的递推每个状态可能从多个前驱状态中选一个最优的。还是拿最长递增子序列来说。定义 dp[i] 以 nums[i] 结尾的最长递增子序列长度那 dp[i] 该怎么算我们需要在 i 之前找一个下标 j满足 j i 且 nums[j] nums[i]这样 nums[i] 就能接在以 nums[j] 结尾的递增子序列后面形成更长的子序列。因为要求最长所以需要遍历所有满足条件的 j取其中的最大值再加 1。转移方程写出来就是dp[i] max(dp[j] 1)对所有满足 0 j i 且 nums[j] nums[i] 的 j如果没有满足条件的 j则 dp[i] 1表示自己单独成一个子序列。这就是转移方程的本质它不是在猜而是严格说明了“当前状态”和“前序状态”之间的数量关系。方程的推导过程其实就是在穷举所有可能的前驱状态然后根据题目要求取最大、最小或累加。再看一个带约束的例子——打家劫舍。题目是每个房屋有金额 nums[i]但不能偷相邻的两家。定义 dp[i] 前 i 个房屋能偷到的最大金额。对于第 i 个房屋只有两种选择不偷那结果就是 dp[i-1]偷那第 i-1 个房屋不能偷结果是 dp[i-2] nums[i]。两者取最大dp[i] max(dp[i-1], dp[i-2] nums[i])这个方程的逻辑非常清晰它把“决策”体现在了状态转移上——面对当前元素选还是不选选了会失去什么、获得什么全在方程里。2.3 初始化与遍历顺序——新手最容易翻车的两个角落状态定义和转移方程想清楚了还有一个很多人栽跟头的地方初始化。初始化的本质是定义“最基础的那些状态”的值它们是递推的起点没有它们整个递推就无从开始。以打家劫舍为例dp[0] 0 表示没有房屋时能偷 0 元dp[1] nums[1] 表示只有一个房屋时必须偷它才能最大化收益。这里有个容易犯的错有些题目下标从 1 开始有些从 0 开始边界条件就会不一样。写代码前先把下标体系定下来不要做到一半来回改。遍历顺序也很关键它必须保证“计算 dp[i] 时它依赖的所有状态都已经算好了”。比如打家劫舍dp[i] 依赖 dp[i-1] 和 dp[i-2]那就从左往右遍历这是自然而然的事。有些问题却没那么直观比如背包问题遍历顺序一变答案直接错误。这个我后面单独展开因为这是动态规划法最重要的实操细节之一。初始化口诀先把最小的那批状态想清楚再开始写循环不确定时用笔在纸上手动模拟一遍前几个状态的计算过程能发现大多数初始化错误。3. 从两个经典问题看完整解题流程3.1 爬楼梯问题动态规划法的最小完备示例爬楼梯可能是最简单的动态规划法入门题了但它麻雀虽小五脏俱全值得完整走一遍一次可以爬 1 级或 2 级台阶问爬到第 n 级有多少种不同的方法。第一步定状态dp[i] 爬到第 i 级台阶的方法数。 第二步写转移到达第 i 级台阶最后一步要么从第 i-1 级跨 1 级上来要么从第 i-2 级跨 2 级上来。所以 dp[i] dp[i-1] dp[i-2]。这里用的是加法原理两种方式是互斥的。 第三步初始化dp[1] 1爬 1 级只有 1 种方式dp[2] 211 或 2共 2 种。如果你想下标从 0 开始dp[0] 1dp[1] 1也能推出同样的结果。 第四步遍历从 3 到 n 正向循环每个状态依赖前两个状态顺序没问题。写完代码后有个很容易想到的优化dp[i] 只用到了 dp[i-1] 和 dp[i-2]根本不需要一个长度为 n 的数组用两个变量滚动更新就行。这就是滚动数组的思想后面会深入讲。有意思的是爬楼梯问题的状态转移方程和斐波那契数列完全一样差别只在初始化不同。这给了一个很重要的启示不同题目可能共享同一套递推结构关键是要理解每个变量在具体语义下代表什么而不是死记硬背代码。3.2 0-1 背包问题为什么遍历顺序如此重要背包问题是动态规划法里最经典的模型也是面试常客而且它最能说明“遍历顺序”这个反直觉的坑。题目描述是这样的有 n 个物品第 i 个物品重量为 w[i]价值为 v[i]背包容量为 C每个物品最多选一次问能装下的最大价值是多少。状态定义用二维数组dp[i][j] 考虑前 i 个物品、背包容量为 j 时能获得的最大价值。转移方程写出来不选第 i 个物品dp[i][j] dp[i-1][j] 选第 i 个物品dp[i][j] max(dp[i][j], dp[i-1][j - w[i]] v[i])前提是 j w[i]二维情况下遍历顺序其实无所谓正序逆序因为 dp[i][...] 永远从 dp[i-1][...] 推过来状态之间天然隔离。但一旦压缩成一维数组问题就来了。压缩后的转移方程变成dp[j] max(dp[j], dp[j - w[i]] v[i])这里 j 如果从小到大遍历dp[j - w[i]] 可能已经被当前第 i 个物品更新过了导致同一个物品被选多次这就是完全背包的行为。而 0-1 背包要求每个物品只能选一次所以 j 必须从大到小遍历保证计算 dp[j] 时用到的 dp[j - w[i]] 还是上一轮没选过当前物品的值。这个细节用一句话说透一维背包里逆序遍历是 0-1 背包正序遍历是完全背包。很多人背了这个结论但不知道原因面试一追问就露馅。你理解了这个原理即使紧张也不会错。4. 动态规划法的进阶优化技巧4.1 空间优化——从 O(n²) 到 O(n) 再到 O(1)动态规划法经常被诟病空间占用大但很多情况下空间是可以大幅压缩的压缩的关键在于观察状态转移的依赖关系。我们做的优化不是魔法而是严格依据“当前状态到底依赖哪些历史状态”来决定保留什么、丢弃什么。还是用最长递增子序列的二维版本举例。如果用 dp[i] 表示以 nums[i] 结尾的最长递增子序列长度这本来就是 O(n) 空间不需要优化。但有些二维 DP 问题比如编辑距离、最长公共子序列状态表是二维的空间 O(n*m)这时候就可以用滚动数组。以最长公共子序列LCS为例两个字符串长度分别为 m 和 n定义 dp[i][j] s1 前 i 个字符和 s2 前 j 个字符的最长公共子序列长度。观察转移方程if s1[i] s2[j]: dp[i][j] dp[i-1][j-1] 1 else: dp[i][j] max(dp[i-1][j], dp[i][j-1])计算第 i 行时只用到了第 i-1 行再往前的行就完全用不到了。所以不需要保留整个二维表只需要两个长度 n 的一维数组甚至可以用一个一维数组加两个临时变量搞定空间从 O(n*m) 降到 O(n)。在我的实际经验中这种空间压缩很少引入 bug因为它的逻辑很直观更新顺序和二维表的逐行计算保持一致就行。关键是理解每一行之间怎么传递而不是机械地套代码。4.2 时间优化——从 O(n²) 到 O(n log n) 的尝试动态规划法的时间复杂度往往卡在状态转移这一步很多题目的瓶颈不是状态数多而是“找最优前驱状态”需要遍历导致复杂度多乘了一个 n。拿最长递增子序列问题来说上面那种 O(n²) 的做法在 n1000 时毫无压力但 n100000 时就完全不行了。优化思路很巧妙额外维护一个数组 tails其中 tails[k] 表示长度为 k1 的递增子序列中末尾元素的最小值。然后对于每个新的元素 x用二分查找找到它在 tails 中应该替换的位置。这个方法的时间复杂度是 O(n log n)是贪心二分的结合但换汤不换药它仍然利用了动态规划法的核心思想——维护一个随长度变化的最优状态。我不是让你跳过朴素 DP 直接背这种优化方案而是想说当遇到时间复杂度不够的问题时先看状态转移的瓶颈在哪是“找前驱状态”太慢还是状态数太多。前者用数据结构优化线段树、树状数组、二分查找后者想办法压缩状态维度。动态规划法的优化没有银弹但有一条主线先写出最朴素、逻辑最清晰的版本确保正确再根据依赖关系逐步优化空间和时间。一上来就追求最优雅的解法往往因为逻辑太绕而写错得不偿失。5. 动态规划法实操避坑指南5.1 六个最容易犯的错误我见过太多人在动态规划法上栽跟头仔细总结下来错误高度集中在以下六类。第一状态定义不清楚导致转移方程写出来是“猜”的。解决方案是动手写之前用一句话说清楚 dp[i] 到底是什么再举个例子验证几个小规模输入。第二初始化搞错。比如 dp[0] 该是 0 还是 1dp[1] 该不该设很多人不去想 dp[0] 的语义直接照抄模板边界一错全盘皆输。第三遍历顺序反了尤其是二维 DP 压缩成一维之后0-1 背包和完全背包的区分就在这里非常容易踩雷。第四数组越界状态转移里用了 i-1、i-2 甚至 j-w[i]但循环起点没有同步调整运行时报错或得到垃圾值。第五忽略了“无后效性”的检查把有后效性的问题硬套动态规划法得出错误答案还找不到原因。第六过度设计用高深优化代替朴素解法代码写错后连排查都不知道从哪下手。5.2 快速定位问题的方法论动态规划法的题一旦结果不对不要急着一行行读代码先用小样例跑一遍并打印每个状态的值对照手动推导的表格立刻就能定位到是哪个状态的初始化、转移还是边界出了问题。我自己的排查习惯是这样的准备一个不足 5 个元素的测试用例笔算一遍完整的 dp 表然后让程序把每个 dp[i] 的值打印出来逐项对比。从第一个不一致的状态开始查往前倒推它依赖的前驱状态值是否也对得上通常一两轮就能锁定问题。这个方法说起来简单但实际Debug效率极高比盯着代码干想快得多。再多说一个实操心得写动态规划法的代码时刻意把“状态定义”“转移方程”“初始化”写进注释里代码和注释一一对应。这样过了几个月再回来看还能快速理解当初的思路也方便别人 review 时看到你的思考过程。6. 动态规划法的学习路径建议6.1 按难度递进的刷题顺序很多初学者一上来就做困难题被打击得体无完肤后就觉得自己不适合算法其实只是路线不对。动态规划法的学习适合由易到难、由标准模板到变形优化。我的建议是从爬楼梯、斐波那契这类递推型开始理解“状态”和“转移”的基本概念不复杂。然后是二维网格路径问题比如从左上角到右下角的路径数目或最小路径和体会二维状态表的构建。接着是序列型问题像最长递增子序列、最长公共子序列这些要理解“以当前位置结尾”这种状态定义的用意。再往后是带约束的背包类问题这里要重点理解遍历顺序、空间压缩。最后再碰区间 DP、树形 DP 这些偏门一点的场景那时候你对动态规划法的理解已经足够扎实学起来会是水到渠成的事。我在实际带新人的时候发现把这个顺序走完大概需要五十道到一百道题过程中每一类都要做到“合上答案自己完整推导出来”的程度而不是看完题解觉得懂了就往下走。6.2 从刷题到真正掌握的习惯养成刷题数量不是目标真正的目标是形成“状态感知力”——拿到一道题能下意识地判断这题能不能用动态规划法能的话状态怎么定义、转移怎么写。这种能力只能靠刻意练习获得没有捷径。我有一个习惯每做完一道动态规划法的题不论对错都会在笔记里回答四个问题为什么这题可以用动态规划法状态定义里的关键信息是什么如果是暴力穷举复杂度是多少状态转移有没有可能进一步优化这四个问题回答清楚了这道题才真正变成自己的。我自己的体会是动态规划法是算法里少有的“懂了就一通百通”的知识点。因为它本质上就是“聪明地穷举”一旦你透彻理解了状态、转移、边界这三个关键词遇到新题就自然知道怎么拆解。最怕的是停留在“背模板”的阶段那换个包装就认不出来了。可能看到这里你还是觉得动态规划法的概念有点多这很正常。我第一次系统学它的时候也是这样但后来发现它其实只需要你做到三件事第一把一个复杂问题拆成一堆有依赖关系的小问题第二明确每个小问题的答案要记录什么信息第三从小到大地依次算出答案直到凑出最终结果。把这个流程刻进脑子里动态规划法就不再是玄学而是一个你随时能调用的工具。以后遇到最短路径、资源分配、文本相似度这类真实场景问题你也会习惯性地想能不能用这个思路来解