算法学习-二分查找(持续更新中)

274 阅读8分钟

本篇文章适合于了解二分查找简单应用,但是针对相关题目未成体系的同学。这也是我自己对于二分查找知识的汇总与深化。某些题目不是只有二分查找一种解法,二分查找或许也不是其最优的解法,该文章专注于二分查找解法。相关文章更新已放在CSDN上,引路->蒋大钊!的博客

  1. 根据mid变换left,right是减治思想缩小范围的体现。 「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」,单调性中隐含着两段性。 多数题目是”极大值极小化“的反比单调性,如LCP 12. 小张刷题计划410. 分割数组的最大值,也有正比单调性,如1482. 制作 m 束花所需的最少天数
  2. 初始化right取length(而不是length-1)的情况是,最后找到的位置可能为length,比如35.搜索插入位置611. 有效三角形的个数300. 最长递增子序列的二分解法
  1. while(left<=right)一般考虑三个分支(可以提前退出)或者有两个分支带ans的解法,left,right都需要+1,-1。
  2. 最终退出循环,left=right+1。
  1. while(left<right)一般考虑两个分支,有取左中位数(mid不加1)和取右中位数(mid加1)两种写法,可以先考虑不带mid的一面,else根据此写反面情况。取哪边需要根据下轮的搜索区间是什么判断
  2. 最终退出循环,left=right。元素在输入数组不存在的话,最后做个单独讨论就行。
  3. 面对left=mid的时候,mid取值需要+1,否则会进入死循环。

题型一:二分求下标(在数组中查找符合条件的元素的下标)

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

// while(left<right) =target情况,findFirst向左收缩,findLast向右收缩
class Solution {
    public int[] searchRange(int[] nums, int target) {
        int len=nums.length;
        if(len==0) return new int[]{-1,-1};
        int first=findFirst(nums,target);
        if(first==-1) return new int[]{-1,-1};
        int last=findLast(nums,target);
        return new int[]{first,last};
    }
    public int findFirst(int[] nums, int target){
        int left=0;
        int right=nums.length-1;
        while(left<right){
            int mid=left+(right-left)/2;
            //[left...mid]
            if(nums[mid]>=target){
                right=mid;
            }else{
                left=mid+1;
            }
        }
        if(nums[left]==target) return left;
        else return -1;
    }
    
    public int findLast(int[] nums, int target){
        int left=0;
        int right=nums.length-1;
        while(left<right){
            int mid=left+(right-left+1)/2;
            //nums[mid]<=target [left...mid]
            if(nums[mid]<=target){
                left=mid;
            }else{
                //nums[mid]>target [left...mid-1]
                right=mid-1;
            }
        }
        return left;
    }
}

35.搜索插入位置

//查找第一个大于等于target的位置 while(left<right) right=mid缩小范围
class Solution {
    public int searchInsert(int[] nums, int target) {
        int left=0;
        int right=nums.length;
        while(left<right){
            int mid=(left+right)/2;
            if(nums[mid]>=target){
                right=mid;
            }else{
                left=mid+1;
            }
        }
        return right;
    }
}

704. 二分查找

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

611. 有效三角形的个数

//time: O(N^2logN)
//查找大于等于某个数的第一个数字,if(nums[mid]>=sum) right=mid,right能取到length
class Solution {
    public int triangleNumber(int[] nums) {
        Arrays.sort(nums);
        int len=nums.length;
        int res=0;
        for(int i=0;i<len-2;i++){
            for(int j=i+1;j<len-1;j++){
                int sum=nums[i]+nums[j];
                //[j+1...len]寻找满足大于等于sum的最小值下标
                int left=j+1;
                int right=len;
                while(left<right){
                    int mid=(left+right)/2;
                    if(nums[mid]<sum){
                        //[mid+1...right]
                        left=mid+1;
                    }else{
                        //nums[mid]>=sum [left...mid]
                        right=mid;
                    }
                }
                //[j+1...left-1]
                res+=left-1-j;
            }
        }
        return res;
    }
}

