Files
leetcode-go/矩阵/19-螺旋矩阵.md
T

350 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: ["LeetCode", "矩阵", "中等"]
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.length`
- `n == matrix[i].length`
- `1 <= m, n <= 10`
- `-100 <= matrix[i][j] <= 100`
---
## 思路
> [!question] 💡 什么是"顺时针螺旋"?
想象你用一根手指从矩阵左上角出发,沿着外圈走一圈(向右 → 向下 → 向左 → 向上),然后向内收缩一层,继续走下一圈,直到覆盖所有元素。
```mermaid
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] 算法流程
每一轮迭代中按固定顺序走四面:
1. **向右**:从 `left` 走到 `right`,处理第 `top` 行。完成后 `top++`(顶墙下移)。
2. **向下**:从 `top` 走到 `bottom`,处理第 `right` 列。完成后 `right--`(右墙左移)。
3. **向左**:从 `right` 走到 `left`,处理第 `bottom` 行。完成后 `bottom--`(底墙上移)。
4. **向上**:从 `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 实现中的常见坑
1. **切片预分配容量**——提前 `make([]int, 0, m*n)` 可以避免运行时扩容,性能更好。
2. **第三个 for 循环是递减的**——Go 不支持 `for j := right; j >= left; j--` 这种原生语法,需要用显式声明 `j` 的 for 语句。
3. **递增变量的边界检查**——在 `top++` / `right--` 之后立即影响后续循环的范围,这是正确性的核心。务必保持检查顺序:先递增/递减,再进入下一步时用新的值判断。
---
## 技巧
> [!tip] 🔑 核心模式:边界收缩法(Bounded Expansion/Contraction)
遇到矩阵的蛇形/螺旋/锯齿遍历时,**用四条边界线裁剪可行区域** 是最直观的建模方式。每一步操作完一面后缩小该维度的范围,直到区域消失。
这个模式的本质是把二维空间压缩成一维序列的过程可视化——类似剥洋葱,一层一层往里。
> [!note] 🐹 Go 语言中的注意事项
- Go 的切片是引用类型,返回值 `[]int` 不会意外污染原矩阵
- 递减索引循环的惯用写法:
```go
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 个角的循环交换
- **单词搜索的回溯题**:也是逐层深入、回溯时恢复状态,与"剥层"思想异曲同工
核心心法是:**把二维操作拆解成维度独立的约束,用边界变量来表达"还可以走多远"。**
---
## 代码
```go
// 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] 💬 延伸思考
这道题的核心思维是 **"将二维导航问题转化为一维约束"**。与其纠结每个拐点的精确转向规则,不如关注:
> 四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起?
这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 **「四边界 + 四步循环 + 重叠检查」**,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。