瑞_数据结构与算法_二分查找

🙊 前言:在计算机科学中,数据结构和算法是两个核心概念。它们是解决问题的基础工具,也是衡量一个程序员技能的重要标准。本文主要使用Java语言对二分查找算法进行实现,会带大家从零一步步到JDK8的Arrays.binarySearch()源码,从浅到深理解二分查找算法。下面我们将一起探讨搜索算法——二分查找




1 折半思想引入

  传说有一道数学题:现有8个小球,其中有一个劣质小球比其他7个小球的质量更轻,用一个没有刻度的天平(不要问为什么没有刻度,数学题不都这样吗?好歹给了个天平,知足吧)即该天平只能看出哪边重哪边轻,你认为平均最少需要称几次才能把那个劣质小球找出来?

小球问题
  注意题目所需的是平均最少,别钻牛角尖用抽奖的方法去称重。想要平均使用最少次数的天平找出这个劣质小球,我们可以如上图所示。第一次:把8个小球分为两组,轻的那一边4个小球中就存在劣质小球。第二次:把4个小球分为两组,轻的那一边2个小球中就存在劣质小球。第三次:把这来个小球称重比较,轻的那个就是劣质小球。

  从这个问题中,我们得到了一个二分查找最核心的思想: 折半 。也就是经典的 “减而治之” 的思想,每一次查找都会排除一定不存在目标元素的区间,而在剩下可能存在目标元素的区间中继续查找,由于查找的区间是有限的,所以在有限次数的缩小区间后,最后一定能查找到目标元素或者查找失败。

  也许8个小球找3次你并不会感觉折半很牛逼。但阁下可曾听闻指数爆炸?如果是我们Java中熟知的int整型最大值 231-1即21亿47483647呢?在偌大的0-21亿个数字里找某一个数字,使用折半思想去找仅仅只需要最多31次!

注意本小节只是为了引出二分算法的核心思想即折半思想,并不是使用二分算法解决这个问题,因为二分算法所查找的数组必须是有序的,上述问题中的小球质量是无序的,小球的有序编号只是为了方便说明,请不要混淆概念

2 二分查找算法

2.1 基本概念

  二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找特定元素的搜索算法。它是一种经典的算法,它利用了有序数组的特性,以对数时间复杂度实现了高效查找。

  搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,继续从缩小的区间的中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法使得每一次比较都让搜索范围缩小一半

2.2 时间复杂度

  关于时间复杂度的概念,可以参考:瑞_数据结构与算法_时空间复杂度(笔记小结)
  在最好的情况下,如果目标值元素恰好在数组中央,只需要循环一次 ,即为O(1)。在平均情况下,目标值元素在数组中的任意位置,这需要进行的比较次数为随机变量,其期望值为log2n,即为O(log n)。在最坏情况下,如果目标值不存在于数组中或者在数组的左端或右端,则需要进行log2n次比较,因为在每次迭代中,搜索范围都会减半,所以最多需要进行 log2(n) 次迭代,即为O(log n)。

  • 最好情况时间复杂度:O(1)
  • 平均情况时间复杂度:O(log n)
  • 最坏情况时间复杂度:O(log n)

因为时间复杂度的表示忽略常数,则二分查找时间复杂度为O(log n),其中 n 是数组的长度

2.3 空间复杂度

  关于空间复杂度的概念,可以参考:瑞_数据结构与算法_时空间复杂度(笔记小结)
  二分查找的空间复杂度为 O(1),也就是常量级别。因为它仅需要在内存中存储几个指针变量来跟踪搜索范围和中间元素的位置,不需要额外的存储空间,而这些存储需求不会随着输入数据规模的增大而增大,这个特性使得二分查找在处理大规模数据集时非常高效。

需要常数个指针 ,因此二分查找空间复杂度为 O(1)

2.4 优点

  • 高效:二分算法的时间复杂度为O(log n),平均性能好,在大多数情况下比线性搜索更快。
  • 简单:二分算法的实现简单,只需要比较和交换元素的操作。
  • 适用于大型数据集:对于大型的有序数据集,二分算法可以显著减少搜索时间。

