浅谈二分查找

来源:互联网 发布:办公无线键盘鼠标 知乎 编辑:程序博客网 时间:2024/05/19 10:39

如果你学习数据结构已经有一段时间了,那么我相信对于二分查找,你一定不会陌生,但是如果你还没听说过斐波那契查找,那么我想这篇文章值得你一读~接下来我们就从最基本的二分查找说起,来聊一聊关于二分查找的各种变化版本。

一、二分查找

为了把重点放在查找算法的讨论上,我们这里统统使用整型数组作为查找目标的容器;其他类型的容器,只要其元素之间可以比较大小,那么就都可以使用基于比较树模型的查找算法,关于比较树模型,可以参考另一篇博客(暂未完成)。

那么对于一个给定的数组,如果数组中的元素是无序的,我们要在这个数组中查找某个元素的话,只能从第一个元素开始一一比对,直到找到我们要找的元素,这样的话需要的时间复杂度为O(n);但是如果这个数组中的元素是按序排列的,那么我们是不是还需要从第一个元素开始依次比对呢?答案是否定的,数组这种循秩访问(也就是按照索引访问元素)的特点加上有序性,使得我们可以将“减而治之”(这里是指搜索的范围不断减小,所以是减治)的策略运用于有序数组的查找,思路如下:

假设我们需要再数组S的[lo, hi)区间中查找元素t:

以任一元素S[mid]为界,数组S可以被分为三个部分:S[lo, mid),S[mid],S(mid, hi);

且根据数组的有序性可知:S[lo, mid) <= S[mid] <= S(mid, hi)

那么我们只需要将S[mid]与目标元素t对比,就可以根据比较结果分三种情况进一步处理:

  1. S[mid] < t,则t若存在,一定位于右侧子区间S(mid, hi);
  2. S[mid] > t,则t若存在,一定位于左侧子区间S[lo, mid);
  3. S[mid] = t,则s[mid]就是我们要找的元素!
这样,如果mid = (lo + hi) / 2的话,当我们在这一次中没有找到目标元素的话,就可以将查找范围直接缩小为原来的一半,然后继续按此方法查找,根据渐进理论可知,采用这种方法将使时间复杂度下降至O(logn)!与O(n)相比几乎改进了一个线性因子。

那么我们怎样实现这样的一种算法呢?我们先来看一下版本A:

//查找范围[lo, hi)int binSearch(int *arr, const int e, int lo, int hi){    while(lo < hi)    {        int mid = (lo + hi) >> 1;        if(e < arr[mid]) hi = mid;        if(arr[mid] < e) lo = mid + 1;        //search successed        else return mid;    }        //if search failed, return -1    return -1;}
通过以上代码,我们可以看到每次迭代有三种可能;查找失败则返回-1,这个版本的代码很直观,但是也有一些缺陷:
  1. 每次迭代的判断次数不平衡,有可能只需判断一次,也有可能要判断两次;
  2. 有多个元素命中时,不能保证返回秩最大(或者最小)者;
  3. 查找失败时,不能指示失败的位置;
针对第一个问题,我们这里要提出一个概念,叫作查找长度
我们在进行二分查找的迭代过程所涉及的计算,无非两类:元素比较大小和秩的算数运算及赋值。元素的秩是整数,它的操作属于O(1)复杂度的基本操作,而元素比较大小一般来讲也是O(1)复杂度的基本操作,但是我们说当向量的元素类型很复杂时,其比较操作未必能保证在常数时间内完成!因此就时间复杂度的常系数而言,元素比较操作的权重远远高于秩的运算和赋值,而查找算法的整体效率也更主要地取决于元素比较操作的次数,即所谓的查找长度。

