二分的前提是有序和随机访问,真正的难点不是写出循环,而是把区间定义和边界条件一次写对。
排查线上问题时要在三万条按时间排列的日志里找第一条 ERROR,线性扫描要读三万个对象,二分只需十五次比较。二分的价值在于把"逐个排除"换成"一次排除一半"。
数据必须有序,且能 O(1) 随机访问任意下标。链表不满足第二条,每次取中点都要走指针,单次定位退化成 O(n),整体 O(n log n),还不如直接遍历。数组、字符串、有序文件索引都满足,所以二分在数组题里出现频率极高。
| 写法 | 区间 | 循环条件 | 收缩方式 | 循环结束 |
|---|---|---|---|---|
| 闭区间 | [left, right] | left <= right | left = mid + 1 / right = mid - 1 | 区间为空 |
| 左闭右开 | [left, right) | left < right | left = mid + 1 / right = mid | left === right |
| 左开右闭 | (left, right] | left < right | left = mid / right = mid - 1 | left === right |
三种写法都能对,错的是混用:区间定义决定了 right 是"候选"还是"哨兵",收缩方式必须跟着变。工程上统一用左闭右开,边界最不容易写错。
// 闭区间:能找到就返回下标
function searchClosed(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
// 左闭右开:right 是哨兵,永远不参与比较
function searchHalfOpen(nums, target) {
let left = 0, right = nums.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return -1;
}
C 和 Java 的 (left + right) / 2 会整数溢出,JS 没有 int,但有两个坑。其一是 (left + right) >> 1 会先把结果转成 32 位有符号整数,下标超过 2³¹-1 就变成负数;其二是 Number 只有 2⁵³ 的精确整数范围,左右端点都很大时相加会丢精度。
const left = 2 ** 31 - 1, right = 2 ** 31 - 1;
console.log((left + right) >> 1); // -1,32 位截断
console.log(left + ((right - left) >> 1)); // 2147483647,正确
left + (right - left) / 2 先做减法,差值不会超过数组长度,既避开溢出又保留精度。习惯写成 (right - left) >> 1 时,要清楚它仍是 32 位运算。
lower_bound 找第一个 >= target 的下标,upper_bound 找第一个 的下标,两者之差就是 target 的出现次数。这套模板不依赖"找到就返回",而是把区间收缩到空,结束时 就是答案。
> targetleft// 第一个 >= target 的位置
function lowerBound(nums, target) {
let left = 0, right = nums.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
// 第一个 > target 的位置
function upperBound(nums, target) {
let left = 0, right = nums.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] <= target) left = mid + 1;
else right = mid;
}
return left;
}
模板不死循环的关键是两点:mid 向下取整,所以 mid < right 恒成立,right = mid 至少收缩一格;left = mid + 1 也严格前进。反过来,闭区间里写 right = mid,当 left === right 时 mid === left,区间不再变化,就会卡死。
二分的本质是在单调函数上找分界点:存在一个谓词 check(i),前半段全为 false、后半段全为 true,二分就是找第一个 true。数组有序只是"nums[i] >= target 单调"的一个特例;旋转数组、山峰数组、答案空间只要满足单调,同样可以二分。
旋转数组的最小值、峰值元素靠"局部趋势"构造谓词;二分答案则把"求最小的可行值"转成"判定某个值是否可行",判定函数往往是一次 O(n) 扫描。
很多人以为二分必须全局有序,实际上只需要所查找的判定条件单调,旋转数组和峰值题都不满足全局有序。
很多人以为 JS 没有整数溢出所以 (left + right) / 2 绝对安全,实际上 >> 1 会把结果截断成 32 位有符号数。
很多人以为 while (left <= right) 可以配 right = mid,实际上这两者混用会在区间只剩一个元素时死循环。
等价于 lower_bound:找到第一个大于等于 target 的位置,找不到时返回数组长度。时间 O(log n),空间 O(1)。
function searchInsert(nums, target) {
let left = 0, right = nums.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
每次二分必有一半是有序的,判断 target 是否落在有序那一半的值域内,决定保留哪一半。时间 O(log n),空间 O(1)。
function searchRotated(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] === target) return mid;
if (nums[left] <= nums[mid]) {
if (nums[left] <= target && target < nums[mid]) right = mid - 1;
else left = mid + 1;
} else {
if (nums[mid] < target && target <= nums[right]) left = mid + 1;
else right = mid - 1;
}
}
return -1;
}
若 nums[mid] < nums[mid + 1],右侧一定存在峰值,把 left 移到 mid+1;否则峰值在左侧或 mid 本身。时间 O(log n),空间 O(1)。
function findPeakElement(nums) {
let left = 0, right = nums.length - 1;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (nums[mid] < nums[mid + 1]) left = mid + 1;
else right = mid;
}
return left;
}
速度 k 越大,吃完所有香蕉的时间越少,时间是 k 的单调递减函数。在 [1, max(piles)] 上二分最小的可行速度,判定函数累加 ceil(pile / k)。时间 O(n log m),空间 O(1)。
function minEatingSpeed(piles, h) {
let left = 1, right = Math.max(...piles);
while (left < right) {
const mid = left + ((right - left) >> 1);
let hours = 0;
for (const p of piles) hours += Math.ceil(p / mid);
if (hours <= h) right = mid;
else left = mid + 1;
}
return left;
}