關於 JavaScript 的數組隨機排序

2021-03-02 前端大全

(點擊上方藍字,快速關注我們)

作者:oldj

blog.oldj.net/2017/01/23/shuffle-an-array-in-javascript/

如果好文章投稿,點擊 → 了解詳情

JavaScript 開發中有時會遇到要將一個數組隨機排序(shuffle)的需求,一個常見的寫法是這樣:

function shuffle(arr) {

   arr.sort(function () {

      return Math.random() - 0.5;

   });

}

或者使用更簡潔的 ES6 的寫法:

function shuffle(arr) {

    arr.sort(() => Math.random() - 0.5);

}

我也曾經經常使用這種寫法,不久前才意識到,這種寫法是有問題的,它並不能真正地隨機打亂數組。

問題

看下面的代碼,我們生成一個長度為 10 的數組['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'],使用上面的方法將數組亂序,執行多次後,會發現每個元素仍然有很大機率在它原來的位置附近出現。

let n = 10000;

let count = (new Array(10)).fill(0);

 

for (let i = 0; i < n; i ++) {

    let arr = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];

    arr.sort(() => Math.random() - 0.5);

    count[arr.indexOf('a')]++;

}

 

console.log(count);

在 Node.JS 6 中執行,輸出[ 2891, 2928, 1927, 1125, 579, 270, 151, 76, 34, 19 ](帶有一定隨機性,每次結果都不同,但大致分布應該一致),即進行 10000 次排序後,字母'a'(數組中的第一個元素)有約 2891 次出現在第一個位置、2928 次出現在第二個位置,與之對應的只有 19 次出現在最後一個位置。如果把這個分布繪製成圖像,會是下面這樣:

類似地,我們可以算出字母'f'(數組中的第六個元素)在各個位置出現的分布為[ 312, 294, 579, 1012, 1781, 2232, 1758, 1129, 586, 317 ],圖像如下:

如果排序真的是隨機的,那麼每個元素在每個位置出現的概率都應該一樣,實驗結果各個位置的數字應該很接近,而不應像現在這樣明顯地集中在原來位置附近。因此,我們可以認為,使用形如arr.sort(() => Math.random() - 0.5)這樣的方法得到的並不是真正的隨機排序。

另外,需要注意的是上面的分布僅適用於數組長度不超過 10 的情況,如果數組更長,比如長度為 11,則會是另一種分布。比如:

let a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k']; // 長度為11

let n = 10000;

let count = (new Array(a.length)).fill(0);

 

for (let i = 0; i < n; i ++) {

    let arr = [].concat(a);

    arr.sort(() => Math.random() - 0.5);

    count[arr.indexOf('a')]++;

}

 

console.log(count);

在 Node.JS 6 中執行,結果為[ 785, 819, 594, 679, 941, 1067, 932, 697, 624, 986, 1876 ],其中第一個元素'a'的分布圖如下:

sort_03

分布不同的原因是 v8 引擎中針對短數組和長數組使用了不同的排序方法(下面會講)。可以看到,兩種算法的結果雖然不同,但都明顯不夠均勻。

國外有人寫了一個Shuffle算法可視化的頁面,在上面可以更直觀地看到使用arr.sort(() => Math.random() - 0.5)的確是很不隨機的。

探索

看了一下ECMAScript中關於Array.prototype.sort(comparefn)的標準,其中並沒有規定具體的實現算法,但是提到一點:

Calling comparefn(a,b) always returns the same value v when given a specific pair of values a and b as its two arguments.

也就是說,對同一組a、b的值,comparefn(a, b)需要總是返回相同的值。而上面的() => Math.random() - 0.5(即(a, b) => Math.random() - 0.5)顯然不滿足這個條件。

翻看v8引擎數組部分的源碼,注意到它出於對性能的考慮,對短數組使用的是插入排序,對長數組則使用了快速排序,至此,也就能理解為什麼() => Math.random() - 0.5並不能真正隨機打亂數組排序了。(有一個沒明白的地方:源碼中說的是對長度小於等於 22 的使用插入排序,大於 22 的使用快排,但實際測試結果顯示分界長度是 10。)

解決方案

知道問題所在,解決方案也就比較簡單了。

方案一

既然(a, b) => Math.random() - 0.5的問題是不能保證針對同一組a、b每次返回的值相同,那麼我們不妨將數組元素改造一下,比如將每個元素i改造為:

let new_i = {

    v: i,

    r: Math.random()

};

即將它改造為一個對象,原來的值存儲在鍵v中,同時給它增加一個鍵r,值為一個隨機數,然後排序時比較這個隨機數:

arr.sort((a, b) => a.r - b.r);

完整代碼如下:

function shuffle(arr) {

    let new_arr = arr.map(i => ({v: i, r: Math.random()}));

    new_arr.sort((a, b) => a.r - b.r);

    arr.splice(0, arr.length, ...new_arr.map(i => i.v));

}

 

