聊聊前端排序的那些事

發表於2016-07-18

前言

貌似前端[1]圈一直以來流傳著一種誤解:前端用不到演算法知識。[2]

長久以來,我也曾受這種說法的影響。直到前陣子遇到一個產品需求,回過頭來看,發現事實並非如此。

前端排序

前端排序的場景

前端將排序條件作為請求引數傳遞給後端,後端將排序結果作為請求響應返回前端,這是一種常見設計。但是對於有些產品則不是那麼適用。

試想一個場景:你在使用美食類APP時,是否會經常切換排序方式,一會兒按照價格排序,一會兒按照評分排序。

實際生產中,受限於伺服器成本等因素,當單次資料查詢成為整體效能瓶頸時,也會考慮通過將排序在前端完成的方式來優化效能。

排序演算法

感覺這個沒什麼介紹的必要,作為電腦科學的一種基礎演算法,描述就直接照搬 Wikipedia

這裡存在這一段內容純粹是為了承(cou)上(man)啟(zi)下(shu)。

JavaScript的排序

既然說到前端排序,自然首先會想到JavaScript的原生介面 Array.prototype.sort

這個介面自 ECMAScript 1st Edition 起就存在。讓我們看看最新的規範中關於它的描述是什麼樣的。

Array.prototype.sort規範

Array.prototype.sort(compareFn)

The elements of this array are sorted. The sort is not necessarily stable (that is, elements that compare equal do not necessarily remain in their original order). If comparefn is not undefined, it should be a function that accepts two arguments x and y and returns a negative value if x < y, zero if x = y, or a positive value if x > y.

顯然,規範並沒有限定 sort 內部實現的排序演算法是什麼。甚至介面的實現都不需要是 穩定排序 的。這一點很關鍵,接下來會多次涉及。

在這樣的背景下,前端排序這件事其實取決於各家瀏覽器的具體實現。那麼,當今主流的瀏覽器關於排序是怎麼實現的呢?接下來,我們分別簡單對比一下 ChromeFirefoxMicrosoft Edge

Chrome中的實現

Chrome 的JavaScript引擎是 v8。由於它是開源的,所以可以直接看原始碼

整個 array.js 都是用 JavaScript 語言實現的。排序方法部分很明顯比曾經看到過的快速排序要複雜得多,但顯然核心演算法還是快速排序。演算法複雜的原因在於 v8 出於效能考慮進行了很多優化。(接下來會展開說)

Firefox中的實現

暫時無法確定Firefox的JavaScript引擎即將使用的陣列排序演算法會是什麼。[3]

按照現有的資訊,SpiderMoney 內部實現了 歸併排序

Microsoft Edge中的實現

Microsoft Edge 的JavaScript引擎 Chakra 的核心部分程式碼已經於2016年初在Github開源。

通過看原始碼可以發現,Chakra 的陣列排序演算法實現的也是 快速排序。而且相比較於 v8,它就只是實現了純粹的快速排序,完全沒有 v8 中的那些效能優化的蹤影。

JavaScript陣列排序的問題

眾所周知,快速排序 是一種不穩定的排序演算法,而 歸併排序 是一種穩定的排序演算法。由於不同引擎在演算法選擇上的差異,導致前端依賴 Array.prototype.sort 介面實現的JavaScript程式碼,在瀏覽器中實際執行的排序效果是不一致的。

排序穩定性的差異需要有特定的場景觸發才會存在問題;在很多情況下,不穩定的排序也不會造成影響。

假如實際專案開發中,對於陣列的排序沒有穩定性的需求,那麼其實看到這裡為止即可,瀏覽器之間的實現差異不那麼重要。

但是若專案要求排序必須是穩定的,那麼這些差異的存在將無法滿足需求。我們需要為此進行一些額外的工作。

舉個例子:

探究差異的背後

尋找解決辦法之前,我們有必要先探究一下出現問題的原因。

Chrome為什麼採用快速排序

其實這個情況從一開始便存在。

Chrome測試版2008年9月2日釋出,然而釋出後不久,就有開發者向 Chromium 開發組提交#90 Bug反饋v8的陣列排序實現不是穩定排序的。

