二分细节全总结——二分法绝对是一个让人眼高手低的玩意,摸鱼了3天才玩明白

609 阅读4分钟

二分法

前言,最近在研究Vue源码,diff算法部分有个计算最长递增子序列的函数叫getSequence,它就在vue库的packages/runtime-core/src/renderer.ts最下面,是diff算法实现乱序重排的一个核心算法,里面就用到了下面例二所描述的二分场景,我不是一个不求甚解的人,对于算法这玩意更是强迫症,每一个细节我都不允许我迷迷糊糊不知所以然,当看vue中这个二分时,越看越迷惑,发现二分真的是一个有太多细节的算法,本来计划半天看完的算法,硬是研究了两三天。 下面的文章只是我做的一篇笔记,但感觉还是有一些价值的,就分享出来吧,未来肯定能帮助到有缘人。

细节一、while的退出条件

使用场景:有序数组中查找元素

关于while循环的终止判断条件是left < right还是left <= right,其实都可以,< 和 <=分别对应了对搜索区间两种不同的描述,具体来说也就是两种情况,第一种是初始化right = arr.lenght - 1,这就表示我们的搜索区间是arr数组的[left, right]arr[right]也在搜索的范围之内,对应while的终止条件是left <= right,退出循环时对应的情况是left > right,自然[left, right](区间表示大于等于left且小于等于right的元素)区间内就没有元素了,即搜索区间为空,也就是所有元素搜索完毕(所谓搜索完毕,就是所有元素都因为我们的逻辑判断,被pass掉了,反映在边界指针leftright身上就是它们围成的搜索区间里面没有元素),right = arr.length - 1whileleft <= right的判断条件是对应的,如下示例:

let left = 0;
let right = arr.length - 1;
while(left <= right) {
  // 缩小区间(增大left,缩小right),寻找(并返回)答案
}

第二种情况,初始化right = arr.length就代表我们的搜索区间是[left, right)(因为arr[right]出界嘛),对应while(left < right),因为left < right终止时的情况是left == right,这时候搜索区间[left, right)中就没有元素了,就表示搜索完毕了,如下示例:

let left = 0;
let right = arr.length;
while(left < right) {
  // 缩小区间(增大left,缩小right),寻找(并返回)答案
}

细节二、搜索区间缩小的力度

然后在while循环的内部,在缩小搜索区间范围,即修改leftright的时候,具体是把left修改为center还是center + 1right修改为center还是center - 1呢?说白了这就是一个缩小搜索区间从而缩小答案所在范围的过程,所以修改left & right遵循两个原则即可:

  • 与搜索区间的定义对齐:搜索区间的定义是在while循环开始之前定义right之时就已经确定的,比如right = arr.length,即搜索区间是左闭右开区间,这时候我们在while内部修改right为新值时,就要保持以左闭右开区间的定义为基础设置right,具体举个例子,在搜索区间为左闭右开的前提下,right设置为center,那么我们就相当于把arr[center]这个元素排除在搜索区间之外了,如果right设置为center + 1,那么就相当于arr[center]仍在搜索区间内。
  • 与想要的目标对齐:说白了就是针对不同的目的,自行设计如何缩小区间,这就不属于规律性的东西了,完全属于需要我们自己设计的、算法的一部分,举个简单的例子,我们利用二分法寻找数组内的目标元素target,在搜索区间为左右都是闭区间的前提下,某一次while循环体内arr[center] > target,那么我们既然是就要寻找指定的元素target,目的很单纯,自然arr[center]判断不等于target之后就可以排除搜索区间了,自然right可以设置为center - 1,而不是center总结:二分是方法,是指导思想,但不是具体的答案,细节的确定需要我们根据情况来具体设计。

细节三、中间元素选择时的取整方向

最后,二分算法要防止死循环,概括来说,就是不能让while跳不出去,这里就牵扯最后一个需要注意的细节——中心下标的取整方向,我们先宏观感受一下所谓的while死循环,如果我们每次都能让区间有效缩小,那么跳出while循环是早晚的事儿!但什么情况下会导致区间无法缩小呢,举个抽象的描述性的例子,搜索区间内剩下最后两个元素了,我们向上取整算中间值,然后根据较大的那个元素进入一个if中,这个if中搜索区间不变,这就导致死循环,确定取整方向防止死循环的方法就是:假设只剩下最后两个元素,(这时候向下取整总是会取到较小的元素,向上取整总是会取到较大的元素),这时候看区间的缩小,选择一个取整方向,这个取整方向可以稳定让left增加或者right减小。 下面的例子二中可以具体感受一下(例子二的代码注释)。

