二分查找算法总结

1 二分查找简介

  二分查找也叫折半查找,是一种常见的查找方法,它将原本是线性时间提升到了对数时间范围,大大缩短了搜索时间。
  二分查找必须具备两个条件,一是数列必须使用顺序存储结构(例如数组),二是数列必须有序。

2 二分查找的原理及实现

  以升序数列为例,比较一个元素与数列中的中间位置的元素的大小,如果比中间位置的元素大,则继续在后半部分的数列中进行二分查找;如果比中间位置的元素小,则在数列的前半部分进行比较;如果相等,则找到了元素的位置。
  每次比较的数列长度都会是之前数列的一半,直到找到相等元素的位置或者最终没有找到要找的元素。

  举一个实例来看一下二分查找的详细过程。
  假设待查找数列为 1、3、5、7、9、11、19,我们要找的元素为 18。首先待查数列下图所示,我们找到中间的元素 7( (1+7)/2=4,第 4 个位置上的元素)。
在这里插入图片描述
  中间元素为 7,我们要找的元素比 7 大,继续在后半部分查找。如下图所示,现在后半部分数列为 9、11、19,继续寻找后半部分的中间元素。
在这里插入图片描述
  中间元素为 11,与 11 比较,比 11 大,则继续在后半部分查找,后半部分只有一个元素 19 了,这时直接与 19 比较,若不相等,则说明在数列中没有找到元素,结束查找。

  递归方法实现二分查找的过程如下所示

public class BinarySearch {
    private int[] array;
    /**
     * 递归实现二分查找
     * @param target
     * @return
     */
    public int searchRecursion(int target) {
        if (array != null) {
            return searchRecursion(target, 0, array.length - 1);
        }
        return -1;
    }
    private int searchRecursion(int target, int start, int end) {
        if (start > end) {
            return -1;
        }
        int mid = start + (end - start) / 2;
        if (array[mid] == target) {
            return mid;
        } else if (target < array[mid]) {
            return searchRecursion(target, start, mid - 1);
        } else if (target > array[mid]) {
            return searchRecursion(target, mid + 1, end);
        }
    }
}

进行二分查找时注意的技巧:

  1. 计算 mid 时建议写成: mid = left + (right - left) / 2,防止 left+right 造成数据溢出。
  2. 写二分查找时,每一种可能尽量用else if全部写清楚,尽量少用else,可以更好地展示所有细节。

  非递归方式实现二分查找的过程如下所示

public class BinarySearch {
    private int[] array;
    /**
     * 初始化数组
     * @param array
     */
    public BinarySearch(int[] array) {
        this.array = array;
    }
    /**
     * 二分查找
     * @param target
     * @return
     */
    public int search(int target) {
        if (array == null) {
            return -1;
        }
        int start = 0;
        int end = array.length - 1;
        while (start <= end) {  //防止只有1个元素的搜索空间[left,right]--left==right时,如果是<,则搜索空间为空
            int mid = start + (end - start) / 2;
            if (array[mid] == target) {
                return mid;
            } else if (target < array[mid]) {
                end = mid - 1;  //变成mid-1或mid+1的原因是,mid在上一轮搜索中已经搜索过了,需要从搜索空间中移除
            } else {
                start = mid + 1;
            }
        }
        return -1;
    }
}

这里要注意的点:
循环的判定条件是:start <= end,防止只有一个元素,即start == end时,如果用<,会导致此时的搜索空间为空。

3 二分查找的优化

  提出一个思路:为什么用二分查找,而不是三分之一、四分之一查找?
  举一个实际的例子:我们在查字典的时候,如果要查以a开头的单词,则你会怎么翻字典?肯定是从最前面开始翻;如果要查以 z 开头的单词,则应该会从最后开始翻。显而易见,你不会采用二分查找的方式去查这个单词在哪,因为这样你会很累。
  同样,假设数据的范围是 1~10000,让你找 10,你会怎么做?简单来说,我觉得直接用顺序查找都比二分查找更快,因为数列是升序的,用顺序查找比二分查找的比较次数少。

  综上考虑,我们可以优化一下二分查找,并不一定要从正中间开始分,而是尽量找到一个更接近我们要找的那个数字的地方,这样能够减少很多查找次数。
  之前我们都是根据长度去找到这个中间位置,现在是根据 key 所在的序列范围区间去找到这个位置。要查找的位置 P = low + (key-a[low]) / (a[high]-a[low]) × (high-low),这是有点复杂,但是仔细看一下,这种计算方式其实就是为了找 key 所在的相对位置,让 key 的值更接近划分的位置,从而减少比较次数。
  这种对二分查找的优化叫作插值查找,插值查找对于数列比较大并且比较均匀的数列来说,性能会好很多;但是如果数列极不均匀,则插值查找未必会比二分查找的性能好。

4 二分查找的特点和性能分析

  二分查找的平均查找长度为 ((n+1)log2(n+1))/n-1,有的书上写的是 log2(n+1)-1,或者是 log2n,具体计算比较麻烦,这里就不讨论了。
  二分查找有个很重要的特点,就是不会查找数列的全部元素,而查找的数据量其实正好符合元素的对数,正常情况下每次查找的元素都在一半一半地减少。所以二分查找的时间复杂度为 O(log2n) 。当然,最好的情况是只查找一次就能找到,但最坏和一般情况下的确比顺序查找好了很多。

5 二分查找的适用场景

  二分查找要求数列本身有序,所以在选择的时候需要确认数列是否本身有序,如果无序,则还需要进行排序,确认这样的代价是否符合实际需求。其实我们在获取一个列表的很多时候,可以直接使用数据库针对某个字段进行排序,在程序中需要找出某个值的元素时,就很适合使用二分查找了。
  二分查找适合元素稍微多一些的数列,如果元素只有十几或者几十个,则其实可以直接使用顺序查找(当然,也有人在顺序查找外面用了一个或几个大循环,执行这几层大循环需要计算机执行百万、千万遍,没有考虑到机器的性能)。
  一般对于一个有序列表,如果只需要对其进行一次排序,之后不再变化或者很少变化,则每次进行二分查找的效率就会很高;但是如果在一个有序列表中频繁地插入、删除数据,那么维护这个有序列表的代价就比较高昂。

6 二分查找的缺陷

  比如有序数组 nums = [1,2,2,2,3],target = 2,此算法返回的索引是 2。但如果想得到 target 的左侧边界,即索引 1,或者想得到 target 的右侧边界,即索引 3,此时普通的二分查找算法是无法处理的。
  这样的需求很常见。你也许会说,找到一个 target 索引,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的时间复杂度了。为满足上述需求,需要对二分查找进行扩展,即寻找左侧和右侧边界的二分查找。

7 二分查找的扩展

  寻找左侧边界的二分查找

int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length; // 注意

    while (left < right) { // 注意
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid; // 注意
        }
    }
    return left;
}

为什么这个算法能够查找到左侧边界

关键在于对 nums[mid] == target 这种情况的处理:
if(nums[mid] == target) right = mid;
可见,找到 target 时不会立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

  寻找右侧边界的二分查找
  寻找右侧边界和寻找左侧边界的代码差不多,只有两处不同,已标注:

int right_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

    while (left < right) {
        int mid = (left + right) / 2;
        if (nums[mid] == target) {
            left = mid + 1; // 1 注意
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    return left - 1; // 2 注意
}

8 总结

  二分查找的思想很简单,但细节处需要十分留意:循环结束的判断条件、搜索空间是左闭右开还是其他方式等等,需要仔细确认自己设置的边界条件。

你可能感兴趣的:(算法,算法,数据结构)