🔍 二分搜索进阶 — 旋转数组与第K小

旋转数组搜索、寻找第K小元素、二分答案法——二分不只是查找

📖 二分搜索进阶的核心思维

第4课我们学了基础的二分查找——在有序数组中找目标值。但二分的思想远不止于此!二分的本质是在一个具有单调性的答案空间上高效搜索。"单调性"不一定指数组有序,而是指"如果某个答案不满足,那么更差的答案也不满足"。

二分搜索进阶三大题型: 1. 旋转数组 — 部分有序的二分 LC 33 搜索旋转排序数组 LC 81 搜索旋转排序数组 II (有重复) LC 153 寻找旋转排序数组中的最小值 LC 154 寻找旋转排序数组中的最小值 II 2. 第K小/大 — 二分答案空间 LC 4 寻找两个正序数组的中位数 LC 215 数组中的第K个最大元素 LC 378 有序矩阵中第K小的元素 3. 二分答案 — 最优化问题 LC 875 爱吃香蕉的珂珂 LC 1011 在D天内送达包裹 LC 410 分割数组的最大值 核心思维转换: 基础: 数组有序 → 二分找目标 进阶: 答案空间单调 → 二分答案

🎯 题目一:搜索旋转排序数组 (LC 33)

题目描述:给定一个升序排列的整数数组 nums,在某个点旋转后(例如 [0,1,2,4,5,6,7] → [4,5,6,7,0,1,2]),搜索目标值 target,返回其索引,不存在则返回 -1。

关键洞察: 旋转数组中, mid将数组分成两半, 其中一半一定是有序的! nums = [4,5,6,7,0,1,2] 情况1: mid在左半(有序部分) [4,5,6,7,0,1,2] ↑ ↑ ↑ lo mid hi nums[lo] ≤ nums[mid] → 左半有序 → 如果 target 在 [nums[lo], nums[mid]) → hi = mid-1 → 否则 → lo = mid+1 情况2: mid在右半(有序部分) [4,5,6,7,0,1,2] ↑ ↑ mid hi nums[mid] ≤ nums[hi] → 右半有序 → 如果 target 在 (nums[mid], nums[hi]] → lo = mid+1 → 否则 → hi = mid-1

思路分析

二分 + 判断哪半有序:取 mid 后,判断 [left, mid] 和 [mid, right] 哪一半是有序的。然后在有序的那一半中判断 target 是否落在该范围内,决定搜索方向。

代码实现

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        left, right = 0, len(nums) - 1
        
        while left <= right:
            mid = (left + right) // 2
            if nums[mid] == target:
                return mid
            
            # 判断哪半有序
            if nums[left] <= nums[mid]:  # 左半有序
                if nums[left] <= target < nums[mid]:
                    right = mid - 1
                else:
                    left = mid + 1
            else:  # 右半有序
                if nums[mid] < target <= nums[right]:
                    left = mid + 1
                else:
                    right = mid - 1
        
        return -1

复杂度分析

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

LC 33 中 nums[left] <= nums[mid] 用了 ≤ 而不是 <,因为当 left == mid 时(只剩2个元素),左半只有一个元素也算"有序"。如果改为 <,当 left == mid 时会进入 else 分支,逻辑虽然也对但不够直观。

🎯 题目二:寻找两个正序数组的中位数 (LC 4)

题目描述:给定两个大小分别为 mn 的正序数组 nums1nums2,找出并返回这两个正序数组的中位数。要求时间复杂度 O(log(m+n))

二分分割法: nums1 = [1,3,5,7,9] m=5 nums2 = [2,4,6,8,10,12] n=6 总共11个元素, 中位数是第6个 在nums1中切i个, 在nums2中切j个: i + j = 6 (前半部分共6个元素) 合法分割条件: nums1[i-1] ≤ nums2[j] (左边最大 ≤ 右边最小) nums2[j-1] ≤ nums1[i] 二分搜索 i 的位置: i=0: nums1切0个, nums2切6个 i=3: nums1切3个(1,3,5), nums2切3个(2,4,6) nums1[2]=5 ≤ nums2[3]=8 ✓ nums2[2]=6 ≤ nums1[3]=7 ✓ 合法! 左半最大=max(5,6)=6, 右半最小=min(7,8)=7 中位数 = (6+7)/2 = 6.5 ┌──────────────────────────────────┐ │ 1 3 5 | 7 9 nums1 切3个 │ │ 2 4 6 | 8 10 12 nums2 切3个 │ │ ───────┼─────── │ │ 左半 右半 │ │ max=6 min=7 │ └──────────────────────────────────┘

