数据结构与算法分析:(九)二分查找算法

一、前言

二分查找算法是针对有序数据集合的查找算法,将原本时间复杂度是线性时间提升到了对数时间范围,大大缩短了搜索时间。二分查找算法的思想非常简单,但细节是魔鬼。也就是你想写成没有bug的二分查找算法很难,出错原因主要集中在判定条件边界值的选择上,很容易就会导致越界或者死循环的情况。

1、思想

二分查找又称折半查找,它是一种效率较高的查找方法。折半查找的算法思想是将数列按有序化(递增或递减)排列,查找过程中采用跳跃式方式查找,即先以有序数列的中点位置为比较对象,如果要找的元素值小 于该中点元素,则将待查序列缩小为左半部分,否则为右半部分。通过一次比较,将查找区间缩小一半。 折半查找是一种高效的查找方法。它可以明显减少比较次数,提高查找效率。但是,折半查找的先决条件是查找表中的数据元素必须有序。

2、优缺点

优点:比较次数少,查找速度快,平均性能好。
缺点:要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。

二、O(logn) 魔鬼查找速度

假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单位是元。我们现在想知道是否存在金额等于 3 元的订单。如果存在,则返回订单数据,如果不存在则返回 null。

最简单的办法当然是从第一个订单开始,一个一个遍历这 1000 个订单,直到找到金额等于 3 元的订单为止。但这样查找会比较慢,最坏情况下,可能要遍历完这 1000 条记录才能找到。那用二分查找能不能更快速地解决呢?

为了方便理解,假设只有 10 个订单,订单金额分别是:2,3,7,11,19,20,21,90,93。

利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。其中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标。

在这里插入图片描述

看到了没?我们两次就找到了这个为3元的订单,简直暴力。

我们来分析下这么暴力的算法背后的时间复杂度为多少。

我们假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。

在这里插入图片描述

可以看出来,这是一个等比数列。其中 n/2k=1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)

二分查找是我们目前为止遇到的第一个时间复杂度为 O(logn) 的算法。后面章节我们还会讲堆、二叉树的操作等等,它们的时间复杂度也是 O(logn)。我这里就再深入地讲讲 O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?

因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。

我们前面讲过,用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高。

三、二分查找的递归与非递归实现

1、有序数组中不存在重复元素

// 有序数组中不存在重复元素
public int binarySearch(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            return mid;
        }
    }
    return -1;
}

容易错的几个点:

  • 循环退出条件
    注意是 low<=high,而不是 low。

  • mid 的取值
    实际上,很多人写法是这个mid=(low+high)/2,这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。更进一步,如果要将性能优化到极致的话,我们可以将这里的除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。

  • low 和 high 的更新
    low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 arr[3]不等于 value,就会导致一直循环不退出。

2、二分查找的递归实现

// 二分查找的递归实现
public int binarySearchRecursive(int[] arr, int n, int value) {
    return binarySearchInternally(arr, 0, n - 1, value);
}

private int binarySearchInternally(int[] arr, int low, int high, int value) {
    if (low > high) return -1;
    int mid = low + ((high - low) >> 1);
    if (arr[mid] > value) {
        binarySearchInternally(arr, low, mid - 1, value);
    } else if (arr[mid] < value) {
        binarySearchInternally(arr, mid - 1, high, value);
    } else {
        return mid;
    }
    return -1;
}

四、几种常见的二分查找变形

不知道你有没有听过这样一个说法:“十个二分九个错”。二分查找虽然原理极其简单,但是想要写出没有 Bug 的二分查找并不容易。我们接下来来看一下以下几种变形问题。

1、查找目标值区域的左边界/查找与目标值相等的第一个位置/查找第一个不小于目标值数的位置

比如下面这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 20,是重复的数据。我们希望查找第一个等于 20 的数据,也就是下标是 5 的元素。

在这里插入图片描述
如果我们用上面讲的二分查找的代码实现,首先拿 20 与区间的中间值 arr[4]比较,20 比 19 大,于是在下标 5 到 8 之间继续查找。下标 5 和 8 的中间位置是下标 6,arr[6]正好等于 8,所以代码就返回了。

尽管 arr[6] 也等于 20,但它并不是我们想要找的第一个等于 20 的元素,因为第一个值等于 20的元素是数组下标为 5 的元素。我们上面讲的二分查找代码就无法处理这种情况了。所以,针对这个变形问题,我们可以稍微改造一下上一小节的代码。

// 查找与目标值相等的第一个位置
public int binarySearch_1(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            if (mid == 0 || arr[mid - 1] != mid) {
                return mid;
            } else {
                high = mid - 1;
            }
        }
    }
    return -1;
}

LeetCode参考:35. Search Insert Position

2、查找目标值区域的右边界/查找与目标值相等的最后一个位置/查找最后一个不大于目标值数的位置

如果你掌握了前面的写法,那这个问题你应该很轻松就能解决。

// 查找与目标值相等的最后一个位置
public int binarySearch_2(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            if (mid == n - 1 || arr[mid + 1] != mid) {
                return mid;
            } else {
                low = mid + 1;
            }
        }
    }
    return -1;
}

3、查找最后一个小于目标值的数/查找比目标值小但是最接近目标值的数

