二分法及其拓展全面講解

前言:本篇博客內容總結自《算法導論》和《算法筆記》。

二分查找

基礎用法

二分查找解決的問題是 :如何在一個嚴格遞增(遞減)序列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}

在實際應用中,遞歸寫法和迭代寫法在效率上的差別不那麼明顯。
呼,終於總結完啦,還有不懂的地方歡迎在評論區留言哦~~~

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