算法笔记 二分法
本文将介绍二分法这一重要思想。
一、二分查找
1.猜数字
玩家 A 从一个范围中选择一个数,然后 B 玩家猜这个数字。
对于 B 玩家而言,每次从范围的中间数猜测是最好的办法。
2.线性查找
如何在一个严格递增序列中找出想要的数字?
一个显而易见的方法是:线性扫描整个序列,若扫描到则返回,扫描完都没有发现则查找失败。这种方法的时间复杂度是 o(n) 。
3.二分查找
二分查找必须基于有序序列
假设在序列 A 中查找元素 x ,
设
left = 0, right = length - 1
;[left, right] 为查找区间设
mid = (left + right) / 2
更好写法:
mid = (left - right) / 2 + right
将 A[mid] 与 x 相比较,
若
A[mid] == x
,查找成功;若
A[mid] > x
,设right = mid - 1
,继续查找;若
A[mid] < x
,设left = mid + 1
,继续查找;
若
left > right
,查询失败。
二分查找的高效之处在于,每一步都可以去除当前区间中的一半元素,因此时间复杂度为 o(logn) 。
1 |
|
3.查询元素区间
假设递增序列 A 中的元素可能重复,求序列中第一个大于等于 x 的元素的位置 L 以及第一个大于 x 的元素的位置 R 。
例如:
递增序列 {1, 3, 3, 3, 6}
查询 3 ,得 L = 1 ,R = 4 ;
查询 5 ,得 L = 4 ,R = 4 ;
查询 6 ,得 L = 4 ,R = 5 ;
查询 8 ,得 L = 5 ,R = 5 ;
(1) 分析:
当序列中有 x 时, L 应该指向第一个 x , R 应该指向最后一个 x 的后一位;
当序列中没有 x 时, L 和 R 都应该指向第一个大于 x 的元素位置(插入 x 时 x 应在的位置)。
(2) 求 L :
设
left = 0, right = length
;[left, right] 为查找区间若 x 比所有元素都大,则应该指向序列的末尾的后一位
设
mid = (left + right) / 2
将 A[mid] 与 x 相比较,
若
A[mid] >= x
,说明 L 一定在 mid 左侧或 mid 处,应该往左子区间查询,并且不能抛弃 mid ,令right = mid
;若
A[mid] < x
,说明 L 一定在 mid 右侧,应该往右子区间查询,并且抛弃 mid ,令left = mid + 1
;
若
left == right
,查询成功,返回 left 或 right 。1.当范围缩小至相差一格时,例如 left = 2,right = 3,则 mid = 2 ,若
A[mid] >= x
,则right == mid == left
;若A[mid] < x
,则left == mid + 1 == left
。2.当范围缩小至相差两格时,例如 left = 2,right = 4,则 mid = 3 ,若
A[mid] >= x
,则right == mid
,变成相差一格的情况 ;若A[mid] < x
,则left == mid + 1 == left
。因此,这种收缩方法一定可以使范围缩小至
left == right
。查找的目标是找到一个位置,而不用判断此位置上的元素是否正确。因此,无需像二分查找一样当
left == right
时还要继续做判断,直接返回值就可以了。
代码如下:
1 |
|
(3) 求 R:
设
left = 0, right = length
;[left, right] 为查找区间若 x 比所有元素都大,则应该指向序列的末尾的后一位
设
mid = (left + right) / 2
将 A[mid] 与 x 相比较,
若
A[mid] > x
,说明 L 一定在 mid 左侧或 mid 处,应该往左子区间查询,并且不能抛弃 mid ,令right = mid
;若
A[mid] <= x
,说明 L 一定在 mid 右侧,应该往右子区间查询,并且抛弃 mid ,令left = mid + 1
;
若
left == right
,查询成功,返回 left 或 right 。
相比求 L 的代码,修改的是比较时的判断语句(
A[mid] > x
和A[mid] <= x
)
代码如下:
1 |
|
4.解题模板
用于解决“寻找有序序列第一个满足某条件的元素的位置”问题的固定模板
left = 0,right = length
1 |
|
用于解决“寻找有序序列第一个不满足某条件的元素的位置”问题的固定模板
left = 0,right = length
1 |
|
二、二分法扩展
1.根号
如何计算“根号二”的近似值。
- 令
left = 1
right = 2
- 令
mid = (left + right) / 2
- 将 mid2 与 2 相比较,
- 若
mid * mid > 2
,应在左区间中继续查找,令right = mid
- 若
mid * mid < 2
,应在右区间中继续查找,令left = mid
- 若
right - left <= eqs
,查找成功,结束循环
- 若
解题代码:
1 |
|
2.求根问题
计算“根号二”的近似值实际上是这样一个问题的特例:给定一个区间,给定一个函数 f(x) ,求方程 f(x) = 0 的根 。
解题代码:
1 |
|
3.一些错误
上文中的 1.根号 和 2.求根问题 都是摘抄原书代码,但个人觉得解题逻辑有些问题,解释如下:
- 函数的终止条件不应该是两个差值小于精度要求,而应该是 mid 的函数值小于要求
- 在判断条件中,若
f(mid) == 0
,应该直接返回值,而不是继续划分区间
修改后代码如下:
1 |
|
4.装水问题
要求在半径为 R 的半圆容器中注入水,水的高度为 h ,使得有水的面积 S1 与半圆面积 S2 的比例正好为 r 。给定 R 和 r ,求高度 h 。
显然,随着 h 的升高,比例 r 一定增加,降低则一定减少。因此可以采用二分法。
解题代码:
1 |
|
5.木棒切割问题
一个显而易见的结论:要求的木棒长度越长,则木棒的数量一定越小。
这个问题可以阶乘求解最后一个满足条件 “k >= K” 的长度 L ,不妨将其转化为求解第一个满足条件 “k < K” 的长度 L ,然后减一即可。
利用二分法的思想,求无法满足要求的最小长度,此长度减一便可以得到满足要求的最大长度。
1 |
|
三、快速幂
1.简单问题
先来看一个问题:给定三个正整数 a, b, m (a < 109 , b < 106 , 1 < m < 109 ),求 ab%m 。
可以用一段简单的代码解决,时间复杂度是 o(b) :
1 |
|
为什么可以每一步都对 m 取模呢?
因为最终结果需要对 m 取模,而如果中间值大于 m ,它继续运算相当于
n * m + 余数
参与运算,n * m
部分注定会被除掉。例如:
1
(m + 1) * n % m = n
而
1
2
(m + 1) % m = 1
1 * n % m = n结果完全相同。
2.复杂问题
给定三个正整数 a, b, m (a < 109 , b < 1018 , 1 < m < 109 ),求 ab%m 。
对于这个问题,显然按照之前的做法是不合适的,因为 o(n) 的复杂度在 1018 面前也非常吃力,此时便可以使用快速幂的方法。
3.递归快速幂
快速幂基于以下事实:
- 如果 b 是奇数,那么有 ab = a * ab-1
- 如果 b 是偶数,那么有 ab = ab / 2 * ab / 2
显然,将 b 转换为偶数,向下拆分,持续递归,便可以更快地计算出幂。
解题代码:
1 |
|
当 b 为偶数时,应该先计算出 fun(a, b / 2, m) ,在用两个运算结果相乘,否则会递归运算两次。
4.迭代快速幂
若将 b 转化为二进制,便可以将 b 转换为若干二次幂之和。
例如:
13 的二进制是 1101 ,可得 13 = 23 + 22 + 0 + 20 = 8 + 4 + 0 + 1,a13 = a8 + 4 + 1 = a8 * a4 * a0 * a1
又二进制仅有 0 和 1 ,可以用奇偶判断,因此可以无需转化。判断 b 的奇偶,如果为偶,说明此位上为 0 ,结果乘以 a0 结果不变,无需运算;如果为奇,说明此为上为 1 ,结果应该乘以 a2i 。
解题思路:
初始化 num = 1 ,用于存放结果
判断 b 的奇偶性,如果为奇,则 num 乘以 a
将 a 平方,将 b 除以 2 (移除末位)
解题代码:
1 |
|
参考
- 算法笔记