你真的理解二分的寫法嗎
說實話,我之前也不完全理解二分查詢的各種寫法,導致在寫各種二分的邊界時我總是弄不清邊界值,於是我只能通過暴力列舉這些邊界值,去一個一個試,這樣子效率真的很低下。於是,痛定思痛,一定要把二分的寫法吃透,就有了這篇文章。
二分寫法的種類
二分寫法的種類很多,最常見的就是二分查找了的最普遍寫法了。程式碼如下:
bool bFind(int a[], int left, int right, int tag)
{
for (; left <= right; ) {
int mid = (left + right) >> 1;
if (a[mid] == tag)
return true;
else
a[mid] < tag ? l = mid + 1 : r = mid - 1;
}
return false;
}
正如我剛才說的,這只是最普通的二分,實際上,二分還可以實現非等值查詢,例如平時所說的二分求上界,二分求下界之類的。
下面我用乘法原理來告訴你們二分查詢有多少種寫法。
- 區間的開閉 - [開區間還是閉區間]
- 左右端點 - [這個端點是和上面區間開閉聯絡的,具體表現為左開還是左閉,右開還是右閉]
- 中點是取整還是進一 - [在計算中點的時候到底是
(left + right) >> 1
(left + right + 1) >> 1
] - 大於還是小於 - [這個對應上下界問題]
- 取不取等於 - [是大於等於還是小於等於]
- 第一個還是最後一個 - [找第一個大於目標的位置還是找最後一個大於目標的位置]
每個選項都是兩種可能,於是二分寫法一共有種寫法。也就是說,從這六個選項中的每個選項中任意挑選一個就可以組成一個二分的問題。
那麼這麼多種類的二分,是不是每種二分都要去記呢?肯定不要啊,不然我還寫這部落格幹嘛。接下來我會告訴你一個通用的方法。
先看一個題目吧,有題目才有切入點。
題目背景
題目解析
程式碼
class Solution {
public:
int GetNumberOfK(vector<int> data ,int k) {
int pre, last, l, r;
// 下界
for (l = 0, r = data.size() ;l < r; ) {
int mid = (l + r) / 2;
if (data[mid] >= k)
r = mid;
else
l = mid + 1;
}
pre = r;
// 上界
for (l = -1, r = (int)data.size() - 1; l < r; ) {
int mid = (l + r + 1) / 2;
if (data[mid] <= k)
l = mid;
else
r = mid - 1;
}
last = l;
return last - pre + 1;
}
};
詳解
這個題目裡,要用一個二分去求下界,一個二分求上界。
求下界
下界的定義是什麼,就是找到一個數,這個數是第一個大於等於k
的數,這個數的位置就是下界;
注意到二分的邊界我設成了l = 0, r = data.size()
,這是一個左閉右開的區間,中點處我用的是mid = (l + r) / 2
,二分結束後,l
等於r
;
下界的位置靠近左端點,所以我們從左端點開始找,因此,可以看到,二分中l
的位置在一步一步向右端點靠近(因此要加一),而r
只是起到縮小範圍的作用;
右端點是個開端點,這是為了處理有序陣列中沒有一個數字比k
大的情況,因此,如果查詢失敗,l
和r
可以指向一個空的位置,也就是陣列的最後一個位置的後一個位置,這個程式設計中的“左閉右開”區間的思想是一樣的;
至於中點處為什麼要向下取整,原因是這樣的:如果這個題要你順序查詢這個有序陣列找到下界,你會從哪裡開始找?肯定是左邊第一個元素開始找啊,你總不可能從第二個元素開始找吧,這就是為什麼要向下取整的原因,向下取整可以避免漏掉最優解;
如果你還不明白,那麼我舉個例子你就知道了,陣列為[-2]
, k = 3
,l = 0, r = 1
,然後你二分的時候中點向上進一,那麼mid = (0 + 1 + 1) / 2 = 1
,然後你會發現mid
不在陣列中,怎麼可能啊,mid
是區間的中點,那必然會在陣列中啊!這就是求下界的時候為什麼中間要向下取整了;
還有一點要始終記得,二分結束後,l = r
,因此最後l, r
都是答案。
求上界
剛剛講了求下界,求上界也如法炮製。
求上界求的是最後一個大於等於k
的數字的位置,我們把陣列反過來,以右端點作為起點開始查詢,這個時候第一個小於或等於k
的元素的位置就是原問題的上界;
你把求上界轉化成求下界來看,是不是程式碼中的一切東西都理所當然了;
r = data.size() - 1, l = -1
,因為把陣列反過來的,故右端點就是起點,結束的位置就是第一個元素前面的一個位置;
mid = (l + r + 1) / 2
,因為把陣列反過來了,因此這裡的向上進一其實就是反過來之後的向下取整。
總結
可以看到,求上界可以轉化為求下界來做,因此,這裡詳細總結一下求下界的做法。
求下界第一件事就是確定左右端點範圍,由於求下界是求第一個滿足condition
條件的位置,這裡用condition
是為了把這個求下界的方法一般化,求第一個滿足條件的位置,因此,以左端點為起點;
最後一個元素的後一個位置作為終點,這是為了在沒有滿足條件的解可以得到一個合理的值(指向最後一個元素的後一個位置就是代表著沒有找到下界);
中點取靠近起點的一端,根據靠近的位置選擇向下取整還是向上進一;
在縮小範圍的時候,如果mid
滿足條件,那麼令r = mid
, 這樣子可以縮小範圍([mid + 1, r)
是一定不滿足條件的,但mid
有可能是答案,沒關係,我們讓l
來等於mid
就行,r
只管縮小範圍),而且,這樣可以保證一定有解;
如果mid
不滿足條件,就令l = mid + 1
, 由於[l, mid]
是一定不滿足條件的,故讓l
一步步靠近r
來找到滿足條件的答案。
這樣,無論是64種二分中的任何一種,你都可以按照這種求下界的方法來做了。
模板
class Solution {
public:
static bool cmp1(const int &a, const int &b) {
return a >= b;
}
static bool cmp2(const int &a, const int &b) {
return a <= b;
}
// 求下界
int getDown(vector<int> data, int k, bool (*cmp)(const int &, const int &))
{
int l, r;
for (l = 0, r = data.size() ; l < r; ) {
int mid = (l + r) / 2;
if (!cmp(data[mid], k))
l = mid + 1;
else
r = mid;
}
return l;
}
// 求上界
int getUp(vector<int> data, int k, bool (*cmp)(const int &, const int &))
{
int l, r;
for (l = -1, r = data.size() - 1; l < r; ) {
int mid = (l + r + 1) / 2;
if (!cmp(data[mid], k))
r = mid - 1;
else
l = mid;
}
return l;
}
int GetNumberOfK(vector<int> data ,int k) {
int up = getUp(data, k, cmp2), down = getDown(data, k, cmp1);
return up - down + 1;
// return getUp(data, k, &cmp) - getDown(data, k, &cmp) + 1;
};
這個模版以上面那個題目為背景寫的,用了一個函式指標,這樣可以把這個問題一般化,這個函式指標指向一個比較函式,這個比較函式中你就寫合法的條件就可以了,中就是這麼設計它們的lower_bound
的。
其他
既然講到了,我就再告訴你upper_bound
函式,lower_bound
函式和我們上面說的求下界是一模一樣的,但是,upper_bound
就有點不一樣了,它也是求上界,但是答案是上界的後一個位置;
利用我們剛才說的求下界,我們可以在此基礎上改編一下求下界的方法,讓它也能求上界,這樣改編出來的函式就和upper_bound
一樣了,但是這樣改編出來的函式只適合上面的這個題目背景,具體問題具體分析,這也是為什麼有了lower_bound
為什麼還要搞一個upper_bound
的原因了;
求下界的函式主體不要動,只要改一下cmp2
就好了。想一下,求最後一個大於等於k
的元素的位置,那我們如果求得了第一個大於k
的元素的位置,那麼這個位置是不是就是答案的後一個位置,所以,cmp2
中的內容改成return a > b;
, 然後呼叫getDown(data, k, cmp2)
就可以求得上界的後一個位置了。