54. 螺旋矩阵(原题卡片见 经典 150 矩阵、GRD 矩阵)

考点归类

矩阵模拟 · 边界收缩。一圈一圈从外向内走,四条边界(top/right/bottom/left)各自向中心收缩,直到所有元素收集完毕。

思路推导

暴力思路:直接模拟转圈走,每步判断是否越界/重复 → 需要额外 visited 数组 O(m×n) 空间,代码啰嗦。

优化核心:用四个边界值记住”当前环的界限”,指针每走一步先收集当前格,再判断是否撞墙——撞墙则收缩对应的边界并换方向。零额外空间。

两种实现:

  1. 4 个 for 循环逐条边扫:每次走完整条边,需要 if (top <= bottom)、if (left <= right) 守卫防重复(单行/单列矩阵必踩坑)
  2. 指针逐格 + 方向机:每步 push 一次,撞墙收缩后转向

关键洞察:以 res.length < m*n 作为终止条件,可以免掉转向处的边界守卫——因为每次先 push 再转向,收集满后循环条件自然失效,转向动作”死”在循环门口,不会越界访问。

🥇 推荐默写:四条 for 剥壳法(Θ(m·n) / O(1))

与方向机版复杂度完全相同,但记忆单元最少——4 个边界变量 + 4 句人话,没有方向数组、没有 % 4 取模、不改入参。实测 1×1 ~ 25×25 共 625 种形状全通过。

var spiralOrder = function (matrix) {
    if (matrix.length === 0 || matrix[0].length === 0) return []
    const res = []
    let top = 0, bottom = matrix.length - 1       // 上下边界(行)
    let left = 0, right = matrix[0].length - 1    // 左右边界(列)
 
    while (top <= bottom && left <= right) {
        for (let j = left; j <= right; j++) res.push(matrix[top][j])        // ① 上边:左 → 右
        top++                                                              // 上边扫完,边界往里收
        for (let i = top; i <= bottom; i++) res.push(matrix[i][right])      // ② 右边:上 → 下
        right--
        if (top <= bottom) {                                               // 还有第二行才走回头路
            for (let j = right; j >= left; j--) res.push(matrix[bottom][j]) // ③ 下边:右 → 左
            bottom--
        }
        if (left <= right) {                                               // 还有第二列才走回头路
            for (let i = bottom; i >= top; i--) res.push(matrix[i][left])   // ④ 左边:下 → 上
            left++
        }
    }
    return res
};

记忆口诀(可顺口背下来,不需要推理):

上边左→右,右边上→下,下边右→左,左边下→上;每扫完一条边,把那条边界往里收一格。

两个守卫为什么必须存在(不是死记)

只剩一行或一列时,「下边」其实就是刚扫过的「上边」,会掉头重复扫:

用例正确输出缺守卫的输出
1×3[1,2,3][1,2,3,2,1] ❌
3×1[1,2,3][1,2,3,2] ❌
2×3 / 3×2—与正确一致 ✅(所以自测选错用例就永远发现不了)

一句话推导:扫「下边」「左边」之前,先确认还有没有第二行 / 第二列。

提交前的自测清单(覆盖全部退化形状)

1×1  [[1]]                → [1]
1×3  [[1,2,3]]            → [1,2,3]         ← 抓守卫
3×1  [[1],[2],[3]]        → [1,2,3]         ← 抓守卫
2×3  [[1,2,3],[4,5,6]]    → [1,2,3,6,5,4]
3×2  [[1,2],[3,4],[5,6]]  → [1,2,4,6,5,3]
3×3                       → [1,2,3,6,9,8,7,4,5]

🥈 备选:摸墙右转法(Θ(m·n) / O(1),但改写入参)

记忆锚点:扫地机器人——走一格 → 踩一脚留脚印 → 探头看下一格,撞墙或撞脚印就右转 → 迈步。只有一条规则,单行/单列的退化形状被「撞脚印」自动兜住,不需要守卫;代价是多记 4 个部件(方向数组、dir、哨兵、四步节奏)且改写入参。

var spiralOrder = function (matrix) {
    if (matrix.length === 0 || matrix[0].length === 0) return []
    const rows = matrix.length, cols = matrix[0].length
    const res = []
    const dx = [0, 1, 0, -1]   // 右 → 下 → 左 → 上;相邻两组满足右转公式 (dx,dy) → (dy,-dx)
    const dy = [1, 0, -1, 0]
    let dir = 0, x = 0, y = 0  // x=行号 y=列号,所以写作 matrix[x][y]
 
    for (let i = rows * cols; i > 0; i--) {
        res.push(matrix[x][y])         // ① 收集当前格
        matrix[x][y] = null            // ② 踩一脚,留脚印(哨兵)
        const nx = x + dx[dir], ny = y + dy[dir]
        if (nx < 0 || nx >= rows || ny < 0 || ny >= cols || matrix[nx][ny] === null) {
            dir = (dir + 1) % 4        // ③ 越界或撞脚印 → 右转(唯一规则)
        }
        x += dx[dir]; y += dy[dir]     // ④ 沿(可能是右转后的)方向迈步
    }
    return res
};

