二分查找详解

大佬原文链接:二分查找从入门到入睡

简介

二分查找是一种简单高效的查找算法,通常用于在有序或二段性(后有详解)的数组中查找某个元素。其基本原理是每次与数组中间元素进行比较,可以缩小一半的查找空间,直到找到目标元素或者区间被缩小为0,目标不存在。二分查找以其原理极为简单,但细节处理却极易出错而闻名。

  • 时间复杂度:O(logn),其中 n 是数组的长度。
  • 空间复杂度:O(1)。

可能提出的疑问

尽管二分查找的基本思想相对简单,但细节可以令人难以招架 ... — 高德纳

不同情况用哪种符号

  • 左右边界初始值为何有时候是 n, 有时候是 n - 1?
  • while中的条件什么时候用 <,什么时候用 <= ?
  • 左右界更新条件的不等号该写哪一个 <, <=, >, >= ?
  • 左右界更新语句该写哪一个 l = c + 1 / l = c / r = c - 1 / r = c ?
  • 中间值下标为什么写成 l + (r - l) / 2 ?
  • 返回值到底是 l 还是 r 还是 l - 1, l + 1, r - 1, r + 1...?

千万别死记硬背,理解【循环不变】原则后可以轻松写出。

为什么会无限循环

错误示例(以模板二情形3为例):

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n
    while l<r:
        c=l+(r-l)//2
        if nums[c]<=target:
            l=c
        else:
            r=c+1
    #return r-1 if (nums[r]==target) else -1 # 处理相等返回情形
    return l if r!=-1 else -1

该代码意外地陷入了无限循环,产生原因如下:

  • 产生循环的条件是某一次进入while时,c = l + (r - l) // 2,使得 nums[c] <= target,于是进入 if 分支,l = c,且假设此次循环开始时 l = c。检视该假设的正确性,将计算 c 的式子中的 c 换成 l,有 l = (l + r) / 2。
  • l + r 为偶数时,l = r,与进入while的条件矛盾,故 l = c 的假设与 l + r 为偶数互相矛盾。
  • l + r 为奇数时,l = r - 1。意想不到的事情发生了,这是可能达到的一种情况。 也就是说,「情形3」代码在某一次进入while时,若 l = r -1,且nums[c] <= target时,将发生无限循环。

e.g.,对于数组 nums = {-1,0,3,5,9,12},target = 3,以其为输入运行「情形3」代码,程序将在 l = 1, r = 2 (满足 l = r - 1,且此时nums[c] = nums[1] = 0 <= target = 3)时开始无限循环。但若target = 5,则程序正常结束,返回正确的结果(建议实际动手分析一下)。实际上只要target大于nums中所有数字,则必然发生无限循环,因为 r 不会更新, l 向 r 逐渐靠近后最终一定位于 r 的前一位,即 l = r - 1,而此时必然有 nums[c] <= target,于是会在这个时候陷入无限循环。

什么是二段性

二分查找不一定需要数组有序,只需要具备「二段性」即可。

关于「二段性」,你会看到有些题目的数组并不具备有序性,但丝毫不妨碍以二分查找处理。这是因为,只要数组能够根据特定的条件(其实就是「循环不变」)被分为两半,且搜索空间为其中的一半,循环地如此二分下去,直到穷尽原搜索空间,最终必能确定答案(存在与否,及若存在是哪个)。这就是「二段性」,更严谨点说是 「输入序列对于答案可被二分至穷尽」 这一本质特征。最典型的莫过于162. 寻找峰值 ,输入元素大小和顺序是任意的,只需至少存在一个数,其左右两边的数小于它即可。看起来十分反直觉,但仍可通过「循环不变」知道其满足上述本质特征,了解到这一点后就不会觉得有多特别了。

循环终止时l、r下标的关系是怎么确定的,为什么是确定的?

模板一的错位终止和模板二的相等终止有详细介绍及证明。

