Leetcode 算法之二分查找 —— Java 题解

252 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

二分查找是一种效率较高的查找方法,一般在有序数组中查找元素时,会使用到二分查找方法。

查找过程大致为:

  1. 将查找元素与查找集合的中间元素比较,如果相等,则查找成功;否则:
  2. 如果查找元素比查找集合的中间元素大,那么我们到查找集合的右半部分;
  3. 如果查找元素比查找集合的中间元素小,那么我们到查找集合的左半部分

重复以上过程,直到我们查找到元素;或者将集合无法进一步缩小,表示集合中没有待查找的元素。

二分查找的代码模板:

// 查找指定元素的数组下标
public int binarySearch(int[] nums, int target){
    if(nums == null || nums.length == 0) {
        return -1;
    }

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

二分查找的示意图:

未命名文件 (4).png

69. x 的平方根 - 简单

给你一个非负整数 x ,计算并返回 x 的 算术平方根 。

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

注意:不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。

示例:

输入:x = 4

输出:2

输入:x = 8

输出:2

解释:8 的算术平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。

题解:

我们声明两个变量 l = 0, r = x,求 [l,r] 区间的中点 mid

如果中点 mid 的平方等于 x,那么直接返回结果;

如果 mid 的平方值小于 x,那么到右半区间查找;

如果 mid 的平方值大于 x,那么到左半区间查找。

需要注意使用乘法可能会整形溢出,有两种解决方法:

  1. 使用 long 类型
  2. 将乘法转换为除法

代码:

public int mySqrt(int x) {
    if (x == 0) {
        return 0;
    }
    int l = 1, r = x;
    while (l <= r) {
        int mid = l+(r-l)/2;
        int sqrt = x / mid;
        if (mid == sqrt) {
            return mid;
        }else if (mid < sqrt) {
            l = mid+1;
        }else {
            r = mid-1;
        }
    }
    return r;
}

1351. 统计有序矩阵中的负数 - 简单

给你一个 m * n 的矩阵 grid,矩阵中的元素无论是按行还是按列,都以非递增顺序排列。 请你统计并返回 grid负数 的数目。

示例:

输入:grid = [[4,3,2,-1],[3,2,1,-1],[1,1,-1,-2],[-1,-1,-2,-3]]

输出:8

解释:矩阵中共有 8 个负数。

题解:

我们要在二维数组中统计负数的个数,二维数组的元素又是一个降序数组,因此我们可以考虑使用二分查找。

我们遍历二维数组中的每个一维数组,对其统计。

对于一维数组,我们使用二分查找,如果中间元素是非负数,我们往右边区间搜索。

如果中间元素是负数,我们往左边区间搜索。

❗❗ 注意:这里我们是不断的搜索,不断地缩小区间,使得区间最后缩小在第一个负数处,我们知道一维数组的元素个数,那么我们也就知道了非负数的个数和负数的个数。

代码:

class Solution {
	public int countNegatives(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        int count = 0;
        for (int i = 0; i < n; i++) {
            count += getCount(grid[i], m);
        }
        return count;
    }

    private int getCount(int[] nums, int m) {
        int l = 0, r = m-1;
        // 确定第一个负数的出现位置
        while (l <= r) {
            int mid = l + (r-l)/2;
            if (nums[mid] >= 0) {   // 非负数,我们往右半区间查找
                l = mid+1;
            }else {                 // 负数,往左半区间查找
                r = mid-1;
            }
        }
        // 返回负数的个数
        return m-l;
    }
}

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

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

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

你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。

示例:

输入:nums = [5,7,7,8,8,10], target = 8

输出:[3,4]

题解:

从升序数组中查找目标值,考虑用二分查找。

我们分两次二分查找,第一次查找目标元素的开始出现位置,第二次查找目标元素的最后出现位置。

如果集合中间的元素大于等于目标值,往左区间找;反之往右区间找。这样我们就能够找到目标值的第一次出现位置,返回右边界即可。

如果集合中间的元素小于等于目标值,往右区间找;反之往左区间找。这样我们就能够找到目标值的最后出现位置,返回左边界-1即可,因为搜索结束后,结果刚好在目标值右边一个位置。

❗❗ 需要注意,我们搜索目标值第一次出现的位置时,可能会返回一个无效值,即 nums.length,表示数组中并没有该元素。

同理,搜索最后出现位置也有可能返回一个无效值。

针对这种情况,我们只需判断返回的第一次出现位置的值是否等于 nums.length 或者该位置的值是否是 target 判断是否有目标值。

代码:

 public int[] searchRange(int[] nums, int target) {

     int first = firstIndex(nums, target);
     int last = lastIndex(nums, target);
     if (first >= nums.length || nums[first] != target) {
         return new int[]{-1,-1};
     }

     return new int[]{first, last};
 }

public int firstIndex(int[] nums, int target) {
    int l = 0, r = nums.length;
    while (l < r) {
        int mid = (r-l)/2+l;
        if (nums[mid] >= target) {
            r = mid;
        }else {
            l = mid+1;
        }
    }
    return r;
}

public int lastIndex(int[] nums, int target) {
    int l = 0, r = nums.length;
    while (l < r) {
        int mid = (r-l)/2+l;
        if (nums[mid] <= target) {
            l = mid+1;
        }else {
            r = mid;
        }
    }
    return l-1;
}

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

已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。

在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4]

