一、什么是"最大化最小值"问题?
在算法题中,经常会遇到这样的描述:
- “让所有工人的最大工作量尽可能小”
- “让所有城市的最小电量尽可能大”
- “让木材切割后的最小长度尽可能大”
- “让学生分配到的最大任务数尽可能小”
这类问题有个共同特点:求在某种约束条件下,某个极值指标的最优值。
乍一看这类问题很难直接求解,但有一个巧妙的思路:把"求最优值"转换为"判断某个值是否可行"。
二、核心思想:二分答案法
2.1 为什么要二分答案?
直接思路的困境:
假设要求"分配任务后,学生们的最大任务量的最小值",如果直接枚举所有可能的分配方案,复杂度会是指数级的,根本无法接受。
转换思路:
我们换个角度问问题:
- 原问题:“最优答案是多少?”(不知道怎么求)
- 新问题:“答案能否达到 X?”(相对容易判断)
如果能高效判断"答案是否能达到 X",那我们就可以用二分搜索在所有可能的答案中找到最优的那个!
2.2 为什么二分法有效?关键在于单调性
二分答案法之所以可行,是因为这类问题有一个关键性质:单调性。
以"最大化最小值"为例:
- 如果"让所有城市电量都达到 10"可以实现,那么"让所有城市电量都达到 9、8、7…"肯定也可以实现(要求更低了)
- 如果"让所有城市电量都达到 10"无法实现,那么"让所有城市电量都达到 11、12、13…"肯定也无法实现(要求更高了)
这种单调性意味着:存在一个临界值 M,小于等于 M 的都可以实现,大于 M 的都无法实现。
答案值: 0 1 2 3 4 5 6 7 8 9 10 11 12
是否可行: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✗ ✗ ✗ ✗ ✗
↑
我们要找的最优答案
这正是二分搜索的应用场景!我们要找的就是那个"从可行到不可行"的临界点。
三、解题框架(四步走)
第一步:识别问题类型
看到以下关键词就要想到二分答案法:
- “最大化最小值”
- “最小化最大值”
- “在…约束下,求…的最优值”
- “让…尽可能大/小”
第二步:确定二分搜索范围
需要确定答案的可能范围 [left, right]:
- left(最小可能值):通常是理论最小值,比如 0,或者初始状态下已有的最小值
- right(最大可能值):通常是理论最大值,比如所有资源之和,或者题目给定的上限
第三步:设计 check(mid) 函数
这是最关键的一步!
check(mid) 函数的作用:判断答案能否达到 mid。
- 返回
true:说明 mid 这个答案可以实现,真正的最优答案 ≥ mid - 返回
false:说明 mid 这个答案无法实现,真正的最优答案 < mid
设计 check 函数时,通常会用到贪心、滑动窗口、差分数组等技巧。
第四步:二分搜索主循环
def solve():
left, right = 确定搜索范围
while left < right:
mid = (left + right + 1) // 2 # 对于"最大化最小值"问题
if check(mid): # mid 可行
left = mid # 尝试更大的值
else: # mid 不可行
right = mid - 1 # 尝试更小的值
return left # 最终 left == right,就是答案
注意:
- "最大化最小值"问题:
mid = (left + right + 1) // 2,可行时left = mid - "最小化最大值"问题:
mid = (left + right) // 2,可行时right = mid
四、完整案例:分木材问题
4.1 问题描述
你有 n 根木材,第 i 根的长度是 lengths[i]。你需要切割这些木材得到至少 k 根木材。
求:切割后木材的最大可能长度是多少?
示例:
输入:lengths = [5, 9, 7], k = 3
输出:5
解释:
- 长度为 5 的木材:保持不切,得到 1 根长度为 5 的木材
- 长度为 9 的木材:切成 5+4,得到 1 根长度为 5 的木材(4 舍弃)
- 长度为 7 的木材:切成 5+2,得到 1 根长度为 5 的木材(2 舍弃)
共得到 3 根长度为 5 的木材,满足要求。
如果目标长度是 6:
- 长度为 5 的木材:无法切出长度为 6 的木材,得到 0 根
- 长度为 9 的木材:切成 6+3,得到 1 根
- 长度为 7 的木材:切成 6+1,得到 1 根
只能得到 2 根,不满足要求。
4.2 问题分析
这是一个典型的"最大化最小值"问题:要让切割后木材的长度(最小的那个标准)尽可能大。
思路转换:
- 原问题:“切割后的最大长度是多少?” → 不知道怎么直接求
- 新问题:“能否切出至少 k 根长度为 X 的木材?” → 可以遍历检查
4.3 解题步骤
第一步:确定搜索范围
left = 1 # 最小长度至少为 1
right = max(lengths) # 最大长度不会超过最长的木材
第二步:设计 check(mid) 函数
判断"能否切出至少 k 根长度为 mid 的木材":
def check(mid):
count = 0 # 统计能切出多少根长度为 mid 的木材
for length in lengths:
count += length // mid # 每根木材能切出几根
return count >= k # 至少 k 根就算成功
为什么这样可行?
- 每根木材
length可以切出length // mid根长度为mid的木材 - 把所有木材能切出的数量加起来,如果 ≥ k,说明目标长度
mid是可行的 - 这是一个贪心策略:每根木材尽可能多地切出目标长度,不浪费
第三步:二分搜索主循环
def maxLength(lengths, k):
left, right = 1, max(lengths)
while left < right:
mid = (left + right + 1) // 2 # 向上取整,避免死循环
if check(mid): # 可以切出至少 k 根长度为 mid 的木材
left = mid # 尝试更长的长度
else: # 无法切出足够的木材
right = mid - 1 # 降低长度要求
return left
4.4 完整代码实现
def cutWood(lengths, k):
"""
lengths: 木材长度数组
k: 需要得到的木材根数
返回:切割后木材的最大可能长度
"""
def check(target_length):
"""检查能否切出至少 k 根长度为 target_length 的木材"""
count = 0
for length in lengths:
count += length // target_length
return count >= k
# 确定搜索范围
left, right = 1, max(lengths)
# 二分搜索
while left < right:
mid = (left + right + 1) // 2 # 向上取整
if check(mid):
left = mid # mid 可行,尝试更大的值
else:
right = mid - 1 # mid 不可行,尝试更小的值
return left
# 测试
lengths = [5, 9, 7]
k = 3
print(cutWood(lengths, k)) # 输出: 5
4.5 执行过程图解
初始:lengths = [5, 9, 7], k = 3
搜索范围:left = 1, right = 9
第一轮:
mid = (1 + 9 + 1) // 2 = 5
check(5): 5//5=1, 9//5=1, 7//5=1 → 共 3 根 ✓
结论:5 可行,尝试更大的 → left = 5
第二轮:
left = 5, right = 9
mid = (5 + 9 + 1) // 2 = 7
check(7): 5//7=0, 9//7=1, 7//7=1 → 共 2 根 ✗
结论:7 不可行,降低要求 → right = 6
第三轮:
left = 5, right = 6
mid = (5 + 6 + 1) // 2 = 6
check(6): 5//6=0, 9//6=1, 7//6=1 → 共 2 根 ✗
结论:6 不可行,降低要求 → right = 5
第四轮:
left = 5, right = 5 → 循环结束
答案:5
五、案例 2:分配任务(最小化最大值)
5.1 问题描述
有 n 个任务,每个任务的工作量是 tasks[i]。现在要把这些任务分配给 k 个工人,每个工人可以完成多个任务,但必须按顺序完成(不能跳过)。
求:如何分配,使得工人中的最大工作量尽可能小?
示例:
输入:tasks = [1, 2, 3, 4, 5], k = 3
输出:6
解释:
工人1: [1, 2, 3] → 工作量 6
工人2: [4] → 工作量 4
工人3: [5] → 工作量 5
最大工作量是 6
如果分成:
工人1: [1, 2] → 4
工人2: [3, 4] → 7 (最大)
工人3: [5] → 5
最大工作量是 7,不如第一种分配
5.2 解题步骤
第一步:确定搜索范围
left = max(tasks) # 至少要能完成最大的单个任务
right = sum(tasks) # 最多是一个人完成所有任务
第二步:设计 check(mid) 函数
判断"能否用 k 个工人,让每个人的工作量都不超过 mid":
def check(max_workload):
workers_needed = 1 # 至少需要 1 个工人
current_workload = 0 # 当前工人的工作量
for task in tasks:
if current_workload + task <= max_workload:
# 当前工人可以继续做这个任务
current_workload += task
else:
# 需要新工人
workers_needed += 1
current_workload = task
return workers_needed <= k # 需要的工人数不超过 k
为什么这样可行?
- 贪心策略:让每个工人尽可能多做任务,直到无法再做
- 如果需要的工人数 ≤ k,说明这个 max_workload 是可行的
- 如果需要的工人数 > k,说明 max_workload 太小了,需要增大
第三步:二分搜索
def minimizeMaxWorkload(tasks, k):
left, right = max(tasks), sum(tasks)
while left < right:
mid = (left + right) // 2 # 向下取整
if check(mid):
right = mid # 可行,尝试更小的值
else:
left = mid + 1 # 不可行,需要更大的值
return left
注意: 这里是"最小化最大值"问题,所以:
- 使用
mid = (left + right) // 2(向下取整) - 可行时
right = mid(尝试更小)
5.3 完整代码
def minimizeMaxWorkload(tasks, k):
def check(max_workload):
"""检查能否用 k 个工人完成,每人工作量不超过 max_workload"""
workers_needed = 1
current_workload = 0
for task in tasks:
if current_workload + task <= max_workload:
current_workload += task
else:
workers_needed += 1
current_workload = task
if workers_needed > k:
return False
return True
left, right = max(tasks), sum(tasks)
while left < right:
mid = (left + right) // 2
if check(mid):
right = mid
else:
left = mid + 1
return left
# 测试
tasks = [1, 2, 3, 4, 5]
k = 3
print(minimizeMaxWorkload(tasks, k)) # 输出: 6
六、案例 3:LeetCode 2528 最大化城市的最小电量
6.1 问题描述
给你一个下标从 0 开始、长度为 n 的整数数组 stations,其中 stations[i] 表示第 i 座城市的电站数量。
每个电站的供电范围是固定的。也就是说,每个电站可以给它所在的城市以及距离它不超过 r 的城市供电。
你可以新建最多 k 个电站。每个新建电站的供电范围和已有电站相同。
返回所有城市电量的最小值的最大值。
示例:
输入:stations = [1, 2, 4, 5, 0], r = 1, k = 2
输出:5
解释:
初始状态下每个城市的电量:
- 城市 0: stations[0] + stations[1] = 1 + 2 = 3
- 城市 1: stations[0] + stations[1] + stations[2] = 1 + 2 + 4 = 7
- 城市 2: stations[1] + stations[2] + stations[3] = 2 + 4 + 5 = 11
- 城市 3: stations[2] + stations[3] + stations[4] = 4 + 5 + 0 = 9
- 城市 4: stations[3] + stations[4] = 5 + 0 = 5
最小电量是 3(城市 0)。
如果在城市 1 新建 2 个电站:
- 城市 0: 3 + 2 = 5
- 城市 1: 7 + 2 = 9
- 城市 2: 11 + 2 = 13
- 城市 3: 9
- 城市 4: 5
所有城市电量最小值变成 5。
6.2 问题分析
这是一个典型的"最大化最小值"问题,而且难度更高,因为:
- 电站有覆盖范围,不是简单的一对一
- 需要计算每个城市的初始电量(区间和)
- check 函数需要模拟新建电站的过程
思路转换:
- 原问题:“所有城市电量的最小值最大是多少?” → 难以直接求解
- 新问题:“能否通过新建最多 k 个电站,让所有城市电量都至少达到 X?” → 可以贪心检查
6.3 解题步骤
第一步:确定搜索范围
# 最小值:初始状态下的最小电量
left = 0
# 最大值:所有电站之和 + 新建的 k 个电站
right = sum(stations) + k
第二步:计算每个城市的初始电量
由于电站的覆盖范围是 r,城市 i 的电量 = stations[i-r] + … + stations[i+r]。
这是一个典型的滑动窗口问题:
def get_initial_power(stations, r):
n = len(stations)
power = [0] * n
# 使用滑动窗口计算每个城市的初始电量
window_sum = sum(stations[max(0, i - r):min(n, i + r + 1)]
for i in range(1))
for i in range(n):
# 计算城市 i 的覆盖范围 [i-r, i+r]
left_idx = max(0, i - r)
right_idx = min(n - 1, i + r)
power[i] = sum(stations[left_idx:right_idx + 1])
return power
第三步:设计 check(mid) 函数
判断"能否用最多 k 个电站,让所有城市电量都至少达到 mid":
核心思想:
- 从左到右遍历每个城市
- 如果当前城市电量 < mid,需要新建电站
- 贪心策略:在能覆盖当前城市的最右侧位置建电站(这样能同时帮助后面的城市)
- 使用差分数组高效处理区间增加
def check(mid):
# 复制一份电站数组,用于模拟新建
add = [0] * n # 新建的电站数量
power = 0 # 当前位置的累计电量
used = 0 # 已使用的新建电站数
# 初始化第一个城市的电量
for j in range(min(n, r + 1)):
power += stations[j] + add[j]
for i in range(n):
# 更新滑动窗口
if i > 0:
# 移出左边界
left_out = i - r - 1
if left_out >= 0:
power -= stations[left_out] + add[left_out]
# 加入右边界
right_in = i + r
if right_in < n:
power += stations[right_in] + add[right_in]
# 如果当前城市电量不足
if power < mid:
need = mid - power
# 在最右侧能覆盖当前城市的位置建电站
build_pos = min(n - 1, i + r)
add[build_pos] += need
power += need
used += need
if used > k:
return False
return True
为什么在最右侧建电站?
- 电站建在位置 j,能覆盖 [j-r, j+r] 范围
- 如果城市 i 电量不足,可以在 [i-r, i+r] 范围内任意位置建电站
- 选择 min(n-1, i+r)(最右侧)能让这个电站同时帮助 i 后面的城市
- 这是贪心策略:让每个电站的价值最大化
6.4 完整代码实现
def maxPower(stations, r, k):
n = len(stations)
def check(min_power):
"""检查能否让所有城市电量都达到 min_power"""
add = [0] * n # 新建的电站
power = 0 # 当前滑动窗口的电量和
used = 0 # 已使用的电站数
# 初始化城市 0 的电量
for j in range(min(n, r + 1)):
power += stations[j]
for i in range(n):
# 维护滑动窗口 [i-r, i+r]
if i > 0:
# 移除左边界
if i - r - 1 >= 0:
power -= stations[i - r - 1] + add[i - r - 1]
# 添加右边界
if i + r < n:
power += stations[i + r] + add[i + r]
# 当前城市电量不足
if power < min_power:
need = min_power - power
# 在最右侧能覆盖当前城市的位置建电站
build_pos = min(n - 1, i + r)
add[build_pos] += need
power += need
used += need
if used > k:
return False
return True
# 计算初始最小电量作为 left
power = [0] * n
for i in range(n):
for j in range(max(0, i - r), min(n, i + r + 1)):
power[i] += stations[j]
left = min(power)
right = sum(stations) + k
# 二分搜索
while left < right:
mid = (left + right + 1) // 2
if check(mid):
left = mid
else:
right = mid - 1
return left
# 测试
stations = [1, 2, 4, 5, 0]
r = 1
k = 2
print(maxPower(stations, r, k)) # 输出: 5
6.5 Go 语言实现
func maxPower(stations []int, r int, k int) int64 {
n := len(stations)
// 计算初始电量
power := make([]int64, n)
for i := 0; i < n; i++ {
for j := max(0, i-r); j < min(n, i+r+1); j++ {
power[i] += int64(stations[j])
}
}
// 找到初始最小电量
left := power[0]
for _, p := range power {
if p < left {
left = p
}
}
// 计算最大可能电量
right := int64(0)
for _, s := range stations {
right += int64(s)
}
right += int64(k)
// 二分搜索
for left < right {
mid := (left + right + 1) / 2
if check(stations, r, k, mid) {
left = mid
} else {
right = mid - 1
}
}
return left
}
func check(stations []int, r int, k int, minPower int64) bool {
n := len(stations)
add := make([]int64, n)
power := int64(0)
used := int64(0)
// 初始化城市 0 的电量
for j := 0; j < min(n, r+1); j++ {
power += int64(stations[j])
}
for i := 0; i < n; i++ {
// 维护滑动窗口
if i > 0 {
if i-r-1 >= 0 {
power -= int64(stations[i-r-1]) + add[i-r-1]
}
if i+r < n {
power += int64(stations[i+r]) + add[i+r]
}
}
// 电量不足,需要新建
if power < minPower {
need := minPower - power
buildPos := min(n-1, i+r)
add[buildPos] += need
power += need
used += need
if used > int64(k) {
return false
}
}
}
return true
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
6.6 关键技巧总结
这道题综合了多个技巧:
- 滑动窗口:高效计算每个城市的电量(区间和)
- 贪心策略:电站建在最右侧,让效益最大化
- 差分思想:虽然代码中直接维护 add 数组,但本质是差分的思想
- 二分答案:将"求最优值"转换为"判断可行性"
时间复杂度: O(n * log(sum + k))
- 二分搜索:O(log(sum + k))
- 每次 check:O(n)
七、常见问题类型汇总
| 问题类型 | 二分目标 | check 函数判断 | 典型题目 |
|---|---|---|---|
| 分配问题 | 最大工作量的最小值 | 能否用 k 个人完成 | 分配任务、分配书籍 |
| 切割问题 | 最大长度 | 能否切出 k 根 | 切割木材、绳子 |
| 距离问题 | 最小距离的最大值 | 能否满足距离要求 | 放置磁铁、安排座位 |
| 资源分配 | 最小资源的最大值 | 能否满足所有需求 | 供电问题、供水问题 |
| 速度问题 | 最小速度 | 能否在规定时间完成 | 运输货物、完成任务 |
七、解题技巧总结
7.1 如何确定 mid 的计算方式?
最大化最小值问题:
mid = (left + right + 1) // 2 # 向上取整
if check(mid):
left = mid # 可行,尝试更大
else:
right = mid - 1 # 不可行,降低要求
最小化最大值问题:
mid = (left + right) // 2 # 向下取整
if check(mid):
right = mid # 可行,尝试更小
else:
left = mid + 1 # 不可行,提高限制
7.2 check 函数常用技巧
- 贪心策略:尽可能充分利用每个资源
- 滑动窗口:处理区间覆盖问题
- 差分数组:处理区间修改问题
- 前缀和:快速计算区间和
7.3 时间复杂度分析
- 二分次数:O(log(right - left))
- 每次 check:O(n)
- 总复杂度:O(n * log(right - left))
通常比暴力枚举的 O(n²) 甚至 O(2^n) 快得多!
八、练习建议
掌握这个套路需要三步:
- 识别模式:看到题目能立刻想到"这是二分答案"
- 设计 check:这是难点,需要结合贪心、滑动窗口等技巧
- 注意细节:搞清楚是向上还是向下取整,left/right 如何更新
建议从简单题目开始练习:
- LeetCode 875(爱吃香蕉的珂珂)
- LeetCode 1011(在 D 天内送达包裹的能力)
- LeetCode 410(分割数组的最大值)
- LeetCode 1482(制作 m 束花所需的最少天数)
祝你早日掌握这个强大的算法套路!
