二分查找与变体实践

172 阅读6分钟

本篇仅分析二分查找的细节问题,在阅读前请确保已经对“二分查找”概念与基本应用有初步了解。

二分查找的三个常用搜索区间

搜索区间终止条件左右指针初始赋值左右指针赋值循环条件
左闭右闭[l,r]相错终止l = 0 r = nums.length - 1l = mid + 1 r = mid - 1l <= r
左闭右开[l,r)相交终止l = 0 r = nums.lengthl = mid + 1 r = midl < r
左开右开(l,r)相邻终止l = -1 r = nums.lengthl = mid r = midl + 1 < r

搜索区间是指在实践中需要考虑的范围。

以上文为例,左闭右开区间时,右边界指针所指向的元素不在考虑范围之内(即已经事实上排除)。

非一般实践中还有左开右闭等搜索区间,本文不再赘述。

LeetCode704.二分查找 为例:

左闭右闭

public int search(int[] nums, int target) {
        int l = 0;
        int r = nums.length - 1;
        while (l <= r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] < target) {
                l = mid + 1;
            } else if (nums[mid] > target) {
                r = mid - 1;
            } else {
                return mid;
            }
        }
        return -1;
    }

左闭右开

public int search(int[] nums, int target) {
        int l = 0;
        int r = nums.length;
        while (l < r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] > target) {
                r = mid;
            } else if (nums[mid] < target) {
                l = mid + 1;
            } else {
                return mid;
            }
        }
        return -1;
    }

左开右开

public int search(int[] nums, int target) {
        int l = -1;
        int r = nums.length;
        while (l + 1 < r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] > target) {
                r = mid;
            } else if (nums[mid] < target) {
                l = mid;
            } else {
                return mid;
            }
        }
        return -1;
    }

二分查找变体

查找左闭合边界

左闭合边界是指在有序数组中,被搜索数的第一次出现下标

例如,在数组[5,7,7,8,8,10]中,7 的左边界是 1; 8 的左边界是 3。

而根据搜索区间,我们可以得到上述三种写法的查找左边界。

下面以左闭右闭为例:

private int getLeft(int[] nums, int target) {
    int l = 0;
    int r = nums.length - 1;
    // int leftBoard = -2;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] < target) {
            l = mid + 1;
        } else {
            r = mid - 1;
            // leftBoard = r;
        }
    }
    // 检查出界情况
    if (l >= nums.length || nums[l] != target) {
        return -1;
    }
    return l;
    // return leftBoard;
}

在实践中,一般将nums[mid] >= target一起讨论,但实际上两者原理不一。

这其中核心问题是,将 mid 排除出搜索区间后,根据 l 返回答案的正确性能否获得保证。答案是肯定的。

对于左右闭合边界,需要重点讨论:为什么当 nums[mid] >= target 时,r = mid - 1,其中主要问题是为什么要将等于的情况与大于一起讨论,如何证明其正确性

从搜索区间来看,每次搜索时,搜索闭区间[l, r]。nums[mid] >= target 时,mid 可能是左边界,也可能不是左边界,此时要迁移右指针,把 mid 排除出搜索区间。

例如,现有数组:[5,7,7,8,8,10]Nl表示左指针位置;Nr表示右指针位置;Nmid表示搜索中间target=7,观察可得,result=1此时启动搜索:[5l,7,7mid,8,8,10r]:r左移此时,满足上文num[mid]==target,显然此时midresult持续搜索:[5l,mid,7r,7,8,8,10]:l右移[5,7r,l,mid,7,8,8,10]:r左移此时两指针相错,循环结束。例如,现有数组:\\ [5,7,7,8,8,10]\\ N^{l}表示左指针位置;N^{r}表示右指针位置;N^{mid}表示搜索中间\\ 令 target = 7,观察可得,result = 1\\ 此时启动搜索:\\ [5^{l},7,7^{mid},8,8,10^{r}]: r左移\\ 此时,满足上文 num[mid]==target,显然此时mid \neq result\\ 持续搜索:\\ [5^{l,mid},7^{r},7,8,8,10]: l右移\\ [5,7^{r,l,mid},7,8,8,10]: r左移\\ 此时两指针相错,循环结束。\\

