Chapter 04

二分与排序思想

第 03 章在线性结构上靠指针与哈希加速——这一章换一种加速器:有序性。有序是可以主动"造"出来的,一旦有序,搜索就能每步砍半。

本章 schema

  • 二分的正确性靠循环不变量,不靠"差不多对"。
  • 边界写法(<= 还是 <、mid-1 还是 mid)由区间的开闭决定。
  • 二分不止查值,还能在答案空间上做"二分答案"。
  • 排序常作为预处理,用一次 O(n log n) 换来后续的线性处理。

§4.1二分查找与边界不变量

在有序序列中,每次比较砍掉一半搜索空间。

二分查找的时间复杂度是 O(log n),因为每一轮把待查区间长度减半:n → n/2 → n/4 → …,经过 log₂n 轮区间缩到一个元素。前提硬性要求序列已按比较键有序——无序数组上二分得到的结论无意义。

二分最易错的不是思路,而是边界。区间端点该不该减一、循环条件用 < 还是 <=,这些抉择必须由一个固定的循环不变量决定,而不是凭手感试。常用两套:

  • 闭区间 [lo, hi]:不变量是"目标若存在,始终落在 [lo, hi] 内"。初始 hi = n-1,循环条件 while lo <= hi,丢弃左半时 lo = mid+1,丢弃右半时 hi = mid-1。
  • 半开区间 [lo, hi):不变量是"目标若存在,落在 [lo, hi) 内"。初始 hi = n,循环条件 while lo < hi,丢弃右半时 hi = mid(因为 mid 本就不在半开区间右端内)。

两套都对,关键是不混用:选定闭区间,就让每一行代码都维持"答案在 [lo, hi] 闭区间内"这个不变量。下图展示闭区间二分逐轮如何收缩。

查 target = 23 轮1 3 9 14 19 23 28 37 45 lo mid hi 19<23 ⇒ 弃左半 轮2 23 28 37 45 lo mid hi 28>23 ⇒ 弃右半 轮3 23 lo=mid=hi 命中,返回下标 4
图 4.1闭区间二分每轮丢弃半个区间,[lo, hi] 像钳子两侧收拢,三轮锁定 target。注意:被丢弃的那一半连同 mid 一起排除(lo=mid+1 或 hi=mid-1),mid 不会被重复检查——这正是循环能终止的原因。
binary_search.pyPython
def binary_search(nums: list[int], target: int) -> int:
    """闭区间模板:在有序 nums 中找 target 的下标,找不到返回 -1。"""
    lo, hi = 0, len(nums) - 1          # 不变量:答案若存在,落在闭区间 [lo, hi] 内
    while lo <= hi:                    # 闭区间非空的条件是 lo <= hi
        mid = lo + (hi - lo) // 2      # 取中点,写法避免 lo+hi 溢出(见下方警告)
        if nums[mid] == target:
            return mid                 # 命中,直接返回
        elif nums[mid] < target:
            lo = mid + 1               # target 在右半,排除 mid 及其左侧
        else:
            hi = mid - 1               # target 在左半,排除 mid 及其右侧
    return -1                          # 区间收空仍未命中


if __name__ == "__main__":
    arr = [3, 9, 14, 19, 23, 28, 37, 45]
    print(binary_search(arr, 23))      # 4
    print(binary_search(arr, 10))      # -1

逐行看不变量如何维持:进入循环时 [lo, hi] 是当前还可能含 target 的全部范围。nums[mid] < target 时,mid 及其左侧全部小于 target,可一并排除,故 lo = mid+1;反之 hi = mid-1。每次都严格缩小区间,循环必然终止。

陷阱 · mid 的写法与 hi=mid-1

mid = lo + (hi - lo) // 2 与 mid = (lo + hi) // 2 在 Python 中结果相同(Python 整数无溢出),但前者是跨语言通用的安全写法——在 C/Java 中 lo+hi 可能超出 int 范围。面试白板上写前者更稳妥。

