Random Tic-tac-toe

Ha-ha, you fool! You fell victim to one of the classic blunders, the most famous of which is “Never get involved in a land war in Asia,” but only slightly less well known is this: “Never go in a blog post expecting a game related article, because it may be a math related article!”

I am reading Probability and Computing: Randomization and Probabilistic Techniques in Algorithms and Data Analysis by Mitzenmacher and Upfal.

The exercises section of Chapter 1 presents the following tic-tac-toe question:

1.26(a): Each of the nine squares is labeled either x\textit{x} or o\textit{o} according to an independent and uniform coin flip. If only one of the players has one (or more) winning tic-tac-toe combinations, that player wins. Otherwise. the game is a tie. Determine the probability that x\textit{x} wins. (You may want to use a computer program to help run through the configurations.)

Not wanting to underutilize the KaTeX\KaTeX capabilities of my website, I decided to write up a solution. I did promise a math post, so the Probability comes first, and Computing second.

Math

This being a math post, let’s define a few sets to make the solution more concise.

Let the set of possible boards be SS, and the set of boards where x\text{x} wins be XX.

First, let’s determine the total number of possible boards, S|S|. Note that this isn’t a typical tic-tac-toe board, where the game stops after a single three-in-a-row.

There are 22 outcomes for a coin flip (or x\text{x} vs o\text{o}), and since each flip is independent, 99 flips to determine the labels for the board result in S=29=512|S| = 2^9 = 512 possible boards.

Secondly, let’s determine the number of boards where x\text{x} has one or more winning combinations, X|X|. This is a much tougher problem, and to work out the solution I split the problem into cases depending on the number of x\text{x}s on the board that result in a win.

  • n(x)=3:n(\text{x}) = 3: We can easily enumerate the cases here, resulting in a total of 88.

  • n(x)=4:n(\text{x}) = 4: Since we cannot have two winning lines with only 4 x\text{x}, we can think of the problem as the n(x)=3n(\text{x}) = 3 problem, but also choosing one of the 6 remaining cells to add another x\text{x}, resulting in 86=488 \cdot 6 = 48 cases.

  • n(x)=5:n(\text{x}) = 5: This requires a bit more enumeration. We can think of the number of winning cases as the difference between the total number of cases and the number of non-winning cases, so

    T={bS  n(x)=5}W={bX  n(x)=5}NW={bX  n(x)=5}W=TNW \begin{align*} T &= \{ b \in S\ |\ n(\text{x}) = 5\}\\ W &= \{ b \in X\ |\ n(\text{x}) = 5\}\\ NW &= \{ b \notin X\ |\ n(\text{x}) = 5\}\\ |W| &= |T| - |NW| \end{align*}

    The total number of boards with 5 of x\text{x} is ( 9  5 )\binom{\ 9\ }{\ 5\ }, as we are choosing 5 out of the 9 available cells to place an x\text{x} in. Therefore, T=( 9  5 )=126|T| = \binom{\ 9\ }{\ 5\ } = 126.

    There are 5 unique boards that are non-winning.

    Of these 5 boards, the first two are unique across all 8 transformations (4 rotations and 4 reflections), while the other three only have 4 unique transformations due to the symmetry shown by the line.

    NW=28+34=28W=12628=98 \begin{align*} |NW| &= 2 \cdot 8 + 3 \cdot 4 = 28\\ |W| &= 126 - 28 = 98 \end{align*}

  • n(x)=6:n(\text{x}) = 6: There are only two boards with 6 x\text{x} that are non-winning.

    This leaves ( 9  6 )2=842=82\binom{\ 9\ }{\ 6\ } - 2 = 84 - 2 = 82 winning boards.

  • n(x)=7:n(\text{x}) = 7: All boards are winning, therefore there are ( 9  7 )=36\binom{\ 9\ }{\ 7\ } = 36 boards.

  • n(x)=8:n(\text{x}) = 8: All boards are winning, therefore there are ( 9  8 )=9\binom{\ 9\ }{\ 8\ } = 9 boards.

  • n(x)=9:n(\text{x}) = 9: All boards are winning, therefore there is ( 9  9 )=1\binom{\ 9\ }{\ 9\ } = 1 board.

