【原创】深入理解二分查找

一、前言

​ 二分查找 Binary Search 可谓是数据结构中最基本、最早接触的算法之一。其思想特别直观: 每次确定要探查的目标在搜索区间的左侧还是右侧, 然后不断缩小探查区间直至检查完毕, 最后返回结果。

​ 然而,将这一简单直观的思想落实到代码实现上却往往存在诸多细节需要注意,稍有不慎就有可能陷入死循环,或遗漏某些边界条件;再加上各类资料上的二分法实现方式又不尽相同,仅凭死记硬背,很容易因遗忘细节而导致代码错漏百出。

​ 因此,本文尝试剖析二分查找代码的方方面面,让读者完全理解代码中每一个细节的用意,从而在手写二分法时能够信手拈来,胸有成竹。

二、代码结构

java 复制代码
int binarySearch(int[] nums, int target) {
    // 1.循环初始状态,区间开闭定义
    while (2.循环条件) {
       // 3.计算中间位置

       // 4.根据中值大小、目标的位置(第一个还是最后一个)决定搜索方向,缩小区间
    }

	// 5.输出
}

二分法代码写法各异,但基本结构如上,一共5步,咱们逐一探究一番。

2.1 循环初始状态

​ 这一步的主要工作是确定搜索区间的开闭定义,虽然看起来简单,但它却对后面所有步骤都有重要的影响。

​ 搜索区间两个端点我们记leftright,都可以开或闭,因此一共有四种组合:

左端点 右端点 符号表示 初始值 (len = nums.length)
[left, right) left = 0, right = len
[left, right] left = 0, right = len - 1
(left, right] left = -1, right= len - 1
(left, right) left = -1, right = len

​ 选哪种定义都可以,但必须始终如一的维持这个定义不变(Loop Invariant),其中的微妙差别,可在后面的步骤中细细品味。

2.2 循环条件

​ 这一步的目的是确定循环结束的条件(或者说允许进入循环搜索的条件)保证能够完整 搜索整个数组,乍看之下可以有两种选择:1.搜索区间为空时停止2.搜索区间只剩一个元素时停止

​ 看起来在区间只有一个元素时停止比较方便,因为结束循环时直接就找到了目标位置。但如果你真的试过就能明白,其实一点儿也不方便,因为这个目标位置可不一定在当前位置,也可能在上次匹配成功的位置。所以后面的讨论我们总是选择搜索区间为空时停止

​ 结合区间的开闭定义不难直接推断出区间为空时left和right的数量关系,再取反即while的循环条件:

区间定义 区间为空时 循环条件
[left, right) left == right left < right
[left, right] left > right (或者 left = right+1) left <= right
(left, right] left == right left < right
(left, right) left+1 == right left+1 < right

​ 举例说明:在[left, right),当left == right时区间为空,因此逻辑取反后 left != right就是循环条件,又因为初始时 left <= right,两者结合起来也就等价于 left < right

2.3 计算中间值

​ 这一步一般来说只需要注意避免下标计算时的整数溢出,也有一些利用位运算的奇巧淫技,读者可自行搜索参考。

例如:

java 复制代码
int mid = left + (right - left) / 2
例外情况

​ 对于区间(left, right],使用上面的公式计算mid会导致进入死循环,例如:

java 复制代码
int[] nums = new int[] {1, 3, 3, 3, 5, 7, 11};
int target = 3;
// 在搜索第一个匹配的位置时就会进入死循环

​ 这是因为计算时整除是下取整,产生的效果是mid总是取的中间靠左的那个位置,而(left, right]在只有一个元素时计算 mid = left,这是一个区间外的无效位置。

​ 解决起来也简单,就是改用下面公式,使得在mid时偏向后面一个位置。

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

​ 进一步探索一下为什么只有(left, right]才会出现这种情况,我们看下表:

区间定义 区间只剩一个元素时端点的数量关系 唯一有效值的位置
[left, right) left + 1 == right left
[left, right] left == right left = right
(left, right] left + 1 == right right
(left, right) left + 2 == right left + 1 = right -1
  • 对于区间(left, right),当只剩一个元素时:mid = left + (right - left) / 2 ==> mid = left + 1这是一个区间内的正常位置,所以不存在问题。
  • 至于[left, right)[left, right]没有问题就更加明显了,因为它的左端点是包含的。

2.4 根据中值和target的大小关系缩小搜索区间

​ 我们以数据存在重复非递减排列为例。

​ 考虑两种常见的要求:查找第一个匹配target的位置、查找最后一个匹配target的位置。

2.4.1 查找第一个匹配target的位置

搜索策略:中值大于等于 目标值时区间向移动。

[left, right)为例说明:

java 复制代码
// nums[mid] = 3 > target,因为数据是非递减,只能向左搜索
int[] nums = new int[] { 1,2,3,3,3,5}; int target = 2;

// nums[mid] = 3 == target,但我们是为了查找第一个匹配的,所以还是要向左搜索
int[] nums = new int[] { 1,2,3,3,3,5}; int target = 3;

合并这两种情况就得到了 nums[mid] >= target时要左移区间,其余情况右移。

