Leetcode 59 二分搜索

1 题目

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]

提示:

  • 0 <= nums.length <= 105
  • -109 <= nums[i] <= 109
  • nums 是一个非递减数组
  • -109 <= target <= 109

2 代码实现

cpp 复制代码
using namespace std ;
class Solution {
public:
    int left_bound(vector<int>& nums ,int target){
        int left = 0 , right = nums.size() - 1 ;
        while (left <= right ){
            int mid = left + (right - left ) / 2 ;
            if (nums[mid] < target ){
                left = mid + 1 ;
            }else if (nums[mid] > target){
                right = mid - 1 ;
            }else if (nums[mid] == target){
                right = mid - 1 ;
            }
        }
        if (left < 0 || left >= nums.size() || nums[left] != target){
            return -1 ;
        }
        return left ;
    }
    int right_bound(vector<int>& nums ,int target){
        int left = 0 , right = nums.size() - 1 ;
        while (left <= right ){
            int mid = left + (right - left ) / 2 ;
            if (nums[mid] < target ){
                left = mid + 1 ;
            }else if (nums[mid] > target){
                right = mid - 1 ;
            }else if (nums[mid] == target){
                left = mid + 1 ;
            }
        }
        if (right < 0 || right >= nums.size() || nums[right] != target){
            return -1 ;
        }
        return right ;
    }


    vector<int> searchRange(vector<int>& nums, int target) {
        int left = left_bound(nums,target);
        int right = right_bound(nums,target);
        return{
            left , right
        };
    }
};

题解

一、题目分析

1. 题目核心要求

  • 输入:非递减(升序)整数数组 nums + 目标值 target
  • 输出:target 在数组中首次出现的索引最后出现的索引 ;若不存在则返回 [-1, -1]
  • 约束:时间复杂度必须为 O(log n)(直接排除暴力遍历 O(n) 解法)

2. 关键特征

  • 数组有序:非递减排列是二分搜索的前提,可通过中间元素快速缩小查找范围
  • 元素可重复:这是本题与「标准二分查找」的核心区别 ------ 标准二分找到目标后直接返回,本题需找到「边界」(首次 / 末次出现位置)
  • 边界场景多:需处理空数组、目标在数组外(小于所有元素 / 大于所有元素)、目标是唯一元素等极端情况

3. 示例解读

  • 示例 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 = [], target=0 → 输出 [-1,-1]空数组无目标元素,返回默认值

二、解题思路

1. 核心思想:二分搜索找边界

