原創佛系紅包演算法,瞭解一下?

youngdro發表於2018-05-02

三年前微信紅包爆火的時候,腦補了下背後的分配原理,並用C寫了個demo,如今回想覺得當時的解法有一定的趣味性,遂豐富完整了下,用js重寫了一遍。

紅包演算法需滿足的規則如下:

  1. 所有人搶到金額之和等於紅包金額,不能超過,也不能少於;
  2. 所有人搶到金額的機率相等;
  3. 每個人搶到的金額均大於0。

我腦補的第一畫面就是:排排坐,分果果

於是分配原理如下:

眾人們先按搶紅包的順序依次入座,圍成圓環,將金額均分到每個人,然後每人同時將自己手中的金額隨機抽出部分給左右臨近的2個人,但保證手頭至少剩餘1單位的金額,完成分配。

  • 由於在總金額的基礎上進行交換分配,故滿足規則一;
  • 由於在金額均分的基礎上再進行同等條件的隨機金額交換,故滿足規則二;
  • 由於隨機分配中保證了至少保留1單位的金額,故滿足規則三。

接下來開始實現上述過程

  1. 獲取分配總額

由於弱型別語言可變換莫測的入參,在拿到總金額數字的時候必須抖個機靈做下過濾,這裡使用了jonschlinkert大神寫的is-number函式,用於判斷入參是否是數字,否則置它為0;另外,為了規避js中小數運算的精度問題,該演算法中只使用整數進行加減,即將小數放到位整數(乘倍數),運算後再縮小回原來倍數(除倍數)。

class RandomSplit{
	constructor(num){
	        // 實際總數
		this.num = this.getNum(num);
		// 放大倍數
		try{
			this.multiple = this.num.toString().split('.')[1].length;
		}catch(e){
			this.multiple = 0;
		}
		// 用於整數運算的總數
		this.calcNum = this.num * Math.pow(10, this.multiple);
	}
	// 判斷是否為number(取用至“is-number”)
	isNumber(num){
		let number = +num;
		if((number - number) !== 0){
			return false;
		}
		if(number === num){
			return true;
		}
		if(typeof num === 'string'){
			if(number === 0 && num.trim() === ''){
				return false;
			}
			return true;
		}
		return false;
	}
	// 獲取數字
	getNum(num, defaultNum = 0){
		return this.isNumber(num) ? (+num) : defaultNum;
	}
}
複製程式碼
  1. 環形入座,將總數按份數均分

看“環形”二字,彷彿需要使用雙向迴圈連結串列,為節省程式碼,這裡只用一維陣列模擬其效果,在陣列首尾做資料銜接即可。在該演算法中,所有用於分配交換的數字的原子單位都是整數1,所以均分也需要均分為整數,例如總數15均分為6份,先每份分到2(Math.floor(15/6)===2),還餘3(15%6===3),為了使後面用於計算的概率儘可能平均,我們需要把這餘下的3個單位均勻灑落到那6份裡面,類似過程如下圖:

原創佛系紅包演算法,瞭解一下?

同理,若想要均分地更加精確,可提供精度的位數,然後將總數按該位數放大,整數均分後每份再按該精度位數縮小。

於是均分函式如下:

// 均分份數, 均分精度, 是否直接返回放大後的整數
average(n, precision, isInt){
	precision = Math.floor(this.getNum(precision, 0));
	n = Math.floor(this.getNum(n));
	let calcNum = this.calcNum * Math.pow(10, precision<0 ? 0 : precision);
	// 份數超過放大後的計算總數,即不夠分的情況
	if(n > calcNum){
		return [];
	}else{
		let index = 0;
		// 平均數
		let avg = Math.floor(calcNum / n);
		// 剩餘數
		let rest = calcNum % n;
		// 剩餘數填充間隔
		let gap = Math.round((n-rest) / rest) + 1;
		// 原始平均陣列
		let result = Array(n).fill(avg);
		// 
		while (rest > 0) {
	    	        index = (--rest) * gap;
			result[index>=n ?(n-1) : index]++;
		}
		// 返回放大後的結果陣列
		if(isInt){
			return result;
		}
		// 返回處理完符合精度要求的結果陣列
		return result.map((item) => {
			return (item / Math.pow(10, this.multiple + precision));
		});
	}
}
複製程式碼