为什么是 hi = mid-1 而非 hi = mid?因为闭区间下 nums[mid] 已经比较过且不等于 target,把它留在区间里只会让区间无法收缩到空——当 lo == hi == mid 时 hi = mid 不改变 hi,循环条件 lo <= hi 恒真,死循环。闭区间必须用 mid-1 / mid+1 把 mid 排除出去。

§4.2二分的变体:找左 / 右边界

相等时不立刻返回,继续向一侧收缩并记录候选。

标准二分一旦 nums[mid] == target 就返回,返回的是任意一个匹配下标。但数组有重复元素时,常需要"第一个等于 target 的位置"(左边界)或"最后一个"(右边界)。做法:命中时不返回,而是把 mid 记为候选,然后继续向左收缩(找左边界令 hi = mid-1)去寻找更靠左的匹配。这等价于 C++ STL 的 lower_bound(第一个 ≥ target 的位置)。

left_bound.pyPython
def left_bound(nums: list[int], target: int) -> int:
    """在含重复元素的有序数组中,返回 target 第一次出现的下标,没有则返回 -1。"""
    lo, hi = 0, len(nums) - 1
    ans = -1                           # 候选:记录目前见过的最靠左的命中位置
    while lo <= hi:
        mid = lo + (hi - lo) // 2
        if nums[mid] < target:
            lo = mid + 1               # 太小,去右半
        elif nums[mid] > target:
            hi = mid - 1               # 太大,去左半
        else:                          # 命中:先记下,再继续向左找更早的命中
            ans = mid
            hi = mid - 1               # 关键:不返回,逼向左边界
    return ans


if __name__ == "__main__":
    arr = [1, 2, 2, 2, 3, 4]
    print(left_bound(arr, 2))          # 1  (第一个 2 的下标)
    print(left_bound(arr, 5))          # -1
想一想

对 arr = [1, 2, 2, 2, 3, 4] 查 target = 2:§4.1 的标准二分返回什么?本节的 left_bound 返回什么?两者为何不同?

展开对照

标准二分首次比较 mid = 2,nums[2] == 2 立即返回 2——返回的是它恰好踩到的那个匹配,下标取决于区间的中点位置,不保证最左。

left_bound 命中后不返回,令 hi = mid-1 继续向左,下一轮在 [0,1] 中找到 nums[1]==2,更新候选为 1,再向左区间收空,最终返回 1。差异的根源:标准二分目标是"找到任一个",左边界二分目标是"在所有匹配中取最左",于是命中后的动作从 return 改成 记录 + 收缩。把 hi = mid-1 换成 lo = mid+1 即得右边界。

§4.3二分答案

不对数组二分,对"答案的取值范围"二分。

这是面试高频盲点。前两节都在有序数组上二分,但二分的真正前提不是"数组有序",而是"判定结果单调"。当问题满足"答案 x 可行 ⇒ 所有比 x 更宽松的取值也可行"时,可行性关于 x 单调——存在一个分界点,一侧全不可行、另一侧全可行。这时可以对答案空间二分:用一个判定函数 check(x)(返回"x 是否可行")替代数组里的元素比较,逼近那个分界点。

速度 1(慢) max(快) check(x) = False(吃不完) check(x) = True(吃得完) 分界点 = 最小可行速度 探测点不可行 ⇒ 向右 探测点可行 ⇒ 向左收
图 4.2判定函数 check(x) 在答案空间上单调:分界点左侧恒 False、右侧恒 True,二分逼近该点即得最小可行答案。注意:单调性是前提而非结论——必须先论证"x 可行则更大的 x 也可行",否则在答案空间上二分会漏解。

经典例题 Koko 吃香蕉:piles 是每堆香蕉数,Koko 每小时选一堆以速度 k 吃(吃不完的下一小时再吃,一小时最多吃一堆),求在 H 小时内吃完所有香蕉的最小速度 k。速度越大越容易按时吃完——可行性关于 k 单调,于是在速度区间 [1, max(piles)] 上二分最小可行速度。

