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] 闭区间内"这个不变量。下图展示闭区间二分逐轮如何收缩。
[lo, hi] 像钳子两侧收拢,三轮锁定 target。注意:被丢弃的那一半连同 mid 一起排除(lo=mid+1 或 hi=mid-1),mid 不会被重复检查——这正是循环能终止的原因。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 = 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 的位置)。
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 是否可行")替代数组里的元素比较,逼近那个分界点。
check(x) 在答案空间上单调:分界点左侧恒 False、右侧恒 True,二分逼近该点即得最小可行答案。注意:单调性是前提而非结论——必须先论证"x 可行则更大的 x 也可行",否则在答案空间上二分会漏解。经典例题 Koko 吃香蕉:piles 是每堆香蕉数,Koko 每小时选一堆以速度 k 吃(吃不完的下一小时再吃,一小时最多吃一堆),求在 H 小时内吃完所有香蕉的最小速度 k。速度越大越容易按时吃完——可行性关于 k 单调,于是在速度区间 [1, max(piles)] 上二分最小可行速度。
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]。排序还顺带让去重变简单——相邻相等即跳过。
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 小元素"有三条路,按场景选不同方案:
| 方案 | 时间复杂度 | 适用场景 |
|---|---|---|
| 全排序后取第 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) 二分总成本更低。
?自测
- 二分查找成立的前提条件是什么?仅仅"数组有序"够不够描述二分答案的前提?
- §4.1 的闭区间模板里,
hi的初始值是多少?循环条件用<还是<=?为什么? - "二分答案"能用的关键性质是什么?用一句话说清它和"在有序数组上二分"的共同点。
- 排序作为预处理在哪些情况下不划算?至少举两种。
参考答案
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 的边界。