Two players alternate and both play optimally. The mechanics are ordinary DP or tree search; the twist is that the objective flips between levels. Pure win/lose impartial games are better handled by Grundy numbers; games with repeated positions by retrograde analysis.

Zermelo’s theorem (1913) is why any of this is well-defined: every finite, deterministic, perfect-information two-player game is determined — from every position exactly one of “the player to move can force a win,” “the opponent can force a win,” or “the player to move can force at least a draw” holds, and backward induction (minimax on the finite game tree, bottom-up) always finds which. Finiteness and perfect information both matter: infinite games can fail to be determined at all (without the axiom of choice’s weaker forms), and hidden information turns the problem into one over belief states, not positions.

SituationMethod
impartial, win/lose onlyGrundy numbers and XOR
win/lose, small state spacememoized W/L recursion (Combinatorial Games): terminal positions lose under normal play, win under misère
scored game (both maximize their own total)advantage DP
different objectives per playerexplicit minimax with a turn dimension
chance nodesexpectiminimax (expectation at chance nodes): Expected Value DP for cyclic dependencies
positions repeat (draws)retrograde analysis
large tree, good evaluation functionalpha-beta with move ordering, iterative deepening, transposition table
large tree, no evaluation functionMCTS with UCT
proving a forced winproof-number search (df-pn)

Score games: the advantage trick

Let be the current player’s advantage (their eventual score minus the opponent’s). Then : the minus sign encodes the role swap, so there is no separate min branch and no “whose turn” dimension. With total and root advantage , the first player scores . Example: take a coin from either end of a row (Coins in a Row).

ll takeEndsAdvantage(const vector<ll>& a) {             // first player's (own - opponent) with optimal play, O(n^2)
    int n = sz(a); vector<vector<ll>> dp(n + 1, vector<ll>(n + 1, 0));   // dp[l][r]: coins l..r-1 left
    for (int len = 1; len <= n; len++) for (int l = 0; l + len <= n; l++) {
        int r = l + len;
        dp[l][r] = max(a[l] - dp[l + 1][r], a[r - 1] - dp[l][r - 1]);
    }
    return dp[0][n];
}

When the players have asymmetric goals or move sets, carry the turn in the state: , . With chance nodes, (expectiminimax); alpha-beta prunes poorly through expectations (“star” pruning variants exist), so for games with chance MCTS is the usual practical answer.

Minimax, negamax and alpha-beta

Minimax: at terminal nodes or at the depth limit, when the maximizer moves, when the minimizer moves. Negamax expresses every value from the point of view of the player to move, so collapses the two cases into one code path (and removes a class of sign errors). Alpha-beta passes a window : at a node where the mover already has , the opponent above would never allow this line, so the remaining children are pruned. The functions below search an explicit tree (ch[v] children, leaf[v] value for the player to move at leaf v); in an engine, replace the loops by generating moves and calling apply / undo.

struct GameTree { vector<vector<int>> ch; vector<int> leaf; ll visited = 0; };
 
int negamax(GameTree& T, int v) {
    T.visited++;
    if (T.ch[v].empty()) return T.leaf[v];
    int best = INT_MIN;
    for (int c : T.ch[v]) best = max(best, -negamax(T, c));
    return best;
}
 
int alphabeta(GameTree& T, int v, int alpha, int beta) {   // fail-hard: result clamped to [alpha, beta]
    T.visited++;
    if (T.ch[v].empty()) return max(alpha, min(beta, T.leaf[v]));
    for (int c : T.ch[v]) {
        alpha = max(alpha, -alphabeta(T, c, -beta, -alpha));
        if (alpha >= beta) break;                             // cutoff
    }
    return alpha;
}
 
int negascout(GameTree& T, int v, int alpha, int beta) {   // principal variation search
    T.visited++;
    if (T.ch[v].empty()) return max(alpha, min(beta, T.leaf[v]));
    bool first = true;
    for (int c : T.ch[v]) {
        int score;
        if (first) score = -negascout(T, c, -beta, -alpha), first = false;
        else {
            score = -negascout(T, c, -alpha - 1, -alpha);     // null window: is this move better than alpha?
            if (alpha < score && score < beta) score = -negascout(T, c, -beta, -score);   // yes: re-search
        }
        alpha = max(alpha, score);
        if (alpha >= beta) break;
    }
    return alpha;
}

With perfect move ordering alpha-beta visits about leaves instead of (Knuth and Moore, 1975): the reachable depth roughly doubles. With random ordering the gain is much smaller, which is why move ordering matters more than any other optimization. Worst case remains ; space .