思路分析

二分分割:在较短的数组上二分搜索分割点 i,则另一个数组的分割点 j = (m+n+1)//2 - i。检查分割是否合法:nums1[i-1] ≤ nums2[j]nums2[j-1] ≤ nums1[i]。如果 nums1[i-1] > nums2[j],说明 i 太大,右移;如果 nums2[j-1] > nums1[i],说明 i 太小,左移。

代码实现

class Solution:
    def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
        # 确保在较短数组上二分
        if len(nums1) > len(nums2):
            nums1, nums2 = nums2, nums1
        
        m, n = len(nums1), len(nums2)
        total_left = (m + n + 1) // 2
        
        left, right = 0, m
        max_left = 0
        
        while left <= right:
            i = (left + right) // 2
            j = total_left - i
            
            nums1_left = float('-inf') if i == 0 else nums1[i-1]
            nums1_right = float('inf') if i == m else nums1[i]
            nums2_left = float('-inf') if j == 0 else nums2[j-1]
            nums2_right = float('inf') if j == n else nums2[j]
            
            if nums1_left <= nums2_right and nums2_left <= nums1_right:
                # 找到合法分割
                max_left = max(nums1_left, nums2_left)
                if (m + n) % 2 == 1:
                    return max_left
                min_right = min(nums1_right, nums2_right)
                return (max_left + min_right) / 2.0
            elif nums1_left > nums2_right:
                right = i - 1
            else:
                left = i + 1
        
        return 0.0

复杂度分析

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

LC 4 的核心技巧:(1) 在较短数组上二分,保证 j ≥ 0;(2) 用 ±inf 处理边界(i=0 或 i=m 时);(3) (m+n+1)//2 保证奇数时左半多一个元素。

🎯 题目三:爱吃香蕉的珂珂 (LC 875) — 二分答案

题目描述:珂珂每小时选择一堆香蕉,吃掉 k 根(不够则吃完这堆),在 h 小时内吃完所有香蕉。求最小的 k

二分答案法: piles = [3,6,7,11], h = 8 答案空间: k ∈ [1, max(piles)] = [1, 11] 单调性: k越大 → 耗时越少 → 可以在h小时内吃完 k越小 → 耗时越多 → 可能无法在h小时内吃完 我们要求: 最大的"不能吃完" → 最小的"能吃完" 二分过程: k=6: 1+1+2+2=6 ≤ 8 ✓ 能吃完 k=3: 1+2+3+4=10 > 8 ✗ 不能吃完 k=4: 1+2+2+3=8 ≤ 8 ✓ 能吃完 k=3: ✗ 不能吃完 → 最小k=4 ┌───────────────────────────────────────┐ │ k: 1 2 3 4 5 6 7 8 9 10 11 │ │ ✗ ✗ ✗ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ │ ↑ │ │ 答案=4 │ └───────────────────────────────────────┘

思路分析

二分答案:k 的范围是 [1, max(piles)],单调性:k 越大越容易在 h 小时内吃完。二分搜索最小的"能吃完"的 k。对于给定的 k,计算吃完所有香蕉的耗时 sum(ceil(pile/k)),判断是否 ≤ h。

