跳转至

狄利克雷双曲线法 & 杜教筛

前置知识:Dirichlet 卷积数论分块

本文介绍 Dirichlet 双曲线法,它可用于在亚线性时间复杂度内计算两个数论函数的 Dirichlet 卷积的前缀和.通过对该算法的分析,可以引申出块筛的概念;据此,本文将进一步介绍块筛卷积的快速计算方法.最后,本文将介绍杜教筛,它可以用于计算两个数论函数在 Dirichlet 卷积意义下的商的前缀和.

Dirichlet 双曲线法

Dirichlet 双曲线法可以用于计算两个数论函数的 Dirichlet 卷积的前缀和.

f,g,h 是数论函数,且 h=fg.那么,利用 Dirichlet 卷积的定义,h 的前缀和

H(n)=k=1nh(k)=k=1nxy=kf(x)g(y).

求和式遍历的点集恰为第一象限(不含坐标轴)中双曲线 xy=n 下方的整点集合.设整点 (x,y) 的权值为 f(x)g(y),那么 H(n) 就是这一权值的和.

如图所示,这一权值和可以通过容斥原理计算:

H(n)=x=1x0f(x)G(nx)+y=1y0F(ny)g(y)F(x0)G(y0).

其中,F,G 分别是 f,g 的前缀和函数,(x0,y0) 是双曲线 xy=n 上任意一个点.表达式中,第一项表示图中绿色区域的权值和,第二项表示图中橙色区域的权值和,第三项则表示两个区域重叠部分的权值和.这个表达式仅含有 x0+y0+1 项.对于合理选择的 (x0,y0),它的计算复杂度显著优于暴力计算 h(n) 的前缀和.这就是 Dirichlet 双曲线法(Dirichlet hyperbola method).

卷积前缀和点值的计算

Dirichlet 双曲线法最基本的应用就是计算前缀和函数的点值 H(n)

如果 F,G 的点值已知(或可以在 O(1) 时间内计算),进而 f,g 的点值也已知,那么 Dirichlet 双曲线法的表达式中每一项都可以在 O(1) 时间内计算,总时间复杂度就等于 O(x0+y0).因为 x0y0=n,所以由均值不等式可知,当 x0=y0=n 时,就得到最低时间复杂度 O(n)

这并非新的结果.在 Dirichlet 双曲线法的表达式中,令 x0>n,就得到

H(n)=x=1nf(x)G(nx).

利用 数论分块 的技巧,当 F,G 的点值已知时,该式可以在 O(n) 时间内计算.这实际上和本节得到的算法是几乎等价的:这两种方法需要的已知信息基本类似,计算的表达式也大致相同.

等价性的说明

细究数论分块的计算过程可以发现,实际计算的表达式为

H(n)=yD(n)(F(ny)F(ny+1))G(y).

其中,D(n)={nx:1xn, xN+} 是数论分块中的关键点集合.

根据数论分块的性质可知,对于 xn,对应分块的高度 y=n/x 各不相同,于是这些分块的长度都是 1,亦即

ny+1+1=x=ny.

因此,这些分块对应的求和为

I1=x=1n(F(x)F(x1))G(nx)=x=1nf(x)G(nx).

剩下的分块,高度 y 遍历 1,2,,y=nn+1.对剩余的和式应用 Abel 变换(即分部积分法的求和形式),就得到

I2=y=1y(F(ny)F(ny+1))G(y)=y=1yF(ny)g(y)F(ny+1)G(y).

数论分块的性质说明,n/nn.由此,分两种情形:

  • n/n=n 时,必然有 y<n.由于它与 x=n 所在块相邻,块高只能恰好相差一,即 y=n1.此时,有

    I2=y=1n1F(ny)g(y)F(n)G(n1)=y=1n1F(ny)g(y)F(n)(G(n)g(n))=y=1nF(ny)g(y)F(n)G(n).
  • n/n>n 时,必然有 y=n.直接代入求和式,就得到

    I2=y=1nF(ny)g(y)F(n)G(n).

