前言:本篇博客内容总结自《算法导论》和《算法笔记》。
二分查找
基础用法
二分查找解决的问题是 :如何在一个严格递增(递减)序列A中找出给定的数x。
可以将二分查找理解成一个将区间不断压缩直到“夹出”欲查询元素下标的过程。它的算法原理是一开始令[left, right]为整个序列的下标区间,然后每次计算当前[left, right]的中间位置mid = (left + right) / 2。判断A[mid]与欲查询的元素x的大小。
- 如果A[x] == x,说明查找成功,返回元素下标后退出查询;
- 如果A[mid] > x,说明元素x在mid的左边,则需要往左子区间[left, mid - 1]继续查找;
- 如果A[mid] < x,说明元素x在mid的右边,则需要往右子区间[mid + 1, right]继续查找。
二叉查找的高效之处在于,每一步都可以去除当前区间中的一半元素,因此其时间复杂度是。
模板如下:
// A[]为严格递增序列,left为二分下界,right为二分上界,x为欲查询的数
// 二分区间为左闭右闭的[left, right],传入的初值为][0, n - 1]
int BinarySearch(int A[], int left, int right, int x)
{
int mid; // mid为left和right的中点
while (left <= right) // 如果left > right就没办法形成闭区间了
{
mid = (left + right) / 2; // 取中点
if (A[mid] == x)
return mid; // 找到x,返回下标
else if (A[mid] > x) // 中间的数大于x
right = mid - 1; // 往左子区间[left, mid - 1]查找
else // 中间的数小于x
left = mid + 1; // 往右子区间[mid + 1, right]查找
}
return -1;
}
二分查找的过程与序列下标从0开始还是从1开始无关,不信的同学可以亲自动笔验证一下,以便加深印象。查询严格递减序列时,只需将代码中的A[mid] > x改为A[mid] < x即可。
需要注意的是,如果二分上界超过int型数据范围的一半,那么当欲查询元素在序列靠后的位置时,语句mid = (left + right) / 2中的left + right就有可能超过int而导致溢出,此时一半用mid = left + (right - left) / 2来代替。两者是完全等价的,后者不过是前者拆分数得到的。
衍生用法
如果递增序列A中的元素可能重复,那么如何对给定的欲查询元素x,求出序列中第一个大于等于x的元素的位置L,以及第一个大于x的元素的位置R。即求一个左闭右开区间[L, R)。
先来考虑第一小问:求出序列中第一个大于等于x的元素的位置L。做法类似于前面二分查找严格递增(递减)序列,根据mid位置处的元素与欲查询元素x的大小来判断应当往哪个子区间继续查找。
- 如果A[mid] >= x,说明第一个大于等于x的元素的位置一定在mid处或mid的左侧,则应往左子区间[left, right]继续查询,即令right = mid;
- 如果A[mid] < x,说明第一个大于等于x的元素的位置一定在mid的右侧,应往右子区间[mid + 1, right]继续查询,即令left = mid + 1。
模板如下:
// A[]为递增序列,x为欲查询的数,函数返回第一个大于等于x的元素的位置
// 二分上下界为左闭右闭的[left, right],传入的初值为[0, n]
int LowerBound(int A[], int left, int right, int x)
{
int mid; // mid为left和right的中点
while (left < right) // 对[left, right]来说,left == right意味着找到唯一位置
{
mid = (left + right) / 2; // 取中点
if (A[mid] >= x) // 中间的数大于等于x
right = mid; // 往左子区间[lfet, mid]查找,注意这里不同于二分查找
else // 中间的数小于x
left = mid + 1; // 往右子区间[mid + 1, right]查找
}
return left; // 返回夹出来的位置
}
几个注意点:
- 循环条件为left < right而不是之前的left <= right,不知道同学们有没有注意到。在上一个问题中,当left > right跳出循环并返回-1时,我们才能确定欲查询元素不在序列中。因为存在着left == right时A[mid]就为欲查询元素的情况,因此left <= right满足时循环应当一直执行,否则就会漏掉这种情况。但是LowerBound函数中,我们不关心元素x是否在序列中。因为即使x不在序列中,我们也需要查找,也会返回一个“假设它存在,它应该在的位置”。于是当left == right时,[left, right]刚好能夹出唯一的位置,这就是需要的结果,也是循环结束的条件,因此只需要当left < right时让循环一直执行即可。
- 由于当left == right时while循环终止,因此最后返回left和right都是可以的。
- 二分的初始区间应当是能覆盖到所有可能返回的结果。首先,二分下界是0是显然的,但是二分上界是n - 1还是n呢?考虑到欲查询元素有可能比序列中的所有元素都要大,此时应当返回n(如果欲查询元素刚好是序列最后一个时返回的是n - 1),因此二分上界是n,故二分的初始区间为[lfet, right] = [0, n]。这一点建议拿序列{1, 3, 5, 8, 9},分别查询9和10模拟一下就能明白了。
接下来考虑第二小问:求序列中第一个大于x的元素的位置。做法仍然类似。
- 如果A[mid] > x,说明第一个大于x的元素的位置一定在mid处或mid的左侧,应往左子区间[left, right]继续查询;
- 如果A[mid] <= x,说明第一个大于x的元素的位置一定在mid的右侧,应往右子区间[mid + 1, right]继续查询。
模板如下:
// A[]为递增序列,x为欲查询的数,函数返回第一个大于x的元素的位置
// 二分上下界为左闭右闭的[left, right],传入的初值为[0, n]
int UpperBound(int A[], int left, int right, int x)
{
int mid; // mid为left和right的中点
while (left < right) // 对[lfet, right]来说,left == right意味着找到唯一位置
{
mid = (left + right) / 2; // 取中点
if (A[mid] > x) // 中间的数大于x
right = mid; // 往左子区间[left, mid]查找,注意这里不同于二分查找
else // 中间的数小于等于x
left = mid + 1; // 往右子区间[mid + 1, right]查找
}
return left; // 返回夹出来的位置
}
细心的同学肯定注意到了,LowerBound函数的if判断的是A[mid] >= x,而UpperBound函数是A[mid] > x。别看只是一个等号之差,但可能算出的结果是天差之别。
在LowerBound函数中,需要找到的是第一个大于等于x的元素的位置。比如序列{1, 3, 3, 3, 5}。初始时left = 0, right = 4,mid = 2,x = 3。此时A[mid] = 3,那么mid = 2就是第一个大于等于3的元素的位置吗?当然不是。很明显,仍然需要往左边的区间继续查找。这就是为什么在LowerBound函数中使用A[mid] >= x以确保前往正确的子区间查找。
而在UpperBound函数中,需要找到的是第一个大于x的元素的位置。仍然用前面的序列举例,如果说在UpperBound中也是用if (A[mid] >= x),那么由A[mid] = 3 == 3可知,此时函数回去查找左子区间。然而实际上我们应该往右子区间找,因为既然该元素都等于x了,那么比它大的元素自然在右边。所以在UpperBound中,A[mid] > x中不能加等号。
总结
LowerBound函数和UpperBound函数都在解决这样一个问题:寻找有序序列中第一个满足某条件的元素的位置。这是一个非常重要且经典的问题,平时能碰到的大部分二分法问题都可以归结于这个问题。下面是解决此类问题的固定模板。所谓的“某条件”在序列中一定是从左到右先不满足,然后满足。
// 解决“寻找有序序列中第一个满足某条件的元素的位置”问题的固定模板
// 二分区间为左闭右闭的[left, right],初值必须能覆盖解的所有可能取值
int Solve(int left, int right)
{
int mid; // mid为left和right的中点
while (left < right) // 对[lfet, right]来说,left == right意味着找到唯一位置
{
mid = (left + right) / 2; // 取中点
if ( 条件成立 ) // 条件成立,则第一个满足条件的元素的位置 <= mid
right = mid; // 往左子区间[left, mid]查找
else // 条件不成立,则第一个满足条件的元素的位置 > mid
left = mid + 1; // // 往右子区间[mid + 1, right]查找
}
return left; // 返回夹出来的位置
}
该问题的镜像问题便是:寻找有序序列中最后一个满足某条件C的元素的位置。此时我们只需先求第一个满足条件!C的元素的位置,然后将该位置 - 1即可。
拓展问题
求根问题
给定一个定义在[L, R]上的单调函数,求方程的根。
假设精度要求为,函数在[L, R]上单调递增。这种问题就可以用二分法来解决,令left和right的初值分别为L,R,然后就可以根据left与right的中点mid的函数值与0的大小关系来判断应往哪个子区间继续逼近的根:
- 如果,说明的根在mid左侧,应往左子区间[left, mid]继续逼近,即令right = mid;
- 如果,说明的根在mid右侧,应往右子区间[mid, right]继续逼近,即令left = mid。
模板如下:
const double eps = 1e-5; // 精度为10^-5
double f(double x) // 计算f(x)的函数
{
return ...;
}
double Solve(double L, double R)
{
double left = L, right = R, mid;
while (right - left > eps) // 当right - left <= 10^-5时表明达到精度要求
{
mid = (left + right) / 2;
if (f(mid) > 0) // r如果f(x)是单调递减函数,此处改为f(mid) < 0
right = mid;
else
left = mid;
}
return mid; // 所返回的当前mid值即为f(x) = 0的根
}
需要注意的是,因为涉及到函数问题时,我们的二分的是定义域,是一个实数域。所以在二分后,采取的是直接令二分上下界减半,而不进行加一或减一的操作。这不同于操作数组的下标,请仔细思考其中的区别。
装水问题
有一个侧面看上去是半圆的储水装置,该半圆的半径为R,要求往里面装入高度为h的水,使其在侧面看去的面积与半圆面积的比例恰好为r。如图所示,现在给定R和r,求高度h。
显然,随着水面升高,面积比例r一定是增大的。所以不妨这么做:在[0, R]范围内对水面高度h进行二分,计算在高度h下面积比例r的值。
- 如果计算得到的r比给定的数值要大,说明高度过高,范围应缩减至较低的一半;
- 如果计算得到的r比给定的数值要小,说明高度过低,范围应缩减至较高的一半。
模板如下:
const double PI = acos(-1.0);
const double eps = 1e-5;
double f(double R, double h) // 计算r = f(h),由实际含义可知r关于h递增
{
double alpha = 2 * acos((R - h) / R);
double L = 2 * sqrt(R * R - (R - h) * (R - h));
double S1 = alpha * R * R / 2 - L * (R - h) / 2;
double S2 = PI * R * R / 2;
return S1 / S2;
}
double Solve(double R, double r)
{
double left = 0, right = R, mid;
while (right - left > eps)
{
mid = (left + right) / 2;
if (f(R, mid) > r)
right = mid; // 同样是实数域,所以不要加一减一
else
left = mid;
}
}
木棒切割问题
给出N根木棒,长度均已知,现在希望通过切割它们来得到至少K段长度相等的木棒(长度必须是整数),问这些长度相等的木棒最长能有多长。
例如有三根长度分别为10、24、15的木棒,假设K = 7,即需要至少7段长度相等的木棒,那么可以得到的最大长度为6。因为它们分别可以提供10/6 = 1段,24/6 = 4段,15/6 = 2段。
首先可以注意到一个结论,如果长度相等的木棒的长度L越长,那么可以得到的木棒段数k越小。从这个角度出发便可以想到本题的算法,即二分最大长度L,根据对当前长度L来说能得到的木棒段数k与K的大小关系来进行二分。它可以写成求解最后一个满足条件“k >= K”的长度L,由前面我们知道可以转换为求解第一个满足“k < K”的长度L,然后减一即可。所以基本思路是这样的:现在假设二分上下界为左闭右闭的[left, right],传入的初值为[0, n],其中n是最长木棒的长度。对当前长度L来说能得到的木棒段数k应为N根木棒的长度分别除以L后的商的和。
- 如果k < K,说明L过大,导致切割数太少,则应往比L更小的子区间查找;
- 如果k >= K,说明L过小,导致切割数太多,则应往比L更大的子区间查找。
模板如下:
// A[]为N根木棒长度递增排序后的序列
// 二分上下界为左闭右闭的[left, right],传入的初值为[0, n]
int Calc_k(int L) // 该函数计算在长度L下切割可得到的木棒数
{
int sum = 0;
for (int i = 0; i < N; ++i)
sum += A[i] / L;
return sum;
}
int Solve(int left, int right)
{
int mid;
while (left + 1 < right)
{
mid = (left + right) / 2;
if (Calc_k(mid) < K)
right = mid;
else
left = mid;
}
return right - 1;
}
快速幂
递归
给定三个正整数,求。
问题很简单,用一个循环就能做出来,但如果问题变成这个呢:
给定三个正整数,求。
再用循环,一般的电脑我觉得都已经算不出来了。这里要使用快速幂的做法,它基于二分的思想,因此也常称为二分幂。快速幂基于以下事实:
- 如果b是奇数,那么有。
- 如果b是偶数,那么有。
显然,b是奇数的情况总可以在下一步转换为b是偶数的情况,而b是偶数的情况总可以在下一步转换为b/2的情况。这样,在级别次数的转换后,局可以把b变为0,而任何正整数的0次方都是1。
举个例子,如果需要求:
- 10为偶数,所以。
- 5为奇数,所以。
- 4为偶数,所以。
- 2为偶数,所以。
- 1为奇数,所以。
- ,然后从下往上一次回退计算即可。
这显然是递归的思想,于是可以得到快速幂的递归写法,时间复杂度为,模板如下:
LL BinaryPow(LL a, LL b, LL m)
{
if (b == 0) // 如果b为0,那么a^0 = 1
return 1;
if (b % 2 == 1) // 如果b为奇数,转化为b - 1
return a * BinaryPow(a, b - 1, m) % m;
else // 如果b为偶数,转换为b / 2
{
LL mul = BinaryPow(a, b / 2, m);
return mul * mul % m;
}
}
在上面的代码中,if (b % 2 == 1)可以用if (b & 1)代替。这是因为b & 1进行位与操作,判断b的末位是否为1,因此当b为奇数时b & 1返回1,if条件成立。这样写执行速度会快一点。
还有一点要注意,当b % 2 == 时不要直接返回BinaryPow(a, b / 2, m) * BinaryPow(a, b / 2, m),而应当算出单个BinaryPow(a, b / 2, m)之后再乘起来。这是因为前者每次都会调用两个BinaryPow函数,导致时间复杂度变成。例如求BinaryPow(8)时,会变成BinaryPow(4) * BinaryPow(4),而不是先计算BinaryPow(4)再乘2。之后,这两个BinaryPow(4)都会各自变成BinaryPow(2) * BinaryPow(2),于是就需要求四次BinaryPow(2);而每个BinaryPow(2)又会变成BinaryPow(1) * BinaryPow(1),因此最后需要求8次BinaryPow(1)。
针对不同的题目,有两个细节可能需要注意:
- 如果初始时a有可能大于等于m,那么需要在进入函数前就让a对m取模;
- 如果m为1,可以直接在函数外部特判为0,不需要进入函数来计算(因为任何整数对1取模一定等于0)。
迭代
对来说,如果把b写成二进制,那么b就可以写成若干二次幂之和。例如13的二进制是1101,于是3号位、2号位、0号位就都是1,那么就可以得到,所以。
通过同样的推导,我们可以把任意的表示成中若干项的乘积。其中,如果b的二进制的i号位为1,那么就被选中。于是可以得到计算的大致思路:令i从0到k枚举b的二进制的每一位,如果当前位为1,那么累积。注意到的前一项总是等于后一项的平方,因此具体实现的时候可以这么做:
- 初始令ans等于1,用来存放累积的结果;
- 判断b的二进制位末尾是否为1(即判断b & 1是否为1,也可以理解为判断b是否为奇数),如果是的话,令ans乘上a的值;
- 令a平方,并将b右移以为(也可以理解为将b除以2);
- 只要b大于0,就返回2。
模板如下:
LL BinaryPow(LL a, LL b, LL m)
{
LL ans = 1;
while (b > 0)
{
if (b & 1)
ans = ans * a % m; // 令ans累积上a
a = a * a % m; // 令a平方
b >>= 1; // 或写成b = b >> 1或 b = b / 2
}
return ans;
}
当b等于13时,可以得到下表所示的模拟过程。
b | b&1 | ans | |
---|---|---|---|
1 | |||
1101 | 1 | ||
110 | 0 | ||
11 | 1 | ||
1 | 1 |
在实际应用中,递归写法和迭代写法在效率上的差别不那么明显。
呼,终于总结完啦,还有不懂的地方欢迎在评论区留言哦~~~