文章目录
第 4 章 居合斩!二分查找
4.1 算法解释
二分查找也常被称为二分法或者折半查找,每次查找时通过将待查找区间分成两部分并只取一部分继续查找,将查找的复杂度大大减少。对于一个长度为 O(n) 的数组,二分查找的时间复杂度为 O(log n)。
举例来说,给定一个排好序的数组 {3,4,5,6,7},我们希望查找 4 在不在这个数组内。第一次折半时考虑中位数 5,因为 5 大于 4, 所以如果 4 存在于这个数组,那么其必定存在于 5 左边这一半。于是我们的查找区间变成了 {3,4,5}。(注意,根据具体情况和您的刷题习惯,这里的 5 可以保留也可以不保留,并不影响时间复杂度的级别。)第二次折半时考虑新的中位数 4,正好是我们需要查找的数字。于是我们发现,对于一个长度为 5 的数组,我们只进行了 2 次查找。如果是遍历数组,最坏的情况则需要查找 5 次。
我们也可以用更加数学的方式定义二分查找。给定一个在 [a, b] 区间内的单调函数 f (x),若f (a) 和 f (b) 正负性相反,那么必定存在一个解 c,使得 f (c) = 0。在上个例子中,f (x) 是离散函数f (x) = x +2,查找 4 是否存在等价于求 f (x) −4 = 0 是否有离散解。因为 f (1) −4 = 3−4 = −1 < 0、f (5) − 4 = 7 − 4 = 3 > 0,且函数在区间内单调递增,因此我们可以利用二分查找求解。如果最后二分到了不能再分的情况,如只剩一个数字,且剩余区间里不存在满足条件的解,则说明不存在离散解,即 4 不在这个数组内。
具体到代码上,二分查找时区间的左右端取开区间还是闭区间在绝大多数时候都可以,因此有些初学者会容易搞不清楚如何定义区间开闭性。这里我提供两个小诀窍,第一是尝试熟练使用一种写法,比如左闭右开(满足 C++、Python 等语言的习惯)或左闭右闭(便于处理边界条件),尽量只保持这一种写法;第二是在刷题时思考如果最后区间只剩下一个数或者两个数,自己的写法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。
二分查找也可以看作双指针的一种特殊情况,但我们一般会将二者区分。双指针类型的题,指针通常是一步一步移动的,而在二分查找里,指针每次移动半个区间长度。
二分法知识点
左闭右闭&左闭右开----讲解视频
手把手带你撕出正确的二分法 | 二分查找法 | 二分搜索法 | LeetCode:704. 二分查找_哔哩哔哩_bilibili
4.2 求开方
[69. x 的平方根 ]
题目描述
给定一个非负整数,求它的开方,向下取整。
输入输出样例
输入一个整数,输出一个整数。
Input: 8
Output: 2
8 的开方结果是 2.82842…,向下取整即是 2。
题解
- 为了防止 int 溢出所以用long
- 使用了左闭右闭的写法,所以L <= R
class Solution {
public:
int mySqrt(int x) {
//针对两种情况 x = 1,0
if(x <= 1)return x;
long left = 0 , right = x/2 , ans = -1;
while(right >= left){
long mid = left + (right - left)/2;
long num = mid * mid;
if(num <= x){//比如 2*2<8,但是8的算术平方根是2
ans = mid;
left = mid + 1;
}else{
right = mid - 1;
}
}
return ans;
}
};
4.3 查找区间
[34. 在排序数组中查找元素的第一个和最后一个位置]
题目描述
给定一个增序的整数数组和一个值,查找该值第一次和最后一次出现的位置。
输入输出样例
输入是一个数组和一个值,输出为该值第一次出现的位置和最后一次出现的位置(从 0 开始);如果不存在该值,则两个返回值都设为-1。
Input: nums = [5,7,7,8,8,10], target = 8
Output: [3,4]
数字 8 在第 3 位第一次出现,在第 4 位最后一次出现。
题解
写出两个辅助函数,利于解题。
class Solution {
public:
//主函数
vector<int> searchRange(vector<int>& nums, int target) {
int L = start(nums , target);
int R = end(nums , target);
vector<int> v{L , R};
return v;
}
//取出下标最小的目标值
int start(vector<int>& nums, int target) {
//- 1 是为了取出第一个有用的值, L R保存的是索引
int L = 0 , R = nums.size() - 1 , ans = -1;
while(R >= L){
int mid = L + (R - L)/2;
//一开始因为>所以移动,后来因为=移动找到最左边符合的值
if(nums[mid] >= target){
ans = mid;
R = mid - 1;
}else{
L = mid +1;
}
}
if(ans == -1 || nums[ans] != target) return -1;
return ans;
}
//取出下标最大的目标值
int end(vector<int>& nums, int target) {
int L = 0 , R = nums.size() - 1, ans = -1;
while(R >= L){
int mid = L + (R - L)/2;
//一开始因为<所以移动,后来因为=移动找到最右边符合的值
if(nums[mid] <= target){
ans = mid;
L = mid +1;
}else{
R = mid - 1;
}
}
if(ans == -1 || nums[ans] != target) return -1;
return ans;
}
};
4.4 旋转数组查找数字
[33. 搜索旋转排序数组]
整数数组
nums
按升序排列,数组中的值 互不相同 。在传递给函数之前,
nums
在预先未知的某个下标k
(0 <= k < nums.length
)上进行了 旋转,例如,[0,1,2,4,5,6,7]
在下标3
处经旋转后可能变为[4,5,6,7,0,1,2]
。给你 旋转后 的数组
nums
和一个整数target
,如果nums
中存在这个目标值target
,则返回它的下标,否则返回-1
。你必须设计一个时间复杂度为
O(log n)
的算法解决此问题。
示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
题解
先通过第一个元素的值和中间值比较,若小于中间值,则前面有序,反之后面有序.然后判断元素是否在有序一侧,反复循环缩小范围,
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (right >= left) {
int mid = left + (right - left) / 2;
if (nums[mid] == target)return mid;
//[1,2,3,4,5] 但左指针和右指针分别指向1,2 查找元素2 在if有=的情况下,执行 右边界有序的else
//在第一个没有=的情况下,执行 左边界有序else 则right = -1越界,出错
//所以在两个数字的情况下,若要分成两部分则,第一个数一部分,第二个数一部分
//所以if一个有=
if (nums[mid] >= nums[left]) { //= 是为了接受两个元素情况下的中间元素
//在左边有序的情况下,判断元素是否在左半侧
if (target < nums[mid] && target >= nums[left]) {
right = mid - 1;
}
else {
left = mid + 1;
}
}
else {
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
}
else {
right = mid - 1;
}
}
}
return -1;
}
};