Adding them all up, we get 8+48+98+82+36+9+1=2828 + 48 + 98 + 82 + 36 + 9 + 1 = 282 winning boards out of 512512 boards total. Now it should be smooth sailing to find the probability that x\text{x} wins, which is 282/512=0.550855.1%282 / 512 = 0.5508 \approx 55.1\%!

ono

Fixing The Mistake

In hindsight, seeing x\text{x} winning with P>0.5P > 0.5 should raise some alarm bells, given that o\text{o} should win with the same probability. In another variant of this game, we could say that everyone wins and go home happy campers. Today though, there is only one spot on the podium for the luckiest tic-tac-toer (ticer-tacer-toer?).

Luckily, we did not do a whole bunch of work for nothing. From our winning boards, we need to prune ones that result in o\text{o} also winning. We go again!

  • n(x)=3:n(\text{x}) = 3: The new total for this is now 22, as the remaining 6 boards result in a win for o\text{o}.

  • n(x)=4:n(\text{x}) = 4: Similar to the previous section, the total is now 26=122 \cdot 6 = 12.

  • n(x)=5:n(\text{x}) = 5: Here we have another kerfuffle. Because we previously calculated the number of winning boards by subtracting non-winning boards from the total number of boards, we cannot simply subtract the winning boards that also result in an o\text{o} win.

    The situation to notice here is that where n(x)=5n(\text{x}) = 5, n(o)=4n(\text{o}) = 4. We know that in all the n(x)=5n(\text{x}) = 5 boards, there are 4848 cases where o\text{o} wins, since win(n(o)=4)=win(n(x)=4)=48\text{win}(n(\text{o}) = 4) = \text{win}(n(\text{x}) = 4) = 48.

    Going back to the n(x)=5n(\text{x}) = 5 non-winning boards:

    I’ve added the o\text{o}s onto two boards, and highlighted their respective wins. Including possible transformations, this means that out of the non-winning boards for x\text{x}, there are 8+4=128 + 4 = 12 winning boards for o\text{o}.

    Therefore, the number of winning boards for o\text{o} where x\text{x} is also winning is 4812=3648 - 12 = 36. This gives the total number of actual wins, ie. wins for x\text{x} where o\text{o} does not win, as 9836=6298 - 36 = 62 boards.

  • n(x)=6:n(\text{x}) = 6: We can utilize a similar, but simpler logic here. The two non-winning boards for x\text{x}:

    is winning for o\text{o}, which leaves the remaining 82=68 - 2 = 6 winning boards for o\text{o} in the winning boards for x\text{x}. So, using the total from the previous section, the new total is 826=7682 - 6 = 76 boards.

  • n(x){7,8,9}:n(\text{x}) \in \{7, 8, 9\}: Onto the home stretch! These cases make it impossible for o\text{o} to win, so the totals remain the same at 36,936, 9, and 11 respectively.

Our new and improved total is 2+12+62+76+36+9+1=1982 + 12 + 62 + 76 + 36 + 9 + 1 = 198 boards, giving a probability of 198/512=0.386738.7%198 / 512 = 0.3867 \approx 38.7\%. That’s much more reasonable!

I can fix that! Let’s write some code. Although I’m not confident my code’s any better than my math…

CS

I’ll be using Python today, for no reason other than I’ve been writing a lot of Rust lately and have been neglecting my first programming language. Let’s do it two slightly different ways, one by enumerating all possible tic-tac-toe boards, and another by randomly generating boards and calculating a running win percentage.

Combinatorics

To generate all possible boards without duplication, we can use the handy itertools.product. My oxidized iron brain can’t help introducing more type-safety into the duck-typed snake language, so we’ll use enumerations.

