☰
高频必考!旋转、螺旋、置零:三道矩阵题,全是“下标的代数运算”
2026/10/28 2:05:16 网站建设 项目流程

给你一个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))

然后分三步:

  1. 扫描:对i >= 1, j >= 1的部分,遇到0就在matrix[i][0]和matrix[0][j]上打标记
  2. 置零:再扫一次i >= 1, j >= 1,若行标记或列标记任一为0就置零
  3. 收尾:用那两个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]=0

Java 版

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.48O(n²)O(1)每格访问常数次,最优(必须读完全部n²个元素)
LC.54O(mn)O(1)(不计输出)每个元素访问一次
LC.73O(mn)O(1)两遍扫描 + 两个 bool

本机实测

对比项规模结果
LC.48 原地(转置+翻转) vs 新建矩阵n = 150070.71msvs 124.17ms →原地快1.76×
LC.54 分层法 vs 方向模拟 + visitedn = 120049.91msvs 260.31ms → 分层快5.22×

LC.48 的结论值得点名:很多人默认“原地旋转必然更慢(要两两交换)”,实测恰恰相反。原因不在算法,而在语言实现——Python里用列表推导构造一个1500×1500的新矩阵,要分配225万个对象引用,这个开销远大于原地的225万次交换。

复杂度给的是增长阶,而“原地 vs 借空间”的快慢要看常数和语言实现。


🚀 举一反三:6道高频变体题

题号题目与本题的关系
LC.867转置矩阵LC.48 的第一步(非方阵版,必须新建)
LC.59螺旋矩阵IILC.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.48j从i+1开始;LC.54if不能省;LC.73 bool必须在扫描前取。

📈 实际应用场景(不止是刷题)

  • 图像处理:旋转、镜像、缩放(本质是下标映射 + 插值)
  • 矩阵运算库:reshape、transpose的底层就是下标的整除与取模
  • 游戏开发:瓦片地图的分层遍历、视野的螺旋扩展
  • 打印/排版:螺旋填充布局

🎁 今日思考题

LC.48那句“转置 + 行翻转”的口诀,你之前是背下来的还是自己推导过?
如果让你现场推导逆时针90°的复合映射,你能在白板上一次写对吗?

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

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

立即咨询