1.前言
二分查找,我们接触最多的是,给定一个完全有序的数组(最普通的二分查找)或者一个局部有序的数组(leetcode搜索旋转数组),然后从整个数组中搜索一个值。往往整个待搜索数组的全体元素显式地展现在我们眼前。但有些时候,这个“数组”给出的方式不会那么直接,而是“隐式”地给出
2.【试一试】问题之隐式二分问题
875. 爱吃香蕉的珂珂
1011. 在 D 天内送达包裹的能力
410. 分割数组的最大值
通过上面3个经典题阐述一下,啥是【试一试】问题
875. 爱吃香蕉的珂珂
珂珂喜欢吃香蕉。有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫离开了,将在 H 小时后回来。
珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
珂珂想在警卫回来前吃掉所有的香蕉。
返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)
示例 1:
输入: piles = [3,6,7,11], H = 8
输出: 4
解释:第1小时吃掉3,第2、3小时吃掉6,第4、5小时吃掉7,第6、7、8小时吃掉11
思路:
首先最直接和自然的思路是正向思维,就是我有没有一个方法,通过某种公式规则直接推导出答案,似乎比较难。那么我们换一个思路,试
——我们先拿一个数作为假想答案,然后去试试看照这样吃需要多少小时,如果时间超了就每小时再多吃一点,否则可以尝试每小时再少吃一点,这小学生都会
再深入一下,珂珂最少每小时得吃一根吧,最多可以吃无数根,但因为一堆香蕉不够吃的话,也得花一小时,因此最多吃max(piles)就可以了,多吃无益。那么最终的答案必然落在[1, max(piles)]里面。这个区间是我们分析出来的,不是显式给出的;然后这个区间显然是一个有序区间,有序区间搜索一个数,妥妥地标准二分查找题
class Solution:
def minEatingSpeed(self, piles: List[int], H: int) -> int:
low, high = 1, max(piles)
while low < high:
mid = low + (high - low) // 2
cnt = 0
for i in range(len(piles)):
remain = 0
if piles[i] % mid != 0:
remain = 1
cnt += (piles[i] // mid + remain)
if cnt > H:
low = mid + 1
break
else:
high = mid
return low
回顾总结:
- 1.这类题的思路是二分查找,难点在于发现一个隐式的搜索区间从而转换为二分搜索
- 2.二分查找,本质就是一个不断试错的过程,遍历、尝试、循环,这些真真是计算机科学的基础和精华
1011. 在 D 天内送达包裹的能力
传送带上的包裹必须在 D 天内从一个港口运送到另一个港口。
传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
返回能在 D 天内将传送带上的所有包裹送达的船的最低运载能力。
示例 1:
输入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10
请注意,货物必须按照给定的顺序装运,因此使用载重能力为 14 的船舶并将包装分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允许的。
找到运载能力的上下界,形成搜索区间[max(weights), sum(weights)]
class Solution:
def shipWithinDays(self, weights: List[int], D: int) -> int:
low, high = max(weights), sum(weights)
while low < high:
mid = low + (high-low) // 2
day = 1
temp_sum = 0
for i in range(len(weights)):
if temp_sum + weights[i] > mid:
day += 1
temp_sum = weights[i]
else:
temp_sum += weights[i]
if day > D:
low = mid + 1
else:
high = mid
return low
410. 分割数组的最大值
给定一个非负整数数组和一个整数 m,你需要将这个数组分成 m 个非空的连续子数组。设计一个算法使得这 m 个子数组各自和的最大值最小。
注意:
数组长度 n 满足以下条件:
1 ≤ n ≤ 1000
1 ≤ m ≤ min(50, n)
示例:
输入:
nums = [7,2,5,10,8]
m = 2
输出:
18
解释:
一共有四种方法将nums分割为2个子数组。
其中最好的方式是将其分为[7,2,5] 和 [10,8],
因为此时这两个子数组各自的和的最大值为18,在所有情况中最小
思路:
这题实际上和吃香蕉、运货物的本质是一样的,只不过没有场景背景,晦涩了一点。H小时吃香蕉 = D天运完 = m个子数组
那么这个题的搜索区间就是[min(nums), sum(nums)],然后通过二分查找判断mid能否把nums分成m个子数组
class Solution:
def splitArray(self, nums: List[int], m: int) -> int:
def isValid(n):
cur_sum, group_count = 0, 1
for i in range(len(nums)):
if cur_sum + nums[i] <= n:
cur_sum += nums[i]
else:
cur_sum = nums[i]
group_count += 1
return group_count <= m
low = max(nums)
high = sum(nums)
while low < high:
mid = low + (high - low) // 2
if isValid(mid):
high = mid
else:
low = mid + 1
return low
3.小结
- 1.本质上来说,最值(最小/最大)问题都可以通过试来解决。通过试来验证当前的尝试值是否满足题意,不满足再继续更新尝试值,直到试出来答案为止。这三个题的本质都是试一试的问题
- 2.尝试值、更新值的方式有很多,而因为这里刚好形成了一个有序区间,那么这里利用二分进行尝试,可以极大降低时间复杂度。当然,我们像小学生那样,一个数一个数地从1试到无穷大,也是可以得到结果的。二分是我们试数的手段,而不是题目的本质