介绍
二分查找的概念非常容易理解:它将搜索空间均分成两半,只保留有目标值的一半,舍弃没有目标值的另一半。通过这种方式,我们每次迭代都能将搜索空间减少一半,直到找到目标值。二分查找能将时间复杂度从 O(n) 减少至 O(log n)。但在短短几分钟内写出一份没有 bug 的代码是相当难的。容易出现一些常见的问题,包括:
- 什么时候中止循环?我们应该使用
left < right还是left <= right作为 while 的循环条件? - 如何初始化
left、right边界变量? - 如何更新边界变量?选择
left = mid,left = mid + 1还是right = mid,right = mid - 1?
人们对二分查找存在一个相当普遍的误解:认为它只适用于简单的场景,比如 “给定一个有序数组,找到其中一个特定的值”。但事实上,它可以用于更加复杂的情况。
在刷了大量的 LeetCode 题后,我归纳了一个强大的二分查找模板。在刷题时只要稍稍调整这个模板就能解决许多困难的题。接下来我将与你们分享该模板,附带模板背后的一些思考:如何将这个通用模板应用于各种问题。希望在读完本文后,你在刷题时会说:“天哪!这个问题可以用二分查找解决!我以前怎么没想到呢!”
通用二分查找模板
假设我们有一个搜索空间。它可以是一个数组、一个范围等。通常它是按升序排序的。对于大多数问题,我们可以将其转换为以下形式:
💡 在满足 condition(k) 为真的条件下,使得 k 最小
以下代码是最通用的二分查找模板:
function binarySearch(arr: number[]): number {
function condition(value: number): boolean {
// 判断逻辑
}
let left = 0, right = arr.length;
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (condition(mid)) right = mid;
else left = mid + 1;
}
return left;
}
这个模板的好处在于:对于大多数二分查找问题,我们只需要复制这个模板,然后修改以下三个部分,就再也不用担心边界情况处理和错误:
- 正确初始化
left、right:使其包含所有可能的值 - 确定返回值:是返回
left还是left - 1?记住一点:退出while循环后,left是满足condition函数的最小k - 设计
condition函数:这是最难最巧妙的部分,需要大量练习。
下面我将向大家展示如何利用这个强大的模板解决许多 LeetCode 题。
基础运用
278. 第一个错误的版本
首先,我们初始化 left = 1 和 right = n 以包含所有可能的取值,在本题我们甚至不需要设计 condition 函数。它已经由 isBadVersion 提供。找到第一个错误的版本相当于找到满足 isBadVersion(k) = true 的最小 k。这与我们的模板完美契合:
var solution = function(isBadVersion: any) {
return function(n: number): number {
let left = 1, right = n;
while (left < right) {
let mid = Math.floor((left + right) / 2);
if (isBadVersion(mid)) right = mid;
else left = mid + 1;
}
return left;
};
};
69. Sqrt(x)
这是个相当简单的问题。我们需要找到满足 k^2 <= x 的最大 k,但还记得我们通常会寻找满足特定条件的最小 k 吗?但在这个问题中,我们寻找的是最大 k。一头雾水? 实际上,满足 condition(k) = false 的最大 k 正好等于满足 condition(k) = true 的最小 k 减 1。这就是为什么前面提到我们需要决定返回 left 还是 left - 1。代码如下:
function mySqrt(x: number): number {
let left = 1, right = x + 1;
while (left < right) {
let mid = Math.floor((left + right) / 2);
if (mid * mid > x) right = mid;
else left = mid + 1;
}
return left - 1;
};
35. 搜索插入位置
这题是非常经典的二分查找。本题寻找满足 nums[k] ≥ target 的最小 k,我们可以直接复制模板。注意到,无论输入数组 nums 是否有重复项,我们的解决方案都是正确的。另外,target 可能大于 nums 中的所有元素,此时它需要被放置在数组的末尾。所以我们应该初始化 right = nums.length 而不是 right = nums.length - 1
function searchInsert(nums: number[], target: number): number {
let left = 0, right = nums.length;
while (left < right) {
let mid = (left + right) >> 1;
if (nums[mid] >= target) right = mid;
else left = mid + 1;
}
return left;
};
高级运用
上面的问题很容易解决,因为题目已经给了我们搜索空间。一眼看去就知道应该使用二分查找。然而,更常见的是搜索空间和搜索目标没有直接给出的情况。有时我们甚至不会意识到应该用二分查找来解决 —— 我们可能会陷于动态规划或 DFS,然后卡住很长时间。
“什么时候可以用二分查找?”。我的回答是,如果我们可以发现某种单调性, 例如:condition(k) === true && condition(k + 1) === true,那么我们可以考虑使用二分查找。
1011. 在 D 天内送达包裹的能力
当我们刚开始遇到这个问题时,可能不会立马想到二分查找。我们可能会将 weights 视为搜索空间,浪费大量时间后才意识到束手无策。事实上,我们应该找所有可行运载能力中最小的一个。我们挖掘出这个问题的单调性:如果我们可以在 days 天内用运载能力 m 运送所有包裹,那么我们肯定可以用大于 m 的运载能力运完所有包裹。现在我们可以设计一个 condition 函数,命名为 feasible,给定一个运载能力 capacity,返回是否有可能在 days 天内运送所有包裹。feasible 可以用贪心法:如果还有空间运送包裹,我们就把包裹放在传送带上,否则我们等待第二天放置这个包裹。最后如果所需的总天数超过 days 返回 false,否则返回 true。
接下来,我们需要正确地初始化我们的边界。显然容量应该至少是 max(weights),否则传送带无法运送最重的包裹。然后,容量不必超过 sum(weights),因为这样我们可以在一天内运送所有包裹。现在我们就有了二分查找模板所需的一切(正确初始化边界值、确定返回值、设计 condition 函数),可以写出一下代码:
function shipWithinDays(weights: number[], days: number): number {
function feasible(capacity: number): boolean {
let total = 0, accuDays = 1;
for (const weight of weights) {
total += weight;
if (total > capacity) {
accuDays++;
total = weight;
if (accuDays > days) return false;
}
}
return true;
}
let left = Math.max(...weights);
let right = weights.reduce((sum, cur) => sum + cur, 0);
while (left < right) {
const mid = (left + right) >> 1;
if (feasible(mid)) right = mid;
else left = mid + 1;
}
return left;
};
410. 分割数组的最大值
仔细观察后,你会发现这题与上面的 LeetCode 1011 有多么相似。同样地,我们可以设计一个 feasible 函数:给定一个输入阈值,判断是否可以将数组拆分为多个子数组,使得每个子数组和都小于等于阈值。 这样,我们就发现了问题的单调性:如果 feasible(m) === true ,那么所有大于 m 的输入都可以满足 feasible 函数。 可以看到代码和 LC 1011 完全一样:
function splitArray(nums: number[], m: number): number {
function feasible(threshold: number): boolean {
let total = 0, cnt = 1;
for (const n of nums) {
total += n;
if (total > threshold) {
cnt++;
total = n;
if (cnt > m) return false;
}
}
return true;
}
let left = Math.max(...nums);
let right = nums.reduce((sum, cur) => sum + cur, 0);
while (left < right) {
const mid = (left + right) >> 1;
if (feasible(mid)) right = mid;
else left = mid + 1;
}
return left;
};
你可能会有疑问:我们的代码返回的 left 确实是满足 feasible 的最小值,但我们怎么确定 left 恰好是分割数组的最大值? 例如,假设 nums = [7,2,5,10,8], m = 2。我们有 4 种不同的方法来拆分数组获得 4 个不同的最大子数组和:25:[[7], [2,5,10,8]],23:[[7,2], [5,10,8]],18:[[[7,2,5], [10,8]],24:[[7],2,5,10], [8]]。 4 种拆分方法只对应 4 个值。但是搜索空间 [max(nums),sum(nums)] = [10,32] 内的值远不止 4 个。也就是说,无论如何拆分 nums,我们都无法得到搜索空间中的大部分值。
我们可以用反证法来证明代码的正确性:假设 k 是满足 feasible 函数的最小值,且没有任何子数组的和等于 k,即每个子数组的和都小于 k。 feasible 函数中的变量 total 记录当前子数组的总和。如果我们的假设是正确的,那么 total 将始终小于 k。因此,feasible(k - 1) 也一定为 true,因为 total 最多等于 k - 1,if (total > threshold) 不会被多触发,因此 feasible(k - 1) 和 feasible(k) 返回的都是 true。但前提为 k 是满足 feasible 函数的最小值,所以 feasible(k - 1) 必定为 false,这与前面矛盾。得证我们的算法是正确的。
875. 爱吃香蕉的珂珂
这与上面提到的 LC 1011 和 LC 410 都非常相似。让我们设计一个 feasible 函数,给定 speed,确定珂珂是否可以在每小时进食速度为 speed 的情况下在 h 小时内吃完所有香蕉。很明显,搜索空间的下界是1,上界是 max(piles),因为珂珂每小时只能选择一堆香蕉吃。
function minEatingSpeed(piles: number[], h: number): number {
function feasible(speed: number): boolean {
return piles.map(total => Math.ceil(total / speed))
.reduce((sum, cur) => sum + cur) <= h;
}
let left = 1, right = Math.max(...piles);
while (left < right) {
const mid = (left + right) >> 1;
if (feasible(mid)) right = mid;
else left = mid + 1;
}
return left;
};
1482. 制作 m 束花所需的最少天数
我们已经解决了上面的三个进阶问题,那么这个问题应该更容易解决。这个问题的单调性很明显:如果我们在等待 d 天后可以制作 m 个花束,那么我们等待 d 天以上,肯定也可以制作 m 个花束。
function minDays(bloomDay: number[], m: number, k: number): number {
if (m * k > bloomDay.length) return -1;
function feasible(day: number): boolean {
let cnt = 0, flowers = 0;
for (const bloom of bloomDay) {
if (bloom > day) {
flowers = 0;
} else {
cnt += Math.floor((flowers + 1) / k);
flowers = (flowers + 1) % k;
}
}
return cnt >= m;
}
let left = 1, right = Math.max(...bloomDay);
while (left < right) {
const mid = (left + right) >> 1;
if (feasible(mid)) right = mid;
else left = mid + 1;
}
return left;
};
668. 乘法表中第k小的数
对于 第k小 问题,我们首先想到的是堆。维护一个 Min-Heap 并且弹出 Heap 顶部 k 次。然而,最小堆在这个问题上并不是最优解。因为我们没有整个乘法表中的每一个数字,我们只有表的宽高。如果我们要采用 Heap 方法,需要显式计算 m * n 的所有值并将它们保存到堆中。这个过程的时间复杂度和空间复杂度都是 O(mn),效率很低。所以就到了二分查找的时候了。还记得我说过设计 condition 函数是最困难的部分吗?为了找到表中的第 k 个最小值,我们可以设计一个 enough 的函数,给定一个入参 num,判断是否至少有 k 个值小于等于 num。满足 enough 函数的最小 num 就是题目的答案。回想一下,二分查找的关键是发现单调性。在这个问题中,如果 num 满足 enough,那么任何大于 num 的值都可以满足 enough。这种单调性是我们二分查找的基础。
让我们考虑搜索空间:显然下界应该是 1,上界应该是乘法表中的最大值,也就是 m * n,那么我们就有了搜索空间 [1, m * n]。二分查找解法相对于堆解法的压倒性优势在于它不需要显式计算该表中的所有数字,它只需要从搜索空间中取出一个值作为 enough 函数的入参,以确定我们应该保留搜索空间的左半部分还是右半部分。这样,二分查找方案只需要 O(1) 的空间复杂度,比堆方案好很多。
接下来我们考虑如何实现 enough。可以看出,乘法表中的每一行都只是其索引的倍数。例如,第 3 行 [3,6,9,12,15...] 中的所有数字都是 3 的倍数。因此,我们可以逐行计算小于等于 num 的个数。代码如下:
function findKthNumber(m: number, n: number, k: number): number {
function enough(num: number): boolean {
let cnt = 0;
for (let val = 1; val <= m; val++) {
const add = Math.min(Math.floor(num / val), n);
if (add === 0) break;
cnt += add;
}
return cnt >= k;
}
let left = 1, right = m * n;
while (left < right) {
const mid = (left + right) >> 1;
if (enough(mid)) right = mid;
else left = mid + 1;
}
return left;
};
在上面的 LC 410 中,我们曾质疑:“二分查找的结果是一个子数组和吗?”。 这里我们也有类似的疑问:“二分查找的结果真的在乘法表中吗?”。答案是肯定的,我们也可以用反证法证明。将 num 表示为满足 enough 函数的最小入参。让我们假设 num 不在表中,这意味着 num 不能被 [1, m] 中的任何 val 整除,即 num % val > 0。因此,将入参从 num 改为 num - 1 不会 对表达式 add = min(num // val, n) 有任何影响。 所以 enough(num - 1) 也会返回 true,就像 enough(num) 一样。但是我们已经知道 num 是满足 enough 函数的最小入参,所以 enough(num - 1) 必须是 false。 矛盾!得证 num 一定在乘法表中。
719. 找出第 k 小的距离对
这题与上面的 LC 668 非常相似,两者都是找到第 k 小的值。就像 LC 668 一样,我们可以设计一个 enough 函数,给定入参 distance,判断是否至少有 k 对的距离小于等于 distance。可以用滑动窗口思想:对数组进行排序并用快慢指针来扫描它。两个指针都从最左端开始。如果当前指向的两个值距离小于等于 distance,则这些指针之间的所有 pair 都有效(因为数组有序),我们向前移动快指针。否则,我们向前移动慢指针。当慢指针到达最右端时,我们完成扫描并查看总数是否超过 k。
显然,我们的搜索空间应该是 [0, max(nums) - min(nums)]。 现在套用模板:
function smallestDistancePair(nums: number[], k: number): number {
nums.sort((a, b) => a - b);
function enough(threshold: number): boolean {
let slow = 0, fast = 0;
let cnt = 0;
while (slow < nums.length) {
while (fast < nums.length && nums[fast] - nums[slow] <= threshold) fast++;
cnt += fast - slow - 1;
slow++;
}
return cnt >= k;
}
let left = 0, right = Math.max(...nums) - Math.min(...nums);
while (left < right) {
const mid = (left + right) >> 1;
if (enough(mid)) right = mid;
else left = mid + 1;
}
return left;
};
1201. 丑数 III
此题没什么特殊的,仍在寻找第 k 小的值。我们需要设计一个 enough 函数,给定入参 num,判断是否至少有 n 个小于等于 num 的丑数。由于 a 可能是 b 或 c 的倍数,或者反过来,我们需要最大公约数来避免重复计数。
// 大数运算精度会不够,需要用 BigInt,本文主要讨论二分查找,故忽略
function nthUglyNumber(n: number, a: number, b: number, c: number): number {
const ab = lcm(a, b);
const ac = lcm(a, c);
const bc = lcm(b, c);
const abc = lcm(a, bc);
function enough(m: number): boolean {
const total = Math.floor(m / a) + Math.floor(m / b) + Math.floor(m / c)
- Math.floor(m / ab) - Math.floor(m / ac) - Math.floor(m / bc) + Math.floor(m / abc);
return total >= n;
}
let left = Math.min(a, b, c), right = 10 ** 10;
while (left < right) {
const mid = ((right - left) >> 1) + left;
if (enough(mid)) right = mid;
else left = mid + 1;
}
return left;
};
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : a;
}
function lcm(a: number, b: number): number {
return a * b / gcd(a, b);
}
1283. 使结果不超过阈值的最小除数
经过上面这么多问题的磨练,这题小菜一碟。我们甚至不需要费心设计 condition 函数,因为问题已经明确告诉我们需要满足什么条件。
function smallestDivisor(nums: number[], threshold: number): number {
function feasible(m: number): boolean {
return nums.map(n => Math.ceil(n / m))
.reduce((sum, cur) => sum + cur, 0) <= threshold;
}
let left = 1, right = Math.max(...nums);
while (left < right) {
const mid = (left + right) >> 1;
if (feasible(mid)) right = mid;
else left = mid + 1;
}
return left;
};
结尾
哇,非常感谢你能坚持到最后。可以看到上面所有的 typescript 代码都非常相似,那是因为我们一直在套用模板。现在你应该明白这个模板的强大之处了吧!我们只需要多加练习,来提高发现问题单调性和设计 condition 函数的能力,就可以通过这个模板来解决很多问题。
希望本文对你有所帮助!
本文为译文,原文在这👇
Powerful Ultimate Binary Search Template and Many LeetCode Problems