收录内容#
原笔记里的母函数部分普通生成函数 / 指数生成函数的区别HDU 2152 / HDU 1085 / HDU 1521的建模整理和 DP、卷积、多项式的关系
先把最核心的话记住#
生成函数最有用的地方,不是记一堆展开式,而是:
- 指数负责记录“选了多少个东西”;
- 系数负责记录“方案数 / 权值 / 排列数”;
- 乘法负责把“几个独立选择”合并起来。
所以我更愿意把它理解成一种把计数问题装进系数里的写法。
如果题目里出现了下面这些信号,通常就该往生成函数上想:
- 每一类物品可以选若干个;
- 最后只关心“总共选了多少个 / 总和是多少”;
- 多个独立选择合并时,本质是卷积;
- 顺序是否重要,会直接决定该用普通生成函数还是指数生成函数。
普通生成函数#
原笔记里的叫法是“母函数”,我这里按竞赛里更常见的写法记成 OGF。
形式:
其中 记录“规模是 ”,系数 记录“规模为 时的方案数 / 权值”。
原笔记里的三个基本性质#
- 加减:
- 乘法:
- 所以,乘法对应的就是卷积。
这也是为什么生成函数经常和 DP 放在一起:很多“前 类物品凑出和为 ”的转移,本质上就是把前面的多项式再乘上一个“这一类物品能提供的选择式”。
原笔记里的理解:指数是个数,系数是组合数#
这个表述我觉得非常好记:
- 指数是“总共选了多少个 / 总权值是多少”;
- 系数是达到这个指数的方案数。
因此,多重集组合数问题特别适合直接写 OGF。
题 1:HDU 2152 - Fruit#
题意可以压成一句:
- 有 类物品;
- 第 类必须选 个;
- 问总共选 个的方案数。
建模#
设第 类实际选了 个,那么:
于是第 类物品贡献一个多项式:
全体方案对应:
我们要的就是 的系数。
这题为什么和 DP 完全等价#
如果写 DP,通常会设:
dp[j]表示当前处理到若干类物品时,凑出总数 的方案数。
加入第 类,相当于枚举这一类取了多少个,再把系数卷进去。这和把当前多项式乘上 是同一件事。
#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题 2:HDU 1085 - Holding Bin-Laden Captive!#
原笔记的核心建模是:
- 有面值 的硬币;
- 第 种硬币有 枚;
- 问最小的、无法凑出来的面值。
建模#
三种硬币对应三项:
展开后:
- 的系数非零,表示金额 可达;
- 第一个系数为零的位置,就是答案。
这个题很适合拿来体会“指数不是个数,而是目标量”。在上一题里指数记录的是“选了几个物品”,这里记录的是“凑出的金额”。
#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指数生成函数#
形式:
原笔记里写得很到位:
- 的 EGF 是 ;
- 的 EGF 是 。
为什么这里要除以 #
因为 EGF 往往用来处理带标号 / 顺序重要的问题。
在 OGF 里,卷积给出的通常是“把数量加起来”的组合数; 在 EGF 里,乘法会自然带出组合数系数:
这里的 就是“从 个位置里分出 个给第一部分、其余给第二部分”。
所以它非常适合处理:
- 顺序重要;
- 元素有标号;
- 排列数天然会冒出来。
题 3:HDU 1521 - 排列数模型#
原笔记的表述是:
- 有 类物品;
- 第 类最多选 个;
- 问恰好选 个时,排列方案数。
如果第 类选了 个,那么总排列数是:
这正是 EGF 的经典味道:把每一类写成
乘起来后取 前的系数,再乘回 即可。
#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普通生成函数和指数生成函数怎么区分#
这是我现在最常用的判断口径:
- 不看顺序 / 看总数 / 看组合数:先往 OGF 想。
- 看顺序 / 看排列数 / 带标号:先往 EGF 想。
再说细一点:
| 问题味道 | 更常见的选择 |
|---|---|
| 每类物品选几个,总共凑出多少 | 普通生成函数 |
| 只关心“和 / 个数”,顺序无关 | 普通生成函数 |
| 安排到若干位置里,位置区分开来 | 指数生成函数 |
| 结果里天然出现 | 指数生成函数 |
它和 DP、多项式到底是什么关系#
这块最好不要拆开看。
和 DP 的关系#
很多生成函数题,落地写法就是 DP:
dp[j]维护当前多项式的第 项系数;- 加入一类物品,就是把一个括号乘上去;
- 所以转移本质就是卷积。
和多项式的关系#
生成函数更偏向建模:
- 指数是什么;
- 系数是什么;
- 问题应该写成哪些括号。
多项式更偏向运算:
- 这堆系数怎么乘得更快;
- 如何从 卷积变成 ;
- 如何做求逆、对数、指数、开根。
所以我自己的理解是:
- 生成函数告诉你“为什么要把答案装进系数里”;
- 多项式告诉你“装进去以后怎么高效地算”。
做题时常见的坑#
我现在会怎么学这一块#
如果是重新走一遍,我会按这个顺序:
- 先把“系数就是答案”这件事吃透;
- 用 OGF 做几道多重集计数;
- 用 EGF 理解排列数为什么自然出来;
- 再去看卷积、多项式,把“建模”和“运算”连起来。