MathIntermediate

Sprague-Grundy Theorem

Key Points

  • Sprague–Grundy theory turns every finite impartial game (normal play) into an equivalent Nim heap with a size called the Grundy number.
  • The Grundy number g(s) of a position s is the MEX (smallest non-negative integer not present) of the Grundy numbers of its options.
  • A disjoint sum of games has Grundy equal to the XOR of component Grundy numbers, so the overall game behaves like one Nim heap of size that XOR.
  • A position is winning if and only if the XOR of component Grundy numbers is non-zero; otherwise it is losing.
  • A winning move always exists from a non-zero XOR position and can be found by making one component’s Grundy become ' = X, where X is the total XOR.
  • Computing Grundy values on a DAG can be done with DFS + memoization or topological order in O(n + m) time.
  • Subtraction and take-and-break games can be handled by 1D DP computing Grundy up to N in O(N |S|).
  • Avoid misère-play traps: Sprague–Grundy applies directly to normal play and finite impartial games only.

Prerequisites

  • Graphs and DAGsGrundy computation often proceeds on a directed acyclic game graph via DFS or topological order.
  • Bitwise XORThe nim-sum of components is a bitwise XOR; winning strategies depend on its properties.
  • Dynamic Programming (DP)Subtraction and combinatorial games on numbers use DP to compute Grundy values.
  • Recursion and MemoizationDFS-based Grundy computation caches results to avoid recomputation and cycles.
  • Set operations and MEXComputing the MEX of children’s Grundy numbers is central to the definition.
  • Nim and P/N positionsUnderstanding Nim clarifies why XOR characterizes winning vs. losing positions.
  • Topological SortingAn iterative alternative to recursion for DAG-based SG computation avoids stack limits.
  • Proof by inductionThe correctness of Grundy assignment and XOR composition is shown by induction on game depth.

Detailed Explanation

Tap terms for definitions

01Overview

The Sprague–Grundy Theorem is a powerful bridge between complex impartial games and the simple, well-understood game of Nim. In impartial games, both players have the same set of moves from any position, and there is no chance or hidden information. The theorem states that every position in such a game can be assigned a non-negative integer called its Grundy number (or nimber), and that number uniquely determines how the position behaves when combined with other positions. Even better, when you play several independent impartial games at once (a disjunctive sum), the whole position behaves as if it were a single Nim pile whose size is the XOR of the component Grundy numbers. This means strategies in seemingly complicated games can be reduced to bitwise XOR computations, a key trick for algorithmic problem solving. Practically, we compute Grundy numbers bottom-up: terminal positions get Grundy 0, and any position’s Grundy is the MEX (minimum excluded number) of the Grundy numbers of its direct options. Once you have these numbers, determining win/lose states and constructing winning moves becomes straightforward: the position is winning exactly when the XOR is non-zero, and a winning move is one that makes the XOR zero.

02Intuition & Analogies

Imagine each impartial game position as a room with doors to other rooms (the allowed moves). Some rooms are dead-ends (no doors)—if it’s your turn there, you immediately lose because you cannot move. Assign those dead-end rooms the label 0. Now, if a room can go to a dead-end, it’s clearly good for you because you can push your opponent into that dead-end; give that room label 1. What about a room that can go to rooms with labels 0 and 1? That’s even stronger—no matter what number the opponent wants you to face next time, you can avoid it. We then give this room the smallest non-negative label not yet used by its exits, which is called MEX (minimum excluded). This labeling process propagates from simple to complex rooms, and the label becomes the room’s Grundy number. Why does XOR appear? Think of playing two independent games at once as juggling two flashlights that can be toggled on/off in binary. The combined brightness (in XOR terms) dictates control: if the total XOR is zero, your opponent mirrors any change you make to maintain zero, leaving you stuck at the end; if the XOR is non-zero, you can toggle exactly one flashlight to make the total zero, placing your opponent in the symmetric trap. Each game’s Grundy acts like a Nim heap’s size, and XOR is the exact operation that captures how multiple heaps interact. So the whole puzzle of many games reduces to: compute these labels via MEX, then use XOR like a switchboard to navigate to a zero state.

03Formal Definition

