二分查找必知必会

Posted FunWithCS

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了二分查找必知必会相关的知识,希望对你有一定的参考价值。

0 Introduction

二分搜索人人都会,想想查字典这个过程,比如你要查询binary这个单词,你首先翻开的肯定是字典的前一小部分,因为你知道以b为首字母的单词不可能出现在字典的1/4位置以后。假如你恰好翻到的那页含有bit这个单词,你知道,你还要再向前翻一些,因为t在n的后面,接着你跟着感觉在前面一点点的位置翻了一页,刚好,到了在某页发现了你要找的单词。

上面的过程或许太过平常,以至于你从来没有想过其中的高效之处:一本高阶词典大约有200000个单词,但你只查询了3次就找到了目标单词。如果把字典的所有单词想象成一个搜索空间,你的每一次翻页都是在缩减这个搜索空间,第一次,你直接舍弃了整个搜索空间的3/4,通过和bit比较,你更加确定这一点。

为什么我们能做到这么快呢,这是基于字典中的单词排列是有序的,也就是按字典序升序排列,如果一本字典是混乱的,a-z混乱排列,那你只有一个一个的检索,如果字典有200000个单词,那你平均就需要查询100000次,大大高于上面的3次。

二分搜索就是建立在这样的基础上,在有序的序列中,通过大规模缩小搜索空间,从而在log(N)时间内快速寻找一个数。可以看到,想法相当自然且易懂,实现起来应该也是如此吧。

在你这样想之前,先看几个事实:

  1. Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky... by Knuth(图灵奖得主)
  2. 二分搜索的1946年问世,但是第一个正确的二分搜索程序却在1962年才出现;
  3. Java类库实现的二分搜索错了20年才被人发现;

所以本文就是想总结一些经验,防止出现二分搜索的错误;

1 从分析错误开始

这是一段Pascal程序,意思很容易看懂(下标从1开始)。看起来,好像就是我们所学过的二分搜索,但是实际上却漏洞百出。

一般情况下,考虑如下几种错误:

  • 没有处理特殊情况,比如数组为空;
  • 遗漏搜索元素;
  • 死循环;
  • 值传递;
  • 加法溢出;

这里没有处理数组为空的情况,不说是错误的话,至少也是一个不完善;会遗漏元素:比如数组仅有一个元素,并且要查询的key等于这个元素,这种情况下,进入ELSE分支,Index+1,循环退出,返回False;死循环这里倒是没有,因为搜索区间一定是在不断减少的;至于值传递,我不太清楚Pascal语言,但是这个问题也是值得注意的,比如在C++中,传递的是一个数组,而不是它的引用,尽管搜索的再快,但是复制数组的开销以及让程序复杂度提高了一个数量级;当lo和hi都很大时,在某些语言中,lo+hi可能导致加法溢出;

所以这段程序是有问题的,那么怎么样写出高效且正确的代码呢?

2 正确的二分搜索

强烈推荐使用左闭右开区间搜索 [lo, hi)

public int lower_bound(int[] nums, int target){ if (nums == null) return 0; int lo = 0, hi = nums.length; while (lo < hi){ int mid = lo + (hi - lo) / 2; if (nums[mid] >= target) hi = mid; else lo = mid + 1; } return lo;}

先简单解释下:

  • 循环条件 while (lo < hi)搜索空间不为空;
  • int mid = lo + (hi - lo) / 2;防止加法溢出

那么怎么证明它是正确的呢?首先要定义一个循环不变式,然后证明对于初始条件是正确的,接着证明在每次迭代后都保持循环不变式为真,由数学归纳法可知这两步证明了无论何时循环终止,结果都是正确的。综上可知,只要循环能停止,就一定得到答案,因此我们必须证明循环能终止。

首先给出循环不变式:

  1. 搜索区间左边的元素,如果存在的话,一定是小于 target;
  2. 搜索右边的元素,如果存在的话,一定是大于等于 target;
  3. 搜索区间存在;

初始条件,int lo = 0, hi = nums.length;左边不存在元素,符合1,右边不存在元素,符合2,搜索区间存在,符合3;

所以初始条件是正确的。

下面来看循环中的两个分支:

  1. if (nums[mid] >= target)由有序性可以得到: ...nums[mid+1] > nums[mid] >= target也就是在 [mid, hi)这个区间内,一定都是大于等于target的,所以让hi=mid,将搜索区间变为 [lo, mid)
  2. 在else中,条件是 nums[mid] < target由有序性可知: target > nums[mid] > nums[mid-1] > ...也就是区间 [lo, mid+1)都是小于target的,所以将搜索区间设置为 [mid+1, hi)

上面所有的变化,都是利用和mid比较,来最大程度缩减搜索空间,且保持循环不变式的正确性;

