[二分答案]对答案二分查找类型题, 附解题模板和例题
如何识别二分答案类型的题?
关键字分析
题目的数组出现 非空 和 连续 的两个信息时
此外, 这种题往往让我们通过一个额外的函数计算某一个条件下的极限答案
例如 LCP 12. 小张刷题计划求 m 天内做题时间最多的一天耗时为 T, 最小的 T 为多少, 那么我们需要通过函数计算 找到一个数,把它作为分割后各个子数组的和的最大值
在 1482. 制作 m 束花所需的最少天数中求从花园中摘 m
束花需要等待的最少的天数, 我们就要通过函数计算 找到一天, 求这一天是否可以做好花
题目给的数组都是非空和连续的, 并且答案是有单调性的
解题思路的直觉来源
基于「力扣」第 69 题、第 287 题,知道二分查找的应用:可以用于查找一个有范围的 整数,就能想到是不是可以使用二分查找去解决这道问题。
事实上,二分查找最典型的应用我们都见过,《幸运 52》猜价格游戏,主持人说「低了」,我们就应该往高了猜。
这种二分查找的应用大家普遍的叫法是「二分答案」,即「对答案二分」。它是相对于二分查找的最原始形式「在一个有序数组里查找一个数」而言的。
挖掘单调性:
以 410. 分割数组的最大值为例, 既然二分法需要一个具有单调性的区间, 我们就想想有没有单调性可以挖掘, 不难发现:
- 如果设置「数组各自和的最大值」很大,那么必然导致分割数很小;
- 如果设置「数组各自和的最大值」很小,那么必然导致分割数很大。
仔细想想,这里「数组各自和的最大值」就决定了一种分割的方法。再联系一下我们刚刚向大家强调的题目的要求 连续 和题目中给出的输入数组的特点: 非负整数数组。
那么,我们就可以通过调整「数组各自和的最大值」来达到:使得分割数恰好为 m
的效果。这里要注意一个问题:
模板
以下给出此类问题的模板
总共分为三步:
- 确定二分上下界
- check 函数逻辑
- 具体二分过程
func template(price []int, k int) int {
// 如果有需要, 得对题目数组排序 sort.Ints(price)
// 二分的下界, 基本上是 0 , 1 , 数组第0个元素
left := 1
// 二分的上界, 往往从题目给的数组就能知道
right := price[len(price) - 1] - price[0]
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 符合条件的话向右逼近或者向左逼近, 由题意来看, [left, right] 变为 [mid + 1, right]
if check(mid, price, k) {
ans = mid
left = mid + 1
}else {
right = mid - 1
}
}
return ans
}
func check(target int, price []int, k int) bool {
// 具体判断逻辑, 参数往往比题目给的参数多一个tareget, 也就是二分过程中的 mid
}
注意:
二分的过程也有两种方式
- left < right
- left <= right
具体如下
var ans int
for left <= right {
mid := left + (right - left) >> 1
if check(mid, price, k) {
ans = mid
left = mid + 1
}else {
right = mid - 1
}
}
return ans
// ----------------------------------------------------
for left < right {
mid := left + (right - left) >> 1
if check(mid, price, k) {
right = mid
}else {
left = mid + 1
}
}
return left
这两种方法我更倾向 left <= right
, 虽然多了一个 ans 变量来记录答案, 但是好理解, 不容易绕进去
比如用 left < right
的时候, 如果符合 check
函数的情况下, 需要 left = mid + 1, 向右逼近, 那么 mid 就不能 向下取整了 而是要 向上取整
即 mid := left + (right - left + 1) >> 1 所以要考虑的就多了
例题
此类问题都如出一辙,请大家特别留意题目中出现的关键字「非负整数」、分割「连续」,思考清楚设计算法的关键步骤和原因,相信以后遇到类似的问题就能轻松应对。
- 「力扣」第 875 题:爱吃香蕉的珂珂(中等)
- LCP 12. 小张刷题计划 (中等)
- 「力扣」第 1482 题:制作 m 束花所需的最少天数(中等)
- 「力扣」第 1011 题:在 D 天内送达包裹的能力(中等)
- 「力扣」第 1552 题:两球之间的磁力(中等)
- 「力扣」第 2517 题:礼盒的最大甜蜜度 (中等)
- 「力扣」第 410 题:分割数组的最大值 (困难)
875. 爱吃香蕉的珂珂
思路
由于吃香蕉的速度和是否可以在规定时间内吃掉所有香蕉之间存在单调性,因此可以使用二分查找的方法得到最小速度 k。
因为只需考虑吃掉所有香蕉的情况, 所以不需要对数组排序
由于每小时都要吃香蕉,即每小时至少吃 1 个香蕉,因此二分查找的下界是 1;由于每小时最多吃一堆香蕉,即每小时吃的香蕉数目不会超过最多的一堆中的香蕉数目,因此二分查找的上界是最多的一堆中的香蕉数目。
二分的上下界如下:
left := 1
right := piles[0]
for _, val := range piles {
right = max(val, right)
}
check
函数: 计算在某个速度下, 吃香蕉所需要的小时:
func getTime(piles []int, speed int) int{
// 计算在speed的速度下需要吃多少小时
// 因为要向上取整, 所以(pile / speed)向上取整后等价于(pile + speed - 1) / speed
time := 0
for _, pile := range piles {
curTime := (pile + speed - 1) / speed
time += curTime
}
return time
}
二分的过程:
// 进行二分查找
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 在h小时之前能够吃完, 符合条件, 向左逼近
if getTime(piles, mid) <= h {
right = mid - 1
ans = mid
}else { // 否则向右逼近
left = mid + 1
}
}
return ans
Code
func minEatingSpeed(piles []int, h int) int {
// 题目要求吃香蕉的速度, 一次最多吃一堆
// 速度的上界是 最多的那一堆香蕉, 下界就是最少的一堆, 可以置为1
left := 1
right := piles[0]
for _, val := range piles {
right = max(val, right)
}
// 进行二分查找
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 在h小时之前能够吃完, 符合条件, 向左逼近
if getTime(piles, mid) <= h {
right = mid - 1
ans = mid
}else { // 否则向右逼近
left = mid + 1
}
}
return ans
}
func getTime(piles []int, speed int) int{
// 计算在speed的速度下需要吃多少小时
// 因为要向上取整, 所以(pile / speed)向上取整后等价于(pile + speed - 1) / speed
time := 0
for _, pile := range piles {
curTime := (pile + speed - 1) / speed
time += curTime
}
return time
}
LCP 12. 小张刷题计划
思路
确定二分的上下界:
最小耗时是 0, 因为有可能每天只做一道题, 并且都求救
最大耗时是所有题的和, 有可能一天之内都做完, 并且不求救
left, right := 0, 0
for i := 0; i < len(time); i ++ {
right += time[i]
}
check 函数逻辑:
求最多的一天耗时不能超过 limit 的情况下, 是否能在 m 天内做完, 如果做不完, 返回 false, 做完了返回 true
func check(limit int, cost []int, m int)bool {
// 每组划分 limit 的最大和, 贪心划分看有多少组
count, sum, maxT := 0, 0, 0
for _, t := range cost {
sum += t
maxT = max(maxT, t)
if sum - maxT > limit {
count ++
if count == m {
return false
}
sum = t
maxT = t
}
}
return true
}
二分的过程:
版本一:
var ans int
for left <= right {
mid = left + (right - left) >> 1
if check(mid, time, m) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
版本二:
for left < right {
mid = left + (right - left) >> 1
if check(mid, time, m) {
right = mid
}else {
left = mid + 1
}
}
Code
func minTime(time []int, m int) int {
left, right := 0, 0
var mid int
for i := 0; i < len(time); i ++ {
right += time[i]
}
var ans int
for left <= right {
mid = left + (right - left) >> 1
if check(mid, time, m) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
return ans
}
func check(limit int, cost []int, m int)bool {
// 每组划分 limit 的最大和, 贪心划分看有多少组
count, sum, maxT := 0, 0, 0
for _, t := range cost {
sum += t
maxT = max(maxT, t)
if sum - maxT > limit {
count ++
if count == m {
return false
}
sum = t
maxT = t
}
}
return true
}
1482. 制作 m 束花所需的最少天数
思路
确定二分的上下界:
二分的上界是最晚开花的一天, 下界是第一天
left := 1
right := 1
for _, val := range bloomDay {
right = max(val, right)
}
check 函数逻辑:
check 函数的目的是 某一天花园开了的花能否制作成 m 束花
func check(day int,bloomDay []int, m int, k int) bool {
num := 0
count := 0
for _, val := range bloomDay {
// i 的位置开花了, 记录连续的花, 如果连续的数目满足k了, 做成了一束花, 重新计数
if val <= day {
count ++
if count == k {
num ++
count = 0
}
}else {
count = 0
}
}
// 说明当天不足以做好花
if num < m {
return false
}
return true
}
二分的过程:
版本一
for left <= right {
// 如果能制作的花数目不足, 那么区间从[left, right]变为[mid + 1, right]
// 否则[left, mid]
mid := left + (right - left) >> 1
if check(mid, bloomDay, m, k) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
版本二
for left < right {
// 如果能制作的花数目不足, 那么区间从[left, right]变为[mid + 1, right]
// 否则[left, mid]
mid := left + (right - left) >> 1
if check(mid, bloomDay, m, k) {
right = mid
}else {
left = mid + 1
}
}
Code
这里开头进行了特判, 如果所有花加起来都做不成 m 束花, 就 return - 1
func minDays(bloomDay []int, m int, k int) int {
if len(bloomDay) < m * k {
return -1
}
// 二分上界是最晚开花的一天, 下界是第一天
left := 1
right := 1
for _, val := range bloomDay {
right = max(val, right)
}
var ans int
for left <= right {
// 如果能制作的花数目不足, 那么区间从[left, right]变为[mid + 1, right]
// 否则[left, mid]
mid := left + (right - left) >> 1
if check(mid, bloomDay, m, k) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
return ans
}
func check(day int,bloomDay []int, m int, k int) bool {
num := 0
count := 0
for _, val := range bloomDay {
// i 的位置开花了, 记录连续的花, 如果连续的数目满足k了, 做成了一束花, 重新计数
if val <= day {
count ++
if count == k {
num ++
count = 0
}
}else {
count = 0
}
}
// 说明当天不足以做好花
if num < m {
return false
}
return true
}
1011. 在 D 天内送达包裹的能力
思路
我们二分寻找的答案是船的运载能力, 也就是运载重量
确定二分的上下界:
上界是 weights 所有物品总重量
下界是 weights 的最小值
left := weights[0]
right := 0
check 函数逻辑:
因为按给出重量(weights
)的顺序往传送带上装载包裹,所以逻辑比较简单
在某个运载能力的条件下, 能否在 days 天内完成运载任务
也就是每天的运载重量不能超过给定的运载能力
func check(target int, weights []int, days int) bool{
curWeight := 0
curDay := 1
for _, weight := range weights {
if weight > target {
return false
}
if curWeight + weight > target {
curDay ++
curWeight = 0
}
curWeight += weight
}
if curDay > days {
return false
}
return true
}
二分的过程:
版本一
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 满足days内装完货, 区间从[left, right] 变成 [left, mid]
if check(mid, weights, days) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
版本二
for left < right {
mid := left + (right - left) >> 1
// 满足days内装完货, 区间从[left, right] 变成 [left, mid]
if check(mid, weights, days) {
right = mid
}else {
left = mid + 1
}
}
Code
func shipWithinDays(weights []int, days int) int {
// 二分的目标是船的运载能力, 也就是运载重量
// 下界是weights最小值, 上界是weights总和
left := weights[0]
right := 0
for _, val := range weights {
left = min(val, left)
right += val
}
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 满足days内装完货, 区间从[left, right] 变成 [left, mid]
if check(mid, weights, days) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
return ans
}
func check(target int, weights []int, days int) bool{
curWeight := 0
curDay := 1
for _, weight := range weights {
if weight > target {
return false
}
if curWeight + weight > target {
curDay ++
curWeight = 0
}
curWeight += weight
}
if curDay > days {
return false
}
return true
}
1552. 两球之间的磁力
确定二分的上下界:
二分的上界是 position 最大值 - position 最小值
下界是 position 最小值, 我设置成了 1
这里为了方便计算, 将 position 数组进行了排序
sort.Ints(position)
// 二分的上界是position里最大值, 下界就是1
left := 1
right := position[len(position) - 1] - position[0]
check 函数逻辑:
二分寻找的答案是最小磁力
那么就求在某个力量为最小磁力的条件下, 能否成功分好 m 个球
具体就是按 position 的排好序的顺序来放球, 如果当前位置放球的话, 与前一个球的力量要大于参数中的最小磁力, 最后看球数量是否 >= m
if count >= m return true else false
func check(target int, position []int, m int) bool{
lastPos := position[0]
count := 1
for i := 1; i < len(position); i ++ {
if position[i] - lastPos >= target {
lastPos = position[i]
count ++
}
}
if count >= m {
return true
}
return false
}
二分的过程:
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 如果符合条件, 二分的区间就从[left, right] 变成 [mid, right]
if check(mid, position, m) {
left = mid + 1
ans = mid
}else {
right = mid - 1
}
}
Code
import "sort"
func maxDistance(position []int, m int) int {
sort.Ints(position)
// 二分的上界是position里最大值, 下界就是1
left := 1
right := position[len(position) - 1] - position[0]
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 如果符合条件, 二分的区间就从[left, right] 变成 [mid, right]
if check(mid, position, m) {
left = mid + 1
ans = mid
}else {
right = mid - 1
}
}
return ans
}
func check(target int, position []int, m int) bool{
lastPos := position[0]
count := 1
for i := 1; i < len(position); i ++ {
if position[i] - lastPos >= target {
lastPos = position[i]
count ++
}
}
if count >= m {
return true
}
return false
}
2517. 礼盒的最大甜蜜度
思路
题目表示 礼盒的 甜蜜度 是礼盒中任意两种糖果 价格 绝对差的最小值
要求我们寻找礼盒的最大甜蜜度
因为要比较价格差值, 所以需要单调区间, 我们将数组排好序
确定二分的上下界:
二分的上界是 price 最大值 - price 最小值
下界是 price 最小差值, 不好确认, 我设置成了 1
left := 1
right := price[len(price) - 1] - price[0]
check 函数逻辑:
二分寻找的答案是甜蜜度
那么就求在某个甜蜜度的条件下, 能否组合出来 k 个糖果
具体就是遍历 price, 双指针法, 两个指针距离超过甜蜜度的话, 计数器 ++, 更新 pre 指针, 最后看 count 是否 >= k
count >= k 说明符合条件 return true
func check(target int, price []int, k int) bool {
// 求最小价格差值, 目标价格差值是target,
count := 1
pre := price[0]
for i := 1; i < len(price); i ++ {
if price[i] - pre >= target {
pre = price[i]
count ++
}
}
if count >= k {
return true
}
return false
}
二分的过程:
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 因为要最大甜蜜度, 所以符合条件的话向上增长, [left, right] 变为 [mid + 1, right]
if check(mid, price, k) {
ans = mid
left = mid + 1
}else {
right = mid - 1
}
}
Code
import (
"sort"
)
func maximumTastiness(price []int, k int) int {
sort.Ints(price)
// 二分的上界是价格差最大的值即price[len(price) - 1] - price[0], 下界为1
left := 1
right := price[len(price) - 1] - price[0]
var ans int
for left <= right {
mid := left + (right - left) >> 1
// 因为要最大甜蜜度, 所以符合条件的话向上增长, [left, right] 变为 [mid + 1, right]
if check(mid, price, k) {
ans = mid
left = mid + 1
}else {
right = mid - 1
}
}
return ans
}
func check(target int, price []int, k int) bool {
// 求最小价格差值, 目标价格差值是target,
count := 1
pre := price[0]
for i := 1; i < len(price); i ++ {
if price[i] - pre >= target {
pre = price[i]
count ++
}
}
if count >= k {
return true
}
return false
}
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
思路
实际上该题有一个动态规划的解法, 但是非常难理解, 需要三层 for 循环嵌套, 还是二分答案的方法更好理解
答案寻找的是 k 个子数组各自和的最大值, 也就是将数组分成 k 个, 寻找最大的子数组的和
确定二分的上下界:
严谨的上下界不好确定
所以上界定为数组元素之和
下界定为数组最大元素, 说明分割数组后最大值单独作为一个子数组
也可以定为 0, 因为是非负整数数组
check 函数逻辑:
二分的答案是 k 个子数组各自和的最大值
那么问题就变成了: 在数组最大和不能超过给定条件的最大和的情况下, 能否将数组分成 k 个
为了二分过程好理解, 直接把 count 子数组个数 return 出去
func check(target int, nums []int, k int) int {
sum := 0
// 最后有剩下没考虑到的 1 个子数组, 所以初始化为 1 了
count := 1
for _, num := range nums {
if sum + num > target {
sum = 0
count ++
}
sum += num
}
return count
}
二分过程:
分的数组比 k 多的话, 说明最大和有点小了, 最好再大点, 所以区间由[left, right] -> [mid + 1, right]
分的数组比 k 少的话, 说明最大和有点大了, 最好再小点, 所以区间由
[left, right] -> [left, mid - 1]
分的数组正好为 k 个的话, 说明最大和符合条件, 但是还想再小点, 所以并入 分的数组比 k 少
的分支
for left <= right {
mid := left + (right - left) >> 1
if check(mid, nums, k) <= k{
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
Code
版本一(好理解一点)
func splitArray(nums []int, k int) int {
// 上界定为数组元素之和
// 下界定为数组最大元素, 说明分割数组后最大值单独作为一个子数组
left := nums[0]
right := 0
for _, num := range nums {
left = max(left, num)
right += num
}
// 二分
var ans int
for left <= right {
mid := left + (right - left) >> 1
if check(mid, nums, k) <= k{
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
return ans
}
func check(target int, nums []int, k int) int {
sum := 0
// 最后有剩下没考虑到的 1 个子数组, 所以初始化为 1 了
count := 1
for _, num := range nums {
if sum + num > target {
sum = 0
count ++
}
sum += num
}
return count
}
版本二 (check 函数返回 bool 值)
func splitArray(nums []int, k int) int {
// 上界定为数组元素之和
// 下界定为数组最大元素, 说明分割数组后最大值单独作为一个子数组
left := nums[0]
right := 0
for _, num := range nums {
left = max(left, num)
right += num
}
// 二分
var ans int
for left <= right {
mid := left + (right - left) >> 1
if check(mid, nums, k) {
right = mid - 1
ans = mid
}else {
left = mid + 1
}
}
return ans
}
func check(target int, nums []int, k int) bool {
sum := 0
// 最后有剩下没考虑到的 1 个子数组, 所以初始化为 1 了
count := 1
for _, num := range nums {
if sum + num > target {
sum = 0
count ++
}
sum += num
}
if count <= k {
return true
}
return false
}