一、朴素二分查找
在之前,提到二分大家或许会觉得,二分就是"有序数组中用来查找某数据的查找方法"。
但其实这样的说法并不完全对,其实二分的应用场景很广,只要是"拥有二段性的数据序列"就能够通过二分进行查找。
那么二段性又是什么?应该如何确定一个数组是否拥有二段性?二分查找的细节问题如(当数组为偶数时,应该取偏向左侧的mid还是取偏向右侧的mid?left和right应该移动到mid?mid + 1?mid - 1?)又该如何确定?别急,我们将从最开始的朴素二分开始介绍,然后逐步的将这些问题解决掉~
📚 先让我们通过一道例题来引入二分查找的概念:
我们先引入最好想的"暴力解法",再通过"暴力解法"思考启发,优化成"二分查找"。
① 暴力枚举法:
从头到尾遍历数组,直到找到目标元素,停止遍历,返回元素下标。
时间复杂度:O(n)
空间复杂度:O(1)
这是最简单的方法,但这种简单的查找元素,使用O(n)的方法显然并不合适,于是我们可以通过这个过程思考一下优化的方法:
比如,当我们从前往后遍历这个有序数组到4时,我们可以发现,包括4在内的之前的所有元素,似乎都是无用的,因为4小于我们的目标值,也就是说4之前的元素也都一定小于这个目标值,那么我们有没有一种方法,能够快速的越过大部分不需要遍历的元素,进行跳跃式的查找呢?
② 二分查找法:
通过二分查找的方式进行查找目标元素。
时间复杂度:O(logn)
空间复杂度:O(1)
📚 那么便引出我们二分查找:
📕 设定一个数组左端left和数组右端right,求出中间值mid,判断中间值与目标值的大小
📕 如果mid对应值小于目标值,则代表mid及其左侧的元素都是多余的,那么修改left = mid + 1
📕 如果mid对应值大于目标值,则代表mid及其右侧的元素都是多余的,那么修改right = mid - 1
📕 如果mid对应值等于目标值,则返回mid;若 left > right 则查找结束,跳出循环;
这样便能修改查找的时间复杂度为O(logn)
📖 代码示例:
class Solution {
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] == target){
return mid;
}else if(nums[mid] > target){
right = mid - 1;
}else {
left = mid + 1;
}
}
return -1;
}
}
(注意:取mid时如果直接使用(left + right) / 2,会有可能造成相加后int值的溢出,所以我们可以采用left + (right - left) / 2 的方法,可以有效地避免溢出)
然而这种并不复杂的解法只能解决一些简单的问题,所以这种二分查找也被称为"朴素二分查找",想要真正的掌握解题方法,还需要我们进一步的进行学习"查找数组目标元素的左右端点"。
二、二分查找目标元素左右端点
📚 同样的,先让我们看一道例题:
我们可以看到,需要查找的目标值为8,并且需要找到数组中第一次出现的8的下标和最后一次出现的8的下标,而"朴素二分"只能帮我们找到一个8。
那么我们可以分为两种方法:查找最左侧的目标元素和查找最右侧的目标元素。
① 查找目标元素的左端点
其实还是比较好想的,我们想要找到一个目标元素的左端点:
📕 第一次查找到该目标元素时,不能直接返回,因为不确定它的左端是否还有目标元素
📕 继续向左查找时,right的移动不能再是(mid - 1),因为这样有可能将目标值直接跳过了
📕 结束循环的条件不能再是(left <= right),而是(left < right),否则可能会陷入死循环
📖 解决了这些注意点,也就能够编写查找目标元素左端点的方法了:
public int searchLeft(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left) / 2;
if(arr[mid] >= num){
right = mid;
}else {
left = mid + 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
② 查找目标元素的右端点
与上述查找左端点同理:
📕 第一次查找到该目标元素时,不能直接返回,因为不确定它的右端是否还有目标元素
📕 继续向右查找时,left的移动不能再是(mid + 1),因为这样有可能将目标值直接跳过了
📕 结束循环的条件不能再是(left <= right),而是(left < right),否则可能会陷入死循环
但是查找右端点时,有一个比较难发现的细节需要我们注意:
我们之前取中间点时,使用的方法是left + (right - left) / 2,这种方法求出的中间点在数组大小为偶数时,取到的是左侧中间点:
而使用这种方式取中间点,就会有一种特殊的情况:
想要解决这种问题,就需要我们将每次取到的中间点变成右侧中间点:left + (right - left + 1) / 2
📖 由此我们便能知道,查找目标元素右端点的方法:
public int searchRight(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left + 1) / 2;
if(arr[mid] <= num){
left = mid;
}else {
right = mid - 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
(注意:取哪一侧的中间值,可以通过查看我们后续编写的left和right,如果存在" - 1"的操作,那么就需要取右侧中间值,否则就需要取左侧中间值)
📖 那么到这里,这题离解决也就只差一步之遥了,我们只需要单独处理一下数组长度为0的情况,即可解决该题:
class Solution {
public int[] searchRange(int[] nums, int target) {
if(nums.length == 0){
return new int[]{-1,-1};
}
return new int[] { searchLeft(nums, target), searchRight(nums, target) };
}
public int searchLeft(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left) / 2;
if(arr[mid] >= num){
right = mid;
}else {
left = mid + 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
public int searchRight(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left + 1) / 2;
if(arr[mid] <= num){
left = mid;
}else {
right = mid - 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
}
三、二分查找的实际应用
学习完上面二分查找的内容,相信大家对二分查找已经有了一定的认识,但是想要具体在题目中使用二分查找进行解题还是远远不够的,我们最开始引入的一个话题"二段性"到这里还并没有提到,那么具体什么是二段性?二段性又该如何使用?应该如何才能知道一个数组的二段性为什么?让我们继续具体的学习下面的内容~
① x 的平方根(二分不一定是数组)
在进行解题之前,我们先分析一下这题中存在的注意点:
📕 结果只保留整数部分,小数部分将被舍去
📕 不允许使用内置指数函数和算符
1. 暴力枚举法
从0到x进行枚举,如果 [ i * i <= x && (i + 1) * (i + 1) > x ] 则返回 i
时间复杂度:O(n)
空间复杂度:O(1)
那么想要通过二分优化它,就需要我们查找二段性:
二段性就是:在一段数据中,该元素左侧满足一个性质1,右侧满足一个性质2。
这就是这题的二段性,至此我们通过二段性进行了一个思维突破:并不是数组才能够使用二分~
2. 二分查找法
通过left和right取mid进行查找
如果(mid * mid) <= 目标值 (left = mid)
如果(mid * mid) > 目标值 (right = mid - 1) ---> [取右侧中间值]
时间复杂度:O(logn)
空间复杂度:O(1)
注意:
我们应该使用long类型变量进行运算
📖 代码示例:
class Solution {
public int mySqrt(int x) {
long left = 0;
long right = x;
while(left < right){
long mid = left + (right - left + 1) / 2;
if(mid * mid <= x){
left = mid;
}else {
right = mid - 1;
}
}
return (int)left;
}
}
② 山脉数组的峰顶索引(二分不一定全有序)
题中存在的注意点:
📕 其中一个值递增到一个峰值元素,然后递减
📕 你必须设计并实现时间复杂度为O(logn)的解决方案
这就是明摆着告诉我们使用二分算法了,所以这题我们就不写暴力枚举的方法了,直接考虑二分~
首先分析山脉数组的二段性:
这题的二段性还是非常简单就能看出来的,通过上面我们画的图就可以知道:
📕 数组中存在的一个峰值,这个峰值的左侧是单调增,右侧是单调递减
📕 当arr[mid] >= arr[mid - 1]时,则代表此时为单调递减,为右侧,并且arr[mid]有可能是我们要找的峰值,所以left = mid
📕 当arr[mid] < arr[mid - 1]时,则代表此时为单调递增,为左侧,arr[mid]不是最大,则不可能是峰值,所以right = mid - 1 ---> [取右侧中间值]
由此,我们通过二段性进一步思维突破:并不是全有序才能使用二分~
📖 代码示例:
class Solution {
public int peakIndexInMountainArray(int[] arr) {
int left = 1;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left + 1) / 2;
if(arr[mid] >= arr[mid - 1]){
left = mid;
}else {
right = mid - 1;
}
}
return left;
}
}
当然,使mid与mid + 1进行比较也是可以的,只是需要注意修改一下left和right取的初值,以防止越界~
③ 搜索旋转排序数组(分段二分)
首先,还是看一下这题中的注意点:
📕 得到的数组是由一个升序数组进行旋转得到的
📕 查找旋转后的数组,存在则返回下标,不存在则返回-1
分析旋转排序数组的二段性:
我们能够知道,当原数组进行旋转后,能够将原数组分为两部分有序数组,这个分界点左边的有序数组是一个(值全大于右侧最大值nums[len - 1])的升序数组,分界点右边的有序数组是一个(值全小于等于右侧最大值nums[len - 1])的升序数组。这就是这题的关键所在,也就是旋转排序数组的二段性。
于是我们可以通过nums[mid]的值对当前位置进行判断,再前往mid目前的并进行查找:
📚 如果nums[mid] = target,则返回mid
📚 如果nums[mid] > nums[len - 1],则代表当前mid处于左侧的有序数组中(进行朴素二分)
📕 如果 nums[mid] > target 则 right = mid - 1
📕 如果 nums[mid] < target 则 left = mid + 1
📕 前提是 target 在左侧范围内 ( target 大于等于该侧最小值 -> (target >= nums[0]) ),如果不满足该条件,则代表目标值不在左侧范围,直接将left = mid + 1,不断向右移动,跳出左侧范围。
📚 如果nums[mid] <= nums[len - 1],则代表当前mid处于右侧的有序数组中(进行朴素二分)
📕 如果 nums[mid] > target 则 right = mid - 1
📕 如果 nums[mid] < target 则 left = mid + 1
📕 前提是 target 在右侧范围内 ( target 小于等于该侧最大值 -> (target <= nums[len - 1]) ),如果不满足该条件,则代表目标值不在右侧范围,直接将right = mid - 1,不断向左移动,跳出右侧范围。
那么,我们又通过二段性进行了一次思维突破:并不一定只需要用到一次二分~
📖 代码示例:
class Solution {
public int search(int[] nums, int target) {
int len = nums.length;
int left = 0;
int right = len - 1;
while(left < right){
int mid = left + (right - left) / 2;
if(nums[mid] == target) return mid;
//判断mid当前在哪一侧
//mid在左侧
if(nums[mid] > nums[len - 1]){
if(nums[0] <= target && nums[mid] > target){
right = mid - 1;
}else {
left = mid + 1;
}
}
//mid在右侧
else {
if(nums[len - 1] >= target && nums[mid] < target){
left = mid + 1;
}else {
right = mid - 1;
}
}
}
return nums[left] == target ? left : -1;
}
}
(由于我们这题使用的是分段的朴素二分,所以mid取左侧中间值还是右侧中间值并不影响结果)
那么关于二分查找的知识,就为大家分享到这里了~希望大家不要只背模板,而是学习二分查找的思维,并且多多练习,做到能够更熟练的在一道题中寻找"二段性",这样才算真正的掌握了二分查找~那么我们下次再见~