多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

计数DP从入门到避坑:状态定义、滚动数组与取模实战

计数DP从入门到避坑:状态定义、滚动数组与取模实战 1. 从666RPG这个标题说起它到底在算什么第一次看到666RPG计数dp这个标题很多人会愣一下——RPG不是角色扮演游戏吗怎么跟动态规划扯上关系了其实这里的RPG是题目名字的一部分而括号里的计数dp才是真正的技术内核。这类题目在算法竞赛里非常典型给一个角色扮演游戏的数值系统让你统计有多少种方式能让角色的某个属性达到目标值或者有多少种操作序列满足特定条件。听起来像游戏本质是组合计数问题。计数类动态规划跟最优化类动态规划最大的区别在于最优化dp求的是最大/最小值是多少而计数dp求的是有多少种方案。这个区别直接决定了状态转移方程的写法——最优化dp用max或min合并子问题计数dp用加法合并而且每一步都要考虑取模。一旦涉及取模就绕不开负数取模、滚动数组、边界初始化这几个老生常谈但又极其容易翻车的点。这篇文章适合三类人看正在刷计数dp题但总是WA的竞赛选手、想系统梳理动态规划计数套路的算法学习者、以及被取模后结果不对折磨过的开发者。我会从状态定义讲起把转移方程的推导过程、滚动数组的优化逻辑、取模的坑点、以及实测中遇到的边界问题全部拆开讲清楚。你不需要有很强的竞赛背景只要能写基本的循环和数组操作就能跟着走完整个思路。先说结论计数dp的核心难点从来不是转移方程本身而是状态定义的完备性和取模运算的一致性。前者决定了你会不会漏算或重算后者决定了你的答案会不会在最后一步莫名其妙变成负数。下面我按实际解题的顺序一层一层往下拆。2. 状态定义计数dp最容易翻车的第一步2.1 为什么方案数的状态定义比最优值更挑剔做最优化dp的时候状态定义稍微粗糙一点往往还能跑出正确答案因为max和min有一定的容错性。但计数dp不行——状态只要有一点不完备方案数就会直接算错而且错得很隐蔽你看着代码逻辑没问题跑出来就是差那么几个数。举个具体的场景。假设题目描述是这样的一个角色初始有某种属性值每回合可以执行若干种操作每种操作会让属性值发生特定变化问经过固定回合数后属性值恰好等于目标值的方案有多少种。最直觉的状态定义是dp[i][j]表示经过i回合后属性值为j的方案数。这个定义看起来没问题但它隐含了一个前提属性值j的范围是有限的且已知的。如果操作可能导致属性值超出预期范围你就必须决定这些越界状态是丢弃、截断还是单独处理。丢弃会导致漏算截断会导致重算这两种错误在计数题里都是致命的。我在实际做题时养成的习惯是先把所有可能的属性值变化列出来算出理论上能达到的最小值和最大值然后根据这个范围来确定第二维的大小。如果范围太大就要考虑用哈希表或者离散化而不是硬开一个巨大的数组。2.2 状态维度的取舍多一维还是少一维计数dp经常面临一个选择要不要把某个条件单独作为一维状态。比如题目里如果有每种操作最多使用一次这种限制你就需要额外一维来记录哪些操作已经用过——但如果操作种类很多这一维会直接爆炸。这时候通常有两种处理思路。第一种是改变遍历顺序把是否使用过这个信息编码进转移的顺序里比如用0/1背包的倒序遍历技巧来保证每个物品只用一次。第二种是状态压缩如果操作种类不超过20种可以用一个整数的二进制位来表示使用情况。这两种方法各有适用场景选哪个取决于数据范围。我个人的经验是先看操作种类数。如果不超过20优先考虑状压如果超过20但每种操作的使用限制可以用顺序来保证就用遍历顺序来控制如果两者都不满足那大概率题目还有别的性质没被利用需要重新读题。2.3 一个具体的状态定义示例假设题目是这样的角色初始属性为0每回合可以选择加1、加2或加3问恰好n回合后属性值等于m的方案数。这个题的状态定义就很直接// dp[i][j] 表示 i 回合后属性值为 j 的方案数 // 转移dp[i][j] dp[i-1][j-1] dp[i-1][j-2] dp[i-1][j-3]但这里有个细节j-1、j-2、j-3可能小于0必须做边界判断。很多人在写的时候直接用if (j k)来跳过这没问题但要注意初始状态的设置。dp[0][0] 1表示0回合后属性为0有1种方案什么都不做其他dp[0][j]都是0。这个初始化看起来简单但它是整个转移的基础一旦写错后面全错。3. 转移方程的推导从暴力枚举到加法原理3.1 加法原理在计数dp中的具体体现计数dp的转移方程本质上就是加法原理的代码化。如果到达状态dp[i][j]有k条不同的路径那么dp[i][j]就等于这k条路径各自来源的状态值之和。这句话听起来像废话但它是推导所有计数dp转移方程的万能钥匙。拿上面那个加1/加2/加3的例子来说。要到达i回合后属性为j这个状态最后一步只能是加1、加2或加3。如果是加1那前一步的状态就是i-1回合后属性为j-1如果是加2前一步就是i-1回合后属性为j-2加3同理。这三条路径互不重叠因为最后一步的操作不同所以直接把三个来源的方案数加起来就是答案。这个推导过程的关键在于确保路径互不重叠且完备。互不重叠意味着你不能把同一条路径算两次完备意味着你不能漏掉任何一条路径。在实际做题时我通常会画一个简单的状态转移图把每个状态的所有入边标出来确认没有遗漏也没有重复。3.2 什么时候需要容斥有些题目的转移路径会重叠这时候就需要用到容斥原理。比如题目里如果有至少使用一次某种操作这样的限制直接加会重复计算必须先算总数再减去不满足条件的部分。容斥在计数dp里是个进阶技巧用不好很容易算错。我的建议是如果转移路径的重叠情况比较复杂先不要急着写容斥而是考虑增加状态维度来消除重叠。比如把是否使用过某操作单独作为一维虽然空间复杂度上去了但逻辑清晰不容易出错。等把基础版本写对了再考虑用容斥来优化空间。3.3 转移顺序对计数结果的影响计数dp的转移顺序很重要尤其是当状态之间存在依赖关系时。一般来说如果dp[i][j]依赖于dp[i-1][...]那就按i从小到大遍历如果依赖于dp[i][...]同一层内的依赖那就要注意遍历j的顺序。这里有个经典的坑0/1背包的倒序遍历。在0/1背包的计数版本里如果正序遍历容量维度同一个物品会被重复使用导致方案数偏大。倒序遍历可以保证每个物品只被考虑一次。这个技巧在计数dp里同样适用而且更容易出错因为计数题的结果不会像最优化题那样出现明显的异常值你可能要对比暴力解才能发现。4. 滚动数组省空间的同时别把答案滚没了4.1 滚动数组的适用条件和实现方式滚动数组的本质是利用dp的层次性如果dp[i][...]只依赖于dp[i-1][...]那就不需要保存所有层只需要保存相邻两层。实现方式通常是用两个数组交替使用或者用一个二维数组但只开两行。// 滚动数组写法 int dp[2][MAXM]; int cur 0, pre 1; dp[pre][0] 1; for (int i 1; i n; i) { memset(dp[cur], 0, sizeof(dp[cur])); // 关键每层开始前清零 for (int j 0; j m; j) { if (j 1) dp[cur][j] (dp[cur][j] dp[pre][j-1]) % MOD; if (j 2) dp[cur][j] (dp[cur][j] dp[pre][j-2]) % MOD; if (j 3) dp[cur][j] (dp[cur][j] dp[pre][j-3]) % MOD; } swap(cur, pre); }这段代码里最容易出错的地方是memset那一行。滚动数组的当前层在写入之前必须清零否则会残留上一轮循环的数据。我见过太多人因为忘了清零导致答案莫名其妙偏大调试半天才发现是这个问题。4.2 滚动数组与取模的交互陷阱滚动数组和取模一起用的时候有一个非常隐蔽的坑如果当前层的某个状态没有被任何转移覆盖到它的值应该是0但如果没有清零它可能保留着上一层的旧值。这个旧值可能是一个已经取过模的正数所以你不会看到负数但答案就是错的。另一个坑是取模的时机。有些人习惯在所有加法做完之后再统一取模这在滚动数组里可能导致中间结果溢出。正确的做法是每次加法之后立即取模或者用long long暂存中间结果最后再取模。具体选哪种取决于数据范围和语言特性。4.3 什么时候不该用滚动数组滚动数组虽然省空间但它会让代码的可读性下降调试难度上升。如果题目的空间限制不紧张我建议先用完整的二维数组写一版确认逻辑正确之后再改成滚动数组。这样即使改出问题你也有一个正确的版本可以对比。另外如果转移方程涉及到同一层内的依赖比如dp[i][j]依赖于dp[i][j-1]滚动数组就不能简单地用两行来实现了因为同一层的状态之间有顺序依赖。这种情况下要么保留完整数组要么用更复杂的原地更新技巧。5. 取模计数dp里最容易埋雷的地方5.1 负数取模为什么你的答案变成了负数取模运算在数学上要求结果是非负的但很多编程语言的%运算符在处理负数时会返回负值。比如在C里-1 % 1000000007的结果是-1而不是1000000006。这个特性在计数dp里会造成灾难性的后果一旦某个中间状态因为减法操作变成了负数后续所有依赖它的状态都会带着这个负数传播下去最终答案可能是一个巨大的负数。解决办法很简单在每次取模之后加一个判断如果是负数就加上模数。int mod_add(int a, int b, int MOD) { return (a b) % MOD; } int mod_sub(int a, int b, int MOD) { return ((a - b) % MOD MOD) % MOD; }这个MOD再%MOD的操作看起来多余但它是保证结果非负的关键。我在实际做题时只要涉及减法都会无条件加上这个保护。5.2 取模常数的选择与溢出风险计数dp常用的模数是10000000071e97和998244353。选这两个数的原因是它们是质数而且在int范围内两个这样的数相加不会溢出int但相乘会。如果你的转移方程里涉及乘法就必须用long long来暂存中间结果。// 安全的长整型取模乘法 long long mod_mul(long long a, long long b, long long MOD) { return (a * b) % MOD; }这里有个经验只要转移方程里出现了乘法就默认用long long。不要试图用int来省那点内存溢出的代价远大于这点开销。5.3 取模与比较运算的冲突有些计数dp题目会在转移过程中涉及比较比如如果方案数大于某个阈值就...。这时候要注意取模之后的数值大小关系已经失去了原本的意义。取模前A大于B取模后可能A小于B。如果题目要求比较方案数的大小那你就不能用取模后的值来比较要么用高精度要么用对数来近似比较。这个坑在纯计数题里不常见但在一些结合了博弈或概率的题目里会出现。遇到这种题先确认题目是否真的需要比较方案数的实际大小还是只需要输出取模后的结果。6. 实测中的边界问题与调试技巧6.1 初始化的边界dp[0][0]到底该设成几dp[0][0] 1是计数dp里最常见的初始化但它并不是万能的。如果题目规定初始属性值不为0那这个初始化就是错的。如果题目规定必须执行至少一次操作那最终答案就不能直接取dp[n][m]而要减去0次操作就达到目标的情况。我的习惯是先把题目的初始条件用自然语言写出来再翻译成代码。比如角色初始属性为0可以进行任意次操作翻译成代码就是dp[0][0] 1其他dp[0][j] 0。如果题目说初始属性为k那就改成dp[0][k] 1。6.2 用暴力解对拍最可靠的验证方式计数dp的答案对不对光靠肉眼看代码是很难判断的。最可靠的方法是用一个暴力搜索或者暴力dp来对拍。暴力解不需要考虑空间和时间复杂度只需要保证逻辑正确。// 暴力递归版本用于对拍 int brute(int turns, int value, int target) { if (turns 0) return value target ? 1 : 0; int res 0; for (int add 1; add 3; add) { res brute(turns - 1, value add, target); } return res; }把暴力解和你的dp解在小数据范围下跑一遍对比结果。如果一致再逐步增大数据范围观察是否出现偏差。这个方法虽然笨但它是发现边界问题最有效的手段。6.3 常见WA原因速查表症状可能原因排查方法答案偏大滚动数组未清零、转移路径重复计算检查memset、画状态转移图答案为负负数取模未处理在减法后加MOD再取模答案偏小边界状态未初始化、转移条件过严检查dp[0][...]的设置小数据对但大数据错溢出、取模时机不对改用long long、每次加法后取模结果不稳定未初始化数组、越界访问用编译器警告、开大数组这张表是我自己踩坑总结出来的基本上覆盖了计数dp里80%的WA情况。遇到问题的时候按表排查比盲目调试效率高得多。7. 从这道题延伸出去的计数dp套路7.1 线性计数dp与树形计数dp的共通逻辑666RPG这类题目属于线性计数dp状态沿着一个维度回合数线性推进。但计数dp的套路远不止线性一种。树形计数dp是在树上做计数每个节点的方案数由子节点的方案数合并而来数位计数dp是在数字的每一位上做计数通常用于统计满足某种数位条件的数字个数。这三种计数dp的共通逻辑是先定义状态再找转移最后处理边界和取模。区别只在于状态的维度结构和转移的方向。把线性计数dp吃透了树形和数位只是换了个遍历顺序和合并方式。7.2 计数dp与组合数学的关系很多计数dp题目的答案其实可以用组合数学的公式直接算出来但dp的优势在于它可以处理那些没有封闭公式的复杂约束。比如加1/加2/加3这个问题如果操作种类是固定的几种可以用生成函数来算但如果操作种类依赖于当前状态比如属性值大于10时只能加1那就只能用dp来做了。我在做题时的判断标准是如果题目有明显的组合结构比如从n个物品里选k个先想组合公式如果约束条件复杂或者状态之间有依赖直接上dp。两者不是对立的很多时候dp的转移方程本身就是组合恒等式的一种表达。7.3 滚动数组之外的空间优化思路滚动数组是最常见的空间优化手段但它不是唯一的。如果状态转移只依赖于前几个状态比如只依赖dp[i-1]和dp[i-2]可以用循环队列来进一步压缩空间。如果状态维度里有某一维的取值范围很小可以把这一维编码进整数的二进制位里用位运算来加速转移。这些优化技巧在竞赛中很实用但在日常开发中除非确实遇到了内存瓶颈否则我建议优先保证代码的可读性和正确性。毕竟一个跑得慢但结果正确的程序比一个跑得快但结果错误的程序有价值得多。8. 我在计数dp上踩过的几个真实坑第一个坑是忘记处理j-k小于0的情况。当时写的是dp[i][j] dp[i-1][j-k]没有加if (j k)的判断结果数组越界访问到了别的内存区域答案时对时错。后来养成了习惯只要转移方程里出现了下标减法先写边界判断。第二个坑是滚动数组的清零位置。我一开始把memset写在了swap之后结果清掉的是上一层的数据当前层反而没清。这个bug找了很久因为代码逻辑看起来完全正确。后来我固定了一个写法在每层循环的开始处清零当前层然后再做转移。第三个坑是取模后比较大小。有一道题要求输出方案数最多的那个状态的编号我直接用取模后的值来比较结果选出来的状态跟正确答案不一样。后来改成用long long存不取模的值来比较只在最后输出时取模问题才解决。这些坑说起来都很简单但在实际写代码的时候注意力往往集中在转移方程的推导上这些细节很容易被忽略。我的建议是把边界处理、取模、清零这些操作写成固定的代码模板每次做题直接套用减少犯低级错误的机会。9. 给正在刷计数dp的朋友几点建议如果你刚开始接触计数dp我建议先从最简单的线性计数题入手比如爬楼梯的计数版本、硬币组合的计数版本。这些题的状态定义和转移方程都很直观适合用来熟悉计数dp的基本套路。等你对基本套路熟悉了再去挑战带约束的题目比如每种操作最多使用一次、必须恰好使用k种操作这类。这些题会逼着你去思考状态定义的完备性和转移路径的互斥性是提升计数dp能力的关键阶段。最后不要怕写暴力解。很多人觉得暴力解太慢、太笨不愿意写。但暴力解是你验证dp正确性的唯一可靠手段。我到现在做计数dp题只要数据范围允许都会先写一个暴力解来对拍。这个习惯帮我省下了大量调试时间。计数dp这个领域入门不难但要做到不出错需要大量的练习和踩坑。希望这篇内容能帮你少走一些弯路。如果你在刷题过程中遇到了其他奇怪的WA情况欢迎一起交流。
返回列表