【LeetCode题解模板系列】二分查找

82 阅读3分钟

理解二分查找

1. 什么是二分查找?

二分查找(Binary Search)是一种高效的查找算法,适用于已排序的数组中查找指定元素。它的基本思想是:首先确定待查找区间的中间位置,然后判断待查找元素与中间位置的元素大小关系,若相等则返回中间位置,若小于中间位置则在中间位置的右侧区间查找,若大于中间位置则在中间位置的左侧区间查找,不断缩小查找范围,直到找到目标元素或者查找区间为空。二分查找的时间复杂度为O(log n),是一种非常高效的查找算法。

2. 标准二分查找示例

以下是一个Java语言的二分查找示例代码:

public class BinarySearch {

    public static int binarySearch(int[ ] arr, int key) {

        int left = 0;
        int right = arr.length - 1;

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

            if (arr[mid] == key) {
                return mid;
            } else if (arr[mid] < key) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return -1;
    }


    public static void main(String[ ] args) {


        int[ ] arr = { 2, 3, 4, 10, 40 };

        int key = 10;
        int index = binarySearch(arr, key);
        if (index == -1) {
            System.out.println("Element is not present in array");
        } else {
            System.out.println("Element is present at index " + index);
        }
    }
}

在上面的代码中,我们定义了一个名为 binarySearch 的静态方法,该方法接受一个已排序的整数数组和要查找的目标值。方法返回目标值在数组中的索引,如果目标值不在数组中,则返回 -1。

在 binarySearch 方法中,我们使用了两个指针 left 和 right 来跟踪要查找的目标值的左右边界。在每次迭代中,我们计算出数组的中间元素 mid,并将其与要查找的目标值进行比较。如果 mid 等于目标值,则返回 mid 的索引。如果 mid 小于目标值,则说明目标值可能在数组的右半部分,因此我们将 left 指针移动到 mid + 1 的位置。如果 mid 大于目标值,则说明目标值可能在数组的左半部分,因此我们将 right 指针移动到 mid - 1 的位置。如果在整个迭代过程中没有找到目标值,则返回 -1。

在 main 方法中,我们定义了一个已排序的整数数组 arr 和要查找的目标值 key,然后调用 binarySearch 方法来查找目标值在数组中的索引。如果返回值为 -1,则说明目标值不在数组中;否则,返回值就是目标值在数组中的索引。

3. 几个关键处理点分析

为什么要使用left <= right,而不是left < right

举个很简单的例子,假设数组就一个元素:[1],现在让你查找1,如果是left < right

还能查到吗?

再比如:[1,2,3,4]这样的数组,当要查询1或4时,如果使用left < right都会查询不到。

所以,实际上使用left <= right主要就是为了处理目标值刚好落在left或者right位置上的情况。

为什么要使用left + (right - left) / 2求mid值

正常来说我们直接通过(left + right)/ 2即可取得mid值,而使用left + (right - left) / 2的方式可以避免(left + right)溢出的情况发生。

注意:当数组长度为偶数时,left + (right - left) / 2取到的值为两个数靠前的一个值,比如数组为[1,2,3,4],mid值指向的是下标1的位置,而不是下标2的位置。

4. 二分查找需要注意的几点

  1. 数据必须有序:二分查找需要将待查找的数据有序,因此在使用二分查找之前,需要对数据进行排序,或者在数据中间插入新元素以维持有序状态。
  2. 每次查找都需要减半:在每次查找过程中,需要将待查找的数据范围减半,因此最多只能进行 log2(n) 次查找,其中 n 是数据的长度。
  3. 边界处理:在查找过程中,需要处理好边界值的问题。

题集练习

一、标准二分:

704. 二分查找(简单)

没什么好说的,就是标准二分查找

class Solution {
    public int search(int[] arr, int key) {
        int left = 0;
        int right = arr.length - 1;

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

            if (arr[mid] == key) {
                return mid;
            } else if (arr[mid] < key) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return -1;
    }
}

374. 猜数字大小(简单)

依然是标准二分的使用场景

/** 
 * Forward declaration of guess API.
 * @param  num   your guess
 * @return 	     -1 if num is higher than the picked number
 *			      1 if num is lower than the picked number
 *               otherwise return 0
 * int guess(int num);
 */

public class Solution extends GuessGame {
    public int guessNumber(int n) {
        int first = 1;
        int last = n;
        while(first <= last){
            int mid = (last - first) / 2 + first;
            if(guess(mid) == 0){
                return mid;
            }else if (guess(mid) == -1){
                last = mid - 1;
            }else{
                first = mid + 1;
            }
        }
        return first;
    }
}

二、找合适的位置

在二分查找的应用场景中,除了找到目标值之外,一般还可以应用于以下几种场景:

  1. 当目标值有重复时,找到第一个等于目标值的位置,或者最后一个等于目标值的位置。
  2. 当目标值不存在时,找到第一个小于目标值的位置。
  3. 当目标值不存在时,找到第一个大于目标值的位置。

下面,我们通过做题来理解一下。

278. 第一个错误的版本 (简单)

找到第一个等于目标值的位置

实际上本题的数组可以理解为是长这样的:[false,false,false,true,true]所以我们可以理解为就是找到第一个为true的位置,所以可以直接通过二分的方式来查找。

  1. 我们同样先定义left和right两个边界。
  2. 每次获取mid值,如果为false,则表示mid值左边也肯定都为false,因此left直接更新为mid + 1,如果为true,则表示mid值右边也肯定都为true,但并不一定就是第一个true,因此我们继续更新right为mid - 1
  3. 当重复上述过程时,最终一定会来到left == right的情况,取left值即可。

