【码道初阶】Leetcode34:在排序数组中查找元素的第一个和最后一个位置的二分查找设计
方法思路
-
问题分析
在一个非递减数组中,寻找目标值的起始和结束位置。若不存在,返回[-1, -1]。需在O(log n)时间内完成。 -
关键观察
- 左边界(第一个等于
target的位置):通过二分查找找到第一个 不小于target的位置。 - 右边界(最后一个等于
target的位置):通过二分查找找到第一个 大于target的位置,再减一。
- 左边界(第一个等于
-
二分查找设计
lowerBound:寻找第一个≥ target的位置。upperBound:寻找第一个> target的位置。
代码解释
整体代码
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
if(nums.empty()) return {-1,-1};
int lower = lowerbound(nums, target);
int upper = upperbound(nums, target) - 1; // 右边界为 upperBound-1
if(lower==nums.size()||nums[lower]!=target) return{-1,-1};
return {lower,upper};
}
int lowerbound(vector<int>& nums,int target)
{
int l=0,r=nums.size(),mid;
while(l<r)
{
mid = l+(r-l)/2;
if(nums[mid]<target)
{
l=mid+1;
}else{
r=mid;
}
}
return r;
}
int upperbound(vector<int>& nums,int target)
{
int l=0,r=nums.size(),mid;
while(l<r)
{
mid = l+(r-l)/2;
if(nums[mid]<=target)
{
l=mid+1;
}else{
r=mid;
}
}
return r;
}
};
1. lowerBound 函数
int lowerBound(vector<int> &nums, int target) {
int l = 0, r = nums.size(), mid;
while (l < r) {
mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1; // 目标在右侧,缩小左边界
} else {
r = mid; // 目标在左侧或当前,缩小右边界
}
}
return l; // 最终 l 是第一个 ≥ target 的位置
}
- 逻辑:若
nums[mid] < target,说明左边界在右侧;否则可能在左侧或当前。 - 终止条件:当
l == r时,l是第一个满足≥ target的位置。
2. upperBound 函数
int upperBound(vector<int> &nums, int target) {
int l = 0, r = nums.size(), mid;
while (l < r) {
mid = l + (r - l) / 2;
if (nums[mid] <= target) {
l = mid + 1; // 目标在右侧,缩小左边界
} else {
r = mid; // 目标在左侧或当前,缩小右边界
}
}
return l; // 最终 l 是第一个 > target 的位置
}
- 逻辑:若
nums[mid] ≤ target,说明右边界在右侧;否则可能在左侧或当前。 - 终止条件:当
l == r时,l是第一个满足> target的位置。
3. 主函数 searchRange
vector<int> searchRange(vector<int> &nums, int target) {
if (nums.empty()) {
return {-1, -1}; // 空数组直接返回
}
int lower = lowerBound(nums, target);
int upper = upperBound(nums, target) - 1; // 右边界为 upperBound-1
// 检查是否存在目标值
if (lower >= nums.size() || nums[lower] != target) {
return {-1, -1};
}
return {lower, upper};
}
- 步骤:
- 处理空数组情况。
- 计算左边界
lower和右边界upper(通过upperBound - 1)。 - 验证
lower是否有效且对应值等于target。
示例分析
以 nums = [5,7,7,8,8,10], target = 8 为例:
lowerBound过程:- 最终返回
3(第一个8的位置)。
- 最终返回
upperBound过程:- 返回
5(第一个>8的位置),upper = 5-1 = 4。
- 返回
- 结果:
[3,4]。
边界情况处理
- 数组为空:直接返回
[-1, -1]。 - 目标不存在:若
lower越界或nums[lower] != target,返回[-1, -1]。 - 单元素数组:如
nums = [1],target = 1,结果为[0,0]。
复杂度分析
- 时间复杂度:两次二分查找,每次
O(log n),总时间为O(log n)。 - 空间复杂度:
O(1),仅用常数空间。
思考:为什么r=nums.size()而不是nums.size()-1?
[1] target=1的情况会输出[0,-1]
在二分查找中,右边界初始化为 nums.size() 而非 nums.size()-1 是设计上的关键,这与二分查找的区间定义和终止条件密切相关。以下详细解释原因,并通过示例分析说明为什么修改为 r = nums.size()-1 会导致错误。
1. 区间定义与初始化
二分查找的区间可以是 左闭右开 [l, r) 或 左闭右闭 [l, r],不同的定义会影响初始化和循环条件。
-
原题解的设计(左闭右开)
初始区间为[0, nums.size()),即所有可能的元素位置。循环条件是l < r,确保区间合法性。 -
若修改为
r = nums.size()-1
此时区间变为左闭右闭[0, nums.size()-1],循环条件需改为l <= r,否则会漏掉最后一个元素。
2. 问题根源分析
当尝试将 r 初始化为 nums.size()-1 时,若未同步调整循环条件和边界更新逻辑,会导致以下问题:
示例:nums = [1], target = 1
假设修改 lowerBound 的初始化为 r = nums.size()-1 = 0,并保持循环条件 l < r:
- 初始状态:
l = 0,r = 0→ 循环条件0 < 0不成立,直接返回l = 0。- 表面正确:
lowerBound返回0,看似正确。
- 表面正确:
upperBound的问题:- 初始状态
l = 0,r = 0→ 循环不执行,返回l = 0。 upper = 0 - 1 = -1→ 最终结果[0, -1],显然错误。
- 初始状态
问题根源:在左闭右闭区间下,循环条件必须为 l <= r,否则无法处理区间内仅剩一个元素的情况。
3. 原题解的设计优势
原题解将区间定义为左闭右开 [l, r),初始化为 r = nums.size(),优势如下:
示例:nums = [1], target = 1
-
lowerBound过程:- 初始区间
[0, 1),循环条件0 < 1成立。 mid = 0 + (1-0)/2 = 0,检查nums[0] < 1→ 不成立,更新r = 0。- 循环结束,返回
l = 0(正确左边界)。
- 初始区间
-
upperBound过程:- 初始区间
[0, 1),循环条件0 < 1成立。 mid = 0,检查nums[0] <= 1→ 成立,更新l = 1。- 循环结束,返回
l = 1→upper = 1 - 1 = 0(正确右边界)。
- 初始区间
结果正确:[0, 0]。
4. 修改为 r = nums.size()-1 的后果
若强制将 r 初始化为 nums.size()-1,但未调整其他逻辑,会导致以下问题:
示例:nums = [2, 2], target = 2
-
lowerBound(假设r = 1,循环条件l < r):- 初始区间
[0, 1)(实际只检查索引0)。 - 返回
l = 0(正确左边界)。
- 初始区间
-
upperBound(假设r = 1,循环条件l < r):- 初始区间
[0, 1),mid = 0,检查nums[0] <= 2→ 成立,更新l = 1。 - 循环结束,返回
l = 1→upper = 1 - 1 = 0。 - 错误结果:
[0, 0](正确应为[0, 1])。
- 初始区间
原因:右边界初始化为 1 导致 upperBound 未检查索引 1。
5. 正确设计的数学保证
-
左闭右开区间
[l, r):
覆盖所有元素,循环终止时l == r,确保所有可能的位置被检查。 -
边界更新逻辑:
若nums[mid] < target→ 左边界更新为mid + 1(排除mid);
否则 → 右边界更新为mid(保留mid作为候选)。
结论
将 r 初始化为 nums.size() 是为了定义左闭右开区间 [l, r),确保:
- 覆盖所有元素,包括数组末尾。
- 终止条件严格,避免遗漏或重复检查。
- 与边界更新逻辑一致,保证正确性。
若强行改为 r = nums.size()-1,需同步修改循环条件和边界更新逻辑,否则会破坏算法的数学保证,导致边界错误。
为何不用再检查upper的情况?
在题解中,无需额外检查 upper 的越界或值是否匹配,因为以下逻辑已经确保当 lower 有效时,upper 必然有效且正确:
1. lower 的有效性保证 upper 的有效性
lower的定义:通过lowerBound找到的是第一个 ≥ target 的位置。若lower有效(未越界且nums[lower] == target),说明数组中存在target。upperBound的定义:返回第一个 > target 的位置,因此upper = upperBound - 1是最后一个 ≤ target 的位置。- 由于数组是非递减的,
lower到upper之间的所有元素必然都等于target。 - 例如,
nums = [5,7,7,8,8,10],target = 8:lowerBound返回3(第一个8),upperBound返回5(第一个>8的位置),upper = 4(最后一个8)。
- 由于数组是非递减的,
2. 关键条件 nums[lower] == target 的覆盖性
- 如果
lower有效且nums[lower] == target,则upper必定有效:upperBound返回的值范围为[0, nums.size()],因此upper = upperBound - 1的范围为[-1, nums.size()-1]。- 但只有当
lower有效时(即存在target),upper才会落在[lower, nums.size()-1]范围内,不可能越界。
- 示例验证:
- 存在
target:nums = [8,8,8],lower = 0,upper = 2,均有效。 - 不存在
target:nums = [5,7,7,10],target = 8,lower = 3(越界或nums[3] != 8),直接返回[-1,-1]。
- 存在
3. 为何不需要检查 upper
- 逻辑闭环:
lower的检查已经覆盖了所有可能的目标值不存在的情况:- 数组为空:直接返回
[-1, -1]。 lower越界(如lower = nums.size()):说明所有元素< target,直接返回[-1, -1]。nums[lower] != target:说明lower是第一个> target的位置(此时target不存在),返回[-1, -1]。
- 数组为空:直接返回
- 数学保证:若
lower有效且nums[lower] == target,则upper必定是最后一个target的位置,无需二次验证。
错误案例模拟
假设强行添加对 upper 的检查:
if (lower >= nums.size() || nums[lower] != target || upper >= nums.size() || nums[upper] != target) {
return {-1, -1};
}
- 冗余性:当
lower有效且nums[lower] == target时,upper一定是最后一个target,不可能出现nums[upper] != target。 - 矛盾性:如果
nums[upper] != target,则说明upperBound的计算错误,但这与算法的数学逻辑矛盾。
通过 lowerBound 和 upperBound 的配合,结合对 lower 的检查,已严格确保结果的正确性。额外检查 upper 是冗余的,因为算法的数学逻辑和代码实现已覆盖所有边界情况。
更多推荐


所有评论(0)