二分法从入门到入骨
原文地址:🌝是时候祭出我的「万能二分模板」了!
笔者在学习了甜姨的万能二分模版后极为震撼,该模版确实能将我们从各种边界判断中拯救出来,且在日常做题过程中屡试不爽。但是大家若是第一次接触难免会有一些疑虑:为什么最后退出 while 循环的时候需要做判断?如果数组中有重复数字时应该怎么缩小查找范围?等等。下面,笔者就为大家深入剖析一下。
1. 基础二分模版
基础二分模版代码如下:
public int rawBinarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1, mid = 0;
while (left <= right) {
mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
也许大家见过很多种类型的二分代码,且在使用时有各种各样边界判断的苦恼:
- 为什么在你的模版里 while 循环可以是 <= ,而在别的二分法里是 < ?
- 为什么在你的模版里是 left = mid + 1 、right = mid - 1,而在别的二分法里看到的是 left = mid 、right = mid - 1 或者 left = mid + 1 、right = mid?
- …
其实,基础二分法根本没有什么模版可言,各位在每到题解或者博客中看到的二分法是针对那道题或者那篇博文而言的,正所谓“一千道题就有一千种二分法”。其实大家如果把细扣代码或者进行单步调试就会解答心中的疑惑。那么是否有一个二分模版能够在任何情况下都能使用,且不出错呢?答案是有的。
2. 万能二分模版
二分万能模版如下:
public int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] < target) {
left = mid;
} else { // 注意这里的else 包含了 nums[mid] > target 和 nums[mid] == target 的情况
right = mid;
}
}
// 退出循环的时候需要判断
if (nums[left] == target) return left;
else if (nums[right] == target) return right;
return -1; // 没有找到
}
与常见的二分代码主要有以下几点区别:
- while 循环的判断条件固定为
left + 1 < right
- 缩小边界时统一使用
left = mid;
和right = mid;
,不用去考虑 mid + 1 或 mid - 1 - 退出循环时,需要针对
nums[left]
和nums[right]
做判断
甜姨的解释:万能模板其实就是不管啥问题,都通过 while 循环把数据区间逼近到 [left, right](其中,left + 1 == right)两个值,所以出来循环后只要判断一下 left 和 right 就行了。
- 首先我们的循环退出判断条件为
left + 1 < right
,这为一个区间,这是前提,也是最终退出循环后做判断的基础; - 其次,我们在循环中更新 left 或者 right 时不用再考虑 +1 、-1 等情况,因为 +1、-1 的目的是为了保证能够顺利退出循环而不会造成死循环。由于我们循环退出条件为
left + 1 < right
,有别于基础二分法的left <= right
,这里我们可以想象基础二分法会将数据区间逼近到一个数,即判断条件nums[mid] == target
,而万能模版是逼近的一个区间,由于我们每次都会更新 left 或者 right ,所以最终总会到达一个区间。 - 为什么退出循环时要做判断?举个例子,比如在数组
{1, 2, 3, 4, 5, 6, 7}
中寻找 1 的时候,按照万能模版的代码,退出循环的时候到达的区间是 [0,1],即nums[left] = 1
nums[right] = 2
,而此时我们要找的是nums[left]
;还是数组{1, 2, 3, 4, 5, 6, 7}
,这次我们要找2,万能模版退出循环到达的区间还是 [0,1],即nums[left] = 1
nums[right] = 2
,只不过这次我们要找的是nums[right]
。从这个简单的例子中就可以看出,虽然能够保证最终能够推出循环,且我们要找的目标值在 [left,right] ,但是到底是区间的第一个元素还是第二个元素,我们无法保证,需要在退出循环后自行验证。这样,我们就将整个数组找目标值的问题转换为在两个数之间找目标值,这样是不是 so easy 呢!
3. 万能二分模版的基本应用
3.1 寻找最左目标值的下标
要想找到最左(第一个)满足条件的值,我们其实是需要尽可能地往左边去寻找,也就是区间右边界尽可能地收缩,特别是当我们找到一个满足条件的值时。算法整体步骤如下:
- 如果当前值(mid 处的值)小于目标值,往右继续寻找,收缩左边界(left = mid);
- 如果当前值(mid 处的值)等于目标值,因为要找的是第一个满足条件的值,所以需要尽可能往左边去寻找,因此需要收缩右边界(right = mid)例如在
[1,2,3,3,3,4,5]
中寻找第一个 3 的下标:我们首先得到的是下标为 3 的那个 3,但是我们想要找的下标为 2 的那个 3,因此继续往左边去寻找,收缩右边界(right = mid) - 如果当前值(mid 处的值)大于目标值,往左继续寻找,收缩右边界(right = mid)
private static int getFirstEquals(int[] nums, int target) {
int length = nums.length;
int left = 0, right = length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] >= target) {
right = mid;
} else {
left = mid;
}
}
if (nums[left] == target) return left;
else if (nums[right] == target) return right;
return -1; // 返回 -1 表示在数组中没找到符合条件的值
}
3.2 寻找最右目标值的下标
同理,我们在寻找满足条件的最右值时,应该尽可能往右边去寻找,也就是左边界尽可能地收缩。算法整体步骤如下:
- 如果当前值(mid 处的值)小于目标值,往右继续寻找,收缩左边界(left = mid);
- 如果当前值(mid 处的值)等于目标值,因为要寻找的最后一个满足条件的值,所以需要尽可能地往右去寻找,因此此时需要收缩左边界(left = mid)
- 如果当前值(mid 处的值)大于目标值,往左继续寻找,收缩右边界(right = mid)
private static int getLastEquals(int[] nums, int target) {
int length = nums.length;
int left = 0, right = length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] <= target) {
left = mid;
} else {
right = mid;
}
}
if (nums[right] == target) return right;
else if (nums[left] == target) return left;
return -1; // 返回 -1 表示在数组中没找到符合条件的值
}
- 总结:不管是找最左还是最右,当 mid 处的值大于或小于 target 时,操作是一致的,小于往右走,大于往左走,只有等于的时候有些许不同。当等于的时候,具体往左还是往右看是找最左还是最右,找最左就继续往左走,找最右就继续往右走
3.3 拓展
类似地,我们可以求出第一个小于 target 的下标、第一个大于 target 的下标、最后一个小于 target的下标 、最后一个大于 target (无意义)。其实从下表中我们可以看出,除了最后求最后一个大于,其它情况中小于时均是往右走(收缩左边界),大于时均是往左走(收缩右边界),只有在等于的时候判断是往左还是往右走即可。而等于的时候我们可以根据“贪心”的思想确定是该往左还是往右。
- 求第一个小于
public int getFirstSmaller(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] >= target) {
right = mid;
} else {
left = mid;
}
}
// 退出循环的时候需要判断
if (nums[left] < target) return left;
else if (nums[right] < target) return right;
return -1; // 没有找到
}
- 求第一个大于
public int getFirstBigger(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] <= target) {
left = mid;
} else {
right = mid;
}
}
// 退出循环的时候需要判断
if (nums[left] > target) return left;
else if (nums[right] > target) return right;
return -1; // 没有找到
}
- 求最后一个小于
public int getLastSmaller(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] >= target) {
right = mid;
} else {
left = mid;
}
}
// 退出循环的时候需要判断
if (nums[right] < target) return right;
else if (nums[left] < target) return left;
return -1; // 没有找到
}
- 求最后一个大于 (无意义)
public int getLastBigger(int[] nums, int target) {
int right = nums.length - 1;
if (nums[right] > target) return right;
return -1;
}
4. 实战
4.1 有序矩阵中第 K 小的元素
给你一个 n x n 矩阵 matrix ,其中每行和每列元素均按升序排序,找到矩阵中第 k 小的元素。
请注意,它是 排序后 的第 k 小元素,而不是第 k 个 不同 的元素。
你必须找到一个内存复杂度优于 O(n2) 的解决方案。
示例 1:
输入:matrix = [[1,5,9],[10,11,13],[12,13,15]], k = 8
输出:13
解释:矩阵中的元素为 [1,5,9,10,11,12,13,13,15],第 8 小元素是 13
示例 2:
输入:matrix = [[-5]], k = 1
输出:-5
提示:
n == matrix.length
n == matrix[i].length
1 <= n <= 300
-109 <= matrix[i][j] <= 109
题目数据 保证 matrix 中的所有行和列都按 非递减顺序 排列
1 <= k <= n2
进阶:
你能否用一个恒定的内存(即 O(1) 内存复杂度)来解决这个问题?
你能在 O(n) 的时间复杂度下解决这个问题吗?这个方法对于面试来说可能太超前了,但是你会发现阅读这篇文章( this paper )很有趣。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/kth-smallest-element-in-a-sorted-matrix
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
4.2 乘法表中第k小的数
几乎每一个人都用 乘法表。但是你能在乘法表中快速找到第k小的数字吗?
给定高度m 、宽度n 的一张 m * n的乘法表,以及正整数k,你需要返回表中第k 小的数字。
例 1:
输入: m = 3, n = 3, k = 5
输出: 3
解释:
乘法表:
1 2 3
2 4 6
3 6 9
第5小的数字是 3 (1, 2, 2, 3, 3).
例 2:
输入: m = 2, n = 3, k = 6
输出: 6
解释:
乘法表:
1 2 3
2 4 6
第6小的数字是 6 (1, 2, 2, 3, 4, 6).
注意:
m 和 n 的范围在 [1, 30000] 之间。
k 的范围在 [1, m * n] 之间。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/kth-smallest-number-in-multiplication-table
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
4.3 找出第 k 小的距离对
给定一个整数数组,返回所有数对之间的第 k 个最小距离。一对 (A, B) 的距离被定义为 A 和 B 之间的绝对差值。
示例 1:
输入:
nums = [1,3,1]
k = 1
输出:0
解释:
所有数对如下:
(1,3) -> 2
(1,1) -> 0
(3,1) -> 2
因此第 1 个最小距离的数对是 (1,1),它们之间的距离为 0。
提示:
2 <= len(nums) <= 10000.
0 <= nums[i] < 1000000.
1 <= k <= len(nums) * (len(nums) - 1) / 2.
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/find-k-th-smallest-pair-distance
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
4.4 第 K 个最小的素数分数
给你一个按递增顺序排序的数组 arr 和一个整数 k 。数组 arr 由 1 和若干 素数 组成,且其中所有整数互不相同。
对于每对满足 0 <= i < j < arr.length 的 i 和 j ,可以得到分数 arr[i] / arr[j] 。
那么第 k 个最小的分数是多少呢? 以长度为 2 的整数数组返回你的答案, 这里 answer[0] == arr[i] 且 answer[1] == arr[j] 。
示例 1:
输入:arr = [1,2,3,5], k = 3
输出:[2,5]
解释:已构造好的分数,排序后如下所示:
1/5, 1/3, 2/5, 1/2, 3/5, 2/3
很明显第三个最小的分数是 2/5
示例 2:
输入:arr = [1,7], k = 1
输出:[1,7]
提示:
2 <= arr.length <= 1000
1 <= arr[i] <= 3 * 104
arr[0] == 1
arr[i] 是一个 素数 ,i > 0
arr 中的所有数字 互不相同 ,且按 严格递增 排序
1 <= k <= arr.length * (arr.length - 1) / 2
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/k-th-smallest-prime-fraction
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
4.5 两个有序数组的第 K 小乘积
给你两个 从小到大排好序 且下标从 0 开始的整数数组 nums1 和 nums2 以及一个整数 k ,请你返回第 k (从 1 开始编号)小的 nums1[i] * nums2[j] 的乘积,其中 0 <= i < nums1.length 且 0 <= j < nums2.length 。
示例 1:
输入:nums1 = [2,5], nums2 = [3,4], k = 2
输出:8
解释:第 2 小的乘积计算如下:
- nums1[0] * nums2[0] = 2 * 3 = 6
- nums1[0] * nums2[1] = 2 * 4 = 8
第 2 小的乘积为 8 。
示例 2:
输入:nums1 = [-4,-2,0,3], nums2 = [2,4], k = 6
输出:0
解释:第 6 小的乘积计算如下:
- nums1[0] * nums2[1] = (-4) * 4 = -16
- nums1[0] * nums2[0] = (-4) * 2 = -8
- nums1[1] * nums2[1] = (-2) * 4 = -8
- nums1[1] * nums2[0] = (-2) * 2 = -4
- nums1[2] * nums2[0] = 0 * 2 = 0
- nums1[2] * nums2[1] = 0 * 4 = 0
第 6 小的乘积为 0 。
示例 3:
输入:nums1 = [-2,-1,0,1,2], nums2 = [-3,-1,2,4,5], k = 3
输出:-6
解释:第 3 小的乘积计算如下:
- nums1[0] * nums2[4] = (-2) * 5 = -10
- nums1[0] * nums2[3] = (-2) * 4 = -8
- nums1[4] * nums2[0] = 2 * (-3) = -6
第 3 小的乘积为 -6 。
提示:
1 <= nums1.length, nums2.length <= 5 * 104
-105 <= nums1[i], nums2[j] <= 105
1 <= k <= nums1.length * nums2.length
nums1 和 nums2 都是从小到大排好序的。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/kth-smallest-product-of-two-sorted-arrays
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
LC 上有一类题有很强的二分属性,比如求最大的最小值、或者求最小的最大值这类的问题。这类题的解法是,先找出答案的上下届(left, right),然后利用二分法求中间值 mid, 并统计数组中符合中间值 mid 的解的数量是否符合要求,符合要求的话就尝试着扩大一点(求最大值)或者缩小一点(求最小值)
4.6 袋子里最少数目的球
class Solution {
public int minimumSize(int[] nums, int maxOperations) {
int max = 0;
for (int num : nums) max = Math.max(max, num);
int left = 1, right = max;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
int currOperations = 0;
for (int num : nums) {
currOperations += ((num - 1) / mid);
}
if (currOperations > maxOperations) {
left = mid;
} else {
right = mid;
}
}
int ops = 0;
for (int num : nums) {
ops += ((num - 1) / left);
}
if (ops <= maxOperations) return left;
return right;
}
}
4.7 两球之间的磁力
class Solution {
public int maxDistance(int[] position, int m) {
Arrays.sort(position);
int n = position.length;
int minDiff = Integer.MAX_VALUE;
for (int i = 1; i < n; i++) minDiff = Math.min(minDiff, position[i] - position[i - 1]);
int left = minDiff, right = (position[n - 1] - position[0]) / (m - 1);
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
if (check(position, mid, m, n)) {
left = mid;
} else {
right = mid;
}
}
if (check(position, right, m, n)) return right;
return left;
}
private boolean check(int[] position, int force, int m, int n) {
int pre = position[0];
int cnt = 1;
for (int i = 1; i < n; i++) {
if (position[i] - pre >= force) {
cnt++;
pre = position[i];
}
}
return cnt >= m;
}
}
4.8 制作 m 束花所需的最少天数
class Solution {
int n;
public int minDays(int[] bloomDay, int m, int k) {
n = bloomDay.length;
int left = Integer.MAX_VALUE, right = Integer.MIN_VALUE;
for (int b : bloomDay) {
left = Math.min(left, b);
right = Math.max(right, b);
}
while (left + 1 <right) {
int mid = left + ((right - left) >> 1);
if (check(bloomDay, m, k, mid)) {
right = mid;
} else {
left = mid;
}
}
if (check(bloomDay, m, k, left)) return left;
if (check(bloomDay, m, k, right)) return right;
return -1;
}
private boolean check(int[] bloomDay, int m, int k, int mid) {
int cnt = 0;
for (int i = 0; i < n; i++) {
if (bloomDay[i] <= mid) {
int j = i;
while (j < n && bloomDay[j] <= mid) j++;
cnt += (j - i) / k;
i = j;
}
}
return cnt >= m;
}
}
4.9 乘法表中第k小的数
class Solution {
public int findKthNumber(int m, int n, int k) {
int left = 1, right = m * n;
while (left + 1 < right) {
int mid = left + ((right - left) >> 1);
int cnt = getCnt(mid, m, n);
if (cnt < k) {
left = mid;
} else {
right = mid;
}
}
if (getCnt(left, m, n) >= k) return left;
return right;
}
public static int getCnt(int mid,int row,int col){
int ans=0;
for(int i=1;i<=row;i++){
if(i*col<=mid) {
ans+=col;
}
else {
ans+=mid/i;
}
}
return ans;
}
}
4.10 有界数组中指定下标处的最大值
class Solution {
public int maxValue(int n, int index, int maxSum) {
long left = 1, right = maxSum - n + 1;
while (left + 1 < right) {
long mid = left + (right - left >> 1);
if (check(n, index, mid, maxSum)) {
left = mid;
} else {
right = mid;
}
}
if (check(n, index, right, maxSum)) return (int) right;
return (int) left;
}
private boolean check(int n, int index, long target, int maxSum) {
long sum = 0l;
long leftCnt = index + 1;
if (target >= leftCnt) {
sum += (target - (leftCnt - 1) + target) * leftCnt / 2;
} else {
sum += (1 + target) * target / 2 + (leftCnt - target);
}
long rightCnt = n - index;
if (target >= rightCnt) {
sum += (target + target - (rightCnt - 1)) * rightCnt / 2;
} else {
sum += (target + 1) * target / 2 + rightCnt - target;
}
sum -= target;
return sum <= maxSum;
}
}