由于要求 O(log n) 时间复杂度,只能用二分搜索。本题的关键是将「找起始 + 结束位置」拆分为两个子问题:

  1. target左边界 :首次出现的索引(最小的 index 满足 nums[index] == target
  2. target右边界 :末次出现的索引(最大的 index 满足 nums[index] == target

2. 二分搜索的范式选择

本题采用「左闭右闭 [left, right]」范式(你提供的代码风格),该范式的核心特点:

  • 初始边界:left=0right = nums.size()-1(左右指针均指向有效元素)
  • 循环条件:left <= right(当 left == right 时,区间 [left, right] 仍有一个元素待检查)
  • 指针更新:找到无效元素后,直接排除当前 midleft=mid+1right=mid-1),确保区间严格缩小

3. 左边界查找逻辑(left_bound 函数)

目标:找到「第一个等于 target 的元素索引」,核心是「找到目标后不返回,继续向左收缩范围」:

  • nums[mid] < targettargetmid 右侧,left=mid+1(排除 mid 及左侧)
  • nums[mid] > targettargetmid 左侧,right=mid-1(排除 mid 及右侧)
  • nums[mid] == targetmid 可能是左边界,但左侧可能还有更靠前的 target,因此 right=mid-1(收缩右边界,继续向左查找)
  • 循环终止后:left 是潜在左边界,需检查是否越界 + 是否等于 target

4. 右边界查找逻辑(right_bound 函数)

目标:找到「最后一个等于 target 的元素索引」,核心是「找到目标后不返回,继续向右收缩范围」:

  • nums[mid] < targettargetmid 右侧,left=mid+1(排除 mid 及左侧)
  • nums[mid] > targettargetmid 左侧,right=mid-1(排除 mid 及右侧)
  • nums[mid] == targetmid 可能是右边界,但右侧可能还有更靠后的 target,因此 left=mid+1(收缩左边界,继续向右查找)
  • 循环终止后:right 是潜在右边界,需检查是否越界 + 是否等于 target

5. 整体流程

  1. 调用 left_bound 得到左边界 left_idx
  2. 调用 right_bound 得到右边界 right_idx
  3. 返回 [left_idx, right_idx](若目标不存在,两者均为 -1,符合题目要求)

三、完整代码(带详细注释)

cpp 复制代码
// 引入 std 命名空间,简化代码(LeetCode 环境默认支持)
using namespace std;

class Solution {
public:
    // 查找 target 的左边界(首次出现的索引),不存在返回 -1
    int left_bound(vector<int>& nums, int target) {
        int left = 0;                  // 左指针初始指向数组开头
        int right = nums.size() - 1;   // 右指针初始指向数组末尾(左闭右闭范式)
        
        // 循环条件:left <= right(区间非空,仍有元素待检查)
        while (left <= right) {
            // 计算 mid:避免 (left+right) 溢出的安全写法,等价于 (left+right)/2
            int mid = left + (right - left) / 2;
            
            if (nums[mid] < target) {
                // target 在 mid 右侧,排除 mid 及左侧,左指针右移
                left = mid + 1;
            } else if (nums[mid] > target) {
                // target 在 mid 左侧,排除 mid 及右侧,右指针左移
                right = mid - 1;
            } else if (nums[mid] == target) {
                // 找到 target,但可能不是左边界,收缩右边界继续向左找
                right = mid - 1;
            }
        }
        
        // 循环终止后,检查 left 是否为有效左边界:
        // 1. left 越界(<0 或 >=nums.size())→ 目标不存在
        // 2. nums[left] != target → 目标不存在(如 nums=[1,3,5], target=2,left=1 但 nums[1]=3≠2)
        if (left < 0 || left >= nums.size() || nums[left] != target) {
            return -1;
        }
        // 以上条件均不满足,left 即为左边界
        return left;
    }

    // 查找 target 的右边界(末次出现的索引),不存在返回 -1
    int right_bound(vector<int>& nums, int target) {
        int left = 0;                  // 左指针初始指向数组开头
        int right = nums.size() - 1;   // 右指针初始指向数组末尾(左闭右闭范式)
        
        // 循环条件:left <= right(区间非空,仍有元素待检查)
        while (left <= right) {
            // 安全计算 mid,避免溢出
            int mid = left + (right - left) / 2;
            
            if (nums[mid] < target) {
                // target 在 mid 右侧,排除 mid 及左侧,左指针右移
                left = mid + 1;
            } else if (nums[mid] > target) {
                // target 在 mid 左侧,排除 mid 及右侧,右指针左移
                right = mid - 1;
            } else if (nums[mid] == target) {
                // 找到 target,但可能不是右边界,收缩左边界继续向右找
                left = mid + 1;
            }
        }
        
        // 循环终止后,检查 right 是否为有效右边界:
        // 1. right 越界(<0 或 >=nums.size())→ 目标不存在
        // 2. nums[right] != target → 目标不存在(如 nums=[1,3,5], target=4,right=2 但 nums[2]=5≠4)
        if (right < 0 || right >= nums.size() || nums[right] != target) {
            return -1;
        }
        // 以上条件均不满足,right 即为右边界
        return right;
    }

    // 本题核心函数:组合左边界和右边界,返回结果
    vector<int> searchRange(vector<int>& nums, int target) {
        int left_idx = left_bound(nums, target);  // 获取左边界
        int right_idx = right_bound(nums, target); // 获取右边界
        return {left_idx, right_idx};             // 返回结果数组
    }
};

四、关键细节拆解(避坑指南)

1. mid 计算的溢出问题

为什么用 mid = left + (right - left)/2 而非 (left+right)/2

  • leftright 接近 int 最大值(如 2^31-1)时,left+right 会超出 int 范围(溢出),导致数值错乱
  • 优化写法先算 right-left(结果一定小于 right,不会溢出),再除以 2,最后加 left,保证计算安全

2. 左边界的循环终止后检查

循环终止时 left > right,为什么用 left 作为潜在左边界?

  • 例:nums=[5,7,7,8,8,10], target=8
    • 最后一次循环:left=3, right=3mid=3nums[3]=8),执行 right=2
    • 循环终止:left=3 > right=2left 恰好是首次出现的索引 3
  • 本质:right 收缩到左边界左侧,left 最终停在左边界上

