最近在刷 LeetCode Hot100,刷到第 16 题,正好是 48. 旋转图像。说实话,这题乍一看是个“中等难度”,但很多第一次做的人(包括我)都会在方向上绕几分钟:到底顺时针是往左还是往右?坐标要怎么换?更别提“原地旋转”这个限制,一上来就断了“开个新数组”的念头。这题其实非常适合用来练二维数组的基础功,也常被面试官当作热场题——它不考高深的算法,考的是你对索引变化、边界条件、以及原地修改的理解。这篇文章就把我从这道题里挖出来的东西完整写一遍:从坐标公式推导、两种主流解法的代码和复杂度,到我自己踩过的坑、以及怎么快速验证旋转结果,都放进来。不管你是刚开始刷题的小白,还是想偷懒直接看结论的选手,照着文章走一遍,这道题应该就彻底拿下了。
1. 题目理解与思路拆解
1.1 先搞清楚到底往哪边转
题目要求很直白:给定一个 n×n 的二维矩阵 matrix,把它顺时针旋转 90 度,并且必须原地修改。比如:
输入: matrix = [[1,2,3], [4,5,6], [7,8,9]] 输出: matrix = [[7,4,1], [8,5,2], [9,6,3]]从行/列的角度去看,规律是:原来的第一行变成了最后一列,原来的第二行变成了中间一列,原来的第三行变成了第一列。更严谨地说,对于矩阵中的任意一个位置(i, j),顺时针旋转 90 度之后它会移动到(j, n-1-i)。这个公式是整个题目的灵魂。
为什么是这个公式?把它拆开理解:所谓顺时针旋转,可以看成先以主对角线为轴做转置,再水平翻转(左右翻转)。转置之后(i, j)变成(j, i),水平翻转之后列坐标变成n-1-i,于是合起来就是(j, n-1-i)。也可以用另一种视角:把矩阵当成一个正方形的“纸片”,旋转后左上角的元素跑到了右上角,右上角的元素跑到了右下角,右下角的跑到了左下角,左下角的跑到了左上角,四个位置不断轮换。只要先把这个坐标映射关系写明白,后面不管用哪种方法实现,心里都有底。
有个很容易绕晕的点:很多人会把顺时针和逆时针搞混。逆时针 90 度对应的公式是(i, j) -> (n-1-j, i)。你看,区别只在列和行的变化方向。所以在写代码之前,建议先在草稿纸上画一个 3×3 的例子,把一个坐标代进去算一遍,确定自己没转反。
提示:刷这种矩阵题,最忌讳一上来就硬想代码。先用坐标点验证方向,比如
(0,0)旋转后应该在(0,n-1)还是(n-1,0)?想清楚这一句话,后面就稳了。
1.2 两种主流思路:转置翻转 vs 分组循环
理解了坐标公式之后,实现方式基本就分两派。
第一派是“转置 + 水平翻转”。前面说过,顺时针旋转等价于先转置再水平翻转。具体做法是先沿着主对角线把矩阵转置,也就是把matrix[i][j]和matrix[j][i]互换;然后对每一行做一次反转。这个方法思路非常清晰,代码量很小,最重要的是不容易写错。缺点是它需要遍历两轮,不过时间复杂度依然是 O(n^2),空间 O(1),完全够用。
第二派是“分组循环”,其实就是直接在矩阵上模拟四个位置的轮换。你从左上角取一个元素,暂存起来,然后让左下角的元素移到左上角,右下角的移到左下角,右上角的移到右下角,最后把暂存的左上角元素放到右上角。一个位置一组,总共需要处理矩阵四分之一区域的元素。这个方法更贴近旋转的本质,但下标计算相对复杂,对边界特别敏感。有的人觉得它更“酷”,也有面试官喜欢追问这种写法,因为它能看出你确实理解了旋转的过程。
从实用角度,我更推荐面试时先写第一种,因为短时间内不容易翻车。第二种可以作为进阶理解,或者当面试官问“能不能再优化”的时候,讲一讲它的原理。两种解法最终结果完全相同,殊途同归。
2. 核心实现:两种解法详解
2.1 解法一:转置 + 水平翻转(推荐,简单)
先用 Python 写一版:
def rotate(matrix: List[List[int]]) -> None: n = len(matrix) # 1. 按主对角线转置 for i in range(n): for j in range(i + 1, n): matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j] # 2. 每一行反转 for i in range(n): matrix[i].reverse()代码只有两段循环,非常好记。转置的时候有个关键细节:内层循环j必须从i+1开始,而不是0。为什么?因为对角线上的元素matrix[i][i]不需要和自己交换,而如果从 0 开始,则会把已经交换过的元素再交换一遍,最后矩阵等于没变。举个极端例子:如果转置循环里j从 0 到 n-1,那么(0,1)和(1,0)会交换两次,白费功夫,还可能因为 Python 的同步赋值技巧而出现隐藏问题。所以我们总是只遍历右上三角区域,保证每对元素只交换一次。
水平翻转部分就简单了:matrix[i].reverse()或者手动写左右交换都行。如果面试官不让你用库函数,就手写:
for i in range(n): left, right = 0, n - 1 while left < right: matrix[i][left], matrix[i][right] = matrix[i][right], matrix[i][left] left += 1 right -= 1C++ 版本也顺手贴一下:
class Solution { public: 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 (auto& row : matrix) { reverse(row.begin(), row.end()); } } };这版代码的时间复杂度是 O(n^2),因为每个元素最多被访问常数次;空间复杂度 O(1),完全没有申请额外矩阵。注意题目要求原地修改,所以函数返回值是None,Python 里直接改传入的列表即可。特别提醒:不要写matrix = new_matrix,那样只是把局部变量重定向,外部原矩阵根本不会变。这也是“原地”题的一个常见坑。
2.2 解法二:分组循环直接旋转(进阶)
如果不想先转置再翻转,也可以一步到位。先回忆旋转链:(i,j)->(j, n-1-i)->(n-1-i, n-1-j)->(n-1-j, i)->(i,j)。四个位置一组,轮流覆盖。为了保证不丢失数据,只需要用一个临时变量tmp暂存起始位置。
关键难点在于循环边界怎么写。最标准的写法是:
def rotate(matrix: List[List[int]]) -> None: n = len(matrix) for i in range(n // 2): for j in range(i, n - i - 1): tmp = 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] = tmp你看,外层循环i的范围是0到n//2 - 1,它控制“层数”。想象一个洋葱,矩阵由外到内一层一层剥开,最外层是最大的正方形,往里每层小一圈。对于 n×n 矩阵,一共有n//2层,中心如果是奇数矩阵单独的位置,不需要旋转。内层循环j的范围是i到n-i-2,这是每层内要处理多少个“四元组”。比如 3×3 的最外层,i=0,j 从 0 到 1,一共两组;一组处理四角,一组处理四条边上的中间元素。如果 j 跑到n-i-1,那就会把旋转链的起点重复覆盖一次,导致结果错乱。
这个边界为什么是n-i-1?因为每一层正方形的边长是n - 2*i,除了最后一个点不需要处理(它由第一个点旋转得到),所以内层循环要跑边长-1次,也就是n - i - 1 - i = n - 2i - 1,对应 Python 的range(i, n-i-1)正好取i ... n-i-2。
C++ 版本:
class Solution { public: void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); for (int i = 0; i < n / 2; i++) { for (int j = i; j < n - i - 1; j++) { int tmp = 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] = tmp; } } } };写这种解法如果不放心,可以在大脑里跑一组 4×4 或者 5×5 的例子,从i=0,j=0开始,按链条把每个位置代进去,确认没有越界。最容易出错的地方是第二项matrix[n-1-j][i],有人会下意识写成matrix[n-1-i][j],那就变成另一条变换链了。记法:从“左边”挪到“顶部”的坐标是(n-1-j, i)。这个位置本质上就是当前列的下方镜像。
两种解法都是 O(n^2) 时间、O(1) 空间。解法一更直白,解法二更“原生”。我自己在面试时通常会先说解法一,然后主动补一句“如果想一步轮换,也可以分四组”;面试官如果感兴趣,我再说第二个。这样既展示理解深度,又不给自己制造写错的风险。
3. 实操细节与避坑指南
3.1 下标计算的忙点在哪里
这道题的下标坑多得离谱,我总结了几个高频的翻车点。
第一,转置循环里j起点的坑。前面已经说过,j必须从i+1开始。但从实际刷题来看,很多人不是不知道,而是写的时候手一滑写成range(n)。这样做的后果是:每对元素被交换两次,矩阵原地复原,然后你会得到“我明明转了,结果没变”的诡异体验。检查方法很简单:打印转置后的矩阵,如果它和原矩阵一模一样,大概率就是交换了两次。
第二,水平翻转和垂直翻转的混淆。有人为了得到顺时针旋转,先做“垂直翻转”(上下翻),再做转置,其实得到的是逆时针旋转。可以把坐标变化推一遍:垂直翻转(i,j)->(n-1-i,j),再转置(n-1-i,j)->(j,n-1-i),这不是顺时针还是逆时针?算一下发现和顺时针公式不同。如果你真想用“垂直翻转+转置”得到顺时针,顺序要反过来:先转置再垂直翻转。所以请记住组合的“先转置再左右翻”。
第三,分组循环边界记错。最常见的错是把内层循环写成for j in range(i, n - i)。这样会多处理一个元素,旋转链走到最后会把第一个位置再次覆盖,导致某个区域重复赋值,矩阵就被搅乱了。如果你不确定,就先拿 3×3 手推:最外层 j 应该只有 0 和 1,不应该有 2。多推两次,边界就刻在脑子里了。
3.2 原地与非原地实现对比
如果不要求原地,这道题就简单许多:直接开一个 n×n 的新数组,按照旋转公式把每个元素填进去。
def rotate_not_inplace(matrix): n = len(matrix) res = [[0] * n for _ in range(n)] for i in range(n): for j in range(n): res[j][n - 1 - i] = matrix[i][j] return res这个版本时空复杂度分别是 O(n^2) 和 O(n^2),正确性一目了然。那为什么 LeetCode 非要原地?因为现实场景中,大矩阵可能占据很多内存,能省一分是一分。面试官问“原地”其实是在考察你是否意识到数组是引用传参,以及是否了解内存受限场景下的处理思路。
从非原地到原地的转变,可以这么想:旋转公式本身不变,但坐标(i,j)会被后面的值覆盖,所以就需要“临时变量”和“分组轮换”。转置加翻转解法其实就是一种更聪明的原地方案:先用两次简单的对称交换完成映射,每一步都不会丢数据,自然不需要额外的大数组。从这个角度讲,解法一不仅好写,设计层面也很优雅。
注意:在 Python 中,如果你写了
matrix = res,函数结束后外界看到的matrix引用还是原来的对象,根本没被修改。要改成matrix[:] = res才有效。这是原地修改题的一个经典陷阱,参与过实习面试的朋友应该都见过。
4. 常见问题与调试技巧实录
4.1 现场踩坑记录
我自己刷这道题的时候,先试了解法一,结果第一次写转置时用了:
for i in range(n): for j in range(i, n): matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]j从i开始,对角线元素自交换,其实不影响正确性,就是白白多执行了 n 次 swap。当时没当回事。后来我又灵机一动,想写成for j in range(n): for i in range(j+1, n):,结果因为交换顺序写错,矩阵也没转成。这个经历给我一个教训:转置的循环结构最好固定格式,不要频繁换循环方向。固定为“外层 i,内层 j=i+1”,每次都这样写,肌肉记忆最靠谱。
解法二我第一次提交也没通过。我犯的是经典错误:把外层循环写成for i in range(n // 2 + 1),导致当 n 是奇数的时候,最中心的那个元素被放进旋转链里转了一圈。比如 3×3 矩阵,i最大为 1,正好把matrix[1][1]给当作分组旋转的一部分。其实 3×3 的中间元素位置是固定的,不需要参与任何轮换。好在测试用例会暴露这个问题,修改n//2后顺利通过。后来我养成了一个习惯:遇到矩阵题,先用 3×3 和 4×4 各测一遍,再上大矩阵随机测试。
4.2 快速验证与辅助工具
这里分享一个调试利器:写一个打印矩阵的函数和一个“旋转 4 次复原”的断言函数。
def show(matrix): for row in matrix: print(row) print() def rotate_and_check(matrix): original = [row[:] for row in matrix] rotate(matrix) # 从当前状态再转3次,应该回到原始状态 for _ in range(3): rotate(matrix) assert matrix == original验证原理很简单:旋转是周期性的,顺时针 4 次回到原样。所以我每写一个实现,就随机生成几个矩阵,跑这个断言。如果断言通过,基本说明没有下标越界和覆盖顺序错误。配合show(matrix)在每一步打印,可以肉眼观察转置后、翻转后的中间形态,快速定位“是哪一步转坏了”。
除了常规测试,我还会手动检查几个特殊点:n=1 的矩阵(只有一个元素,不需要做任何操作),n=2 的矩阵(每次两组,最容易看出整体形状),以及 n=5 的奇数矩阵(中心元素必须保持不动)。边界情况多测几组,代码健壮性就上来了。
提示:LeetCode 的调试器虽然方便,但本地用这些辅助函数更顺手。特别是随机生成 10 组 0~10 边长的矩阵反复测试,比只依赖 OJ 的用例更让人放心。
5. 从 Hot100 说开去
5.1 为什么这题被选入 Hot100
LeetCode Hot100 收录的题目通常有代表性,这题也是。旋转图像考察了二维数组的基础操作、坐标变换、原地修改,这几个点几乎在每场算法面试里都有概率出现。而且它够简单,容易给面试者树立信心;同时它又能演变出许多问法,比如逆时针旋转、旋转 180 度、旋转 k 次,甚至不旋转只找变换规律。把这题吃透,相当于打通了一个小类别的“母题”。
在 Hot100 里的位置也很有意思,它被夹在很多链表和字符串题中间,很多人容易忽略它。但实际刷题时,这道题付出的时间回报比很高——你只需要花一下午搞明白两种解法,之后遇到矩阵变换类的题会轻松很多。从我在牛客上的观察,剑指 Offer 29 这种顺时针打印矩阵的题和它也沾边,因为都涉及“一圈一圈处理”,思想相通。
5.2 相关变式与延伸
学会了顺时针旋转 90 度,再碰到变体就能快速套用:
逆时针旋转 90 度。公式是
(i,j)->(n-1-j,i)。也可以先用水平翻转(上下翻),再转置。如果你只记得顺时针解法,可以这样转化:先做垂直翻转,再做一次顺时针旋转?自己推一下,很快能得出结论。旋转 180 度。等价于每个元素直接对称交换:交换
(i,j)和(n-1-i,n-1-j)。可以通过两次顺时针 90 度实现,也可以写一次双重循环完成,两种都正确。旋转 k 次(k 为正整数)。只需要取模
k % 4,然后按情况调用一次、两次或三次顺时针旋转。如果 k 是负数,可以先取模转换为正整数。
这些变体在面试里经常换个外壳出现,背住“坐标公式 + 分组轮换”这两个核心,基本就不怕了。另外,像“生命游戏”“矩阵置零”这类题,也同样考察原地修改矩阵时如何用额外标记记录状态,本质都是对二维数组操作的自信心。
最后再说一点个人体会。这道题最值得记的不是那几行代码,而是“先推公式、再动手写”的习惯。我一开始也是上来就写循环,结果来回改边界,后来改成先在草稿纸上写出(i,j) -> (j, n-1-i),再顺手推一下顺时针 4 个位置的轮换链,思路立刻清晰,写代码也快了很多。如果你下次做其他矩阵题也卡住,不妨试试这个方法,可能比你自己瞎试效率高一倍。