整数划分

整数划分是研究这样的问题:如何将一个正整数表示成若干个正整数的和。

比如: 8=4+2+2。等式右边的数字允许重复出现。

下面我们从这样几个方面来考察:

  1. 如何以不重复的方式列出所有可能性。(Enumerate problem)
  2. 如何快速的计算出有多少种可能性。(Count problem)

比如以数字8为例,它可以拆分成以下一些数字:

8 = 8 8 = 7+1
8 = 6+2
8 = 6+1+1
8 = 5+3
8 = 5+2+1
8 = 5+1+1+1
8 = 4+4
8 = 4+3+1
8 = 4+2+2
8 = 4+2+1+1
8 = 4+1+1+1+1
8 = 3+3+2
8 = 3+3+1+1
8 = 3+2+2+1
8 = 3+2+1+1+1
8 = 3+1+1+1+1+1
8 = 2+2+2+2
8 = 2+2+2+1+1
8 = 2+2+1+1+1+1
8 = 2+1+1+1+1+1+1
8 = 1+1+1+1+1+1+1+1

上面一共有22行。我们用函数p(n)代表正整数n有多少种拆分。于是就有:
p(1) = 1, p(2) = 2, p(3) = 3, p(4) = 5, p(5) = 7, p(6) = 11,p(10) = 42, p(100) = 190569292。

然后我们会对这个问题做一些扩展。比如:

  1. 限制等式右边的数字的个数必须是k
  2. 限制等式右边的数字必须来自于某个集合(coin exchange problem)
  3. 限制等式右边的数字必须都是奇数
  4. 限制等式右边的数字不允许重复(这个问题与上面的问题3等价)

以字典序列出所有可能性

Donald Knuth在TAOCP的7.2.1.2节对所有此类问题给出了一个一般的框架。假设我们有一个序列 a1,a2,,an, 我们可以按照下面的步骤生成它的下一个排列:

  1. Find the largest j such that aj can be increased.
  2. Increase aj by the smallest fesible amount
  3. Find the lexicographically least way to extend the new a1,,aj to a complete pattern.

整数划分 - 不限个数

在这个问题上,我们可以把排序函数反过来,按照字典逆序的方式生成,这样会更简单一些。

套用到前面的算法框架中就是:

  1. Find the largest j such that aj can be increased: 从右往左找,找到第一个不是1的数字。
  2. Increase aj by the smallest fesible amount: 把找到的数字减去1.
  3. Find the lexicographically least way to extend the left sequence: 用贪婪算法把剩余的部分补齐。先尝试把aj往右尽可能的smash。然后尝试aj1,aj2,,1。直到整个新数组的和达到我们的目标为止。

代码如下:

1// F's signature is "void(const int* begin, const int* end)"
2#include <vector>
3template <typename F>
4inline void PartitionInt(int n, F &&f) {
5 // Set all the elements to 1
6 std::vector<int> a(n + 1, 1);
7 // Set the first one to zero
8 a[0] = 0;
9 // Now the sum of all the elements in vector a is n
10 int m = 1;
11 int q;
12 while (true) {
13 // Set the last number to n
14 a[m] = n;
15 // If the last number is 1, let q points to the next to last number.
16 // Else, q points to the last number
17 q = m - (n == 1 ? 1 : 0);
18
19 while (true) {
20 f(a.data() + 1, a.data() + 1 + m);
21 if (a[q] != 2) break;
22 // Break 2 to 1 + 1
23 a[q] = 1;
24 --q;
25 ++m;
26 }
27
28 if (q == 0) {
29 break;
30 }
31 // Decrease a[q] by 1
32 int x = a[q] - 1;
33 a[q] = x;
34 n = m - q + 1;
35 m = q + 1;
36 while (n > x) {
37 a[m++] = x;
38 n -= x;
39 }
40 }
41}

整数划分 - 限个数

在上面的题目上加一个限制:等号右面的必须是k个数字。

