【Algorithm】二分查找算法
本篇文章主要介绍二分查找算法
1 二分查找算法简介
1) 二分查找算法的概念
所谓的二分查找算法其实就是在一个数组中,通过一个点将数组分为两部分,然后在其中一部分里面查找结果的算法就叫做二分查找算法。这样讲解可能比较抽象,接下来我们通过一道题目来理解什么是二分查找算法:
二分查找
链接:704. 二分查找 - 力扣(LeetCode)
题目描述:
给定一个
n
个元素有序的(升序)整型数组nums
和一个目标值target
,写一个函数搜索nums
中的target
,如果target
存在返回下标,否则返回-1
。你必须编写一个具有
O(log n)
时间复杂度的算法。
示例 1:输入:nums
= [-1,0,3,5,9,12],target
= 9 输出: 4 解释: 9 出现在nums
中并且下标为 4示例 2:
输入:nums
= [-1,0,3,5,9,12],target
= 2 输出: -1 解释: 2 不存在nums
中因此返回 -1提示:
- 你可以假设
nums
中的所有元素是不重复的。n
将在[1, 10000]
之间。nums
的每个元素都将在[-9999, 9999]
之间。
题目解析:
这道题目比较好理解,题目中会给你一个数组 nums 与一个目标值 target,数组中的元素都是升序的,题目的要求是让你在 nums 中寻找一个数,如果该数等于 target,那就返回这个数字的下标,如果找不到就返回 -1。
算法讲解:
我们先来想一下暴力解法,暴力解法很好想,就是遍历一遍数组,如果 nums[i] == target,那就返回 i,数组遍历完后,如果还没有返回,那就返回 -1。显然暴力解法的时间复杂度为 O(n)。
那么怎么优化呢?题目中有一个条件是数组中的元素都是有序的,所以我们可以利用这个条件来进行优化。首先我们先选取任意一个位置 i,如果 nums[i] == target,那我们直接就返回 i 了;如果 nums[i] < target,由于数组是有序的,所以 i 位置以及 i 位置左边的元素都会小于 target,所以结果只可能存在于 i 右半部分,此时我们发现该问题可以通过 i 这个点将数组分为两部分,并且我们可以通过条件排除掉一部分,这时候我们就可以二分算法解决问题了。
首先我们定义 left = 0, right = nums.size() - 1,我们取中间点作为分割点(取 1/3 或者 1/4 点都是可以的,只不过取中间点效率更高),也就是 mid = left + (right - left)/2,如果 nums[mid] > target,说明结果只存在于 mid 的左半部分,所以让 right = mid - 1;如果 nums[mid] < target,说明结果只存在于 mid 的右半部分,所以让 left = mid + 1;如果 nums[mid] == target,直接返回 mid 就可以了。那么停止条件是什么呢,在我们查找的过程中,我们发现如果 left > right 了,其实所有的数字都已经检测过了,这时候肯定要停止,那么如果 left == right 呢?其实此时也是需要进入循环的,因为 left == right 的上一步要么是 nums[mid] > target, 然后 right = mid - 1,此时 left == right,或者是 nums[mid] < target,然后 left = mid + 1,此时 left == right,但是 nums[left] 并没有检测过,万一这个数字是正确结果,我们就遗漏了这种情况,所以当 left <= right 时,我们就进入循环。
代码:
class Solution
{
public:int search(vector<int>& nums, int target) {int left = 0, right = nums.size() - 1;while (left <= right){int mid = left + (right - left) / 2;if (nums[mid] > target) right = mid - 1;else if (nums[mid] < target) left = mid + 1;else return mid;}return -1;}
};
我们来分析一下其时间复杂度,当执行次数为 1 次时,数组的长度变为 n/2,当执行次数为 2 次时,数组的长度为 n/4,也就是 n/2^2,当执行次数为 3 次时,数组长度变为了 n/8,也就是 n/2^3,以此类推,当执行次数为 x 次时,数组长度就是 n/2^x,而停止条件是数组长度为1,所以 n/2^x = 1 ==> 2^x = n ==> x = ,所以时间复杂度 T(n) = O(logn)。
通过这道题目,我们可以了解到什么是二分查找算法,就是当数组具有二段性,可以将数组分为两部分,我们可以根据条件排除掉两部分中的一部分,只在剩下的那部分中寻找答案时,这个就叫做二分查找算法(这也是二分查找算法的条件)。所以应用二分查找算法并不一定要数组中的元素有序,只要具有二段性,我们就可以采用二分查找算法。
2) 二分查找算法模板
当我们确定一个算法能够使用二分查找算法时,这个算法的代码编写一般都是具有套路的,也就是二分查找算法的模板,但我们使用二分查找算法时,我们就可以套用模板编写代码:
while (left <= right)
{int mid = left + (right - left) / 2; if (...) right = mid - 1;else if (...) left = mid + 1;else ...
}
使用模板时,需要注意循环条件为 left <= right,...是根据题目判断出的数组二段性的条件。
2 左边界与右边界的二分查找
1) 左边界与右边界的二分查找简介
上面的那个二分查找算法模板,其中求中点的方式为 mid = left + (right - left) / 2,其实还有一种求中点的方式为 mid = left + (right - left + 1) / 2,这两种求中点的方式对于奇数个元素是没有区别的,比如 nums 中共有 5 个元素,最左边和最右边元素的下标分别是 0 和 4,此时 left = 0,right = 4,第一种 mid = 0 + (4 - 0) / 2 = 2,第二种 mid = 0 + (4 - 0 + 1) / 2 = 2(C\C++ / 运算符两边都是整数求出来为整数),但是对于偶数个元素就不一样了,比如共有 4 个元素,此时 left = 0, right = 3,第一种 mid = 0 + (3 - 0) / 2 = 1,第二种 mid = 0 + (3 - 0 + 1) / 2 = 2,所以采用第一种方法求出来的中点为左边的中点,第二种还方法求出来的中点为右边的中点。对于两种求中点的方法,其实二分查找的策略也是不同的,接下来我们通过一道题目来进行讲解。
在排序数字中查找元素的第一个和最后一个位置
链接:34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
题目描述:
给你一个按照非递减顺序排列的整数数组
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]提示:
0 <= nums.length <= 105
-109 <= nums[i] <= 109
nums
是一个非递减数组-109 <= target <= 109
题目解析:
题目中会给你一个 nums 数组和一个目标值 target,nums 中的元素是以非递减顺序排列的,比如 nums = [1, 1, 2, 3, 4, 5, 5, 6, 6, 6]。题目要求你在 nums 数组中找到第一个与最后一个跟 target 相同的元素,并以一个 vector<int> 的形式返回这两个元素的下标,不存在就返回 [-1, -1]。
算法讲解:
这道题目与上面的那道二分查找的题目很像,只不过那一道题目中的元素是单调递增的,这道题目中 nums 元素是单调不减的,我们可以向上一道题一样先对 nums 中的元素进行分组。这道题目既要查找左边界,又要查找右边界,所以我们先讲解如何查找左边界。
查找左边界:
在查找左边界时,我们需要找到这一道题目的二段性,因为 nums 中的元素是单调不减的,所以与 target 相同的元素会有很多,我们不能简单的分成 nums[i] < target、nums[i] > target 或者 nums[i] == target,如果等于 target 直接返回,那就不一定是正确答案,比如 nums = [1, 2, 2, 2, 3]。所以我们需要进行另一种二段性的划分,在查找左边界时,我们可以利用 nums[i] < target 与 nums[i] >= target 来划分,因为要找到左边界,所以我们就需要找到 nums[i] >= target 区间内的第一个数字,此时我们采用的二分策略是:
(1)nums[mid] < target,说明左边界不可能在 mid 及 mid 左边的元素里,所以此时我们让 left = mid + 1
(2) nums[mid] >= target,这时候我们不能让 right = mid - 1,因为 mid 可能就是左边界,如果是左边界,那么 right = mid - 1 之后,剩下的区间里就没有正确答案了,所以此时我们让 right = mid
采用上面的二分查找策略之后,我们需要思考两个问题,那就是查找的停止条件和找寻中点的方式是什么?停止条件其实就两种,一种是 left < right,一种是 left <= right,如果是 left <= right,是会出现死循环的,因为会存在这么一种情况:在二分查找进行了一段时间之后,left、mid、right 分别是 [2, 2, 3],target = 3,nums[mid] < 3,所以 left = mid + 1,此时 left == right == mid,所以如果是 left <= right,会再次进入循环,此时 nums[mid] >= target,right = mid,这样一直重复下去,就死循环了,所以循环条件只能是 left < right。其实当 left == right 的时候,这时候就已经是答案了,因为 right 一直位于合法区间内,left 一直位于不合法区间,而且 left 是会极力的跳出不合法区间,所以一旦他们两个相遇,就必定是结果。
那么找寻中点的方式是选择左边还是右边的中点呢?其实查找左边界应该选择左边的中点,因为选择右边的中点依然是会死循环的:存在这么一种情况,当二分查找进行了一段时间之后,left 与 right 相邻,并且 nums[right] >= target,如果选择右边中点,那么 mid == right,此时 nums[mid] >= target,right = mid,这样就陷入了死循环,所以我们应该选择左边中点。
查找右边界:
有了查找左边界的方法,查找右边界也类似,只是二分查找策略需要调整一下:
(1) nums[mid] > target,那么 mid 及 mid 右边的元素就不会是结果,right = mid - 1
(2) nums[mid] <= target,此时 mid 可能是正确结果,所以 left = mid
那么循环条件与找中点方式呢?循环条件依旧是 left < right,因为 left <= right 依旧会像左边界一样进入死循环,例子为 [2, 3, 3],分别是 left, mid, right,target = 2。查找中点的方式应该是找右边中点,如果查找左边中点,当 left 与 right 相邻且 nums[left] <= target 时,此时就会进入死循环。
代码:
class Solution
{
public:vector<int> searchRange(vector<int>& nums, int target) {vector<int> v = {-1, -1};//特殊处理if (nums.size() == 0) return v;//查找左端点int left = 0, right = nums.size() - 1;while (left < right){//中点为左边中点int mid = left + (right - left) / 2;if (nums[mid] < target) left = mid + 1;else right = mid;}if (nums[left] == target) v[0] = left;//查找右端点left = 0, right = nums.size() - 1;while (left < right){//中点为右边中点int mid = left + (right - left + 1) / 2;if (nums[mid] <= target) left = mid;else right = mid - 1;}if (nums[left] == target) v[1] = left;return v;}
};
通过这道题目,我们发现其实是采用左边中点还是右边中点非常重要,因为会涉及到死循环的问题,所以我们接下来就来总结模板来帮助记忆。
2) 模板
//寻找左边界
while (left < right)
{//一定是求左边中点int mid = left + (right - left) / 2;if (...) left = mid + 1;else right = mid;
}//寻找右边界
while (left < right)
{//一定是求右边中点int mid = left + (right - left + 1) / 2;if (...) left = mid;else right = mid - 1;
}
在上面的模板中,最重要的就是判断条件和求中点的方式,下面的 left 和 right 如何变化,只需根据题目分析即可。
3 总结
在学习二分查找算法的过程中,不能只关注于模板,一定要理解背后的原理,如果只背模板有时候不会分析还是很容易写出死循环代码,所以大家一定要深刻理解二分算法背后的原理以及什么情况下要使用左边中点,什么时候使用右边中点,一定要搞懂。