在上一篇浅谈二分思想中,我们谈到了提过二分的本质,其实就是不断折半,折到最后折无可折的那个结果就是最符合要求的结果。
现在我们从答案出发,对答案的整体可能范围不断二分,最后找到最合适的答案。我们称这种方法为二分答案法。
二分答案法:
- 估计 最终答案可能的范围 是什么
- 分析 问题的答案 和 给定条件 之间的单调性 ,这种分析往往只需要简单分析。
- 建立一个 f f f函数,当答案固定的情况下,判断给定的条件是否达标。
- 在 最终答案可能的范围上不断二分搜索,每次用 f f f函数判断, 直到二分结束,找到最合适的答案
核心点:分析单调性、建立 f f f函数
P
r
o
b
l
e
m
1
Problem1
Problem1 爱吃香蕉的珂珂 LeetCode 875
珂珂喜欢吃香蕉。这里有 n
堆香蕉,第 i
堆中有 piles[i]
根香蕉。警卫已经离开了,将在 h
小时后回来。
珂珂可以决定她吃香蕉的速度 k
(单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 k
根。如果这堆香蕉少于 k
根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在 h
小时内吃掉所有香蕉的最小速度 k
(k
为整数)。
示例 1:
输入:piles = [3,6,7,11], h = 8
输出:4
示例 2:
输入:piles = [30,11,23,4,20], h = 5
输出:30
示例 3:
输入:piles = [30,11,23,4,20], h = 6
输出:23
提示:
1 <= piles.length <= 104
piles.length <= h <= 109
1 <= piles[i] <= 109
问题分析:
我们先贪心地想,既然吃香蕉的速度为 k k k,那为了在有限时间内吃掉更多堆香蕉,我们应该要优先吃掉 香蕉数量不大于 k k k 的堆,对于 香蕉数量大于 k k k 的堆,我们也要优先吃掉 香蕉数量较少 的堆。所以我们要先对 p i l e s [ i ] piles[i] piles[i]进行升序排序,然后从左往右 一个堆一个堆地 去吃。
现在吃香蕉堆的顺序确定下来了,但是如果吃的速度太慢( k k k太小),就算选择了最优顺序也不能在 h h h小时内吃掉所有香蕉。所以我们要增大吃香蕉的速度 k k k,很容易发现, k k k越小,越可能吃不完, k k k越大,在 h h h小时内越可能吃完所有香蕉。
按照刚刚的分析,我们知道能否在 h h h小时内吃完所有香蕉相对于**吃香蕉的速度( k k k)**是单调的,草图如下:
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/3b96977d6bfa4d4cb07e32116439d1c9.jpeg#pic_center)
所以我们可以可能利用二分来寻找最小的 k k k。
好了,现在我们就来详细讨论一下是怎么个二分法,二分法的代码应该怎样去写。
因为吃香蕉的速度肯定不小于 0 0 0,所以初始左边界 l e f t = 0 left = 0 left=0,那右边界呢?按道理我们选一个非常大的数作为右边界就可以,但是这样会使得我们二分的次数增多,浪费时间,所以我们应该找到符合题目情况的合理右边界。
我们在最开始的贪心分析中,不是对 p i l e s [ i ] piles[i] piles[i]排了序嘛,如果 p i l e s [ i ] piles[i] piles[i]的最大值 p i l e s [ N − 1 ] piles[N-1] piles[N−1]都比 k k k小,那就说明珂珂每吃一次就可以吃掉一堆,毫无疑问地符合条件,所以我们就把右边界 r i g h t right right取值为 p i l e s [ N − 1 ] piles[N-1] piles[N−1]。
现在轮到 m i d mid mid了, m i d = ( l e f t + r i g h t ) / 2 mid = (left + right) / 2 mid=(left+right)/2,当 m i d mid mid满足条件:吃香蕉速度为 m i d mid mid时,珂珂能在 h h h小时内吃完所有香蕉,我们就把右边界 r i g h t right right赋值为 m i d mid mid。当 m i d mid mid不满足条件时,我们就把左边界 l e f t left left赋值为 m i d + 1 mid + 1 mid+1。
当区间被二分缩小到一位数或者左边界 l e f t left left大于右边界 r i g h t right right时,我们取出 r i g h t right right作为最终值。
以上就是二分部分的讨论,那现在就剩下 k k k是否满足条件 的问题上来了。
其实也很简单,从左往右一个堆一个堆地吃,统计每个堆需要吃的次数再求和就可以了。
解决代码:
bool check_Pro1(int lamda, vector<int>& new_piles, int h) {
if (lamda == 0)
return false;
long sum = 0;
for (int i = 0; i < new_piles.size(); i++) {
sum += (new_piles[i] / lamda) + (new_piles[i] % lamda == 0 ? 0 : 1);
}
if (sum > h)
return false;
return true;
}
int solution_Pro1(vector<int> piles, int h) {
sort(piles.begin(), piles.end());
int left = 0;
int right = piles[piles.size() - 1];
while (left < right) {
int mid = (left + right) / 2;
check_Pro1(mid, piles, h) ? right = mid : left = mid + 1;
}
return right;
}
P
r
o
b
l
e
m
2
Problem2
Problem2 分割数组的最大值 LeetCode 410
给定一个非负整数数组 nums
和一个整数 k
,你需要将这个数组分成 k
个非空的连续子数组。
设计一个算法使得这 k
个子数组各自和的最大值最小。
示例 1:
输入:nums = [7,2,5,10,8], k = 2
输出:18
解释:
一共有四种方法将 nums 分割为 2 个子数组。
其中最好的方式是将其分为 [7,2,5] 和 [10,8] 。
因为此时这两个子数组各自的和的最大值为18,在所有情况中最小。
示例 2:
输入:nums = [1,2,3,4,5], k = 2
输出:9
示例 3:
输入:nums = [1,4,4], k = 3
输出:4
提示:
1 <= nums.length <= 1000
0 <= nums[i] <= 106
1 <= k <= min(50, nums.length)
问题分析:
这显然是一个最小化最大值问题,基本上也要用到二分思想,这个二分基本上是对 需要最小化的变量 进行二分。
我们记某种划分得到的
k
k
k个子数组各自和的最大值为
s
c
o
r
e
score
score,用数学公式来表示,其实就是:
m
a
x
(
每部分的和
s
u
m
)
max(每部分的和sum)
max(每部分的和sum)
题目要求返回的是
s
c
o
r
e
score
score的最小值,所以我们要使
m
a
x
(
每部分的和
s
u
m
)
max(每部分的和sum)
max(每部分的和sum)尽可能的小。现在我们来分析一下
s
c
o
r
e
score
score的可能取值范围吧,也就是分析范围区间的左边界
l
e
f
t
left
left与右边界
r
i
g
h
t
right
right。【一般在分析边界的时候,都往极端情况去分析,极端情况更可能出现最值嘛】
数组nums
是非负整数数组,所以每部分的和
s
u
m
sum
sum至少由一个数构成,而
s
c
o
r
e
score
score又是各自和的最大值,所以
s
c
o
r
e
score
score的值不可能小于
n
u
m
s
nums
nums中的最大值,我们记为
m
a
x
max
max,那么区间的左边界
l
e
f
t
left
left我们可以定为
m
a
x
max
max,那区间的右边界呢?区间的右边界自然是每部分和的最大可能取值,我们知道,非负整数数组的某个子数组累加和一定不会大于整体累加和,我们把数组nums
的整体累加和记为
A
A
A,则区间的右边界应该定为
A
A
A。
分析到这里,我们知道了 s c o r e score score的可能取值范围,现在就需要寻找 s c o r e score score与哪种问题条件之间具有单调性了,根据单调性我们才可以进行二分寻找最合适的答案。
我们从 s c o r e score score入手,一般而言, s c o r e score score越大,数组和为 s c o r e score score的那个区间中的数字个数会越多一点,其他区间中的数字个数也可以略微增加一些,只要保证区间数组和不大于 s c o r e score score。每个区间的数字个数多了,但是总数组的数字也就只有N个,这就说明区间的个数少了嘛。所以我们可以得出:
【结论2-1】 子数组各自和的最大值 s c o r e score score越大,其对应的至少区间个数越小。
相应地,我们提出如下问题:
【问题2-1】给定一个非负整数数组nums
,要想将其分裂成一个个连续的子数组,并且保证这些连续子数组的各自和的最大值为
s
c
o
r
e
score
score,请问至少需要划分成几个子数组?
如果 s c o r e score score的值 A 1 A_1 A1时对应的子数组个数为 a a a, s c o r e score score的值为 A 2 A_2 A2时对应的子数组个数为 b b b,根据【结论1】,可以得出 b < = a b <= a b<=a。
回到二分查找条件上来,如果
s
c
o
r
e
=
m
i
d
score = mid
score=mid时,其对应的【问题2-1】的解小于k,说明我们能给出这么一种划分,将整个数组nums
份成
k
k
k个子数组并且子数组各自和的最大值为
s
c
o
r
e
score
score。那说明这个
m
i
d
mid
mid取值是满足题目条件的,所以我们缩小区间
r
i
g
h
t
=
m
i
d
right = mid
right=mid。如果
m
i
d
mid
mid不满足条件(【问题2-1】的解大于
k
k
k),那我们就要选择
s
c
o
r
e
score
score取值较大的那一片区间,
l
e
f
t
=
m
i
d
+
1
left = mid +1
left=mid+1。
至此,二分的初始范围和二分查找条件都已经分析完毕。
现在只剩下【问题2-1】的解决方案,这个解决方案其实也比较简单,需要用到贪心思想。
我们贪心地想,一个子区间的区间累加和越大,这个子区间的长度一般来说会越大,对整个数组nums
来说,如果每个子区间的区间累加和尽可能地逼近
s
c
o
r
e
score
score(但小于
s
c
o
r
e
score
score),那整个数组被划分之后的子区间数量会尽可能地少。
直接看下面这段解决代码你就明白了:
解决代码:
int check_Pro2(vector<int>& nums, int score) {
int ans = 1;
int subSum = 0; //记录子区间的累加和
for (int i = 0;i < nums.size();i++) {
subSum += nums[i];
if (subSum > score) {
ans++;
subSum = nums[i];
}
}
return ans;
}
int solution_Pro2(vector<int>& nums, int k) {
int sum = 0;
int max = 0;
for (int num : nums) {
sum += num;
max = num > max ? num : max;
}
//主体二分框架
int left = max;
int right = sum;
while (left < right) {
int mid = (left + right) / 2;
if (check_Pro2(nums, mid) > k) {
left = mid + 1;
}
else {
right = mid;
}
}
return right;
}
P
r
o
b
l
e
m
3
Problem3
Problem3 机器人跳跃问题 nowcoder ZJ24
机器人正在玩一个古老的基于DOS的游戏。游戏中有 N + 1 N+1 N+1座建筑——从 0 0 0到 N N N编号,从左到右排列。
编号为 0 0 0的建筑高度为0个单位,编号为 i i i的建筑的高度为 H i H_i Hi个单位。
起初, 机器人在编号为0的建筑处。每一步,它跳到下一个(右边)建筑。假设机器人在第k个建筑,且它现在的能量值是 E E E, 下一步它将跳到第 k + 1 k+1 k+1个建筑。它将会得到或者失去正比于与 H k + 1 H_{k+1} Hk+1与 E E E之差的能量。如果$ H_{k+1}> E$ 那么机器人就失去 H k + 1 − E H_{k+1} - E Hk+1−E 的能量值,否则它将得到$ E - H_{k+1}$ 的能量值。
游戏目标是到达第N个建筑,在这个过程中,能量值不能为负数个单位。现在的问题是机器人以多少能量值开始游戏,才可以保证成功完成游戏?
输入描述:
第一行输入,表示一共有 N 组数据
第二个是 N 个空格分隔的整数, H 1 , H 2 , H 3 , . . . , H n H_1, H_2, H_3, ..., H_n H1,H2,H3,...,Hn 代表建筑物的高度
输出描述:
输出一个单独的数表示完成游戏所需的最少单位的初始能量
示例:
输入:5
3 4 3 2 4
输出:4
问题分析:
问题最后要返回的是,机器人能够成功完成游戏需要的最少能量值,而成功完成游戏代表着什么呢?代表着从初始位置开始,机器人向右一步一步跳直到跳到终点第 N + 1 N+1 N+1个建筑的过程中,在中途从未出现过能量值为负的情况。
我们记机器人的初始能量值为 e n e r g y energy energy,一般而言, e n e r g y energy energy越大,每跳一步失去的能量值或者得到的能量值越大,在游戏过程中能量值耗尽的可能性就越小,即完成游戏就越容易。所以我们可以分析出 能否成功完成游戏与初始能量值 之间是具有单调性的,函数图如下:
现在我们来讨论这道题的二分框架。我们很容易想到,能量 e n e r g y energy energy不能为负,所以左边界。当 e n e r g y energy energy比这些建筑的最大高度还大时,机器人在跳跃过程中会畅通无阻,所以我们将右边界 r i g h t right right设置为 m a x max max【 m a x max max为这些建筑的最大高度】。
二分筛选也很简单,如果初始值为 m i d mid mid时不能成功完成游戏,那我们就需要选择较大的区间: l e f t = m i d + 1 left = mid + 1 left=mid+1,如果可以完成游戏,我们就选择较小的区间来逼近 e n e r g y energy energy的最小值: r i g h t = m i d right = mid right=mid。
好了,现在又只剩下一个问题:
给定初始能量值 e n e r g y energy energy,机器人能否成功完成游戏,能则返回 t r u e true true,否则返回 f a l s e false false。
这个问题我暂时想不出来有什么简单的解法,只能带着 e n e r g y energy energy一步步跳,中途如果 e n e r g y energy energy为负,则直接返回 f a l s e false false,若到达终点时 e n e r g y energy energy仍非负,则返回 t r u e true true。
解决代码:
bool check_Pro3(vector<int> Heights, int N, int energy, int max) {
for (int i = 0; i < N; i++) {
if (energy >= Heights[i]) {
energy += (energy - Heights[i]);
} else energy -= (Heights[i] - energy);
if (energy >= max) return true;
if (energy < 0) return false;
}
return true;
}
int main() {
int N;
cin >> N;
vector<int> Heights(N);
int max = 0;
for (int i = 0; i < N; i++) {
int num ;
cin >> num;
Heights[i] = num;
max = max > Heights[i] ? max : Heights[i];
}
int left = 0;
int right = max;
int answer = right;
while (left <= right) { // 使用 <= 保证区间收敛
int mid = (left + right) / 2;
if (check_Pro3(Heights, N, mid, max)) {
answer = mid; // 保存当前可行的解
right = mid - 1; // 尝试更小的能量
} else {
left = mid + 1; // 增加能量
}
}
cout << answer;
return 0;
}
【注意】 我们以上代码解释两点:
- 在
check
函数中设置if (energy >= max) return true;
是因为如果不加限制地让 e n e r g y energy energy一直这么加下去, e n e r g y energy energy很有可能会超过int
甚至long
数据类型的表示范围,所以我们要适当地进行剪枝。在某一时刻 e n e r g y energy energy比最大值 m a x max max还要大,那么之后的建筑机器人一定可以到达,所以我们提前返回true
。 - 第二点是针对于二分的,如果
m
i
d
mid
mid满足条件,我们就用
answer
去存储这个当前可行解,之后再利用right = mid - 1
与left = mid +1
来收敛二分区间。
我们再来看今天这篇文章的最后一题
P r o b l e m 4 Problem4 Problem4 找出第K小的数对距离
数对 (a,b)
由整数 a
和 b
组成,其数对距离定义为 a
和 b
的绝对差值。
给你一个整数数组 nums
和一个整数 k
,数对由 nums[i]
和 nums[j]
组成且满足 0 <= i < j < nums.length
。返回 所有数对距离中 第 k
小的数对距离。
示例 1:
输入:nums = [1,3,1], k = 1
输出:0
解释:数对和对应的距离如下:
(1,3) -> 2
(1,1) -> 0
(3,1) -> 2
距离第 1 小的数对是 (1,1) ,距离为 0 。
示例 2:
输入:nums = [1,1,1], k = 2
输出:0
示例 3:
输入:nums = [1,6,1], k = 3
输出:5
提示:
n == nums.length
2 <= n <= 104
0 <= nums[i] <= 106
1 <= k <= n * (n - 1) / 2
问题分析:
乍一看,这道题和前三道题有些许不同,前三道题都是最大化…或者最小化…问题,这道题只需要选择第 k k k小的数对距离。所以我们需要思考一下怎么对这个第 k k k小的数对距离问题进行转换,转换成可以进行二分答案的问题。
我们记数对距离为 p a i r D i s t a n c e pairDistance pairDistance,由于数对有很多种,所以 p a i r D i s t a n c e pairDistance pairDistance也有很多取值,出现重复取值的情况也比较常见。对于二分答案来说,不管取值的情况是连续的还是零散的,我们只想要知道 p a i r D i s t a n c e pairDistance pairDistance取值的左边界和右边界。
左边界显然易见,因为数对距离是两个数 a , b a,b a,b之间的差值的绝对值,所以左边界 l e f t = 0 left = 0 left=0,右边界也很简单,数组的最大值减去最小值就是差值的最大值,也就是所有数对距离中的最大值嘛,如果我们对整个数组从小到大进行排序的话,右边界 r i g h t = n u m s [ N − 1 ] − n u m s [ 0 ] right = nums[N-1]- nums[0] right=nums[N−1]−nums[0]。
现在我们需要思考一下 p a i r D i s t a n c e pairDistance pairDistance跟什么东西放一起讨论具有函数单调性呢,这个东西最好还要和题目所给条件有关。
回想一下,我们还从未讨论过第 k k k小这个条件,如果这个数对距离【我们记为 p a i r D i s t a n c e k pairDistance_k pairDistancek】是第k小的,那么在它之间一定有第1小【 p a i r D i s t a n c e 1 pairDistance1 pairDistance1】、… 、 第 k − 1 k-1 k−1小【 p a i r D i s t a n c e k − 1 pairDistance_{k-1} pairDistancek−1】的数对距离。根据这个我们可以知道, p a i r D i s t a n c e pairDistance pairDistance越大,比它小的数对距离就越多。
我们回到二分框架的 m i d mid mid上,如果数对距离为 m i d mid mid时,数对距离小于或等于 m i d mid mid的数对个数 大于或等于 k k k,说明答案在整个区间的左半部分,这时我们要 a n s w e r = m i d , r i g h t = m i d − 1 answer = mid,right = mid-1 answer=mid,right=mid−1来收敛区间和保留当前可行解【疑点1】,如果数对距离大于 m i d mid mid的数对个数大于 k k k,那说明答案在整个区间的右半部分,我们要 l e f t = m i d + 1 left = mid +1 left=mid+1来选取右半部分进行区间收敛并选取可行解【疑点2】。
现在我们只剩下一个问题需要解决:
【问题4-1】给定一个整数 d i s t a n c e distance distance,数对距离小于或等于 d i s t a n c e distance distance的数对有多少个?
在最开始分析的时候,我们已经对数组进行了排序,所以数对距离就是数对中 下标大的数 减去 下标小的数 。回到【问题4-1】,这个问题其实有暴力解法,我把所有的数对都遍历一遍,拎出 数对距离小于或等于 d i s t a n c e distance distance 的数对进行计数就好了,但是这种操作的时间复杂度是 O ( n ∗ ( n − 1 ) / 2 ) O(n*(n-1)/2) O(n∗(n−1)/2),肯定会超时。
看过我之前文章的同学可能会想到,我之前写过一篇 【滑动窗口遍历数组】的文章,利用滑动窗口可以在 带着目的 的情况下大大减少遍历数组的时间。
我们直接给出解决【问题4-1】的代码:
bool check_Pro4(vector<int> nums, int distance, int k) {
int left = 0;
int ans = 0;
while (left < nums.size() - 1) {
int right = left + 1;
while (nums[right] - nums[left] <= distance) {
//数对距离比distance小,窗口可以继续往右移
ans++;
right++;
}
left++;
}
return ans <= k;
}
解决代码:
现在只剩个二分主框架:
int solution_Pro4(vector<int> nums, int k) {
sort(nums.begin(), nums.end());
int left = 0, right = nums[nums.size() - 1];
int answer;
while (left <= right) {
int mid = (left + right) / 2;
if (check_Pro4(nums, mid, k)) {
answer = mid;
right = mid - 1;
}
else {
left = mid + 1;
}
}
return answer;
}
在第四题末尾补充一下对两个疑点的解释:
- 数对距离为 m i d mid mid时,数对距离小于或等于 m i d mid mid的数对个数 大于或等于 k k k,这对应着两种情况。第一种情况: m i d mid mid的值和真正第 k k k小的值不相等,那我们利用 r i g h t = m i d − 1 right = mid -1 right=mid−1来收敛区间没问题,即使加上 a n s w e r = m i d answer = mid answer=mid也不会影响最终的 a n s w e r answer answer,因为真正第 k k k小的值在收敛后的区间里,它会对 a n s w e r answer answer进行更新。第二种情况: m i d mid mid的值和真正第 k k k小的值相等,这个时候我们仍然在利用 r i g h t = m i d − 1 right = mid-1 right=mid−1收敛区间,如果不加上$answer = mid ,那真正第 ,那真正第 ,那真正第k 小的值就被我们错过了,这显然要出问题,所以最后我们选定 小的值就被我们错过了,这显然要出问题,所以最后我们选定 小的值就被我们错过了,这显然要出问题,所以最后我们选定answer = mid, right = mid - 1$。
- 如果 数对距离小于或等于 m i d mid mid的数对个数小于 k k k,那说明 m i d mid mid是第 m m m小的数,这个 m m m一定小于 k k k,所以我们只需要选取右半部分区间来进行收敛,只需要 l e f t = m i d + 1 left = mid +1 left=mid+1即可。