🚀 Alguni Start learning

Unit 18: Winning Games

Both players play perfectly.

Unit 18 of 25 in Competitive programming for kids. Its 4 lessons are Winning and Losing Places, Nim, Grundy Numbers and Many Heaps, Strange Rules — below is everything each one explains, and a question or two from it to try.

Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.

This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.

🕹️ Winning and Losing Places

A pile of sticks and one rule

Twelve sticks. Take 1, 2 or 3 on your turn. Whoever takes the last one wins.

Both players are perfect, so this is not about tactics: every position is already decided, and the question is only which.

The definition, and it is the whole subject

A position is winning if some move leads to a losing one. It is losing if every move leads to a winning one.

Zero sticks is losing — the player about to move has already lost. Everything else follows from there, smallest first.

Python

n = 12
moves = [1, 2, 3]
win = [False] * (n + 1)

for i in range(1, n + 1):
    for m in moves:
        if i - m >= 0 and not win[i - m]:
            win[i] = True

print(win)

It prints

[False, True, True, True, False, True, True, True, False, True, True, True, False]

The pattern falls out

Losing at 0, 4, 8, 12 — the multiples of four. And the reason is plain once you see it: whatever your opponent takes from a multiple of four, you take the rest of the four.

So the answer for a million sticks is n % 4 == 0, with no table at all. Building the small table to find the pattern is the standard contest move.

Python

n = 20
moves = [1, 2, 3]
win = [False] * (n + 1)
for i in range(1, n + 1):
    for m in moves:
        if i - m >= 0 and not win[i - m]:
            win[i] = True

print([i for i in range(n + 1) if not win[i]])
print([i for i in range(n + 1) if i % 4 == 0])

It prints

[0, 4, 8, 12, 16, 20]
[0, 4, 8, 12, 16, 20]

Change the rule and the pattern changes

Allow 1, 3 or 4 instead and the losing positions become 0, 2, 7, 9, 14, 16 — a pattern of period 7, and nobody would have guessed it.

That is why you always build the table first. Guessing the pattern before seeing it is how a wrong answer gets submitted with confidence.

Python

n = 20
moves = [1, 3, 4]
win = [False] * (n + 1)
for i in range(1, n + 1):
    for m in moves:
        if i - m >= 0 and not win[i - m]:
            win[i] = True

print([i for i in range(n + 1) if not win[i]])

It prints

[0, 2, 7, 9, 14, 16]

Try it yourself

When is a position losing?

  • When it has no moves left
  • When every move from it leads to a winning position
  • When some move leads to a losing position
  • When the number of sticks is even

What does this print?

Python

n = 6
moves = [2, 3]
win = [False] * (n + 1)
for i in range(1, n + 1):
    for m in moves:
        if i - m >= 0 and not win[i - m]:
            win[i] = True

print([i for i in range(n + 1) if not win[i]])

Answer them in the app

🪵 Nim

Several heaps at once

Heaps of 3, 4 and 5 sticks. On your turn take any number from one heap. Last stick wins.

The table from lesson 1 will not do: the positions are now triples of numbers, and there are far too many of them.

Xor the heap sizes

Here is the whole answer, and it is astonishing: the player to move loses exactly when the xor of all the heap sizes is 0.

3 xor 4 xor 5 is 2, so the first player wins. Nothing about this is obvious, which is why the next card checks it.

Python

piles = [3, 4, 5]

x = 0
for p in piles:
    x ^= p

print(x, x != 0)
print(1 ^ 4 ^ 5)

It prints

2 True
0

Checking it against a real search

Label every position of three small heaps the slow way — a position is winning if any move reaches a losing one — and compare with the xor rule. They agree everywhere.

This is exactly how you should treat a rule you cannot prove: search the small cases and see whether the rule survives.

Python

LIMIT = 5
memo = {}

def winning(a, b, c):
    if (a, b, c) in memo:
        return memo[(a, b, c)]
    piles = [a, b, c]
    result = False
    for i in range(3):
        for take in range(1, piles[i] + 1):
            nxt = piles[:]
            nxt[i] -= take
            if not winning(nxt[0], nxt[1], nxt[2]):
                result = True
    memo[(a, b, c)] = result
    return result

same = True
for a in range(LIMIT):
    for b in range(LIMIT):
        for c in range(LIMIT):
            if winning(a, b, c) != ((a ^ b ^ c) != 0):
                same = False

print(same)

It prints

True

And it tells you the move to play

If the xor is not 0, find a heap where pile ^ x is smaller than the pile, and cut it down to that. The new xor is 0, so your opponent is now the one in trouble.

From 3, 4, 5 with an xor of 2: 3 becomes 1, and 1 xor 4 xor 5 is 0.

Python

piles = [3, 4, 5]
x = 3 ^ 4 ^ 5

for i in range(len(piles)):
    if (piles[i] ^ x) < piles[i]:
        print(i, piles[i], piles[i] ^ x)

It prints

0 3 1

Try it yourself

The heaps are 1, 2 and 3. Who wins?

  • The player to move
  • The other player
  • It depends on the first move
  • Nobody — it is a draw

