0.简介
二分算法的特点是:最恶心,细节最多,最容易写出死循环的算法,但是掌握后会发现它可变为一个最简单的算法。我们以前总认为二分查找只能用于数组有序的情况,真正掌握后会发现就算数组无序也能用二分算法。需要注意的是不要死背模板,理解后再记忆。
1.二分查找
704. 二分查找 - 力扣(LeetCode)
https://leetcode.cn/problems/binary-search/description/
题目给了我们一个数组,给了我们一个数,让我们在数组中找出这个数,找到了返回这个数的下标,找不到返回-1。暴力解法就是从左往右遍历这个数组一遍,当我们找到这个数时停止遍历操作,此时返回这个数的下标。暴力解法每一次比较只能舍弃一个数,因此最差情况是从头到尾遍历数组一遍,所以暴力解法的时间复杂度是O(N)。接下来看如何优化,暴力解法并没有用数组有序的特性,接下来用题目中说的数组升序的特性来解决一下该问题。比如:

随便拿4和t比较,发现4是小于t的,此时可发现一个事情。由于升序4左边区间数小于4,4又小于t,那4左边的这一匹数全都比t小,所以可把这段去了,再去5678这一段找结果就行。再比如前面找的数是7,此时7>t,由于升序7右边的一批数全都比t大,所以可把7右边这些数去了,再去剩下的区间寻找。总结一下这个规律:在数组中随便找了个数和t比较,做完比较后划分出了两个区域:

根据规律可以有选择性的去掉一个区间,然后去另一个区间去寻找,此时就称这个题是有二段性的,有二段性就可用二分查找来解觉这个问题。
因此二分算法的本质是当属=数组有二段性时,二段性是当我们发现一个规律,依据规律选取某一点后数组分为两部分,依据规律可有选择性的舍去一部分而在另一部分继续查找,就可用二分查找算法。那二分查找算法具体是什么呢?从前面的规律中抽象一下,假设横线为一个数组,我们刚开始是随便找了个数,我们第一步可有很多选数的方法:

不管找那个位置都可把区间分为两段,一开始有很多的划分选择,但我们还是选第一个中间位置。这是个概率学问题,记住选中间点时间复杂度最好就行了:

接下来总结一下规律,因为要频繁的找中间和确定接下来要找的区间,所以可定义一个left指针指向区间左端点,right指针指向区间右端点,mid来表示区间的中点。中间的点的值设为x,x和t有三种关系:1.x<t,因需要求left更新到mid+1的位置,到接下来的left和right区间找。2.x>t,仅需让right更新到mid-1位置,到接下来的left和right区间找。3.x==t,返回结果。这三步骤就是朴素二分查找算法的核心,把这三步转化为代码就是二分查找算法。
接下来讲解细节问题:(算法要不断变化区间去找)1.循环结束条件。2.二分查找算法为什么是正确的。3.时间复习度。1:left>right时结束循环,因为区间数是未知的,就算一个区间也要判断。2:因为从暴力解法优化来的,一次可以排除许多暴力解法的可能。3:看循环执行多少次,当循环一进,要找的区间就会对半,因此是logN。求中间有两种方式:1.(left+right)/2,这样可能在数据大时有溢出的风险。2.left到right区间长知道后仅需让left走区间长的一半,left+(right-left)/2,这样可防止溢出。下面来实现:

下面总结一下朴素二分查找的模板:

2.在排列数组中查找元素的第一个和最后一个位置
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/description/ 先想一下暴力解法,从前往后遍历,第一次遇到t时用一个begin标记一下,往后遍历找到末尾位置用end标记一下,返回begin和end就可以了(O(N))。接下来优化一下暴力解法,可以利用数组有序的特性。上一个题数组有序找一个数用的是朴素二分算法,这里尝试用朴素二分来解决问题:

先来一个左指针,再来一个右指针,然后计算它们中点,根据中点的位置来分情况讨论。在这道题中用这样的方法解决问题时间复杂度会很高,这里一开始找到mid也就找到t了,但不能确定这个值正好是起始位置或终点位置。因此还要前面找终点,后面找起点,这样时间复杂度是O(N)。因此要想一个更优秀的策略,依旧是二分且利用数组的有序特性,只不过朴素二分基础上对我们二分策略做一下优化。先回到二分的本质,当我们发现二段性规律就可以利用二分查找,下面看这道题中有无二段性:我们不能同时找开始位置和结束位置,所以先来解决查找区间的左端点:

现在想找左端点,能否根据这个点的情况把数组划分为两部分呢?可划分为左区域和连带这个数的右边区域,左区域都是小于t的,右区域都是大于等于t的,因此有二段性,可以用二分:

1:x<t,left更新为mid+1,然后到新的[left,right]区域寻找结果。2:x>=t,right更新到mid的位置,接下来去新的[left,right]区间更新结果。下面来看细节处理:1.循环条件:

