跳转至

杜教筛

杜教筛被用于处理一类数论函数的前缀和问题。对于数论函数 f,杜教筛可以在低于线性时间的复杂度内计算 S(n)=i=1nf(i)

算法思想

我们想办法构造一个 S(n) 关于 S(ni) 的递推式。

对于任意一个数论函数 g,必满足:

i=1n(fg)(i)=i=1ndig(d)f(id)=i=1ng(i)S(ni)

其中 fg 为数论函数 fg狄利克雷卷积

略证

g(d)f(id) 就是对所有 in 的做贡献,因此变换枚举顺序,枚举 d,id(分别对应新的 i,j

i=1ndig(d)f(id)=i=1nj=1n/ig(i)f(j)=i=1ng(i)j=1n/if(j)=i=1ng(i)S(ni)

那么可以得到递推式:

g(1)S(n)=i=1ng(i)S(ni)i=2ng(i)S(ni)=i=1n(fg)(i)i=2ng(i)S(ni)

假如我们可以构造恰当的数论函数 g 使得:

  1. 可以快速计算 i=1n(fg)(i)
  2. 可以快速计算 g 的前缀和,以用数论分块求解 i=2ng(i)S(ni)

则我们可以在较短时间内求得 g(1)S(n)

注意

无论数论函数 f 是否为积性函数,只要可以构造出恰当的数论函数 g, 便都可以考虑用杜教筛求 f 的前缀和。

如考虑 f(n)=iφ(n), 显然 f 不是积性函数,但可取 g(n)=1, 从而:

k=1n(fg)(k)=in(n+1)2

计算 km(fg)(k)kmg(k) 的时间复杂度均为 O(1), 故可以考虑使用杜教筛。

时间复杂度

R(n)={nk:k=2,3,,n}, 我们有如下引理:

引理

对任意的 mR(n),我们有 R(m)R(n).

证明

m=nx, 任取 myR(m), 由整除分块/数论分块的 引理 1 可知:

my=nxyR(n).

设计算 i=1n(fg)(i)i=1ng(i) 的时间复杂度均为 O(1). 由引理可知:使用记忆化之后,每个 S(k) (kR(n)) 均只会计算一次。

由整除分块/数论分块的 引理 2 可知 |R(n)|=2n+Θ(1). 设计算 S(n) 的时间复杂度为 T(n), 则:

T(n)=kR(n)T(k)=Θ(n)+k=1nO(k)+k=2nO(nk)=O(0n(x+nx)dx)=O(n3/4).

若我们可以预处理出一部分 S(k), 其中 k=1,2,,mmn. 设预处理的时间复杂度为 T0(m), 则此时的 T(n) 为:

T(n)=T0(m)+kR(n);k>mT(k)=T0(m)+k=1n/mO(nk)=O(T0(m)+0n/mnxdx)=O(T0(m)+nm).

T0(m)=O(m)(如线性筛),由均值不等式可知:当 m=Θ(n2/3) 时,T(n) 取得最小值 O(n2/3).

伪证一例

设计算 S(n) 的复杂度为 T(n), 则有:

T(n)=Θ(n)+O(i=2nT(ni)) T(ni)=Θ(ni)+O(j=2n/iT(nij))=O(ni)
Note

O(j=2n/iT(nij)) 视作高阶无穷小,从而可以舍去。

故:

T(n)=Θ(n)+O(i=2nni)=O(i=1nni)=O(0nnxdx)=O(n3/4)
Bug

问题在于「视作高阶无穷小,从而可以舍去」这一处。我们将 T(ni) 代入 T(n) 的式子里,有:

T(n)=Θ(n)+O(i=2nni)+O(i=2nj=2n/iT(nij))=O(n+0nnxdx)+O(i=2nj=2n/iT(nij))=O(n3/4)+O(i=2nj=2n/iT(nij))

我们考虑 i=2nj=2n/iT(nij) 这部分,不难发现:

i=2nj=2n/iT(nij)=Ω(i=2nT(nini1))=Ω(i=2nT(ni))

由于没有引入记忆化,因此上式中的 T(ni) 仍然是 Ω((ni)1/4) 的,进而所谓的「高阶无穷小」部分是不可以舍去的。

实际上杜教筛的亚线性时间复杂度是由记忆化保证的。只有使用了记忆化之后才能保证不会出现那个多重求和的项。

例题

问题一

P4213【模板】杜教筛(Sum)

S1(n)=i=1nμ(i)S2(n)=i=1nφ(i) 的值,1n<231.

我们知道:

ϵ=[n=1]=μ1=dnμ(d)S1(n)=i=1nϵ(i)i=2nS1(ni)=1i=2nS1(ni)

时间复杂度的推导见 时间复杂度 一节。

对于较大的值,需要用 map/unordered_map 存下其对应的值,方便以后使用时直接使用之前计算的结果。

当然也可以用杜教筛求出 φ(x) 的前缀和,但是更好的方法是应用莫比乌斯反演。

i=1nj=1n[gcd(i,j)=1]=i=1nj=1ndi,djμ(d)=d=1nμ(d)nd2

由于题目所求的是 i=1nj=1i[gcd(i,j)=1], 所以我们排除掉 i=1,j=1 的情况,并将结果除以 2 即可。

观察到,只需求出莫比乌斯函数的前缀和,就可以快速计算出欧拉函数的前缀和了。时间复杂度 O(n23).

S(n)=i=1nφ(i).

同样的,φ1=id, 从而:

S(n)=i=1nii=2nS(ni)=12n(n+1)i=2nS(ni)
代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <cstring>
#include <iostream>
#include <map>
using namespace std;
constexpr int MAXN = 2000010;
long long T, n, pri[MAXN], cur, mu[MAXN], sum_mu[MAXN];
bool vis[MAXN];
map<long long, long long> mp_mu;

long long S_mu(long long x) {  // 求mu的前缀和
  if (x < MAXN) return sum_mu[x];
  if (mp_mu[x]) return mp_mu[x];  // 如果map中已有该大小的mu值,则可直接返回
  long long ret = (long long)1;
  for (long long i = 2, j; i <= x; i = j + 1) {
    j = x / (x / i);
    ret -= S_mu(x / i) * (j - i + 1);
  }
  return mp_mu[x] = ret;  // 路径压缩,方便下次计算
}

long long S_phi(long long x) {  // 求phi的前缀和
  long long ret = (long long)0;
  long long j;
  for (long long i = 1; i <= x; i = j + 1) {
    j = x / (x / i);
    ret += (S_mu(j) - S_mu(i - 1)) * (x / i) * (x / i);
  }
  return (ret - 1) / 2 + 1;
}

int main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> T;
  mu[1] = 1;
  for (int i = 2; i < MAXN; i++) {  // 线性筛预处理mu数组
    if (!vis[i]) {
      pri[++cur] = i;
      mu[i] = -1;
    }
    for (int j = 1; j <= cur && i * pri[j] < MAXN; j++) {
      vis[i * pri[j]] = true;
      if (i % pri[j])
        mu[i * pri[j]] = -mu[i];
      else {
        mu[i * pri[j]] = 0;
        break;
      }
    }
  }
  for (int i = 1; i < MAXN; i++)
    sum_mu[i] = sum_mu[i - 1] + mu[i];  // 求mu数组前缀和
  while (T--) {
    cin >> n;
    cout << S_phi(n) << ' ' << S_mu(n) << '\n';
  }
  return 0;
}

