二分查找——从入门到精通

文章目标#

通过阅读本文,可以彻底搞懂二分查找的基本原理及各种变体,可以独立完成下面的力扣题目

力扣题目 考查点
374. 猜数字大小 基本模板
69. x 的平方根 基本模板
278. 第一个错误的版本 查找左边界
153. 寻找旋转排序数组中的最小值 寻找旋转数组中的目标值(无重复元素)
154. 寻找旋转排序数组中的最小值 II 寻找旋转数组中的目标值(包含重复元素)
34. 在排序数组中查找元素的第一个和最后一个位置 查找左右边界

基本原理#

在计算机科学中,二分搜索又被成为半区间搜索,对数搜索或者二分chop,它用于在排序数列中找到目标值的位置。算法不断比较数列中间元素与目标值,

  1. 如果目标值与中间元素匹配,那直接返回中间的位置;
  2. 如果目标值比中间元素小,继续搜索低半边的数列;
  3. 如果目标值比中间元素大,那么继续搜索高半边

注意到每次在比较元素之后搜索的区间会减少一半(去掉目标值不可能在的那一半区间),所以在最坏的情况下,算法的复杂度是O(logn)。下面是二分搜索与线性搜索的比较示意图,以严格单调增且无重复元素的序列为例,查找目标值为37的序列值。

序列长度是17,

  1. 二分查找第一次寻找整个搜索区间的中间的index(从0开始计数)为(0 + 16) / 2 = 8的数字23,23比37小,所以更新查找区间为[9,16];
  2. 查找新区间的中间index = (9 + 16) /2 = 12,结果是数字41,比37大,更新查找区间为[9, 11];
  3. 查找新区间的中间index = (9 + 11) / 2 = 10,结果是数字31,比37大,继续更新查找区间为[11, 11],此时中间元素就是第11号元素,即37,找到目标值,查找结束。

根据以上算法步骤,很顺利就可以写出下面的伪代码,

// basic template
function binary_search(A, n, T) is
    L := 0
    R := n − 1
    while L ≤ R do
        m := floor((L + R) / 2)
        if A[m] < T then
            L := m + 1
        else if A[m] > T then
            R := m − 1
        else:
            return m
    return unsuccessful

使用上面的基本模板我们就可以解答文章开始列出的第一道题目了,374. 猜数字大小题目,给出二分查找的基本模板。这道题目就是最传统的猜大小的谜题,标准的解答模板的代码如下

/**
 * Forward declaration of guess API.
 * @param  num   your guess
 * @return 	     -1 if num is lower than the guess number
 *			      1 if num is higher than the guess number
 *               otherwise return 0
 * int guess(int num);
 */

class Solution {
public:
    int guessNumber(int n) {
        int left = 1;
        int right = n;

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

            if (rlt == 0) {
                return mid;
            }
			// detail 3
            if (rlt == -1) {
                right = mid - 1;
            }  else {
                left = mid + 1;
            }
        }
        return -1;
    }
};

关键技术细节#

Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky -- Donald Knuth

就像高德纳所说,二分法的思想简单且易于理解,但是二分法的细节却藏了很多坑,魔鬼就在细节中。为了透彻理解二分法的实现,有必要对上面的代码的下面3个技术细节(标记为detail注释的地方)进行深究。

循环的入口条件#

代码16行,为什么循环的入口条件是left <= right

无论何时[left,right]表示可能包含目标值的搜索区间

注意这是个左闭右闭的闭区间,单纯从数学角度出发,这个区间最短的长度就是1,也就是left=right的时候。如果left>right了,那[left,right]就是空集,这个集合肯定不包含目标值,也就没有继续搜索的必要了,所以循环退出。

中点计算#

为什么是mid = left + ((right - left) >> 1),也就是数学的floor((left+right)/2)?
这里的中点转换成数学表示就是

(1)mid=left+right2

其实,我们还有另外一种选择,将中间值定为

(2)mid=left+right2

第2种方式的中点值的选择是否可行?从后面的分析,其实是可行的。那这两个计算公式有什么区别?最大的区别在于right=left+1的时候,也就是搜索区间长度为2左右端点挨着的时候,如下图所示,下一次循环计算mid,第1个公式结果是mid=4,而第2个公式的结果是mid=5

指针相邻

,第1个公式mid=left+right2=left+left+12=left,而第2个公式mid=left+right2=left+left+12=left+0.5=left+1=right,请记住这个重要的结论。

当搜索区间的左右两端点挨着的时候(即right=left+1),floor函数计算的中点mid是left,ceil函数计算的中点mid是right

请记住这个特殊的区间情况,至于为何基础模板选择floor而没有选择ceil,在后面的变化类型一节很关键。

搜索区间调整#