代码实现

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        left, right = 1, max(piles)
        result = right
        
        while left <= right:
            mid = (left + right) // 2
            hours = sum((p + mid - 1) // mid for p in piles)
            
            if hours <= h:  # 能吃完,尝试更小的k
                result = mid
                right = mid - 1
            else:           # 吃不完,需要更大的k
                left = mid + 1
        
        return result

复杂度分析

时间复杂度:O(n × log(max(piles))),空间复杂度:O(1)

二分答案法的核心:(1) 确定答案的范围和单调性;(2) 写一个 check 函数判断某个答案是否可行;(3) 二分搜索边界值。适用场景:最优化问题 + 答案空间有单调性。LC 410(分割数组的最大值)、LC 1011(D天内送达)都是这个模式。

🔬 二分答案法通用模板

# 二分答案法通用模板
def binary_search_answer(nums, condition):
    """
    condition(x) 返回True表示x可行
    求: 最大的不可行 → 最小的可行 (或反过来)
    """
    left, right = min_answer, max_answer
    result = right  # 默认最大值
    
    while left <= right:
        mid = (left + right) // 2
        if condition(mid):   # mid可行, 尝试更优
            result = mid
            right = mid - 1  # 求最小可行值
            # left = mid + 1  # 求最大可行值
        else:                # mid不可行
            left = mid + 1   # 需要更大
            # right = mid - 1 # 需要更小
    
    return result

# 常见变体:
# 1. 求最小可行值: 能行就右移right
# 2. 求最大可行值: 能行就左移left
# 3. 浮点二分: 用eps控制精度, 循环100次或 |right-left|

二分搜索的三种层次

层次1: 基础二分 — 在有序数组中找target → LC 35, LC 704 层次2: 部分有序二分 — 判断哪半有序 → LC 33, LC 81, LC 153, LC 154 层次3: 二分答案 — 答案空间单调 → LC 4, LC 875, LC 1011, LC 410 判断"能不能用二分"的核心问题: "如果某个答案不满足要求, 那么比它更差/更好的答案是否也不满足?" 如果是 → 答案空间有单调性 → 可以二分!

🔬 寻找旋转排序数组的最小值 (LC 153)

与 LC 33 互补的问题——不找 target,而是找最小值。

class Solution:
    def findMin(self, nums: List[int]) -> int:
        left, right = 0, len(nums) - 1
        
        while left < right:
            mid = (left + right) // 2
            if nums[mid] > nums[right]:
                # mid在旋转点左侧 → 最小值在右边
                left = mid + 1
            else:
                # mid在旋转点右侧(或就是最小值) → 最小值在左边
                right = mid
        
        return nums[left]
核心判断:nums[mid] > nums[right] 说明 mid 在旋转点的左侧(左半较大的部分),最小值在右侧,left = mid + 1。否则最小值在 mid 或左侧,right = mid。最终 left == right 就是最小值的位置。
LC 153 vs LC 33 的区别:LC 33 是找 target,需要判断哪半有序再决定搜索方向;LC 153 是找最小值,只需要判断 mid 在旋转点左侧还是右侧。两者都是 O(log n),但判断逻辑不同。

📝 练习题单

  1. LC 33 搜索旋转排序数组(部分有序二分)✅
  2. LC 4 寻找两个正序数组的中位数(二分分割)✅
  3. LC 875 爱吃香蕉的珂珂(二分答案)✅
  4. LC 153 寻找旋转排序数组中的最小值
  5. LC 154 寻找旋转排序数组中的最小值 II(有重复)
  6. LC 81 搜索旋转排序数组 II(有重复)
  7. LC 410 分割数组的最大值(二分答案)
  8. LC 1011 在D天内送达包裹(二分答案)
  9. LC 378 有序矩阵中第K小的元素(二分答案)
  10. LC 287 寻找重复数(二分答案/快慢指针)

🔑 关键知识点总结

  • 旋转数组二分:mid 一定将数组分成两半,其中一半有序
  • LC 33 核心判断nums[left] ≤ nums[mid] → 左半有序
  • LC 4 二分分割:在短数组上二分,j = (m+n+1)//2 - i
  • 二分答案法:答案空间有单调性 → 可以二分搜索答案
  • 二分答案三步:确定范围 → 写check函数 → 二分边界
  • LC 153 vs LC 33:找最小值用 nums[mid] > nums[right] 判断
  • 判断能否二分:答案空间是否有单调性(更差的全不满足)
成就解锁:二分大师 — 掌握部分有序二分和二分答案法,能在最优化问题中识别二分可解性!🎉
LeetCode AC验证:LC 33 Search Rotated Array ✅ | LC 4 Median of Two Arrays ✅ | LC 875 Koko Eating Bananas ✅
思考题:LC 410 "分割数组的最大值"中,为什么可以二分答案?提示:如果"最大子数组和为 x"可以分割,那么最大子数组和为 x+1 也一定可以——这就是单调性。