2.5 缺点

  既然二分查找算即法高效、又简单,那还要其他查找算法干嘛?二分算法真是成也萧何,败也萧何。因为二分算法所查找的数组必须是已排序的数组,在有序的区间中,每一次比较后,查找的区间就呈指数爆炸减半。但众所周知,排序本身需要耗费时间,且在数据结构的维护上又会引出其他问题,所以缺点如下:

  • 前提条件:二分算法要求数据集是有序的。
  • 插入和删除操作复杂:二分算法在进行插入和删除操作时,需要保持数据的有序性,因此操作相对复杂。
  • 对元素可比较性依赖:二分算法要求元素可比较,如果元素的比较操作复杂或不可用,二分算法可能不适用。
  • 对初始排序数据集的依赖:二分算法的性能高度依赖于初始数据集的排序状态。如果数据集是随机的,那么性能可能并不优于线性搜索。

再次强调:使用二分算法的前提是数据集有序。升序降序无所谓,有序才是重点。




3 二分查找算法实现

3.0 算法步骤

  二分查找算法的实现步骤其实很简单,因为二分算法的优点之一是实现简单(好像写了一句废话),但真的只要能理解折半思想就会二分查找算法,步骤如下:

  • 前提:整个数组是有序的、数组默认是递增的即升序的(如果是递减,以下步骤则取反)。
  • 选择数组中间的元素和需要查找的目标值比较大小
  • 如果中间元素 = 目标元素,返回中间元素的索引,查找直接结束,可喜可贺。
  • 如果中间元素大于目标元素,说明中间元素向右的所有元素都大于目标值,全部排除。即目标元素可能在左半部分,继续在左半部分递归查找。
  • 如果中间元素小于目标元素,说明中间数字向左的所有数字都小于目标值,全部排除。目标元素可能在右半部分,继续在右半部分递归查找。
  • 重复上述过程,直到找到目标元素或左指针大于右指针即查找失败(目标元素不在数组中)

关键代码

  初始条件:left = 0,right = length - 1
  终止条件:left > right,即 left == right + 1
  向左查找:right = mid - 1
  向右查找:left = mid + 1


二分算法是典型的一看就会,一写就废,下面我们来试试

3.1 基础版实现

    /**
     * 二分查找基础版
     * <ol>
     *     <li>i, j, m 指针都可能是查找目标</li>
     *     <li>i > j 时表示区域内没有要找的了</li>
     *     <li>每次改变 i, j 边界时, m 已经比较过不是目标, 因此分别 m + 1 m - 1</li>
     *     <li>向左查找, 比较次数少, 向右查找, 比较次数多</li>
     * </ol>
     * 找不到返回 -1
     *
     * @param a      待查找的升序数组,写a是想和Arrays.binarySearch源码保持一致
     * @param target 待查找的目标值
     * @return int 找到则返回索引
     * @author LiaoYuXing-Ray 2024/1/6 17:09
     **/
    public static int binarySearchBasic(int[] a, int target) {
        // 设置指针
        int i = 0;
        // 设置初值
        int j = a.length - 1;
        while (i <= j) {
            // >>> 为无符号移位运算,防止两个很大的整型数相加导致内存溢出
            int m = (i + j) >>> 1;
            /*
                如果中间元素大于目标元素,说明中间元素向右的所有元素都大于目标值,全部排除。即目标元素可能在左半部分,继续在左半部分递归查找
             */
            if (target < a[m]) {
                j = m - 1;
                /*
                    如果中间元素小于目标元素,说明中间数字向左的所有数字都小于目标值,全部排除。目标元素可能在右半部分,继续在右半部分递归查找
                 */
            } else if (a[m] < target) {
                i = m + 1;
                /*
                   如果中间元素 = 目标元素,即查找成功
                 */
            } else {
                return m;
            }
        }
        // 返回一个负数代表没有查找到该目标值,方便代码逻辑处理
        return -1;
    }