这次我们依然是按照降序的方式列出所有可能性。但是与上述算法不同的是:本算法从左往右寻找第一个应该被减少的元素。

1/**
2 * @brief PartitionIntToFixedParts Divide integer `n` into m parts.
3 * @param n
4 * @param m
5 * @param f Template F's signature is void(const int *begin, const int *end)
6 */
7#include <cassert>
8#include <vector>
9template <typename F>
10void PartitionIntToFixedParts(int n, int m, F &f) {
11 assert(n >= m)(static_cast <bool> (n >= m) ? void (0) : __assert_fail
("n >= m", __builtin_FILE (), __builtin_LINE (), __extension__
__PRETTY_FUNCTION__))
;
12 assert(m >= 2)(static_cast <bool> (m >= 2) ? void (0) : __assert_fail
("m >= 2", __builtin_FILE (), __builtin_LINE (), __extension__
__PRETTY_FUNCTION__))
;
13 // Set all the elements to 1
14 // 数组a里的元素永远保证按照从大到小排列
15 std::vector<int> a(m + 1, 1);
16 a[0] = n - m + 1;
17 // Set the last one to -1
18 a.back() = -1;
19
20 while (true) {
21 // 访问当前数组
22 f(a.data(), a.data() + m);
23 // 如果第一个元素和第二个元素之间的差值大于1
24 if (a[1] < a[0] - 1) {
25 // 把第一个元素减少1
26 --a[0];
27 // 把第二个元素增加1
28 ++a[1];
29 // 于是现在就是一个有效的序列
30 continue;
31 }
32 // 下面是更一般的情形,需要一直往后找一个足够小的元素
33 int j = 2;
34 int s = a[0] + a[1] - 1;
35 // 因为a的末尾有一个多余的哨兵元素,所以下面的for-loop不会是死循环(一定能找到一个足够小的元素)
36 for (; a[j] >= a[0] - 1; ++j) {
37 s += a[j];
38 }
39 // 如果找到的是哨兵元素,退出循环
40 if (j >= m) break;
41 // 把找到元素增加1
42 int x = a[j] + 1;
43 a[j] = x;
44 // 把a[j]左边的元素修改成至少和a[j]一样大
45 --j;
46 while (j >= 1) {
47 a[j] = x;
48 s -= x;
49 --j;
50 }
51 a[0] = s;
52 }
53}

用生成函数进行计数

假设H是一个由正整数组成的集合。对于给定的某个正整数n,我们需要把n拆分成H里面的一些数字的和。用p(“H”, n)代表有多少种可能性。那么我们定义如下生成函数:

f(q)=n>=0p(H,n)qn

于是我们有:

f(q)=nH(1qn)1

举个例子,对于硬币兑换问题,假设我们有2分,3分,5分三种硬币,求有多少种可能性能组成面值n。那么对应的生成函数就是:

f(q)=1(1q2)(1q3)(1q5)

在此基础上做扩展,如果我们限制拆分后每个数字出现的次数为d。并定义这样的拆分的数量为:p(H(d),n) ,那么我们就有:

f(q)=n>=0p(H(d),n)qn

用类似的推导,我们可以得到:

fd(q)=nH(1+qn++qdn)

等式右边是一个有限项的等比数列(比例为qn),于是有:

fd(q)=nH1q(d+1)n1qn

在这个基础上我们可以证明:“把n拆分成奇数的和” 与“把n拆分成不重复的整数的和” 是等价的。 因为“把n拆分成奇数的和”对应的生成函数是:

f(q)=n=1(1q2n1)1

“把n拆分成不重复的整数的和” 对应着上面d=1的情况,即它的生成函数是:

f(q)=n=11q2n1qn

上面式子的分子含有q的所有偶数项。分母含有q的所有奇数及偶数项。所以把相同的项消掉后,下面就只剩奇数项。于是n=11q2n1qnn=111q2n1是等价的。