Chương 5·Bài 53

Sprague Grundy Theorem

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 X=a1⊕a2⊕⋯⊕anX = a_1 \oplus a_2 \oplus \dots \oplus a_n. Người đi trước thua khi và chỉ khi X=0X = 0.

  • Từ X=0X = 0, đổi một đống aia_i thành ai′≠aia_i' \ne a_i thì XX đổi thành ai⊕ai′≠0a_i \oplus a_i' \ne 0.
  • Từ X≠0X \ne 0, lấy bit cao nhất của XX; có đống aia_i chứa bit đó, và ai⊕X<aia_i \oplus X < a_i. Hạ đống ấy xuống ai⊕Xa_i \oplus X 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, X=0X = 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.

Gõ các đống rồi thử.

Hỏi nhanh

Ba đống 3,4,53, 4, 5. Người đi trước cần đưa đống nào về bao nhiêu để thắng?

Ví dụNước Đi Cuối Cùng

Đề. Nhiều ván Nim, tổng số đống tới 2⋅1052 \cdot 10^5, mỗi đống tới 10910^9. 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.

Code đầy đủ
#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

Số Grundy

mex⁡\operatorname{mex} 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

g(s)=mex⁡{ g(s′):s→s′ }.g(s) = \operatorname{mex}\{\, g(s') : s \to s' \,\}.

Trạng thái không còn nước đi có g=0g = 0. Đống Nim cỡ aa có g=ag = a: từ nó đi được tới mọi cỡ nhỏ hơn.

Định lý Sprague-Grundy: trạng thái ss tương đương đống Nim cỡ g(s)g(s). Từ ss đi được tới trạng thái có Grundy bằng mọi số nhỏ hơn g(s)g(s); 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 g(s)g(s). Vì vậy tổng của nhiều trò con độc lập chơi như Nim trên các Grundy:

g(s1+s2+⋯+sk)=g(s1)⊕g(s2)⊕⋯⊕g(sk).g(s_1 + s_2 + \dots + s_k) = g(s_1) \oplus g(s_2) \oplus \dots \oplus g(s_k).

Tính gg 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ó dd nước đi thì g≤dg \le d, nên mảng đánh dấu cho mex chỉ cần d+1d + 1 ô.

Ví dụBộ Luật Chung

Đề. Nhiều bộ luật tới khi gặp 0. Bộ luật là tập SS gồm k≤100k \le 100 lượng được lấy. Mỗi vị trí gồm tối đa 100 đống cỡ tới 10410^4; mỗi lượt lấy đúng d∈Sd \in S 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 g(x)g(x) của một đống cỡ xx rồi XOR. g(x)g(x) là mex của các g(x−d)g(x - d) với d∈Sd \in S, d≤xd \le x.

Bước 1: bảng Grundy cho mỗi bộ luật. Tính từ nhỏ tới lớn vì g(x)g(x) chỉ phụ thuộc các giá trị nhỏ hơn. Có kk nước đi nên g(x)≤kg(x) \le k: mảng seen chỉ cần k+1k + 1 ô và phải xóa trước mỗi xx.

C++
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.

C++
for (int i = 1; i <= l; i++) {
    int h;
    cin >> h;
    x ^= g[h];
}
res += x ? 'W' : 'L';
Code đầy đủ
#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

Ước lượng phép tính: mỗi bộ luật 104⋅k=10610^4 \cdot k = 10^6 phép.

Tìm quy luật

Hình tính từng G(n)G(n) là mex của các GG sau một nước đi (màu xanh). Đổi tập nước đi để tìm chu kỳ.

Bảng Grundy của trò lấy sỏi.

Trạng thái quá lớn để tính hết thì viết brute force in g(0),g(1),…g(0), g(1), \dots rồi đoán dạng: tuần hoàn (a mod 4a \bmod 4 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 55 và 66: người đi trước thắng hay thua?

Ví dụHạ Số Bằng Ước

Đề. N≤105N \le 10^5 số Ai∈[2,105]A_i \in [2, 10^5]. 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 Ω(A)\Omega(A) là số thừa số nguyên tố của AA tính cả bội. Ước nhỏ hơn của AA là AA 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 Ω(A)\Omega(A) (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ỡ Ω(A)\Omega(A): 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ố ii, gán ii cho mọi bội chưa có ước nhỏ hơn.

C++
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 Ω\Omega. Bỏ một thừa số nguyên tố thì Ω\Omega giảm 1. Duyệt ii tăng dần nên omega[i / spf[i]] đã có.

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.

Code đầy đủ
#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

Độ phức tạp: sàng O(Vlog⁡log⁡V)O(V \log \log V), rồi O(N)O(N).

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:

g(x)=mex⁡{ g(a)⊕g(b):a+b=x, taˊch hợp lệ }.g(x) = \operatorname{mex}\{\, g(a) \oplus g(b) : a + b = x,\ \text{tách hợp lệ} \,\}.

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

Đề. T≤1000T \le 1000 test, mỗi test tối đa 100 đống cỡ tới 10410^4. 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 g(x)g(x) là mex của g(a)⊕g(x−a)g(a) \oplus g(x - a) trên mọi a<x−aa < x - a; đ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 10410^4, 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ị vv đã đượ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 xx. Grundy ở đây nhỏ, dưới 2142^{14}, nên mark cỡ 2142^{14} chứa đủ mọi giá trị XOR.

C++
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;
}
Code đầy đủ
#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

Độ phức tạp: tính bảng khoảng 2,5⋅1072{,}5 \cdot 10^7 phép, mỗi test O(n)O(n).

Nim bậc thang

Bóng trên các bậc 1..n1..n, mỗi lượt chuyển một số bóng dương từ bậc k>1k > 1 xuống bậc k−1k - 1. Chỉ các bậc chẵn quyết định: trò tương đương Nim trên p2,p4,p6,…p_2, p_4, p_6, \dots

  • 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 2⋅1052 \cdot 10^5, mỗi bậc tới 10910^9 bóng. In first hay second.

Lời giải. XOR số bóng ở các bậc chẵn.

Code đầy đủ
#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

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.
  • g(s)=mex⁡g(s) = \operatorname{mex} 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