如何成为卷王(上)

· · 个人记录

卷积&多项式乘法

想要成为卷王,首先需要学会卷积。

两个数列 \bold a\bold b 的卷积 \bold c 定义为:

c_k=\sum_{i+j=k}a_ib_j

类似地定义循环卷积:

c_k=\sum_{(i+j)\bmod n=k}a_ib_j

那么两个多项式 A(x)=\sum\limits_{i=0}^{n-1}a_ix^iB(x)=\sum\limits_{i=0}^{n-1}b_ix^i 的乘积定义为:

C(x)=A(x)\cdot B(x)=\sum_{i=0}^{n-1}\sum_{j=0}^{n-1}a_ib_jx^{i+j}=\sum_{i=0}^{2(n-1)}c_ix^i

其中,\bold c\bold a\bold b 的卷积。因此「多项式乘法」和「计算两序列的卷积/循环卷积」是等价的。

如果直接暴力计算的话,不难发现复杂度是 O(n^2) 的。

这太慢了,卷王需要速度。

怎么优化这个 n^2 的复杂度啊?

用点值表示法试试!

如果用点值表示法,我们发现,如果知道了 C(x)=A(x)B(x) 的足够多的点值,那么就可以计算出 C(x) 的各项系数。

如果朴素地算出这些点值,需要在 O(n^2) 的时间>_<

所以我们优化了个寂寞啊

至少要低于 O(n^2) 吧qwq......

这里我们考虑一组特殊的点值。

单位根

我们定义 \sqrt{-1}=i,即 i^2=-1

方程 x^n-1=0n 个复根 x 被称为 n单位根,记作 \omega_n^0,\omega_n^1,\omega_n^2,\cdots,\omega_n^{n-1}

其中,\omega_n^k 被定义为 e^{\frac{2k\pi i}{n}}=\cos\dfrac{2k\pi}{n}+i\sin\dfrac{2k\pi}{n}。而 e^{i\theta}=\cos\theta+i\sin\theta 就是欧拉公式,它的证明和 \text{FFT} 没啥关系,就不写啦>_<

