Chương 5·Bài 52

Ad Hoc Game Theory

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.

Đổi tập số sỏi được lấy mỗi lượt; T là thắng, B là thua cho người sắp đi.

Với tham số tới 101810^{18}, không thể tính hết, nhưng có thể tính bằng tay hoặc brute force cho nn 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 nn nào là vị trí thua cho người đi trước?

Ví dụBước Nhảy Trên Hoán Vị

Đề. Hoán vị aa dài n≤5000n \le 5000. Từ ô ii được nhảy tới ô jj nếu ∣i−j∣|i - j| là bội dương của aia_i và aj>aia_j > a_i. 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 aa giảm dần: khi tới ô ii, mọi ô đích đã có kết quả. Ô ii 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ị vv.

Bước 2: các ô đích. Vòng j bắt đầu từ i % v, tăng theo bước vv: đi qua mọi ô cách ii một bội của vv, 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 ô ii.

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

Tổng số ô xét là ∑vn/v=O(nlog⁡n)\sum_v n / v = O(n \log n).

Ví dụBước Nhảy 1-2-K

Đề. Quân ở ô nn trên dải 0..n0..n, mỗi lượt lùi 1, 2 hoặc kk ô; không đi được thì thua. n≤1018n \le 10^{18}, 3≤k≤10183 \le k \le 10^{18}, tối đa 100 test.

Lời giải. Không có bước kk, thua đúng khi nn chia hết cho 3. Nếu kk không chia hết cho 3, bước kk 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 kk chia hết cho 3, brute force cho thấy chu kỳ k+1k + 1: lấy r=n mod (k+1)r = n \bmod (k + 1), ô r=kr = k thắng (nhảy kk về 0), các rr khác thắng khi rr không chia hết cho 3.

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

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ỗ

Đề. nn khúc gỗ dài ai≤109a_i \le 10^9. 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ó ∑ai\sum a_i khúc. Tổng số lượt là ∑(ai−1)\sum (a_i - 1); lẻ thì errorgorn thắng.

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

Ví dụCặp Ký Tự Biến Mất

Đề. Xâu chữ thường dài tới 10510^5. 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.

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

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.

Người đi trước đặt đĩa vào tâm bàn, sau đó luôn đặt đĩa đối xứng qua tâm với đĩa đối thủ vừa đặt.12345người đi trước: 1, 3, 5 · đối thủ: 2, 4
Người đi trước đặt đĩa vào tâm bàn, sau đó luôn đặt đĩa đối xứng qua tâm với đĩa đối thủ vừa đặt.

Ví dụĐặt Đĩa Tròn

Đề. Bàn a×ba \times b, hai người luân phiên đặt đĩa bán kính rr nằm trọn trong bàn và không chồng lên nhau. Ai không đặt được thì thua. a,b,r≤109a, b, r \le 10^9.

Lời giải. Không đặt nổi một đĩa (2r>a2r > a hoặc 2r>b2r > b) 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.

Code đầ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

Ví dụMột Nửa Số Đống

Đề. nn đống đá, nn chẵn, n≤5000n \le 5000. Mỗi lượt chọn đúng n/2n/2 đống khác rỗng và bớt mỗi đống một lượng dương. Khi còn ít hơn n/2n/2 đống khác rỗng, người tới lượt thua. Alice đi trước.

Lời giải. Gọi cc là số đống bằng giá trị nhỏ nhất. Nếu c>n/2c > n/2, 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 n/2n/2 đố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 c≤n/2c \le n/2, Alice kéo n/2n/2 đố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.

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

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 f(trạng thaˊi)f(\text{trạng thái}) 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 xx được vxv_x điểm, rồi đổi vai: f=max⁡x(vx−f(trạng thaˊi sau))f = \max_x (v_x - f(\text{trạng thái sau})). Có hiệu dd và tổng SS thì điểm người đầu là (S+d)/2(S + d) / 2. 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.

Mỗi ô f(l, r) lấy từ hai ô kề (xanh): lấy số trái hay số phải rồi đổi vai.

Hỏi nhanh

Dãy 5,1,25, 1, 2; 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?

Ví dụLấy Số Hai Đầu