let a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];

let n = 10000;

let count = (new Array(a.length)).fill(0);

 

for (let i = 0; i < n; i ++) {

    shuffle(a);

    count[a.indexOf('a')]++;

}

 

console.log(count);

一次執行結果為:[ 1023, 991, 1007, 967, 990, 1032, 968, 1061, 990, 971 ]。多次驗證,同時在這兒查看shuffle(arr)函數結果的可視化分布,可以看到,這個方法可以認為足夠隨機了。

方案二(Fisher–Yates shuffle)

需要注意的是,上面的方法雖然滿足隨機性要求了,但在性能上並不是很好,需要遍歷幾次數組,還要對數組進行splice等操作。

考察Lodash 庫中的 shuffle 算法,注意到它使用的實際上是Fisher–Yates 洗牌算法,這個算法由 Ronald Fisher 和 Frank Yates 於 1938 年提出,然後在 1964 年由 Richard Durstenfeld 改編為適用於電腦編程的版本。用偽代碼描述如下:

-- To shuffle an array a of n elements (indices 0..n-1):

for i from n−1 downto 1 do

     j ← random integer such that 0 ≤ j ≤ i

     exchange a[j] and a[i]

一個實現如下(ES6):

function shuffle(arr) {

    let i = arr.length;

    while (i) {

        let j = Math.floor(Math.random() * i--);

        [arr[j], arr[i]] = [arr[i], arr[j]];

    }

}

或者對應的 ES5 版本:

function shuffle(arr) {

  var i = arr.length, t, j;

  while (i) {

    j = Math.floor(Math.random() * i--);

    t = arr[i];

    arr[i] = arr[j];

    arr[j] = t;

  }

}

小結

如果要將數組隨機排序,千萬不要再用(a, b) => Math.random() - 0.5這樣的方法。目前而言,Fisher–Yates shuffle 算法應該是最好的選擇。

覺得本文對你有幫助?請分享給更多人

關注「前端大全」,提升前端技能

