MathIntermediate

Game Theory - Calculation Techniques

Key Points

  • Sprague–Grundy theory converts any impartial, normal-play game into an equivalent Nim heap using a Grundy number.
  • The Grundy number g(x) of a position x is the mex of the Grundy numbers of all positions reachable in one move.
  • For a sum of independent subgames (like multiple piles), the overall Grundy is the XOR of the subgames' Grundy numbers.
  • A position is winning (N-position) if and only if the XOR (nim-sum) of all Grundy numbers is non-zero.
  • Subtraction games with a finite move set produce Grundy sequences that are ultimately periodic, enabling O(1) query after preprocessing.
  • Some games exhibit striking patterns (e.g., Beatty sequences and Fibonacci-like structure) that characterize P-positions.
  • Misère play breaks standard SG theory; Nim has a special misère rule: if all heaps are size 1, decide by parity; otherwise play like normal Nim.
  • In practice, compute small Grundy values by DP/memoization, look for periodicity, prove or trust the pattern, and then reduce multi-pile instances by XOR.

Prerequisites

  • Bitwise XORXOR combines Grundy numbers for independent components; Nim-sum logic is central.
  • Recursion and memoizationGrundy numbers on DAGs are computed via recursive definitions with caching.
  • Dynamic programmingSubtraction game Grundy values are filled bottom-up, using small state transitions.
  • Graph basics (DAGs, DFS)Game positions often form DAGs; DFS is used to compute Grundy numbers.
  • Set operations and mexThe mex function is used to define Grundy numbers from reachable options.
  • Modular arithmeticPeriodicity lets us map large indices back into a period using modulo operations.
  • Asymptotic analysisChoosing limits and verifying complexity bounds is key in competitive programming.
  • Number theory (Beatty sequences, golden ratio)Recognizing Wythoff Nim’s P-positions avoids full SG computation in some problems.

Detailed Explanation

Tap terms for definitions

01Overview

Game theory calculation techniques for impartial combinatorial games revolve around the Sprague–Grundy (SG) theorem. The central idea is to assign a nonnegative integer called the Grundy number (or nimber) to every game position so that each position behaves like a Nim heap of that size. Once we can compute Grundy numbers for single components, sums of independent games reduce to XOR of these numbers, giving a clean, fast method to decide winners and even construct winning moves. Practically, many competitive programming problems define allowed moves on a single pile (subtraction games) or on graphs (move along edges). For subtraction games, the sequence of Grundy numbers as a function of heap size often becomes periodic after some point, which lets us answer large queries efficiently. For graph games that are acyclic, memoized DFS computes Grundy numbers quickly. When dealing with multiple piles or multiple independent components, XORing the Grundy numbers tells us instantly whether the position is winning or losing. Misère play (last move loses) is trickier: the SG framework does not directly apply in general, but Nim has a famous special-case rule that competitive programmers frequently exploit. This toolkit—compute small cases, spot patterns, prove or accept periodicity, and reduce via XOR—is the workhorse at CF 1900–2300.

02Intuition & Analogies

Think of each game position as a light with a unique color label: that label is its Grundy number. When you combine several lights (independent subgames), their colors blend using a special blend rule: XOR. If the resulting color is black (zero), the position is safe for the opponent; otherwise it’s bright (non-zero) and winning for you. How do we paint a single position? We look at all one-move options; each of those positions already has a color. We then choose the smallest nonnegative color not appearing among those options—the mex. This ensures that every move from this position leads to a strictly different color, mirroring Nim’s property: from a heap of size g, every smaller heap size is reachable. For subtraction games (remove s stones from a pile where s is in some fixed set), the color sequence by heap size often repeats in cycles, like wallpaper patterns. Once we notice the repeating pattern, we don’t have to repaint infinitely many walls; we just map any big index back into the repeating section. Some games have beautiful arithmetic patterns—Wythoff Nim’s losing positions align with lines of slope the golden ratio, evoking Fibonacci structure. Misère play flips the win/lose condition at the very endgame. For Nim, there’s a neat trick: if you’re only juggling 1-stone heaps, winning depends on parity; otherwise behave as usual. In short, Grundy numbers give each position a compact fingerprint, XOR combines fingerprints, and periodicity lets us fast-forward.

03Formal Definition

Consider an impartial, finite, normal-play game defined by a directed acyclic graph (DAG) of positions V and legal moves E. The set of options of a position x is \((x) = \{ y (x y) E \}\). The Grundy number g(x) is defined recursively by \(g(x) = (\{ g(y) y (x) \})\), where \((S)\) is the minimum nonnegative integer not in S. Terminal positions (no moves) thus have g(x) = 0. For the disjunctive sum (independent play) of positions \(, , , \), the Sprague–Grundy theorem states \(g( + + + ) = g() g() g()\), where \(\) is bitwise XOR. A position is a P-position (previous player wins, i.e., losing for the player to move) if and only if its Grundy number is 0; otherwise it is an N-position (next player wins). For subtraction games with finite move set S and heap size n, options are \(n-s\) for s in S with s n, and thus \(g(n) = (\{ g(n-s) : s S, s n \})\). The sequence \(\{g(n)\}_{n 0}\) is ultimately periodic for any finite S; practical bounds on the preperiod and period follow from a finite-state argument based on windows of length \( S\). Misère play (last move loses) generally breaks this framework, but Nim admits a modified rule: if all heaps have size 1, the P-positions are those with an odd number of heaps; otherwise, use normal-play Nim analysis.

04When to Use

Use Sprague–Grundy analysis when your game is impartial (both players share the same moves from a position), finite (no infinite play), and under normal play (last move wins). It shines in problems where the game decomposes into independent components: multiple piles, multiple tokens on disjoint graphs, or strings/segments that never interact after a move. For subtraction games with finite allowed removals, compute Grundy values by DP up to a limit, look for periodicity, and then answer very large heap sizes instantly by mapping indices into the period. In graph-based games with no cycles (e.g., moving a token down a DAG), memoized DFS computes Grundy values directly; then XOR across tokens determines the outcome. When a statement mentions Fibonacci, golden ratio, or Beatty sequences, suspect structure like Wythoff Nim: learn the P-positions first (often enough to solve), and only compute Grundy if needed. For misère tasks, check if the game reduces to Nim; if so, apply misère Nim’s special rule. Otherwise, be cautious: generic SG methods do not directly extend, and ad hoc reasoning or known misère results (misère quotients) may be needed.

⚠️Common Mistakes

• Forgetting impartiality or normal-play assumptions: SG only applies when moves available do not depend on the player and the last move wins. If the game is partizan or misère (except Nim’s special case), SG conclusions may be wrong. • Computing mex inefficiently: scanning large sets naively can be slow. Bound the mex by out-degree or by a known small range, and use a small boolean array or bitset per node. • Ignoring cycles: DFS-based SG requires acyclicity. If the move graph has cycles, you must ensure no infinite play (e.g., via hot/cold games) or transform the problem; otherwise memoization may loop or be undefined. • Mis-detecting periodicity: a short repetition early on might be a coincidence. Verify that a candidate period persists over a long trailing window (e.g., several multiples of the period) before trusting it for huge queries. • Mixing up XOR logic for winning move construction: to move to a P-position, find a move in some component that changes its Grundy g to g' such that g' = g \oplus X where X is the current global XOR. Equivalently, decrease that component’s Grundy to make the overall XOR zero—don’t try to change multiple components at once. • Misère Nim edge case: the special rule triggers only when all heaps are size 1. If any heap is larger, analyze exactly as normal Nim. • Overcomputing DP: for subtraction games, once periodicity is established, avoid computing beyond preperiod + period. Map large queries via modular arithmetic to save time and memory.

Key Formulas

mex

Explanation: The mex of a set S is the smallest nonnegative integer not contained in S. It assigns the next available nimber not already present among options.

Grundy Recurrence

Explanation: The Grundy number of position x is the mex of the Grundy numbers of all positions reachable in one move from x. Terminal nodes have g(x) = 0.

SG Sum Rule

Explanation: The Grundy number of a disjoint sum of positions equals the bitwise XOR of their individual Grundy numbers. This enables multi-pile reduction to a single Nim heap.

Winning Criterion

Explanation: A position is winning for the player to move exactly when its Grundy number is non-zero. If g(x) = 0, any move leads to a non-zero value for the opponent.

Subtraction Game DP

Explanation: For a heap of size n and finite move set S, the Grundy value is computed from smaller heaps reachable by subtracting allowed amounts. This yields a linear DP.

Winning Move Condition

Explanation: If the global XOR X is non-zero, there exists a component i and a move to a child y whose Grundy equals g() XOR X, making the new total XOR zero.

Ultimate Periodicity

Explanation: Finite subtraction games have Grundy sequences that repeat with some period p after a preperiod q. This allows mapping large n to smaller indices.

Period Bound (Practical)

Explanation: A useful competitive-programming bound is that the period for a subtraction game is at most exponential in the maximum move. In practice, periods are often much smaller.

Wythoff P-positions

Explanation: The losing positions of Wythoff Nim are given by two complementary Beatty sequences linked to the golden ratio. Recognizing these can solve problems without computing Grundy values explicitly.

Mise8re Nim Rule

Explanation: Under misère play, Nim requires a special-case decision: only when all heaps are 1 do we flip the parity condition. For any larger heap present, standard XOR analysis applies.

Complexity Analysis

For a general impartial game on a DAG with V positions and E edges, memoized DFS computes g(x) once per node. Each mex is computed over at most outdeg(x) values, so total time is O(V + E + Σ me). With a bounded small Grundy range per node, mex can be done with a temporary boolean array sized to outdeg(x) + 1, yielding O(E) aggregate time and O(H) extra space per call, where H is the local bound; overall space is O(V) for the recursion stack and memo table. For subtraction games with finite move set S ( = k), bottom-up DP for g(0..N) costs O(Nk) time and O(N) space. If periodicity is detected with period p after preperiod q, you can cap N at q + c·p (for some verification multiplier c like 3–5) and then answer each query in O(1) by mapping n to q + ((n − q) mod p). Period detection by searching the smallest p that repeats over a long window of length L runs in O(L·p), but since k and observed p are small in practice, this is fast. For multi-pile reductions with m piles, computing each pile’s g once and XORing is O(m). Constructing a winning move scans the options of one candidate pile: O(outdeg) or O(k) for subtraction rules. Misère Nim runs in O(m) time and O(1) extra space by counting heaps of size 1 and computing the XOR for the normal case. In competitive settings, careful constants (e.g., tight mex bounds, bitsets, and early periodicity detection) make these methods fit within typical constraints.

Code Examples

