FXJ Wiki

Back

生成函数Blur image

一个计数问题#

nn 类物品,第 ii 类最多选 aia_i 个。问恰好选 mm 个的方案数。

把第 ii 类的选择方案写成多项式:

1选 0 个+x选 1 个+x2选 2 个++xai\underbrace{1}_{\text{选 0 个}} + \underbrace{x}_{\text{选 1 个}} + \underbrace{x^2}_{\text{选 2 个}} + \cdots + x^{a_i}

指数记录”选了几个”,系数记录”方案数”(这里每种选择恰好对应 1 种方案)。

把所有类乘起来:

i=1n(1+x+x2++xai)\prod_{i=1}^n \left(1 + x + x^2 + \cdots + x^{a_i}\right)

展开后,xmx^m 的系数就是答案——因为每次从每个括号里挑一项,指数加起来恰好等于 mm,系数乘起来恰好是 1(每种选法贡献 1)。

这个把计数问题装进多项式系数里的写法,就是生成函数。

普通生成函数(OGF)#

序列 a0,a1,a2,a_0, a_1, a_2, \dots 的普通生成函数(Ordinary Generating Function)定义为:

F(x)=n0anxnF(x) = \sum_{n \ge 0} a_n x^n

基本运算#

加法:

F(x)±G(x)=n0(an±bn)xnF(x) \pm G(x) = \sum_{n \ge 0} (a_n \pm b_n) x^n

乘法(卷积):

F(x)G(x)=n0(i=0naibni)xnF(x)G(x) = \sum_{n \ge 0} \left( \sum_{i=0}^n a_i b_{n-i} \right) x^n

OGF 的乘法天然对应序列卷积。这是它和 DP 关系密切的根本原因:很多”前若干类合并出和为 nn“的 DP 转移,本质就是在把多项式一个一个乘上去。

常见封闭形式#

可能反复出现的几个式子:

  • 每类可以选任意多个:1+x+x2+=11x1 + x + x^2 + \cdots = \dfrac{1}{1-x}
  • 每类最多选 aa 个:1+x++xa=1xa+11x1 + x + \cdots + x^a = \dfrac{1-x^{a+1}}{1-x}
  • 重量为 ww,可以选任意多个:1+xw+x2w+=11xw1 + x^w + x^{2w} + \cdots = \dfrac{1}{1-x^w}
  • 重量为 ww,最多选 aa 个:1+xw++xaw=1x(a+1)w1xw1 + x^w + \cdots + x^{aw} = \dfrac{1-x^{(a+1)w}}{1-x^w}
  • 二项式系数:(1+x)m=n0(mn)xn(1+x)^m = \sum_{n \ge 0} \binom{m}{n} x^n
  • 多重集组合数:1(1x)m+1=n0(m+nn)xn\dfrac{1}{(1-x)^{m+1}} = \sum_{n \ge 0} \binom{m+n}{n} x^n

公式并不重要,需要想清楚的是:指数记录什么,系数记录什么,每一类物品的选择空间该写成哪个括号。

例 1:HDU 2152 — Fruit#

nn 类物品,第 ii 类必须选 [li,ri][l_i, r_i] 个,问总共选 mm 个的方案数。

ii 类贡献的多项式是:

xli+xli+1++xrix^{l_i} + x^{l_i+1} + \cdots + x^{r_i}

全体方案:

i=1n(xli+xli+1++xri)\prod_{i=1}^n \left( x^{l_i} + x^{l_i+1} + \cdots + x^{r_i} \right)

xmx^m 的系数即可。实现上直接用 DP 做多项式乘法:

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    while (cin >> n >> m) {
        vector<int> l(n + 1), r(n + 1);
        for (int i = 1; i <= n; ++i) {
            cin >> l[i] >> r[i];
        }

        vector<int> dp(m + 1), ndp(m + 1);
        dp[0] = 1;
        for (int i = 1; i <= n; ++i) {
            fill(ndp.begin(), ndp.end(), 0);
            for (int used = 0; used <= m; ++used) {
                if (!dp[used]) continue;
                for (int take = l[i]; take <= r[i] && used + take <= m; ++take) {
                    ndp[used + take] += dp[used];
                }
            }
            dp.swap(ndp);
        }
        cout << dp[m] << '\n';
    }
    return 0;
}
cpp

这题的 DP 和生成函数多项式乘法是完全等价的两套表述。

例 2:HDU 1085 — Holding Bin-Laden Captive!#

