有了这套模板,女友不再用担忧我刷不动 LeetCode 了

全文包含 12000+ 字、30 张高清图片,预计阅读时间为 40 分钟,强烈建议先收藏再仔细阅读。python

做者 | 李威程序员

整理 | 公众号:五分钟学算法算法

我的博客 | www.cxyxiaowu.com编程

来源 | www.liwei.party/数组


下面的动画以 「力扣」第 704 题:二分查找 为例,展现了使用这个模板编写二分查找法的通常流程。安全

binary-search-template-new.gif

如下“演示文稿”展现了本文所要讲解的主要内容,您能够只看这部分的内容,若是您还想看得更仔细一点,能够查看“演示文稿”以后的原文。bash

《十分好用的二分查找法模板》演示文稿

binary-search-template-1.png
binary-search-template-2.png
binary-search-template-3.png
binary-search-template-4.png
binary-search-template-5.png
binary-search-template-6.png
binary-search-template-7.png
binary-search-template-8.png
binary-search-template-9.png
binary-search-template-10.png
binary-search-template-11.png
binary-search-template-12.png
binary-search-template-13.png

(上面的“演示文稿”是对如下文字的归纳。)ide


一、导读

本文介绍了我这半年以来,在刷题过程当中使用“二分查找法”刷题的一个模板,包括这个模板的优势、使用技巧、注意事项、调试方法等。函数

虽然说是模板,但我不打算一开始就贴出代码,由于这个模板根本没有必要记忆,只要你可以理解文中叙述的知识点和注意事项,并加以应用(刷题),相信你会和我同样喜欢这个模板,而且认为使用它是天然而然的事情。测试

这个模板应该可以帮助你解决 LeetCode 带“二分查找”标签的常见问题(简单、中等难度)。

只要你可以理解文中叙述的知识点和注意事项,并加以应用(其实就是多刷题),相信你会和我同样喜欢这个模板,而且认为使用它是天然而然的事情。

二、历史上有关“二分查找法”的故事

二分查找法虽然简单,但写好它并无那么容易。咱们能够看看一些名人关于二分查找法的论述。

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

译:“虽然二分查找的基本思想相对简单,但细节可能会很是棘手”。来自维基百科 Binary_search_algorithm,请原谅本人可能很是不优雅的中文翻译。

  • 一样是高德纳先生,在其著做《计算机程序设计的艺术 第 3 卷:排序和查找》中指出:

二分查找法的思想在 1946 年就被提出来了。可是第 1 个没有 Bug 的二分查找法在 1962 年才出现。

(因时间和我的能力的关系,我没有办法提供英文原文,若是能找到英文原文的朋友欢迎提供一下出处,在此先谢过。)

听说这个 Bug 在 Java 的 JDK 中都隐藏了将近 10 年之后,才被人们发现并修复。

  • 《编程珠玑》的做者 Jon Bentley:

When Jon Bentley assigned binary search as a problem in a course for professional programmers, he found that ninety percent failed to provide a correct solution after several hours of working on it, mainly because the incorrect implementations failed to run or returned a wrong answer in rare edge cases.

译:当 JonBentley 把二分查找做为专业程序员课程中的一个问题时,他发现百分之九十的人在花了几个小时的时间研究以后,没有提供正确的解决方案,主要是由于错误的实现没法正确运行(笔者注:可能返回错误的结果,或者出现死循环),或者是不能很好地判断边界条件。

三、“传统的”二分查找法模板的问题

(1)取中位数索引的代码有问题

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

这行代码是有问题的,在 leftright 都比较大的时候,left + right 颇有可能超过 int 类型能表示的最大值,即整型溢出,为了不这个问题,应该写成:

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

事实上,int mid = left + (right - left) / 2right 很大、 left 是负数且很小的时候, right - left 也有可能超过 int 类型能表示的最大值,只不过通常状况下 leftright 表示的是数组索引值,left 是非负数,所以 right - left 溢出的可能性很小。

更好的写法是:

int mid = (left + right) >>> 1 ;
复制代码

缘由在后文介绍,请读者留意:

使用“左边界索引 + 右边界索引”,而后“无符号右移 1 位”是推荐的写法。

(2)循环能够进行的条件写成 while (left <= right) 时,在退出循环的时候,须要考虑返回 left 仍是 right,稍不注意,就容易出错

以本题(LeetCode 第 35 题:搜索插入位置)为例。

