欢迎关注公众号: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] [left−1,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+(right−left)/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,left−1] ,所以在此之前的一步,肯定有 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,right−1]之间,仍可以继续循环)。具体是哪种情况根据上一步的判定原因不同而决定。我们进行分类讨论:
(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=mid−1得到了新查找范围,即 [ l e f t , l e f t − 1 ] [left, left - 1] [left,left−1] ,说明这种情况中,有 m i d = l e f t mid = left mid=left。
同时,在上一次循环过程中, l e f t − 1 left - 1 left−1 位置的元素一定已经检查过,且有 a [ l e f t − 1 ] < t a r g e t a[left - 1] < target a[left−1]<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=mid−1 得到新的下界 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,left−1] ,且此时 l e f t − 1 left - 1 left−1 位置的元素一定已经检查过,其平方小于 x x x,正是我们所需要的整数部分,所以退出循环后应返回 l e f t − 1 left - 1 left−1 位置对应的元素;
而若 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=mid−1,且 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 if−else 的判定条件都是简单判断,而在有的题目中,根据不同的题意要对每一轮循环求出的值进行结果判定,判断是否满足要求的条件,再根据题意进行搜索范围的更新。这就是二分答案,简单来说,相较于上面提出的二分模板,解决二分答案问题要增加一个判定函数。下面我们通过两道题来看一看这个判定函数长啥样。
欢迎关注公众号: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、最后要根据不同情况确定退出循环后的返回值。