☰
螺旋矩阵 II 三种解法拆解:边界条件、方向数组与递归剥层
2026/10/10 11:10:54 网站建设 项目流程

说实话,这道题我前前后后刷过不下三遍,每次隔一段时间重新写,写出来的版本都不一样。在线题库里编号59的“螺旋矩阵 II”,题面一句话:给定一个正整数 n,生成一个 n×n 的矩阵,把 1 到 n² 以顺时针螺旋顺序填进去。比如 n=3 的结果是:

1 2 3 8 9 4 7 6 5

看起来简单吧?真正动手写的时候,你很快会发现:要么中间那一格被漏掉,要么四个角被重复覆盖两三次,要么 n=1 的时候交上去返回一个全 0 数组。这篇文章我会用三个不同的视角拆这道题,把我实际调试时撞见过的几个典型错误也列出来,最后再说说它和同一系列几道题的迁移关系。你要是正在准备笔试、面试,或者刚开始刷矩阵类题目,这篇应该能帮你省不少时间。

1. 先想清楚:这道题为什么看着简单,写起来却容易翻车

1.1 从“人脑转圈”到“代码转圈”的翻译

人填螺旋的时候,眼睛能看到一整圈,外圈内圈一目了然。程序不一样,它只有两个坐标变量和一个二维数组,每一步只能做一个决定:下一个格子往哪个方向走。所以第一步要做的,是把“顺时针螺旋”翻译成一个可循环的规则。

我用 5×5 的路径来举例。从 (0,0) 出发,向右走到 (0,4),向下走到 (4,4),向左走到 (4,0),然后关键的来了——向上只走到 (1,0) 就停,而不是回到起点 (0,0)。很多人第一次写错就是死在这:把最外层想象成一个闭合的环,最后还要再向上走一步回起点,结果把 (0,0) 给覆盖了。

第一次意识到这个细节的时候,我确实愣了一下。后来才慢慢总结出真正到位的翻译方式:最外层上边走一整行,右边走一竖,下边走一整行,左边走剩下的那一大半竖,然后把“墙”向内缩一圈,重复同样的动作。这其实就是后续所有解法的共同底座。

1.2 真正的考点是循环不变量与终止条件

这道题看起来是模拟题,但更准确地说是两个数组技巧的组合:循环不变量和终止条件。

循环不变量说的是:每一轮循环开始时,我们面对的是一个还没有被填过的正方形区域,左上角、右上角、左下角都明确定义;循环内把这个区域的最外圈填满;循环结束时,区域向内收缩一圈。只要每条边负责的角点范围始终保持一致,代码就不会乱。

终止条件则藏着这道题最锋利的坑。n 为奇数时,矩阵最后会剩一个中心格;n 为偶数时,矩阵恰好一层层剥完。你只要把 n=1、2、3、4 四种情况各画一遍,就会立刻发现:处理“最后一层”才是整个题最容易出错的地方。后面几节我所有的代码和调试讨论,都是在围绕这两个考点展开。

2. 解法一:按层模拟,四边界收缩写法的完整拆解

2.1 四边填充与边界收缩的完整写法

第一版我推荐按层模拟,这也是最容易建立心智模型的写法。直接给代码:

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 # 如果只剩一行/一列,填完上边就可以结束 if top == bottom or left == right: break # 右边:从上到下,避开右上角,含右下角 for i in range(top + 1, bottom + 1): matrix[i][right] = num num += 1 # 下边:从右到左,避开右下角,含左下角 for j in range(right - 1, left - 1, -1): matrix[bottom][j] = num num += 1 # 左边:从下到上,避开左下角和左上角 for i in range(bottom - 1, top, -1): matrix[i][left] = num num += 1 top += 1 bottom -= 1 left += 1 right -= 1 return matrix

代码很短,但有几个地方必须重点解释。

循环条件 left <= right and top <= bottom 表示当前还有至少一个格子没填,这个条件在 n 为任何正整数时都成立。上边填充是从 left 到 right 一整行,填完左上角和右上角。填完之后立刻检查 if top == bottom or left == right: break,这是整段代码中最容易被忽略、也最重要的一行。

接着右边从 top + 1 开始往下填到 bottom,因为 matrix[top][right] 已经在上一段填过了;下边从 right - 1 倒着填到 left,因为 matrix[bottom][right] 刚刚被右边填过;左边从 bottom - 1 倒着填到 top + 1,则是为了避让左下角和左上角。四条边各司其职之后,top 加一、bottom 减一、left 加一、right 减一,进入下一圈。

2.2 为什么填完上边就要判断 break