Move orderingIdea
iterative deepeningsearch depth ; use each result to order the next; costs only a factor more
transposition tablecache positions by hash; the stored best move is tried first
killer movesmoves that caused a cutoff at the same ply elsewhere
history heuristica global per-move score from past cutoffs
domain heuristicscaptures first (MVV-LVA in chess), checks, central squares
RefinementIdea
negamaxone code path instead of separate min / max
NegaScout / PVSfull window for the first move, null window for the others, re-search on fail-high; 10–20% fewer nodes with good ordering, worse with bad ordering (re-searches)
MTD(f)all searches are null-window, driven by bisection on the root value; elegant but needs a good transposition table, so PVS is the robust default most engines use
aspiration windowsstart with a narrow window around the previous iteration’s score
quiescence searchextend past the depth limit on “noisy” positions (captures) to avoid the horizon effect
null-move pruninggive the opponent a free move; if still winning, prune
late move reductionssearch later (presumably worse) moves at reduced depth

Zobrist hashing. Assign a random 64-bit number to every (square, piece) pair; the hash of a position is the XOR of the numbers of its occupied squares. A move updates it in : XOR the piece out of the old square and into the new one. This makes transposition tables practical, and the same trick hashes any set that changes by single-element updates.

struct Zobrist {                                         // hash of a board of `cells` cells with `kinds` piece types
    vector<vector<ull>> z; ull h = 0;
    Zobrist(int cells, int kinds, ull seed = 0x9e3779b97f4a7c15ULL) : z(cells, vector<ull>(kinds)) {
        mt19937_64 rng(seed); for (auto& r : z) for (auto& x : r) x = rng();
    }
    void toggle(int cell, int kind) { h ^= z[cell][kind]; }   // place or remove a piece
};

A best-first search for proving or disproving a binary goal (“can White force a win?”). Every node carries a proof number pn (minimum number of leaves that must be proven true to prove it) and a disproof number dn.

Nodepndn
proven true0
proven false0
unknown leaf11
OR node (the mover needs one winning child)
AND node (every child must win)

Loop: from the root, descend to the child with minimal pn at OR nodes and minimal dn at AND nodes until a leaf (the most-proving node: proving it advances the proof of the root most, and disproving it advances the disproof most); expand it, update pn/dn back to the root; stop when the root’s pn or dn is 0. Unlike alpha-beta, which searches to a uniform depth, PNS follows the shape of the tree and dives into narrow forced sequences, which is ideal for endgames and puzzles. Checkers was solved (Schaeffer et al., 2007) with proof-number variants as a major component.

// Proof-number search on an explicit AND/OR tree: kids[v] children, isOr[v], at leaves value[v] (true / false).
// Expands one node per iteration (children become visible only when expanded). Returns the proven root value.
bool proofNumberSearch(const vector<vector<int>>& kids, const vector<char>& isOr, const vector<char>& value) {
    const ll INF = LLONG_MAX / 4; int n = sz(kids);
    vector<ll> pn(n, 1), dn(n, 1); vector<char> expanded(n, 0); vector<int> par(n, -1);
    auto setLeaf = [&](int v) { if (kids[v].empty()) { pn[v] = value[v] ? 0 : INF; dn[v] = value[v] ? INF : 0; } };
    auto recompute = [&](int v) {
        ll a = isOr[v] ? INF : 0, b = isOr[v] ? 0 : INF;
        for (int c : kids[v]) {
            if (isOr[v]) a = min(a, pn[c]), b = min(INF, b + dn[c]);
            else a = min(INF, a + pn[c]), b = min(b, dn[c]);
        }
        pn[v] = a; dn[v] = b;
    };
    setLeaf(0);
    while (pn[0] != 0 && dn[0] != 0) {
        int v = 0;
        while (expanded[v]) {                                // descend to the most-proving node
            int best = -1;
            for (int c : kids[v]) if (best < 0 || (isOr[v] ? pn[c] < pn[best] : dn[c] < dn[best])) best = c;
            v = best;
        }
        expanded[v] = 1;
        for (int c : kids[v]) par[c] = v, setLeaf(c);
        recompute(v);
        for (int u = par[v]; u != -1; u = par[u]) recompute(u);
    }
    return pn[0] == 0;
}
VariantImprovement
PN²a second-level PN search at each leaf; much less memory
df-pn (depth-first PN)depth-first with thresholds, linear memory: the standard modern version
PDSproof and disproof number iterative deepening
weak PNSinitialise unknown leaves with heuristic pn/dn instead of 1/1
lambda searchcombines PN search with threat-space search