去掉注释的话,在查找算法中代码量是真心少,测试如下:

    public static void main(String[] args) {
        int[] arr = {100, 200, 300, 486, 500, 600, 700};
        System.out.println(binarySearchBasic(arr, 100));    // 0
        System.out.println(binarySearchBasic(arr, 200));    // 1
        System.out.println(binarySearchBasic(arr, 300));    // 2
        System.out.println(binarySearchBasic(arr, 400));    // -1
        System.out.println(binarySearchBasic(arr, 486));    // 3
        System.out.println(binarySearchBasic(arr, 500));    // 4
        System.out.println(binarySearchBasic(arr, 600));    // 5
        System.out.println(binarySearchBasic(arr, 700));    // 6
    }

注意:
返回一个负数代表未查找到该目标值,方便代码逻辑处理。因为如果能查找到,那返回的数组下标一定大于等于0
“>>>” 为无符号移位运算,防止两个很大的整型数相加导致内存溢出
在基础版的 i 和 j 指针,它们不仅仅是指边界,它们也会参与比较,即为左闭右闭的边界

3.2 基础改变版实现

    /**
     * 基础改变版
     *
     * <ol>
     *     <li>i, m 指针可能是查找目标</li>
     *     <li>j 指针不可能是查找目标</li>
     *     <li>i >= j 时表示区域内没有要找的了</li>
     *     <li>改变 i 边界时, m 已经比较过不是目标, 因此需要 i = m + 1</li>
     *     <li>改变 j 边界时, m 已经比较过不是目标, 所以 j = m</li>
     * </ol>
     * 找不到返回 -1
     *
     * @param a      待查找的升序数组,写a是想和Arrays.binarySearch源码保持一致
     * @param target 待查找的目标值
     * @return int 找到则返回索引
     * @author LiaoYuXing-Ray 2024/1/6 17:31
     **/
    public static int binarySearchBasicChange(int[] a, int target) {
        int i = 0;
        // 改变一
        int j = a.length;
        // 改变二
        while (i < j) {
            int m = (i + j) >>> 1;
            if (target < a[m]) {
                // 改变三
                j = m;
            } else if (a[m] < target) {
                i = m + 1;
            } else {
                return m;
            }
        }
        return -1;
    }

对比基础版,改变版只是修改了区间,测试如下:

    public static void main(String[] args) {
        int[] arr = {100, 200, 300, 486, 500, 600, 700};
        System.out.println(binarySearchBasicChange(arr, 100));    // 0
        System.out.println(binarySearchBasicChange(arr, 200));    // 1
        System.out.println(binarySearchBasicChange(arr, 300));    // 2
        System.out.println(binarySearchBasicChange(arr, 400));    // -1
        System.out.println(binarySearchBasicChange(arr, 486));    // 3
        System.out.println(binarySearchBasicChange(arr, 500));    // 4
        System.out.println(binarySearchBasicChange(arr, 600));    // 5
        System.out.println(binarySearchBasicChange(arr, 700));    // 6
    }

注意:改变版的 i 和 j 指针, i 和 j 是边界,但 j 不会参与运算比较,即为左闭右开的边界

3.3 平衡版实现

    /**
     * 平衡版
     * 平衡版改进后:循环的作用只是为了缩小边界
     *
     * <ol>
     *     <li>不奢望循环内通过 m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 i)</li>
     *     <li>i 指针可能是查找目标</li>
     *     <li>j 指针不可能是查找目标</li>
     *     <li>j - i 表示范围内待查找的元素个数</li>
     *     <li>当区域内还剩一个元素时, 表示为 j - i == 1</li>
     *     <li>改变 i 边界时, m 可能就是目标, 同时因为 2. 所以有 i = m</li>
     *     <li>改变 j 边界时, m 已经比较过不是目标, 所以有 j = m</li>
     *     <li>三分支改为二分支, 循环内比较次数减少</li>
     * </ol>
     * 找不到返回 -1
     *
     * @param a      待查找的升序数组,写a是想和Arrays.binarySearch源码保持一致
     * @param target 待查找的目标值
     * @return int 找到则返回索引
     * @author LiaoYuXing-Ray 2024/1/6 17:38
     **/
    public static int binarySearchBalance(int[] a, int target) {
        int i = 0;
        int j = a.length;
        // 范围内待查找的元素个数 > 1 时
        while (1 < j - i) {
            int m = (i + j) >>> 1;
            // 目标在左边
            if (target < a[m]) {    
                j = m;
                // 目标在 m 或右边
            } else {                
                i = m;
            }
        }
        return (target == a[i]) ? i : -1;
    }

