
聊 LeetCode 59 螺旋矩阵 II。刷过算法题的朋友应该都有感觉模拟类问题在面试里属于“看着简单写起来翻车”的重灾区。这道题给你一个正整数 n要你生成一个 n×n 的矩阵把 1 到 n² 按顺时针螺旋顺序填进去。它不涉及复杂的算法思想既不考贪心也不考动规纯粹考察你对二维数组遍历、边界控制和循环终止条件的掌控力。很多人在面试中遇到它逻辑上说得头头是道一动手写代码就各种越界、覆盖、死循环最后只能尴尬收场。这篇文章我就把这个题从头到尾拆开讲清楚两条主流的实现路线以及我在实际调试中踩过的坑和总结出来的排查技巧。无论是刚开始刷题的新手还是准备冲刺面试的进阶玩家这篇都能给你一些不一样的参考。1. 题目拆解与整体思路选择1.1 一道没有“算法难度”的算法题先明确题目在做什么。给定 n3输出应该是1 2 3 8 9 4 7 6 5给定 n4输出应该是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当前未填充区域的最左列初始为 0right当前未填充区域的最右列初始为 n-1top当前未填充区域的最上行初始为 0bottom当前未填充区域的最下行初始为 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 判断的原因。为了把这一点说清楚我用一个 n2 的例子手动走一遍初始 left0, right1, top0, bottom1填上边matrix[0][0]1, matrix[0][1]2然后 top 变为 1填右边matrix[1][1]3然后 right 变为 0此时 top1, bottom1left0, right0区域仍存在但只剩一行一列填下边matrix[1][0]4然后 bottom 变为 0此时 left0, right0但 top1, bottom0top 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而不是原来的 top1因为此时 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 right4.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(fleft{left}, right{right}, top{top}, bottom{bottom}) # 填充代码... for row in matrix: print(row)这样运行一次 n3 的用例整个过程就一清二楚了。我第一次用这种方法排查时发现自己犯的错误是第三个 for 循环的 range 终点写错了但光看代码死活没看出来打印矩阵后看到下边漏了一个数字立刻就定位到了问题。方向模拟法同理你可以在每次填充后打印 row、col、d 和当前矩阵。如果发现某个格子的数值不对回溯一下这一步的方向索引和坐标就能判断是转向条件的问题还是方向数组定义的问题。5.2 小规模用例验证法不要一上来就跑 n5、n10 这种大用例出了错很难定位。我建议按 n1、n2、n3、n4 的顺序逐个验证n1 验证最简单的单元素场景输出必须是 [[1]]。n2 验证“一圈就结束”的场景输出必须是 [[1,2],[4,3]]。n3 验证完整的一圈加中心输出必须是 [[1,2,3],[8,9,4],[7,6,5]]。n4 验证两圈的情况输出最内层是 2×2 的子矩阵。这四个规模覆盖了所有典型的边界场景单元素、单圈、多圈、偶数阶、奇数阶。如果这四种都通过基本可以放心提交。我的经验是绝大多数错误都会在 n2 或 n3 时暴露出来。其中 n2 最容易暴露“四个 for 无条件执行”的问题n3 最容易暴露 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 这道题在面试评级中通常属于“基础中等”难度。它不会直接决定你是否通过面试但经常作为一个“过程题”出现面试官先用它来考察你写代码的基本功再在这个基础上叠加追问判断你的思维深度。我印象很深刻的一次经历是面试官让我写完标准解后马上追问“如果矩阵里有障碍物数字碰到障碍物时应该怎么办”这一下就把题目从“数组遍历”升级到“路径模拟”。我当时用的是方向模拟法所以只要在判断转向条件里加一个“下一步是否是障碍物”的判断即可很自然地就答上来了。如果当时我只会边界收缩法这个追问就会难很多。所以我的建议是两道题都掌握并且能说清楚各自适合什么场景。至少要能在面试中做到讲完一种方法后主动补充一句“这道题也可以从另一个角度理解”。这种主动展示知识深度的行为往往比默默写出标准答案更让面试官印象深刻。这篇文章写到这核心的内容已经全部覆盖了。最后分享一个我在实际刷题中的体会螺旋矩阵这类模拟题第一次写错很正常千万不要怀疑自己的智商。我第一次写 n4 的用例跑出来中间一个数字漏填了我当时盯着代码愣是没找到原因后来乖乖打了一遍日志才发现是第三个 for 循环的终点写错了。从那以后我养成了“先打日志再动脑子”的调试习惯。这种习惯远比背下这道题的答案更有价值。