分析:根据题意并结合题目给出的 4 个示例,不难分析出这个问题的等价表述以下:

一、若是目标值(严格)大于排序数组的最后一个数,返回这个排序数组的长度,不然进入第 2 点。

二、返回排序数组从左到右,大于或者等于目标值的第 1 个数的索引

事实上,当给出数组中有不少数和目标值相等的时候,咱们返回任意一个与之相等的数的索引值均可以,不过为了简单起见,也为了方便后面的说明,咱们返回第 1 个符合题意的数的索引。

题目告诉你“排序数组”,其实就是在疯狂暗示你用二分查找法。 二分查找法的思想并不难,但写好一个二分法并不简单,下面就借着这道题为你们作一个总结。

刚接触二分查找法的时候,咱们可能会像下面这样写代码,我把这种写法容易出错的地方写在了注释里:

参考代码:针对本题(LeetCode 第 35 题)

// 公众号:五分钟学算法
public class Solution3 {

    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (nums[len - 1] < target) {
            return len;
        }

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

        while (left <= right) {
            int mid = (left + right) / 2;
            // 等于的状况最简单,咱们应该放在第 1 个分支进行判断
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 题目要咱们返回大于或者等于目标值的第 1 个数的索引
                // 此时 mid 必定不是所求的左边界,
                // 此时左边界更新为 mid + 1
                left = mid + 1;
            } else {
                // 既然不会等于,此时 nums[mid] > target
                // mid 也必定不是所求的右边界
                // 此时右边界更新为 mid - 1
                right = mid - 1;
            }
        }
        // 注意:必定得返回左边界 left,
        // 若是返回右边界 right 提交代码不会经过
        // 【注意】下面我尝试说明一下理由,若是你不太理解下面我说的,那是我表达的问题
        // 但我建议你不要纠结这个问题,由于我将要介绍的二分查找法模板,能够避免对返回 left 和 right 的讨论

        // 理由是对于 [1,3,5,6],target = 2,返回大于等于 target 的第 1 个数的索引,此时应该返回 1
        // 在上面的 while (left <= right) 退出循环之后,right < left,right = 0 ,left = 1
        // 根据题意应该返回 left,
        // 若是题目要求你返回小于等于 target 的全部数里最大的那个索引值,应该返回 right

        return left;
    }
}
复制代码

说明

一、当把二分查找法的循环能够进行的条件写成 while (left <= right) 时,在写最后一句 return 的时候,若是不假思索,把左边界 left 返回回去,虽然写对了,但能够思考一下为何不返回右边界 right 呢?

二、可是事实上,返回 left 是有必定道理的,若是题目换一种问法,你可能就要返回右边界 right,这句话不太理解没有关系,我也不打算讲得很清楚(在上面代码的注释中我已经解释了缘由),由于实在太绕了,这不是我要说的重点。

由此,我认为“传统二分查找法模板”使用的痛点在于:

传统二分查找法模板,当退出 while 循环的时候,在返回左边界仍是右边界这个问题上,比较容易出错。

那么,是否是能够回避这个问题呢?答案是确定的,答案就在下面我要介绍的“神奇的”二分查找法模板里。

四、“神奇的”二分查找法模板的基本思想

(1)首先把循环能够进行的条件写成 while(left < right),在退出循环的时候,必定有 left == right 成立,此时返回 left 或者 right 均可以

或许你会问:退出循环的时候还有一个数没有看啊(退出循环以前索引 left 或 索引 right 上的值)? 没有关系,咱们就等到退出循环之后来看,甚至通过分析,有时都不用看,就能肯定它是目标数值。

(何时须要看最后剩下的那个数,何时不须要,会在第 5 点介绍。)

更深层次的思想是“夹逼法”或者称为“排除法”。

(2)“神奇的”二分查找法模板的基本思想(特别重要)

“排除法”即:在每一轮循环中排除一半以上的元素,因而在对数级别的时间复杂度内,就能够把区间“夹逼” 只剩下 1 个数,而这个数是否是咱们要找的数,单独作一次判断就能够了。

“夹逼法”或者“排除法”是二分查找算法的基本思想,“二分”是手段,在目标元素不肯定的状况下,“二分” 也是“最大熵原理”告诉咱们的选择。

仍是 LeetCode 第 35 题,下面给出使用 while (left < right) 模板写法的 2 段参考代码,如下代码的细节部分在后文中会讲到,所以一些地方不太明白没有关系,暂时跳过便可。

