算法题目 二分查找

目录

      • 二分模版
      • 一般的二分法
        • 寻找正确插入的位置
        • X的平方根
        • 环形区域内寻找比目标值大的最小值
        • 第一个错误版本
        • 寻找旋转排序数组中的最小值
        • 排序数组中查找元素的第一个和最后一个位置
      • 巧妙的二分法
        • 成对元素中的单一元素
        • 乘法表中第k小的数
        • 二分法寻找最优区间的左边界
        • 分割数组的最大值
        • 吃完所有香蕉的最小速度


二分模版

正常实现

Input : [1,2,3,4,5];key : 3
return the index : 2

public int binarySearch(int[] nums, int key) {
    int l = 0, h = nums.length - 1;
    while (l <= h) {
        int m = l + (h - l) / 2;
        if (nums[m] == key) {
            return m;
        } else if (nums[m] > key) {
            h = m - 1;
        } else {
            l = m + 1;
        }
    }
    return -1;
}

时间复杂度

二分查找也称为折半查找,每次都能将查找区间减半,这种折半特性的算法时间复杂度为 O(logN)。

m 的计算

有两种计算中值 m 的方式:

  • m = (l + h) / 2
  • m = l + (h - l) / 2

l + h 可能出现加法溢出,也就是说加法的结果大于整型能够表示的范围。但是 l 和 h 都为正数,因此 h - l 不会出现加法溢出问题。所以,最好使用第二种计算法方法。

未成功查找的返回值

循环退出时如果仍然没有查找到 key,那么表示查找失败。

  • -1:以一个错误码表示没有查找到 key

变种实现

二分查找可以有很多变种,变种实现要注意边界值的判断。例如在一个有重复元素的数组中查找 key 的最左位置的实现如下:

public int binarySearch(int[] nums, int key) {
    int l = 0, h = nums.length - 1;
    while (l < h) {
        int m = l + (h - l) / 2;
        if (nums[m] >= key) {
            h = m;
        } else {
            l = m + 1;
        }
    }
    return l;
}

该实现和正常实现有以下不同:

  • h 的赋值表达式为 h = m
  • 循环条件为 l < h
  • 最后返回 l 而不是 -1

在 nums[m] >= key 的情况下,可以推导出最左 key 位于 [l, m] 区间中,这是一个闭区间。h 的赋值表达式为 h = m,因为 m 位置也可能是解。

在 h 的赋值表达式为 h = m 的情况下,如果循环条件为 l <= h,那么会出现循环无法退出的情况,因此循环条件只能是 l < h。以下演示了循环条件为 l <= h 时循环无法退出的情况:

nums = {0, 1, 2}, key = 1
l m h
0 1 2 nums[m] >= key
0 0 1 nums[m] < key
1 1 1 nums[m] >= key
1 1 1 nums[m] >= key

查找的返回值的理解
变种的二分查找最终返回的是 l,该返回值有以下解释:

  • 当key存在于nums中,且唯一存在,则 l 就是key在nums中的位置
  • 当key存在于nums中,但不唯一存在,则 l 是key在nums中最左边出现的位置
  • 当key不存在于nums中,但min(nums)
  • 当key不存在于nums中,但key
  • 当key不存在于nums中,但key>max(nums), l 为 nums.length-1

当循环体退出时,为了验证有没有查找到,需要在调用端判断一下返回位置上的值和 key 是否相等。


一般的二分法

寻找正确插入的位置

leetcode 35 正确插入的位置(简单)
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

你可以假设数组中无重复元素。

示例 1:

输入: [1,3,5,6], 5
输出: 2

示例 2:

输入: [1,3,5,6], 2
输出: 1

示例 3:

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

示例 4:

输入: [1,3,5,6], 0
输出: 0

题解:

