技术文 | Billy陪你聊二分

欢迎关注公众号:GTAlgorithm,看完整版技术文!

引入

借着二分答案的题目LeetCode 202场周赛,正好来跟大家聊聊二分系列~

最经典的二分问题就是猜数问题:一个人选定一个1到100的数,第二个人有多次机会,每次猜一个数,第一个人会告诉第二个人猜的数比选定的数大还是小,然后第二个人继续猜,直到猜到答案。

在这个问题里,当然每次猜可选区域里的中间元素即可,每次如果猜的数小了,就把区域上界缩小到猜的数;如果猜的数大了,就把区域下界提高到猜的数。简单代码如下:

void guess(int x) {
	int left = 1, right = 100;
	while(left <= right) {
		int mid = left + (right - left) / 2;
		if(mid == x) {
			return mid;
		} else if(mid > x) {
			right = mid - 1;
		} else {
			left = mid + 1;
		}
	}
    return -1;
}


初始化时,要保证上下界本身都在考虑范围内。对于本题,要在1到100之间的数中猜数,所以初始化区间定为 [ 1 , 100 ] [1, 100] [1,100]

由于维持循环的条件为 l e f t < = r i g h t left <= right left<=right,所以退出循环时的查找范围为 [ r i g h t + 1 , r i g h t ] [right + 1, right] [right+1,right] [ l e f t − 1 , l e f t ] [left - 1, left] [left1,left] (后面会讲到),也即退出循环的时候区间内都已经搜索过了,具体最后返回-1还是其他值,要根据具体题目进行判断。

代码框架里,在求 m i d mid mid 时之所以用 l e f t + ( r i g h t − l e f t ) / 2 left + (right - left) / 2 left+(rightleft)/2 而非 ( l e f t + r i g h t ) / 2 (left + right) / 2 (left+right)/2 是为了防止溢出。

更新上下界时,由于 m i d mid mid 对应的元素已经被检查过,所以新区间不需包含 m i d mid mid

最后若退出循环还没有返回值说明并未找到答案,返回-1即可(虽然猜数问题中不会出现这种情况)。



例题1:搜索插入位置(LeetCode 35)

在这里插入图片描述

这是一道模板题,数组已经排好序,且元素没有重复,直接套用模板即可,唯一需要改动的部分是最后的返回值。由于题目要求如果未找到目标值,返回其按序插入的位置,所以我们需要对二分的过程进行分析。

上面说过了,退出循环时的查找范围为 [ r i g h t + 1 , r i g h t ] [right + 1, right] [right+1,right] [ l e f t , l e f t − 1 ] [left, left - 1] [left,left1] ,所以在此之前的一步,肯定有 l e f t = r i g h t left = right left=right l e f t + 1 = r i g h t left + 1 = right left+1=right ,计算得到 m i d = l e f t mid = left mid=left,然后未能找到该元素而退出了循环(否则若 r i g h t > l e f t + 1 right > left + 1 right>left+1,计算出的 m i d mid mid 一定处于 [ l e f t + 1 , r i g h t − 1 ] [left + 1, right - 1] [left+1,right1]之间,仍可以继续循环)。具体是哪种情况根据上一步的判定原因不同而决定。我们进行分类讨论:

(1)根据模板,若 a [ m i d ] > t a r g e t a[mid] > target a[mid]>target ,需要降低上界,根据 r i g h t = m i d − 1 right = mid - 1 right=mid1得到了新查找范围,即 [ l e f t , l e f t − 1 ] [left, left - 1] [left,left1] ,说明这种情况中,有 m i d = l e f t mid = left mid=left

同时,在上一次循环过程中, l e f t − 1 left - 1 left1 位置的元素一定已经检查过,且有 a [ l e f t − 1 ] < t a r g e t a[left - 1] < target a[left1]<target(这样才会根据 l e f t = m i d + 1 left = mid + 1 left=mid+1 得到新的下界 l e f t left left),而本次循环得到了 a [ l e f t ] = a [ m i d ] > t a r g e t a[left] = a[mid] > target a[left]=a[mid]>target,所以要插入的元素就应该在 l e f t left left 位置,退出循环后返回 l e f t left left 位置元素即可;

(2)同理,若 a [ m i d ] < t a r g e t a[mid] < target a[mid]<target ,需要提高下界,根据 l e f t = m i d + 1 left = mid + 1 left=mid+1 得到新的查找范围为 [ r i g h t + 1 , r i g h t ] [right + 1, right] [right+1,right] ,说明这种情况中有 m i d = r i g h t mid = right mid=right