koko_eating.pyPython
import math


def min_eating_speed(piles: list[int], h: int) -> int:
    """对答案(速度)二分:返回 h 小时内吃完所有香蕉的最小速度。"""

    def check(speed: int) -> bool:
        # 以 speed 吃完所需总小时数;每堆向上取整(吃不满也占一小时)
        hours = sum(math.ceil(p / speed) for p in piles)
        return hours <= h          # 可行 = 能在 h 小时内吃完

    lo, hi = 1, max(piles)          # 答案空间:速度至少 1,至多 max(一小时一堆)
    ans = hi                        # 候选:hi 一定可行(每堆一小时吃完)
    while lo <= hi:                 # 闭区间二分,但比较换成 check
        mid = lo + (hi - lo) // 2
        if check(mid):             # mid 可行 ⇒ 记录,并尝试更小的速度
            ans = mid
            hi = mid - 1
        else:                      # mid 不可行 ⇒ 速度必须更大
            lo = mid + 1
    return ans


if __name__ == "__main__":
    print(min_eating_speed([3, 6, 7, 11], 8))        # 4
    print(min_eating_speed([30, 11, 23, 4, 20], 5))  # 30
    print(min_eating_speed([30, 11, 23, 4, 20], 6))  # 23

逐行看 check 的单调性:速度 speed 越大,每堆 ceil(p / speed) 越小,hours 越小,越容易满足 hours <= h。因此 check(speed)==True 意味着所有比它更大的速度也为 True——这正是图 4.2 的单调阶梯。二分逻辑与 §4.1 闭区间模板同构:把 nums[mid] == target 的三路比较,换成 check(mid) 的两路判定,命中可行就记录并向左压(找更小),不可行就向右抬。

识别信号

题面出现"最小的最大值""最大的最小值""满足某条件的最小/最大 x",且约束 x 的取值范围已知、判定一个具体 x 是否可行容易(多为一次线性扫描),就该想到二分答案。把"求最优值"转成"判定某个值是否可行 + 在答案区间二分"。

§4.4排序作为预处理

先花 O(n log n) 排序,把后续处理压成 O(n)。

很多题目本身不要求排序,但排序后结构变得规整,可接双指针或贪心在 O(n) 内解决。总代价 O(n log n) 由排序主导,换来的是后续逻辑的简化与去重的便利。何时不值得:n 很小(常数开销盖过渐进收益),或数据本就有序(排序纯属浪费),或题目要求保持原始顺序(排序破坏了下标语义)。

经典例题三数之和:在数组中找出所有不重复的三元组 (a, b, c) 使 a+b+c == 0。先排序,再固定第一个数 nums[i],在其右侧用对撞双指针(复用第 03 章)找两数之和为 -nums[i]。排序还顺带让去重变简单——相邻相等即跳过。

three_sum.pyPython
def three_sum(nums: list[int]) -> list[list[int]]:
    """返回所有和为 0 的不重复三元组。排序 O(n log n) + 对撞双指针 O(n^2)。"""
    nums.sort()                        # 预处理:排序,使双指针与去重都成为可能
    n = len(nums)
    res: list[list[int]] = []
    for i in range(n - 2):
        if nums[i] > 0:                # 已排序,最小数 > 0 则三数之和不可能为 0
            break
        if i > 0 and nums[i] == nums[i - 1]:
            continue                   # 跳过重复的第一个数,避免重复三元组
        lo, hi = i + 1, n - 1          # 对撞双指针:在右侧有序区间找两数之和
        while lo < hi:
            s = nums[i] + nums[lo] + nums[hi]
            if s < 0:
                lo += 1                # 和偏小,左指针右移增大
            elif s > 0:
                hi -= 1                # 和偏大,右指针左移减小
            else:
                res.append([nums[i], nums[lo], nums[hi]])
                lo += 1
                hi -= 1
                while lo < hi and nums[lo] == nums[lo - 1]:
                    lo += 1            # 跳过重复的第二个数
                while lo < hi and nums[hi] == nums[hi + 1]:
                    hi -= 1            # 跳过重复的第三个数
    return res


