[算法系列]吃透二分查找:经典题目+解题思路+模板

本文深入探讨二分查找算法,通过多个LeetCode题目展示不同变体,包括寻找目标值的第一个和最后一个出现位置、旋转数组中的最小值、在旋转排序数组中寻找目标等。详细解析了每个问题的解题思路和模板,强调了二分查找在有序数组中的应用及边界处理策略。

0.最原始的二分查找

本文勉强算是递归系列的第八篇文章吧。不过实际上本文是二分专题。以多道LC上题介绍二分的解题思路与模板。

沿着递归的思路,今天我们来谈谈二分查找

严格来说,二分查找和递归联系不算太紧密,本文介绍的主要是非递归版本的二分查找类型习题的解析,话不多说,先来看看原始的BinarySearch吧!

给定一个没有重复元素的有序数组arr,查找目标值target的下标,若arr中没有则返回-1

由于是递归系列,那我们从递归版的二分开始:

(ps:本文代码中中部分边界判空条件省略)

    /* 递归版二分查找 */
    public static int binarySearchRecur(int[] arr ,int target, int left , int right){
        if(left >= right) return -1;
        int mid = (right + left) >> 1;

        if(arr[mid] == target )
            return mid;
        if(arr[mid] > target)
            return binarySearchRecur(arr, target , left , mid -1);
        if(arr[mid] < target)
            return binarySearchRecur(arr,target , mid + 1, right);
        return  -1;
    }

下面改成非递归版的,也是我们今天得重头戏

    /*
    迭代版二分查找 LeetCode704
     */
    public static int binarySearchIter(int[] arr, int target) {
        int left = 0, right = arr.length - 1;
        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(arr[mid] == target)
                return mid;
            if(arr[mid] > target)
                right = mid - 1;
            if(arr[mid] < target)
                left = mid + 1;
        }
        return -1;
    }

1.寻找第一个/最后一个出现target的位置

  • 给定一个可能有重复元素的有序数组arr,查找目标值target第一次出现的下标,若arr中没有则返回-1

    比如:[1,3,4,5,5,5,5,7] ,target = 5

    返回 :3

    [2,5,6,6,6,8],target = 7

    返回:-1

  • 给定一个可能有重复元素的有序数组arr,查找目标值target最后一次出现的下标,若arr中没有则返回-1

    比如:[1,3,4,5,5,5,5,7] ,target = 5

    返回 :6

这是二分中很经典的一类问题,求区间。

第一个题目问法有几种形式:

  • 求第一个出现target的下标
  • 求小于target的个数
  • 求小于target的最大元素

两种种说法做法几乎的!!!其中第1和第2点完全相同,只是换了种说法,第3点在1,2结果减1即可

1.1求第一个出现target的下标 / 小于target的个数

在基本二分的框架中稍作修改:

    /* 求第一个出现target的下标 / 小于target的个数  */
	public static int findFirstTarget(int[] arr , int target){
        int left = 0 , right = arr.length - 1;
        while (left <= right) {
            int mid = left + ((right - left) >> 1);
            if(arr[mid] == target)
                right = mid - 1;	//注意1
            if(arr[mid] > target)
                right = mid - 1;
            if(arr[mid] < target)
                left = mid + 1;
        }
        if(left >= arr.length ||arr[left] != target) //注意2
            return -1;
        return left;
    }

与上面纯正二分相比,注意代码中的两个地方:

  1. arr[mid] == target时,并不是直接返回mid,为啥?因为这时并不知道mid所在位置是否为target的第一个(他的左边可能还有target)。

    因此将右边界缩小,往左边找。

  2. while结束时,left停留在righ+1的位置。

    若:right始终在最右边,则left=arr.length,不存在该下标(target比所有元素都大,此时未找到target)

    若:left最终停留在中间位置,但arr[left] != target,(此时找到的只是合适的插入target的位置,同样未找到target)

1.2求最后一个出现target的下标

和上面类似,直接上代码:

    /*
        最后出现target的位置
     */
    public static int findLastTarget(int[] arr , int target){
        int left = 0 , right = arr.length -1;
        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(arr[mid] == target)
                left = mid + 1;		//与1.1不同之处:mid与target处值相同时,往右边缩
            if(arr[mid] > target)
                right = mid - 1;
            if(arr[mid] < target)
                left = mid + 1;
        }
        if(right < 0 || arr[right] != target)
            return -1;
        return right;
    }

上面两个算是基本框架,掌握后小改就能解决不少问题。下面是这两种模型解题需要思考的地方,也就是对1.1和1.2需要改造的地方:

  1. 问题的要求是要找到哪个target?还是要找到第一个比target小(或等于)的元素?还是第一个比target大(或等于)的元素?
  2. 如果当前mid指向的值等于target了之后,应该怎么处理?是直接return?还是移动left或者right?
  3. 最后一次循环的时候(循环是以left <= right作为判断条件),left = right,那么这个时候,我是移动left还是移动right,移动之后,是left指向的我想返回的结果,还是right?
  4. 没找到怎么办?

