Có những bài đọc xong không gọi được tên thuật toán nào: không có dãy cần sắp xếp, không có đồ thị, không có công thức quy hoạch động. Đề chỉ kể một trò chơi nhỏ hay một quá trình lặp đi lặp lại, rồi hỏi kết quả với tới . Lời giải thường là một quan sát ngắn, kiểu “cứ bốn bước lại về chỗ cũ”, và code chỉ vài dòng.
Những bài như vậy gọi là ad hoc: lời giải dành riêng cho đúng bài đó. Không có công thức chung, nhưng có vài thói quen giúp tìm ra quan sát: thử với đầu vào nhỏ, tìm đại lượng không đổi, nghĩ ngược từ kết quả, chặn cận rồi dựng cách đạt cận, gom các trường hợp về vài dạng. Bài này đi qua từng thói quen.
Thử nhỏ, tìm quy luật
Có bóng đèn xếp hàng, đánh số từ 1, ban đầu đều tắt. Ở vòng () ta bấm công tắc của mọi bóng có số thứ tự chia hết cho . Sau vòng có bao nhiêu bóng sáng, với tới ?
Mô phỏng đúng như đề tốn lần bấm, khoảng khi : được với , vô vọng với . Chưa vội nghĩ cách nhanh. Cứ viết bản chậm mà chắc đúng, chạy với nhỏ, rồi nhìn kết quả.
Với , mỗi cột là một bóng, ô tô màu là vòng có bấm bóng đó. Đọc theo cột thì bóng bị bấm một lần cho mỗi ước của . Bóng sáng khi số lần bấm lẻ, và trong 12 bóng chỉ có 1, 4, 9 thỏa: đều là số chính phương. Giả thuyết: bóng sáng khi và chỉ khi là số chính phương.
Chứng minh ngắn: các ước của ghép thành cặp với , mỗi cặp góp hai ước nên số ước chẵn. Chỉ khi , ước đứng một mình và số ước lẻ. Vậy số bóng sáng là số các số chính phương không vượt , tức . Ký hiệu là phần nguyên của , số nguyên lớn nhất không vượt : vì . Chương trình dưới đối chứng công thức với vét cạn cho mọi :
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll on[3005];
ll brute(ll n) {
for (ll i = 1; i <= n; i++) on[i] = 0;
for (ll k = 1; k <= n; k++)
for (ll i = k; i <= n; i += k) on[i] = 1 - on[i];
ll cnt = 0;
for (ll i = 1; i <= n; i++) cnt += on[i];
return cnt;
}
ll isqrt(ll n) {
ll r = sqrtl(n);
while (r * r > n) r--;
while ((r + 1) * (r + 1) <= n) r++;
return r;
}
signed main() {
bool ok = true;
for (ll n = 1; n <= 3000; n++)
if (brute(n) != isqrt(n)) ok = false;
cout << (ok ? "khớp với mọi n từ 1 đến 3000" : "SAI") << '\n';
cout << isqrt(1e18) << '\n';
}
khớp với mọi n từ 1 đến 3000
1000000000
Hàm isqrt sửa kết quả của sqrtl bằng hai vòng while, vì căn số thực có thể lệch một đơn vị khi cỡ . Quy trình này dùng cho rất nhiều bài: viết vét cạn, in bảng với đầu vào nhỏ, đoán quy luật, rồi chứng minh hoặc ít nhất đối chứng với vét cạn trên mọi đầu vào nhỏ.
Bước cuối không phải thủ tục cho đủ. Nối điểm trên đường tròn bằng mọi dây cung thì hình tròn bị chia thành miền với . Rất dễ đoán , nhưng với kết quả là 31. Vài số hạng khớp chưa phải chứng minh.
Hỏi nhanh
Một bạn vét cạn một bài với và thấy đáp án là . Bạn kết luận đáp án là . Bước hợp lý nhất tiếp theo là gì?
Hữu hạn số hạng khớp với vô số công thức khác nhau, nên “công thức duy nhất” sai ngay từ đầu; số miền chia hình tròn khớp năm số hạng đầu rồi lệch. Nhưng bỏ giả thuyết chỉ vì nó đơn giản cũng sai: bài bóng đèn có đáp án , còn gọn hơn. Cách đúng là kiểm trên nhiều trường hợp hơn số đã dùng để đoán, và chứng minh nếu được.
Ví dụBước Nhảy Theo Chẵn Lẻ
Đề. Điểm xuất phát ở , . Bước thứ dịch đơn vị: sang trái nếu tọa độ ngay trước bước chẵn, sang phải nếu lẻ. In tọa độ sau bước, test.
Lời giải. Thử nhỏ với : vị trí sau bước là . Hướng đi là trái, phải, phải, trái, rồi lặp lại; mỗi khối bốn bước đưa điểm về chỗ cũ.
Vì sao chu kỳ 4: dịch đơn vị, trái hay phải, đều đổi chẵn lẻ của vị trí đúng bằng chẵn lẻ của . Vậy sau bước, vị trí cùng chẵn lẻ với , và dãy có chẵn lẻ là chẵn, lẻ, lẻ, chẵn rồi lặp lại. Bỏ các khối đủ bốn bước, chỉ còn r = n % 4 bước cuối; với chẵn, độ dời là , , , ứng với . Với lẻ mọi hướng đổi ngược nên độ dời đổi dấu.
Độ phức tạp: mỗi test .
Chỗ dễ sai là số lẻ âm: trong C++ -5 % 2 bằng -1, nên phải kiểm x % 2 != 0 chứ không phải x % 2 == 1.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, x, n;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> x >> n;
ll d = 0;
if (n % 4 == 1) d = -n;
if (n % 4 == 2) d = 1;
if (n % 4 == 3) d = n + 1;
if (x % 2 != 0) d = -d;
cout << x + d << '\n';
}
}
6
0 0
2 1
3 2
-5 3
10 4
-2 5
0
1
2
-9
10
-7
Mỗi test chỉ tốn vài phép tính, nên khi đề cho tới test, gần như toàn bộ thời gian nằm ở nhập xuất. Hai dòng tắt đồng bộ ở đầu main và '\n' thay cho endl (bài B.2) lúc này quyết định AC hay TLE:
cin mặc định chậm hơn bản tắt đồng bộ hơn 15 lần. Tắt đồng bộ mà vẫn dùng endl thì còn chậm gấp khoảng 10 lần, vì endl đẩy bộ đệm ra tệp sau từng dòng.
Bất biến
Nhiều bài hỏi “sau một chuỗi thao tác tùy ý, có đạt được trạng thái này không”. Vét cạn mọi chuỗi thao tác bùng nổ rất nhanh. Nhưng đôi khi có một đại lượng không bao giờ đổi; đại lượng đó là bất biến (invariant). Nếu trạng thái đích có giá trị bất biến khác trạng thái đầu thì đích không thể đạt được, dù đi bao nhiêu bước.
Bài quen thuộc nhất là lát domino. Bàn cờ bỏ hai ô ở hai góc đối diện, còn 62 ô. Có lát kín bằng 31 quân domino không? Tô bàn cờ hai màu như thường lệ. Mỗi quân domino phủ đúng một ô mỗi màu, nên hiệu “số ô xanh đã phủ trừ số ô trắng đã phủ” luôn bằng 0. Nhưng hai góc đối diện cùng màu, bỏ đi thì còn 30 ô một màu và 32 ô màu kia, nên không có cách lát nào.
Bất biến hay gặp: tổng, phần dư của tổng khi chia cho một số, tính chẵn lẻ, hiệu số ô của hai màu. Nhưng bất biến chỉ cho điều kiện cần. Muốn khẳng định “có thể” phải chỉ ra cách làm.
Thử một bài lập trình: dãy số, mỗi thao tác chọn hai phần tử kề nhau và đổi dấu cả hai; có đưa mọi phần tử về không âm được không? Nếu dãy không có số 0, số lượng số âm chỉ đổi , hoặc , nên chẵn lẻ của nó là bất biến: số âm lẻ thì không thể. Số 0 phá bất biến, vì đổi dấu số 0 vẫn là 0. Chiều ngược lại cần dựng: đổi dấu hai phần tử ở bằng chuỗi thao tác trên các cặp , các phần tử ở giữa bị đổi hai lần nên như cũ. Vậy đáp án là có khi và chỉ khi số âm chẵn hoặc dãy có số 0.
Hỏi nhanh
Dãy chỉ có một số âm. Thao tác: chọn hai phần tử kề nhau, đổi dấu cả hai. Có đưa được về dãy không âm không?
Ba thao tác: ; thao tác cuối đổi dấu cặp và số 0 vẫn là 0. Đáp án “không vì số lượng lẻ” quên ngoại lệ của số 0. Một thao tác trên chỉ chuyển số âm sang chỗ khác.
Ví dụThoát Khỏi Tầm Hậu
Đề. Bàn cờ (), hậu đứng yên ở , khống chế cả hàng, cột và hai đường chéo. Vua ở muốn tới , mỗi bước sang một trong tám ô kề, không bao giờ đứng trên ô bị khống chế. Đầu và đích đều an toàn. Vua có tới được không?
Lời giải. Hàng và cột của hậu chia bàn cờ thành bốn góc. Vua đi mỗi bước một ô nên muốn sang góc khác phải đứng lên hàng hoặc cột của hậu, mà đó là ô bị khống chế. Vậy “vua ở góc nào” là bất biến: nếu đầu và đích khác góc thì chắc chắn không tới được.
Chiều ngược lại cần dựng: trong một góc, các đường chéo của hậu chỉ đi qua đúng một góc theo một hướng, và vua bước chéo được nên vượt qua đường chéo ấy mà không đứng lên nó. Đi trong cùng một góc luôn tới được đích. Đáp án là có khi và chỉ khi cùng phía với và cùng phía với .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, ax, ay, bx, by, cx, cy;
signed main() {
cin >> n >> ax >> ay >> bx >> by >> cx >> cy;
bool okx = (bx < ax) == (cx < ax);
bool oky = (by < ay) == (cy < ay);
cout << (okx && oky ? "YES" : "NO") << '\n';
}
8
4 4
1 2
3 1
YES
Nghĩ ngược
Màn hình hiện số . Máy có hai nút: nhân đôi số trên màn hình, hoặc trừ nó đi 1. Ít nhất bao nhiêu lần bấm để màn hình hiện , với ?
Đi xuôi từ , mỗi bước có hai lựa chọn; thử mọi cách bấm phải đi qua hàng tỉ giá trị. Đi ngược từ thì khác: từ ta quay về (hoàn tác phép trừ) hoặc (hoàn tác nhân đôi, chỉ khi chẵn). Với lẻ chỉ còn một lựa chọn, vì kết quả của phép nhân đôi luôn chẵn.
Với chẵn và , cứ chia đôi. Nếu một đường đi tối ưu mở đầu bằng ở số chẵn, nó phải cộng 1 một số chẵn lần rồi mới chia đôi được, tới sau bước; chia đôi ngay rồi cộng lần cho cùng kết quả chỉ sau bước. Khi , còn lại lần cộng 1. Cứ hai vòng thì giảm ít nhất một nửa, mà chia đôi 30 lần là về 1, nên vòng lặp chạy không quá khoảng 60 lần.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, steps;
signed main() {
cin >> n >> m;
while (m > n) {
if (m % 2 == 1) m++;
else m /= 2;
steps++;
}
cout << steps + n - m << '\n';
}
3 10
3
Từ ta đi ngược ; đảo lại thành (nhân đôi, trừ 1, nhân đôi).
Hỏi nhanh
Ở số chẵn , vì sao đi ngược ta chia đôi thay vì cộng 1?
Lý do là phép thay thế: tốn 3 bước, còn tốn 2 bước và cùng kết quả. Số lẻ vẫn đi tiếp được bằng cách cộng 1 lần nữa. Chia đôi không phải lúc nào cũng lại gần hơn (với , chia đôi được 4), nhưng vẫn tối ưu nhờ phép thay thế trên.
Nghĩ ngược còn một dạng quen thuộc: các thao tác ghi đè lên nhau. Khi nhiều thao tác lần lượt gán giá trị cho cùng một ô, chỉ thao tác cuối cùng chạm vào ô đó còn thấy được.
Ví dụVệt Sơn Cuối Cùng
Đề. Bảng ban đầu trống. Mỗi thao tác tô cả một hàng bằng R hoặc cả một cột bằng B, màu mới ghi đè màu cũ. Cho bảng cuối (bảo đảm tạo được, có ít nhất một thao tác), hỏi thao tác cuối cùng tô màu gì. bảng.
Lời giải. Dựng lại cả chuỗi thao tác là việc khó, vì nhiều chuỗi cho cùng một bảng. Nhưng thao tác cuối không bị ai ghi đè nên vệt nó tô còn nguyên. Nếu thao tác cuối tô một hàng đỏ, bảng có một hàng đủ tám ô R. Nếu thao tác cuối tô một cột xanh, cột đó cắt mọi hàng, nên không hàng nào đủ tám ô R. Vậy có hàng RRRRRRRR thì in R, ngược lại in B.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
bool red = false;
for (ll i = 1; i <= 8; i++) {
string row;
cin >> row;
if (row == "RRRRRRRR") red = true;
}
cout << (red ? 'R' : 'B') << '\n';
}
}
2
.B....B.
.B....B.
.B....B.
RRRRRRRR
.B....B.
.B....B.
.B....B.
.B....B.
...B....
RRRBRRRR
...B....
...B....
...B....
...B....
...B....
RRRBRRRR
R
B
Dữ liệu có thể có dòng trống giữa các bảng; cin >> row tự bỏ qua khoảng trắng nên không phải xử lý riêng.
Chặn cận rồi dựng
Bài tối ưu kiểu ad hoc thường có hai nửa. Nửa thứ nhất chứng minh đáp án không thể tốt hơn một giá trị (cận). Nửa thứ hai chỉ ra cách làm đạt đúng . Hai nửa gặp nhau thì là đáp án; thiếu một nửa mới chỉ là phỏng đoán. Cách nghĩ này quay lại ở bài 0.5 (tham lam).
Ví dụ: chồng sỏi, chồng có viên; mỗi lượt lấy một viên từ hai chồng khác nhau. Làm được nhiều nhất bao nhiêu lượt? Gọi là tổng, là chồng lớn nhất. Mỗi lượt lấy 2 viên nên không quá lượt; mỗi lượt lấy ít nhất một viên ngoài chồng lớn nhất nên không quá lượt. Cận đạt được: nếu thì ghép mỗi viên ngoài chồng lớn nhất với một viên của nó; ngược lại xếp mọi viên thành hàng (cùng chồng đứng cạnh nhau) và ghép viên với viên , hai viên ghép luôn khác chồng vì một chồng dài không quá .
Cận thứ hai đến từ việc nhìn vào phần tử cực trị. Khi đề có quá nhiều lựa chọn, phần tử lớn nhất, nhỏ nhất hay đầu mút gần nhất thường là chỗ chặn đáp án.
Hỏi nhanh
Ba chồng sỏi có viên. Mỗi lượt lấy một viên từ hai chồng khác nhau. Nhiều nhất làm được bao nhiêu lượt?
Mỗi lượt phải lấy từ hai chồng khác nhau, nên chồng 5 viên luôn cần một viên của chồng khác; hai chồng kia chỉ có 2 viên, nên tối đa 2 lượt, và đạt được. Cận đúng nhưng chưa chặt. Đáp án 5 quên rằng các viên ở chồng khác hết sau hai lượt.
Ví dụBốn Ô Đồng Màu
Đề. Ảnh ô, mỗi ô một màu (chữ cái thường). Mỗi thao tác chọn không quá hai ô đang cùng màu và đổi chúng sang cùng một màu khác. Ít nhất bao nhiêu thao tác để bốn ô cùng màu? test.
Lời giải. Gọi là số màu khác nhau. Cận: một thao tác chỉ đổi các ô của một màu, nên làm biến mất nhiều nhất một màu; muốn còn một màu phải làm biến mất màu, tức cần ít nhất thao tác. Dựng: giữ màu chiếm nhiều ô nhất làm đích. Mỗi màu còn lại chiếm không quá hai ô (màu nào chiếm ba ô thì đã là màu nhiều nhất), nên đổi sang màu đích được trong một thao tác. Vậy đáp án là . Đếm bằng cách duyệt bốn ô: ô nào có màu chưa gặp ở các ô trước thì cộng 1.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t;
signed main() {
cin >> t;
while (t--) {
string a, b;
cin >> a >> b;
string s = a + b;
ll d = 0;
for (ll i = 0; i < 4; i++) {
bool seen = false;
for (ll j = 0; j < i; j++)
if (s[j] == s[i]) seen = true;
if (!seen) d++;
}
cout << d - 1 << '\n';
}
}
4
aa
aa
aa
ab
ab
ac
ab
cd
0
1
2
3
Gom về vài dạng
Đôi khi một không gian trông rất lớn nhưng chỉ có vài kiểu thật sự khác nhau. Việc cần làm là mô tả các kiểu đó rồi thử từng kiểu, thay vì tìm cách biến đổi tổng quát.
Ví dụ: đổi ít ký tự nhất trong xâu nhị phân để không còn hai ký tự kề nhau giống nhau. Xâu hợp lệ phải xen kẽ, và ký tự đầu quyết định cả xâu, nên chỉ có hai xâu đích: 0101… và 1010…. Đếm số vị trí lệch so với xâu thứ nhất; xâu thứ hai lệch đúng vị trí, đáp án là .
ll c = 0;
for (ll i = 0; i < n; i++)
if (s[i] - '0' != i % 2) c++;
cout << min(c, n - c) << '\n';
Cùng ý tưởng hiện ra ở nhiều chỗ. Quay ma trận lặp lại chỉ cho 4 trạng thái. Một dãy tuần hoàn chu kỳ chỉ có điểm bắt đầu khác nhau. Trước khi tối ưu cách duyệt, hãy đếm xem số trường hợp có thật sự là vô số không.
Ví dụMa Trận Sau Những Lần Quay
Đề. Ma trận gồm bốn số đôi một khác nhau. Ma trận đẹp nếu mỗi hàng tăng từ trái sang phải và mỗi cột tăng từ trên xuống. Được quay cả ma trận theo chiều kim đồng hồ bao nhiêu lần tùy ý; có thu được ma trận đẹp không?
Lời giải. Quay bốn lần trở về ban đầu, nên “bao nhiêu lần tùy ý” thật ra chỉ là 4 trạng thái: quay lần. Thử cả bốn. Quay theo chiều kim đồng hồ đưa ma trận có hàng trên a b, hàng dưới c d thành ma trận có hàng trên c a, hàng dưới d b.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, a, b, c, d;
signed main() {
cin >> t;
while (t--) {
cin >> a >> b >> c >> d;
bool ok = false;
for (ll k = 0; k < 4; k++) {
if (a < b && c < d && a < c && b < d) ok = true;
ll na = c, nb = a, nc = d, nd = b;
a = na, b = nb, c = nc, d = nd;
}
cout << (ok ? "YES" : "NO") << '\n';
}
}
4
1 3
2 4
3 1
4 2
1 4
2 3
4 3
2 1
YES
YES
NO
YES
Khi đi thi
- Đề chỉ mô tả một quá trình mà tới hay thì đáp án thường là công thức hoặc chu kỳ: viết vét cạn cho subtask nhỏ, in bảng và đoán quy luật.
- Hỏi “có thể hay không” qua các thao tác thì tìm bất biến trước, rồi tìm cách dựng cho phần “có thể”; hỏi tối ưu thì tìm cận từ phần tử cực trị, rồi dựng cách đạt cận.
- Đoán xong đừng nộp ngay: đối chứng với vét cạn trên thật nhiều đầu vào nhỏ, kể cả biên (, , số âm, mọi phần tử bằng nhau). Bài ad hoc thường hỏng ở biên hơn ở ý chính.
Tóm tắt
- Ad hoc là lời giải sinh ra từ cấu trúc riêng của bài; các động tác lặp lại: thử nhỏ, bất biến, nghĩ ngược, cận rồi dựng, gom về vài dạng.
- Thử nhỏ: vét cạn đúng, in bảng, đoán, rồi chứng minh hoặc đối chứng. Vài số hạng đầu khớp chưa phải chứng minh.
- Bất biến cho điều kiện cần; muốn khẳng định “có thể” phải chỉ ra cách làm cụ thể.
- Nghĩ ngược khi các bước xuôi phân nhánh: tìm bước bị ép buộc, nhớ rằng thao tác cuối không bị ghi đè.
- Bài tối ưu: chứng minh một cận, thường từ phần tử cực trị, rồi dựng cách đạt đúng cận.
- Phần dư của số âm trong C++ mang dấu âm: kiểm số lẻ bằng
x % 2 != 0.
Bài tập
Đọc thêm
- Ad Hoc ProblemsUSACO Guide
- Invariant PrincipleBrilliant
- Mutilated chessboard problemWikipedia
- Dividing a circle into areasWikipedia
- 构造OI Wiki