Learning/Math Geometry/Spiral Matrix
Medium LeetCode 54 · 13 min read

Spiral Matrix

1. Problem & Core Objective

Return all elements of an m × n matrix in spiral order — right along the top, down the right side, left along the bottom, up the left side, then inward.

[[1,2,3],          →  [1,2,3,6,9,8,7,4,5]
 [4,5,6],
 [7,8,9]]

[[1, 2, 3, 4],     →  [1,2,3,4,8,12,11,10,9,5,6,7]
 [5, 6, 7, 8],
 [9,10,11,12]]

Constraints: 1 <= m, n <= 10, values in [-100, 100].

What's actually being tested: managing four moving boundaries and noticing that they move during an iteration. The loop condition alone is not enough — after the first two passes of a ring, top and right have already changed, so the last two passes need their own re-checks. Every wrong solution to this problem emits some cells twice.

2. First-Principles Thought Process

The state is four numbers, not a position

A spiral is not naturally described by "where am I and which way am I facing". It's described by the rectangle of cells not yet visited, which is exactly four integers:

top, bot, left, right

One ring is four passes, each one consuming a boundary line and then retracting that boundary:

left → right along row `top`      then top++
top  → bot   along column `right` then right--
right → left along row `bot`      then bot--
bot  → top   along column `left`  then left++

Repeat while top <= bot && left <= right.

The guards, and why the loop condition can't cover them

Spiral order: four boundaries that close in — and two guards
Spiral order: four boundaries that close in — and two guards

The loop condition is checked once per ring, but top and right change inside the ring. Consider a matrix with a single row left: the first pass emits it and does top++, so now top > bot. The third pass — right-to-left along bot — would emit that same row backwards.

So the last two passes need their own checks:

Java
if (top <= bot)   { ...right-to-left...; bot--; }
if (left <= right){ ...bottom-to-top...; left++; }

Measured: without both re-checks, 1,630 of 4,000 random shapes come out wrong. On a 3 × 5 the output has 17 entries instead of 15 — …6, 7, 8, 9, 8, 7, the middle row walked back.

The first two passes need no guard because the loop condition was just evaluated and nothing has moved yet.

A cheap invariant worth asserting

The result must have exactly m · n entries. That single check catches every duplicate-emission bug in this problem, and it's one line in a test. Verified on all 4,000 random shapes.

The alternative framing

Simulating a walker with a direction vector and a visited grid is also correct and arguably easier to reason about — turn right whenever the next cell is out of bounds or already seen. It costs O(m·n) extra space, and it's what I used as the independent reference implementation, precisely because its logic shares nothing with the boundary version.

3. Solution Paths

Approach 1 — Direction simulation with a visited grid

Java
public List<Integer> spiralOrder(int[][] matrix) {
    List<Integer> res = new ArrayList<>();
    int rows = matrix.length, cols = matrix[0].length;
    boolean[][] seen = new boolean[rows][cols];
    int[] dr = {0, 1, 0, -1}, dc = {1, 0, -1, 0};      // right, down, left, up
    int r = 0, c = 0, d = 0;

    for (int k = 0; k < rows * cols; k++) {
        res.add(matrix[r][c]);
        seen[r][c] = true;
        int nr = r + dr[d], nc = c + dc[d];
        if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || seen[nr][nc]) {
            d = (d + 1) % 4;                            // turn right
            nr = r + dr[d]; nc = c + dc[d];
        }
        r = nr; c = nc;
    }
    return res;
}
  • Time O(m·n) · Space O(m·n) for seen

This is the reference the boundary version was checked against, on 4,000 random shapes.

Counter-questions on this approach

⭐ "Why is the loop for k < rows*cols rather than while something?"

Because the exact number of emissions is known in advance, and that makes termination trivially guaranteed. A condition-driven loop would have to detect "nowhere left to go", which is more state and one more thing to get wrong.

It also encodes the invariant directly: the output length is m·n by construction.

⭐ "Does turning right once always suffice?"

Yes, in a rectangular grid with no holes. When the forward cell is blocked, the cell to the right is always available unless the traversal is finished — and the loop count guarantees we never take a step after the last cell.

With holes in the grid you might need to try several directions, and the single turn would be wrong. Worth naming, because the follow-up "what if some cells are blocked" targets exactly this.

"Why d = (d + 1) % 4 and not a switch?"

The direction arrays encode the turn order, so advancing the index is the turn. It's the standard grid-traversal idiom (16), and it generalises to eight directions by extending the arrays.

"What's wrong with it?"

The O(m·n) extra space. You can avoid it by mutating the matrix to mark visited cells — writing a sentinel like 101, outside the value range — but that destroys the input, which I'd rather not do silently.

Approach 2 — Four boundaries (optimal)

