Trò chơi hai người, thông tin đầy đủ, không may rủi, luân phiên đi, người hết nước đi thì thua. Nhiều bài như vậy không cần lý thuyết Grundy: chỉ cần định nghĩa đúng vị trí thắng, vị trí thua, rồi tìm quy luật bằng cách tính các trạng thái nhỏ, đếm chẵn lẻ số lượt, hoặc dựng một chiến lược đối xứng để luôn có nước đáp trả. Khi hai người tranh điểm, DP minimax trên trạng thái làm nốt phần còn lại.
Vị trí thắng và vị trí thua
Một trạng thái là thua (cho người sắp đi) nếu mọi nước đi đều dẫn tới trạng thái thắng; là thắng nếu có ít nhất một nước tới trạng thái thua. Trạng thái không còn nước đi là thua. Khi đồ thị trạng thái không có chu trình, tính theo thứ tự ngược của nước đi: trạng thái “gần kết thúc” trước.
Với tham số tới , không thể tính hết, nhưng có thể tính bằng tay hoặc brute force cho nhỏ rồi nhìn ra chu kỳ, sau đó chứng minh chu kỳ đó bằng quy nạp: mỗi trạng thái thua chỉ dẫn tới trạng thái thắng, mỗi trạng thái thắng có nước tới trạng thái thua.
Hỏi nhanh
Trò lấy sỏi: mỗi lượt bỏ 1, 2 hoặc 3 viên, người không lấy được thua. Những nào là vị trí thua cho người đi trước?
Từ bội của 4, lấy bao nhiêu cũng để lại số không chia hết cho 4; từ số không chia hết cho 4, lấy đúng phần dư là về bội của 4. Vậy bội của 4 là thua, còn lại thắng.
Ví dụBước Nhảy Trên Hoán Vị
Đề. Hoán vị dài . Từ ô được nhảy tới ô nếu là bội dương của và . Với mỗi ô xuất phát, in A nếu người đi trước thắng, B nếu thua.
Lời giải. Mọi nước đi tăng giá trị, nên xét ô theo giảm dần: khi tới ô , mọi ô đích đã có kết quả. Ô thắng nếu có một ô đích thua.
Bước 1: duyệt theo giá trị giảm dần. at[v] là vị trí của giá trị .
Bước 2: các ô đích. Vòng j bắt đầu từ i % v, tăng theo bước : đi qua mọi ô cách một bội của , cả bên trái lẫn bên phải, trong một vòng. j >= 1 loại ô 0; a[j] > v giữ đúng điều kiện nước đi và tự loại chính ô .
for (ll v = n; v >= 1; v--) {
ll i = at[v];
for (ll j = i % v; j <= n; j += v)
if (j >= 1 && a[j] > v && !win[j]) win[i] = 1;
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5005;
ll n, a[N], at[N];
bool win[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
at[a[i]] = i;
}
for (ll v = n; v >= 1; v--) {
ll i = at[v];
for (ll j = i % v; j <= n; j += v)
if (j >= 1 && a[j] > v && !win[j]) win[i] = 1;
}
for (ll i = 1; i <= n; i++) cout << (win[i] ? 'A' : 'B');
cout << '\n';
}
1
1
B
Tổng số ô xét là .
Ví dụBước Nhảy 1-2-K
Đề. Quân ở ô trên dải , mỗi lượt lùi 1, 2 hoặc ô; không đi được thì thua. , , tối đa 100 test.
Lời giải. Không có bước , thua đúng khi chia hết cho 3. Nếu không chia hết cho 3, bước từ bội của 3 vẫn rơi vào số không chia hết cho 3, nên quy luật giữ nguyên. Nếu chia hết cho 3, brute force cho thấy chu kỳ : lấy , ô thắng (nhảy về 0), các khác thắng khi không chia hết cho 3.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll t, n, k;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n >> k;
bool alice;
if (k % 3) alice = n % 3 != 0;
else {
ll r = n % (k + 1);
alice = r == k || r % 3 != 0;
}
cout << (alice ? "Alice" : "Bob") << '\n';
}
}
1
0 3
Bob
Số lượt cố định: chỉ cần chẵn lẻ
Có những trò mà dù chơi thế nào, tổng số lượt vẫn như nhau. Khi đó không có chiến lược nào: người đi trước thắng khi và chỉ khi tổng số lượt lẻ. Dấu hiệu: mỗi lượt làm một đại lượng giảm đúng 1, và trò chỉ dừng khi đại lượng đó về một giá trị cố định.
Cần chứng minh tổng số lượt không phụ thuộc cách chơi, không chỉ đoán. Ví dụ, xóa cặp ký tự kề bằng nhau: kết quả cuối cùng (xâu không còn cặp nào) là duy nhất, giống như rút gọn ngoặc, nên số lần xóa luôn bằng nhau.
Ví dụNhững Khúc Gỗ
Đề. khúc gỗ dài . Mỗi lượt chặt một khúc dài ít nhất 2 thành hai khúc nguyên dương. Ai không chặt được thì thua; errorgorn đi trước.
Lời giải. Mỗi lượt tăng số khúc đúng 1, trò dừng khi mọi khúc dài 1, tức có khúc. Tổng số lượt là ; lẻ thì errorgorn thắng.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll t, n;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n;
ll s = 0;
for (ll i = 1; i <= n; i++) {
ll x;
cin >> x;
s += x - 1;
}
cout << (s % 2 ? "errorgorn" : "maomao90") << '\n';
}
}
1
1
1
maomao90
Ví dụCặp Ký Tự Biến Mất
Đề. Xâu chữ thường dài tới . Mỗi lượt xóa hai ký tự kề nhau bằng nhau; ai không xóa được thì thua. In Yes nếu người đi trước thắng.
Lời giải. Duyệt xâu với một ngăn xếp: ký tự trùng đỉnh ngăn xếp thì bỏ cả hai và đếm một lần xóa. Số lần xóa không phụ thuộc thứ tự, nên chỉ xem nó lẻ hay chẵn.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
string s, st;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> s;
ll cnt = 0;
for (char c : s) {
if (!st.empty() && st.back() == c) st.pop_back(), cnt++;
else st.push_back(c);
}
cout << (cnt % 2 ? "Yes" : "No") << '\n';
}
a
No
Chiến lược đối xứng
Nếu trạng thái có một phép đối xứng, và người thứ hai luôn đi được nước gương của nước vừa rồi, thì người thứ hai không bao giờ hết nước trước: người thứ nhất đi được thì nước gương cũng hợp lệ. Muốn người đi trước hưởng chiến lược này, nước đầu tiên của họ phải đưa trạng thái về dạng đối xứng, ví dụ chiếm đúng tâm.
Dạng tổng quát hơn là ghép cặp: chia các nước đi thành cặp sao cho sau khi đối thủ dùng một nước, nước còn lại của cặp vẫn hợp lệ. Lập luận chỉ cần chỉ ra phép ghép, không cần duyệt cây trò chơi.
Ví dụĐặt Đĩa Tròn
Đề. Bàn , hai người luân phiên đặt đĩa bán kính nằm trọn trong bàn và không chồng lên nhau. Ai không đặt được thì thua. .
Lời giải. Không đặt nổi một đĩa ( hoặc ) thì người đầu thua. Ngược lại người đầu đặt đĩa đầu vào tâm, rồi đáp mỗi đĩa của đối thủ bằng đĩa đối xứng qua tâm: chỗ đó luôn trống, vì nếu nó chồng lên đĩa nào thì đĩa đối thủ vừa đặt cũng chồng lên ảnh gương của đĩa ấy.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll a, b, r;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> a >> b >> r;
cout << (2 * r <= min(a, b) ? "First" : "Second") << '\n';
}
3 3 1
First
Ví dụMột Nửa Số Đống
Đề. đống đá, chẵn, . Mỗi lượt chọn đúng đống khác rỗng và bớt mỗi đống một lượng dương. Khi còn ít hơn đống khác rỗng, người tới lượt thua. Alice đi trước.
Lời giải. Gọi là số đống bằng giá trị nhỏ nhất. Nếu , mọi nước của Alice phải chạm ít nhất một đống nhỏ nhất, làm nó nhỏ hơn hẳn; Bob kéo đống khác về đúng giá trị mới đó, lại có hơn nửa số đống bằng nhỏ nhất, cho tới khi Alice hết nước. Nếu , Alice kéo đống lớn nhất về đúng giá trị nhỏ nhất, tạo ra trạng thái có hơn nửa số đống nhỏ nhất cho Bob.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5005;
ll n, a[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
ll mn = *min_element(a + 1, a + n + 1), c = 0;
for (ll i = 1; i <= n; i++)
if (a[i] == mn) c++;
cout << (c > n / 2 ? "Bob" : "Alice") << '\n';
}
2
1 1
Bob
Tranh điểm: DP minimax trên hiệu số
Khi hai người cùng tối đa điểm của mình trên một tổng cố định, đặt là hiệu “điểm người sắp đi trừ điểm người kia” khi cả hai chơi tối ưu. Người sắp đi chọn nước được điểm, rồi đổi vai: . Có hiệu và tổng thì điểm người đầu là . Một số bài có cấu trúc đặc biệt cho phép tham lam (sắp giảm dần rồi chọn lần lượt), nhưng khi các lượt ảnh hưởng nhau qua hình dạng trạng thái thì cần DP.
Hỏi nhanh
Dãy ; mỗi lượt lấy một số ở đầu trái hoặc phải. Người đầu chơi tối ưu được bao nhiêu điểm?
Lấy 5 thì còn : đối thủ lấy 2, người đầu lấy nốt 1, được 6. Lấy 2 thì đối thủ lấy 5, người đầu chỉ còn 1, được 3. Tham lam “lấy số to hơn ở hai đầu” đúng ở đây nhưng sai với : lấy 2 thì mất 100 vào tay đối thủ, còn lấy 1 bên phải thì người đầu chắc chắn được 100.
Ví dụLấy Số Hai Đầu
Đề. Dãy số nguyên (có thể âm, trị tuyệt đối tới ). Hai người lần lượt lấy một số ở đầu trái hoặc đầu phải và cộng vào điểm mình, cùng tối đa điểm mình. In điểm người đầu.
Lời giải. Gọi là điểm người đi lượt này hơn đối thủ khi còn đoạn . Lấy đầu trái được , rồi đối thủ hơn mình ; lấy đầu phải tương tự. Vậy , . Biết tổng và hiệu thì điểm người đầu là .
Bước 1: một mảng cho độ dài hiện tại. dp[l] ở vòng độ dài len là . Lúc tính, dp[l + 1] vẫn là giá trị độ dài cũ, tức ; dp[l] cũ là . Duyệt tăng dần nên đọc dp[l + 1] trước khi nó bị ghi. Bộ nhớ thay vì số long long.
for (ll len = 2; len <= n; len++)
for (ll l = 1; l + len - 1 <= n; l++) {
ll r = l + len - 1;
dp[l] = max(a[l] - dp[l + 1], a[r] - dp[l]);
}
cout << (sum + dp[1]) / 2 << '\n';
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5005;
ll n, a[N], dp[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
ll sum = 0;
for (ll i = 1; i <= n; i++) cin >> a[i], sum += a[i], dp[i] = a[i];
for (ll len = 2; len <= n; len++)
for (ll l = 1; l + len - 1 <= n; l++) {
ll r = l + len - 1;
dp[l] = max(a[l] - dp[l + 1], a[r] - dp[l]);
}
cout << (sum + dp[1]) / 2 << '\n';
}
1
1
1
thời gian, bộ nhớ.
Khi đi thi
- Viết brute force tính thắng thua cho tham số nhỏ trước tiên; in bảng ra, tìm chu kỳ hay điều kiện, rồi chứng minh bằng quy nạp hai chiều.
- Hỏi ngay: tổng số lượt có cố định không? Nếu có, đáp án là chẵn lẻ. Nếu trạng thái có đối xứng, thử chiến lược gương, có thể sau một nước mở đầu.
- Tranh điểm thì DP hiệu số ; nhớ kiểm bộ nhớ, thường cuốn mảng theo độ dài.
Tóm tắt
- Trạng thái thua khi mọi nước dẫn tới trạng thái thắng; tính theo thứ tự ngược nước đi.
- Quy luật cho tham số lớn đến từ bảng trạng thái nhỏ, kèm chứng minh.
- Số lượt cố định thì chỉ chẵn lẻ quyết định; đối xứng hay ghép cặp cho người thứ hai, hoặc người thứ nhất sau một nước mở đầu, luôn có nước đáp.
- Trò tranh điểm: DP trên hiệu số, điểm người đầu là .
Bài tập
Đọc thêm
- Game Theorycp-algorithmsTrạng thái thắng thua trên đồ thị, kể cả có chu trình.
- Introduction to Game TheoryUSACO GuideVị trí thắng thua, bài luyện có lời giải.
- Combinatorial Game TheoryWikipediaThuật ngữ normal play, misère và chiến lược.