300. 最长递增子序列

参考动态规划设计方法&&纸牌游戏讲解二分解法 - 最长递增子序列
牌顶有序因此可以利用二分,找到大于等于poker的第一个位置(以此保证牌堆顶有序),类似于35.搜索插入位置,其中的right初始化为pile类似于将right赋值为length。

//time: O(NlogN)
//while(left<right) right=pile
class Solution {
    public int lengthOfLIS(int[] nums) {
        int pile=0;
        int[] top=new int[nums.length];
        for(int poker:nums){
            //在[0...pile]里搜索,如果可以放在已有牌堆,得到的left<=pile-1,否则可以放在上轮新建的堆上,pile++
            int left=0;
            int right=pile;
            while(left<right){
                int mid=(left+right)/2;
                if(poker>top[mid]) left=mid+1;
                else right=mid;
            }
            if(left==pile) pile++;
            top[left]=poker;
        }
        return pile;
    }
}

436. 寻找右区间

//time:O(NlogN)
// 排序预处理+二分查找第一个大于等于目标值的位置
// 对intervals start进行排序,根据intervals end进行右侧区间查找,当end>arr[len-1]说明无右侧区间
class Solution {
    public int[] findRightInterval(int[][] intervals) {
        int len=intervals.length;
        int[] arr=new int[len];
        HashMap<Integer,Integer> map=new HashMap<>();
        for(int i=0;i<len;i++){
            map.put(intervals[i][0],i);
            arr[i]=intervals[i][0];
        }
        Arrays.sort(arr);
        int[] res=new int[len];
        for(int j=0;j<len;j++){
            int index=binarySearch(arr,intervals[j][1]);
            if(index==-1) res[j]=-1;
            else res[j]=map.get(arr[index]);
        }
        return res;
    }
    
    public int binarySearch(int[]arr, int target){
        int len=arr.length;
        //特殊处理
        if(target>arr[len-1]) return -1;
        int left=0;
        int right=len-1;
        while(left<right){
            int mid=(left+right)/2;
            if(arr[mid]>=target){
                right=mid;
            }else{
                left=mid+1;
            }
        }
        return left;
    }
}

1237. 找出给定方程的正整数解

/*
 * // This is the custom function interface.
 * // You should not implement it, or speculate about its implementation
 * class CustomFunction {
 *     // Returns f(x, y) for any given positive integers x and y.
 *     // Note that f(x, y) is increasing with respect to both x and y.
 *     // i.e. f(x, y) < f(x + 1, y), f(x, y) < f(x, y + 1)
 *     public int f(int x, int y);
 * };
 */

// time: O(NlogN)
// while(left<=right) 利用f(x,y)对于x,y的单调性二分查找
class Solution {
    public List<List<Integer>> findSolution(CustomFunction customfunction, int z) {
        List<List<Integer>> res=new ArrayList<>();
        for(int i=1;i<=1000;i++){
            int left=1;
            int right=1000;
            while(left<=right){
                int mid=(left+right)/2;
                int ans=customfunction.f(i,mid);
                if(ans==z){
                    res.add(Arrays.asList(i,mid));
                    break;
                }else if(ans>z){
                    right=mid-1;
                }else{
                    left=mid+1;
                }
            }
        }
        return res;
    }
}

4. 寻找两个正序数组的中位数

使用二分法直接在两个数组中找中位数分割线,使得nums1nums2中分割线满足以下性质即可根据分割线左右的数来确定中位数:

前置:m = nums1.lengthn = nums2.length。设inums1中分割线,则取值为[0, m],表示分割线左侧元素下标为[0, i-1],分割线右侧元素下标为[i, m-1];设jnums2中分割线,....。ij始终代表分割线右侧的元素下标,也即数组中的元素个数,取0代表分割线在整个数组最左侧,取m或者n代表分割线在最后。

  • m+n为偶数: i + j = (m + n + 1)/2 ,为奇数:i + j = (m + n + 1)/2
  • 分割线左侧元素小于等于分割线右侧元素。由于两个数组均为正序数组,则只需要要求:nums1[i-1] <= nums2[j] && nums2[j-1] <= nums1[i]由于该条件等价于在有序数组nums1中的下标[0, m]中找到最大的i使得nums1[i-1] <= nums2[j],因此可以使用二分查找。(证明:假设我们已经找到了满足条件的最大i,使得nums1[i-1] <= nums2[j],那么此时必有nums[i] > nums2[j],进而有nums[i] > nums2[j-1])。

分割线找到后,若m+n为奇数,分割线左侧的最大值即为中位数;若为偶数,分割线左侧的最大值与分割线右侧的最小值的平均数即为中位数。时间复杂度:O(log(min(m, n))),空间复杂度:O(1)

//right=mid 最大的i解法
class Solution {
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        //在短数组nums1中分割,尽量让nums2不出现越界
        if(nums1.length>nums2.length){
            int[]temp=nums1;
            nums1=nums2;
            nums2=temp;
        }
        int m=nums1.length;
        int n=nums2.length;

        int half=(m+n+1)/2;
        //在nums1 [0...m]中二分查找分割线i,nums2分割线j根据half总数量判断
        //在有序数组nums1中的下标[0, m]中找到最大的i最终使得nums1[i-1]<=nums2[j]&&nums2[j-1]<=nums1[i]
        int left=0;
        int right=m;
        while(left<right){
            //在循环中i肯定不为0,left,right距离始终>=1
            int i=(left+right+1)/2;
            int j=half-i;
            if(nums1[i-1]>nums2[j]){
                //i偏大,[left...i-1]
                right=i-1;
            }else{
                //nums1[i-1]<=nums[j][i...right]向右侧逼近
                //left=i,mid需要+1
                left=i;
            }
        }
        
        //还有可能碰到四种极端情况,在交叉比较时需要处理
        int i=left;
        int j=half-left;
        int nums1LeftMax=i==0?Integer.MIN_VALUE:nums1[i-1];
        int nums2LeftMax=j==0?Integer.MIN_VALUE:nums2[j-1];
        int nums1RightMin=i==m?Integer.MAX_VALUE:nums1[i];
        int nums2RightMin=j==n?Integer.MAX_VALUE:nums2[j];

        if((m+n)%2==1){
            return Math.max(nums1LeftMax,nums2LeftMax);
        }else{
            return (Math.max(nums1LeftMax,nums2LeftMax)+Math.min(nums1RightMin,nums2RightMin))/2.0;
        }
        
    }
}

33. 搜索旋转排序数组

mid总是可以分割出一半有序数组,一半非有序数组,优先判断target是否在有序数组内,以此进行区间收缩。通过判断是否nums[mid]<nums[right],对mid右侧是否是有序数组进行判断。

// time: O(logN)
class Solution {
    public int search(int[] nums, int target) {
        int len=nums.length;
        int left=0;
        int right=len-1;
        while(left<right){
            int mid=(left+right+1)/2;
            if(nums[mid]<nums[right]){
                //[mid...right]是有序数组
                if(nums[mid]<=target&&target<=nums[right]){
                    left=mid;
                }else{
                    right=mid-1;
                }
            }else{
                //nums[mid]>=nums[right]
                //左侧是有序数组,不包括mid,同时和上面保持一致
                if(nums[left]<=target&&target<=nums[mid-1]){
                    right=mid-1;
                }else{
                    left=mid;
                }
            }
        }
        if(nums[left]==target) return left;
        return -1;
    }
}

题型二:二分答案(在一个有范围的区间里搜索一个整数)

374. 猜数字大小

