LeetCode hot100——34.在排序数组中查找元素的第一个和最后一个位置:Java 二分模板、边界分析

一句话说明核心方法

两次二分 :第一次求第一个 ≥ 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 = nwhile (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;框架同源,体现二分不止能搜数组。

相关推荐
金金计较.2 小时前
Go语言-3
java·开发语言·golang
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之元注解简化权限表达式
java·数据库·spring
罗西的思考4 小时前
机器人模型(WM / WAM / VLA)综合分析与对比:从「看」到「想」再到「做」
人工智能·算法·机器学习
xifangge20254 小时前
AGENTS.md 怎么写?涵盖 Java、Python、Vue、Go 的 8 套开箱即用模板
java·vue.js·python
Wang's Blog4 小时前
Java框架快速入门: Spring Security+OAuth2之云服务集成与多因子认证设计
java·开发语言·spring
liangsheng_g5 小时前
SpringAOP拦截器链递归与事务钩子补偿源码实战
java·spring
SL_staff5 小时前
制造业私有化文档平台的技术实践:从知识孤岛到可追溯知识资产
java·spring·开源
小哈里5 小时前
【知识】从学科到实践:自然・工程・社科・人文|算法・研发・产品・品牌设计
程序人生·算法·产品·设计·工程
小π军5 小时前
最大重叠区间数量
数据结构·算法