不想改写入参时,把哨兵换成 const seen = new Uint8Array(rows * cols)(下标 x * cols + y)。

🥉 取巧:旋转法(3 行,但复杂度退化)

吃行 → 转置 → 颠倒:每转一次,「第一行」就自动变成螺旋的下一条边,所以完全不用维护边界。

var spiralOrder = function (matrix) {
    const res = []
    while (matrix.length) {
        res.push(...matrix.shift())                    // 吃掉最上面一行
        if (!matrix.length) break
        matrix = matrix[0].map((_, j) => matrix.map(row => row[j])).reverse()  // 逆时针转 90°
    }
    return res
};

每次旋转都要整块重建矩阵,实际开销 Θ(m·n·min(m,n))(实测 50×50 拷贝量 32.8 倍、100×100 达 66.2 倍),且改写入参——只能当白板卡壳时的救命写法,主动交代复杂度后可升级为上面两版。

写法对比

写法记忆单元时间空间改入参退化形状
剥壳法(推荐默写)4 边界 + 4 句人话Θ(m·n)O(1)否需 2 个守卫
摸墙右转方向数组 + dir + 哨兵 + 4 步节奏Θ(m·n)O(1)是自动兜住
方向机 + 边界收缩4 边界 + 方向数组 + 4 条 if 映射Θ(m·n)O(1)否需边界逻辑
旋转法(取巧)3 个词Θ(m·n·min(m,n))O(m·n)是自动兜住

写法详解:方向机 + 边界收缩

var spiralOrder = function (matrix) {
    if (matrix.length === 0) return []
    const rows = matrix.length, cols = matrix[0].length
    const res = []
    const DIRS = { right: [1,0], down: [0,1], left: [-1,0], up: [0,-1] }
    let top = 0, bottom = rows - 1, left = 0, right = cols - 1
    let dir = 'right', x = 0, y = 0
 
    while (res.length < rows * cols) {
        res.push(matrix[y][x])          // ① 先收集当前格
        if (dir === 'right' && x === right) { dir = 'down'; top++ }        // 撞右墙→上边完成,收 top
        else if (dir === 'down' && y === bottom) { dir = 'left'; right-- } // 撞底墙→右边完成,收 right
        else if (dir === 'left' && x === left) { dir = 'up'; bottom-- }    // 撞左墙→下边完成,收 bottom
        else if (dir === 'up' && y === top) { dir = 'right'; left++ }      // 撞顶墙→左边完成,收 left
        const [dx, dy] = DIRS[dir]
        x += dx; y += dy            // ② 沿当前方向走一步
    }
    return res
}

对照自己写的原版(收缩”前方”边界 + up 初始为 1 补偿):也能正确跑通,但 up=1 的来历不直观;对称版改为收缩”身后”边界,四个边界初值天然为 0/0/m-1/n-1。

复杂度

  • 剥壳法 / 方向机版:时间 O(m×n),每个元素恰好收集一次;空间 O(1)(不计输出数组)
  • 摸墙右转(哨兵版):同上,Θ(m·n) / O(1)
  • 旋转法:时间 Θ(m·n·min(m,n))、空间 O(m·n),见上方对比表

易错点

  1. 方向循环 right→down→left→up→right,转向 = 沿循环走一格。dy 符号别写反:向下 dy=+1,向上 dy=-1(自己原版第三、四个分支就写反过)
  2. 用 else if 而非多个 if:多个 if 会在转角处级联换向(撞左墙转 up 后可能立刻又满足 y===top),虽然常被终止条件兜住,但逻辑脆弱
  3. 指针写法的相对优势:以 res.length < m*n 终止,天然免掉剥壳法需要的 top <= bottom / left <= right 守卫(代价是多记方向数组)
  4. 空矩阵要提前返回,否则 matrix[0] 越界(剥壳法 / 摸墙法 / 旋转法三版都躲不掉)
  5. 剥壳法的 2 个守卫只在单行/单列暴露:用 2×3、3×3 自测永远测不出来,必须专门测 1×3 / 3×1
  6. 剥壳法的收缩时机:top++ 要在「上边」扫完后立刻执行,否则「右边」会从拐角重复扫起
  7. 摸墙法的哨兵判断用 === null:输入本身可能含 null 时不适用,改 Uint8Array;另外 x 是行号、y 是列号,别和数学坐标混

变体与举一反三

  • 59. 螺旋矩阵 II:反向生成矩阵,把”收集”换成”填入”,骨架完全一致。剥壳法直接复用;摸墙版只改两处——matrix[x][y] = null → m[x][y] = i、=== null → !== 0(初值 0 天然就是哨兵,比 54 还省事)
  • 885. 螺旋矩阵 III:题目直接给方向机,同款右转规则,可拿来练手感
  • 48. 旋转图像:同为矩阵逐层处理,转置+翻转,见 经典 150 矩阵
  • GRD 矩阵(GRD 刷题系列)

← 速查卡片见 经典 150 矩阵