二分查找算法算法指导 意境级讲解

Posted 炫云云

tags:

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


二分查找又称折半搜索算法。 狭义地来讲,二分查找是一种在有序数组查找某一特定元素的搜索算法。这同时也是大多数人所知道的一种说法。实际上, 广义的二分查找是将问题的规模缩小到原有的一半。类似的,三分法就是将问题规模缩小为原来的 1/3。

问题定义

给定一个由数字组成的有序数组 nums,并给你一个数字 target。问 nums 中是否存在 target。如果存在, 则返回其在 nums 中的索引。如果不存在,则返回 - 1。

  • 这是二分查找中最简单的一种形式。当然二分查找也有很多的变形,这也是二分查找容易出错,难以掌握的原因。常见变体有:
  • 如果存在多个满足条件的元素,返回最左边满足条件的索引。
  • 如果存在多个满足条件的元素,返回最右边满足条件的索引。
  • 数组不是整体有序的。 比如先升序再降序,或者先降序再升序。
  • 将一维数组变成二维数组。
  • 。。。

接下来,我们逐个进行查看。

前提:

数组是有序的(如果无序,我们也可以考虑排序,不过要注意排序的复杂度)

二分查找中使用的术语:

  • target —— 要查找的值
  • index —— 当前位置
  • left 和 right —— 左右指针
  • mid —— 左右指针的中点,用来确定我们应该向左查找还是向右查找的索引

成功的二分查找的 3 个部分

二分查找一般由三个主要部分组成:

  1. 预处理 —— 如果集合未排序,则进行排序。

  2. 二分查找 —— 使用循环或递归在每次比较后将查找空间划分为两半。

  3. 后处理 —— 在剩余空间中确定可行的候选者。

模版一

查找一个数

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

示例 1:

输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

算法描述:

  • 先从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;
  • 如果目标元素大于中间元素,则在数组大于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
  • 如果目标元素小于中间元素,则在数组小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
  • 如果在某一步骤数组为空,则代表找不到。

复杂度分析

  • 平均时间复杂度: O ( l o g N ) O(logN) O(logN)
  • 最坏时间复杂度: O ( l o g N ) O(logN) O(logN)
  • 最优时间复杂度: O ( 1 ) O(1) O(1)

空间复杂度

  • 迭代: O ( 1 ) O(1) O(1)
  • 递归: O ( l o g N ) O(logN) O(logN)(无尾调用消除)

后面的复杂度也是类似的,不再赘述。

这种搜索算法每一次比较都使搜索范围缩小一半,是典型的二分查找。

这个是二分查找中最简答的一种类型了,我们先来搞定它。 我们来一个具体的例子,假设 nums 为 [-1,0,3,5,9,12], target 为 9。

  • 刚开始数组中间的元素为 3。
  • 9 > 3 ,由于 3 左边的数字都小于 9 ,因此不可能是答案。我们将范围缩写到了 3 的右侧。
  • 此时中间元素为 5。
  • 5 > 3,由于 5 左边的数字都小于 9 ,因此不可能是答案。我们将范围缩写到了 5 的右侧。
  • 此时中间元素为 9,正好是我们要找的target,返回其索引 4 即可。

如何将上面的算法转换为容易理解的可执行代码呢?

def binarySearch(nums, target):
    """
    :type nums: List[int]
    :type target: int
    :rtype: int
    """
    if len(nums) == 0:
        return -1
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid]<target:
            left = mid + 1
        else:
            right = mid-1

    # End Condition: left > right
    return -1

寻找第一个的满足条件的位置

例如nums = [5,7,7,8,8,10], target = 8,第一个的满足条件的位置=3.

查找一个数类似, 我们仍然套用查找一个数的思维框架和代码模板。

