“逆时针打印矩阵”这个标题,很多人第一反应是:不就是从外往里绕圈输出吗?但真上手写代码后会发现,绕圈不难,难的是绕到一半怎么停下来。它以“面试题29”的编号流传于各类算法题库,是更常见的“顺时针打印矩阵”的镜像变体,考察的核心几乎完全一致:边界条件的处理、方向顺序的设计、以及代码结构的清晰度。这篇文章我会带你把逆时针螺旋的边界收缩法彻底吃透,内容包括最优解实现、4x4实例走读、面试现场常见翻车点,以及同类题目的扩展思路。适合正在准备算法面试的开发者,也适合想补一下矩阵遍历基本功的朋友。
1. 题目回顾与整体设计思路
1.1 先看清题目到底要什么
先还原一下题目:给定一个 m 行 n 列的二维矩阵 matrix,从左上角 (0, 0) 出发,按照逆时针螺旋顺序,从外到内依次输出所有元素。拿下面这个 3x3 矩阵举例:
1 2 3 4 5 6 7 8 9按逆时针方向走,输出序列应该是:1 4 7 8 9 6 3 2 5。把路线画出来非常直观:先沿左边向下走到左下角,再沿底边向右走到右下角,再沿右边向上走到右上角,再沿顶边向左走,然后进入第二圈,重复同样的方向。
这类题在算法面试里出镜率很高,原因在于它“代码不长但坑位密集”。题面一句话就能说清,但真正写代码时,方向顺序、边界收缩、终止条件三个环节只要有一个想岔,输出就会缺元素或者重复元素。面试官很喜欢用这道题来考察候选人对循环不变量和边界条件的敏感度。
另外要提一句:这道题和“顺时针打印矩阵”本质上是一个镜像问题。面试题29的原版通常是顺时针方向,逆时针算是高频变体。但不管是正还是逆,核心解法模型完全一样,只是四条边的打印顺序不同。掌握了逆时针,回手改一下方向序就是顺时针。
1.2 两种主流解法的取舍
面对这道题,常见有两种解法。
第一种是“方向数组 + 访问标记”。维护一个方向向量数组,比如逆时针方向就是:先向下、再向右、再向上、再向左。再开一个同样大小的 visited 二维数组记录哪些格子已经访问过。每走一步先尝试保持当前方向,如果下一个位置越界或者已经被访问,就切换到下一个方向。这种解法属于“直观模拟”,思路非常机械,几乎不需要动脑子。参考代码如下:
def spiral_ccw_by_visited(matrix): if not matrix or not matrix[0]: return [] m, n = len(matrix), len(matrix[0]) visited = [[False] * n for _ in range(m)] # 逆时针方向:下、右、上、左 dirs = [(1, 0), (0, 1), (-1, 0), (0, -1)] x = y = 0 d = 0 res = [] for _ in range(m * n): res.append(matrix[x][y]) visited[x][y] = True nx, ny = x + dirs[d][0], y + dirs[d][1] if nx < 0 or nx >= m or ny < 0 or ny >= n or visited[nx][ny]: d = (d + 1) % 4 nx, ny = x + dirs[d][0], y + dirs[d][1] x, y = nx, ny return res这种方案的好处是逻辑统一,不容易漏元素,甚至把方向数组改一改就能应对各种变体(比如从任意起点开始)。坏处是额外需要一个 O(mn) 的 visited 矩阵,而且如果面试官追问“能不能不用额外空间”,这套代码就有点说不过去。
第二种是“四指针收缩法”,也是这篇博文要重点讲的方案。用四个变量 top、bottom、left、right 圈住当前还没打印的那部分矩阵范围,每一轮从外到内剥一条边,剥完这条边就把对应指针往中间挪一格。整个过程不需要额外数组,空间上更有优势,代码结构也更加对称。
面试场合我更推荐四指针法。原因是这道题本身就在考察你对边界条件的掌控力,四指针法天然把“边界”这个考点放在最显眼的位置。而且它的时间复杂度是 O(mn),空间复杂度不计输出结果的话是 O(1),这个复杂度分析在面试里非常好讲。
1.3 核心心法:把绕圈理解成“剥洋葱”
四指针法的本质,其实就是剥洋葱。把一个矩阵看成一层套一层的“洋葱皮”,每一轮循环剥下当前最外圈,剥完之后剩下的还是一个更小的矩阵,然后继续剥。直到某一次剥完,发现矩阵缩成了一个空范围。
每一步都重复同样的规律:
- 打印当前范围最左边那一列(从上往下);
- 打印当前范围最底边那一行(从左往右);
- 打印当前范围最右边那一列(从下往上);
- 打印当前范围最顶边那一行(从右往左)。
打印完一条边,立刻把对应的指针向中间收缩。这四个动作构成一个稳定的“循环节”,只要循环节不错位,代码的正确性就有保证。
我在练习这道题时有一个体会:不要试图在脑中模拟太抽象的“螺旋”,而是把每一轮的动作写成一个固定的四步模板。第一次写可能觉得啰嗦,多写几次就会觉得这个模板非常可靠,几乎不会出错。
2. 核心实现细节与边界条件
2.1 四条边打印顺序:下、右、上、左
先从方向顺序说起。逆时针从左上角出发,最直观的路线是:
- 第一步向下:沿着矩阵最左边一列,从 top 走到 bottom;
- 第二步向右:沿着矩阵最底边一行,从左走到右;
- 第三步向上:沿着矩阵最右边一列,从 bottom 往上走到 top;
- 第四步向左:沿着矩阵最顶边一行,从右往左走。
写成符号就是:下 → 右 → 上 → 左。如果你写的是顺时针版,方向顺序就变成:右 → 下 → 左 → 上。两者差别只在方向序列的排列,但面试时经常有人写着写着就串台了。
这里还有一个容易踩的细节:走“向左边”的时候,顶边行的最左端元素其实已经被第一步“向下打印左列”打印过了;走“向上边”的时候,右列的最顶端元素会被包含进来,而最底端的元素已经在第二步“向右打印底行”里打过了。这些“重叠位”之所以不会重复打印,就是边界收缩在起作用。每打印完一条边,对应的 left、bottom、right、top 会向内部收缩,藏在边角位置的元素自然不会被第二遍碰到。
2.2 为什么每一步之后都要判断边界
这一节应该是整道题最关键的地方,也是绝大多数人翻车的位置。
大循环的入口条件一般是while top <= bottom and left <= right,但只靠这一个条件远远不够。在执行完某一条边并更新指针之后,四个指针之间有可能已经交叉,此时不应该再继续执行后面的边,否则就会发生重复打印,甚至数组越界。
拿一个极端的例子来说:矩阵是一行三列,[[1, 2, 3]]。初始 top = 0,bottom = 0,left = 0,right = 2。进入循环后:
- 向下打印左边列:只有 (0,0) 一个元素,输出 1,left 变成 1;
- 此时 left 还没超过 right,继续向右打印底行:输出 2 和 3,bottom 变成 -1;
- 关键来了,此时 top=0,bottom=-1,说明矩阵范围已经空了。如果代码没有在这里立刻中断,下一步“向上打印最右列”就会拿着 bottom=-1 的起点去构造循环,要么一上来就用越界下标访问 matrix[-1][2],要么进入一个空循环之后又把右边界缩掉,最后再跑去重复打印顶行。
所以边界判断的正确姿势是:每打印完一条边、收缩完指针之后,立刻检查是否还有下一条边可以打印。具体来说,可以套用下面这套规律:
- 打印完左边后,
left += 1,如果left > right,说明左右边界已经交叉,直接 break; - 打印完底边后,
bottom -= 1,如果top > bottom,说明上下边界已经交叉,直接 break; - 打印完右边后,
right -= 1,如果left > right,直接 break; - 打印完顶边后,
top += 1,然后回到 while 条件判断。
这套检查从我的实战经验看是必须的,少一个都会出问题。尤其是面试官如果刻意给你一个单行矩阵或者单列矩阵,那几乎就是在专门等你在这里出错。
2.3 边界收缩为什么天然防重复
边界收缩是防重复的关键,原因很简单:每打印完一条边,这条边上的全部元素就相当于“永久离开”下一轮的打印范围。举个例子,当第一步把左边一列从上到下打完,立刻执行left += 1,下一次向右打印底行时,底行的最左端元素就会因为j从新的 left 开始而被跳过。以此类推。
所以只要保证每条边打印完之后收缩对应指针,元素即使物理上被打印过一次,也不会再出现在后续任何一条边的候选范围里。这也是为什么四指针法不需要额外的 visited 数组,因为边界本身起到了“标记已访问”的作用。
3. 完整代码实现与实例走读
3.1 迭代版参考代码(Python)
把上面的分析落成代码,就是下面这一版。我习惯把每条边的打印、指针收缩、边界检查三个操作紧挨着写,让整个循环节非常清晰。
def spiral_matrix_ccw(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 ans = [] while top <= bottom and left <= right: # 1. 向下:打印最左列 for i in range(top, bottom + 1): ans.append(matrix[i][left]) left += 1 if left > right: break # 2. 向右:打印最底行 for j in range(left, right + 1): ans.append(matrix[bottom][j]) bottom -= 1 if top > bottom: break # 3. 向上:打印最右列 for i in range(bottom, top - 1, -1): ans.append(matrix[i][right]) right -= 1 if left > right: break # 4. 向左:打印最顶行 for j in range(right, left - 1, -1): ans.append(matrix[top][j]) top += 1 return ans每一段代码的动作顺序都是统一的:先用 for 循环打印对应边,然后立刻收缩指针,然后判断是否 break。三行连成一个不可分割的整体后,代码的正确性会明显提高。
时间复杂度是 O(mn),因为每个元素恰好被访问一次。额外空间方面,除了存放结果的 ans 数组,只用到了四个整型变量,所以是 O(1) 的工作空间。面试时把这两句复杂度分析讲出来,会显得对代码有充分把握。
3.2 用 4x4 矩阵完整走一遍
纸上得来终觉浅,这里我用一个 4x4 矩阵完整走一遍代码的执行过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16初始状态:top=0,bottom=3,left=0,right=3。
第一轮循环:
- 向下打印 left=0 这一列,行从 0 到 3:输出 1、5、9、13。然后 left 变成 1。
- 向右打印 bottom=3 这一行,列从 1 到 3:输出 14、15、16。然后 bottom 变成 2。
- 向上打印 right=3 这一列,行从 2 到 0:输出 12、8、4。然后 right 变成 2。
- 向左打印 top=0 这一行,列从 2 到 1:输出 3、2。然后 top 变成 1。
第一轮结束后,ans 为:[1, 5, 9, 13, 14, 15, 16, 12, 8, 4, 3, 2],当前内部范围是 top=1、bottom=2、left=1、right=2。
第二轮循环:
- 向下打印 left=1 这一列,行从 1 到 2:输出 6、10。left 变成 2。
- 向右打印 bottom=2 这一行,列从 2 到 2:输出 11。bottom 变成 1。
- 向上打印 right=2 这一列,行从 1 到 1:输出 7。right 变成 1。
- 检查 left=2 > right=1,break。
最终结果:[1, 5, 9, 13, 14, 15, 16, 12, 8, 4, 3, 2, 6, 10, 11, 7]。总共 16 个元素,一个不多一个不少。
这里最值得回味的是第二轮第 4 步“向左打印顶行”被 break 掉了。因为走到这一步时,内部只剩[6, 7]和[10, 11]这 2x2 的四个元素,而 6、10、11、7 已经分别被前面三步打印过,如果继续向左走,必然碰到已经输出过的 6。这就是边界检查在非正方向情况下起到保护作用的典型案例。
3.3 递归版思路与其他语言注意点
除了迭代法,还有一种递归思路也值得一提。它的核心是:先打印当前外圈,然后递归处理缩掉一圈的内部子矩阵。
def spiral_ccw_recursive(matrix): res = [] def solve(top, bottom, left, right): if top > bottom or left > right: return for i in range(top, bottom + 1): res.append(matrix[i][left]) for j in range(left + 1, right + 1): res.append(matrix[bottom][j]) for i in range(bottom - 1, top - 1, -1): res.append(matrix[i][right]) for j in range(right - 1, left, -1): res.append(matrix[top][j]) solve(top + 1, bottom - 1, left + 1, right - 1) if matrix and matrix[0]: solve(0, len(matrix) - 1, 0, len(matrix[0]) - 1) return res递归版的思路和“剥洋葱”一脉相承,但注意它的每条边都通过起点偏移来避开已经打印过的角点。这个版本在单行、单列的情况下依然安全,比如单行时中间的 for 循环会自动跳过。缺点也很明显:递归深度是min(m, n) // 2量级,矩阵特别长条时会有额外栈空间开销,所以面试时把它作为“另一种思路”来聊没问题,写代码时我建议还是优先迭代版。
如果用 C++ 写,还要留意几个细节:一是入口要同时判断matrix.empty()和matrix[0].empty();二是矩阵的 m、n 建议直接用 int,不要用 size_t,否则边界减到 -1 时会原地爆炸。Java 版本需要注意 List 的使用,逻辑和 Python 完全一致。
4. 常见问题与排查技巧
4.1 为什么输出里会出现重复元素
重复打印是最常见的报错现象,多数时候问题出在边界检查不完整。典型场景就是我前面说的单行矩阵:第一步打印左列之后 left 加 1,第二步打印底行时 bottom 减成 -1,如果代码没有在第二步之后做top > bottom检查,第三步“向上打印右列”可能返回空,但第四步“向左打印顶行”会因为 top 还没变,又把已经打印过的第二、第三个元素重新打印一遍。
重复元素还有一种隐蔽的成因:四条边的打印范围没有利用更新后的指针,而是仍然用了旧的 left、right、top、bottom。比如向右打印底行时,for j in range(left, right + 1)里的 left 必须是用新值还是旧值?答案是:第一步已经打印过左下角,所以这里的 left 应该用更新后的新值,从 left 开始,才能跳过左下角。如果代码习惯性写成range(0, right + 1),那矩阵左下角和底边的第一个元素就会被打印两次。
排查重复问题时,建议先用一个 1x3 或 3x1 的极端矩阵跑一遍。这种矩阵最能暴露边界检查缺失的问题。
4.2 为什么某些元素被漏掉了
漏打印的常见原因有三个。
第一个是方向顺序写反了。比如逆时针的序列是下、右、上、左,有人习惯性写成顺时针的右、下、左、上,那么整个螺旋路径就会整体错位,某些夹在边角之间的元素可能在路径中途被跳过。
第二个是边界收缩的时机不对。比如向上打印最右列时,行的范围是从 bottom 到 top。如果在此之前已经执行了bottom -= 1,那么起点就会漏掉右下角那一层元素。而如果没执行bottom -= 1,右下角元素又会被重复打印。所以“打印”和“收缩”必须跟每一步的指针状态严格匹配。
第三个是终止条件里的等号处理。大循环条件写成while top <= bottom and left <= right是允许等号的,因为当 top 等于 bottom 且 left 等于 right 时,内部还有最后一个元素需要打印。如果错误地写成while top < bottom and left < right,那无论矩阵是奇数边长还是只剩一行一列,都会漏掉中心那部分元素。
4.3 面试现场如何快速自查
面试时写完代码,我会固定用六个用例在脑子里或者草稿纸上快速跑一遍:
- 空矩阵:预期返回空数组;
- 单行矩阵
[[1, 2, 3]]:预期输出 1 2 3; - 单列矩阵
[[1], [2], [3]]:预期输出 1 2 3; - 单元素矩阵
[[1]]:预期输出 1; - 2x3 矩阵:验证非对称尺寸下的边界收缩;
- 3x3 或 4x4 矩阵:验证完整螺旋。
只要这六个用例能通过,代码基本就稳了。
额外分享一个经验:写代码前可以先在草稿纸上把一个宽高不等的矩阵画出来,从左上角开始沿着逆时针方向把路径描一遍。描的时候注意每一个拐点的位置,那些拐点其实就是四条边交接的角点。把角点的处理搞明白,代码里的边界条件自然就清晰了。
4.4 面试谈吐上的小技巧
面试官问到“逆时针打印矩阵”时,可以主动确认一下起点:“默认是从左上角开始,方向序列是下、右、上、左,对吗?”这句话虽然看起来像在问废话,但能立刻避免双方对题目定义的偏差。因为有些面试官理解的“逆时针”可能边界条件不同,明确之后后面不会跑偏。
如果面试官追问“能不能不用 result 数组,直接 print 数字”,那当然可以,把ans.append(...)直接替换成打印语句即可,空间复杂度降为 O(1)。如果问“能不能把矩阵原地旋转后再输出”,那是另一道题,不要当场混淆。
5. 同类题目的举一反三
5.1 顺时针打印矩阵:只需改方向序
掌握了逆时针,顺时针就等于送分。四指针法里,把四条边的顺序改成“右 → 下 → 左 → 上”,其余逻辑完全不动。以 3x3 矩阵为例,顺时针输出是 1 2 3 6 9 8 7 4 5,逆时针输出是 1 4 7 8 9 6 3 2 5。两者路径互为镜像。
所以很多题库把这两道题放在同一章练习,因为核心代码模型是同一套,改几个字就是另一道题。这也是为什么“面试题29”的变体能被反复用来考察候选人的迁移能力。
5.2 从任意中心向外螺旋:方向数组方案更通用
如果题目改成“从矩阵中心出发,逆时针向外螺旋打印”,四指针法就不太合适了,因为中心可能不在角上,而且路径不是“一圈比一圈小”,而是“一圈比一圈大”。这种场景更适合方向数组 + 访问标记法。
方向数组法好处是天然支持任意起点、任意方向顺序。你只要给一个初始坐标和方向序号,然后每步探测下一个位置是否越界或已访问,就能模拟任意形态的螺旋路径。所以在刷题时可以把它当备选方案留着,万一碰到变体题不至于没有思路。
5.3 蛇形遍历与矩阵旋转
矩阵相关的遍历变体还有不少。比如蛇形遍历,要求第一行从左到右、第二行从右到左、第三行再从左到右。这种题通常是逐行处理,遇到偶数行反转输出顺序,和螺旋遍历的边界收缩模型完全不同。
再比如矩阵旋转 90 度,虽然也有坐标变化,但它本质是“元素重排”,不是“遍历顺序”,解题思路是分圈旋转或者先转置再水平翻转。面试官往往把这几类题目放在一起问,目的都是看你是否熟练掌握二维数组下标的变化规律。我个人的建议是:把“螺旋遍历”“蛇形遍历”“矩阵旋转”“对角线遍历”这四类基础题都练一遍,二维数组的题目基本就全覆盖了。
这道题后续还能怎么扩展?如果你把逆时针打印的结果反过来看,其实就是“顺时针从外到内打印”的另一个变体。搞清楚一条遍历路径的全部细节之后,任何方向序变体都可以用同一套边界收缩模型解决。我在实际刷题阶段,把顺时针、逆时针、从右下角开始、从右上角开始四种版本都写了一遍,过程中犯过的错误几乎都集中在同一类:某个方向做完之后没有及时收缩对应指针。所以如果你只能记住一个核心要点,请记住“打印一条边 → 收缩指针 → 检查边界”是一个原子操作。把它焊死在代码里,这道题就翻不出浪花。