例如,$3$ 次单位根有:$\omega_3^0=1,\omega_3^1=\dfrac{\sqrt{3}i-1}{2},\omega_3^2=\dfrac{-\sqrt{3}i-1}{2}$。 我们需要用到单位根的这两个性质: $$ \omega_{n}^{k}=\omega_{nd}^{kd},d\in \mathbb N\tag{1} $$ 事实上,$\omega_{nd}^{kd}=\cos\dfrac{2kd\pi}{nd}+i\sin\dfrac{2kd\pi}{nd}=\cos\dfrac{2k\pi}{n}+i\sin\dfrac{2k\pi}{n}=\omega_n^k$,故 $(1)$ 成立。 $$ \omega_{2n}^{n+k}=-\omega_{2n}^k\tag{2} $$ 事实上,$\omega_{2n}^{n+k}=\omega_{2n}^n\cdot\omega_{2n}^k=\omega_2^1\cdot\omega_{2n}^k=-\omega_{2n}^k$,故 $(2)$ 成立。 ----------------------- ## 离散傅里叶变换 设 $\bold a$ 是长为 $n$ 的序列,对于 $k\in [0,n)$,定义: $$ b_k=\sum_{i=0}^{n-1}a_i\omega_n^{ki} $$ 那么我们称 $\bold b$ 为 $\bold a$ 的**「离散傅里叶变换」**,即 $\text{DFT}$ ,记作 $\bold b=\mathcal{F}(\bold a)$ 。 如果我们将序列 $\bold a$ 与 $\bold b$ 的循环卷积记作 $\bold c=\bold a\ast \bold b$,那么不难发现: $$ \mathcal{F}(\bold c)=\mathcal{F(\bold a\ast\bold b)=}\mathcal{F(\bold a)}\cdot\mathcal{F(\bold b)} $$ 其中 $\cdot$ 表示逐点乘积。 如果我们令多项式 $A(x)=\sum a_ix_i$,不难发现 $b_k$ 就是 $A(x)$ 在 $\omega_n^k$ 处的点值,即:$A(\omega_n^k)=b_k$。 也就是说,想要计算 $\bold a$ 的 $\text{DFT}$,那么我们就需要 $A(x)=\sum a_ix^i$ 的 $n$ 个点值。 同样地,只要我们知道了 $\bold a$ 的 $\text{DFT}$,我们同样可以利用这 $n$ 个点值表示出 $A(x)$,计算出 $A(x)$ 的各项系数。 但是,如果朴素地计算 $\text{DFT}$ 也就是这 $n$ 个点值,那复杂度是 $O(n^2)$ 的>\_< 于是就出现一个问题:如何快速计算一个序列 $\bold a$ 的 $\text{DFT}$ 呢? ------------------------------ ## 蝴蝶操作 我们考虑将多项式 $A(x)=\sum\limits_{i=0}^{n-1}$ 的 $n$ 项系数按奇偶性分类: 不妨设 $n=2m$,则 $$ \begin{aligned} A(x)&=\sum_{i=0}^{n-1}a_ix^i=\sum_{i=0}^{m-1}a_{2i}x^{2i}+\sum_{i=0}^{m-1}a_{2i+1}x^{2i+1}\\ &=\sum_{i=0}^{m-1}a_{2i}x^{2i}+x\sum_{i=0}^{m-1}a_{2i+1}x^{2i} \end{aligned} $$ 我们记 $$ A_0(x)=\sum_{i=0}^{m-1}a_{2i}x^{i}\\ A_1(x)=\sum_{i=0}^{m-1}a_{2i+1}x^{i} $$ 那么就得到了两个次数不超过 $m$ 的多项式 $A_0(x)$ 和 $A_1(x)$,并且有: $$ A(x)=A_0(x^2)+xA_1(x^2)\tag{\#} $$ 现在,如果我们有了 $A_0(\omega_m^k)$ 和 $A_1(\omega_m^k)$ 的值,带入 $(\#)$ 式,计算得: $$ A(\omega_n^k)=A_0(\omega_n^{2k})+\omega_{n}^kA_1(\omega_n^{2k})=A_0(\omega_m^{k})+\omega_{n}^kA_1(\omega_m^k)\\ A(\omega_n^{k+m})=A_0(\omega_n^{2k+n})+\omega_{n}^{k+m}A_1(\omega_n^{2k+n})=A_0(\omega_m^{k})-\omega_{n}^kA_1(\omega_m^{k}) $$ 因此,只要我们有了 $A_0(x)$ 和 $A_1(x)$ 在 $\omega_m^0,\omega_m^1,\cdots,\omega_m^{m-1}$ 处的点值,就可以在 $O(n)$ 的时间内计算出 $A(x)$ 在 $\omega_n^0,\omega_n^1,\cdots,\omega_n^{n-1}$ 处的点值。 那么我们递归下去计算 $A_0(x)$ 和 $A_1(x)$ 的点值,每次将问题规模减半,合并子问题又是 $O(n)$ 的,于是复杂度就是: $$ T(n)=2T(n/2)+O(n)=O(n\log n) $$ 于是,我们成功在 $O(n\log n)$ 的时间内计算出了 $A(x)$ 在 $n$ 次单位根上的点值,也就是其系数数列 $\bold a$ 的 $\text{DFT}$ 。 这个过程通常被称为**「蝴蝶操作」**。 -------------------- 现在我们就可以在 $O(n\log n)$ 的时间内计算出 $\bold a$ 和 $\bold b$ 的 $\text{DFT}$。 再根据 $\mathcal{F}(\bold a\ast \bold b)=\mathcal{F(\bold a)}\cdot\mathcal{F(\bold b)}$,我们可以在 $O(n)$ 的时间内计算出它们的卷积的 $\text{DFT}$。 但是我们要算的是 $\bold a\ast \bold b$ 而不是它的 $\text{DFT}$ 啊=\_= 如果把 $\text{DFT}$ 看作点值,然后用拉格朗日插值,把 $\mathcal{F(\bold a\ast\bold b)}$ 转化为 $\bold a\ast\bold b$,那复杂度还是 $O(n^2)$ >\_< 那现在的问题就是:我们知道了序列 $\bold c$ 的 $\text{DFT}$,如何在低于 $O(n^2)$ 的时间内计算出 $\bold c$? ----------------- ## 离散傅里叶变换的逆变换 对于长为 $n$ 的序列 $\bold a$,若其 $\text{DFT}$ 为 $\bold b$,即: $$ b_k=\sum_{i=0}^{n-1}a_i\omega_n^{ki}\tag{1} $$ 我们证明 $(1)$ 的逆变换,即其 $\text{IDFT}$ 为: $$ a_k=\dfrac{1}{n}\sum_{i=0}^{n-1}b_i\omega_n^{-ki}\tag{2} $$ 我们将 $(1)$ 中 $b_k$ 的表达式带入 $(2)$,得到: $$ \dfrac{1}{n}\sum_{i=0}^{n-1}b_i\omega_n^{-ki}=\dfrac{1}{n}\sum_{i=0}^{n-1}\omega_n^{-ki}\sum_{j=0}^{n-1}a_i\omega_n^{ij} $$ 交换一下求和顺序(其实就是提取公因式),得到: $$ \dfrac{1}{n}\sum_{i=0}^{n-1}\omega_n^{-ki}\sum_{j=0}^{n-1}a_j\omega_n^{ij}=\dfrac{1}{n}\sum_{j=0}^{n-1}a_j\sum_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_n^{-ki} $$ 我们考虑后面的那个小可爱 $\sum\limits_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_n^{-ki}

j=k 时,有:

\sum_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_n^{-ki}=\sum_{i=0}^{n-1}\omega_n^{ik-ik}=\sum_{i=0}^{n-1}1=n

j\neq k 时,则有:

\sum_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_n^{-ki}=\sum_{i=0}^{n-1}\omega_n^{i(j-k)}

我们发现这东西是个类似于等比数列求和的形式,于是用一下等比数列求和公式:

\sum_{i=0}^{n-1}\omega_n^{i(j-k)}=\sum_{i=0}^{n-1}\left(\omega^{j-k}\right)^i=\dfrac{1-\left(\omega^{j-k}_{n}\right)^n}{1-\omega_n^{j-k}}=\dfrac{1-\left(\omega_n^n\right)^{j-k}}{1-\omega_n^{j-k}}=\dfrac{1-1}{1-\omega_n^{j-k}}=0

于是只要 j\neq k\dfrac{1}{n}a_j\sum\limits_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_{n}^{-ki} 整个就是个 0,不会对答案产生任何影响。

因此这个式子就被化简成了:

\dfrac{1}{n}\sum_{i=0}^{n-1}b_i\omega_n^{-ki}=\dfrac{1}{n}\sum_{j=0}^{n-1}a_j\sum_{i=0}^{n-1}\omega_{n}^{ij}\cdot\omega_n^{-ki}=\dfrac{1}{n}a_k\cdot n=a_k

a_k=\dfrac{1}{n}\sum_{i=0}^{n-1}b_i\omega_n^{-ki}\tag{2}

证毕。

类似地,我们也可以证明 (1)(2)等价的

我们称 (2)「离散傅里叶变换的逆变换」,即 \text{IDFT}。如果朴素地按照表达式计算 \text{IDFT},那么复杂度还是 O(n^2) 的。

但实际上,观察 \text{IDFT} 的表达式,对比一下 \text{DFT} 的表达式:

b_k=\sum_{i=0}^{n-1}a_i\omega_n^{ki}\tag{DFT} a_k=\dfrac{1}{n}\sum_{i=0}^{n-1}b_i\omega_n^{-ki}\tag{IDFT}

实际上只需要将计算 \text{DFT} 时用到的「蝴蝶操作」中的 \omega_n 全部替换成 \omega_n^{-1} ,类似地递归下去,同样可以在 O(n\log n) 的时间内计算 \text{IDFT}。唯一不同的是算完之后需要乘上 \dfrac{1}{n}

