Bài 9: Độ phức tạp thuật toán, đọc ghi file và cách bài được chấm trên Online Judge (AC, WA, TLE)
Lời mở đầu
Sau 8 bài, em đã viết được kha khá chương trình. Nhưng khi nộp lên ClueOJ, Codeforces hay đi thi học sinh giỏi, rất nhiều bạn gặp cảnh: “Thầy ơi em chạy ví dụ ra đúng mà sao chỉ được 40 điểm?”. Nguyên nhân thường không nằm ở ý tưởng mà ở ba thứ: chương trình chạy quá chậm với dữ liệu lớn, tràn số, hoặc quên đọc ghi file đúng yêu cầu đề. Bài này là “bài học sống còn” trước khi bước vào thế giới thuật toán: em sẽ biết cách ước lượng thời gian chạy chỉ bằng việc nhìn giới hạn đề bài, từ đó chọn được thuật toán đủ nhanh ngay từ đầu.
freopen, đọc đến hết file; tràn số và tăng tốc nhập xuất. Có khung chạy tay đếm số phép tính, mô phỏng “máy ước lượng thời gian” và 8 bài tập kèm gợi ý.Lộ trình bài viết, bấm vào từng phần để nhảy tới:
Phần 1Máy chấm (Online Judge) làm gì với bài của em?
Khi em bấm Nộp bài, máy chấm không “đọc hiểu” code của em như thầy cô. Nó chỉ làm đúng một việc lặp đi lặp lại: biên dịch chương trình, rồi chạy nó với từng bộ test (dữ liệu vào bí mật do người ra đề chuẩn bị), so kết quả em in ra với đáp án. Mỗi test đúng được một phần điểm. Mỗi lần chạy bị giới hạn thời gian (thường 1 giây) và bộ nhớ (thường 256 MB).
Máy chấm biên dịch, chạy từng test trong giới hạn thời gian và bộ nhớ, rồi so kết quả. Mỗi test trả về một trạng thái.
| Trạng thái | Nghĩa là | Nguyên nhân hay gặp |
|---|---|---|
| AC | Đúng và đủ nhanh | Chúc mừng 🎉 |
| WA | Kết quả sai | Sai thuật toán, sót trường hợp đặc biệt ($n = 1$, số âm, số 0), tràn số, in thiếu/thừa dấu cách hoặc xuống dòng sai định dạng |
| TLE | Chạy quá thời gian | Thuật toán có độ phức tạp quá lớn so với $n$, vòng lặp vô hạn, nhập xuất chậm |
| MLE | Quá bộ nhớ | Khai báo mảng quá to (ví dụ long long a[10000][10000] ≈ 800 MB) |
| RTE | Chương trình bị “sập” | Truy cập ngoài mảng, chia cho 0, đệ quy quá sâu, mảng lớn khai báo trong main |
| CE | Không biên dịch được | Thiếu thư viện, sai cú pháp, chọn nhầm ngôn ngữ khi nộp |
Mô phỏng trực quan. Em đã biết AC, WA, TLE là gì, nhưng tận mắt thấy máy chấm làm việc vẫn dễ nhớ hơn nhiều. Em chọn một trong bốn bài nộp cho đề "tính 1 + 2 + … + n", bấm Tiếp để xem máy chấm chạy từng test, bấm giờ, so đáp án; để ý vì sao có bài qua hết test nhỏ mà vẫn trượt test lớn.
Phần 2Độ phức tạp thuật toán và ký hiệu O lớn
Để biết chương trình nhanh hay chậm, ta không bấm giờ mà đếm số phép tính cơ bản (cộng, so sánh, gán…) nó phải làm, tính theo kích thước dữ liệu $n$. Ta chỉ quan tâm số phép tính tăng nhanh cỡ nào khi $n$ tăng, bỏ qua hằng số nhỏ. Cách viết gọn đó là ký hiệu O lớn: nếu chương trình làm khoảng $3n + 5$ phép tính, ta nói nó có độ phức tạp $O(n)$; khoảng $\frac{n(n-1)}{2}$ phép tính thì là $O(n^2)$.
Ví dụ 1: tính tổng $1 + 2 + \dots + n$. Cách dùng vòng lặp làm $n$ lần cộng, độ phức tạp $O(n)$. Cách dùng công thức Gauss $\frac{n(n+1)}{2}$ chỉ làm 3 phép tính dù $n$ lớn cỡ nào, độ phức tạp $O(1)$. Khung chạy tay dưới đây có biến dem đếm số lần vòng lặp chạy để em thấy rõ:
Code C++ và Python
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
long long s1 = 0, dem = 0;
for(long long i = 1; i <= n; i++){
s1 += i;
dem++;
}
long long s2 = n * (n + 1) / 2;
cout << s1 << " " << s2 << " " << dem;
return 0;
}
n = int(input())
s1 = dem = 0
for i in range(1, n + 1): # O(n): n lần cộng
s1 += i
dem += 1
s2 = n * (n + 1) // 2 # O(1): chỉ 3 phép tính
print(s1, s2, dem)
Ví dụ 2: hai vòng lặp lồng nhau, $O(n^2)$. Đếm số cặp chỉ số $(i, j)$ với $i \lt j$ mà $a_i + a_j = k$. Cách “thử hết” dùng hai vòng for lồng nhau: với mỗi $i$, $j$ chạy từ $i + 1$ tới $n - 1$. Tổng số cặp phải xét là $(n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2}$, cỡ $n^2$. Khi $n$ gấp đôi, thời gian gấp bốn.
Code C++ và Python
#include <iostream>
using namespace std;
int a[100005];
int main(){
int n, k;
cin >> n >> k;
for(int i = 0; i < n; i++) cin >> a[i];
long long dem = 0, solan = 0;
for(int i = 0; i < n; i++){
for(int j = i + 1; j < n; j++){
solan++;
if(a[i] + a[j] == k) dem++;
}
}
cout << dem << " " << solan;
return 0;
}
n, k = map(int, input().split())
a = list(map(int, input().split()))
dem = solan = 0
for i in range(n):
for j in range(i + 1, n): # O(n^2): n(n-1)/2 cặp
solan += 1
if a[i] + a[j] == k:
dem += 1
print(dem, solan)
Ví dụ 3: chia đôi liên tục, $O(\log n)$. Cứ mỗi bước chia $n$ cho 2 (bỏ phần dư) tới khi còn 1. Số bước chỉ khoảng $\log_2 n$: với $n = 10^9$ chỉ cần khoảng 30 bước, $n = 10^{18}$ cũng chỉ 60 bước! Đây là lý do tìm kiếm nhị phân ở bài 6 nhanh như vậy.
Code C++ và Python
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
int dem = 0;
while(n > 1){
n /= 2;
dem++;
}
cout << dem;
return 0;
}
n = int(input())
dem = 0
while n > 1: # O(log n)
n //= 2
dem += 1
print(dem)
Độ phức tạp càng “cao” thì đường càng dựng đứng: chỉ cần $n$ tăng một chút, số phép tính đã bùng nổ.
for(i = 1; i * i <= n; i++) → $O(\sqrt{n})$. Hàm sort có sẵn → $O(n \log n)$. Hai đoạn code chạy nối tiếp nhau thì lấy cái lớn hơn: $O(n) + O(n^2) = O(n^2)$.Mô phỏng trực quan. Ký hiệu O lớn nghe khá trừu tượng, nên em hãy xem nó như một cuộc đua: bốn chương trình trong phần này cùng chạy với một giá trị n, mỗi bước mỗi chương trình làm một phép tính. Em tăng dần n (8, 12, 16) để thấy làn O(n²) bị bỏ xa thế nào.
Phần 3Ước lượng thời gian: quy tắc 108 phép tính mỗi giây
Một máy chấm bình thường làm được khoảng $10^8$ phép tính đơn giản mỗi giây (con số ước lượng, đủ dùng). Vậy với giới hạn 1 giây, em chỉ cần thay $n$ lớn nhất của đề vào độ phức tạp: nếu ra dưới khoảng $10^8$ thì yên tâm, vượt quá nhiều thì chắc chắn TLE. Bảng “tra ngược” dưới đây cho biết với $n$ cỡ nào thì nên nghĩ tới thuật toán nào:
| Giới hạn $n$ trong đề | Độ phức tạp chấp nhận được | Gợi ý thuật toán |
|---|---|---|
| $n \le 10$ – $20$ | $O(2^n)$, $O(n!)$ | Duyệt mọi trường hợp, quay lui |
| $n \le 500$ | $O(n^3)$ | Ba vòng lặp lồng nhau |
| $n \le 5000$ | $O(n^2)$ | Hai vòng lặp lồng nhau |
| $n \le 10^6$ | $O(n \log n)$ hoặc $O(n)$ | Sắp xếp, tìm kiếm nhị phân, mảng cộng dồn, đếm phân phối |
| $n \le 10^{12}$ | $O(\sqrt{n})$ | Duyệt tới căn bậc hai (ước số, nguyên tố) |
| $n \le 10^{18}$ | $O(\log n)$ hoặc $O(1)$ | Công thức toán, chia đôi, lũy thừa nhanh |
Em hãy thử “máy ước lượng” dưới đây: nhập $n$, máy tính giúp số phép tính và thời gian chạy dự kiến của từng độ phức tạp, đối chiếu với giới hạn 1 giây.
%, truy cập mảng nhảy lung tung chậm hơn phép cộng vài lần, còn Python chậm hơn C++ khoảng 10 – 50 lần (vì vậy thi HSG nên dùng C++). Ngoài thời gian, hãy tính cả bộ nhớ: một int tốn 4 byte, long long 8 byte; mảng $10^7$ số int tốn khoảng 40 MB, vẫn ổn với giới hạn 256 MB, nhưng $10^8$ số long long (800 MB) thì MLE.Phần 4Đọc ghi file với freopen
Đề thi học sinh giỏi thường yêu cầu: “Dữ liệu vào từ file BAI1.INP, kết quả ghi ra file BAI1.OUT”. Nếu em vẫn đọc từ bàn phím, máy chấm sẽ không đưa dữ liệu cho em và chương trình bị chấm 0 điểm dù thuật toán đúng hoàn toàn. Đây là lỗi mất điểm “oan” phổ biến nhất ở các kỳ thi! Cách đơn giản nhất trong C++ là thêm 2 dòng freopen ở đầu main: mọi lệnh cin/cout phía sau tự động đọc ghi file, phần còn lại của code giữ nguyên.
Code C++ và Python
#include <iostream>
using namespace std;
int main(){
freopen("BAI1.INP", "r", stdin); // "r" = read: đọc từ file
freopen("BAI1.OUT", "w", stdout); // "w" = write: ghi ra file
int a, b;
cin >> a >> b; // giờ cin đọc từ BAI1.INP
cout << a + b; // giờ cout ghi vào BAI1.OUT
return 0;
}
import sys
sys.stdin = open("BAI1.INP", "r")
sys.stdout = open("BAI1.OUT", "w")
a, b = map(int, input().split()) # input() giờ đọc từ file
print(a + b) # print() giờ ghi vào file
.INP khác .inp trên máy chấm Linux). 2. Đặt freopen sau lệnh cin: phải đặt ngay đầu main. 3. Nộp lên OJ không dùng file mà vẫn để freopen → đọc file không tồn tại, chương trình không có dữ liệu. Mẹo: khi luyện trên ClueOJ, đề nào ghi “dữ liệu vào từ bàn phím” thì xóa hoặc thêm // trước 2 dòng freopen. 4. Test trên máy mình: nhớ tạo file BAI1.INP cùng thư mục với file code rồi mới chạy, kết quả nằm trong BAI1.OUT chứ không hiện ra màn hình.Đọc đến hết file. Có đề không cho biết trước có bao nhiêu số, chỉ nói “file gồm nhiều số nguyên”. Khi đó dùng while(cin >> x): biểu thức cin >> x trả về “đúng” nếu đọc được một số, và trả về “sai” khi đã hết dữ liệu, vòng lặp tự dừng. Khung chạy tay dưới đây giả sử dữ liệu vào đã được freopen từ file:
Code C++ và Python
#include <iostream>
using namespace std;
int main(){
long long x, tong = 0;
int dem = 0;
while(cin >> x){
tong += x;
dem++;
}
cout << dem << " " << tong;
return 0;
}
import sys
a = list(map(int, sys.stdin.read().split())) # đọc toàn bộ file, tách thành các số
print(len(a), sum(a))
Phần 5Tràn số và tăng tốc nhập xuất
Tràn số là nguyên nhân số một gây WA “bí ẩn”. Kiểu int chỉ chứa được số trong khoảng $\pm 2{,}1 \cdot 10^9$. Nếu kết quả phép tính vượt quá, máy không báo lỗi mà “quay vòng” sang một số rất vô lý (thường là số âm). Ví dụ $n = 100000$ thì $n \cdot n = 10^{10}$ vượt quá int. Cách chữa: dùng long long (tới khoảng $9{,}2 \cdot 10^{18}$) và chú ý ép kiểu trước khi nhân: 1LL * n * n. Viết long long c = n * n; với n là int vẫn tràn, vì phép nhân được làm trên int trước rồi mới gán.
Code C++ và Python
#include <iostream>
using namespace std;
int main(){
int n;
cin >> n;
int a = n * n;
long long b = n * n;
long long c = 1LL * n * n;
cout << a << endl << b << endl << c;
return 0;
}
n = int(input())
# Python tự mở rộng số nguyên nên KHÔNG bao giờ tràn số
print(n * n)
Tăng tốc nhập xuất. Khi phải đọc tới $10^6$ số, bản thân việc nhập xuất bằng cin/cout cũng có thể làm TLE. Hai thói quen nên có trong mọi bài thi:
Code C++ và Python
#include <bits/stdc++.h> // gộp mọi thư viện chuẩn (dùng được trên máy chấm)
using namespace std;
int main(){
ios::sync_with_stdio(false); // tắt đồng bộ với scanf/printf → cin, cout nhanh gấp nhiều lần
cin.tie(nullptr);
int n;
cin >> n;
for(int i = 1; i <= n; i++)
cout << i << '\n'; // dùng '\n' thay cho endl (endl xả bộ đệm mỗi lần nên rất chậm)
return 0;
}
import sys
input = sys.stdin.readline # đọc nhanh hơn input() mặc định
n = int(input())
print('\n'.join(str(i) for i in range(1, n + 1))) # in một lần thay vì in n lần
#include <bits/stdc++.h>, using namespace std;, trong main viết ngay freopen (nếu đề yêu cầu) và hai dòng tăng tốc nhập xuất, dùng long long cho các đại lượng có thể lớn, in xuống dòng bằng '\n'. Trước khi nộp, tự hỏi: độ phức tạp của mình với $n$ lớn nhất là bao nhiêu phép tính?Bài tậpLuyện tư duy độ phức tạp
Mỗi bài đều có giới hạn rất lớn: cách “thử hết” sẽ bị TLE. Trước khi xem gợi ý, em hãy tự tính xem cách đơn giản nhất mất bao nhiêu phép tính, rồi nghĩ cách giảm xuống.
Cho $n$ ($1 \le n \le 10^9$). Tính $1 + 2 + \dots + n$.
1000000000500000000500000000💡 Gợi ý
long long.Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
cout << n * (n + 1) / 2;
return 0;
}
n = int(input())
print(n * (n + 1) // 2)
Cho $a, b, k$ ($1 \le a \le b \le 10^{18}$, $1 \le k \le 10^{18}$). Đếm số các số nguyên trong đoạn $[a, b]$ chia hết cho $k$.
1 20 36💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
long long a, b, k;
cin >> a >> b >> k;
cout << b / k - (a - 1) / k;
return 0;
}
a, b, k = map(int, input().split())
print(b // k - (a - 1) // k)
File TONG.INP chứa nhiều số nguyên (không biết trước bao nhiêu số, nằm trên nhiều dòng), mỗi số có trị tuyệt đối không quá $10^9$. Ghi ra file TONG.OUT tổng của chúng. (Trên khung ví dụ, coi như đọc từ bàn phím.)
3 5
-2 10
723💡 Gợi ý
while(cin >> x) như phần 4 để đọc tới hết file, nhớ freopen hai file. Tổng có thể vượt int nên dùng long long. Python: sum(map(int, sys.stdin.read().split())).Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
// freopen("TONG.INP", "r", stdin);
// freopen("TONG.OUT", "w", stdout);
long long x, tong = 0;
while(cin >> x) tong += x;
cout << tong;
return 0;
}
import sys
# sys.stdin = open("TONG.INP"); sys.stdout = open("TONG.OUT", "w")
print(sum(map(int, sys.stdin.read().split())))
Cho $n$ ($1 \le n \le 10^{18}$). Tìm số nguyên $k$ lớn nhất sao cho $2^k \le n$.
1006💡 Gợi ý
p = 1 lên gấp đôi cho tới khi p * 2 > n, cẩn thận tràn số ở bước nhân.Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
int k = 0;
while(n > 1){
n /= 2;
k++;
}
cout << k;
return 0;
}
n = int(input())
print(n.bit_length() - 1) # hoặc chia đôi như C++
Cho dãy $n$ số nguyên ($2 \le n \le 10^5$). Đếm số cặp chỉ số $i \lt j$ sao cho $a_i + a_j$ là số chẵn.
5
1 2 3 4 54💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
int n;
cin >> n;
long long c0 = 0, c1 = 0;
for(int i = 0; i < n; i++){
long long x;
cin >> x;
if(x % 2 == 0) c0++;
else c1++;
}
cout << c0 * (c0 - 1) / 2 + c1 * (c1 - 1) / 2;
return 0;
}
n = int(input())
a = list(map(int, input().split()))
c0 = sum(1 for x in a if x % 2 == 0)
c1 = n - c0
print(c0 * (c0 - 1) // 2 + c1 * (c1 - 1) // 2)
Cho số nguyên dương $n$ ($n \le 10^{18}$). Tính tổng các chữ số của $n$.
987654321045💡 Gợi ý
n % 10, bỏ chữ số cuối bằng n /= 10, lặp tới khi $n = 0$. Số lần lặp bằng số chữ số (tối đa 19), độ phức tạp $O(\log_{10} n)$. Nhớ dùng long long.Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
int tong = 0;
while(n > 0){
tong += n % 10;
n /= 10;
}
cout << tong;
return 0;
}
n = input().strip()
print(sum(int(c) for c in n))
Cho $n$ ($1 \le n \le 10^{12}$). Đếm số ước nguyên dương của $n$.
369💡 Gợi ý
for(i = 1; i * i <= n; i++), nếu n % i == 0 thì cộng 2 (cặp $i$ và $n/i$), riêng khi i * i == n chỉ cộng 1. Chỉ $10^6$ bước, độ phức tạp $O(\sqrt{n})$. Bài 12 sẽ học kỹ hơn.Xem code tham khảo (C++ / Python)
#include <iostream>
using namespace std;
int main(){
long long n;
cin >> n;
long long dem = 0;
for(long long i = 1; i * i <= n; i++){
if(n % i == 0){
if(i * i == n) dem += 1;
else dem += 2;
}
}
cout << dem;
return 0;
}
n = int(input())
dem = 0
i = 1
while i * i <= n:
if n % i == 0:
dem += 1 if i * i == n else 2
i += 1
print(dem)
Cho dãy $n$ số nguyên dương tăng dần chặt ($n \le 10^6$) và số $x$. Đếm số cặp $i \lt j$ có $a_i + a_j = x$.
6 10
1 2 4 6 8 93💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
int a[1000005];
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long x;
cin >> n >> x;
for(int i = 0; i < n; i++) cin >> a[i];
int l = 0, r = n - 1;
long long dem = 0;
while(l < r){
long long s = (long long)a[l] + a[r];
if(s == x){ dem++; l++; r--; }
else if(s < x) l++;
else r--;
}
cout << dem;
return 0;
}
n, x = map(int, input().split())
a = list(map(int, input().split()))
l, r, dem = 0, n - 1, 0
while l < r:
s = a[l] + a[r]
if s == x:
dem += 1; l += 1; r -= 1
elif s < x:
l += 1
else:
r -= 1
print(dem)
- 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