在LeetCode的数组类题目中,「搜索二维矩阵」是一道经典的二分查找应用题,核心考察对有序结构的利用和二分思想的灵活运用。题目给出的矩阵有两个关键特性:每行从左到右非严格递增,且每行第一个元素大于前一行最后一个元素。这两个特性决定了我们可以用高效的二分查找替代暴力遍历,将时间复杂度从O(mn)优化到O(log(mn))或O(logm + logn)。
今天就来拆解这道题的两种主流解题方法,结合代码逐行解析,帮大家理清思路、吃透细节,无论是面试还是日常刷题都能轻松应对。
题目回顾
给定一个m x n的整数矩阵,满足:
-
每行中的整数从左到右按非严格递增顺序排列。
-
每行的第一个整数大于前一行的最后一个整数。
给定一个整数target,判断target是否在矩阵中,存在返回true,否则返回false。
示例:若矩阵为[[1,3,5,7],[10,11,16,20],[23,30,34,60]],target=3则返回true,target=13则返回false。
思路一:将二维矩阵“扁平化”为一维数组(最优解)
观察题目给出的矩阵特性,我们会发现一个关键:整个矩阵可以看作是一个“有序的一维数组”。因为每行递增,且下一行的第一个元素大于上一行最后一个,相当于把所有行拼接起来,就是一个严格递增的一维数组。
基于这个特性,我们可以直接对这个“虚拟的一维数组”做二分查找,无需额外处理行和列的关系,只需要将一维数组的索引与二维矩阵的行、列做映射即可。
代码实现(TypeScript)
function searchMatrix_1(matrix: number[][], target: number): boolean {
// 边界判断:矩阵为空或第一行为空,直接返回false
if (!matrix || !matrix[0]) {
return false;
}
const m = matrix.length; // 矩阵的行数
const n = matrix[0].length; // 矩阵的列数
let low = 0, high = m * n - 1; // 虚拟一维数组的左右边界(索引从0到m*n-1)
// 二分查找核心逻辑
while (low <= high) {
let mid = Math.floor((high + low) / 2); // 中间索引(一维数组)
// 关键:将一维索引mid映射到二维矩阵的行和列
// 行索引 = Math.floor(mid / n)(相当于mid除以列数取整)
// 列索引 = mid % n(相当于mid除以列数取余)
let x = matrix[Math.floor(mid / n)][mid % n];
if (x < target) {
// 目标值在右半部分,缩小左边界
low = mid + 1;
} else if (x > target) {
// 目标值在左半部分,缩小右边界
high = mid - 1;
} else {
// 找到目标值,直接返回true
return true;
}
}
// 循环结束仍未找到,返回false
return false;
};
核心细节解析
-
边界处理:首先判断矩阵是否为空(matrix为null/undefined)或第一行是否为空(matrix[0]为空),这两种情况直接返回false,避免后续索引越界。
-
索引映射:这是该思路的核心。假设虚拟一维数组的索引为mid,那么对应的二维矩阵行索引是Math.floor(mid / n)(因为每一行有n个元素,每n个索引对应一行),列索引是mid % n(取余得到当前行内的位置)。
-
时间复杂度:O(log(mn)),因为二分查找的次数是log2(mn),每次查找仅做一次索引映射和数值比较,时间为O(1)。
-
空间复杂度:O(1),仅使用了几个变量存储边界和中间值,没有额外开辟空间。
思路二:两次二分查找(先找行,再找列)
如果觉得“扁平化”的索引映射不好理解,还可以采用更直观的两次二分查找:第一步先找到target可能所在的行,第二步在该行中查找target是否存在。
第一步(找行):遍历矩阵的行首元素,找到“最后一个行首元素 ≤ target”的行,因为矩阵每行递增且下一行首元素大于上一行尾元素,target如果存在,一定在这一行。
第二步(找列):在找到的目标行中,用二分查找判断target是否存在。
代码实现(TypeScript)
function searchMatrix_2(matrix: number[][], target: number): boolean {
// 边界判断:矩阵为空或第一行为空,直接返回false
if (!matrix || !matrix[0]) {
return false;
}
const m = matrix.length;
const n = matrix[0].length;
// 第一步:二分查找目标行(返回target可能所在的行索引,找不到返回-1)
const searchRow = (): number => {
let low = 0, high = m - 1;
let targetRow = -1; // 初始化目标行为-1(表示未找到)
while (low <= high) {
const mid = Math.floor((high + low) / 2);
if (matrix[mid][0] <= target) {
// 当前行首元素 ≤ target,可能是目标行,继续向右查找更合适的行
low = mid + 1;
targetRow = mid; // 更新目标行
} else if (matrix[mid][0] > target) {
// 当前行首元素 > target,目标行在左半部分
high = mid - 1;
}
}
return targetRow;
}
const row = searchRow();
if (row === -1) {
// 没有找到符合条件的行,直接返回false
return false;
}
// 第二步:在目标行中二分查找target
const searchCol = (): boolean => {
let low = 0, high = n - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (matrix[row][mid] < target) {
low = mid + 1;
} else if (matrix[row][mid] > target) {
high = mid - 1;
} else {
return true;
}
}
return false;
}
return searchCol();
};
核心细节解析
-
找行逻辑:searchRow函数中,我们始终记录“最后一个行首元素 ≤ target”的行,因为如果target存在,必然在这一行(下一行的行首元素大于target,不可能在下行)。如果循环结束后targetRow仍为-1,说明所有行首元素都大于target,target不存在。
-
找列逻辑:searchCol函数就是标准的一维数组二分查找,在目标行中遍历列,判断target是否存在。
-
时间复杂度:O(logm + logn),两次二分查找分别消耗O(logm)和O(logn),整体与思路一的O(log(mn))等价(因为log(mn) = logm + logn)。
-
空间复杂度:O(1),同样没有额外开辟空间,仅使用局部变量。
两种思路对比与总结
| 思路 | 核心逻辑 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|---|
| 思路一(扁平化) | 将矩阵看作一维数组,一次二分查找 | O(log(mn)) | O(1) | 代码简洁,索引映射是关键,稍难理解 |
| 思路二(两次二分) | 先找行,再找列,两次二分查找 | O(logm + logn) | O(1) | 逻辑直观,分步清晰,容易理解 |
解题关键总结
这道题的核心是利用矩阵的“有序性”——无论是将其扁平化看作一维有序数组,还是分两步找行、找列,本质都是二分查找思想的应用。解题时需要注意两个关键点:
-
边界处理:必须先判断矩阵是否为空、行是否为空,避免索引越界。
-
索引映射(思路一)/ 行判断(思路二):这是两种思路的核心,也是容易出错的地方,需要熟练掌握。