1. 二分算法为什么值得学?
第一次听说二分查找(Binary Search)是在大学数据结构课上,教授用"猜数字游戏"举例:假设你需要在1-100之间猜一个预设的数字,每次猜测后会被提示"大了"或"小了",最优策略就是从中间值50开始猜。这个看似简单的策略,背后正是二分思想的精髓——通过每次排除一半的无效区域,将问题规模指数级缩小。
在实际开发中,我遇到过一个典型场景:用户行为日志的时间范围查询。当需要从10亿条按时间排序的日志中快速定位某个时间点时,线性扫描需要平均5亿次比较,而二分查找仅需30次(log₂10⁹≈29.9)。这种效率差异在数据量大时会产生质的飞跃——从不可行变为可行。
注意:二分查找的前提是数据必须有序。如果面对无序数据,先排序再二分的总时间复杂度可能不如直接线性扫描。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标准二分查找的实现细节
2.1 基础框架与边界处理
以下是Java的标准实现模板,我习惯用左闭右开区间表示法:
java复制int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length; // 注意:右边界不包含
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; // 右开
}
}
return -1;
}
这里有几个关键点:
- 右边界开闭选择:右开区间(right=nums.length)比右闭区间(right=nums.length-1)更不容易出错,因为循环条件统一为left < right
- 防整数溢出:
mid = (left + right)/2的写法在left+right超过2³¹-1时会溢出,应改用减法形式 - 终止条件:当left == right时,搜索空间为空,循环终止
2.2 测试用例设计要点
我曾在一个项目调试中花费3小时才定位到二分查找的边界错误,从此养成了必测以下场景的习惯:
| 测试场景 | 示例输入 | 预期输出 |
|---|---|---|
| 目标在数组头部 | [1,3,5], 1 | 0 |
| 目标在数组尾部 | [1,3,5], 5 | 2 |
| 目标不存在 | [1,3,5], 2 | -1 |
| 空数组 | [], 1 | -1 |
| 全相同元素 | [2,2,2], 2 | 0或1或2 |
| 偶数长度数组 | [1,3,5,7], 3 | 1 |
实测经验:全相同元素的情况最能暴露边界处理问题,建议单独测试
3. 二分算法的四大高阶变种
3.1 寻找左边界
当数组中有重复元素时,可能需要找到最左侧的target。修改条件判断:
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 < nums.length && nums[left] == target ? left : -1;
}
关键变化:当nums[mid] == target时继续向左收缩(right=mid),这样循环结束时left指向第一个等于target的位置。
3.2 寻找右边界
类似地,寻找最后一个等于target的位置:
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 > 0 && nums[left-1] == target ? left-1 : -1;
}
这里有个易错点:返回的是left-1而非left,因为循环终止时left指向的是第一个大于target的位置。
3.3 旋转数组搜索
当有序数组被旋转后(如[4,5,6,7,0,1,2]),依然可以用二分:
python复制def search(nums, target):
left, right = 0, len(nums)-1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
# 左半部分有序
if nums[left] <= nums[mid]:
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
else: # 右半部分有序
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1
return -1
这个算法的精妙之处在于:虽然整体无序,但mid总会将数组分成一个有序部分和一个可能有序部分,通过比较target与有序部分的边界值来决定搜索方向。
3.4 答案二分法
有些问题需要在一个范围内二分查找最优解,如"吃香蕉问题":
有N堆香蕉,每小时最多吃K个,要在H小时内吃完,求最小的K
java复制int minEatingSpeed(int[] piles, int H) {
int left = 1;
int right = Arrays.stream(piles).max().getAsInt();
while (left < right) {
int mid = left + (right - left)/2;
if (canFinish(piles, mid, H)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
boolean canFinish(int[] piles, int speed, int H) {
int time = 0;
for (int p : piles) {
time += (p + speed - 1) / speed; // 向上取整
}
return time <= H;
}
这类问题的特点是:
- 解空间有序(吃香蕉速度越大,所需时间越少)
- 存在一个判定函数(canFinish)来检查当前解是否可行
- 需要找满足条件的最小/最大值
4. 工程实践中的常见陷阱
4.1 死循环问题
在实现二分时,我曾遇到过因mid计算不当导致的死循环。例如:
java复制// 错误示范
while (left < right) {
int mid = (left + right)/2; // 当left=3, right=4时 mid=3
if (shouldGoLeft()) {
right = mid; // 仍然是right=3
} else {
left = mid; // 死循环
}
}
解决方案:
- 确保区间一定会缩小:left=mid+1或right=mid-1
- 使用
mid = left + (right - left + 1)/2在特定情况下
4.2 浮点数二分精度控制
计算平方根时的二分实现:
python复制def sqrt(x, epsilon=1e-6):
left, right = 0, x
while right - left > epsilon:
mid = (left + right) / 2
if mid * mid < x:
left = mid
else:
right = mid
return (left + right) / 2
关键点:
- 终止条件改为区间长度小于epsilon
- 不需要处理整数溢出的问题
- 对于x<1的情况,右边界应设为1而非x
4.3 复杂条件的二分
在电商价格筛选功能中,可能需要实现这样的查询:"找出价格大于等于100且评价数超过50的商品中最便宜的"。这类复合条件的查询可以通过自定义比较函数实现:
java复制List<Product> products = ...; // 已按价格排序
int left = 0, right = products.size();
while (left < right) {
int mid = left + (right - left)/2;
if (products.get(mid).price >= 100
&& products.get(mid).reviewCount >= 50) {
right = mid;
} else {
left = mid + 1;
}
}
这种写法利用了价格已排序的特性,找到第一个满足复合条件的产品就是最便宜的合格产品。
