Java算法-一文搞懂二分查找(朴素二分和左右端点二分)

一、朴素二分查找

在之前,提到二分大家或许会觉得,二分就是"有序数组中用来查找某数据的查找方法"。

但其实这样的说法并不完全对,其实二分的应用场景很广,只要是"拥有二段性的数据序列"就能够通过二分进行查找。

那么二段性又是什么?应该如何确定一个数组是否拥有二段性二分查找的细节问题如(当数组为偶数时,应该取偏向左侧的mid还是取偏向右侧的mid?left和right应该移动到mid?mid + 1?mid - 1?)又该如何确定?别急,我们将从最开始的朴素二分开始介绍,然后逐步的将这些问题解决掉~

📚 先让我们通过一道例题来引入二分查找的概念

我们先引入最好想的"暴力解法",再通过"暴力解法"思考启发,优化成"二分查找"。

暴力枚举法

从头到尾遍历数组,直到找到目标元素,停止遍历,返回元素下标。

时间复杂度:O(n)

空间复杂度:O(1)

这是最简单的方法,但这种简单的查找元素,使用O(n)的方法显然并不合适,于是我们可以通过这个过程思考一下优化的方法:

比如,当我们从前往后遍历这个有序数组到4时,我们可以发现,包括4在内的之前的所有元素,似乎都是无用的,因为4小于我们的目标值,也就是说4之前的元素也都一定小于这个目标值,那么我们有没有一种方法,能够快速的越过大部分不需要遍历的元素,进行跳跃式的查找呢?

二分查找法

通过二分查找的方式进行查找目标元素。

时间复杂度:O(logn)

空间复杂度:O(1)

📚 那么便引出我们二分查找

📕 设定一个数组左端left和数组右端right,求出中间值mid,判断中间值与目标值的大小

📕 如果mid对应值小于目标值,则代表mid及其左侧的元素都是多余的,那么修改left = mid + 1

📕 如果mid对应值大于目标值,则代表mid及其右侧的元素都是多余的,那么修改right = mid - 1

📕 如果mid对应值等于目标值,则返回mid;若 left > right 则查找结束,跳出循环;

这样便能修改查找的时间复杂度为O(logn)

📖 代码示例

class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        while(left <= right){
            int mid = left + (right - left) / 2;
            if(nums[mid] == target){
                return mid;
            }else if(nums[mid] > target){
                right = mid - 1;
            }else {
                left = mid + 1;
            }
        }
        return -1;
    }
}

(注意:取mid时如果直接使用(left + right) / 2,会有可能造成相加后int值的溢出,所以我们可以采用left + (right - left) / 2 的方法,可以有效地避免溢出)

然而这种并不复杂的解法只能解决一些简单的问题,所以这种二分查找也被称为"朴素二分查找"想要真正的掌握解题方法,还需要我们进一步的进行学习"查找数组目标元素的左右端点"

二、二分查找目标元素左右端点

📚 同样的,先让我们看一道例题

我们可以看到,需要查找的目标值为8,并且需要找到数组中第一次出现的8的下标和最后一次出现的8的下标,而"朴素二分"只能帮我们找到一个8。

那么我们可以分为两种方法:查找最左侧的目标元素和查找最右侧的目标元素。

① 查找目标元素的左端点

其实还是比较好想的,我们想要找到一个目标元素的左端点:

📕 第一次查找到该目标元素时,不能直接返回,因为不确定它的左端是否还有目标元素

📕 继续向左查找时,right的移动不能再是(mid - 1),因为这样有可能将目标值直接跳过了

📕 结束循环的条件不能再是(left <= right),而是(left < right),否则可能会陷入死循环

📖 解决了这些注意点,也就能够编写查找目标元素左端点的方法了

    public int searchLeft(int[] arr, int num) {
        int left = 0;
        int right = arr.length - 1;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(arr[mid] >= num){
                right = mid;
            }else {
                left = mid + 1;
            }
        }
        if(arr[left] == num){
            return left;
        }
        return -1;
    }

② 查找目标元素的右端点

与上述查找左端点同理:

📕 第一次查找到该目标元素时,不能直接返回,因为不确定它的右端是否还有目标元素