這個Bug ISSUE討論的時間跨度很大。一直到2015年11月10日,仍然有開發者對v8的陣列排序實現問題提出評論。

同時我們還注意到,該ISSUE曾經已被關閉。但是於2013年6月被開發組成員重新開啟,作為 ECMAScript Next 規範討論的參考。

es-discuss的最後結論是這樣的

It does not change. Stable is a subset of unstable. And vice versa, every unstable algorithm returns a stable result for some inputs. Mark’s point is that requiring “always unstable” has no meaning, no matter what language you chose.

/Andreas

正如本文前段所引用的已定稿 ECMAScript 2015 規範中的描述。

時代特點

IMHO,Chrome釋出之初即被報告出這個問題可能是有其特殊的時代特點。

上文已經說到,Chrome第一版2008年9月 釋出的。根據statcounter的統計資料,那個時期市場佔有率最高的兩款瀏覽器分別是IE(那時候只有IE6IE7) 和 Firefox,市場佔有率分別達到了67.16%25.77%。也就是說,兩個瀏覽器加起來的市場佔有率超過了90%

而根據另一份瀏覽器排序演算法穩定性的統計資料顯示,這兩款超過了90%市場佔有率的瀏覽器都採用了穩定的陣列排序。所以Chrome釋出之初被開發者質疑也是合情合理的。

符合規範

Bug ISSUE討論的過程中,可以大概理解開發組成員對於引擎實現採用快速排序的一些考量。

其中一點,他們認為引擎必須遵守ECMAScript規範。由於規範不要求穩定排序的描述,故他們認為 v8 的實現是完全符合規範的。

效能考慮

另外,他們認為 v8 設計的一個重要考量在於引擎的效能。

快速排序 相比較於 歸併排序,在整體效能上表現更好:

  • 更高的計算效率。快速排序 在實際計算機執行環境中比同等時間複雜度的其他排序演算法更快(不命中最差組合的情況下)
  • 更低的空間成本。前者僅有O(㏒n)的空間複雜度,相比較後者O(n)的空間複雜度在執行時的記憶體消耗更少
v8在陣列排序演算法中的效能優化

既然說 v8 非常看中引擎的效能,那麼在陣列排序中它做了哪些事呢?

通過閱讀原始碼,還是粗淺地學習了一些皮毛。

混合插入排序

快速排序 是分治的思想,將大陣列分解,逐層往下遞迴。但是若遞迴深度太大,為了維持遞迴呼叫棧的記憶體資源消耗也會很大。優化不好甚至可能造成棧溢位。

目前v8的實現是設定一個閾值,對最下層的10個及以下長度的小陣列使用 插入排序

根據程式碼註釋以及 Wikipedia 中的描述,雖然插入排序的平均時間複雜度為 O(n²) 差於快速排序的 O(n㏒n)。但是在執行環境,小陣列使用插入排序的效率反而比快速排序會更高,這裡不再展開。

v8程式碼示例

三數取中

正如已知的,快速排序的阿克琉斯之踵在於,最差陣列組合情況下會演算法退化。

快速排序的演算法核心在於選擇一個基準 (pivot),將經過比較交換的陣列按基準分解為兩個數區進行後續遞迴。試想如果對一個已經有序的陣列,每次選擇基準元素時總是選擇第一個或者最後一個元素,那麼每次都會有一個數區是空的,遞迴的層數將達到 n,最後導致演算法的時間複雜度退化為 O(n²)。因此 pivot 的選擇非常重要。

v8採用的是 三數取中(median-of-three) 的優化:除了頭尾兩個元素再額外選擇一個元素參與基準元素的競爭。

第三個元素的選取策略大致為:

  1. 當陣列長度小於等於1000時,選擇折半位置的元素作為目標元素。
  2. 當陣列長度超過1000時,每隔200-215個(非固定,跟著陣列長度而變化)左右選擇一個元素來先確定一批候選元素。接著在這批候選元素中進行一次排序,將所得的中位值作為目標元素

最後取三個元素的中位值作為 pivot

v8程式碼示例

原地排序

在溫習快速排序演算法時,我在網上看到了很多用JavaScript實現的例子。

