看到 LeetCode 48 图像旋转这道题,很多人的第一反应是:这有什么难的?把每个元素按位置搬到新数组不就完事了。但等到面试官补一句“能不能不用额外空间”,或者要求你当场写出 bug-free 的原地旋转版本时,才发现里面全是细节。题目本身不复杂,输入一个 n x n 的二维矩阵,把它顺时针旋转 90 度。示例中三阶矩阵转一圈,行变成列、列倒过来,视觉上很清楚,难的是如何在不申请大额外空间的前提下,用下标操作完成整个变换。
这篇文章我用 Python 和 Java 双语言把三种主流解法完整拆开:辅助数组法、原地四元素轮转法、转置加翻转法。三种思路从易到难,从直观到精巧,正好对应面试中从“能做出”到“能做漂亮”的进阶过程。适合刚开始刷题、被二维数组绕晕的读者,也适合准备面试时想把自己的答案讲出层次感的朋友。
1. 旋转的本质:先搞清楚下标映射
1.1 题目在考什么
LeetCode 48 的题目描述很简短:给定一个 n × n 的二维矩阵,表示一张图像,将图像顺时针旋转 90 度。要求原地旋转,也就是说不能新建一个矩阵来接收结果,必须在原矩阵上直接改动。你可能会想,图像旋转不是常规操作吗?真正实现起来,每一位元素要移动到哪一格,都对应一个严格的下标关系。
本题考的并不是高深的算法,而是几个点:映射关系是否清晰、循环边界是否严谨、能否从 O(n^2) 空间优化到 O(1) 空间。这也是为什么它在热门 100 题里长期占位的根本原因——虽然看起来只是 Medium 难度,但写好并不容易。很多人在刷题群里抱怨“看题解三分钟就懂,过两天自己写又错”,基本都栽在边界条件上。
1.2 用四角推导映射关系
以 3x3 矩阵来走一遍,原矩阵是 [[1,2,3],[4,5,6],[7,8,9]],旋转后是 [[7,4,1],[8,5,2],[9,6,3]]。我们观察四个角的迁移路径:
- 1 从 (0,0) 到 (0,2)
- 3 从 (0,2) 到 (2,2)
- 9 从 (2,2) 到 (2,0)
- 7 从 (2,0) 到 (0,0)
用一个式子概括就是:原矩阵中的 matrix[i][j],旋转后应该去 matrix[j][n-1-i] 这个位置。例如 (0,0) 代入,得到 (0,2),正确; (0,2) 代入,得到 (2,2),正确。反过来,新矩阵中 (i,j) 位置的值来自原矩阵的 (n-1-j,i),这个反推公式也非常常用,特别是写辅助数组版本时,按新矩阵的扫描顺序去旧矩阵里取值,往往比正向填值更顺手。
1.3 映射关系是所有解法的锚点
很多人刷题时喜欢直接背解法三的代码,背下来之后能过,但换个方向(比如逆时针旋转)就懵了。原因是没抓住映射关系。其实不管解法怎么变,核心都是同一个公式。
辅助数组版本是公式的直接翻译,四元素轮转是公式在固定范围上做了四次复合,转置加翻转则是把公式拆成两步线性变换。理解到这一层,就能自己推导出逆时针 90 度(映射是 (i,j)->(n-1-j,i))、180 度之类的变体,而不是每次遇到新题都从头开始猜代码。
2. 解法一:辅助数组——先用最直观的方式保证正确
2.1 思路
辅助数组法的思路直白得不能再直白:开一张同样大小的二维数组 rotated,遍历原矩阵的每个格子,根据映射关系把值填到 rotated 对应位置。最后再把 rotated 复制回 matrix。
由于我们是按“目标位置接收来源值”的方式写,直接用新表扫描更简单:rotated[i][j] = matrix[n-1-j][i]。这句话的意思是,新矩阵的第 i 行第 j 列,应该取原矩阵的第 n-1-j 行、第 i 列的元素。
2.2 Python 实现
from typing import List def rotate(self, matrix: List[List[int]]) -> None: n = len(matrix) rotated = [[0] * n for _ in range(n)] for i in range(n): for j in range(n): rotated[i][j] = matrix[n - 1 - j][i] for i in range(n): for j in range(n): matrix[i][j] = rotated[i][j]注意最后一步必须做两层复制。如果直接写matrix = rotated,外面的调用方根本感知不到变化。在 LeetCode 这类 OJ 上,它检查的是 matrix 这个对象的内容,你改掉局部变量引用是无效的。这也是 Python 里非常经典的一个坑。
如果你想要写法更简洁,最后一层复制也可以写成matrix[:] = rotated,列表的切片赋值会原地修改外层列表的内容。但两层循环更通用,尤其在面试手写场景下不容易让面试官产生疑惑。
2.3 Java 实现
class Solution { public void rotate(int[][] matrix) { int n = matrix.length; int[][] rotated = new int[n][n]; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { rotated[i][j] = matrix[n - 1 - j][i]; } } for (int i = 0; i < n; i++) { matrix[i] = rotated[i].clone(); } } }这里的 clone() 或者 System.arraycopy 都可以,目的都是把一维数组的内容真正拷贝进原来的行数组。Java 二维数组本质上是数组的数组,直接matrix[i] = rotated[i]其实也可以,因为是把整行引用替换掉,原二维数组的外层容器还在。
但我个人建议用 clone() 或 arraycopy,语义更清晰,面试时也能展示你对引用的理解。
2.4 复杂度与定位
时间 O(n^2) 是不可避免的,因为 n^2 个元素都要挪位置。额外空间 O(n^2)。这个版本应该作为思路铺垫,而不是最终答案。
为何还要写它?因为面试时先讲一个正确但不够优的版本,再讲优化,能直观展示“识别问题 -> 优化问题”的思维方式。而且辅助数组版本最容易验证映射公式没有写反,很多人用解法三出 bug 时,都会回到这个版本做对照。
3. 解法二:原地四元素轮转——空间压缩到 O(1)
3.1 为什么要一圈一圈处理
在原地旋转的前提下,我们不能把某个元素直接搬走到另一个位置后不管它,因为那样会覆盖还没处理的值。解决思路是“分组交换”:四个在旋转路径上互相接力的元素,刚好构成一个环,把它们作为一个小组同时旋转。
整个矩阵可以看成一层套一层的方框,外圈旋转后仍在最外圈,内圈旋转后仍在更内圈。因此从最外层开始,逐层向里处理即可。最内层如果只有一个中心元素(n 为奇数),它旋转后仍在自己位置,不需要处理。
3.2 四元素组的坐标公式
对于第 layer 圈,定义 first = layer,last = n - 1 - layer。这一圈的边长是 last - first + 1,除了四个角以外,每条边上还有若干中间元素。
取一个偏移量 offset,范围是 0 到 last - first - 1(左闭右开),四个对应位置分别是:
- 上边:matrix[first][first + offset]
- 右边:matrix[first + offset][last]
- 下边:matrix[last][last - offset]
- 左边:matrix[last - offset][first]
这四者正好构成一个旋转闭环:上边跑到右边,右边跑到下边,下边跑到左边,左边跑回上边。算法上为了不丢失值,先把上边存到临时变量 top,然后执行“左 -> 上,下 -> 左,右 -> 下,上 -> 右”的逆方向赋值,最后把 top 放到右边的位置。
3.3 完整代码
Python:
from typing import List def rotate(self, matrix: List[List[int]]) -> None: n = len(matrix) for layer in range(n // 2): first = layer last = n - 1 - layer for i in range(first, last): offset = i - first top = matrix[first][first + offset] matrix[first][first + offset] = matrix[last - offset][first] matrix[last - offset][first] = matrix[last][last - offset] matrix[last][last - offset] = matrix[first + offset][last] matrix[first + offset][last] = topJava:
class Solution { public void rotate(int[][] matrix) { int n = matrix.length; for (int layer = 0; layer < n / 2; layer++) { int first = layer; int last = n - 1 - layer; for (int i = first; i < last; i++) { int offset = i - first; int top = matrix[first][first + offset]; matrix[first][first + offset] = matrix[last - offset][first]; matrix[last - offset][first] = matrix[last][last - offset]; matrix[last][last - offset] = matrix[first + offset][last]; matrix[first + offset][last] = top; } } } }3.4 边界陷阱:offset 为什么要排除最后一个
offset 的最大值是 last - first - 1,而不是 last - first。因为 offset 如果取到 last - first,那么“上边”位置 first + offset 就等于 last,此时四个位置全部落在四个角上,而这四个角已经在上一个 offset 中被处理过了。再处理一遍,相当于多旋转一次,会把刚才已经放好的四角又换回去。
这是原地旋转最容易犯的 off-by-one 错误。建议写完后用 4x4 矩阵手推一遍:外圈 offset 只取 0、1、2,三个元素组刚好覆盖外圈 12 个位置。
3.5 坐标方向怎么记才不会乱
这版代码最容易卡壳的是四行赋值的方向。我的记忆技巧是:永远写“拿后来者填补当前位置”的逆推版本。也就是说,要让上边位置变成左边位置的值,要让左边位置变成下边位置的值,要让下边位置变成右边位置的值,要让右边位置变成原来的上边值。这样四个赋值语句的顺序是固定的,配合 top 临时变量,不会出现覆盖原始值的问题。
很多资料给的是“上给右、右给下、下给左、左给上”的正向版本,思路也行,但方向反了容易混淆,选一种记住就好。我自己在面过几个候选人后发现,凡是能把这四行赋值顺序讲清楚的人,对二维数组下标的掌控感明显更强。
4. 解法三:转置加翻转——最优雅的写法
4.1 转置
先沿主对角线转置:把 matrix[i][j] 和 matrix[j][i] 交换。注意只遍历 i+1 到 n-1,也就是矩阵的上三角部分,否则每个元素会被交换两次,结果又变回原样。
这一步可以用生活里的动作来想:把整个矩阵沿着从左上角到右下角的那条对角线“对折”一下。对折之后,原来在右上的元素跑到左下,原来左下的元素跑到右上,行列坐标互换。
4.2 水平翻转
每行左右对折,把 matrix[i][j] 与 matrix[i][n-1-j] 交换,列只遍历到 n//2 即可。
这两步分开看都很简单,合起来却是一个完整的顺时针旋转,这种“简单操作的复合”在算法里很常见。
4.3 为什么这两步等于顺时针旋转 90 度
用下标推导最清楚。原矩阵元素 (i,j),转置后到 (j,i),再水平翻转(行号不变,列号变成 n-1-i),最终位置是 (j, n-1-i)。对照第 1 节推导的顺时针映射(i,j)->(j, n-1-i),完全一致。
注意顺序不能反。先水平翻转再转置得到的是逆时针 90 度,因为 (i,j) 先变成 (i,n-1-j),再转置变成 (n-1-j,i) ,这不是顺时针映射。如果你不想每次推导,可以只记一句:顺时针 = 先转置 + 再左右翻转;逆时针 = 先转置 + 再上下翻转,或者先左右翻转再转置。
4.4 完整代码
Python:
from typing import List def rotate(self, matrix: List[List[int]]) -> None: 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): for j in range(n // 2): matrix[i][j], matrix[i][n - 1 - j] = matrix[i][n - 1 - j], matrix[i][j]Java:
class Solution { public void rotate(int[][] matrix) { int n = matrix.length; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { int tmp = matrix[i][j]; matrix[i][j] = matrix[j][i]; matrix[j][i] = tmp; } } for (int i = 0; i < n; i++) { for (int j = 0; j < n / 2; j++) { int tmp = matrix[i][j]; matrix[i][j] = matrix[i][n - 1 - j]; matrix[i][n - 1 - j] = tmp; } } } }4.5 为什么推荐它作为最终答案
转置加翻转的时间复杂度 O(n^2),额外空间 O(1),代码量显著小于四元素轮转,而且每个步骤的名字本身就是语义:先转置,再翻转,任何面试官一听就懂。更关键的是它的出错率低,边界只需要注意一个“j 从 i+1 开始”、一个“j < n/2”,不容易写出越界访问。
如果面试官希望看你的代码风格,这一版基本是完美答案。
5. 三种解法放在一起怎么选
5.1 横向对比表
| 解法 | 时间复杂度 | 额外空间 | 原地性 | 代码量 | 面试友好度 |
|---|---|---|---|---|---|
| 辅助数组法 | O(n^2) | O(n^2) | 否 | 少 | 用于铺垫 |
| 四元素轮转 | O(n^2) | O(1) | 是 | 中 | 考细节 |
| 转置加翻转 | O(n^2) | O(1) | 是 | 最少 | 强烈推荐 |
时间复杂度上三者没有区别,空间上后两者严格优于辅助数组。如果你在本地刷题只是为了 AC,解法三的简单直接最有优势;如果你是为了面试,三种都要能写出来,因为面试官经常会追问优化过程。
5.2 面试表述的推荐节奏
我会这样讲:首先给出辅助数组方案,明确说明它的优点是直观,缺点是空间 O(n^2);然后主动提出可以压缩空间,引出四元素轮转或转置翻转;最后补一句“还有一种更简洁的做法是先转置再左右翻转”。这样一套下来,既展示了从暴力到优化的思维过程,又展示了代码能力。
不要一上来就写转置翻转,有些面试官会觉得你在背题;先把映射关系讲清楚,再优化,观感完全不同。换句话说,这道题不仅考你会不会写,还考你会不会“讲”。
5.3 旋转方向的扩展
如果题目改成逆时针 90 度,用转置翻转只是把顺序换成“水平翻转 + 转置”即可;如果改成 180 度,可以直接做两次行列交换,甚至两次水平翻转再两次垂直翻转,也可以理解成“每行反转 + 每列反转”。熟练掌握了映射,这类变形题基本三分钟写出。
另外你会发现,解法二的四元素轮转也是可以扩展的:逆时针旋转只需要把赋值反向循环一次,180 度旋转则只需要交换两个对角元素。这些变形都在同一个框架下,不建议单独背。
5.4 一行 zip 技巧与非方阵的现实延伸
Python 里有一个很容易让人眼前一亮的写法:list(zip(*matrix[::-1])),它能把矩阵逆序后再打包转置,得到的就是顺时针旋转后的新矩阵。但它有一个致命限制:返回的是新的二维结构(元组组成的列表),完全不符合“原地修改”的要求。所以这个写法在 LeetCode 48 上不能直接用,只能在“允许返回新矩阵”的题目里玩梗或者做快速验证。
另外,LeetCode 48 明确约束是 n × n 方阵,所以原地旋转才可行。真实世界的图像一般是 width × height 的长方形,直接原地旋转不可行,需要额外的转置缓冲或处理策略。如果面试中遇到“矩阵旋转”的变体题,第一件事永远是确认矩阵是否方阵、是否允许额外空间、返回值是原地还是新数组。这些约束直接决定用什么解法。
6. 最容易翻车的细节与我的调试习惯
6.1 转置时的双重循环起点
for j in range(n) 会导致交换两次,矩阵没有变化。而且这个 bug 不会报错,很坑。所有转置类操作的统一口诀:右上三角,j 从 i+1 开始。
有些同学会写成for j in range(i, n),这样对角线元素会被交换一次,看起来好像没区别,但因为在原地交换时对角线元素与自身交换,不影响结果,所以从 i 或 i+1 开始本质上没问题。但从标准转置定义看,j 从 i+1 开始更干净,也能减少一次无意义的自交换。
6.2 原地旋转的圈层循环
外圈循环 range(n//2),如果写成 n 会重复处理中心元素或用错误坐标越界。写成 n-1 则会少处理最内层的一圈。这里最稳的记忆方式:每一层都要留下一个中心位置不处理,所以循环次数恰为 n 的一半(向下取整)。
6.3 Python 中 List 引用的真相
Python 列表存储的是对象的引用,matrix 的每一行是一个列表对象。二维列表做浅拷贝时,比如matrix.copy(),只会复制外层列表,内层行列表仍然共享引用。一旦修改内层任何一个元素,另一个“拷贝”也会跟着变,这是很多人用 Python 写矩阵题目时经常遇到的神秘现象。
更安全的拷贝方式有copy.deepcopy(matrix),但代价很大。在本题的辅助数组版本里,直接用列表推导式[[0] * n for _ in range(n)]生成新表,而不是用乘法[[0] * n] * n——后者会让每一行都指向同一个列表对象,改一行等于改所有行,属于必背的 Python 大坑。
6.4 Java 中数组拷贝的正确姿势
Java 二维数组int[][]是引用类型,int[][] copy = matrix不复制内容,Arrays.copyOf也只能浅拷贝一层,matrix.clone()同样如此。真正安全的拷贝需要两层循环或者System.arraycopy对每一行执行。
在解法一中,我用了matrix[i] = rotated[i].clone(),这相当于把每一行的一维数组内容复制到新的行数组中,效率不错,语义也清晰。如果你在面试中写出matrix[i] = rotated[i],也说得通,但最好补一句“这是替换行引用”,显示你对 Java 数组模型理解到位。
6.5 手写用例的习惯
我每次写完都会快速跑 1x1、2x2、3x3、4x4 四组用例。2x2 只有四个角,最容易暴露赋值方向错;3x3 有中心元素,最容易暴露冗余重复处理;4x4 有多个 offset,最容易暴露边界值。在 OJ 上提交前,先花十秒在纸上把 3x3 走通,能省掉一次 WA。
比如 3x3 矩阵,中心元素 5 在旋转后仍然在中心,肉眼就能判断有没有被误处理;四角上的 1、3、9、7 如果方向错了,立刻能看出来。这个习惯在 LeetCode 的矩阵类题目里非常通用,尤其是旋转矩阵、螺旋矩阵、生命游戏这类题目,手推一个小例子比看半天调试日志都快。
老实说,LeetCode 48 这道题我刷过不止一遍,每次换一种解法重新写都会发现新的细节。后来我给自己定了个规矩:凡是矩阵题,先写映射公式,再动手写代码。解法三虽然短,但如果你不能在三分钟内从映射公式推导出来,说明还没吃透;真正面试的时候,脑子里只有一段背诵的代码是很危险的。
建议你也试试:用十分钟把三种解法各写一遍,把这里的 3x3、4x4 手推一遍,之后再遇到旋转类题目,会顺手很多。