Đề. Dãy n≤5000n \le 5000 số nguyên (có thể âm, trị tuyệt đối tới 10910^9). 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 f(l,r)f(l, r) là điểm người đi lượt này hơn đối thủ khi còn đoạn al..ara_l..a_r. Lấy đầu trái được ala_l, rồi đối thủ hơn mình f(l+1,r)f(l + 1, r); lấy đầu phải tương tự. Vậy f(l,r)=max⁡(al−f(l+1,r), ar−f(l,r−1))f(l, r) = \max(a_l - f(l + 1, r),\ a_r - f(l, r - 1)), f(l,l)=alf(l, l) = a_l. Biết tổng SS và hiệu d=f(1,n)d = f(1, n) thì điểm người đầu là (S+d)/2(S + d) / 2.

Bước 1: một mảng cho độ dài hiện tại. dp[l] ở vòng độ dài len là f(l,l+len−1)f(l, l + len - 1). Lúc tính, dp[l + 1] vẫn là giá trị độ dài cũ, tức f(l+1,r)f(l + 1, r); dp[l] cũ là f(l,r−1)f(l, r - 1). Duyệt ll tăng dần nên đọc dp[l + 1] trước khi nó bị ghi. Bộ nhớ O(n)O(n) thay vì n2n^2 số long long.

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

O(n2)O(n^2) thời gian, O(n)O(n) 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ố f=max⁡(v−f′)f = \max(v - f'); 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à (S+d)/2(S + d)/2.

Bài tập

1
Cuộc Đấu Ước Chungcanuc80k5200Mô phỏng thẳng: mỗi lượt lấy gcd của số cố định với số đá còn lại, ai không đủ đá thì thua.
LQDOJ
2
Bước Chạm Bội Bacanuc80k5201nn không chia hết cho 3 thì Vanya thắng ngay lượt đầu; ngược lại Vova luôn đưa số về bội của 3.
LQDOJ
3
Quân Cờ Góc Bảngcanuc80k5202Mỗi lượt đổi chẵn lẻ của tổng tọa độ đã đi; Burenka thắng khi n+mn + m lẻ.
LQDOJ
4
Ba Hộp Nútcanuc80k5204Hai người cùng giành nút chung trước; cc lẻ thì Anna được thêm một lượt chung, so aa với bb.
LQDOJ
5
Bảng Que Giao Nhaucanuc80k5205Mỗi lượt bỏ một thanh mỗi chiều, số lượt là min⁡(n,m)\min(n, m); lẻ thì Akshat thắng.
LQDOJ
6
Chữ Số Quyết Địnhcanuc80k5206Chữ số cuối nằm ở vị trí cùng tính chẵn lẻ với nn; chỉ người giữ các vị trí đó quyết định.
LQDOJ
7
Xâu Đối Xứng Cuối Cùngcanuc80k5208Đếm số chữ cái có tần suất lẻ; bằng 0 hoặc lẻ thì người đầu thắng.
LQDOJ
8
Những Bó Quecanuc80k5210Số lượt là ⌊n/k⌋\lfloor n/k \rfloor, Sasha thắng khi số đó lẻ.
LQDOJ
9
Dãy Đống Liên Tiếpcanuc80k5212Tìm đống đầu tiên lớn hơn 1: người tới được nó điều khiển phần còn lại; chẵn lẻ số đống bằng 1 đứng trước quyết định.
LQDOJ
10
Chẵn Và Lẻcanuc80k5213Mỗi lượt lấy phần tử lớn nhất còn lại, vì lấy nó vừa có thể cộng điểm vừa tước điểm của đối thủ.
LQDOJ
11
Phần Tử Đầu Tiêncanuc80k5214Alice thắng khi a1a_1 lớn hơn phần tử nhỏ nhất trong phần còn lại: luôn đổi phần tử nhỏ nhất lên đầu cho Bob.
LQDOJ
12
Đống Đá Không Lặpcanuc80k5219T thắng khi có đống lớn hơn tổng các đống khác, hoặc tổng số viên lẻ.
LQDOJ
13
Trò Chơi Số Nguyêncanuc80k5224Xét các trường hợp n=1n = 1, n=2n = 2, nn lẻ, nn là lũy thừa của 2, n=2pn = 2p với pp nguyên tố lẻ.
LQDOJ
14
Xóa Ước Sốcanuc80k5229nn lẻ thì thua; nn chẵn không phải lũy thừa của 2 thì thắng; n=2kn = 2^k thắng khi kk chẵn.
LQDOJ
15
Phân Chia Câycanuc80k5236BFS từ hai đầu: mỗi người giành các đỉnh gần mình hơn (Fennec được đỉnh cách đều); so số đỉnh hai bên.
LQDOJ

Đọc thêm