class Solution {
    public int searchInsert(int[] nums, int target) {
        if(nums.length == 0){
            return 0;
        }
           
        int l = 0, r = nums.length-1;
        
        if(target>nums[r]){
            return r+1;
        }
        
        while(l

X的平方根

leetcode 69 x的平方根(简单)
实现 int sqrt(int x) 函数。

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

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

示例 1:

输入: 4
输出: 2

示例 2:

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

题解:
注意:乘法运算很容易造成溢出

class Solution {
    public int mySqrt(int x) {
        int l = 1, r = x;
        while(l=x/mid){     // 乘法可能会溢出, 改为除法
                r = mid;
            }else{
                l = mid+1;
            }
        }
        
        return l>x/l? (l-1):l;  // 乘法可能会溢出, 改为除法
    }
}

环形区域内寻找比目标值大的最小值

leetcode 744 寻找比目标字母大的最小字母(简单)
给定一个只包含小写字母的有序数组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 = “g”
输出: “j”

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

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

注:

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

题解:

class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int n = letters.length;
        int l = 0, r = n-1;
        while(l=target){
                r = mid;
            }else{
                l = mid+1;
            }
        }
        
        char res = letters[l];
        
        if(l == n-1 && letters[l] < target){
            res = letters[0];
        }
        
        if(letters[l] == target){
            int i = 1;
            while(res == target){    // 防止[e,e,e,e,n,n] 的情况
                res = letters[(l+i)%n];
                i++;
            }           
        } 
        
        return res;
    }
}

第一个错误版本

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

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

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

示例:

给定 n = 5,并且 version = 4 是第一个错误的版本。
调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true
所以,4 是第一个错误的版本。

题解:

