多重集的r-组合是非常常见的组合问题, 但相关资料通常只给出组合数的计算, 却无法给出实际的方案, 下面将通过一个水果摆盘问题由简单到复杂逐步推导并给出最终的求组合数和组合方案的算法.
水果拼盘问题
假定有一次聚会需要准备一个水果拼盘, 其中拼盘中需要装入6个水果, 目前有4个苹果, 3个香蕉和6个桃子, 求有多少种不同组合, 并列举所有的方案.
用数学方式描述即为
存在一个多重集 \(S\), 其中元素允许重复, 比如本题中: $$S=[🍎, 🍎, 🍎, 🍎, 🍌, 🍌, 🍌, 🍑, 🍑, 🍑, 🍑, 🍑, 🍑]$$
本文为了简化写作以下形式(注意元素的个数写在乘号前): $$S=[4 \times🍎, 3 \times 🍌, 6 \times 🍑]$$
现在需从中选出 \(r\) 个元素组成一个组合, 求有多少不同的组合数.
水果无限多
首先推导水果无限多的情况, 由于拼盘容量仅有6个, 故只需水果有6个即等价于无限的情况.
隔板法
用隔板法可以简单推导:
- 准备好所有水果
- 在苹果中插入一个隔板, 有0~6号位置共7种选择, 假定插在 \(k_1\) 位置, 取框中 \(k_1\) 水果共加入拼盘.
- 在香蕉中插入一个隔板, 由于已经有了 \(k_1\) 个苹果, 所以只有0~6-\(k_1\) + 1种选择, 假定插在 \(k_2\) 位置.
- 最后由于只有三种水果, 故只有1种选择, 只能加入 \(6 - k_1 - k_2\) 个桃子
综上可以总结出一个结论:
在一个有 \(k\) 种元素的多重集 $$S=[\infty·a_1, \infty·a_2, \infty·a_3, \dots, \infty·a_k]$$ 中选出 \(r\) 个元素共有$$\binom{r+k-1}{k-1}$$种组合. 在水果拼盘问题种有 \(\binom{6 + 3 - 1}{3 - 1} = 28\) 种组合.
该推导方式存在三个问题:
- 结论不直观, 从隔板法到组合数公式跨度略大
- 不能处理水果数目有限的情况
- 不能给出组合方案
后面会逐一解决以上三个问题.
动态规划法
通过 \(dp[i][j]\) 数组存放所有的组合方案, 其中 \(i\) 代表有有 \(i\) 种水果, \(j\) 代表果盘容量为 \(j\). 比如 \(dp[3][6]\) 即为3种水果选6个的所有组合方案.
- 建立dp数组
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | |||||||
1 (🍎) | |||||||
2 (🍎, 🍌) | |||||||
3 (🍎, 🍌, 🍑) |
- 初始化dp数组
当 \(j=0\) 时不选择水果, 为空组合, \(i=0\) 时没有水果组合无法组合, 即 \(dp[\_][0] = \varnothing\), \(dp[0][\_]\) 不存在(下划线"_"代表任意值), 特别的 \(dp[0][0]=\varnothing\)
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (🍎) | \(\varnothing\) | ||||||
2 (🍎, 🍌) | \(\varnothing\) | ||||||
3 (🍎, 🍌, 🍑) | \(\varnothing\) |
- 处理dp[1][_]
在 \(i=1\) 时有1种水果苹果, 那么取1个时只可以取1个苹果, 取2个时只能取2个苹果, 以此类推, 显然后一个方案必然由前一个方案加1个苹果得到, 即 \(dp[1][j] = dp[1][j-1] \cup [🍎]\)
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | [5\(\times\)🍎] | [6\(\times\)🍎] |
2 (🍎, 🍌) | \(\varnothing\) | ||||||
3 (🍎, 🍌, 🍑) | \(\varnothing\) |
- 处理dp[2][_]
在 \(i=2\) 时有2种水果, 推广3中得结论有 \(dp[2][j] = dp[2][j-1] \cup [🍌]\), 同时 \(dp[2][j]\) 上一行 \(dp[1][j]\) 的方案也是可行的, 故有 \(dp[2][j] = (dp[2][j-1] \cup [🍌]) + dp[1][j]\)
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | [5\(\times\)🍎] | [6\(\times\)🍎] |
2 (🍎, 🍌) | \(\varnothing\) | [🍌][🍎] | [🍌🍌] [🍌🍎] [🍎🍎] |
[🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[4\(\times\)🍌] [🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
[5\(\times\)🍌] [4\(\times\)🍌,🍎] [🍌🍌🍌🍎🍎] [🍌🍌🍎🍎🍎] [🍌,4\(\times\)🍎] [5\(\times\)🍎] |
[6\(\times\)🍌] [5\(\times\)🍌,🍎] [4\(\times\)🍌,🍎🍎] [🍌🍌🍌🍎🍎🍎] [🍌🍌,4\(\times\)🍎] [🍌,5\(\times\)🍎] [6\(\times\)🍎] |
3 (🍎, 🍌, 🍑) | \(\varnothing\) |
- 处理剩余情况
由4的结论推广得到 \(dp[i][j] = (dp[i][j-1] \cup [第i种水果]) + dp[i-1][j]\), 基于此我们完成整个 \(dp\) 数组 (部分项目过多, 不再列举)
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | [5\(\times\)🍎] | [6\(\times\)🍎] |
2 (🍎, 🍌) | \(\varnothing\) | [🍌][🍎] | [🍌🍌] [🍌🍎] [🍎🍎] |
[🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[4\(\times\)🍌] [🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
[5\(\times\)🍌] [4\(\times\)🍌,🍎] [🍌🍌🍌🍎🍎] [🍌🍌🍎🍎🍎] [🍌,4\(\times\)🍎] [5\(\times\)🍎] |
[6\(\times\)🍌] [5\(\times\)🍌,🍎] [4\(\times\)🍌,🍎🍎] [🍌🍌🍌🍎🍎🍎] [🍌🍌,4\(\times\)🍎] [🍌,5\(\times\)🍎] [6\(\times\)🍎] |
3 (🍎, 🍌, 🍑) | \(\varnothing\) | [🍑][🍌][🍎] | [🍑🍑] [🍑🍌] [🍑🍎] [🍌🍌] [🍌🍎] [🍎🍎] |
[🍑🍑🍑] [🍑🍑🍌] [🍑🍑🍎] [🍑🍌🍌] [🍑🍌🍎] [🍑🍎🍎] [🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[4\(\times\)🍑] [🍑🍑🍑🍌] [🍑🍑🍑🍎] [🍑🍑🍌🍌] [🍑🍑🍌🍎] [🍑🍑🍎🍎] [🍑🍌🍌🍌] [🍑🍌🍌🍎] [🍑🍌🍎🍎] [🍑🍎🍎🍎] [4\(\times\)🍌] [🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
\(\dots\) | \(\dots\) |
基于对 \(dp\) 数组的逐层推导, 我们得到如下的动态转移方程:
其中 \(f_i\) 为第i层出现的水果
如果单纯记录组合数而不必得到方案, 则同样易得动态转移方程
如此可得到无限水果下的组合数 \(dp\) 数组
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
2 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
3 | 1 | 3 | 6 | 10 | 15 | 21 | 28 |
可以注意到 \(dp\) 数组构成了一个杨辉三角, 由杨辉三角通项公式易得无限水果条件下的摆盘问题解为:
通过 \(dp\) 数组法不仅能够推导出组合数公式, 通过集合运算给出了所有组合的具体方案
水果有限
水果有限时组合方案必然是无限时的子集, 所以需要通过一些方法减去不可行的方案, 从前文的动态转移方程中发现 \(dp[i][j-1]\cup [f_i]\) 这步骤不一定可行, 因为当 \(f_i\) 水果数目不足时不可以继续加入, 所以重点就在于处理该过程.
动态规划法
同样采用动态规划法推导组合方案:
- 初始化dp数组
显然有
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (4\(\times\)🍎) | \(\varnothing\) | ||||||
2 (4\(\times\)🍎, 3\(\times\)🍌) | \(\varnothing\) | ||||||
3 (4\(\times\)🍎, 3\(\times\)🍌, 6\(\times\)🍑) | \(\varnothing\) |
- 处理dp[1][_]
利用公式 \(dp[i][j] = (dp[i][j-1] \cup [🍎]) + dp[i-1][j]\), 显然苹果数目只够填满1~4格, \(j>4\) 的情况开始特殊处理.
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (4\(\times\)🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | N/A | N/A |
2 (4\(\times\)🍎, 3\(\times\)🍌) | \(\varnothing\) | ||||||
3 (4\(\times\)🍎, 3\(\times\)🍌, 6\(\times\)🍑) | \(\varnothing\) |
-
处理dp[2][_]
利用公式 \(dp[i][j] = (dp[i][j-1] \cup [🍌]) + dp[i-1][j]\), 香蕉同样存在不足的情况, \(j>3\) 的情况开始特殊处理.
\(dp[2][4]\) 中香蕉开始出现不足, 此时需要删除最开始的第1个组合;
\(dp[2][4]\) 删除了元素的基础上, dp[2][5]还要删除第1个组合;
\(dp[2][6]\) 同样需要删除第1个组合.删除元素是因为其前3格把所有香蕉都用了, 所以必须删除部分元素.
\(dp[2][1]\) 开始有1个组合使用香蕉, 所以必须删除1个 \(dp[2][4]\) 元素
\(dp[2][2]\) 开始有2个组合使用香蕉, 所以必须删除2个 \(dp[2][5]\) 元素, 由于 \(dp[2][4]\) 已经删除了1个, 所以 \(dp[2][5]\) 实际只要删除1个.
\(dp[2][3]\) 开始有3个组合使用香蕉, 所以必须删除3个 \(dp[2][6]\) 元素, 由于 \(dp[2][5]\) 已经删除了2个, 所以 \(dp[2][6]\) 实际只要删除1个.同时根据前文组合数递推式$$dp[i][j-3] = dp[i][j-1-3] + dp[i-1][j-3]$$经过化简可以推知删除元素的个数恰好等于 $$dp[i-1][j-1-3]$$中的组合数, 这就是为什么 \(dp[2][4]\) 需要删除 \(card(dp[2-1][4-1-3]) = card(dp[1][0]) = 1\) 个元素(空集也是一种组合); 同理 \(dp[2][5]\) 需要删除 \(card(dp[1][1]) = 1\) 个元素, 以此类推 (这部分推导因为过于复杂, 省略了用容斥原理展开并化简的过程, 有余力的读者可以尝试推导).
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (4\(\times\)🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | N/A | N/A |
2 (4\(\times\)🍎, 3\(\times\)🍌) | \(\varnothing\) | [🍌][🍎] | [🍌🍌] [🍌🍎] [🍎🍎] |
[🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
[🍌🍌🍌🍎🍎] [🍌🍌🍎🍎🍎] [🍌,4\(\times\)🍎] |
[🍌🍌🍌🍎🍎🍎] [🍌🍌,4\(\times\)🍎] |
3 (4\(\times\)🍎, 3\(\times\)🍌, 6\(\times\)🍑) | \(\varnothing\) |
- 处理dp[3][_]
由3得到新的转移方程推知 $$dp[i][j] = (dp[i][j-1] \cup [🍌] 舍去最初card(dp[i-1][j- 1 - \text{amount of }f_i])个组合) + dp[i-1][j]$$用这个公式可以推出 \(dp[3]\) 行 (由于 \(\text{amount of }🍑 = 6\) 所以实际不会删除任何组合).
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | \(\varnothing\) | N/A | N/A | N/A | N/A | N/A | N/A |
1 (4\(\times\)🍎) | \(\varnothing\) | [🍎] | [🍎🍎] | [🍎🍎🍎] | [4\(\times\)🍎] | N/A | N/A |
2 (4\(\times\)🍎, 3\(\times\)🍌) | \(\varnothing\) | [🍌][🍎] | [🍌🍌] [🍌🍎] [🍎🍎] |
[🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
[🍌🍌🍌🍎🍎] [🍌🍌🍎🍎🍎] [🍌,4\(\times\)🍎] |
[🍌🍌🍌🍎🍎🍎] [🍌🍌,4\(\times\)🍎] |
3 (4\(\times\)🍎, 3\(\times\)🍌, 6\(\times\)🍑) | \(\varnothing\) | [🍑][🍌][🍎] | [🍑🍑] [🍑🍌] [🍑🍎] [🍌🍌] [🍌🍎] [🍎🍎] |
[🍑🍑🍑] [🍑🍑🍌] [🍑🍑🍎] [🍑🍌🍌] [🍑🍌🍎] [🍑🍎🍎] [🍌🍌🍌] [🍌🍌🍎] [🍌🍎🍎] [🍎🍎🍎] |
[4\(\times\)🍑] [🍑🍑🍑🍌] [🍑🍑🍑🍎] [🍑🍑🍌🍌] [🍑🍑🍌🍎] [🍑🍑🍎🍎] [🍑🍌🍌🍌] [🍑🍌🍌🍎] [🍑🍌🍎🍎] [🍑🍎🍎🍎] [🍌🍌🍌🍎] [🍌🍌🍎🍎] [🍌🍎🍎🍎] [4\(\times\)🍎] |
[5\(\times\)🍑] [4\(\times\)🍑,🍌] [4\(\times\)🍑,🍎] [🍑🍑🍑🍌🍌] [🍑🍑🍑🍌🍎] [🍑🍑🍑🍎🍎] [🍑🍑🍌🍌🍌] [🍑🍑🍌🍌🍎] [🍑🍑🍌🍎🍎] [🍑🍑🍎🍎🍎] [🍑🍌🍌🍌🍎] [🍑🍌🍌🍎🍎] [🍑🍌🍎🍎🍎] [🍑,4\(\times\)🍎] [🍌🍌🍌🍎🍎] [🍌🍌🍎🍎🍎] [🍌,4\(\times\)🍎] |
[6\(\times\)🍑] [5\(\times\)🍑,🍌] [5\(\times\)🍑,🍎] [4\(\times\)🍑,🍌🍌] [4\(\times\)🍑,🍌🍎] [4\(\times\)🍑,🍎🍎] [🍑🍑🍑🍌🍌🍌] [🍑🍑🍑🍌🍌🍎] [🍑🍑🍑🍌🍎🍎] [🍑🍑🍑🍎🍎🍎] [🍑🍑🍌🍌🍌🍎] [🍑🍑🍌🍌🍎🍎] [🍑🍑🍌🍎🍎🍎] [🍑🍑,4\(\times\)🍎] [🍑🍌🍌🍌🍎🍎] [🍑🍌🍌🍎🍎🍎] [🍑🍌,4\(\times\)🍎] [🍌🍌🍌🍎🍎🍎] [🍌🍌,4\(\times\)🍎] |
基于对dp数组的逐层推导, 我们得到如下的动态转移方程
其中 \(f_i\) 为第i层出现的水果
如果单纯记录组合数而不必得到方案, 则同样易得动态转移方程
如此可得到有限水果下的方案数dp数组
dp | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
1(4) | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
2(3) | 1 | 2 | 3 | 4 | 4 | 3 | 2 |
3(5) | 1 | 3 | 6 | 10 | 14 | 17 | 19 |
代码实现
下面用rust代码实现列举出所有中的r-组合方案
use std::fmt::Display;fn main() {let fruit_amount = [4, 3, 6];let fruit_kind = [Fruit::Apple, Fruit::Banana, Fruit::Peach];// use amount[] and kind[] replace S = [4*Apple, 3*Banana, 6*Peach]let plans: DPCell<Fruit> = r_combination_of_multiset(&fruit_amount, &fruit_kind, 6);for plan in plans.iter() {print!("[");for fruit in plan.iter() {print!("{}", fruit);}println!("]");}println!("there are {} plans", plans.len());
}type MultiSet<T> = Vec<T>;
type DPCell<T> = Vec<MultiSet<T>>;
type DPMatrix<T> = Vec<Vec<DPCell<T>>>;#[derive(Clone, Copy)]
enum Fruit {Apple,Banana,Peach,
}fn r_combination_of_multiset<T: Clone>(amount: &[usize], kind: &[T], r: usize) -> DPCell<T> {let amount: Vec<usize> = [&[0], amount].concat();let row_count = amount.len();let col_count = r + 1;let mut combinations: DPMatrix<T> = Vec::with_capacity(row_count);// dp initfor _ in 0..row_count {let mut row = Vec::with_capacity(col_count);for _ in 0..col_count {row.push(DPCell::new());}combinations.push(row);}for row in combinations.iter_mut() {row[0].push(vec![]);}// dp[i][j] += dp'[i][j-1] cup [i] skip card(dp[i-1][j-1-amount[i]]) + dp[i-1][j]for i in 1..row_count {for j in 1..col_count {let up = &combinations.clone()[i - 1][j];let left = &combinations.clone()[i][j - 1];let skip = if amount[i] < j {combinations.clone()[i - 1][j - 1 - amount[i]].len()} else {0};let current = &mut combinations[i][j];// dp[i][j] += dp'[i][j-1] cup [i] skip card(dp[i-1][j-1-amount[i]])add_left(current, left, kind[i - 1].clone(), skip);// dp[i][j] += dp[i-1][j]add_above(current, up);}}combinations.last().unwrap().last().unwrap().to_vec()
}fn add_left<T: Clone>(current: &mut DPCell<T>, left: &DPCell<T>, element_to_add: T, skip: usize) {for set_in_left in left.clone().iter_mut().skip(skip) {set_in_left.push(element_to_add.clone());current.push(set_in_left.to_vec());}
}fn add_above<T: Clone>(current: &mut DPCell<T>, above: &DPCell<T>) {for multiset in above.iter() {current.push(multiset.clone());}
}impl Display for Fruit {fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {match self {Self::Apple => write!(f, "🍎"),Self::Banana => write!(f, "🍌"),Self::Peach => write!(f, "🍑"),}}
}
运行得到
[🍑🍑🍑🍑🍑🍑]
[🍌🍑🍑🍑🍑🍑]
[🍎🍑🍑🍑🍑🍑]
[🍌🍌🍑🍑🍑🍑]
[🍎🍌🍑🍑🍑🍑]
[🍎🍎🍑🍑🍑🍑]
[🍌🍌🍌🍑🍑🍑]
[🍎🍌🍌🍑🍑🍑]
[🍎🍎🍌🍑🍑🍑]
[🍎🍎🍎🍑🍑🍑]
[🍎🍌🍌🍌🍑🍑]
[🍎🍎🍌🍌🍑🍑]
[🍎🍎🍎🍌🍑🍑]
[🍎🍎🍎🍎🍑🍑]
[🍎🍎🍌🍌🍌🍑]
[🍎🍎🍎🍌🍌🍑]
[🍎🍎🍎🍎🍌🍑]
[🍎🍎🍎🍌🍌🍌]
[🍎🍎🍎🍎🍌🍌]
there are 19 plans
基于以上代码简单修改即可得到无限水果组合方案以及组合数算法, 请读者自行编写. 注意由于涉及到删除集合中前若干元素的操作, 所以要求集合有序, 本文代码中使用Vec代替Set操作.