Đếm số nguyên tố đến N lớn (Lucy/Meissel)
Bài toán: cho rất lớn (tới , ), tính là số số nguyên tố không vượt quá . Sàng Eratosthenes cần bộ nhớ nên không dùng được khi (một mảng bool cỡ là 100 GB). Thuật toán Lucy_Hedgehog (đặt theo tên người đăng nó trên diễn đàn Project Euler) tính trong thời gian và chỉ bộ nhớ: với chạy dưới giây, với khoảng giây. Cùng khung đó tính được tổng các số nguyên tố, đếm số nguyên tố theo lớp đồng dư, và đếm số lượng số có ước nguyên tố nhỏ nhất bằng cho trước.
Ý tưởng / Trực giác
Nhận xét 1: chỉ có giá trị khác nhau
Xét tập . Với có không quá giá trị; với thì , lại không quá giá trị. Vậy .
Tập có một tính chất "đóng" rất quý: nếu thì với mọi , vì
Nghĩa là nếu ta tính một đại lượng cho mọi , mà công thức truy hồi của chỉ cần và tại giá trị nhỏ, thì mọi thứ cần dùng đều nằm trong . Ta sẽ tính cho cả giá trị này cùng lúc, và đáp án là (ứng với ).
Nhận xét 2: sàng Eratosthenes "theo số lượng"
Nhớ lại sàng Eratosthenes: duyệt số nguyên tố tăng dần, gạch bỏ các bội . Sau khi xử lý xong mọi , số còn lại trong đúng là các số nguyên tố.
Lucy không gạch từng số, mà chỉ theo dõi số lượng còn lại. Định nghĩa
Tức là là số phần tử còn sống trong đoạn sau khi sàng bằng mọi số nguyên tố . Ta có:
- Khởi tạo (chưa gạch gì, còn đủ ).
- Khi : sàng thêm không gạch gì trong , nên . Đặc biệt .
- Nếu không phải số nguyên tố: không có gì thay đổi, .
Công thức truy hồi và vì sao đúng
Với nguyên tố và :
Lượng bị trừ phải bằng số phần tử bị gạch ở bước , tức số hợp số có ước nguyên tố nhỏ nhất đúng bằng . Mỗi số như vậy viết duy nhất được thành với:
- ;
- không có ước nguyên tố nào nhỏ hơn (nếu không thì ước nhỏ nhất của nhỏ hơn ).
Đếm các như vậy:
- đếm các là số nguyên tố hoặc không có ước nguyên tố . Tập này gồm hai phần: các số nguyên tố nhỏ hơn (bị đếm vì là số nguyên tố, nhưng không hợp lệ) và mọi không có ước nguyên tố (hợp lệ).
- Số nguyên tố nhỏ hơn có đúng số, và : mọi số nguyên tố đều , nên sau khi sàng bằng mọi số nguyên tố thì đoạn đã được sàng xong, chỉ còn các số nguyên tố.
Trừ đi là ra đúng số hợp lệ, nên công thức đúng. Chú ý nên hiệu luôn không âm.
Cài đặt thực tế chỉ giữ một mảng cho và cập nhật tại chỗ theo từng . Để vẫn là giá trị của bước trước () khi dùng, ta duyệt giảm dần: nên nó chưa bị sửa ở bước . Còn thì không bị sửa ở bước vì .
Nhận biết nguyên tố cũng không cần sàng riêng: nguyên tố khi và chỉ khi (lúc xét tới , hai giá trị này đã là và ).
Ví dụ chạy tay
, . Tập gồm các giá trị với (phần "lớn") và (phần "nhỏ", các ):
V (giảm dần): 30 15 10 7 6 | 5 4 3 2 1
--- large (30/i) -- | ------ small ------
Khởi tạo :
v : 30 15 10 7 6 5 4 3 2 1
S[v] : 29 14 9 6 5 4 3 2 1 0
Bước ( nên 2 nguyên tố; below ). Cập nhật mọi , giảm dần:
v=30: S[30] -= S[15] - 0 = 14 -> 15 (gạch 4,6,...,30: 14 số chẵn)
v=15: S[15] -= S[7] - 0 = 6 -> 8
v=10: S[10] -= S[5] - 0 = 4 -> 5
v=7 : S[7] -= S[3] - 0 = 2 -> 4
v=6 : S[6] -= S[3] - 0 = 2 -> 3
v=5 : S[5] -= S[2] - 0 = 1 -> 3
v=4 : S[4] -= S[2] - 0 = 1 -> 2
^ S[15], S[7], ... vẫn là giá trị cũ vì duyệt v giảm dần
v : 30 15 10 7 6 5 4 3 2 1
S[v] : 15 8 5 4 3 3 2 2 1 0
Bước (; below ). Cập nhật :
v=30: S[30] -= S[10] - 1 = 4 -> 11 (gạch 9, 15, 21, 27)
v=15: S[15] -= S[5] - 1 = 2 -> 6 (gạch 9, 15)
v=10: S[10] -= S[3] - 1 = 1 -> 4 (gạch 9)
v : 30 15 10 7 6 5 4 3 2 1
S[v] : 11 6 4 4 3 3 2 2 1 0
Bước : nên 4 là hợp số, bỏ qua.
Bước (; below ). Chỉ :
v=30: S[30] -= S[6] - 2 = 1 -> 10 (gạch 25; còn 35 > 30)
v : 30 15 10 7 6 5 4 3 2 1
S[v] : 10 6 4 4 3 3 2 2 1 0
^^
Kết quả (các số ). Tiện thể ta có luôn , , : cả bảng là cho mọi .
Cài đặt
Lưu bằng hai mảng small / large
Không dùng map (chậm mỗi lần truy cập). Với :
small[v]lưu với ;large[i]lưu với .
Khi cần với : vì , nếu thì đọc large[i*p], ngược lại nên đọc small[N/(i*p)]. Mọi truy cập đều là đọc mảng trực tiếp theo chỉ số; phép chia chỉ còn ở N / (i*p) (khi ), ở v / p trong vòng small, và một lần n / p2 cho mỗi số nguyên tố .
Thứ tự " giảm dần" tương ứng với: duyệt large[i] theo tăng dần, rồi small[v] theo giảm dần.
C++: đếm số nguyên tố
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// pi(n) by Lucy_Hedgehog's method: O(n^{3/4}) time, O(sqrt n) memory.
ll countPrimes(ll n) {
if (n < 2) return 0;
ll r = sqrtl((long double)n);
while (r * r > n) r--; // fix floating-point error
while ((r + 1) * (r + 1) <= n) r++;
// small[v] = S(v) for v <= r; large[i] = S(n / i) for i <= r
vector<ll> small(r + 1), large(r + 1);
for (ll v = 1; v <= r; v++) small[v] = v - 1;
for (ll i = 1; i <= r; i++) large[i] = n / i - 1;
for (ll p = 2; p <= r; p++) {
if (small[p] == small[p - 1]) continue; // p is composite
ll below = small[p - 1]; // pi(p - 1)
ll p2 = p * p;
// large values: v = n / i >= p^2 <=> i <= n / p^2
ll iEnd = min(r, n / p2);
for (ll i = 1; i <= iEnd; i++) {
ll ip = i * p; // S(v / p) = S(n / (i * p))
ll sub = (ip <= r) ? large[ip] : small[n / ip];
large[i] -= sub - below;
}
// small values, decreasing, only v >= p^2
for (ll v = r; v >= p2; v--)
small[v] -= small[v / p] - below;
}
return large[1]; // S(n / 1) = pi(n)
}
int main() {
ll n;
cin >> n;
cout << countPrimes(n) << "\n";
}
Kiểm chứng: , , .
C++: tổng các số nguyên tố (theo modulo)
Thay "đếm" bằng "cộng": khởi tạo và mỗi bước trừ . Hệ số xuất hiện vì mỗi số bị gạch có dạng nên đóng góp . Công thức chỉ đúng khi hàm được cộng (ở đây ; tương tự với hoặc ) là nhân tính hoàn toàn: với mọi .
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1e9 + 7;
// Sum of primes <= n, modulo MOD.
ll sumPrimes(ll n) {
if (n < 2) return 0;
ll r = sqrtl((long double)n);
while (r * r > n) r--;
while ((r + 1) * (r + 1) <= n) r++;
auto prefix = [](ll v) { // (2 + 3 + ... + v) mod MOD
ll a = v, b = v + 1;
if (a % 2 == 0) a /= 2; else b /= 2;
return ((a % MOD) * (b % MOD) % MOD - 1 + MOD) % MOD;
};
vector<ll> small(r + 1), large(r + 1), cnt(r + 1);
for (ll v = 1; v <= r; v++) small[v] = prefix(v), cnt[v] = v - 1;
for (ll i = 1; i <= r; i++) large[i] = prefix(n / i);
for (ll p = 2; p <= r; p++) {
if (cnt[p] == cnt[p - 1]) continue; // exact counts detect primes
ll below = small[p - 1], p2 = p * p, pm = p % MOD;
ll iEnd = min(r, n / p2);
for (ll i = 1; i <= iEnd; i++) {
ll ip = i * p;
ll sub = (ip <= r) ? large[ip] : small[n / ip];
large[i] = ((large[i] - pm * ((sub - below + MOD) % MOD)) % MOD + MOD) % MOD;
}
for (ll v = r; v >= p2; v--) {
small[v] = ((small[v] - pm * ((small[v / p] - below + MOD) % MOD)) % MOD + MOD) % MOD;
cnt[v] -= cnt[v / p] - cnt[p - 1]; // keep exact pi(v) for small v
}
}
return large[1];
}
int main() {
ll n;
cin >> n;
cout << sumPrimes(n) << "\n";
}
Mảng cnt giữ chính xác cho chỉ để nhận biết số nguyên tố: sau khi lấy modulo, hai giá trị tổng có thể trùng nhau một cách tình cờ nên không dùng small[p] == small[p-1] được nữa. Kiểm chứng: tổng các số nguyên tố là , chia dư .
Python
Python đủ nhanh tới khoảng (khoảng giây). Dùng dict cho gọn; muốn nhanh hơn thì đổi sang hai list small/large như bản C++.
from math import isqrt
def count_primes(n):
"""pi(n) by Lucy_Hedgehog's method."""
if n < 2:
return 0
r = isqrt(n)
vals = [n // i for i in range(1, r + 1)] # large values, decreasing
vals += list(range(vals[-1] - 1, 0, -1)) # small values, decreasing
S = {v: v - 1 for v in vals}
for p in range(2, r + 1):
if S[p] == S[p - 1]:
continue # p is composite
below = S[p - 1]
p2 = p * p
for v in vals: # decreasing order
if v < p2:
break
S[v] -= S[v // p] - below
return S[n]
print(count_primes(int(input())))
Độ phức tạp
Bộ nhớ: , hai mảng độ dài . Với chỉ khoảng số long long, tức 16 MB.
Thời gian: Ở bước , ta cập nhật mọi có . Đổi thứ tự tính tổng: mỗi được cập nhật một lần cho mỗi số nguyên tố , nên tổng công việc là
- Phần lớn, với : .
- Phần nhỏ, : .
Vậy thời gian . Chính xác hơn, vì chỉ số nguyên tố mới gây cập nhật và , tổng thực tế là . Đo thực tế số lần cập nhật: với , với , với ; mỗi lần chỉ gồm vài lần đọc mảng, một phép trừ và nhiều nhất một phép chia 64 bit (n / ip ở vòng large khi , hoặc v / p ở vòng small), nên vẫn chạy dưới giây.
Vòng for p duyệt tới cả số hợp, nhưng mỗi số hợp chỉ tốn nên không ảnh hưởng.
| Sàng Eratosthenes | Lucy | |
|---|---|---|
1 GB bool (khoảng 60 MB nếu dùng bitset chỉ số lẻ), vài giây |
s, 0,5 MB | |
| không khả thi | khoảng s, 5 MB | |
| không khả thi | khoảng s, 16 MB |
⚠️ Lỗi thường gặp
- Tính sai do số thực.
doublechỉ có 53 bit, nên(ll)sqrt(N)có thể lệch 1 khi lớn và sát một số chính phương (vd(ll)sqrt(1e18 - 1)ra thay vì ). Triệu chứng: thiếu hoặc thừa một giá trị trong , đọcsmall[]ngoài mảng. Cách tránh: sausqrtlluôn chỉnh bằng hai vòngwhile, hoặc dùngmath.isqrttrong Python. - Duyệt tăng dần. Khi đó đã bị sửa ở bước trước khi dùng, số bị gạch bị trừ thiếu. Triệu chứng: ra thay vì . Cách tránh:
large[i]theo tăng (tức giảm), rồismall[v]theo giảm. - Vòng
for pdừng ởp < rthay vìp <= r. Khi (vd ), số vẫn phải gạch . Triệu chứng: ra thay vì . Cách tránh: điều kiệnp <= r. - Tràn số khi tính
i * phoặcp * p. Với thì , : vượtint. Cách tránh: mọi biến làlong long. Với bản tính tổng, phải lấy modulo trước khi nhân, và tổng các số nguyên tố đã vượt , nên hoặc lấy modulo hoặc dùng__int128. - Nhận biết số nguyên tố bằng
small[p] != small[p-1]trong bản có modulo. Hai tổng theo modulo có thể trùng tình cờ. Cách tránh: giữ song song một mảng đếm chính xác, hoặc sàng Eratosthenes riêng tới . - Áp dụng cho hàm không nhân tính hoàn toàn. Công thức "trừ " chỉ đúng nếu với mọi , như , , , hay ký tự trong phần mở rộng dưới. Với , hay "số ước" thì không dùng trực tiếp được (xem phần Mở rộng).
- Dùng
map/unordered_maptrong C++. Chạy đúng nhưng chậm hơn 5 đến 20 lần, có thể TLE với . Cách tránh: hai mảngsmall/large.
Biến thể / Mở rộng
Đếm số theo ước nguyên tố nhỏ nhất
Lượng bị trừ ở bước khi , tức , chính là số hợp số có ước nguyên tố nhỏ nhất bằng . Cộng thêm (chính số ) ta được
Với số nguyên tố thì đáp án là đúng (chỉ có ), vì mọi hợp số có ước nguyên tố nhỏ nhất đều : viết với , nếu thì có ước nguyên tố nhỏ hơn (mâu thuẫn), còn nếu thì . Ghi lại lượng trừ tại large[1] ở mỗi bước là có ngay bảng này, không tốn thêm độ phức tạp. Khi nhỏ (cỡ ), bảng tương tự lấy được trực tiếp từ mảng lp[] của sàng tuyến tính.
Đếm số nguyên tố theo lớp đồng dư mod 4
Hàm khi là nhân tính hoàn toàn. Cách đơn giản hơn là giữ hai mảng: đếm số lẻ còn sống , đếm số , bỏ qua (không đưa số chẵn vào ngay từ đầu):
- khởi tạo , ;
- với : cùng lớp với , nên trừ phần của , trừ phần của ;
- với : đổi lớp so với , nên trừ phần của và ngược lại.
Cùng ý tưởng mở rộng cho mọi modulo nhỏ : giữ mảng, mỗi số nguyên tố hoán vị các lớp theo phép nhân với .
Hàm nhân tính tổng quát: Min_25 và sàng Du
Muốn tính cho nhân tính (không hoàn toàn) như , , số ước: bước 1 dùng đúng Lucy để có cho mọi (vì thường là đa thức theo ), bước 2 cộng thêm các hợp số theo ước nguyên tố nhỏ nhất. Đó là sàng Min_25, chạy khoảng . Riêng tổng tiền tố của và còn có sàng Du (Du's sieve) dựa trên tích Dirichlet, . Cả hai đều dựa trên cùng nhận xét "chỉ có giá trị ".
Giới thiệu Meissel-Lehmer
Legendre định nghĩa là số số nguyên không chia hết cho số nguyên tố đầu tiên . Theo bao hàm, loại trừ, có truy hồi
Liên hệ chính xác với Lucy: với ,
vì đếm số cùng mọi có ước nguyên tố nhỏ nhất lớn hơn , còn bỏ số nhưng giữ thêm số nguyên tố . Meissel chọn , . Các số không có ước nguyên tố nào trong gồm: số , các số nguyên tố , và các tích đúng hai số nguyên tố với (ba thừa số thì vượt ). Với mỗi thừa số nhỏ (cần , tức ), thừa số lớn chạy qua các số nguyên tố trong , có giá trị. Suy ra
cần tại các điểm , lấy bằng một sàng đoạn; tính bằng đệ quy có cắt tỉa và bảng tra cho nhỏ. Lehmer, rồi Lagarias, Miller, Odlyzko cải tiến để đạt khoảng thời gian. Với trong thi đấu, Lucy ngắn gọn hơn nhiều và đủ nhanh; Meissel-Lehmer chỉ đáng cài khi tới trở lên.
Khi nào dùng kỹ thuật nào
| Tình huống | Kỹ thuật |
|---|---|
| Kiểm tra một số có nguyên tố không | Miller-Rabin |
| hoặc giá trị hàm nhân tính cho mọi | Sàng Eratosthenes / sàng tuyến tính |
| Liệt kê số nguyên tố trong đoạn , , | Sàng đoạn (segmented sieve) |
| Chỉ cần , , hoặc theo lớp đồng dư, | Lucy_Hedgehog (trang này) |
| từ trở lên | Meissel-Lehmer |
Bài tập luyện
Luyện kỹ thuật
- Số nguyên tố hay số đối xứng? (primepal) (Nâng cao) khởi động: cần cho mọi tới khoảng , đủ nhỏ để dùng sàng thường với mảng tiền tố; bài giúp phân biệt khi nào sàng là đủ và khi nào mới cần Lucy.
- Dãy số nguyên tố cùng nhau (kthcopr) (Kỳ cựu) đếm số không chia hết cho các ước nguyên tố của bằng bao hàm, loại trừ: đúng là hàm của Legendre (nền của Meissel-Lehmer) với tập số nguyên tố tùy ý, kết hợp chặt nhị phân.
- Số may mắn kép (dblhappy) (Kỳ cựu) định lý Fermat quy bài về đếm số nguyên tố trong , cộng thêm nếu số nằm trong đoạn (vì ); lời giải gốc sàng bitset tới , nhưng Lucy với hai mảng theo lớp đồng dư mod 4 (phần Mở rộng) chạy tức thì và gần như không tốn bộ nhớ.
Trong ba bài trên, chỉ dblhappy áp dụng trực tiếp kỹ thuật Lucy; primepal (sàng thường) và kthcopr (bao hàm, loại trừ trên vài số nguyên tố) là bài liên quan để đối chiếu. Pool CTOJ hiện chưa có bài "thuần" đếm hoặc tổng số nguyên tố với ; khi có sẽ bổ sung vào đây.
Đề VOI
- Xây dựng ma trận (voi22_matrix) (Chuyên gia) VOI 2022: điền tay vài hàng để thấy giá trị ô chỉ phụ thuộc vào thứ tự của ước nguyên tố nhỏ nhất của chỉ số hàng và cột; với , chạy Lucy riêng cho (chỉ số hàng) và (chỉ số cột): số chỉ số có cùng ước nguyên tố nhỏ nhất chính là lượng Lucy trừ đi ở bước (cộng 1), còn mỗi số nguyên tố tạo một lớp có đúng một phần tử.
Bình luận