博弈論完全指南 — Nim 遊戲、Sprague-Grundy 定理與組合賽局分析 | 資料結構與演算法

2026/07/18
博弈論完全指南 — 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-positionN-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-positionPrevious player wins前一位行動的玩家獲勝,即 當前玩家必輸
N-positionNext player wins當前玩家必勝

判定規則非常直覺:

  1. 終局狀態(無法移動)為 P-position(當前玩家無法行動,輸了)
  2. 若某狀態能移動到 至少一個 P-position,則它是 N-position(把對手推入必輸局面)
  3. 若某狀態只能移動到 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 的所有後繼狀態 })

定理核心

  1. Grundy 值 = 0 的狀態是 P-position(先手必輸)
  2. Grundy 值 ≠ 0 的狀態是 N-position(先手必勝)
  3. 複合遊戲(多個獨立子遊戲同時進行)的 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-GrundyO(n × |S|)O(n)任意公平遊戲(S 為移動集合)
Wythoff 判定O(1)O(1)兩堆含對稱取法的 Nim 變體
MinimaxO(b^d)O(d)完美資訊博弈(b = 分支因子, d = 深度)
Alpha-Beta 剪枝O(b^(d/2)) 最佳O(d)Minimax 的實際優化
Bitmask DPO(n × 2^n)O(2^n)小型遊戲狀態(n ≤ 20)
區間 DPO(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))

其他推薦題目

題號題目難度核心技巧
292Nim GameEasyn % 4 數學規律
1025Divisor GameEasy奇偶性分析
464Can I WinMediumBitmask DP + 記憶化
486Predict the WinnerMedium區間 DP(差值定義)
877Stone GameMedium區間 DP / 數學觀察

總結

本文系統性地介紹了博弈論在演算法領域的核心知識:

  1. P/N-position:博弈分析的基礎框架,透過終局狀態反推每個狀態的勝負
  2. Nim 遊戲:XOR 運算判定勝負,Bouton 定理提供數學上的嚴謹保證
  3. Sprague-Grundy 定理:任意公平遊戲都等價於 Nim,Grundy 值透過 mex 函數計算
  4. 複合賽局:多個獨立子遊戲的 Grundy 值做 XOR,化繁為簡
  5. Wythoff 遊戲:黃金比例在博弈中的優美應用
  6. Green Hackenbush:圖論與博弈論的交匯,Colon Principle 簡化分析
  7. Minimax + Alpha-Beta:偏移遊戲的搜索框架,剪枝可帶來指數級加速
  8. 變體延伸:Misere Nim 的邊界處理、DAG 上的圖博弈

博弈論的核心心法是:一切皆 Nim。只要一個遊戲是公平的(雙方選項相同)、確定的(無隨機因素)、有限的(必然終止),它就可以透過 Sprague-Grundy 定理化約為 Nim 遊戲。掌握了 XOR 與 mex 這兩個工具,你就掌握了分析絕大多數組合賽局的鑰匙。

在下一篇文章中,我們將進入 隨機演算法(Randomized Algorithms) 的世界,探索 Monte Carlo、Las Vegas 演算法、隨機快排、水塘抽樣等用隨機性換取效率的精妙技巧。

上一篇:034 — 計算幾何
BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。