算法——二分法查找

摘要

二分法查找算法是一种在有序数组中查找特定元素的搜索算法。首先,梳理二分查找算法实现原理;其次,提供二分查找算法的三种不同实现;最后,分析该算法的局限性。

前言

  在大学上算法分析课的时候,老师就说二分查找算法是一种效率较高的、适用于数据量较大序列的搜索算法,此算法基于顺序存储结构的线性表,且要求序列中元素按关键字有序排列,升序和降序都可以。

  二分法的时间复杂度是O(logn),所以在算法中,比O(n)更优的时间复杂度几乎只能是O(logn)的二分法。根据时间复杂度来倒推算法也是面试中的常用策略:题目中若要求算法的时间复杂度是O(logn),那么这个算法基本上就是非二分法莫属。故本文浅析其三种实现方法,使大家对它有一个更深刻的认知。

  在分析二分查找算法之前,我们先梳理计算机中有哪些数据结构。常见的数据结构有数组(Array)、堆栈(Stack)、队列(Queue)、链表(Linked List)、树(Tree)、图(Graph)、堆(Heap)、散列表(Hash)。
常见数据结构

算法原理

  二分法又可以被称为二分查找(binary search)或者折半检索,其基本思想是对于一个有序数组,每次都通过与数组中间元素对比,将问题规模缩小为之前的一半,直到找到目标值。广义的二分查找是将问题的规模尽可能的缩小到原有的一半。这里用到了数组这种数据结构,它属于常见数据结构之一。

  二分查找的原理如下:假设待搜索序列是按照升序排列的,则

  1. 如果序列为空,那么就返回-1,并退出算法;这表示查找不到目标值。

  2. 如果序列不为空,则将它的中间元素与目标值进行比较,看它们是否相等。

  3. 如果相等,则查找成功,返回该中间元素的索引并退出。

  4. 如果中间元素大于目标值,那么就将序列的前半部分作为新的待搜索序列;这是因为后半部分的所有元素都大于目标值,故可以被排除。

  5. 如果中间元素小于目标值,那么就将当前序列的后半部分作为新的待搜索序列;这是因为前半部分的所有元素都小于目标值,故可以被排除。

  6. 对新的待搜索序列重新执行第1步的工作。

  二分查找之所以是一种效率较高的检索方法,是因为在匹配失败的时候,每次都能排除剩余元素中一半的元素。因此可能包含目标值的有效区间就收缩得很快,而不像顺序查找那样,每次仅能排除一个元素。

基于折半检索寻找一个数

  这是折半检索算法最简单的应用场景,可能也是大家最熟悉的,即在指定搜索范围内搜索一个数,如果存在,返回其索引;否则,返回 -1。下面给出两种非递归方式的折半检索实现方案:

import java.util.Arrays;

/**
 * 二分法查找
 *
 * @author Wiener 使用二分法的前提是数组已经排序
 *
 */
public class Demo {

    public static void main(String[] args) {
        int[] arr = { 3, 1, 8, 2, 9, 100,200, 33, 22, 11, 18, 14, 17, 15, 3 };
        // 使用Arrays.sort()排序
        Arrays.sort(arr);
        System.out.println(Arrays.toString(arr));
        // 返回结果
        //int index = binarySearch(arr, 99);
        int index = binarySearch_2(arr, 11);
        System.out.println("index=" + index);
    }

    /**
     * 二分法查找一返回下标
     * @param arr 数组
     * @param key 待匹配的值,看数组中是否存在
     * @return key 在数组中的下标,如果是-1,就说明数组中不存在此元素
     */
    public static int binarySearch(int[] arr, int key) {// 数组和要查找的数
        int min = 0; // 最小的下标
        int max = arr.length - 1;// 最大的下标
        int mid = (min + max) / 2;// 中间的下标
        while (arr[mid] != key) { // 用于于缩小查找范围
            if (key > arr[mid]) { //比中间数还在
                min = mid + 1;   //使最小的下标=中间下标+1
            } else if (key < arr[mid]) {//比中间数还小
                max = mid - 1;   //使最大的下标=中间下标-1
            }
            if(max<min){
                return -1;
            }
            mid=(min+max)/2; //再次计算中间下标
        }

        return mid;
    }
    /*
     * 二分法查找一如果返回的下标是-1,就说明没有
     */
    public static int binarySearch_2(int[] arr, int key) {// 数组和要查找的数
        int min = 0; // 最小的下标
        int max = arr.length - 1;// 最大的下标
        int mid = min + (max - min) >> 1;// 中间数下标,等价于(min + max) / 2
        while(min<=max){
            if(key>arr[mid]){
                min=mid+1;
            }else if(key<arr[mid]){
                max=mid-1;
            }else{
                return mid;
            }
            mid=(min+max)/2;
        }
        //没找到
        return -1;

    }

}

  分析二分查找算法的一个技巧是:尽量避免出现 else,而是把所有情况用 else if 写清楚,这样可以清晰地展现所有细节。

  温馨提示,退出循环的条件之所以是min<=max,而不是min<max,是因为 max = arr.length - 1

  关于mid如何取值,实际上,mid=(min + max) / 2 这种写法是有缺陷的。因为如果min和max比较大的话,两者之和就有可能会溢出。改进的地方是将mid的计算方式写成min + (max - min) >> 1。因为相比除法运算来说,计算机处理位运算性能提升很多。

二分法的递归实现

  实际上二分查找除了以循环遍历来实现,还可以用递归来实现。代码如下:

    /**
     * 递归思想实现二分查找
     *
     * @param orderedArray 有序数组
     * @param low      数组最小值下标
     * @param high     数组最大值下标
     * @param key      查找元素
     * @return 查找元素不存在返回-1,存在返回对应的数组下标
     */
    public static int internallyBinarySearch(int orderedArray[], int low, int high, int key) {
        int mid = (high - low) / 2 + low;
        if (orderedArray[mid] == key) {
            return mid;
        }
        if (low >= high) {
            return -1;
        } else if (key > orderedArray[mid]) {
            return internallyBinarySearch(orderedArray, mid + 1, high, key);
        } else {
            return internallyBinarySearch(orderedArray, low, mid - 1, key);
        }
    }

应用场景的局限性

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

  首先,二分查找依赖的是顺序表结构,简单点说就是数组。二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过
链表等其它数据结构存储的,则无法实现二分查找。

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

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

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

  再次,数据量太小不适合二分查找。数据量很小时,顺序遍历就足够了,完全没有必要用二分查找。只有数据量比较大的时候,二分查找的优势才会比较明显。

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

  最后,数据量太大也不适合二分查找。二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间,但是我们的内存都是离散的,可能电脑没有这么多的内存。

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

如何在 1000 万个整数中快速查找某个整数?

  假设内存限制是 100MB,每个数据大小是 8 字节,最简单的办法就是将数据存储在数组中,内存占用差不多是 10000000/1024/1024 ≈ 76.3MB,符合内存的限制。我们可以先对这 1000 万数据进行排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。

  欢迎点赞阅读,一同学习交流;若有疑问,请在文章下方留下你的神评妙论!

Reference

https://zhuanlan.zhihu.com/p/65610304
https://blog.csdn.net/weixin_41923658/article/details/86090645
https://www.cnblogs.com/gshao/p/13489436.html#_label3


  读后有收获,小礼物走一走,请作者喝咖啡。

赞赏支持

原文地址:https://www.cnblogs.com/east7/p/15084121.html