参考代码 1:重点理解为何候选区间的索引范围是 [0, size]

public class Solution {

    public int searchInsert(int[] nums, int target) {
        # 返回大于等于 target 的索引,有多是最后一个
        int len = nums.length;

        if (len == 0) {
            return 0;
        }

        int left = 0;
        # 若是 target 比 nums里全部的数都大,则最后一个数的索引 + 1 就是候选值,所以,右边界应该是数组的长度
        int right = len;
    	 # 二分的逻辑必定要写对,不然会出现死循环或者数组下标越界
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
复制代码

参考代码 2:对因而否接在原有序数组后面单独判断,不知足的时候,再在候选区间的索引范围 [0, size - 1] 内使用二分查找法进行搜索。

public class Solution {

    // 只会把比本身大的覆盖成小的
    // 二分法
    // 若是有一连串数跟 target 相同,则返回索引最靠前的

    // 特例: 3 5 5 5 5 5 5 5 5 5
    // 特例: 3 6 7 8

    // System.out.println("尝试过的值:" + mid);
    // 1 2 3 5 5 5 5 5 5 6 ,target = 5
    // 1 2 3 3 5 5 5 6 target = 4


    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return -1;
        }
        if (nums[len - 1] < target) {
            return len;
        }
        int left = 0;
        int right = len - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // nums[mid] 的值能够舍弃
                left = mid + 1;
            } else {
                // nums[mid] 不能舍弃
                right = mid;
            }
        }
        return right;
    }

    public static void main(String[] args) {
        int[] nums = {1, 2, 3, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6};
        int target = 4;
        Solution2 solution2 = new Solution2();
        int searchInsert = solution2.searchInsert(nums, target);
        System.out.println(searchInsert);
    }
}
复制代码

五、细节、注意事项、调试方法

(1)前提:思考左、右边界,若是左、右边界不包括目标数值,会致使错误结果

例:LeetCode 第 69 题:x 的平方根

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

因为返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

分析:一个非负整数的平方根最小多是 0 ,最大多是它本身。 所以左边界能够取 0 ,右边界能够取 x。 能够分析得再细一点,但这道题没有必要,由于二分查找法会帮你排除掉不符合的区间元素。

例:LeetCode 第 287 题:寻找重复数

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。

分析:题目告诉咱们“其数字都在 1 到 n 之间(包括 1 和 n)”。所以左边界能够取 1 ,右边界能够取 n。

要注意 2 点

  • 若是 leftright 表示的是数组的索引,就要考虑“索引是否有效” ,即“索引是否越界” 是重要的定界依据;
  • 左右边界必定要包括目标元素,例如 LeetCode 第 35 题:“搜索插入位置” ,当 target 比数组中的最后一个数字还要大(不能等于)的时候,插入元素的位置就是数组的最后一个位置 + 1,即 (len - 1 + 1 =) len,若是忽略掉这一点,把右边界定为 len - 1 ,代码就不能经过在线测评。

(2)中位数先写 int mid = (left + right) >>> 1 ; 根据循环里分支的编写状况,再作调整

理解这一点,首先要知道:当数组的元素个数是偶数的时候,中位数有左中位数和右中位数之分。

  • 当数组的元素个数是偶数的时候:

使用 int mid = left + (right - left) / 2 ; 获得左中位数的索引;

使用 int mid = left + (right - left + 1) / 2 ; 获得右中位数的索引。

  • 当数组的元素个数是奇数的时候,以上两者都能选到最中间的那个中位数。

其次,

int mid = left + (right - left) / 2 ; 等价于 int mid = (left + right) >>> 1

int mid = left + (right - left + 1) / 2 ; 等价于 int mid = (left + right + 1) >>> 1

咱们使用一个具体的例子来验证:当左边界索引 left = 3,右边界索引 right = 4 的时候,

mid1 = left + (right - left) // 2 = 3 + (4 - 3) // 2 = 3 + 0 = 3

mid2 = left + (right - left + 1) // 2 = 3 + (4 - 3 + 1) // 2 = 3 + 1 = 4

左中位数 mid1 是索引 left,右中位数 mid2 是索引 right

记忆方法

(right - left) 不加 1 选左中位数,加 1 选右中位数

