
矩阵的两个性质保证了:将矩阵按行展开成一维数组后,数组是严格递增的 。因此我们可以直接用二分查找,将二维坐标映射为一维索引,时间复杂度为 O(log(mn)),空间复杂度为 O(1)。
坐标映射公式
- 一维索引
idx对应二维坐标:- 行号:
row = idx / n(n为矩阵列数) - 列号:
col = idx % n
- 行号:
- 二分查找边界:
left = 0,right = m * n - 1
为什么要用 (left + right) >>> 1?
很多新手在写二分查找时,会用最直观的写法:
int mid = (left + right) / 2;
但在工业级代码(如 JDK 源码)中,几乎看不到这种写法,取而代之的是:
int mid = (left + right) >>> 1;
// 或
int mid = left + (right - left) / 2;
1. 整数溢出
Java 中 int 类型是 32 位有符号整数,取值范围为 [-2^31, 2^31 - 1],即 [-2147483648, 2147483647]。
当 left 和 right 都接近 int 最大值时:
int left = 2147483647;
int right = 2147483647;
// left + right = 4294967294,超出 int 范围,溢出为负数
int sum = left + right; // sum = -2
int mid = sum / 2; // mid = -1,完全错误!
在二分查找中,错误的负数索引会直接导致数组越界异常(ArrayIndexOutOfBoundsException),程序崩溃。
2. >>> 无符号右移:从根源解决溢出
Java 提供了两种右移运算符:
>>:有符号右移,高位补符号位(正数补 0,负数补 1),等价于除以 2(向下取整)>>>:无符号右移,无论正负,高位统一补 0,将数值视为 32 位无符号整数处理
原理验证
对于溢出后的负数 sum = -2(二进制补码为 11111111 11111111 11111111 11111110):
sum >> 1:高位补 1,结果为-1(错误)sum >>> 1:高位补 0,结果为2147483647(正确,即(2147483647 + 2147483647) / 2 = 2147483647)
对于非负整数,(left + right) >>> 1 完全等价于 (left + right) / 2,但永远不会出现溢出问题,完美解决了二分查找的索引计算风险。
3. 三种写法的全面对比
| 写法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
(left + right) / 2 |
可读性高 | 存在整数溢出风险,不安全 | 仅适用于 left + right 不超过 int 最大值的场景 |
left + (right - left) / 2 |
无溢出,可读性较好 | 运算步骤多,性能略差 | 所有场景,新手友好 |
(left + right) >>> 1 |
无溢出、代码简洁、性能最优 | 新手可读性差 | 工业级代码、JDK 源码、高性能场景 |
延伸:二分查找的其他安全写法
除了无符号右移,还有一种经典的安全写法:
int mid = left + (right - left) / 2;
原理
通过变形避免直接相加:
left + (right - left) / 2 = (2left + right - left) / 2 = (left + right) / 2
这种写法从根源上避免了 left + right 的溢出,可读性比 >>> 更高,适合新手理解,也是面试中的标准写法。
性能对比
(left + right) >>> 1:仅需一次加法 + 一次位运算,JVM 可直接优化为机器指令,性能最优left + (right - left) / 2:需要一次减法 + 一次除法 + 一次加法,性能略差,但现代 JVM 优化后差异极小
实战:搜索二维矩阵的完整优化
1. 边界处理
代码中必须处理矩阵为空、单行、单列等极端情况,避免空指针异常:
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return false;
}
2. 二分查找的循环条件
while (left <= right):适用于闭区间[left, right],每次循环都会检查mid,最终left > right时退出,代表未找到while (left < right):适用于开区间[left, right),需要额外处理最后一个元素,容易出错,不推荐新手使用
3. 示例验证
以题目示例矩阵为例:
[
[1, 3, 5, 7],
[10, 11, 16, 20],
[23, 30, 34, 60]
]
- 展开为一维数组:
[1, 3, 5, 7, 10, 11, 16, 20, 23, 30, 34, 60] - 查找
target = 3:mid最终命中索引1,对应row=0, col=1,返回true - 查找
target = 13:遍历完所有区间未命中,返回false
总结
- 核心结论 :
int mid = (left + right) >>> 1是二分查找中安全、高效、无溢出的中间索引计算方法,是 JDK 源码的标准写法。 - 原理本质 :利用无符号右移
>>>将溢出的负数视为无符号整数,保证计算结果正确,同时保持最优性能。 - 替代方案 :
left + (right - left) / 2是新手友好的安全写法,可读性更高,适合面试场景。 - 应用场景:所有二分查找算法(如搜索二维矩阵、有序数组查找、旋转数组查找等)都应使用安全写法,避免整数溢出。
拓展思考
1. 为什么题目中的「非严格递增」不影响二分?
虽然每行是「非严格递增」,但「每行第一个元素大于前一行最后一个元素」的性质,保证了整个矩阵展开后是严格递增的,因此二分查找的逻辑完全成立。
2. 其他语言的对应写法
- Python :Python 整数无溢出,直接用
mid = (left + right) // 2即可 - C++ :用
mid = left + (right - left) / 2避免溢出,无>>>运算符 - JavaScript :用
mid = Math.floor((left + right) / 2),注意位运算会截断为 32 位整数
3. 二分查找的常见坑
- 整数溢出:必须使用安全写法计算
mid - 循环条件错误:
left <= right与left < right的区别 - 边界更新错误:
left = mid + 1/right = mid - 1与left = mid/right = mid的选择 - 坐标映射错误:行号 / 列号的计算逻辑(
mid / n与mid % n)
完整代码
/**
* 搜索二维矩阵
* @param matrix 满足行内非严格递增、行首严格大于前行尾的矩阵
* @param target 目标值
* @return target 是否在矩阵中
*/
public boolean searchMatrix(int[][] matrix, int target) {
// 边界处理:矩阵为空、行空、列空,直接返回false
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return false;
}
int m = matrix.length; // 矩阵行数
int n = matrix[0].length; // 矩阵列数
int left = 0; // 二分左边界(一维索引)
int right = m * n - 1; // 二分右边界(一维索引)
// 闭区间二分查找:[left, right]
while (left <= right) {
// 安全计算中间索引:无符号右移,避免int溢出
int mid = (left + right) >>> 1;
// 一维索引转二维坐标
int row = mid / n;
int col = mid % n;
int current = matrix[row][col];
if (current == target) {
// 找到目标值,返回true
return true;
} else if (current < target) {
// 目标值在右半区间,更新左边界
left = mid + 1;
} else {
// 目标值在左半区间,更新右边界
right = mid - 1;
}
}
// 遍历完所有区间未找到,返回false
return false;
}