Object-Oriented Design Interview

Course Content

Object-Oriented Design Interview

14 sections · 29 lessons

Tic Tac Toe: win detection in code and extensions


With the win check placed on Board, the satisfying part of this problem is making it fast. This lesson writes it three ways — each faster and no longer than the last — and then works through the follow-ups, every one of which tests whether the earlier decisions were really general.

Win detection, in code

The satisfying part of this problem. Write the naive version, show what it costs, then replace it with something that is both faster and shorter.

The naive version: rescan everything

Java
public boolean hasWinner() {    for (int r = 0; r < n; r++)                          // rows        if (allSame(grid[r])) return true;    for (int c = 0; c < n; c++)                          // columns        if (allSame(column(c))) return true;    return allSame(mainDiagonal()) || allSame(antiDiagonal());}

For 3×3 this is correct and costs about 24 cell reads per move. Nobody would improve it.

For a 15×15 board with K=5 it becomes badly wrong in two ways. It reads roughly 900 cells per move, and — more importantly — it is not even correct, because with K < N a win is any run of K in a row anywhere, not a full line. The general version needs to scan every window of K cells in every row, column, and both diagonal directions: hundreds of windows per move.

The insight

A win can only involve the cell that was just played. Nothing else on the board changed.

So instead of asking "is there a winning line anywhere?", ask "did this move complete a line?" That reduces the work from the whole board to four directions through one cell.

Version two: check four directions through the last move

Java
private static final int[][] DIRECTIONS = {{0,1}, {1,0}, {1,1}, {1,-1}};  // →, ↓, ↘, ↙public boolean isWinningMove(int row, int col) {    Piece piece = grid[row][col];    for (int[] d : DIRECTIONS) {        int count = 1            + countInDirection(row, col, d[0], d[1], piece)      // one way            + countInDirection(row, col, -d[0], -d[1], piece);   // and back        if (count >= k) return true;    }    return false;}private int countInDirection(int row, int col, int dr, int dc, Piece piece) {    int count = 0;    int r = row + dr, c = col + dc;    while (r >= 0 && r < n && c >= 0 && c < n && grid[r][c] == piece) {        count++; r += dr; c += dc;    }    return count;}

Cost per move: at most 4 × 2 × K cell reads. For K=5 that is about 40 reads instead of several hundred, it handles any N and any K, and it is fifteen lines. For most interviews this is the right place to stop — it is correct, general, and clearly better than the scan.

Version three: O(1) with counters, for the full-line case

When K equals N — the classic rule, a full row, column, or diagonal — you can do better still. Keep a running count per line instead of reading cells at all.

Java
public class Board {    private final int[] rowScore, colScore;      // +1 for X, -1 for O    private int diagScore, antiDiagScore;    public boolean recordMove(int row, int col, Piece piece) {        grid[row][col] = piece;        int delta = (piece == Piece.X) ? 1 : -1;        rowScore[row] += delta;        colScore[col] += delta;        if (row == col)            diagScore     += delta;        if (row + col == n - 1)    antiDiagScore += delta;        return Math.abs(rowScore[row])  == n            || Math.abs(colScore[col])  == n            || Math.abs(diagScore)      == n            || Math.abs(antiDiagScore)  == n;    }}

Four additions and four comparisons per move, regardless of board size. A line reaches ±N only when all N of its cells hold the same piece, because each X adds one and each O subtracts one — mixed lines can never reach the magnitude.

XOXOrowScore[0] = +1rowScore[1] = +1rowScore[2] = −1colScore[0] =0colScore[1] =+1colScore[2] =−1diagScore = +2antiDiag = −1X plays (1,1) — the four counters it can possibly affectcell (1,1)rowScore[1]+1colScore[1]+1diagScore+1antiDiagScore+1then: |score| == 3 ?Full rescan, every move3×3: about 24 cell reads · 15×15 with K=5: several hundred window checksCounters4 additions and 4 comparisons — at any board sizea win can only involve the cell justplayed — everything else is unchangedCounters work when K = N. For K < N — Gomoku, Connect Four — walk the four directions out from the cell instead; a counter cannot express "five in a row somewhere".
The four arrows are the entire cost of a move — that one observation is the whole optimisation.

The honest limits of version three

Say these out loud; they are what makes the answer look considered rather than memorised:

  • It only works when K = N. Counting a whole line cannot express "five in a row somewhere in a fifteen-wide row". For K < N, use version two.
  • Undo must decrement the counters. A move stack that only restores the grid leaves the counters wrong, and the bug appears several moves later — a genuinely nasty one.
  • It assumes exactly two pieces. The +1/−1 trick breaks with three players; you would need a count per piece per line.

Extensions

1. N×N boards and K in a row

Already handled if you took the advice in The problem, and the questions to ask: N and K are constructor parameters, the win check is the four-direction walk, and the same class plays 3×3 tic tac toe and 19×19 Gomoku with no changes.

One class, four games3 by 3, three in a rowN by N, K in a rowGomoku on 19 by 19Connect Four adds gravity
If N and K were constructor parameters, three of these four extensions cost nothing at all.

If your attempt hard-coded 3, this is the extension the interviewer will ask for, and the answer is a rewrite of the win check rather than a parameter. That is the cost of hard-coding, and it is worth feeling once.

2. A computer player behind a Strategy interface

The move that makes bots and humans interchangeable:

Java
public interface MoveStrategy {    Move chooseMove(BoardView board, Piece piece);   // BoardView is read-only}public class RandomStrategy implements MoveStrategy {    public Move chooseMove(BoardView board, Piece piece) {        List<Position> empty = board.emptyPositions();        return new Move(empty.get(random.nextInt(empty.size())), piece);    }}

BoardView being read-only is the detail worth pointing out: a bot is asked what it would do and cannot change anything. That is the same "player decides, game applies" separation as in Finding the objects, now enforced by the type.

Minimax, the perfect-play strategy, is another implementation of the same interface: try every legal move, recurse assuming the opponent plays optimally, and take the best outcome. For 3×3 the game tree is small enough to search exhaustively — at most 9! = 362,880 orderings, and far fewer in practice with early termination. For a 15×15 board it is hopeless without depth limits and a heuristic, which is worth saying because it shows you know where the approach stops working.

The design point is that neither bot changes Game. That is requirement N2 satisfied.

3. Undo via a move stack

Java
public class Game {    private final Deque<Move> history = new ArrayDeque<>();    public void undo() {        if (history.isEmpty()) throw new NothingToUndoException();        Move last = history.pop();        board.remove(last.getRow(), last.getCol());   // must also decrement counters        currentPlayer = last.getPlayer();        status = GameStatus.IN_PROGRESS;              // a finished game becomes live again    }}

Three things to say while writing it:

  • The per-line counters from version three above must be decremented, or the board's summary drifts from its grid.
  • Undoing a winning move must reopen the game, which means status is derived state that undo has to restore.
  • This is the Command pattern in embryo (The seven patterns that actually appear). If moves gained variety — place, swap, pass — each becoming a Command with execute and undo would be the right step. With one kind of move, a Move record on a stack is enough, and saying that is better than adding the pattern.

4. Generalising to Connect Four

An excellent final question, because the answer reveals whether your model was really general.

Connect Four is an N×M board, K=4, with gravity: you choose a column and the piece falls to the lowest empty row. Almost everything transfers:

Piece of the designTransfers?
Board, Piece, Player, MoveUnchanged
The four-direction win checkUnchanged — this is the payoff for version two
Per-line countersNo — K < N, so they cannot express a run
Move validationChanged: a move names a column, and the row is computed
Game turn order and statusUnchanged

The single change is in move placement:

Java
public int dropInColumn(int col, Piece piece) {    for (int row = rows - 1; row >= 0; row--) {        // from the bottom up        if (grid[row][col] == null) { grid[row][col] = piece; return row; }    }    throw new ColumnFullException(col);}

One new method and a different validation rule. If your design needs more than that, the win check was tied to the board size or the move was tied to a cell rather than to a placement rule — and that diagnosis is worth saying out loud.