Ba đống sỏi 3, 4, 5. Mỗi lượt chọn một đống và bỏ đi một số viên bất kỳ; ai hết sỏi để bỏ thì thua. Đi trước thắng được không? Câu trả lời là một phép XOR: $3 \oplus 4 \oplus 5 e 0$, nên thắng.
Khi trò gồm nhiều trò con độc lập, mỗi lượt chỉ đi ở một trò, biết từng trò con thắng hay thua là chưa đủ để ghép. Số Grundy gán cho mỗi trạng thái một số tự nhiên, sao cho nó tương đương một đống Nim cỡ đó. Trò lớn có Grundy bằng XOR Grundy các trò con, và thua khi giá trị này bằng 0. Công cụ dùng cho trò công bằng (hai người có cùng tập nước đi), luôn kết thúc, người hết nước thì thua.
Nim
Có nhiều đống, mỗi lượt chọn một đống và bỏ đi một số viên dương. Gọi . Người đi trước thua khi và chỉ khi .
- Từ , đổi một đống thành thì đổi thành .
- Từ , lấy bit cao nhất của ; có đống chứa bit đó, và . Hạ đống ấy xuống thì tổng XOR về 0.
Hai tính chất này cùng trạng thái cuối (mọi đống bằng 0, , thua) là đủ để quy nạp.
Trong hình, mỗi cột bit có số chữ 1 chẵn thì XOR bằng 0. Bấm Đi nước thắng để xem cách đưa XOR về 0.
Hỏi nhanh
Ba đống . Người đi trước cần đưa đống nào về bao nhiêu để thắng?
. Bit cao nhất của 2 là bit 1, có trong đống 3; . Sau nước đó , đối thủ ở thế thua.
Ví dụNước Đi Cuối Cùng
Đề. Nhiều ván Nim, tổng số đống tới , mỗi đống tới . In first nếu người đi trước thắng, ngược lại second.
Lời giải. Tính tổng XOR của các đống.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, n;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n;
ll x = 0;
for (ll i = 1; i <= n; i++) {
ll a;
cin >> a;
x ^= a;
}
cout << (x ? "first" : "second") << '\n';
}
}
3
3
2 5 7
4
1 4 6 8
1
9
second
first
first
Số Grundy
của một tập là số tự nhiên nhỏ nhất không thuộc tập. Số Grundy định nghĩa bởi
Trạng thái không còn nước đi có . Đống Nim cỡ có : từ nó đi được tới mọi cỡ nhỏ hơn.
Định lý Sprague-Grundy: trạng thái tương đương đống Nim cỡ . Từ đi được tới trạng thái có Grundy bằng mọi số nhỏ hơn ; còn nước tới trạng thái có Grundy lớn hơn thì đối thủ luôn đi tiếp được về đúng . Vì vậy tổng của nhiều trò con độc lập chơi như Nim trên các Grundy:
Tính theo thứ tự ngược nước đi: trạng thái nhỏ trước, hoặc DFS có ghi nhớ trên DAG. Trạng thái có nước đi thì , nên mảng đánh dấu cho mex chỉ cần ô.
Ví dụBộ Luật Chung
Đề. Nhiều bộ luật tới khi gặp 0. Bộ luật là tập gồm lượng được lấy. Mỗi vị trí gồm tối đa 100 đống cỡ tới ; mỗi lượt lấy đúng viên từ một đống. In W hoặc L cho từng vị trí.
Lời giải. Mỗi vị trí là tổng các đống độc lập, nên chỉ cần Grundy của một đống cỡ rồi XOR. là mex của các với , .
Bước 1: bảng Grundy cho mỗi bộ luật. Tính từ nhỏ tới lớn vì chỉ phụ thuộc các giá trị nhỏ hơn. Có nước đi nên : mảng seen chỉ cần ô và phải xóa trước mỗi .
for (int x = 0; x <= 10000; x++) {
for (int v = 0; v <= k; v++) seen[v] = 0;
for (int i = 0; i < k; i++)
if (s[i] <= x) seen[g[x - s[i]]] = 1;
int v = 0;
while (seen[v]) v++;
g[x] = v;
}
Bước 2: mỗi vị trí. XOR g[h] của các đống; khác 0 là người đi trước thắng.
for (int i = 1; i <= l; i++) {
int h;
cin >> h;
x ^= g[h];
}
res += x ? 'W' : 'L';
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int H = 1e4 + 5;
int k, m, s[105], g[H];
bool seen[105];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
while (cin >> k && k) {
for (int i = 0; i < k; i++) cin >> s[i];
for (int x = 0; x <= 10000; x++) {
for (int v = 0; v <= k; v++) seen[v] = 0;
for (int i = 0; i < k; i++)
if (s[i] <= x) seen[g[x - s[i]]] = 1;
int v = 0;
while (seen[v]) v++;
g[x] = v;
}
cin >> m;
string res;
for (int j = 1; j <= m; j++) {
int l, x = 0;
cin >> l;
for (int i = 1; i <= l; i++) {
int h;
cin >> h;
x ^= g[h];
}
res += x ? 'W' : 'L';
}
cout << res << '\n';
}
}
3
1 3 4
4
1 2
2 1 2
2 3 4
3 2 2 2
2
2 5
3
1 1
1 2
2 2 2
0
LWWL
LWL
Mỗi bộ luật phép.
Tìm quy luật
Hình tính từng là mex của các sau một nước đi (màu xanh). Đổi tập nước đi để tìm chu kỳ.
Trạng thái quá lớn để tính hết thì viết brute force in rồi đoán dạng: tuần hoàn ( khi lấy 1 đến 3 viên), theo cấu trúc số học, hay bằng một đại lượng khác.
Chứng minh thường là chỉ ra trò đó chính là Nim trên đại lượng ấy: mỗi nước làm đại lượng giảm, và giảm được đúng mọi lượng dương.
Hỏi nhanh
Mỗi lượt lấy 1, 2 hoặc 3 viên từ một đống. Hai đống và : người đi trước thắng hay thua?
Grundy của đống là , nên hai đống có Grundy và , tổng XOR bằng . Không được XOR trực tiếp kích thước đống, vì đây không phải Nim thường.
Ví dụHạ Số Bằng Ước
Đề. số . Mỗi lượt chọn một số và thay bằng một ước dương nhỏ hơn nó. Ai không đi được thì thua; Anna đi trước.
Lời giải. Gọi là số thừa số nguyên tố của tính cả bội. Ước nhỏ hơn của là bỏ đi một số dương thừa số nguyên tố, và bỏ được bất kỳ số lượng nào từ 1 tới (bỏ hết được 1, cũng là ước hợp lệ). Đúng là bốc một số dương viên từ đống cỡ : mỗi số là một đống Nim.
Bước 1: sàng ước nguyên tố nhỏ nhất. Với mỗi số nguyên tố , gán cho mọi bội chưa có ước nhỏ hơn.
for (int i = 2; i < V; i++)
if (!spf[i])
for (int j = i; j < V; j += i)
if (!spf[j]) spf[j] = i;
Bước 2: tính . Bỏ một thừa số nguyên tố thì giảm 1. Duyệt tăng dần nên omega[i / spf[i]] đã có.
for (int i = 2; i < V; i++) omega[i] = omega[i / spf[i]] + 1;
Bước 3: XOR các omega[a]; khác 0 là Anna thắng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int V = 1e5 + 5;
int n, spf[V], omega[V];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (int i = 2; i < V; i++)
if (!spf[i])
for (int j = i; j < V; j += i)
if (!spf[j]) spf[j] = i;
for (int i = 2; i < V; i++) omega[i] = omega[i / spf[i]] + 1;
cin >> n;
int x = 0;
for (int i = 1; i <= n; i++) {
int a;
cin >> a;
x ^= omega[a];
}
cout << (x ? "Anna" : "Bruno") << '\n';
}
6
2 4 6 8 9 12
Anna
Sàng , rồi .
Tách đống
Có trò cho tách một đống thành nhiều đống. Sau nước đó trạng thái là tổng của các đống mới, nên Grundy của nó là XOR Grundy các phần:
Cách nhìn này dùng được cả cho trò trên dải hay bảng: một nước đi chia vùng trống thành các vùng độc lập, mỗi vùng là một trò con.
Ví dụHai Phần Khác Nhau
Đề. test, mỗi test tối đa 100 đống cỡ tới . Mỗi lượt tách một đống thành hai đống dương có kích thước khác nhau. Ai không tách được thì thua; Alice đi trước. In Case i: Alice hoặc Case i: Bob.
Lời giải. Tách một đống thành hai đống là chuyển sang tổng của hai trò con, Grundy bằng XOR. Vậy là mex của trên mọi ; điều kiện 2 * a < x loại cách tách thành hai phần bằng nhau. Tính trước một lần cho mọi cỡ tới , rồi mỗi test chỉ XOR.
Bước 1: đánh dấu bằng số hiệu. mark[v] = x thay cho mảng đúng/sai: giá trị đã được đánh dấu cho lần tính hiện tại khi mark[v] == x, nên không cần xóa mảng giữa các . Grundy ở đây nhỏ, dưới , nên mark cỡ chứa đủ mọi giá trị XOR.
for (int x = 1; x < A; x++) {
for (int a = 1; 2 * a < x; a++) mark[g[a] ^ g[x - a]] = x;
int v = 0;
while (mark[v] == x) v++;
g[x] = v;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int A = 1e4 + 5;
int g[A], mark[1 << 14];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (int x = 1; x < A; x++) {
for (int a = 1; 2 * a < x; a++) mark[g[a] ^ g[x - a]] = x;
int v = 0;
while (mark[v] == x) v++;
g[x] = v;
}
int T;
cin >> T;
for (int tc = 1; tc <= T; tc++) {
int n, x = 0;
cin >> n;
for (int i = 1; i <= n; i++) {
int a;
cin >> a;
x ^= g[a];
}
cout << "Case " << tc << ": " << (x ? "Alice" : "Bob") << '\n';
}
}
4
1
1
2
3 5
3
4 7 10
2
13 18
Case 1: Bob
Case 2: Alice
Case 3: Bob
Case 4: Alice
Tính bảng khoảng phép, mỗi test .
Nim bậc thang
Bóng trên các bậc , mỗi lượt chuyển một số bóng dương từ bậc xuống bậc . Chỉ các bậc chẵn quyết định: trò tương đương Nim trên
- Chuyển bóng từ bậc chẵn xuống bậc lẻ: bóng đó ra khỏi các đống chẵn, đúng một nước Nim.
- Chuyển bóng từ bậc lẻ xuống bậc chẵn: người kia chuyển đúng số bóng ấy xuống tiếp một bậc, các đống chẵn trở lại như cũ. Nước này không thay đổi được kết quả.
Mẫu này gặp ở nhiều bài: xu trượt về trái trên một dải, các quân không được vượt nhau. Thường phải đổi bài về “khoảng trống giữa các quân” rồi lấy các khoảng ở vị trí xen kẽ.
Ví dụBóng Trên Bậc Thang
Đề. Nhiều ván, tổng số bậc tới , mỗi bậc tới bóng. In first hay second.
Lời giải. XOR số bóng ở các bậc chẵn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, n;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n;
ll x = 0;
for (ll i = 1; i <= n; i++) {
ll p;
cin >> p;
if (i % 2 == 0) x ^= p;
}
cout << (x ? "first" : "second") << '\n';
}
}
2
5
4 3 2 3 1
4
0 5 8 2
second
first
Khi đi thi
- Nhiều trò con độc lập, mỗi lượt đi một trò: tính Grundy từng trò, XOR lại, khác 0 là thắng.
- Mex theo thứ tự ngược nước đi; mảng đánh dấu cỡ số nước đi cộng 1, hoặc đánh dấu bằng số hiệu trạng thái để khỏi xóa.
- Trạng thái lớn: in bảng Grundy nhỏ, đoán chu kỳ hay đại lượng tương đương, rồi chứng minh trò đó là Nim.
Tóm tắt
- Nim thua khi tổng XOR bằng 0; nước thắng hạ đống chứa bit cao nhất của tổng XOR.
- Grundy các trạng thái kề; tổng trò chơi có Grundy là XOR.
- Tách đống hay chia vùng: Grundy của nước đi là XOR các phần mới.
- Nim bậc thang chỉ tính các bậc chẵn.
Bài tập
Đọc thêm
- Trò chơi Nim và định lý Sprague-GrundyVNOI Wiki
- Sprague-Grundy theorem. Nimcp-algorithms
- Game TheoryUSACO Guide
- Sprague-Grundy theoremWikipedia