动图理解二分搜索的实现细节

1,132 阅读9分钟


文章大纲:
  • 普通二分搜索

  • 左侧边界的二分搜索

  • 右侧边界的二分索索

  • 二分搜索实现细节总结

  • 速记卡




二分搜索的思想非常容易, 但是难就难在代码的实现细节,很多时候对着抽象的代码干想是效率低下的,通过动图可以更好地厘清这些细节的问题。

当然,理解并记忆一段算法有很多种方法, 你可以做首诗, 可以画图, 也可以用ppt画动画。但是本人更倾向于可以调试的动画, 就是说动画会有什么样的效果完全交给你写的代码决定, 你本人是无法预测的。

当然, 算法可视化有动态和静态两种,静态的算法可视化也是一种相当牛逼的方法。个人认为算法可视化的最高水准是,通过一个静态的图片,就可以解读出整个算法的特征以及各种细节。
同样的,你会得到一张什么样的静态图片完全是由你的代码决定, 这才是算法可视化吸引人的地方啊!

接下来进入本文的正题,二分搜索。

下面是一个有序数组
let nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]


我们要用二分搜索查找这个数组中元素 7 的下标,也是就找到下图指针所示元素的下标。


1 普通二分搜索

代码就是如下所示:
let binatySearch = function(nums){
  let left = 0, right = nums.length - 1
  
  while (left <= right) {
    let mid = (left + right) >> 1if (nums[mid].value == target) {
      return mid
    }
    else if (nums[mid].value < target) {
      left = mid + 1
    }
    else { // nums[mid].value > target
      right = mid - 1
    }
  }
  return -1 
}
直接看代码肯定很难理解二分搜索, 但是结合动画就非常好理解了:


本公众号之前有篇文章专门介绍了游标动画,如何看懂冒泡算法的边界条件?

现在游标动画升级了,引入了新的动画元素——线段,我们姑且称新版的动画为线段动画。线段动画具备了部分静态可视化的特征。


图中left和right两个指针表示的区间是[left, right]。

通过线段图, 我们可以很好地理解left和right两个指针的区间变化。

一开始 [left, right] 表示的是整个数组的元素,然后不断取中间值对半缩减区间,因为是有序数组,当中间值大于要查找的元素时,缩减的是右指针,当中间值小于要查找的元素时,缩减的是左指针。


理解了区间的变化,自然也就理解了代码中 mid + 1 以及 mid - 1 的含义。


当然,二分搜索最重要一步就是取左右指针的中间值也是有技巧的, 详情可以看我这篇文章:位运算的妙用-求两个整数的平均值

本文的示例为了简单易懂 (懒), 所以用了最傻瓜的写法。

为了动画更好理解,图中被选为中间值的元素会用其他颜色高亮表示。


请留意下 while (left <= right) 这里为什么是<= ?

我们直接把代码中的等号去掉, 改成 while (left < right) 会有什么效果,直接看动画:

可以看到,当左右指针重叠的时候动画就结束了,没有返回我们期望的元素。没有了等号,也就相当于漏掉了左右指针相等时所表示的区间。


普通二分搜索的弊端

来看看下面这个存在重复元素的有序数组:
let nums = [1, 2, 3, 4, 4, 6, 6, 6, 6, 10, 11, 12, 13, 14, 15, 16, 16, 16, 16]

数组中存在多个元素 6 , 如果我们要返回的是最左边的6或者是最右边的6。


用普通的二分搜索只会返回最先找到的 6 的下标, 比如下图动画中最先找到的是第二个 6 ,显然是不满足需求的。


2 左侧边界二分搜索

怎么才能找到最左侧边界的元素 6 呢?代码如下所示:
// [left, mid) mid [mid+1, right)
let binatySearchLeftBound = function(nums){
  let left = 0, right = nums.length
  while (left < right) {
    let mid = (left + right) >> 1
    if (nums[mid].value == target) {
      right = mid
    }
    else if (nums[mid].value < target) {
      left = mid + 1
    }
    else { // data[mid].value > target
      right = mid
    }
  }
  if (nums[left].value != target) return -1
  return left
}
结合代码来看动画会更好理解:


代码最后为什么会 return left 呢?
从动画中可以看到,最后左右指针总是会重叠的,所以返回 left 或者 right 都是一样。

代码的循环条件 while (left < right) 为什么没有等号呢?
我们看看如果现在直接加上等号 while (left <= right) , 那么动画会有什么表现:

这里我们很清楚的看到,线段在不断向下延伸,出现死循环了!!!
如果加上了等号,也就是当左右指针重叠的时候,就会出现左指针 等于 右指针 等于 中间值的情况,然后不断执行这句代码 right = mid ,永远跳不出循环。所以说,细节是魔鬼。

什么时候返回return -1 呢?
其实通过线段可以看到,左右指针所表示的区间在不断向左侧边界收缩,也就是从[6,6,6,6] 一直收缩到 [6] , 所以如果左边的指针指向的元素不是 6 时,就表示数组中不存在 6 ,直接返回 -1 即可。


上面代码 left 和 right 两个指针表示的是左闭右开区间 [left, right) ,这点动画的线段也有体现:

