深刻了解javascript的sort方法

寫於2015年6月18日,可能已過期,請謹慎參考。
全部示例代碼未經完整測試,僅示意思路。
在javascript中,數組對象有一個有趣的方法 sort,它接收一個類型爲函數的參數做爲排序的依據。這意味着開發者只須要關注如何比較兩個值的大小,而不用管「排序」這件事內部是如何實現的。不過了解一下sort的內部實現也不是一件壞事,何不深刻了解一下呢?

算法課上,咱們會接觸不少種排序算法,什麼冒泡排序、選擇排序、快速排序、堆排序等等。那麼javascript的 sort方法採用哪一種排序算法呢?要搞清楚這個問題,呃,直接看 v8源代碼好了。v8中對 Array.sort的實現是採用javascript完成的,粗看下來,使用了快速排序算法,但明顯比咱們熟悉的快速排序要複雜。那麼到底複雜在什麼地方?爲何要搞這麼複雜?這是咱們今天要探討的問題。

快速排序算法

快速排序算法之因此被稱爲快速排序算法,是由於它能達到最佳和平均時間複雜度均爲O(n·logn),是一種應用很是普遍的排序算法。它的原理並不複雜,先找出一個基準元素(pivot,任意元素都可),而後讓全部元素跟基準元素比較,比基準元素小的,放到一個集合中,其餘的放到另外一個集合中;再對這兩個集合執行快速排序,最終獲得徹底排序好的序列。

因此快速排序的核心是不斷把原數組作切割,切割成小數組後再對小數組進行相同的處理,這是一種典型的分治的算法設計思路。實現一個簡單的快速排序算法並不困難。咱們不妨試一下:
function QuickSort(arr, func) {
    if (!arr || !arr.length) return [];
    if (arr.length === 1) return arr;
    var pivot = arr[0];
    var smallSet = [];
    var bigSet = [];
    for (var i = 1; i < arr.length; i++) {
        if (func(arr[i], pivot) < 0) {
            smallSet.push(arr[i]);
        } else {
            bigSet.push(arr[i]);
        }
    }
    return QuickSort(smallSet, func)
           .concat([pivot])
           .concat(QuickSort(bigSet, func));
}
複製代碼

這是一個很是基礎的實現,選取數組的第一項做爲基準元素。javascript

原地(in-place)排序

咱們能夠注意到,上面的算法中,咱們實際上是建立了一個新的數組做爲計算結果,從空間使用的角度看是不經濟的。javascript的快速排序算法中並無像上面的代碼那樣建立一個新的數組,而是在原數組的基礎上,經過交換元素位置實現排序。因此,相似於push、pop、splice這幾個方法, sort方法也是會修改原數組對象的!java

咱們前面說過,快速排序的核心在於切割數組。那麼若是隻是在原數組上交換元素,怎麼作到切割數組呢?很簡單,咱們並不須要真的把數組切割出來,只須要記住每一個部分起止的索引號。舉個例子,假設有一個數組[12, 4, 9, 2, 18, 25],選取第一項12爲基準元素,那麼按照原始的快速排序算法,會把這個數組切割成兩個小數組:[4, 9, 2], 12, [18, 25]。可是咱們一樣能夠不切割,先經過比較、交換元素,將原數組修改爲[4, 9, 2, 12, 18, 25],再根據基準元素12的位置,認爲0~2號元素是一組,4~5號元素是一組,爲了表述方便,我這裏將比基準元素小的元素組成的分區叫小數分區,另外一個分區叫大數分區。這很像電腦硬盤的分區,並非真的把硬盤分紅了C盤、D盤,而是記錄下一些起止位置,在邏輯上分紅了若干個分區。相似的,在快速排序算法中,咱們也把這個過程叫作分區(partition)。因此相應的,我也要修改一下以前的說法了,快速排序算法的核心是分區。git

說了這麼多,仍是實現一個帶分區的快速排序吧: github

function swap(arr, from, to) {
    if (from == to) return;
    var temp = arr[from];
    arr[from] = arr[to];
    arr[to] = temp;
}
 
function QuickSortWithPartition(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    if (arr.length === 1) return arr;
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    var pivot = arr[from];
    var smallIndex = from;
    var bigIndex = from + 1;
    for (; bigIndex <= to; bigIndex++) {
        if (func(arr[bigIndex], pivot) < 0) {
            smallIndex++;
            swap(arr, smallIndex, bigIndex);
        }
    }
    swap(arr, smallIndex, from);
    QuickSortWithPartition(arr, func, from, smallIndex - 1);
    QuickSortWithPartition(arr, func, smallIndex + 1, to);
    return arr;
}
複製代碼