在上一次循环中, r i g h t + 1 right + 1 right+1 位置的元素肯定已经在上一轮循环中判断过小于 t a r g e t target target (这样才会根据 r i g h t = m i d − 1 right = mid - 1 right=mid1 得到新的下界 r i g h t right right),而本轮又判断出 a [ r i g h t ] = a [ m i d ] < t a r g e t a[right] = a[mid] < target a[right]=a[mid]<target,所以插入位置应该在 r i g h t + 1 right + 1 right+1

对于本题,最后返回 l e f t left left r i g h t + 1 right + 1 right+1 都是正确的,选择其中一种即可(为简化代码,左右指针用 l l l r r r 表示):

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        if(target < nums[0]) {
            return 0;
        } else if(target > nums[nums.size() - 1]) {
            return nums.size();
        }
        int l = 0, r = nums.size() - 1;
        while(l <= r) {
            int mid = (l + r) / 2;
            if(nums[mid] == target) {
                return mid;
            } else if(nums[mid] > target) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        return l;
    }
};



例题2: x x x的平方根(LeetCode 69)

在这里插入图片描述

这也是一道模板题,跟例题1基本上完全相同,为啥还要放一道呢?是为了帮大家把如何判断退出循环后的返回值再巩固一下。这里,我们直接进入分类讨论:

题目要求保留平方根的整数部分,我们假设平方根可表示为 x = a + b \sqrt{x} = a + b x =a+b,其中 a a a 为整数部分,那么必有 a 2 < x a^2 < x a2<x

根据模板,若 m i d > x mid > \sqrt{x} mid>x ,需要降低上界,此时得到的新查找范围为 [ l e f t , l e f t − 1 ] [left, left - 1] [left,left1] ,且此时 l e f t − 1 left - 1 left1 位置的元素一定已经检查过,其平方小于 x x x,正是我们所需要的整数部分,所以退出循环后应返回 l e f t − 1 left - 1 left1 位置对应的元素;

而若 m i d < x mid < \sqrt{x} mid<x ,需要提高下界,此时得到的新查找范围为 [ r i g h t + 1 , r i g h t ] [right + 1, right] [right+1,right] ,由于 r i g h t right right 位置的元素已经判断过,其平方小于 x x x ,所以退出循环时返回 r i g h t right right 也是正确的:

class Solution {
public:
    int mySqrt(int x) {
        if(x == 1 || x == 2) {  //懒人行为:不想倒腾细节的时候就简单枚举一下
            return 1;
        }
        int l = 0, r = x / 2;
        while(l <= r) {
            double mid = (l + r) / 2;
            if(mid * mid == double(x)) {
                return mid;
            } else if(mid * mid < double(x)) {
                l = mid + 1;
            } else {
                r = mid - 1;
            }
        }
        return l - 1;
    }
};


欢迎关注公众号:Grand Theft Algorithm,看完整版技术文!

例题3:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)

在这里插入图片描述

这道题是例题1的扩展,也即排序数组中出现了重复元素,要找到第一次或最后一次出现的位置。

与模板不同的是,找到元素后不会离开返回,要继续向左(找第一次出现位置)或向右(找最后一次出现位置)寻找,且退出循环后的返回值不同。我们以寻找第一次出现的位置为例,若当前二分搜索找到的元素值 a [ m i d ] > t a r g e t a[mid] > target a[mid]>target,说明要找的元素在 m i d mid mid 左边,若 a [ m i d ] = = t a r g e t a[mid] == target a[mid]==target,虽然找到了元素,但不确定是否为第一次出现,也要继续往左找,所以循环内的范围调整过程就变成了如下框架:

while(l <= r) {
    
    
    int mid = (l + r) / 2;
    if(a[mid] >= target) {
    
    
        r = mid - 1;
    } else {
    
    
        l = mid + 1;
    }
}

退出循环后,具体的返回值要进行类似于之前例题的分类讨论:若最后一轮循环有 a [ m i d ] > = t a r g e t a[mid] >= target a[mid]>=target,则此时 r i g h t = m i d − 1 right = mid - 1 right=mid1,且 a [ r i g h t ] < t a r g e t a[right] < target a[right]<target,此时 l e f t left left 保持在 m i d mid mid 的位置,所以 l e f t left left 是可能的 t a r g e t target target 第一次出现的位置;若最后一轮循环有 a [ m i d ] < t a r g e t a[mid] < target a[mid]<target,则 l e f t = m i d + 1 left = mid + 1 left=mid+1,且 a [ l e f t ] > = t a r g e t a[left] >= target a[left]>=target,同样 l e f t left left 是可能的 t a r g e t target target 第一次出现的位置。这里我们发现,与之前两道例题不同的是,退出循环时 l e f t left left 是唯一可能的位置,这是因为循环内范围调整的判定条件不同而导致的。

