二分查找算法
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,通常我们用该算法能够将我们O(n)的时间复杂度优化到 O( l o g 2 n {log_2{n}} log2n);
1、应用二分的场景和条件:可以二分并不是具有单调性, 真正来说能二分应该说具有二段性,即存在可以二分的某种性质,可以将左右两边都划为不同的两类。 二分本身并不难,但二分的条件,和边界条件的判断有时候很坑,这里二分建议直接背过y总的模板:
整数二分的模板
由于整数的二分并不能完全分为左右两边一样的情况,因为整数 / 2 会直接将小数部分去掉 , 在 C / C++ 中是直接下取整 。故其更新左右边界有如下两种情况 .
版本 1
bool check(int x) {/* ... */} // 检查x是否满足某种性质
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1; // 等价于 (l + r) / 2
if (check(mid)) r = mid; // check()判断mid是否满足性质
else l = mid + 1;
}
return l;
}
版本 2
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1; //注意这里必须上取整 + 1, 不然l = mid, 可能在两个的情况下无限递归.
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
分析一下为啥版本2是 mid = (l + r + 1) / 2
这里主要是分析当二分只剩下两个元素中选择的情况, 即此时存在 r = l + 1 这个关系
二分必定有解,即上面两个版本的 while(l < r) 必定可以退出, 退出的条件就是 l == r, 此时l / r 对应的位置的元素即为此次二分的结果, 但此时二分虽然必出结果, 但该结果未必正确. 具体可以看下面的例子.
举个例子
#include <iostream>
#include <vector>
using namespace std;
auto main() -> int
{
vector<int> v = {1, 2, 3, 5, 7}; // 升序的序列
int l = 0, r = v.size() - 1, target;
cin >> target;
while(r > l)
{
int mid = l + r >> 1; // 取中间的值
// 若此时中间的值 >= 目标值, 说明目标值在中间值的左边的位置,故此时将右边界更新为 mid
if(v[mid] >= target) r = mid;
// 否则此时中间值小于目标值, 目标值若存在必定在中间值的右边,故更新左边界为 mid + 1
else l = mid + 1;
}
if(v[l] == target) cout << "yes" << endl;
else cout << "no" << " v[l] = " << v[l] << endl;
return 0;
}
# case 1
输入: 3
输出: yes
# case 2
输入: 4
输出: no v[l] = 5
# case 3
输入: 6
输出: no v[l] = 7
对于case2 和 case3 的输出分析, 我们此时的if中的check条件为 : v[mid] >= target ,即我们想找到第一个大于等于 target的值, 并不是要找一个等于target的值, 通过这个check保证了我们的二分一定有解。 通过这个check将我们的有序序列分为了两部分, 一部分是 >= target , 另一部分 < target;
具体应用场景
题目链接:剑指offer 53.在排序数组中查找数字 I
这道题应该是很经典的二分题目,考虑了两种二分的边界情况. 统计一个数字在排序数组中出现的次数。如果按照平常的做法, 那肯定是从左到右遍历一遍, 记录下. 这样时间复杂度为
O
(
n
)
O(n)
O(n), 但实际上这道题如果面试考你, 就是考你二分,因为题目已经说明了这是一个排序数组. 故这个数组本身就是具有二段性, 一段是不大于target目标值的区间, 一段是大于target目标值的区间.
class Solution
{
public:
int search(vector<int>& nums, int target)
{
int l = 0, r = nums.size() - 1;
if(r - l + 1 == 0) return 0;
while(r > l)
{
int mid = l + r >> 1;
// 如果存在多个target值, 我们先找到的是大于等于target的最小值, 即最左边的那个target,所以这里更新的是r,右边界向左逼近.
if(nums[mid] >= target) r = mid;
else l = mid + 1;
}
// 由于我们找的是大于等于target的最小值, 故有可能target值并不存在, 故此时还要进一步判断找到的左边界是否正确.
if(nums[l] != target) return 0;
int l1 = l;
r = nums.size() - 1;
while(r > l)
{
int mid = l + r + 1 >> 1;
// 接下来找target右边界, 即我们的左边界要向右逼近, 这个条件的更新即此时的mid对应的值小于等于target,我们才左边界才向右靠近.
if(nums[mid] <= target) l = mid;
else r = mid - 1;
}
return r - l1 + 1; // 返回找到的个数.
}
};
题目链接 : 896. 最长上升子序列 II
这里不直接分析题目, 直接看二分的核心代码
#include<iostream>
using namespace std;
constexpr int INF = 1e+9;
constexpr int N = 1e+5 + 10;
int n, a[N]; // 数组a[i]的含义为子序列长度为i, 且储存元素为长度为i的序列中结尾的最小值
auto main() -> int
{
cin >> n;
a[0] = -INF - 10; // 子序列长度为0的结尾值直接设个最小值即可
int t = 0; // 表示此时a数组所储存的最长子序列长度为t,初始为0
for(int i = 1; i <= n; ++i)
{
int x; cin >> x; // 依次输入待处理的数
int l = 0, r = t; // 这里用二分查找小于x的最大的那个数字
while(r > l)
{
int mid = l + r + 1>> 1;
if(a[mid] < x) l = mid; // check条件和l的更新情况其是为了找到小于x的最大的那个数字
else r = mid - 1;
}
t = max(t, r + 1); // 更新t
a[++r] = x; // 更新长度为r + 1长度的序列的结尾的值
}
cout << t << endl;
return 0;
}
题目链接 : 153. 寻找旋转排序数组中的最小值
// 旋转数组即属于那种不具备单调性,但具备二段性, 故能用二分进行优化的情况.
class Solution
{
public:
int findMin(vector<int>& nums)
{
int n = nums.size() - 1;
if(nums[0] <= nums[n]) return nums[0];
int l = 0, r = n;
while(r > l)
{
int mid = l + r >> 1;
if(nums[mid] < nums[0]) r = mid; // 找到小于nums[0]的最小值.
else l = mid + 1;
}
return nums[l];
}
};