极客时间:数据结构与算法之美笔记

二分查找(上):如何用最省内存的方式实现快速查找功能?

2019-01-13  本文已影响0人  红酥手黄藤酒丶

二分查找(上):如何用最省内存的方式实现快速查找功能?

注意,二分查找(也叫折半查找)是针对有序数据集合的查找算法。
二分查找的思想很简单,首先来看一道思考题。

假设我们有 1000 万个整数数据,每个数据占 8 个字节,如何设计数据结构和算法,快速判
断某个整数是否出现在这 1000 万数据中嗯?我们希望这个功能不要占用太多的内存空间,最
多不要超过 100MB,你会怎么做?

极客时间原文链接

一、无处不在的二分思想

我们应该都玩过类似的一种小游戏:我随机写一个 0-99 之间的数字,你来猜我写的是什么,你每猜一次,我就会告诉你是 大了 还是 小了。直到猜中为止。

此处概念逻辑比较简单,不多费口舌。

image

这是生活中的例子,我们回到实际的开发场景中。
假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单元是 元。我们现在想知道是否存在金额等于 19 元的订单。如果存在,则返回订单数据,如果不存在,则返回 null。

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

为了方便,这里假设只有 10 个订单,订单金额分别是:8、11、19、23、27、33、45、55、67、98

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

查找过程示意图

二分查找的思想:二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

二、惊人的查找速度 ○(logn)

二分查找是一种非常高效的查找算法。分析一下它的时间复杂度。

这里假设数据大小是 n,每次查找后目标数据都会缩小为原来的一半。最坏情况下,直到查找区间被缩小为空,才停止。

被查找区间大小的变化

这明显是一个等比数列,而且我们知道每次缩小的操作,只涉及到两个数据大小的比较(常量时间),所以实际上我们的目标是找到总共缩小了多少次,在上图等比数列中,当 n/2^k=1 时, k 的值就是总共缩小的次数。那么经过了 k 次区间缩小的操作,时间复杂度就是 ○(k)。

推导过程

所以时间复杂度就是:○(㏒n)

我们知道,时间复杂度来描述算法的运行时间,实际上描述的是一个增量问题,那么 ○(㏒n) 这个时间复杂度有多高效呢?在某些情况下,甚至比 ○(1) 时间复杂度还要高效。

因为在使用 大○表示法 时,会省略掉常数、系数、低阶。那么 ○(1) 的常数也可能是 1000,10000 等,所以常量级时间复杂度有时候可能还没有 ○(㏒n) 算法执行效率高。

置于 ○(㏒n) 与 ○(n²) 的差距会随着 n 的增长变得非常明显,比如 n 等于 2 的 32 次方,大概是 42 亿,而如果使用 二分查找,我们只需要比较 32 次即可。

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

此节只是 二分查找 的简单实现,下一节是各种二分查找的变体。

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

    public static int bsearch(int[] a,int n,int value){
        int low = 0;
        int high = n-1;

        while (low<=high){
            int mid = low + (high - low) / 2;

            if(a[mid] == value){
                return mid;
            }else if(a[mid] < value){
                low = mid + 1;
            }else {
                high = mid - 1;
            }
        }

        return -1;
    }

需要注意容易出错的三个地方:

循环退出条件:
注意是 low<=high ,而不是 low < high
原因:若判断条件为 low < high,那么当目标数据是数据中最大值时(下标为 n-1),那么最后一次的 low 和 high 值是相等的,都为 n-1,此时判断 low < high 为false,不进入循环,那么实际上就少判断了 最大值 是否是目标数据的情况。

mid 的取值
要写成:mid = low + (high - low)/2 ,若写成 mid = (high+low)/2 ,当 high、low的值较大时,可能会溢出 int 类型的容量。还可以使用位运算:mid = low + ((high - low) >> 1) ,可读性稍差。

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

四、二分查找的递归实现

使用递归思路,要先写出递推公式和终止条件。

    public static int bsearch2(int[] a, int n, int target) {
        return bsearchInternally(a, 0, n - 1, target);
    }

    private static int bsearchInternally(int[] a, int low, int high, int target) {
        if (low > high) {
            return -1;
        }

        int mid = low + ((high - low) >> 1);

        if (a[mid] == target) {
            return mid;
        } else if (a[mid] < target) {
            bsearchInternally(a, mid + 1, high, target);
        } else {
            bsearchInternally(a, low, mid - 1, target);
        }
        
        return -1;
    }

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

二分查找的时间复杂度是 ○(㏒n),查找效率非常高。不过并不是什么情况下都可以使用二分查找的。

二分查找依赖的是顺序表结构,简单点说就是数组
可以依赖链表吗?答案是不可以,因为二分查找算法需要按照下标随机访问元素。

二分查找针对的是有序数据
如果数据是无序的,需要先排序才可以使用二分查找。
另外,如果数据是动态变化的,那么二分查找将不再使用,因为维护一个动态数据的有序,成本是很高的。

数据量太小不适合二分查找
○(㏒n) 时间复杂度,n 越大越,效果越明显,若 n 很小,完全没必要使用二分查找,直接顺序遍历就足够了。
但要注意一点,如果数据之间的比较过程非常耗时(比如两个长度超过300的字符串进行比较),那么就应该尽可能的减少比较次数,此时二分查找便比较适合。

数据量过大也不适合二分查找
主要原因是二分查找依赖数组这种数据结构,数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻,如果有 1GB 大小的数据,用数组来存储,那么就需要 1GB 连续的内存空间。
这个 连续 很可怕,即时有 2GB 的内存空间剩余,但是如果这剩余的 2GB 内存空间都是零散的,那么照样无法申请一个 1GB 大小的数组。

六、解答开篇题

如何在最大 100MB 内存的情况下,在 1000 万个整数中快速查找某个整数?
每个数据大小为 8 个字节,最简单的办法就是将数据存储在数组中,内存占用差不多是 80MB,符合内存的规则。然后将这 1000 万个数据从小到大排序,再利用二分查找算法,即可快速找到数据。

七、课后题

  1. 如何编程实现 求一个数的平方根? 要求精确到小数点后 6 位。
    我没做出来我也没看懂评论区各路大神的解析。。。
  2. 如果数据使用链表存储,二分查找的时间复杂度就会变得很高,那查找的时间复杂度究竟是多少呢?
    这个看懂了,直接截图评论区正确答案:


    image
上一篇 下一篇

猜你喜欢

热点阅读