3. 右边界的循环终止后检查

循环终止时 left > right,为什么用 right 作为潜在右边界?

  • 例:nums=[5,7,7,8,8,10], target=8
    • 最后一次循环:left=4, right=4mid=4nums[4]=8),执行 left=5
    • 循环终止:left=5 > right=4right 恰好是末次出现的索引 4
  • 本质:left 扩张到右边界右侧,right 最终停在右边界上

4. 边界检查的必要性

为什么必须检查 nums[left] == targetnums[right] == target

  • 反例:nums=[1,3,5], target=2
    • 左边界查找:mid=1nums[1]=3>2)→ right=0mid=0nums[0]=1<2)→ left=1;循环终止 left=1>right=0
    • 此时 left=1 在数组范围内,但 nums[1]=3≠2,若不检查会误返回 1,导致错误

5. 空数组的处理

nums.size()==0 时,right = nums.size()-1 = -1,循环条件 left<=right0<=-1)不成立,直接进入边界检查,返回 -1,符合预期

五、时间复杂度与空间复杂度分析

1. 时间复杂度:O(log n)

  • 左边界查找:每次循环将区间缩小一半,最多执行 log2(n) 次(如 n=1e5 时,log2(1e5)≈17
  • 右边界查找:同理,也是 O(log n)
  • 总时间复杂度:O(log n) + O(log n) = O(log n),满足题目要求

2. 空间复杂度:O(1)

  • 仅使用 leftrightmid 三个临时变量,无额外空间开销(不考虑函数返回值的数组空间)

六、测试用例全覆盖(验证代码正确性)

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](正确)

4. 目标是唯一元素

  • 输入:nums=[6], target=6 → 输出 [0,0](正确)

5. 数组全是目标元素

  • 输入:nums=[2,2,2], target=2 → 输出 [0,2](正确)

6. 目标在数组开头

  • 输入:nums=[2,2,3,4], target=2 → 输出 [0,1](正确)

7. 目标在数组结尾

  • 输入:nums=[1,3,5,5], target=5 → 输出 [2,3](正确)

8. 目标小于所有元素

  • 输入:nums=[2,4,6], target=1 → 输出 [-1,-1](正确)

9. 目标大于所有元素

  • 输入:nums=[2,4,6], target=7 → 输出 [-1,-1](正确)

七、总结

本题的核心是「二分搜索找边界」,关键在于理解「找到目标后不返回,继续收缩范围」的逻辑:

  • 左边界:找到目标后收缩右边界(right=mid-1),向左逼近
  • 右边界:找到目标后收缩左边界(left=mid+1),向右逼近
  • 边界检查:循环终止后需验证结果的有效性,避免误判

你提供的代码完美契合「左闭右闭」范式,逻辑严谨,只需注意边界检查的完整性,即可通过所有测试用例。这种解法时间复杂度最优,是面试中的标准解法,掌握后可应对所有「二分找边界」类问题。

3 为什么 return 要用花括号包裹

【是因为return 多个值需要花括号吗?】

你的理解方向是对的!

但更准确的说法是:不是 "return 多个值需要花括号",而是花括号在这里构造了一个「临时 vector 对象」,return 实际只返回了这一个 vector 对象 ------ 这个对象里包含了两个元素(左边界、右边界),从而间接实现了 "返回多个结果" 的效果

要彻底搞懂,我们需要从 C++ 的「返回值规则」和「vector 初始化语法」两方面拆解:

一、先明确:C++ 函数不能直接返回多个独立值

C++ 的函数返回值规则是「单值返回 」------ 一个函数只能返回一个 数据(可以是 int、string、自定义类对象等),不能直接写 return a, b;(这是逗号表达式,实际只返回最后一个值 b)。

比如本题需要返回「左边界 + 右边界」两个整数,直接 return left, right; 是错误的,编译器只会返回 right,丢弃 left

