.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;
}

可以在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騷操作】

DotNet騷操作


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM