模擬微信發紅包,n我的搶總金額爲m的紅包,請設計一個算法並實現面試
這個題目是我在上週二面試字節跳動時候遇到的,當時寫出來的仍是一個暴力版本,回來以後就和朋友交流了一下,不少人也遇到過了,因此這個題目算是字節跳動研發/測試/測開系列常常出的題目,還挺有意思的,記錄分享一下 初步想法-暴力版本 說實話,剛開始看到這個題目的時候,個人想法是這樣的:算法
每次在(0, m)這個區間內隨機一個值,記爲r; 計算一下剩餘金額m-r,剩餘金額m-r必須大於(n-1)*0.01,否則後面的n-1我的沒法完成分配; 按照順序隨機n-1次,最後剩下的金額能夠直接當作最後一個紅包,不須要隨機;微信
嗯,聽上去不錯,而後動手實現了一下這個解法版本: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) while n > 1: # 這裏須要注意兩個細節: # - random.uniform(a, b)的隨機區間是≥a&≤b,即[a, b] # - random.uniform(a, b)隨機出來的值是0.0012032010230123,保留兩位小數以後是有可能出現等於0.00的狀況 alloc_result = round(random.uniform(0.01, m-0.01), 2) # (m - alloc_result) < (n * 0.01)的判斷是爲了保證這一次的隨機以後,後續的總金額能夠繼續分配,不然將從新隨機指導知足這個條件 if (m - alloc_result) < (n * 0.01) or alloc_result <= 0.00: continue result.append(alloc_result) n = n - 1 m = m - alloc_result數據結構
result.append(round(m, 2)) return result
複製代碼 看上去OK的,接下來我用相對正常的數據自測了一下,相似這樣: for _ in xrange(10): print money_alloc(10, 5) 複製代碼 輸出結果以下: [3.73, 6.15, 0.06, 0.03, 0.03] [4.28, 0.8, 1.09, 2.13, 1.7] [0.66, 2.27, 5.5, 1.5, 0.07] [6.55, 1.46, 0.82, 0.2, 0.97] [5.48, 0.47, 0.65, 0.48, 2.92] [6.4, 3.09, 0.29, 0.01, 0.21] [9.94, 0.02, 0.01, 0.01, 0.02] [4.98, 4.97, 0.01, 0.01, 0.03] [8.17, 1.3, 0.18, 0.17, 0.18] [3.49, 5.45, 0.36, 0.3, 0.4] 複製代碼 從這個隨機結果裏面,咱們發現了這個解法的一個特色,就是紅包金額愈來愈小,等於說:誰先搶,誰能搶到的紅包金額就越大。 接着,咱們用相對極限的狀況(好比1塊錢 ,100我的分)再次測試的時候,悲劇發生了,程序陷入了深深的隨機當中沒法自拔,究其緣由在於越日後,金額的可用區間就越小,隨機的壓力就越大。 總結一下這個暴力解法:app
大衆思路,適合錢多、人少的場景,在錢少、人多的狀況下會陷入隨機死循環; 公平性太差,先搶的優點過大,顯然不符合當前微信紅包的這種公平性;dom
暴力版本二 既然錢少、人多的狀況下會陷入隨機死循環,那麼是否是就無解了呢,固然不是 def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) # 加入隨機次數統計 random_count = 0 while n > 1: alloc_result = round(random.uniform(0.01, m-0.01), 2) if (m - alloc_result) < (n * 0.01) or alloc_result <= 0.00: random_count += 1 # 隨機10次尚未結果,直接給丫來一個0.01,行不行? if random_count > 10: alloc_result = 0.01 random_count = 0 result.append(alloc_result) n = n - 1 m = m - alloc_result continue result.append(alloc_result) n = n - 1 m = m - alloc_result result.append(round(m, 2)) return result 複製代碼 這裏暴力版本二里面,主要加入了一個隨機次數統計值random_count,來避免隨機陷入「死循環」,代碼邏輯比較簡單,就不贅述了 接着咱們再次對這個算法進行測試,以下: for _ in xrange(10): print money_alloc(1, random.randint(10, 99)) 複製代碼 測試結果以下: [0.03, 0.13, 0.16, 0.01, 0.1, 0.2, 0.06, 0.02, 0.01, 0.01, 0.01, 0.01, 0.02, 0.01, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.03]性能
[0.79, 0.02, 0.03, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.03]學習
[0.01, 0.08, 0.01, 0.01, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.03] 複製代碼 OK,感受還湊合,暴力版本二雖然解決了暴力版本一當中的死循環問題,可是公平性問題仍是沒有被解決。 接下來介紹一下另外的兩種解法:二倍均值法和線段切割法,這兩種方法借鑑了小灰的算法思路。 二倍均值法 在暴力版本中,不公平的問題主要體如今前面的區間太大,後面可用的隨機區間過小,致使了紅包金額嚴重失衡的問題,因此二倍均值法的核心在於穩定隨機的區間。 先介紹一下二倍均值的思路:測試
計算一個平均值,好比10塊錢,10我的,人都可得1塊錢; 第一次隨機時,將隨機區間定義在(0, 2)之間,隨機獲得一個值r1,即第一個紅包; 接着進行第二次隨機,計算剩餘金額10-r1,計算剩餘人均(10-r1)/9,而後在[0, 人均 * 2]中隨機出第二個紅包; 以此類推,完成紅包分配的過程;優化
我當初看到這個思路的時候,有這樣的一個疑問:
爲何要將均值*2呢,直接在(0, 均值)這個區間進行隨機不行嗎?好比說10人分10塊錢,第一次爲何不直接在(0, 1)這個區間而要再(0, 2)這個區間呢?
關於隨機的問題,有點超綱了,總結來講就是在(a, b)這區間內的隨機,那麼隨機出來的值在(a+b)/2附近的機率更大 按照這個思路繼續分析下去,對於上面這個實例來講,基本上可讓每一個人搶到的紅包都在1塊錢左右 咱們用Python實現一下看看: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) while n > 1: avg_money = round(m / n * 2, 2) - 0.01 alloc_result = round(random.uniform(0.01, avg_money), 2) result.append(alloc_result) n = n - 1 m = m - alloc_result result.append(round(m, 2)) return result 複製代碼 接着用正常測試用例,測試一下:
10塊錢5我的分
for _ in xrange(10): print money_alloc(10, 5)
分配結果, 隨機結果在2附近的值一眼看下去仍是居多的
[1.83, 0.78, 0.28, 2.74, 4.37] [1.17, 4.13, 0.54, 0.66, 3.5] [1.37, 1.67, 1.3, 5.57, 0.09] [3.49, 2.5, 1.22, 0.75, 2.04] [2.1, 3.2, 0.5, 3.19, 1.01] [2.83, 2.01, 2.12, 1.2, 1.84] [2.97, 0.79, 1.45, 1.52, 3.27] [2.77, 1.64, 1.53, 0.41, 3.65] [3.49, 0.88, 0.39, 3.26, 1.98] [1.79, 3.61, 2.55, 1.21, 0.84] 複製代碼 接着用極限測試用例,測試一下:
1塊錢50我的分,分配結果(數據太多,隨機取了兩個)
[0.01, 0.03, 0.01, 0.01, 0.01, 0.02, 0.03, 0.02, 0.03, 0.03, 0.01, 0.01, 0.01, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.02, 0.02, 0.02, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.03, 0.02, 0.01, 0.02, 0.03, 0.02, 0.01, 0.01, 0.02, 0.04, 0.03, 0.04, 0.01, 0.02] [0.03, 0.03, 0.02, 0.02, 0.03, 0.03, 0.03, 0.02, 0.02, 0.01, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.01, 0.02, 0.03, 0.02, 0.01, 0.03, 0.02, 0.02, 0.03, 0.01, 0.02, 0.03, 0.02, 0.02, 0.01, 0.01, 0.03, 0.02, 0.01, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.02, 0.01, 0.02, 0.01, 0.01, 0.02] 複製代碼 二倍均值法很好的解決了暴力版本當中的公平性問題,讓每一個人可以搶到的紅包差距不會太大 總結一下二倍均值法:
解決了暴力版本中的公平性問題,但實際的微信紅包在分配結果上並非均等的,具體你們應該都有體會
線段切割法 爲了讓最終的分配結果體現出差別性,更貼近實際使用中的微信搶紅包過程,能夠考慮線段切割法。 線段切割法的思路大體以下: 一、將紅包的分配過程想象成線段切割,紅包的總金額爲線段的總長度; 二、在線段上標記處N-1個不重複的點,線段就被切割成了N分長度(金額)不一樣的小線段; 三、標記方法:每次都在(0, m)這個區間隨機出一個值,即爲標記點; 四、最後計算相鄰標記點之間的差距(子線段長度)即爲紅包金額; 話很少說,直接上Python實現: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money") # 0爲線段的起點 result = [0] m = round(m, 2) while n > 1: alloc_result = round(random.uniform(0.01, m - 0.01), 2) if alloc_result in result: continue result.append(alloc_result) n = n - 1 # m爲線段的終點 result.append(m) result.sort() return [round(result[index+1]- item, 2) for index, item in enumerate(result) if index < len(result) - 1] 複製代碼 測試一下: [1.07, 6.08, 2.85]
[0.04, 0.11, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.07, 0.02, 0.02, 0.05, 0.12, 0.02, 0.01, 0.01, 0.01, 0.13, 0.02, 0.01, 0.05, 0.03, 0.02, 0.07, 0.01, 0.02, 0.02, 0.01, 0.03, 0.01] 複製代碼 OK,到這裏彷佛全部的問題都已經完美解決了,這個解法看起來好完美。 But...事實真的是這樣嗎? 如今,咱們拋開實際的場景,迴歸到這個算法自己,不妨測試一下1萬塊3萬人分,測試代碼以下: for _ in xrange(5): a = time.time() money_alloc(10000, 30000) b = time.time() print b - a 複製代碼 測試結果大概以下: 7.04587507248 7.84848403931 7.50485801697 7.98592209816 8.28649902344 複製代碼 在個人電腦上,大概須要耗時七、8秒的樣子,這... 不慌不慌,咱們先分析一下代碼可能的問題:
隨機3W+次,這個過程本省確實耗時,但感受也沒有什麼改善空間了; alloc_result in result在result很大的時候,查找效率過低,很是耗時,這個必須改掉; result.append(alloc_result)若是是有序插入,那麼後續的list.sort就不必了; 另外,list.sort()耗時麼?
什麼數據結構的查找效率最高呢?固然是hashmap,也就是dict啦,而且在測試過程當中發現list.sort()耗時基本在10ms之內,優化空間不大,因此沒有考慮有序插入。 線段切割法-優化版本 最終優化以後的代碼以下: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money") result = [0] # 犧牲一部分空間,提高查重的效率 tmp_dict = dict() m = round(m, 2) while n > 1: alloc_result = round(random.uniform(0.01, m - 0.01), 2) # hash 版本 if alloc_result in tmp_dict: continue tmp_dict[alloc_result] = None result.append(alloc_result) n = n - 1
result.append(m) result.sort() return [round(result[index+1]- item, 2) for index, item in enumerate(result) if index < len(result) - 1]
複製代碼 優化以後,咱們用剛纔的測試代碼再次測試一下看看效果: 0.197105169296 0.169443130493 0.162744998932 0.167745113373 0.147526979446 複製代碼 7秒到200ms的效率提高,不要太直觀 至於空間複雜度,留給小夥伴本本身研究吧,但願有更好的版本能夠學習一下 問題總結
搶紅包算法的三種解法:暴力分配、二倍均值、線段切割; 暴力分配僅僅只能適用於這個問題自己,實際過程當中沒有任何應用價值; 二倍均值解決了暴力分配的問題,但缺乏了大紅包的驚喜; 在實際的微信紅包分配過程當中,線段切割優化和不優化實際上差別應該不至於太大,可是追求性能,優化版本仍是有更多的改進空間;
附錄:關於random.uniform uniform通常用於生成浮點數,關於random.uniform的功能描述: | uniform(self, a, b) | Get a random number in the range [a, b) or [a, b] depending on rounding. 複製代碼 如今看來這個描述並不許確 實際運用時,我一直認爲這個隨機區間是[a, b],實則否則
random.uniform(0, 1) 0.15407896775722285 random.uniform(0, 1) 0.16189270320003113 random.uniform(0, 0) # a == b 0.0 random.uniform(0, -1) # a > b -0.8838459569306347 random.uniform(0, -100) # a > b -76.93918157758513 複製代碼 因此其實a>b的時候也是能夠進行浮點數隨機的,隨機區間並非絕對意義的最小值是a,最大值是b 你, 學到了嗎?