【LeetCode算法题精讲】二分查找精讲

数据结构与算法精讲 · 第12篇 三语言实现:Python / Java / C++


一、引言

🤔 思考:在一个包含 100 万个有序数字的数组中查找某个目标值,最多需要多少次比较?

答案是 20 次。没错,只需要 20 次------这就是二分查找的魔力。

二分查找是有序数组中的最强武器。它将线性搜索的 O(n) 时间复杂度降到了 O(log n),在数据量越大时优势越明显:

数据规模 线性搜索(最坏) 二分查找(最坏)
100 100 次 7 次
10,000 10,000 次 14 次
1,000,000 1,000,000 次 20 次
1,000,000,000 10 亿次 30 次

但二分查找有一个著名的"坑"------边界条件

🤔 思考:为什么同样是二分查找,有的人写出来 bug-free,有的人写出来总是死循环或越界?

答案在于区间定义 。闭区间 [left, right] 和左闭右开 [left, right) 两种区间定义,决定了循环条件、指针移动方式完全不同。理解了这一点,二分查找的所有变体都能一通百通。

本文将围绕两个经典题目展开:

  1. 704. 二分查找(Easy)------ 标准二分查找,掌握基本功

  2. 34. 在排序数组中查找元素的第一个和最后一个位置(Medium)------ 边界查找,掌握左右边界模板


二、二分查找基础

2.1 什么是二分查找?

二分查找(Binary Search) 是一种在 有序数组 中查找目标值的算法。它的核心思想是:每次将搜索范围缩小一半

算法步骤

  1. 确定搜索区间 [left, right]

  2. 计算中间位置 mid = left + (right - left) / 2

  3. 比较 nums[mid]target

    • 如果 nums[mid] == target:找到目标,返回 mid

    • 如果 nums[mid] < target:目标在右半部分,left = mid + 1

    • 如果 nums[mid] > target:目标在左半部分,right = mid - 1

  4. 重复步骤 2-3,直到区间为空

2.2 时间复杂度

  • 最好情况:O(1)------第一次就命中

  • 最坏情况:O(log n)------需要一直搜索到区间为空

  • 空间复杂度:O(1)------仅需常数级额外空间

2.3 前提条件

  • 数组有序(升序或降序)

  • 随机访问(数组支持 O(1) 的索引访问)

2.4 两种区间定义

二分查找的"循环不变量"是区间定义。区间的定义决定了代码的写法

区间类型 初始化 循环条件 右区间更新 左区间更新
闭区间 left, right right = len-1 left <= right right = mid-1 left = mid+1
左闭右开 [left, right) right = len left < right right = mid left = mid+1

🤔 思考 :为什么闭区间用 left <= right,而左闭右开用 left < right?因为前者区间内包含 right 指向的元素,而后者不包含------当 left == right 时,左闭右开区间已经为空了。

2.5 二分查找过程图解

以下图展示在有序数组 nums = [-1, 0, 3, 5, 9, 12] 中查找 target = 9 的过程(闭区间版本):


三、704. 二分查找(Easy)

3.1 题目描述

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1

示例 1:

复制代码
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

示例 2:

复制代码
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1

3.2 解法思路

这是最基础的二分查找,使用闭区间 [left, right] 实现。

核心逻辑

  • 初始化 left = 0, right = len(nums) - 1

  • 循环条件 left <= right(区间非空)

  • 每次比较 nums[mid]target,缩小搜索范围

🤔 思考 :为什么 mid 的计算要用 left + (right - left) / 2 而不是 (left + right) / 2?因为当 left + right 很大时可能溢出整型范围。left + (right - left) / 2 这种写法避免了溢出风险。

3.3 代码实现

Python
复制代码
class Solution:
    def search(self, nums: List[int], target: int) -> int:
        left, right = 0, len(nums) - 1  # 闭区间 [left, right]
        
        while left <= right:  # 区间非空
            mid = left + (right - left) // 2  # 防止溢出
            
            if nums[mid] == target:
                return mid  # 找到目标,返回索引
            elif nums[mid] < target:
                left = mid + 1  # 目标在右半部分
            else:
                right = mid - 1  # 目标在左半部分
        
        return -1  # 未找到