在每次判断与target的差距之后,搜索区间为什么是这么调整的?
二分法另一个容易搞错的问题是区间调整,leftright好像可以选择mid的3个边界值mid,mid1,mid+1中的任意一个,其实调整的原则很简单,

  1. 排除target不可能存在的区间,保留可能存在的区间
  2. 保证任意判断分支为true时最后都可以跳出循环,尤其当right=left+1

先看第1条原则,基础模板中,根据mid的值与目标值的差距,搜索区间[left,right]的调整有3个判断分支:

  1. 如果和目标值相同A[mid]=A[target],直接跳出循环,返回mid(代码第21行);
  2. 如果比目标值大A[mid]>A[target],那么肯定target<mid,所以targetmid1,因此target位于区间[left,mid1]上,更新right=mid1
  3. 如果比目标值小A[mid]<A[target],那么肯定target>mid,所以targetmid+1,因此target位于区间[mid+1right]上更新left=mid+1

区间的调整仅仅保留了target可能存在的部分。

分支3的调整情况,看如下图示

考察第2条原则,是否可以退出循环?这个循环的退出条件是left>right。对照上面的3个判断分支分别为true的情况:

  1. 第1个判断分支直接退出循环,符合条件;
  2. 同理,第2个判断分支每次判断之后right在变小,所以必然在若干次之后比left小,直至退出循环
  3. 第3个判断分支每次调整之后left在变大,所以所以必然在若干次之后比right大,直至退出循环

所以随着区间的调整,必然会在某一步满足退出的条件。特别的,当right=left+1时,mid=left

  • 第2个判断分支,right=mid1=left1=right2<left=right1,跳出循环
  • 第3个判断分支,left=mid+1=left+1=right,再次进入循环,此时mid=left=rightleft=mid+1=right+1>right,可以看到在迭代2次之后也退出循环

第2条原则满足。


结合上面的分析,二分法的步骤包括:

A[mid]target的大小判断入手,判断它们的各种大小关系分支,确定每个判断分支的区间调整策略,需要满足下面的两条原则

  1. 排除target不可能存在的区间,保留可能存在的区间
  2. 保证任意判断分支为true时最后都可以跳出循环,尤其当right=left+1

深入探究#

上一小节讨论了3个关键技术细节,循环入口条件、中点计算和搜索区间调整,下面可以看到在满足上面的原则基础上,它们都可以变化。

分支合并#

当前的基础代码有3个判断分支,是否可以将3个分支合并成2个呢?下面的基础模板就可以。

class Solution {
public:
    int guessNumber(int n) {
        int left = 1;
        int right = n;

        while (left < right) { // 退出条件变了
            int mid = left + ((right - left) >> 1);
            // 调整策略的分支从3个变成了2个
            if (guess(mid) <= 0) {
                right = mid;
            }
            else {
                left = mid + 1;
            }
        }
		// 返回的条件也变了
        if (guess(left) == 0) {
            return left;
        }

        return -1;
    }
};

再仔细考察一下,上面的代码,3个地方发生变化(已经在注释种标出)依然可以通过测试,其实还是按照上面的2个原则来看。

  1. 每次调整区间,排除target不可能存在的区间,保留可能存在的区间

    重新分析之前的3个判断分支,比较特殊是第1点,如果A[mid]=A[target],那么mid=target,很明显

    • 判断分支1与判断分支2合并,当A[mid]A[target]targetmid,所以target可能位于[left,mid]区间,调整right=mid,注意此处rightmid1
    • 判断分支1与判断分支3合并,当A[mid]A[target]targetmid,所以target可能位于[mid,right]区间,调整left=mid,注意此处leftmid+1

    这两个策略看起来似乎都可以,结合其他两种情况,如果将3个分支合并为2个,应该有2种调整策略

    # solution 1,另一种正确的方案
    function binary_search_alternative(A, n, T) is
        L := 0
        R := n − 1
        while L < R do
            m := floor((L + R) / 2)
            if A[m] >= T then
                R := m
            else:
                L := m + 1
        if A[L] = T then
            return L
        return unsuccessful
    

    或者

    # solution 2,错误示范
    function binary_search_alternative(A, n, T) is
        L := 0
        R := n − 1
        while L < R do
            m := floor((L + R) / 2)
            if A[m] <= T then
                L := m
            else:
                R := m - 1
        if A[L] = T then
            return L
        return unsuccessful
    
  2. 保证任意判断分支为true时最后都可以跳出循环,尤其当right=left+1

    我们再看上面的两种伪代码,退出的条件是left>=right,直接考察left=right1的情况,如果在搜索的若干步之后搜索的区间变成下面的情况

    指针相邻

    下一步搜索的mid=4,按照方案2,如果此时

    1. 第2个判断分支为true,right=mid1=3<left,退出循环;
    2. 第1个判断分支为true(代码第7~8行),那么调整left=mid=4,再次进入之后会发现陷入死循环,核心原因在于中点的选择上,因为使用的是floor函数,导致left永远恒等于mid,换句或说,left永远不再增加,而且也进不到更新right的分支,陷入死循环,考察方案1就没有这种情况。

    所以方案2的结果是错的。

