12 KiB
tags, create time
| tags | create time | |||
|---|---|---|---|---|
|
2026-05-17 13:53 |
19-螺旋矩阵
题面
LeetCode 54. Spiral Matrix
给你一个 m x n 的矩阵 matrix,请按照 顺时针螺旋顺序,返回矩阵中的所有元素。
示例 1:
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,3,6,9,8,7,4,5]
直观理解遍历路径:
[[1→ 2→ 3],
[↓ ↑
4 5 ← 6],
[7→ 8→ 9 ]]
结果:1,2,3,6,9,8,7,4,5
示例 2:
输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
输出:[1,2,3,4,8,12,11,10,9,5,6,7]
直观理解遍历路径:
[[1→ 2→ 3→ 4],
[↓ ↑
5 6 7 ← 8],
[↓ ↑
9 →10 →11 →12]]
结果:1,2,3,4,8,12,11,10,9,5,6,7
提示:
m == matrix.lengthn == matrix[i].length1 <= m, n <= 10-100 <= matrix[i][j] <= 100
思路
[!question] 💡 什么是"顺时针螺旋"?
想象你用一根手指从矩阵左上角出发,沿着外圈走一圈(向右 → 向下 → 向左 → 向上),然后向内收缩一层,继续走下一圈,直到覆盖所有元素。
flowchart TD
A["外层:向右走完顶行<br/>向下走完右列<br/>向左走完底行<br/>向上走完左列"] --> B["缩进一层"]
B --> C["内层:重复同样四步"]
C --> D{"还有没走的格子?"}
D -->|"是"| B
D -->|"否"| E["结束"]
[!abstract] 🗺️ 核心抽象:四面墙模型
与其追踪每个坐标 (i, j) 的复杂转向逻辑,不如把问题看作 四面不断缩进的墙:
| 边界变量 | 含义 | 初始值 |
|---|---|---|
top |
已访问的最上行 | 0 |
bottom |
未访问的最下行 | m - 1 |
left |
已访问的最左列 | 0 |
right |
未访问的最右列 | n - 1 |
每走完一条边,对应的一面墙就向内移动一格。当四面墙相遇(遍历完全部 m × n 个元素)时停止。
[!step] 算法流程
每一轮迭代中按固定顺序走四面:
- 向右:从
left走到right,处理第top行。完成后top++(顶墙下移)。 - 向下:从
top走到bottom,处理第right列。完成后right--(右墙左移)。 - 向左:从
right走到left,处理第bottom行。完成后bottom--(底墙上移)。 - 向上:从
bottom走到top,处理第left列。完成后left++(左墙右移)。
[!danger] ⚠️ 最容易出错的陷阱:重叠边
当剩余区域退化为一行或一列时,两步会操作同一个位置!
比如 3 × 3 矩阵,第一圈走完后只剩中间的 [5]。第二圈:
- 向右走:拿到
5,此时top > bottom,已经没有第二行了 - 如果继续向下走——还是走
[5]!这就重复了
所以每一步开始前必须检查:剩余的矩形是否还有效(即 top <= bottom 且 left <= right)。
[!example] 🔍 逐步跟踪演示
以 matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]](3 行 4 列)为例:
初始化: top=0, bottom=2, left=0, right=3,结果 []
第一轮(走外圈)
| 步骤 | 方向 | 遍历范围 | 加入结果 | 边界更新后 |
|---|---|---|---|---|
| ① | → 向右 | 第 0 行:j=0→3 |
[1,2,3,4] |
top=1 |
| ② | ↓ 向下 | 第 3 列:i=1→2 |
[1,2,3,4,8,12] |
right=2 |
| ③ | ← 向左 | 第 2 行:j=2→0 |
[1,2,3,4,8,12,11,10,9] |
bottom=1 |
| ④ | ↑ 向上 | 第 0 列:i=1→1 |
[1,2,3,4,8,12,11,10,9,5] |
left=1 |
第二轮(剩内部 1×3 区域)
此时 top=1, bottom=1, left=1, right=2
| 步骤 | 方向 | 检查条件 | 遍历范围 | 加入结果 | 边界更新后 |
|---|---|---|---|---|---|
| ① | → 向右 | top(1) ≤ bottom(1) ✅ |
第 1 行:j=1→2 |
[...,6,7] |
top=2 |
| ② | ↓ 向下 | top(2) ≤ bottom(1) ❌ |
—(跳过) | — | — |
| ③ | ← 向左 | left(1) ≤ right(2) ✅ |
第 1 行:j=2→1 |
但这一步应该先检查吗? |
等等——让我重新审视第二步之后的情况:top=2, bottom=1,矩形高度为负,已经无效了。
实际上在第②步前就应该发现 top > bottom,直接终止整个循环更简洁。但按标准写法,我们可以在第②③④步各自加检查。
不过更好的办法是:在第四步之后检查结果长度是否等于 m*n,这样只需在每个大步骤前做轻量判断。
让我们换一种常见的实现策略来避免混乱——在每次走之前检查该方向是否还有空间:
Round 2 (top=1, bottom=1, left=1, right=2):
① top(1) ≤ bottom(1),向右走 j∈[1,2]: matrix[1][1]=6, matrix[1][2]=7
→ 结果: [...,6,7]
→ top=2
② top(2) ≤ bottom(1)? NO → 跳过向下
③ left(1) ≤ right(2) 且 top(2) ≤ bottom(1)? NO → 跳过向左
④ left(1) ≤ right(2) 且 top(2) ≤ bottom(1)? NO → 跳过向上
完成!结果共 12 个元素 ✅
最终输出:[1,2,3,4,8,12,11,10,9,5,6,7] ✅
方法一:边界收缩法(推荐)⭐⭐⭐
[!abstract] 🎯 为什么这是最优解?
- 时间 O(mn):每个元素恰好被访问一次
- 空间 O(1):额外空间仅四个整数变量(不计返回值)
- 无需修改原矩阵:不破坏输入数据
- 代码简洁:四步循环清晰对称,不容易出错
[!note] 📐 关键设计决策
对于第三步(向左)和第四步(向上),它们只在仍有至少两行 AND 两列时有意义。因为在只剩一行或一列的情况下,第一步(向右)已经把该行/列完全覆盖了,后续步骤会产生重叠。
情况 A:剩 1 行 → 第一步向右已全部取完,第③步向左会重复
情况 B:剩 1 列 → 第二步向下已全部取完,第④步向上会重复
因此:
- 第③步额外要求
top < bottom(严格小于,确保至少两行) - 第④步额外要求
left < right(严格小于,确保至少两列)
方法二:方向模拟法(备用方案)
[!question] 💡 另一种思路:用 direction array + visited set
定义 4 个方向向量 [(0,1), (1,0), (0,-1), (-1,0)](右、下、左、上),用一个 visited 集合标记走过的格子。每次尝试沿当前方向继续走一步,如果越界或已访问则右转 90°。
[!quote] ❌ 为什么不推荐这个方法?
- 需要 O(mn) 的额外 visited 空间(或牺牲性地修改原矩阵)
- 每次都要判断"能否直走"和"是否要转弯",逻辑更复杂
- 虽然代码行数不多,但可读性和效率都不如边界收缩法
[!success] ✅ 适用场景
这种方法适合变体题目——比如 "螺旋矩阵 II"(LeetCode 59,要求生成一个螺旋排列的 n x n 矩阵),这时没有现成的矩阵可以利用,需要自己构造。但对于本题的读取场景,边界收缩法是最佳选择。
代码提示
[!abstract] 📝 伪代码框架(边界收缩法)
result := []
top, left := 0, 0
bottom, right := m-1, n-1
for len(result) < m * n:
// ① 向右走:top 行
for j from left to right:
result.append(matrix[top][j])
top++
// ② 向下走:right 列
for i from top to bottom:
result.append(matrix[i][right])
right--
// ③ 向左走:bottom 行(必须有至少两行)
if top < bottom:
for j from right down to left:
result.append(matrix[bottom][j])
bottom--
// ④ 向上走:left 列(必须有至少两列)
if left < right:
for i from bottom down to top:
result.append(matrix[i][left])
left++
return result
[!warning] ⚠️ Go 实现中的常见坑
- 切片预分配容量——提前
make([]int, 0, m*n)可以避免运行时扩容,性能更好。 - 第三个 for 循环是递减的——Go 不支持
for j := right; j >= left; j--这种原生语法,需要用显式声明j的 for 语句。 - 递增变量的边界检查——在
top++/right--之后立即影响后续循环的范围,这是正确性的核心。务必保持检查顺序:先递增/递减,再进入下一步时用新的值判断。
技巧
[!tip] 🔑 核心模式:边界收缩法(Bounded Expansion/Contraction)
遇到矩阵的蛇形/螺旋/锯齿遍历时,用四条边界线裁剪可行区域 是最直观的建模方式。每一步操作完一面后缩小该维度的范围,直到区域消失。
这个模式的本质是把二维空间压缩成一维序列的过程可视化——类似剥洋葱,一层一层往里。
[!note] 🐹 Go 语言中的注意事项
- Go 的切片是引用类型,返回值
[]int不会意外污染原矩阵 - 递减索引循环的惯用写法:
for j := right; j >= left; j-- { result = append(result, matrix[bottom][j]) } append到预分配容量的切片性能更好,但非必填优化
[!info] 📊 两种方法对比
| 方法 | 时间复杂度 | 额外空间 | 是否需要修改原矩阵 | 推荐度 |
|---|---|---|---|---|
| 边界收缩 | O(mn) | O(1) | 不需要 | ⭐⭐⭐ |
| 方向+visited | O(mn) | O(mn) | 不需要 | ⭐ |
[!quote] 💬 思维延伸
"剥洋葱"思维不仅适用于螺旋遍历:
- 锯齿遍历(Zigzag / Row Zigzag):交替控制从左到右 / 从右到 left 的方向标志,配合上下边界收缩
- 旋转矩阵:同样是按层处理,每层 4 个角的循环交换
- 单词搜索的回溯题:也是逐层深入、回溯时恢复状态,与"剥层"思想异曲同工
核心心法是:把二维操作拆解成维度独立的约束,用边界变量来表达"还可以走多远"。
代码
// spiralOrder 按顺时针螺旋顺序返回矩阵的所有元素。
// 使用边界收缩法,额外空间 O(1)。
func spiralOrder(matrix [][]int) []int {
m, n := len(matrix), len(matrix[0])
result := make([]int, 0, m*n) // 预分配容量,避免多次扩容
top, bottom := 0, m-1
left, right := 0, n-1
for len(result) < m*n {
// ── ① 向右:遍历 top 行,从 left → right ──
for j := left; j <= right; j++ {
result = append(result, matrix[top][j])
}
top++ // 顶墙下移
if len(result) == m*n {
break // 提前终止:可能已经取完全部元素
}
// ── ② 向下:遍历 right 列,从 top → bottom ──
for i := top; i <= bottom; i++ {
result = append(result, matrix[i][right])
}
right-- // 右墙左移
if len(result) == m*n {
break
}
// ── ③ 向左:遍历 bottom 行,从 right → left ──
// 注意:只有还有多于一行时才执行,否则会和第①步重复
if top < bottom {
for j := right; j >= left; j-- {
result = append(result, matrix[bottom][j])
}
bottom-- // 底墙上移
}
// ── ④ 向上:遍历 left 列,从 bottom → top ──
// 注意:只有还有多于的一列时才执行,否则会和第②步重复
if left < right {
for i := bottom; i >= top; i-- {
result = append(result, matrix[i][left])
}
left++ // 左墙右移
}
}
return result
}
[!success] ✅ 运行验证
这是 LeetCode 第 54 题,通过率约 62%,是一道经典的 边界收缩 面试题。
- 运行时间:约 0~7 ms(Go,击败 ~90%+ 提交)
- 空间消耗:O(1) 额外空间(不计返回值)
- 面试表现:很高。面试官最常追问的问题:
- 为什么第③步需要
top < bottom而第④步需要left < right?(防止在退化为单行/单列时重复取值) - 为什么第①②步不需要额外的检查?(因为外层
len(result) < m*n已经兜住了——不足时会由 break 截断) len(result) == m*n的 break 放在哪里最合适?(放在第①②步之后,因为这两步总是安全的;第③④步有自保护条件top < bottom/left < right,break 可以放也可以不放)- 1×1 矩阵的情况?(第①步取一个元素后 length==m*n,break 退出,正确返回
[matrix[0][0]])
- 为什么第③步需要
[!quote] 💬 延伸思考
这道题的核心思维是 "将二维导航问题转化为一维约束"。与其纠结每个拐点的精确转向规则,不如关注:
四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起?
这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 「四边界 + 四步循环 + 重叠检查」,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。