根据相错终止的思路,l 会停留在 r 的右侧,即 l = r + 1,当 mid 是搜索区间的左边界,而暂时地将 mid 排除出搜索区间后,最终 r 会在 mid - 1 不再移动,最终在终止时 l 会落在当时被排除的 mid 位置,可断言:l 是左边界。正确性可以保证。

讨论:当mid已指向result时,继续移动指针是否会影响正确性。在判断条件nums[mid]>=target,对r左移,则意味着:l永远不会越过result再如,现有数组:[3,5,7,8,10]Nl表示左指针位置;Nr表示右指针位置;Nmid表示搜索中间target=7,观察可得,result=2此时启动搜索,[3l,5,7mid,8,10r]:r左移继续搜索:[3l,mid,5r,7,8,10]:l右移[3,5l,r,mid,7,8,10]:r左移[3,5r,7l,8,10]:循环终止,l重新落在正确位置上。故当l为返回条件时,正确性可以保障。讨论:当mid已指向result时,继续移动指针是否会影响正确性。\\ 在判断条件 nums[mid] >= target,对r左移,则意味着:l永远不会越过result\\ 再如,现有数组:\\ [3,5,7,8,10]\\ N^{l}表示左指针位置;N^{r}表示右指针位置;N^{mid}表示搜索中间\\ 令 target = 7,观察可得,result = 2\\ 此时启动搜索,[3^{l},5,7^{mid},8,10^{r}]: r左移\\ 继续搜索: [3^{l,mid},5^{r},7,8,10]: l右移\\ [3,5^{l,r,mid},7,8,10]: r左移\\ [3,5^{r},7^{l},8,10]: 循环终止,l重新落在正确位置上。\\ 故当l为返回条件时,正确性可以保障。

相应的,可以推理如果写判断条件 nums[mid] == target 时,l = mid + 1,此时 l 必定会越过正确结果 。所以我们要根据右指针返回结果:

private int getLeft(int[] nums, int target) {
        int l = 0;
        int r = nums.length - 1;
        while (l <= r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] < target) {
                l = mid + 1;
            } else {
                r = mid - 1;
            }
        }
        // 检查出界情况
        if (r + 1 == nums.length || nums[r + 1] != target) {
            return -1;
        }
        return r + 1;
    }

但此种方法过于繁琐,所以使用 l 返回即可。

左闭右开写法:

private int getLeft(int[] nums, int target) {
        int l = 0;
        int r = nums.length;
        while (l < r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] < target) {
                l = mid + 1;
            } else {
                r = mid;
            }
        }
        // 检查出界情况
        if (l >= nums.length || nums[l] != target) {
            return -1;
        }
        return l;
    }

查找右闭合边界

相似地,我们也可以反向推得到右边界写法:

左闭右闭写法:

private int getRight(int[] nums, int target) {
        int l = 0;
        int r = nums.length - 1;
        while (l <= r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] <= target) {
                l = mid + 1;
            } else {
                r = mid - 1;
            }
        }
        if (r == -1 || nums[r] != target) {
            return -1;
        }
        return r;
    }

左开右闭写法:

private int getRight(int[] nums, int target) {
        int l = 0;
        int r = nums.length;
        while (l < r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] <= target) {
                l = mid + 1;
            } else {
                r = mid;
            }
        }
        //  注意:因为这里是左闭右"开" 所以r指针指向的并不是实际的位置,r-1才是。
        if (r == 0 || nums[r - 1] != target) {
            return -1;
        }
        return r - 1;
    }

查找插入位置

LeetCode.35搜索插入位置 为例:

搜索插入位置实际上和查找左闭合边界非常类似,如果能搜索到与结果相等的下标,自然搜索到的位置就是插入位置。

如果数组中不存在与 target 相同的元素,那么该问题实际上变成了查找左开放边界

在实践中,实际上是将nums[mid] > targetnums[mid] < target分类讨论。

