Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Hard Problems & Adversarial Search  ›  Lesson

Nim, Grundy Numbers & Winning Positions

Impartial Games 32 minXOR every heap together: zero means you're already losing, anything else names your winning move
You're building a piece ofMeridian — the bid desk
This piece — nim_winning_move(): When two bidders are deadlocked on identical terms, settles it with a Nim-style game whose winner is known before a single move is played.
Scenario Two bidders land on identical terms and the tender rules say the tie gets settled by a quick game: each takes stones from shared piles, and whoever can't move loses. The bid desk wants to know who wins before a single stone is taken -- the XOR of the piles is the entire answer.
Your task
Build nim_winning_move(heaps). heaps is a list of pile sizes in a game of Nim: two players alternate removing any positive number of stones from one heap, and whoever takes the last stone wins. XOR every heap size together. If the result is 0, the position is already losing for whoever moves next -- return None. Otherwise, find a heap h where h XOR (XOR of all heaps) is smaller than h, and return {"heap_index": ..., "remove": h - that smaller value, "new_size": that smaller value} -- the move that brings the total XOR to 0. Example: nim_winning_move([3, 4, 5]) -> heap_index 0, remove 2, new_size 1.

Nim, Grundy Numbers & Winning Positions

In short: in Nim, two players alternate taking any positive number of stones from one heap, and whoever takes the last stone wins. There's no searching needed to know who's winning: XOR every heap size together. If the result is 0, the player about to move is already losing against perfect play. If it's anything else, there's always a move — take from the heap whose own bit pattern can absorb the difference — that brings the XOR back to 0 and hands the loss to the opponent instead. Grundy numbers extend this idea past Nim to any "take-away" game: the Grundy number of a position is the mex (minimum excludant — the smallest non-negative integer not reachable in one move) of the Grundy numbers of everywhere one move can reach, and a position is a loss for the mover exactly when its Grundy number is 0.

Three heap rows (3, 4, 5 tally marks) and their binary columns, with a green XOR row reading 010 = 2 — not zero, so it's a win, and fixing heap 0 from 3 to 1 clears the row

Why XOR, and why zero loses

watch it move · grid

Each bit column is its own tiny vote. XOR of a column is 1 exactly when an odd number of heaps have a 1 there.

Column b2: 0, 1, 1 — two 1s, an even number, so XOR is 0. 4 by 3 table. 10 of 12 cells filled.

1 / 4

A losing position (XOR already 0) stays losing no matter what the mover does, because changing one heap always flips at least one bit somewhere, which can never bring an all-even column count back to all-even everywhere at once:

python

Finding the winning move

A winning position (XOR nonzero) always has at least one heap whose bits can absorb the difference — shrinking that one heap clears the whole XOR row:

What does this print?
def nim_winning_move(heaps):
    total = 0
    for h in heaps:
        total ^= h
    if total == 0:
        return None
    for i, h in enumerate(heaps):
        target = h ^ total
        if target < h:
            return (i, target)
    return None

print([nim_winning_move([3, 4, 5]), nim_winning_move([1, 1])])

Check the fix actually works by recomputing the XOR after the move — it should land on 0, handing the losing position to the opponent:

broken — fix it

This prints 1 instead of 0. Make the loop cover every heap, not heaps[1:].

Grundy numbers: Nim's idea, generalised

A single Nim heap of size n is trivially its own Grundy number — any move reaches any smaller size, so mex of {0, 1, ..., n-1} is n. The real value of the idea shows up on a different game: take 1, 2, or 3 stones from one heap (not plain Nim's "any amount"):

python

The pattern repeats every 4: 0,1,2,3,0,1,2,3,0. Every multiple of 4 is a loss for the mover — exactly what you'd find by hand-analysing this well-known game, just arrived at by the same mechanical mex rule that works on any take-away game, not only this one.

Quick practice

What does this print?
print(3 ^ 4 ^ 5, 1 ^ 1, 2 ^ 3 ^ 1)
What does this print?
def mex(values):
    result = 0
    while result in values:
        result += 1
    return result

print(mex({0, 1, 3}), mex({1, 2, 3}), mex(set()))

🎯 Your turn

Build nim_winning_move(heaps). XOR every heap together. If the result is 0, return None — the position is already a loss for whoever moves next. Otherwise find a heap h where h XOR total is smaller than h, and return {"heap_index": ..., "remove": ..., "new_size": ...} for the move that shrinks it there.

  • nim_winning_move([3, 4, 5]) → {"heap_index": 0, "remove": 2, "new_size": 1}.
  • nim_winning_move([1, 1]) → None.

Hint: for each heap in turn, compute h ^ total; the first one that's smaller than h names both the heap and its new size.

Press ▶ Run, try the Live App chips, then ✓ Check.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upXOR every heap together

Write xor_all(heaps), returning the XOR of every value in heaps. xor_all([3, 4, 5]) -> 2.

bit-manipulation
DrillIs this a loss for whoever moves next?

Write is_losing_position(heaps), returning True exactly when xor_all(heaps) is 0. is_losing_position([1, 1]) -> True. is_losing_position([3, 4, 5]) -> False.

bit-manipulationnim
BuildTake some stones

Write apply_move(heaps, heap_index, new_size), returning a NEW list of heaps with heaps[heap_index] replaced by new_size (the original list is left unchanged). apply_move([3, 4, 5], 0, 1) -> [1, 4, 5].

nim
BossThe mex rule, for a take-away game

Write grundy_number(n, moves): for a heap of size n where a move removes any amount in moves (leaving a smaller heap, size >= 0), the Grundy number is mex (minimum excludant: the smallest non-negative integer NOT in the set) of the Grundy numbers of every position reachable in one move. grundy_number(0, [1,2,3]) -> 0 (no moves possible). grundy_number(4, [1,2,3]) -> 0.

nimgrundy
CapstoneThe whole pattern, laid out

Write grundy_table(max_n, moves), returning [grundy_number(n, moves) for n in range(max_n + 1)]. grundy_table(8, [1,2,3]) -> [0,1,2,3,0,1,2,3,0].

nimgrundy
nim_winning_move.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
nim_winning_move(heaps) → dictA move that forces the XOR of all heaps to 0, or None if the position is already a loss for whoever moves next.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.