Ultra-efficient Bit-boarding
Let's store the game in a binary integer, and evaluate everything using just one step!
- We know that X's moves occupy 9 bits:
xxx xxx xxx
- We know that O's moves occupy 9 bits:
ooo ooo ooo
So, a board position could be represented in just 18 bits: xoxoxo xoxoxo xoxoxo
But, whilst this might look efficient, it doesn't help us with determining a win. We need a more useful bit pattern... one that not only encodes the moves, but also encodes the rows, columns and diagonals in a reasonable way.
I would do this by using a clever integer value for each board position.
Choosing a more useful representation
First, we need a board notation, just so that we can discuss this. So, similar to Chess, lets number the rows with letters and the columns with numbers - so we know which square we're talking about
|
1 |
2 |
3 |
A |
a1 |
a2 |
a3 |
B |
b1 |
b2 |
b3 |
C |
c1 |
c2 |
c3 |
And let's give each a binary value.
a1 = 100 000 000 100 000 000 100 000 ; Row A Col 1 (top left corner)
a2 = 010 000 000 000 100 000 000 000 ; Row A Col 2 (top edge)
a3 = 001 000 000 000 000 100 000 100 ; Row A Col 3 (top right corner)
b1 = 000 100 000 010 000 000 000 000 ; Row B Col 1 (left edge)
b2 = 000 010 000 000 010 000 010 010 ; Row B Col 2 (middle square)
b3 = 000 001 000 000 000 010 000 000 ; Row B Col 4 (right edge)
c1 = 000 000 100 001 000 000 000 001 ; Row C Col 1 (bottom left corner)
c2 = 000 000 010 000 001 000 000 000 ; Row C Col 2 (bottom edge)
c3 = 000 000 001 000 000 001 001 000 ; Row C Col 3 (bottom right corner)
... where, the binary values encode which rows, columns and diagonals the position appears in. (we'll look at how this works this later)
We will use these values to build two representations of the game, one for X and one for O
- X starts with an empty board :
000 000 000 000 000 000 000 000
- O starts with an empty board :
000 000 000 000 000 000 000 000
Let's follow X's moves (O would be the same principle)
- X plays A1... so we OR (the X board) with value A1
- X plays A2... so we OR with value A2
- X plays A3... so we OR with value A3
What does that do to X's board value :
a1 = 100 000 000 100 000 000 100 000
... ORed with
a2 = 010 000 000 000 100 000 000 000
... ORed with
a3 = 001 000 000 000 000 100 000 100
... equals :
XB = 111 000 000 100 100 100 100 100
Reading from left to right we see that X has :
111
(All positions) in Row 1 (\o/ A win, Yay!)
000
(No positions) in Row 2
000
(No positions) in Row 3
100
(One position) Only the first position of Column 1
100
(One position) Only the first position of Column 1
100
(One position) Only the first position of Column 1
100
(One position) Only the first position of Diagonal 1
100
(One position) Only the first position of Diagonal 2
You'll notice that whenever X (or O) has a winning line, then there will also be three consecutive bits in his board value. Precisely Where those three bits are, dictates which row/column/diagonal he won on.
So, the trick now is to find a way to check for this (three consecutive bits set) condition in a single operation.
Modifying the values to make detection easier
To assist with this, let's change our bit representation so that there are always ZEROs between the groups of three (Because 001 110
is also three consecutive bits - but they are NOT a valid win ... so, a fixed zero spacer would break these up: 0 001 0 110
)
So, after adding some spacing ZEROes, we can be confident that ANY three consecutive set bits in X's or O's board value indicates a win!
So, our new binary values (with zero-padding) look like this :
a1 = 100 0 000 0 000 0 100 0 000 0 000 0 100 0 000 0
; 0x80080080 (hex)
a2 = 010 0 000 0 000 0 000 0 100 0 000 0 000 0 000 0
; 0x40008000
a3 = 001 0 000 0 000 0 000 0 000 0 100 0 000 0 100 0
; 0x20000808
b1 = 000 0 100 0 000 0 010 0 000 0 000 0 000 0 000 0
; 0x08040000
b2 = 000 0 010 0 000 0 000 0 010 0 000 0 010 0 010 0
; 0x04004044
b3 = 000 0 001 0 000 0 000 0 000 0 010 0 000 0 000 0
; 0x02000400
c1 = 000 0 000 0 100 0 001 0 000 0 000 0 000 0 001 0
; 0x00820002
c2 = 000 0 000 0 010 0 000 0 001 0 000 0 000 0 000 0
; 0x00402000
c3 = 000 0 000 0 001 0 000 0 000 0 001 0 001 0 000 0
; 0x00200220
You'll notice that each "winline" of the board now requires 4 bits.
8 winlines x 4 bits each = 32 bits! Isn't that convenient : )))))
Parsing
We could shift through all the bits looking for three consecutive bits, but that will take 32 shifts x 2 players... and a counter to keep track. It's slow!
We could AND with 0xF, looking for the value 8+4+2=14. And this would allow us to check 4 bits at a time. Cutting the number of shifts by a quarter. But again, this is slow!
So, instead, let's check ALL of the possibilities at once...
Ultra-efficient win detection
Imagine we wanted to evaluate the A3+A1+B2+C3 case (a win on the diagonal)
a1 = 100 0 000 0 000 0 100 0 000 0 000 0 100 0 000 0, OR
a3 = 001 0 000 0 000 0 000 0 000 0 100 0 000 0 100 0, OR
b2 = 000 0 010 0 000 0 000 0 010 0 000 0 010 0 010 0, OR
c3 = 000 0 000 0 001 0 000 0 000 0 001 0 001 0 000 0, =
XB = 101 0 010 0 001 0 100 0 010 0 101 0 111 0 110 0 (See the win, on Diagonal 1?)
Now, let's check it for a win, by efficiently merging three bits into one...
Simply use : XB AND (XB << 1) AND (XB >> 1)
in other words: XB ANDed with (XB shifted left) AND (XB shiftted right)
Let's try an example...
10100100001010000100101011101100 ; whitespaces removed for easy shifting
(AND)
01001000010100001001010111011000 ; XB shifted left
(AND)
01010010000101000010010101110110 ; XB shifted left
(Equals)
00000000000000000000000001000000
See that? Any non-zero result means a win!
But, where did they win
Want to know where they won? Well, you could just use a second table :
0x40000000 = RowA
0x04000000 = RowB
0x00400000 = RowC
0x00040000 = Col1
0x00004000 = Col2
0x00000400 = Col3
0x00000040 = Diag1
0x00000004 = Diag2
However, we can be smarter than that, as the pattern is VERY regular!
For example, in assembly you can use BSF (Bit Scan Forward)
to find the number of leading zeros. Then subtract 2 and then /4 (Shift Right 2) - to get a number between 0 and 8... which you can use as an index to look up into an array of win strings :
{"wins the top row", "takes the middle row!", ... "steals the diagonal!" }
This makes the whole game logic... from move checking, to board updating and right through to win/loss detection and an appropriate success message, all fit in a handful of ASM instructions.
... it's tiny, efficient and ultrafast!
Checking whether a move is playable
Obviously, ORing "X's board" with "O's board" = ALL POSITIONS
So, you can check if a move is valid quite easily. If user chooses UpperLeft, this position has an integer value. Just check the 'AND' of this Value with (XB OR OB)...
... if the result is nonzero, then the position is already in use.
Conclusion
If you're looking for efficient ways to process a board, don't start with a board object. Try to discover some useful abstraction.
See if the states fit within an integer, and think about what an 'easy' bitmask to process would look like. With some clever choice of integers to represent moves, positions or boards... you might find that the entire game can be played, evaluated and scored VERY efficiently - using simply bitwise logic.
Closing apologies
BTW I'm not a regular here on StackOverflow, so I hope this post wasn't too chaotic to follow. Also, please be kind... "Human" is my second language and I'm not quite fluent yet ;)
Anyway, I hope this helps someone.