二分查找详解

Posted beeblog72

tags:

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

看到一个大佬的博客详解二分查找算法,有一段内容让我深有感触:

我周围的人几乎都认为二分查找很简单,但事实真的如此吗?二分查找真的很简单吗?并不简单。看看 Knuth 大佬(发明 KMP 算法的那位)怎么说的:

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

这句话可以这样理解:思路很简单,细节是魔鬼。

这两个月刷题遇到不少要用到二分查找的题。当年学数据结构的时候觉得这是一个相当直观且好理解的算法,但是真正刷题时觉得这个算法需要注意的坑还是挺多的。最普通的应用就是找某个元素的索引(数组有序且不重复),再复杂一些的还有找某个元素最左边或最右边的索引。更高端的有对数组的索引或者数组中整数的取值范围进行二分查找,不过这一块还是万变不离其宗,查找的范围依旧是[left, right],难点在于要怎么找到二分查找的对象。

二分查找基本框架

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    left, right = 0, ...  # 左右边界的赋值可变
    while left ... right:  # 需要注意有没有等号
        mid = left + (right - left) // 2
        if arr[mid] == target:
            ...  # 要不要直接return
        elif arr[mid] < target:
            left = ...  # 要不要加一
        elif arr[mid] > target:
            right = ...  # 要不要减一
    return ...  # 有返回mid的,有left的各种

上面一段代码中的...部分是需要根据题目需要修改的地方,也就是二分查找的细节所在。另外,计算mid的公式也可以写成mid = (left + right) // 2,按上面那样写是为了防止溢出(虽然在Python里并不会有整型溢出的问题,不过最好养成这个习惯)。

找一个数的索引

这是二分查找最简单的一种应用,只要学习过数据结构肯定闭着眼睛都能写出来。

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    left, right = 0, n - 1
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid - 1
    return -1  # 没有找到,返回 -1

这里有几个地方需要注意。

左右指针的赋值

左右指针的初始化决定了搜索区间是开区间还是闭区间

左右指针初始化为left, right = 0, n - 1,也就是说搜索区间是一个闭区间,即[0, n - 1]。而当mid处的值不是目标值时,就要把mid从搜索区间中去除,继续在两边的某个闭区间中搜索。因此,左右指针的更新规则为left = mid + 1right = mid - 1

终止条件

搜索区间为空时就应该跳出循环

上面的代码中,while循环的条件是left <= right,也就是说当left == right + 1时跳出while循环。实际上,这个终止条件与前面说的闭区间相对应,当left == right时,闭区间内仍有一个索引的位置需要搜索;当left == right + 1时,[right + 1, right]已经是一个空集,意味着已经没有索引需要搜索了,因此就跳出循环。

开区间

爷就是喜欢开区间,那咋办嘛

开区间情况下,左右指针应初始化为left, right = 0, n,对应的搜索区间是一个开区间,即[0, n)

  • arr[mid] < target时,同样地,要把mid从搜索区间中去除,注意此时是开区间。于是left = mid + 1,对应的搜索区间为[mid + 1, right);
  • arr[mid] > target时,right = mid, 对应的搜索区间为[left, mid)

此时,while循环的条件应为left < right,也就是说当left == right时跳出while循环。对应的搜索区间为[left, left),显然这个区间为空,即搜索完毕。完整代码如下。

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    left, right = 0, n
    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    return -1  # 没有找到,返回 -1

局限性

这个版本的二分查找至少还有两个需求无法满足:

  • 如果target在数组中多次出现,我们想要找到它的左边界(即最早出现时的索引)或者右边界要怎么改呢?
  • 如果target在数组中不出现,我们想要找到它插入到该数组中应该在的位置要怎么改呢?

寻找左侧边界

基于开区间版本,修改几个位置即可。

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    if n == 0:  # 特判
    	return -1
    left, right = 0, n
    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            right = mid  # 修改
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    return left  # 修改

代码还可以进一步简化,但是本文主要是为了弄清原理,这么写看起来更直观一些。基于这三处修改,我们来看看为什么这个版本可以返回左侧边界。

为什么需要特判

实际上也不是所有情况都需要特判,因为很多时候我们不会在空数组中搜索,那不是吃饱了撑的吗。主要是为了和返回的索引区分开来,避免返回值与空数组的情况发生混淆,具体往下看就明白了。

返回值的意义

这个版本的代码除了当target在数组中多次出现时返回它的左侧边界,它的返回值还有一个含义就是当前数组中比target小的元素个数。因此返回值的范围是[0, n],如果没有特判,数组为空时也返回0,会发生混淆。

为什么可以找到左边界

关键在于这段代码:

if arr[mid] == target:
    right = mid

当找到target时,不返回索引,而是继续向左搜索,即在[left, mid)中搜索。

那么问题来了,如果此时mid已经是左边界了,继续在[left, mid)中搜索不会出错吗?事实上,每次左指针的更新规则为left = mid + 1且while循环的条件是left < right。也就是说最终仍然会搜索到[left, mid),此时left == mid

寻找右侧边界

基于上面的代码,修改一下就行。

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    if n == 0:  # 特判
    	return -1
    left, right = 0, n
    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            left = mid + 1  # 修改
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    return left - 1  # 修改

为什么要返回left - 1

注意到,当找到target时,不返回索引,而是继续向右搜索,left = mid + 1。因此,while循环结束时的左指针一定指向target右边的第一个元素。因此要返回left - 1

寻找插入位置

上面提到,寻找左侧边界版本的返回值同样代表了数组中比target小的元素个数,索引不就来了吗?

def binarySearch(arr: List[int], target: int):
    n = len(arr)
    left, right = 0, n
    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            right = mid  # 修改
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    return left  # 修改

需要注意的是,这里不需要特判,数组为空时直接往里放就完事了。

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

二分查找详解

java 二分查找法

二分查找算法案列详解

详解二分查找算法

《详解二分查找》视频解说

二分查找算法详解