文章目录
二分查找
二分查找,也被称为二分法或折半查找,每次查找时通过将待查找区间分成两部分并只取一部分继续查找,可大大降低查找复杂度。如:
对于一个长度为O(n)
的数组,二分查找的时间复杂度为O(log n)
。
如何定义二分查找时区间的左右端开闭性?书中提供了两点:一是尝试熟练应用一种写法,如左闭右开(满足C++、Python等语言习惯)或左闭右闭(便于处理边界条件);二是思考如果最后区间只剩下一个数或者两个数,自己的写法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。
更加具体的解题方法参考力扣题解:
模板一:
当我们将区间[l, r]
划分成[l, mid]
和[mid + 1, r]
时,其更新操作是r = mid
或者l = mid + 1
,计算mid
时不需要加1
,即mid = (l + r)/2
,为防止l + r
溢出可以写作mid = l + (r-l)/2
。
代码模板:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = (l + r)/2;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
模板2:
当我们将区间[l, r]
划分成[l, mid-1]
和[mid, r]
时,其更新操作是r = mid-1
或者l = mid
,为了防止死循环,计算mid
时需要加1
,即mid = (l + r+1)/2
。
代码模板:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = ( l + r + 1 ) /2;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
问题1:为什么模板1和模板2的mid
取值不同?
对于第二个模板,当我们更新区间时,如果左边界l
更新为l = mid
,此时mid
的取值就应为mid = (l + r + 1)/ 2
。因为当右边界r = l + 1
时,此时mid = (l + l + 1)/2
,下取整,mid
仍为l
,左边界再次更新为l = mid = l
,相当于没有变化,while
循环就会陷入死循环。因此,我们总结出来一个小技巧,当左边界要更新为l = mid
时,我们就令 mid =(l + r + 1)/2
,上取整,此时就不会因为r
取特殊值r = l + 1
而陷入死循环了。
问题2:为什么模板要取while( l < r)
,而不是while( l <= r)
?
本质上取l < r
和l <= r
是没有任何区别的,只是习惯问题,如果取l <= r
,只需要修改对应的更新区间即可。
问题3:while
循环结束条件是l >= r
,但为什么二分结束时我们优先取r
而不是l
?
二分的while
循环的结束条件是l >= r
,所以在循环结束时l
有可能会大于r
,此时就可能导致越界,因此,基本上二分问题优先取r
都不会翻车。
求开方
69.x 的平方根
给你一个非负整数x
,计算并返回x
的算术平方根
。
由于返回类型是整数,结果只保留整数部分
,小数部分将被舍去
。
注意:不允许使用任何内置指数函数和算符,例如 pow(x, 0.5)
或者 x ** 0.5
。
示例 1:
输入:x = 4
输出:2
示例 2:
示例 2:
输入:x = 8
输出:2
解释:8 的算术平方根是 2.82842…, 由于返回类型是整数,小数部分将被舍去。
思路:
二分查找法:可以将这道题转换为:给定一个非负整数a
,求f(x) = x^2 - a = 0
的解。已知x >= 0
,所以函数在定义域单调递增。考虑到f(0) = -a <= 0
,f(a) = a^2 - a >= 0
,所以在[0,a]
区间上使用二分法找到f(x) = 0
的解。需要注意a = 0
的情况需要单独讨论。
牛顿迭代法:摘自百度百科
此处f(x) = x^2 - a = 0
,有
x
n
+
1
=
(
x
n
+
a
/
x
n
)
/
2
x_{n+1} = (x_n + a / x_n)/2
xn+1=(xn+a/xn)/2。
代码1-常规解法:
class Solution {
public:
long int mySqrt(long int x) {
if (x == 0) return 0;
else if (x == 1) return 1;
long int i;//防止溢出
for (i = 1;i < x;i++){
if (i * i <= x) continue;
else break;
}
return i - 1;
}
};
代码2-二分查找法:
class Solution {
public:
int mySqrt(int x) {
if(x == 0) return x;
int l = 1,r = x,mid,sqrt;
while(l <= r){
mid = l + (r - l)/2;//详见注释
sqrt = x / mid;
if(sqrt == mid){
return mid;
}
else if(sqrt > mid){//如果mid大于sqrt,则x/mid>mid,x-mid^2>0,证明sqrt应该在mid右边区间重新二分查找
l = mid + 1;
}
else r = mid - 1;
}
return r;//如果到最后还是没有sqrt == mid的情况,此时返回r即可
}
};
注释:mid = (l + r)/2 = (2l + r - l)/2 = l + (r - l)/2
,这样做的目的是防止l + r
过大而溢出。
代码3-牛顿迭代法:
class Solution {
public:
int mySqrt(int x) {
long int sqrt = x;
while (sqrt * sqrt > x){
sqrt = (sqrt + x / sqrt)/2;
}
return sqrt;
}
};
查找区间
34. 在排序数组中查找元素的第一个和最后一个位置
给你一个按照非递减顺序排列的整数数组nums
,和一个目标值target
。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值target
,返回[-1, -1]
。
你必须设计并实现时间复杂度为O(log n)
的算法解决此问题。
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
示例 2:
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
示例 3:
输入:nums = [], target = 0
输出:[-1,-1]
思路:
以nums = [5,7,7,8,8,10], target = 8
为例进行分析:
一、第一次二分查找target
第一次出现的位置:
- 二分的范围:
l = 0
,r = nums.size() - 1
,需要去二分查找>= target
的最左边界; - 当
nums[mid] >= target
时,往左半区域找,r = mid
; - 当
nums[mid] < target
时,往右半区域找,l = mid + 1
; - 如果最后
nums[r] != target
,说明数组中不存在目标值target
,返回[-1,-1]
,否则得到target
第一次出现的位置L
。
二、第二次二分查找target
最后一次出现的位置:
- 二分的范围:
l = 0
,r = nums.size() - 1
,需要去二分查找<= target
的最右边界; - 当
nums[mid] <= target
时,往右半区域找,l = mid
; - 当
nums[mid] > target
时,往左半区域找,r = mid - 1
; - 得到
target
最后一次出现的位置R
。
代码1-不完全二分法但是是自己独立思考出来的:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
//必须设计并实现时间复杂度为 O(log n) 的算法解决此问题:使用二分查找实现要求复杂度
//第一个出现的位置和最后出现的位置 参考763
//数组越界问题需要考虑
vector<int> ans(2,-1);
if(nums.size() == 0) return ans;
int l = 0,r = nums.size()-1,mid;
while(l <= r){
mid = l + (r - l)/2;
if(nums[mid] == target){//证明需要往前往后找
l = mid;
r = mid;
while(l != 0 && nums[l - 1] == target){//这里就不是二分了 属于一个一个查找
l--;
}
while(r != nums.size() - 1 && nums[r + 1] == target){
r++;
}
ans[0] = l;
ans[1] = r;
return ans;
}
else if(nums[mid] < target){//证明需要往后找
l = mid + 1;
}
else{//证明需要往前找
r = mid - 1;
}
}
return ans;
}
};
代码2-二分查找法:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
if (nums.size() == 0) return {-1,-1};
int l = 0, r = nums.size() - 1, L, R;
//第一次查找 找大于等于目标值的最左边界
while(l < r){
int mid = (l + r) / 2;
if(nums[mid] >= target) r = mid;
else l = mid + 1;
}
if(nums[r] != target) return{-1,-1};
//第二次查找 找小于等于目标值的最右边界
L = r;
l = 0, r = nums.size() - 1;
while(l < r){
int mid = (l + r + 1) / 2;
if(nums[mid] <= target) l = mid;
else r = mid - 1;
}
R = r;
return {L,R};
}
};
**补充知识:std::lower_bound()
和std::upper_bound()
**
这道题可以看作是自己实现C++ 里的lower_bound 和upper_bound 函数。
std::lower_bound()
是在区间内找到第一个大于等于 value
的值的位置并返回,如果没找到就返回 end()
位置;
std::upper_bound()
是找到第一个大于 value
值的位置并返回,如果找不到同样返回 end()
位置。
代码:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
vector<int> ans = {-1, -1};
//查找第一个大于或等于target的元素的迭代器
auto it_begin = lower_bound(nums.begin(), nums.end(), target);
//如果找到且等于target
if(it_begin != nums.end() && *it_begin == target)
ans[0] = it_begin - nums.begin();
//查找第一个大于target的元素的迭代器
auto it_end = upper_bound(nums.begin(), nums.end(), target);
//当nums只有一个元素时且大于或等于target时,it_end肯定会指向nums.end()
// 小于时,nums.begin() == it_end,此时返回-1
if(it_end != nums.begin() && *(it_end - 1) == target)
ans[1] = it_end - nums.begin() - 1;
return ans;
}
};
旋转数组查找数字
81.搜索旋转排序数组 II
已知存在一个按非降序排列的整数数组nums
,数组中的值不必互不相同。
在传递给函数之前,nums
在预先未知的某个下标 k
(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
(下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7]
在下标 5
处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4]
。
给你 旋转后 的数组 nums
和一个整数 target
,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums
中存在这个目标值 target
,则返回 true
,否则返回 false
。
你必须尽可能减少整个操作步骤。
示例 1:
输入:nums = [2,5,6,0,0,1,2], target = 0
输出:true
示例 2:
输入:nums = [2,5,6,0,0,1,2], target = 3
输出:false
思路:
对于当前的中点,如果它指向的值小于等于右端,那么说明右区间是排好序的;反之,那么说明左区间是排好序的。
如果目标值位于排好序的区间内,我们可以对这个区间继续二分查找;反之,我们对于另一半区间继续二分查找。
注意,因为数组存在重复数字,如果中点和左端的数字相同,我们并不能确定是左区间全部相同,还是右区间完全相同。在这种情况下,我们可以简单地将左端点右移一位,然后继续进行二分查找。
图解:
代码:
class Solution {
public:
bool search(vector<int>& nums, int target) {
int l = 0, r = nums.size() - 1;
while (l <= r){
int mid = l + (r - l) / 2;
if (nums[mid] == target){
return true;
}
if (nums[l] == nums[mid]){ //必须最先判断这个条件
++l;
}
else if (nums[mid] <= nums[r]){//右区间升序
if(target > nums[mid] && target <= nums[r]){
l = mid + 1;
}
else r = mid - 1;
}
else{//左区间升序
if (target >= nums[l] && target < nums[mid]){
r = mid - 1;
}
else l = mid + 1;
}
}
return false;
}
};
练习
154.寻找旋转排序数组中的最小值 II
已知一个长度为 n
的数组,预先按照升序排列,经由 1
到 n
次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,4,4,5,6,7]
在变化后可能得到:
若旋转 4
次,则可以得到 [4,5,6,7,0,1,4]
若旋转 7
次,则可以得到 [0,1,4,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]]
旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]
。
给你一个可能存在 重复 元素值的数组 nums
,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素
。
你必须尽可能减少整个过程的操作步骤。
示例 1:
输入:nums = [1,3,5]
输出:1
示例 2:
输入:nums = [2,2,2,0,1]
输出:0
思路:
数组nums
分成左右两个升序区间,找出数组的最小值等价于找出升序区间2
的头。
- 如果
nums[mid] == nums[r]
,那么就让--r
,相当于剔除部分重复元素; - 如果
nums[mid] < nums[r]
,证明自此往右的区间是升序区间2(的一部分),nums[mid]
可能为升序区间2的头,要再从左区间找数组最小值,r = mid
; - 否则左区间为升序区间,头不可能在左区间内,要再从右区间找升序区间2的头,
l = mid + 1
; - 最后
l == r
,return nums[r]
;
代码:
class Solution {
public:
int findMin(vector<int>& nums) {
int n = nums.size();
int l = 0, r = n - 1, mid, target;
while(l < r){
mid = l + (r - l)/2;
if(nums[mid] == nums[r]) --r;//注意和81题对比
else if(nums[mid] < nums[r]){
r = mid;
}
else{
l = mid + 1;
}
}
return nums[r];
}
};
540. 有序数组中的单一元素
给你一个仅由整数组成的有序数组,其中每个元素都会出现两次,唯有一个数只会出现一次。
请你找出并返回只出现一次的那个数。
你设计的解决方案必须满足 O(log n)
时间复杂度和 O(1)
空间复杂度。
示例 1:
输入: nums = [1,1,2,3,3,4,4,8,8]
输出: 2
示例 2:
输入: nums = [3,3,7,7,10,11,11]
输出: 10
思路:
当看到题目要求 O(log n)
时间复杂度时,我们的想法一定是采用二分查找。但如何逐步缩小范围得到我们所需要的值呢?需要分情况讨论,刚好可以使用两个示例作为不同情况。
nums = [1,1,2,3,3,4,4,8,8]
当l = 0
,r = nums.size() - 1
时,mid = (l + r) / 2 = 4
,此时mid
为偶数,证明前面已经有偶数个数的数字,如果nums[mid + 1] != nums[mid]
,证明单一元素一定在mid
之前的区间(包含mid
);否则单一元素一定在mid+1
后面的区间;nums = [3,3,7,7,10,11,11]
当l = 0
,r = nums.size() - 1
时,mid = (l + r) / 2 = 3
,此时mid
为奇数,证明前面已经有奇数个数的数字,如果nums[mid - 1] != nums[mid]
,证明单一元素一定在mid
之前的区间(包含mid
);否则单一元素一定在mid
后面的区间。- 最终
l = r
,返回nums[r]
即可。
图解:
代码:
class Solution {
public:
int singleNonDuplicate(vector<int>& nums) {
//在出现独立数之前和之后,奇偶位数的值发生了什么变化? c下标出现了小于0的-1
int n = nums.size() - 1;
int l = 0, r = n;
while(l < r){
int mid = l + (r - l) / 2;
if(mid % 2 == 0){//如果mid是偶数,其前面的数字是偶数个(事实证明我的思路应该没什么大问题,重点在于判断mid是否是偶数出了问题!!)
// /是取两数相除后的商 %是取两数相除后的余
if(nums[mid + 1] != nums[mid]){//如果mid后面一个数不等于mid,证明单一元素一定在mid之前的区间(包含mid)
r = mid;
}
else{//否则单一元素一定在mid+1后面的区间
l = mid + 2;
}
}
else{//如果mid是奇数,其前面的数字是奇数个
if(nums[mid - 1] != nums[mid]){//如果mid前面一个数不等于mid,证明单一元素一定在mid之前的区间(包含mid)
r = mid;
}
else{//否则单一元素一定在mid后面的区间
l = mid + 1;;
}
}
}
return nums[r];
}
};
4. 寻找两个正序数组的中位数
给定两个大小分别为 m
和 n
的正序(从小到大)数组 nums1
和 nums2
。请你找出并返回这两个正序数组的 中位数
。
算法的时间复杂度应该为 O(log (m+n))
。
示例 1:
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2
示例 2:
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
思路:
首先想到的一定是暴力解法,合并两个数组,排序,找出中位数,若采用冒泡排序的方法,则时间复杂度可能达到O(m + n) ^ 2
;接着会想到双指针解法,两个指针分别指向两个数组,直到某一指针指向中位数所在的位置,但是这样的复杂度是O(m + n)
;一般看到log
的复杂度最好选用二分查找法,看了力扣好多题解,头都晕了,最后参考题解进行分析加举例帮助理解叭!
中位数:奇数长度有序数组的中位数为最中间的数字,偶数长度有序数组的中位数是最中间两个数字的平均值。假设两个有序数组的长度分别为m
和n
,由于两个数组长度之和m + n
的奇偶不确定,因此需要分情况来讨论。
此处作者使用了一个小trick
来避免讨论奇偶:无论m+n
是奇数或者偶数,只需分别找第 (m + n + 1) / 2
个数和第(m + n + 2) / 2
个数,求其平均值即可。
- 假如
m + n= 7
,为奇数,第4个数为数组的中位数,此时(m + n + 1) / 2 = 4
,(m + n + 2) / 2 = 4
,指向的值都相等,平均数仍是其本身; - 假如
m + n= 8
,为偶数,第4个数和第5个数的平均数为数组的中位数,此时(m + n + 1) / 2 = 4
,(m + n + 2) / 2 = 5
,平均数为数组中位数。
那么接下来重点就变成如何在两个有序数组中找到第k
个(第k
小的)元素。
首先,为了避免产生新的数组从而增加时间复杂度,我们使用两个变量 i
和 j
分别来标记数组nums1
和nums2
的起始位置。
-
递归出口:
当k=1
时候,相当于求最小值,我们只要比较nums1
和nums2
的起始位置i
和j
上的数字就可以了。 -
一般情况:
取两个数组中的第k / 2
个元素(midVal1
和midVal2
)进行比较,如果midVal1 < midVal2
,则说明nums1
中的前k / 2
个元素不可能成为第k
个元素的候选,所以将nums1
中的前k / 2
个元素去掉,作为新数组和nums2
求第k - k / 2
小的元素,因为我们把前k / 2
个元素去掉了,所以相应的k
值也应该减少k / 2
。midVal1 > midVal2
的情况亦然。
举例说明:
假设有两数组:nums1 = [1,3,5,7]
,nums2 = [2,4,6,8,10]
,需要找到第5小的元素,即k = 5
。
nums1
第k / 2 = 2
小的元素是midVal1 = 3
,nums2
第k / 2 = 2
小的元素是midVal2 = 4
,midVal1 < midVal2
,则nums1中的前k / 2个元素<=midVal1<midVal2
,无论如何nums1
的前k / 2
个元素都在最前面,不可能是第k
个。在本例中也就是1
不可能是第5小的元素,需要剔除,相应的k
值也应变为k - k / 2
,等于3。 -
边界问题:
当某一个数组的起始位置大于等于其数组长度时,说明其所有数字均已经被淘汰了,相当于一个空数组了,那么实际上就变成了在另一个数组中找数字,直接就可以找出来了。
由于两个数组的长度不定,所以有可能某个数组元素数不足k / 2
,所以我们需要先检查一下,数组中到底存不存在第k / 2
个数字,如果存在就取出来,否则就赋值上一个整型最大值,这样肯定会大于另一个数组的第k / 2
个元素,从而把另一个数组的前k / 2
个元素淘汰。
ps:赋予整型最大值的意思只是说如果第一个数组的k / 2
不存在,则说明这个数组的长度小于k / 2
,那么另外一个数组的前k / 2
个我们是肯定不要的。例如,nums1
长度是2,nums2
长度是12,则k
为7,k / 2
为3,因为nums1
长度小于3,则无法判断中位数是否在其中,而nums2
的前3个数中一定没有中位数!
图解:
代码:
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2)
{
int m = nums1.size();
int n = nums2.size();
//中位数 = (left + right)/2
int left = (m + n + 1) / 2;
int right = (m + n + 2) / 2;
return (findKth(nums1, 0, nums2, 0, left) + findKth(nums1, 0, nums2, 0, right)) / 2.0;
}
//在两个有序数组中找到第k个元素(例如找第一个元素,k=1,即nums[0])
//i: nums1的起始位置 j: nums2的起始位置(i,j都是从0开始)
int findKth(vector<int>& nums1, int i, vector<int>& nums2, int j, int k)
{
//若nums1为空(或是说其中数字全被淘汰了)
//在nums2中找第k个元素,此时nums2起始位置是j,所以是j+k-1
if(i >= nums1.size()) return nums2[j + k - 1];
//nums2同理
if(j >= nums2.size()) return nums1[i + k - 1];
//递归出口
if(k == 1) return std::min(nums1[i], nums2[j]);
//这两个数组的第K/2小的数字,若不足k/2个数字则赋值整型最大值,以便淘汰另一数组的前k/2个数字
int midVal1 = (i + k/2 - 1 < nums1.size()) ? nums1[i + k/2 - 1] : INT_MAX;
int midVal2 = (j + k/2 - 1 < nums2.size()) ? nums2[j + k/2 - 1] : INT_MAX;
//二分法核心部分
if(midVal1 < midVal2)
return findKth(nums1, i + k/2, nums2, j, k - k/2);//如果midVal1 < midVal2,则舍弃nums1的前k/2个元素
else
return findKth(nums1, i, nums2, j + k/2, k - k/2);//如果midVal1 >= midVal2,则舍弃nums2的前k/2个元素
}
};