1. 螺旋矩阵到底考什么:先把这个题看透
如果你刷过一段时间算法题,大概率遇到过这道题:给定一个 m 行 n 列的矩阵,要求按顺时针螺旋顺序返回矩阵中的所有元素。这就是经典的“螺旋矩阵”,LeetCode 上是 54 题,还有一个反向版本是 59 题——给定一个正整数 n,生成一个包含 1 到 n² 所有元素、且元素按顺时针螺旋排列的正方形矩阵。两个题本质是一回事,一个从矩阵拆成序列,一个从序列装回矩阵。
我第一次做这道题的时候,感觉非常难受。矩阵不长,就一个二维数组,但真上手写代码,边界条件处理得乱七八糟。不是越界就是死循环,要么就是输出顺序反了。后来我才意识到,螺旋矩阵真正的价值不在于“螺旋”这两个字,而在于它把二维数组遍历时最容易踩的坑全部集中到了一起:循环边界怎么收缩、方向状态怎么切换、什么时候该停下来。这两个题每句话都在说同一件事:你在处理一个多维结构的时候,有没有一套清晰的、自洽的边界管理方法。
所以这篇文章适合谁看?准备算法面试的人,刚接触二维数组遍历的新手,以及那些已经会写但想搞清楚“为什么这么写不会错”的人。我会把螺旋矩阵的三种常见解法、完整代码、边界条件排查,以及它和矩阵运算、矩阵应用之间的延伸关系全部拆开讲一遍。看完之后,你不仅能手写螺旋矩阵,还能把二维数组遍历这类问题的套路记牢。
2. 三种主流解法的取舍:别一上来就写代码
很多人拿到螺旋矩阵直接开干,写完发现各种 bug。其实这道题在动手之前,先想清楚用哪种思路,比急着敲代码重要得多。目前最主流的解法有三类:按层模拟、方向向量模拟、递归剥洋葱。我逐个说清楚,讲完你就知道为什么大多数人在面试里会选第一种。
2.1 解法一:按层模拟,最容易写对的思路
按层模拟的核心逻辑是把矩阵看成一层一层的“回字形”结构。最外层一圈先遍历完,然后往里收缩一层,再遍历第二圈,直到所有元素都被访问。这个思路最大的优点在于:每一圈的处理逻辑完全一致,你只需要写一遍,剩下的靠循环重复就行。
每个圈由四个边界决定:上边界 top、下边界 bottom、左边界 left、右边界 right。遍历一圈的过程是固定四步:
- 从左到右遍历 top 行;
- 从上到下遍历 right 列;
- 从右到左遍历 bottom 行(前提是 top 和 bottom 不是同一行);
- 从下到上遍历 left 列(前提是 left 和 right 不是同一列)。
每走完一圈,top 加一、bottom 减一、left 加一、right 减一。循环继续的条件是 top 小于等于 bottom 并且 left 小于等于 right。为什么不是小于而是小于等于?因为当矩阵只剩一行或者一列的时候,top 和 bottom 相等,或者 left 和 right 相等,我们仍然需要处理最后一个圈,这时候可以用小于等于让循环进入最后一轮,然后靠内部的两个 if 条件避免重复访问。
这个方法面试时最稳,因为代码结构清晰,边界条件少,你只需要记住四个方向、四个边界、两个防重复的判断,基本不会写出圈。
2.2 解法二:方向向量 + 状态切换,代码量最少
第二种思路是方向模拟。把“右、下、左、上”四个方向定义成四个方向向量,每次走一步,判断下一步会不会走出边界或者走到已经访问过的位置,如果是就换方向,否则继续走。
方向向量的定义大概是这样的:右是 (0, 1),下是 (1, 0),左是 (0, -1),上是 (-1, 0)。用一个 direction 指针在四个方向里循环轮转,每次尝试往当前方向走一步,如果越界或者目标位置已经被访问过,就切换到下一个方向。
这种写法最直观,代码量也少,大概 20 行左右就能搞定。但它有两个隐含代价:第一,你需要额外用一个 visited 数组记录访问状态,多出来 O(m×n) 的空间;第二,你得保证“被访问过”这个判断在所有情况下都正确,否则很容易出现重复访问同一个位置的问题。如果你在用 Python,可以把 visited 数组省掉,改用“下一步位置在边界外就转向”的方式,但那样你得同时维护四个边界变量,本质上就又回到了按层模拟的思路。
2.3 解法三:递归剥洋葱,思路酷炫但效率一般
递归解法的想法很直接:先把矩阵最外圈的元素按顺序取出来,然后对去掉最外圈之后的内部矩阵递归调用同样的处理函数。递归的终止条件是矩阵为空,或者只剩一行、只剩一列。
这个解法听起来很优雅,代码也不复杂,但我实际用下来并不推荐在面试里首选它。原因有两个:一是递归有函数调用开销,虽然对这道题影响不大,但没必要;二是每次递归都要对矩阵做切片操作,如果是在 Python 里用列表切片,比如 matrix[1:-1] 这种方式,会复制子矩阵,最差情况下额外消耗接近 O(m×n×min(m,n)) 的时间复杂度。更容易被面试官追问的点在于:你如何把切出来的子矩阵再传回去?边界怎么切才不丢元素?这些细节一多,人一紧张就容易崩。所以递归适合作为“你能想到多种解法”的加分项,不适合作为主打的保底方案。
2.4 三种解法怎么选
从实际面试角度出发,我的建议很明确:按层模拟练到条件反射,方向模拟练到能随手写出来,递归知道原理即可。下面这张表是三种解法的核心对比:
| 解法 | 核心思想 | 时间复杂度 | 空间复杂度 | 面试推荐度 |
|---|---|---|---|---|
| 按层模拟 | 四边界向内收缩 | O(m×n) | O(1) | ★★★★★ |
| 方向向量模拟 | 方向数组 + 状态切换 | O(m×n) | O(m×n)(visited 数组) | ★★★★ |
| 递归剥洋葱 | 逐层切片 + 递归 | O(m×n)(切片版本退化) | O(min(m,n)) 递归栈 | ★★★ |
如果你面试时手写按层模拟,面试官大概率不会再为难你。如果面试官想加难度,通常就会问“如果矩阵不是正方形怎么办”“能不能逆时针输出”“能不能从内往外”这些变体。别慌,这些变体的核心仍然是边界管理,只是方向顺序或者起始位置变了。
3. 手把手实现螺旋矩阵:完整代码与边界细节拆解
下面我把按层模拟的方向完整写一遍,用 Python 先实现 LeetCode 54 的“矩阵拆序列”,再用 C++ 实现一下方向向量的版本,你正好可以对比两种语言、两种思路的写法差异。代码我标注了精讲部分,建议拿着代码慢慢对照看。
3.1 Python 实现:按层模拟(LeetCode 54)
from typing import List def spiralOrder(matrix: List[List[int]]) -> List[int]: if not matrix or not matrix[0]: return [] m, n = len(matrix), len(matrix[0]) top, bottom, left, right = 0, m - 1, 0, n - 1 result = [] while top <= bottom and left <= right: # 第一步:从左到右遍历上边界 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]) # 第三步:从右到左遍历下边界 # 只有 top != bottom 时才需要执行,否则会重复第一步已访问的元素 if top < bottom: for j in range(right - 1, left - 1, -1): result.append(matrix[bottom][j]) # 第四步:从下到上遍历左边界 # 只有 left != right 时才需要执行,否则会重复第二步已访问的元素 if left < right: for i in range(bottom - 1, top, -1): result.append(matrix[i][left]) top += 1 bottom -= 1 left += 1 right -= 1 return result这段代码的精髓就两个地方:第一是 while 条件里用“小于等于”而不是“小于”,保证最后只剩一行或一列时循环仍能进去;第二是第三步和第四步前面的 if 判断,这是整个按层模拟最容易出错的两行。
我讲一个具体场景你就能明白为什么必须有这两个 if。假设矩阵只有一行三列,也就是 matrix = [[1, 2, 3]]。第一轮循环里,第一步会正确输出 1、2、3。然后它还会执行第二步吗?不会,因为 range(top + 1, bottom + 1) 等价于 range(1, 1),是空的。第三步会执行吗?如果按我的写法,会先判断 top < bottom,此时 top 等于 bottom,条件不成立,所以不会执行。但如果你没写这个 if,第三步会从 right - 1 一路往左遍历到 left - 1,把 2、1 又输出一遍。这就是重复访问。第四步同理,如果 left 和 right 相等,你没写 if,就会把已经访问过的元素再反向输出一次。
3.2 C++ 实现:方向向量模拟(LeetCode 59)
C++ 版本我用方向向量的思路写生成矩阵的 59 题,因为从 1 到 n² 按顺序填充的过程正好需要“每走一步就判断下一步能不能走”的状态切换。
#include <vector> using namespace std; vector<vector<int>> generateMatrix(int n) { vector<vector<int>> matrix(n, vector<int>(n, 0)); vector<vector<bool>> visited(n, vector<bool>(n, false)); // 方向顺序:右、下、左、上 int dx[4] = {0, 1, 0, -1}; int dy[4] = {1, 0, -1, 0}; int x = 0, y = 0, dir = 0; for (int num = 1; num <= n * n; num++) { matrix[x][y] = num; visited[x][y] = true; // 预判下一步位置 int nx = x + dx[dir]; int ny = y + dy[dir]; // 如果越界或者已经访问过,就切换方向 if (nx < 0 || nx >= n || ny < 0 || ny >= n || visited[nx][ny]) { dir = (dir + 1) % 4; nx = x + dx[dir]; ny = y + dy[dir]; } x = nx; y = ny; } return matrix; }这个写法的关键是预判而不是事后补救。每次填完当前位置,立刻计算下一步的位置,一旦发现越界或撞上已访问节点,马上换方向,再重新计算下一步位置。这样保证循环从 1 到 n 的平方不会被卡死。visited 数组起了“记忆”的作用,没有它你很难判断一个位置是否已经填过,尤其是绕到内部之后方向切换频繁,靠纯坐标计算容易漏判。
3.3 边界条件逐条讲清楚
螺旋矩阵的边界管理核心其实只有四句话:
- 循环条件是top <= bottom && left <= right,保证最后一个内部圈还能被处理;
- 上边界的遍历区间是[left, right],闭区间,左右端点都不漏;
- 右边界的遍历区间是[top+1, bottom],从 top+1 开始是为了跳过第一步已经访问过的右上角元素;
- 下边界的遍历区间是[right-1, left],左边界的遍历区间是[bottom-1, top+1],这两个动作必须由
top < bottom和left < right来守护。
很多人写代码变成死循环,问题几乎都出在收缩边界的位置。我曾经见过同学把 top 加一写在第一步之前,结果第二轮循环的上边界一开始就往下缩了一行,导致最上面一行永远少遍历一个元素。记住,边界收缩永远在四个方向遍历全部完成之后统一进行,而不是边遍历边收缩。
3.4 复杂度分析:为什么必须遍历完 m 乘 n 个元素
螺旋矩阵的输入规模是 m×n,输出也是 m×n 个元素。无论用什么解法,每个元素都必须在某一时刻被读一次或写一次,所以时间复杂度的下界就是 O(m×n)。按层模拟的空间复杂度是 O(1),因为我们只用了几个边界变量和最终的结果数组,没有额外的辅助结构。方向向量模拟的空间复杂度是 O(m×n),原因是多维护了一个 visited 二维数组。
如果你在面试里被问“能不能不用 visited 数组”,最简单的回答就是用按层模拟,因为四个边界本身就是“访问状态”的天然标记。这也再次说明一个问题:有时候多一个思路不是炫技,而是为了在面试官追问时能有退路。
4. 常见问题与排查技巧实录:这些坑我全踩过
算法题最怕的不是写不出来,而是写出来之后在极端例子上翻车。我把螺旋矩阵最常见的四类问题集中整理了一下,每个问题都附带排查思路,你照着检查一遍基本能定位自己的 bug。
4.1 死循环、重复访问、漏元素
这三个表面是不同的问题,根源往往是一个:方向切换的判断条件不对。死循环通常发生在方向切换后下一步仍然越界的情况。比如方向向量模拟里,如果某个位置四周全被访问过,而你的 dir 切换逻辑只切换一次,切换后的新方向依然走不出去,程序就会卡在那里反复尝试。解决办法是在 for 循环内部用 while 循环来切换方向,直到找到一个能走的方向为止。我见过不少 LeetCode 题解里就是这么处理的:
// 用 while 而不是 if while (true) { int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx >= 0 && nx < n && ny >= 0 && ny < n && !visited[nx][ny]) { x = nx; y = ny; break; } dir = (dir + 1) % 4; }漏元素则偏向按层模拟这边,尤其是忘记写第三步和第四步的 if 判断,导致只有一行或者只有一列的矩阵丢失部分元素,或者输出顺序异常。我排查这类问题的标准做法是:先手工画一个 3×3 的矩阵,把边界变量的变化过程和每一步遍历的坐标全部列出来,对着代码走一遍,看看有没有哪一步变量的取值和自己推演的不一致。
4.2 一列、一行、单元素这种特殊形状
如果矩阵只有一列,比如 [[1], [2], [3]],按层模拟的循环过程是:第一步输出 matrix[0][0],即 1;第二步输出 matrix[1][0]、matrix[2][0],即 2 和 3;第三步因为 top 等于 bottom 吗?不,top=0,bottom=2,不相等,所以会尝试执行从左到右遍历下边界。注意,这里第三步的 for 循环区间是 range(right - 1, left - 1, -1),也就是 range(-1, -1, -1),这个范围为空,所以不会输出任何元素,不影响结果。第四步 left < right 不成立,直接跳过。最终输出 [1, 2, 3],正确。
如果是 3×1 的矩阵,第二步只有一列,第三步区间为空,第四步 left < right,这里 left=0,right=0,不成立,跳过,输出正确。单元素 [[5]] 更简单:第一步输出 5,第二步 range(1, 1) 为空,第三步和第四步条件都不成立,输出 [5]。这些边界情况只要按我的代码写,基本都能直接通过,不需要额外特判。
4.3 面试官追问:反过来让你从螺旋序列重建矩阵
LeetCode 59 的生成版就是正向重建,但面试官还可能换个问法:给你一个螺旋顺序的一维数组,和一个目标行数列数,要求把矩阵还原出来。这种题的核心仍然是方向模拟,只是把“从 1 到 n² 填数”换成了“按顺序取一维数组的元素填入二维数组”。
我建议你用方向向量模拟来解决,因为“按顺序取元素”天然适合逐格填充。visited 数组的作用从“防止重复访问”变成了“标记已填充位置”,逻辑几乎不变。唯一的注意点是:给定的一维数组长度必须恰好等于 m×n,否则不可能唯一还原,这个细节可以和面试官主动确认。
4.4 我的排查技巧总结
最后分享一个我自己的调试习惯。写螺旋矩阵相关的代码,不要直接跑测试用例,先自己画一个 3×3 或 4×4 的矩阵,在纸上标出螺旋遍历的路径。路径画对了,再对照代码看每一段 for 循环的区间是不是精确覆盖了路径上的每一段。用这个方法,我帮不少朋友定位过 bug,几乎每次都能在三分钟之内找到问题所在。边界条件的题,最忌讳空想,画图永远是最快的排查工具。
5. 从螺旋矩阵延伸到更广的矩阵世界
刷完螺旋矩阵,如果你只记住了这道题的答案,其实是浪费了一次很好的机会。矩阵这个结构在编程、数据科学、图像处理、机器学习里到处都是。螺旋矩阵只是帮助你建立了“二维结构遍历”的基本功,接下来我把几个高频的矩阵相关概念串一遍,你会发现它们的内在逻辑和螺旋矩阵一样,本质上都是“我要清晰地管理结构里的位置和状态”。
5.1 矩阵乘法与分块矩阵求逆:理解规则而不是背公式
矩阵乘法是几乎所有矩阵应用的地基。规则一句话概括:A 是 m×k 矩阵,B 是 k×n 矩阵,相乘得到 m×n 矩阵 C,C 的第 i 行第 j 列等于 A 的第 i 行与 B 的第 j 列对应元素乘积之和。我刚学的时候总觉得这个规则很抽象,直到把它理解成“行向量和列向量做点积”,才真正走进门。
热词里还出现了分块矩阵求逆和分块矩阵的 n 次方公式。分块矩阵求逆的核心思想是:把一个大矩阵切成几块,利用块之间的运算关系把求逆过程简化。比如常见的 2×2 分块矩阵:
假设一个矩阵可以分成四块: [ M = \begin{bmatrix} A & B \ C & D \end{bmatrix} ] 其中 A、D 是方阵且 A 可逆,那么它的逆可以通过舒尔补来分块表达。舒尔补的概念第一次接触可能觉得绕,但它的本质就是把大问题化小,和螺旋矩阵按层收缩的思想是同一类,都是在“降低问题的规模”。
分块矩阵求 n 次方常用在递推数列、图论路径计数这类场景。如果你是初学者,我的建议是先把普通矩阵乘法和单位矩阵的概念吃透,再去研究分块求逆。不要一上来就硬背公式,因为公式一旦记混,后面所有基于矩阵的高级运算都会跟着出错。
5.2 混淆矩阵:机器学习里最常用的矩阵
在分类问题里,混淆矩阵是一个 k 行 k 列的表格(二分类就是 2×2),行代表真实类别,列代表预测类别。它的四个核心格子分别是 TP(真正例)、FP(假正例)、FN(假负例)、TN(真负例),准确率、精确率、召回率、F1 这些指标全是从这四个格子里算出来的。
混淆矩阵和螺旋矩阵看起来毫无关系,但它们有一个点相通:都是二维数组,都需要你去理解行和列索引的含义。螺旋矩阵的行列索引是物理位置,混淆矩阵的行列索引是语义标签,但操作方式都是一样——按行列定位、按区域统计。热词里提到的 yolo 混淆矩阵总和问题,本质上也是混淆矩阵的统计口径不一致导致的。如果你用多分类混淆矩阵,记得先确认你是按行归一化还是按列归一化,不同归一化方式算出来的每个类别的召回率和精确率含义不一样。
5.3 numpy 求逆与矩阵特征值分解
Python 里用 numpy 做矩阵运算是日常操作。求逆核心就一行:np.linalg.inv(A)。但这个函数有个重要前提:矩阵必须是方阵且满秩,否则会抛出 LinAlgError。判断矩阵是否满秩可以用np.linalg.matrix_rank(A),秩等于矩阵行数(或列数)时才可能求逆。
特征值分解的理解可以用一个生活化类比:一个矩阵可以看作一个“变换”,特征向量是变换后方向不变的那些特殊方向,特征值是这些方向上被拉伸或缩放的倍数。这个概念在矩阵论里非常基础,也是很多数据结构化分析的基础。你不需要一开始就掌握完整的矩阵论推导,但理解特征值分解和矩阵秩,对你阅读很多算法资料都会有帮助。
5.4 相机的 H 矩阵和文献矩阵
热词里还出现了“相机的 h 矩阵”和“如何用 excel 搭建文献矩阵”。相机 H 矩阵通常指单应性矩阵(Homography Matrix),描述的是同一平面在两个不同视角之间的投影变换关系。它在全景拼接、标定、增强现实中经常出现,核心是求解一个 3×3 矩阵的 8 个自由度。这个话题已经进入计算机视觉的领域,不是我这次展开的重点,但如果你已经把矩阵的概念吃透,再去看 H 矩阵会轻松不少。
文献矩阵则完全是另一个方向的用法:用矩阵的形式整理文献综述,行是文献,列是主题、方法、结论等维度,交叉点填关键信息。这个习惯我强烈推荐,因为表格化整理文献的过程,本质上就是在用二维结构组织信息,和冒泡排序要维护循环边界一样,都是在结构里做有序管理。
最后再强调几句我的实操体会
回到螺旋矩阵本身。我刷了这么多年题,最大的体会就是:这道题的代码量很小,但错误密度极高。每过一段时间回来看,都可能发现新的边界问题。所以我不建议你背代码,而是建议你把四个边界变量和两个防重复 if 条件的逻辑真正想明白。想明白之后,由外到内、由内到外、顺时针、逆时针、矩形、方形这些变体你都能顺手写出来。
我的日常训练方法是:写完按层模拟,再用方向向量写一遍 59 题,最后试着用递归写一遍 54 题。三个版本都写完,再隔几天不看答案重写一次。这种反复摩擦会逼你把边界条件内化成肌肉记忆,而不是靠着记忆硬套模板。
如果你刚接触矩阵这块,也别急着吞下太多延伸知识。先把螺旋矩阵这个二维遍历的基础打牢,再慢慢去接触矩阵乘法、混淆矩阵、numpy 运算这些实用方向。矩阵这个工具的强大之处就在于:很多看起来不一样的问题,最后都能被抽象成“在二维结构里管理位置和状态”,而螺旋矩阵正是你迈入这个大门值得认真对待的第一道门槛。