看起來代碼長了不少,不過並不算複雜。首先因爲涉及到數組元素交換,因此先實現一個 swap方法來處理元素交換。快速排序算法中,增長了兩個參數, from和 to,分別表示當前要處理這個數組的哪一個部分, from是起始索引, to是終止索引;若是這兩個參數缺失,則表示處理整個數組。算法

一樣的,我用最簡單的方式選取基準元素,即所要處理分區的第一個元素。而後我定義了smallIndex和 bigIndex兩個變量,分別表示的是左側小數分區的終止索引和右側大數分區的終止索引。什麼意思?就是說從第一個元素(基準元素)到第 smallIndex個元素間的全部元素都比基準元素小,從第 smallIndex + 1到第 bigIndex個元素都比基準元素大。一開始沒有比較時,很顯然這兩部分分區都是空的,而比較的過程很簡單,直接是 bigIndex向右移,一直移到分區尾部。每當 bigIndex增長1,咱們會進行一次判斷,看看這個位置上的元素是否是比基準元素大,若是大的話,不用作處理,它已經處於大數分區了;但若是比基準元素小,就須要進行一次交換。怎麼交換呢?首先將 smallIndex增長1,意味着小數分區增長了一個元素,但此時 smallIndex位置的元素很明顯是一個大數(這個說法其實不對,若是以前大數分區裏面沒有元素,此時 smallIndex和bigIndex相等,但對交換沒有影響),而在 bigIndex位置的元素是一個小數,因此只要把這兩個位置的元素交換一下就行了。編程

最後可別忘了一開始的起始元素,它的位置並不正確,不過只要將它和 smallIndex位置的元素交換位置就能夠了。同時咱們獲得了對應的小數分區 [from...smallIndex – 1]和大數分區[smallIndex + 1…to]。再對這兩個分區遞歸排序便可。數組

分區過程的優化

上面的分區過程(僅僅)仍是有必定的優化空間的,由於上面的分區過程當中,大數分區和小數分區都是從左向右增加,其實咱們能夠考慮從兩側向中間遍歷,這樣能有效地減小交換元素的次數。舉個例子,例如咱們有一個數組 [2, 1, 3, 1, 3, 1, 3],採用上面的分區算法,一共碰到三次比基準元素小的狀況,因此會發生三次交換;而若是咱們換個思路,把從右往左找到小於基準和元素,和從左往右找到大於基準的元素交換,這個數組只須要交換一次就能夠了,即把第一個3和最後一個1交換。性能優化

咱們也來嘗試寫一下實現: markdown

function QuickSortWithPartitionOp(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    if (from >= to - 1) return arr;
    var pivot = arr[from];
    var smallEnd = from + 1;
    var bigBegin = to;
    while (smallEnd < bigBegin) {
        while (func(arr[bigBegin], pivot) > 0 && smallEnd < bigBegin) {
            bigBegin--;
        }
        while (func(arr[smallEnd], pivot) < 0 && smallEnd < bigBegin) {
            smallEnd++;
        }
        if (smallEnd < bigBegin) {
            swap(arr, smallEnd, bigBegin);
        }
    }
    swap(arr, smallEnd, from);
    QuickSortWithPartitionOp(arr, func, from, smallEnd - 1);
    QuickSortWithPartitionOp(arr, func, smallEnd + 1, to);
    return arr;
}
複製代碼

分區與性能

前面咱們說過,快速排序算法平均時間複雜度是O(logn),但它的最差狀況下時間複雜度會衰弱到O(n2)。而性能好壞的關鍵就在於分區是否合理。若是每次都能平均分紅相等的兩個分區,那麼只須要logn層迭代;而若是每次分區都不合理,總有一個分區是空的,那麼須要n層迭代,這是性能最差的場景。函數

那麼性能最差的場景會出現嗎?對於一個內容隨機的數組而言,不太可能出現最差狀況。但咱們平時在編程時,處理的數組每每並非內容隨機的,而是極可能預先有必定順序。設想一下,若是一個數組已經排好序了,因爲以前的算法中,咱們都是採用第一個元素做爲基準元素,那麼必然會出現每次分區都會有一個分區爲空。這種狀況固然須要避免。

