Bài 3.5. Bài tập tổng hợp về số nguyên tố và đồng dư

Bài 3.5. Bài tập tổng hợp về số nguyên tố và đồng dư
Chào mừng trở lại với chuỗi bài viết về Cấu trúc dữ liệu và Giải thuật! Sau khi làm quen với các khái niệm cơ bản và một số cấu trúc dữ liệu đầu tiên, hôm nay chúng ta sẽ lặn sâu vào một lĩnh vực toán học cực kỳ quan trọng và có nhiều ứng dụng trong lập trình, đặc biệt là trong thiết kế giải thuật: Số nguyên tố và Đồng dư.
Hai khái niệm này thoạt nhìn có vẻ chỉ là lý thuyết toán học khô khan, nhưng chúng lại là trụ cột xây dựng nên nhiều kỹ thuật và thuật toán mạnh mẽ, từ các bài toán tối ưu cơ bản đến những hệ thống mật mã phức tạp đảm bảo an toàn thông tin của chúng ta. Hiểu rõ về số nguyên tố và đồng dư sẽ mở ra những cánh cửa mới trong việc giải quyết các bài toán lập trình đầy thách thức.
Hãy cùng bắt đầu khám phá!
I. Số Nguyên Tố (Prime Numbers)
Số nguyên tố là một trong những khối xây dựng cơ bản nhất của toán học. Chúng là những số tự nhiên lớn hơn 1 chỉ có đúng hai ước số dương phân biệt là 1 và chính nó. Ví dụ điển hình là 2, 3, 5, 7, 11, ... Số 4 không phải số nguyên tố vì nó có các ước là 1, 2, và 4. Số 1 không phải số nguyên tố theo quy ước.
Tại sao số nguyên tố lại quan trọng trong lập trình?
- Nền tảng của Số học: Mọi số tự nhiên lớn hơn 1 đều có thể phân tích duy nhất thành tích của các số nguyên tố (Định lý cơ bản của Số học). Điều này giúp chúng ta hiểu cấu trúc của các con số.
- Mật mã học: Các thuật toán mã hóa hiện đại như RSA dựa trên sự khó khăn của việc phân tích một số lớn thành tích của hai số nguyên tố rất lớn.
- Thuật toán: Nhiều giải thuật (ví dụ: kiểm tra tính nguyên tố, phân tích thừa số nguyên tố, các bài toán liên quan đến ước chung lớn nhất, bội chung nhỏ nhất) trực tiếp làm việc với số nguyên tố.
- Hashing: Đôi khi, kích thước của bảng băm (hash table) được chọn là một số nguyên tố để giảm thiểu xung đột.
1. Kiểm tra tính nguyên tố (Primality Test)
Đây là bài toán cơ bản nhất: Cho một số nguyên dương N, kiểm tra xem N có phải là số nguyên tố không?
Phương pháp ngây thơ (Naive Approach)
Ý tưởng đơn giản nhất là thử chia N cho tất cả các số từ 2 đến N-1. Nếu N chia hết cho bất kỳ số nào trong khoảng này, thì N không phải là số nguyên tố. Ngược lại, nếu không chia hết cho số nào, thì N là số nguyên tố.
bool isPrimeNaive(int n) {
if (n <= 1) return false; // 0 và 1 không phải nguyên tố
for (int i = 2; i < n; i++) {
if (n % i == 0) {
return false; // Tìm thấy ước, không phải nguyên tố
}
}
return true; // Không tìm thấy ước nào, là nguyên tố
}
Giải thích code:
- Hàm
isPrimeNaive(int n)
nhận một số nguyênn
. - Trường hợp
n <= 1
được xử lý trước vì 0 và 1 không phải số nguyên tố. - Vòng lặp
for
bắt đầu từi = 2
và chạy đếni < n
. - Bên trong vòng lặp, chúng ta kiểm tra xem
n
có chia hết choi
không (n % i == 0
). - Nếu có, tức là
n
có ước khác 1 và chính nó, hàm trả vềfalse
. - Nếu vòng lặp kết thúc mà không tìm thấy ước nào, hàm trả về
true
.
Độ phức tạp: Phương pháp này có độ phức tạp là O(N), khá chậm đối với các số N lớn.
Tối ưu hóa
Chúng ta có thể tối ưu hóa đáng kể.
- Tối ưu 1: Chúng ta chỉ cần kiểm tra đến
N/2
. Nếu một số N có ướcd > N/2
, thì ước còn lạiN/d
sẽ nhỏ hơn 2, điều này chỉ xảy ra khi d=N (ước là chính nó) hoặc N=1 (không xét). Do đó, mọi ước thực sự (khác 1 và N) của N đều phải nhỏ hơn hoặc bằng N/2. - Tối ưu 2: Quan sát sâu hơn, nếu N có một ước
d
, thìN = d * k
vớik = N/d
. Nếu cảd
vàk
đều lớn hơnsqrt(N)
, thì tíchd * k
sẽ lớn hơnsqrt(N) * sqrt(N) = N
. Điều này mâu thuẫn vớiN = d * k
. Do đó, ít nhất một trong hai ướcd
hoặck
phải nhỏ hơn hoặc bằngsqrt(N)
. Điều này có nghĩa là nếu N có ước, nó phải có một ước nhỏ hơn hoặc bằngsqrt(N)
. Chúng ta chỉ cần kiểm tra đếnsqrt(N)
.
#include <cmath> // Cần thư viện cmath cho sqrt()
bool isPrimeOptimized(int n) {
if (n <= 1) return false; // 0 và 1 không phải nguyên tố
for (int i = 2; i * i <= n; i++) { // Kiểm tra đến căn bậc hai của n
if (n % i == 0) {
return false; // Tìm thấy ước
}
}
return true; // Là nguyên tố
}
Giải thích code:
- Hàm
isPrimeOptimized(int n)
tương tự hàm trước. - Điểm khác biệt chính là điều kiện của vòng lặp:
i * i <= n
. Điều này tương đương vớii <= sqrt(n)
nhưng tránh tính toánsqrt
trong mỗi lần lặp (và tránh làm việc với số dấu phẩy động). - Các trường hợp cơ bản và logic trả về tương tự.
Độ phức tạp: Phương pháp này có độ phức tạp O(sqrt(N)), nhanh hơn nhiều so với O(N) cho N lớn.
Tối ưu hóa thêm
Chúng ta có thể tối ưu hóa hơn nữa bằng cách loại bỏ việc kiểm tra các ước chẵn (ngoại trừ số 2) và các ước chia hết cho 3 (ngoại trừ số 3).
- Số 2 và 3 là số nguyên tố.
- Tất cả các số nguyên tố khác 2 và 3 đều có dạng
6k ± 1
(tức là6k - 1
hoặc6k + 1
) vớik
là số nguyên dương. - Chúng ta kiểm tra riêng cho 2 và 3. Sau đó, bắt đầu kiểm tra từ 5 với bước nhảy 6: kiểm tra
i
vài+2
(tương ứng với6k-1
và6k+1
).
bool isPrime(int n) {
if (n <= 1) return false; // 0 và 1
if (n <= 3) return true; // 2 và 3
if (n % 2 == 0 || n % 3 == 0) return false; // Các số chẵn khác 2, các số chia hết cho 3 khác 3
// Kiểm tra các số có dạng 6k ± 1 từ 5 trở đi
for (int i = 5; i * i <= n; i = i + 6) {
if (n % i == 0 || n % (i + 2) == 0)
return false; // Tìm thấy ước dạng 6k-1 hoặc 6k+1
}
return true; // Là nguyên tố
}
Giải thích code:
- Xử lý các trường hợp đặc biệt:
n <= 1
,n <= 3
,n % 2 == 0
hoặcn % 3 == 0
. - Vòng lặp bắt đầu từ
i = 5
. Điều kiện lặp vẫn lài * i <= n
. - Bước nhảy của
i
lài = i + 6
. Trong mỗi lần lặp, chúng ta kiểm tra chia hết choi
vài+2
. - Logic này bao phủ việc kiểm tra các ước tiềm năng dạng 5, 7, 11, 13, 17, 19, ... cho đến
sqrt(n)
.
Độ phức tạp: Vẫn là O(sqrt(N)) về mặt tiệm cận, nhưng nhanh hơn trong thực tế vì số phép chia ít hơn khoảng 3 lần so với chỉ kiểm tra số lẻ.
2. Tìm tất cả số nguyên tố trong một khoảng (Generating Primes)
Khi cần tìm tất cả các số nguyên tố nhỏ hơn hoặc bằng một giới hạn N, việc kiểm tra từng số một bằng isPrime
sẽ không hiệu quả (tổng độ phức tạp sẽ là khoảng N sqrt(N)). Thuật toán Sàng Eratosthenes* (Sieve of Eratosthenes) là một phương pháp hiệu quả hơn nhiều.
Ý tưởng của Sàng Eratosthenes:
- Tạo một danh sách tất cả các số tự nhiên từ 2 đến N, ban đầu đánh dấu tất cả chúng là "có thể là nguyên tố".
- Bắt đầu với số nguyên tố nhỏ nhất là 2. Đánh dấu tất cả các bội số của 2 (lớn hơn 2) là "không phải nguyên tố".
- Tìm số tiếp theo trong danh sách vẫn được đánh dấu là "có thể là nguyên tố". Số đó chắc chắn là một số nguyên tố (vì nó không phải là bội của bất kỳ số nguyên tố nào nhỏ hơn nó).
- Lặp lại bước 2 và 3: đánh dấu tất cả các bội số của số nguyên tố mới tìm được là "không phải nguyên tố".
- Tiếp tục quá trình này cho đến khi bạn xử lý tất cả các số nguyên tố mà bình phương của chúng nhỏ hơn hoặc bằng N.
#include <vector>
#include <iostream> // Để in kết quả (ví dụ)
std::vector<bool> sieveOfEratosthenes(int n) {
// Tạo vector boolean, ban đầu giả sử tất cả đều là nguyên tố
std::vector<bool> is_prime(n + 1, true);
// 0 và 1 không phải nguyên tố
is_prime[0] = is_prime[1] = false;
// Bắt đầu từ 2
for (int p = 2; p * p <= n; p++) {
// Nếu p vẫn được đánh dấu là nguyên tố
if (is_prime[p] == true) {
// Đánh dấu tất cả các bội số của p (lớn hơn hoặc bằng p*p) là không phải nguyên tố
// Bắt đầu từ p*p vì các bội nhỏ hơn (p*2, p*3,... < p*p) đã bị đánh dấu
// bởi các số nguyên tố nhỏ hơn p.
for (int i = p * p; i <= n; i += p)
is_prime[i] = false;
}
}
// Vector is_prime[i] = true nếu i là số nguyên tố
return is_prime;
}
// Ví dụ sử dụng Sàng Eratosthenes
int main_sieve_example() {
int limit = 100;
std::vector<bool> primes = sieveOfEratosthenes(limit);
std::cout << "Cac so nguyen to nho hon hoac bang " << limit << " la:" << std::endl;
for (int p = 2; p <= limit; p++) {
if (primes[p]) {
std::cout << p << " ";
}
}
std::cout << std::endl;
return 0;
}
Giải thích code:
- Hàm
sieveOfEratosthenes(int n)
nhận giới hạn trênn
. - Tạo một
std::vector<bool>
có kích thướcn + 1
.is_prime[i]
sẽ làtrue
nếui
là số nguyên tố vàfalse
nếu không. Ban đầu gán tất cả làtrue
. - Gán
is_prime[0]
vàis_prime[1]
làfalse
. - Vòng lặp ngoài bắt đầu từ
p = 2
. Điều kiện lặp làp * p <= n
. Chúng ta chỉ cần xử lý các số nguyên tốp
màp*p <= n
vì nếu một số compositeC <= n
có ước nguyên tốq > sqrt(n)
, thì ước còn lạiC/q
phải nhỏ hơnsqrt(n)
, vàC
đã bị đánh dấu bởi ước nhỏ hơn này. - Bên trong vòng lặp ngoài, nếu
is_prime[p]
vẫn làtrue
, điều đó có nghĩap
là số nguyên tố (chưa bị đánh dấu bởi các số nguyên tố nhỏ hơn). - Vòng lặp trong bắt đầu từ
i = p * p
và tăngi
lênp
mỗi lần lặp (i += p
). Điều này đánh dấu tất cả các bội số củap
(từp*p
trở đi) là không phải nguyên tố. - Hàm trả về vector
is_prime
. - Hàm
main_sieve_example
minh họa cách sử dụng: gọi hàm sàng và in ra các chỉ sốp
màis_prime[p]
làtrue
.
Độ phức tạp: Sàng Eratosthenes có độ phức tạp xấp xỉ O(N log log N), nhanh hơn đáng kể so với O(N * sqrt(N)) khi N lớn. Đây là phương pháp hiệu quả nhất để tìm tất cả số nguyên tố trong một phạm vi.
II. Đồng Dư (Modular Congruence)
Đồng dư là một khái niệm trong số học mô đun (modular arithmetic). Nó liên quan đến phần dư của phép chia.
Chúng ta nói rằng hai số nguyên a
và b
là đồng dư theo mô đun m
(ký hiệu a ≡ b (mod m)
) nếu chúng có cùng phần dư khi chia cho số nguyên dương m
. Một cách định nghĩa tương đương là a - b
chia hết cho m
.
Ví dụ:
17 ≡ 5 (mod 12)
vì17 chia 12 dư 5
và5 chia 12 cũng dư 5
. Hoặc17 - 5 = 12
, chia hết cho 12.3 ≡ 10 (mod 7)
vì3 chia 7 dư 3
và10 chia 7 dư 3
. Hoặc3 - 10 = -7
, chia hết cho 7.25 ≡ 0 (mod 5)
vì25 chia 5 dư 0
. Hoặc25 - 0 = 25
, chia hết cho 5.
Mô đun m
thường đại diện cho một "chu kỳ". Ví dụ phổ biến nhất là đồng hồ 12 giờ (mô đun 12). Nếu bây giờ là 3 giờ, sau 14 giờ nữa sẽ là 3 + 14 = 17
giờ. Trên đồng hồ 12 giờ, 17 giờ tương đương với 5 giờ chiều (17 mod 12 = 5
).
1. Các tính chất của Đồng dư
Đồng dư có nhiều tính chất hữu ích giúp chúng ta thực hiện các phép tính số học trên phần dư:
Cho a ≡ b (mod m)
và c ≡ d (mod m)
:
- Phản xạ:
a ≡ a (mod m)
- Đối xứng: Nếu
a ≡ b (mod m)
thìb ≡ a (mod m)
. - Bắc cầu: Nếu
a ≡ b (mod m)
vàb ≡ c (mod m)
thìa ≡ c (mod m)
. - Cộng:
a + c ≡ b + d (mod m)
. - Trừ:
a - c ≡ b - d (mod m)
. - Nhân:
a * c ≡ b * d (mod m)
. - Lũy thừa:
a^k ≡ b^k (mod m)
vớik
là số nguyên dương.
Những tính chất này cho phép chúng ta thực hiện các phép tính số học trước rồi mới lấy mô đun, hoặc lấy mô đun sau mỗi phép tính (đối với cộng, trừ, nhân) mà kết quả đồng dư vẫn được giữ nguyên. Điều này cực kỳ quan trọng khi làm việc với các số rất lớn mà không thể lưu trữ trực tiếp.
Ví dụ: Tính (123 + 456) mod 10
.
- Cách 1 (Tính tổng trước):
123 + 456 = 579
.579 mod 10 = 9
. - Cách 2 (Lấy mô đun từng số rồi cộng):
123 mod 10 = 3
,456 mod 10 = 6
.(3 + 6) mod 10 = 9 mod 10 = 9
. Kết quả là như nhau.
Ví dụ: Tính (12 * 34) mod 10
.
- Cách 1:
12 * 34 = 408
.408 mod 10 = 8
. - Cách 2:
12 mod 10 = 2
,34 mod 10 = 4
.(2 * 4) mod 10 = 8 mod 10 = 8
. Kết quả là như nhau.
2. Ứng dụng: Tính lũy thừa mô đun (Modular Exponentiation)
Một bài toán phổ biến là tính a^b mod m
, trong đó a
, b
, và m
có thể là các số rất lớn. Nếu tính a^b
trước rồi mới lấy mô đun, giá trị a^b
có thể vượt quá giới hạn lưu trữ của các kiểu dữ liệu thông thường.
Chúng ta sử dụng tính chất nhân của đồng dư: (x * y) mod m = ((x mod m) * (y mod m)) mod m
. Áp dụng tính chất này liên tục trong quá trình tính lũy thừa.
Phương pháp ngây thơ
Tính a^b
bằng cách nhân a
với chính nó b
lần, và sau mỗi lần nhân, lấy mô đun m
.
long long powerNaive(long long base, long long exp, long long mod) {
long long res = 1;
base %= mod; // Đảm bảo base nằm trong khoảng [0, mod-1]
for (int i = 0; i < exp; i++) {
res = (res * base) % mod;
}
return res;
}
Giải thích code:
- Hàm
powerNaive
nhận cơ sốbase
, số mũexp
, và mô đunmod
. - Khởi tạo
res = 1
. base %= mod
đảm bảobase
ban đầu không quá lớn.- Vòng lặp
exp
lần, mỗi lần nhânres
vớibase
và lấy mô đun% mod
. - Trả về kết quả cuối cùng.
Độ phức tạp: O(exp), vẫn chậm nếu exp
rất lớn.
Phương pháp tối ưu: Lũy thừa nhị phân (Binary Exponentiation / Exponentiation by Squaring)
Đây là một kỹ thuật rất hiệu quả để tính a^b
trong O(log b) thời gian. Ý tưởng dựa trên biểu diễn nhị phân của số mũ b
.
Giả sử b
ở dạng nhị phân là b_k b_{k-1} ... b_1 b_0
, nghĩa là b = b_k * 2^k + b_{k-1} * 2^{k-1} + ... + b_1 * 2^1 + b_0 * 2^0
.
Khi đó, a^b = a^(b_k * 2^k + ... + b_0 * 2^0) = a^(b_k * 2^k) * ... * a^(b_0 * 2^0)
.
Mỗi thành phần a^(b_i * 2^i)
chỉ khác 1 nếu bit b_i
là 1.
Chúng ta có thể tính các giá trị a^(2^0), a^(2^1), a^(2^2), ...
bằng cách bình phương liên tiếp:
a^(2^0) = a
a^(2^1) = (a^(2^0))^2 = a^2
a^(2^2) = (a^(2^1))^2 = (a^2)^2 = a^4
a^(2^i) = (a^(2^(i-1)))^2
Trong quá trình tính, nếu bit hiện tại của b
là 1, chúng ta nhân kết quả vào tích cuối cùng. Nếu bit là 0, chúng ta bỏ qua.
long long power(long long base, long long exp, long long mod) {
long long res = 1; // Khởi tạo kết quả là 1
base %= mod; // Đảm bảo base nằm trong khoảng [0, mod-1]
while (exp > 0) {
// Nếu bit cuối cùng của exp là 1 (exp là số lẻ)
if (exp % 2 == 1) {
res = (res * base) % mod; // Nhân base hiện tại vào kết quả
}
// Bình phương base và lấy mô đun cho bước tiếp theo (bit tiếp theo của exp)
base = (base * base) % mod;
// Dịch phải exp 1 bit (chia exp cho 2)
exp /= 2;
}
return res;
}
// Ví dụ sử dụng lũy thừa nhị phân
int main_power_example() {
long long base = 2;
long long exp = 10;
long long mod = 100;
long long result = power(base, exp, mod); // Tính 2^10 mod 100
std::cout << base << "^" << exp << " mod " << mod << " = " << result << std::endl; // Kết quả: 1024 mod 100 = 24
base = 1000000007; // Một số lớn
exp = 1000000000; // Số mũ rất lớn
mod = 1000000009; // Mô đun lớn
result = power(base, exp, mod);
std::cout << base << "^" << exp << " mod " << mod << " = " << result << std::endl; // Sẽ in ra kết quả đúng mà không bị tràn số
return 0;
}
Giải thích code:
- Hàm
power
nhậnbase
,exp
,mod
. res = 1
là phần tử đơn vị cho phép nhân.base %= mod
xử lý cơ số ban đầu.- Vòng lặp
while (exp > 0)
xử lý từng bit của số mũexp
, từ bit thấp nhất đến bit cao nhất. if (exp % 2 == 1)
: Kiểm tra bit cuối cùng củaexp
. Nếu là 1 (tứcexp
lẻ), điều đó có nghĩa làbase^(2^i)
(vớii
là số lần lặp đã qua) là một thừa số trong kết quả cuối cùng. Ta nhânbase
hiện tại vàores
. Phép nhân(res * base) % mod
luôn giữ kết quả trong phạm vi mô đun.base = (base * base) % mod
: Tínhbase^(2 * current_power_of_2)
cho lần lặp tiếp theo. Lại lấy mô đun để tránh tràn số.exp /= 2
: Dịch phải số mũ, loại bỏ bit đã xử lý và chuẩn bị cho bit tiếp theo.- Khi
exp
trở thành 0, vòng lặp kết thúc, vàres
chứa kết quảbase^original_exp mod mod
.
Độ phức tạp: O(log exp), vì trong mỗi lần lặp, số mũ exp
được chia đôi. Đây là phương pháp chuẩn khi cần tính lũy thừa mô đun với số mũ lớn.
III. Kết hợp Số Nguyên Tố và Đồng Dư trong Bài Toán
Số nguyên tố và đồng dư thường xuất hiện cùng nhau trong các bài toán yêu cầu tính toán trên số lớn hoặc các bài toán liên quan đến cấu trúc số học.
Ví dụ về một bài toán có thể sử dụng cả hai khái niệm:
Bài toán: Cho một số nguyên dương N và một mô đun M. Tính tổng của tất cả các số nguyên tố nhỏ hơn hoặc bằng N, lấy kết quả theo mô đun M.
Input: N, M
Output: (p1 + p2 + ... + pk) mod M
, trong đó p1, p2, ..., pk
là tất cả số nguyên tố <= N
.
Phân tích:
- Cần tìm tất cả số nguyên tố <= N. Sàng Eratosthenes là lựa chọn hiệu quả nhất.
- Cần tính tổng các số nguyên tố này. Vì tổng có thể rất lớn, chúng ta cần áp dụng tính chất đồng dư của phép cộng. Thay vì tính tổng S = p1 + p2 + ... + pk rồi mới lấy S mod M, chúng ta sẽ tính S = (S + pi) mod M cho từng số nguyên tố pi.
Giải thuật:
- Sử dụng Sàng Eratosthenes để tạo danh sách/vector boolean
is_prime
cho các số từ 0 đến N. - Khởi tạo biến
sum = 0
. - Duyệt qua các số
i
từ 2 đến N. - Nếu
is_prime[i]
làtrue
(nghĩa lài
là số nguyên tố), cập nhậtsum = (sum + i) % M
. - Sau khi duyệt hết các số đến N,
sum
chính là kết quả cần tìm.
Code minh họa:
#include <iostream>
#include <vector>
#include <numeric> // Thư viện cho std::accumulate nếu cần, nhưng ở đây dùng vòng lặp
// Hàm Sàng Eratosthenes (copy từ phần trước)
std::vector<bool> sieveOfEratosthenes(int n) {
std::vector<bool> is_prime(n + 1, true);
is_prime[0] = is_prime[1] = false;
for (int p = 2; p * p <= n; p++) {
if (is_prime[p] == true) {
for (int i = p * p; i <= n; i += p)
is_prime[i] = false;
}
}
return is_prime;
}
long long sumPrimesModulo(int n, int m) {
// Bước 1: Tìm các số nguyên tố <= n bằng Sàng Eratosthenes
std::vector<bool> is_prime = sieveOfEratosthenes(n);
// Bước 2 & 3: Khởi tạo tổng và duyệt các số
long long sum = 0;
for (int i = 2; i <= n; ++i) {
// Bước 4: Nếu i là số nguyên tố
if (is_prime[i]) {
// Cộng i vào tổng và lấy mô đun M ngay lập tức
sum = (sum + i) % m;
}
}
// Bước 5: Trả về kết quả cuối cùng
return sum;
}
// Ví dụ sử dụng hàm sumPrimesModulo
int main_combined_example() {
int n = 100; // Tìm tổng các số nguyên tố <= 100
int m = 1000; // Lấy kết quả modulo 1000
long long result = sumPrimesModulo(n, m);
std::cout << "Tong cac so nguyen to nho hon hoac bang " << n
<< " modulo " << m << " la: " << result << std::endl;
n = 10000; // Phạm vi lớn hơn
m = 1000000007; // Mô đun lớn (thường dùng trong lập trình thi đấu)
result = sumPrimesModulo(n, m);
std::cout << "Tong cac so nguyen to nho hon hoac bang " << n
<< " modulo " << m << " la: " << result << std::endl; // Kết quả sẽ được tính đúng modulo M
return 0;
}
Giải thích code:
- Chúng ta tái sử dụng hàm
sieveOfEratosthenes
đã viết ở trên. - Hàm
sumPrimesModulo(int n, int m)
nhận giới hạnn
và mô đunm
. - Gọi
sieveOfEratosthenes(n)
để có vectoris_prime
. - Khởi tạo
sum
kiểulong long
để tránh tràn số trước khi lấy mô đun (mặc dù với phép cộng từng bước thìlong long
có thể không bắt buộc nếum
không quá lớn, nhưng dùnglong long
là an toàn). - Duyệt từ 2 đến N.
- Nếu
is_prime[i]
làtrue
, ta cộngi
vàosum
. Phép tính quan trọng làsum = (sum + i) % m;
. Điều này đảm bảosum
luôn nằm trong phạm vi[0, m-1]
, ngăn chặn tràn số ngay cả khi tổng các số nguyên tố rất lớn. Đây chính là ứng dụng trực tiếp của tính chất cộng của đồng dư. - Hàm
main_combined_example
minh họa cách gọi hàm với các tham số khác nhau.
Độ phức tạp: Độ phức tạp chính là độ phức tạp của Sàng Eratosthenes, tức O(N log log N), cộng thêm một vòng lặp duyệt N số để tính tổng (O(N)). Tổng cộng vẫn là O(N log log N), rất hiệu quả.
Bài tập ví dụ:
Sinh vật trong sở thú
Trong một chuyến tham quan sở thú, FullHouse Dev được giao nhiệm vụ nghiên cứu về hai loài sinh vật đặc biệt. Họ nhận thấy rằng mỗi loài có số tay khác nhau và cần tìm ra cách để các sinh vật có thể nắm tay nhau một cách hợp lý nhất.
Bài toán
Trong sở thú có hai loại sinh vật, loại A có \(a\) tay và loại B có \(b\) tay. Nhiệm vụ là tìm ra số lượng sinh vật ít nhất sao cho chúng có thể nắm tay nhau theo các điều kiện sau:
- Mỗi sinh vật chỉ được nắm tay với sinh vật khác loại
- Mỗi sinh vật phải sử dụng hết số tay của mình
- Đảm bảo rằng với điều kiện đã cho, đáp án là duy nhất
INPUT FORMAT:
- Dòng đầu tiên chứa số nguyên \(T\) - số lượng test case
- \(T\) dòng tiếp theo, mỗi dòng chứa hai số nguyên \(a\) và \(b\) - số tay của sinh vật loại A và loại B
OUTPUT FORMAT:
- \(T\) dòng, mỗi dòng chứa hai số nguyên \(x\) và \(y\) - số lượng sinh vật loại A và số lượng sinh vật loại B cần thiết. Lưu ý rằng tổng \(x + y\) phải là nhỏ nhất có thể
Ràng buộc:
- \(1 \leq T \leq 100\)
- \(1 \leq a, b \leq 100\)
Ví dụ
INPUT
1
20 2
OUTPUT
1 10
Giải thích
- Cần ít nhất 1 sinh vật loại A và 10 sinh vật loại B
- Một sinh vật loại A có 20 tay
- Mười sinh vật loại B có tổng cộng 20 tay
- Như vậy, các sinh vật có thể nắm tay nhau theo đúng yêu cầu đề bài ```cpp // Hướng dẫn giải bài "Sinh vật trong sở thú"
/*
Phân tích yêu cầu:
- Có
x
sinh vật loại A (mỗi con cóa
tay) vày
sinh vật loại B (mỗi con cób
tay). - Tổng số tay loại A:
x * a
. - Tổng số tay loại B:
y * b
. - Mỗi sinh vật dùng hết tay.
- Nắm tay chỉ xảy ra giữa loại A và loại B.
- Điều này có nghĩa là tổng số tay của loại A phải bằng tổng số tay của loại B để không có tay nào bị thừa.
- Phương trình cần giải:
x * a = y * b
. - Cần tìm cặp số nguyên dương
(x, y)
thỏa mãn phương trình và có tổngx + y
nhỏ nhất.
- Có
Tìm giải pháp tối thiểu:
- Từ phương trình
x * a = y * b
, ta có tỷ lệx / y = b / a
. - Để tìm cặp số nguyên dương
(x, y)
nhỏ nhất thỏa mãn tỷ lệ này, ta cần rút gọn phân sốb/a
về dạng tối giản. - Cách rút gọn phân số
b/a
là chia cả tử sốb
và mẫu sốa
cho ước chung lớn nhất (UCLN) của chúng. - Gọi
g = UCLN(a, b)
. - Khi đó,
b / a = (b / g) / (a / g)
. - Cặp số nguyên dương nhỏ nhất thỏa mãn
x / y = (b/g) / (a/g)
chính làx = b/g
vày = a/g
.
- Từ phương trình
Các bước thực hiện:
- Đọc số lượng test case
T
. - Lặp lại
T
lần:- Đọc hai số
a
vàb
. - Tính ước chung lớn nhất
g
củaa
vàb
. Có thể dùng thuật toán Euclid hoặc hàmstd::gcd
trong C++ (cần include<numeric>
, có từ C++17). - Kết quả
x
làb / g
. - Kết quả
y
làa / g
. - In ra
x
vày
, cách nhau một khoảng trắng, kết thúc bằng xuống dòng.
- Đọc hai số
- Đọc số lượng test case
Hàm UCLN (nếu không dùng std::gcd):
- Có thể viết một hàm
int gcd(int p, int q)
sử dụng thuật toán Euclid:int gcd(int p, int q) { while (q != 0) { int temp = q; q = p % q; p = temp; } return p; }
- Có thể viết một hàm
*/
// Gợi ý về cấu trúc code C++: /*
include <iostream>
include <numeric> // Cho std::gcd (C++17 trở lên), nếu không thì tự cài hàm gcd
// Viết hàm gcd nếu cần (trước C++17) // int gcd(int a, int b) { ... }
int main() { // Tắt đồng bộ cout và cin cho nhanh (tùy chọn) std::ios_base::sync_with_stdio(false); std::cin.tie(NULL);
int T;
std::cin >> T;
while (T--) {
int a, b;
std::cin >> a >> b;
// Tính UCLN
int common_divisor = std::gcd(a, b); // Hoặc dùng hàm gcd tự cài: gcd(a, b);
// Tính số lượng sinh vật
int x = b / common_divisor;
int y = a / common_divisor;
// In kết quả
std::cout << x << " " << y << std::endl;
}
return 0;
} */ ```
Comments