积性函数前缀和筛法
起因是我正在学筛法,然后去看了一眼最快解。
没想到 P4213 【模板】杜教筛、P3768 简单的数学题、P5325 【模板】Min_25 筛 的最快解都被同一位大佬 @XeCtera 垄断,且前两篇的他还没有开 O2 优化。
考虑到这是一篇比较近的提交,我猜测其使用了某种科技,打开一看发现是 Θ(n2/3log−1n) 的,而且其实现竟然比较简洁,去看这位大佬的个人博客发现其只讲了一种(也很快的)朴素杜教筛写法,却没有关于时间复杂度降低的技巧叙述(upd:原来是没有往下翻太深,求 μ 的块筛是有特殊优化的)。
去网上搜这种科技发现似乎是这种 看起来很麻烦的筛法。
本篇有很多摘抄和分析自 @XeCtera 的博客及代码,在此感谢。
目录
- 前言
- 块筛与杜教筛
- Dirichlet 双曲线法
- 块筛
- 块筛卷积
- 杜教筛
- 复杂度分析
- 实现 & 极致卡常
- 洲阁筛与 min_25 筛
- 思路构造
- min_25 筛的搜索实现
- PN 筛
- Powerful Number 及其性质
- PN 筛的原理
- 实现
- 复杂度上的进展
- 例题
- 参考资料
以下常用 x/y 表示 ⌊yx⌋(但 n1/3 这种明显的分数表示不是整数除法),用 [x] 表示 ⌊x⌋。
本篇的复杂度记号并不严谨,是 Θ 和 O 混用的,大家意会就好。
函数运算记号出现 ∗ 时认为是 Dirichlet 卷积,剩下的都认为是函数乘法,例如 idc(x)=xc。
前言
我们常常需要解决积性函数的前缀和问题,对于大多数积性函数可以使用线性筛法 Θ(n) 求出 [1,n] 处的值,但我们对此并不满足,于是就有了各种积性函数前缀和筛法,本文将由浅入深地介绍常用的筛法。
你可以在本篇笔记中以不那么狭隘的视角认识筛法。
本篇不会有太多的例题,因为是对筛法性质的探索。
块筛与杜教筛
Dirichlet 双曲线法
对于数论函数 f,g,h:Z+→R,f=g∗h,其中 R 为交换环,定义 Sf(x)=∑i=1xf(i),那么有:
Sf(x)=ab≤x∑g(a)h(b)
可以引入 Dirichlet 双曲线法对其快速计算:
若 x,y>0∧xy=n(注意 x,y 可以是实数),则:
S(f∗g)(n)=ab≤n∑f(a)g(b)=a≤x∑f(a)Sg(n/a)+b≤y∑g(b)Sf(n/b)−Sf(x)Sg(y)
通常取 x=y=n。
解释:
我觉得不言而喻了。
上式的等价版本其实是:
S(f∗g)(n)=ab≤n∑f(a)g(b)=a≤n∑f(a)b≤n/a∑g(b)=a≤n∑f(a)Sg(n/a)
这种整除分块的形式是我们更熟悉的。
双曲线法只能给我们提供一个行动纲领,想要快速计算 S(f∗g),我们需要一些更强大的工具。
块筛
并不是一种筛法,而是一个概念。
对于数论函数 f,将 i∈N∩[1,n],Sf(n/i) 的值称为 f 在 n 处的块筛,块筛一共有 Θ(n) 个值,下文称 f 在 n 处的块筛为 Bf(n)。
(事实上可以证明前 [1,n]∩N 一定都是块筛的下标,i−1n−in=i∗(i−1)n>1,因此两者的下取整值不同。)
许多算法用到的其实就是块筛的值,最典型的例子就是整除分块,除了多测,其他时候你基本用到的都是块筛。
回顾前文 Dirichlet 双曲线法和整除分块的式子,可以发现,计算其中右式时,我们需要用到的就是 Bf,Bg。
也就是说,如果我们已知 Bf,Bg,我们可以 Θ(n) 求出 S(f∗g)(n) 的值,这个的用处大概就是不需要傻傻地去用杜教筛再来一遍。
块筛卷积
已知 Bf,Bg,求 B(f∗g)。
直接使用双曲线法(或整除分块)计算就好,复杂度等稍后讲完本质相同的杜教筛再分析。
杜教筛
换个直观点的名称可能应该叫块筛卷积逆/块筛除法?
若 f∗g=h,已知 Bg,Bh,如何求 Bf?
Sh(n)=a≤n∑g(a)Sf(n/a)
发现 a=1 时整个柿子中出现了 Sf(n),将其提出来:
g(1)Sf(n)=Sh(n)−a=2∑ng(a)Sf(n/a)Sf(n)=g(1)Sh(n)−∑a=2ng(a)Sf(n/a)
若 g 为积性函数则 g(1)=1,所以这个分母通常当作没有。
在这里顺便给出 Dirichlet 双曲线法求解杜教筛的过程,在下文我将论述 Dirichlet 双曲线法与整除分块的常数差异是巨大的。
令 s=[n]:
Sh(n)=a≤s∑f(a)Sg(n/a)+b≤s∑g(b)Sf(n/b)−Sf(s)Sg(s)
发现 b=1 时原式中出现了 Sf(n),将其提出:
g(1)Sf(n)=Sh(n)−a≤s∑f(a)Sg(n/a)−b=2∑sg(b)Sf(n/b)+Sf(s)Sg(s)=g(1)Sh(n)−∑a=1sf(a)Sg(n/a)−∑b=2sg(b)Sf(n/b)+Sf(s)Sg(s)
看着麻烦了不少对吧,如果我们仔细观察的话会发现除法变少了,我们稍后再说。
这就是杜教筛的核心柿子,构造易于求块筛(易于求前缀和)的函数 g,h 使得 f∗g=h,即可快速计算 f。
杜教筛除了常见的递归查表写法,还有一种常数较小的递推写法。
根据上面的转移式,我们知道求解 Sf(n) 需要依赖 Sf(n/i),于是我们有如下的算法流程:
- 枚举 k=1,2,⋯,[n],求解 Sf(k)(该部分被后来的预处理所替代)。
- 枚举 k=[n],⋯,2,1,求解 Sf(n/k)。
来几个实战演习:求 Bμ(n),Bφ(n),利用 μ∗I=ϵ,φ∗I=id 即可。
如果同时求两个的话,可以先算 Bμ,再利用 φ=μ∗id,使用块筛 n 求出 Sφ(n)。
再来个例子,反演 的过程在最后会补上,题目转化后为:f(x)=φ(x)x2,求 Bf。
有意思的一点是:fh∗gh=(f∗g)h,理由如下:
d∣x∑f(d)h(d)g(dx)h(dx)=h(x)d∣x∑f(d)g(dx)
所以很自然的,f∗id2=id3。
复杂度分析
块筛卷积与杜教筛的本质是相同的:
T(n)=Θk=1∑nk+Θk=1∑nkn=Θ(n43)
设阈值 B≥n,则 1∼B 的部分通常可以线性筛预处理,则剩余部分的时间复杂度变为:
T(n)=Θk=1∑n/Bkn=Θ(Bn)
如果可以线性预处理,那么时间复杂度为 Θ(B+Bn),B=n2/3 时时间复杂度为 Θ(n2/3)。
关于空间复杂度,预处理积性函数的空间复杂度并不能压缩,所以很遗憾,与时间复杂度相同都为 Θ(n2/3)。
注意,杜教筛写成递归形式但不带记忆化的复杂度是接近 Θ(n) 的。
需要注意的一点是(在后文会用到),根据双曲线法的公式,若 f,g 的非零项密度的 Θ(log−1n) 的,此时不必枚举 0 项,预处理之外的复杂度可以变为极为优秀的 Θ(n2/3log−1n)。
实现 & 极致卡常
注:在学习算法时,切忌提前进行常数优化,请先保证正确性与本质都理解透彻后,再写一些复杂的小常数写法,否则你会和曾经的作者一样痛不欲生。
关于块筛的存储,有两种方式:
- 把每个块筛的值维护一个大小为 2n 的数组,并维护实际下标与数组下标的双射。
- 维护两个数组,一个存储 [1,n],另一个存储 [n,n],每个转移都拆成三部分,即大对大、大对小、小对小(或者维护一个数组但把大的部分坐标平移一下)。
第一种方式更为好写,第二种方式有更小的常数(因为可以把一部分除法变为乘法),在下方的“卡常”版本中采用了第二种实现方式。
推荐考场上按照第一种方式来,如果有闲情雅致卡常的话再慢慢改成第二种方式。
递归是好写的:
1 2 3 4 5 6 7 8 9
| auto DJ=[](auto &&DJ,ll n,auto &mp,auto &&Sf,auto &&Sg,auto &&S)->ll{ if(n<=B) return Sf[n]; if(mp.count(n)) return mp[n]; ll &ans=mp[n]=S(n); for(ll l=2,r;l<=n;l=r+1) { r=n/(n/l); ans-=(Sg(r)-Sg(l-1))*DJ(DJ,n/l,mp,Sf,Sg,S); } return ans; };
|
递推写法也并不难,预处理省略了(块筛中下标比较大的部分存储下标的倒数即可,方便访问且常数小):
1 2 3 4 5 6 7 8
| auto SF=[](ll x) {return x<=B?sf[x]:Sf[n/x];}; UF(i,n/B,1) { ll m=n/i;Sf[i]=S(m); for(ll l=2,r;l<=m;l=r+1) { r=m/(m/l); Sf[i]-=(Sg(r)-Sg(l-1))*SF(m/l); } }
|
带记忆化的递归 总时间跑了约 3.5s,而 不带记忆化的递推块筛 跑出了惊人的 5.2s。
想必带上记忆化也不会优化太多,但最优解的 @XeCtera 使用 不带记忆化的递推块筛 跑出了 315ms 的好成绩。
经查看,他使用的是双曲线法而非整除分块,难道这两种方法的效率差的很大吗?
我们可以依照 Dirichlet 双曲线法类比着写一份代码,看看速度差异。
(在推导过程中,我仍然觉得整除分块更为直观强大,但是在写代码方面把整除分块换成双曲线无疑会变快不少。)
经过实测,在 P4213 【模板】杜教筛 中,整除分块里 每多一次除法 最后一个点的总时间就会增加 600~800ms,这是相当恐怖的。
令我意外的是,换用双曲线法后,将瓶颈处的整数除法变为“先存倒数再浮点数乘法”竟然是更快的,并直接让我总时间 380ms->280ms,超过了 @XeCtera 的暴力。
注意浮点数乘法写 x+=1.0*y/z 效率不如 x+=int(1.0*y/z),前者是用 double 算完加法再转回 int,不精确且慢。
于是我们写出了这样一份通用杜教筛板子,且完成了极致卡常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| auto D=[](ll n,auto &f,auto &sf,auto &Sf,auto &&Sg,auto &&g,auto &&Sh) { int s=sqrt(n); F(i,n/B+1,s) Sf[i]=sf[n/i]; UF(i,n/B,1) { int m=n/i,S=sqrt(m);i=n/m; Sf[i]=Sh(m)+sf[S]*Sg(S)-Sg(m); F(j,2,S) { ll t=i*j,t2=m*inv[j]; Sf[i]-=(t>s?sf[t2]:Sf[t])*g(j)+Sg(t2)*f[j]; } } return Sf[1]; }; auto Q=[](ll n,auto &&f,auto &&sf,auto &&Sf,auto &&g,auto &&Sg){ int s=sqrt(n); ll res=-sf[s]*Sg(s); F(i,1,s) res+=f[i]*Sg(n/i)+g(i)*Sf[i]; return res; };
|
块筛卷积类比着杜教筛写即可。
结尾注:在实际应用中更易操作也常数优秀的写法是开一个数组,前 S 位存储小部分的值,后 S 位存储大数的值,大数的映射规则是 x<->2*S+1-n/x。
洲阁筛与 min_25 筛
思路构造
由于杜教筛需要你构造易于求块筛(前缀和)的函数 g,h,相对来说不是那么友好。
那么我们考虑更加一般的一类积性函数:∀p∈P,f(p) 是一个低次多项式,且 f(pc) 的值可以快速求出。
用 fp 表示 f 仅保留 p 的幂处的函数值、剩余值都为 0,用 ∏(∗) 表示以狄利克雷卷积为乘法的连乘。
首先根据积性函数的性质,原函数 f 等于各素数贡献的 Dirichlet 卷积:
f=p∈P∏(∗)fp
我们把素数分为 >n 的和 ≤n 的,分别称其为大素数和小素数。
我们想要对一个中间结果 Bg 附加或取消 Bfp 的贡献,根据上面的整除分块公式:
S(f∗g)(n)=a≤n∑f(a)Sg(n/a)
由于 fp 中只有 logpn 项有值,我们枚举 a=pc,就可以在 Θ(nlogpn) 的复杂度内求出新的块筛。
考虑每个数至多有一个大素数因子,我们先考虑所有大素数的贡献 B(∏(∗)p≥nfp),此时只有素数处的值非零,因此我们可以把大素数贡献拆成若干个 idc(将低次多项式拆开)的贡献之和。
我们把 idc 中的小素数贡献取消掉,然后组合出我们想要的积性函数,就得到了 B(∏(∗)p≥nfp)(大素数的贡献)。
再附加上 f 中小素数贡献就可以了,我们之前说过 f(pc) 可以快速求,所以这部分也是简单的。
这样可以得到一个做法,分析一下复杂度:
p≤n∑O(nlogpn)=∫1nO(nlogpn)log−1pdp=O(nlog−1n)
菜!
我们考虑优化这个做法。
如果你取消贡献时从小到大取消,附加贡献从大到小附加,你会发现取消 fp 的时候 g 中 ≤p2 的位置只有 1 和 ≥p 的质数有值,而根据我们之前的分析,f(p) 处的值是一个朴素的低次多项式,也就是说如果我们直接保留质数处的值,在之后的过程中仍然是可处理的。
也就是说我们整除分块的时候 a 可以从 p2 开始枚举了,但你发现这好像还是 Θ(nlogpn) 的啊??
不一样的地方在于需要算的 n/x 至少要满足 ≥p2,然后就能得出 x≤O(n/p2),即合法的 x 至多有 O(n/p2) 个。
重新分析一下复杂度,以 p=n1/4 为界限(注意是以这个为界限分析,代码中不需要区分):
p≤n1/4∑O(nlogpn)+n1/4<p≤n∑O(p2nlogpn)=O(n3/4log−1n)
这就是大名鼎鼎的洲阁筛,用 O(n3/4log−1n) 的时间复杂度和 Θ(n) 的空间复杂度求解几乎任意会遇到的积性函数。
(网络上关于洲阁筛和 min_25 筛的讲解几乎是清一色的 dp,在笔者看来这并不直观,所以本篇采用了另一种角度来引入,虽然在实现上还是得老老实实基于 dp。)
洲阁筛的实现
关于最初始的 Bidc,只能委屈我们用自然数幂和公式来求了,记不住伯努利数就打个高斯消元凑合一下吧。
不要指望洲阁筛有很优秀的写法,当我们决定在一个积性函数上保留质数处的值时,它已经暂时不是一个积性函数了,做法自然也称不上优美。
关于取消和附加贡献的部分,我们需要做一个魔改版的 Dirichlet 卷积(逆卷积),所以肯定不能一个数一个数地取消了,每次要把一个质数幂次集来附加或取消。
正常的跑卷积如果在原来的数组,需要类似于跑 0/1 背包的方式倒序附加。
而倘若是 fp(pk)=(pk)c 这种特殊的形式,那我们就开心了,我们可以直接对该质数跑一个类似完全背包的东西,正序附加,这样一次处理完整个质数幂。
我们发现第一次取消 idc(pk) 时便是这种形式,而第二次附加就是普通函数了。
我们来讨论一些具体细节:
- 最开始的时候我们需要默认每个 idc(1)=0,这是因为我们在取消贡献时少了 1 处的值就可以自然地少减掉一个 p,达到保留这个质数的目的。
- 下面附加贡献的形式上我们需要采用 dp,因为魔改后的数组其实并没有太好的性质,所以我们要粗暴一点来理解它。
- 注意:我们此处仍然粗暴地将 1 处的值视为 0,这是因为若干个 idc 组合之后 1 处的值是混乱的,而我们上一步基于 idc(1)=0。
- 设 hi,x 为 [1,x] 中与 ∏j<ipi 互质的下标的 f 的和(由于不是完全积性函数,我们枚举的是最小质因子),则转移为:
hi,x←hi+1,x+c>0∑f(pic)hi+1,x/pic(有误)
-
- 但是!这描述的是把质数位置也删掉的方程。
- 若考虑上我们保留的质数位置,方程应该长这样:
hi,x←hi+1,x+c>0pic≤x∑f(pic)([c>1]+hi+1,x/pic−[x/pic≥pi]hi+1,pi)=hi+1,x+c>0pic+1≤x∑(f(pic+1)+f(pic)(hi+1,x/pic−hi+1,pi))
大体思路就是你要获得这个位置上的真实值,就必须减掉没有贡献的质数们,我们倒序加入质数,所以此时的 hi+1,pi 就是我们要减掉的。
为什么有 [c>1] 呢,因为你把这个质数的值保留了下来,但质数的次幂没有保留(我们把 1 处的值抠掉就是为了保证统计质数时不会由 1 转移而来造成重复贡献,但质数的次幂并没有被保留,它理应通过 1 转移而来,这就是这个系数的来源。)
这步推导基于若 x∈[pc,pc+1),则 hi+1,x/pic=0(仅能组成 1,而 1 处的值并无贡献),所以把两个柿子拆开。
在真实实现上还是可以比这个破 dp 优美一点点的,可以写在一个一维数组里。
以 P5325 【模板】Min_25 筛 为例,我们需要求 Bf,f(pk)=p2k−pk,n≤1010。
代码。
min_25 筛的搜索实现
min_25 筛通过搜索实现洲阁筛的思想,成功在小数据下暴打了一众筛法。
其实就是把比较复杂的第二部分写成搜索而已,需要提前处理质数的 idc 前缀和。
虽然它的常数很小,但由于其第二部分没有采用优化,实质上复杂度是 O(n1−ϵ) 的,在范围比较大的时候打不过洲阁筛。
并且它只能求单点值,不是块筛很难打得过洲阁筛。
代码等有空了写。
PN 筛
Powerful Number 及其性质
我们称每个质因子的幂次都 ≥2 的数为 Powerful Number,我喜欢把其构成的集合记作 PN(懒得打)。
性质:∣PN∩[1,n]∣=O(n)。
显然 x=a2b3⟺x∈PN,考虑把每个奇数次质因子扔三个到 b 里,剩下的和偶数次的扔到 a 里,枚举 a 可得:
= = Oa=1∑n(a2n)31O(∫1n(x2n)31dx)O(n)
求出 PN∩[1,n] 只需要暴力搜索质因数组合即可。
PN 筛的原理
这是一种对杜教筛的扩展,可以在杜教筛的 Θ(n2/3) 复杂度内求出一些复杂积性函数的块筛。
设我们要求 Bf,我们需要找到一个积性函数 g 满足 g 在 p∈P 处的值与 f 都相同。
设 h=f∗g−∗(g∗h=f),由于 Dirichlet 卷积及其逆运算关于积性函数是封闭的,可知 h 也是积性函数。
那么由于 ∀p∈P,f(p)=g(p)h(1)+g(1)h(p),h(1)=g(1)=1,可得 h(1)=1,h(p)=0。
由于 h 是积性函数,所以只有在 x∈PN 处 h(x) 有可能不为 0。
想一想之前的整除分块式子:
Sf(n)=i∈[1,n]∩PN∑h(i)Sg(n/i)
放下 Sg(n/i) 不管,我们还需要求出 h(x)(x∈PN)。
由于积性函数,其实质就是要求 h(pc),p∈P。
如何通用地计算 h(pc) 呢?
考虑 f(pc)=∑d=0ch(pd)g(pc−d),移项得 h(pc)=f(pc)−∑d=0c−1h(pd)g(pc−d)。
这确实是个极为通用的方法,复杂度为 Θ(lnnnlogp2n),可以运用上文的分析方法分析为 O(n) 的。
(可能是 Θ(lnnn) 的,但我们至少要加上 PN 的个数。)
综上,若 Sg 容易计算,则该算法单点复杂度为 Θ(n),否则使用杜教筛求解,单点和块筛复杂度都变为与杜教筛相同的 Θ(n2/3)。
实现
以 P5325 【模板】Min_25 筛 为例,我们找的 g 可以是 iφ(i)。
code,需要特别注意的一点是,枚举 PN 时最好设定一个 lim=[n/x],不要贸然乘上 x∗pi2,这会爆 long long。
复杂度上的进展
我们终于可以开始介绍开头提到的那个时间复杂度 O(n2/3log−1n) 的简单筛法了。
它其实是对杜教筛的扩展,结合了一部分洲阁筛的思想,可以让 PN 筛和杜教筛包括块筛卷积的复杂度变为原来的 logn1。
算法原理及流程介绍
现在有一个可以杜教筛的函数 f,我们要在 O(n2/3log−1n) 的复杂度内求出其块筛。
考虑以 n1/6 为界,若我们把 [1,n1/6]∩P 的小素数贡献从全局暴力取消掉,则复杂度为:
i=1∑n1/6[i∈P]O(nlogin)=O(n2/3log−1n)
这个 log−1n 是质数密度,而我们的 logpn 为什么可以相当于常数呢?
你其实也能感受到,其实只有常数个 logpn 是 log2n 的级别,剩下大部分 logpn 其实都是常数级别,感性理解是这样,证明的话可以随便放缩一下,不是本文重点。
然后你就惊奇地发现,所有函数的非零值密度都变成了 O(log−1n)(证明可以见 zzt’s blog),此时双曲线法、杜教筛、块筛卷积的复杂度都变为原来的 logn1。
由于我们可以轻易地加回小素数贡献,那你此时先考虑把不含小素数因子的部分算出。
再次考虑以 n2/3 为界,你发现 [1,n2/3] 的有效值部分共 O(n2/3log−1n) 个,所以这部分可以直接爆搜质因子组成(注意不要搜小素数),直接求出其函数值。
然后再考虑 (n2/3,n] 的部分,由于 f 可杜教筛,直接开筛,复杂度分析同带预处理的杜教筛,只不过这次我们要精细实现,观察双曲线法的公式:
S(f∗g)(n)=a≤n∑f(a)Sg(n/a)+b≤n∑g(b)Sf(n/b)−Sf(n)Sg(n)
你求的是不含小素数因子的部分,所以要注意辅助函数 g,h 也要去掉小素数贡献,所以 a,b 只需要枚举有值部分即可,直接用一个数组记录下来哪些位置没有被撤销就好。
最后加回小素数贡献即可。
值得注意的是,该算法空间复杂度是 Θ(n2/3)(因为枚举质因子需要用到 Θ(n2/3) 以内的素数),但其实可以优化到 Θ(n)。
你发现 >n1/2 的质数根本不用枚举,因为该数不用小素数能组合出来的在 n2/3 以内的数只有它自己,而它自己是可以用 idc 凑出来的。
以 μ 为例,我们在筛其他素数的时候故意加上一个 1,然后把块筛和 BI 减掉小素数的贡献相减,这样所有 [1,n2/3] 的值就处理好了,且只用到了 Θ(n) 的空间,再也不怕出题人给一个 1012 杜教筛板子卡你空间了。
由于 BI 本来就是筛 μ 要用到的,所以没什么多余要写的,需要注意尽管 μ=0 也要继续 dfs,因为最后要加上 1。
嘶。写出来是写出来了,就是有点慢,杜教模板跑的没直接杜教暴力快。
看来拆分成两个数组还是有很大的常数优化的。
这个方法可以看作是杜教筛的一个优化空间小技巧吧,其实最开始写这篇就是奔着这个做法来的,没想到惨淡收场,不过中间还是收获了不少的。
参考资料
OI中常用数论函数求和法的简化陈述
OI 积性函数求和现有做法的最后一块拼图
浅谈数论函数求和法:杜教筛
积性函数求和问题的一种筛法
后记