最后还需判断 l e f t left left 的合法性:是否越界,若未越界,该位置元素值是否确保等于 t a r g e t target target,若相等,则 l e f t left left 就是 t a r g e t target target 第一次出现的位置,否则返回 − 1 -1 1

同理,对于寻找最后一个位置的过程,循环内的范围调整判定条件应为:

while(l <= r) {
    int mid = (l + r) / 2;
    if(a[mid] > target) {
        r = mid - 1;
    } else {
        l = mid + 1;
    }
}

这里把 a [ m i d ] = t a r g e t a[mid] = target a[mid]=target 的情况归到了下面的 e l s e else else 分支中,因为即便找到当前元素,为了判断其是否是最后一次出现,也需要提高范围下界,继续向右寻找。同时,退出循环后的唯一可能位置变为了 r i g h t right right (自己写一写是为什么),再去判断 r i g h t right right 的合法性即可。

至此,修改原有模板,用了两种二分框架就完成了这道题:

class Solution {
public:
    int bs_left(vector<int>& a, int target) {
        int l = 0, r = a.size() - 1;
        while(l <= r) {
            int mid = l + (r - l) / 2;
            if(a[mid] >= target) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if(l >= a.size() || a[l] != target) {
            return -1;
        }
        return l;
    }

    int bs_right(vector<int>& a, int target) {
        int l = 0, r = a.size() - 1;
        while(l <= r) {
            int mid = l + (r - l) / 2;
            if(a[mid] > target) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if(r < 0 || a[r] != target) {
            return -1;
        }
        return r;
    }
    
    vector<int> searchRange(vector<int>& nums, int target) {
        int l = bs_left(nums, target);
        int r = bs_right(nums, target);
        return vector<int>{l, r};
    }
};


二分答案

上面提到过的例题中, i f − e l s e if-else ifelse 的判定条件都是简单判断,而在有的题目中,根据不同的题意要对每一轮循环求出的值进行结果判定,判断是否满足要求的条件,再根据题意进行搜索范围的更新。这就是二分答案,简单来说,相较于上面提出的二分模板,解决二分答案问题要增加一个判定函数。下面我们通过两道题来看一看这个判定函数长啥样。



欢迎关注公众号:Grand Theft Algorithm,看完整版技术文!

例题4:在 D 天内送达包裹的能力(LeetCode 1011)在这里插入图片描述

在这里插入图片描述

根据题目,要求出最低运载能力。对于一艘船,我们必然会在不超过其承载力的前提下贪心地装载货物,才能使得总时间最短。我们假设承载力为 K K K 时能够在 D D D 天内完成任务,那么任何承载力大于 K K K 的船都必然能完成任务。显然,最低的运载能力必须要大于等于最大货物的重量,否则不可能完成任务,所以我们从最低的运载能力开始逐渐增大,直到找到满足条件的承载力,即是要求的答案。但是线性增大效率过低,所以可以用二分答案的方法。

首先找到初始化的搜索范围边界,上面的分析中已经说过,下界不低于最大货物的重量,而上界其实可以设定为全部货物的总重量,这样一天就可以完成任务。

其次我们要考虑循环内的判定过程,我们定义判定函数 $bool\ check(int\ x) $ 表示承载力为 x x x 时是否能完成任务,那么在实现时我们的判定条件就是 c h e c k ( m i d ) check(mid) check(mid) ,如果结果为 T r u e True True 说明可以完成任务,由于我们要找到最小的承载力,所以要降低上界继续搜索;如果结果为 F a l s e False False 说明当前承载力无法完成任务,所以要提高下界继续搜索。

二分答案的问题重点就在于判定函数的实现,对于本题,要求按数组顺序装载包裹,所以我们用贪心法直接计算当前承载力需要几天完成任务即可。遍历给定数组 w e i g h t s weights weights ,若加上当前遍历到的包裹重量超出了承载力,说明今天无法运这个包裹,天数加1;否则今天可以运载这个包裹,继续向后遍历。数组遍历完成后,判断所需天数与要求天数 D D D 的大小关系即可:

bool check(int x, vector<int>& w, int D) {
    int tmp = 0, days = 1;	// 初始化当前重量tmp,当前天数为1天
    for(auto wt : w) {
        if(tmp + wt > x) {	// 超过当前承载力,说明需要第二天运送,tmp初始化为wt
            days++;
            tmp = wt;
        } else {
            tmp += wt;	// 未超过,更新当前重量
        }
    }
    return days <= D;
}

最后,需要判断退出循环后的返回值。类似于前面几道例题的判断,最后一次若判定成功后需降低上界,所以下界 l l l 位置保持不变,对应的是能够完成任务的承载力;最后一次若判定失败后需提高下界, l l l 对应的新位置为之前判断过的能够完成任务的承载力。所以最后返回 l l l 对应的承载力即可:

class Solution {
public:
    bool check(int x, vector<int>& w, int D) {
        int tmp = 0, days = 1;
        for(auto wt : w) {
            if(tmp + wt > x) {
                days++;
                tmp = wt;
            } else {
                tmp += wt;
            }
        }
        return days <= D;
    }
    
    int shipWithinDays(vector<int>& weights, int D) {
        int mx = -1, sum = 0;
        for(auto wt : weights) {
            mx = max(mx, wt);
            sum += wt;
        }
        int l = mx, r = sum;
        while(l <= r) {
            int mid = l + (r - l) / 2;
            if(check(mid, weights, D)) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        return l;
    }
};



例题5:找出第k小的距离对(LeetCode 719)

在这里插入图片描述

找第k小的距离对,也可以用二分答案的方法解决。

首先找到初始化的搜索范围边界,下界定义为数组中数对距离中的最小值,上界定义为数对距离中的最大值。

其次我们要考虑判定函数 $bool\ check(int\ x) $ 在这道题中的定义,由于要找第k小的距离对,所以我们将判定函数表示为:**是否有至少 k k k 个数对的距离小于等于 x x x .**如果结果为 T r u e True True ,说明当前距离 x x x 是可能的答案,此时要找到确切的第 k k k 小的距离,所以要降低上界继续搜索;如果结果为 F a l s e False False 说明当前距离比要求的答案要小,所以要提高下界继续搜索。

这样,判定函数的实现只要遍历数组,求小于等于给定距离 x x x 的数对个数即可,由于数组有序,用双指针法遍历可以在线性时间内得出结果,最后判断得到的数对个数是否大于等于 k k k 即可:

bool check(int x, vector<int> a, int k) {
    int res = 0;
    int index = 1;  // 记录上一次遍历到的位置
    for(int i = 0; i < a.size(); i++) {
        int j = index;
        while(j < a.size() && a[j] <= a[i] + x) {
            j++;
        }
        res += j - i - 1;
        index = j;
    }
    return res >= k;
}

最后,需要判断退出循环后的返回值。与例4相同,最后返回 l l l 即可:

class Solution {
public:
    bool check(int x, vector<int> a, int k) {
        //printf("mid = %d\n", x);
        int res = 0;
        int index = 1;  // 记录上一次遍历到的位置
        for(int i = 0; i < a.size(); i++) {
            int j = index;
            while(j < a.size() && a[j] <= a[i] + x) {
                j++;
            }
            res += j - i - 1;
            index = j;
        }
        return res >= k;
    }
    
    int smallestDistancePair(vector<int>& nums, int k) {
        sort(nums.begin(), nums.end());
        int l = 0, r = nums[nums.size() - 1] - nums[0];
        while(l <= r) {
            int mid = l + (r - l) / 2;
            if(check(mid, nums, k)) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        return l;
    }
};

综上所述,二分的基本内容就都介绍完了,我们再来简单回顾一下:

1、二分搜索的前提是在有序范围内查找,第一步要确定搜索范围;

2、第二步要给出判定函数的定义,并根据最左、最右、第 k k k 个等题意要求更新搜索区域上下界;

3、第三步要给出判定函数的实现,简单二分问题的 i f ( a [ m i d ] = = t a r g e t ) if(a[mid] == target) if(a[mid]==target) 也可以看作简单的判定函数;

4、最后要根据不同情况确定退出循环后的返回值。

欢迎关注公众号:Grand Theft Algorithm,看完整版技术文!

猜你喜欢

转载自blog.csdn.net/weixin_42396397/article/details/108095151