What does this print?

Python

piles = [7, 9, 12]
x = 0
for p in piles:
    x ^= p

print(x, "first" if x != 0 else "second")

Answer them in the app

🔢 Grundy Numbers

Winning and losing is not enough information

To combine several games you need more than "this one is lost". You need to know which Nim heap it behaves like.

That number is the position's Grundy value, and a Grundy value of 0 is exactly a losing position.

The smallest number not among the moves

Look at the Grundy values of every position you can move to. The value here is the smallest whole number missing from that list — usually called the mex.

For take-1-2-or-3 it comes out as n % 4, which is a nicer version of lesson 1's table: it says 0 at exactly the losing positions and tells you the heap size everywhere else.

Python

def grundy(n, moves):
    g = [0] * (n + 1)
    for i in range(1, n + 1):
        seen = set()
        for m in moves:
            if i - m >= 0:
                seen.add(g[i - m])
        x = 0
        while x in seen:
            x += 1
        g[i] = x
    return g

print(grundy(12, [1, 2, 3]))

It prints

[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0]

A rule where the values are not obvious at all

Takes of 1, 3 or 4 give values that repeat with period 7 and never get above 3.

So a heap of 5 under this rule plays exactly like a Nim heap of 3, and a heap of 7 is already lost.

Python

def grundy(n, moves):
    g = [0] * (n + 1)
    for i in range(1, n + 1):
        seen = set()
        for m in moves:
            if i - m >= 0:
                seen.add(g[i - m])
        x = 0
        while x in seen:
            x += 1
        g[i] = x
    return g

print(grundy(12, [1, 3, 4]))

It prints

[0, 1, 0, 1, 2, 3, 2, 0, 1, 0, 1, 2, 3]

And then you xor them, exactly like Nim

Several independent games played side by side: work out each one's Grundy value and xor them together. Zero means the player to move loses.

That is the Sprague–Grundy theorem, and it means every game of this kind is secretly Nim.

Try it yourself

A position can move to positions with Grundy values 0, 1 and 3. What is its own value?

  • 0
  • 2
  • 4
  • 3

What does this print?

Python

seen = set([0, 1, 2, 4])
x = 0
while x in seen:
    x += 1

print(x)

Answer them in the app

🏆 Many Heaps, Strange Rules

Now put the two ideas together

Three heaps — 5, 7 and 9 sticks — and on your turn you may take 1, 3 or 4 from one heap.

Neither lesson answers this alone. Lesson 3 gives each heap a Nim size; lesson 2 says what to do with several Nim heaps.

Python

def grundy(n, moves):
    g = [0] * (n + 1)
    for i in range(1, n + 1):
        seen = set()
        for m in moves:
            if i - m >= 0:
                seen.add(g[i - m])
        x = 0
        while x in seen:
            x += 1
        g[i] = x
    return g

g = grundy(9, [1, 3, 4])
piles = [5, 7, 9]
values = [g[p] for p in piles]

print(values)
print(values[0] ^ values[1] ^ values[2])

It prints

[3, 0, 0]
3

Three, so the first player wins

The heap of 5 behaves like a Nim heap of 3; the heaps of 7 and 9 are both worth 0 and might as well not be there.

So the whole position is a single Nim heap of 3 — and the winning move is whichever take makes the xor 0.

Checking the whole thing by brute force

Search the game tree of two small heaps and compare with the xor of the Grundy values. They agree on every position.

When a contest problem invents a new rule, this is the safety net: write the slow search, write the clever rule, and run them against each other before submitting.

Python

MOVES = [1, 3, 4]
LIMIT = 8

def grundy(n, moves):
    g = [0] * (n + 1)
    for i in range(1, n + 1):
        seen = set()
        for m in moves:
            if i - m >= 0:
                seen.add(g[i - m])
        x = 0
        while x in seen:
            x += 1
        g[i] = x
    return g

def winning(a, b, memo):
    if (a, b) in memo:
        return memo[(a, b)]
    result = False
    for take in MOVES:
        if a - take >= 0 and not winning(a - take, b, memo):
            result = True
        if b - take >= 0 and not winning(a, b - take, memo):
            result = True
    memo[(a, b)] = result
    return result

g = grundy(LIMIT, MOVES)
memo = {}
same = True
for a in range(LIMIT + 1):
    for b in range(LIMIT + 1):
        if winning(a, b, memo) != ((g[a] ^ g[b]) != 0):
            same = False

print(same)

It prints

True

What makes a game "of this kind"

Both players have the same moves available, the game always ends, and the last player to move wins. That is an impartial game, and Sprague–Grundy covers all of them.

Chess is not one — the players move different pieces. Nor is any game with a draw. Check that before reaching for xor.

Try it yourself

Two heaps have Grundy values 2 and 2. Who wins?

  • The player to move
  • The other player
  • It depends on the rule
  • Nobody

What does this print?

Python

g = [0, 1, 0, 1, 2, 3, 2, 0, 1, 0]
piles = [4, 6, 8]
values = [g[p] for p in piles]
print(values, values[0] ^ values[1] ^ values[2])

Answer them in the app