JavaScriptで Math.random() - 0.5 のシャッフルは不適切

Math.random() - 0.5 のシャッフルは不適切

JavaScriptで配列をシャッフルしたいとき、「sort(() => Math.random() - 0.5)」で処理したコードを見かけることがあります。

生成AIだと配列のシャッフルのコードを「sort(() => Math.random() - 0.5)」で生成することがあるので、以前よりも見かける機会が増えています。

直感的で短く書けるため便利ですが、このコードは数学的にも構造的にも均等なシャッフルにならず、致命的な偏りが発生します。

JavaScript
// ❌ 偏りが発生する不適切なシャッフル
const badShuffle = (arr) => [...arr].sort(() => Math.random() - 0.5)

const data = ['A', 'B', 'C']
const counts = {
  'A-B-C': 0,
  'A-C-B': 0,
  'B-A-C': 0,
  'B-C-A': 0,
  'C-A-B': 0,
  'C-B-A': 0
}
const trials = 1000000

for (let i = 0; i < trials; i++) {
  const badShuffled = badShuffle(data)
  const pattern = badShuffled.join('-')

  counts[pattern] = (counts[pattern] || 0) + 1
}

console.table(counts)
// ❌️「A-B-C」および「B-A-C」に偏る
// A-B-C	250584
// A-C-B	124939
// B-A-C	249634
// B-C-A	125352
// C-A-B	125065
// C-B-A	124426

JavaScriptの sort() メソッドは、比較関数(compareFn(a, b))が一貫した決定論的な比較結果を返すことを前提として設計されています。

具体的には「a > b かつ b > c ならば a > c である」というルールに従って要素の位置を確定していきます。

しかし、Math.random() - 0.5 を使うと、同じ要素ペアを比較するたびに正(a > b)にも負(a < b)にも転じます。

ソートアルゴリズム(V8エンジンのTimsortなど)は比較結果の矛盾により内部状態が崩れ、要素の比較や移動を途中で無駄にスキップしてしまいます。

確率分布が均等 1 N! にならない

N個の要素をシャッフルしたとき、理想的な全パターンの出現確率はすべて等しく均等になるべきです。

しかし、3要素 [A, B, C] の場合、sort(() => Math.random() - 0.5) で処理すると確率が以下のようになるため均等になりません。

  • [A, B, C] または [B, A, C]→ 約25%(10万回実行時の期待値は250,000)
  • [A, C, B], [B, C, A], [C, A, B], [C, B, A] → 約12.5%(10万回実行時の期待値は125,00)

このように、最も出やすい並びと最も出にくい並びで約2倍もの差が生じます。

最初は「A-B」または「B-A」のどちらかの50%ですが、要素数が増えるほど「先頭にあった要素は先頭付近にとどまりやすい」という強い偏りが目立つようになります。

ちなみに「A-B」と「B-A」のような2パターンであれば、50%の確率なので sort(() => Math.random() - 0.5) でも偏りは発生しません。

JavaScript
// ❌ 偏りが発生する不適切なシャッフル
const badShuffle = (arr) => [...arr].sort(() => Math.random() - 0.5)

const data = ['A', 'B']
const counts = {
  'A-B': 0,
  'B-A': 0
}
const trials = 1000000

for (let i = 0; i < trials; i++) {
  const badShuffled = badShuffle(data)
  const pattern = badShuffled.join('-')

  counts[pattern] = (counts[pattern] || 0) + 1
}

console.table(counts)
// ✅️「A-B」および「B-A」の2パターンなら偏りなし
// A-B	500130
// B-A	499870

しかし、2パターンのシャッフルだとしても、後から3パターン以上に仕様変更になって不具合が発生する可能性があるので、sort(() => Math.random() - 0.5) のシャッフルは使うべきではないです。

正しいシャッフル:Fisher-Yates アルゴリズム

偏りのない完全なランダムシャッフルを行うには、Fisher-Yates(フィッシャー–イェーツ)アルゴリズム を使用します。

末尾の要素から順に未選択のインデックスと要素を入れ替えていくアルゴリズムで、確率的にも数学的に完全に均等となります。

JavaScript
// ✅️ 偏りのないシャッフル (Fisher-Yates shuffle)
const shuffle = (arr) => arr.reduceRight((acc, _, i) => { const j = Math.floor(Math.random() * (i + 1)); [acc[i], acc[j]] = [acc[j], acc[i]]; return acc }, [...arr])

const data = ['A', 'B', 'C']
const counts = {
  'A-B-C': 0,
  'A-C-B': 0,
  'B-A-C': 0,
  'B-C-A': 0,
  'C-A-B': 0,
  'C-B-A': 0
}
const trials = 1000000

for (let i = 0; i < trials; i++) {
  const shuffled = shuffle(data)
  const pattern = shuffled.join('-')

  counts[pattern] = (counts[pattern] || 0) + 1 
}

console.table(counts)
// ✅️ 偏りが発生しない
// A-B-C	167117
// A-C-B	166148
// B-A-C	166267
// B-C-A	167257
// C-A-B	166648
// C-B-A	166563

ちなみにlodashなどのライブラリのシャッフルはFisher-Yates アルゴリズムを使用しているため、偏りが発生しません。

まとめ

あなたのプロジェクトフォルダ内でシャッフルを sort(() => Math.random() - 0.5) で処理しているコードがある場合は偏った結果になっています。

もし見つけたら、偏りがないFisher-Yatesを使用したシャッフルへの変更を推奨します。