一種很容易的解決方法是不要選取固定位置的元素做爲基準元素,而是隨機從數組裏挑出一個元素做爲基準元素。這個方法頗有效,極大機率地避免了最差狀況。這種處理思想很簡單,我就不另外寫代碼了。

然而極大機率地避免最差狀況並不等於避免最差狀況,特別是對於數組很大的時候,更要求咱們在選取基準元素的時候要更謹慎些。

三數取中(median-of-three)

基準元素應當精心挑選,而挑選基準元素的一種方法爲三數取中,即挑選基準元素時,先把第一個元素、最後一個元素和中間一個元素挑出來,這三個元素中大小在中間的那個元素就被認爲是基準元素。

簡單實現一下獲取基準元素的方法:

function getPivot(arr, func, from, to) {
    var middle = (from + to) >> 1;
    var i0 = arr[from];
    var i1 = arr[to];
    var i2 = arr[middle];
    var temp;
    if (func(i0, i1) > 0) {
        temp = i0;
        i0 = i1;
        i1 = temp;
    }
    if (func(i0, i2) > 0) {
        arr[middle] = i0;
        arr[from] = i2;
        arr[to] = i1;
        return i0;
    } else {
        arr[from] = i0;
        if (func(i1, i2) > 0) {
            arr[middle] = i1;
            arr[to] = i2;
            return i1;
        } else {
            arr[middle] = i2;
            arr[to] = i1;
            return i2;
        }
    }
}
複製代碼

這個例子裏我徹底沒管基準元素的位置,一是下降複雜度,另外一個緣由是下面討論重複元素處理時,基準元素的位置沒什麼意義。不過我把最小的值賦給了第一個元素,最大的值賦給了第二個元素,後面處理重複元素時會有幫助。

固然,僅僅是三數取中得到的基準元素,也不見得是可靠的。因而有一些其餘的取中值的方法出現。有幾種比較典型的手段,一種是平均間隔取一個元素,多個元素取中位數(即多取幾個,增長可靠性);一種是對三數取中進行遞歸運算,先把大數組平均分紅三塊,對每一塊進行三數取中,會獲得三個中值,再對這三個中值取中位數。

不過查閱v8的源代碼,發現v8的基準元素選取更爲複雜。若是數組長度不超過1000,則進行基本的三數取中;若是數組長度超過1000,那麼v8的處理是除去首尾的元素,對剩下的元素每隔200左右(200~215,並不固定)挑出一個元素。對這些元素排序,找出中間的那個,並用這個元素跟原數組首尾兩個元素一塊兒進行三數取中。這段代碼我就不寫了。

針對重複元素的處理

到目前爲止,咱們在處理元素比較的時候比較隨意,並無太多地考慮元素相等的問題。但實際上咱們作了這麼多性能優化,對於重複元素引發的性能問題並無涉及到。重複元素會帶來什麼問題呢?設想一下,一個數組裏若是全部元素都相等,基準元素無論怎麼選都是同樣的。那麼在分區的時候,必然出現除基準元素外的其餘元素都被分到一塊兒去了,進入最差性能的case。

那麼對於重複元素應該怎麼處理呢?從性能的角度,若是發現一個元素與基準元素相同,那麼它應該被記錄下來,避免後續再進行沒必要要的比較。因此仍是得改分區的代碼。

function QuickSortWithPartitionDump(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    if (from >= to - 1) return arr;
    var pivot = getPivot(arr, func, from, to);
    var smallEnd = from;
    var bigBegin = to;
    for (var i = smallEnd + 1; i < bigBegin; i++) {
        var order = func(arr[i], pivot);
        if (order < 0) {
            smallEnd++;
            swap(arr, i, smallEnd);
        } else if (order > 0) {
            while (bigBegin > i && order > 0) {
                bigBegin--;
                order = func(arr[bigBegin], pivot);
            }
            if (bigBegin == i) break;
            swap(arr, i, bigBegin);
            if (order < 0) {
                swap(arr, i, smallEnd);
                smallEnd++;
            }
        }
    }
    QuickSortWithPartitionDump(arr, func, from, smallEnd);
    QuickSortWithPartitionDump(arr, func, bigBegin, to);
    return arr;
}
複製代碼