Plain PNS keeps the whole tree in memory, its main weakness. Uses: endgame solvers for checkers, Go life-and-death, shogi tsume, Hex, connect-6, and any “is this position winnable?” puzzle. The transferable idea for contests: when searching for a witness, expand the node that is cheapest to resolve rather than searching uniformly (the same philosophy as branch and bound).

Monte Carlo tree search and UCT

MCTS chooses good moves without any evaluation function, from random playouts; it is the algorithm behind AlphaGo. Each iteration: (1) selection, descend from the root by a tree policy (UCT) while nodes are fully expanded; (2) expansion, add one child for an untried move; (3) simulation, play to the end with a fast default policy (uniformly random in the basic version); (4) backpropagation, add the result to the visit count and value sum of every node on the path, flipping the result at each level. At the end play the move with the most visits (more robust than the best average).

UCT (Kocsis and Szepesvári, 2006) treats each node’s children as a multi-armed bandit and picks the child maximizing

exploitation plus an exploration bonus that shrinks as a child is sampled; unvisited children have infinite priority. UCB1 has regret, optimal up to constants (Auer, Cesa-Bianchi and Fischer, 2002); a game tree is a recursive bandit, and UCT’s value estimates converge to minimax, the probability of a suboptimal root move tending to zero.

// MCTS with UCT for a game given by moves(state) and a terminal result, on integer states (for illustration:
// any game where the player who cannot move loses). Returns the index of the chosen move at the root.
struct MCTS {
    function<vector<int>(int)> moves; double C = 1.41; mt19937 rng{7};
    struct Node { int state, parent; vector<int> kids, untried; double W = 0; int N = 0; };
    vector<Node> T;
    int rollout(int s) {                                 // 1 if the player to move at s wins the random playout
        int turn = 0;
        while (true) { auto m = moves(s); if (m.empty()) return turn; s = m[rng() % m.size()]; turn ^= 1; }
    }
    int search(int root, int iterations) {
        T.clear(); T.push_back({root, -1, {}, moves(root)});
        for (int it = 0; it < iterations; it++) {
            int v = 0;
            while (T[v].untried.empty() && !T[v].kids.empty()) {                       // selection
                int best = -1; double bv = -1e18, lg = log((double)T[v].N);
                for (int c : T[v].kids) { double u = T[c].W / T[c].N + C * sqrt(lg / T[c].N); if (u > bv) bv = u, best = c; }
                v = best;
            }
            if (!T[v].untried.empty()) {                                                // expansion
                int s = T[v].untried.back(); T[v].untried.pop_back();
                T.push_back({s, v, {}, moves(s)}); T[v].kids.push_back(sz(T) - 1); v = sz(T) - 1;
            }
            double r = 1 - rollout(T[v].state);         // value for the player who moved INTO v
            for (int u = v; u != -1; u = T[u].parent) { T[u].N++; T[u].W += r; r = 1 - r; }  // backpropagation
        }
        int best = -1; for (int c : T[0].kids) if (best < 0 || T[c].N > T[best].N) best = c;
        return T[best].state;
    }
};
Behaviour
pure greedy: locks onto the first line that looks good, often badly wrong
–exploitative: good when rollouts are informative and the tree is deep
theoretical default for rewards in
nearly uniform search, wastes budget

Tune empirically against a fixed opponent; it matters more than most other parameters.

Minimax + alpha-betaMCTS
needs an evaluation functionyes, and this is the hard partno
search shapeuniform depthasymmetric, selective
anytimeno (needs a full ply)yes
best forchess, checkers (good evaluators exist)Go, Hex, general game playing
branching factorpoor beyond good
tactical sharpnessexcellentcan miss narrow forced lines

Improvements. RAVE / AMAF (share statistics between moves that appear anywhere in a playout; a large early speed-up in Go), domain-specific rollout policies (even a weak heuristic beats uniform random), neural guidance (AlphaZero: a value network replaces rollouts and PUCT with a learned prior replaces uniform exploration, which is most of why it searches so few nodes), UCB1-Tuned (uses the empirical variance), progressive bias (a decaying heuristic term ), progressive widening (expand children gradually for large or continuous action spaces), tree reuse (keep the subtree of the move actually played). Beyond games: A/B testing and online advertising (the original bandit setting), hyperparameter search (successive halving and Hyperband are bandit algorithms), heuristic contest problems (UCT-style selection over neighbourhood moves as an alternative to simulated annealing), Monte Carlo planning in MDPs and reinforcement learning.