二分查找_模板学习(减治思想)

首先用一个题进行二分查找的引入

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

示例 1:

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

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

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

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

分析

方法一:暴力解法

  • 从数组的左边遍历到右边,如果遇到相等的元素,直接返回下标;

  • 遇到第 1 个严格大于 target 的元素,返回这个元素的下标;

  • 如果数组里所有的元素都严格小于 target,返回数组的长度 len。

    public int searchInsert(int[] nums, int target) {
        for(int i = 0; i < nums.length; i++) {
            if(target <= nums[i]){
                return i;
            }
        }
        return nums.length;
    }

python

class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
        for i in range(len(nums)):
            if nums[i] >= target:
                return i

        return len(nums)
    
    
# 或者
class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
    if target in list:
        return list.index(target)
    else:
        list.append(target)
        list.sort()
        return list.index(target)

方法二: 二分查找

分析:在有序数组中查找插入元素的位置,显然可以使用二分查找。这篇题解提供的思路是「排除法」,思路是:在循环的过程中,不断排除不需要的解,最后剩下的那个元素的位置就一定是插入元素的位置。

具体来说:

  • 首先,插入位置有可能在数组的末尾(题目中的示例 3),需要单独判断,此时返回数组的长度;
  • 否则,根据示例和暴力解法的分析,插入的位置是大于等于 target第 1 个元素的位置。

因此,严格小于 target 的元素一定不是解,在循环体中将左右边界 leftright 逐渐向中间靠拢,最后 left 和 right 相遇,则找到了插入元素的位置。

插入元素的位置可能在数组的末尾下标的下一个(见例 3),因此在初始化右边界 right 的时候,可以设置成为数组的长度 len,根据这个思路,可以写出如下代码。

	public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
        
        int left = 0;
        // 因为有可能数组的最后一个元素的位置的下一个是我们要找的,故右边界是 len
        int right = len;
        while (left < right) {
            int mid = left + (right - left) / 2;
            // 小于 target 的元素一定不是解
            if (nums[mid] < target) {
                // 下一轮搜索的区间是 [mid + 1, right]
                left = mid + 1;
            } else {
              	// 下一轮搜索的区间是 [left, mid]
                right = mid;
            }
        }
        return left;
    }
from typing import List


class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
        size = len(nums)
        if size == 0:
            return 0

        left = 0
        # 因为有可能数组的最后一个元素的位置的下一个是我们要找的,故右边界是 len
        right = size
        while left < right:
            mid = (left + right) // 2
            # 严格小于 target 的元素一定不是解
            if nums[mid] < target:
                # 下一轮搜索区间是 [mid + 1, right]
                left = mid + 1
            else:
                right = mid
        return left

二分查找算法简介

在这里插入图片描述

二分查找的思想

减而治之,即将大规模问题转化成小规模问题。减而治之是分而治之的特例,将大问题划分成若干个子问题以后,最终答案只在其中一个子问题里。

生活中的二分查找

以前央视有一档节目叫《幸运 52》,里面有个游戏「猜价格」,主持人说猜高了,观众就往低了猜,主持人说猜低了,观众就往高了猜,直至猜中为止。

程序员定位 bug,经常是在一些逻辑关键点做一些变量的打印输出,以逐渐缩小查找范围,最终定位出问题的代码行(或者块)。

二分查找的基本问题(二分查找模板一)

「力扣」第 704 题:二分查找

思路:先看中间位置的元素,如果恰好等于,则直接返回中间位置的下标,否则看中间位置元素的值 nums[mid] 和 target 的关系决定下一轮在哪一侧寻找目标元素。

这个思路把待查找数组分为了 3 个部分:mid 所在位置,mid 的左边,mid 的右边。根据 mid 元素与目标元素的值的大小关系,如果 nums[mid] 恰好等于 target 直接返回就好了,否则根据不等关系,确定下一轮搜索的区间在哪里。

public class Solution {