那怎么实现 "返回多个结果"?核心思路是:把多个值 "打包" 成一个「单一对象」,返回这个对象即可。本题中用的「vector<int>」就是这个 "打包容器"------vector 是 C++ 的动态数组,可以存储多个同类型元素,正好能装下两个边界值。

二、花括号 {} 的作用:构造临时 vector 对象

return {left, right}; 等价于 return vector<int>{left, right};花括号在这里是「列表初始化(uniform initialization)」语法,作用是:

在 return 语句中直接构造一个临时的 vector<int>对象,这个对象包含两个元素:left(第一个元素)、right(第二个元素),然后函数返回这个临时对象

举个直观的对比:

cpp 复制代码
// 写法1:显式构造vector对象(容易理解)
vector<int> searchRange(...) {
    int left = ...;
    int right = ...;
    vector<int> result = {left, right}; // 用花括号初始化vector
    return result; // 返回这个vector对象
}

// 写法2:隐式构造临时vector对象(你的代码写法)
vector<int> searchRange(...) {
    int left = ...;
    int right = ...;
    return {left, right}; // 直接用花括号构造临时vector,省略显式声明
}

这两种写法完全等价,编译器会自动将 {left, right} 解析为「包含 left 和 right 的 vector<int>临时对象」,然后返回给函数调用者。

三、为什么能这样写?依赖两个 C++ 特性

  1. vector 支持列表初始化 :C++11 及以后,vector 允许用花括号 {} 直接初始化元素,比如 vector<int> arr = {1,2,3}; 是合法的,会创建一个包含 1、2、3 的 vector。
  2. 函数返回值的隐式类型转换 :函数返回类型是 vector<int>,而 {left, right} 能被编译器自动转换为 vector<int> 类型,因此可以直接写在 return 后,无需显式声明。

四、常见误区澄清

  1. 误区:"花括号是用来包裹多个返回值的"纠正:花括号是用来「初始化 vector 对象」的,返回的依然是一个单一的 vector 对象,不是多个独立值。

  2. 误区:"只有 vector 能用这种写法"纠正:所有支持列表初始化的类型都可以,比如返回 pair<int, int>(另一种打包两个值的类型):

    cpp 复制代码
    pair<int, int> searchRange(...) {
        int left = ...;
        int right = ...;
        return {left, right}; // 构造临时pair对象,同样合法
    }

    本题要求返回「数组」(LeetCode 中 vector 和数组在输出时等价),因此用 vector 更合适。

五、总结

return {left, right}; 的本质的是:

  1. 利用 C++ 的列表初始化语法,快速构造一个包含两个边界值的 vector<int> 临时对象;
  2. 函数返回这个单一的 vector 对象,间接实现 "返回多个结果" 的需求;
  3. 这种写法简洁高效,是 C++ 中处理 "多结果返回" 的常用技巧(尤其在 LeetCode 等算法题中)。

简单记:当函数需要返回多个同类型值时,用 vector 打包,花括号直接初始化后返回,本质是返回一个包含所有结果的 vector 对象

相关推荐
卷福同学4 小时前
不用服务器,不用配环境,我10分钟上线了一个AI Agent
人工智能·后端·算法
问君能有几多愁~6 小时前
C++ 数据结构复习笔记
数据结构·c++·笔记
至乐活着6 小时前
深入解析跳表SkipList:原理、实现与性能优化实战
数据结构·算法·跳表·skiplist·java实现
r_oo_ki_e_7 小时前
Java Map 集合学习笔记
java·笔记·学习
Jerry7 小时前
LeetCode 383. 赎金信
算法
ai产品老杨7 小时前
H264 H265视频分析常见问题和排查清单
人工智能·算法·音视频
Jerry8 小时前
LeetCode 454. 四数相加 II
算法
笨鸟先飞的橘猫8 小时前
skynet——sharetable学习
学习·skynet
liuyicenysabel8 小时前
大模型学习笔记 · 第八篇 · 进阶:偏好对齐与多卡训练
人工智能·笔记·学习
可编程芯片开发8 小时前
基于CPS-SPWM链式STATCOM系统在电压不平衡环境下控制策略的simulink建模与仿真
算法