综上,除了一个 Abel 变换,数论分块的计算过程实际上就是在计算如下算式:

H(n)=x=1nf(x)G(nx)+y=1nF(ny)g(y)F(n)G(n).

这就是 (x0,y0)=(n,n) 时 Dirichlet 双曲线法的表达式.因此可以说,两种算法的计算过程几乎等价.而且,由于 Dirichlet 双曲线法利用了更多数论分块的性质,避免了朴素数论分块中的不必要计算,常数更小一些.

在处理实际问题时,已知 F,G 点值这一条件可能过强.但是,Dirichlet 双曲线法(或对应的数论分块)其实并不需要 F,G 的全部点值信息.观察求和表达式可知,其实只需要 FG 在数论分块关键点集合

D(n)={nx:1xn, xN+}

处的取值.它既是全体块高的集合,也是全体块的右端点集合.注意,根据数论分块的 性质 可知,这一集合包含所有 1xn 的整数值,因此,已知 F,GD(n) 处的取值就相当于已知 f,g 在所有 1xn 处的取值.这一集合的大小是 Θ(n) 的,所以,计算 H(n) 时,只需要知道 F,G 在一个稀疏集合处的点值信息.这个观察是优化数论函数前缀和计算的关键.

块筛及其卷积

有些时候,h=fg 并非最终需要计算前缀和的函数,而只是中间步骤之一.根据前文分析,为了后续计算,需要求出前缀和函数 H 在集合 D(n) 处的取值.这就称为数论函数 h块筛

Sh(n)={H(x):xD(n)}.

本节讨论 块筛卷积 问题的计算方法:已知 f,g 的块筛时,求它们的 Dirichlet 卷积 h=fg 的块筛.

朴素算法

朴素算法就是将块筛的计算看作是 |D(n)| 次前缀和点值的计算.这样做的总时间复杂度为

O(dD(n)d)=O(x=1nx+x=1nnx)=O(1nxdx+1nnxdx)=O(n3/4).

正是因为块筛是稀疏的,所以整个块筛可以在亚线性时间内求出.

但是,这一算法显然过于暴力.集合 D(n) 中较小的那些元素取值相对稠密,所以块筛中相邻两个前缀和相差并不大,完全可以直接计算卷积 h 的点值,再求它的前缀和.这比起对每个点都单独求一遍前缀和点值要更快.例如,对 x=1,2,,n 分别计算前缀和点值需要

O(x=1nx)=O(n3/4)

的时间;但是,直接计算 h 在这些点处的点值再累和,却只需要 O(n1/2logn) 的时间.尽管如此,如果只知道 f,g 的块筛而不知道更多信息,那么这一思路是无法继续优化该算法复杂度的.这是因为块筛中只包含 xn 处的点值信息,至多只能计算 h1xn 处的点值,而剩余前缀和点值的计算仍然需要 O(n3/4) 的时间.

利用点值信息优化

如果已知信息除了 f,g 的块筛外,还包含它们的更多点值,那么,确实可以改进这一算法的复杂度.实践中,这一算法通常应用于 f,g 的点值可以快速预处理时.

选择 zn.卷积 h 的块筛分为两部分:

  • 计算 fg 的卷积 h1xz 处的点值,再直接累加求和得到 H1xz 处的点值.
  • 对于 xD(n)x>z,通过 Dirichlet 双曲线法计算 Hx 处的点值.

对于一般情形,这样做的时间复杂度为

O(zlogz+dD(n), dzd)=O(zlogz+x=1n/znx)=O(zlogz+1n/znxdx)=O(zlogz+nz).

z=(nlogn)2/3 时,总时间复杂度最小为 O(n2/3(logn)1/3)

