从暴力到Z字形消元:力扣240「搜索二维矩阵II」的降维打击之路
一道题告诉你,为什么从右上角开始搜索是一个"开挂"般的操作
前言
在连续攻克了矩阵的"置零"(73)、"螺旋遍历"(54)和"旋转图像"(48)之后,今天我们迎来了一道看似相似、实则内核完全不同的经典题目------力扣240. 搜索二维矩阵 II(Search a 2D Matrix II)。
这道题在LeetCode上标记为中等(Medium) ,但它的出现频率极高,是Amazon、Google、Microsoft、字节跳动的面试常客。为什么大厂对它青睐有加?因为它完美地考察了候选人对**"有序数据分布"**的敏感度。
很多同学看到"有序矩阵",第一反应是"二分查找",但当你准备写二分时,却发现它和力扣74(搜索二维矩阵 I)有着本质的区别:
- 力扣74 :每行从左到右递增,且下一行的第一个元素大于上一行的最后一个元素(全局有序)。→ 可以直接展平做二分。
- 力扣240 :每行从左到右递增,每列从上到下递增,但下一行的第一个元素不一定大于上一行的最后一个元素(局部有序,类似杨氏矩阵)。
这种"部分有序"的特性,堵死了"全局二分"的偷懒之路,但也催生了一个极其优雅的解法------从右上角开始的Z字形消元。今天,我们就从最暴力的遍历开始,一步步进化到这个"开挂"般的线性解法,让你彻底理解其中的数学之美。
题目回顾
编写一个高效的算法来搜索
m x n矩阵matrix中的一个目标值target。该矩阵具有以下特性:
- 每行 中的整数从左到右按升序排列。
- 每列 中的整数从上到下按升序排列。
示例矩阵:
csharp[ [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;给定target = 20,返回false。约束条件:
m == matrix.length,n == matrix[i].length,1 <= n, m <= 300。
核心难点:为什么不能直接套用二分法?
在力扣74中,因为二维矩阵可以完美展开成一维有序数组,所以我们可以用 mid 映射到 (mid/n, mid%n) 来进行二分。
但在本题中,虽然行和列分别有序,但matrix[i][n-1](行尾)可能大于matrix[i+1][0](下一行行首)。例如示例中,第一行行尾是15,第二行行首是2,15 > 2。这意味着整个矩阵不存在全局单调性,我们无法通过一次二分排除掉一半的数据。
那怎么办呢?既然无法"一刀切",我们就只能利用局部的有序性,每次排除一行或一列。这就是"Z字形消元"的核心出发点。
第一层:暴力遍历法 ------ 最朴素的"地毯式搜索"
不管数据怎么排列,最无脑的方法永远管用:遍历整个矩阵,挨个比较。
java
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[0].length; j++) {
if (matrix[i][j] == target) return true;
}
}
return false;
}
}
复杂度分析:
- 时间复杂度:
O(m * n)。对于最大 300x300 的矩阵,勉强能接受,但如果数据量达到 10^5 级别,直接超时。 - 空间复杂度:
O(1)。
点评: 这道题的暴力法没有任何技术含量,但它确立了我们的底线------无论如何,最坏情况我们总要看完所有元素。接下来的优化,就是看在哪些情况下我们可以提前终止 或者跳过大片区域。
第二层:逐行二分法 ------ 利用行内有序性
既然每一行是有序的,那我们可以对每一行 单独进行二分查找。这个思路虽然还是需要遍历所有行,但每一行的查找效率从 O(n) 降到了 O(log n)。
java
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
for (int[] row : matrix) {
// 简单的剪枝:如果目标不在当前行范围内,直接跳过
if (row[0] > target || row[row.length - 1] < target) {
continue;
}
int left = 0, right = row.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (row[mid] == target) return true;
if (row[mid] < target) left = mid + 1;
else right = mid - 1;
}
}
return false;
}
}
复杂度分析:
- 时间复杂度:
O(m * log n)。比暴力法进步了一大截,尤其是在n很大时效果显著。 - 空间复杂度:
O(1)。
点评: 这是面试中比较容易想到的优化方案,代码也很安全。如果面试官不要求极致优化,这个解法已经能拿80分了。但面试官往往会继续追问:"能不能做到 O(m+n)?"
第三层:Z字形消元法(右上角起点) ------ 真正的"降维打击"
终于到了这道题最精彩的部分!我们可以利用矩阵"行递增、列递增"的特性,从右上角(或左下角)开始,像走迷宫一样一步步逼近目标。
核心思想:把右上角当作"哨兵"
我们选定矩阵的右上角 matrix[0][n-1] 作为起点。观察这个位置的性质:
- 它是当前行(第一行)的最大值。
- 它是当前列(最后一列)的最小值。
基于这个"鞍点"特性,我们可以进行如下逻辑推理:
- 如果
current == target,找到了,返回true。 - 如果
current > target,说明当前列的所有元素(因为从上到下递增,下面的元素都大于等于current)都大于target,所以当前列可以全部排除 ,col--(向左移动)。 - 如果
current < target,说明当前行的所有元素(因为从左到右递增,左边的元素都小于等于current)都小于target,所以当前行可以全部排除 ,row++(向下移动)。
这个逻辑的精妙之处在于: 每一次比较,我们都能确定地排除一整行或一整列 。最多经过 m + n 步,要么找到目标,要么把矩阵"削"成空的。
图解流程
以示例矩阵为例,搜索 target = 5:
| 步骤 | 当前位置 (row, col) | 当前值 | 比较 | 操作 | 排除区域 |
|---|---|---|---|---|---|
| 1 | (0, 4) | 15 | 15 > 5 | 排除第4列(全列都>5) | col → 3 |
| 2 | (0, 3) | 11 | 11 > 5 | 排除第3列 | col → 2 |
| 3 | (0, 2) | 7 | 7 > 5 | 排除第2列 | col → 1 |
| 4 | (0, 1) | 4 | 4 < 5 | 排除第0行(全行都<5) | row → 1 |
| 5 | (1, 1) | 5 | 5 == 5 | 找到! | 返回 true |
注意看,我们只用了 5 步就找到了目标,而矩阵有 25 个元素!
代码实现
java
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return false;
}
int rows = matrix.length;
int cols = matrix[0].length;
// 从右上角出发
int row = 0;
int col = cols - 1;
while (row < rows && col >= 0) {
int current = matrix[row][col];
if (current == target) {
return true;
} else if (current > target) {
// 当前列向下都大于 target,排除该列
col--;
} else {
// 当前行向左都小于 target,排除该行
row++;
}
}
return false;
}
}
python
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
if not matrix or not matrix[0]:
return False
rows, cols = len(matrix), len(matrix[0])
row, col = 0, cols - 1
while row < rows and col >= 0:
if matrix[row][col] == target:
return True
elif matrix[row][col] > target:
col -= 1
else:
row += 1
return False
复杂度分析:
- 时间复杂度:
O(m + n)。最坏情况下,我们可能走遍矩阵的"右上边界"到"左下边界",即m + n步。 - 空间复杂度:
O(1)。
为什么从右下角(左下角)也可以?
- 从左下角
(m-1, 0)出发:它是当前行的最小值、当前列的最大值。如果current > target,排除当前行(row--);如果current < target,排除当前列(col++)。 - 从左上角 或右下角 出发行不行?不行。因为左上角是最小值,它无法确定排除哪一行或哪一列(
current < target时,向右和向下都大于它,无法抉择)。右下角是最大值,同理。
所以,一定要从"一个角上是该行最大且该列最小"或"该行最小且该列最大"的位置出发。这被称为矩阵的"鞍点"属性。
第四层(深度思考):与二分法的结合与变种
虽然 O(m+n) 已经是最优解了,但在某些特定场景下,我们可以把"剪枝"做得更极致。
优化思路:先二分跳跃,再Z字形消元
如果矩阵非常大(例如 m=1000, n=1000),O(m+n) 其实已经非常快了。但如果面试官追问"能不能利用行有序做更多剪枝",你可以回答:
我们可以先对第一列 进行二分查找,找到
target可能出现的行的范围(即matrix[i][0] <= target <= matrix[i][n-1]),然后只在这些候选行中执行逐行二分或Z字形搜索。这在某些数据分布下能进一步优化常数。
不过,因为 O(m+n) 本身就是线性复杂度中的天花板,这种混合优化在实际工程中意义有限,但提出来能展示你对问题的深入思考。
深度总结:三种解法进化图谱
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 | 面试推荐度 |
|---|---|---|---|---|
| 暴力遍历法 | O(m·n) | O(1) | 双重循环遍历所有元素 | ⭐(不满足高效要求) |
| 逐行二分法 | O(m·log n) | O(1) | 利用行内有序,每行二分 | ⭐⭐⭐(稳健且易写) |
| Z字形消元法 | O(m+n) | O(1) | 从右上角"鞍点"出发,每次排除一行或一列 | ⭐⭐⭐⭐⭐(最优解,面试必推) |
从这道题中我们学到了什么?
-
"部分有序"不等于"无法二分"。当全局二分无法使用时,我们要学会利用数据分布的局部特征。本题中,局部特征就是"右上角是行最大列最小",这个"鞍点"是打开局面的钥匙。
-
排除法的力量 。很多时候,我们不需要精确命中,只需要证明某一部分绝对不包含答案,就可以把它丢弃。这道题中的Z字形消元,本质上就是一个"不断缩小搜索空间"的过程。
-
矩阵边角往往是解题的入口 。在力扣的矩阵题中(旋转48、螺旋54、置零73),边界总是扮演着特殊角色。对于搜索类矩阵题,从边界入手往往能发现独特的单调性。
-
复杂度从 O(m·n) 到 O(m+n) 的跃迁。把二维的乘积复杂度降维成一维的加法复杂度,这就是"降维打击"的威力所在。在面试中,能讲清楚为什么每一步能排除一整行/列,是打动面试官的关键。
最后的一些心里话
力扣240和力扣74放在一起对比学习,效果翻倍。力扣74教你"全局有序怎么二分",力扣240教你"局部有序怎么消元"。如果你能在面试中把这两道题的异同点(全局有序 vs 杨氏矩阵)给面试官分析清楚,绝对是一个大大的加分项。
下次遇到这种"行递增、列递增"的矩阵,别再傻傻地写二分或者暴力了。记住今天的口诀:
右上角,站岗哨;大了左移,小了下跳。
这条"Z"字形的路径,就是通往最优解的最短路。
如果你觉得这篇题解帮你彻底搞懂了搜索二维矩阵,欢迎点赞、收藏、转发!评论区聊聊你还见过哪些从"角"入手的巧妙算法题? 🚀