为什么会提前溢出

关于求中间值坐标的写法。
最简单的写法是 c = (l + r) / 2,但直接相加会使得 l + r 大于 2^31-1 (2147483647) 时(提前)溢出,例如 l = 1, r = 2^31-1,计算 c 时, l + r = 2^31 (2147483648) 导致溢出。但原本应该有 c = 1073741824,l, r, c都不应该溢出,只是因为 l + r 导致了(提前)溢出。
因此改写成先减后加的形式 c = l + (r - l) // 2。这是最常见的形式。
很多人会用 >> 代替除法,写成 c = l + ((r - l) >> 1) 也是可以的。
值得一提的是JDK中采用的是 c = (l + r) >>> 1的写法。

  • ">>>" 是无符号右移运算符(Unsigned right shift operator),与 >> 的区别在于右移的时不考虑符号位,总是从左侧补0,l + r 不溢出的时候符号位本来就是0,与 >> 效果相同。 l + r 溢出时最高位符号位从0进位成了1,经过 >>> 的移位,最高位又变回了0,这是一种利用位运算的trick,可以参考这里。
  • 需要注意的是若采用此种写法,需要保证 (l + r) 为非负整数。因为若 (l + r) 为负数,经过高位补0后将得到错误的正数。通常情况下,l 与 r 代表下标,不会出现负数情况,但有的题目要在包含正负数的范围内,对这些「数值」(而非下标)进行二分查找, l 和 r 表示可能为正也可能为负的「数值」,此时就不用能 >>> 的写法。例如462. 最少移动次数使数组元素相等 II 题就不能采用 >>> 写法。

循环不变原则

跟踪循环中变化的细节是困难的,因此我们需要找到一些在整个循环过程中都不会发生变化的「量」或「关系」,以便得到循环结束后某些确定的结论。

if nums[c]<target: #1
    l=c+1 
else: #2
    r=c-1 
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于target。
  • 对于#2行,若进入该分支,则 r 下标更新后其右侧元素「必」大于等于target。

在理解了「循环不变」原理后,编写这个版本的代码时尝试寻找 l 或 r 更新后是否能有类似target必在或必不在某个确定的范围的「循环不变」关系。

模板一(错位终止,左闭右闭)

标志——

  • l,r=0,n-1
  • while l<=r
  • l=c+1,r=c-1
  • l=r+1(终止时)

四种一般情形——

  • 情形1: 大于等于。有相等元素时返回等于下标,否则返回刚好大于下标,否则返回 -1。(704题要求返回等于下标或-1)
  • 情形2: 大于。不考虑相等,返回刚好大于下标,否则返回-1。
  • 情形3: 小于等于。有相等元素时返回等于下标,否则返回刚好小于下标,否则返回 -1。(704题要求返回等于下标或-1)
  • 情形4: 小于。不考虑相等,返回刚好小于下标,否则返回-1。

错位终止

while l<=r:

结束时必有: r = l - 1

证明示例如下:

左闭右闭

l 与 r 的初始取值为:l,r=0,n-1
常规思路,[l,r] 刚好覆盖所有元素。
注意:如果模板二也采取左闭右闭的形式,可能会报错,后有详细解释。

情形1:大于等于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n-1
    while l<=r:
        c=l+(r-l)//2
        if nums[c]<target: #1
            l=c+1
        else: #2
            r=c-1
    #return l if (l!=n and nums[l]==target) else -1 # 处理相等返回情形
    return l if l!=n else -1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后其右侧元素「必」大于等于 target。
  • 结束时,由于 l 左侧元素必小于 target,所以下标 l 对应元素必大于等于 target。
  • (特殊)若 nums 所有元素均小于 target:由[循环不变]原则可知 r 保持不变(n-1),l 不断右移直至 l=r+1=n。而真实情况应为 -1,所以有末尾的判断语句。

