1 二分查找概念
二分查找也称折半查找,是一种在有序数组中查找某一特定元素的搜索算法。我们可以从定义可知,运用二分搜索的前提是数组必须是有序的,这里需要注意的是,我们的输入不一定是数组,也可以是数组中某一区间的起始位置和终止位置。
二分查找可以优化代码的时间复杂度,在面试过程中比较常见,更能锻炼逻辑思维能力。在追求代码极致性能时,二分查找比普通遍历时间复杂度要小。
2 基础二分例题
2.1 简单的二分查找,从数组nums中查找指定的target,若查到返回其索引,查不到返回-1。
int FindIndexOfTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] == target){
return mid;
}
if (nums[mid] < target){ // target在右半区间或者不存在
left = mid + 1;
}
if (nums[mid] > target){ // target在左半区间或者不存在
right = mid - 1;
}
}
return -1;
}
以上代码有3个注意点
(1)计算 mid 时 ,不能使用 (left + right )/ 2,否则有可能会导致溢出;
(2)while (left < = right) { } 注意括号内为 left <= right ,而不是 left < right ,如果我们设置条件为 left < right 则当我们执行到最后一步时,则我们的 left 和 right 重叠时,则会跳出循环,返回 -1,区间内不存在该元素,但 left 和 right 此时指向的可能就是目标元素 ;
(3)left = mid + 1,right = mid - 1 而不是 left = mid 和 right = mid。否则可能会进入死循环。
2.2 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。假设数组中无重复元素。
思路:如果在数组中找到目标值,返回mid即可,十分容易理解;
如果在数组中找达不到目标值,那么最后一次循环left==right,如果nums[left]==nums[right]==nums[mid]<target,那么执行left=mid+1(其实就是left++),由于left>right跳出循环,可以发现此时left就是应该插入的位置;如果nums[left]==nums[right]==nums[mid]>target,那么执行right=mid-1(其实就是right--),由于left>right跳出循环,此时的left也是target应该插入的位置。
int FindIndexOfTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] == target){
return mid;
}
if (nums[mid] < target){ // target在右半区间或者不存在
left = mid + 1;
}
if (nums[mid] > target){ // target在左半区间或者不存在
right = mid - 1;
}
}
return left;
}
2.3 在排序数组中查找元素的第一个索引位置,例如数组为[1,2,2,2,2,3,6,55],查找2的首个索引。(假设数组存在target)
思路:如果nums[mid]==target时,不再返回mid,而在左半区间继续查找,即right=mid-1,这样有希望找到target的首个索引位置。那么问题来了,即使定位到了首个索引,由于下一步是right=mid-1,right索引对应的数据小于target(此时right+1才是首个索引)。[left,right]区间内的值均小于target,于是在最新的[left,right]区间不断向右查找,最终left=right=mid,此时nums[left]==nums[mid]==num[right]<target,执行left=mid+1(最新的left已经指向了target的首个索引),由于left>right,跳出循环,返回left即为结果。
int FindFirstIndexOfTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] < target){ // 在左半区间查找
left = mid + 1;
}
if (nums[mid] >= target){ // 在右半区间查找
right = mid - 1;
}
}
return left;
}
问题来了,如果数组内不存在target,以上代码怎么修改呢?其实也很简单,不能再直接返回left,要判断一下nums[left]是否等于target,如果不等于就返回-1。
还有种极端情况,数组内不但不存在target,target还要大于数组内所有元素,所以循环跳出后left还在向右移位,最后left==numsSize,left已经超出了数组索引最大值(numsSize-1),结合这两点就知道什么情况下返回left了。
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] < target){ // 在左半区间查找
left = mid + 1;
}
if (nums[mid] >= target){ // 在右半区间查找
right = mid - 1;
}
}
if(left < numsSize && nums[left] == target)
{
return left;
}
return -1;
2.4 在排序数组中查找元素的最后一个索引位置,例如数组为[1,2,2,2,2,3,6,55],查找2的末位索引。(假设数组存在target)
思路:和前面分析类似,如果nums[mid]==target时,不再返回mid,而在右半区间查找,即left=mid+1,这样有希望找到target的末位索引,当mid定位到target的最后一个索引时,由于执行left=mid+1,导致[left,right]区间内的值均大于target,此时target末位索引是left-1。于是不断向左区间查询,直至left==right也没有查到,因为此时nums[left]==nums[right]==nums[mid]>target,便执行right=mid-1,此时right就是target的末位索引了,由于left>right也跳出了循环。
int FindLastIndexOfTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] <= target){ // 在右半区间查找
left = mid + 1;
}
if (nums[mid] > target){ // 在左半区间查找
right = mid - 1;
}
}
return right;
}
2.5 找出第一个大于目标元素的索引
思路:显然应该分为两种情况,一种情况是nums[mid]<=target,肯定要left=mid+1,向右区间查询;
另一种情况是nums[mid]>target,这种相对复杂,要不要返回mid??但此时mid可能不是首个大于target的索引。如果mid==left(区间内的首位),或者nums[mid-1]<=target,此时mid必然是首个大于target的索引了,返回mid即可,否则就可以继续向左查询了,执行right=mid-1即可。
int FindFirstIndexOfBigTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] <= target){ // 在右半区间查找
left = mid + 1;
}
if (nums[mid] > target){
if(mid == left || nums[mid - 1] <= target){
return mid;
}else{
right = mid - 1;
}
}
}
return -1;
}
2.6 找出最后一个小于目标元素的索引
思路:与前面类似,分两种情况,一种情况是nums[mid]>=target,肯定要在左区间继续搜寻,即right=mid-1
另外一种情况是nums[mid]<target,此时mid可能就是答案,但也能不是最后一个。当mid==right或者nums[mid+1]>=target时,mid就是区间内最后一个小于目标元素的索引,返回mid即可。否则由于mid不是最后一个,向右区间继续搜寻即可,即left=mid+1。
int FindLastIndexOfSmallTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] >= target){ // 右区间均>=target,不符合条件
right = mid - 1;
}
if (nums[mid] < target){ // 此时的mid可能是答案,但可能继续在右半区间
if(mid == right || nums[mid + 1] >= target){
return mid;
}else{
left = mid + 1;
}
}
}
return -1;
}
3 二分进阶例题
3.1 一个从小到大的有序数组经过旋转,变成不完全有序数组nums,查找target的索引。(例如[7,8,9,2,3,6])。假设数组内不含有重复元素。
思路:一次遍历很简单,但其复杂度高于二分算法,二分算法往往是解题精髓。nums数组被mid索引一分为二,其中一半肯定有序,另外一半肯定无序。有序部分再一分为二,两部分都有序;无序部分再一分为二,一部分肯定有序,一部分肯定无序...........规律便出来了
- 采用二分法实现,旋转排序数组一分为二,其中一定有一个是有序的,另一个可能是有序,也能是部分有序。
- 此时有序部分用二分法查找。
- 无序部分再一分为二,其中一个一定有序,另一个可能有序,可能无序。
- 每次缩小一半搜索区间,实现查找
(0)如果nums[mid]==target,直接返回mid即可。否则执行以下两步,不断减小搜索空间
(1)如果nums[left]<nums[mid],那么左半区间必然有序,否则右半区间必然有序。
(2)如果左半区间有序,并且nums[left] <= target && target < nums[mid],这说明target刚好在左半区间,否则就在右半区间
如果右半区间有序,并且target > nums[mid] && target <= nums[right],这说明target刚好落在右半区间,否则就在左半区间
int FindIndexOfTarget(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //写法比(left+right)/2要好,防止溢出int
if (nums[mid] == target){
return mid;
}
if (nums[mid] > nums[left]){ //左半部分有序
if (nums[left] <= target && target < nums[mid]){//target刚好落在左半部分
right = mid - 1;
}else{//target不在左半部分,必然在右半区间
left = mid + 1;
}
}
else{ //右半部分有序
if (target > nums[mid] && target <= nums[right]){//target刚好在右半部分
left = mid + 1;
}else{//target不再右半部分,必然在左半区间
right = mid - 1;
}
}
}
return -1;
}
3.2 编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:每行中的整数从左到右按升序排列。每行的第一个整数大于前一行的最后一个整数。
思路:可以完全转化为一维有序数组二分查找的思想,只是初始left为0,初始right为二维数组总元素个数减1,nums[mid]变为了nums[mid/col][mid%col]。代码如下。
bool searchMatrix(int **matrix, int matrixSize, int *matrixColSize, int target)
{
int row = matrixSize;//二维数组有多少行
int col = matrixColSize[0];//二维数组每行有多少个元素,即有多少列
int left = 0;
int right = row * col - 1;
while (left <= right)
{
int mid = left + (right - left) / 2;
int num = matrix[mid / col][mid % col];
if(num == target){
return true;
}
if (num < target){
left = mid + 1;
}
if (num > target){
right = mid - 1;
}
}
return false;
}
4 总结
二分查找在学习过程,可能会有不同模板,导致同一例题写法多种多样,不能死记硬背,二分查找核心是不断减小搜索空间,最终查到target。实现过程中要注意死循环问题和数组越界问题,意识到这两个边界问题,能减少不少麻烦。也可参考上述例题二分查找的写法,理解后能解决大部分的二分问题。