深入C++ STL二分查找边界:从算法本质到工程实践的全方位驾驭
如果你在LeetCode上刷题时,经常看到别人优雅地用几行代码就解决了需要你写一整个二分查找循环的问题,心里会不会有点痒痒的?或者,当你自己尝试使用 lower_bound 和 upper_bound 时,总觉得对自定义比较函数那一块云里雾里,用起来战战兢兢?别担心,这种感觉我太熟悉了。几年前我刚接触STL算法时,也是对着文档看了又看,写出来的代码却总在边界条件上栽跟头。直到后来,我花了些时间,从底层实现一路追到应用场景,才真正把它们变成了我工具箱里最趁手的“瑞士军刀”之一。
这篇文章,就是为你准备的。我们不只停留在“怎么用”的层面,而是要一起拆开STL的黑盒子,看看这两个函数内部的齿轮是如何咬合的。更重要的是,我们会探讨如何通过自定义比较函数,让这两个看似简单的工具,迸发出解决复杂问题的巨大能量。无论你是想优化算法竞赛的代码,还是在构建高性能的后端服务,理解并熟练运用这两个函数,都能让你写出更简洁、更健壮、也更具表现力的C++代码。
1. 算法基石:二分查找的两种“边界”哲学
在讨论具体的函数之前,我们必须先统一思想:lower_bound 和 upper_bound 的本质是什么?很多人会脱口而出:“一个找第一个大于等于目标值的位置,一个找第一个大于目标值的位置。” 这没错,但这是结果,不是本质。
它们的本质,是在有序区间上,定义并寻找两种不同的“插入点”。
想象你有一本按字母顺序排列的电话簿,现在你要插入一个新名字“David”。这本书的编排规则决定了David应该出现的位置。lower_bound 帮你找到的是这样一个位置:如果David已经存在,这个位置指向第一个David;如果David不存在,这个位置就是David应该被插入的位置,以保证插入后序列依然有序。换句话说,它寻找的是“第一个不小于David”的位置。
而 upper_bound 寻找的是“第一个大于David”的位置。如果David存在多个,upper_bound 会指向最后一个David之后的位置。这个位置同样是一个有效的插入点,插入后也能保持有序,但它与 lower_bound 找到的位置,共同界定了一个范围:[lower_bound, upper_bound) 这个左闭右开区间,包含了序列中所有等于David的条目。
为什么理解这一点至关重要?因为一旦你从“插入点”的角度去思考,很多困惑就迎刃而解了。比如,为什么要求区间必须有序?因为只有有序,我们才能明确定义一个值的“应有位置”。为什么底层用二分查找?因为二分是定位这个“应有位置”最高效的方法。
提示:
std::lower_bound和std::upper_bound并不要求容器本身有序,它们只要求对于给定的比较准则,区间是**已划分(partitioned)**的。这是一个比“全序”更弱但更精确的条件,也是它们能与自定义比较函数灵活配合的理论基础。
让我们看一个最基础的例子,感受一下这种“边界感”:
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec = {10, 20, 20, 20, 30, 40};
// 寻找值 20 的边界
auto low = std::lower_bound(vec.begin(), vec.end(), 20);
auto up = std::upper_bound(vec.begin(), vec.end(), 20);
std::cout << "lower_bound at index: " << (low - vec.begin()) << std::endl; // 输出 1
std::cout << "upper_bound at index: " << (up - vec.begin()) << std::endl; // 输出 4
// 区间 [low, up) 包含了所有的 20
std::cout << "Elements equal to 20: ";
for (auto it = low; it != up; ++it) {
std::cout << *it << " "; // 输出 20 20 20
}
std::cout << std::endl;
// 寻找不存在的值 25
auto low25 = std::lower_bound(vec.begin(), vec.end(), 25);
auto up25 = std::upper_bound(vec.begin(), vec.end(), 25);
std::cout << "lower_bound for 25 at index: " << (low25 - vec.begin()) << std::endl; // 输出 4 (指向30)
std::cout << "upper_bound for 25 at index: " << (up25 - vec.begin()) << std::endl; // 输出 4 (同样指向30)
// 两者相等,说明 25 不在序列中,且它们都指向同一个插入位置。
return 0;
}
这个简单的例子揭示了一个关键事实:对于不存在的值,lower_bound 和 upper_bound 会返回同一个位置——即该值应该被插入的位置。这个特性在实现诸如“插入并保持有序”的操作时非常有用。
2. 窥探引擎盖下:手写二分查找边界算法
仅仅知道怎么用API是不够的。要真正驾驭一个工具,最好的方式就是尝试自己造一个轮子,哪怕造得粗糙些。我们来亲手实现一下 lower_bound 和 upper_bound 的简化版本,这能让你对二分查找的细节和边界处理有刻骨铭心的理解。
我们先从最经典的二分查找开始,它只回答“是否存在”:
// 经典二分查找,返回目标值下标,未找到返回-1
int binary_search(const std::vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 闭区间 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2; // 防止溢出
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else { // nums[mid] > target
right = mid - 1;
}
}
return -1; // 未找到
}
现在,我们把它改造成寻找“第一个不小于target”的位置,也就是 lower_bound:
// 模拟 std::lower_bound,返回第一个 >= target 的元素索引
int my_lower_bound(const std::vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 注意!这里是开区间 [left, right)
while (left < right) {
int mid = left + (right - left) / 2;
// 核心决策:如果中间值小于目标,说明答案在右侧(不含mid)
if (nums[mid] < target) {
left = mid + 1;
} else {
// 如果中间值 >= 目标,答案可能是mid,也可能在左侧,所以right收缩到mid
right = mid;
}
}
// 循环结束时,left == right,且指向第一个 >= target 的位置,或 nums.end()
return left;
}
这里有几个极易出错的细节:
- 区间表示:我们使用了左闭右开区间
[left, right)。这意味着right初始化为nums.size()


3万+

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