情形2:大于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n-1
    while l<=r:
        c=l+((r-l)>>1)
        if nums[c]<=target: #1
            l=c+1 
        else: #2
            r=c-1
    return l if l!=n else -1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于等于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后其右侧元素「必」大于 target。
  • 结束时,由于 l 左侧元素必小于等于 target,所以下标 l 对应元素必大于 target。
  • (特殊)若 nums 所有元素均小于等于 target:由[循环不变]原则可知 r 保持不变(n-1),l 不断右移直至 l=r+1=n。而真实情况应为 -1,所以有末尾的判断语句。

情形3:小于等于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n-1
    while l<=r:
        c=l+(r-l)//2
        if nums[c]<=target: #1
            l=c+1
        else: #2
            r=c-1
    #return r if (nums[l]==target) else -1 # 处理相等返回情形
    return r
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于等于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后其右侧元素「必」大于 target。
  • 结束时,由于 r 右侧元素必大于 target,所以下标 r 对应元素必小于等于 target。
  • (特殊)若 nums 所有元素均大于 target:由[循环不变]原则可知 l 保持不变(0),r 不断左移直至 r=l-1=-1。恰好与真实情况相同,所以无须像情形1或2那样添加判断语句。

情形4:小于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n-1
    while l<=r:
        c=l+(r-l)//2
        if nums[c]<target:
            l=c+1
        else:
            r=c-1
    return r
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后其右侧元素「必」大于等于 target。
  • 结束时,由于 r 右侧元素必大于等于 target,所以下标 r 对应元素必小于 target。
  • (特殊)若 nums 所有元素均大于等于 target:由[循环不变]原则可知 l 保持不变(0),r 不断左移直至 r=l-1=-1。恰好与真实情况相同,所以无须像情形1或2那样添加判断语句。

模板二(相等终止,左闭右开)

标志——

  • l,r=0,n
  • while l<r
  • l=c+1,r=c
  • l=r(终止时)

相等终止

while l<r:

结束时必有: r = l

证明示例如下:

左闭右开

l 与 r 的初始取值为:l,r=0,n
错误示范,若采用左闭右闭 [l,r]:

  • 由于 while 的条件是 l < r,当nums只有一个元素时,将无法进入while
  • 当 target 大于 nums 中所有元素时,r = n 将是这一情况的一个标志,倘若 r 初始值为 n - 1,只看 r 的最终取值是无法判断为上述情况的,仍需要比较一次 target 与 nums 中的最后一个元素。
  • 在后续「情形3」和「情形4」的代码中还可以进一步体会将 r 初始值设置为 r = n 带来的统一返回值的好处。

情形1:大于等于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n
    while l<r:
        c=l+(r-l)//2
        if nums[c]<target: #1
            l=c+1
        else: #2
            r=c
    #return r if (l!=n and nums[r]==target) else -1 # 处理相等返回情形
    return r if l!=n else -1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后r 及其右侧元素「必」大于等于 target。
  • 结束时,由于 r 及其右侧元素必大于等于 target,所以下标 r 对应元素必大于等于 target。
  • (特殊)若 nums 所有元素均小于 target:由[循环不变]原则可知 r 保持不变(n),l 不断右移直至 l=r=n。而真实情况应为 -1,所以有末尾的判断语句。

情形2:大于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n
    while l<r:
        c=l+(r-l)//2
        if nums[c]<=target: #1
            l=c+1
        else: #2
            r=c
    return r if l!=n else -1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于等于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后r 及其右侧元素「必」大于 target。
  • 结束时,由于 r 及其右侧元素必大于 target,所以下标 r 对应元素必大于 target。
  • (特殊)若 nums 所有元素均小于等于 target:由[循环不变]原则可知 r 保持不变(n),l 不断右移直至 l=r=n。而真实情况应为 -1,所以有末尾的判断语句。