Consider a finite impartial game under normal play with state space S and a directed move relation s s' when s' is reachable from s in one legal move. The Sprague–Grundy function g: S is defined recursively by g(s) = \{ g(s') : s s' \}. Terminal positions T (with no outgoing moves) satisfy g(t) = 0, since the set in question is empty and MEX of the empty set is 0. The disjunctive sum of positions , , ..., —meaning on each turn you choose exactly one component to move in—is assigned the Grundy value ) g() g(), where denotes bitwise XOR. The central theorem states that every finite impartial game position is equivalent to a Nim heap of size equal to its Grundy number, and the disjunctive sum corresponds to the XOR of heap sizes. A position is a P-position (previous player wins, i.e., losing for the player to move) iff its Grundy number is 0; otherwise it is an N-position (next player wins). Furthermore, from any N-position in a disjunctive sum, there exists a move in some component i taking g() to g'() = g() G that makes the overall XOR zero.

04When to Use

Use Sprague–Grundy when: (1) The game is impartial—both players have identical legal moves from any position; (2) It is normal play—the player unable to move loses; (3) The game is finite (no infinite plays), which in practice often means the move graph is a DAG or strictly decreasing in a well-founded measure. Typical competitive programming uses include: subtraction games (you can remove certain amounts each move), take-and-break games on piles or strings, token-on-graph games where you move a token along directed edges until stuck, and multi-board problems where several independent components are played simultaneously. If the problem gives you multiple independent positions and asks for the winner under optimal play, compute each component’s Grundy, XOR them, and check if the result is zero. If you need the winning move, choose a component where changing its Grundy to g_i' = g_i \oplus X (with X the total XOR) is possible, then find a child state with Grundy g_i'. Avoid Sprague–Grundy when the game is partisan (players have different moves), misère play (last move loses), or when cycles allow infinite play unless extra conditions ensure termination.

⚠️Common Mistakes

  • Forgetting normal-play condition: Sprague–Grundy applies to normal play. Misère versions (e.g., misère Nim) need special handling; applying XOR naively can yield wrong answers.
  • Ignoring impartiality: If players have different moves (partisan game), Grundy numbers do not apply; you need Conway’s theory of games or other tools.
  • Overlooking finiteness: If the move graph allows cycles without a measure guaranteeing termination, the classic SG recursion may be ill-defined. Many problems ensure acyclicity or strictly decreasing state size—check constraints.
  • Wrong MEX computation: MEX must be the smallest non-negative integer not in the set, not the minimum or maximum. Implement it with a boolean presence array sized to at most the outdegree (or |S| in 1D DP) to avoid missing small gaps.
  • Off-by-one and indexing errors: When building graphs, ensure nodes are zero- or one-indexed consistently; otherwise memoization/topological order breaks.
  • Conflating single-game strategy with multi-game: In a single component, a winning move aims for Grundy 0; in a sum, you aim to make the total XOR zero, which may mean moving to a child with nonzero Grundy.
  • Assuming you must explore all states: In many problems you only need Grundy for reachable states from the start; use DFS with memoization to avoid unnecessary computation and TLE.
  • Not proving existence of the targeted child: When you set g_i' = g_i \oplus X < g_i, SG theory guarantees a move to a child with that Grundy. Implement a search over neighbors and assert its existence only after verifying g_i \oplus X < g_i holds.

Key Formulas

Sprague–Grundy definition

Explanation: A position’s Grundy number is the smallest non-negative integer not among the Grundy numbers of its direct options. Terminal positions have g(s) = 0 since the set is empty.

MEX

Explanation: The MEX of a finite set of non-negative integers is the smallest non-negative integer that does not appear in the set. It is used to assign new Grundy numbers from children’s values.

Disjunctive sum Grundy

Explanation: The Grundy number of a sum of independent games equals the bitwise XOR of their individual Grundy numbers. This makes the whole position behave like one Nim heap of size G.

Winning condition (sum)

Explanation: A player to move has a winning strategy in the sum of impartial games if and only if the XOR of component Grundy numbers is non-zero. If it is zero, the position is losing.

Winning move target

Explanation: From a non-zero XOR position, pick a component i with the highest set bit of G. Moving in that component to a child with Grundy ' makes the total XOR zero and is thus a winning move.

Existence of zeroing move

