二分搜索应用 II
1. 题目列表
题目列表:
序号 | 题目 | 难度 |
---|---|---|
1 | 410. 分割数组的最大值 | 困难 |
2 | 658. 找到 K 个最接近的元素 | 中等 |
3 | 2594. 修车的最少时间 | 中等 |
4 | 2560. 打家劫舍 IV | 中等 |
5 | 162. 寻找峰值 | 中等 |
6 | 1552. 两球之间的磁力 | 中等 |
2. 应用
2.1. Leetcode 410. 分割数组的最大值
2.1.1. 题目
给定一个非负整数数组 \(nums\) 和一个整数 \(k\) ,你需要将这个数组分成 \(k\) 个非空的连续子数组。设计一个算法使得这 \(k\) 个子数组各自和的最大值最小。
示例 1:
输入:\(nums = [7,2,5,10,8]\), \(k = 2\)
输出:\(18\)
解释:
一共有四种方法将 nums 分割为 2 个子数组。其中最好的方式是将其分为 \([7,2,5]\) 和 \([10,8]\) 。因为此时这两个子数组各自的和的最大值为 \(18\),在所有情况中最小。
提示:
\(1 <= nums.length <= 1000\)
\(0 <= nums[i] <= 10^6\)
\(1 <= k <= min(50, nums.length)\)
2.1.2. 解题思路
题目中要求将数组 \(nums\) 分成 \(k\) 个非空连续子数组,使得每个子数组的和尽可能小,同时,分割的子数组个数又恰好等于 \(k\),那么,对于分割的方式必然存在单调性,因此,我们可以使用二分搜索的方式求解。
设数组 \(nums\) 的长度为 \(n\),显然,每个区间和的最小值就是数组中的最大值,同时,不超过数组所有元素的和,那么
-
二分查找的右侧区间就是 \(left = \max_{i=0}^{n}nums[i]\);
-
二分查找的右侧区间就是 \(right = \sum_{i=0}^{n}nums[i]\)。
那么,只需要在闭区间 \([left, right] = [\max_{i=0}^{n}nums[i], \ \sum_{i=0}^{n}nums[i]]\) 内,多次进行二分查找,只要某一个数组和恰好可以使数组分割为 \(k\),即可退出查找。
2.1.3. 代码实现
class Solution {
public int splitArray(int[] nums, int k) {
int sum = 0;
int maxNum = 0;
for (int num : nums) {
sum += num;
maxNum = Math.max(maxNum, num);
}
int left = maxNum;
int right = sum;
while (left < right) {
int mid = left + (right - left) / 2;
// 分割次数大于k,说明每一段的目标和太小了,需要缩小左区间
if (check(nums, k, mid)) {
left = mid + 1;
} else {
right = mid;
}
}
return right;
}
private boolean check(int[] nums, int k, int target) {
int splitCount = 1; // 以当前的区间中点作为子数组的和,可以分割的次数
int sum = 0;
for (int num : nums) {
if (sum + num <= target) {
sum += num;
} else {
// 若分割次数大于k,则需要缩小区间左边界
splitCount++;
if (splitCount > k) {
return true;
}
sum = num;
}
}
return false;
}
}
2.2. Leetcode 658. 找到 K 个最接近的元素
2.2.1. 题目
给定一个 排序好 的数组 arr ,两个整数 k 和 x ,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。
整数 a 比整数 b 更接近 x 需要满足:
- \(|a - x| < |b - x|\) 或者
- \(|a - x| == |b - x|\) 且 \(a < b\)
示例 1:
输入:arr = [1,2,3,4,5], k = 4, x = 3
输出:[1,2,3,4]
2.2.2. 解题思路
假设数组 \(nums\) 的长度为 \(n\),由于原始的数组 \(nums\) 是有序的,因此,我们可以利用二分查找的思路,找到一个满足条件的,且长度为 \(k\) 的数组子区间:
跟查找一个具体的数值不同的是,这里我们需要查找一个符合条件的子区间,并且在查找过程中,因为我们希望找到与 \(x\) 差值更小并且数组的索引更小,所以,我们只需要找到这个子区间的左侧端点即可,这样,题目就等价于通过二分查找求左边界的问题了,因此,我们可以将初始查找区间设置为:\([0, n - 1 - k]\)。
这样,每次比较的时候,需要比较下一个区间的起点 \(nums[mid]\) 和区间终点 \(nums[mid + k]\) 与 \(x\) 的差值,即可缩小差值范围了。
2.2.3. 复杂度
-
时间复杂度: \(O(logn + k)\)
-
空间复杂度: \(O(n)\)
2.2.4. 代码实现
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
int left = 0, right = arr.length - k - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (x - arr[mid] <= arr[mid + k] - x) {
right = mid - 1;
} else {
left = mid + 1;
}
}
List<Integer> result = new ArrayList<>();
for (int i = left; i < left + k; i++) {
result.add(arr[i]);
}
return result;
}
}
2.3. Leetcode 2594. 修车的最少时间
2.3.1. 题目
给你一个整数数组 ranks ,表示一些机械工的 能力值 。\(ranks_i\) 是第 i 位机械工的能力值。能力值为 r 的机械工可以在 \(r \times n^2\) 分钟内修好 n 辆车。
同时给你一个整数 cars ,表示总共需要修理的汽车数目。
请你返回修理所有汽车 最少 需要多少时间。
注意:所有机械工可以同时修理汽车。
示例 1:
输入:ranks = [4,2,3,1], cars = 10
输出:16
解释:
- 第一位机械工修 2 辆车,需要 4 * 2 * 2 = 16 分钟。
- 第二位机械工修 2 辆车,需要 2 * 2 * 2 = 8 分钟。
- 第三位机械工修 2 辆车,需要 3 * 2 * 2 = 12 分钟。
- 第四位机械工修 4 辆车,需要 1 * 4 * 4 = 16 分钟。
16 分钟是修理完所有车需要的最少时间。
2.3.2. 解题思路
对于能力值为 \(r\) 的工人可以在 \(r \times n^2\) 分钟内修好 \(n\) 辆车,那么,他在 \(t\) 分钟可以修好的车量数为:
对于修车的时间 \(t\) 必然存在单调性,即
-
如果 \(t\) 分钟内,所有的汽车都可以修理完,那么,大于等于 \(t\) 分钟时,也可以将所有汽车修理完;
-
如果 \(t\) 分钟内,所有的汽车都不能修理完,那么,小于等于 \(t\) 分钟时,也不能将所有汽车修理完。
那么,我们可以枚举时刻来查找满足条件的时间。
二分查找的边界:
-
下界:由于车辆最少有 \(1\) 辆,所以,修改好一辆车至少需要一分钟,因此,下边界为 \(1\)。
-
上界:任何一个人独立完成工作的所需的时间,必然大于所有人一起完成工作所需的时间,因此,可以取任意一个人独立完成所需的时间为上边界。
所以,二分查找的区间范围:
其中,\(k\) 为数组 \(ranks\) 的长度。
因此,我们可以在闭区间内使用二分查找,找到满足条件的左边界即可。
2.3.3. 复杂度
-
时间复杂度: \(O(nlog(rank[0] \times cars ^ 2))\)
其中,\(n\) 是数组 \(ranks\) 的长度。
-
空间复杂度: \(O(1)\)
2.3.4. 代码实现
class Solution {
public long repairCars(int[] ranks, int cars) {
// 任何一个工人修完所有车辆的时间一定大于等于最优解
long left = 1, right = (long) ranks[0] * cars * cars;
while (left < right) {
long mid = left + (right - left) / 2;
if (check(ranks, cars, mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
private boolean check(int[] ranks, int cars, long minute) {
long count = 0;
for (int rank : ranks) {
count += (long) Math.sqrt(1.0 * minute / rank);
}
return count >= cars;
}
}
2.4. Leetcode 2560. 打家劫舍 IV
2.4.1. 题目
沿街有一排连续的房屋。每间房屋内都藏有一定的现金。现在有一位小偷计划从这些房屋中窃取现金。
由于相邻的房屋装有相互连通的防盗系统,所以小偷 不会窃取相邻的房屋 。
小偷的 窃取能力 定义为他在窃取过程中能从单间房屋中窃取的 最大金额 。
给你一个整数数组 nums 表示每间房屋存放的现金金额。形式上,从左起第 i 间房屋中放有 nums[i] 美元。
另给你一个整数 k ,表示窃贼将会窃取的 最少 房屋数。小偷总能窃取至少 k 间房屋。
返回小偷的 最小 窃取能力。
示例 1:
输入:nums = [2,3,5,9], k = 2
输出:5
解释:
小偷窃取至少 2 间房屋,共有 3 种方式:
- 窃取下标 0 和 2 处的房屋,窃取能力为 max(nums[0], nums[2]) = 5 。
- 窃取下标 0 和 3 处的房屋,窃取能力为 max(nums[0], nums[3]) = 9 。
- 窃取下标 1 和 3 处的房屋,窃取能力为 max(nums[1], nums[3]) = 9 。
因此,返回 min(5, 9, 9) = 5 。
2.4.2. 解题思路
通过分析,容易看出,小偷的偷窃能力 \(capability\) 范围是:
对于每一个能力值 \(capability\) ,它显然满足单调性,即:
-
大于能力值的房间都不能偷窃;
-
小于能力值的房间,且其相邻房间未被偷窃,那么,它可以被偷窃。
因此,我们可以考虑使用二分查找,满足条件的最小能力值。
注意,在查找过程中,我们需要用到贪心的思想,即对于每一个能力值,越早偷窃,留给剩余房子偷窃的机会就越多。
如果房间数量为 \(n\),那么,能偷窃房间不超过 \(\frac{n}{2}\),因此,在偷窃过程中,遇到能偷窃的房间,需要尽可能地多偷窃更多的房间。
2.4.3. 复杂度
-
时间复杂度: \(O(nlog(\max^{n}_{i=0}(nums[i]) - \min^{n}_{i=0}(nums[i]))\)
-
空间复杂度: \(O(1)\)
2.4.4. 代码实现
class Solution {
public int minCapability(int[] nums, int k) {
int left = Integer.MAX_VALUE, right = Integer.MIN_VALUE;
for (int num : nums) {
right = Math.max(right, num);
left = Math.min(left, num);
}
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(nums, k, mid)) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
private boolean check(int[] nums, int k, int capability) {
int count = 0;
boolean visited = false;
for (int num : nums) {
if (num <= capability && !visited) {
count++;
visited = true;
} else {
visited = false;
}
}
return count >= k;
}
}
2.5. Leetcode 162. 寻找峰值
2.5.1. 题目
峰值元素是指其值严格大于左右相邻值的元素。
给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。
你可以假设 \(nums[-1] = nums[n] = -\infty\) 。
你必须实现时间复杂度为 \(O(log n)\) 的算法来解决此问题。
示例 1:
输入:nums = [1,2,1,3,5,6,4]
输出:1 或 5
解释:
- 你的函数可以返回索引 1,其峰值元素为 2;
- 或者返回索引 5, 其峰值元素为 6。
2.5.2. 解题思路
从题意可知,峰值元素一定严格大于左右相邻值的元素。
题目中可能有多个峰值元素,那么,对于任意两个相邻的峰谷之间的局部元素它是满足单调性的,并且数组中没有相邻的重复元素,因此,我们可以通过二分查找,确定峰值的位置。
每次二分时区间的中点,必然落在某两个相邻的峰谷之间,即
-
如果元素出现在峰值的左侧,移动左指针;
-
如果元素出现在峰值的右侧,移动右指针;
这样,就可以通过二分查找,在 \(O(logn)\) 的复杂度内完成查找。
这里,我们可以将题目等价于一个在闭区间内查找左边界的二分查找,第一个不满足条件的元素,就是题目的答案之一。
类似地,我们也可以将其看成查找右边界的二分查找。
2.5.3. 复杂度
-
时间复杂度: \(O(log(n))\)
-
空间复杂度: \(O(1)\)
2.5.4. 代码实现
- 查找左边界的思路
class Solution {
public int findPeakElement(int[] nums) {
int n = nums.length;
if (n == 1) {
return 0;
}
int left = 0, right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
}
-
查找右边界的思路
参考:162. 寻找峰值
2.6. Leetcode 1552. 两球之间的磁力
2.6.1. 题目
2.6.2. 解题思路
假设数组 \(postion\) 长度为 \(n\),我们考虑先对数组 \(postion\) 排序。
那么,题目可以等价于:从有序数组 \(postion\) 中任意选择 \(m\) 个元素,对于这 \(m\) 个元素,我们对选出的相邻元素进行两两求差值可以得到 \(m - 1\) 个差值:
求所有组合中,最小的 \(f(i, j)\) 的最大值。
这是一个求解极小值极大化的题目,显然,为了使最小差值最大,我们每次需要挑选的两个数字 \((position[i], position[j])\) 的间距都要尽可能远。
假设所求的目标值为 \(target\),显然,这样的组合数求解具有二段性,即:
-
当 \(f_{min}(i,j) \gt target\) 时,能选择的数字个数必然小于 \(m\);
-
当 \(f_{min}(i,j) \le target\) 时,能选择的数字个数必然大于等于 \(m\)。
那么,我们使用二分查找求解满足条件的 \(f(i, j)\) 。
2.6.3. 复杂度
-
时间复杂度: \(O(nlogn + nlog(\max^n_{i=0}(postion[i])))\);
其中,排序需要的时间复杂度为 \(O(logn)\),二分查找的复杂度为 \(nlog(\max^n_{i=0}(postion[i]))\)
-
空间复杂度: \(O(logn)\)。
排序需要的栈空间,复杂度为 \(O(logn)\)。
2.6.4. 代码实现
class Solution {
public int maxDistance(int[] position, int m) {
Arrays.sort(position);
int n = position.length;
int result = -1;
int left = 0, right = position[n - 1] - position[0];
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(position, mid, m)) {
left = mid + 1;
result = mid;
} else {
right = mid - 1;
}
}
return result;
}
private boolean check(int[] position, int distance, int m) {
int last = position[0], count = 1;
for (int i = 1; i < position.length; i++) {
if (position[i] - last >= distance) {
last = position[i];
count += 1;
}
}
return count >= m;
}
}
3. 总结
二分查找不需要关注区间内的元素具有什么性质,而是需要关注区间外面的元素具有什么性质。
参考: