二分查找详解:从普通二分到左右边界

目录

前言

一、什么是二分查找?

二、普通二分查找

三、普通二分的执行逻辑

[四、mid 的计算方式](#四、mid 的计算方式)

五、左边界二分

[1. 左边界二分要找什么?](#1. 左边界二分要找什么?)

[2. 左边界二分代码](#2. 左边界二分代码)

[3. 左边界二分的移动逻辑](#3. 左边界二分的移动逻辑)

[4. 左边界二分为什么用偏左 mid?](#4. 左边界二分为什么用偏左 mid?)

[5. 左边界二分模板](#5. 左边界二分模板)

六、右边界二分

[1. 右边界二分要找什么?](#1. 右边界二分要找什么?)

[2. 右边界二分代码](#2. 右边界二分代码)

[3. 右边界二分的移动逻辑](#3. 右边界二分的移动逻辑)

[4. 右边界二分为什么用偏右 mid?](#4. 右边界二分为什么用偏右 mid?)

[5. 右边界二分模板](#5. 右边界二分模板)

[七、查找第一个和最后一个 target](#七、查找第一个和最后一个 target)

[1. 查找第一个等于 target 的位置](#1. 查找第一个等于 target 的位置)

[2. 查找最后一个等于 target 的位置](#2. 查找最后一个等于 target 的位置)

八、二分查找的复杂度分析

九、为什么二分比普通遍历快?

十、二分的空间复杂度

十一、左右边界二分总结

十二、总结


前言

二分查找是算法中非常经典的基础内容。

它的思想很简单:

每次取中间位置,然后根据判断结果排除一半范围。

但是二分真正容易出错的地方,不是思想,而是代码边界。

比如:

复制代码
while (left <= right)

还是:

复制代码
while (left < right)

mid 是这样写:

复制代码
int mid = left + (right - left) / 2;

还是这样写:

复制代码
int mid = left + (right - left + 1) / 2;

找左边界时为什么是:

复制代码
right = mid;

找右边界时为什么又是:

复制代码
left = mid;

这些问题都和二分的搜索区间、边界更新方式有关。

本文会从普通二分开始,逐步介绍:

复制代码
1. 什么是二分查找
2. 普通二分怎么写
3. mid 为什么要这样计算
4. 左边界二分怎么理解
5. 右边界二分怎么理解
6. 二分复杂度为什么是 O(log n)

一、什么是二分查找?

二分查找适用于有序数组,或者具有单调性的问题。

比如有一个升序数组:

复制代码
int a[] = {1, 3, 5, 7, 9, 11, 13};

如果要查找 11,可以先看中间值 7

因为:

复制代码
11 > 7

所以 11 不可能在 7 的左边,只可能在右边。

于是左半部分可以直接排除。

这就是二分的核心:

复制代码
每次排除一半不可能的范围。

所以二分查找的前提是:

复制代码
有序性,或者单调性。

如果数据没有顺序,也没有单调规律,就不能直接使用二分。


二、普通二分查找

普通二分用于在有序数组中查找某个值是否存在。

代码如下:

复制代码
int binarySearch(int a[], int n, int target) {
    int left = 0;
    int right = n - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;

        if (a[mid] == target) {
            return mid;
        } else if (a[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }

    return -1;
}

这里使用的是闭区间:

复制代码
[left, right]

也就是说,leftright 都是当前搜索范围内的有效下标。

所以循环条件是:

复制代码
while (left <= right)

当:

复制代码
left == right

时,区间里还有一个元素需要判断。

当:

复制代码
left > right

时,说明搜索区间已经为空,查找失败。


三、普通二分的执行逻辑

普通二分每次取中间位置:

复制代码
int mid = left + (right - left) / 2;

然后比较 a[mid]target

如果:

复制代码
a[mid] == target

说明找到了,直接返回 mid

如果:

复制代码
a[mid] < target

说明 mid 以及 mid 左边的元素都小于目标值,答案只可能在右边。

所以更新:

复制代码
left = mid + 1;

如果:

复制代码
a[mid] > target

说明 mid 以及 mid 右边的元素都大于目标值,答案只可能在左边。

所以更新:

复制代码
right = mid - 1;

普通二分适合查找某个值是否存在。

但是如果数组中有重复元素,它不保证找到第一个或者最后一个。

例如:

复制代码
int a[] = {1, 3, 3, 3, 5, 7};

查找 3 时,普通二分可能返回下标 123

如果要找第一个 3,就需要左边界二分。

如果要找最后一个 3,就需要右边界二分。


四、mid 的计算方式

很多人会这样写 mid

复制代码
int mid = (left + right) / 2;

这个写法在数学上没有问题,但在代码中可能出现整数溢出。

比如:

复制代码
left = 2000000000;
right = 2100000000;

此时:

复制代码
left + right

可能超过 int 的范围。

所以更推荐写成:

复制代码
int mid = left + (right - left) / 2;

它先计算:

复制代码
right - left

再加回 left,可以避免 left + right 过大。

这个写法得到的是偏左中点。

例如:

复制代码
left = 0;
right = 5;

那么:

复制代码
mid = 2;

如果想得到偏右中点,可以写成:

复制代码
int mid = left + (right - left + 1) / 2;

此时:

复制代码
left = 0;
right = 5;
mid = 3;

所以:

复制代码
int mid = left + (right - left) / 2;

是偏左中点。

复制代码
int mid = left + (right - left + 1) / 2;

是偏右中点。

偏左中点和偏右中点,在左右边界二分中非常重要。


五、左边界二分

1. 左边界二分要找什么?

左边界二分不是直接找 target,而是找:

复制代码
第一个 >= target 的位置

对于升序数组来说,可以按照 target 把数组分成两部分:

复制代码
< target | >= target

左边是小于 target 的部分。

右边是大于等于 target 的部分。

左边界二分要找的,就是右半部分的第一个位置。

例如:

复制代码
int a[] = {1, 3, 3, 3, 5, 7, 9};
int target = 3;

可以看成:

复制代码
1 | 3  3  3  5  7  9
  ↑
左边是 < target
右边是 >= target

所以第一个 >= 3 的位置是下标 1


2. 左边界二分代码

复制代码
int lowerBound(int a[], int n, int target) {
    if (n == 0) return -1;

    int left = 0;
    int right = n - 1;

    while (left < right) {
        int mid = left + (right - left) / 2;

        if (a[mid] >= target) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    return a[left] >= target ? left : -1;
}

这个函数返回的是:

复制代码
第一个 >= target 的下标

如果不存在,就返回 -1


3. 左边界二分的移动逻辑

左边界二分要找的是:

复制代码
第一个 >= target 的位置

所以判断条件是:

复制代码
a[mid] >= target

如果:

复制代码
a[mid] >= target

说明 mid 已经进入了右半部分。

也就是说:

复制代码
mid 可能是答案
答案也可能在 mid 左边

所以更新:

复制代码
right = mid;

这里不能写成:

复制代码
right = mid - 1;

因为 mid 本身可能就是第一个 >= target 的位置,不能丢掉。

如果:

复制代码
a[mid] < target

说明 mid 还在左半部分。

也就是说:

复制代码
mid 和 mid 左边都小于 target
它们都不可能是答案

所以更新:

复制代码
left = mid + 1;

4. 左边界二分为什么用偏左 mid?

左边界二分中,mid 要这样计算:

复制代码
int mid = left + (right - left) / 2;

这是偏左中点。

原因是左边界二分里面会出现:

复制代码
right = mid;

当区间只剩两个元素时:

复制代码
left = 3;
right = 4;

使用偏左中点:

复制代码
mid = 3;

如果:

复制代码
a[mid] >= target

执行:

复制代码
right = mid;

区间变成:

复制代码
[3, 3]

如果:

复制代码
a[mid] < target

执行:

复制代码
left = mid + 1;

区间变成:

复制代码
[4, 4]

无论哪种情况,区间都会缩小。

但是如果左边界二分错误地使用偏右中点:

复制代码
int mid = left + (right - left + 1) / 2;

当:

复制代码
left = 3;
right = 4;

时:

复制代码
mid = 4;

如果此时执行:

复制代码
right = mid;

区间还是:

复制代码
[3, 4]

范围没有变化,就可能死循环。

所以左边界二分要使用偏左中点。


5. 左边界二分模板

复制代码
while (left < right) {
    int mid = left + (right - left) / 2;

    if (a[mid] >= target) {
        right = mid;
    } else {
        left = mid + 1;
    }
}

它对应的数组划分是:

复制代码
< target | >= target

目标是找:

复制代码
右半部分的第一个位置

六、右边界二分

1. 右边界二分要找什么?

右边界二分不是直接找 target,而是找:

复制代码
最后一个 <= target 的位置

对于升序数组来说,可以按照 target 把数组分成两部分:

复制代码
<= target | > target

左边是小于等于 target 的部分。

右边是大于 target 的部分。

右边界二分要找的,就是左半部分的最后一个位置。

例如:

复制代码
int a[] = {1, 3, 3, 3, 5, 7, 9};
int target = 3;

可以看成:

复制代码
1  3  3  3 | 5  7  9
           ↑
左边是 <= target
右边是 > target

所以最后一个 <= 3 的位置是下标 3


2. 右边界二分代码

复制代码
int upperBoundLastLE(int a[], int n, int target) {
    if (n == 0) return -1;

    int left = 0;
    int right = n - 1;

    while (left < right) {
        int mid = left + (right - left + 1) / 2;

        if (a[mid] <= target) {
            left = mid;
        } else {
            right = mid - 1;
        }
    }

    return a[left] <= target ? left : -1;
}

这个函数返回的是:

复制代码
最后一个 <= target 的下标

如果不存在,就返回 -1


3. 右边界二分的移动逻辑

右边界二分要找的是:

复制代码
最后一个 <= target 的位置

所以判断条件是:

复制代码
a[mid] <= target

如果:

复制代码
a[mid] <= target

说明 mid 还在左半部分。

也就是说:

复制代码
mid 可能是答案
答案也可能在 mid 右边

所以更新:

复制代码
left = mid;

这里不能写成:

复制代码
left = mid + 1;

因为 mid 本身可能就是最后一个 <= target 的位置,不能丢掉。

如果:

复制代码
a[mid] > target

说明 mid 已经进入了右半部分。

也就是说:

复制代码
mid 和 mid 右边都大于 target
它们都不可能是答案

所以更新:

复制代码
right = mid - 1;

4. 右边界二分为什么用偏右 mid?

右边界二分中,mid 要这样计算:

复制代码
int mid = left + (right - left + 1) / 2;

这是偏右中点。

原因是右边界二分里面会出现:

复制代码
left = mid;

当区间只剩两个元素时:

复制代码
left = 3;
right = 4;

如果使用偏左中点:

复制代码
int mid = left + (right - left) / 2;

那么:

复制代码
mid = 3;

如果执行:

复制代码
left = mid;

区间还是:

复制代码
[3, 4]

范围没有变化,就会死循环。

所以右边界二分必须使用偏右中点:

复制代码
int mid = left + (right - left + 1) / 2;

这样当:

复制代码
left = 3;
right = 4;

时:

复制代码
mid = 4;

如果:

复制代码
a[mid] <= target

执行:

复制代码
left = mid;

区间变成:

复制代码
[4, 4]

如果:

复制代码
a[mid] > target

执行:

复制代码
right = mid - 1;

区间变成:

复制代码
[3, 3]

无论哪种情况,区间都会缩小,不会死循环。


5. 右边界二分模板

复制代码
while (left < right) {
    int mid = left + (right - left + 1) / 2;

    if (a[mid] <= target) {
        left = mid;
    } else {
        right = mid - 1;
    }
}

它对应的数组划分是:

复制代码
<= target | > target

目标是找:

复制代码
左半部分的最后一个位置

七、查找第一个和最后一个 target

有了左边界和右边界二分,就可以解决重复元素的问题。

1. 查找第一个等于 target 的位置

找第一个等于 target,可以转化为:

复制代码
找第一个 >= target 的位置

然后判断这个位置是否真的等于 target

代码如下:

复制代码
int firstEqual(int a[], int n, int target) {
    if (n == 0) return -1;

    int left = 0;
    int right = n - 1;

    while (left < right) {
        int mid = left + (right - left) / 2;

        if (a[mid] >= target) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    return a[left] == target ? left : -1;
}

例如:

复制代码
int a[] = {1, 3, 3, 3, 5, 7};

查找第一个 3,返回下标 1


2. 查找最后一个等于 target 的位置

找最后一个等于 target,可以转化为:

复制代码
找最后一个 <= target 的位置

然后判断这个位置是否真的等于 target

代码如下:

复制代码
int lastEqual(int a[], int n, int target) {
    if (n == 0) return -1;

    int left = 0;
    int right = n - 1;

    while (left < right) {
        int mid = left + (right - left + 1) / 2;

        if (a[mid] <= target) {
            left = mid;
        } else {
            right = mid - 1;
        }
    }

    return a[left] == target ? left : -1;
}

例如:

复制代码
int a[] = {1, 3, 3, 3, 5, 7};

查找最后一个 3,返回下标 3


八、二分查找的复杂度分析

二分查找的效率很高,原因在于它每次都会把搜索范围缩小一半。

假设一开始有 n 个元素。

第一次查找后,范围变成:

复制代码
n / 2

第二次查找后,范围变成:

复制代码
n / 4

第三次查找后,范围变成:

复制代码
n / 8

继续下去,第 k 次查找后,范围变成:

复制代码
n / 2^k

当搜索范围缩小到只剩 1 个元素时,查找基本结束。

也就是说:

复制代码
n / 2^k = 1

两边同时乘以 2^k,得到:

复制代码
n = 2^k

所以:

复制代码
k = log₂n

这里的 k 就是二分查找大概需要执行的次数。

因此,二分查找的时间复杂度是:

复制代码
O(log n)

九、为什么二分比普通遍历快?

普通遍历是一个一个查找。

如果数组长度是 1000000,最坏情况下可能需要查找:

复制代码
1000000 次

而二分查找每次排除一半范围。

大概只需要:

复制代码
log₂1000000 ≈ 20

也就是说,面对一百万个有序数据,二分查找大约二十次左右就可以完成查找。

这就是二分查找比普通遍历快很多的原因。


十、二分的空间复杂度

如果使用循环写法,二分只需要几个变量:

复制代码
int left;
int right;
int mid;

没有额外数组,也没有递归调用栈。

所以空间复杂度是:

复制代码
O(1)

如果使用递归写法,函数会不断递归调用,产生调用栈。

递归深度和二分次数一样,大约是:

复制代码
log₂n

所以递归写法的空间复杂度是:

复制代码
O(log n)

实际写代码时,更推荐使用循环写法。


十一、左右边界二分总结

左边界二分:

复制代码
目标:
找第一个 >= target 的位置

数组划分:
< target | >= target

判断条件:
a[mid] >= target

满足条件:
right = mid

不满足条件:
left = mid + 1

mid 写法:
int mid = left + (right - left) / 2;

循环条件:
while (left < right)

右边界二分:

复制代码
目标:
找最后一个 <= target 的位置

数组划分:
<= target | > target

判断条件:
a[mid] <= target

满足条件:
left = mid

不满足条件:
right = mid - 1

mid 写法:
int mid = left + (right - left + 1) / 2;

循环条件:
while (left < right)

十二、总结

普通二分用于查找某个值是否存在。

模板如下:

复制代码
while (left <= right) {
    int mid = left + (right - left) / 2;

    if (a[mid] == target) {
        return mid;
    } else if (a[mid] < target) {
        left = mid + 1;
    } else {
        right = mid - 1;
    }
}

左边界二分用于查找第一个满足条件的位置。

可以理解为:

复制代码
< target | >= target

找右半部分的第一个位置。

模板如下:

复制代码
while (left < right) {
    int mid = left + (right - left) / 2;

    if (a[mid] >= target) {
        right = mid;
    } else {
        left = mid + 1;
    }
}

右边界二分用于查找最后一个满足条件的位置。

可以理解为:

复制代码
<= target | > target

找左半部分的最后一个位置。

模板如下:

复制代码
while (left < right) {
    int mid = left + (right - left + 1) / 2;

    if (a[mid] <= target) {
        left = mid;
    } else {
        right = mid - 1;
    }
}

最后记住三句话:

复制代码
普通二分:找某个值是否存在。

左边界二分:找第一个 >= target 的位置,用偏左 mid。

右边界二分:找最后一个 <= target 的位置,用偏右 mid。

二分查找的时间复杂度是:

复制代码
O(log n)

循环写法的空间复杂度是:

复制代码
O(1)

二分看起来简单,但真正的重点在于边界控制。只要想清楚数组如何被 target 分成两部分,以及自己要找哪一部分的边界,二分就会清晰很多。

相关推荐
wayz111 小时前
Volume:PVO(百分比成交量震荡指标)技术指标详解
算法·金融·数据分析·量化交易·特征工程
毕竟是shy哥1 小时前
PromptHash:基于亲和提示协同学习的自适应哈希检索跨模态算法
学习·算法·哈希算法
甄心爱学习1 小时前
【项目实训(个人12)】
人工智能·python·算法
团象科技1 小时前
走访近百支出海技术团队后的海外云计算资源选型实操观察
大数据·人工智能·算法
勤自省2 小时前
吴恩达机器学习课程实验:线性回归模型入门(课后实验)
人工智能·算法·机器学习·回归·线性回归
ChillCoding2 小时前
更新中:C++ STL库,查找排序(基础算法),数据结构,数学算法,竞赛相关基础
数据结构·c++·算法
智者知已应修善业2 小时前
【51单片机使用IO组赋值方法实现无源蜂鸣器响时LED12亮不响时34亮】2024-3-7
c++·经验分享·笔记·算法·51单片机
珊瑚里的鱼2 小时前
【动态规划】按摩师
算法·动态规划
Fms_Sa2 小时前
贪心算法-背包问题
算法·贪心算法·c#