目录
[LeetCode 34:排序数组中查找元素的第一个和最后一个位置](#LeetCode 34:排序数组中查找元素的第一个和最后一个位置)
[1. 为什么用target+1?会有溢出风险吗?](#1. 为什么用target+1?会有溢出风险吗?)
[2. 为什么lowerBound用左闭右开区间?](#2. 为什么lowerBound用左闭右开区间?)
[3. 为什么计算 mid 用left + (right - left)/2?](#3. 为什么计算 mid 用left + (right - left)/2?)
[十一、全闭区间 [left, right]](#十一、全闭区间 [left, right])
[1. 区间定义](#1. 区间定义)
[2. 代码实现](#2. 代码实现)
[3. 关键逻辑解释](#3. 关键逻辑解释)
[4. 作用 / 适用场景](#4. 作用 / 适用场景)
[十二、左开右闭区间 (left, right]](#十二、左开右闭区间 (left, right])
[1. 区间定义](#1. 区间定义)
[2. 代码实现](#2. 代码实现)
[3. 关键逻辑解释](#3. 关键逻辑解释)
[4. 作用 / 适用场景](#4. 作用 / 适用场景)
[1. 区间定义](#1. 区间定义)
[2. 代码实现](#2. 代码实现)
[3. 关键逻辑解释](#3. 关键逻辑解释)
[4. 作用 / 适用场景](#4. 作用 / 适用场景)
LeetCode 34:排序数组中查找元素的第一个和最后一个位置
一、题目描述
给定一个非递减排序 的整数数组
nums和目标值target,请找出target在数组中的起始位置 和结束位置 。若数组中不存在target,返回[-1, -1]。要求:算法的时间复杂度必须为 O(log n)(暴力遍历 O (n) 不满足要求)。
示例说明
- 示例 1:输入:
nums = [5,7,7,8,8,10], target = 8输出:[3,4](8 第一次出现在索引 3,最后一次在索引 4) - 示例 2:输入:
nums = [5,7,7,8,8,10], target = 6输出:[-1,-1](数组中无 6) - 示例 3:输入:
nums = [1], target = 1输出:[0,0](仅一个元素且为 target)
二、核心思路:二分法找「边界」
数组是非递减有序的,这意味着:
- 所有等于
target的元素会连续集中在某个区间内; - 我们只需找到这个区间的左边界 (第一个等于 target 的位置)和右边界(最后一个等于 target 的位置)。
而找边界的关键是:将「找等于 target 的边界」转化为「找第一个≥x 的位置」 (利用lowerBound函数),从而复用同一套二分逻辑,简化代码。
找等于target的左边界---找到第一个≥target的位置
****找等于target的右边界---找到第一个≥target+1的位置,然后-1
三、关键转化逻辑
非递减数组中,「target 的起止位置」可以通过以下规则转化为lowerBound的查询:
| 目标位置 | 转化为「找第一个≥x 的位置」 | 示例(target=8) |
|---|---|---|
| 起始位置(左边界) | x = target | 找第一个≥8 的位置 → 3 |
| 结束位置(右边界) | x = target + 1 | 找第一个≥9 的位置 → 5,再减 1 → 4 |
为什么这个转化成立?
数组非递减的特性保证了:
- 「第一个≥target」的位置,就是 target 第一次出现的位置(若存在);
- 「第一个≥(target+1)」的位置,是第一个比 target 大的数的位置,它的前一个位置必然是 target 最后一次出现的位置。
四、lowerBound函数详解
lowerBound是二分法的核心工具,功能是:在非递减数组 中,找到第一个≥target的元素的索引。
实现逻辑(左闭右开区间)
采用「左闭右开区间 [left, right)」设计(右边界初始为nums.length),好处是:
- 避免处理「最后一个元素」的边界问题;
- 终止条件统一为
left == right,无需额外判断。
java
// 核心:找第一个≥target的元素索引(左闭右开区间)
private int lowerBound(int[] nums, int target) {
int left = 0;
int right = nums.length; // 左闭右开区间:[left, right)
while (left < right) { // 区间不为空时循环
int mid = left + (right - left) / 2; // 避免(left+right)溢出
if (nums[mid] >= target) {
// 目标在左半区,收缩右边界(保留mid,因为可能是第一个≥target的位置)
right = mid;
} else {
// 目标在右半区,收缩左边界(mid不可能是目标,直接+1)
left = mid + 1;
}
}
return left; // 最终left==right,即第一个≥target的位置
}
注意:Java 中计算 mid 时,用
left + (right - left) / 2而非(left + right) / 2,是为了避免left + right超出 int 范围导致溢出(比如 left 和 right 都是接近 Integer.MAX_VALUE 的数)。
五、完整代码实现(Java)
java
class Solution {
public int[] searchRange(int[] nums, int target) {
// 步骤1:找起始位置(第一个≥target的位置)
int start = lowerBound(nums, target);
// 验证target是否存在:
// - start越界(所有元素都<target)
// - start位置的元素≠target(无target)
if (start == nums.length || nums[start] != target) {
return new int[]{-1, -1};
}
// 步骤2:找结束位置(第一个≥(target+1)的位置 - 1)
int end = lowerBound(nums, target + 1) - 1;
return new int[]{start, end};
}
// 核心:找第一个≥target的元素索引(左闭右开区间)
private int lowerBound(int[] nums, int target) {
int left = 0;
int right = nums.length; // 左闭右开区间:[left, right)
while (left < right) { // 区间不为空时循环
int mid = left + (right - left) / 2; // 避免溢出
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
}
六、示例流程拆解
(nums=[5,7,7,8,8,10], target=8)
步骤 1:找 start = lowerBound (nums, 8)
| 循环次数 | left | right | mid | nums[mid] | 条件(nums [mid]≥8) | 调整后 left/right |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 8 | 是 | right=3 |
| 2 | 0 | 3 | 1 | 7 | 否 | left=2 |
| 3 | 2 | 3 | 2 | 7 | 否 | left=3 |
| 终止 | 3 | 3 | - | - | - | 返回 3 |
步骤 2:找 end = lowerBound (nums, 9) - 1
先计算lowerBound(nums, 9):
| 循环次数 | left | right | mid | nums[mid] | 条件(nums [mid]≥9) | 调整后 left/right |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 8 | 否 | left=4 |
| 2 | 4 | 6 | 5 | 10 | 是 | right=5 |
| 3 | 4 | 5 | 4 | 8 | 否 | left=5 |
| 终止 | 5 | 5 | - | - | - | 返回 5 |
因此end = 5 - 1 = 4,最终返回[3,4]。
七、边界情况测试
情况 1:数组为空(nums=new int [0])
start = lowerBound(new int[0], target) = 0;- 验证
start == nums.length(0==0),返回[-1,-1]。
情况 2:target 是第一个元素(nums=[2,2,3], target=2)
start = lowerBound(nums,2) = 0;end = lowerBound(nums,3) - 1 = 2 - 1 = 1;- 返回
[0,1]。
情况 3:target 是最后一个元素(nums=[1,3,5], target=5)
start = lowerBound(nums,5) = 2;end = lowerBound(nums,6) - 1 = 3 - 1 = 2;- 返回
[2,2]。
八、传统解法对比(分别找左右边界)
除了复用lowerBound,也可以分别写「找左边界」和「找右边界」的二分函数,逻辑更直观但代码稍冗余:
java
class Solution {
public int[] searchRange(int[] nums, int target) {
int left = findLeft(nums, target);
int right = findRight(nums, target);
return new int[]{left, right};
}
// 找左边界:第一个等于target的位置
private int findLeft(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int res = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
res = mid;
right = mid - 1; // 继续找更左的位置
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return res;
}
// 找右边界:最后一个等于target的位置
private int findRight(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int res = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
res = mid;
left = mid + 1; // 继续找更右的位置
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return res;
}
}
九、常见问题解答
1. 为什么用target+1?会有溢出风险吗?
转化逻辑的核心是 "找第一个比 target 大的数",
target+1是最直接的方式;若
target是int最大值(Integer.MAX_VALUE),target+1会溢出为Integer.MIN_VALUE,此时可优化逻辑:
java// 避免target+1溢出的写法 int end = lowerBound(nums, target) == nums.length ? -1 : lowerBound(nums, target + 1) - 1;实际面试 / 刷题中,
target为Integer.MAX_VALUE的场景极少,上述优化仅作兜底。
2. 为什么lowerBound用左闭右开区间?
- 右边界设为
nums.length,避免了 "数组最后一个元素" 的越界判断(比如数组长度为 0 时,right=0 直接返回);- 终止条件
left==right更简洁,无需额外处理left>right的情况;- 符合 Java 中数组的索引习惯(索引范围
0 ~ nums.length-1,右边界设为nums.length刚好覆盖所有可能)。
3. 为什么计算 mid 用left + (right - left)/2?
Java 中int的取值范围是[-2^31, 2^31-1],若left和right都接近2^31-1,left + right会超出int范围导致整数溢出 ,而left + (right - left)/2能避免这个问题(等价于(left + right)/2,但无溢出风险)。
十、关键点
这道题的核心是利用二分法找「边界」 ,复用lowerBound的转化思路是 Java 版的最优写法:
- 起始位置 = 第一个≥target 的位置;
- 结束位置 = 第一个≥(target+1) 的位置 - 1;
- 验证 target 是否存在,不存在则返回
[-1,-1]。
该写法兼顾了代码简洁性和效率(时间复杂度 O (log n),空间复杂度 O (1)),符合题目要求,也是面试中面试官期望看到的最优解。
下面逐一讲解「全闭、左开右闭、全开」三种区间的代码实现、逻辑和作用,并对比差异。
十一、全闭区间 [left, right]
1. 区间定义
- 左指针
left:初始0(包含,数组第一个元素); - 右指针
right:初始nums.length - 1(包含,数组最后一个元素); - 循环条件:
left <= right(区间不为空,因为闭区间left == right时仍有一个元素);
2. 代码实现
java
// 全闭区间 [left, right] 实现 lowerBound
private int lowerBound_Closed(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
// 初始化结果为数组长度(默认所有元素都 < target)
int res = nums.length;
while (left <= right) {
int mid = left + (right - left) / 2; // 避免溢出
if (nums[mid] >= target) {
// 找到≥target的元素,记录位置,继续找更左的位置
res = mid;
right = mid - 1; // 收缩右边界(排除mid,找左半区)
} else {
// 元素<target,找右半区
left = mid + 1;
}
}
return res; // 最终res是第一个≥target的位置
}
3. 关键逻辑解释
(以 nums=[5,7,7,8,8,10], target=8 为例)
| 循环次数 | left | right | mid | nums[mid] | 条件(≥8) | 调整逻辑 | res |
|---|---|---|---|---|---|---|---|
| 1 | 0 | 5 | 2 | 7 | 否 | left=3 | 6 |
| 2 | 3 | 5 | 4 | 8 | 是 | res=4,right=3 | 4 |
| 3 | 3 | 3 | 3 | 8 | 是 | res=3,right=2 | 3 |
| 终止 | 3 | 2 | - | - | - | 返回 res=3 | 3 |
4. 作用 / 适用场景
- 优点:逻辑最直观,符合新手对 "区间" 的认知(从 0 到最后一个元素),调试时容易跟踪指针位置;
- 缺点:需要额外维护
res变量(因为循环终止时left > right,无法直接返回指针),略增加代码量;- 适用:新手入门、面试中快速手写(不易出错)。
十二、左开右闭区间 (left, right]
1. 区间定义
- 左指针
left:初始-1(不包含,数组第一个元素的左侧); - 右指针
right:初始nums.length - 1(包含,数组最后一个元素); - 循环条件:
left < right(区间不为空,开区间左指针不能等于右指针);
2. 代码实现
java
// 左开右闭区间 (left, right] 实现 lowerBound
private int lowerBound_LeftOpenRightClosed(int[] nums, int target) {
int left = -1;
int right = nums.length - 1;
while (left < right) {
// 取上中位数(避免死循环):(left + right + 1) / 2
int mid = left + (right - left + 1) / 2;
if (nums[mid] >= target) {
// 目标在左半区,收缩右边界(保留mid,因为right是闭区间)
right = mid - 1;
} else {
// 目标在右半区,收缩左边界(left是开区间,直接设为mid)
left = mid;
}
}
// 循环结束时left=right,第一个≥target的位置是left+1
return left + 1;
}
3. 关键逻辑解释
(以 nums=[5,7,7,8,8,10], target=8 为例)
| 循环次数 | left | right | mid | nums[mid] | 条件(≥8) | 调整逻辑 |
|---|---|---|---|---|---|---|
| 1 | -1 | 5 | 2 | 7 | 否 | left=2 |
| 2 | 2 | 5 | 4 | 8 | 是 | right=3 |
| 3 | 2 | 3 | 3 | 8 | 是 | right=2 |
| 终止 | 2 | 2 | - | - | - | 返回 left+1=3 |
4. 作用 / 适用场景
- 优点:无需额外维护 res 变量,循环结束后通过
left+1直接得到结果; - 缺点:左指针初始为 - 1(违反直觉),且需要取「上中位数」(
mid = left + (right-left+1)/2)避免死循环,新手易出错; - 适用:算法竞赛中追求代码极简(少变量),但日常开发 / 面试不推荐。
十三、全开区间 (left, right)
1. 区间定义
- 左指针
left:初始-1(不包含,数组第一个元素左侧); - 右指针
right:初始nums.length(不包含,数组最后一个元素右侧); - 循环条件:
left + 1 < right(区间不为空,全开区间需至少隔一个元素);
2. 代码实现
java
// 全开区间 (left, right) 实现 lowerBound
private int lowerBound_AllOpen(int[] nums, int target) {
int left = -1;
int right = nums.length;
while (left + 1 < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
// 目标在左半区,收缩右边界
right = mid;
} else {
// 目标在右半区,收缩左边界
left = mid;
}
}
// 循环结束时left+1=right,right就是第一个≥target的位置
return right;
}
3. 关键逻辑解释
(以 nums=[5,7,7,8,8,10], target=8 为例)
| 循环次数 | left | right | mid | nums[mid] | 条件(≥8) | 调整逻辑 |
|---|---|---|---|---|---|---|
| 1 | -1 | 6 | 2 | 7 | 否 | left=2 |
| 2 | 2 | 6 | 4 | 8 | 是 | right=4 |
| 3 | 2 | 4 | 3 | 8 | 是 | right=3 |
| 终止 | 2 | 3 | - | - | - | 返回 right=3 |
4. 作用 / 适用场景
- 优点:区间逻辑完全对称(左右都开),数学上更优雅;
- 缺点:指针初始值违反直觉,循环条件是
left+1 < right(新手易写错),实际应用场景极少; - 适用:算法理论研究 / 教学(讲解区间对称性),工程开发 / 面试几乎不用。
十四、各区间核心差异对比表
| 区间类型 | 初始化(left/right) | 循环条件 | 指针调整核心 | 返回值 | 优点 | 缺点 |
|---|---|---|---|---|---|---|
| 左闭右开 [L,R) | 0 / nums.length | left < right | right=mid / left=mid+1 | left(=right) | 代码极简、无死循环 | 右边界为 nums.length(略反直觉) |
| 全闭 [L,R] | 0 / nums.length-1 | left <= right | 需维护 res,right=mid-1/L=mid+1 | res | 逻辑最直观、新手友好 | 多一个 res 变量 |
| 左开右闭 (L,R] | -1 / nums.length-1 | left < right | 取上中位数,right=mid-1/L=mid | left+1 | 无 res 变量 | 初始值反直觉、易死循环 |
| 全开 (L,R) | -1 / nums.length | left+1 < right | right=mid / left=mid | right | 区间对称、数学优雅 | 条件 / 初始值易写错 |
十五、选型建议
-
优先选左闭右开 [left, right):
- 代码极简(无需 res 变量)、无死循环风险,是 Java/C++ 标准库(如
Arrays.binarySearch)的底层实现逻辑,面试 / 工作中写这个版本,面试官会认为你懂标准写法; - 唯一的 "反直觉"(right 初始为 nums.length),但记住 "右边界是数组长度" 即可,习惯后比全闭区间更高效。
- 代码极简(无需 res 变量)、无死循环风险,是 Java/C++ 标准库(如
-
新手过渡可选全闭区间 [left, right]:
- 先通过全闭区间理解二分逻辑,再过渡到左闭右开,避免一开始就被 "开区间" 绕晕。
-
左开右闭 / 全开区间:尽量不用:
- 实际应用场景极少,且容易因 "上中位数""循环条件" 写错导致死循环,面试中写这种写法,反而容易被面试官挑错。
十六、完整验证代码
java
class Solution {
public int[] searchRange(int[] nums, int target) {
// 任选一种lowerBound实现,结果一致
int start = lowerBound_Closed(nums, target);
// int start = lowerBound(nums, target); // 左闭右开版
// int start = lowerBound_LeftOpenRightClosed(nums, target); // 左开右闭版
// int start = lowerBound_AllOpen(nums, target); // 全开版
if (start == nums.length || nums[start] != target) {
return new int[]{-1, -1};
}
int end = lowerBound_Closed(nums, target + 1) - 1;
return new int[]{start, end};
}
// 左闭右开(推荐)
private int lowerBound(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
// 全闭区间
private int lowerBound_Closed(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int res = nums.length;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
res = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}
// 左开右闭区间
private int lowerBound_LeftOpenRightClosed(int[] nums, int target) {
int left = -1;
int right = nums.length - 1;
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (nums[mid] >= target) {
right = mid - 1;
} else {
left = mid;
}
}
return left + 1;
}
// 全开区间
private int lowerBound_AllOpen(int[] nums, int target) {
int left = -1;
int right = nums.length;
while (left + 1 < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid;
}
}
return right;
}
}