相關焦點

  • 洗牌算法:給數組隨機排序
    洗牌算法是一個比較形象的術語,本質上讓一個數組內的元素隨機排列。此外,我們將該方法掛載在了 Array對象的原型下面,所以任何數組都可以直接調用該方法:var tempArray = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]tempArray.shuffle(); 工作原理看完代碼之後,讓我們看看它對數組都做了寫什麼。首先,該方法選中數組的最後一個元素:
  • JavaScript數組 - 系統排序
    系統排序排序的方法有很多,有逆向排序、冒泡排序、選擇排序、快速排序等我們先來看一下系統排序的方法1.reverse( ); 逆向排序格式:數組.reverse();舉個小例子:將10,20,30,40逆向排序
  • JavaScript中的簡單排序算法
    英文 | https://medium.com/javascript-in-plain-english/simple-sorting-algorithms-in-javascript
  • Javascript一維數組和對象數組排序方案
    js中用 sort() 方法為數組排序。如果這個參數被省略,那麼數組中的元素將按照ASCII字符順序進行排序。如:var arr = ["a", "A", "c", "B"];arr.sort();console.log(arr); // ["A", "B", "a", "c"]注意:sort() 方法在在原數組上進行排序,不生成副本。
  • JavaScript數組 - 選擇排序
    選擇排序選擇排序相對於冒泡排序是比較好理解的排序方法原理:通過比較首先選出最小的數放在第一個位置上,然後在其餘的數中選擇次小的數放在第二個位置,以此類推,直到排序完成。同樣還是用冒泡的那幾個數9,8,7,6,5,4我們通過選擇排序來進行從小到大排序先這種排序比較方法也叫做打擂臺法先看看第1個數9,往後所有的數進行比較,比較完成後就可以選出最小的數放在第0個位置;然後再進行第2個數進行比較,依次類推……我們來一起看這種排序方法怎麼寫上面這6個數,要比較多少次,打多少次擂臺呢?
  • JavaScript數組 - 冒泡排序
    冒泡排序這節我們來講大名鼎鼎的冒泡排序原理:前後兩個數兩兩進行比較,如果符合交換條件,交換位置,知道所有數據排序完成,結束比較。舉個小例子:我們來給9,8,7,6,5,4,排序為4,5,6,7,8,9排序比較的過程:第一輪:接著進行第二輪的排序比較:(9在最大位置上,所以在接下來的幾輪裡不跟著參與)再進行第三輪的排序比較(這一輪的8在最大位置,以後也不再參與)依次類推…經過上面的每一輪比較
  • Javascript數組系列四之數組的轉換與排序Sort方法
    Javascirpt 數組中的方法,數組的轉換是我們在項目的開發過程中,數據類型之間的轉換有著非常重要的作用,而數組轉換成其他數據類型是我們常見的一種。toString該方法是對數組轉換成字符串,數組的每一個元素都會調用 「toString」方法 ,返回一個新字符串。
  • PHP數組排序函數大全
    介紹在眾多數組排序函數中,最常用的排序函數:sort、rsort、asort、arsort。主要區別1.有些函數基於 array 的鍵來排序,而其他的基於值來排序的:$array['key'] = 'value';。
  • JavaScript:學會splice()數組操作
    使用javascript數組類型內置的splice方法僅需一行代碼即可輕鬆實現對數組元素進行插入、刪除、替換操作。方法名:Array.prototype.splice(index,count[,elm1,elm2...n])使用Array類型的splice方法可以對數組元素進行插入、替換、刪除。
  • JavaScript 數組操作函數總結
    注意如果參數也是數組的話,則是將全部數組當做一個元素壓入到原本的數組裡面去。pop() 函數則每次只會彈出結尾的元素,並返回彈出的元素,若是是對空組數調用 pop() 則返回undefined。如下示例,我們將創建一個數組,並把一個元素添加到數組的開頭,並返回數組的新長度:code123456789<script type="text/javascript">var arr = new Array()
  • 每日一課 | JavaScript中的數組
    JavaScript中的數組具有length屬性,該屬性返回該數組的大小。,並且未在數組中添加任何元素。 1)同時聲明和填充數組var array=[10,20,30];console.log(array);2)使用數組構造函數聲明數組:在此方法中,我們使用數組構造函數聲明數組,然後使用索引填充此數組。
  • JavaScript實現九種排序算法
    1.插入排序最普通的排序算法, 從數組下標1開始每增1項排序一次,越往後遍歷次數越多;原理圖:代碼:// 插入排序 從下標1開始每增1項排序一次,越往後遍歷次數越多很常見很容易理解的排序算法, 排序思路:遍歷數組,每次遍歷就將最大(或最小)值推至最前。
  • C++實現對數組的下標排序例子
    本文實現的功能是:ip轉換為數值, 數值轉換為ip,具體代碼如下: 在C++中,怎樣實現對數組的下標排序呢?;#include <stdlib.h>#include <time.h>#define size 10struct temp{ int number; int index;};int main(){ int s[size]; //通過隨機數得到數組的初始值
  • JavaScript算法之快速排序
    如果你在面試軟體工程師的職位,經常被問到的一個問題是快速排序是如何工作的。快速排序最簡單的邏輯步驟如下:選擇一個基準元素,把數組分為兩個子數組。可以隨機選擇一個元素作為基準元素。重排數組以實現小於基準元素的元素被放置在基準的左側,大於基準元素的元素被放置在右側。對於左側和右側的元素重複步驟1和步驟2。也就是遞歸運行如上步驟,把更大的值和更小的值分割開來。
  • 利用VBA實現數據的隨機排序
    這講是第四節「利用VBA實現數據的隨機排序」。這套教程從簡單的錄製宏開始講解,一直到窗體的搭建,內容豐富,案例眾多。大家可以非常容易的掌握相關的知識,這套教程面向初學人員,共三冊,十七章,都是我們在利用EXCEL工作過程中需要掌握的知識點,希望大家能掌握利用。第四節 利用VBA實現數據的隨機排序大家好,我們這講的內容是隨機排序的實現。什麼是隨機排序呢?
  • 如何在 JavaScript 中等分數組
    將數組分為兩個相等的部分我們可以分兩步將數組分成兩半:使用length/2和Math.ceil()方法找到數組的中間索引使用中間索引和Array.splice()方法獲得數組等分的部分Math.ceil() 函數返回大於或等於一個給定數字的最小整數。
  • 15個必須知道的JavaScript數組方法
    原文 | https://www.ibrahima-ndaw.com/blog/15-must-known-javascript-array-methods-in
  • 快速排序的JavaScript實現詳解
    了解快速排序背後的邏輯先看一下快速排序的工作原理:在數組中選擇一個元素,這個元素被稱為基準(Pivot)。通常把數組中的第一個或最後一個元素作為基準。然後,重新排列數組的元素,以使基準左側的有元素都小於基準,而右側的所有元素都大於基準。這一步稱為分區。
  • JavaScript實現排序算法
    隨後只需進行微調就可完成數組的排序;具體過程如下:排序之前的,儲存10個數據的原始數組為:設初始增量gap = length / 2 = 5,即數組被分為了5組,如圖所示分別為:[8, 3]、[9, 5]、[1, 4]、[7, 6]、[2, 0]:隨後分別在每組中對數據進行局部排序,5組的順序如圖所示,變為:[3, 8]、[5, 9]、[1, 4]、[6,
  • Javascript中實現JSON數組多鍵值排序
    【IT168 技術】在某項目中,需要實現用戶自定義菜單的顯示順序,以及某項菜單是否顯示,摸索了很久,最後找到了一個自己比較滿意的思路:  ①首先在後臺使用C#獲取資料庫中的菜單數據,生成一個包含菜單數據項的JSON數組(由於某種原因沒有使用SQL中的ORDER BY),如下: