算法 --二分查找

什么是二分算法?(核心思想)

二分算法的核心思想是**"分而治之"(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(log⁡n) 。

使用二分的绝对前提

很多人有一个误区,认为"只要数组是有序的,就能用二分"。这句话对,但不全面。

二分查找真正的绝对前提是:数据具有"二段性"(或单调性)。

什么是二段性?

在当前的搜索区间内,你必须能找到一个"判定条件",使得这个条件把区间一分为二:一半全部满足 这个条件,另一半全部不满足这个条件。

只要具备了二段性,不管数组是不是完全有序,都可以使用二分。 相反,如果一个数组是乱序的,你找不到这样一个判定条件,二分就失效了。

二分算法的两大基本形态

在实际应用中,二分算法主要解决两类问题,对应两种形态:

  1. 查找特定值(存在性判断)

    • 目标:判断目标值在不在,在的话返回它的索引。

    • 特点:一旦找到 arr[mid] == target,就可以直接返回,不需要继续找了。

  2. 查找边界(极值查找)

    • 目标:找到满足条件的第一个 位置(左边界),或者满足条件的最后一个位置(右边界)。

    • 特点:即使 arr[mid] == target,也不能停止,还要继续向左(或向右)收缩区间,直到找到最边缘的那个位置。

二分算法的"细节陷阱"(为什么这么难写?)

二分算法的代码框架很短,通常只有五六行,但为什么极度容易写错?因为里面有四个变量在互相纠缠,一旦搭配错,就会导致死循环数组越界

  1. 循环条件 :是写 while (left < right) 还是 while (left <= right)

  2. 中值计算 :是写 mid = (left + right) / 2 还是 mid = (left + right + 1) / 2

  3. 左边界更新 :是写 left = mid 还是 left = mid + 1

  4. 右边界更新 :是写 right = mid 还是 right = mid - 1

逐题深度剖析细节陷阱

搜索插入位置题目 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] >= targetnums[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 的位置。此时这个位置就是插入位置。

x的平方根

题目 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 <= xmid * 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 的最大整数(即答案)。

山脉数组的峰顶索引题目 3:山脉数组的峰顶索引

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,指向唯一的峰值。

在排序数组中查找元素的第一个和最后一个位置

题目 4:在排序数组中查找元素的第一个和最后一个位置

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] >= targetnums[mid] < target

    • 原因 :我们要找第一个 等于 target 的位置。如果 nums[mid] >= target,说明 mid 有可能是答案(如果等于),或者答案在 mid 左边(如果大于)。我们绝不能放弃 mid 及其左侧,所以把 mid 归入"保留区间"。
  • 找右边界 :以 target 为界。分为 nums[mid] <= targetnums[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) / 2left + (right - left + 1) / 2,避免 left + right 超出 int 范围。

5. 循环结束的状态:确定"返回值"
  • 如果写的是 while(left < right),结束状态永远是 left == right

  • 此时返回值可能有以下几种:

    • 返回 left(或 right)。

    • 返回 left + 1right - 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,指向最后一个满足条件的位置。 此时只需检查该位置是否真正满足题目要求即可。

识别信号

信号一:数据具备"二段性"或"单调性"

这是使用二分的物理前提 。数据不需要全局有序,但必须能根据某个条件被一刀切成两半

  1. 显式的有序(或部分有序)

    • 信号 :题目直接告诉你数组是"升序"、"降序"、"非递减"的,或者是一个"旋转排序数组"(如 [4,5,6,7,0,1,2])。

    • 例子 :标准的二分查找、搜索旋转排序数组中的最小值

  2. 隐式的单调性(函数关系)

    • 信号 :题目中存在一个变量 x,随着 x 的增大,结果 f(x) 呈现单调递增或递减的趋势。虽然数组不是现成的,但答案所在的"值域"是有序的。

    • 例子 :求 x 的平方根。随着整数 i 增大,i * i 是单调递增的。所以我们可以在值域 [0, x] 上进行二分。

  3. 局部的二段性(双调数组)

    • 信号:数据先增后减,或者先减后增。存在一个唯一的"峰值"或"谷值"。

    • 例子:山脉数组找峰顶。虽然整体无序,但峰值左侧全是上坡,右侧全是下坡,天然具备二段性。

信号二:问题目标是寻找"边界"或"极值"

当你看到题目要求寻找**"第一个"、"最后一个"、"最大/最小的满足条件的值"**时,这就是强烈的二分信号。

  1. 寻找边界

    • 信号 :在排序数组中查找元素的第一个和最后一个位置;寻找第一个大于等于 target 的位置;寻找最后一个小于 target 的位置。

    • 解释:这类问题本质上是寻找满足条件的"左边界"或"右边界",这正是二分算法(特别是左右逼近模板)最擅长的领域。

  2. 最大化最小值 / 最小化最大值

    • 信号:题目要求"在满足某种约束的情况下,使得某个值尽可能大/尽可能小"。(这类问题通常被称为"二分答案")。

    • 解释 :这类问题通常很难直接求解,但如果你给一个猜测的答案 mid,你可以很容易地写出一个 check(mid) 函数来判断这个答案是否可行。因为可行性随着 mid 的变化具有单调性,所以可以对"答案的值域"进行二分。

相关推荐
圣保罗的大教堂1 小时前
leetcode 1674. 使数组互补的最少操作次数 中等
leetcode
拂拉氏1 小时前
【项目实现】 C++ vector实现(空间配置器+异常处理)
c++·vector·异常·空间配置器
qq_199886871 小时前
第8板块·第3节:设备间拷贝与点对点(P2P)传输
c++·人工智能·gpu算力·cuda
Omics Pro1 小时前
新型条件传输模型!虚拟细胞扰动预测
数据库·人工智能·算法·机器学习·自然语言处理
爱奥尼欧2 小时前
【C++】map、set 的底层是什么?红黑树五条性质、插入旋转到模拟实现
开发语言·数据结构·c++
黄金龙PLUS2 小时前
5个800比特大状态置换算法的设计与分析
算法·网络安全·密码学·哈希算法·同态加密
政企项目老覃2 小时前
大模型 Agent 自主任务编排:电商客服地址解析从 12% 失败率到 2.1% 的落地复盘
人工智能·程序人生·算法
汉克老师2 小时前
CSP-J 2026 初赛试题解析(第三部分:完善程序题(第二题))精讲
c++·csp-j·小学生·学c++编程
kukubuzai2 小时前
双指针系列二--末尾篇(3道题)
c++·算法·leetcode