前文我們了解了快速排序算法的實現,本文我們來了解下另一種流行的排序算法-歸並排序算法。
我們先來回顧下快排。快排的核心是找出一個基准元素,把數組中比該元素小的放到左邊數組,比該元素大的放到右邊數組,如果左邊數組和右邊數組分別有序,那么leftArray+midItem+rightArray就是我們要的排序結果了。要使得左右數組有序,只需要對它們分別調用快排函數就可以了。遞歸調用需要一個出口,當數組長度<=1的時候,就是遞歸出口。
我們再進一步看,其實遞歸調用的結果形成了一棵二叉樹!我們以數組[2, 1, 3, 4, 7, 6, 5]
為例,代入數據到之前的快排算法中,堆棧中其實形成了一棵如下二叉樹(二叉搜索樹):
4
/ \
1 6
\ / \
2 5 7
\
3
當遞歸到最底層向上回溯時,其實我們只需把父節點和左子樹右子樹的元素合並成一個數組就行了。而更令人激動的是,左子樹的值 <= midItem <= 右子樹的值(因為是一棵二叉搜索樹)!於是我們只需要簡單地將它們按序concat就ok了。
說了這么多,我們回到本文的主題上——歸並排序。之所以說到二叉樹,是因為歸並排序同樣可以用構成一棵二叉樹來解釋,只不過快排的復雜度花在了成樹(二叉搜索樹)上(從上往下),而歸並排序的復雜度花在了歸並上(從下往上)。
我們以數組[1, 5, 6, 2, 4, 3]
舉例,歸並排序的第一步,將數組一分為2:
[1, 5, 6] [2, 4, 3]
接着將分成的數組繼續一分為2,直到長度為1,我們構成如下二叉樹(成樹 從上往下):
[1, 5, 6, 2, 4, 3]
/ \
[1, 5, 6] [2, 4, 3]
/ \ / \
[1] [5, 6] [2] [4, 3]
/ \ / \
[5] [6] [4] [3]
當遞歸到了盡頭,我們向上回溯,對於兩個有序的數組,我們將它們合並成一個有序數組,從而完成整個歸並排序(歸並 從下往上):
[1, 2, 3, 4, 5, 6]
/ \
[1, 5, 6] [2, 3, 4]
/ \ / \
[1] [5, 6] [2] [3, 4]
/ \ / \
[5] [6] [4] [3]
代碼不難,直接上代碼:
function merge(left, right) {
var tmp = [];
while (left.length && right.length) {
if (left[0] < right[0])
tmp.push(left.shift());
else
tmp.push(right.shift());
}
return tmp.concat(left, right);
}
function mergeSort(a) {
if (a.length === 1)
return a;
var mid = ~~(a.length / 2)
, left = a.slice(0, mid)
, right = a.slice(mid);
return merge(mergeSort(left), mergeSort(right));
}
這段合並排序的代碼相當簡單直觀,但是mergeSort()函數會導致很頻繁的自調用。一個長度為n的數組最終會調用mergeSort() 2*n-1
次,這意味着如果需要排序的數組長度很大會在某些棧小的瀏覽器上發生棧溢出錯誤。
這里插個話題,關於遞歸調用時瀏覽器的棧大小限制,可以用代碼去測試:
var cnt = 0;
try {
(function() {
cnt++;
arguments.callee();
})();
} catch(e) {
console.log(e.message, cnt);
}
// chrome: Maximum call stack size exceeded 35992
// firefox: too much recursion 11953
遇到棧溢出錯誤並不一定要修改整個算法,只是表明遞歸不是最好的實現方式。這個合並排序算法同樣可以迭代實現,比如(摘抄自《高性能JavaScript》):
function merge(left, right) {
var result = [];
while (left.length && right.length) {
if (left[0] < right[0])
result.push(left.shift());
else
result.push(right.shift());
}
return result.concat(left, right);
}
function mergeSort(a) {
if (a.length === 1)
return a;
var work = [];
for (var i = 0, len = a.length; i < len; i++)
work.push([a[i]]);
work.push([]); // 如果數組長度為奇數
for (var lim = len; lim > 1; lim = ~~((lim + 1) / 2)) {
for (var j = 0, k = 0; k < lim; j++, k += 2)
work[j] = merge(work[k], work[k + 1]);
work[j] = []; // 如果數組長度為奇數
}
return work[0];
}
console.log(mergeSort([1, 3, 4, 2, 5, 0, 8, 10, 4]));
這個版本的mergeSort()函數功能與前例相同卻沒有使用遞歸。盡管迭代版本的合並排序算法比遞歸實現要慢一些,但它並不會像遞歸版本那樣受調用棧限制的影響。把遞歸算法改用迭代實現是實現棧溢出錯誤的方法之一。