平衡版改进后,循环的作用只是为了缩小边界,测试如下:

    public static void main(String[] args) {
        int[] arr = {100, 200, 300, 486, 500, 600, 700};
        System.out.println(binarySearchBalance(arr, 100));    // 0
        System.out.println(binarySearchBalance(arr, 200));    // 1
        System.out.println(binarySearchBalance(arr, 300));    // 2
        System.out.println(binarySearchBalance(arr, 400));    // -1
        System.out.println(binarySearchBalance(arr, 486));    // 3
        System.out.println(binarySearchBalance(arr, 500));    // 4
        System.out.println(binarySearchBalance(arr, 600));    // 5
        System.out.println(binarySearchBalance(arr, 700));    // 6
    }

去掉else if ,减少循环内的平均比较次数
修改后不再循环内找出目标值,循环只是为了缩小边界
改进后时间复杂度最好最坏都为 O(log n)即θ(log n),因为必须要循环结束才会进行比较

3.4 大佬版 Arrays.binarySearch0()

接下来我们来膜拜大佬们的写法,Arrays.binarySearch()方法源码如下:

在这里插入图片描述

binarySearch中调用的binarySearch0方法源码如下:

在这里插入图片描述

3.5 源码解析

  讲道理,博主在将源码与基础版进行核心逻辑比较的时候,第一眼感觉和基础版区别也不是很大…所以核心的逻辑可以根据基础版触类旁通,就不再说明了。

  甚至觉得源码中的写法不符合人类的常规思路,毕竟升序排序的话,是左边小右边大,为什么不向下图右边那样写呢,这样不是更符合升序的写法思维?这个问题博主是至今没想明白,但源码中巧妙的点并不是这里。

在这里插入图片描述

  在进行相同的测试后,发现了源码中一个很牛逼的点,注意看测试结果中的第四个

    public static void main(String[] args) {
        int[] arr = {100, 200, 300, 486, 500, 600, 700};
        System.out.println(Arrays.binarySearch(arr, 100));    // 0
        System.out.println(Arrays.binarySearch(arr, 200));    // 1
        System.out.println(Arrays.binarySearch(arr, 300));    // 2
        System.out.println(Arrays.binarySearch(arr, 400));    // -4
        System.out.println(Arrays.binarySearch(arr, 486));    // 3
        System.out.println(Arrays.binarySearch(arr, 500));    // 4
        System.out.println(Arrays.binarySearch(arr, 600));    // 5
        System.out.println(Arrays.binarySearch(arr, 700));    // 6
    }

  注意看System.out.println(Arrays.binarySearch(arr, 400)); // -4 ,输出了-4!?妙的是,如果400这个值要插入到这个有序数组中并且使其仍然有序,正好就是下标为3 = -(-4+1)也就是arr[3]的位置。感兴趣的小伙伴可以多测试几个数,能得到这样的结果并不是巧合,是归功于源码中的 return -(low + 1);注意此处源码的low是表示左区间。
  既然如此,有这好事不得使用cv大法,把基础版代码进行替换,把基础版中的return -1;修改为基础版中的左区间 i 即return -i;写“-”是因为负数代表未查找到该目标值,再进行测试,结果如下:

    public static void main(String[] args) {
        int[] arr = {100, 200, 300, 486, 500, 600, 700};
        System.out.println(binarySearchBasic(arr, 99));     // 0
        System.out.println(binarySearchBasic(arr, 100));    // 0
        System.out.println(binarySearchBasic(arr, 111));    // -1
        System.out.println(binarySearchBasic(arr, 200));    // 1
        System.out.println(binarySearchBasic(arr, 222));    // -2
        System.out.println(binarySearchBasic(arr, 300));    // 2
        System.out.println(binarySearchBasic(arr, 333));    // -3
        System.out.println(binarySearchBasic(arr, 400));    // -3
        System.out.println(binarySearchBasic(arr, 486));    // 3
        System.out.println(binarySearchBasic(arr, 500));    // 4
        System.out.println(binarySearchBasic(arr, 555));    // -5
        System.out.println(binarySearchBasic(arr, 600));    // 5
        System.out.println(binarySearchBasic(arr, 666));    // -6
        System.out.println(binarySearchBasic(arr, 700));    // 6
        System.out.println(binarySearchBasic(arr, 777));    // -7
    }