情形3:小于等于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n
    while l<r:
        c=l+(r-l)//2
        if nums[c]<=target:
            l=c+1
        else:
            r=c
    #return r-1 if (nums[r]==target) else -1 # 处理相等返回情形
    return r-1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于等于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后r 及其右侧元素「必」大于 target。
  • 结束时,由于 r 及其右侧元素必大于 target,所以下标 r-1 对应元素必小于等于 target。
  • (特殊)若 nums 所有元素均大于 target:由[循环不变]原则可知 l 保持不变(0),r 不断左移直至 r=l=0,即r-1=-1。恰好与真实情况相同,所以无须像情形1或2那样添加判断语句。

情形4:小于(target)

def binarySearch(nums,target):
    n=len(nums)
    l,r=0,n
    while l<r:
        c=l+(r-l)//2
        if nums[c]<target: #1
            l=c+1
        else: #2
            r=c
    return r-1
  • 对于#1行,若进入该分支,则 l 下标更新后其左侧元素「必」小于 target。
  • 对于#2行,若进入该分支,则 r 下标更新后r 及其右侧元素「必」大于等于 target。
  • 结束时,由于 r 及其右侧元素必大于等于 target,所以下标 r-1 对应元素必小于 target。
  • (特殊)若 nums 所有元素均大于 target:由[循环不变]原则可知 l 保持不变(0),r 不断左移直至 r=l=0,即r-1=-1。恰好与真实情况相同,所以无须像情形1或2那样添加判断语句。

内置函数

使用前提:数组有序

python

Python中的二分函数为 bisect_left / bisect / bisect_right。
bisect_left返回「大于等于」x的(第一个)元素下标,若都小于x,返回最后一个元素下标+1(即类似n),使用的是「模版二」的情形1写法。

def bisect_left(a, x, lo=0, hi=None, *, key=None):
    """Return the index where to insert item x in list a, assuming a is sorted.
    The return value i is such that all e in a[:i] have e < x, and all e in
    a[i:] have e >= x.  So if x already appears in the list, a.insert(i, x) will
    insert just before the leftmost x already there.
    Optional args lo (default 0) and hi (default len(a)) bound the
    slice of a to be searched.
    """

    if lo < 0:
        raise ValueError('lo must be non-negative')
    if hi is None:
        hi = len(a)
    # Note, the comparison uses "<" to match the
    # __lt__() logic in list.sort() and in heapq.
    if key is None:
        while lo < hi:
            mid = (lo + hi) // 2
            if a[mid] < x:
                lo = mid + 1
            else:
                hi = mid
    else:
        while lo < hi:
            mid = (lo + hi) // 2
            if key(a[mid]) < x:
                lo = mid + 1
            else:
                hi = mid
    return lo

bisect与bisect_right用法相同,返回「大于」x 的(第一个)元素下标,若都小于x,返回最后一个元素下标+1(即类似n),使用的是「模版二」的情形2写法。

def bisect_right(a, x, lo=0, hi=None, *, key=None):
    """Return the index where to insert item x in list a, assuming a is sorted.
    The return value i is such that all e in a[:i] have e <= x, and all e in
    a[i:] have e > x.  So if x already appears in the list, a.insert(i, x) will
    insert just after the rightmost x already there.
    Optional args lo (default 0) and hi (default len(a)) bound the
    slice of a to be searched.
    """

    if lo < 0:
        raise ValueError('lo must be non-negative')
    if hi is None:
        hi = len(a)
    # Note, the comparison uses "<" to match the
    # __lt__() logic in list.sort() and in heapq.
    if key is None:
        while lo < hi:
            mid = (lo + hi) // 2
            if x < a[mid]:
                hi = mid
            else:
                lo = mid + 1
    else:
        while lo < hi:
            mid = (lo + hi) // 2
            if x < key(a[mid]):
                hi = mid
            else:
                lo = mid + 1
    return lo
posted @ 2022-05-21 20:32  岸南  阅读(223)  评论(0)    收藏  举报