搞懂二分法中的处理细节

众所周知,二分法在处理有序的集合时应用广泛,能快速找到目标元素,达到log(n)的时间复杂度。 可谓是非常高效的一种算法了,使用二分法处理的流程大同小异,有些需要注意的细节,可能会让刚接触的同学困惑。 本文就二分法中的处理细节,进行详细的阐述。

一个简单的示例

我们先从一个简单的示例开始,认识什么是二分法?

二分法(binary search),也是二分搜索、二分检索、二分查找等等各种称谓,其实都是一个意思:就是从一组有序的数据集合中查找定位某个(或某些个)元素。 由于我们的前提条件是有序的集合,那么就能凭借这一先天优势,通过不断地缩小查找范围,就能在log(n)的时间范围内找到目标元素。

假定有一组数据:nums=【1, 3, 5, 7, 9】,要查找的元素(target)为3,每次取中间位置的索引(mid)

二分法的搜索流程是:

  • 【1】先定义左右两个指针(i=0、j=4),分别指向列表的起始 和 末尾的元素
  • 【2】计算中间位置的索引(mid = i + (j - i) / 2)
  • 【3】判断中间位置的索引(mid)同起始(i)和结束位置(j)指针所指向的元素的大小关系
  • 【4】如果nums[mid] === target,那么说明找到目标元素了,结束查找过程即可。
  • 【5】如果 nums[mid] < target ,说明中间位置的元素太小了,需要挪动左侧指针到mid + 1的位置上,即(i = mid + 1)
  • 【6】如果 nums[mid] > target ,说明中间位置的元素太大了,需要挪动右侧指针到mid - 1的位置上,即(j = mid - 1)
  • 【7】不断重复上面的【2】~【6】的过程,直到左指针(i)和右指针(j)相遇退出查找过程

如果按照上面的流程来处理,我们就能在三轮之内锁定目标元素【3】,这里就涉及到一些细节问题了?

  1. 到底是2轮就能查找到目标元素,还是需要 3 轮
  2. 退出循环的条件,该怎么写,是while(i < j) or while(i <= j)
  3. 两种写法的本质区别是什么

带着这些问题,我们用代码一步一步来测试验证。

二分代码示例

先写一个最常规的处理方式,【写法一】代码如下:

js 复制代码
function binarySearch(nums, target) {
  let [i, j] = [0, nums.length - 1];
  while (i <= j) {
    // 计算 i => j 区间的中间位置索引 mid
    const mid = i + Math.floor((j - i) / 2);
    // 打印每轮中左右指针和中间位置索引的数值
    console.log(i, mid, j);
    if (nums[mid] === target) return mid;
    if (nums[mid] > target) {
      j = mid - 1;
    } else {
      i = mid + 1;
    }
  }

  return -1;
}

为了便于观察,我们打印每轮中左右指针和中间位置索引的数值。

js 复制代码
const nums = [1, 3, 5, 7, 9];
const result = binarySearch(nums, 3);
console.log(result);
// 输出下面的结果:
// 0 2 4
// 0 0 1
// 1 1 1
// 1

可以直观的看出,上面的【写法一】需要执行 3 次循环,在第三次的时候 左右指针和中间位置索引相遇(i == mid == j)

我们再换一种写法,【写法二】代码如下:

js 复制代码
function binarySearch2(nums, target) {
  let [i, j] = [0, nums.length - 1];
  while (i < j) {
    const mid = i + Math.floor((j - i) / 2);
    console.log(i, mid, j);
    if (nums[mid] === target) return mid;
    if (nums[mid] > target) {
      j = mid;
    } else {
      i = mid + 1;
    }
  }

  return -1;
}

为了便于观察,我们同样打印【写法二】中每轮中左右指针和中间位置索引的数值。

js 复制代码
const nums = [1, 3, 5, 7, 9];
const result = binarySearch2(nums, 3);
console.log(result);
// 输出下面的结果:
// 0 2 4
// 0 1 2
// 1

可以直观的看出,上面的【写法二】仅执行 2 次循环,在第二次的时候中间值正好命中目标元素【3】

我们再来看第三种写法,【写法三】代码如下:

js 复制代码
function binarySearch3(nums, target) {
  let [i, j] = [0, nums.length - 1];
  while (i < j) {
    const mid = i + Math.floor((j - i) / 2) + 1;
    console.log(i, mid, j);
    if (nums[mid] === target) return mid;
    if (nums[mid] > target) {
      j = mid - 1;
    } else {
      i = mid;
    }
  }

  return -1;
}