所以 \text{IDFT} 同样可以在 O(n\log n) 的时间内被计算。

总结

总结一下我们的 \text{FFT} 流程:

  1. 利用 \text{DFT},在 O(n\log n) 的时间内分别计算出 \bold a\bold b\text{DFT}
  2. 把算出来的出来的这两个 \text{DFT} 相乘,在 O(n) 的时间内得到 \bold c\text{DFT}
  3. 利用 \text{IDFT},在 O(n\log n) 的时间内,把 \bold c\text{DFT} 转化为 \bold c ,就计算出了 \bold a\bold b 的卷积。

综上,我们在 O(n\log n) 的时间内计算出了两个序列的卷积!

FFT 代码实现&注意事项&技巧(递归版)

先写 \text{DFT}

typedef complex<double> comp;
#define pi acos(-1)//不要手打 pi QAQ,最好用 acos(-1)
void dft(comp *a,int p){
    if(p==1)return;//递归边界
    int q=p>>1;
    comp a1[q],a2[q];
    for(int i=0;i<q;i++){
        a1[i]=a[i<<1];
        a2[i]=a[(i<<1)|1];
    }//按奇偶分类
    dft(a1,q);dft(a2,q);//分治
    for(int i=0;i<q;i++){
        comp omega=comp(cos(2*pi*i/p),sin(2*pi*i/p));
        a[i]=a1[i]+omega*a2[i];
        a[i+q]=a1[i]-omega*a2[i];//照搬我们推出来的式子qwq
    }
}

然后把 \text{DFT} 中的 \omega_n 换成 \omega_n^{-1},写出 \text{IDFT}

void idft(comp *a,int p){
    if(p==1)return;//递归边界
    int q=p>>1;
    comp a1[q],a2[q];
    for(int i=0;i<q;i++){
        a1[i]=a[i<<1];
        a2[i]=a[(i<<1)|1];
    }//按奇偶分类
    idft(a1,q);idft(a2,q);//分治
    for(int i=0;i<q;i++){
        comp omega=comp(cos(-2*pi*i/p),sin(-2*pi*i/p));//添一个负号表示负一次幂qwq
        a[i]=a1[i]+omega*a2[i];
        a[i+q]=a1[i]-omega*a2[i];//照搬我们推出来的式子qwq
    }
}

我们发现 idft()dft() 两者之间只差了一个负号,因此可以合并成:

void fft(comp *a,int p,int tag){//tag=1 表示 dft,tag=-1 表示 idft 不乘 1/n 之前的操作
    if(p==1)return;//递归边界
    int q=p>>1;
    comp a1[q],a2[q];
    for(int i=0;i<q;i++){
        a1[i]=a[i<<1];
        a2[i]=a[(i<<1)|1];
    }//按奇偶分类
    fft(a1,q,tag);fft(a2,q,tag);//分治
    for(int i=0;i<q;i++){
        comp omega=comp(cos(tag*2*pi*i/p),sin(tag*2*pi*i/p));
        a[i]=a1[i]+omega*a2[i];
        a[i+q]=a1[i]-omega*a2[i];//照搬我们推出来的式子qwq
    }
}

由于计算 cos()sin() 都需要相对较为高昂的代价,因此我们提前算出 \omega_n=\cos\dfrac{2\pi}{n}+i\sin\dfrac{2\pi}{n},然后每次循环乘一遍。

于是:

void fft(comp *a,int p,int tag){//tag=1 表示 dft,tag=-1 表示 idft
    if(p==1)return;//递归边界
    int q=p>>1;
    comp a1[q],a2[q];
    for(int i=0;i<q;i++){
        a1[i]=a[i<<1];
        a2[i]=a[(i<<1)|1];
    }//按奇偶分类
    fft(a1,q,tag);fft(a2,q,tag);//分治
    comp omega=comp(1,0),wn=comp(cos(tag*2*pi/p),sin(tag*2*pi/p));
    for(int i=0;i<q;i++){
        a[i]=a1[i]+omega*a2[i];
        a[i+q]=a1[i]-omega*a2[i];//照搬我们推出来的式子qwq
        omega=omega*wn;
    }
}

