【笔面试算法学习专栏】二分查找专题:力扣hot100经典题目深度解析

目录

  1. 二分查找算法基础
  2. [题目一:搜索旋转排序数组(LeetCode 33)](#题目一:搜索旋转排序数组(LeetCode 33))
  3. [题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)](#题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34))
  4. [题目三:寻找旋转排序数组中的最小值(LeetCode 153)](#题目三:寻找旋转排序数组中的最小值(LeetCode 153))
  5. 二分查找变式与技巧总结
  6. 实战练习与扩展

1. 二分查找算法基础

二分查找(Binary Search)是计算机科学中最经典的算法之一,广泛应用于有序数组的高效查找。其核心思想是通过不断缩小搜索范围,将查找时间复杂度从线性 O ( n ) O(n) O(n) 优化到对数 O ( log ⁡ n ) O(\log n) O(logn)。

1.1 基本原理与适用条件

二分查找的基本原理基于有序性单调性两个关键前提:

  1. 有序性:数组必须是有序的(通常为升序)
  2. 单调性:数组元素具有单调递增或递减的特性

算法通过以下步骤实现:

  • 初始化左右指针:left = 0right = n-1
  • 计算中间位置:mid = left + (right - left) // 2(防止溢出)
  • 比较 nums[mid]target
    • 若相等,返回 mid
    • nums[mid] < target,目标在右侧,更新 left = mid + 1
    • nums[mid] > target,目标在左侧,更新 right = mid - 1
  • 重复直到 left > right,返回 -1

1.2 时间复杂度分析

二分查找的时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn),其中 n n n 是数组长度。这是因为每次迭代都将搜索范围减半:

  • 第1次迭代:范围 n n n
  • 第2次迭代:范围 n / 2 n/2 n/2
  • 第3次迭代:范围 n / 4 n/4 n/4
  • ...
  • 第 k k k 次迭代:范围 n / 2 k − 1 n/2^{k-1} n/2k−1

当范围缩小到1时停止,即 n / 2 k − 1 = 1 n/2^{k-1} = 1 n/2k−1=1,解得 k = log ⁡ 2 n + 1 k = \log_2 n + 1 k=log2n+1,故时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)。

空间复杂度为 O ( 1 ) O(1) O(1),仅使用常数个变量。

1.3 标准二分查找模板

二分查找有两种常见的区间定义方式,每种方式都有对应的模板:

模板一:闭区间 [left, right](直观易懂,推荐新手使用)

python 复制代码
def binary_search(nums, target):
    """
    在有序数组nums中查找target,返回其索引,若不存在返回-1
    """
    left, right = 0, len(nums) - 1
    
    while left <= right:  # 闭区间:当left > right时区间为空
        mid = left + (right - left) // 2  # 防止溢出
        
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1  # 目标在右侧,排除mid及左侧
        else:  # nums[mid] > target
            right = mid - 1  # 目标在左侧,排除mid及右侧
    
    return -1  # 未找到

模板二:左闭右开区间 [left, right)(防越界,推荐进阶使用)

python 复制代码
def binary_search_open(nums, target):
    """
    左闭右开区间实现
    """
    left, right = 0, len(nums)  # 注意:right初始为len(nums),不包含
    
    while left < right:  # 左闭右开:当left == right时区间为空
        mid = left + (right - left) // 2
        
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1  # 目标在右侧
        else:
            right = mid  # 目标在左侧,注意这里不是mid-1
    
    return -1

注: 两种模板的核心区别在于区间定义和指针更新:

  • 闭区间:right = mid - 1 排除mid
  • 左闭右开:right = mid 排除mid(mid不在新区间内)

选择哪种模板取决于个人习惯,关键在于保持一致性,不要混用。

1.4 适用场景与局限性

二分查找适用于以下场景:

  1. 静态有序数组:数组元素不频繁变动,且已排序
  2. 查找单个元素:返回目标值的位置或确认不存在
  3. 数据量较大:线性查找不可行时

