博弈論完全指南 — Nim 遊戲、Sprague-Grundy 定理與組合賽局分析 | 資料結構與演算法
博弈論(Game Theory) 是演算法競賽與面試中極具策略深度的主題,核心工具包括 Nim 遊戲 的 XOR 必勝判定、Sprague-Grundy 定理 將任意公平遊戲等價為 Nim、以及 Minimax 搜索與 Alpha-Beta 剪枝 的賽局樹分析。從石子遊戲的數學必勝策略,到棋類 AI 的搜索框架,博弈論為我們提供了分析雙人對抗問題的完整理論武器。本文帶你從 P/N-position 基礎概念出發,逐步掌握 Nim、Grundy 數計算、複合賽局分析、Wythoff 遊戲與 Green Hackenbush 等進階技巧,搭配 JavaScript/TypeScript 與 C++ 雙語言完整實作。
前言
想像你和朋友面對桌上的三堆石子,輪流各取任意數量(至少取一顆,只能從同一堆取),取走最後一顆石子的人獲勝。你該怎麼決定第一步?答案藏在一個優雅的數學運算中—— XOR(互斥或)。
這就是 Nim 遊戲,博弈論中最經典的問題。1902 年,數學家 Charles Bouton 證明了一個令人驚嘆的定理:只要將所有石堆數量做 XOR 運算,結果不為零的一方就有必勝策略。這個看似簡單的規則,背後蘊含著深刻的數學結構。
更令人驚訝的是,1930 年代 Sprague 和 Grundy 獨立證明了一個更強大的定理:任何公平遊戲(Impartial Game)都可以等價為一場 Nim 遊戲。這意味著無論遊戲規則多麼複雜,只要它屬於公平遊戲的範疇,我們都能用 Nim 的 XOR 理論來分析勝負。
在程式設計的世界中,博弈論的應用範圍遠超石子遊戲:
- 演算法競賽:Nim 變體、Sprague-Grundy 分析、狀態壓縮博弈 DP
- 人工智慧:棋類 AI(Minimax + Alpha-Beta 剪枝)
- 面試高頻題:LeetCode 上有大量博弈相關題目,考驗對必勝 / 必輸狀態的推理能力
- 機制設計:拍賣系統、廣告競價等賽局均衡分析
學習本文後,你將能夠:
- 理解 P-position 與 N-position 的概念,分析任意博弈的勝負
- 掌握 Nim 遊戲 的 XOR 必勝策略並求出具體的獲勝走法
- 運用 Sprague-Grundy 定理 計算 Grundy 值,將複雜遊戲化簡為 Nim
- 分析 複合賽局(Composite Games) 的勝負判定
- 實作 Wythoff 遊戲 與 Green Hackenbush 等進階博弈
- 使用 Minimax + Alpha-Beta 剪枝 建構賽局 AI
核心概念
組合賽局(Combinatorial Games)分類
博弈論的研究對象可依資訊完整性與玩家選項分類:
博弈(Game)
├── 完美資訊(Perfect Information) ← 本文重點
│ ├── 公平遊戲(Impartial Game):雙方可移動集合相同(如 Nim)
│ │ └── Sprague-Grundy 定理適用
│ └── 偏移遊戲(Partisan Game):雙方可移動集合不同(如西洋棋)
│ └── Minimax / Alpha-Beta 適用
└── 不完美資訊(Imperfect Information)
└── 撲克、德州撲克 → CFR 演算法(超出本文範圍)
本文聚焦於 完美資訊遊戲,特別是 公平遊戲(Impartial Game)——兩位玩家面對相同的可移動選項,唯一的差別是輪到誰行動。
N-position 與 P-position
所有組合賽局的狀態都可以分為兩類:
| 術語 | 全稱 | 含義 |
|---|---|---|
| P-position | Previous player wins | 前一位行動的玩家獲勝,即 當前玩家必輸 |
| N-position | Next player wins | 當前玩家必勝 |
判定規則非常直覺:
- 終局狀態(無法移動)為 P-position(當前玩家無法行動,輸了)
- 若某狀態能移動到 至少一個 P-position,則它是 N-position(把對手推入必輸局面)
- 若某狀態只能移動到 N-position,則它是 P-position(無論怎麼走,對手都必勝)
簡單範例:取石子遊戲(每次可取 1 或 2 顆)
石子數: 0 1 2 3 4 5 6 7 8 9
狀態: P N N P N N P N N P
↑ ↑ ↑
無法動 只能到 N 只能到 N
規律:每 3 個一循環 → n % 3 == 0 時為 P-position(先手必輸)
Nim 遊戲與 XOR 策略
Nim 遊戲 是最經典的公平遊戲:有若干堆石子,兩人輪流從任一堆取任意正整數顆石子,取走最後一顆的人獲勝。
Bouton 定理(1902):將所有石堆數量做 XOR 運算,XOR = 0 為 P-position(先手必輸),XOR ≠ 0 為 N-position(先手必勝)。
Nim Game:三堆石子 [3, 5, 7]
堆 A: ● ● ● (3 = 011)
堆 B: ● ● ● ● ● (5 = 101)
堆 C: ● ● ● ● ● ● ● (7 = 111)
XOR 計算:
011
101
111
------
001 ← XOR = 1 ≠ 0 → N-position(先手必勝)
必勝策略:找一步移動使 XOR 變為 0
將堆 B 從 5 改為 4:XOR = 011 ^ 100 ^ 111 = 000 ✓
將堆 C 從 7 改為 6:XOR = 011 ^ 101 ^ 110 = 000 ✓
為什麼 XOR 有效? 直覺上,XOR = 0 的狀態具有「平衡」的性質。當 XOR = 0 時,任何移動都會破壞這個平衡(使 XOR ≠ 0);而從 XOR ≠ 0 的狀態,先手總能找到一步移動恢復平衡(使 XOR = 0)。這個「保持平衡」的能力一路推進到終局(所有堆為空,XOR = 0),確保維持平衡的一方是最後行動者。
Sprague-Grundy 定理
Sprague-Grundy 定理是組合賽局理論的基石,它將 Nim 的 XOR 分析推廣到 所有公平遊戲。
核心概念——Grundy 值(Nimber)與 mex 函數:
| 術語 | 定義 |
|---|---|
| mex(Minimum Excludant) | 一個非負整數集合中 不包含的最小非負整數,例如 mex({0, 1, 3}) = 2 |
| Grundy 值 G(s) | 狀態 s 的 Grundy 值 = mex({ G(s’) : s’ 是 s 的所有後繼狀態 }) |
定理核心:
- Grundy 值 = 0 的狀態是 P-position(先手必輸)
- Grundy 值 ≠ 0 的狀態是 N-position(先手必勝)
- 複合遊戲(多個獨立子遊戲同時進行)的 Grundy 值 = 各子遊戲 Grundy 值的 XOR
Subtraction Game(每次可取 1 或 2)的 Grundy 值計算:
G(0) = 0 (終局,無法移動)
G(1) = mex({G(0)}) = mex({0}) = 1
G(2) = mex({G(1), G(0)}) = mex({1, 0}) = 2
G(3) = mex({G(2), G(1)}) = mex({2, 1}) = 0 ← P-position
G(4) = mex({G(3), G(2)}) = mex({0, 2}) = 1
G(5) = mex({G(4), G(3)}) = mex({1, 0}) = 2
週期:[0, 1, 2, 0, 1, 2, ...] 週期 3
複合賽局的威力:假設同時進行兩場獨立的 Subtraction Game,堆 A 有 4 顆石子(G = 1),堆 B 有 5 顆石子(G = 2)。整體 Grundy 值 = 1 XOR 2 = 3 ≠ 0,先手必勝。
JavaScript / TypeScript 實作
Nim 遊戲判定與策略
// ─── Nim 遊戲:勝負判定 ─────────────────────────────────
// 單堆 Nim(每次取 1~3 顆,LeetCode 292)
function canWinNim(n: number): boolean {
// 定理:n % 4 === 0 時先手必輸
// 1~3 必勝(直接取完);4 必輸(取 k 顆,對手取 4-k 顆)
return n % 4 !== 0;
}
// 一般多堆 Nim:XOR 所有堆
function canWinNimGeneral(piles: number[]): boolean {
let xor = 0;
for (const pile of piles) {
xor ^= pile;
}
return xor !== 0; // XOR ≠ 0 → 先手必勝
}
// 測試
console.log(canWinNim(4)); // 輸出:false(4 % 4 = 0,先手必輸)
console.log(canWinNim(7)); // 輸出:true
console.log(canWinNimGeneral([3, 5, 7])); // 輸出:true(XOR = 1 ≠ 0)
console.log(canWinNimGeneral([1, 2, 3])); // 輸出:false(XOR = 0)
// ─── Nim 必勝策略:找出使 XOR 歸零的移動 ────────────────
function nimStrategy(piles: number[]): { pileIndex: number; newSize: number } | null {
const xor = piles.reduce((acc, p) => acc ^ p, 0);
if (xor === 0) return null; // 當前為 P-position,無必勝策略
for (let i = 0; i < piles.length; i++) {
const target = piles[i] ^ xor; // 使整體 XOR 歸零所需的值
if (target < piles[i]) { // 只能減少,不能增加
return { pileIndex: i, newSize: target };
}
}
return null; // 理論上不會到這裡(XOR ≠ 0 時一定存在解)
}
// 測試
const strategy = nimStrategy([3, 5, 7]);
console.log(strategy);
// 輸出:{ pileIndex: 1, newSize: 4 }(將堆 B 從 5 減到 4)
// 驗證:[3, 4, 7] → XOR = 011 ^ 100 ^ 111 = 000 ✓
console.log(canWinNimGeneral([3, 4, 7])); // 輸出:false(對手面臨 P-position)
Grundy 數計算(Sprague-Grundy)
// ─── Sprague-Grundy:計算 Grundy 值 ────────────────────
// mex 函數:集合中不包含的最小非負整數
function mex(set: Set<number>): number {
let val = 0;
while (set.has(val)) val++;
return val;
}
// Subtraction Game:每次可取 subtractSet 中的任意數量
function computeGrundy(maxN: number, subtractSet: number[]): number[] {
const G = new Array(maxN + 1).fill(0);
// G[0] = 0(終局狀態,無法移動,P-position)
for (let n = 1; n <= maxN; n++) {
const reachable = new Set<number>();
for (const s of subtractSet) {
if (n - s >= 0) {
reachable.add(G[n - s]); // 收集可達狀態的 Grundy 值
}
}
G[n] = mex(reachable);
}
return G;
}
// 測試:每次可取 1 或 2 顆
const grundy = computeGrundy(12, [1, 2]);
console.log(grundy);
// 輸出:[0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0]
// 週期 3:n % 3 === 0 時為 P-position
// 測試:每次可取 1、3 或 4 顆
const grundy2 = computeGrundy(15, [1, 3, 4]);
console.log(grundy2);
// 輸出:[0, 1, 0, 1, 2, 3, 2, 0, 1, 0, 1, 2, 3, 2, 0, 1]
// 週期 7(從 index 1 開始)
複合賽局分析
// ─── 複合賽局:多個獨立遊戲的 XOR 合併 ─────────────────
function compositeGameResult(grundyValues: number[]): {
canWin: boolean;
totalGrundy: number;
} {
const totalGrundy = grundyValues.reduce((acc, g) => acc ^ g, 0);
return {
canWin: totalGrundy !== 0,
totalGrundy,
};
}
// 範例:三場獨立的 Subtraction Game(每次取 1~2),堆大小分別為 4, 5, 7
const G = computeGrundy(10, [1, 2]);
const games = [G[4], G[5], G[7]]; // [1, 2, 1]
const result = compositeGameResult(games);
console.log(result);
// 輸出:{ canWin: true, totalGrundy: 2 }
// 解釋:1 XOR 2 XOR 1 = 2 ≠ 0 → 先手必勝
Wythoff 遊戲
Wythoff 遊戲 是 Nim 的一個經典變體:有兩堆石子,每次可以從一堆取任意正整數顆,或從兩堆同時取 相同數量 的石子。取走最後一顆的人獲勝。
// ─── Wythoff 遊戲 ──────────────────────────────────────
// Wythoff 定理:(a, b) 為 P-position 若且唯若
// a = floor(k * φ), b = floor(k * φ²)(或反過來)
// 其中 φ = (1 + √5) / 2(黃金比例)
function isWythoffPPosition(a: number, b: number): boolean {
// 確保 a <= b
if (a > b) [a, b] = [b, a];
const phi = (1 + Math.sqrt(5)) / 2;
const k = b - a; // 差值
const expectedA = Math.floor(k * phi);
return a === expectedA;
}
// 必勝判定
function canWinWythoff(a: number, b: number): boolean {
return !isWythoffPPosition(a, b);
}
// 測試:前幾組 P-position(冷局面)
// (0,0), (1,2), (3,5), (4,7), (6,10), (8,13), ...
console.log(isWythoffPPosition(0, 0)); // 輸出:true(P-position)
console.log(isWythoffPPosition(1, 2)); // 輸出:true
console.log(isWythoffPPosition(3, 5)); // 輸出:true
console.log(isWythoffPPosition(4, 7)); // 輸出:true
console.log(isWythoffPPosition(2, 3)); // 輸出:false(N-position)
console.log(canWinWythoff(2, 3)); // 輸出:true(先手必勝)
Green Hackenbush
Green Hackenbush 是組合賽局理論中的經典圖論博弈。遊戲在一張圖上進行,圖的某些節點固定在「地面」上。玩家輪流移除一條邊,若移除後有節點與地面失去連接,這些節點也會被移除。無法行動的玩家落敗。
// ─── Green Hackenbush(簡化版:樹上的遊戲)───────────────
// 在樹上的 Green Hackenbush 中,每條從地面出發的鏈
// 的 Grundy 值等於其長度(邊數)
// 對於一般樹結構,使用 Colon Principle:
// 將每個節點的所有子樹的 Grundy 值 XOR 起來,
// 替換為一條等長的鏈
function hackenbushTreeGrundy(
adj: number[][], // 鄰接表
root: number, // 地面連接的根節點
): number {
const visited = new Set<number>();
function dfs(node: number): number {
visited.add(node);
let grundy = 0;
for (const neighbor of adj[node]) {
if (!visited.has(neighbor)) {
// 子邊的 Grundy 值 = 子樹 Grundy 值 + 1(這條邊本身)
grundy ^= (dfs(neighbor) + 1);
}
}
return grundy;
}
return dfs(root);
}
// 測試:一棵簡單的樹
// 0(地面)
// / \
// 1 2
// /
// 3
const adj = [[1, 2], [0, 3], [0], [1]];
const g = hackenbushTreeGrundy(adj, 0);
console.log(g);
// 輸出:1
// 解釋:
// 節點 3 → G=0,邊 1-3 → G=0+1=1
// 節點 1 → 子樹 G=1,邊 0-1 → G=1+1=2
// 節點 2 → G=0,邊 0-2 → G=0+1=1
// 根節點 0 → G = 2 XOR 1 = 3... 等等,
// 但 root 是地面,所以 Grundy = 2 XOR 1 = 3?
// 不,Colon Principle 從葉子往上 XOR:
// dfs(3)=0, dfs(1)=0^(0+1)=1, dfs(2)=0, dfs(0)=(1+1)^(0+1)=2^1=3
// G = 3 ≠ 0 → 先手必勝
console.log(g !== 0 ? "先手必勝" : "先手必輸");
// 輸出:先手必勝
Minimax 與 Alpha-Beta 剪枝
對於 偏移遊戲(Partisan Game)——如井字棋(Tic-Tac-Toe),雙方可移動選項不同,需要使用 Minimax 演算法 搜索賽局樹。
// ─── Minimax + Alpha-Beta 剪枝(Tic-Tac-Toe AI)────────
type Board = number[][]; // 0 = 空, 1 = X(MAX), -1 = O(MIN)
function checkWinner(board: Board): number {
const lines = [
[[0,0],[0,1],[0,2]], [[1,0],[1,1],[1,2]], [[2,0],[2,1],[2,2]], // 橫
[[0,0],[1,0],[2,0]], [[0,1],[1,1],[2,1]], [[0,2],[1,2],[2,2]], // 縱
[[0,0],[1,1],[2,2]], [[0,2],[1,1],[2,0]], // 斜
];
for (const line of lines) {
const sum = line.reduce((s, [r, c]) => s + board[r][c], 0);
if (sum === 3) return 1; // X 勝
if (sum === -3) return -1; // O 勝
}
return 0; // 平局或未結束
}
function isFull(board: Board): boolean {
return board.every(row => row.every(cell => cell !== 0));
}
// Alpha-Beta 剪枝的 Minimax
function minimax(
board: Board,
depth: number,
isMaximizing: boolean,
alpha: number,
beta: number
): number {
const winner = checkWinner(board);
if (winner !== 0) return winner * (10 - depth); // 越快贏分數越高
if (isFull(board)) return 0; // 平局
if (isMaximizing) {
let best = -Infinity;
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (board[r][c] === 0) {
board[r][c] = 1; // X 落子
best = Math.max(best, minimax(board, depth + 1, false, alpha, beta));
board[r][c] = 0; // 還原
alpha = Math.max(alpha, best);
if (beta <= alpha) return best; // Beta 剪枝
}
}
}
return best;
} else {
let best = Infinity;
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (board[r][c] === 0) {
board[r][c] = -1; // O 落子
best = Math.min(best, minimax(board, depth + 1, true, alpha, beta));
board[r][c] = 0; // 還原
beta = Math.min(beta, best);
if (beta <= alpha) return best; // Alpha 剪枝
}
}
}
return best;
}
}
// 找出 X(MAX 玩家)的最佳落子位置
function bestMove(board: Board): [number, number] {
let bestScore = -Infinity;
let move: [number, number] = [-1, -1];
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (board[r][c] === 0) {
board[r][c] = 1;
const score = minimax(board, 0, false, -Infinity, Infinity);
board[r][c] = 0;
if (score > bestScore) {
bestScore = score;
move = [r, c];
}
}
}
}
return move;
}
// 測試
const board: Board = [
[ 0, 0, -1],
[ 0, 1, 0],
[ 0, 0, 0],
];
console.log("最佳落子:", bestMove(board));
// 輸出:最佳落子: [0, 0](或其他最優位置)
C++ 對照實作
Nim 遊戲
#include <vector>
#include <iostream>
using namespace std;
// 單堆 Nim(每次取 1~3)
bool canWinNim(int n) {
return n % 4 != 0;
}
// 多堆 Nim
bool canWinNimGeneral(vector<int>& piles) {
int xorSum = 0;
for (int p : piles) xorSum ^= p;
return xorSum != 0;
}
// 必勝策略
pair<int, int> nimStrategy(vector<int>& piles) {
// 回傳 {堆 index, 移除後的目標數量},{-1, -1} 表示必輸
int xorSum = 0;
for (int p : piles) xorSum ^= p;
if (xorSum == 0) return {-1, -1};
for (int i = 0; i < (int)piles.size(); i++) {
int target = piles[i] ^ xorSum;
if (target < piles[i]) {
return {i, target};
}
}
return {-1, -1};
}
int main() {
cout << canWinNim(4) << endl; // 輸出:0(false)
cout << canWinNim(7) << endl; // 輸出:1(true)
vector<int> piles = {3, 5, 7};
cout << canWinNimGeneral(piles) << endl; // 輸出:1
auto [idx, target] = nimStrategy(piles);
cout << "堆 " << idx << " 改為 " << target << endl;
// 輸出:堆 1 改為 4
return 0;
}
Sprague-Grundy 函數
#include <vector>
#include <set>
#include <iostream>
using namespace std;
vector<int> computeGrundy(int maxN, vector<int>& subtractSet) {
vector<int> G(maxN + 1, 0);
for (int n = 1; n <= maxN; n++) {
set<int> reachable;
for (int s : subtractSet) {
if (n - s >= 0) reachable.insert(G[n - s]);
}
// 計算 mex
int m = 0;
while (reachable.count(m)) m++;
G[n] = m;
}
return G;
}
bool compositeGame(vector<int>& grundyValues) {
int xorSum = 0;
for (int g : grundyValues) xorSum ^= g;
return xorSum != 0;
}
int main() {
vector<int> subtractSet = {1, 2};
auto G = computeGrundy(9, subtractSet);
for (int g : G) cout << g << " ";
// 輸出:0 1 2 0 1 2 0 1 2 0
cout << endl;
vector<int> games = {G[4], G[5]}; // {1, 2}
cout << compositeGame(games) << endl; // 輸出:1(先手必勝)
return 0;
}
Wythoff 遊戲
#include <cmath>
#include <iostream>
#include <algorithm>
using namespace std;
bool isWythoffPPosition(int a, int b) {
if (a > b) swap(a, b);
double phi = (1.0 + sqrt(5.0)) / 2.0;
int k = b - a;
int expectedA = (int)floor(k * phi);
return a == expectedA;
}
bool canWinWythoff(int a, int b) {
return !isWythoffPPosition(a, b);
}
int main() {
// P-position 測試
cout << isWythoffPPosition(0, 0) << endl; // 輸出:1(true)
cout << isWythoffPPosition(1, 2) << endl; // 輸出:1
cout << isWythoffPPosition(3, 5) << endl; // 輸出:1
cout << isWythoffPPosition(4, 7) << endl; // 輸出:1
// N-position 測試
cout << canWinWythoff(2, 3) << endl; // 輸出:1(先手必勝)
return 0;
}
Minimax + Alpha-Beta 剪枝
#include <vector>
#include <algorithm>
#include <climits>
#include <iostream>
using namespace std;
int checkWinner(vector<vector<int>>& board) {
int lines[8][3][2] = {
{{0,0},{0,1},{0,2}}, {{1,0},{1,1},{1,2}}, {{2,0},{2,1},{2,2}},
{{0,0},{1,0},{2,0}}, {{0,1},{1,1},{2,1}}, {{0,2},{1,2},{2,2}},
{{0,0},{1,1},{2,2}}, {{0,2},{1,1},{2,0}}
};
for (auto& line : lines) {
int s = board[line[0][0]][line[0][1]]
+ board[line[1][0]][line[1][1]]
+ board[line[2][0]][line[2][1]];
if (s == 3) return 1;
if (s == -3) return -1;
}
return 0;
}
bool isFull(vector<vector<int>>& board) {
for (auto& row : board)
for (int cell : row)
if (cell == 0) return false;
return true;
}
int minimax(vector<vector<int>>& board, int depth,
bool isMax, int alpha, int beta) {
int w = checkWinner(board);
if (w != 0) return w * (10 - depth);
if (isFull(board)) return 0;
if (isMax) {
int best = INT_MIN;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
if (board[r][c] == 0) {
board[r][c] = 1;
best = max(best, minimax(board, depth + 1, false, alpha, beta));
board[r][c] = 0;
alpha = max(alpha, best);
if (beta <= alpha) return best; // Beta 剪枝
}
return best;
} else {
int best = INT_MAX;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
if (board[r][c] == 0) {
board[r][c] = -1;
best = min(best, minimax(board, depth + 1, true, alpha, beta));
board[r][c] = 0;
beta = min(beta, best);
if (beta <= alpha) return best; // Alpha 剪枝
}
return best;
}
}
pair<int, int> bestMove(vector<vector<int>>& board) {
int bestScore = INT_MIN;
pair<int, int> move = {-1, -1};
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
if (board[r][c] == 0) {
board[r][c] = 1;
int score = minimax(board, 0, false, INT_MIN, INT_MAX);
board[r][c] = 0;
if (score > bestScore) {
bestScore = score;
move = {r, c};
}
}
return move;
}
複雜度分析
| 演算法 | 時間複雜度 | 空間複雜度 | 適用場景 |
|---|---|---|---|
| Nim XOR 判定 | O(n) | O(1) | 多堆 Nim 及等價遊戲 |
| Sprague-Grundy | O(n × |S|) | O(n) | 任意公平遊戲(S 為移動集合) |
| Wythoff 判定 | O(1) | O(1) | 兩堆含對稱取法的 Nim 變體 |
| Minimax | O(b^d) | O(d) | 完美資訊博弈(b = 分支因子, d = 深度) |
| Alpha-Beta 剪枝 | O(b^(d/2)) 最佳 | O(d) | Minimax 的實際優化 |
| Bitmask DP | O(n × 2^n) | O(2^n) | 小型遊戲狀態(n ≤ 20) |
| 區間 DP | O(n^2) | O(n^2) | 序列博弈(兩端取值) |
Alpha-Beta 剪枝效益分析:
完整 Minimax 搜索:O(b^d)
Alpha-Beta 最佳情況:O(b^(d/2)) ← 等效搜索深度翻倍
Alpha-Beta 平均情況:O(b^(3d/4))
以西洋棋為例(b ≈ 35, d = 6):
Minimax: 35^6 ≈ 18 億節點
Alpha-Beta 最佳: 35^3 ≈ 42,875 節點 ← 超過 40,000 倍加速
變體與延伸
Misere Nim(反向 Nim)
在標準 Nim 中,取走最後一顆的人獲勝。Misere Nim 則相反:取走最後一顆的人 落敗。
令人意外的是,Misere Nim 的分析與標準 Nim 幾乎相同,只有一個微妙的邊界差異:
// ─── Misere Nim ────────────────────────────────────────
// 規則:取走最後一顆石子的人「輸」
function canWinMisereNim(piles: number[]): boolean {
const xor = piles.reduce((acc, p) => acc ^ p, 0);
const allOnes = piles.every(p => p <= 1); // 所有堆大小都 ≤ 1
if (allOnes) {
// 特殊情況:所有堆都是 0 或 1
// 奇數個 1 → 被迫取最後一顆 → 必輸
// 偶數個 1 → 對手取最後一顆 → 必勝
const count = piles.filter(p => p === 1).length;
return count % 2 === 0;
}
// 一般情況:與標準 Nim 相同!XOR ≠ 0 先手必勝
return xor !== 0;
}
// 測試
console.log(canWinMisereNim([1, 1, 1])); // 輸出:false(3 個 1,被迫取最後一顆)
console.log(canWinMisereNim([1, 1])); // 輸出:true(2 個 1,對手取最後一顆)
console.log(canWinMisereNim([2, 3])); // 輸出:true(XOR = 1 ≠ 0)
console.log(canWinMisereNim([1, 2, 3])); // 輸出:false(XOR = 0)
為什麼只有邊界情況不同? 標準 Nim 的必勝策略是「保持 XOR = 0,最終把所有堆清空」。在 Misere Nim 中,必勝策略是「保持 XOR = 0,但在最後關頭留下奇數個大小為 1 的堆給對手」。只有當所有堆都已經 ≤ 1 時,兩種規則的判定才會不同。
圖上的博弈(Graph Games)
許多博弈問題可以建模為有向圖上的棋子移動。給定一個有向無環圖(DAG),棋子從某個起始節點出發,兩人輪流沿有向邊移動,無法移動者落敗。
// ─── 圖上的博弈:DAG 上的 Grundy 值 ────────────────────
function graphGameGrundy(
adj: number[][], // 鄰接表(有向圖)
n: number // 節點數(0 ~ n-1)
): number[] {
// 拓撲排序 + 反向計算 Grundy 值
const grundy = new Array(n).fill(-1);
// 找出所有出度為 0 的節點(終局狀態)
const outDegree = new Array(n).fill(0);
for (let u = 0; u < n; u++) {
outDegree[u] = adj[u].length;
}
// DFS + 記憶化計算 Grundy 值
function dfs(u: number): number {
if (grundy[u] !== -1) return grundy[u];
if (adj[u].length === 0) {
return grundy[u] = 0; // 終局,G = 0
}
const reachable = new Set<number>();
for (const v of adj[u]) {
reachable.add(dfs(v));
}
let m = 0;
while (reachable.has(m)) m++;
return grundy[u] = m;
}
for (let u = 0; u < n; u++) {
if (grundy[u] === -1) dfs(u);
}
return grundy;
}
// 測試:一個簡單的 DAG
// 0 → 1 → 3
// 0 → 2 → 3
// 節點 3 是終局(出度 0)
const dagAdj = [[1, 2], [3], [3], []];
const dagGrundy = graphGameGrundy(dagAdj, 4);
console.log(dagGrundy);
// 輸出:[2, 1, 1, 0]
// 節點 0 的 Grundy 值 = 2 ≠ 0 → 從節點 0 出發先手必勝
面試考點
博弈論在面試中的考察重點不在於記憶定理,而在於 推理能力 與 狀態分析:
1. 找規律型(O(1) 數學解)
面試最常見的類型。給定簡單規則,推導出先手必勝 / 必輸的數學條件。
- 解題套路:手動列出 n = 0, 1, 2, …, 10 的勝負狀態,觀察週期
- 經典例題:LeetCode 292(n % 4),LeetCode 1025(n 為偶數時必勝)
2. 記憶化搜索 / DP 型
當狀態空間較小時,用 Bitmask DP 或記憶化搜索暴力枚舉所有狀態。
- 關鍵技巧:定義
dp(state)= 當前玩家在 state 下是否必勝 - 轉移邏輯:若存在任一後繼狀態使對手必輸,則當前必勝
- 經典例題:LeetCode 464(Bitmask DP)
3. 區間 DP 型
雙人從序列兩端輪流取值,用區間 DP 分析最優策略。
- 狀態設計:
dp[i][j]= 先手在[i, j]段的淨收益(差值定義) - 轉移方程:
dp[i][j] = max(a[i] - dp[i+1][j], a[j] - dp[i][j-1]) - 經典例題:LeetCode 877, 486
4. 常見面試追問
| 問題 | 回答要點 |
|---|---|
| 「如何判斷先手必勝?」 | P/N-position 分析,或 Grundy 值是否非零 |
| 「能否從暴力解優化?」 | 找數學規律(週期性)、或 DP 記憶化 |
| 「Nim 為什麼用 XOR?」 | Bouton 定理:XOR = 0 的平衡性質 |
| 「時間複雜度瓶頸?」 | 狀態數量 × 每個狀態的轉移數量 |
LeetCode 練習
292. Nim Game
難度: Easy | 方法: 數學規律
// 單堆取 1~3,先取完者勝
function canWinNim(n: number): boolean {
return n % 4 !== 0;
}
// 1~3:直接取完 → 必勝
// 4:取 k,對手取 4-k → 必輸
// 歸納:n % 4 === 0 → 必輸,否則必勝
複雜度: 時間 O(1),空間 O(1)
464. Can I Win
難度: Medium | 方法: Bitmask DP + 記憶化
function canIWin(maxChoosableInt: number, desiredTotal: number): boolean {
// 邊界:所有數字總和不夠
const totalSum = (maxChoosableInt * (maxChoosableInt + 1)) / 2;
if (totalSum < desiredTotal) return false;
if (desiredTotal <= 0) return true;
const memo = new Map<number, boolean>();
function dp(usedMask: number, currentTotal: number): boolean {
if (memo.has(usedMask)) return memo.get(usedMask)!;
for (let i = 1; i <= maxChoosableInt; i++) {
const bit = 1 << (i - 1);
if (usedMask & bit) continue; // 已使用
// 取 i 後直接達標,或對手處於必輸狀態
if (currentTotal + i >= desiredTotal ||
!dp(usedMask | bit, currentTotal + i)) {
memo.set(usedMask, true);
return true;
}
}
memo.set(usedMask, false);
return false;
}
return dp(0, 0);
}
// 測試
console.log(canIWin(10, 11)); // 輸出:false
console.log(canIWin(10, 0)); // 輸出:true
複雜度: 時間 O(n * 2^n),空間 O(2^n),其中 n = maxChoosableInt(≤ 20)
877. Stone Game
難度: Medium | 方法: 區間 DP / 數學
// 解法一:數學觀察 — O(1)
// 堆數為偶數,先手可以選擇全取奇數位或全取偶數位
// 兩組和不同,先手永遠能選較大組 → 必勝
function stoneGame(piles: number[]): boolean {
return true;
}
// 解法二:區間 DP — 通用解(適用於無此數學結論的變體)
function stoneGameDP(piles: number[]): boolean {
const n = piles.length;
// dp[i][j]:先手在 piles[i..j] 能比後手多得的石子數
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
for (let i = 0; i < n; i++) dp[i][i] = piles[i];
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
dp[i][j] = Math.max(
piles[i] - dp[i + 1][j], // 取左端
piles[j] - dp[i][j - 1] // 取右端
);
}
}
return dp[0][n - 1] > 0;
}
// 測試
console.log(stoneGameDP([5, 3, 4, 5])); // 輸出:true
console.log(stoneGameDP([3, 7, 2, 3])); // 輸出:true
複雜度: 數學解 O(1);區間 DP O(n^2) 時間,O(n^2) 空間
486. Predict the Winner
難度: Medium | 方法: 區間 DP
function predictTheWinner(nums: number[]): boolean {
const n = nums.length;
const dp: number[][] = Array.from({ length: n }, (_, i) => {
const row = new Array(n).fill(0);
row[i] = nums[i]; // 初始化對角線
return row;
});
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
dp[i][j] = Math.max(
nums[i] - dp[i + 1][j],
nums[j] - dp[i][j - 1]
);
}
}
return dp[0][n - 1] >= 0; // >= 0 包含平局(題目定義先手平局算贏)
}
// 測試
console.log(predictTheWinner([1, 5, 2])); // 輸出:false
console.log(predictTheWinner([1, 5, 233, 7])); // 輸出:true
複雜度: 時間 O(n^2),空間 O(n^2)
1025. Divisor Game
難度: Easy | 方法: 數學規律
function divisorGame(n: number): boolean {
// 觀察:n 為偶數 → 先手必勝,n 為奇數 → 先手必輸
// 原因:偶數時先手取 1(1 必整除任何數),留奇數給對手
// 奇數的因子都是奇數,奇數 - 奇數 = 偶數,又留偶數給先手
// 最終 n = 1 時無法行動 → 此時輪到的人必輸
return n % 2 === 0;
}
// 驗證型 DP 解法
function divisorGameDP(n: number): boolean {
const dp = new Array(n + 1).fill(false);
// dp[0] = false, dp[1] = false(無法行動)
for (let i = 2; i <= n; i++) {
for (let x = 1; x < i; x++) {
if (i % x === 0 && !dp[i - x]) {
dp[i] = true; // 存在一步使對手必輸
break;
}
}
}
return dp[n];
}
// 測試
console.log(divisorGame(2)); // 輸出:true(取 1,留 1 給對手)
console.log(divisorGame(3)); // 輸出:false(只能取 1,留 2 給對手)
console.log(divisorGame(4)); // 輸出:true
複雜度: 數學解 O(1);DP 解 O(n * sqrt(n))
其他推薦題目
| 題號 | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 292 | Nim Game | Easy | n % 4 數學規律 |
| 1025 | Divisor Game | Easy | 奇偶性分析 |
| 464 | Can I Win | Medium | Bitmask DP + 記憶化 |
| 486 | Predict the Winner | Medium | 區間 DP(差值定義) |
| 877 | Stone Game | Medium | 區間 DP / 數學觀察 |
總結
本文系統性地介紹了博弈論在演算法領域的核心知識:
- P/N-position:博弈分析的基礎框架,透過終局狀態反推每個狀態的勝負
- Nim 遊戲:XOR 運算判定勝負,Bouton 定理提供數學上的嚴謹保證
- Sprague-Grundy 定理:任意公平遊戲都等價於 Nim,Grundy 值透過 mex 函數計算
- 複合賽局:多個獨立子遊戲的 Grundy 值做 XOR,化繁為簡
- Wythoff 遊戲:黃金比例在博弈中的優美應用
- Green Hackenbush:圖論與博弈論的交匯,Colon Principle 簡化分析
- Minimax + Alpha-Beta:偏移遊戲的搜索框架,剪枝可帶來指數級加速
- 變體延伸:Misere Nim 的邊界處理、DAG 上的圖博弈
博弈論的核心心法是:一切皆 Nim。只要一個遊戲是公平的(雙方選項相同)、確定的(無隨機因素)、有限的(必然終止),它就可以透過 Sprague-Grundy 定理化約為 Nim 遊戲。掌握了 XOR 與 mex 這兩個工具,你就掌握了分析絕大多數組合賽局的鑰匙。
在下一篇文章中,我們將進入 隨機演算法(Randomized Algorithms) 的世界,探索 Monte Carlo、Las Vegas 演算法、隨機快排、水塘抽樣等用隨機性換取效率的精妙技巧。
上一篇:034 — 計算幾何