Explanation: Because the highest set bit of G is present in some g(), XORing reduces that component’s value, and the SG definition guarantees a child with the needed Grundy exists. That move zeroes the total XOR.

DAG Grundy complexity

Explanation: Computing Grundy values on a directed acyclic graph with n nodes and m edges using DFS or topological DP visits each node and edge a constant number of times.

Subtraction-game DP

Explanation: For numbers up to N and a move set S, each state checks at most |S| options to compute MEX, resulting in linear times the move-set size.

Memory bounds

Explanation: Graph-based SG stores adjacency and memoized Grundy per node; numeric DP stores Grundy for each integer up to N. Both are linear in the input size.

Complexity Analysis

For a token-on-DAG game with n states and m directed edges, computing Grundy numbers via DFS with memoization or a topological ordering is O(n + m) time. Each node’s Grundy is computed once, and we scan all outgoing edges once in total. While calculating MEX, we only need a boolean presence array of size at most outdegree(v) + 1, since the MEX of a set of size d is at most d. Thus, the total overhead across all nodes remains O(n + m). Space usage is O(n + m) to store the graph plus O(n) for the Grundy array and recursion stack. For subtraction or bounded-move numeric games up to N with move set S, a 1D DP that computes g[0..N] runs in O(N ) time and O(N) space: for each i, we iterate over allowed moves s in S with i − , mark children’s Grundy values, and take MEX. For multi-component sums, we compute each component’s Grundy independently and XOR them; the XOR aggregation is O(k) for k components. Finding a winning move from a non-zero XOR state requires identifying a component with ( ⊕ X) < and then scanning its neighbors to find a child with target Grundy ' = ⊕ X. This takes O(outdegree(component)) time after Grundy precomputation. In practice, careful implementation details—like reusing a static for MEX marking sized to local degree, and restricting DFS to states reachable from the start—keep constants low and avoid TLE. Beware recursion depth on tall DAGs; iterative topological DP can circumvent stack limits.

Code Examples

Compute Grundy numbers on a DAG and find a winning move from a start node
1#include <bits/stdc++.h>
2using namespace std;
3
4// Compute Grundy numbers on a DAG using DFS + memoization.
5// Then, given a start node s, report whether it's winning and a winning move if it exists.
6
7int n, m; // number of nodes and edges
8vector<vector<int>> adj; // adjacency list: edges u -> v mean you can move from u to v
9vector<int> grundy; // memoized Grundy numbers, -1 means uncomputed
10vector<int> state; // 0=unvisited, 1=visiting, 2=done (cycle detection)
11
12int dfs_grundy(int v) {
13 if (grundy[v] != -1) return grundy[v];
14 state[v] = 1;
15 // Collect children's Grundy numbers
16 // MEX of a set of size d is at most d, so allocate d+1
17 int d = (int)adj[v].size();
18 vector<int> childVals; childVals.reserve(d);
19 for (int u : adj[v]) {
20 if (state[u] == 1) {
21 // Cycle detected. For classic SG, cycles are not allowed.
22 // In contests, input usually guarantees acyclicity or termination.
23 // We handle it conservatively by exiting.
24 cerr << "Error: cycle detected (game not finite)." << '\n';
25 exit(0);
26 }
27 int gu = dfs_grundy(u);
28 childVals.push_back(gu);
29 }
30 vector<char> seen(d + 1, false);
31 for (int g : childVals) if (g <= d) seen[g] = true;
32 int mex = 0;
33 while (mex <= d && seen[mex]) ++mex;
34 state[v] = 2;
35 return grundy[v] = mex;
36}
37
38int main() {
39 ios::sync_with_stdio(false);
40 cin.tie(nullptr);
41
42 // Input format:
43 // n m
44 // m lines: u v (0-indexed), edge u -> v is a legal move
45 // s (start node)
46
47 if (!(cin >> n >> m)) return 0;
48 adj.assign(n, {});
49 for (int i = 0; i < m; ++i) {
50 int u, v; cin >> u >> v; adj[u].push_back(v);
51 }
52 int s; cin >> s;
53
54 grundy.assign(n, -1);
55 state.assign(n, 0);
56
57 for (int v = 0; v < n; ++v) if (grundy[v] == -1) dfs_grundy(v);
58
59 cout << "Grundy(s) = " << grundy[s] << '\n';
60 if (grundy[s] == 0) {
61 cout << "Losing position for the first player.\n";
62 return 0;
63 }
64
65 // Winning move: in a single component, aim for child with Grundy 0.
66 for (int u : adj[s]) {
67 if (grundy[u] == 0) {
68 cout << "Winning move: " << s << " -> " << u << " (to Grundy 0)\n";
69 return 0;
70 }
71 }
72 // By SG theory, there must be a child with every t in [0, grundy[s)-1].
73 // Fallback search for the smallest such t.
74 for (int t = 0; t < grundy[s]; ++t) {
75 for (int u : adj[s]) if (grundy[u] == t) {
76 cout << "Winning move: " << s << " -> " << u << " (to Grundy " << t << ")\n";
77 return 0;
78 }
79 }
80
81 // Should not reach here in a well-formed finite impartial game.
82 cout << "No winning move found (unexpected).\n";
83 return 0;
84}
85