if __name__ == "__main__":
    print(three_sum([-1, 0, 1, 2, -1, -4]))   # [[-1, -1, 2], [-1, 0, 1]]
    print(three_sum([0, 0, 0, 0]))            # [[0, 0, 0]]

排序把对撞双指针的前提(区间有序、可单调移动指针)和去重的前提(重复元素相邻)一次性备齐。没有这步预处理,找两数之和退化成哈希或 O(n²) 暴力,去重则要靠集合,代码更繁琐。这就是"用一次排序换后续线性处理"的典型形态。

§4.5备选方案:求第 k 小元素

"求第 k 小元素"有三条路,按场景选不同方案:

表 4.1 · 求第 k 小元素的方案对比
方案时间复杂度适用场景
全排序后取第 k 个 O(n log n) k 接近 n、需要同时拿到完整有序结果,或要多次按不同 k 查询。
大小为 k 的堆(复用第 02 章) O(n log k) k 远小于 n、数据流式到来无法一次性载入,只需第 k 个而非全序。
quickselect(快速选择) 平均 O(n),最坏 O(n²) 单次查询、数据在内存中可随机访问、不要求稳定——平均最快。

表中以 quickselect 为默认推荐,但"selected"随场景变动:流式或 k 极小时堆更稳,需完整有序或多次查询时全排序更划算。

另一组常见取舍是二分查找 vs 线性扫描:线性扫描 O(n)、无需有序、实现最简;二分 O(log n) 但要求有序,且排序本身要 O(n log n)。判据是查询次数——只查一次,线性扫描就够(排序的代价收不回);同一份数据要查很多次,则一次排序加每次 O(log n) 二分总成本更低。

?自测

  1. 二分查找成立的前提条件是什么?仅仅"数组有序"够不够描述二分答案的前提?
  2. §4.1 的闭区间模板里,hi 的初始值是多少?循环条件用 < 还是 <=?为什么?
  3. "二分答案"能用的关键性质是什么?用一句话说清它和"在有序数组上二分"的共同点。
  4. 排序作为预处理在哪些情况下不划算?至少举两种。
参考答案

1. 普通二分查找要求序列按比较键有序。二分答案的前提更本质:判定函数关于答案单调(x 可行 ⇒ 更宽松的取值也可行),有序数组只是单调性的一个特例。

2. 闭区间 hi = len(nums) - 1,循环条件用 <=。因为不变量是"答案落在闭区间 [lo, hi]",当 lo == hi 时区间仍含一个待检元素,必须进入循环,故用 <=。

3. 关键性质是可行性关于答案单调,存在一个分界点把答案空间切成"全不可行 | 全可行"两段。共同点:两者都在一个单调结构上靠每轮砍半逼近目标,区别只是比较对象从数组元素换成了判定函数。

4. (任两条)n 很小,排序常数开销盖过收益;数据本就有序,排序纯属浪费;题目要求保持元素原始顺序/下标语义,排序会破坏它;只查一次且不复用有序性时,排序代价收不回。

挑战题

两个有序数组的第 k 小

给定两个升序数组 a(长 m)与 b(长 n),返回合并后第 k 小的元素,要求时间复杂度优于 O(m + n)。进阶:当 k 取中位数位置时,即求两个有序数组的中位数。

提示

不要真的合并。对 k 做"排除式"二分:每轮各取 a、b 前 k//2 个的末位比较,较小的一侧那 k//2 个元素都不可能是第 k 小,整段排除并把 k 相应减小。每轮把 k 砍半,复杂度降到 O(log k)(约 O(log(m+n)))。注意处理某数组被取空、k 减到 1 的边界。