先记住这个答案
使用左闭右开区间[0,n),在循环中若nums[mid] <= target则将左界移动到mid+1,否则把右界收到mid。结束时左界指向第一个大于target的位置,因此请检查left-1是否越界且等于target,是则返回该下标,否则返回-1。该写法同样说明二分为什么能直接应用在重复元素上,因为任何等于target的元素都被归入左侧推进通道。
- 上界下标减一即潜在答案
- 等于目标时左界必须前进以向右
- 每次验证left-1是否等于目标
以上界为桥的实现逻辑
先定义区间为左闭右开[0,n),保持不变量 nums[0..left-1] <= target(若存在)且 nums[right..n-1] > target。循环时取中位索引 mid,检查 nums[mid] <= target 是否成立。成立说明 mid 左侧包括 mid 都不可能成为“第一个大于 target”的位置,所以把 left 推到 mid+1;否则当前 mid 位置已经超过 target,需要把右边界收到 mid。每步都削减查找量,直到 left == right,此时 left 正是第一个严格大于 target 的索引。
相对找第一个出现位置,这里把“相等”归入左侧推进,让相等的元素都被跳过,所以收敛点自然在重复段之后,因此结果是这段的右侧后一位置。随后用 left - 1 回退就能命中最后一个等于 target 的值。如果有多个不相等的值夹着,这个推断同样成立;如果数组中根本没出现 target,left - 1 可能指向比 target 更小的值,所以必须做一次相等校验。
function findLast(nums, t) {
let left = 0, right = nums.length;
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (nums[mid] <= t) {
left = mid + 1;
} else {
right = mid;
}
}
return left > 0 && nums[left - 1] === t ? left - 1 : -1;
}
console.log(findLast([1,2,2,2,3],2)); // 3
console.log(findLast([1,2,3,3,3,4],3)); // 4
console.log(findLast([5],5)); // 0
console.log(findLast([5],6)); // -1
console.log(findLast([],1)); // -1
console.log(findLast([2,2,2],2)); // 2查看输出与解释
3
4
0
-1
-1
2实现采用左闭右开区间,当 nums[mid] <= target 时左界推进,否则右界收紧;最终 left 为首个大于 target 的下标,返回前校验 left-1 处是否等于 target。特殊输入下仍保持 O(log n) 时间,O(1) 空间。
日志恢复中的游标续传
假设事故恢复系统里有一个按时间戳严格升序排列的消息数组,每条记录带毫秒级时间,业务需要找到“某指定时间戳最后一次写入”的下标,以便从它后面继续拉取未消费消息。输入给定数组 [100,200,200,200,300] 且目标 200,函数应返回 3 而不是 1。调用上面的二分实现,命中索引之后直接得到续传游标,避免一条条线性检查。
这种场景强调的不是能不能找到一个值,而是重复时间戳出现多批时,取最大值才不丢数据。若用普通二分返回任意一个等于目标的下标,游标可能停在中间导致部分日志被重复处理;用上界回退恰好给出连续重复段的末尾,语义与“最后写入”完全对应。因为在分布式系统中日志常有大纪元重复,复杂度仍是 O(log n),空间 O(1)。
易错边界与防御检查
最容易失效的输入是 target 并不存在。例如数组 [1,2,4] 中找 3,循环结束后 left = 2(因为第一个比3大的是4的下标2),left-1 = 1,nums[1] 是2不等于3,所以必须检查“left-1 合法 && nums[left-1] === target”才返回索引,否则返回-1。另一个极端是 target 大于数组全部元素,例如找9时 left=n,此时 left-1 索引 n-1 仍不等于9,直接返回-1即可;若是数组只有1个重复元素也一样。
空数组与单元素数组也容易让新手慌乱。空数组时 left=0,条件守卫会短路并返回-1;单元素且匹配时,如 [7] 找7,left=1,left-1=0匹配返回0。若要扩展成找第一个大于等于(lower bound)、第一个大于(upper bound)等变体,只需反转 <= 为 < 就能切换语义,这种对称性是二分模板的核心价值。代价是把“是否找到”的判断留到函数最后,比直接返回−1的位置多一次比较,但仍保持 O(log n)。
容易答错的地方
- 遇到等于目标值就返回当前 mid
- mid 可能不是重复段末尾,尤其在目标在左侧或右侧还有更多相同元素时,返回 mid 会提前终止。应该继续压缩区间,将相等情况视为仍需向右探测。
- 认为只需反向改动不等号即可
- 找最后一个与找第一个并非简单把
<改成>就能完成。换位符号的同时还要决定哪边继续推进、返回哪个边界。应保持<=推进左界、>收紧右界,最后取 left−1 并做相等校验。
面试官还会怎么问?
能否直接用 while (left <= right) 的闭区间实现?
可以。取 mid 时相等条件下 left = mid + 1,循环结束后 left 指向首个更大元素,返回 left-1 并验证是否为目标即可。闭区间写法需要小心 right 初始化为 n-1,终止状态更容易差一。
如果数组里全是重复目标,这个查找的最坏情况复杂度怎样?
仍是 O(log n)。因为每次更新不变量都会让 mid 位置确定,不变量与重复元素数量无关。即使整个数组都为 target,寻找上界的区间长度在每一步都减半,只需要约 log2(n) 次比较。
如果要找最后一个小于目标的位置,怎么改?
把判据 nums[mid] < target 放入左侧推进分支,循环结束后 left 是第一个不小于 target 的位置,那么 left-1 恰好是最后一个小于 target 的位置。这与上界模板一脉相承。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。