寫於2015年6月18日,可能已過期,請謹慎參考。在javascript中,數組對象有一個有趣的方法 sort,它接收一個類型爲函數的參數做爲排序的依據。這意味着開發者只須要關注如何比較兩個值的大小,而不用管「排序」這件事內部是如何實現的。不過了解一下sort的內部實現也不是一件壞事,何不深刻了解一下呢?
全部示例代碼未經完整測試,僅示意思路。
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
咱們能夠注意到,上面的算法中,咱們實際上是建立了一個新的數組做爲計算結果,從空間使用的角度看是不經濟的。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(n·logn),但它的最差狀況下時間複雜度會衰弱到O(n2)。而性能好壞的關鍵就在於分區是否合理。若是每次都能平均分紅相等的兩個分區,那麼只須要logn層迭代;而若是每次分區都不合理,總有一個分區是空的,那麼須要n層迭代,這是性能最差的場景。函數
那麼性能最差的場景會出現嗎?對於一個內容隨機的數組而言,不太可能出現最差狀況。但咱們平時在編程時,處理的數組每每並非內容隨機的,而是極可能預先有必定順序。設想一下,若是一個數組已經排好序了,因爲以前的算法中,咱們都是採用第一個元素做爲基準元素,那麼必然會出現每次分區都會有一個分區爲空。這種狀況固然須要避免。
一種很容易的解決方法是不要選取固定位置的元素做爲基準元素,而是隨機從數組裏挑出一個元素做爲基準元素。這個方法頗有效,極大機率地避免了最差狀況。這種處理思想很簡單,我就不另外寫代碼了。
然而極大機率地避免最差狀況並不等於避免最差狀況,特別是對於數組很大的時候,更要求咱們在選取基準元素的時候要更謹慎些。
基準元素應當精心挑選,而挑選基準元素的一種方法爲三數取中,即挑選基準元素時,先把第一個元素、最後一個元素和中間一個元素挑出來,這三個元素中大小在中間的那個元素就被認爲是基準元素。
簡單實現一下獲取基準元素的方法:
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之間的元素:
對於小數組(小於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; } } 複製代碼
因爲快速排序的不穩定性(少數狀況下性能差,前文已經詳細描述過),David Musser於1997設計了內省排序法(Introsort)。這個算法在快速排序的基礎上,監控遞歸的深度。一旦長度爲n的數組通過了logn層遞歸(快速排序算法最佳狀況下的遞歸層數)尚未結束的話,就認爲此次快速排序的效率可能不理想,轉而將剩餘部分換用其餘排序算法,一般使用堆排序算法(Heapsort,最差時間複雜度和最優時間複雜度均爲O(n·logn))。
快速排序遞歸很深,若是遞歸太深的話,很能夠出現「爆棧」,咱們應該儘量避免這種狀況。上面提到的對小數組採用選擇排序算法,以及採用內省排序算法均可以減小遞歸深度。不過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方法,它的性能使人放心。那麼剩下問題的就是:做爲開發者,咱們應該如何編寫高質量高性能的代碼?是否是應該更精益求精一點,讓咱們代碼更經得起推敲,更值得信任?