34. 在排序数组中查找元素的第一个和最后一个位置:用两个边界定位区间

题目

给定一个按照非递减顺序排列的整数数组 nums,以及一个目标值 target,要求找出 target 在数组中的开始位置和结束位置。

如果数组中不存在 target,返回 [-1, -1]

题目要求时间复杂度为 O(log n),因此不能线性扫描,应该使用二分查找。

例如:

复制代码
nums = [5, 7, 7, 8, 8, 10]
target = 8
返回 [3, 4]

核心思路:把区间问题转成两个边界问题

要找到 target 的左右边界,可以分别寻找两个位置:

复制代码
左边界 = 第一个 >= target 的位置
右边界 = 第一个 > target 的位置 - 1

nums = [5, 7, 7, 8, 8, 10],target = 8 为例:

复制代码
第一个 >= 8 的位置是 3
第一个 > 8 的位置是 5
所以右边界是 5 - 1 = 4

因此答案是 [3, 4]

这种思路本质上是同时使用了 lower_boundupper_bound

  • lower_bound(target):第一个 >= target 的位置。
  • upper_bound(target):第一个 > target 的位置。

lower_bound 返回的是插入位置

二分查找中一个很容易出错的点是:边界函数返回的不是"找到元素的下标",而是"第一个满足条件的位置",也可以理解成插入位置。

例如:

复制代码
nums = [1]
target = 2

第一个 >= 2 的位置并不存在于数组内部,但 2 按顺序应该插在数组末尾,所以返回值应该是:

复制代码
nums.length = 1

这个 1 虽然不能用来访问 nums[1],但它是一个有意义的边界值。调用方可以用它判断 target 是否比所有元素都大:

复制代码
if (l >= nums.length) {
    return new int[] { -1, -1 };
}

如果在二分函数内部把 nums.length 改成 -1,主函数就无法正确判断这种情况,反而会破坏边界语义。

为什么不用 target + 1

一种常见写法是:

复制代码
int left = lower(nums, target);
int right = lower(nums, target + 1) - 1;

这个思路在普通数据下可行,因为"第一个 >= target + 1 的位置"相当于"第一个 > target 的位置"。

但 Java 的 int 有上界。如果 target == Integer.MAX_VALUE,那么 target + 1 会发生溢出。因此,更稳的做法是直接写一个能区分两种边界的函数:

  • 找第一个 >= target 时,左侧条件是 nums[mid] < target
  • 找第一个 > target 时,左侧条件是 nums[mid] <= target

二分框架完全相同,只是判断条件不同。

代码实现

复制代码
class Solution {
    public int[] searchRange(int[] nums, int target) {
        int n = nums.length;
        if (n == 0) {
            return new int[] { -1, -1 };
        }

        int left = boundary(nums, target, true);
        if (left >= n || nums[left] != target) {
            return new int[] { -1, -1 };
        }

        int right = boundary(nums, target, false) - 1;
        return new int[] { left, right };
    }

    // isLowerBound = true  -> 第一个 >= target 的位置
    // isLowerBound = false -> 第一个 > target 的位置
    public int boundary(int[] nums, int target, boolean isLowerBound) {
        int left = -1;
        int right = nums.length;

        while (right > left + 1) {
            int mid = left + (right - left) / 2;

            if (isLowerBound ? nums[mid] < target : nums[mid] <= target) {
                left = mid;
            } else {
                right = mid;
            }
        }

        return right;
    }
}

二分不变量

这份代码采用开区间哨兵写法:

复制代码
left = -1
right = nums.length

leftright 可以理解为两个虚拟哨兵:

  • left 左侧表示已经确认不满足目标边界的位置。
  • right 右侧表示已经确认满足目标边界的位置。

当寻找第一个 >= target 的位置时:

复制代码
nums[left] < target
nums[right] >= target

当寻找第一个 > target 的位置时:

复制代码
nums[left] <= target
nums[right] > target

注意:left = -1right = nums.length 都是虚拟位置,不能直接访问。循环结束时 right == left + 1,此时 right 就是第一个满足条件的位置。

边界检查

拿到左边界后,必须先判断它是否是有效下标:

复制代码
if (left >= n || nums[left] != target) {
    return new int[] { -1, -1 };
}

这一步覆盖了两种情况:

  1. left >= n:说明 target 比数组中所有元素都大。
  2. nums[left] != target:说明数组中不存在 target,只是找到了一个更大的插入位置。

例如:

复制代码
nums = [1], target = 2

左边界会返回 1,也就是 nums.length,因此直接返回 [-1, -1]

复杂度

代码执行两次二分查找,每次时间复杂度为 O(log n),因此整体时间复杂度仍然是:

复制代码
O(log n)

只使用了常量级变量,空间复杂度为:

复制代码
O(1)

总结

这道题的关键不是单纯"写一个二分",而是明确边界函数的语义:

  1. lower_bound 返回第一个 >= target 的位置。
  2. upper_bound 返回第一个 > target 的位置。
  3. 返回值可以等于 nums.length,表示目标值应该插在数组末尾。
  4. 不要在边界函数内部把 nums.length 改成 -1,否则会破坏插入位置语义。
  5. 为避免 target + 1 溢出,可以用一个布尔参数区分 >= target> target 两种边界。

以后遇到"在有序数组中找某个值的范围"这类问题,可以优先考虑把范围拆成两个边界:左边界找第一个 >= target,右边界找第一个 > target 后再减一。

相关推荐
郑州光合科技余经理5 小时前
代驾系统架构拆解:订单链路、权限组织与私有化源码交付
开发语言·后端·算法·架构·系统架构·uni-app·php
To_OC8 小时前
LC 35 搜索插入位置:二分查找谁都会,边界条件谁写谁懵
javascript·算法·leetcode
码哥DFS8 小时前
二叉树的直径
开发语言·javascript·算法
营养充电站11 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
人工智能·算法·docker·jupyter
技术小黑12 小时前
RNN算法实战系列05 | 天气预测
人工智能·rnn·算法
Ivanqhz14 小时前
php8 use-def链
算法·php
evans在进步15 小时前
LeetCode 189 轮转数组:三次反转原地解决,图解 Java 实现
java·算法·leetcode
歌_顿15 小时前
Drawing_To_Model
算法