MEX — Middle Level¶
Table of Contents¶
- Introduction
- Deeper Concepts
- Dynamic MEX with Ordered Sets
- Range MEX Queries
- MEX in Game Theory — Sprague-Grundy
- Comparison with Alternatives
- Advanced Patterns
- Code Examples
- Error Handling
- Performance Analysis
- Best Practices
- Visual Animation
- Summary
Introduction¶
Focus: "Why is the bound MEX ≤ n tight?" and "When does MEX get hard?"
At the middle level, you stop treating MEX as a single algorithm and start treating it as a problem family: 1. Static whole-array MEX — O(n) bucket 2. Dynamic MEX with insert/delete — O(log n) per operation via ordered sets or segment trees 3. Range MEX on subarrays — O((n + q) log n) via persistent segment tree or offline Mo's 4. MEX in game theory — the operation that makes Sprague-Grundy work
You should be able to defend the algorithmic choice for any of these and avoid the classic competitive-programming mistake of "I'll just compute MEX from scratch on every query."
Deeper Concepts¶
Invariant: MEX ≤ n¶
For an input set S of size n (or array of length n with duplicates), MEX(S) ≤ n. The proof: the set {0, 1, ..., n} has n+1 elements. By pigeonhole at least one is missing from S. So you only need to track values in [0, n].
This is the structural invariant powering every MEX algorithm. Any approach that scales with max(S) instead of n is wasting work for static MEX.
When MEX bound changes¶
For dynamic MEX the bound is the current set size |S|. After deletes, MEX may drop below the historical max-MEX. After inserts, it may rise but never above |S|.
For range MEX the bound is the subarray length, not the array length. MEX(a[l..r]) ≤ r - l + 1.
MEX is not monotone under insertions¶
A common bug: assuming MEX(S ∪ {v}) ≥ MEX(S). Counter-example:
That direction does hold (MEX cannot decrease on insert). But deletes can drop MEX, sometimes drastically. Tracking MEX under arbitrary updates requires a structure, not a counter.
Recurrence: amortized MEX over a stream¶
If you add elements one at a time and want MEX after each add, the total work using a "scan from current MEX upward" trick is O(n + max-final-MEX) = O(n), amortized O(1) per insert. The MEX pointer monotonically increases under insert-only workloads.
For inserts only: MEX is monotone non-decreasing.
After each insert: advance MEX while seen[MEX] is true.
Total advances across n inserts: O(n).
Dynamic MEX with Ordered Sets¶
When the set supports insert(v), delete(v), and query_mex(), you need a structure better than a bucket scan from 0.
Approach 1: TreeSet of "missing" values¶
Maintain a TreeSet<int> containing every non-negative integer not currently in the set, capped at some upper bound (e.g., n+1 or 2×|S|).
insert(v): removevfrom the missing setdelete(v): addvto the missing setmex(): return the minimum of the missing set — O(log n)
Java¶
import java.util.TreeSet;
import java.util.HashSet;
public class DynamicMEX {
private final TreeSet<Integer> missing = new TreeSet<>();
private final HashSet<Integer> present = new HashSet<>();
private final int cap;
public DynamicMEX(int cap) {
this.cap = cap;
for (int i = 0; i <= cap; i++) missing.add(i);
}
public void insert(int v) {
if (v < 0 || v > cap) return;
if (present.add(v)) missing.remove(v);
}
public void delete(int v) {
if (v < 0 || v > cap) return;
if (present.remove(v)) missing.add(v);
}
public int mex() {
return missing.first();
}
}
Approach 2: Segment tree over [0, n]¶
Each leaf i is 1 if i is missing, else 0. Internal nodes store the sum or min-index of a missing value. mex() is a descent on the tree: - If left subtree has at least one missing value, go left. - Else go right and add mid+1 offset.
Both insert/delete and mex are O(log n).
Python: segment tree on missing flags¶
class MEXSegTree:
def __init__(self, cap):
self.cap = cap
self.size = 1
while self.size <= cap:
self.size *= 2
# Each node stores count of *missing* values in its range
self.tree = [0] * (2 * self.size)
# Initially every value 0..cap is missing
for i in range(cap + 1):
self.tree[self.size + i] = 1
for i in range(self.size - 1, 0, -1):
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
self.count = [0] * (cap + 1) # multiplicity per value
def insert(self, v):
if not (0 <= v <= self.cap):
return
self.count[v] += 1
if self.count[v] == 1:
self._set(v, 0)
def delete(self, v):
if not (0 <= v <= self.cap) or self.count[v] == 0:
return
self.count[v] -= 1
if self.count[v] == 0:
self._set(v, 1)
def _set(self, i, val):
i += self.size
self.tree[i] = val
i //= 2
while i:
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
i //= 2
def mex(self):
# Descend: leftmost leaf with value 1
node = 1
while node < self.size:
if self.tree[2*node] > 0:
node = 2*node
else:
node = 2*node + 1
return node - self.size
insert, delete, mex are all O(log n).
Range MEX Queries¶
Problem: given a static array a[0..n-1] and many queries (l, r), compute MEX of a[l..r].
This is much harder than whole-array MEX because the answer depends on the chosen subrange.
Approach 1: Offline, Mo's algorithm¶
Sort queries by Mo's block ordering. Maintain a frequency array and a mex pointer that you advance/retreat as elements enter/leave the window. Total complexity: O((n + q) · √n · log n) — the log factor comes from maintaining the missing-min via a structure.
A cleaner variant uses a sqrt-decomposition over value buckets so each insert/delete is O(√n) and mex is O(√n), giving O((n + q) · √n) total.
Approach 2: Persistent segment tree¶
For each prefix a[0..i], store a persistent segment tree mapping value v to its last occurrence index. To answer (l, r), descend the segment tree of prefix r: a value v is missing in [l, r] iff its last occurrence < l. Find the leftmost such value. Query: O(log n). Total: O((n + q) log n).
Python (sketch — persistent seg tree)¶
# pseudo-code: a persistent segment tree over values 0..n.
# Each version is the state after seeing a[0..i].
# leaf v stores: last index j where a[j] = v (or -1 if none).
# internal nodes store: min of leaf values in their subtree.
# Query (l, r): descend version r; at each node, if left subtree's min < l,
# go left; else go right. Leaf v with min < l is missing.
def range_mex(versions, l, r):
node = versions[r]
lo, hi = 0, len(versions)
while lo < hi:
mid = (lo + hi) // 2
if node.left.min < l:
node = node.left
hi = mid
else:
node = node.right
lo = mid + 1
return lo
Approach 3: Wavelet tree / merge sort tree¶
Variations exist with O(log² n) per query and O(n log n) build. In contest contexts, persistent seg tree wins.
MEX in Game Theory — Sprague-Grundy¶
The Sprague-Grundy theorem says every impartial game position has a non-negative integer called its Grundy number (or Nim value), and:
A position is losing for the player to move iff G(position) = 0. The XOR of Grundy numbers tells you the value of a sum of games.
Example: Subtraction game¶
You have a pile of n stones. On each turn, remove 1, 3, or 4. Last to move wins.
from functools import lru_cache
@lru_cache(maxsize=None)
def grundy(n):
if n == 0:
return 0
reach = set()
for move in (1, 3, 4):
if n >= move:
reach.add(grundy(n - move))
g = 0
while g in reach:
g += 1
return g
print([grundy(i) for i in range(15)])
# [0, 1, 0, 1, 2, 3, 2, 0, 1, 0, 1, 2, 3, 2, 0]
You will write this mex of reachable grundies pattern in every Sprague-Grundy problem. Master it.
Why MEX specifically?¶
The MEX choice is not arbitrary. It makes Sprague-Grundy compose with XOR under game-sum. If G were some other function (e.g., max + 1), the elegant XOR equivalence with Nim would fail. The Bouton/Sprague proof uses MEX's "first missing" property to construct a winning strategy.
Comparison with Alternatives¶
| Setting | Best Structure | Time/op | Space |
|---|---|---|---|
| Static whole-array MEX | Bucket array | O(n) total | O(n) |
| Dynamic insert/delete | TreeSet of missing OR seg tree | O(log n) | O(n) |
| Range MEX (offline) | Mo's + sqrt-decomp | O((n+q)√n) | O(n) |
| Range MEX (online) | Persistent seg tree | O(log n) per query | O(n log n) |
| Sprague-Grundy small states | Memoized recursion + set scan | O(states · max-G) | O(states) |
Choose static bucket when MEX is computed once. Choose ordered set / seg tree when the underlying set changes between MEX calls. Choose persistent seg tree when the array is fixed and many range queries hit it. Choose offline Mo's when range queries are not interleaved with updates and you want simpler code.
Advanced Patterns¶
Pattern: Incremental MEX under insert-only workloads¶
When you only insert (never delete) and want MEX after each insert, keep a single int mex and a bucket. After each insert, advance mex while seen[mex] is true. Total work O(n) amortized.
Go¶
type IncMEX struct {
seen []bool
mex int
}
func NewIncMEX(cap int) *IncMEX {
return &IncMEX{seen: make([]bool, cap+2)}
}
func (m *IncMEX) Insert(v int) {
if v >= 0 && v < len(m.seen) {
m.seen[v] = true
}
for m.mex < len(m.seen) && m.seen[m.mex] {
m.mex++
}
}
func (m *IncMEX) MEX() int { return m.mex }
Amortized O(1) per insert.
Pattern: MEX over multisets with counters¶
For a multiset, track multiplicity per value. MEX still depends only on which values are present (count ≥ 1), so the structures above adapt by toggling presence on 0 → 1 and 1 → 0 transitions.
Pattern: Bitset MEX with hardware speedup¶
For very large bucket arrays, use a bitset and __builtin_ffsll (C/C++) or BitSet.nextClearBit (Java) to find the first zero bit in O(n/64) words.
Java¶
import java.util.BitSet;
public static int mexBitset(int[] arr) {
int n = arr.length;
BitSet seen = new BitSet(n + 1);
for (int v : arr) if (v >= 0 && v <= n) seen.set(v);
return seen.nextClearBit(0);
}
nextClearBit is hardware-accelerated and beats a manual scan.
Code Examples¶
Full dynamic MEX in three languages¶
Go: TreeSet via sort.Search on a sorted slice (toy)¶
For Go without a built-in TreeSet, use github.com/emirpasic/gods or a segment tree. Below is a segment-tree-based dynamic MEX.
package main
import "fmt"
type DynMEX struct {
cap int
size int
tree []int
count map[int]int
}
func NewDynMEX(cap int) *DynMEX {
sz := 1
for sz <= cap {
sz *= 2
}
m := &DynMEX{cap: cap, size: sz, tree: make([]int, 2*sz), count: map[int]int{}}
for i := 0; i <= cap; i++ {
m.tree[sz+i] = 1
}
for i := sz - 1; i > 0; i-- {
m.tree[i] = m.tree[2*i] + m.tree[2*i+1]
}
return m
}
func (m *DynMEX) set(i, val int) {
i += m.size
m.tree[i] = val
for i /= 2; i > 0; i /= 2 {
m.tree[i] = m.tree[2*i] + m.tree[2*i+1]
}
}
func (m *DynMEX) Insert(v int) {
if v < 0 || v > m.cap {
return
}
m.count[v]++
if m.count[v] == 1 {
m.set(v, 0)
}
}
func (m *DynMEX) Delete(v int) {
if v < 0 || v > m.cap || m.count[v] == 0 {
return
}
m.count[v]--
if m.count[v] == 0 {
m.set(v, 1)
}
}
func (m *DynMEX) MEX() int {
node := 1
for node < m.size {
if m.tree[2*node] > 0 {
node = 2 * node
} else {
node = 2*node + 1
}
}
return node - m.size
}
func main() {
m := NewDynMEX(10)
m.Insert(0); m.Insert(1); m.Insert(2)
fmt.Println(m.MEX()) // 3
m.Insert(4)
fmt.Println(m.MEX()) // 3
m.Delete(1)
fmt.Println(m.MEX()) // 1
}
Java: TreeSet-of-missing¶
import java.util.TreeSet;
import java.util.HashMap;
public class DynamicMEXTreeSet {
private final TreeSet<Integer> missing = new TreeSet<>();
private final HashMap<Integer, Integer> count = new HashMap<>();
private final int cap;
public DynamicMEXTreeSet(int cap) {
this.cap = cap;
for (int i = 0; i <= cap; i++) missing.add(i);
}
public void insert(int v) {
if (v < 0 || v > cap) return;
int c = count.getOrDefault(v, 0);
if (c == 0) missing.remove(v);
count.put(v, c + 1);
}
public void delete(int v) {
if (v < 0 || v > cap) return;
Integer c = count.get(v);
if (c == null || c == 0) return;
count.put(v, c - 1);
if (c - 1 == 0) missing.add(v);
}
public int mex() {
return missing.first();
}
public static void main(String[] args) {
DynamicMEXTreeSet m = new DynamicMEXTreeSet(10);
m.insert(0); m.insert(1); m.insert(2);
System.out.println(m.mex()); // 3
m.insert(4);
System.out.println(m.mex()); // 3
m.delete(1);
System.out.println(m.mex()); // 1
}
}
Python: SortedList-of-missing¶
from sortedcontainers import SortedList
from collections import Counter
class DynamicMEX:
def __init__(self, cap):
self.cap = cap
self.missing = SortedList(range(cap + 1))
self.count = Counter()
def insert(self, v):
if not (0 <= v <= self.cap):
return
if self.count[v] == 0:
self.missing.discard(v)
self.count[v] += 1
def delete(self, v):
if not (0 <= v <= self.cap) or self.count[v] == 0:
return
self.count[v] -= 1
if self.count[v] == 0:
self.missing.add(v)
def mex(self):
return self.missing[0]
if __name__ == "__main__":
m = DynamicMEX(10)
for v in (0, 1, 2):
m.insert(v)
print(m.mex()) # 3
m.insert(4)
print(m.mex()) # 3
m.delete(1)
print(m.mex()) # 1
Error Handling¶
| Scenario | What goes wrong | Correct approach |
|---|---|---|
| Insert value outside cap | Out-of-bounds in bucket / tree | Guard with 0 ≤ v ≤ cap |
| Delete value not present | Counter goes negative | Check count[v] > 0 first |
mex() on empty multiset | Different from MEX(∅) of "values 0..cap missing" | Initialize missing set with {0..cap} so empty multiset has mex = 0 |
| Cap too small | Real MEX exceeds tracked range | Grow cap or use unbounded structure |
| Recomputing MEX each query in a dynamic setting | O(n) per query, TLE | Use TreeSet-of-missing or seg tree |
Performance Analysis¶
Benchmark: 100k inserts, then 100k MEX queries.
| Approach | Build | Per-op MEX |
|---|---|---|
| Recompute bucket on each query | — | O(n) |
| TreeSet of missing | O(n log n) | O(log n) |
| Segment tree | O(n) | O(log n) |
BitSet nextClearBit (static) | O(n/64) | O(n/64) |
For dynamic workloads with n = 10^5 and q = 10^5, TreeSet/seg-tree solutions complete in ~0.2 s. Recompute fails (~10+ s).
Python benchmark sketch¶
import time
from sortedcontainers import SortedList
def bench(n=100_000):
m = SortedList(range(n + 1))
count = [0] * (n + 1)
t0 = time.time()
for i in range(n):
v = i % (n // 2)
if count[v] == 0:
m.discard(v)
count[v] += 1
for _ in range(n):
_ = m[0]
print(f"{n} mixed ops in {(time.time() - t0)*1000:.1f} ms")
bench()
Best Practices¶
- Pick the structure once you know the workload pattern. If it's just one MEX call, bucket. If many under updates, TreeSet/seg tree.
- Cap the value range explicitly at construction. Lazily growing the structure adds complexity for little gain.
- Wrap with a unit test that exercises empty, full-coverage, all-zero, and worst-case-after-deletes inputs.
- In contests, prefer TreeSet when the language has one (Java, C++ STL
std::set, PythonSortedList). Less code than a custom segment tree, same complexity. - For Sprague-Grundy, memoize the recursion and keep state compact — Grundy tables explode otherwise.
- Profile before reaching for persistent segment trees. They are powerful but heavy; offline Mo's is often simpler and fast enough.
Visual Animation¶
See
animation.htmlfor interactive MEX variants.Middle-level animation includes: - Dynamic MEX: insert/delete and live MEX update - Side-by-side: bucket recompute vs TreeSet-of-missing - Range MEX query on a fixed array (Mo's window movement) - Sprague-Grundy table buildup for a subtraction game - Cap-overflow demonstration: what happens when MEX > tracked range
Summary¶
At middle level, MEX is no longer one algorithm but a family selected by workload. For static, bucket; for dynamic, TreeSet-of-missing or segment tree; for range queries, persistent segment tree or Mo's; for game theory, the MEX-of-reachable pattern. The invariant MEX ≤ |S| keeps every variant in linear space, and most dynamic operations land at O(log n) per call. The hardest part is recognizing which MEX problem you actually have.