如何写好二分查找?

说来惭愧,9月份之后因为各种各样的原因停更了3个月,眼看2019就要过去了,想着怎么着也要来一篇给2019收个尾吧。那么今天我们就开启一个新的话题——算法。

二分查找的思路大家都清楚,典型的分治实现方式。然而结合自己过去的经历,想正确地写出一个二分实现似乎又很难,几乎每次都会有各种各样的问题。正如Donald Knuth所说的那样:

Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky.

思路很简单,细节是魔鬼。

那么今天,我们就来看看二分查找中有哪些细节是值得我们重视的,避免一不小心再掉坑里了。

首先,我们看一下典型的二分查找实现方式:

public class BinarySearchTest {

    private int target = 4;

    private int[] array = new int[]{1, 3, 4, 5};

    public static void main(String[] args) {
        BinarySearchTest binarySearch = new BinarySearchTest();
        binarySearch.binarySearch();
    }

    private int binarySearch() {
        int left = 0;
        int right = array.length - 1;
        int middle = 0;
        while (left < right) {
            middle = (left + right) / 2;
            System.out.println("left = " + left + ", right = " + right + ", middle = " + middle);
            if (array[middle] > target) {
                right = middle - 1;
            }else {
                left = middle;
            }
        }

        System.out.println("left = " + left + ", right = " + right + ", middle = " + middle);
        return left;
    }
}

你能看出以上这段代码有什么问题吗?

有,还很多。不过这里我们先卖个关子,一点一点往下看。

中位数索引的计算

最常见的中位数索引计算方式是这样的:

int middle = (left + right) / 2;

如果写出这样的中位数索引计算方式,不客气地讲,你对二分查找还没入门。原因很简单,当left和right的值都很大时,(left + right)很有可能导致整型溢出。因此进阶版的方式是像下面这样:

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

这种方式虽然有所改进,但实质上还是没有完全避免整型溢出的问题。当right很大且left为负数的时候,依然存在同样的风险。正确的方式应该是这样:

int middle = (left + right) >>> 1;

看到这里,有同学可能有疑问了。同样是(left + right),这种方式不是一样可能导致整型溢出吗?

别急,要回答这个问题,我们需要先弄清楚Java中右移运算符(>>) 和无符号右移运算符(>>>)的区别。

顾名思义,>>和>>>的区别在于作右移运算时是否需要处理符号位。具体来说是这样的:

  • 右移运算符 >> 在右移时,丢弃右边指定位数,左边补上符号位,右移运算后被操作数的符号保持不变。
  • 无符号右移运算符 >>> 在右移时,丢弃右边指定位数,左边补上 0,也就是对于正数来说,二者一样,而负数通过 >>> 后能变成正数。

说来拗口,看个例子吧:

public static void main(String[] args) {
    int left = (1 << 31 - 1);
    int right = (1 << 31 - 1);
    System.out.println("left = " + left + ", right = " + right);
    System.out.println("left + right = " + (left + right));
    System.out.println("(left + right) / 2 = " + (left + right) / 2);
    System.out.println("(left + right) >> 1 = " + ((left + right) >> 1));
    System.out.println("(left + right) >>> 1 = " + ((left + right) >>> 1));
    System.out.println((-2147483648) >>> 1);
}

==========================================
left = 1073741824, right = 1073741824
left + right = -2147483648
(left + right) / 2 = -1073741824
(left + right) >> 1 = -1073741824
(left + right) >>> 1 = 1073741824

思考题:(-4 >>> 1)结果是几呢?2、-2抑或其他?

那是不是意味着我们在二分查找中计算中位数索引时,可以无脑使用>>>呢?当然也不是。因为如果(left + right)的结果不是因为整型溢出导致的负数,而是本身正确结果就是负数的时候,就不能使用>>>了。不过在绝大多数二分查找的场景中,left和right都是表示索引的非负数,因此后两种方式都是可取的。但考虑到移位运算的高效性,个人更推荐使用>>>的方式,事实上在JDK1.8中Arrays类也大量使用了这种方式。

左右中位数的选择

首先解释下左右中位数的概念。看以下两个数组:

[1, 2, 3, 4, 5] ---- left = 0, right = 4, middle = 2
[1, 2, 3, 4] ---- left = 0, right = 3, middle = ?(1还是2)

第一个数组元素个数为奇数,毫无疑问中位数是3,对应索引middle=2。