我见过不少人在写这道题时,会先写一个 while 循环,然后在循环体里把上边、右边、下边、左边四条边全部填完,再去更新边界。这种写法在 n=2 或者 n=4 的时候可能碰巧能跑通,但到 n=3 或者 n=5 的时候,最内层只剩一个格子,四条边都会对这个中心格执行填充。

本质原因在终止条件的判断时机。用表格看几组典型情况:

n需要填的层数最后一层形态是否需要 break
212×2 完整一圈不需要,四边正常填完
321×1 中心格需要,否则中心被重复写
422×2 完整一圈不需要
531×1 中心格需要

所以规律很清楚:只有在最后一层退化成一个点的时候才触发 break,而判断的位置要放在上边填完、右边开始之前。为什么是这里?因为一个点也可以理解为“只有上边的长度为 1 的特殊矩形”,填完它就完成了全部任务。把判断放在这个位置,代码写的和你的思维模型完全一致。

这个 break 不是可有可无的优化,而是必需。如果 n=3 时四条边都执行,中心 (1,1) 会被写三次。由于每次写的值恰好都是最后那个数字,最终结果用肉眼检查“仿佛是对的”,但一旦换到下一节的矩形版本,同样的错误会直接导致整行数据被覆盖成错误值。

2.3 四条边的角点归属约定

四条边闭开区间如果不统一,就会产生重复或者遗漏。下面这张表是我调试这道题之后整理出来的约定,建议直接抄进注释里:

边起点终点角点归属
上边(top, left)(top, right)含左上角、右上角
右边(top + 1, right)(bottom, right)含右下角、不含右上角
下边(bottom, right - 1)(bottom, left)含左下角、不含右下角
左边(bottom - 1, left)(top + 1, left)不含左下角、不含左上角

也就是说,每条边只负责两个端点中的一个,四个角恰好各被覆盖一次。这种把“边界归属”事先定清楚的工程习惯,单看很平常,却能在各种二维数组题里反复救你命。我后来写任何矩阵边界题,第一件事都是先想清楚角点归谁,再动笔。

3. 解法二:方向数组加碰壁转向,一种更通用的状态机写法

3.1 状态机视角:走不动了就右转

按层模拟是非常具体的策略,方向数组则是更抽象的一层。你可以先把“右下左上”四个方向事先放在一个列表里,用一个索引 d 表示当前方向。每次填完当前格子,先按当前方向试探下一步;如果下一步越界,或者已经被填过,就把 d 加一取模 4,换到下一个方向。

这就像在一个正方形迷宫里右手扶墙:撞到墙就右转 90 度,没撞墙就继续走。整个过程是一个典型的二维状态机:状态是 (row, col, d),每次根据“下一步是否可达”决定是保持方向还是切换方向。

我比较推荐理解这个视角,是因为它把“边界判断”从一个巨大的 if 嵌套中抽离出来,变成了一个统一的试探函数,错误率会显著下降。

3.2 完整实现与两个易错点

方向数组的完整代码是这样的:

def generateMatrix(n): matrix = [[0] * n for _ in range(n)] dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)] # 右、下、左、上 row, col = 0, 0 d = 0 for num in range(1, n * n + 1): matrix[row][col] = num dr, dc = dirs[d] nr, nc = row + dr, col + dc if nr < 0 or nr >= n or nc < 0 or nc >= n or matrix[nr][nc] != 0: d = (d + 1) % 4 dr, dc = dirs[d] nr, nc = row + dr, col + dc row, col = nr, nc return matrix

这段代码我第一次写的时候踩过两个坑,今天先说清楚,免得你再踩一次。

第一个坑:转向之后必须重新计算目标格。这段逻辑里的 if 分支里做了 d = (d + 1) % 4 之后,要立刻重新执行 dr, dc = dirs[d] 和 nr, nc = row + dr, col + dc。不少人转向之后继续用旧的 nr, nc,结果同一个格子被反复填,或者原地死循环。

第二个坑:判断条件用 matrix[nr][nc] != 0,而不是 matrix[nr][nc] > 0。虽然从 1 到 n² 都是正数,写大于 0 看起来也行,但一旦你想复用这套代码去处理别的填充场景,比如填充 0 作为特殊标记,就很容易埋雷。用 != 0 等于明确表达“这个格子是否已经被访问过”,语义更清晰。

还有一个细节:这个写法只需要 if,不需要写 while 循环去反复转向。因为螺旋路径上每个方向的“尽头”只有一面墙,撞墙之后转一次,新方向一定是可达的。用 while 也不会错,但会增加不必要的复杂度。想明白“为什么恰好只需要一次”,比多写几层保险更重要。

3.3 为什么这种写法更适合做扩展

方向数组其实是很多网格类题目的通用骨架。无论是二维网格上的 DFS、BFS,还是蛇形走位、棋盘游戏里的坐标移动,都可以套用。唯一的改动通常是 dirs 里的方向集合,以及“试错之后的修正逻辑”。

