文章目录
本篇文章适合于已经了解二分查找简单应用,但是针对相关题目未成体系的同学。这也是我自己对于二分查找知识的汇总与深化。
某些题目不是只有二分查找一种解法,二分查找或许也不是其最优的解法,该文章专注于二分查找解法。
本文参考:
背景知识
根据mid变换left,right是减治思想缩小范围的体现。 「二分」的本质是两段性,并非单调性。只要一段满足某个性质,另外一段不满足某个性质,就可以用「二分」,单调性中隐含着两段性。
多数题目是「极大值极小化」的反比单调性,如LCP 12. 小张刷题计划、410. 分割数组的最大值,也有「极小值极大化」,如1552. 两球之间的磁力;
同时也有正比单调性,如1482. 制作 m 束花所需的最少天数。可以通过「极大」、「极小」的提示,来分别选择left=mid
、right=mid
。二分查找只是
收缩边界
,即二分查找只负责查找,不负责查找范围的合理性判断,上下界限要规定好,有结果的前提是满足条件的值会出现在范围[left,right]
中。就如1894.找到需要补充粉笔的学生编号中没有一个满足粉笔要求从而产生bug,981.基于时间的键值存储不能找到正确的时间戳。初始化right可以设置为length(而不是length-1)的情况是,最后找到的位置可能为length,比如35.搜索插入位置,611. 有效三角形的个数,300. 最长递增子序列的二分解法。同时注意
right=len-1要进行数组长度=0的特判
。
解题模板
首先,while(left<right)
和while(left<=right)
都是针对[left,right]
闭区间来说的,他们的不同主要是退出循环后的结果,一个是left=right
一个为left=right+1
。
while(left<=right):
- 一般考虑三个分支(可以提前退出)或者有两个分支带ans的解法,left,right都需要+1,-1。
- 最终退出循环,left=right+1。
while(left<right):
- 一般考虑两个分支,有取左中位数(
mid不加1
)和取右中位数(mid加1
)两种写法,可以先考虑不带mid的一面,else根据此写反面情况。取哪边需要根据下轮的搜索区间是什么判断。- 面对left=mid的时候,mid取值需要+1,否则会进入死循环。当区间里只有两个元素的时候,mid 的值等于 left,一旦进入搜索区间 [mid. . right],下一轮搜索区间还是[left…right],搜索区间不能缩小,反复下去,进入死循环。
- 最终退出循环,left=right。元素在输入数组不存在的话,最后做个单独讨论就行。
在写二分题目时,经常会遇到形如「在有序数组中查询大于某个数的最小数」这类问题。具体来说有四类:
≥
\ge
≥:在有序数组中查询大于或等于某个数的第一个(最小)数;
>
\gt
>:在有序数组中查询大于某个数的第一个数;
≤
\le
≤:在有序数组中查询小于或等于某个数的最后一个(最大)数;
<
\lt
<:在有序数组中查询小于某个数的最后一个数。
在解题的时候,我会套用 ≥ \ge ≥和 ≤ \le ≤的模板,其他情况也可以通过转换解决,比如查询 > \gt >得到了下标 i i i,那么 i − 1 i−1 i−1就是 ≤ \le ≤ 的结果了(假设数组为升序),同理 < \lt <也可以用 ≥ \ge ≥ 算出来。 > \gt > 和 ≥ \ge ≥ 也可以转换,对于整数来说, > x > x >x 等价于 ≥ x + 1 \ge x+1 ≥x+1。
大于或等于某个数的第一个(最小)数:
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] =target的时候右边界缩小
if(nums[mid]>=target){
right=mid;
}else{
left=mid+1;
}
}
if(nums[left]==target) return left;
else return -1;
}
针对这种模板,还有一种递归的写法:
class Solution {
int ans=-1;
public int search(int[] nums, int target) {
dfs(nums,0,nums.length-1,target);
return ans;
}
// 相当于模拟while(left<right)
public void dfs(int[]nums,int left,int right,int target){
if(left==right){
if(nums[left]==target) ans=left; // 查看是否能找到目标值
return;
}
int mid=(left+right)/2;
if(nums[mid]<target) dfs(nums,mid+1,right,target);
else dfs(nums,left,mid,target);
}
}
小于或等于某个数的最后一个(最大)数:
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;
# [left...mid] =target的时候左边界收缩
if(nums[mid]<=target){
left=mid;
}else{
//nums[mid]>target [left...mid-1]
right=mid-1;
}
}
return left;
}
题型一:二分求下标(在数组中查找符合条件的元素的下标)
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;
}
}
剑指Offer53-I.在排序数组中查找数字I
class Solution {
public int search(int[] nums, int target) {
//二分查找默认right=len-1需要len>=1
if(nums.length==0) return 0;
int first=findFirst(nums,target);
if(first==-1) return 0;
int last=findLast(nums,target);
return last-first+1;
}
public int findFirst(int[]nums,int target){
int len=nums.length;
int left=0;
int right=len-1;
while(left<right){
int mid=(left+right)/2;
if(nums[mid]<target){
left=mid+1;
}else{
right=mid;
}
}
if(nums[left]==target) return left;
return -1;
}
public int findLast(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]>target){
right=mid-1;
}else{
left=mid;
}
}
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;
}
}
793.阶乘函数后K个零
参考题解阶乘函数后 K 个零问题详细剖析,将阶乘尾0问题转换为x阶乘中因子5的个数问题,同时注意到,结果要么是5要么是0。因此问题为,查找是否存在x,使得x的阶乘中因子5个数为k, 若存在,答案为5,否则为0。接下来就是划分x的上下界问题,就是使得f(x)=x/5+x/5^2+x/5^3....=k
,参考题解,x>=4k&&x<=5k
,同时因子5的个数,也可以通过x阶乘的这个x计算出来。
class Solution {
public int preimageSizeFZF(int k) {
long left=4L*k;
long right=5L*k;
while(left<right){
long mid=left+(right-left)/2+1;
if(cal(mid)>k){
right=mid-1;
}else{
left=mid;
}
}
return cal(left)==k?5:0;
}
public long cal(long a){
long res=0;
while(a!=0){
res+=a/5;
a/=5;
}
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;
}
}
以下旋转数组解法参考「宫水三叶」,学会利用二段性。
利用旋转数组的性质,数组前半段>=nums[0]
,后半段不满足,这是「二段性」的体现。找到旋转点后判断target所处的区间,进行二分查找。
class Solution {
public int search(int[] nums, int target) {
int len=nums.length;
if(len==1) return nums[0]==target?0:-1;
int left=0;
int right=len-1;
//找到旋转点的位置,是>=nums[0]的最后一个位置
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]<nums[0]){
right=mid-1;
}else{
//nums[mid]>=nums[0]
left=mid;
}
}
System.out.println(left+" "+right);
//通过比较target与nums[0]的位置来定下target的查找区间,默认target是一定存在的,因此left=left+1的条件可能会越界
if(target>=nums[0]){
left=0;
}else{
left=left+1;
right=len-1;
}
System.out.println(left+" "+right);
//在以上区间查找target
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]>target){
right=mid-1;
}else{
left=mid;
}
}
System.out.println(left+" "+right);
//最后比较right,以免在数组顺序排列的情况下,target小于最小元素,left=left+1越界
return nums[right]==target?right:-1;
}
}
81.搜索旋转排序数组II
相对于33.搜索旋转排序数组,本题有了相同元素,如果在相同元素处进行旋转,将会让分割不具有两段性,因此可以讲后面不满足两段性的地方排除。
class Solution {
public boolean search(int[] nums, int target) {
int len=nums.length;
int left=0;
int right=len-1;
//恢复二段性
while(0<right&&nums[0]==nums[right]) right--;
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]<nums[0]){
right=mid-1;
}else{
left=mid;
}
}
if(target>=nums[0]){
right=left;
left=0;
}else{
left=left+1;
right=len-1;
}
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]>target){
right=mid-1;
}else{
left=mid;
}
}
return nums[right]==target;
}
}
面试题 10.03.搜索旋转数组
解法类似于81.搜索旋转排序数组 II,区别是多个相同元素返回索引最小的元素,需要right=mid,如用例[5,5,5,1,2,3,4,5] 5
class Solution {
public int search(int[] nums, int target) {
int len=nums.length;
int left=0;
int right=len-1;
//恢复二段性
while(0<right&&nums[0]==nums[right]) right--;
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]<nums[0]){
right=mid-1;
}else{
left=mid;
}
}
if(target>=nums[0]){
right=left;
left=0;
}else{
left=left+1;
right=len-1;
}
while(left<right){
int mid=(left+right)/2;
if(nums[mid]<target){
left=mid+1;
}else{
right=mid;
}
}
return nums[right]==target?right:-1;
}
}
153.寻找旋转排序数组中的最小值
同前面一样,可以利用二段性,在原数组中,旋转的分割点后的元素都大于它,因此旋转以后,原旋转分割点变为现在的nums[0]
,同样将数组分为前半段大于等于nums[0]
,后半段小于nums[0]
,在查找不断缩小边界的过程中,仍然是和nums[0]
比较。特别地,在原数组有序的情况下,上面的结论不好看出来,如果到数组的最后一个元素,都为nums[mid]>=nums[0]
,则表明nums[mid]>=nums[0]
对所有元素都成立,因此进行特殊处理最小元素为nums[0]
。参考宫水三叶的题解.
本来尝试一步到位找原数组的最小值,但是没法确定最后一个元素是真的最小元素还是纯粹是原数组已经升序了,一路加到了最后。因此直接转为找原数组的最大值。
class Solution {
public int findMin(int[] nums) {
int left=0;
int right=nums.length-1;
while(left<right){
int mid=left+(right-left+1)/2;
if(nums[0]<=nums[mid]){
left=mid; // nums[0]<nums[mid]或者nums[0]==nums[mid]有可能就是最大值了
}else{
// nums[0]>nums[mid],一定不是最大值
right=mid-1;
}
}
// 即使是原数组已经排好序了,仍然可行
return nums[(left+1)%(nums.length)];
}
}
154.寻找旋转排序数组中的最小值II
类似于153. 寻找旋转排序数组中的最小值,为了利用二段性需要进行二段性恢复。
class Solution {
public int findMin(int[] nums) {
int len=nums.length;
int left=0;
int right=len-1;
while(right>0&&nums[right]==nums[0])right--;
while(left<right){
int mid=(left+right+1)/2;
if(nums[mid]<nums[0]){
right=mid-1;
}else{
//nums[mid]>=nums[0]
left=mid;
}
}
return left+1<len?nums[left+1]:nums[0];
}
}
852.山脉数组的峰顶索引
元素具有二段性,峰顶元素左侧满足arr[i−1]<arr[i]
性质,右侧不满足。
class Solution {
public int peakIndexInMountainArray(int[] arr) {
int len=arr.length;
int left=1;
int right=len-2;
while(left<right){
int mid=(left+right+1)/2;
if(arr[mid]>=arr[mid-1]){
left=mid;
}else{
right=mid-1;
}
}
return left;
}
}
1095.山脉数组中查找目标值
/**
* // This is MountainArray's API interface.
* // You should not implement it, or speculate about its implementation
* interface MountainArray {
* public int get(int index) {}
* public int length() {}
* }
*/
class Solution {
public int findInMountainArray(int target, MountainArray mountainArr) {
int len=mountainArr.length();
//先找到山峰,然后分别在左边和右边的单调区间内进行二分查找
int peak=findTop(mountainArr);
int res=findSorted(target,mountainArr,0,peak);
if(res!=-1) return res;
int res1=findReverse(target,mountainArr,peak+1,len-1);
return res1;
}
public int findTop(MountainArray mountainArr){
int len=mountainArr.length();
int left=0;
int right=len-2;
while(left<right){
int mid=(left+right)/2;
if(mountainArr.get(mid)>=mountainArr.get(mid+1)){
right=mid;
}else{
left=mid+1;
}
}
return left;
}
public int findSorted(int target, MountainArray mountainArr,int start,int end){
int left=start;
int right=end;
while(left<right){
int mid=(left+right)/2;
if(mountainArr.get(mid)<target){
left=mid+1;
}else{
right=mid;
}
}
if(mountainArr.get(left)==target) return left;
return -1;
}
public int findReverse(int target, MountainArray mountainArr,int start,int end){
int left=start;
int right=end;
while(left<right){
int mid=(left+right)/2;
if(mountainArr.get(mid)>target){
left=mid+1;
}else{
right=mid;
}
}
if(mountainArr.get(left)==target) return left;
return -1;
}
}
162.寻找峰值
这里的「二段性」其实是指:在以 mid
为分割点的数组上,根据 nums[mid]
与 nums[mid±1]
的大小关系,可以确定其中一段满足「必然有解」,另外一段不满足「必然有解」(可能有解,可能无解)。因此,以前的「二段性」还能继续细分,不仅仅只有满足 01
特性(满足/不满足)的「二段性」可以使用二分,满足1?
特性(一定满足/不一定满足)也可以二分。
往爬坡的方向搜索准没错,参考@林小鹿的本题图解。同时考虑搜索区间的if else
,不一定>或者<
的那一方就一定不能取中位数。
class Solution {
public int findPeakElement(int[] nums) {
int len=nums.length;
int left=0;
int right=len-1;
while(left<right){
int mid=(left+right)/2;
//取左中位数,nums[mid+1]不越界
if(nums[mid]>nums[mid+1]){
//原数组中没有相等元素
//往递增方向搜索
right=mid;
}else{
//nums[mid]<nums[mid+1]
left=mid+1;
}
}
return left;
}
}
class Solution {
public int findPeakElement(int[] nums) {
int len=nums.length;
int left=0;
int right=len-1;
while(left<right){
int mid=(left+right+1)/2;
//取右中位数,nums[mid-1]不越界
if(nums[mid]>nums[mid-1]){
//原数组中没有相等元素
//往递增方向搜索
left=mid;
}else{
//nums[mid]<nums[mid-1]
right=mid-1;
}
}
return left;
}
}
74.搜索二维矩阵
二维转变为一维
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
int row=matrix.length;
int col=matrix[0].length;
int left=0;
int right=row*col-1;
while(left<right){
int mid=(left+right+1)/2;
int r=mid/col;
int c=mid%col;
if(matrix[r][c]>target){
right=mid-1;
}else{
left=mid;
}
}
int r=left/col;
int c=left%col;
if(matrix[r][c]==target) return true;
return false;
}
}
278.第一个错误的版本
/* The isBadVersion API is defined in the parent class VersionControl.
boolean isBadVersion(int version); */
// 找到出错版本中最小的i,满足两段性
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)!=true){
left=mid+1;
}else{
right=mid;
}
}
return left;
}
}
528.按权重随机选择
根据前缀和构造权重分布序列,一个数字越大,他在权重数列中所占的区域越大。产生一个[1,presum[end]]
的随机数进行二分查找就可以。为什么从1开始,可以这样理解,数组假设为[3, 1], 前缀和为[3, 4]; 1, 2, 3 -> 下标0,4 -> 下标1
考虑到后面的前缀和都大于前面的前缀和,具有二段性,即在[1...len]中二分查找一个最小的i,能使得presum[i]>=target
//time: O(N)
class Solution {
int[]presum;
int len;
public Solution(int[] w) {
len=w.length;
presum=new int[len+1];
for(int i=1;i<=len;i++){
presum[i]=presum[i-1]+w[i-1];
}
}
public int pickIndex() {
int target= (int)(Math.random()*presum[len])+1;
int left=1;
int right=len;
//在[1...len]中二分查找一个最小的i,能使得presum[i]>=target
while(left<right){
int mid=left+(right-left)/2;
if(presum[mid]>=target){
right=mid;
}else{
left=mid+1;
}
}
//最后产生的presum序号比w序号大1
return left-1;
}
}
/**
* Your Solution object will be instantiated and called as such:
* Solution obj = new Solution(w);
* int param_1 = obj.pickIndex();
*/
1894.找到需要补充粉笔的学生编号
在前缀和数组中,查找第一个不满足要求的学生,即presum[i]>k,i可以取到right=i
,由于前缀和数组整体偏移一位,最后减去1.
//time: O(N)
class Solution {
//在前缀和数组中,查找第一个不满足要求的学生,即presum[i]>k
public int chalkReplacer(int[] chalk, int k) {
int len=chalk.length;
long[] presum=new long[len+1];
for(int i=1;i<=len;i++){
presum[i]=presum[i-1]+chalk[i-1];
}
//考虑到循环,我们只看最后一轮的粉笔
int limit=(int)(k%presum[len]);
int left=1;
int right=len;
while(left<right){
int mid=(left+right)/2;
System.out.println(mid);
if(presum[mid]>limit){
right=mid;
}else{
//limit>=presum[mid]
left=mid+1;
}
}
return left-1;
}
}
当通过找最后一个满足要求的,想最后再+1,[5,1,5] 22一直不通过。主要是因为在[1...len]的查找范围内,
可能没有一个能满足要求,
[5,1,5] 22 最终最小只能找到的下标为1,这显然也不满足<=limit要求
。偏移可通过代码如下,其中,return presum[left]<=limit?left:0; 当presum[left]<=limit时,正常返回(k取余后,left必不等于len),presum[left]>limit时,直接返回0
class Solution {
//在前缀和数组中,找到最后一位满足粉笔要求的同学,后一位就是需要补充粉笔的同学
public int chalkReplacer(int[] chalk, int k) {
int len=chalk.length;
long[] presum=new long[len+1];
for(int i=1;i<=len;i++){
presum[i]=presum[i-1]+chalk[i-1];
}
//考虑到循环,我们只看最后一轮的粉笔
int limit=(int)(k%presum[len]);
int left=1;
int right=len;
while(left<right){
int mid=(left+right+1)/2;
System.out.println(mid);
if(presum[mid]>limit){
right=mid-1;
}else{
//limit>=presum[mid]
left=mid;
}
}
//当limit>=presum[left]时,正常返回,limit<presum[1]时,直接返回0
return presum[left]<=limit?left:0;
}
}
540.有序数组中的单一元素
运用的二段性是,在唯一出现数字前面的数,都是一对一对奇偶相等的,然而在它后面的数字,则是不相等的。
class Solution:
def singleNonDuplicate(self, nums: List[int]) -> int:
left=0
right=len(nums)-1
while left<right:
mid =(left+right)//2
# 奇数和前一个比较,偶数和后一个比较,如果比较的前面都是出现两次的话,两个值应该是相等的
if nums[mid]!=nums[mid^1]:
right=mid
else:
left=mid+1
return nums[left]
题型二:二分答案(在一个有范围的区间里搜索一个整数)
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的平方根
只能选择right=mid-1; left=mid
的方法,因为要向下取整,left=mid+1
可能会越界。
// 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;
}
}
29.两数相除
类似于69.x的平方根,选择right=mid-1; left=mid
。同时运用了快速乘法
,类似于快速幂的解法。
注意在考虑越界的情况下,整型int a=-2^31, 不能long b=-a; 深刻理解运算符优先级
。
class Solution {
public int divide(int dividend, int divisor) {
boolean neg=(dividend<0&&divisor>0)||(dividend>0&&divisor<0);
long x=dividend;
long y=divisor;
//只能用x=-x,如果x=-dividend,如果原dividend=-2^31,-dividend越界再次变为-2^31
if(x<0) x=-x;
System.out.println(x);
if(y<0) y=-y;
System.out.println(y);
long left=0;
long right=x;
while(left<right){
long mid=(left+right+1)/2;
if(muli(mid,y)<=x){
left=mid;
}else{
right=mid-1;
}
}
long ans=neg==true?-left:left;
if(ans>Integer.MAX_VALUE||ans<Integer.MIN_VALUE) return Integer.MAX_VALUE;
return (int)ans;
}
public long muli(long a,long b){
long result=0;
while(b>0){
if((b&1)==1){
result+=a;
}
b>>=1;
a+=a;
}
return result;
}
}
441.排列硬币
类似于69.x的平方根,运用等差公式求出硬币和,不断逼近。在[1...n]中,找到最大的i,使得需要用到的硬币数量<=n
// 在[1...n]中,找到最大的i,使得需要用到的硬币数量<=n
class Solution {
public int arrangeCoins(int n) {
long left=1;
long right=n;
while(left<right){
long mid=left+(right-left+1)/2;
if(mid*(mid+1)/2>n){
right=mid-1;
}else{
left=mid;
}
}
return (int)left;
}
}
题型三:二分答案的升级版(每一次缩小区间的都需要进行check)
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;
// check
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
h增加,相应的文章数就会减少,但要保证num>=mid,可以二段性
// time: O(NlogN)
// 在[0,len]中查找最大的mid,使得num>=mid
class Solution {
public int hIndex(int[] citations) {
int left=0;
int right=citations.length;
while(left<right){
int mid=left+(right-left+1)/2;
int num=check(citations,mid);
if(num>=mid){
left=mid; //找最大的
}else{
right=mid-1;
}
}
return left;
}
public int check(int[]citations, int h){
int sum=0;
for(int i:citations){
if(i>=h) sum++;
}
return sum;
}
}
274.H指数
解法同275.H指数II
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;
// check
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.爱吃香蕉的珂珂
速度越快,消耗的时间就可以越小,为了在h以内速度最慢,可以根据此进行二分缩小范围
//在[1,maxV]二分查找最小速度k,使得吃完所有香蕉的时间<=h
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int left=1;
int right=0;
for(int p:piles){
right=Math.max(right,p);
}
while(left<right){
int mid=left+(right-left)/2; // 速度
int cost=check(piles,mid); // 消耗的时间
if(cost<=h){
right=mid; // 速度尽可能地小
}else{
left=mid+1;
}
}
return left;
}
public int check(int[]piles,int speed){
int sum=0;
for(int p:piles){
if(p<speed) sum+=1;
else if(p%speed==0){
sum+=p/speed;
}else{
sum+=p/speed+1;
}
}
return sum;
}
}
410.分割数组的最大值
利用「数组各自和最大值大」,反而「分割数小」的反比单调性进行二分查找。在[max(nums),∑nums]
找到最小的value
,使得分割数小于等于m
。根据数组元素为「整数」以及单调性的性质,最终一定能找到一个value
使得分割数等于m
,并且最大值最小。
这题的关键在于枚举「数组各自和最大值」,同时上下界限要规定好,二分查找只负责查找,不负责查找范围的合理性判断,使得「分割数」不断逼近m
,因此不应该被分割数m限制住思维,同时分割数的判定函数也有tricky。
//分割数组和的最大值的最小值,使得最大值尽可能小的同时,最大分成m个组
//time: O(Nlog∑nums)
class Solution {
public int splitArray(int[] nums, int k) {
int sum=0;
int maxv=0;
for(int i:nums){
sum+=i;
maxv=Math.max(maxv,i);
}
int left=maxv; // 最小值限制
int right=sum;
while(left<right){
int mid=left+(right-left)/2; // 调节最大值
int gnums=check(nums,mid); // 划分出来的组数
if(gnums<=k){ // 反比,组数小了想变大,需要最大值更小
right=mid; // 最大值要尽可能小,说明当前最大值大了组数小了,或者已经相等了
}else{
left=mid+1;
}
}
return left;
}
//先加nums[i],如果超过maxSum则将i置于下一组,但是要注意最后要多加上一组
public int check(int[]nums, int maxSum){
int curv=0;
int curg=0;
int len=nums.length;
for(int i=0;i<len;i++){
curv+=nums[i];
if(curv>maxSum){
curv=nums[i];
curg++;
}
}
return curg+1;
}
}
LCP12.小张刷题计划
类似于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;
}
}
1552.两球之间的磁力
//在[minv,maxv]二分查找磁力,找到最大的磁力i,使得需要放的篮子数目>=m
//磁力越小,需要的篮子越多
class Solution {
public int maxDistance(int[] position, int m) {
Arrays.sort(position);
int len=position.length;
int minv=Integer.MAX_VALUE;
for(int i=1;i<len;i++){
minv=Math.min(minv,position[i]-position[i-1]);
}
int maxv=position[len-1]-position[0];
int left=minv;
int right=maxv;
while(left<right){
int mid=(left+right+1)/2;
int boxes=calBox(position,mid);
if(boxes<m){
right=mid-1;
}else{
left=mid;
}
}
return left;
}
public int calBox(int[]position,int k){
int boxes=1;
int len=position.length;
int start=position[0];
for(int i=1;i<len;i++){
if(position[i]-start>=k){
boxes++;
start=position[i];
}
}
return boxes;
}
}
1608.特殊数组的特征值
class Solution {
public int specialArray(int[] nums) {
int right=nums.length;
int left=0;
while(left<right){
int mid=(left+right)/2;
if(check(nums,mid)>mid){
left=mid+1;
}else{
right=mid;
}
}
//在上面的范围内找答案,在没找到的情况下也是左右边界,因此要再次check
if(check(nums,left)==left) return left;
else return -1;
}
public int check(int[]nums,int limit){
int ans=0;
for(int n:nums){
if(n>=limit) ans++;
}
return ans;
}
}
878.第N个神奇数字
二分查找+容斥原理,这里二分查找是经过了一步转化,要返回第n个数x,其实就是在一个范围中找到x,满足小于等于数字x刚好有N个数字,且是满足条件的最小值,x越大,满足条件的数字越多,因此单调可以二段性夹逼。
通过容斥原理可以算出小于等于当前数字x,且满足能被能被 a 或 b 整除的数字个数,参考灵神的题解。我们要找到小于等于当前数字x刚好有N个数字的最小数字,因此需要选择if(num>=n){right=mid;}
的模板。
class Solution {
public int nthMagicalNumber(int n, int a, int b) {
long left=1;
// 这里是天坑,Math.min(a,b)返回int型,n是int型,int*int要考虑溢出int的问题
long right=(long)Math.min(a,b)*n;
int mod=(int)1e9+7;
while(left<right){
long mid=(left+right)/2;
long num=check(mid,a,b);
if(num>=n){
right=mid;
}else{
left=mid+1;
}
}
// 最后一定能找到
return (int)(left%mod);
}
// 容斥原理计算满足条件的个数
public long check(long x,int a,int b){
return x/a+x/b-x/(a*b/gcd(a,b));
}
public int gcd(int a,int b){
return b==0?a:gcd(b,a%b);
}
}
题型四:将二分查找运用在解题步骤中
1818.绝对差值和
将二分查找用于找nums1
最逼近nums2
中元素的值,前提是对nums1
排序,由于是绝对值,因此需要考虑逼近值右侧的元素(在考虑left=mid,找到的逼近值是找到<=target的最后一个元素
)。差值和优化可以考虑原先的差值和加上减少的差值,该减小的差值为最大。
//time:O(NlogN)
class Solution {
public int minAbsoluteSumDiff(int[] nums1, int[] nums2) {
int mod=(int)1e9+7;
//针对sorted进行排序,便于后面的二分查找最近值
int[]sorted=nums1.clone();
Arrays.sort(sorted);
int len=nums2.length;
long sum=0;
int minus=0;
for(int i=0;i<len;i++){
int target=nums2[i];
int diff=Math.abs(nums2[i]-nums1[i]);
sum+=diff;
int left=0;
int right=len-1;
//在sorted中找到<=target的最后一个元素
while(left<right){
int mid =(left+right+1)/2;
if(sorted[mid]<=target){
left=mid;
}else{
right=mid-1;
}
}
int diff2=Math.abs(sorted[left]-target);
if(left+1<len) diff2=Math.min(diff2,Math.abs(sorted[left+1]-target));
minus=Math.max(minus,diff-diff2);
}
sum-=minus;
return (int)(sum%mod);
}
}
981.基于时间的键值存储
在key对应的list中二分查找<=timestamp的Node的v
,要考虑整个list中没有符合条件的情况,即map存储的list中的timestamp过大,要找的timestamp过小,最终收缩找到的边界node的t就过大
。
class TimeMap {
class Node{
String k,v;
int t;
public Node(String _k, String _v, int _t){
k=_k;
v=_v;
t=_t;
}
}
HashMap<String,List<Node>> map=new HashMap<>();
public void set(String key, String value, int timestamp) {
List<Node> list=map.getOrDefault(key,new ArrayList<>());
//set的timestamp都是严格递增的
list.add(new Node(key,value,timestamp));
map.put(key,list);
}
public String get(String key, int timestamp) {
List<Node> list=map.getOrDefault(key,new ArrayList<>());
int s=list.size();
if(s==0) return "";
//二分查找t<=timestamp的最大值,考虑可能没有这个值
int left=0;
int right=s-1;
while(left<right){
int mid=(left+right+1)/2;
if(list.get(mid).t>timestamp){
right=mid-1;
}else{
left=mid;
}
}
//重复判断,如果找到的值不满足
return list.get(left).t<=timestamp?list.get(left).v:"";
}
}
/**
* Your TimeMap object will be instantiated and called as such:
* TimeMap obj = new TimeMap();
* obj.set(key,value,timestamp);
* String param_2 = obj.get(key,timestamp);
*/
658.找到K个最接近的元素
参考liweiwei大神的题解,图解实在是清晰,它将问题转换为了「查找连续区间的左边界」。
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
int left=0;
int size=arr.length;
int right=size-k;
while(left<right){
int mid=(left+right)/2;
if(x-arr[mid]>arr[mid+k]-x){
left=mid+1;
}else{
right=mid;
}
}
ArrayList<Integer> ans=new ArrayList<>();
for(int i=left;i<left+k;i++){
ans.add(arr[i]);
}
return ans;
}
}