二分查找:
1. 基本二分查找
给一个数组,已升序排序,即不存在重复元素,查找给定值target,如果不存在,返回该值在数组中可以插入的位置。二分查找本质是利用分治加剪枝不断进行问题规模的缩小,到最后问题不可分解决问题。将一个区间分为两半(缩小规模),分别查找,因为另一个子问题肯定无解,不需要查找(剪枝),所以本质是剪枝的分治。另外对二分查找循环不变式分析的过程要按查找值存在与否分类讨论,且随着迭代的深入,区间大小始终是在减半收缩。
1.1. 流程
1)将查找区间一分为二,选取中间点,若是要查找元素,返回
2)如果中间点小于target值,查找右半区间,从 mid + 1 到 right
3)若中间点大于target值,查找左半区间,从 left 到 mid - 1
4)如果区间不存在,即left > right ,结束。
1.2. 朴素实现
int binary_search(int *arr, int size, int target) {
int l = 0, r = size - 1;
while (l <= r) {
int mid = l + (r-l) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] > target) {
r = mid - 1;
} else {
l = mid + 1;
}
}
return l;
}
1.3. 分析
1.3.1 循环条件解释
因为循环的本质是在进行区间的收缩,因为对于一个target来说,肯定要么在左半区间,要么在右半区间,或者刚好在区间中间点(会直接返回),其他情况都需要不断查找target在哪个半区间,而区间最小的情况是只有一个元素,即 [ l e f t , l e f t ] [left, left] [left,left]或者 [ r i g h t , r i g h t ] [right,right] [right,right]。
1.3.2 循环不变式
循环不变式分为两种情况讨论,如果target在数组中,如果target不在数组中
1. target在数组中
target一定在区间
[
l
e
f
t
,
r
i
g
h
t
]
[left, right]
[left,right]中。
- 初始情况
target值一定在区间 [ l e f t , r i g h t ] [left,right] [left,right]中。 - 迭代
将区间一分为二,如果mid所指值小于target,因为target存在,那么target一定在 [ m i d + 1 , r i g h t ] [mid + 1, right] [mid+1,right]右半区间,如果mid所指的值大于target,同理target的值一定在 [ l e f t , m i d − 1 ] [left, mid - 1] [left,mid−1]的左半区间,通过不断的收缩区间大小为原来一半,不断缩小搜索范围。 - 终止
可以知道最终left会等于right,此时由不变式分析,target一定在 [ l e f t , r i h g t ] [left,rihgt] [left,rihgt]区间,此时区间只有一个数一定是target。
2. target不在数组
循环不变式: 如果要插入target则一定插入在区间中的某个位置,要么就是插入在left,或者插入在right之后。
-
初始情况
初始情况右三种:- I. target在数组下标left之前
此时区间中所有值均大于target,target插入必定插入在left - II. target在数组right之后
此时target大于数组中所有值,插入一定插入在right之后 - III. target在数组中某两个数之间
[
i
,
i
+
1
]
[i, i + 1]
[i,i+1]
此时插入一定是插入在数组中间
- I. target在数组下标left之前
-
迭代
- I. target在数组left之前
此时区间中所有的数均大于target,所以一直查找 [ l e f t , m i d − 1 ] [left, mid - 1] [left,mid−1]左半区间。left始终不变,插入位置在left。 - II. target在right之后
此时区间中所有数都小于target,一直查找 [ m i d + 1 , r i g h t ] [mid + 1, right] [mid+1,right]右半区间,right不变,插入位置在right之后。 - III. target在数组中某两个数之间
[
i
,
i
+
1
]
[i,i+1]
[i,i+1]
此时分类:-
m
i
d
>
i
+
1
mid > i+1
mid>i+1
即 [ i , i + 1 ] [i,i+1] [i,i+1]在左半区间,有 t a r g e t < a r r [ i + 1 ] < a r r [ m i d ] target < arr[i+1] < arr[mid] target<arr[i+1]<arr[mid], 那么会查找左区间 [ l e f t , m i d − 1 ] [left, mid - 1] [left,mid−1], m i d − 1 mid - 1 mid−1更接近 i + 1 i + 1 i+1,区间在向[i,i+1]收缩。循环不变式满足 -
m
i
d
<
i
mid < i
mid<i
如果在右区间即 a r r [ m i d ] < a r r [ i ] < t a r g e t arr[mid] < arr[i] < target arr[mid]<arr[i]<target会查找右半区间 [ m i d + 1 , r i g h t ] [mid + 1, right] [mid+1,right], m i d + 1 mid + 1 mid+1更接近 i i i,区间的边界向[i, i + 1]收缩,循环不变式满足。 -
m
i
d
=
i
mid = i
mid=i
i等于mid,此时 a r r [ m i d ] = a r r [ i ] < t a r g e t arr[mid] = arr[i] < target arr[mid]=arr[i]<target,下一轮查找 [ m i d + 1 , r i g h t ] = [ i + 1 , r i g h t ] [mid+1, right] = [i+1, right] [mid+1,right]=[i+1,right]。后续迭代进入I的情况。left是要插入位置,循环不变式成立。 -
m
i
d
=
i
+
1
mid = i + 1
mid=i+1
mid等于i + 1,有 t a r g e t < a r r [ i + 1 ] = a r r [ m i d ] target < arr[i+1] = arr[mid] target<arr[i+1]=arr[mid],下一轮查找 [ l e f t , m i d − 1 ] = [ l e f t , i ] [left, mid - 1] = [left, i] [left,mid−1]=[left,i] 。后续迭代进入II的情况。
-
m
i
d
>
i
+
1
mid > i+1
mid>i+1
总结迭代的情况,由于区间一直向 [ i , i + 1 ] [i,i+1] [i,i+1]收缩,即区间收缩时,左边界向i靠近,右边界向i+1靠近,最后一定会进入迭代I、II的情况。
- I. target在数组left之前
-
终止
- I. target在left之前
此时,区间一直在收缩,直到left=right,最后一轮 t a r g e t < a r r [ l e f t ] = a r r [ m i d ] target < arr[left] = arr[mid] target<arr[left]=arr[mid], 下一轮查找区间 [ l e f t , m i d − 1 ] = [ l e f t , l e f t − 1 ] [left, mid - 1] = [left, left - 1] [left,mid−1]=[left,left−1] ,区间不存在停止。此时左边界left指向了target可以插入的位置。 - II. target在right之前
区间一直收缩,最后一轮 a r r [ m i d ] = a r r [ r i g h t ] < t a r g e t arr[mid] = arr[right] < target arr[mid]=arr[right]<target,下一轮查找 [ m i d + 1 , r i g h t ] = [ r i g h t + 1 , r i g h t ] [mid + 1, right] = [right + 1, right] [mid+1,right]=[right+1,right],此时左边界right+1指向了target可以插入的位置,也即循环终止时的left值指向。 - III. target在数组
[
i
,
i
+
1
]
[i,i+1]
[i,i+1]之间
此时迭代会一直向 [ i , i + 1 ] [i,i+1] [i,i+1]收缩,最后一定会使得迭代进入情况I或者II的终止情况,最终迭代均结束,终止时左边界始终是target要插入的位置。
- I. target在left之前
总结:对于target不在数组中,在迭代过程中由于区间一直向收缩,最终都会落入I或者II类情况,即target已经在搜索区间之外的情况,最终迭代都会结束。
1.4. 终止过程分析
本节讨论循环如何终止的情况,分为最后只剩1个元素,2个元素,3个元素的情况,讨论如下
- 只有一个元素
如果只有一个元素,那么left=right。-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
如果迭代发现target小于mid的值,查找左半区间边,right = mid - 1 = left - 1,此时left指向插入点位置。 -
t
a
r
g
e
t
>
n
u
m
s
[
m
i
d
]
target > nums[mid]
target>nums[mid]
如果发现target大于mid的值,查找右半区间left = mid + 1 = right + 1,此时left仍然停在插入点位置。
-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
- 有两个元素
此时 mid = left = right - 1-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
此时查找左半区间,right = mid - 1 = left - 1,left停在插入点位置 -
t
a
r
g
e
t
>
n
u
m
s
[
m
i
d
]
target > nums[mid]
target>nums[mid]
此时查护照右半区间, left = mid + 1 = right,进入一个元素的情况
-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
- 有三个元素
left + 1 = mid = right - 1。-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
查找左半区间, right = mid -1 = left, 进入一个元素的情况 -
t
a
r
g
e
t
>
n
u
m
s
[
m
i
d
]
target > nums[mid]
target>nums[mid]
查找右半区间, left = mid + 1 = right, 进入一个元素的情况
-
t
a
r
g
e
t
<
n
u
m
s
[
m
i
d
]
target < nums[mid]
target<nums[mid]
1.5. 收缩区间的边界问题
在收缩过程中,区间只可以向左半区间或者右半区间收缩,如果区间元素个数大于2,那么mid一定是处于left和right中间的,无论向左向右区间一定是可以正常缩小的,但是如果区间只有2个元素或者一个元素时,由于mid的计算方式导致mid会等于left或者right,如果left和right赋值为mid,区间可能会出现无法进一步缩小的情况,所以left和right要如何赋值来使区间收缩是非常重要的细节,一不小心就可能死循环需要注意,尤其是left切记不可以赋值为mid,right赋值为mid时循环条件需要改为while (left < right)
- left = mid 向右收缩边界问题
当区间元素大于2时,区间都可以正常向右缩小,但是当区间收缩到只有两个元素时可能无法继续向右收缩,因为如果下一步需要在右半区间查找,向右收缩,此时 r i g h t = l e f t + 1 right = left + 1 right=left+1,且 m i d = l e f t + ( r i g h t − l e f t ) / 2 = l e f t + ⌊ 1 / 2 ⌋ = l e f t mid = left + (right - left) / 2 = left + \lfloor 1 /2\rfloor = left mid=left+(right−left)/2=left+⌊1/2⌋=left,如果令left=mid, 那么mid会一直等于left,left也会一直等于mid,区间无法继续收缩,导致下一轮迭代区间还是不变,陷入死循环。但是如果确实需要使用,可以考虑修改计算中点的算法,避免陷入死循环。 - right = mid 向左收缩问题边界
此时区间元素大于1时均可正常向左收缩,但是在区间只有最后一个元素时, l e f t = r i g h t , m i d = l e f t = r i g h t left=right, mid = left = right left=right,mid=left=right,如果需要进一步查找左半区间会使得right一直停留在原处,无法继续向左收缩区间陷入死循环。但是可以改变收缩条件为while (left < right)这样就可以避免陷入死循环,但是引出的问题就是最后出循环后需要多一轮判断,因为最后的值到底存在与否不可知道,或者也可以将right置为取不到的值,也就是首轮时right在区间尾部,类似STL中的end,这样也可以保证,但是逻辑上肯定有问题,得修修补补。
2. 变形
2.1. 下界
如果数组非降序排列,可能存在重复元素,给定target,若存在返回该值的最小下标,如果不存在返回可插入的位置。
2.1.1. 分析
此时需要返回第一个位置,比较时如果相等,不可以直接返回,因为该位置可能是众多target中间的位置,所以需要继续查找,但是对于等于的情况要如何处理,是查找左半区间,还是查找右半区间。
- 查找左半区间
此时如果左半区间存在target值,根据以上对朴素二分查找循环不变式分析,如果左半区间存在,一定找得到,如果不存在,则区间中元素均小于target,会一直查找右半,那么最后停止时left一定指向right的右边,即之前的中间值位置,由于之前等于是查找左半区间 a r r [ m i d ] > = t a r g e t , m i d = r i g h t + 1 , a r r [ r i g h t + 1 ] > = t a r g e t arr[mid] >= target, mid = right + 1,arr[right + 1] >= target arr[mid]>=target,mid=right+1,arr[right+1]>=target,那么终止时left一定指向最小的下标,或者插入的位置。 - 查找右半区间
此时如果后面有target,那么一定找的到位置,如果不存在,则知道区间中所有元素均大于target的值,会一直查找最左半,right最后停止在left的右边,$arr[left - 1] <= target $,则right可能指向最大下标,但是left一定是指向了插入位置。
综上等于的情况查找左半区间最后left可能是最小下标,可能是插入位置,查找右半区间则right可能是最大下标,否则left是插入位置。
int lower_bound(int *arr, int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
if (target <= arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
- 中间值等于目标值时,搜索左半区间
如果左半区间中没有目标值,那么计算出的所有mid均小于target,一直执行l=mid + 1, 那么最后l=r时,l = mid + 1 = r + 1,即最小下标。如果左半区间有目标值,如果mid小于目标值,l = mid + 1, 如果mid等于目标值(只可能等于,因为上一轮mid是等于),那么 r = mid - 1。进行不断的区间收缩迭代,一定可以找到最下下标。
2.2. 上界
给定数组非降序排列,给定target,若存在返回该值的最大下标,不存在,则返回可插入的位置。和下界问题类似,等于的情况查找右半区间即可。要么存在
int upper_bound(int *arr, int n, int target) {
int left = 0;
int right = n - 1;
while (left <= right) {
if (target >= arr[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right >= 0 && arr[right] == target) {
return target;
} else {
return left;
}
}
2.2.3. 循环不变式分析
上述的上界和下界问题,循环不变式分析,如果不存在target,那么情况和普通的查找一致,如果存在target,分析核心见2.2.1节的思想,即分析找到target后要如何查找区间,对区间做是否存在target来做分类讨论。
2.3. 寻找带有重复值的旋转数组最小值
leetcode 154
这个问题也是二分的思想求解问题,因为二分的核心思想是收缩区间,所以在这个问题也是一样,但是这个问题对于中间值等于左右值时,无法确定查找值在左半区间还是右半区间,但是可以确定一定在左右之间,所以此时只能简单的通过同时缩减左右一个单位来减少区间大小以减小问题规模。
int findMin(vector<int>& nums) {
int size = nums.size();
if (size == 1) return nums[0];
int l = 0; int r = size - 1;
while (l < r) { // pivot must in [l,r], while condition
int mid = l + (r - l) / 2;
if (nums[l] < nums[r]) break;
if (nums[mid] > nums[r]) { // pivot must in right half range [mid + 1, r]
l = mid + 1;
} else if(nums[mid] < nums[r]) { // pivot must in left half range [l, mid]
r = mid;
} else {
if (nums[l] > nums[r]) {
r = mid;
} else { // no more info to know whether pivot is located in left or right of mid, just shrink the range by adjust left and right
++l;
--r;
}
}
}
return nums[l];
}
3. 总结
对于二分查找问题,本质是分治,只不过其余问题不用解而已。在一个区间 [ l e f t , r i g h t ] [left,right] [left,right]中查找一个值,值要么在,要么不在。如果在要么在 [ l e f t , m i d − 1 ] [left, mid - 1] [left,mid−1],要么在 [ m i d + 1 , r i g h t ] [mid + 1, right] [mid+1,right],或者就在mid处。如果值不在,则讨论target的插入位置是否在区间中。通过想像一个区间被mid一分为二两个半区间,固定不动,然后用target和mid比较以确定target所在半区间,以及要如何收缩,以此思考问题的解决过程。
- 区间收缩
分析时只考虑中间点将区间一分为二,然后确定下一步需要搜索的区间,只有下图的模型,考虑target在哪个区间,以及如何收缩区间的问题。而考虑left和right之间有几个元素属于终止条件的问题,是边界问题,迭代的一般过程无需考虑,只需知道区间一直在减小即可。//left ..................mid....................right// - 终止条件和边界
由于区间收缩到最后会出现只有两个元素和只有一个元素的情况,此时mid的值可能等于left,也可能等于right,是收缩区间的时候属于特殊情况,见1.4分析。
本文详细分析了二分查找算法,包括基本的流程、朴素实现、循环条件解释和循环不变式分析。重点探讨了目标值存在与不存在时的循环不变式,并对变形如下界、上界及寻找旋转数组最小值问题进行了讨论。总结了二分查找的本质是分治和区间收缩,强调了处理边界问题的重要性。
498





