vault backup: 2026-05-17 14:27:46
This commit is contained in:
+349
@@ -0,0 +1,349 @@
|
||||
---
|
||||
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] 💬 延伸思考
|
||||
|
||||
这道题的核心思维是 **"将二维导航问题转化为一维约束"**。与其纠结每个拐点的精确转向规则,不如关注:
|
||||
|
||||
> 四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起?
|
||||
|
||||
这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 **「四边界 + 四步循环 + 重叠检查」**,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。
|
||||
Reference in New Issue
Block a user