查找、二分与顺序统计量 — 从零精通算法与数据结构——Google 面试系统备战 第3篇
第3章:查找、二分与顺序统计量
本章目标
读完本章你会:
- 手写二分查找的三种变体(精确查找、lower_bound、upper_bound)
- 在旋转数组、山脉数组等变种场景中应用二分思想
- 用 QuickSelect 在 O(n) 期望时间内找到第 k 小的元素
- 理解 Median-of-Medians(BFPRT)的最坏 O(n) 正确性
- 将查找和选择算法添加到 algo_toolkit
知识讲解
从一个生活例子开始
你有一本按字母顺序排列的电话簿,要找 "Smith" 的电话。
翻开中间——是 "Johnson" → "Smith" 在右边 → 扔掉左半 → 再翻右边中间 → 是 "Peterson" → "Smith" 在右边 → 再折半……
你不需要翻 1000 页,大概 10 次翻页就能找到。这就是二分查找的直觉:每次决策排除一半候选。
但等一下——如果电话簿被撕掉了几页,重新拼在一起,虽然整体失去了严格的字母顺序,但你注意到拼合处仍然是一个"断崖"——你能在这样一本"旋转"过的电话簿里找到目标吗?
这就是二分查找的深层思想:不是依赖"整体有序",而是"每次能判断目标在哪一半"。
工作原理
3.1 标准二分查找
// 在有序数组 [begin, end) 中查找 target
// 返回迭代器指向目标,未找到返回 end
auto BinarySearch(RandomIt begin, RandomIt end, const T& target) {
auto left = 0;
auto right = std::distance(begin, end) - 1;
while (left <= right) { // ≤ 不是 <——因为 [left, right] 是闭区间
auto mid = left + (right - left) / 2; // 防溢出
if (begin[mid] == target) return begin + mid;
if (begin[mid] < target) left = mid + 1;
else right = mid - 1;
}
return end; // 未找到
}
关键细节:left + (right - left) / 2 vs (left + right) / 2
后者在 left + right 溢出 32 位 int 最大值(约 21 亿)时产生未定义行为。前者永远安全。
3.2 二分查找的泛化:lower_bound & upper_bound
标准二分查找只处理"找等于 target 的元素"。但实际场景中:
- lower_bound: 第一个 ≥ target 的位置(插入而不破坏顺序)
- upper_bound: 第一个 > target 的位置
// lower_bound: 第一个 >= target 的位置
// 不变式:答案在 [left, right] 之间
auto LowerBound(RandomIt begin, RandomIt end, const T& target) {
auto left = begin;
auto right = end;
while (left != right) {
auto mid = left + (right - left) / 2;
if (*mid < target) {
left = mid + 1; // mid 及左边都 < target,排除
} else {
right = mid; // mid 可能是答案,保留
}
}
return left;
}
// upper_bound: 第一个 > target 的位置
auto UpperBound(RandomIt begin, RandomIt end, const T& target) {
auto left = begin;
auto right = end;
while (left != right) {
auto mid = left + (right - left) / 2;
if (*mid <= target) {
left = mid + 1; // mid 及左边都 ≤ target,排除
} else {
right = mid; // mid 可能是答案
}
}
return left;
}
两者唯一的区别: *mid < target(lower_bound)vs *mid <= target(upper_bound)。这个细微差别精确对应了"排除区"的定义:lower_bound 排除严格小于的部分,upper_bound 排除小于等于的部分。
巧记: equal_range = [lower_bound, upper_bound)——这个区间内所有元素都等于 target。
3.3 二分思想的泛化:二分答案
二分不仅仅在有序数组上查找。只要满足单调性,就可以二分。核心问题变成:「给定一个候选答案,能不能快速判断它是太大了还是太小了?」
经典场景:
- 求平方根:
mid * mid < x→ 太小 → left=mid,否则 right=mid - 最小化最大分配: 工人分配任务,二分"最大工作量"
- Koko 吃香蕉: 二分"吃香蕉的速度"
这类问题在 LeetCode 上极常见,Google 面试中也时有出现。关键思维转变:不是二分索引,而是二分答案空间。
3.4 QuickSelect:不排序也能找第 k 小
问题:给定无序数组,找出第 k 小的元素(1-indexed)。不用排序整数组。
直觉: 快排的 partition 告诉你 pivot 的确切排名——如果 pivot 排第 q,那么第 k 小就在左(k < q)、正好是 pivot(k=q)、或右(k > q)。递归其中一边即可。
QUICKSELECT(A, left, right, k):
if left == right: return A[left]
pivot_idx = RANDOM(left, right)
q = PARTITION(A, left, right) // q 是 pivot 的最终位置(从 0 开始)
if k == q + 1: // 正好是 pivot
return A[q]
else if k < q + 1: // 在左边找
return QUICKSELECT(A, left, q-1, k)
else: // 在右边找
return QUICKSELECT(A, q+1, right, k)
复杂度分析(为什么是期望 O(n)):
第一轮:partition n 个元素
第二轮:partition n/2 个元素(期望)
第三轮:partition n/4 个元素
...
总和:n + n/2 + n/4 + ... = 2n = O(n)
与快排的关键区别:快排递归两边(O(n log n)),QuickSelect 只递归一边(O(n))。
3.5 Median-of-Medians(BFPRT):最坏 O(n) 的选择算法
QuickSelect 的期望是 O(n),但最坏情况(总是选到最小 pivot)会退化到 O(n²)。BFPRT 通过确定性地选一个"好"pivot保证了最坏 O(n)。
BFPRT 选 pivot 的五步法:
1. 将 n 个元素按每组 5 个分成 ⌈n/5⌉ 组
2. 每组内排序(5 个元素排序是 O(1)),取中位数
3. 递归调用 BFPRT,找出这 ⌈n/5⌉ 个中位数的中位数 → pivot
4. 用这个 pivot 做 partition
5. 根据 k 的位置,递归左边或右边
为什么选 5 个一组? 数学上可以证明:5 个一组的中位数的中位数,至少大于 3n/10 的元素,也至少小于 3n/10 的元素。这意味着递归规模至少缩小 30%。递推式为 T(n) ≤ T(n/5) + T(7n/10) + O(n),解出来是 O(n)。
实践中的诚实结论: BFPRT 常数巨大,实际中远不如随机 QuickSelect 快。它存在的意义是理论上证明了"最坏 O(n) 的选择算法存在"——面试中除非面试官直接问,否则不提。
代码实战
在 algo_toolkit 中添加 searching.h。
include/algo/searching.h
#ifndef ALGO_SEARCHING_H_
#define ALGO_SEARCHING_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <iterator>
#include <vector>
namespace algo {
// ========== 二分查找系列 ==========
// 精确查找:返回 target 的索引,未找到返回 -1
// 要求 [begin, end) 有序
template <typename RandomIt, typename T>
int64_t BinarySearch(RandomIt begin, RandomIt end, const T& target);
// lower_bound:第一个 >= target 的位置
template <typename RandomIt, typename T>
RandomIt LowerBound(RandomIt begin, RandomIt end, const T& target);
// upper_bound:第一个 > target 的位置
template <typename RandomIt, typename T>
RandomIt UpperBound(RandomIt begin, RandomIt end, const T& target);
// ========== 选择算法 ==========
// QuickSelect:期望 O(n) 时间找第 k 小(k 从 1 开始)
// 会修改输入数组
int QuickSelect(std::vector<int>& data, int k);
// MedianOfMedians(BFPRT):最坏 O(n) 时间找第 k 小
int MedianOfMedians(std::vector<int>& data, int k);
// ========== 二分答案(泛化) ==========
// 在整数范围 [lo, hi] 上二分查找第一个满足 predicate 的值
// 要求:存在阈值 T,使得 predicate(x) 在 x < T 时为 false,x ≥ T 时为 true
template <typename Predicate>
int64_t BinarySearchFirstTrue(int64_t lo, int64_t hi, Predicate&& predicate);
} // namespace algo
#include "algo/searching_impl.h"
#endif // ALGO_SEARCHING_H_
include/algo/searching_impl.h
#ifndef ALGO_SEARCHING_IMPL_H_
#define ALGO_SEARCHING_IMPL_H_
#include <algorithm>
#include <cstdlib>
#include <random>
#include <utility>
#include <vector>
namespace algo {
// ========== 二分查找实现 ==========
template <typename RandomIt, typename T>
int64_t BinarySearch(RandomIt begin, RandomIt end, const T& target) {
int64_t left = 0;
int64_t right = std::distance(begin, end) - 1;
while (left <= right) {
// 防溢出写法:left + (right - left) / 2
int64_t mid = left + (right - left) / 2;
auto& mid_val = *(begin + mid);
if (mid_val == target) return mid;
if (mid_val < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // 未找到
}
template <typename RandomIt, typename T>
RandomIt LowerBound(RandomIt begin, RandomIt end, const T& target) {
auto left = begin;
auto right = end;
while (left != right) {
auto mid = left + (right - left) / 2;
if (*mid < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
template <typename RandomIt, typename T>
RandomIt UpperBound(RandomIt begin, RandomIt end, const T& target) {
auto left = begin;
auto right = end;
while (left != right) {
auto mid = left + (right - left) / 2;
if (*mid <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
// ========== QuickSelect ==========
namespace detail {
int Partition(std::vector<int>& data, int left, int right, int pivot_idx) {
int pivot_val = data[pivot_idx];
std::swap(data[pivot_idx], data[right]); // pivot 移到末尾
int store_idx = left;
for (int i = left; i < right; ++i) {
if (data[i] < pivot_val) {
std::swap(data[store_idx], data[i]);
++store_idx;
}
}
std::swap(data[store_idx], data[right]); // pivot 归位
return store_idx;
}
int QuickSelectImpl(std::vector<int>& data, int left, int right, int k) {
if (left == right) return data[left];
// 随机选 pivot
static thread_local std::mt19937 rng(std::random_device{}());
int pivot_idx = left + std::uniform_int_distribution<int>(0, right - left)(rng);
int q = Partition(data, left, right, pivot_idx);
// q 是从 0 开始的位置,k 是从 1 开始的
if (k == q + 1) return data[q];
if (k < q + 1) return QuickSelectImpl(data, left, q - 1, k);
return QuickSelectImpl(data, q + 1, right, k);
}
} // namespace detail
inline int QuickSelect(std::vector<int>& data, int k) {
if (data.empty() || k < 1 || k > static_cast<int>(data.size())) {
return -1; // 无效输入——生产代码中应该抛异常
}
return detail::QuickSelectImpl(data, 0,
static_cast<int>(data.size()) - 1, k);
}
// ========== MedianOfMedians BFPRT ==========
namespace detail {
// 对 5 个元素插入排序,返回中位数位置
int MedianOf5(std::vector<int>& data, int a, int b, int c, int d, int e) {
// 5 个元素的排序网络——最快的方式
auto swap_if_greater = [&](int i, int j) {
if (data[i] > data[j]) std::swap(data[i], data[j]);
};
swap_if_greater(a, b);
swap_if_greater(c, d);
swap_if_greater(a, c);
swap_if_greater(b, d);
swap_if_greater(b, e);
swap_if_greater(b, c);
// 现在 data[b] 或 data[c] 是中位数
if (data[b] > data[c]) std::swap(data[b], data[c]);
swap_if_greater(c, d);
return c; // 第三大的元素的索引
}
int FindPivot(std::vector<int>& data, int left, int right) {
int n = right - left + 1;
if (n <= 5) {
// 对于 ≤5 个元素,排序后取中位数
std::sort(data.begin() + left, data.begin() + right + 1);
return left + n / 2;
}
// 1. 每组 5 个,取中位数,移到数组前面
int median_count = 0;
for (int i = left; i <= right; i += 5) {
int group_end = std::min(i + 4, right);
int group_size = group_end - i + 1;
if (group_size == 5) {
int med = MedianOf5(data, i, i + 1, i + 2, i + 3, i + 4);
std::swap(data[left + median_count], data[med]);
} else {
// 最后一组不足 5 个:排序取中位数
std::sort(data.begin() + i, data.begin() + group_end + 1);
int med = i + group_size / 2;
std::swap(data[left + median_count], data[med]);
}
++median_count;
}
// 2. 递归找出中位数的中位数
int mid_of_meds = left + median_count / 2;
return FindPivot(data, left, left + median_count - 1); // 注意:返回的是 pivot 值的位置
// 实际上我们直接在这里递归调用完整 BFPRT...
// 简化处理:用 QuickSelect 近似——生产代码通常这样做
}
} // namespace detail
// ⚠ 教学简化:MedianOfMedians() 此处调用 QuickSelect() 作为快速默认实现。
// 真正的 BFPRT 最坏 O(n) 算法需完整实现递归 pivot 选择——见 ⭐⭐⭐ 挑战练习。
// 当前简化版用于日常使用(QuickSelect 期望 O(n) 在实际中已足够),
// 保留 MedianOfMedians 这个接口名是为了展示"确定性选择算法"的 API 设计。
inline int MedianOfMedians(std::vector<int>& data, int k) {
return QuickSelect(data, k);
}
// ========== 泛化二分答案 ==========
template <typename Predicate>
int64_t BinarySearchFirstTrue(int64_t lo, int64_t hi, Predicate&& predicate) {
while (lo < hi) {
int64_t mid = lo + (hi - lo) / 2;
if (predicate(mid)) {
hi = mid; // mid 满足条件,答案可能是 mid 或更左
} else {
lo = mid + 1; // mid 不满足,答案一定在右边
}
}
// lo == hi,此时 predicate(lo) 应该是 true(除非全为 false)
return lo;
}
} // namespace algo
#endif // ALGO_SEARCHING_IMPL_H_
本章小结
- 标准二分查找:O(log n),关键是
left + (right - left) / 2防溢出 - lower_bound / upper_bound:泛化的二分,区别仅在
<vs≤ - 二分答案:只要满足单调性就可以二分,不只二分索引,二分答案空间
- QuickSelect:期望 O(n) 找第 k 小,思路是 partition 后只递归一边
- BFPRT:理论保证最坏 O(n),实际常数大,实践中 Random QuickSelect 更好
left < right对应半开区间,left <= right对应闭区间——选择取决于你的不变式设计
关键术语
| 术语 | 释义 |
|---|---|
| 二分查找 | 在有序序列中每次排除一半候选的 O(log n) 查找算法 |
| lower_bound | 第一个大于等于目标值的位置 |
| upper_bound | 第一个严格大于目标值的位置 |
| QuickSelect | 基于快排 partition 的选择算法,期望 O(n) 时间 |
| BFPRT | 确定性最坏 O(n) 选择算法,通过 Median-of-Medians 选 pivot |
| 单调性 | 性质:若 x < T 不满足条件,则任何 < x 的值也不满足——二分的必要条件 |

浙公网安备 33010602011771号