题目描述
思路解析
一句话答案:LeetCode 34 在排序数组中查找元素的第一个和最后一个位置,标准解法是两次二分查找:命中 target 后不停手,一次继续向左收缩逼出左边界,一次继续向右收缩逼出右边界。两次各 O(log n),总时间 O(log n)、空间 O(1),找不到返回 [-1, -1]。
这道题真正在问什么
给一个非递减排序、可能含重复元素的数组 nums,找出 target 出现的开始下标和结束下标;不存在就返回 [-1, -1]。题面明确要求 O(log n) 时间——这一条直接排除了从头到尾线性扫描,等于在提示:必须用二分查找,而且是能处理重复元素的二分。
为什么普通二分命中后不能直接返回
普通二分查找在 nums[mid] 等于 target 时立刻返回 mid,但重复元素面前它只能保证「找到了某一个 target」,完全不知道左边、右边还有没有别的 target。拿示例 nums=[5,7,7,8,8,8,10]、target=8 来说,第一次算中点就可能命中中间那个 8,可题目要的是最左的下标 3 和最右的下标 5。
一个看似聪明的补救是:命中后向左右两侧线性扩展,直到值变化为止。但最坏情况下数组全是 target,扩展要走遍整个数组,复杂度退化成 O(n),违反题目要求。要稳定 O(log n),扩展这一步也必须是二分的。
命中之后继续收缩,边界是怎么被卡出来的
找左边界时,规则只改一处:nums[mid] 等于 target 时先把 mid 记进候选 res,然后不返回,而是把右端点收到 mid 左边一格,强行到更左的区间里继续找。如果左边还有更早的 target,之后的循环会命中它并覆盖 res;如果没有,后续二分全部落空,res 里留着的就是最左命中。找右边界完全对称:命中后把左端点推到 mid 右边一格,往更右找。
这样做为什么不会漏?靠的是数组的单调性:找左边界时每次砍掉的右半区间里,所有下标都比当前命中的 mid 大,绝不可能藏着「更左」的 target,所以砍掉是安全的。每一轮区间严格缩小一半,可行区间空了循环就停,res 恰好停在边界上。
为什么跑两次二分总时间还是 O(log n)
左边界和右边界是两个方向相反的目标,一次二分没法同时把区间往左右两头收,所以标准写法就是同一套框架跑两次,用一个开关参数决定命中后往哪边继续。每次二分把区间砍半,最多 log n 轮;两次加起来是 2·log n 次比较,常数翻倍但量级仍是 O(log n)。空间上只用左右端点、中点和候选几个变量,O(1)。
容易翻车的三个边界细节
一是循环条件必须允许区间剩单个元素时再查一次,写成左端点不超过右端点;只写严格小于会漏掉最后一个候选。二是中点建议写成 mid = l + (r - l) // 2 而不是左右端点直接相加除二,防止大数组下标相加溢出(Python 无此问题,但换到 Java、C++ 就是真 bug)。三是 target 不存在时两次二分的候选都停在初始值 -1,自然返回 [-1, -1],不需要任何额外特判——这是「先记候选再收缩」写法附带的好处。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
核心口诀:两次二分,一次卡到第一个等于 target 的位置(左边界),一次卡到最后一个(右边界)。下面分两段演给你看。
把左指针 l 放在最左 0、右指针 r 放在最右 11,整个数组都是搜索区间。准备第一轮二分。
算出中点 mid=5,nums[5]=8 正好等于 target=8。先把左边界候选记成 5(绿色)——但不能就此返回,左边可能还有更早的 8。
把右指针 r 收到 4,只在更左的区间继续找,逼出最左的 8。
算出中点 mid=2,nums[2]=5 比 target=8 小。target 只可能在右半边。
左指针 l 跳到 3,砍掉左边一半(灰格),下一轮只在右半二分。
算出中点 mid=3,nums[3]=5 比 target=8 小。target 只可能在右半边。
左指针 l 跳到 4,砍掉左边一半(灰格),下一轮只在右半二分。
算出中点 mid=4,nums[4]=7 比 target=8 小。target 只可能在右半边。
左指针 l 跳到 5,砍掉左边一半(灰格),下一轮只在右半二分。
l 越过 r,区间空了,第一段结束。逼出的左边界是下标 5——这是第一个等于 8 的位置。
第二次二分重新从完整区间 [0,11] 开始,l 和 r 复位。这次的目标是最右边的 8。
算出中点 mid=5,nums[5]=8 等于 target=8。记下右边界候选 5(绿色)——右边可能还有更晚的 8。
把左指针 l 收到 6,只在更右的区间继续找,逼出最右的 8。
算出中点 mid=8,nums[8]=8 等于 target=8。记下右边界候选 8(绿色)——右边可能还有更晚的 8。
把左指针 l 收到 9,只在更右的区间继续找,逼出最右的 8。
算出中点 mid=10,nums[10]=12 比 target=8 大,target 在左半。
右指针 r 跳到 9,砍掉右边一半(灰格)。
算出中点 mid=9,nums[9]=10 比 target=8 大,target 在左半。
右指针 r 跳到 8,l 越过 r,第二段结束。逼出的右边界是下标 8——这是最后一个等于 8 的位置。
两次二分各自卡到了左边界 5 和右边界 8(绿色整段就是所有的 8)。两次二分都是 O(log n),合起来还是 O(log n),完美满足要求。
空数组、找不到、单元素这几种边界都要能正确返回 -1 或同一个下标。
面试常被追问「为什么不能线性扩展」——答案是会退化成 O(n)。
参考代码
def searchRange(nums, target): def bound(findLeft): l, r, res = 0, len(nums) - 1, -1 while l <= r: mid = l + (r - l) // 2 if nums[mid] < target: l = mid + 1 elif nums[mid] > target: r = mid - 1 else: res = mid if findLeft: r = mid - 1 # 命中继续往左 else: l = mid + 1 # 命中继续往右 return res return [bound(True), bound(False)]复杂度
- 时间:O(log n),两次二分,每次砍一半,2·log n 仍是 O(log n)
- 空间:O(1),只用 l、r、mid 几个变量,不开额外数组
易错点
面试追问把动画讲成自己的话
追问能不能只用一次二分找到任意一个 target,再向左右线性扩展?
追问左边界和右边界能合并成一次遍历吗?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
搜索插入位置
LeetCode 35 · 简单 · 沿着 二分查找 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题