二分查找(Binary Search)算法,是一种在有序数组中查找某一特定元素的搜索算法,其时间复杂度为 ,空间复杂度为 。本文对二分查找本身及相关算法进行简单介绍和证明。
二分查找
首先我们来看最基础的二分查找算法。二分查找算法的思想非常直接:给定大小为 的有序数组 ,以及查找目标 :
- 初始化搜索空间 , 为 , 为 。
- 确定中间元素下标 。在实践中,为了防止下标出现整数溢出,一般采用 。
- 若 ,查找结束。
- 若 ,代表 在 左侧,令 ,返回 2。
- 若 ,代表 在 右侧,令 ,返回 2。
- 若 , 中不包含 。
上述步骤中有几个比较重要的认识:
- 查找空间 ,代表若 存在,则应当在这个区间中。每次对 和 的修改都应当维持这一约束。
- 每次对 和 进行修改时,查找空间一定是缩小的,否则会陷入死循环。
- 在步骤 4 中,若 ,则令 。这里将 排除出搜索空间,使得区间缩小。
- 在步骤 5 中,若 ,则令 。这里同样将 排除出搜索空间,使得区间缩小。
- 若 ,搜索空间为空,代表没有找到 。
上述算法的代码实现:
int binarySearch(const std::vector<int>& arr, const int target) {
int left = 0;
int right = arr.size();
while (left < right) {
const int mid = (right - left) / 2 + left;
if (arr[mid] > target) {
right = mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
}
我们可以利用迭代器,让算法对所有可随机访问的容器都有效:
template <typename RandomAccessIterator,
typename T =
typename std::iterator_traits<RandomAccessIterator>::value_type>
RandomAccessIterator binarySearch(RandomAccessIterator begin,
RandomAccessIterator end,
const T& target) {
auto left = begin;
auto right = end;
while (left < right) {
auto mid = (right - left) / 2 + left;
if (*mid < target) {
left = mid + 1;
} else if (*mid > target) {
right = mid;
} else {
// *mid == target
return mid;
}
}
return end;
}
二分查找上界与下界
基础的二分查找算法很容易理解。二分查找的思想还有一些常见的场景,比如查找上界(Upper Bound)和下界(Lower Bound)。
给定有序数组 和 :
- 上界: 中最小的下标 ,其满足 。
- 下界: 中最小的下标 ,其满足 。
举例来说,对于数组 ,,上界就是 ,下界就是 。
如果还是感觉太抽象,那么可以结合插入排序来看。所谓下界,就是最小的可以插入的位置;所谓上界,就是最大的可以插入的位置。
二分查找上界
从二分查找修改为二分查找上界,即查找 中最小的下标 ,其满足 ,我们需要确定每次比较时如何修改查找区间 。分两种情况:
若 ,代表上界在 右侧,则 。
若 ,代表上界是 或者在 左侧。
这个条件乍一看应该令 ,但是当 时 此时令 ,得到 ,也就是说 没有变化,导致查找空间没有变化,陷入死循环。
此处正确的变化是 。
这可能会带来问题: 不是上界还则罢了,如果 就是上界,令 ,等于我们在这里将之排除出搜索空间,不会出问题吗?
我们可以简单证明一下。
首先证明 。
我们知道 一定成立,因此我们需要证明 。
假设 ,则有 在 且 的情况下,满足这一等式的唯一解是 ,即 ,但这是二分查找结束的条件,不可能在计算 时出现,因此 ,因此 得证。
那么好了,若 是上界,且 ,则始终有 ,进而 。此后 的值再也不会发生变化, 会一路增长直到 退出查找,此时 就是上界,没有任何问题。
将以上规则写成代码:
template <typename RandomAccessIterator,
typename T =
typename std::iterator_traits<RandomAccessIterator>::value_type>
RandomAccessIterator upperBound(RandomAccessIterator begin,
RandomAccessIterator end,
const T& target) {
auto left = begin;
auto right = end;
while (left < right) {
auto mid = (right - left) / 2 + left;
if (*mid <= target) {
left = mid + 1;
} else {
// *mid > target
right = mid;
}
}
return left;
}
边界情况
若 ,则始终有 ,即 不变, 一直增大到 ,最后返回 ,符合预期。
若 ,则始终有 ,即 不变, 一直减小到 ,最后返回 ,符合预期。
二分查找下界
二分查找下界,即查找 中最小的下标 ,其满足 ,与上界的情况类似。
若 ,则下界在 右侧,令 。
若 ,则 可能是下界,也可能下界位于 左侧。令 。
我们已经证明过 。因此如果 是下界,且 ,则始终有 。此后 的值再也不会发生变化, 会一路增长直到 退出查找,此时 就是下界。
写成代码:
template <typename RandomAccessIterator,
typename T =
typename std::iterator_traits<RandomAccessIterator>::value_type>
RandomAccessIterator lowerBound(RandomAccessIterator begin,
RandomAccessIterator end,
const T& target) {
auto left = begin;
auto right = end;
while (left < right) {
auto mid = (right - left) / 2 + left;
if (*mid >= target) {
right = mid;
} else {
// *mid < target
left = mid + 1;
}
}
return left;
}
边界情况
若 ,则始终有 ,即 不变, 一直增大到 ,最后返回 ,符合预期。
若 ,则始终有 ,即 不变, 一直减小到 ,最后返回 ,符合预期。