跳转至

446_二分查找面试题


一句话说明

二分查找的本质是"每次排除一半不可能的答案",适用于有序数组或"答案空间有单调性"的场景,模板难在边界处理,记住三种写法避免死循环。


核心知识点

白话理解

猜数字游戏:1到100里猜一个数,每次猜中间值,被告知大了还是小了,最多7次就能猜到。这就是二分——每次砍掉一半可能性,O(log n) 找到答案。

三种模板

# 模板1:找精确值(最基础)
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

# 模板2:找左边界(第一个>=target的位置)
def lower_bound(nums, target):
    left, right = 0, len(nums)             # 注意:right = len(nums)
    while left < right:                    # 注意:<
        mid = left + (right - left) // 2
        if nums[mid] < target:
            left = mid + 1
        else:
            right = mid                    # 不排除mid
    return left                            # left就是下界

# 模板3:找右边界(最后一个<=target的位置)
def upper_bound(nums, target):
    left, right = 0, len(nums)
    while left < right:
        mid = left + (right - left) // 2
        if nums[mid] <= target:
            left = mid + 1
        else:
            right = mid
    return left - 1                        # 最后一个<=target的位置

经典题目与解法

题目1:搜索旋转排序数组(LeetCode 33)

题意:原本有序的数组被旋转(如 [4,5,6,7,0,1,2]),在其中找目标值,O(log n)。

def search_rotated(nums, target):
    """
    思路:虽然旋转了,但每次 mid 必然把数组分成:
    一半有序 + 一半无序
    判断 target 是否在有序那半,从而收缩范围
    """
    left, right = 0, len(nums) - 1

    while left <= right:
        mid = left + (right - left) // 2

        if nums[mid] == target:            # 找到了
            return mid

        # 判断左半 [left, mid] 是否有序
        if nums[left] <= nums[mid]:
            # 左半有序:判断target是否在左半范围内
            if nums[left] <= target < nums[mid]:
                right = mid - 1            # target在左半,去左边
            else:
                left = mid + 1             # target在右半,去右边
        else:
            # 右半 [mid, right] 有序
            if nums[mid] < target <= nums[right]:
                left = mid + 1             # target在右半,去右边
            else:
                right = mid - 1            # target在左半,去左边

    return -1                              # 没找到

# 测试
print(search_rotated([4,5,6,7,0,1,2], 0))  # 4
print(search_rotated([4,5,6,7,0,1,2], 3))  # -1
print(search_rotated([1], 0))              # -1

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


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

题意:在有序数组中找目标值第一次和最后一次出现的下标。

def search_range(nums, target):
    """
    两次二分:第一次找左边界,第二次找右边界
    """
    def find_left(nums, target):
        """找第一个等于target的位置"""
        left, right = 0, len(nums)
        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < target:
                left = mid + 1
            else:
                right = mid                # 等于也往左收缩
        return left                        # left是第一个>=target的位置

    def find_right(nums, target):
        """找最后一个等于target的位置"""
        left, right = 0, len(nums)
        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] <= target:
                left = mid + 1             # 等于也往右扩张
            else:
                right = mid
        return left - 1                    # left-1是最后一个<=target的位置

    left_idx = find_left(nums, target)
    right_idx = find_right(nums, target)

    # 验证找到的位置确实等于target
    if left_idx < len(nums) and nums[left_idx] == target:
        return [left_idx, right_idx]
    return [-1, -1]                        # 不存在

# 测试
print(search_range([5,7,7,8,8,10], 8))    # [3, 4]
print(search_range([5,7,7,8,8,10], 6))    # [-1, -1]
print(search_range([], 0))                # [-1, -1]

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


题目3:寻找峰值(LeetCode 162,二分答案)

题意:在数组中找峰值(大于左右邻居),可以返回任意一个,O(log n)。

def find_peak_element(nums):
    """
    思路:如果 mid 右边比 mid 大,则右边一定有峰值(单调递增要么有峰,要么末尾是峰)
    如果 mid 左边比 mid 大,则左边一定有峰值
    """
    left, right = 0, len(nums) - 1

    while left < right:
        mid = left + (right - left) // 2

        if nums[mid] < nums[mid + 1]:      # 右边更高,峰在右边(含mid+1)
            left = mid + 1
        else:                              # 左边更高或相等,峰在左边(含mid)
            right = mid

    return left                            # left == right,就是峰值位置

# 测试
print(find_peak_element([1,2,3,1]))     # 2(nums[2]=3是峰值)
print(find_peak_element([1,2,1,3,5,6,4]))  # 1或5

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


面试技巧

  1. 死循环两大原因left = mid(应为 mid+1)或 right = mid - 1(应为 mid)——每次确认循环体内 left/right 都在变化
  2. 边界条件要清晰while left <= rightright = mid - 1while left < rightright = mid,两套配合不能混用
  3. 防溢出写法mid = left + (right - left) // 2 而非 (left + right) // 2,Python 不溢出但 Java/C++ 要注意
  4. 二分答案(二分答案空间):题目问"最小化最大值"或"最大化最小值",往往可以二分答案,对答案做 check
  5. Python 有 bisect 模块bisect.bisect_left(nums, x) = 左界,bisect.bisect_right(nums, x) - 1 = 右界

速查表

import bisect

# Python bisect 模块速查
nums = [1, 3, 3, 5, 7]
bisect.bisect_left(nums, 3)    # 1  第一个>=3的位置
bisect.bisect_right(nums, 3)   # 3  第一个>3的位置
bisect.insort_left(nums, 4)    # 插入4,保持有序

# 二分答案模板(最大化最小值/最小化最大值)
def can_achieve(mid):           # 检查答案mid是否可行
    # ... 业务逻辑
    return True/False

left, right = min_possible, max_possible
while left < right:
    mid = (left + right) // 2
    if can_achieve(mid):
        left = mid + 1          # 可行,尝试更大
    else:
        right = mid             # 不可行,降低答案
answer = left - 1              # 或left,看具体情况
场景模板注意点
精确查找left<=right, right=mid-1返回-1如果未找到
找左边界left<right, right=mid返回left
找右边界left<right, left=mid+1返回left-1
旋转数组判断哪半有序分4种情况讨论
二分答案对答案空间二分先写check函数