当然,Dirichlet 卷积 点值计算 的复杂度与 f,g,h 的性质有关.对于 f,g,h 有特殊性质的情形,最优分点和复杂度均略有不同:

  • 如果 fg 是积性的,那么当 z=(nloglogn)2/3 时,总时间复杂度最小为 O(n2/3(loglogn)1/3)
  • 如果 h 是积性的,那么当 z=n2/3 时,总时间复杂度最小为 O(n2/3)

应用这一优化并不需要 f,g 的全部点值,而只需要它们在 1xz 处的点值.因为这一算法也得到了 h1xz 处的点值,所以当 h 作为中间变量时,同样可以利用 h 的点值优化后续计算过程.数论函数的块筛,再加上它这些点值,就构成了一个增强版的块筛:它们是在 O(n2/3+ε) 时间内计算卷积前缀和的全部必要信息.

快速块筛卷积

前置知识:快速傅里叶变换

注意

初学者可以跳过本节内容.

本节讨论周康阳在 2024 年集训队论文提出的快速块筛卷积算法.它可以在 O(nlog2n) 时间内根据块筛 SfSg 计算出它们卷积 Sh 的取值.这一算法不依赖于额外的点值信息和数论函数的积性,但实现较为复杂.

块筛卷积问题希望计算

h(z)=xy=zf(x)g(y)

在块筛 D(n)={n/t:1tn} 处的前缀和.对于这一问题,单一贡献可以由 (x,y,t) 标记,即将项 f(x)g(y) 累加到 n/t 处前缀和的过程.算法将这些贡献分成若干组处理.

首先,考虑 x>n 的所有点对于这一系列前缀和的贡献,那些 y>n 的贡献类似.因为所有贡献必须满足 xyn/t,即 xytn,所以只需要枚举所有可能的 t,y,利用前缀和技巧以及块筛 Sf 中的信息,就可以在 O(t,y:tyn1)=O(nlogn) 时间内计算出这部分贡献.

然后,考虑 n/tn 的这部分贡献.这一部分同样可以暴力枚举所有可能的 x,y 完成,时间复杂度仍然是 O(x,y:xyn1)=O(nlogn).这一部分实际上得到了函数 hD(n) 的前 n 个点值.

考虑剩下的贡献,即满足 x,ynn/t>n 的贡献.所有贡献必须满足 xytn,亦即 lnx+lnyln(n/t).取正数 S,可以利用 Slnx+SlnySln(n/t) 近似估计这一条件.定义多项式 σf(u)σg(u),使得它们的系数 [uk]σf[uk]σg 分别等于满足 Slnx=kf(x) 的和以及满足 Slny=kg(y) 的和(只考虑 x,yn 的这一部分).利用快速傅里叶变换(FFT)可以得到它们的乘积 σfσg,它的系数 [uk](σfσg) 就表示 Slnx+Slny=kf(x)g(y) 的和.由此,只需要对于每个 D(n) 中剩下的每个 n/t 都找到满足 kSln(n/t) 的最大 k 值,就可以得到这一部分贡献的估计值.

最后,对于这一部分贡献进行误差修正.前文试图利用更强的条件 Slnx+SlnySln(n/t) 去近似 lnx+lnyln(n/t),可能会遗漏部分贡献.这只会发生在 Slnx+Slny>Sln(n/t) 时,所以有

Slnx+1+Slny+1Slnx+Slny>Sln(n/t)Slnx+Slny.

这相当于

xyt(ne2/S,n].

这是一个长度为 O(n/S) 的区间.枚举区间内所有可能的贡献 (x,y,t),逐个检验是否遗漏,就可以完成误差的修正.为了快速枚举区间内所有贡献,可以首先筛出不超过 n 的全部素数,再利用这些素数去除区间中的整数,剩下的因子必然是大于 n 的素数,由此就可以得到区间内所有整数的素因数分解,进而可以快速枚举所有可能的 (x,y,t)

考察最后这一部分贡献计算的时间复杂度.估计贡献时,需要对长度为 Slogn 的多项式做乘法,时间复杂度为 O(Slognlog(Slogn)).误差修正时,预处理素因数分解的时间复杂度为 O(n+(n/S)loglogn),枚举区间内所有贡献的时间复杂度为 O(k(ne2/S,n]d3(k)),此处,d3(n) 表示将 n 分解成三个有序整数乘积的方法数.解析数论的结果1指出:

