Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Bài 42: Lý thuyết trò chơi trong C++ và Python: trạng thái thắng thua, Nim, hàm Grundy, minimax và tổng ôn series Thuật toán chuyên sâu

Bài 42: Lý thuyết trò chơi trong C++ và Python: trạng thái thắng thua, Nim, hàm Grundy, minimax và tổng ôn series Thuật toán chuyên sâu
Series: Thuật toán chuyên sâu Độ khó: Khá Bài 42 / Thuật toán chuyên sâu Lý thuyết trò chơi Tổng ôn
// Thuật toán chuyên sâu · Bài 42
Lý thuyết trò chơi: thắng thua, Nim, Grundy, minimax và tổng ôn series
Hai người chơi đều thông minh tuyệt đối, ai sẽ thắng? Chỉ với bảng thắng thua, một phép XOR và một hàm mex, em trả lời được câu hỏi tưởng như phải thử mọi ván cờ. Cuối bài là tấm bản đồ ôn tập cho cả series.

Lời mở đầu

Đề HSG rất thích những bài dạng “An và Bình chơi một trò chơi… cả hai đều chơi tối ưu, hỏi ai thắng?”. Lần đầu gặp, nhiều bạn thử mô phỏng từng ván và lạc ngay trong hàng triệu khả năng. Thật ra phần lớn các bài này chỉ cần một ý: tô màu thắng/thua cho từng trạng thái, và đó chính là quy hoạch động ở bài 19 mặc một chiếc áo mới. Khi có nhiều trò chơi chạy song song, công cụ lại là phép XOR em đã học ở bài 20. Còn trò “lấy hai đầu dãy” là một bài QHĐ đoạn (bài 27) chính hiệu.

Đây cũng là bài khép lại chặng đầu (bài 33–42) của series Thuật toán chuyên sâu. Sau bốn phần về lý thuyết trò chơi, Phần 5 sẽ xếp toàn bộ kiến thức từ bài 33 tới bài 42 lên một tấm bản đồ, kèm bảng “dấu hiệu trong đề → kỹ thuật” và lời khuyên khi ôn thi HSG quốc gia. Ba bài tập cuối cũng là ba bài tổng ôn trộn trò chơi với cây, Fenwick và số học.

Trong bài này bạn sẽ học: khái niệm trạng thái thắng / thua và cách lập bảng cho trò bốc sỏi; trò Nim và định lý XOR (kèm cách tìm nước đi thắng); hàm Grundy, phép mex và định lý Sprague–Grundy để cộng nhiều trò chơi độc lập; minimax bằng QHĐ trên đoạn cho trò lấy hai đầu dãy; và phần tổng ôn series bài 33 – 42. Có khung chạy tay bảng thắng thua và bảng QHĐ minimax, mô phỏng tô bảng, mô phỏng tính Grundy, chơi thử Nim với máy, cùng 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 1Trạng thái thắng, trạng thái thua

Xét trò chơi quen thuộc: có $n$ viên sỏi, An và Bình lần lượt bốc, An đi trước. Mỗi lượt được bốc từ $1$ tới $k$ viên. Ai đến lượt mà không còn sỏi để bốc thì thua (nói cách khác: ai bốc viên cuối cùng thì thắng). Cả hai đều chơi tối ưu. Hỏi ai thắng?

Các trò chơi trong đề thi thường có chung bốn đặc điểm: hai người luân phiên, thông tin đầy đủ (không giấu bài, không tung xúc xắc), luật đi như nhau cho cả hai người, và trò chơi chắc chắn kết thúc. Khi đó mỗi trạng thái (ở đây là số sỏi còn lại) chỉ có thể là một trong hai loại:

  • Thua (L): trạng thái kết thúc, hoặc mọi nước đi đều dẫn tới trạng thái thắng (cho đối thủ).
  • Thắng (W): có ít nhất một nước đi dẫn tới trạng thái thua (cho đối thủ).

“Thắng” ở đây luôn hiểu là thắng cho người đang đến lượt. Hai quy tắc trên cho phép tô trạng thái theo thứ tự từ nhỏ tới lớn, giống hệt QHĐ: ô $0$ là L, rồi mỗi ô sau nhìn lại các ô trước. Với $k = 3$:

L0W1W2W3L4W5W6W7L8W9W10W11L12−1−2−3lấy 2 viên → đối thủ còn 4 (L)

Hình 1. Bảng thắng thua khi được bốc 1 – 3 viên. Từ ô 6 (ô vàng) có ba nước đi tới 5, 4, 3; ô 4 là L nên 6 là W: bốc 2 viên để Bình còn 4 viên.

Nhìn bảng là thấy quy luật: các ô thua là $0, 4, 8, 12, \ldots$, tức là bội của $k + 1$. Giải thích rất đẹp: nếu $n$ chia hết cho $k + 1$, Bình bốc $x$ viên thì An bốc $k + 1 - x$ viên, số sỏi lại chia hết cho $k + 1$, cứ thế cho tới khi về 0 đúng lượt Bình. Ngược lại nếu $n \bmod (k+1) \ne 0$, An bốc ngay $n \bmod (k+1)$ viên để trao thế đó cho Bình. Đáp án chỉ là một phép chia lấy dư, chạy được với $n$ tới $10^{18}$.

Khi tập nước đi lạ hơn, chẳng hạn chỉ được bốc 1, 3 hoặc 4 viên, công thức không còn hiển nhiên nhưng cách tô bảng vẫn y nguyên. Em tự tô bảng với tập nước đi tuỳ ý trong mô phỏng dưới đây (đổi ô “Được lấy” thành 1 3 4 để thấy chu kỳ khác):

MÔ PHỎNGTô bảng thắng/thua cho trò bốc sỏiNhập số sỏi n (tối đa 30) và tập số viên được lấy mỗi lượt, rồi bấm Tiếp để tô từng ô từ 0 trở lên.
ô đang xétW: thắngL: thuachưa tô
$n$01234567891011121314
với {1, 3, 4}LWLWWWWLWLWWWWL

Bảng 1. Tập nước đi {1, 3, 4}: các ô thua là 0, 2, 7, 9, 14, …, lặp lại theo chu kỳ 7 (n mod 7 bằng 0 hoặc 2).

Code lập bảng cho tập nước đi bất kỳ: với mỗi $i$, thử từng nước $s_j$, hễ gặp $i - s_j$ là ô thua thì $i$ là ô thắng. Độ phức tạp $O(n \cdot m)$. Em chạy tay từng dòng để thấy vòng lặp break ngay khi tìm được một nước thắng:

