Java 算法 - 二分法查找

Java 算法 - 二分法查找

数据结构与算法之美目录(https://www.cnblogs.com/binarylei/p/10115867.html)

二分法查找是一种非常高效的查找方式,时间复杂度为 O(logn)。

唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第 3 卷《排序和查找》中说到:"尽管第一个二分查找算法于 1946 年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现。"

二分查找原理非常简单,但想要写出没有 Bug 的二分查找并不容易,"十个二分九个错"。本文先介绍最简单的一种二分查找的代码实现,再深入分析几种二分查找的变形问题。

1. 工作原理

这一部分,我们说的简单二分查找法,也是精确查找。如 JDK 的 Collections#binarySearch。

public int bsearch(int[] arr, int value) {
    int low = 0;
    int high = arr.length - 1;
    while (low <= high) {
        int mid = (low + high) / 2;
        if (value < arr[mid]) {
            high = mid - 1;
        } else if (value == arr[mid]) {
            return mid;
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

说明: 简单的二分查找非常简单,但还是有几个细节需要特别注意一下:

  1. 循环退出条件。注意是 low <= high,而不是 low < high,否则可能会查找不到数组,反回 -1。
  2. mid 的取值。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。写成 low + (high - low) / 2,或改写成位运算 low + ((high - low) >> 1),或 (low + high) >>> 1。
  3. low 和 high 的更新。如果直接写成 low = mid 或者 high = mid,就可能会发生死循环。比如,当 high = 3,low = 3 时,如果 a[3] 不等于 value,就会导致一直循环不退出。

改进后的二分查找法如下,以 Collections#binarySearch 为例:

private static <T> int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
    int low = 0;
    int high = list.size() - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        Comparable<? super T> midVal = list.get(mid);
        int cmp = midVal.compareTo(key);

        if (cmp < 0)
            low = mid + 1;
        else if (cmp > 0)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found
}

2. 使用场景

二分查找法虽然查找效率高效,但使用条件非常苛刻,场景使用有限:

  1. 二分查找的底层数据结构必须是数组。因为需要根据下标随机访问数组。

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

    对于频繁变化的动态数据,二分法不适合。因为每次查找前都需要重新排序,虽然查找的时间复杂度是 O(logn),但排序的时间复杂度是 O(nlogn),因而总的时间复杂度就变成 O(nlogn)。

  3. 数据量太小不适合二分查找。数据量太小则不能体现二分查找法的优势,还不如直接顺序遍历。

    比如我们在一个大小为 10 的数组中查找一个元素,不管用二分查找还是顺序遍历,查找速度都差不多。只有数据量比较大的时候,二分查找的优势才会比较明显。当然,如果数据比较操作非常耗时,不管数据量大小,都推荐使用二分查找。如长度超过 300 的字符串比较。

  4. 数据量太大也不适合二分查找。数据量太大内存不够,因为数组必须使用连续的内存进行存储。二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。

总结来说:二分法查找底层必须使用有序的静态数组,对于动态数据,或数据量太小,或太大都不适合用二分法。

思考1:二分查找法的底层数据结构为什么不能是链表?

二分查找法每次都获取链表的中间结点,采用快慢结点算法获取链表的中间节点时,快慢指针都要移动链表长度的一半次,也就是 n / 2 次,总共需要移动 n 次指针才行。

  • 第一次,链表长度为 n,需要移动指针 n 次;
  • 第二次,链表长度为 n / 2,需要移动指针 n / 2 次;
  • 第三次,链表长度为 n / 4,需要移动指针 n / 4 次;
  • ......
  • 以此类推,一直到 1 次为值
  • 指针移动的总次数 n + n / 2 + n / 4 + n / 8 + ... + 1 = n(1 - 0.52) / (1 - 0.5) = 2n

也就是说,如果采用链表的数据结构,仅获取中间结点的时间复杂度是 O(2n),不仅远远大于数组二分查找 O(logn),也要大于顺序查找的时间复杂度 O(n)。

思考2:动态数据如何快速查找呢?

我们知道动态数据每次查找前都先进行排序后查找,查找的时间复杂度就变成 O(nlogn)。有没有好的快速查找方法呢?这时跳表就登场了。跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都是 O(logn)。

3. 模糊匹配 - 二分法查找法变形

事实上,二分法在精确匹配上使用的并不多,我们可以用 HashMap 等数据结构替换(虽然 HashMap 比数组更耗内存),二分法往往用在模糊查找上。

  • 查找第一个值等于给定值的元素
  • 查找最后一个值等于给定值的元素
  • 查找第一个大于等于给定值的元素
  • 查找最后一个小于等于给定值的元素

3.1 查找第一个值等于给定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int 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] != value)) return mid;
            else high = mid - 1;
        }
    }
    return -1;
}

说明: 只需要在二分查找的基础上做一点改动即可,如果查找到相等的元素,需要进一步判断前一个元素是否等于要查找的值,如果等于要查找的值,则需要继续递归。

上述的二分法查找代码可读性最好,当然还有一种更高效的写法,可读性就稍微差一点了:

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] >= value) {
            high = mid - 1;
        } else {
            low = mid + 1;
        }
    }

    if (low < n && arr[low] == value) return low;
    else return -1;
}

3.2 查找最后一个值等于给定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int 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] != value)) return mid;
            else low = mid + 1;
        }
    }
    return -1;
}

3.3 查找第一个大于等于给定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int 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;
}

3.4 查找最后一个小于等于给定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int 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;
}

3.5 模糊匹配应用场景

比如,我们需要根据 IP 查找对应的地址,如果数据库中有 100 万个 IP 段对应的地址库,如何高效的进行 IP 匹配呢?比如:

171.43.252.0 ~ 171.43.252.254 武汉
171.43.253.0 ~ 171.43.253.254 广州
171.43.254.0 ~ 171.43.254.254 上海
...

我们的解决方案是这样,首先我们知道 IPv4 可以转换成一个 int 类型数据。我们以每个地址段的起始 IP 进行排序,这样问题就转换成了查找最后一个小于等于给定 IP 问题的解,如果查找的 IP 在查找的 IP 段内,就返回这个地址,否则返回空。


每天用心记录一点点。内容也许不重要,但习惯很重要!

posted on 2020-03-06 15:32  binarylei  阅读(...)  评论(...编辑  收藏

导航

统计