☰
顺时针打印矩阵:边界收缩法详解与面试实战
2026/10/15 11:54:47 网站建设 项目流程

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 分钟内写完并跑通所有边界用例。

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

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

立即咨询