局限性包括:

  1. 必须有序:对无序数组需要先排序
  2. 内存连续:要求数组在内存中连续存储(链表不适用)
  3. 查找单个值:不适用于区间查找、最值查找等变式(需改造算法)

2. 题目一:搜索旋转排序数组(LeetCode 33)

2.1 题目描述

题目链接LeetCode 33. Search in Rotated Sorted Array

题目要求

  • 给定一个升序排列的整数数组 nums,数组在某个未知下标 k 处进行了旋转
  • 旋转后的数组形式为:[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
  • 数组中没有重复元素
  • 在 O ( log ⁡ n ) O(\log n) O(logn) 时间复杂度内找到目标值 target 的索引,不存在则返回 -1

示例

复制代码
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

2.2 问题分析与核心思路

旋转排序数组的特点:整体无序但局部有序。具体来说:

  • 数组被分成两个严格递增的子数组
  • 左子数组的所有元素 > 右子数组的所有元素(因为原数组严格递增)

关键洞察 :在旋转数组中,对于任意中间位置 mid,其左侧或右侧必有一侧是完全有序的

证明:

  • 设旋转点为 k,数组分为 [0, k-1][k, n-1] 两段升序
  • 任取 mid
    • mid < k[0, mid] 完全在左段,有序
    • mid >= k[mid, n-1] 完全在右段,有序

因此,二分查找仍适用,但判断逻辑需调整:

  1. 判断 mid 所在侧是否有序
  2. 若有序,检查 target 是否在该有序区间内
  3. 根据结果缩小搜索范围

2.3 算法步骤详解

步骤1:初始化指针

复制代码
left = 0, right = len(nums) - 1

步骤2:进入循环(while left <= right

  1. 计算 mid = left + (right - left) // 2
  2. nums[mid] == target,直接返回 mid
  3. 判断左侧是否有序:
    • 情况Anums[left] <= nums[mid](左侧有序)
      • nums[left] <= target < nums[mid]target 在左侧有序区间内,更新 right = mid - 1
      • 否则:target 在右侧,更新 left = mid + 1
    • 情况Bnums[left] > nums[mid](右侧有序)
      • nums[mid] < target <= nums[right]target 在右侧有序区间内,更新 left = mid + 1
      • 否则:target 在左侧,更新 right = mid - 1

步骤3:循环结束未找到,返回 -1

2.4 边界条件处理

关键细节

  1. 等号处理
    • 判断左侧有序时用 nums[left] <= nums[mid],包含 left == mid 的情况
    • 检查 target 范围时,注意排除 nums[mid](已判断不相等)
  2. 数组长度
    • 空数组直接返回 -1
    • 单元素数组直接比较
  3. 旋转点位置
    • 旋转点在开头(完全有序):算法仍正确
    • 旋转点在结尾(完全有序):算法仍正确

2.5 Python代码实现

python 复制代码
def search_rotated(nums, target):
    """
    在旋转排序数组中搜索目标值
    时间复杂度:O(log n),空间复杂度:O(1)
    """
    if not nums:
        return -1
    
    left, right = 0, len(nums) - 1
    
    while left <= right:
        mid = left + (right - left) // 2
        
        if nums[mid] == target:
            return mid
        
        # 判断左侧是否有序
        if nums[left] <= nums[mid]:
            # 左侧有序
            if nums[left] <= target < nums[mid]:
                # target在左侧有序区间内
                right = mid - 1
            else:
                # target在右侧
                left = mid + 1
        else:
            # 右侧有序
            if nums[mid] < target <= nums[right]:
                # target在右侧有序区间内
                left = mid + 1
            else:
                # target在左侧
                right = mid - 1
    
    return -1

注: 代码中 nums[left] <= target < nums[mid] 的等号处理:

  • nums[left] <= target:包含 target == nums[left] 的情况
  • target < nums[mid]:排除 target == nums[mid](已在前面的if判断)

2.6 复杂度分析

  • 时间复杂度 : O ( log ⁡ n ) O(\log n) O(logn)
    • 每次迭代范围减半,最多 log ⁡ 2 n \log_2 n log2n 次
  • 空间复杂度 : O ( 1 ) O(1) O(1)
    • 只使用常数个变量

2.7 测试用例验证

python 复制代码
# 测试用例
test_cases = [
    ([4,5,6,7,0,1,2], 0, 4),
    ([4,5,6,7,0,1,2], 3, -1),
    ([1], 0, -1),
    ([1], 1, 0),
    ([1,3], 3, 1),
    ([3,1], 1, 1),
    ([1,2,3,4,5], 3, 2),  # 未旋转
]

for nums, target, expected in test_cases:
    result = search_rotated(nums, target)
    assert result == expected, f"Failed: nums={nums}, target={target}, got {result}, expected {expected}"

3. 题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)

3.1 题目描述

题目链接LeetCode 34. Find First and Last Position of Element in Sorted Array

题目要求

  • 给定一个按照非递减顺序排列的整数数组 nums,和一个目标值 target
  • 找出目标值在数组中的开始位置和结束位置
  • 若不存在,返回 [-1, -1]
  • 要求算法时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)

