第一次在笔试里遇到螺旋矩阵的时候,我盯着题目愣了一分钟:明明只是按顺序读取一个二维数组,为什么换个方向就让人摸不着头脑?后来给不同基础的人讲过很多遍,才慢慢明白,这道题的难点根本不在“读数组”,而在于你能不能把“方向变化”和“边界收缩”这两件事同时管住。它要解决的问题很直白:给定一个 m 行 n 列的二维数组,按“从左到右、从上到下、从右到左、从下到上”的转圈顺序,把每个元素依次读出来,得到一个一维结果数组。道理谁都懂,但落到代码上,边界怎么收、方向什么时候换、空矩阵怎么处理,全是细节坑。
这篇文章适合刚接触二维数组的初学者,也适合准备算法面试的求职者,甚至适合想把这套“边界控制”思路迁移到图像处理、棋盘布局等场景的开发者。我会从几个完全不同的实现视角拆解同一个问题,给出可运行的代码和逐步推演,最后再聊复杂度、避坑技巧和扩展变体。整篇读下来,你不仅会写螺旋矩阵,还能学会一套通用的边界控制方法论。
1. 问题定义与核心思路解构
1.1 约定符号与问题抽象
先约定符号:矩阵有 m 行 n 列,元素位置用 (i, j) 表示,其中 i 是行号,j 是列号,均从 0 开始。螺旋遍历本质是一种“带方向的线性化”操作。所谓“带方向”,是因为访问顺序不按行优先或列优先,而是按四个固定方向的循环:右、下、左、上。每走完一个方向,就顺时针切换到下一个方向,直到所有元素被读完。
更关键的一点是:这个方向循环并不是每走一步都切换,而是“碰到边界才切换”。什么叫边界?有两种含义:一是矩阵本身的物理边界,也就是行号或列号超出 [0, m-1] 或 [0, n-1] 的范围;二是已经被访问过的元素形成的逻辑边界。举个例子,3×3 矩阵中从右上角竖直往下读,读到底部后,下一步理论上应该向左,但此时右侧两列都已经被读过一遍,这些读过的位置就形成了一堵“看不见的墙”。后文的所有算法分支,本质上都是围绕“如何识别并处理这两类边界”展开的。
1.2 两派主流思路:收缩边界 VS 转向绕圈
围绕“怎么判断该转向”,业界大体分成两派,对应两种完全不同的心智模型。
第一派是边界收缩法,也叫剥洋葱法。维护 top、bottom、left、right 四个指针,把矩阵当前未被访问的区域看作一圈一圈的“洋葱皮”,每遍历完一圈,四个指针各自向内收缩,直到区域消失。这种方法不需要额外标记数组,空间复杂度低,是我平时最推荐上手实现的版本。它背后的思想是“整体区域管理”:不关心单个元素是否被访问过,只关心当前剩余区域是一个完好的矩形,只要保证这个矩形不断收缩,就不会读重、不会读漏。
第二派是方向转向法,也叫状态机法。用一个方向数组表示右下左上四个方向,再准备一张和矩阵等大的标记表,每走一步先试探“下一步是否越界或已访问”,如果撞上了就顺时针转 90 度。这种方法更好理解,尤其适合在网格里做“自动绕行”的扩展,比如遇到障碍物时重新规划路线。它背后的思想是“单步决策”:每一步只关心当前站在哪里、下一步能不能走,不需要维护大范围的边界。
用一个生活化的类比帮助你建立直觉:边界收缩法就像人在矩形花坛里扫落叶,路线是围着外圈走,走完一圈就往里缩小一圈;方向转向法像扫地机器人,装着传感器,撞到墙或者撞到扫过的区域就右转,碰不到就一直走。两种策略结果完全一致,但实现细节和适用场景差别很大。
2. 边界收缩法:最直观的剥洋葱方案
2.1 四个指针的含义与更新节奏
边界收缩法的核心是四个指针,它们的含义必须在一开始就钉死:top 指向当前未访问区域最上方的行号,bottom 指向最下方的行号,left 指向最左侧的列号,right 指向最右侧的列号。初始状态是 top=0,bottom=m-1,left=0,right=n-1。
每一轮大循环做四件事,顺序是固定的:从左到右扫描 top 行,扫描完 top 向下收缩一行;从上到下扫描 right 列,扫描完 right 向左收缩一列;从右到左扫描 bottom 行,扫描完 bottom 向上收缩一行;从下到上扫描 left 列,扫描完 left 向右收缩一列。最核心的节奏是两个:先扫描,后收缩;每扫完一段,该方向上的边界立即变化。这样下一段扫描使用的就是更新后的边界,不会出现重复访问。
这里有一个容易忽略的细节:不要在一轮循环开始前一次性把四个指针全部更新,而是每扫描一段更新一个指针。很多新手喜欢先算好“这一圈要访问哪些位置”,再统一收缩,结果代码极其复杂。其实逐段更新的顺序,本身就是循环不变量的一部分,你只需要把“当前剩余区域是个矩形”这个条件维护好,算法就不会乱。
2.2 为什么在向左、向上扫描前要跑检查
很多人写出来的版本一跑就数组越界,原因就出在最后两段。考虑一个极端情况:1 行 4 列的矩阵。第一轮从左到右扫描完 top 行,top 变成 1,但此时 bottom 仍然等于 0。第二轮从上到下扫描 right 列,循环条件 top <= bottom 已经不成立,于是这一段什么都不做。但如果你直接进入“从右到左扫描 bottom 行”,就会发现 bottom 这一行其实已经被第一段读过了,结果会重复输出,甚至可能因为 left 和 right 已经变化而访问到错误的位置。
所以正确写法里,在“从右到左扫描 bottom 行”之前必须判断 top <= bottom 是否还成立;在“从下到上扫描 left 列”之前判断 left <= right 是否还成立。这两个判断是整个算法最精妙,也最容易漏掉的地方。它们的本质是防止单行或单列矩阵在转向时读到已经读过的区域。
2.3 完整实现与逐步模拟
直接看代码,语言用 Python,逻辑清晰即可:
def spiral_order(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 result = [] while top <= bottom and left <= right: # 第一段:从左到右,固定 top 行 for j in range(left, right + 1): result.append(matrix[top][j]) top += 1 # 顶部边界向下收缩 # 第二段:从上到下,固定 right 列 for i in range(top, bottom + 1): result.append(matrix[i][right]) right -= 1 # 右侧边界向左收缩 # 第三段:从右到左,固定 bottom 行 if top <= bottom: for j in range(right, left - 1, -1): result.append(matrix[bottom][j]) bottom -= 1 # 底部边界向上收缩 # 第四段:从下到上,固定 left 列 if left <= right: for i in range(bottom, top - 1, -1): result.append(matrix[i][left]) left += 1 # 左侧边界向右收缩 return result用一个 3×3 矩阵手动推一遍,感受指针的变化。假设矩阵是 [[1,2,3],[4,5,6],[7,8,9]],初始 top=0,bottom=2,left=0,right=2。
第一轮循环:第一段输出 1、2、3,top 变成 1;第二段输出 6、9,right 变成 1;第三段 top=1 <= bottom=2 成立,输出 8、7,bottom 变成 1;第四段 left=0 <= right=1 成立,输出 4,left 变成 1。
第二轮循环开始前,未访问区域正好剩下最中心的 5,边界是 top=1,bottom=1,left=1,right=1。第一段输出 5,top 变成 2;第二段循环条件 i 从 2 到 1,不成立;第三段判断 top=2 <= bottom=1 不成立,跳过;第四段判断 left=1 <= right=1 成立,但循环条件 i 从 bottom=1 到 top=2 递减,恰好是 range(1, 1, -1),为空,也什么都不做。最后 while 条件变成 top=2 > bottom=1,退出。结果正是 [1,2,3,6,9,8,7,4,5]。
如果你只拿 4×3 的长方形矩阵推演,会发现规律完全一样,只是哪一段长、哪一段短会变化。关键在于每一段扫描的区间始终由当前边界实时决定,而不是用“这一圈固定的起止坐标”。
2.4 这个方案的空间优势
边界收缩法不需要额外的标记表,只维护四个整数变量,算法本身的空间复杂度是常数。对于几万乘几万的大矩阵,这意味着内存占用几乎不随矩阵规模增长。与之对比,方向转向法需要额外维护一个同等大小的布尔矩阵,内存多出一个数量级。虽然很多在线判题平台不会因为这一点内存就判你超限,但在真实项目中处理超大网格时,差别就会显现。这也是我在实际工程里更倾向边界收缩法的原因。
3. 方向转向法:把遍历写成状态机
3.1 方向数组与转向条件
方向转向法的核心是方向数组。定义 dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)],分别对应右、下、左、上四个方向。当前位置是 (r, c),当前方向是 di,那么下一步的候选位置就是 (r + dirs[di][0], c + dirs[di][1])。
每一步先试探这个候选位置是否可行。可行条件有两个:一是数组下标不越界,二是该位置没有被访问过。只要其中任意一条不满足,就把 di 更新为 (di + 1) % 4,也就是顺时针旋转到下一个方向,然后基于新方向重新计算候选位置。注意,这里旋转一次通常就够了,因为在一个标准的空旷矩形网格中,你永远不会需要连续旋转两次才能真正前进。
这种方法的思想很像一个“状态机”:当前状态由坐标和方向组成,状态转移规则是“能走就走,不能走就右转”。由于每一步的方向选择都依赖于上一步的结果,所以天然适合用循环实现,而且代码整体非常统一,不需要像边界收缩法那样区分四段逻辑。
3.2 打标记的完整代码
下面是基于访问标记数组的完整实现:
def spiral_order_visited(matrix): if not matrix or not matrix[0]: return [] m, n = len(matrix), len(matrix[0]) dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)] # 右、下、左、上 seen = [[False] * n for _ in range(m)] result = [] r, c = 0, 0 di = 0 for _ in range(m * n): result.append(matrix[r][c]) seen[r][c] = True # 试探下一步 nr, nc = r + dirs[di][0], c + dirs[di][1] if nr < 0 or nr >= m or nc < 0 or nc >= n or seen[nr][nc]: di = (di + 1) % 4 # 转向 nr, nc = r + dirs[di][0], c + dirs[di][1] r, c = nr, nc return result这段代码有一点需要特别强调:转向之后,你必须基于原来的 r、c 重新计算新的 nr、nc,而不是在已经越界的坐标上继续加。很多初学者在这里写错,把 r、c 先更新成越界的值再去转向,结果怎么转都越界。正确的方式是:先算出原始候选位置,发现不能走,再基于当前合法位置重新计算。
遍历步数固定为 m*n 次,不需要显式判断循环结束,因为每个元素只会被加入 result 一次,总步数天然等于元素总数。
3.3 两种主流解法的对比与选型建议
用表格把两种方案的差异摆在面前,方便你快速决策:
| 对比维度 | 边界收缩法 | 方向转向法 |
|---|---|---|
| 额外空间 | O(1),只需四个指针 | O(mn),需要访问标记表 |
| 代码结构 | 四段扫描,逻辑有区分度 | 单步循环,代码更均匀 |
| 边界条件处理 | 依赖两个防护判断 | 统一交给越界检查 |
| 是否容易扩展 | 很难扩展到带障碍物的网格 | 改一下候选判断即可扩展 |
| 口述讲解难度 | 需要讲清楚循环不变量 | 方向数组和标记表更直观 |
选型建议其实很直接:如果只是为了遍历标准矩形矩阵,边界收缩法代码更省空间,面试中也不容易在标记表上栽跟头;如果你面对的题目变成了“螺旋遍历一个有障碍物的网格”,或者你脑子里已经想到要往复杂网格扩展,方向转向法的框架更合适,因为它判断的就是“下一步能不能走”,这本身就是带障碍物的通用逻辑。
我在实际讲解算法时,通常建议新手从方向转向法入手先建立感知,再立刻切换到边界收缩法刷几道题。这两种思路像是一枚硬币的两面,只有都见过,遇到变体时才能快速套用。
4. 递归与逐层抽象:换个视角看同一问题
4.1 每递归一层,矩阵就“瘦”一圈
递归思路非常优雅:每次都处理当前剩余矩形的最外层,然后把内部区域作为一个新的小矩形递归处理。递归函数传入更新后的四个边界,当 top > bottom 或 left > right 时终止。这和边界收缩法本质上是一套逻辑,只是把 while 循环换成了递归调用。
def spiral_order_recursive(matrix): if not matrix or not matrix[0]: return [] result = [] m, n = len(matrix), len(matrix[0]) def fill(top, bottom, left, right): if top > bottom or left > right: return # 当前层的最上边 for j in range(left, right + 1): result.append(matrix[top][j]) # 当前层的最右边 for i in range(top + 1, bottom + 1): result.append(matrix[i][right]) # 当前层的最下边,注意防止重复 if top < bottom: for j in range(right - 1, left - 1, -1): result.append(matrix[bottom][j]) # 当前层的最左边,同样注意防止重复 if left < right: for i in range(bottom - 1, top, -1): result.append(matrix[i][left]) # 向内收缩一层 fill(top + 1, bottom - 1, left + 1, right - 1) fill(0, m - 1, 0, n - 1) return result这里的防护判断和边界收缩法中的逻辑一样,但多了一个值得注意的细节:第二段“最右边”的循环是从 top+1 开始,而不是从 top 开始。为什么?因为最右上角的元素已经在第一段读过了,如果从 top 开始,会重复读取。同样的道理,第三段从 right-1 开始,第四段从 bottom-1 开始,都是为了避开上一段已经读过的角点。这四个角点太容易出错了,我见过不少人在这上面卡半天,原因就是没有意识到“段与段之间共享端点”。
递归版本的优点在于结构和人脑的“一圈一圈剥”完全一致,特别适合在面试中用语言描述:你说“我先把外圈读到数组里,再把剩下的矩形递归处理”,面试官立刻能跟上你的思路。缺点是递归调用有额外开销,如果你的矩阵特别扁长,递归层数大约是 min(m,n)//2,在 Python 里一般不会爆栈,但大规模工程中循环版本总是更稳妥的选择。
4.2 递归方案的适用边界与风险
递归方案的空间复杂度是 O(min(m,n)),原因是递归栈深度取决于矩阵的“短边”。一个 1000×1000 的矩阵,递归深度大约 500 层,多数语言默认栈空间扛得住;但如果是 100000×100000 的矩阵,即便能分配这么大的数组,递归深度也可能触及语言层面的限制。所以在超大矩阵场景下,迭代方案更可靠。
另外还要注意,递归方案如果不小心,很容易写出“递归到单行或单列时重复读取”的 bug。比如一个 3×1 的矩阵,第一层读完最上边以后,top 变成 1,但递归调用 fill(1, 2, 0, 0) 还是会跑完整个 3×1 的列。这时需要靠防护判断拦住重复读取的边。这也是为什么我建议用 1×n、n×1、1×1 三种边界形态去测试递归版本,它们能把所有角点问题逼出来。
5. 边界条件、复杂度与实战避坑
5.1 时间与空间复杂度精确推导
先给结论:边界收缩法时间 O(mn)、空间 O(1)(不计返回数组);方向转向法时间 O(mn)、空间 O(mn);递归逐层法时间 O(mn)、空间 O(min(m,n))。
为什么时间一定是 O(mn)?因为无论哪种写法,每个元素都恰好被放入 result 一次、读一次,所以总操作次数与矩阵元素总数 mn 成正比。这已经是最优了——你不可能在少于 mn 的时间内读完全部元素,因为每个元素至少要被访问一次才算遍历完成。有些同学会担心“我每个元素会不会被判断多次”?比如方向转向法中每个位置会试探一次下一步,这部分额外判断是常数级别的,不改变 O(mn) 的大 O 结论。
空间复杂度的细节值得单独说明。边界收缩法只用了四个整数变量,加上返回数组本身,如果你把返回数组的空间也算进去,总空间是 O(mn);但算法内部没有额外分配,所以通常说“算法本身 O(1) 空间”。方向转向法多维护了一张和矩阵等大的布尔表,严格说总空间是 O(mn) + O(mn),但仍记为 O(mn)。递归版本的空间来自调用栈,深度为 min(m,n)//2,因此记为 O(min(m,n))。
还有个容易被忽略的性能细节:内存访问局部性。矩阵在内存里是“一行一行连续存放”的,边界收缩法在扫描 top 行或 bottom 行时是连续读取,缓存命中率不错;扫描 right 列或 left 列时是跨行跳跃读取,缓存效果差一些。方向转向法在标准矩形中也有类似问题,因为它的路线同样是先连续扫一段再转向。这个差异在普通规模的问题上几乎可以忽略,但在超大矩阵和高频调用场景下会成为真实的性能瓶颈。如果未来你发现同样复杂度的代码在极端数据上运行时间差出好几倍,多半就是缓存局部性的问题,而不是算法本身的差距。
5.2 典型错误、排查顺序与避坑清单
这一节整理我在实际带教过程中见过的高频 bug,每一行都是真实踩过的坑。
第一个坑:空矩阵处理。m=0 或 n=0 时直接返回空数组,这很好理解,但如果 matrix[0] 是空列表,比如 [[]],此时 m=1、n=0,matrix[0] 为假,就必须在开头同时判断 not matrix and not matrix[0]。漏掉后者会直接导致下标越界。
第二个坑:漏掉两个防护判断。单行矩阵会重复输出同一行;单列矩阵会在最右列来回读。这两个防护判断我在前文反复强调,因为它们在视觉上很“多余”,一旦遇到 3×3 这种正常矩阵,不加也能通过,很多人就会顺手删掉。等真实数据变成 1 行 5 列,代码立刻暴露。
第三个坑:循环条件写成 top <= bottom or left <= right。一旦用了 or,已经收缩完的方向会继续参与循环,导致越界或重复。这里必须是 and,因为只有两个方向同时还有未访问区域,当前剩余区域才是一个有效的矩形。
第四个坑:边界更新顺序错误。有些人喜欢先用一个临时变量把四个边界算好,再统一赋值,结果第二轮扫描时使用的还是旧边界。记住“每扫描一段,立刻更新对应边界”这个原则,就不会乱。
排查顺序建议:先打印当前 top、bottom、left、right,再逐段打印每段扫描的起点和终点,确认“该扫的区间扫到了、不该扫的没扫到”。这个调试习惯比盯着代码发呆高效得多。
| 症状 | 可能原因 | 检查点 |
|---|---|---|
| 单行矩阵输出重复 | 第三段缺少 top <= bottom 判断 | 打印第三段执行前后的 top 和 bottom |
| 单列矩阵输出重复 | 第四段缺少 left <= right 判断 | 打印第四段执行前后的 left 和 right |
| 结果缺元素 | 边界更新顺序错误,或循环条件用成了 or | 打印每一轮四个边界值 |
| 运行时下标越界 | 开头没有处理空矩阵 | 检查 matrix 为空、matrix[0] 为空的情况 |
| 结果顺序错乱 | 第二段循环起点写成了 top 而不是 top+1 | 打印每一轮的访问坐标序列 |
5.3 验证方法:用 1 到 9 的矩阵逐格比对
很多初学者写完代码,只在脑子里过一遍 3×3,觉得没问题就提交,结果一提交就错。我自己的习惯是准备一组固定测试用例,包括:3×3、4×4、1×1、1×5、5×1、2×3、3×2、空矩阵。尤其是 2×3 和 3×2 这种长宽不一致的矩形,最能暴露防护判断的问题。
验证方式也很简单:用 1 到 mn 的自然数按行优先填满矩阵,然后调用函数得到 result,再用等长的序号数组对比。如果 result 的第一个元素是 1、第二个是 2……最后一个是 mn,说明遍历顺序正确。这个验证方式不依赖任何外部库,写一个十来行的脚本就能跑完所有用例,非常值得养成习惯。
6. 变体与应用场景延伸
6.1 常见变体:从中心出发、逆螺旋、蛇形矩阵
螺旋矩阵的变体很多,但万变不离其宗。比如“从中心向外螺旋”,这种题目常见于可视化场景,初始位置从矩阵中心开始,方向初始向左,依次向外扩张。奇数矩阵存在唯一中心,偶数矩阵则需要先约定中心区域是 2×2 中的哪一个,这个约定不同,输出结果就不同。做题前一定要先和出题人确认规则,否则你写得再对也可能牛头不对马嘴。
再比如“逆螺旋遍历”,从外圈到内圈,每一段的方向与标准螺旋相反。实现上,要么把四段扫描的方向全部反转,要么在读完标准螺旋后把 result 整体反转。前者直接改代码,后者思路简单但顺序不一定正确,需要仔细验证。
“蛇形矩阵”只按行来回折返:奇数行从左到右,偶数行从右到左。它的难点不在边界收缩,而在如何判断当前行是奇数还是偶数,以及如何处理最后一行的截断。虽然名字带“蛇”,但和螺旋矩阵的联系主要是“方向变化”,控制方式更接近逐行反转。
还有一种比较刁钻的变体:要求你只输出螺旋路径上第 k 个元素,而不是输出全部元素。这时候如果你老老实实遍历全部元素,效率不是最优;更高级的做法是直接根据 k 计算它位于第几圈、圈上的哪一段、具体坐标是什么,把时间压缩到 O(1) 或 O(log min(m,n))。这类题目已经超出基础面试范围,但它是检验你是否真正理解“圈层结构”的好题目。
6.2 真实应用场景
螺旋矩阵并非只是刷题工具,在真实工程中也有它的位置。
图像处理中,某些边缘检测算法会按螺旋顺序对像素边缘采样,以提取“从外到内”的轮廓层。比如指纹识别里,经常需要把指纹脊线按从外到内的顺序展开成一维信号,再做匹配,这时螺旋遍历就是一个自然的预处理步骤。
游戏地图和UI布局中,有些棋盘游戏需要按螺旋路径分布格子,比如一圈一圈往外生成地牢房间、塔防地图的路径点。这种情况下,螺旋遍历的边界控制直接决定地图生成的正确性。
数据可视化场景中,热力图或仪表盘如果希望按环形路径展示关键指标,也需要把二维数据展平成一维顺序,螺旋顺序是保持“相邻元素在空间上仍靠近”的好选择。
从面试的角度看,螺旋矩阵还是考察“循环不变量”的经典载体。只要你能把“每次循环保持剩余未访问区域是一个完整矩形”这句话讲清楚,证明你对循环不变量有直觉,比单纯背下代码有用得多。很多技术面试官问这道题,看的不是你写出来的结果,而是你碰见边界问题时的反应:是停下来慌,还是立刻定位到防护判断。
我自己在教学和工程实操中最大的体会是:能把螺旋矩阵写对的人,往往不是背过解法,而是真正理解了“边界控制”这几个字。你只要在每一轮循环开始前问自己三个问题:当前未访问区域是不是一个矩形?四个边界是否准确?每个元素是否只访问一次?如果这三个答案都是肯定的,那么无论矩阵是空的、单行的、单列的、还是长宽失衡的,代码都不会乱。
最后再分享一个小技巧:如果你在面试现场被这道题逼得有点慌,别急着写代码,先用一个 3×3 的例子把访问顺序写在纸上,标出每一圈之后边界的变化。等边界变化规律清晰了,再动笔写,基本一遍就能过。这个习惯,比记十种模板都有用。