算法原理及实现
算法原理
二分查找又可以称为折半查找,算法的基本思想是基于一个有序数组(本文默认以升序为例),每次
拿中间的元素和目标元素比较,根据比较结果将问题的规模缩小到原问题的一半,直到找到结果,或者找不到目标值为止。
算法过程如下:
找到数组的中间元素与目标值进行比较
如果相等,则查找成功,返回该元素的索引。
如果目标值大于中间元素,则排除该数组的前半部分,后续以该数组的后半部分作为新的搜索数组。
如果目标值小于中间元素,则排除该数组的后半部分,后续以该数组的前半部分作为新的搜索数组。
从第一步开始对新的数组再次进行比较
二分查找之所以效率高,是因为每次查找失败的时候,总是可以排除一半的元素。而传统的顺序查找每次仅仅只能排除一个元素,比较之后高低立判。
时间复杂度推导:
假设有序数组a中的元素个数为n,取 a[n / 2] 与目标值x 作比较,如果x = a[n / 2],则找到目标值,算法结束,如果 x < a[n / 2] ,则只需要在数组a 的前半部分继续搜索x,反之在后半部分搜索。
所以时间复杂度就是比较次数。一共n个元素,每次排除一半,可以推出公式 => n / 2,n / 4 , n / 8 ... n / 2^k,其中k 就是比较的次数。因为k 肯定是大于0的(比较次数不可能小于0吧),那么2^k 肯定满足2^k ≥ 1 ,所以可以推出 n / 2^k ≥ 1 ,令 n / 2^k = 1 ,可以得到 k = log2n(以2为底,n 的对数),所以时间复杂度可以表示为O(log2n)。
二分查找代码实现
/**
* 二分查找,前提是arr有序
* @param arr
* @param target
* @return
*/
public static int binarySearch(int[] arr, int target) {
//左边界
int left = 0;
//右边界
int right = arr.length - 1;
int targetIndex = -1; //记录匹配元素的索引位置
while (left <= right) {
//中间位置
// int mid = (left + right) / 2;
int mid = left + ((right - left) >> 1);//这种写法可以防止溢出,并且位运算的性能较好
if (arr[mid] > target) {
//中间元素大于目标值,排除后半部分,right指向中间位置的前一个位置
right = mid - 1;
} else if (arr[mid] < target) {
//中间元素小于目标值,排除前半部分,left指向中间位置的后一个位置
left = mid + 1;
} else {
//相等,直接跳出循环
targetIndex = mid;
break;
}
}
return targetIndex;
}
代码很简单,不过还是有一些小细节需要注意:
关于mid的取值,为什么最好不要写成 mid = (left + right) /2 ,因为这种写法有缺陷,left和right相加的结果可能会溢出。
循环的条件是 left ≤ right 而不是 left < right,因为right 的值为 arr.length - 1,如果是< ,则会丢失比较right和left 重合位置的元素。
基于递归实现二分查找
/**
* 基于递归的二分查找
*
* @param arr 有序数组
* @param left 左边界
* @param right 右边界
* @param target 目标值
* @return
*/
public static int internallyBinarySearch(int[] arr, int left, int right, int target) {
//找中间位置
int mid = left + ((right - left) >> 1);
if (arr[mid] == target) {
return mid;
}
if (left >= right) {
return -1;
} else if (target > arr[mid]) {
//目标值大于中间元素,递归的搜索后半部分
return internallyBinarySearch(arr, mid + 1, right, target);
} else {
//目标值小于中间元素,递归的搜索前半部分
return internallyBinarySearch(arr, left, mid - 1, target);
}
}
递归调用的思路和普通的方式其实本质上一样,区别就在于递归在处理大于或者小于目标值的情况时,直接把数组还有限定的范围指明,然后再进行方法调用,比较的逻辑是没有变的。
二分查找变体
查找第一个大于等于给定值的元素
/**
* 查找第一个大于等于给定元素的值
* @param arr
* @param target
* @return 返回 -1 表示不存在比目标值大的元素
*/
public static int binarySearch3(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
int targetIndex = -1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] >= target) {//中间位置元素>= 目标值
if (mid == 0 || arr[mid - 1] < target) {
//如果mid是0,那么肯定就是第一个大于给定值的元素位置
//如果mid不等于0,但是mid位置的前一个元素是小于给定值的,那么当前mid位置就是第一个大于等于给定值元素的位置
targetIndex = mid;
break;
} else {
right = mid - 1;
}
} else {
left = mid + 1;
}
}
return targetIndex;
}
查找最后一个小于等于给定值的元素
/**
* 查找最后一个小于等于给定值的元素
*
* @param arr 有序序列
* @param target 给定值
* @return 返回-1 表示没有比给定值小的元素
*/
public static int binarySearch2(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
int targetIndex = -1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] <= target) {
if (mid == arr.length - 1 || arr[mid + 1] > target) {
//如果mid的值为数组的最后一个位置,那么mid肯定是最后一个小于等于给定值的元素位置
//如果mid不是最后一个位置,但是mid的下一个位置元素是大于给定值的,那么也说明mid是最后一个小于等于给定值元素的位置
targetIndex = mid;
break;
} else {
left = mid + 1;
}
} else {
right = mid - 1;
}
}
return targetIndex;
}
可以看出这两个变体只是在原有二分查找的基础上做了一些小小的修改。比如第一个大于等于给定值的元素,我们在判断的时候只是做了如下修改
if (arr[mid] >= target) {//合并了大于和等于的情况
if (mid == 0 || arr[mid - 1] < target) {
//如果mid是0,那么肯定就是第一个大于给定值的元素位置
//如果mid不等于0,但是mid位置的前一个元素是小于给定值的,那么当前mid位置就是第一个大于等于给定值元素的位置
targetIndex = mid;
break;
} else {
right = mid - 1;
}
}
二分查找虽然是一种比较高效的算法,但是局限性也是比较明显的。
必须有序:在实际场景中,有时候我们是很难保证要查找的内容是有序的。比如我想查找磁盘中的某个文件,但是各个文件在磁盘中存储一般都是随机无序的。
数组:数组的读取时间复杂度是O(1),但是数组的存储是需要连续的内存空间,如果我们需要1G的内存来加载有序的序列数据,如果内存中没有一块儿连续的1G空间,那么也是无法使用二分查找的。
虽然局限性是有的,但是我们可以根据实际场景来决定是否可以用二分查找法:
场景一:数据量很小的数组。数组太小,直接顺序遍历可能更高效,而且使用简单。
场景二:每次元素与元素的比较是比较耗时的,那么使用二分查找就能有效减少元素的比较次数。
场景三:数据量太大的数组。数组是需要连续空间的,系统不一定有这么大的连续空间可以使用,所以此时不适用。
下面我们来简单分析两个实际场景案例。
在1000W个整数中快速找到指定的数据
假设内存限制在100MB以内,每个数据大小是8字节,最简单的办法就是将数据存储在数组中,内存占用差不多是80MB,符合内存的限制。可以先对这1000万数据从小到大排序,然后再利⽤二分查找算法,就可以快速地查找想要的数据了。
快速定位IP地址归属地
IP地址的归属地如果相对比较固定,我们可以维护一个IP地址库,例如:
[117.123.1.0, 117.123.1.255] 深圳
[117.123.2.0, 117.123.2.255] 广州
[117.123.3.0, 117.123.3.255] 北京
[117.123.4.0, 117.123.4.255] 上海
[117.123.5.0, 117.123.5.255] 杭州
比如我想要查询117.123.1.18 IP地址的归属地时,发现这个地址在[117.123.1.0, 117.123.1.255]这个地址范围内,就可以知道对应的归属地为“深圳”。
IP地址是由4个8位的二进制数组合,所以可以把IP转换成一个32位的整型数,然后对这些转换之后的IP整型数进行排序得到一个有序数组。我们进行IP归属地查询的时候,把给定IP转换成一个32位的整型数,作为给定值去数组中查找最后一个小于等于给定值的元素,找到之后再判断查询的IP是否在这个范围内。
总结
二分查找属于比较基础的查找算法,效率很高,但是也有自身的局限性,使用的时候需要根据具体的场景进行分析。