螺旋矩阵这道题,我在刷力扣hot100到第19题时,第一反应是"这不就是个打印矩阵嘛",结果一写代码,边界条件改了整整三版才跑通。后来给几个准备面试的同学讲这道题,发现大家踩的坑出奇一致:不是while条件把循环搞死,就是元素重复读取,再不然就在单行单列矩阵上直接越界。今天就把螺旋矩阵的Python解法从头拆一遍,把每个容易翻车的细节都指出来。
这道题题面非常短:给你一个 m 行 n 列的矩阵,按顺时针螺旋顺序返回矩阵中的所有元素。比如 3x3 的 [[1,2,3],[4,5,6],[7,8,9]],返回 [1,2,3,6,9,8,7,4,5]。整个遍历路径像一条从左上角出发、贴着外圈绕一圈、再向里绕的蛇形线。
说实话,这道题没有高深算法,本质就是"模拟"。面试官喜欢它,是因为它用最少的代码量考你三件事:二维数组下标的熟练度、边界条件的完整性、循环结构的严谨性。这三件事恰恰是工程里写矩阵运算、图像处理、游戏寻路代码的基本功,所以它被收进hot100一点都不意外。什么人该刷这道题?一是刚开始刷hot100、想系统建立二维数组题感的新手,二是面试前想快速把高频题复盘的求职者,三是我这种反复在边界上翻车、想一次性把逻辑理清楚的"老菜鸟"。不管你是哪一类,看完这篇你至少能明白:为什么四个边界变量这么设计、两个if判断为什么必不可少、方向转向法的思路又好在哪。
1. 题面与解题思路:先把"走法"想明白
1.1 用3x3矩阵走一遍看规律
先别急着写代码,拿最经典的3x3矩阵手动走一遍。矩阵长这样:
1 2 3 4 5 6 7 8 9期望输出是 [1,2,3,6,9,8,7,4,5]。你观察这条路径:
- 第一个右行:1 -> 2 -> 3,走到第一行末尾。
- 第一个下行:3 -> 6 -> 9,走到最后一列末尾。
- 第一个左行:9 -> 8 -> 7,走到最后一行开头。
- 第一个上行:7 -> 4,走到第一列第二行。
- 最后一个小右行:4 -> 5,把剩下的中间元素读掉。
这里有个很容易被忽略的细节:四条"边走完的长度分别是3、3、3、2,最后还剩一个元素。为什么不是3、2、2、1?因为四个角被四条边瓜分了:左上角给了第一条边,右上角给了第二条边,右下角给了第三条边,左下角给了第四条边。每个角只归属一条边,遍历才不会重复。这就是螺旋矩阵边界设计的核心逻辑。
再看大一点的矩阵也一样。4x4矩阵最外圈走完后,剩下的是一个2x2的子矩阵,继续按同样的方向绕一圈就能读完。也就是说,整道题本质上可以抽象成一个"剥洋葱"的模型:每一圈做同样的动作,只是范围越来越小。
1.2 核心思路:一圈一圈向内收缩
把最外圈读完之后,剩下的部分仍然是一个小矩形,而且这个小矩形的读法和外层完全一样。这个规律非常自然:读外圈时,上下左右四个边界各向中心收缩一格,子矩阵再用同样的规则读。
所以解题就变成两个问题:
- 用四个变量 top、bottom、left、right 表示当前未读矩形的边界。
- 每一层按"上边、右边、下边、左边"的顺序,用for循环把四条边读掉,然后更新边界。
循环什么时候停?当 top 和 bottom 交错、或者 left 和 right 交错时,说明矩形已经不存在,所有元素都读完了。这个停止条件不是靠拍脑袋想出来的,而是由边界变量的几何意义直接推出来的:只要四条边界还能围成一个非空矩形,循环就应该继续;一旦围不起来了,就必须停。
1.3 为什么"先画图再写码"会坑你
我发现不少同学解题的路径是:草稿纸上画两遍、觉得思路懂了、直接写循环。结果写着写着就开始加临时判断,最后代码自己都看不懂。原因在于:画图看到的是"结果路径",而代码要表达的是"可复用的过程"。
你必须在头脑里确认一句话:每一层做的事情是不是完全一样?如果一样,循环怎么写?如果不一样,是哪里不一样?边界收缩法把每一层的操作抽象成了完全一致的"读四边+收缩边界",这才是能一次写对的根源。如果你只是想"从左上角开始走,碰壁就转向",那更推荐后面讲的方向转向法,那个思路更适合这种心智模型。
2. 边界收缩法:代码的骨架与细节
2.1 四个边界变量怎么设计
一开始,整个矩阵还没有被读取,所以四个变量的初始值非常直接:
- top = 0,最上面一行是第0行。
- bottom = m - 1,最下面一行是最后一行。
- left = 0,最左边一列是第0列。
- right = n - 1,最右边一列是最后一列。
读外圈时,我们按上、右、下、左四段来取数。每读完一段,就把对应的边界朝中心方向移动一格:
- 读完整条上边后,top += 1。
- 读完整条右边后,right -= 1。
- 读完整条下边后,bottom -= 1。
- 读完整条左边后,left += 1。
四段走完,边界组成了一个新的、更小的矩形,正好对应下一圈。while循环条件写成:
while top <= bottom and left <= right: # 读一圈为什么必须是这个条件?因为最终矩形会收成一个比一格还小的区域。想象一下3x3矩阵:第一圈结束后,top=1、bottom=1、left=1、right=1,仍然满足条件,继续读中间那个元素;读完后top=2、bottom=1,条件不满足,循环退出。如果你用了 or 连接条件,第4次循环就会继续,去读一个根本不存在的位置,直接越界报错。
2.2 四条边的遍历顺序与完整代码
在力扣的核心代码模式里,方法签名是 spiralOrder(self, matrix: List[List[int]]) -> List[int]。完整代码如下:
from typing import List class Solution: def spiralOrder(self, matrix: List[List[int]]) -> List[int]: if not matrix or not matrix[0]: return [] res = [] top, bottom = 0, len(matrix) - 1 left, right = 0, len(matrix[0]) - 1 while top <= bottom and left <= right: # 上边:左 -> 右 for col in range(left, right + 1): res.append(matrix[top][col]) top += 1 # 右边:上 -> 下 for row in range(top, bottom + 1): res.append(matrix[row][right]) right -= 1 # 下边:右 -> 左 if top <= bottom: for col in range(right, left - 1, -1): res.append(matrix[bottom][col]) bottom -= 1 # 左边:下 -> 上 if left <= right: for row in range(bottom, top - 1, -1): res.append(matrix[row][left]) left += 1 return res这段代码有几个地方值得细说。
空的判断:
not matrix or not matrix[0]同时挡住"外层为空"和"第一行不存在"两种情况。力扣的测试用例偶尔会给[],偶尔给[[]],两种都要守住。每段用的是闭区间遍历,所以范围写成
range(left, right + 1)。反向遍历要小心range(right, left - 1, -1),右边界写成 left-1,因为Python的range是左闭右开区间,想包含left就必须让停止位置再往前一格。第三、第四段前面的 if 判断是整个解法最关键的防重读逻辑,我在下一小节详细说。
2.3 两个if判断:防重读防越界的灵魂
为什么"上边"和"右边"不需要 if,而"下边"和"左边"需要?答案在于前两段执行完之后,top 和 right 已经移动了。
假设矩阵只有一行,比如 [[1,2,3]]。第一段读完之后 top=1,第二段因为 range(top, bottom+1) 即 range(1,1) 是空的,不会读取任何元素。此时 top=1、bottom=0,while条件在下一轮就不满足了,但这一轮还没有结束。如果不加判断,第三段会继续在 bottom=0 这一行上从右往左读,把第一段已经读过的 3、2、1 又读一遍,输出直接变成 [1,2,3,3,2,1] 这种明显错误的结果。加上if top <= bottom,就能在"已经没有下边可读"时跳过这段。
对称地处理单列矩阵,比如 [[1],[2],[3]]。前两段读完后,right 变成0,left 仍然是0,left <= right 满足,但左边已经没有新的元素可读了。如果不加if left <= right,第四段会从 bottom 行往上再读一遍,同样产生重复。所以这道题里,前两段天然安全、后两段必须加判断,是固定法则。
2.4 复杂度分析
时间复杂度:每个元素恰好被访问一次,外层while内部的所有for加在一起,总操作数就是 mn 次 append,所以是 O(mn)。空间复杂度:除了返回值 res 之外只用了四个整数变量,所以是 O(1),如果不把返回数组本身算进去的话。
这个复杂度是所有解法里最优的,也是面试官希望听到的标准答案。对于 m=200、n=200 的矩阵,也就是4万个元素,螺旋读取的时间开销非常小。所以这道题的优化空间不在于复杂度,而在于代码的简洁度和可读性,这也是我不推荐用递归的重要原因之一。
3. 方向转向法:另一条优雅的实现路径
3.1 思路:不要管边界,管"下一步能不能走"
边界收缩法靠四个变量和两个if来维持形状,逻辑严谨但对新手稍显繁琐。方向转向法提供了另一种视角:维护一个当前的行走方向,每一步向"前"试探,如果发现前方越界或者已经被访问过,就顺时针转90度,再往前走。这样不需要精确算每条边的长度,代码看起来更"自动化"。
方向数组是核心:
右: (0, 1) 下: (1, 0) 左: (0, -1) 上: (-1, 0)用一个索引 d 从0开始,每转一次方向就执行d = (d + 1) % 4。取模运算保证了方向会在0到3之间循环,不会越界。
3.2 完整代码与关键注释
from typing import List class Solution: def spiralOrder(self, matrix: List[List[int]]) -> List[int]: if not matrix: return [] m, n = len(matrix), len(matrix[0]) total = m * n visited = [[False] * n for _ in range(m)] res = [] dr = [0, 1, 0, -1] dc = [1, 0, -1, 0] d = 0 r = c = 0 for _ in range(total): res.append(matrix[r][c]) visited[r][c] = True # 先按当前方向试探下一步 nr, nc = r + dr[d], c + dc[d] if nr < 0 or nr >= m or nc < 0 or nc >= n or visited[nr][nc]: # 越界或已经走过,转向 d = (d + 1) % 4 nr, nc = r + dr[d], c + dc[d] r, c = nr, nc return res这个解法的关键在 visited 数组。它记录哪些格子已经走过,用来判断"是否该转向"。注意转向条件用了五个 or:上下左右四个越界条件,加上一个"已经被访问过"。其中"已被访问过"处理的是从下往上走之后再转向的场景,比如内圈的格子被外层包围后,再往前走一步就会踩到已经访问过的区域。
循环次数直接用 total = m*n 控制,保证每个格子只被访问一次,不会多走也不会少走。这个思路其实比人工去数每条边有多长更"无脑",写起来也更不容易漏。
3.3 两种解法对比
| 对比点 | 边界收缩法 | 方向转向法 |
|---|---|---|
| 核心变量 | top/bottom/left/right | 方向数组 + visited |
| 空间复杂度 | O(1) | O(m*n) |
| 边界判断 | 两个if防重复 | 一个转向条件 |
| 面试观感 | 严谨,体现边界控制能力 | 简洁,体现思路开阔 |
我个人的体会是:面试时先讲边界收缩法,因为它的空间复杂度更好,而且能体现你准确把握边界的能力。讲完再补一句"如果想更简洁,也可以用方向数组加visited数组实现",通常能让面试官觉得你的思路是开放的。但不要把方向转向法作为唯一的解法,因为开一个和矩阵一样大的visited数组在面试官眼里并不漂亮。
4. 高频踩坑点与排查实录
4.1 空矩阵和空行:第一道门槛
力扣的输入是 List[List[int]],很多测试集都会包含matrix = []。不在开头挡一下,访问 matrix[0] 就直接抛 IndexError。更隐蔽的是matrix = [[]],也就是"有外层但第一行没有元素"的情况。
处理方式就一行:
if not matrix or not matrix[0]: return []这里的 or 是短路逻辑:先判断 matrix 是否为空,如果为空直接返回,matrix[0] 根本不会执行,所以不会出现下标错误。这是一个典型的Python写法,面试时可以顺嘴提一句,展示你对语言特性的熟悉程度。
4.2 单行、单列矩阵的重复读取问题
假设矩阵是 [[1,2,3,4]],程序若不加两个if,会怎样?
- 第一段读一整行:1、2、3、4。
- 第二段不读,因为没有下方元素。
- 第三段又从右往左读:4、3、2、1。
- 输出变成 [1,2,3,4,4,3,2,1]。
非常离谱,但这是新手最常见的bug,没有之一。再用2x2矩阵验证一下:[[1,2],[3,4]] 若不加if,输出顺序是上边1、2,右边4,下边3,左边1,最后结果是 [1,2,4,3,1],左上角被重复读了一次。加上两个if后,才能得到正确的 [1,2,4,3]。
这个例子建议你亲手推一遍,推完就明白那两个if到底在防什么了。
4.3 range倒序遍历的下标陷阱
正向读range(left, right + 1)没人会写错,但反向读range(right, left - 1, -1)经常有人写错。常见的错误写法有两种:
- 写成
range(left, right),直接漏掉最左边的元素。 - 写成
range(right, left, -1),少读一个元素,循环范围完全不对。
记住:range是左闭右开区间。要从 right 倒着读到 left,就必须让停止位置比 left 再往前一格,所以是 left-1。这个细节看着小,写错了结果就错得离谱,而且有时候并不会直接报错,只是输出少几个数,排查起来更费劲。
4.4 怎么快速定位自己代码的问题
如果写出来跑不对,我建议用一个"最小非平凡用例"去人工推演,比如 3x4 或 4x3 这种行列不等的矩形。在纸上模拟一次循环,标记 top、bottom、left、right 的变化,看哪一步出了问题。
我在帮别人看代码时发现,大部分错误并不是逻辑完全不会,而是某一步边界更新的顺序搞反了。比如有人在读第三段之前就先 right -= 1,导致第三段的终点提前了一名,漏掉一整列。这种bug靠眼睛是看不出来的,必须把变量值一步步写下来对照。
5. 变体题:螺旋矩阵II与更多扩展
5.1 螺旋矩阵II:把读取倒过来变成生成
力扣第59题螺旋矩阵II正好是这一题的逆运算:给你一个正整数 n,要求生成一个包含 1 到 n^2 所有元素的 n x n 螺旋矩阵,元素按顺时针螺旋顺序排列。n=3 时,输出是:
[[1,2,3], [8,9,4], [7,6,5]]思路几乎一样:还是四个边界变量,但把"从矩阵读元素append到结果"换成"从小到大把数填进矩阵"。初始化一个 n x n 的全零矩阵,然后用一个变量 num 从1递增到 n*n,按上右下左的顺序填数。同样需要两个if防重填。
参考实现:
from typing import List class Solution: def generateMatrix(self, n: int) -> List[List[int]]: mat = [[0] * n for _ in range(n)] num = 1 top, bottom, left, right = 0, n - 1, 0, n - 1 while top <= bottom and left <= right: for col in range(left, right + 1): mat[top][col] = num num += 1 top += 1 for row in range(top, bottom + 1): mat[row][right] = num num += 1 right -= 1 if top <= bottom: for col in range(right, left - 1, -1): mat[bottom][col] = num num += 1 bottom -= 1 if left <= right: for row in range(bottom, top - 1, -1): mat[row][left] = num num += 1 left += 1 return mat这个代码的坑在于,一定要按"填一个格子,num加一"的顺序同步操作,不能先全部填完再加,否则内层数字会错位。把这道题放在螺旋矩阵后面做,边界收缩法的理解会明显变深。
5.2 逆时针螺旋等变体的通用处理
面试官可能会换个方向:把顺时针改成逆时针,或者要求从内圈往外绕。其实底层逻辑完全一致,只需要调整"方向顺序"或"起始边界"。
逆时针拿3x3举例,路径是 1 -> 4 -> 7 -> 8 -> 9 -> 6 -> 3 -> 2 -> 5。用方向转向法,只要把方向数组的顺序从"右、下、左、上"改成"下、右、上、左"即可。用边界收缩法,把四段遍历的顺序改成"左边、下边、右边、上边"。
从内到外则稍微复杂一点,因为要先确定起始点。奇数阶矩阵可以从中心开始,偶数阶要先选好起始位置,或者把矩阵补一圈再剥。这种变体看起来花哨,其实是在考察你有没有真正理解"边界变量 + 固定方向顺序"这一套抽象。理解了,变体只是换个顺序的事。
5.3 和旋转矩阵、蛇形矩阵的联动
推荐两道相关题目一起刷:48题旋转图像,以及498题对角线遍历。旋转图像的核心是"层与层之间的变换",和螺旋矩阵的层级收缩是同一套思维;对角线遍历涉及方向切换和边界判断,和方向转向法的 visited 思想有共同点。
刷题讲究举一反三。螺旋矩阵吃透了,这几个变体大概率能触类旁通。如果你还有余力,可以再试试把矩阵旋转、回形遍历、对角线遍历放在一起对比总结,你会发现二维数组题的高频考点其实就是下标变换和边界控制这两件事。
最后说点我个人的体会。螺旋矩阵这道题看起来简单,但它对"边界感"的锻炼非常值钱。我前面反复强调的两个if判断,实际上在很多工程代码里都会遇到。比如做图像邻域操作、卷积计算、地图寻路的时候,判断"当前位置旁边还有没有格子"就是这种操作。养成"每一步先确认边界是否依然有意义"的习惯,能帮你少踩很多坑。
再分享一个小技巧:面试时写完边界收缩法,可以主动补充一句"这两个if分别对应单行和单列的情况,是为了防止重复读取"。这样一句话就能告诉面试官,你不仅会写,而且知道为什么要写。配合前面聊过的方向转向法拓展思路,这道题基本就能拿满印象分。