文章目录
- 简介
- [73. 矩阵置零](#73. 矩阵置零)
- [54. 螺旋矩阵](#54. 螺旋矩阵)
- [48. 旋转图像](#48. 旋转图像)
- [240. 搜索二维矩阵 II](#240. 搜索二维矩阵 II)
- 个人学习总结
简介
本篇博客将聚焦于 LeetCode 中几个极具代表性的矩阵问题:73. 矩阵置零、54. 螺旋矩阵、48. 旋转图像 以及 240. 搜索二维矩阵 II。这些问题分别考察了原地修改、模拟路径、坐标变换和利用数据特性进行搜索等核心技巧。我将逐一分享我的解题思路、详细的代码实现以及复杂度分析,希望能为同样在探索这些问题的朋友提供一些参考与启发。
73. 矩阵置零
问题描述
给定一个 m x n 的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法。
示例
示例 1:
输入:matrix = [[1,1,1],[1,0,1],[1,1,1]]
输出:[[1,0,1],[0,0,0],[1,0,1]]
示例 2:
输入:matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
输出:[[0,0,0,0],[0,4,5,0],[0,3,1,0]]
标签提示: 数组、哈希表、矩阵
解题方法
标记法
解题思路
这道题的核心难点在于,如果我们一边遍历矩阵一边将元素置零,那么后面新产生的 0 会"污染"我们的判断,导致错误的行和列被置零。
例如,矩阵 [[1,1,1],[1,0,1],[1,1,1]],当我们遍历到 (1,1) 的 0 时,我们把第一行和第二列都置零。但当我们继续遍历到 (0,1) 这个新置的 0 时,如果我们再次处理,就会错误地把第一行和第一列都置零。
为了避免这个问题,一个直观的思路是将"发现零"和"设置零"这两个过程分开。我们可以先完整地遍历一遍矩阵,用某种方式标记下哪些行和哪些列是需要被置零的。等全部标记完成后,再进行第二次遍历,根据标记信息,将对应的行和列统一置零。
解题步骤
第一步:初始化标记数组
- 获取矩阵的维度 m(行数)和 n(列数)。
- 创建一个长度为 m 的一维整型数组 row,用于标记每一行是否需要被置零。
- 创建一个长度为 n 的一维整型数组 col,用于标记每一列是否需要被置零。
第二步:第一次遍历,标记需要置零的行和列
- 使用两层嵌套循环遍历整个 matrix 矩阵。
- 对于每一个元素 matrix[i][j],判断其值是否为 0。
- 如果 matrix[i][j] 等于 0,就将标记数组中对应的位置设为 1,即 row[i] = 1 和 col[j] = 1。这表示第
i 行和第 j 列之后需要被整体置零。
第三步:第二次遍历,根据标记执行置零操作
- 再次使用两层嵌套循环遍历整个 matrix 矩阵。
- 对于每一个元素 matrix[i][j],检查其所在的行或列是否被标记过,即判断 row[i] == 1 或者 col[j] == 1
是否成立。 - 如果这个条件成立,就将当前元素 matrix[i][j] 的值设置为 0。
第四步:完成修改
- 当第二次遍历结束后,所有需要被置零的行和列都已经被正确处理,矩阵的最终状态符合题目要求。
实现代码
java
class Solution {
public void setZeroes(int[][] matrix) {
int m = matrix.length;
int n = matrix[0].length;
int[] row = new int[m];
int[] col = new int[n];
for(int i = 0; i < m; i ++){
for(int j = 0; j < n; j ++){
if(matrix[i][j] == 0){
row[i] = col[j] = 1;
}
}
}
for(int i = 0; i < m; i ++){
for(int j = 0; j < n; j ++){
if(row[i] == 1 || col[j] == 1){
matrix[i][j] = 0;
}
}
}
}
}
复杂度分析
- 时间复杂度:O(m * n)
- 代码中包含了两个独立的嵌套循环。
- 第一个循环(标记阶段)遍历了 m * n 个元素。
- 第二个循环(置零阶段)也遍历了 m * n 个元素。
- 循环内的操作都是常数时间 O(1) 的。
- 因此,总的时间复杂度为 O(m * n) + O(m * n) = O(m * n)。
- 空间复杂度:O(m + n)
- 题目要求"原地"算法,但"原地"通常指不使用与输入规模相当的额外空间(如另一个 m x n 的矩阵),而不是完全不使用额外空间。
- 在这个解法中,我们额外使用了两个数组:
- row 数组的大小为 m。
- col 数组的大小为 n。
- 因此,额外空间的开销与矩阵的行数和列数之和成正比,空间复杂度为 O(m + n)。
O(1)空间解法分析
解题思路
此方法的核心思想是"就地取材",即利用输入矩阵本身的空间来存储标记信息,从而达到 O(1) 的额外空间复杂度。
我们选择矩阵的第一行和第一列作为"标记区"。matrix[i][0] 用来标记第 i 行是否需要被置零,matrix[0][j] 用来标记第 j 列是否需要被置零。
然而,这个策略有一个潜在的冲突:如果第一行或第一列自身就包含 0,我们该如何区分这个 0 是原始数据,还是我们后来设置的标记呢?
为了解决这个冲突,我们引入了两个布尔变量 frow 和 fcol,在开始使用第一行和第一列作为标记之前,先用它们记录下第一行和第一列的初始状态(即它们是否本来就包含 0)。
整个流程可以概括为四个步骤:
- 预处理:检查并记录第一行和第一列的初始状态。
- 标记:遍历矩阵的其余部分,利用第一行和第一列来记录需要置零的行和列。
- 置零:根据第一行和第一列的标记,将矩阵的其余部分置零。
- 收尾:根据第一步记录的初始状态,决定是否将第一行和第一列本身置零。
解题步骤
- 初始化与预处理:
- 获取矩阵的行数 m 和列数 n。
- 创建两个布尔标志 frow 和 fcol,分别用于记录第一行和第一列是否原本就包含 0,初始值均为 false。
- 遍历第一行,如果发现任何元素为 0,则将 frow 设为 true 并终止遍历。
- 遍历第一列,如果发现任何元素为 0,则将 fcol 设为 true 并终止遍历。
- 设置标记:
- 从矩阵的第二行、第二列(即坐标 (1, 1))开始遍历。
- 对于每一个元素 matrix[i][j],如果其值为 0,则将其对应的第一列标记 matrix[i][0] 和第一行标记matrix[0][j] 都置为 0。这表示"第 i 行"和"第 j 列"未来需要被整体置零。
- 执行置零:
- 再次从坐标 (1, 1) 开始遍历矩阵。
- 对于每一个元素 matrix[i][j],检查其对应的行标记 matrix[i][0] 或列标记 matrix[0][j] 是否为 0。
- 如果任意一个标记为 0,则将当前元素 matrix[i][j] 的值设为 0。
- 处理边界情况:
- 根据第一步记录的 frow 标志,如果其为 true,则将第一行的所有元素都置为 0。
- 根据第一步记录的 fcol 标志,如果其为 true,则将第一列的所有元素都置为 0。
实现代码
java
class Solution {
public void setZeroes(int[][] matrix) {
int m = matrix.length;
int n = matrix[0].length;
// 步骤 1: 判断第一行和第一列是否原本就包含 0
boolean frow = false;
boolean fcol = false;
for(int i = 0; i < n; i ++){
if(matrix[0][i] == 0){
frow = true;
break;
}
}
for(int i = 0; i < m; i ++){
if(matrix[i][0] == 0){
fcol = true;
break;
}
}
// 步骤 2: 使用第一行和第一列作为标记,记录其余行列的 0
// 从 (1,1) 开始遍历,避免污染第一行和第一列的标记
for(int i = 1; i < m; i ++){
for(int j = 1; j < n; j ++){
if(matrix[i][j] == 0){
matrix[i][0] = 0;
matrix[0][j] = 0;
}
}
}
// 步骤 3: 根据第一行和第一列的标记,将对应的行列置零
// 同样从 (1,1) 开始处理
for(int i = 1; i < m; i ++){
for(int j = 1; j < n; j ++){
if(matrix[i][0] == 0 || matrix[0][j] == 0){
matrix[i][j] = 0;
}
}
}
// 步骤 4: 最后处理第一行和第一列
if(frow){
for(int i = 0; i < n; i ++){
matrix[0][i] = 0;
}
}
if(fcol){
for(int i = 0; i < m; i ++){
matrix[i][0] = 0;
}
}
}
}
复杂度分析
- 时间复杂度:O(m * n)
- 我们进行了数次独立的遍历:检查第一行(O(n))、检查第一列(O(m))、设置标记(O(mn))、执行置零(O(mn))、处理第一行和第一列(O(m+n))。所有操作的时间复杂度相加,其最高阶项为 O(mn),因此总时间复杂度为 O(mn)。
- 空间复杂度:O(1)
- 在整个算法过程中,我们只使用了常数个额外变量(m, n, frow, fcol, i, j)。我们没有使用任何与矩阵规模(m 或 n)相关的额外数据结构,因此空间复杂度为 O(1),这是本题的最优空间复杂度。
54. 螺旋矩阵
问题描述
给你一个 m 行 n 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。
示例
示例 1:
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,3,6,9,8,7,4,5]
示例 2:
输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
输出:[1,2,3,4,8,12,11,10,9,5,6,7]
标签提示: 数组、矩阵、模拟
解题方法
模拟法
解题思路
这个方法的核心思想是模拟一个人在矩阵中行走的路径。我们想象自己从矩阵的左上角出发,按照固定的顺序(向右 -> 向下 -> 向左 -> 向上)前进,直到遍历完所有元素。
关键在于如何判断何时需要"转弯"。转弯的条件有两个:
- 撞墙:下一步移动会超出矩阵的边界。
- 重复访问:下一步移动会到达一个已经访问过的单元格。
为了实现这个逻辑,我们需要:
- 一个方向数组,来定义"向右、向下、向左、向上"这四个方向的移动方式(即行和列的增量)。
- 一个标记矩阵(visited),用来记录哪些单元格已经被访问过,以避免重复。
整个过程就像一个拥有简单逻辑的机器人,在矩阵中自动寻路,直到走完所有格子。
解题步骤
- 初始化:创建结果列表,并定义四个移动方向(右、下、左、上)。同时,创建一个与原矩阵同样大小的标记矩阵,用于记录已访问过的单元格。
- 模拟遍历:从矩阵左上角 (0, 0) 开始,循环 m * n 次(m为行数,n为列数),确保访问所有元素。
- 移动与转向:在每一步中,将当前元素加入结果列表并标记为已访问。然后,尝试按当前方向移动到下一个位置。如果下一个位置越界或已被访问,则顺时针切换到下一个方向(例如,从"向右"切换到"向下"),然后再进行移动。
- 返回结果:循环结束后,结果列表中便存储了完整的螺旋顺序,将其返回。
实现代码
java
class Solution {
public List<Integer> spiralOrder(int[][] matrix) {
List<Integer> result = new ArrayList<Integer>();
if(matrix == null || matrix.length == 0 || matrix[0].length == 0){
return result;
}
int rows = matrix.length;
int cols = matrix[0].length;
boolean[][] visited = new boolean[rows][cols];
int[][] direction = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}};
int row = 0, col = 0;
int total = rows * cols;
int index = 0;
for(int i = 0; i < total; i ++){
result.add(matrix[row][col]);
visited[row][col] = true;
int nextrow = row + direction[index][0], nextcol = col + direction[index][1];
if(nextrow < 0 || nextrow >= rows || nextcol < 0 || nextcol >= cols || visited[nextrow][nextcol]){
index = (index + 1) % 4;
}
row += direction[index][0];
col += direction[index][1];
}
return result;
}
}
复杂度分析
-
时间复杂度:O(m * n)
其中 m 是矩阵的行数,n 是矩阵的列数。算法的主循环执行了 m * n 次,以确保访问了矩阵中的每一个元素。在循环内部,所有的操作(如列表添加、数组访问、条件判断、算术运算)都是常数时间 O(1) 的操作。因此,总的时间复杂度为 O(m * n)。
-
空间复杂度:O(m * n)
算法的主要空间消耗来自于 visited 标记矩阵,它的大小与输入矩阵相同,为 m * n。direction 数组是常数大小的。result 列表用于存储输出,通常不计入辅助空间复杂度。因此,由辅助数据结构决定的额外空间复杂度为 O(m * n)。
48. 旋转图像
问题描述
给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。
你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
示例 :
标签提示: 数组、数学、矩阵
解题方法
原地旋转
解题思想
这种方法的核心是直接模拟旋转过程。通过观察可以发现,矩阵的旋转可以分解为若干个"四元组"的循环交换。例如,左上角的元素会移动到右上角,右上角的元素会移动到右下角,右下角的元素会移动到左下角,左下角的元素会移动到左上角。
我们不需要遍历整个矩阵,只需要遍历矩阵的左上角区域(对于奇数 n,包括中间行和列),以该区域的每个元素作为旋转的起点,完成一次四个元素的循环交换即可。这样可以确保每个元素都被且仅被处理一次。
解题步骤
- 确定遍历范围:只需遍历矩阵的左上四分之一区域。外层循环 i 从 0 到 n/2 - 1,内层循环 j 从 0 到 (n+1)/2 - 1。
- 循环交换:对于每个坐标 (i, j),它所在的"四元组"的另外三个坐标分别是 (n-j-1, i)、(n-i-1, n-j-1) 和 (j, n-i-1)。
- 使用一个临时变量 temp,将这四个元素按顺时针方向进行原地交换。
- 遍历完所有需要处理的起点后,整个矩阵旋转完成。
实现代码
java
class Solution {
public void rotate(int[][] matrix) {
int n = matrix.length;
int temp = 0;
for(int i = 0; i < n / 2; i ++){
for(int j = 0; j < (n + 1) / 2; j ++){
temp = matrix[i][j];
matrix[i][j] = matrix[n - j - 1][i];
matrix[n - j - 1][i] = matrix[n - i - 1][n - j - 1];
matrix[n - i - 1][n - j - 1] = matrix[j][n - i - 1];
matrix[j][n - i - 1] = temp;
}
}
}
}
复杂度分析
-
时间复杂度:O(n²)
外层循环和内层循环共同遍历了大约 n² / 4 个元素,每个元素进行常数次操作,因此总时间复杂度为 O(n²)。
-
空间复杂度:O(1)
算法只使用了常数个额外变量(如 temp 和循环变量 i, j),没有使用与矩阵大小相关的额外存储空间,因此是原地操作。
翻转法
解题思想
这种方法将一个复杂的旋转操作分解为两个更简单、更基本的矩阵操作:翻转 和 转置。
一个矩阵顺时针旋转90度,可以等价于:
-
先沿水平中轴线进行一次上下翻转(垂直翻转)。
-
再沿主对角线(从左上到右下)进行一次转置。
通过这两个简单的步骤组合,就能实现与旋转完全相同的效果。这种方法将问题转化为了两个更易于实现和理解的子问题。
解题步骤
- 垂直翻转:遍历矩阵的上半部分行,将每一行与对称的下半部分行进行交换。即交换 matrix[i][j] 和 matrix[n - 1 - i][j]。
- 对角线转置:遍历矩阵的上三角区域(即 j < i 的部分),将每个元素与其关于主对角线对称的元素进行交换。即交换 matrix[i][j] 和 matrix[j][i]。
- 完成以上两步后,矩阵即完成了顺时针90度的旋转。
实现代码
java
class Solution {
public void rotate(int[][] matrix) {
int n = matrix.length;
int temp = 0;
// 利用翻转
for(int i = 0; i < n / 2; i ++){
for(int j = 0; j < n; j ++){
temp = matrix[i][j];
matrix[i][j] = matrix[n - i - 1][j];
matrix[n - i - 1][j] = temp;
}
}
for(int i = 0; i < n; i ++){
for(int j = 0; j < i; j ++){
temp = matrix[i][j];
matrix[i][j] = matrix[j][i];
matrix[j][i] = temp;
}
}
}
}
复杂度分析
-
时间复杂度:O(n²)
垂直翻转需要遍历约 n² / 2 个元素,对角线转置也需要遍历约 n² / 2 个元素。总操作次数与 n² 成正比,因此时间复杂度为 O(n²)。
-
空间复杂度:O(1)
与方法一相同,该方法也只使用了常数个额外变量来完成交换,是原地操作。
240. 搜索二维矩阵 II
问题描述
编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
示例:
标签提示: 数组、二分查找、分治、矩阵
解题方法
Z字形搜索
解题思路
这个方法的核心思想是利用矩阵的特性,从一个特殊的位置开始,使得每一步都能排除掉一行或一列,从而缩小搜索范围。
这个特殊的位置就是矩阵的右上角(或者等价地,左下角)(左上角你会发现往右和往下都是增大值无法确定方向,而右下角则不论往左还是往上都是减小值也无法确定方向)。
- 右上角元素的特性:它是它所在行的最大值,同时也是它所在列的最小值。
- 利用这个特性:
- 如果当前元素大于目标值 target,那么目标值不可能在当前列(因为当前元素已经是该列最小的了)。因此,我们可以安全地向左移动,排除掉这一整列。
- 如果当前元素小于目标值 target,那么目标值不可能在当前行(因为当前元素已经是该行最大的了)。因此,我们可以安全地向下移动,排除掉这一整行。
通过这种"Z字形"的移动路径,我们每一步都能做出明确的判断,并缩小搜索空间,直到找到目标或搜索完所有可能的区域。
解题步骤
-
初始化指针:从矩阵的右上角开始,即 x = 0 (第一行) 和 y = n - 1 (最后一列)。
-
循环搜索:当指针 x 和 y 仍在矩阵边界内时(x < m 且 y >= 0),执行循环:
a. 将当前元素 matrix[x][y] 与 target 比较。
b. 如果相等,说明找到目标,直接返回 true。
c. 如果当前元素大于 target,说明目标值在当前元素的左侧。将列指针 y 减一,向左移动。
d. 如果当前元素小于 target,说明目标值在当前元素的下方。将行指针 x 加一,向下移动。
-
结束搜索:如果循环结束(即指针移出了矩阵边界),说明在矩阵中未找到目标值,返回 false。
实现代码
java
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
int m = matrix.length;
int n = matrix[0].length;
int x = 0, y = n - 1;
while(x < m && y >=0){
if(matrix[x][y] == target){
return true;
}
if(matrix[x][y] > target){
y --;
}else{
x ++;
}
}
return false;
}
}
复杂度分析
-
时间复杂度:O(m + n)
在最坏的情况下,指针会从右上角走到左下角。在这个过程中,行指针 x 最多增加 m 次,列指针 y 最多减少 n 次。总的移动次数不会超过 m + n 次。因此,时间复杂度为 O(m + n)。
-
空间复杂度:O(1)
算法只使用了常数个额外变量(m, n, x, y),没有使用与矩阵大小相关的额外存储空间。因此,空间复杂度为 O(1)。
个人学习总结
通过对以上四个经典矩阵问题的梳理与求解,我深刻体会到矩阵类问题的魅力与挑战所在。它们不仅仅是简单的循环嵌套,更是对思维方式的综合考验。在此,我总结几点核心的学习心得:
-
遍历的艺术: 矩阵问题的核心往往在于如何设计一个高效且正确的遍历路径。无论是"螺旋矩阵"中模拟人行走路径的Z字形遍历,还是"搜索二维矩阵
II"中从右上角开始的排除式遍历,都体现了跳出常规逐行逐层思维的重要性。精确控制遍历的起点、方向和终止条件是解题的关键。
-
空间优化的智慧: "原地操作"是矩阵问题中一个高频且重要的要求。它迫使我们思考如何利用有限的资源解决问题。"矩阵置零"的 O(1)
空间解法是"就地取材"思想的绝佳体现------利用矩阵自身(第一行和第一列)作为标记区,这种技巧在解决其他问题时也极具借鉴意义。
-
洞察问题本质,寻找"巧解": 许多矩阵问题并非只能通过暴力模拟来解决。关键在于能否发现数据背后隐藏的规律或特性。"搜索二维矩阵 II"的 Z 字形搜索法,正是利用了矩阵行与列的有序性;"旋转图像"的翻转法,则是将复杂的旋转操作巧妙地分解为两个更基础的矩阵变换。这提醒我们,在动手编码前,多花时间观察和分析,往往能找到更优雅、更高效的解法。
-
严谨的边界与索引处理:这是所有矩阵问题,乃至所有数组问题的基础。无论是"螺旋矩阵"的循环控制,还是"旋转图像"的坐标映射,精确的边界条件和索引计算是保证代码正确性的生命线。一个
+1 或 -1 的疏忽,就可能导致数组越界或逻辑错误。在编写代码时,对边界条件的反复推敲和验证是必不可少的环节。
总而言之,解决矩阵问题需要我们兼具宏观的算法设计能力和微观的代码实现细节把控能力。每一次成功的解题,都是对逻辑思维和编程技巧的一次绝佳锻炼。我将继续探索,享受在代码世界中解决难题的乐趣。