knd3(k)=nP(logn)+O(n43/96+ε),

其中,P() 是二次多项式.因为前两步时间复杂度已经是 O(nlogn),忽略所有 o(nlogn) 的项,最后这一部分贡献计算的时间复杂度为

O(Slognlog(Slogn)+nSlog2n).

S=n,就得到时间复杂度为 O(nlog2n).这也是该算法的总时间复杂度.

参考实现
  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
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
struct BlockSieve {
  long long n, b;
  std::vector<int> s1, s2;

  BlockSieve(long long _n)
      : n(_n), b(std::sqrt(_n + 0.25l)), s1(b + 1), s2(b + 1) {}

  int& operator[](long long x) { return x <= b ? s1[x] : s2[n / x]; }
};

// Fast block sieve convolution by ZKY.
BlockSieve block_sieve_convolute(const BlockSieve& f, const BlockSieve& g) {
  // Special cases for n <= 3 are not implemented.
  // The upper bound is a loose bound for NTT to work for M = 998244353.
  assert(f.n == g.n && f.n > 3 && f.n < 1e11);
  long long n = f.n, b = f.b;
  BlockSieve h(n);
  std::vector<int> df(b + 1), dg(b + 1);
  for (int i = 1; i <= b; ++i) {
    df[i] = (f.s1[i] + M - f.s1[i - 1]) % M;
    dg[i] = (g.s1[i] + M - g.s1[i - 1]) % M;
  }
  // Prefix sum trick for x or y > sqrt(n).
  for (int t = 1; t <= n / (b + 1); ++t) {
    long long x;
    for (int y = 1; (x = n / (t * y)) > b; ++y) {
      (h.s2[t] += (long long)dg[y] * (f.s2[n / x] + M - f.s1[b]) % M) %= M;
      (h.s2[t] += (long long)df[y] * (g.s2[n / x] + M - g.s1[b]) % M) %= M;
    }
  }
  // Brutal force for small x*y.
  for (int x = 1; x <= b; ++x) {
    for (int y = 1; x * y <= b; ++y) {
      (h.s1[x * y] += (long long)df[x] * dg[y] % M) %= M;
    }
  }
  for (int i = 1; i <= b; ++i) {
    (h.s1[i] += h.s1[i - 1]) %= M;
  }
  // Approximation.
  std::vector<int> id1(b + 1);
  for (int i = 1; i <= b; ++i) {
    id1[i] = std::ceil(b * std::log(i));
  }
  std::vector<long double> id2(b + 1);
  for (int i = 1; i <= b; ++i) {
    id2[i] = b * std::log(1.0l * n / i);
  }
  std::vector<int> sf(id1[b] + 1), sg(id1[b] + 1);
  for (int i = 1; i <= b; ++i) {
    (sf[id1[i]] += df[i]) %= M;
    (sg[id1[i]] += dg[i]) %= M;
  }
  auto sh = ntt_mul(sf, sg);
  for (int i = 1; i < (int)sh.size(); ++i) {
    (sh[i] += sh[i - 1]) %= M;
  }
  for (int t = 1; t <= n / (b + 1); ++t) {
    int k = std::min((int)std::floor(id2[t]), (int)sh.size() - 1);
    (h.s2[t] += sh[k]) %= M;
  }
  // Correction.
  auto primes = get_primes(b);  // Obtain primes not exceeding b.
  long long l = n * std::exp(-2.0l / b);
  std::vector<long long> co(n - l + 1);
  std::iota(co.begin(), co.end(), l);
  std::vector<std::vector<std::pair<long long, int>>> fac(n - l + 1);
  for (int p : primes) {
    for (int i = (l + p - 1) / p * p - l; i <= n - l; i += p) {
      int e = 0;
      for (; co[i] % p == 0; co[i] /= p) ++e;
      fac[i].emplace_back(p, e);
    }
  }
  for (int i = 0; i <= n - l; ++i) {
    if (co[i] != 1) {
      fac[i].emplace_back(co[i], 1);
    }
  }
  long long num = 0;
  std::vector<std::pair<long long, int>> cur;
  auto dfs = [&](auto&& dfs, int k, int x, int y) {
    if (k == (int)cur.size()) {
      auto t = num / x / y;
      if (t && t <= n / (b + 1) && id1[x] + id1[y] > id2[t]) {
        (h.s2[t] += (long long)df[x] * dg[y] % M) %= M;
      }
      return;
    }
    auto p = cur[k].first;
    auto e = cur[k].second;
    long long nx = x;
    for (int e1 = 0; e1 <= e; ++e1) {
      if (e1) {
        nx *= p;
        if (nx > b) break;
      }
      long long ny = y;
      for (int e2 = 0; e1 + e2 <= e; ++e2) {
        if (e2) {
          ny *= p;
          if (ny > b) break;
        }
        dfs(dfs, k + 1, nx, ny);
      }
    }
  };
  for (int i = 0; i <= n - l; ++i) {
    num = l + i;
    cur = fac[i];
    dfs(dfs, 0, 1, 1);
  }
  return h;
}

