查找元素的首末位置 图解题解
找有序数组里某值的首末下标,二分跑两趟:一趟找左边界,一趟找右边界,复用同一套模板。
找升序数组里某个数的「第一次和最后一次出现」,就像在一排已经按姓氏排好的名单里找第一个和最后一个「王」——先用二分找到「第一个不小于王的位置」得到左界,再用二分找「第一个比王大的位置」减一得到右界。同一个二分跑两次,左闭右开区间保证候选不丢,全程 O(log n)。
这道题到底在问什么
- 输入
- nums=[5,7,7,8,8,8,10], target=8
- 输出
- [3, 5] (8 第一次在下标 3,最后一次在下标 5)
最优解:为什么这么做
一句话答案: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],不需要任何额外特判——这是「先记候选再收缩」写法附带的好处。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3核心口诀:两次二分,一次卡到第一个等于 target 的位置(左边界),一次卡到最后一个(右边界)。下面分两段演给你看。
- 5把左指针 l 放在最左 0、右指针 r 放在最右 11,整个数组都是搜索区间。准备第一轮二分。
- 6算出中点 mid=5,nums[5]=8 正好等于 target=8。先把左边界候选记成 5(绿色)——但不能就此返回,左边可能还有更早的 8。
- 7把右指针 r 收到 4,只在更左的区间继续找,逼出最左的 8。
- 8算出中点 mid=2,nums[2]=5 比 target=8 小。target 只可能在右半边。
- 9左指针 l 跳到 3,砍掉左边一半(灰格),下一轮只在右半二分。
- 10算出中点 mid=3,nums[3]=5 比 target=8 小。target 只可能在右半边。
- 11左指针 l 跳到 4,砍掉左边一半(灰格),下一轮只在右半二分。
- 12算出中点 mid=4,nums[4]=7 比 target=8 小。target 只可能在右半边。
- 13左指针 l 跳到 5,砍掉左边一半(灰格),下一轮只在右半二分。
- 14l 越过 r,区间空了,第一段结束。逼出的左边界是下标 5——这是第一个等于 8 的位置。
- 16第二次二分重新从完整区间 [0,11] 开始,l 和 r 复位。这次的目标是最右边的 8。
- 17算出中点 mid=5,nums[5]=8 等于 target=8。记下右边界候选 5(绿色)——右边可能还有更晚的 8。
- 18把左指针 l 收到 6,只在更右的区间继续找,逼出最右的 8。
- 19算出中点 mid=8,nums[8]=8 等于 target=8。记下右边界候选 8(绿色)——右边可能还有更晚的 8。
- 20把左指针 l 收到 9,只在更右的区间继续找,逼出最右的 8。
- 21算出中点 mid=10,nums[10]=12 比 target=8 大,target 在左半。
- 22右指针 r 跳到 9,砍掉右边一半(灰格)。
- 23算出中点 mid=9,nums[9]=10 比 target=8 大,target 在左半。
- 24右指针 r 跳到 8,l 越过 r,第二段结束。逼出的右边界是下标 8——这是最后一个等于 8 的位置。
- 25两次二分各自卡到了左边界 5 和右边界 8(绿色整段就是所有的 8)。两次二分都是 O(log n),合起来还是 O(log n),完美满足要求。
⚠️ 容易写错的地方
✗ 错:命中 target 就立刻 return mid
✓ 对:要继续往一侧收缩才能卡到边界
直接返回只能拿到「某一个」target,无法保证是最左/最右
✗ 错:mid = (l+r)/2 可能溢出
✓ 对:mid = l + (r-l)/2 更安全
l+r 在大数组里可能超过 int 上限
✗ 错:循环条件写成 l < r
✓ 对:应是 l <= r
区间剩一个元素时 l==r 也要检查,否则漏判
完整代码(Python / C++ / Java)
Python
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)]C++
class Solution {
int bound(vector<int>& a, int t, bool left){
int l = 0, r = a.size() - 1, res = -1;
while(l <= r){
int mid = l + (r - l) / 2;
if(a[mid] < t) l = mid + 1;
else if(a[mid] > t) r = mid - 1;
else { res = mid; if(left) r = mid - 1; else l = mid + 1; }
}
return res;
}
public:
vector<int> searchRange(vector<int>& nums, int target){
return {bound(nums, target, true), bound(nums, target, false)};
}
};Java
class Solution {
private int bound(int[] a, int t, boolean left) {
int l = 0, r = a.length - 1, res = -1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (a[mid] < t) {
l = mid + 1; // mid 太小,找右半
} else if (a[mid] > t) {
r = mid - 1; // mid 太大,找左半
} else {
res = mid; // 命中,先记下
if (left) r = mid - 1; // 卡左边界:继续往左
else l = mid + 1; // 卡右边界:继续往右
}
}
return res;
}
public int[] searchRange(int[] nums, int target) {
return new int[]{bound(nums, target, true), bound(nums, target, false)};
}
}复杂度
时间
O(log n)
两次二分,每次砍一半,2·log n 仍是 O(log n)
空间
O(1)
只用 l、r、mid 几个变量,不开额外数组
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 查找元素的首末位置 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
能不能只用一次二分找到任意一个 target,再向左右线性扩展?+
能写出来,但最坏情况退化成 O(n)。比如数组全是 target,向两边扩展要走遍整个数组,违反 O(log n) 要求。两次二分才稳定 O(log n)。
左边界和右边界能合并成一次遍历吗?+
本质是两个独立的二分目标(第一个 / 最后一个),方向相反,没法用一次二分同时卡住两端,所以标准写法就是跑两次。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 查找元素的首末位置 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。