Generic Sprague–Grundy on a DAG and multi-token XOR
1#include <bits/stdc++.h>
2using namespace std;
3
4// Compute Grundy numbers on a directed acyclic graph (DAG).
5// Then XOR across multiple tokens (independent components) to decide the winner.
6// Input format (example):
7// n m
8// m lines: u v (edge u -> v)
9// t
10// t lines: start_node_of_each_token
11// Nodes are 0..n-1
12
13vector<vector<int>> gph;
14vector<int> grundy;
15vector<char> vis;
16
17int mex_from_used(vector<int>& neighGr){
18 // Compute mex of small set using boolean array
19 int mx = (int)neighGr.size();
20 // mex bounded by size of set; we can allocate mx+1
21 vector<char> used(mx + 1, 0);
22 for (int v : neighGr) if (v <= mx) used[v] = 1;
23 for (int i = 0; i <= mx; ++i) if (!used[i]) return i;
24 return mx + 1; // fallback (rare)
25}
26
27int dfs_grundy(int u){
28 if (vis[u]) return grundy[u];
29 vis[u] = 1;
30 vector<int> childG;
31 childG.reserve(gph[u].size());
32 for (int v : gph[u]){
33 childG.push_back(dfs_grundy(v));
34 }
35 grundy[u] = mex_from_used(childG);
36 return grundy[u];
37}
38
39int main(){
40 ios::sync_with_stdio(false);
41 cin.tie(nullptr);
42
43 int n, m; if(!(cin >> n >> m)) return 0;
44 gph.assign(n, {});
45 for (int i = 0; i < m; ++i){
46 int u, v; cin >> u >> v;
47 gph[u].push_back(v);
48 }
49 int t; cin >> t;
50 vector<int> tokens(t);
51 for (int i = 0; i < t; ++i) cin >> tokens[i];
52
53 grundy.assign(n, 0);
54 vis.assign(n, 0);
55
56 // Compute Grundy for all token start nodes (others lazily if reachable)
57 int xorsum = 0;
58 for (int s : tokens){
59 xorsum ^= dfs_grundy(s);
60 }
61
62 cout << (xorsum ? "First" : "Second") << " player wins\n";
63
64 // Optional: construct a winning move if First wins
65 if (xorsum){
66 // Find a token and a move that makes XOR zero
67 for (int i = 0; i < t; ++i){
68 int u = tokens[i];
69 int gu = grundy[u];
70 // We need a child v with grundy[v] == gu ^ xorsum
71 int need = gu ^ xorsum;
72 for (int v : gph[u]){
73 if (grundy[v] == need){
74 cout << "Winning move: move token #" << i << " from node " << u << " to node " << v << "\n";
75 return 0;
76 }
77 }
78 }
79 // If not found (shouldn't happen on DAG with correct SG), we did something wrong.
80 cout << "No explicit winning move found (check graph acyclicity).\n";
81 }
82 return 0;
83}
84

Build a DAG of positions and compute each node’s Grundy via memoized DFS. Combine multiple independent tokens by XORing their Grundy values; non-zero means the first player wins. To find a winning move, change one component so its Grundy becomes g' = g ^ X (where X is the current XOR), which makes the overall XOR zero.

Time: O(n + m) to compute all reachable Grundy values; mex per node is O(outdeg(u)) using a small boolean array, so overall O(n + m).Space: O(n + m) for the graph, O(n) for memoization and recursion stack.
Subtraction game DP with periodicity detection for fast queries
1#include <bits/stdc++.h>
2using namespace std;
3
4// Subtraction game: from a heap of size n, you may remove any s in S (s <= n).
5// We compute Grundy values g[0..LIM] bottom-up, detect an eventual period,
6// and then answer large queries by mapping to preperiod + (n - preperiod) % period.
7
8static const int VERIFY_MULTIPLIER = 5; // check multiple periods to avoid false positives
9
10// Try to find the smallest period p such that seq[i] == seq[i-p] for all i in [start, end)
11int find_period(const vector<int>& seq, int start){
12 int N = (int)seq.size();
13 for (int p = 1; start + p < N; ++p){
14 bool ok = true;
15 for (int i = start + p; i < N; ++i){
16 if (seq[i] != seq[i - p]) { ok = false; break; }
17 }
18 if (ok) return p;
19 }
20 return -1;
21}
22
23int main(){
24 ios::sync_with_stdio(false);
25 cin.tie(nullptr);
26
27 int k; cin >> k; // size of S
28 vector<int> S(k);
29 for (int i = 0; i < k; ++i) cin >> S[i];
30 sort(S.begin(), S.end()); S.erase(unique(S.begin(), S.end()), S.end());
31 int M = S.back();
32
33 // Choose a reasonable limit to observe periodicity (tunable per problem)
34 // Often 2e5..5e5 is enough for competitive programming.
35 int LIM = max(1000, M * 400); // heuristic upper bound
36
37 vector<int> g(LIM + 1, 0);
38 for (int n = 1; n <= LIM; ++n){
39 // gather reachable Grundy values
40 // mex is at most |S|, so use small boolean array
41 vector<char> used(k + 1, 0);
42 for (int s : S){
43 if (s > n) break;
44 int val = g[n - s];
45 if (val <= k) used[val] = 1;
46 }
47 int m = 0; while (m <= k && used[m]) ++m;
48 g[n] = m;
49 }
50
51 // Detect period after a warm-up window (start >= M)
52 int start = max(M * 3, 50);
53 start = min(start, LIM / 2); // ensure room
54
55 // Strengthen detection: require repetition to hold over last window of size W
56 int period = -1;
57 for (int trial = start; trial <= start * 2 && trial < LIM; trial += max(1, M)){
58 int p = find_period(g, trial);
59 if (p == -1) continue;
60 // verify over several periods
61 int end = LIM;
62 bool ok = true;
63 for (int i = trial + p; i < end; ++i){
64 if (g[i] != g[i - p]) { ok = false; break; }
65 }
66 if (ok) { period = p; start = trial; break; }
67 }
68
69 int q; cin >> q;
70 while (q--){
71 long long n; cin >> n;
72 int ans;
73 if (period == -1 || n <= LIM){
74 // fall back to table if within range or no period found
75 long long idx = min<long long>(n, LIM);
76 ans = g[(int)idx];
77 } else {
78 if (n < start) ans = g[(int)n];
79 else {
80 long long idx = start + ( (n - start) % period );
81 ans = g[(int)idx];
82 }
83 }
84 cout << ans << "\n";
85 }
86 return 0;
87}
88

