1894年这个数字在算法领域有着特殊含义——它代表了二分查找算法最早被明确记录的时间。当时法国数学家Edouard Lucas在其著作中首次描述了这种高效的搜索方法。而"东方博弈"这个前缀,暗示了我们将从独特的视角来解析这个经典算法。
二分查找左侧边界问题可以这样定义:在一个有序数组中,存在多个重复的目标值,我们需要找到最左边那个目标值的索引位置。例如在数组[1,2,2,2,3]中查找2,正确的左侧边界应该是索引1而非其他出现位置。
注意:这个问题与标准二分查找的关键区别在于,当找到目标值时不能立即返回,而需要继续向左搜索确认是否存在更早的出现。
传统二分查找在找到目标值时会立即返回,这在无重复元素的数组中完全正确。但当存在重复元素时,这种策略无法保证返回的是最左侧的索引。例如:
python复制def binary_search(nums, target):
left, right = 0, len(nums)-1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid # 可能不是最左侧
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
要实现左侧边界查找,需要在找到目标值时继续收缩右边界:
python复制def left_bound(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
else:
right = mid - 1 # 关键区别:继续向左搜索
# 检查left是否越界或未找到
if left >= len(nums) or nums[left] != target:
return -1
return left
这个实现有三个关键点:
nums[mid] == target时不立即返回,而是让right = mid - 1继续搜索左侧left的合法性left + (right - left) // 2防止整数溢出算法正确性基于循环不变式:目标值如果存在,其最左出现位置一定在[left, right]区间内。每次迭代:
nums[mid] < target,左侧可以安全排除(因为数组有序)nums[mid] > target,右侧可以安全排除nums[mid]可能是目标,但我们不能排除左侧还有更早出现循环结束时,left指向第一个不小于target的元素位置,这正好是左侧边界(如果存在)。
len(nums) == 0的情况left < right而非left <= rightleft的合法性left = mid而非left = mid + 1调试技巧:可以用[1,2,2,2,3]这个测试用例逐步跟踪变量变化,验证算法行为。
类似思路可以实现右侧边界查找,只需在找到目标值时继续向右搜索:
python复制def right_bound(nums, target):
left, right = 0, len(nums)-1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
else:
left = mid + 1 # 关键区别:继续向右搜索
# 需要检查right的合法性
if right < 0 or nums[right] != target:
return -1
return right
标准的二分查找时间复杂度为O(log n),左侧边界版本同样保持这个复杂度。虽然可能需要更多次迭代(当存在大量重复元素时),但时间复杂度量级不变。
迭代实现的空间复杂度是O(1),仅需常数个额外空间存储指针。
mid = (left + right) >> 1比除法更快cpp复制int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.size() || nums[left] != target) {
return -1;
}
return left;
}
java复制public int leftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
}
javascript复制function leftBound(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.length || nums[left] !== target) {
return -1;
}
return left;
}
完整的测试应该包含以下情况:
python复制test_cases = [
([], 1), # 空数组
([1,2,2,2,3], 2), # 正常有重复
([1,3,5], 2), # 目标不存在
([1,3,5], 0), # 目标小于所有
([1,3,5], 6), # 目标大于所有
([2,2,2], 2), # 全相同
([1,2,3,4,5], 3), # 无重复
([1]*10000 + [2], 2), # 大数据集
(list(range(100000)), 99999) # 大范围
]
for nums, target in test_cases:
print(f"Input: {nums}, {target} => Output: {left_bound(nums, target)}")
二分查找体现了分治思想的核心——每次操作都将问题规模减半。这种指数级的缩减使得即使对于大规模数据,也能在极少的步骤内完成搜索。理解这一点有助于将其应用于其他分治算法。
二分查找的思想可以推广到任何具有以下特征的问题:
例如在版本控制中查找引入bug的提交,或在时间序列中查找事件发生点。
在现代CPU架构下,二分查找可能不如线性查找对小数据集高效(由于分支预测失败和缓存不友好)。实践中对于小数组(如n<64),线性扫描可能更快。