【码道初阶】Leetcode34:在排序数组中查找元素的第一个和最后一个位置的二分查找设计


方法思路

  1. 问题分析
    在一个非递减数组中,寻找目标值的起始和结束位置。若不存在,返回 [-1, -1]。需在 O(log n) 时间内完成。

  2. 关键观察

    • 左边界(第一个等于 target 的位置):通过二分查找找到第一个 不小于 target 的位置。
    • 右边界(最后一个等于 target 的位置):通过二分查找找到第一个 大于 target 的位置,再减一。
  3. 二分查找设计

    • 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};
}
  • 步骤
    1. 处理空数组情况。
    2. 计算左边界 lower 和右边界 upper(通过 upperBound - 1)。
    3. 验证 lower 是否有效且对应值等于 target

示例分析

nums = [5,7,7,8,8,10], target = 8 为例:

  1. lowerBound 过程
    • 最终返回 3(第一个 8 的位置)。
  2. upperBound 过程
    • 返回 5(第一个 >8 的位置),upper = 5-1 = 4
  3. 结果[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

  1. 初始状态l = 0, r = 0 → 循环条件 0 < 0 不成立,直接返回 l = 0
    • 表面正确lowerBound 返回 0,看似正确。
  2. 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
  1. lowerBound 过程

    • 初始区间 [0, 1),循环条件 0 < 1 成立。
    • mid = 0 + (1-0)/2 = 0,检查 nums[0] < 1 → 不成立,更新 r = 0
    • 循环结束,返回 l = 0(正确左边界)。
  2. upperBound 过程

    • 初始区间 [0, 1),循环条件 0 < 1 成立。
    • mid = 0,检查 nums[0] <= 1 → 成立,更新 l = 1
    • 循环结束,返回 l = 1upper = 1 - 1 = 0(正确右边界)。

结果正确[0, 0]


4. 修改为 r = nums.size()-1 的后果

若强制将 r 初始化为 nums.size()-1,但未调整其他逻辑,会导致以下问题:

示例:nums = [2, 2], target = 2
  1. lowerBound(假设 r = 1,循环条件 l < r):

    • 初始区间 [0, 1)(实际只检查索引 0)。
    • 返回 l = 0(正确左边界)。
  2. upperBound(假设 r = 1,循环条件 l < r):

    • 初始区间 [0, 1)mid = 0,检查 nums[0] <= 2 → 成立,更新 l = 1
    • 循环结束,返回 l = 1upper = 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),确保:

  1. 覆盖所有元素,包括数组末尾。
  2. 终止条件严格,避免遗漏或重复检查。
  3. 与边界更新逻辑一致,保证正确性。

若强行改为 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 的位置。
    • 由于数组是非递减的,lowerupper 之间的所有元素必然都等于 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] 范围内,不可能越界。
  • 示例验证
    • 存在 targetnums = [8,8,8]lower = 0upper = 2,均有效。
    • 不存在 targetnums = [5,7,7,10]target = 8lower = 3(越界或 nums[3] != 8),直接返回 [-1,-1]

3. 为何不需要检查 upper

  • 逻辑闭环lower 的检查已经覆盖了所有可能的目标值不存在的情况:
    1. 数组为空:直接返回 [-1, -1]
    2. lower 越界(如 lower = nums.size()):说明所有元素 < target,直接返回 [-1, -1]
    3. 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 的计算错误,但这与算法的数学逻辑矛盾。

通过 lowerBoundupperBound 的配合,结合对 lower 的检查,已严格确保结果的正确性。额外检查 upper 是冗余的,因为算法的数学逻辑和代码实现已覆盖所有边界情况。

你可能感兴趣的:(码道初阶,算法,数据结构,leetcode)