外观
YAACA.md---
title: YA! 烷基计数方法
createTime: 2025/4/30
categories:
- IT
tags:
- OI
- maths
- chemistry
---
YA 是 Yet Another 口牙!
:::info **写作风格提示**
1. 我希望本文是一篇生动的博客。所以不要被它的长度吓到。本文真的很有趣。
2. 我之所以有时自称“我们”,不是因为想显得专业,也不是因为我是西方的君主,更不是因为我因为泰坦神迹变成了一千个分身,而是因为:我想和读者作为一个共同体,一起把这个问题从零解决掉。
:::
## 问题
$\mathrm{-C_n H_{2n+1}}$ ($n$ 烷基)有多少种不同的结构?
## 状态转移方程
### 前置知识
在 $n$ 个元素中选取可以重复的 $m$ 个组成可重集,方案数为 $\displaystyle\binom{n+m-1}{m}$
## 转移方程
递归讨论半键上的碳原子所连的三个烷基,可以得到递归方程:
$$
f(n)=\sum_{\substack{0\le i<j<k \le n-1 \\ i+j+k=n-1}} f(i)f(j)f(k)
+ \sum_{\substack{0\le i,j,k \le n-1 \\ i=j\ne k \\ i+j+k=n-1}} \binom{f(i)+1}{2} f(k)
+ \sum_{\substack{0\le i=j=k \le n-1 \\ i+j+k=n-1}} \binom{f(i)+2}{3}
$$
<垃圾话> 化学课一结束,Q 就跟 F 和我分享了他的公式(原始公式不是这样的,这是更简洁优雅的一种表示)。自习课(老师允许我们去打球),我们一拍即合,去小办公室借了电脑,开始写程序验证。验证通过!很显然,根据上面的方程,这个算法是 $O(n^3)$ 的,但我总觉得还有更快的算法。 </垃圾话>
## 从 O(n^3) 到 O(n^2)
### 换上下限
~~前面的式子优雅吗?我要开始爆算了。~~
其实是先繁后简的思想啦,爆算完更优雅哦!
:::tip 注意
- 为了可读性,在之后的所有求和中,我们都默认 $0\le i,j,k \le n-1, i+j+k=n-1$.
- 你可能会看到只有一项的求和。那是为了式子的统一。
- 为了可读性,求和次序明确时,不加括号。
:::
由对称性以及容斥原理,可得
$$
\begin{aligned}
\sum_{i<j<k} f(i)f(j)f(k)
&= \frac{1}{6} \sum_{|\{i,j,k\}|=3} f(i)f(j)f(k)
\\
&= \frac{1}{6} \left( \sum_{i,j,k}-3\sum_{i=j}+2\sum_{i=j=k} \right) f(i)f(j)f(k)
\\
&= \frac{1}{6} \sum_{i,j,k} f(i)f(j)f(k)
- \frac{1}{2} \sum_{2i+k=n-1} f(i)^2f(k)
+ \frac{1}{3} \sum_{3i=n-1} f(i)^3
\end{aligned}
$$
又因为
$$
\begin{aligned}
\sum_{i=j\ne k} \binom{f(i)+1}{2} f(k)
&= \left( \sum_{i=j}-\sum_{i=j=k} \right) \binom{f(i)+1}{2} f(k)
\\
&= \sum_{2i+k=n-1} \binom{f(i)+1}{2} f(k)
- \sum_{3i=n-1} \binom{f(i)+1}{2} f(i)
\end{aligned}
$$
我们就可以把原式化为
$$
\begin{aligned}
f(n) &= \frac{1}{6} \sum_{i,j,k} f(i)f(j)f(k)
+ \sum_{2i+k=n-1} \left(
\binom{f(i)+1}{2} f(k)
- \frac{1}{2} f(i)^2f(k)
\right)
+ \sum_{3i=n-1} \left(
\frac{1}{3} f(i)^3
+ \binom{f(i)+2}{3}
- \binom{f(i)+1}{2} f(i)
\right)
\\
&= \frac{1}{6} \sum_{i,j,k} f(i)f(j)f(k)
+ \frac{1}{2} \sum_{2i+k=n-1} f(i)f(k)
+ \frac{1}{3} \sum_{3i=n-1} f(i)
\end{aligned}
$$
十分可爱!——Q 如此评价道。
以上也可以用 Polya 定理推导。
### 分离变量
有了以上的式子,我们就可以分离变量,分散计算了。
$$
\begin{aligned}
f(x) &=
\frac{1}{6} \sum_{i,j,k} f(i)f(j)f(k)
+ \frac{1}{2} \sum_{2i+k=n-1} f(i)f(k)
+ \frac{1}{3} \sum_{3i=n-1} f(i)
\\
&= \sum_{0\le k\le n-1} f(k) \left(
\frac{1}{6} \left( \sum_{0\le i\le n-1-k}{f(i)f(n-1-k-i)} \right)
+ \frac{1}{2} f\left(\frac{n-1-k}{2}\right)
\right)
+ \frac{1}{3} f\left( \frac{n-1}{3} \right)
\\
& \qquad \text{注:} \forall x\notin\mathbb{N}, f(x) \mathop{=}\limits^\text{def} 0
\\
\end{aligned}
$$
令 $g(n) = \displaystyle \frac{1}{6} \left( \sum_{0\le i\le n}{f(i)f(n-i)} \right) + \frac{1}{2} f\left(\frac{n}{2}\right)$,则
$$
f(n) =
\sum_{0\le k\le n-1} f(k)g(n-1-k)
+ \frac{1}{3} f\left( \frac{n-1}{3} \right)
$$
我们容易发现,有两个类似卷积形式的求和,以上公式也可以写成
$$
\begin{aligned}
f(n) &= (f*g)(n-1) + \frac{1}{3} f\left(\frac{n-1}{3}\right)
\\
g(n) &= \frac{1}{6}(f*f)(n) + \frac{1}{2} f
\left(\frac{n}{2}\right)
\end{aligned}
$$
:::tip 注意
此处只是取卷积中的一项,如果使用完整的卷积,虽然有 FFT,但是会造成冗余。
:::
<垃圾话> 看到这个公式,F 就忍不住让我用 FFT 了。我告诉他只是取其中一项,然而他还是坚持自己的直觉。</垃圾话>
或者,我们将 $f, g$ 看成数据结构,则有转移方程
$$
\begin{aligned}
& f.\mathrm{append}\left( f \otimes g + \frac{1}{3} f\left(\frac{n-1}{3}\right) \right)
\\
& g.\mathrm{append}\left( \frac{1}{6} f \otimes f + \frac{1}{2} f
\left(\frac{n}{2}\right) \right)
\end{aligned}
$$
其中 $f \otimes g = f \cdot \operatorname{rev}(g)$.
如果你不想要小数,令 $g'(n) = 6g(n)$ 即可。
$$
\begin{aligned}
& f.\mathrm{append}\left( \frac{1}{6} \left( f \otimes g + 2f\left(\frac{n-1}{3}\right)\right) \right)
\\
& g.\mathrm{append}\left( f \otimes f + 3 f
\left(\frac{n}{2}\right) \right)
\end{aligned}
$$
以上的公式形式简洁、计算方便,是手工计算的不二之选。
```python
def rev_prod(l1, l2):
return sum(a*b for a, b in zip(l1, l2[::-1]))
f = [1]
g = [4]
for i in range(1, 10):
f.append((rev_prod(f, g) + (f[(i-1)//3]*2 if i%3==1 else 0)) // 6)
g.append(rev_prod(f, f) + (f[i//2]*3 if i%2==0 else 0))
print(f'-C{i}H{2*i+1} 共有 {f[i]} 种同分异构体')
```
## 从 O(n^2) 到 O(n sqrt(nlogn))
通过上面的化简,我们就可以将问题抽象化,只需要找到一个(一对)支持 $\text{append}$ 和 $\otimes$ 操作的数据结构即可。
那么,我们能否寻找一个更快的数据结构,支持尾插以及 $\otimes$ 运算呢?
DeepSeek 说不行,但是在这件事情上,DeepSeek 甚至没有占卜来得靠谱。当然,我没有占卜过,但是当我看到这个数据结构的时候,冥冥之中总感觉它可以优化到 $O(n\sqrt{n})$.
所以我偏要去优化。
~~其实这人是受化学老师刺激了。~~
我们的插入是 $O(1)$ 而运算是 $O(n)$ 的。根据此消彼长~~我瞎编的~~原理,我们要给插入操作制造冗余信息,才能用这些信息来优化运算。
是的。我为什么在前面讲了 FFT 呢?就是埋了个伏笔啊!
我们看看 FFT 是如何加速运算的。
:::tip 注意
之后的推导都是对数据结构抽象的推导,与实际问题不直接相关。
:::
首先我们来解个莫名其妙的不等式:$0 \le i,n-i \le p ~(0 \le p \le n)$,
答案很简单,就是 $n-p \le i \le p$.
接下来我们要干什么呢?让我们把求和分块:
$$
\sum_{i=0}^{n} a(i)b(n-i) = \sum_{i=n-p}^{p} a(i)b(n-i) + \sum_\text{otherwise} a(i)b(n-i)
$$
关注第一个和式,有
$$
\displaystyle\sum_{i=n-p}^{p} a(i)b(n-i)
= (a_{0..p} * b_{0..p})(n)
$$
:::info 注
根据之前的解不等式,由于 $n-i \le p$ 的限制,$i$ 的下限取不到 $0$.
:::
显然,可以用 FFT,一次性 $O(p\log p)$ 地把 $(a_{0..p} * b_{0..p})$ 算出来。
剩下的求和,时间复杂度为 $O(n-p)$.
其实就是类似分块打表的思想。
全部加起来,总时间复杂度为
$$
T(N) =
\displaystyle \left(\sum_{n=1}^{N}n-\max_{\substack{p\in P \\ p\le n}} p \right)
+ \left( \sum_{p\in P} p \log p \right)
$$
~~P 是 Preprocessing 的意思,不是 prime 啊!用质数数列当 p 肯定是不行的啦!~~
所以,我们怎么选择 $p$ 点,使得复杂度最小呢?
### 一个块长调一天
首先我们用特殊值探个路。
我们设 $p_i = ki$,也就是等距取 $p$.
于是我们有
$$
\begin{aligned}
T(N)
&= O\left( Nk + \sum_{i=1}^{N/k} ki\log i \right)
\\
&= O\left(Nk + \frac{N^2}{k} \log N \right)
\\
&\ge O\left(N \sqrt{N \log N} \right)
\end{aligned}
$$
当 $k=\sqrt{N \log N}$ 时取等。
当然,由于常数的关系,$\sqrt{N \log N}$ 未必是最佳的块长。不过这不影响它的复杂度。
### 块长最优性证明
设 $p_i$ 在 $x$ 处的密度为 $\rho(x) \in [0,1]$,用相近的方法对以上的求和做积分近似:
$$
\begin{aligned}
T(N)
&= O\left( \int_1^N \rho(x)x \log x + (1-\rho(x)) \frac{1}{\rho(x)} ~\mathrm{d}x \right)
\\
&= O\left( -N + \int_1^N \rho(x)x \log x + \frac{1}{\rho(x)} ~\mathrm{d}x \right)
\\
&= O\left( -N + \int_1^N \rho(x)x \log x + \frac{1}{\rho(x)} ~\mathrm{d}x \right)
\\
&\ge O\left( -N + \int_1^N \sqrt{x \log x} ~\mathrm{d}x \right)
\\
&= O(N \sqrt{N \log N})
\end{aligned}
$$
取等条件是 $\rho(x)=\dfrac{1}{\sqrt{x\log x}}$,这喻示着 $p_{i+1} = p_i + C\sqrt{p_i\log_2{p_i}}$.
但是简单起见,我们仍然使用 $p_i = ki$,它的复杂度也是一样的。
<垃圾话>算到这里,我竟激动得无法入眠。不知怎地,天空忽然下起了大雨。我想,那是因为我心中的乌云已经~~被可莉炸掉了~~被彻底扫清了罢。</垃圾话>
### 核心真伪难辨代码片段
```python
def rev_prod(l1, l2):
return sum(a*b for a, b in zip(l1, l2[::-1]))
class DataStructure:
def __init__(self, x, y, k):
self.a = [x]
self.b = [y]
self.k = k
self.max_idx = 0
self.p = -1
self.conv = []
def add(self, x, y):
self.a.append(x)
self.b.append(y)
self.max_idx += 1
if self.max_idx % self.k == 0:
self.p = self.max_idx
self.conv = conv(self.a, self.b)
def calc(self):
if 2*self.p < self.max_idx:
return rev_prod(self.a, self.b)
else:
ans = self.conv[self.max_idx]
ans += sum(self.a[i]*self.b[self.max_idx-i]
for i in range(self.max_idx-self.p))
ans += sum(self.a[i]*self.b[self.max_idx-i]
for i in range(self.p+1, self.max_idx+1))
return ans
```
## 关于时间复杂度与优绩主义的桎梏
有人要问了:
> 对于烷烃计数,已经有 $O(N \log N)$ 的算法了,为什么还要设计一个更慢的算法呢?
是的,这样这样的确算不上什么突破。但是探索的乐趣却是实打实的。
语文期中考,作文主题是“无用之用”。这两件事,或许有异曲同工之妙吧。
## P-1 烷基计数
### 题目背景
搓了个 $O(N\sqrt{N\log N})$ 的算法。
### 题目描述
求有 $n$ 个碳原子个数的烷基有多少种同分异构体。
### 输入格式
本题多测。
第一行一个整数 $T$,接下来 $T$ 行,每行一个整数 $n_i$.
### 输出格式
输出 $T$ 行,第 $i$ 行输出 $n_i$ 个碳原子个数的烷基的同分异构体数目,对 $998244353$ 取模。
### 数据范围
$1 \le T, n_i \le 5\times10^4$.
## 番外:卡常小能手
$f \otimes f$ 是对称的,计算量可以砍一半。
然后我们再用 $p_{i+1} = p_i + \mu p_i\log_2{p_i}$ 优化块长。接下来就是决定最优的 $\mu$ 值,三分即可。
取整数的 $\mu = 3$,此时和生成函数+牛顿迭代只差一个数量级,机子好一点可以卡过。
## 附录:题目的 C++ 实现
```cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll mod = 998244353,
one_sixth = (mod+1) / 6, // 998244354 % 6 == 0
maxn = 10e4+100;
template<typename _T>
void fread( _T &x ) {
x = 0; char s = getchar(); bool f = false;
while( s < '0' || '9' < s ) { f = s == '-', s = getchar(); }
while( '0' <= s && s <= '9' ) { x = ( x << 3 ) + ( x << 1 ) + ( s - '0' ), s = getchar(); }
if( f ) x = -x;
}
template<typename _T>
void fwrite( _T x ) {
if( x < 0 ) putchar( '-' ), x = -x;
if( 9 < x ) fwrite( x / 10 );
putchar( x % 10 + '0' );
}
template<ll Mod, ll G> // Mod必须为满足NTT条件的质数,G为对应的原根
struct NTT {
// 快速幂(内部使用,无需暴露)
static ll _pow(ll a, ll b) {
ll res = 1;
while (b) {
if (b & 1) res = res * a % Mod;
a = a * a % Mod;
b >>= 1;
}
return res;
}
// 位逆序预处理
static vector<int> _get_rev(int len, int bit) {
vector<int> rev(len);
for (int i = 0; i < len; ++i)
rev[i] = (rev[i >> 1] >> 1) | ((i & 1) << (bit - 1));
return rev;
}
// 核心变换函数(invert=false为正变换,invert=true为逆变换)
static void _transform(vector<ll>& a, const vector<int>& rev, bool invert) {
int n = a.size();
for (int i = 0; i < n; ++i)
if (i < rev[i]) swap(a[i], a[rev[i]]);
for (int len = 2; len <= n; len <<= 1) {
ll wn = _pow(G, (Mod - 1) / len);
if (invert) wn = _pow(wn, Mod - 2);
for (int i = 0; i < n; i += len) {
ll w = 1;
for (int j = 0; j < len/2; ++j) {
ll u = a[i + j], v = a[i + j + len/2] * w % Mod;
a[i + j] = (u + v) % Mod;
a[i + j + len/2] = (u - v + Mod) % Mod;
w = w * wn % Mod;
}
}
}
if (invert) {
ll inv_n = _pow(n, Mod - 2);
for (ll& x : a) x = x * inv_n % Mod;
}
}
// 卷积入口函数
static vector<ll> convolve(vector<ll> a, vector<ll> b) {
int n = a.size(), m = b.size();
if (n == 0 || m == 0) return {};
int len = 1, bit = 0;
while (len < n + m - 1) len <<= 1, ++bit;
a.resize(len); b.resize(len);
auto rev = _get_rev(len, bit);
_transform(a, rev, false);
_transform(b, rev, false);
for (int i = 0; i < len; ++i)
a[i] = a[i] * b[i] % Mod;
_transform(a, rev, true);
a.resize(n + m - 1);
return a;
}
static vector<ll> self_convolve(vector<ll> a) {
int n = a.size();
if (n == 0) return {};
int len = 1, bit = 0;
while (len < 2*n - 1) len <<= 1, ++bit;
vector<int> rev = _get_rev(len, bit);
a.resize(len);
// 只需一次正变换
_transform(a, rev, false);
// 点乘平方
for (int i = 0; i < len; ++i)
a[i] = a[i] * a[i] % Mod;
_transform(a, rev, true);
a.resize(2*n - 1);
return a;
}
};
NTT<mod, 3> ntt;
bool isp[maxn];
struct DS {
vector<ll> a, b;
ll maxidx;
ll p=-1;
vector<ll> conv;
bool is_symmetrical;
DS (ll x, ll y, bool is_symmetrical_=false):
maxidx(0),
p(-1),
is_symmetrical(is_symmetrical_) {
a.push_back(x);
b.push_back(y);
}
void add(ll x, ll y){
maxidx++;
a.push_back(x);
b.push_back(y);
if (isp[maxidx]){
p = maxidx;
conv = is_symmetrical? ntt.self_convolve(a): ntt.convolve(a, b);
}
}
ll calc(){
if ((p<<1) < maxidx){
ll ans = 0;
for (int i=0; i<=maxidx; i++){
ans += a[i] * b[maxidx-i] % mod;
ans %= mod;
}
return ans;
}
ll ans = conv[maxidx], add=0;
for (int i=0; i<maxidx-p; i++){
add += a[i] * b[maxidx-i] % mod;
add %= mod;
}
if (is_symmetrical) {
add <<= 1;
add %= mod;
} else {
for (int i=p+1; i<=maxidx; i++){
add += a[i] * b[maxidx-i] % mod;
add %= mod;
}
}
return (ans + add) % mod;
}
};
const ll mu = 3;
ll n[maxn];
ll f[maxn], g[maxn];
inline void solve(ll N){
for (int p=4; p<=N; p+=mu*sqrt(p*log2(p))){
isp[p] = true;
}
f[0] = 1;
g[0] = 4;
DS ffds(1, 1, true),
fgds(1, 4);
for (int i=1; i<=N; i++){
f[i] = (fgds.calc() +
(i%3==1? (f[(i-1)/3]<<1): 0))
% mod * one_sixth % mod;
ffds.add(f[i], f[i]);
g[i] = (ffds.calc() +
((i&1)? 0: (f[i>>1]*3))) % mod;
fgds.add(f[i], g[i]);
}
}
int main(){
ll T, N=0;
fread(T);
for (int i=1; i<=T; i++){
fread(n[i]);
N = max(N, n[i]);
}
solve(N);
for (int i=1; i<=T; i++){
fwrite(f[n[i]]);
putchar('\n');
}
return 0;
}
```
!!有没有人发现,Yet Another Alkyls Counting Algorithm 的首字母缩写是 YAACA,然后让我们把 AA 看成 M……一阵强劲的音乐响起!!