/** 
 * Forward declaration of guess API.
 * @param  num   your guess
 * @return 	     -1 if num is lower than the guess number
 *			      1 if num is higher than the guess number
 *               otherwise return 0
 * int guess(int num);
 */
// while(left<=right)
public class Solution extends GuessGame {
    public int guessNumber(int n) {
        int left=1;
        int right=n;
        while(left<=right){
            int mid=left+(right-left)/2;
            int res=guess(mid);
            if(res==0){
                return mid;
            }else if(res==1){
                left=mid+1;
            }else{
                right=mid-1;
            }
        }
        return -1;
    }
}

69. x 的平方根

// while(left<right) left=mid 注意if else不能随意
class Solution {
    public int mySqrt(int x) {
        if(x==0) return 0;
        if(x<4) return 1;
        int left=2;
        int right=x/2;
        while(left<right){
            int mid=left+(right-left+1)/2;
            if(mid>x/mid){
                //[left...mid-1] 大于的话可以right=mid-1,但是小于的话left=mid+1有可能平方会超过x
                right=mid-1;
            }else{
                //mid*mid<=x [mid...right]
                left=mid;
            }
        }
        return left;
    }
}

题型三:二分答案的升级版(每一次缩小区间的时候都需要遍历数组)

287. 寻找重复数

// time: O(NlogN)
//while(left<right); if(count>mid) right=mid;
class Solution {
    public int findDuplicate(int[] nums) {
        int len=nums.length;
        int n=len-1;
        //在[1...n]中查找
        int left=1;
        int right=n;
        while(left<right){
            int mid=(left+right)/2;
            int count=0;
            for(int i:nums){
                if(i<=mid)count++;
            }
            // [left...mid] 
            if(count>mid){
                right=mid;
            }else{
                // count<=mid [mid+1...right]
                left=mid+1;
            }
        }
        return left;
    }
}

275. H 指数 II

// time: O(NlogN)
// 在[0,len]中查找最大的mid,使得count>=mid
class Solution {
    public int hIndex(int[] citations) {
        int len=citations.length;
        int left=0;
        int right=len;
        while(left<right){
            int mid=(left+right+1)/2;
            int count=0;
            for(int i:citations){
                if(i>=mid)count++;
            }
            if(count>=mid){
                left=mid;
            }else{
                right=mid-1;
            }
        }
        return left;
    }
}

1292. 元素和小于等于阈值的正方形的最大边长

前缀和+二分查找

通过前缀和矩阵保存以[i,j]为右下角索引的左上角子矩阵的数字之和,通过动态规划思想dp[i][j]=mat[i-1][j-1]+dp[i-1][j]+dp[j-1][i]-dp[i-1][j-1]计算,在构建二维矩阵时,可以通过在左方、正上方增加一行0,保证最边缘dp[i][j]计算的统一。矩阵中任意区域的数字之和可以通过dp[i][j] - dp[i - k][j] - dp[i][j - k] + dp[i - k][j - k]计算得到。

二分查找是在边长范围[0,Math.min(m,n)]中,查找最大的i使得在整个mat中存在一个正方形区域其数字之和小于等于threshold。这个判定可以抽象出来一个函数。

//time: O(M*N*log(Math.min(M,N)))
//在边长范围[0,Math.min(m,n)]中,查找最大的i使得在整个mat中存在一个正方形区域其数字之和小于等于threshold
class Solution {
    int m,n;
    int[][]dp;
    public int maxSideLength(int[][] mat, int threshold) {
        m=mat.length;
        n=mat[0].length;
        dp=new int[m+1][n+1];
        //通过动态规划求得前缀和
        for(int i=1;i<=m;i++){
            for(int j=1;j<=n;j++){
                dp[i][j]=mat[i-1][j-1]+dp[i-1][j]+dp[i][j-1]-dp[i-1][j-1];
            }
        }
        int left=0;
        int right=Math.min(m,n);
        while(left<right){
            int mid=(left+right+1)/2;
            if(check(mid,threshold)){
                left=mid;
            }else{
                right=mid-1;
            }
        }
        return left;
    }
    public boolean check(int k,int threshold){
        for(int i=1;i<=m;i++){
            for(int j=1;j<=n;j++){
                //i-k,j-k包含外围0的区域,可以用于计算外侧有数字的区域
                if(i-k<0||j-k<0) continue;
                else{
                    //只计算有数字的区域
                    int temp= dp[i][j]-dp[i-k][j]-dp[i][j-k]+dp[i-k][j-k];
                    if(temp<=threshold)return true;
                }
            }
        }
        return false;
    }
}

