不知道你有没有这种刷题错觉 ------一维数组的二分查找我熟得不行,模板都能背下来了。可一碰到二维矩阵的查找题,脑子瞬间就短路,第一反应就是写两层循环暴力搜。
我第一次刷这道题的时候就是这样,噼里啪啦写完遍历,提交一看通过了,正准备跳下一题,突然瞥见题目要求 O (log (m * n)) 的复杂度。合着我写了个不符合要求的解法,白高兴一场。
其实这题真的是纸老虎,剥开二维的外壳,里面就是最基础的二分查找。
先快速说清题意
给你一个 m×n 的整数矩阵,满足两个规则:
- 每一行从左到右严格递增
- 下一行的第一个数,一定比上一行最后一个数还要大
再给你一个目标数 target,判断 target 是否存在于矩阵中。核心要求:时间复杂度必须是对数级别,不能暴力遍历。
思路一:先找行,再行内搜(两次二分)
这是我最开始想到的路子,比较符合直觉,脑子不用拐太多弯。既然每一行的第一个数都是递增的,那第一步先用二分,锁定 target 可能落在哪一行。
具体怎么找行呢?不是找行首等于 target 的,那大概率找不到。我们要找的是:行首元素小于等于 target 的最后一行。
比如示例里 target = 16,三行的行首分别是 1、10、23。小于等于 16 的行首有 1 和 10,最后一个是第 1 行,那 target 就只可能在第 1 行里。如果 target 比第一行的行首还小,那直接就可以返回 false 了。
这个方法我踩过一个小坑:最开始找完行,我直接就进去二分了,忘了判断 target 是不是比这一行的最后一个元素还大。比如 target = 21,按规则会分到第 1 行,但第 1 行最大是 20,这时候直接返回 false 就行,不用再做无用的二分。加这个小判断既能省时间,也能避免一些边界错误。
锁定行之后,这一行本身就是个有序数组,再做一次普通的二分查找就搞定了。
这个写法好处是思路顺,一步步来不容易乱;坏处就是要写两套二分逻辑,代码稍长一点。
两次二分代码实现
javascript
var searchMatrix = function(matrix, target) {
const m = matrix.length;
if (m === 0) return false;
const n = matrix[0].length;
// 第一次二分:找目标所在的行
let top = 0, bottom = m - 1;
let targetRow = -1;
while (top <= bottom) {
const midRow = Math.floor((top + bottom) / 2);
const firstNum = matrix[midRow][0];
if (firstNum === target) {
// 刚好等于行首,直接命中
return true;
} else if (firstNum < target) {
// 这行有可能,先记下来,再往下看看有没有更合适的
targetRow = midRow;
top = midRow + 1;
} else {
bottom = midRow - 1;
}
}
// 没找到符合条件的行,说明target比所有数都小
if (targetRow === -1) return false;
// 小优化:target比这行最后一个数还大,直接不用搜了
if (matrix[targetRow][n - 1] < target) return false;
// 第二次二分:在目标行里精确查找
let left = 0, right = n - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const num = matrix[targetRow][mid];
if (num === target) {
return true;
} else if (num < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return false;
};
找行的二分写法有好几种,我这种是遇到符合条件就记录行号的写法,个人觉得最好理解,不容易写错边界。
思路二:拍平成一维,一次二分搞定
这个是我复盘时发现的更优雅的写法,也是我现在写题时更偏爱的。
你仔细品题目给的两个条件:每行递增 + 下一行开头大于上一行结尾。这不就等于说,把所有行按顺序拼在一起,就是一个从头到尾完全有序的一维数组吗?
既然是有序数组,那不就是标准二分查找的主场?唯一要处理的,就是把一维数组的索引,换算回二维矩阵的行和列。
换算公式很简单,假设总列数是 n,一维索引是 mid:
- 行号 = Math.floor (mid /n)
- 列号 = mid % n
这个坑我实打实踩过!一开始手滑写成了除以行数 m,结果直接数组越界,debug 了五分钟才反应过来。记住,是除以列数,因为一行有 n 个元素,每满 n 个就换下一行。
一次二分代码实现
javascript
var searchMatrix = function(matrix, target) {
const m = matrix.length;
// 空矩阵直接返回,边界情况别漏了
if (m === 0) return false;
const n = matrix[0].length;
let left = 0;
let right = m * n - 1; // 虚拟一维数组的右边界
while (left <= right) {
const mid = Math.floor((left + right) / 2);
// 核心:一维索引转二维坐标
const row = Math.floor(mid / n);
const col = mid % n;
const num = matrix[row][col];
if (num === target) {
return true;
} else if (num < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 循环跑完都没找到
return false;
};
复杂度分析
两种写法的时间复杂度其实是等价的。两次二分是 O (log m + log n),一次二分是 O (log (m * n)),根据对数运算法则,这俩完全相等,都符合题目 O (log (m * n)) 的要求。空间复杂度都是 O (1),只用到了几个指针变量。
最后唠两句
这道题说穿了,考的根本不是什么二维数组高级算法,而是你能不能识别出 "全局有序" 这个隐藏条件。很多人一看到矩阵就发怵,觉得肯定有复杂玩法,其实都是纸老虎。
刷题刷多了你就会发现,很多中等题都是 "换皮题",把你熟悉的知识点换个场景考你。能不能看破本质,就是能不能快速解题的关键。
至于两种写法选哪个?面试的时候如果你紧张,怕坐标换算搞混,老老实实写两次二分绝对没问题,一样是满分答案,思路清晰还不容易出错。如果想写得简洁帅气,那就用拍平成一维的写法,代码短,看起来也更有水平。
两个我踩过的坑再提醒大家一遍:
- 边界情况别漏,空矩阵、只有一行、只有一列都要考虑到
- 用一次二分的话,坐标映射一定是除以列数 n,别和行数 m 搞反
你第一次做这题的时候,第一反应是暴力还是二分?更习惯用两次二分还是一次拍平的写法?评论区唠唠你的刷题趣事~觉得有用的话点个赞,后面继续更更多二分变种题的题解!