有面值 1,2,51,2,5 的硬币,第 ii 种有 aia_i 枚。问最小的无法凑出的金额。

三种硬币对应三项:

(1+x++xa1)(1+x2+x4++x2a2)(1+x5++x5a3)(1+x+\cdots+x^{a_1})(1+x^2+x^4+\cdots+x^{2a_2})(1+x^5+\cdots+x^{5a_3})

展开后,第一个系数为 00 的位置就是答案。这里指数记录的是”金额”而非”个数”,是生成函数建模灵活性的好例子。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int a1, a2, a3;
    while (cin >> a1 >> a2 >> a3 && (a1 || a2 || a3)) {
        int maxSum = a1 + 2 * a2 + 5 * a3;
        vector<int> dp(maxSum + 1), ndp(maxSum + 1);
        dp[0] = 1;

        vector<pair<int, int>> items = {{1, a1}, {2, a2}, {5, a3}};
        for (auto [w, cnt] : items) {
            fill(ndp.begin(), ndp.end(), 0);
            for (int s = 0; s <= maxSum; ++s) {
                if (!dp[s]) continue;
                for (int k = 0; k <= cnt && s + k * w <= maxSum; ++k) {
                    ndp[s + k * w] += dp[s];
                }
            }
            dp.swap(ndp);
        }

        int ans = 0;
        while (ans <= maxSum && dp[ans]) ++ans;
        cout << ans << '\n';
    }
    return 0;
}
cpp

上面两题的做法都是”建模成生成函数 + DP 实现乘法”。但生成函数不只是一个建模容器——它还能通过化简封闭形式,直接解出通项。

例 3:斐波那契数列#

斐波那契数列:F0=0,  F1=1,  Fn=Fn1+Fn2  (n2)F_0 = 0,\; F_1 = 1,\; F_n = F_{n-1} + F_{n-2}\;(n \ge 2)

F(x)=n0FnxnF(x) = \sum_{n \ge 0} F_n x^n。利用递推关系:

F(x)=F0+F1x+n2Fnxn=x+n2(Fn1+Fn2)xn=x+xn2Fn1xn1+x2n2Fn2xn2=x+xF(x)+x2F(x)\begin{aligned} F(x) &= F_0 + F_1 x + \sum_{n \ge 2} F_n x^n \\ &= x + \sum_{n \ge 2} (F_{n-1} + F_{n-2}) x^n \\ &= x + x\sum_{n \ge 2} F_{n-1} x^{n-1} + x^2 \sum_{n \ge 2} F_{n-2} x^{n-2} \\ &= x + xF(x) + x^2 F(x) \end{aligned}

解出:

F(x)=x1xx2F(x) = \frac{x}{1 - x - x^2}

对分母做部分分式分解。设 1xx2=(1αx)(1βx)1 - x - x^2 = (1 - \alpha x)(1 - \beta x),其中:

α=1+52,β=152\alpha = \frac{1 + \sqrt{5}}{2},\quad \beta = \frac{1 - \sqrt{5}}{2}

则:

F(x)=15(11αx11βx)F(x) = \frac{1}{\sqrt{5}}\left( \frac{1}{1 - \alpha x} - \frac{1}{1 - \beta x} \right)

利用 11cx=n0cnxn\frac{1}{1 - cx} = \sum_{n \ge 0} c^n x^n 展开:

Fn=15(αnβn)F_n = \frac{1}{\sqrt{5}}\left( \alpha^n - \beta^n \right)

这就是斐波那契数列的通项公式。整个过程大概是:递推式 → 生成函数方程 → 封闭形式 → 展开 → 通项。这是生成函数很强大的用法:通过代数操作直接求解。

例 4:卡特兰数#

卡特兰数的递推:

C0=1,Cn=i=0n1CiCni1  (n1)C_0 = 1,\quad C_n = \sum_{i=0}^{n-1} C_i C_{n-i-1}\;(n \ge 1)

等号右边是自己卷自己。设 C(x)=n0CnxnC(x) = \sum_{n \ge 0} C_n x^n

C(x)=1+n1(i=0n1CiCni1)xn=1+xn1i=0n1CiCni1xn1=1+xC(x)2\begin{aligned} C(x) &= 1 + \sum_{n \ge 1} \left( \sum_{i=0}^{n-1} C_i C_{n-i-1} \right) x^n \\ &= 1 + x \sum_{n \ge 1} \sum_{i=0}^{n-1} C_i C_{n-i-1} x^{n-1} \\ &= 1 + x \cdot C(x)^2 \end{aligned}