在上面的写法三种,相对于写法二,不同之处在于中间位置索引 mid 的计算方式和左指针 i 的移动方式。 mid,每次取除法的上限, 左指针右移时将基点设为 中间位置 mid,不再是mid + 1。

我们同样打印【写法三】中每轮中左右指针和中间位置索引的数值。

js 复制代码
const nums = [1, 3, 5, 7, 9];
const result = binarySearch3(nums, 3);
console.log(result);
// 输出下面的结果:
// 0 3 4
// 0 2 2
// 0 1 1
// 1

可以直观的看出,【写法三】执行 3 次循环,在第三次的时候中间值正好命中目标元素【3】,且这时左指针 i 没有和右指针 j 相遇,中间位置索引 mid 最终在右指针 j 的位置相遇,并取得目标元素【3】。

偶数个元素

上面我们用的测试集合【1, 3, 5, 7, 9】,是 5 个元素,如果是偶数个元素呢? 那么我们再增加一个元素,分别测试【写法一】、【写法二】和【写法三】的输出结果。

js 复制代码
binarySearch([1, 3, 5, 7, 9, 10], 3);
// 写法一的输出:
// 0 2 5
// 0 0 1
// 1 1 1

binarySearch2([1, 3, 5, 7, 9, 10], 3);
// 写法二的输出:
// 0 2 5
// 0 1 2

binarySearch3([1, 3, 5, 7, 9, 10], 3)
// 写法三的输出:
// 0 3 5
// 0 2 2
// 0 1 1
// 1

通过实测,我们可以发现无论是奇数个元素还是偶数个元素,【写法二】的处理方式总会少一轮的处理过程。

大于 or 大于等于

其实除了上面的两种写法,还能变形出更多种的写法,我们不再探究更多形式上的变化,来关注不同写法背后的本质。 下面我们重点分析一下,【写法一】和【写法二】关键的差别,及本质的区别。

因为【写法三】和【写法二】比较相似,while条件一致,仅仅是左右指针谁变动1的微小区别

在【写法一】中循环退出的判断逻辑是i <= j,每次左右指针的变化是在mid的基础上加减一(i = mid + 1 or j = mid - 1),不断缩短考察的集合范围。

js 复制代码
while (i <= j) {
  const mid = i + Math.floor((j - i) / 2);
    if (nums[mid] === target) return mid;
    if (nums[mid] > target) {
      j = mid - 1;
    } else {
      i = mid + 1;
    }
}

这种方式的优势是直观、好理解,最终一定会呈现出一种状态:左指针 == 中间索引 == 右指针,三者一定会相遇的。 为什么会相遇呢?

从上图我们可以找到答案,每当i 和 j走到相差 1 的位置时,由于除法是向下取值的,所以会有左指针(i)先和中间索引(mid)相遇的情况发生,走到下一轮的时候,由于nums[i] < target,左指针向右移动一位才会出现:i = mid + 1,i = j =mid

在【写法二】中循环的逻辑判断条件变成了:i < j,此时j的处理逻辑也发生了改变,j 取 mid 作为基准,而不再是 mid - 1,从而来保证中间位置索引 mid 能够走到集合中的每个位置。

js 复制代码
// i <= j  => i < j
while (i < j) {
  const mid = i + Math.floor((j - i) / 2);
    if (nums[mid] === target) return mid;
    if (nums[mid] > target) {
      // j = mid - 1 =>   j = mid
      j = mid;
    } else {
      i = mid + 1;
    }
}

从上面的对比来看,好像是【写法二】性能要优于【写法一】,事实真的如此吗? 上面我们测试的数据量比较小,下面我们换多一点儿的数据做测试。

大数量的情况

我们稍微改动一下代码,统计一下跑过的循环轮数,【写法一】:

js 复制代码
function binarySearch(nums, target) {
  let [i, j] = [0, nums.length - 1];
  let countLoop = 0;
  while (i <= j) {
    const mid = i + Math.floor((j - i) / 2);
    countLoop += 1;
    if (nums[mid] === target) {
      console.log('total loop times: ', countLoop);
      return mid;
    }
    if (nums[mid] > target) {
      j = mid - 1;
    } else {
      i = mid + 1;
    }
  }

  return -1;
}

执行下面的测试用例,从100 ~ 1000万的不同范围内查找3,:

js 复制代码
binarySearch([...Array(100).keys()], 3);
binarySearch([...Array(1000).keys()], 3);
binarySearch([...Array(10000).keys()], 3);
binarySearch([...Array(100000).keys()], 3);
binarySearch([...Array(1000000).keys()], 3);
binarySearch([...Array(10000000).keys()], 3);
// 【写法一】输出的结果:
// total loop times:  6
// total loop times:  10
// total loop times:  11
// total loop times:  16
// total loop times:  20
// total loop times:  21

