分拆数 - 入门

找到以前在校内给学弟讲课用的课件,把文字版搬到这里来咧。欢迎大家提出建议或者批评!
分拆数(整数分拆)这个知识点可能用的次数不多,但是本身还是很有趣的:

  • 可以使用 DP / 生成函数求解。
  • 有时候和分析题目复杂度上界有关系。
  • 和杨表有密切关系。

作为新青年,当然还是值得学习一下有木有!

分拆数

分拆数 \(p_n\) 是指将一个自然数 \(n\),分拆成若干正整数之和的方案数。

比如说 \(3\) 可以被拆为 \(1 + 1 + 1\)\(1 + 2\)\(3\) 的形式,一共三种,于是 \(p_3 = 3\)

没有额外规定的情况下,拆出来的正整数是可重集,且不在乎内部顺序(可以钦定是排序后的)。
单独规定了 \(p_0 = 1\)

K 部分拆数

我们让 \(p(n,k)\) 表示将一个自然数 \(n\) 拆成恰好 \(k\) 个正整数之和的方案数。

于是沿着前面的例子,有 \(p(3,3) = 1\)\(p(3,2) = 1\)\(p(3,1) = 1\)

单独规定了 \(p(0,0) = 1\),其余 \(p(0,i)\)\(p(i,0)\) 均为 \(0\)

计算分拆数

爆搜

我们总是可以从大到小放数字,定义 \(f(i,s)\) 表示目前可以放小于等于 \(i\) 的数字,还可以放的数字总和为 \(s\) 的方案数。

那么 \(f(i,s) = \sum_{x = 1}^i f(x, s - x)\) 满足 \(s - x \ge 0\)。递归求解 \(f(n,n)\) 即可。

复杂度大致是 \(O(n * p_n)\) 的,这里有个渐进估计 \(p_n \sim \frac{1}{4n\sqrt{3}} e^{\pi \sqrt{\frac{2n}{3}}}\),是亚指数级的。

在一些题目里,如果出现了 \(n=50\) 这种范围,可以考虑复杂度上界是否可能与分拆数有关。

简易 DP

根据定义,我们容易发现一种分拆数 \(p_n\) 的计算方式是把它的 \(k = 0 \dots n\) 部分拆数加起来

\[\begin{align} p_n = \sum_{k=0}^n p(n,k) \end{align} \]

求和的上界是 \(n\),因为分拆出来的是正整数,每个数至少是 \(1\)。现在问题就变成了,如何计算 \(p(n,k)\) 呢?

考虑以如下办法生成一个分拆方案,初始数列为 \(\{0\}\),进行若干轮操作,每轮:

  • 先在数列末尾放入 \(x\)\(0\),可以不放。
  • 随后数列中所有数字都加一。

这样做,我们就可以生成任何一个不升的数列,同时得到唯一对应的操作序列 \(X\)。考虑分拆数列和操作序列之间的关系:

  • 向末尾添加 \(x\)\(0\),随后全局加一。
  • 将所有 \(n\)\(k\) 分拆,转移成了 \(n+k+x\)\(k+x\) 分拆。

于是根据这种刻画,我们可以得到如下的求和公式,即枚举补了 \(x = 0 \dots k\)\(0\) 后全局加一。

\[\begin{align} p(n,k) &= \sum_{x=0}^k p(n-k,k-x) \\ &= \sum_{x=0}^k p(n-k,x) \end{align} \]

两式是等价形式,区别是将 \(x\) 定义为了 \(k-x\),交换了求和枚举的顺序。后者更简洁,我们后面会用第二个写法。

此时计算每个状态需要 \(O(k)\) 次加法,而总状态数为 \(O(nk)\),故复杂度目前是 \(O(nk^2)\),已经比暴力好了很多。

当然还可以进一步优化,这里使用一个非常常见而且有趣的 trick,观察下面两个式子:

\[\begin{align} p(n,k) &= \sum_{x=0}^k p(n-k,x) \\ p(n-1,k-1) &= \sum_{x=0}^{k-1} p(n-k,x) \end{align} \]

好像两者之间只差了一项 ?!!!

\[\begin{align} p(n,k) = p(n-1,k-1)+ p(n-k,k) \end{align} \]

于是现在计算每个状态只需要 \(O(1)\) 次加法,得到了 \(O(nk)\) 的算法。

到现在我们已经能比较快地计算所有 K 部分拆数了,于是在 \(O(n^2)\) 的时间里,计算出所有 \(p_n\) 是非常简单的!

互异分拆数

诶,如果要求拆分出的每个数字不一样怎么办呢?我们稍微修改一下前面的生成方式:

  • 在数列末尾放入至多一个 \(0\)(或者不放)。
  • 数列中所有数字加一。

于是有 \(d(n,k) = d(n-k,k) + d(n-k, k-1)\),前者表示没放,后者表示放了,复杂度依然是 \(O(nk)\),但可以分析得更精细。

由于 \(1+2+3 \dots + \sqrt n = O(n)\),这里的 \(k\) 不可能超过 \(O(\sqrt n)\),故复杂度其实是 \(O(n \times \min(k,\sqrt n))\) 的。

Ferrers 图和 Durfee square

\(12\) 为例子,我们可以直观地对每种分拆给出一种共轭的分拆(翻转这个图形)。


在原始情况下 \(p(n,k)\) 表示将 \(n\) 分为恰好 \(k\) 部的方案数。
而观察其共轭分拆,可以表示将 \(n\) 分为任意部,最大值恰好为 \(k\) 的方案数。赋予了 \(p(n,k)\) 新的含义。