We compute Grundy values for a subtraction game up to a limit using DP. Then we attempt to detect an ultimate period that persists over a long window. Once a period is trusted, any large heap size n is reduced to an equivalent index by modular arithmetic, making each query O(1).

Time: Preprocessing O(LIM · |S|); period search up to about O(LIM^2) in the worst-case naive check but practically much smaller due to early detection. Each query O(1).Space: O(LIM) for the Grundy table.
Different piles with their own subtraction sets: winner and one winning move
1#include <bits/stdc++.h>
2using namespace std;
3
4// We have m piles; pile i has size a[i] and its own subtraction set S_i.
5// Compute Grundy for each pile (with small DP up to its size or via memo), XOR to decide winner,
6// and if winning, output one move that makes the XOR zero.
7
8int main(){
9 ios::sync_with_stdio(false);
10 cin.tie(nullptr);
11
12 int m; cin >> m;
13 vector<int> A(m);
14 vector<vector<int>> Sets(m);
15 int maxA = 0;
16 for (int i = 0; i < m; ++i){
17 int sz, k; cin >> sz >> k; // pile size and number of allowed moves
18 A[i] = sz; maxA = max(maxA, sz);
19 Sets[i].resize(k);
20 for (int j = 0; j < k; ++j) cin >> Sets[i][j];
21 sort(Sets[i].begin(), Sets[i].end());
22 Sets[i].erase(unique(Sets[i].begin(), Sets[i].end()), Sets[i].end());
23 }
24
25 // For simplicity and clarity, compute each pile's Grundy via DP up to its size separately.
26 // If total sizes are large with few distinct rules, group by rule set to reuse DP.
27 map<vector<int>, vector<int>> dpByRule; // key: S_i, value: g[0..max_needed]
28
29 auto compute_g = [&](const vector<int>& S, int need){
30 auto it = dpByRule.find(S);
31 if (it == dpByRule.end() || (int)it->second.size() <= need){
32 vector<int> g;
33 if (it == dpByRule.end()) g.assign(need + 1, 0);
34 else g = it->second, g.resize(need + 1, 0);
35 int start = (int)it->second.size();
36 if (it == dpByRule.end()) start = 1; // g[0] = 0 already
37 vector<int> Ssorted = S;
38 for (int n = max(1, start); n <= need; ++n){
39 int K = (int)Ssorted.size();
40 vector<char> used(K + 1, 0);
41 for (int s : Ssorted){ if (s > n) break; int v = g[n - s]; if (v <= K) used[v] = 1; }
42 int m = 0; while (m <= K && used[m]) ++m; g[n] = m;
43 }
44 dpByRule[S] = g;
45 return g;
46 } else {
47 return it->second;
48 }
49 };
50
51 vector<int> G(m);
52 int X = 0;
53 for (int i = 0; i < m; ++i){
54 vector<int> g = compute_g(Sets[i], A[i]);
55 G[i] = g[A[i]];
56 X ^= G[i];
57 }
58
59 cout << (X ? "First" : "Second") << " player wins\n";
60
61 if (X){
62 // Find a pile and a legal move to make total XOR zero.
63 for (int i = 0; i < m; ++i){
64 int target = G[i] ^ X; // we need to move pile i to some state with Grundy == target
65 if (target > G[i]) continue; // in subtraction games, Grundy usually does not increase by moves
66 // Scan moves to find a child with the needed Grundy.
67 vector<int> g = dpByRule[Sets[i]]; // has at least up to A[i]
68 for (int s : Sets[i]){
69 if (s > A[i]) break;
70 int newSize = A[i] - s;
71 if (g[newSize] == target){
72 cout << "Winning move: on pile #" << i << ", remove " << s << " to get size " << newSize << "\n";
73 return 0;
74 }
75 }
76 }
77 cout << "No explicit winning move found (check DP coverage).\n";
78 }
79
80 return 0;
81}
82

