信息竞赛 高级数学
诗曰:
“高数第一杀手,考试一考就寄。复数知识一用,
算成正一。朴素演算善后,死磕公式何必?考场信心十足,全错当场暴毙。”
前置知识:复数、位运算
Part1. 快速傅里叶变换
Div1. 世界上最优雅的算法
FFT的前身是DFT,可以简单看作是一堆OIer争先恐后对DFT算法进行优化的结果。美苏冷战期间,双方都对自己的核实力有所隐瞒,就等着某一天用自己的核导弹打对方个措手不及……在一次科学议会上,有人提出在苏联国境周边安装大量地震波传感器,将传感器的数据回收处理,分析是否出现了类似于核试验产生的震波从而判断苏联是否在进行秘密核试验。但是在当时用于频谱分析的DFT算法效率太过低下,传感器又必须在短时间内分析大量的频谱数据,因此逆境出人才——当时参与会议的其中一位科学家Tukey后来找到了程序员Cooley,后者在当时是一名操作ENIAC的程序员。二者分工明确,在1965年他们提出了FFT算法(当时叫做Cooley-Tukey算法),这个算法在后来被IEEE列入他们主编的“20世纪十大程序算法”之中。
FFT在信号处理领域受到了广泛的应用,直至今日它仍然被集成在大多数波谱分析仪器中作为底层算法之一。然而在信息学竞赛中,它被广大的OIer看中,拿去优化了高精度乘法的计算。它可以将朴素的高精度乘法的
Div2. 系数表示法与点值表示法
我们在初中时期学过一元二次方程的函数图像。一般情况下,我们都会用形如:
系数表示法可以直接通过
假如我们让一个三次函数那还不如不优化
为了减少这里的复杂度,我们借助一点高一所学的奇偶函数的内容~(没学过/没学懂没关系,奇函数相当于关于原点对称、偶函数相当于关于
假如该函数是一个偶次的幂函数,幂函数的定义是
函数
经过一轮拆分:

这张图可以直观看出原函数(绿线)被拆分成偶项(红线)、奇项(蓝线)和常数项(橙线)后的样子。
对于奇项,代任意
令
Div3. 单位根
首先花一些篇幅来介绍复数的概念。我们学过:形如
而复数就像是实数和虚数的一次友好会面,复数域用字母
紧接着,我们的FFT之旅就要来到下一个目的地——复平面。复平面类似于平面直角坐标系,只不过
比如说上一节遗留的式子,递归继续深入一层时,递归函数会变成
每次递归,函数括号中的参数的幂就会乘2。幂在数值上就是
引入单位根的概念:满足

点
如果是八次(

其中
假如我们按逆时针方向,起始点
(对应点关于原点对称) (单位根次数和编号同乘2,坐标相同)
特殊地,
这下终于可以开始从一般例子出手推了。令
按照奇偶项分为两组(这里可以直接通过
函数的自变量来到了
如果此时
正因此,当
Div4. IFFT与蝴蝶操作
IFFT的中文名称是快速傅里叶逆变换。有些人比如刚开始接触FFT的我认为只要敲出FFT的代码,就可以解决任何函数卷积的问题了……错了,FFT主要是将系数表示法转换为点值表示法,相当于数学考试解析式求解题题干里给你的已知点坐标!卷积之路才刚刚过半,接下来介绍如何将相乘后得到的点值表示法重新转化为系数表示法。由于FFT是系数到点值的转换、这种方式是点值到系数的转换,因而称之快速傅里叶逆变换——IFFT。
我们的FFT算法已经将两个函数的卷积
假设一个函数的系数形式
简记为:根倒求和、
再加上,复数的乘积在复平面上有个奇妙的性质:复平面上一个向量可以看作是从实轴正半轴逆时针旋转特定的角度后得到的,这个角度称作向量的辐角,复数相乘时,得到的结果的辐角将是这两个向量的辐角之和、模长将是两个向量模长的乘积。简称辐角相加、模长相乘。除法作为乘法的逆运算,它在复平面上操作的结果是辐角相减、模长相除。因而单位根的倒数
对于代码的设计,尽管C++STL库提供了Complex<>复数类,但还是建议手写Complex。因为STL库的运行效率有时会很慢,会被卡掉,而且它的码量也不大,手写难度不高。强烈推荐手写结构体Complex。
递归Version:
#include <bits/stdc++.h>#define N 6000010using namespace std;
const double PI = acos(-1.0);
struct Complex { double real, imag;
Complex(double r = 0.0, double i = 0.0) { real = r, imag = i; }} f[N], g[N];
Complex operator +(const Complex &l, const Complex &r) { Complex res(l.real + r.real, l.imag + r.imag); return res;}
Complex operator -(const Complex &l, const Complex &r) { Complex res(l.real - r.real, l.imag - r.imag); return res;}
Complex operator *(const Complex &l, const Complex &r) { //(a+bi)*(c+di)=(ac-bd)+(ad+bc)i Complex res(l.real * r.real - l.imag * r.imag, l.real * r.imag + l.imag * r.real); return res;}
Complex operator /(const Complex &l, const Complex &r) { //(a+bi)/(c+di)=(a+bi)(c-di)/(c^2+d^2)=[(ac+bd)+(bc-ad)i]/(c^2+d^2) Complex res((l.real * r.real + l.imag * r.imag) / (r.real * r.real + r.imag * r.imag), (l.imag * r.real - l.real * r.imag) / (r.real * r.real + r.imag * r.imag)); return res;}
void FT(int len, Complex *c, int type) { // type=1时为FFT,type=-1时单位根纵坐标取相反数,为IFFT if (len == 1) return; Complex c1[len >> 1], c2[len >> 1]; // c1为偶项系数,c2为奇项系数,数组大小除以2 for (int i = 0; i <= len; i++) { // 按奇偶性分类存储 if (i % 2 == 0) c1[i >> 1] = c[i]; // 紧凑存储,c1、c2数组下标除以2 else c2[i >> 1] = c[i]; } FT(len >> 1, c1, type); // 处理偶项 FT(len >> 1, c2, type); // 处理奇项 Complex omega = Complex(cos(2.0 * PI / len), type * sin(2.0 * PI / len)); // 计算单位根 Complex k = Complex(1.0, 0.0); register Complex butterfly; for (int i = 0; i < (len >> 1); i++, k = k * omega) { butterfly = k * c2[i]; // 蝴蝶操作,记录下复数乘积(现场算太慢)便于调用 c[i] = c1[i] + butterfly; // 单位根 c[i + (len >> 1)] = c1[i] - butterfly; // 单位根性质,系数取反得到另一半 }}
void IFFT(int len, Complex *c) { FT(len, c, -1);}
void FFT(int len, Complex *c) { FT(len, c, 1);}
int main() { int n, m; cin>>n>>m; for (int i = 0; i <= n; i++) cin>>f[i].real; for (int i = 0; i <= m; i++) cin>>g[i].real; int len = 1; while (len <= n + m) len <<= 1; // 得到最小递归层数 FFT(len, f); FFT(len, g); for (int i = 0; i <= len; i++) f[i] = f[i] * g[i]; // 点值相乘得到卷积点值 IFFT(len, f); // 卷积点值转化为系数 for (int i = 0; i <= n + m; i++) cout<<(int) (f[i].real / len + 0.5)<<' '; return 0;}时间复杂度:
这个代码足够通过模板题有些人说不行,只需把数组开大一点点(5e6起步)就可以了……但是这个代码其实还可以继续优化,但是这下就很棘手了,本来推来推去已经够烧脑了你还要让我优化?,考虑到递归算法有些低效,浪费了一些空间和执行效率。那么我们如何把它变成一个非递归版本呢?答案是迭代!
迭代FFT与二进制优化
我们按照奇偶下标将原多项式分为了偶项和奇项,这样做的复杂度是
递归之前,系数下标:
递归一层,系数下标:
递归两层,系数下标:
递归三层,系数下标:
既然称作二进制优化,我们来看看第三层的数字写成二进制形式是怎样的:000 100 010 110 001 101 011
没有规律……别急,我们把数字倒过来念:000 001 010 011 100 101 110。诶?他们是单调递增的,再写成十进制就是:0 1 2 3 4 5 6。我们就找到规律了:递归结束时系数下标的二进制值等于原多项式的系数下标的颠倒二进制值
常规操作,我们使用进制转换来实现二进制逆序,这样的时间复杂度是
int rev(int n) { int res = 0; int k = log2(n) + 1; for (int i = 1; i <= k; i++) { if (n & 1) res += pow(2, k - i); n >>= 1; } return res;}借鉴了这篇题解的逆序思路,逆序的时间复杂度是
int l,r[MAXN];int limit=1;void fast_fast_tle(complex *A,int type){ for(int i=0;i<limit;i++) if(i<r[i]) swap(A[i],A[r[i]]);//求出要迭代的序列 for(int mid=1;mid<limit;mid<<=1)//待合并区间的中点 { complex Wn( cos(Pi/mid) , type*sin(Pi/mid) ); //单位根 for(int R=mid<<1,j=0;j<limit;j+=R)//R是区间的右端点,j表示前已经到哪个位置了 { complex w(1,0);//幂 for(int k=0;k<mid;k++,w=w*Wn)//枚举左半部分 { complex x=A[j+k],y=w*A[j+mid+k];//蝴蝶效应(应为“蝴蝶操作”) A[j+k]=x+y; A[j+mid+k]=x-y; } } }}以及r数组的读入:
for(int i=0;i<limit;i++) r[i]= ( r[i>>1]>>1 )| ( (i&1)<<(l-1) ) ;当然,提高组不考,如果考到,基本上那个递归版本也够用了……
部分内容可能已过时