本篇文章适合于了解二分查找简单应用,但是针对相关题目未成体系的同学。这也是我自己对于二分查找知识的汇总与深化。某些题目不是只有二分查找一种解法,二分查找或许也不是其最优的解法,该文章专注于二分查找解法。相关文章更新已放在CSDN上,引路->蒋大钊!的博客
- 根据mid变换left,right是减治思想缩小范围的体现。 「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」,单调性中隐含着两段性。 多数题目是”极大值极小化“的反比单调性,如LCP 12. 小张刷题计划、410. 分割数组的最大值,也有正比单调性,如1482. 制作 m 束花所需的最少天数。
- 初始化right取length(而不是length-1)的情况是,最后找到的位置可能为length,比如35.搜索插入位置,611. 有效三角形的个数,300. 最长递增子序列的二分解法
- while(left<=right)一般考虑三个分支(可以提前退出)或者有两个分支带ans的解法,left,right都需要+1,-1。
- 最终退出循环,left=right+1。
- while(left<right)一般考虑两个分支,有取左中位数(
mid不加1)和取右中位数(mid加1)两种写法,可以先考虑不带mid的一面,else根据此写反面情况。取哪边需要根据下轮的搜索区间是什么判断。- 最终退出循环,left=right。元素在输入数组不存在的话,最后做个单独讨论就行。
- 面对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. 寻找两个正序数组的中位数
使用二分法直接在两个数组中找中位数分割线,使得nums1和nums2中分割线满足以下性质即可根据分割线左右的数来确定中位数:
前置:m = nums1.length,n = nums2.length。设i为nums1中分割线,则取值为[0, m],表示分割线左侧元素下标为[0, i-1],分割线右侧元素下标为[i, m-1];设j为nums2中分割线,....。i和j始终代表分割线右侧的元素下标,也即数组中的元素个数,取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;
}
}