重识二分法(上)

4,323 阅读5分钟

"一听就会,一写就废"的二分法

二分法(Binary Search Algorithm),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。这种搜索算法每一次比较都使搜索范围缩小一半。

binary-search-animations.gif

二分法在最好情况下是常数复杂度,即 O(1)O(1),而最坏情况下是对数时间复杂度的,即 O(logn)O(logn) 。二分查找算法使用常数空间,对于任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。

  • 二分法最好情况

linear-vs-binary-search-best-case.gif

  • 二分法最坏情况

linear-vs-binary-search-worst-case.gif

二分法“一听就会,一写就废”的原因在于以下几点:

区间模型

如何选取左指针 leftleft,右指针 rightright ?

  • [left,right][left,right]
  • [left,right)[left,right)
  • (left,right](left,right]
  • (left,right)(left,right)

循环条件

如何选取二分法循环条件 ?

  • leftrightleft \le right
  • left<rightleft \lt right

mid指针取值

如何计算 midmid 指针 ?

  • mid=(left+right)/2mid = (left + right) / 2
  • mid=(left+right+1)/2mid = (left + right + 1) / 2
  • mid=(left+right1)/2mid = (left + right - 1) /2

如果上述条件选取不好,就有可能出现“漏值(边界条件遗漏)”、“死循环(leftleftrightright 反复被赋值相同的 midmid)”等的情况。故我们基于最简单的二分模型(即,numsnums单调递增无重复元素的数组,参考704. 二分查找)对以上情况进行展开讨论,从而找到最佳实践。

左闭右闭的区间模型

不忘初心,方得始终,既然“初心”选择了 [leftleft,rightright],那么接下来收敛二分区间的过程都要遵循这个区间模型。

1、区间初始定义

区间初始值定义:left=0left=0, right=len1right=len - 1,其中 lenlen 是数组 numsnums 的长度。

2、循环条件选择

这里需要思考一下,究竟是 leftrightleft \le right 还是 left<rightleft \lt right ?一言蔽之,就判断 left==rightleft == right 是否有意义。显然,对于 [left,right][left,right] 区间模型,left==rightleft == right 是有意义的。因为当 left==rightleft == right 时,代表当前区间只有一个元素。故,循环条件选择:leftrightleft \le right

3、mid指针讨论

我们分别讨论 leftleftrightright 指针之间元素个数(不包含 leftleftrightright 指针指向的边界元素)的奇偶性来确定 midmid 指针的计算模型。

当left与right之间的元素个数为偶数时

numsnums 数组的长度为 2n2n (nn 是正整数)。索引从 00n2n-2n1n-1 个元素,索引从 n+1n+12n12n-1n1n-1 个元素,所以索引为 n1n-1(中间偏左)nn(中间偏右) 的元素即为中间元素。初始情况下 left=0left=0, right=2n1right=2n-1leftleftrightright 之间的元素个数为 2n22n -2(该表达式可转换成 2×(n1)2 \times (n-1),其计算结果一定为偶数)。

👉使用 mid=(left+right)/2mid = (left + right) / 2 会使 midmid 指向中间偏左的元素

此时 mid=(0+2n1)/2=n1/2mid = (0 + 2n-1) / 2 = n - 1/2 ,此时取值 n1n - 1(向下取整)

👉使用 mid=(left+right+1)/2mid = (left + right + 1) / 2 会使 midmid 指向中间偏右的元素

此时 mid=(0+2n1+1)/2=nmid = (0 + 2n-1 + 1) / 2 = n

👉使用 mid=(left+right1)/2mid = (left + right - 1) / 2 会使 midmid 指向中间偏左的元素

此时 mid=(0+2n11)/2=n1mid = (0 + 2n-1 - 1) / 2 = n-1

当left与right之间的元素个数为奇数时

numsnums 数组的长度为 2n+12n+1 (nn 是正整数)。索引从 00n1n-1nn 个元素,索引从 n+1n+12n2nnn 个元素,所以索引为 nn 的元素即为中间元素。初始情况下 left=0left=0, right=2nright=2nleftleftrightright 之间的元素个数为 2n12n-1(一定为奇数)。

👉使用 mid=(left+right)/2mid = (left + right) / 2 会使 midmid 指向恰为中心的元素。

此时 mid=(0+2n)/2=nmid = (0 + 2n) / 2 = n

👉使用 mid=(left+right+1)/2mid = (left + right + 1) / 2会使 midmid 指向恰为中心的元素。

此时 mid=(0+2n+1)/2=n+1/2mid = (0 + 2n + 1) / 2 = n + 1/2,此时取值 nn(向下取整)

👉使用 mid=(left+right1)/2mid = (left + right - 1) / 2会使 midmid 指向中间偏左的元素。

此时 mid=(0+2n1)/2=n1/2mid = (0 + 2n - 1) / 2 = n - 1/2,此时取值 n1n-1(向下取整)

