二分查找
介绍
二分查找 binary search,也称为折半搜索、对数搜索,是用来在一个 有序数组 中查找某一元素的算法。
算法流程:以在一个升序数组中查找一个数为例。
- 每次考察数组当前部分的 中间元素
- 如果中间元素刚好是要找的,就结束搜索过程
- 如果中间元素小于所查找的值,那么左侧的只会更小,因此在右侧查找
- 如果中间元素大于所查找的值,同理,只需到左侧查找
时间复杂度:
- 平均时间复杂度
- 最坏时间复杂度
因为在二分搜索过程中,算法每次都把查询的区间长度减半,所以对于一个长度为
模板
整数二分
cpp
int binary_search(int start, int end, int key) {
int ret = -1;
int mid;
while (start <= end) {
mid = start + ((end - start) >> 1); // 直接相加可能会溢出
if (arr[mid] < key) {
start = mid + 1;
}
else if (arr[mid] > key) {
end = mid - 1;
}
else {
ret = mid;
break;
}
}
return ret;
}
常用的模板一般是不需要 ret = -1
这种情况的。如果没有找到该元素,我们返回第一个大于它的元素的下标,之后再通过比对返回值和 key
是否相同来判断数组中是否存在该元素。
由此,二分查找也可以被定义为 查找数组中第一个 >= key
的元素下标。
很多语言都集成了这个函数:
C++ 代码
cpp
#include <algorithm>
using namespace std;
vector<int> nums = {1, 2, 3, 3, 5}
lower_bound(nums.begin(), nums.end(), val); // 返回首个 >= val 的元素的迭代器,不存在就返回 end()
upper_bound(nums.begin(), nums.end(), val); // 返回首个 > val 的元素的迭代器,不存在就返回 end()
Python 代码
python
import bisect
ls = [1, 2, 5, 5, 5, 7, 9]
index1 = bisect.bisect(ls, 5) # 返回首个 > key 的元素下标
index2 = bisect.bisect_left(ls, 5) # 返回首个 >= key 的元素下标
index3 = bisect.bisect_right(ls, 5) # 同 bisect.bisect
print(f"index1 = {index1}, index2 = {index2}, index3 = {index3}")
# index1 = 5, index2 = 2, index3 = 5
我们可以看出,一般都会集成两个函数(同时需要自行保证单调性):
- 返回第一个
>= key
的元素下标 - 返回第一个
> key
的元素下标
同样的,我们可以自行解决如下问题:
- 返回最后一个
< key
的元素下标:第一个>= key
的元素下标- 1
- 返回最后一个
<= key
的元素下标:第一个> key
的元素下标- 1
而如果需要我们手写,则可以分为四种写法:
- 闭区间
- 左闭右开区间
- 左开右闭区间
- 开区间
这里用 Python 实现全部四种写法,用其他语言实现其中一种。具体这种写法的正确性可以用 循环不变式 来证明,即 区间内始终是尚未经过判断的数,证明和思考过程可以看参考资料中的视频 —— 红蓝染色法。
Python 代码:闭区间
python
def lower_bound(nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1 # 闭区间 [left, right]
while left <= right: # 区间不为空
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1 # [mid + 1, right]
else:
right = mid - 1 # [left, mid - 1]
return left # right + 1
解释:
[0: left - 1]
区间总是< target
,染成红色(循环不变式,因此nums[left]
为所求)[right + 1: ]
区间总是>= target
,染成蓝色- 退出前一轮
left = right = mid
- 如果
nums[mid] < target
时,染成红色,left = mid + 1
- 如果
nums[mid] >= target
时,染成蓝色,right = mid - 1
- 此时
nums[left]
总被染成蓝色,因此是第一个>= target
的数 - 当然
nums[right + 1]
也符合要求
- 如果
- 一定要注意最后的
nums[left]
可能 越界
Python 代码:左闭右开区间
python
def lower_bound2(nums: List[int], target: int) -> int:
left = 0
right = len(nums) # 左闭右开区间 [left, right)
while left < right: # 区间不为空
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1 # [mid + 1, right)
else:
right = mid # [left, mid)
return left # right
解释:
[0: left - 1]
区间总是< target
,染成红色[right : ]
区间总是>= target
,染成蓝色- 退出前一轮为
[left, left + 1)
- 退出时为
[left, left)
,由于[right : ]
区间为蓝色,因此nums[left]
为所求
Python 代码:左开右闭区间
python
def lower_bound3(nums: List[int], target: int) -> int:
left = -1
right = len(nums) - 1 # 左开右闭区间 (left, right]
while left < right:
mid = (left + right + 1) // 2 # 由于向下取整导致只有一个元素的时候总是更新到左端点
if nums[mid] < target:
left = mid
else:
right = mid - 1
return left + 1 # right + 1
解释:
[0: left]
区间总是< target
,染成红色[right + 1: ]
区间总是>= target
,染成蓝色
Python 代码:开区间
python
def lower_bound4(nums: List[int], target: int) -> int:
left = -1
right = len(nums) # 开区间 (left, right)
while left + 1 < right: # 区间不为空
mid = (left + right) // 2
if nums[mid] < target:
left = mid # (mid, right)
else:
right = mid # (left, mid)
return right # left + 1
解释:
[0: left]
区间总是< target
,染成红色[right: ]
区间总是>= target
,染成蓝色- 退出时为
(left, left + 1)
要注意,只有 左开右闭区间 写法中需要对 mid
做 上取整 处理,因为出口情况 (l, l+1]
时由于下取整会导致 mid = l
,因此无法更新区间。
总结
我们要找的是 第一个蓝色
python
# 闭区间
[l, l + 1], mid = l =>
[l, l] or [l + 1, l + 1]
# 左闭右开区间
[l, l + 1), mid = l =>
[l, l) or [l + 1, l + 1) # 此时 l + 1 确定为蓝色
# 左开右闭区间
(l, l + 1], mid = l + 1 =>
(l, l] or (l + 1, l + 1] # 此时 l 确定为红色
# 开区间
(l, l + 2), mid = l + 1 =>
(l, l + 1) or (l + 1, l + 2) # 此时 l 确定为红色,l + 2 确定为蓝色
C++ 代码:左闭右开区间
cpp
int lower_bound(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) >> 1;
if (nums[mid] < target) {
left = mid + 1;
}
else {
right = mid;
}
}
return left;
}
浮点数二分
浮点数二分就比较简单了。一般有:
- 循环 100 次,等价于区间缩短为原先的
1/2^100
- 区间长度小于
1e-6
后停止
这两种终止方法。
cpp
double l = -10000;
double r = 10000;
while (r - l > 1e-8) {
double mid = (l + r) / 2;
if (mid * mid * mid >= n) {
r = mid;
}
else {
l = mid;
}
}
二分答案
三分法
其他
参考资料:
- 二分 - OI Wiki (oi-wiki.org)
- 二分查找 红蓝染色法_哔哩哔哩_bilibili
- codeforces-go/copypasta/sort.go at master · EndlessCheng/codeforces-go (github.com)
二分查找
模板题目:
力扣题目:
- 34. 在排序数组中查找元素的第一个和最后一个位置 模板
- 35. 搜索插入位置 模板
- 704. 二分查找 模板
- 2529. 正整数和负整数的最大计数 *应用
- 2300. 咒语和药水的成功对数 *应用
- 278. 第一个错误的版本
- 162. 寻找峰值
- 1901. 寻找峰值 II
- 153. 寻找旋转排序数组中的最小值
- 33. 搜索旋转排序数组
- 540. 有序数组中的单一元素
- 702. 搜索长度未知的有序数组(会员题)
- 2936. 包含相等值数字块的数量(会员题)
其他题目:
- https://codeforces.com/problemset/problem/600/B 1300
- https://atcoder.jp/contests/abc248/tasks/abc248_d
二分答案
力扣题目:
- 275. H 指数 II *经典题
- 1283. 使结果不超过阈值的最小除数 1542
- 2187. 完成旅途的最少时间 1641 *典型题
- 2226. 每个小孩最多能分到多少糖果 1646
- 1870. 准时到达的列车最小时速 1676
- 1011. 在 D 天内送达包裹的能力 1725
- 875. 爱吃香蕉的珂珂 1766 *经典题
- 1898. 可移除字符的最大数目 1913
- 1482. 制作 m 束花所需的最少天数 1946
- 1642. 可以到达的最远建筑 1962
- 2861. 最大合金数 1981
- 2258. 逃离火灾 2347
- 2137. 通过倒水操作让所有的水桶所含水量相等(会员题)
- 2702. 使数字变为非正数的最小操作次数(会员题)
洛谷题目:
其他题目:
- https://codeforces.com/contest/1701/problem/C 1400
- https://codeforces.com/contest/670/problem/D2 1500
- https://codeforces.com/problemset/problem/1610/C 1600
- https://codeforces.com/contest/1843/problem/E 1600
- https://codeforces.com/problemset/problem/1118/D2 1700
- https://codeforces.com/contest/883/problem/I 1900
三分法
洛谷题目:
最小化最大值
- 2064. 分配给商店的最多商品的最小值 1886
- 1760. 袋子里最少数目的球 1940
- 2439. 最小化数组中的最大值 1965
- 2560. 打家劫舍 IV 2081
- 778. 水位上升的泳池中游泳 2097 *相当于最小化路径最大值
- 2616. 最小化数对的最大差值 2155
- 2513. 最小化两个数组中的最大值 2302
最大化最小值
- 1552. 两球之间的磁力 1920
- 2517. 礼盒的最大甜蜜度 2021
- 2861. 最大合金数 1981
- 2812. 找出最安全路径 2154
- 2528. 最大化城市的最小供电站数目 2236
- LCP 12. 小张刷题计划