34. 查找元素的第一个和最后一个位置

难度:中等 | 主题:边界二分——两次 35

题目

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]。要求 O(log n) 时间复杂度。

示例

 
输入:nums = [5,7,7,8,8,10], target = 8
 
输出:[3,4]
 

思路

先讲个故事:点名册上的重名

老师点名:“叫 8 号的同学站起来。“你发现班上有两个 8 号——第一个 8 号和最后一个 8 号。要快速找到这两个位置,你会怎么做?

你不会一个个数——点名册是按学号排序的,二分查找能快速定位。但你需要的不是”有没有 8 号”,而是第一个 8 号和最后一个 8 号


引导式推导:拆分问题

关键洞察:一个二分不够,那就做两次。


graph LR

    A["找第一个≥target<br/>(左边界)"] --> B["检查target是否存在"]

    B --> C["找第一个>target<br/>(右边界+1)"]

    C --> D["右边界减1<br/>得到最终右边界"]

第一次二分:找第一个 ≥ target 的位置(35 题模板)→ leftIdx

第二次二分:找第一个 > target 的位置 → rightIdx

如果 target 存在:

  • 左边界 = leftIdx

  • 右边界 = rightIdx - 1

如果 target 不存在,leftIdxrightIdx 会交叉或越界。

为什么不用一次二分找到 target 后向两边扩散? 如果整个数组都是 target 值(比如 [8,8,8,8,8]),向两边扩散是 O(n),不符合题目要求的 O(log n)。


统一二分函数


graph LR

    subgraph binarySearch lower

        M["找到第一个满足条件的边界位置"]

        M --> L["lower=True<br/>条件: nums[mid]>=target<br/>找左边界"]

        M --> U["lower=False<br/>条件: nums[mid]>target<br/>找右边界+1"]

    end

用一个 binarySearch(lower) 函数封装两种模式:

  • lower=True:条件用 >=,返回第一个 ≥ target 的位置

  • lower=False:条件用 >,返回第一个 > target 的位置


代码

 
class Solution:
 
    def searchRange(self, nums: list[int], target: int) -> list[int]:
 
        def binarySearch(lower: bool) -> int:
 
            left, right = 0, len(nums) - 1
 
            ans = len(nums)
 
            while left <= right:
 
                mid = (right - left) // 2 + left
 
                if nums[mid] > target or (lower and nums[mid] >= target):
 
                    right = mid - 1
 
                    ans = mid
 
                else:
 
                    left = mid + 1
 
            return ans
 
        leftIdx = binarySearch(True)
 
        rightIdx = binarySearch(False) - 1
 
        if (leftIdx <= rightIdx and rightIdx < len(nums)
 
                and nums[leftIdx] == target and nums[rightIdx] == target):
 
            return [leftIdx, rightIdx]
 
        return [-1, -1]
 

ans 为什么初始化为 len(nums) 而不是 -1 当 target 大于所有元素时,leftIdx 返回 len(nums)rightIdx 返回 len(nums)-1,校验 leftIdx <= rightIdx 自然失败。


复杂度

指标解释
时间O(log n)两次二分
空间O(1)几个指针变量

实战考量

频率分析

出现在:字节/美团/阿里高频题。二分边界查找的核心模板,是 704→35→34 的进阶路径终点。

延伸思考

Q:为什么用两次二分而不是一次二分找到 target 后向两边扩散?

A:最坏情况整个数组都是 target,扩散是 O(n)。两次二分始终 O(log n)。

Q:binarySearch(False) 后为什么要 -1

A:binarySearch(False) 找第一个 > target 的位置,右边界是最后一个等于 target 的位置,等于”第一个大于的索引减 1”。

Q:为什么找到边界后还要校验 nums[leftIdx] == target

A:二分只保证找到边界位置,不保证 target 一定存在。比如 nums=[1,3], target=2,leftIdx 返回 1(3 的位置),但 3 ≠ 2。

易错点

  • ans = len(nums) 不是 -1

  • 事后必须校验 target 是否存在

  • rightIdx-1


生活类比

两次二分 → 找一个人的朋友圈首发和末条

翻朋友圈的时间线,要找”2024 年发的第一条朋友圈”和”最后一条”。不会一条条翻——先二分找第一条 2024 年的(左边界),再二分找第一条 2025 年的(右边界+1),最后一条就是前一天。


相关题目

题目关系
35搜索插入位置基础:一次边界二分
153寻找旋转数组最小值旋转数组二分
704二分查找二分基础模板

→ 返回题单:LeetCode学习路线图 > 八、二分查找

速记卡(面试闪卡)

Q1:一句话讲清「34. 查找元素的第一个和最后一个位置」到底是什么?

A:在有序数组里找 target 的首尾下标,要求 O(log n);靠两次边界二分分别定位左边界和右边界。

Q2:题目:点名册上的重名(boundary binary search) —— 怎么理解?

A:有序数组里 target 可能重复出现(如 [5,7,7,8,8,10] 找 8 → [3,4])。要的不是「有没有 8」,而是第一个 8 和最后一个 8 的位置。就像点名册按学号排好,要快速找到两个重名 8 号各站哪。

Q3:思路:一次不够就做两次(two-pass 二分) —— 怎么理解?

A:一次二分找到 target 后向两边扩散不行——全数组都是 target 时退化为 O(n),违反 O(log n)。改做两次:第一次找「第一个 ≥ target」(左边界 leftIdx),第二次找「第一个 > target」(rightIdx),右边界 = rightIdx-1。两次始终 O(log n)。

Q4:代码:binarySearch(lower) 封装(O(log n)) —— 怎么理解?

A:一个 binarySearch(lower) 封装两种:lower=True 条件 ≥ 找左边界,lower=False 条件 > 找右边界+1。ans 初始化为 len(nums)(非 -1):target 比所有都大时 leftIdx 返回 n、校验自然失败。最后必须校验 nums[leftIdx]==target,防 target 根本不存在。

Q5:复杂度与实战(O(log n),高频模板) —— 怎么理解?

A:时间 O(log n) 两次二分;空间 O(1)。实战:字节/美团/阿里高频,是 704→35→34 边界二分进阶终点。易错:ans 初值用 len(nums) 不是 -1;rightIdx 要 -1;事后必须校验 target 存在(如 nums=[1,3], target=2 会误返回)。

Q6:核心速记主线有哪些?

  • 题目:有序数组找 target 首尾下标,O(log n)

  • 思路:两次二分,左边界=首≥target,右边界=首>target-1

  • 代码:binarySearch(lower) 封装,ans 初值 len(nums)

  • 复杂度:时间 O(log n),空间 O(1)

  • 易错:校验 target 存在;rightIdx-1;别用扩散法

口诀

A:数组查首尾要周,

两次二分边界求;

左为首发右为收,

对数时间不犯愁。

相关链接