示例

复制代码
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]

输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]

输入:nums = [], target = 0
输出:[-1,-1]

3.2 问题分析与核心思路

本题本质上是查找左边界和右边界的问题:

  • 左边界 :第一个等于 target 的位置
  • 右边界 :最后一个等于 target 的位置

关键挑战:数组可能包含重复元素,普通二分查找找到任意一个匹配位置后不能直接返回,需要继续搜索边界。

解决方案:分别实现两个函数:

  1. find_left_bound(nums, target):查找左边界
  2. find_right_bound(nums, target):查找右边界

两种实现思路:

  • 思路一 :先找到任意一个匹配位置,然后向两侧扩展(最坏 O ( n ) O(n) O(n),不符合要求)
  • 思路二 :改造二分查找,直接搜索边界( O ( log ⁡ n ) O(\log n) O(logn),符合要求)

我们采用思路二,核心在于:

  • 查找左边界:当 nums[mid] >= target 时,收缩右边界
  • 查找右边界:当 nums[mid] <= target 时,收缩左边界

3.3 左边界查找算法

目标 :找到第一个等于 target 的位置,即最小的 i 使得 nums[i] >= target

算法步骤

  1. 初始化:left = 0, right = len(nums) - 1(闭区间)
  2. 循环条件:while left <= right
  3. 计算 mid
  4. 比较:
    • nums[mid] >= target:目标可能在左侧(包括 mid),更新 right = mid - 1
    • nums[mid] < target:目标一定在右侧,更新 left = mid + 1
  5. 循环结束后:
    • 检查 left 是否越界:left >= len(nums)
    • 检查 nums[left] 是否等于 target
    • 若都满足,返回 left,否则返回 -1

关键点

  • 循环结束时,left 指向第一个大于等于 target 的位置
  • 需要验证该位置的值确实等于 target

3.4 右边界查找算法

目标 :找到最后一个等于 target 的位置,即最大的 i 使得 nums[i] <= target

算法步骤

  1. 初始化:left = 0, right = len(nums) - 1
  2. 循环条件:while left <= right
  3. 计算 mid
  4. 比较:
    • nums[mid] <= target:目标可能在右侧(包括 mid),更新 left = mid + 1
    • nums[mid] > target:目标一定在左侧,更新 right = mid - 1
  5. 循环结束后:
    • 检查 right 是否越界:right < 0
    • 检查 nums[right] 是否等于 target
    • 若都满足,返回 right,否则返回 -1

关键点

  • 循环结束时,right 指向最后一个小于等于 target 的位置
  • 需要验证该位置的值确实等于 target

3.5 Python代码实现