PS:不用担心我们在移动区间时把目标target移出了搜索区间,因为我们的目的就是探查所有可能的位置,到循环结束时,结果肯定就在区间外了。

​ 在明确了判断条件的前提下,我们看一下左移区间的方式(动右端点)。再次强调:移动区间时必须维持初始的端点开闭定义 ,并且必须排除当前的mid(否则会进入死循环)。

区间定义 左移(动右端点)
[left, right) 由于右端点是不包含,所以直接赋值:right = mid
[left, right] 由于右端点是包含,所以新的右端点得在mid前一位,因此right=mid-1
(left, right] 由于右端点是包含,所以新的右端点得在mid前一位,因此right=mid-1
(left, right) 由于右端点是不包含,所以直接赋值:right = mid

类似的,右移区间的方式:

区间定义 右移(动左端点)
[left, right) 由于左端点是包含,所以赋值mid的后一个位置:left = mid+1
[left, right] 由于左端点是包含,所以赋值mid的后一个位置:left = mid+1
(left, right] 由于左端点是不包含,所以直接赋值:left = mid
(left, right) 由于左端点是不包含,所以直接赋值:left = mid

PS:如果还没有弄懂为什么要这么赋值,参考附录中的"区间的左侧和右侧"。

总结一下

区间定义 缩小区间
[left, right) if (nums[mid] >= target) { right = mid; } else { left = mid + 1; }
[left, right] if ( nums[mid] >= target) { right = mid - 1; } else { left = mid + 1; }
(left, right] if ( nums[mid] >= target) { right = mid - 1; } else { left = mid; }
(left, right) if ( nums[mid] >= target) { right = mid; } else { left = mid; }

可以看到,不管我们怎么定义区间的开闭,缩小区间的判断都相同:nums[mid] >= target

2.4.2 查找最后一个匹配target的位置

搜索策略:中值小于等于 目标值时区间向移动

[left, right)为例说明:

java 复制代码
// nums[mid] = 3 < target,因为数据是非递减,只能向右搜索
int[] nums = new int[] { 1,2,3,3,3,5}; int target = 4;

// nums[mid] = 3 == target,但我们是为了查找最后一个匹配的,所以还是要向右搜索
int[] nums = new int[] { 1,2,3,3,3,5}; int target = 3;

合并这两种情况就得到了 nums[mid] <= target时要移区间。

端点的移动方式,跟2.4.1中列出的方式完全一致。这里只列出完整逻辑:

区间定义 缩小区间
[left, right) if (nums[mid] <= target) { left = mid + 1; } else { right = mid; }
[left, right] if ( nums[mid] <= target) { left = mid+1; } else { right = mid-1; }
(left, right] if ( nums[mid] <= target) { left = mid; } else { right = mid - 1; }
(left, right) if ( nums[mid] <= target) { left = mid; } else { right = mid; }

2.5 输出

到这一步,已经退出循环,该收获果实了。依据要求不同,我们在2.4中使用了不同的移动策略,因此结果位置也存在差别:

输出 结果所在位置
第一个匹配的位置 由于我们尽量向左 移动,当循环终止时,target只可能 在区间的右侧
最后一个匹配的位置 由于我们尽量向右 移动,循环终止时,target只可能 在区间的左侧

在这一步我们只需要考虑结果是在区间左侧还是右侧(也可能不存在,此时返回-1),然后根据区间的定义取值就行,如下表:

区间定义 循环终止时的数量关系 左侧下标 右侧下标
[left, right) left == right left-1, right-1 right, left
[left, right] left > right (等价于 left=right+1) left-1, right right+1, left
(left, right] left == right left-1, right-1 right+1, left+1
(left, right) left+1 == right left, right-1 right, left+1

说明:

  1. 左侧下标右侧下标中的第一个值是我们根据区间定义得到的位置,取左侧端点就用left的表达式,右侧就用right的表达式,第二个值是我们根据循环退出时的left,right数量关系换算得到的。

  2. 有一些资料说使用比如说[left, right]可以简化代码,因为直接使用 left 就可以取到结果,初听可能不明所以,但结合文章上面的分析,其实就好理解了。

    例如:在区间[left, right],我们要取右侧的下标,本来应该使用 right+1,但存在数量关系left=right+1,所以也可以用left

好了,最后一步:返回结果:如果结果下标的所在值确实等于target就返回下标,否则就返回-1。但仍然有一点需要注意,循环结束时结果下标可能会移动到数组有效范围之外,这时需要先判断下标有效性,再做值的判断。

例如:

java 复制代码
int idx = right + 1;
return idx >= 0 && idx < nums.length && nums[idx] == target ? idx : -1;

至此,二分法代码剖析完毕,希望对你有所帮助。

附录-二分法的变种写法1

相信大家在初学二分法的时候都写过在循环中的等值判断,当找到了target就立即返回的代码吧。如下:

java 复制代码
int binaraySearch(int[] nums, int target) {
    ...
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 找到了目标值立即返回
                return mid;
            } else if (nums[mid] < target) {
                left = mid;
            } else {
                right = mid - 1;
            }
        }
    ...
}