1.3寻找比target小的最大元素

看Leetcode69:

实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

示例 1: 
输入: 4
输出: 2

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

代码:

    /*
        LeetCode 69
        实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。
        由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
     */
    public static int  mySqrt(int x){
        int left = 0 , right = x;
        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(mid * mid == x)
                return mid;
            if(mid * mid > x)
                right = mid - 1;
            if(mid * mid < x)
                left = mid + 1;
        }
        return right;
    }

此题思路按照1.1复述即可,求小于target的最大元素即是求第一个target的前面那个元素

注意:

  1. 当mid * mid = x,即说明找到了平方根,返回即可
  2. 最后,为啥这里right是指向我想返回的结果呢?

1.4寻找比target大的最小元素

​ 给定一个只包含小写字母的有序数组letters 和一个目标字母 target,寻找有序数组里面比目标字母大的最小字母。 数组里字母的顺序是循环的。举个例子,如果目标字母target = ‘z’ 并且有序数组为 letters = [‘a’, ‘b’],则答案返回 ‘a’。

示例:
输入:
letters = ["c", "f", "j"]
target = "a"
输出: "c"

输入:
letters = ["c", "f", "j"]
target = "c"
输出: "f"

输入:
letters = ["c", "f", "j"]
target = "j"
输出: "c"

代码:

    /*
        LeetCode 744
        给你一个排序后的字符列表 letters ,列表中只包含小写英文字母。
        另给出一个目标字母 target,请你寻找在这一有序列表里比目标字母大的最小字母。
     */
    public static char nextGreatestLetter(char[] letters, char target) {
        //按题意,先把特殊情况处理
        if(target < letters[0] || target >= letters[letters.length - 1])
            return letters[0];

        int left = 0 , right = letters.length - 1;

        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(letters[mid] == target)
                left = mid + 1;
            if(letters[mid] > target)
                right = mid - 1;
            if(letters[mid] < target)
                left = mid + 1;
        }
        return letters[left];
    }

此题思路按照1.2复述即可,求大于target的最小元素即是求最后那个target后面的那个元素

注意:

  1. letters[mid] == target,需要继续往后找,寻找最后一个target
  2. 最后,为啥这里left是指向我们返回的结果呢?

1.5[查找元素的第一个和最后一个位置]

LeetCode 34

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

你的算法时间复杂度必须是 O(log n) 级别。

如果数组中不存在目标值,返回 [-1, -1]。

示例 1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]

示例 2:
输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]

其实就是1.1和1.2的合体版,代码如下:

    public int[] searchRange(int[] arr, int target) {
        int first = findFirstTarget(arr , target);
        int last = findLastTarget(arr , target);
        return new int[] {first,last};
    }
	public static int findFirstTarget(int[] arr , int target){...}
	public static int findLastTarget(int[] arr , int target){...}

1.6 第一个错误版本

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

示例:

给定 n = 5,并且 version = 4 是第一个错误的版本。

调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true

所以,4 是第一个错误的版本。

其实就是在一堆false序列中,找到第一个变成true的位置即可。是不是思路和1.1或1.2一摸一样啊。哈哈哈~

/* The isBadVersion API is defined in the parent class VersionControl.
      boolean isBadVersion(int version); */

public class Solution extends VersionControl {
    public int firstBadVersion(int n) {
       int left = 1 , right = n ;
        while (left <=  right){
            int mid = left + ((right - left) >> 1);
            if(!isBadVersion(mid) )
                left = mid + 1;
            if(isBadVersion(mid))
                right = mid - 1;
        }
        return left;        
    }
}

最后,我们也来考虑考虑为啥结果返回的是left指向的元素。

回答遗留的问题:

  1. 我怎么知道最后应该返回left指向的还是right指向的呢?

    假设法。

    比如当while (left <= right)时,就假设最后一步:left = right时,均到达题意要求的位置。

    比如上题,我们假设left和right都指向第一个true的位置。此时按照代码所写:right = mid - 1;。这时候right变化,而left还停留在原地(所求位置)。所以最终left为我们所求。

    有小伙伴问那我假设left = right同时停留在最后一个为false的位置呢?这时下一次循环会走left = mid + 1;,此时依然left指向正确位置。

    因此我们最后返回left。寻找第一个target或最后一个target也是这样推理出来的,各位可以试试。

2.旋转数组系列问题

该系列问题有四道,变化之处就是把有序数组进行旋转。

2.1寻找旋转排序(无重复)数组中的最小值

LeetCode153

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

你可以假设数组中不存在重复元素。

示例 1:

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

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

/**
 * LeetCode153
  */