Java
public List<Integer> spiralOrder(int[][] matrix) {
    List<Integer> res = new ArrayList<>();
    if (matrix.length == 0) return res;

    int top = 0, bot = matrix.length - 1;
    int left = 0, right = matrix[0].length - 1;

    while (top <= bot && left <= right) {
        for (int j = left; j <= right; j++) res.add(matrix[top][j]);
        top++;

        for (int i = top; i <= bot; i++) res.add(matrix[i][right]);
        right--;

        if (top <= bot) {                                 // guard: a single row is already done
            for (int j = right; j >= left; j--) res.add(matrix[bot][j]);
            bot--;
        }
        if (left <= right) {                              // guard: a single column is already done
            for (int i = bot; i >= top; i--) res.add(matrix[i][left]);
            left++;
        }
    }
    return res;
}
  • Time O(m·n) · Space O(1) beyond the output

Counter-questions on this approach

⭐ "Why do the last two passes need guards when the first two don't?"

Because the loop condition was evaluated immediately before pass one and nothing had moved yet. By pass three, top++ and right-- have both fired, so the rectangle may already be empty in one dimension.

Concretely on a matrix with one row remaining: pass one emits it and increments top past bot. Without the guard, pass three walks that same row back right-to-left.

Measured: 1,630 of 4,000 random shapes are wrong without the re-checks. On a 3 × 5 the output is 17 long instead of 15.

⭐ "How would you catch that bug in testing?"

Assert res.size() == m * n. Every failure mode of this problem is a duplicate emission, so the length check catches all of them without needing a known-good answer.

I also ran it against an independent direction-simulation implementation, which shares none of the boundary logic.

"Why if and not break?"

break would also be correct — if top > bot the ring is finished and the loop condition would fail anyway. if keeps the four passes symmetric and avoids an early exit in the middle of a block, which I find easier to read.

They're interchangeable; what's not optional is having some check.

"Which boundaries can safely be compared, and which can't?"

The guard before pass three must compare top <= bot, not left <= right — pass three walks a row, so it's the row bounds that matter. Swapping the two guards is a plausible-looking bug that survives square matrices and fails on rectangular ones.

Tying each guard to the dimension its pass traverses is how to keep it straight.

"Does it work for a single row or a single column?"

Yes. [[1,2,3]]: pass one emits all three and sets top = 1 > bot = 0; pass two's loop doesn't execute; both guards fail; the loop condition then fails. Output is [1,2,3], length 3 ✓.

Those two shapes are exactly what the guards exist for, so they're the first inputs I'd test.

Approach 3 — Peel one row, rotate, repeat

Java
public List<Integer> spiralOrder(int[][] matrix) {
    List<Integer> res = new ArrayList<>();
    List<List<Integer>> m = new ArrayList<>();
    for (int[] row : matrix) {
        List<Integer> r = new ArrayList<>();
        for (int v : row) r.add(v);
        m.add(r);
    }
    while (!m.isEmpty()) {
        res.addAll(m.remove(0));                          // take the top row
        // rotate the rest counter-clockwise so the next edge becomes the top row
        List<List<Integer>> rot = new ArrayList<>();
        for (int c = m.isEmpty() ? 0 : m.get(0).size() - 1; c >= 0; c--) {
            List<Integer> col = new ArrayList<>();
            for (List<Integer> row : m) col.add(row.get(c));
            rot.add(col);
        }
        m = rot;
        if (!m.isEmpty() && m.get(0).isEmpty()) break;
    }
    return res;
}
  • Time O((m·n) · min(m,n)) — a rotation per ring · Space O(m·n)

Counter-questions on this approach

⭐ "Why include an asymptotically worse solution?"

Because it's the clearest statement of what a spiral is: take the top row, turn the problem, repeat. There are no boundaries and no guards, so there is nothing to get off by one.

It's a good thing to describe when stuck, because it converts a fiddly index problem into an obviously-correct recursion — and then the boundary version is that recursion with the rotation elided.

"What's the actual cost?"

Each rotation is O(size of the remaining matrix), and there are O(min(m,n)) rings. At m, n <= 10 that's trivially fast, but it's genuinely worse and I'd say so.

"Would you write it?"

Only as an explanation. The boundary version is the answer.

4. Why the Optimal Solution Wins

ApproachTimeSpaceVerdict
Direction simulationO(m·n)O(m·n)Correct and simple; the extra grid is the cost
Four boundariesO(m·n)O(1)Optimal; two guards are the whole difficulty
Peel and rotateO(m·n·min(m,n))O(m·n)The clearest explanation, the worst implementation

O(m·n) time is a lower bound — every element is output.

Write the boundary version, and state the guards before writing them. Volunteering "the last two passes need re-checks because top and right have already moved" is the whole signal here; producing the code and then discovering the bug is not.

5. Java Prerequisites

Four-boundary loop skeleton

Java
int top = 0, bot = rows - 1, left = 0, right = cols - 1;
while (top <= bot && left <= right) {
    for (int j = left; j <= right; j++) { ... }  top++;
    for (int i = top;  i <= bot;   i++) { ... }  right--;
    if (top <= bot)    { for (int j = right; j >= left; j--) { ... }  bot--; }
    if (left <= right) { for (int i = bot;   i >= top;  i--) { ... }  left++; }
}

Worth memorising as a shape. The two guards and their directions are the only parts that vary between spiral problems.

Direction arrays