截止目前的讨论,对于 [left,right][left, right] 区间模型,无论 midmid 选择哪种计算模型,均可指向区域中心的元素(恰为中心的元素,或中间偏左、中间偏右的元素,均为区域中心的元素),且左右区间的长度大体均等。因此,要选择出合适的计算方法,还需要看计算出的落盘新区间,方可确定 midmid 的计算模型。

4、计算落盘新区间

通过比较 nums[mid]nums[mid]targettarget,从而计算新的落盘区间,即

  • nums[mid]==targetnums[mid] == target,直接返回 midmid 即可;

  • nums[mid]>targetnums[mid] \gt target,说明 targettarget 一定在 [left,mid)[left, mid) 区间内。我们发现,此区间模型已经违背了我们的“初心”(左闭右闭区间模型),新的落盘区间应为 [left,mid1][left, mid - 1]。故,此时应将右闭区间赋值给 rightright 指针,即 right=mid1right = mid - 1;

  • nums[mid]<targetnums[mid] \lt target,说明 targettarget 一定在 (mid,right](mid, right] 区间内。我们发现,此区间模型又违背了我们的“初心”,因此新的落盘区间应为 [mid+1,right][mid+1, right]。故,此时应将左闭区间赋值给 leftleft 指针,即 left=mid+1left = mid + 1

我们发现,leftleftrightright 指针在每次计算落盘新区间时都会发生改变(每次循环后的 leftleft 会比前一次循环的 leftleft 多1,亦或是,每次循环后的 rightright 会比前一次循环的 rightright 少1),故三种 midmid 计算方式均可以满足要求。为防止计算精度溢出问题, midmid 计算方式选择为 mid=left+(rightleft)>>1mid= left + (right - left) \gt\gt 1(该计算方式在数理上等价于:mid=(left+right)/2mid = (left + right) / 2)。

5、最终返回值

如果 targettarget的确存在于 numsnums 数组中,其索引位置一定会在第四步中 nums[mid]==targetnums[mid] == target 时返回,否则一定返回 1-1

6、参考代码

迭代法

/**
 * 左闭右闭区间模型[left, right]下的二分法
 * 空间复杂度 o(1)
 * 时间复杂度 o(logN)
 */
function search(nums: number[], target: number): number {
	let left = 0;
	let right = nums.length - 1;
	
	while(left <= right) {
		// [0, +infinity), 强行向下取整, 例如 ~~(1.5) == 1
		// (-infinity, 0), 强行向上取整, 例如 ~~(-0.5) == 0, ~~(-1.5) == -1
		const mid = left + ~~((right - left) / 2); // 执行耗时最短
		// const mid = left + ~~((right - left + 1) / 2); // 亦可
		// const mid = left + ~~((right - left - 1) / 2); // 亦可
		const midVal = nums[mid];
		
		if (midVal === target) {
			return mid;
		}
		
		if (midVal > target) {
			right = mid - 1;
		} else  {
			left = mid + 1;
		}
	}
	
	return -1;
}

递归法

/**
 * 左闭右闭区间模型[left, right]下的二分法
 * 空间复杂度 O(logN)
 * 时间复杂度 O(logN)
 */
function search(nums: number[], target: number): number {
	return searchHelper(nums, target, 0, nums.length - 1);
}

function searchHelper(nums: number[], target: number, left: number, right: number): number {
	// 因为循环条件为 left <= right,故退出的条件(取反)为 left > right
        if (left > right) {
		return -1;
	}
	// [0, +infinity), 强行向下取整, 例如 ~~(1.5) == 1
	// (-infinity, 0), 强行向上取整, 例如 ~~(-0.5) == 0, ~~(-1.5) == -1
	const mid = left + ~~((right - left) / 2); // 执行耗时最短
	// const mid = left + ~~((right - left + 1) / 2); // 亦可
	// const mid = left + ~~((right - left - 1) / 2); // 亦可
		
	if (nums[mid] === target) {
		return mid;
	}

	if (nums[mid] > target) {
		return searchHelper(nums, target, left, mid - 1); 
	}

	return searchHelper(nums, target, mid + 1, right);
}

总结

区间模型循环条件midmid 指针取值
[left,right][left, right]leftrightleft \le rightmid=(left+right)/2mid = (left + right)/2mid=(left+right+1)/2mid = (left + right + 1) / 2mid=(left+right1)/2mid = (left + right - 1) / 2

如果 [nums[mid]==target[nums[mid] == target,直接返回 midmid 提前结束二分搜索;

如果 [nums[mid]>target[nums[mid] > targetleftleft 指针不动,right=mid1right = mid - 1 后继续二分;

如果 [nums[mid]<target[nums[mid] < targetrightright 指针不动,left=mid+1left = mid + 1 后继续二分;

接下来继续分析其他三种区间模型,即左 [left,right)[left,right)、左 (left,right](left,right] 、左 (left,right)(left,right)