刷题的时候我发现矩阵题很容易给人一种 "全靠找规律" 的错觉。但刷完几道 Hot100 矩阵题后会发现:很多坑不是难在规律有多隐蔽,而是操作会污染原始数据,干扰后续判断。下面按照我做题真实思考流程复盘四道高频矩阵题:矩阵置零、螺旋矩阵、旋转矩阵、搜索二维矩阵 II。 配套 C++ 完整代码,附带关键注释。
一、矩阵置零:从暴力直接修改 → 先标记后修改
给定一个 m
xn 的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法**。**示例 1:
输入:matrix = [[1,1,1],[1,0,1],[1,1,1]] 输出:[[1,0,1],[0,0,0],[1,0,1]]
刚开始做矩阵置零这道题时,我的第一反应非常直白:遍历整个矩阵,只要遇到一个 0,就直接把它所在的整行、整列全部置零。
这是正常人的直觉思路,简单粗暴,看起来完全没问题。
但大家可以先停下来思考一个问题:为什么这个看似正确的暴力方法,完全过不了题?
核心致命坑点:数据污染 。 如果我们遍历的时候,发现一个 0 就立刻当场置零行列,原本正常的数字会被提前改成 0。 当循环走到后面的位置时,程序根本分不清:这个 0 是矩阵原本就有的 0,还是我刚刚手动置零出来的 0? 于是后续的遍历会被前面的操作误导,产生连锁错误,整张矩阵最后会被改得面目全非。
这也是矩阵题型最经典的陷阱:遍历过程中不能直接修改原矩阵,修改会破坏遍历依据。
想通这个问题后,我立刻换了一个反向思维: 既然不能边遍历边修改,那我就「先记录、后修改」。 我们不需要当场置零任何元素,只需要提前开好两个数组:
- 一个记录「哪些行需要置零」
- 一个记录「哪些列需要置零」
第一次遍历矩阵:只做标记,不修改原数据。遇到原始 0,就把对应行、列标记为 true。 第二次遍历矩阵:根据之前的标记,统一批量置零。
先统计、后更新,全程不会破坏原始矩阵数据,彻底解决数据污染问题。
这道题教会了我矩阵题第一个通用核心思想: 矩阵遍历最怕即时修改,但凡修改会影响后续判断,一律采用「先标记、后统一更新」的策略。
cpp
class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<bool> row(m, false);
vector<bool> col(n, false);
// 第一轮:只标记,不修改原矩阵
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
if(matrix[i][j] == 0){
row[i] = true;
col[j] = true;
}
}
}
// 第二轮:依据标记批量置零
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
if(row[i] || col[j]){
matrix[i][j] = 0;
}
}
}
}
};
二、螺旋矩阵:从无脑模拟走方向 → 分层圈遍历,警惕边界与剩余线段
给你一个
m行n列的矩阵matrix,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。示例 1:
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]] 输出:[1,2,3,6,9,8,7,4,5]
拿到螺旋矩阵,第一直觉:模拟走路,右、下、左、上循环走,碰到边界或者已经访问过的元素就转向。
大家可以想一想,这个直观模拟思路会遇到什么麻烦?
直接模拟行走最大问题:需要额外开辟一个visited标记矩阵记录哪些位置已经访问过。一旦标记数组不能维护好,就会重复遍历或者越界;如果想不开额外数组,直接修改原矩阵做标记,又会遇到上一题同样的数据污染问题,原始值被覆盖丢失。
于是我转向分层转圈的思路,一圈一圈剥离矩阵外圈。 每完整走完一圈,就向内收缩一层。每一圈分别执行:从左到右、从上到下、从右到左、从下到上四趟遍历。
这里我当时踩了一个很隐蔽的坑:圈循环跑完之后,矩阵不一定全部遍历完成!
结论:取行列的较小值min(rows,cols)。
- 如果较小值是偶数:全部外圈遍历完成,没有剩余元素;
- 如果较小值是奇数:剥完所有外圈,中间会剩下一条单行或者单列,正方形矩阵会退化成单个中心点,必须单独循环补上这一部分。
如果忽略这个剩余线段的处理,部分样例就会少输出数字。
这道题带给我的感悟:
模拟方向容易写烂,分层处理是矩阵遍历常用手段;不要写完主循环就万事大吉,一定要思考循环结束后,会不会残留未处理的区域。
cpp
class Solution {
public:
vector<int> spiralOrder(vector<vector<int>> &matrix)
{
vector<int> ans;
if (matrix.empty()) return ans;
int rows = matrix.size(), cols = matrix[0].size();
int n = min(rows, cols);
int circle = n / 2;
int cnt = 0;
while (circle--)
{
// 上边:左→右
for (int i = cnt; i < cols - cnt; i++)
ans.push_back(matrix[cnt][i]);
// 右边:上+1→下
for (int i = cnt + 1; i < rows - cnt; i++)
ans.push_back(matrix[i][cols - cnt - 1]);
// 下边:右-1→左
for (int i = cols - cnt - 2; i >= cnt; i--)
ans.push_back(matrix[rows - cnt - 1][i]);
// 左边:下-1→上+1
for (int i = rows - cnt - 2; i > cnt; i--)
ans.push_back(matrix[i][cnt]);
cnt++;
}
// min(行,列)为奇数,存在中间剩余线段
if (n % 2 != 0)
{
if (rows >= cols)
{
// 剩余竖线
for (int i = cnt; i < rows - cnt; i++)
ans.push_back(matrix[i][cnt]);
}
else
{
// 剩余横线
for (int i = cnt; i < cols - cnt; i++)
ans.push_back(matrix[cnt][i]);
}
}
return ans;
}
};
三、旋转矩阵:从直接移动元素引发覆盖丢失 → 两种原地变换思路
给定一个 n × n 的二维矩阵
matrix表示一个图像。请你将图像顺时针旋转 90 度。你必须在**原地** 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。
示例 1:
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]] 输出:[[7,4,1],[8,5,2],[9,6,3]]
看到 "把方阵顺时针旋转 90°,原地修改",我第一反应:直接把元素挪到旋转后对应的位置。
很快就遇到问题:四个位置循环轮换,如果直接赋值覆盖,没有临时变量保存,原始值直接丢失,也就是前面聊到的数据污染。 比如 A 位置的值要搬到 D,D 搬到 C,C 搬到 B,B 搬到 A;如果直接连续赋值,还没把旧 A 放到 B,A 的值就已经被覆盖。 于是诞生四向轮换写法:用临时变量缓存第一个元素,完成四个位置循环交换,规避数据丢失。
但四向轮换的下标非常繁琐,坐标很容易写错。有没有更简单的原地方案?
我拿 3 阶小样例手写尝试: 原矩阵做主对角线转置,观察转置后的矩阵和目标旋转矩阵,发现只需要把每一行再做一次逆序,结果正好等于顺时针旋转 90°。
先转置,再每行 reverse,全部依靠两两 swap 交换。swap 不会单向覆盖,天然不会产生数据污染。
这里要特别注意顺序! 提问:如果我先每行逆序,再做转置,得到的是顺时针旋转 90 度吗?
答案是否定的,得到的是逆时针旋转 90°。矩阵变换属于复合变换,操作顺序不能随意调换。
两种方案对比:
- 四向轮换:几何直观模拟旋转,但下标易错;
- 复合基础变换:转置 + 翻转,代码简短,需要能看透坐标映射关系。
感悟:矩阵原地变换,尽量优先使用两两 swap;直接赋值覆盖几乎一定会带来数据丢失问题。
cpp
// 方案1:转置 + 每行翻转(推荐,代码简洁)
#include <algorithm>
class Solution {
public:
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size();
// 主对角线转置
for(int i = 0; i < n; i++)
{
for(int j = 0; j < i; j++)
{
swap(matrix[i][j], matrix[j][i]);
}
}
// 每行逆序
for(int i = 0; i < n; i++)
{
reverse(matrix[i].begin(), matrix[i].end());
}
}
};
cpp
// 方案2:四向轮换
class Solution {
public:
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size();
for(int layer = 0; layer < n / 2; layer++)
{
int first = layer;
int last = n - layer - 1;
for(int j = first; j < last; j++)
{
int offset = j - first;
int A_x = first, A_y = j;
int B_x = last - offset,B_y = first;
int C_x = last, C_y = last - offset;
int D_x = first + offset,D_y = last;
int temp = matrix[A_x][A_y];
matrix[A_x][A_y] = matrix[B_x][B_y];
matrix[B_x][B_y] = matrix[C_x][C_y];
matrix[C_x][C_y] = matrix[D_x][D_y];
matrix[D_x][D_y] = temp;
}
}
}
};
四、搜索二维矩阵 II:暴力遍历到利用矩阵单调性,右上角起点 ≈ 二叉搜索树
编写一个高效的算法来搜索 m
xn 矩阵matrix中的一个目标值target。该矩阵具有以下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
示例 1:
输入:matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5 输出:true
矩阵性质:每行从左到右升序,每列从上到下升序,查找目标值是否存在。
最朴素想法:双重循环全部遍历一遍,肯定能找到答案,但是时间复杂度 O (m*n),完全浪费有序条件。
那能不能像一维数组二分,直接对整个矩阵二分?
答案:不可以。和搜索二维矩阵 I 不一样,本题下一行第一个元素不一定大于上一行最后一个元素,整体不满足全局单调,全局二分失效。
那怎么充分利用「行递增、列递增」的性质? 这里引入一个非常巧妙的视角:我们从主要问题出发(如何在矩阵找到全局单调),既然从左上角不满足全局单调,那我们观察一下:
右上角:往左matrix变小,往下matrix变大
左下角:往右matrix变大,往上matrix变小
右下角:往左往上移动,matrix都变小
可以得出结论:从右上角或者左下角出发,都可以可以得到单调的路径,那么我们选取右上角。
把矩阵右上角看作一棵二叉搜索树 (BST) 的根节点!
- 二叉搜索树:左子树更小,右子树更大;
- 当前矩阵:向左走数值变小,向下走数值变大。
映射关系: 向左移动 → 访问左子树 向下移动 → 访问右子树
操作逻辑:起点选定右上角
- 当前值 == target,直接 return true;
- 当前值 > target:目标一定不在当前列,向左走(访问左子树);
- 当前值 < target:目标一定不在当前行,向下走(访问右子树)。
为什么选右上角?右上角是一行最大值、一列最小值。一次比较直接排除一整行或者一整列,没有选择歧义。 如果随便选左上角,元素小于 target 时,既可以向下又可以向右,无法缩小搜索范围。
这道题没有修改矩阵,不存在数据污染,核心思想是利用矩阵特性剪枝,把二维搜索转化成 BST 单向遍历,避免无脑暴力。
cpp
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.empty() || matrix[0].empty()) return false;
int m = matrix.size();
int n = matrix[0].size();
// 起点:右上角,等价二叉搜索树根
int x = 0, y = n - 1;
while(x < m && y >= 0)
{
if(matrix[x][y] == target)
{
return true;
}
else if(matrix[x][y] > target)
{
y--; // 向左,左子树
}
else
{
x++; // 向下,右子树
}
}
return false;
}
};



