查找、二分与顺序统计量 — 从零精通算法与数据结构——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_

本章小结

  1. 标准二分查找:O(log n),关键是 left + (right - left) / 2 防溢出
  2. lower_bound / upper_bound:泛化的二分,区别仅在 < vs
  3. 二分答案:只要满足单调性就可以二分,不只二分索引,二分答案空间
  4. QuickSelect:期望 O(n) 找第 k 小,思路是 partition 后只递归一边
  5. BFPRT:理论保证最坏 O(n),实际常数大,实践中 Random QuickSelect 更好
  6. 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 的值也不满足——二分的必要条件
posted @ 2026-06-22 01:09  Yobeeo  阅读(4)  评论(0)    收藏  举报