[C#] 產生相異亂數

C# 有 Random 物件和 .Next() 等方法產生亂數,但不像 Python 有 random.sample() 等可直接產生相異隨機數字的好用方法,就整理了一下幾個並比較效率。以下目標都是產生 start 到 end 之間(頭尾有包含),number 個的相異且隨機的整數,並成一個整數陣列。例如 start = 20 、end = 50、number = 10,會產生 {37, 25, 48, 20, 32, 50, 44, 42, 29, 46}

作法一:單純 for loop

利用 Array 的 .Contains() 方法,若產生的新數已經出現過,就將 index-- 來重取,直到迴圈結束:

public int[] RandomDistinctByForLoop(int start, int end, int number)
{
  Random rnd = new Random();

  int[] arrayResult = new int[number];
  for (int i = 0; i < number; i++)
  {
    int rndResult = rnd.Next(start, end + 1);
    if (arrayResult.Contains(rndResult))  // 若已存在
    {
      i--;                                // 使 for 不會步進,再重覆一次
    }
    else                                  // 若不存在
    {
      arrayResult[i] = rndResult;         // 存入亂數結果
    }
  }
  return arrayResult;
}

簡單好寫,缺點是如果 number 很大時,在後期會因為多數都已重覆所以耗費大量時間。

作法二:單純 while loop

和上面差不多,用 while 來思考,很容易想到的寫法,若產生的新數沒有出現過,就放入 Array,並將 index++ ,直到數量達成`:

public int[] RandomDistinctByWhileLoop(int start, int end, int number)
{
  Random rnd = new Random();
  int[] arrayResult = new int[number];
  int index = 0, rndResult;
  do
  {
    rndResult = rnd.Next(start, end + 1);
    if (!arrayResult.Contains(rndResult))       // 亂數結果不存在
    {
      arrayResult[index++] = rndResult;         // 才存入,且index遞增
    }
  } while (index < number);
  return arrayResult;
}

和上面相似,所以也有後期效率問題。

作法三:巢狀for配while loop

弄作業時同學去 google 到的寫法,沒有利用 array 的 .Contains() 方法而是多一個內層的 for loop 和 while 檢查是否已存在:

public int[] RandomDistinctByForAndWhile(int start, int end, int number)
{
  Random rnd = new Random();
  int[] arrayResult = new int[number];
  for(int i=0; i < number; i++)
  {
    arrayResult[i] = rnd.Next(start, end + 1);
    for(int j=0; j < i; j++)
    {
      while (arrayResult[j] == arrayResult[i])
      {
        arrayResult[i] = rnd.Next(start, end + 1);
        j = 0;
      }
    }
  }
  return arrayResult;
}

這個的效率糟到不行,下面會看到。

作法四:用 List 亂數取值同時移除掉

這是我寫踩地雷和貪食蛇時用的方法,亂數出 List 中元素的位置 index,取出來該位置的數放入 Array 就把 List 的移除掉:

using System.Collections.Generic;
public int[] RandomDistinctByListRemove(int start, int end, int number)
{
  Random rnd = new Random();
  List<int> pickList = new List<int>();
  int[] arrayResult = new int[number];
  for (int i = start; i < end + 1; i++) 
  {
    pickList.Add(i);                  // 先造出連續數列,也可以用 Enumerable ToList
  }
  for (int i = 0; i < number; i++)
  {
    int rndIndex = rnd.Next(pickList.Count);    // 用剩餘長度取亂數作為 index
    arrayResult[i] = pickList[rndIndex];        // 取出該 index 位置的數
    pickList.RemoveAt(rndIndex);                // 移除使 List 變短
  }
  return arrayResult;
}

這個作法的好處是當 number 很大,並不會有重覆一直重新取亂數的情形,每次都是從 List 剩下的數中取,又因為之前有砍掉所以剩下也絕對不會重覆。

作法五:用 Linq 排序

StackOverflow 上很有名的作法,簡潔又有效率,無可挑剔:

using System.Linq;
public int[] RandomDistinctByLinq(int start, int end, int number)
{
Random rnd = new Random(DateTime.Now.Millisecond);
return Enumerable.Range(start, end - start + 1)
            .OrderBy(x => rnd.Next(number))
            .Take(number)
            .ToArray();
}

Enumerable.Range() 產生一個連續序列,.OrderBy() 將各位置置換到亂數後的位置,.Take() 只取前 number 個出來。

效率

下面用 Stopwatch 物件的 .Start() 和 .Stop() 計算之間經過的時間,再印出 StopwarchObject.ElapseMilliseconds 經過的毫秒數,例如:

using System.Diagnostics;
var sw = new Stopwatch();
sw.Start();
RandomDistinctByForLoop(1000000, 2000000, 100000);
sw.Stop();
Console.WriteLine("by for loop: " + sw.ElapsedMilliseconds);
sw.Reset();

以上程式碼會印出第一個只用 for 的方法,在取出 1000000 到 2000000 之間的 100000 個相異隨機整數,所花費的毫秒數。下面測試取 100000 個和取 900000 個的效率,前者佔範圍的十分之一,後者佔範圍的十分之九:

方法取100000個取900000個
RandomDistinctByForLoop4719604648
RandomDistinctByWhileLoop4671601386
RandomDistinctByForAndWhile104701833934
RandomDistinctByListRemove403320928
RandomDistinctByLinq263290

單位:毫秒(千分之一秒)

當取的數量愈來愈接近整個範圍時,例如一百萬個數字之中取九十萬個數,前三個方法後期愈有嚴重的效率問題,雖然「已重覆就重新再取」的想法很簡單,若有取逼近全範圍數量的需求時就要考慮後面兩個做法。例如貪食蛇要隨機生成食物,在遊戲後期因為蛇的成長使得食物的位置嚴重受限,可能要使用「將可以的位置做出來,從中亂數取一位置」,而不是「亂數取所有位置其中一個,若是蛇的身體則重取」。

機率公平性

但是 Linq 的寫法與效率快雖快,取個幾次之後發現,似乎沒有那麼均勻,似乎前面偏多,不太容易出現範圍後半段的數字,我就試驗了一下統計各個方法取出來數字的機率。以 1 到 100 之間取 20 個相異整數為例,理論上每個數字被取到的機率是 C9919 / C10020 = 1/5 = 0.20,實際結果呢?我試驗了 10000 次,每次都 1 到 100 之間取 20 個相異整數,確實每個數字都很接近 2000 個,大約是 0.20 ,但仔細看會發現 Linq 真的在範圍前段比較容易出現,不容易取得後段的整數。(亂數物件都有使用 GUID 即 new Random(Guid.NewGuid().GetHashCode()) 使得密集取亂數的結果不致於偏頗)

靠 for loop 和 Array 的 .Contains() :


靠 while loop 和 Array 的 .Contains() :


不用 .Contains() 靠巢狀 for 和 while loop :


靠 List 取數和截短 :


靠 Linq 的 .OrderBy() :


顯然前四個發生機率比較集中在 0.20 前後,正負0.01,但是 Linq 就嚴重出現前段的數字較容易出現的情形,所以雖然快速但不是公平的亂數方法,還是用 List 每取走數字就截短的方法是公正裡效率又不錯的。

留言