1. 从一道题看矩阵遍历的本质
顺时针打印矩阵这道题,在面试里出现的频率高得离谱。我前前后后带过不少人刷题,发现一个很有意思的现象:大部分人第一次看到这道题,脑子里立刻蹦出来的方案是"模拟",然后就开始写四个方向的循环,写着写着边界条件就乱了,最后要么死循环,要么漏元素,要么数组越界。问题出在哪?不是逻辑能力不够,而是没有想清楚矩阵遍历的本质是什么。
这道题的核心需求非常直白:给你一个 m 行 n 列的二维矩阵,要求按照从外到内、顺时针螺旋的顺序,把所有元素依次取出来放进一个一维数组。比如一个 3x3 的矩阵,输出顺序就是外圈一圈走完,再走内圈。听起来简单,但它考察的东西一点都不简单——边界控制能力、循环不变量的设计、对二维数组索引的敏感度,这三样东西在工程代码里到处都是,面试官出这道题不是想看你背答案,是想看你在复杂边界下能不能写出不崩的代码。
适合谁来深入看这篇内容?如果你正在准备技术面试,这道题属于必须拿下的一类;如果你已经工作但很少处理二维数据结构,拿它练手能帮你重新校准对索引和边界的敏感度;如果你是刚学编程的新手,这道题也是一个非常好的"从暴力模拟到结构化思维"的过渡练习。接下来我会从设计思路、核心细节、完整实现、常见坑四个层面,把这道题彻底拆开讲透。
2. 整体设计与思路拆解
2.1 为什么"四条边收缩"是最稳的方案
顺时针打印矩阵,市面上流传的解法大致分两派。一派是方向数组模拟法,设定右、下、左、上四个方向,用一个变量记录当前方向,撞到边界或者已访问元素就转向。另一派是边界收缩法,用四个指针分别标记当前未打印区域的上下左右边界,每打印完一条边就收缩对应的边界,直到边界交错为止。
我两种都写过,实测下来,边界收缩法在面试场景下更稳。原因有三点。第一,方向数组法需要额外维护一个 visited 数组来标记已访问元素,空间复杂度从 O(1) 变成 O(mn),虽然不影响渐进复杂度的大头,但面试官往往会追问"能不能不用额外空间",这时候你就得临时改方案。第二,方向数组法的转向条件判断容易写错,尤其是"撞到已访问元素"这个条件,在最后一圈容易出问题。第三,边界收缩法的逻辑更符合人的直觉——你就想象自己拿一把剪刀,沿着矩阵的外框剪一圈,然后框缩小一圈再剪,直到剪完。
边界收缩法的核心不变量是这样的:在任意时刻,未打印的区域始终是一个矩形,由 top、bottom、left、right 四个边界确定。每一轮循环做四件事——从左到右打印上边,从上到下打印右边,从右到左打印下边,从下到上打印左边。每打印一条边,对应的边界就往内收缩一格。当 top > bottom 或者 left > right 时,说明没有剩余元素了,循环结束。
2.2 四个边界指针的初始值与终止条件
初始状态很直观:top = 0,bottom = rows - 1,left = 0,right = cols - 1。这四个值定义了整个矩阵的范围。
终止条件需要多想一步。很多人写的是while (top <= bottom && left <= right),这个条件本身没问题,但关键在于每打印一条边之后都要重新检查,而不是一轮四条边全打完再检查。为什么?考虑一个只有一行的矩阵,比如 [[1,2,3]]。第一轮:打印上边 1,2,3,top 变成 1,此时 top(1) > bottom(0),如果继续打印右边、下边、左边就会出错。所以正确的做法是每打印一条边就检查一次边界是否还合法。
这个细节是这道题最容易翻车的地方,我在面试中见过太多人在这里栽跟头。他们写出来的代码在 3x3 或者 4x4 的矩阵上跑得好好的,一遇到单行或者单列的矩阵就崩了。根本原因就是没有在每条边之后做边界检查。
2.3 时间与空间复杂度的分析
时间复杂度很直接:矩阵里每个元素恰好被访问一次,所以是 O(mn),其中 m 是行数,n 是列数。这个没什么好优化的,因为题目要求就是遍历所有元素。
空间复杂度方面,如果不算输出数组本身占用的空间,额外空间就是四个指针变量,O(1)。如果算上输出数组,那就是 O(mn)。面试的时候建议主动说清楚这一点:"不计输出空间的话,额外空间复杂度是 O(1)。"这样面试官会觉得你对复杂度分析有清晰的边界意识。
对比一下方向数组法:它需要 O(mn) 的 visited 数组,虽然时间复杂度一样,但空间上多了一个量级。当然也有不用 visited 数组的方向模拟写法,通过不断缩小边界来实现,但那种写法本质上就是边界收缩法了,只是换了个形式。
3. 核心细节解析与实操要点
3.1 四条边的打印顺序与索引范围
这是整道题最核心的细节,我把它拆成四条边逐一说明。
上边(从左到右):行索引固定为 top,列索引从 left 到 right。打印完后 top 自增 1。注意这里的列索引是闭区间 [left, right],包含两端。
右边(从上到下):列索引固定为 right,行索引从 top 到 bottom。打印完后 right 自减 1。注意此时 top 已经自增过了,所以起始行是新的 top,这样避免了重复打印右上角的元素。
下边(从右到左):行索引固定为 bottom,列索引从 right 到 left。打印完后 bottom 自减 1。同样,right 已经自减过了,所以起始列是新的 right。
左边(从下到上):列索引固定为 left,行索引从 bottom 到 top。打印完后 left 自增 1。bottom 和 top 也都已经更新过了。
这个顺序和索引范围的配合非常精妙,每一步都利用了前一步更新后的边界值来避免重复。如果你自己手写的时候发现某个角上的元素被打印了两次,八成就是没有利用更新后的边界。
3.2 边界检查的时机为什么如此关键
我再强调一遍这个点,因为它真的太重要了。四条边的打印不是无条件的,每条边打印之前都需要检查当前边界是否还合法。
具体来说,打印上边之前检查 top <= bottom,打印右边之前检查 left <= right,打印下边之前检查 top <= bottom,打印左边之前检查 left <= right。你可能会问,为什么不能在一轮开始的时候统一检查一次?因为一轮中间边界会发生变化,初始合法的边界在打印完一条边之后可能就变得不合法了。
举个具体的例子:矩阵 [[1,2,3,4]],1 行 4 列。初始 top=0, bottom=0, left=0, right=3。打印上边:1,2,3,4,top 变成 1。此时如果继续打印右边,列索引 right=3,行索引从 top=1 到 bottom=0,这个范围是空的,循环不会执行,看起来没问题。但如果矩阵是 [[1],[2],[3],[4]],4 行 1 列呢?初始 top=0, bottom=3, left=0, right=0。打印上边:1,top 变成 1。打印右边:列索引 0,行从 1 到 3,打印 2,3,4,right 变成 -1。打印下边:行索引 bottom=3,列从 right=-1 到 left=0,这时候就会出问题。所以必须在打印下边之前检查 left <= right,此时 left=0, right=-1,条件不满足,跳过。打印左边同理跳过。
注意:每打印完一条边,立刻更新对应的边界指针,然后在打印下一条边之前做边界合法性检查。这个节奏不能乱。
3.3 单行与单列矩阵的特殊处理
单行和单列矩阵是这道题的两大杀手。很多人的代码在常规矩阵上跑得完美,一提交就挂在类似 [[1,2,3]] 或者 [[1],[2],[3]] 这样的测试用例上。
对于单行矩阵,只有上边需要打印,右边、下边、左边都不应该执行。边界收缩法天然能处理这种情况,前提是你做了正确的边界检查。对于单列矩阵,上边打印一个元素,右边打印剩余元素,下边和左边跳过。
我在实际写代码的时候有一个习惯:写完主体逻辑后,一定会手动跑一遍这几种边界情况——1x1、1xn、nx1、2x2、3x3。这五种情况覆盖了绝大多数边界 bug。你也可以把这个当成自己的检查清单。
3.4 与转圈打印、蛇形打印的区别
面试里经常把顺时针打印和另外两种矩阵遍历搞混:转圈打印和蛇形打印(也叫 Z 字形打印)。
转圈打印通常指的是从外到内一圈一圈打印,但每一圈的起点和方向可能不同,有些变体要求偶数圈从左到右、奇数圈从右到左。蛇形打印则是按行遍历,但奇数行从左到右、偶数行从右到左。
顺时针打印的特点是方向始终是右、下、左、上循环,而且是从外到内逐层递进。搞清楚这个区别很重要,因为面试官可能会在你写完顺时针之后追问:"如果改成蛇形打印怎么做?"这时候你如果能快速说出思路差异,印象分会高很多。
4. 完整实现与代码逐段拆解
4.1 Python 实现与逐行注释
def spiral_order(matrix): if not matrix or not matrix[0]: return [] rows, cols = len(matrix), len(matrix[0]) top, bottom = 0, rows - 1 left, right = 0, cols - 1 result = [] while top <= bottom and left <= right: # 从左到右打印上边 for col in range(left, right + 1): result.append(matrix[top][col]) top += 1 # 从上到下打印右边 if top <= bottom: for row in range(top, bottom + 1): result.append(matrix[row][right]) right -= 1 # 从右到左打印下边 if left <= right: for col in range(right, left - 1, -1): result.append(matrix[bottom][col]) bottom -= 1 # 从下到上打印左边 if top <= bottom: for row in range(bottom, top - 1, -1): result.append(matrix[row][left]) left += 1 return result这段代码有几个细节值得展开说。
第一,开头的空矩阵判断if not matrix or not matrix[0]是必须的。LeetCode 上有些测试用例会传空数组或者[[]]这样的输入,不处理的话直接报 IndexError。
第二,上边的打印没有加额外的边界检查,因为 while 循环的条件已经保证了 top <= bottom 且 left <= right,而上边是每轮第一个打印的,此时边界一定合法。
第三,右边、下边、左边的打印都加了if检查。右边的检查是if top <= bottom,因为打印上边之后 top 自增了,如果此时 top 超过了 bottom,说明没有剩余行可以打印右边了。下边的检查是if left <= right,因为打印右边之后 right 自减了。左边的检查又是if top <= bottom,因为打印下边之后 bottom 自减了。
第四,range 的用法要注意。从左到右是range(left, right + 1),从右到左是range(right, left - 1, -1),从下到上是range(bottom, top - 1, -1)。Python 的 range 是左闭右开的,所以右端点要加一或者减一。
4.2 Java 实现与差异对比
public List<Integer> spiralOrder(int[][] matrix) { List<Integer> result = new ArrayList<>(); if (matrix == null || matrix.length == 0 || matrix[0].length == 0) { return result; } int rows = matrix.length, cols = matrix[0].length; int top = 0, bottom = rows - 1; int left = 0, right = cols - 1; while (top <= bottom && left <= right) { for (int col = left; col <= right; col++) { result.add(matrix[top][col]); } top++; if (top <= bottom) { for (int row = top; row <= bottom; row++) { result.add(matrix[row][right]); } right--; } if (left <= right) { for (int col = right; col >= left; col--) { result.add(matrix[bottom][col]); } bottom--; } if (top <= bottom) { for (int row = bottom; row >= top; row--) { result.add(matrix[row][left]); } left++; } } return result; }Java 版本和 Python 版本在逻辑上完全一致,差异主要在语法层面。Java 的数组长度用matrix.length和matrix[0].length,循环用标准的 for 循环,结果收集用 ArrayList。注意 Java 里判断空矩阵要同时检查matrix.length == 0和matrix[0].length == 0,因为可能存在[[]]这种情况。
4.3 手动模拟 3x3 矩阵的完整过程
拿矩阵[[1,2,3],[4,5,6],[7,8,9]]走一遍,把每一步的边界值和输出都列出来。
初始状态:top=0, bottom=2, left=0, right=2, result=[]。
第一轮循环:
- 打印上边:col 从 0 到 2,输出 1,2,3。top 变成 1。
- 检查 top(1) <= bottom(2),成立。打印右边:row 从 1 到 2,输出 6,9。right 变成 1。
- 检查 left(0) <= right(1),成立。打印下边:col 从 1 到 0,输出 8,7。bottom 变成 1。
- 检查 top(1) <= bottom(1),成立。打印左边:row 从 1 到 1,输出 4。left 变成 1。
第一轮结束,result = [1,2,3,6,9,8,7,4],边界变成 top=1, bottom=1, left=1, right=1。
第二轮循环:
- 打印上边:col 从 1 到 1,输出 5。top 变成 2。
- 检查 top(2) <= bottom(1),不成立,跳过右边。
- 检查 left(1) <= right(1),成立,但下边的打印需要 top <= bottom,此时不成立,实际上代码里下边的检查是 left <= right,这里成立,但 bottom 的循环范围是 range(right, left-1, -1) 即 range(1, 0, -1),会打印 matrix[bottom][1] = matrix[1][1] = 5,这就重复了!
等等,这里有问题。让我重新检查一下代码逻辑。在第二轮中,打印上边之后 top 变成 2,bottom 还是 1。此时 top > bottom,说明没有剩余行了。但是下边的检查条件是if left <= right,此时 left=1, right=1,条件成立,会执行下边的打印。而下边的打印是for col in range(right, left-1, -1),即 col 从 1 到 1,打印 matrix[bottom][col] = matrix[1][1] = 5。但 5 已经在打印上边的时候被打印过了。
这说明我的代码有问题!下边的检查条件应该是if top <= bottom而不是if left <= right。让我修正一下。
实际上,正确的检查逻辑应该是这样的:打印下边之前,需要同时满足 top <= bottom 和 left <= right。因为下边是水平方向的打印,需要行还有效(top <= bottom),列也还有效(left <= right)。但在打印上边之后 top 已经自增了,如果 top > bottom,说明这一轮已经没有更多的行需要打印了,下边和左边都不应该执行。
所以正确的代码应该是:
def spiral_order(matrix): if not matrix or not matrix[0]: return [] rows, cols = len(matrix), len(matrix[0]) top, bottom = 0, rows - 1 left, right = 0, cols - 1 result = [] while top <= bottom and left <= right: for col in range(left, right + 1): result.append(matrix[top][col]) top += 1 if top <= bottom: for row in range(top, bottom + 1): result.append(matrix[row][right]) right -= 1 if top <= bottom and left <= right: for col in range(right, left - 1, -1): result.append(matrix[bottom][col]) bottom -= 1 if top <= bottom and left <= right: for row in range(bottom, top - 1, -1): result.append(matrix[row][left]) left += 1 return result修正后的逻辑:下边和左边的打印都需要同时检查 top <= bottom 和 left <= right。这样在第二轮中,top=2, bottom=1,top <= bottom 不成立,下边和左边都会被跳过,循环结束。最终 result = [1,2,3,6,9,8,7,4,5],正确。
这个 bug 非常典型,我在第一次写这道题的时候也踩过。它说明了一个道理:边界检查的条件不是随便写的,每一条边的打印需要哪些边界有效,必须想清楚。上边需要行有效(top <= bottom)和列有效(left <= right),右边需要行有效和列有效,下边需要行有效和列有效,左边需要行有效和列有效。但因为打印顺序的关系,有些条件在 while 循环入口已经保证了,有些则需要额外检查。
4.4 另一种写法:方向数组模拟法
为了对比,我也把方向数组模拟法的代码贴出来。
def spiral_order_direction(matrix): if not matrix or not matrix[0]: return [] rows, cols = len(matrix), len(matrix[0]) visited = [[False] * cols for _ in range(rows)] directions = [(0, 1), (1, 0), (0, -1), (-1, 0)] # 右、下、左、上 result = [] row, col, d = 0, 0, 0 for _ in range(rows * cols): result.append(matrix[row][col]) visited[row][col] = True next_row = row + directions[d][0] next_col = col + directions[d][1] if (0 <= next_row < rows and 0 <= next_col < cols and not visited[next_row][next_col]): row, col = next_row, next_col else: d = (d + 1) % 4 row += directions[d][0] col += directions[d][1] return result这种写法的优点是逻辑直观,就是模拟一个人沿着矩阵走,撞墙就右转。缺点是额外用了 O(mn) 的 visited 数组,而且转向的判断条件需要仔细处理。在实际面试中,如果面试官没有特别要求,我更推荐边界收缩法,因为它空间更优,而且更能体现你对边界控制的理解。
5. 常见问题与排查技巧实录
5.1 元素重复打印的三种典型场景
元素重复打印是这道题最高频的 bug,没有之一。我总结下来,重复打印通常发生在三种场景。
场景一:单行矩阵。比如 [[1,2,3]],打印完上边之后 top 变成 1,bottom 还是 0。如果此时不检查 top <= bottom 就继续打印下边,会再次打印 1,2,3。修复方法就是在打印下边之前检查 top <= bottom。
场景二:单列矩阵。比如 [[1],[2],[3]],打印完上边(只有 1)之后 top 变成 1,打印右边(2,3)之后 right 变成 -1。如果此时不检查 left <= right 就继续打印下边,会出问题。修复方法是在打印下边之前检查 left <= right。
场景三:最后一圈只剩一行或一列。比如 3x3 矩阵走到最后一圈只剩中心元素 5,打印完上边之后 top 变成 2,bottom 是 1,此时 top > bottom,不应该再打印下边和左边。修复方法同上。
这三种场景的共同点是:边界收缩之后,剩余的未打印区域可能退化成一条线甚至一个点,此时某些方向的打印就不再需要了。解决办法就是在每条边打印之前,检查该方向所需的边界是否仍然有效。
5.2 死循环的排查思路
死循环在这道题里相对少见,但也不是没有。如果你发现程序跑不出结果,大概率是边界指针没有正确更新,导致 while 循环的条件一直为真。
排查方法很简单:在 while 循环里打印当前的 top、bottom、left、right 值,看看它们是否在每轮循环后都有变化。正常情况下,每轮循环至少有一个边界指针会移动,如果四个指针都不动,那就是死循环了。
常见的原因包括:忘记在打印完一条边后更新边界指针,或者更新方向搞反了(比如 top 应该自增却写成了自减)。这种错误在面试紧张的时候很容易犯,建议写完代码后手动走一遍循环,确认每个指针的移动方向正确。
5.3 空矩阵与特殊输入的防御性处理
LeetCode 上这道题的测试用例包括以下几种特殊输入:空数组[]、包含空行的数组[[]]、1x1 矩阵[[1]]、1xn 矩阵、nx1 矩阵。你的代码需要能正确处理所有这些情况。
防御性处理的模板是:
if not matrix or not matrix[0]: return []这一行代码同时处理了[]和[[]]两种情况。对于[],not matrix为真;对于[[]],not matrix为假但not matrix[0]为真。两个条件用 or 连接,任意一个成立就返回空数组。
提示:在面试中,建议主动询问面试官是否需要处理空矩阵的情况。有些面试官会直接告诉你输入保证非空,有些则会考察你有没有这个意识。主动问一句不丢分,反而显得你考虑周全。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 修复方案 |
|---|---|---|---|
| 元素重复打印 | 边界检查缺失 | 用 1xN 和 Nx1 矩阵测试 | 每条边打印前检查对应边界 |
| 数组越界 | 索引范围计算错误 | 检查 range 的起止值 | 确认左闭右开区间的端点 |
| 死循环 | 边界指针未更新 | 打印每轮的四个指针值 | 确保每轮至少一个指针移动 |
| 结果缺少元素 | 循环终止条件过早 | 用 2x2 矩阵手动模拟 | 检查 while 条件是否用了 <= |
| 空矩阵报错 | 未做防御性判断 | 传入 [] 和 [[]] 测试 | 开头加空矩阵判断 |
6. 从这道题延伸出的矩阵处理思维
6.1 边界收缩法在其他矩阵题中的应用
边界收缩这个思路不只用于顺时针打印。很多矩阵相关的题目都可以用类似的框架来解决。
比如螺旋矩阵 II,要求生成一个 n x n 的螺旋矩阵,填充 1 到 n²。这其实就是顺时针打印的逆过程,用同样的边界收缩框架,把"读取"换成"写入"就行了。
再比如矩阵置零,如果要求原地操作,也可以用边界标记的思路来减少空间复杂度。还有旋转图像,虽然通常用转置加翻转来做,但如果你用边界收缩的思路逐层旋转,也能得到正确结果,只是代码会复杂一些。
我的经验是:凡是涉及"逐层处理"的矩阵题目,边界收缩法都值得优先考虑。它的核心优势在于用四个指针精确描述了当前处理的范围,逻辑清晰,不容易出错。
6.2 面试中的表达技巧与追问应对
面试的时候,写完代码只是第一步,更重要的是能清晰地解释你的思路。我建议按照这个顺序来表达:先说整体方案(边界收缩法,四个指针),再说核心不变量(未打印区域始终是矩形),然后说每轮的操作(四条边依次打印并收缩),最后说终止条件(边界交错)。
面试官可能会追问的几个问题,提前准备好答案:
"如果矩阵非常大,内存放不下怎么办?"——这道题本身要求返回所有元素,所以内存是必须的。但如果只是要求按顺序输出而不存储,可以用生成器逐元素 yield,空间复杂度降到 O(1)。
"能不能不用四个指针?"——可以用方向数组加 visited 数组,但空间复杂度会上升。也可以用一个指针加步长计算,但代码可读性会下降。
"如果要求逆时针打印呢?"——把四条边的打印顺序反过来就行了,先打印左边从下到上,再打印下边从右到左,以此类推。
6.3 实际工程中的矩阵遍历场景
你可能会觉得,矩阵遍历这种题只有面试才用得到,实际工作中根本用不上。其实不然。
图像处理里的卷积操作,本质上就是在矩阵上滑动一个窗口,虽然不需要螺旋遍历,但边界处理的思路是相通的。游戏开发里的地图渲染,经常需要按层遍历 tile 地图,边界收缩法可以直接套用。数据分析里的二维数组聚合操作,比如按圈层统计某些指标,也会用到类似的遍历模式。
甚至在一些看似不相关的领域,比如打印机的走纸路径规划、仓库拣货的最优路径,都能看到螺旋遍历的影子。所以这道题的价值不在于题目本身,而在于它训练了你对二维空间边界控制的敏感度,这个能力是可以迁移的。
6.4 我踩过的坑与实操建议
最后分享几个我自己踩过的坑。
第一个坑是过度依赖方向数组。我早期刷题的时候特别喜欢用方向数组,觉得通用性强,后来发现很多矩阵题用方向数组反而把简单问题复杂化了。边界收缩法虽然看起来代码长一点,但逻辑更直白,调试起来也更容易。
第二个坑是忽略单行单列的测试。我有一次面试,代码写完自己觉得没问题,面试官让我跑一个 1x4 的矩阵,结果直接挂了。从那以后我养成了一个习惯:写完任何矩阵相关的代码,第一件事就是手动跑 1x1、1xN、Nx1 这三种情况。
第三个坑是边界检查条件写错。就像我前面展示的那样,下边的检查条件应该是top <= bottom and left <= right,而不是只检查其中一个。这个错误很隐蔽,因为它在大多数常规矩阵上不会暴露,只有特定形状的矩阵才会触发。
实操建议:把这道题的代码模板背下来,但不要死记硬背。理解四个指针的含义、四条边的打印顺序、边界检查的时机,这三样东西理解了,代码自然就能写出来。面试前手写三遍,确保能在没有提示的情况下 15 分钟内写完并跑通所有边界用例。