聊 LeetCode 59 螺旋矩阵 II。刷过算法题的朋友应该都有感觉,模拟类问题在面试里属于“看着简单,写起来翻车”的重灾区。这道题给你一个正整数 n,要你生成一个 n×n 的矩阵,把 1 到 n² 按顺时针螺旋顺序填进去。它不涉及复杂的算法思想,既不考贪心也不考动规,纯粹考察你对二维数组遍历、边界控制和循环终止条件的掌控力。很多人在面试中遇到它,逻辑上说得头头是道,一动手写代码就各种越界、覆盖、死循环,最后只能尴尬收场。这篇文章我就把这个题从头到尾拆开,讲清楚两条主流的实现路线,以及我在实际调试中踩过的坑和总结出来的排查技巧。无论是刚开始刷题的新手,还是准备冲刺面试的进阶玩家,这篇都能给你一些不一样的参考。
1. 题目拆解与整体思路选择
1.1 一道没有“算法难度”的算法题
先明确题目在做什么。给定 n=3,输出应该是:
1 2 3 8 9 4 7 6 5给定 n=4,输出应该是:
1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7整个过程就像一条蛇在矩阵里游走,先向右走到头,然后向下走到头,再向左走到头,最后向上走到头,每次走完一圈,活动区域就缩小一圈,直到所有格子填满为止。
这题难在哪?难在你必须同时管理多个边界条件。很多人在写循环时,要么忘了更新边界,要么更新顺序不对,要么没有处理单行单列的边缘情况。它考察的核心能力是“在复杂状态变化中保持代码清晰”,这种能力在工作中的实际价值很高——业务代码里到处都是状态管理,和这个本质上是一回事。
1.2 两条主流路线的选择逻辑
我见过的大多数题解会告诉你两种写法:一种是“边界收缩法”,维护上下左右四个变量,每填充一条边就收缩对应的边界;另一种是“方向模拟法”,用方向数组记录右、下、左、上四个方向,遇到越界或重复访问就转向。
这两种方法没有绝对的好坏,但适用场景和思维模式差别很大。
边界收缩法的核心逻辑是“一圈一圈处理”。每处理完一圈,矩阵的可填充区域就变小,直到 left > right 或 top > bottom 时结束。它的优点是思路直观,不需要额外空间,代码容易理解,面试时讲起来也清晰。缺点是代码相对较长,四个 for 循环容易让人眼花,而且边界收缩的时机必须严格对应。
方向模拟法的核心逻辑是“一步步走”。当前位置 (row, col),按照当前方向前进,如果下一步越界或者格子已经被填过,就右转 90 度继续走。它的优点是代码更短,状态转移更统一,处理任意形状的矩阵也更灵活。缺点是需要额外的标记空间(或者利用原数组初始值做标记),而且转向判断一旦写错就是死循环。
我个人在面试中推荐先用边界收缩法讲思路,因为面试官更容易跟上你的逻辑;如果面试官追问有没有其他做法,再补充方向模拟法。两条路线我都会在下面详细拆解,并给出完整的代码和踩坑点。
2. 边界收缩法:最直观的逐层填充实现
2.1 四个边界变量的初始化与循环条件
边界收缩法最核心的东西就是四个变量:
- left:当前未填充区域的最左列,初始为 0
- right:当前未填充区域的最右列,初始为 n-1
- top:当前未填充区域的最上行,初始为 0
- bottom:当前未填充区域的最下行,初始为 n-1
每次循环填充一圈,填充完一圈后,top 加 1、bottom 减 1、left 加 1、right 减 1。循环继续的条件是 left <= right 且 top <= bottom。这个条件很关键,它保证了我们只处理仍然存在的区域,不会重复填充。
用一个生活化的类比:想象你在一张纸上用笔一圈圈地画螺旋,每画完一圈,就把纸的四个边缘向里折一点,剩下的区域越来越小,直到纸的中心被填满。这就是边界收缩的直观感受。
2.2 每圈填充的四个动作与顺序
每一圈的填充顺序是固定的:先从左到右填上边,然后从上到下填右边,再从右到左填下边,最后从下到上填左边。这里有一个隐藏考点:当矩阵只剩一行或只剩一列时,四个填充动作并不需要全部执行,否则会重复覆盖已经填好的格子。
具体来说,填充完上边后 top 加 1;填充完右边后 right 减 1。此时如果区域已经收缩到只剩一行(也就是 top > bottom),那么下边的填充就不应该执行,否则会把上边刚填过的格子再填一遍。同样,右边填充完后如果只剩一列(left > right),左边的填充也应该跳过。这就是为什么在填充下边和左边时要额外加 if 判断的原因。
为了把这一点说清楚,我用一个 n=2 的例子手动走一遍:
- 初始 left=0, right=1, top=0, bottom=1
- 填上边:matrix[0][0]=1, matrix[0][1]=2,然后 top 变为 1
- 填右边:matrix[1][1]=3,然后 right 变为 0
- 此时 top=1, bottom=1,left=0, right=0,区域仍存在但只剩一行一列
- 填下边:matrix[1][0]=4,然后 bottom 变为 0
- 此时 left=0, right=0,但 top=1, bottom=0,top > bottom,循环结束
如果第四步不加判断,而是无条件填矩阵这一行,就会把已经填过的 3 覆盖掉。
2.3 完整代码与逐行细节说明
我给出一个清晰可运行的 Python 版本:
class Solution: def generateMatrix(self, n: int) -> List[List[int]]: matrix = [[0] * n for _ in range(n)] left, right, top, bottom = 0, n - 1, 0, n - 1 num = 1 while left <= right and top <= bottom: # 从左到右填充上边 for i in range(left, right + 1): matrix[top][i] = num num += 1 top += 1 # 从上到下填充右边 for i in range(top, bottom + 1): matrix[i][right] = num num += 1 right -= 1 # 从右到左填充下边 if top <= bottom: for i in range(right, left - 1, -1): matrix[bottom][i] = 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逐行拆解几个关键点:
第一个 for 循环从上边开始,区间是 left 到 right 闭区间,填完后一定要立即执行 top += 1。这一步很多人会忘,或者把 top += 1 放到所有循环结束之后,那样下一圈的上边边界就是错的。
第二个 for 循环填充右边,注意起点是更新后的 top,而不是原来的 top+1,因为此时 top 已经收缩了一行。区间是 [top, bottom],方向是从上到下。填完后执行 right -= 1。
第三个 for 循环填充下边之前,判断 top <= bottom 是否成立。只有当下边仍然存在时才执行。方向是从右到左,注意 range(right, left - 1, -1) 这个写法,三个参数分别是起点、终点(不含)、步长 -1,很多新手写成 range(right, left, -1),会漏掉最左边的那个元素。
第四个 for 循环填充左边之前,判断 left <= right 是否成立。方向是从下到上,起点是更新后的 bottom,终点是 top,注意也是用 top - 1 作为 range 的终点来包含 top 那行。
这个版本的代码为什么不容易错?因为每一步都严格对应一次边界收缩,只要边界变量和 for 循环区间对齐,逻辑上就很顺。
2.4 为什么说“先收缩再做下一步”更容易排查问题
我在调试这类代码时有一个习惯:把边界变量的变化看作“状态机”。每填充一条边,状态就迁移一次。如果代码运行结果不对,我会在 while 循环开头打印 left、right、top、bottom 四个值,再打印当前矩阵,立刻能看出来是哪一步的状态迁移出了错。
这种方式比方向模拟法更容易排查,因为边界收缩法的状态变化是显式的——你随时知道当前应该填哪条边、应该填哪个区间。相比之下,方向模拟法的状态是隐式的,错了之后你只能看到某个格子填错,但很难直接判断是方向数组的问题、转向条件的问题还是初始值的问题。
所以我的建议是:面试写这道题时,优先用边界收缩法。它虽然代码行数多一点,但每一步都可以向面试官解释清楚,即使出了小问题,你也能快速定位和修正。
3. 方向模拟法:更短更通用的另一种解法
3.1 方向数组与转向条件的核心设计
方向模拟法的思路是完全不同的:我不再考虑“圈”的概念,而是让一个“指针”从左上角出发,沿着当前方向一格一格走。每走一格就填入一个数字。在准备走下一格之前,检查一下下一步会不会越界,或者下一步的格子是否已经被填过。如果会,就右转。
关键在于方向数组的设计。定义四个方向:
dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)]这个数组分别对应向右、向下、向左、向上。当前位置是 (row, col),当前方向索引是 d,那么下一步的位置就是:
nr = row + dirs[d][0] nc = col + dirs[d][1]如果 (nr, nc) 越界,或者 matrix[nr][nc] != 0,说明这个方向走不下去了,需要转向。转向的方法是 d = (d + 1) % 4,从右转到下,从下转到左,从左转到上,从上再转回右,形成完美闭环。
为什么用 matrix[nr][nc] != 0 作为已访问的判断?因为我们初始化矩阵时全部置 0,而填入的数字从 1 开始,所以 0 天然就是“未访问”标记,不需要额外开一个 visited 数组。这个小技巧让代码更简洁。
3.2 转向条件的完整代码与执行流程
class Solution: def generateMatrix(self, n: int) -> List[List[int]]: matrix = [[0] * n for _ in range(n)] dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)] row, col, d = 0, 0, 0 for num in range(1, n * n + 1): matrix[row][col] = num nr = row + dirs[d][0] nc = col + dirs[d][1] if nr < 0 or nr >= n or nc < 0 or nc >= n or matrix[nr][nc] != 0: d = (d + 1) % 4 nr = row + dirs[d][0] nc = col + dirs[d][1] row, col = nr, nc return matrix这段代码的执行流程可以这样理解:每轮 for 循环只填一个格子。先填当前格子,然后计算“如果在当前方向上继续走,下一步会到哪”。如果下一步不能走,就转向,重新计算下一步。然后把当前位置更新为下一步的位置,进入下一轮循环。
这里有一个极其容易踩的坑:转向之后,要重新计算 nr 和 nc,而不是直接用原来的 nr、nc 再加偏移量。我看到很多新手写成这样:
if 越界: d = (d + 1) % 4 row += dirs[d][0] col += dirs[d][1]这种写法看似合理,实际上是把“下一步位置”和“当前位置增量”混在一起了。正确做法是先算出下一步的目标位置,确认合理后再更新 row 和 col。如果转向,就必须基于原位置重新计算目标位置。否则,你会发现指针越走越偏。
3.3 方向模拟法的优势与潜在问题
方向模拟法的最大优势是代码短、状态统一。它天然支持非正方形矩阵,也天然支持“已访问区域作为障碍”的通用场景。只要修改方向数组的顺序,就可以实现逆时针螺旋;只要加一个 visited 标记,就可以处理矩阵内部有障碍物的情况。
但它也有一个隐蔽的问题:它依赖“上一步已经填过”的信息来判断转向。如果你忘记把矩阵初始化成 0,或者填入的数字从 0 开始,那么 0 就不再是“未访问”标记,判断逻辑会直接失效。所以在实际编码时,我会先检查矩阵的初始值和数字的起始值是否冲突。
另一个问题是性能。边界收缩法在每次循环中直接定位一整条边,方向模拟法是一格一格移动,虽然二者的时间复杂度都是 O(n²),但方向模拟法的常数项稍大。不过对于这个题的规模,完全可以忽略不计。
3.4 两种方法应该怎么选
如果面试官只要求“给出一种解法”,我建议你写边界收缩法,因为它的边界状态显式,面试官容易跟踪你的思路。如果面试官追问“能不能让代码更短”,或者“如果矩阵不是正方形怎么办”,我会切换到方向模拟法,展示你对通用解法的理解。
事实上,这两种方法并不冲突。很多熟练的工程师会在心里同时记住两种模型:边界收缩模型适合“按层处理”的场景,方向模拟模型适合“逐步游走”的场景。螺旋矩阵 II 刚好覆盖了这两个典型场景,所以它才能成为面试高频题。
4. 复杂度分析、易错点与参数细节
4.1 时间复杂度和空间复杂度到底怎么算
两个方法的时间复杂度都是 O(n²),因为最终生成的矩阵有 n² 个元素,每个元素都要被填充一次,这个下界是不可避免的。空间复杂度也相同,除了要返回的 matrix 本身占用 O(n²) 空间外,额外空间都是 O(1)——边界变量或方向数组都只占常量空间。
这里有一个面试官可能会追问的细节:既然输出本身就有 n² 个元素,为什么还能说“额外空间 O(1)”?因为复杂度分析里,通常把必须的输出空间排除在“额外空间”之外。如果你把返回的矩阵也算进去,所有解法都是 O(n²),这个指标就没有区分度了。所以在面试中回答复杂度时,要特意说明“不计入结果矩阵本身的空间”。
4.2 边界收缩法的高频易错点清单
我结合自己刷题和帮别人 review 代码的经历,把边界收缩法最常出问题的几个位置列一下:
- 边界收缩的顺序错乱。正确顺序是:填上边后收缩 top,填右边后收缩 right,填下边后收缩 bottom,填左边后收缩 left。这个顺序不能乱,因为每个循环的区间依赖前面已经收缩过的边界。如果你先收缩 right 再去填下边,下边的区间就少了一格。
- 第三个和第四个循环忘记加 if 判断。这个错法在 n 为偶数时通常不暴露,但在 n 为奇数时,最内层会出现只剩一行或一列的情况,不加判断就会重复填充,甚至数组越界。
- range 的终点写错。填下边时,如果写成 range(right, left, -1),会漏掉最左边的元素,导致螺旋形状断开。正确的是 range(right, left - 1, -1),关键是理解 range 的终点是不包含的。
- 用 num 计数时,忘记在每次赋值后自增。这种低级错误虽然好排查,但一旦发生,往往是在高压面试环境下,人会一阵慌乱。我的习惯是写完代码后立刻自查一遍自增逻辑,确认 num 的最终值是 n² + 1。
为了更直观,我做一个错误示范的对比表格:
| 错误写法 | 错误结果 | 正确写法 |
|---|---|---|
for i in range(right, left, -1) | 漏填最左列元素 | range(right, left - 1, -1) |
下边填充前不加if top <= bottom | 单行时重复填充已填格子 | 加条件判断 |
| 四个 for 写完后再统一收缩边界 | 下一次循环区间错误 | 每个 for 后立即收缩对应边界 |
循环条件写成left < right | 中心元素未填 | left <= right |
4.3 方向模拟法的高频易错点清单
方向模拟法虽然代码短,但错误模式更加隐蔽:
- 转向后没有重新计算目标位置。这是最大的坑,我在前面已经强调过。一旦转向后还用旧目标位置,指针就会“走歪”,你可能要调试很久才能发现真正原因是这个细节。
- 把矩阵初始化为 0 之外的数字。比如有些语言默认二维数组初始值可能是随机值,或者在 Java 里如果用了 Integer 包装类数组,默认是 null,直接用 matrix[nr][nc] != 0 会导致深层次的问题。稳妥的做法是明确初始化所有元素为 0。
- 方向数组下标越界。如果你忘记取模,d 一直累加到 4 时就会越界。正确写法是 (d + 1) % 4 或者 d = (d + 1) & 3,因为 4 是 2 的幂。
- 循环次数错误。如果 for 循环写成 range(1, n * n) 而不是 range(1, n * n + 1),最后一个格子不会被填充,矩阵中心会留下 0。这个错误很容易漏网,特别是当你用打印的方式检查时,矩阵最后一行可能因为视觉惯性被忽略。
我在实际调试中总结了一个习惯:不管用哪种方法,填充完成后都写一个简单的断言,检查 matrix[0][0] == 1 且 matrix[n-1][n-1] 或矩阵中心是 n²。如果这两个点对不上,说明最外圈或最内圈出了问题。
5. 实战排查:常见问题定位与解决实录
5.1 用“打印矩阵”代替空想调试
我发现很多人在刷这类题时,代码跑错后习惯盯着代码愣看,效率极低。正确的做法是,在每一步关键操作后打印当前矩阵和关键变量。以边界收缩法为例,我在 while 循环里加打印:
while left <= right and top <= bottom: print(f"left={left}, right={right}, top={top}, bottom={bottom}") # 填充代码... for row in matrix: print(row)这样运行一次 n=3 的用例,整个过程就一清二楚了。我第一次用这种方法排查时,发现自己犯的错误是第三个 for 循环的 range 终点写错了,但光看代码死活没看出来;打印矩阵后,看到下边漏了一个数字,立刻就定位到了问题。
方向模拟法同理,你可以在每次填充后打印 row、col、d 和当前矩阵。如果发现某个格子的数值不对,回溯一下这一步的方向索引和坐标,就能判断是转向条件的问题还是方向数组定义的问题。
5.2 小规模用例验证法
不要一上来就跑 n=5、n=10 这种大用例,出了错很难定位。我建议按 n=1、n=2、n=3、n=4 的顺序逐个验证:
- n=1 验证最简单的单元素场景,输出必须是 [[1]]。
- n=2 验证“一圈就结束”的场景,输出必须是 [[1,2],[4,3]]。
- n=3 验证完整的一圈加中心,输出必须是 [[1,2,3],[8,9,4],[7,6,5]]。
- n=4 验证两圈的情况,输出最内层是 2×2 的子矩阵。
这四个规模覆盖了所有典型的边界场景:单元素、单圈、多圈、偶数阶、奇数阶。如果这四种都通过,基本可以放心提交。我的经验是,绝大多数错误都会在 n=2 或 n=3 时暴露出来。其中 n=2 最容易暴露“四个 for 无条件执行”的问题,n=3 最容易暴露 range 终点写的亏漏问题。
5.3 边界值自查清单
提交之前,我会快速在脑子里过一遍这几个关键点:
- num 的最终填充位置是不是矩阵的几何中心?对于奇数 n,中心是 (n//2, n//2);对于偶数 n,最后填充的是内圈左边的某个格子。
- 最后一个填充的数字是不是 n²?
- 整个矩阵里的数字有没有重复?用集合去重检查,如果不是 n² 个不同数字,说明发生了重复填充。
- 有没有任何元素保持初始值 0?如果有,说明漏填了某些位置。
这四个问题,任何一个回答“是”,都说明代码还有问题。特别是重复填充和漏填,它们经常同时出现——一个格子被重复填了,另一个格子就没被填到,总和仍然是 n² 个格子,但内容错了。这时候光看格子数量是排查不出来的,必须验证数字的完整性。
6. 从螺旋矩阵 II 延伸出来的变种与真实面试定位
6.1 螺旋遍历矩阵:生成的反向操作
LeetCode 54 题正好是螺旋矩阵 II 的反向操作:给定一个 m×n 的矩阵,按螺旋顺序输出所有元素。方向模拟法几乎可以直接复用,只需要把“填充数字”改成“读取元素”。这道变种在面试中出现频率更高,因为很多公司喜欢考“读取”而非“生成”。
我在准备面试时,是把这两道题放在一起复习的。生成螺旋矩阵的代码写熟了,螺旋遍历的代码只需要调整几行逻辑。反过来,如果你先掌握了螺旋遍历,再写生成时,只需要把读操作换成写操作,方向数组和边界判断完全一致。这种“正反互推”的复习方式,会让你对问题本质的理解更深。
6.2 矩形矩阵与逆时针螺旋变种
标准的螺旋矩阵 II 要求的是正方形矩阵,但面试官可以轻易把它改成“生成 m×n 的螺旋矩阵”。这时候边界收缩法就比方向模拟法更麻烦一些,因为矩形矩阵最后可能剩下一行或一列,你必须非常仔细地处理 if 判断。方向模拟法反而更简单——方向数组和转向条件完全不用变,只需要把 n 换成 rows 和 cols,并在判断越界时分别用 rows 和 cols 做边界。
逆时针螺旋也很容易实现。方向模拟法只需要把方向数组改成:
dirs = [(0, 1), (0, -1)? 不对,逆时针需要从右→上→左→下还是右→下→左→上?让我想清楚。标准顺时针的方向顺序是 右 → 下 → 左 → 上,对应 dirs = [(0,1), (1,0), (0,-1), (-1,0)]。如果要求逆时针,也就是 右 → 上 → 左 → 下,对应 dirs = [(0,1), (-1,0), (0,-1), (1,0)]。你只需要调整方向数组的排列顺序,转向逻辑完全不用动。这就是方向模拟法在扩展性上的优势。
6.3 从中心向外螺旋的输出方式
还有一个更进阶的变种:不是从左上角开始顺时针向外绕圈,而是从矩阵中心开始,逆时针或顺时针向外扩散填充。这类问题在实际工作中可能对应“从某点扩散遍历”的场景,比如图像处理里的区域生长,或者地图生成里的波纹扩散。
从中心向外螺旋和标准螺旋最大的区别是:标准螺旋的方向切换时机是“碰到边界或已访问区域”,而从中心向外时,你通常要维护一个“层数”或者“步长”的概念。比如按右、上、左、下的顺序走,步长依次为:右走 1 步,上走 1 步,左走 2 步,下走 2 步,右走 3 步……这个步长规律是:每走两个方向,步长加 1。如果你能把这个规律应用到方向模拟法中,代码会相当简洁。
这种变种题并不算特别高频,但一旦出现,很多没准备过的人会当场卡住。因为大家习惯了标准螺旋的“从外面绕到里面”,逆向来一次就有点反直觉。我个人建议是把标准螺旋、螺旋遍历、从中心扩散这三种放在一起复习,你会发现它们共享同一个核心模型:方向数组 + 位置更新 + 转向判断。
6.4 这道题在面试中的真实定位
最后说点实在的。螺旋矩阵 II 这道题,在面试评级中通常属于“基础中等”难度。它不会直接决定你是否通过面试,但经常作为一个“过程题”出现:面试官先用它来考察你写代码的基本功,再在这个基础上叠加追问,判断你的思维深度。
我印象很深刻的一次经历是,面试官让我写完标准解后,马上追问:“如果矩阵里有障碍物,数字碰到障碍物时应该怎么办?”这一下就把题目从“数组遍历”升级到“路径模拟”。我当时用的是方向模拟法,所以只要在判断转向条件里加一个“下一步是否是障碍物”的判断即可,很自然地就答上来了。如果当时我只会边界收缩法,这个追问就会难很多。
所以我的建议是:两道题都掌握,并且能说清楚各自适合什么场景。至少要能在面试中做到,讲完一种方法后,主动补充一句“这道题也可以从另一个角度理解”。这种主动展示知识深度的行为,往往比默默写出标准答案更让面试官印象深刻。
这篇文章写到这,核心的内容已经全部覆盖了。最后分享一个我在实际刷题中的体会:螺旋矩阵这类模拟题,第一次写错很正常,千万不要怀疑自己的智商。我第一次写 n=4 的用例,跑出来中间一个数字漏填了,我当时盯着代码愣是没找到原因,后来乖乖打了一遍日志,才发现是第三个 for 循环的终点写错了。从那以后,我养成了“先打日志,再动脑子”的调试习惯。这种习惯,远比背下这道题的答案更有价值。