二分法写法-开闭?+1-1?

二分法思路很简单,就是通过比较中间点来缩小区间嘛,可我一直对其有些畏惧,无它,总是写不对。要说直接背模版吧,实在是不甘心。今天找时间终于又梳理了一遍。

分析

以一个最为常用的问题和做法为例:

//问题:找到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 + 1right = 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元素是属于左侧还是右侧,以及区间应该如何变化。判断左侧还是右侧的方式:

  1. 比较nums[mid]和nums[left]
  2. 比较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位置

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值