1283. 使结果不超过阈值的最小除数

// time:O(Nlog(max(nums)))
//[1...maxV]中二分查找最小的i,使得除法结果求和小于等于threshold
class Solution {
    public int smallestDivisor(int[] nums, int threshold) {
        int maxV=0;
        for(int i:nums){
            maxV=Math.max(maxV,i);
        }
        int left=1;
        int right=maxV;
        while(left<right){
            int mid=(left+right)/2;
            int sum=0;
            for(int i:nums){
                //两个整数除法需要得到准确的值强转double
                sum+=Math.ceil((double)i/mid);
            }
            if(sum>threshold){
                left=mid+1;
            }else{
                //sum<=threshold
                right=mid;
            }
        }
        return left;
    }
}

1300. 转变数组后最接近目标值的数组和

[0,max(arr)]区间内,随着value增大,数组的和sum是单调递增的,考虑二分查找,用sum衡量与target的接近程度。

// time:O(NlogN)
// 找到第一个使得sum大于等于target的value,与value-1判断
class Solution {
    public int findBestValue(int[] arr, int target) {
        int rightBound=0;
        for(int i:arr){
            rightBound=Math.max(rightBound,i);
        }
        int left=0;
        int right=rightBound;
        while(left<right){
            int mid=(left+right)/2;
            int sum=calSum(arr,mid);
            if(target<=sum){
                right=mid;
            }else{
                left=mid+1;
            }
        }
        int target1=calSum(arr,left);
        //如果left=0,left-1=-1,由于target>0,因此根据sum单调性,一定是value=0最优解
        int target2=calSum(arr,left-1);
        if(Math.abs(target-target2)<=Math.abs(target1-target)){
            return left-1;
        }
        return left;
        
    }
    
    public int calSum(int[]arr, int value){
        int sum=0;
        for(int i:arr){
            sum+=Math.min(i,value);
        }
        return sum;
    }
}

875. 爱吃香蕉的珂珂

//在[1,maxV]二分查找最小速度k,使得吃完所有香蕉的时间<=h
class Solution {
    public int minEatingSpeed(int[] piles, int h) {
        int maxV=0;
        for(int pile:piles){
            maxV=Math.max(maxV,pile);
        }
        int left=1;
        int right=maxV;
        while(left<right){
            int mid=(left+right)/2;
            int cnt=0;
            for(int pile:piles){
                cnt+=pile%mid==0?pile/mid:pile/mid+1;
            }
            if(cnt>h){
                left=mid+1;
            }else{
                // cnt<=h
                right=mid;
            }
        }
        return left;
    }
}

410. 分割数组的最大值

利用「数组各自和最大值大」,「分割数小」的反比单调性进行二分查找。在[max(nums),∑nums]找到最小的value,使得分割数小于等于m。根据数组元素为「整数」以及单调性的性质,最终一定能找到一个value使得分割数等于m

这题的关键在于枚举「数组各自和最大值」,使得「分割数」不断逼近m,因此不应该被分割数m限制住思维,同时分割数的判定函数也有tricky。

