本文希望讀者玩過云頂之弈,不懂編程的可以直接拉到最下面去看結論,懂編程的希望你了解遞歸、分治、圖、堆這些基本概念,并掌握Python或者Go語言。
代碼已公開在github上:https://github.com/weiziyoung/TFT ,轉載請注明來源。
今天是11月11日,首先恭喜FPX一頓摧枯拉朽橫掃G2, 拿下S賽冠軍!證明了LPL是世界第一賽區,也讓電競作為一種賽事在這代人心中銘記。本屆S賽結束,也就意味著,S8告一段落,S9即將上線。而云頂之弈作為今年剛出的新模式,在上周11月6日也發布了S2元素崛起版本,一時間各種打法也是層出不窮,小編我也是一名忠實的云頂之弈玩家,但目前還沒有玩過S2版本,主要想把這篇文章先寫好分享給想讀的人。
其實早在今年暑假剛出這個新模式,大家都還不會玩,還在摸索各種陣容的時候,我就在思考一件事——如何通過編程的手段搜索到6人口、7人口、8人口甚至9人口時湊到的最多的羈絆?這種想法來源于一個慘痛的經歷,就是我第一次玩的時候,大概只湊出來了一個3貴族2騎士羈絆,就草草第七名帶走了...當時就覺得這個游戲太難了,這么多卡片怎么能全記住?除了英雄之外,還有裝備合成也跟英雄聯盟差的很遠,但玩個兩三局,大概就明白:
這個游戲想吃雞有三個核心——羈絆、英雄等級、裝備, 三個核心有兩個占優勢,基本可以達到前四,三個都占優勢,就穩定吃雞了。這里我們主要討論的就是去搜索羈絆,從而在這個維度上不吃虧。而裝備這塊比較靠臉,所以不做討論,英雄等級這塊其實可以根據每張卡在每個階段出現的概率來估算出來這個陣容成型的難易程度,但是在本片博客里不做討論,這里只討論一個問題,就是羈絆。
文章大綱
- 云頂之弈游戲簡介
- 基本算法思路
- 準備實驗數據
- 排列組合的原理和實現
- 用圖降低搜索復雜度
- 評估函數的設計和實現
- 最小堆維護Top 100陣容
- 結果展示
- 分析與總結
云頂之弈游戲簡介
一般讀到這里的讀者應該都玩過云頂之弈,但為了照顧有些只打匹配排位從不下棋的同學,這里還是簡單介紹一下這個游戲機制。
- 方框
1
所在的是小小英雄,就是召喚師啦,好看用的。 - 方框
2
是你目前的隊伍,這個隊伍可以由不同英雄組成,但是隊伍規模取決于你等級的高低。 - 方框
3
是你候選區的英雄,放一些你暫時不想上場的英雄,當然這個區域大多數是用來合成英雄的,3個相同1星英雄可以合成成2星,3個相同2星英雄可以合成為3星。當然我們這里不討論如何優化英雄等級的話題。 - 方框
4
是發牌員給你發的牌,還有你目前有多少錢,每回合發牌員會給你發5張牌,你需要用金幣去購買,這里只需要記住一點,星級越高的英雄越難抽到,并且也越強。 - 方框
5
就是我們的核心——羈絆了,它是根據場上的英雄的種族和職業所確定的,比如目前場上小炮和男槍可以組成一個槍手的buff,這個Buff可以使得槍手在攻擊時造成2個目標傷害,而劫的話自己是個忍者,所以可以組成一個忍者buff,它可以提升自己暴擊幾率和攻擊速度。每個羈絆都有自己的效果,同時,羈絆也有自己的等級,比如當你只有2個槍手的時候,你的槍手能夠同時造成2個敵人的傷害,而4個槍手的時候,你可以再攻擊時同時造成3個目標的傷害;同時羈絆也有范圍,有的羈絆只對單個人有效,比如雙帝國、三貴族、單忍者,大多數羈絆對同種族的有效,比如狂野、槍手、劍士,少數羈絆對隊伍里所有英雄都有效,比如騎士、法師。
具體的S1版本英雄羈絆圖如下(有一些后期英雄沒加上去,比如潘森、卡薩、海克斯):
總共是56只英雄,大多英雄擁有一個種族,一個職業,船長的職業既是劍士也是槍手,納爾的種族既是約德爾人也是狂野。一般來說,這個游戲在七人口時陣容成型,這個階段基本能看出誰勝誰負,所以我們的目的就是選7個英雄,組成羈絆上最強的陣容。
基本算法思路
就像之前所說的,我們的目的是在56個英雄里選n個英雄,然后從里面選出羈絆最強的前K個。這句話可以拆分為這三個問題:
- 首先,如何讓計算機去自動把所有組合的可能性一個不拉地遍歷出來?不重復也不漏檢?
- 其次,給定一個陣容,如何去評判羈絆的強度?
- 第三,怎么去保存前K個羈絆最強的結果?
對于第一個問題來說,很多編程語言都有combination的拓展庫,方便程序員求出一個列表的元素所有的組合可能性。但是這個是個好的方案嘛?真的可行嘛?如果不可行,怎么去優化?
對于第二個問題來說,我們在評估一個東西,或者說量化一個東西的時候,應該采用哪些指標?羈絆多是不是意味著羈絆就強?如果不是的話,是否需要引入主觀性的一些指標,比如單個羈絆對英雄的增益程度?另外這個羈絆好成型嘛?是不是容易在組建的半路上暴斃?這些都是需要注意的問題。
對于第三個問題來說,看起來很容易,但排序真的可行嗎?由于我們搜索的結果多達幾百萬個的陣容組合,全部排序后再取前K個現實嘛?
準備實驗數據
本次主要使用語言為Go,并且用Python做一些腳本輔助我們做一些分析,之所以采用Go來寫核心代碼,是因為這種上百萬輪次的搜索,Go往往比Python能快出一個數量級,同時Go工程化之類的也做的更好一些,語法也不至于像C++和Java那樣繁瑣。
程序 = 算法 + 數據。數據是一切的基石,要實現我們這次的目標,我們至少需要擁有兩個數據:英雄數據、羈絆數據。在國外英雄聯盟官網上,我們可以找到這個頁面:TFT GAMEPLAY GUIDE,接下來只要用Python 的BeautifulSoup包吧頁面解析出來就可以了,大概20行代碼就可以搞定了,由于思路比較簡單,這里就不放代碼了,給個鏈接自己看:python_scripts/scrape.py。
如下所示,這里我們需要記錄英雄的元數據包括:名字、頭像、費用、種族和職業,總共56個英雄,這里不展示了。需要的自己去取:data/champions.json
{
"name": "Varus",
"avatar": "https://am-a.akamaihd.net/image?f=https://news-a.akamaihd.net/public/images/articles/2019/june/tftcompendium/Champions/Varus.png&resize=64:",
"price": 2,
"origin": ["demon"],
"class": ["ranger"]
},
另外是羈絆數據,這個數據可以從英雄數據里面整理出來,同時也要我們自己手填一些數據,以惡魔為例:
{
"name": "demon",
"bonus_num": [2,4,6],
"scope": [2,2,2],
"champions": [
"Varus","Elise","Morgana","Evelynn",
"Aatrox","Brand","Swain"]
},
惡魔羈絆需要在2只時觸發,且在4,6時羈絆進階,那bonus_num
就是[2,4,6]
,而惡魔羈絆無論多少級,都是只有同種族的受益,所以范圍序號是2
,具體范圍序號含義我們定義如下
-
1
代表只有一個英雄能吃到這個羈絆buff的效果,典型的比如3貴族、2帝國。 -
2
代表持有該羈絆的能夠吃到這個buff效果,大多數羈絆都屬于這個效果,比如惡魔、冰川、狂野、變形者、刺客、槍手、劍士、4帝國等等。 -
3
代表隊伍全部都可以吃到這個buff,比如6貴族、騎士、法師這些。 -
4
代表一個特殊的羈絆范圍,就是護衛了,護衛是除了護衛本身,其周圍的人都能吃到buff。
champions
就是持有這個羈絆的所有英雄了,全部羈絆數據在這里:data/traits.json。這就是我們現在所能拿到的所有客觀數據,不摻雜任何拍腦袋給的主觀權重。實際上在評估時,這種數據越多越好,主觀性太強的指標例如英雄強度、羈絆強度這種,公說公有理,婆說婆有理,很難有客觀的結論,盡量少引入到評價體系中。
排列組合的原理和實現
現在我們有所有英雄了,作為召喚師,我覺得很有必要把它們一字排開欣賞欣賞...畢竟S2就看不到他們的絕大多數了。
所以我們的任務就是從55個英雄里面挑出8個英雄,讓他們的羈絆數量最多。所以這是一個排列組合里的組合問題,可以根據公式求出組合數量:
其中
n
等于55,m
等于8,也就是八人口時,需要搜索231917400
個不重復的可能性。
如何實現組合呢
最經典的思路就是分治了,看個簡單的問題,比如對[a,b,c,d,e]
求個數為3的所有組合。那么,我們首先會先把a
取出來,問題簡化成了對[b,c,d,e]
求個數為2的所有組合。其次,我們把b
取出來,問題簡化成了對[c,d,e]
求個數為1的所有組合,這時候問題就簡單了.示意圖如下:
紅框表示你現在已經選擇的字母,紅框下面的數字代表需要繼續進行組合的元素,到三層結束。
Python實現代碼,非常短小精干,需要仔細品味和研讀,理解遞歸、分治的優雅:
def combine(data, step, selected_data, target_num):
if len(selected_data) == target_num: # 遞歸的結束條件:已選擇的元素數量等于目標數量
print(selected_data)
return
if step == len(data): # 游標到頭了還沒找出來,就結束吧
return
selected_data.append(data[step]) # 選擇當前元素把她加到已選擇的隊伍里
combine(data, step + 1, selected_data, target_num) # 將游標推進,進入遞歸去找下一層
selected_data.pop() # 把選擇過的元素踢出去
combine(data, step + 1, selected_data, target_num) #在不選擇剛才被踢出去的元素情況下繼續遞歸
if __name__ == '__main__':
data = ['a','b','c','d', 'e']
combine(data, 0, [], 3)
理解了上面這個代碼,換個變量名,加入evaluate函數,就可以用于搜索我們的全羈絆了。
def combine(champions, step ,combo, max_num):
if len(combo) == max_num: # 如果隊伍到了最大的人口,就進行評估
evaluate(combo)
return
if step == len(combo):
return
combo.append(champions[step]) # 把游標所指定的英雄加到隊伍里面去
combine(champions, step + 1, combo, max_num) # 游標往前進,繼續抓壯丁
combo.pop() # 把剛才指定的英雄踢出去
combine(champions, step+1, combo, max_num) # 再繼續往前進抓壯丁
def evaluate(combo):
# 這里寫給定一個陣容,怎么去評估它的強度,應該返回一個數值,或者是多個維度的評分結構體。
# 往后再議
pass
def init_champions():
# 這里從json里讀數據,代碼略
pass
if __name__ == "__main__":
champions = init_champions() # 把英雄數據導入進去,每個英雄應該是個結構體,或者是個字典。
combine(champions, 0, [], 7)
跑了一下,自行感受一下Python??蝸牛一般的速度吧:
平均每秒遍歷
36979
個結點,搜索6人口的最優羈絆竟然要花14分鐘,作為一個堆效率有追求的程序員,怎么能夠容忍這種事情出現??我只想對這個結果說:所以接下來就沒有Python代碼了,同樣的算法用Go跑的話,速度是每秒大約20w個結點, 大概是Python的7倍左右,如果用C++來寫會更快,但如果讓我用C++來寫可能要明年你們才能看到我這篇文章了,所以程序員要在開發效率和運行速度中取得一個平衡:
用圖降低搜索復雜度
窮舉法的弊端
由之前的公式:
我們可以算出,八人口需要搜索231917400
個結點,用Python搜索大概需要1.7個小時左右,用Golang搜索大概需要20分鐘,速度還是很不夠看,從語言上已經優化不了了,那就從算法上進行優化。
結合這個游戲,仔細思考一下我們是否真的需要對56個英雄都組合一遍呢?這么看不夠直觀,我舉個非常簡單的栗子
給定圖上的五只英雄:蜘蛛、蓋倫、浪人、維魯斯、豹女、寒冰,選出三個英雄,目標是讓他們組成的羈絆數量最大,用大腦去看,那結果一定是“蜘蛛、維魯斯、寒冰”,但是,我們模擬之前窮舉法的過程,首先選出蜘蛛,其次選擇第二位的蓋倫,如果真的有人會在拿到蜘蛛的情況下去第二位去選擇蓋倫湊羈絆,大概會讓人覺得:
基于羈絆的思路
正常的人拿完蜘蛛,下一步一定是拿維魯斯或者豹女,拿維魯斯因為剛好可以湊一個惡魔,維魯斯又是一個比較強的打工二費卡,何樂而不為?拿豹女是因為后面可能可以湊3換型,能湊出3換型,前期坦度是妥妥的,所以我們在拿到蜘蛛的情況下,不可能去考慮下一步拿蓋倫和狼人,在下一步拿到維魯斯的情況下,去考慮豹女和寒冰,(思考一下為什么要考慮豹女?),這樣我們就達到了最多羈絆:雙惡魔加雙寒冰。綜上,我們簡化搜索的主要邏輯就是每次只選擇與他能產生羈絆的對象,基于這個想法,我們的搜索就變成:
而圖就是用來描述每個對象之間關系的一種數據結構,在這里,圖用來描述英雄之間的羈絆關系,而圖的表示方法有兩種:鄰接矩陣法和鄰接表法,兩者的取舍取決于圖的稀疏程度。將上面官方給的羈絆-英雄圖轉個方式就得到了英雄-羈絆鄰接矩陣圖(57*57的矩陣,有相同羈絆則輸出1, 沒有則輸出0)由圖中可以看出,該矩陣為稀疏矩陣,所以我們后面用鄰接表法來表示該矩陣):
另外,所有的英雄都和機器人、浪人、忍者有羈絆,因為隊伍里只要添上它們任何中的一個,都可以為羈絆數+1,符合我們的優化預期。亞索在這里不是孤兒了。
那么怎么利用這個信息去優化我們的算法呢?這需要進一步地去理解“組合”搜索究竟做了什么?是否可以用圖的方式來進行組合搜索?答案是肯定的,以剛才組合a,b,c,d,e
選出3個進行組合為例,換個思路來想這個事,實際上他們彼此之間也可以用有向圖來表示:
所以之前那個組合示意圖,也可以這么理解:
綜上所述,對于組合而言,我們只要把每個結點指向起后面的所有結點,然后用普通的圖搜索,就可以得到組合的結果。
而利用羈絆圖,我們可以不用把每個結點指向后面的所有結點,相反,我們只要把每個結點指向后面所有能跟當前組合產生羈絆的結點就可以了,注意!不能只考慮和當前結點產生羈絆,而要考慮隊伍里所有英雄所擁有的所有結點,否則會漏搜索!我們優化的初衷是,保證搜索結果不變的情況下,減少不必要的搜索,而不能漏搜索。
因此核心搜索代碼如下:
type Graph map[int][]int
// GenerateGraph 生成羈絆圖
func GenerateGraph(championList models.ChampionList) Graph{
graph := make(Graph)
positionMap := make(map[string]int)
for index, champion := range championList {
positionMap[champion.Name] = index
}
for no, champion := range championList {
// children 排序
children := make([]int, 0, 30)
// 加入相同職業的英雄
classes := champion.Class
for _, class := range classes {
sameClassChampions := globals.TraitDict[class].Champions
for _, champion := range sameClassChampions {
index := positionMap[champion]
if index > no{
children = append(children, index)
}
}
}
// 加入相同種族的英雄
origins := champion.Origin
for _, origin := range origins {
sameOriginChampions := globals.TraitDict[origin].Champions
for _, champion := range sameOriginChampions {
index := positionMap[champion]
if index > no {
children = append(children, index)
}
}
}
// 加入1羈絆的英雄
for _, championName := range globals.OneTraitChampionNameList {
index := positionMap[championName]
if index > no {
children = append(children, index)
}
}
// 對index從小到大排序
sort.Ints(children)
children = utils.Deduplicate(children)
graph[no] = children
}
return graph
}
// Traverse 圖遍歷,
// championList, 英雄列表,固定不變。 graph 羈絆圖,也是固定不變。node 為當前的結點, selected 為已選擇的英雄, oldChildren是父節點的children
func Traverse(championList models.ChampionList, graph Graph, node int, selected []int, oldChildren []int) {
selected = append(selected, node)
if len(selected) == lim {
combo := make(models.ChampionList, lim)
for index, no := range selected {
unit := championList[no]
combo[index] = unit
}
metric := evaluate.Evaluate(combo)
heap.Push(&Result, metric)
// 超過最大就pop
if len(Result) == globals.Global.MaximumHeap {
heap.Remove(&Result, 0)
}
return
}
newChildren := graph[node]
children := append(oldChildren, newChildren...)
sort.Ints(children)
children = utils.DeduplicateAndFilter(children, node)
copyChildren := make([]int, len(children), 50)
copy(copyChildren, children)
for _, child := range children {
copySelected := make([]int, len(selected), lim)
copy(copySelected, selected)
Traverse(championList, graph, child, copySelected, copyChildren)
}
}
// TraitBasedGraphSearch 基于羈絆圖的圖搜索
func TraitBasedGraphSearch(championList models.ChampionList, teamSize int) models.ComboMetricHeap {
graph := GenerateGraph(championList)
lim = teamSize
heap.Init(&Result)
startPoints := getSlice(0, len(championList)-teamSize + 1)
for _,startNode := range startPoints{
Traverse(championList, graph, startNode, make([]int, 0, teamSize), make([]int, 0, 57))
}
return Result
}
用這種方法所產生的有向圖如下圖所示(這里順手安利一個網絡圖可視化的js庫antv-G6),大幅度簡化了初始的搜索圖(自行想象一下所有結點連接所有后續結點密密麻麻的效果圖)。
實際上,我認為這種啟發式搜索,有點A star搜索的意思在里面,核心思想就是講后續children進行排序,將預期離目標結果近的放在前面。這里做的極端了一些,我們把沒有產生羈絆的后續結點全部咔嚓了,但實際上這并不會造成漏檢(讀者可以自己實驗一下)
最后,比較一下基于羈絆圖的結點搜索數量和不基于羈絆圖的結點搜索數量,橫坐標是人口,縱坐標是結點數量,注意一下縱坐標的跨度,是指數級別的。
所以到這里,這篇博客的核心部分就講完了,基本思想就是利用現有的知識(英雄之間產生的羈絆)來大幅度簡化搜索。
評估函數的設計與實現
之前我們一直都沒有實現評估函數,其實這個評估函數的設計是非常靈活的,也是玩家可以加入自己玩游戲的經驗的一部分。這里我們用4個指標來描述陣容強度:
type ComboMetric struct {
// 英雄組合
Combo []string `json:"combo"`
// 隊伍總羈絆數量 = sigma{羈絆} * 羈絆等級
TraitNum int `json:"trait_num"`
// 具體羈絆
TraitDetail map[string]int `json:"trait_detail"`
// 總英雄收益羈絆數量 = sigma{羈絆} 羈絆范圍 * 羈絆等級
TotalTraitNum int `json:"total_trait_num"`
// 當前陣容羈絆強度 = sigma{羈絆} 羈絆范圍 * 羈絆強度
TotalTraitStrength float32 `json:"total_trait_strength"`
// 當前陣容強度 = sigma{英雄} 英雄強度 * 羈絆強度
TotalStrength float64 `json:"total_strength"`
}
-
隊伍總羈絆數量: 這個是最好理解的,你可以理解為你左側邊欄有多少個羈絆,也就是這個部分,誰不喜歡亮刷刷的一排羈絆呢?看的就很舒服。注意,像6惡魔這種算3個羈絆,而不能只算1個羈絆,6貴族算2個羈絆。這也是我們最開始的motivation,就是尋找怎么能讓左邊的羈絆燈亮的最多。
image.png 英雄總收益羈絆數量: 這個也是好理解的,燈亮的多并不代表強,我的經驗告訴我,往往吃雞的陣容,燈亮的往往并不多,有時候甚至就三四個,因此需要引入其他衡量標準。因為不同羈絆有不同的收益范圍,所以這個指標就是計算的就是每個羈絆羈絆收益范圍乘以它等級的總和。6貴族羈絆之所以挺強,強的不在于它單個屬性有多強,而在于它產生了單個buff到群體buff的一個質變,騎士Buff好用也是這個道理,為什么大家都喜歡用騎士過渡,甚至到后面主流吃雞陣容就包括騎士+槍呢?本質上就是因為騎士能夠提供全隊伍的收益,而不是只針對本種族的收益。
當前陣容羈絆強度: 這個指標開始就加入人為指標了,也就是其輸出取決于玩家對羈絆的理解,這個指標引入了羈絆強度這個概念,這個參數是指:當英雄擁有該羈絆時,能夠比不擁有羈絆時強多少倍,比如在這里我設置貴族buff可以讓英雄強1.8倍,雙惡魔buff能讓英雄強1.3倍,龍buff能夠直接增強2倍...具體可以看我data/traits.json文件。
當前陣容整體強度: 這個跟上一版差別就在于考慮了英雄的等級,比如同樣是雙騎士,你拿個蓋倫加諾手,肯定比不上你拿個波比加豬妹。這里為了簡化情景,所以設定,2星英雄比1星英雄強1.25倍,3星又比2星強1.25倍...以此類推,最后5星英雄大約比1星英雄強2.5倍,如果你覺得這個數值低了,可以自己在配置文件里面調整。
最后我們的evaluate評估函數如下,注意一個問題,就是忍者buff的奇異設定,游戲規定,忍者Buff只在1和4的時觸發,在2,3時會熄滅,這不同于其他任何一個羈絆規則,所以要拎出來單獨處理一下:
// Evaluate 評估當前組合的羈絆數量、單位收益羈絆總數、羈絆強度
func Evaluate(combo []models.ChampionDict) models.ComboMetric {
var traitDetail = make(map[string]int)
comboName := make([]string, 0, len(combo))
traitNum := 0
totalTraitNum := 0
totalTraitStrength := float32(0.0)
// 初始化英雄強度向量
unitsStrength := make([]float64, len(combo), len(combo))
traitChampionsDict := make(map[string][]int)
for index, unit := range combo {
comboName = append(comboName, unit.Name)
unitStrength := math.Pow(globals.Global.GainLevel, float64(unit.Price-1))
unitsStrength[index] = unitStrength
for _, origin := range unit.Origin {
traitChampionsDict[origin] = append(traitChampionsDict[origin], index)
}
for _, class := range unit.Class {
traitChampionsDict[class] = append(traitChampionsDict[class], index)
}
}
for trait, champions := range traitChampionsDict {
num := len(champions)
bonusRequirement := globals.TraitDict[trait].BonusNum
var bonusLevel = len(bonusRequirement)
for index, requirement := range bonusRequirement {
if requirement > num {
bonusLevel = index
break
}
}
// 忍者只有在1只和4只時觸發,其他不觸發
if trait == "ninja" && 1 < num && num < 4 {
bonusLevel = 0
}
if bonusLevel > 0 {
traitDetail[trait] = bonusRequirement[bonusLevel-1]
bonusScope := globals.TraitDict[trait].Scope[bonusLevel-1]
traitNum += bonusLevel
bonusStrength := globals.TraitDict[trait].Strength[bonusLevel-1]
benefitedNum := 0
switch bonusScope {
case 1:
{
benefitedNum = 1 // 單體Buff,例如 機器人、浪人、三貴族、雙帝國
for _, champion := range champions {
unitsStrength[champion] *= float64(bonusStrength)
}
}
case 2:
{
benefitedNum = num // 對同一種族的Buff,大多數羈絆都是這種
for _, champion := range champions {
unitsStrength[champion] *= float64(bonusStrength)
}
}
case 3:
{
benefitedNum = len(combo) // 群體Buff,如騎士、六貴族、四帝國
for index, _ := range unitsStrength {
unitsStrength[index] *= float64(bonusStrength)
}
}
case 4:
{
benefitedNum = len(combo) - 2 // 護衛Buff,比較特殊,除護衛本身外,其他均能吃到buff
for index, _ := range unitsStrength {
isGuard := false
for _, champion := range champions {
if index == champion {
isGuard = true
break
}
}
if !isGuard {
unitsStrength[index] *= float64(bonusStrength)
}
}
}
}
totalTraitNum += bonusLevel * benefitedNum
totalTraitStrength += float32(benefitedNum) * bonusStrength
}
}
metric := models.ComboMetric{
Combo: comboName,
TraitNum: traitNum,
TotalTraitNum: totalTraitNum,
TraitDetail: traitDetail,
TotalTraitStrength: totalTraitStrength,
TotalStrength: utils.Sum(unitsStrength),
}
return metric
}
最小堆維護Top 100陣容
之前也提到了,我們每次搜索都是對上千萬乃至上億的葉子結點進行評估,那么如何取出評估結點的前100名呢?我們會想到把結果存起來,然后排序,但這么做可行嘛?
想一下我們十人口進行搜索,總共搜索了25844630個結點,假設每存一個metric需要消耗1kb,那最后把它們全部存下來,大約需要24G,記住這是存在內存里的哦,而不是在硬盤上的噢,正常PC的內存條能有16G很不錯了吧,更何況還要跑個操作系統在上面,所以這個方案一定是不行的,那有什么更好的方案呢?
這就需要聯系我上個月寫的博客,詳解數據結構——堆,這篇博文里我們講到利用堆,我們只需要在內存里開辟堆長度大小的空間即可,比如我們想保留前100個結果,那我們只要開辟100k的內存即可,而每次插入刪除,都是log n
的復雜度,非常快。
而保留前K個結果,需要使用的是最小堆,golang里集成了堆的數據結構,只需要重寫它的一些接口就可以用了,所以我們的ComboMetric完整版實現就是這樣,具體用起來就是每次都push,滿了就把堆頂pop出來即可,最后剩下來的就是前K個結果,把它們最后排個序即可:
package models
type ComboMetric struct {
// 英雄組合
Combo []string `json:"combo"`
// 隊伍總羈絆數量 = sigma{羈絆} * 羈絆等級
TraitNum int `json:"trait_num"`
// 具體羈絆
TraitDetail map[string]int `json:"trait_detail"`
// 總英雄收益羈絆數量 = sigma{羈絆} 羈絆范圍 * 羈絆等級
TotalTraitNum int `json:"total_trait_num"`
// 當前陣容羈絆強度 = sigma{羈絆} 羈絆范圍 * 羈絆強度
TotalTraitStrength float32 `json:"total_trait_strength"`
// 當前陣容強度 = sigma{英雄} 英雄強度 * 羈絆強度
TotalStrength float64 `json:"total_strength"`
}
// 定義一個最小堆,保留前K個羈絆
type ComboMetricHeap []ComboMetric
func (h ComboMetricHeap) Len() int {
return len(h)
}
func (h ComboMetricHeap) Less(i,j int) bool {
return h[i].TotalStrength < h[j].TotalStrength
}
func (h ComboMetricHeap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *ComboMetricHeap) Push(x interface{}) {
*h = append(*h, x.(ComboMetric))
}
func (h *ComboMetricHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
結果展示
這篇博客最最重要的環節要來了,我們需要檢驗,計算機搜索出來的最強陣容,是否和S1版本的吃雞陣容是相符的。全部結果文件在result里,讀者也可以自己把代碼下下來編譯跑一下。
另外因為很多陣容之間的區別僅僅是換了一個相同羈絆的英雄,或者改了一個小羈絆,所以我們這里對搜索結果做了一個很簡單的去重融合,當兩個陣容羈絆相似度過高時進行合并,相似度可以用Jaccard similarity coefficient 來計算集合之間的相似度,如果相似度大于0.7,則認為屬于同一套陣容:
羈絆數最多的陣容
首先我們看不可能有錯的一個指標——羈絆數。直觀來說,就是搜索出讓左邊的羈絆燈亮最多的陣容(這種陣容不一定強)
- 六人口
{
"combo": ["艾希","狗熊","機器人","劫","螳螂","卡薩"],
"trait_num": 7,
"trait_detail": {
"冰川": 2,
"刺客": 3,
"忍者": 1,
"斗士": 2,
"機器人": 1,
"游俠": 2,
"虛空": 2
},
"total_trait_num": 12,
"total_trait_strength": 16.4,
"total_strength": 19.052499984405003
},
總羈絆數達到了7個羈絆,注意這是6人口,正常咱們玩自走棋,6人口大約是4個羈絆數左右,畢竟陣容還沒成型,但是實際上6人口在不用鏟子的情況下最多可以有7個羈絆。
- 七人口
{
"combo": [
"狗熊","豬妹","機器人","慎","船長","卡密爾","金克斯"
],
"trait_num": 7,
"trait_detail": {
"冰川": 2,
"劍士": 3,
"忍者": 1,
"斗士": 2,
"機器人": 1,
"槍手": 2,
"海克斯": 2
},
"total_trait_num": 17,
"total_trait_strength": 22.6,
"total_strength": 21.726248967722068
},
七人口最大羈絆數竟然還是7。不過不同于6人口只有一種組合能達到7羈絆,七人口前100個中基本都達到了7羈絆。
- 八人口
{
"combo": [
"艾希", "狗熊","機器人","劫","螳螂","挖掘機",
"大蟲子","卡薩"
],
"trait_num": 9,
"trait_detail": {
"冰川": 2,
"刺客": 3,
"忍者": 1,
"斗士": 4,
"機器人": 1,
"游俠": 2,
"虛空": 4
},
"total_trait_num": 25,
"total_trait_strength": 24.4,
"total_strength": 27.058749668872913
}
總共是9個羈絆,看著陣容好像是虛空斗刺哈哈哈,但虛空斗刺沒有艾希。這套陣容強度看上去還是可以的。
- 九人口
{
"combo": [
"狗熊","豬妹","機器人","蓋倫","薇恩","天使","劫","螳螂","卡薩"
],
"trait_num": 9,
"trait_detail": {
"冰川": 2,
"刺客": 3,
"忍者": 1,
"斗士": 2,
"機器人": 1,
"游俠": 2,
"虛空": 2,
"貴族": 3,
"騎士": 2
},
"total_trait_num": 22,
"total_trait_strength": 28.55,
"total_strength": 31.621585006726214
}
總之我沒看過亮9棧燈的陣容,看樣子挺花哨的,但這個陣容其實不妥的。羈絆只是吃雞的一小部分,實際上更多的需要依靠英雄等級、裝備、輸出和坦克的組合。
- 十人口
{
"combo": [
"維魯斯","烏鴉","亞索","機器人","諾手","天使",
"阿卡麗","螳螂","挖掘機","卡薩"
],
"trait_num": 10,
"trait_detail": {
"刺客": 3,"帝國": 2,"忍者": 1,"惡魔": 2,
"斗士": 2,"機器人": 1,"浪人": 1,"游俠": 2,
"虛空": 2,"騎士": 2
},
"total_trait_num": 24,
"total_trait_strength": 31,
"total_strength": 37.67076561712962
}
亮了10棧燈,這種陣容基本看看就好,不可能成型并且吃雞的,因為這是個有5個5費卡的陣容。
強度最高的陣容
正如之前說的,羈絆多陣容并不一定強,所以一定要結合英雄等級、羈絆強度、羈絆范圍這些來算,這里英雄等級的增益和羈絆強度都是具有主觀判斷在里面的,而且算上這些指標實際上也是不夠的,看下計算出的陣容就知道了:
- 六人口
{
"combo": [
"潘森","布隆","麗桑卓","狗熊","冰鳥","凱南"
],
"trait_num": 5,
"trait_detail": {
"元素師": 3,
"冰川": 4,
"忍者": 1,
"護衛": 2
},
"total_trait_num": 14,
"total_trait_strength": 15.2,
"total_strength": 30.272461525164545
},
這看上去是一個冰川元素陣容,游戲剛出的時候,這套陣容還是很容易吃雞的,主要就是利用麗桑卓和冰鳥都是冰川+元素,導致這套陣容又有控制又有坦度,在以前誰都不會玩這個游戲的年代很容易吃雞,小編我第一次吃雞用的就是冰川元素流。但冰川元素逐漸沒落了,原因就是后來大家都會玩這個游戲了,導致游戲節奏加快,而這個陣容一個最大的缺點就是成型有點困難,豬妹和冰鳥都不是那么容易抽到的,前期靠布隆一個坦度點是肯定不夠的。
- 七人口
{
"combo": [
"莫甘娜","龍王","潘森","日女","天使","鐵男","死歌"
],
"trait_num": 5,
"trait_detail": {
"幽靈": 2,"護衛": 2,"法師": 3,"騎士": 2,"龍": 2
},
"total_trait_num": 22,
"total_trait_strength": 29.9,
"total_strength": 40.17980836913922
},
這個看上去是護衛龍,但又不太像,因為護衛龍好像沒有人配法師的,但這不是最重要的,最重要的是,這套陣容太不容易成型了!!因為我們的評價指標里沒有考慮羈絆的成型難易度,導致它更偏好等級高的英雄,強度看上去還可以,有輸出有坦克,但有誰7人口能湊出來3個五星,2個四星呢?
- 八人口
{
"combo": [
"龍王","潘森","布隆","麗桑卓","冰鳥",
"凱南","露露","小法"
],
"trait_num": 7,
"trait_detail": {
"元素師": 3,"冰川": 2,"忍者": 1,
"護衛": 2,"法師": 3,"約德爾": 3,
"龍": 2
},
"total_trait_num": 24,
"total_trait_strength": 34.100002,
"total_strength": 50.979002334643155
}
跟上面有點像(其實我不太清楚為什么七八人口都是護衛龍),這套陣容其實是缺乏坦度的hhh還不容易成型。所以我們的評估指標還是有問題哈哈哈,看到這套陣容人傻了。
- 九人口
{
"combo": [
"潘森","亞索","劍姬","蓋倫",
"薇恩","盧錫安","日女","天使","船長"
],
"trait_num": 7,
"trait_detail": {
"劍士": 3,"護衛": 2,"槍手": 2,
"浪人": 1,"貴族": 6,"騎士": 2
},
"total_trait_num": 47,
"total_trait_strength": 54.249996,
"total_strength": 61.73055001568699
}
這套陣容我還是用過的,能不能吃雞要看裝備,亞索能2星并且吃到裝備基本能吃雞,吃不到裝備就很缺乏輸出,據說也可以把裝備給船長養船長這個點,不過沒試過。九人口貴族崛起大概是因為貴族的全范圍buff比較給力。
分析與總結
貢獻
直到云頂之弈S1結束,網上并沒有一篇用圖搜索來組建羈絆陣容的文章,這篇文章就當是彌補這一塊的空白吧,它從另一個角度去為我們推薦了陣容。核心思想就是利用英雄之間的相互羈絆來簡化暴力搜索。
缺陷
實際上我覺得在評估陣容強度的時候,模型還是過于粗糙的,具體表現如下:
- 首先忽視了坦度和輸出的配合這個維度。導致有些推薦陣容全是坦克沒有輸出,有些陣容只有輸出沒有坦克。
其次忽視了羈絆之間的克制關系。可以看到七八人口的時候,計算出來的都是以護衛龍為核心的陣容,因為護衛羈絆提供的收益范圍很大,但前提條件是你把英雄都集中放護衛周圍,但這種方法實際上是被海克斯完克的,所以在實際時間上,護衛buff的收益并沒有這里計算中的那么大。
忽略了陣過渡的平滑程度。這是這里存在的最大問題,由于我們在評價陣容的時候,給高等級英雄傾向了一些權重,導致陣容中會有數量較多的高費英雄,實際上不考慮陣容成型難易程度的推薦就是在耍流氓。比如潘森剛出來的時候,很多人推薦貴族護衛龍,實際應用上效果并不好。
-
沒有考慮英雄升星的難易程度。這個實際上跟上面是一種問題,我在搜索結果里找賭刺的陣容,直接被排名拍到了40多名,但賭刺絕對是6人口的T1陣容,這里面的原因就是刺客的卡費普遍是低的,導致在這套算法里賺不到便宜,但其實低費卡更容易到三星,而三星低費卡的強度是高于高費卡的,尤其是像三星劫這樣的英雄。
image.png 沒有考慮金鏟鏟。因為簡化問題,這里沒有考慮金鏟鏟,如果考慮金鏟鏟的話,搜索空間將會變得極其龐大,相當于為每個英雄都給配劍士、刺客、騎士、冰川、約德爾、惡魔這些羈絆。這些加上去以后,復雜度也就跟全搜索差不多了。
踩坑記錄
- Golang append函數,函數原型如下:
func append(slice []Type, elems ...Type) []Type
從原型上看是傳入一個切片,和若干需要加入的元素,返回一個切片。但實際上傳入的slice切片在運行的過程中會被修改,返回的那個切片實際上就是你傳入的slice切片。所以在使用golang里面的append函數的時候,記得把接受變量設置成你傳入的第一個slice變量,或者使用前對slice進行copy。
- 保留前K大個數實際上要用小頂堆,而不是想當然地使用大頂堆。
- 在考慮當前英雄的后續結點的時候,不能只考慮當前英雄的羈絆,而要考慮隊伍里所有英雄的羈絆,否則會漏檢。