那么,何时使用左中位数,何时使用右中位数呢?选中位数的依据是为了不死循环,得根据分支的逻辑来选择中位数,而分支逻辑的编写也有技巧,下面具体说。

(3)先写逻辑上容易想到的分支逻辑,这个分支逻辑一般是排除中位数的逻辑;

在逻辑上,“多是也有可能不是”让咱们感到举棋不定,但**“必定不是”是咱们很是坚定的,一般考虑的因素特别单一,所以“好想” **。在生活中,咱们常常听到这样的话:找对象时,“有车、有房,能够考虑,但没有必定不要”;找工做时,“事儿少、离家近能够考虑,可是钱少必定不去”,就是这种思想的体现。

例:LeetCode 第 69 题:x 的平方根

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

因为返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

分析:由于题目中说“返回类型是整数,结果只保留整数的部分,小数部分将被舍去”。例如 5 的平方根约等于 2.236,在这道题应该返回 2。所以若是一个数的平方小于或者等于 x,那么这个数有多是也有可能不是 x 的平方根,可是能很确定的是,若是一个数的平方大于 x ,这个数确定不是 x 的平方根。

注意:先写“好想”的分支,排除了中位数以后,一般另外一个分支就不排除中位数,而没必要具体考虑另外一个分支的逻辑的具体意义,且代码几乎是固定的。

(4)循环内只写两个分支,一个分支排除中位数,另外一个分支不排除中位数,循环中不单独对中位数做判断

既然是“夹逼”法,没有必要在每一轮循环开始前单独判断当前中位数是不是目标元素,所以分支数少了一支,代码执行效率更高。

如下是“排除中位数的逻辑”思考清楚之后,可能出现的两个模板代码。

二分查找法模板

能够排除“中位数”的逻辑,一般比较好想,但并不绝对,这一点视状况而定。

分支条数变成 2 条,比原来 3 个分支要考虑的状况少,好处是:

不用在每次循环开始单独考虑中位数是不是目标元素,节约了时间,咱们只要在退出循环的时候,即左右区间压缩成一个数(索引)的时候,去判断这个索引表示的数是不是目标元素,而没必要在二分的逻辑中单独作判断。

这一点很重要,但愿读者结合具体练习仔细体会,每次循环开始的时候都单独作一次判断,在统计意义上看,二分时候的中位数刚好是目标元素的几率并不高,而且即便要这么作,也不是普适性的,不能解决绝大部分的问题

还以 LeetCode 第 35 题为例,经过以前的分析,咱们须要找到“大于或者等于目标值的第 1 个数的索引”。对于这道题而言:

(1)若是中位数小于目标值,它就应该被排除,左边界 left 就至少是 mid + 1

(2)若是中位数大于等于目标值,还不可以确定它就是咱们要找的数,由于要找的是等于目标值的第 1 个数的索引中位数以及中位数的左边都有多是符合题意的数,所以右边界就不能把 mid 排除,所以右边界 right 至可能是 mid,此时右边界不向左边收缩。

下一点就更关键了

(5)根据分支逻辑选择中位数的类型,多是左中位数,也多是右位数,选择的标准是避免死循环

形成死循环的代码

死循环容易发生在区间只有 2 个元素时候,此时中位数的选择尤其关键。选择中位数的依据是:避免出现死循环。咱们须要确保:

(下面的这两条规则提及来很绕,能够暂时跳过)。

一、若是分支的逻辑,在选择左边界的时候,不能排除中位数,那么中位数就选“右中位数”,只有这样区间才会收缩,不然进入死循环;

二、同理,若是分支的逻辑,在选择右边界的时候,不能排除中位数,那么中位数就选“左中位数”,只有这样区间才会收缩,不然进入死循环。

理解上面的这个规则能够经过具体的例子。针对以上规则的第 1 点:若是分支的逻辑,在选择左边界的时候不能排除中位数,例如:

Python 伪代码:

while left < right:
      # 不妨先写左中位数,看看你的分支会不会让你代码出现死循环,从而调整
    mid = left + (right - left) // 2
    # 业务逻辑代码
    if (check(mid)):
        # 选择右边界的时候,能够排除中位数
        right = mid - 1
    else:
        # 选择左边界的时候,不能排除中位数
        left = mid
