刷LeetCode的朋友应该对hot100这张清单不陌生,它基本是面试前最高性价比的题单,能把这100道题吃透,大部分公司的算法面都能稳住。而第54题“螺旋矩阵”又是hot100里非常经典的一道二维数组遍历题,看似简单,但对边界控制和逻辑清晰度的要求一点也不低。我第一次做这题时也踩了坑,后来把几种解法都梳理了一遍,发现这道题其实是练习“状态控制”和“边界处理”的绝佳素材,值得单独拿出来认真拆一拆。
这篇文章我会先把题目背后的真实考点讲清楚,再给出一套我认为最实用的边界收缩解法,同时对比方向数组法和按层模拟法的优劣,最后附上高频Bug清单和调试心得,希望能帮你把这道题彻底吃透。
1. 拿到题目先别慌:螺旋矩阵到底在考什么
1.1 题型定位:为什么hot100会选中它
LeetCode hot100的选材逻辑其实很明确,它只收录那些能在45分钟面试时间内完成、且能充分考察候选人基本功的题目。螺旋矩阵(Spiral Matrix)就是这类题的典型代表,它不涉及高深的算法套路,不依赖复杂的数据结构,但它对“控制逻辑”的要求非常苛刻。
你需要在一个m行n列的二维矩阵中,按照顺时针螺旋的顺序,把每个元素依次输出。听起来很直白,但真上手写的时候,很多人会陷入“边界条件怎么判断”“什么时候该转向”“循环什么时候终止”这些细节里。这些能力恰恰是日常业务开发中最常用的,比如图像处理里的像素遍历、游戏地图的寻路渲染、Excel表格的区域扫描,本质上都离不开对二维结构的精细控制。
所以hot100选它,不是因为题目本身有多难,而是因为它能把一个人的代码严谨度和思维清晰度暴露得明明白白。这也是为什么很多面试官喜欢在电面或一面里出这道题——比起考察你背了多少模板,它更能看出你写代码的习惯和思考路径。
1.2 读懂题目的三个关键信号
我第一次做这道题时,第一反应是“这不就是个简单的按层遍历吗”,但真正动手写才发现有几个关键信号必须读懂。
第一个信号是“顺时针螺旋”。这决定了你的遍历方向必须严格按照“右→下→左→上”的顺序循环,而且每走完一个方向,这个方向的边界就要向内收缩一格。
第二个信号是“m和n不相等”。很多人做这题时习惯性拿方阵来想,但题目明确说明矩阵的行列数不一定相等,甚至可能是空矩阵或只有一行/一列。这些边界情形才是真正的坑点所在。
第三个信号是“所有的元素都要被访问且只被访问一次”。这听起来像废话,但很多错误实现会在某些边界条件下重复访问或漏掉元素。这正是螺旋矩阵最需要关注的核心约束。
理解这三个信号之后,解题的大方向就清晰了:我们需要控制一个不断缩小的矩形边界,像剥洋葱一样一层一层把元素取出来。下面我会详细展开这套最实用的解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法核心:四条边界收缩法
2.1 思路可视化:想象一个不断缩小的矩形框
边界收缩法的核心思想可以用一句话概括:维护四个边界变量top、bottom、left、right,它们分别代表当前尚未遍历区域的上、下、左、右边界,初始时top指向第0行,bottom指向最后一行,left指向第0列,right指向最后一列。
每一轮循环里,我们按顺时针方向遍历当前矩形框的四条边:
- 第一步,从left到right遍历top行,也就是从左往右输出上边界的元素,然后top向下移一行;
- 第二步,从top到bottom遍历right列,也就是从上往下输出右边界的元素,然后right向左移一列;
- 第三步,如果top依然小于等于bottom,从right到left遍历bottom行,也就是从右往左输出下边界的元素,然后bottom向上移一行;
- 第四步,如果left依然小于等于right,从bottom到top遍历left列,也就是从下往上输出左边界的元素,然后left向右移一列。
这样每轮循环结束,矩形框的四条边都向内收缩了一圈,下一轮继续处理内部的子矩阵。整个过程就像用一个不断缩小的相框去框住矩阵中尚未读过的部分,直至相框消失。
2.2 边界收缩终止条件:什么时候停
很多人在写这题时,最纠结的问题就是循环到底什么时候结束。边界收缩法的终止条件非常明确:只要top <= bottom 且 left <= right,就说明还有未遍历的区域,循环继续;一旦两个条件中的任意一个不满足,就说明整个矩阵已经遍历完毕。
这里有一个容易被忽略的细节:在下边界的遍历和左边界的遍历前,需要额外加判断条件。为什么要加?因为当矩形只剩一行或一列时,上边界和下边界可能会重合,左边界和右边界也可能会重合。如果不加判断,第二步走完之后top已经大于bottom了,但第三步如果还继续执行,就会重复遍历上一步已经读过的行。我自己第一次写时就栽在这里,输出结果里出现了一串重复元素。
所以稳妥的写法是:在“从右往左遍历下边界”之前判断top <= bottom,在“从下往上遍历左边界”之前判断left <= right。这两个判断看着多余,却能保证你的代码在单行矩阵、单列矩阵、窄矩形这些极端情况下依然正确。
2.3 代码实现与细节
直接给一份可运行的Python实现,这是我目前用得最顺手的版本:
python复制class Solution:
def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
if not matrix or not matrix[0]:
return []
top, bottom = 0, len(matrix) - 1
left, right = 0, len(matrix[0]) - 1
res = []
while top <= bottom and left <= right:
# 1. 从左到右遍历上边界
for j in range(left, right + 1):
res.append(matrix[top][j])
top += 1
# 2. 从上到下遍历右边界
for i in range(top, bottom + 1):
res.append(matrix[i][right])
right -= 1
# 3. 从右到左遍历下边界(需要确认还有行)
if top <= bottom:
for j in range(right, left - 1, -1):
res.append(matrix[bottom][j])
bottom -= 1
# 4. 从下到上遍历左边界(需要确认还有列)
if left <= right:
for i in range(bottom, top - 1, -1):
res.append(matrix[i][left])
left += 1
return res
这段代码有几个值得注意的细节。首先是range的边界,在Python里range(left, right + 1)表示从left到right闭区间,而range(right, left - 1, -1)则是从right到left倒序闭区间,两者都必须把终点加1或减1。其次是top和bottom更新时机,每段遍历完成之后立即收缩边界,这能保证下一步的遍历范围永远落在尚未访问的区域里。最后是结果数组res,所有元素依次append进去,最后直接返回即可。
复杂度方面,时间上每个元素恰好被访问一次,所以是O(mn);空间上除了结果数组之外只用了四个变量,属于O(1)额外空间。这个复杂度在面试中已经是最优的了。
3. 其他解法对比:方向数组与层模拟
3.1 方向数组状态机法
除了边界收缩,另一种常见的实现方式是用方向数组配合状态机来模拟行走路径。思路是先定义四个方向:右(0, 1)、下(1, 0)、左(0, -1)、上(-1, 0),然后从(0, 0)位置出发,按照当前方向一直走,遇到越界或者已经访问过的格子就向右转向,再继续走。
这种解法需要额外维护一个visited二维数组,用来标记哪些格子已经访问过。每次移动前先计算下一个位置,如果发现越界或已访问,就调转方向,再计算一次。
python复制class Solution:
def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
if not matrix or not matrix[0]:
return []
m, n = len(matrix), len(matrix[0])
total = m * n
visited = [[False] * n for _ in range(m)]
dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)]
row = col = dir_idx = 0
res = []
for _ in range(total):
res.append(matrix[row][col])
visited[row][col] = True
nr = row + dirs[dir_idx][0]
nc = col + dirs[dir_idx][1]
if nr < 0 or nr >= m or nc < 0 or nc >= n or visited[nr][nc]:
dir_idx = (dir_idx + 1) % 4
nr = row + dirs[dir_idx][0]
nc = col + dirs[dir_idx][1]
row, col = nr, nc
return res
这种解法的优点是逻辑风格统一,不需要单独处理“只剩一行/一列”这种特例,因为visited数组天然地防止了重复访问。缺点是额外引入了visited数组,空间复杂度从O(1)变成了O(mn),而且代码行数也更多。如果面试时你更习惯这种写法,它同样没问题,但我个人在追求最简实现时还是会选边界收缩法。
3.2 按层模拟法
按层模拟法在思路上和边界收缩法很接近,但它更强调“层”的概念。你可以把矩阵看作一圈一圈的同心矩形,从最外层开始,按照顺序把每一圈的元素全部取出来,再进入下一圈。
每一圈的起点是四个边界变量的交点,处理和边界收缩法几乎一样,只是代码上更倾向于用一个layer变量来标记当前圈数,然后根据layer计算出这一圈的四个边界。递归和迭代都可以实现,迭代写起来反而更直观。
这种解法的问题是理解和实现上稍微绕一点,尤其当你把layer换算成top、bottom、left、right时,很容易在索引计算上出错。对于这道题来说,它不算最优解,但如果你觉得层概念更好理解,也可以作为辅助思路来学习。
3.3 解法对比与选型建议
三种解法放在一起对比,各自的优劣可以看这张表:
| 解法 | 时间复杂度 | 空间复杂度 | 代码量 | 边界处理难度 | 适用场景 |
|---|---|---|---|---|---|
| 边界收缩法 | O(mn) | O(1) | 短 | 中等,需处理单行/单列 | 面试最优解,首选 |
| 方向数组法 | O(mn) | O(mn) | 中等 | 低,visited天然防重 | 写代码怕重复时更稳 |
| 按层模拟法 | O(mn) | O(1) | 长 | 高,容易索引混乱 | 适合理解思路,不建议面试首选 |
我个人的建议是优先掌握边界收缩法,它代码最短,空间最优,面试中写出这套解法非常加分。方向数组法可以作为备选方案,当你在紧张状态下担心边界条件写错时,它是更“无脑”的保底选择。按层模拟法了解即可,不用花太多时间。
4. 实操过程:从分析到编码的完整推导
4.1 复杂度分析与边界条件
在实际写代码之前,先把复杂度算清楚。对于一个m行n列的矩阵,总共m*n个元素,任何算法都必须至少遍历每个元素一次,所以时间复杂度下界就是O(mn)。边界收缩法和方向数组法都做到了线性扫描,都是最优的。
空间复杂度则需要仔细看。输出数组res本身是题目要求的返回值,不能算作额外空间。边界收缩法只用四个整型变量记录边界,所以额外空间是O(1)。方向数组法额外使用了一个visited数组,所以是O(mn)。在面试中如果面试官追问“能不能用O(1)空间”,那答案自然就是边界收缩法。
边界条件方面,至少要考虑这四种情况:
- 空矩阵:matrix为空或matrix[0]为空,直接返回空数组;
- 单行矩阵:比如[[1, 2, 3, 4]],遍历一次上边界就结束了;
- 单列矩阵:比如[[1], [2], [3]],遍历一次右边界就结束了;
- 一行一列的特殊矩阵:只有[[1]],一次循环就输出唯一元素。
这些案例看似边缘,却在面试中经常被用来测试你的代码是否够健壮。建议真正写题时,把这些用例放在手动测试列表里,一遍跑通再提交。
4.2 从暴力到最优的推演过程
我在初学这道题时,第一版代码是这么想的:先处理最外圈,再递归处理内圈。这个思路没有错,但实现时我试图用一个矩阵的“重置”逻辑去模拟螺旋,结果代码越写越长,Bug也越找越多。后来我意识到,与其想着“当前走到哪了”,不如直接维护“还没走过的区域”,这就是边界收缩法能避开大量细节问题的原因。
推演过程其实很简单:第一次进入循环时,矩形框就是整个矩阵,输出上、右、下、左四条边,每输出一条边就收缩一次。第二次进入循环时,矩形框已经缩小了一圈,继续重复同样的过程。当某个方向只剩一条线时,后面的方向会因为没有剩余区域而自动跳过,代码很短却把逻辑封装得很干净。
这种“维护剩余区域”的思维模式在算法题里非常常用,比如矩阵旋转、图像处理、棋盘覆盖等题目都在反复使用。学这道题的价值,远不止于会写一个螺旋遍历。
4.3 变体题:LeetCode 59 螺旋矩阵II
刷过54题之后,一定要顺手把LeetCode 59螺旋矩阵II做了。这道题是54题的逆向版:给定一个正整数n,要求生成一个n x n的矩阵,让矩阵按顺时针螺旋顺序填入1到n²的所有数字。
核心思路和边界收缩法完全一致,只是角色反转了——54题是“读”矩阵,59题是“写”矩阵。你可以用同一个边界收缩框架,只是把“读取元素放进结果数组”换成“把递增的数字填入矩阵”。
python复制class Solution:
def generateMatrix(self, n: int) -> List[List[int]]:
matrix = [[0] * n for _ in range(n)]
top, bottom = 0, n - 1
left, right = 0, n - 1
num = 1
target = n * n
while num <= target:
for j in range(left, right + 1):
matrix[top][j] = num
num += 1
top += 1
for i in range(top, bottom + 1):
matrix[i][right] = num
num += 1
right -= 1
if top <= bottom:
for j in range(right, left - 1, -1):
matrix[bottom][j] = num
num += 1
bottom -= 1
if left <= right:
for i in range(bottom, top - 1, -1):
matrix[i][left] = num
num += 1
left += 1
return matrix
把两道题放在一起对比,你会明显发现螺旋遍历的通用框架:维护四个边界,按方向收缩,处理特判。一旦掌握了这个模板,无论题目换成“顺时针读”还是“顺时针写”,甚至改成逆时针或“之字形”,你都能快速应对。
5. 常见问题与排查技巧实录
5.1 高频Bug速查表
这题在LeetCode评论区里,最常见的错误基本集中在下面几个位置。我把自己和朋友们踩过的坑整理成了一张表,方便你自查:
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| 输出重复元素 | 下边界或左边界无条件遍历 | 分别在第三步和第四步前加if判断 |
| 输出缺元素 | 边界更新顺序错误 | 确保每走完一条边立刻收缩对应边界 |
| 死循环,内存溢出 | 终止条件写成top < bottom | 终止条件必须包含等号,即top <= bottom |
| 空矩阵报错 | 忘记检查matrix[0] | 开头判断 if not matrix or not matrix[0] |
| 单列矩阵输出错乱 | 从左到右和从右到左逻辑冲突 | 用单列用例逐行走一遍流程 |
这些Bug几乎每个人都会遇到一两个,不用焦虑,关键是要理解为什么会出现。
5.2 调试方法论:三步定位法
如果你在实际写代码时出了问题,我推荐按下面三个步骤来排查,效率和体验都会好很多。
第一步,准备一组能覆盖所有情形的小用例。我常用的是三组:3x3矩阵、1x5单行矩阵、5x1单列矩阵。前一个测常规逻辑,后两个专门测边界特判。
第二步,在逻辑分支的关键节点临时加上print语句,打印每一步的top、bottom、left、right值以及当前方向。这一步能非常直观地看到边界收缩是否按预期进行,也能快速发现哪里出现了重复或缺失。
第三步,把用例缩小到最小规模。如果你在3x3矩阵上挂了,就试试2x2,再试试1x2,逐步缩小范围,往往很快就能定位到具体的错误分支。
这套调试流程,在刷其他二维数组题目时也完全通用。
5.3 面试临场建议与刷题方法论
最后说一点个人体会。螺旋矩阵这类题,在面试现场最忌讳闷头写代码。建议先拿笔画一个3x3的矩阵,告诉面试官你的思路是“维护四条边界,每走一条边就收缩一格”,然后用一个简单例子模拟一轮循环。等面试官确认思路没问题之后再动手,这样即使代码有小Bug,面试官也知道你的方向是对的。
刷题方法上,“做完一道题之后顺手看它的变体”是我一直在用的习惯。54题和59题是一对孪生题,做完54再看59,你会发现自己对“边界收缩”的理解会深很多。而hot100的好处就是,它本身已经被筛选过一遍,每道题都值得你花时间研究透,而不是盲目追求题量。
说实话,螺旋矩阵这个题我第一次写用了快二十分钟,还跑出了重复元素。但在我把边界收缩法和方向数组法都写了一遍、同时对比了59题之后,再遇到类似的矩阵遍历题就再也不怕了。刷题的意义就在于此——见多识广,熟能生巧。
