54. 螺旋矩阵(原题卡片见 经典 150 矩阵、GRD 矩阵)
考点归类
矩阵模拟 · 边界收缩。一圈一圈从外向内走,四条边界(top/right/bottom/left)各自向中心收缩,直到所有元素收集完毕。
思路推导
暴力思路:直接模拟转圈走,每步判断是否越界/重复 → 需要额外 visited 数组 O(m×n) 空间,代码啰嗦。
优化核心:用四个边界值记住”当前环的界限”,指针每走一步先收集当前格,再判断是否撞墙——撞墙则收缩对应的边界并换方向。零额外空间。
两种实现:
- 4 个 for 循环逐条边扫:每次走完整条边,需要
if (top <= bottom)、if (left <= right)守卫防重复(单行/单列矩阵必踩坑) - 指针逐格 + 方向机:每步 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),见上方对比表
易错点
- 方向循环
right→down→left→up→right,转向 = 沿循环走一格。dy 符号别写反:向下 dy=+1,向上 dy=-1(自己原版第三、四个分支就写反过) - 用
else if而非多个if:多个 if 会在转角处级联换向(撞左墙转 up 后可能立刻又满足 y===top),虽然常被终止条件兜住,但逻辑脆弱 - 指针写法的相对优势:以
res.length < m*n终止,天然免掉剥壳法需要的top <= bottom/left <= right守卫(代价是多记方向数组) - 空矩阵要提前返回,否则
matrix[0]越界(剥壳法 / 摸墙法 / 旋转法三版都躲不掉) - 剥壳法的 2 个守卫只在单行/单列暴露:用
2×3、3×3自测永远测不出来,必须专门测1×3/3×1 - 剥壳法的收缩时机:
top++要在「上边」扫完后立刻执行,否则「右边」会从拐角重复扫起 - 摸墙法的哨兵判断用
=== 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 矩阵