C++ STL 二分查找算法族详解
本文面向面试和日常开发,先速查用法,再拆底层原理,最后上八股。
一、用法速查
1.1 前置条件:有序区间
STL 二分查找四件套全部要求输入区间已按 < 或传入的比较器排好序,否则行为未定义(可能 false negative,也可能死循环)。编译不报错,结果全不可靠。
1.2 四函数速查表
| 函数 | 返回值 | 含义 |
|---|---|---|
binary_search | bool | val 是否存在 |
lower_bound | ForwardIt | 第一个 ≥ val 的位置 |
upper_bound | ForwardIt | 第一个 > val 的位置 |
equal_range | pair<It,It> | [lower, upper) 区间 |
1.3 同一组数据上的差异
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main() {
vector<int> v{1, 3, 5, 5, 5, 7, 9};
int val = 5;
cout << boolalpha;
cout << "binary_search: " << binary_search(v.begin(), v.end(), val) << "\n";
// true
auto lo = lower_bound(v.begin(), v.end(), val);
cout << "lower_bound: v[" << (lo - v.begin()) << "] = " << *lo << "\n";
// v[2] = 5
auto hi = upper_bound(v.begin(), v.end(), val);
cout << "upper_bound: v[" << (hi - v.begin()) << "] = " << *hi << "\n";
// v[5] = 7
auto [l, u] = equal_range(v.begin(), v.end(), val);
cout << "equal_range: [" << (l - v.begin()) << ", " << (u - v.begin()) << ")\n";
// [2, 5)
cout << "count of 5: " << (u - l) << "\n"; // 3
}
1.4 自定义比较器 / 逆序场景
vector<int> v{9, 7, 5, 5, 5, 3, 1}; // 降序
// 降序区间必须用 greater<int>() 作为比较器
// lower_bound 返回第一个 ≤ val 的位置
auto lo = lower_bound(v.begin(), v.end(), 5, greater<int>());
// v[2] = 5
auto hi = upper_bound(v.begin(), v.end(), 5, greater<int>());
// v[5] = 3
1.5 C++20 ranges 版本
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main() {
vector<int> v{1, 3, 5, 5, 5, 7, 9};
cout << ranges::binary_search(v, 5) << "\n"; // true
auto lo = ranges::lower_bound(v, 5);
cout << "lower_bound: " << *lo << "\n"; // 5
auto hi = ranges::upper_bound(v, 5);
cout << "upper_bound: " << *hi << "\n"; // 7
auto [l, u] = ranges::equal_range(v, 5);
cout << "equal_range: [" << *l << ", " << *u << ")\n"; // 5, 7
// 投影——按 second 查找
vector<pair<int, int>> vp{{1, 10}, {2, 20}, {3, 30}};
auto it = ranges::lower_bound(vp, 20, {}, &pair<int,int>::second);
cout << "proj lower_bound: (" << it->first << "," << it->second << ")\n";
// (2,20)
}
二、底层原理
2.1 核心二分框架
四个函数共用同一套二分逻辑,区别仅在于边界判断条件和返回的信息量。
2.2 lower_bound 标准实现(gcc libstdc++)
template<class ForwardIt, class T>
ForwardIt lower_bound(ForwardIt first, ForwardIt last, const T& value) {
ForwardIt it;
typename iterator_traits<ForwardIt>::difference_type count, step;
count = distance(first, last);
while (count > 0) {
it = first;
step = count / 2;
advance(it, step);
if (*it < value) {
first = ++it;
count -= step + 1;
} else {
count = step;
}
}
return first;
}
核心逻辑:
*mid < val→ 目标在右半,排除 mid 及其左侧(first = ++mid)- 否则 → 目标在左半(含 mid),收缩右边界
- 循环结束,
first指向第一个 ≥ val 的位置
STL 使用 count/step 模式而非 left/right,天然避免 off-by-one——count 递减到 0 自然终止。
2.3 upper_bound 的差异
upper_bound 判断条件从 < 变成 <=(取反等价于 !(val < *mid)):
if (!(value < *it)) { // 即 *it <= value
first = ++it;
count -= step + 1;
} else {
count = step;
}
| 函数 | 条件(找到第一个……的元素) |
|---|---|
lower_bound | *it < val 为 false,即 *it ≥ val |
upper_bound | val < *it 为 true,即 *it > val |
2.4 equal_range = lower_bound + upper_bound
template<class ForwardIt, class T>
pair<ForwardIt, ForwardIt> equal_range(ForwardIt first, ForwardIt last, const T& value) {
return {lower_bound(first, last, value),
upper_bound(first, last, value)};
}
两次二分查找,对随机访问迭代器仍是 O(log n)。
2.5 lower_bound 在有序数组上的跳跃过程
2.6 ForwardIterator 陷阱与链表二分
四个函数全部使用 ForwardIterator,不要求 RandomAccessIterator。这意味着 std::forward_list 也能调用——编译通过。
2.7 STL 二分 vs 手写二分
| 问题 | 表现 | STL 如何避免 |
|---|---|---|
(left+right)/2 溢出 | left+right > INT_MAX 时 UB | 用 count/2 无加法溢出 |
| 死循环 | left = mid 而非 left = mid+1 | count -= step+1 保证递减 |
| off-by-one | 区间开闭搞混 | count 到 0 自动终止 |
| 边界条件写反 | 差一个 = 全错 | 统一用 < 判断,按需取反 |
STL 的 count/step 模式把上述坑全封装了。
三、面试题
Q1:binary_search 为什么不返回位置?
“设计取舍。binary_search 的语义是判存在性,返回迭代器会迫使用户处理 end()。实际实现就是 auto it = lower_bound(...); return it != last && !(val < *it);——一层包装。单一职责:存在性给 bool,找位置给 lower_bound。”
Q2:lower_bound 和 upper_bound 的 off-by-one 陷阱?
“两组边界。第一,所有元素都小于 val 时,两者都返回 last——解引用 UB。第二,lower_bound 返回第一个 ≥ val,如果 val 不存在,它指向 val 本应在的位置(插入仍有序)。upper_bound 返回第一个 > val。所以 [lower, upper) 恰好是所有等于 val 的区间。”
Q3:equal_range 什么场景用?
“两种场景。第一,multiset/multimap 批量操作——auto [l, u] = mp.equal_range(5) 拿到区间,mp.erase(l, u) 一键删除。第二,统计计数——distance(l, u) 得个数,比手动调两次更安全。注意 equal_range 内部就是调 lower_bound + upper_bound,性能没加成,纯代码简洁。”
Q4:二分查找对 forward_list 的效率陷阱?
“forward_list 是单向链表,迭代器是 ForwardIterator 而非 RandomAccessIterator。编译能过,但 advance 是 O(n),每轮 O(log n) 轮次,总 O(n log n)。对链表用 find O(n) 反而更快。标准没禁止这种组合,工程中绝不要做。”
Q5:为什么必须有序?
“二分查找核心假设是区间按比较器有序。如果无序,mid 和 val 的比较结果无法预测右侧元素大小关系。比如 [3,1,5],mid=1,发现 1 < val 就去右半找——左边可能藏着 val。这是算法前提,不是 STL 约束。”
Q6:手写二分的常见 bug?
“四个经典坑。第一,mid = (left + right) / 2 大数溢出,改成 left + (right - left) / 2。第二,死循环——while(left<right) 时 left=mid 而非 left=mid+1,right-left==1 时永不收敛。第三,区间边界不统一——[left,right) 和 [left,right] 混用。第四,*mid < target 还是 *mid <= target 差一个 = 号决定是 lower_bound 还是 upper_bound。STL 的 count/step 实现把这些问题全封装了。”
Q7:lower_bound 在 map/set 上成员 vs 全局?
“map/set 有成员函数版本,比 std::lower_bound 快。全局版本在非 RandomAccessIterator 上只能 O(n) advance(虽然有特化),成员函数直接利用红黑树结构保证 O(log n)。对 map/set 永远用 mp.lower_bound(k)。”
Q8:C++20 ranges 二分的新东西?
“两点。第一,不用传 begin/end——ranges::lower_bound(v, 5) 直接传容器。第二,投影——ranges::lower_bound(vp, 20, {}, &pair<int,int>::second),比较时只看 second 成员,不需要写 Lambda。”
Q9:equal_range 为什么返回 pair 不是 tuple?
“pair 足够表达——两个迭代器分别表示开始和结束。C++11 结构化绑定后 auto [l, u] = equal_range(...) 读起来不输 tuple。标准委员会对 pair 的偏好在算法层面一致——minmax、partition_copy 等返回值都用 pair。”
一句话总结:STL 二分四件套共享一套 O(log n) 二分框架,区别仅在于边界判断条件和返回信息量;理解 lower_bound 返回"第一个 ≥ val"这一核心语义,分清数组和链表上的性能差异,知道手写二分的溢出和 off-by-one 陷阱,是掌握这一族算法的关键。
589

被折叠的 条评论
为什么被折叠?