📕 继续向右查找时,left的移动不能再是(mid + 1),因为这样有可能将目标值直接跳过了

📕 结束循环的条件不能再是(left <= right),而是(left < right),否则可能会陷入死循环

但是查找右端点时,有一个比较难发现的细节需要我们注意

我们之前取中间点时,使用的方法是left + (right - left) / 2,这种方法求出的中间点在数组大小为偶数时,取到的是左侧中间点

而使用这种方式取中间点,就会有一种特殊的情况

想要解决这种问题,就需要我们将每次取到的中间点变成右侧中间点:left + (right - left + 1) / 2

📖 由此我们便能知道,查找目标元素右端点的方法

    public int searchRight(int[] arr, int num) {
        int left = 0;
        int right = arr.length - 1;
        while(left < right){
            int mid = left + (right - left + 1) / 2;
            if(arr[mid] <= num){
                left = mid;
            }else {
                right = mid - 1;
            }
        } 
        if(arr[left] == num){
            return left;
        }
        return -1;
    }

(注意:取哪一侧的中间值,可以通过查看我们后续编写的left和right,如果存在" - 1"的操作,那么就需要取右侧中间值,否则就需要取左侧中间值)

📖 那么到这里,这题离解决也就只差一步之遥了,我们只需要单独处理一下数组长度为0的情况,即可解决该题

class Solution {
    public int[] searchRange(int[] nums, int target) {
        if(nums.length == 0){
            return new int[]{-1,-1};
        }
        return new int[] { searchLeft(nums, target), searchRight(nums, target) };
    }
        
    public int searchLeft(int[] arr, int num) {
        int left = 0;
        int right = arr.length - 1;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(arr[mid] >= num){
                right = mid;
            }else {
                left = mid + 1;
            }
        }
        if(arr[left] == num){
            return left;
        }
        return -1;
    }

    public int searchRight(int[] arr, int num) {
        int left = 0;
        int right = arr.length - 1;
        while(left < right){
            int mid = left + (right - left + 1) / 2;
            if(arr[mid] <= num){
                left = mid;
            }else {
                right = mid - 1;
            }
        } 
        if(arr[left] == num){
            return left;
        }
        return -1;
    }
}

三、二分查找的实际应用

学习完上面二分查找的内容,相信大家对二分查找已经有了一定的认识,但是想要具体在题目中使用二分查找进行解题还是远远不够的,我们最开始引入的一个话题"二段性"到这里还并没有提到,那么具体什么是二段性?二段性又该如何使用?应该如何才能知道一个数组的二段性为什么?让我们继续具体的学习下面的内容~

x 的平方根(二分不一定是数组)

在进行解题之前,我们先分析一下这题中存在的注意点

📕 结果只保留整数部分,小数部分将被舍去

📕 不允许使用内置指数函数和算符

1. 暴力枚举法

从0到x进行枚举,如果 [ i * i <= x && (i + 1) * (i + 1) > x ] 则返回 i

时间复杂度:O(n)

空间复杂度:O(1)

那么想要通过二分优化它,就需要我们查找二段性

二段性就是:在一段数据中,该元素左侧满足一个性质1,右侧满足一个性质2。

这就是这题的二段性,至此我们通过二段性进行了一个思维突破:并不是数组才能够使用二分~

2. 二分查找法

通过left和right取mid进行查找

如果(mid * mid) <= 目标值      (left = mid)

如果(mid * mid) > 目标值        (right = mid - 1)   --->   [取右侧中间值]

时间复杂度:O(logn)

空间复杂度:O(1)

注意:

我们应该使用long类型变量进行运算

📖 代码示例

class Solution {
    public int mySqrt(int x) {
        long left = 0;
        long right = x;
        while(left < right){
            long mid = left + (right - left + 1) / 2;
            if(mid * mid <= x){
                left = mid;
            }else {
                right = mid - 1;
            }
        }
        return (int)left;
    }
}

山脉数组的峰顶索引(二分不一定全有序)

题中存在的注意点

📕 其中一个值递增到一个峰值元素,然后递减

📕 你必须设计并实现时间复杂度为O(logn)的解决方案

