在算法学习中,二分法是一种高效且应用广泛的查找策略。它不仅能用于有序数组的元素查找,更在“最小化最大值”“最大化最小值”等优化问题中发挥着关键作用。本文将结合两道典型例题,从问题分析、思路推导到代码实现,带你深入理解二分法在这类问题中的应用,并总结常见错误与避坑指南。
二分法的本质是通过不断将搜索范围减半,快速定位目标值。在“最小化最大值”问题中,其核心逻辑依赖于答案的单调性:若某个值 x
满足条件,则所有大于 x
的值也一定满足条件;若 x
不满足条件,则所有小于 x
的值也一定不满足条件。
基于这个特性,我们可以通过以下步骤求解:
left
)和最大值(上界 right
)。mid
是否满足条件(即能否通过有限操作实现目标)。mid
满足条件,尝试更小的范围(收缩右边界);若不满足,尝试更大的范围(收缩左边界),直至找到最小的满足条件的值。给你一个整数数组 ranks
表示机械工的能力值,能力值为 r
的机械工修理 n
辆车需要 r * n²
分钟。同时给你一个整数 cars
表示需要修理的汽车总数,所有机械工可以同时工作。返回修理所有汽车最少需要的时间。
t
,使得所有机械工在 t
分钟内可修理的汽车总数 ≥ cars
。t
足够,则所有大于 t
的时间也一定足够。t
,计算每个机械工在 t
分钟内最多可修理的汽车数(n = sqrt(t / r)
),累加后判断是否 ≥ cars
。// 错误示例:错误的验证逻辑
func repairCarsWrong(ranks []int, cars int) int {
minR := ranks[0]
for _, r := range ranks {
if r < minR {
minR = r
}
}
left, right := 0, minR*cars*cars
for left < right {
mid := (left + right) / 2
// 错误:简单用机械工数量+操作数乘以mid判断,未考虑实际修理规则
if (len(ranks) * mid) >= cars {
right = mid
} else {
left = mid + 1
}
}
return left
}
import "math"
func repairCars(ranks []int, cars int) int {
// 确定上下界:下界1,上界为能力最强的机械工单独修完所有车的时间
minR := ranks[0]
for _, r := range ranks {
if r < minR {
minR = r
}
}
left, right := 1, minR*cars*cars
for left < right {
mid := left + (right-left)/2
if canRepair(ranks, cars, mid) {
right = mid // 可行,尝试更小时间
} else {
left = mid + 1 // 不可行,尝试更大时间
}
}
return left
}
// 验证函数:判断t分钟内能否修完cars辆车
func canRepair(ranks []int, cars, t int) bool {
total := 0
for _, r := range ranks {
// 每个机械工最多修n辆:r*n² ≤ t → n ≤ sqrt(t/r)
n := int(math.Sqrt(float64(t / r)))
total += n
if total >= cars {
return true
}
}
return false
}
给你一个整数数组 nums
和一个整数 maxOperations
。每一次操作可以将一个袋子中的球分到两个新的袋子中(即一次操作可将一个数 x
拆分为 a
和 b
,其中 a + b = x
)。返回拆分后所有袋子中球的最大数量的最小值。
x
,使得通过 ≤ maxOperations
次操作,可将所有袋子的球数降至 ≤ x
。x
可行,则所有大于 x
的值也可行。x
,计算每个数 num
拆分为 ≤ x
所需的操作数((num-1)/x
),累加后判断是否 ≤ maxOperations
。// 错误示例:错误的验证条件
func minimumSizeWrong(nums []int, maxOperations int) int {
maxNum := 0
sum := 0
for _, num := range nums {
if num > maxNum {
maxNum = num
}
sum += num
}
left, right := 1, maxNum
for left < right {
mid := left + (right-left)/2
// 错误:用总数量和机械工数量简单相乘判断,未计算实际操作次数
if (len(nums)+maxOperations)*mid >= sum {
right = mid
} else {
left = mid + 1
}
}
return left
}
func minimumSize(nums []int, maxOperations int) int {
// 确定上下界:下界1,上界为初始最大值
maxNum := 0
for _, num := range nums {
if num > maxNum {
maxNum = num
}
}
left, right := 1, maxNum
for left < right {
mid := left + (right-left)/2
if canSplit(nums, mid, maxOperations) {
right = mid // 可行,尝试更小最大值
} else {
left = mid + 1 // 不可行,尝试更大最大值
}
}
return left
}
// 验证函数:判断能否通过≤maxOps次操作使所有数≤maxSize
func canSplit(nums []int, maxSize, maxOps int) int {
ops := 0
for _, num := range nums {
if num > maxSize {
// 计算拆分次数:num拆分为k个≤maxSize的数,需要k-1次操作
// k = ceil(num/maxSize) → 操作数 = k-1 = (num-1)/maxSize
ops += (num - 1) / maxSize
if ops > maxOps {
return false
}
}
}
return true
}
在使用二分法解决“最小化最大值”问题时,容易陷入以下误区:
最常见的错误是未正确设计验证函数,如用简单的数学公式(如总和、数量乘积)替代实际操作规则。例如例题2中,错误地用 (len(nums)+maxOperations)*mid >= sum
判断,忽略了“每次只能拆分一个数”的规则。
解决:验证函数必须严格模拟问题场景,计算实际所需的操作次数或资源量。
解决:下界通常设为问题的最小可能值(如1),上界设为“最极端情况的值”(如例题1中能力最强的机械工单独修完所有车的时间)。
mid
可行时,错误地收缩左边界(left = mid
),导致范围无法收敛。mid
不可行时,未正确增加左边界(left = mid
而非 left = mid + 1
),导致死循环。解决:记住收缩规则:
mid
满足条件):收缩右边界 right = mid
(尝试更小值)。mid
不满足条件):收缩左边界 left = mid + 1
(尝试更大值)。解决“最小化最大值”问题的二分法流程可归纳为:
left
(下界)和 right
(上界)。mid
是否满足条件(操作次数是否≤限制)。left
和 right
,直至 left == right
,此时即为最小可行解。二分法的高效性体现在时间复杂度上:若验证函数为 O(n)
,二分范围为 [1, M]
,则总复杂度为 O(n log M)
,远优于暴力枚举的 O(M*n)
。
掌握二分法的关键在于理解单调性和设计正确的验证函数。多练习类似问题(如分割数组的最大值、Koko吃香蕉等),能帮助你快速掌握这一强大的算法工具。
希望本文能帮助你理解二分法在“最小化最大值”问题中的应用。如果有疑问或补充,欢迎在评论区交流!