二分法詳解
大家好,我是Weekoder!
這是我的第一篇文章,如果有做的不好的地方,請見諒,我會盡力改正。
本文中的圖片擷取於網路影片,非惡意搬運。
二分法,是一個高效的演算法,查詢一個數的時間複雜度只需要\(O(\log n)\),大大最佳化了樸素演算法(從頭到尾地遍歷)\(O(n)\)的線性複雜度。稍後我會對它的對數複雜度做分析。
舉個例子,當你要在一個長度為\(2\times 10^9\)(20億)的陣列中裡查詢一個數時,樸素演算法\(O(n)\)的複雜度肯定會超時,更別說去尋找多個數了。但如果使用二分法進行查詢,查詢一次只需大約執行30次!真是恐怖的差別。
那麼,到底該怎麼實現二分法,實現二分法又有什麼條件呢?這是我們接下來要解決的問題。
二分法的理論概念
二分法,是指在有序序列中透過折半的方式快速鎖定目標的位置,在不斷的二分下,最終找到答案。別急,我第一次看到的時候也是一頭霧水。那麼,我們來分析一下這段話。
- 折半是具體怎樣折半?
這個問題很好地引入了二分法的實現。我們來看一段虛擬碼。
int left = 0, right = n + 1;
while (還沒有結束) {
int mid = (left + right) / 2;
if (...)
left = mid;
else
right = mid;
}
可以看到,我們先定義了兩個指標 left 和 right(其實就是類似於 for 迴圈中的 i 和 j,不是什麼很深奧的東西,不要像我一開始一樣被誤導了),分別指向陣列的第一個元素的前一個位置和最後一個元素的後一個位置,它們之間就是答案所在的範圍。在while迴圈中間,又定義了一個 mid,它指向的是left和right的中間,最好是寫作\(mid=(left+right)>>1\)(位運算,等同於除以2)。然後,當觸發了某個條件,left會指向mid,否則會讓right指向mid。請思考這樣做的含義。等等,這不就是相當於把答案的範圍折半了嗎?於是,我們就順利地完成了折半的操作。總結一下,就是每次計算 left 和 right 的中間,並在某種判斷條件下讓 left 或 right 指向 mid,也就是折半。
現在,讓我們換一種角度思考。不是去思考left和right之間是什麼,而是去思考left和right之前是什麼,即1--left和right--n這兩個區間。請認真再反覆思考這句話的含義。
我們現在來看這樣一張圖片:left 指向藍色區域(下標 1--left)從左往右的最後一個元素4,而 right 指向紅色區域(下標 right--n(8))從右往左的最後一個元素5。
這樣就好理解了,藍色區域 1--left 可以理解為是 left 擴充套件的區域,而紅色區域 right--n 可以理解為是 right 擴充套件的區域。也就是說,二分查詢其實就是在不斷擴充套件 left 和 right,最後根據情況返回 left 或 right。 為什麼是根據情況返回 left 或 right 呢?因為在實際情況中,有可能要求返回 left,也有可能要求返回 right,但肯定是不會直接像“請返回 left”這樣直接告訴你的。接下來我們逐一來補全虛擬碼中未完成的部分。
- 為什麼非得是在有序序列中?無序不行嗎?
這是實現二分法的條件:陣列需要有序。可以是單調不遞減(從小到大)或單調不遞增(從大到小)。為解決這個疑問,我們來補全虛擬碼中while迴圈裡的if條件,也就是讓 left 或 right 指向 mid 時 是讓 left 還是 right 指向 mid 的條件。
我們來看為什麼樸素演算法的效率低下。從我們之前擴充套件的角度來看,樸素演算法相當於是兩個指標在一個個緩慢擴充套件,直到遇到對方區域才停止。
可以看到,這樣的效率是很低下的。
那麼,二分是怎樣對擴充套件最佳化的呢?
答案是每次計算中間值 mid,判斷 mid 屬於哪種顏色,並直接讓 left 或 right 指向 mid,於是就一下子擴充套件了很多。 這裡假設 mid 現在指向的區域是藍色的,那麼我們就會讓 left 直接指向 mid。 這意味著什麼? 既然藍色區域已經擴充套件到 mid 了,那麼就說明 mid 之前的數也必須是藍色的,這樣這個操作才合法,才是正確的。那我們怎麼保證 mid 之前的數是藍色的呢? 很簡單,只要讓陣列有序就行了,這樣就能保證 mid 之前的數全部小於現在 mid 指向的數,也就全部是藍色的了。 同理,只要陣列有序, right
之前的數也全部都大於 right 現在指向的數,這個擴充套件操作也能成功。
總結一下,陣列需要有序是因為這樣二分時擴充套件的最佳化才能合法,並且我們又解決了一個問題:while迴圈裡的if條件是在判斷 mid 是屬於什麼顏色的。 我們把這個判斷稱為\(IsBlue\)(屬於藍色區域)。
現在更新虛擬碼為:
int mid = (left + right) >> 1;
if (IsBlue(mid))
left = mid;
else
right = mid;
- 不斷地二分具體是要分幾次?
到這裡,我們終於把虛擬碼補全了。具體要分幾次取決於while迴圈的條件。
我們知道,二分法其實就是不斷擴充套件 left 和 right 的過程,而我們觀察上一幅圖,當 left 和 right 處於什麼關係時,擴充套件就完成了?答案也呼之欲出了:\(left+1=right\) 。
於是,我們完成虛擬碼的最後更新:
int left = 0, right = n + 1;
while (left + 1 != right) {
int mid = (left + right) / 2;
if (IsBlue(mid))
left = mid;
else
right = mid;
}
return left or right;
至此,二分法的概念和實現就講得差不多了。
那麼,我們也就知道了,因為二分查詢其實是在不斷折半,所以總時間複雜度剛好是 \(O(\log n)\)。
二分法的細節處理
- 細節1
為什麼left的初始值為0,right的初始值為n+1?不能等於1和n嗎?
設想一下,如果整個陣列都是紅色,那麼 left 一開始就會指向紅色區域,造成錯誤;同理,如果整個陣列都是藍色,那麼 right 一開始就會指向藍色區域,同樣會造成錯誤,所以將指標初始化為 \(0\) 和 \(n+1\)。
- 細節2
在更新指標時,能寫成 \(left=mid+1\) 或者 \(right=mid-1\)嗎?
我們來看一個例子:
設想一下,這個時候如果 \(left=mid+1\),會發生什麼?沒錯,left 會指向紅色區域,導致錯誤。同理,如果 mid 指向紅色區域的最後一個元素,right 也會指向藍色區域,導致錯誤。所以,將 left 和 right 直接指向 mid 更合適。
二分法的實現與建模
做一道例題就明白了。
給定一個有序整數陣列 \(a[]\) 和一個整數陣列 \(x[]\) 以及它們的長度 \(aLen\) 和 \(xLen\)。\((0\le a_i\le 2\times 10^6,0\le x_i\le 10^8,aLen,xLen\le 10^6)\)
現在定義 \(f(i)\) 為第一個符合 \(a_j\ge x_i\) 的 \(j\),如果沒有,返回 \(0\)。
試求出 \(f(1,2,3,...xLen-1,xLen)\)。保證有解。
陣列又是有序的,又要查詢多個數,很容易想到效率高的二分查詢。那我們做題時該怎麼建模呢?放心,一點也不難。我們只需要把虛擬碼中的 \(IsBlue\) 條件和到底是要返回 left 還是 right 搞清楚就行了。現在我們來劃分紅藍區域。
首先,題目要求我們返回的是第一個符合條件的數,我們來看一下能不能把藍色區域定義為大於等於 \(x_i\) 的數。顯然是不可以的,因為藍色區域是從左到右的,指向的是最後一個大於等於 \(x_i\) 的元素,所以要把紅色區域定義為大於等於 \(x_i\) 的數,藍色區域就是小於 \(x_i\) 的元素。\(IsBlue\) 的條件就是 \(a[mid]<x_i\),最後返回 right。給出程式碼如下。
\(Code:\)
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5; // 陣列大小
int a[N], x[N], aLen, xLen; // a陣列,x陣列,它們的大小
void bin_search(int x) { // 寫成函式方便快捷
int l = 0, r = aLen + 1; // 指標初始化要在邊界外
while (l + 1 != r) { // 當擴充套件還未結束
int mid = (l + r) >> 1; // 計算中間值,>> 位運算,等同於除以2
if (a[mid] < x) // 當處於藍色區域
l = mid; // 藍色區域擴充套件
else // 否則就是紅色區域
r = mid; // 紅色區域擴充套件
}
if (a[r] == x) cout << r << " "; // 如果查詢的答案符合
else cout << 0 << " "; // 沒有找到,輸出0
return ; // 函式最好都要寫返回
}
int main() {
cin >> aLen >> xLen; // 輸入陣列大小
for (int i = 1; i <= aLen; i++) cin >> a[i]; // 輸入a陣列
for (int i = 1; i <= xLen; i++) cin >> x[i]; // 輸入x陣列
for (int i = 1; i <= xLen; i++) // 迴圈輸出f(i)
bin_search(x[i]); // 二分查詢函式
return 0; // 大功告成!
}
可以輸入樣例自測。
輸入樣例:
5 3
2 5 7 9 11
6 2 15
輸出樣例:
3 1 0
總結一下二分法的總體建模思路,就是確定紅藍區域以及返回 left 還是 right,並套用模板求解。當然,有一些細節也要處理,比如指標的初始值,擴充套件時防止跑到對面區域等。
最後
怎麼樣,你是否看懂了二分法的所有過程並理解了呢?其實二分法的思想很簡單,但實現的過程中總會遇到一些麻煩。所以我才寫了我的第一篇文章,想幫助大家理解二分法並能熟練運用它。希望你喜歡。