谈一谈二分查找及其变形问题
何为二分查找?
二分查找,也称为折半查找,是用于在有序数组中查找特定元素的搜索算法。
二分查找的查找过程是将位于数组中间的元素值与要查找特定值进行比较。若中间元素值小于特定值,则说明该元素位于数组右侧区域,在右侧区域继续折半查找。若中间元素值大于特定值,则说明该元素位于数组左侧区域,在左侧区域继续折半查找。若等于,则返回中间元素的数组下标。
通过上面的示意图,我们可以看出对于一个具有 n 个元素的数组,第一次折半查找后数组范围变为n/2;第二次折半查找后数组范围变为n/4;以此类推,最后数组范围为1。我们可以看出它的变化规律是
最后得出 ,即k = logn ,默认底数为2。因此二分查找算法是一个时间复杂度为O(logn)的算法。这种时间复杂度的算法非常的高效,试想一下如果查找的数据数量达到100万的数据量,也只需要20次折半查找即可。
补充说明:准确来说,对于一个数据量为n的有序数组,查找一个特定元素的比较次数最多为“logn向下取整+1”。以一个数据量为8的为例,三次折半后元素查找范围为1,但仍需要一次比较来判断是否为特定值。若等于则返回数组下标;若不等于,则low会大于high,此时循环结束返回-1,表示未找到元素。
下面列出二分查找的实现
public int BinarySearch(int[] array,int n,int num){
int low = 0;
int high = n-1;
int mid;
while(low<=high){
mid = low+(high-low)/2;
if(array[mid]>num){
high = mid-1;
}else if(array[mid]<num){
low = mid+1;
}else{
return mid;
}
}
return -1;
}
在上面实现中,需注意两点:
循环的结束条件为low<=high ,错写成low<high,会漏掉查找low==high时的元素。
mid 的赋值语句,通常我们往往写成(high+low)/2,虽然语句的含义也是取中间元素,但在计算机执行时high与low之和可能会造成越界。因此正确的写法是 low+(high-low)/2。
在数组元素值为Int型或long型时,还有一种更为高效的计算方式为mid=low+((high-low)>>1), 这里运用了移位的思想。
下面讲解两道有关二分查找的变形问题
问题1. 查找有序数组中第一个等于某个特定值的元素
在原始问题中如果数组内有重复元素,它只能找到其中一个即返回结果。在这里需要返回元素第一次出现的位置。
有人可能会提出一个想法:先通过原始二分查找方法找到该值,然后逐一向前查找。这种方式的弊端是在有些情况下它会等同于顺序查找,时间复杂度变为O(n)。
所以我们还要在二分查找上多思考,也即是在找到一个等于特定值的元素后,还要判断是否可以再继续二分查找。那么什么情况下不再向前二分查找呢?要么此时的mid 已经是第一个元素,没有元素可找了;要么前一个元素值不等于它,因为是有序数组,此时也说明它是第一个要找的元素了。反过来说,如果此时元素位置不是0且与它前一个元素的值相等,则继续二分查找。
把上述思想转换为代码,内容如下:
public int BinarySearchFirst(int[] array,int n,int num){
int low = 0;
int high = n-1;
int mid;
while(low<=high){
mid = low+((high-low)>>2);
if(array[mid]<num){
low = mid+1;
}else if(array[mid]>num){
high = mid-1;
}else{
if(mid>0 && array[mid-1]==array[mid]){
high = mid-1;
}else{
return mid;
}
}
}
return -1;
}
和本题相似的题目还有:查找有序数组中最后一个等于某个特定值的元素;查找有序数组中第一个大于等于某个特定值的元素;查找有序数组中最后一个小于等于某个特定值的元素。这几个问题的解法是一个思路,理解了其中一个,其他的都迎刃而解了。
问题2. 搜索旋转排序数组
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7]
可能变为 [4,5,6,7,0,1,2]
)。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1
。
你可以假设数组中不存在重复的元素。你的算法时间复杂度必须是 O(log n) 级别。
这道题是LeetCode编号33的题目,难度为中等,通过率36.5%。大家可以先思考一下解题思路。
先说说我最直接的一个思路:数组经过旋转之后我们可以把数组看为两部分,前半部分为大数区间,后半部分为小数区间。首先判断数组的中间元素array[mid] 是在大数区间或是小数区间;
如果在大数区间,比如[12,14,16,18,20,22,24,26,2,4,6],这时有三种情况:
- array[mid] < num , 这时num 一定在中间元素右侧。因此有low= mid+1;
- array[mid] >num , 仅根据这个条件num 在左侧或右侧都有可能;那么若有num>= array[0], 则一定在中间元素左侧,即有high=mid-1;否则一定在中间元素右侧,即有low=mid+1;
- array[mid]=num , 表明找到了该元素,返回mid;
如果在小数区间,比如[12,14,16,0,1,2,3,4,5,6,7,8],同样有三种情况:
- array[mid] >num ,这时num一定在中间元素左侧,有high=mid-1;
- array[mid]<num, 这时num 也是有可能在左侧或右侧。此时若有num<=array[n-1],则一定在中间元素右侧,即有low = mid+1;否则在中间元素左侧,有high=mid-1;
- array[mid]=num, 表明找到了该元素,返回mid。
将上述思路转换成代码实现:
public int BinarySearchRotateArray(int[] array,int n,int num){
int low = 0;
int high = n-1;
int mid;
while(low<=high){
mid = low+(high-low)/2;
//判断是在大数区间或小数区间
if(array[mid]>=array[0]){
if(array[mid]<num){
low = mid+1;
}else if(array[mid]>num){
if(num>=array[0]){
high = mid-1;
}else{
low = mid+1;
}
}else{
return mid;
}
}else{
if(array[mid]>num){
high = mid-1;
}else if(array[mid]<num){
if(num<=array[n-1]){
low = mid+1;
}else{
high = mid-1;
}
}else{
return mid;
}
}
}
return -1;
}
举例:[4,5,6,7,12,15,16,18,20,22,0,1,2],查找元素7 二分查找的查找过程: low:0 high:12 mid:6 low:0 high:5 mid:2 low:3 high:5 mid:4 low:3 high:3 mid:3 return 3
之后我看了LeetCode社区中的几种思想,一起分析一下:
第一种思想是要先通过二分查找的方法找到旋转点,然后对两个有序序列分别做二分查找。这种方法能满足题目要求,只是第一次二分查找旋转点的时间复杂度为logn,之后对两个序列二分查找平均情况下也各是log(n/2)。因此在同为O(logn)的思路中这种方法不是最优的。
第二种思路是不论中间元素在哪个位置,总是有一侧是有序的;那么我们对有序的部分采用原始二分查找方法,对无序的部分继续按照这种思路折半。
这种思路理解起来比较清晰,但它的缺点是特定值未必在有序的部分中,那么我们对有序的就直接二分查找是浪费了时间。因此这种方法的时间复杂度为log(n/2)+log(n/4)+log(n/8)+…+log(1)=logn(logn-1)/2 ,不满足题目O(logn)的要求。
那么我们在这个基础上优化一下,对有序的部分先判断目标元素是不是存在有序部分,存在则采用二分查找。这样它的时间复杂度就变为了O(logn)。
经过优化后的代码实现如下所示:
public int BinarySearchRotateArray(int[] array,int n,int num){
int low = 0;
int high = n-1;
int mid;
while(low<=high){
mid = low+(high-low)/2;
if(array[mid]>=array[low]){
if(array[low]<=num && num<=array[mid]) {
return BinarySearch(array, n, low, mid, num);
}else{
low = mid+1;
}
}else{
if(array[mid]<=num && num<=array[high]){
return BinarySearch(array,n,mid,high,num);
}else{
high = mid-1;
}
}
}
return -1;
}
public int BinarySearch(int[] array,int n,int low,int high,int num){
int mid;
while(low<=high){
mid = low+(high-low)/2;
if(array[mid]>num){
high = mid-1;
}else if(array[mid]<num){
low = mid+1;
}else{
return mid;
}
}
return -1;
}