import enum
import itertools

class Player(enum.Enum):
    X = enum.auto()
    O = enum.auto()

    def __repr__(self):
        return f"{self.name}"

PLAYERS = list(Player)
for board in itertools.product(PLAYERS, repeat=9):
    # Prints:
    # (X, X, X, X, X, X, X, X, X)
    # (X, X, X, X, X, X, X, X, O)
    # (X, X, X, X, X, X, X, O, X)
    # (X, X, X, X, X, X, X, O, O)
    # (X, X, X, X, X, X, O, X, X)
    # ...
    print(board)

We can then write a function to test for a win from a particular player:

def is_win_for(board: tuple[Player, ...], player: Player) -> bool:
    INDICES = (
        ((0, 0), (0, 1), (0, 2)),
        ((1, 0), (1, 1), (1, 2)),
        ((2, 0), (2, 1), (2, 2)),
        ((0, 0), (1, 0), (2, 0)),
        ((0, 1), (1, 1), (2, 1)),
        ((0, 2), (1, 2), (2, 2)),
        ((0, 0), (1, 1), (2, 2)),
        ((0, 2), (1, 1), (2, 0)),
    )
    for indices in INDICES:
        if all(board[col * 3 + row] == player for (row, col) in indices):
            return True
    return False

Putting it all together:

total_boards = 0
wins = 0

PLAYERS = list(Player)
for board in itertools.product(PLAYERS, repeat=9):
    print(board)
    if is_win_for(board, Player.X) and not is_win_for(board, Player.O):
        wins += 1
    total_boards += 1

print(f"{wins}/{total_boards} = {wins / total_boards}")

And drumroll please…

$ python solver.py
98/512 = 0.38671875

Tough customer!

Simulation

Now, let’s do it more in the spirit of the question!

import random

total_boards = 0
wins = 0

PLAYERS = list(Player)
for i in range(10000001):
    # Show incremental progress
    if i % 1000000 == 0 and total_boards != 0:
        print(f"{i} iterations: {wins}/{total_boards} = {wins / total_boards}")

    # Our coin flip generated board
    board = tuple(random.choice(PLAYERS) for _ in range(9))

    if is_win_for(board, Player.X) and not is_win_for(board, Player.O):
        wins += 1
    total_boards += 1

Second drumroll please…

$ python solver.py
98/512 = 0.38671875
1000000 iterations: 387490/1000000 = 0.38749
2000000 iterations: 774802/2000000 = 0.387401
3000000 iterations: 1161301/3000000 = 0.3871003333333333
4000000 iterations: 1547835/4000000 = 0.38695875
5000000 iterations: 1934959/5000000 = 0.3869918
6000000 iterations: 2321540/6000000 = 0.38692333333333334
7000000 iterations: 2707923/7000000 = 0.38684614285714286
8000000 iterations: 3094900/8000000 = 0.3868625
9000000 iterations: 3482005/9000000 = 0.38688944444444445
10000000 iterations: 3868924/10000000 = 0.3868924

Close enough!

Conclusion

The math part of this post took a lot longer than the CS part of this post. I will stuff my fingers into my ears and chalk that up to me not being a math major. Please do let me know if there’s a more rigorous, less “proof-by-enumeration” math solution. I can be reached at feedback@<this website's domain name>.

The Exercise For The Reader is part (b) of the same problem:

1.26(b): x\textit{x} and o\textit{o} take turns, with the x\textit{x} player going first. On the x\textit{x} player’s turn, an x\textit{x} is placed on a square chosen independently and uniformly at random from the squares that are still vacant; o\textit{o} plays similarly. The first player to have a winning tic-tac-toe combination wins the game, and a tie occurs if neither player achieves a winning combination. Find the probability that each player wins. (Again, you may want to write a program to help you.)

Eat your veggies, and I’ll see you in the next post.