再谈二分查找

270 阅读6分钟

前言

  去年暑假我写了一篇文章:一文弄懂二分查找,主要阐述了二分查找的一种写法及其对应的理解方法。

  那篇文章从发表到现在已经有七个月的时间了,这七个月的时间里,我在刷题的过程中,发现了之前那篇文章的些许疏漏之处,觉得有必要做一些补充,故提笔写了这篇文章。

上集回顾

  一文弄懂二分查找 主要提出了一种二分查找的写法及理解方法。如查找升序数组中第一个大于target的位置,我们可以采用下面的写法:

public static int myBinary(int[] nums,int target) {
    int low = 0,high = nums.length;
    while (low < high) {
        int mid = low + (high - low) / 2;
        if (nums[mid] <= target) {
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return low;
}

  回顾一下这种方法如何理解:

  • nums为升序数组,若if (nums[mid] <= target) 成立,则下标mid以及mid之前的元素肯定都是小于等于target的,故直接low = mid + 1;,那么low左边的元素全是不符合要求的;
  • nums为升序数组,若if (nums[mid] > target) 成立,那么mid以及mid之后的元素都是大于target的,由于找第一个大于target的元素,故high = mid;high即是目前符合条件的最小的下标值。
  • 按照这种思路不断二分,low不断增加,high不断减小,最终low == high时,即low,high左边都是不符合条件的元素,low,highlow,high右边都是符合条件的解,即此时的low,high就是符合条件的第一个解。
    若是在升序数组中,找第一个大于等于target的元素,可以直接将if (nums[mid] <= target) 改为if (nums[mid] < target) 即可。用这种low控制不可行解的边界,high控制可行解的边界,不断逼近直至lowhigh相等的思路,可以写出查找第一个xxx的正确代码。

存在的问题

  然而,在我刷题的过程中,发现了这种理解方法的一个疏漏之处,忘了是力扣哪个题了,当时题目抽象出来,就是要找升序数组中最后一个小于target的元素,鉴于之前总结的二分查找,我很容易写出了下面的代码:

public static int myBinary(int[] nums,int target) {
    int low = 0,high = nums.length;
    while (low < high) {
        int mid = low + (high - low) / 2;
        if (nums[mid] < target) {
            low = mid;
        }
        else {
            high = mid - 1;
        }
    }
    return low;
}

  这个代码和上面那个代码不一样的地方就是这里用high控制不可行解的边界,用low作为可行解的边界。看样子是没有问题的,然而大概只能过一半的用例,有些用例会超时。大伙也可以自行在IDE上试试:

public class Blog {
    public static int myBinary(int[] nums,int target) {
        int low = 0,high = nums.length;
        while (low < high) {
            int mid = low + (high - low) / 2;
            if (nums[mid] < target) {
                low = mid;
            }
            else {
                high = mid - 1;
            }
        }
        return low;
    }
    public static void main(String[] args) {
        int[] nums = new int[] {1, 2, 4, 4, 4, 6, 7};
        int target = 3;
        System.out.println(myBinary(nums,target));
    }
}

  比方说把数组初始化为[1, 2, 4, 4, 4, 6, 7]target = 3时,就会产生死循环:

image.png

原因探寻

  咱们走一遍那个例子:一开始,low为0,high为7,mid为3,那么经过一轮更新,low为0,high为2,mid为1,经过再一轮更新,low为1,high为2,mid为1,好了,发现问题了,此时,nums[mid]小于3,经过一轮更新之后,lowhighmid还是不变,程序僵持在这里了。

  我们还可以发现,并不是所有情况都会死循环的,比如你把数组改为[1, 4, 4, 4, 4, 6, 7]target = 3,这时候可以成功找到,因为在low为0,high为2,mid为1这一轮更新会将high减一个,达到了while的终止条件。

  那么啥时候会出现死循环的情况呢?不难发现,当某次更新之后,lowhigh相邻了,由于我们更新mid的语句为:int mid = low + (high - low) / 2;,此时,mid更新为low,由于在这个代码里,low控制可行解,故nums[mid]符合条件,再次进行low = mid,程序就卡死在这了。

  这种情况下,死循环的根本原因即,lowhigh相邻时,mid的计算指向了low,而low代表可行解的边界,nums[mid] < target肯定是成立的,这导致程序进入了更新可行解边界也就是low的代码分支:low = mid,这相当于没有更新,low,high,mid均和之前一样,程序会一直进行无效的死循环。

  改的方法也很简单了,lowhigh不相邻的时候,mid的更新是没啥问题的,咱们只要让lowhigh相邻时,mid指向high也就是不可行解边界就行了,这样不管是更新low还是更新high都可以到达终止条件,将mid的更新语句改为:int mid = high - (high - low) / 2即可;

一个疑问

  那有人问了,为啥找第一个大于XXX的元素的时候,那个代码就不会死循环呢?注意到本文开头的代码,这种情况下,low控制的是不可行解,high控制的可行解,而每次mid的更新为:int mid = low + (high - low) / 2;,当lowhigh相邻时,mid指向low这个不可行解的边界,所以不管nums[mid]target是何关系,highlow的更新均会导致while终止条件的到达,正确结束循环。

总结

  本文对之前的博客:一文弄懂二分查找 进行了补充,当利用lowhigh一个控制可行解边界,一个控制不可行解边界的思想进行二分查找代码编写时,要注意当lowhigh相邻时,对mid的更新应保证mid落在不可行解那一边,否则,会导致代码一直无效的更新可行解边界,导致程序的死循环。以下两套代码应配套使用(只针对作者的二分查找写法,并不是说二分查找只有这么写才是对的):

// 查找升序数组里第一个大于 target 的元素下标
public static int myBinary(int[] nums,int target) {
    int low = 0,high = nums.length;
    while (low < high) {
        // low 和 high 相邻时,保证 mid 落在不可行解那一边
        int mid = low + (high - low) / 2;
        if (nums[mid] <= target) {
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return low;
}
// 查找升序数组里最后一个小于等于 target 的元素下标
public static int myBinary(int[] nums,int target) {
    int low = 0,high = nums.length;
    while (low < high) {
        // low 和 high 相邻时,保证 mid 落在不可行解那一边
        int mid = high - (high - low) / 2;
        if (nums[mid] <= target) {
            low = mid;
        }
        else {
            high = mid - 1;
        }
    }
    return low;
}

  当然,在升序数组里查找最后一个小于等于target的元素也可以通过求在升序数组里找第一个大于target的元素下标之后再减1来得到。

P.S.

  我在掘金的上一篇博客:Is Redlock Safe? 一场关于 Redlock 的辩论 是今年 1 月 5 号发的,本篇博客离上一篇也过了七八十天了。这两个多月因为找暑期实习和搞毕设,弄得我比较焦虑,所以一直没静下心来写博客,这篇博客也是这个阴历年自己写的第一篇博客,也在这里许愿自己今年一切顺利。