Cặp Số Chia Hết Cho 3 (BAI3)
Đề bài
Bài 3: Cặp số chia hết cho 3
Với hai số nguyên dương $u, v$, khi viết số $v$ sau số $u$ ta được một số mới.
Ví dụ: $u = 99$, $v = 123$, khi viết số $v$ sau số $u$, ta được số mới là số $99123$.
Cho $n$ số nguyên dương $a_1, a_2, \ldots, a_n$ và $m$ số nguyên dương $b_1, b_2, \ldots, b_m$. Với mỗi giá trị $b_i$ $(1 \le i \le m)$, bạn hãy cho biết có bao nhiêu số $a_j$ $(1 \le j \le n)$ sao cho khi viết $a_j$ sau $b_i$ được một số mới chia hết cho 3?
Dữ liệu: vào từ file văn bản BAI3.INP
- Dòng đầu chứa hai số nguyên dương $n, m$ $(1 \le n, m \le 100000)$.
- Dòng tiếp theo chứa $n$ số nguyên dương $a_1, a_2, \ldots, a_n$ $(a_i \le 10^8,\ 1 \le i \le n)$.
- Dòng tiếp theo chứa $m$ số nguyên dương $b_1, b_2, \ldots, b_m$ $(b_i \le 10^8,\ 1 \le i \le m)$.
Kết quả: ghi ra file văn bản BAI3.OUT trên $m$ dòng, dòng thứ $i$ ghi số lượng số $a_j$ $(1 \le j \le n)$ sao cho khi viết $a_j$ sau $b_i$ được một số mới chia hết cho 3.
Ràng buộc: 60% số test tương ứng với 60% số điểm có $1 \le n, m \le 1000$.
(Khi chạy thử/nộp trên hệ thống chấm online thì đọc dữ liệu từ bàn phím và in ra màn hình như code trong bài.)
5 2
123 4 5 7 10
3 21
3Giải thích: $b_1 = 3$, có duy nhất một số $a_1$ khi viết sau $b_1$ tạo thành số $3123$ chia hết cho 3. $b_2 = 2$, có ba số $a_2, a_4, a_5$ khi viết sau $b_2$ tạo thành các số $24, 27, 210$ chia hết cho 3.
Lời mở đầu
Bài này mình được một bạn hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. Bài này rất hay để thấy được cái "đã" của giải thuật: từ hơn 10 tỷ lần lặp rút xuống chỉ còn 200 ngàn lần, mà code còn ngắn hơn nữa.
Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:
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 OJGiải thích thêm ví dụ: với $b_1 = 3$, ta lần lượt ghép từng $a_j$ vào sau: $3123$ ✔, $34$, $35$, $37$, $310$ ⇒ chỉ có $3123$ chia hết cho 3 ⇒ in 1. Với $b_2 = 2$: $2123$, $24$ ✔, $25$, $27$ ✔, $210$ ✔ ⇒ in 3.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Làm đúng như đề: ghép số thật – O(N·M)
Cách dễ nhất nhưng chắc chắn không tối ưu là làm đúng như đề mô tả: với mỗi $b_i$, ghép lần lượt từng $a_j$ vào sau nó, kiểm tra số mới có chia hết cho 3 không, có thì tăng biến đếm. Xét hết các $a_j$ thì in kết quả, rồi chuyển sang $b_i$ tiếp theo và lại xét từ đầu dãy $a$. Cách này là vét cạn, không tối ưu, nhưng vẫn nên cài đặt để sau này lấy nó làm công cụ kiểm tra tính chính xác cho cách tối ưu.
Ta không cần tạo mảng $b$: mỗi $b_i$ chỉ dùng xong rồi bỏ, nên chỉ cần đọc vào một biến $x$ rồi xử lý luôn, không tốn bộ nhớ cho cả mảng $m$ phần tử.
int chứa vừa, nhưng số sau khi ghép có thể lên tới 18 chữ số ⇒ int không chứa nổi, phải dùng long long. Nếu vẫn để $b_i$ kiểu int thì ở bước tính $b_i \times 10^k$, trình biên dịch lưu kết quả tạm theo kiểu của các biến tham gia (tức int) ⇒ tràn ngay, nên phải ép kiểu. Trong code của mình, $x$ đã là long long và pow(10, cnt) được ép về long long nên không lo. Kết quả ghép lưu vào biến tạm temp2 để không làm thay đổi $x$, vì $x$ còn dùng để ghép với các $a_j$ tiếp theo.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;
int main(){
int n, m;
cin >> n >> m;
int a[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
}
while(m--){
long long x;
cin >> x;
int res = 0;
for(int j = 0; j < n; ++j){
int temp = a[j];
int cnt = 0;
while(temp != 0){
temp /= 10;
cnt++;
}
long long temp2 = x * (long long)pow(10, cnt) + a[j];
if(temp2 % 3 == 0){
res++;
}
}
cout << res << endl;
}
return 0;
}
import sys
data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [int(v) for v in data[2:2 + n]]
out = []
for i in range(m):
x = int(data[2 + n + i])
res = 0
for aj in a:
temp = aj
cnt = 0
while temp != 0:
temp //= 10
cnt += 1
temp2 = x * 10 ** cnt + aj # ghép aj vào sau x
if temp2 % 3 == 0:
res += 1
out.append(str(res))
print('\n'.join(out))
Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng $a$ ⇒ $O(N)$.
- Thời gian: $O(N)$ đọc mảng $a$ + $O(M \cdot (N + \log a_j))$ cho phần xử lý chính (với mỗi cặp còn phải đếm chữ số của $a_j$). Trường hợp xấu nhất $N = M = 10^5$, $a_j$ có 8–9 chữ số ⇒ cỡ $10^5 \cdot 10^5 = 10^{10}$ lần lặp, tức hơn 10 tỷ lần, vượt xa ngưỡng $10^8$ ⇒ chắc chắn TLE, chỉ ăn được 60% số test có $n, m \le 1000$.
Cách 2Dấu hiệu chia hết cho 3: tổng các chữ số – O(N·M)
Hướng này độ phức tạp vẫn tương tự (vẫn chưa tối ưu), nhưng không cần mất công ghép số nữa. Dựa vào dấu hiệu quen thuộc: một số chia hết cho 3 khi và chỉ khi tổng các chữ số của nó chia hết cho 3. Số mới ghép $a_j$ sau $b_i$ có các chữ số chính là chữ số của $b_i$ rồi tới chữ số của $a_j$ ⇒ tổng chữ số của số mới = tổng chữ số của $b_i$ + tổng chữ số của $a_j$.
- Khi đọc mỗi $a_j$, tính luôn tổng các chữ số của nó và lưu đè vào $a_j$.
- Đọc từng $b_i$ vào biến $x$ (không cần mảng $b$), tính tổng chữ số
sumcủa nó, rồi duyệt các $a_j$: nếu(sum + a[j]) % 3 == 0thì ghép $a_j$ sau $x$ sẽ chia hết cho 3 ⇒ tăng biến đếm.
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;
int main(){
int n, m;
cin >> n >> m;
int a[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
int sum = 0;
while(a[i] != 0){
sum += a[i] % 10;
a[i] /= 10;
}
a[i] = sum;
}
while(m--){
int x;
cin >> x;
int sum = 0;
while(x != 0){
sum += x % 10;
x /= 10;
}
int res = 0;
for(int j = 0; j < n; ++j){
if((sum + a[j]) % 3 == 0){
res++;
}
}
cout << res << endl;
}
return 0;
}
import sys
def tong_chu_so(v):
s = 0
while v != 0:
s += v % 10
v //= 10
return s
data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [tong_chu_so(int(v)) for v in data[2:2 + n]] # a[i] = tổng chữ số của a[i]
out = []
for i in range(m):
s = tong_chu_so(int(data[2 + n + i]))
res = 0
for aj in a:
if (s + aj) % 3 == 0:
res += 1
out.append(str(res))
print('\n'.join(out))
Độ phức tạp cả về không gian hay thời gian vẫn tương tự cách 1: $O(N)$ bộ nhớ và $O(N \cdot M)$ thời gian ⇒ vẫn TLE với $N = M = 10^5$.
Cách 3Chỉ cần quan tâm số dư khi chia 3 – O(N + M)
Hai cách trên tuy đều ra kết quả đúng nhưng chẳng cách nào tối ưu cả. Cách tối ưu thực sự cho bài này đúng chất là tối ưu hơn hẳn cả về thời gian, bộ nhớ và thậm chí code còn ngắn gọn hơn rất nhiều, nếu các bạn nhìn ra được bản chất vấn đề hehe.
Như cách 2, gọi $x$ là tổng các chữ số của $a_j$, $y$ là tổng các chữ số của $b_i$. Cặp thoả mãn khi $(x + y) \bmod 3 = 0$. Phép chia dư cho 3 thì kết quả chỉ có thể là $0$, $1$ hoặc $2$, và ta có quy tắc modulo:
| $x \bmod 3$ | $y \bmod 3$ cần có | Kiểm tra |
|---|---|---|
| 0 | 0 | $(0 + 0) \bmod 3 = 0$ |
| 1 | 2 | $(1 + 2) \bmod 3 = 0$ |
| 2 | 1 | $(2 + 1) \bmod 3 = 0$ |
Nhìn sâu hơn nữa: số $14$ có tổng chữ số $1 + 4 = 5$, $5 \bmod 3 = 2$, và $14 \bmod 3$ cũng bằng $2$. Số $15$ có tổng $6$, $6 \bmod 3 = 0$, và $15 \bmod 3 = 0$. Số $16$ có tổng $7$, $7 \bmod 3 = 1$, và $16 \bmod 3 = 1$. Tức là một số và tổng các chữ số của nó luôn có cùng số dư khi chia cho 3 ⇒ ta chẳng cần tính tổng chữ số nữa, lấy luôn $a_j \bmod 3$ và $b_i \bmod 3$ là được!
Đến đây đã quá rõ ràng, nhưng nếu xử lý không khéo thì vẫn sẽ phức tạp vấn đề. Sẽ rất đơn giản nếu ta dùng kỹ thuật mảng đánh dấu:
- Tạo mảng đếm
acó 3 phần tử (chỉ số 0..2), khởi tạo toàn 0. Với mỗi giá trị $x$ đọc vào của dãy $a$ (lại chẳng cần lưu dãy $a$, chỉ dùng một biến đại diện $x$), tănga[x % 3]++. Vậya[r]là số lượng số trong dãy $a$ có số dư $r$ khi chia 3. - Đọc lần lượt từng $b_i$ vào $x$ (cũng không cần mảng $b$): nếu $x \bmod 3 = 0$ thì đáp án là
a[0]; nếu $x \bmod 3 = 1$ thì đáp án làa[2]; nếu $x \bmod 3 = 2$ thì đáp án làa[1].
cout << a[(3 - (x % 3)) % 3]; chính là cách viết gọn của:
if(x % 3 == 0) cout << a[0];
else if(x % 3 == 1) cout << a[2];
else cout << a[1];
Để hiểu thì cứ thế $x \bmod 3$ lần lượt là $0, 1, 2$: $(3 - 0) \bmod 3 = 0$, $(3 - 1) \bmod 3 = 2$, $(3 - 2) \bmod 3 = 1$. Đây cũng là chỗ để tối ưu code về mặt trình bày.Minh hoạ
Em nhập dãy $a$, dãy $b$ rồi bấm Tiếp: mỗi số $a_j$ được "thả" vào một trong 3 giỏ theo số dư khi chia 3, sau đó mỗi $b_i$ chỉ việc nhìn vào đúng một giỏ để lấy đáp án:
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;
int main(){
int n, m, x;
cin >> n >> m;
int a[3] = {0};
while(n--){
cin >> x;
a[x % 3]++;
}
while(m--){
cin >> x;
cout << a[(3 - (x % 3)) % 3] << endl;
}
return 0;
}
import sys
data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [0, 0, 0] # a[r] = số lượng số trong dãy a có số dư khi chia 3 là r
for i in range(n):
a[int(data[2 + i]) % 3] += 1
out = []
for i in range(m):
x = int(data[2 + n + i])
out.append(str(a[(3 - x % 3) % 3]))
print('\n'.join(out))
Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng đánh dấu chỉ có 3 phần tử, chẳng đáng kể ⇒ $O(1)$.
- Thời gian: $O(N) + O(M)$, theo quy tắc cộng Big O lấy theo thằng lớn nhất ⇒ $O(\max(N, M))$. Trường hợp xấu nhất $N = M = 10^5$ chỉ lặp $2 \cdot 10^5$ lần, tức 200 ngàn lần ⇒ yên tâm không sợ TLE.
Đến đây các bạn đã thấy cái hay của giải thuật rồi chứ? Từ hơn 10 tỷ lần lặp rút xuống chỉ còn 200 ngàn lần. Quá tuyệt vời đúng không các bạn 😍. Phải nặn óc ra suy nghĩ, nhưng chương trình chạy nhanh hơn rất nhiều, trong tình huống này còn tiết kiệm được bộ nhớ và rút ngắn số dòng code nữa, tối ưu đủ đường luôn rồi còn gì nữa 😁.
- 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