Đếm Số Nguyên Tố (MK119SNT)
Đề bài
Cho hai số nguyên dương $L \le R$. Hãy đếm xem trong đoạn $[L, R]$ có bao nhiêu số nguyên tố.
Dữ liệu vào:
- Dòng đầu ghi số nguyên dương $T$ là số bộ test;
- $T$ dòng tiếp theo, mỗi dòng chứa hai số nguyên dương $L \le R$ cách nhau bởi một dấu cách.
Giới hạn: $1 \le T \le 10^2$, $1 \le L \le R \le 10^6$.
Dữ liệu ra: Với mỗi cặp số $L$ và $R$, ghi ra trên một dòng số số nguyên tố trong đoạn $[L, R]$.
2
2 3
10 152
2Lời mở đầu
Đây là một bài rất "kinh điển" để luyện tay với số nguyên tố. Đi qua bài này các bạn sẽ thấy rõ một hành trình tối ưu: từ cách vét cạn cỡ $10^{11}$ phép tính, rút xuống 100 triệu, rồi rút xuống chỉ còn cỡ 5 triệu. Đồng thời mình cũng chỉ cho các bạn một dấu hiệu nhận dạng rất hay gặp của dạng bài "nhiều truy vấn trên đoạn" 😇.
Mình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ. Có $T = 2$ bộ test:
- Đoạn $[2, 3]$ gồm 2 và 3 – cả hai đều là số nguyên tố ⇒ in ra 2.
- Đoạn $[10, 15]$ gồm 10, 11, 12, 13, 14, 15 – chỉ có 11 và 13 là số nguyên tố ⇒ in ra 2.
Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJLộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Vét cạn kiểm tra từng số – O(T · (R − L) · √R)
Một cách "ngây thơ" là vét cạn: chạy đủ $T$ bộ test (cái này chắc chắn không tránh được rồi), với mỗi bộ test chạy vòng lặp xét đủ các số trong đoạn từ $L$ đến $R$; với mỗi số gọi hàm kiểm tra tính nguyên tố, nếu thỏa thì tăng biến đếm. Sau cùng in biến đếm ra, rồi qua test tiếp theo, reset biến đếm và lặp lại quy trình như cũ.
Hàm kiểm tra số nguyên tố mình viết theo cách khá tối ưu: chỉ thử chia cho các $i$ với $i \cdot i \le x$, độ phức tạp $O(\sqrt{X})$.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
/* Hàm kiểm tra số nguyên tố với độ phức tạp O(sqrt(X)) */
bool isPrime(int x){
for(int i = 2; i * i <= x; ++i){
if(x % i == 0){
return false;
}
}
return x >= 2;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
int t;
cin >> t;
while(t--){
int l, r;
cin >> l >> r;
int cnt = 0;
for(int i = l; i <= r; ++i){
if(isPrime(i)){
cnt++;
}
}
cout << cnt << endl;
}
return 0;
}
import sys
# Hàm kiểm tra số nguyên tố với độ phức tạp O(sqrt(X))
def is_prime(x):
i = 2
while i * i <= x:
if x % i == 0:
return False
i += 1
return x >= 2
def main():
data = sys.stdin.read().split()
t = int(data[0])
out = []
p = 1
for _ in range(t):
l, r = int(data[p]), int(data[p + 1])
p += 2
cnt = 0
for i in range(l, r + 1):
if is_prime(i):
cnt += 1
out.append(str(cnt))
print("\n".join(out))
main()
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(1)$ do chẳng cần dùng thêm mảng phụ gì cả.
- Thời gian: $O(T \cdot (R - L) \cdot \sqrt{R})$: duyệt $T$ bộ test, mỗi bộ test duyệt hết các số từ $L$ đến $R$, mỗi số kiểm tra mất tối đa cỡ $\sqrt{R}$ (giá trị lớn nhất cần kiểm tra chính là $R$). Thử nhẩm trường hợp xấu nhất: $T = 100$ bộ test, bộ nào cũng $L = 1$, $R = 10^6$ ⇒ $10^2 \cdot 10^6 \cdot 10^3 = 10^{11}$. Vượt xa ngưỡng 1 giây ⇒ chắc chắn bị TLE.
Cách 2Sàng Eratosthenes rồi duyệt đoạn – O(N log log N + T · (R − L))
Ta nhận ra một cách tốt hơn: thay vì với mỗi giá trị trong đoạn $L..R$ cứ phải liên tục gọi hàm kiểm tra số nguyên tố, ta có thể ngay từ đầu lọc ra, đánh dấu hết những số trong đoạn từ 1 đến $10^6$ xem số nào là số nguyên tố. Bước này dùng sàng nguyên tố Eratosthenes để tạo ra mảng sàng a (a[x] = 0 nghĩa là $x$ nguyên tố, a[x] = 1 nghĩa là không). Rồi với mỗi $i$ trong đoạn $L..R$, chỉ cần nhìn a[i] là biết ngay $i$ có phải số nguyên tố không, từ đó tăng biến đếm.
Khung minh hoạ bên dưới cho các bạn xem sàng chạy trên các số nhỏ (bước cuối cùng là phần mảng đếm cho Cách 3, các bạn xem luôn cũng được):
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
const int n = 1000001;
int a[n]; // mảng toàn cục: mặc định toàn 0
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
/* Bước sàng nguyên tố Eratosthenes */
a[0] = a[1] = 1;
for(int i = 2; i * i < n; ++i){
if(a[i] == 0){
for(int j = i * i; j < n; j += i){
if(a[j] == 0){
a[j] = 1;
}
}
}
}
/* Bước đọc dữ liệu từng bộ test và xử lý in ra kết quả */
int t;
cin >> t;
while(t--){
int l, r;
cin >> l >> r;
int cnt = 0;
for(int i = l; i <= r; ++i){
if(a[i] == 0){
cnt++;
}
}
cout << cnt << endl;
}
return 0;
}
import sys
n = 1000001
# Bước sàng nguyên tố Eratosthenes (a[x] = 1 nghĩa là x KHÔNG phải số nguyên tố)
a = bytearray(n)
a[0] = a[1] = 1
i = 2
while i * i < n:
if a[i] == 0:
a[i * i::i] = b"\x01" * len(range(i * i, n, i))
i += 1
# Bước đọc dữ liệu từng bộ test và xử lý in ra kết quả
data = sys.stdin.read().split()
t = int(data[0])
out = []
p = 1
for _ in range(t):
l, r = int(data[p]), int(data[p + 1])
p += 2
# đếm số ô bằng 0 trong đoạn [l, r] (vẫn là duyệt O(R - L))
cnt = a.count(0, l, r + 1)
out.append(str(cnt))
print("\n".join(out))
Ở khung chạy tay dưới đây, phần sàng trên $10^6$ số mình chỉ cho chạy chi tiết với vài số nguyên tố đầu tiên, phần còn lại gom thành một bước (chứ chạy tay đủ thì cả triệu bước 😅). Kết quả mảng a vẫn là kết quả thật của cả $10^6$ số.
int n = 1000001; int a[n] = {0}; bên trong main và cho vòng sàng chạy j <= n. Có 2 điểm nên sửa:
- Mảng chỉ có index $0..n-1$, nên
j <= nsẽ chạm tớia[1000001]– ngoài mảng (vì $1000001 = 101 \times 9901$ nên thật sự có lúc gạch đúng vào ô đó). Mình sửa thànhi * i < nvàj < n. - Mảng 4 MB khai báo trong
mainnằm trên stack, một số máy chấm giới hạn stack nhỏ có thể bị lỗi. Đưa ra mảng toàn cục thì an toàn hơn, lại tự động được khởi tạo toàn 0.
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ với $N = 10^6 + 1$ cho mảng sàng.
- Thời gian: bước sàng $O(N \log \log N)$ cỡ 5 triệu – ổn. Nhưng bước xử lý tiếp theo vẫn phải xét đủ $T$ bộ test, mỗi bộ test chạy từ $L$ đến $R$: trường hợp xấu nhất $T = 100$, $L = 1$, $R = 10^6$ thì là $O(T \cdot (R - L)) = 10^8$ (100 triệu). Mức này chạm ngưỡng tối đa nên khả năng TLE vẫn còn. Tuy nhiên các bạn có thể thử, biết đâu gặp đúng server chấm mạnh, hoặc test không rơi vào trường hợp xấu nhất thì sao hehe 😁.
Cách 3Sàng + mảng đếm cộng dồn – O(N log log N + T)
Áp vào bài này: đầu tiên vẫn dùng sàng Eratosthenes đánh dấu trong phạm vi 1 đến $10^6$ số nào là số nguyên tố. Rồi ta tạo mảng thống kê số lượng số nguyên tố tính từ 1 trở đi. Ví dụ với 10 giá trị đầu tiên:
| i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| nguyên tố? | – | ✔ | ✔ | – | ✔ | – | ✔ | – | – | – |
| a[i] | 0 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 4 | 4 |
Chẳng hạn a[1] = 0 vì từ 1 đến 1 không có số nguyên tố nào; a[4] = 2 vì từ 1 đến 4 có 2 số nguyên tố (2, 3); a[7] = 4 vì từ 1 đến 7 có 4 số nguyên tố (2, 3, 5, 7); a[10] = 4 vẫn là 2, 3, 5, 7. Tức là a[i] chính là số lượng số nguyên tố trong đoạn từ 1 đến $i$.
Có mảng này rồi thì truy vấn đoạn $[L, R]$ rất đơn giản: lấy a[R] - a[L], rồi xét riêng $L$ có phải số nguyên tố không, nếu có thì cộng thêm 1. Tại sao? Vì a[R] là số lượng số nguyên tố từ 1 đến $R$, a[L] là số lượng từ 1 đến $L$, nên a[R] - a[L] chỉ ra số lượng trong đoạn $L + 1$ đến $R$ – thiếu mất chính $L$.
Ví dụ $L = 3$, $R = 10$: a[10] - a[3] $= 4 - 2 = 2$. Mà $L = 3$ là số nguyên tố nên cộng thêm 1 ⇒ đáp án 3. Nhẩm lại: từ 3 đến 10 có 3, 5, 7 – đúng 3 số ✅. Các bạn cứ thử nhẩm thêm vài trường hợp khác để tự kiểm chứng nhé.
a[L] với a[L - 1]: nếu khác nhau tức là tới $L$ số lượng tăng thêm 1 ⇒ $L$ là số nguyên tố. Vì $L \ge 1$ nên a[L - 1] luôn hợp lệ (dùng tới ô index 0 = 0).(Công thức của mình viết dài ra cho các bạn thấy rõ ý "xét riêng $L$"; gọn lại thì nó chính là a[R] - a[L - 1] – đúng kiểu mảng cộng dồn quen thuộc.)
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
const int n = 1000001;
int a[n]; // mảng toàn cục: mặc định toàn 0
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
/* Bước sàng nguyên tố Eratosthenes */
a[0] = a[1] = 1;
for(int i = 2; i * i < n; ++i){
if(a[i] == 0){
for(int j = i * i; j < n; j += i){
if(a[j] == 0){
a[j] = 1;
}
}
}
}
/* Biến mảng sàng thành mảng đếm số nguyên tố tính từ 1 */
int cnt = 0;
for(int i = 0; i < n; ++i){
if(a[i] == 0){
cnt++;
}
a[i] = cnt;
}
/* Bước đọc dữ liệu từng bộ test và in ra kết quả ngay */
int t;
cin >> t;
while(t--){
int l, r;
cin >> l >> r;
cout << a[r] - a[l] + (a[l] != a[l - 1]) << endl;
}
return 0;
}
import sys
from itertools import accumulate
n = 1000001
# Bước sàng nguyên tố Eratosthenes (a[x] = 1 nghĩa là x KHÔNG phải số nguyên tố)
sieve = bytearray(n)
sieve[0] = sieve[1] = 1
i = 2
while i * i < n:
if sieve[i] == 0:
sieve[i * i::i] = b"\x01" * len(range(i * i, n, i))
i += 1
# Biến mảng sàng thành mảng đếm số nguyên tố tính từ 1
# (a[i] = số lượng số nguyên tố trong đoạn 1..i; accumulate cộng dồn cho nhanh)
a = list(accumulate(1 - x for x in sieve))
# Bước đọc dữ liệu từng bộ test và in ra kết quả ngay
data = sys.stdin.read().split()
t = int(data[0])
out = []
p = 1
for _ in range(t):
l, r = int(data[p]), int(data[p + 1])
p += 2
out.append(str(a[r] - a[l] + (a[l] != a[l - 1])))
print("\n".join(out))
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ với $N = 10^6 + 1$ cho mảng sàng (cũng là mảng đếm).
- Thời gian: bước sàng $O(N \log \log N)$; bước biến mảng sàng thành mảng đếm $O(N)$; bước xử lý truy vấn $O(T)$ với $T \le 10^2$. Tổng cộng $O(N \log \log N) + O(N) + O(T)$, theo quy tắc cộng Big-O lấy thành phần lớn nhất ⇒ $O(N \log \log N)$, cao lắm cỡ 5 triệu phép tính – thỏa ngưỡng 1 giây, chắc chắn AC không sợ TLE nhé.
Cách này chấp nhận tốn thêm bộ nhớ so với cách vét cạn, nhưng độ phức tạp tối ưu hơn rất nhiều. Cùng nhìn lại hành trình tối ưu nào:
Các bạn đã thấy cái hay của thuật toán chưa 🥰
bytearray, gán lát cắt và accumulate cho nhanh (logic giữ nguyên), chạy khoảng 1.4 giây và 0.6 giây; bản Python Cách 1 chỉ để tham khảo vì chậm hơn nhiều.- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét