二分法及其拓展全面讲解

前言:本篇博客内容总结自《算法导论》和《算法笔记》。

二分查找

基础用法

二分查找解决的问题是 :如何在一个严格递增(递减)序列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]继续查找。

二叉查找的高效之处在于,每一步都可以去除当前区间中的一半元素,因此其时间复杂度是O(n)O(n)
模板如下:

// 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; // 返回夹出来的位置
}

几个注意点:

  1. 循环条件为left < right而不是之前的left <= right,不知道同学们有没有注意到。在上一个问题中,当left > right跳出循环并返回-1时,我们才能确定欲查询元素不在序列中。因为存在着left == right时A[mid]就为欲查询元素的情况,因此left <= right满足时循环应当一直执行,否则就会漏掉这种情况。但是LowerBound函数中,我们不关心元素x是否在序列中。因为即使x不在序列中,我们也需要查找,也会返回一个“假设它存在,它应该在的位置”。于是当left == right时,[left, right]刚好能夹出唯一的位置,这就是需要的结果,也是循环结束的条件,因此只需要当left < right时让循环一直执行即可。
  2. 由于当left == right时while循环终止,因此最后返回left和right都是可以的。
  3. 二分的初始区间应当是能覆盖到所有可能返回的结果。首先,二分下界是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]上的单调函数f(x)f(x),求方程f(x)=0f(x) = 0的根。
假设精度要求为eps=105eps = 10^{-5},函数f(x)f(x)在[L, R]上单调递增。这种问题就可以用二分法来解决,令left和right的初值分别为L,R,然后就可以根据left与right的中点mid的函数值f(mid)f(mid)与0的大小关系来判断应往哪个子区间继续逼近f(x)=0f(x) = 0的根:

  • 如果f(mid)>0f(mid)>0,说明f(x)=0f(x) = 0的根在mid左侧,应往左子区间[left, mid]继续逼近,即令right = mid;
  • 如果f(mid)<0f(mid)<0,说明f(x)=0f(x) = 0的根在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的水,使其在侧面看去的面积S1S_1与半圆面积S2S_2的比例恰好为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;
}

快速幂

递归

给定三个正整数abm(a<109,b<106,1<m<109)a、b、m(a<10^9, b<10^6,1<m<10^9),求ab%ma^b\%m
问题很简单,用一个循环就能做出来,但如果问题变成这个呢:
给定三个正整数abm(a<109,b<1018,1<m<109)a、b、m(a<10^9, b<10^{18},1<m<10^9),求ab%ma^b\%m
再用循环,一般的电脑我觉得都已经算不出来了。这里要使用快速幂的做法,它基于二分的思想,因此也常称为二分幂。快速幂基于以下事实:

  1. 如果b是奇数,那么有ab=aab1a^b = a*a^{b-1}
  2. 如果b是偶数,那么有ab=ab/2ab/2a^b = a^{b/2}*a^{b/2}

显然,b是奇数的情况总可以在下一步转换为b是偶数的情况,而b是偶数的情况总可以在下一步转换为b/2的情况。这样,在log(b)log(b)级别次数的转换后,局可以把b变为0,而任何正整数的0次方都是1。
举个例子,如果需要求2102^{10}

  1. 10为偶数,所以210=25252^{10} = 2^5*2^5
  2. 5为奇数,所以25=2242^5 = 2*2^4
  3. 4为偶数,所以24=22222^4 = 2^2*2^2
  4. 2为偶数,所以22=21212^2 = 2^1*2^1
  5. 1为奇数,所以21=2202^1 = 2*2^0
  6. 20=12^0 = 1,然后从下往上一次回退计算即可。

这显然是递归的思想,于是可以得到快速幂的递归写法,时间复杂度为log(b)log(b),模板如下:

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函数,导致时间复杂度变成O(2log(b))=O(b)O(2^{log(b)}) = O(b)。例如求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)。

针对不同的题目,有两个细节可能需要注意:

  1. 如果初始时a有可能大于等于m,那么需要在进入函数前就让a对m取模;
  2. 如果m为1,可以直接在函数外部特判为0,不需要进入函数来计算(因为任何整数对1取模一定等于0)。

迭代

aba^b来说,如果把b写成二进制,那么b就可以写成若干二次幂之和。例如13的二进制是1101,于是3号位、2号位、0号位就都是1,那么就可以得到13=23+22+20=8+4+113=2^3+2^2+2^0 = 8 + 4 + 1,所以a13=a8+4+1=a8a4a1a^{13} = a^{8+4+1} = a^8 * a^4*a^1
通过同样的推导,我们可以把任意的aba^b表示成a2k... a8a4a2a1a^{2^k}、...\ 、a^8、a^4、a^2、a^1中若干项的乘积。其中,如果b的二进制的i号位为1,那么a2ia^{2^i}就被选中。于是可以得到计算aba^b的大致思路:令i从0到k枚举b的二进制的每一位,如果当前位为1,那么累积a2ia^{2^i}。注意到a2k... a8a4a2a1a^{2^k}、...\ 、a^8、a^4、a^2、a^1的前一项总是等于后一项的平方,因此具体实现的时候可以这么做:

  1. 初始令ans等于1,用来存放累积的结果;
  2. 判断b的二进制位末尾是否为1(即判断b & 1是否为1,也可以理解为判断b是否为奇数),如果是的话,令ans乘上a的值;
  3. 令a平方,并将b右移以为(也可以理解为将b除以2);
  4. 只要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 aa
1 aa
1101 1 1a=a1 * a = a a2a^2
110 0 aa a4a^4
11 1 aa4=a5a * a^4 = a^5 a8a^8
1 1 a5a8=a13a^5 * a^8 = a^{13}

在实际应用中,递归写法和迭代写法在效率上的差别不那么明显。
呼,终于总结完啦,还有不懂的地方欢迎在评论区留言哦~~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章