We build the game graph and recursively compute Grundy numbers from sinks upward using DFS with memoization. The MEX is computed using a small boolean array sized to outdegree+1. For a single-component game, any position with Grundy > 0 is winning, and there exists a child with Grundy 0; moving there forces a losing position on the opponent. The program reports the Grundy of the start node and outputs such a winning move.

Time: O(n + m)Space: O(n + m)
Classic Nim: decide winner and construct a winning move
1#include <bits/stdc++.h>
2using namespace std;
3
4// Given heap sizes, compute nim-sum, decide winner, and print one winning move.
5int main() {
6 ios::sync_with_stdio(false);
7 cin.tie(nullptr);
8
9 int n; // number of heaps
10 if (!(cin >> n)) return 0;
11 vector<long long> a(n);
12 for (int i = 0; i < n; ++i) cin >> a[i];
13
14 long long x = 0; // nim-sum (XOR of heap sizes)
15 for (auto v : a) x ^= v;
16
17 if (x == 0) {
18 cout << "Second\n"; // losing for first player
19 return 0;
20 }
21
22 cout << "First\n"; // winning for first player
23
24 // Find a heap to reduce so that total XOR becomes 0.
25 for (int i = 0; i < n; ++i) {
26 long long target = a[i] ^ x; // desired new size for heap i
27 if (target < a[i]) {
28 long long remove = a[i] - target;
29 cout << "Move: heap " << i << ", remove " << remove
30 << " (" << a[i] << " -> " << target << ")\n";
31 return 0;
32 }
33 }
34
35 // Should not happen if x != 0
36 cout << "No winning move found (unexpected).\n";
37 return 0;
38}
39

The XOR of heap sizes determines the winner in Nim: non-zero means the first player wins. To construct a winning move, pick any heap i where (a[i] XOR X) < a[i] and reduce it to that target. This sets the total XOR to zero, leaving a P-position for the opponent.