给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。

你必须尽可能减少整个操作步骤。

示例:

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

输出:true

题解:

数组是部分有序的,可能不会想到可以用二分查找解决。

  1. 确定查找区间的中点 mid,如果是目标值,返回结果
  2. 否则,我们可以区间为左区间[l,mid],右区间[mid+1,r]
  3. 判断哪个区间是升序的,如果 nums[mid] > nums[l],那么左区间是升序的,否则右区间是升序的。也有可能两个区间都是升序的。
  4. 如果左区间升序,那么判断 target 是否会在该区间内,如果可能在该区间内,我们在左半区间进行下一轮搜索,将搜索集合缩小为左半区间
  5. 如果右区间升序,那么判断 target 是否会在该区间内,如果可能在该区间内,我们在右半区间进行下一轮搜索,将搜索集合缩小为右半区间

需要注意,由于数组中会存在重复元素,我们要判断区间是否升序时,是通过 nums[mid] > nums[l] 判断的,但是这两个值可能相等,此时我们可以通过移动 l 缩小集合来规避这种情况。

代码:

public boolean search(int[] nums, int target) {

    int l = 0, r = nums.length-1;
    while (l <= r) {
        int mid = (r-l)/2+l;
        if (nums[mid] == target) {
            return true;
        }
        if (nums[l] == nums[mid]) {
            l++;
        }else if (nums[l] < nums[mid]) {            // 左区间是否升序
            if (target >= nums[l] && target < nums[mid]) {
                r = mid-1;
            }else {
                l = mid+1;
            }
        }else {                                     // 右区间升序
            if (target > nums[mid] && target <= nums[r]) {
                l = mid+1;
            }else {
                r = mid-1;
            }
        }
    }
    return false;
}

154. 寻找旋转排序数组中的最小值 II - 中等

已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,4,4,5,6,7] 在变化后可能得到: 若旋转 4 次,则可以得到 [4,5,6,7,0,1,4] 若旋转 7 次,则可以得到 [0,1,4,4,5,6,7] 注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]

给你一个可能存在 重复 元素值的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

你必须尽可能减少整个过程的操作步骤。

示例:

输入:nums = [1,3,5]

输出:1

输入:nums = [2,2,2,0,1]

输出:0

题解:

本题解法与 81. 搜索旋转排序数组 II 类似。

找到升序的半区间,然后判断区间内的左边界元素(该区间的最小值)是否比我们之前找到的更小。

  1. 首先确定中点 mid,如果 mid 与集合左边界 l 元素相等,那么更新最小值ans 并移动 l 指针缩小集合范围,进行新的一轮搜索;
  2. 如果左半区间有序,那么更新最小值 ans 为左边界元素
  3. 如果右半区间有序,那么更新最小值 ansnums[mid]

代码:

public int findMin(int[] nums) {

    int l = 0, r= nums.length-1;
    int ans = Integer.MAX_VALUE;
    while (l <= r) {
        int mid = (r-l)/2 + l;
        if (nums[l] == nums[mid]) {
            ans = Math.min(ans, nums[l]);
            l++;
        }else if (nums[l] < nums[mid]) {    // 如果左区间有序
            ans = Math.min(nums[l], ans);
            l = mid+1;
        }else {                             // 如果右区间有序
            ans = Math.min(nums[mid], ans);
            r = mid-1;
        }
    }
    return ans;
}

540. 有序数组中的单一元素 - 中等

给你一个仅由整数组成的有序数组,其中每个元素都会出现两次,唯有一个数只会出现一次。

请你找出并返回只出现一次的那个数。

你设计的解决方案必须满足 O(log n) 时间复杂度和 O(1) 空间复杂度。

实例:

输入: nums = [1,1,2,3,3,4,4,8,8]

输出: 2

题解:

题目要求我们使用 O(log n) 时间复杂度和 O(1) 空间复杂度,对于 O(log n) 时间复杂度,并且数组是有序的,我们很容易想到二分查找,可以尝试解决看。

我们可以将数组中的相邻两个元素当成一个元素来看待,从而进行二分查找。

  1. 把两个元素当成一个元素看待,那么数组的长度 len 也要减半
  2. 确定集合中点 mid,如果当前元素相等,那么单一元素只可能出现在右半区间,往右半区间查找
  3. 否则,往左半区间查找

❗❗ 需要注意中点只有一个元素的情况,例如[1,1,2]

代码:

public int singleNonDuplicate(int[] nums) {
    int len = nums.length/2+1;
    int l = 0, r = len-1;
    while (l <= r) {
        int mid = (r-l)/2 + l;
        int n1 = nums[mid*2];
        // 需要注意当前遍历只有一个元素的情况
        int n2 = mid*2+1 < nums.length ? nums[mid*2+1] : -n1;
        if (n1 != n2) {
            r = mid-1;
        }else {
            l = mid+1;
        }
    }
    return nums[l*2];
}