1. Minimax, Negamax & The Alpha-Beta Pruning Window
In a two-player deterministic zero-sum game of perfect information (Chess, Go, Checkers, Connect Four), the Minimax value of state $s$ is the best score the active player can guarantee against a perfect opponent. Rather than writing separate max_value() and min_value() functions, production engines use Negamax, relying on the zero-sum symmetry $\max(a, b) = -\min(-a, -b)$ and evaluating every board strictly from the side-to-move's perspective.
Core Invariant — The $[\alpha, \beta]$ Search Window in Negamax
At any node in Negamax:
- $\alpha$ (Lower Bound): The minimum score the current side-to-move is already guaranteed by a previously explored path.
- $\beta$ (Upper Bound): The maximum score the opponent will allow us to achieve here (because if we can achieve $\ge \beta$, the opponent will deviate at an ancestor node where they already have a better alternative $-\beta$).
When recursing to a child state $s'$, the roles swap and signs negate: the child is searched with window $[\alpha', \beta'] = [-\beta, -\alpha]$. As soon as any move achieves $\text{score} \ge \beta$ ($alpha \ge \beta$), a Beta-Cutoff (Fail-High) occurs and all remaining sibling moves are pruned.
2. The $\Theta(b^{d/2})$ Theorem, Iterative Deepening & Zobrist Hashing
How much does Alpha-Beta prune? If moves are searched in worst-first order, Alpha-Beta prunes nothing and visits all $b^d$ leaves. If moves are searched in best-first order (the best move at each node is tried first), Knuth and Moore (1975) proved that Alpha-Beta visits exactly:
$$N_{\text{best}} = b^{\lceil d/2 \rceil} + b^{\lfloor d/2 \rfloor} - 1 = \Theta(b^{d/2})$$This reduces the effective branching factor from $b$ to $\sqrt{b}$ (e.g., in Chess from $b \approx 35$ to $\sqrt{35} \approx 6$), literally doubling the reachable search depth within a fixed time budget! To achieve near-perfect move ordering and avoid re-searching identical positions reached via different move sequences (transpositions):
- Zobrist Hashing (1970): Pre-generate a table of random 64-bit integers $Z[\text{piece}][\text{square}]$. The hash of a board is the XOR of $Z[p][sq]$ for all pieces on the board. Making or unmaking a move updates the 64-bit hash in $O(1)$ time via 2 or 3 XORs!
- Transposition Table (TT) Node Types: A cached depth-$d$ search over window $[\alpha, \beta]$ stores one of three bound flags:
EXACT(PV-Node, $\alpha < v < \beta$): True minimax value is $v$.LOWER_BOUND(Cut-Node / Beta-Cutoff, $v \ge \beta$): True value is $\ge v$.UPPER_BOUND(All-Node / Fail-Low, $v \le \alpha$): True value is $\le v$.
- Iterative Deepening ($d = 1, 2, \dots, D$): Searching depths $1 \dots D-1$ adds only $\frac{1}{\sqrt{b} - 1}$ overhead, while populating the Transposition Table with the best move (TT-move) at every node—guaranteeing near-optimal move ordering for depth $D$!
Root updates alpha = +3"] Root --> M2["Move 2 (MIN) [alpha=+3, beta=+inf]"] M1 --> L1["Leaf: +3"] M1 --> L2["Leaf: +7"] M2 --> L3["Reply 2a: Leaf -2
MIN gets <= -2 <= alpha(+3)"] M2 -. "ALPHA-BETA CUTOFF!
Skip Replies 2b, 2c" .-> L4["Pruned Subtrees"]
Hardware Reality — Lockless Power-of-2 Transposition Tables & Cache-Line Alignment
Using std::unordered_map<uint64_t, TTEntry> in a game engine destroys performance due to heap node allocations and pointer chasing. Production engines (like Stockfish) allocate a single flat power-of-2 array of 16-byte or 64-byte cache-line-aligned TTEntry structs indexed by zobrist_hash & (table_size - 1), issuing __builtin_prefetch(&tt[hash & mask]) as soon as a move is made so the cache line arrives from DRAM before evaluation begins.
3. Complexity Matrix
Step through a 3-ply Minimax game tree tracking [α, β] bounds at MAX and MIN plies, highlighting β ≤ α cutoffs that prune dead subtrees.
Invariant & State
| Game Tree Algorithm | Worst-Case Leaves | Best-Case Leaves | Random Move Ordering | Auxiliary Space |
|---|---|---|---|---|
| Plain Minimax / Negamax | O(b^d) | O(b^d) | O(b^d) | O(d) |
| Negamax + Alpha-Beta Pruning | O(b^d) | O(b^(d/2)) | O(b^(3d/4)) | O(d) |
| Zobrist Hash Incremental Update | O(1) | O(1) | O(1) | O(|Pieces| * |Squares|) |
| Transposition Table Lookup / Store | O(1) | O(1) | O(1) | O(2^k) (Fixed Arena) |
4. Reference C++20 Implementation
#include <algorithm>
#include <cstdint>
#include <vector>
enum class BoundFlag : std::uint8_t { NONE = 0, EXACT = 1, LOWER = 2, UPPER = 3 };
struct TTEntry {
std::uint64_t key = 0;
int value = 0;
int depth = -1;
int best_move = -1;
BoundFlag flag = BoundFlag::NONE;
};
// Generic Negamax with Alpha-Beta Pruning and Zobrist Transposition Table
template <typename GameState>
class AlphaBetaEngine {
std::vector<TTEntry> tt;
std::size_t mask;
static constexpr int INF = 1'000'000'000;
public:
explicit AlphaBetaEngine(std::size_t power_of_two_entries = (1ULL << 20))
: tt(power_of_two_entries), mask(power_of_two_entries - 1) {}
int negamax(GameState& state, int depth, int alpha, int beta) {
const int alpha_orig = alpha;
const std::uint64_t h = state.zobrist_hash();
TTEntry& entry = tt[h & mask];
// 1. Transposition Table Lookup
int tt_move = -1;
if (entry.flag != BoundFlag::NONE && entry.key == h) {
tt_move = entry.best_move;
if (entry.depth >= depth) {
if (entry.flag == BoundFlag::EXACT) return entry.value;
if (entry.flag == BoundFlag::LOWER) alpha = std::max(alpha, entry.value);
else if (entry.flag == BoundFlag::UPPER) beta = std::min(beta, entry.value);
if (alpha >= beta) return entry.value; // TT Cutoff!
}
}
// 2. Base Case: Terminal State or Horizon Depth
if (depth == 0 || state.is_terminal()) {
return state.evaluate_for_side_to_move();
}
// 3. Move Generation + Move Ordering (Prioritize TT Best Move first!)
std::vector<int> moves = state.generate_moves();
if (tt_move != -1) {
auto it = std::find(moves.begin(), moves.end(), tt_move);
if (it != moves.end()) std::iter_swap(moves.begin(), it);
}
int best_val = -INF;
int best_move = moves.empty() ? -1 : moves[0];
for (int m : moves) {
state.make_move(m);
int score = -negamax(state, depth - 1, -beta, -alpha);
state.undo_move(m);
if (score > best_val) {
best_val = score;
best_move = m;
}
if (score > alpha) {
alpha = score;
}
if (alpha >= beta) {
break; // Beta-Cutoff (Fail-High)
}
}
// 4. Transposition Table Store with Correct Bound Classification
entry.key = h;
entry.value = best_val;
entry.depth = depth;
entry.best_move = best_move;
if (best_val <= alpha_orig) {
entry.flag = BoundFlag::UPPER;
} else if (best_val >= beta) {
entry.flag = BoundFlag::LOWER;
} else {
entry.flag = BoundFlag::EXACT;
}
return best_val;
}
};
Production Pitfall — Caching Cut-Nodes as Exact Values & -INT_MIN Overflow
Two bugs silently break Negamax engines: First, when a node fails high ($ ext{best_val} ge eta$) or fails low ($ ext{best_val} le alpha_{ ext{orig}}$), the search did not compute the true minimax value—it only proved an inequality! Storing it as EXACT in the Transposition Table will poison future searches that probe the same position with a wider $[alpha, eta]$ window. Second, never set INF = INT_MAX and pass -INT_MIN; in two's complement signed 32-bit arithmetic, -INT_MIN overflows (UB) back to INT_MIN. Always use a safe sentinel like 1'000'000'000.