注意到如果我们按照上述的操作进行 \text{FFT},只能处理长度为 2^k 的数列的卷积,其中 k\in \mathbb N^*

因此,我们一般会选取一个最小的 2^k\ge n+m 作为序列长度,多出来的地方就看做 0,然后再进行 \text{FFT}

所以,实际上,在计算卷积的时候,我们通常会:

int k=1;for(;k<=(N+M);k<<=1);

然后再:

fft(x,k,1);
fft(y,k,1);
for(int i=0;i<=k;i++)z[i]=x[i]*y[i];
fft(z,k,-1);

最后才是:

for(int i=0;i<=N+M;i++)cout<<(int)(z[i].real()/k+0.5)<<" ";//不要忘记除以序列长度喵 qwq

此时,由于我们寻找的是最小的 2^k\ge n+m ,因此数组并不能直接开成 10^6 这么大,而要开到最小的 2^k\ge 10^6+10^6 ,大概是 2^{21}=2097152 qwq。

方便起见,开成 2100000 也是没有问题的QAQ。

整个代码十分简洁易懂(所以窝并没有加多少注释),就是把我们之前推导的式子照搬过来了qwq。

迭代实现 FFT

然而递归版常数大到爆炸,尤其是我们递归的时候居然还要递归一下开一遍内存,很容易空间爆炸>_<

(虽然递归版开 O2 就能直接艹过板子题)

而且,\text{FFT} 作为一个经常要使用的算法,如果它的常数过大导致整个程序时间爆炸是很不好的qwq。

能不能把 \text{FFT} 改写成迭代形式啊?

