目录
概述
在一个有序数组中找到第一个大于k的数,这该怎么做?
我们当然可以进行暴力枚举,但是这显然不是最优的做法。
既然这是一个有序数组,我们能不能跳过一些无意义的垃圾区域呢?
今天我们来谈:二分查找。
思路
二分查找的第一个问题就是红蓝染色问题。
在一个有序递增数组中找到第一个大于等于target的数:
将小于target的数都标记为蓝色,大于等于target的标记为红色。那么我们要寻找的就是红蓝边界处的右侧首元素。
LeetCode 35:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为
O(log n)
的算法。示例 1:
输入: nums = [1,3,5,6], target = 5 输出: 2
先来看这段代码:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int l=-1,r=nums.size();
while(l+1!=r){
int m=(l+r)/2;
if(nums[m]<target)l=m;
else r=m;
}
return r;
}
};
我们定义了左右指针l和r,他们起初各自指向数组的-1位置和n位置,也就是前后的两个无效位置。
随后每次都取中间值m:
如果中间值小于target,就转入m右侧区域[m,r]寻找。
如果中间值大于等于target,就转入m左侧区域[l,m]寻找。
这一切的根源是因为数组是有序的。如果l到m都是蓝色的,那[l,m]内一定没有我们需要的红蓝边界。
这也叫折半查找:每次都将数组对半分开,在分开处判断其颜色,然后转入我们需要的区域继续寻找。
这样做的本质其实就是选择性的跳过大量的垃圾区域,你会注意到其时间复杂度是logn级别的。
二分查找模版
红蓝染色模版就是最基本的二分查找模版。
你会见到各种不同的模版,但是我想提供最清晰明了的一种:
int binary_serach(vector<int>& nums, int target) {
const int n=nums.size();
int l=-1,r=n;
while(l+1!=r){
int m=(l+r)/2;
if(is_blue(m))l=m;
else r=m;
}
return r;//或return l,这取决于你的目的
}
我们来看几个细节:
①int l=-1,r=n;
这样使得我们的m有机会取到数组中的每一个位置,包括0和n-1。
②while(l+1!=r)
有些模版将终止条件设为l>r,但是明显不够直观:我们应该严格限制l始终在r的左侧,终止时l与r恰好挨在一起。
③if(is_blue(m))l=m;else r=m;
注意到:我们总将蓝色m赋值给l,红色m赋值给r。结合上一条你会发现:当循环结束时,l与r挨在一起,且各分属红蓝两区,即l与r之间即为红蓝边界,我们只需要返回我们需要的位置即可。
此外,你会注意到:当数组全为红时,l总是-1,;当数组全为蓝时,r总是n;
核心概念:二段性
二段性分析
在基本的红蓝染色法之中我们发现:只需要is_blue(m)总是在数组的左侧区域返回true值就将数组这部分判断为蓝色。
[0,blue]是一段蓝色区域,[red,n-1]是一段红色区域,且blue+1==red,至于红和蓝的判定规则,可以不仅仅是对target比大小这么简单。
LeetCode 33:
整数数组
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,5,6,7]
在下标3
处经旋转后可能变为[4,5,6,7,0,1,2]
。给你 旋转后 的数组
nums
和一个整数target
,如果nums
中存在这个目标值target
,则返回它的下标,否则返回-1
。示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0 输出:4示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3 输出:-1
Code
class Solution {
public:
int search(vector<int>& nums, int target) {
int l=-1,r=nums.size();
auto to_left=[&](int m)->bool{
int x=nums[m],end=nums.back();
if(x>end)return target<=x&&target>end;
return target>end||target<=x;
};
while(l+1!=r){
int m=(l+r)/2;
if(to_left(m))r=m;
else l=m;
}
return nums[r]==target?r:-1;
}
};
to_left内部的规则是:{
如果:中间位置的值大于数组末尾(即第二段有序区的末尾),那么可以断定此处为第一段有序区,此时如果target也在第一段有序区(target也大于数组末尾)且target<=nums[m]
或:中间位置的值小于等于数组末尾,那么可以断定此处为第二段有序区,如果target在第一段有序区或target<=nums[m]
这两种情况都应该转入左侧寻找。
}
非有序数组的二分查找看起来很诡异,但这透露出二分查找的本质:二段性。
在本题中,蓝色区域总满足大于末尾元素且小于target,红色区域总满足小于等于末尾元素或大于等于target。
在上文的二分查找基本模版中,二段性分析是简单的if(nums[m]<target),而基于这道题,数组有两段有序区,但归根结底还是可以基于二段性分为一段blue区和一段red区。
我们鲜明的认识到这样一个二段性结论:
所谓二段性,就是数组一定有一段左侧区域严格满足某一性质(即is_blue返回true),右侧严格满足另一性质,这两种性质必须是严格互斥的,这两段中间即为二段性边界。
恢复二段性
请牢记上述的二段性结论,我们来看这道题:
LeetCode 81:
已知存在一个按非降序排列的整数数组
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
此题与上一题唯一的不同就是数字是重复出现的了,这导致了什么呢?
其实,它几乎与上题没什么区别,除了有些数据破坏了二段性而已。
如你所见,[2,5,6,0,0,1,2]就破坏了二段性:
[2,5,6],满足的是大于等于2,[0,0,1,2]满足的是小于等于2,而:二段性就是数组一定有一段左侧区域严格满足某一性质(即is_blue返回true),右侧严格满足另一性质,这两种性质必须是严格互斥的。
大于等于2和小于等于2并不是严格互斥的,所以本题的一些数据会卡掉上一题的二分查找代码,那怎么解决呢?恢复二段性不就好了。
从数组首尾同时删数,一直到首尾元素不同,我们就实现了两段数字的性质严格互斥。
Code
class Solution {
public:
bool search(vector<int>& nums, int target) {
while(!nums.empty()&&nums.front()==nums.back()){
if(target==nums.back())return true;
else nums.pop_back();
}
if(nums.empty())return false;
auto is_blue=[&](int m)->bool{
if(nums[m]>nums.back())return target>nums.back()&&target<=nums[m];
else return target<=nums[m]||target>nums.back();
};
int l=-1,r=nums.size();
while(l+1!=r){
int m=(l+r)/2;
if(is_blue(m))r=m;
else l=m;
}
return nums[r]==target;
}
};
复杂度
时间复杂度:O(logn)
空间复杂度:O(1)
总结
二分查找的本质就是利用数组的二段性进行快速的分析,希望你理解了我们的二段性分析和恢复二段性的思想。