统计一下【写法一】跑过的循环轮数:

js 复制代码
function binarySearch2(nums, target) {
  let [i, j] = [0, nums.length - 1];
  let countLoop = 0;
  while (i < j) {
    const mid = i + Math.floor((j - i) / 2);
    countLoop += 1;
    if (nums[mid] === target) {
      console.log('total loop times: ', countLoop);
      return mid;
    }
    if (nums[mid] > target) {
      j = mid;
    } else {
      i = mid + 1;
    }
  }

  return -1;
}

执行下面的测试用例,从100 ~ 1000万的不同范围内查找3,

js 复制代码
binarySearch2([...Array(100).keys()], 3);
binarySearch2([...Array(1000).keys()], 3);
binarySearch2([...Array(10000).keys()], 3);
binarySearch2([...Array(100000).keys()], 3);
binarySearch2([...Array(1000000).keys()], 3);
binarySearch2([...Array(10000000).keys()], 3);

// 【写法二】输出的结果:
// total loop times:  5
// total loop times:  8
// total loop times:  13
// total loop times:  15
// total loop times:  18
// total loop times:  23

通过对比,可以发现,在数据量级不同时,结果不同

  • 数据量:100 时,(【写法一】= 6) > (【写法二】 = 5)
  • 数据量:1000时,(【写法一】= 10) > (【写法二】 = 8)
  • 数据量:一万时,(【写法一】= 11) < (【写法二】 = 13)
  • 数据量:十万时,(【写法一】= 16) > (【写法二】 = 15)
  • 数据量:百万时,(【写法一】= 20) > (【写法二】 = 18)
  • 数据量:千万时,(【写法一】= 21) < (【写法二】 = 23)

数据量大的时候,会出现【写法一】优于【写法二】的情况,且从概率上说数据量越大【写法一】应该越有优势。因为每次挪动的位置会稍微大一点点(j = mid - 1 会比 j = mid 快那么一丢丢)。

总结

本文主要讲述了二分法中三种不同的写法,三者的区别很细微,很多同学可能都不会注意到的。 三种写法的关键区别,在于while循环条件 和 左右指针的变动逻辑如何处理的。

最后再把三种写法的关键区别罗列一下,同时这里计算中间位置索引,我们换成更高效的位运算来处理。

  1. 【写法一】,最普遍的方式,左右指针每次以 mid 为基准加减一
js 复制代码
while (i <= j) {
  const mid = i + ((j - i) >> 1);
  // 省略等于的情况
  if (nums[mid] > target) j = mid -1;
  else i = mid + 1;
}
  1. 【写法二】,while条件不再有等于,且 j 的计算方式改变
js 复制代码
while (i < j) {
  const mid = i + ((j - i) >> 1);
  // 省略等于的情况
  if (nums[mid] > target) j = mid;
  else i = mid + 1;
}
  1. 【写法三】,while条件不再有等于,mid 和 i 的计算方式改变
js 复制代码
while (i < j) {
  const mid = i + ((j - i) >> 1) + 1;
  // 省略等于的情况
  if (nums[mid] > target) j = mid -1;
  else i = mid;
}

上面的三种写法,本质都没有多大的区别。关键一点是,要能保证中间位置索引(mid)能够取到任何位置的元素,只要做到了这一点,就没有问题。二分法的本质就是不断的算小目标范围,每一轮缩小原先一半的求解范围,在O(log(n))的时间复杂度内就能找到目标值。以上三种写法上的处理细节对于性能的影响微乎其微,可以忽略不计。假如被问到了,能够解释清楚其背后的本质就可以了。日常开发遇到二分法的应用场景,选择一种你最熟悉的方式书写即可。

相关推荐
浮生如梦_26 分钟前
Halcon基于laws纹理特征的SVM分类
图像处理·人工智能·算法·支持向量机·计算机视觉·分类·视觉检测
励志成为嵌入式工程师2 小时前
c语言简单编程练习9
c语言·开发语言·算法·vim
捕鲸叉3 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer3 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
wheeldown3 小时前
【数据结构】选择排序
数据结构·算法·排序算法
观音山保我别报错4 小时前
C语言扫雷小游戏
c语言·开发语言·算法
TangKenny6 小时前
计算网络信号
java·算法·华为
景鹤6 小时前
【算法】递归+深搜:814.二叉树剪枝
算法
iiFrankie6 小时前
SCNU习题 总结与复习
算法
鱼跃鹰飞6 小时前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试