若是上图第二种的情况执行循环,为啥呢:

一段区间会有上面那样三种情况,若是情况一,假设结果在这里:

left刚开始处于没有结果的不合法区域,right刚开始在合法区域,在上述操作中,right一直在合法区域,left一直想跳出不合法区域,当right等于left时就是最终结果,无需判断。若是情况二,当全部大于t时,right只会向左边移动,直到移动到left和right相遇为止,最终只需要判断是否等于t就行。若是情况三,当全部小于t时,left一直右移,相遇时判断是否为t,相遇时会判断是否为t。这三种情况包含了所有情况,都说明left==right时无需再进循环了,如果判断了就会死循环。2:求中点操作:

这两种方式的区别是当数组是偶数时:

在朴素二分里都可以,这里不可以,比如:

剩下两个元素时,用方式2求mid在right这里。二段性把数组划分成了两部分,若是第一个条件x<t,left会移到mid+1的位置,判断会结束;若mid是第二个条件x>=t,right等于mid会一直不动,陷入死循环,所以用第二种方式求中点会陷入死循环。第一种求中点求的是一个左端点(相较于中间来谈),这样遇到遇到条件1left右移和right相等就终止循环了;若是条件2right更新到mid位置相等也是终止循环。
再来解决查找区间的右端点:

根据右端点依旧可以把区间划分为两部分,左边区间全都是小于等于3的,右边区间都是大于3的,有二段性就可以利用二分:

1:x<=t时,left更新到mid位置,继续到新的[left, right]中找。2.x>t,mid落在右边区域,right更新到mid-1位置,继续去新的[left,right]找。下面看细节处理:1.循环条件:left<right(原因和前面一样)。2.求中点的方式:

当最后剩两个元素时,第一个方式mid在left这里,若中了方式1left一直不动是死循环,所以用方式二。遇到方式1left到mid位置终止循环;遇到方式2right到mid-1位置也会终止循环。下面来实现(优化:找到左端点后下一次可直接从左端点开始;数组为空特殊处理):

下面来总结一下模板(仅需记住求mid这里,下面出现-1时就+1):

3.x的平方根
69. x 的平方根 - 力扣(LeetCode)
https://leetcode.cn/problems/sqrtx/description/ 先想一想如何用一个暴力解法来求一个数的平方根?结果只要求保留整数,因此可以从1,2,3......开始算出每个数的平方,算出后一个个试,若试到第一次大于这个数时返回前一个数,试到等于这个数时返回这个数。比如:

从暴力解法中我们会发现这个数组是有序的,那看看能否发现二段性进而将暴力解法改为二分查找。在上述暴力解法中,结果是4或6这样的情况,这两个数的特点是要么这个数平方小于x,要么这个数的平方等于x。因为数组是有序的,我们用线抽象一下这些数:

若ret是最终结果,发现从ret开始的所有往左的区域的数的平方都是小于等于x的,ret往右的区域的平方都是大于x的。因此发现了二段性,最终结果会把整个数组分为两段区间,左区间平方后都<=x,右区间平方后都大于x,所以可以用二分查找来解决问题:

定义left和right,根据中点来分类讨论(查找区间是从1到x):1.mid*mid <= x,left更新到mid位置,因为mid可能正好是x。2.mid*mid > x,right更新到mid - 1的位置。分析完这些就可套模板实现了(处理细节:题目范围表明x可能是小于1的,0.xxx的平方根还是0,直接处理)(正常判断mid等条件:mid = left+(right-left)/2,最后判断出减法上面就+1)(数据量大不敢int直接乘,longlong防止溢出):

4.搜索插入位置
35. 搜索插入位置 - 力扣(LeetCode)
https://leetcode.cn/problems/search-insert-position/
这道题中有两种情况:1.恰好可以找到目标值target。2.找不到恰好要插入的位置,插入位置恰好出现在第一次比它大的数的前一个位置或数组的后面。所以最终要找的值是大于等于t的,所以可把数组划分为两部分:左边区域都小于t,右边区域都大于等于t。接下来用查区间左端点的模板解决,下面来分析一下mid:

1.x<t,让left更新到mid+1,在新的left和right找。2.x>=t,right更新到mid位置。下面来实现(先正常求中间值,没出现减法不写加法)(超出数组处理一下):

5.山脉数组的封顶索引
852. 山脉数组的峰顶索引 - 力扣(LeetCode)
https://leetcode.cn/problems/peak-index-in-a-mountain-array/description/山脉数组的意思是里面的数都是先上升,再下降的:

找的是峰顶,返回下标。先想一个暴力解法:把数组抽象成折线:

