前言
二分查找是在有序数组中快速搜寻某元素的算法,其思想易懂,但在实现过程中却存在很多的细节问题,比如:
- 左区间端点更新时是加一还是不加?
- 右区间端点更新时是减一还是不减?
- 循环的终止条件是 < < <还是 ≤ \leq ≤?
接下来,我将以例题入手,带你逐步解开这些疑惑。
例题分析一
1.题目描述
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
2.函数定义
int binarySearch(int *nums, int numsSize, int target)
{
}
3.原题链接
4.题解
int binarySearch(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize - 1;
int mid;
while (left <= right)
{
mid = (left + right) / 2;
if (nums[mid] < target)
{
left = mid + 1;
}
else if (nums[mid] > target)
{
right = mid - 1;
}
else
{
return mid;
}
}
return -1;
}
5.分析
这是一个元素存在性问题,我们在
[
l
e
f
t
,
r
i
g
h
t
]
[left,right]
[left,right] 中要寻找等于
t
a
r
g
e
t
target
target 的元素,首先判断
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 与
t
a
r
g
e
t
target
target 的大小关系,如果
n
u
m
s
[
m
i
d
]
<
t
a
r
g
e
t
nums[mid]<target
nums[mid]<target,那么要寻找的元素必定在
[
m
i
d
+
1
,
r
i
g
h
t
]
[mid + 1,right]
[mid+1,right] 内,所以左区间端点更新时需要加一。同理,右区间端点更新时也需要减一。
下面再分析循环的终止条件为什么是
≤
\leq
≤,我们设想这样一种情况,当左右区间端点相等即
l
e
f
t
=
r
i
g
h
t
left = right
left=right 时,
m
i
d
=
l
e
f
t
=
r
i
g
h
t
mid = left = right
mid=left=right,此时
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 与
t
a
r
g
e
t
target
target 的大小关系还没有被判断,因此当
l
e
f
t
=
r
i
g
h
t
left = right
left=right 时还要再循环一次。
例题分析二
1.题目描述
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
2.函数定义
int searchInsert(int *nums, int numsSize, int target)
{
}
3.原题链接
4.题解
int searchInsert(int *nums, int numsSize, int target)
{
int left = 0;
int right = numsSize; // (1)
int mid;
while (left < right) // (2)
{
mid = left + (right - left) / 2; // (3)
if (nums[mid] < target)
{
left = mid + 1;
}
else
{
right = mid; // (4)
}
}
return right;
}
5.分析
如果简化题意,就是要寻找
≥
t
a
r
g
e
t
\geq target
≥target 的最小值,返回其下标。如果
n
u
m
s
[
m
i
d
]
≥
t
a
r
g
e
t
nums[mid]\geq target
nums[mid]≥target,那么
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 就满足了寻找的一个条件,那
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 是否最小呢?这我们无法确定,它可能是最小的,但也可能不是。所以我们接下来要在
[
l
e
f
t
,
m
i
d
]
[left,mid]
[left,mid] 中去寻找,这对应了
(
4
)
(4)
(4) 处右区间端点更新时不减一。
那为什么
(
2
)
(2)
(2) 处循环终止条件是
<
<
<呢?当
l
e
f
t
=
r
i
g
h
t
left = right
left=right 时,
[
l
e
f
t
,
r
i
g
h
t
]
[left,right]
[left,right] 中只有一个元素
r
i
g
h
t
right
right, 而
r
i
g
h
t
right
right 其实是由之前的
m
i
d
mid
mid 更新而来,由于之前
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 已经判断过和
t
a
r
g
e
t
target
target 的大小关系,所以我们不需要再判断了。那如果写
≤
\leq
≤,多判断一次会怎样?我告诉你:你将会陷入此循环中万劫不复!
哈哈哈开玩笑的啦,不就是个死循环嘛。
设
l
e
f
t
=
r
i
g
h
t
=
a
left = right = a
left=right=a,则
m
i
d
=
a
mid = a
mid=a,在右区间端点
r
i
g
h
t
≠
n
u
m
s
S
i
z
e
right\neq numsSize
right=numsSize 的情况下,右区间端点对应的元素已经确定大于等于
t
a
r
g
e
t
target
target了。也就是现在的
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 确定是
≥
t
a
r
g
e
t
\geq target
≥target 的,那么无论接下来循环多少次,
r
i
g
h
t
right
right 都等于
a
a
a。在右区间端点
r
i
g
h
t
=
n
u
m
s
S
i
z
e
right = numsSize
right=numsSize 的情况下,还会造成数组越界访问。所以当
l
e
f
t
=
r
i
g
h
t
left = right
left=right 时循环应终止,最后返回
l
e
f
t
left
left 或
r
i
g
h
t
right
right 都是一样的。
接下来,再来看
(
1
)
(1)
(1) 处,如果
t
a
r
g
e
t
target
target 比数组最后一个元素还要大,那么其插入位置就应该是数组尾部的后一个位置,也就是对应下标为
n
u
m
s
S
i
z
e
numsSize
numsSize 的地方。所以要在
[
0
,
n
u
m
s
S
i
z
e
]
[0,numsSize]
[0,numsSize] 中去寻找插入位置,右区间端点
r
i
g
h
t
right
right 就应初始化为
n
u
m
s
S
i
z
e
numsSize
numsSize。
最后说一下
(
3
)
(3)
(3) 处,首先证明在数学上与
m
i
d
=
(
l
e
f
t
+
r
i
g
h
t
)
/
2
mid = (left + right) / 2
mid=(left+right)/2 是等价的。证明非常简单:
m i d = l e f t + r i g h t − l e f t 2 mid = left + \frac{right - left}{2} mid=left+2right−left
= 2 × l e f t + r i g h t − l e f t 2 =\frac{2 \times left + right - left}{2} =22×left+right−left
= l e f t + r i g h t 2 = \frac{left + right}{2} =2left+right
那既然如此,为什么还要采用 ( 3 ) (3) (3) 处的写法呢?难道是为了秀操作?当然不是啦。我们知道,数据是有范围的,当 l e f t left left 和 r i g h t right right 都很大的时候,它们的和就有可能超过 32 32 32 位整型的最大值 2147483647 2147483647 2147483647,从而产生错误,而求差则不会,所以 ( 3 ) (3) (3) 处的写法是很安全的。
举一反三
好了,看到这里相信你已经对二分查找的各种细节都有所了解了。那么接下来就容我问你三个问题:
给定一个排序数组 n u m s nums nums 和一个目标值 t a r g e t target target,请你返回:
- 大于 t a r g e t target target 的最小值的下标
- 小于等于 t a r g e t target target 的最大值的下标
- 小于 t a r g e t target target 的最大值的下标
实际上这三个问题都与例题二类似,这里我就讲解第3个问题。
int binarySearch(int *nums, int numsSize, int target)
{
int left = -1;
int right = numsSize - 1;
int mid;
while (left < right)
{
mid = right - (right - left) / 2; // (1)
if (nums[mid] < target)
{
left = mid;
}
else
{
right = mid - 1;
}
}
return left;
}
这里我就只对
(
1
)
(1)
(1) 处进行分析了。至于其他的么……
此题
(
1
)
(1)
(1) 处的写法与例题一、例题二中写法在数学上都是等价的。但在这道题中,如果用例题一或例题二的写法就必然有问题。你能思考这是为什么吗?
考虑这样一种情况,当
l
e
f
t
=
r
i
g
h
t
−
1
=
a
left = right -1 = a
left=right−1=a 时,如果采用例题一或例题二的写法,不难算出
m
i
d
=
l
e
f
t
=
a
mid = left = a
mid=left=a ,如果
l
e
f
t
≠
−
1
left \neq -1
left=−1,
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 确定是
<
t
a
r
g
e
t
< target
<target 了,所以接下来无论循环多少次 ,都有
l
e
f
t
=
r
i
g
h
t
−
1
=
a
left = right - 1 = a
left=right−1=a,也就是陷入了死循环。如果
l
e
f
t
=
−
1
left = -1
left=−1,会造成数组越界访问。此题
(
1
)
(1)
(1) 处写法在这种情况下,
m
i
d
=
r
i
g
h
t
=
a
+
1
mid = right = a + 1
mid=right=a+1, 接下来无论
n
u
m
s
[
m
i
d
]
nums[mid]
nums[mid] 与
t
a
r
g
e
t
target
target 大小关系什么,都会有
l
e
f
t
=
r
i
g
h
t
left = right
left=right,然后跳出循环。同样的,例题二也不能采用此题的写法,可以自己试着分析一下。
总结
好了,相信经过我的一番心思缜密(
乱
七
八
糟
乱七八糟
乱七八糟)的分析以后,你对二分查找的各种细节问题都已了如指掌(
稀
里
糊
涂
稀里糊涂
稀里糊涂)。但真正让你有所提升的,一定不是看我的文章,而是自己动手实践,多思考,多总结。