举个例子,如果题目改成“从左下角出发,逆时针螺旋”,只需要把 dirs 顺序调整一下,或者把初始方向换一个,其他逻辑全部保留。按层模拟遇到这种变化,就得重新设计一整轮循环。也是因为这个原因,我刷题时一旦发现题目是网格遍历,会优先往方向数组这个方向想。

4. 解法三:递归剥层,用“自顶向下”的眼光看同一个问题

4.1 把螺旋看成自相似结构

如果说按层模拟是“从左往右写”,递归剥层则是“从外往内看”。螺旋矩阵在结构上有一种天然的自相似性:把最外圈填掉以后,剩下的中心区域依然是一个螺旋矩阵,只是边长少了 2,起点换到了原来的 (1,1)。

这种观察可以直接写成递归。每一层函数只负责“填当前这一圈”,然后调用自己,处理边长更小的子矩阵。递归出口有两个:边长小于等于 0,说明没有格子要填;边长等于 1,说明只剩一个中心格,填完就行。

4.2 递归实现与坐标偏移陷阱

递归版本的关键在于“全局坐标”和“局部坐标”的区分。每个子矩阵的左上角在原始矩阵里有一个 start 偏移,函数内部所有的坐标都要在局部坐标的基础上加 start。看起来简单,写起来很容易乱。

def generateMatrix(n): matrix = [[0] * n for _ in range(n)] def fill(start, size, num): if size <= 0: return num if size == 1: matrix[start][start] = num return num + 1 # 上边:局部坐标的第 0 行 for j in range(size): matrix[start][start + j] = num num += 1 # 右边:局部坐标的第 size-1 列,从第 1 行开始 for i in range(1, size): matrix[start + i][start + size - 1] = num num += 1 # 下边:局部坐标的第 size-1 行,从右往左 for j in range(size - 2, -1, -1): matrix[start + size - 1][start + j] = num num += 1 # 左边:局部坐标的第 0 列,从下往上,避开左下角和左上角 for i in range(size - 2, 0, -1): matrix[start + i][start] = num num += 1 return fill(start + 1, size - 2, num) fill(0, n, 1) return matrix

这段代码里的角点归属和 2.3 的表格完全一致,唯一不同的是它用局部坐标写,看起来反而更清晰:上边是第 0 行整行,右边是第 size-1 列除第 0 行外,下边是第 size-1 行除最后一列外,左边是第 0 列去掉首尾。

递归版本有个容易忽略的点:num 计数器必须通过返回值传出来,或者用 nonlocal 关键字,否则外层调用看不到内层已经填到哪个数字了。这个坑我实际调试时遇到过,当时递归层数一多,数字就从中间断掉了。

4.3 三版实现对比与选型建议

三个版本各有侧重,我用一张表做横向对比:

解法核心视角额外空间主要易错点迁移表现
按层模拟边界收缩O(1)break 位置、角点归属适合 54 题,但特性判断要多加
方向数组状态机O(1)转向后重新计算目标格适合各种网格题,改动最小
递归剥层子问题拆解递归栈 O(n)start 偏移、num 传递思路新颖,实际编码略绕

笔试面试的时候,我默认推荐第一种,逻辑最直白;如果题目是一个任意形状的矩阵,或者后续需要频繁调整方向,我会选方向数组;递归版本更多是作为“你有没有多角度思考”的加分项。

5. 实测踩坑:我调试这三个版本时遇到的具体错误

这一节我想写得比其他博客更长一些,因为正确的代码网上一搜一大把,而真正能帮你省时间的,往往是那些错误的写法和定位思路。

5.1 错误一:四条边无条件执行

最常见的错误版本长这样:

while top <= bottom and left <= right: for j in range(left, right + 1): matrix[top][j] = num num += 1 for i in range(top + 1, bottom + 1): matrix[i][right] = num num += 1 for j in range(right - 1, left - 1, -1): matrix[bottom][j] = num num += 1 for i in range(bottom - 1, top, -1): matrix[i][left] = num num += 1 # 四边都填完再收缩边界

这段代码在 n=3 时,中心 (1,1) 会被填三次。比较迷惑的是,最终矩阵打印出来是正确的,因为三次写的都是同一个数字 9,覆盖多少次都不改变结果。

我当时是怎么定位到问题的?把填充动作换成一个打印语句,把每次写入的坐标打出来,立刻看到 (1,1) 重复出现。这也是一个通用的调试技巧:遇到“结果对但感觉不太对”的代码,先在写入点打印坐标或访问顺序,往往一眼就能看出冗余逻辑。