接下来我们以长度为7的向量为例,分析其成功查找长度和失败查找长度:(涉及的具体证明可以查阅原书

每一个成功查找所对应的查找长度,仅取决于向量的长度n以及目标元素所对应的秩,而与元素的具体数值无关,因此我们可以得出,n = 7时,各元素所对应的成功查找长度和失败查找长度分别为:

{4, 3, 5, 2, 5, 4, 6}  平均为4.14;

{3, 4, 4, 5, 4, 5, 5, 6}  平均为4.50;

我们可以看出,迭代次数相同的情况下对应的查找长度不尽相同!这就是所谓的不平衡,那么对于更加一般的情况(n = 2^k - 1),我们采用递推分析法:

递推式:C(k) = [C(k-1) + (2^(k-1) - 1)] + 2 + [C(k-1) + 2 * (2^(k-1) - 1)]

C(k)是查找长度;

[C(k-1) + (2^(k-1) - 1)] 代表经过一次成功的比较,查找范围深入到左侧部分;

2 代表经过两次失败的比较,命中要查找的元素;

[C(k-1) + 2 * (2^(k-1) - 1)] 代表经过一次失败的比较和一次成功的比较,查找范围深入到右侧部分;

最终我们可以得出平均查找长度为O(1.5logn);对于失败查找长度同样可以得出为O(1.5logn);我们说这个常系数还有改进的余地,当查找更加平衡时,常系数是可以减小的!

二、斐波那契查找

为了优化算法的时间复杂度,我们不妨从上述那个递推式入手:

递推式:C(k) = [C(k-1) + (2^(k-1) - 1)] + 2 + [C(k-1) + 2 * (2^(k-1) - 1)]

我们仔细观察这两项:C(k-1) + (2^(k-1) - 1) 和 C(k-1) + 2 * (2^(k-1) - 1),其中(2^(k-1) - 1)为子向量的宽度,而系数1和2则是算法深入左右子向量所需要做的比较次数,我们说该算法的不平衡正是源于此处!如果我们能够将系数为1的子向量长度适当加长,系数为2的子向量长度适当减小,那么算法将变得平衡。这正是斐波那契查找的思路来源。

我们在分割向量为左右两部分时,切分点不一定非得是中间点,事实上切分点的选取是任意的!那么我们根据上述改进思路,不妨根据斐波那契数来选择切分点mi,现假设向量长度n = fib(k) - 1。

那么fibSearch(e, 0, n)就可以以mi = fib(k - 1) - 1作为左右子向量的切分点:

左长度:fib(k - 1) - 1;

右长度:(fib(k) - 1) - (fib(k - 1) - 1) - 1

所以无论往那个方向深入,新向量的长度依然是某个斐波那契数减1,因此可以反复迭代,直到命中目标或者长度收缩至0而终止,这就是斐波那契查找。

我们依然以n = fib(6) - 1 = 7为例,成功查找长度和失败查找长度分别为:

{5,4,3,5,2,5,4}  平均为4.00;

{4,5,4,4,5,4,5,4}  平均为4.38;

较之上一个版本确实有所提高!根据递推方程不难证明平均查找长度为O(1.44logn),比之前略有提高。


三、二分查找一个比较好的实现版本

其实我们大可不必使用斐波那契查找这种麻烦的方法,而采用另外一种更直接的方法来平衡二分查找的最好和最坏时间复杂度,就是我们可以直接将三分支改为两分支!我们在每个切分点S[mid]处仅做一次比较,若目标元素小于S[mid]则深入S[lo, mi),否则,深入S(mid,hi),乍一看我们可能会漏掉目标元素从而使本应成功的一次查找以失败告终,然而真的是这样嘛?我们先来看一下代码:

int binSearch(int *arr, const int e, int lo, int hi){    while(lo < hi)    {        int mid = (lo + hi) >> 1;        arr[mid] <= e ? lo = mid + 1 : hi = mid;    }        return --lo;}

如果你还记得在我们介绍完第一个版本之后提到的三个缺陷,那么我相信你看到这段代码之后一定会有所启发,我们在这里再把那三个缺陷列举一下:
  1. 每次迭代的判断次数不平衡,有可能只需判断一次,也有可能要判断两次;
  2. 有多个元素命中时,不能保证返回秩最大(或者最小)者;
  3. 查找失败时,不能指示失败的位置;
我们来证明一下,这个较好版本的二分查找可以同时解决这三个缺陷!

第一个就不用说了,我们将三分支转变成二分支,判断次数非常平衡!接下来我们主要证明后面的两个。

首先,这个版本只有当有效区间宽度缩短至0的时候才会终止,而每一次迭代我们都可以证明这样的一个不变性:

  • arr[0, lo)中的元素皆不大于e;arr[hi, n)中的元素皆大于e;
证明:首次迭代时,lo = 0, hi = n,arr[0, lo)和arr[hi, n)皆为空,不变性成立;

在进入某次循环是,无非两种情况:

  1. arr[mid] <= e,这时 lo = mid + 1,arr[0, lo)中的元素皆不大于e;
  2. arr[mid] > e,这时 hi = mid,arr[hi, n)中的元素肯定也都大于e;
循环终止时,lo = hi。考察此时的arr[lo]和arr[lo - 1],我们可以发现arr[lo - 1]作为arr[0, lo)中的最后一个元素,其必不大于e;而arr[lo] = arr[hi]作为arr[hi, n]的第一个元素,其必大于e。因此arr[lo - 1]就是我们要找的元素,如果有多个元素命中,其必为秩最大者;而当查找失败时,lo - 1就是我们要找的失败的位置,在这个位置插入元素e,原数组依然有序,这在插入排序中是非常有用的一个功能。


原创粉丝点击