--- 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["外层:向右走完顶行
向下走完右列
向左走完底行
向上走完左列"] --> 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] 💬 延伸思考 这道题的核心思维是 **"将二维导航问题转化为一维约束"**。与其纠结每个拐点的精确转向规则,不如关注: > 四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起? 这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 **「四边界 + 四步循环 + 重叠检查」**,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。