簡單解釋一下這段代碼,上文已經說過,在 getPivot方法中,我將比基準小的元素放到第一位,把比基準大的元素放到最後一位。定義三個變量 smallEnd、 bigBegin、 i,從 from到smallEnd之間的元素都比基準元素小,從 smallEnd到 i之間的元素都和基準元素同樣大,從i到 bigBegin之間的元素都是尚未比較的,從 bigBegin到 to之間的元素都比基準元素大。瞭解這個關係就好理解這段代碼了。遍歷從 smallEnd + 1到 bigBegin之間的元素:

  • 若是這個元素小於基準,那麼 smallEnd增長1,這時 smallEnd位置的元素是等於基準元素的(或者此時 smallEnd與 i相等),交換 smallEnd與 i處的元素就能夠了。
  • 果這個元素大於基準,相對比較複雜一點。此時讓 bigBegin減少1,檢查大數分區前面一個元素是否是大於基準,若是大於基準,重複此步驟,不斷讓 bigBegin減少1,直到找到不比基準大的元素(若是這個過程當中,發現 bigBegin與 i相等,則停止遍歷,說明分區結束)。找到這個不比基準大小元素時須要區分是否是比基準小。若是比基準小,須要作兩步交換,先將i位置的大數和 bigBegin位置的小數交換,這時跟第一種case同時, smallEnd增長1,而且將 i位置的小數和 smallEnd位置的元素交換。若是和基準相等,則只須要將 i位置的大數和 bigBegin位置的小數交換。
  • 果這個元素與基準相等,什麼也不用作。

小數組優化

對於小數組(小於16項或10項。v8認爲10項如下的是小數組。),可能使用快速排序的速度還不如平均複雜度更高的選擇排序。因此對於小數組,可使用選擇排序法要提升性能,減小遞歸深度。

function insertionSort(a, func, from, to) {
    for (var i = from + 1; i < to; i++) {
        var element = a[i];
        for (var j = i - 1; j >= from; j--) {
            var tmp = a[j];
            if (func(tmp, element) > 0) {
                a[j + 1] = tmp;
            } else {
                break;
            }
        }
        a[j + 1] = element;
    }
}
複製代碼

v8引擎沒有作的優化

因爲快速排序的不穩定性(少數狀況下性能差,前文已經詳細描述過),David Musser於1997設計了內省排序法(Introsort)。這個算法在快速排序的基礎上,監控遞歸的深度。一旦長度爲n的數組通過了logn層遞歸(快速排序算法最佳狀況下的遞歸層數)尚未結束的話,就認爲此次快速排序的效率可能不理想,轉而將剩餘部分換用其餘排序算法,一般使用堆排序算法(Heapsort,最差時間複雜度和最優時間複雜度均爲O(logn))。

v8引擎額外作的優化

快速排序遞歸很深,若是遞歸太深的話,很能夠出現「爆棧」,咱們應該儘量避免這種狀況。上面提到的對小數組採用選擇排序算法,以及採用內省排序算法均可以減小遞歸深度。不過v8引擎中,作了一些不太常見的優化,每次咱們分區後,v8引擎會選擇元素少的分區進行遞歸,而將元素多的分區直接經過循環處理,無疑這樣的處理大大減少了遞歸深度。我大體把v8這種處理的過程寫一下:

function quickSort(arr, from, to){
    while(true){
        // 排序分區過程省略
        // ...
 
        if (to - bigBegin < smallEnd - from) {
            quickSort(a, bigBegin, to);
            to = smallEnd;
        } else {
            quickSort(a, from, smallEnd);
            from = bigBegin;
        }
    }
}
複製代碼

不得不說是一個很巧妙的實現。

總結

不知不覺這篇文章寫了這麼長。原本想對比各類優化之間的性能差別,如今看來也沒有什麼必要。雖然快速排序算法是一個很容易很基礎的算法,但我相信不少人並無可以這麼深刻地去了解、去優化一個算法。而讀過了v8引擎對於這麼一個簡單算法的實現後,我發現它並無簡單地爲了實現一個算法而去實現,而是確確實實地盡一切可能去提升算法效率,去消除可能引發性能問題的因素。結論是你真的能夠放心地使用 Array.sort方法,它的性能使人放心。那麼剩下問題的就是:做爲開發者,咱們應該如何編寫高質量高性能的代碼?是否是應該更精益求精一點,讓咱們代碼更經得起推敲,更值得信任?

相關文章
相關標籤/搜索