目录
二分查询也叫折半查找,即在一个有序的数组中,利用数组随机下标访问的时间复杂度是O(1)的特点,每轮查找可以折半计算数组中间的下标位置,每次将查找的范围缩小为原来的一半,这个比较好理解。只是对于二分查找的前提条件比较严苛,才能达到查询的时间复杂度为O(logN)。而红黑树、B+树、跳表都是可以执行快速读写的数据结构,并且性能也可以达到O(logN)的查询时间复杂度。
二分查找的使用场景
1、空间占用低的场景
数组二分查找的应用场景是在Redis等对数据空间占用严苛的场景下,可以使用有序数组的二分查找。
2、二分查找处理"近似查找"的问题
查找第一个X值出现的位置,最后一个X值出现的位置,最后一个小于等于X的值的位置,第一个大于等于X值出现的位置。这样的场景对于红黑树、跳表、B+树中实现起来就会比较的麻烦,并且需要考虑性能。
真实的场景比如:
1)、前分析了TimSort在python、java语言中作为默认的排序算法,而当数据量比较小时内部使用了插入排序算法,只是传统的插入排序本身没有利用前面待插入的子数组已经是有序的特点,而二分插入排序就是对其做了优化。插入排序本身是稳定的排序,所以优化之后的二分插头排序本身也应该需要是稳定排序。此时待排序的元素一定是在已拍好序的后面,即如果前面的有序子数组【1,2,4,4,5】那么当前如果要插入一个4,需求就是寻找最后一个4的下标(或者第一个大于4的下标)
2)、再比如,每个城市都有自己的ip地址的区间 [202.102.133.0, 202.102.133.255] 为XX市,像这样的城市区间可能有几十万个,给一个随机的ip怎样才能快速定位到该ip所属的区间,使用上面的数据结构解决起来也是比较麻烦的。由于ip本身一般不会经常变动,比较符合静止数据的特点,这里解决的思路就是将ip转换为32位的整形数,存储在数组中,进行排序。查询ip区间就转换为“在有序数组中,查找最后一个小于等于某个给定值的元素”。
String[] ip = strIp.split("\\.");
整形数为: (Long.parseLong(ip[0]) << 24) + (Long.parseLong(ip[1]) << 16) + (Long.parseLong(ip[2]) << 8) + Long.parseLong(ip[3]);
二分查找
二分查找每次将数据范围缩小为原理的一半,即每次查找的范围为 n,n/2,n/4,...,n/2^k, 查询范围极具的缩小。查询的时间复杂度为O(logN),在数据量比较小时与O(n)时间复杂度没有太大差别,但是当数据规模非常大时性能提升“恐怖”。下面是一个数据规模与两种复杂度的变化表:
所以二分查找的特点或者应用场景也就出来了:
1、二分查询必须基于数组的结构
上面分析过了,二分查询的二分是二分下标的意思,利用的是数组的下标访问是否复杂度为O(1)。基于有序链表等数据结构二分查找本身没有意义。
2、二分查询的前提是数组有序
前面博客分析了那么多种数组排序算法,最好的时间复杂度也只能达到O(N*logN)。所以二分查询高性能是建立在该基础上,比较适合静态数据以及一次排序多次查询的场景。
3、二分查找不适合数据量小的场景
因为数据量比较小的场景,数据规模在比较小的时候,O(logN)和O(n)时间复杂度的查询次数并没有明显的差别。
4、二分查询不适合数据量比较大的场景
可能如上图,当数据规模达到42亿时,如果是遍历数组或链表时间复杂度是上亿级别,使用二分查询是32次。但是存储的数据结构是数组,必须要求开辟一片连续的内存空间,比如存储数据需要4个G的连续内存空间,如果内存是碎片化的,那么服务站内存为16个G也不一定创建数组成功。
二分查询的代码是面试的高频,二分查询非常好理解但是非常容易出错(尽管第一个二分查找算法于 1946 年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现 - 出自......)。注意点:
1)、判断退出的条件是 low <= high 而不是 low < high
2)、middle值计算写法
最简单的就是 int middle = (high + low) / 2; 当数据量比较大时计算可能溢出则可以优化为:int middle = low + (high - low) / 2; 考虑极致性能可以使用位运算:int middle = low + ((high - low) >> 1);不要忘了最外层的括号,我有一次就是忘记写了,结果死循环。
3)、高低位的值计算
high = middle - 1;而 low = middle + 1;否则也是死循环。
下面基于非递归和递归两种方式进行实现:
1、非递归的二分查找【自己偏向于写非递归】
/**
* 基本的二分查找
* @param array 待查找的有序数组
* @param value 待查找的值
* @return 查找值对应的下标
*/
public static int binarySearch(int[] array, int value) {
int low = 0;
int high = array.length - 1;
while (low <= high) {
int middle = low + ((high - low) >> 1);
if (array[middle] == value) {
return middle;
// 升序,当前中间值比待查询的值小,则在后半段,将最小值重置;
// 降序数组则把判断是小于变成大于
} else if (array[middle] < value) {
low = middle + 1;
} else {
high = middle - 1;
}
}
return -1;
}
2、递归的二分查找
/**
* 递归实现基本的二分查找
* @param array 待查找的有序数组
* @param value 待查找的值
* @return 查找值对应的下标
*/
public static int recursionBinarySearch(int[] array, int value) {
return binarySearchInternally(array, 0, array.length - 1, value);
}
private static int binarySearchInternally(int[] array, int low, int high, int value) {
if (low > high) {
return -1;
}
int middle = low + ((high - low) >> 1);
if (array[middle] == value) {
return middle;
} else if (array[middle] < value) {
return binarySearchInternally(array, middle + 1, high, value);
} else {
return binarySearchInternally(array, low, middle - 1, value);
}
}
"近似"二分查找
public static void main(String[] args) {
/*
第一个 5 出现的下标位置为:3
最后一个 5 出现的下标位置为:6
第一个大于等于5 出现的下标位置为:3
最后一个小于等于5 出现的下标位置为:6
*/
int[] arr = new int[]{1, 2, 3, 5, 5, 5, 5, 7, 8, 9};
int firstIndex = firstValueIndex(arr, 5);
System.out.println("第一个 5 出现的下标位置为:" + firstIndex);
int lastIndex = lastValueIndex(arr, 5);
System.out.println("最后一个 5 出现的下标位置为:" + lastIndex);
int firstGtEIndex = firstGtOrEquals(arr, 5);
System.out.println("第一个大于等于5 出现的下标位置为:" + firstGtEIndex);
int lastLtEIndex = lastLtOrEquals(arr, 5);
System.out.println("最后一个小于等于5 出现的下标位置为:" + lastLtEIndex);
}
1、查找第一个X值出现的位置
/**
* 查询第一个X出现的位置
* @param array 待查询的有序数组
* @param value 需要查询的值
* @return 第一个等值的下标
*/
public static int firstValueIndex(int[] array, int value) {
int low = 0;
int high = array.length - 1;
while (low <= high) {
int middle = low + ((high - low) >> 1);
if (array[middle] == value) {
// 第一个位置,那么当前下标是0 或者 前面一个值等于value就是第一个出现的位置
if (middle == 0 || array[middle - 1] != value) {
return middle;
} else {
// 否则需要去前面的空间判断
high = middle - 1;
}
} else if (array[middle] < value) {
low = middle + 1;
} else {
high = middle - 1;
}
}
return -1;
}
2、查询最后一个X值出现的位置
/**
* 查询最后一个X出现的位置下标
* @param arr 待查询的有序数组
* @param value 待查询的值
* @return 等值的最后一个元素的下标
*/
public static int lastValueIndex(int[] arr, int value) {
int low = 0;
int high = arr.length - 1;
while (low <= high) {
int middle = low + ((high - low) >> 1);
if (arr[middle] == value) {
// 如果当前已经是数组的最后一位(也防止后面判断时+1的下标越界),或者下一个值不等于该值
if (middle == arr.length - 1 || arr[middle + 1] != value) {
return middle;
} else {
// 去后面找
low = middle + 1;
}
} else if (arr[middle] < value) {
low = middle + 1;
} else {
high = middle - 1;
}
}
return -1;
}
3、查询第一个大于等于X值的位置
/**
* 查询第一个大于等于X值的下标位置
* @param arr 待查询的有序数组
* @param value 待查询的值
* @return 第一个大于等于下下标出现的位置
*/
public static int firstGtOrEquals(int[] arr, int value) {
int low = 0;
int high = arr.length - 1;
while (low <= high) {
int middle = low + ((high - low) >> 1);
if (arr[middle] >= value) {
// 当前以及是数组头部或者前一个不是该值,说明当前就是等于该值的第一个
if (middle == 0 || arr[middle - 1] != value) {
return middle;
} else {
// 否则前面还有等于该值的,去前面空间找
high = middle - 1;
}
} else {
// 否则中间值比要查找的值小,去更大的空间找
low = middle + 1;
}
}
return -1;
}
4、查询最后一个小于等于X值的位置
/**
* 查询最后一个小于等于X的下标位置
* @param arr 待查询的有序数组
* @param value 待查询的值
* @return 最后一个小于等于值的下标位置
*/
public static int lastLtOrEquals(int[] arr, int value) {
int low = 0;
int high = arr.length - 1;
while (low <= high) {
int middle = low + ((high - low) >> 1);
if (arr[middle] <= value) {
// 如果当前已经是最好一个元素,或者后面一个下标位的值不能于该值,那么现在就是最后一个等值位置
if (middle == arr.length - 1 || arr[middle + 1] != value) {
return middle;
} else {
// 否则 arr[middle + 1] == value,后面还有等值, 调大最小值
low = middle + 1;
}
} else {
// 否则,中间值大于了待查询的值,需要去低空间查找
high = middle - 1;
}
}
return -1;
}