二分查找之谜题

1 前言

二分查找本身是个简单的算法,但是正是因为其简单,更容易写错。甚至于在二分查找算法刚出现的时候,也是存在bug的(溢出的bug),这个bug直到几十年后才修复(见《编程珠玑》)。本文打算对二分查找算法进行总结,并对由二分查找引申出来的问题进行分析和汇总。若有错误,请指正。

2 二分查找是这样的

相信大家都知道二分查找的基本算法,如下所示,这就是二分查找算法代码:

int bisearch(int a[], int n, int t)  //数组a有序,长度为n, 待查找的值为t
{
    int l = 0, u = n - 1;
    while (l <= u) {
        int m = l + (u - l) / 2; //同(l+u)/ 2,这里是为了溢出
        if (t > a[m])
            l = m + 1;
        else if (t < a[m])
            u = m - 1;
        else
            return m;
    }
    return -(l+1);
}

算法的思想就是:从数组中间开始,每次排除一半的数据,时间复杂度为O(lgN)。这依赖于数组有序这个性质。如果t存在数组中,则返回t在数组的位置;否则,不存在则返回-(l+1)

这里需要解释下为什么t不存在数组中时不是返回-1而要返回-(l+1)。首先我们可以观察l的值,如果查找不成功,则l的值恰好是t应该在数组中插入的位置。

举个例子,假定有序数组a={1, 3, 4, 7, 8}, 那么如果t=0,则显然t不在数组中,则二分查找算法最终会使得l=0 > u=-1退出循环;如果t=9,则t也不在数组中,则最后l=5 > u=4退出循环。如果t=5,则最后l=3 > u=2退出循环。因此在一些算法中,比如DHT(一致性哈希)中,就需要这个返回值来使得新加入的节点可以插入到合适的位置中,在求最长递增子序列的NlgN算法中,也用到了这一点,参见博文最长递增子序列算法

还有一个小点就是之所以返回-(l+1)而不是直接返回-l是因为l可能为0,如果直接返回-l就无法判断是正常返回位置0还是查找不成功返回的0。

3 二分查找数字第一次出现的位置

现在考虑一个稍微复杂点的问题,如果有序数组中有重复数字,比如数组a={1, 2, 3, 3, 5, 7, 8},需要在其中找出3第一次出现的位置。这里3第一次出现位置为2。这个问题在《编程珠玑》第九章有很好的分析,这里就直接用了。算法的精髓在于循环不变式的巧妙设计,代码如下:

int bsearch_first(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*循环不变式a[l]<t<=a[u] && l<u*/
        int m = l + (u - l) / 2; //同(l+u)/ 2
        if (t > a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1=u && a[l]<t<=a[u]*/
    int p = u;
    if (p>=n || a[p]!=t)
        p = -1;
    return p;
}

算法分析:设定两个不存在的元素a[-1]和a[n],使得a[-1] < t <= a[n],但是我们并不会去访问者两个元素,因为(l+u)/2 > l=-1, (l+u)/2 < u=n。循环不变式为l<u && t>a[l] && t<=a[u] 。循环退出时必然有l+1=u, 而且a[l] < t <= a[u]。循环退出后u的值为t可能出现的位置,其范围为[0, n],如果t在数组中,则第一个出现的位置p=u,如果不在,则设置p=-1返回。该算法的效率虽然解决了更为复杂的问题,但是其效率比初始版本的二分查找还要高,因为它在每次循环中只需要比较一次,前一程序则通常需要比较两次。

举个例子:对于数组a={1, 2, 3, 3, 5, 7, 8},我们如果查找t=3,则可以得到p=u=2,如果查找t=4,a[3]<t<=a[4], 所以p=u=4,判断a[4] != t,所以设置p=-1。 一种例外情况是u>=n, 比如t=9,则u=7,此时也是设置p=-1.特别注意的是,l=-1,u=n这两个值不能写成l=0,u=n-1。虽然这两个值不会访问到,但是如果改成后面的那样,就会导致二分查找失败,那样就访问不到第一个数字。如在a={1,2,3,4,5}中查找1,如果初始设置l=0,u=n-1,则会导致查找失败。

扩展
如果要查找数字在数组中最后出现的位置呢?其实这跟上述算法是类似的,稍微改一下上面的算法就可以了,代码如下:

int bsearch_last(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*循环不变式, a[l] <= t < a[u]*/
        int m = l + (u - l) / 2;
        if (t >= a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1 = u && a[l] <= t < a[u]*/
    int p = l;
    if (p<=-1 || a[p]!=t)
        p = -1;
    return p;
}

当然还有一种方法可以将查询数字第一次出现和最后一次出现的代码写在一个程序中,只需要对原始的二分查找稍微修改即可,代码如下:

int binary_search_first_last(int arr[], int p, int q, int value, bool firstflag = true)
{
    int begin = p;
    int end = q;
    while(begin <= end)
    {
        int mid = (begin + end)/2;
        if(arr[mid] == value) //找到了,判断是第一次出现还是最后一次出现
        {
            if(firstflag) //查询第一次出现的位置
            {
                if(mid != p && arr[mid-1] != value)
                    return mid;
                else if(mid == p)
                    return p;
                else
                    end = mid - 1;
            }
            else    //查询最后一次出现的位置
            {
                if(mid != q && arr[mid+1] != value)
                    return mid;
                else if(mid == q)
                    return q;
                else
                    begin = mid + 1;
            }
        }
        else if(arr[mid] < value)
            begin = mid + 1;
        else  
            end = mid - 1;
  }  
    return -1;
}  

4 旋转数组元素查找问题

题目

把一个有序数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。例如数组{3, 4, 5, 1, 2}为{1, 2, 3, 4, 5}的一个旋转。现在给出旋转后的数组和一个数,旋转了多少位不知道,要求给出一个算法,算出给出的数在该数组中的下标,如果没有找到这个数,则返回-1。要求查找次数不能超过n。

分析

由题目可以知道,旋转后的数组虽然整体无序了,但是其前后两部分是部分有序的。由此还是可以使用二分查找来解决该问题的。

解法一::2次二分查找。
首先确定数组分割点,也就是说分割点两边的数组都有序。比如例子中的数组以位置2分割,前面部分{3,4,5}有序,后半部分{1,2}有序。然后对这两部分分别使用二分查找即可。代码如下:

int split(int a[], int n)
{
    for (int i=0; i<n-1; i++) {
        if (a[i+1] < a[i])
            return i;
    }
    return -1;
}

int bsearch_rotate(int a[], int n, int t)
{
    int p = split(a, n); //找到分割位置
    if (p == -1)
        return bsearch_first(a, n, t); //如果原数组有序,则直接二分查找即可
    else {
        int left = bsearch_first(a, p+1, t); //查找左半部分
        if (left == -1) {  //左半部分没有找到,则查找右半部分
            int right = bsearch_first(a+p+1, n-p-1, t); //查找右半部分
            if (right != -1) return right+p+1;  //返回位置,注意要加上p+1
            return -1;
        }
        return left; //左半部分找到,则直接返回
    }
}

解法二:1次二分查找。
二分查找算法有两个关键点:1)数组有序;2)根据当前区间的中间元素与x的大小关系,确定下次二分查找在前半段区间还是后半段区间进行。

仔细分析该问题,可以发现,每次根据low和high求出mid后,mid左边([low, mid])和右边([mid, high])至少一个是有序的。a[mid]分别与a[left]和a[right]比较,确定哪一段是有序的。

  • 如果左边是有序的,若x<a[mid]且x>a[left], 则right=mid-1;其他情况,left =mid+1;
  • 如果右边是有序的,若x> a[mid] 且x<a[right] 则left=mid+1;其他情况,right =mid-1;
    代码如下:
int bsearch_rotate(int a[], int n, int t)
{
    int low = 0, high = n-1;
    while (low <= high) {
        int mid = low + (high-low) / 2;
        if (t == a[mid])
            return mid;
        if (a[mid] >= a[low]) { //数组左半有序
            if (t >= a[low] && t < a[mid])
                high = mid - 1;
            else
                low = mid + 1;
        } else {       //数组右半段有序
            if (t > a[mid] && t <= a[high])
                low = mid + 1;
            else
                high = mid - 1;
        }   
    }   
    return -1; 
}

5 参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 1、用C语言实现一个revert函数,它的功能是将输入的字符串在原串上倒序后返回。 2、用C语言实现函数void ...
    希崽家的小哲阅读 6,239评论 0 12
  • 原文出处:http://www.cnblogs.com/maybe2030/p/4715035.html引文出处:...
    明教de教主阅读 9,104评论 0 7
  • 分治策略 本文包括分治的基本概念二分查找快速排序归并排序找出伪币棋盘覆盖最大子数组 源码链接:https://gi...
    廖少少阅读 1,830评论 0 7
  • 背景 一年多以前我在知乎上答了有关LeetCode的问题, 分享了一些自己做题目的经验。 张土汪:刷leetcod...
    土汪阅读 12,719评论 0 33
  • (打油诗) 儿女断奶后, 妈妈更辛苦。 上午打麻将, 下午斗地主。 晚上聊微信, 家务谁做主。 天天方便面, 口口...
    喷泉阅读 162评论 2 7