LeetCode刷题—数组:二分法

二分查找

目录如下:
1. 二分查找基本框架
2. 二分查找左边界
3. 二分查找右边界

相关题目:
704,二分查找
34,在排序数组中查找元素的第一个和最后一个位置
278,第一个错误的版本
875,爱吃香蕉的可可
1011,在D天内送达包裹的能力
69,x的平方根
744,寻找比目标字母大的最小字母
153,寻找旋转排序数组中的最小值

引入

常用的二分查找场景:寻找一个数、寻找重复数的左侧边界、寻找重复数的右侧边界。

二分查找框架
int binarySearch(int[] nums, int target) {    
    int left = 0, right = ...;
    while(...) {        
        int mid = left + (right - left) / 2;        		if (nums[mid] == target) {            
            ...        
        }else if (nums[mid] < target) {            				left = ...                           
        } else if (nums[mid] > target) {            			  right = ...        
        }    
    }    
    return ...;
}

分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。

704,二分查找,easy

给定一个升序数组nums和一个目标值target,搜索 nums中的target,如果存在返回下标,否则返回 -1。

示例 1:

输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

示例 2:

输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1

提示:

你可以假设 nums 中的所有元素是不重复的。
n 将在 [1, 10000]之间。
nums 的每个元素都将在 [-9999, 9999]之间。

题解:

此题是二分查找最简单的场景。通过代码理解上面框架的细节。

代码

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) left = mid + 1;
            else if(nums[mid] > target) right = mid - 1;
        }
        return -1;
    }
}

细节

  1. while 内的循环条件为什么是 <= 而不是< ?

    答:初始化 right 的索引为 nums.length - 1,相当于两端都闭区间 [left, right]这个区间其实就是每次进行搜索的区间

    当找到目标值(nums[mid] == target)停止搜索,如果没找到,就需要while循环终止,然后返回 -1 。

    那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着没得找了,就等于没找到。

    while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。

    while(left < right) 的终止条件是 left == right,写成区间的形式就是 [right, right],或者带个具体的数字进去 [2, 2]这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。

    如果非要用 while(left < right) 也可以,我们已经知道了出错的原因,就打个补丁好了:

        //...
        while(left < right) {
            // ...
        }
        return nums[left] == target ? left : -1;
    
  2. 为什么 left = mid + 1right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断?

    答:本题中搜索区间的两端都是闭的,[left, right]。当索引 mid 对应元素不是target,需要改变leftright重新搜索,搜索区间变为 [left, mid - 1] 或者 [mid + 1, right]

  3. 此算法的缺陷?

    答:当一个有序数组中含有多个目标值时,无法处理。例如:有序数组 nums = [1,2,2,2,3]target 为 2,此算法返回的索引是 2。

引入

上面题目说明了在有序数组中找到元素 target 的方法,下面介绍 搜索左右边界 的方法。

一、寻找左侧边界的二分搜索
  • 下面是最常见的代码形式:
int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length; // 注意

    while (left < right) { // 注意
        int mid = (left + right) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid; // 注意
        }
    }
    return left;
}
  • 细节:

    1. while 内的循环条件为什么是 < 而不是<= ?

      答:因为 right = nums.length,搜索区间是 [left,right)左闭右开。

      while终止的条件是 left == right,此时搜索区间是 [left,left)为空,可以正确终止。

    2. 为什么 left = mid + 1right = mid

      答:由于搜索区间左闭右开,遍历到nums[mid]之后,重新分成的两个区间应该为 [mid + 1, right)[left, mid)

    3. 为什么返回的是 left 而不是 -1 ?

      答:因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qt9xu791-1611046986289)(https://gblobscdn.gitbook.com/assets%2F-MQPy5wekRlwH59Y-EnB%2Fsync%2Fda0a55ba889b5c1682dee99f8180d2bfcea66610.jpg?alt=media)]

      对于这个数组,算法会返回 1。这个 1 的含义可以这样解读:nums 中小于 2 的元素有 1 个。

      比如对于有序数组 nums = [2,3,5,7], target = 1,算法会返回 0,含义是:nums 中小于 1 的元素有 0 个。

      再比如说 nums = [2,3,5,7], target = 8,算法会返回 4,含义是:nums 中小于 8 的元素有 4 个。

      综上可以看出,函数的返回值(即 left 变量的值)取值区间是闭区间 [0, nums.length],所以我们简单添加两行代码就能在正确的时候 return -1:

      while (left < right) {
          //...
      }
      // target 比所有数都大
      if (left == nums.length) return -1;
      // 类似之前算法的处理方式
      return nums[left] == target ? left : -1;
      

      另外,返回left 或 right 都可以,因为while终止的条件是 left == right

  • 最终代码:

    int left_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length; // 注意
    
        while (left < right) { // 注意
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                right = mid;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid; // 注意
            }
        }
        // target 比所有数都大
    	if (left == nums.length) return -1;
    	// 类似之前算法的处理方式
    	return nums[left] == target ? left : -1;
    }
    