这个错误真正危险的地方不在正方形矩阵,而在于后面的 54 题“螺旋矩阵”里,输入可能是 m×n 的矩形,剥完外层后内部可能只剩一行或一列。此时如果四条边无条件执行,那一行或者一列会被反复覆盖,最终结果就是整行数据全乱。

5.2 错误二:角点的开闭区间不一致

另一个高频错误是四条边的开闭区间没有统一约定。比如上边写成 for j in range(left, right + 1),右边写成 for i in range(top, bottom + 1),下边写成 for j in range(right, left - 1, -1),左边写成 for i in range(bottom, top - 1, -1)。四个循环全部使用“含端点”的区间,结果四个角都被覆盖了两到三次。

更隐蔽的版本是某个角漏填。我在调试时遇到过一次:上边含右上角,右边却从 bottom 开始往上填,结果右上角被填了两次,右下角反而没人管。

解决思路就是前面那张角点归属表。建议在代码旁边用注释固定四条边的开闭区间,甚至可以写一个简短的断言辅助验证。我在本地调试时还试过一种写法——把每一步的行列下标都打印出来,对照手写的 5×5 期望坐标去逐行检查,非常直观。

5.3 错误三:n=1 单独特判反而写成死代码

这个错误听起来很低级,但线上提交时经常出现。原因在于很多人习惯用 while left < right 作为外层循环条件,n=1 时循环一次都进不去,矩阵保持全 0。

于是有人加了一个特判:

if n == 1: return [[0]]

注意,这里返回的是 [[0]],不是 [[1]]。如果 n=1 应该返回矩阵 [[1]]。更合理的做法是少做特判,直接采用 while top <= bottom and left <= right 的写法,让 n=1 自然走完上边填充流程,break 之后返回正确结果。

如果你确实习惯 while left < right 的写法,那一定要在循环结束后补上中心格:

if n % 2 == 1: center = n // 2 matrix[center][center] = n * n

这种写法也完全正确,很多题解里会用到。它等于是把“最后一层中心格”从主循环里拿了出来,单独处理。我个人更喜欢第一种 break 方案,因为统一处理所有 n,不用分奇偶,逻辑更收敛。

6. 做完 59 题之后:这套逻辑能直接迁移到哪些题

6.1 54 题:矩形螺旋读取只多了一个 break 场景

54 题和 59 题几乎是镜像关系:59 题是给定 n 生成螺旋矩阵,54 题是给定一个 m×n 的矩阵按螺旋顺序读取。按层模拟的代码可以直接照搬,唯一的区别是——矩形剥完外层之后,内部可能只剩一行横条或者一列竖条,这时必须立即 break,否则会重复读取。

每填完一条边就收缩边界再判断,是最稳的节奏。伪代码大概是:

while top <= bottom and left <= right: # 读上边 top += 1 if top > bottom: break # 读右边 right -= 1 if left > right: break # 读下边 bottom -= 1 if top > bottom: break # 读左边 left += 1

这其实是 59 题 break 逻辑的放大版:正方形矩阵中“只剩一行”和“只剩一列”几乎同时发生,矩形里却会分开出现。意识到这一点,你再看 59 题的 break 会更有画面感。

6.2 885 题:从任意点出发后,步长规律成为主角

885 题“螺旋矩阵 III”是同一套逻辑的大变身。起点不再是左上角,而是矩阵中任意一个坐标;方向顺序依然是右下左上,但不再“撞墙转向”,而是按照 1、1、2、2、3、3……这样递增的步长走。

如果你按层模拟的思路去解这道题,会非常痛苦,因为每一圈的边界都不完整。但如果用方向数组,你只需要把“下一步越界或已访问就转向”改成“当前方向剩余步数为 0 就转向”,核心骨架几乎不动。这也是我前面说的,方向数组为什么是网格题通用解法的原因。

6.3 2326 题:把链表变成填充源

2326 题“螺旋矩阵 IV”的输入是一个链表,要求把链表里的值按螺旋顺序填进 m×n 矩阵。解法有两种思路:先把链表读完转成数组,然后直接套方向数组法填充;或者边遍历链表边维护坐标。本质上换汤不换药,依赖的还是同一个骨架。

这类“输入形态变化,遍历逻辑不变”的题目,特别适合用来检验你到底有没有理解核心,而不是背模板。如果你能不看代码,在一分钟内说出解法思路,说明真的通了。

6.4 一轮小专题下来我最大的感受

螺旋系列几道题刷下来,我最大的体会是:矩阵题最怕的不是“不会模拟”,而是“边界约定不统一”。从 59 题到 54 题再到 885 题,不管走法怎么变,核心都是循环不变量和方向切换。你会在反复练习中养出一个习惯:拿到二维数组题,先画坐标、定边界、写清楚角点归属,再动笔写循环。这个习惯养成了,比多记下两三道题的代码有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询