杜教筛

前文讨论了如何计算数论函数 Dirichlet 卷积的前缀和.本节考虑它的逆过程:设 fg=h,且 f,h 已知,计算 g 的前缀和

G(n)=x=1ng(x).

换句话说,本节考虑两个数论函数 Dirichlet 卷积意义的商的前缀和计算.本节总是假设 f(1)0,以保证 f 可逆.

为此,在 Dirichlet 双曲线法表达式中令 x0>n,就得到

H(n)=x=1nf(x)G(nx).

直接解出 G(n),就得到

G(n)=1f(1)(H(n)x=2nf(x)G(nx)).

这就是杜教筛的表达式.实际上对于 x01,总有

G(n)=1f(1)(H(n)x=2x0f(x)G(nx)y=1y0F(ny)g(y)+F(x0)G(y0)).

无论是哪种形式,它都是一个关于 G(n) 的递推关系式.为计算 G(n) 的取值,需要计算 GD(n){n} 处的取值.因为 D(n) 具有 递归结构,即对于 mD(n),总是有 D(m)D(n),所以在整个递归计算过程中,只需要计算 GD(n) 中元素处的取值各一次.换句话说,计算 G(n) 时,实际上得到了 g 的块筛 Sg(n)

具体实现时,可以采用递归的方法,并采用记忆化避免重复计算.当然,也可以采用迭代的方法,从小到大依次计算 D(n) 中每个点处 G 的取值.此时,表达式中的求和式既可以采用数论分块计算,也可以采用 Dirichlet 双曲线法计算.

这些实现的复杂度是相同的.由于杜教筛总是得到块筛,所以杜教筛的复杂度其实相当于计算块筛的复杂度.如果已知信息只有 F,H 的块筛,那么杜教筛的复杂度就是 O(n3/4);如果对于某个 zn,可以在 T0(z) 时间内预处理出 g1xz 处的点值,那么杜教筛的复杂度就是

O(T0(z)+nz).

g 是积性函数时,可以应用线性筛,即 T0(z)=Θ(z),所以最优需要预处理到 z=n2/3 处,总时间复杂度为 O(n2/3);对于更一般的情形,总时间复杂度则为 O(n2/3(logn)1/3).这些都和块筛部分的分析完全一致.

递归实现时,不使用记忆化将导致复杂度错误

杜教筛的表达式中,计算 G(n) 的值,需要依赖于 D(n){n}G 的取值.此处保证复杂度的关键在于注意到集合 D(n) 的递归结构:当 mD(n) 时,D(m)D(n).利用这一性质,可以通过记忆化加速计算.利用记忆化后,如前文所示,算法的复杂度是 O(n3/4) 的.此处要说明的是,如果不使用记忆化,算法复杂度是错误的.

