ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

动态规划解多重集组合数:从暴力搜索到前缀和优化

动态规划解多重集组合数:从暴力搜索到前缀和优化 1. 项目概述从一道经典问题说起最近在整理动态规划DP的笔记翻到了“多重集组合数”这个老问题。这问题乍一看有点唬人名字里又是“多重集”又是“组合数”还跟DP挂钩让不少刚接触算法竞赛的朋友望而却步。但说穿了它解决的就是一个非常实际的资源分配问题你有若干种不同类型的物品每种物品有固定的数量上限现在要从中选出特定数量的物品问总共有多少种不同的选取方案。比如一个水果篮里有3个苹果、2个香蕉、4个橙子这就是一个多重集现在要从中恰好选出5个水果有多少种不同的水果组合这里的“组合”不关心顺序只关心每种水果拿了几个。这个问题之所以经典是因为它完美地体现了动态规划中“状态定义”和“状态转移”的核心思想并且是理解更复杂背包问题如多重背包的绝佳跳板。网上相关的解法文章不少但很多要么一上来就甩公式要么代码简短却缺乏对背后思路的细致拆解让初学者看得云里雾里。今天我就结合自己当年踩过的坑和后来的理解把这个问题的DP解法从头到尾、掰开揉碎了讲清楚。我们会从最朴素的暴力递归开始一步步优化到标准的DP解法并探讨其时间与空间复杂度的优化技巧。无论你是正在备战面试还是单纯想巩固DP基础相信这篇近万字的详解都能让你有所收获。2. 问题定义与核心思路拆解2.1 问题形式化描述让我们先把问题用数学语言严格定义一下这是理清思路的第一步。假设我们有n种不同的物品。对于第i种物品i从1到n它的最大可用数量为a[i]。现在我们需要从这n种物品中总共选取恰好m个物品m是一个给定的整数。注意选取时同一种物品被认为是完全相同的即我们只关心第i种物品拿了多少个而不关心具体拿了哪几个因为它们没区别。我们的目标是计算有多少种不同的选取方案。输入n: 物品种类数m: 需要选取的物品总数a[1..n]: 一个数组a[i]表示第i种物品的最大可用数量。输出一个整数表示不同的选取方案数。通常结果会很大需要对一个给定的模数比如1e97取模。举个例子n3,m5,a[3, 2, 4]。 这表示有3种物品比如A, B, CA最多拿3个B最多拿2个C最多拿4个。要拿总共5个。 一些合法的方案有(3A, 2B, 0C), (2A, 2B, 1C), (1A, 2B, 2C), (3A, 0B, 2C) 等等。 我们需要计算出所有这样的方案总数。2.2 暴力搜索与问题复杂度最直接的想法是暴力枚举对于第1种物品尝试拿0个、1个、...、a[1]个对于第2种物品在剩余名额内尝试拿0到a[2]个以此类推。最后检查拿的总数是否恰好为m。这种搜索树的大小是(a[1]1) * (a[2]1) * ... * (a[n]1)。在最坏情况下如果每种物品都有m个即a[i] m那么复杂度是O((m1)^n)这是指数级的完全不可接受。当n和m达到几十或上百时计算就遥遥无期了。注意这里暴力的“不可行”不是理论上的而是实践中的。它为我们使用动态规划提供了最直接的动机——存在大量的重复子问题。例如在枚举过程中我们可能会多次到达“已经考虑了前i种物品且当前已选取总数是j”的相同状态暴力搜索会为每个这样的状态重新计算后续的所有可能性造成了巨大的浪费。2.3 动态规划的核心直觉动态规划的本质是用空间换时间通过记录并复用子问题的解来避免重复计算。对于多重集组合数问题一个非常自然的DP状态定义是定义dp[i][j]表示从前i种物品中选取恰好j个物品的方案数。这里i的范围是0到n考虑前0种物品即什么都不考虑j的范围是0到m。最终答案就是dp[n][m]。那么如何从规模更小的问题推导出dp[i][j]呢这就是状态转移方程要解决的问题。思考的关键在于最后一种物品即第i种物品拿了多少个。假设我们在计算dp[i][j]。我们已经决定了前i-1种物品的选取方案现在来考虑第i种物品。我们可以拿k个第i种物品其中k可以从0取到min(a[i], j)因为最多拿a[i]个且拿的总数不能超过当前目标j。如果我们拿了k个第i种物品那么前i-1种物品就需要提供剩下的j-k个物品。而从前i-1种物品中选取j-k个的方案数正是我们定义好的子问题dp[i-1][j-k]。因此我们可以得到一个初步的状态转移方程dp[i][j] sum_{k0}^{min(a[i], j)} dp[i-1][j-k]其中sum表示求和。这个方程的含义是要得到从前i种物品中选j个的方案我们需要枚举第i种物品拿的数量k然后将所有对应的子方案数加起来。边界条件dp[0][0] 1从前0种物品中选0个只有一种方案什么都不选。dp[0][j] 0(对于j 0)没有物品可选却想选出东西这是不可能的。这个思路是清晰且正确的但它引出了我们接下来要解决的核心问题如何高效地计算这个求和直接枚举k会导致时间复杂度为O(n * m * max(a))如果max(a)和m都很大比如都是1000那么n*m*max(a)就是10^9量级依然很高。我们需要更聪明的转移方式。3. 核心算法解析与优化推导3.1 直接枚举法的实现与缺陷根据上面的转移方程dp[i][j] sum(dp[i-1][j-k] for k in 0..min(a[i], j))我们可以写出最直接的DP代码框架以模数MOD为例MOD 10**9 7 dp [[0] * (m1) for _ in range(n1)] dp[0][0] 1 for i in range(1, n1): for j in range(0, m1): # 枚举第i种物品拿多少个 max_k min(a[i-1], j) # 注意a数组索引从0开始 for k in range(0, max_k 1): dp[i][j] (dp[i][j] dp[i-1][j-k]) % MOD这个三重循环的复杂度是O(n * m * max(a))。正如前面所说当m和max(a)较大时例如都是1000n100就会带来10^8次运算在常规的竞赛或面试环境中很可能超时。我们需要优化内层的求和循环。3.2 利用前缀和优化转移仔细观察求和式dp[i][j] dp[i-1][j] dp[i-1][j-1] ... dp[i-1][j - min(a[i], j)]。 我们可以发现当j增加1时求和的范围发生了滑动。让我们定义S(i, j) sum_{k0}^{min(a[i], j)} dp[i-1][j-k]。 那么dp[i][j] S(i, j)。现在考虑S(i, j1)S(i, j1) dp[i-1][j1] dp[i-1][j] ... dp[i-1][j1 - min(a[i], j1)]这看起来和S(i, j)关系不大。我们换一种等价但更利于优化的表示。令k j - k则原求和式可以改写为dp[i][j] sum_{kj-min(a[i], j)}^{j} dp[i-1][k]也就是说dp[i][j]是dp[i-1][*]在区间[j - min(a[i], j), j]上的和。这个区间求和正是前缀和Prefix Sum可以大显身手的地方。我们可以在每一轮i的循环中预先计算出dp[i-1][*]的前缀和数组prefix其中prefix[t] sum_{u0}^{t} dp[i-1][u]。那么区间[L, R]的和就可以用prefix[R] - prefix[L-1]在O(1)时间内得到。具体来说dp[i][j] prefix[j] - prefix[j - min(a[i], j) - 1]这里需要注意边界当j - min(a[i], j) - 1 0时prefix[负数]视为0。因此优化后的算法流程如下初始化dp[0][0] 1dp[0][j0] 0。对于i从 1 到 n a. 计算上一行i-1的DP值的前缀和数组prefix。 b. 对于j从 0 到 mL max(0, j - a[i])// 区间左端点注意这里min(a[i], j)等价于a[i]如果j a[i]否则为j。但用j - a[i]表示左端点更统一。dp[i][j] (prefix[j] - (prefix[L-1] if L0 else 0)) % MOD最终答案为dp[n][m]。这个算法的时间复杂度成功降到了O(n * m)因为对于每个(i, j)我们都可以在O(1)时间内完成转移。空间复杂度为O(n * m)。实操心得这个“区间和”的视角是优化本题的关键。很多DP问题在转移时需要求和如果求和范围是连续区间且与当前状态j有关一定要优先考虑前缀和优化。在面试或竞赛中能主动提出并实现这个优化是区分普通解法和高效解法的重要标志。3.3 空间复杂度的进一步优化滚动数组上面的DP表是(n1) x (m1)的二维数组。注意到在计算dp[i][j]时我们只依赖于上一行dp[i-1][*]的数据。这是典型的可以使用滚动数组进行空间优化的场景。我们可以只使用两个一维数组dp_curr表示当前行idp_prev表示上一行i-1。在每一轮循环开始将dp_curr作为新的当前行并基于dp_prev计算。计算完成后交换两个数组的角色或者将dp_curr赋值给dp_prev进入下一轮。结合前缀和优化代码会非常简洁初始化dp_prev[0] 1其余为0。对于i从 1 到 n a. 计算dp_prev的前缀和数组prefix。 b. 清空或初始化dp_curr为全0。 c. 对于j从 0 到 mL max(0, j - a[i])dp_curr[j] (prefix[j] - (prefix[L-1] if L0 else 0)) % MODd. 将dp_curr赋值给dp_prev作为下一轮的“上一行”。最终答案为dp_prev[m]。这样空间复杂度从O(n*m)降到了O(m)。这在m很大比如上万而n也很大时能有效节省内存。注意事项在取模运算中prefix[j] - prefix[L-1]可能会得到负数。安全的做法是(prefix[j] - prefix[L-1] MOD) % MOD。在L0时prefix[L-1]不存在我们将其值视为0所以计算为(prefix[j] MOD) % MOD实际上就是prefix[j] % MOD。4. 完整代码实现与逐行解读理解了原理和优化后我们来看一份完整的、带有详细注释的Python实现。这份代码使用了滚动数组和前缀和优化时间复杂度O(n*m)空间复杂度O(m)。def multiset_combination_count(n, m, a, MOD10**97): 计算多重集组合数。 参数: n (int): 物品种类数。 m (int): 需要选取的物品总数。 a (List[int]): 长度为n的列表a[i]表示第i种物品的最大数量。 MOD (int): 模数默认为10^97。 返回: int: 方案数对MOD取模的结果。 # dp_prev 代表上一行i-1的DP值初始时i0。 # dp_prev[j] 表示从前0种物品中选j个的方案数。 dp_prev [0] * (m 1) dp_prev[0] 1 # 边界条件选0个物品只有1种方案什么都不选 for i in range(1, n 1): # 计算上一行dp_prev的前缀和 prefix [0] * (m 1) prefix[0] dp_prev[0] for j in range(1, m 1): prefix[j] (prefix[j-1] dp_prev[j]) % MOD # 准备当前行的dp数组 dp_curr [0] * (m 1) ai a[i-1] # 第i种物品的最大数量注意列表索引从0开始 for j in range(0, m 1): # 区间左端点 L max(0, j - ai) L j - ai if L 0: L 0 # 利用前缀和计算区间和sum(dp_prev[L..j]) if L 0: # 区间从0开始和就是prefix[j] dp_curr[j] prefix[j] else: # 区间[L, j]的和 prefix[j] - prefix[L-1] dp_curr[j] (prefix[j] - prefix[L-1]) % MOD # 将当前行作为下一轮的“上一行” dp_prev dp_curr # 最终答案从前n种物品中选m个的方案数 return dp_prev[m] % MOD # 示例使用开头的例子 if __name__ __main__: n 3 m 5 a [3, 2, 4] result multiset_combination_count(n, m, a) print(f从{a}中选{m}个的方案数模1e97为{result}) # 可以手动验证一些小例子例如 # n1, m5, a[10] - 方案数为1全拿这一种 # n2, m3, a[1,1] - 方案数为0 (因为每种最多1个加起来最多2个无法达到3个)逐行解读与关键点初始化dp_prevdp_prev[j]初始状态对应i0没有物品。只有dp_prev[0]1是合法的表示什么都不选有一种方案。外层循环for i in range(1, n1)依次考虑第1到第n种物品。计算前缀和prefix这是优化的核心。prefix[j]存储了dp_prev[0] dp_prev[1] ... dp_prev[j]的和。注意每次外层循环都要重新计算因为dp_prev更新了。内层循环for j in range(0, m1)计算dp_curr[j]即考虑完前i种物品后选取j个的方案数。区间左端点L的计算L max(0, j - ai)。j - ai是理论上的左端点但如果它为负数意味着我们可以从第0个开始取即k可以从0取到j所以左端点修正为0。利用前缀和求区间和如果L 0那么区间[0, j]的和就是prefix[j]。否则区间[L, j]的和等于prefix[j] - prefix[L-1]。这里做了取模运算为保证结果非负代码中直接% MOD。在实际中更安全的写法是(prefix[j] - prefix[L-1] MOD) % MOD。滚动更新计算完当前所有dp_curr[j]后将dp_curr赋值给dp_prev用于下一轮计算。返回结果循环结束后dp_prev[m]就是考虑了所有n种物品后选取m个的方案数。5. 算法扩展、变体与关联问题掌握了标准解法后我们来看看这个模型的一些变体和关联问题这能帮助你更深刻地理解其应用场景。5.1 变体一每种物品有最小选取数量限制原问题中每种物品可以拿0到a[i]个。如果问题变为第i种物品至少要拿b[i]个至多拿a[i]个b[i] a[i]求方案数。解法这是一个经典的转换技巧。既然每种物品必须至少拿b[i]个我们可以先“预支”这部分。令m m - sum(b[i])。如果m 0说明根本不可能满足最低要求方案数为0。否则问题转化为对于第i种物品可以拿0到(a[i] - b[i])个总共拿m个。这就完全化归成了标准的多重集组合数问题。只需将新的数量上限a[i] a[i] - b[i]和新的目标m代入我们的DP函数即可。5.2 变体二方案数极大时的处理与不同模数我们的代码默认对1e97取模。但在某些情况下答案可能不需要取模或者模数不同甚至需要高精度计算。不取模/大整数如果n,m,a[i]很小结果可能不会溢出Python的大整数范围可以直接计算。只需移除代码中的所有% MOD操作即可。但要注意方案数可能增长非常快组合数性质稍大的输入就可能产生天文数字。不同模数直接将函数参数MOD改为需要的值即可。注意模数最好是质数这样很多数论性质成立。如果模数不是质数前缀和做减法时(a-b)%MOD仍成立但涉及除法如组合数公式时就需要特别小心。高精度计算如果题目要求输出精确值如某些OJ题而结果远超普通整数范围就需要使用高精度库如Python的int本身可以处理任意大整数其他语言可能需要手动实现大数类。5.3 与背包问题的深刻联系多重集组合数问题本质上是多重背包问题的“计数”版本。在经典的多重背包中每种物品有重量w[i]、价值v[i]和数量上限a[i]背包容量为W目标是最大化总价值。而我们的问题是每种物品有数量上限a[i]目标总数量为m目标是计算方案总数。状态定义背包问题是dp[j]表示容量为j时的最大价值我们是dp[i][j]表示前i种物品总数为j的方案数。转移方程背包问题的转移是dp[j] max(dp[j], dp[j-k*w[i]] k*v[i])我们的转移是dp[i][j] sum(dp[i-1][j-k])。优化两者都可以使用前缀和优化来将分组枚举优化为O(1)转移。理解这种对应关系能让你同时掌握两类问题的精髓。经验技巧很多DP问题都是相通的。当你学会多重集组合数的前缀和优化后再去看多重背包的“单调队列优化”会发现思想内核非常相似都是优化一个滑动窗口的最值或和。这种举一反三的能力是算法学习的关键。5.4 组合数学视角的另解生成函数从纯数学角度看多重集组合数问题等价于求方程x1 x2 ... xn m的整数解个数其中0 xi a[i]。这可以通过容斥原理或生成函数来求解。生成函数的方法是第i种物品的选取方案对应多项式(1 x x^2 ... x^{a[i]})。那么所有物品的选取方案总数就是所有这些多项式的乘积F(x) Π_{i1}^{n} (1 x ... x^{a[i]})。我们要求的答案就是这个乘积中x^m项的系数。这种方法在理论分析时很漂亮但直接用于计算特别是模数下效率并不比DP高尤其是当n和a[i]较大时。不过它为问题提供了一个不同的视角在某些特定限制下比如所有a[i]都相等可能能推导出更简单的公式。6. 常见错误、调试技巧与实战心得即使理解了算法在实现时也难免会遇到一些坑。这里我总结几个常见的错误和调试技巧。6.1 边界条件处理不当这是DP问题最常见的错误来源。数组下标越界在计算前缀和prefix[L-1]时必须检查L0。我们的代码中通过if L 0进行了分支处理。初始化错误dp[0][0]必须初始化为1。如果初始化为0则所有结果都是0。同样dp[0][j0]必须为0。j的循环范围必须是0到minclusive。从0开始是因为我们要计算选取0个物品的方案数这通常是1即空方案这在状态转移中是必要的中间状态。负数的模运算在dp_curr[j] (prefix[j] - prefix[L-1]) % MOD这行如果prefix[j] prefix[L-1]结果会是负数。在Python中-1 % MOD会得到正数MOD-1这是正确的。但在C/Java中-1 % MOD可能得到-1导致错误。安全的写法永远是(prefix[j] - prefix[L-1] MOD) % MOD。6.2 复杂度分析与优化验证时间优化后的算法是O(n*m)。你可以通过分析循环嵌套来验证两层循环外层n内层m内层循环内部是O(1)操作。空间使用滚动数组后是O(m)。你可以检查代码中是否只维持了dp_prev,dp_curr,prefix这三个长度为m1的数组。验证优化效果可以写一个未优化的三重循环版本作为“暴力对拍器”用随机生成的小数据比较两个版本的结果是否一致。这是检验优化算法正确性的黄金标准。6.3 调试与打印中间状态当程序输出错误结果时最好的调试方法是打印DP表。对于小规模的输入如n3, m5, a[3,2,4]可以打印出每一轮每个i计算后的dp_curr数组。# 在dp_curr计算完成后添加打印 print(fi{i}, dp_curr {dp_curr})对照着手算的前几行很容易发现哪里出了错。例如i1时dp_curr应该表示只考虑第一种物品最多拿3个时拿0,1,2,3,4,5个的方案数。显然dp_curr[0]1,dp_curr[1]1,dp_curr[2]1,dp_curr[3]1,dp_curr[4]0,dp_curr[5]0。如果不符合就检查前缀和计算或区间求和逻辑。6.4 从理论到实战的思维转换最后分享一点我的实战心得。学习DP不能只停留在看懂例题和代码。关键是要训练出一种“问题转化”的思维。当你看到一个新问题时要能快速识别出它是否是某个经典模型的变体。对于“选取”、“组合”、“方案数”、“每种有上限”这类关键词要立刻联想到多重集组合数或背包模型。然后问自己几个问题“状态”是什么通常与“考虑了前多少种物品”和“当前的总数/总量”有关。“决策”是什么通常是“对于当前物品拿多少个”。转移方程如何列出枚举决策将子问题合并。初始条件是什么通常是“什么都不考虑时总数为0的方案数为1”。如何优化如果转移是求和形式且求和范围是连续区间立刻想到前缀和。多重集组合数问题就像一块很好的磨刀石它结构清晰又有优化空间。彻底搞懂它对你攻克更复杂的DP问题如树形DP、状压DP会大有裨益。下次遇到类似问题希望你能自信地写出O(n*m)的优化解法。
返回列表