刷 Leetcode Hot 100 的矩阵模块,是我个人觉得性价比最高的一块。别看它题目数量不多,一共就那么几道,但每一道都能单独拿出来当面试题,而且几乎每道题都能从“暴力解”一路问到“原地算法”,非常考验对下标、边界和空间复杂度的敏感度。我见过不少人刷到这里,草草看一遍题解就划走,结果面试时被问到一个细节就卡住。这篇文章我就把这四道核心题——旋转图像、螺旋矩阵、矩阵置零、搜索二维矩阵 II——从思路到代码到坑位,一次讲透。适合正准备刷 Hot 100、或者面试前想集中突击矩阵题的读者。
1. 矩阵题的本质:玩的是下标,不是矩阵
1.1 Hot 100里的矩阵题为什么值得单独刷
矩阵题在 Leetcode 上看着像“数组题的二维版”,但实际刷起来你会发现,它的思维方式跟一维数组完全不同。一维数组的题目核心是“遍历方式”和“状态压缩”,而矩阵题的核心是“坐标映射”和“边界收缩”。同样是 O(n^2) 的复杂度,一维题可能是在算前缀和,二维题可能是在做原地变换,差别非常大。
Hot 100 里的矩阵题有一个共同特点:代码量都很短,短到随便一个新手都能看懂,但真正自己写的时候,超过一半的人会在边界条件上翻车。比如螺旋矩阵,很多人第一眼觉得“这不就是个循环嘛”,真写起来,单行单列的情况一出现,输出直接乱掉;再比如矩阵置零,如果没想清楚标记行的优先级,你会发现原本的 0 被新写入的 0 覆盖,后面整个判断全乱套。
所以矩阵题在面试里特别受欢迎,因为面试官可以用很短的题目,快速看出候选人是否具备“把抽象规则落实到坐标操作”的能力。你刷完这四道题,再去看矩阵类的其他题目,会发现套路感极强,基本就是这几种模式的组合或变形。
1.2 矩阵题的三种基本套路
我刷完矩阵模块之后,把所有遇到的题目归了一下类,其实就三种套路:
第一种是“下标映射”。代表题就是旋转图像、翻转矩阵、对角线遍历。这类题的本质是找到新旧坐标之间的数学关系,然后把每个元素搬到它该去的位置。核心手段是“分步变换”,比如旋转 90 度可以用“转置 + 水平翻转”组合出来,这样就不用去死记旋转公式。
第二种是“边界收缩”。代表题就是螺旋矩阵、蛇形遍历。这类题的核心是维护上下左右四个边界,每次沿着一个方向收集元素,然后收窄对应的边界。难点在于循环终止条件,以及处理非正方形矩阵时“只剩一行/一列”的边界情况。
第三种是“原地标记”。代表题就是矩阵置零。这类题的难点不是“怎么找到 0”,而是“怎么在不申请额外空间的前提下,同时记录多个标记”,并且确保标记本身不会污染后续的判断。
当然还有搜索类,比如搜索二维矩阵 II,它更像是“二分思想在二维空间的投影”。我把这三种套路对应的解题模板在下面四道题里逐一拆开讲,你会发现它们之间还有交叉,比如旋转图像也用到原地操作,螺旋矩阵也涉及下标变化,但理解主线条之后,细节就都串起来了。
2. 四道核心题拆解:从暴力到最优
2.1 旋转图像:转置加翻转,两步完成原地90度旋转
旋转图像这道题要求你把一个 n×n 的矩阵顺时针旋转 90 度,并且必须在原矩阵上直接修改,不能用额外的矩阵。
最直观的暴力解是新建一个同样大小的矩阵,然后按照matrix[i][j] -> new[j][n-1-i]的规则填回去。这个解法没错,但空间复杂度是 O(n^2),面试官大概率会追问:“能不能不用额外空间?”
原地旋转的关键在于观察坐标变化规律。顺时针旋转 90 度,元素从(i, j)移动到(j, n-1-i)。如果直接搬元素,会涉及到多个元素互相覆盖,很容易绕晕。更聪明的做法是拆成两步:
- 第一步,沿着主对角线(左上到右下)做转置,即交换
matrix[i][j]和matrix[j][i]。 - 第二步,每一行做左右翻转。
我们来验证一下坐标变化。转置之后,元素从(i, j)变成(j, i);再做水平翻转,列坐标变成n-1-i。最终(i, j) -> (j, n-1-i),正好就是顺时针旋转 90 度的结果。
class Solution: def rotate(self, matrix): n = len(matrix) # 第一步:沿主对角线转置 for i in range(n): for j in range(i + 1, n): matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j] # 第二步:每一行左右翻转 for i in range(n): matrix[i].reverse()这里面有几个细节需要注意。转置时内层循环的j必须从i+1开始,不能从 0 开始,否则每个元素会被交换两次,等于白做。你可以想象一下,如果从 0 开始,(0,1)和(1,0)先交换一遍,遍历到(1,0)时又交换一遍,结果矩阵完全没变。
n为奇数时中心元素自己跟自己交换,不影响结果。如果题目要求逆时针旋转 90 度,方法类似:先做上下翻转,再沿主对角线转置,或者先转置再上下翻转,效果是一样的,你可以自己推一遍验证。
时间复杂度 O(n^2),空间复杂度 O(1)。这题的面试价值在于,它考察你是否懂得“用数学变换分解复杂操作”,而不是硬背旋转公式。
2.2 螺旋矩阵:四个边界收缩,一圈一圈往外抽
螺旋矩阵要求你按顺时针螺旋顺序输出矩阵中的所有元素。很多人拿到题的第一反应是模拟“转向”,维护方向数组和访问标记。这种做法能过,但比较笨重。更清爽的方法是用四个边界变量直接控制行走范围。
核心逻辑是:定义一个top、bottom、left、right四个边界,初始分别指向矩阵的上下左右。然后按照“左到右、上到下、右到左、下到上”的顺序,每走完一个方向,就把对应的边界往里收一格。循环继续的条件是top <= bottom且left <= right。
class Solution: def spiralOrder(self, matrix): if not matrix or not matrix[0]: return [] res = [] top, bottom = 0, len(matrix) - 1 left, right = 0, len(matrix[0]) - 1 while top <= bottom and left <= right: # 左到右 for j in range(left, right + 1): res.append(matrix[top][j]) top += 1 # 上到下 for i in range(top, bottom + 1): res.append(matrix[i][right]) right -= 1 # 右到左(注意判断边界是否还合法) if top <= bottom: for j in range(right, left - 1, -1): res.append(matrix[bottom][j]) bottom -= 1 # 下到上(同样要判断) if left <= right: for i in range(bottom, top - 1, -1): res.append(matrix[i][left]) left += 1 return res这段代码里最关键的坑就在最后两个if。如果是标准正方形矩阵,不加if也能勉强跑对,但一旦遇到 2×3、3×2 这种长方形矩阵,最后两个方向的循环就可能把已经输出过的元素再输出一遍,或者访问到不合法的边界。
举个例子,矩阵只有一行,比如[[1,2,3]]。第一次左到右输出1,2,3,然后top变成 1,此时top > bottom,外层循环本来该结束了,但如果不加if保护,右到左循环会继续访问matrix[bottom],而bottom还是 0,就会把3,2,1再输出一遍,最终结果变成1,2,3,3,2,1,明显错误。
所以我的习惯是:每收缩一个边界之后,都要重新检查top <= bottom和left <= right,再决定是否执行后面的方向。这样无论矩阵是正方形、扁的还是窄的,都能正确处理。
2.3 矩阵置零:用第一行第一列当“标记板”
矩阵置零的要求是:如果矩阵里有某个元素为 0,就把它所在的行和列全部置为 0,而且必须在原矩阵上完成,额外空间只能用常量级。
最暴力的方法当然是把所有 0 的位置记下来,再逐个去置零,空间复杂度 O(m×n) 或 O(m+n)。面试官肯定不满意。原地做法是借助矩阵的第一行和第一列充当“标记板”。
具体思路分三步。第一步,先检查第一行和第一列原本是否包含 0,用两个布尔变量row0和col0记录下来。为什么必须事先记录?因为后面我们要用第一行和第一列存标记,如果一开始没有保存原始状态,等我们改完之后就再也分不清“原本就有 0”和“作为标记写入的 0”了。
第二步,扫描除了第一行第一列以外的区域。如果发现matrix[i][j] == 0,就把这个信息“投影”到标记板上:matrix[i][0] = 0和matrix[0][j] = 0。
第三步,根据标记板,把对应的整行整列置零。这一步只需要遍历非首行首列的元素,判断matrix[i][0]或matrix[0][j]是否为 0 即可。
最后再处理第一行和第一列本身。如果一开始row0为真,就把第一行整个置零;col0同理。
class Solution: def setZeroes(self, matrix): m = len(matrix) n = len(matrix[0]) row0_has_zero = any(matrix[0][j] == 0 for j in range(n)) col0_has_zero = any(matrix[i][0] == 0 for i in range(m)) # 用第一行第一列记录标记 for i in range(1, m): for j in range(1, n): if matrix[i][j] == 0: matrix[i][0] = 0 matrix[0][j] = 0 # 根据标记置零(非首行首列) for i in range(1, m): for j in range(1, n): if matrix[i][0] == 0 or matrix[0][j] == 0: matrix[i][j] = 0 # 最后处理首行首列 if row0_has_zero: for j in range(n): matrix[0][j] = 0 if col0_has_zero: for i in range(m): matrix[i][0] = 0这道题稍不留神就会写成用“额外 m+n 数组”记录哪些行哪些列要置零。那种写法也能过,但空间是 O(m+n)。而上面的原地标记法空间是 O(1),也是面试官期望的答案。
一个小技巧:如果你觉得两个布尔变量也不想要,还可以用matrix[0][0]同时标记首行,再单独用一个变量标记首列,不过那样代码的可读性会下降。我一般倾向于清晰优先,面试时讲原理比炫技更重要。
2.4 搜索二维矩阵 II:从右上角出发,一次走完
搜索二维矩阵 II 的题干很经典:每行从左到右递增,每列从上到下递增,让你判断目标值是否存在。注意这里和另一道题“搜索二维矩阵”有区别,那道题更严格——每行的最后一个元素小于下一行的第一个元素,整个矩阵可以看成一维递增数组,直接二分就能做。而这题的矩阵只是行内和列内分别递增,行与行之间没有严格的整体顺序关系,不能直接当成一维数组。
面对这个矩阵,最容易想到的暴力方法当然是双重循环,O(mn)。但更好的解法可以做到 O(m+n),出发点选在右上角。
为什么选右上角?因为右上角的元素有一个很特殊的性质:它是当前行的最大值,同时是当前列的最小值。如果目标值比它小,那目标值一定不在这一列,因为这一列往下只会越来越大;如果目标值比它大,那目标值一定不在这一行,因为这一行往左只会越来越小。于是每一步都可以排除一整行或一整列。
class Solution: def searchMatrix(self, matrix, target): if not matrix or not matrix[0]: return False m = len(matrix) n = len(matrix[0]) i = 0 j = n - 1 while i < m and j >= 0: if matrix[i][j] == target: return True elif matrix[i][j] > target: j -= 1 else: i += 1 return False你也可以从左下角出发,逻辑刚好反过来:目标值比当前大就往右走,比当前小就往上走。只要能保证每一步都排除一行或一列,起点选哪个角都可以。但千万不要从左上角或右下角出发,因为这两个角的元素在相邻方向上都比它大(或都比他小),你无法判断该往哪个方向走。
这道题的代码可能是四道题里最短的,但它的思维含量不低。面试官经常在此基础上追问“如果每行升序且每列升序,但行数很大,怎么进一步优化”,这时候可以提一下二分法的变体——对每一行做二分查找,复杂度 O(m log n);更好的做法是利用行列有序性做“分治”,不过那是进阶内容了,先把右上角法讲清楚,已经足够应对大多数面试。
3. 实操现场:那些让我卡了半小时的边界问题
3.1 旋转矩阵的下标错误
我第一次写旋转图像的时候,转置循环写成了for j in range(0, n),结果运行完矩阵纹丝不动。当时差点怀疑是不是 Leetcode 判题出了问题,调试了一下才发现,元素被交换了两次又换回了原位。从那以后我记住一个原则:凡是交换型操作,内层循环只遍历一半,对角线上的元素不用处理。
还有一点容易踩的是翻转方向。顺时针旋转 90 度是“转置 + 水平翻转”,但如果题目换成逆时针,有些人会惯性写成“转置 + 水平翻转”,结果整个图形变成了镜像,怎么转都不对。我的建议是,动手写之前先在草稿纸上标一个 2×2 或 3×3 的小矩阵,手动推一次坐标变化,再决定先翻转还是先转置,这样比硬记公式可靠得多。
3.2 置零时的标记污染
矩阵置零的标记污染问题是重灾区。我第一次做这题时,想着“反正第一行第一列最后也要处理”,于是扫描的时候直接允许i=0和j=0参与标记写入,结果第一行里的 0 被转成了标记,后面遍历到第一列的元素时,误以为那一整列都需要置零,最后输出全乱了。
正确的顺序一定要是:先备份首行首列的状态,再标记,再置零非首行首列,最后恢复首行首列。每一步都不能提前。这里有个实操心得:你可以在心里把矩阵分成四个区域——首行、首列、其余区域、matrix[0][0]。处理顺序就是先处理其余区域,再处理三条边界,这样逻辑最清晰。
3.3 螺旋输出单行单列的陷阱
螺旋矩阵的边界问题我在前面代码里已经强调了,这里单独拿出来说一下实操现场。我第一次写的时候用的是“方向切换 + visited 标记”的写法,面对 3×3 矩阵没问题,但一跑到 3×1 或 1×3 这种极端形状,要么数组越界,要么重复输出。后来换成四边界收缩法,并且在每段方向结束后检查边界是否合法,所有极端形状就都顺了。
还有一个容易忽略的细节:在“右到左”的循环里,如果right已经小于left,range(right, left - 1, -1)会生出一个空序列,不会报错,但后面的bottom -= 1会继续执行,导致边界被多收缩一次,最终少输出元素。所以我建议在右到左和下到上两个方向前都做边界检查,宁可多写两个if,也不要省这几行代码。
3.4 常见问题排查速查表
| 题目 | 典型报错/现象 | 根本原因 | 解决方案 |
|---|---|---|---|
| 旋转图像 | 结果和原矩阵一样 | 内层循环遍历全量,元素交换两次 | 内层 j 从 i+1 开始 |
| 旋转图像 | 旋转后变成镜像 | 翻转顺序不对 | 用 2×2 矩阵手动推导 |
| 螺旋矩阵 | 单行/单列重复输出 | 边界收缩后未检查合法性 | 右到左前检查 top<=bottom |
| 螺旋矩阵 | 输出顺序局部乱序 | 忘记先收缩边界再走下一方向 | 每走完一个方向立即收缩 |
| 矩阵置零 | 首行或首列被误置零 | 未先保存首行首列状态 | 用布尔变量提前记录 |
| 矩阵置零 | 结果中多余的行/列变成 0 | 标记写入阶段覆盖了原始数据 | 扫描时从 i=1, j=1 开始 |
| 搜索二维矩阵 II | 超时或漏判 | 起点选在左上角无法二分决策 | 从右上角或左下角出发 |
| 搜索二维矩阵 II | 对空矩阵报错 | 没考虑 matrix 为空或首行为空 | 开头加 if 判断 |
这张表基本覆盖了我刷这四道题时遇到的所有常见问题,如果你看到某个现象,直接对照原因和方案修改就行。
4. 刷题策略与面试表达:怎么把这些题的价值榨干
4.1 先暴力后优化,矩阵题最稳的答题节奏
矩阵题的面试考察点往往不是“能不能写出最优解”,而是“能不能从暴力解逐步推导到优化解”。以旋转图像为例,你完全可以先大大方方地写一个额外矩阵版本,然后说“这个方案空间复杂度是 O(n^2),接下来我们看怎么原地处理”,面试官不会觉得你水平差,反而会认为你思路清晰。
矩阵题的优化方向通常就两个。一个是压缩空间,比如矩阵置零,从 O(mn) 的位置数组优化到 O(m+n) 的行列标记,再优化到 O(1) 原地标记。另一个是压缩时间,比如搜索二维矩阵,从 O(mn) 暴力搜索优化到 O(m+n) 右上角法。我建议你刷题时把这个“从坏到好”的过程完整走一遍,不要只看最优解,否则遇到变形题很容易被卡住。
4.2 面试时如何讲清楚矩阵题
面试中写代码之前,先用一两句话说出解题框架,这比直接埋头写代码要加分得多。比如螺旋矩阵,你可以先说“我打算维护四个边界,每走完一个方向收缩对应边界,循环直到边界交错”。这句话说完,面试官就明白了你的思路,即使代码里有一点小 bug,他也会更倾向于认为那是笔误,而不是理解问题。
我还发现一个面试技巧:把数学关系用言语表达出来。旋转图像可以说“旋转 90 度等价于转置加水平翻转”,这会让面试官觉得你不是在背题解,而是真的理解坐标变换。矩阵置零可以说“用首行首列当标记板”,这种具象化的表述也有助于对方快速跟上你的思路。
4.3 从Hot 100延伸出去的矩阵变形
四道核心题掌握之后,矩阵类的其他题目基本都是换皮。比如“对角线遍历”考的就是下标映射,你需要分解出对角线上 i+j 为常数的规律;“生命游戏”考的是原地标记,它用二进制位在一个整数里同时存当前状态和下一状态,思路和矩阵置零如出一辙;“旋转数组”其实就是一维版的旋转问题,也可以类比二维旋转的“分步操作”。
我个人建议的刷题路径是:先把 48 和 54 这两道搞清楚,因为它们覆盖了“坐标映射”和“边界收缩”两大主旋律;然后刷 73 和 240,把“原地标记”和“单调性搜索”补齐。这样四道题刷下来,你在矩阵题上的覆盖率已经相当可观了。
另外有一点值得多说一句:矩阵题对编程基础的要求其实不高,不需要什么高级数据结构,但非常考验耐心和细心。我见过很多人刷矩阵题时草草看题解,觉得自己懂了,结果过了两周回头再写,还是卡在边界条件上。如果你时间紧张,我建议至少把这四道题的手写过程走三遍:第一遍看着题解写,第二遍合上书自己写,第三遍隔一天再写,直到能一次通过为止。这个过程可能很枯燥,但效果比盲目刷几十道新题要好得多。
5. 一点实战体会
刷完 Hot 100 的矩阵模块,我最大的感觉是:这四道题是性价比最高的“防守型题目”。它们不像动态规划那样需要大量练习才能形成感觉,也不像图论那样需要背诵模板,只需要你沉下心把下标变化和边界条件捋顺,就能在短时间内形成肌肉记忆。面试前如果只有一天时间,我会毫不犹豫地把这部分过一遍,因为矩阵题出现频率高、代码量小、套路固定,属于那种“练了就能拿分”的题目。最后再分享一个小技巧:刷矩阵题时,随手在草稿纸上画一个 3×3 的格子,把所有坐标写下来,推导公式时别偷懒。这个习惯帮我避开了至少一半的边界坑,希望对你也有用。