Java
复制代码
class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;  // 闭区间 [left, right]
        
        while (left <= right) {  // 区间非空
            int mid = left + (right - left) / 2;  // 防止溢出
            
            if (nums[mid] == target) {
                return mid;  // 找到目标,返回索引
            } else if (nums[mid] < target) {
                left = mid + 1;  // 目标在右半部分
            } else {
                right = mid - 1;  // 目标在左半部分
            }
        }
        
        return -1;  // 未找到
    }
}
C++
复制代码
class Solution {
public:
    int search(vector<int>& nums, int target) {
        int left = 0, right = nums.size() - 1;  // 闭区间 [left, right]
        
        while (left <= right) {  // 区间非空
            int mid = left + (right - left) / 2;  // 防止溢出
            
            if (nums[mid] == target) {
                return mid;  // 找到目标,返回索引
            } else if (nums[mid] < target) {
                left = mid + 1;  // 目标在右半部分
            } else {
                right = mid - 1;  // 目标在左半部分
            }
        }
        
        return -1;  // 未找到
    }
};

3.4 左闭右开版本对比

作为对比,下面是左闭右开 [left, right) 的实现:

Python
复制代码
class Solution:
    def search(self, nums: List[int], target: int) -> int:
        left, right = 0, len(nums)  # 左闭右开 [left, right)
        
        while left < right:  # left == right 时区间为空
            mid = left + (right - left) // 2
            
            if nums[mid] == target:
                return mid
            elif nums[mid] < target:
                left = mid + 1  # mid 已经检查过,左区间从 mid+1 开始
            else:
                right = mid  # 注意:右区间是 [left, mid),mid 是开区间,所以 right = mid
                
        return -1

🤔 思考 :对比闭区间版本,左闭右开版本有两个关键区别:① right 初始化为 len(nums) 而不是 len(nums)-1;② 循环条件为 left < right 而不是 left <= right;③ 更新 right 时用 right = mid 而不是 right = mid - 1。这三个区别都由"区间定义"决定,理解了区间定义就不会混淆。

3.5 复杂度分析

维度 分析
时间复杂度 O(log n),n 为数组长度。每次搜索范围缩小一半
空间复杂度 O(1),仅使用常数级变量

3.6 易错点

  1. 循环条件混淆 :闭区间用 left <= right,左闭右开用 left < right。写反了会导致死循环或漏掉元素

  2. mid 计算溢出(left + right) / 2 在 left 和 right 都很大时可能溢出,应使用 left + (right - left) / 2

  3. 区间更新遗漏 :更新区间时,left = mid + 1right = mid - 1 要确保 mid 已经被排除

  4. 数组未排序:二分查找的前提是数组有序,如果数组无序结果会出错

3.7 查找过程图解

nums = [-1, 0, 3, 5, 9, 12] 中查找 target = 9 的完整过程:


四、34. 在排序数组中查找元素的第一个和最后一个位置(Medium)

4.1 题目描述

给你一个按照非递减顺序排列的整数数组 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]

4.2 解法一:找到目标后左右收缩(不推荐)

思路:先用二分查找找到任意一个目标值的位置,然后向左线性扫描找左边界,向右线性扫描找右边界。

问题:最坏情况下(数组中所有元素都等于 target),线性扫描的复杂度为 O(n),不满足题目 O(log n) 的要求。

🤔 思考 :为什么解法一不推荐?因为当数组中有大量重复元素时,线性扫描会退化到 O(n)。例如 nums = [8,8,8,...,8],找到中间后向左向右扫描 n/2 次,总复杂度 O(n)。

4.3 解法二:分离查找左右边界模板(推荐,O(log n))

核心思想 :使用两次二分查找,分别寻找左边界和右边界。关键在于------找到 target 后不立即返回,而是继续收缩搜索区间

左边界查找
  • nums[mid] == target 时,不返回,而是将 right = mid - 1,继续在左半部分搜索

  • 最终退出循环时,left 指向第一个等于 target 的位置(如果存在)

右边界查找
  • nums[mid] == target 时,不返回,而是将 left = mid + 1,继续在右半部分搜索

  • 最终退出循环时,right 指向最后一个等于 target 的位置(如果存在)

🤔 思考 :为什么左边界查找后 left 指向第一个目标值?因为当 nums[mid] == target 时,我们让 right = mid - 1,把所有等于 target 的元素都排除在搜索区间之外,最后 left 会停在第一个 target 的位置。同理,右边界查找让 left = mid + 1,最后 right 停在最后一个 target 的位置。

4.4 代码实现

Python
复制代码
class Solution:
    def searchRange(self, nums: List[int], target: int) -> List[int]:
        def find_left() -> int:
            """查找左边界"""
            left, right = 0, len(nums) - 1
            while left <= right:
                mid = left + (right - left) // 2
                if nums[mid] < target:
                    left = mid + 1
                else:  # nums[mid] >= target
                    # 当 nums[mid] == target 时,继续向左搜索
                    right = mid - 1
            # 退出循环后,left 指向第一个 >= target 的位置
            return left

        def find_right() -> int:
            """查找右边界"""
            left, right = 0, len(nums) - 1
            while left <= right:
                mid = left + (right - left) // 2
                if nums[mid] <= target:
                    # 当 nums[mid] == target 时,继续向右搜索
                    left = mid + 1
                else:  # nums[mid] > target
                    right = mid - 1
            # 退出循环后,right 指向最后一个 <= target 的位置
            return right

        left_idx = find_left()
        right_idx = find_right()

        # 检查是否找到 target
        if left_idx <= right_idx and left_idx < len(nums) and nums[left_idx] == target:
            return [left_idx, right_idx]
        return [-1, -1]
Java
复制代码
class Solution {
    public int[] searchRange(int[] nums, int target) {
        int leftIdx = findLeft(nums, target);
        int rightIdx = findRight(nums, target);

        // 检查是否找到 target
        if (leftIdx <= rightIdx && leftIdx < nums.length && nums[leftIdx] == target) {
            return new int[]{leftIdx, rightIdx};
        }
        return new int[]{-1, -1};
    }

    private int findLeft(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {  // nums[mid] >= target
                // 当 nums[mid] == target 时,继续向左搜索
                right = mid - 1;
            }
        }
        // 退出循环后,left 指向第一个 >= target 的位置
        return left;
    }

    private int findRight(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] <= target) {
                // 当 nums[mid] == target 时,继续向右搜索
                left = mid + 1;
            } else {  // nums[mid] > target
                right = mid - 1;
            }
        }
        // 退出循环后,right 指向最后一个 <= target 的位置
        return right;
    }
}
C++
复制代码
class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int leftIdx = findLeft(nums, target);
        int rightIdx = findRight(nums, target);

        // 检查是否找到 target
        if (leftIdx <= rightIdx && leftIdx < nums.size() && nums[leftIdx] == target) {
            return {leftIdx, rightIdx};
        }
        return {-1, -1};
    }

private:
    int findLeft(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 {  // nums[mid] >= target
                // 当 nums[mid] == target 时,继续向左搜索
                right = mid - 1;
            }
        }
        // 退出循环后,left 指向第一个 >= target 的位置
        return left;
    }

    int findRight(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) {
                // 当 nums[mid] == target 时,继续向右搜索
                left = mid + 1;
            } else {  // nums[mid] > target
                right = mid - 1;
            }
        }
        // 退出循环后,right 指向最后一个 <= target 的位置
        return right;
    }
};

4.5 复杂度分析

维度 分析
时间复杂度 O(log n),两次二分查找,每次 O(log n)
空间复杂度 O(1),仅使用常数级变量