C++▶ Chạy tay từng dòng codewin[i] = true nếu có nước s[j] đưa đối thủ tới ô thua i − s[j]. Ô xanh lá: W, ô vàng: i đang xét, ô đỏ: ô đối thủ nhận được.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n, m;
6 cin >> n >> m;
7 vector<int> s(m);
8 for(int j = 0; j < m; j++) cin >> s[j];
9 vector<bool> win(n + 1, false);
10 for(int i = 1; i <= n; i++){
11 for(int j = 0; j < m; j++){
12 if(s[j] <= i && !win[i - s[j]]){
13 win[i] = true;
14 break;
15 }
16 }
17 }
18 for(int i = 0; i <= n; i++) cout << (win[i] ? 'W' : 'L');
19 return 0;
20}
Xem code C++ / Python: lập bảng thắng thua với tập nước đi bất kỳ (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n, m;
    cin >> n >> m;
    vector<int> s(m);
    for(int j = 0; j < m; j++) cin >> s[j];
    vector<bool> win(n + 1, false);
    for(int i = 1; i <= n; i++){
        for(int j = 0; j < m; j++){
            if(s[j] <= i && !win[i - s[j]]){
                win[i] = true;
                break;
            }
        }
    }
    for(int i = 0; i <= n; i++) cout << (win[i] ? 'W' : 'L');
    return 0;
}
import sys

data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
s = list(map(int, data[2:2 + m]))
win = [False] * (n + 1)                  # win[0] = False: hết sỏi thì thua
for i in range(1, n + 1):
    for x in s:
        if x <= i and not win[i - x]:    # có nước đẩy đối thủ vào thế thua
            win[i] = True
            break
print(''.join('W' if w else 'L' for w in win))
Khuôn mẫu trò chơi thắng thua
win[trạng thái kết thúc] = false;
for(trạng thái u theo thứ tự “gần kết thúc” trước)
    win[u] = (tồn tại nước đi u → v mà win[v] == false);
Thứ tự tính phải đảm bảo mọi $v$ đi tới từ $u$ đã có đáp án: sỏi thì từ ít tới nhiều, trên đồ thị không chu trình thì theo thứ tự topo ngược (bài 35), trên cây thì từ lá lên gốc. Nếu không thấy thứ tự, dùng đệ quy có nhớ.
Bảng nhỏ trước, công thức sau
Với $n$ tới $10^{18}$ không thể lập bảng, nhưng em hãy lập bảng cho $n \le 50$ bằng code trên, nhìn ra chu kỳ rồi mới chứng minh (hoặc tin) công thức. Đây là kỹ năng ăn điểm cực mạnh trong phòng thi: viết vét cạn nhỏ để “đoán quy luật”, như cách bài 22 đã dặn.

Phần 2Trò chơi Nim và định lý XOR

Nim là “ông tổ” của lý thuyết trò chơi: có $n$ đống sỏi $a_1, a_2, \ldots, a_n$. Mỗi lượt, người chơi chọn một đống còn sỏi và bốc đi bao nhiêu viên cũng được (ít nhất 1). Ai không bốc được thì thua. Trạng thái giờ là cả bộ $(a_1, \ldots, a_n)$, số trạng thái quá lớn để lập bảng. May mắn là có một định lý cực gọn:

Định lý Bouton (1901)
Đặt $X = a_1 \oplus a_2 \oplus \cdots \oplus a_n$ (phép XOR). Người đi trước thắng khi và chỉ khi $X \ne 0$.

Chứng minh chỉ cần kiểm tra đúng hai quy tắc W/L ở Phần 1, với “L” là “XOR bằng 0”:

  • Trạng thái kết thúc (mọi đống bằng 0) có $X = 0$. Đúng là L.
  • Từ $X = 0$, mọi nước đi đều làm $X \ne 0$. Nước đi đổi một đống $a_i$ thành $a_i' \ne a_i$, nên XOR mới là $X \oplus a_i \oplus a_i' = a_i \oplus a_i' \ne 0$.
  • Từ $X \ne 0$, luôn có nước đi về $X = 0$. Gọi $d$ là bit cao nhất của $X$. Phải có một đống $a_i$ mang bit $d$ (vì XOR của cột bit $d$ bằng 1). Đổi $a_i$ thành $a_i \oplus X$: bit $d$ của nó từ 1 thành 0, các bit cao hơn giữ nguyên, nên $a_i \oplus X < a_i$, tức là một nước bốc hợp lệ. XOR mới là $X \oplus a_i \oplus (a_i \oplus X) = 0$.

Ví dụ ba đống $3, 4, 5$. Viết dưới dạng nhị phân rồi XOR từng cột (nhắc lại bài 20: XOR là “cộng không nhớ”, cột có số chữ số 1 lẻ thì ra 1):

ĐốngSố sỏiBit 2Bit 1Bit 0
13011
24100
35101
XOR2010

Bảng 2. $3 \oplus 4 \oplus 5 = 2 \ne 0$: An thắng. Bit cao nhất của $X$ là bit 1, đống 1 (số 3) mang bit này, đổi $3$ thành $3 \oplus 2 = 1$, tức bốc 2 viên. Còn lại $1, 4, 5$ có XOR bằng 0.