下面我们用左闭右闭区间 [left, right] 再实现一次,也就是如下图的线段所示


左闭右闭区间实现


所以如果改成区间[left, right]的实现, 代码应该是如下所示:
// [left, mid-1] mid [mid+1, right]
let binatySearchLeftBoundClosed = function(nums){
  let left = 0, right = nums.length - 1
  while (left <= right) {
    let mid = (left + right) >> 1    
       
    if (nums[mid].value == target) {
      right = mid - 1
    }
    else if (nums[mid].value < target) {
      left = mid + 1
    }
    else { // data[mid].value > target
      right = mid - 1
    }
  }
  if (nums[left].value != target) return -1
  return left
}

动画效果如图所示:


可以看到, 这一次代码这里的 while (left <= right) 是有等号的。

这里我们是不是可以总结一个规律一

如果 [left, right), 那么 while 循环的条件是 while (left < right) ;

如果 [left, right], 那么 while 循环的条件是 while (left <= right) ;


上面的动画还有一个不同点是,动画结束的时候 right指针 走到了 left指针 的左边。

这是由 right = mid - 1 这句代码决定的,也就是说循环结束的那一刻,我们要找的目标元素下标应该 等于 left指针,也等于right指针加1。

因此结合 [left, right) 和 [left, right] 两段代码,我们似乎还可以总结一个规律二
如果 if (nums[mid].value == target) 的实现是 right = mid - 1 时, 最后返回目标元素下标可以写成 return right + 1 。

如果 if (nums[mid].value == target) 的实现是 right = mid 时, 最后返回目标元素下标可以写成 return right 。
如果 if (nums[mid].value == target) 的实现是 right = mid + 1 时, 最后返回目标元素下标可以写成 return right - 1 。

3 右侧边界二分搜索

如果我们要找最右边的元素6:


代码就是如下所示:
// [left, mid) mid [mid+1, right)
binatySearchRightBound = function(nums){
  let left = 0, right = nums.length
  
  while (left < right) {
    let mid = (left + right) >> 1
    if (nums[mid].value == target) {
      left = mid + 1
    }
    else if (nums[mid].value < target) {
      left = mid + 1
    }
    else { // data[mid].value > target
      right = mid
    }
  }
​
  if (right <= 0 || nums[right-1].value != target) return -1
  return right-1
}

动画效果如图所示:

请注意, 方法最后的返回值变成了 return right-1 这是为什么呢?
根据上面的规律二, 我们直接看 if (nums[mid].value == target) 的实现, 也就是 left = mid + 1 ,所以最后应该是 return left-1 ,当然循环结束后,左右指针肯定是重叠的,return right-1 也是等价的。

还有, return -1 的条件也变成了, if (right <= 0 || nums[right-1].value != target), 这又是为什么呢?

首先条件 nums[right-1].value != target 就很好理解了,从动画的线段可以看到,区间在不断向最右边界的元素 6 收缩,循环结束后,如果 right-1 不是要查找的目标,那么就表示元素不存在。


再看看条件 right <= 0 。如果我们要查找数组中不存在的元素, 比如查找0, 动画如下所示。

如果没有 right <= 0 这个条件,那么随着区间右边界的不断缩减, right指针毫无疑问将会超出数组的边界。


左闭右闭区间实现

照例我们将代码改成区间[left, right]的实现, 如下所示: 
// [left, mid) mid [mid+1, right)
binatySearchRightBoundClosed = function(nums, node, timeline){
  let left = 0, right = nums.length - 1
  while (left <= right) {
    let mid = (left + right) >> 1if (nums[mid].value == target) {
      left = mid + 1
    }
    else if (nums[mid].value < target) {
      left = mid + 1
    }
    else { // data[mid].value > target
      right = mid - 1
    }
  }
​
  if (right < 0 || nums[right].value != target) return -1
  return right
}
最终动画效果会变成下面这个样子:

注意, return -1 的条件这里 right < 0 是没有等号的,因为区间 [left, right] 的右指针是可以等于0的。

3 总结

通过上面二分搜索的几种实现,我们总结了几个值得注意的细节。

一,右区间的开闭,也就是右指针的赋值 right = nums.length 决定了 while 循环里 while (left < right) 是否有等号(规律一)。
二,nums[mid].value == target 的实现 决定了方法最后的返回值(规律二)。

三,return -1 的边界条件也很值得注意,要充分考虑找不到元素时或者指针溢出时的情况。

X References

本文参考了最近github上比较火的项目labuladong / fucking-algorithm里的一篇文章:二分查找详解


往期回顾





小卡片

二分搜索实现细节:

规律一

如果 [left, right), 那么 while 循环的条件是 while (left < right) ;

如果 [left, right], 那么 while 循环的条件是 while (left <= right) ;

规律二
如果 if (nums[mid].value == target) 的实现是 right = mid - 1 时, 最后返回目标元素下标可以写成 return right + 1 。

如果 if (nums[mid].value == target) 的实现是 right = mid 时, 最后返回目标元素下标可以写成 return right 。
如果 if (nums[mid].value == target) 的实现是 right = mid + 1 时, 最后返回目标元素下标可以写成 return right - 1 。



字节武装: 用d3动画讲解各种有趣的编程知识。