给你一个n×n矩阵,要求原地顺时针旋转90°。
大多数人的第一反应是“一圈一圈地转”:最外层四个角互换、四条边轮换,然后往里一层……
这个思路能做对,但边界条件极其容易写错(n/2层?每层循环几次?奇偶长度差在哪?)。
而另一些人知道一句口诀:“转置 + 每行翻转”。
两行代码,边界全无。
问题来了:凭什么?
今天第一篇要做的,就是把这句口诀背后的下标复合推导完整写出来——不是“转置加翻转就行”,而是给出一个你能自己验证的等式:
顺时针90°的目标映射: (i, j) → (j, n−1−i) 转置: (i, j) → (j, i) 行翻转: (i, j) → (i, n−1−j) 先转置再行翻转: (i, j) → (j, i) → (j, n−1−i) ✓ 完全吻合这类题的共同点就是:不要模拟过程,要算下标。一旦把变换写成下标的代数式,代码就成了这个式子的直译。
顺带给一个实测,它与很多人的直觉相反:原地旋转比新建矩阵快1.76倍(n=1500)。
原因不是算法更优,而是Python里构造新矩阵的对象分配开销远大于原地交换——这已经是本周第三次“理论直觉 vs 实测”打架了。
📦 题目速览(30秒读懂)
题目1:旋转图像(LC.48)
将n×n矩阵原地顺时针旋转90°,不得使用另一个矩阵。
示例:
[[1,2,3],[4,5,6],[7,8,9]]→[[7,4,1],[8,5,2],[9,6,3]]
题目2:螺旋矩阵(LC.54)
按顺时针螺旋顺序返回矩阵所有元素。
示例:
[[1,2,3],[4,5,6],[7,8,9]]→[1,2,3,6,9,8,7,4,5]
题目3:矩阵置零(LC.73)
若某元素为0,则其所在行和列全部置零。原地算法,进阶要求O(1)额外空间。
示例:
[[1,1,1],[1,0,1],[1,1,1]]→[[1,0,1],[0,0,0],[1,0,1]]
🧠 核心思路
LC.48:一次旋转 = 两次对称
先把三个变换写成下标映射(行号在前、列号在后):
| 变换 | 映射 | 直觉 |
|---|---|---|
| 顺时针90°(目标) | (i, j) → (j, n−1−i) | 原来的第i行变成第n−1−i列 |
| 转置(沿主对角线翻转) | (i, j) → (j, i) | 行列互换 |
| 行翻转(每行左右颠倒) | (i, j) → (i, n−1−j) | 列号镜像 |
现在把它们复合:对(i, j)先做转置得到(j, i),再对结果应用行翻转规则——此时“行号”是j、“列号”是i,所以行翻转后变成(j, n−1−i)。
(i, j) ──转置──► (j, i) ──行翻转──► (j, n−1−i) = 目标映射 ✓这就证完了。而且它是逐格独立的——每个格子只关心自己,不需要考虑层、不需要考虑奇偶,所以代码只有两层循环:
foriinrange(n):forjinrange(i+1,n):# 从i+1开始,只交换上三角matrix[i][j],matrix[j][i]=matrix[j][i],matrix[i][j]forrowinmatrix:row.reverse()⚠️为什么j从i+1开始?
因为转置是对称交换:matrix[i][j]与matrix[j][i]互换。若j从0开始,每对会被交换两次,等于没换(对角线上的格子i == j则原地自交换,无害但多余)。只遍历上三角,正好每对处理一次。
LC.54:分层剥洋葱 vs 方向模拟
分层法(推荐主线):维护top / bottom / left / right四条边界,每轮依次走四条边,走完把边界收缩一圈。
第1条:顶行 left → right (走完top += 1) 第2条:右列 top → bottom (走完right -= 1) 第3条:底行 right → left (走完bottom -= 1) ← 需要if 第4条:左列 bottom → top (走完left += 1) ← 需要if⚠️那两个if是整道题最容易漏的地方。
当矩阵收缩到只剩一行时,第1条走完top就大于bottom了,再走第3条会把同一行反向再读一遍;只剩一列时同理。
所以:
iftop<=bottom:# 走底边前先确认还有行ifleft<=right:# 走左边前先确认还有列用3×3演示最直观:第一轮走完外圈8个元素后,只剩中心一个格子(只剩一行且只剩一列),此时两个if会同时把底边和左边都跳过,只走顶行那一个格子。
方向模拟 + visited(对照解法):维护方向向量(dr, dc) = (0, 1),每走一步先试探下一格,若越界或已访问就把方向右转90°,即(dr, dc) = (dc, -dr)。
思路直白,但要额外开一个m×n的visited数组。
实测:分层法比方向模拟快5.22×(n=1200),且空间O(1)。
LC.73:把首行首列征用为标记位
朴素做法是用两个Set记录“哪些行该置零、哪些列该置零”,空间O(m+n)。进阶要求是O(1)。
关键在于:矩阵自己就有m+n个“天然标记位”——第一行和第一列。
我们可以用matrix[i][0]标记“第i行该置零”,用matrix[0][j]标记“第j列该置零”。
⚠️但这里有个陷阱:第一行和第一列本身可能就有0。
一旦我们用它们做标记,就分不清“这个0是原始数据”还是“我们打的标记”。所以必须先用两个bool把首行首列自己的状态存下来:
row0_has_zero=any(matrix[0][j]==0forjinrange(cols))col0_has_zero=any(matrix[i][0]==0foriinrange(rows))然后分三步:
- 扫描:对
i >= 1, j >= 1的部分,遇到0就在matrix[i][0]和matrix[0][j]上打标记 - 置零:再扫一次
i >= 1, j >= 1,若行标记或列标记任一为0就置零 - 收尾:用那两个bool处理首行和首列本身
⚠️注意顺序:两个bool必须在第一遍扫描之前就取好。
若等到后面再判断,首行首列已被标记污染,就读不出原始状态了。
用输入本身当额外空间。一维时用符号位(取反标记),二维时用首行首列。
🖼️ 图解算法(手把手走一遍)
LC.48:[[1,2,3],[4,5,6],[7,8,9]]的两步
原始 转置后 行翻转后(= 答案) [1, 2, 3] [1, 4, 7] [7, 4, 1] [4, 5, 6] ──► [2, 5, 8] ──► [8, 5, 2] [7, 8, 9] [3, 6, 9] [9, 6, 3]逐格验证映射(i, j) → (j, n−1−i):原矩阵的(0,0) = 1应该移到(0, 2);答案中matrix[0][2] = 1✓。原(2,0) = 7应移到(0, 0);答案中matrix[0][0] = 7✓。
LC.54:3×3的两轮分层
第 1 轮(边界 top=0,bottom=2,left=0,right=2): 顶行 → 1, 2, 3 top变为 1 右列 → 6, 9 right变为 1 底行 → 8, 7 (top=1 <= bottom=2 ✓ 通过)bottom变为 1 左列 → 4 (left=0 <= right=1 ✓ 通过)left变为 1 本轮收集:[1,2,3,6,9,8,7,4] 第 2 轮(边界 top=1,bottom=1,left=1,right=1): 顶行 → 5 top变为2 右列(跳过:top=2 > bottom=1) 底行(跳过:top=2 > bottom=1) 左列(跳过:left=1 <= right=1,但 top > bottom已使其无元素) 本轮收集:[5]最终[1,2,3,6,9,8,7,4,5]✓第二轮正是那两个if发挥作用的地方——若不跳,会把中心的5反复输出。
LC.73:[[0,1,2,0],[3,4,5,2],[1,3,1,5]]的三步
原始矩阵 [0, 1, 2, 0] [3, 4, 5, 2] [1, 3, 1, 5] 步骤0:记录首行首列自身状态 row0_has_zero = True(首行有0) col0_has_zero = False(首列是0,3,1,无0) 步骤1:扫描i≥1,j≥1打标记(本例无内部0,标记不变) 步骤2:按标记置零i≥1,j≥1(本例无变化) 步骤3:处理首行首列 因row0_has_zero = True → 首行全置0 因col0_has_zero = False → 首列不动 结果 [0, 0, 0, 0] [0, 4, 5, 0] [0, 3, 1, 0]这里最容易绕晕:matrix[i][0]既可能是“第i行的标记”,又是“第0列的一个普通元素”。
答案里matrix[1][0]之所以是0,是因为列0被置零了(首行第0列原本就是0),而不是因为第1行需要置零。两个bool的用途,就是让这两种语义不打架。
💻 代码实现(Python + Java)
Python版
classSolution:# ---------- LC.48 旋转图像(原地) ----------defrotate(self,matrix:list[list[int]])->None:n=len(matrix)# 第一步:转置(只遍历上三角,避免每对交换两次)foriinrange(n):forjinrange(i+1,n):matrix[i][j],matrix[j][i]=matrix[j][i],matrix[i][j]# 第二步:每一行左右翻转# 复合结果: (i,j) → (j,i) → (j, n-1-i) = 顺时针90°forrowinmatrix:row.reverse()# ---------- LC.54 螺旋矩阵(分层法) ----------defspiralOrder(self,matrix:list[list[int]])->list[int]:ifnotmatrixornotmatrix[0]:return[]top,bottom=0,len(matrix)-1left,right=0,len(matrix[0])-1res=[]whiletop<=bottomandleft<=right:forjinrange(left,right+1):# 顶行:左 → 右res.append(matrix[top][j])top+=1foriinrange(top,bottom+1):# 右列:上 → 下res.append(matrix[i][right])right-=1iftop<=bottom:# ⚠️ 只剩一行时跳过forjinrange(right,left-1,-1):res.append(matrix[bottom][j])bottom-=1ifleft<=right:# ⚠️ 只剩一列时跳过foriinrange(bottom,top-1,-1):res.append(matrix[i][left])left+=1returnres# ---------- LC.73 矩阵置零(首行首列做标记位) ----------defsetZeroes(self,matrix:list[list[int]])->None:rows,cols=len(matrix),len(matrix[0])# 步骤0:先把首行首列自身是否含0记下来(必须在打标记之前)row0_has_zero=any(matrix[0][j]==0forjinrange(cols))col0_has_zero=any(matrix[i][0]==0foriinrange(rows))# 步骤1:用首行首列记录"内部"哪些行列该置零foriinrange(1,rows):forjinrange(1,cols):ifmatrix[i][j]==0:matrix[i][0]=0matrix[0][j]=0# 步骤2:按标记置零(同样只处理内部)foriinrange(1,rows):forjinrange(1,cols):ifmatrix[i][0]==0ormatrix[0][j]==0:matrix[i][j]=0# 步骤3:用步骤0的bool处理首行首列ifrow0_has_zero:forjinrange(cols):matrix[0][j]=0ifcol0_has_zero:foriinrange(rows):matrix[i][0]=0Java 版
classSolution{publicvoidrotate(int[][]matrix){intn=matrix.length;for(inti=0;i<n;i++){for(intj=i+1;j<n;j++){// 只遍历上三角inttmp=matrix[i][j];matrix[i][j]=matrix[j][i];matrix[j][i]=tmp;}}for(inti=0;i<n;i++){intlo=0,hi=n-1;while(lo<hi){inttmp=matrix[i][lo];matrix[i][lo]=matrix[i][hi];matrix[i][hi]=tmp;lo++;hi--;}}}publicList<Integer>spiralOrder(int[][]matrix){List<Integer>res=newArrayList<>();if(matrix==null||matrix.length==0||matrix[0].length==0)returnres;inttop=0,bottom=matrix.length-1;intleft=0,right=matrix[0].length-1;while(top<=bottom&&left<=right){for(intj=left;j<=right;j++)res.add(matrix[top][j]);top++;for(inti=top;i<=bottom;i++)res.add(matrix[i][right]);right--;if(top<=bottom){for(intj=right;j>=left;j--)res.add(matrix[bottom][j]);bottom--;}if(left<=right){for(inti=bottom;i>=top;i--)res.add(matrix[i][left]);left++;}}returnres;}publicvoidsetZeroes(int[][]matrix){introws=matrix.length,cols=matrix[0].length;booleanrow0=false,col0=false;for(intj=0;j<cols;j++)if(matrix[0][j]==0)row0=true;for(inti=0;i<rows;i++)if(matrix[i][0]==0)col0=true;for(inti=1;i<rows;i++){for(intj=1;j<cols;j++){if(matrix[i][j]==0){matrix[i][0]=0;matrix[0][j]=0;}}}for(inti=1;i<rows;i++){for(intj=1;j<cols;j++){if(matrix[i][0]==0||matrix[0][j]==0)matrix[i][j]=0;}}if(row0)for(intj=0;j<cols;j++)matrix[0][j]=0;if(col0)for(inti=0;i<rows;i++)matrix[i][0]=0;}}⚠️防坑提醒(必看):
- LC.48 的转置必须
j从i+1开始——从0开始每对交换两次,等于没转置。- LC.54 的两个
if一个都不能省——只剩一行/一列时漏掉会把中间元素重复读。- LC.73 的两个bool 必须在打标记之前取——等到步骤3再判断,首行首列已被污染。
- LC.73 里
matrix[i][0]有双重身份——既是行标记又是第0列元素,搞混是本题最常见错误。
⏱️ 复杂度分析(面试必问)
| 题目 | 时间 | 空间 | 说明 |
|---|---|---|---|
| LC.48 | O(n²) | O(1) | 每格访问常数次,最优(必须读完全部n²个元素) |
| LC.54 | O(mn) | O(1)(不计输出) | 每个元素访问一次 |
| LC.73 | O(mn) | O(1) | 两遍扫描 + 两个 bool |
本机实测
| 对比项 | 规模 | 结果 |
|---|---|---|
| LC.48 原地(转置+翻转) vs 新建矩阵 | n = 1500 | 70.71msvs 124.17ms →原地快1.76× |
| LC.54 分层法 vs 方向模拟 + visited | n = 1200 | 49.91msvs 260.31ms → 分层快5.22× |
LC.48 的结论值得点名:很多人默认“原地旋转必然更慢(要两两交换)”,实测恰恰相反。原因不在算法,而在语言实现——Python里用列表推导构造一个1500×1500的新矩阵,要分配225万个对象引用,这个开销远大于原地的225万次交换。
复杂度给的是增长阶,而“原地 vs 借空间”的快慢要看常数和语言实现。
🚀 举一反三:6道高频变体题
| 题号 | 题目 | 与本题的关系 |
|---|---|---|
| LC.867 | 转置矩阵 | LC.48 的第一步(非方阵版,必须新建) |
| LC.59 | 螺旋矩阵II | LC.54 的反向:按螺旋顺序填数 |
| LC.885 | 螺旋矩阵III | 从内部起点向外螺旋,边界处理更复杂 |
| LC.566 | 重塑矩阵 | 二维 ↔ 一维下标映射(r = idx // n, c = idx % n) |
| LC.498 | 对角线遍历 | 按i+j分层,奇偶层方向相反 |
| LC.200/130 | 岛屿数量 / 被围绕的区域 | 二维下标的遍历 |
💬 面试追问模拟(提前准备,惊艳全场)
Q1:LC.48 逆时针90°怎么做?
逆时针的目标映射是
(i, j) → (n−1−j, i)(验证:左上角(0,0)该去左下角(n−1, 0)✓)。它同样是两次对称,但换了一个字:
| 方向 | 分解 | 复合 |
|---|---|---|
| 顺时针 | 转置 +行翻转 | (i,j) → (j,i) → (j, n−1−i) |
| 逆时针 | 转置 +列翻转 | (i,j) → (j,i) → (n−1−j, i) |
两句口诀只差“行”和“列”一个字,这就是必须自己推导而不能死记的原因——背错一个字,整个矩阵就转反了。
Q2:LC.48 用分层旋转怎么写?
for i in range(n // 2),第i层有n − 1 − 2i个“四角轮换”组,每组做四次赋值。
它能过,但要处理n为奇数时中心层的情况,代码量是转置版的四倍。
面试时先给转置版(简洁且能证明),分层版作为补充。
Q3:LC.73 如果矩阵里存的是对象,不能随便改首行首列呢?
那就只能用O(m+n)的两个
Set,这是空间与“不可变性”的取舍。
看清数据的可变性,是选解法的前提之一。
Q4:LC.54 改成“由内向外螺旋”?
初始边界设为最内层,每轮向外扩张而非收缩,四条边的遍历方向也全部反过来。
骨架不变,改的是边界的增减方向。
🧩 实战小技巧(刷题党必备)
- 口诀:不要模拟,要算下标;顺时针 = 转置 + 行翻转,逆时针 = 转置 + 列翻转。
- 模板:LC.48 转置只遍历上三角;LC.54 四条边界 + 两个
if;LC.73两个bool先存。 - 防坑:LC.48
j从i+1开始;LC.54if不能省;LC.73 bool必须在扫描前取。
📈 实际应用场景(不止是刷题)
- 图像处理:旋转、镜像、缩放(本质是下标映射 + 插值)
- 矩阵运算库:
reshape、transpose的底层就是下标的整除与取模 - 游戏开发:瓦片地图的分层遍历、视野的螺旋扩展
- 打印/排版:螺旋填充布局
🎁 今日思考题
LC.48那句“转置 + 行翻转”的口诀,你之前是背下来的还是自己推导过?
如果让你现场推导逆时针90°的复合映射,你能在白板上一次写对吗?