这种写法有稍有局限:

  1. 数据得是不重复、或者不要求找到第一个/最后一个位置
  2. 由于写在第一个判断,而平均来看大部分情况下可能都是不等于target的,所以理论上速度会稍慢,可以把这个分支调整到else中。

当然,没有绝对的好坏。

附录-二分法的变种写法2

每次循环都检查是否找到了target,然后记录位置,循环结束后直接返回这个位置。

java 复制代码
public int search(int[] nums, int target) {
    int left = -1, right = nums.length - 1, last = -1;

    while (left < right) {
        int mid = left + (right - left + 1) / 2; // 注意这里取中点的方式,防止整型溢出
        if (nums[mid] < target) {
            left = mid;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else {
            // 找到了目标值,继续向左搜索第一个出现的位置
            last = mid;
            right = mid - 1;
        }
    }

    return last;
}

这种写法的优势很明显,循环结束后直接返回,省心省事,唯一美中不足的是比较和赋值次数相对较多,一般情况下推荐使用。

附录-区间的左侧和右侧

  1. 左闭右开区间 [i, j):
    • 左侧第一个下标: i-1
    • 右侧第一个下标: j
  2. 左闭右闭区间 [i, j]:
    • 左侧第一个下标: i-1
    • 右侧第一个下标: j+1
  3. 左开右闭区间 (i, j]:
    • 左侧第一个下标: i
    • 右侧第一个下标: j+1
  4. 左开右开区间 (i, j):
    • 左侧第一个下标: i
    • 右侧第一个下标: j

简单总结:对于位置 e

包含/非包含e 左侧邻接点 右侧邻接点
包含 [] e - 1 e + 1
非包含 () e e

附录-参考代码

java 复制代码
// 左闭右闭区间查找第一个匹配的
public int search(int[] arr, int target) {
    int i = 0, j = arr.length - 1;
    // 当区间为空时(i>j)停止,取反就是循环条件
    while (i <= j) {
        int mid = i + (j - i) / 2;
        // 中值大于等于目标值时区间向左移动
        if (arr[mid] >= target) {
            j = mid - 1;
        } else {
            i = mid + 1;
        }
    }

    // [i,j]的右侧下标是j+1, 循环终止时满足:i=j+1,所以也可以用i表示
    return i < arr.length && arr[i] == target ? i : -1;
}

// 左闭右闭区间查找最后一个匹配的
public int search(int[] arr, int target) {
    int i = 0, j = arr.length - 1;
    // 当区间为空时(i=j+1)停止,取反就是循环条件
    while (i <= j) {
        int mid = i + (j - i) / 2;
        // 中值小于等于目标值时区间向右移动
        if (arr[mid] <= target) {
            i = mid + 1;
        } else {
            j = mid - 1;
        }
    }

    // [i,j]的左侧下标是i-1, 循环终止时满足:i=j+1, 所以也可以用j表示
    // return i - 1 < arr.length && i - 1 >= 0 && arr[i - 1] == target ? i - 1 : -1;
    return j < arr.length && j >= 0 && arr[j] == target ? j : -1;
}

// 左闭右开区间查找第一个匹配的
public int search(int[] arr, int target) {
    int i = 0, j = arr.length;
    // 当区间为空时(i==j)停止,取反就是循环条件
    while (i < j) {
        int mid = i + (j - i) / 2;
        // 中值大于等于目标值时区间向左移动
        if (arr[mid] >= target) {
            j = mid;
        } else {
            i = mid + 1;
        }
    }

    // [i, j)的右侧下标是j,循环终止时满足:i==j, 因此也可以用i表示
    return i < arr.length && arr[i] == target ? i : -1;
}

// 左闭右开区间查找最后一个匹配的
public int search(int[] arr, int target) {
    int i = 0, j = arr.length;
    // 当区间为空时(i==j)停止,取反就是循环条件
    while (i < j) {
        int mid = i + (j - i) / 2;
        // 中值小于等于目标值时区间向右移动
        if (arr[mid] <= target) {
            i = mid + 1;
        } else {
            j = mid;
        }
    }
 
    // 区间[i, j)的左侧下标就是i-1,循环终止时满足:i==j,因此也可以用j-1表示
    // return i - 1 < arr.length && i - 1 >= 0 && arr[i - 1] == target ? i - 1 : -1;
    return j - 1 < arr.length && j - 1 >= 0 && arr[j - 1] == target ? j - 1 : -1;
}
相关推荐
古希腊掌管学习的神几秒前
[机器学习]sklearn入门指南(1)
人工智能·python·算法·机器学习·sklearn
波音彬要多做2 分钟前
41 stack类与queue类
开发语言·数据结构·c++·学习·算法
程序员老冯头2 小时前
第十五章 C++ 数组
开发语言·c++·算法
AC使者7 小时前
5820 丰富的周日生活
数据结构·算法
cwj&xyp7 小时前
Python(二)str、list、tuple、dict、set
前端·python·算法
xiaoshiguang311 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡11 小时前
【C语言】判断回文
c语言·学习·算法
别NULL11 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇11 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
yuanbenshidiaos13 小时前
C++----------函数的调用机制
java·c++·算法