plus:判断左区间右移还是右区间左移有个技巧,就是把arr[center]target按从小到大排列,脑子里想象一个从小到大的队列,比如left arr[center] target right,因为我们是缩小target所在的区间大小,此时arr[center]小于target,移动left或者right后保证target仍然在leftright中间,自然是left右移。

二分实战

例一、寻找有序数组arrtarget元素的下标

function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1; // 同时相当于定义了搜索区间为闭区间[left, right]
  while (left <= right) { // left <= right,即当不满足搜索条件时left > right,此时搜索区间[left, right]内没有元素,搜索完毕
    let centerIndex = Math.floor((left + right) / 2); // 这里向上或向下取整都行,因为下面三种if的情况搜索区间都能得到缩减(或者直接return),保证了每一次while循环体的执行都离着跳出循环近了一步,不会陷入死循环
    let centerItem = arr[centerIndex];
    if (target === centerItem) {
      return centerIndex;
    } else if (target < centerItem) {
      right = centerIndex - 1; // centerItem已经判断过了,所以直接排除在搜索区间之外即可
    } else if (target > centerItem) {
      left = centerIndex + 1; // 与上同理,left = centerIndex + 1即排除centerItem
    }
  }
  return "数组中没有target元素";
}

例二、寻找目标元素数组中从左到右第一个小于等于的元素的位置(vue3中getSequence方法源码中的二分应用)

举个例子,对于数组[1, 2, 4, 8],我们给一个值3,那么从左到右3第一个小于等于的是4,所以返回4的位置下标2

我们明确二分为整体思想后,就要设计算法,即

  1. 选用哪一种区间(完全闭区间还是左闭右开)
  2. arr[center]大于小于等于target时如何缩小区间(如何改变leftright
  3. 最后返回什么(经过设计最后哪个变量可以代表答案)

算法设计:

选用左闭右开区间,即while(left < right),最后返回left。其实我也不知道这个算法从零到一应该怎么分析,前面这两个设计怎么来的实话实说我也没搞懂设计思想的源头,但是基于这两个头和尾设计,我来补充一下中间区间缩小的细节:

  1. 如果arr[center] < target,因为我们要找的元素起码是大于target的,最后返回left,所以自然可以把arr[center]排除出搜索区间了,自然而然更新leftcenter + 1
  2. 如果arr[center] === target,这个元素满足target小于等于它,这时候我们缩小右边界,令right = center,这里我们是照应返回值与区间定义的,因为while(left < right)退出循环时left === right,所以我们把右边界缩小为center是合适的,这样最后退出循环时arr[left]一定是一个大于等于target的值,换句话说就是right指针指向的元素一定是满足大于等于target的元素,最后返回时left等于right,这里我表述不清楚,自己悟一悟没问题的。
  3. 如果arr[center] > target,即target < arr[center](方便想象那个递增队列进而确定移动哪个指针),right左移,arr[center]可以保证是target小于的元素,但不一定是从左到右第一个(最小的),所以同上,我们也让right = center即可。

算法实现:

function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1;
  while (left < right) {
    let centerIndex = Math.floor((left + right) / 2); // 一定要选择向下取整,运用上面的分析方法,在算法的最后,搜索区间内剩下了两个元素,左右指针分别指向2和4,然后目标元素是3,如果我们向上取整,那么中间元素就是4,那么右指针左移,进入到下面二个if中,...,进入死循环。
    // 所以选择向下取整,因为left = centerIndex + 1,即区间必定缩小,这样永远不会因为最后时因为取整而导致区间无法缩小
    let centerItem = arr[centerIndex];
    if (target < centerItem) {
      right = centerIndex;
    } else if(target === centerItem) {
      right = centerIndex;
    } else if (target > centerItem) {
      left = centerIndex + 1;
    }
  }
  return left;
}

plus:最后一个算法的例子真的不是很简单,我讲的也不是很好,想要弄明白需要自己去多思考,但我感觉重点不是具体的算法咋解,而是上面关于细节的总结,希望能帮到你。