Game Theory - Advanced Games
Key Points
- â˘SpragueâGrundy (SG) theory solves impartial, normal-play, terminating games by assigning each position a nonnegative integer called its Grundy value.
- â˘SG fails when the game can draw (infinite play/cycles) or when the game is partizan (players have different legal moves).
- â˘Draws can be handled algorithmically with retrograde analysis on the state graph, labeling states as Win/Lose/Draw via a BFS-like propagation.
- â˘Staircase Nim reduces to ordinary Nim: the XOR of stones on odd-indexed stairs (1-based) alone determines the outcome.
- â˘Wythoffâs Game P-positions are given by Beatty sequences built from the golden ratio; moving to the closest P-position is optimal.
- â˘Green Hackenbush (all-green edges) is impartial and reduces to Nim; BlueâRed Hackenbush is partizan and requires different theory.
- â˘Use SG values and nim-sum when you have a disjoint sum of impartial subgames on a DAG; do not use SG for partizan or draw-possible games.
- â˘In contests, model the game as a directed graph: if acyclic and impartial use SG; if cyclic use Win/Lose/Draw retrograde; if partizan use retrograde on (position, player).
Prerequisites
- âDirected graphs and BFS/DFS â Game positions and moves are naturally modeled as nodes and directed edges; retrograde analysis relies on traversals.
- âBitwise XOR â Nim-sum combines Grundy numbers across independent subgames and determines outcomes.
- âTopological sorting â Computing SpragueâGrundy numbers on a DAG is simplest in reverse topological order.
- âNumber theory: Beatty sequences and golden ratio â Wythoffâs Game P-positions are characterized by golden-ratio Beatty sequences.
- âRecurrence and memoization â Grundy computation is a recursive mex over options; memoization avoids recomputation.
- âGame outcome classes (P/N/Draw) â Understanding how wins, losses, and draws propagate is key to modeling and solving games.
Detailed Explanation
Tap terms for definitions01Overview
Advanced combinatorial game theory extends classic Nim-style reasoning to richer settings: games can have three outcomes (win, lose, draw), and players may have different legal moves (partizan games). The SpragueâGrundy theorem gives a powerful way to solve impartial, normal-play, terminating games by mapping every position to a nonnegative integer (its Grundy value), letting us combine independent components via XOR (nim-sum). However, the theoremâs assumptions are strict: if play can continue forever (draws from cycles) or if the two playersâ move sets differ (partizan), the SG framework no longer applies directly. In programming contests, many problems can be turned into graph problems: positions are nodes, legal moves are directed edges, and the current player is either implicit (impartial) or explicit (partizan). For impartial DAGs we compute Grundy numbers; for general graphs (possibly cyclic) we compute Win/Lose/Draw via retrograde propagation; for partizan games we do retrograde on doubled states (position, player). Classic examples include Staircase Nim (reduces to Nim via an odd-level invariant), Wythoffâs Game (golden-ratio Beatty P-positions), and Hackenbush (green version reduces to Nim; colored versions are partizan). Mastering when SG appliesâand having alternative tools when it doesnâtâis the key to solving 2000â2400 CF-level game problems.
02Intuition & Analogies
Think of game positions as rooms in a maze. A directed corridor from room A to room B means âyou can move from A to Bâ. If every walk eventually leaves the maze (no cycles), you can assign each room a simple label: how many nim-heaps does this room feel like? That label is the Grundy number. When you play several mazes at once (independent subgames), the effect is like taking multiple Nim heaps and XORing their sizesâthe overall challenge of the maze you face is just the XOR of those labels. But what if the maze has loops? You could keep walking forever without leaving, so there may be no forced win or lossâthis is a draw. In that case, Grundy numbers lose their meaning because they rely on always reaching a dead end (a terminal room). Instead, you label rooms as Win/Lose/Draw using pushâpull logic: if you can step to a Lose for the opponent, youâre Win; if all ways out go to Win for the opponent, youâre Lose; otherwise youâre Draw. Now imagine the maze gives different doors to the two playersâpartizan games. One player might be allowed through certain doors the other canât use. The SG trick relies on both players having the same doors from a room, so it breaks. You can still solve many finite partizan mazes by tracking whose turn it is and using the same Win/Lose/Draw propagation over a doubled state space (room, player). Concrete cases help: in Staircase Nim, only stones on odd steps really matter; in Wythoffâs, the âcoldâ positions line up along two magical golden-ratio rays; in Green Hackenbush, complicated vine shapes secretly behave like plain Nim heaps.
03Formal Definition
04When to Use
Use SpragueâGrundy when the game is impartial, normal-play, and guaranteed to terminateâmost commonly when the state graph is a directed acyclic graph (DAG) or when repeated states are impossible by a decreasing measure. Decompose the game into a disjoint sum of independent components, compute each componentâs Grundy value via mex of its options, and XOR them to decide the winner and find a winning move. Apply this to problems like Green Hackenbush on rooted trees, Staircase Nim, or token-removal games on DAGs. When the game can draw (cycles), switch to Win/Lose/Draw retrograde analysis on the directed state graph: mark terminals as Lose, propagate Wins to predecessors that can move into Lose, mark Lose where all moves go to Win, and leave the rest as Draw. When moves differ by player (partizan), explicitly include the player in the state (position, player) and run the same retrograde process; this works regardless of cycles and immediately gives WL/Draw outcomes. Use Wythoffâs Game formulas when you recognize the three-move pattern (remove from one pile or both equally): P-positions fall on golden-ratio Beatty pairs, letting you jump directly to optimal targets in O(1). Avoid SG in misère play (last move loses) except for the special Nim case, and avoid SG in general partizan settings like BlueâRed Hackenbush where surreal numbers, not XOR, govern sums.
â ď¸Common Mistakes
⢠Applying SG to games with cycles or potential infinite play. SG requires termination; if cycles exist, Grundy values can be undefined or misleading. Use Win/Lose/Draw retrograde instead. ⢠Ignoring impartiality. If the two players have different moves from some positions (partizan), SG and nim-sum are invalid. Model states as (position, player) and compute WL/Draw, or use partizan theory if needed. ⢠Forgetting the disjoint-sum requirement. XOR only combines independent subgames. If a move in one component changes the legal moves in another, they are not independent and XOR will lie. ⢠Misère confusion. Under misère rules, Grundy addition fails in general; only Nim has a simple fix (if all heaps are size 1, parity decides; otherwise play like normal Nim). ⢠Off-by-one indexing in Staircase Nim. The invariant uses 1-based odd indices; switching to 0-based without adjusting breaks the XOR invariant. ⢠Wythoff precision errors. Floating-point computation of Ď can misclassify border cases; use integer/long double carefully or purely integer formulas to avoid rounding mistakes. ⢠Treating Green Hackenbush as partizan. All-green edges yield an impartial game reducible to Nim; color asymmetry (blue/red) makes it partizan. ⢠Assuming DAG when it isnât. Many grid-walk games look acyclic but allow revisits; add a decreasing potential or detect cycles before committing to SG.
Key Formulas
Grundy Recurrence
Explanation: The Grundy value of a position is the smallest nonnegative integer not taken by any optionâs Grundy values. This lets us reduce positions to equivalent Nim heaps.
Nim-sum of Subgames
Explanation: The Grundy of a disjoint sum of impartial games equals the bitwise XOR of their individual Grundy values. If the XOR is 0, the position is losing (P-position).
Wythoff P-positions
Explanation: In Wythoffâs Game, the cold (P) positions are exactly the Beatty pairs formed by the golden ratio and its square. Moving to the nearest such pair is optimal.
Wythoff Difference Property
Explanation: The difference between heaps in a P-position equals the index k. Given a state (a,b) with a b, target and move to ( k , k + k).
Graph Retrograde Complexity
Explanation: Processing a game graph with n states and m directed edges using BFS-style retrograde takes linear time in the size of the graph. Each edge and node is visited a constant number of times.
Staircase Nim Invariant
Explanation: With 1-based indexing, the XOR of stones on odd steps determines the outcome. A zero XOR is losing; a nonzero XOR is winning by adjusting one odd pile.
mex Definition
Explanation: The mex of a set S of nonnegative integers is the smallest nonnegative integer not in S. It powers the Grundy recurrence.
Typical Costs
Explanation: Computing mex over a sorted vector can be O(n)âO(n log n); overall SG on a DAG is O(n+m). Wythoff classification is O(1) using arithmetic.
Complexity Analysis
Code Examples
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // We solve Win/Lose/Draw on a game graph that may have cycles and may be partizan. 5 // States are (position, player), player in {0,1}. Edges alternate turns. 6 // Input format (example): 7 // n m0 m1 8 // then m0 edges for player 0: u v meaning from (u,0) you can move to (v,1) 9 // then m1 edges for player 1: u v meaning from (u,1) you can move to (v,0) 10 // Positions are 0..n-1. A state with no outgoing edges is Losing for the player to move. 11 // Output: for each position, outcome for player 0 to move and player 1 to move. 12 13 int main(){ 14 ios::sync_with_stdio(false); 15 cin.tie(nullptr); 16 17 int n; int m0, m1; 18 if(!(cin >> n >> m0 >> m1)){ 19 return 0; 20 } 21 // Build edges on doubled graph: id = pos*2 + player 22 int N = 2*n; 23 vector<vector<int>> out(N), in(N); 24 vector<int> outdeg(N, 0); 25 26 auto id = [&](int pos, int pl){ return pos*2 + pl; }; 27 28 for(int i=0;i<m0;i++){ 29 int u,v; cin>>u>>v; 30 int a = id(u,0), b = id(v,1); 31 out[a].push_back(b); 32 in[b].push_back(a); 33 } 34 for(int i=0;i<m1;i++){ 35 int u,v; cin>>u>>v; 36 int a = id(u,1), b = id(v,0); 37 out[a].push_back(b); 38 in[b].push_back(a); 39 } 40 for(int s=0;s<N;s++) outdeg[s] = (int)out[s].size(); 41 42 // -1 unknown, 0 = Lose, 1 = Win, 2 = Draw 43 vector<int> res(N, -1); 44 deque<int> dq; 45 46 // Initialize: states with no outgoing moves are Losing for the player to move 47 for(int s=0;s<N;s++) if(outdeg[s]==0){ res[s]=0; dq.push_back(s); } 48 49 // Retrograde propagation 50 while(!dq.empty()){ 51 int s = dq.front(); dq.pop_front(); 52 for(int p: in[s]){ 53 if(res[p] != -1) continue; // already decided 54 if(res[s] == 0){ 55 // If you can move to a Losing state for opponent, you are Winning 56 res[p] = 1; 57 dq.push_back(p); 58 } else if(res[s] == 1){ 59 // All your moves lead to Winning states for opponent -> you may become Losing 60 if(--outdeg[p] == 0){ 61 res[p] = 0; 62 dq.push_back(p); 63 } 64 } 65 // Draws are assigned at the end to undecided states 66 } 67 } 68 69 for(int s=0;s<N;s++) if(res[s]==-1) res[s]=2; // Draw 70 71 // Print results per position for both players 72 auto nameOf = [&](int v){ return v==0?"Lose":(v==1?"Win":"Draw"); }; 73 74 for(int pos=0; pos<n; ++pos){ 75 cout << "pos " << pos << ": P0-" << nameOf(res[id(pos,0)]) 76 << ", P1-" << nameOf(res[id(pos,1)]) << "\n"; 77 } 78 return 0; 79 } 80
We create a doubled graph over states (position, player). Terminal states (no outgoing edges) are Losing for the player to move. Using a queue, we propagate: any predecessor of a Losing state is Winning; if all successors of a state are Winning, it becomes Losing. Unlabeled states after propagation are Draws (they lie in or are attracted to cycles that avoid decided outcomes). This works for both impartial and partizan rules and for cyclic graphs.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Compute Grundy numbers on an impartial DAG and the XOR over components. 5 // Input: n m, then m edges u->v (same for both players), and a partition of nodes into c components. 6 // For demonstration, we read a list of component roots and output their SG XOR. 7 8 int main(){ 9 ios::sync_with_stdio(false); 10 cin.tie(nullptr); 11 12 int n,m; 13 if(!(cin>>n>>m)) return 0; 14 vector<vector<int>> g(n); 15 vector<int> indeg(n,0); 16 for(int i=0;i<m;i++){ 17 int u,v; cin>>u>>v; 18 g[u].push_back(v); 19 indeg[v]++; 20 } 21 // Topological order (Kahn) 22 queue<int> q; 23 for(int i=0;i<n;i++) if(indeg[i]==0) q.push(i); 24 vector<int> topo; topo.reserve(n); 25 while(!q.empty()){ 26 int u=q.front(); q.pop(); topo.push_back(u); 27 for(int v: g[u]) if(--indeg[v]==0) q.push(v); 28 } 29 if((int)topo.size()!=n){ 30 cerr << "Graph has cycles; SG requires a DAG.\n"; 31 return 0; 32 } 33 34 vector<int> grundy(n,0); 35 // Compute SG in reverse topological order so children are ready 36 vector<int> seen; seen.reserve(64); 37 for(int it=n-1; it>=0; --it){ 38 int u = topo[it]; 39 // gather children's grundy values 40 int mx = 0; 41 for(int v: g[u]) mx = max(mx, grundy[v]); 42 vector<char> used(mx+2, false); // enough to hold mex up to mx+1 43 for(int v: g[u]) used[grundy[v]] = true; 44 int gval = 0; while(gval < (int)used.size() && used[gval]) ++gval; 45 grundy[u] = gval; 46 } 47 48 // Read a set of k starting positions that form independent subgames 49 int k; cin>>k; 50 int xorsum = 0; 51 for(int i=0;i<k;i++){ int s; cin>>s; xorsum ^= grundy[s]; } 52 cout << "xor = " << xorsum << "\n"; 53 cout << (xorsum? "First": "Second") << " player wins\n"; 54 55 return 0; 56 } 57
We ensure the graph is acyclic and compute Grundy values by processing nodes in reverse topological order. The mex of childrenâs Grundy numbers gives each nodeâs Grundy. Disjoint starting positions combine via XOR: zero means a losing (P) position; nonzero means winning (N).
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Staircase Nim (1-based stairs): you may move any positive number of stones 5 // from stair i (i>=2) to stair i-1. Last move wins (normal play). 6 // The invariant: XOR over odd-indexed stairs alone determines outcome. 7 // We also output a concrete winning move if one exists. 8 9 int main(){ 10 ios::sync_with_stdio(false); 11 cin.tie(nullptr); 12 13 int n; if(!(cin>>n)) return 0; 14 vector<long long> a(n+1); // 1..n 15 for(int i=1;i<=n;i++) cin>>a[i]; 16 17 long long xorsum = 0; 18 for(int i=1;i<=n;i+=2) xorsum ^= a[i]; 19 20 if(xorsum==0){ 21 cout << "Second\n"; 22 return 0; 23 } 24 cout << "First\n"; 25 26 // Find a winning move: pick an odd i where decreasing a[i] makes XOR zero. 27 // Let target = a[i] ^ xorsum, with target < a[i]. Move k = a[i]-target stones from i to i-1. 28 for(int i=1;i<=n;i+=2){ 29 long long target = a[i] ^ xorsum; 30 if(target < a[i]){ 31 long long k = a[i] - target; // move k stones from i to i-1 32 cout << "move " << k << " from stair " << i << " to " << (i-1) << "\n"; 33 return 0; 34 } 35 } 36 // Should never reach here if xorsum != 0 37 return 0; 38 } 39
For 1-based indexing, only odd stairs contribute to the nim-sum. Moving stones from an odd stair i to iâ1 reduces a[i] and changes the XOR; moving from an even stair changes only a[iâ1] (odd), which is equivalent to moving on that odd pile in Nim. Therefore the XOR over odd stairs decides the game. The code prints a concrete winning move by reducing some odd a[i] to a[i] XOR xorsum and moving the difference to stair iâ1.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Wythoff's Game: two heaps (a,b), a<=b; move: remove any positive from one heap, 5 // or remove the same positive from both. P-positions are (floor(k*phi), floor(k*phi^2)). 6 // We test if (a,b) is P and, if not, output one optimal target P-position. 7 8 static inline long long floordiv_long_double(long double x){ 9 long double fx = floor(x + 1e-12L); // protect against tiny negative eps 10 return (long long)fx; 11 } 12 13 int main(){ 14 ios::sync_with_stdio(false); 15 cin.tie(nullptr); 16 17 long long a,b; if(!(cin>>a>>b)) return 0; 18 if(a>b) swap(a,b); 19 20 const long double phi = (1.0L + sqrtl(5.0L)) / 2.0L; // golden ratio 21 long long k = b - a; 22 long long ak = floordiv_long_double(k * phi); 23 long long bk = ak + k; // floor(k*phi^2) but equal to ak + k 24 25 bool isP = (a == ak && b == bk); 26 cout << (isP? "Second\n" : "First\n"); 27 28 if(!isP){ 29 // Move to (ak, bk). We can always achieve this in one legal move. 30 // If a > ak: remove (a - ak) from heap a. If b > bk: remove (b - bk) from heap b. 31 // Otherwise, remove t from both heaps where t = a - ak = b - bk. 32 long long ta = (a > ak ? a - ak : 0); 33 long long tb = (b > bk ? b - bk : 0); 34 if(ta && tb){ 35 // Should be equal; remove from both 36 long long t = min(ta, tb); 37 cout << "remove " << t << " from both\n"; 38 } else if(ta){ 39 cout << "remove " << ta << " from heap A\n"; 40 } else if(tb){ 41 cout << "remove " << tb << " from heap B\n"; 42 } else { 43 // Rare due to rounding â adjust safely by searching nearby k 44 // Fallback: try neighbor kb11 to counter rounding. 45 for(long long dk: {-1LL,1LL}){ 46 long long kk = k + dk; 47 if(kk<0) continue; 48 long long a2 = floordiv_long_double(kk * phi); 49 long long b2 = a2 + kk; 50 if(a==a2 && b>=b2){ cout << "remove " << (b-b2) << " from heap B\n"; break; } 51 if(b==b2 && a>=a2){ cout << "remove " << (a-a2) << " from heap A\n"; break; } 52 long long t = a - a2; if(t>0 && b - b2 == t){ cout << "remove " << t << " from both\n"; break; } 53 } 54 } 55 } 56 return 0; 57 } 58
We use the golden-ratio characterization: let k = b â a; then (a,b) is a P-position iff a = âkĎâ and b = a + k. If not P, moving to (âkĎâ, âkĎâ + k) is always legal: it is either a one-heap reduction or a diagonal reduction. The code computes this target and outputs a concrete winning move, with minor guarding against floating-point edge cases.