在 Ferrers 图的基础上,我们可以从左上角找到一个极大的正方形,设边长为 \(h\),则两侧可以被视作最大值不超过 \(h\) 的分拆!


我们不妨考虑枚举窦菲方 Durfee square 的边长 \(h\)(花费 \(h^2\) 的数值),然后算另外两个部分的方案数,以此计算 \(p_n\)

下文的代码来源于 EI 的文章,原文使用生成函数作为动机,但本文毕竟是入门教程,最好用非生成函数的方式解释一下:

int b = sqrt(n);
ans[0] = tmp[0] = 1;

for (int h = 1; h <= b; ++h) {           // (1)

  for (int rep = 0; rep < 2; ++rep)      // (2)
    for (int j = h; j <= n - h * h; ++j) // (3)
      add(tmp[j], tmp[j - h]);

  for (int j = h * h; j <= n; ++j)       // (4)
    add(ans[j], tmp[j - h * h]);
}

首先可以看到最外部的循环 \((1)\) 是在枚举 \(h\),我们乘机介绍一下数组的含义:

  • \(ans[x]\) 表示 \(p_x\) 的值
  • \(tmp[x]\) 实际上是 \(tmp[h][x]\),表示使用不超过 \(h\) 的数字,两侧可以拼出面积恰好为 \(x\) 的方案数(里面不含 \(h^2\) 的面积)
    优化掉了一维内存是因为我们从小到大枚举 \(h\),正好可以压掉这维。

随后循环 \((2)\) 进行了两次循环,表示依次向两侧(下、右)加入大小恰为 \(h\) 的行 / 列(先前 \((1)\) 已经加入了 \(1 \dots h-1\))。
内部循环 \((3)\) 实则是裸的背包转移,加入一个 \(h\) 后,面积从 \(j - h\) 变为了 \(j\),故将方案数累加。

维护好了 \(h\) 对应的 \(tmp[h][x]\) 后,我们只需要给每个 \(x\) 加上 \(h^2\) 就得到了对应的最终面积 \(j=x+h^2\),加给 \(ans[j]\) 即可。

这样我们就在 \(O(n \sqrt n)\) 时间内求出了 \(p_1, p_2 \dots p_n\),可喜可贺,可喜可贺呀!

根号分治

实际上我们也有根号分治的做法,代码量和直观度都稍微差一些。然而根号分治的做法较为通用,值得大家学习:

我们继续利用自然平衡的事实:在分拆方案内,大于 \(\sqrt n\) 的数不会超过 \(\sqrt n\) 个。于是可以将问题分为两部分求解:

  • \(f(i, x)\) 表示只使用值域 \([1, i]\) 的数字,拼出 \(x\) 的方案数。
  • \(g(i, x)\) 表示使用了 \(i\) 个大于 \(\sqrt n\) 的数字,拼出 \(x\) 的方案数。

这两部分都有 \(i \le \sqrt n\),均可以在 \(O(n \sqrt n)\) 的时间内完成求解。第二部分需要注意生成答案的方式:

f[0] = 1;
for (int i = 1; i < B; ++i) {  // 先暴力背包求出 f(B - 1, j),放入的所有数字小于 B 的方案
  for (int j = i; j <= n; ++j) {
    f[j] += f[j - i];
  }
}

ans = f;
g0 = g1 = f;

for (int i = 1; i <= n / B; ++i) { // i 是目前放入的 >= B 的数字个数
  std::fill(g1.begin(), g1.end(), 0);

  for (int x = i * B; x <= n; ++x) {
    g1[x] = g0[x - B] + g1[x - i]  // 要么放入一个最小的数字 B, 要么全局 + 1
    add(ans[x], g1[x]);
  }

  std::swap(g0, g1);
}

更棒的算法

其实我们还有利用五边形数定理的算法,以及利用生成函数的 polylog 算法,我们留到 下一篇文章 里再讲吧。

例题

abc226_f Score of Permutations

给定 \(N\)\(K\)\(N \le 50, K \le 10^4\))。
对于长度为 \(N\) 的排列 \(P\),定义它的分数 \(s\) 为使得 \(P^m = \text{I}\)(即经过 \(m\) 次置换后回到初始状态)的最小正整数 \(m\)
求所有 \(N!\) 个排列的 \(s^K\) 之和,结果对 \(998244353\) 取模。

link

考察了一个经典结论是,一个排列的置换次数 \(m\),就是它所有置换环长度的最小公倍数。

到这里想必大家已经会了,因为 \(p_{50} = 204,226\),我们暴力枚举 \(N\) 的所有分拆(即枚举每个置换环长度),对圆排列计算贡献即可。

这样类似的题目还有 [SHOI2006] 有色图,一般要解决的问题和分拆数关系不大,整数分拆只是被用来估计爆搜的复杂度了。

Div 2 EDU 181 / CF2125 E Sets of Complementary Sums

对于一个元素可重复的正整数数组 \(a\),设其元素之和为 \(s\),为其产生一个不可重集 \(G=\{s - a_i\}\)

你可以构造无穷多个数组,求能生成多少不同的 \(G\),满足 \(|G| = n\),且 \(G\) 中元素的值在 \([1,x]\) 的范围内。

多测,\(T\le 10^4, 1\le n,x \le 10^5\)link

这类题目的难点一般在于转化成分拆数(或类似的求解方式),给读者留作习题吧!

参考资料

EI 大大的 分拆数的第三种计算方法

posted @ 2026-08-16 20:21  Aurora5090  阅读(26)  评论(0)    收藏  举报