这类题目可能的解空间在一个范围内,不停二分遍历可能解。找到一个可行解后还要继续找,直到“最小值”的可行解。类比到二分搜索算法中就是满足条件的最左值。
以”410. 分割数组的最大值“问题为例,可能解空间在“最大的单个元素”和“所有元素和”之间。然后二分查找可行解空间,一个可行解是分组数等于或者小于目标组数。“小于目标组数”成立的原因是可以继续拆分,拆分后子数组元素和一定满足条件,显然这不一定是最优解。找到可行解后如果继续优化条件不成立,那么上一次的可行解就是最优解。
从可行解中找最左值“二分搜索”中边界条件很容易出错。比如下面找最左值的方式:
while (left < right) {
int mid = left + (right - left) / 2;
int splits = split(nums, mid);
if (splits > m) {
// 如果分割数太多,说明「子数组各自的和的最大值」太小,此时需要将「子数组各自的和的最大值」调大
// 下一轮搜索的区间是 [mid + 1, right]
left = mid + 1;
} else {
// 下一轮搜索的区间是上一轮的反面区间 [left, mid]
right = mid;
}
}
个人更喜欢下面的方式,容易理解,不易出错,缺点是代码有点冗余。
while(left <= right) {
int mid = left + (right - left) / 2;
int groups = split(nums, mid);
if(groups > k) { // 分组和太小,分组数大于预定值
left = mid + 1;
} else if(groups < k) { // 分组和太大,分组数小于预定值
right = mid - 1;
} else { // 可能分组数 < k 走不到这里,因为 split 是贪心算法。但实际上可以从 < k 拆分到 k 组
if(mid == max || split(nums, mid -1) > k) {
return mid;
} else {
right = mid - 1;
}
}
}
package org.stone.study.algo.ex202403;
/**
* [410. 分割数组的最大值](https://leetcode.cn/problems/split-array-largest-sum/)
* * 给定一个非负整数数组 nums 和一个整数 k ,你需要将这个数组分成 k 个非空的连续子数组。
* 设计一个算法使得这 k 个子数组各自和的最大值最小。
*/
public class SplitArrayWithMinSum {
public static void main(String[] args) {
int[] nums = new int[] {7,2,5,10,8};
int k = 2;
// ans: 18
System.out.println("ans:" + new SplitArrayWithMinSum().splitArray(nums, k));
}
/**
* 给定一个非负整数数组 nums 和一个整数 k ,把这个数组分成 k 个非空的连续子数组。
* 找到一种分组方式,使得返回k 个子数组各自和的最大值最小
* @param nums
* @param k
* @return
*/
public int splitArray(int[] nums, int k) {
int max = 0, sum = 0;
for(int num : nums) {
max = Math.max(max, num);
sum += num;
}
int left = max, right = sum;
while(left <= right) {
int mid = left + (right - left) / 2;
int groups = split(nums, mid);
//System.out.println("left:" + left + ", right:" + right + ", mid:" + mid + ", groups:" + groups);
if(groups > k) { // 分组和太小,分组数大于预定值
left = mid + 1;
} else if(groups < k) { // 分组和太大,分组数小于预定值
right = mid - 1;
} else { // 可能分组数 < k 走不到这里,因为 split 是贪心算法。但实际上可以从 < k 拆分到 k 组
if(mid == max || split(nums, mid -1) != k) {
return mid;
} else {
right = mid - 1;
}
}
}
return left;
}
/**
* 拆分数组,每组和最大为maxSum。返回可以拆分的组数
* 直接贪心算法实现
*/
private int split(int[] nums, int maxSum) {
int curSum = 0;
int splitNum = 1;
for(int num : nums) {
if(curSum + num > maxSum) {
++splitNum;
curSum = 0;
}
curSum += num;
}
return splitNum;
}
}
package org.stone.study.algo.ex202403;
import java.util.Arrays;
/**
* [875. 爱吃香蕉的珂珂](https://leetcode.cn/problems/koko-eating-bananas/)
* 珂珂喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 h 小时后回来。
* 珂珂可以决定她吃香蕉的速度 k (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 k 根。如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
* 珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
* 返回她可以在 h 小时内吃掉所有香蕉的最小速度 k(k 为整数)。
*/
public class MinEatingSpeed {
/**
* 解法 1:二分查找最左值边界处理好理解一点。
* @param piles
* @param h
* @return
*/
public int minEatingSpeed(int[] piles, int h) {
int max = Arrays.stream(piles).max().getAsInt();
int left = 1, right = max;
while(left <= right) {
int mid = left + (right - left) / 2;
long spent = spentHours(piles, mid);
if(spent > h) {
left = mid + 1;
} else if(spent < h){
right = mid - 1;
} else {
//System.out.println("spent:" + spent + ", left:" + left + ", right:" + right);
if(mid == 1 || spentHours(piles, mid - 1) > h) {
return mid;
} else {
right = mid - 1;
}
}
}
//System.out.println("left:" + left + ", right:" + right);
return left;
}
/**
* 以速度 speed 依次消耗 piles 数组。不足去整(往大的取)
*/
private long spentHours(int[] piles, int speed) {
long spent = 0;
for(int pile : piles) {
spent += (pile + speed - 1) / speed;
}
return spent;
}
/**
* 解法 2,更简洁,但是二分查找最左值边界理解上有点困难
* @param piles
* @param h
* @return
*/
public int minEatingSpeed2(int[] piles, int h) {
int max = 0;
for(int p : piles) {
max = Math.max(max, p);
}
int l = 1, r = max, ans = max;
while(l <= r) {
int total = 0;
int m = l + (r -l) / 2;
for(int p : piles) {
total += (p-1)/m + 1; //向上取整
}
if(total > h) {
l = m + 1;
} else {
ans = m;
r = m - 1;
}
}
return ans;
}
}
通用的二分查找值,最左值,最右值方法请参考1
package org.stone.study.algo.search;
/**
* @Author: shidonghua
* @Description:
* @Date: 3/8/21 09:07
* @Version: 1.0
*/public class BinarySearch {
/**
* 二分搜索,并找最左边的元素
*
* @param a
* @param n
* @param value
* @return
*/
public int bsearch_LeftMost(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == 0) || (a[mid - 1] != value)) return mid; // 最左逼近
else high = mid - 1;
}
}
return -1;
}
/**
* 二分搜索最右边的元素
*
* @param a
* @param n
* @param value
* @return
*/
public int bsearch_RightMost(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == n - 1) || (a[mid + 1] != value)) return mid; // 往右边逼近
else low = mid + 1;
}
}
return -1;
}
/**
* 第一个大于等于元素
*
* @param a
* @param n
* @param value
* @return
*/
public int bsearch_GE(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] >= value) {
if ((mid == 0) || (a[mid - 1] < value)) return mid;
else high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
/**
* 最后一个小于等于
*
* @param a
* @param n
* @param value
* @return
*/
public int bsearch_LE(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else {
if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
else low = mid + 1;
}
}
return -1;
}
}