4.6 易错点

  1. 边界判断 :查找完成后,需要验证 leftIdx <= rightIdxnums[leftIdx] == target,确保 target 确实存在

  2. 越界问题leftIdx 可能等于 len(nums)(当 target 大于所有元素时),rightIdx 可能等于 -1(当 target 小于所有元素时)

  3. 左边界查找条件nums[mid] < target 时向右,else(包括 ==)时向左------等于 target 时继续向左收缩

  4. 右边界查找条件nums[mid] <= target 时向右(包括等于),else 时向左------等于 target 时继续向右收缩

  5. 空数组处理nums 为空时,findLeft 返回 0,findRight 返回 -1,leftIdx > rightIdx,触发 [-1, -1] 逻辑

4.7 查找左右边界过程图解

nums = [5, 7, 7, 8, 8, 10] 中查找 target = 8 的左右边界:


五、查找边界模板总结

基于闭区间 [left, right] 和左闭右开 [left, right),我们可以整理出四种二分查找模板。

5.1 闭区间模板 left, right

模板一:标准二分查找(704)
复制代码
def binary_search(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1
模板二:查找左边界(34 左边界)
复制代码
def find_left(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] < target:
            left = mid + 1
        else:  # >= target,继续向左
            right = mid - 1
    return left  # 第一个 >= target 的位置
模板三:查找右边界(34 右边界)
复制代码
def find_right(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] <= target:
            left = mid + 1  # <= target,继续向右
        else:
            right = mid - 1
    return right  # 最后一个 <= target 的位置

5.2 左闭右开模板 [left, right)

模板四:左闭右开标准二分
复制代码
def binary_search_left_open(nums, target):
    left, right = 0, len(nums)  # 注意 right = len(nums)
    while left < right:  # 注意条件
        mid = left + (right - left) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid  # 注意:right = mid 而不是 mid - 1
    return -1

5.3 模板对比

功能 闭区间 左闭右开
初始化 left=0, right=len-1 left=0, right=len
循环条件 left <= right left < right
右区间更新 right = mid - 1 right = mid
左区间更新 left = mid + 1 left = mid + 1
查找左边界 返回 left 返回 left
查找右边界 返回 right 返回 right - 1

🤔 思考 :建议初学者先掌握闭区间 版本。因为闭区间更直观------leftright 都指向实际存在的元素,left <= right 的循环条件也更容易理解。在闭区间版本熟练之后,再学习左闭右开版本。


六、对比总结

对比维度 704. 二分查找 34. 查找首个和最后一个位置
难度 ⭐ Easy ⭐⭐ Medium
重复元素 数组无重复元素 数组可能有重复元素
返回值 单个索引(或 -1) 左右边界 [left, right](或 -1, -1
核心技巧 标准二分查找 左右边界查找模板
边界处理 找到 target 立即返回 找到 target 后继续收缩区间
循环条件 left <= right left <= right
时间复杂度 O(log n) O(log n)
空间复杂度 O(1) O(1)
面试频率 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

核心感悟

  • 704 题是二分查找的基本功------理解区间定义、循环条件、指针移动

  • 34 题是二分查找的进阶应用------处理重复元素时的边界查找

  • 两题共同体现了二分查找的核心思想:每次排除一半不可能的空间


七、家族题

掌握了二分查找的基础和边界查找后,以下题目可以一通百通:

69. x 的平方根(Easy)

  • 题目:计算并返回 x 的平方根,只保留整数部分

  • 解法 :在 [0, x] 范围内二分查找,找到最大的 mid 使得 mid * mid <= x

  • 核心思想二分查找搜索空间------不一定要在数组上二分,可以在答案范围上二分

  • 关键点 :注意 mid * mid 可能溢出,使用 longmid <= x / mid

162. 寻找峰值(Medium)

  • 题目:在一个无序数组中找到一个峰值元素(比左右邻居都大),返回任意一个峰值

  • 解法 :二分查找------如果 nums[mid] < nums[mid+1],峰值在右边;否则在左边

  • 核心思想二分查找不一定需要有序数组------只要有"二段性"就可以二分

  • 关键点:峰值一定存在(数组两端假设为 -∞),利用爬坡方向缩小搜索范围

33. 搜索旋转排序数组(Medium)

  • 题目 :在一个旋转过的有序数组中搜索目标值(如 [4,5,6,7,0,1,2]

  • 解法:二分查找------先判断 mid 在左半段还是右半段,再决定搜索方向

  • 核心思想旋转数组只有一半是有序的------利用有序部分判断 target 是否在其中

  • 关键点 :判断 nums[left] <= nums[mid] 确定左半段是否有序

278. 第一个错误的版本(Easy)

  • 题目:查找第一个导致错误的版本(类似找左边界)

  • 解法 :二分查找------如果 isBadVersion(mid) 为 true,继续向左搜索;否则向右

  • 核心思想二分查找找第一个满足条件的值------本质是左边界查找

  • 关键点:这是 34 题左边界模板的直接应用

家族题难度梯度


八、结语

🤔 思考:回顾一下,二分查找看似简单,为什么能让无数面试者翻车?

二分查找的核心价值在于------它展示了"分而治之"思想的威力:每次排除一半不可能的空间,将 O(n) 的问题降到 O(log n)。

三个关键记忆点

  1. 循环不变量 :区间的定义决定了代码------闭区间 [left, right] vs 左闭右开 [left, right)

  2. 边界查找 :找到 target 后不立即返回,继续收缩区间------right = mid - 1 找左边界,left = mid + 1 找右边界

  3. 二段性:二分查找的核心不是"有序",而是"二段性"------只要能找到某个条件将数组分成两部分,就可以用二分

刷题建议

  • 先手写 3 遍 704 题的闭区间版本,直到能闭着眼写出来

  • 再手写 3 遍 34 题的左右边界查找模板

  • 然后刷家族题:278(左边界)→ 69(答案范围二分)→ 162(无序二分)→ 33(旋转数组二分)

  • 面试时,先明确告诉面试官"我用的是闭区间版本",然后写出代码

下一篇预告双指针专题------从两数之和到盛最多水的容器,全面掌握双指针技巧。


九、参考文献

  1. 力扣官方题解 --- 704. 二分查找

  2. 力扣官方题解 --- 34. 在排序数组中查找元素的第一个和最后一个位置

  3. 代码随想录 --- 二分查找篇

  4. labuladong --- 二分查找模板

  5. LeetCode 69. x 的平方根

  6. LeetCode 162. 寻找峰值

  7. LeetCode 33. 搜索旋转排序数组

  8. LeetCode 278. 第一个错误的版本

相关推荐
民乐团扒谱机1 小时前
【微实验】线性代数之——奇异值分解 SVD:穿透数据迷雾的“X光机”,附 MATLAB 全流程仿真
线性代数·算法·matlab
Escalating_xu1 小时前
【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)
android·java·linux
arin8761 小时前
【图论】网络流
算法
小僧景贤1 小时前
嵌入式之队列解析(裸机/RTOS通用原理+实战场景+源码解析)
数据结构·嵌入式·队列·裸机开发·rtos开发·固件架构
承渊政道1 小时前
10:30提交预约会不会撞上10:00的会议?我用飞算JavaAI3.9.1和3.9.9跑了四个时间段
java·springboot·ai编程·飞算javaai·java代码生成
SL_staff1 小时前
风控规则如何从硬编码解耦?我们在 JVS-Rules 中的实践与思考
java·spring boot·设计模式
_Narcissus_1 小时前
B+树的概念和操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
aichitang20241 小时前
快乐泛函每一天!内积空间
c++·python·数学·算法·机器学习·ai·泛函分析
数据库技术讲堂1 小时前
从 GitOps 到数据库变更:NineData 如何打通 CI/CD 的数据库治理链路
java·数据库·ci/cd