刷LeetCode热题100的朋友,几乎没人能绕开螺旋矩阵这道题。题号54,题目描述连一行都不到:给你一个 m 行 n 列的矩阵,按顺时针螺旋顺序返回矩阵中的所有元素。规则一句话就能说清,可真到面试现场手写代码,翻车率却高得惊人。我最早刷这道题时也栽过跟头,代码跑起来要么死循环,要么重复收集元素,最后对着控制台一点点打日志,才反应过来是边界条件写拧了。后来我在不同公司面试里遇到过三次这道题,帮别人review代码也看过不少版本,慢慢把这道题的坑都摸透了。
这道题难不在算法思想,而在“模拟一个过程”时的边界管理能力。热题100把它放在中间位置,不是没有道理的——它考察的是那种“看着简单、写着易错”的代码功底,而这种功底恰恰是很多高强度业务开发里真正需要的东西。
1. 为什么“螺旋矩阵”能成为热题100的常驻嘉宾
1.1 题目本身到底在考什么
先看题目最原始的样子。给定一个矩阵:
1 2 3 4 5 6 7 8 9 10 11 12按顺时针螺旋顺序输出,结果应该是:
1, 2, 3, 4, 8, 12, 11, 10, 9, 5, 6, 7圈数不多,但每一圈都要经历“向右、向下、向左、向上”四个方向。难点在于:当矩阵不是正方形时,最后一圈可能只剩一行或者只剩一列,这个时候还是机械地走四个方向,就会出现越界或者重复收集。
大部分第一次写这道题的人,脑子里第一个念头是——我用一个方向数组,碰到边界就转向不就行了。这种“方向模拟法”确实是一种思路,但它有个隐藏成本:你得额外开一个同尺寸的visited数组来标记哪些格子已经访问过。空间复杂度从O(1)变成O(m*n),在面试现场如果被追问能否优化空间,很多人就卡住了。
1.2 为什么大厂面试喜欢拿它当“手写代码”环节
热题100里的题很多,有些考动态规划,有些考二分,有些考图遍历。螺旋矩阵属于“模拟题”这个类别,它没有高深的算法套路,纯靠逻辑严谨性。面试官选它,往往不是为了考你背没背过题解,而是想观察你在一个明确规则下,能不能把过程拆解清楚,能不能想到边界情况,能不能在手写代码时保持变量关系清晰。
面试反馈里经常看到这样的评价:候选人思路清晰,代码写对了,但花了二十分钟。真实原因就是边界条件反复试错。这种题如果能在十分钟内一遍写对,至少说明你有比较强的“状态管理”意识——每一圈走完,哪些边界要收缩,下一步从哪里继续,心里有数。
1.3 它和后续题目的关联
别小看这道题。它的思想会延展到好几道题上:螺旋矩阵II是反向构造,给你一个n,让你生成螺旋矩阵;螺旋矩阵III是从任意起点开始螺旋走;还有矩阵旋转、蛇形遍历、之字形打印,本质上都是同一类“按规则遍历矩阵”的问题。把螺旋矩阵的边界收缩逻辑吃透,后面遇到这些题会轻松很多。
2. 先想清楚遍历规律:顺时针螺旋的拆解
2.1 用“走迷宫”的视角理解螺旋
想象你在一个矩形迷宫入口,规则很简单:沿着当前方向一直走,走到墙就右转。这个“墙”有两种:一种是矩阵本身的物理边界,另一种是你已经踩过的格子。如果不用额外数组,怎么判断已踩过的格子?答案是想办法让“墙”随遍历进度向内收缩。
每一圈由四条边组成:
- 上边:从左往右走,走完这一行后,上边界下移一行;
- 右边:从上往下走,走完这一列后,右边界左移一列;
- 下边:从右往左走,走完这一行后,下边界上移一行;
- 左边:从下往上走,走完这一列后,左边界右移一列。
这个过程像不像一个矩形框在逐渐缩小?没错,这就是“边界收缩法”的核心形象。四个变量——top、bottom、left、right——框出当前还没有遍历的区域,每走完一条边,就把对应的边界往里缩一格。
2.2 两种主流思路对比
方向模拟法和边界收缩法都能做对,但风格差别很大,面试表现也不一样。
| 对比维度 | 方向模拟法 | 边界收缩法 |
|---|---|---|
| 核心思路 | 用方向数组控制前进方向,访问过就转向 | 用四个边界变量框定未遍历区域,走完一条边收缩一次 |
| 额外空间 | 需要visited数组,O(m*n) | 不需要额外数组,O(1) |
| 代码量 | 略短,但转向条件多 | 稍长,但逻辑直观 |
| 出错点 | 转向时机、越界判断 | 单行单列时的重复收集 |
| 面试官观感 | 能跑通,但追问空间优化会露怯 | 边界清晰,容易讲明白 |
如果只是为了AC,两种方法都行。但面试场景下,我强烈推荐边界收缩法。理由很简单:它把“访问过”这个隐性状态,转化成了“边界变量”这个显性状态,每一步都看得见摸得着,不会出现“我明明转向了为啥又回去了”这种玄学问题。
2.3 特别提醒:不要一开始就陷入“逐格模拟”的细节
有个常见误区是拿到题就想着“我怎么知道下一步该往哪走”,于是开始设计方向数组、设计转向条件。这样也能做,但容易漏边界。我的建议是:先画一个3×3矩阵,从外到里标出访问顺序,然后观察每一圈的规律,再把规律转成代码。螺旋遍历的规律是“每圈走四条边,走完边界收缩”,这比“遇到墙就右转”更容易写出正确代码。
3. 边界收缩法的完整推导与代码实现
3.1 核心不变量:每一步都在“未遍历区域”的边界上
写边界收缩法之前,先明确一个不变量:变量top、bottom、left、right分别表示当前尚未遍历区域的上下左右边界,遍历过程就是不断从外圈向内圈收缩。
用3×3矩阵举例:
1 2 3 4 5 6 7 8 9初始状态:top=0, bottom=2, left=0, right=2。
第一圈:
- 上边:访问matrix[0][0], matrix[0][1], matrix[0][2],top变为1;
- 右边:访问matrix[1][2], matrix[2][2],right变为1;
- 下边:访问matrix[2][1], matrix[2][0],bottom变为1;
- 左边:访问matrix[1][0],left变为1。
此时top=1, bottom=1, left=1, right=1,还剩中间的5。继续进入第二轮循环,上边访问matrix[1][1],top变成2,循环结束。
这个例子很清楚地展示了,每一圈结束后,未遍历区域缩小了一圈,而每个元素都被恰好访问一次。
3.2 Python实现:最推荐的“手写版”
Python代码简单直观,适合作为面试手写的主语言,也适合快速验证思路:
def spiralOrder(matrix): # matrix为空或者首行为空时,直接返回[] if not matrix or not matrix[0]: return [] m, n = len(matrix), len(matrix[0]) top, bottom = 0, m - 1 left, right = 0, n - 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这段代码里最关键的是第3步和第4步前面的两个if判断。很多网上的题解版本会把这两个判断省略,那是因为它们在while循环条件上做了额外处理,但初学者照着写,很容易在单行或单列矩阵上报错。加上这两个if,逻辑才是完备的。
3.3 Java与C++的实现差异
如果面试官指定Java或C++,思路完全一样,只是语法稍有不同。Java版本核心循环:
public List<Integer> spiralOrder(int[][] matrix) { List<Integer> res = new ArrayList<>(); if (matrix == null || matrix.length == 0 || matrix[0].length == 0) { return res; } int top = 0, bottom = matrix.length - 1; int left = 0, right = matrix[0].length - 1; while (top <= bottom && left <= right) { for (int j = left; j <= right; j++) { res.add(matrix[top][j]); } top++; for (int i = top; i <= bottom; i++) { res.add(matrix[i][right]); } right--; if (top <= bottom) { for (int j = right; j >= left; j--) { res.add(matrix[bottom][j]); } bottom--; } if (left <= right) { for (int i = bottom; i >= top; i--) { res.add(matrix[i][left]); } left++; } } return res; }C++版本几乎一样,唯一的坑是二维vector的判空比数组严格。如果你是C++选手,我建议把if(matrix.empty() || matrix[0].empty())写在最前面,否则后面对matrix[0]的访问可能直接越界。
3.4 复杂度分析:为什么说它空间是O(1)
时间复杂度:矩阵有m×n个元素,每个元素被访问一次、加入结果一次,所以是O(m×n)。
空间复杂度:除了返回结果数组res,算法本身只用top、bottom、left、right四个变量,以及循环里的临时变量。严格来说,结果数组是题目要求返回的,不计入额外空间。所以额外空间是O(1)。
这也是边界收缩法比方向模拟法更优秀的地方:同等时间复杂度下,空间少了一个量级。面试时把这个复杂度分析讲清楚,面试官会认为你真的理解了这道题,而不是背了题解。
4. 最容易栽跟头的三个细节:单行、单列与死循环
4.1 单行矩阵为什么会越界也能跑但结果错误
考虑一个只有一行的矩阵:
[1, 2, 3, 4]初始状态:top=0, bottom=0, left=0, right=3。
走完第一步“上边”,收集了1、2、3、4,top变成了1。此时while条件top <= bottom是1 <= 0,直接退出循环,结果正确。
看起来没问题对吧?但如果去掉代码里的两个if判断,流程会怎样?第二步“右边”执行的是for i in range(top, bottom+1),也就是range(1, 1),空循环不会执行。第三步“下边”执行for j in range(right, left-1, -1),也就是从3到0,会把矩阵[bottom][j]也就是matrix[0][3]到matrix[0][0]全部重新收集一遍,结果变成重复收集。这就要出大问题了。
换句话说,单行矩阵的问题不在越界,而在第三步不该执行时却被执行了,形成了反向重复遍历。这个bug特别隐蔽,因为小规模测试时可能看不出问题,只有矩阵行数为1或列数为1时才暴露。
4.2 单列矩阵的镜像问题
对称地,单列矩阵:
[1, 2, 3]^T初始:top=0, bottom=2, left=0, right=0。
第一步上边收集1,top变成1。第二步右边收集2、3,right变成-1。此时如果不加if left <= right判断,直接执行第四步“左边”,会从bottom到top反向再收集一遍,结果也是重复。
这两种情况本质上是同一个问题:在只剩一行或只剩一列的最后一圈,不能走完四条边,只能走两条边甚至一条边。两个if判断就是在给“是否还有剩余行/列”把关。
4.3 while循环条件为什么是<=而不是<
这是一个容易被忽略但极其重要的细节。
用<=表示:只要当前还有至少一行一列未被遍历,循环就继续。如果用<,当最后只剩一行时,top和bottom相等但不会进入循环,中间那行元素就会丢失。
我见过有人为了避免单行单列重复的问题,把while条件改成top < bottom && left < right,结果在3×3矩阵上就丢了中心的5。因为3×3矩阵第二轮开始时top=1, bottom=1,top < bottom为false,循环直接结束,中心元素没被收集。这种改法是典型的“顾此失彼”。
4.4 死循环的常见诱因与定位技巧
死循环在螺旋矩阵里不常见,但一旦出现,多半是边界变量更新与循环条件不匹配。比如你忘了在某个方向遍历后收缩对应边界,top一直不变,循环条件永远满足,就会反复收集同一个元素。
定位死循环的办法很简单,在循环里加一个计数器,超过m×n次就强制退出:
count = 0 while top <= bottom and left <= right: count += 1 if count > m * n: print("可能死循环了") break # ... 原有的边界遍历逻辑这种调试技巧虽然粗暴,但能快速确认问题方向。实际刷题时我建议先用小矩阵手动过一遍,3×3、3×4、1×3、3×1各跑一遍,能把大部分问题暴露出来。
我实战中的一条经验是:写完代码后,不要急着提交,先在草稿纸上用一个3×4或4×3的矩阵走一遍。手写走通一遍,基本就不会有边界问题了。很多时候你对着代码发呆找不到问题,但手推一遍立刻就能看出来是哪一步的边界写错了。
5. 变种题型:从螺旋遍历到螺旋构造与螺旋路径
5.1 LeetCode 59 螺旋矩阵II:思路反过来
螺旋矩阵II要求给定正整数n,生成一个包含1到n²所有元素的螺旋矩阵。它和54题正好相反:54题是遍历读出来,59题是按顺序写进去。
实现上同样用边界收缩法,区别只是把append改成赋值:
def generateMatrix(n): matrix = [[0] * n for _ in range(n)] top, bottom = 0, n - 1 left, right = 0, n - 1 num = 1 while top <= bottom and left <= right: 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在热题100里,59题经常和54题成对出现。面试时如果先考54题,大概率会追问“能不能反过来生成一个螺旋矩阵”,所以这两题建议一起准备。
5.2 LeetCode 885 螺旋矩阵III:带起点的螺旋
885题就更进阶了,给你起点坐标(rStart, cStart)和行数列数,从起点出发螺旋遍历,只记录矩阵范围内的坐标。它和标准螺旋矩阵的差别在于:螺旋路径的中心不在矩阵中心,甚至可能起点就在矩阵外面。
这种题的解法是用方向模拟法加“步长递增”规律:向右1步、向下1步、向左2步、向上2步、向右3步、向下3步……每走完两个方向,步长加1。这也是“螺旋”二字的本质:每一步走的距离是1, 1, 2, 2, 3, 3, 4, 4...
这类题在面试中出现频率不如前两者高,但在大厂的加面轮次里有可能作为扩展题出现。它的核心启示是:螺旋遍历的本质是“步长按规律递增的方向行走”,边界收缩法只是它在矩阵场景下的一种简化表达。
5.3 常见面试变形:逆时针、蛇形、从中心开始
逆时针螺旋:把方向数组的顺序从“右下左上”改成“下右上左”,或者把四条边的遍历顺序换一下。理解边界收缩法后,改动成本很低。
蛇形遍历(之字形打印):不按完整螺旋走,而是第一行从左到右、第二行从右到左、第三行再从左到右。它比螺旋简单,但常见于电话面试快速筛选。
从中心开始向外螺旋:本质是885题的镜像,更适合用方向模拟法实现。面试时如果被问到,可以先说自己熟悉的边界收缩法,然后分析为什么这类题更适合方向模拟。
我建议准备程度是这样的:54题必须闭眼能写,59题必须能快速改出来,885题了解思路即可,逆时针和蛇形作为扩展了解。
6. 实战复盘:我在面试现场讲这道题的过程
6.1 拿到题目后第一分钟做什么
我第一次在面试里遇到螺旋矩阵时,第一反应是有点慌,因为太熟了,反而怕写太快显得像背题。后来我总结了一套稳妥的节奏:
先把题目用自己的话复述一遍,跟面试官确认输入输出。然后不要急着写,说一句“我画个3×3的矩阵,先看一下每一圈的访问顺序”。画图这个动作很重要,一方面帮自己理清思路,另一方面让面试官看到你的思考过程。
画完图之后,我会说“我准备用四个边界变量来维护当前未遍历区域,每走完一条边就收缩对应的边界”。一句话把方案讲清楚,然后再动笔写代码。
6.2 边写边讲的节奏
写代码时不要闷头写,每写一段就说一下这段在做什么:
“这里先遍历上边,从左到右,走完top加1。” “接下来遍历右边,从上到下,走完right减1。” “在走下边之前,我加了一个if判断。因为如果只剩一行,上边走完top已经大于bottom,这时不应该再走第三、第四步,否则会重复收集。”
这些话看起来像是在自言自语,其实是在向面试官展示你的边界意识。我见过不少候选人代码写得飞快,但面试官问“这里为什么要加if”时答不上来,最后反而留下不好的印象。边写边讲,能避免这种尴尬。
6.3 面试官可能追问的扩展点
写完代码并且跑通基本用例后,面试官通常会有三个方向的追问。
第一个追问:“时间复杂度是多少?”这个好答,每个元素访问一次,O(m×n)。
第二个追问:“空间复杂度呢?”如果用的是边界收缩法,直接答O(1),并说明除了返回数组只用四个变量。
第三个追问:“如果矩阵是空的怎么办?”这个问题其实在代码开头已经处理了,if not matrix or not matrix[0]: return []。但要注意,如果是C++,matrix[0]可能为空,要对matrix.empty()单独判断。
还有一个小概率追问:“能不能用递归实现?”答案是可以,但没必要。递归实现每一圈调用一次,本质上还是边界收缩,但代码更绕,可读性变差。如果被问到,我会说“递归在这里没有额外收益,反而增加栈开销和代码复杂度,迭代实现更直观”。
6.4 我复盘后发现:这道题的真正价值在于“状态管理”
刷完这道题很久之后回头看,我越来越觉得螺旋矩阵的真正价值,不在于那个螺旋本身,而在于它训练了一种“状态管理”的思维。
写业务代码的时候,你经常要维护多个状态变量,比如分页参数、游标位置、当前有效范围。螺旋矩阵的top、bottom、left、right四个变量,本质上就是一种“有效范围”的管理。每处理一段数据,范围收缩一次,直到范围为空。懂得了这个抽象,你会发现很多“模拟类”的问题都有类似的解法,比如矩阵旋转、数组逆序、双指针收缩,本质上都是在维护“还有效的范围”。
从这个角度看,热题100把它列为必刷题,并不是因为它难,而是因为它小而有代表性。它用最小的代码量,把“边界管理、状态更新、异常分支”这三件事全考了一遍。能把这道题讲透、写对、说清复杂度,面试官对你代码能力的判断,基本就有底了。
最后再分享一个小经验:刷这种“模拟过程”的题,千万别只看题解。自己动手画矩阵、写代码、跑测试,踩一遍坑,记忆才深。我每次给朋友讲这道题,都会先让他写,写错了我再带着他一起看边界,效果比直接甩一份标准答案好太多。