0. 二分查找及变体简介
餐后我们经常会玩儿的猜数字游戏(庄家规定一个数字区间,然后写下一个数,大家猜这个数,每次猜完之后如果没有猜中,庄家更新数字区间),为了能够尽快结束游戏,一般都会使用二分的方式进行报数,这里面的思想就是二分查找。
二分查找是从有序数字序列中找到某个值的一种快速查找方式,至多logn(n代表数字个数)次就可以找到这个数字,查找速度很快。在解决相关算法题目时,如果需要从一个有序区间中寻找到一个满足约束条件的值,那么就可以考虑使用二分查找的思想,例如求一个数的平方根。
1. 最基本的二分查找形式
实现方式包括递归方式和非递归方式,非递归方式用的多,因为递归方式需要生成递归调用栈,空间开销比较大。
实现过程中有几个地方需要注意:
- 循环结束条件一定是 low<=high,因为查找的数字可能位于low或者high位置;
- 在更新中间位置时,low+high在比较极端的情况下可能会产生溢出,需要注意,通常写为:mid = low + (high - low)/2;结果不变,但是这样处理不会产生溢出;
- 对于没有找到值的情况,需要事先确定应该如何返回,通常返回一个比较特殊的数,例如:-1,
// 普通:非递归
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
//加上等号因为要找的数可能在low或high位置
while (low <= high) {
// int mid= low +((high-low)>>1);
// int mid = (low + high) / 2;
// 这里需要注意:采用减法操作可以避免大数溢出
// 另外如果采用移位运算一定要加括号,因为右移的优先级低
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
low = mid + 1;
} else {
high = mid - 1;
}
}
// 如果是面试需要事先跟面试官确认好应该返回什么
return -1;
}
// 递归方式:递归一定注意要有终止条件
// 二分查找的递归实现
public int bsearch(int[] a, int n, int val) {
return bsearchInternally(a, 0, n - 1, val);
}
private int bsearchInternally(int[] a, int low, int high, int value) {
// 注意递归终止的条件,与迭代方式刚好相反
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
// 求平方根:
Public float sqrt(x){
low=0;
high=x;
mid=x/2;
// 这个地方也有可能存在溢出问题,可以采用每次记录上一次的值,判断两次值的差值的方式
// 对于某些苛刻的面试,一般需要考虑到极端情况
// 溢出处理一般可以采用加法变减法比较,乘法变除法比较
while(abs(mid**2-x)>0.000001){
// 判断条件可以改为下面防止溢出
// if(mid < x / mid)
if(mid**2<x) low=mid;
else high=mid;
mid=(low+high)/2;
}
Return mid;
}
2. 查找第一个值(最后一个值)等于给定值的元素
一般情况下数组中保存的数字可能会存在重复的情况,并且在实际应用中,存储的都是对象,只是根据对象的某些值进行排序查找的,所以在这种情况下这样的变形就发挥作用了。
另外有一道这样的算法题,给定一个排好序的数组,找出某个数字出现的次数。这个题目首先想到的肯定是遍历一遍进行统计,这样时间复杂度是O(n)。一般对于比较简单的面试题,面试官想要的一定不是平常的解,对于这个题就可以利用二分的变体找出第一个值等于给定值的元素位置,再找出最后一个值等于给定值的位置,两个值做差+1就可以得到个数,时间复杂度是O(logn),如果数据量很大的情况下,这个提高是非常明显的。
对于这种变体,着重需要注意的就是当找到一个值等于给定值的时候不能直接返回,因为不确定找到的值是不是位于序列(所有相等值构成的序列)的头部或尾部。这个时候如果mid值为0可以直接返回,因为已经是整个序列的最前面了。如果mid-1或者mid+1的值不等于给定值,直接返回,因为该值已经位于序列的最前方或者最后方。其他情况都让mid -1 或者mid +1,因为此时mid的前后还存在相同的值。
按照代码很容易理解其中的逻辑。
// 查找第一个值等于给定值的元素:
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
//mid等于0说明前面没有数了,如果前面还有数且等于value就继续向前找
if ((mid == 0) || (a[mid - 1] != value)) return mid;
else high = mid - 1;
}
}
return -1;
}
// 查找最后一个值等于给定值的元素:
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
// mid等于n-1说明后面没有数了,如果后面还有数且等于value就继续向后找
if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
else low = mid + 1;
}
}
return -1;
}
3. 找出第一个(最后一个)大于等于给定值的元素
该种变体也是针对某些特殊场景而使用的,例如根据手机号码查找归属地的问题等,实现的思路与前面第二小节相似。拿找出第一个大于等于给定值为例,如果a[mid]<value,那么正常处理mid+1.如果a[mid]>=value,这个时候就需要注意了,当前找到的可能不是第一个,这个时候就需要判断,如果mid==0,表明前面没有数了可以直接返回,如果a[mid-1] < value,说明前面的数小于当前值,那么当前值就是所要找的数直接返回,除此之外都需要继续往前找。
// 找出第一个大于等于
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] >= value) {
// 处理的关键,不符合条件继续往前找
if ((mid == 0) || (a[mid - 1] < value)) return mid;
else high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
//查找最后一个小于等于给定值的元素
public int bsearch7(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else {
// 不符合条件继续往后找
if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
else low = mid + 1;
}
}
return -1;
}
4. 总结
- 二分查找的写法比较固定,但是里面需要注意的细节很多,在具体写的时候一定要考虑全面
- 根据题目类型判断是否可以使用二分查找,一般只要是在有序区间中寻找满足指定约束的题目都会用到
- 对于二分查找的变体一定要理解其实现原理