.NET如何写正确的“抽奖”——数组乱序算法
.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; }可以在?LINQPad6中,使用如下代碼,測試隨機打亂?0-10的數列,進行?50萬條次模擬統計:
int[] Measure(int n, int maxTime) { var data = Enumerable.Range(1, 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萬條數據統計結果如下:?
可見,排在兩端的數字幾乎沒多大變化,如果用于公司年會抽獎,那么排在前面的人將有巨大的優勢。
對比一下,如果在公司年會抽獎現場,大家?CodeReview時在這時“揭竿而起”,是不是很正常?
為什么會這樣?
因為排序算法的本質是不停地比較兩個值,每個值都會比較不止一次。因此要求比較的值必須是穩定的,在此例中明顯不是。要獲得穩定的結果,需要將隨機數固定下來,像這樣:
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%):?
只有寫完很多個不正確的版本,才能體會出寫出正確的代碼,每一個標點符號都很重要的感覺。
微信不能評論,各位可以前往博客園:https://www.cnblogs.com/sdflysha/p/20191103-shuffle-array-with-dotnet.html
喜歡的朋友請關注我的微信公眾號:【DotNet騷操作】
總結
以上是生活随笔為你收集整理的.NET如何写正确的“抽奖”——数组乱序算法的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在.NET Core 3.0中发布单个E
- 下一篇: .NET Core 3.0 中间件 Mi