Processing math: 100%

杜教篩

例題

我們先來看一道題目。

S(n)=ni=1μ(i) 的值。(1n1010

由於 n 太大了,即使用線性篩也會 TLE。但是先回想一個引理:

μ1=dnμ(d)=[n=1]

對它取前綴和,會發現它很好算,也就是 ni=1μ1=ni=1[i=1]=1

因此若可以找到 μ1 的前綴和,還有 μ 的前綴和之間的關係,說不定就能夠加速計算 μ 的前綴和。

而這就是大名鼎鼎的杜教篩。

杜教篩

杜教篩適用於以下情況:

給定函數 f,要求 S(n)=ni=1f(n)

定理:

g(1)×S(n)=ni=1(gf)(i)ni=2g(i)×S(ni)

證明如下:

ni=1(gf)(i)=ni=1dig(d)×f(id)根據 Dirichlet 捲積的定義展開=nd=1ndi=1g(d)×f(i)套路,將 sigma 的順序交換,並改變枚舉的變數=nd=1g(d)ndi=1f(i)g(d) 與 i 無關,將其移到前面=nd=1g(d)×S(nd)將 f 換成前綴和=g(1)×S(n)+nd=2g(d)×S(nd)將 d=1 的情況獨立出來

移項後,即可得到:

g(1)×S(n)=ni=1(gf)(i)ni=2g(i)×S(ni)

至此可以發現,若我們有個快速計算 RHS 的方法,那只要再除掉 g(1),就能夠快速得到 S(n) 了。

當然的,g(1) 必須是非零,這點後面不再贅述。




寫成 pseudo code 可以得到:

def Find_S(n): ans = sum of convolution(g, f)(1...n) for i in range(2, n): ans = ans - g(i) * Find_S(n / i) return ans / g(1)

注意到,for 迴圈的部分,只要我們能夠快速求出 g 的前綴,那麼就可以數論分塊。

Sgg 的前綴和。

def Find_S(n): ans = sum of convolution(g, f)(1...n) i = 1 while i <= n: r = n / (n / i) ans = ans - (Sg(r) - Sg(i - 1)) * Find_S(n / i) i = r + 1 return ans / g(1)

因此,杜教篩的流程其實就是不斷透過上述定理還有數論分塊,來進行遞迴計算。




Find_S 是一個遞迴函式,接下來我們會討論在遞迴中,實際會走訪的狀態數是多少。

首先我們需要一個引理:

對任意正整數 nab,都有 nab=nab

證明:

na=x+y,其中 x 是非負整數、y 是小數部分。

LHS=xb,RHS=x+yb

假設 LHSRHS,那麼 LHS<RHS

因為兩者皆是非負整數,所以在相異的假設下,至少相差一,由此可得:

xbx+yb1=x+ybbxx+yb

然而 y 是小數部分、b 是正整數,也就是 y<1yb<0,得到 x 比自己還要小,產生矛盾。

因此由反證法可知引理是正確的。

接著我們需要定義一種集合:

R(n)={ni: i=1,2,,n}

翻譯成白話,R(n) 就是 n 除以所有比其小的正整數並取下高斯後,那些數字們所組成的集合。

而我們在數論分塊那章節時就知道,這個集合的大小是 O(n)

有了這個定義,我們就可以來看下面這個定理:

mR(n),則 R(m)R(n)

證明:

根據定義,mR(n) 代表存在某個正整數 i (1in) 使得 ni=m

因此

R(m)={nij: j=1,2,,m}

利用前述引理,可以簡化成:

R(m)={nij: j=1,2,,m}

由此可見 R(m)R(n)

有了這份定理,我們回顧 Find_S

Find_S 是遞迴函數,要計算 Find_S(n) 會需要遞迴到所有 i=2nFind_S(n / i)

注意到,也就是所有 iR(n){1}Find_S(n / i)

m=ni

計算 Find_S(m) 時也會需要用到所有 jR(m){1}Find_S(m / j)

根據定理可知,所需要的那些 j,其實也會屬於 R(n){1}

也就是說,在計算 Find_S(n) 的遞迴中,所遇到的任何 Find_S(x)x 都會屬於 R(n)

因為 |R(n)|=O(n),所以遞迴時所需的不同狀態數只有 O(n) 個,可以記憶化來計算。

def Find_S(n): if Find_S(n) has been visited: return ans[n] ans[n] = sum of convolution(g, f)(1...n) i = 1 while i <= n: r = n / (n / i) ans[n] = ans[n] - (Sg(r) - Sg(i - 1)) * Find_S(n / i) i = r + 1 ans[n] = ans[n] / g(1) return ans[n]

時間複雜度

上個章節討論了杜教篩的原理,並證明了會遞迴到的狀態只有 O(n),這裡我們要來討論時間複雜度。

在此我們有兩個假設:

  • gf 的前綴和很好計算
  • g 的前綴和很好計算

方便起見,我們把它們當成可以 O(1) 得到。

那麼問題就只剩下計算 ni=2g(i)×S(ni) 的時間複雜度了。

我們需要所有 iR(n)Find_S(n / i)

而計算 Find_S(x) 會需要用到所有 jR(x)Find_S(x / j),也就是需要 O(x) 個狀態。

合併下來可知時間複雜度是:

xR(n)x=nx=1(x+nx)按照 n 分成兩邊=n1(x+nx)dx使用微積分來估算=(23×x32|n1)+(2×n×x12|n1)透過微積分運算得到=O(n34)

因此,純粹使用杜教篩的話,時間複雜度為 O(n34)




更進一步,我們可以選定某個數字 kn,將 S[1]S[k] 使用線性篩來得到。

因此在 Find_S 中,我們只需要計算 R(n) 中大於 k 的元素即可。

因此時間複雜度會降為:

xR(n)x>kx=nkx=1nx=nk1nxdx=2×n×x12|nk1=O(nk)

結合線性篩,總時間複雜度是:O(k+nk)

最佳情況發生在 k=nk,也就是 k=n23 的時候。

因此杜教篩的時間複雜度,就進一步降低為 O(n23) 了。

例題

現在我們重新回顧最開始所講的例題。

S(n)=ni=1μ(i) 的值。(1n1010

由杜教篩的定理可知,我們需要找到一個函數 g,使得:

  • gμ 的前綴和很好計算
  • g 的前綴和很好計算

而我們知道 (μ1)(n)=[n=1],其前綴和很顯然,就是 ni=1[i=1]=1

1(n) 的前綴和也是顯然,就是 ni=11(i)=n

因此可以將 g 函數選為 1 函數,因此可得:

S(n)=1ni=2S(ni)

利用這個遞迴式,寫成程式碼如下:

#include <bits/stdc++.h> using namespace std; using ll = long long; ll n; ll lim; // n^(2/3) unordered_map<ll, ll> s; // prefix sum of mu vector<ll> psm; // prefix sum of mu void LinearSieve() { vector<int> primes; vector<int> pf(lim); // prime factor for (int i = 2; i < lim; ++i) { if (pf[i] == 0) { primes.push_back(i); pf[i] = i; } for (auto p : primes) { if (i * p >= lim) break; pf[i * p] = p; if (i % p == 0) break; } } vector<int> mu(lim); psm.resize(lim); mu[1] = psm[1] = 1; for (int i = 2; i < lim; ++i) { if (i % (pf[i] * pf[i]) != 0) mu[i] = -mu[i / pf[i]]; psm[i] = psm[i - 1] + mu[i]; } } ll Find_S(ll n) { if (n < lim) return psm[n]; if (s.find(n) != s.end()) return s[n]; s[n] = 1; for (ll i = 2, r = 0; i <= n; i = r + 1) { r = n / (n / i); s[n] -= (r - i + 1) * Find_S(n / i); } return s[n]; } int main() { cin >> n; while ((__int128)lim * lim * lim <= (__int128)n * n) ++lim; LinearSieve(); cout << Find_S(n) << "\n"; }

或者也可以使用迭代來實作:

#include <bits/stdc++.h> using namespace std; using ll = long long; int lim; unordered_map<ll, ll> s; // prefix sum of mu vector<ll> psm; // prefix sum of mu void LinearSieve() { vector<int> primes; vector<int> pf(lim); // prime factor for (int i = 2; i < lim; ++i) { if (pf[i] == 0) { primes.push_back(i); pf[i] = i; } for (auto p : primes) { if (i * p >= lim) break; pf[i * p] = p; if (i % p == 0) break; } } vector<int> mu(lim); psm.resize(lim); mu[1] = psm[1] = 1; for (int i = 2; i < lim; ++i) { if (i % (pf[i] * pf[i]) != 0) mu[i] = -mu[i / pf[i]]; psm[i] = psm[i - 1] + mu[i]; } } int main() { ll n; cin >> n; while ((__int128)lim * lim * lim <= (__int128)n * n) ++lim; LinearSieve(); vector<ll> v; for (ll i = 1, r = 0; i <= n; i = r + 1) { r = n / (n / i); v.push_back(i); } reverse(v.begin(), v.end()); for (auto i : v) { if (n / i < lim) continue; ll m = n / i; s[m] = 1; for (ll j = 2, r = 0; j <= m; j = r + 1) { r = m / (m / j); if (m / j < lim) s[m] -= (r - j + 1) * psm[m / j]; else s[m] -= (r - j + 1) * s[m / j]; } } if (n == 1) cout << psm[1] << "\n"; else cout << s[n] << "\n"; }



接著來看第二道例題:

S(n)=ni=1φ(i) 的值。(1n1010

同樣的,要使用杜教篩,就必須找到一個 g 使得 gφg 本身的前綴都很容易計算。

回想數論函數章節所講的引理,選擇 1 當作 g 的話,可以得到:

  • 1φ=idni=1id(i)=n×(n+1)2
  • ni=11(i)=n

兩者的確都很容易得到。

代入杜教篩的定理中,可以得到:

S(n)=n×(n+1)2ni=2S(ni)

寫成程式碼如下:

#include <bits/stdc++.h> using namespace std; using ll = long long; ll n; int lim; // n^(2/3) vector<__int128> psp; // prefix sum of phi void LinearSieve() { vector<int> primes; vector<int> pf(lim); // prime factor for (int i = 2; i < lim; ++i) { if (pf[i] == 0) { primes.push_back(i); pf[i] = i; } for (auto p : primes) { if (i * p >= lim) break; pf[i * p] = p; if (i % p == 0) break; } } vector<int> phi(lim); psp.resize(lim); phi[1] = psp[1] = 1; for (int i = 2; i < lim; ++i) { int now = 1, res = i; while (res % pf[i] == 0) { res /= pf[i]; now *= pf[i]; } phi[i] = phi[res] * now / pf[i] * (pf[i] - 1); psp[i] = psp[i - 1] + phi[i]; } } unordered_map<ll, __int128> s; __int128 Find_S(ll n) { if (n < lim) return psp[n]; if (s.find(n) != s.end()) return s[n]; s[n] = (__int128)n * (n + 1) / 2; for (ll i = 2, r = 0; i <= n; i = r + 1) { r = n / (n / i); s[n] -= (r - i + 1) * Find_S(n / i); } return s[n]; } int main() { cin >> n; while ((__int128)lim * lim * lim <= (__int128)n * n) ++lim; LinearSieve(); __int128 x = Find_S(n); string ans = ""; while (x) ans.push_back(x % 10 + '0'), x /= 10; reverse(ans.begin(), ans.end()); cout << ans << "\n"; }

或者寫成迭代的樣子:

#include <bits/stdc++.h> using namespace std; using ll = long long; ll n; int lim; // n^(2/3) unordered_map<ll, __int128> s; // prefix sum of phi vector<__int128> psp; // prefix sum of phi void LinearSieve() { vector<int> primes; vector<int> pf(lim); // prime factor for (int i = 2; i < lim; ++i) { if (pf[i] == 0) { primes.push_back(i); pf[i] = i; } for (auto p : primes) { if (i * p >= lim) break; pf[i * p] = p; if (i % p == 0) break; } } vector<int> phi(lim); psp.resize(lim); phi[1] = psp[1] = 1; for (int i = 2; i < lim; ++i) { int now = 1, res = i; while (res % pf[i] == 0) { res /= pf[i]; now *= pf[i]; } phi[i] = phi[res] * now / pf[i] * (pf[i] - 1); psp[i] = psp[i - 1] + phi[i]; } } int main() { ll n; cin >> n; while ((__int128)lim * lim * lim <= (__int128)n * n) ++lim; LinearSieve(); vector<ll> v; for (ll i = 1, r = 0; i <= n; i = r + 1) { r = n / (n / i); v.push_back(i); } reverse(v.begin(), v.end()); for (auto i : v) { if (n / i < lim) continue; ll m = n / i; s[m] = (__int128)m * (m + 1) / 2; for (ll j = 2, r = 0; j <= m; j = r + 1) { r = m / (m / j); if (m / j < lim) s[m] -= (r - j + 1) * psp[m / j]; else s[m] -= (r - j + 1) * s[m / j]; } } __int128 x; if (n == 1) x = psp[1]; else x = s[n]; string ans = ""; while (x) ans.push_back(x % 10 + '0'), x /= 10; reverse(ans.begin(), ans.end()); cout << ans << "\n"; }



另解,注意到:

ni=1ϕ(i)=ni=1ij=1[gcd(i,j)=1]=12×(ni=1nj=1[gcd(i,j)=1]+1)

第一個等式是根據 φ 的定義得到。

第二個等式是因為 (a,b)(b,a) 這樣的 pair 會被重複算到兩次,加一是因為 i=1j=1 的情況。

而對於右式:

ni=1nj=1[gcd(i,j)=1]=ni=1nj=1dgcd(i,j)μ(d)套用在數論函數章節講過的引理=ni=1nj=1didjμ(d)將 gcd 按照定義拆開=nd=1ndi=1ndj=1μ(d)套路,交換 sigma 的順序=nd=1μ(d)ndi=1ndj=11μ(d) 和 ij 無關,移到前面=nd=1μ(d)nd2將後面兩個 sigma 整理好

這是典型數論分塊的形式,為此我們需要求 μ 的前綴和。

回想數論分塊:

for (int i = 1, r = 0; i <= n; i = r + 1) { r = n / (n / i); ans += 1LL * (S[r] - S[i - 1]) * (n / i); }

可以知道,要求的前綴和,會是每一塊的右界(i1 是上一塊的右界),也就是 ri=1μ(i)

注意到,r=nn/i,將 n/i 看成另一個整數 k

這樣一來,r=nk,因此 rR(n)

也就是說,要求的 ri=1μ(i),恰好和杜教篩中會求到的相同。

因此可以結合例題一還有數論分塊,來求出 φ 的前綴和。

Bottleneck 在於求 μ 的前綴和,時間複雜度依然是 O(n23)




洛谷 P213 即是上面兩道練習的模板題。

在此附上 AC code。

在這道題目中要注意,n+1 可能會導致 overflow。

附註:我只有測試過以下的 code,上面的 code 並沒有丟到 OJ 上測試過。

#include <bits/stdc++.h> using namespace std; using ll = long long; int lim = (1 << 21); int primeFactor[(1 << 21)]; vector<int> primes; int mu[(1 << 21)]; ll phi[(1 << 21)]; unordered_map<int, int> mu_s; unordered_map<int, ll> phi_s; signed main() { ios::sync_with_stdio(0); cin.tie(0); for (int i = 2; i < lim; ++i) { if (!primeFactor[i]) { primes.push_back(i); primeFactor[i] = i; } for (auto p : primes) { if (i * p >= lim) break; primeFactor[i * p] = p; if (i % p == 0) break; } } mu[1] = 1; phi[1] = 1; for (int i = 2; i < lim; ++i) { if (i % (1LL * primeFactor[i] * primeFactor[i]) == 0) { mu[i] = 0; phi[i] = phi[i / primeFactor[i]] * primeFactor[i]; } else { mu[i] = -mu[i / primeFactor[i]]; phi[i] = phi[i / primeFactor[i]] * (primeFactor[i] - 1); } } for (int i = 2; i < lim; ++i) { mu[i] += mu[i - 1]; phi[i] += phi[i - 1]; } int T; cin >> T; while (T--) { int n; cin >> n; if (n < lim) { cout << phi[n] << " " << mu[n] << "\n"; continue; } vector<int> R; for (ll i = 1, r = 0; lim <= n / i; i = r + 1) { r = n / (n / i); R.push_back(i); } reverse(R.begin(), R.end()); for (auto i : R) { ll m = n / i; if (mu_s.find(m) != mu_s.end()) continue; mu_s[m] = 1; phi_s[m] = m * (m + 1) / 2LL; for (ll j = 2, r = 0; j <= m; j = r + 1) { r = m / (m / j); if (m / j < lim) { mu_s[m] -= (r - j + 1) * mu[m / j]; phi_s[m] -= (r - j + 1) * phi[m / j]; } else { mu_s[m] -= (r - j + 1) * mu_s[m / j]; phi_s[m] -= (r - j + 1) * phi_s[m / j]; } } } cout << phi_s[n] << " " << mu_s[n] << "\n"; } }

練習題

S(n)=ni=1nj=1ij×gcd(i,j) 的值。(n1010

(hint:先以數論函數章節所教的技巧進行化簡,變成前綴和的樣子後再做杜教篩。)