34. 在排序数组中查找元素的第一个和最后一个位置
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]。你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
示例 2:
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
示例 3:
输入:nums = [], target = 0
输出:[-1,-1]
解题思路总览
- 双二分(lower_bound / upper_bound 分离)
- 双指针式收缩二分(分别找左边界与右边界的另一种写法)
- 递归二分同时搜两端(分治递归版)
- 先标准二分再向两侧线性扩展(不满足最坏 O(log n),仅作对比)
- 利用 Arrays.binarySearch 辅助再找边界(最坏 O(n),仅作对比)
说明:题目要求时间复杂度 O(log n)。满足该要求的为:方法1、方法2、方法3。方法4、5 仅用于思路拓展与错误实践分析,实际面试应避免使用(或需额外说明均摊/期望场景)。
思路一:双二分(lower_bound / upper_bound 分离)
原理与适用场景:
利用二分查找有序数组中"第一个 >= target 的位置"(lower_bound)与"第一个 > target 的位置"(upper_bound)。若 lower_bound 下标超界或对应元素不等于 target,说明不存在。否则答案为 [lower_bound, upper_bound - 1]。该模式稳定、语义清晰,是最推荐写法。适用任何需要找区间边界的非递减排序数组问题,比如统计出现次数、插入位置计算等。
实现步骤:
- 定义函数 lowerBound(nums, target):二分寻找第一个 >= target 的索引。
- 定义函数 upperBound(nums, target):二分寻找第一个 > target 的索引。
- 调用 lb = lowerBound(nums, target)。若 lb == n 或 nums[lb] != target -> 返回 [-1,-1]。
- 调用 ub = upperBound(nums, target)。返回 [lb, ub-1]。
- 全部过程每个二分 O(log n)。
JAVA 代码实现:
java
public class SolutionBounds {
// 主函数:返回 target 在有序数组中的起止下标
public int[] searchRange(int[] nums, int target) {
int n = nums.length; // 数组长度
int lb = lowerBound(nums, target); // 第一个 >= target 的位置
// 若 lb 超界或 lb 位置的值不等于 target,说明不存在
if (lb == n || nums[lb] != target) {
return new int[]{-1, -1};
}
int ub = upperBound(nums, target); // 第一个 > target 的位置
return new int[]{lb, ub - 1}; // 右边界 = ub - 1
}
// lower_bound: 找到第一个 >= target 的索引
private int lowerBound(int[] a, int target) {
int l = 0; // 左闭
int r = a.length; // 右开(半开区间 [l, r))
while (l < r) { // 区间非空继续
int mid = l + (r - l) / 2; // 避免 (l+r) 可能的溢出
if (a[mid] >= target) { // mid 位置已经满足 >= target,压缩右端
r = mid;
} else { // a[mid] < target 需要右移
l = mid + 1;
}
}
return l; // 或 r,循环结束 l == r
}
// upper_bound: 找到第一个 > target 的索引
private int upperBound(int[] a, int target) {
int l = 0;
int r = a.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (a[mid] > target) { // mid 已经严格大于 target,右端缩到 mid
r = mid;
} else { // a[mid] <= target,需要继续往右找
l = mid + 1;
}
}
return l; // 返回第一个 > target 的位置
}
}
思路二:双指针式收缩二分(分别找左边界/右边界的另一种写法)
原理与适用场景:
与思路一本质相同,但采用"寻找特定边界"语义化条件:
- 找左边界:在 nums[mid] >= target 时继续向左收缩,保证最终落在最左的 target。
- 找右边界:在 nums[mid] <= target 时继续向右收缩,最终落在最右 target。
写法上多用 (l <= r) 形式并保存答案变量 res。适合已经熟悉普通二分的人过渡到边界二分。
实现步骤:
- left = findLeft(nums, target) 使用经典 while(l <= r)。
- 若 left == -1(未找到)返回 [-1,-1]。
- right = findRight(nums, target)。
- 返回 [left, right]。
JAVA 代码实现:
java
public class SolutionShrink {
public int[] searchRange(int[] nums, int target) {
int left = findLeft(nums, target); // 查找最左出现
if (left == -1) { // 没找到直接返回
return new int[]{-1, -1};
}
int right = findRight(nums, target); // 查找最右出现
return new int[]{left, right};
}
// 查找最左边界
private int findLeft(int[] a, int target) {
int l = 0;
int r = a.length - 1;
int ans = -1; // 记录答案
while (l <= r) { // 闭区间写法
int mid = l + (r - l) / 2;
if (a[mid] >= target) { // mid 可能是左边界,记录并继续左侧搜索
if (a[mid] == target) {
ans = mid; // 暂存候选
}
r = mid - 1; // 收缩右端
} else { // a[mid] < target,只能去右侧
l = mid + 1;
}
}
return ans;
}
// 查找最右边界
private int findRight(int[] a, int target) {
int l = 0;
int r = a.length - 1;
int ans = -1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (a[mid] <= target) { // mid 可能是右边界
if (a[mid] == target) {
ans = mid; // 记录候选
}
l = mid + 1; // 继续右侧
} else { // a[mid] > target 只能去左侧
r = mid - 1;
}
}
return ans;
}
}
思路三:递归二分同时搜两端(分治递归版)
原理与适用场景:
递归拆分区间 [l,r]:
- 若 nums[mid] == target:向左右延展继续递归,合并得最左与最右位置。
- 若 nums[mid] > target:仅递归左半。
- 若 nums[mid] < target:仅递归右半。
剪枝:当区间最小值 > target 或最大值 < target 可直接终止,提升常数。最坏仍 O(log n)(找到后左右延展以二分方式探索边界,不做线性扫描)。
适合想用递归风格写法的场景,便于与其它分治模板统一。
实现步骤:
- 定义递归函数 dfs(nums, l, r, target) 返回 int[]{left,right} 或 {-1,-1}。
- 剪枝:若 l>r 或 target 不在 [nums[l], nums[r]] 范围,返回 {-1,-1}。
- 计算 mid。
- nums[mid] == target:分别向左右递归并合并边界(当前 mid 也纳入)。
- 根据大小关系单侧递归。
- 初始调用并返回。
JAVA 代码实现:
java
public class SolutionRecursive {
public int[] searchRange(int[] nums, int target) {
if (nums == null || nums.length == 0) { // 空数组直接返回
return new int[]{-1, -1};
}
return dfs(nums, 0, nums.length - 1, target);
}
private int[] dfs(int[] a, int l, int r, int target) {
if (l > r) { // 区间为空
return new int[]{-1, -1};
}
if (a[l] > target || a[r] < target) { // 剪枝:整体不可能包含 target
return new int[]{-1, -1};
}
int mid = l + (r - l) / 2; // 中点
if (a[mid] == target) { // 命中,向左右扩展(递归二分方式)
int left = mid;
int right = mid;
// 向左递归继续找更左位置
int[] leftPart = dfs(a, l, mid - 1, target);
if (leftPart[0] != -1) { // 左侧找到至少一个 target
left = leftPart[0];
}
// 向右递归继续找更右位置
int[] rightPart = dfs(a, mid + 1, r, target);
if (rightPart[1] != -1) { // 右侧找到
right = rightPart[1];
}
return new int[]{left, right};
} else if (a[mid] > target) { // 只可能在左半
return dfs(a, l, mid - 1, target);
} else { // a[mid] < target 只可能在右半
return dfs(a, mid + 1, r, target);
}
}
}
思路四:先标准二分定位一个 target 再向两侧线性扩展(不满足最坏 O(log n))
原理与适用场景:
先普通二分找任意一个 target 索引 pos,然后从 pos 向左、向右 while 扫描直到值不等于 target。若 target 大量集中(例如全部元素都相等),两侧扩展会退化为 O(n)。因此最坏不满足题目约束,面试中通常不被接受,除非可额外说明数据分布稀疏、期望复杂度需求。列出此法用于对比"线性扩展风险"。
实现步骤(简述):
- 二分找一个出现位置 pos。
- 若未找到返回 [-1,-1]。
- i=pos 向左减,j=pos 向右增,直到越界或不等。
- 返回 [i+1, j-1]。
复杂度:最坏 O(n)。
思路五:利用 Arrays.binarySearch + 再找边界(最坏 O(n))
原理与适用场景:
调用 Java 标准库 Arrays.binarySearch 得到任意一个匹配位置,然后再像思路四一样线性扩展。其性能瓶颈与思路四相同。优点是初始定位代码更短。仍不满足最坏 O(log n)。
实现步骤(简述):
- pos = Arrays.binarySearch(nums, target)。
- pos < 0 -> [-1,-1]。
- 线性左右扩展。
补充说明(对比分析)
-
正确满足 O(log n) 的方案:
- 思路一(双二分):实现最简洁,可复用 lower/upper 模板,易于扩展到统计次数(ub - lb)。
- 思路二(收缩式):与思路一复杂度相同,适合从"普通二分"过渡到"边界二分"的人群;需要多个 ans 变量。
- 思路三(递归分治):逻辑清晰,可与其他递归模板统一,但递归深度 O(log n),有函数栈开销,迭代更高效。
-
不满足最坏 O(log n)(仅学习对比):
- 思路四、五:在目标元素占多数时扩展 O(n),示例:nums=[2,2,2,2,2], target=2。
-
时间复杂度:
- 思路一:O(log n)
- 思路二:O(log n)
- 思路三:O(log n)
- 思路四:最坏 O(n)
- 思路五:最坏 O(n)
-
空间复杂度:
- 思路一:O(1)
- 思路二:O(1)
- 思路三:O(log n)(递归栈)
- 思路四:O(1)
- 思路五:O(1)
-
代码健壮性注意点:
- mid 计算使用 mid = l + (r - l) / 2 避免整数溢出。
- 检查数组为空(length == 0)直接返回 [-1,-1]。
- 注意区分不同区间表示法:半开 [l,r) vs 闭区间 [l,r],不能混用。
-
推荐结论:
- 首选思路一(lower/upper 模板)。
- 若不熟悉半开区间,可用思路二(闭区间写法)逐步过渡。
- 递归仅在希望统一分治风格时使用。
-
测试用例建议:
- 普通:nums=[5,7,7,8,8,10], target=8 -> [3,4]
- 不存在:nums=[5,7,7,8,8,10], target=6 -> [-1,-1]
- 全部相同:nums=[2,2,2,2], target=2 -> [0,3]
- 单元素匹配:nums=[1], target=1 -> [0,0]
- 单元素不匹配:nums=[1], target=0 -> [-1,-1]
- 边界在首:nums=[3,3,3,4,5], target=3 -> [0,2]
- 边界在尾:nums=[1,2,2,2], target=2 -> [1,3]
- 大量重复及空数组:nums=[], target=1 -> [-1,-1]
综上,使用双二分模板能最直接、稳定地满足题目对 O(log n) 的要求,是最具可维护性的实现方式。