复制代码
  • 在区间中的元素只剩下 2 个时候,例如:left = 3right = 4。此时左中位数就是左边界,若是你的逻辑执行到 left = mid 这个分支,且你选择的中位数是左中位数,此时左边界就不会获得更新,区间就不会再收缩(理解这句话是关键),从而进入死循环
  • 为了不出现死循环,你须要选择中位数是右中位数,当逻辑执行到 left = mid 这个分支的时候,由于你选择了右中位数,让逻辑能够转而执行到 right = mid - 1 让区间收缩,最终成为 1 个数,退出 while 循环。

上面这段话不理解没有关系,由于我尚未举例子,你有个印象就好,相似地,理解选择中位数的依据的第 2 点。

(6)退出循环的时候,可能须要对“夹逼”剩下的那个数单独作一次判断,这一步称之为“后处理”。

二分查找法之因此高效,是由于它利用了数组有序的特色,在每一次的搜索过程当中,均可以排除将近一半的数,使得搜索区间愈来愈小,直到区间成为一个数。回到这一节最开始的疑问:“区间左右边界相等(即收缩成 1 个数)时,这个数是否会漏掉”,解释以下:

一、若是你的业务逻辑保证了你要找的数必定在左边界和右边界所表示的区间里出现,那么能够放心地返回 left 或者 right,无需再作判断;

二、若是你的业务逻辑不能保证你要找的数必定在左边界和右边界所表示的区间里出现,那么只要在退出循环之后,再针对 nums[left] 或者 nums[right] (此时 nums[left] == nums[right])单独做一次判断,看它是否是你要找的数便可,这一步操做经常叫作“后处理”。

  • 若是你能肯定候选区间里目标元素必定存在,则没必要作“后处理”。

例:LeetCode 第 69 题:x 的平方根

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

因为返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

分析:非负实数 x 的平方根在 [0, x] 内必定存在,故退出 while (left < right) 循环之后,没必要单独判断 left 或者 right 是否符合题意。

  • 若是你不能肯定候选区间里目标元素必定存在,须要单独作一次判断。

例:LeetCode 第 704 题:二分查找

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,若是目标值存在返回下标,不然返回 -1。

分析:由于目标数有可能不在数组中,当候选区间夹逼成一个数的时候,要单独判断一下这个数是否是目标数,若是不是,返回 -1。

(7)取中位数的时候,要避免在计算上出现整型溢出;

int mid = (left + right) / 2; 的问题:在 left 和 right 很大的时候,left + right 会发生整型溢出,变成负数,这是一个 bug ,得改!

int mid = left + (right - left) / 2;right 很大、 left 是负数且很小的时候, right - left 也有可能超过 int 类型能表示的最大值,只不过通常状况下 leftright 表示的是数组索引值,left 是非负数,所以 right - left 溢出的可能性很小。所以,它是正确的写法。下面介绍推荐的写法。

int mid = (left + right) >>> 1; 若是这样写, left + right 在发生整型溢出之后,会变成负数,此时若是除以 2mid 是一个负数,可是通过无符号右移,能够获得在不溢出的状况下正确的结果

解释“无符号右移”:在 Java 中,无符号右移运算符 >>> 和右移运算符 >> 的区别以下:

  • 右移运算符 >> 在右移时,丢弃右边指定位数,左边补上符号位;
  • 无符号右移运算符 >>> 在右移时,丢弃右边指定位数,左边补上 0,也就是说,对于正数来讲,两者同样,而负数经过 >>> 后能变成正数。

下面解释上面的模板中,取中位数的时候使用先用“+”,而后“无符号右移”。

一、int mid = (left + right) / 2int mid = left + (right - left) / 2 两种写法都有整型溢出的风险,没有哪个是绝对安全的,注意:这里咱们取平均值用的是除以 2,而且是整除:

  • int mid = (left + right) / 2leftright 都很大的时候会溢出;
  • int mid = left + (right - left) / 2right 很大,且 left 是负数且很小的时候会溢出;

二、写算法题的话,通常是让你在数组中作二分查找,所以 leftright 通常都表示数组的索引,所以 left 在绝大多数状况下不会是负数而且很小,所以使用 int mid = left + (right - left) // 2 相对 int mid = (left + right) // 2 更安全一些,而且也能向别人展现咱们注意到了整型溢出这种状况,但事实上,还有更好的方式;

三、建议使用 int mid = (left + right) >>> 1 这种写法,实际上是大有含义的:

JDK8 中采用 int mid = (left + right) >>> 1 ,重点不在 + ,而在 >>>

