1894年这个时间点让我联想到计算机科学史上的一个重要分水岭——在算法领域,二分查找(Binary Search)作为一种经典算法,其核心思想最早可追溯至1946年约翰·莫奇利的论文。而"东方博弈"这个前缀,暗示着我们需要从东方思维的角度重新审视这个西方经典的算法实现。
二分查找左侧边界问题在实际开发中极为常见。比如在电商系统中查询某价格区间的商品时,我们需要找到第一个不低于最低价的商品索引;在日志分析中定位某个时间戳首次出现的位置;在游戏开发中确定玩家分数对应的排名区间等。
典型问题描述:给定一个升序排列的整数数组 nums 和目标值 target,找出 target 在数组中最左边出现的索引。如果 target 不存在,则返回它应该被插入的位置。例如:
我们先看标准的二分查找实现(Java示例):
java复制int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
这个实现有几个明显问题:
受东方哲学"守中致和"思想启发,我们调整算法策略:
java复制int leftBound(int[] nums, int target) {
int left = 0, right = nums.length; // 注意右边界
while (left < right) { // 注意条件
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid; // 收紧右边界
} else {
left = mid + 1;
}
}
return left;
}
关键改良点:
注意:这个实现中返回的left可能等于nums.length(当所有元素都小于target时),调用方需要根据业务场景判断是否越界
我们定义循环不变式为:目标索引(或插入位置)始终在[left, right]区间内。需要证明:
每次迭代都将搜索区间减半:
空间复杂度为O(1),仅使用固定数量的额外空间。
java复制if (nums == null || nums.length == 0) {
return 0; // 插入位置为0
}
java复制int result = leftBound(nums, target);
if (result == nums.length || nums[result] != target) {
System.out.println("Target not found, insert position: " + result);
} else {
System.out.println("First occurrence at index: " + result);
}
计算mid时使用left + (right - left) / 2而非(left + right) / 2,避免left+right可能导致的整数溢出。
假设玩家分数数组为[100,200,200,300,400],查找分数为200的排名:
java复制int rank = leftBound(scores, 200) + 1; // 返回2(第二名)
商品价格数组[99,199,199,299,399],查找第一个不低于200的商品:
java复制int first = leftBound(prices, 200); // 返回3(299元)
日志时间戳[1630000000,1630001000,1630002000],查找第一个不小于1630001500的日志:
java复制int logIndex = leftBound(timestamps, 1630001500);
类似思路可以实现右侧边界查找(返回最后一个等于target的索引+1):
java复制int rightBound(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left - 1; // 注意减1
}
查找第一个满足条件的元素(如第一个大于等于target的):
java复制int firstGreaterOrEqual(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
错误示例:
java复制while (left <= right) { // 错误的条件
if (nums[mid] >= target) {
right = mid; // 可能导致left == right时无限循环
}
// ...
}
解决方法:保持循环条件与边界更新的一致性,要么都用闭区间,要么用左闭右开。
常见错误是直接返回mid而不验证:
java复制if (nums[mid] == target) {
return mid; // 可能不是最左边的
}
正确做法:继续收缩右边界直到循环结束。
必备测试场景:
对于特别大的数组,可以手动展开循环(以4次迭代为例):
java复制while (right - left >= 4) {
int mid1 = left + (right - left) / 4;
int mid2 = left + (right - left) / 2;
int mid3 = left + 3*(right - left) / 4;
if (nums[mid2] >= target) {
right = mid2;
} else if (nums[mid1] >= target) {
right = mid1;
} else if (nums[mid3] >= target) {
left = mid2 + 1;
right = mid3;
} else {
left = mid3 + 1;
}
}
// 处理剩余少量元素
将常见情况放在前面:
java复制if (nums[mid] < target) { // 假设小于的情况更常见
left = mid + 1;
} else {
right = mid;
}
Python实现注意点:
python复制def left_bound(nums, target):
left, right = 0, len(nums) # 右开区间
while left < right:
mid = (left + right) // 2 # Python整数无溢出
if nums[mid] >= target:
right = mid
else:
left = mid + 1
return left # 可以处理大整数情况
二分查找的演变历程:
对于部分旋转的有序数组如[4,5,6,1,2,3],如何查找target?需要先找到旋转点再分段查找。
在行和列都排序的矩阵中,如何高效查找?可以从右上角开始类似二分查找的移动。
当需要查找最接近target的值时,可以在循环中记录最近值,或修改终止条件。
在实际工程中,二分查找左侧边界的实现看似简单,但魔鬼藏在细节中。我曾在一次线上事故中因为忽略了数组越界检查,导致服务崩溃。后来养成了习惯:对所有二分查找实现,都会额外测试边界条件和异常场景。记住,好的算法工程师不是能写出最花哨的代码,而是能写出最健壮的代码。