Chương 5·Bài 52

Ad Hoc Game Theory

Có một đống 10 viên sỏi. Hai người lần lượt lấy 1, 2 hoặc 3 viên; ai không còn gì để lấy thì thua. Đi trước nên lấy mấy viên?

Trả lời được là nhờ nhìn ra vị trí thắng, vị trí thua. Nhiều bài trò chơi không cần lý thuyết nào nặng hơn: tính các trạng thái nhỏ để tìm quy luật, đếm chẵn lẻ số lượt, hoặc dựng chiến lược đối xứng để luôn có nước đáp trả. Khi hai người tranh điểm, DP minimax làm nốt phần còn lại.

Thắng và 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. Nó 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. Đồ thị trạng thái không có chu trình thì tính theo thứ tự ngược của nước đi, từ trạng thái gần kết thúc.

Hình dưới tính bảng này cho trò lấy sỏi. Đổi tập số sỏi được lấy mỗi lượt để thấy quy luật.

T là thắng, B là thua cho người sắp đi.

Tham số tới 101810^{18} thì không tính hết được. Ta tính brute force cho nn nhỏ, nhìn ra chu kỳ, rồi chứng minh 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;
#define 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

Độ phức tạp: 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;
#define 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

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 đó chiến lược không còn ý nghĩa: 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 thay đổi đú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 thì xâu cuối cùng là duy nhất, như rút gọn ngoặc, nên số lần xóa luôn như 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;
#define 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

Đối xứng

Nếu người thứ hai luôn đi được nước gương của nước vừa rồi thì 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 phải đưa trạng thái về dạng đối xứng, ví dụ chiếm đúng tâm.

Tổng quát hơn là ghép cặp: chia các nước đi thành cặp sao cho đối thủ dùng một nước thì nước còn lại của cặp vẫn hợp lệ. 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;
#define 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

Tranh điểm

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})). Biết 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 cho phép tham lam (sắp giảm dần rồi chọn lần lượt). Khi các lượt ảnh hưởng nhau qua hình dạng trạng thái thì cần DP.

Trong hình, mỗi ô f(l,r)f(l, r) lấy từ hai ô kề màu xanh: lấy số trái hay số phải, rồi đổi vai.

Bảng f(l, r) cho bài lấy số ở hai đầu.

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;
#define 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ớ. Cuốn mảng không chỉ tiết kiệm bộ nhớ, nó còn nhanh hơn nhiều:

DP lấy số hai đầu, n = 4000, trung vị 5 lần chạy, i7-10850H, g++ -O2.bảng 2D, theo độ dài86 ms · gấp 15 lầnbảng 2D, theo hàng32 ms · gấp 5,7 lầncuốn thành mảng 1D5,6 ms
DP lấy số hai đầu với bốn nghìn phần tử, ba cách lưu bảng, đo trên i7-10850H, g++ -O2.

Cùng 8⋅1068 \cdot 10^6 ô cần tính, mảng một chiều nhanh hơn bảng hai chiều duyệt theo độ dài khoảng 15 lần, và duyệt theo hàng cũng chậm hơn khoảng 6 lần. Quy tắc: DP chỉ cần lớp trước thì cuốn mảng, cả bộ nhớ lẫn tốc độ đều lợi.

Khi đi thi

  • Viết brute force 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.
  • Hỏi ngay: tổng số lượt có cố định không? Có thì đáp án là chẵn lẻ. Trạng thái có đối xứng thì 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'); 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

Đọc thêm