從資料集中隨機抽取一定數量的資料

邊城發表於2022-03-16

今天翻到一個以前回答的問題:從列表中隨機抽取一定數量的資料。之前回答是使用 Fisher-Yates 洗牌演算法來解決的,但是閱讀了評論之後,又有了一些新想法。

先不說是什麼演算法,只說說隨機抽取的思路。

隨機抽取的演算法演進

假設有 n 個資料儲存在一個列表 source 中(在 JavaScript 中是陣列),需要隨機抽取 m (m <= n) 個資料出來,結果放在另一個列表 result 中。由於隨機抽取是一個重複過程,可以使用一個 m 次的迴圈來完成,迴圈體中每次從 source 中選一個數出來(找到它,並把它從 source 中刪除),依次放在 result 中。用 JavaScript 來描述就是

function randomSelect(source, m) {
    const result = [];
    for (let i = 0; i < m; i++) {
        const rIndex = ~~(Math.random() * source.length);
        result.push(source[rIndex]);
        source.splice(rIndex, 1);
    }
    return result;
}

在多數語言中,從列表中間刪除一個資料,都會造成之後的資料重排,是個較低效率的操作。考慮到從一組資料中隨機抽取一個是等概率事件,與資料所在的位置無關,我們可以把選出來的資料去掉之後,不減少列表長度,而是直接把列表最後一個資料挪過來。下一次隨機取找位置的時候,不把最後一個元素考慮在內。這樣改進之後的演算法:

function randomSelect(source, m) {
    const result = [];
    for (let i = 0, n = source.length; i < m; i++, n--) {
//                  ^^^^^^^^^^^^^^^^^              ^^^
        const rIndex = ~~(Math.random() * n);
        result.push(source[rIndex]);
        source[rIndex] = source[n - 1];
//      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
    return result;
}

注意到這裡 n--n - 1 是可以合併的,合併後:

for (let i = 0, n = source.length; i < m; i++) {
    ...
    source[rIndex] = source[--n];
}

這時候,再次注意到,source 後面沒用的空間,其實是和 result 空間一樣大的,如果把這部分空間利用起來,就不再需要 result

function randomSelect(source, m) {
    for (let i = 0, n = source.length; i < m; i++) {
        const rIndex = ~~(Math.random() * n);
        --n;
        // 交換選中位置的資料和當前最後一個位置的資料
        [source[rIndex], source[n]] = [source[n], source[rIndex]];
    }
    // 把後面 m 個資料返回出來就是隨機選中的
    return source.slice(-m);  // -m 和 source.length - m 等效
}

如果保留原來的 result 及相關演算法,會發現 result 和現在返回的陣列元素正好是相反序排列的。但是不重要,因為我們的目的是隨機選擇,不管是否 revert,結果集都是隨機的。

但是這麼一來,假設 m = source.length,整個 source 中的資料都都被隨機排列了 —— 這就是 Fisher-Yates 演算法。當然,實際上只需要進行 source.length - 1 次處理就可以達到完全洗牌的效果。

Fisher-Yates 洗牌 (shuffle) 演算法

Fisher-Yates 高效和等概率的洗牌演算法。其核心思想是從 1 到 n 之間隨機抽取出一個數和最後一個數 (n) 交換,然後從 1 到 n-1 之間隨機出一個數和倒數第二個數 (n-1) 交換……,經過 n - 1 輪之後,原列表中的資料就被完全隨機打亂了。

每次和“當前最後一個元素交換”會把處理結果後置。如果改為每次與當前位置(即 i 位置)元素交換,就可以把結果集前置。但要注意隨機數的選擇就不是 [0, n) (0 < n < source.length) 這個範圍,而是 [i, source.length) 這個範圍了:

function randomSelect(source, m) {
    for (let i = 0, n = source.length; i < m; i++, n--) {
        const rIndex = ~~(Math.random() * n) + i;
        [source[rIndex], source[i]] = [source[i], source[rIndex]];
    }
    return source.slice(0, m);
}

這個過程可以用一個圖來幫助理解(隨機選 10 個)

image.png

既然是洗牌演算法,在資料量不大的情況下,可以使用現成的工作函式洗牌再從中取出來指定大小的連續資料就可以實現隨機抽取的目的。比如使用 Loadsh 的 _.shuffle() 方法

import _ from "lodash";
const m = 10;
const result = _.shuffle(data).slice(0, m);

這裡有兩個問題

  • 如果原資料量較大,或者原資料數量與要抽取的資料數量差異較大,會浪費很多算力
  • 洗牌演算法會修改原始資料集中的元素順序

對於第一個問題,使用前面手工敲的 randomSelect() 就行了,第二個問題下面專門來討論。

改進,不修改原資料集

要想不改變原資料,那就是不對資料來源中的元素進行交換或移位。但是需要知道哪些資料已經被選過,應該怎麼辦呢?有如下幾種方法

  • 附加一個已選擇元素序號集,如果某次算出來的 rIndex 能在這個集合中找到,就重新選一次。
    這是個辦法,但隨著可選序號和不可選序號的比例逐漸變小,重選的概率會大大增加,完全不能保證整個演算法的效率。
  • 同樣按上述方法使用一個已選擇序號集,但是發生碰撞的時候不重選,而是對序號進行累加取模。
    這個方法比上一個要穩定一些,但仍然存在不太穩定的累加計算,而且可能會降低隨機性。
  • ……

仔細想想,之前我們把最後一個未使用元素與 rIndex 所在元素進行交換的目的,就是為了讓 rIndex 再次出現時能命中一個未取到的值 —— 假設這個值不是從原資料集中去取,而是從一個附加的資料集去取呢?

舉例來說,rIndex = 5 的情況,第一次取 source[5] 得到 6,此時本應該把最後一個值賦過來,也就是 source[5] = source[13] = 14。我們把這個賦值過程改為 map[5] = source[13] = 14;下一次再命中 rIndex = 5 的時候,先去檢查 map 中是否存在 map[5],如果有就使用,沒有再使用 source 中的元素。用程式碼來描述這個通用過程就是:

const n = 
const value = map[rIndex] ?? source[rIndex];
result.push(value);  // 可以和上一句合併
map[rIndex] = map[n] ?? source[n];

map 中儲存了某個索引對應的修改後的值,所以每次去 source 中取值的時候,都先檢查 map。如果 map 中有,就取 map 中的;map 中沒有才去 source 中找。

說起來比較抽象,還是上圖

image.png

相應的程式碼也就容易寫出來了:

function randomSelect(source, m) {
    const result = [];
    const map = new Map();

    for (let i = 0, n = source.length; i < m; i++, n--) {
        const rIndex = ~~(Math.random() * n) + i;
        result[i] = map.get(rIndex) ?? source[rIndex];
//                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        map.set(rIndex, map.get(i) ?? source[i]);
//                      ^^^^^^^^^^^^^^^^^^^^^^^
    }

    return result;
}
提示:這段程式碼可以和上一節最後一個 randomeSelect 程式碼對比著看。

相關文章