最近在比赛和刷题的时候经常遇到二分答案的题,但时不时会因为一些细节上的错误而浪费时间,本文旨在整理常见的二分搜索的写法、二分搜索可能会遇到的一些小问题,以及 C++ 中与二分搜索相关的库函数,以免今后再犯类似的错误。

二分搜索的写法

查找某个值的下标

定义函数 binarySearch(nums, target) 为搜索有序数组 nums 中是否存在 i 使得 nums[i] == target,如果是,返回 i,否则返回 -1.

int binarySearch(vector<int>& nums, int target) {
	int low = 0, high = (int)nums.size() - 1;
	while (low <= high) {
		int mid = low + (high - low) / 2;
		if (nums[mid] == target) {
			return mid;
		}
		else if (nums[mid] > target) {
			high = mid - 1;
		}
		else {
			low = mid + 1;
		}
	}
	return -1;
}

这应该是大部分人最早接触的二分形式,也是最简单、最好理解的二分写法,但如果 nums 中元素存在重复的情况,并且我们需要在 nums 中存在多个 i 使得 nums[i] == target 时返回最小的 i,这种写法就失效了,而这种情况往往就是解决大部分有关二分搜索的算法问题时会遇到的。

查找左边界

要想成功实现对边界的查找,就需要对二分搜索的过程有一个更为深入的理解。还是采用与上面类似的写法,初始时将区间左右边界初始化为 low = 0, high = n - 1;. 在定义左右边界时我们应该注意到,待搜索的区间范围为 lowhigh,但是由于 lowhigh 本身就有可能为所要查找的最终结果 i,因此搜索目标位于闭区间 [low, high] 内,实际上区间内的数据分布情况我们是不得而知的,而我们已经获取的信息其实是区间外的信息,即:

  • i <= low - 1 时,nums[i] < target.
  • i >= high + 1 时,nums[i] >= target.

以上信息即为二分搜索过程中的循环不变量。需要注意的是,当 lowhigh 本身就位于左右边界的情况下, low - 1high + 1 已经超出数组范围,但由于 nums 是一个有序数组,因此我们可以这样考虑:nums[-1] = -∞, nums[n] = +∞. 因此上述的循环不变量在二分搜索开始时也满足。而要使得循环不变量在整个二分搜索过程中均满足,就需要在得到区间中点 mid 后,严格按照上述规则来更新区间左右端点:

  • nums[mid] >= target 时,要使得 nums[high + 1] >= target,那么可令 high + 1 = mid,等价于 high = mid - 1.
  • nums[mid] < target 时,要使得 nums[low - 1] < target,令 low - 1 = mid,等价于 low = mid + 1.

最终,循环条件为该闭区间不为空,表示仍然存在未确定的区间外信息,即 low <= high(取等号是因为当 low == high 时,闭区间内仍然有一个元素,应该继续循环),当退出循环时满足 low == high + 1,此时根据上述的循环不变量可知,nums[low - 1] == nums[high] < targetnums[high + 1] == nums[low] >= target,即 nums[low] 为有序数组 nums 中第一个大于等于 target 的值。

依据以上区间边界初始化方法、边界更新方法以及最终的返回值,可以很容易地编写相应代码。

// 闭区间型
int lower_bound(vector<int>& nums, int target) {
	int low = 0, high = (int)nums.size() - 1;
	while (low <= high) {
		int mid = low + (high - low) / 2;
		if (nums[mid] >= target) {
			high = mid - 1;
		}
		else {
			low = mid + 1;
		}
	}
	return low;
}

当然,除了以上“闭区间型”写法外,还有“左闭右开型“和开区间型,这些写法的本质思想是完全一样的,只不过是选取的循环不变量不同。

// 左闭右开型
int lower_bound(vector<int>& nums, int target) {
	int low = 0, high = (int)nums.size();
	while (low < high) { // 左闭右开区间当 low == high 时就已为空
		int mid = low + (high - low) / 2;
		// 循环不变量
		// nums[high] >= target
		// nums[low - 1] < target
		if (nums[mid] >= target) {
			high = mid;
		}
		else {
			low = mid + 1;
		}
	}
	return low;
}
// 开区间型
int lower_bound(vector<int>& nums, int target) {
	int low = -1, high = (int)nums.size();
	while (low + 1 < high) { // 开区间当 low + 1 == high 时就已为空
		int mid = low + (high - low) / 2;
		// 循环不变量
		// nums[high] >= target
		// nums[low] < target
		if (nums[mid] >= target) {
			high = mid;
		}
		else {
			low = mid;
		}
	}
	return high;
}

查找右边界

要对右边界进行查找,同样可以通过改写循环不变量来实现。不过通常对于元素类型为整型的有序数组来说,对右边界的查找可以转化为对左边界的查找。

比如要查找整型有序数组 nums 中最小的 i 满足 nums[i] > target,记为 upper_bound(nums, target),由于对于整型来说,nums[i] > targetnums[i] >= target + 1 等价,因此可以直接查找最小的 i 满足 nums[i] >= target + 1 ,得到的 i 即为最小的 i 满足 nums[i] > target,即 upper_bound(nums, target) = lower_bound(nums, target + 1).

同样的,诸如 nums[i] < targetnums[i] <= target 等等问题都可以通过类似的思想进行等价,这里就不过多赘述。

常见的问题

在采用二分答案法解决一些最优化问题时,上下界的确定往往是比较困难和繁琐的。但由于进行一次二分搜索的时间复杂度为 O(logn)n 的大小对最终时间的影响不会很大,因此实际面对这些问题时,往往直接令下界为 low = 0, high = INT32_MAX,但是这样又很容易出现整型溢出的问题,尤其是采用 mid = (low + high) / 2 这种写法的情况下,虽然 lowhigh 的值均位于 [0, INT32_MAX] 之间,但 low + high 却可能大于 INT32_MAX,从而导致一些意料不到的错误出现。因此求区间中值比较好的写法是 mid = low + (high - low) / 2,其在数学上与上述计算方式等价,但却可以很好地规避掉整型溢出的问题。

此外,有些问题还需要 mid 参与一些运算,来进行该问题的最优化判定,这时一个接近溢出的整数在进行一些加法或乘法运算后很容易因此溢出。因此一个比较安全的做法是将区间上下界以及区间中值都定义为 64 位整型(C++ 中为 long long 类型)。

相关库函数

  • lower_bound(first, last, value, comp);

first, last 为搜索数组的左闭右开区间,通常直接取 first = nums.begin(), last = nums.end()value 为要与元素比较的值,comp谓词函数,与排序等算法的谓词函数类似,即第一参数先序于第二参数时,返回 true,否则返回 false.

该函数的返回值为指向范围 firstlast 之间的首个不满足元素值 element < value 或者 comp(element, value) 的元素的迭代器,如果找不到,则返回 last.

  • upper_bound(first, last, value, comp);

参数与 lower_bound 相同,而返回值为指向范围 firstlast 之间的首个满足元素值 element > value 或者 comp(value, element) 的元素的迭代器,如果找不到,则返回 last.

这里只是简单的介绍了一下两种二分操作的参数及返回值,想要了解具体信息,可参考 cppreference.

参考资料

二分查找 红蓝染色法【基础算法精讲】 - 哔哩哔哩

二分查找有几种写法?它们的区别是什么? - 知乎

二分查找中的循环不变式_二分搜索的循环不变式 - groovy2007的博客

std::lower_bound - cppreference.com