FEATURED · 精选文章

多重集组合数问题:从暴力枚举到动态规划的优化解法

发布时间 / 2026/8/28 17:52:09
来源 / 创域科博编辑部
栏目 / 资讯中心
多重集组合数问题:从暴力枚举到动态规划的优化解法 1. 多重集组合数问题从暴力枚举到动态规划的思维跃迁在算法竞赛和实际编程中我们经常会遇到一类经典的计数问题给定一个多重集即元素可以重复的集合从中选取特定数量的元素问有多少种不同的选取方法这就是所谓的“多重集组合数”问题。乍一看这似乎只是高中数学组合数的简单扩展但当你真正动手去实现时会发现暴力枚举在数据规模稍大时就会立刻超时。比如你有3种水果苹果、香蕉、橘子数量分别是2个、3个、1个。现在要从中恰好选出4个水果有多少种不同的选法手动列举可能还行但如果水果种类变成100种每种最多有1000个要选出500个那可能的方案数将是一个天文数字枚举法完全不可行。这时动态规划Dynamic Programming, DP就闪亮登场了。它不像蛮力法那样傻傻地尝试所有可能而是聪明地将大问题分解成小问题并记住这些小问题的答案避免重复计算。解决多重集组合数的DP方法堪称是理解“计数类DP”思想的一块绝佳敲门砖。它完美地展示了如何将看似复杂的“选择”过程转化为状态转移方程从而高效求解。无论你是正在备战算法竞赛的学生还是希望提升问题建模能力的开发者掌握这个问题的DP解法都能让你对“状态”和“转移”有更深刻的认识。接下来我们就一起拆解这个问题看看DP是如何化繁为简的。2. 问题定义与核心难点剖析2.1 精确的问题描述让我们先严格定义一下“多重集组合数”问题。 假设我们有n种不同的物品。第i种物品有a[i]个i从1到n。 现在我们需要从这些物品中恰好选出m个物品。 问总共有多少种不同的选取方法这里的关键词是“恰好”和“不同方法”。两种选取方法被认为是相同的当且仅当每种物品被选取的数量完全一致。顺序不重要我们只关心最终每种物品拿了几个。举个例子n 3 (物品种类0, 1, 2)a [3, 2, 1] (物品0有3个物品1有2个物品2有1个)m 4 (需要选出4个物品)一些合法的选取方案选3个物品0和1个物品1: (3, 1, 0)选2个物品0和2个物品1: (2, 2, 0)选2个物品0, 1个物品1, 1个物品2: (2, 1, 1)选1个物品0, 2个物品1, 1个物品2: (1, 2, 1) 注意物品1只有2个所以这是合法的上限 我们需要计算出所有这样的方案总数。2.2 暴力搜索为何失效最直观的想法是深度优先搜索DFS对于第i种物品枚举选取的数量k从0到min(a[i], 剩余数量)然后递归处理下一种物品直到总数量达到m或所有物品处理完。 这种方法的复杂度是多少在最坏情况下每种物品都有很多个分支数量是指数级增长的。时间复杂度是 O(Π(a[i]1))这在实际中a[i]稍大一点是完全无法接受的。当n100, m500时搜索空间巨大等待结果和等待宇宙热寂差不多。2.3 动态规划的切入点DP的核心思想是用空间换时间记录子问题的解。对于多重集组合数一个很自然的子问题是仅考虑前i种物品从中选出j个物品有多少种方法我们定义dp[i][j]表示这个子问题的答案。 那么最终我们要求的就是dp[n][m]。现在关键在于如何从dp[i-1][?]推导出dp[i][j]也就是状态转移方程。这需要我们思考在已经处理好前i-1种物品的基础上加入第i种物品后方案数如何变化3. 基础DP解法三重循环与优化思路3.1 最直观的状态转移方程当我们计算dp[i][j]时意味着我们正在决定第i种物品要拿多少个。假设我们决定从第i种物品中拿k个k可以是0, 1, 2, ...,min(a[i], j)因为最多不能超过该物品的库存a[i]也不能超过当前需要的总数j。 那么剩下的j-k个物品就必须从前i-1种物品中选取。而从i-1种物品中选取j-k个的方案数我们已经计算过了就是dp[i-1][j-k]。因此dp[i][j]就是所有可能的k取值对应的dp[i-1][j-k]的总和。 写成方程就是dp[i][j] Σ dp[i-1][j-k]其中 k 从 0 遍历到min(a[i], j)。初始化dp[0][0] 1考虑0种物品选出0个物品有1种方法什么都不选。dp[0][j] 0 (j0)考虑0种物品想选出大于0个物品是不可能的方案数为0。3.2 代码实现与复杂度分析根据上面的方程我们可以写出一个三重循环的DP代码框架以C为例#include iostream #include vector #include algorithm using namespace std; int main() { int n, m; cin n m; vectorint a(n1); // 让下标从1开始方便理解 for (int i 1; i n; i) cin a[i]; // dp[i][j]: 从前i种物品中选出j个的方案数 vectorvectorlong long dp(n1, vectorlong long(m1, 0)); dp[0][0] 1; for (int i 1; i n; i) { for (int j 0; j m; j) { for (int k 0; k min(a[i], j); k) { dp[i][j] dp[i-1][j-k]; } } } cout dp[n][m] endl; return 0; }复杂度分析外层i循环: O(n)中层j循环: O(m)内层k循环: 最坏 O(m) (当 a[i] 很大时) 总时间复杂度为 O(n * m * m) O(n * m²)。当n和m都在1000左右时运算量将达到10^9级别这在常规时间限制1-2秒内是无法完成的。因此这个基础的三重循环解法虽然正确但效率太低需要优化。注意在实际编码中dp[i][j]可能会非常大远超int范围通常需要使用long long或者配合取模运算如果题目要求输出对某个大质数取模的结果这在竞赛中非常常见。3.3 优化动机寻找冗余计算观察状态转移方程dp[i][j] Σ_{k0}^{min(a[i], j)} dp[i-1][j-k]。 我们计算dp[i][j]和dp[i][j-1]时求和项有很大一部分是重叠的。 具体来说dp[i][j] dp[i-1][j] dp[i-1][j-1] ... dp[i-1][j-min(a[i], j)]dp[i][j-1] dp[i-1][j-1] dp[i-1][j-2] ... dp[i-1][(j-1)-min(a[i], j-1)]可以看出dp[i][j]的求和式几乎就是dp[i][j-1]的求和式向前平移了一项然后根据a[i]的限制在首尾有所增减。如果我们能利用dp[i][j-1]的结果来快速计算dp[i][j]就能省去内层的k循环将复杂度降为 O(n * m)。4. 高效DP解法前缀和优化与滚动数组4.1 利用前缀和优化状态转移我们定义prefix_sum[j] dp[i-1][0] dp[i-1][1] ... dp[i-1][j]即前i-1种物品选取不超过j个的所有方案数的前缀和。那么dp[i][j]的求和式可以优雅地表示为dp[i][j] prefix_sum[j] - prefix_sum[j - min(a[i], j) - 1]更准确地说是dp[i][j] prefix_sum[j] - (j - a[i] - 1 0 ? prefix_sum[j - a[i] - 1] : 0)推导过程dp[i][j] Σ_{k0}^{min(a[i], j)} dp[i-1][j-k]令t j-k则当k0时tj当kmin(a[i], j)时tj-min(a[i], j)。 所以求和变为Σ_{tj-min(a[i], j)}^{j} dp[i-1][t]。 这正好是前缀和prefix_sum[j]减去prefix_sum[j-min(a[i], j) - 1]。 由于min(a[i], j)在j a[i]时就是j此时j-min(a[i], j)-1 -1我们规定prefix_sum[-1] 0。 因此通用公式为dp[i][j] prefix_sum[j] - (j a[i] ? prefix_sum[j - a[i] - 1] : 0)这样我们在计算完第i-1层的所有dp值后可以先花 O(m) 时间计算出这一层的前缀和数组。然后在计算第i层的dp[i][j]时对于每个j我们都可以在 O(1) 时间内通过两次前缀和查询得到结果。总复杂度成功降为 O(n * m)。4.2 代码实现前缀和优化版#include iostream #include vector using namespace std; int main() { int n, m; cin n m; vectorint a(n1); for (int i 1; i n; i) cin a[i]; const int MOD 1000000007; // 假设题目要求对1e97取模 vectorvectorlong long dp(n1, vectorlong long(m1, 0)); dp[0][0] 1; for (int i 1; i n; i) { // 计算上一行i-1的前缀和 vectorlong long prefix(m1, 0); prefix[0] dp[i-1][0]; for (int j 1; j m; j) { prefix[j] (prefix[j-1] dp[i-1][j]) % MOD; } // 利用前缀和计算当前行 dp[i][j] for (int j 0; j m; j) { // dp[i][j] sum_{k0}^{min(a[i], j)} dp[i-1][j-k] // prefix[j] - prefix[j - min(a[i], j) - 1] // 当 j a[i] 时 min(a[i], j) j, 那么 j - min(a[i], j) - 1 -1 if (j a[i]) { dp[i][j] prefix[j]; // 相当于 prefix[j] - prefix[-1] 而prefix[-1]我们定义为0 } else { // 注意下标不要越界 dp[i][j] (prefix[j] - prefix[j - a[i] - 1] MOD) % MOD; } } } cout dp[n][m] endl; return 0; }4.3 空间优化滚动数组技巧观察状态转移计算dp[i][j]时只依赖于上一行dp[i-1][*]的数据。这意味着我们不需要保留整个n x m的二维数组只需要两个一维数组一个表示“上一行”一个表示“当前行”交替使用即可。这被称为“滚动数组”能将空间复杂度从 O(n*m) 降低到 O(m)。滚动数组实现#include iostream #include vector using namespace std; int main() { int n, m; cin n m; vectorint a(n1); for (int i 1; i n; i) cin a[i]; const int MOD 1000000007; vectorlong long dp_prev(m1, 0), dp_curr(m1, 0); dp_prev[0] 1; // 对应 i0 的情况 for (int i 1; i n; i) { // 计算上一行的前缀和 vectorlong long prefix(m1, 0); prefix[0] dp_prev[0]; for (int j 1; j m; j) { prefix[j] (prefix[j-1] dp_prev[j]) % MOD; } // 计算当前行 for (int j 0; j m; j) { if (j a[i]) { dp_curr[j] prefix[j]; } else { dp_curr[j] (prefix[j] - prefix[j - a[i] - 1] MOD) % MOD; } } // 滚动当前行变成下一轮的“上一行” swap(dp_prev, dp_curr); // 清空dp_curr可选因为下一轮会覆盖但为了逻辑清晰可以重置为0 fill(dp_curr.begin(), dp_curr.end(), 0); } // 循环结束后dp_prev 对应的是 in 的结果 cout dp_prev[m] endl; return 0; }实操心得使用滚动数组时要格外小心状态的覆盖顺序。在这个问题里由于我们按行计算并且当前行只依赖于上一行的完整数据所以直接交换数组是安全的。但在一些其他DP问题如经典的01背包中如果按行计算时内层循环需要逆序更新就不能简单交换而需要在同一个数组上“就地”更新。理解状态依赖的方向是正确使用滚动数组的关键。5. 边界条件、初始化与模运算处理5.1 边界条件的细致讨论DP的边界条件处理不当是导致错误的一大根源。对于多重集组合数问题有几个边界需要仔细考虑dp[0][0] 1这是整个DP的起点代表“没有任何物品可选且需要选0个物品”的方案数为1即什么都不选这一种方案。这个初始化必须正确。dp[0][j] 0 (j 0)没有物品可选却想选出大于0个物品这是不可能的。在我们的循环中dp_prev数组初始化为全0然后设置dp_prev[0]1就自然满足了这一条件。前缀和的下标处理在计算dp[i][j] prefix[j] - prefix[j - a[i] - 1]时当j - a[i] - 1 0时prefix[负数]是没有定义的。我们必须显式判断这种情况将其值视为0。代码中的if (j a[i])分支就是为了处理这种情况。物品数量限制a[i]在求和时k的上限是min(a[i], j)。在优化后的前缀和公式中这个min操作通过条件判断j a[i]来体现。5.2 大数取模的注意事项由于方案数可能极其巨大题目通常要求输出结果对某个大质数如1e97取模。在实现时必须时刻注意模运算加法与减法(a b) % MOD和(a - b MOD) % MOD。减法后加MOD是为了防止出现负数。前缀和中的累加计算前缀和数组时每次加法后都要取模。最终结果输出前确保是正数取模后的结果。一个常见的坑是在计算dp_curr[j] (prefix[j] - prefix[j - a[i] - 1] MOD) % MOD;时即使prefix[j]已经比prefix[j - a[i] - 1]大直接相减也可能因为之前的取模操作而变成负数例如prefix[j]是取模后的值可能实际上比另一个取模后的值小。所以 MOD再取模是标准且安全的做法。5.3 测试用例与调试编写完代码后务必用多个不同规模的测试用例进行验证。简单测试用例输入 n3, m4 a[3, 2, 1] 输出 4对应我们之前举例的四种方案边界测试用例m0无论物品有多少选出0个的方案只有1种。输入n5, m0, a[...任意...] 输出1某种物品数量为0输入n2, m2, a[2, 0] 输出1 (只能从第一种物品里选2个)m大于所有物品数量之和方案数应为0。输入n2, m5, a[2, 2] 输出0注意事项在竞赛或面试中先想清楚这些边界情况并在代码中妥善处理能避免很多不必要的失分。我个人的习惯是在写完转移方程后立刻在纸上手动模拟一下这些边界用例确保逻辑正确。6. 算法扩展与变种思考掌握了标准的多重集组合数DP解法后我们可以看看一些相关的变种问题这有助于深化对DP思想的理解。6.1 变种1每种物品至少选一个如果问题变为从n种物品中恰好选出m个且每种物品至少选一个有多少种方案 我们可以做一个简单的转化。既然每种至少要一个我们可以先从每种物品中“预支”1个出来。那么问题就等价于从新的多重集中第i种物品数量变为a[i] - 1中选出m - n个物品因为我们已经预支了n个。当然前提是a[i] 1对所有i成立且m n。然后套用原来的DP即可。6.2 变种2考虑顺序的组合数排列数原问题中(2个苹果, 1个香蕉)和(1个香蕉, 2个苹果)被视为同一种方案因为我们只关心每种物品的数量。如果考虑顺序即选取的m个物品排成一列相同的物品之间没有区别问有多少种不同的序列这就变成了多重集的排列数问题。 这是一个完全不同的问题通常使用指数型生成函数EGF或DP结合组合数学来解决状态设计会更加复杂。6.3 变种3背包问题的视角多重集组合数问题可以看作是一种特殊的背包问题背包容量为m。有n种物品第i种物品的重量是1每选一个就占用1个“容量”价值忽略且最多能拿a[i]个。求恰好装满背包的方案数。 这正是一个“有数量限制的完全背包问题”的计数版本。标准的完全背包计数问题每种物品无限个的状态转移是dp[j] dp[j - weight[i]]。而有了数量限制后就需要我们上面讨论的优化技巧来高效计算。6.4 从组合数学角度理解从纯粹的数学公式来看多重集组合数等于 求所有非负整数解(x1, x2, ..., xn)的个数满足x1 x2 ... xn m且0 xi a[i]。 如果没有上限a[i]这就是经典的“隔板法”问题解的数量为C(mn-1, n-1)。有了上限后通常需要用容斥原理来求解但容斥原理的复杂度是 O(2^n)在n很大时不可行。而我们的DP解法提供了一种在n和m较大时依然可行的多项式时间算法。7. 常见错误与实战调试技巧即便理解了算法在实现时也容易踩坑。下面记录几个我实战中遇到过的问题和解决方法。7.1 数组越界这是最常犯的错误之一尤其是在处理前缀和下标j - a[i] - 1时。错误示例dp_curr[j] (prefix[j] - prefix[j - a[i] - 1]) % MOD; // 当 j - a[i] - 1 0 时崩溃正确做法必须进行条件判断。long long prev_prefix (j - a[i] - 1 0) ? prefix[j - a[i] - 1] : 0; dp_curr[j] (prefix[j] - prev_prefix MOD) % MOD;或者像我们之前代码那样用if (j a[i])和else分支来处理。7.2 整数溢出与模运算即使使用了long long在中间计算前缀和prefix[j] prefix[j-1] dp_prev[j]时如果不对每一步取模累加值仍可能溢出。必须在加法操作后立即取模。prefix[j] (prefix[j-1] dp_prev[j]) % MOD; // 正确 prefix[j] prefix[j-1] dp_prev[j]; // 危险可能溢出 prefix[j] (prefix[j-1] dp_prev[j]) % MOD; // 即使dp_prev[j]已经取过模这里再取模也是安全的7.3 滚动数组的状态污染在使用滚动数组时如果忘记在每轮迭代开始前重置当前行数组或者错误地复用了旧数据会导致结果错误。建议做法使用两个明确的数组dp_prev和dp_curr。每轮计算完dp_curr后交换两者。在下一轮计算前可以选择性地将新的dp_curr即交换后的旧dp_prev清零。虽然下一轮会覆盖所有元素但清零是一个好习惯能避免思维上的混淆。7.4 初始化错误忘记初始化dp[0][0] 1会导致所有结果都是0。确保DP的“种子”状态正确设置。7.5 调试方法当程序输出错误答案时可以尝试以下方法小数据模拟用n2, m3, a[1,2]这样的小数据在纸上或通过打印中间dp表来手动模拟整个过程对比程序输出。打印DP表在关键步骤后打印出整个dp_prev或dp_curr数组检查是否符合预期。对于二维DP打印出整个表格尤其有效。对比暴力算法对于非常小的n和m比如n5, m5, a[i]3写一个DFS暴力搜索程序验证DP的结果是否与暴力枚举的结果一致。这是验证DP正确性的黄金标准。检查输入读取确认n,m,a[i]的读取是否正确数组下标是否从1开始如果代码逻辑是这么设计的。8. 性能分析与适用场景总结8.1 时间复杂度对比暴力DFSO(Π(a[i]1))。指数级不可接受。三重循环DPO(n * m²)。在 n, m 500 时或许可以勉强接受但通常竞赛中会卡掉。前缀和优化DPO(n * m)。这是最优的复杂度足以应对 n, m 在 1000-2000 量级的问题。滚动数组空间优化空间复杂度从 O(n*m) 降为 O(m)。8.2 适用场景与限制适用场景纯粹的计数问题如本文所述的多重集组合数。有限制条件的整数划分可以将“把整数m划分成n个部分且第i部分不超过a[i]”转化为此类问题。组合数学中的有上限变量方程求解。限制数值范围DP算法要求m不能太大通常几千以内因为我们需要开一个大小为O(m)的数组并执行O(n*m)次操作。如果m达到10^5n100那么10^7次操作在1秒内尚可但空间和时间都开始吃紧。取模要求如果题目不要求取模而要求输出精确的巨大整数则需要实现高精度运算会增加代码复杂度。思维难度相对于直接套公式的简单组合问题DP解法需要一定的建模和优化能力。8.3 与其他DP问题的联系理解多重集组合数DP对学习其他经典DP问题大有裨益完全背包问题计数版可以看作是a[i] m即每种物品无限个的特殊情况。此时状态转移可以进一步优化为dp[j] dp[j - weight[i]]的一维数组正向遍历。01背包问题计数版可以看作是a[i] 1的特殊情况。状态转移是dp[j] dp[j - weight[i]]但需要逆向遍历j。线性DP多重集组合数的DP本质上是一种线性DP按物品种类顺序处理状态是“已考虑的种类数”和“已选取的物品总数”。掌握这种根据物品数量限制来设计状态和优化转移的方法能让你在面对更复杂的计数DP时有更清晰的思路。比如一些树形DP中的计数问题或者带有更多维状态的DP其核心的优化思想——用前缀和或数据结构加速区间求和——是相通的。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