Each pile may have different allowed removals. We compute DP per distinct rule set S_i only once, reuse its table for the needed pile sizes, XOR all pile Grundy numbers to decide the winner, and if winning, find a move that makes the overall XOR zero by targeting Grundy g' = g ^ X in some pile.

Time: Let U be the number of distinct rule sets and Ni the maximum size for set i, with ki = |S_i|. Preprocessing is sum over i of O(Ni · ki). Winner check and move construction are O(m · ki) in the worst case.Space: O(sum Ni) for stored DP tables across distinct rule sets.
Mise8re Nim solver (special-case rule)
1#include <bits/stdc++.h>
2using namespace std;
3
4// Misère Nim decision:
5// If all heaps are size 1, P-position iff the number of heaps is odd.
6// Otherwise, treat exactly as normal Nim (XOR != 0 is winning).
7
8int main(){
9 ios::sync_with_stdio(false);
10 cin.tie(nullptr);
11
12 int n; if(!(cin >> n)) return 0;
13 vector<long long> a(n);
14 long long xr = 0;
15 bool allOne = true;
16 for (int i = 0; i < n; ++i){
17 cin >> a[i];
18 xr ^= a[i];
19 if (a[i] != 1) allOne = false;
20 }
21
22 bool firstWins;
23 if (allOne){
24 // odd count => P-position (losing for player to move)
25 firstWins = !(n % 2 == 1);
26 } else {
27 firstWins = (xr != 0);
28 }
29
30 cout << (firstWins ? "First" : "Second") << " player wins\n";
31
32 // Optional: construct a winning move in non-all-one case (normal Nim move)
33 if (!allOne && firstWins){
34 long long X = xr;
35 for (int i = 0; i < n; ++i){
36 long long hi = a[i];
37 long long target = hi ^ X;
38 if (target < hi){
39 long long remove = hi - target;
40 cout << "Example winning move: on heap #" << i << ", remove " << remove << " to leave " << target << "\n";
41 break;
42 }
43 }
44 }
45
46 return 0;
47}
48

Implements the misère Nim rule. If all heaps are 1, the parity of heaps decides the winner; otherwise the usual Nim XOR rule applies. We also show how to construct a winning move in the normal case.

Time: O(n) to read heaps and compute XOR.Space: O(1) extra space beyond the input array.