这就是明摆着告诉我们使用二分算法了,所以这题我们就不写暴力枚举的方法了,直接考虑二分~

首先分析山脉数组的二段性:

这题的二段性还是非常简单就能看出来的,通过上面我们画的图就可以知道:

📕 数组中存在的一个峰值,这个峰值的左侧是单调增,右侧是单调递减

📕 当arr[mid] >= arr[mid - 1]时,则代表此时为单调递减,为右侧,并且arr[mid]有可能是我们要找的峰值,所以left = mid

📕 当arr[mid] < arr[mid - 1]时,则代表此时为单调递增,为左侧,arr[mid]不是最大,则不可能是峰值,所以right = mid - 1   --->   [取右侧中间值]

由此,我们通过二段性进一步思维突破:并不是全有序才能使用二分~

📖 代码示例

class Solution {
    public int peakIndexInMountainArray(int[] arr) {
        int left = 1;
        int right = arr.length - 1;
        while(left < right){
            int mid = left + (right - left + 1) / 2;
            if(arr[mid] >= arr[mid - 1]){
                left = mid;
            }else {
                right = mid - 1;
            }
        }
        return left;
    }
}

当然,使mid与mid + 1进行比较也是可以的,只是需要注意修改一下left和right取的初值,以防止越界~

搜索旋转排序数组(分段二分)

首先,还是看一下这题中的注意点

📕 得到的数组是由一个升序数组进行旋转得到的

📕 查找旋转后的数组,存在则返回下标,不存在则返回-1

分析旋转排序数组的二段性:

我们能够知道,当原数组进行旋转后,能够将原数组分为两部分有序数组,这个分界点左边的有序数组是一个(值全大于右侧最大值nums[len - 1])的升序数组,分界点右边的有序数组是一个(值全小于等于右侧最大值nums[len - 1])的升序数组。这就是这题的关键所在,也就是旋转排序数组的二段性。

于是我们可以通过nums[mid]的值对当前位置进行判断,再前往mid目前的并进行查找:

📚 如果nums[mid] = target,则返回mid

📚 如果nums[mid] > nums[len - 1],则代表当前mid处于左侧的有序数组中(进行朴素二分)

     📕 如果 nums[mid] > target 则 right = mid - 1

     📕 如果 nums[mid] < target 则 left = mid + 1

     📕 前提是 target 在左侧范围内 ( target 大于等于该侧最小值 -> (target >= nums[0]) ),如果不满足该条件,则代表目标值不在左侧范围,直接将left = mid + 1,不断向右移动,跳出左侧范围。

📚 如果nums[mid] <= nums[len - 1],则代表当前mid处于右侧的有序数组中(进行朴素二分)

     📕 如果 nums[mid] > target 则 right = mid - 1

     📕 如果 nums[mid] < target 则 left = mid + 1

     📕 前提是 target 在右侧范围内 ( target 小于等于该侧最大值 -> (target <= nums[len - 1]) ),如果不满足该条件,则代表目标值不在右侧范围,直接将right = mid - 1,不断向左移动,跳出右侧范围。 

那么,我们又通过二段性进行了一次思维突破并不一定只需要用到一次二分~

📖 代码示例

class Solution {
    public int search(int[] nums, int target) {
        int len = nums.length;
        int left = 0;
        int right = len - 1;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(nums[mid] == target) return mid;
            //判断mid当前在哪一侧
            //mid在左侧
            if(nums[mid] > nums[len - 1]){
                if(nums[0] <= target && nums[mid] > target){
                    right = mid - 1;
                }else {
                    left = mid + 1;
                }
            }
            //mid在右侧
            else {
                if(nums[len - 1] >= target && nums[mid] < target){
                    left = mid + 1;
                }else {
                    right = mid - 1;
                }
            }
        }
        return nums[left] == target ? left : -1;
    }
}

(由于我们这题使用的是分段的朴素二分,所以mid取左侧中间值还是右侧中间值并不影响结果)

那么关于二分查找的知识,就为大家分享到这里了~希望大家不要只背模板,而是学习二分查找的思维,并且多多练习,做到能够更熟练的在一道题中寻找"二段性",这样才算真正的掌握了二分查找~那么我们下次再见~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值