【二分查找】为什么我的二分查找总写错?
关于二分查找,其思想一般很容易理解,但其代码实现却包含比较多的细节,新手往往写出错误甚至陷入死循环的代码。本篇笔记旨在记录关于二分查找的如下几个方面:
- 如何准确无误并快速的写出二分查找?
- 二分查找出错或进入死循环的原因分析
1、如何准确无误并快速的写出二分查找?
问题引入
问题1:给定一个
n
个元素有序的(升序)整型数组nums
和下标范围low
~high
和 一个目标值x
,写一个函数搜索下标范围low
~high
的nums
中第一个x
,如果目标值存在返回下标,否则返回-1
。
- 注:这里的low
~high
先假定为左闭右开区间[low, high),之后问题1
的代码实现以这个区间为前提。如果是左闭右闭区间[low, high],对应的代码实现需要作出一定的修改。
示例1:
输入: nums = [-1,0,3,5,9,9,12], low=0, high=6, x = 9
输出: 4
解释: 9 出现在 nums 的左闭右开区间 [0,6) 中,从左至右第一个位置的下标为 4
二分查找的基本思想
以下给出二分查找的基本思想:
- 令左右指针 L = low, R = high
- 若 L < R ,在[L, R)区间执行如下循环进行筛选:
- 取中间元素 nums[mid] 与目标值 x 比较:
- 若 nums[mid] >= x ,则说明 所有 nums[mid, R) >= x,排除右半区域。令R=mid,继续执行步骤2;
- 若 nums[mid] < x ,则说明 所有 nums[L, mid+1) < x,排除左半区域。令 L = mid+1,继续执行步骤2;
- 取中间元素 nums[mid] 与目标值 x 比较:
- 循环结束,L == R。此时达到筛选目的 nums[low, L) < x <= nums[R, high)。此时只需判断 nums[R] 与目标值x的关系即可。
二分查找代码实现
// 找到升序数组 nums 在左闭右开区间 [low, hihg) 中出现的第一个等于目标值 x 的元素下标,若存在则返回其下标,否则返回 -1
int bioSearch(int nums[], int low, int high, int x) {
int L = low, R = high ;
// 假设 nums[L-1] = -INF, nums[R] = INF
// 循环不变式:nums[low:L) < x <= nums[R:high),nums[L,R)待筛选
while (L < R) { // 待筛选区间左闭右开[low, high)决定了循环边界为 L < R
int mid = L + ((R - L) / 2 ); // 等价于 mid = (L+R)/2 ;
if (nums[mid] >= x) R = mid ; // 说明 nums[mid,R)>=x,在[L,mid)区间继续筛选,故令 R = mid
else L = mid + 1 ; // 说明 nums[L,mid+1) < x,在[mid+1,R)区间继续筛选,故令 L = mid+1
}
// 循环结束 L == R,此时nums[low:L) < x <= nums[R:high)
// 根据上面循环不变式可知,nums[R]才可能是第一个等于目标值x的元素,但只知道 nums[R] >= x,是“=”还是“>”需要进一步判断
if (R == high || nums[R] != x) { // 若R == high,说明 nums[low, high) < x
return -1 ;
} else {
return R ;
}
}
以上基本思想初学者往往很好理解,但是其代码的实现却存在几个易错点:
- 【循环边界】循环结束边界为什么是:while (L < R) ,而不是:while (L <= R)
- 【左右指针修改】左右指针的更改如何选取?为啥 L = mid + 1,而 R = mid ?
要回答以上两个问题,首先让我们更深一步理解一下二分查找。二分查找的精髓就在于:如何将求解规模为 N 的问题转化为求解规模为 N/2 的子问题。
对于上面给出的基本思想,与其将其叫做二分查找,我更愿意称其为二分筛选。每一轮循环都实现了筛选掉一半不满足条件的元素,然后通过更改左右指针进入下一轮筛选 …… 直至将左闭右开区间 [low, high)
中的所有元素都筛选一遍。
【循环边界】的解释
以上二分筛选过程,L不断向右移动,R不断向左移动。当 L == R时,左闭右开区间 [L, R) 为空,所以对于左闭右开区间 [low, high) 的【循环边界】设置为 while (L < R) 便保证了将左闭右开区间 [low, high)区间的所有元素进行筛选了一遍。
而对于左闭右闭区间 [low, high]。当 L == R 时,左闭右闭区间 [L, R] 是有意义的,还包含一个元素,故其【循环边界】应该设置为 while (L <= R)。
【循环边界】小结
- 左闭右开区间 [low, high) → 循环边界为:while (L < R)
- 左闭右闭区间 [low, high] → 循环边界为:while (L <= R)
实现一个循环代码,除了搞清楚【循环边界】,更重要的是搞清楚循环最终目的和每一轮的目的。循环是在重复执行同一个操作,那么我们每一轮循环的目的应该是一致的。由此我们自然而然引出一个叫循环不变式的概念。其定义如下:
循环不变式是一种条件式(必须满足的条件,对循环而言是保持不变的,无论循环执行了多少次),循环语句没执行一次,就要求中间的结果必须符合不变式的要求。
事实上,循环不变式可以理解为循环的每一轮结束后应该满足的条件式。循环不变式可以很好的帮助我们快速而准确的实现一段正确的二分筛选的循环代码。
-
首先,我们写循环的最终目的:找到升序数组 nums 在左闭右开区间 [low, hihg) 中出现的第一个等于目标值 x 的元素下标。翻译翻译这句话即:找到一个下标i,使得 nums[low, i) < x <= nums[i, high)
-
其次,我们写循环的每一轮目的:每一轮循环筛选掉一半不满足条件的元素,然后通过更改左右指针进入下一轮筛选。翻译翻译这句话即:每一轮循环结束时,应满足:nums[low, L) < x <= nums[R, high),nums[L, R) 为下一轮待筛选元素。
注:这里结合循环的最终目的,可见最后一轮循环结束后,需要使 L == R,便可达到目的:nums[low, i) < x <= nums[i, high)。故这里也可以得出【循环边界】应该设置为:while (L < R) 的结论
【左右指针修改】的解释
【左右指针修改】是由循环的每一轮目的所决定的。在循环过程中无非两种情况:
- 若 nums[mid] >= x,则说明 nums[mid, R) >= x,右半区间元素被筛选淘汰,故令 R = mid,执行结束后满足 x <= nums[R, high),新待筛选区间为 [L, R)
- 若 nums[mid] < x,则说明 nums[l, mid+1) < x,左半区间元素被筛选淘汰,故令 L = mid + 1,执行结束后满足 nums[low, L) < x,新待筛选区间为 [L, R)
结合以上两种情况即有如下循环不变式:nums[low, L) < x <= nums[R, high),nums[L, R) 为下一轮待筛选元素
下面给出写二分查找(或二分筛选)的关键点所在:
- 明确【待筛选区间】和【二分筛选的目的】,并由此得出【循环边界】和【循环不变式】
快速写出二分查找代码示例
范例:找到升序数组 nums 在左闭右闭区间 [low, high] 中出现的最后一个等于目标值 x 的元素下标,若存在则返回其下标,否则返回 -1
分析:
- 由题目要求知:【待筛选区间】为左闭右闭 [low, high] ,【二分筛选目的】找到一个下标i,使得 nums[low, i] <= x < nums[i+1, high]
- 由【待筛选区间】为左闭右闭知每一轮循环结束时,新的筛选区间为 [L, R],故原区间 [low, high]被划分为三个区间:[low, L-1]、[L, R]、[R+1, high]。 综合【二分筛选目的】可知【循环不变式】为:nums[low, L-1] <= x < nums[R+1, high],[L, R]为待筛选区间
最终代码实现如下:
// 找到升序数组 nums 在左闭右闭区间 [low, hihg] 中出现的最后一个等于目标值 x 的元素下标,若存在则返回其下标,否则返回 -1
int last_bioSearch(int nums[], int low, int high, int x) {
int L = low, R = high ;
// 循环不变式:A[low,L-1] <= X < A[R+1,high],[L, R]为待筛选区间
while (L <= R) {
int mid = L + (R - L) / 2 ;
if (nums[mid] > x) R = mid-1 ; // 说明 nums[mid,R]>x,需在[L,mid-1]区间继续筛选,故令 R = mid-1
else L = mid + 1 ; // 说明 nums[L,mid] <= x,需在[mid+1,R]区间继续筛选,故令 L = mid+1
}
// 循环结束,R==L-1,此时满足 A[low,R] <= x < A[L,high]
// 根据上面循环不变式可知,nums[R]才可能是最后一个等于目标值x的元素,但只知道 nums[R] <= x,是“=”还是“<”需要进一步判断
if (R == high || nums[R] != x) {
return -1 ;
} else {
return R ;
}
}
2、二分查找出错或进入死循环的原因分析
二分查找的关键代码在于下面这段:
while (L < R) { // 待筛选区间左闭右开
int mid = L + ((R - L) / 2 );
if (nums[mid] >= x) R = mid ;
else L = mid + 1 ;
}
这段代码容易出错的有两个地方:
- 循环结束边界:取 L < R 还是 L <= R
- 左右指针的设置:设置不当就会导致死循环
循环结束边界前文已经做过解释,这里不再叙述。下面重点说明一下进入死循环的原因。
循环每一轮进行的操作为:将原待筛选区间 [L, R) 的元素进行一次筛选,筛选后更改左指针或右指针的值,形成新的待筛选区间 [L, R)。
由此可见,进入死循环的原因即是:新的待筛选区间与原待筛选区间相比,没发生任何改变。
首先,对于左指针 L,我们往往设定它向右边不断移动,因此其可能的取值为:L = mid,或 L = mid+1;对右指针,同理可得其可能的取值为:R = mid,或 R = mid -1;
而 mid = (L+R) / 2
在 C语言 中为向下取整。即 当 R = L+1
时,mid = (L+R) / 2 = L
,此时如果在循环语句中令 L = mid
,那么新的筛选区间就不会更改,从而陷入死循环。
比如以下代码,就是一个会进入死循环的代码:
while (L < R) { // 待筛选区间左闭右开
int mid = L + ((R - L) / 2 );
if (nums[mid] >= x) R = mid ;
else L = mid ; // 此条语句就会导致死循环产生
}
总结
至此,做一个关于二分查找的总结。写二分查找应该注意如下几点:
- 明确【待筛选区间】和【二分筛选的目的】,并由此得出【循环边界】和【循环不变式】
- 【左右指针修改】可能会导致死循环产生
#完