python 复制代码
def search_range(nums, target):
    """
    查找目标值的起始和结束位置
    时间复杂度:O(log n),空间复杂度:O(1)
    """
    def find_left_bound(nums, target):
        """查找左边界"""
        left, right = 0, len(nums) - 1
        
        while left <= right:
            mid = left + (right - left) // 2
            
            if nums[mid] >= target:
                # 收缩右边界,继续向左搜索
                right = mid - 1
            else:
                # nums[mid] < target,目标在右侧
                left = mid + 1
        
        # 验证左边界
        if left < len(nums) and nums[left] == target:
            return left
        return -1
    
    def find_right_bound(nums, target):
        """查找右边界"""
        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,目标在左侧
                right = mid - 1
        
        # 验证右边界
        if right >= 0 and nums[right] == target:
            return right
        return -1
    
    # 处理空数组
    if not nums:
        return [-1, -1]
    
    left_idx = find_left_bound(nums, target)
    
    # 若左边界未找到,直接返回[-1, -1]
    if left_idx == -1:
        return [-1, -1]
    
    right_idx = find_right_bound(nums, target)
    
    return [left_idx, right_idx]

注: 代码的两个辅助函数采用对称设计:

  • find_left_boundnums[mid] >= target 时收缩右边界,最终返回 left
  • find_right_boundnums[mid] <= target 时收缩左边界,最终返回 right

3.6 复杂度分析

  • 时间复杂度 : O ( log ⁡ n ) O(\log n) O(logn)
    • 两次二分查找,每次 O ( log ⁡ n ) O(\log n) O(logn)
    • 总时间复杂度仍为 O ( log ⁡ n ) O(\log n) O(logn)
  • 空间复杂度 : O ( 1 ) O(1) O(1)

3.7 测试用例验证

python 复制代码
# 测试用例
test_cases = [
    ([5,7,7,8,8,10], 8, [3,4]),
    ([5,7,7,8,8,10], 6, [-1,-1]),
    ([], 0, [-1,-1]),
    ([1], 1, [0,0]),
    ([1,1,1,1], 1, [0,3]),
    ([1,2,3,4,5], 3, [2,2]),
]

for nums, target, expected in test_cases:
    result = search_range(nums, target)
    assert result == expected, f"Failed: nums={nums}, target={target}, got {result}, expected {expected}"

4. 题目三:寻找旋转排序数组中的最小值(LeetCode 153)

4.1 题目描述

题目链接LeetCode 153. Find Minimum in Rotated Sorted Array

题目要求

  • 给定一个升序排列的整数数组 nums,数组在某个未知下标 k 处进行了旋转
  • 数组中没有重复元素
  • 在 O ( log ⁡ n ) O(\log n) O(logn) 时间复杂度内找到数组中的最小元素

示例

复制代码
输入:nums = [3,4,5,1,2]
输出:1

输入:nums = [4,5,6,7,0,1,2]
输出:0

输入:nums = [11,13,15,17]
输出:11

4.2 问题分析与核心思路

旋转排序数组的特点:

  • 数组被分成两个严格递增的子数组:左子数组和右子数组
  • 左子数组的所有元素 > 右子数组的所有元素(关键性质)
  • 最小值位于右子数组的第一个位置

关键洞察 :最小值将数组分为两个有序部分,且最小值小于其左侧和右侧的元素

二分查找策略:

  1. 比较 nums[mid]nums[right]
    • nums[mid] < nums[right]:最小值在左侧(包括 mid),更新 right = mid
    • nums[mid] > nums[right]:最小值在右侧,更新 left = mid + 1
  2. left == right 时,找到最小值