/* 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 l=1, r=n;
        while(l

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

leetcode 153 寻找旋转排序数组中的最小值(中等)
假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [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

题解:

class Solution {
    public int findMin(int[] nums) {
        int n = nums.length;
        int l=0, r=n-1;
        while(lnums[r]){
                l = mid+1;
            }else{
                r = mid;
            }
        }        
        return nums[l];
    }
}

排序数组中查找元素的第一个和最后一个位置

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]

题解:

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int[] res = new int[2];
 
        int s = sub(nums, target);
        int e = sub(nums, target+1)-1;
        
        if( s == nums.length || nums[s] != target){  // ||运算具有短路性质,顺序不能变 
            res[0] = -1;
            res[1] = -1;
        }else{
            res[0] = s;
            res[1] = e;
        }
        
        return res;        
    }
    
    // 二分查找
    private int sub(int[] nums, int target){
        int l=0, r=nums.length;    // 注意,这里的右边界取nums.length, 当目标值大于数组中最大值时,返回nums.length
        while(l=target){
                r = mid;
            }else{
                l = mid+1;
            }
        }        
        return l;
    }
}

巧妙的二分法

成对元素中的单一元素

leetcode 540 有序数组中的单一元素(中等)
给定一个只包含整数的有序数组,每个元素都会出现两次,唯有一个数只会出现一次,找出这个数。

示例 1:

输入: [1,1,2,3,3,4,4,8,8]
输出: 2

示例 2:

输入: [3,3,7,7,10,11,11]
输出: 10

注意: 您的方案应该在 O(log n)时间复杂度和 O(1)空间复杂度中运行。

题解:
插入单一元素后,成对元素在数组中的奇偶位会发生改变。

偶位 奇位 偶位 奇位 偶位 奇位 偶位 奇位 偶位
1 1 2 2 9 4 4 5 5

插入单一元素9后,原先先偶数位后奇数位的成对元素,变成了先奇数位后偶数位

由此,我们先保证mid为偶数位,若nums[mid]==nums[mid+1], 则单一元素在mid+1之后, 令l=mid+2;

若nums[mid] != mid[mid+1], 则说明mid可能是单一元素所在位置,也可能是单一元素后边的位置,令r=mid.

class Solution {
    public int singleNonDuplicate(int[] nums) {
        int n = nums.length;
        int l = 0, r = n-1;
        
        while(l

乘法表中第k小的数

leetcode 668 乘法表中第k小的数(困难)
几乎每一个人都用 乘法表。但是你能在乘法表中快速找到第k小的数字吗?

给定高度m 、宽度n 的一张 m * n的乘法表,以及正整数k,你需要返回表中第k 小的数字。

例 1:

输入: m = 3, n = 3, k = 5
输出: 3
解释:
乘法表:
1 2 3
2 4 6
3 6 9
第5小的数字是 3 (1, 2, 2, 3, 3).

例 2:

输入: m = 2, n = 3, k = 6
输出: 6
解释:
乘法表:
1 2 3
2 4 6
第6小的数字是 6 (1, 2, 2, 3, 4, 6).

注意:

  • m 和 n 的范围在 [1, 30000] 之间。
  • k 的范围在 [1, m * n] 之间。

题解:
解这道题可能会想着先构造出这个乘法表,然后再去搜索,但这样是行不通的,因为m、n的取值可能非常大,非常耗内存。

首先我们知道在m、n的乘法表中取值范围为[1, m * n],那么我们可不可以使用使用二分搜索呢?

观察乘法表我们会发现,由于构造关系,决定了他每一行都是递增的。

如果我们需要在第i行中寻找小于等于num的个数,我们只要min(num / i, n),其中(i是这一行的行号,n是矩阵的列数)num / i代表的是如果num也在第i行,n为列数,所以只要取最小值就是第i行中不大于num的个数。(比如例题1中,我们需要知道第2行,不大于4的个数,min(4 / 2, 3) == 2个(就是2, 4))

这样,我们确定这个乘法表中不大于num的数的总个数就非常简单了,我们只要将每一行不大于num的个数累加即可。(比如例题1中,我们需要知道乘法表中不大于4的个数,第一行3个、第二行2个,第三行1个)

现在,我们就可以使用二分搜索了,初始化left = 1, right = n * m ,mid = (left + right) / 2,在m,n的乘法表中寻找不超过mid的个数。

class Solution {
    public int findKthNumber(int m, int n, int k) {
        int l = 1, r = m*n;
        
        while(l=k){
                r = mid;
            }else{
                l = mid+1;
            }                        
        }
        
        return l;       
    }
}

二分法寻找最优区间的左边界

leetcode 658 找到 K 个最接近的元素(中等)
给定一个排序好的数组,两个整数 k 和 x,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。如果有两个数与 x 的差值一样,优先选择数值较小的那个数。

示例 1:

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

示例 2:

输入: [1,2,3,4,5], k=4, x=-1
输出: [1,2,3,4]

说明:

  • k 的值为正数,且总是小于给定排序数组的长度。
  • 数组不为空,且长度不超过 104
  • 数组里的每个元素与 x 的绝对值不超过 104

题解:
假设 mid 是左边界,则当前区间覆盖的范围是 [mid, mid + k -1]. 如果发现 a[mid] 与 x 距离比 a[mid + k] 与 x 的距离要大,说明解一定在右侧。

class Solution {
    public List findClosestElements(int[] arr, int k, int x) {
        List res = new ArrayList();
        int n = arr.length;
        if(n==0 || k>n){
            return res;
        }
        
        int l=0, r=n-k;   // 注意这里 r 的初值时n-k,而不是n-1
        while(l arr[m+k]-x){  // 区间需要右移
                l=m+1;
            }else{                      // 区间左移或者不移,当arr[m]恰好是最优区间的左边界时,区间不移
                r=m;
            }
        }
        
        for(int i=l; i

分割数组的最大值

lettcode 410 分割数组的最大值(困难)
给定一个非负整数数组和一个整数 m,你需要将这个数组分成 m 个非空的连续子数组。设计一个算法使得这 m 个子数组各自和的最大值最小。

注意:
数组长度 n 满足以下条件:

  • 1 ≤ n ≤ 1000
  • 1 ≤ m ≤ min(50, n)

示例:

输入:
nums = [7,2,5,10,8]
m = 2
输出:
18
解释:
一共有四种方法将nums分割为2个子数组。
其中最好的方式是将其分为[7,2,5] 和 [10,8],
因为此时这两个子数组各自的和的最大值为18,在所有情况中最小。

题解:
1 dfs暴力求解, leetcode会超时
时间复杂度O(n^m), 空间复杂度O(n)

class Solution {
    private int res = Integer.MAX_VALUE;
    
    public int splitArray(int[] nums, int m) {
        
        dfs(nums, m, 0, 0, 0, 0);
        return res;       
    }
    
    private void dfs(int[] nums, int m, int index, int cntOfArr, int curSum, int maxSum){
        if(index == nums.length){
            if(cntOfArr == m){
                res = Math.min(res, maxSum);
            }
            
            return;
        }
        
        // 由于连续,只能向前一个分割区添加索引为index的元素
        // 当index==0时,只能新开一个分组
        if(index > 0){
            dfs(nums, m, index+1, cntOfArr, curSum+nums[index], Math.max(maxSum, curSum+nums[index]));
        }
        
        // 分割数没有达到上限m时, 可以将当前元素作为一个新分割
        if(cntOfArr  

2 动态规划

这个问题满足无后向性的特点。

无后向性的特点意味着,一旦当前状态确定了,它就不会被之后的状态影响。在这个问题里面,如果我们在将 nums[0…j] 分成 i 份时得到了当前最小的分割数组的最大值,不论后面的部分怎么分割这个值不会受到影响。

首先我们把 dp[i][j] 定义为将 nums[0…j] 分成 i 份时能得到的最小的分割子数组最大值。

对于第 i个子数组,它为数组中下标 k + 1 到 j 的这一段。因此,dp[i][j] 可以从 max(f[i-1][k], nums[k + 1] + … + nums[j]) 这个公式中得到。遍历所有可能的 k,会得到 dp[i][j] 的最小值。

整个算法那的最终答案为 dp[m-1][n-1],其中 n 为数组大小。

复杂度分析

时间复杂度: O(n^2 * m)O(n^2∗m)
总状态数为 O(n * m)O(n∗m)。为了计算每个状态 f[i][j],需要遍历整个数组去找到那个最优的 k,这里会产生 O(n)O(n) 次循环。所以总时间复杂度为 O(n ^ 2 * m)O(n^2 ∗m).

空间复杂度: O(n * m)O(n∗m)
空间复杂度为状态总数,也就是 O(n * m)O(n∗m)。

class Solution {
    public int splitArray(int[] nums, int m) {
        
        int n = nums.length;
        
        // 用第[0,i]号节点完成第[0,j]件工作,所需的最大处理时间
        int[][] dp = new int[m][n];
        
        // sum[k] 表示nums[0]~nums[k]的和
        int[] sum = new int[n];
        sum[0] = nums[0];
        for(int i=1; ij
                dp[i][j] = Integer.MAX_VALUE;
                for(int k=0; k

3.二分查找法
子数组的最大值是有范围的,即在区间[max(nums), sum(nums)]之中。

令l=max(nums),h=sum(nums),mid=(l+h)/2,计算数组和最大值不大于mid对应的子数组个数cnt (这个是关键!)

如果cnt>m,说明划分的子数组多了,即我们找到的mid偏小,故l=mid+1;
否则,说明划分的子数组少了,即mid偏大(或者正好就是目标值),故h=mid。

class Solution {
    public int splitArray(int[] nums, int m) {
        int n = nums.length;
        int maxNum = nums[0];
        int sumArr = 0;
        for(int i=0; i maxNum){
                maxNum=nums[i];
            }
            sumArr += nums[i];
        }
        
        // 子数组的最大和一定介于[maxNum, sumOfArr]之间,通过猜测最大和,对此区间进行二分查找
        // 当猜测的子数组最大和偏小时,子数组个数会偏多,猜测的子数组最大和偏大时,子数组个数会偏小
        int l = maxNum, r = sumArr;
        while(l maxSum){
                cnt++;
                sum = 0;
            }
            sum = sum + nums[i];
        }
        
        return cnt+1;  // 注意,最后一组可能不满足sum + nums[i] > maxSum,这里要加一
    }
}

吃完所有香蕉的最小速度

leetcode 875 爱吃香蕉的珂珂(中等)
珂珂喜欢吃香蕉。这里有 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

题解:
二分法, 找出能吃完所有香蕉的最小速度的可能取值范围,通过二分定位最终答案

class Solution {
    public int minEatingSpeed(int[] piles, int H) {
        int n = piles.length;
        int maxNum = piles[0];
        for(int i=0; imaxNum){
                maxNum = piles[i];
            }
        }
        
        // 能吃完所有香蕉的最小速度介于[1, maxNum]之间,进行二分查找
        int l=1, r=maxNum;
        while(lH){
                l = mid+1;
            }else{
                r = mid;
            }
        }
        
        return l;        
    }
    
    // 在速度为k的情况下,吃完所有香蕉所需的最少小时数
    private int getH(int[] piles, int k){
        int h = 0;
        for(int i=0; i

参考:Cyc2018 算法

你可能感兴趣的:(算法基础)