public static int findMin(int[] nums) {
    int left  = 0 , right = nums.length - 1;
    while (left < right){
        int mid = left + ((right - left) >> 1);
        if(nums[mid] > nums[right])
            left = mid + 1;
        else if(nums[mid] < nums[right])
            right = mid;
        else if(nums[mid] == nums[right])
            right = mid;
    }
    return nums[left];
}

思路几乎类似,我们考虑的是,旋转的起点是在mid的左边还是右边。

  • nums[mid] > nums[right]数组最小值一定在mid的右边,反之一定在mid左半边

有小伙伴问:那能否通过nums[mid] < nums[left]进行判断呢?答案是不行:

在这里插入图片描述

如上图,同样不满足nums[mid] < nums[left],但最小值(旋转起点)却可能在左也可能在右

另外,为什么这里的while条件是left < right而不是left <= right呢?

2.2寻找旋转排序(有重复)数组中的最小值

LeetCode154

假设按照升序序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

注意数组中可能存在重复的元素。

示例 1:

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

输入: [2,2,2,0,1]
输出: 0

    /*
        LeetCode 154
     */
    public static int findMinInDup(int[] nums) {
        int left = 0 , right = nums.length - 1;
        while (left < right){
            int mid = left + ((right - left) >> 1);
            if(nums[mid] > nums[right])
                left = mid + 1;
            else if(nums[mid] < nums[right])
                right = mid ;
            else
                right -- ;
        }
        return nums[left];
    }

1.2与1.1的区别仅在于:

nums[mid] = nums[right]时,right向左缩一位(即右边界缩一位),这是由于我们不能确定最小值在 mid的左边还是右边。

2.3 在旋转排序数组(无重复)中寻找target

LeetCode33

给你一个升序排列的整数数组 nums ,和一个整数 target 。

假设按照升序排序的数组在预先未知的某个点上进行了旋转。(例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请你在数组中搜索 target ,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

示例 1:

输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
示例 2:

输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
示例 3:

输入:nums = [1], target = 0
输出:-1

提示:

1 <= nums.length <= 5000
-10^4 <= nums[i] <= 10^4
nums 中的每个值都 独一无二
nums 肯定会在某个点上旋转
-10^4 <= target <= 10^4

思路:在数组中二分查找target,核心依然是确定left和right在mid两边如何缩小。

回想标准二分中,nums[mid] > target就把right缩到mid - 1 处,这是因为数组单调有序,target比nums[mid]还小,自然不可能在mid右边了。

在旋转数组中,我们依然希望寻找单调有序的部分,然后来进行上述判断。

随手写一个旋转数组:[4,5,6,7,0,1,2] 。我们可以看到,至少有一半的部分是符合单调有序的(比如4~7),但这个有序部分可左边,可右边。因此,我们抓住这个单调有序区间进行讨论。即,在两种情况下进行二分查找,觉得有些绕可先看代码:

    /*
        LeetCode 33
        搜索旋转排序数组
     */
    public static int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(nums[mid] == target)
                return mid;
            if(nums[mid] >= nums[right]){    //单调递增区间在mid左边的情况
                if(target >= nums[left] && target <= nums[mid])	//此条件能判断target是否在该单调区间中
                    right = mid - 1;	//在该区间,则右边界缩到mid-1处
                else
                    left = mid + 1;		//不在,则说明在mid右边,则左边界缩到mid+1处
            }
            if(nums[mid] <= nums[right]){
                if(target >= nums[mid] && target <= nums[right])
                    left = mid + 1;
                else
                    right = mid - 1;
            }
        }
        return  -1;
    }

2.4 在旋转排序数组(有重复)中寻找target

LeetCode 81. 搜索旋转排序数组 II

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,0,1,2,2,5,6] 可能变为 [2,5,6,0,0,1,2] )。

编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false

示例 1:

输入: nums = [2,5,6,0,0,1,2], target = 0
输出: true

示例 2:

输入: nums = [2,5,6,0,0,1,2], target = 3
输出: false

此题与2.3的区别就在于数组中的元素可重复了

因此与2.3的处理不同之处在于当nums[mid] = nums[right]时,并不知道target在那边,因此right向左缩一个。

    /*
        LeetCode 81
        81. 搜索旋转排序数组2
     */
    public boolean search2(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right){
            int mid = left + ((right - left) >> 1);
            if(nums[mid] == target) return true;
            if(nums[mid] > nums[right]){
                if(target >= nums[left] && target <= nums[mid])
                    right = mid - 1;
                else
                    left = mid + 1;
            }else if(nums[mid] < nums[right]){
                if(target > nums[mid] && target <= nums[right]){
                    left = mid + 1;
                }else {
                    right = mid;
                }
            }else{
                right -- ;
            }
        }
        return false;
    }

3.LeetCode的类似题

3.1 求峰值

LeetCode 162

