引入
对于两个多项式,形如
我们将其记为与,当我们需要快速求(卷积)时,我们就会用算法来解决这个问题。
而就是来解决集合上的一类卷积问题。
- 前言
在比赛与平时练题时,我们常常会遇到一些关于集合的动态规划的问题,比如集合的方案数计数,某些集合的权值计算等,但是一般推出的递推式复杂度会成指数或者阶乘之高,是无法忍受的,但是又没有好的解决方法,于是就放弃去打爆力算了。
那么对于两个集合,形如
我们将其记为与,现在又两个分别定义在和的函数与,现有另一个定义在集合上的函数。对于一个集合,的计算方式如下,我们用表示全集,其中。
这里为一类集合的运算,一般为(交,并,对称差),这个便是集合的卷积。
那么有没有类似FFT的方法快速计算两个集合之间的卷积呢?若有不就可以解决前言中的问题了?答案肯定是有的。
前置
集合幂级数
对数列理论熟悉的读者应该知道,我们可以把一个数列和一个形式幂级数相对应,称之为该数列的生成函数。由于有很多相关的理论与快速算法,这个方法就能解决很多的数列的计算问题。
那么我们对于集合也定义类似的东西,看看能否达到同样的效果。
设是一个域,则称函数为上的一个集合幂级数,对于一个集合,我们令为把带入函数时的函数值,同样的将称为该集合幂级数的第项的系数。那么如果所有的确定了的话,该函数也就确定了。
那么我们设为集合幂级数,定义加法,也是一个集合幂级数,且对于一个集合,满足如下式子:
所以,集合幂级数的加法就是对应系数相加,减法同理,改为即可。
下面我们定义集合幂级数的具体形式。参照形式幂级数的样子,我们定义如下: 对于任意的,我们用表示函数中的第项的系数为。
那么对于一个集合幂级数的函数,形式如下:
一般简写为
我们以此来表示一个集合幂级数。
举个栗子,当(表示实数集),对于该函数,我们可以用来表示一个对应系数为的集合幂级数。
我们同样定义乘法如下,对于集合幂级数,其中,且其中的每一项满足,那么这个乘法就可以满足交换,结合,分配等定律了。
集合的并,交,对称差卷积
并
我们令三个集合幂级数,其中满足如下式子:
那么我们称为的集合并卷积,简记为。
交
我们令三个集合幂级数,其中满足如下式子:
那么我们称为的集合交卷积,简记为。
对称差
我们令三个集合幂级数,其中满足如下式子:
那么我们称为的集合对乘差卷积,简记为或者(能看懂就行)。
对于如上的三个卷积,显然都可的时间复杂度计算(括号外面的为时间复杂度的符号,里面的表示全集,表示全集大小)。
但是这样肯定不能解决大多数问题,那么我们需要考虑快速算法。
分治是一种降低复杂度的好方法,那么这个能否分治呢?
我们将全集中的一个元素单独提出,也就是对于所有含的集合提一个出来,那么可以写成由两个集合组合出来的函数,表示不含的部分,表示含有的部分,那么,由此我们可以把原式写成如下形式
对于不同的,我们可以将其化简成不同的形式。
- 时
由于当前的运算为并,那么,,(有元素的与没有的并起来就有元素,都有元素并起来还是有,都没有并起来就还是没有)。
所以我们进行如下化简:
这里将乘号略写
将其化简为乘积的形式
我们将其看作两种形式,第一种为单独的或,另一种为单独的或,那么我们通过分治的方法,先将和分别变化成和
然后对应的项乘起来,我们令,此时,但是此时的并不是我们要求的,联想的做法,既然变换过去了,肯定要找个方法变换回来。再看我们推导化简式子的最后有一个,所以我们将变成,就可以得到了。
复杂度分析,我们令为元素种类的大小,设总的变换的时间复杂度为,可得,其中是因为提出一个元素后,元素种类减少1,分成了本来有该元素和本来无该元素的两部分,所以乘2,再加上当前这次变换的复杂度。最后解得总复杂度,而大小是远远小于由个元素组成的集合的全集的大小的,一般,复杂度远远低于暴力复杂度,可以看做。
说明:例如当前,有元素,那么,所以的大小为。
- 时
同样对于交,我们化简原式,过程略,得到如下式子:
乘号略写公式太难打了QAQ。
说明:因为交是必须两个都有元素,最后才会有,否则就没有元素。
写成乘积形式
同样的,提出类似的部分后,我们可以对先自己变换,然后相乘(对应项),然后再变换一次变回来就可以求出。
其中
复制度同上。
- 时
对于对称差,都是大同小异。
化简后得到
说明:因为对称差计算有点奇怪
好吧其实也不奇怪,两者都有或者都没有该元素,计算出来就没有。只要其中一个有另一个没有,计算出来就有。
所以变换为:
同理。
自己手推一下应该可以得到。
- 对称差的另外一种推导方法
我们这么半天,集合幂级数的性质也没太明显用,下面就将对称差的另外一种推导方法。
首先引入如下式子:
对于一个集合有:
对于证明我们较容易发现,当时恒为,那么也就恒为1,又因为,所以最后除以了一个,式子的值就为1。对于不为空集,令一个元素,由于,根据对称差的分配律,原式等于,又因为中没有交集大小增加1,有交集大小减少1,所以怎么都要变化1,所以,那么可以得知,由于,所以与一一对应,所以最后原式的值肯定为0。
我们接下来看:
对于一个集合,可知
说明:这里能这样变换是因为对答案无影响,会消掉,所以如果有或者没有某个元素,和交后,会被如上方法抵消影响。
举个栗子:的某个元素的有无情况如下(因为都与相交,所以可以不管的影响):
,此时 会有该元素,但是三种情况分别为都为不变,同样没有该元素的都为不变,所以可以拆开,其实因为出来有该元素必须有奇数个1,无该元素必须有偶数个1,所以的奇数次方为-1,偶数次方为0,而奇数出来有该元素,就是还是,偶数没有,就是,所以为1。
这里前半部分只有当时后半部分才为1,所以这个式子的值为1,然后对于单个集合的变换,我们可以将集合看作一个常量,也就是式子最后半部分关于的那个括号里的值可以看作1,所以这种形式可以启发我们定义以下变换。
这样我们就可以定义一种变换,对于一个集合幂级数,我们定义它的沃尔什变换为集合幂级数,其中:
其逆变换同样由上面推导式得到:
再从集合对称差卷积得到:
于是我们先可以对做沃尔什变换,再相乘得到然后对其做沃尔什逆变换得到答案
这里可以用递推的方法,我们设为只考虑那些与的对称差是的子集的沃尔什变换的第项(也就是的集合的之和),这里容易得到对于不包含的有
说明:对于第一个式子,不含有元素的根据定义式,将其拆分为两部分,对于前半部分因为已经是不含有了,但是对于后者,我们要用来表示,所以当换成时,变成,它的集合大小会增加1,因为开始中没有,交出来也没有,后面中有了,所以交出来就比原来多了一个(这里的中是有的),所以,所以可以得到一式。
对于二式,也是同样的道理,开始变成了含有的集合,同样用定义式将其拆成两部分,然后后半部分已经为所以要将前半部分变成没有的,同样来看,和没有变化,因为这里的是不包含元素的,所以可以得到二式。
但是对于最底层的,根据定义来看,我们最底层的只能等于,这样,所以,当大小为奇数时它等于,为偶数时他就等于,这样十分不好处理,所以我们统一规定,,那么对于上面求出的式子也要进行改动。
我们这里假设,那么,(其实反过来也可以,虽然式子符号(正负)会变,最终的答案不会改变),因为后者会比前者多一个元素,所以本来要乘以,又因为我们上面的重定义,所以不能乘以,所以对于后者要取相反数,这里我们便得到了最终的集合对称差卷积的沃尔什变换的式子:
其实也就和之前的证明方法的结果相同了,。
逆变换也就是原来的变换乘以了,所以我们发现这里可以不用像原来的逆变换每次除以2,可以做顺变换,最后答案除以即可。
注:对于其它两个集合运算也有类似的证明,但是过于繁琐,而且实际应用中集合对称差用到此证明公式的最多,所以其余两个就不讲此类证法,有兴趣的读者可自行查阅资料。
到这里,沃尔什变换就大概完整了。
三种卷积的变换与逆变换
集合并
沃尔什变换
沃尔什逆变换
集合交
沃尔什变换
沃尔什逆变换
集合对称差
沃尔什变换
沃尔什逆变换
注意:在取模运算时,除2要变成乘以2对应的逆元。
那么对于全集的每个元素,我们可以用0或者1来表示它是否在某个集合里,就可以用一个二进制数来表示集合,这在运算上就十分方便了,原因如下:
对于集合并,它就相当于位运算中的, , 。
对于集合交,它就相当于位运算中的&(and), , 1011&0110=0010。
对于集合对称差,你应该想到了,没错,它就是 , ,
那么由于位运算每一位之间互不影响,所以我们可以把它按照当前位为0或1分治,也就是表示当前元素有无分治。
但是递归虽然可以,有时常数以及实际效率并不是很优秀,所以我们考虑不用递归的形式。
我们发现它的全集,可以用二进制数全部表示出来,转换为十进制来看就是,其中表示。
那么我们来看二进制的形式:
就等于
我们发现对于第位(从右往左数),它为相隔个数,为也是,如,,,以此类推,所以我们可以用循环来代替递归。
第一层循环,枚举当前为第位
for(int bit=1;bit<(1<<size);bit<<=1)
第二次枚举当前连续的一段0和1
int inc=bit<<1;
for(int i=0;i<(1<<size);i+=inc)
依次枚举这一段对应的0和1,
for(int j=0;j<bit;j++)
那么我们用表示0这一位,表示1这一位,那么就有
x=a[i+j],y=a[i+j+bit];
//eg.当前这一位是{0,0,1,1,0,0,1,1}
// | |
// i i+bit
// j {0~1}
所以对于三种变化我们计算如下
运算方式 | or并 | and交 | xor对称差 |
---|---|---|---|
沃尔什变换 | a[i+j+bit]=x+y | a[i+j]=x+y | a[i+j]=x+y,a[i+j+bit]=x-y |
沃尔什逆变换 | a[i+j+bit]=y-x | a[i+j]=x-y | a[i+j]=(x+y)/2,a[i+j+bit]=(x-y)/2 |
那么代码就很容易写了。
模板题目
Luogu P4717
#include<cstdio>
#include<cstring>
#include<algorithm>
#define ll long long
using namespace std;
const int M=1<<19;
const ll mod=998244353ll;
int n;
ll A[M],B[M];
ll a[M],b[M];
ll c[3][M],inv;
ll pow(ll a){
ll b=mod-2,ans=1;
for(;b;b>>=1,a=(a*a)%mod){
if(b&1) ans=(ans*a)%mod;
}
return ans;
}
void WT(ll *w,int n,int f,int type){
ll x,y;
//type:0 or,1 and,2 xor
//f=1 沃尔什变换 f=-1 沃尔什逆变换
for(int d=1;d<n;d<<=1){
for(int ad=d<<1,i=0;i<n;i+=ad){
for(int j=0;j<d;j++){
x=w[i+j],y=w[i+j+d];
if(f==1){
switch(type){
case 0:{
w[i+j+d]=(x+y)%mod;
break;
}
case 1:{
w[i+j]=(x+y)%mod;
break;
}
case 2:{
w[i+j]=(x+y)%mod;
w[i+j+d]=(x-y+mod)%mod;
break;
}
}
}else{
switch(type){
case 0:{
w[i+j+d]=(y-x+mod)%mod;
break;
}
case 1:{
w[i+j]=(x-y+mod)%mod;
break;
}
case 2:{
w[i+j]=(x+y)*inv%mod;
w[i+j+d]=(((x-y)*inv%mod)+mod)%mod;
break;
}
}
}
}
}
}
}
void FWT(ll *a,ll *b,int n){
for(int f=0;f<3;f++){
memset(A,0,sizeof(A));memset(B,0,sizeof(B));
for(int i=0;i<n;i++) A[i]=a[i],B[i]=b[i];
WT(A,n,1,f);WT(B,n,1,f);
for(int i=0;i<n;i++) A[i]=(A[i]*B[i])%mod;
WT(A,n,-1,f);
for(int i=0;i<n;i++) c[f][i]=(A[i]%mod+mod)%mod;
}
}
int main(){
inv=pow(2);
scanf("%d",&n);n=(1<<n);
for(int i=0;i<n;i++) scanf("%lld",&a[i]);
for(int i=0;i<n;i++) scanf("%lld",&b[i]);
FWT(a,b,n);
for(int i=0;i<3;i++){
for(int j=0;j<n;j++) printf("%lld ",c[i][j]);puts("");
}
return 0;
}
hdxrie的更简洁的模板IN THERE
其中有一些简单的特判,避免了我的switch语句的麻烦。(感谢hdxrie提供)
而暴力计算其实十分简单,为了方便理解,这里放一份暴力代码。
#include<cstdio>
#include<cstring>
#include<algorithm>
#define ll long long
using namespace std;
const int M=1<<19;
const ll mod=998244353ll;
int n;
ll a[M],b[M];
ll cxor[M],cor[M],cand[M];
int main(){
scanf("%d",&n);
if(n>13) return 0;
n=(1<<n);
for(int i=0;i<n;i++)scanf("%lld",&a[i]);
for(int i=0;i<n;i++)scanf("%lld",&b[i]);
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
ll now=(a[i]*b[j])%mod;
(cor[i|j]+=now)%=mod;
(cxor[i^j]+=now)%=mod;
(cand[i&j]+=now)%=mod;
}
}
for(int i=0;i<n;i++) printf("%lld ",cor[i]);puts("");
for(int i=0;i<n;i++) printf("%lld ",cand[i]);puts("");
for(int i=0;i<n;i++) printf("%lld ",cxor[i]);puts("");
return 0;
}
End
这里就结束了,如果有不清楚或者错误的地方,欢迎向我提出,我会尽量及时回答与修改(我也是个初学者)。
参考
具体应用与题目
参考这篇文章