Xem code C++ / Python: nim: xác định người thắng và một nước đi thắng (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;
    vector<long long> a(n);
    long long X = 0;
    for(int i = 0; i < n; i++){ cin >> a[i]; X ^= a[i]; }
    if(X == 0){                                   // mọi nước đi đều làm XOR khác 0
        cout << "THUA";
        return 0;
    }
    for(int i = 0; i < n; i++)
        if((a[i] ^ X) < a[i]){                    // đống có bit cao nhất của X
            cout << "THANG\n" << i + 1 << " " << a[i] - (a[i] ^ X);
            return 0;
        }
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
X = 0
for x in a:
    X ^= x
if X == 0:                                  # mọi nước đi đều làm XOR khác 0
    print("THUA")
else:
    for i, x in enumerate(a):
        if x ^ X < x:                       # đống có bit cao nhất của X
            print("THANG")
            print(i + 1, x - (x ^ X))       # lấy bớt để đống còn x ^ X
            break

Đọc thì dễ, nhưng chỉ khi tự chơi em mới thấy sức mạnh của định lý: máy dưới đây luôn đưa XOR về 0 sau lượt của nó. Em nhập các đống, chọn ai đi trước và ghi sẵn vài nước đi của mình (dạng “đống số_viên”, cách nhau bởi dấu phẩy). Thử thắng máy khi em đi trước với $3, 4, 5$ xem:

MÔ PHỎNGChơi thử Nim với máyNhập các đống (2–5 đống, mỗi đống ≤ 15), ai đi trước, và nước đi của em dạng “đống số_viên”. Máy luôn chơi theo định lý XOR.
bit 1bit 1 của XORbit 0 của XORsỏi vừa bị lấy
Nim “ngược” (misère)
Nếu luật đổi thành ai bốc viên cuối cùng thì thua, đáp án đổi chút ít: nếu mọi đống đều $\le 1$ thì người đi trước thắng khi số đống bằng 1 là chẵn; còn lại vẫn là “thắng khi $X \ne 0$”. Đề hay cài bẫy ở câu chữ “bốc viên cuối cùng thắng hay thua”, em đọc kỹ trước khi áp công thức.

Phần 3Hàm Grundy (mex) và tổng nhiều trò chơi

Câu hỏi tự nhiên: nếu có nhiều đống, nhưng mỗi đống chơi theo luật của trò bốc sỏi ở Phần 1 (chỉ được bốc 1, 3 hoặc 4 viên) thì sao? Biết từng đống là W hay L không đủ: hai đống cùng là W, gộp lại có thể thành L. Ta cần một con số giàu thông tin hơn W/L, đó là số Grundy.

Với một tập số tự nhiên $A$, ký hiệu $\operatorname{mex}(A)$ (minimum excludant) là số tự nhiên nhỏ nhất không thuộc $A$: $\operatorname{mex}\{0, 1, 3\} = 2$, $\operatorname{mex}\{1, 2\} = 0$, $\operatorname{mex}\{\} = 0$. Số Grundy của trạng thái $u$ định nghĩa đệ quy:

$g(u) = \operatorname{mex}\{\, g(v) : u \to v \text{ là một nước đi} \,\}$

Hai tính chất quan trọng:

  • $g(u) = 0$ khi và chỉ khi $u$ là trạng thái thua. (Mex bằng 0 nghĩa là không có $v$ nào có $g(v) = 0$, đúng quy tắc L.) Vậy Grundy là bản “nâng cấp” của W/L.
  • Một đống Nim có $x$ viên đi được tới mọi $0, 1, \ldots, x - 1$, nên $g(x) = x$. Trạng thái có Grundy $g$ “giống hệt” một đống Nim $g$ viên.
Định lý Sprague–Grundy
Trò chơi tổng của các trò chơi độc lập $G_1, G_2, \ldots, G_n$ (mỗi lượt chọn đúng một trò để đi một nước) có số Grundy là $g = g(G_1) \oplus g(G_2) \oplus \cdots \oplus g(G_n)$. Người đi trước thắng khi và chỉ khi $g \ne 0$.

Lý do: mỗi trò con “đóng vai” một đống Nim có $g(G_i)$ viên. Đi một nước trong trò con giống như đổi kích thước đống đó (có thể tăng lên, nhưng nước tăng luôn bị đối thủ “trả lại” được nên không ảnh hưởng kết quả). Vậy cả trò tổng là Nim, áp định lý XOR của Phần 2. Bảng Grundy của trò bốc {1, 3, 4}:

$x$01234567891011121314
$g(x)$010123201012320

Bảng 3. Grundy của trò bốc 1, 3 hoặc 4 viên: 0 1 0 1 2 3 2 rồi lặp lại chu kỳ 7. Ô có Grundy 0 đúng là các ô L ở Bảng 1.

Ví dụ: hai đống 4 và 6 viên. Đứng riêng thì cả hai đều là W ($g = 2$), nhưng $2 \oplus 2 = 0$ nên người đi trước thua! Em xem mô phỏng tính từng số Grundy bằng mex, rồi XOR các đống ở cuối. Hàng “mex” tô vàng các giá trị có trong tập, ô xanh lá là số nhỏ nhất bị thiếu:

MÔ PHỎNGTính số Grundy bằng mex, rồi XOR nhiều đốngNhập tập nước đi và các đống sỏi (tối đa 5 đống, mỗi đống ≤ 20). Mỗi trạng thái tốn hai bước: gom tập Grundy đến được, rồi lấy mex.
đang tínhtrạng thái đến đượcGrundy ≠ 0 (thắng)Grundy = 0 (thua)
Xem code C++ / Python: hàm grundy và tổng nhiều đống sỏi (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int m, n;
    cin >> m;
    vector<int> s(m);
    for(auto &x : s) cin >> x;
    cin >> n;
    vector<int> p(n);
    int mx = 0;
    for(auto &x : p){ cin >> x; mx = max(mx, x); }
    vector<int> g(mx + 1, 0);                     // g[0] = 0
    for(int x = 1; x <= mx; x++){
        vector<bool> seen(m + 2, false);          // Grundy không vượt quá m
        for(int t : s) if(t <= x) seen[g[x - t]] = true;
        int v = 0;
        while(seen[v]) v++;                       // mex
        g[x] = v;
    }
    int X = 0;
    for(int x : p){ cout << g[x] << " "; X ^= g[x]; }
    cout << "\n" << (X ? "THANG" : "THUA");
    return 0;
}
import sys

data = sys.stdin.read().split()
m = int(data[0])
s = list(map(int, data[1:1 + m]))
n = int(data[1 + m])
p = list(map(int, data[2 + m:2 + m + n]))
mx = max(p)
g = [0] * (mx + 1)                          # g[0] = 0
for x in range(1, mx + 1):
    seen = {g[x - t] for t in s if t <= x}  # Grundy các trạng thái đến được
    v = 0
    while v in seen:                        # mex
        v += 1
    g[x] = v
X = 0
for x in p:
    X ^= g[x]
print(*[g[x] for x in p])
print("THANG" if X else "THUA")
Vài nhận xét giúp code nhanh
1. Từ một trạng thái có $m$ nước đi thì Grundy $\le m$, nên mảng đánh dấu cho mex chỉ cần $m + 2$ phần tử. 2. Grundy trò bốc sỏi gần như luôn tuần hoàn; khi $n$ lớn, in bảng ra, tìm chu kỳ rồi dùng $n \bmod T$. 3. Trò chơi “tách đôi” (một nước biến đống $x$ thành hai đống $y, z$) thì trạng thái sau là một tổng, Grundy của nó là $g(y) \oplus g(z)$ chứ không phải một số đơn lẻ.

Phần 4Minimax: trò chơi lấy hai đầu dãy

Không phải trò chơi nào cũng chỉ hỏi thắng/thua. Bài toán kinh điển: một dãy $a_1, \ldots, a_n$ đặt trên bàn. An và Bình lần lượt lấy một số ở đầu trái hoặc đầu phải của dãy còn lại, cộng vào điểm của mình. An đi trước, cả hai muốn điểm của mình lớn nhất. Hỏi điểm của mỗi người?

Tham lam không đúng. Với dãy $8, 15, 3, 7$, nếu An luôn lấy đầu lớn hơn thì An lấy 8, mở đường cho Bình lấy 15:

Cách chơi của AnLượt 1 (An)Lượt 2 (Bình)Lượt 3 (An)Lượt 4 (Bình)An – Bình
Tham lam: lấy đầu lớn hơn8157315 – 18
Tối ưu78 (hoặc 3)153 (hoặc 8)22 – 11

Bảng 4. Lấy 7 trước thì dù Bình lấy đầu nào, số 15 cũng lộ ra cho An.

Ý tưởng minimax. Mỗi người chọn nước đi tốt nhất cho mình, và biết rằng đối thủ cũng sẽ chọn tốt nhất cho họ. Vì tổng điểm cố định (trò chơi “tổng bằng hằng số”), thứ cần tối ưu là hiệu. Gọi $dp[l][r]$ là (điểm người đang đến lượt) − (điểm đối thủ) khi trên bàn còn đoạn $a_l..a_r$ và cả hai chơi tối ưu. Người đang đi có hai lựa chọn:

  • lấy $a_l$: được $a_l$, rồi tới lượt đối thủ trên đoạn $[l+1, r]$, đối thủ hơn mình $dp[l+1][r]$ → hiệu là $a_l - dp[l+1][r]$;
  • lấy $a_r$: hiệu là $a_r - dp[l][r-1]$.

$dp[l][l] = a_l, \qquad dp[l][r] = \max\big(a_l - dp[l+1][r],\; a_r - dp[l][r-1]\big)$

Phép trừ chính là “đổi vai”: cái tối đa cho đối thủ là cái tối thiểu cho mình. Đây là QHĐ đoạn như bài 27: tính các đoạn theo độ dài tăng dần. Cuối cùng An được $(S + dp[1][n]) / 2$ điểm, Bình được $(S - dp[1][n]) / 2$ với $S$ là tổng dãy. Bảng $dp$ cho ví dụ (đánh số từ 0):

$dp[l][r]$r = 0r = 1r = 2r = 3
l = 0 (8)87−411
l = 1 (15)151211
l = 2 (3)34
l = 3 (7)7

Bảng 5. $dp[0][3] = \max(8 - dp[1][3], 7 - dp[0][2]) = \max(8 - 11, 7 + 4) = 11$: An nên lấy 7 (đầu phải), hơn Bình 11 điểm. Tổng 33 nên An 22, Bình 11.

Em chạy tay để xem từng ô được tính từ hai ô “bỏ đầu trái” và “bỏ đầu phải” thế nào:

C++▶ Chạy tay từng dòng codedp[l][r] = max(a[l] − dp[l+1][r], a[r] − dp[l][r−1]): mỗi lựa chọn trừ đi phần “hơn” của đối thủ trên đoạn còn lại.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n;
6 cin >> n;
7 vector<long long> a(n);
8 for(auto &x : a) cin >> x;
9 vector<vector<long long>> dp(n, vector<long long>(n, 0));
10 long long sum = 0;
11 for(int i = 0; i < n; i++){ dp[i][i] = a[i]; sum += a[i]; }
12 for(int len = 2; len <= n; len++)
13 for(int l = 0; l + len - 1 < n; l++){
14 int r = l + len - 1;
15 dp[l][r] = max(a[l] - dp[l + 1][r], a[r] - dp[l][r - 1]);
16 }
17 cout << (sum + dp[0][n - 1]) / 2 << " " << (sum - dp[0][n - 1]) / 2;
18 return 0;
19}
Xem code C++ / Python: trò chơi lấy hai đầu dãy bằng qhđ đoạn (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;
    vector<long long> a(n);
    for(auto &x : a) cin >> x;
    vector<vector<long long>> dp(n, vector<long long>(n, 0));
    long long sum = 0;
    for(int i = 0; i < n; i++){ dp[i][i] = a[i]; sum += a[i]; }
    for(int len = 2; len <= n; len++)
        for(int l = 0; l + len - 1 < n; l++){
            int r = l + len - 1;
            dp[l][r] = max(a[l] - dp[l + 1][r], a[r] - dp[l][r - 1]);
        }
    cout << (sum + dp[0][n - 1]) / 2 << " " << (sum - dp[0][n - 1]) / 2;
    return 0;
}
import sys