问题二

「LuoguP3768」简单的数学题

大意:求

i=1nj=1nijgcd(i,j)(modp)

其中 n1010,5×108p1.1×109,p 是质数。

利用 φ1=id 做莫比乌斯反演化为:

d=1nF2(nd)d2φ(d)

其中 F(n)=12n(n+1)

d=1nF(nd)2 做数论分块,d2φ(d) 的前缀和用杜教筛处理:

f(n)=n2φ(n)=(id2φ)(n)S(n)=i=1nf(i)=i=1n(id2φ)(i)

需要构造积性函数 g,使得 f×gg 能快速求和。

单纯的 φ 的前缀和可以用 φ1 的杜教筛处理,但是这里的 f 多了一个 id2,那么我们就卷一个 id2 上去,让它变成常数:

S(n)=i=1n((id2φ)id2)(i)i=2nid2(i)S(ni)

化一下卷积:

((id2φ)id2)(i)=di(id2φ)(d)id2(id)=did2φ(d)(id)2=dii2φ(d)=i2diφ(d)=i2(φ1)(i)=i3

再化一下 S(n):

S(n)=i=1n((id2φ)id2)(i)i=2nid2(i)S(ni)=i=1ni3i=2ni2S(ni)=(12n(n+1))2i=2ni2S(ni)

