什么是二分算法?(核心思想)
二分算法的核心思想是**"分而治之"(Divide and Conquer)** ,更准确地说是**"减治"(Decrease and Conquer)**。
它的基本逻辑非常贴近生活:猜数字游戏 。
假设我心里想了一个 1 到 100 之间的数字,你来猜。
-
如果你从 1 开始一个一个猜,最坏情况要猜 100 次。这叫线性查找,时间复杂度 O(n)。
-
但如果你先猜 50:
-
我说"太小了",那你瞬间就排除了 1~50,下一次直接从 51~100 中间猜(75)。
-
我说"太大了",那你瞬间排除了 51~100,下一次从 1~50 中间猜(25)。
-
-
每次猜测,你都能排除掉一半的错误答案 。无论我想什么数字,你最多只需要猜 7 次(因为 2的七次方=128>100 )。这就是二分查找,时间复杂度 O(logn) 。
使用二分的绝对前提
很多人有一个误区,认为"只要数组是有序的,就能用二分"。这句话对,但不全面。
二分查找真正的绝对前提是:数据具有"二段性"(或单调性)。
什么是二段性?
在当前的搜索区间内,你必须能找到一个"判定条件",使得这个条件把区间一分为二:一半全部满足 这个条件,另一半全部不满足这个条件。
只要具备了二段性,不管数组是不是完全有序,都可以使用二分。 相反,如果一个数组是乱序的,你找不到这样一个判定条件,二分就失效了。
二分算法的两大基本形态
在实际应用中,二分算法主要解决两类问题,对应两种形态:
-
查找特定值(存在性判断)
-
目标:判断目标值在不在,在的话返回它的索引。
-
特点:一旦找到
arr[mid] == target,就可以直接返回,不需要继续找了。
-
-
查找边界(极值查找)
-
目标:找到满足条件的第一个 位置(左边界),或者满足条件的最后一个位置(右边界)。
-
特点:即使
arr[mid] == target,也不能停止,还要继续向左(或向右)收缩区间,直到找到最边缘的那个位置。
-
二分算法的"细节陷阱"(为什么这么难写?)
二分算法的代码框架很短,通常只有五六行,但为什么极度容易写错?因为里面有四个变量在互相纠缠,一旦搭配错,就会导致死循环 或数组越界:
-
循环条件 :是写
while (left < right)还是while (left <= right)? -
中值计算 :是写
mid = (left + right) / 2还是mid = (left + right + 1) / 2? -
左边界更新 :是写
left = mid还是left = mid + 1? -
右边界更新 :是写
right = mid还是right = mid - 1?
逐题深度剖析细节陷阱
cpp
class Solution {
public:
int searchInsert(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
right = mid;
}
if (nums[left] < target)
return right + 1;
return right;
}
};
1. 如何分成两段?为什么要这么分?
-
分为
nums[mid] >= target和nums[mid] < target。 -
原因 :我们要找的是第一个大于等于
target的位置(无论它是等于还是需要插入)。如果nums[mid] >= target,说明mid可能就是那个插入点,或者插入点在mid左边。我们必须保留mid。
2. 逼近方向?
向左逼近 。我们需要找到第一个 >= target 的位置。
3. 代码四大维度剖析:
-
while条件 :left < right。 -
mid取值 :mid = left + (right - left) / 2;。向下取整。 -
边界更新 :如果
nums[mid] < target,执行left = mid + 1(舍弃);否则right = mid(保留)。 -
结束状态 :
left == right,指向第一个>= target的位置。此时这个位置就是插入位置。
题目 2:x的平方根
cpp
class Solution {
public:
int mySqrt(int x)
{
if(x < 1) return 0; // 处理边界情况
int left = 1, 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;
}
};
1. 如何分成两段?为什么要这么分?
-
分为
mid * mid <= x和mid * mid > x。 -
原因 :题目要求向下取整 的平方根,即寻找满足
mid * mid <= x的最大整数 。如果mid * mid <= x,说明mid可能是答案,或者真正的答案在mid右边。我们必须保留mid。
2. 逼近方向?
向右逼近 。我们要找的是满足 mid * mid <= x 的最大值。
3. 代码四大维度剖析(正确写法):
-
while条件 :left < right。 -
mid取值 :mid = left + (right - left + 1) / 2;。向上取整 (因为是向右逼近,保留mid,必须防死循环)。 -
边界更新 :如果
mid * mid <= x,执行left = mid(保留,向右逼近);否则right = mid - 1(舍弃)。 -
结束状态 :
left == right,指向满足mid * mid <= x的最大整数(即答案)。
cpp
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr)
{
int left = 0, 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;
}
};
1. 如何分成两段?为什么要这么分?
-
比较右邻居,分为
arr[mid] < arr[mid + 1](上坡)和arr[mid] > arr[mid + 1](下坡)。 -
原因:在山脉数组中,山峰必定在递增区和递减区的交界处。
-
如果
arr[mid] < arr[mid + 1],说明现在在上坡 。既然在上坡,山峰一定在mid的右边。那么mid以及它左边的所有元素都绝不可能是山峰(因为它们后面还有更高的),所以我们可以大胆舍弃它们。 -
如果
arr[mid] > arr[mid + 1],说明现在在下坡 。既然在下坡,山峰一定在mid的左边或者就是mid本身。所以mid有可能是答案,我们必须保留它。
-
2. 逼近方向?
左右均可,取决于你比较的是左邻居还是右邻居。
本题特殊易错点:
arr[mid] < arr[mid + 1](和右边比) 和 arr[mid] > arr[mid - 1](和左边比),在判断"当前处于上坡"这个物理事实上,是等价的。这会导致在判断是否处于上坡时,比较左邻居和比较右邻居的条件写混,导致死循环。所以做这道题时不仅要判断出有什么样的二段性,也要想好mid是和左边比还是右边比。
3. 代码四大维度剖析(推荐解法:比较右邻居):
-
while条件 :left < right。 -
mid取值 :mid = left + (right - left) / 2;。向下取整。 -
边界更新 :比较右邻居
arr[mid] < arr[mid + 1](上坡)。因为上坡的mid绝对不可能是山峰 ,所以放心大胆舍弃,执行left = mid + 1;否则(下坡),说明mid可能是山峰,执行right = mid(保留)。 -
结束状态 :
left == right,指向唯一的峰值。
cpp
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target)
{
// 处理边界情况
if(nums.size() == 0) return {-1, -1};
int begin = 0;
// 1. 二分左端点
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;
}
if(nums[left] != target) return {-1, -1};
else begin = left; // 标记⼀下左端点
// 2. ⼆分右端点
left = 0, right = nums.size() - 1;
while(left < right)
{
int mid = left + (right - left + 1) / 2;
if(nums[mid]<=target) left = mid;
else right = mid-1;
}
return {begin, right};
}
};
1. 如何分成两段?为什么要这么分?
-
找左边界 :以
target为界。分为nums[mid] >= target和nums[mid] < target。- 原因 :我们要找第一个 等于
target的位置。如果nums[mid] >= target,说明mid有可能是答案(如果等于),或者答案在mid左边(如果大于)。我们绝不能放弃mid及其左侧,所以把mid归入"保留区间"。
- 原因 :我们要找第一个 等于
-
找右边界 :以
target为界。分为nums[mid] <= target和nums[mid] > target。- 原因 :我们要找最后一个 等于
target的位置。如果nums[mid] <= target,说明mid有可能是答案,或者答案在mid右边。我们绝不能放弃mid及其右侧,所以把mid归入"保留区间"。
- 原因 :我们要找最后一个 等于
2. 逼近方向?
-
找左边界:向左逼近 (寻找第一个满足
>= target的位置)。 -
找右边界:向右逼近 (寻找最后一个满足
<= target的位置)。
3. 代码四大维度剖析:
-
找左边界
-
while条件 :left < right。 -
mid取值 :mid = left + (right - left) / 2;。向下取整。 -
边界更新 :如果
nums[mid] < target,说明mid及左边绝对没答案,执行left = mid + 1(舍弃);否则(>=)执行right = mid(保留,向左逼近)。 -
结束状态 :
left == right,指向第一个>= target的位置。需额外检查nums[left] == target。
-
-
找右边界
-
while条件 :left < right。 -
mid取值 :mid = left + (right - left + 1) / 2;。向上取整(防死循环)。 -
边界更新 :如果
nums[mid] <= target,执行left = mid(保留,向右逼近);否则(>)执行right = mid - 1(舍弃)。 -
结束状态 :
left == right,指向最后一个<= target的位置。
-
综合以上题目,可以提取出一套严密的二分查找法则。
1. 如何分成两段?(寻找"二段性")
二分查找的关键不在于数组是否有序,而在于你设定的判定条件(Check)是否能将区间一分为二。
-
在分段时,一定要问自己:如果条件成立,答案是在左半区还是右半区?
-
例如,在求平方根时,条件是
mid*mid <= x,因为我们要找最大值,所以答案一定在"满足条件"的那一侧的最右端。这就是为什么要向右逼近的原因。 -
只要逻辑不冲突,就能二分。
2. while 循环条件:控制"何时结束"
-
99% 的二分查找都可以统一写成
while (left < right)。 -
这种写法意味着循环结束时,
left必然等于right。这个唯一的交集点,就是我们苦苦寻找的答案。 -
例外情况 :如果是简单的查找某个具体的数,允许
left == right时再判断一次,可以写成while (left <= right),但这种方式很难处理复杂的边界(如求左边界),且容易因为right = mid - 1导致right变成-1越界。
3. 逼近方向?(决定区间如何收缩)
根据你要找的是"第一个满足条件的"还是"最后一个满足条件的",分为两大流派:
-
向左逼近(找左边界/第一个值):当条件满足时,试图去寻找更左边的答案。
-
向右逼近(找右边界/最后一个值):当条件满足时,试图去寻找更右边的答案。
4. mid 的取值:决定了"偏向"
假设区间只剩两个元素:left = 1, right = 2。
-
如果是向左逼近 :我们用的是
right = mid。如果mid向下取整(mid = 1),即便执行了right = mid,区间变成了[1, 1],正常收缩。如果mid向上取整(mid = 2),执行right = mid,区间还是[1, 2],就会陷入死循环 。因此向左逼近必须向下取整。 -
如果是向右逼近 :我们用的是
left = mid。如果mid向下取整(mid = 1),执行left = mid,区间还是[1, 2],就会陷入死循环 。如果mid向上取整(mid = 2),执行left = mid,区间变成了[2, 2],正常收缩。因此向右逼近必须向上取整。 -
防溢出写法 :所有
mid都应该写成left + (right - left) / 2或left + (right - left + 1) / 2,避免left + right超出int范围。
5. 循环结束的状态:确定"返回值"
-
如果写的是
while(left < right),结束状态永远是left == right。 -
此时返回值可能有以下几种:
-
返回
left(或right)。 -
返回
left + 1或right - 1(比如找插入位置时,如果所有元素都小于target,需要返回size)。
-
-
如果有必要,在返回前,还需要检查
nums[left]是否真的等于target。
代码四大维度的联动机制(核心法则)
| 维度 | 向左逼近(寻找左边界) | 向右逼近(寻找右边界) | 核心解释 |
|---|---|---|---|
while 条件 |
left < right |
left < right |
统一使用左闭右开或左闭右闭区间,结束时 left == right。 |
mid 取值 |
left + (right - left) / 2 (向下取整) |
left + (right - left + 1) / 2 (向上取整) |
这是防死循环的铁律。 |
满足条件时 (如 nums[mid] >= target) |
right = mid (保留 mid) |
left = mid (保留 mid) |
因为 mid 可能是答案,绝不能跳过它。 |
不满足条件时 (如 nums[mid] < target) |
left = mid + 1 (舍弃 mid) |
right = mid - 1 (舍弃 mid) |
因为 mid 绝对不是答案,果断跳过。 |
| 结束状态 | left == right,指向第一个满足条件的位置。 |
left == right,指向最后一个满足条件的位置。 |
此时只需检查该位置是否真正满足题目要求即可。 |
识别信号
信号一:数据具备"二段性"或"单调性"
这是使用二分的物理前提 。数据不需要全局有序,但必须能根据某个条件被一刀切成两半。
-
显式的有序(或部分有序)
-
信号 :题目直接告诉你数组是"升序"、"降序"、"非递减"的,或者是一个"旋转排序数组"(如
[4,5,6,7,0,1,2])。 -
例子 :标准的二分查找、搜索旋转排序数组中的最小值。
-
-
隐式的单调性(函数关系)
-
信号 :题目中存在一个变量
x,随着x的增大,结果f(x)呈现单调递增或递减的趋势。虽然数组不是现成的,但答案所在的"值域"是有序的。 -
例子 :求
x的平方根。随着整数i增大,i * i是单调递增的。所以我们可以在值域[0, x]上进行二分。
-
-
局部的二段性(双调数组)
-
信号:数据先增后减,或者先减后增。存在一个唯一的"峰值"或"谷值"。
-
例子:山脉数组找峰顶。虽然整体无序,但峰值左侧全是上坡,右侧全是下坡,天然具备二段性。
-
信号二:问题目标是寻找"边界"或"极值"
当你看到题目要求寻找**"第一个"、"最后一个"、"最大/最小的满足条件的值"**时,这就是强烈的二分信号。
-
寻找边界
-
信号 :在排序数组中查找元素的第一个和最后一个位置;寻找第一个大于等于
target的位置;寻找最后一个小于target的位置。 -
解释:这类问题本质上是寻找满足条件的"左边界"或"右边界",这正是二分算法(特别是左右逼近模板)最擅长的领域。
-
-
最大化最小值 / 最小化最大值
-
信号:题目要求"在满足某种约束的情况下,使得某个值尽可能大/尽可能小"。(这类问题通常被称为"二分答案")。
-
解释 :这类问题通常很难直接求解,但如果你给一个猜测的答案
mid,你可以很容易地写出一个check(mid)函数来判断这个答案是否可行。因为可行性随着mid的变化具有单调性,所以可以对"答案的值域"进行二分。
-