C++算法刷题--二分查找法
摘要
这篇刷题日记整理二分查找法在 8 道 LeetCode 题目中的实际应用,涵盖从最基础的有序数组找值,到可重复数组找端点、旋转数组找最小值、山脉数组找峰值、缺失数定位等进阶场景。核心难度不在写二分本身,而在于为每道题建立正确的查找边界------即循环不变量与 mid 取值方向如何配合。每题附了解题思路,重点解释指针移动策略、特判条件及其原因。
二分查找法简介
二分查找的核心前提:序列具有有序性(或可证明的单调性)。每次根据中点值与目标的比较,将搜索区间折半,时间复杂度稳定在 O(log n),空间 O(1)。
三个基本要素:
- 循环不变量 :保证答案始终在
[left, right]区间内,这是每次指针移动的依据 - mid 的取值方向 :
- 偏左:
mid = left + (right - left) / 2(向下取整) - 偏右:
mid = left + (right - left + 1) / 2(向上取整) - 两者在区间长度为 2 时行为不同,必须与指针移动方式配套使用,否则死循环
- 偏左:
- 收敛条件 :
while(left < right)收敛到单点left == right;while(left <= right)收敛到空区间left > right
两类典型边界模板:
| 边界类型 | 含义 | 指针移动规则 | mid 取值 |
|---|---|---|---|
| 最左边界 | 第一个 >= target 的位置 |
nums[mid] >= target → right = mid,否则 left = mid + 1 |
偏左 |
| 最右边界 | 最后一个 <= target 的位置 |
nums[mid] > target → right = mid - 1,否则 left = mid |
偏右 |
关键易错点:
- 没有精确匹配元素时(LC35、LC69),找的是"插入位置",循环终止后
[left, right]的实际含义需结合具体不变量判断 - 山脉数组(LC852):搜索区间取
[1, n-2]是为了避免与arr[0]或arr[n-1]比较时越界,且两端不可能为峰顶 - 旋转数组(LC153):以
nums[right]为参照值,将"找旋转分界点"转化为普通的最左边界查找
力扣704.二分查找
题解:
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)
return mid;
else if(nums[mid]<target)
left=mid+1;
else right=mid-1;
}
if(nums[left]==target)
return left;
else return -1;
}
}
题目解析及解题思路:
题目本质:在有序无重复数组中找 target 的下标。二分查找利用的就是有序性:中点值小于 target,说明 target 只可能在右半边;大于 target 则只可能在左半边,每次把区间砍一半,最终收敛。本题解的查找边界是"找值"型:mid 取偏左,找到就立即 return mid;找不到时用 left=mid+1 / right=mid-1 把中点排除掉;循环结束 left==right 收敛后,还需要补一次判断 numsleft==target,成立返回 left,否则返回 -1------这个"收敛后再校验"的步骤是必要的,防止 target 恰好落在收敛点上被漏掉。时间复杂度 O(log n),空间 O(1)。
力扣34在排序数组中查找元素的第一个和最后一个位置
题解:
cpp
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target)
{
if(nums.size()==0)
return {-1,-1};
int begin=0;
int end=0;
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)
begin=left;
else return {-1,-1};
right=nums.size()-1;
while(left<right)
{
int mid=left+(right-left+1)/2;
if(nums[mid]>target)
right=mid-1;
else left=mid;
}
if(nums[right]==target)
end=right;
return {begin,end};
}
};
题目解析及解题思路:
题目要求有序可重复数组中 target 的左右端点。核心思路是两次二分,各找一个查找边界。第一次找"第一个值不小于 target 的位置"(最左边界):numsmid<target 时 left=mid+1,numsmid>=target 时 right=mid,区间收敛到左端点;收敛后若不相等于 target,说明数组里没有 target,直接返回 {-1,-1}。第二次从刚才的左端点出发,找"最后一个值不大于 target 的位置"(最右边界):此时 mid 要取偏右((right-left+1)/2),numsmid>target 时 right=mid-1,否则 left=mid。易错点:左右边界要套两个不同的模板,找左端点时若把 mid 取偏右,区间长度为 2 时左右指针会互跳导致死循环。
力扣35搜索插入位置
题解:
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+1)/2;
if(nums[mid]<=target)
left=mid;
else right=mid-1;
}
if(nums[left]>=target)
return left;
else return left+1;
}
};
题目解析及解题思路:
题目本质是"找数组中第一个值不小于 target 的位置":该位置的值等于 target 就直接插在那,比 target 大就插在它左边一位。查找边界是"最左边界":numsmid<=target 时 left=mid(mid 本身可能就是边界,要保留在区间里),否则 right=mid-1,mid 需取偏右防止死循环。循环结束后 left 就是候选位置:若 numsleft>=target,说明是相等情况,返回 left;否则左边都比 target 小,返回 left+1。这个模式和 C++ STL 的 lower_bound 完全一致,凡是"看似二分、实际可能没有精确相等元素"的题都可以套这个边界。
力扣69.x的平方根
题解:
cpp
class Solution {
public:
int mySqrt(int x)
{
if(x==0)
return 0;
if(x==1)
return 1;
int left=1,right=x;
long long mid=0;
while(left<right)
{
mid=left+(right-left+1)/2;
if(mid*mid<=x)
left=mid;
else right=mid-1;
}
if(mid*mid<=x)
return mid;
else return mid-1;
}
};
题目解析及解题思路:
题目要求找"平方不超过 x 的最大整数",即 midmid<=x 能成立的最大 mid,是典型的"满足条件的位置最右端点"型查找边界。mid 取偏右,满足条件时 left=mid,不满足时 right=mid-1,循环结束后 left 就是最大的满足条件候选。两个坑点:一是 x==0 和 x==1 要直接返回,尤其 x==1 时若不特判,区间为 1,1,循环体一次都不执行,mid 还是初始值 0,最后校验 midmid<=x 会错误地返回 0;二是 x 较大时 mid*mid 可能超出 int 范围,mid 要声明成 long long 防溢出。
力扣852山脉数组的峰顶索引
题解:
cpp
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr)
{
int left=1,right=arr.size()-2;
while(left<right)
{
int mid=left+(right-left+1)/2;
if(arr[mid]>arr[mid-1])
left=mid;
else right=mid-1;
}
return left;
}
};
题目解析及解题思路:
山脉数组"先严格递增再严格递减",峰顶就是递增到递减的分界点。二分找分界:取 mid 看斜率,arrmid>arrmid-1 说明 mid 还在上坡,峰顶在 mid 处或右侧,left=mid;否则 mid 已到达或越过转折,峰顶在 mid 处或左侧,right=mid-1。搜索区间取 1, n-2:一方面要和 arrmid-1 比较,防止 mid=0 越界,另一方面两端保证不是峰顶,可以直接排除。区间收敛后 left 就是峰顶下标。这个"往上走就向右、往下走就向左"的思想也是 LC162 的基础。
力扣162寻找峰值
题解:
cpp
class Solution {
public:
int findPeakElement(vector<int>& nums)
{
int left=0,right=nums.size()-1;
if(right==0)
return right;
if(nums[left]>nums[left+1])
return left;
if(nums[right]>nums[right-1])
return right;
while(left<right)
{
int mid=left+(right-left+1)/2;
if(nums[mid]>nums[mid-1])
left=mid;
else right=mid-1;
}
return left;
}
};
题目解析及解题思路:
与 LC852 思路相同,但数组不保证是山脉数组------只保证"存在峰值且相邻元素不相等"------而且两端也可能是峰值(题目假设越界处为负无穷)。所以先排除三种特殊情况:只有一个元素直接返回;首元素大于次元素,峰值在 0;末元素大于次末元素,峰值在末尾。剩下在中间区间二分,思想一致:numsmid>numsmid-1 说明还在上坡,峰值必在 mid 或其右侧,left=mid;否则 right=mid-1。区间收敛后 left 就是一个峰值。注意题目只要求找到"一个"峰值而非"最高"的,二分收敛到任意上坡点即可。
力扣153寻找旋转排序数组中的最小值
题解:
cpp
class Solution {
public:
int findMin(vector<int>& nums)
{
int left=0,right=nums.size()-1,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];
}
};
题目解析及解题思路:
旋转排序数组(无重复)本质上是两个单调区间拼接:某处...最大, 最小...某处。关键是找到一个可靠的参照值,把"找分界"转化为普通的边界查找:本解取 x=numsright(旋转时它是最小值,未旋转时它是最大值),问题变成找"第一个值不大于 x 的位置"。numsmid>x 说明 mid 在左半段,最小值在它右边,left=mid+1;否则 mid 在右半段,最小值在 mid 处或左边,right=mid。区间收敛后 numsleft 就是最小值。用"中点值与右端值比较"这个技巧,无论数组旋转还是没旋转都能正确定位分界点。
力扣LCR 173点名
题解:
cpp
class Solution {
public:
int takeAttendance(vector<int>& records)
{
int left=0,right=records.size()-1;
while(left<right)
{
int mid=left+(right-left)/2;
if(records[mid]>mid)
right=mid;
else left=mid+1;
}
if(records[left]==left)
return left+1;
else return left;
}
};
题目解析及解题思路:
records 是 0,1,2,... 自然数序列缺了一个数,数组长度恰好比总个数少一。关键不变量:设缺失的数为 k,则 k 之前的位置都有 recordsi==i,k 及之后的位置都有 recordsi=i+1>i,问题就转化为找"第一个 recordsi>i 的位置"。查找边界:recordsmid>mid 说明缺失数在 mid 或更左,right=mid;否则缺失数在右边,left=mid+1。循环收敛到 left 后:若 recordsleft==left,说明缺失的是末尾那个数,返回 left+1;否则缺失的就是 left。这个思路和"找值不小于下标的第一个位置"是同一类边界查找。
任何的建议和心得欢迎交流,我们共同进步!