设不使用记忆化时,应用递归方法计算 G(n) 的复杂度是 T(n).那么,有

T(n)=Θ(n)+dD(n), dnT(d)=Θ(n)+x=1n/n1T(x)+x=2nT(nx).

利用类似 主定理 的证明思路,可以说明,最后一项主导了该式的增长,且 T(n)Θ(nα),其中,α1.73ζ(α)=2 的根.

应用杜教筛计算数论函数 g 前缀和时,关键在于找到合适的 f,h 使得 h=fgf,h 的块筛都容易计算.在有些时候,这样的 f,h 是显然的;在另一些时候,需要利用 Dirichlet 卷积的性质或通过计算相应的 Dirichlet 生成函数来找到相应的分解.后文的例题展示了这些情形.

例题

本节介绍几道计算数论函数前缀和的题目.

AtCoder Regular Contest 116 C - Multiple Sequences

给定正整数 NM.需要计算有多少长度为 N 的序列 A 满足 1AiMAi 整除 Ai+1.答案对 998244353 取模.数据范围:1N,M2×105

解答

设长度为 nAn=m 的序列数目为 fn(m).那么,最终的答案就是 m=1MfN(m).要计算 fn(m),考虑应用动态规划的方法.动态转移方程为:

fn(m)=kmfn1(k).

利用 Dirichlet 卷积的记号,它可以记作 fn=fn11,其中,1 是常值数论函数.注意到 f1=1,归纳可知,fn=1n.也就是说,fnn 个常值函数的卷积.最后的答案就是 fN 的前缀和.由于过程中只涉及积性函数,利用前文介绍的 Dirichlet 卷积前缀和的计算方法,单次卷积前缀和的计算只需要 O(M2/3) 的时间;再利用 快速幂 的方法,只需要计算 O(logN) 次卷积前缀和即可得到 fN 的卷积前缀和的值.整体时间复杂度为 O(M2/3logN)

参考实现
 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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <cassert>
#include <cmath>
#include <iostream>
#include <vector>

constexpr int M = 998244353;

// Compute the Dirichlet convolution h = f * g.
// Assume that h is multiplicative.
auto dirichlet_convolute(const std::vector<int>& f, const std::vector<int>& g) {
  int n = f.size() - 1;
  std::vector<int> h(n + 1), primes, rem(n + 1), lpf(n + 1);
  std::vector<bool> vis(n + 1);
  h[1] = 1;
  for (int x = 2; x <= n; ++x) {
    if (!vis[x]) {
      primes.push_back(x);
      rem[x] = 1;
      lpf[x] = x;
    }
    for (int p : primes) {
      if (x * p > n) break;
      vis[x * p] = true;
      rem[x * p] = x % p ? x : rem[x];
      lpf[x * p] = p;
      if (x % p == 0) break;
    }
    if (rem[x] == 1) {  // prime powers.
      for (int k = x; k; k /= lpf[x]) {
        (h[x] += (long long)f[k] * g[x / k] % M) %= M;
      }
    } else {  // other cases.
      h[x] = (long long)h[rem[x]] * h[x / rem[x]] % M;
    }
  }
  return h;
}

// Block sieve convolution for multiplicative functions.
struct BlockSieve {
  int n, z;
  std::vector<int> f, F, F2;

  BlockSieve(int _n, int _z)
      : n(_n), z(_z), f(_z + 1), F(_z + 1), F2(_n / _z + 1) {}

  int sum(int x) const { return x <= z ? F[x] : F2[n / x]; }