思维框架

  • 首先定义搜索区间为 [left, right]。
  • 终止搜索条件为 left > right。
  • 循环体内,我们不断计算 mid ,并将 nums[mid] 与 目标值比对。
    • 如果 nums[mid] 等于目标值, 则收缩右边界,我们找到了一个备胎,由于我们要找第 1 个位置,此时我们应该向左边继续查找;(注意这里不一样
    • 如果 nums[mid] 小于目标值, 说明目标值在 mid 右侧,这个时候搜索区间可缩小为 [mid + 1, right]
    • 如果 nums[mid] 大于目标值, 说明目标值在 mid 左侧,这个时候搜索区间可缩小为 [left, mid - 1]
  • 由于不会提前返回,因此我们需要检查最终的 left,看 nums[left]是否等于 target。
    • 如果不等于 target,或者 left 出了右边边界了,说明至死都没有找到一个备胎,则返回 -1.
    • 否则返回 left 即可,备胎转正。

代码模板:

实际上 nums[mid] > target 和 nums[mid] == target 是可以合并的。我这里为了清晰,就没有合并,大家熟悉之后合并起来即可。

def findFirstPosition(nums, target):
    l, r = 0, len(nums) - 1
    while l <= r:
        mid = (l + r) // 2
        if nums[mid] == target:
            # ① 不可以直接返回,应该继续向左边找,即 [left..mid - 1] 区间里找
            # 收缩右边界
            r = mid - 1;
        elif nums[mid] < target:  # 应该继续向右边找,搜索区间变为 [mid+1, right]
            l = mid + 1
        else: #  nums[mid] > target ,应该继续向左边找 ,搜索区间变为 [left, mid - 1]
            r = mid - 1
    # 此时 left 和 right 的位置关系是 [right, left],注意上面的 ①,此时 left 才是第 1 次元素出现的位置
    # 因此还需要特别做一次判断
    if l >= len(nums) or nums[l] != target: 
        return -1
    return l

寻找最后一个的满足条件的值

例如nums = [5,7,7,8,8,10], target = 8,最后一个的满足条件的位置=4.

查找一个数类似, 我们仍然套用查找一个数的思维框架和代码模板。

思维框架

  • 首先定义搜索区间为 [left, right]。

  • 终止搜索条件为 left > right。

  • 循环体内,我们不断计算 mid ,并将 nums[mid] 与 目标值比对。

    • 如果 nums[mid] 等于目标值, 则收缩左边界,我们找到了一个备胎,继续看看右边还有没有了
    • 如果 nums[mid] 小于目标值, 说明目标值在 mid 右侧,这个时候搜索区间可缩小为 [mid + 1, right]
    • 如果 nums[mid] 大于目标值, 说明目标值在 mid 左侧,这个时候搜索区间可缩小为 [left, mid - 1]
  • 由于不会提前返回,因此我们需要检查最终的 right,看 nums[right]是否等于 target。

    • 如果不等于 target,或者 right 出了左边边界了,说明至死都没有找到一个备胎,则返回 -1.
    • 否则返回 right 即可,备胎转正。

代码模板:

实际上 nums[mid] < target 和 nums[mid] == target 是可以合并的。我这里为了清晰,就没有合并,大家熟悉之后合并起来即可。

def findLastPosition(nums, target):
    # 左右都闭合的区间 [l, r]
    l, r = 0, len(nums) - 1
    while l <= r:
        mid = (l + r) // 2
        if nums[mid] == target:
            # 只有这里不一样:不可以直接返回,应该继续向右边找,即 [mid + 1, right] 区间里找
            # 收缩左边界
            l = mid + 1;
        # 应该继续向右边找,搜索区间变为 [mid+1, right]
        elif nums[mid] < target: 
            l = mid + 1
        # 应该继续向左边找,搜索区间变为 [left, mid - 1]
        elif nums[mid] > target: 
            r = mid - 1
    if r < 0 or nums[r] != target: return -1
    return r

二分查找的问题变种

事实上,「力扣」上的「二分查找」问题没有那么简单。例如,让我们找:

  • 大于等于 target 的下标最小的元素;
  • 小于等于 target 的下标最大的元素。

这样的问题有一个特点:当看到了 nums[mid] 恰好等于 target 的时候,还得继续查找,继续看看左边的元素的值,或者继续看看右边元素的值。如果不小心,很可能逻辑写错。如果还用「1. 二分查找的基本问题」介绍的方式编写代码,就没有那么容易:

while 里面的 if 、else 该怎么写,有没有什么固定的思路?

退出循环以后,返回 left 还是 right 需要分类讨论。

本题解要介绍的「二分查找」的思想其实不是什么新鲜的事儿。如下图所示,它像极了「双指针」算法,left 和 right 向中间走,直到它们重合在一起。

在这里插入图片描述

把待搜索区间分成两个部分

根据看到的中间位置的元素的值 nums[mid] 可以把待搜索区间分为两个部分:

  • 一定不存在 目标元素的区间:下一轮搜索的时候,不用考虑它;
  • 可能存在 目标元素的区间:下一轮搜索的时候,需要考虑它。

由于 mid 只可能被分到这两个区间的其中一个,即:while 里面的 if 和 else 就两种写法:

  • 如果 mid 分到左边区间,即区间分成 [left,mid] 与 [mid + 1,right],此时分别设置 right = mid 与 left = mid + 1;
  • 如果 mid 分到右边区间,即区间分成 [left,mid - 1] 与 [mid,right],此时分别设置 right = mid - 1 与 left = mid。

并且把循环条件写成 while (left < right)。在上面把待搜索区间分成两个部分的情况下,退出循环以后一定会有 left == right 成立,因此在退出循环以后,不需要考虑到底返回 left 还是返回 right。

这里介绍一个 「重要的经验」:

在 写 if 语句的时候,通常把容易想到的,不容易出错的逻辑写在 if 的里面,这样就把复杂的、容易出错的情况放在了 else 的部分,这样编写代码不容易出错。

什么情况是容易想到的,不容易出错的呢?我的经验是:题目要我们找符合条件 a 的元素,我们就对条件 a 取反面,这样分析不容易出错。

例如(搜索插入位置),题目要我们找出第一个大于等于 target 的元素的下标,那么小于 target 的元素就一定不是我们要找的。因此 if 语句就是

if nums[mid] < target:
	# 下一轮搜索区间是 [mid + 1,right]
	left = mid + 1

剩下的情况放在 else 中,我们 甚至可以不用分析 else 是什么情况。if 的区间是 [mid + 1,right],它的反面区间就是 [left,mid],此时 else 就应该设置 right = mid。

因此完整的逻辑就是:

if nums[mid] < target:
	# 下一轮搜索区间是 [mid + 1,right]
	left = mid + 1
else:
	right = mid

为什么是 left = mid + 1 、 right = mid 搭配使用,而 left = mid 、 right = mid - 1 搭配使用,这一点完全不用记忆,我们画图说明。

在这里插入图片描述

大家去思考下一轮搜索应该在哪个区间里,就能考虑清楚到底下一轮更新的是 left 还是 right ,到底加不加 1,到底减不减 1。

  • 使用left = mid + 1 、 right = mid 时,mid要向下取整。mid = int(left + (right - left) / 2)

  • 使用left = mid 、 right = mid - 1时,mid要向上取整。mid = int(left + (right - left+1) / 2),这是 为了防止在区间只有两个元素的时候出现死循环;

下图解释了取 mid 的时候上下取整的原因。

在这里插入图片描述

搜索插入位置

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

你可以假设数组中无重复元素。

示例 1:

输入: [1,3,5,6], 5
输出: 2

示例 2:

输入: [1,3,5,6], 2
输出: 1

示例 3:

输入: [1,3,5,6], 7
输出: 4

算法思路:

需要返回第 1 个 大于等于(等于的情况可以看示例 1,这里省略) target的下标。因此 如果当前 mid 看到的数值严格小于 target,那么 mid 以及 mid 左边的所有元素就一定不是题目要求的输出,就根据这一点可以写出本题二分查找算法的完整逻辑。

class Solution(object):
    def searchInsert(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        # 特殊判断
        if nums[-1] < target:
            return len(nums)
        left = 1
        right = len(nums) - 1
        #在区间 nums[left,right] 里查找第 1 个大于等于 target 的元素的下标
        while(left<right):
            mid = int(left + (right - left) / 2) # 防止计算时溢出
            if nums[mid] < target: # target只会在[mid + 1,right]
                # 下一轮搜索的区间是 [mid + 1,right]
                left = mid+1
            else:  # target只会在[left,mid]
                right = mid
        # End Condition: left == right
        return left

这样的思路还可以完成查找一个数的问题:

def binarySearch(nums, target):
    """
    :type nums: List[int]
    :type target: int
    :rtype: int
    """
    if len(nums) == 0:
        return -1
    left, right = 0, len(nums) - 1
    while left < right:
        mid = int(left + (right - left) / 2) # 防止计算时溢出
        if nums[mid] < target: # target只会在[mid + 1,right]
            # 下一轮搜索的区间是 [mid + 1,right]
            left = mid+1
        else:  # target只会在[left,mid]
            right = mid
    # End Condition: left == right
    if nums[left] == target:
        return left
    return -1

模版二

模板 #2 是二分查找的高级模板。它用于查找需要访问数组中当前索引及其直接右邻居索引的元素或条件。

def binarySearch(nums, target):
    """
    :type nums: List[int]
    :type target: int
    :rtype: int
    """
    if len(nums) == 0:
        return -1

    left, right = 0, len(nums)
    while left < right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1   # 搜索区间变为 [mid + 1, right]
        else:
            right = mid  # 搜索区间变为 [left, mid]

    # Post-processing:
    # End Condition: left == right
    if left != len(nums) and nums[left] == target:
        return left
    return -1

关键属性

  • 一种实现二分查找的高级方法。
  • 查找条件需要访问元素的直接右邻居。
  • 使用元素的右邻居来确定是否满足条件,并决定是向左还是向右。
  • 保证查找空间在每一步中至少有 2 个元素。
  • 需要进行后处理。 当你剩下 1 个元素时,循环 / 递归结束。 需要评估剩余元素是否符合条件。

while(left <= right) 与 while(left < right) 写法的区别

我的理解如下:

首先抓住它们最主要的特征:

  • while(left <= right) 在退出循环的时候 left = right + 1,即 right 在左,left 在右;

  • while(left < right) 在退出循环的时候,有 left == right 成立。

我的经验是 left <= right 用在简单的二分问题中,如果题目要我们找的数的性质很简单,可以用这种写法,在 循环体里找 到了就退出,比如上面的查找一个数。

在一些复杂问题中,例如找一些边界的值(就比如当前这个问题),用 while(left < right) 其实是更简单的,把要找的数留到最后,在退出循环以后做判断。我觉得最重要的原因是退出循环以后有 left == right 成立,这种思考问题的方式不容易出错。

while(left < right)写法难点在于理解:初学的时候很难理解出现死循环的原因。特别是很难理解分支的取法决定中间数的取法。不过通过练习和调试,把这一关过了,相信解决一些难度较大的额额分查找问题就相对容易了。建议大家尝试使用 while(left < right) 的方式去解决一些较困难的问题。

寻找第一个的满足条件的位置

例如nums = [5,7,7,8,8,10], target = 8,第一个的满足条件的位置=3.

def findFirstPosition(nums, target):
    l, r = 0, len(nums) - 1
    while l < r:
        mid = l  + (r  - l ) // 2
        # 小于一定不是解
        if nums[mid] < target: # 搜索区间变为 [mid+1, right]
            l = mid + 1
        else:  #nums[mid] >= target , mid可能是解,可能不是
            r = mid         #  收缩右边界,搜索区间变为 [left, mid]
        
    if l >= len(nums) or nums[l] != target: return -1
    return l

寻找最后一个的满足条件的值

例如nums = [5,7,7,8,8,10], target = 8,最后一个的满足条件的位置=4.

def findLastPosition(nums, target):
    # 左右都闭合的区间 [l, r]
    l, r = 0, len(nums) - 1
    while l <  r:
        mid = l  + (r  - l + 1) / 2
        # 大于一定不是解
        if nums[mid] > target: # 搜索区间变为 [left,mid - 1]
            r = mid - 1
        else:  #nums[mid] <= target , mid可能是解,可能不是
            l = mid         #  收缩左边界,搜索区间变为 [mid,right]
    if r < 0 or nums[r] != target: return -1
    return r

x 的平方根

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

示例 1:

输入: 4
输出: 2

示例 2:

输入: 8
输出: 2
说明: 8 的平方根是 2.82842..., 
     由于返回类型是整数,小数部分将被舍去。

方法一:二分查找

由于 x x x 平方根的整数部分 ans \\textit{ans} ans 是满足 k 2 ≤ x k^2 \\leq x k2x的最大 k k k 值,因此我们可以对 k k k 进行二分查找,从而得到答案。

二分查找的下界为 0,上界可以粗略地设定为 x x x。在二分查找的每一步中,我们只需要比较中间元素 mid \\textit{mid} mid 的平方与 x x x 的大小关系,并通过比较的结果调整上下界的范围。

在判断是否满足条件要用除,不能直接mid * mid,因为如果mid过大会溢出,除的话分2种,一种是x / mid < mid,说明 mid大了, 即最优解在[left,mid],压缩右边界,即right =mid

第2种情况是 x / mid >= mid,说明 mid 太小了,即最优解在[mid+1,right],那么就让下边界往上去一点, left = mid +1,为什么这里不减1呢?因为控制left总比x的根大一点,这样返回最终结果直接返回 left - 1即可,代码如下:

class Solution(object):
    def mySqrt(self, x):
        """
        :type x: int
        :rtype: int
        """
        if x==0 or x==1:
            return x
        left=0
        right = x
        while left<right:
            mid =  left + (right - left) // 2 # 防止计算时溢出
            if  mid> x/mid: # 最优解在`[left,mid]`
                right =mid 
            else:             # 最优解在`[mid+1,right]`
                left = mid +1
        return left-1

复杂度分析

时间复杂度: O ( log ⁡ x ) O(\\log x) O(logx) ,即为二分查找需要的次数。

空间复杂度: O ( 1 ) O(1) O(1)

方法二:牛顿迭代

牛顿法求根,原理是使用函数 f ( x ) f(x) f(x) 的泰勒级数的前面几项来寻找方程 f ( x ) = 0 f(x)=0 f(x)=0 的根。

将函数 f ( x ) f(x) f(x) x 0 x_0 x0 处展开成泰勒级数:

f ( x ) = f ( x 0 ) + f ′ ( x 0 ) ( x − x 0 ) + 1 2 f ′ ′ ( x 0 ) ( x − x 0 ) 2 + … (5) f(x)=f(x_0)+f^{'}(x_0)(x-x_0)+\\frac{1}{2}f^{''}(x_0)(x-x_0)^2+\\dots \\tag5 f(x)=f(x0)+f(x0)(xx0)以上是关于二分查找算法算法指导 意境级讲解的主要内容,如果未能解决你的问题,请参考以下文章

8.5 意境级讲解迁移学习

意境级讲解 jieba分词和词云关键词抽取:TextRankTF-IDF

一图学懂信奥赛基石级考点:二分查找

21 意境级讲解 共指消解的方法

5.8 拉普拉斯算子和拉普拉斯矩阵,图拉普拉斯算子推导 意境级讲解

算法初步:二分查找