为什么是取left而不是right呢?

因为在整个重复过程中,left每次是因为取到了false才加1,而right是因为取到了true才减1,因此当最终left == right时,right会因为isBadVersion(mid)为true,而又被多减了一位。而如果isBadVersion(mid)为false,则left刚好应该再加一位。

/* 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;
        int right = n;
        while(left <= right){
            int mid = (right - left) / 2 + left;
            if(isBadVersion(mid)){
                right = mid - 1;
            }else{
                left = mid + 1;
            }
        }
        return left;
    }
}

35. 搜索插入位置(简单)

找到第一个小于等于目标值的位置

理解一下题目的含义,实际上就是找到第一个小于等于目标值的位置。

几乎和标准二分查找一样的模板,理解一下上一题【第一个错误的版本】的分析就明白了。

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

744. 寻找比目标字母大的最小字母(简单)

找到第一个大于目标值的位置

道理是一样的,最终当left == right相等时,还是取left值,只不过要根据题目要求,当left越界后,则要返回第一个下标的值。

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

34. 在排序数组中查找元素的第一个和最后一个位置(中等)

有了前几题的经验,本题只要在原有的套路基础上稍微改动一下即可:

  1. 首先,无论是找第一个等于目标值还是最后一个等于目标值,只要当nums[mid] == target时,就先记下来。
  2. 然后,如果是找第一个等于目标值的情况,当nums[mid] == target时,就继续收缩右半部分值。
  3. 同理,如果是找最后一个等于目标值的情况,当nums[mid] == target时,就继续收缩左半部分值。
public int[] searchRange(int[] nums, int target) {
    int[] ans = {-1, -1};
    int left = 0;
    int right = nums.length - 1;
    while (left <= right) {
        int mid = (right - left) / 2 + left;
        if (nums[mid] == target) {
            ans[0] = mid;
            right = mid - 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    left = 0;
    right = nums.length - 1;
    while (left <= right) {
        int mid = (right - left) / 2 + left;
        if (nums[mid] == target) {
            ans[1] = mid;
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return ans;
}

三、局部有序问题

当我们提到二分查找时,有时会将其局限于严格有序的数组中。但实际上,在一些局部有序的情况下,我们同样可以使用二分查找来解决问题。接下来,我们将探讨如何使用二分查找来处理局部有序性的问题。

33. 搜索旋转排序数组(中等)

虽然数组不是完全有序的,但是我们不难发现,通过一次二分后,必然有一半有序的,一半无序的(但是无序的情况依旧能通过二分变为一半有序一半无序),只要符合这样的规律,那么我们就可以依然可以使用二分进行查找。

图解说明(x轴表示数组下标,y轴表示值)

假设中间点在旋转点的左边,那么中间点的左半部分一定是有序的。

假设中间点在旋转点的右边,那么中间点的右半部分一定是有序的。

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

            /**
             * 如果nums[mid] < nums[right]成立,则表示mid 到 right区间一定是有序的,那么接下来按照有序的方式进行二分查找即可
             * 否则表示left 到 mid的区间一定是有序的,那么同样在这个区间也可以按照二分的方式查找即可
             */
          
            if(nums[mid] < nums[right]){
                // 如果nums[mid] < target && target <= nums[right]成立,则在mid到right的范围内找,然后在left到mid的范围找
                if(nums[mid] < target && target <= nums[right]){
                    left = mid + 1;
                }else{
                    right = mid - 1;
                }
            }else{
                if(nums[left] <= target && target < nums[mid]){
                    right = mid - 1;
                    
                }else{
                    left = mid + 1;
                }
            }
        }
        return -1;
    }
}

81. 搜索旋转排序数组 II(中等)

这是上一题的延伸,与上一题的区别关键点就在于,有重复数据出现了,这就导致了可能会出现nums[left] == nums[mid] == nums[right]的情况,且出现这种情况以后,没办法判断目标值是在左半部分还是右半部分。

下图是当nums[left] == nums[mid] == nums[right]时,目标值可能在右半部分的情况。

下图是当nums[left] == nums[mid] == nums[right]时,目标值可能在左半部分的情况。

为了解决遇到nums[left] == nums[mid] == nums[right]的情况,我们可以分别将left加1,right减1之后,然后再重新进行二分查找。

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

    }
}

153. 寻找旋转排序数组中的最小值(中等)

从下图可以看出,当mid小于right时,则最小值一定在mid的左边,所以收缩右半部分即可,反之则一定在mid的右边,收缩左半部分即可。

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

本题有两个关键点处理:

为什么是left < right,而不是标准的left <= right ?

因为在满足nums[mid] < nums[right]条件时,执行的是right=mid,而不是right = mid + 1,因此如果再用left <= right,则会出现死循环,比如数组[1,2],会永远满足left <= right的条件。

为什么是right = mid,而不是right = mid + 1 ?

如果第一个问题是因为right = mid产生的,那为什么不按照right = mid + 1处理呢?原因很简单,因为最小值可能就在mid的坐标上,因此不能过滤掉,而left则不存在这个问题(可以看图理解)。

154. 寻找旋转排序数组中的最小值 II(困难)

本题也是上一题的延伸,场景和【81. 搜索旋转排序数组 II(中等)】一样,多了重复的数据问题,所以解决方式也是一样的,直接移位即可。

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