演算法之逆序對兒查詢
話說昨晚一夜沒休息好,滿腦子都是想的這到演算法題目。早上決定把它記錄下來。對於陣列中逆序對兒的查詢是非常普遍的一到演算法題,各種面試中都會出現它和它的變種。我們首先來看看查詢逆序對兒的問題定義:
輸入:給定元素各不相同陣列; 輸出:陣列中逆序對兒的個數; 其中,逆序對兒的定義是,給定陣列A下標i, j,其中i < j,但是A[i] > A[j],則我們稱A[i]和A[j]是一個逆序對兒。
舉個栗子
逆序對兒是啥,乾巴巴的看定義有點生硬。這裡我們舉個栗子,給定陣列:
[1, 3, 5, 2, 4, 6] 那麼,我們很明顯的能看到3在2的前面,所以3和2是一對兒逆序對兒;同樣,5和2, 5和4又是另外兩對兒逆序對兒。
意義
這裡你又要問了,費這老大勁兒查詢陣列的逆序對兒有個雞毛用呀。這就涉及到具體應用場景了。這裡舉個簡單的應用場景。我們都喜歡把自己看的書和電影都在豆瓣上進行評分、評論等標記,那麼豆瓣是如何知道你有什麼興趣、跟其他人興趣是否相似的呢?假設這裡有10部大部頭的書,你對它們進行喜好程度的排名得到陣列A,而A中的值對應著每一部書在其它第三人哪裡的排名,則就根據逆序對兒的查詢可以判斷出興趣的相似性。比方說《白鹿原》,你排在第一,故把它放在了A1這個位置,但是另一個做相似性對比的人覺得它也就能拍個第8,則A1=8。類似這樣就能得到一個長度為10的陣列,根據其中的逆序對兒即可計算出興趣愛好的相似性。 知道意義之後,我們來看看怎麼查詢陣列中的逆序對兒。
遍歷陣列解決
估計你我都一樣,跟絕大數其它同學一樣能想到最直接的方法就是遍歷陣列,然後做對比。這裡因為很簡單,我們只給出簡單的虛擬碼,具體實現留給讀者自己。
// 問題:給定陣列長度為N的陣列,元素各不相同,返回該陣列中逆序對兒的個數。
inversionsNum = 0;
for i = 0; i 小於n; i++
for j = i+1; j < n; j++
if A[i] > A[j]
inversioinsNum++;
複製程式碼
這裡,根據虛擬碼我們很容易知道這裡原始遍歷陣列的方法的複雜度為O(n的平方)。那麼,至此我們仍然要問一下自己,我們是否能再做的好一點?答案是顯然的,否則也就不用繼續寫下去了。
歸併查詢逆序對兒
既然我們之前瞭解過歸併排序,同時也瞭解了分治策略,那麼何不把它在這裡應用一番。在繼續之前,我們需要明白幾個概念,並且為了利用歸併排序重新定義一下問題:
概念: 左逆序對兒:假設陣列從中間一分為二,則出現在左半邊陣列A1的逆序對兒被稱為左逆序對兒; 右逆序對兒:同左逆序對兒類似,出現在右半邊陣列A2的逆序對兒被稱為右逆序對兒; 交叉逆序對兒:即逆序對兒A[i]和A[j],其中A[i]出現在左半邊陣列A1中,A[j]出現在右半邊陣列A2中。 問題:我們將過去定義的返回陣列中逆序對兒的個數改為返回陣列逆序對兒的個數和排序後的陣列。
知道這些後,我們來看看為什麼又把歸併排序給扯上關係了。 分治策略的根本就是分而治之,這裡我們把陣列對半分下去就是這個原因,這個沒啥說的。這裡需要重點說明一下歸併排序中歸併對於交叉逆序對兒的意義。我們知道,給定一個陣列,假設用我們上面給的陣列A=[1, 3, 5, 2, 4, 6],通過歸併排序後最終得到的是A1=[1, 3, 5]和A2=[2, 4, 6]。在最終歸併這兩個陣列的時候,即一個個從A1或者A2中取值放到B中的時候,很明顯,如果沒有逆序對兒,那麼A1的元素肯定先放完。如果A1的元素還沒放完,A2的元素就要往B中放,那麼肯定出現了逆序對兒,並且逆序對兒的個數還與A1中剩下的元素有關。 例如:第一個放A1[1]即1到B中,第二個放A2[1]即2到B中,則這時候A1中還有3和5這兩個元素,則出現了兩個逆序對兒;繼續放A1[2]即3到B中,然後放A2[2]即4到B中,這時候A1中還有5這個元素沒放,則出現了一個逆序對兒;然後繼續直到放置完畢。 從這裡我們可以看到,交叉逆序對兒的個數跟A2的元素放置的時候A1中沒被放置的個數有關。至此,我們設計演算法可以將陣列無限劃分直到元素個數為1,然後再逐步歸併查詢逆序對兒。
歸併查詢逆序對兒虛擬碼
定義函式inversionsCount
if n =0 or n = 1
return (arr, 0)
else
(C, leftInversions) = inversionsCount(A左半邊陣列)
(D, rightInversions) = inversionsCount(A右半邊陣列)
(B, splitInversions) = mergeSplitCount(C, D)
return (B, leftInversions + rightInversions + splitInversions)
其中mergeSplitCount演算法為:
定義函式mergeSplitCount
i = j = 0; k = 0; cts = 0;
for k=0; k < n; k++
if C[i] > D[j]
B[k] = D[j]
k++;
j++;
cts = n - i;
else
B[k] = C[i]
k++;
i++
return (B, cts)
複製程式碼
至此,希望你已經完全看懂了所有的步驟。下面我們看看具體的JavaScript中的實現吧。
歸併查詢逆序對兒JavaScript的實現
function inversionsCount (arr) {
let l = arr.length, i = Math.floor(l/2);
if ( [0, 1].indexOf(l) !== -1) {
return {
arr: arr,
cts: 0
};
} else {
let lfObj = inversionsCount(arr.slice(0, i));
let rtObj = inversionsCount(arr.slice(i, l));
let mdObj = mergeSplitCount(lfObj.arr, rtObj.arr);
return {
arr: mdObj.arr,
cts: lfObj.cts + rtObj.cts + mdObj.cts
}
}
}
function mergeSplitCount (arrLeft, arrRight) {
var ll = arrLeft.length, lr = arrRight.length, cts = 0, i = j = k = 0, resArr = [];
while (i < ll && j < lr) {
if (arrLeft[i] < arrRight[j]) {
resArr[k] = arrLeft[i];
k++;
i++;
} else {
resArr[k] = arrRight[j];
k++;
j++;
cts += ll - i;
}
}
while (k < ll + lr) {
if (i >= ll) {
resArr[k] = arrRight[j];
k++;
j++;
} else {
resArr[k] = arrLeft[i];
i++;
k++;
}
}
return {
arr: resArr,
cts: cts
}
}
複製程式碼
歸併查詢逆序對兒的時間複雜度
這裡很容易看到歸併查詢逆序對兒跟歸併排序類似,都是二分法將原陣列分治,然後逐級歸併解決問題。因此很容易得出複雜度為O(nlogn)
原文同樣發表於Github Issues,歡迎關注。