LeetCode 348 - Design Tic-Tac-Toe
The problem asks us to design a data structure that simulates an n x n Tic-Tac-Toe game between two players. We need to implement a class that supports two operations: - Initializing a game board of size n - Processing moves one at a time and immediately determining whether…
Difficulty: 🟡 Medium
Topics: Array, Hash Table, Design, Matrix, Simulation
Solution
Problem Understanding
The problem asks us to design a data structure that simulates an n x n Tic-Tac-Toe game between two players. We need to implement a class that supports two operations:
- Initializing a game board of size
n - Processing moves one at a time and immediately determining whether the current move causes a player to win
A move is represented by three values:
row, the row index where the player places a markcol, the column index where the player places a markplayer, either1or2
After every move, we must return:
0if nobody has won yet1if player 1 wins2if player 2 wins
The winning condition is standard Tic-Tac-Toe logic:
- A complete row
- A complete column
- The main diagonal
- The anti-diagonal
The important part of this problem is the follow-up question. A naive implementation would check the entire board after every move, which can be expensive. The problem explicitly asks whether we can do better than O(n^2) per move.
The constraints help guide the expected solution:
2 <= n <= 100- At most
n^2moves - Every move is valid
- No duplicate moves
- No moves after someone wins
These guarantees simplify the implementation because we do not need to validate moves or handle invalid game states.
Several edge cases are important:
- A player may win on the main diagonal
- A player may win on the anti-diagonal
- A move may belong to both diagonals simultaneously, such as the center cell of an odd-sized board
- Small boards like
2 x 2can produce wins quickly - Large boards require efficient move processing
The key challenge is designing move() so that each call runs in constant time instead of scanning the board repeatedly.
Approaches
Brute Force Approach
The most straightforward implementation is to store the entire board and, after every move, scan:
- The entire row
- The entire column
- The main diagonal if applicable
- The anti-diagonal if applicable
This works because a player wins only if all cells in one of these lines belong to that player.
For example, after placing a mark at (row, col):
- Check whether every cell in
board[row]belongs to the same player - Check whether every cell in column
colbelongs to the same player - If
row == col, check the main diagonal - If
row + col == n - 1, check the anti-diagonal
This approach is correct because it directly verifies the winning conditions after each move.
However, it is inefficient. Each move may require scanning up to O(n) cells for rows, columns, and diagonals. If implemented carelessly by scanning the entire board, it could even become O(n^2) per move.
Optimal Approach
The key insight is that we do not need to store or inspect the entire board repeatedly.
A player wins only when they completely fill:
- One row
- One column
- One diagonal
Instead of storing the board state, we can maintain running counts:
rows[i]tracks the cumulative score for rowicols[j]tracks the cumulative score for columnjdiagonaltracks the main diagonalanti_diagonaltracks the anti-diagonal
We use this trick:
- Player 1 contributes
+1 - Player 2 contributes
-1
If any counter reaches:
+n, player 1 wins-n, player 2 wins
This works because only one player's marks can occupy a line completely.
For example, suppose n = 3.
If row 2 becomes:
X X X
Then the row counter becomes:
+1 +1 +1 = +3
Similarly, if player 2 fills a column:
O O O
The column counter becomes:
-1 -1 -1 = -3
This allows every move to run in constant time.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n) per move | O(n²) | Stores full board and scans rows, columns, and diagonals |
| Optimal | O(1) per move | O(n) | Tracks row and column counts incrementally |
Algorithm Walkthrough
- Initialize four tracking structures:
rows, an array of sizencols, an array of sizendiagonal, an integeranti_diagonal, an integer
These structures keep cumulative scores for each line on the board. 2. Convert the player into a numeric value.
- Player 1 becomes
+1 - Player 2 becomes
-1
Using opposite signs allows both players to share the same counters cleanly. 3. Update the row counter.
Add the player's value to rows[row].
4. Update the column counter.
Add the player's value to cols[col].
5. Check whether the move lies on the main diagonal.
A cell belongs to the main diagonal when:
$row = col$
If true, update the diagonal counter. 6. Check whether the move lies on the anti-diagonal.
A cell belongs to the anti-diagonal when:
$row + col = n - 1$
If true, update the anti-diagonal counter. 7. Check for a winning condition.
If the absolute value of any updated counter becomes n, then one player has completely filled that line.
$|counter| = n$ 8. Return the winner if found.
- Return
1if the counter is positive - Return
2if the counter is negative
- If no counter reaches
nor-n, return0.
Why it works
The algorithm maintains an invariant:
- Every row, column, and diagonal counter equals the sum of contributions from moves placed in that line.
Because player 1 contributes +1 and player 2 contributes -1, a line can reach magnitude n only if all n cells belong to the same player. Therefore, checking whether a counter equals n or -n correctly identifies a winner immediately after each move.
Python Solution
class TicTacToe:
def __init__(self, n: int):
self.n = n
self.rows = [0] * n
self.cols = [0] * n
self.diagonal = 0
self.anti_diagonal = 0
def move(self, row: int, col: int, player: int) -> int:
value = 1 if player == 1 else -1
self.rows[row] += value
self.cols[col] += value
if row == col:
self.diagonal += value
if row + col == self.n - 1:
self.anti_diagonal += value
if (
abs(self.rows[row]) == self.n
or abs(self.cols[col]) == self.n
or abs(self.diagonal) == self.n
or abs(self.anti_diagonal) == self.n
):
return player
return 0
# Your TicTacToe object will be instantiated and called as such:
# obj = TicTacToe(n)
# param_1 = obj.move(row, col, player)
The constructor initializes the bookkeeping arrays and diagonal counters. We do not store the full board because the problem only requires winner detection.
Inside move(), the current player is converted into either +1 or -1. This allows both players to share the same counters elegantly.
The row and column counters are updated immediately. If the move lies on either diagonal, the corresponding diagonal counter is updated as well.
Finally, the method checks whether any relevant counter has magnitude n. If so, the current player has completed a winning line, so we return that player number. Otherwise, the game continues and we return 0.
Go Solution
type TicTacToe struct {
n int
rows []int
cols []int
diagonal int
antiDiagonal int
}
func Constructor(n int) TicTacToe {
return TicTacToe{
n: n,
rows: make([]int, n),
cols: make([]int, n),
diagonal: 0,
antiDiagonal: 0,
}
}
func (this *TicTacToe) Move(row int, col int, player int) int {
value := 1
if player == 2 {
value = -1
}
this.rows[row] += value
this.cols[col] += value
if row == col {
this.diagonal += value
}
if row+col == this.n-1 {
this.antiDiagonal += value
}
if abs(this.rows[row]) == this.n ||
abs(this.cols[col]) == this.n ||
abs(this.diagonal) == this.n ||
abs(this.antiDiagonal) == this.n {
return player
}
return 0
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
/**
* Your TicTacToe object will be instantiated and called as such:
* obj := Constructor(n)
* param_1 := obj.Move(row, col, player)
*/
The Go implementation mirrors the Python logic closely. Since Go does not provide a built-in integer abs() function, we define a helper manually.
Slices are used for rows and cols, which behave similarly to dynamic arrays in Python. The struct stores all counters directly.
Because Go passes structs by value by default, the Move() method uses a pointer receiver so updates persist across method calls.
Worked Examples
Example 1
n = 3
Initial state:
| Variable | Value |
|---|---|
| rows | [0, 0, 0] |
| cols | [0, 0, 0] |
| diagonal | 0 |
| anti_diagonal | 0 |
Move 1
move(0, 0, 1)
Player 1 contributes +1.
| Structure | Updated Value |
|---|---|
| rows[0] | 1 |
| cols[0] | 1 |
| diagonal | 1 |
State:
| Variable | Value |
|---|---|
| rows | [1, 0, 0] |
| cols | [1, 0, 0] |
| diagonal | 1 |
| anti_diagonal | 0 |
No counter reaches 3.
Return 0.
Move 2
move(0, 2, 2)
Player 2 contributes -1.
| Structure | Updated Value |
|---|---|
| rows[0] | 0 |
| cols[2] | -1 |
| anti_diagonal | -1 |
State:
| Variable | Value |
|---|---|
| rows | [0, 0, 0] |
| cols | [1, 0, -1] |
| diagonal | 1 |
| anti_diagonal | -1 |
Return 0.
Move 3
move(2, 2, 1)
Player 1 contributes +1.
| Structure | Updated Value |
|---|---|
| rows[2] | 1 |
| cols[2] | 0 |
| diagonal | 2 |
State:
| Variable | Value |
|---|---|
| rows | [0, 0, 1] |
| cols | [1, 0, 0] |
| diagonal | 2 |
| anti_diagonal | -1 |
Return 0.
Move 4
move(1, 1, 2)
Player 2 contributes -1.
This move belongs to both diagonals.
| Structure | Updated Value |
|---|---|
| rows[1] | -1 |
| cols[1] | -1 |
| diagonal | 1 |
| anti_diagonal | -2 |
State:
| Variable | Value |
|---|---|
| rows | [0, -1, 1] |
| cols | [1, -1, 0] |
| diagonal | 1 |
| anti_diagonal | -2 |
Return 0.
Move 5
move(2, 0, 1)
| Structure | Updated Value |
|---|---|
| rows[2] | 2 |
| cols[0] | 2 |
| anti_diagonal | -1 |
Return 0.
Move 6
move(1, 0, 2)
| Structure | Updated Value |
|---|---|
| rows[1] | -2 |
| cols[0] | 1 |
Return 0.
Move 7
move(2, 1, 1)
| Structure | Updated Value |
|---|---|
| rows[2] | 3 |
| cols[1] | 0 |
Now:
abs(rows[2]) == 3
Player 1 wins.
Return 1.
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(1) per move | Each move updates a fixed number of counters |
| Space | O(n) | Two arrays of size n are maintained |
The algorithm achieves constant-time move processing because every operation involves only direct index updates and constant-time checks. No board scanning is required.
The space complexity is linear because we store row and column counters, each of size n. The diagonal counters require only constant extra space.
Test Cases
# Provided example
game = TicTacToe(3)
assert game.move(0, 0, 1) == 0 # first move
assert game.move(0, 2, 2) == 0 # opponent move
assert game.move(2, 2, 1) == 0 # diagonal progress
assert game.move(1, 1, 2) == 0 # center move
assert game.move(2, 0, 1) == 0 # row buildup
assert game.move(1, 0, 2) == 0 # column buildup
assert game.move(2, 1, 1) == 1 # player 1 wins row
# Row victory
game = TicTacToe(2)
assert game.move(0, 0, 1) == 0
assert game.move(1, 0, 2) == 0
assert game.move(0, 1, 1) == 1 # row completed
# Column victory
game = TicTacToe(2)
assert game.move(0, 0, 1) == 0
assert game.move(0, 1, 2) == 0
assert game.move(1, 0, 1) == 1 # column completed
# Main diagonal victory
game = TicTacToe(3)
assert game.move(0, 0, 1) == 0
assert game.move(0, 1, 2) == 0
assert game.move(1, 1, 1) == 0
assert game.move(0, 2, 2) == 0
assert game.move(2, 2, 1) == 1 # main diagonal win
# Anti-diagonal victory
game = TicTacToe(3)
assert game.move(0, 2, 2) == 0
assert game.move(0, 0, 1) == 0
assert game.move(1, 1, 2) == 0
assert game.move(1, 0, 1) == 0
assert game.move(2, 0, 2) == 2 # anti-diagonal win
# Larger board without winner
game = TicTacToe(5)
assert game.move(0, 0, 1) == 0
assert game.move(1, 1, 2) == 0
assert game.move(2, 2, 1) == 0
assert game.move(3, 3, 2) == 0
assert game.move(4, 4, 1) == 0 # no full diagonal yet
# Center cell contributes to both diagonals
game = TicTacToe(3)
assert game.move(1, 1, 1) == 0
assert game.diagonal == 1
assert game.anti_diagonal == 1
| Test | Why |
|---|---|
| Provided example | Validates standard gameplay |
| Row victory | Ensures row tracking works |
| Column victory | Ensures column tracking works |
| Main diagonal victory | Validates main diagonal logic |
| Anti-diagonal victory | Validates anti-diagonal logic |
| Larger board without winner | Confirms no false positives |
| Center cell test | Ensures both diagonals update correctly |
Edge Cases
One important edge case occurs when a move lies on both diagonals simultaneously. This happens only at the center cell of an odd-sized board. A buggy implementation may update only one diagonal counter. Our implementation explicitly checks both conditions independently, so both counters are updated correctly.
Another edge case is very small boards, especially 2 x 2. Wins can occur after only two moves from the same player. Some implementations accidentally assume more moves are required. Since our algorithm checks counters immediately after every move, wins are detected correctly regardless of board size.
A third important edge case is alternating contributions from different players in the same row or column. For example, if player 1 and player 2 both place marks in a row, the counter values cancel out partially. This is why the +1 and -1 technique works so effectively. A row can reach magnitude n only if every cell belongs to the same player.
Another subtle edge case is avoiding unnecessary board storage. A naive implementation might allocate an entire n x n matrix even though winner detection does not require it. Our solution stores only aggregate counters, reducing memory usage from O(n²) to O(n).