二分/多分概念和技巧
1.概念
二分/多分是通过两个或者多个变量,将大的问题依次转成小的问题求解,降低时间复杂度。
本质上是分治法的思想,不同点是分治可以将问题分为多个小问题,而二分/多分是将问题转成一个小问题。
如何正确的写对二分需要注意某些细节。
2.二分适用情况
- 双指针的适用问题,双指针本质上也是二分思想。
- 有序序列,或者部分有序序列上的查找。
- 三分求单峰或者单谷函数的极值点。
- 将答案转成判定
- 排除大问题的某一部分,将大问题转成小问题,再小问题上继续。
3.有序域上的二分实现
这里的有序域指的是答案的范围,而不是问题的范围,有的问题可能不是有序的,但是答案的查找范围可以看作有序的。
二分查找本质上就是找一个数,这个数满足某个函数,把这个函数叫做valid();有时候满足valid()函数的元素有多个,因此,单独对原数组使用valid()函数,我们可以找到一个集合。我们可能需要找到在原数组中排在最左或者最右的的元素。
所以二分查找实现时,需要注意2点:
- valid()函数设计。
- 找最左还是最右的元素。
所以在实现二分时,可以将原问题转换成找到所有满足某个条件的元素(valid()设计)的最右或者最左的值。
重要:通过描述可以发现,[l-r]这个区间上有一个点满足x使得
[l-x]上的点都满足或者不满足valid()函数(根据找最左还是最右边),[x-r]同理。
因此根据上述条件,有2种实现方式。
找满足valid(),最左的元素,使用[ )。
l = 0;
r = n; //r也可以取n-1
while(l<r){
int mid = (l+r) >> 1; //也可以写成l+((r-l)>>1)
if(valid()) r = mid;
else l = mid+1;
}
找满足valid(),最右的元素,使用( ]。
l = -1; //l也可以取0
r = n-1;
while(l<r){
int mid = (l+r+1) >> 1; //注意这里和上面的[)不一样。也可以写成r-((l+r)>>1)
if(valid()) l = mid;
else r = mid - 1;
}
在求mid时,使用移位操作,而不是除法(l+r)/2,是因为右移操作会向下取整,而除法向0取整。因此,右移不仅可以用于正数,还可以用于负数。
对于可能出现溢出的情况,则可以使用:l + (r-l)>>1 代替(l+r)>>1; 使用r-(r-l)>>1代替(l+r+1)>>1。
上述的代码都只能用于整数域上的二分查找,也即原数组元素时整数,对于实数域上的二分查找,可以做下述处理:
//将while循环条件的l<r改为l+eps<r;其中eps表示要查找的实数的精确度。
while(l+eps < r){
//......
}
例子:
1.在包含重复元素的数组[1,2,2,3,3,3,4,5,5,5,6,7,8,8,9] 找到排在最左边的5的下标,此题应该返回7。
//1:此题,我们可以将原题转化成,找到满足>=5的最左边的数。
//2:因此,valid()函数,就是找到所有>=5的数,[5,6,7,8,9]都符合。
//3:找最左边,使用[ )找到最左边的数。
//代码:
nums = [1,2,2,3,3,3,4,5,5,5,6,7,8,8,9];
Boolean valid(int a){
return a>=5;
}
int main(){
int l = 0;
int r = nums.length; //因为右边是开区间,所以初始值为nums.length。
while(l<r){
int mid = (l+r)>>1;
if(valid(nums[mid])) r = mid;
else l = mid +1;
}
return nums[l] == 5?l:-1;
}
2.改变一下题目,在包含重复元素的数组[1,2,2,3,3,3,4,5,5,5,6,7,8,8,9] 找到排在最右边的5的下标,此题应该返回9。
//1:此题和上题类似,我们可以将原题转成,找到满足<=5的最右边的数。
//2:因此,valid()函数,就是找到所有<=5的数,[1,2,3,4,5]都符合。
//3:找最右边使用( ]。
//代码:
nums = [1,2,2,3,3,3,4,5,5,5,6,7,8,8,9];
Boolean valid(int a){
return a<=5;
}
int main(){
int l = -1; //因为左边是开区间,所以初始值为-1。
int r = nums.length-1;
while(l<r){
int mid = (l+r+1)>>1;
if(valid(nums[mid])) l = mid;
else r = mid - 1;
}
return nums[l] == 5?l:-1;
}
3.再改变一下,在不包含重复元素的数组[1,2,3,4,5,6,7,8,9]种找到5。
这题就有多种写法了:
-
将原题转成找到满足<=5的最右边的数,同第2题代码。
-
将原题转成找到满足>=5的最左边的数,同第1题代码。
-
将原题转成找到满足==5的最左边的数。
nums = [1,2,3,4,5,6,7,8,9]; Boolean valid(int a){ return a==5; } int main(){ int l = 0; int r = nums.length; while(l<r){ int mid = (l+r)>>1; if(valid(nums[mid]) && nums[mid] < 5) r = mid; else l = mid +1; } return nums[l] == 5?l:-1; }
可以看出,我们可以扩展valid()函数,将nums[mid] <5也考虑进去,这样就转成了前一种实现方式了;所以这种实现方式实际没意义,我们需要统一,因此在我们转换原问题的时候不能过于保守,仍然可以扩展valid()函数,只要不影响最终结果就行;下面的实现方式也一样。
- 将原题转成找到满足==5的最右边的数。
4.leetcode 69.
此题,乍一看一个数的平方跟是实数,但是,我们看答案都是返回整数,因此也是整数域上的二分。
//1.可以将本题转成,在0-x范围内,找到一个数a,a*a<=x的最右边的a,也即符合条件的最大的a。
//2.所以valid()就是找到所有a*a<=x的a。
//因为找到最右边的a,因此使用(]。
//代码
Boolean valid(int a,int x){
return (long)a*a<=x;
}
public int mySqrt(int x) {
int l = 0;
int r = x;
while(l<r){
int mid = r-((r-l)>>1);//防止溢出
if(valid(mid,x)) l = mid;
else r = mid -1;
}
return l;
}
4.非有序域上的二分实现
对于这种不是再有序域上的查找,一般满足最后一种使用情况的二分。这种题可能只是因为可以通过排除一定不存在解的序列,将大问题转成小问题。
例子:
1.给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的中位数 。
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2