非單調性解空間的二分查找
前言
我們在判斷一個問題的答案是否可以通過二分搜索快速獲得的兩個重要判斷是:
1.能否確定問題的解空間範圍。
2.解空間的分佈是否具備單調性。
判定1.通常是顯然成立的,因爲大部分解空間有範圍的前提下,我們纔會採用搜索算法。
而作爲lgn複雜度的二分搜索顯然是不可輕易錯過的搜索算法。
但是二分搜索算法有一個要命前提的第二個條件,就是“解空間分佈具有單調性”。
比如解空間隨着變量單調遞增或者遞減,或者水平。又或者是允許採用分治思想,分段處理,使其滿足在每一段解空間具備單調性。
但是我們生活中大部分解分佈都是不具備單調性的(傾向於紊亂狀態)。
而本文針對討論一類相對有規律的“紊亂解空間分佈”。
即,解空間在整體上是單調性的,允許解空間在某一個單調性的迴歸線上來回波動。比如,疫情期間的股市行情,就是在某一個單調遞減迴歸線上來回波動,並最終暴跌。
如果要在這類曲線下搜索某一特徵唯一的股價數據所對應的時間點,還是可以的。
我們可以先採用二分搜索定位到可能滿足特徵要求的數據大致區間範圍,然後再做進一步搜索。
比起暴力搜索,我們等於成功把時間複雜度從O(n)降低爲O(lgn).
這一步已經達到了我們的目的,也許你會說,這是在爲了做二分搜索,而採用二分搜索。但是在面對陌生的問題時,幸運地我們都會有多個解題思路,而正確的做法應該包容的解搜多個解答,也許他不是最優的,但他不見得不會給我們帶來新的啓發。
而且二分搜索有一個我們難以抗拒的魅力,就是他的空間複雜度是O(1)。我們完全沒理由不去考慮二分搜索的可行性。
例題分析
接下來我們以一道例題展開講解:
167. 兩數之和 II - 輸入有序數組
給定一個已按照升序排列 的有序數組,找到兩個數使得它們相加之和等於目標數。
函數應該返回這兩個下標值 index1 和 index2,其中 index1 必須小於 index2。
說明:
返回的下標值(index1 和 index2)不是從零開始的。
你可以假設每個輸入只對應唯一的答案,而且你不可以重複使用相同的元素。
示例:
輸入: numbers = [2, 7, 11, 15], target = 9
輸出: [1,2]
解釋: 2 與 7 之和等於目標數 9 。因此 index1 = 1, index2 = 2 。
本題來源於:leetcode.con
題目分析
這道題目最暴力的解法,就是按照題目要求找出所有解,然後再進行搜索。
其時間複雜度爲O(n*n), 我們一遍遍歷一遍算,所以空間複雜度也是O(1)。
而官方最優解法是利用滑窗算法來求解,我們用頭尾指針,作爲滑窗的起點,尾指針往左移,表示把當前值減小,頭指針往右移動,表示把當前值增大,我們將當前值跟目標值target比較,通過跳轉頭尾指針逼近目標值。由於我們要確保頭指針不能大於尾指針(題目要求),因此我們可以輕易算出時間複雜度是O(n),空間複雜度是O(1)。
相當優秀,將複雜度降低爲線性。
如果採用我們二分搜索的思想,又會怎樣呢?
搜索我們能確定解空間的範圍就是從下標[0][0]~[n-1][n-1]。
按照二維數組在OS中連續內存分佈的順序,得到解空間的分佈顯然不是線性的。
這裏我直接給出答案:
最終的分佈大概是這樣的
| /
| /\ /
| / \ /
| /\ / \/
| / \/
| /\/
| /
|——————————————————————————
比如例題 輸入: numbers = [2, 7, 11, 20], target = 9
[0][0] 不滿足題目要求
[0][1] 9
[0][2] 13
[0][3] 22
[1][1] 不滿足題目要求
[1][2] 18
[1][3] 27
[2][2] 不滿足題目要求
[2][3] 31
[3][3] 不滿足題目要求
([0][3]爲22,而[1][2]爲18,明明升高了,又降下來,等於破壞了單調性)
我們從題目可以看出,他總體是具備單調性的。
那麼下一個問題就是怎麼將原解空間建模爲一個具備單調性的解空間呢?
我們知道原解空間的自變量是二維的,我們可以從將具備單調性的維度挑出來,最簡單的方法就是直接將問題變成1維,我們原函數是:
f([i][j]),他不具備單調性,如果我們把它變成一維,只需要將j變成一個常量,即當i=k時,第一個j爲k+1。我們令j永遠等於k+1即可,這裏的k可以認爲是j的基準地址,1纔是變量的第一個常量值,所以k+1仍然可以認爲是一個常量。
那麼問題則變爲 f([i][0]),這個解空間顯然是具備單調性的。
-
我們利用二分搜索搜索這個函數的解空間,判定函數就變爲“當i=k時,是否可能存在target,即最小值是否小於target或者最大值大於target”。
這個是否我們通過這個新的模糊判定函數,過濾到很多個i。 -
緊接着只需要掃描這些i的所有解空間即可。對於每一個i的搜索過程,我們採用暴力搜索的話,需要花費o(n)的複雜度。如果有m個i則總複雜度就是o(lgn + m * n) = o(m*n)
-
在本題當前,第二個維度j其實具有單調性的,意味着我們可以再一次進行二分搜索,那麼總體複雜度就變成O(lgn + m * lgn) = o(m*lgn)
而其中m是一個動態值,他的最大值,也就是最壞的情況,即每一列i的j上下限都相等,就意味着,我們利用二分搜索的過濾出來的i有將近n個,複雜度則變爲
O(lgn + nlgn) = o(nlgn),最好的情況就是恰好過濾出1個i,則複雜度爲o(lgn + 1*lgn)= o(lgn)
平均複雜度就是O(nlgn)了。
這就是意味着,在某些趨勢比較明顯情況下,我們可以得到比一個o(n)更快的計算過程。
簡單的寫了一下代碼,作爲參考:
class Solution {
public static int[] mynumbers;
public int[] twoSum(int[] numbers, int target) {
if( numbers.length == 2 ) {
return new int[] {1,2};
}
mynumbers = numbers;
// 找最低點i
int left = 1;
int right = numbers.length - 1;
int privot = 0;
while(left <= right) {
privot = (right - left) / 2 + left;
if(mapMax(privot) <= target && mapMax(privot+1) >= target) {
break;
} else if (mapMax(privot) > target) {
right = privot;
} else if( mapMax(privot+1) < target) {
left = privot + 1;
}
}
if( mynumbers[privot] + mynumbers[privot+1] == target ) {
return new int[]{privot+1,privot+2};
}
int low = privot;
// 找最高點i
left = 1;
right = numbers.length - 1;
privot = 0;
while(left <= right) {
privot = (right - left) / 2 + left;
if(mapMin(privot) <= target && mapMin(privot+1) >= target) {
break;
} else if (mapMin(privot) > target) {
right = privot;
} else if( mapMin(privot+1) < target) {
left = privot + 1;
}
}
if( mynumbers[0] + mynumbers[privot+1] == target ) {
return new int[]{1, privot+2};
}
int hight = privot;
/** 將最高點和最低點之間的列,入隊,其實可以不用隊列,直接記下最大值最小值即可,因爲i具備連續性。**/
List<Integer> list = new ArrayList<>();
for(int j = low; j <= hight; j++) {
list.add(j);
}
/** 二分遍歷所有列 **/
for(int i=0; i < list.size(); i++) {
int k = list.get(i);
int left2 = 0;
int right2 = k - 1;
privot = 0;
boolean found = false;
while(left2 <= right2) {
privot = (right2 - left2) / 2 + left2;
if(sum(privot, k) == target) {
found = true;
break;
} else if (sum(privot, k) > target) {
right2 = privot - 1;
} else if(sum(privot, k) < target) {
left2 = privot + 1;
}
}
if(found) {
return new int[]{privot+1, k+1};
}
}
return new int[]{-9999, -9999};
}
// 1 <= i <= len - 1
// 該列最大值
int mapMax(int i) {
return mynumbers[i-1] + mynumbers[i];
}
// 1 <= i <= len - 1
// 該列最小值
int mapMin(int i) {
return mynumbers[0] + mynumbers[i];
}
// 二項和
int sum(int i, int k) {
return mynumbers[i] + mynumbers[k];
}
}
總結:
最終還是提倡具體問題具體方法來解決。這裏只是處於對二分查找的性質進一步學習而發起研究。在寫代碼的過程中,我們也意識到一個點就是二分查找的麻煩之處在於邊界值的處理。雖然他的思想比較通俗易懂,但是能否很好的處理邊界則是另外一回事了。