为什么比较 nums[mid]nums[right]

  • 在旋转数组中,右子数组是有序的,且 nums[right] 是右子数组的最大值
  • 比较可以判断 mid 位于哪个子数组:
    • nums[mid] < nums[right]mid 在右子数组,最小值在 mid 左侧(包括 mid
    • nums[mid] > nums[right]mid 在左子数组,最小值在 mid 右侧

4.3 算法步骤详解

步骤1:初始化指针

复制代码
left = 0, right = len(nums) - 1

步骤2:进入循环(while left < right

  • 注意:这里使用 < 而不是 <=,因为当 left == right 时已找到最小值
  1. 计算 mid = left + (right - left) // 2
  2. 比较 nums[mid]nums[right]
    • nums[mid] < nums[right]:最小值在 [left, mid],更新 right = mid
    • nums[mid] > nums[right]:最小值在 [mid+1, right],更新 left = mid + 1

步骤3:循环结束,返回 nums[left]

  • 此时 left == right,指向最小值

4.4 边界条件处理

关键细节

  1. 循环条件while left < right
    • left == right 时,区间只有一个元素,即为最小值
    • 使用 <= 会导致死循环,因为 right = mid 可能不缩小范围
  2. 完全有序数组
    • 若数组未旋转(完全有序),算法仍正确
    • 此时最小值在数组开头,二分查找会逐步向左收缩
  3. 单元素数组
    • 直接返回该元素

4.5 Python代码实现

python 复制代码
def find_min_rotated(nums):
    """
    寻找旋转排序数组中的最小值
    时间复杂度:O(log n),空间复杂度:O(1)
    """
    if not nums:
        return -1  # 根据题目要求调整
    
    left, right = 0, len(nums) - 1
    
    while left < right:
        mid = left + (right - left) // 2
        
        if nums[mid] < nums[right]:
            # mid在右子数组,最小值在左侧(包括mid)
            right = mid
        else:
            # nums[mid] > nums[right],mid在左子数组,最小值在右侧
            left = mid + 1
    
    return nums[left]  # 或nums[right],此时left == right

注: 循环条件 left < right 确保了至少有两个元素时才进入循环。对于单元素数组,直接返回 nums[0]

4.6 复杂度分析

  • 时间复杂度 : O ( log ⁡ n ) O(\log n) O(logn)
    • 每次迭代范围减半
  • 空间复杂度 : O ( 1 ) O(1) O(1)

4.7 测试用例验证

python 复制代码
# 测试用例
test_cases = [
    ([3,4,5,1,2], 1),
    ([4,5,6,7,0,1,2], 0),
    ([11,13,15,17], 11),
    ([1], 1),
    ([2,1], 1),
    ([1,2,3], 1),  # 未旋转
]

for nums, expected in test_cases:
    result = find_min_rotated(nums)
    assert result == expected, f"Failed: nums={nums}, got {result}, expected {expected}"

5. 二分查找变式与技巧总结

5.1 常见变式类型

二分查找不仅限于查找单个值,通过改造判断逻辑,可以解决多种变式问题:

1. 查找边界问题

  • 左边界:第一个等于/大于等于目标值的位置
  • 右边界:最后一个等于/小于等于目标值的位置

2. 旋转数组问题

  • 搜索旋转排序数组
  • 寻找旋转排序数组中的最小值
  • 寻找旋转排序数组中的最大值

3. 峰值问题

  • 寻找峰值元素(比相邻元素大)

4. 有序矩阵问题

  • 在二维有序矩阵中搜索

5. 寻找重复数

  • 在包含重复的有序数组中寻找目标

5.2 通用模板与技巧

技巧1:区间定义一致性

  • 选择一种区间定义(闭区间或左闭右开)并贯穿始终
  • 闭区间:left = 0, right = len(nums)-1,循环条件 while left <= right
  • 左闭右开:left = 0, right = len(nums),循环条件 while left < right

技巧2:中间值计算防溢出

  • 永远使用:mid = left + (right - left) // 2
  • 避免:mid = (left + right) // 2(可能溢出)

技巧3:指针更新逻辑

  • 闭区间:排除 mid,更新 left = mid + 1right = mid - 1
  • 左闭右开:右侧不包含 mid,更新 right = mid;左侧包含 mid,更新 left = mid + 1

技巧4:目标值判断顺序

  • 优先判断 nums[mid] == target 的情况
  • 然后根据问题调整其他判断逻辑

5.3 面试常见考点

考点1:边界条件处理

  • 空数组、单元素数组
  • 目标值不存在
  • 目标值在数组两端

考点2:重复元素处理

  • 当数组包含重复元素时,如何正确查找边界
  • 最坏情况时间复杂度分析

考点3:算法正确性证明

  • 如何证明二分查找的正确性
  • 循环不变量的理解与应用

考点4:复杂度分析

  • 时间复杂度的严格推导
  • 空间复杂度的分析

5.4 调试技巧与常见错误

常见错误1:死循环

  • 原因:指针更新不当,范围未缩小
  • 检查:leftright 是否在每次迭代中变化

常见错误2:漏查元素

  • 原因:循环条件不当,提前退出
  • 检查:最后一个元素是否被检查

常见错误3:索引越界

  • 原因:访问 nums[mid]mid 超出范围
  • 检查:循环条件和指针更新逻辑

调试方法

  1. 打印每次迭代的 leftrightmidnums[mid]
  2. 使用小规模测试用例验证
  3. 检查边界情况

6. 实战练习与扩展

6.1 推荐练习题

基础巩固

  1. LeetCode 35. 搜索插入位置

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

进阶挑战

  1. LeetCode 162. 寻找峰值

  2. LeetCode 33 的变体:搜索旋转排序数组 II

综合应用

  1. LeetCode 74. 搜索二维矩阵

  2. LeetCode 240. 搜索二维矩阵 II

6.2 扩展思考

思考1:二分查找的变式通用性

  • 如何将二分查找应用于非数值搜索问题?
  • 示例:在有序函数值中查找满足条件的输入

思考2:二分查找与分治法的关系

  • 二分查找是分治法的一种特殊形式
  • 比较:二分查找 vs 快速排序 vs 归并排序

思考3:现实应用场景

  • 数据库索引的B+树搜索
  • 操作系统中的内存分配算法
  • 游戏AI中的最优决策搜索

6.3 学习路径建议

  1. 第一阶段:掌握基础

    • 理解二分查找的原理和适用条件
    • 熟练掌握两种区间定义的模板
    • 完成LeetCode 704、35、278
  2. 第二阶段:攻克变式

    • 学习边界查找的改造方法
    • 理解旋转数组问题的特殊性
    • 完成LeetCode 34、33、153
  3. 第三阶段:综合应用

    • 解决二维矩阵搜索问题
    • 理解峰值问题的变式
    • 完成LeetCode 74、240、162
  4. 第四阶段:深入理解

    • 学习二分查找的数学证明
    • 分析最坏情况下的性能
    • 探索现实世界中的应用

6.4 总结

二分查找是算法学习的基石之一,其核心价值在于:

  1. 高效性 : O ( log ⁡ n ) O(\log n) O(logn) 的时间复杂度,远超线性搜索
  2. 简洁性:代码实现简洁,逻辑清晰
  3. 通用性:通过改造可解决多种变式问题

掌握二分查找的关键在于:

  1. 理解原理:有序性、单调性、分治思想
  2. 熟练模板:闭区间和左闭右开两种实现
  3. 灵活应用:根据问题特点调整判断逻辑
相关推荐
lcreek2 小时前
流量优化之道:Ford-Fulkerson 最大流算法
算法·
我叫黑大帅2 小时前
Go 中最强大的权限控制库(Casbin)
后端·面试·go
垫脚摸太阳2 小时前
第 36 场 蓝桥·算法挑战赛·百校联赛---赛后复盘
数据结构·c++·算法
Aaswk2 小时前
刷题笔记(回溯算法)
数据结构·c++·笔记·算法·leetcode·深度优先·剪枝
NAGNIP3 小时前
一文搞懂CNN经典架构-ResNet!
算法·面试
计算机安禾3 小时前
【数据结构与算法】第14篇:队列(一):循环队列(顺序存储
c语言·开发语言·数据结构·c++·算法·visual studio
Frostnova丶3 小时前
(11)LeetCode 239. 滑动窗口最大值
数据结构·算法·leetcode
GoCoding3 小时前
YOLO-Master 与 YOLO26 开始
算法
VALENIAN瓦伦尼安教学设备3 小时前
设备对中不良的危害
数据库·嵌入式硬件·算法