中点计算变更#

上面的方案2,因为不满足第2条原则而失败,那有没有办法通过改变其他部分而满足原则呢?第1个判断分支如果为true,当right=left+1时,根据代码第6行,mid=left,所以进入分支之后left=mid=left,那可以将第6行的代码改成下面这样,

# solution 3,solution 2的改造
function binary_search_alternative(A, n, T) is
    L := 0
    R := n − 1
    while L < R do
        m := ceil((L + R) / 2) # ceil取代floor函数
        if A[m] <= T then
            L := m
        else:
            R := m - 1
    if A[L] = T then
        return L
    return unsuccessful

再考察一下上面的搜索区间左右端点相邻的情况,下一次搜索的mid=5,无论走哪一个判断分支,最终left=right,跳出循环,所以对于第2条原则需要补充

可以通过调整中点的计算方式,满足原则2,从而避免死循环

判断条件变更#

需要注意,其他2个的模板的循环条件为left<right,当leftright时退出,如果left=right,那么搜索的区间只剩下一个元素,跳出循环之后别忘了要检查这个元素是否满足条件。如果最后的这个元素依然不是target,那么所有的元素也不是了,所以在最后我们加了一个判断,

    if A[L] = T then
        return L
    return unsuccessful

变种题目#

理解上面的分析过程之后,进入进阶版的二分法题目。

重复元素左边界#

参考278. 第一个错误的版本,从某个版本开始,版本就已经不可用了,但是在这个版本之前,所有的版本均可用,需要找出第一个不可用的版本。假如说,我们有100个版本,从第70个版本开始不用,那么怎么快速找到70呢?如下所示

题目要找到最左边第一个true对应的下标70,也是找到重复的true区间的左边界。初始化left=0,right=100,我们还是从判断分支入手

  1. 如果A[mid]=true,那么target可能位于[mid+1,right]区间,调整left=mid+1
  2. 如果A[mid]=falsemid可能是target,但是mid+1不会是target(因为我们要找的是最左边的那个FALSE),那么target可能位于[left,mid]区间,调整right=mid

结合中点的计算原则mid=floor((left+right)/2),判断right=left+1时候,2个判断分支最终都可以跳出循环。最终代码如下

// The API isBadVersion is defined for you.
// bool isBadVersion(int version);

class Solution {
public:
    int firstBadVersion(int n) {
        int left = 1;
        int right = n;

        while (left < right) {
            mid = left + ((right - left) >> 1);
            if (isBadVersion(mid) == false) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        
        return left;            
    }
};

更一般的,假如我们要查找序列[1,2,3,4,4,5,6,6,7,7,9,10,10,10]中的最左边的10的index,该如何处理?从分支判断入手,

  1. 如果A[mid]<10,那么10可能位于区间[mid+1,right]区间,调整left=mid+1
  2. 如果A[mid]=10,那么10可能位于区间[left,mid]区间,调整right=mid
  3. 如果A[mid]>10,那么10可能位于区间[left,mid1]区间,调整right=mid1

合并分支2和分支3,变成

  1. 如果A[mid]<10,那么10可能位于区间[mid+1,right]区间,调整left=mid+1
  2. 如果A[mid]10,那么10可能位于区间[left,mid]区间,调整right=mid

判断当left=right1时,两个分支都可以顺利退出。代码如下

class Solution {
public:
    int CheckVal(int n) {
        int left = 1;
        int right = n;

        while (left < right) {
            mid = left + ((right - left) >> 1);
            if (A[mid] < 10) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        if (A[left] == 10) {
            return left;  
        }
        return -1;       
    }
};

重复元素右边界#

假如我们要查找序列[1,2,3,4,4,5,6,6,7,7,9,10,10,10]中的最右边的10的index,该如何处理?从分支判断入手,

  1. 如果A[mid]<10,那么最右边的10可能位于区间[mid+1,right]区间,调整left=mid+1
  2. 如果A[mid]=10,这个A[mid]可能是最右边的10,也可能不是,但是A[mid1]肯定不是最右边的10了,所以目标值可能位于区间[mid,right]区间,调整left=mid
  3. 如果A[mid]>10,那么最右边的10可能位于区间[left,mid1]区间,调整right=mid1

合并分支1和分支2,变成

  1. 如果A[mid]10,那么10可能位于区间[mid,right]区间,调整left=mid
  2. 如果A[mid]>10,那么10可能位于区间[left,mid1]区间,调整right=mid1

判断当left=right1时,按照floor函数计算,第1个分支会陷入死循环,所以需要调整中点的计算方式为ceil,判断两个分支都可以顺利退出,所以代码如下

class Solution {
public:
    int CheckVal(int n) {
        int left = 1;
        int right = n;

        while (left < right) {
            mid = left + ((right - left) >> 1) + 1;
            if (A[mid] <= 10) {
                left = mid;
            } else {
                right = mid - 1;
            }
        }
        if (A[left] == 10) {
            return left;  
        }
        return -1;       
    }
};

旋转数组(无重复元素)#

来做文章目标中的153. 寻找旋转排序数组中的最小值这道题,具体看看示例2中的题目怎么做

示例2

具体的值和index的分布如下所示

  1. 初始化搜索范围,left=0,right=n1=6

  2. mid=3,从判断A[3]=7target的值入手,但是比较棘手的是target是多少呢(我们不知道最小值是0,仅仅知道这是一个旋转序列)?那我们根据什么判断target存在的可能区间呢?注意下面这幅图

这个旋转数组分为前后两个区间,前面区间每一个数字都比后面区间的数字大(因为没有重复数字),分别称为高半区间和低半区间。很明显,

  1. 最小值一定在低半区间,且位于低半区间的起始点上;
  2. 我们不知道低半区间的起始点在哪里,也不知道高半区间的终点在哪里;
  3. 我们可以根据当前元素与数组最后一个元素(想想为什么不是第一个元素)的大小确认出具体位于哪个半区间,如果A[id]>A[n1],那么在高半区间,否则在低半区间。

那这个和判断target所在区间有什么关系呢?如果mid位于高半区间,那么可以肯定最小值肯定不在[left,mid]中,更新left=mid+1,如果位于低半区间,可以肯定[mid+1,right]不可能是最小值,更新right=mid。于是有下面的代码

class Solution {
public:
    int findMin(vector<int>& nums) {
        if (nums.size() == 1) return nums[0];
        int left = 0;
        int right = nums.size() - 1;
        while (left < right) {
            int mid = left + ((right - left) >> 1);
            if (nums[mid] > nums[nums.size() - 1]) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return nums[left];
    }
};
  1. 判断是否在right=left+1时,每个分支可以跳出循环,可以👍,完毕。

旋转数组( 包含重复元素)左边界#

再来看154. 寻找旋转排序数组中的最小值 II - 力扣(LeetCode),这个题目与上面有点区别,就是它有重复元素,如下图

看起来可以按照上面的题目如法炮制,判断A[mid]A[n1]的大小,

  1. 如果A[mid]>A[n1]mid位于高半区间,则最小值肯定不在[left,mid]里面,更新left=mid+1

  2. 如果A[mid]<A[n1]mid位于低半区间,则最小值肯定不在[mid+1,right]里面,更新right=mid

  3. 如果A[mid]=A[n1],这个时候就说不清楚mid是在高区间还是低区间了。因为可能有下面的情况

    再仔细想想,其实在这一步,我们不需要知道我们位于哪个区间,我们要清楚mid跟最小值的index的关系,参考下面的图

    考虑数组中的最后一个元素A[right],在最小值右侧的元素,它们的值一定都小于等于A[right];而在最小值左侧的元素,它们的值一定都大于等于 A[right]。假定中点为pilot,比较A[pilot]A[right]的大小,可以间接判断出pilottarget的位置关系。

    第一种情况,A[pilot]<A[high],说明此时最小的点位于pilot的左边,所以更新right=pilot

    第二种情况,A[pilot]>A[high],说明此时最小的点位于pilot的右边,所以更新left=pilot+1

    第三种情况,A[pilot]=A[high],此时唯一可以确定的是最小值在high的左边,所以更新right=right1

则有如下的答案

class Solution {
public:
    int findMin(vector<int>& nums) {
        int low = 0;
        int high = nums.size() - 1;
        while (low < high) {
            int pivot = low + (high - low) / 2;
            if (nums[pivot] < nums[high]) {
                high = pivot;
            }
            else if (nums[pivot] > nums[high]) {
                low = pivot + 1;
            }
            else {
                high -= 1;
            }
        }
        return nums[low];
    }
};

考察right=left+1时候,三个分支都可以顺利跳出循环,搞定👍

参考资料#

  1. 二分查找、二分边界查找算法的模板代码总结 - SegmentFault 思否,给我很多启发的一篇文章
  2. Binary search algorithm,维基百科页面,英文版里面的内容很详尽
posted @   bugxch  阅读(360)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 2025年我用 Compose 写了一个 Todo App
· 张高兴的大模型开发实战:(一)使用 Selenium 进行网页爬虫
点击右上角即可分享
微信分享提示
主题色彩