二分查找(Binary Search)算法也叫折半查找算法,针对的是一个有序的数据集合,它的查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素或区间被缩小为 0。下图展示了查找 19 这个元素的二分查找过程:
image.png

性能分析

二分查找是一种非常高效的查找算法,可以达到惊人的 O(logn)。假设数据大小是 n,每次查找后数据都会缩小为原来的一半,直到查找区间被缩小为空才停止。
image.png
可以看出,这是一个等比数列,其中 k 的值就是总共缩小的次数,而每一次缩小操作只涉及了两个数据的大小比较,所以经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)

代码实现

下面,我们先来看如何来写最简单的二分查找。最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。

  1. public static int search(int[] array, int value) {
  2. int min = 0;
  3. int max = array.length - 1;
  4. while (min <= max) {
  5. // 获取中间位置
  6. int middle = (min + max) / 2;
  7. if (array[middle] == value) {
  8. return middle;
  9. }
  10. if (array[middle] > value) {
  11. // 目标数据在中间位的左侧,所以max索引减1
  12. max = middle - 1;
  13. } else {
  14. // 目标数据在中间位的右侧,所以min索引加1
  15. min = middle + 1;
  16. }
  17. }
  18. return -1;
  19. }
  • 循环退出条件注意是 min<=max,而不是 min<max。
  • int middle = (min + max)/2 这种写法是有问题的,可能会导致整型溢出。改进的方法是将 middle 的计算方式写成 min+(max-min)/2。如果要将性能优化到极致可以将这里的除以 2 操作转化成位运算 min+((max-min)>>1)。
  • min 和 max 的更新如果直接写成 min=middle 或者 max=middle,就可能会发生死循环。

实际上,二分查找除了用循环来实现,还可以用递归来实现:

  1. public static int search(int[] array, int value) {
  2. return searchInternally(array, 0, array.length - 1, value);
  3. }
  4. private static int searchInternally(int[] array, int min, int max, int value) {
  5. if (min > max) {
  6. return -1;
  7. }
  8. int middle = min + ((max - min) >> 1);
  9. if (array[middle] == value) {
  10. return middle;
  11. }
  12. if (array[middle] > value) {
  13. return searchInternally(array, min, middle -1, value);
  14. } else {
  15. return searchInternally(array, middle + 1, max, value);
  16. }
  17. }

应用局限

二分查找的时间复杂度是 O(logn),查找数据的效率非常高。不过,并不是什么情况下都可以用二分查找,它的应用场景是有很大局限性的。那什么情况下适合用二分查找,什么情况下不适合呢?

1)二分查找依赖顺序表结构,即数组
因为二分查找算法需要按照下标来随机访问元素,数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以如果使用链表来存储数据,二分查找的时间复杂就会变得很高。二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。

2)二分查找针对的是有序数据
二分查找要求数据必须是有序的。如果数据不是有序则我们需要先排序,而排序的时间复杂度最低是 O(nlogn)。所以,如果我们针对的是一组静态数据,不会进行频繁插入、删除操作,那我们可以进行一次排序,多次二分查找,这样排序的成本可被均摊,二分查找的边际成本就会比较低。但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。

所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。

3)数据量太小不适合二分查找
如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。只有数据量比较大的时候,二分查找的优势才会比较明显。不过,如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。

4)数据量太大也不适合二分查找
二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。

二分查找变形问题

我们上面写的二分查找是最简单的一种,即有序数据集合中不存在重复的数据,查找值等于某个给定值的数据。下面我们来看一些二分查找的变形问题,问题中涉及的数组都是有序,且存在重复元素的。

1. 查找第一个值等于给定值的元素

对于有序数据集合中存在重复的数据,我们希望找到第一个值等于给定值的数据。比如一个有序数组 a,其中,a[5]、a[6]、a[7] 的值都是 8。我们希望查找第一个等于 8 的数据,即下标是 5 的元素。
image.png
如果用之前的二分查找的代码实现,首先拿 8 与区间的中间值 a[4] 比较,8 >6,于是在 a[5…9] 之间继续查找。a[7] 正好等于8,所以代码就返回了。尽管 a[7] 也等于8,但它并不是我们想要找的第一个等于 8 的元素。针对这个变形问题,我们可以稍微改造一下上面的代码。

  1. public static int search(int[] array, int value) {
  2. int min = 0;
  3. int max = array.length - 1;
  4. while (min <= max) {
  5. int middle = min + ((max - min) >> 1);
  6. if (array[middle] > value) {
  7. // 目标数据在中间位的左侧,所以max索引减1
  8. max = middle - 1;
  9. } else if (array[middle] < value) {
  10. // 目标数据在中间位的右侧,所以min索引加1
  11. min = middle + 1;
  12. } else {
  13. // 如果命中,则判断它的前一个元素是否也命中,middle为0则表示前面没数据了
  14. if (middle == 0 || array[middle - 1] != value) {
  15. return middle;
  16. } else {
  17. max = middle - 1;
  18. }
  19. }
  20. }
  21. return -1;
  22. }

