众所周知,二分法在处理有序的集合时应用广泛,能快速找到目标元素,达到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】,这里就涉及到一些细节问题了?
- 到底是2轮就能查找到目标元素,还是需要 3 轮
- 退出循环的条件,该怎么写,是
while(i < j) or while(i <= j)
- 两种写法的本质区别是什么
带着这些问题,我们用代码一步一步来测试验证。
二分代码示例
先写一个最常规的处理方式,【写法一】代码如下:
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循环条件 和 左右指针的变动逻辑如何处理的。
最后再把三种写法的关键区别罗列一下,同时这里计算中间位置索引,我们换成更高效的位运算来处理。
- 【写法一】,最普遍的方式,左右指针每次以 mid 为基准加减一
js
while (i <= j) {
const mid = i + ((j - i) >> 1);
// 省略等于的情况
if (nums[mid] > target) j = mid -1;
else i = mid + 1;
}
- 【写法二】,while条件不再有等于,且 j 的计算方式改变
js
while (i < j) {
const mid = i + ((j - i) >> 1);
// 省略等于的情况
if (nums[mid] > target) j = mid;
else i = mid + 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))的时间复杂度内就能找到目标值。以上三种写法上的处理细节对于性能的影响微乎其微,可以忽略不计。假如被问到了,能够解释清楚其背后的本质就可以了。日常开发遇到二分法的应用场景,选择一种你最熟悉的方式书写即可。