一句话说明核心方法
跑两次二分 :第一次求第一个 ≥ target 的下标 (lower bound),第二次求第一个 > target 的下标 (upper bound);两者只差一个等号,区间 [lower, upper - 1] 就是答案,两个边界重合则说明 target 不存在。
思路推导
题意转化 :在非递减数组(可能有重复)里找 target 的最早出现 和最晚出现位置。
关键观察 1 :为什么必须两次二分,一次不够?------数组有重复。单次二分命中 nums[mid] == target 时直接返回,拿到的是中间某个 target,不保证是最左/最右。想一次拿到边界,要么命中后继续线性扩展(最坏退化成 O(n),违反题目要求),要么改判定条件------把"等于 target"也归入"继续往左/往右搜"的分支,让二分自己收敛到边界。
关键观察 2:两个边界各对应一个经典语义------
- 开始位置 = 第一个
nums[i] >= target的 i(lower bound) - 结束位置 = 第一个
nums[i] > target的 i(upper bound)减 1
注意 lower bound 和 upper bound 的代码只差一个字符 :nums[mid] >= target 收右边界,nums[mid] > target 才收。这不是巧合------两套判定共享同一个"左闭右开 + 保留候选"的框架,等号的位置决定了 target 本身被划到哪一边。
关键观察 3 :target 不存在时,lower bound 和 upper bound 必然相等 ------数组里没有任何元素等于 target,"第一个 ≥ target"和"第一个 > target"指向同一个位置。利用这一点,l == r 一个判断就覆盖了所有缺失场景(含空数组、target 越界),不用再写 l < n && nums[l] == target 的越界检查。
二分过程示意(nums = 5,7,7,8,8,10, target = 8)
findLeft(求第一个 ≥ 8):
初始: l = 0, r = 6
0 1 2 3 4 5
[ 5 7 7 8 8 10 ]
L R
第1轮: mid = 3, nums[3] = 8 >= 8 → r = mid = 3 (mid 可能是答案,保留)
5 7 7 8 8 10
L R
第2轮: mid = 1, nums[1] = 7 < 8 → l = mid + 1 = 2
5 7 7 8 8 10
L R
第3轮: mid = 2, nums[2] = 7 < 8 → l = mid + 1 = 3
l == r == 3 → 返回 3 ✓
findRight(求第一个 > 8):
初始: l = 0, r = 6
第1轮: mid = 3, nums[3] = 8 > 8? 否 → l = 4 (8 本身属于左边,丢弃)
第2轮: mid = 5, nums[5] = 10 > 8 → r = 5 (10 可能是"第一个>",保留)
第3轮: mid = 4, nums[4] = 8 > 8? 否 → l = 5
l == r == 5 → 返回 5 ✓
答案 = [3, 5 - 1] = [3, 4] ✓
两个搜索的对比一目了然:判定里含等号的那次(findLeft),target 被推向右边,收敛到"最左的 target";不含等号的那次(findRight),target 被划入左边,收敛到"target 后面第一个空位"。
Java 完整代码
java
class Solution {
public int[] searchRange(int[] nums, int target) {
int l = findLeft(nums, target); // 第一个 >= target 的下标
int r = findRight(nums, target); // 第一个 > target 的下标
if (l == r) {
// 两个边界重合 ⇒ 数组里没有任何元素等于 target(含空数组)
return new int[]{-1, -1};
}
return new int[]{l, r - 1}; // 结束位置 = 第一个 > target 的前一个
}
// lower bound:第一个 >= target 的下标
private int findLeft(int[] nums, int target) {
int l = 0, r = nums.length; // 左闭右开 [l, r)
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] >= target) { // 含等号:target 归右边,mid 保留为候选
r = mid;
} else {
l = mid + 1; // nums[mid] 确定太小,丢弃
}
}
return l;
}
// upper bound:第一个 > target 的下标
private int findRight(int[] nums, int target) {
int l = 0, r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) { // 不含等号:target 归左边
r = mid;
} else {
l = mid + 1;
}
}
return l; // 循环结束时 l == r,写哪个都行,建议统一
}
}
关键代码逐行解释
-
两次独立二分而不是"找到后向两边扩展" ------扩展是线性的,
[8,8,8,...,8]全相同数组会退化成 O(n);两次二分严格 O(log n),这是本题对复杂度的硬要求,也是面试的考察点。 -
nums[mid] >= target → r = mid(findLeft 的灵魂) ------>=把"等于"也划入右半区:mid 上的 target 可能就是最左的那个 ,不能丢弃,所以r = mid保留候选而不是mid - 1。循环不变量:[0, l)全< target,[r, n)全>= target,收敛时 l 正好压在分界线上。 -
nums[mid] > target → r = mid(findRight 把>=改成>) ------target 本身属于左半区(它不是"第一个 > target"),nums[mid] == target时和nums[mid] < target一样l = mid + 1。两个函数 90% 相同,唯一差异就是这个等号------这正是 lower/upper bound 的本质:等号决定 target 的归属方向。 -
if (l == r) return new int[]{-1, -1}------利用"缺失时 lower == upper"的性质判缺失。对比常见写法if (l == nums.length || nums[l] != target),本写法少一个越界判断,且天然覆盖空数组(两个二分都返回 0)。原理:数组里不存在等于 target 的元素时,"第一个 ≥"和"第一个 >"只能是同一个位置。 -
return new int[]{l, r - 1}------结束位置是语义转换:findRight 返回的是"target 后面第一个空位",减 1 才是"最右的 target"。这一步减法忘掉,答案的右端点会系统性偏大 1,样例一跑就能发现。 -
findRight 里
return l(原代码是return r) ------循环结束时l == r,数值上没错;但 findLeft 返回 l、findRight 返回 r,两个几乎一样的函数返回值不一致,读者会怀疑"是不是有什么讲究"。其实没有,统一返回 l 可读性更好。
时间、空间复杂度
-
时间复杂度:O(log n)
- 两次独立二分,每次区间严格减半,各 O(log n);相加仍是 O(log n)(2·log n 是常数因子)。n = 10⁵ 时每次约 17 轮,总共约 34 轮。
-
空间复杂度:O(1)
- 只有 l / r / mid 三个变量,无递归无辅助结构。
易错点
-
沿用上一题习惯,命中
== target就提前 return :无重复的 35 题这么写没问题;本题有重复,提前返回拿到的是中间某个 target,不是边界。上一篇文章就预警过这一点------有序数组找边界类题目,判定条件必须吸收等号,而不是提前退出。 -
结束位置忘了减 1 :直接返回
[l, r],右端点大 1。根源是没意识到 findRight 的返回值是"第一个 > target 的位置"(开区间语义),不是"最后一个 target"。 -
判缺失写错导致越界 :用
nums[l] == target判存在却没先检查l == nums.length,target 大于所有元素时数组越界。l == r判缺失可以绕开这个问题;若坚持检查nums[l],务必先判l < n。 -
两个二分的等号位置写反 :本该 findLeft 用
>=、findRight 用>,写反后返回的"开始位置"会跑到"结束位置"的右边(或反之),输出形如[5, 3]。记忆口诀:求左边界,target 往右推(含等号);求右边界,target 往左推(不含等号)。 -
混用区间约定 :右开框架里写
r = mid - 1,或左闭右闭框架里漏掉±1,轻则漏解重则死循环。右开区间的三件套是绑定的:r = n、while (l < r)、r = mid------要么整套用,要么整套换。
可复用模板
把两次二分抽成通用 lower/upper bound 母版,所有"有序数组找边界"的题都能套:
java
class Solution {
public int[] searchRange(int[] nums, int target) {
int left = lowerBound(nums, target); // 第一个 >= target
int right = upperBound(nums, target); // 第一个 > target
if (left == right) return new int[]{-1, -1}; // 不存在(含空数组)
return new int[]{left, right - 1};
}
// 母版:flag 决定 lower/upper ------ 判定含等号是 lower,不含是 upper
private int lowerBound(int[] nums, int target) {
int l = 0, r = nums.length; // 右开 [l, r)
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] >= target) r = mid; // ← 含等号
else l = mid + 1;
}
return l;
}
private int upperBound(int[] nums, int target) {
int l = 0, r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) r = mid; // ← 不含等号,唯一差异
else l = mid + 1;
}
return l;
}
}
变体提示:
- 统计 target 出现次数 →
upperBound - lowerBound,一行搞定,无需遍历。 - 找"最后一个 ≤ target" →
upperBound(target) - 1(同理"第一个 < target" =lowerBound(target) - 1)。 - 判定条件抽象化 (第一个错误版本 278)→ 把
nums[mid] >= target换成isBadVersion(mid),框架一字不动。 - 二分答案 → 数组换成隐式的值域
[lo, hi],判定换成check(mid),循环体结构完全相同。
相似题及区别
-
LeetCode 35 搜索插入位置:只跑一次 lower bound,且无重复元素,返回值兼具"下标/插入点"双重语义;本题是它的"重复元素完整版", lower + upper 各来一次再拼区间。
-
LeetCode 704 二分查找:最朴素的单次二分,命中即返回;和本题对照最能看清"提前 return 在重复元素下失效"这个问题。
-
LeetCode 278 第一个错误的版本:判定函数换成布尔接口,只求"第一个 true",本质是一次 lower bound;本题模板把判定泛化后直接覆盖它。
-
LeetCode 69 x 的平方根 :二分对象从数组下标换成整数值域("猜 mid、平方验证"),判定条件从比较数组元素变成比较
mid * mid与 x;框架同源,体现二分不止能搜数组。