左闭右闭写法:

public int searchInsert(int[] nums, int target) {
        int l = 0;
        int r = nums.length - 1;
        while (l <= r) {
            int mid = l + (r - l) / 2;
            if (nums[mid] < target) {
                l = mid + 1;
            } else if (nums[mid] > target) {
                r = mid - 1;
            } else {
                return mid;
            }
        }
        return l;
    }

左闭右开写法:

public int searchInsert(int[] nums, int target) {
    int l = 0;
    int r = nums.length;
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] < target) {
            l = mid + 1;
        } else if (nums[mid] > target) {
            r = mid;
        } else {
            return mid;
        }
    }
    return l;
}

其他问题

中指针整型溢出问题

中指针溢出问题指的是在静态类型语言下,mid = (l + r) / 2可能会造成整型溢出,本质上是l + r大小超过整数范围。

在JDK下,该bug存在了 9 年之久。

一般有两种解决方案:

  • 改写为先减再加的方式:mid = l + (r - l) / 2。可以避免l + r溢出。
  • 右移替代除法,效率会高一点点:mid = l + ((r - l) >> 1)

特别的,在 JDK 中如此计算 mid:mid = (l + r) >>> 1

>>>是无符号右移运算符,与>>的区别是不考虑符号位,总是往左侧补0。l + r溢出的时候最高符号位从0变成了1,而用>>>又变成了0,所以可以解决溢出问题。

但是用这种写法要保证l + r >= 0,如果l + r为负数,高位补 0 就会得到错误的正数。

在一般情况下,l 与 r 代表下标,相加恒为正数,但部分题目的搜索的不是下标,而是数值,相加可能为 0,需要特别注意,例如:LeetCode.462最小操作次数使数组元素相等II

二分查找的二段适用性

二分查找不一定只能用在已经排序的数组(即:单调性)环境下,可以用于任何具有二段性的环境中。

例如:LeetCode.162寻找峰值

在该题中,只需要返回一个峰值,而可以思考如下:

给定一数组:[N0,N1,N2,N...,Nx1,Nx]假设结果为Nresult则有:N0<N1,时N0N1范围数值上升Nx1>Nx,时Nx1Nx范围数值下降则可断定:在N1Nx1范围内存在result(之一)此时获取mid,则有:Nmid1<Nmid,时Nmid1Nmid范围数值上升则代表在NmidNx1范围内存在result(之一)则可以继续搜索,直到搜索得到result给定一数组:[N_{0},N_{1},N_{2},N_{...},N_{x-1},N_{x}]\\ 假设结果为 N_{result}\\ 则有:\\ 当N_{0} < N_{1},时 N_{0} \sim N_{1} 范围数值上升\\ 当N_{x-1} > N_{x},时 N_{x-1} \sim N_{x} 范围数值下降\\ 则可断定:在N_{1} \sim N_{x-1}范围内存在result(之一)\\ 此时获取mid,则有:\\ 当N_{mid - 1} < N_{mid},时 N_{mid - 1} \sim N_{mid} 范围数值上升\\ 则代表在 N_{mid} \sim N_{x - 1}范围内存在result(之一)\\ 则可以继续搜索,直到搜索得到result。

代码如下:

public int findPeakElement(int[] nums) {
        if (nums.length == 1) return 0;
        if (nums[0] > nums[1]) return 0;
        if (nums[nums.length - 1] > nums[nums.length - 2]) return nums.length - 1;
        // [0,1] 上升,[len - 2,len - 1] 下降 二分搜索
        int l = 1;
        int r = nums.length - 2;
        while (l <= r) {
            int mid = (l + r) >>> 1;
            // 符合条件的峰值
            if (nums[mid] > nums[mid - 1] && nums[mid] > nums[mid + 1]) return mid;
            // [mid - 1, mid] 上升,搜索右边
            if (nums[mid] > nums[mid - 1]) l = mid + 1;
            // [mid - 1, mid] 下降,搜索左边
            else r = mid - 1;
        }
        return 0;
    }