最大程度缩减搜索空间,更多的不是为了效率,而是保证循环可以正确退出,上面的两个收缩区间过程必须那样写,只有这样,才能保证无论怎么比较,搜索区间都是缩小的。否则在长度缩小到1的时候,会产生死循环。

说来说去还是二分搜索,似乎和下面这种经典的也没什么区别:

public int binary_search(int[] nums, int target){ if (nums == null) return -1; int lo = 0, hi = nums.length - 1; while (lo <= hi){ int mid = lo + (hi - lo) / 2; if (nums[mid] < target) lo = mid + 1; else if (nums[mid] > target) hi = mid - 1; else return mid; } return -1;}

结果上会有一些小差异,但一定都是正确的结果,区别在哪呢?实际上这是两种截然不同的思想。从结果上:如果有重复元素,经典写法只是随机返回其中一个,但是左闭右开写法返回的是第一次出现的元素;

那深层次又有什么不同呢?经典写法解决的是二分搜索问题,而左闭右开解决的是二分问题。这怎么讲?

回忆一下上面,左闭右开得到的最终结果将数组划分为两部分,前一部分严格小于target,后半部分大于等于target;但是经典写法没有保证;第一种写法的函数名,我命名为lower_bound表达的就是这个意思,也就是满足大于等于target的第一个元素位置;

3 抽象出一个框架

更进一步,可以将这个比较元素抽象成G(mid),从而得到二分搜索一般形式:

public int lower_bound(int[] nums, int target){ if (nums == null) return 0; int lo = 0, hi = nums.length; while (lo < hi){ int mid = lo + (hi - lo) / 2; if (G(mid)) hi = mid; else lo = mid + 1; } return lo;}

上面函数的意义是:找到满足G(mid)的最小下标。这样一来,二分搜索问题似乎可以看成转换为一个最小值问题。

4 Examples

来看一个例子:计算并返回 x 的平方根,其中 x 是非负整数。额外要求:结果只保留整数的部分,小数部分将被舍去。

如果用二分搜索来做,可以转换一下:argmax(m) {m*m <= x}

再转换一下,就是求最小的m使得m*m > x,那么m-1就是答案:

class Solution(object): def mySqrt(self, x): lo ,hi = 0, x + 1; while lo < hi: mid = (lo + hi) >> 1; if mid * mid > x: hi = mid; else: lo = mid + 1; return lo - 1;

如果你还没有看出这个方法好在哪里,简单一句话就是:它让不用脑子也可以保证答案正确。所以你要做的,就是找一下这个函数G,然后将问题转换成最小(最大)值问题即可。

难点在于,G这个函数,有时候它没有这么简单,比如LeetCode875:

珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)。

简化一下问题:有N堆香蕉,选择一个速度K,每小时只能吃一次,在时间H内吃完,问最小的速度K。为什么有最小呢,假设K为无穷大,在N<H时一定能吃完,所以可以逐渐降低K,这就存在一个最小值了。

这就得到了我们二分的思路,找到一个最小的K,使得能吃完:

class Solution { public int minEatingSpeed(int[] piles, int H) { if (piles.length > H) return -1;
int lo = 1, hi = (int)1e9; while (lo < hi){ int mid = lo + (hi - lo) / 2; int c = 0; for (int i = 0; i < piles.length; i++) c += (piles[i] + mid - 1) / mid; if (H >= c) hi = mid; else lo = mid + 1; } return lo; }}
int c = 0;for (int i = 0; i < piles.length; i++) c += (piles[i] + mid - 1) / mid;

这一串就是上文说到的形式复杂的G(m),(piles[i] + mid - 1) / mid是为了向上取整。

还有一点,上面的两个例子,二分的都是value,而不是index,这根据不同的问题有不同的选择。

5 Summary

二分搜索有很多不同的写法,但是写正确很难。没有最好的写法,只有最易于你理解的写法。上面的左闭右开一个显著的缺点就是溢出,比如hi是int最大值,再+1就导致溢出。所以也要根据不同的情况来取舍。

当你使用自己的写法时,有如下几点要考虑:

6 Main reference

https://stackoverflow.com/questions/504335/what-are-the-pitfalls-in-implementing-binary-search

https://www.zhihu.com/question/36132386

http://coldattic.info/post/95/



以上是关于二分查找必知必会的主要内容,如果未能解决你的问题,请参考以下文章

Redis必知必会之zset底层—Skip List跳跃列表(面试加分项)

PHP高级工程师必知必会:提高php代码质量36计

正则表达式必知必会 3/10

MySQL必知必会---过滤数据

必知必会的设计原则——合成复用原则

「速查表」Spark&Hadoop&Hive必知必会.pdf