測試效果如下:

原創佛系紅包演算法,瞭解一下?

  1. 相鄰隨機交換

得到均分數額後,每個位置先隨機出將要給出的數額,該數額大於等於0且小於自己的初始數額,再將該數額隨機劃分為兩份,分別給到相鄰的左右位置。

// 隨機劃分的份數, 劃分精度
split(n, precision){
        n = Math.floor(this.getNum(n));
	precision = Math.floor(this.getNum(precision, 0));
	// 均分
	let arr = this.average(n, precision, true);
	let arrResult = arr.concat();
	for (let i = 0; i < arr.length; i++) {
	        //給出的總額
		let num = Math.floor(Math.random() * arr[i]);
		// 給左鄰的數額
		let numLeft = Math.floor(Math.random() * num);
		// 給右鄰的數額
		let numRight = num - numLeft;
		// 首尾index處理
		let iLeft = i===0 ? (arr.length-1) : (i-1);
		let iRight = i===(arr.length-1) ? 0 : (i+1);
		arrResult[i] -= num;
		arrResult[iLeft] += numLeft;
		arrResult[iRight] += numRight;
	}
	// 縮小至原尺度
	return arrResult.map((item) => {
		return (item / Math.pow(10, this.multiple + precision));
	});
}
複製程式碼

測試效果如下:

原創佛系紅包演算法,瞭解一下?

整體結果測試

使用Echarts繪製隨機分配結果,將100數額劃分為10份,精度為1,橫座標為順序位置,縱座標為分配到的數額:

原創佛系紅包演算法,瞭解一下?

那每個位置獲得數額的概率是否相等呢?下圖是隨機分配100次的結果,並將每個位置的在這100次分配中所得的平均數用紅色標出:

原創佛系紅包演算法,瞭解一下?

那分配1000次呢?

原創佛系紅包演算法,瞭解一下?

由此可見,隨機分配次數越多,每個順序位置得到的平均數額會穩定在平均分配的數額左右,公平性得到了印證;同時,因為每個位置只能得到相鄰兩個位置的數額交換,所以分配結果中任意位置的數額不會超過平均數額的3倍(即自己一毛不拔,同時又得到相鄰者的傾力相助),這樣便可以控制隨機分配結果中的最高金額不至於過高。

腦洞來了,要是並不是左右相鄰進行交換呢?改變交換規則會怎樣?

  • 和除自己外的隨機位置的兩位進行隨機數額交換?從概率上講,和之前等價...
  • 只和自己左右或者右邊的位置進行隨機數額交換?分配結果依然公平,但最高數額不會超過平均數額的2倍
  • 每個位置隨機左右一邊然後進行隨機數額交換?又雙叒隨機,還是公平的,最高數額還是少於平均數額的3倍(感覺貌似可以替代之前的方案,還能順便降一倍的線性複雜度,我文章要重寫了?! (°ー°〃))
  • 誰說只能挑2個進行交換?3個4個5個一起來行不行?行... 挑選位置公平的話,分配結果就公平,但是最大數額與交換數量正相關,但越高的數額,能得到的概率會急劇減小。

打住打住,再細想下去,我的坑怕是要填不完了 _(:з」∠)_

那這個東西除了可以分紅包還能幹嘛?

我用它寫過一個沒有後端資料的進度條,一抖一抖增加長短不一還彷彿真的在幫你載入一樣...

其他...望君發揮想象力

最後 ( ˙-˙ )

原創佛系紅包演算法,瞭解一下?

初入掘金,不足之處望海涵,轉載煩請註明出處

相關文章