Lý thuyết trò chơi
Người viết: Nguyễn Nhật Minh Khôi
Reviewer chính: Trần Quang Lộc, Hồ Ngọc Vĩnh Phát
Trong thực tế, có rất nhiều loại trò chơi khác nhau, tuy nhiên trong bài viết này, chúng ta sẽ chỉ giới hạn trong các trò chơi tổ hợp cân bằng vì độ phổ biến của nó trong lập trình thi đấu.
Giới thiệu: trò chơi bốc sỏi¶
Trước khi đi vào các định nghĩa, chúng ta sẽ đi qua một ví dụ để hiểu được vấn đề mà lý thuyết trò chơi với trò chơi tổ hợp cân bằng giải quyết.
Phát biểu trò chơi¶
Hai bạn An và Bình đang chơi bốc sỏi, trò chơi được phát biểu như sau: cho một đống sỏi có
Cách giải¶
Ta sẽ tiếp cận bài toán bằng cách giải những trường hợp đơn giản trước, sau đó rút ra quy luật chung cho trường hợp tổng quát.
Với
Với
Do đó, trong trường hợp này An nên cố gắng kéo dài trò chơi bằng cách chỉ bốc
Từ nhận xét trên, ta thấy nếu
Ta rút ra được một quy luật của trò chơi: nếu ở lượt của ai mà số sỏi là
Vậy chiến thuật tối ưu của An dựa trên số sỏi hiện tại
với
Trò chơi tổ hợp cân bằng¶
Trò chơi ở trên chính là một ví dụ điển hình cho trò chơi tổ hợp cân bằng. Trong phần này, chúng ta sẽ tổng quát hóa bài toán này thành định nghĩa trò chơi tổ hợp cân bằng để thể giải được một lớp bài toán lớn hơn.
Định nghĩa¶
Trò chơi tổ hợp là trò chơi gồm: hai người chơi (ở đây gọi người chơi trước là
Trong trò chơi ví dụ, giả sử
thì mỗi trạng thái sẽ là số sỏi còn lại hiện tại của trò chơi. Do đó tập trạng thái của trò chơi là (hình dưới).
Giả sử đang ở trạng thái
, ta có thể di chuyển hợp lệ đến trạng thái (lấy ra viên sỏi), (lấy ra viên sỏi) hoặc (lấy ra viên sỏi). Do đó ta có các phần tử thuộc tập di chuyển hợp lệ (hình dưới).
Từ đó, ta nhận xét được tập các bước di chuyển hợp lệ
của cả hai người chơi sẽ là tất cả những cặp số nguyên ( ) sao cho (từ trạng thái có số sỏi chỉ có thể lấy ra , , hoặc viên sỏi) và (số sỏi lấy ra không được phép lớn hơn số sỏi đang có). Trò chơi kết thúc khi không còn viên sỏi nào để bốc, do đó tập trạng thái kết thúc của cả hai người chơi là
. Khi đó người bốc viên sỏi cuối cùng sẽ là người thắng.
Khi hai người chơi có tập các bước di chuyển hợp lệ
Trò chơi bốc sỏi ở ví dụ chính là một trò chơi tổ hợp cân bằng, do cả An và Bình đều chỉ được cho phép bốc
, , hoặc viên sỏi một lần và đều thắng khi bốc được viên sỏi cuối cùng.
Chiến thuật thắng¶
Mục tiêu của chúng ta khi giải các bài toán với trò chơi tổ hợp cân bằng là tìm ra chiến thuật mà ở đó một trong hai người chơi luôn có thể ép người còn lại thua. Chiến thuật như vậy gọi là một chiến thuật thắng.
Rõ ràng khi phân tích trò chơi bốc sỏi, ta thấy rằng nếu cả An và Bình đều chơi tối ưu thì chỉ cần biết được trạng thái ban đầu của trò chơi, ta có thể xác định được người đi trước (An) sẽ thắng hay thua. Nếu số sỏi ban đầu là
Vậy từ đây, ta thấy với một trò chơi tổ hợp cân bằng, chỉ cần biết trạng thái ban đầu, ta có thể suy ra được ai sẽ là người chiến thắng. Để phục vụ cho việc đưa ra chiến thuật thắng, người ta phân loại các trạng thái cùa trò chơi thành 2 tập
Trong trò chơi ví dụ, nếu số sỏi ban đầu là
thì - -
Từ định nghĩa trên,
Tới đây, có thể bạn sẽ đặt ra câu hỏi: Liệu mọi trạng thái của trò chơi đều thuộc một trong hai tập
Ràng buộc đầu tiên xác định trường hợp cơ bản nhất. Hai ràng buộc sau sẽ giúp chúng ta liên tục đệ quy từ trường hợp cơ bản để xây dựng được tập
Khi xây dựng được tập
Qua đó, ta thấy được ý nghĩa của việc đặt tên tập
Thuật toán xác định tập P và N¶
Ta có ý tưởng thuật toán để tìm tập
// Hàm kiểm tra một trạng thái thuộc P (true) hay N (false)
bool isInP(State u) {
if (u in T) // nếu u là trạng thái kết thúc thì u thuộc P
return true;
// duyệt qua tất cả các trạng thái v trong tập hợp S
for (State v in S)
if (u, v) in Q and isInP(v)
return false; // nếu có v thuộc P thì u thuộc N
return true; // nếu không thì u thuộc P
}
Ở đây có một chú ý về cách cài đặt trên, đó là kiểu dữ liệu State
là một kiểu dữ liệu tượng trưng để lưu một trạng thái của trò chơi. Trong thực tế, trạng thái hay được biểu diễn bằng một số nguyên int, tuy nhiên nhiều trường hợp trạng thái của chúng ta không ở dạng số nguyên, khi đó có một số cách các bạn có thể xem xét để lưu trạng thái như: bitmask, hashmap (unordered_map trong c++).
Tuy nhiên, thuật toán này có một nhược điểm, đó là gọi lại cùng một giá trị quá nhiều lần do không lưu lại kết quả trong quá trình đệ quy. Cách giải quyết chính là quy hoạch động top-down (hay còn gọi là đệ quy có nhớ), trong đó ta có một mảng
Ví dụ cài đặt thuật toán cho trò chơi bốc sỏi¶
Đầu tiên, ta cần thêm các thư viện cần thiết, cũng như khai báo mảng
## include<bits/stdc++.h>
using namespace std;
const int MAX_N = 100000; // số trạng thái tối đa
int dp[MAX_N];
Sau đó, ta viết hàm xác định xem trạng thái
bool isInP(int u) {
if (dp[u] != -1) // nếu u đã được tính trước đó thì
return dp[u];
if (u == 0) { // u = 0 là trạng thái kết thúc nên thuộc P
dp[u] = 1;
return 1;
}
// Từ u chỉ có thể đi tới các v hợp lệ
for (int v = u - 1; v >= max(u - 3, 0); v--)
if (isInP(v)) {
dp[u] = 0;
return false;
}
// u không đi được trạng thái nào thuộc P
dp[u] = 1;
return true;
}
Ở đây quy ước giá trị của mảng
Sở dĩ ta quy ước như vậy vì c++ có cơ chế implicit casting. Nói đơn giản, khi trả về isInP(u)
, bool
với quy tắc:
Cài đặt này tối ưu thời gian đệ quy bằng hai ý sau:
1. Khi tính xong, trước khi trả về giá trị thì ta lưu giá trị lại vào một mảng
Cuối cùng, ta viết hàm main để nhập xuất và kết luận nghiệm. Lưu ý trước khi thực hiện quá trình tìm tập fill
do thư viện chuẩn của c++ cung cấp.
int main() {
int n;
cin >> n;
fill(dp, dp + n + 1, -1);
if (!isInP(n)) cout << "A win";
else cout << "B win";
return 0;
}
Trò chơi Nim chuẩn¶
Sau khi đã tìm hiểu lý thuyết trò chơi tổ hợp cân bằng tổng quát, phần tiếp theo ta sẽ áp dụng lý thuyết đó vào một bài toán kinh điển trong lý thuyết trò chơi - trò chơi Nim - đại diện tiêu biểu cho trò chơi tổ hợp cân bằng.
Phát biểu trò chơi¶
Có
Có hai người chơi thay phiên nhau bỏ sỏi. Người chơi trong lượt hiện tại có thể loại bỏ bao nhiêu sỏi tùy thích, miễn là tất cả các sỏi đều cùng một đống. Hình thức hơn, người chơi sẽ chọn một đống sỏi
Trò chơi kết thúc khi không còn sỏi trên bàn chơi. Người chiến thắng là người lấy được viên sỏi cuối cùng. Nói cách khác, người thua cuộc là người đầu tiên không thể lấy sỏi trong lượt của mình.
Trò bốc sỏi ở ví dụ trên chính là biến thể của trò chơi Nim chuẩn, khác hai chỗ là ở đây chỉ có một đống sỏi (
Giá trị Nim¶
Mục tiêu lớn nhất của chúng ta vẫn là xác định xem ai là người thắng nếu cả hai người chơi tối ưu, hay nói cách khác là xác định được chiến thuật thắng.
Tuy nhiên, khi bắt tay vào làm, bạn sẽ gặp ngay một rào cản, đó là trạng thái của trò chơi đang được biểu diễn dưới dạng một bộ các số nguyên chứ không phải một số nguyên, điều đó gây khó khăn cho chúng ta khi tổng quát hóa lời giải và lập trình, một khó khăn rất dễ thấy đó là nếu chúng ta xài mảng
Đầu tiên, hãy giải quyết trường hợp đơn giản nhất - chỉ có một đống sỏi duy nhất với
Tiếp theo, chúng ta sẽ tìm cách ghép các đống sỏi đơn lại thành một trò chơi hoàn chỉnh gồm
Ba thuộc tính này, đặc biệt nhất là tính chất
Thực chất, phép bitwise XOR chỉ là phép cộng modulo
Ví dụ: với trò chơi Nim có ba đống sỏi với số sỏi lần lượt là
Định lý Bouton¶
Tuy nhiên, trong phần trình bày ở phía trên có một giả thuyết rất quan trọng mà ta chỉ mới thừa nhận chứ chưa chứng minh, đó là trạng thái trò chơi hiện tại thuộc
Định lý Bouton: trạng thái của trò chơi Nim
Chứng minh
Gọi
Đầu tiên, trạng thái kết thúc là
Thứ hai, với một trạng thái thuộc
Lưu ý rằng phép XOR không giống phép cộng thông thường. Ở phép cộng hai số nguyên dương, kết quả luôn lớn hơn các toán hạng ban đầu. Tuy nhiên, trong phép XOR điều này không xảy ra, kết quả có thể lớn hơn hoặc nhỏ hơn các toán hạng ban đầu. Do đó việc ta có thể đảm bảo luôn tồn tại đống sỏi
Vì
Ví dụ, nếu trò chơi Nim hiện tại có
cột có số sỏi lần lượt là , , , , thì thao tác tính tổng Nim và chọn cột để lấy sỏi ra sẽ diễn ra như hình dưới
Cuối cùng, với một trạng thái thuộc
Điều này có nghĩa là ta không bốc viên sỏi nào từ đống
Ví dụ, nếu trò chơi Nim hiện tại có
đống có số sỏi lần lượt là , , , thì tổng Nim . Xét bit đầu tiên từ phải qua, ta thấy được số lượng bit được bật tại vị trí này là số chẵn ( , tương ứng với bit đầu tiên của và ). Tương tự, số lượng các bit được bật tại các vị trị khác đều có tính chất này. Điều này không phải là trùng hợp mà do tính chất của phép XOR, nếu muốn bit thứ trong kết quả bằng thì số lượng bit thứ được bật trong các toán hạng phải là số chẵn. Từ đây ta nhận thấy, việc bỏ sỏi ở một đống sỏi chỉ có thể làm thay đổi số lượng bit được bật tại mỗi vị trí lên hoặc xuống 1 đơn vị, do đó dù cho lấy sỏi ở cột nào đi nữa thì vẫn sẽ xuất hiện một vị trí có số bit được bật là lẻ.
Rõ ràng
Qua định lý Bouton, chúng ta có một cách xác định tập
Cài đặt¶
Hàm isInP
trong trường hợp này rất đơn giản, nếu lưu số lượng sỏi mỗi đống vào vector số nguyên thì thuật toán chỉ đơn giản là XOR của các phần tử với nhau, độ phức tạp thuật toán là
bool isInP(vector<int> v) {
int g = 0;
for (auto p: v)
g ^= p;
return (g == 0);
}
Misère Nim¶
Ngoài trò chơi Nim chuẩn, có rất nhiều biến thể của trò chơi Nim. Một trong số đó là misère Nim, cách chơi giống như trò chơi Nim bình thường, tuy nhiên thay vì người lấy viên sỏi cuối cùng sẽ thắng, trong Misère Nim thì người lấy viên sỏi cuối cùng sẽ thua.
Khi đó, dựa vào trạng thái bắt đầu, ta có thể xác định việc thắng thua như sau:
- Nếu tất cả đống sỏi đều có số sỏi nhỏ hơn hơn
Định lý Sprague-Grundy¶
Đồ thị của trò chơi¶
Nếu xem mỗi trạng thái trong tập trạng thái
Ví dụ: trong trò chơi bốc sỏi ở phần đầu, giả sử ta chỉ có một đống sỏi
viên, thì đồ thị của trò chơi sẽ như hình dưới, trạng thái kết thúc có bậc ra bằng .
Cũng cần chú ý rằng các trò chơi được xem xét trong phần định lý Sprague-Grundy có một tính chất quan trọng, đó là chúng sẽ kết thúc trong hữu hạn bước. Khi đó, hiển nhiên đồ thị trò chơi phải không tồn tại chu trình, vì nếu tồn tại chu trình, sẽ tồn tại trường hợp người chơi cố tình đi theo chu trình đó và sẽ không bao giờ đến được đỉnh kết thúc, nghĩa là khi đó trò chơi sẽ lặp vĩnh viễn. Loại đồ thị có hướng không có chu trình như trên còn có thể gọi tắt là DAG (Directed Acyclic Graph).
Trò chơi tổng¶
Để có thể kết hợp nhiều trò chơi đơn lẻ với nhau, ta đặt ra khái niệm trò chơi tổng.
Trò chơi tổng: Cho trò chơi
Ví dụ: trò chơi Nim có
đống sỏi có thể xem như trò chơi tổng của ba trò chơi , và , với là trò chơi chỉ bốc ở đống sỏi thứ , là trò chơi chỉ bốc ở đống sỏi thứ , là trò chơi chỉ bốc ở đống sỏi thứ .
Mở rộng trò chơi Nim¶
Định lý Bouton đã cho chúng ta một cách giải rất đẹp cho trò chơi Nim chuẩn, nhưng trong thực tế, hầu hết các bài lý thuyết trò chơi trong lập trình thi đấu sẽ không là trò chơi Nim chuẩn mà sẽ được thay đổi luật chơi ở một số điểm. Lúc ấy, định lý Bouton sẽ không thể giải quyết các dạng bài này. Trong phần này, ta sẽ trình bày hai định lý quan trọng để giải quyết các trò chơi cân bằng kết thúc trong hữu hạn bước. Tuy nhiên ta sẽ chỉ trình bày về động lực hình thành và định nghĩa của hai định lý. Để hiểu rõ hơn, bạn đọc hãy đọc phần Phụ lục để hiểu được chứng minh.
Để giải quyết, ta quay lại chiến lược ban đầu của mình - phân tách trạng thái trò chơi thành nhiều trò chơi con có cùng cấu trúc, tìm một số tính chất đặc biệt của mỗi trò chơi con, sau đó kết hợp chúng lại với nhau để có câu trả lời cho trò chơi gốc.
Hãy lấy ví dụ với một biến thể của trò chơi Nim chuẩn, trong đó chúng ta có
Đầu tiên, ta cũng xét trò chơi ở dạng đơn giản nhất: chỉ có một đống sỏi duy nhất với
Hãy nhìn trò chơi dưới góc độ đồ thị. Đồ thị này có
Rõ ràng đỉnh
Tuy nhiên, cách làm ở trên chỉ cho ta trạng thái định tính của từng trạng thái, để phục vụ cho việc ghép các trò chơi lại, ta cần một hàm định lượng. Hàm mà chúng ta sẽ dùng có tên là hàm Sprague-Grundy, với một trạng thái
Trong định nghĩa trên có dùng hàm mex (minimum excludant), hàm này sẽ nhận vào một tập hợp và trả về số nguyên không âm
Câu hỏi đặt ra là: tại sao lại là hàm Sprague-Grundy? Hàm này có ý nghĩa gì trong việc giải các trò chơi tổ hợp cân bằng?
Để thấy rõ hơn ý nghĩa của hàm Sprague-Grundy, ta có thể ví dụ biến thể của trò chơi Nim ở trên.
Quan sát ví dụ ở trên, ta có nhận xét rằng các trạng thái
Định lý 1: Trong một trò chơi tổ hợp cân bằng, trạng thái
Vậy với hàm Sprague-Grundy, ta đã giải quyết được trò chơi đơn giản nhất, vấn đề tiếp theo là giải trò chơi tổng. Ta cũng sẽ tìm một phép toán
Định lý 2: với trò chơi tổng
Ví dụ: với trò chơi Nim biến thể với luật bốc các số chính phương như trên, nhưng bây giờ ta có
đống sỏi lần lượt có viên sỏi. Khi đó ta có thể coi mỗi đống sỏi là một trò chơi thành phần, vì vậy giá trị Sprague-Grundy của trò chơi tổng là , vậy đây là trạng thái mà người chơi đi đầu tiên sẽ giành chiến thắng.
Hai định lý 1 và 2 trong phần này có ý nghĩa rất quan trọng, nó cho ta cách giải bất cứ trò chơi tổ hợp cân bằng nào, miễn là trò chơi đó luôn kết thúc trong hữu hạn bước, hay nói cách khác đồ thị của trò chơi là một DAG. Định lý 2 giúp chúng ta phân rã trò chơi phức tạp ra thành những trò chơi thành phần đơn giản hơn và định lý 1 giúp chúng ta giải quyết những trò chơi thành phần đơn giản đó.
Như vậy, ta thấy rằng thực ra cách giải trò chơi Nim cũng chỉ là một trường hợp riêng của cách giải với giá trị Sprague-Grundy này, trong đó giá trị Nim của trò chơi Nim chỉ có một đống
Ví dụ với trò chơi Nim chỉ có một đống
viên sỏi
và định lý Bouton tương đương định lý 2.
Định lý Sprague-Grundy¶
Định lý Sprague-Grundy phát biểu rằng: mọi trò chơi tổ hợp cân bằng kết thúc trong hữu hạn bước đều tương đương với trò chơi Nim một cột, trong đó trạng thái
Sở dĩ có nhận xét này là vì ở trạng thái
Tuy nhiên, luật chơi trò chơi Nim một cột này không giống với bình thường, vì trong trò chơi bình thường từ trạng thái
Định lý Sprague-Grundy cho ta sự liên tưởng giữa một trò chơi tổ hợp cân bằng với trò chơi Nim một cột - một trò chơi tổ hợp cân bằng đơn giản, cung cấp cho ta một cách nhìn trực quan hơn về trò chơi tổ hợp cân bằng. Tuy bản thân định lý không có nhiều ứng dụng, nhưng các định lý phụ trợ cho việc chứng minh định lý này lại là nền móng quan trọng cho việc giải các trò chơi tổ hợp cân bằng, chính là các định lý 1 và 2 ở phần trước. Để tránh bài viết quá dài dòng, ở đây sẽ không trình bày lại chứng minh của định lý này, bạn đọc có thể tham khảo thêm về cách chứng minh định lý Sprague-Grundy bằng khái niệm trò chơi tương đương được đề cập đến trong wiki.
Ví dụ¶
Ta sẽ ví dụ với trò chơi sau
Cho bàn cờ
Đầu tiên, vì luật chơi cho phép có nhiều quân mã trên cùng ô nên các quân mã có thể di chuyển độc lập với nhau, như vậy ta có thể coi trò chơi có
Ta sẽ giải trò chơi với một quân mã trước. Rõ ràng kết quả của trò chơi khi này chỉ phụ thuộc vào vị trí của quân mã, do đó một trạng thái của trò chơi tương ứng với một cặp số nguyên
// khai báo các thông tin của trò chơi
const int MAXN = 100;
int N;
int g[MAXN + 1][MAXN + 1];
int di[] = {-2, -2, -1, 1};
int dj[] = {1, -1, -2, -2};
// hàm tính mex của một vector U
int mex(vector<int>& U) {
int res = 0;
sort(U.begin(), U.end());
for (int x: U)
if (res == x)
++res;
return res;
}
int calculateGValue(int i, int j) {
if (g[i][j] != -1)
return g[i][j];
int res = 0;
vector<int> U;
// lấy giá trị SP của các trạng thái mà (i,j) có thể đi tới
for (int k = 0; k < 4; ++k) {
int x = i + di[k];
int y = j + dj[k];
if (1 <= x && x <= N && 1 <= y && y <= N)
U.push_back(calculateGValue(x, y));
}
// tính mex và trả về giá trị
res = mex(U);
g[i][j] = res;
return res;
}
Ý tưởng của hàm trên chỉ đơn giản là tại mỗi vị trí di
và dj
kích thước U
. Lưu ý là ở đây để tối ưu thời gian chạy thì ta sẽ dùng kỹ thuật đệ quy có nhớ đã trình bày ở phần Trò chơi tổ hợp cân bằng, do đó trước khi gọi tính giá trị Sprague-Grundy của từng ô trong bảng thì phải khởi tạo tất cả giá trị của mảng
Trước tiên, ta định nghĩa cấu trúc dữ liệu để lưu trữ thông tin của một quân mã, đó một struct
gồm hai thông tin
struct Cell {
int row, col;
};
bool isFirstWin(vector<Cell> Q) {
int res = 0;
for (Cell x: Q)
res ^= g[x.row][x.col];
return (res > 0);
}
Bài tập luyện tập¶
Phụ lục¶
Phần này sẽ chứng minh cơ sở toán học cho hai định lý
Định lý 1: Trong một trò chơi tổ hợp cân bằng, trạng thái
Chứng minh:
Tương tự phần trước, ta sẽ gọi
Thứ nhất, các trạng thái kết thúc
Thứ hai, với một trạng thái
Thứ ba, với mọi trạng thái
Từ ba tính chất vừa chứng minh, ta thấy rõ ràng tập
Định lý 2: với trò chơi tổng
Chứng minh:
Do
Từ đó, nếu gọi
Chi tiết hơn, ta cần chứng minh hai điều sau:
1. Với mọi
Để chứng minh ý 1, với một
Vậy
Để chứng minh ý 2 ta dùng phản chứng, giả sử trạng thái hiện tại là
Điều này là mâu thuẫn với giả thuyết ban đầu là ta chọn trò chơi thành phần
Với hai ý được chứng minh này, ta đã chứng minh được định lý 2.