.NET如何寫正確的“抽獎”——數組亂序算法
數組亂序算法常用於抽獎等生成臨時數據操作。就拿年會抽獎來說,如果你的算法有任何瑕疵,造成了任何不公平,在年會現場code review
時,搞不好不能活着走出去。
這個算法聽起來很簡單,簡單到有時會拿它做面試題去考候選人,但它實際又很不容易,因為細節很重要,稍不留神就錯了。
首先來看正確的做法:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray();
for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i + 1);
T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
}
return arr;
}
可以在LINQPad 6
中,使用如下代碼,測試隨機打亂0-10
的數列,進行50萬
條次模擬統計:
int[] Measure(int n, int maxTime)
{
var data = Enumerable.Range(0, n);
var sum = new int[n];
var r = new Random();
for (var times = 0; times < maxTime; ++times)
{
var result = ShuffleCopy(data, r);
for (var i = 0; i < n; ++i)
{
sum[i] += result[i] != i ? 1 : 0;
}
}
return sum;
}
然后可以使用LINQPad
特有的報表函數,將數據展示為圖表:
Util.Chart(
Measure(10, 50_0000).Select((v, i) => new { X = i, Y = v}),
x => x.X, y => y.Y, Util.SeriesType.Bar
).Dump();
運行效果如下(記住這是正確的示例):
可見50萬
次測試中,曲線基本平穩,0-10
的分布基本一致,符合統計學上的概率相等。
再來看看如果未做任何排序的代碼:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) => data.ToArray();
曲線:
記住這兩條曲線,它們將作為我們的參考曲線。
不然呢?
其實正確的代碼每一個標點符號都不能錯,下面我將演示一些錯誤的示例
錯誤示例1
多年前我看到某些年會抽獎中使用了代碼(使用JavaScript
、錯誤示例):
[0,1,2,3,4,5,6,7,8,9].sort((a, b) => Math.random() - 0.5)
// 或者
[0,1,2,3,4,5,6,7,8,9].sort((a, b) => Math.random() - Math.random())
返回結果如下:
(10) [8, 4, 3, 6, 2, 1, 7, 9, 5, 0]
看起來“挺”正常的,數據確實被打亂了,這些代碼在C#
中也能輕易寫出來:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) =>
data.OrderBy(v => r.NextDouble() < 0.5).ToArray();
50萬
條數據統計結果如下:
可見,排在兩端的數字幾乎沒多大變化,如果用於公司年會抽獎,那么排在前面的人將有巨大的優勢。
對比一下,如果在公司年會抽獎現場,大家Code Review
時在這時“揭竿而起”,是不是很正常?
為什么會這樣?
因為排序算法的本質是不停地比較兩個值,每個值都會比較不止一次。因此要求比較的值必須是穩定的,在此例中明顯不是。要獲得穩定的結果,需要將隨機數固定下來,像這樣:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r) => data
.Select(v => new { Random = r.NextDouble(), Value = v})
.OrderBy(v => v.Random)
.Select(x => x.Value)
.ToArray();
此時結果如下(正確):
這種算法雖然正確,但它消耗了過多的內存,時間復雜度為整個排序的復雜度,即O(N logN)
。
亂個序而已,肯定有更好的算法。
錯誤示例2
如果將所有值遍歷一次,將當前位置的值與隨機位置的值進行交換,是不是也一樣可以精准打亂一個數組呢?
試試吧,按照這個想法,代碼可寫出如下:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray();
for (var i = 0; i < arr.Length; ++i)
{
int randomIndex = r.Next(arr.Length);
T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
}
return arr;
}
運行結果如下:
有一點點不均勻,我可以保證這不是誤差,因為多次測試結果完全一樣,咱們拿數據說話,通過以下代碼,可以算出所有值的變化比例:
Measure(10, 50_0000).Select(x => (x / 50_0000.0).ToString("P2")).Dump();
結果如下:
0 90.00%
1 90.54%
2 90.97%
3 91.29%
4 91.41%
5 91.38%
6 91.31%
7 90.97%
8 90.60%
9 90.01%
按道理每個數字偏離本值比例應該是90.00%
的樣子,本代碼中最高偏離值高了1.41%
,作為對比,可以看看正確示例的偏離比例數據:
0 90.02%
1 90.05%
2 90.04%
3 89.98%
4 90.05%
5 90.04%
6 90.07%
7 90.03%
8 89.97%
9 90.02%
可見最大誤差不超過0.05%
,相比高達1%
的誤差,這一定是有問題的。
其實問題在於隨機數允許移動多次,如果出現多次隨機,可能最終的值就不隨機了,可以見這個示例,如果一個窗口使用這樣的方式隨機畫點:坐標x兩個隨機數相加、坐標y僅一個隨機數,示例代碼如下:
// 安裝NuGet包:FlysEngine.Desktop
using var form = new RenderWindow();
var r = new Random();
var points = Enumerable.Range(0, 10000)
.Select(x => (x: r.NextDouble() + r.NextDouble(), y: r.NextDouble()))
.ToArray();
form.Draw += (o, ctx) =>
{
ctx.Clear(Color.CornflowerBlue);
foreach (var p in points)
{
ctx.FillRectangle(new RectangleF(
(float)p.x / 2 * ctx.Size.Width,
(float)p.y * ctx.Size.Width,
ctx.Size.Width / 100, ctx.Size.Height / 100), form.XResource.GetColor(Color.Black));
}
};
RenderLoop.Run(form, () => form.Render(0, PresentFlags.None));
那么畫出來的點可能是這個樣子:
可見,1萬
條數據,x
坐標兩個隨機數相加之后,即使下方代碼中除以2
了,結果已經全部偏向中間值了(和本例代碼效果一樣),而只使用一次的y
坐標,隨機程度正常。想想也能知道,就像扔色子一樣,兩次扔色子平均是6
的機率遠比平均是3
的機率低。
因此可以得出一個結論:隨機函數不能隨意疊加。
錯誤示例3
如何每個位置的點只交換一次呢?沒錯,我們可以倒着寫這個函數,首先來看這樣的代碼:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray();
for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i);
T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
}
return arr;
}
注意循環終止條件是i > 0
,而不是直接遍歷的i >= 0
,因為r.Next(i)
的返回值一定是小於i
的,用>=0
沒有意義,首先來看看結果:
用這個算法,每個數字出來都一定不是它自己本身,這合理嗎?聽起來感覺也合理,但真的如此嗎?
假設某公司年會使用該算法抽獎,那結論就是第一個人不可能中獎,如果恰好你正好是抽獎名單列表的第一個人,你能接受嗎?
據說當年二戰時期德國的通訊加密算法,就是因為加密之前一定和原先的數據不一樣,導致安全性大大降低,被英國破解的。
這個問題在於算法沒允許和數字和自己進行交換,只需將r.Next(i)
改成r.Next(i + 1)
,問題即可解決。
總結
所以先回顧一下文章最初算法:
T[] ShuffleCopy<T>(IEnumerable<T> data, Random r)
{
var arr = data.ToArray();
for (var i = arr.Length - 1; i > 0; --i)
{
int randomIndex = r.Next(i + 1);
T temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
}
return arr;
}
然后重新體會一下它性感的測試數據(10
條數據,標准的90%
):
只有寫完很多個不正確的版本,才能體會出寫出正確的代碼,每一個標點符號都很重要的感覺。
喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】