zoukankan      html  css  js  c++  java
  • 二分搜索算法

    Table of Contents

    1. 前言
    2. 中点值计算
    3. 搜索区间与终止条件
    4. 等于某值
    5. 第一个不小于
    6. 第一个大于
    7. 最后一个不大于和最后一个小于
    8. 结尾
    9. 参考链接

    前言

    曾经一度觉得二分搜索算法是一个比较简单的算法,直到我看到了这样一些问题:

    • 查找一个已排序序列中第一个不小于某个值的元素
    • 查找一个已排序序列中最后一个不大于某个值的元素
    • 查找一个已排序序列中第一个大于某个值的元素
    • 查找一个已排序序列中最后一个小于某个值的元素

    很明显,这些问题都是可以通过二分搜索算法解决的,然而当我准备开始写的时候,才发现,我不能很好的把它们写出来。

    或者说,我只知道怎么解决:查找一个已排序序列中等于某个值的元素。

    中点值计算

    二分搜索算法是一个很细节的算法,中点值的计算就是其需要注意的一个地方。

    通常情况下,中点值的计算是这样的:

    int mid = (left + right) / 2;
    

    如果对二分搜索算法有一定的了解,应该知道,这样的计算可能存在溢出的问题,即:left 和 right 的和可能超出 int 类型的取值范围。

    因此,中点值的计算应该用如下算式替代:

    int mid = left + (right - left) / 2;
    

    然而,中点值的计算也存在一个隐含的条件:向下取整,也就是说,我们平时写的二分搜索算法的中点值都是向 左边界 接近的。

    所以说,以下两者是等价的:

    int mid = (left + right) / 2;
    int mid = left + (right - left) / 2;
    

    当然了,既然存在向下取整的写法,自然也存在向上取整的写法,只不过平时我们不怎么用罢了,而这篇博客也不做讨论,
    不过还是给出中点值向上取整的写法:

    int mid = ceil((left + right) / 2);
    int mid = right - (right - left) / 2;
    

    搜索区间与终止条件

    很明显,二分搜索算法的搜索区间存在四种选择: [left, right], [left, right), (left, right], (left, right).

    这里不会讨论所有这四种区间,只讨论其中的两种: [left, right][left, right) 以及对应的终止条件选择。

    • 首先是区间 [left, right], 很明显搜索这个区间时应该包括左右两端的值。

      同时,我们又了解到一般情况下,中点值的计算时向左边界接近的,因此,我们不需要担心无法搜索到左边界,
      只需要考虑怎么能够搜索到右边界。

      在中点值是向下取整的情况下,很明显,只有当 left 和 right 相等的时候,我们才能搜索到右边界,因此,循环的终止条件应该为:

      while (left <= right) {
        ...
      }
      
    • 然后是区间 [left, right), 有了前面的基础,我们可以很快的反应过来,右边界是不需要的,因此,我们只需要将终止条件修改为:

      while (left < right) {
        ...
      }
      

      由于中点值的计算是向下取整的,而 left 和 right 相等的时候就会退出循环,所以可以保证不会使中点值和右边界值相等。

    等于某值

    查找一个已排序序列中等于某个值的元素是很简单的,这里就直接给出代码好了:

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

    这个实现是针对区间 [left, right] 而言的,需要注意的地方是 搜索区间的缩减:

    • 当中点值大于目标值时,说明区间 [mid, right] 内的元素都是我们不需要的,
      因此,执行的操作是: right = mid - 1.

    • 相应的,当中点值小于目标值时,说明区间 [left, mid] 内的元素都是我们不需要的,
      因此,执行的操作是: left = mid + 1.

    这里需要重点关注的是由边界值的变化,因为搜索的区间是 [left, right], 因此,当中点值不需要时,
    可以让右边界值直接等于中点值减一。

    但是,这对于区间 [left, right) 来说就不一样了:

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

    在这种情况下,中点值大于目标值,说明区间 [mid, right] 内的元素都是我们不需要的,但是,
    mid - 1 还是需要等待判断的。

    因此,右边界值被修改为 mid 而不是 mid - 1.

    第一个不小于

    从这里开始就只讨论区间 [left, right) 的写法了,对于区间 [left, right], 有兴趣的可以去研究一下。

    有了前面的基础,中点值的计算和终止条件的选择都不是什么问题了,因此,目前最大的问题就是搜索区间的修改问题。

    搜索区间的修改是根据中点值和搜索区间的开闭性来确定的,而区间的开闭性已经确定了,因此,现在需要考虑的是中点值的问题。

    中点值的可能情况:

    1. 小于目标值,我们的目标是不小于目标值,因此,包括中点值在内的元素都不在我们的搜索范围内
    2. 等于目标值,此时,中点值左侧可能同样存在等于目标值的元素,因此,不能贸然修改左值,那么,修改右值?
    3. 大于目标值,情况和等于目标值类似

    综合上面三种情况来看,当中点值小于目标值时的处理很简单,直接修改左边界就可以了,但是对于中点值大于等于目标值时,
    是不能轻率的修改左边界的,因此,只能考虑修改右边界。

    此时,中点值有可能就是我们要的答案,因此,不可能将右边界修改为 mid - 1, 那么,我们需要要将右边界修改为 mid + 1 吗?

    这里我们可以用一个简单的程序来测试一下:

    seq = [2 for i in range(10)]
    left, right = 0, len(seq)
    while left < right:
        mid = left + (right - left) // 2
        if seq[mid] < 2:
            left = mid + 1
        else:
            right = mid + 1
        print(right)
    

    测试程序的输出为:

    6 4 3 2 2 2 2 2 2 2 2......
    

    这是一个无限死循环,因为终止条件是 left < right,而中点值的计算是趋向于左边界的,此时,如果将右边界修改为 mid + 1, 那么问题就变成了:

    seq[left] = seq[left + 1] = target;
    right = left + 1;
    mid = left + (right - left) / 2 = left + 1 / 2 = left;
    right = mid + 1 = left + 1;
    

    因此,在这种情况下,我们只能将右边界修改为 mid, 在这种情况下:

    • 假如右边界就是我们的目标,那么,右边界往左的所有元素都不是我们需要的,这就会使得做边界不断往右边界靠近,直到触发终止条件
    • 假如左边界才是我们的目标,那么,右边界就会不断往左边界靠近,直到触发终止条件

    最后,我们的实现如下:

    int lower_bound(arr, left, right, target) {
      while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] < target) {
          left = mid + 1;  // [mid + 1, right)
        } else {
          right = mid;     // [left, mid)
        }
      }
      return left;
    }
    

    最最后,在来考虑以下两种情况:

    1. 目标值比序列中的所有值都小,此时,左边界就是我们的结果,因为左边界的值已经不小于目标值
    2. 目标值比序列中的所有值都大,此时,左边界会不断逼近右边界,这个右边界是不属于我们的搜索区间的,
      因此,当返回值等于右边界值时,说明找不到目标值

    简单来说,只要返回值比右边界值小,那么结果都是成立的。

    第一个大于

    前面考虑了第一个不小于的情况,这里再来考虑第一个大于就容易多了,核心依然是搜索区间的修改。

    很明显,当中点值小于等于目标值时,区间 [left, mid] 都不是我们需要的,直接将左边界修改为 mid + 1 就可以了,
    右边界的修改和前面一样,因此,这里的实现只需要将前面的代码改动一点点就可以了:

    int upper_bound(arr, left, right, target) {
      while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] <= target) {
          left = mid + 1;  // [mid + 1, right)
        } else {
          right = mid;     // [left, mid)
        }
      }
      return left;
    }
    

    对,没错,只需要将中间的判断条件 < 改成 <= 就可以了,使用上和前面的那个也是一样的。

    最后一个不大于和最后一个小于

    这两个的实现可以取个巧,首先我们可以研究一下前面两个实现返回的结果的情况:

    // arr[result] >= target
    // arr[result - 1] < target
    int lower_bound(arr, left, right, target) {
      ...
    }
    
    // arr[result] > target
    // arr[result - 1] <= target
    int upper_bound(arr, left, right, target) {
      ...
    }
    

    很明显,最后一个不大于的意思就是:

    arr[result] <= target
    arr[result + 1] > target
    

    这一点和第一个大于的返回结果很相似,因此,最后一个不大于可以借助第一个大于来实现:

    int result = upper_bound(arr, left, right, target) - 1;
    

    如果结果为 -1 说明目标值就不存在。

    最后一个小于也是一样的道理:

    int result = lower_bound(arr, left, right, target) - 1;
    

    结尾

    二分搜索算法真的是一个很细节的算法,各种实现之间的区别都不大,很多就是加一减一这种程度的区别,
    但不注意还容易搞错。

    对此,我只想说,$*#&*@#^&…

    参考链接

  • 相关阅读:
    设计模式之设计原则
    把二叉树打印成多行
    快速排序的递归遍历和非递归遍历
    二叉树的非递归遍历
    约瑟夫环问题
    strcpy strcat strcmp memcpy函数的自己实现
    【解题模板】一些很基础的板子
    【笔记】取模运算的用法
    【OJ技巧】DSACPP pa-book中的一些提示
    【编程语言】Java基础进阶——面向对象部分
  • 原文地址:https://www.cnblogs.com/rgbit/p/10346151.html
Copyright © 2011-2022 走看看