搞懂二分法中的处理细节

众所周知,二分法在处理有序的集合时应用广泛,能快速找到目标元素,达到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))的时间复杂度内就能找到目标值。以上三种写法上的处理细节对于性能的影响微乎其微,可以忽略不计。假如被问到了,能够解释清楚其背后的本质就可以了。日常开发遇到二分法的应用场景,选择一种你最熟悉的方式书写即可。

相关推荐
Lee川4 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i6 小时前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有7 小时前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有7 小时前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫8 小时前
Looper.loop() 循环机制
面试
AAA梅狸猫8 小时前
Handler基本概念
面试
Gorway8 小时前
解析残差网络 (ResNet)
算法
Wect8 小时前
浏览器缓存机制
前端·面试·浏览器
拖拉斯旋风8 小时前
LeetCode 经典算法题解析:优先队列与广度优先搜索的巧妙应用
算法
Wect9 小时前
LeetCode 207. 课程表:两种解法(BFS+DFS)详细解析
前端·算法·typescript