力扣hot100第20题,旋转图像,也就是LeetCode 48题,是刷题清单里绕不开的经典矩阵题。题目要求把一个n×n的二维矩阵原地顺时针旋转90度,不能另开一个数组存结果。很多第一次刷这题的人都有同感:看着简单,真写起来却容易出错,尤其是对“左上角的值被覆盖后去哪了”这种细节没有把握。我当初在力扣热题100里刷到这题时,第一反应是“重新开个矩阵往里填不就行了吗”,但题目明确要求原地修改,这直接逼着你把旋转从“读写”问题变成“置换”问题。这篇文章把两种主流做法、坐标公式、常见坑都串一遍,适合正在刷hot100、准备机试和面试的朋友。
1. 读懂题意:旋转90度到底做了什么
1.1 题目要求与示例拆解
题目输入是一个n×n的二维矩阵,输出是顺时针旋转90度后的同一个矩阵。以三阶矩阵为例,输入:
1 2 3 4 5 6 7 8 9输出:
7 4 1 8 5 2 9 6 3逐个数地看,你会发现一个非常朴素的对应关系:原来的第一行,变成了新矩阵的最后一列,并且顺序是从下往上读。即原矩阵的1跑到了新矩阵的右上角,2跑到了右边中间,3跑到了右下角。再往深一层想,其实每个元素的位置变化都满足一个统一的坐标规则:
原矩阵中
A[i][j]这个元素,旋转后应该出现在新矩阵的B[j][n-1-i]位置。
这个公式是整个题目的核心。不管用哪种解法,本质上都是在实现这个坐标映射。理解了这个,后面看转置加翻转、逐层交换才不会被各种下标绕晕。
1.2 从坐标变换理解旋转的本质
为什么“转置加水平翻转”能等价于顺时针旋转90度?这是这道题最值得搞懂的一点。
矩阵转置的意思是把A[i][j]和A[j][i]互换,也就是沿主对角线镜像。转置后,原位置(i,j)的元素会跑到T[j][i]。水平翻转的意思是把每一行左右颠倒,元素(i,j)会跑到(i, n-1-j)。
现在把两个操作合起来:先转置,再水平翻转。原位置(i,j)的元素,转置后先到(j,i),再水平翻转就到(j, n-1-i)。你看,这和开头说的旋转目标位置(j, n-1-i)完全一样。所以顺序是固定的:先转置,再每行左右翻转,得到的就是顺时针旋转90度的结果。
如果把顺序反过来,先水平翻转再转置,得到的位置是(j, i)水平翻转后变成(j, n-1-i)?不,计算一下:先水平翻转,原元素到(i, n-1-j),再转置到(n-1-j, i)。这个结果对应的是逆时针旋转90度。所以“先翻转还是先转置”是有讲究的,面试里经常会拿这个点来追问。
1.3 两种思路怎么选
这道题的标准解法就两种:一种是转置加水平翻转,一种是逐层四元交换。两者时间复杂度都是O(n^2),空间复杂度都是O(1),没有谁优谁劣,但使用场景有差异。
转置加翻转的优点是逻辑简单、代码短、下标不容易越界,面试时边说边写很顺畅。逐层四元交换的好处是更“纯原地”,没有中间视角,而且对缓存局部性更友好,如果面试官追问细节,能讲出这一层会加分。我的建议是:第一次刷先把转置加翻转吃透,确保五分钟内能无脑写出来;等复习第二轮时,再把逐层交换练熟,作为进阶方案。
2. 方案一:转置加水平翻转
2.1 操作步骤与原理说明
这个方案就两步,第一步得到转置矩阵,第二步把每一行左右翻转。
第一步要特别注意遍历范围。常规的转置操作,很多人会写成双层循环从0到n-1全遍历,结果发现矩阵根本没变化。原因很简单:交换matrix[i][j]和matrix[j][i]时,如果你遍历了全矩阵,那么当外层i=1、j=0时,你已经把刚才i=0、j=1时交换过的一对又换回去了。正确做法是只遍历上三角,也就是j从i+1开始,这样每个非对角线元素只交换一次。
第二步的水平翻转,每行内部左右对称交换,循环只需要走到n/2,不能走到n,否则同样会换两次。两步都注意到“只处理一半”,代码就不会出问题。用生活化的类比来理解,转置相当于把一块方板沿着左上到右下的对角线折了一下,水平翻转相当于再把这块板左右对折,两次折叠叠加起来,正好是把整块板顺时针转了90度。
2.2 代码实现与逐行解析
C++实现很简洁:
void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); // 第一步:沿主对角线转置 for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { swap(matrix[i][j], matrix[j][i]); } } // 第二步:每行水平翻转 for (int i = 0; i < n; i++) { for (int j = 0; j < n / 2; j++) { swap(matrix[i][j], matrix[i][n - 1 - j]); } } }Python版本同样直接:
def rotate(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()Python的reverse()天然就是水平翻转,所以第二步代码更短。但要注意,如果你打算在面试里写Python,reverse()这个细节要知道它做了什么,别让面试官觉得你在背API。
2.3 为什么这个方案不容易写错
转置加翻转的方案容错率高,因为每一步都是“基础操作”。
第一,转置的边界条件非常固定,j = i + 1,你不需要去考虑当前处理的是哪一层,只需要判断是否在对角线以上。第二,水平翻转的j < n/2也固定,不管n是奇数还是偶数,整数除法都能正确处理中间的对称轴。相比之下,逐层交换方案要求你同时跟踪四个坐标,一个符号写错就全盘错,调试起来要费更多时间。
我刷hot100时,身边不少朋友选择背逐层交换的代码,结果一周后再刷又不会了。但转置加翻转因为步骤少、每一步都有明确的几何含义,哪怕三个月后忘了,只要记得“先对角折,再左右折”,就能很快重新推出来。这也是我把它作为默认解法的原因。
3. 方案二:逐层原地四元交换
3.1 分层拆解:从外圈到内圈
第二种思路是直接模拟旋转过程。把矩阵想象成一圈一圈的洋葱结构,最外层是一圈,次外层是一圈,直到中心。旋转矩阵时,每一圈内部自己转,圈与圈之间互不影响。
以四阶矩阵为例,最外圈包含四个角和四条边,除了四个角外,每条边中间还有两个元素。这一整圈本身有12个元素,但旋转时它们不是“各自搬家”,而是每条边对应位置的一组四个元素同时轮转。处理完这一圈,再处理内部那一圈,内部那一圈就是一个2×2的小方阵,四个元素循环转一遍。
流程可以写成两层循环:外层循环i控制当前是第几圈,从0到n/2-1;内层循环j控制当前圈内每条边上的第几个位置,从i到n-2-i。这里最难理解的就是内层循环上限为什么是n-2-i而不是n-1-i,后面专门解释。
3.2 四元交换的实现代码
核心操作是四元素循环交换。以原矩阵位置(i, j)为例,顺时针旋转90度之后,这个位置的新值应该来自左下角,也就是(n-1-j, i)。这个(n-1-j, i)位置的新值又来自右下角(n-1-i, n-1-j),右下角的新值来自右上角(j, n-1-i),右上角的新值再回到左上角(i, j)。四者形成一个闭环。
C++实现如下:
void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); for (int i = 0; i < n / 2; i++) { for (int j = i; j < n - 1 - i; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[n - 1 - j][i]; matrix[n - 1 - j][i] = matrix[n - 1 - i][n - 1 - j]; matrix[n - 1 - i][n - 1 - j] = matrix[j][n - 1 - i]; matrix[j][n - 1 - i] = temp; } } }记忆方式:四句赋值是从“左下角”开始喂给“左上角”,按逆时针方向搬运。或者干脆只记坐标变化链,(i,j) ← (n-1-j,i) ← (n-1-i,n-1-j) ← (j,n-1-i) ← (i,j),然后按箭头方向把temp串起来。
3.3 边界条件的推导
内层循环到底应该有多少次,是很多人卡住的点。看第i圈,这一圈的边长是n - 2*i,但每条边上需要和另外三条边对应位置交换的元素个数是边长-1,也就是n - 2*i - 1个。这是因为每条边的最后一个位置已经由下一条边的起始位置承接了,如果把它也算进去,最后会多交换一次,整个圈又回到原样。
举个例子,三阶矩阵最外圈边长为3,每条边需要处理的中间元素数是2,也就是j = 0和j = 1,对应的循环条件是j < n - 1 - i,即j < 2。四阶矩阵最外圈边长4,内层循环处理j = 0, 1, 2,共3次;第二圈是2×2方阵,边长2,处理1次,即j = 1。不管n是奇数还是偶数,这套公式都成立。n为奇数时,最中间的一个格子单独留在中心,不需要交换。
如果内层误写成j < n - 1 - i,也就是处理了n - 2*i次,看起来代码好像也没越界,但角落元素会被连续换两次,最终恢复原状,整个矩阵除了可能某些位置变化,基本等于没转,这是新手最常见的隐蔽错误。
4. 复杂度、扩展变种与面试问答
4.1 时间与空间复杂度对比
先说结论:两种方案都是O(n^2)时间和O(1)额外空间,都是遍历矩阵中每个元素常数次。
理论上,任何方案都不可能低于O(n^2)时间,因为n×n矩阵有n^2个元素,每个元素至少得访问一次才知道它该去哪。空间上,要求原地修改,所以额外空间不能随n增长。转置加翻转虽然过程直观,但它不是“一步到位”的旋转,而是先做一次变换再做一次变换,每次变换都访问全矩阵,总访问次数是2×n^2,常数系数是2。逐层四元交换每个元素也只访问常数次,两者实际运行时间差不多。
两者真正的区别在代码风格上。我测试过,在n比较大的情况下,逐层交换因为能更好地按层访问连续内存,缓存命中率略好一点,但差距在刷题层面完全可以忽略。选择哪个,取决于你在面试中想展示哪一面:想稳,选转置;想炫,选逐层。
4.2 逆时针旋转与180度旋转
面试题很少直接考顺时针旋转,更多是换一个角度问,比如逆时针旋转90度,或者连续旋转多次。
逆时针旋转90度也有对称的解法:先沿副对角线翻转,再水平翻转,效果等同于逆时针90度。更简单的方式是复用顺时针代码:把原地顺时针旋转函数连续调用三次,就是逆时针一次。这个技巧在比赛中很实用,不用额外记新的下标公式。
旋转180度更简单,每行先水平翻转,再整列上下翻转,或者反过来,结果都一样。本质上,180度旋转就是把每个元素(i,j)搬到(n-1-i,n-1-j),这是关于矩阵中心点的中心对称变换。用代码表示就是上下翻转加左右翻转两个循环。
// 上下翻转 for (int i = 0; i < n / 2; i++) { for (int j = 0; j < n; j++) { swap(matrix[i][j], matrix[n - 1 - i][j]); } } // 左右翻转 for (int i = 0; i < n; i++) { for (int j = 0; j < n / 2; j++) { swap(matrix[i][j], matrix[i][n - 1 - j]); } }建议把“旋转90度=转置+水平翻转”“旋转180度=水平+垂直翻转”这两组关系刻在脑子里,面试时遇到矩阵变换题,都可以套用。
4.3 面试追问的应答思路
面试官在看完这道题之后,大概率会追几个问题。第一个常见追问是:“如果允许额外开一个矩阵,你会怎么写?”这时候你要能迅速给出新矩阵版本,核心代码就是开头那个坐标公式:新矩阵ans[j][n-1-i] = matrix[i][j]。能写出这版,说明你确实理解坐标映射,而不是只背了原地解的代码。
第二个追问是:“两种原地方案,你更喜欢哪种?为什么?”这时候别只说“都行”。比较好的回答思路是:转置加翻转更容易推导,适合现场演示正确性;逐层交换更接近旋转的本质,而且天然适合进一步优化的场景。如果面试官做的是图形学方向,还可以补一句矩阵变换可以拆成多个基本变换的复合,旋转矩阵等于转置矩阵和翻转矩阵的乘积。
第三个追问可能涉及泛化:“如果矩阵不是方阵,怎么旋转?”这里要坦白,非方阵的原地旋转通常不现实,因为形状都变了,一般需要新矩阵存储。LeetCode的题明确限定n×n,所以这个追问更多是考察你是否意识到“方阵”这个前提的价值。
5. 实战踩坑与刷题心得
5.1 常见的三种写错方式
刷这题最容易踩的坑,我总结成三句话:转置遍历了全部元素,翻转遍历了整行所有位置,逐层交换多算了一个位置。
第一种错误写出来之后非常迷惑:代码运行,矩阵看起来完全没变。原因是转置时从j=0遍历到j<n,每个非对角线元素都被交换了两次,最终回到原位。解决方法是记住转置只处理上三角,j从i+1开始。第二种错误是水平翻转的循环写成j < n,同样导致换两次。虽然看起来是“翻转了”,实际矩阵还是原样。第三种错误则相反,矩阵变化了但结果不对,比如左上角的值去了不该去的地方,这是逐层方案中内层循环边界多算了一位。
还有一种隐蔽问题,C++里matrix.size()返回的是size_t无符号类型,如果直接用n-1再和负数比较就容易出问题。写循环时先把n转成int,能省去很多不必要的麻烦。
5.2 快速自测方法
写完代码,建议先用2×2、3×3、4×4三个用例自测。2×2能验证基础四角交换,3×3能验证奇数阶的中心不动,4×4能验证多层嵌套。
更实用的验证方式是利用旋转的性质:一个矩阵连续顺时针旋转四次,一定会回到原矩阵。你可以先手写一个小的测试矩阵,然后调用rotate四次,断言结果等于原矩阵。这个方法比对照样例输出更快,尤其适合提交前快速排查。另一个性质是:旋转两次等于180度旋转,也就是每个元素(i,j)变成(n-1-i,n-1-j)。如果旋转两次后逐项比对这个关系,也能快速判断代码对不对。
我在本地刷题时习惯写一个随机矩阵生成器,随机生成5×5矩阵,旋转一次之后再用上面的性质验证,反复跑几十组,能在几分钟内确认代码在所有边界条件下都稳定。
5.3 我的刷题习惯与建议
力扣hot100里的题目很多都是“高频面试原题”,旋转图像就是典型的代表。我自己刷这道题的过程比较曲折:第一遍用新矩阵版本写对了,觉得题很简单;结果三天后手写原地版,卡了二十分钟没写出来,原因就是没理解坐标映射,光在背代码。后来把坐标公式写在纸上,推导了一遍转置加翻转的等价性,才算真正掌握。
如果你也正在刷这道题,我的建议很简单:先确保能写出新矩阵版本,然后原地化,最后再理解逐层交换。别跳过推导步骤直接背代码,面试时一旦被追问“为什么”,背答案很容易露馅。矩阵旋转这类题,一旦理解了坐标变换,后面再遇到生命游戏、螺旋矩阵这些类似题目,都会觉得轻松很多。
最后分享一个我自己一直在用的小技巧:把这道题的坐标公式写在代码注释里。下次回看代码,不需要重新推导,一眼就能看懂当时的设计思路。刷题不是为了刷数量,一道经典题吃透,比囫囵吞枣过十道更有价值。