    public int search(int[] nums, int target) {
        int len = nums.length;

        int left = 0;
        int right = len - 1;
        // 目标元素可能存在在区间 [left, right]
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 目标元素可能存在在区间 [mid + 1, right]
                left = mid + 1;
            } else {
                // 目标元素可能存在在区间 [left, mid - 1]
                right = mid - 1;
            }
        }
        return -1;
    }
}

说明:

  • 循环可以继续的条件是 while (left <= right) ,表示当 left == right 成立的时候,还有一个元素,即下标 left(right)位置的元素还没有看到,需要继续查看这个元素的值,看看是不是目标元素;

  • 关于取中间数 int mid = (left + right) / 2; 在 left + right 很大的时候会发生整形溢出,一般这样改写:

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

这两种写法事实上没有太大的区别,在 left 和 right 都表示数组下标的时候,几乎不会越界,因为绝大多数情况下不会开那么长的数组。

在 Java 中还可以这样写

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

>>>,它表示,在 left + right 发生整型溢出的时候,高位补 0,结果依然正确。这一点是从 JDK 的源码中 Arrays.binarySearch() 方法借鉴来的。

在 Python 中不用这样改写,Python 在 left + right 发生整型溢出的时候会自动转成长整形。

这里不建议把 / 2 改写成 >> 1,理由是高级语言在编译期间会做优化,会将 / 2,以及除以 2 的方幂的操作,在内部修改为 >>,工程师只需要写程序本来的逻辑就好了。如果使用位运算,在 C++ 中可能还需要注意运算优先级的问题。

为什么取二分之一?三分之一、五分之四可不可以?
(对于这道问题)当然可以,大家可以自行验证。结合二分查找的思路并不难理解,其实只要在数组中间任意找一个位置的元素,如果恰好是目标元素,则直接返回。如果不是根据这个元素的值和目标元素的大小关系,进而在当前位置的左侧还是右侧继续查找。

在我们对问题一无所知的时候,取中间数是最好的做法(如果有学习过《机器学习》熵的概念可能会有感触)。

还有一个细节,/ 2 表示的是下取整,当数组中的元素个数为偶数的时候,int mid = left + (right - left) / 2; 只能取到位于左边的那个元素。那么取位于右边的那个元素可以吗,当然可以,理由同上。
取右边中间数的表达式是(其实就是在括号里 + 1,表示上取整):

int mid = left + (right - left) / 2;
以上的代码可以认为是二分查找的模板一。下面我们介绍二分查找模板二,应用这个模板的思想可以解决「力扣」上很多二分查找算法的变种和一些复杂的二分查找问题。

二分查找模板二(在循环体里排除不存在目标元素的区间)

基本思想

从考虑哪些元素一定不是目标元素开始考虑。在本题解最开始其实已经介绍了:根据看到的 mid 位置的元素,排除掉一定不可能存在目标元素的区间,而下一轮在可能存在目标的子区间里继续查找。

具体做法

  • 先把循环可以继续的条件写成 while (left < right),表示退出循环的时候,[left, right] 这个区间里只有一个元素,这个元素有可能就是目标元素;
  • 写 if 和 else 语句的时候,思考当 nums[mid] 满足什么性质的时候,num[mid] 不是解,进而接着判断 mid 的左边有没有可能是解,mid 的右边有没有可能是解;
    说明:
  • 做题的经验告诉我,思考什么时候不是解比较好想。如果一个数要满足多个条件,只需要对其中一个条件取反,就可以达到缩小搜索范围的目的;
  • 此时 mid 作为待查找数组的分界,就把它分为两个区间:一个部分可能存在目标元素,一个部分一定不存在目标元素。

理解如何避免死循环(重难点)

根据 mid 被分到左边区间还是右边区间,代码写出来只有以下 2 种:

边界收缩行为 1: mid 被分到左边。即区间被分成 [left, mid][mid + 1, right],这里用「闭区间」表示区间端点可以取到,下同;

代码写出来是这样的:

if (check(mid)) {
    // 下一轮搜索区间是 [mid + 1, right],因此把左边界设置为 mid + 1 位置
    left = mid + 1;
} else {
    // 上面对了以后,剩下的区间一定是 [left, mid],因此右边界设置为 mid 位置
    right = mid;
}

说明:这里的 check(mid) 函数通常是一个表达式,在一些情况下有可能逻辑比较复杂,建议专门抽取成一个私有方法,以突显主干逻辑。

边界收缩行为 2: mid 被分到右边。即区间被分成 [left, mid - 1] 与 [mid, right];

同上,代码写出来是这样的:

if (check(mid)) {
    // 下一轮搜索区间是 [left, mid - 1],因此把右边界设置为 mid - 1 位置
    right = mid - 1;
} else {
    // 上面对了以后,剩下的区间一定是 [mid, right],因此左边界设置为 mid 位置
    left = mid;
}

面对面的边界收缩行为 2(mid 被分到右边),在待搜索区间收缩到只剩下 2 个元素的时候,就有可能造成死循环。搜索区间不能缩小,是造成死循环的原因

有了上面的分析,我们把上面「边界收缩行为」对应的中间数取法补上:

边界收缩行为 1: mid 被分到左边。即区间被分成 [left, mid] 与 [mid + 1, right],此时取中间数的时候下取整。

int mid = left + (right - left) / 2;
if (check(mid)) {
    // 下一轮搜索区间是 [mid + 1, right]
    left = mid + 1;
} else {
    right = mid;
}

边界收缩行为 2: mid 被分到右边。即区间被分成 **[left, mid - 1] 与 [mid, right],**此时取中间数的时候上取整。

int mid = left + (right - left + 1) / 2;
if (check(mid)) {
    // 下一轮搜索区间是 [left, mid - 1]
    right = mid - 1;
} else {
    left = mid;
}

如果这里有疑惑,大家在练习的过程中,当发生死循环的时候:在 while 循环里把 left、right、mid 变量的值打印出来看看,就很清楚了。

遇到几次死循环,调试正确以后,就能很清楚地记住下面这条规则:在 if else 语句里面只要出现 left = mid 的时候,把去中间数行为改成上取整即可。

二分查找模板二(在循环体里排除不存在目标元素的区间)的一般步骤

1、确定搜索区间初始化时候的左右边界,有时需要关注一下边界值。在初始化时,有时把搜索区间设置大一点没有关系,但是如果恰好把边界值排除在外,再怎么搜索都得不到结果;

例如本题(「力扣」第 35 题),如果一开始把 len 这个位置排除在外进行二分搜索(在之前也没有特判),代码是怎么都通不过评测系统的。

2、先写上 while (left < right) ,表示退出循环的条件是 left == right,对于返回左右边界就不用思考了,因此此时它们下标的值相等;

3、先写下取整的中间数取法,然后**从如何把 mid 排除掉的角度思考 if 和 else 语句应该怎样写。**这里建议写两个注释,非必需。

  • 一般而言,我都会把**「什么时候不是目标元素」作为注释写在代码中,提醒自己要判断正确,**这一步判断非常关键,直接影响到后面的代码逻辑;

  • 然后接着思考 mid 不是解的情况下,mid 的左右两边可能存在解,**把下一轮搜索的区间范围作为注释写进代码里,**进而在确定下一轮搜索区间边界的收缩行为时,不容易出错。

  • if 有把握写对的情况下,else 就是 if 的反面,可以不用思考,直接写出来。

说明:这种思考方式,就正正好把待搜索区间从逻辑上分成两个区间,一个区间不可能存在目标元素,进而在另一个区间里继续搜索,更符合二分的语义。

4、根据 if else 里面写的情况,看看是否需要修改中间数下取整的行为;

只有看到 left = mid 的时候,才需要调整成为上取整,记住这一点即可,我因为刚开始不理解这种写法,遇到很多次死循环,现在已经牢记在心了。

5、退出循环的时候,一定有 left == right 成立**。有些时候可以直接返回 left (或者 right,由于它们相等,后面都省略括弧)或者与 left 相关的数值,有些时候还须要再做一次判断,判断 left 与 right 是否是我们需要查找的元素,这一步叫「后处理」。**