//分割数组和的最大值的最小值,使得分割数小于等于m
//time: O(Nlog∑nums)
class Solution {
    public int splitArray(int[] nums, int m) {
        int maxV=0;
        int sum=0;
        for(int i:nums){
            maxV=Math.max(maxV,i);
            sum+=i;
        }
        int left=maxV;
        int right=sum;
        while(left<right){
            int mid=left+(right-left)/2;
            int splits=calSplit(nums,mid);
            if(splits>m){
                left=mid+1;
            }else{
                right=mid;
            }
        }
        return left;
    }

    //先加i,如果超过k则将i置于下一轮,splits++
    public int calSplit(int[]nums,int k){
        int splits=1;
        int sum=0;
        for(int i:nums){
            sum+=i;
            if(sum>k){
                splits++;
                sum=i;
            }
        }
        return splits;
    }
}

LCP 12. 小张刷题计划

类似于410. 分割数组的最大值,只不过在分割函数上需要考虑省去该区间内的最大值。二分查找在[0,∑nums]找到最小的value,使得分割数小于等于m。由于是从大范围不断减治下来,刚开始分割数一般都小于m,在二分查找逼近中,在可以分割成m个数组的情况下,答案就是分割成m个数组。

// time:O(Nlog(∑nums))
class Solution {
    public int minTime(int[] time, int m) {
        int sum=0;
        // for(int i:time){
        //     sum+=i;
        // }
        int left=0;
        // int right=sum;
        int right=Integer.MAX_VALUE;
        while(left<right){
            int mid=left+(right-left)/2;
            int splits=calSplit(time,mid);
            if(splits>m){
                left=mid+1;
            }else{
                right=mid;
            }
        }
        return left;
    }
    public int calSplit(int[]time, int k){
        int sum=0;
        int maxv=0;
        int splits=1;
        for(int i:time){
            sum+=i;
            maxv=Math.max(maxv,i);
            if(sum-maxv>k){
                splits++;
                sum=i;
                maxv=i;
            }
        }
        return splits;
    }
}

1011. 在 D 天内送达包裹的能力

410. 分割数组的最大值
左边界为max(nums),最终运输天数可能小于days天。

class Solution {
    public int shipWithinDays(int[] weights, int days) {
        int sum=0;
        int maxv=0;
        for(int i:weights){
            sum+=i;
            maxv=Math.max(i,maxv);
        }
        //做边界
        int left=maxv;
        int right=sum;
        while(left<right){
            System.out.println("left="+left+"right="+right);
            int mid=(left+right)/2;
            int splits=calsShip(weights,mid);
            if(splits>days){
                left=mid+1;
            }else{
                right=mid;
            }
        }
        return left;
    }

    public int calsShip(int[] weights, int k){
        int splits=1;
        int sum=0;
        for(int i:weights){
            sum+=i;
            if(sum>k){
                sum=i;
                splits++;
            }
        }
        return splits;
    }
}

1482. 制作 m 束花所需的最少天数

等待天数越多,可以用于制作的花束就越多,成正比单调性。判断当前数组里可以制作多少花束是tricky的。

//time: O(Nlog(max(nums)))
//[min...max]中二分查找最小的day,使得能够得到的花束>=m
class Solution {
    public int minDays(int[] bloomDay, int m, int k) {
        if(bloomDay.length<m*k) return -1;
        int left=Integer.MAX_VALUE;
        int right=0;
        for(int i:bloomDay){
            left=Math.min(left,i);
            right=Math.max(right,i);
        }
        while(left<right){
            int mid=(left+right)/2;
            int bouquets=calBouquet(bloomDay,k,mid);
            if(bouquets<m){
                //得到花束太少,加长等待天数day
                left=mid+1;
            }else{
                right=mid;
            }
        }
        return left;
    }
    public int calBouquet(int[] bloomDay,int k,int day){
        //连续的花朵数
        int cnt=0;
        int res=0;
        for(int i:bloomDay){         
            if(day>=i)cnt++;
            else cnt=0;

            if(cnt==k){
                res++;
                cnt=0;
            }
        }
        return res;
    }
}