但是發現一大部分的程式碼實現如下所示

以上程式碼的主要問題在於:利用 leftright 兩個數區儲存遞迴的子陣列,因此它需要 O(n) 的額外儲存空間。這與理論上的平均空間複雜度 O(㏒n) 相比差距較大。

額外的空間開銷,同樣會影響實際執行時的整體速度。這也是快速排序在實際執行時的表現可以超過同等時間複雜度級別的其他排序演算法的其中一個原因。所以一般來說,效能較好的快速排序會採用原地 (in-place) 排序的方式。

v8 原始碼中的實現是對原陣列進行元素交換。

Firefox為什麼採用歸併排序

它的背後也是有故事的。

Firefox其實在一開始釋出的時候對於陣列排序的實現並不是採用穩定的排序演算法,這塊有據可考。

Firefox(Firebird)最初版本 實現的陣列排序演算法是 堆排序,這也是一種不穩定的排序演算法。因此,後來有人對此提交了一個Bug

Mozilla開發組內部針對這個問題進行了一系列討論

從討論的過程我們能夠得出幾點

  1. 同時期 Mozilla 的競爭對手是 IE6,從上文的統計資料可知IE6是穩定排序的
  2. JavaScript之父 Brendan Eich 覺得 Stability is good
  3. Firefox在採用 堆排序 之前採用的是 快速排序

基於開發組成員傾向於實現穩定的排序演算法為主要前提,Firefox3歸併排序 作為了陣列排序的新實現。

解決排序穩定性的差異

以上說了這麼多,主要是為了講述各個瀏覽器對於排序實現的差異,以及解釋為什麼存在這些差異的一些比較表層的原因。

但是讀到這裡,讀者可能還是會有疑問:如果我的專案就是需要依賴穩定排序,那該怎麼辦呢?

解決方案

其實解決這個問題的思路比較簡單。

瀏覽器出於不同考慮選擇不同排序演算法。可能某些偏向於追求極致的效能,某些偏向於提供良好的開發體驗,但是有規律可循。

從目前已知的情況來看,所有主流瀏覽器(包括IE6,7,8)對於陣列排序演算法的實現基本可以列舉:

  1. 歸併排序 / Timsort
  2. 快速排序

所以,我們將快速排序經過定製改造,變成穩定排序的是不是就可以了?

一般來說,針對物件陣列使用不穩定排序會影響結果。而其他型別陣列本身使用穩定排序或不穩定排序的結果是相等的。因此方案大致如下:

面對歸併排序這類實現時由於演算法本身就是穩定的,額外增加的自然序比較並不會改變排序結果,所以方案相容性比較好。

但是涉及修改待排序陣列,而且需要開闢額外空間用於儲存自然序屬性,可想而知 v8 這類引擎應該不會採用類似手段。不過作為開發者自行定製的排序方案是可行的。

方案程式碼示例

以上只是一個簡單的滿足穩定排序的演算法改造示例。

之所以說簡單,是因為實際生產環境中作為陣列輸入的資料結構冗雜,需要根據實際情況判斷是否需要進行更多樣的排序前型別檢測。

後言

必須看到,這幾年越來越多的專案正在往富客戶端應用方向轉變,前端在專案中的佔比變大。隨著未來瀏覽器計算能力的進一步提升,它允許進行一些更復雜的計算。伴隨職責的變更,前端的形態也可能會發生一些重大變化。

行走江湖,總要有一技傍身。

標註

  1. 前端現在已經是一個比較寬泛的概念。本文中的前端主要指的是以瀏覽器作為載體,以 JavaScript 作為程式語言的環境
  2. 本文無意於涉及演算法整體,謹以常見的排序演算法作為切入點
  3. 在確認 Firefox 陣列排序實現的演算法時,搜到了 SpiderMoney 的一篇排序相關的Bug。大致意思是討論過程中有人建議用極端情況下效能更好的 Timsort 演算法替換 歸併排序,但是 Mozilla 的工程師表示由於 Timsort 演算法存在License授權問題,沒辦法在 Mozilla 的軟體中直接使用演算法,等待對方的後續回覆

參考文件

相關文章