咱们看极端的状况,lefthigh 都是整型最大值的时候,注意,此时 32 位整型最大值它的二进制表示的最高位是 0,它们相加之后,最高位是 1 ,变成负数,可是再通过无符号右移 >>>重点是忽略了符号位,空位都以 0 补齐),就能保证使用 + 在整型溢出了之后结果仍是正确的。

Java 中 CollectionsArrays 提供的 binarySearch 方法,咱们点进去看 leftright 都表示索引,使用无符号右移又不怕整型溢出,那就用 int mid = (left + right) >>> 1 好啦。位运算原本就比使用除法快,这样看来使用 +<<< 真的是又快又好了。

我想这一点多是 JDK8 的编写者们更层次的考量。

看来之后写算法题,就用 int mid = (left + right) >>> 1 吧,反正更多的时候 leftright 表示索引。

公众号:五分钟学算法

(8)编码一旦出现死循环,输出必要的变量值、分支逻辑是调试的重要方法。

当出现死循环的时候的调试方法:打印输出左右边界、中位数的值和目标值、分支逻辑等必要的信息。

按照个人经验,一开始编码的时候,稍不注意就很容易出现死循环,不过没有关系,你能够你的代码中写上一些输出语句,就容易理解“在区间元素只有 2 个的时候容易出现死循环”。具体编码调试的细节,能够参考我在「力扣」第 69 题:x 的平方根的题解《二分查找 + 牛顿法(Python 代码、Java 代码)》

六、总结

总结一下,我爱用这个模板的缘由、技巧、优势和注意事项:

(1)缘由:

无脑地写 while left < right: ,这样你就不用判断,在退出循环的时候你应该返回 left 仍是 right,由于返回 left 或者 right 都对;

(2)技巧:

先写分支逻辑,而且先写排除中位数的逻辑分支(由于更多时候排除中位数的逻辑容易想,可是前面我也提到过,这并不绝对),另外一个分支的逻辑你就不用想了,写出第 1 个分支的反面代码便可(下面的说明中有介绍),再根据分支的状况选择使用左中位数仍是右中位数;

说明:这里再多说一句。若是从代码可读性角度来讲,只要是你认为好想的逻辑分支,就把它写在前面,而且加上你的注释,这样方便别人理解,而另外一个分支,你就没必要考虑它的逻辑了。有的时候另外一个分支的逻辑并不太好想,容易把本身绕进去。若是你练习作得多了,会造成条件反射。

我简单总结了一下,左右分支的规律就以下两点:

  • 若是第 1 个分支的逻辑是“左边界排除中位数”(left = mid + 1),那么第 2 个分支的逻辑就必定是“右边界不排除中位数”(right = mid),反过来也成立;
  • 若是第 2 个分支的逻辑是“右边界排除中位数”(right = mid - 1),那么第 2 个分支的逻辑就必定是“左边界不排除中位数”(left = mid),反之也成立。

“反过来也成立”的意思是:若是在你的逻辑中,“边界不能排除中位数”的逻辑好想,你就把它写在第 1 个分支,另外一个分支是它的反面,你能够不用管逻辑是什么,按照上面的规律直接给出代码就能够了。能这么作的理论依据就是“排除法”。

在「力扣」第 287 题:寻找重复数的题解《二分法(Python 代码、Java 代码)》和这篇题解的评论区中,有我和用户 @fighterhit 给出的代码,在一些状况下,咱们先写了不排除中位数的逻辑分支,更合适的标准就是“哪一个逻辑分支好想,就先写哪个”,欢迎你们参与讨论。

(3)优势:

分支条数只有 2 条,代码执行效率更高,不用在每一轮循环中单独判断中位数是否符合题目要求,写分支的逻辑的目的是尽可能排除更多的候选元素,而判断中位数是否符合题目要求咱们放在最后进行,这就是第 5 点;

说明:每一轮循环开始都单独判断中位数是否符合要求,这个操做不是颇有普适性,由于从统计意义上说,中位数直接就是你想找的数的几率并不大,有的时候还要看看左边,还要看看右边。不妨就把它放在最后来看,把候选区间“夹逼”到只剩 1 个元素的时候,视状况单独再作判断便可。

(4)注意事项 1:

