UP | HOME

二分搜索算法

目录

1 前言

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

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

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

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

2 中点值计算

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

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

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;

3 搜索区间与终止条件

很明显,二分搜索算法的搜索区间存在四种选择: [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 相等的时候就会退出循环,所以可以保证不会使中点值和右边界值相等。

4 等于某值

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

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.

5 第一个不小于

从这里开始就只讨论区间 [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. 目标值比序列中的所有值都大,此时,左边界会不断逼近右边界,这个右边界是不属于我们的搜索区间的,因此,当返回值等于右边界值时,说明找不到目标值

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

6 第一个大于

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

很明显,当中点值小于等于目标值时,区间 [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;
}

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

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

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

// 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;

8 结尾

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

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

9 参考链接

版权声明:本作品采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可