引入
最大值最小化问题, 是一类选择一种合适的方案, 使得某个数据的最大值尽可能小的问题. 比如:
(Leetcode 410) 给定一个非负整数数组
nums和一个整数k,你需要将这个数组分成k个非空的连续子数组,使得这k个子数组各自和的最大值 最小。
意思是, 我们需要选择一种合适的分法, 使得所有子数组之和中, 最大的那一个子数组和尽可能小. 最大值变小了, 其它的子数组和也会相应变小, 以不超过新的最大值. 相当于将子数组和整体往小压缩.
因此, 下面的问法也是等价的:
(Leetcode 475) 你的任务是设计一个有固定加热半径的供暖器向所有房屋供暖, 在加热器的加热半径范围内的每个房屋都可以获得供暖。
给出位于一条水平线上的房屋
houses和供暖器heaters的位置,请你找出并返回可以覆盖所有房屋的最小加热半径。
问题中并没有最大值的字样. 但根据题意, 每台供暖器的加热半径是固定的, 我们不能漏掉任何一个房屋的供暖, 所以应当确定的是半径的最大值才能保证全覆盖. 然后, 我们想让这个最大值尽可能小, 因此这也是最大值最小化问题.
当然, 如果值太过小, 就无法全覆盖了. 因此左边界是有限制的.
同理, 我们也有最小值最大化问题, 含义是类似的. 在实际场景中, 最大值最小化往往是使成本或耗时尽可能减小, 而最小值最大化是使收益尽可能增大.
接下来我们逐步从基本的二分查找开始, 循序渐进得到该问题的解法.
从二分查找开始
我们默认读者了解基本的二分查找, 即在一个有序数组中, 用的时间复杂度查找某个数对应的下标. 以下是一个基本的二分查找模板:
// 在vec中查找值等于num的数的下标int binary_search(vector<int>& vec, int num) { if (vec.empty()) return -1; // 防止下一行溢出 int left = 0, right = vec.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; if (vec[mid] == num) { return mid; } else if (vec[mid] < num) { left = mid + 1; } else if (vec[mid] > num) { right = mid - 1; } } return -1;}接下来我们思考另一个问题: 在值可能不唯一的数组中, 如何查找相等值中最左边的那一个?
首先, 我们通过原本算法搜索到的下标结果不一定表示最左边的值. 因此在搜索到结果后, 应当进一步往左搜索, 希望能找到更左的值.
我们用 ans 变量存储搜索到的值. 当 vec[mid] == num 时, 我们不直接 return mid , 而是存储 ans = mid , 然后令 right = mid - 1 , 进一步向左搜索.
那么如何判断是否还有更左的值呢? 我们只需要在 while (left <= right) 的条件下继续跑完整个二分流程, 直到退出循环. 此时的 ans 就是最左值的下标. 因为:
- 如果
right = mid - 1后计算出新的mid依然位于num的位置, 就更新ans, 继续向左搜索. - 如果发现新的
mid跑的太远, 已经比最左边的num还要左了, 就通过原本的left = mid + 1;把它往右拉回来.
此外, 假如一个等于 num 的数都没有, 应当返回 -1 , 因此我们令 ans 的初始值为 -1 , 如果循环结束之前都没有更新过 ans , 就原样返回.
最终的代码如下:
// 在vec中查找最左侧的值等于num的数的下标int search_left(vector<int>& vec, int num) { if (vec.empty()) return -1; int ans = -1; int left = 0, right = vec.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; if (vec[mid] == num) { ans = mid; right = mid - 1; } else if (vec[mid] < num) { left = mid + 1; } else if (vec[mid] > num) { right = mid - 1; } } return ans;}如果寻找最右侧的下标, 只需要在 else 分支改为 left = mid + 1 即可.
循序渐进
现在我们再进一步. 如果问的是数组中第一个大于num的数呢?
此时我们判断结果是否能存储到 ans 的条件就不是 vec[mid] == num 了, 只要 vec[mid] > num 就可以向左继续搜索. 这样判断分支只有两个:
if (vec[mid] > num) { ans = mid; right = mid - 1;} else { left = mid + 1;}在这里, 第一个大于num的数就相当于我们之前找的最左侧的值.
此时其实就相当于C++ STL中 upper_bound() 的功能.
如果判断逻辑更为复杂, 我们可以提取为check()函数, 形如:
if (check(vec[mid])) { ans = mid; right = mid - 1;} else { left = mid + 1;}这已经非常接近最大值最小化问题解法的终极形态了.
更进一步, 如果我们不是在一个数组中搜索, 而是直接在某个范围内找到符合条件的数的最小值呢?
我们只需要从用数组的下标来搜索改为直接用数来进行搜索:
// 在[L, R]区间内搜索符合check()的最小值int search(int L, int R) { int ans = -1; int left = L, right = R; while (left <= right) { int mid = left + (right - left) / 2; if (check(mid)) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans;}非常好! 事实上, 现在我们的代码已经是一个最大值最小化的模板了.
核心
为什么这就是最大值最小化问题了? 因为在文章开头, 我们定义这是一类选择一种合适的方案, 使得某个数据的最大值尽可能小的问题. 而现在:
- 通过不断二分搜索, 我们不断逼近最小的值
- 通过
check(), 我们能判断一个值是仍在合理区间内, 还是已经突破下限不合题意了(如供暖半径无法全覆盖).
因此我们实际上已经进入到了最大值最小化问题的领域内.
单调性
之所以查找第一个大于num的数的逻辑可以直接用于这类问题, 是因为他们的问题都具有一个关键性质: 单调性. 也就是说:
- 如果某个值
m是可行的, 那么大于m的值一定也是可行的. - 如果某个值
n是不可行的, 那么小于n的值也一定是不可行的.
比如如果一个数50大于所需的 num 20, 那么比50大的数一定也大于20; 如果一个供暖半径3无法覆盖所有房屋, 更小的半径2一定更无法覆盖.
用 check() 来说, 就是
- 如果存在
m, 使得check(m) == true, 那么对于所有x > m, 有check(x) == true - 如果存在
n, 使得check(n) == false, 那么对于所有y < n, 有check(y) == false
解法
二分搜索部分的思路都是大致相同的, 此类问题的关键在于如何编写 check() 函数. 一般的方法是贪心或动态规划.
用一个实际的例子说明:
有 个任务, 时长分别为 , 分配给 个人, 如何总时长最多的那个人的总时长尽可能小?
规定任务是连续分配的, 不会出现同一个人完成的情况
我们需要使总时长的最大值最小化. 为此, 我们应当对范围内所有的总时长最大值 limit 进行搜索. 对于每个 limit , 判断其是否合理, 即判断 check(limit) 是否为 true . 我们需要找到一种判断的方法.
考虑如下的贪心算法:
- 对于一个
limit, 遍历A(存放每个任务时长的数组)中的每个task_time. - 把
task_time累加起来, 直到超出limit, 此时说明某个人的总时长到极限了, 必须换下一个人分配任务. 将人数计数器count += 1. - 如果所有
task_time遍历完(分配完任务),count未超过人数总数m, 说明可行, 返回true. 如果任何时候发现count > m, 说明limit太小了, 就返回false.
代码如下:
bool check(int limit, int m, vector<int>& A) { // 显然check函数除了limit还可以设置其它参数 int count = 1; // 当前需要的人数 int current_sum = 0;
for (int task_time : A) { if (current_sum + task_time <= limit) { current_sum += task_time; } else { ++count; current_sum = task_time; // 如果人数超过了限制,说明 limit 定小了 if (count > m) return false; } } return true;}最难的一步完成了. 接下来是二分搜索的部分. 我们需要确定搜索的范围.
- 左边界
L:limit最小的可能性. 至少要等于时间最长的那个任务, 即. 比如A = [5, 1, 1, 1]时, 至少也需要limit = 5. - 右边界
R: 最坏的情况, 所有任务都分给一个人做, 即.
如果边界判断不准, 可以设置的稍微大一些, 也能得到正确的结果. 甚至即使设置为能涵盖整个样例输入的范围, 也不会对性能产生太大影响(毕竟是).
得到搜索代码如下:
int search(int m, vector<int>& A) { // 单次遍历获取左边界max和右边界sum int max_task = 0; int sum = 0; for (int task_time : A) { if (task_time > max_task) max_task = task_time; sum += task_time; } // 实际题目中需注意输入数据范围
int left = max_task, right = sum; int ans = -1; while (left <= right) { int mid = left + (right - left) / 2; if (check(mid, m, A)) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans;}另外, 由于本题始终有解, 令 limit = R 时一定是可行的, 因此不存在返回 ans = -1 的情况, 可以初始化 int ans = right . 进一步地, 在当前这种写法下, ans 的最终值必然等于 left , 因此可以不设置 ans 变量, 直接返回 left .
但是, 如果题目不一定有解, 则需要 ans = -1 来判断. 因此当前写法更为通用和清晰.
如果是最小值最大化问题, 只需要反转 search() 中的移动逻辑:
if (check(mid, m, A)) { ans = mid; left = mid + 1;} else { right = mid - 1;}总结
最大值最小化问题或最小值最大化问题可以归纳为三步:
- 写
check(). 方法根据题目而定, 各显神通awa - 定边界. 宁可大不可小.
- 套模板. 默写二分查找代码.
开头的两道Leetcode题目可作为练习.
暂无评论,快来发表第一条评论吧!
请登录后评论
登录后即可发表评论,参与讨论