前言:
作为查找板块中最重要的算法和思想,二分查找是典型的一看就会,一做就废。要不要加=?要不要+1?要不要-1?这是二分查找最让人头痛的地方,作为一个思想不难,细节制胜的算法,拒绝死记硬背,本文将详细解析它的算法思路和原理。
目录
二、寻找左侧边界(最大值最小化or在单调序列中找x或其前驱)
三、寻找右侧边界(最小值最大化or在单调序列中找x或其后继)
一、查找确切值
1、左闭右闭区间
顾名思义,该方法是在一段区间内寻找target目标值,并且左边界和右边界都可以取到。
左边界和右边界都可以取到,看似非常简单,但是是本方法控制边界和细节的关键所在。
int binarySearch(vector<int> &nums, int target) {
//初始化区间
int left = 0, right = nums.size() - 1;
while (left <= right)
{
//小细节:为什么不直接用(left+right)/2
//这就涉及到程序执行先后顺序的问题了
//直接先i+j的话有可能发生整数溢出的问题
//而下面这种方法便可巧妙地一定程度上规避这种问题
int mid = left + (right - left) / 2;
if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
else
return mid;
}
// 未找到目标元素,返回 -1
return -1;
}
- 因为是双闭区间,左右边界的值都可以取到,所以我们在取左右边界值时分别取它可以取到的最小和最大值,即left = 0, right = nums.size() - 1;
- 因为是双闭区间,左右边界的值都可以取到,所以我们在缩小边界时左右边界都要进行移动
1.当target>nums[mid]时,说明target在[mid+1,right]上,因此left=mid+1;
2.当target<nums[mid]时,说明target在[left,mid-1]上,因此right=mid-1;
3.当target=nums[mid]时,说明已找到,返回target;
- 为什么时left<=right?
同样,还是因为它是双闭区间,left=right时也可以存在区间[left,right]
看到这里可能还是理解的不太深刻,看接下来这个对比例子就明白了
2、左闭右开区间
int binarySearch(vector<int> &nums, int target) {
//初始化区间
int left = 0, right = nums.size();
while (left < right)
{
//小细节:为什么不直接用(left+right)/2
//这就涉及到程序执行先后顺序的问题了
//直接先i+j的话有可能发生整数溢出的问题
//而下面这种方法便可巧妙地一定程度上规避这种问题
int mid = left + (right - left) / 2;
if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid ;
else
return mid;
}
// 未找到目标元素,返回 -1
return -1;
}
- 因为左闭右开,右边界的值不能确切取到,所以在设置right时要+1
- 同样,还是因为左闭右开,右边界的值不能确切取到,所以在缩小区间时,right不用-1,反正它也取不到,直接等于mid即可,其实仔细想想效果是一样的
- 同样,还还是因为左闭右开,右边界的值不能确切取到,所以left=right时不可能存在区间[left,right),自然也就无法继续了,故循环条件为left<right而没有=
根据上面的对比分析,我们可以发现,它的细节其实原理非常简单,一切细节的设计都是围绕区间的开闭展开,不过要特别说明的是,我们通常更习惯的是采用左闭右闭的双闭区间形式,这样左右都是对称操作的,更不容易出错。
本文下面提到的算法均采用双闭区间的形式。
二、寻找左侧边界(最大值最小化or在单调序列中找x或其前驱)
int binarySearchInsertion(vector<int> &nums, int target) {
int l = 0, r = nums.size() - 1;
while (l <= r) {
int m = l + (r - l) / 2;
if (nums[m] < target) {
l = m + 1;
} else if (nums[m] > target) {
r = m - 1;
} else {
l = m - 1;
}
}
return l;
}
- 当target>nums[m]或target<nums[m]时,说明还没有找到target,因此仍采用普通二分区间的缩小区间操作,使指针l和r向target靠近
- 当nums[m]==target时,使r向小于target的元素靠近,因此用r=m-1来缩小区间(因为此处是寻找左侧边界,所以缩小边界时向左缩小)
- 循环结束后,l指向最左边的target,r指向首个小于target的元素,所以l就是我们的左侧边界
不难发现,二分查找无非就是给指针i和j分别设定搜索目标,在不断的循环二分中,让l和r都逐渐逼近预先设定的目标,总的来说就是一个不断向目标奔赴的过程。
三、寻找右侧边界(最小值最大化or在单调序列中找x或其后继)
int binarySearchInsertion(vector<int> &nums, int target) {
int l = 0, r = nums.size() - 1;
while (l <= r) {
int m = l + (r - l) / 2;
if (nums[m] < target) {
l = m + 1;
} else if (nums[m] > target) {
r = m - 1;
} else {
l = m +1;
}
}
return r;
}
- 与找左侧边界的思想相同,在没有找到时都是进行普通的二分区间操作缩小区间,唯一变化的两个地方在于:
- ①当nums[m]==target时,使l向大于target的元素靠近,因此用l=m+1来缩小区间(因为此处是寻找右侧边界,所以缩小边界时向右缩小)
- ②循环结束后,r指向最右边的target,l指向首个大于target的元素,所以r就是我们的左侧边界
四、二分查找的经典情景应用
通过上面三种算法的介绍,我们已经初步掌握了二分的思想,并且这三种方法可以用来解决绝大部分二分的题目,不过要注意巧妙灵活的变形和运用
下面介绍二分的一种经典应用思想:
当我们容易得出结果具有单调性时,可以先从小到大假设结果,然后设置一个check函数看该答案是否能使题目成立,主要用到的二分思想是寻找左侧边界和右侧边界,因为题目通常是问最小值或最大值,下面是该思想的几道经典题目:
五、二分的好处和局限性
1、好处
- 二分的查找效率非常高,每次都砍掉一半不可能的值,定量分析也可以得出
- 无需额外空间
2、局限性
- 仅限于有序数据
- 仅适用于顺序表,不能用于链表
- 当数据量较小时,线性查找反而比二分查找更快
六、总结
本文详细介绍了二分的细节实现,这是一种很重要的思想,一定要多加练习,想不清楚时多画图进行实践验证。