Time: O(n)Space: O(1)
Sum of independent DAG games: compute XOR and output a winning move across components
1#include <bits/stdc++.h>
2using namespace std;
3
4struct Game {
5 int n, m, s; // nodes, edges, start node
6 vector<vector<int>> adj; // adjacency list
7 vector<int> grundy, state; // memo and cycle detection
8
9 int dfs(int v) {
10 if (grundy[v] != -1) return grundy[v];
11 state[v] = 1;
12 int d = (int)adj[v].size();
13 vector<int> child; child.reserve(d);
14 for (int u : adj[v]) {
15 if (state[u] == 1) {
16 cerr << "Error: cycle detected in a component (not finite)." << '\n';
17 exit(0);
18 }
19 child.push_back(dfs(u));
20 }
21 vector<char> seen(d + 1, false);
22 for (int g : child) if (g <= d) seen[g] = true;
23 int mex = 0; while (mex <= d && seen[mex]) ++mex;
24 state[v] = 2;
25 return grundy[v] = mex;
26 }
27
28 void compute_all() {
29 grundy.assign(n, -1);
30 state.assign(n, 0);
31 for (int v = 0; v < n; ++v) if (grundy[v] == -1) dfs(v);
32 }
33};
34
35int main() {
36 ios::sync_with_stdio(false);
37 cin.tie(nullptr);
38
39 // Input format:
40 // k (number of components)
41 // For each component i=1..k:
42 // n m
43 // m lines: u v (0-indexed), edge u->v
44 // s (start node for that component)
45
46 int k; if (!(cin >> k)) return 0;
47 vector<Game> games(k);
48 for (int i = 0; i < k; ++i) {
49 cin >> games[i].n >> games[i].m;
50 games[i].adj.assign(games[i].n, {});
51 for (int e = 0; e < games[i].m; ++e) {
52 int u, v; cin >> u >> v; games[i].adj[u].push_back(v);
53 }
54 cin >> games[i].s;
55 games[i].compute_all();
56 }
57
58 // Compute XOR of start positions
59 int X = 0;
60 for (int i = 0; i < k; ++i) X ^= games[i].grundy[games[i].s];
61
62 if (X == 0) {
63 cout << "Second\n"; // losing for first player
64 return 0;
65 }
66
67 cout << "First\n";
68
69 // Find a component to move in: need g_i' = g_i XOR X < g_i
70 for (int i = 0; i < k; ++i) {
71 int gi = games[i].grundy[games[i].s];
72 int target = gi ^ X;
73 if (target < gi) {
74 // Find a child s' of s with grundy = target
75 int from = games[i].s;
76 for (int u : games[i].adj[from]) {
77 if (games[i].grundy[u] == target) {
78 cout << "Move in component " << i << ": " << from << " -> " << u
79 << " (Grundy " << gi << " -> " << target << ")\n";
80 return 0;
81 }
82 }
83 // Should exist by SG theory; if not found due to input oddity, continue searching
84 }
85 }
86
87 cout << "No winning move found (unexpected).\n";
88 return 0;
89}
90

We treat each independent DAG game as a component, compute its Grundy table, and XOR the starting positions. If the XOR is non-zero, we select a component whose Grundy contributes the highest set bit and move to a child with Grundy g_i' = g_i XOR X, which zeroes the total XOR. The program prints which component to play in and the move inside it.

Time: O(Σ(n_i + m_i))Space: O(Σ(n_i + m_i))
Subtraction game DP (general move set) with winning move reconstruction
1#include <bits/stdc++.h>
2using namespace std;
3
4// Game: Start with integer N. A move subtracts s in S if current >= s. Terminal at 0.
5// Compute Grundy up to N and decide winner for given start N. Output a winning move if exists.
6
7int main() {
8 ios::sync_with_stdio(false);
9 cin.tie(nullptr);
10
11 int N, K; // maximum number and size of move set
12 // Input: N K
13 // Next line: K distinct positive integers s in S
14 if (!(cin >> N >> K)) return 0;
15 vector<int> S(K);
16 for (int i = 0; i < K; ++i) cin >> S[i];
17 sort(S.begin(), S.end());
18
19 vector<int> g(N + 1, 0); // g[0] = 0 by definition
20 // DP from 1..N
21 for (int x = 1; x <= N; ++x) {
22 // Child Grundy values from x - s for all s in S with s <= x
23 int d = 0; // at most K moves contribute
24 // For MEX, allocate size up to number of children + 1
25 vector<int> child;
26 child.reserve(S.size());
27 for (int s : S) {
28 if (s > x) break;
29 child.push_back(g[x - s]);
30 }
31 vector<char> seen((int)child.size() + 1, false);
32 for (int val : child) if (val <= (int)child.size()) seen[val] = true;
33 int mex = 0; while (mex <= (int)child.size() && seen[mex]) ++mex;
34 g[x] = mex;
35 }
36
37 cout << "Grundy[" << N << "] = " << g[N] << '\n';
38 if (g[N] == 0) {
39 cout << "Second\n"; // losing for first player
40 } else {
41 cout << "First\n";
42 // Find winning move: aim for child with Grundy 0
43 for (int s : S) {
44 if (s <= N && g[N - s] == 0) {
45 cout << "Winning move: subtract " << s << " (" << N << " -> " << (N - s) << ")\n";
46 break;
47 }
48 }
49 }
50
51 return 0;
52}
53

We compute Grundy values for a numeric subtraction game using bottom-up DP. Each state x considers moves x → x − s for s in S and assigns g[x] as the MEX of children’s g. The starting position N is winning iff g[N] > 0. To output a move, pick any s in S such that g[N − s] = 0.

Time: O(N · K)Space: O(N)