二、寻找右侧边界的二分查找

与上面思想类似。

  • 代码:

    int right_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length; // 注意
    
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                //向右收缩
                left = mid + 1; // 注意
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid; 
            }
        }
        return left - 1;// 注意
    }
    
  • 细节:

    1. 为什么找右边界 left = mid + 1

      答:当 nums[mid] == target 时,增大搜索区间 的下界 left,使区间向右收缩。由于搜索区间为[left,right),则向右收缩时,新的下界为 left = mid + 1。寻找左边界向左收缩时,新的上界 right = mid

    2. 为什么最后返回 left - 1 而不像左边界 返回 left

      答:while终止条件 left == right,也就是返回 right - 1

      img

      因为对 left 的更新必须是 left = mid + 1,(也就是 mid = left - 1),while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target

    3. 为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办

      答:类似之前的左侧边界搜索,因为 while 的终止条件是 left == right,就是说 left 的取值范围是 [0, nums.length],所以可以添加两行代码,正确地返回 -1:

      while (left < right) {
          // ...
      }
      if (left == 0) return -1;//注意
      return nums[left-1] == target ? (left-1) : -1;
      
  • 最终代码:

    int right_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length; // 注意
    
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                //向右收缩
                left = mid + 1; // 注意
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid; 
            }
        }
        // target 比所有数都小
        if (left == 0) return -1;
    	return nums[left-1] == target ? (left-1) : -1;
    }
    
34,在排序数组中查找元素的第一个和最后一个位置,medium

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

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

进阶:

你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?

示例 1:

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

示例 2:

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

示例 3:

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

提示:

0 <= nums.length <= 105
-109 <= nums[i] <= 109
nums 是一个非递减数组
-109 <= target <= 109

题解:

思路就是找 target 的左右边界,如果找不到就返回 [-1, -1]。

代码

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int leftBound = -1;
        int rightBound = -1;
        leftBound = getLeftBound(nums, target);
        rightBound = getRightBound(nums, target);
        return new int[]{leftBound, rightBound};
    } 
   //左边界
    public int getLeftBound(int[] nums, int target){
        if(nums.length == 0) return -1;
        int n = nums.length;
        int left = 0;
        int right = n;
        //左闭右开 [left,right)
        while(left < right){
            int mid = left + (right - left) / 2;
            if(nums[mid] == target){
                //向左收缩
                right = mid;
            }else if(nums[mid] < target){
                left = mid + 1;
            }else if(nums[mid] > target){
                right = mid;
            }
        }
        if(left == n) return -1;
        return nums[left] == target? left : -1;
    }
    
    //右边界
    public int getRightBound(int[] nums, int target){
        if(nums.length == 0) return -1;
        int n = nums.length;
        int left = 0;
        int right = n;
        //左闭右开 [left,right)
        while(left < right){
            int mid = left + ( right - left)/ 2;
            if(nums[mid] == target){
                //向右收缩
                left = mid + 1;
            }else if(nums[mid] < target){
                left = mid + 1;
            }else if(nums[mid] > target){
                right = mid;
            }
        }
        if(left == 0) return -1;
        return nums[left - 1] == target? (left - 1): -1;
    }
} 
278,第一个错误的版本,easy

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

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

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

示例:

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

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

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

