
一句话说明核心方法跑两次二分第一次求第一个 ≥ 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 完整代码javaclass 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 母版所有“有序数组找边界”的题都能套javaclass 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;框架同源体现二分不止能搜数组。