三年前微信紅包爆火的時候,腦補了下背後的分配原理,並用C寫了個demo,如今回想覺得當時的解法有一定的趣味性,遂豐富完整了下,用js重寫了一遍。
紅包演算法需滿足的規則如下:
- 所有人搶到金額之和等於紅包金額,不能超過,也不能少於;
- 所有人搶到金額的機率相等;
- 每個人搶到的金額均大於0。
我腦補的第一畫面就是:排排坐,分果果
。
於是分配原理如下:
眾人們先按搶紅包的順序依次入座,圍成圓環,將金額均分到每個人,然後每人同時將自己手中的金額隨機抽出部分給左右臨近的2個人,但保證手頭至少剩餘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,所以均分也需要均分為整數,例如總數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));
});
}
}
複製程式碼
測試效果如下:
- 相鄰隨機交換
得到均分數額後,每個位置先隨機出將要給出的數額,該數額大於等於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個一起來行不行?
行... 挑選位置公平的話,分配結果就公平,但是最大數額與交換數量正相關,但越高的數額,能得到的概率會急劇減小。
打住打住,再細想下去,我的坑怕是要填不完了 _(:з」∠)_
那這個東西除了可以分紅包還能幹嘛?
我用它寫過一個沒有後端資料的進度條,一抖一抖增加長短不一還彷彿真的在幫你載入一樣...
其他...望君發揮想象力
最後 ( ˙-˙ )
初入掘金,不足之處望海涵,轉載煩請註明出處