在《二分查找法的實現和應用匯總》中,我介紹了二分查找法的基本應用,不過在面試的准備過程中,我還碰到了更多對於二分查找法的更進一步的使用。其實在《二分查找法的實現和應用匯總》的最后,我已經介紹了一個非常規的使用,也就是基於“輪轉后的有序數組(Rotated Sorted Array)”檢查某一個數是否存在。
找到輪轉后的有序數組中第K小的數
對於普通的有序數組來說,這個問題是非常簡單的,因為數組中的第K-1個數(即A[K-1])就是所要找的數,時間復雜度是O(1)常量。但是對於輪轉后的有序數組,在不知道輪轉的偏移位置,我們就沒有辦法快速定位第K個數了。
不過我們還是可以通過二分查找法,在log(n)的時間內找到最小數的在數組中的位置,然后通過偏移來快速定位任意第K個數。當然此處還是假設數組中沒有相同的數,原排列順序是遞增排列。
在輪轉后的有序數組中查找最小數的算法如下:
//return the index of the min value in the Rotated Sorted Array, whose range is [low, high] int findIndexOfMinVaule(int A[], int low, int high) { if (low > high) return -1; while (low < high) { int mid = (low + high)/2; if (A[mid] > A[high]) low = mid +1; else high = mid; } //at this point, low is equal to high return low; }
接着基於此結果進行偏移,再基於數組長度對偏移后的值取模,就可以找到第K個數在數組中的位置了:
int findKthElement(int A[], int m, int k) { if (k > m) return -1; int base = findIndexOfMinVaule(A, 0, m-1); int index = (base+k-1)%m; return index; }
找出兩個有序數組中第K個數
之前我談到,對於一個有序數組來說,找到第K個數是非常簡單的,假如我們有兩個有序的數組,希望從中找到第K小的數呢?
這個問題最直觀的解決方法就是像歸並排序中的歸並算法那樣,從頭開始比較,找到那第K小的數,那么平均時間復雜度就是O(m+n),其中m,n分別是兩個數組的長度。
不過通過二分查找法,得到一個復雜度為O(log(m+n))的算法(很多地方說這個算法的復雜度是O(log m + log n),我沒有進行准確的演算和統計,但是個人認為O(log(m+n))才對)。先來看算法代碼,然后來分析(或者你也可以看這篇文章)。
這里對於參數的假設如下:數組的索引是以0為基數並且m+n > 0;k是以1為基數並且1<=k <= m+n,兩個數組的集合沒有重復元素。在這樣的假設下,暗示我們總是可以找到那個第k個數。
//return the value of kth element in union of two sorted array int findKthElement(int A[], int m, int B[], int n, int k) { int i = int(double(m)/(m+n)*(k -1)); int j = (k-1) - i; //A[i] or B[j] is the Kth element, return it if ((j <= 0 || B[j-1] < A[i]) && (j >= n || A[i] < B[j])) return A[i]; if ((i <= 0 || A[i-1] < B[j]) && (i >= m || B[j] < A[i])) return B[j]; //A[i] is too small, get rid of lower part of A and higher part of B if (0 < j && A[i] < B[j-1]) return findKthElement(A+i+1, m-i-1, B, j, k-i-1); //B[j] is too small, get rid of higher part of A and lower part of B else //if(i > 0 && B[j] < A[i-1]) return findKthElement(A, i, B+j+1, n-j-1, k-j-1); }
個人認為這里面最繁瑣的是數組索引因為不同的基數而引起的轉換問題。比如里面的i和j,很顯然i+j == k-1。而實際上,數組A中0-i的元素加上數組B中0-j的元素一共有(i+1)+(j+1) == k+1個數。
因為數組A和B都是有序的,所以我們知道A[i] > A[0…i-1]都大,B[j] > B[0…j-1]。
進一步,如果B[j-1] < A[i] < B[j],那么A[j]就正好大於 A中前i個數B中的前j個數也就是總共k-1個數,於是A[j]就是我們要找的目標數;
反之如果A[i-1] < B [j] < A[i],那么B[j]就成立我們要找的數。
萬一A[i]和B[j]都不是我們要找的數,要么A[i]比B[j-1]小,要么B[j]比A[i-1]小。
假如是A[i]比B[j-1]小,那么我們可以分析推測出來,A[0…i]都太小而不能成為我們要找的目標,而B[j…n-1]又太大,也不可能是我們要找的目標。所以我們就可以開始二分查找的第二步操作——剪枝,讓我們的范圍縮小。而因為我們去除了A中較小的部分,所以我們要查找的數也從第k個變成了第(k-i-1)個。
對於B[j]比A[i-1]小的情況也是一樣的。
從遞歸調用的地方,我們看出k總是在不斷減小的,簡單分析更可以知道,如果k是1的話就會停止遞歸。(這也是為什么我會認為總的時間復雜度是O(log(m+n))的地方。)所以位於i和j的值選取就變得比較關鍵。
一開始,我設定 int i = k > m? m-1: k-1;也就是讓i相對比較大。雖然平均效率上差不多,但是如果剪枝時總是去除B的前段的話,k減小的速度就比較慢。例如最壞的情況:A中所有的數比B中都要大,而我們正好要找第n個數(也就是B中最后一個數),於是每次遞歸k都只減小了1。此時的復雜度就成了O(n)。
按數組大小來分配i和j可以做到對於任意的案例k的減少都是比較平均的。
對於那些邊界檢查,在之前的假設之下其實只是會有相等的情況出現,不過檢查區域並不會比檢查點糟糕。
進一步,其實剪枝時的(0 < j && A[i] < B[j-1]) 並不需要去檢查 0 < j,因為j為0的情況只可能出現在n為0 (即B是一個空數組)。而此時,A[i]已是我們要找的目標而被返回了。寫上(0 < j && A[i] < B[j-1]) 只是暗示它其實是((j <= 0 || B[j-1] < A[i])的取反。
整數的求平方根函數
這個其實也是畢竟常見的面試問題,要求不調用math庫,實現對整數的sqrt方法,返回值只需要是整數。
其實這個問題用數學的表達方式就是:對於非負整數x,找出另一個非負整數n,其中n滿足 n2 ≤ x < (n+1)2。
所以最直接的方法就是從0到X遍歷過去直到找到滿足上述條件的n。這個算法的復雜度自然是O(n)。
仔細想想,其實我們要找的數是在0和X之間,而他正巧可以視為一個有序的數組。似乎有可以運用二分查找法的可能。再回想二分查找法是要找到滿足“與目標數相等”這一條件的數,而這里同樣也是要找滿足一定條件的數。所以我們就可以用二分法來解這個問題了,讓復雜度降為O(log n)。
為方便起見,我假設傳入的參數是非負的,因此使用unsigned int。
unsigned int sqrt(unsigned int x) { //no value should larger than max*max, otherwise it would be overflow unsigned int max = (1 << (sizeof(x)/2*8))-1; //65535 if (max*max < x)return max; unsigned int low = 0; unsigned int high = max-1; unsigned int mid = 0; while (1) { mid = (low+high)/2; if (x < mid * mid) high = mid-1; else if((mid+1)*(mid+1) <= x) low = mid+1; else //if(mid * mid <= x && x < (mid+1)*(mid+1)) break; } return mid; }
當然在這個問題上,還有其它好的算法,這里只是想借次來指出二分查找法的應用。
二分查找法擴展
之前已經介紹了二分查找法的不少內容。下邊,我還想再講講碰到的一些面試題目中可用二分查找法的題目。不過這些都是基於“找出兩個有序數組中第K個數”的擴展問題。
找出兩個有序數組中的中數
其實這個問題還是假設數組中不會有相同的值,直觀的解法也是O(m+n)的遍歷。網上有很多的解答方法(可見文后引用),不過都存在一定問題。該問題主要出在中數(Median)的計算方式上:
如果數組有個數是奇數,那么中數的值就是有序時處於中間的數;如果數組個數是偶數的,那么就是有序時中間兩個數平均時。
網上的解法,有些假定兩個數組相同長度,因此不具有一般性;另一種解法,則是針對兩種情況做出不同的解法。
其實參照“找出兩個有序數組中第K個數”,我們又已經兩個數組的長度,其實就已經知道我們要找的數其實是第(m+n)/2 + 1個數(奇數時)或者是第(m+n)/2 和第(m+n)/2 + 個數(偶數時)。即使是調用兩次,復雜度也依然是O(log(m+n))。
找出多個服務器中第K個數
問題的描述是這樣的:在多個服務器上每個服務器都有一批數,每個服務器直接並不知道對方的內容,你有一個主機可以聯到每個服務器上去請求數據,現在要求從這些服務器的數據中找出第K小的數。
一個常用的方法就是在主機上創建一個存儲K個數據堆結構然后獲取每個服務器的數據並與堆中數據進行比較和交換。最后我們就可以得到所要的數據。
該問題其實可視為是“找出兩個有序數組中第K個數”的多服務器大規模變體。一般諸如Google,Facebook,Microsoft這樣的大公司會比較喜歡問(我有個朋友面試FB時就問到了這個問題)。
問題並沒有表明每個服務器下的數是否有序的,不過我們可以讓每個服務器對自己的數據進行排序,這是同步的,所以最多是O(n log n)的時間。在主機上則維護一個K大的有序數組,然后於每個服務器之間進行進行“找出兩個有序數組中第K個數”,之后保留下新的前K個數。其復雜度應該是O(n log k),其中n是服務器數量,k是所求目標。加上服務器排序時間,基本可以維持在O(n log n)的級別。
- Find the k-th Smallest Element in the Union of Two Sorted Arrays – LeetCode
- Binary Search to Compute Square root (Java) – StackOverflow
- Median of two sorted arrays – GeeksforGeeks
- Median of Two Sorted Arrays –LeetCode
- Microsoft Interview Question – CareerCup
- Find the largest k numbers in k arrays stored across k machines – StackOverflow