C# Tips | コレクション・LINQ:ランダム抽出

C# C#
スポンサーリンク

はじめに:「ランダム抽出」は“偏りなく一部だけを見る”ための技

ランダム抽出は、「大量のデータの中から、ランダムに一部だけ取り出す」ためのテクニックです。
テストデータを適当に 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) をコピペする」段階から卒業して、
“データ量と目的に合った、業務で通用するランダム抽出ユーティリティ”を、自分で設計できるようになります。

タイトルとURLをコピーしました