\text{FFT} 递归树中,如果我们能够快速地处理出叶子节点所构成的序列 A(本例中为 A=\{a_0,a_4,a_2,a_6,a_1,a_5,a_3,a_7\},那么就可以自底向上地实现整个 \text{FFT} 操作。

具体地,我们现在有了一个序列 A,就是这个递归树的叶子节点所构成的序列。

首先我们从前往后,两个两个地取出 A 中元素,然后对于每两个元素,算出它们的 \text{DFT},这样就有了叶子节点上一层的 n/2\text{DFT}

然后用这 n/2\text{DFT} ,可以再算出来再往上一层的 n/4\text{DFT},直到最后算出来递归树的「根」处的 整个序列的 \text{DFT}

假设我们已经有了这个序列 A 的初始值,那么就能写出这样的代码:

typedef complex<double> comp;
comp A[MAXN];
void dft(comp *a,int N){    //N 是 2 的正整数次幂。
    Pre(a,N);
    //假装我们已经通过一些神奇操作在低于 O(n log n) 的时间内预处理出了 A[] qwq。
    int n=log2(N);
    for(int i=1;i<=n;i++){
        int m=1<<i;         //m=2^i,就是递归时这一层中每组的长度
        for(int j=0;j<N;j+=m){
            for(int k=0;k<m/2;k++){
                comp omega=comp(cos(2*k*pi/m),sin(2*k*pi/m));
                comp x=omega*A[j+k+m/2],y=A[j+k];
                A[j+k]=y+x;A[j+k+m/2]=y-x;
            }
        }
    }
}

所以,到底怎么在一个较好的时间复杂度内确定 A 啊?

位逆序置换

我们仔细观察一下递归的过程,还以 7 次多项式的八个系数为例:

观察 (0,4,2,6,1,5,3,7) 的二进制表示:(000,100,010,110,001,101,011,111)

诶,把这个二进制表示翻转过来:

000 100 010 110 001 101 011 111
翻转后: 000 001 010 011 100 101 110 111

这,这不就是 (0,1,2,3,4,5,6,7) 的二进制表示嘛!

实际上这其中的道理是很好解释的:考虑我们分组时的分组依据。

第一次我们将奇偶分了个组,把偶数放到前面,把奇数放到后面,也就相当于把二进制表示中末尾为 0 的放到前面,末位为 1 的放到后面

再想想把 (0,1,2,3,4,5,6,7) 的二进制表示:显然前面四个的首位是 0,后面四个的首位是 1,毕竟这个序列是递增的嘛QAQ。

也就是说,我们在末位上把 0 全都提到了前头,把 1 全都扔到了后头,而 (0,1,2,3,4,5,6,7) 则是在首位上把 0 提到前头,把 1 扔到后头,一模一样啊QAQ!

那同理,我们后面的操作也都是把每组中的 0 提到前头,把 1 扔到后头;这恰巧相当于 (0,1,2,3,4,5,6,7) 这一顺序排列在每组中的操作。

如果我们令 \text{rev}(n) 为 「将 n 的二进制表示翻转过来之后得到的值」,根据上面的结论,我们就有:

A_{\text{rev}(i)}=a_i

那我们就可以这么处理序列 A

for(int i=0;i<n;i++)if(i<rev[i])swap(a[i],a[rev(i)]);

其中 rev(n) 是一个计算「将 n 的二进制翻转后得到的值」的函数。

这样一来,经过处理后的序列 \bold a 就是我们要求的叶子节点了。

计算 rev(n) 显然可以做到 O(\log n),毕竟 n 在二进制下的位数也一共只有 O(\log n) ,随便你怎么搞都行嘛qwq。

这样的话就做到了 O(n\log n) 计算序列 A

实际上,如果 n 在二进制表示下的后 L 位(本例中 L=3),那么将 n 的这 L 位翻转,其实就相当于:把前面的 L-1 位拽出来,翻转一下,再把最后一位搞到最前头去qwq!

也就是说,如果我们知道了 \text{rev}(\texttt{n>>1})\text{rev}(n/2) 的值,那么就可以直接 O(1) 算出来 \text{rev}(n) 的值

来构造一下这个递推式吧qwq。

首先要把前 L-1 位单拉出来并翻转:rev[i>>1]

注意,这里我们相当于把前面的 L-1 全部右移一位,把最后一位「顶掉」,这会导致最前面空出来一个 0

那翻转之后这个最前面的 0 就被翻到了最后一位,也就是说,我们需要再右移一位,把这个 0 顶掉,即:rev[i>>1]>>1

现在,这个 rev[i>>1]>>1 就是 rev[i] 的后面 L-1 位翻转得到的结果了,而且位置是没有问题的qwq。

现在只需要把 i 的最后一位搞到前头去就行啦!

i 的最后一位,就是 i&1;搞到前头去就相当于左移 L-1 位(这样它变成了第一位),再和刚才的 rev[i>>1]>>1 「或」一下即可qwq。

那么递推式就是:rev[i]=(rev[i>>1]>>1)|((i&1)<<(L-1))

因此,我们就利用递推做到了 O(n) 预处理 \text{rev}(i),然后 O(n) 计算序列 A qwq!

FFT 代码实现&注意事项&技巧(非递归版)

此时我们需要算出这些数在二进制下的位数,也就是把这一步改一下:

int k=1,L=0;
for(;k<=(n+m);k<<=1,L++)//L 就是我们需要考虑的位数。

其他的都基本没有什么区别qwq。

typedef complex<double> comp;

int rev[MAXN],k,L;

void init(){
    rev[0]=0;
    for(int i=0;i<k;i++)rev[i]=(rev[i>>1]>>1)|((i&1)<<(L-1));
}//预处理 rev[]

void fft(comp *a,int N,int tag){
    init();
    for(int i=1;i<N;i++)if(i<rev[i])swap(a[i],a[rev[i]]);
    int n=log2(N);
    for(int i=1;i<=n;i++){
        int m=1<<i;         //m=2^i,就是递归时这一层中每组的长度
        comp wn=comp(cos(tag*2*pi/m),sin(tag*2*pi/m));
        for(int j=0;j<N;j+=m){
            comp omega=comp(1,0);
            for(int l=0;l<m/2;l++){
                comp x=omega*a[j+l+m/2],y=a[j+l];
                a[j+l]=y+x;a[j+l+m/2]=y-x;
                omega=omega*wn;
            }
        }
    }
}