这给出了关于 C(x)C(x) 的二次方程:

xC(x)2C(x)+1=0xC(x)^2 - C(x) + 1 = 0

解得(取满足 C(0)=C0=1C(0) = C_0 = 1 的根):

C(x)=114x2xC(x) = \frac{1 - \sqrt{1 - 4x}}{2x}

用牛顿二项式定理展开 (14x)1/2(1 - 4x)^{1/2}

(14x)1/2=n0(12n)(4x)n=12x2x24x3\begin{aligned} (1 - 4x)^{1/2} &= \sum_{n \ge 0} \binom{\frac{1}{2}}{n} (-4x)^n \\ &= 1 - 2x - 2x^2 - 4x^3 - \cdots \end{aligned}

代入整理得:

C(x)=n01n+1(2nn)xnC(x) = \sum_{n \ge 0} \frac{1}{n+1} \binom{2n}{n} x^n

所以:

Cn=1n+1(2nn)C_n = \frac{1}{n+1} \binom{2n}{n}

这个推导展示了生成函数处理”自卷”结构的能力——递推式里 C(x)C(x) 和自己卷,就转化成了关于 C(x)C(x) 的代数方程。

简化版的系数提取14x\sqrt{1-4x},直接利用广义二项式系数:

也可以不展开

(1/2n)=(1)n1n22n1(2n2n1)\binom{1/2}{n} = \frac{(-1)^{n-1}}{n \cdot 2^{2n-1}} \binom{2n-2}{n-1}

代入整理同样得到 Cn=1n+1(2nn)\displaystyle C_n = \frac{1}{n+1}\binom{2n}{n}

指数生成函数(EGF)#

序列 a0,a1,a2,a_0, a_1, a_2, \dots 的指数生成函数(Exponential Generating Function)定义为:

F(x)=n0anxnn!F(x) = \sum_{n \ge 0} a_n \frac{x^n}{n!}

为什么除以 n!n!#

OGF 的卷积是 i=0naibni\sum_{i=0}^n a_i b_{n-i},而 EGF 乘法给出的是:

F(x)G(x)=(i0aixii!)(j0bjxjj!)=n0xnn!i=0n(ni)aibni\begin{aligned} F(x)G(x) &= \left( \sum_{i \ge 0} a_i \frac{x^i}{i!} \right) \left( \sum_{j \ge 0} b_j \frac{x^j}{j!} \right) \\ &= \sum_{n \ge 0} \frac{x^n}{n!} \sum_{i=0}^n \binom{n}{i} a_i b_{n-i} \end{aligned}

卷积里多出了一个 (ni)\binom{n}{i}。这个组合数恰好对应”从 nn 个带标号的位置中,分 ii 个给第一类、其余 nin-i 个给第二类”。

所以 EGF 和 OGF 的分工很明确:

  • OGF:只关心”选了几个”,顺序无关,计数用组合数;
  • EGF:关心”安排在哪些位置”,顺序重要,计数用排列数。

两个常用 EGF:

  • 1,1,1,\langle 1, 1, 1, \dots \rangle 的 EGF:ex=n0xnn!e^x = \sum_{n \ge 0} \frac{x^n}{n!}
  • 1,p,p2,\langle 1, p, p^2, \dots \rangle 的 EGF:epx=n0pnxnn!e^{px} = \sum_{n \ge 0} p^n \frac{x^n}{n!}

排列数的 EGF#

nn 类物品,第 ii 类选了 bib_i 个,总个数 m=bim = \sum b_i。如果顺序重要,排列数是:

m!b1!b2!bn!\frac{m!}{b_1! b_2! \cdots b_n!}

把第 ii 类写成 EGF 的截断:

1+x1!+x22!++xaiai!1 + \frac{x}{1!} + \frac{x^2}{2!} + \cdots + \frac{x^{a_i}}{a_i!}

乘起来后,xmx^m 项是:

xmm!b1++bn=m,  biaim!b1!b2!bn!\frac{x^m}{m!} \sum_{b_1+\cdots+b_n=m,\; b_i \le a_i} \frac{m!}{b_1! b_2! \cdots b_n!}

m!b1!bn!\frac{m!}{b_1!\cdots b_n!} 恰好就是这种选法的排列数。所以 xm/m!x^m/m! 的系数就是总排列数;习惯上直接读系数后再乘回 m!m!

例 5:HDU 1521 — 排列数模型#

