数据结构与算法|第十一章:二分查找-上
前言
每次看到或者听到二分查找,都会想起一个故事,给博主留下了深刻的印象,今天分享给大家,故事源自于吴军博士的《谷歌方法论》。
这是一个关于二分查找的游戏,比如游戏者让大家在心目中想一个1-1000的数字,然后你问他问题,对方回答“yes(是)”或者“no(不是)”,你用10次一定能够猜出他心目中想的数字。
这个花招其实很简单,你第一次只要问他是否小于500,如果他给出了肯定的答案,说明数字在1-499里面,第二次折半问他这个问题即可。类似地,如果他的回答是否定的,说明对方心目中的数字是在500-1000之间,第二次往大了折半问即可。然后你不断缩小范围,每次减少一半,这样问10次即可,因为 2 的十次方等于1024,大于1000。这就是二分查找。
如果你用这个办法找到数据库中某个身份证号的位置(以及相应的个人信息),大约只要31次,不需要把13亿中国人的身份证扫一遍,31次和13亿次,这个差距还是巨大的。
1.项目环境
- jdk 1.8
- github 地址:https://github.com/huajiexiewenfeng/data-structure-algorithm
- 本章模块:chapter08
2.图解原理
假设我们需要从数组 [1,11,19,21,25,27,33,38,55,87],查找元素 19
我们用三个标示来分别标示
- low 待查找区间的第一位元素下标
- high 待查找区间的最后一位元素下标
- mid 待查找区间的中间元素下标
我们只需要进行三次查询,就可以找到 元素19。
3.O(logn) 惊人的查找速度
从前言中的例子就可以知道,即使在13亿人中查找某个人,也只需要31次查找就可以找到,这样的查找速度在我第一次接触二分查找的时候带来了深深的震撼,我们来分析一下它的时间复杂度。
假设数据大小是 n,每次查找会缩小为原来的一半,最坏情况下,直到区间缩小为空,才停止。
n − > n / 2 − > n / 4 − > n / 8 − > . . . − > n / 2 k − > . . . n->n/2->n/4->n/8->...->n/2^k->... n−>n/2−>n/4−>n/8−>...−>n/2k−>...
当 n / 2 k = 1 n/2^k=1 n/2k=1 时,k 的值就是总共缩小的次数,每次缩小只需要比较 mid位置值 和 查找元素 两个值的大小,所以经过 k 次缩小的时间复杂度为 O ( k ) O(k) O(k)。通过 n / 2 k = 1 n/2^k=1 n/2k=1 可以得到 k = l o g n k = logn k=logn,所以二分查找的时间复杂度为 O ( l o g n ) O(logn) O(logn)。
O ( l o g n ) O(logn) O(logn) 这种对数时间复杂度是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O ( 1 ) O(1) O(1) 的算法还要高效。为什么这么说呢?
我们前面讲过,用大O标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O ( l o g n ) O(logn) O(logn) 的算法执行效率高。
4.代码实现
private static int binarySearch(int[] numbers, int value) {
int index = -1;
int low = 0;
int high = numbers.length - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (numbers[mid] == value) {
return mid;
} else if (numbers[mid] < value) {
low = mid + 1;
} else if (numbers[mid] > value) {
high = mid - 1;
}
}
return index;
}
测试
public static void main(String[] args) {
int[] numbers = new int[]{1, 11, 19, 21, 25, 27, 33, 38, 55, 87};
int index = binarySearch(numbers, 19);
System.out.printf("元素[%d]下标:[%d]", 19, index);
}
执行结果:
元素[19]下标:[2]
上述的代码只是最简单的实现,我们可以对照 Java 中的 Arrays 中的实现来看看。
- java.util.Arrays#binarySearch0(int[], int, int, int)
// Like public version, but without range checks.
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
int key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
int midVal = a[mid];
if (midVal < key)
low = mid + 1;
else if (midVal > key)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
区别点1:
int mid = (low + high) >>> 1, >>> 这种位移操作性能更高。
区别点2:
int midVal = a[mid]; 获取到中间元素值后,进行比较,而不是每次比较都去取一次,虽然是一个常数的时间
区别3:
返回值 -(low + 1),可能比 -1 更有意义吧,这个仁者见仁了
问题点:
其实我们的取中间位置的写法其实是有些问题的,如果 low 和 high 两个值比较大的话,有可能会超过 int 的大小限制,我们可以改写成
int mid = low+((high-low)>>1)
5.递归实现
这里我们来复习了一下之前章节的递归算法
private static int binarySearchInternally(int[] numbers, int low, int high, int key) {
if (low > high || low >= numbers.length) {
return -(low + 1);
}
int mid = low + ((high - low) >> 1);
int midVal = numbers[mid];
if (midVal == key) {
return mid;
} else if (midVal < key) {
return binarySearchInternally(numbers, mid + 1, high, key);
} else {
return binarySearchInternally(numbers, low, mid - 1, key);
}
}
执行结果:
普通实现-元素[19]下标:[2]
递归实现-元素[19]下标:[2]
6.二分查找限制多
二分查找的速度非常快,不过它的应用场景也非常有局限性,并不是什么情况都可以用二分查找。
二分查找的限制:
- 二分查找依赖的是顺序表结构,简单说就是数组
- 二次查找的数组必须是有序的,如果一个数组没有进行排序,我们可以先使用快排进行排序,再进行二分查找,对于一个数组没有频繁的插入和删除操作,我们可以进行一次排序,多次二分查找,这样对排序成本进行均摊,可以降低二分查找的成本
- 数据量太小的不适合二分查找,小的话直接遍历即可
- 数据量太大也不适合二分查找,这个是由于数组的限制,数组的数据结构要求必须是连续的空间,如果数据量非常大比如 1GB,如果我们的服务器上的内存没有连续的 1GB 的空间(即使剩余空间 2GB 也不一定有 1GB 的连续空间),就无法进行查找
7.小结
二分查找是一种针对有序数据的高效查找算法,时间复杂度是 O ( l o g n ) O(logn) O(logn) 。但应用场景也比较有限。底层必须依赖数组,并且还要求数据是有序的。对于较小规模的数据查找,我们直接使用顺序遍历就可以了,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁插入和删除的数据。
8.参考
- 极客时间 -《数据结构与算法之美》王争
- 得到 -《谷歌方法论》吴军