上升的数用圆圈表示,下降的数用三角表示(找封顶最左边和最右边的元素不用考虑)。定义一个指针指向开始的位置,若指向的位置小于后一个数,肯定不是峰值,就让指针指向后一个位置......当找到一个数大于后面的数,那这个数就是峰值,这样下来时间复杂度是O(N)。接下来看看怎么优化:发现走完折现数组被天然的分为了两段,左边从第二个数开始都是大于前一个数的,右边(不算封顶)从上面开始都是严格的小于前一个数。也就是左边的特点是arr[i] > arr[i-1],右边的特点是arr[i]<arr[i-1]。依据这样的特点可以把数组分为两部分,既然有二段性就用二分查找。下面来分析:

1.若arr[mid]>arr[mid-1],则在左区域,left更新到mid。2.若arr[mid]<arr[mid-1],r则落在右区域,把right更新到mid-1。下面来实现(细节:1.排除第一个和最后一个 2.先不管mid,然后结合情况看是否+1):

6.寻找峰值
162. 寻找峰值 - 力扣(LeetCode)
https://leetcode.cn/problems/find-peak-element/description/ 先想一个暴力解法,从数组起始位置开始(下标为0)寻找山峰:1.要么直接往下走:

也就是当前位置的值大于下一个位置的值,就不用再走了,因为最左边是负无穷,这个位置就是峰值直接返回。2.发现开始是上坡的:

当发现从一个位置开始出现下降的时候,就是当前位置的值大于右边位置的值时候,返回这个位置的值。3.还有样的情况:

一直走走到了n-1的位置,此时返回山峰。暴力解法就是从第一个位置开始,一直向后走分情况讨论即可O(N)。接下来优化暴力解法:

这是一个数组,我此时选择了i的位置,根据这个位置和下一个位置特点可分情况讨论:1.arr[i] > arr[i-1],此时是一个下降的趋势,说明i及左边区域一定有一个上升的趋势,因为最左边是负无穷,左边区域一定有最终结果,但右边区域不一定,因为下降可能下降到负无穷大,所以接下来可去左边区域寻找结果。2.arr[i] < arr[i-i],此时是一个上升的趋势,左边可能没最终结果,可能由负无穷一直上升;右边一定存在最终结果,因为会降到负无穷,接下来可去右边区域寻找结果。结合以上发现了二段性:依据两个值的关系把数组分成了两段,所以可用二分查找。接下来分情况:

1.arr[mid] > arr[mid+1],左边一定有,right更新到mid。2.arr[mid] < arr[mid+1],右边一定有,让left更新到mid+1的位置。下面来实现:

7.寻找旋转排序数组中的最小值
153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array/description/暴力解法就是从前往后遍历,找到最小值O(N)。下面来优化,把旋转完的数组可以抽象为一个折线图,可分为两段:

题中说了数严格的不同,所以AB段严格的在虚线上,CD段严格的在虚线下面。以D为参照物,AB中每个值都大于D这个值,CD中每个值都是小于等于D这个值的,仅需找到C位置就是最小值。这样符合二段性,所以查某个区域的某个端点,用二分查找。二分查找体现在AB是严格的大于D值的,CD是严格的小于D值的。AB:nums[i] > nums[i -1],AB段的所有元素都是大于D的;CD:nums[i] <= nums[n-1],CD段的所有元素都是小于等于D的。下面分情况讨论:

mid在AB区间:nums[mid] > nums[n-1],left到mid+1位置;mid在CD区间:nums[mid] <= nums[n-1],right到mid的位置。(还可以尝试选择A点为参照物,这样AB:nums[i] >= nums[0];CD:nums[i] < nums[0],但注意一直上升时的边界情况)下面来实现:

8.0~n-1中缺失的数字
LCR 173. 点名 - 力扣(LeetCode)
https://leetcode.cn/problems/que-shi-de-shu-zi-lcof/description/ 题目意思是如果数组长为3,说明在0~2中缺了一个数。这道题有很多方法:1.哈希表:建立n+1大小的哈希表,遍历原始数组然后往哈希表中填,填完后看哪个位置没有填到那个位置就是缺失的数字。2.直接遍历找结果,看看从哪个地方断开了。3.位运算,用异或操作,把所有数组异或一遍,最后剩下单独的。4.高斯求和,把没有缺少的一组和算出来,然后依次减原数组的数。以上方式都是O(N)。5.二分:

虚线左边数组中的值和下面一一对应,虚线右边不是一一对应,所以有二段性。要找的最终结果是右区间的最左边的部分下标,因为开始对不上了,说明是缺失的值。下面细化一下:

1.若nums[mid] == mid,说明落在了左区间,left更新到mid+1位置。2.nums[mid] != mid,说明落在了右区间,right更新到mid。有个细节问题:[0,1,2,3],缺失4,也就是最终落的位置和下标一样,返回一个位置+1。下面来实现:
