资讯动态

螺旋矩阵遍历全解析:边界收缩法与方向转向法的实现与避坑指南

发布时间:2026/10/21 6:29:43 来源:尧图企业网站定制
第一次在笔试里遇到螺旋矩阵的时候我盯着题目愣了一分钟明明只是按顺序读取一个二维数组为什么换个方向就让人摸不着头脑后来给不同基础的人讲过很多遍才慢慢明白这道题的难点根本不在“读数组”而在于你能不能把“方向变化”和“边界收缩”这两件事同时管住。它要解决的问题很直白给定一个 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 指向最右侧的列号。初始状态是 top0bottomm-1left0rightn-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]]初始 top0bottom2left0right2。第一轮循环第一段输出 1、2、3top 变成 1第二段输出 6、9right 变成 1第三段 top1 bottom2 成立输出 8、7bottom 变成 1第四段 left0 right1 成立输出 4left 变成 1。第二轮循环开始前未访问区域正好剩下最中心的 5边界是 top1bottom1left1right1。第一段输出 5top 变成 2第二段循环条件 i 从 2 到 1不成立第三段判断 top2 bottom1 不成立跳过第四段判断 left1 right1 成立但循环条件 i 从 bottom1 到 top2 递减恰好是 range(1, 1, -1)为空也什么都不做。最后 while 条件变成 top2 bottom1退出。结果正是 [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这里的防护判断和边界收缩法中的逻辑一样但多了一个值得注意的细节第二段“最右边”的循环是从 top1 开始而不是从 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每一行都是真实踩过的坑。第一个坑空矩阵处理。m0 或 n0 时直接返回空数组这很好理解但如果 matrix[0] 是空列表比如 [[]]此时 m1、n0matrix[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 而不是 top1打印每一轮的访问坐标序列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 的例子把访问顺序写在纸上标出每一圈之后边界的变化。等边界变化规律清晰了再动笔写基本一遍就能过。这个习惯比记十种模板都有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