峰值元素是指其值大于左右相邻值的元素。

给定一个输入数组 nums,其中 nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。

数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。

你可以假设 nums[-1] = nums[n] = -∞。

示例 1:

输入: nums = [1,2,3,1]
输出: 2
解释: 3 是峰值元素,你的函数应该返回其索引 2。
示例 2:

输入: nums = [1,2,1,3,5,6,4]
输出: 1 或 5
解释: 你的函数可以返回索引 1,其峰值元素为 2;
或者返回索引 5, 其峰值元素为 6。

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

3.2 求H指数 II

LeetCode 275

给定一位研究者论文被引用次数的数组(被引用次数是非负整数),数组已经按照 升序排列 。编写一个方法,计算出研究者的 h 指数。

h 指数的定义: “h 代表“高引用次数”(high citations),一名科研人员的 h 指数是指他(她)的 (N 篇论文中)总共有 h 篇论文分别被引用了至少 h 次。(其余的 N - h 篇论文每篇被引用次数不多于 h 次。)"

示例:

输入: citations = [0,1,3,5,6]
输出: 3
解释: 给定数组表示研究者总共有 5 篇论文,每篇论文相应的被引用了 0, 1, 3, 5, 6 次。
由于研究者有 3 篇论文每篇至少被引用了 3 次,其余两篇论文每篇被引用不多于 3 次,所以她的 h 指数是 3。

说明:

如果 h 有多有种可能的值 ,h 指数是其中最大的那个。

    public int hIndex(int[] citations) {
        if(citations == null || citations.length == 0)
            return 0;
        if(citations.length == 1)
            return Math.min(1,citations[0]);    
        int left = 0 , right = citations.length - 1;
        while(left <= right){
            int mid = left + ((right - left ) >> 1);
            if(citations.length - mid  == citations[mid])
                return citations.length - mid ;
            if( citations.length - mid > citations[mid])
                left = mid + 1;
            if(citations.length - mid < citations[mid])
                right = mid - 1;
        }
        return citations.length - left;
    }

3.3 求H指数I

LeetCode 274

给定一位研究者论文被引用次数的数组(被引用次数是非负整数)。编写一个方法,计算出研究者的 h 指数。

h 指数的定义:h 代表“高引用次数”(high citations),一名科研人员的 h 指数是指他(她)的 (N 篇论文中)总共有 h 篇论文分别被引用了至少 h 次。(其余的 N - h 篇论文每篇被引用次数 不超过 h 次。)

例如:某人的 h 指数是 20,这表示他已发表的论文中,每篇被引用了至少 20 次的论文总共有 20 篇。

示例:

输入:citations = [3,0,6,1,5]
输出:3 
解释:给定数组表示研究者总共有 5 篇论文,每篇论文相应的被引用了 3, 0, 6, 1, 5 次。
     由于研究者有 3 篇论文每篇 至少 被引用了 3 次,其余两篇论文每篇被引用 不多于 3 次,所以她的 h 指数是 3。

**提示:**如果 h 有多种可能的值,h 指数是其中最大的那个。

    public int hIndex(int[] citations) {
        if(citations == null || citations.length == 0 )
            return 0;
        if(citations.length == 1 && citations[0] == 0 )
            return 0;
        
        int len = citations.length;
        int[] paperCount = new int[len + 1];

        //将大于len的全归为len吗,并且计数
        for (int i = 1; i < len + 1; i++) 
            paperCount[Math.min(len,citations[i - 1])]++;
        
        int i = len;
        int sum = 0;
        while (i >= 0 && sum <= i)	//倒着找,找到第一个
            sum += paperCount[i --];
        
        return i+1;
    }
}

小结:
本文从基本二分出发,通过几道leetcode相关题目,介绍了常见二分问题模式与思考方式。要明确,首先二分搜索就是搜索方式的一种,在有序表的情况代替顺序遍历降低时间复杂度。
核心在于迭代过程中每一次逐步缩小left与right之间的范围。难点1:在于考虑三种情况下的处理方式:等于mid时返回与否?若缩小往哪边缩?是mid = right 还是 mid = right -1? 诸如此类。难点2:最后返回是left还是right?这个用文中提到的假设演绎法即可。

往期回顾:

  1. [算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
  2. [算法系列] 递归应用: 快速排序+归并排序算法及其核心思想与拓展 … 附赠 堆排序算法
  3. [算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营
  4. [算法系列]搞懂DFS(1)——经典例题(数独游戏, 部分和, 水洼数目)图文详解
  5. [算法系列]搞懂DFS(2)——模式套路+经典例题详解(n皇后问题,素数环问题)
  6. [算法系列]递归应用——二叉树(1):二叉树遍历详解解+LeetCode经典题目+模板总结
  7. [算法系列]递归应用——二叉树(2):一种带信息递归返回的求解方式
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值