算法(一):二分
(1)概述
1.定义:二分是一种在有序数组中查找某一特定元素的搜索算法,时间复杂度是O(logn)。
2.目的:二分查找用于在多条记录中快速找到待查找的记录,它的思想是:每次将查找的范围缩小一半,直到最后找到记录或者找不到记录返回。
3.使用条件:①上下界确定 ②区间内有序(也可以是局部有序)。
4.思想:确定一个区间[L,R],需要找到一个性质(由题目条件决定),并且该性质满足一下两点: ①满足二段性 ②答案是二段性的分界点。即该性质整个区间分成两段,一段大于,另一段小于所要查找的结果(分界点)。
核心:假设目标值在闭区间[l, r]中, 每次将区间长度缩小一半,当l = r时,我们就找到了目标值。
(2)模版(整数二分)
1.第一类:
将区间[l, r]划分成[l, mid]和[mid + 1, r],其更新操作是r = mid或者l = mid + 1;计算mid时不需要加1。
此时mid的计算公式为:
m
i
d
=
(
l
+
r
)
/
2
mid = (l + r)/2
mid=(l+r)/2
python代码:
# 情景一,在一个有序区间内查找某个值,该值将区间二分为两部分,左侧小于目标值,右侧大于目标值
# 目标值为x,aim[mid]则是二分查找时的临时值,用以与目标值判断大小
# 如果临时值大于目标值,则区间左移,反之右移
def search(l, r):
while l < r:
mid = l + r >> 1 # 位运算,大幅提高代码效率,等价于:mid = (l + r) // 2
if aim[mid] >= x: r = mid # 此时目标值位于区间左侧
else: l = mid + 1
return l # 返回l、r都一样
# 情景二(推广形式),用函数check(mid)来判断二分值mid是否符合题目条件
# 区间被目标值分为两部分,左部分check函数返回False,右部分check函数返回True
def search(l, r):
while l < r:
mid = l + r >> 1
if check(mid): r = mid # 如果函数返回真,代表二分值mid位于右部分,即处于目标值右侧
else: l = mid + 1
return l
2.第二类:
当我们将区间[l, r]划分成[l, mid - 1]和[mid, r]时,其更新操作是r = mid - 1或者l = mid;此时为了防止死循环,计算mid时需要加1。
此时mid的计算公式为:
m
i
d
=
(
l
+
r
+
1
)
/
2
mid = (l + r + 1)/2
mid=(l+r+1)/2
python代码:
# 情景一,在一个有序区间内查找某个值,该值将区间二分为两部分,左侧小于目标值,右侧大于目标值
# 目标值为x,aim[mid]则是二分查找时的临时值,用以与目标值判断大小
# 如果临时值大于目标值,则区间左移,反之右移
def search(l, r):
while l < r:
mid = l + r + 1 >> 1 # 位运算,大幅提高代码效率,等价于:mid = (l + r + 1) // 2
if aim[mid] <= x: l = mid # 此时目标值位于区间左侧
else: r = mid - 1
return l # 返回l、r都一样
# 情景二(推广形式),用函数check(mid)来判断二分值mid是否符合题目条件
# 区间被目标值分为两部分,左部分check函数返回Ture,右部分check函数返回False
def search(l, r):
while l < r:
mid = l + r >> 1
if check(mid): l = mid # 如果函数返回真,代表二分值mid位于左部分,即处于目标值左侧
else: r = mid - 1
return l
(3)两类二分的说明(整数二分)
整数二分是指区间内都是一个个离散的数值,因此需要注意端点问题。
如下图所示,一个区间根据某个性质(目标值)被划分为两个区间,红色区间的任意一个值必然不会满足绿色区间的条件,而两类二分的区别就在于目标值ans位于绿区间的左端点还是红区间右端点。
1.如果目标值ans位于绿区间的左端点,当mid < ans时,mid落在红区间,必然不可能等于ans,因此改变区间时l就应该等于mid + 1;反之,当mid > ans时,mid落在绿区间,由于并不知道目标值ans是否存在于这个区间内,此时mid是否为绿区间的左端点未知,因此可能取到该点,即r = mid。
2.如果目标值ans位于红区间的右端点,当mid > ans时,mid落在绿区间,必然不可能等于ans,因此改变区间时r就应该等于mid - 1;反之,当mid < ans时,mid落在红区间,由于并不知道目标值ans是否存在于这个区间内,此时mid是否为绿区间的左端点未知,因此可能取到该点,即l = mid。
如果目标值位于区间内,则两类二分都能找到相同的ans值,但是如果区间内没有ans这个值,那么返回的最终结果就会有差异。
为了更好地理解两种二分的区别,举个例子,现在需要在下图的区间内查找目标值ans = 35,则将小于35的数划为红色区间,大于35的数划为绿色区间,由于在区间内没有35这个值,因此只能找到与35最接近的值。我们既可以将ans视作在绿区间左端点,也可以认为它位于红区间右端点。下面为第一类二分的步骤:
记l、m、mid为左右中点索引,num[l]、num[r]、num[mid]为具体值。
①初始mid = (0 + 8) / 2 = 4,则num[mid] = 23 < ans, 故令l = mid + 1 = 5。
②第二次循环,mid = (5 + 8) / 2 = 6(注意为整除)则num[mid] =32 < ans, 故令l = mid + 1 = 7。
③第三次循环,mid = (7 + 8) / 2 = 7(注意为整除)此时l = r,循环结束,输出此时num[l] = 49为最终结果。
从最终结果输出49可以看出,但ans不在区间内时,第一类二分会查找到与ans最接近的较大值。同理第二类二分则会查找到与ans最接近的较小值,这里就不演示了,大家可以去试一试。
结论:两类二分其实用哪一个没有太大区别,目标值如果存在,则都能找到目标值;目标值如果不存在,也都会找到最接近的值,只不过是从目标值不同的两侧找到的。做题时用哪个都行。
(4)实数二分及模板
实数二分是指区间内都是连续的值,因此不存在端点的问题,也就没必要区分两类二分,模板代码如下:
python代码:
def search(l, r):
# 由于可以取到区间的任意一个实数,如果只规定l < r,那么while会陷入死循环,因此确保答案达到足够的精度情况下设置停止条件
while r - l > 10**-8:
mid = (l + r) / 2
if check(mid) >= flo:
r = mid
else:
l = mid
return l
(5)例题一
数的范围 Acwing题目地址
给定一个按照升序排列的长度为 n 的整数数组,以及 q 个查询。
对于每个查询,返回一个元素 k 的起始位置和终止位置(位置从 0开始计数)。
如果数组中不存在该元素,则返回 -1 -1
。
输入格式
第一行包含整数 n和 q,表示数组长度和询问个数。
第二行包含 n 个整数(均在 1∼10000 范围内),表示完整数组。
接下来 q 行,每行包含一个整数 k,表示一个询问元素。
输出格式
共 q 行,每行包含两个整数,表示所求元素的起始位置和终止位置。
如果数组中不存在该元素,则返回 -1 -1
。
数据范围
1 ≤ n ≤ 100000
1 ≤ q ≤ 10000
1 ≤ k ≤ 10000
输入样例:
6 3
1 2 2 3 3 4
3
4
5
输出样例:
3 4
5 5
-1 -1
这题的意思是需要从一个升序数组中寻找到某个数的起始位置和末尾位置,例如样例的数组,输入查询3,则则返回3 4
,表示在数组中,3这个数字第一次出现是在索引为3的地方,最后一次出现是在索引为4的地方,事实也确实如此。
思路:
很明显,要查找两个数,就需要用到两次二分,首先查找查询第一次出现的索引。由于数组是升序的,因此可以将找到的起始位置索引当做l进行第二次二分找到末尾位置。
python代码:
if __name__ == '__main__':
n,q = map(int,input().split())
nums = list(map(int,input().split()))
while q:
ans = int(input())
l, r = 0, n - 1
# 第一次二分找到起始位置
while l < r:
mid = (l + r) >> 1
if nums[mid] >= ans:
r = mid
else:
l = mid + 1
# 如果能找到目标值,则返回,否则输出-1
if nums[l] == ans:
print(l,end = " ")
else:
print(-1,end = " ")
# 重置区间右端点r,进行第二次二分,此时的l就是起始位置的索引
r = n - 1
while l < r:
mid = (l + r + 1) >> 1
if ans >= nums[mid]:
l = mid
else:
r = mid - 1
if nums[l] == ans:
print(l)
else:
print(-1)
q -= 1
第一次二分既可以选择第一类二分,也可以选择第二类。但需要注意,第二次二分则必须选择第二类二分,如下图所示,以起始位置作为左端点再次进行二分,新区间被终止位置分成两段,并且终止位置一定位于左半段的右端点(因为数组升序)。
并且,当num[mid] == ans时,查询的终止位置要么就是在mid位置,要么就是在mid的右侧,因为mid的存在,左侧相同的查询数字一定不是终止位置,因此需要让l = mid继续二分。综上,本题寻找终止位置的二分符合第二类的情况。
(6)例题二
机器人跳跃问题 Acwing题目地址
机器人正在玩一个古老的基于 DOS 的游戏。
游戏中有 N+1 座建筑——从 0 到 N 编号,从左到右排列。
编号为 0 的建筑高度为 0 个单位,编号为 i 的建筑高度为 **H(i)**个单位。
起初,机器人在编号为 0 的建筑处。
每一步,它跳到下一个(右边)建筑。
假设机器人在第 k 个建筑,且它现在的能量值是 E,下一步它将跳到第 k+1 个建筑。
如果 H(k+1) > E,那么机器人就失去 H(k+1) − E 的能量值,否则它将得到 E − H(k+1) 的能量值。
游戏目标是到达第 N 个建筑,在这个过程中能量值不能为负数个单位。
现在的问题是机器人至少以多少能量值开始游戏,才可以保证成功完成游戏?
输入格式
第一行输入整数 N。
第二行是 N 个空格分隔的整数,H(1), H(2), …, H(N) 代表建筑物的高度。
输出格式
输出一个整数,表示所需的最少单位的初始能量值上取整后的结果。
数据范围
1 ≤ N, H(i) ≤ 105
输入样例1:
5
3 4 3 2 4
输出样例1:
4
输入样例2:
3
4 4 4
输出样例2:
4
思路:
机器人在跳跃过程中,可能会有能量的损失导致它的总能量为负。在障碍物不变的情况下,初始能量值的多少就成了机器人能否完成游戏的唯一因素。因此可以使用二分查找,规定一个初始能量的区间,目标值就是通过游戏的最少初始能量,区间则被目标值被分为两段。
而本题的另一个关键就是如何确定初始能量的区间,根据题目给的能量计算公式,机器人在第 k + 1 个建筑时的能量等于第 k 个建筑时的能量乘2减去第 k 个建筑的高度。
E
k
+
1
=
2
E
k
−
H
k
+
1
E_{k+1} = 2E_k - H_{k+1}
Ek+1=2Ek−Hk+1
因此可以很明显的得出结论,当初始能量 E0 等于所有建筑物中最高的高度时,机器人一定能完成游戏。
由此可以得到需要二分的区间为:[0, max(Hi)],然后写一个check函数,如果二分中的mid(初始能量)在游戏过程中会使得机器人在某个建筑物时能量为负,则返回False,否则返回True。
python代码:
def check(mid):
for i in range(n):
mid = 2 * mid - h[i]
if mid < 0:
return False
return True
if __name__ == "__main__":
n = int(input())
h = list(map(int, input().split()))
max_num = max(h)
l, r = 0, max_num
while l < r:
mid = l + r >> 1
# 如果返回值为真,则说明该初始能量能够完成游戏,但是否为最小值不确定,因此需要向左缩小区间继续二分
if check(mid):
r = mid
# 如果返回值为假,则说明游戏中能量会为负,则该初始能量太小不足以完成游戏,因此需要向右缩小区间
else:
l = mid + 1
print(l)