Spiral Matrix Traversal: The Four-Boundary Method
7 min readBytePatterns
How to read a matrix in spiral order with four shrinking walls, why two small checks stop single rows being read twice, and how to test it against a plain walk.
"Return all elements of the matrix in spiral order." The problem has no clever algorithm hiding in it — every cell is read once, in an order you can draw with a finger. What makes it hard is the bookkeeping: off-by-one errors at the corners and a middle row that gets read twice. The four-boundary method removes most of that bookkeeping by keeping one simple fact true for the whole loop.
The problem it solves
Given a grid with rows rows and cols columns, output its values clockwise from the top-left corner: along the top row, down the right column, back along the bottom row, up the left column, then the same again one ring further in. The grid need not be square, and it may be a single row or a single column.
The same traversal appears in variations — filling a matrix with 1 to n² in spiral order, printing a matrix anticlockwise, or walking outward from the centre — so a version you can reason about pays off more than once.
The intuition
Do not track a direction and a turn counter. Track the unread rectangle instead. Four numbers describe it: top, bottom, left and right, the first and last row and column that still hold unread cells.
One lap is four moves, and each move reads a whole edge of that rectangle and then shrinks it:
- Read the top row from
lefttoright, then movetopdown by one. - Read the right column from
toptobottom, then moverightin by one. - Read the bottom row from
rightback toleft, then movebottomup by one. - Read the left column from
bottomback up totop, then moveleftin by one.
The invariant is that the rectangle always contains exactly the cells not yet read. When top passes bottom or left passes right, the rectangle is empty and the loop stops.
There is one catch. After the first two moves, the rectangle may already be empty — a grid of one row has nothing left once its top row is read. The third and fourth moves each need a check that a row or column is still there. Without them, the bottom move reads the same single row again, backwards.
Watch it run
The animation reads a 4 × 4 board. The dashed rectangle is the four walls. Each step walks one edge and pulls that wall in, until the walls cross over the last cell. The readout counts cells output so far, which ends at sixteen — each cell once.
Spiral Order
Step 1 of 9
Four walls — top, bottom, left, right — hold the part of the board still unread.
The same interactive animation as the lesson — step through it with the controls.
The code
The four moves, with the two guards, and the same loop without them:
def spiral(grid):
if not grid or not grid[0]:
return []
top, bottom = 0, len(grid) - 1
left, right = 0, len(grid[0]) - 1
out = []
while top <= bottom and left <= right:
for c in range(left, right + 1): # top wall, left to right
out.append(grid[top][c])
top += 1
for r in range(top, bottom + 1): # right wall, downwards
out.append(grid[r][right])
right -= 1
if top <= bottom: # a row is still unread
for c in range(right, left - 1, -1): # bottom wall, right to left
out.append(grid[bottom][c])
bottom -= 1
if left <= right: # a column is still unread
for r in range(bottom, top - 1, -1): # left wall, upwards
out.append(grid[r][left])
left += 1
return out
def spiral_unguarded(grid): # the same loop without the two ifs
top, bottom, left, right = 0, len(grid) - 1, 0, len(grid[0]) - 1
out = []
while top <= bottom and left <= right:
out += [grid[top][c] for c in range(left, right + 1)]; top += 1
out += [grid[r][right] for r in range(top, bottom + 1)]; right -= 1
out += [grid[bottom][c] for c in range(right, left - 1, -1)]; bottom -= 1
out += [grid[r][left] for r in range(bottom, top - 1, -1)]; left += 1
return out
print(spiral([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]))
# [1, 2, 3, 4, 8, 12, 11, 10, 9, 5, 6, 7]
print(spiral([[1, 2, 3]]), spiral([[1], [2], [3]]))
# [1, 2, 3] [1, 2, 3]
print(spiral_unguarded([[1, 2, 3]]))
# [1, 2, 3, 2, 1]
print(spiral_unguarded([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]))
# [1, 2, 3, 4, 8, 12, 11, 10, 9, 5, 6, 7, 6]
The unguarded version fails on a single row, which you might expect, and also on the 3 × 4 grid, which you might not. After the first lap the unread rectangle is the single row 6 7. The second lap reads it as its top wall, the rectangle is now empty, and the unchecked bottom move reads 6 again. Any grid whose innermost ring is one row or one column thick triggers it.
To test the boundary version, compare it with the other common method: walk cell by cell, turning clockwise whenever the next cell is off the grid or already visited. It is slower to write and needs a seen matrix, but it is hard to get wrong, which is what you want from a reference. Two thousand random grids, from 1 × 1 up to 9 × 9:
import random
def walk(grid): # the other method: turn when blocked
rows, cols = len(grid), len(grid[0])
seen = [[False] * cols for _ in range(rows)]
dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)] # right, down, left, up
r = c = d = 0
out = []
for _ in range(rows * cols):
out.append(grid[r][c])
seen[r][c] = True
nr, nc = r + dirs[d][0], c + dirs[d][1]
if not (0 <= nr < rows and 0 <= nc < cols) or seen[nr][nc]:
d = (d + 1) % 4 # blocked: turn clockwise
nr, nc = r + dirs[d][0], c + dirs[d][1]
r, c = nr, nc
return out
random.seed(9)
ok, unguarded_wrong = True, 0
for _ in range(2000):
rows, cols = random.randint(1, 9), random.randint(1, 9)
grid = [[random.randint(0, 99) for _ in range(cols)] for _ in range(rows)]
ok &= spiral(grid) == walk(grid)
unguarded_wrong += spiral_unguarded(grid) != walk(grid)
print(ok, unguarded_wrong) # True 887
The boundary version agrees on every grid. The unguarded one is wrong on 887 of the 2,000 — not a rare corner case, but close to half of all rectangles.
The complexity
Every cell is appended exactly once, and each loop iteration does work proportional to the cells it outputs, so the time is O(rows × cols). The boundary version uses O(1) extra space besides the output: four integers. The turning walk uses the same time but an extra O(rows × cols) for the seen matrix, unless you overwrite the grid with a marker — which destroys the input and fails if the marker is a legal value.
Where it goes wrong
- Missing the two guards. Shown above: a thin innermost ring is read twice.
- Patching with a cell count. Cutting the unguarded output to
rows * colsvalues happens to give the right answer, because the extra reads only ever come at the very end. It hides the bug instead of explaining it, and an interviewer will ask why the cut is there. Fix the guard. - Mixing inclusive and exclusive bounds. Here all four bounds are inclusive, so every
rangeends atbound + 1orbound - 1. Pick one convention and do not switch halfway through. - Empty input.
grid[0]on an empty list raises an error; check before reading the width.
How to say it in an interview
"I keep four inclusive bounds around the part of the matrix I haven't read. Each lap reads the top row, the right column, the bottom row and the left column, shrinking one bound after each. The bottom and left moves are guarded, because after the first two moves the remaining rectangle can be empty — that's what breaks single rows and thin centres. It's O(rows × cols) time and O(1) extra space."
The same four-bound thinking helps with rotating a matrix in place, which also works ring by ring.