  BlockSieve operator*(const BlockSieve& oth) const {
    assert(n == oth.n && z == oth.z);
    BlockSieve res(n, z);
    res.f = dirichlet_convolute(f, oth.f);
    for (int i = 1; i <= z; ++i) {
      res.F[i] = (res.F[i - 1] + res.f[i]) % M;
    }
    for (int i = 1; i <= n / z; ++i) {
      int k = n / i;
      int sqr = std::sqrt(k + 0.25l);
      for (int x = 1; x <= sqr; ++x) {
        (res.F2[i] += (long long)f[x] * oth.sum(k / x) % M) %= M;
        (res.F2[i] += (long long)sum(k / x) * oth.f[x] % M) %= M;
      }
      (res.F2[i] += (M - (long long)sum(sqr) * oth.sum(sqr) % M)) %= M;
    }
    return res;
  }
};

int main() {
  int n, k;
  std::cin >> k >> n;
  int z = std::pow(n, 2.0l / 3);
  // Initialization of inputs.
  BlockSieve po(n, z);
  for (int i = 1; i <= z; ++i) {
    po.f[i] = 1;
    po.F[i] = i;
  }
  for (int i = 1; i <= n / z; ++i) {
    po.F2[i] = n / i;
  }
  BlockSieve res(n, z);
  res.f[1] = 1;
  for (int i = 1; i <= z; ++i) {
    res.F[i] = 1;
  }
  for (int i = 1; i <= n / z; ++i) {
    res.F2[i] = 1;
  }
  // Binary exponentiation.
  for (; k; k >>= 1) {
    if (k & 1) res = res * po;
    po = po * po;
  }
  std::cout << res.sum(n) << std::endl;
  return 0;
}
P4213【模板】杜教筛(Sum)

μφ 分别是莫比乌斯函数和欧拉函数.求 S1(n)=i=1nμ(i)S2(n)=i=1nφ(i) 的值.数据范围:1n<231.

解答

注意到 Dirichlet 卷积关系式:

ε=μ1, id=φ1.

其中,ε(n)=[n=1] 是 Dirichlet 卷积的单位元函数,id(n)=n 是恒等函数,1(n)=1 是常值函数.因为这三个函数的前缀和都可以在 O(1) 时间内计算且都是积性函数,所以,利用前文介绍的杜教筛方法就可以在 O(n2/3) 时间内计算.

对于欧拉函数的前缀和的计算,另一种方法是利用 莫比乌斯反演.注意到

S2(n)=i=1nφ(i)=i=1nj=1i[ij]=i=1nj=1idμ(d)[di][dj]=dμ(d)12nd(nd+1).

在数论分块的过程中,需要 μ(d) 的前缀和,而这可以通过杜教筛预处理出来.时间复杂度仍然是 O(n2/3)

参考实现
 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」简单的数学题

给定 p,n,计算

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

数据范围:n10105×108p1.1×109p 是质数.

解答

利用 欧拉函数 的性质,做如下反演:

T(n)=i=1nj=1nijgcd(i,j)=i=1nj=1nijdφ(d)[di][dj]=dφ(d)(i=1n/did)(j=1n/djd)=dd2φ(d)F(nd)2.

其中,F(n)=12n(n+1).该式可以通过数论分块计算,但需要预处理出 d2φ(d) 的前缀和.

为此,可以利用杜教筛.记 f(n)=(id2φ)(n)S(n)=i=1nf(i).应用杜教筛的关键是构造出函数 g,使得 fgg 都可以快速求和.前文已经讨论了用杜教筛预处理 φ 前缀和的方法,这只需要利用关系:id=φ1.相较于 φ,这里的 f 多了一个 id2.由于 id 是完全积性函数,利用 Dirichlet 卷积的 性质,只需要类似地将每一项都乘以 id2,就得到

id3=fid2.

因为 id2(n)=n2id3(n)=n3 的前缀和都可以在 O(1) 时间内计算,f 的前缀和就可以在 O(n2/3) 时间内预处理得到.再加上数论分块,整体时间复杂度仍然是 O(n2/3)

另一种将类似积性函数表示为两函数之(在 Dirichlet 卷积意义下的)商的方法是利用 Dirichlet 生成函数

参考实现
 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;
}

习题

参考资料与注释