本题就是这样,因为插入元素的位置,一定在搜索范围里,因此退出循环的时候,不用再做一次判断。

与其它二分查找模板的比较

while 里面的表达式决定了退出循环以后区间 [left, right] 有几个元素。几个模板的主要区别就在这里。

1、 while (left <= right) 事实上是把待搜索区间「三分」,if else 有三个分支,它直接面对目标元素,在目标元素在待搜索数组中有只有 1 个的时候,可能提前结束查找。但是如果目标元素没有在待搜索数组中存在,则不能节约搜索次数;

2、while (left < right) 是本题解推荐使用的思考方法,不建议背模板,建议的方法是多做题,掌握解题的思路和技巧;

优点是:更符合二分语义,不用去思考返回 left 还是 right,在退出循环的时候,有的时候,根据语境,不正确的数都排除掉,最后剩下的那个数就一定是目标值,不需要再做一次判断。

这种方法思考的细节最少,写程序不易出错。

缺点是:需要理解当分支逻辑出现 left = mid 的时候,要修改取中间数的行为,使其上取整。

3、while (left + 1 < right) 这种写法其实很多人都在用,如果你理解了本题解介绍的方法,理解它就很容易了。使用它在退出循环的时候,有 left + 1 = right 成立,即 left 和 right夹成的区间里一定有 2 个元素,此时需要分别判断 left 和 right 位置的元素是不是目标元素,有时需要注意判断的先后顺序。

优点:不用去理解和处理第 2 点说的那种上取整的行为,因为不会出现死循环。
缺点:

  • while (left + 1 < right) 这种写法我个人认为不那么自然;
  • 一定需要后处理,在后处理这个问题上增加了思考的负担;
  • left = midright = mid 这种写法比较随意,而事实上,程序员完全有能力决定 mid 是保留还是被排除在外。

补充:二分查找变体(来自极客APP专栏)

// 查找第一个值等于给定值的元素
private int firstEquals(int[] arr, int target) {
    int l = 0, r = arr.length - 1;
    while (l < r) {
        int mid = l + ((r - l) >> 1);
        if (arr[mid] < target) l = mid + 1;
        else r = mid; // 收缩右边界不影响 first equals
    }
    if (arr[l] == target && (l == 0 || arr[l - 1] < target)) return l;
    return -1;
}
// 查找最后一个值等于给定值的元素
private int lastEquals(int[] arr, int target) {
    int l = 0, r = arr.length - 1;
    while (l < r) {
        int mid = l + ((r - l + 1) >> 1);
        if (arr[mid] > target) r = mid - 1;
        else l = mid; // 收缩左边界不影响 last equals
    }
    if (arr[l] == target && (l == arr.length - 1 || arr[l + 1] > target)) return l;
    return -1;
}
// 查找第一个大于等于给定值的元素
private int firstLargeOrEquals(int[] arr, int target) {
    int l = 0, r = arr.length - 1;
    while (l < r) {
        int mid = l + ((r - l) >> 1);
        if (arr[mid] < target) l = mid + 1;
        else r = mid; // 收缩右边界不影响 first equals
    }
    if (arr[l] >= target && (l == 0 || arr[l - 1] < target)) return l; // >=
    return -1;
}
// 查找最后一个小于等于给定值的元素
private int lastLessOrEquals(int[] arr, int target) {
    int l = 0, r = arr.length - 1;
    while (l < r) {
        int mid = l + ((r - l + 1) >> 1);
        if (arr[mid] > target) r = mid - 1;
        else l = mid; // 收缩左边界不影响 last equals
    }
    if (arr[l] <= target && (l == arr.length - 1 || arr[l + 1] > target)) return l; // <=
    return -1;
}

注:本博为自身二分查找学习的记录收藏,以上二分查找的内容均截取自liweiwei1419,威威哥写的真的挺棒,为大家推荐一波

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值