data = sys.stdin.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
# dp[l][r] = (điểm người đang đi) − (điểm đối thủ) khi còn đoạn a[l..r]
dp = [[0] * n for _ in range(n)]
for i in range(n):
    dp[i][i] = a[i]
for ln in range(2, n + 1):                  # đoạn ngắn tính trước
    for l in range(n - ln + 1):
        r = l + ln - 1
        dp[l][r] = max(a[l] - dp[l + 1][r], a[r] - dp[l][r - 1])
s = sum(a)
print((s + dp[0][n - 1]) // 2, (s - dp[0][n - 1]) // 2)
Minimax tổng quát
Với trò chơi bất kỳ có điểm số: $f(u) = \max_{u \to v} \big(\text{điểm của nước đi} - f(v)\big)$, trạng thái kết thúc có $f = 0$. Thắng/thua ở Phần 1 là trường hợp riêng (thắng = +1, thua = −1). Nếu trạng thái là đoạn $[l, r]$ thì QHĐ đoạn $O(n^2)$; nếu là “còn $i$ phần tử” thì QHĐ tuyến tính; nếu là tập con nhỏ thì QHĐ bitmask.
Mẹo chẵn lẻ: người đi trước không bao giờ thua khi n chẵn
Tô các vị trí lẻ màu đỏ, vị trí chẵn màu xanh. Khi $n$ chẵn, hai đầu dãy luôn khác màu ở lượt An, và sau khi An lấy một màu thì Bình chỉ còn lấy được màu kia. Vậy An có thể lấy toàn bộ số màu đỏ hoặc toàn bộ số màu xanh, chọn màu có tổng lớn hơn. Câu hỏi “An có thua không?” thì trả lời ngay, nhưng hỏi điểm tối đa thì vẫn phải QHĐ.

Phần 5Tổng ôn series Thuật toán chuyên sâu

Mười bài của series đi theo năm nhánh lớn. Em nhìn sơ đồ để nhớ “bài nào nằm ở đâu”, rồi dùng bảng ngay sau để luyện phản xạ: đọc đề, thấy dấu hiệu, nghĩ ngay tới kỹ thuật.

Cây và đồ thịBài 33Cây, QHĐ trên câyBài 34LCA, nhảy nhị phânBài 35Sắp xếp topo, DAGBài 36TPLT mạnh, khớp, cầuTruy vấn trên đoạnBài 37Sparse Table, LazyBài 38Chia căn, thuật toán MoXâuBài 39Z, Manacher, TrieToán và trò chơiBài 40Số học nâng caoBài 42Trò chơi và tổng ônTìm kiếmBài 41Kỹ thuật tìm kiếm

Hình 2. Sơ đồ kiến thức series Thuật toán chuyên sâu (bài 33 – 42), chia thành 5 nhánh.

Dấu hiệu trong đềBàiKỹ thuậtĐộ phức tạp
Cây n đỉnh, tính một đại lượng cho mọi cây con; chọn đỉnh không kề nhau; đường kính câyBài 33DFS, QHĐ trên cây$O(n)$
Nhiều câu hỏi về đường đi giữa hai đỉnh trên cây, khoảng cách, tổ tiên thứ $k$Bài 34LCA, nhảy nhị phân$O((n + q) \log n)$
Công việc có điều kiện trước – sau; đường đi dài nhất / đếm đường trên đồ thị không chu trìnhBài 35Sắp xếp topo, QHĐ trên DAG$O(n + m)$
Đồ thị có hướng, nhóm đỉnh đi lại được với nhau; “bỏ một đỉnh / một cạnh thì mất liên thông”Bài 36TPLT mạnh, khớp, cầu$O(n + m)$
Min/max đoạn trên mảng không đổi, rất nhiều truy vấn; cộng/gán cả đoạn rồi hỏi tổng/minBài 37Sparse Table; segment tree lazy$O(1)$; $O(\log n)$
Truy vấn đoạn offline khó gộp (đếm giá trị phân biệt, đếm tần suất), $n, q \le 10^5$Bài 38Chia căn, thuật toán Mo$O((n + q)\sqrt{n})$
So khớp tiền tố, xâu con đối xứng dài nhất, nhiều xâu có tiền tố chung, XOR lớn nhất của hai sốBài 39Z-function, Manacher, Trie$O(n)$
Phân tích thừa số rất nhiều số, sàng trên đoạn lớn, đồng dư, hệ phương trình đồng dư, modulo không nguyên tốBài 40Sàng spf, sàng đoạn, phi Euler, Euclid mở rộng, CRTtuỳ bài, thường $O(N \log \log N)$
“Giá trị lớn nhất nhỏ nhất”, “nhỏ nhất để đạt được”; $n \le 40$ nhưng $2^n$ quá lớnBài 41Tìm kiếm nâng cao (chặt nhị phân đáp án, chặt tam phân, chia đôi tập, nhánh cận)$O(\log V \cdot \text{kiểm tra})$
“Hai người chơi tối ưu, ai thắng / được bao nhiêu điểm”Bài 42Thắng thua, Nim, Grundy, minimaxtheo số trạng thái

Bảng 6. Dấu hiệu trong đề → kỹ thuật cần dùng, cho series Thuật toán chuyên sâu. Bấm “Bài NN” để mở lại lý thuyết.

Ở mức HSG quốc gia, gần như không còn bài nào chỉ dùng một kỹ thuật. Một vài “cặp đôi” rất hay gặp:

  • Topo + QHĐ (bài 35 + bài 19/27): mọi QHĐ thực chất là đi trên một DAG trạng thái; trò chơi trên đồ thị không chu trình cũng là tô W/L theo topo ngược.
  • TPLT mạnh + topo (bài 36 + 35): nén mỗi thành phần thành một đỉnh để được DAG, rồi QHĐ trên DAG.
  • LCA + mảng cộng dồn trên cây (bài 34 + 13): cộng giá trị trên đường đi $u - v$ bằng cách cộng ở $u$, $v$ và trừ ở LCA.
  • Chặt nhị phân đáp án + cấu trúc dữ liệu (bài 41 + 31/37): đoán đáp án, kiểm tra bằng Fenwick hoặc segment tree.
  • Trò chơi + số học / cây / Fenwick (bài 42 + 40/33/31): biến trò chơi thành Nim, phần còn lại là tính nhanh Grundy, đúng như bài tập 6 – 8 bên dưới.
Ba câu hỏi để định vị bài khó
1. Đối tượng là gì? Dãy, xâu, cây, đồ thị có hướng hay vô hướng, hay một trò chơi? 2. Thao tác lặp lại nhiều nhất là gì? Hỏi đoạn, hỏi đường đi trên cây, so khớp xâu, kiểm tra một đáp án đoán trước… Thao tác đó quyết định cấu trúc dữ liệu. 3. Giới hạn cho phép bao nhiêu? $n \le 20$ mời gọi bitmask, $n \le 40$ mời gọi chia đôi tập, $n, q \le 10^5$ với truy vấn offline mời gọi Mo, $10^{18}$ đòi hỏi công thức hoặc $O(\log)$.

Lời khuyên khi ôn HSG quốc gia.

  • Thư viện cá nhân phải “thuộc tay”. LCA nhảy nhị phân, Tarjan, segment tree lazy, sparse table, Z-function, Manacher, trie, sàng ước nguyên tố nhỏ nhất: mỗi đoạn em gõ lại được trong 5 phút mà không nhìn.
  • Luyện theo chủ đề trước, trộn đề sau. Hai tháng đầu làm mỗi chủ đề 10 – 15 bài từ dễ tới khó; giai đoạn sau chỉ làm đề tổng hợp có bấm giờ để luyện khâu nhận dạng.
  • Luôn có vét cạn và stress test. Với bài khó, subtask nhỏ thường chiếm 30 – 50% số điểm; code vét cạn còn là “đáp án chuẩn” để đối chiếu, như mình làm cho mọi bài tập trong series.
  • Cẩn thận đệ quy sâu. Cây $2 \cdot 10^5$ đỉnh dạng đường thẳng làm DFS đệ quy tràn ngăn xếp (nhất là Python), nên tập viết DFS bằng ngăn xếp như bài tập 6.
  • Chữa đề kỹ hơn làm đề. Mỗi bài sai, ghi lại: dấu hiệu nào mình bỏ lỡ, lỗi cài đặt nào (tràn số, biên, modulo âm). Sổ lỗi này là tài liệu ôn quý nhất trước ngày thi.
  • Đọc lại bài 32 về chiến thuật phòng thi: chia thời gian, ăn subtask, kiểm tra tên tệp và định dạng.

Tiếp theo học gì? Lý thuyết đến đây đã đủ cho phần lớn đề HSG quốc gia; phần còn lại là làm thật nhiều bài. Mình đã xếp toàn bộ bài tập trên blog thành lộ trình học thuật toán theo từng chặng, mỗi chặng ứng với một nhóm kỹ thuật, bài nào cũng có lời giải C++ và Python. Em đi lần lượt, chặng nào làm được khoảng 70% số bài thì chuyển sang chặng sau.

Luyện tập trên web
Các bài dưới đây trên blog đều có lời giải chi tiết bằng C++ và Python, xếp từ dễ tới khó. Em hãy tự làm trước rồi mới xem lời giải nhé.Xem toàn bộ bài cùng chủ đề: nhãn Lý thuyết trò chơi.

Bài tậpLuyện lý thuyết trò chơi và tổng ôn series

Bài 1 – 5 luyện riêng lý thuyết trò chơi. Bài 6 – 8 là bài tổng ôn: phần trò chơi chỉ là bước đầu, phần còn lại cần kiến thức của bài 33, 31/37 và 40. Quy ước chung: An đi trước, cả hai chơi tối ưu, ai đến lượt mà không đi được thì thua; in An hoặc Binh là tên người thắng.

Bài 1Bốc sỏiDễ

Có $T$ ván chơi ($T \le 10^5$). Ván thứ $i$ có $n$ viên sỏi, mỗi lượt được bốc từ $1$ tới $k$ viên ($1 \le n, k \le 10^{18}$). Với mỗi ván, in tên người thắng.

Input
3
10 3
8 3
5 9
Output
An
Binh
An

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Đúng kết quả Phần 1: thế thua là các bội của $k + 1$. Ván 1: $10 \bmod 4 = 2$, An bốc 2 viên để Bình còn 8 (bội của 4). Ván 2: $8$ chia hết cho 4 nên An thua. Ván 3: $k \ge n$, An bốc hết luôn. Cẩn thận: $k + 1$ có thể tới $10^{18} + 1$, vẫn vừa long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int T;
    cin >> T;
    while(T--){
        long long n, k;
        cin >> n >> k;
        cout << (n % (k + 1) != 0 ? "An" : "Binh") << "\n";   // bội của k + 1 là thế thua
    }
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
T = int(data[0])
out = []
for t in range(T):
    n, k = int(data[1 + 2 * t]), int(data[2 + 2 * t])
    out.append("An" if n % (k + 1) else "Binh")   # bội của k + 1 là thế thua
print("\n".join(out))
Bài 2Bốc sỏi theo tập nước điDễ

Mỗi lượt chỉ được bốc đúng $s_1$, $s_2$, …, hoặc $s_m$ viên ($m \le 10$, các $s_j$ khác nhau, $1 \le s_j \le 10^5$). Có $q$ câu hỏi ($q \le 10^5$), câu thứ $i$ cho số sỏi ban đầu $n_i$ ($1 \le n_i \le 10^5$). In tên người thắng cho từng câu.

Input: dòng đầu $m, q$; dòng hai gồm $m$ số $s_j$; dòng ba gồm $q$ số $n_i$.

Input
3 4
1 3 4
2 5 7 10
Output
Binh
An
Binh
An

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Lập bảng win tới $\max n_i$ một lần như khung chạy tay Phần 1, rồi trả lời mỗi câu trong $O(1)$. Đối chiếu Bảng 1: 2 và 7 là L, 5 và 10 là W. Để ý trạng thái nhỏ hơn mọi $s_j$ là L vì không đi được.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int m, q;
    cin >> m >> q;
    vector<int> s(m), ns(q);
    for(auto &x : s) cin >> x;
    int mx = 0;
    for(auto &x : ns){ cin >> x; mx = max(mx, x); }
    vector<char> win(mx + 1, 0);
    for(int i = 1; i <= mx; i++)
        for(int x : s)
            if(x <= i && !win[i - x]){ win[i] = 1; break; }
    for(int x : ns) cout << (win[x] ? "An" : "Binh") << "\n";
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
m, q = int(data[0]), int(data[1])
s = sorted(map(int, data[2:2 + m]))
ns = list(map(int, data[2 + m:2 + m + q]))
mx = max(ns)
win = [False] * (mx + 1)
for i in range(1, mx + 1):
    for x in s:
        if x > i:
            break
        if not win[i - x]:
            win[i] = True
            break
print("\n".join("An" if win[x] else "Binh" for x in ns))
Bài 3Nim và số nước đi thắngTrung bình

Có $n$ đống sỏi ($1 \le n \le 2 \cdot 10^5$), đống $i$ có $a_i$ viên ($1 \le a_i \le 10^{18}$), chơi Nim thông thường. In tên người thắng và số nước đi thắng khác nhau của An ở lượt đầu (nước đi đưa An tới chiến thắng nếu sau đó An chơi tối ưu). Nếu Bình thắng, in Binh 0.

Input
3
3 4 5
Output
An 1

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Nước đi thắng là nước đưa XOR về 0. Ở đống $i$, chỉ có một giá trị mới làm XOR bằng 0 là $a_i \oplus X$, và nó hợp lệ khi $a_i \oplus X < a_i$, tức là $a_i$ chứa bit cao nhất của $X$. Vậy đáp án là số đống có bit đó. Ví dụ: $X = 2$ (bit 1), chỉ đống 3 (nhị phân 011) có bit 1.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<long long> a(n);
    long long X = 0;
    for(auto &x : a){ cin >> x; X ^= x; }
    if(X == 0){ cout << "Binh 0"; return 0; }
    int cnt = 0;
    for(auto x : a) if((x ^ X) < x) cnt++;    // đống chứa bit cao nhất của X
    cout << "An " << cnt;
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
X = 0
for x in a:
    X ^= x
if X == 0:
    print("Binh 0")
else:
    print("An", sum(1 for x in a if x ^ X < x))   # đống chứa bit cao nhất của X
Bài 4Tổng các trò bốc sỏiKhá

Có $n$ đống sỏi ($n \le 10^5$), đống $i$ có $a_i$ viên ($0 \le a_i \le 10^5$). Mỗi lượt chọn một đống và bốc đúng $s_1$, …, hoặc $s_m$ viên ở đống đó ($m \le 10$, các $s_j$ khác nhau, $1 \le s_j \le 10^5$). In tên người thắng.

Input: dòng đầu $m, n$; dòng hai gồm $m$ số $s_j$; dòng ba gồm $n$ số $a_i$.

Input
3 2
1 3 4
4 6
Output
Binh

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Áp Sprague–Grundy: tính $g(x)$ bằng mex cho $x$ tới $\max a_i$ (mỗi trạng thái $\le m$ nước nên $g \le 10$), rồi XOR $g(a_i)$. Ví dụ: $g(4) = g(6) = 2$ (Bảng 3), $2 \oplus 2 = 0$ nên Bình thắng, dù từng đống riêng lẻ đều là thế thắng.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int m, n;
    cin >> m >> n;
    vector<int> s(m), a(n);
    for(auto &x : s) cin >> x;
    int mx = 0;
    for(auto &x : a){ cin >> x; mx = max(mx, x); }
    vector<int> g(mx + 1, 0);
    for(int x = 1; x <= mx; x++){
        bool seen[12] = {false};                 // m <= 10 nên Grundy <= 10
        for(int t : s) if(t <= x) seen[g[x - t]] = true;
        int v = 0;
        while(seen[v]) v++;
        g[x] = v;
    }
    int X = 0;
    for(int x : a) X ^= g[x];
    cout << (X ? "An" : "Binh");
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
m, n = int(data[0]), int(data[1])
s = sorted(map(int, data[2:2 + m]))
a = list(map(int, data[2 + m:2 + m + n]))
mx = max(a)
g = [0] * (mx + 1)
for x in range(1, mx + 1):
    seen = set()
    for t in s:
        if t > x:
            break
        seen.add(g[x - t])
    v = 0
    while v in seen:
        v += 1
    g[x] = v
X = 0
for x in a:
    X ^= g[x]
print("An" if X else "Binh")
Bài 5Lấy hai đầu dãyKhá

Cho dãy $n$ số nguyên dương ($1 \le n \le 2000$, $a_i \le 10^9$). An và Bình lần lượt lấy một số ở đầu trái hoặc đầu phải của dãy còn lại và cộng vào điểm của mình, mỗi người muốn điểm của mình lớn nhất. In điểm của An và điểm của Bình.

Input
4
8 15 3 7
Output
22 11

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
QHĐ đoạn của Phần 4, nhưng $n = 2000$ thì bảng $n^2$ số long long tốn 32 MB. Mẹo tiết kiệm: chỉ giữ một mảng $d[l]$ cho độ dài hiện tại. Khi chuyển từ độ dài $len - 1$ lên $len$, cập nhật $d[l]$ theo $l$ tăng dần: lúc đó $d[l + 1]$ vẫn là giá trị cũ của đoạn $[l+1, r]$, còn $d[l]$ cũ là đoạn $[l, r-1]$, đúng hai ô cần dùng. Đáp án tới $2 \cdot 10^{12}$ nên dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<long long> a(n);
    long long sum = 0;
    for(auto &x : a){ cin >> x; sum += x; }
    vector<long long> d(a);                    // d[l] = dp[l][l + len - 1], độ dài len = 1
    for(int len = 2; len <= n; len++)
        for(int l = 0; l + len - 1 < n; l++)    // d[l + 1] vẫn là giá trị của độ dài len - 1
            d[l] = max(a[l] - d[l + 1], a[l + len - 1] - d[l]);
    cout << (sum + d[0]) / 2 << " " << (sum - d[0]) / 2;
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
d = a[:]                                    # độ dài 1: dp[l][l] = a[l]
for ln in range(2, n + 1):
    d = [max(a[l] - d[l + 1], a[l + ln - 1] - d[l]) for l in range(n - ln + 1)]
s = sum(a)
print((s + d[0]) // 2, (s - d[0]) // 2)
Bài 6Quân cờ trên cây (tổng ôn bài 33)Khá

Cho cây $n$ đỉnh ($n \le 2 \cdot 10^5$) gốc tại đỉnh 1. Trên cây đặt $k$ quân cờ ($k \le 2 \cdot 10^5$) tại các đỉnh $c_1, \ldots, c_k$ (nhiều quân có thể chung một đỉnh). Mỗi lượt, người chơi chọn một quân và đi nó xuống một đỉnh con của đỉnh nó đang đứng. Quân ở lá thì không đi được. In tên người thắng.

Input: dòng đầu $n, k$; $n - 1$ dòng sau mỗi dòng một cạnh $u, v$; dòng cuối gồm $k$ số $c_i$.

Input
7 2
1 2
1 3
2 4
2 5
3 6
6 7
2 6
Output
Binh

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Mỗi quân là một trò chơi độc lập trên cây, trạng thái là đỉnh đang đứng. Grundy $g(u) = \operatorname{mex}$ của $g$ các con, lá có $g = 0$: đây là QHĐ trên cây từ lá lên gốc như bài 33. Cây có thể là đường thẳng dài $2 \cdot 10^5$ nên duyệt DFS bằng ngăn xếp, lưu thứ tự thăm rồi tính ngược. Ví dụ: lá 4, 5, 7 có $g = 0$; $g(6) = 1$; $g(3) = 0$; $g(2) = \operatorname{mex}\{0, 0\} = 1$; quân ở 2 và 6 cho $1 \oplus 1 = 0$ nên Bình thắng.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, k;
    cin >> n >> k;
    vector<vector<int>> adj(n + 1);
    for(int i = 0; i < n - 1; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    vector<int> par(n + 1, 0), order, st = {1};
    par[1] = -1;
    while(!st.empty()){                          // DFS bằng ngăn xếp, tránh tràn đệ quy
        int u = st.back(); st.pop_back();
        order.push_back(u);
        for(int v : adj[u]) if(v != par[u]){ par[v] = u; st.push_back(v); }
    }
    vector<int> g(n + 1, 0);
    for(int t = n - 1; t >= 0; t--){             // con luôn được tính trước cha
        int u = order[t];
        vector<int> vals;
        for(int v : adj[u]) if(v != par[u]) vals.push_back(g[v]);
        sort(vals.begin(), vals.end());
        int mex = 0;
        for(int x : vals){ if(x == mex) mex++; else if(x > mex) break; }
        g[u] = mex;
    }
    int X = 0;
    for(int i = 0; i < k; i++){ int c; cin >> c; X ^= g[c]; }
    cout << (X ? "An" : "Binh");
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, k = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    pos = 2
    for _ in range(n - 1):
        u, v = int(data[pos]), int(data[pos + 1]); pos += 2
        adj[u].append(v); adj[v].append(u)
    par = [0] * (n + 1); par[1] = -1
    order, st = [], [1]
    while st:                                   # DFS bằng ngăn xếp
        u = st.pop(); order.append(u)
        for v in adj[u]:
            if v != par[u]:
                par[v] = u; st.append(v)
    g = [0] * (n + 1)
    for u in reversed(order):                   # con luôn được tính trước cha
        vals = {g[v] for v in adj[u] if v != par[u]}
        m = 0
        while m in vals:
            m += 1
        g[u] = m
    X = 0
    for i in range(k):
        X ^= g[int(data[pos + i])]
    print("An" if X else "Binh")

main()
Bài 7Nim trên đoạn có cập nhật (tổng ôn bài 31, 37)Khá

Cho dãy $a_1, \ldots, a_n$ ($n \le 10^5$, $1 \le a_i \le 10^9$) và $q$ thao tác ($q \le 10^5$), mỗi thao tác là một trong hai loại:

  • 1 i v: gán $a_i = v$;
  • 2 l r: nếu chơi Nim với các đống $a_l, a_{l+1}, \ldots, a_r$ thì ai thắng? In An hoặc Binh.
Input
5 4
1 2 3 4 5
2 1 3
2 2 4
1 3 2
2 1 3
Output
Binh
An
An

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Theo định lý Nim chỉ cần XOR của đoạn. XOR có tính chất giống phép cộng: $x \oplus x = 0$ nên XOR đoạn $= P_r \oplus P_{l-1}$ với $P$ là XOR tiền tố. Có cập nhật nên dùng Fenwick (bài 31) với phép XOR thay cho phép cộng; gán $a_i = v$ tương đương “XOR thêm $a_i \oplus v$” tại vị trí $i$. Ví dụ: $1 \oplus 2 \oplus 3 = 0$ (Bình), $2 \oplus 3 \oplus 4 = 5$ (An), sau khi gán $a_3 = 2$ thì $1 \oplus 2 \oplus 2 = 1$ (An).
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, q;
vector<int> bit, a;

void upd(int i, int v){ for(; i <= n; i += i & -i) bit[i] ^= v; }
int pre(int i){ int r = 0; for(; i > 0; i -= i & -i) r ^= bit[i]; return r; }

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> q;
    bit.assign(n + 1, 0); a.assign(n + 1, 0);
    for(int i = 1; i <= n; i++){ cin >> a[i]; upd(i, a[i]); }
    while(q--){
        int t, x, y;
        cin >> t >> x >> y;
        if(t == 1){ upd(x, a[x] ^ y); a[x] = y; }          // XOR bỏ giá trị cũ, thêm giá trị mới
        else cout << ((pre(y) ^ pre(x - 1)) ? "An" : "Binh") << "\n";
    }
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, q = int(data[0]), int(data[1])
    a = [0] + [int(x) for x in data[2:2 + n]]
    bit = [0] * (n + 1)
    for i in range(1, n + 1):                   # dựng Fenwick trong O(n)
        bit[i] ^= a[i]
        j = i + (i & -i)
        if j <= n:
            bit[j] ^= bit[i]
    out = []
    pos = 2 + n
    for _ in range(q):
        t, x, y = int(data[pos]), int(data[pos + 1]), int(data[pos + 2]); pos += 3
        if t == 1:
            v = a[x] ^ y; a[x] = y
            while x <= n:
                bit[x] ^= v; x += x & -x
        else:
            r, i = 0, y
            while i > 0:
                r ^= bit[i]; i -= i & -i
            i = x - 1
            while i > 0:
                r ^= bit[i]; i -= i & -i
            out.append("An" if r else "Binh")
    print("\n".join(out))

main()
Bài 8Trò chơi chia số (tổng ôn bài 40)Khó

Trên bảng có $n$ số nguyên dương $a_1, \ldots, a_n$ ($n \le 10^5$, $a_i \le 10^6$). Mỗi lượt, người chơi chọn một số $x > 1$ trên bảng và một ước $d > 1$ của $x$, rồi thay $x$ bằng $x / d$. Khi mọi số đều bằng 1 thì người đến lượt thua. In tên người thắng.

Input
3
12 8 5
Output
An

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Gọi $\Omega(x)$ là số thừa số nguyên tố của $x$ tính cả lặp ($12 = 2 \cdot 2 \cdot 3$ nên $\Omega = 3$). Chia $x$ cho $d$ làm $\Omega$ giảm đúng $\Omega(d) \ge 1$, và giảm bao nhiêu (từ 1 tới $\Omega(x)$) cũng được bằng cách chọn $d$ là tích của bấy nhiêu thừa số. Vậy mỗi số là một đống Nim $\Omega(x)$ viên! Tính $\Omega$ nhanh bằng sàng ước nguyên tố nhỏ nhất (bài 40), rồi XOR. Ví dụ: $3 \oplus 3 \oplus 1 = 1 \ne 0$ nên An thắng.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<int> a(n);
    int mx = 2;
    for(auto &x : a){ cin >> x; mx = max(mx, x); }
    vector<int> spf(mx + 1, 0);                    // ước nguyên tố nhỏ nhất
    for(int i = 2; i <= mx; i++)
        if(spf[i] == 0)
            for(int j = i; j <= mx; j += i)
                if(spf[j] == 0) spf[j] = i;
    int X = 0;
    for(int x : a){
        int cnt = 0;                               // Omega(x): số thừa số nguyên tố kể cả lặp
        while(x > 1){ x /= spf[x]; cnt++; }
        X ^= cnt;
    }
    cout << (X ? "An" : "Binh");
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:n + 1]))
    M = max(a) + 1
    spf = list(range(M))                        # ước nguyên tố nhỏ nhất
    r = int(M ** 0.5) + 1
    is_p = bytearray([1]) * (r + 1)
    primes = []
    for i in range(2, r + 1):
        if is_p[i]:
            primes.append(i)
            is_p[i * i::i] = bytearray(len(range(i * i, r + 1, i)))
    for p in reversed(primes):                  # số nguyên tố nhỏ ghi đè sau cùng
        if p * p < M:
            spf[p * p::p] = [p] * len(range(p * p, M, p))
    X = 0
    for x in a:
        c = 0                                   # Omega(x)
        while x > 1:
            x //= spf[x]; c += 1
        X ^= c
    print("An" if X else "Binh")

main()

Vậy là em đã hoàn thành trọn vẹn series Thuật toán chuyên sâu. Cảm ơn em đã kiên trì đi cùng mình qua mười bài học dày đặc này. Chúc em giữ nhịp luyện tập đều đặn và đạt kết quả thật tốt ở kỳ thi HSG sắp tới!

Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Bản quyền thuộc về Thầy Nguyễn Việt Nam Sơn. Bài viết, lời giải, hình minh hoạ và mã nguồn trên trang là công sức biên soạn của thầy qua nhiều năm giảng dạy. Vui lòng không sao chép, đăng lại hay sử dụng dưới mọi hình thức khi chưa được sự đồng ý của tác giả. Nếu thấy hữu ích, hãy chia sẻ bằng đường link bài gốc nhé. Cảm ơn bạn!
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • 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.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • 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