はじめに:「ランダム抽出」は“偏りなく一部だけを見る”ための技
ランダム抽出は、「大量のデータの中から、ランダムに一部だけ取り出す」ためのテクニックです。
テストデータを適当に 100 件だけ見たいとき、アンケート回答からランダムに数件だけピックアップしたいとき、ログの一部だけをサンプリングしたいときなど、業務でも出番がかなり多いです。
ここでは、C# と LINQ を使って
単一要素のランダム抽出
複数件のランダム抽出(小規模コレクション)
大量データ向けの効率的なランダム抽出
を、初心者向けにかみ砕いて説明していきます。
特に、「とりあえず OrderBy(Guid.NewGuid()) でやる」のと「ちゃんと考えたランダム抽出」の違いを、しっかり押さえていきます。
単一要素のランダム抽出
インデックスをランダムに選ぶ基本パターン
一番シンプルな「ランダム抽出」は、「リストの中から 1 件だけランダムに選ぶ」パターンです。
これは、インデックスをランダムに選ぶのが一番素直です。
using System;
using System.Collections.Generic;
var list = new List<string> { "A", "B", "C", "D", "E" };
var random = new Random();
if (list.Count > 0)
{
int index = random.Next(list.Count); // 0 ~ Count-1 のどれか
string picked = list[index];
Console.WriteLine(picked);
}
C#ここでの重要ポイントは、「Random.Next(list.Count) で“有効なインデックス範囲の中からランダムに 1 つ選ぶ”」という考え方です。Next の引数は「上限(排他的)」なので、Count を渡せば 0 ~ Count-1 のどれかになります。
もう一つ大事なのは、Random を毎回 new しないことです。
短時間に何度も new すると、同じシードで初期化されてしまい、同じ順番が繰り返されることがあります。
業務ユーティリティとしては、Random をフィールドやシングルトンで共有するのが基本です。
複数件のランダム抽出(小さめのコレクション)
「シャッフルしてから Take する」という発想
「リストからランダムに 10 件取りたい」というような場合、
一番分かりやすいのは「一度シャッフルしてから、先頭から必要件数だけ取る」というやり方です。
シャッフルは前回話したフィッシャー–イェーツ法を使うと効率的です。
ここでは、拡張メソッドとして用意しておきます。
using System;
using System.Collections.Generic;
public static class ShuffleExtensions
{
private static readonly Random _random = new Random();
public static void ShuffleInPlace<T>(this IList<T> list)
{
if (list is null) throw new ArgumentNullException(nameof(list));
for (int i = list.Count - 1; i > 0; i--)
{
int j = _random.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
public static IList<T> Shuffled<T>(this IEnumerable<T> source)
{
if (source is null) throw new ArgumentNullException(nameof(source));
var list = new List<T>(source);
list.ShuffleInPlace();
return list;
}
}
C#このユーティリティを使って、ランダム抽出を書いてみます。
using System.Linq;
var all = Enumerable.Range(1, 100).ToList(); // 1 ~ 100
var sample = all
.Shuffled()
.Take(10)
.ToList();
Console.WriteLine(string.Join(", ", sample));
C#ここでの重要ポイントは、「シャッフル+Take で“重複なしのランダム抽出”が自然に書ける」ということです。
同じ要素を二度選びたくない場合(サンプリング、テストデータ抽出など)は、このパターンがとても相性が良いです。
OrderBy(Guid.NewGuid()).Take(n) の問題点
よく見かける書き方として、次のようなものがあります。
var sample = all
.OrderBy(_ => Guid.NewGuid())
.Take(10)
.ToList();
C#動きとしては「シャッフルしてから Take」と同じですが、内部的には
全要素分 Guid を生成する
Guid をキーにしてソートする
という処理をしているため、要素数が増えるとかなり重くなります。
小さなリストなら構いませんが、「業務ユーティリティ」として常用するにはコストが高すぎます。
ここでの重要ポイントは、「OrderBy(Guid.NewGuid()) は“お手軽だが重いシャッフル”であり、性能を気にする場面では避けるべき」ということです。
大量データ向けランダム抽出:リザーバサンプリング
「全部メモリに載せられない」場合の考え方
ログや履歴など、件数が何十万、何百万とある場合、
「全部 List に読み込んでからシャッフル」は現実的ではありません。
このときに使えるのが「リザーバサンプリング(Reservoir Sampling)」というアルゴリズムです。
ざっくり言うと、
最初の k 件をとりあえず採用する
k+1 件目以降は、「一定の確率で既存のどれかと入れ替える」
最後まで処理すると、「全体からランダムに k 件選んだ」のと同じ状態になる
という仕組みです。
C# での実装例
「IEnumerable<T> からランダムに n 件だけ抽出する」ユーティリティとして書いてみます。
using System;
using System.Collections.Generic;
public static class SamplingExtensions
{
private static readonly Random _random = new Random();
public static IList<T> Sample<T>(this IEnumerable<T> source, int count)
{
if (source is null) throw new ArgumentNullException(nameof(source));
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
var reservoir = new List<T>(count);
int index = 0;
foreach (var item in source)
{
if (index < count)
{
reservoir.Add(item);
}
else
{
int j = _random.Next(index + 1); // 0 ~ index のどれか
if (j < count)
{
reservoir[j] = item;
}
}
index++;
}
return reservoir;
}
}
C#使い方はこうです。
var bigSequence = Enumerable.Range(1, 1_000_000); // 100万件
var sample = bigSequence.Sample(10);
Console.WriteLine(string.Join(", ", sample));
C#ここでの重要ポイントは、「全体を List にせず、ストリームとして 1 件ずつ見ながらランダム抽出している」ということです。
メモリ使用量は「抽出したい件数」に比例し、元データの件数には依存しません。
大量データのサンプリングには、このアプローチが非常に強力です。
実務でのランダム抽出の使いどころと設計のポイント
「重複あり」か「重複なし」かを最初に決める
ランダム抽出を設計するとき、まず決めるべきなのはここです。
同じ要素が複数回選ばれてもよいか
それとも、1 回選ばれた要素は二度と選ばれないほうがよいか
重複ありでよければ、「ランダムなインデックスを何回か引く」だけで済みます。
var random = new Random();
var picks = new List<int>();
for (int i = 0; i < 10; i++)
{
int index = random.Next(list.Count);
picks.Add(list[index]);
}
C#重複なしにしたいなら、「シャッフル+Take」か「リザーバサンプリング」のような方法を選ぶべきです。
ここを曖昧にすると、「思っていたより同じ要素ばかり選ばれる」といった不満につながります。
「全件をメモリに載せられるかどうか」で手法を変える
全件を List にしても問題ない件数なら、Shuffled().Take(n) が一番分かりやすくて安全です。
全件をメモリに載せたくない、あるいは載せられない規模なら、Sample のようなストリームベースのアルゴリズムを選ぶべきです。
この判断を最初にしておくと、「とりあえず全部 ToList してから考える」という危険な癖を避けられます。
乱数の「再現性」が必要かどうかを考える
テストや検証では、「同じシードで同じランダム抽出結果を再現したい」ことがあります。
その場合は、Random を外から渡せるようにしておくと便利です。
public static IList<T> Sample<T>(this IEnumerable<T> source, int count, Random random)
{
// 中身はさきほどの Sample と同じだが、_random の代わりに random を使う
}
C#同じシードで初期化した Random を使えば、何度実行しても同じサンプリング結果になります。
これはテストコードを書くときに特に重要な考え方です。
まとめ:「ランダム抽出ユーティリティ」は“現実的な規模で公平さを保つ道具”
ランダム抽出の本質は、
「全部を見るのは現実的でない、あるいは意味がないときに、
偏りをできるだけ減らして“一部だけ代表として見る”」
ことです。
押さえておきたいポイントは、
単一要素なら「ランダムなインデックスで 1 件取る」
小規模なら「シャッフル+Take」で重複なしランダム抽出
大規模なら「リザーバサンプリング」でメモリを抑えつつランダム抽出
重複ありかなし、全件をメモリに載せるかどうか、再現性が必要かどうかを最初に決める
ここまで腹落ちしていれば、
「なんとなく OrderBy(Guid.NewGuid()).Take(n) をコピペする」段階から卒業して、
“データ量と目的に合った、業務で通用するランダム抽出ユーティリティ”を、自分で設計できるようになります。
