#008. 查找与选择:二分查找与第 K 小元素
#学习目标:在有序里找答案
二分查找是「人人都会、人人写错」的典型:经典统计是九成以上的专业程序员无法一次性写出无 bug 的版本。错的地方几乎从不在思路,而在边界——循环条件是取等还是不取、mid 之后该加一还是减一、区间收缩后会不会永远不动。系统的解法不是背三套模板,而是循环不变量:先明确「答案若存在必在某个区间内」这句话,循环的每一行都由这句话反推出来,边界就不再靠运气。
本章第二个主题是选择问题(selection problem):不求全序,只求第 k 小。它有一个漂亮的复杂度阶梯——排序 \(O(n \log n)\)、QuickSelect 平均 \(O(n)\) 最坏 \(O(n^2)\)、中位数的中位数(BFPRT)最坏 \(O(n)\)。源题库提纲明确要求「三种都要实现」,本章给出全部三段 C++ 代码和推导,特别是 BFPRT 为什么能做到最坏线性——它本质上是「快速选择 + 保证划分不失衡的选基准法」,而那个保证又内嵌了一次递归,是分治分析里少见的精彩样本。
写二分前先写下一句话:「若 target 存在则必在 [lo, hi] 内」。每次收缩区间后这句话必须依然成立;成立,循环就必然终止且不出错。
左闭右闭 \([lo, hi]\) 与左闭右开 \([lo, hi)\) 两种约定各有配套的循环条件与收缩规则,混用是死循环与漏解的根源。
排序 \(O(n \log n)\) 稳;QuickSelect 平均 \(O(n)\);BFPRT 最坏 \(O(n)\)。面试从简单讲起,被追问再升级,层层有后手。
#二分查找:循环不变量与两种区间约定
左闭右闭版本。区间 \([lo, hi]\) 表示「target 若存在,下标必落在这个闭区间内」。区间非空即 \(lo \le hi\),所以循环条件带等号;探测 \(mid\) 后,两种排除都要把 mid 自己丢掉:\(a[mid] \lt target\) 时答案只可能在 \([mid+1, hi]\),反之在 \([lo, mid-1]\)。
怎么读:这句话就是代码每一行的理由——循环条件 \(lo \le hi\)(区间空了就没必要找)、收缩 \(lo = mid + 1\) 或 \(hi = mid - 1\)(mid 已被探测过,必须出区间)。每轮区间长度至少减一,所以循环必终止,复杂度 \(O(\log n)\)。
左闭右开版本。区间 \([lo, hi)\) 适合「找边界」而不是「找存在」。以「第一个不小于 x 的位置」(即 lower_bound)为例,不变量写成两半:
怎么读:下界左边全小于 x、上界右边全不小于 x;区间为空(\(lo = hi\))时两个断言相接,接缝处 \(lo\) 恰是第一个满足 \(a[\cdot] \ge x\) 的位置。循环条件 \(lo \lt hi\)(半开区间非空),收缩规则是 \(a[mid] \lt x\) 时 \(lo = mid + 1\)(mid 已确认小于 x,可排除),否则 \(hi = mid\)(mid 可能正是答案,不能丢,只收缩上界)。注意这里 hi = mid 不减一——减一就会越过答案;而它能终止是因为 \(mid \lt hi\) 严格成立(下取整保证 mid 取不到 hi)。
中点与死循环。mid 一律写成 \(lo + (hi - lo) / 2\),避免 \(lo + hi\) 先相加的溢出(009 章会专门讲整数溢出)。真正的死循环陷阱出在「收缩写 \(lo = mid\)」的场合:下取整的 mid 在区间只剩两个元素时等于 lo,若那一轮判断走进 \(lo = mid\),区间永远不变。规则:谁收缩成 mid,谁就要向上取整——用 \(mid = lo + (hi - lo + 1) / 2\) 配 \(lo = mid\),用下取整配 \(hi = mid\)。记住「区间长度为 2 时 mid 必须能动」这个检验,写完在脑内跑一遍即可。
每轮循环后区间长度严格变小:左闭右闭版每轮丢掉 mid 且一侧收缩,长度至少减 1;左闭右开版配对正确时同样如此。长度是有限的自然数、单调递减,必到 0 或 1 触发退出。死循环都是「某一轮长度没变」——把疑犯代码在长度 2 的区间上手动跑一遍,立刻现形。
#二分的变式:lower_bound 与旋转数组
边界语义族。有序数组上的四个标准问题:lower_bound(第一个 \(\ge x\))、upper_bound(第一个 \(\gt x\),把不变量里的 \(\ge\) 换成 \(\gt\) 即可)、「最后一个 \(\le x\)」(上取整配 \(lo = mid\) 的典型)、以及标准查找。它们共用同一个不变量框架,只是「判定条件」与「区间约定」的组合不同。掌握语义的好处:遇到「第一个满足某条件的位置」时,只要条件随下标单调(前段全否、后段全是),都能套 lower_bound 模板,这就是「二分答案」的通用性——开方、最小可行容量等问题在 035 章展开。
旋转有序数组。「数组在某处旋转过」(如 \([4,5,6,7,0,1,2]\))整体无序,但对半切开必有一半是有序的。比较 \(a[lo]\) 与 \(a[mid]\):若 \(a[lo] \le a[mid]\),则左半 \([lo, mid]\) 有序,target 是否落在其中可以用两次比较精确判断;否则右半 \([mid, hi]\) 有序,同样处理。于是每轮仍能安全丢掉一半,\(O(\log n)\) 保持。这个「先判断哪半可靠、再在可靠的半边内做精确判断」的不变量思维,是旋转类变式的万能钥匙。
旋转数组不变量:若 target 在数组中,则它在 \([lo, hi]\) 内;且 \(a[lo] \le a[mid]\) 蕴含 \([lo, mid]\) 完全有序,可用边界比较直接判定 target 是否在该段。
有重复的退化:若允许重复且 \(a[lo] = a[mid] = a[hi]\),无法判断哪半有序,只能 lo 与 hi 各向内收缩一步,最坏退化 \(O(n)\)——这是必须能口头说出的边界。
#第 K 小元素:三种实现的复杂度阶梯
方法一:排序。排完取下标 \(k-1\),\(O(n \log n)\)。它是最稳的基准解:没有退化、代码一行。面试先用它把题意钉住(k 从 1 还是从 0 计?允许修改输入吗?),再谈优化。
方法二:QuickSelect。复用 007 章的 partition:基准落位下标 p,若 \(p = k\) 直接返回;\(p \lt k\) 只递归右半,否则只递归左半——只走一侧,这是与快排的本质区别。随机基准下每轮期望丢掉一半区间:
怎么读:每层工作量是上一层的一半(期望意义),加起来是等比级数,总和被首项的常数倍控制——这是「平均线性」的标准论证。最坏情况(划分持续 0 : n−1)退化为 \(T(n) = T(n-1) + O(n) = O(n^2)\)。实践中加一道保险:递归一定深度或区间收缩不利时退回排序,这正是 std::nth_element 的 introselect 策略。
方法三:中位数的中位数(BFPRT)。QuickSelect 的软肋是基准可能选得极差。BFPRT(Blum–Floyd–Pratt–Rivest–Tarjan)用一个「不太差」的基准强制划分至少 3 : 7:
选基准:每 5 个元素一组,组内排序取中位数;对这些中位数(约 \(n/5\) 个)递归调用 BFPRT 求它们的中位数 M,作为 pivot。组大小取 5 是为了两个递归项之和严格小于 1 份。
划分下界:约一半组的中位数 \(\ge M\),每组再贡献 3 个 \(\ge M\) 的元素,因此至少约 \(3n/10\) 个元素不小于 M、同时至少约 \(3n/10\) 个不大于 M——两侧都至少占三成,划分不可能 0 : n−1。
怎么读:第一项是递归求「中位数的中位数」,第二项是递归处理划分后较大的一侧(至多七成),第三项是分组排序与划分的线性开销。因为 \(\tfrac15 + \tfrac7{10} = \tfrac9{10} \lt 1\),各层总工作量按 \((9/10)^k\) 几何衰减,求和线性。这正是组大小取 5 的原因:组更大(比如 3)时两个递归项之和可能 \(\ge 1\),论证失效。
| 方法 | 平均 | 最坏 | 额外空间 | 是否修改输入 | 一句话定位 |
|---|---|---|---|---|---|
| 排序取下标 | \(O(n \log n)\) | \(O(n \log n)\) | 视排序而定 | 是 | 基准解,先说它钉住题意 |
| QuickSelect | \(O(n)\) | \(O(n^2)\) | \(O(1)\)(迭代版) | 是 | 实践默认,随机化必备 |
| BFPRT | \(O(n)\) | \(O(n)\) | 递归 \(O(\log n)\) | 是 | 理论最坏线性,常数大 |
BFPRT 的价值是「最坏 \(O(n)\)」的理论保证,代价是分组、两重递归带来的大常数,实测通常比随机 QuickSelect 慢。工程取舍是 introselect:QuickSelect 打底,恶化迹象出现时退回堆或排序。面试口径:「BFPRT 我会推导,实践用随机 QuickSelect,标准库 nth_element 就是这个思路的组合」。
#例题详解 I:标准二分与死循环陷阱
例题 1:升序数组(无重复)中查找 target,存在返回下标,不存在返回 −1。要求两种区间约定各写一版,并演示一个典型的死循环写法。
思路。左闭右闭版以「存在则在 \([lo, hi]\)」为不变量;左闭右开版以「\([0, lo)\) 全小于 target、\([hi, n)\) 全大于 target」为不变量。
int binarySearch(vector<int>& a, int target) { // 左闭右闭 [lo, hi]
int lo = 0, hi = (int)a.size() - 1;
while (lo <= hi) { // 区间非空才继续
int mid = lo + (hi - lo) / 2; // 防溢出的中点写法
if (a[mid] == target) return mid;
if (a[mid] < target) lo = mid + 1; // mid 已排除,答案在右侧
else hi = mid - 1; // mid 已排除,答案在左侧
}
return -1; // 区间为空,不存在
}
int binarySearchHO(vector<int>& a, int target) { // 左闭右开 [lo, hi)
int lo = 0, hi = (int)a.size();
while (lo < hi) { // 半开区间非空
int mid = lo + (hi - lo) / 2; // mid 恒 < hi,下取整安全
if (a[mid] == target) return mid;
if (a[mid] < target) lo = mid + 1;
else hi = mid; // mid 可能是答案,不能减一
}
return -1;
}
死循环演示。若在「找最后一个满足条件的位置」这类问题里随手写:
while (lo < hi) {
int mid = (lo + hi) / 2; // 下取整
if (a[mid] <= x) lo = mid; // 错误:区间只剩 2 个元素时 mid == lo
else hi = mid - 1;
}
取 \(lo = 2\)、\(hi = 3\):mid 恒为 2,若判断走进 \(lo = mid\),区间永远是 \([2, 3)\)——程序挂死。修正:收缩侧为 lo 时中点必须上取整,写 \(mid = lo + (hi - lo + 1) / 2\)。
复杂度与边界。时间 \(O(\log n)\),空间 \(O(1)\)。边界:空数组(两个版本都直接返回 −1)、单元素、target 小于全部或大于全部(区间缩空自然退出)、有重复时本写法返回「某个」匹配位置——要精确到第一个需换 lower_bound 语义(见例题 2)。
面试怎么讲。写之前先声明约定:「我采用左闭右闭,不变量是答案若存在必在闭区间内」;写完用长度 2 的区间口头验证一轮终止性。这个习惯本身就是考点。
#例题详解 II:旋转有序数组查找
例题 2:升序数组在某个下标处被旋转(元素互不相同),如 [4, 5, 6, 7, 0, 1, 2]。在其中查找 target,要求 \(O(\log n)\)。
建模。整体无序,但任意取 mid 后,\(a[lo] \le a[mid]\) 与 \(a[mid] \le a[hi]\) 二者必有一个成立,即至少一半是有序的。先识别有序的一半,再在有序范围内做两次比较精确判断 target 是否在其中:在则收缩到这半,不在则收缩到另一半。不变量「target 若存在必在 \([lo, hi]\)」全程保持,每轮区间减半。
int searchRotated(vector<int>& a, int target) {
int lo = 0, hi = (int)a.size() - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] == target) return mid;
if (a[lo] <= a[mid]) { // 左半 [lo, mid] 有序(无重复时)
if (a[lo] <= target && target < a[mid])
hi = mid - 1; // target 落在有序的左半
else
lo = mid + 1; // 否则必在右半
} else { // 右半 [mid, hi] 有序
if (a[mid] < target && target <= a[hi])
lo = mid + 1; // target 落在有序的右半
else
hi = mid - 1;
}
}
return -1;
}
数值走查。a = [4, 5, 6, 7, 0, 1, 2],target = 0:第一轮 \(lo=0, hi=6, mid=3\),\(a[0]=4 \le a[3]=7\) 判左半有序;0 不在 \([4, 7)\) 内,\(lo = 4\)。第二轮 \(lo=4, hi=6, mid=5\),\(a[4]=0 \le a[5]=1\) 左半有序;\(a[4] \le 0 \lt a[5]\) 成立,\(hi = 4\)。第三轮 \(mid = 4\),\(a[4] = 0\) 命中,返回 4。三轮 \(\log_2 7 \approx 2.8\),符合预期。
复杂度与边界。时间 \(O(\log n)\),空间 \(O(1)\)。边界:未旋转的数组(永远走「左半有序」分支,退化为普通二分,仍正确);单元素;两元素(mid = lo,「左半有序」判断自身成立,逻辑不变);target 不存在时区间缩空返回 −1。有重复元素的变式(旋转数组 II):当 \(a[lo] = a[mid] = a[hi]\) 时无法判断哪半有序,必须 lo、hi 各收缩一步,最坏 \(O(n)\)——面试要主动说出来。
面试怎么讲。先讲不变量再讲分支:「切一半必有一半有序,有序的一半可以精确判断 target 是否在内,所以每轮仍能安全扔一半」。追问「为什么不能直接比较 a[mid] 与 target」时回答:整体无序,该比较无法定位 target 在哪一侧,必须借有序半边做桥。
例题 3:升序数组可能有重复。返回第一个大于等于 x 的下标(lower_bound 语义),若所有元素都小于 x 返回 n。
建模。条件「\(a[i] \ge x\)」随下标单调:前段全为假、后段全为真。要找真假分界,用左闭右开不变量:\(a[0, lo)\) 全小于 x,\(a[hi, n)\) 全不小于 x。
int lowerBound(vector<int>& a, int x) { // 第一个 >= x 的下标
int lo = 0, hi = (int)a.size();
while (lo < hi) { // 不变量:[0,lo) < x,[hi,n) >= x
int mid = lo + (hi - lo) / 2;
if (a[mid] < x) lo = mid + 1; // mid 确认 < x,可安全排除
else hi = mid; // mid 满足 >= x,可能是答案
}
return lo; // lo == hi,两段断言在此相接
}
正确性论证。初始 \(lo = 0\)、\(hi = n\),两个空区间让断言平凡成立。每轮收缩后断言保持:进入 \(lo = mid + 1\) 的前提是 \(a[mid] \lt x\),因此 \([0, mid+1)\) 全小于 x;进入 \(hi = mid\) 的前提是 \(a[mid] \ge x\),因此 \([mid, n)\) 全不小于 x。循环结束时 \(lo = hi\),左侧断言给出「\(lo\) 前面都小于 x」,右侧断言给出「\(lo\) 起都 \(\ge x\)」,\(lo\) 恰为分界。终止性:\(mid \lt hi\) 严格成立(下取整且 \(lo \lt hi\)),两种收缩都严格减小区间。
复杂度与边界。\(O(\log n)\)、\(O(1)\)。边界:x 小于全部元素返回 0;大于全部返回 n(调用方须检查 n 越界语义);重复元素正是它的主场——「第一个」位置被精确锁定,普通二分做不到。改成 upper_bound(第一个 \(\gt x\))只需把判定换成 \(a[mid] \le x\)。
面试怎么讲。强调「这类题的正确打开方式是写不变量而不是试边界」,然后给应用:插入位置、计数某值出现次数 = upper_bound − lower_bound、二分答案的判定函数都长这样。
#例题详解 III:第 K 小的三种实现
例题 4:给定整数数组与 k(1 到 n),返回第 k 小的元素。分别用排序、QuickSelect、BFPRT 三种方法实现并给出复杂度。
方法一:排序。基准解,一行核心逻辑,\(O(n \log n)\) 时间。面试先写它并声明「保证正确,下面优化」,同时钉住 k 的计数起点。
int kthBySort(vector<int> a, int k) { // k 从 1 计
sort(a.begin(), a.end());
return a[k - 1];
}
方法二:QuickSelect。复用 007 章的 Lomuto partition(含随机化)。每轮只递归/迭代包含第 k 位的一侧,平均 \(O(n)\)、最坏 \(O(n^2)\)、迭代版 \(O(1)\) 额外空间。
int partition(vector<int>& a, int lo, int hi) { // 随机化 Lomuto
int r = lo + rand() % (hi - lo + 1);
swap(a[r], a[hi]);
int pivot = a[hi], i = lo;
for (int j = lo; j < hi; ++j)
if (a[j] <= pivot) swap(a[i++], a[j]);
swap(a[i], a[hi]);
return i; // 基准的最终下标
}
int quickSelect(vector<int>& a, int k) { // k 从 0 计:第 k 小 = 下标 k
int lo = 0, hi = (int)a.size() - 1;
while (true) {
if (lo == hi) return a[lo];
int p = partition(a, lo, hi);
if (p == k) return a[p]; // 基准恰好在第 k 位
if (p < k) lo = p + 1; // 第 k 小在右侧,扔掉左侧
else hi = p - 1; // 在左侧,扔掉右侧
}
}
方法三:BFPRT(中位数的中位数)。基准不再是随机元素,而是递归求出的「中位数的中位数」,保证两侧各占至少三成,最坏 \(O(n)\)。
int bfprt(vector<int>& a, int lo, int hi, int k); // 前向声明
int medianOfMedians(vector<int>& a, int lo, int hi) {
int n = hi - lo + 1;
if (n <= 5) { // 递归基:小组直接排序取中位数
sort(a.begin() + lo, a.begin() + hi + 1);
return a[lo + n / 2];
}
vector<int> meds;
for (int i = lo; i <= hi; i += 5) {
int r = min(i + 4, hi); // 每组至多 5 个,组内排序 O(1)
sort(a.begin() + i, a.begin() + r + 1);
meds.push_back(a[i + (r - i) / 2]); // 收集各组中位数
}
// 递归地对中位数组成的数组求中位数
return bfprt(meds, 0, (int)meds.size() - 1, (int)meds.size() / 2);
}
int bfprt(vector<int>& a, int lo, int hi, int k) { // k 为全局下标
int pivot = medianOfMedians(a, lo, hi);
int lt = lo, i = lo, gt = hi; // 三路划分:< | == | >
while (i <= gt) {
if (a[i] < pivot) swap(a[lt++], a[i++]);
else if (a[i] > pivot) swap(a[i], a[gt--]);
else ++i;
}
if (k < lt) return bfprt(a, lo, lt - 1, k); // 落在小于段
else if (k > gt) return bfprt(a, gt + 1, hi, k); // 落在大于段
return pivot; // k 落在等于段,直接命中
}
为什么最坏线性(数值化)。约 \(n/5\) 组、一半组的中位数 \(\ge M\),每组另有 2 个元素 \(\ge\) 本组中位数,故至少约 \(3n/10\) 个元素不小于 M;对称地至少约 \(3n/10\) 个不大于 M。递归的大侧至多 \(7n/10\),加上求中位数的中位数的 \(n/5\),\(\tfrac15 + \tfrac7{10} = \tfrac9{10} \lt 1\),各层工作量几何衰减,总和 \(\le 10 \cdot c n\),即 \(O(n)\)。
复杂度与边界(三法对照)。排序法 \(O(n \log n)\) 无退化;QuickSelect 平均 \(O(n)\)、最坏 \(O(n^2)\)(随机化使其概率上几乎不出现);BFPRT 恒 \(O(n)\) 但常数大。共同边界:k 必须落在 1..n(先校验,防止越界);全相等数组——三路版 QuickSelect 与 BFPRT 一次划分即返回,朴素两路 QuickSelect 退化为平方;负数不影响(只做比较);三种方法都会重排输入,不允许修改时先复制(空间代价 \(O(n)\))。中位数即 \(k = \lfloor n/2 \rfloor\) 的第 k 小,两个 k 的解平均后是偶数个元素的中位数。
面试怎么讲。按「排序 → QuickSelect → BFPRT」的顺序主动升级:先给基准解,再讲「只递归一侧所以平均线性」,最后补「最坏线性的办法是把随机基准换成中位数的中位数,代价是常数」。追问「为什么不直接用 nth_element」时答:它就是 introselect(QuickSelect + 恶化退化的组合),生产用它,但面试考的是原理。数据流上的第 k 大不该用本节方法(数据不能全读进内存),用大小为 k 的堆,见 004 章。
#误区与边界
一,mid 写成 \((lo + hi) / 2\):大数组下 lo + hi 先溢出,必须写 \(lo + (hi - lo) / 2\)。二,区间约定混用:左闭右闭配 \(hi = mid\)、或左闭右开配 \(hi = mid - 1\),都会越过答案或死循环。三,收缩成 \(lo = mid\) 却用下取整中点——长度 2 的区间上永久卡死,见例题 1 的演示。四,对不单调的条件做二分:二分的前提是「条件随下标单调(前段全假后段全真)」,浮点或参数二分前先确认单调性,否则二分是在随机跳。
「找第一个/最后一个满足条件的位置」——lower_bound 模板加语义判定。「旋转数组有重复怎么办」——相等三分时收缩两端,最坏 \(O(n)\)。「第 k 大而不是第 k 小」——即第 \(n - k + 1\) 小,对称转换。「不允许修改数组」——复制或改用堆/平衡树方案。「两个有序数组的中位数」——在第 k 小上做二分排除,\(O(\log \min(m, n))\),是本章思想的进阶应用。「二分还能求什么」——开方、最小满足条件的容量等「二分答案」题,见 035 章。
#检查清单
- 我能先写出循环不变量(「答案若存在必在区间内」或左右两段断言)再写二分代码,并逐行说明每步收缩为何保持不变量。
- 我能默写左闭右闭与左闭右开两套写法,说清循环条件、收缩规则与 mid 取整的配套关系。
- 我能在长度 2 的区间上手动检验终止性,识别并修复「lo = mid + 下取整」型死循环。
- 我能实现 lower_bound 语义并用于插入位置、出现次数(upper_bound − lower_bound)等问题。
- 我能在旋转数组上先判断哪半有序、再精确判断 target 是否落在有序半边,并说出有重复时的退化。
- 我能写出排序、随机 QuickSelect、BFPRT 三种第 k 小实现,并给出各自平均与最坏复杂度。
- 我能推导 BFPRT 的 \(3n/10\) 划分下界与 \(T(n) \le T(n/5) + T(7n/10) + O(n)\) 的线性求和。
- 我知道 mid 的防溢出写法,并了解 009 章将展开的补码与溢出细节。