ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

动态规划实战:从斯特林数到分组方案数问题解析

动态规划实战:从斯特林数到分组方案数问题解析 1. 项目概述从AtCoder竞赛题看动态规划的实战拆解最近在刷AtCoder Beginner Contest 217的G题这道题被不少朋友标记为“典型的DP动态规划好题”。如果你也和我一样在算法竞赛或者日常开发中遇到需要优化决策、计算方案数或者最值的问题时常常感到无从下手那么深入理解这道题背后的DP思想绝对能帮你打开一扇新的大门。动态规划远不止是面试八股文里的“背包问题”和“最长公共子序列”它在解决诸如资源分配、路径规划、字符串匹配乃至游戏策略等复杂场景时展现出的是一种化繁为简、高效拆解问题的强大能力。这篇文章我就以ABC 217 G题为例带你走一遍从读题、建模到代码实现的完整思考过程分享我在解决这类问题时总结的通用套路和避坑技巧。无论你是正在备战算法竞赛的新手还是希望提升工程中问题建模能力的开发者相信这篇深度解析都能给你带来实实在在的收获。2. 问题核心与DP思路的建立2.1 原题回顾与问题抽象首先我们得明确题目到底在问什么。AtCoder Beginner Contest 217的G题通常被简称为“ABC217 G”。这类题目描述往往涉及将N个物品分成若干组并满足特定条件求分组方案数。这是组合数学与动态规划结合的经典场景。我们不妨将问题抽象一下假设有N个不同的球编号1到N我们需要将它们放入若干个盒子中。但放法有规则比如可能要求每个盒子至少有多少个球或者盒子有特定的容量限制又或者球与球之间存在某种排斥或吸引关系不能放一起。题目最终要求的是在所有满足规则的分组方案中其总数对某个大质数通常是998244353取模后的结果。为什么是方案数取模因为答案可能是一个天文数字直接输出不现实取模是算法竞赛中的常规操作。而998244353是一个常用的模数因为它是一个质数且其原根性质便于进行数论变换。面对“求方案数”的问题暴力枚举所有分组情况比如考虑每个球属于哪个盒子的时间复杂度是O(N^N)级别的显然不可行。这时动态规划的优势就体现出来了。DP的核心思想是“记住过去决策未来”。我们不需要一次性考虑所有N个球的复杂排列组合而是可以按顺序处理每个球同时记录下当前已经形成的分组状态并基于此状态来决策当前球是放入一个已有组还是独自成立新组。2.2 DP状态设计与“状态”究竟是什么这是DP最核心也最考验功力的部分。状态设计的好坏直接决定了问题能否被解决以及解决效率的高低。对于分组问题一个非常常见且有效的状态定义是dp[i][j]表示已经考虑了前i个球编号1到i并且这i个球恰好分成了j个非空组所有满足题目特定约束的方案总数。我们来拆解这个定义i考虑的物品数量这代表了DP的阶段。我们从小到大处理每个球体现了“按顺序决策”的思想。j当前形成的组数这代表了在当前阶段下我们需要记录的一个关键“状态”信息。仅仅知道处理了多少个球还不够因为最终答案可能与分成的组数强相关例如题目可能要求恰好分成K组。dp[i][j]的值存储的是方案数。它汇总了所有能将前i个球分成j组且满足截至i为止所有约束条件的方案。为什么这样设计是有效的因为它巧妙地捕捉了问题的“无后效性”。当我们决定第i1个球的去向时我们只关心前i个球已经被分成了几组即j而完全不需要关心这j组内部具体是哪些球、组与组之间的顺序等细节。因为题目通常只关心“分组”这个结构本身而不关心组的具体编号或组内排列除非特别说明。这种状态定义将指数级复杂度的具体分组情况压缩成了多项式级别的状态数量i最多Nj最多N状态总数O(N²)这就是DP的魔力。注意这里有一个关键理解点——“组”被认为是无标号的。也就是说{1,2}, {3}和{3}, {1,2}被视为同一种分组方案。如果题目要求组有区别例如盒子有编号那么状态设计和转移方程会有所不同。ABC 217 G题通常默认组是无区别的。2.3 状态转移方程的推导状态定义好了接下来就是思考状态之间如何转移。也就是已知dp[i][j]前i个球分成j组的方案数当新加入第i1个球时dp[i1][?]会如何变化。对于第i1个球它只有两种本质不同的去向独自成立一个新组这个球自己单独作为一组。那么在i个球形成j组的基础上新增一个球单独成组总组数就变成了j1。因此这种决策贡献给了dp[i1][j1]。 转移贡献为dp[i][j] - dp[i1][j1]。有多少种前i个球的方案就有多少种通过这种方式形成的新方案。加入已有的一个旧组这个球放入已经存在的j个组中的某一个。那么总组数j保持不变。 转移贡献为dp[i][j] * j - dp[i1][j]。为什么是乘以j因为前i个球有dp[i][j]种方案形成了j个组对于其中每一种具体方案第i1个球都有j个不同的组可以选择加入。所以方案数要乘以j。将这两种决策的贡献加起来就得到了完整的状态转移方程dp[i1][j] dp[i][j] * j dp[i][j-1]等一下这里需要仔细对齐。我们分别来看对于dp[i1][j]前i1个球分成j组它可能来源于两种前驱状态。来源一前i个球已经分成了j组然后第i1个球加入了这j组中的某一个。贡献为dp[i][j] * j。来源二前i个球分成了j-1组然后第i1个球独自成立了一个新组。贡献为dp[i][j-1]。因此dp[i1][j] dp[i][j] * j dp[i][j-1]。这个方程是解决这类分组问题的基石。它有一个响亮的名字——第二类斯特林数Stirling Numbers of the Second Kind的递推公式。dp[i][j]在这里计算的就是将i个不同的球放入j个无标号的非空盒子中的方法数记作S(i, j)。2.4 初始化与边界条件任何DP都需要一个起点。根据我们的状态定义dp[0][0] 1考虑0个球分成0个组这算作一种“空”的方案通常这样定义可以使递推顺利进行。dp[0][j] 0 (j0)没有球却要分成正数组这是不可能的。dp[i][0] 0 (i0)有球存在却要求分成0组这也是不可能的。有了初始状态dp[0][0]1我们就可以通过双重循环i从0到N-1j从0到i来逐步递推出所有的dp[i][j]。3. 从通用公式到具体题目的适配与实现3.1 识别题目约束与状态扩展上面推导的是最基础的“任意分组”模型。但竞赛题目总会在基础模型上增加各种约束条件比如每组至少有多少个元素例如每组至少2个球。最多能分成多少组例如最多K组。元素之间有特定关系例如某些球不能放在同一组。组本身有大小限制或特殊属性。对于ABC 217 G题其具体约束就是关键所在。原题描述通常是将1到N的数字分成若干组使得每组的大小元素个数都在某个集合A中例如A {2, 3, 5}表示每组大小只能是2、3或5。求总方案数。这就在基础模型上增加了一个强有力的约束每组的大小是受限的。我们之前的状态dp[i][j]只记录了球数和组数但无法得知每个组当前的大小因此无法判断一个分组方案是否最终满足“每组大小属于集合A”的条件。怎么办我们需要升级我们的状态让它携带更多信息。一个直接的思路是在状态中记录每个组的大小。但这几乎不可能因为组的大小组合方式太多。这时需要转换视角。既然题目只关心最终每组的大小而不关心中间过程每组的具体大小我们可以尝试另一种DP顺序不是按球一个一个加入而是按组一组一组构造。我们定义新的状态dp[i]表示将i个球进行分组且每一组的大小都来自给定集合A总方案数。那么dp[i]如何从更小的状态转移过来呢 考虑最后形成的一组。这最后一组的大小可以是集合A中的任何一个数aa i。当最后一组的大小确定为a后剩下的i-a个球必须已经事先被分成了若干组每组大小也都在A中。因此我们有dp[i] sum_{a in A, a i} ( dp[i-a] * C )这里的C是什么是组合系数。当我们从i个球中选出a个球作为最后一组时有多少种选法是C(i-1, a-1)。为什么不是C(i, a)因为我们要保证组的“无标号”性。一个常用的技巧是始终让当前组包含编号最小的那个未被使用的球这里可以假设是编号为i的球但实际上是任意的通过固定一个元素来避免重复计数。更严谨地说由于球是不同的我们只需要考虑组合但为了避免重复计算同一种分组因为组是无序的我们通常强制规定最后一组包含当前未分组中编号最大的球或编号最小的球。这样当我们决定最后一组大小为a时这个组已经包含了某个特定球比如编号为i的球那么只需要从剩下的i-1个球中选出a-1个球加入这个组即可。所以组合系数是C(i-1, a-1)。因此转移方程为dp[i] sum_{a in A, a i} ( dp[i-a] * C(i-1, a-1) ) % MOD其中dp[0] 10个球有一种分组方式不分组。这种按“最后一组”来思考的方式将问题从“记录所有组”的复杂性中解放出来是处理带组合约束分组问题的利器。3.2 算法实现与细节打磨理论清晰后我们来写代码。这里以“每组大小属于集合A”的ABC 217 G题变种为例。MOD 998244353 def solve(): N int(input()) # 假设输入N # 假设集合A以列表形式给出例如 A [2, 3, 5] A list(map(int, input().split())) # 预处理组合数 C(n, k) mod MOD 这里用递推式 C[n][k] C[n-1][k-1] C[n-1][k] C [[0]*(N1) for _ in range(N1)] for n in range(N1): C[n][0] C[n][n] 1 for k in range(1, n): C[n][k] (C[n-1][k-1] C[n-1][k]) % MOD # DP数组初始化 dp [0] * (N1) dp[0] 1 # 边界条件 # 递推 for i in range(1, N1): for a in A: if a i: continue # 从i个球中强制包含某个特定球例如最后一个球i再选a-1个球 # 组合数为 C(i-1, a-1) comb C[i-1][a-1] dp[i] (dp[i] dp[i-a] * comb) % MOD print(dp[N] % MOD) if __name__ __main__: solve()实现要点与避坑指南模运算每一步加法和乘法后都要取模防止整数溢出。在Python中虽然大整数不会溢出但取模是题目要求且能保持数值在合理范围内。组合数计算直接使用公式计算C(i-1, a-1)在N较大比如1e5时是不可行的。需要预处理组合数。对于N在2000左右可以用上述的杨辉三角递推复杂度O(N²)。如果N更大1e5模数MOD是质数时可以预处理阶乘和阶乘逆元用公式C(n, k) n! / (k! * (n-k)!)在O(1)时间内计算复杂度为O(N log MOD)。边界条件dp[0]1这是这类递推的常见设定代表“什么都没有”也是一种合法的状态使得递推式可以正确启动。可以理解为当最后一组恰好用完了所有球时剩下的球数为0其方案数为1。集合A的处理注意题目中集合A可能包含重复元素或者有大小大于N的元素。在循环中需要判断a i。通常可以先对A排序或去重以提高效率。时间复杂度该算法复杂度为 O(N * |A|)其中 |A| 是集合A的大小。在竞赛中需要评估数据范围是否可接受。3.3 一个具体的计算实例为了加深理解我们手动算一个小例子。设N5,A {2, 3}。求每组大小只能是2或3的分组方案数。初始化dp[0] 1。i1: 最小集合元素是221无法形成任何组。dp[1]0。i2: 考虑a2。dp[2] dp[0] * C(1, 1) 1 * 1 1。a3大于2跳过。所以dp[2]1。含义{1,2} 作为一组。i3: 考虑a2。dp[3] dp[1] * C(2, 1) 0 * 2 0。 考虑a3。dp[3] dp[0] * C(2, 2) 1 * 1 1。 所以dp[3]1。含义{1,2,3} 作为一组。i4: 考虑a2。dp[4] dp[2] * C(3, 1) 1 * 3 3。从{1,2,3,4}中固定一个球选另一个与它成组剩下两个球必须是一个大小为2的组而dp[2]1。具体方案{1,2},{3,4}; {1,3},{2,4}; {1,4},{2,3} 考虑a3。dp[4] dp[1] * C(3, 2) 0 * 3 0。 所以dp[4]3。i5: 考虑a2。dp[5] dp[3] * C(4, 1) 1 * 4 4。固定一个球选另一个与它成大小为2的组剩下3个球必须是一个大小为3的组。方案数从剩下4个球选1个有4种。 考虑a3。dp[5] dp[2] * C(4, 2) 1 * 6 6。固定一个球选另外2个与它成大小为3的组剩下2个球必须是一个大小为2的组。方案数从剩下4个球选2个有C(4,2)6种。 所以dp[5] 4 6 10。最终答案为10。我们可以枚举验证5个不同的球分成若干组每组大小2或3。可能的组结构有(2,3) 和 (3,2)因为组无顺序这是一种。计算方案数结构为(2,3)先选2个球成一组剩下3个球自然成一组。选法有 C(5,2)10种。但注意组是无标号的所以选出{1,2}和{3,4,5}与选出{3,4,5}和{1,2}是同一种分组。所以我们多算了一倍不对仔细想。当我们用C(5,2)选出一组2个球时剩下的3个球自动成为另一组。这里并没有给两个组编号所以不会重复。因此就是10种。 等等我们之前dp算出来是10但这里只有一种组结构(2,3)算出来是10。那(3,2)呢它和(2,3)是同一个结构因为组无序。所以总方案就是10种。同时5个球不可能分成超过两组因为2245, 235正好。所以答案就是10。与DP结果一致。4. 动态规划的思维深化与常见变种4.1 如何训练DP的“状态感”很多初学者觉得DP难是因为无法将问题转化为合适的状态。我个人的经验是多问自己几个问题问题的“阶段”是什么什么是在自然推进的通常是处理的物品数量、走过的步数、到达的位置等。在当前阶段为了做出后续决策最少需要知道过去的哪些“总结性信息”这些信息就是状态。比如在分组问题中过去分了多少组j就是关键信息在背包问题中过去使用了多少容量是关键信息。这些状态信息能否合并是否过多好的状态应该尽可能少但要足够做出决策。如果状态太多比如试图记录每个组的具体成员就要考虑能否用更抽象的信息如组数、组的大小分布来代替。状态转移代表了什么决策把“阶段”向前推进一小步处理一个新物品、走一步等有哪些选择每个选择如何影响状态以ABC 217 G题为例我们经历了两种状态设计第一种斯特林数阶段已考虑球数状态已分组数。决策新球独立成组或加入旧组。第二种分组大小受限阶段总球数状态方案数。决策枚举最后一组的大小。第二种方法实际上是一种“背包DP”的思维总球数i是背包容量每个组的大小a是一件物品且可以重复使用但这里不是无限重复因为一组只能用一次但同大小的组可以有多组dp[i]是凑出容量i的方案数。只不过“物品”的组合系数不是1而是C(i-1, a-1)这体现了“从i个不同元素中选取a个组成一组”的组合意义。4.2 同类DP问题举一反三掌握了分组DP的模型你可以尝试解决许多变种问题贝尔数Bell Number求N个不同元素划分成任意非空子集的方案数。这就是我们最开始推导的斯特林数对所有组数j求和Bell(N) sum_{j0 to N} S(N, j)。可以用DPdp[i][j]计算斯特林数后求和也有更快的递推公式。每组至少K个元素状态转移时只有当组的大小至少为K时才能“关闭”一个组即将其计入最终方案。可能需要额外状态记录当前正在构建的组的大小。元素间有排斥关系某些球不能同组。这通常需要结合状态压缩DP状压DP用二进制位表示哪些球已经分组状态数会达到2^N只适用于N较小如N20的情况。求方案的具体内容不仅求方案数还要输出所有方案。这就需要回溯记录状态转移的路径在DP完成后进行DFS重建方案。时间复杂度会大幅增加。4.3 调试与验证DP的正确性DP代码写出来怎么知道对不对尤其是边界条件容易出错。小数据暴力枚举对于N很小的情况比如N8写一个DFS或位运算枚举所有分组方案暴力计数。将结果与你的DP程序输出对比。这是最可靠的验证方法。打印DP表在递推过程中打印出整个dp数组。观察数值是否在合理范围内是否满足你自己的递推公式可以手动计算前几行核对。检查模运算在加法和乘法后是否都正确取模特别是在计算组合数时。理解初始状态反复思考dp[0]1的含义。在很多计数DP中这个“空方案”是必要的起点。5. 竞赛实战中的优化策略与高级技巧当N很大比如1e5时O(N²)的斯特林数DP或O(N*|A|)的分组DP可能就不够快了。我们需要优化。5.1 利用卷积优化转移观察第二种DP的转移方程dp[i] sum_{a in A} dp[i-a] * C(i-1, a-1)。 当|A|很大或者A是连续区间时这个求和是O(N*|A|)的。但注意到对于不同的i组合数C(i-1, a-1)的计算并不独立且与dp[i-a]构成了一个类似卷积的形式但又不是标准的卷积因为组合数依赖于i。一种常见的优化场景是当题目约束更简单时例如“每组大小至少为K”。此时转移可能简化为dp[i] dp[i-1] dp[i-K]等形式可以使用矩阵快速幂在O(log N)时间内求解。对于更一般的集合A如果问题性质特殊有时可以利用生成函数Generating Function来求解。将dp序列看作某个生成函数的系数约束条件对应生成函数的方程通过多项式求逆、牛顿迭代等算法在O(N log N)甚至更优复杂度内求解。但这属于更高级的数论/多项式技巧在ABC的G题中较少见多见于更高级别的比赛。5.2 空间优化与滚动数组我们的DP数组通常是dp[N1]。如果状态转移只依赖于前面有限项比如只依赖于dp[i-a]其中a是常数那么空间复杂度就是O(N)这通常可以接受。如果使用了二维数组dp[i][j]如斯特林数且N较大如3000空间O(N²)可能达到近10MB在内存限制下也是可以的但我们可以用滚动数组优化到O(N)。对于斯特林数递推dp[i1][j] dp[i][j]*j dp[i][j-1]我们发现计算第i1行只依赖于第i行。因此只需要两个一维数组交替使用即可。MOD 998244353 N, K map(int, input().split()) # 假设题目要求计算S(N, K) dp_prev [0]*(N1) dp_curr [0]*(N1) dp_prev[0] 1 # 对应dp[0][0] for i in range(1, N1): dp_curr[0] 0 # dp[i][0] 0 for i0 for j in range(1, i1): dp_curr[j] (dp_prev[j] * j dp_prev[j-1]) % MOD # 滚动 dp_prev, dp_curr dp_curr, dp_prev # 循环结束后dp_prev 对应 dp[N] print(dp_prev[K] % MOD)5.3 处理大组合数模质数当N很大时预处理组合数C(n, k) mod MOD需要用到费马小定理求逆元。 预处理阶乘fact[i] i! % MOD和阶乘逆元inv_fact[i] (i!)^{-1} % MOD。 那么C(n, k) fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD。 预处理复杂度O(N)每次查询O(1)。MOD 998244353 N 10**5 fact [1]*(N1) inv_fact [1]*(N1) for i in range(1, N1): fact[i] fact[i-1] * i % MOD inv_fact[N] pow(fact[N], MOD-2, MOD) # 费马小定理求逆元 for i in range(N, 0, -1): inv_fact[i-1] inv_fact[i] * i % MOD def comb(n, k): if k 0 or k n: return 0 return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD将这个comb函数代入之前分组DP的转移方程中即可高效处理N较大的情况。6. 从解题到掌握我的DP学习心得动态规划的魅力在于它提供的是一种框架性的思考方式。就像解ABC 217 G题从最朴素的状态爆炸到找到关键状态dp[i][j]再到根据具体约束调整状态定义dp[i]最后优化实现。这个过程本身比记住这道题的答案更重要。我刚开始刷DP时总想背模板后来发现根本背不完。真正有用的是理解每个状态参数的实际意义理解转移方程对应的决策是什么。把“状态”和“决策”这两点想明白了很多DP题就有了突破口。另外一定要动手实现哪怕看了题解。实现过程中会遇到各种细节问题循环顺序、边界初始化、模运算、数组大小……这些都是宝贵的经验。用小数据测试用暴力程序对拍是调试DP代码最踏实的方法。最后不要害怕复杂的DP。像ABC的G题这种难度已经是需要认真思考才能解决的题目了。多练习几道类似的分组问题、背包问题、区间DP问题你会慢慢发现它们之间的共性。当你再看到“求方案数”的问题时脑子里能自然浮现出几种可能的状态设计方向那就说明你真的进步了。这道题只是一个起点后面还有更广阔的DP世界等着你去探索比如状态压缩DP、树形DP、概率DP等等每一类都是解决特定领域问题的强大工具。
返回列表