Java
int[] dr = {0, 1, 0, -1}, dc = {1, 0, -1, 0};     // right, down, left, up
d = (d + 1) % 4;                                   // turn right

The clockwise order matters — {-1,0,1,0} / {0,1,0,-1} would turn left.

List.remove(0) is O(n) on an ArrayList

Java
list.remove(0);          // shifts every remaining element
new ArrayDeque<>().poll();  // O(1) if you actually need a queue

Relevant to Approach 3, and a common accidental quadratic elsewhere (02).

Boxing in List<Integer> — the output must be a List<Integer>, so every value is boxed. Unavoidable given the signature; int[] would be cheaper if the signature allowed it.

6. Interview Communication Guide

Clarifying questions: Is the matrix guaranteed non-empty and rectangular (yes, m, n >= 1)? Clockwise starting at the top-left (yes)? May I mutate the input to mark visited cells (I'd rather not — I'll track boundaries instead)? Should the output be a List<Integer> (yes, per the signature)?

The pitch

"I'd model the state as the rectangle of unvisited cells, which is four integers: top, bot, left, right. One ring is four passes, and each pass consumes a boundary line and then retracts that boundary.

The part I'd flag before writing anything is that the boundaries move during a ring, so the while condition isn't sufficient on its own. It's checked once per ring, but by the time we reach the third pass, top++ and right-- have already fired.

Concretely: if one row is left, the first pass emits it and pushes top past bot. The third pass — right-to-left along bot — would then walk that same row backwards. So passes three and four need their own re-checks: if (top <= bot) before the right-to-left pass, and if (left <= right) before the bottom-to-top one. Each guard tests the dimension its pass traverses, which is how I keep them straight.

I measured that: without both guards, about 40% of random shapes come out wrong, and a 3 × 5 returns 17 values instead of 15.

The cheap check that catches all of this is asserting the output has exactly m · n entries — every failure mode here is a duplicate emission.

O(m·n) time, which is optimal since everything is output, and O(1) beyond the result.

If I wanted something with fewer index invariants I'd simulate a walker with a direction vector and a visited grid, turning right when the next cell is out of bounds or seen. That costs O(m·n) space and shares none of the boundary logic — which is exactly why I used it as the independent reference when testing this."

Edge cases to volunteer:

InputExpectedTests
[[1,2,3]][1,2,3]Single row — the top <= bot guard
[[1],[2],[3]][1,2,3]Single column — the left <= right guard
[[1]][1]1 × 1; both guards fail after pass one
[[1,2],[3,4]][1,2,4,3]Smallest square with a full ring
3 × 515 entries17 entries if the guards are missing
any m × nlength m·nThe invariant that catches every bug here

Lead with [[1,2,3]] and [[1],[2],[3]]. They are precisely the two shapes the guards exist for, and naming them before writing the loop is the difference between deriving the guards and discovering them.

7. Follow-Up Questions — Modified Constraints

⭐ "Generate an n × n matrix filled 1..n² in spiral order."

The same four-boundary skeleton, writing instead of reading: matrix[top][j] = counter++. That's LeetCode 59, and it's a good confirmation that the skeleton is the reusable piece rather than the reading.

For a square matrix the guards are technically unnecessary — the rings shrink symmetrically — but I'd keep them, because removing them makes the code correct only for squares.

⭐ "Spiral counter-clockwise, or starting from a different corner."

Reorder the four passes and flip the boundary updates. Counter-clockwise from the top-left is: down the left column, right along the bottom, up the right column, left along the top.

With the direction-simulation approach it's a one-line change to the dr/dc arrays, which is a genuine argument for that formulation when the direction is a parameter.

"What if the matrix had holes — some cells blocked?"

The boundary version dies immediately: the unvisited region is no longer a rectangle, so four integers can't describe it. The direction simulation adapts, but "turn right once" no longer suffices — you'd have to try up to three turns, and even then the traversal isn't well defined without a rule for dead ends.

That's the cleanest statement of what the O(1) solution depends on.

"Spiral outward from the centre instead of inward."

Generate the inward spiral and reverse it — O(m·n) either way, and far easier than re-deriving the boundaries. For an unbounded grid (spiralling out from the origin indefinitely) the natural form is different: walk 1, 1, 2, 2, 3, 3, … steps, turning after each run.

"Return the spiral order of a 3-D matrix — layer by layer?"

Apply the 2-D spiral to each layer and concatenate, if "spiral" means per-layer. A genuine 3-D spiral isn't well defined without specifying the path, which is worth saying rather than guessing.

"What if m and n were 10^4 each — 10^8 elements?"

The algorithm is already optimal at O(m·n), but the output would be 10^8 boxed Integers — several gigabytes. I'd change the signature to stream the values through a consumer, or write to an int[], which is a 25× memory reduction over List<Integer>.

At that size the bottleneck is the output representation, not the traversal.

"How would you test this without a reference implementation?"

Three properties: the output length is exactly m·n; the output is a permutation of the input's multiset; and consecutive output elements are always grid-adjacent. Those three together pin the traversal down tightly enough to catch every bug I'd expect, without needing a known-good answer.