总言
主要内容:编程题举例,理解二分查找的思想。
文章目录
1、二分查找
折半查找法也称为二分查找法,它充分利用了元素间的次序关系,采用分治策略,达成特定元素的搜索。其时间复杂度为
O
(
l
o
g
n
)
O(log n)
O(logn),因此在数据量较大时,查找效率非常高。
说明:
1、二分查找不一定要求数组有序,实际上只要满足区间的二段性即可。(找到某一位置,能将区间分成两段,每段具有不同特性/规律)
2、通常可将二分查找分为朴素的二分查找、查找左边界的二分查找、查找右边界的二分查找三种类型。(第一种为最基础的二分查找,我们在学习C语言时曾经写过,后两者为反而是最常用的方法,但相比之下细节点也较多)
2、二分查找(easy)
题源:链接。
2.1、朴素的二分查找
1)、暴力解法过渡到二分查找,说明二分查找为什么更高效
暴力查找下,则需要从头到尾遍历一遍数组,找出满足条件的目标元素(nums[i] == target
)。这种情况下时间复杂度为
O
(
n
)
O(n)
O(n),一次只能排除一个元素。
之所以说暴力查找慢,是因为它没有利用当前数组有序的特性。 我们找到一个中间值mid,根据条件可知,mid 左边的元素值均小于mid ([left, mid) < nums[mid]
),mid 右边的元素值均大于mid([left, mid) > nums[mid]
)。因此,只需要将mid处的元素值和target比较,就可以知道target在哪一段区间中。
mid和target做比较,情况有三:
1、nums[mid] < target
,说明[left,mid]区间内元素均 < target,可以排除。即有:left = mid +1
,继续新一轮循环查找。
2、nums[mid] > target
,说明[mid,right]区间内元素均 > target,可以排除。既有:right = mid -1
,继续新一轮循环查找。
3、nums[mid] == target
,说明该中间值正是目标元素,跳出循环返回结果。
这样一次就能干掉一批元素。(如图,这就是区间二段性的体现,只不过不同题中,这里的特性规律会有不同。)
二分查找的时间复杂度正是由此而来:(设有
n
n
n个元素)
1
1
1 次查找,还剩
n
2
\frac{n}{2}
2n个元素;
2
2
2 次查找,还剩
n
2
∗
1
2
=
n
4
\frac{n}{2}*\frac{1}{2} = \frac{n}{4}
2n∗21=4n个元素;
3
3
3 次查找,还剩
n
4
∗
1
2
=
n
8
\frac{n}{4}*\frac{1}{2} = \frac{n}{8}
4n∗21=8n个元素;
…
…
……
……
x
x
x 次查找,还剩
n
2
x
\frac{n}{2^x}
2xn个元素。最坏情况下,最后一次查找剩余
1
1
1 个元素,则有
n
2
x
=
1
\frac{n}{2^x} = 1
2xn=1,即
n
=
2
x
,
x
=
l
o
g
2
n
n = 2^x, x =log_2n
n=2x,x=log2n。
即时间复杂度为:
O
(
n
)
=
l
o
g
n
O(n) = logn
O(n)=logn。
2)、其它细节
1、我们在找中值时,一般采取中间值,即(left + right )/2
处,实际上mid不一定要在
1
2
\frac{1}{2}
21 位置处,1/3、1/4
等等位置处也可以,只是选择1/2位置能够减少查找次数,有助于保持查找过程的平衡性,避免因为划分不均而导致某些情况下的性能下降。(数学期望问题。)
此外,相比于直接写成mid = (left + right )/2
,为了避免left+right
的值存在溢出,一般情况下都会选择写成:
mid = left + (right - left) /2;
mid = left + (right - left + 1) /2;
由于向零取整,上述两种写法:
①对于元素个数为奇数(即right - left 的差是偶数时),计算结果一样。例如:下标为0、1、2
,此时(2 - 0) / 2 = 1
,(2 - 0 + 1) / 2 = 1
②对于元素个数为偶数(即right - left 的差是奇数)时,前者 mid 偏向较小的那个索引,后者mid 偏向较大的那个索引。 在朴素的二分查找中,二者无区别,在后续的寻找左边界或寻找右边界的二分查找中,就需要留意写法。例如:下标为0、1、2、3
,此时(3 - 0) / 2 = 1
, (3 - 0 + 1) / 2 = 2
2、循环结束条件:left > right,那么循环条件为:left <= right。
问题:为什么此处 left == right
要算进去? left、right两指并非同时相向移动,每次循环只有其中一个指针移动,相向情况下,某一次循环势必会导致两指针碰撞,即left、right指向同一位置,而此时碰撞点处的元素还未进行相关判断,因此需要将其算入。
3)、题解
相关写法:
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;int right = nums.size() - 1; // 初始化 left 与 right 指针
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; // 如果程序⾛到这⾥,说明没有找到⽬标值,返回 -1
}
};
3、在排序数组中查找元素的第一个和最后一个位置(medium)
题源:链接。
3.1、暴力解法 or 朴素的二分查找
由于数组是按照非递减顺序排列,说明其中有区间段呈直线趋势。暴力解法下,从左到右遍历一次,首个target即目标左值,再从右到左遍历一次,首个target即目标右值。(图左)
对于二分查找,若使用先前的那种最基础的二分查找方法,由于要找最左值和最右值,极端情况下,将会降为暴力查找。(图右)
因此,我们要学习新模式下的二分查找。
3.2、寻找左边界 or 右边界的二分查找
3.2.1、寻找左边界的二分查找
1)、寻找左边界的二分查找:
无论是哪一种形式变化,二分查找的本质是要抓住区间的二段性,因两端区间的特性不同,从而能让left、right两指针进行条件挪动。
以数组{1,2,2,3,3,3,5,6}
举例。此例中,我们要寻找左边界,可将区间分为如下两段。用 resLeft
表示左区间的边界, resRight
表示右区间的边界,则 左边界的特点如下:
左边区间 [left, resLeft] 都是⼩于 x 的;
右边区间[resLeft, right] 都是⼤于等于 x 的;
因此,mid
的落点有如下两种情况:
1、当 mid
落在 [left, resLeft]
区间内,此时有 arr[mid] < target
,说明 [left, mid]
都是可以舍去的,此时更新 left 到 mid + 1 的位置,继续在 [mid + 1, right]
上寻找左边界;
2、当 mid
落在 [resLeft, right]
区间内,此时有arr[mid] >= target
,说明 [mid + 1, right]
是可以舍去的(注意这里 mid 可能是最终结果,不能舍去),此时更新 right 到 mid 的位置,继续在 [left, mid] 上寻找左边界;
3、由此,就可以通过二分来快速寻找左边界。
1)、x < target, 此时 left = mid + 1 ,继续在[mid+1,right]区间上找左边界;
2)、x >= target, 此时 right = mid, 继续在[left,mid]区间上找左边界。
循环结束的条件判断:
需要注意,后续这两种二分查找中,循环继续的条件为:left < right
。 为什么不像朴素二分查找一样使用 left <= right 呢?
回答:此两种写法下,当left = right
时,就是我们要找的最终结果,无需判断。如果判断,就会死循环。
如何求mid中值: 在选左边界的二分查找中,我们需要选哪一个作为运算 mid 表达式? 这里我们选取mid = left + (right - left) /2;
,偏向于找左侧的那个元素比较合适。
2)、一个模板:不建议死记硬背
3.2.2、寻找右边界的二分查找
1)、寻找右边界的二分查找
有了上述对左边界的认识,右边界理解起来也大同小异。
右边界的特点:
左边区间 (包括右边界) [left, resLeft] 都是⼩于等于 x 的;
右边区间 [resRight, right] 都是⼤于 x 的;
因此,mid
的落点有如下两种情况:
1、当 mid
落在 [left, resLeft]
区间内,此时有 arr[mid] <= target
,说明 [left, mid -1]
是可以舍去的(注意这里 mid 可能是最终结果,不能舍去),此时更新 left 到 mid 的位置,继续在 [mid, right]
上寻找右边界;
2、当 mid
落在 [resLeft, right]
区间内,此时有arr[mid] > target
,说明 [mid, right]
都是可以舍去的,此时更新 right 到 mid -1 的位置,继续在 [left, mid]
上寻找右边界;
总结为:
1)、x <= target, 此时 left = mid,继续在[mid,right]区间上找右边界;
2)、x > target, 此时 right = mid + 1, 继续在[left,mid]区间上找右边界。
循环继续的条件仍旧为:left < right
。
但此处求mid中值的表达式应选择: mid = left + (right - left +1 ) / 2;
,偏向于找右侧的那个元素比较合适。
2)、一个模板:不建议死记硬背
3.2.3、题解
此题要寻找左边界和右边界,上述两种方式的二分查找均要用到。
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
if(nums.empty())// 处理边界情况
return {-1,-1};
vector<int> ret;
int left = 0; int right = nums.size()-1;
//找左边界
while(left < right)
{
int mid = left + (right - left ) / 2;
if(nums[mid] < target)
left = mid + 1;
else // nums[mid] >= target
right = mid;
}
if(nums[right] == target)
ret.push_back(right);
else ret.push_back(-1);
//找右边界
left = 0; right = nums.size()-1;
while(left < right)
{
int mid = left + (right - left + 1) /2;
if(nums[mid] > target)
right = mid -1;
else // nums[mid] <= target
left = mid;
}
if(nums[left] == target)
ret.push_back(left);
else ret.push_back(-1);
return ret;
}
};
4、搜索插入位置(easy)
题源:链接。
4.1、题解
说明:此题上述三种方法均可使用,只要理清楚其中逻辑和细节即可。
1)、寻找左边界的二分查找:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
if(nums.back() < target)//处理边界情况
return nums.size();
int left = 0; int right = nums.size()-1;
while(left < right)
{
int mid = left + (right - left) /2;
if(nums[mid] < target)
left = mid + 1;
else right = mid;
}
return left;// left == right 的时候, left 或者 right 所在的位置就是我们要找的结果。
}
};
2)、寻找右边界的二分查找:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
if(nums[0] > target)//处理边界情况
return 0;
int left = 0; int right = nums.size()-1;
while(left < right)
{
int mid = left + (right - left + 1) /2;
if(nums[mid]<= target)
left = mid;
else right = mid -1;
}
//判断最终left\right指向位置
if(nums[left] == target) return left;
else return left + 1;
}
};
5、x的平方根(easy)
题源:链接。
5.1、题解
1)、暴力解法
说明: 从 1 到 x 穷举所有数的平方。如果 i * i == x
,直接返回 x ;如果 i * i > x
,说明之前的⼀个数是结果,返回 i - 1 。
需要注意,由于 i * i
可能超过 int 的最⼤值,因此此处使用用 long long
类型比较合适。
class Solution {
public:
int mySqrt(int x) {
long long i = 0;// 由于两个较⼤的数相乘可能会超过 int 最⼤范围,因此⽤ long long
for (i = 0; i <= x; i++)
{
if (i * i == x)// 如果两个数相乘正好等于 x,直接返回 i
return i;
if (i * i > x) // 如果第⼀次出现两个数相乘⼤于 x,说明结果是前⼀个数
return i - 1;
}
return -1; // 为了处理oj题需要控制所有路径都有返回值
}
};
2)、二分查找
说明: 结果只保留整数部分,根据题可知,是按照向下取整获取结果的,因此,此题使用寻找右边界的二分查找相对方便(也可以使用另外的二分查找,明确边界、循环结束条件等即可)。
题中
0
<
=
x
<
=
2
31
−
1
0 <= x <= 2^{31} - 1
0<=x<=231−1,下述不必单独将0拎出讨论,由于0不满足left < right
的条件判断,会直接返回结果,而其正好是0。
class Solution {
public:
int mySqrt(int x) {
long long left = 0; long long right = x;
while(left < right)
{
long long mid = left + (right - left + 1) /2;
if(mid*mid <= x)
left = mid;
else right = mid -1;
}
return left;
}
};
6、山峰数组的峰顶(easy)
题源:链接。
6.1、题解
1)、暴力解法
根据题目可知,峰顶的特点是该位置的元素比两侧的元素都要大。因此,我们可以遍历数组内的每一个元素,找到某一个元素比两边的元素大即可。
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int n = arr.size();
// 遍历数组内每⼀个元素,直到找到峰顶
for (int i = 1; i < n - 1; i++)
// 峰顶满⾜的条件:两侧元素小于山顶元素
if (arr[i] > arr[i - 1] && arr[i] > arr[i + 1])
return i;
return -1;// 找不到
}
};
2)、二分查找
虽然此题中数组不是单调排列的,但仍旧具有二段性,可以使用二分查找。这也侧面说明二分查找不需要数组有序。
将区间分为两端,根据 mid
落下的位置,可以分为下述三种情况:
1、若mid
落在左段,此时 mid 位置呈现上升趋势(mid < mid + 1),在mid左侧的元素不会比mid大。因此,接下来可以在 [mid + 1, right]
区间继续搜索;
2、若 mid
落在右段,此时 mid 位置呈现下降趋势(mid > mid + 1 ),在mid右侧的元素不会比mid大,说明我们接下来要在 [left, mid - 1]
区间搜索;
3、如果 mid 位置就是山峰,直接返回结果。
这里使用左边界的二分查找或右边界的二分查找都可以,也可以按照上述将分三种情况来讨论。
朴素的二分查找写法:
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int left = 0; int right = arr.size() -1;
while(left <= right)
{
int mid = left + (right - left) /2;
if(arr[mid] > arr[mid+1] && arr[mid] > arr[mid -1])
return mid;
else if(arr[mid] < arr[mid + 1])//左山坡:上升趋势
left = mid + 1;
else right = mid -1;//右山坡:下降趋势
}
return -1;//这里是为了完善OJ的输出,题目告知了 arr 一定是山脉数组
}
};
使用左边界的二分查找写法:
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int left = 0;int right = arr.size()-1;
while(left < right)
{
int mid = left + (right - left) /2;
if(arr[mid] < arr[mid+1]) //左山坡
left = mid + 1;
else right = mid;//右山坡
}
return left;
}
};
7、寻找峰值(medium)
题源:链接。
7.1、题解
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left = 0; int right = nums.size()-1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(nums[mid] < nums[mid + 1])
left = mid + 1;
else right = mid;
}
return right;
}
};
8、搜索旋转排序数组中的最小值(medium)
题源:链接。
8.1、题解
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
int x = nums[right]; // 标记⼀下最后⼀个位置的值
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > x)
left = mid + 1;
else
right = mid;
}
return nums[left];
}
};
9、0~n-1中缺失的数字(easy)
题源:链接。
9.1、题解
此题常见解法:①哈希表;②直接遍历;③位运算;④高斯求和;⑤二分查找。
这里我们以二分查找为主:关键点在于如何获得区间的二段性。
class Solution {
public:
int takeAttendance(vector<int>& records) {
int left = 0; int right = records.size()-1;
while(left < right)
{
int mid = left + (right -left) /2;
if(mid ==records[mid])
left = mid +1;
else right = mid;
}
return left == records[left] ? left + 1 : left;
}
};
10、等差数列中缺失的数字(easy)
题源:链接。
10.1、题解
暴力解法只用遍历数组,查找元素是否满足等差数列条件即可。如:
1、
a
n
+
1
−
a
n
=
a
n
−
a
n
−
1
a_{n+1} -a_n = a_{n} -a_{n-1}
an+1−an=an−an−1 ,对比当前项与前后两个项的差等于同一个常数。
2、
a
n
+
d
=
a
n
+
1
a_n + d= a_{n+1}
an+d=an+1,先根据首位元素获取公差,再判断当前项与后一项是否满足公差,若不满足,则后一项为返回值。
3、
a
n
=
a
1
+
(
n
−
1
)
×
d
a_n = a_1 + (n-1)×d
an=a1+(n−1)×d,根据公式,遍历数组,与首项计算结果做比较。
二分查找同理,关键在于如何想到判断条件,使得left
或right
分别移动的。
class Solution {
public:
int missingNumber(vector<int>& arr) {
int d =(arr.back()-arr.front())/(int)arr.size();
//这里数组长度要加上缺少的那一个元素,此外vector::size()返回值是unsigned int,此处运算存在整值提升(公差为负数会出问题)。
int left = 0; int right = arr.size()-1;
while(left < right)
{
int mid = left + (right - left) /2;
if(arr[0]+mid*d == arr[mid])
left = mid + 1;
else right = mid;
}
return arr[left]-d;
}
};