因为我们求解的是第一个值等于给定值的元素,所以当 array[middle] 的值等于要查找的值时,我们需要确认下 array[middle] 是不是第一个值等于给定值的元素。重点看第 14 行代码。

  • 如果 middle 等于0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;
  • 如果 array[middle-1] 不等于 value,说明 array[middle] 就是我们要找的第一个值等于给定值的元素
  • 如果 array[middle-1] 等于 value,说明此时的 array[middle] 肯定不是我们要查找的第一个值等于给定值的元素。那就要更新 max=middle-1,因为要找的元素肯定出现在 [min, middle-1] 之间。

2. 查找最后一个值等于给定值的元素

  1. public static int search(int[] array, int value) {
  2. int min = 0;
  3. int max = array.length - 1;
  4. while (min <= max) {
  5. int middle = min + ((max - min) >> 1);
  6. if (array[middle] > value) {
  7. max = middle - 1;
  8. } else if (array[middle] < value) {
  9. min = middle + 1;
  10. } else {
  11. if (middle == max || array[middle + 1] != value) {
  12. return middle;
  13. } else {
  14. min = middle + 1;
  15. }
  16. }
  17. }
  18. return -1;
  19. }

我们还是重点看第 11 行代码。如果 array[middle] 这个元素已经是区间中的最后一个元素了,那它肯定是我们要找的;如果 array[middle+1] 不等于 value,那也说明 array[middle] 就是我们要找的最后一个值等于给定值的元素。如果 array[middle+1] 等于 value,那说明当前的这个 array[middle] 并不是最后一个值等于给定值的元素。我们就更新 min = middle + 1,因为要找的元素肯定出现在 [middle+1, max] 之间。

3. 查找第一个大于等于给定值的元素

再来看另外一类变形问题。在有序数组中,查找第一个大于等于给定值的元素。比如,数组中存储的这样一个序列:3,4,6,7,10。如果查找第一个大于等于 5 的元素,那就是 6。实际上,实现的思路跟前面的那两种变形问题的实现思路类似,代码写起来甚至更简洁。

  1. public static int search(int[] array, int value) {
  2. int min = 0;
  3. int max = array.length - 1;
  4. while (min <= max) {
  5. int middle = min + ((max - min) >> 1);
  6. // 如果大于等于给定值,则判断它的前一个元素是否也大于等于给定值
  7. if (array[middle] >= value) {
  8. if (middle == 0 || array[middle - 1] < value) {
  9. return middle;
  10. } else {
  11. max = middle - 1;
  12. }
  13. } else {
  14. min = middle + 1;
  15. }
  16. }
  17. return -1;
  18. }

如果 array[middle] 小于要查找的值 value,那要查找的值肯定在 [middle+1, max] 之间,所以,我们更新 min = middle + 1。对于 array[middle] 大于等于给定值 value 的情况,我们要先看下这个 array[middle] 是不是我们要找的第一个值大于等于给定值的元素。如果 array[middle] 前面已经没有元素,或者前面一个元素小于要查找的值 value,那 array[middle] 就是我们要找的元素。这段逻辑对应的代码是第 8 行。如果 array[middle-1] 也大于等于要查找的值 value,那说明要查找的元素在 [min, middle-1]之间,所以将 max 更新为 middle-1。

4. 查找最后一个小于等于给定值的元素

  1. public static int search6(int[] array, int value) {
  2. int min = 0;
  3. int max = array.length - 1;
  4. while (min <= max) {
  5. int middle = min + (((max - min)) >> 1);
  6. if (array[middle] > value) {
  7. max = middle - 1;
  8. } else {
  9. if (middle == array.length - 1 || array[middle + 1] >= value) {
  10. return middle;
  11. } else {
  12. min = middle + 1;
  13. }
  14. }
  15. }
  16. return -1;
  17. }