题解

利用二分法找第一个错误的版本,搜索范围为 [ 1 , n ) [1,n) [1,n),左边界。

代码

/* 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 = left + (right - left) / 2;
            if(isBadVersion(mid)){
                right = mid;
            }
            else    
                left = mid + 1;
        }
        return left;
    }
}
875,爱吃香蕉的可可,medium

珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。

珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。

珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。

返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)。

示例 1:

输入: piles = [3,6,7,11], H = 8
输出: 4

示例 2:

输入: piles = [30,11,23,4,20], H = 5
输出: 30

示例 3:

输入: piles = [30,11,23,4,20], H = 6
输出: 23

提示:

1 <= piles.length <= 10^4
piles.length <= H <= 10^9
1 <= piles[i] <= 10^9

题解:

题中求在 H 小时吃掉全部香蕉的最小速度 K,即在所有可能吃完的速度中找到左边界 K,就转换为 二分法寻找左边界的问题。

对于吃的速度来说,最小为 1,最大为 max(piles),因为每小时只能选一堆香蕉吃。

应用上面寻找左边界的框架,并根据实际场景修改。

  • 先找到数组的最大元素 maxPile
  • [1, maxPile] 范围内使用二分法找 K,target 为是否能吃完香蕉。
    • 如果当前速度能吃完,向左收缩继续找。
    • 如果当前速度不能吃完,向右找。
      代码
class Solution {
    public int minEatingSpeed(int[] piles, int H) {
        int maxPile = 0;
        for(int i : piles){
            if(i > maxPile) maxPile = i;
        }
        int left = 1;
        int right = maxPile + 1;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(canEat(piles, mid, H)){
                //能吃完就向左收缩
                right = mid;
            }
            else{
                left = mid + 1;
            }
        }
        return left;
    }
    //target:当前速度是否能吃完
    public boolean canEat(int[] piles, int speed, int H){
        int time = 0;
        for(int pile : piles){
            time += timeOf(pile, speed);
        }
        return time <= H;
    }
    public int timeOf(int pile, int speed) {
        //向上取整
        return pile / speed + (pile % speed > 0 ? 1 : 0);
    }
}
1011,在D天内送达包裹的能力,medium

传送带上的包裹必须在 D 天内从一个港口运送到另一个港口。

传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。

返回能在 D 天内将传送带上的所有包裹送达的船的最低运载能力。

示例 1:

输入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10

请注意,货物必须按照给定的顺序装运,因此使用载重能力为 14 的船舶并将包装分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允许的。

示例 2:

输入:weights = [3,2,2,4,1,4], D = 3
输出:6
解释:
船舶最低载重 6 就能够在 3 天内送达所有包裹,如下所示:
第 1 天:3, 2
第 2 天:2, 4
第 3 天:1, 4

示例 3:

输入:weights = [1,2,3,1,1], D = 4
输出:3
解释:
第 1 天:1
第 2 天:2
第 3 天:3
第 4 天:1, 1

提示:

1 <= D <= weights.length <= 50000
1 <= weights[i] <= 500

题解:

与上一题相同,是二分法在实际问题的应用。

搜索范围是 能在D天内将所有包裹送达的运载能力 [max(weights),sum(weights)]

寻找左边界。

target:以此运载能力 cap可以在D天全部送达

代码:

class Solution {
    public int shipWithinDays(int[] weights, int D) {
        int maxWeight = 0;
        int sum = 0;
        for(int i : weights){
            sum += i;
        }
        for(int i : weights){
            if(i > maxWeight) i = maxWeight;
        }
        int left = maxWeight;
        int right = sum + 1;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(canTrans(weights, D, mid)){
                right = mid;
            }
            else{
                left = mid + 1;
            }
        }
        return left;
    }
    
    //当前运输能力为K,能否在D天运完
    public boolean canTrans(int[] w, int D, int cap){
        //运送物品个数
        int i = 0;
        //遍历D天,如果运送物品个数 = w数组元素个数,表示可以运完
        for (int day = 0; day < D; day++) {
            int maxCap = cap;
            while ((maxCap -= w[i]) >= 0) {
                i++;
                if (i == w.length)
                    return true;
            }
        }   
        return false;
    }
}
69,x的平方根,easy

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

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

示例 1:

输入: 4
输出: 2

示例 2:

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

题解:

很容易想到 x 的平方根比 x / 2 小,但事实如此吗?

解方程 √ x < x / 2 √x < x / 2 x<x/2 ,得 $x > 4 $,即 x = 1 , 2 , 3 x = 1,2,3 x=1,2,3 时,平方根为 1。在写代码时就要考虑这一特殊情况。

还要注意此题向下取整,具体怎么做在代码中体现。

class Solution {
    public int mySqrt(int x) {
        int left = 0;
        int right = x / 2;
        while(left <= right){
            int mid = left + (right - left) / 2;
            //有测试用例是 2147395599,求得 mid 平方会超过 int 范围,因此需要将平方结果转为 long 
            long sqr = (long)mid * mid;
            long sqr_next = (long)(mid + 1) * (mid + 1);
            //比如 x=8,mid=2,sqr=4,sqr_next=9。向下取整即取当前的mid。
            if(sqr == x || (sqr < x && sqr_next > x)){
               return mid;
            } 
            else if(sqr > x){
                right = mid - 1;
            }
            else {
                left = mid + 1;
            }
        }
        //返回 x 是考虑 x = 1时
        return x;
    }
}

细节

做题时对向下取整和边界问题困扰很久,这种方法比较好理解,也不用考虑那么多。

744,寻找比目标字母大的最小字母,easy

给你一个排序后的字符列表 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 = "d"
输出: "f"

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

提示:

letters长度范围在[2, 10000]区间内。
letters 仅由小写字母组成,最少包含两个不同的字母。
目标字母target 是一个小写字母。

题解

  • 方法一:线性搜索。因为数组已排序,遍历 letters 数组,找到第一个比目标字母大的字母就返回。若都比目标字母小,则返回第一个。
class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
//线性搜索
        for(int i = 0; i < letters.length; i++){
            if(letters[i] - target > 0) 
                return letters[i];        
        }
        return letters[0];
    }
}
  • 方法二:二分搜索。在 letters 数组中找到比 目标字母大的字母 中的左边界。
class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int left = 0;
        int right = letters.length;
        while(left < right){
            int mid = left + (right - left) / 2;
            if(letters[mid] - target > 0){
                right = mid;
            }
            else
                left = mid + 1;
        }
        //数组中的字符都比 target 小
        if(left == letters.length) return letters[0];
        return letters[left];
    }
}

细节

因为可能全部字母都比目标字母大,有可能找不到左边界,还要单独判断一下 left == letters.length,如果是,返回第一个字母。

153,寻找旋转排序数组中的最小值,medium

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

请找出其中最小的元素。

示例 1:

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

示例 2:

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

示例 3:

输入:nums = [1]
输出:1

提示:

1 <= nums.length <= 5000
-5000 <= nums[i] <= 5000
nums 中的所有整数都是 唯一 的
nums 原来是一个升序排序的数组,但在预先未知的某个点上进行了旋转

  • 方法一:线性搜索。如果升序数组发生了旋转,比较相邻两元素,如果前一个元素比后一个元素大,返回后者。如果没旋转,返回首个元素即最小值。
class Solution {
    public int findMin(int[] nums) {
//线性搜索
        for(int i = 0;i < nums.length - 1; i++){
            if(nums[i] > nums[i + 1]) return nums[i + 1];
        }
        //如果不旋转,返回首个元素
        return nums[0];
    }
}
  • 方法二:二分法。此题的目的是找到数组的最小值。

    注意此时不一定是有序数组,可能发生了旋转。在找到 mid 时,要确定最小值在 mid 的左侧还是右侧。

class Solution {
    public int findMin(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        int min = Integer.MAX_VALUE;
        while(left <= right){
            int mid = left + (right - left) / 2;  
            //min在mid右边
            if(nums[mid] > nums[right]){
                min = Math.min(min, nums[mid]);
                left = mid + 1;
            }
            else{
                min = Math.min(min, nums[mid]);
                right = mid - 1;
            }
        }
        return min;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值