但针对元素个数为偶数的场景,比如第二个数组,中位数其实有两个选择:2或3。其中2就是所谓的左中位数,而3自然就是右中位数。

左右中位数的索引计算方式也是有所区别的:

int mid = (left + right) >>> 1;

得到的就是左中位数索引,而

int mid = (left + right + 1) >>> 1;

得到的就是右中位数的索引了。

当然,针对元素个数为偶数的场景,两者并无区别。

既然中位数有左右之分,那问题来了:我们应该如何选择?

答案也很简单:选择左右中位数的唯一标准就是避免死循环。

什么意思呢?回到文章开头的那个例子,运行一下你会发现程序陷入了死循环,输出结果一直是这样的:

left = 2, right = 3, middle = 2
left = 2, right = 3, middle = 2
...

不难发现,发生死循环是因为某种原因导致左右边界没有收敛。而这里所谓的某种原因其实就是左右中位数的选择与分支逻辑不匹配导致的。

死循环往往发生在区间中只剩下两个元素的时候,此时在分支逻辑中选择左边界的时候,我们并没有排除掉中位数(left = middle),而进入下一次循环的时候,中位数选择的又恰恰是左中位数,从而导致区间无法收敛。

找到的原因,解决起来也就简单了,分支逻辑不变,中位数选择右中位数即可。

同样,如果分支逻辑中选择有边界是没有排除中位数,那么中位数必须选择做中位数。

循环退出条件

凡涉及循环的操作,都要特别关注的一点就是循环退出条件,在二分查找中也不例外。这里稍有不慎也可能导致死循环的发生。

还是回到文章开头那个例子,如果我们将循环条件中的 < 改为 <=,也就是:

while (left <= right) 

那么即使你选择了正确的中位数,程序依然会发生死循环,此时left = right = middle。

为了避免这种情况导致的死循环,使用 < 当然是一种方式。还有一种方式是增加一个分支逻辑,对中位数是否为目标元素作单独判断。修改后的代码如下:

private int binarySearch() {
    int left = 0;
    int right = array.length - 1;
    int middle = 0;
    while (left <= right) {
        middle = (left + right + 1) / 2;
        System.out.println("left = " + left + ", right = " + right + ", middle = " + middle);

        if (array[middle] == target) {
            return middle;
        }

        if (array[middle] > target) {
            right = middle - 1;
        }else {
            left = middle + 1;
        }
    }
  
    System.out.println("left = " + left + ", right = " + right + ", middle = " + middle);
    return -1;
}

使用这种方式,即使在循环退出条件中不小心使用了<=,也不用担心死循环的发生。而且增加一个分支逻辑还有一个好处是不用考虑左右中位数的问题了,因为此时不论怎样,区别一定是会一直收敛下去的。

总结

本文梳理了一下二分查找中的坑,总结下来在二分查找中要注意以下几点:

  • 计算中位数索引时,要防止整型溢出的风险,尽量使用无符号右移运算符。
  • 循环退出条件尽量使用<,使用 <= 要重点关注可能死循环的风险。
  • 处理分支逻辑时,最好增加一个分支逻辑对中位数进行单独判断。
  • 如果你实在只想写两个分支逻辑,也不是不行,但这时要选择正确的中位数,避免死循环的发生。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,539评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,594评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,871评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,963评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,984评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,763评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,468评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,850评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,002评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,144评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,823评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,483评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,026评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,150评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,415评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,092评论 2 355

推荐阅读更多精彩内容

  • 前言 二分查找作为程序员的一项基本技能,是面试官最常使用来考察程序员基本素质的算法之一,也是解决很多查找类题目的常...
    Jesse1995阅读 2,202评论 0 0
  • 原文链接: 点这里更多内容就在我的个人博客 BlackBlog.tech 欢迎关注!谢谢大家! 本文源自LeetC...
    BlackBlog__阅读 3,265评论 2 13
  • --- layout: post title: "如果有人问你关系型数据库的原理,叫他看这篇文章(转)" date...
    蓝坠星阅读 793评论 0 3
  • 简介 二分查找(Binary Search)算法,也叫折半查找算法。在给顺序表结构中(也就是数组)快速查找某一个值...
    mah93阅读 578评论 0 0
  • 二分查找是在每次匹配后,将查找的空间一分为二的算法,二分查找应该是有序的数组进行查找. 二分查找模板 1. 模板一...
    Tim在路上阅读 1,145评论 0 0