【算法】——会了二分查找,对O(logn)真的很敏感
前言
在算法的世界里,二分查找(Binary Search)犹如一盏智慧的明灯,为我们在数据海洋中指明方向。这种基于分治思想的经典算法,能在O(log n)的时间复杂度内快速定位目标元素,将传统线性搜索的效率提升到了全新的高度。
二分查找的精妙之处在于它每一次比较都能将搜索范围减半,这种指数级的效率提升使其成为处理有序数据的首选方案。从基础的数组查找到各种变形应用,二分查找展现了算法设计中"分而治之"思想的强大威力。无论是数据库索引、游戏开发中的碰撞检测,还是机器学习中的超参数调优,二分查找都扮演着不可或缺的角色。
本文将深入剖析二分查找的核心原理,从标准实现到各种实用变体,通过清晰的代码示例和实际应用场景,带您领略这一经典算法的优雅与力量。让我们一起探索,如何用二分查找的智慧,在编程世界中实现更高效的搜索解决方案。
二分查找
二分查找是一种在有序集合中快速定位目标元素的分治算法,其核心是通过每次比较将搜索范围减半,实现指数级效率提升。
二分查找算法遵循三个关键原则:
- 有序性:输入数据必须预先排序(升序或降序)
- 边界收缩:通过比较中间元素动态调整搜索区间
- 终止条件:当区间缩小到单个元素或空时结束
这里的有序性,也可以理解为二段性
当你要分治的数组或者其他数据结构呈现两极分化,可以靠一个特征分成两段的时候,也可以使用二分查找
二分查找也有模板,并且它的模板很适用,直接往题目上套就行
二分查找因为使用目的不同,模板也有三种
模版一:标准二分查找
// 在有序数组中查找目标值,返回索引(不存在返回-1)
int binary_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 -1;
}
模版二:寻找左边界
// 找到第一个 = target 的元素位置(可用于重复元素查找)
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // 注意右边界
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;
}
return left;
将数组分成左半区域和右半区域
- 左半区域满足一个条件,例如均小于target
- 右半区域满足一个条件,例如均大于或者等于target
寻找左边界,是寻找右半区域的左边界
- 更新right时,不能越过右半区域,因此right = mid
- 更新left时,能越过左半区域,因此left=mid+1
注意:寻找左边界时的mid更新策略,必须是mid = left+(right-left)/2,否则会死循环
模版三:寻找右边界
// 找到最后一个 = target 的元素位置
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size()-1;
while(left<right){
int mid = left + (right-left+1)/2;
if(nums[mid] > target) right = mid-1;
else left = mid;
}
return right;
}
将数组分成左半区域和右半区域
- 左半区域满足一个条件,例如均小于或者等于target
- 右半区域满足一个条件,例如均大于target
寻找右边界,是寻找左半区域的右边界
- 更新right时,能越过右半区域,因此right = mid-1
- 更新left时,不能越过左半区域,因此left=mid
注意:寻找右边界时的mid更新策略,必须是mid = left+(right-left+1)/2,否则会死循环
很多时候,大家会把寻找左边界和寻找右边界的mid更新策略记混,不妨试试关注left或者right的更新策略中是否出现-1,如果出现-1,则mid中就需要+1,就是寻找右边界,否则就是寻找左边界,不+1
什么时候使用二分查找
二分查找(Binary Search)是一种高效的搜索算法,但并非所有问题都适用。以下是判断是否可以使用二分查找的关键条件:
1. 数据必须有序(或部分有序)
二分查找的核心是每次比较后能排除一半的搜索范围,因此数据必须满足单调性(升序或降序)。
适用场景:
- 完全有序数组(如
[1, 3, 5, 7, 9]
) - 部分有序数组(如旋转排序数组
[4, 5, 6, 1, 2, 3]
) - 其他具有单调性的问题(如数学函数、答案范围可二分的情况)
不适用场景:
- 完全无序的数组(需先排序,否则只能用线性搜索)
2. 问题需要快速查找(优于 O(n))
当暴力解法的时间复杂度为 O(n) 或更高时,二分查找能优化到 O(log n)。
典型问题:
- 精确查找:在有序数组中找目标值(如
std::lower_bound
) - 边界查找:找第一个/最后一个满足条件的元素(如
>=x
的最小值) - 最值问题:求满足条件的最大值/最小值(如「吃香蕉问题」)
- 数学问题:求平方根、对数等(利用单调性逼近答案)
3. 问题具有「二分性」
即问题的解可以通过中间值判断搜索方向,分为两类:
(1) 显式二分(直接基于有序数据)
- 在有序数组中查找目标值(LeetCode 704)
- 找插入位置(LeetCode 35)
- 搜索旋转排序数组(LeetCode 33)
(2) 隐式二分(答案范围可二分)
- 求最值问题:
- 最小化最大值(如「分割数组的最大值」LeetCode 410)
- 最大化最小值(如「分配巧克力」LeetCode 1231)
- 数学逼近:
- 求平方根(LeetCode 69)
- 解方程(如
x^x = 1000
的解)
关键特征:
- 问题的解存在一个明确的边界(如「能否在
h
小时内吃完香蕉?」) - 可以通过中间值
mid
判断解在左半还是右半区间。
二分查找解决问题
例题:在排序数组中寻找元素的第一个位置和最后一个位置
题目要求,在数组中找到指定元素出现的第一个和最后一个位置
时间复杂度为O(logn)明着告诉你使用二分查找,这算是二分查找的一个标志
而因为要找到两个位置,则需要二分查找两次
- 查找左边界一次
- 查找右边界一次
直接套模版
代码:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()-1;
int begin = 0;
int end = 0;
if(right<0) return {-1,-1};
//寻找左端点
while(left<right){
int mid = left + (right-left)/2;
if(nums[mid] < target) left = mid+1;
else right = mid;
}
if(nums[left] != target) return {-1,-1};
begin = left;
//寻找右端点
right = nums.size()-1;
while(left<right){
int mid = left + (right-left+1)/2;
if(nums[mid] > target) right = mid-1;
else left = mid;
}
end = right;
return {begin,end};
}
};
例题:山脉数组的峰顶元素
思路:
山脉数组满足这个条件:arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1]
其中的arr[i]将数组分成两部分
- 左半部分,前一个元素比后一个元素小
- 右半部分,前一个元素比后一个元素大
这符合我们说的,使用二分查找时的二段性,通过前后大小关系将数组严格分成两个部分
山脉数组的定义要求峰顶元素是最后一个满足 arr[i] < arr[i+1]
的位置
因此,本题是寻找右边界,则套用寻找右边界的模版
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
if(arr.size()==0) return -1;
int ret = 0;
int left = 1;
int right = arr.size()-2;
//寻找右边界
while(left<right){
int mid = left + (right-left+1)/2;
if(arr[mid]>arr[mid-1]) left = mid;
else if(arr[mid]>arr[mid+1]) right = mid-1;
}
return left;
}
};
例题:寻找峰值
i < j
左右两边的最小值都是负无穷
随便两个元素,如果nums[i] > nums[j]
- 代表[0,i]中,一定存在一个峰顶元素
- 因为左边的最小值是负无穷,所以从0到峰顶元素是一直上升的,遇到峰顶元素开始下降
随便两个元素,如果nums[i]<nums[j]
- 代表[j,n-1]中,一定存在一个峰顶元素
- 因为最右边的最小值上服务器,所以从j位置到峰顶元素是一直上升的,遇到峰顶元素开始下降
因此,我们需要寻找的是开始下降的元素,即寻找右边区域的最左边
- 当nums[mid] > nums[mid+1] ,right = mid;
- 当nums[mid] < nums[mid+1],left = mid + 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 if(nums[mid]>nums[mid+1]) right=mid;
}
return left;
}
};
🌟 结语:二分查找——让搜索快如闪电的魔法 🔍✨
在这趟二分查找的探索之旅中,我们从最基础的模板出发,一路解锁了各种神奇的应用场景!无论是经典的有序数组查找🔢,还是充满挑战的山脉数组峰顶定位🏔️,二分查找都用它那O(log n)的超高效率惊艳了我们。
记住这三个黄金法则:
1️⃣ 数据要有序(或部分有序)📈
2️⃣ 问题要可二分(能通过中间值判断方向)🎯
3️⃣ 边界要明确(知道什么时候该收网)🎣
掌握了这些,你就能像算法界的福尔摩斯一样🔍,在各种数据迷宫中快速找到正确答案!下次遇到搜索问题,不妨先问问:这里能用二分查找吗?说不定就能收获意想不到的惊喜哦~
愿你在算法的世界里继续披荆斩棘,让二分查找成为你最锋利的宝剑⚔️!我们下个算法见~ 🚀
P.S. 记住:人生就像二分查找,有时候退一步(right=mid-1),反而能更快找到答案呢😉