二分法思路很简单,就是通过比较中间点来缩小区间嘛,可我一直对其有些畏惧,无它,总是写不对。要说直接背模版吧,实在是不甘心。今天找时间终于又梳理了一遍。
分析
以一个最为常用的问题和做法为例:
//问题:找到nums中第一个>=target的位置
int left = 0, right = nums.size() - 1;
while(left <= right){
int mid = left + (right - left)/2;
if(nums[mid] < target){
left = mid + 1;//避免死循环
}else{
right = mid - 1;//避免死循环
}
}
//答案:left
可以将判断条件抽象,把数组分为两部分:
符合条件的项 | 不符合条件项
函数check
:当符合条件时返回true,不符合条件返回false
//问题:找到nums中第一个不符合条件的位置
int left = 0, right = nums.size() - 1;
while(left <= right){
int mid = left + (right - left)/2;
if(check(nums,target)){
left = mid + 1;//避免死循环
}else{
right = mid - 1;//避免死循环
}
}
//答案:left
我认为二分法最为关键的是二分目的,比如上述的“找到第一个不符合条件的位置”。
我建议只记这一种模型。首先,“第一个不符合条件的位置”意味着这是右半部分的边界,那边左边一项便是“最后一个符合条件的位置”。其他问题也可以规约为这个问题。比如找到==target
的位置,那么就可以找到第一个不符合“<target
”的位置。二分法细节太多了,太容易记错。写二分的时候只要能够确认这个“条件”是什么,就可以套用这个模型了。
上述写法是左闭右闭区间,left直到最后一步之前,指向的项一直是符合条件的,right同理一直指向的都是符合条件的。但最后一步,left = mid + 1
和right = mid - 1
,这造成了left将越过边界到右侧,指向右侧第一项,right将越过边界到左侧,指向左侧最后一项。
然而mid+1和mid-1是必不可少的。这里分析一下:循环退出条件应当是区间内元素为空,因为是左闭右闭区间,所以只要left<=right,区间就还不是空。所以left==right
是有可能的,这时mid==left==right
,所以mid一定要+1和-1。同理,其他形式(左开右闭,左闭右开,左开右开)都可以这样分析
四种形式
这里以leetcode704为例:给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
int search(vector<int>& nums, int target) {
//左闭右闭
int left = 0, right = nums.size() - 1;
while(left <= right){
int mid = left + (right - left)/2;
if(nums[mid] < target){
left = mid + 1;//避免死循环
}else{
right = mid - 1;//避免死循环
}
}
//right因为mid-1会越过边界线到符合条件的最后一个
//left因为mid+1会越过边界线到不符合条件的第一个
if(left < nums.size() && nums[left] == target){
return left;
}
return -1;
}
int search_l(vector<int>& nums, int target) {
//左开右闭
int left = -1, right = nums.size() - 1;
while(left < right){
int mid = left + (right - left + 1)/2;//避免mid越界
if(nums[mid] < target){
left = mid ;
}else{
right = mid-1;//避免死循环
}
}
//right因为mid-1会越过边界线到符合条件的最后一个,与left相等
right++;
if(right < nums.size() && nums[right] == target){
return right;
}
return -1;
}
int search_r(vector<int>& nums, int target) {
//左闭右开
int left = 0, right = nums.size();
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] < target){
left = mid + 1;//避免死循环
}else{
right = mid;
}
}
//left因为mid+1会越过边界线到不符合条件的第一个,与right相等
if(right < nums.size() && nums[right] == target){
return right;
}
return -1;
}
int search_l_r(vector<int>& nums, int target) {
//双开区间
int left = -1, right = nums.size();
while(left + 1 < right){
int mid = left + (right - left)/2;
if(nums[mid] < target){
left = mid;
}else{
right = mid;
}
}
//left为符合条件最后一个,right为不符合条件第一个
if(right < nums.size() && nums[right] == target){
return right;
}
return -1;
}
可以看到,循环退出条件本质上都是“区间内无元素”
关于mid:
取中间值之所以不用(left+right)/2
是因为避免left+right整数溢出(虽然大多数情况下不会出现)
在左开右闭区间写法中,注意到mid的取值为left + (right - left + 1)/2;
,这是为了取上边界。因为是左开右闭,循环中可能出现left+1 == right,如果取下边界则会出现mid=left,而left并不在区间中。
总结
以上四种写法中,最常用的还是左闭右闭,左闭右开。
左闭右闭是所有值都在nums中,不会越界,而且我最先接触的就是这种。
左闭右开使用的人最多。这种方式适用性最广,比如迭代器就是左闭右开。。。
左开右开是循环退出时,left即为左侧最后一个,right即为右侧第一个,实在优雅。
变形
(1)牛客BM19 寻找峰值:对于所有有效的 i 都有 nums[i] != nums[i + 1],寻找nums中的峰值,-1和n位置视为负无穷
这里用二分的思路是判断mid和mid+1位置的大小,永远向大的位置寻找,一定能找到一个峰值。关键在于:区间左边界一定是斜率向上,右边界一定是斜率向下。
但是这里不能直接套用前面提到的写法,原因是判断左边还是右边涉及到mid附近的多个元素,这就导致循环退出条件其实并不是区间为空。当区间元素数量无法满足判断时,其实就应该退出,不然就需要特判。
按照 “区间左边界一定是斜率向上,右边界一定是斜率向下” 的思路,以闭区间写法为例,可以这样写:
int findPeakElement(vector<int>& nums) {
int n =nums.size();
int left = 0;
int right = n-1;
while(left <= right){
int mid = left + (right -left)/2;
if(mid + 1 < n && nums[mid] < nums[mid+1]){
left = mid + 1;
}else if(mid - 1 >= 0 && nums[mid] < nums[mid-1]){
right = mid - 1;
}else{
return mid;
}
}
return left;//没啥用,不会执行到这
}
这里循环退出条件其实已经变了,当mid位置大于两边的时候,可以提前退出,而区间只有一个元素的时候,其实肯定会满足mid位置大于两边的条件,正常退出。唔,结尾的return其实永远不会到达(按照题目的要求)
如果要写得更简洁一些呢?(虽然有时候会更不好懂)
int findPeakElement(vector<int>& nums) {
int n = nums.size();
int left = 0;
int right = n-1;
while(left <= right){
int mid = left + (right -left)/2;
if(mid+1<n && nums[mid] < nums[mid+1]){
left = mid + 1;
}else{
right = mid - 1;
}
}
return left;
}
分析:如果mid+1<n
始终成立,那么这不会有什么问题,if成立则left位置作为左边界是合适的,if不成立说明mid元素比右边大,则至少mid+1作为右边界是合适的。如果mid-1作为右边界不合适(mid已经是峰值了,且是剩余区间中唯一的峰值),那么由于左边界始终斜率向上,区间内是单调递增的,最终left会越过mid-1到mid的位置。
而如果mid+1<n
不成立,则说明left==right==n-1
,如果n-1是唯一峰值,那么前面一定是单调递增,最终left也会越过n-2到n-1的位置。
如果初始区间为
[0,n-2]
,那么mid+1<n
的判断也可以省去,原因同样:如果n-1是唯一峰值,那么前面一定是单调递增,最终left也会越过n-2到n-1的位置(参考leetcode灵茶山艾府寻找峰值题解)
(2)牛客BM21,旋转数组的最小数字:有一个长度为 n 的非降序数组,比如[1,2,3,4,5],将它进行旋转,即把一个数组最开始的若干个元素搬到数组的末尾,变成一个旋转数组,比如变成了[3,4,5,1,2],或者[4,5,1,2,3]这样的。请问,给定这样一个旋转数组,求数组中的最小值。
这里使用二分的重点在于如何判断mid元素是属于左侧还是右侧,以及区间应该如何变化。判断左侧还是右侧的方式:
- 比较nums[mid]和nums[left]
- 比较nums[mid] 和nums[right]
也可以比较nums[mid]和nums[0]或者nums[n-1],但是相较于比较nums[left]和nums[right]还是有点不好的,因为我们总是希望更准确地判断,尽量减少相等的情况。
以2为例,我们可以分析这个比较的特点,如果大于,则说明mid处于左侧,目标元素肯定在mid右边;如果小于,则说明mid处于右侧,目标元素肯定处于mid左边或者就是mid;如果等于,则无法判断,可以缩小右边界进行试探。这个比较有个前提,right一定是一直在右侧的。
因为要确保比较有意义,那么右边界收缩就不能为mid-1,只能是mid,所以。。。。不应该使用闭区间写法,至少右边应该是开区间(这个问题由于多了第三个分支,闭区间写法也是可行的,只不过最后一步是right减一,left指向位置是答案)。而结果呢,right一定是右侧第一个元素。所以左闭右开区间写法:
int minNumberInRotateArray(vector<int>& nums) {
// write code here
int n = nums.size();
int left,right;
left=0,right=n-1;//注意这里right = n-1;
while(left<right){
int mid = left + (right-left)/2;
if(nums[mid] > nums[right]){
left = mid+1;
}else if(nums[mid] < nums[right]){
right = mid;
}else{
right--;
}
}
return nums[right];
}
right = n-1;
这是个重要技巧。保证了nums[right]一定有意义。同时由于这类问题不会有不存在的情况出现,初始化为n-1可以理解为先假定答案在n-1位置