可以发现:在未查找到的元素中的返回结果的绝对值,直接就是该元素如果需要插入到有序数组中的下标,不需要任何的计算转化。比如99就是插入到绝对值|0|即arr[0]中,400就是插入到绝对值|-3|即arr[3]中,777就是插入到绝对值|-7|即arr[7]中,当然如果要插入,数组需要扩容,但扩容不是探讨的重点

  那问题来了,既然如果直接返回左区间的绝对值就是要插入到有序数组的下标,那为什么源码还要使用-(low + 1)呢,在这里+(-1)岂不是多此一举。

  细细思考和观察后可以发现:测试中99和100都是返回0,那这个0究竟是什么含义?是代表查找到该目标值在数组中的元素下标为0?还是未查找到该目标值,需要插入到有序数组中的绝对值的下标为0?那如何区别它究竟是+0还是-0呢?毕竟0又没有办法区分正负。所以大佬们+(-1)是为了找出插入点为0的时候,区分是+0还是-0,只要约定好key found的逻辑是-(low + 1),那在返回的时候进行返回值的正负判断后,如果为负数就可以通过-(result+1)获取到插入点!!!所以写源码的大佬就是大佬,代码不仅 严谨而且优雅而且严谨。

  果然cv大法就要贯彻到极致,不要自以为是的瞎改大佬代码,我们现在把基础版中的return -i;修改为return -(i + 1);去掉注释后的完整基础版代码如下:

    public static int binarySearchBasic(int[] a, int target) {
        int i = 0;
        int j = a.length - 1;
        while (i <= j) {
            int m = (i + j) >>> 1;
            if (target < a[m]) {
                j = m - 1;
            } else if (a[m] < target) {
                i = m + 1;
            } else {
                return m;
            }
        }
        return -(i + 1);
    }

测试代码如下:

    public static void main(String[] args) throws InterruptedException {
        int[] arr = {1, 2, 4};
        binarySearchTest(arr, 0);
        binarySearchTest(arr, 1);
        binarySearchTest(arr, 2);
        binarySearchTest(arr, 3);
        binarySearchTest(arr, 4);
        binarySearchTest(arr, 5);
    }

    private static void binarySearchTest(int[] arr, int target) throws InterruptedException {
        int result = Arrays.binarySearch(arr, target); // 源码
//        int result = binarySearchBasic(arr, target); // 基础版代码的修改
        if (result < 0) {
            System.err.println("未查找到该目标值,需要插入的话,下标为:arr[" + -(result + 1) + "]");
        } else {
            System.out.println("查找到数组中存在目标值:" + arr[result] + "\t下标为:" + result);
        }
        // 休眠是为了防止 err 输出语句排序混乱
        Thread.sleep(30);
    }

测试结果如下:

在这里插入图片描述

现在连边界值0也考虑到了,完美结束。总体二分法的思想和实现都是相对简单的,但是属于典型的一看就会,一写就忘,所以请大家一定要多实际上手去编写代码。尤其是多看看大佬们的源码,真的每次阅读源码都受益匪浅,感觉大佬不愧是大佬,大佬代码是真的优雅而且严谨,阅读源码多是一件美事啊~




本文是博主的粗浅理解,可能存在一些错误或不完善之处,如有遗漏或错误欢迎各位补充,谢谢

  如果觉得这篇文章对您有所帮助的话,请动动小手点波关注💗,你的点赞👍收藏⭐️转发🔗评论📝都是对博主最好的支持~

  • 21
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

瑞486

你的点赞评论收藏才更是动力~

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

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

打赏作者

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

抵扣说明:

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

余额充值