
给你一个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倍n1500。原因不是算法更优而是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(i1,n):# 从i1开始只交换上三角matrix[i][j],matrix[j][i]matrix[j][i],matrix[i][j]forrowinmatrix:row.reverse()⚠️为什么j从i1开始因为转置是对称交换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条会把同一行反向再读一遍只剩一列时同理。所以iftopbottom:# 走底边前先确认还有行ifleftright:# 走左边前先确认还有列用3×3演示最直观第一轮走完外圈8个元素后只剩中心一个格子只剩一行且只剩一列此时两个if会同时把底边和左边都跳过只走顶行那一个格子。方向模拟 visited对照解法维护方向向量(dr, dc) (0, 1)每走一步先试探下一格若越界或已访问就把方向右转90°即(dr, dc) (dc, -dr)。思路直白但要额外开一个m×n的visited数组。实测分层法比方向模拟快5.22×n1200且空间O(1)。LC.73把首行首列征用为标记位朴素做法是用两个Set记录“哪些行该置零、哪些列该置零”空间O(mn)。进阶要求是O(1)。关键在于矩阵自己就有mn个“天然标记位”——第一行和第一列。我们可以用matrix[i][0]标记“第i行该置零”用matrix[0][j]标记“第j列该置零”。⚠️但这里有个陷阱第一行和第一列本身可能就有0。一旦我们用它们做标记就分不清“这个0是原始数据”还是“我们打的标记”。所以必须先用两个bool把首行首列自己的状态存下来row0_has_zeroany(matrix[0][j]0forjinrange(cols))col0_has_zeroany(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.543×3的两轮分层第 1 轮边界 top0,bottom2,left0,right2 顶行 → 1, 2, 3 top变为 1 右列 → 6, 9 right变为 1 底行 → 8, 7 top1 bottom2 ✓ 通过bottom变为 1 左列 → 4 left0 right1 ✓ 通过left变为 1 本轮收集[1,2,3,6,9,8,7,4] 第 2 轮边界 top1,bottom1,left1,right1 顶行 → 5 top变为2 右列跳过top2 bottom1 底行跳过top2 bottom1 左列跳过left1 right1但 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 JavaPython版classSolution:# ---------- LC.48 旋转图像原地 ----------defrotate(self,matrix:list[list[int]])-None:nlen(matrix)# 第一步转置只遍历上三角避免每对交换两次foriinrange(n):forjinrange(i1,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,bottom0,len(matrix)-1left,right0,len(matrix[0])-1res[]whiletopbottomandleftright:forjinrange(left,right1):# 顶行左 → 右res.append(matrix[top][j])top1foriinrange(top,bottom1):# 右列上 → 下res.append(matrix[i][right])right-1iftopbottom:# ⚠️ 只剩一行时跳过forjinrange(right,left-1,-1):res.append(matrix[bottom][j])bottom-1ifleftright:# ⚠️ 只剩一列时跳过foriinrange(bottom,top-1,-1):res.append(matrix[i][left])left1returnres# ---------- LC.73 矩阵置零首行首列做标记位 ----------defsetZeroes(self,matrix:list[list[int]])-None:rows,colslen(matrix),len(matrix[0])# 步骤0先把首行首列自身是否含0记下来必须在打标记之前row0_has_zeroany(matrix[0][j]0forjinrange(cols))col0_has_zeroany(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){intnmatrix.length;for(inti0;in;i){for(intji1;jn;j){// 只遍历上三角inttmpmatrix[i][j];matrix[i][j]matrix[j][i];matrix[j][i]tmp;}}for(inti0;in;i){intlo0,hin-1;while(lohi){inttmpmatrix[i][lo];matrix[i][lo]matrix[i][hi];matrix[i][hi]tmp;lo;hi--;}}}publicListIntegerspiralOrder(int[][]matrix){ListIntegerresnewArrayList();if(matrixnull||matrix.length0||matrix[0].length0)returnres;inttop0,bottommatrix.length-1;intleft0,rightmatrix[0].length-1;while(topbottomleftright){for(intjleft;jright;j)res.add(matrix[top][j]);top;for(intitop;ibottom;i)res.add(matrix[i][right]);right--;if(topbottom){for(intjright;jleft;j--)res.add(matrix[bottom][j]);bottom--;}if(leftright){for(intibottom;itop;i--)res.add(matrix[i][left]);left;}}returnres;}publicvoidsetZeroes(int[][]matrix){introwsmatrix.length,colsmatrix[0].length;booleanrow0false,col0false;for(intj0;jcols;j)if(matrix[0][j]0)row0true;for(inti0;irows;i)if(matrix[i][0]0)col0true;for(inti1;irows;i){for(intj1;jcols;j){if(matrix[i][j]0){matrix[i][0]0;matrix[0][j]0;}}}for(inti1;irows;i){for(intj1;jcols;j){if(matrix[i][0]0||matrix[0][j]0)matrix[i][j]0;}}if(row0)for(intj0;jcols;j)matrix[0][j]0;if(col0)for(inti0;irows;i)matrix[i][0]0;}}⚠️防坑提醒必看LC.48 的转置必须j从i1开始——从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 % nLC.498对角线遍历按ij分层奇偶层方向相反LC.200/130岛屿数量 / 被围绕的区域二维下标的遍历 面试追问模拟提前准备惊艳全场Q1LC.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)两句口诀只差“行”和“列”一个字这就是必须自己推导而不能死记的原因——背错一个字整个矩阵就转反了。Q2LC.48 用分层旋转怎么写for i in range(n // 2)第i层有n − 1 − 2i个“四角轮换”组每组做四次赋值。它能过但要处理n为奇数时中心层的情况代码量是转置版的四倍。面试时先给转置版简洁且能证明分层版作为补充。Q3LC.73 如果矩阵里存的是对象不能随便改首行首列呢那就只能用O(mn)的两个Set这是空间与“不可变性”的取舍。看清数据的可变性是选解法的前提之一。Q4LC.54 改成“由内向外螺旋”初始边界设为最内层每轮向外扩张而非收缩四条边的遍历方向也全部反过来。骨架不变改的是边界的增减方向。 实战小技巧刷题党必备口诀不要模拟要算下标顺时针 转置 行翻转逆时针 转置 列翻转。模板LC.48 转置只遍历上三角LC.54 四条边界 两个ifLC.73两个bool先存。防坑LC.48j从i1开始LC.54if不能省LC.73 bool必须在扫描前取。 实际应用场景不止是刷题图像处理旋转、镜像、缩放本质是下标映射 插值矩阵运算库reshape、transpose的底层就是下标的整除与取模游戏开发瓦片地图的分层遍历、视野的螺旋扩展打印/排版螺旋填充布局 今日思考题LC.48那句“转置 行翻转”的口诀你之前是背下来的还是自己推导过如果让你现场推导逆时针90°的复合映射你能在白板上一次写对吗