nn 类物品,第 ii 类最多选 aia_i 个,问恰好选 mm 个的排列方案数。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    vector<double> fac(11, 1.0);
    for (int i = 1; i < (int)fac.size(); ++i) fac[i] = fac[i - 1] * i;

    while (cin >> n >> m) {
        vector<int> a(n + 1);
        for (int i = 1; i <= n; ++i) cin >> a[i];

        vector<double> dp(m + 1), ndp(m + 1);
        dp[0] = 1.0;
        for (int i = 1; i <= n; ++i) {
            fill(ndp.begin(), ndp.end(), 0.0);
            for (int used = 0; used <= m; ++used) {
                if (dp[used] == 0.0) continue;
                for (int take = 0; take <= a[i] && used + take <= m; ++take) {
                    ndp[used + take] += dp[used] / fac[take];
                }
            }
            dp.swap(ndp);
        }

        cout << (long long)llround(dp[m] * fac[m]) << '\n';
    }
    return 0;
}
cpp

代码里 dp[used] / fac[take] 就是在做 EGF 的卷积——每一项按 xtake/(take)!x^{take} / (take)! 的系数贡献,最后乘回 m!m! 得到真正的排列数。

例 6:错排问题#

nn 个元素的排列,每个元素都不在原来位置,方案数记为 DnD_n

递推式:D0=1,  Dn=(n1)(Dn1+Dn2)  (n2)D_0 = 1,\; D_n = (n-1)(D_{n-1} + D_{n-2})\;(n \ge 2)

设 EGF D(x)=n0Dnxnn!D(x) = \sum_{n \ge 0} D_n \frac{x^n}{n!}。从递推式出发((n1)(n-1) 在 EGF 下对应求导与移位的组合),可导出封闭形式:

D(x)=ex1xD(x) = \frac{e^{-x}}{1-x}

展开 ex=k0(1)kk!xke^{-x} = \sum_{k \ge 0} \frac{(-1)^k}{k!} x^k11x=j0xj\frac{1}{1-x} = \sum_{j \ge 0} x^j,做卷积:

Dn=n!k=0n(1)kk!D_n = n! \sum_{k=0}^n \frac{(-1)^k}{k!}

取整后就是 Dn=n!e+12D_n = \left\lfloor \frac{n!}{e} + \frac{1}{2} \right\rfloor。一旦得到 D(x)=ex/(1x)D(x) = e^{-x}/(1-x),通项就只是一次卷积展开——这就是 EGF 的典型用法。

生成函数、DP 与多项式#

同一类计数问题,三种视角可以互相翻译。以「nn 种物品,第 ii 种有 aia_i 个,重量为 wiw_i,求装满容量 mm 的方案数」为例:

生成函数视角

i=1n(1+xwi+x2wi++xaiwi)\prod_{i=1}^n \left( 1 + x^{w_i} + x^{2w_i} + \cdots + x^{a_i w_i} \right)

答案:xmx^m 的系数。这是建模层——把问题翻译成代数对象。

DP 视角

dp[j] 表示当前凑出容量 jj 的方案数。加入第 ii 种物品时:

for (int j = m; j >= 0; --j)
    for (int k = 1; k <= a[i] && j + k * w[i] <= m; ++k)
        dp[j + k * w[i]] += dp[j];
cpp

这是实现层——直接模拟多项式乘法,复杂度 O(nmavg(ai))O(nm \cdot \text{avg}(a_i))

多项式视角

mm 很大(比如 10510^5),朴素 DP 不够时,用 NTT 把多项式乘法的 O(n2)O(n^2) 降到 O(nlogn)O(n \log n)。配合分治 / 启发式合并,可以把若干个小多项式的连乘加速。

这是高效运算层——不改变建模,只改变”怎么让计算机算得更快”。

三者的分工线:

  • 生成函数把问题装进系数,告诉你”指数是什么、系数是什么、括号该写成什么样”;
  • DP 在规模较小时直接模拟多项式乘法,是最简单的落地方式;
  • 多项式算法(FFT/NTT/FPS)在规模大时把卷积从 O(n2)O(n^2) 加速到 O(nlogn)O(n \log n)

所以很多生成函数题的解题路径是:先建出生成函数 → 用小规模 DP 验证建模正确 → 如果数据大,再上 NTT。

参考#

生成函数
https://fxj.wiki/blog/algorithm-generating-functions
Author 玛卡巴卡
Published at 2024年9月13日
Comment seems to stuck. Try to refresh?✨