目录
[1. 二分查找(朴素的二分)](#1. 二分查找(朴素的二分))
[2. 在排序数组中查找元素的第一个和最后一个位置(查找边界端点)](#2. 在排序数组中查找元素的第一个和最后一个位置(查找边界端点))
一、什么是二分算法?
之前我们认为的二分算法其实就是用于在有序数组中快速查找特定元素,每次查找都可以舍去一半的数据量,从而大大提供时间效率。而我们这里要说的二分算法的使用情况不论是有序还是无序的数组情况,其实都可以使用,关心的本质其实是数组具有二段性(即通过某个规律可以将1数组分成2部分)。
本本主要想总结一下,二分算法中了两个经典使用模板,即朴素的二分查找模板和查找左边界或右边界的模板。当我们理解了这两种模板,我们应该就可以解决大部分的二分题型了。二分算法的原理是很简单的,而关键难就难在处理各自细节细节问题上。
二、模板例题
1. 二分查找(朴素的二分)
题目如图所示:
这个题目的意思就是让我们在一个有序数组中找一个给定的目标值是否存在,存在则返回对应下标,否则,则返回 -1。并且要求时间复杂度是一个O(log n)的,所以我们就必须使用二分算法了。
首先第一步,观察数组的二段性。
因为这是一个有序数组,所以当我们在数组中任意选择一个数据 x ,就可以将数组划分为两部分,左边部分都是小于x的,右边部分都是大于x的,如图所示:
这就找到了数组的二段性了。
第二步,使用二分算法的思想。
将左端点下标设为 left,右端点下标设为 right ,然后求出它们的中间下标就为 mid ,其中 mid = (left + right)/ 2 。每次判断的就是 mid 下标对应的数据与目标值 target 的关系:
- 如果 nums[ mid ] < target ,则说明 [ left, mid ] 部分(mid 左边部分)的值都是小于目标值 target 的,那么我们就可以将此时的 left = mid + 1 ,然后再算出mid,进行判断;
- 如果 nums[ mid ] > target ,则说明 [ mid, right] 部分(mid 右边部分)的值都是大于目标值 target 的,那么我们就可以将此时的 right = mid - 1 ,然后再算出mid,进行判断;
- 如果遇到 nums[ mid ] == target ,则说明数组中存在 target 这个值,返回对应下标即可。
那么我们通过重复这些操作就可以完成二分查找。但是我们还需要处理异常细节问题:
- 循环的结束条件是什么?
如此重复操作,left 和 right 就会逐渐靠近,最终遇到 left == right ,而当 left == right 时,我们仍然需要计算 mid,此时的 mid 下标的元素就是left 和right中对应的元素,那么当left和right相遇时的元素如果等于 target,返回 mid 即可;但是如果不等于target,则说明这个数组中没有与target相同的元素,因为此时我们已经通过left和right 扫描完了整个数组。因此,我们循环的判断条件就是while( left <= right ) ,。 - 如何计算 mid 的值?
为什么要考虑计算mid的值呢?因为在有些题目中,数组的范围是和很大的,此时执行 mid = (left + right)/ 2 ,left + right 的值可能会超过本身数据类型的最大值,导致数据溢出,所以我们最好的方法应该这样计算: mid = left + (right - left) / 2。
大体思路如图所示:
实现代码如下所示:
cpp
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)
left = mid + 1;
else if(nums[mid] > target)
right = mid - 1;
else
return mid;
}
return -1;
}
};
最后,关于时间复杂度的计算:
所以时间复杂度就是O(log n)。
2. 在排序数组中查找元素的第一个和最后一个位置(查找边界端点)
题目链接:34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
题目如图所示:
这到题的意思就是让你我们在一个非递减(即要么递增要么不变)数组中去找一个目标值target,注意这个目标值在数组中可能存在多个,我们需要返回它的开始位置和结束位置的下标。若没有这个目标值,则返回 [ -1, -1] 。
题目中要求的O(log n)的时间复杂度,所以我们也需要使用二分的方法来解决。
这道题不能使用上述朴素的二分方法来解决,因为朴素的二分只能判断这个值在不在,返回的下标不能保证是开始位置或结束位置。
我们需要分成两种情况来解决:
- 情况1:查找区间的左端点。
首先看到二段性:由于这里的一个非递减(即要么递增要么不变)的数组,所以我们的任意选一个位置的所在值的左端点,就可以将整个数组分成两部分,如图所示:
对于左端点,左边部分的所有值都是严格小于x的,右边部分的所有值一定是大于或等于x的。这是我们再使用二分。
然后通过二分,将左端点下标设为 left,右端点下标设为 right ,然后求出它们的中间下标就为 mid 。此时我们每次判断mid对应的数据与target时就需要分成两种情况,如下所示:
- 如果 nums[ mid ] < target ,则说明 [ left, mid ] 部分(mid 左边部分)的值都是严格小于目标值 target 的,那么我们就可以舍去闭区间 [ left, mid ] 的数据,将此时的 left = mid + 1 ,然后再算出mid,再进行判断;
- 如果 nums[ mid ] >= target ,则说明 [ mid, right] 部分(mid 右边部分)的值都是大于或等于目标值 target 的,那么由于这个闭区间 [ left, mid ] 的数据中可能存在我们的目标值,那么就不能将 mid 对应的这个点舍去,则需要将此时的 right = mid,然后再算出 mid,再进行判断;
接下来处理细节问题:
- 循环结束的条件判断是什么?
我们这里并不能选择while(left <= right) 了,需要去掉这里的等号。如果这里取了等号,则当left == right 时进行取中点的时候,如果nums[mid] >= target 则会命中条件2,会执行 right = mid ,此时left 又等于 right 又会进行入循环,如此会一直重复,陷入死循环。所以我们的循环条件应该为 while(left < right) 。那么当我们循环结束的时候,left 一定等于 right ,我们只需要判断nums[ left ] 是否等于目标值,就可以判断目标值是否存在,若存在,则就可以记录一下左端点的位置,若不存在,则返回 [ -1, -1 ] 即可。 - 如何计算中点?
关于计算中点,我们有两种方法一种是 (left + right) / 2 ,一种是 (left + right + 1) / 2,这两种方法的区别是当数据个数为偶数的时候,中点下标取的是靠左的那一个元素下标,而后者是取的靠右哪一个数据的下标。如图所示:
如果我们使用 (left + right + 1) / 2,则当我们的数据只有两个的时候,如果此时计算mid ,mid会等于right,如果此时nums[ mid ] >= target ,则会命中条件2,执行right = mid,此时再计算mid,mid又会等于right,从而一直重复该操作,出现死循环。 如图所示:
所以我们求中点的方式为**(left + right) / 2**。
总结一下,查找区间的左端点所有的细节条件的判断如下图所示:
- 情况2:查找区间的右端点。(处理思路和情况1很相似)
二段性判断:我们任意选择一个数据的右端点所在的值,就可以将整个数组分成两部分,如图所示:
对于右端点,左边部分的所有值都一定是小于或等于x的,右边部分的所有值严格是大于x的。这是我们再使用二分。
然后使用二分,将左端点下标设为 left,右端点下标设为 right ,然后求出它们的中间下标就为 mid 。此时我们每次判断 mid 对应的数据与target时就需要分成两种情况,如下所示:
- 如果 nums[ mid ] <= target ,则说明 [ left, mid ] 部分(mid 左边部分)的值都一定是小于或等于目标值 target 的,那么我们的答案就在闭区间 [ left, mid ] 的数据,则将此时的 left = mid ,然后再算出mid,再进行判断;
- 如果 nums[ mid ] > target ,则说明 [ mid, right] 部分(mid 右边部分)的值都是大于目标值 target 的,那么就可以舍去闭区间 [ left, mid ] 的数据,将此时的 right = mid - 1 即可,然后再算出 mid,再进行判断;
然后处理细节问题:
- 循环结束的条件判断是什么? 我们选择的仍然是while( left < right ) 。因为如果选择 while( left <= right ) ,则当我left == right 时,我们求出的中点mid就是等于left(等于right),如果此时的nums[ mid ] <= target,此时就会名字条件1,执行left = mid ,然后又会执行mid 等于left(等于right),一直重复下去,陷入死循环。
- 如何计算中点?
这里求中点的方式和情况1就不一样的,这里求中点的方法为:(left + right + 1) / 2 ,即当数据是偶数的时候,我们的中点是是靠右的那一个数据。如果我们使用 (left + right) / 2,则当我们的数据只有两个的时候,如果此时计算mid ,mid会等于left,如果此时nums[ mid ] >= target ,则会命中条件2,执行 left= mid,此时再计算mid,mid又会等于left,从而一直重复该操作,出现死循环。如图所示:
总结一下操作右端点的所有条件即细节,如下图所示:
所以最终我们这道题的代码实现如下所示:
cpp
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target)
{
int n = nums.size();
if (n == 0) return { -1,-1 };
// 1. 二分找开始位置
int left = 0, right = n - 1;
while (left < right)
{
int mid = left + (right - left) / 2;
if (nums[mid] >= target) right = mid;
else left = mid + 1;
}
// left和right相遇,判断是否有结果
if (nums[left] != target) return { -1,-1 };
//保存一下
int begin = left;
// 2. 二分找结束位置
left = 0, right = n - 1;
while (left < right)
{
int mid = left + (right - left + 1) / 2;
if (nums[mid] <= target) left = mid;
else right = mid - 1;
}
return { begin, left };
}
};
三、二分算法总结
我们想要使用二分算法,关键就是需要我们的解具有二段性,就可以使用二分算法找出答案:
- 首先根据待查找区间的中点位置,分析答案会出现在哪⼀侧;
- 接下来舍弃⼀半的待查找区间,转而在有答案的区间内继续使用二分算法查找结果。
虽然我们这里介绍了两种模板例题,但是我们只需要掌握一种即可,我们最常用的就是上述的第二种模板,查找某一个值的左右区间的端点。
下面是总结的关于查找某一个值的左右区间的端点使用模板,如下代码:
cpp
// ⼆分查找区间左端点
int left = 0, right = n - 1;
while (left < right)
{
int mid = (left + right) / 2;
if (nums[mid] >= target) right = mid;
else left = mid + 1;
}
// ⼆分结束之后可能需要判断是否存在结果
cpp
// ⼆分查找区间右端点
int left = 0, right = n - 1;
while (left < right)
{
int mid = (left + right + 1) / 2;
if (nums[mid] <= target) left = mid;
else right = mid - 1;
}
// ⼆分结束之后可能需要判断是否存在结果
为了防止溢出,求中点时可以下面的方式:
- 二分查找区间左端点时:int mid = left + (right - left) / 2;
- 二分查找区间右端点时:int mid = left + (right - left + 1) / 2;
使用时机
关键在于分析题目数组中的二段性,就讨论题即可。
记忆方式:
求左端点和右端点的方法,它们的循环判断条件都是一样的,我们只需要记住:如果 if、else中出现了 - 1 的时候,那么在求中点的时候就需要 + 1。
感谢各位观看!希望能多多支持!