外观
YA! 烷基计数方法
YA 是 Yet Another 口牙!
写作风格提示
- 我希望本文是一篇生动的博客。所以不要被它的长度吓到。本文真的很有趣。
- 我之所以有时自称“我们”,不是因为想显得专业,也不是因为我是西方的君主,更不是因为我因为泰坦神迹变成了一千个分身,而是因为:我想和读者作为一个共同体,一起把这个问题从零解决掉。
问题
−CnH2n+1 (n 烷基)有多少种不同的结构?
状态转移方程
前置知识
在 n 个元素中选取可以重复的 m 个组成可重集,方案数为 (mn+m−1)
转移方程
递归讨论半键上的碳原子所连的三个烷基,可以得到递归方程:
f(n)=0≤i<j<k≤n−1i+j+k=n−1∑f(i)f(j)f(k)+0≤i,j,k≤n−1i=j=ki+j+k=n−1∑(2f(i)+1)f(k)+0≤i=j=k≤n−1i+j+k=n−1∑(3f(i)+2)
<垃圾话> 化学课一结束,Q 就跟 F 和我分享了他的公式(原始公式不是这样的,这是更简洁优雅的一种表示)。自习课(老师允许我们去打球),我们一拍即合,去小办公室借了电脑,开始写程序验证。验证通过!很显然,根据上面的方程,这个算法是 O(n3) 的,但我总觉得还有更快的算法。 </垃圾话>
从 O(n^3) 到 O(n^2)
换上下限
前面的式子优雅吗?我要开始爆算了。
其实是先繁后简的思想啦,爆算完更优雅哦!
注意
- 为了可读性,在之后的所有求和中,我们都默认 0≤i,j,k≤n−1,i+j+k=n−1.
- 你可能会看到只有一项的求和。那是为了式子的统一。
- 为了可读性,求和次序明确时,不加括号。
由对称性以及容斥原理,可得
i<j<k∑f(i)f(j)f(k)=61∣{i,j,k}∣=3∑f(i)f(j)f(k)=61i,j,k∑−3i=j∑+2i=j=k∑f(i)f(j)f(k)=61i,j,k∑f(i)f(j)f(k)−212i+k=n−1∑f(i)2f(k)+313i=n−1∑f(i)3
又因为
i=j=k∑(2f(i)+1)f(k)=i=j∑−i=j=k∑(2f(i)+1)f(k)=2i+k=n−1∑(2f(i)+1)f(k)−3i=n−1∑(2f(i)+1)f(i)
我们就可以把原式化为
f(n)=61i,j,k∑f(i)f(j)f(k)+2i+k=n−1∑((2f(i)+1)f(k)−21f(i)2f(k))+3i=n−1∑(31f(i)3+(3f(i)+2)−(2f(i)+1)f(i))=61i,j,k∑f(i)f(j)f(k)+212i+k=n−1∑f(i)f(k)+313i=n−1∑f(i)
十分可爱!——Q 如此评价道。
以上也可以用 Polya 定理推导。
分离变量
有了以上的式子,我们就可以分离变量,分散计算了。
f(x)=61i,j,k∑f(i)f(j)f(k)+212i+k=n−1∑f(i)f(k)+313i=n−1∑f(i)=0≤k≤n−1∑f(k)(61(0≤i≤n−1−k∑f(i)f(n−1−k−i))+21f(2n−1−k))+31f(3n−1)注:∀x∈/N,f(x)=def0
令 g(n)=61(0≤i≤n∑f(i)f(n−i))+21f(2n),则
f(n)=0≤k≤n−1∑f(k)g(n−1−k)+31f(3n−1)
我们容易发现,有两个类似卷积形式的求和,以上公式也可以写成
f(n)g(n)=(f∗g)(n−1)+31f(3n−1)=61(f∗f)(n)+21f(2n)
注意
此处只是取卷积中的一项,如果使用完整的卷积,虽然有 FFT,但是会造成冗余。
<垃圾话> 看到这个公式,F 就忍不住让我用 FFT 了。我告诉他只是取其中一项,然而他还是坚持自己的直觉。</垃圾话>
或者,我们将 f,g 看成数据结构,则有转移方程
f.append(f⊗g+31f(3n−1))g.append(61f⊗f+21f(2n))
其中 f⊗g=f⋅rev(g).
如果你不想要小数,令 g′(n)=6g(n) 即可。
f.append(61(f⊗g+2f(3n−1)))g.append(f⊗f+3f(2n))
以上的公式形式简洁、计算方便,是手工计算的不二之选。
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))
通过上面的化简,我们就可以将问题抽象化,只需要找到一个(一对)支持 append 和 ⊗ 操作的数据结构即可。
那么,我们能否寻找一个更快的数据结构,支持尾插以及 ⊗ 运算呢?
DeepSeek 说不行,但是在这件事情上,DeepSeek 甚至没有占卜来得靠谱。当然,我没有占卜过,但是当我看到这个数据结构的时候,冥冥之中总感觉它可以优化到 O(nn).
所以我偏要去优化。其实这人是受化学老师刺激了。
我们的插入是 O(1) 而运算是 O(n) 的。根据此消彼长我瞎编的原理,我们要给插入操作制造冗余信息,才能用这些信息来优化运算。
是的。我为什么在前面讲了 FFT 呢?就是埋了个伏笔啊!
我们看看 FFT 是如何加速运算的。
注意
之后的推导都是对数据结构抽象的推导,与实际问题不直接相关。
首先我们来解个莫名其妙的不等式:0≤i,n−i≤p (0≤p≤n),
答案很简单,就是 n−p≤i≤p.
接下来我们要干什么呢?让我们把求和分块:
i=0∑na(i)b(n−i)=i=n−p∑pa(i)b(n−i)+otherwise∑a(i)b(n−i)
关注第一个和式,有
i=n−p∑pa(i)b(n−i)=(a0..p∗b0..p)(n)
注
根据之前的解不等式,由于 n−i≤p 的限制,i 的下限取不到 0.
显然,可以用 FFT,一次性 O(plogp) 地把 (a0..p∗b0..p) 算出来。
剩下的求和,时间复杂度为 O(n−p).
其实就是类似分块打表的思想。
全部加起来,总时间复杂度为
T(N)=n=1∑Nn−p∈Pp≤nmaxp+p∈P∑plogp
P 是 Preprocessing 的意思,不是 prime 啊!用质数数列当 p 肯定是不行的啦!
所以,我们怎么选择 p 点,使得复杂度最小呢?
一个块长调一天
首先我们用特殊值探个路。
我们设 pi=ki,也就是等距取 p.
于是我们有
T(N)=ONk+i=1∑N/kkilogi=O(Nk+kN2logN)≥O(NNlogN)
当 k=NlogN 时取等。
当然,由于常数的关系,NlogN 未必是最佳的块长。不过这不影响它的复杂度。
块长最优性证明
设 pi 在 x 处的密度为 ρ(x)∈[0,1],用相近的方法对以上的求和做积分近似:
T(N)=O(∫1Nρ(x)xlogx+(1−ρ(x))ρ(x)1 dx)=O(−N+∫1Nρ(x)xlogx+ρ(x)1 dx)=O(−N+∫1Nρ(x)xlogx+ρ(x)1 dx)≥O(−N+∫1Nxlogx dx)=O(NNlogN)
取等条件是 ρ(x)=xlogx1,这喻示着 pi+1=pi+Cpilog2pi.
但是简单起见,我们仍然使用 pi=ki,它的复杂度也是一样的。
<垃圾话>算到这里,我竟激动得无法入眠。不知怎地,天空忽然下起了大雨。我想,那是因为我心中的乌云已经被可莉炸掉了被彻底扫清了罢。</垃圾话>
核心真伪难辨代码片段
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(NlogN) 的算法了,为什么还要设计一个更慢的算法呢?
是的,这样这样的确算不上什么突破。但是探索的乐趣却是实打实的。
语文期中考,作文主题是“无用之用”。这两件事,或许有异曲同工之妙吧。
P-1 烷基计数
题目背景
搓了个 O(NNlogN) 的算法。
题目描述
求有 n 个碳原子个数的烷基有多少种同分异构体。
输入格式
本题多测。
第一行一个整数 T,接下来 T 行,每行一个整数 ni.
输出格式
输出 T 行,第 i 行输出 ni 个碳原子个数的烷基的同分异构体数目,对 998244353 取模。
数据范围
1≤T,ni≤5×104.
番外:卡常小能手
f⊗f 是对称的,计算量可以砍一半。
然后我们再用 pi+1=pi+μpilog2pi 优化块长。接下来就是决定最优的 μ 值,三分即可。
取整数的 μ=3,此时和生成函数+牛顿迭代只差一个数量级,机子好一点可以卡过。
附录:题目的 C++ 实现
#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……一阵强劲的音乐响起