分块求解即可。

代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 不要为了省什么内存把数组开小,会卡80
#include <cmath>
#include <iostream>
#include <map>
using namespace std;
constexpr int N = 5e6, NP = 5e6, SZ = N;
long long n, P, inv2, inv6, s[N];
int phi[N], p[NP], cnt, pn;
bool bp[N];
map<long long, long long> s_map;

long long ksm(long long a, long long m) {  // 求逆元用
  long long res = 1;
  while (m) {
    if (m & 1) res = res * a % P;
    a = a * a % P, m >>= 1;
  }
  return res;
}

void prime_work(int k) {  // 线性筛phi,s
  bp[0] = bp[1] = true, phi[1] = 1;
  for (int i = 2; i <= k; i++) {
    if (!bp[i]) p[++cnt] = i, phi[i] = i - 1;
    for (int j = 1; j <= cnt && i * p[j] <= k; j++) {
      bp[i * p[j]] = true;
      if (i % p[j] == 0) {
        phi[i * p[j]] = phi[i] * p[j];
        break;
      } else
        phi[i * p[j]] = phi[i] * phi[p[j]];
    }
  }
  for (int i = 1; i <= k; i++)
    s[i] = (1ll * i * i % P * phi[i] % P + s[i - 1]) % P;
}

long long s3(long long k) {  // 立方和
  return k %= P, (k * (k + 1) / 2) % P * ((k * (k + 1) / 2) % P) % P;
}

long long s2(long long k) {  // 平方和
  return k %= P, k * (k + 1) % P * (k * 2 + 1) % P * inv6 % P;
}

long long calc(long long k) {  // 计算S(k)
  if (k <= pn) return s[k];
  if (s_map[k]) return s_map[k];  // 对于超过pn的用map离散存储
  long long res = s3(k), pre = 1, cur;
  for (long long i = 2, j; i <= k; i = j + 1)
    j = k / (k / i), cur = s2(j),
    res = (res - calc(k / i) * (cur - pre) % P) % P, pre = cur;
  return s_map[k] = (res + P) % P;
}

long long solve() {
  long long res = 0, pre = 0, cur;
  for (long long i = 1, j; i <= n; i = j + 1) {
    j = n / (n / i);
    cur = calc(j);
    res = (res + (s3(n / i) * (cur - pre)) % P) % P;
    pre = cur;
  }
  return (res + P) % P;
}

int main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> P >> n;
  inv2 = ksm(2, P - 2), inv6 = ksm(6, P - 2);
  pn = (long long)pow(n, 0.666667);  // n^(2/3)
  prime_work(pn);
  cout << solve();
  return 0;
}

参考资料

  1. 任之洲,2016,《积性函数求和的几种方法》,2016 年信息学奥林匹克中国国家队候选队员论文
  2. 杜教筛的时空复杂度分析 - riteme.site