左中位数仍是右中位数选择的标准根据分支的逻辑而来,标准是每一次循环都应该让区间收缩,当候选区间只剩下 2 个元素的时候,为了不死循环发生,选择正确的中位数类型。若是你实在很晕,不防就使用有 2 个元素的测试用例,就能明白其中的缘由,另外在代码出现死循环的时候,建议你能够将左边界、右边界、你选择的中位数的值,还有分支逻辑都打印输出一下,出现死循环的缘由就一目了然了;

(5)注意事项 2:

若是能肯定要找的数就在候选区间里,那么退出循环的时候,区间最后收缩成为 1 个数后,直接把这个数返回便可;若是你要找的数有可能不在候选区间里,区间最后收缩成为 1 个数后,还要单独判断一下这个数是否符合题意。

最后给出两个模板,你们看的时候看注释,没必要也无需记忆它们。

公众号:五分钟学算法

二分查找模板-1.png

二分查找模板-2.png

说明:我写的时候,通常是先默认将中位数写成左中位数,再根据分支的状况,看看是否有必要调整成右中位数,便是不是要在 (right - left) 这个括号里面加 1

虽然说是两个模板,区别在于选中位数,中位数根据分支逻辑来选,原则是区间要收缩,且不出现死循环,退出循环的时候,视状况,有可能须要对最后剩下的数单独作判断

我想我应该是成功地把你绕晕了,若是您以为啰嗦的地方,就当我是“重要的事情说了三遍”吧,确实是重点的地方我才会重复说。固然,最好的理解这个模板的方法仍是应用它。在此建议您不妨多作几道使用“二分查找法”解决的问题,用一下我说的这个模板,在发现问题的过程当中,体会这个模板好用的地方,相信你必定会和我同样爱上这个模板的

在「力扣」的探索版块中,给出了二分查找法的 3 个模板,我这篇文章着重介绍了第 2 个模板,可是我介绍的角度和这个版块中给出的角度并不同,第 1 个模板被我“嫌弃”了,第 3 个模板我看过了,里面给出的例题也能够用第 2 个模板来完成,若是你们有什么使用心得,欢迎与我交流。

公众号:五分钟学算法

七、应用提高

这里给出一些练习题,这些练习题均可以使用这个“神奇的”二分查找法模板比较轻松地写出来,而且获得一个不错的分数,你们加油!

LeetCode 第 704 题

说明:传送门。这道题是二分查找的模板题,由于目标值有可能在数组中并不存在,因此退出 while 循环的时候,要单独判断一下。

LeetCode 第 69 题

说明:传送门

(1)题解连接已经在上文中已经给出,这道题根据分支的逻辑应该选右中位数;

(2)这道题由于还有更高效的“牛顿法”,因此看起来排名并非特别理想。

LeetCode 第 300 题

说明:传送门,第 300 题的一个子过程就是本题(第 35 题),我在这道题的题解《动态规划 + 贪心算法(二分法)(Python 代码、Java 代码)》 中给了两个 Python 的示例代码,它们是对本文中给出的注意事项:

若是你肯定要搜索的数在区间里,循环完成之后直接返回便可;若是你不肯定要搜索的数在区间里,循环完成之后须要再作一次判断。

的具体代码实现。

LeetCode 第 153 题

说明:传送门,二分查找法还能够用于部分有序数组中元素的查找。

LeetCode 第 154 题

说明:传送门

LeetCode 第 287 题

说明:传送门,这道题是对“数”做二分,而不是对索引作二分,具体能够参考我写的题解《二分法(Python 代码、Java 代码)》

这里要感谢一下「力扣」的用户 @顾叶峰,他提醒了我“慎用 L 啊,跟 1 傻傻分不清楚了”,根据他的建议,我正在尽力修改之前我写的题解(包括本文)。

LeetCode 第 1095 题

说明:传送门。这道题颇有意思,作这一道题等于作了 3 道二分查找的问题,而且,你还会发现,这 3 个二分查找的问题写出来的分支都是同样的,所以它们选中位数的时候,都选择了左中位数。

LeetCode 第 658 题

说明:传送门。这道题是「力扣」的探索版块里给出了二分查找法的 3 个模板中第 3 个模板的练习题,实际上也能够用我给出的这个模板(即“探索”里面的第 2 个模板)来完成,这道题我也写了题解《排除法(双指针) + 二分法(Python 代码、Java 代码)》

LeetCode 第 4 题

说明:传送门,这道题我也写了题解《合并之后找 + 归并过程当中找 + 找两个数组的“边界线”(Python 代码、Java 代码)》

相关文章
相关标签/搜索