二分法解题思路及模板
一、引入
若要解决leetcode704:二分查找:
在本题中如不是用二分法查找,采用遍历的方式进行查找。其时间复杂度将为:O(n)。在遍历时,代码的逻辑十分简单,只需逐一比对每个元素是否与目标值相当,但如果当数据量十分巨大的时候,该方法的时间消耗就很大。
public int search(int[] nums, int target) {
int result=-1;
for (int i=0;i< nums.length;i++){
if (nums[i]==target){
result=i;
break;
}
}
return result;
}
采取二分法进行查找时,其时间复杂度为:O(log(n))。与遍历方法相比,大大的提升了查找的效率。
public int search(int[] nums, int target) {
int result=-1,left=0,right= nums.length-1;
while (left<=right){
int mid=(left+right)/2;
if (nums[mid]==target) {
result=mid;
break;
} else if (nums[mid]<target) left=mid+1;
else right=mid-1;
}
return result;
}
二、二分要点
二分法:
是一个时间效率极高的算法,其时间复杂度为:O(log(n))。在解决查找大量数据中的目标值的问题中有着极高的效率。其主要思想是,利用数据的二段性不断的将数据规模缩小为当前数据量的一半,成为下次操作的数据,从而得到最终结果。
在二分法中的解题过程中,往往会遇到越界、死循环、边界移动错误、二分对象不明确等诸多问题。针对于这些问题,笔者总结出一句简单有效口诀:一二三四
。在使用二分法解题时,只需解决这四个要点即可完成二分的代码逻辑。
一
口诀中的“一”指的是在使用二分法时具体被二分的数据对象。确定二分对象是二分法最根本的前提。对于一些简单的题目而言,大家可以很快的找到二分的对象如:leetcode704:二分查找,然而对于难度较高且可以使用二分法解决的题目而言,即便我们知道了要使用二分法,但却不知道如何二分、对谁二分,如:leetcode275:H指数Ⅱ
就 leetcode704:二分查找题而言,大家一下便能找到被二分的对象:有序数组。然而,对于本题而言,二分对象并不是citations
数组。那么,二分对象又是什么呢?通过对题目的分析,可以得出h指数的指可以是不在数组内的任意非负整数。因此,不难想到本题的二分对象是0~citations.length
的区间序列。
public int hIndex(int[] citations) {
int n = citations.length,l = 0, r = n;
while (l < r) {
int mid = l + r + 1 >> 1;
if (process(citations, mid)) l = mid;
else r = mid - 1;
}
return r;
}
public boolean process(int[] citations, int mid) {
int index= citations.length-mid;
if (citations[index]>=mid) return true;
return false;
}
对于本题其二分对象也不 是nums
数组,而是0~Max{nums}+1
的区间序列
public boolean process(int[] nums,int c,int maxOperations){
int sum=0;
for (int x:nums){
sum+=(x-1)/c;
}
return sum<=maxOperations;
}
public int minimumSize(int[] nums, int maxOperations) {
Arrays.sort(nums);
int l=0,r=nums[nums.length-1]+1;
while (l+1<r){
int c=l+(r-l)/2;
if (process(nums,c,maxOperations)) r=c;
else l=c;
}
return r;
}
二
口诀中的“二”指的是二分法的两个目的以及二段性判断条件。
两种目的
1. 寻找目标值
以寻找目标值为目的的二分法最经典的题目便是leetcode704:二分查找,此处不做赘述。
2. 寻找边界值
以寻找边界值为目的的二分法又可分为两种:寻找最小值
、寻找最大值
。在确认二分对象后,要根据题意确认需要查找的是最大值还是最小值。对于两种寻找边界值的目的,将进行以下分析。
某次二分操作时的情况如下:
left:二分对象左边界
right:二分对象右边界
mid:二分操作中点
target:最终结果
针对不同的情况在寻找边界值时我们可以根据寻找边界值的特性来判断边界移动情况,其思想大致为:求最大值时,越接近left边界的元素越不满足条件,所以需移动left边界;在求最小值时,越接近right边界的元素越不满足条件,所以需移动right边界。具体分析如下:
- mid元素在target左侧
对于本情况的分析如下:无论是求最大值还是最小值,由于left至mid区间内的元素不包含target(即是不满足条件),将舍弃该区间内的数据。那么将移动left。
- mid元素在target右侧
对于本情况的分析如下:无论是求最大值还是最小值,由于mid至right区间内的元素不包含target(即是不满足条件),将舍弃该区间内的元素。那么将移动right。
- mid元素即为target
对于本情况的分析如下:本情况与前两种情况不同之处为,左右两个区间均含有target。进而无法直接舍弃某个区间的元素,需根据实际二分目的及终止条件的选取确定取舍。分析本情况需结合后续内容,因此在此处暂不进行分析。
二段性函数
二段性:
对某一范围内的数据,存在某个临界点,使得临界点一侧的数据满足某一性质,而另一侧的数据不满足该性质。通常表现为大于临界点或小于临界点。
二段性函数即是进行二分时作为数据筛选的标准。
如:
leetcode275:H指数Ⅱ:二段性函数需判断是否满足至少有h个元素的值大于等于h。
leetcode374:猜数字大小:二段性函数需判断是否为既定的数字。
实例:
leetcode1712:将数组分成三个子数组的方案数本题中既要使用二分法寻找最小值,也要使用二分法寻找最大值。
public int waysToSplit(int[] nums) {
int n = nums.length;
int[] sums = new int[n];// 计算前缀和
sums[0] = nums[0];
for (int i = 1; i < n; i++) sums[i] = sums[i - 1] + nums[i];
final int MOD = 1000000000 + 7;
long ans = 0;
int t = sums[n - 1] / 3;
for (int i = 0; i < n && sums[i] <= t; i++) {
int left = lowerBound(i + 1, n - 1, sums, sums[i] * 2);
int right = upperBound(i + 1, n - 1, sums, sums[i] + (sums[n - 1] - sums[i]) / 2);
if (right >= left) {
ans += right - left + 1;
}
}
return (int) (ans % MOD);
}
public int lowerBound(int left, int right, int[] nums, int target) {
while (left < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
public int upperBound(int left, int right, int[] nums, int target) {
while (left < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] <= target) left = mid + 1;
else right = mid;
}
return left - 1;
}
三
口诀中的“三”指的是在二分循环中三种终止循环的形式:相邻终止
、相等终止
、相错终止
。在解决问题时,对于某些题目这三种终止循环的方式都能够使用,只需注意初始时二分对象边界值的取舍。如:leetcode875:爱吃香蕉的珂珂
在下面模板内容中的左右边界移动情况可先暂记为模板方式,将在四中论述。
1. 相邻终止
在相邻终止方式中,初始区间边界通常为左开右开
的形式。左开右开
的意思是:在确定二分对象时,左右边界要在可行范围之外。例如:某个二分对象的可行范围为1~10,那么其二分对象的边界为:left=0、right=11。
其模板为:
public int function() {
int left = 左边界, right = 右边界;
while(left + 1 < right){
int mid = left + (right - left) / 2;
if(二段性函数) left=mid;
else right=mid;
}
}
在相邻终止方式中,在终止前存在以上三种情况,终止时即是左右边界相邻。三种情况的变化如下:
- 情况1:无论left、right变化,最终都会变成情况3。
- 情况2:left变化变成情况3,right变化变成终止情景跳出循环。
- 情况3:无论left、right变化,最终都会变成终止情景跳出循环。
根据上述内容,对于本题分析可知。在使用相邻终止结束二分循环时,二分对象的可行范围为:1~Max{piles},那么二分对象的边界就为:left=0、right=Max{piles}+1。则可的如下代码:
public boolean process(int[] piles,int y,long h){
long sum=0;
for (int x:piles){
int count=x/y;
int temp=x%y==0 ? 0 : 1;
sum+=count+temp;
}
return sum<=h;
}
public int minEatingSpeed(int[] piles, int h) {
Arrays.sort(piles);
int left=0,right=piles[piles.length-1]+1,result=right;
while (left+1<right){
int mid=left+(right-left)/2;
if (process(piles,mid,h)){
right=mid;
result=Math.min(result,mid);
}else left=mid;
}
return result;
}
2. 相等终止
在相邻终止方式中,初始区间边界通常为左闭右开
或者左开右闭
形式。左闭合右开
的意思是:在确定二分对象时,左右边界要在可行范围之外。例如:某个二分对象的可行范围为1~10,那么其二分对象的边界为:left=0、right=11。
左闭右开
左闭右开:
在确定二分对象时,左边界要在可行范围之内、右边界在可行范围之外。例如:某个二分对象的可行范围为1~10,那么其二分对象的边界为:left=1、right=11。
其模板为:
public int function() {
int left = 左边界, right = 右边界;
while(left < right){
int mid = left + (right - left) / 2;
if(二段性函数) left=mid+1;
else right=mid;
}
}
在相等终止方式左闭右开中,在终止前存在以上四种情况,终止时即是左右边界相等。四种情况的变化如下:
- 情况1:left变化变成情况4,right变化变成情况3。
- 情况2:无论left、right变化,最终都会变成情况4。
- 情况3:left变化变成终止情景跳出循环,right变化变成情况4。
- 情况4:无论left、right变化,最终都会变成终止情景跳出循环。
左开右闭
左开右闭:
在确定二分对象时,左边界要在可行范围之外、右边界在可行范围之内。例如:某个二分对象的可行范围为1~10,那么其二分对象的边界为:left=0、right=10。
其模板为:
public int function() {
int left = 左边界, right = 右边界;
while(left < right){
int mid = left + (right - left) / 2;
if(二段性函数) left=mid;
else right=mid-1;
}
}
在相等终止方式左开右闭中,在终止前存在以上四种情况,终止时即是左右边界相等。四种情况的变化如下:
- 情况1:left变化变成情况3,right变化变成情况4。
- 情况2:无论left、right变化,最终都会变成情况4。
- 情况3:left变化变成情况4,right变化变成终止情景跳出循环。
- 情况4:无论left、right变化,最终都会变成终止情景跳出循环。
根据上述内容,对于本题分析可知。在使用相等终止结束二分循环且为左闭右开
区间时,二分对象的可行范围为:1~Max{piles},那么二分对象的边界就为:left=1、right=Max{piles}+1。则可的如下代码:
public int process(int[] piles, int x) {
int hour = 0;
for (int i = 0; i < piles.length; i++) {
hour += piles[i] / x;
if (piles[i] % x > 0) hour++;
}
return hour;
}
public int minEatingSpeed(int[] piles, int h) {
Arrays.sort(piles);
int left = 1;//速度最小时,耗时最长
int right = piles[piles.length-1] + 1;//速度最大时,耗时最短
while (left < right) {
int mid = left + (right - left) / 2;
if (process(piles, mid) <= h) right = mid;
else left = mid + 1;
}
return left;
}
3. 相错终止
在相邻终止方式中,初始区间边界通常为左闭右闭
的形式。左闭右闭
的意思是:在确定二分对象时,左右边界要在可行范围之内。例如:某个二分对象的可行范围为1~10,那么其二分对象的边界为:left=1、right=10。
其模板为:
public int function() {
int left = 左边界, right = 右边界;
while(left <= right){ // 循环条件
int mid = left + (right - left) / 2; // 中间值坐标
if(二段性函数) left=mid+1;
else right=mid-1;
}
}
在相错终止方式中,在终止前存在以上五种情况,终止时即是左右边界相互交错。五种情况的变化如下:
- 情况1:无论left、right变化,最终都会变成情况4。
- 情况2:left变化变成情况4,right变化变成终止情景跳出循环。
- 情况3:无论left、right变化,最终都会变成情况5。
- 情况4:left变化变成情况5,right变化变成终止情景跳出循环。
- 情况5:无论left、right变化,最终都会变成终止情景跳出循环。
根据上述内容,对于本题分析可知。在使用相错终止结束二分循环时,二分对象的可行范围为:1~Max{piles},那么二分对象的边界就为:left=0、right=Max{piles}。则可的如下代码:
public long process(int[] piles, int k) {
long hour = 0;
for (int i = 0; i < piles.length; i++) {
hour += piles[i] / k;
if (piles[i] % k > 0) hour++;
}
return hour;
}
public int minEatingSpeed(int[] piles, int h) {
int left = 1;// 首先确定 x 的范围
int MAX_PILES = 1;// 最大速度应该为给定数组中的最大数
for (int i = 0; i < piles.length; i++) {
if (MAX_PILES <= piles[i]) MAX_PILES = piles[i];
}
int right = MAX_PILES;
while (left <= right) {
int mid = left + right >> 1;
if (process(piles, mid) <= h) right = mid - 1;
else left = mid + 1;
}
return left;
}
四
口诀中的“四”指的是在二分循环中四种边界取舍取舍和四种结果操作:left=mid
、left=mid+1
、right=mid
、right=mid-1
。
在二分过程中的边界取舍中一般情况的取舍已在二中寻找边界值进行了论述,此处论述的为特殊情况的取舍。在特殊情况中往往需考虑二分对象边界取舍情况、左右边界相邻时取舍情况、边界指向结果时取舍情况。
在取舍时还需考虑二分的向下取整的特性。
根据不同情况可以得出四种取舍的结论:左开left=mid
、左闭left=mid+1
、右开right=mid
、右闭right=mid-1
。
得出以上四个结论需根据二分对象可行范围进行分析
left=mid
left=mid对应的是二分对象左侧可行性范围为开区间的情况
对于该情况可细分为再边界时的取舍与一般情况的取舍。
由于该区间左侧为开区间那么可能存在left+1元素为结果的情况。当left边界移动时,说明left到mid区间的元素的二段性相同(都满足条件或都不满足条件),但由于该区间为开区间无法确定mid元素的二段性,因此left仅能取舍到mid。
left=mid+1
left=mid+1对应的是二分对象左侧可行性范围为闭区间的情况
对于该情况可细分为再边界时的取舍与一般情况的取舍。
由于该区间左侧为闭区间那么可能存在left元素为结果的情况。当left边界移动时,说明left到mid区间的元素的二段性相同(都满足条件或都不满足条件),但由于该区间为闭区间可以确定mid元素的二段性,因此left能取舍到mid+1。
right=mid
right=mid对应的是二分对象右侧可行性范围为开区间的情况
对于该情况可细分为再边界时的取舍与一般情况的取舍。
由于该区间右侧为开区间那么可能存在right-1元素为结果的情况。当right边界移动时,说明mid到right区间的元素的二段性相同(都满足条件或都不满足条件),但由于该区间为开区间无法确定mid元素的二段性,因此right仅能取舍到mid。
right=mid-1
right=mid-1对应的是二分对象右侧可行性范围为闭区间的情况
对于该情况可细分为再边界时的取舍与一般情况的取舍。
由于该区间右侧为开区间那么可能存在right元素为结果的情况。当right边界移动时,说明mid到right区间的元素的二段性相同(都满足条件或都不满足条件),但由于该区间为开区间可以确定mid元素的二段性,因此right能取舍到mid-1。
三、二分法流程
根据上述二分发要点可总结出以下二分法的大致流程
-
确定二分对象
-
确认二分目的(寻找目标值、寻找最大值、寻找最小值)
-
确定二段性判断函数
-
选择终止条件(相邻终止、相错终止、相等终止)
在选择终止条件时即可确定二分对象可行性区间和边界取舍情况
终止条件 | 区间范围 | 取舍情况 |
---|---|---|
相邻终止 | 左开右开 | left=mid、right=mid |
相等终止 | 左开右闭 | left=mid、right=mid-1 |
相等终止 | 左闭右开 | left=mid+1、right=mid |
相错终止 | 左闭右闭 | left=mid+1、right=mid-1 |
二分法的题目练习可以在leetcode的二分查找专题进行练习。