也談前端面試常見問題之「陣列亂序」

韓子遲發表於2016-07-05

前言

終於可以開始 Collection Functions 部分了。

可能有的童鞋是第一次看樓主的系列文章,這裡再做下簡單的介紹。樓主在閱讀 underscore.js 原始碼的時候,學到了很多,同時覺得有些知識點可以獨立出來,寫成文章與大家分享,而本文正是其中之一(完整的系列請猛戳 https://github.com/hanzichi/underscore-analysis)。之前樓主已經和大家分享了 Object 和 Array 的擴充套件方法中一些有意思的知識點,今天開始解讀 Collection 部分。

看完 Collection Functions 部分的原始碼,首先迫不及待想跟大家分享的正是本文主題 —— 陣列亂序。這是一道經典的前端面試題,給你一個陣列,將其打亂,返回新的陣列,即為陣列亂序,也稱為洗牌問題。

一個好的方案需要具備兩個條件,一是正確性,毋庸置疑,這是必須的,二是高效性,在確保正確的前提下,如何將複雜度降到最小,是我們需要思考的。

splice

幾年前樓主還真碰到過洗牌問題,還真的是 “洗牌”。當時是用 cocos2d-js(那時還叫 cocos2d-html5)做牌類遊戲,發牌前毫無疑問需要洗牌。

當時我是這樣做的。每次 random 一個下標,看看這個元素有沒有被選過,如果被選過了,繼續 random,如果沒有,將其標記,然後存入返回陣列,直到所有元素都被標記了。後來經同事指導,每次選中後,可以直接從陣列中刪除,無需標記了,於是得到下面的程式碼。

這個解法的正確性應該是沒有問題的(有興趣的可以自己去證明下)。我們假設陣列的元素為 0 – 10,對其亂序 N 次,那麼每個位置上的結果加起來的平均值理論上應該接近 (0 + 10) / 2 = 5,且 N 越大,越接近 5。為了能有個直觀的視覺感受,我們假設亂序 1w 次,並且將結果做成了圖表,猛戳 http://hanzichi.github.io/test-case/shuffle/splice/ 檢視,結果還是很樂觀的。

驗證了正確性,還要關心一下它的複雜度。由於程式中用了 splice,如果把 splice 的複雜度看成是 O(n),那麼整個程式的複雜度是 O(n^2)。

Math.random()

另一個為人津津樂道的方法是 “巧妙應用” JavaScript 中的 Math.random() 函式。

同樣是 [0, 1, 2 … 10] 作為初始值,同樣跑了 1w 組 case,結果請猛戳 http://hanzichi.github.io/test-case/shuffle/Math.random/

看平均值的圖表,很明顯可以看到曲線浮動,而且多次重新整理,折現的大致走向一致,平均值更是在 5 上下 0.4 的區間浮動。如果我們將 [0, 1, 2 .. 9] 作為初始陣列,可以看到更加明顯不符預期的結果(有興趣的可以自己去試下)。究其原因,要追究 JavaScript 引擎對於 Math.random() 的實現原理,這裡就不展開了(其實是我也不知道)。因為 ECMAScript 並沒有規定 JavaScript 引擎對於 Math.random() 應該實現的方式,所以我猜想不同瀏覽器經過這樣的亂序後,結果也不一樣。

什麼時候可以用這種方法亂序呢?”非正式” 場合,一些手寫 DEMO 需要亂序的場合,這不失為一種 clever solution。

但是這種解法不但不正確,而且 sort 的複雜度,平均下來應該是 O(nlogn),跟我們接下來要說的正解還是有不少差距的。

Fisher–Yates Shuffle

關於陣列亂序,正確的解法應該是 Fisher–Yates Shuffle,複雜度 O(n)。

其實它的思想非常的簡單,遍歷陣列元素,將其與之前的任意元素交換。因為遍歷有從前向後和從後往前兩種方式,所以該演算法大致也有兩個版本的實現。

從後往前的版本:

underscore 中採用從前往後遍歷元素的方式,實現如下:

將其解耦分離出來,如下:

跟前面一樣,做了下資料圖表,猛戳 http://hanzichi.github.io/test-case/shuffle/Fisher-Yates/

關於證明,引用自月影老師的文章

隨機性的數學歸納法證明

對 n 個數進行隨機:

  1. 首先我們考慮 n = 2 的情況,根據演算法,顯然有 1/2 的概率兩個數交換,有 1/2 的概率兩個數不交換,因此對 n = 2 的情況,元素出現在每個位置的概率都是 1/2,滿足隨機性要求。
  2. 假設有 i 個數, i >= 2 時,演算法隨機性符合要求,即每個數出現在 i 個位置上每個位置的概率都是 1/i。
  3. 對於 i + 1 個數,按照我們的演算法,在第一次迴圈時,每個數都有 1/(i+1) 的概率被交換到最末尾,所以每個元素出現在最末一位的概率都是 1/(i+1) 。而每個數也都有 i/(i+1) 的概率不被交換到最末尾,如果不被交換,從第二次迴圈開始還原成 i 個數隨機,根據 2. 的假設,它們出現在 i 個位置的概率是 1/i。因此每個數出現在前 i 位任意一位的概率是 (i/(i+1)) * (1/i) = 1/(i+1),也是 1/(i+1)。
  4. 綜合 1. 2. 3. 得出,對於任意 n >= 2,經過這個演算法,每個元素出現在 n 個位置任意一個位置的概率都是 1/n。

小結

關於陣列亂序,如果面試中被問到,能說出 “Fisher–Yates Shuffle”,並且能基本說出原理(你也看到了,其實程式碼非常的簡單),那麼基本應該沒有問題了;如果能更進一步,將其證明呈上(甚至一些面試官都可能一時證明不了),那麼就牛逼了。千萬不能只會用 Math.random() 投機取巧!

Read More:

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

也談前端面試常見問題之「陣列亂序」

相關文章