今天劳动节假期,闲来无事,在leetcode看到了有关于二分查找的题目,想起二分查找虽然简单,但是边界条件容易出现问题,因此特别写下这篇文章记录一下。
二分查找
二分查找又称为折半查找,是针对有序数组实现O(logN)的算法。总结起来二分查找有四种情况,从区间闭合情况和中间值取值方式上分,一共可以有四种形式。
[l,r]
在左闭,右闭区间的情况下,退出条件应该包含等于的情况,并且中间每一个步骤都是考虑左闭、右闭的处理方式。对于mid的取值,既可以取上限,也可以取下限。
/**
*mid取下限
*/
public class BinarySearch {
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int m = l + ((r - l) >> 1);
if (nums[m] == target) {
return m;
} else if (nums[m] > target) {
r = m - 1;
} else {
l = m + 1;
}
}
return -1;
}
}
因为如果是左闭、右闭区间,那么l/r两个指针是可能在最后指向同一个元素的,因此如果取上限的方式,加1的操作导致此时的mid不指向这最后一个元素,因此,取上限的方式需要做特殊的处理,不推荐使用。
还有需要注意的是,这种方式在最后剩下两个元素的时候,其实还需要有两个元素需要验证。还有,以mid取下限为例:
- 如果寻找的target小于nums中的所有元素,那么最终r为-1
- 如果寻找的target大于nums中的所有元素,那么最终l为nums.length
- 如果选择的是l < r的判断方式,在跳出循环以后,仍需要比较,而且必须考虑越界的情况
[l,r)
public class BinarySearch {
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length;
while (l < r) {
int m = l + ((r - l) >> 1); //为了可以取到左边,必须取下限
if (nums[m] == target) {
return m;
} else if (nums[m] > target) {
r = m; //依然保持右开的特性
} else {
l = m + 1;
}
}
return -1;
}
}
因为l/r两个指针指向连续两个元素时,因为有一个区间的开的,因此,其实只剩下最后一个元素需要验证了,因此,判断完成以后,直接结束。
(l,r]
public class BinarySearch {
public int search(int[] nums, int target) {
int l = -1;
int r = nums.length - 1;
while (l < r) {
int m = l + ((r - l) >> 1) + 1;//为了可以取到右边,必须取上限
if (nums[m] == target) {
return m;
} else if (nums[m] > target) {
r = m - 1; //维持右闭
} else {
l = m; //维持左开
}
}
return -1;
}
}
最后,附上两个leetcode经典例题
leetcode34. 在排序数组中查找元素的第一个和最后一个位置
class Solution {
public int[] searchRange(int[] nums, int target) {
int l = findLeft(nums, target);
if (l == -1) {
return new int[]{-1, -1};
}
int r = findRight(nums, target, l);
return new int[]{l, r};
}
//寻找最左边的target
private int findLeft(int[] nums, int target) {
int res = -1;
//使用左闭,右闭的形式
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int m = l + ((r - l) >> 1);
if (nums[m] == target) {
res = m;
r = m - 1;
} else if (nums[m] > target) {
r = m - 1;
} else {
l = m + 1;
}
}
return res;
}
private int findRight(int[] nums, int target, int left) {
//此时已经有了l边界,因此直接考虑(l,r]方式 并且必然存在最右
int l = left;
int r = nums.length - 1;
int res = left;
while (l < r) {
int m = l + ((r - l) >> 1) + 1;
if (nums[m] == target) {
res = m;
l = m; // 向右继续逼近
} else if (nums[m] > target) {
r = m - 1;
} else {
l = m;
}
}
return res;
}
}
leetcode 875. 爱吃香蕉的珂珂
class Solution {
public int minEatingSpeed(int[] piles, int h) {
//1.由题目piles.length <= h <= 109条件可得,以所有香蕉堆中最多的速度吃,一定吃的完
//因此可以考虑使用二分法,在[1,max]之间进行判断,找到最小的值
//考虑到极致情况下 必须max速度才可以吃完 使用左闭、右闭区间
int l = 1;
int r = getMax(piles);
int res = 0;
while (l <= r) {
int speed = l + ((r - l) >> 1);
long times = getTimes(piles, speed);
if (times <= h) {
//说明吃的太快,可以放慢速度
res = speed;
r = speed - 1;
} else {
//说明吃的太慢,需要加快速度
l = speed + 1;
}
}
return res;
}
private int getMax(int[] piles) {
int res = 0;
for (int i = 0; i < piles.length; i++) {
res = Math.max(res, piles[i]);
}
return res;
}
//根据speed计算出,吃完所有香蕉所需要的时间
//trick:利用整数的特点完成向上取整 因为如果超出,那么最起码超出1.加上speed - 1 以后就可以实现向上
private long getTimes(int[] piles, int speed) {
//防止数据溢出,使用long
long res = 0;
for (int i = 0; i < piles.length; i++) {
res += ((piles[i] + speed - 1) / speed);
}
return res;
}
}
leetcode 69. x 的平方根
class Solution {
public int mySqrt(int x) {
//判断特殊情况
if (x == 0 || x == 1) {
return x;
}
//排除上述两个情况以后,不可能结果是x本身了,且最小为1,因此使用[l,r)来书写
//这样写
int l = 1;
int r = x;
int res = l;
while (l < r) {
int m = l + ((r - l) >> 1);
//为了避免出现乘法溢出的情况 使用除法
if (m == x / m) {
return m;
} else if (m > x / m) {
r = m; //维持右开
} else {
l = m + 1;
res = m; //使得m * m越来越接近 x
}
}
return res;
}
}
结语
个人认为:二分查找的关键点在于,区间选择、循环条件的结束以及结果更新的时机,做好上述三点,逻辑就会更加清楚。