
先说明一下这个标题是我自己挖的坑一个入门DP的完整踩坑实录从最初连状态都不会定义到后来能顺手处理各类线性DP模型中间把洛谷题单刷了两轮也把动态规划在车辆路径问题里的玩法摸了个大概。这篇不整虚的直接把我理解DP模型的思路、做题时的套路、以及实际工程里怎么用DP解决路线规划问题一次性捋清楚。不管你是刚接触动态规划的新手还是刷题刷到瓶颈想换个视角的老手这篇应该都能给你一点启发。1. 内容整体设计与思路拆解1.1 为什么动态规划这么难学很多人一上来就背状态转移方程然后发现换个题目又不会了。问题的根源不在于方程本身而在于你根本没理解这个方程是怎么被设计出来的。动态规划的核心不是公式而是三件事状态怎么定义、转移怎么推导、边界怎么处理。只要这三样想明白方程是自然写出来的不需要背。我当年入坑的时候第一个看不懂的问题是为什么最长上升子序列的状态是dp[i]表示以第i个元素结尾的最长上升子序列长度而不是前i个元素的最长上升子序列长度这两个定义乍一看差不多实际上差别大了去了。前者把结尾元素写进了状态里这样转移的时候新元素只需要和结尾元素比较大小就能决定能不能接上去后者虽然也能做但转移的时候你得知道前i个元素里最长子序列的结尾是谁这个信息在状态里根本没有保存转移就变得很麻烦。这就是DP设计的第一个核心心得状态里要包含转移所需的最小信息量。你不需要把所有信息都塞进去但转移时依赖的信息一定要在状态里能找到。1.2 DP和背包、贪心、暴力的边界在哪里初学的时候最容易混淆的是贪心和动态规划。贪心是每一步都做当前看起来最优的选择它赌的是局部最优能推出全局最优DP是记录所有可能的选择和它们的结果最后从这些结果里挑出最优的它不赌它把可能性都算一遍。用个生活中的例子你出门买菜要经过五个路口每个路口可以选左边的菜摊或者右边的菜摊价格和新鲜度各不相同。贪心的做法是每个路口都选当前最便宜的那家赌整体花销最小DP的做法是把到达每个路口时花销最小和新鲜度最高这两种状态都记下来到了下一个路口用上一步的状态加上当前的选择更新出新的最优状态最后在所有状态里挑最符合你需求的。那么什么时候应该用DP有一个很实操的判断标准问题可以拆成多个子问题子问题之间有重叠并且你大概能画出状态之间的转移关系。如果子问题完全独立没有重叠用分治或递归就够了如果子问题互相影响且存在大量重复计算DP就是最合适的选择。暴力搜索复杂度通常是阶乘或指数级别DP能把复杂度压缩到多项式级别这就是它的存在意义。1.3 我搭的这套DP学习框架经过两轮洛谷题单的洗礼我把DP的学习路径拆成了五层第一层是模型识别看到题目能认出这是线性DP、背包DP、区间DP、树形DP还是状压DP第二层是状态设计认清楚状态里需要存什么信息第三层是转移推导从当前状态出发想清楚能走到哪些后续状态或者当前状态能从哪些前面状态走过来第四层是边界与初始化确定dp数组的起点和无效值第五层是复杂度优化从朴素写法到滚动数组、单调队列、斜率优化等进阶手段。这套框架看着简单但每一层都有坑。模型识别错误会导致整个思路跑偏状态设计漏信息会让转移无从下手边界初始化错了程序能跑但答案不对并且这类逻辑错误非常难查——因为编译器不会报错你的代码语法全对但结果就是差几个数。刷题过程中我在这五个环节上反复踩坑下面把最核心的细节拆开讲。2. 核心细节解析与实操要点2.1 线性DP的三个经典模型线性DP是DP里最基础也是最重要的分支洛谷题单的前半段基本全在这上面。我总结了三个必须吃透的经典模型这三个模型之间的变体几乎覆盖了线性DP的绝大多数考题。第一个是最长上升子序列状态定义是dp[i]表示以a[i]结尾的最长上升子序列长度转移方程是dp[i] max(dp[j] 1)其中j满足j i且a[j] a[i]初始状态所有dp[i]都设为1。朴素写法的时间复杂度是O(n^2)用贪心加二分可以优化到O(n log n)。第二个是最长公共子序列状态定义是dp[i][j]表示a的前i个字符和b的前j个字符的最长公共子序列长度转移分两种情况如果a[i] b[j]那么dp[i][j] dp[i-1][j-1] 1否则dp[i][j] max(dp[i-1][j], dp[i][j-1])。这里的关键是理解当前字符相同则从左上角继承并加1当前字符不同则从左边或上边继承较大值这个转移逻辑在编辑距离等很多题目里都能复用。第三个是最大子段和状态定义是dp[i]表示以a[i]结尾的最大子段和转移是dp[i] max(a[i], dp[i-1] a[i])。这个转移背后的含义是新元素单独成一段还是接在前面的段后面。很多人觉得这个太简单但它体现了DP里一个非常重要的思想——决策就是二选一你把接或不接这个选择翻译成了max整个问题就干净利落地解决了。2.2 状态定义的三条铁律状态定义是整个DP问题的魂定义错了后面全错。我踩坑总结出来三条铁律第一条状态必须完整描述当前决策所需的信息。还是拿最长上升子序列举例你转移时要比较大小所以状态里必须包含结尾元素的信息而不能只存长度。否则你只知道当前子序列多长不知道应该拿什么和新元素比较转移就断了。第二条状态的维度决定了时间复杂度不要把无关信息塞进状态里。比如要求输出具体方案时你可以另外开一个pre数组记录路径而不是在dp状态里存一整个序列。状态维度越高复杂度越爆炸能用一维解决的不要用二维。第三条状态的顺序要满足无后效性也就是说某阶段的状态一旦确定就不受之后阶段决策的影响。这是DP能成立的前提。做题时可以问自己当前状态是不是只和之前的状态有关如果状态会依赖未来的信息要么重新设计状态要么换一种遍历顺序。2.3 边界和初始化的坑我帮你踩过了边界定不好程序不报错就是在答案上给你使绊子。最常见的错误是求最小值的时候初始化为0导致小的答案被0污染求方案的组合数时忘了设置dp[0] 1这个基准起点余数DP时忘了初始化dp[0][0] 0导致全程结果错误。我记得特别清楚的一个案例洛谷P1048采药说白了是0-1背包。我的状态定义是dp[i][j]表示前i个物品放入容量为j的背包的最大价值边界条件是dp[0][j] 0表示没有物品时价值为0这个没错。但我第一次写的时候把物品循环写反了导致每个物品被重复放入被重复取用结果答案偏大。后来我换了一维滚动数组的写法容量循环改成从大到小倒序遍历这个问题就彻底规避了。这里分享一个通吃的边界检查方法先拿最简单的小数据比如n1或n2手动在纸上跑一遍转移把所有dp值手算出来再和程序输出对比。这一步能筛掉80%的边界问题。3. 实操过程与核心环节实现3.1 手把手实现最长上升子序列我以洛谷B3637最长上升子序列为例完整走一遍从状态设计到代码实现的过程。给定一个长度为n的序列a要求严格上升的子序列最长长度。状态定义用dp[i]表示以a[i]结尾的最长上升子序列长度。初始化每个元素自己就是一个长度为1的子序列所以dp数组全部设为1。转移对于每个i遍历所有j i如果a[j] a[i]说明a[i]可以接在以a[j]结尾的上升子序列后面此时dp[i] max(dp[i], dp[j] 1)。n int(input()) a list(map(int, input().split())) dp [1] * n for i in range(n): for j in range(i): if a[j] a[i]: dp[i] max(dp[i], dp[j] 1) print(max(dp))这个朴素写法的复杂度是O(n^2)n跑到5000以上就会吃力。进阶优化是用一个辅助数组tail来维护长度为len的上升子序列的最小结尾元素然后对每个新元素在tail里做二分查找复杂度降到O(n log n)。import bisect tail [] for x in a: pos bisect.bisect_left(tail, x) if pos len(tail): tail.append(x) else: tail[pos] x print(len(tail))这段代码的巧妙之处在于tail数组本身不是某个具体的最长上升子序列它只维护长度对应的最小结尾值。比如序列是[2, 5, 3, 4]处理完前三个元素后tail [2, 3]表示长度为1的上升子序列最小结尾是2长度为2的上升子序列最小结尾是3对应子序列[2, 3]。bisect_left找的是第一个不小于x的位置保证严格递增。3.2 0-1背包的滚动数组优化背包问题是DP的另一座大山洛谷题单里P1048采药、P1060开心的金明都是经典题。先理清0-1背包的状态dp[i][j]表示前i件物品放入容量为j的背包能获得的最大价值转移是dp[i][j] max(dp[i-1][j], dp[i-1][j - w[i]] v[i])意思是第i件物品不放入或放入两种情况取最大值。由于dp[i]这一层的更新只依赖dp[i-1]这一层可以用一维数组滚动优化n, m map(int, input().split()) dp [0] * (m 1) for _ in range(n): w, v map(int, input().split()) for j in range(m, w - 1, -1): dp[j] max(dp[j], dp[j - w] v)核心技巧是容量循环必须从大到小。因为如果从小到大dp[j - w]已经被本轮的物品更新过相当于同一个物品被放入了多次这就变成了完全背包。从大到小遍历时dp[j - w]还是上一轮的值保证每个物品最多被选一次。这个从大到小还是从小到大的差别就是0-1背包和完全背包在代码层面唯一的区别。你理解了为什么代码怎么写就永远不会搞混。3.3 车辆动态规划问题DP思想在工程里的真实应用热搜词里有车辆动态规划问题我当时第一反应是汽车行业里的整车性能多目标优化后来仔细看才发现这个词在算法竞赛里一般指基于动态规划的车辆路径规划比如物流配送中怎么安排车辆的行驶路线让总成本最低。这类问题的典型场景是有一批货物需要配送到多个客户点每辆车从配送中心出发载重有限每个客户点必须被访问正好一次目标是让所有车辆的总行驶距离最短。这个问题在数学上是带容量约束的车辆路径问题精确求解是NP-hard的但对于小规模场景比如客户数不超过15个可以用状态压缩DP求解。状态定义为dp[mask][i]表示当前已经访问过的客户点集合是mask最后一辆车停在第i个客户点时的最小总距离。mask是一个二进制数第k位等于1表示第k个客户点已经被访问过。转移时从当前客户点i出发选择一个还没有访问过的客户点j更新dp[mask | (1 j)][j] min(dp[mask | (1 j)][j], dp[mask][i] dist[i][j])。这个模型的计算量是O(2^n * n^2)n10时大概10万级别n15时大约700万级别都在可接受范围。如果客户点增加到30个朴素状压就扛不住了得换启发式算法或者分支限界法。这里也体现了一个从业者的基本素质知道DP的边界在哪里该用启发式的时候就别硬扛着精确算法。3.4 洛谷题单的刷题顺序建议洛谷的【动态规划题单】分为几个模块入门题、线性DP、背包DP、区间DP、树形DP、状压DP、数位DP、优化。我的建议是严格按照顺序来不要跳。入门题主要是理解DP的基本概念和代码框架比如P1216数字三角形状态是dp[i][j]表示到达第i行第j列的最大路径和转移是dp[i][j] max(dp[i1][j], dp[i1][j1])这个题目教会你从下往上逆推的思路。线性DP模块选几道精刷不要贪多。P1020导弹拦截需要LIS还要理解贪心维护的tail数组P1091合唱队形是LIS和LDS的组合应用建议先做这题再去看复杂的变体。背包DP模块至少把0-1背包、完全背包、多重背包各做一道。洛谷P1616疯狂采药是完全背包P1776宝物筛选是多重背包的二进制拆分优化。这一组刷下来背包问题的各种变体基本就能拿捏了。区间DP模块重点做P1880石子合并和P1063能量项链这类题的状态转移涉及合并操作核心思路是先小区间后大区间遍历顺序比较特殊需要单独练。树形DP和状压DP是进阶内容建议前四模块吃透之后再碰。树形DP可以做P1352没有上司的舞会经典的在树上进行决策状态是选或不选当前节点。状压DP可以做P1433吃奶酪和上面说的车辆路径问题同源连状态定义都很像。4. 常见问题与排查技巧实录4.1 状态转移方程推不出来怎么办推不出转移方程通常有三种原因。一是状态定义不合理信息不够比如你要做选择但状态里没有记录选择需要的参数二是对问题的结构理解不透没有抓住最后一步或者当前决策到底是什么三是题目还没看透就上手写代码跳过了手推过程。我的习惯是先别碰代码在纸上画出小规模样例的完整状态表。比如数字三角形就画一个四层的三角形手动把每层的dp值算出来然后盯着状态表找规律。你会发现每一格的值都是由它左下和右下的格子决定的多找几次规律转移方程自己就跳出来了。这个方法听着笨但比空想高效得多。另外一个技巧是尝试从最后一步思考假设整个问题的最优解已经得到了那倒数第二步是什么样子的把最后一步拆分出来转移方程就有了。4.2 一维DP和二维DP怎么选判断用几维状态核心看问题里有几个独立维度的参数。背包问题里物品编号是一个维度容量是另一个维度所以自然要二维用滚动数组优化掉物品维度后变成一维数组但逻辑上仍然是二维DP的压缩。有个辅助判断法你想想状态转移时要做几层循环每层循环对应一个维度。最长上升子序列只需要枚举结尾位置和前面位置所以是一维DP最长公共子序列要枚举两个序列的位置所以是二维DP状压DP多了一个集合维度所以用二进制表示。有时候你写的二维DP其实可以压缩成一维比如背包问题。压缩的核心条件是dp[i]这层的值只依赖dp[i-1]而且计算顺序能保证不覆盖掉旧值。如果依赖跨了两层以上比如依赖dp[i-2]就得用两个滚动数组交替存储或者用循环数组取模。4.3 答案差了一点怎么定位问题答案差一点点基本都是转移条件写错了或者边界初始化不对。我自己的排查顺序是第一步打印dp数组和手推的结果逐格对比找出第一个不同的位置。第二步检查那个位置对应的转移条件。比如最长上升子序列要求严格递增但你可能写成了a[j] a[i]导致相等元素也能接上去答案偏大。第三步检查初始化和无效值的设置。最小化问题里dp数组初始化为正无穷还是0结果天差地别组合计数问题里dp[0]有没有设为1直接决定答案是不是0。排查中最容易忽略的是遍历顺序。区间DP的遍历顺序是长度从小到大很多人在做石子合并时把区间顺序搞反了导致大区间更新时用到的小区间结果还没算出来答案就错了。这类问题打印dp数组的时候能明显看出来——你会发现一部分格子是无穷大或0那基本就是遍历顺序的问题。4.4 空间优化技巧不只是滚动数组滚动数组是最常规的优化手段但DP的空间优化还有几个小技巧。第一是压缩无用维度。如果你的转移只依赖前一层那么就用两个数组交替甚至直接一维倒序。第二是复用状态比如计算路径数时某些中间状态在后续不再被引用可以直接覆盖。第三是使用更省空间的数据类型比如余数DP时dp值只有0和1可以用bitset来做DP加速这在数位DP里很常用。还有一个容易被忽视的点递归改递推。记忆化搜索虽然好写但会占用额外的递归栈空间极限数据下可能爆栈。递推的代码虽然难写一点但空间占用和运行效率都更可控。刷洛谷题单时如果遇到MLE优先检查递归深度和二维数组开的大小。4.5 常见误区速查表误区表现正确做法状态缺少决策所需信息转移时发现无法判断是否合法把关键参数加入状态定义背方程不理解推导换一道题就不会做用小样例手推状态表理解转移来源0-1背包容量循环从小到大物品被重复选取容量循环从大到小求最小值时初始化为0答案偏小初始化为正无穷区间DP遍历顺序错误大区间结果错误先枚举长度再枚举起点区间长度从小到大没有单独处理边界n1或空数组时出错特判最小规模输入过度优化写滚动数组后状态覆盖混乱先把朴素版写对再优化5. 车辆动态规划实战从算法题到工程落地5.1 一个简化版的物流配送问题说完了刷题我把车辆动态规划问题单独拉出来展开一次实战因为我发现很多做算法题的人看到DP在工程里的应用就懵了不知道怎么把题目里的模型往真实场景上靠。假设你经营一家小型同城配送公司有3辆车、8个配送点车辆从同一个仓库出发每辆车载重上限是50件货物目标是让所有车跑的总路程最短。这个问题用数学语言描述就是带容量约束的车辆路径问题我前面提过状态压缩DP的解法思路。这里把完整的实现过程讲一遍。第一步是数据准备写一个坐标列表每个配送点有坐标和有货物需求件数仓库是坐标原点。第二步是算距离矩阵dist[i][j]第i个点和第j个点之间的欧氏距离。第三步是状态设计dp[mask][i]表示已经访问过的客户集合是mask当前最后一站是i的最小总距离。第四步是转移枚举所有还没访问的点j试试从i走到j会不会比已有的dp[mask | (1 j)][j]更优。第五步是加容量约束状态里加一个维度load表示当前车辆的剩余容量如果load demand[j]就不能从当前状态走到j。这里状态就变成了三维dp[mask][i][load]维度更多状态数也更多。实际工程里很少真的用纯DP解这类问题因为客户点一多就爆了更常用的是把DP当作子问题求解器配合集群划分的启发式算法使用。比如先用K-means把客户点分成几簇每簇对应一辆车簇内部的配送顺序再用DP或贪心求解整体复杂度大幅降低。5.2 算例演示3辆车8个点怎么算我用一个具体算例演示一下这个过程。假设仓库坐标是(0,0)8个配送点的坐标分别是A(2,3)、B(5,2)、C(1,5)、D(4,6)、E(6,4)、F(7,1)、G(3,7)、H(8,5)每件货物占据1个容量单位需求分别是A2件、B3件、C1件、D4件、E2件、F3件、G1件、H2件总需求18件3辆车的总容量150件容量其实是充足的这时容量约束不生效问题退化成纯粹的路线最短问题。那容量约束什么时候生效当车辆数或者单车载重被压得很紧的时候比如总需求180件3辆车每辆50件这时候排路线就得先保证装得下再去优化距离。在纯最短路线假设下dp[mask][i]的状态总数是2^8 * 8 2048转移需要对每个状态枚举8个点总运算量大概1.6万次代码瞬间出结果。这就是状压DP在小规模问题里的威力——你甚至不需要特别注意时间复杂度。但如果扩大到20个点状态数就成了2^20 * 20 ≈ 2000万再加上每个状态枚举20个转移点就是4亿次运算普通写法就会明显卡顿。这个时候就轮到上一节说的启发式算法出场了。5.3 工程里两种常见场景的取舍真实业务里车辆路径问题通常分两类一类是离线规划比如每天早上根据当天的订单一次性算出所有路线对时间要求不苛刻跑上几十秒也能接受这种场景可以用模拟退火、遗传算法这类元启发式方法求近优解。另一类是在线调度比如司机在路上跑着忽然来了新订单得在几秒内重新规划路线这种场景只能用贪心或局部搜索这样的快速算法DP可以作为局部优化器来微调单条路线内的配送顺序。我的实际体会是DP在工程里很少单独扛大梁更多是和其他方法配合。比如先用聚类把客户划给各车再用DP计算单条路线内的最优访问顺序这恰恰是DP最擅长的——状态空间小的时候精确算法的效果远好于启发式。这种大问题启发式分治小问题DP精确求解的组合拳在车辆调度、路径规划等领域非常常用。6. 给新手的刷题防坑建议6.1 不要跳过基础不要只看题解刷题最忌讳的就是看一道过一道题解看懂了关上页面自己写却写不出来。每次AC之后建议做两件事一是关掉代码隔一天再自己从头写一遍二是想一想如果题目改了条件比如严格上升改成非严格上升最长改成最短你的状态和转移要动哪里。这个改条件的训练法特别有用。比如最长上升子序列要求严格上升改成非严格上升只需要把二分查找的bisect_left改成bisect_right改成最长下降只需要把序列翻转或者比较符号反过来。你能通过改条件看到状态和转移之间的关联DP才算真正入门了。6.2 建立自己的DP模板库建议按模型分类整理自己的模板库。每类模型存一个典型题目、一份标准代码、三行注释解释状态含义和转移方向。遇到新题先判断它属于哪个模型套模板再改细节。我自己的模板库里分了线性DP专题、背包专题、区间DP专题、树形DP专题、状压DP专题、数位DP专题。每个专题下有两三个典型题。这个模板库不是让你背题而是让你加速识别题目的模式。就像老医生看X光片看多了一眼就能判断病灶类型。但判断完类型具体怎么处理还是要回到病灶本身这就是为什么模板库只存典型题不存变体题的原因。6.3 什么时候该放弃DP换其他算法有些题目看起来像DP实际用DP做会很别扭。判断标准是状态空间太大或者转移需要的信息无法用有限维状态描述。比如给定一个树要求在所有叶子节点之间建立最短路径网络这更像是最小生成树问题而不是DP问题。再比如求一个图的最短路径虽然可以用DP的思想理解Dijkstra但实际更好的做法是直接用优先队列跑Dijkstra。有些题目里状态需要记录访问过的点的顺序这只能用状压DP硬解但如果点的数量多于20就超出能力范围了需要换搜索或启发式。刷题刷到最后你会形成一种直觉看到问题先判断它到底是什么类型再决定往哪个方向想。与其说这是算法能力不如说是模式识别能力。6.4 我从洛谷题单里最常回看的几个题题单里有些题我刷完就忘了有些题隔三差五还会翻出来看看。P1216数字三角形是逆推思路的开山题每次看都有新体会P1020导弹拦截让我真正理解了tail数组和LIS优化是怎么回事P1880石子合并让我意识到区间DP的遍历顺序不是从起点出发而是从小到大枚举区间长度P1352没有上司的舞会让我明白树形DP本质上和线性DP是一样的逻辑只是父子关系代替了线性相邻关系。如果你还在入门阶段这几个题值得反复刷每一轮刷都会有新的理解。尤其是P1020我前前后后刷了四遍每次都能发现一点新东西。7. 写在最后的个人体会动态规划这个专题我从最初的看到题目就懵到后来能独立想出状压DP解车辆路径问题的全套状态定义中间隔了大概三个月的持续刷题时间。这个过程中最关键的变化不是背了更多公式而是养成了先在纸上手推小样例再写代码的习惯。很多次我以为自己想清楚了一写代码才发现转移有漏洞手推样例能帮我提前发现这些漏洞。另外一个印象很深的教训是不要过度追求代码的简洁和优雅先把朴素的、逻辑完整的版本写出来让它能跑、能过样例再去做优化。我见过太多人一上来就写滚动数组结果状态覆盖顺序不对debug半天发现还不如写二维数组然后加一个if判断空间够不够。优化的前提是正确这个顺序不能乱。如果你去刷洛谷的动态规划题单建议给自己定一个节奏每天一到两道题每道题都按手推样例-写代码-对比题解-总结模板四步走。不要贪多这个专题的覆盖范围太广贪多嚼不烂。等到你能独立画出五六个模型的完整状态转移表再回头看最初的自己会明显感觉到质的飞跃。最后再分享一个我测试过很有效的小技巧把每道题的状态定义和转移方程写在纸上不写代码。如果写不出来说明你还没完全想通这时候打开编辑器写代码只会浪费时间。等你能在纸上把状态和转移写得明明白白代码一般十分钟就能敲完而且很少需要debug。这个习惯我到现在还在用做题效率提升得不是一点半点。