此题可有第1小题变形而来

arr [2, 3, 7, 11, 19, 20, 20, 20, 93]
target 20
return 4

// 查找最后一个小于目标值的数
public static int binarySearch_3(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] >= value) {
            high = mid - 1;
        } else {
            if (mid == n - 1 || arr[mid + 1] >= value) {
                return mid;
            } else {
                low = mid + 1;
            }
        }
    }
    return -1;
}

4、查找第一个大于目标值的数/查找比目标值大但是最接近目标值的数

// 查找第一个大于目标值的数
public static int binarySearch_4(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            if (mid == 0 || arr[mid - 1] <= value) {
                return mid;
            } else {
                high = mid - 1;
            }
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

5、旋转数组返回最小元素

5.1、查找旋转数组的最小元素(假设不存在重复数字)

LeetCode参考:153. Find Minimum in Rotated Sorted Array

Input: [3,4,5,1,2]
Output: 1

// 查找旋转数组的最小元素(假设不存在重复数字)
public static int binarySearch_5(int[] arr, int n) {
    int low = 0, high = n - 1;
    while (low < high) {
        int mid = low + ((high - low) >> 1);
        if(arr[mid] > arr[high])
            low = mid + 1;
        else{
            high = mid;
        }
    }
    return arr[low];
}

意这里和之前的二分查找的几点区别:

  • 循环判定条件为low < high,没有等于号。
  • 循环中,通过比较arr[low]与arr[mid]的值来判断mid所在的位置。
  • 如果arr[mid] > arr[high],说明前半部分是有序的,最小值在后半部分,令low = mid + 1。
  • 如果arr[mid] <= arr[high],说明最小值在前半部分,令high = mid。

最后,left会指向最小值元素所在的位置。

5.2、查找旋转数组的最小元素(存在重复项)

LeetCode参考:154. Find Minimum in Rotated Sorted Array II

Input: [2,2,2,0,1]
Output: 0

// 查找旋转数组的最小元素(存在重复项)
public static int binarySearch_6(int[] arr, int n) {
    int low = 0, high = n - 1;
    while (low < high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > arr[high]) {
            low = mid + 1;
        } else if (arr[mid] < arr[high]) {
            high = mid;
        } else {
            high--;
        }
    }
    return arr[low];
}

和之前不存在重复项的差别是:当arr[mid] == arr[high]时,我们不能确定最小值在 mid的左边还是右边,所以我们就让右边界减一。


6、在旋转排序数组中搜索

6.1、不考虑重复项

LeetCode参考:33. Search in Rotated Sorted Array

// 在旋转排序数组中搜索 (不考虑重复项)
public static int binarySearch_7(int[] arr, int target) {
    int low = 0, high = arr.length - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] == target) return mid;
        if (arr[low] <= arr[mid]) { // 左边升序,注意此处用小于等于
            if (target >= arr[low] && target < arr[mid]) { // 在左边范围内
                high = mid - 1;
            } else { // 只能从右边找
                low = mid + 1;
            }
        } else { // 右边升序
            if (target <= arr[high] && target > arr[mid]) { // 在右边范围内
                low = mid + 1;
            } else { // 只能从左边找
                high = mid - 1;
            }
        }
    }
    return -1;
}

6.2、存在重复项

LeetCode参考:81. Search in Rotated Sorted Array II

// 在旋转排序数组中搜索 (存在重复项)
public static boolean binarySearch_8(int[] arr, int target) {
    int low = 0, high = arr.length - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] == target) return true;
        if (arr[low] == arr[mid]) {
            low++;
            continue;
        }
        if (arr[low] <= arr[mid]) { // 左边升序,注意此处用小于等于
            if (target >= arr[low] && target < arr[mid]) { // 在左边范围内
                high = mid - 1;
            } else { // 只能从右边找
                low = mid + 1;
            }
        } else { // 右边升序
            if (target <= arr[high] && target > arr[mid]) { // 在右边范围内
                low = mid + 1;
            } else { // 只能从左边找
                high = mid - 1;
            }
        }
    }
    return false;
}

如果你把几道二分查找算法题都能自己做的出来,那么你对二分查找掌握的到了一定水平了。

五、二分查找应用场景的局限性

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

1、二分查找依赖的是顺序表结构,简单点说就是数组。

那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过,数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。

二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。

2、二分查找针对的是有序数据。

二分查找对这一点的要求比较苛刻,数据必须是有序的。如果数据没有序,我们需要先排序。前面章节里我们讲到,排序的时间复杂度最低是 O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。

但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。

所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据呢?别急,等到二叉树那一节我会详细讲。

3、数据量太小不适合二分查找。

如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。比如我们在一个大小为 10 的数组中查找一个元素,不管用二分查找还是顺序遍历,查找速度都差不多。只有数据量比较大的时候,二分查找的优势才会比较明显。

不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时。我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势。

4、数据量太大也不适合二分查找。

二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。

注意这里的“连续”二字,也就是说,即便有 2GB 的内存空间剩余,但是如果这剩余的 2GB 内存空间都是零散的,没有连续的 1GB 大小的内存空间,那照样无法申请一个 1GB 大小的数组。而我们的二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

老周聊架构

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值